a9dbc2661d
ci/woodpecker/tag/build Pipeline was successful
- 登录后不再强制跳转设备绑定页(MobileLayout 移除拦截) - 新建签到任务时检测未绑定设备码,弹窗提示「请先在个人中心绑定设备码」并支持跳转 - 个人中心设备码栏新增「随机」按钮:随机生成 UUID 设备码直接绑定, 前端连续两次风险确认(随机后现有手机会提示频繁切换设备无法签到) - 后端新增 POST /device/random-bind(登录态,随机 UUID v4 绑定,mobile_type=manual) - 新手引导在「新建每日签到任务」前新增「先绑定设备码」步骤
417 lines
16 KiB
Go
417 lines
16 KiB
Go
package handler
|
|
|
|
import (
|
|
"cpdaily-new/internal/database"
|
|
"cpdaily-new/internal/model"
|
|
"cpdaily-new/internal/utils"
|
|
"crypto/rand"
|
|
"encoding/hex"
|
|
"fmt"
|
|
"html/template"
|
|
"log/slog"
|
|
"time"
|
|
|
|
"github.com/gofiber/fiber/v2"
|
|
"gorm.io/gorm"
|
|
)
|
|
|
|
// 生成随机 token
|
|
func generateToken() string {
|
|
b := make([]byte, 32)
|
|
rand.Read(b)
|
|
return hex.EncodeToString(b)
|
|
}
|
|
|
|
// GenerateDeviceQr 生成设备绑定二维码
|
|
func GenerateDeviceQr(c *fiber.Ctx) error {
|
|
userID := c.Locals("userId").(uint)
|
|
|
|
// 生成 token
|
|
token := generateToken()
|
|
|
|
// 创建绑定记录,5 分钟过期
|
|
record := model.DeviceBindToken{
|
|
UserID: userID,
|
|
Token: token,
|
|
Status: "pending",
|
|
ExpiresAt: time.Now().Add(5 * time.Minute),
|
|
}
|
|
if err := database.DB.Create(&record).Error; err != nil {
|
|
return utils.FailServer(c, "生成二维码失败")
|
|
}
|
|
|
|
return utils.Success(c, fiber.Map{
|
|
"token": token,
|
|
"expiresIn": 300,
|
|
})
|
|
}
|
|
|
|
// GetDeviceStatus 查询设备绑定状态(长轮询用)
|
|
func GetDeviceStatus(c *fiber.Ctx) error {
|
|
userID := c.Locals("userId").(uint)
|
|
token := c.Query("token")
|
|
if token == "" {
|
|
return utils.FailParam(c, "缺少 token")
|
|
}
|
|
|
|
var record model.DeviceBindToken
|
|
err := database.DB.Where("token = ? AND user_id = ?", token, userID).First(&record).Error
|
|
if err != nil {
|
|
if err == gorm.ErrRecordNotFound {
|
|
return utils.Fail(c, 404, "二维码已过期或无效")
|
|
}
|
|
return utils.FailServer(c, "查询失败")
|
|
}
|
|
|
|
if record.Status == "expired" {
|
|
return utils.Fail(c, 410, "二维码已过期")
|
|
}
|
|
|
|
// 检查是否过期
|
|
if time.Now().After(record.ExpiresAt) {
|
|
database.DB.Model(&record).Update("status", "expired")
|
|
return utils.Fail(c, 410, "二维码已过期")
|
|
}
|
|
|
|
if record.Status == "bound" {
|
|
// 更新用户的 mobile_code
|
|
database.DB.Model(&model.SysUser{}).Where("id = ?", userID).Updates(map[string]interface{}{
|
|
"mobile_code": record.DeviceCode,
|
|
"mobile_type": "authorize",
|
|
})
|
|
return utils.Success(c, fiber.Map{
|
|
"status": "bound",
|
|
"deviceCode": record.DeviceCode,
|
|
})
|
|
}
|
|
|
|
return utils.Success(c, fiber.Map{
|
|
"status": "pending",
|
|
})
|
|
}
|
|
|
|
// SubmitDeviceCode 授权页提交设备码
|
|
type SubmitDeviceCodeRequest struct {
|
|
Token string `json:"token"`
|
|
DeviceCode string `json:"deviceCode"`
|
|
}
|
|
|
|
func SubmitDeviceCode(c *fiber.Ctx) error {
|
|
var req SubmitDeviceCodeRequest
|
|
if err := c.BodyParser(&req); err != nil {
|
|
return utils.FailParam(c, "请求参数错误")
|
|
}
|
|
if req.Token == "" || req.DeviceCode == "" {
|
|
return utils.FailParam(c, "参数不完整")
|
|
}
|
|
|
|
var record model.DeviceBindToken
|
|
err := database.DB.Where("token = ? AND status = ?", req.Token, "pending").First(&record).Error
|
|
if err != nil {
|
|
if err == gorm.ErrRecordNotFound {
|
|
return utils.Fail(c, 404, "二维码无效或已过期")
|
|
}
|
|
return utils.FailServer(c, "查询失败")
|
|
}
|
|
|
|
// 检查过期
|
|
if time.Now().After(record.ExpiresAt) {
|
|
database.DB.Model(&record).Update("status", "expired")
|
|
return utils.Fail(c, 410, "二维码已过期")
|
|
}
|
|
|
|
// 更新绑定状态
|
|
database.DB.Model(&record).Updates(map[string]interface{}{
|
|
"device_code": req.DeviceCode,
|
|
"status": "bound",
|
|
})
|
|
|
|
slog.Info("设备绑定成功", "userId", record.UserID, "deviceCode", req.DeviceCode)
|
|
|
|
// 也直接更新用户
|
|
database.DB.Model(&model.SysUser{}).Where("id = ?", record.UserID).Updates(map[string]interface{}{
|
|
"mobile_code": req.DeviceCode,
|
|
"mobile_type": "authorize",
|
|
})
|
|
|
|
return utils.Success(c, fiber.Map{"status": "bound"})
|
|
}
|
|
|
|
// RandomBindDevice 随机生成设备码并绑定到当前用户(个人中心「随机」按钮)
|
|
// 风险:与真实手机绑定的设备码不一致,执行签到时现有手机会提示「频繁切换设备」导致无法签到,
|
|
// 前端已做连续两次确认;此处不再重复校验,直接生成 UUID(大写,与真实设备码格式一致)绑定。
|
|
func RandomBindDevice(c *fiber.Ctx) error {
|
|
userID := c.Locals("userId").(uint)
|
|
|
|
b := make([]byte, 16)
|
|
if _, err := rand.Read(b); err != nil {
|
|
return utils.FailServer(c, "生成设备码失败")
|
|
}
|
|
// 标准 UUID v4 格式(大写),与真实设备码样式一致
|
|
b[6] = (b[6] & 0x0f) | 0x40
|
|
b[8] = (b[8] & 0x3f) | 0x80
|
|
deviceCode := fmt.Sprintf("%X-%X-%X-%X-%X", b[0:4], b[4:6], b[6:8], b[8:10], b[10:16])
|
|
|
|
if err := database.DB.Model(&model.SysUser{}).Where("id = ?", userID).Updates(map[string]interface{}{
|
|
"mobile_code": deviceCode,
|
|
"mobile_type": "manual",
|
|
}).Error; err != nil {
|
|
return utils.FailServer(c, "绑定失败")
|
|
}
|
|
|
|
slog.Info("随机绑定设备码", "userId", userID, "deviceCode", deviceCode)
|
|
return utils.Success(c, fiber.Map{"deviceCode": deviceCode})
|
|
}
|
|
|
|
// ClosePage 授权完成页(当 WebView 无法关闭时跳转至此)
|
|
func ClosePage(c *fiber.Ctx) error {
|
|
c.Type("html")
|
|
return c.SendString(`<!DOCTYPE html>
|
|
<html lang="zh-CN">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width,initial-scale=1.0,maximum-scale=1.0,user-scalable=no">
|
|
<title>授权完成</title>
|
|
<style>
|
|
*{margin:0;padding:0;box-sizing:border-box}
|
|
body{font-family:-apple-system,BlinkMacSystemFont,"SF Pro Display","PingFang SC",sans-serif;background:#F5F5F7;min-height:100vh;display:flex;align-items:center;justify-content:center}
|
|
.card{text-align:center;padding:40px 28px;max-width:380px;width:100%}
|
|
.icon{width:72px;height:72px;border-radius:20px;background:linear-gradient(135deg,#34c759,#30d158);display:flex;align-items:center;justify-content:center;margin:0 auto 20px;font-size:36px;color:#fff}
|
|
h1{font-size:22px;font-weight:700;color:#1d1d1f;margin-bottom:10px}
|
|
p{font-size:15px;color:#86868b;line-height:1.6}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div class="card">
|
|
<div class="icon">✓</div>
|
|
<h1>授权已完成</h1>
|
|
<p>设备绑定成功,请继续操作</p>
|
|
</div>
|
|
</body>
|
|
</html>`)
|
|
}
|
|
|
|
// AuthorizePage 扫码后打开的授权页面(HTML)
|
|
func AuthorizePage(c *fiber.Ctx) error {
|
|
token := c.Params("token")
|
|
if token == "" {
|
|
return c.Status(400).SendString("无效的二维码")
|
|
}
|
|
|
|
// 验证 token 有效性
|
|
var record model.DeviceBindToken
|
|
err := database.DB.Where("token = ?", token).First(&record).Error
|
|
if err != nil || record.Status != "pending" || time.Now().After(record.ExpiresAt) {
|
|
if err == nil && time.Now().After(record.ExpiresAt) {
|
|
database.DB.Model(&record).Update("status", "expired")
|
|
}
|
|
return c.Status(400).Type("html").SendString(`
|
|
<!DOCTYPE html><html lang="zh-CN"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1.0,maximum-scale=1.0,user-scalable=no">
|
|
<title>设备授权</title><style>*{margin:0;padding:0;box-sizing:border-box}
|
|
body{font-family:-apple-system,BlinkMacSystemFont,"SF Pro Display","PingFang SC",sans-serif;background:#F5F5F7;min-height:100vh;display:flex;align-items:center;justify-content:center}
|
|
.card{text-align:center;padding:40px 28px;max-width:380px;width:100%}
|
|
.icon{width:72px;height:72px;border-radius:20px;background:linear-gradient(135deg,#ff3b30,#ff6961);display:flex;align-items:center;justify-content:center;margin:0 auto 20px;font-size:36px;color:#fff}
|
|
h1{font-size:22px;font-weight:700;color:#1d1d1f;margin-bottom:10px}
|
|
p{font-size:15px;color:#86868b;line-height:1.6}
|
|
</style></head><body>
|
|
<div class="card"><div class="icon">✕</div><h1>二维码无效</h1><p>二维码无效或已过期,请重新获取</p></div>
|
|
</body></html>`)
|
|
}
|
|
|
|
// 构建提交 URL
|
|
submitURL := fmt.Sprintf("/api/device/submit")
|
|
|
|
tmpl := template.Must(template.New("authorize").Parse(`<!DOCTYPE html>
|
|
<html lang="zh-CN">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width,initial-scale=1.0,maximum-scale=1.0,user-scalable=no">
|
|
<title>设备授权</title>
|
|
<style>
|
|
*{margin:0;padding:0;box-sizing:border-box}
|
|
body{font-family:-apple-system,BlinkMacSystemFont,"SF Pro Display","PingFang SC",sans-serif;background:#F5F5F7;min-height:100vh;display:flex;align-items:center;justify-content:center;overflow-x:hidden}
|
|
.aurora-orb{position:fixed;border-radius:50%;filter:blur(100px);opacity:0.6;pointer-events:none;z-index:0;animation:float 20s ease-in-out infinite}
|
|
.aurora-orb-1{width:60vw;height:60vw;background:radial-gradient(circle,rgba(162,210,255,0.4),transparent);top:-20%;left:-10%}
|
|
.aurora-orb-2{width:55vw;height:55vw;background:radial-gradient(circle,rgba(200,180,255,0.3),transparent);bottom:-20%;right:-10%;animation-delay:-10s}
|
|
@keyframes float{0%,100%{transform:translate(0,0) scale(1)}50%{transform:translate(30px,-30px) scale(1.1)}}
|
|
.container{position:relative;z-index:1;width:100%;max-width:380px;padding:20px}
|
|
.card{background:rgba(255,255,255,0.82);backdrop-filter:blur(20px) saturate(180%);-webkit-backdrop-filter:blur(20px) saturate(180%);border:1px solid rgba(255,255,255,0.6);border-radius:24px;box-shadow:0 8px 32px rgba(0,0,0,0.08);padding:36px 28px 32px;text-align:center}
|
|
.icon-wrap{width:72px;height:72px;border-radius:20px;display:flex;align-items:center;justify-content:center;margin:0 auto 20px;font-size:36px}
|
|
.icon-loading{background:linear-gradient(135deg,#0071e3,#40a9ff)}
|
|
.icon-success{background:linear-gradient(135deg,#34c759,#30d158)}
|
|
.icon-error{background:linear-gradient(135deg,#ff3b30,#ff6961)}
|
|
.icon-warn{background:linear-gradient(135deg,#ff9f0a,#ffcc02)}
|
|
h1{font-size:22px;font-weight:700;letter-spacing:-0.02em;color:#1d1d1f;margin-bottom:10px}
|
|
.desc{font-size:15px;color:#86868b;line-height:1.6;margin-bottom:16px}
|
|
.spinner{width:36px;height:36px;border:3px solid rgba(0,113,227,0.15);border-top-color:#0071e3;border-radius:50%;animation:spin 0.8s linear infinite;margin:20px auto 0}
|
|
@keyframes spin{to{transform:rotate(360deg)}}
|
|
.code-box{background:#F2F2F7;border-radius:12px;padding:14px 16px;font-family:"SF Mono","Fira Code",monospace;font-size:13px;color:#1d1d1f;word-break:break-all;line-height:1.7;border:1px solid rgba(0,0,0,0.06);user-select:all;text-align:left;margin:12px 0}
|
|
.btn-primary{background:#0071e3;color:white;padding:13px 24px;border-radius:980px;border:none;font-size:15px;font-weight:500;cursor:pointer;transition:all 0.3s;width:100%}
|
|
.btn-primary:hover{filter:brightness(1.08)}
|
|
.btn-primary:disabled{opacity:0.5;cursor:not-allowed}
|
|
.steps{margin-top:16px;display:flex;flex-direction:column;gap:10px;text-align:left}
|
|
.step{display:flex;align-items:flex-start;gap:12px;background:rgba(0,0,0,0.03);border-radius:12px;padding:12px 14px;border:1px solid rgba(0,0,0,0.05)}
|
|
.step-num{width:22px;height:22px;border-radius:50%;background:#0071e3;color:white;font-size:12px;font-weight:700;display:flex;align-items:center;justify-content:center;flex-shrink:0;margin-top:1px}
|
|
.step-text{font-size:14px;color:#1d1d1f;line-height:1.5}
|
|
.step-text span{color:#86868b;font-size:13px;display:block;margin-top:2px}
|
|
.hidden{display:none}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div class="aurora-orb aurora-orb-1"></div>
|
|
<div class="aurora-orb aurora-orb-2"></div>
|
|
<div class="container">
|
|
<div class="card" id="app">
|
|
<!-- 加载中 -->
|
|
<div id="state-loading">
|
|
<div class="icon-wrap icon-loading" style="color:#fff">📱</div>
|
|
<h1>正在验证...</h1>
|
|
<p class="desc">请稍候</p>
|
|
<div class="spinner"></div>
|
|
</div>
|
|
<!-- 非今日校园 -->
|
|
<div id="state-notcpdaily" class="hidden">
|
|
<div class="icon-wrap icon-warn" style="color:#fff">📵</div>
|
|
<h1>请用今日校园扫码</h1>
|
|
<p class="desc">此页面需要在<strong>今日校园 App</strong>内打开才能获取设备码</p>
|
|
<div class="steps">
|
|
<div class="step"><div class="step-num">1</div><div class="step-text">打开今日校园 App<span>在手机上启动今日校园</span></div></div>
|
|
<div class="step"><div class="step-num">2</div><div class="step-text">点击右上角扫一扫<span>使用 App 内置扫码功能</span></div></div>
|
|
<div class="step"><div class="step-num">3</div><div class="step-text">扫描电脑上的二维码<span>扫码后设备码将自动发送</span></div></div>
|
|
</div>
|
|
</div>
|
|
<!-- 获取中 -->
|
|
<div id="state-fetching" class="hidden">
|
|
<div class="icon-wrap icon-loading" style="color:#fff">📱</div>
|
|
<h1>正在获取设备码</h1>
|
|
<p class="desc">请稍候,正在读取设备信息...</p>
|
|
<div class="spinner"></div>
|
|
</div>
|
|
<!-- 成功 -->
|
|
<div id="state-success" class="hidden">
|
|
<div class="icon-wrap icon-success" style="color:#fff">✓</div>
|
|
<h1>授权成功</h1>
|
|
<p class="desc">正在跳转...</p>
|
|
</div>
|
|
<!-- 提交中 -->
|
|
<div id="state-submitting" class="hidden">
|
|
<div class="icon-wrap icon-loading" style="color:#fff">📤</div>
|
|
<h1>正在提交</h1>
|
|
<p class="desc">设备码正在提交至服务器...</p>
|
|
<div class="spinner"></div>
|
|
</div>
|
|
<!-- 失败 -->
|
|
<div id="state-error" class="hidden">
|
|
<div class="icon-wrap icon-error" style="color:#fff">✕</div>
|
|
<h1>获取失败</h1>
|
|
<p class="desc" id="error-msg" style="color:#d70015"></p>
|
|
<button class="btn-primary" onclick="location.reload()" style="margin-top:16px">重试</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<script>
|
|
const TOKEN = '{{.Token}}';
|
|
const SUBMIT_URL = '{{.SubmitURL}}';
|
|
|
|
function isCpdaily() {
|
|
var ua = navigator.userAgent.toLowerCase();
|
|
return ua.indexOf('cpdaily') !== -1 || ua.indexOf('wisedu') !== -1 || ua.indexOf('campushoy') !== -1;
|
|
}
|
|
|
|
function showState(id) {
|
|
['state-loading','state-notcpdaily','state-fetching','state-success','state-submitting','state-error'].forEach(function(s) {
|
|
document.getElementById(s).classList.add('hidden');
|
|
});
|
|
document.getElementById(id).classList.remove('hidden');
|
|
}
|
|
|
|
function loadCordova() {
|
|
return new Promise(function(resolve, reject) {
|
|
var isAndroid = /Android|Linux/.test(navigator.userAgent);
|
|
var src = 'https://static.campushoy.com/innerjs/' + (isAndroid ? 'cordova_android.js' : 'cordova_ios.js');
|
|
var s = document.createElement('script');
|
|
s.src = src;
|
|
s.onload = resolve;
|
|
s.onerror = function() { reject(new Error('Cordova 脚本加载失败')); };
|
|
document.head.appendChild(s);
|
|
if (!window.mamp) window.mamp = {};
|
|
window.mamp.initEnv = function(cb) { document.addEventListener('deviceready', cb, false); };
|
|
});
|
|
}
|
|
|
|
function waitCordova() {
|
|
return new Promise(function(resolve, reject) {
|
|
var n = 0;
|
|
var t = setInterval(function() {
|
|
if (window.cordova && window.cordova.exec) {
|
|
clearInterval(t);
|
|
resolve();
|
|
} else if (++n > 600) {
|
|
clearInterval(t);
|
|
reject(new Error('Cordova 加载超时'));
|
|
}
|
|
}, 100);
|
|
});
|
|
}
|
|
|
|
function getDeviceUuid() {
|
|
return new Promise(function(resolve, reject) {
|
|
window.cordova.exec(
|
|
function(res) { resolve(res.uuid); },
|
|
function(err) { reject(new Error(err ? (err.message || String(err)) : '未知错误')); },
|
|
'MAMPDevice', 'getDeviceInfo', []
|
|
);
|
|
});
|
|
}
|
|
|
|
async function submitDeviceCode(deviceCode) {
|
|
var resp = await fetch(SUBMIT_URL, {
|
|
method: 'POST',
|
|
headers: {'Content-Type': 'application/json'},
|
|
body: JSON.stringify({token: TOKEN, deviceCode: deviceCode})
|
|
});
|
|
var data = await resp.json();
|
|
if (data.code !== 0) throw new Error(data.message || '提交失败');
|
|
}
|
|
|
|
(async function() {
|
|
// 检查是否在今日校园 App 内
|
|
if (!isCpdaily()) {
|
|
showState('state-notcpdaily');
|
|
return;
|
|
}
|
|
|
|
showState('state-fetching');
|
|
try {
|
|
await loadCordova();
|
|
await waitCordova();
|
|
var deviceCode = await getDeviceUuid();
|
|
|
|
showState('state-submitting');
|
|
await submitDeviceCode(deviceCode);
|
|
|
|
showState('state-success');
|
|
// 立即关闭页面,同时跳转到完成页兜底
|
|
try { window.close(); } catch(e) {}
|
|
try { cordova.exec(null, null, 'MAMPWebview', 'close', []); } catch(e) {}
|
|
try { window.webkit.messageHandlers.close.postMessage(null); } catch(e) {}
|
|
try { window.JSInterface.close(); } catch(e) {}
|
|
try { window.history.back(); } catch(e) {}
|
|
try { window.history.go(-1); } catch(e) {}
|
|
window.location.replace('/api/device/close');
|
|
} catch (e) {
|
|
document.getElementById('error-msg').textContent = e.message || '未知错误';
|
|
showState('state-error');
|
|
}
|
|
})();
|
|
</script>
|
|
</body>
|
|
</html>`))
|
|
|
|
c.Type("html")
|
|
return tmpl.Execute(c.Response().BodyWriter(), map[string]string{
|
|
"Token": token,
|
|
"SubmitURL": submitURL,
|
|
})
|
|
}
|