diff --git a/backed/cmd/server/main.go b/backed/cmd/server/main.go index a818447..92a4b9d 100644 --- a/backed/cmd/server/main.go +++ b/backed/cmd/server/main.go @@ -96,6 +96,7 @@ func main() { device.Post("/generate", middleware.AuthRequired(), handler.GenerateDeviceQr) device.Post("/submit", handler.SubmitDeviceCode) device.Get("/status", middleware.AuthRequired(), handler.GetDeviceStatus) + device.Post("/random-bind", middleware.AuthRequired(), handler.RandomBindDevice) // 管理后台 API admin := api.Group("/admin") diff --git a/backed/internal/handler/device.go b/backed/internal/handler/device.go index 8c60db0..17ba7e1 100644 --- a/backed/internal/handler/device.go +++ b/backed/internal/handler/device.go @@ -137,6 +137,32 @@ func SubmitDeviceCode(c *fiber.Ctx) error { 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") diff --git a/frondend/src/api/auth.ts b/frondend/src/api/auth.ts index 8dc1181..314ff65 100644 --- a/frondend/src/api/auth.ts +++ b/frondend/src/api/auth.ts @@ -121,6 +121,11 @@ export function getDeviceStatus(token: string) { }) } +/** 随机生成设备码并绑定到当前账号(个人中心「随机」按钮,需连续两次风险确认) */ +export function randomBindDevice() { + return api.post<{ data: { deviceCode: string } }>('/device/random-bind') +} + /** 新 CDK 兑换(需验证码,由管理后台生成的 CDK;次数 CDK 加次数 / VIP CDK 延长 VIP) */ export function redeemCDK(code: string, captchaId: string, captchaCode: string) { return api.post<{ diff --git a/frondend/src/components/GuideOverlay.vue b/frondend/src/components/GuideOverlay.vue index 872c22a..53ecc66 100644 --- a/frondend/src/components/GuideOverlay.vue +++ b/frondend/src/components/GuideOverlay.vue @@ -57,6 +57,13 @@ const steps: GuideStep[] = [ descByKind: () => '新用户注册即送奖励积分,邀请好友也能赚积分。\n积分满 1 后,点这里「兑换」按钮即可换成签到次数,1 积分 = 1 次签到,可重复兑换。', }, + { + tab: 'profile', + title: '先绑定设备码', + targets: ['[data-guide="profile-device-code"]'], + descByKind: () => + '签到任务依赖设备码执行。到这一栏点「重新绑定」用手机扫码授权,或点「随机」直接生成设备码。\n注意:随机设备码与真实手机不一致,执行签到时现有手机会提示「频繁切换设备」无法签到,建议优先扫码授权。', + }, { tab: 'home', title: '新建每日签到任务', diff --git a/frondend/src/layouts/MobileLayout.vue b/frondend/src/layouts/MobileLayout.vue index 427eab1..9e1ab98 100644 --- a/frondend/src/layouts/MobileLayout.vue +++ b/frondend/src/layouts/MobileLayout.vue @@ -212,10 +212,7 @@ onMounted(async () => { const data = res.data.data userInfo.value = data - if (!data.mobileCode) { - router.replace('/bind-device') - return - } + // 未绑定设备码不再强制跳转绑定页(新建任务时会在入口处弹窗引导) // 新手引导:数据库判定该用户未看过当前版本引导则展示(跨设备一致); // 本次若来自 ?guide=1 强播则优先展示(guideForced 已消费,下次加载不再生效) if (guideForced.value || needGuide(data)) { diff --git a/frondend/src/views/home/HomeView.vue b/frondend/src/views/home/HomeView.vue index 4610771..f156649 100644 --- a/frondend/src/views/home/HomeView.vue +++ b/frondend/src/views/home/HomeView.vue @@ -88,9 +88,19 @@ async function removeTaskLog(id: number) { } } +// ======== 设备码绑定状态(新建任务入口拦截用) ======== +const deviceCode = ref('') +const showBindDeviceDialog = ref(false) + // ======== 初始化 ======== onMounted(async () => { - // MobileLayout 已加载用户信息并处理绑设备检查 + // 拉取设备码绑定状态(新建任务入口检测未绑定时弹窗引导) + try { + const res = await authApi.getUserInfo() + deviceCode.value = res.data.data?.mobileCode || '' + } catch { + // 拉取失败不阻塞页面 + } // 这里直接加载任务数据和日志 await loadTasks() await loadTaskLogs() @@ -142,6 +152,11 @@ async function loadTasks() { // ======== Step 0: 选择任务标题 ======== async function startCreateTask() { + // 未绑定设备码:弹窗引导去个人中心绑定,不进入创建流程 + if (!deviceCode.value) { + showBindDeviceDialog.value = true + return + } showStep.value = true step.value = 0 stepTitle.value = '选择签到任务' @@ -1295,6 +1310,38 @@ function getGPSLocation(source: string) { + + +
+
+
+
+
+ +
+

未绑定设备码

+

+ 请先在个人中心绑定设备码,绑定后才能创建和执行签到任务 +

+
+ + +
+
+
+
+ +
import { ref, computed, onMounted } from 'vue' import { useRouter } from 'vue-router' -import { User, IdCard, LogOut, RefreshCw, ClipboardCheck, Gift, Share2, X, MessageSquare } from 'lucide-vue-next' +import { User, IdCard, LogOut, RefreshCw, ClipboardCheck, Gift, Share2, X, MessageSquare, Dices, TriangleAlert } from 'lucide-vue-next' import { Button } from '@/components/ui/button' import { Input } from '@/components/ui/input' import { useMessage } from '@/composables/useMessage' @@ -135,6 +135,40 @@ function goRebind() { router.push('/bind-device') } +// ---- 随机绑定设备码(连续两次风险确认) ---- +// 0=关闭 1=第一次确认 2=第二次确认 +const randBindStep = ref(0) +const randBinding = ref(false) + +function openRandomBind() { + randBindStep.value = 1 +} + +function closeRandomBind() { + randBindStep.value = 0 +} + +async function confirmRandomBind() { + if (randBindStep.value === 1) { + // 第一次确认 → 进入第二次确认 + randBindStep.value = 2 + return + } + randBinding.value = true + try { + const res = await authApi.randomBindDevice() + if (userInfo.value) { + userInfo.value.mobileCode = res.data.data.deviceCode + } + randBindStep.value = 0 + message.success('随机设备码绑定成功') + } catch (e: any) { + message.error(e?.response?.data?.message || e?.message || '绑定失败') + } finally { + randBinding.value = false + } +} + // ---- 兑换弹窗 ---- function openRedeemModal(kind: 'count' | 'vip' = 'count') { redeemKind.value = kind @@ -419,7 +453,7 @@ async function handleRedeemInvite() {
-
+
@@ -433,13 +467,22 @@ async function handleRedeemInvite() {

- +
+ + +
@@ -531,6 +574,66 @@ async function handleRedeemInvite() {
+ + + +
+
+ + + + + +
+
+