Files
becomer a9dbc2661d
ci/woodpecker/tag/build Pipeline was successful
feat: 设备码绑定改为非强制 + 随机绑定入口
- 登录后不再强制跳转设备绑定页(MobileLayout 移除拦截)
- 新建签到任务时检测未绑定设备码,弹窗提示「请先在个人中心绑定设备码」并支持跳转
- 个人中心设备码栏新增「随机」按钮:随机生成 UUID 设备码直接绑定,
  前端连续两次风险确认(随机后现有手机会提示频繁切换设备无法签到)
- 后端新增 POST /device/random-bind(登录态,随机 UUID v4 绑定,mobile_type=manual)
- 新手引导在「新建每日签到任务」前新增「先绑定设备码」步骤
2026-09-08 20:15:24 +08:00

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,
})
}