Compare commits

...

71 Commits

Author SHA1 Message Date
becomer 7639912d7c v1.2.45:请假申请功能落地(后端链路 + 前端请假弹窗),前端 tab 改「请/销假」、请假/销假按钮并排平分——① 功能:新增请假接口组(GET /leave/types 类型列表含权威开关、POST /leave/apply-approvers 审批人、POST /leave/apply SSE 同步执行),cpdaily/leave_apply.go 封装 checkOnceLeave + queryLeaveTypes + queryLeaveTypeCfg + obtainApproveFlow + apply 提交全链路;前端 LeaveView 新增请假申请弹窗(类型选择/起止时间/离校开关与三字段/请假原因含字数校验/一键定位/图片附件/审批人选择),日志区同时展示请假与销假记录并以徽标区分;② 关键差异(对比老版本 + 只读实测三校样本):queryLeaveTypes 返回的 needApproval/enableAttach 等开关全为 null,权威开关只在 queryLeaveTypeCfg(老版本逐类型再查一次,本实现按同样方式合并后返回,单类型 cfg 失败不阻塞列表);/leave/apply 为明文 JSON body + 仅 Cpdaily-Extension 头(与销假的 bodyString+sign 加密链路不同,实测确认);mustOut=1 时强制离校且开关不可改;审批人接口时间必须精确到秒(yyyy-MM-dd HH:mm:ss,datetime-local 值自动补秒);③ 服务端复核:execute 时重新查 cfg 并校验 disableTime/原因字数下限/needApproval 必选审批人,再做 checkOnceLeave 资格检查,全部失败均落日志;日志表新增 action 字段(apply/report)延续同表记录;限流扣费与销假同规则(2m×3/1h×6/24h×10,VIP 免扣非 VIP 成功扣 sign_count);④ 全程只读实测(checkOnceLeave/queryLeaveTypes/queryLeaveTypeCfg,未调用 /leave/apply 提交),新增 4 个单测覆盖类型合并/资格检查/表单构造(离校与不离校两分支)/提交,go test ./...、go vet、前端 build、vue-tsc 全过
ci/woodpecker/tag/build Pipeline was successful
2026-09-14 12:13:47 +08:00
becomer 2f20eebccd v1.2.44:销假功能完整落地(后端 SSE 同步执行 + 前端销假页重写),并按真实接口实测修正位置/审批人语义——① 功能:新增 /leave 路由组(pending-list 待销假列表 / approvers 审批人 / execute SSE 执行 / logs 日志 CRUD),cpdaily/leave.go 封装 listOther+detailOther+obtainApproveFlow+report 全链路,cp_leave_logs 落库成败日志,用户级限流(2m×3/1h×6/24h×10),VIP 免扣非 VIP 成功扣 sign_count,图片经 OBS 浏览器直传(taskType=leave);前端 api/leave.ts + LeaveView.vue 重写(销假按钮/日志区/待销假弹窗/销假表单弹窗);② 规定销假位置修正:老版本取 leaveExtends[0](续假位置,实测近乎全空)或注释的 detail.applyLocation(请假提交时定位),均非学校规定位置;实测真实返回 reportPlaces[](address/经纬度/radius 米/设置人),已改为解析并前端列表选择+预填经纬度,用户可在规定位置间切换;③ 多校差异适配:实测 ynu/whit/lzufe 三校样本确认 leaveType.reportApplyFlow 控制销假是否需选审批人(ynu=1,whit/lzufe=0)、enableAttach 控制图片上传(ynu=1,其余=0),前端按开关自适应渲染审批人选择器与图片组件,提交时按需携带 approverWid;④ 解析 bug 修复:leaveType.code 真实为数字(3/12/2),原声明 string 导致 detailOther 全部解析失败(即"规定位置拿不到"的根因),改为 interface{} 承接并同时产出文本/数字双形式;obtainApproveFlow 要求时间精确到秒(yyyy-MM-dd HH:mm:ss),已自动补秒;⑤ 全程只读实测(listOther/detailOther/obtainApproveFlow,未调用任何 /report 提交),新增/重写 4 个单测覆盖开关解析/规定位置/审批人/提交表单,go test ./...、go vet、前端 build、vue-tsc 全过
ci/woodpecker/tag/build Pipeline was successful
2026-09-14 11:44:37 +08:00
becomer 3aee45be80 v1.2.43:滑块验证码改为「激活窗口等待 + 打码值窄序列」策略,推翻 ±12 扫描——① 决定性发现:服务端 open 后存在约 1.3~1.7s 激活延迟,延迟内提交任何值(含真值)都返回 errorCode=0;激活后接受以真值为中心的 ±8 宽区间(实测上缘 +8、下缘 -6~-8,相对打码值),打码平台返回值即为区间中心(误差 0±1),此前观测到的「打码偏差 -8~+9」实为快速扫描跨过激活窗口的落点伪影,并非识别误差;② 策略改造:slider.go 单轮流程改为 取图 → 打码 → 等待至 open+2.5s(抖动裕度)→ 按 v、v+1、v-1 窄序列提交(候选间隔 350ms,首候选通常即命中),全部失败仍换图重试(最多 5 轮),提交次数上界从 125 次降至 15 次;③ 新增激活等待回归测试(mock 复刻 300ms 拒绝窗口,断言等待后首个候选命中且不重发),重写候选命中/枚举耗尽测试(3×5=15 次 verify 上界),新增 v-1 命中用例;④ 真实账号端到端实测 3/3 一次通过(gapX=75/96/122 均 offset=0 直接命中,全程约 4s,302 ST 票据 → 7 cookies),go test ./...、go vet 全过
ci/woodpecker/tag/build Pipeline was successful
2026-09-14 00:29:25 +08:00
becomer 46c8a554ff v1.2.42:华侨大学(hqdx)CAS 滑块验证码登录完整落地——① sign 协议:POST /authserver/common/verifySliderCaptcha.htl 提交 sign=AES-128-CBC-PKCS7(key=openSliderCaptcha 小图 base64 解码末 16 字节 safeSecure、iv=16 位随机、明文=64 位随机前缀+JSON{canvasLength:280,moveLength,tracks}),响应 errorCode=1 通过 / 0 失败,moveLength 为缺口真实值精确整数(±1 无容差),失败不消费图片、仅成功才作废;② 识别与连发策略:打码平台 /slider 识别打码值 v 后在同一张图上按 v、v±1…v±12(±12 窗口 25 候选)顺序连发,任一命中即止,全部失败换图重试(最多 5 轮 + 递增等待);实测打码偏差分布 -8~+9(另偶发识别失败图靠换图覆盖),故窗口取 ±12 留裕度;③ 流程接入:login_cas.go 按配置 sliderIds 分流滑块学校,casLogin 的表单选择器 #pwdLoginDiv 适配 hqu 登录页三表单布局(账密字段),滑块通过后 captcha 留空提交登录;hqdx 特判强制走 CAS(IAP 账密接口 FAIL_UPNOTMATCH 不兼容);④ 新增 slider.go + slider_test.go:sign 加解密/载荷/轨迹校验、候选命中、换图重试、5 图全失败(125 次 verify 上界)mock 全覆盖。实测:华侨大学真实账号端到端登录成功(3.11s,首图 +9 偏差命中 → 302 ST 票据 → CASTGC/JSESSIONID/MOD_AUTH_CAS 等 7 cookies),go test ./...、go vet 全过
ci/woodpecker/tag/build Pipeline was successful
2026-09-13 23:46:28 +08:00
becomer 1507b386bc v1.2.41:许昌学院(xcu)单独适配 CAS 登录 + MFA 验证方式解析兼容 xcuTheme 弹窗布局——① 许昌学院:平台接口返回 joinType=CLOUD(IsIap=true),但其 IAP 账密接口 /iap/doLogin 与通用密码加密格式不兼容(实测 RSA hex/base64 多种编码均返回 FAIL_UPNOTMATCH,无法走通);学校真实链路为校园网关 302 → 学校 CAS(authserver.xcu.edu.cn)账密 + 多因子认证 → 回调 /iap/loginSuccess?sessionToken=... 建立网关会话,故在 CpdailyLogin 中对 xcu 特判强制走 CAS,登录入口由 ampUrl(casp.xcu.edu.cn,service 与 cookie 域不匹配)替换为 wecloud 网关 /portal/login(service 指向 xcu.campusphere.net,cookie 正确落在 wec-* 任务接口所在域),新增 login_xcu.go;② MFA 解析:许昌学院定制主题 xcuTheme 将验证方式渲染在「选择认证方式」弹窗中(div.other_type_button[id],id 即方式编号),原选择器均不匹配导致解析出 0 个方式(只能靠兜底),mfaMethodSelectors 新增 .other_type_button 兼容该布局;新增单测 5 个(xcu 入口计算 2 个 + MFA 弹窗布局 3 个),go test ./... 全过;已用真实账号端到端验证:CpdailyLogin → 网关入口 → CAS 账密成功 → MFA 触发并解析出 [{3 短信验证码 default:true}](修复前为 0 个)
ci/woodpecker/tag/build Pipeline was successful
2026-09-13 21:50:54 +08:00
becomer 1a15b359f8 v1.2.40:扫码签到(signMode=2)提交格式修复 + MFA 验证方式「全列表选择」改造——① 扫码签到:平台对二维码任务校验 qrUuid,旧格式(带 signInstanceWid 无 qrUuid)响应虽为 SUCCESS 但不落签到记录(假成功),现提交体改为 qrUuid 替代 signInstanceWid(不并存、不含经纬度、跳过定位校验),新增 qrcode.go 用 gozxing 解码二维码图片提取 uuid(URL uuid= 后 32 位串),空白 qrUuid 必失败;成功与否以提交后复核 isSigned 为唯一判据,已用真实任务(云大 2026.09.13 年级大会签到)闭环验证;② MFA:登录触发后不再自动发码,改为解析 reAuthLoginView 页面 Tab 渲染的全部支持验证方式(.mobile-reauth-tab-item/.reauth-tab-item/.reauth-tab-more-item/.changeReAuthTypes 多布局兼容、跳过注释块、过滤非动态码方式、空列表用当前方式兜底)随 4281 返回(authMethods/defaultType),用户选择后调新增接口 POST /api/auth/login/mfa/send 发送验证码(所选≠当前先调 changeReAuthType.do 切换并同步会话,响应含 mobile/codeTime),新增业务码 4284(发送失败)与 4282(验证码错误可重试)/4283(会话过期需重登)区分;前端登录页 MFA 弹窗改为「选择验证方式→输入验证码」两阶段,选择态/校验态失败判定与登录失败弹窗解耦,输入态支持「更换验证方式」回退;新增后端单测 10 个(MFA 方式解析含真实样本、规范化、发码全流程 mock)+ 扫码表单解析测试,go test ./...、go vet、前端 build、vue-tsc 全部通过
ci/woodpecker/tag/build Pipeline was successful
2026-09-13 21:03:51 +08:00
becomer f23ab1a261 v1.2.39 补充:MFA access token 服务端校验宽限——存量已签发 token 无需重新登录即可平滑过渡到 2 小时——已签发的 JWT 因签名保护无法原地修改,且 MFA 通道 access/refresh 同 TTL、刷新窗口实际不存在(refresh 与 access 同时过期,前端又仅在 401 时被动刷新),存量用户(如杨蕊宁)原本只能重新登录换发新 token;现 ValidateToken 对已过期的 MFA access token(issuer=cpdaily-new 且携带 iat)按「签发时间 + 当前 MfaAccessExpire」再放行一次,配置调大后存量 1h token 自动延用至 2 小时窗口;refresh 不享受宽限(严格按自身 exp 校验),避免出现刷新窗口导致会话被滑动静默续期、违背 MFA 短会话设计。新增 jwt_test.go 覆盖 7 个场景:宽限内放行(存量 1h token 过期 30 分钟)、超 2h 拒绝、refresh 拒绝、非 MFA 拒绝、正常有效期放行、错误签名拒绝、缺 iat 拒绝。全部测试与 go vet 通过
ci/woodpecker/tag/build Pipeline was successful
2026-09-13 18:28:16 +08:00
becomer 84a602ae3b v1.2.39:MFA 通道短期 JWT 有效期从 1 小时调整为 2 小时——MFA 学校(如云南大学)第三方会话无法自动续期,token 需与第三方 Cookie 有效期对齐;实测 whit 学校 MOD_AUTH_CAS cookie 签发后超过 1.5 小时仍有效(校验接口返回 errCode=0),说明第三方会话寿命大于 1 小时,故将 MFA 短期 token 放宽到 2 小时。新增独立配置项 mfaAccessExpire(默认 7200 秒,未配置时 Load 自动采用,服务器部署新版本无需修改配置文件即生效),不再复用普通登录的 accessExpire(仍为 1 小时),MFA 专属调整不影响常规登录链路;登录签发(finalizeLogin)与刷新重签(RefreshTokens 的 MFA 分支)同步改用新配置。注意:MFA 通道 access/refresh 同 TTL(现 2 小时),已签发的旧 token 无法原地延长,需重新登录后生效 2026-09-13 18:22:49 +08:00
becomer 35a2f1e432 v1.2.38:修复云南大学 CAS 登录 MFA 流程被 302 踢回登录页的两个根因——① simpleCookieJar 此前按 cookie 名处理删除指令,而云大 CAS 先发 CASTGC=TGT-...(Path=/authserver)再发删除指令 CASTGC=; Max-Age=0; path=/authserver/(尾部多一斜杠,Path 不匹配),浏览器与标准库 jar 按 RFC 6265 精确匹配作用域不会删除 TGT,按名删除把有效 TGT 一并误删,导致 reAuthLoginView.do 因检查不到 SSO 会话而 302 踢回登录页、MFA 页面无法渲染;现改为跳过删除指令与空值 cookie(不入库也不删除,同时避免发送空值 CASTGC 的历史问题),同名残留会在下次登录时被 CAS 重新 Set-Cookie 覆盖,离线 diff 验证与标准库 jar 对 MFA URL 的输出一致;② resty 默认重定向策略在跨 host 请求时把 User-Agent 替换为 go-resty/2.x(redirect.go checkHostAndAddHeaders),而 wisedu 定制 CAS 按请求 UA 分流渲染移动端/PC 模板,campusphere 网关跳转 ids.ynu.edu.cn 必然跨 host,UA 被替换后 CAS 渲染 PC 模板(25824 字节)导致 MFA 流程校验失败,现自定义 RedirectPolicy 在跨 host 重定向时保留原 UA,移动端模板(14283 字节)正常渲染。云大真实账号端到端验证通过:登录页移动模板 → POST 302 MFA → reAuthLoginView.do 200(29730 字节)→ reAuthType=3(短信通道)解析成功 → dynamicCodeByReauth.do 发码返回 success(短信实发至用户手机)
ci/woodpecker/tag/build Pipeline was successful
2026-09-13 17:59:19 +08:00
becomer 98689f68f4 v1.2.37:修复 MFA 通道登录丢失邀请码导致邀请奖励不生效的问题——v1.2.35 抽取 finalizeLogin 公共函数时,MFAVerify 调用传了空邀请码且 Redis 暂存的 mfaSession 未保存 InviteCode,导致经 MFA 验证码流程注册的新用户不建立邀请关系、不发放邀请奖励;现 startMFALogin 暂存会话时一并保存登录请求中的 inviteCode,验证成功注册时正常传入 findOrCreateUser,与普通登录通道行为完全一致
ci/woodpecker/tag/build Pipeline was successful
2026-09-12 14:56:04 +08:00
becomer 3632cb642e v1.2.36:MFA 触发响应改为 HTTP 200 + 专用业务码,修复前端误弹「登录失败」弹窗的问题——登录触发多因子认证时不再返回 401(会被前端 catch 走登录失败 Dialog 路径),改为 code 4281(需MFA,data 含 mfaToken/expiresIn);验证码提交失败区分 code 4282(验证码错误可重试,弹窗保留清空重输)与 code 4283(验证会话过期/失效,关闭弹窗引导重新登录),cpdaily 包新增 ErrMFASessionExpired 哨兵错误供 errors.Is 判定;前端登录成功分支按 code 4281 弹出独立的多因子认证验证码弹窗,MFA 提交按 0/4282/4283 精确分支处理,与普通登录错误提示彻底解耦
ci/woodpecker/tag/build Pipeline was successful
2026-09-12 14:42:50 +08:00
becomer 47401e7388 v1.2.35:多因子认证(MFA)登录全流程支持——CAS 账密登录 302 到 reAuthCheck/reAuthLoginView.do?isMultifactor=true 时不再误判为登录成功,自动解析 MFA 页面参数(reAuthType/service/reAuthUserId)并调用 dynamicCode/getDynamicCodeByReauth.do 自动发送验证码;CAS 会话按域暂存 Redis(mfa_session:<token>,10 分钟 TTL,密码 AES 加密),新增 POST /api/auth/login/mfa 接口提交验证码(reAuthSubmit.do)→ CAS 票据交换 → activateSession → 与普通登录一致的注册/更新流程;MFA 通道登录签发短期 JWT(access/refresh 均与第三方 Cookie 有效期对齐,默认 1 小时,Claims 携带 mfa 标记且刷新不延长会话),静默重登遇 MFA 学校返回明确提示需重新登录;前端登录页处理 needMFA 响应弹出多因子认证 Dialog(验证码输入/10 分钟倒计时/输错可重试),验证成功后进入正常登录态。已用待适配学校(合肥幼儿师范高等专科学校)真实账号端到端验证:发码→收码→提交→reAuth_success→用户注册成功→第三方接口可正常拉取用户信息
ci/woodpecker/tag/build Pipeline was successful
2026-09-12 14:32:13 +08:00
becomer c5fe95b3ec v1.2.34:后台用户管理表格四列合并为两列——「学工号」「真实姓名」合并为「学工号/姓名」列(主行学工号 font-mono、副行姓名小灰字),「学校」「设备码」合并为「学校/设备码」列(主行学校名、副行脱敏设备码 mono 小字保留复制按钮),均采用与「最后登录」列一致的两行主副格式,列宽调整为 10%/12%,腾出的横向空间还给其余列;顺带提交打卡页收藏本站贴边窄竖条按钮的圆角微调(rounded-l-full 改 rounded-l-md,接近长方形观感)
ci/woodpecker/tag/build Pipeline was successful
2026-09-12 13:16:05 +08:00
becomer 0f49e7b1da v1.2.33:收藏本站贴边按钮修正为窄竖条——上一版把 writing-mode 加在按钮容器上导致 flex 主轴随竖排书写模式转为水平,图标被挤到右侧、整体过宽;现改为容器保持常规纵排、仅文字 span 用 writing-mode 竖排,效果为图标在上、竖排文字在下、贴屏幕右缘的窄条(宽约 28px),并按需求移除上下浮动跳动动画及相关 keyframes/降级样式
ci/woodpecker/tag/build Pipeline was successful
2026-09-12 12:50:19 +08:00
becomer 95988c3ca9 v1.2.32:打卡页「收藏本站」悬浮气泡改为贴屏幕右缘竖排标签——全局引导入口上移后与原收藏气泡(right-3 bottom-20)位置重叠互相遮挡,收藏按钮改为 writing-mode 竖排、right-0 贴边、左侧圆角、位置抬高至引导按钮上方(bottom-32),两者横向纵向均错开互不遮挡,保留浮动跳动动画
ci/woodpecker/tag/build Pipeline was successful
2026-09-12 12:47:56 +08:00
becomer edd2b248e2 v1.2.31:新手引导第四步文案统一为「执行签到」并去掉「自动」二字,引导入口按钮提升为全局显示——标题改为「执行签到」,真实任务/示例卡空态/列表兜底三种说明与第四步 mock 提示中的「自动完成今天的签到」「自动完成每日签到」「开始今天的自动签到」分别改为「完成今天的签到」「完成每日签到」「开始今天的签到」;「忘记操作?再次查看引导」悬浮气泡从个人中心(ProfileView)移至移动端布局层(MobileLayout,含 guide-bob 浮动动画与 prefers-reduced-motion 降级),打卡/销假/个人中心所有页面恒定显示,引导展示期间自动隐藏,ProfileView 移除旧入口及 inject/Sparkles/样式残留
ci/woodpecker/tag/build Pipeline was successful
2026-09-12 12:43:57 +08:00
becomer 985748a3d3 v1.2.30:新手引导第四步(执行任务自动签到)在用户无真实任务时补齐示例任务卡 mock——原第三步的示例卡机制扩展为第三/四步共用:第四步先高亮空态区并展示示例卡,随后定位轮询自动升级命中示例卡自身的「执行」按钮(data-guide 按步骤动态挂载,第三步不抢焦点),按钮加蓝色脉冲动画强调,气泡文案切换为「点示例卡片右侧的执行按钮即可开始今天的自动签到」;示例卡锚定改为空态区 rect(轮询持续校正),不随高亮框滑移错位,mock 判定用 guide-root 归属区分真实任务按钮避免乒乓循环;有真实任务时第四步仍高亮真实执行按钮,行为不变
ci/woodpecker/tag/build Pipeline was successful
2026-09-12 12:37:01 +08:00
becomer c0ea9ecd8f v1.2.29:前端新增亮暗主题按时间自动切换与底部导航改版——利用 Tailwind v4 颜色工具类引用 --color-* 变量的特性,globals.css 在 .dark 作用域整体覆盖灰阶/彩色浅底/语义 token 调色板,全站 1000+ 处硬编码颜色类一次性适配暗色(深彩色与遮罩黑保持原值),蓝底彩底上的白字/半透明白元素用类名级规则恢复纯白;切换规则为浏览器系统时间 18:00–次日 06:00 暗色(useTheme.ts 立即校正+分钟级轮询自动切换,index.html 内联脚本首屏渲染前预设 .dark 防白闪,theme-color 同步状态栏色),纯自动无手动开关;底部导航「首页」改名「打卡」(CalendarCheck2 图标,路由 name 保持 home 零逻辑改动),新增「销假」tab(CalendarX2)与 /leave 可爱风占位页(🚧 弹跳+糖果漂浮装饰+马卡龙渐变+颜文字,暗色自动适配);绑定成功文案「即将进入首页」同步改为「即将进入打卡页」,布局内容区 bg-[#f5f5f5] 改语义类 bg-background
ci/woodpecker/tag/build Pipeline was successful
2026-09-12 12:17:51 +08:00
becomer e7e106c606 v1.2.28:验证码字体子集化——用 fonttools pyftsubset 从 Sour Gummy ExtraBold 提取仅含 0-9 的数字子集(验证码只需数字),嵌入体积从 58KB 降至 3.3KB,cmap 仅保留 U+0030-0039,渲染效果不变
ci/woodpecker/tag/build Pipeline was successful
2026-09-12 10:44:28 +08:00
becomer 240e1226ef v1.2.27:验证码全面改版为可爱风——弃用 7x9 像素点阵,嵌入 Sour Gummy ExtraBold 开源字体(OFL 授权,猫啃网)渲染数字,字符按字形包围盒精确居中并随机旋转 ±10°,白描边贴纸效果;画布 180x60 且代码级保障最小尺寸,四周统一 15px 留白;干扰线改为绘制在数字上层的贯穿式弯曲斜线(强制大倾角、四向随机、线身带 1~2 个正弦拱似抛物线/波浪),专用冷色色池避开红粉;马卡龙背景+白色气泡+糖珠点缀;引入 golang.org/x/image 字体渲染依赖
ci/woodpecker/tag/build Pipeline was successful
2026-09-12 10:41:00 +08:00
becomer 643e706335 v1.2.26:记录用户登录 IP——认证中间件当天首次更新登录时间时同步写入客户端真实 IP(优先 nginx 转发的 X-Real-IP,其次 X-Forwarded-For 首段,兜底 RemoteAddr),真实登录接口同样记录;后台用户列表新增「最后登录」列(IP+时间)、详情弹窗新增最后登录IP展示
ci/woodpecker/tag/build Pipeline was successful
2026-09-11 23:27:59 +08:00
becomer 61a8d2b3fb v1.2.25:首页新增「收藏本站」右侧悬浮气泡按钮(与个人中心引导气泡同款交互),点击复制本站网址到剪贴板并按设备类型展示手动添加步骤——手机浏览器通过菜单添加书签/收藏或添加到主屏幕,电脑浏览器 Ctrl+D
ci/woodpecker/tag/build Pipeline was successful
2026-09-11 22:29:44 +08:00
becomer 6532025cc0 v1.2.24:已打卡任务的绿色对勾改为可点击,点击后弹窗确认"您当前已经打卡,是否再次打卡",确认后以与正常执行完全相同的规则(表单校验、次数检测、限流、设备校验)再次打卡;执行中显示加载动画,失败自动回到可执行状态
ci/woodpecker/tag/build Pipeline was successful
2026-09-11 22:20:28 +08:00
becomer 7a4507903f v1.2.23:修复任务状态两个问题——1)跨天状态不重置:昨日成功后今天仍显示绿色对勾且无法执行,改为列表查询时按当天成功日志惰性重置为待执行;2)打卡完成后列表仍显示未签到/不刷新:成功后本地乐观更新+延迟校正刷新,显示以本地当天成功优先,并增加切回页面自动刷新
ci/woodpecker/tag/build Pipeline was successful
2026-09-11 22:16:23 +08:00
becomer a95c843bf7 v1.2.22:个人中心取消底部真实姓名/学号展示行,避免明文暴露敏感信息;新增右侧气泡跳动按钮「忘记操作?再次查看引导」,点击重播新手引导
ci/woodpecker/tag/build Pipeline was successful
2026-09-11 19:51:42 +08:00
becomer 764bae23fc v1.2.21:认证中间件补当天登录标记——token 长期有效不重新登录时,当天首次请求条件更新 last_login_at,修正后台当天登录人数统计
ci/woodpecker/tag/build Pipeline was successful
2026-09-11 14:24:40 +08:00
becomer 1f4d601ffa v1.2.20:后台用户管理列表中当天已登录用户的整行背景色显示为浅绿色
ci/woodpecker/tag/build Pipeline was successful
2026-09-10 20:40:21 +08:00
becomer 5de7852786 v1.2.19:修复签到图片直传OBS被拒(改用接口返回formData字段集);执行时自动将base64照片上传换正式URL;验证码创建/校验改multipart;表单必填项为空执行前拦截
ci/woodpecker/tag/build Pipeline was successful
2026-09-10 18:06:10 +08:00
becomer 98631afdc5 v1.2.18:代理后台仅保留仪表盘、代理管理、CDK管理菜单,其余页面路由守卫拦截重定向回仪表盘
ci/woodpecker/tag/build Pipeline was successful
2026-09-10 11:21:18 +08:00
becomer bc4e739e09 v1.2.17:hfnu 切换为标准 IAP 登录(绕过学校 SSO 拒绝单点登录跳转);忘记密码提示改为打开学校官方登录地址测试登录或找回密码
ci/woodpecker/tag/build Pipeline was successful
2026-09-10 11:14:37 +08:00
becomer 9bffdb7026 恢复 CI 触发逻辑:when 保持 event: tag + tag: v*(仅打 tag 触发)
ci/woodpecker/tag/build Pipeline was successful
2026-09-09 23:48:14 +08:00
becomer 42c8b66a01 CI 修复:when 语法适配 Woodpecker 3.16;蓝绿部署入口切换到 jinrixiaoyuan.top
- when 改为 event: tag + ref: refs/tags/v*(tag: v* 为无效遗留键导致 pipeline 被过滤)
- 反代切换文件由已废弃的 xiaozhushou 站点改为 jinrixiaoyuan.top/proxy/root.conf(frps 公网链路)
- 移除首次部署初始化分支(站点已存在),未知端口时中止部署防误切
2026-09-09 23:44:04 +08:00
becomer 81b2942cad v1.2.16:hfnu 专属登录适配;拒绝改为单条记录;修复拒绝按钮换行与真实 IP 获取
- 新增 login_hfnu.go:合肥师范学院老式 jasig CAS 专用登录(URL 重写 jsessionid + 强制验证码),识别学校端 SSO 跳转被拒故障并明确报错
- 拒绝待适配学校改为仅拒绝当前记录,邮件只通知该条记录联系邮箱,不再批量拒绝同校其他记录
- 管理页操作按钮加 whitespace-nowrap 修复换行
- TrustedProxies 增加 127.0.0.1,配合 frpc PROXY protocol 透传恢复真实客户端 IP
2026-09-09 23:11:41 +08:00
becomer 4b1dea3d3c 待适配学校支持拒绝操作:拒绝必填理由并邮件通知联系邮箱
ci/woodpecker/tag/build Pipeline was successful
- 新增管理端接口 POST /admin/pending-schools/:id/reject,拒绝理由必填(≤500字)
- 拒绝时该校全部待处理记录标记为 rejected 并保存理由
- 新增 NotifySchoolRejected 邮件模板,拒绝理由 HTML 转义后发送至该校全部联系邮箱
- 列表接口返回 rejectReason 并支持 status=rejected 筛选
- 管理页新增拒绝按钮(组级+行级)、拒绝理由弹窗、已拒绝状态标签与理由展示、已拒绝筛选
2026-09-09 22:31:06 +08:00
becomer 0d665ad0fe 重构任务详情获取:执行时严格按任务名从当天列表实时匹配,移除全部兜底回退
ci/woodpecker/tag/build Pipeline was successful
- 执行链路新增 FetchTodayTaskDetail:仅用当天任务列表按任务名匹配未签实例,
  wid 每次执行时实时获取;匹配失败直接报明确错误(未找到/今日已签),不再退回配置 wid
- 预览/历史预填充改用 GetTaskDetailByWid 纯 wid 查询,无隐式回退
- 删除 findLatestTaskInstance 与 fetchTaskDetail 的 allowFallback 递归回退逻辑
- RealTitle 直接取列表真实任务名,去掉二次匹配
2026-09-08 23:12:29 +08:00
becomer 67d925bb91 refactor: 任务实例 wid 改为执行时实时获取,配置快照仅作兜底
ci/woodpecker/tag/build Pipeline was successful
- 任务执行入口优先按任务名从当天列表实时匹配未签到的实例,
  循环任务跨天不再依赖配置时保存的 signWid/signInstanceWid 快照
- 月度列表中的历史漏签实例不再被误当作可提交目标
  (历史实例提交会被今日校园判定"已结束")
- 当天列表无可用实例(无同名任务/接口异常)时退回配置 wid 兜底,
  详情为空再按任务名匹配近期已签实例还原表单结构
2026-09-08 22:59:29 +08:00
becomer bf0e1656ab fix: 循环签到/查寝任务自动切换到当日任务实例,修复"查寝已结束"
ci/woodpecker/tag/build Pipeline was successful
循环任务(如每日晚归寝打卡)每天生成新的 signInstanceWid,历史实例的
详情接口仍可返回数据,导致原有的"详情为空才回退"逻辑不触发,提交到
过期实例被今日校园判定为"查寝已结束"。

现在获取任务详情时,若当日列表中同名任务存在未签到的实例且与详情返回
的实例不一致,自动切换到当日实例后再填充表单提交。
2026-09-08 22:51:03 +08:00
becomer 66d07781be fix: 执行日志弹窗细节优化
ci/woodpecker/tag/build Pipeline was successful
- 时间列只显示 HH:MM:SS(日期由弹窗筛选决定)
- 状态徽标禁止换行
- 说明列显示完整错误信息,不再截断
2026-09-08 22:41:17 +08:00
becomer 99c16652a7 feat: 后台用户管理新增 VIP 列
ci/woodpecker/tag/build Pipeline was successful
- 列表接口返回 vipStatus(active/expired/none)与 vipExpireAt
- 表格新增 VIP 列:有效显示金色徽标+到期日期,过期显示已过期,未开通显示非VIP
2026-09-08 22:31:42 +08:00
becomer cc4aa1c00e feat: 后台当天执行总次数可点击查看执行日志明细
ci/woodpecker/tag/build Pipeline was successful
- 后端新增 GET /admin/task-logs 接口:按日期(默认当天)、成功/失败状态、
  关键字(用户名/姓名/任务标题)筛选,联表 sys_user 返回用户与学校信息,分页返回
- 前端用户管理页「当天执行总次数」卡片改为可点击,弹出日志明细弹窗
- 弹窗支持日期切换(可查历史日期)、状态筛选、关键字搜索与分页浏览
2026-09-08 22:12:41 +08:00
becomer 15eceea38a docs: 随机设备码适用人群提示改为帮打卡导致频繁切换设备的用户
ci/woodpecker/tag/build Pipeline was successful
2026-09-08 20:21:54 +08:00
becomer a9dbc2661d feat: 设备码绑定改为非强制 + 随机绑定入口
ci/woodpecker/tag/build Pipeline was successful
- 登录后不再强制跳转设备绑定页(MobileLayout 移除拦截)
- 新建签到任务时检测未绑定设备码,弹窗提示「请先在个人中心绑定设备码」并支持跳转
- 个人中心设备码栏新增「随机」按钮:随机生成 UUID 设备码直接绑定,
  前端连续两次风险确认(随机后现有手机会提示频繁切换设备无法签到)
- 后端新增 POST /device/random-bind(登录态,随机 UUID v4 绑定,mobile_type=manual)
- 新手引导在「新建每日签到任务」前新增「先绑定设备码」步骤
2026-09-08 20:15:24 +08:00
becomer ebc94ec9a0 fix: 复制按钮与 toast 层级修复
ci/woodpecker/tag/build Pipeline was successful
- copyToClipboard 调整为先同步 execCommand(用户手势内最可靠),
  Clipboard API 仅作异步兜底;修复移动端先 await Clipboard API 被拒后
  手势丢失导致 execCommand 静默失败的问题(单行复制不生效)
- 单行复制成功也弹「已复制」toast,失败提示明确,不再无反馈
- toast 容器 z-50 提至 z-[100],不再被弹窗遮罩盖住
2026-09-08 18:27:23 +08:00
becomer 98cf8666f6 feat: 新手引导 VIP 步骤移到最后;CDK 列表展示类型与额度
ci/woodpecker/tag/build Pipeline was successful
- 引导顺序调整为:领取次数 → 新建任务 → 执行任务 → 邀请好友 → VIP 会员
- CDK 列表「次数」列拆为「类型」(次数卡/VIP周卡/VIP月卡徽标)+「额度」(N 次 / N 天)
- CDKCode 类型补充 type/vipDays 字段(后端已输出)
2026-09-08 17:51:34 +08:00
becomer ff3593f057 fix: VIP 展示与复制按钮等 7 项体验修复
ci/woodpecker/tag/build Pipeline was successful
- VIP 徽标:修复 position 覆盖导致徽标挤变形头像,加 z-10 浮于头像上层
- VIP 用户「可签到次数」显示无限次
- VIP 兑换弹窗标题统一「续费 VIP」,去掉提示文案;按钮统一「续费 VIP」
- 新增 utils/clipboard:兼容 HTTP/移动端的复制(Clipboard API + execCommand 回退),
  修复待适配学校密码复制、CDK 结果列表单个复制按钮不生效
- 新手引导新增一步:VIP 会员与普通签到次数的区别
2026-09-08 13:25:13 +08:00
becomer 74422b1848 fix: VIP 卡默认价改为周卡 30 元 / 月卡 100 元(对齐单次扣费 6 元的定价逻辑)
ci/woodpecker/tag/build Pipeline was successful
2026-09-08 12:40:20 +08:00
becomer 84f87cd168 feat: VIP CDK 改为周卡/月卡两档(7天/30天),价格分开配置
ci/woodpecker/tag/build Pipeline was successful
- 后端:vip_price_week(周卡,默认5元)/ vip_price_month(月卡,默认15元),
  VIP CDK 仅支持 7/30 天,vip-info 返回双价格
- 后台:生成弹窗改为周卡/月卡选择,设置页拆分周卡/月卡售价两项
- 前台:开通按钮展示 周¥x · 月¥y 双价格
2026-09-08 12:29:59 +08:00
becomer 386e9269bf fix: VIP 价格设置前端兜底值与后端默认一致(15 元)
ci/woodpecker/tag/build Pipeline was successful
2026-09-08 12:23:31 +08:00
becomer 17a71b1e2a feat: 新增 VIP 功能(CDK 兑换开通、徽标展示、免扣次数、后台管理)
ci/woodpecker/tag/build Pipeline was successful
- 数据库:sys_user.vip_expire_at、cdk_codes type/vip_days、系统设置 vip_price
- 后端:CDK 生成支持 count/vip 类型(VIP 默认 30 天),兑换延长 VIP 到期时间,/cdk/vip-info 查询售价
- 任务执行:VIP 有效期内的用户跳过次数扣减
- 前台:个人中心 VIP 徽标(反光动画)+ 到期时间 + CDK 兑换弹窗
- 后台:CDK 生成弹窗类型选择 + 系统设置 VIP 月卡售价项
2026-09-08 12:19:23 +08:00
becomer 1b4f029602 feat: 后台用户列表增加学校列;待适配学校表格小屏横向滚动
ci/woodpecker/tag/build Pipeline was successful
用户列表在真实姓名后新增学校列(数据接口本已返回 schoolName),
各列宽度相应调整;待适配学校账号表格外层增加 overflow-x-auto
并设置最小宽度,修复手机端表格溢出被裁剪无法查看完整信息的问题。
2026-09-08 07:40:59 +08:00
becomer 2246752724 fix: 引导页手指改指右上角菜单,浏览器打开后自动跳回原页面
ci/woodpecker/tag/build Pipeline was successful
1. 手指图标按业界通用引导样式移至右上角,指向模拟的 ···
   菜单按钮并带点动动画
2. 修复微信右上角"在浏览器打开"仍停留在引导页的问题:
   - 引导页在正常浏览器 UA 下由守卫自动 replace 回 redirect 原地址
   - 放行纯 QQ 浏览器 App(MQQBrowser),避免安卓微信唤起 QQ
     浏览器后再次被拦造成死循环;仍拦微信与手机QQ内置浏览器
2026-09-07 22:16:38 +08:00
becomer a303782ab4 feat: 微信/QQ 内置浏览器访问时跳转浏览器打开引导页
ci/woodpecker/tag/build Pipeline was successful
检测 MicroMessenger/QQ/MQQBrowser UA 时路由守卫优先拦截,
跳转 /open-in-browser 引导页(手指动画指向"用浏览器打开"
按钮),点击复制原地址含全部 query;系统浏览器打开后自动
回到原页面。
2026-09-07 22:07:02 +08:00
becomer 2cac0cdfee fix: 查寝历史位置超出签到范围时自动改用最近签到点提交
ci/woodpecker/tag/build Pipeline was successful
学校老师频繁调整查寝组签到点(小半径500~1000m),历史记录位置
卡在边缘(如距边角修正1仅540m),配置一变即触发"位置不在签到
范围内"。提交前校验历史位置与 signPlaceSelected 各点距离,全部
出界时自动改用最近签到点坐标,位置在范围内则继续沿用历史记录。
2026-09-07 21:51:18 +08:00
becomer 75d334680b feat: 用户新建工单时邮件提醒管理员
ci/woodpecker/tag/build Pipeline was successful
复用待适配学校通知模式: GetAdminEmails + SendMailAsync, 邮件含标题/
用户/学校/内容摘要(HTML转义防注入, 500字截断), 仅新建工单触发。
2026-09-06 18:37:53 +08:00
becomer 35fa5a6560 feat: 后台用户管理展示邀请人
ci/woodpecker/tag/build Pipeline was successful
- sys_user 新增 inviter_id 字段, 注册时记录邀请关系
- ListUsers/GetUserDetail 返回 inviterId/inviterName(姓名优先)
- 前端用户列表加邀请人列, 详情弹窗加邀请人卡片
- 历史数据通过系统公告(邀请奖励到账/新人注册奖励 同刻配对)回填
2026-09-05 22:06:52 +08:00
becomer dbd3c88d32 fix: 修复芜湖职业技术大学等CAS学校登录入口错选问题
ci/woodpecker/tag/build Pipeline was successful
getCasValidLoginUrl 原来按 AmpUrl/AmpUrl2 顺序取第一个可用URL, 部分学校
AmpUrl 是自研门户(ehall), 登录后cookie落在门户域, 与后续签到/查寝任务
接口所在campusphere网关域不匹配, 导致姓名同步失败且任务拉取返回302页面。
芜湖与安徽师范结构同构(网关+AmpUrl2), 修改为先取campusphere/wec网关入口。
2026-09-04 21:33:05 +08:00
becomer 33e29573ca fix: 验证码限流改令牌桶(同IP 5分钟最多10次), 前端显示限流提示不再静默失败
ci/woodpecker/tag/build Pipeline was successful
原 SetNX 固定 10 秒/次:共享出口 IP 下易互相挤占且失败时前端静默,
表现为"验证码不显示且无提示"。改为 Redis Lua 原子令牌桶(容量10、速率
每30s回补1个),429 返回动态等待文案;登录主验证码、失败工单弹窗、
兑换弹窗三处加载失败均展示错误提示并支持点击重试。
2026-09-04 18:46:02 +08:00
becomer 4a80382b60 fix: 全局时间统一北京时间(Asia/Shanghai), 修复记录与展示偏移8小时
ci/woodpecker/tag/build Pipeline was successful
根因: 运行容器默认 UTC 且后端未设时区, time.Now()/gorm 时间戳/DSN
loc=Local/手动 Format 全部按 UTC, 导致展示为"美国时间"、月度与每日
边界错位。修复:
- main.go 在 database.Init 前固定 time.Local=Asia/Shanghai
  (DSN loc=Local 随之一致, 一处生效覆盖全部业务点)
- pending_school.go 今日判断改北京时间当日0点(原 Truncate 按UTC午夜偏移8h)
- Dockerfile/build.yml/compose 加 TZ=Asia/Shanghai 双保险
配套: 存量 datetime 列已 +8h 迁移(备份 /root/cpdaily-backup-pre-tz-*.sql)
2026-09-03 21:06:59 +08:00
becomer 8eaaf5781a fix: 引导强播标记改为一次性消费, 杜绝看完引导后刷新又弹且不上报
ci/woodpecker/tag/build Pipeline was successful
?guide=1 强播标记此前写入 sessionStorage 后在整页加载时只置位不清除,
同一标签页后续普通刷新被残留标记误判为强播: 关闭引导不写库、刷新又弹。
现 MobileLayout 读取标记后立即消费(removeItem), 仅当次加载内强播生效,
刷新后回到数据库判定(未看过 v2 才弹, 关闭即上报写库)。
2026-09-03 16:51:13 +08:00
becomer 111cc0439c fix: 引导气泡 bottom 模式定位失效致遮住高亮按钮(r.bottom 为 NaN)
ci/woodpecker/tag/build Pipeline was successful
holeRect 数据模型只有 top/left/width/height,bubbleStyle 的 bottom
分支却直接读 r.bottom(undefined+12=NaNpx),被浏览器丢弃后气泡
永远停留在 center 兜底的 top:96px 压住目标按钮;改为 r.top+r.height
合成底部坐标(v1.1.36 引入 bottom 优先后暴露此问题)
2026-09-03 16:32:33 +08:00
becomer a97537660e fix: 引导气泡方位改为下方优先 + 统一决策/越界自愈, 杜绝顶部溢出遮住目标
ci/woodpecker/tag/build Pipeline was successful
- 方位决策抽为 choosePos:下方可完整容纳优先(移动端向下展开、
  贴合"上方高度不足应放下方"的预期);下方不足再上方;两侧都不足时
  比较视口裁切量选溢出更少一侧(原兜底在两侧都不足时可能选顶部导致
  气泡被顶出屏幕遮住高亮目标)
- locate 找到目标后先立即测量一次再滚动定位:消除切步/跨页等待滚动
  期间气泡停留在顶部兜底样式(top:96px)压住目标按钮的窗口
- 自愈增强:top 模式顶部被裁切、bottom 模式底部被裁切、center 兜底
  样式与目标相交时,按真实气泡尺寸重走同一 choosePos 决策(与 measure
  同源,不会两侧乒乓);center 相交则直接重新定位
2026-09-03 16:21:07 +08:00
becomer 664d8a2594 feat: 新手引导改为数据库记录判定, 跨设备一致(用户选项: 存量未看过 v2 也会弹一次)
ci/woodpecker/tag/build Pipeline was successful
- SysUser 新增 guide_version 字段(GORM 自动迁移加列, 存量默认空)
- GET /auth/userinfo 返回 guideVersion; 新增 POST /auth/guide/complete
  记录用户已看完当前版本引导(v2)
- 前端移除 localStorage 完成标记: 登录后按 userInfo.guideVersion
  判定是否展示引导, 关闭/完成引导后上报写库 → 换设备不再重复打扰
- ?guide=1 测试强播入口保留, 且强播来源关闭引导时不上报数据库,
  便于 QA 反复重测而不污染账号引导状态
2026-09-03 16:17:42 +08:00
becomer 6ede88b01b feat: 引导支持 ?guide=1 强制重播(测试入口), 气泡按钮改主题纯色
ci/woodpecker/tag/build Pipeline was successful
- 新手引导完成标记存于浏览器 localStorage(设备维度,与账号无关),
  服务器无法按学号远程清除;新增 URL ?guide=1 一次性强制重播入口:
  main.ts 消费参数存入会话标记(登录跳转丢失 query 也不影响),
  MobileLayout 每次进入时命中即重播,关闭引导后恢复常规逻辑
- 引导气泡主按钮去掉蓝紫渐变,改为与站内主按钮一致的纯主题蓝
2026-09-03 16:12:52 +08:00
becomer 6b42cff492 fix: 引导切步无重绘闪烁, 滚动稳定后再测位, 第三步空任务展示示例卡引导
ci/woodpecker/tag/build Pipeline was successful
- 气泡切步不再以 :key 重建(去掉重放入场动画与"遮罩重新生成"观感);
  同 tab 切步保留旧高亮作为过渡锚点,测到新目标后平滑滑移而非闪黑
- locate 改为等待平滑滚动静默 120ms/最长 800ms 后测量,杜绝滚动中途
  错位导致气泡方位误判(跑到页面顶部/遮住目标按钮)
- 气泡方位判定高度保守化 need=max(240, bH+24),保证整颗气泡落在
  目标一侧且始终保留间隙
- 第三步无真实任务时全屏压暗并展示仿真实样式的示例任务卡(含执行
  按钮),引导新用户理解任务执行流程;第二步文案按要求更新
- 轮询新增目标升级/失联自动重定位:邀请码异步返回导致 生成→复制、
  行→兑换按钮 v-if 切换时高亮自动跟随
2026-09-03 16:08:40 +08:00
becomer cc1150c1ac fix: 引导高亮实时校正定位, 气泡绕开高亮目标
ci/woodpecker/tag/build Pipeline was successful
- 引导展示期间 300ms 轮询重测 + body/目标/气泡 ResizeObserver, 修正异步数据/图片加载导致布局变化后的高亮错位
- 气泡方位按真实气泡高度选择, 内容文案变化后自动重排; top/bottom 定位始终与目标保留间隙
- 渲染后自检气泡与真实目标相交则翻侧绕开(400ms 防抖), 杜绝气泡遮住高亮按钮
- 气泡位置变化加 CSS 过渡平滑跟随
2026-09-03 15:54:47 +08:00
becomer 032c8e526f fix: 部署脚本移除 cp backed/config.toml(配置已出库, 由宿主机运维维护, 修复 v1.1.30 部署中止)
ci/woodpecker/tag/build Pipeline was successful
2026-09-03 15:45:57 +08:00
becomer d442f538e1 feat: 新手引导升级为 spotlight 真实页面高亮引导, 邀请奖励积分动态读取系统配置
ci/woodpecker/tag/build Pipeline failed
- 引导改为遮罩挖洞高亮真实 DOM 元素 + 气泡 tooltip, 四步跨首页/个人中心, 全屏拦截点击由气泡推进
- GUIDE_DONE_KEY 升级为 xz_guide_v2_done, 所有存量用户重走一遍引导
- 引导完成前不查询公告强制弹窗, 关闭引导后统一补齐
- HomeView/ProfileView 目标元素增加 data-guide 标记, 支持懒加载轮询等待与无任务降级分支
- 后端 GetMyInviteInfo/GenerateMyInviteCode 返回 inviterRewardPerInvite/inviteeRewardPerInvite
- ProfileView 与引导气泡邀请奖励文案动态化, 接口失败降级泛指文案
2026-09-03 15:43:32 +08:00
becomer 20921c6317 chore: 生产配置移出版本库, 提供脱敏模板
backed/config.toml 内含生产数据库/Redis/JWT/captcha 凭证,随仓库分发
将导致凭证泄露(已实测凭仓库内 JWT 密钥可离线伪造超管 token)。
本地文件保留(git rm --cached 不删磁盘),新增 config.toml.example
脱敏模板说明部署方式;生产配置在服务器 /opt 下由容器挂载,不受影响。
2026-09-03 15:32:11 +08:00
becomer 097aed6f40 fix: 收紧管理端越权, 全量CDK/代理列表/公告管理/系统设置仅超管
ci/woodpecker/tag/build Pipeline was successful
实测发现任意代理 token 可访问 /admin/all-cdks、/admin/all-agents、
/agent/list 查看全站数据,并可创建全站强制弹窗公告、篡改系统计费与
SMTP 配置。为这些接口补充 AdminOnly 中间件;公告收取仍走
/my-announcements,代理自管理功能(自有CDK/下级代理)不受影响。
2026-09-03 15:28:43 +08:00
becomer a75d57f309 feat: CDK分页/禁用恢复/生成结果复制 + 引导v2四步并延迟公告查询
ci/woodpecker/tag/build Pipeline was successful
- CDK列表服务端分页(状态筛选/关键字/汇总统计),修复admin全量列表Preload缺失关联问题
- CDK禁用/恢复接口(代理限自己、admin全部),用户管理补充每页条数选择
- 生成CDK后弹窗展示全部CDK并支持复制全部/单个
- 新手引导新增第4步(邀请好友赚积分),引导升级v2机制:所有现有用户重新走一次
- 公告强制弹窗延迟到引导完成后查询,避免弹窗冲突;移除路由调试请求
2026-09-03 15:18:49 +08:00
becomer fc3bfe09f4 feat: 验证码走Redis防刷 + 新用户首次登录三步引导
ci/woodpecker/tag/build Pipeline was successful
- 验证码存储迁移 Redis(TTL 5 分钟自动过期,不再内存无限增长),
  Redis 不可用时兜底内存存储,不影响登录可用性
- 验证码生成接口按客户端 IP 限频(10 秒/次,SetNX),移除每次
  打印验证码的调试日志,防止日志刷屏
- 登录响应新增 firstLogin 标记,新注册用户首次进入自动展示全屏
  三步新手引导:个人中心积分兑换次数→首页新建签到任务→执行任务
2026-09-03 15:11:10 +08:00
84 changed files with 11850 additions and 979 deletions
+3
View File
@@ -6,6 +6,9 @@ backed/cmd/server/cpdaily-new
backed/*.log
backed/*.har
# 本地/生产配置文件(含敏感凭证,禁止入库;模板见 backed/config.toml.example)
backed/config.toml
# Frontend build (rebuilt by Docker multi-stage)
backed/dist/
frondend/dist/
+21 -35
View File
@@ -26,33 +26,18 @@ steps:
IMAGE_TAG="${CI_COMMIT_TAG:-latest}"
# 确保网络存在
docker network create likedream-net 2>/dev/null || true
# 应用配置由宿主机 /opt/cpdaily-xiaozhushou/config.toml 运维维护
# (已移出版本库,避免密钥入库;容器以只读挂载方式读取)
# ============================================================
# 第一步:初始化 1Panel 站点配置(首次部署时才需要)
# ============================================================
# nginx 配置文件在 git clone 后位于 deploy/nginx/ 目录
# 同步配置文件到 /opt/cpdaily-xiaozhushou/config.toml
mkdir -p /opt/cpdaily-xiaozhushou
cp backed/config.toml /opt/cpdaily-xiaozhushou/config.toml
if ! docker exec 1Panel-openresty-ESmn ls /www/sites/xiaozhushou/proxy/api.conf 2>/dev/null; then
echo "=== 首次部署:创建 1Panel 站点配置 ==="
docker exec 1Panel-openresty-ESmn mkdir -p /www/sites/xiaozhushou/proxy /www/sites/xiaozhushou/log
docker cp deploy/nginx/xiaozhushou.conf 1Panel-openresty-ESmn:/usr/local/openresty/nginx/conf/conf.d/xiaozhushou.conf
docker cp deploy/nginx/api.conf 1Panel-openresty-ESmn:/www/sites/xiaozhushou/proxy/api.conf
docker exec 1Panel-openresty-ESmn nginx -s reload
echo " 1Panel 站点配置完成 ✓"
fi
# ============================================================
# 第二步:读取当前代理端口,确定蓝绿状态
# 第一步:读取当前反代端口,确定蓝绿状态
# 反代入口为 1Panel 站点 jinrixiaoyuan.top(frps 公网链路):
# /www/sites/jinrixiaoyuan.top/proxy/root.conf → proxy_pass http://127.0.0.1:8884|8885
# ============================================================
CURRENT_PORT=$(docker exec 1Panel-openresty-ESmn \
grep -o 'proxy_pass http://10.10.10.128:[0-9]\+' \
/www/sites/xiaozhushou/proxy/api.conf | grep -o '[0-9]\+$')
echo "=== 当前 1Panel 反代端口: $CURRENT_PORT ==="
grep -oE 'proxy_pass http://[0-9.]+:[0-9]+' \
/www/sites/jinrixiaoyuan.top/proxy/root.conf | grep -o '[0-9]\+$')
echo "=== 当前反代端口: $CURRENT_PORT ==="
if [ "$CURRENT_PORT" = "8884" ]; then
PORT_ACTIVE=8884
@@ -61,9 +46,8 @@ steps:
PORT_ACTIVE=8885
PORT_STANDBY=8884
else
echo "未知当前端口 $CURRENT_PORT,默认使用 8884/8885"
PORT_ACTIVE=8884
PORT_STANDBY=8885
echo "ERROR: 未知反代端口 $CURRENT_PORT,中止部署(期望 8884/8885)"
exit 1
fi
echo " 活跃: $PORT_ACTIVE | 备用: $PORT_STANDBY"
@@ -71,19 +55,20 @@ steps:
docker rm -f cpdaily-xiaozhushou-new 2>/dev/null || true
# ============================================================
# 第三步:在备用端口启动新容器
# 第二步:在备用端口启动新容器
# ============================================================
echo "=== 蓝绿部署: 启动新容器 on :$PORT_STANDBY (image: $IMAGE_TAG) ==="
docker run -d --name cpdaily-xiaozhushou-new \
--restart unless-stopped \
--network likedream-net \
-e TZ=Asia/Shanghai \
-p $PORT_STANDBY:8093 \
-v /opt/cpdaily-xiaozhushou/config.toml:/app/config.toml:ro \
cpdaily-xiaozhushou:$IMAGE_TAG
# ============================================================
# 第四步:健康检查
# 第三步:健康检查
# ============================================================
HEALTHY=false
for i in $(seq 1 15); do
@@ -104,45 +89,46 @@ steps:
echo " 新容器已就绪 ✓"
# ============================================================
# 第五步:切换 1Panel 代理到备用端口
# 第四步:切换 1Panel 反代到备用端口
# ============================================================
echo "=== 切换 1Panel 反代 :$PORT_ACTIVE → :$PORT_STANDBY ==="
docker exec 1Panel-openresty-ESmn \
sed -i "s|:$PORT_ACTIVE|:$PORT_STANDBY|g" \
/www/sites/xiaozhushou/proxy/api.conf
/www/sites/jinrixiaoyuan.top/proxy/root.conf
docker exec 1Panel-openresty-ESmn nginx -s reload
echo " Nginx 热加载完成(零中断)✓"
# ============================================================
# 第六步:停止旧容器
# 第五步:停止旧容器
# ============================================================
echo "=== 清理旧容器 ==="
docker stop cpdaily-xiaozhushou 2>/dev/null || true
docker rm cpdaily-xiaozhushou 2>/dev/null || true
# ============================================================
# 第七步:在活跃端口启动正式容器
# 第六步:在活跃端口启动正式容器
# ============================================================
echo "=== 启动正式容器 on :$PORT_ACTIVE ==="
docker run -d --name cpdaily-xiaozhushou \
--restart unless-stopped \
--network likedream-net \
-e TZ=Asia/Shanghai \
-p $PORT_ACTIVE:8093 \
-v /opt/cpdaily-xiaozhushou/config.toml:/app/config.toml:ro \
cpdaily-xiaozhushou:$IMAGE_TAG
# ============================================================
# 第八步:切回 1Panel 代理到活跃端口
# 第七步:切回 1Panel 反代到活跃端口
# ============================================================
echo "=== 切回 1Panel 反代 :$PORT_STANDBY → :$PORT_ACTIVE ==="
docker exec 1Panel-openresty-ESmn \
sed -i "s|:$PORT_STANDBY|:$PORT_ACTIVE|g" \
/www/sites/xiaozhushou/proxy/api.conf
/www/sites/jinrixiaoyuan.top/proxy/root.conf
docker exec 1Panel-openresty-ESmn nginx -s reload
echo " Nginx 热加载完成(零中断)✓"
# ============================================================
# 第九步:清理
# 第八步:清理
# ============================================================
echo "=== 清理 ==="
docker rm -f cpdaily-xiaozhushou-new 2>/dev/null || true
+2
View File
@@ -24,6 +24,8 @@ RUN go build -ldflags="-s -w" -o server ./cmd/server/
# ============================================================
FROM alpine:latest
RUN apk add --no-cache ca-certificates tzdata
# 时区固定为北京时间(tzdata 已安装;进程层 main.go 另有 time.Local 兜底)
ENV TZ=Asia/Shanghai
COPY --from=backend-builder /build/server /app/server
COPY --from=frontend-builder /build/dist /app/dist
WORKDIR /app
+54 -18
View File
@@ -11,6 +11,7 @@ import (
"cpdaily-new/internal/handler"
"cpdaily-new/internal/middleware"
"cpdaily-new/internal/ratelimit"
"cpdaily-new/internal/service"
"github.com/gofiber/fiber/v2"
"github.com/gofiber/fiber/v2/middleware/filesystem"
@@ -22,20 +23,33 @@ func main() {
log.Fatalf("加载配置失败: %v", err)
}
// 全局时区固定为中国标准时间(必须在 database.Init 之前设置:
// DSN 的 loc=Local 跟随 time.Local,影响数据库 datetime 读写序列化)。
// 运行镜像若不设 TZ 默认 UTC,会让所有记录/查询/统计/输出偏移 8 小时
// (前端展示为"美国时间"),每月 1 日与每日 0-8 点的边界计算也会错位。
loc, err := time.LoadLocation("Asia/Shanghai")
if err != nil {
log.Fatalf("加载时区 Asia/Shanghai 失败: %v", err)
}
time.Local = loc
// 连接数据库
database.Init()
// 连接 Redis(验证码存储/限流)
service.InitRedis()
// 启动限流记录定时清理(每5分钟清理一次)
ratelimit.Cleanup(5 * time.Minute)
// 创建 Fiber 应用
app := fiber.New(fiber.Config{
AppName: "Cpdaily New",
// 信任 openresty 反代(host 网络,经 docker NAT 后源 IP 为 172.22.0.1),
// 从 X-Forwarded-For 取真实客户端 IP(如待适配学校提交记录)
TrustedProxies: []string{"172.22.0.0/16"},
// 信任 openresty 反代(frpc 经 PROXY protocol 透传后,openresty 直接来源为 127.0.0.1;
// 宿主机 docker NAT 场景来源为 172.22.0.1),从 X-Forwarded-For 取真实客户端 IP
TrustedProxies: []string{"172.22.0.0/16", "127.0.0.1"},
EnableTrustedProxyCheck: true,
ProxyHeader: fiber.HeaderXForwardedFor,
ProxyHeader: fiber.HeaderXForwardedFor,
})
// 中间件
@@ -49,11 +63,14 @@ func main() {
auth.Get("/captcha", handler.GetCaptcha)
auth.Get("/schools", handler.SearchSchools)
auth.Post("/login", handler.Login)
auth.Post("/login/mfa", handler.MFAVerify)
auth.Post("/login/mfa/send", handler.MFASendCode)
auth.Post("/refresh", handler.RefreshToken)
auth.Get("/userinfo", middleware.AuthRequired(), handler.GetUserInfo)
auth.Post("/generate-invite", middleware.AuthRequired(), handler.GenerateMyInviteCode)
auth.Get("/my-invite", middleware.AuthRequired(), handler.GetMyInviteInfo)
auth.Post("/redeem-invite", middleware.AuthRequired(), handler.RedeemInviteReward)
auth.Post("/guide/complete", middleware.AuthRequired(), handler.CompleteGuide)
// 签到任务相关
task := api.Group("/task", middleware.AuthRequired())
@@ -71,15 +88,29 @@ func main() {
task.Post("/upload-policy", handler.GetUploadPolicy)
task.Post("/preview-url", handler.GetPreviewUrl)
// 销假(请假销假)
leave := api.Group("/leave", middleware.AuthRequired())
leave.Get("/pending-list", handler.GetPendingLeaves)
leave.Post("/approvers", handler.GetLeaveApprovers)
leave.Post("/execute", handler.ExecuteLeave)
leave.Get("/logs", handler.ListLeaveLogs)
leave.Delete("/logs/:id", handler.DeleteLeaveLog)
// 请假申请(同模块)
leave.Get("/types", handler.GetLeaveTypes)
leave.Post("/apply-approvers", handler.GetLeaveApplyApprovers)
leave.Post("/apply", handler.ExecuteLeaveApply)
// CDK 兑换
cdk := api.Group("/cdk", middleware.AuthRequired())
cdk.Post("/redeem", handler.UserRedeemCDK)
cdk.Get("/vip-info", handler.GetUserVipInfo)
// 设备绑定
device := api.Group("/device")
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")
@@ -92,33 +123,37 @@ func main() {
adminAuth := api.Group("/admin", middleware.AdminAuthRequired())
adminAuth.Get("/cdks", handler.ListCDKs)
adminAuth.Post("/cdks", handler.GenerateCDK)
adminAuth.Get("/all-cdks", handler.ListAllCDKs)
adminAuth.Put("/cdks/:id/status", handler.UpdateCDKStatus)
// 全量数据仅超管可见(代理只能看自己生成的)
adminAuth.Get("/all-cdks", middleware.AdminOnly(), handler.ListAllCDKs)
adminAuth.Get("/agents", handler.ListSubAgents)
adminAuth.Post("/agents", handler.CreateSubAgent)
adminAuth.Put("/agents/:id", handler.UpdateSubAgent)
adminAuth.Get("/agents/:id", handler.GetAgentDetail)
adminAuth.Put("/agents/:id/password", handler.UpdateAgentPassword)
adminAuth.Put("/agents/:id/status", handler.UpdateAgentStatus)
adminAuth.Get("/all-agents", handler.ListAllAgents)
adminAuth.Get("/agent/list", handler.ListAllAgents)
adminAuth.Get("/all-agents", middleware.AdminOnly(), handler.ListAllAgents)
adminAuth.Get("/agent/list", middleware.AdminOnly(), handler.ListAllAgents)
// 管理后台 - 用户管理(仅admin)
adminAuth.Get("/users", middleware.AdminOnly(), handler.ListUsers)
adminAuth.Get("/users/:id", middleware.AdminOnly(), handler.GetUserDetail)
adminAuth.Put("/users/:id/status", middleware.AdminOnly(), handler.UpdateUserStatus)
// 任务执行日志查询(供"当天执行总次数"卡片点击查看)
adminAuth.Get("/task-logs", middleware.AdminOnly(), handler.AdminListTaskLogs)
// 管理后台 - 公告
adminAuth.Post("/announcements", handler.CreateAnnouncement)
adminAuth.Get("/announcements", handler.ListAnnouncements)
adminAuth.Get("/announcements/:id", handler.GetAnnouncement)
adminAuth.Put("/announcements/:id", handler.UpdateAnnouncement)
adminAuth.Delete("/announcements/:id", handler.DeleteAnnouncement)
// 管理后台 - 公告(公告面向全站代理/用户推送,管理操作仅超管;代理/管理员通过 /my-announcements 收取公告)
adminAuth.Post("/announcements", middleware.AdminOnly(), handler.CreateAnnouncement)
adminAuth.Get("/announcements", middleware.AdminOnly(), handler.ListAnnouncements)
adminAuth.Get("/announcements/:id", middleware.AdminOnly(), handler.GetAnnouncement)
adminAuth.Put("/announcements/:id", middleware.AdminOnly(), handler.UpdateAnnouncement)
adminAuth.Delete("/announcements/:id", middleware.AdminOnly(), handler.DeleteAnnouncement)
// 管理后台 - 系统设置
adminAuth.Get("/settings", handler.GetSettings)
adminAuth.Post("/settings", handler.UpdateSetting)
adminAuth.Put("/settings", handler.UpdateSetting)
adminAuth.Post("/settings/test-mail", handler.TestMail)
// 管理后台 - 系统设置(全站计费/奖励/SMTP 配置,仅超管)
adminAuth.Get("/settings", middleware.AdminOnly(), handler.GetSettings)
adminAuth.Post("/settings", middleware.AdminOnly(), handler.UpdateSetting)
adminAuth.Put("/settings", middleware.AdminOnly(), handler.UpdateSetting)
adminAuth.Post("/settings/test-mail", middleware.AdminOnly(), handler.TestMail)
// 管理后台 - 我的公告(代理/管理员视角,共用普通用户公告处理器,但 target_type=agent)
adminAuth.Get("/my-announcements", handler.ListUserAnnouncements)
@@ -161,6 +196,7 @@ func main() {
// 管理后台 - 待适配学校
adminAuth.Get("/pending-schools", handler.AdminListPendingSchools)
adminAuth.Post("/pending-schools/:id/mark-done", handler.AdminMarkSchoolDone)
adminAuth.Post("/pending-schools/:id/reject", handler.AdminRejectSchool)
// 健康检查(用于蓝绿部署)
app.Get("/health", func(c *fiber.Ctx) error {
-31
View File
@@ -1,31 +0,0 @@
[server]
port = 8093
mode = "release"
[database]
host = "10.10.10.128"
port = 3306
user = "xiaozhushou"
password = "bh7S4JzPVyY0VLJh44i"
dbname = "xiaozhushou"
[jwt]
secret = "cpdaily-new-jwt-secret-key-2026"
accessExpire = 3600
refreshExpire = 864000
[captcha]
width = 150
height = 46
length = 4
[cpdaily]
appVersion = "9.8.8"
systemVersion = "iOS 17"
calVersion = "first_v4"
sliderIds = ["1018764144934965", "sdhyxy", "hqdx"]
captchaBaseUrl = "http://10.10.10.128:8890"
captchaApiKey = "baca122..."
[agent]
systemCdkPrice = 10.0
+45
View File
@@ -0,0 +1,45 @@
# 配置文件模板:复制为 config.toml 并按环境填写真实值。
# 注意:本文件随仓库分发,禁止填写任何真实凭证(数据库/Redis/JWT/第三方密钥)。
# 生产配置存放于服务器 /opt/cpdaily-xiaozhushou/config.toml(容器以挂载方式读取),
# 请勿将生产 config.toml 提交进 git。
[server]
port = 8093
mode = "release"
[database]
host = "127.0.0.1"
port = 3306
user = "your_db_user"
password = "your_db_password"
dbname = "xiaozhushou"
[jwt]
# 生产环境必须使用强随机密钥(openssl rand -hex 32),示例值仅用于本地开发,切勿用于生产。
secret = "dev-only-change-me-please"
accessExpire = 3600
refreshExpire = 864000
# MFA 通道登录短期 token 有效期(秒):与第三方 Cookie 对齐,默认 7200(2 小时)
mfaAccessExpire = 7200
[captcha]
width = 150
height = 46
length = 4
[cpdaily]
appVersion = "9.8.8"
systemVersion = "iOS 17"
calVersion = "first_v4"
sliderIds = ["1018764144934965", "sdhyxy", "hqdx"]
captchaBaseUrl = "http://127.0.0.1:8890"
captchaApiKey = "your_captcha_api_key"
[agent]
systemCdkPrice = 10.0
[redis]
host = "127.0.0.1"
port = 6379
password = "your_redis_password"
db = 0
+9 -5
View File
@@ -6,11 +6,12 @@ require (
github.com/BurntSushi/toml v1.6.0
github.com/PuerkitoBio/goquery v1.12.0
github.com/go-resty/resty/v2 v2.16.5
github.com/go-sql-driver/mysql v1.8.1
github.com/gofiber/fiber/v2 v2.52.13
github.com/golang-jwt/jwt/v5 v5.3.1
github.com/makiuchi-d/gozxing v0.1.1
github.com/redis/go-redis/v9 v9.7.0
golang.org/x/crypto v0.50.0
golang.org/x/time v0.6.0
golang.org/x/image v0.46.0
gorm.io/driver/mysql v1.6.0
gorm.io/gorm v1.31.2
)
@@ -19,6 +20,9 @@ require (
filippo.io/edwards25519 v1.1.0 // indirect
github.com/andybalholm/brotli v1.1.0 // indirect
github.com/andybalholm/cascadia v1.3.3 // indirect
github.com/cespare/xxhash/v2 v2.2.0 // indirect
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
github.com/go-sql-driver/mysql v1.8.1 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/jinzhu/inflection v1.0.0 // indirect
github.com/jinzhu/now v1.1.5 // indirect
@@ -27,11 +31,11 @@ require (
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-runewidth v0.0.16 // indirect
github.com/rivo/uniseg v0.2.0 // indirect
github.com/tidwall/match v1.1.1 // indirect
github.com/valyala/bytebufferpool v1.0.0 // indirect
github.com/valyala/fasthttp v1.51.0 // indirect
github.com/valyala/tcplisten v1.0.0 // indirect
golang.org/x/net v0.52.0 // indirect
golang.org/x/sys v0.43.0 // indirect
golang.org/x/text v0.36.0 // indirect
golang.org/x/sys v0.48.0 // indirect
golang.org/x/text v0.42.0 // indirect
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 // indirect
)
+25 -6
View File
@@ -8,6 +8,14 @@ github.com/andybalholm/brotli v1.1.0 h1:eLKJA0d02Lf0mVpIDgYnqXcUn0GqVmEFny3VuID1
github.com/andybalholm/brotli v1.1.0/go.mod h1:sms7XGricyQI9K10gOSf56VKKWS4oLer58Q+mhRPtnY=
github.com/andybalholm/cascadia v1.3.3 h1:AG2YHrzJIm4BZ19iwJ/DAua6Btl3IwJX+VI4kktS1LM=
github.com/andybalholm/cascadia v1.3.3/go.mod h1:xNd9bqTn98Ln4DwST8/nG+H0yuB8Hmgu1YHNnWw0GeA=
github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs=
github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c=
github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA=
github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0=
github.com/cespare/xxhash/v2 v2.2.0 h1:DC2CZ1Ep5Y4k3ZQ899DldepgrayRUGE6BBZ/cd9Cj44=
github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78=
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
github.com/go-resty/resty/v2 v2.16.5 h1:hBKqmWrr7uRc3euHVqmh1HTHcKn99Smr7o5spptdhTM=
github.com/go-resty/resty/v2 v2.16.5/go.mod h1:hkJtXbA2iKHzJheXYvQ8snQES5ZLGKMwQ07xAwp/fiA=
github.com/go-sql-driver/mysql v1.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpvNJ1Y=
@@ -25,6 +33,8 @@ github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
github.com/klauspost/compress v1.17.9 h1:6KIumPrER1LHsvBVuDa0r5xaG0Es51mhhB9BQB2qeMA=
github.com/klauspost/compress v1.17.9/go.mod h1:Di0epgTjJY877eYKx5yC51cX2A2Vl2ibi7bDH9ttBbw=
github.com/makiuchi-d/gozxing v0.1.1 h1:xxqijhoedi+/lZlhINteGbywIrewVdVv2wl9r5O9S1I=
github.com/makiuchi-d/gozxing v0.1.1/go.mod h1:eRIHbOjX7QWxLIDJoQuMLhuXg9LAuw6znsUtRkNw9DU=
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
@@ -32,10 +42,12 @@ github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWE
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc=
github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU=
github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
github.com/redis/go-redis/v9 v9.7.0 h1:HhLSs+B6O021gwzl+locl0zEDnyNkxMtf/Z3NNBMa9E=
github.com/redis/go-redis/v9 v9.7.0/go.mod h1:f6zhXITC7JUJIlPEiBOTXxJgPLdZcA93GewI7inzyWw=
github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY=
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA=
github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM=
github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
github.com/valyala/fasthttp v1.51.0 h1:8b30A5JlZ6C7AS81RsWjYMQmrZG6feChmgAolCl1SqA=
@@ -51,6 +63,8 @@ golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI=
golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q=
golang.org/x/image v0.46.0 h1:b1+oYj0Jbp6K5MDT4i4/eZpYlk3V8SJhhDKh6LBHAyQ=
golang.org/x/image v0.46.0/go.mod h1:3B3W05VGVQyuXucLINLjXKrqISASfi4Xj+iCVkLMwew=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
@@ -87,8 +101,8 @@ golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI=
golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo=
golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og=
golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
@@ -107,8 +121,9 @@ golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg=
golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164=
golang.org/x/text v0.42.0 h1:JbOZXgfeCPU9gacVtYliJqOhD+zhrEqK4LfdpmlUZqI=
golang.org/x/text v0.42.0/go.mod h1:ojzP1Z+2QtioaF8DTtO8K5q7JWVVYwZKenzujK0Zd0E=
golang.org/x/time v0.6.0 h1:eTDhh4ZXt5Qf0augr54TN6suAUudPcawVZeIAPU7D4U=
golang.org/x/time v0.6.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
@@ -117,7 +132,11 @@ golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58=
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 h1:go1bK/D/BFZV2I8cIQd1NKEZ+0owSTG1fDTci4IqFcE=
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
gorm.io/driver/mysql v1.6.0 h1:eNbLmNTpPpTOVZi8MMxCi2aaIm0ZpInbORNXDwyLGvg=
gorm.io/driver/mysql v1.6.0/go.mod h1:D/oCC2GWK3M/dqoLxnOlaNKmXz8WNTfcS9y5ovaSqKo=
gorm.io/driver/sqlite v1.6.0 h1:WHRRrIiulaPiPFmDcod6prc4l2VGVWHz80KspNsxSfQ=
gorm.io/driver/sqlite v1.6.0/go.mod h1:AO9V1qIQddBESngQUKWL9yoH93HIeA1X6V633rBwyT8=
gorm.io/gorm v1.31.2 h1:3o8FXNo9v9S858gil+3LlZA1LkCOzgb4g5BL64FgaCo=
gorm.io/gorm v1.31.2/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs=
+12
View File
@@ -0,0 +1,12 @@
// Package assets 嵌入静态资源(go:embed 不支持 .. 相对路径,统一放本包供各处引用)。
package assets
import _ "embed"
// SourGummyDigits Sour Gummy ExtraBold 的 0-9 数字子集字体(SIL OFL 1.1 开源授权,
// 可免费商用;来源:Google Fonts / 猫啃网),圆润软糖风格,用于图形验证码。
// 经 fonttools pyftsubset 从完整字体(58KB)子集化提取——验证码仅需数字,
// 嵌入体积从 58KB 缩至约 3KB,cmap 中仅保留 U+0030-0039。
//
//go:embed fonts/SourGummy-ExtraBold-Digits.ttf
var SourGummyDigits []byte
@@ -0,0 +1,93 @@
Copyright 2018 The Sour Gummy Project Authors (https://github.com/eifetx/Sour-Gummy-Fonts)
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
https://openfontlicense.org
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
+17
View File
@@ -12,10 +12,19 @@ type Config struct {
Database DatabaseConfig `toml:"database"`
Jwt JwtConfig `toml:"jwt"`
Captcha CaptchaConfig `toml:"captcha"`
Redis RedisConfig `toml:"redis"`
Cpdaily CpdailyConfig `toml:"cpdaily"`
Agent AgentConfig `toml:"agent"`
}
// RedisConfig 验证码等缓存使用的 Redis 配置
type RedisConfig struct {
Host string `toml:"host"`
Port int `toml:"port"`
Password string `toml:"password"`
Db int `toml:"db"`
}
// AgentConfig 代理系统配置
type AgentConfig struct {
SystemCdkPrice float64 `toml:"systemCdkPrice"` // CDK系统底价(元)
@@ -38,6 +47,10 @@ type JwtConfig struct {
Secret string `toml:"secret"`
AccessExpire int `toml:"accessExpire"`
RefreshExpire int `toml:"refreshExpire"`
// MfaAccessExpire MFA 通道登录的短期 token 有效期(秒)。MFA 学校第三方会话无法
// 自动续期(重新登录需人工输入验证码),token 有效期与第三方 Cookie 对齐;独立于
// 普通登录的 accessExpire 便于单独调整(默认 7200 = 2 小时,未配置时自动采用)。
MfaAccessExpire int `toml:"mfaAccessExpire"`
}
type CaptchaConfig struct {
@@ -78,6 +91,10 @@ func Load(path string) error {
if v := os.Getenv("JWT_SECRET"); v != "" {
Global.Jwt.Secret = v
}
// MFA 通道短期 token 有效期默认 2 小时(未配置时采用,部署新版本自动生效)
if Global.Jwt.MfaAccessExpire <= 0 {
Global.Jwt.MfaAccessExpire = 7200
}
return nil
}
+1 -1
View File
@@ -23,7 +23,7 @@ func Init() {
}
// 自动迁移
if err := DB.AutoMigrate(&model.SysUser{}, &model.DeviceBindToken{}, &model.CpTask{}, &model.CpTaskLog{}, &model.AgentUser{}, &model.CDKCode{}, &model.SystemConfig{}, &model.Announcement{}, &model.AnnouncementRead{}, &model.Ticket{}, &model.TicketMessage{}, &model.TicketNotification{}, &model.PendingSchool{}); err != nil {
if err := DB.AutoMigrate(&model.SysUser{}, &model.DeviceBindToken{}, &model.CpTask{}, &model.CpTaskLog{}, &model.CpLeaveLog{}, &model.AgentUser{}, &model.CDKCode{}, &model.SystemConfig{}, &model.Announcement{}, &model.AnnouncementRead{}, &model.Ticket{}, &model.TicketMessage{}, &model.TicketNotification{}, &model.PendingSchool{}); err != nil {
log.Fatalf("自动迁移失败: %v", err)
}
+142 -5
View File
@@ -25,12 +25,25 @@ type SysUserListItem struct {
SignCount int `json:"signCount"`
InviteReward float64 `json:"inviteReward"`
InvitedCount int `json:"invitedCount"`
InviterID *uint `json:"inviterId"` // 邀请人用户ID(null=非邀请注册)
InviterName string `json:"inviterName"` // 邀请人展示名(姓名优先,其次用户名)
TaskCount int64 `json:"taskCount"`
ExecutedCount int64 `json:"executedCount"` // 累计成功执行次数
VipStatus string `json:"vipStatus"` // vip: active / expired / none
VipExpireAt string `json:"vipExpireAt"` // VIP 到期时间,未开通为空
TodayStatus string `json:"todayStatus"` // today: success / fail / none
Status string `json:"status"`
CreatedAt string `json:"createdAt"`
LastLoginAt string `json:"lastLoginAt"`
LastLoginIP string `json:"lastLoginIp"`
}
// inviterDisplay 邀请人展示名:姓名优先,其次用户名
func inviterDisplay(username, realName string) string {
if realName != "" {
return realName
}
return username
}
// ListUsers 获取用户列表(仅admin)
@@ -130,6 +143,26 @@ func ListUsers(c *fiber.Ctx) error {
}
}
// 邀请人信息(本页用户的 inviter_id 去重后一次查询)
inviterNameMap := make(map[uint]string)
if len(users) > 0 {
inviterIDs := make([]uint, 0, len(users))
seen := make(map[uint]bool)
for _, u := range users {
if u.InviterID != nil && !seen[*u.InviterID] {
seen[*u.InviterID] = true
inviterIDs = append(inviterIDs, *u.InviterID)
}
}
if len(inviterIDs) > 0 {
var inviters []model.SysUser
database.DB.Select("id, username, real_name").Where("id IN ?", inviterIDs).Find(&inviters)
for _, iv := range inviters {
inviterNameMap[iv.ID] = inviterDisplay(iv.Username, iv.RealName)
}
}
}
items := make([]SysUserListItem, 0, len(users))
for _, u := range users {
lastLogin := ""
@@ -140,6 +173,21 @@ func ListUsers(c *fiber.Ctx) error {
if todayStatus == "" {
todayStatus = "none"
}
inviterName := ""
if u.InviterID != nil {
inviterName = inviterNameMap[*u.InviterID]
}
// VIP 状态:active=有效期内 / expired=已过期 / none=未开通
vipStatus := "none"
vipExpireAt := ""
if u.VipExpireAt != nil {
vipExpireAt = u.VipExpireAt.Format("2006-01-02 15:04")
if u.VipExpireAt.After(time.Now()) {
vipStatus = "active"
} else {
vipStatus = "expired"
}
}
items = append(items, SysUserListItem{
ID: u.ID,
Username: u.Username,
@@ -152,12 +200,17 @@ func ListUsers(c *fiber.Ctx) error {
SignCount: u.SignCount,
InviteReward: u.InviteReward,
InvitedCount: u.InvitedCount,
InviterID: u.InviterID,
InviterName: inviterName,
TaskCount: taskCountMap[u.ID],
ExecutedCount: executedMap[u.ID],
VipStatus: vipStatus,
VipExpireAt: vipExpireAt,
TodayStatus: todayStatus,
Status: u.Status,
CreatedAt: u.CreatedAt.Format("2006-01-02 15:04:05"),
LastLoginAt: lastLogin,
LastLoginIP: u.LastLoginIP,
})
}
@@ -197,11 +250,14 @@ func GetUserDetail(c *fiber.Ctx) error {
var taskCount int64
database.DB.Model(&model.CpTask{}).Where("user_id = ?", user.ID).Count(&taskCount)
// 统计被邀请人(通过邀请码查找)
var invitees []model.SysUser
database.DB.Where("invite_code = ?", user.InviteCode).Find(&invitees) // 查找使用该用户邀请码注册的人,不对,应该查找谁是被这个用户邀请的
// 实际上我们无法直接知道谁是被这个用户邀请的,因为没有记录"被谁邀请"的字段
// 可以通过统计该用户的 invited_count 来知道邀请人数
// 邀请人信息(被谁邀请)
var inviterName string
if user.InviterID != nil {
var inviter model.SysUser
if err := database.DB.Select("id, username, real_name").First(&inviter, *user.InviterID).Error; err == nil {
inviterName = inviterDisplay(inviter.Username, inviter.RealName)
}
}
lastLogin := ""
if user.LastLoginAt != nil {
@@ -228,6 +284,8 @@ func GetUserDetail(c *fiber.Ctx) error {
"signCount": user.SignCount,
"inviteReward": user.InviteReward,
"invitedCount": user.InvitedCount,
"inviterId": user.InviterID,
"inviterName": inviterName,
"status": user.Status,
"disabledReason": user.DisabledReason,
"taskCount": taskCount,
@@ -235,6 +293,7 @@ func GetUserDetail(c *fiber.Ctx) error {
"mobileType": user.MobileType,
"createdAt": user.CreatedAt.Format("2006-01-02 15:04:05"),
"lastLoginAt": lastLogin,
"lastLoginIp": user.LastLoginIP,
})
}
@@ -281,3 +340,81 @@ func UpdateUserStatus(c *fiber.Ctx) error {
"status": req.Status,
})
}
// AdminTaskLogItem 任务执行日志列表项(后台查看用)
type AdminTaskLogItem struct {
ID uint `json:"id"`
UserID uint `json:"userId"`
Username string `json:"username"`
RealName string `json:"realName"`
SchoolName string `json:"schoolName"`
TaskType string `json:"taskType"`
Title string `json:"title"`
RealTitle string `json:"realTitle"`
IsSuccess bool `json:"isSuccess"`
ErrorMessage string `json:"errorMessage"`
CreatedAt time.Time `json:"createdAt"`
}
// AdminListTaskLogs 任务执行日志列表(仅admin,供"当天执行总次数"卡片点击查看)
// 支持按日期(默认当天)、执行状态、关键字(用户名/姓名/任务标题)筛选,分页返回
func AdminListTaskLogs(c *fiber.Ctx) error {
// 日期(yyyy-MM-dd,默认当天)
date := strings.TrimSpace(c.Query("date", ""))
if date == "" {
date = time.Now().Format("2006-01-02")
}
dayStart, err := time.ParseInLocation("2006-01-02", date, time.Local)
if err != nil {
return utils.Fail(c, 400, "日期格式错误,应为 yyyy-MM-dd")
}
dayEnd := dayStart.AddDate(0, 0, 1)
// 执行状态筛选:all / success / fail
status := c.Query("status", "all")
if status != "all" && status != "success" && status != "fail" {
status = "all"
}
keyword := strings.TrimSpace(c.Query("keyword", ""))
// 分页
page, _ := strconv.Atoi(c.Query("page", "1"))
pageSize, _ := strconv.Atoi(c.Query("pageSize", "20"))
if page < 1 {
page = 1
}
if pageSize < 1 || pageSize > 100 {
pageSize = 20
}
query := database.DB.Table("cp_task_logs").
Joins("LEFT JOIN sys_user ON sys_user.id = cp_task_logs.user_id").
Where("cp_task_logs.created_at >= ? AND cp_task_logs.created_at < ?", dayStart, dayEnd)
if status == "success" {
query = query.Where("cp_task_logs.is_success = 1")
} else if status == "fail" {
query = query.Where("cp_task_logs.is_success = 0")
}
if keyword != "" {
like := "%" + keyword + "%"
query = query.Where("sys_user.username LIKE ? OR sys_user.real_name LIKE ? OR cp_task_logs.title LIKE ? OR cp_task_logs.real_title LIKE ?",
like, like, like, like)
}
var total int64
query.Count(&total)
var items []AdminTaskLogItem
query.Select("cp_task_logs.id, cp_task_logs.user_id, sys_user.username, sys_user.real_name, sys_user.school_name, cp_task_logs.task_type, cp_task_logs.title, cp_task_logs.real_title, cp_task_logs.is_success, cp_task_logs.error_message, cp_task_logs.created_at").
Order("cp_task_logs.created_at DESC").
Offset((page - 1) * pageSize).
Limit(pageSize).
Scan(&items)
return utils.Success(c, fiber.Map{
"items": items,
"total": total,
"page": page,
"pageSize": pageSize,
})
}
+314 -55
View File
@@ -1,13 +1,20 @@
package handler
import (
"cpdaily-new/internal/config"
"cpdaily-new/internal/database"
"cpdaily-new/internal/model"
"cpdaily-new/internal/service"
cpd "cpdaily-new/internal/service/cpdaily"
"cpdaily-new/internal/utils"
"context"
"crypto/rand"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"log/slog"
"net/http"
"strconv"
"strings"
"time"
@@ -17,16 +24,22 @@ import (
"gorm.io/gorm"
)
// GetCaptcha 获取图片验证码
// GetCaptcha 获取图片验证码(令牌桶限流:同 IP 5 分钟最多获取 10 次,防刷屏打爆资源)
func GetCaptcha(c *fiber.Ctx) error {
// 优先取 XFF 真实客户端 IP(openresty 反代受信);直连无 XFF 时回退 socket 源 IP
ip := c.IP()
if ip == "" {
ip = c.Context().RemoteIP().String()
}
ok, wait := service.AllowCaptchaToken(ip)
if !ok {
return utils.Fail(c, 429, fmt.Sprintf("验证码获取过于频繁,请 %s后再试", service.FormatWait(wait)))
}
result, err := service.GenerateCaptcha()
if err != nil {
slog.Error("生成验证码失败", "err", err)
return utils.FailServer(c, "生成验证码失败")
}
// 调试:记录验证码
if code, ok := service.GetCaptchaDebug(result.CaptchaID); ok {
slog.Info("验证码", "captchaId", result.CaptchaID[:8], "code", code)
}
return utils.Success(c, result)
}
@@ -80,7 +93,11 @@ func Login(c *fiber.Ctx) error {
}
// 执行 cpdaily 登录
cookies, err := cpd.CpdailyLogin(c.Context(), school, req.Username, req.Password)
cookies, mfaCtx, err := cpd.CpdailyLogin(c.Context(), school, req.Username, req.Password)
if errors.Is(err, cpd.ErrMFARequired) && mfaCtx != nil {
// 多因子认证:验证码已自动发送,暂存会话并返回 mfaToken 给前端(携带邀请码,注册时同样生效)
return startMFALogin(c, school, req.Username, req.Password, req.InviteCode, mfaCtx)
}
if err != nil {
slog.Error("cpdaily登录失败", "error", err)
return c.Status(401).JSON(utils.Response{
@@ -92,6 +109,107 @@ func Login(c *fiber.Ctx) error {
})
}
// 登录成功:公共处理(MOD_ 检查、用户注册、JWT 签发)
return finalizeLogin(c, school, req.Username, req.Password, cookies, false, req.InviteCode)
}
// MFAVerifyRequest 提交多因子认证验证码
type MFAVerifyRequest struct {
MfaToken string `json:"mfaToken"`
Code string `json:"code"`
}
// MFAVerify 提交多因子认证验证码,完成登录
// 前端在 /auth/login 返回 needMFA=true 后,引导用户输入今日校园收到的验证码并调用本接口
func MFAVerify(c *fiber.Ctx) error {
var req MFAVerifyRequest
if err := c.BodyParser(&req); err != nil || req.MfaToken == "" || req.Code == "" {
return utils.FailParam(c, "请求参数错误")
}
code := strings.TrimSpace(req.Code)
// 读取暂存的 MFA 会话(成功前不删除,允许验证码输错重试;TTL 到期自动失效)
sess, err := loadMFASession(c.Context(), req.MfaToken)
if err != nil {
// HTTP 200 + 专用业务码 4283:前端关闭验证码弹窗并引导重新登录
return utils.RespondWithCode(c, utils.CodeMFAVerifySessionExpired, "验证会话不存在或已过期,请重新登录", nil)
}
school, err := cpd.GetSchoolDetail(sess.SchoolID)
if err != nil {
slog.Error("MFA验证: 获取学校详情失败", "schoolId", sess.SchoolID, "error", err)
return utils.RespondWithCode(c, utils.CodeMFAVerifyFailed, "获取学校信息失败,请稍后重试", nil)
}
// 完成 MFA 认证并走完 CAS 票据交换
cookies, err := cpd.CompleteMFALogin(c.Context(), sess.MFA, code)
if err != nil {
slog.Warn("MFA验证失败", "username", sess.Username, "error", err)
if errors.Is(err, cpd.ErrMFASessionExpired) {
// 会话失效:前端关闭验证码弹窗,引导重新登录
return utils.RespondWithCode(c, utils.CodeMFAVerifySessionExpired, "验证会话已失效,请重新登录", nil)
}
// 验证码错误或其他失败:弹窗保留,可重新输入(业务码 4282)
return utils.RespondWithCode(c, utils.CodeMFAVerifyFailed, "验证失败: "+err.Error(), nil)
}
// 验证成功,清理暂存会话
deleteMFASession(c.Context(), req.MfaToken)
// 短期 JWT:与第三方 Cookie 有效期对齐(MFA 登录无法自动续期)
return finalizeLogin(c, school, sess.Username, sess.Password, cookies, true, sess.InviteCode)
}
// MFASendCodeRequest 发送多因子认证验证码(用户已选定验证方式)
type MFASendCodeRequest struct {
MfaToken string `json:"mfaToken"`
ReAuthType string `json:"reAuthType"` // 用户选择的验证方式(reAuthType 编码:3=短信 5=今日校园 11=邮箱 等)
}
// MFASendCode 按用户选定的验证方式发送验证码
// 前端在 /auth/login 返回 4281(含 authMethods 列表)后,用户选择方式点击「发送验证码」时调用本接口:
// - 成功(code 0):前端进入验证码输入阶段(data 含 mobile/codeTime)
// - 4284:发送失败(弹窗留在选择界面,可重试或更换方式)
// - 4283:验证会话已过期(关闭弹窗引导重新登录)
func MFASendCode(c *fiber.Ctx) error {
var req MFASendCodeRequest
if err := c.BodyParser(&req); err != nil || req.MfaToken == "" {
return utils.FailParam(c, "请求参数错误")
}
sess, err := loadMFASession(c.Context(), req.MfaToken)
if err != nil {
return utils.RespondWithCode(c, utils.CodeMFAVerifySessionExpired, "验证会话不存在或已过期,请重新登录", nil)
}
result, err := cpd.SendMFADynamicCode(c.Context(), sess.MFA, req.ReAuthType)
if err != nil {
slog.Warn("MFA发送验证码失败", "username", sess.Username, "reAuthType", req.ReAuthType, "error", err)
if errors.Is(err, cpd.ErrMFASessionExpired) {
return utils.RespondWithCode(c, utils.CodeMFAVerifySessionExpired, "验证会话已失效,请重新登录", nil)
}
return utils.RespondWithCode(c, utils.CodeMFASendCodeFailed, "发送失败: "+err.Error(), nil)
}
// 用户选择的验证方式已同步到会话(提交 reAuthSubmit.do 需使用同一方式),更新暂存
if err := updateMFASession(c.Context(), req.MfaToken, sess); err != nil {
slog.Warn("MFA会话更新失败", "error", err)
}
msg := result.Message
if result.Mobile != "" && !strings.Contains(msg, result.Mobile) {
msg = fmt.Sprintf("%s,验证码已发送至 %s", msg, result.Mobile)
}
slog.Info("MFA验证码已发送", "username", sess.Username, "reAuthType", req.ReAuthType)
return utils.RespondWithCode(c, 0, msg, fiber.Map{
"mobile": result.Mobile,
"codeTime": result.CodeTime,
})
}
// finalizeLogin 登录成功后的公共处理:MOD_ 检查、Cookie 序列化、查找/注册用户、
// 更新登录信息、签发 JWT。shortToken=true 时签发 MFA 短期 token。
func finalizeLogin(c *fiber.Ctx, school *cpd.SchoolDetailInfo, username, password string, cookies []*http.Cookie, shortToken bool, referralCode string) error {
// 检查 MOD_ cookie
hasMOD := false
for _, cookie := range cookies {
@@ -114,10 +232,10 @@ func Login(c *fiber.Ctx) error {
cookieStr := cpd.SerializeCookies(cookies)
// 构建用户名: 学校简称 + 学号
userName := buildUsername(school, req.Username)
userName := buildUsername(school, username)
// 查找或创建用户
user, err := findOrCreateUser(userName, req.Password, req.SchoolID, school.Name, req.Username, cookieStr, req.InviteCode)
user, isNew, err := findOrCreateUser(userName, password, school.ID, school.Name, username, cookieStr, referralCode)
if err != nil {
slog.Error("用户处理失败", "error", err)
return utils.FailServer(c, "用户处理失败")
@@ -136,30 +254,37 @@ func Login(c *fiber.Ctx) error {
})
}
// 更新 cookie 和最后登录时间
// 更新 cookie 和最后登录时间(含登录 IP)
now := time.Now()
database.DB.Model(&user).Updates(map[string]interface{}{
"cookie": cookieStr,
"cookie": cookieStr,
"last_login_at": now,
"last_login_ip": utils.ClientIP(c),
})
// 保存明文密码(用于 cookie 过期时自动重新登录)
if err := saveRawPassword(user.ID, req.Password); err != nil {
if err := saveRawPassword(user.ID, password); err != nil {
slog.Error("保存密码失败", "userId", user.ID, "error", err)
}
// 尝试从 cpdaily 获取用户主页信息并更新本地数据
go syncUserProfile(user, cookieStr, school.WecloudUrl)
// 生成 JWT
tokenPair, err := service.GenerateTokens(user.ID, user.Username, user.SchoolID)
// 生成 JWT:MFA 通道登录使用与第三方 Cookie 对齐的短期 token
var tokenPair *service.TokenPair
if shortToken {
tokenPair, err = service.GenerateShortTokens(user.ID, user.Username, user.SchoolID, int64(config.Global.Jwt.MfaAccessExpire))
} else {
tokenPair, err = service.GenerateTokens(user.ID, user.Username, user.SchoolID)
}
if err != nil {
return utils.FailServer(c, "生成token失败")
}
return utils.Success(c, fiber.Map{
"user": user,
"token": tokenPair,
"user": user,
"token": tokenPair,
"firstLogin": isNew, // 本次登录是否为新注册账号(前端据此展示首次使用引导)
})
}
@@ -215,6 +340,19 @@ func GetUserInfo(c *fiber.Ctx) error {
return utils.Success(c, user)
}
// GuideCompleteVersion 当前新手引导版本:看过该版本即不再重复展示(数据库记录,跨设备一致)
const GuideCompleteVersion = "v2"
// CompleteGuide 记录用户已完成当前版本新手引导
func CompleteGuide(c *fiber.Ctx) error {
userID := c.Locals("userId").(uint)
if err := database.DB.Model(&model.SysUser{}).Where("id = ?", userID).
Update("guide_version", GuideCompleteVersion).Error; err != nil {
return utils.FailServer(c, "记录失败,请稍后重试")
}
return utils.Success(c, nil)
}
// syncUserProfile 同步 cpdaily 用户信息到数据库
func syncUserProfile(user *model.SysUser, cookieStr string, wecloudUrl string) {
cookieMap := cpd.ParseCookieJSON(cookieStr)
@@ -232,14 +370,14 @@ func syncUserProfile(user *model.SysUser, cookieStr string, wecloudUrl string) {
func syncProfileToUser(profile *cpd.CpdailyUserProfile) map[string]interface{} {
return map[string]interface{}{
"real_name": profile.Name,
"gender": profile.Gender,
"avatar": profile.Img,
"academy": profile.Academy,
"major": profile.Major,
"class_name": profile.ClassName,
"grade": profile.Grade,
"mobile_phone": profile.MobilePhone,
"real_name": profile.Name,
"gender": profile.Gender,
"avatar": profile.Img,
"academy": profile.Academy,
"major": profile.Major,
"class_name": profile.ClassName,
"grade": profile.Grade,
"mobile_phone": profile.MobilePhone,
"org_full_name": profile.OrgFullName,
}
}
@@ -294,14 +432,14 @@ func getRegisterRewardAmount() float64 {
// createSystemAnnouncement 创建系统自动生成的公告(强制弹窗)
func createSystemAnnouncement(userID uint, userType string, title, content string) {
announcement := model.Announcement{
Title: title,
Content: content,
Source: "system",
TargetType: userType,
Title: title,
Content: content,
Source: "system",
TargetType: userType,
TargetUserID: &userID,
ForcePopup: true,
LoopPopup: false,
CreatedBy: 0, // 系统创建
ForcePopup: true,
LoopPopup: false,
CreatedBy: 0, // 系统创建
}
if err := database.DB.Create(&announcement).Error; err != nil {
slog.Error("创建系统公告失败", "error", err, "userId", userID)
@@ -327,21 +465,22 @@ func createInviteNotifications(inviterID, inviteeID uint, inviterReward, invitee
}
// 查找或创建用户
func findOrCreateUser(username, password, schoolID, schoolName, studentID, cookie, referralCode string) (*model.SysUser, error) {
// findOrCreateUser 查找或自动注册用户;返回 (用户, 是否首次注册, 错误)
func findOrCreateUser(username, password, schoolID, schoolName, studentID, cookie, referralCode string) (*model.SysUser, bool, error) {
var user model.SysUser
err := database.DB.Where("username = ?", username).First(&user).Error
if err == nil {
return &user, nil
return &user, false, nil
}
if !errors.Is(err, gorm.ErrRecordNotFound) {
return nil, err
return nil, false, err
}
// 自动注册:生成密码哈希和邀请码
hashedPwd, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil {
return nil, err
return nil, false, err
}
// 为新用户生成一个唯一的邀请码(避免空字符串与 uniqueIndex 冲突)
@@ -360,13 +499,13 @@ func findOrCreateUser(username, password, schoolID, schoolName, studentID, cooki
// 使用事务包裹:用户创建 + 奖励发放,保证原子性
err = database.DB.Transaction(func(tx *gorm.DB) error {
user = model.SysUser{
Username: username,
Password: string(hashedPwd),
SchoolID: schoolID,
SchoolName: schoolName,
StudentID: studentID,
Cookie: cookie,
InviteCode: myInviteCode, // 始终设置邀请码,避免空字符串唯一索引冲突
Username: username,
Password: string(hashedPwd),
SchoolID: schoolID,
SchoolName: schoolName,
StudentID: studentID,
Cookie: cookie,
InviteCode: myInviteCode, // 始终设置邀请码,避免空字符串唯一索引冲突
}
if err := tx.Create(&user).Error; err != nil {
return err
@@ -387,6 +526,9 @@ func findOrCreateUser(username, password, schoolID, schoolName, studentID, cooki
return nil
}
// 记录邀请关系(后台用户管理可展示"被谁邀请")
tx.Model(&user).Update("inviter_id", inviter.ID)
// 给邀请人发放奖励积分 + 增加邀请计数
if inviterReward > 0 {
tx.Model(&inviter).Updates(map[string]interface{}{
@@ -424,7 +566,7 @@ func findOrCreateUser(username, password, schoolID, schoolName, studentID, cooki
if err != nil {
slog.Error("用户创建失败(可能用户名冲突)", "username", username, "error", err)
return nil, err
return nil, false, err
}
// 邀请成功后,在事务外创建系统通知(双方各一条强制弹窗公告)
@@ -441,7 +583,7 @@ func findOrCreateUser(username, password, schoolID, schoolName, studentID, cooki
}
slog.Info("新用户自动注册", "username", username, "school", schoolName, "referralCode", referralCode, "myInviteCode", myInviteCode)
return &user, nil
return &user, true, nil
}
// GenerateMyInviteCode 生成/获取当前用户的邀请码
@@ -456,10 +598,12 @@ func GenerateMyInviteCode(c *fiber.Ctx) error {
// 已有邀请码直接返回
if user.InviteCode != "" {
return utils.Success(c, fiber.Map{
"inviteCode": user.InviteCode,
"inviteReward": user.InviteReward,
"invitedCount": user.InvitedCount,
"canRedeem": user.InviteReward >= 1.0,
"inviteCode": user.InviteCode,
"inviteReward": user.InviteReward,
"invitedCount": user.InvitedCount,
"canRedeem": user.InviteReward >= 1.0,
"inviterRewardPerInvite": getInviterRewardAmount(),
"inviteeRewardPerInvite": getInviteeRewardAmount(),
})
}
@@ -473,10 +617,12 @@ func GenerateMyInviteCode(c *fiber.Ctx) error {
slog.Info("邀请码已生成", "userId", userID, "inviteCode", code)
return utils.Success(c, fiber.Map{
"inviteCode": code,
"inviteReward": 0,
"invitedCount": 0,
"canRedeem": false,
"inviteCode": code,
"inviteReward": 0,
"invitedCount": 0,
"canRedeem": false,
"inviterRewardPerInvite": getInviterRewardAmount(),
"inviteeRewardPerInvite": getInviteeRewardAmount(),
})
}
@@ -489,10 +635,12 @@ func GetMyInviteInfo(c *fiber.Ctx) error {
}
return utils.Success(c, fiber.Map{
"inviteCode": user.InviteCode,
"inviteReward": user.InviteReward,
"invitedCount": user.InvitedCount,
"canRedeem": user.InviteReward >= 1.0,
"inviteCode": user.InviteCode,
"inviteReward": user.InviteReward,
"invitedCount": user.InvitedCount,
"canRedeem": user.InviteReward >= 1.0,
"inviterRewardPerInvite": getInviterRewardAmount(),
"inviteeRewardPerInvite": getInviteeRewardAmount(),
})
}
@@ -533,3 +681,114 @@ func generateRandomCode(n int) string {
}
return string(code)
}
// ========== 多因子认证(MFA)会话暂存 ==========
const (
mfaSessionKeyFmt = "mfa_session:%s"
mfaSessionTTL = 10 * time.Minute
)
// mfaSession Redis 暂存的 MFA 中间会话(Password AES 加密存储)
type mfaSession struct {
SchoolID string `json:"schoolId"`
Username string `json:"username"`
Password string `json:"password"`
InviteCode string `json:"inviteCode,omitempty"` // 注册时携带的邀请码(MFA 通道注册同样生效)
MFA *cpd.MFAContext `json:"mfa"`
}
// startMFALogin 暂存 MFA 会话并返回 mfaToken 与可用验证方式列表,
// 前端引导用户选择验证方式后调用 /auth/login/mfa/send 获取验证码
func startMFALogin(c *fiber.Ctx, school *cpd.SchoolDetailInfo, username, password, inviteCode string, mfaCtx *cpd.MFAContext) error {
if service.Redis == nil {
return utils.FailServer(c, "多因子认证暂不可用(存储服务未就绪),请稍后重试")
}
tokenBytes := make([]byte, 32)
if _, err := rand.Read(tokenBytes); err != nil {
return utils.FailServer(c, "生成验证会话失败")
}
mfaToken := hex.EncodeToString(tokenBytes)
// 密码 AES 加密后暂存,验证成功后用于注册/更新账号
encPassword, err := encryptRawPassword(password)
if err != nil {
return utils.FailServer(c, "生成验证会话失败")
}
data, _ := json.Marshal(mfaSession{
SchoolID: school.ID,
Username: username,
Password: encPassword,
InviteCode: inviteCode,
MFA: mfaCtx,
})
if err := service.Redis.Set(context.Background(), fmt.Sprintf(mfaSessionKeyFmt, mfaToken), data, mfaSessionTTL).Err(); err != nil {
slog.Error("MFA会话暂存失败", "error", err)
return utils.FailServer(c, "生成验证会话失败")
}
slog.Info("MFA会话已创建", "school", school.Name, "username", username,
"defaultType", mfaCtx.ReAuthType, "authMethods", len(mfaCtx.AuthMethods))
// HTTP 200 + 专用业务码 4281:前端按 code 弹出「选择验证方式」弹窗(而非登录失败弹窗)
return utils.RespondWithCode(c, utils.CodeMFARequired, "该校登录需多因子认证,请选择验证方式获取验证码", fiber.Map{
"needMFA": true,
"mfaToken": mfaToken,
"expiresIn": int(mfaSessionTTL.Seconds()),
"authMethods": mfaCtx.AuthMethods,
"defaultType": mfaCtx.ReAuthType,
})
}
// loadMFASession 读取暂存的 MFA 会话(验证码输错可重试,成功后才删除)
func loadMFASession(ctx context.Context, mfaToken string) (*mfaSession, error) {
if service.Redis == nil || mfaToken == "" {
return nil, fmt.Errorf("mfa session unavailable")
}
val, err := service.Redis.Get(ctx, fmt.Sprintf(mfaSessionKeyFmt, mfaToken)).Result()
if err != nil {
return nil, err
}
var sess mfaSession
if err := json.Unmarshal([]byte(val), &sess); err != nil {
return nil, err
}
// 解密密码
raw, err := decryptRawPassword(sess.Password)
if err != nil {
return nil, err
}
sess.Password = raw
return &sess, nil
}
// deleteMFASession 删除暂存的 MFA 会话
func deleteMFASession(ctx context.Context, mfaToken string) {
if service.Redis == nil || mfaToken == "" {
return
}
service.Redis.Del(ctx, fmt.Sprintf(mfaSessionKeyFmt, mfaToken))
}
// updateMFASession 更新暂存的 MFA 会话(如用户切换验证方式后),TTL 顺延
func updateMFASession(ctx context.Context, mfaToken string, sess *mfaSession) error {
if service.Redis == nil || mfaToken == "" || sess == nil {
return fmt.Errorf("mfa session unavailable")
}
encPassword, err := encryptRawPassword(sess.Password)
if err != nil {
return err
}
data, err := json.Marshal(mfaSession{
SchoolID: sess.SchoolID,
Username: sess.Username,
Password: encPassword,
InviteCode: sess.InviteCode,
MFA: sess.MFA,
})
if err != nil {
return err
}
return service.Redis.Set(ctx, fmt.Sprintf(mfaSessionKeyFmt, mfaToken), data, mfaSessionTTL).Err()
}
+7 -2
View File
@@ -88,8 +88,8 @@ func refreshCpdailyCookie(user *model.SysUser) error {
slog.Info("刷新cookie: 尝试重新登录cpdaily", "userId", user.ID, "school", school.Name)
// 执行 cpdaily 登录
cookies, err := cpd.CpdailyLogin(context.Background(), school, fresh.StudentID, rawPassword)
if err != nil {
cookies, mfaCtx, err := cpd.CpdailyLogin(context.Background(), school, fresh.StudentID, rawPassword)
if err != nil && !errors.Is(err, cpd.ErrMFARequired) {
errMsg := err.Error()
slog.Warn("刷新cookie: cpdaily登录失败", "userId", user.ID, "error", errMsg)
@@ -102,6 +102,11 @@ func refreshCpdailyCookie(user *model.SysUser) error {
}
return err
}
// 多因子认证学校:重新登录需人工输入验证码,无法自动续期
if errors.Is(err, cpd.ErrMFARequired) || mfaCtx != nil {
slog.Warn("刷新cookie: 该账号登录需多因子认证,无法自动续期", "userId", user.ID, "school", school.Name)
return errors.New("该账号所属学校开启登录验证码校验,需重新登录完成验证")
}
// 检查 MOD_ cookie 确保登录成功
hasMOD := false
+293 -35
View File
@@ -10,6 +10,7 @@ import (
"log/slog"
"math/rand"
"strconv"
"strings"
"time"
"github.com/gofiber/fiber/v2"
@@ -18,8 +19,10 @@ import (
// GenerateCDKRequest 生成CDK请求
type GenerateCDKRequest struct {
Count int `json:"count"` // 生成数量,默认1
MaxRuns int `json:"maxRuns"` // 每张CDK可运行次数,默认1
Count int `json:"count"` // 生成数量,默认1
MaxRuns int `json:"maxRuns"` // 每张CDK可运行次数(type=count),默认1
Type string `json:"type"` // CDK类型:count=执行次数(默认)/ vip=VIP时长
VipDays int `json:"vipDays"` // VIP天数(type=vip),默认30(一个月)
}
// 获取基础扣费(从系统设置或配置)
@@ -35,6 +38,22 @@ func getBaseDeduction() float64 {
return config.Global.Agent.SystemCdkPrice
}
// getVipCardPrice 获取 VIP 卡价格(元/张,系统设置)
// key: vip_price_week(周卡7天,默认30元)/ vip_price_month(月卡30天,默认100元)
// (单次运行基础扣费 6 元,周卡/月卡按次折算后再给折扣)
func getVipCardPrice(key string, def float64) float64 {
var cfg model.SystemConfig
if database.DB.Where("`key` = ?", key).First(&cfg).Error == nil {
if v, err := strconv.ParseFloat(cfg.Value, 64); err == nil && v > 0 {
return v
}
}
return def
}
func getVipWeekPrice() float64 { return getVipCardPrice("vip_price_week", 30.00) }
func getVipMonthPrice() float64 { return getVipCardPrice("vip_price_month", 100.00) }
// GenerateCDK 生成CDK
func GenerateCDK(c *fiber.Ctx) error {
agentID := c.Locals("adminId").(uint)
@@ -43,32 +62,70 @@ func GenerateCDK(c *fiber.Ctx) error {
if err := c.BodyParser(&req); err != nil {
return utils.FailParam(c, "请求参数错误")
}
// CDK 类型校验(默认次数)
if req.Type == "" {
req.Type = "count"
}
if req.Type != "count" && req.Type != "vip" {
return utils.Fail(c, 400, "CDK类型无效,必须为 count 或 vip")
}
if req.Count <= 0 {
req.Count = 1
}
if req.Count > 100 {
return utils.Fail(c, 400, "单次最多生成100个CDK")
}
if req.MaxRuns <= 0 {
req.MaxRuns = 1
}
if req.MaxRuns > 999 {
return utils.Fail(c, 400, "每张CDK最多999次")
}
// 获取代理信息
var agent model.AgentUser
if err := database.DB.First(&agent, agentID).Error; err != nil {
return utils.Fail(c, 404, "账号不存在")
}
if agent.Status == "disabled" {
return utils.Fail(c, 403, "账号已被禁用")
}
baseDeduction := getBaseDeduction()
cdkPrice := baseDeduction * float64(req.MaxRuns) * agent.Rate
costTotal := baseDeduction * float64(req.MaxRuns) * agent.Rate * float64(req.Count)
var maxRuns int
var vipDays int
var unitPrice float64 // 单张CDK代理扣费
var costPerCDK float64
if req.Type == "vip" {
// VIP CDK:仅支持周卡(7天)/ 月卡(30天)两档
if req.VipDays <= 0 {
req.VipDays = 30
}
if req.VipDays != 7 && req.VipDays != 30 {
return utils.Fail(c, 400, "VIP CDK 仅支持 7 天(周卡)或 30 天(月卡)")
}
maxRuns = 0
vipDays = req.VipDays
var vipPrice float64
if req.VipDays == 7 {
vipPrice = getVipWeekPrice()
} else {
vipPrice = getVipMonthPrice()
}
unitPrice = vipPrice * agent.Rate
costPerCDK = vipPrice
} else {
// 次数 CDK
if req.MaxRuns <= 0 {
req.MaxRuns = 1
}
if req.MaxRuns > 999 {
return utils.Fail(c, 400, "每张CDK最多999次")
}
maxRuns = req.MaxRuns
vipDays = 0
unitPrice = baseDeduction * float64(req.MaxRuns) * agent.Rate
costPerCDK = baseDeduction * float64(req.MaxRuns)
}
cdkPrice := unitPrice
costTotal := unitPrice * float64(req.Count)
// 事务:扣余额 + 生成CDK
cdkCodes := make([]model.CDKCode, 0, req.Count)
@@ -89,9 +146,11 @@ func GenerateCDK(c *fiber.Ctx) error {
cdk := model.CDKCode{
Code: code,
Price: cdkPrice,
CostPrice: baseDeduction * float64(req.MaxRuns),
CostPrice: costPerCDK,
AgentID: agentID,
MaxRuns: req.MaxRuns,
MaxRuns: maxRuns,
Type: req.Type,
VipDays: vipDays,
Status: "unused",
CreatedAt: now,
UpdatedAt: now,
@@ -123,22 +182,154 @@ func GenerateCDK(c *fiber.Ctx) error {
})
}
// ListCDKs 获取自己生成的CDK列表
// ListCDKs 获取自己生成的CDK列表(分页)
func ListCDKs(c *fiber.Ctx) error {
agentID := c.Locals("adminId").(uint)
var cdks []model.CDKCode
database.DB.Where("agent_id = ?", agentID).Order("created_at DESC").Limit(100).Find(&cdks)
return utils.Success(c, cdks)
return listCDKs(c, "agent", agentID)
}
// ListAllCDKs 获取所有CDK列表(仅admin)
func ListAllCDKs(c *fiber.Ctx) error {
var cdks []model.CDKCode
database.DB.Preload("Agent").Order("created_at DESC").Limit(200).Find(&cdks)
return listCDKs(c, "all", 0)
}
return utils.Success(c, cdks)
// CDKSummary CDK 状态汇总
type CDKSummary struct {
Total int64 `json:"total"`
Unused int64 `json:"unused"`
Used int64 `json:"used"`
Disabled int64 `json:"disabled"`
}
// listCDKs 分页查询CDK(scope: agent=自己 / all=全部)
func listCDKs(c *fiber.Ctx, scope string, agentID uint) error {
// 分页
page, _ := strconv.Atoi(c.Query("page", "1"))
pageSize, _ := strconv.Atoi(c.Query("pageSize", "20"))
if page < 1 {
page = 1
}
if pageSize < 1 || pageSize > 100 {
pageSize = 20
}
offset := (page - 1) * pageSize
// 筛选:状态 / CDK码关键字
status := strings.TrimSpace(c.Query("status", ""))
keyword := strings.TrimSpace(c.Query("keyword", ""))
base := func() *gorm.DB {
q := database.DB.Model(&model.CDKCode{})
if scope == "agent" {
q = q.Where("agent_id = ?", agentID)
}
return q
}
countQuery := base()
if status != "" {
countQuery = countQuery.Where("status = ?", status)
}
if keyword != "" {
countQuery = countQuery.Where("code LIKE ?", "%"+keyword+"%")
}
var total int64
countQuery.Count(&total)
// 状态汇总(不受筛选影响,按当前可见范围统计)
summary := CDKSummary{}
for _, s := range []string{"unused", "used", "disabled"} {
var n int64
base().Where("status = ?", s).Count(&n)
switch s {
case "unused":
summary.Unused = n
case "used":
summary.Used = n
case "disabled":
summary.Disabled = n
}
}
summary.Total = summary.Unused + summary.Used + summary.Disabled
// 数据
var cdks []model.CDKCode
dataQuery := base()
if status != "" {
dataQuery = dataQuery.Where("status = ?", status)
}
if keyword != "" {
dataQuery = dataQuery.Where("code LIKE ?", "%"+keyword+"%")
}
dataQuery.Order("created_at DESC").Offset(offset).Limit(pageSize)
if scope == "all" {
dataQuery = dataQuery.Preload("Agent")
}
dataQuery.Find(&cdks)
return utils.Success(c, fiber.Map{
"total": total,
"items": cdks,
"page": page,
"pageSize": pageSize,
"summary": summary,
})
}
// UpdateCDKStatusRequest 禁用/恢复CDK请求
type UpdateCDKStatusRequest struct {
Action string `json:"action"` // disable / enable
}
// UpdateCDKStatus 禁用/恢复CDK(代理仅能操作自己生成的,admin可操作全部)
func UpdateCDKStatus(c *fiber.Ctx) error {
cdkID := c.Params("id")
adminID := c.Locals("adminId").(uint)
role, _ := c.Locals("adminRole").(string)
var req UpdateCDKStatusRequest
if err := c.BodyParser(&req); err != nil {
return utils.FailParam(c, "请求参数错误")
}
if req.Action != "disable" && req.Action != "enable" {
return utils.Fail(c, 400, "操作无效,必须为 disable 或 enable")
}
var cdk model.CDKCode
if err := database.DB.First(&cdk, cdkID).Error; err != nil {
return utils.Fail(c, 404, "CDK不存在")
}
// 权限:非 admin 只能操作自己生成的 CDK
if role != "admin" && cdk.AgentID != adminID {
return utils.Fail(c, 403, "无权操作该CDK")
}
if req.Action == "disable" {
if cdk.Status == "disabled" {
return utils.Fail(c, 400, "该CDK已是禁用状态")
}
if cdk.Status != "unused" {
return utils.Fail(c, 400, "仅未使用的CDK可以禁用")
}
if err := database.DB.Model(&cdk).Update("status", "disabled").Error; err != nil {
slog.Error("禁用CDK失败", "error", err, "cdkId", cdk.ID)
return utils.FailServer(c, "操作失败")
}
slog.Info("禁用CDK成功", "cdkId", cdk.ID, "code", cdk.Code, "adminId", adminID)
return utils.Success(c, fiber.Map{"id": cdk.ID, "status": "disabled"})
}
// enable:仅 disabled 可恢复为 unused
if cdk.Status != "disabled" {
return utils.Fail(c, 400, "仅已禁用的CDK可以恢复")
}
if err := database.DB.Model(&cdk).Update("status", "unused").Error; err != nil {
slog.Error("恢复CDK失败", "error", err, "cdkId", cdk.ID)
return utils.FailServer(c, "操作失败")
}
slog.Info("恢复CDK成功", "cdkId", cdk.ID, "code", cdk.Code, "adminId", adminID)
return utils.Success(c, fiber.Map{"id": cdk.ID, "status": "unused"})
}
// UserRedeemCDKRequest 用户兑换CDK请求
@@ -178,6 +369,9 @@ func UserRedeemCDK(c *fiber.Ctx) error {
}
// 使用行锁(悲观锁)确保并发安全
var vipExpireAt time.Time
redeemType := cdk.Type
redeemRuns := cdk.MaxRuns
err = database.DB.Transaction(func(tx *gorm.DB) error {
// 重新查询并锁定该行
var lockedCDK model.CDKCode
@@ -187,28 +381,54 @@ func UserRedeemCDK(c *fiber.Ctx) error {
if lockedCDK.Status != "unused" {
return errors.New("CDK码已被使用")
}
if lockedCDK.MaxRuns <= 0 {
return errors.New("CDK码已无可运行次数")
}
now := time.Now()
// 一次性消耗全部次数
totalRuns := lockedCDK.MaxRuns
updates := map[string]interface{}{
"status": "used",
"user_id": userID,
"used_at": now,
}
if lockedCDK.Type == "vip" {
// ===== VIP CDK:延长 VIP 到期时间 =====
if lockedCDK.VipDays <= 0 {
return errors.New("VIP CDK配置无效")
}
// 锁定用户行,防止并发兑换导致到期时间计算错误
var lockedUser model.SysUser
if err := tx.Set("gorm:query_option", "FOR UPDATE").First(&lockedUser, userID).Error; err != nil {
return err
}
// 已开通且未过期:在原到期时间上顺延;否则从当前时间起算
base := now
if lockedUser.VipExpireAt != nil && lockedUser.VipExpireAt.After(base) {
base = *lockedUser.VipExpireAt
}
vipExpireAt = base.AddDate(0, 0, lockedCDK.VipDays)
if err := tx.Model(&model.SysUser{}).Where("id = ?", userID).
Update("vip_expire_at", vipExpireAt).Error; err != nil {
return err
}
} else {
// ===== 次数 CDK:一次性消耗全部次数 =====
if lockedCDK.MaxRuns <= 0 {
return errors.New("CDK码已无可运行次数")
}
redeemRuns = lockedCDK.MaxRuns
if err := tx.Model(&lockedCDK).Updates(updates).Error; err != nil {
return err
}
// 增加用户签到次数(一次性加上所有剩余次数)
if err := tx.Model(&model.SysUser{}).Where("id = ?", userID).
Update("sign_count", gorm.Expr("sign_count + ?", redeemRuns)).Error; err != nil {
return err
}
return nil
}
if err := tx.Model(&lockedCDK).Updates(updates).Error; err != nil {
return err
}
// 增加用户签到次数(一次性加上所有剩余次数)
if err := tx.Model(&model.SysUser{}).Where("id = ?", userID).
Update("sign_count", gorm.Expr("sign_count + ?", totalRuns)).Error; err != nil {
return err
}
return nil
})
@@ -219,15 +439,30 @@ func UserRedeemCDK(c *fiber.Ctx) error {
if err.Error() == "CDK码已无可运行次数" {
return utils.Fail(c, 400, "CDK码已无可运行次数")
}
if err.Error() == "VIP CDK配置无效" {
return utils.Fail(c, 400, "VIP CDK配置无效,请联系管理员")
}
slog.Error("兑换CDK失败", "error", err)
return utils.FailServer(c, "兑换失败")
}
// 按 CDK 类型返回不同提示
if redeemType == "vip" {
slog.Info("VIP CDK兑换成功", "userId", userID, "code", req.Code, "vipDays", cdk.VipDays, "newExpireAt", vipExpireAt.Format("2006-01-02 15:04:05"))
return utils.Success(c, fiber.Map{
"message": "VIP兑换成功",
"type": "vip",
"vipDays": cdk.VipDays,
"vipExpireAt": vipExpireAt.Format("2006-01-02 15:04:05"),
})
}
slog.Info("CDK兑换成功", "userId", userID, "code", req.Code)
return utils.Success(c, fiber.Map{
"message": "兑换成功",
"totalRuns": cdk.MaxRuns,
"type": "count",
"totalRuns": redeemRuns,
})
}
@@ -246,3 +481,26 @@ func generateCDKCode() string {
func formatFloat(f float64) string {
return utils.FormatFloat(f)
}
// GetUserVipInfo 获取当前用户 VIP 信息(到期时间 + 售价,供个人中心展示)
func GetUserVipInfo(c *fiber.Ctx) error {
userID := c.Locals("userId").(uint)
var user model.SysUser
if err := database.DB.Select("id, vip_expire_at").First(&user, userID).Error; err != nil {
return utils.FailAuth(c, "用户不存在")
}
vipExpireAt := ""
if user.VipExpireAt != nil {
vipExpireAt = user.VipExpireAt.Format("2006-01-02 15:04:05")
}
isVip := user.VipExpireAt != nil && user.VipExpireAt.After(time.Now())
return utils.Success(c, fiber.Map{
"isVip": isVip,
"vipExpireAt": vipExpireAt,
"vipWeekPrice": formatFloat(getVipWeekPrice()),
"vipMonthPrice": formatFloat(getVipMonthPrice()),
"vipPrice": formatFloat(getVipMonthPrice()), // 兼容旧字段:月卡价
})
}
+26
View File
@@ -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")
+345
View File
@@ -0,0 +1,345 @@
package handler
import (
"bufio"
"encoding/json"
"errors"
"fmt"
"log/slog"
"strings"
"time"
"cpdaily-new/internal/database"
"cpdaily-new/internal/model"
"cpdaily-new/internal/ratelimit"
cpd "cpdaily-new/internal/service/cpdaily"
"cpdaily-new/internal/utils"
"github.com/gofiber/fiber/v2"
"gorm.io/gorm"
)
// ---------- 销假(请假销假)----------
// GetPendingLeaves 获取今日校园待销假列表(实时请求今日校园,附带规定销假位置)
func GetPendingLeaves(c *fiber.Ctx) error {
userID := c.Locals("userId").(uint)
var user model.SysUser
if err := database.DB.First(&user, userID).Error; err != nil {
return utils.FailAuth(c, "用户不存在")
}
if user.Cookie == "" {
return utils.Fail(c, 400, "请先登录")
}
cookieMap := cpd.ParseCookieJSON(user.Cookie)
host := getSchoolHost(user.SchoolID)
items, err := cpd.FetchLeaveList(cookieMap, host)
if err != nil && isCookieExpired(err) {
slog.Info("获取待销假列表cookie过期,尝试刷新", "userId", userID)
if refreshErr := refreshCpdailyCookie(&user); refreshErr == nil {
cookieMap = cpd.ParseCookieJSON(user.Cookie)
items, err = cpd.FetchLeaveList(cookieMap, host)
} else if errors.Is(refreshErr, ErrPasswordChanged) {
err = ErrPasswordChanged
}
}
if err != nil {
slog.Error("获取待销假列表失败", "userId", userID, "error", err)
if errors.Is(err, ErrPasswordChanged) {
return utils.Fail(c, 401, "登录凭证已过期,请重新登录")
}
return utils.Fail(c, 503, "获取待销假列表失败: "+err.Error())
}
return utils.Success(c, items)
}
// ExecuteLeaveRequest 销假执行参数
type ExecuteLeaveRequest struct {
LeaveID string `json:"leaveId"` // 今日校园请假记录 ID
Longitude string `json:"longitude"` // 销假经度(必填)
Latitude string `json:"latitude"` // 销假纬度(必填)
Address string `json:"address"` // 销假地址(无规定位置时必填)
ImageURL string `json:"imageUrl"` // 销假图片 URL(可选)
ApproverWid string `json:"approverWid"` // 审批人 userWid(reportApplyFlow=1 时为必填)
}
// ExecuteLeave 执行销假(SSE 同步执行:POST 请求 + 流式事件返回进度与结果)。
// 事件流:connecting → fetching_detail → submitting → success / task_error
func ExecuteLeave(c *fiber.Ctx) error {
userID := c.Locals("userId").(uint)
var req ExecuteLeaveRequest
if err := c.BodyParser(&req); err != nil {
return utils.FailParam(c, "请求参数错误")
}
req.LeaveID = strings.TrimSpace(req.LeaveID)
req.Longitude = strings.TrimSpace(req.Longitude)
req.Latitude = strings.TrimSpace(req.Latitude)
req.Address = strings.TrimSpace(req.Address)
req.ApproverWid = strings.TrimSpace(req.ApproverWid)
if req.LeaveID == "" || req.Longitude == "" || req.Latitude == "" {
return utils.FailParam(c, "销假经纬度不能为空")
}
var user model.SysUser
if err := database.DB.First(&user, userID).Error; err != nil {
return utils.FailAuth(c, "用户不存在")
}
if user.Cookie == "" {
return utils.Fail(c, 400, "请先登录")
}
// SSE 头
c.Set("Content-Type", "text/event-stream")
c.Set("Cache-Control", "no-cache")
c.Set("Connection", "keep-alive")
c.Set("X-Accel-Buffering", "no")
// SSE 写入器:所有检查在流内进行,通过 SSE event 返回结果
c.Context().SetBodyStreamWriter(func(w *bufio.Writer) {
sendEvent := func(event string, data map[string]interface{}) {
d, _ := json.Marshal(data)
fmt.Fprintf(w, "event: %s\ndata: %s\n\n", event, string(d))
w.Flush()
}
// 保存销假日志
saveLog := func(isSuccess bool, errMsg, title string) {
logEntry := model.CpLeaveLog{
UserID: userID,
LeaveID: req.LeaveID,
Title: title,
SchoolName: user.SchoolName,
Action: model.LeaveActionReport,
IsSuccess: isSuccess,
ErrorMessage: errMsg,
}
database.DB.Create(&logEntry)
}
// VIP 判定:VIP 且未过期则免扣次数直接执行
isVip := user.VipExpireAt != nil && user.VipExpireAt.After(time.Now())
// 检查可用次数(VIP 免检查)
if !isVip && user.SignCount <= 0 {
slog.Warn("销假次数不足,拒绝执行", "userId", userID)
sendEvent("task_error", map[string]interface{}{"message": "可用次数不足,请购买CDK或通过邀请获得次数"})
return
}
// 限流:同一用户(销假为同步执行,无任务维度,按用户维度限流)
rateKey := fmt.Sprintf("leave:%d", userID)
rules := []ratelimit.Rule{
{Limit: 3, Window: 2 * time.Minute, Label: "2m"},
{Limit: 6, Window: 1 * time.Hour, Label: "1h"},
{Limit: 10, Window: 24 * time.Hour, Label: "1d"},
}
if ok, msg := ratelimit.AllowMulti(rateKey, rules); !ok {
slog.Warn("销假触发限流", "userId", userID, "msg", msg)
sendEvent("task_error", map[string]interface{}{"message": msg, "rateLimit": true})
return
}
// 1. 连接成功
sendEvent("connecting", map[string]interface{}{"status": "connecting", "message": "正在连接今日校园..."})
cookieMap := cpd.ParseCookieJSON(user.Cookie)
host := getSchoolHost(user.SchoolID)
// 2. 获取请假详情
sendEvent("fetching_detail", map[string]interface{}{"status": "fetching_detail", "message": "正在获取请假详情..."})
detail, err := cpd.FetchLeaveDetail(cookieMap, host, req.LeaveID)
if err != nil && isCookieExpired(err) {
slog.Info("获取请假详情cookie过期,尝试自动刷新", "userId", userID, "leaveId", req.LeaveID)
if refreshErr := refreshCpdailyCookie(&user); refreshErr == nil {
cookieMap = cpd.ParseCookieJSON(user.Cookie)
detail, err = cpd.FetchLeaveDetail(cookieMap, host, req.LeaveID)
} else if errors.Is(refreshErr, ErrPasswordChanged) {
err = ErrPasswordChanged
}
}
if err != nil {
errMsg := "获取请假详情失败: " + err.Error()
if errors.Is(err, ErrPasswordChanged) {
errMsg = "登录凭证已过期,请重新登录"
}
slog.Error("获取请假详情失败", "userId", userID, "leaveId", req.LeaveID, "error", err)
saveLog(false, errMsg, leaveLogTitle(detail, ""))
sendEvent("task_error", map[string]interface{}{"status": "error", "message": errMsg, "needReLogin": errors.Is(err, ErrPasswordChanged)})
return
}
title := leaveLogTitle(detail, req.LeaveID)
// 销假地址:用户选择/填写的优先,否则兜底第一个规定销假位置地址;两者皆空时阻止提交
if req.Address == "" && len(detail.ReportPlaces) == 0 {
errMsg := "该请假未设置规定销假位置,请填写销假地址"
saveLog(false, errMsg, title)
sendEvent("task_error", map[string]interface{}{"status": "error", "message": errMsg})
return
}
// 审批人:请假类型要求销假审批(reportApplyFlow=1)时必须选择审批人
if detail.ReportApplyFlow && req.ApproverWid == "" {
errMsg := "该请假类型销假需要选择审批人,请重新选择"
saveLog(false, errMsg, title)
sendEvent("task_error", map[string]interface{}{"status": "error", "message": errMsg, "needApprover": true})
return
}
// 3. 构造表单并提交销假
sendEvent("submitting", map[string]interface{}{"status": "submitting", "message": "正在提交销假..."})
form := cpd.BuildLeaveReportForm(detail, req.Address, req.ImageURL, req.ApproverWid)
err = cpd.SubmitLeaveReport(cookieMap, host, form, req.Longitude, req.Latitude, user.MobileCode, user.StudentID)
if err != nil && isCookieExpired(err) {
slog.Info("提交销假cookie过期,尝试自动刷新", "userId", userID, "leaveId", req.LeaveID)
if refreshErr := refreshCpdailyCookie(&user); refreshErr == nil {
cookieMap = cpd.ParseCookieJSON(user.Cookie)
err = cpd.SubmitLeaveReport(cookieMap, host, form, req.Longitude, req.Latitude, user.MobileCode, user.StudentID)
} else if errors.Is(refreshErr, ErrPasswordChanged) {
err = ErrPasswordChanged
}
}
if err != nil {
errMsg := "销假失败: " + err.Error()
if errors.Is(err, ErrPasswordChanged) {
errMsg = "登录凭证已过期,请重新登录"
}
slog.Error("销假失败", "userId", userID, "leaveId", req.LeaveID, "error", err)
saveLog(false, errMsg, title)
sendEvent("task_error", map[string]interface{}{"status": "error", "message": errMsg, "needReLogin": errors.Is(err, ErrPasswordChanged)})
return
}
// 4. 成功:写日志 + 扣减可用次数(VIP 有效期内免扣)
saveLog(true, "", title)
if !isVip {
database.DB.Model(&model.SysUser{}).Where("id = ? AND sign_count > 0", userID).
Update("sign_count", gorm.Expr("sign_count - 1"))
sendEvent("success", map[string]interface{}{
"status": "success",
"message": "销假成功!",
})
} else {
sendEvent("success", map[string]interface{}{
"status": "success",
"message": "销假成功!(VIP 免扣次数)",
})
}
slog.Info("销假成功", "userId", userID, "leaveId", req.LeaveID, "title", title, "isVip", isVip)
})
return nil
}
// GetLeaveApprovers 获取销假可选审批人(leaveType.reportApplyFlow=1 的请假类型)。
// 请求:{ leaveId };服务端自行取详情中的 leaveType/起止时间,前端无需重复传递。
func GetLeaveApprovers(c *fiber.Ctx) error {
userID := c.Locals("userId").(uint)
var req struct {
LeaveID string `json:"leaveId"`
}
if err := c.BodyParser(&req); err != nil {
return utils.FailParam(c, "请求参数错误")
}
req.LeaveID = strings.TrimSpace(req.LeaveID)
if req.LeaveID == "" {
return utils.FailParam(c, "leaveId 不能为空")
}
var user model.SysUser
if err := database.DB.First(&user, userID).Error; err != nil {
return utils.FailAuth(c, "用户不存在")
}
if user.Cookie == "" {
return utils.Fail(c, 400, "请先登录")
}
cookieMap := cpd.ParseCookieJSON(user.Cookie)
host := getSchoolHost(user.SchoolID)
detail, err := cpd.FetchLeaveDetail(cookieMap, host, req.LeaveID)
if err != nil && isCookieExpired(err) {
if refreshErr := refreshCpdailyCookie(&user); refreshErr == nil {
cookieMap = cpd.ParseCookieJSON(user.Cookie)
detail, err = cpd.FetchLeaveDetail(cookieMap, host, req.LeaveID)
} else if errors.Is(refreshErr, ErrPasswordChanged) {
err = ErrPasswordChanged
}
}
if err != nil {
slog.Error("获取请假详情失败(审批人)", "userId", userID, "leaveId", req.LeaveID, "error", err)
if errors.Is(err, ErrPasswordChanged) {
return utils.Fail(c, 401, "登录凭证已过期,请重新登录")
}
return utils.Fail(c, 503, "获取请假详情失败: "+err.Error())
}
if !detail.ReportApplyFlow {
return utils.Success(c, []cpd.LeaveApprover{})
}
approvers, err := cpd.FetchReportApprovers(cookieMap, host, detail.LeaveCodeNum, detail.StartTime, detail.EndTime)
if err != nil && isCookieExpired(err) {
if refreshErr := refreshCpdailyCookie(&user); refreshErr == nil {
cookieMap = cpd.ParseCookieJSON(user.Cookie)
approvers, err = cpd.FetchReportApprovers(cookieMap, host, detail.LeaveCodeNum, detail.StartTime, detail.EndTime)
} else if errors.Is(refreshErr, ErrPasswordChanged) {
err = ErrPasswordChanged
}
}
if err != nil {
slog.Error("获取销假审批人失败", "userId", userID, "leaveId", req.LeaveID, "error", err)
if errors.Is(err, ErrPasswordChanged) {
return utils.Fail(c, 401, "登录凭证已过期,请重新登录")
}
return utils.Fail(c, 503, "获取销假审批人失败: "+err.Error())
}
return utils.Success(c, approvers)
}
// leaveLogTitle 销假日志标题:请假类型名 + "销假";类型名缺失时兜底用 leaveId 前缀避免标题为空
func leaveLogTitle(detail *cpd.LeaveDetail, leaveID string) string {
if detail != nil && detail.LeaveType != "" {
return detail.LeaveType + "销假"
}
if leaveID != "" {
return "销假(" + leaveID + ")"
}
return "销假"
}
// ListLeaveLogs 获取销假日志列表
func ListLeaveLogs(c *fiber.Ctx) error {
userID := c.Locals("userId").(uint)
var logs []model.CpLeaveLog
database.DB.Where("user_id = ?", userID).
Order("created_at DESC").
Limit(10).
Find(&logs)
return utils.Success(c, logs)
}
// DeleteLeaveLog 删除销假日志
func DeleteLeaveLog(c *fiber.Ctx) error {
userID := c.Locals("userId").(uint)
id := c.Params("id")
var log model.CpLeaveLog
if err := database.DB.Where("id = ? AND user_id = ?", id, userID).First(&log).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return utils.Fail(c, 404, "日志不存在")
}
return utils.FailServer(c, "查询失败")
}
database.DB.Delete(&log)
return utils.Success(c, nil)
}
+354
View File
@@ -0,0 +1,354 @@
package handler
import (
"bufio"
"encoding/json"
"errors"
"fmt"
"log/slog"
"strconv"
"strings"
"time"
"cpdaily-new/internal/database"
"cpdaily-new/internal/model"
"cpdaily-new/internal/ratelimit"
cpd "cpdaily-new/internal/service/cpdaily"
"cpdaily-new/internal/utils"
"github.com/gofiber/fiber/v2"
"gorm.io/gorm"
)
// ---------- 请假申请 ----------
// loadLeaveUser 读取当前用户(请假链路公共前置)
func loadLeaveUser(c *fiber.Ctx) (*model.SysUser, error) {
userID := c.Locals("userId").(uint)
var user model.SysUser
if err := database.DB.First(&user, userID).Error; err != nil {
return nil, fmt.Errorf("用户不存在")
}
if user.Cookie == "" {
return nil, fmt.Errorf("请先登录")
}
return &user, nil
}
// GetLeaveTypes 获取请假类型列表(含全部权威开关,前端据此渲染表单)
func GetLeaveTypes(c *fiber.Ctx) error {
user, err := loadLeaveUser(c)
if err != nil {
return utils.FailAuth(c, err.Error())
}
cookieMap := cpd.ParseCookieJSON(user.Cookie)
host := getSchoolHost(user.SchoolID)
types, err := cpd.FetchLeaveTypes(cookieMap, host)
if err != nil && isCookieExpired(err) {
if refreshErr := refreshCpdailyCookie(user); refreshErr == nil {
cookieMap = cpd.ParseCookieJSON(user.Cookie)
types, err = cpd.FetchLeaveTypes(cookieMap, host)
} else if errors.Is(refreshErr, ErrPasswordChanged) {
err = ErrPasswordChanged
}
}
if err != nil {
slog.Error("获取请假类型失败", "userId", user.ID, "error", err)
if errors.Is(err, ErrPasswordChanged) {
return utils.Fail(c, 401, "登录凭证已过期,请重新登录")
}
return utils.Fail(c, 503, "获取请假类型失败: "+err.Error())
}
return utils.Success(c, types)
}
// GetLeaveApplyApprovers 获取请假可选审批人(needApproval=1 的类型)。
// 请求:{ leaveType, startTime, endTime }(与销假审批人接口区分:此处选择类型发生在提交前)。
func GetLeaveApplyApprovers(c *fiber.Ctx) error {
user, err := loadLeaveUser(c)
if err != nil {
return utils.FailAuth(c, err.Error())
}
var req struct {
LeaveType int `json:"leaveType"`
StartTime string `json:"startTime"`
EndTime string `json:"endTime"`
}
if err := c.BodyParser(&req); err != nil {
return utils.FailParam(c, "请求参数错误")
}
if req.StartTime == "" || req.EndTime == "" {
return utils.FailParam(c, "开始/结束时间不能为空")
}
cookieMap := cpd.ParseCookieJSON(user.Cookie)
host := getSchoolHost(user.SchoolID)
approvers, err := cpd.FetchReportApprovers(cookieMap, host, req.LeaveType, req.StartTime, req.EndTime)
if err != nil && isCookieExpired(err) {
if refreshErr := refreshCpdailyCookie(user); refreshErr == nil {
cookieMap = cpd.ParseCookieJSON(user.Cookie)
approvers, err = cpd.FetchReportApprovers(cookieMap, host, req.LeaveType, req.StartTime, req.EndTime)
} else if errors.Is(refreshErr, ErrPasswordChanged) {
err = ErrPasswordChanged
}
}
if err != nil {
slog.Error("获取请假审批人失败", "userId", user.ID, "leaveType", req.LeaveType, "error", err)
if errors.Is(err, ErrPasswordChanged) {
return utils.Fail(c, 401, "登录凭证已过期,请重新登录")
}
return utils.Fail(c, 503, "获取请假审批人失败: "+err.Error())
}
return utils.Success(c, approvers)
}
// ExecuteLeaveApplyRequest 请假申请执行参数
type ExecuteLeaveApplyRequest struct {
LeaveType int `json:"leaveType"` // 类型编码
StartTime string `json:"startTime"` // yyyy-MM-dd HH:mm:ss
EndTime string `json:"endTime"` // yyyy-MM-dd HH:mm:ss
LeaveReason string `json:"leaveReason"` // 请假原因
OutStatus bool `json:"outStatus"` // 是否离校
UrgencyMobile string `json:"urgencyMobile"` // 紧急联系人(离校必填)
Address string `json:"address"` // 目的地(离校必填)
DetailAddress string `json:"detailAddress"` // 详细地址(离校必填)
Longitude string `json:"longitude"` // 定位经度
Latitude string `json:"latitude"` // 定位纬度
LocationAddr string `json:"locationAddr"` // 定位地址
ImageURL string `json:"imageUrl"` // 附件(可选)
ApproverWid string `json:"approverWid"` // 审批人 userWid
ApproverId string `json:"approverId"` // 审批人 userId
ApproverName string `json:"approverName"` // 审批人姓名
}
// ExecuteLeaveApply 执行请假申请(SSE 同步执行:checkOnceLeave → 提交)。
// 事件流:connecting → submitting → success / task_error
func ExecuteLeaveApply(c *fiber.Ctx) error {
userID := c.Locals("userId").(uint)
var req ExecuteLeaveApplyRequest
if err := c.BodyParser(&req); err != nil {
return utils.FailParam(c, "请求参数错误")
}
req.StartTime = strings.TrimSpace(req.StartTime)
req.EndTime = strings.TrimSpace(req.EndTime)
req.LeaveReason = strings.TrimSpace(req.LeaveReason)
req.Longitude = strings.TrimSpace(req.Longitude)
req.Latitude = strings.TrimSpace(req.Latitude)
req.LocationAddr = strings.TrimSpace(req.LocationAddr)
req.ApproverWid = strings.TrimSpace(req.ApproverWid)
if req.StartTime == "" || req.EndTime == "" {
return utils.FailParam(c, "开始/结束时间不能为空")
}
if req.LeaveReason == "" {
return utils.FailParam(c, "请假原因不能为空")
}
if req.Longitude == "" || req.Latitude == "" {
return utils.FailParam(c, "定位经纬度不能为空")
}
if req.LocationAddr == "" {
return utils.FailParam(c, "定位地址不能为空")
}
if req.OutStatus {
if strings.TrimSpace(req.UrgencyMobile) == "" {
return utils.FailParam(c, "紧急联系人手机号不能为空")
}
if strings.TrimSpace(req.Address) == "" {
return utils.FailParam(c, "目的地不能为空")
}
if strings.TrimSpace(req.DetailAddress) == "" {
return utils.FailParam(c, "详细地址不能为空")
}
}
var user model.SysUser
if err := database.DB.First(&user, userID).Error; err != nil {
return utils.FailAuth(c, "用户不存在")
}
if user.Cookie == "" {
return utils.Fail(c, 400, "请先登录")
}
// SSE 头
c.Set("Content-Type", "text/event-stream")
c.Set("Cache-Control", "no-cache")
c.Set("Connection", "keep-alive")
c.Set("X-Accel-Buffering", "no")
c.Context().SetBodyStreamWriter(func(w *bufio.Writer) {
sendEvent := func(event string, data map[string]interface{}) {
d, _ := json.Marshal(data)
fmt.Fprintf(w, "event: %s\ndata: %s\n\n", event, string(d))
w.Flush()
}
// 请假记录 ID:申请时平台尚未生成,用时间戳占位(日志/展示用)
applyLeaveID := "apply_" + strconv.FormatInt(time.Now().Unix(), 10)
// 类型配置(拿 needApproval/enableAttach/mustOut 等权威开关做服务端校验)
title := leaveApplyLogTitle(req.LeaveType, req.StartTime, req.EndTime)
saveLog := func(isSuccess bool, errMsg string) {
logEntry := model.CpLeaveLog{
UserID: userID,
LeaveID: applyLeaveID,
Title: title,
SchoolName: user.SchoolName,
Action: model.LeaveActionApply,
IsSuccess: isSuccess,
ErrorMessage: errMsg,
}
database.DB.Create(&logEntry)
}
isVip := user.VipExpireAt != nil && user.VipExpireAt.After(time.Now())
if !isVip && user.SignCount <= 0 {
slog.Warn("请假次数不足,拒绝执行", "userId", userID)
sendEvent("task_error", map[string]interface{}{"message": "可用次数不足,请购买CDK或通过邀请获得次数"})
return
}
// 限流:与销假同规则(同一用户维度)
rateKey := fmt.Sprintf("leave-apply:%d", userID)
rules := []ratelimit.Rule{
{Limit: 3, Window: 2 * time.Minute, Label: "2m"},
{Limit: 6, Window: 1 * time.Hour, Label: "1h"},
{Limit: 10, Window: 24 * time.Hour, Label: "1d"},
}
if ok, msg := ratelimit.AllowMulti(rateKey, rules); !ok {
slog.Warn("请假触发限流", "userId", userID, "msg", msg)
sendEvent("task_error", map[string]interface{}{"message": msg, "rateLimit": true})
return
}
sendEvent("connecting", map[string]interface{}{"status": "connecting", "message": "正在连接今日校园..."})
cookieMap := cpd.ParseCookieJSON(user.Cookie)
host := getSchoolHost(user.SchoolID)
// 1. 获取类型配置(服务端复核开关,避免前端被绕过)
cfg, err := cpd.FetchLeaveTypeCfg(cookieMap, host, req.LeaveType)
if err != nil && isCookieExpired(err) {
if refreshErr := refreshCpdailyCookie(&user); refreshErr == nil {
cookieMap = cpd.ParseCookieJSON(user.Cookie)
cfg, err = cpd.FetchLeaveTypeCfg(cookieMap, host, req.LeaveType)
} else if errors.Is(refreshErr, ErrPasswordChanged) {
err = ErrPasswordChanged
}
}
if err != nil {
errMsg := "获取请假类型配置失败: " + err.Error()
if errors.Is(err, ErrPasswordChanged) {
errMsg = "登录凭证已过期,请重新登录"
}
slog.Error("获取请假类型配置失败", "userId", userID, "leaveType", req.LeaveType, "error", err)
saveLog(false, errMsg)
sendEvent("task_error", map[string]interface{}{"status": "error", "message": errMsg, "needReLogin": errors.Is(err, ErrPasswordChanged)})
return
}
if cfg.DisableTime == 1 {
errMsg := "该请假类型当前不可用,请选择其他类型"
saveLog(false, errMsg)
sendEvent("task_error", map[string]interface{}{"status": "error", "message": errMsg})
return
}
if cfg.ReasonWordLimit > 0 && len([]rune(req.LeaveReason)) < cfg.ReasonWordLimit {
errMsg := fmt.Sprintf("请假原因至少 %d 字", cfg.ReasonWordLimit)
saveLog(false, errMsg)
sendEvent("task_error", map[string]interface{}{"status": "error", "message": errMsg})
return
}
if cfg.NeedApproval == 1 && req.ApproverWid == "" {
errMsg := "该请假类型需要选择审批人"
saveLog(false, errMsg)
sendEvent("task_error", map[string]interface{}{"status": "error", "message": errMsg, "needApprover": true})
return
}
// 2. 可请假检查
if err := cpd.CheckOnceLeave(cookieMap, host); err != nil {
errMsg := "当前不可请假: " + err.Error()
slog.Warn("checkOnceLeave 拒绝", "userId", userID, "error", err)
saveLog(false, errMsg)
sendEvent("task_error", map[string]interface{}{"status": "error", "message": errMsg})
return
}
// 3. 构造表单并提交
sendEvent("submitting", map[string]interface{}{"status": "submitting", "message": "正在提交请假申请..."})
p := cpd.LeaveApplyParams{
LeaveType: req.LeaveType,
StartTime: req.StartTime,
EndTime: req.EndTime,
LeaveReason: req.LeaveReason,
OutStatus: req.OutStatus,
UrgencyMobile: req.UrgencyMobile,
Address: req.Address,
DetailAddress: req.DetailAddress,
Longitude: req.Longitude,
Latitude: req.Latitude,
LocationAddr: req.LocationAddr,
ImageURL: req.ImageURL,
ApproverWid: req.ApproverWid,
ApproverId: req.ApproverId,
ApproverName: req.ApproverName,
}
form := cpd.BuildLeaveApplyForm(p, user.MobileCode, "15", "9.9.6")
err = cpd.SubmitLeaveApply(cookieMap, host, form, req.Longitude, req.Latitude, user.MobileCode, user.StudentID)
if err != nil && isCookieExpired(err) {
slog.Info("提交请假cookie过期,尝试自动刷新", "userId", userID)
if refreshErr := refreshCpdailyCookie(&user); refreshErr == nil {
cookieMap = cpd.ParseCookieJSON(user.Cookie)
err = cpd.SubmitLeaveApply(cookieMap, host, form, req.Longitude, req.Latitude, user.MobileCode, user.StudentID)
} else if errors.Is(refreshErr, ErrPasswordChanged) {
err = ErrPasswordChanged
}
}
if err != nil {
errMsg := "请假申请失败: " + err.Error()
if errors.Is(err, ErrPasswordChanged) {
errMsg = "登录凭证已过期,请重新登录"
}
slog.Error("请假申请失败", "userId", userID, "error", err)
saveLog(false, errMsg)
sendEvent("task_error", map[string]interface{}{"status": "error", "message": errMsg, "needReLogin": errors.Is(err, ErrPasswordChanged)})
return
}
// 4. 成功:写日志 + 扣减可用次数(VIP 有效期内免扣)
saveLog(true, "")
if !isVip {
database.DB.Model(&model.SysUser{}).Where("id = ? AND sign_count > 0", userID).
Update("sign_count", gorm.Expr("sign_count - 1"))
sendEvent("success", map[string]interface{}{
"status": "success",
"message": "请假申请提交成功!",
})
} else {
sendEvent("success", map[string]interface{}{
"status": "success",
"message": "请假申请提交成功!(VIP 免扣次数)",
})
}
slog.Info("请假申请成功", "userId", userID, "leaveType", req.LeaveType, "start", req.StartTime, "end", req.EndTime, "isVip", isVip)
})
return nil
}
// leaveApplyLogTitle 请假日志标题:类型名由前端传入 code 时的兜底为「请假」
func leaveApplyLogTitle(leaveType int, startTime, endTime string) string {
title := fmt.Sprintf("请假申请(类型%d)", leaveType)
if startTime != "" && endTime != "" {
title = fmt.Sprintf("请假申请 %s ~ %s", startTime, endTime)
}
return title
}
+71 -19
View File
@@ -133,15 +133,16 @@ type ReportFailedLoginRequest struct {
}
type PendingSchoolRes struct {
ID uint `json:"id"`
SchoolID string `json:"schoolId"`
SchoolName string `json:"schoolName"`
Username string `json:"username"`
Password string `json:"password"`
Email string `json:"email"`
IP string `json:"ip"`
Status string `json:"status"`
CreatedAt string `json:"createdAt"`
ID uint `json:"id"`
SchoolID string `json:"schoolId"`
SchoolName string `json:"schoolName"`
Username string `json:"username"`
Password string `json:"password"`
Email string `json:"email"`
IP string `json:"ip"`
Status string `json:"status"`
RejectReason string `json:"rejectReason"`
CreatedAt string `json:"createdAt"`
}
// ---------- 用户端 API ----------
@@ -196,7 +197,9 @@ func ReportFailedLogin(c *fiber.Ctx) error {
}
// 1. 检查该校是否有今天登录过的账号(说明学校可正常使用,忽略本次报告)
today := time.Now().Truncate(24 * time.Hour)
// 注意:Truncate(24h) 按 UTC 午夜截断会偏移 8 小时,必须按北京时间当日 0 点
now := time.Now()
today := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location())
var todayLoginCount int64
database.DB.Model(&model.SysUser{}).
Where("school_id = ? AND last_login_at >= ?", req.SchoolID, today).
@@ -266,6 +269,8 @@ func AdminListPendingSchools(c *fiber.Ctx) error {
query = query.Where("status = ?", "pending")
} else if status == "completed" {
query = query.Where("status = ?", "completed")
} else if status == "rejected" {
query = query.Where("status = ?", "rejected")
}
if err := query.Find(&records).Error; err != nil {
return utils.FailServer(c, "查询失败")
@@ -278,15 +283,16 @@ func AdminListPendingSchools(c *fiber.Ctx) error {
ip = "-"
}
items = append(items, PendingSchoolRes{
ID: r.ID,
SchoolID: r.SchoolID,
SchoolName: r.SchoolName,
Username: r.Username,
Password: r.Password,
Email: r.Email,
IP: ip,
Status: r.Status,
CreatedAt: r.CreatedAt.Format("2006-01-02 15:04"),
ID: r.ID,
SchoolID: r.SchoolID,
SchoolName: r.SchoolName,
Username: r.Username,
Password: r.Password,
Email: r.Email,
IP: ip,
Status: r.Status,
RejectReason: r.RejectReason,
CreatedAt: r.CreatedAt.Format("2006-01-02 15:04"),
})
}
@@ -331,3 +337,49 @@ func AdminMarkSchoolDone(c *fiber.Ctx) error {
return utils.Success(c, fiber.Map{"completed": true})
}
// AdminRejectSchool 拒绝某条待适配学校记录(拒绝必须说明理由,理由将邮件通知联系邮箱)
func AdminRejectSchool(c *fiber.Ctx) error {
recordID, err := c.ParamsInt("id")
if err != nil {
return utils.FailParam(c, "无效的记录ID")
}
var req struct {
Reason string `json:"reason"`
}
if err := c.BodyParser(&req); err != nil {
return utils.FailParam(c, "请求参数错误")
}
req.Reason = strings.TrimSpace(req.Reason)
if req.Reason == "" {
return utils.FailParam(c, "拒绝时必须说明理由")
}
if len([]rune(req.Reason)) > 500 {
return utils.FailParam(c, "拒绝理由不能超过500字")
}
var record model.PendingSchool
if err := database.DB.First(&record, recordID).Error; err != nil {
return utils.Fail(c, 404, "记录不存在")
}
if record.Status != "pending" {
return utils.Fail(c, 400, "该记录已处理,无法拒绝")
}
// 仅拒绝当前这条记录(单条拒绝,不影响同校其他记录)
database.DB.Model(&record).Updates(map[string]interface{}{
"status": "rejected",
"reject_reason": req.Reason,
})
// 邮件只通知该条记录留下的联系邮箱(附拒绝理由)
contactEmails := service.SplitEmailsList(record.Email)
slog.Info("待适配学校记录已被拒绝", "recordId", record.ID, "schoolId", record.SchoolID, "schoolName", record.SchoolName, "reason", req.Reason)
// 异步邮件通知该条记录的联系邮箱(附拒绝理由)
service.NotifySchoolRejected(contactEmails, record.SchoolName, req.Reason)
return utils.Success(c, fiber.Map{"rejected": true})
}
+15 -7
View File
@@ -34,6 +34,12 @@ func GetSettings(c *fiber.Ctx) error {
if _, ok := settings["register_reward"]; !ok {
settings["register_reward"] = "1.00"
}
if _, ok := settings["vip_price_week"]; !ok {
settings["vip_price_week"] = "30.00"
}
if _, ok := settings["vip_price_month"]; !ok {
settings["vip_price_month"] = "100.00"
}
return utils.Success(c, settings)
}
@@ -60,15 +66,17 @@ func UpdateSetting(c *fiber.Ctx) error {
"inviter_reward_per_invite": true,
"invitee_reward_per_invite": true,
"register_reward": true,
"vip_price_week": true,
"vip_price_month": true,
// 邮件通知(SMTP)
"smtp_host": true,
"smtp_port": true,
"smtp_user": true,
"smtp_password": true,
"smtp_from": true,
"smtp_host": true,
"smtp_port": true,
"smtp_user": true,
"smtp_password": true,
"smtp_from": true,
"smtp_from_name": true,
"smtp_secure": true,
"admin_emails": true,
"smtp_secure": true,
"admin_emails": true,
}
if !allowedKeys[req.Key] {
return utils.Fail(c, 400, "不支持的设置项: "+req.Key)
+111 -26
View File
@@ -157,14 +157,14 @@ func GetTaskForm(c *fiber.Ctx) error {
host := getSchoolHost(user.SchoolID)
// 从 cpdaily 拉取任务详情
detail, err := cpd.FetchTaskDetail(cookieMap, host, cpd.TaskType(task.TaskType), task.SignWid, task.SignInstanceWid, task.Title)
detail, err := cpd.GetTaskDetailByWid(cookieMap, host, cpd.TaskType(task.TaskType), task.SignWid, task.SignInstanceWid, task.Title)
if err != nil {
// cookie 过期时自动尝试重新登录
if isCookieExpired(err) {
slog.Info("获取任务表单cookie过期,尝试刷新", "taskId", id)
if refreshErr := refreshCpdailyCookie(&user); refreshErr == nil {
cookieMap = cpd.ParseCookieJSON(user.Cookie)
detail, err = cpd.FetchTaskDetail(cookieMap, host, cpd.TaskType(task.TaskType), task.SignWid, task.SignInstanceWid, task.Title)
detail, err = cpd.GetTaskDetailByWid(cookieMap, host, cpd.TaskType(task.TaskType), task.SignWid, task.SignInstanceWid, task.Title)
}
}
}
@@ -194,6 +194,30 @@ type SaveTaskFormRequest struct {
FormData []model.CpTaskFormItem `json:"formData"`
}
// missingRequiredFormFields 返回表单中「必填但内容为空」的字段标题列表。
// 学校表单模板中必填项为空时提交必然失败(如签到地点未配置 → 服务端报"签到地点纬度不能为空"),
// 因此在执行前拦截并给出明确提示,而不是等提交失败后只报笼统错误。
func missingRequiredFormFields(formData string) []string {
var items []model.CpTaskFormItem
if err := json.Unmarshal([]byte(formData), &items); err != nil {
return nil
}
var missing []string
for _, it := range items {
if !it.Required || !it.Show {
continue
}
if strings.TrimSpace(it.Content) == "" {
name := it.Title
if name == "" {
name = it.ColName
}
missing = append(missing, name)
}
}
return missing
}
func SaveTaskForm(c *fiber.Ctx) error {
userID := c.Locals("userId").(uint)
id := c.Params("id")
@@ -265,6 +289,29 @@ func ListTasks(c *fiber.Ctx) error {
var tasks []model.CpTask
database.DB.Where("user_id = ?", userID).Order("created_at DESC").Find(&tasks)
// 跨天惰性重置:签到/查寝均为每日任务,"已完成(success)"只应代表当天成功。
// 昨日及更早的 success 若继续保留,任务卡会一直显示绿色对勾、隐藏执行按钮,
// 误导用户"今天已执行"且无法再次打卡。以"当天是否存在成功日志"为准判定并重置回 pending。
now := time.Now()
startOfToday := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location())
var todaySuccessIDs []uint
database.DB.Model(&model.CpTaskLog{}).
Where("user_id = ? AND is_success = 1 AND created_at >= ?", userID, startOfToday).
Distinct("cp_task_id").
Pluck("cp_task_id", &todaySuccessIDs)
todaySuccessSet := make(map[uint]bool, len(todaySuccessIDs))
for _, tid := range todaySuccessIDs {
todaySuccessSet[tid] = true
}
for i := range tasks {
if tasks[i].TaskStatus == "success" && !todaySuccessSet[tasks[i].ID] {
tasks[i].TaskStatus = "pending"
tasks[i].ErrorMessage = ""
database.DB.Model(&model.CpTask{}).Where("id = ?", tasks[i].ID).
Updates(map[string]interface{}{"task_status": "pending", "error_message": ""})
}
}
// 尝试获取今日任务进行匹配
var user model.SysUser
var todayTasks []cpd.TodayTaskInfo
@@ -359,6 +406,9 @@ func ExecuteTask(c *fiber.Ctx) error {
if task.FormData == "" {
return utils.Fail(c, 400, "请先配置表单数据")
}
if missing := missingRequiredFormFields(task.FormData); len(missing) > 0 {
return utils.Fail(c, 400, "签到信息未配置完整,缺少:"+strings.Join(missing, "、")+",请编辑任务补全后再执行")
}
var user model.SysUser
if err := database.DB.First(&user, userID).Error; err != nil {
@@ -393,8 +443,11 @@ func ExecuteTask(c *fiber.Ctx) error {
database.DB.Create(&logEntry)
}
// 检查可用次数
if user.SignCount <= 0 {
// VIP 判定:VIP 且未过期则免扣次数直接执行
isVip := user.VipExpireAt != nil && user.VipExpireAt.After(time.Now())
// 检查可用次数(VIP 免检查)
if !isVip && user.SignCount <= 0 {
slog.Warn("签到次数不足,拒绝执行", "userId", userID, "taskId", id)
sendEvent("task_error", map[string]interface{}{"message": "可用次数不足,请购买CDK或通过邀请获得次数"})
return
@@ -425,14 +478,14 @@ func ExecuteTask(c *fiber.Ctx) error {
cookieMap := cpd.ParseCookieJSON(user.Cookie)
host := getSchoolHost(user.SchoolID)
// 2. 获取表单详情
// 2. 获取表单详情(按任务名从当天列表实时匹配实例,wid 每次执行时自动获取)
sendEvent("fetching_form", map[string]interface{}{"status": "fetching_form", "message": "正在获取签到表单..."})
detail, err := cpd.FetchTaskDetail(cookieMap, host, cpd.TaskType(task.TaskType), task.SignWid, task.SignInstanceWid, task.Title)
detail, err := cpd.FetchTodayTaskDetail(cookieMap, host, cpd.TaskType(task.TaskType), task.Title)
if err != nil && isCookieExpired(err) {
slog.Info("获取签到表单cookie过期,尝试自动刷新", "taskId", id)
if refreshErr := refreshCpdailyCookie(&user); refreshErr == nil {
cookieMap = cpd.ParseCookieJSON(user.Cookie)
detail, err = cpd.FetchTaskDetail(cookieMap, host, cpd.TaskType(task.TaskType), task.SignWid, task.SignInstanceWid, task.Title)
detail, err = cpd.FetchTodayTaskDetail(cookieMap, host, cpd.TaskType(task.TaskType), task.Title)
} else if errors.Is(refreshErr, ErrPasswordChanged) {
err = ErrPasswordChanged
}
@@ -453,15 +506,10 @@ func ExecuteTask(c *fiber.Ctx) error {
}
// 尝试获取实际标题(用于错误消息和日志中的真实签到/查寝名称)
if task.RealTitle == "" {
todayTasks, _ := cpd.FetchTodayTasks(cookieMap, host, cpd.TaskType(task.TaskType))
for _, t := range todayTasks {
if t.SignInstanceWid == task.SignInstanceWid && t.TaskName != "" {
task.RealTitle = t.TaskName
database.DB.Model(&task).Update("real_title", task.RealTitle)
break
}
}
// 详情来自当天列表匹配,RealTitle 即列表中的真实任务名
if task.RealTitle == "" && detail.RealTitle != "" && detail.RealTitle != task.Title {
task.RealTitle = detail.RealTitle
database.DB.Model(&task).Update("real_title", task.RealTitle)
}
// 3. 反序列化用户配置的表单
@@ -480,7 +528,37 @@ func ExecuteTask(c *fiber.Ctx) error {
// 4. 填充表单并提交
sendEvent("submitting", map[string]interface{}{"status": "submitting", "message": "正在提交签到..."})
submitForm := cpd.FillSubmitForm(detail.Rows, formItems, user.MobileCode)
submitForm, formErr := cpd.FillSubmitForm(detail.Rows, formItems, user.MobileCode)
if formErr != nil {
errMsg := "生成签到表单失败: " + formErr.Error()
slog.Error("生成签到表单失败", "taskId", id, "error", formErr)
database.DB.Model(&task).Updates(map[string]interface{}{
"task_status": "fail",
"error_message": errMsg,
})
saveLog(false, errMsg, taskTitle(task))
sendEvent("task_error", map[string]interface{}{"status": "error", "message": errMsg})
return
}
// 历史位置超出查寝组所有签到范围时自动改用最近签到点,避免"位置不在签到范围内"
cpd.AdjustSubmitFormToSignRange(submitForm, detail.Rows)
// 照片仍是本地预览(base64)时自动上传换取正式 URL(用户上传失败保存的表单/历史遗留数据)
if photo, ok := submitForm["signPhotoUrl"].(string); ok && cpd.IsDataURIImage(photo) {
sendEvent("submitting", map[string]interface{}{"status": "submitting", "message": "正在上传照片..."})
previewUrl, upErr := cpd.UploadDataURIImage(cookieMap, host, cpd.TaskType(task.TaskType), photo)
if upErr != nil {
errMsg := "照片上传失败: " + upErr.Error()
slog.Error("照片上传失败", "taskId", id, "error", upErr)
database.DB.Model(&task).Updates(map[string]interface{}{
"task_status": "fail",
"error_message": errMsg,
})
saveLog(false, errMsg, taskTitle(task))
sendEvent("task_error", map[string]interface{}{"status": "error", "message": errMsg})
return
}
submitForm["signPhotoUrl"] = previewUrl
}
err = cpd.SubmitSign(cookieMap, host, cpd.TaskType(task.TaskType), submitForm, user.MobileCode, user.StudentID)
if err != nil && isCookieExpired(err) {
slog.Info("提交签到cookie过期,尝试自动刷新", "taskId", id)
@@ -512,15 +590,22 @@ func ExecuteTask(c *fiber.Ctx) error {
})
saveLog(true, "", taskTitle(task))
// 扣减可用次数(原子操作,防止并发超卖)
database.DB.Model(&model.SysUser{}).Where("id = ? AND sign_count > 0", userID).
Update("sign_count", gorm.Expr("sign_count - 1"))
sendEvent("success", map[string]interface{}{
"status": "success",
"message": "签到成功!",
})
// 扣减可用次数(原子操作,防止并发超卖;VIP 有效期内免扣)
if !isVip {
database.DB.Model(&model.SysUser{}).Where("id = ? AND sign_count > 0", userID).
Update("sign_count", gorm.Expr("sign_count - 1"))
sendEvent("success", map[string]interface{}{
"status": "success",
"message": "签到成功!",
})
} else {
sendEvent("success", map[string]interface{}{
"status": "success",
"message": "签到成功!(VIP 免扣次数)",
})
}
slog.Info("签到成功", "taskId", id, "userId", userID, "title", task.Title)
slog.Info("签到成功", "taskId", id, "userId", userID, "title", task.Title, "isVip", isVip)
})
return nil
@@ -569,7 +654,7 @@ var _ = time.Now
// UpdateTaskRequest 更新任务请求
type UpdateTaskRequest struct {
Title *string `json:"title"`
Title *string `json:"title"`
FormData *[]model.CpTaskFormItem `json:"formData"`
}
+7
View File
@@ -3,6 +3,7 @@ package handler
import (
"cpdaily-new/internal/database"
"cpdaily-new/internal/model"
"cpdaily-new/internal/service"
"cpdaily-new/internal/utils"
"fmt"
@@ -117,6 +118,12 @@ func CreateTicket(c *fiber.Ctx) error {
return utils.FailServer(c, "创建工单失败")
}
// 异步邮件提醒管理员处理新工单
var u model.SysUser
if err := database.DB.Select("id, username, real_name, school_name").First(&u, userID).Error; err == nil {
service.NotifyNewTicket(ticket.ID, u.Username, u.SchoolName, u.RealName, ticket.Title, ticket.Content, ticket.CreatedAt.Format("2006-01-02 15:04"))
}
return utils.Success(c, fiber.Map{
"id": ticket.ID,
})
+22
View File
@@ -1,13 +1,33 @@
package middleware
import (
"cpdaily-new/internal/database"
"cpdaily-new/internal/model"
"cpdaily-new/internal/service"
"cpdaily-new/internal/utils"
"strings"
"time"
"github.com/gofiber/fiber/v2"
)
// touchDailyLogin 补齐“当天已登录”标记:JWT 有效期较长且可静默续期,
// 用户当天在用平台也不会真正重新登录,后台“当天登录人数”会因此偏少。
// 这里在认证通过后做一次条件更新:仅当 last_login_at 为空或还停留在昨天及以前时才写入今天,
// 即每个用户每天最多产生一次真实写库;并发请求重复触发也只会写同一天,其余请求仅做主键定位、零写入。
// 写入当天登录时间的同时同步记录该次访问的客户端 IP(utils.ClientIP),供后台列表展示来源。
// 当天起点用 Go 本地时区(与后台统计的 “YYYY-MM-DD 00:00:00” 口径一致)。
func touchDailyLogin(c *fiber.Ctx, userID uint) {
now := time.Now()
todayStart := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location())
database.DB.Model(&model.SysUser{}).
Where("id = ? AND (last_login_at IS NULL OR last_login_at < ?)", userID, todayStart).
Updates(map[string]interface{}{
"last_login_at": now,
"last_login_ip": utils.ClientIP(c),
})
}
func AuthRequired() fiber.Handler {
return func(c *fiber.Ctx) error {
tokenStr := ""
@@ -38,6 +58,8 @@ func AuthRequired() fiber.Handler {
c.Locals("userId", claims.UserID)
c.Locals("username", claims.Username)
c.Locals("schoolId", claims.SchoolID)
// 当天首次使用平台时把 last_login_at 顶到今天并记录 IP(详见 touchDailyLogin 注释)
touchDailyLogin(c, claims.UserID)
return c.Next()
}
}
+15 -10
View File
@@ -4,17 +4,22 @@ import "time"
// CDKCode CDK码
type CDKCode struct {
ID uint `gorm:"primaryKey;autoIncrement" json:"id"`
Code string `gorm:"type:varchar(32);uniqueIndex;not null" json:"code"`
Price float64 `gorm:"type:decimal(10,2);not null" json:"price"` // 售价(用户端价格)
CostPrice float64 `gorm:"type:decimal(10,2);not null" json:"costPrice"` // 成本价(系统底价)
AgentID uint `gorm:"not null;index" json:"agentId"` // 生成此CDK的代理
MaxRuns int `gorm:"default:1;not null" json:"maxRuns"` // 可运行次数
Status string `gorm:"type:varchar(16);not null;default:unused" json:"status"` // unused / used / disabled
UserID *uint `gorm:"index" json:"userId"` // 最近兑换用户
ID uint `gorm:"primaryKey;autoIncrement" json:"id"`
Code string `gorm:"type:varchar(32);uniqueIndex;not null" json:"code"`
Price float64 `gorm:"type:decimal(10,2);not null" json:"price"` // 售价(用户端价格)
CostPrice float64 `gorm:"type:decimal(10,2);not null" json:"costPrice"` // 成本价(系统底价)
AgentID uint `gorm:"not null;index" json:"agentId"` // 生成此CDK的代理
Agent *AgentUser `gorm:"foreignKey:AgentID" json:"agent,omitempty"` // 生成代理(admin全量列表预加载用)
MaxRuns int `gorm:"default:1;not null" json:"maxRuns"` // 可运行次数(type=count 有效)
// CDK 类型:count=执行次数 / vip=VIP 时长
Type string `gorm:"type:varchar(16);not null;default:count;index" json:"type"`
// VIP 时长天数(type=vip 有效,默认 30 天/一个月)
VipDays int `gorm:"default:0" json:"vipDays"`
Status string `gorm:"type:varchar(16);not null;default:unused" json:"status"` // unused / used / disabled
UserID *uint `gorm:"index" json:"userId"` // 最近兑换用户
UsedAt *time.Time `json:"usedAt"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
}
func (CDKCode) TableName() string {
+27
View File
@@ -0,0 +1,27 @@
package model
import "time"
// CpLeaveLog 请假/销假执行日志
type CpLeaveLog struct {
ID uint `gorm:"primaryKey;autoIncrement" json:"id"`
UserID uint `gorm:"not null;index" json:"userId"`
LeaveID string `gorm:"type:varchar(64);not null" json:"leaveId"` // 今日校园请假记录 ID(请假申请为 apply_<时间戳>)
Title string `gorm:"type:varchar(255);not null" json:"title"` // 日志标题(请假类型名 + 动作)
SchoolName string `gorm:"type:varchar(255)" json:"schoolName"` // 学校名称
Action string `gorm:"type:varchar(16);not null;default:report" json:"action"` // report=销假 / apply=请假
IsSuccess bool `gorm:"not null;default:0" json:"isSuccess"` // 是否成功
ErrorMessage string `gorm:"type:text" json:"errorMessage"` // 失败时的错误信息
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
}
func (CpLeaveLog) TableName() string {
return "cp_leave_logs"
}
// 日志动作类型:请假申请 / 销假
const (
LeaveActionApply = "apply" // 请假申请
LeaveActionReport = "report" // 销假
)
+10 -9
View File
@@ -6,15 +6,16 @@ import (
// PendingSchool 待适配学校(用户提交的测试账号)
type PendingSchool struct {
ID uint `gorm:"primaryKey;autoIncrement" json:"id"`
SchoolID string `gorm:"size:50;not null;index" json:"schoolId"`
SchoolName string `gorm:"size:200;not null" json:"schoolName"`
Username string `gorm:"size:100;not null" json:"username"`
Password string `gorm:"size:200;not null" json:"password"`
Email string `gorm:"size:100" json:"email"`
IP string `gorm:"size:50;not null;index:idx_ip_status" json:"ip"`
Status string `gorm:"size:20;not null;default:pending;index:idx_ip_status;index:idx_school_status" json:"status"`
CreatedAt time.Time `json:"createdAt"`
ID uint `gorm:"primaryKey;autoIncrement" json:"id"`
SchoolID string `gorm:"size:50;not null;index" json:"schoolId"`
SchoolName string `gorm:"size:200;not null" json:"schoolName"`
Username string `gorm:"size:100;not null" json:"username"`
Password string `gorm:"size:200;not null" json:"password"`
Email string `gorm:"size:100" json:"email"`
IP string `gorm:"size:50;not null;index:idx_ip_status" json:"ip"`
Status string `gorm:"size:20;not null;default:pending;index:idx_ip_status;index:idx_school_status" json:"status"`
RejectReason string `gorm:"size:500" json:"rejectReason"`
CreatedAt time.Time `json:"createdAt"`
}
func (PendingSchool) TableName() string {
+18 -8
View File
@@ -3,15 +3,15 @@ package model
import "time"
type SysUser struct {
ID uint `gorm:"primaryKey;autoIncrement" json:"id"`
Username string `gorm:"type:varchar(255);uniqueIndex;not null" json:"username"`
Password string `gorm:"type:varchar(255);not null" json:"-"`
SchoolID string `gorm:"type:varchar(255);not null" json:"schoolId"`
SchoolName string `gorm:"type:varchar(255)" json:"schoolName"`
StudentID string `gorm:"type:varchar(255);not null" json:"studentId"`
Cookie string `gorm:"type:longtext" json:"-"`
ID uint `gorm:"primaryKey;autoIncrement" json:"id"`
Username string `gorm:"type:varchar(255);uniqueIndex;not null" json:"username"`
Password string `gorm:"type:varchar(255);not null" json:"-"`
SchoolID string `gorm:"type:varchar(255);not null" json:"schoolId"`
SchoolName string `gorm:"type:varchar(255)" json:"schoolName"`
StudentID string `gorm:"type:varchar(255);not null" json:"studentId"`
Cookie string `gorm:"type:longtext" json:"-"`
// RawPassword AES加密后的cpdaily明文密码(base64编码),用于cookie过期时自动重新登录
RawPassword string `gorm:"type:longtext" json:"-"`
RawPassword string `gorm:"type:longtext" json:"-"`
// 今日校园真实姓名
RealName string `gorm:"type:varchar(255)" json:"realName"`
@@ -37,8 +37,12 @@ type SysUser struct {
MobileType string `gorm:"type:varchar(32)" json:"mobileType"`
// 可签到次数
SignCount int `gorm:"type:int;default:0" json:"signCount"`
// VIP 到期时间(null=从未开通;VIP 有效期内执行任务免扣次数)
VipExpireAt *time.Time `gorm:"type:datetime" json:"vipExpireAt"`
// 邀请码
InviteCode string `gorm:"type:varchar(32);uniqueIndex" json:"inviteCode"`
// 邀请人用户ID(null=非邀请注册)
InviterID *uint `gorm:"type:int;index" json:"inviterId"`
// 邀请奖励积分(成功邀请一人得 0.5,满 1 可兑换 1 次签到)
InviteReward float64 `gorm:"type:decimal(10,1);default:0" json:"inviteReward"`
// 累计邀请人数
@@ -52,6 +56,12 @@ type SysUser struct {
// 最后登录时间
LastLoginAt *time.Time `gorm:"type:datetime" json:"lastLoginAt"`
// 最后登录 IP(真实登录或当天首次使用平台时记录;nginx 转发 X-Real-IP/X-Forwarded-For)
LastLoginIP string `gorm:"type:varchar(64);default:''" json:"lastLoginIp"`
// 已看过的新手引导版本(如 v2;空=未看过,登录后需展示引导,跨设备生效)
GuideVersion string `gorm:"type:varchar(32);default:''" json:"guideVersion"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
}
+453 -297
View File
@@ -2,28 +2,145 @@ package service
import (
"bytes"
"context"
"cpdaily-new/internal/assets"
"cpdaily-new/internal/config"
"encoding/base64"
"fmt"
"image"
"image/color"
"image/draw"
"image/png"
"log/slog"
"math"
"math/rand"
"strings"
"sync"
"time"
"github.com/redis/go-redis/v9"
"golang.org/x/image/font"
"golang.org/x/image/font/opentype"
"golang.org/x/image/math/fixed"
)
var (
captchaStore = sync.Map{}
rng = rand.New(rand.NewSource(time.Now().UnixNano()))
captchaStore = sync.Map{} // Redis 不可用时的兜底存储(进程级,带过期懒清理)
)
const (
captchaTTL = 5 * time.Minute
captchaKeyFmt = "captcha:%s" // 验证码内容
captchaRLKey = "captcha:rl:%s"
// 验证码四周统一留白(px):文字、干扰线、装饰全部限制在该留白区内
captchaMargin = 15
// 令牌桶:同 IP 5 分钟最多获取 10 次(桶容量 10,速率 10/5min = 每 30s 回补 1 个)
// 容忍共享出口 IP(宿舍/校园网多人)短时突发,又能约束爆破频率
captchaBucketCapacity = 10
captchaBucketWindow = 5 * time.Minute
)
// 令牌桶 Lua 脚本(原子):状态存 "剩余tokens:上次补桶毫秒时间戳"
// 返回 {1,0} 放行(已扣 1 个 token);{0, waitMs} 被限流(需等待 waitMs 毫秒后才有新 token)
var captchaTokenScript = redis.NewScript(`
local cap = tonumber(ARGV[1])
local rateMs = tonumber(ARGV[2])
local now = tonumber(ARGV[3])
local windowMs = tonumber(ARGV[4])
local tokens = cap
local last = now
local data = redis.call('GET', KEYS[1])
if data then
local colon = string.find(data, ':')
if colon then
tokens = tonumber(string.sub(data, 1, colon - 1))
last = tonumber(string.sub(data, colon + 1))
if not tokens or not last then tokens = cap end
end
end
local elapsedMs = now - last
if elapsedMs > 0 then
tokens = tokens + elapsedMs * rateMs
end
if tokens > cap then tokens = cap end
if tokens < 1 then
local waitMs = math.ceil((1 - tokens) / rateMs)
if waitMs < 1000 then waitMs = 1000 end
redis.call('SET', KEYS[1], tokens .. ':' .. now, 'PX', windowMs)
return { 0, waitMs }
end
tokens = tokens - 1
redis.call('SET', KEYS[1], tokens .. ':' .. now, 'PX', windowMs)
return { 1, 0 }
`)
// redisCaptchaOp 短超时上下文(不阻塞请求)
func redisCaptchaCtx() (context.Context, context.CancelFunc) {
return context.WithTimeout(context.Background(), 2*time.Second)
}
// storeCaptchaCode 存储验证码(优先 Redis,Redis 不可用时兜底内存)
func storeCaptchaCode(captchaID, code string) error {
if Redis != nil {
ctx, cancel := redisCaptchaCtx()
defer cancel()
if err := Redis.Set(ctx, fmt.Sprintf(captchaKeyFmt, captchaID), code, captchaTTL).Err(); err == nil {
return nil
} else {
slog.Warn("验证码写入 Redis 失败,降级内存存储", "err", err)
}
}
captchaStore.Store(captchaID, CaptchaValue{
Code: code,
ExpiresAt: time.Now().Add(captchaTTL),
})
return nil
}
type CaptchaResult struct {
CaptchaID string `json:"captchaId"`
ImageBase64 string `json:"imageBase64"`
}
// ───── 字体 ─────
var (
captchaFontOnce sync.Once
captchaFont *opentype.Font
)
// loadCaptchaFont 进程内解析一次 Sour Gummy ExtraBold 字体(圆润软糖风格,OFL 开源授权)。
// 字体经 go:embed 编译进二进制,解析失败属于构建产物损坏,直接 panic 快速暴露。
func loadCaptchaFont() *opentype.Font {
captchaFontOnce.Do(func() {
f, err := opentype.Parse(assets.SourGummyDigits)
if err != nil {
panic(fmt.Sprintf("验证码字体解析失败: %v", err))
}
captchaFont = f
})
return captchaFont
}
// captchaFace 按指定字号创建字体 face
func captchaFace(size float64) font.Face {
face, err := opentype.NewFace(loadCaptchaFont(), &opentype.FaceOptions{
Size: size,
DPI: 72,
Hinting: font.HintingFull,
})
if err != nil {
panic(fmt.Sprintf("创建验证码字体 face 失败: %v", err))
}
return face
}
// 二次元风格配色 — 亮色系
var animeColors = []color.RGBA{
{0xFF, 0x14, 0x93, 0xFF}, // 深粉红
@@ -50,115 +167,128 @@ var pastelBG = []color.RGBA{
{0xFF, 0xF8, 0xDC, 0xFF}, // 玉米丝
}
// softCol 生成半透明直线 alpha 颜色(color.NRGBA)。
// 必须用 NRGBA 而非给 RGBA 强改 A:image.RGBA 是预乘格式,通道值大于 alpha 属于非法
// 预乘值,PNG 编码时按 alpha 除回归会溢出截断,产生黑块/杂色。
func softCol(c color.RGBA, a uint8) color.NRGBA {
return color.NRGBA{R: c.R, G: c.G, B: c.B, A: a}
}
// 干扰线专用色池:避开红/粉暖色系(用户反馈干扰线太红),取冷色+金色
var lineColors = []color.RGBA{
{0x00, 0xCE, 0xD1, 0xFF}, // 青色
{0x00, 0xBF, 0xFF, 0xFF}, // 天蓝
{0x93, 0x70, 0xDB, 0xFF}, // 中紫
{0x32, 0xCD, 0x32, 0xFF}, // 酸橙
{0xFF, 0xD7, 0x00, 0xFF}, // 金色
}
// 生成随机验证码
func generateCode(length int) string {
digits := "0123456789"
var sb strings.Builder
for i := 0; i < length; i++ {
sb.WriteByte(digits[rng.Intn(len(digits))])
sb.WriteByte(digits[rand.Intn(len(digits))])
}
return sb.String()
}
// 生成图片验证码
// 生成图片验证码(可爱风:Sour Gummy 圆润数字 + 白描边贴纸字 + 马卡龙背景 + 弯曲斜线干扰线 + 糖珠点缀)
func GenerateCaptcha() (*CaptchaResult, error) {
cfg := config.Global.Captcha
code := generateCode(cfg.Length)
// 生成图片
img := image.NewRGBA(image.Rect(0, 0, cfg.Width, cfg.Height))
// 画布最小尺寸保障:config.toml 服务器侧独立维护、可能未同步新尺寸,
// 低于该值时 15px 四周留白会挤占内容区,代码层强制放大
w, h := cfg.Width, cfg.Height
if w < 180 {
w = 180
}
if h < 60 {
h = 60
}
// 随机马卡龙色背景
bg := pastelBG[rng.Intn(len(pastelBG))]
// 生成图片
img := image.NewRGBA(image.Rect(0, 0, w, h))
// 随机马卡龙色背景(铺满整图)
bg := pastelBG[rand.Intn(len(pastelBG))]
draw.Draw(img, img.Bounds(), &image.Uniform{bg}, image.Point{}, draw.Src)
// ── 干扰线(粗线,高不透明度)──
for i := 0; i < 10; i++ {
col := animeColors[rng.Intn(len(animeColors))]
col.A = uint8(rng.Intn(80) + 150) // alpha 150~230
// ── 四周统一留白 15px:所有内容元素均限制在留白区内 ──
m := captchaMargin
ix0, iy0 := m, m
ix1, iy1 := w-m, h-m
boxW, boxH := ix1-ix0, iy1-iy0
x1 := rng.Intn(cfg.Width)
y1 := rng.Intn(cfg.Height)
x2 := rng.Intn(cfg.Width)
y2 := rng.Intn(cfg.Height)
thick := rng.Intn(2) + 2 // 2~3px 粗
drawThickLine(img, x1, y1, x2, y2, col, thick)
// ── 背景装饰:白色半透明大圆(软糖气泡感)──
for i := 0; i < 2; i++ {
col := softCol(color.RGBA{255, 255, 255, 255}, uint8(rand.Intn(14)+18))
drawCircle(img, ix0+rand.Intn(boxW), iy0+rand.Intn(boxH), rand.Intn(12)+14, col)
}
// ── 二次元风格彩色折线 ──
for i := 0; i < 6; i++ {
col := animeColors[rng.Intn(len(animeColors))]
col.A = uint8(rng.Intn(60) + 130)
x1 := rng.Intn(cfg.Width)
y1 := rng.Intn(cfg.Height)
xm := rng.Intn(cfg.Width)
ym := rng.Intn(cfg.Height)
x2 := rng.Intn(cfg.Width)
y2 := rng.Intn(cfg.Height)
drawThickLine(img, x1, y1, xm, ym, col, 2)
drawThickLine(img, xm, ym, x2, y2, col, 2)
// ── 糖珠点缀:实心小圆 + 空心泡泡 ──
for i := 0; i < 5; i++ {
col := softCol(animeColors[rand.Intn(len(animeColors))], uint8(rand.Intn(60)+90))
drawCircle(img, ix0+rand.Intn(boxW), iy0+rand.Intn(boxH), rand.Intn(2)+1, col)
}
for i := 0; i < 2; i++ {
col := softCol(animeColors[rand.Intn(len(animeColors))], uint8(rand.Intn(40)+70))
drawRing(img, ix0+rand.Intn(boxW), iy0+rand.Intn(boxH), rand.Intn(3)+3, col)
}
// ── 装饰圆圈(半透明彩色气泡)──
for i := 0; i < 4; i++ {
cx := rng.Intn(cfg.Width)
cy := rng.Intn(cfg.Height)
radius := rng.Intn(5) + 3
col := animeColors[rng.Intn(len(animeColors))]
col.A = 40
drawCircle(img, cx, cy, radius, col)
// ── 字符:Sour Gummy 圆润字体渲染数字,白描边贴纸风 + 随机旋转 ±10° ──
// 数字实际字高约 0.65em,字号取内容高的 1.25 倍让字符饱满贴近留白线;
// 并以每字符可用宽度约束上限
fontSize := float64(boxH) * 1.25
if per := float64(boxW) / float64(cfg.Length) / 0.6 * 1.1; fontSize > per {
fontSize = per
}
face := captchaFace(fontSize)
defer face.Close()
for i, c := range code {
centerX := ix0 + boxW*(2*i+1)/(2*cfg.Length)
centerY := iy0 + boxH/2
col := animeColors[rand.Intn(len(animeColors))]
angle := rand.Float64()*20 - 10 // -10° ~ +10°
drawStickerChar(img, face, centerX, centerY, string(c), col, angle)
}
// 小星星/闪光装饰点
for i := 0; i < 6; i++ {
x := rng.Intn(cfg.Width)
y := rng.Intn(cfg.Height)
starCol := animeColors[rng.Intn(len(animeColors))]
starCol.A = 100
drawStarDot(img, x, y, starCol)
// 少量柔和彩色噪点(半透明小点)
for i := 0; i < 20; i++ {
x := ix0 + rand.Intn(boxW)
y := iy0 + rand.Intn(boxH)
col := softCol(animeColors[rand.Intn(len(animeColors))], uint8(rand.Intn(50)+40))
setPx(img, x, y, col)
}
// 彩色噪点
for i := 0; i < 100; i++ {
x := rng.Intn(cfg.Width)
y := rng.Intn(cfg.Height)
col := animeColors[rng.Intn(len(animeColors))]
col.A = uint8(rng.Intn(100) + 80)
img.Set(x, y, col)
}
// ── 绘制文字 — 4px 像素块,带倾斜效果 ──
charBlockW := cfg.Width / cfg.Length
charPixelSize := 4 // 4px 像素块
charMapW := 7 // 字符矩阵宽度
charMapH := 9 // 字符矩阵高度
charW := charMapW * charPixelSize
charH := charMapH * charPixelSize
for i, ch := range code {
// 每个字符在其块中居中
blockCenterX := i*charBlockW + charBlockW/2
charX := blockCenterX - charW/2
charY := (cfg.Height - charH) / 2
// 微调位置
charX += rng.Intn(5) - 2
charY += rng.Intn(3) - 1
// 每个字符选择鲜艳的二次元颜色
charColor := animeColors[rng.Intn(len(animeColors))]
// 随机倾斜程度 (shear: 每行偏移像素数)
shear := rng.Intn(2) + 1 // 1~2
// 1/3 概率向右倾斜,2/3 向左倾斜
if rng.Intn(3) != 0 {
shear = -shear
// ── 干扰线:贯穿式弯曲斜线(左右端随机高度 + 强制大倾角 + 四向随机,
// 线条本身带 1~2 个正弦拱——单拱似抛物线,双拱似波浪;绘制在数字上层)──
for i := 0; i < 3; i++ {
col := softCol(lineColors[rand.Intn(len(lineColors))], uint8(rand.Intn(40)+60))
y1 := iy0 + rand.Intn(boxH+1)
y2 := iy0 + rand.Intn(boxH+1)
// 保证“很斜”:两端高度差至少内容高的一半;差值方向即上斜/下斜随机
if dy := y2 - y1; abs(dy) < boxH/2 {
step := boxH/2 + rand.Intn(boxH/4+1)
if dy < 0 {
y2 = y1 - step // 右端更低 → 左高右低(下斜)
if y2 < iy0 {
y2, y1 = iy0, min(iy0+step, iy1)
}
} else {
y2 = y1 + step // 右端更高 → 左低右高(上斜)
if y2 > iy1 {
y2, y1 = iy1, max(iy1-step, iy0)
}
}
}
drawCharLarge(img, charX, charY, string(ch), charColor, charPixelSize, shear)
// 随机交换起止端点,覆盖左斜/右斜方向
if rand.Intn(2) == 0 {
y1, y2 = y2, y1
}
drawCurvedLine(img, ix0, y1, ix1, y2, col, 2)
}
// 编码为 PNG base64
@@ -170,10 +300,9 @@ func GenerateCaptcha() (*CaptchaResult, error) {
// 生成唯一 ID 并存储验证码
captchaID := generateCode(16)
captchaStore.Store(captchaID, CaptchaValue{
Code: code,
ExpiresAt: time.Now().Add(5 * time.Minute),
})
if err := storeCaptchaCode(captchaID, code); err != nil {
return nil, err
}
return &CaptchaResult{
CaptchaID: captchaID,
@@ -190,6 +319,19 @@ func VerifyCaptcha(captchaID, code string) bool {
if captchaID == "" || code == "" {
return false
}
// 优先 Redis:GETDEL 原子取出并删除(Redis 6.2+)
if Redis != nil {
ctx, cancel := redisCaptchaCtx()
defer cancel()
val, err := Redis.GetDel(ctx, fmt.Sprintf(captchaKeyFmt, captchaID)).Result()
if err == nil {
return strings.EqualFold(val, code)
}
if err != redis.Nil {
slog.Warn("验证码校验读取 Redis 失败", "err", err)
}
// 未命中或出错:再查内存兜底
}
val, ok := captchaStore.Load(captchaID)
if !ok {
return false
@@ -202,59 +344,232 @@ func VerifyCaptcha(captchaID, code string) bool {
return strings.EqualFold(cv.Code, code)
}
// GetCaptchaDebug 读取验证码(调试用)
func GetCaptchaDebug(captchaID string) (string, bool) {
val, ok := captchaStore.Load(captchaID)
if !ok {
return "", false
// AllowCaptchaToken 验证码生成令牌桶限流:同 IP 5 分钟内最多获取 10 次。
// 返回 (true, 0) 表示允许生成并已扣减 token;
// 返回 (false, wait) 表示被限流,wait 为最快可再次获取的等待时长(已向上取整到秒)。
// 空 IP 或 Redis 不可用/出错时放行(存储兜底同样受限,避免误伤正常用户)。
func AllowCaptchaToken(ip string) (bool, time.Duration) {
if ip == "" || Redis == nil {
return true, 0
}
cv := val.(CaptchaValue)
return cv.Code, true
ctx, cancel := redisCaptchaCtx()
defer cancel()
rateMs := float64(captchaBucketCapacity) / float64(captchaBucketWindow.Milliseconds())
res, err := captchaTokenScript.Run(ctx, Redis,
[]string{fmt.Sprintf(captchaRLKey, ip)},
captchaBucketCapacity,
fmt.Sprintf("%.12f", rateMs),
time.Now().UnixMilli(),
captchaBucketWindow.Milliseconds(),
).Int64Slice()
if err != nil {
slog.Warn("验证码限流 Redis 操作失败,放行", "err", err)
return true, 0
}
if len(res) != 2 {
return true, 0
}
if res[0] == 1 {
return true, 0
}
// waitMs 向上取整到秒(至少 1 秒)
wait := time.Duration(((res[1]+999)/1000)*1000) * time.Millisecond
if wait < time.Second {
wait = time.Second
}
return false, wait
}
// FormatWait 将等待时长格式化为提示文案片段:如 "5 秒"、"3 分钟"
func FormatWait(d time.Duration) string {
secs := int((d + 999*time.Millisecond) / time.Second)
switch {
case secs < 60:
return fmt.Sprintf("%d 秒", secs)
case secs < 3600:
return fmt.Sprintf("%d 分钟", (secs+59)/60)
default:
return fmt.Sprintf("%d 小时", (secs+3599)/3600)
}
}
// setPx 带边界检查的安全画点
func setPx(img *image.RGBA, x, y int, col color.Color) {
if x >= 0 && x < img.Bounds().Max.X && y >= 0 && y < img.Bounds().Max.Y {
img.Set(x, y, col)
}
}
// drawCurvedLine 绘制带弧度的贯穿斜线:沿 (x1,y1)→(x2,y2) 直线走向,
// 按线段法向叠加 1~2 个正弦拱偏移(单拱似抛物线、双拱似波浪),拱向随机,
// 起止点仍在直线两端——整体保持大倾角四向随机,线条本身带柔和小波浪。
func drawCurvedLine(img *image.RGBA, x1, y1, x2, y2 int, col color.Color, thickness int) {
dx, dy := x2-x1, y2-y1
steps := max(abs(dx), abs(dy))
if steps == 0 {
return
}
lineLen := math.Hypot(float64(dx), float64(dy))
amp := 3 + rand.Intn(4) // 拱高 3~6px
arches := 1 + rand.Intn(2) // 1~2 个拱
phase := rand.Float64() * 2 * math.Pi
flip := 1.0
if rand.Intn(2) == 0 {
flip = -1.0 // 拱起方向随机(法向正/负)
}
nx, ny := float64(-dy)/lineLen, float64(dx)/lineLen // 单位法向量
for i := 0; i <= steps; i++ {
t := float64(i) / float64(steps)
cx := float64(x1) + float64(dx)*t
cy := float64(y1) + float64(dy)*t
off := flip * float64(amp) * math.Sin(float64(arches)*2*math.Pi*t+phase)
px := int(cx + nx*off + 0.5)
py := int(cy + ny*off + 0.5)
for k := 0; k < thickness; k++ {
setPx(img, px, py+k-thickness/2, col)
}
}
}
// drawRing 绘制空心圆环(泡泡装饰)
func drawRing(img *image.RGBA, cx, cy, radius int, col color.Color) {
for step := 0; step < 72; step++ {
a := float64(step) * math.Pi / 36
x := cx + int(float64(radius)*math.Cos(a)+0.5)
y := cy + int(float64(radius)*math.Sin(a)+0.5)
setPx(img, x, y, col)
}
}
// strokeDirs 白色描边的 12 方向偏移(半径 2px 圆周均匀采样,叠出圆润粗描边)
var strokeDirs []image.Point
func init() {
for i := 0; i < 12; i++ {
a := float64(i) * math.Pi / 6
strokeDirs = append(strokeDirs, image.Point{
int(math.Round(2 * math.Cos(a))),
int(math.Round(2 * math.Sin(a))),
})
}
}
// drawStickerChar 以 (cx,cy) 为中心绘制“贴纸风”字符:
// Sour Gummy 圆润字形 + 12 方向白色粗描边(贴纸效果)+ 糖果色填充,
// 再按 angleDeg 旋转后以 alpha 混合贴到底图。
// 按字形实际包围盒(而非 ascent/descent 行高)在临时画布中精确居中——
// 数字无下降部,若按行高定位会整体偏上、下方留白偏大。
func drawStickerChar(dst *image.RGBA, face font.Face, cx, cy int, char string, col color.RGBA, angleDeg float64) {
// 测量字形包围盒(相对绘制原点 Dot 的 26.6 定点坐标)
gb, _, _, _, ok := face.Glyph(fixed.Point26_6{}, []rune(char)[0])
if !ok {
return
}
glyphW := gb.Dx()
glyphH := gb.Dy()
pad := 4 // 描边 + 旋转余量
tmpW, tmpH := glyphW+pad*2, glyphH+pad*2
tmp := image.NewRGBA(image.Rect(0, 0, tmpW, tmpH))
// 平移 Dot 使字形包围盒左上角恰好落在 (pad,pad),即字形在画布中精确居中
baseDX := fixed.I(pad - gb.Min.X)
baseDY := fixed.I(pad - gb.Min.Y)
// 白色描边:12 方向偏移叠加
white := image.NewUniform(color.RGBA{255, 255, 255, 255})
for _, off := range strokeDirs {
fd := &font.Drawer{
Dst: tmp, Src: white, Face: face,
Dot: fixed.Point26_6{X: baseDX + fixed.I(off.X), Y: baseDY + fixed.I(off.Y)},
}
fd.DrawString(char)
}
// 糖果色主体
fd := &font.Drawer{
Dst: tmp, Src: image.NewUniform(col), Face: face,
Dot: fixed.Point26_6{X: baseDX, Y: baseDY},
}
fd.DrawString(char)
rotateOverlay(dst, tmp, cx, cy, angleDeg)
}
// rotateOverlay 将透明背景的 src 以 (cx,cy) 为中心旋转 angleDeg 后混合到 dst。
// 反向映射 + 双线性采样:1:1 旋转下无空洞、边缘平滑无锯齿。
func rotateOverlay(dst, src *image.RGBA, cx, cy int, angleDeg float64) {
a := angleDeg * math.Pi / 180
cos, sin := math.Cos(a), math.Sin(a)
srcCx := float64(src.Bounds().Dx()) / 2
srcCy := float64(src.Bounds().Dy()) / 2
radius := int(math.Hypot(srcCx, srcCy)) + 1
db := dst.Bounds()
for y := cy - radius; y <= cy+radius; y++ {
if y < db.Min.Y || y >= db.Max.Y {
continue
}
for x := cx - radius; x <= cx+radius; x++ {
if x < db.Min.X || x >= db.Max.X {
continue
}
dx, dy := float64(x-cx), float64(y-cy)
sx := cos*dx + sin*dy + srcCx
sy := -sin*dx + cos*dy + srcCy
sr, sg, sb, sa := sampleBilinear(src, sx, sy)
if sa <= 0 {
continue
}
// 预乘 alpha 域的 over 混合:out = src + dst*(1-α)
af := sa / 255
dr, dg, dbb, da := dst.At(x, y).RGBA()
nr := sr + float64(dr>>8)*(1-af)
ng := sg + float64(dg>>8)*(1-af)
nb := sb + float64(dbb>>8)*(1-af)
na := sa + float64(da>>8)*(1-af)
dst.Set(x, y, color.RGBA{uint8(nr), uint8(ng), uint8(nb), uint8(na)})
}
}
}
// sampleBilinear 对预乘 alpha 的 RGBA 图做双线性采样,返回 0-255 范围的预乘 RGBA
func sampleBilinear(src *image.RGBA, sx, sy float64) (r, g, b, a float64) {
bnd := src.Bounds()
x0, y0 := int(math.Floor(sx)), int(math.Floor(sy))
fx, fy := sx-float64(x0), sy-float64(y0)
if sx < 0 || sy < 0 || sx >= float64(bnd.Max.X) || sy >= float64(bnd.Max.Y) {
return 0, 0, 0, 0
}
for dy := 0; dy <= 1; dy++ {
for dx := 0; dx <= 1; dx++ {
px, py := x0+dx, y0+dy
if px < bnd.Min.X || px >= bnd.Max.X || py < bnd.Min.Y || py >= bnd.Max.Y {
continue
}
wx := 1 - fx
if dx == 1 {
wx = fx
}
wy := 1 - fy
if dy == 1 {
wy = fy
}
w := wx * wy
if w == 0 {
continue
}
c := src.RGBAAt(px, py)
r += float64(c.R) * w
g += float64(c.G) * w
b += float64(c.B) * w
a += float64(c.A) * w
}
}
return
}
// 画粗线(绘制多条平行线模拟粗线)
func drawThickLine(img *image.RGBA, x1, y1, x2, y2 int, col color.Color, thickness int) {
dx, dy := x2-x1, y2-y1
steps := max(abs(dx), abs(dy))
if steps == 0 {
return
}
for i := 0; i <= steps; i++ {
cx := x1 + dx*i/steps
cy := y1 + dy*i/steps
// 在垂直方向偏移绘制粗线效果
for t := -(thickness - 1); t < thickness; t++ {
// 根据线的方向决定偏移方向
var px, py int
if abs(dx) > abs(dy) {
px = cx
py = cy + t
} else {
px = cx + t
py = cy
}
if px >= 0 && px < img.Bounds().Max.X && py >= 0 && py < img.Bounds().Max.Y {
img.Set(px, py, col)
}
}
}
}
// 划线(单像素)
func drawLine(img *image.RGBA, x1, y1, x2, y2 int, col color.Color) {
dx, dy := x2-x1, y2-y1
steps := max(abs(dx), abs(dy))
if steps == 0 {
return
}
for i := 0; i <= steps; i++ {
x := x1 + dx*i/steps
y := y1 + dy*i/steps
if x >= 0 && x < img.Bounds().Max.X && y >= 0 && y < img.Bounds().Max.Y {
img.Set(x, y, col)
}
}
}
// 绘制半透明圆圈
func drawCircle(img *image.RGBA, cx, cy, radius int, col color.Color) {
@@ -271,165 +586,6 @@ func drawCircle(img *image.RGBA, cx, cy, radius int, col color.Color) {
}
}
// 绘制小星星闪光点
func drawStarDot(img *image.RGBA, x, y int, col color.Color) {
offsets := [][2]int{
{0, 0}, {1, 0}, {-1, 0}, {0, 1}, {0, -1},
{1, 1}, {-1, -1}, {1, -1}, {-1, 1},
}
for _, off := range offsets {
px := x + off[0]
py := y + off[1]
if px >= 0 && px < img.Bounds().Max.X && py >= 0 && py < img.Bounds().Max.Y {
img.Set(px, py, col)
}
}
}
// 使用 7x9 点阵绘制大号字符(带倾斜 shear 效果)
func drawCharLarge(img *image.RGBA, x, y int, char string, col color.Color, pixelSize int, shear int) {
// 7x9 数字点阵映射
charMaps := map[string][][]int{
"0": {
{0, 1, 1, 1, 1, 1, 0},
{1, 1, 1, 1, 1, 1, 1},
{1, 1, 0, 0, 0, 1, 1},
{1, 1, 0, 0, 0, 1, 1},
{1, 1, 0, 0, 0, 1, 1},
{1, 1, 0, 0, 0, 1, 1},
{1, 1, 0, 0, 0, 1, 1},
{1, 1, 1, 1, 1, 1, 1},
{0, 1, 1, 1, 1, 1, 0},
},
"1": {
{0, 0, 0, 1, 0, 0, 0},
{0, 0, 1, 1, 0, 0, 0},
{0, 1, 1, 1, 0, 0, 0},
{0, 0, 0, 1, 0, 0, 0},
{0, 0, 0, 1, 0, 0, 0},
{0, 0, 0, 1, 0, 0, 0},
{0, 0, 0, 1, 0, 0, 0},
{0, 0, 0, 1, 0, 0, 0},
{0, 1, 1, 1, 1, 1, 0},
},
"2": {
{0, 1, 1, 1, 1, 1, 0},
{1, 1, 1, 1, 1, 1, 1},
{0, 0, 0, 0, 0, 1, 1},
{0, 0, 0, 0, 0, 1, 1},
{0, 1, 1, 1, 1, 1, 0},
{1, 1, 0, 0, 0, 0, 0},
{1, 1, 0, 0, 0, 0, 0},
{1, 1, 1, 1, 1, 1, 1},
{0, 1, 1, 1, 1, 1, 0},
},
"3": {
{0, 1, 1, 1, 1, 1, 0},
{1, 1, 1, 1, 1, 1, 1},
{0, 0, 0, 0, 0, 1, 1},
{0, 0, 0, 0, 0, 1, 1},
{0, 1, 1, 1, 1, 1, 0},
{0, 0, 0, 0, 0, 1, 1},
{0, 0, 0, 0, 0, 1, 1},
{1, 1, 1, 1, 1, 1, 1},
{0, 1, 1, 1, 1, 1, 0},
},
"4": {
{0, 0, 0, 0, 1, 1, 0},
{0, 0, 0, 1, 1, 1, 0},
{0, 0, 1, 1, 1, 1, 0},
{0, 1, 1, 0, 1, 1, 0},
{1, 1, 0, 0, 1, 1, 0},
{1, 1, 1, 1, 1, 1, 1},
{0, 0, 0, 0, 1, 1, 0},
{0, 0, 0, 0, 1, 1, 0},
{0, 0, 0, 0, 1, 1, 0},
},
"5": {
{1, 1, 1, 1, 1, 1, 1},
{1, 1, 1, 1, 1, 1, 1},
{1, 1, 0, 0, 0, 0, 0},
{1, 1, 0, 0, 0, 0, 0},
{0, 1, 1, 1, 1, 1, 0},
{0, 0, 0, 0, 0, 1, 1},
{0, 0, 0, 0, 0, 1, 1},
{1, 1, 1, 1, 1, 1, 1},
{0, 1, 1, 1, 1, 1, 0},
},
"6": {
{0, 1, 1, 1, 1, 1, 0},
{1, 1, 1, 1, 1, 1, 1},
{1, 1, 0, 0, 0, 0, 0},
{1, 1, 0, 0, 0, 0, 0},
{1, 1, 1, 1, 1, 1, 0},
{1, 1, 0, 0, 0, 1, 1},
{1, 1, 0, 0, 0, 1, 1},
{1, 1, 1, 1, 1, 1, 1},
{0, 1, 1, 1, 1, 1, 0},
},
"7": {
{1, 1, 1, 1, 1, 1, 1},
{1, 1, 1, 1, 1, 1, 1},
{0, 0, 0, 0, 0, 1, 1},
{0, 0, 0, 0, 0, 1, 1},
{0, 0, 0, 0, 1, 1, 0},
{0, 0, 0, 1, 1, 0, 0},
{0, 0, 1, 1, 0, 0, 0},
{0, 0, 1, 1, 0, 0, 0},
{0, 0, 1, 1, 0, 0, 0},
},
"8": {
{0, 1, 1, 1, 1, 1, 0},
{1, 1, 1, 1, 1, 1, 1},
{1, 1, 0, 0, 0, 1, 1},
{1, 1, 0, 0, 0, 1, 1},
{0, 1, 1, 1, 1, 1, 0},
{1, 1, 0, 0, 0, 1, 1},
{1, 1, 0, 0, 0, 1, 1},
{1, 1, 1, 1, 1, 1, 1},
{0, 1, 1, 1, 1, 1, 0},
},
"9": {
{0, 1, 1, 1, 1, 1, 0},
{1, 1, 1, 1, 1, 1, 1},
{1, 1, 0, 0, 0, 1, 1},
{1, 1, 0, 0, 0, 1, 1},
{0, 1, 1, 1, 1, 1, 1},
{0, 0, 0, 0, 0, 1, 1},
{0, 0, 0, 0, 0, 1, 1},
{1, 1, 1, 1, 1, 1, 1},
{0, 1, 1, 1, 1, 1, 0},
},
}
pattern, ok := charMaps[char]
if !ok {
return
}
// shear: 每行相对中心行(4)的水平偏移
// shear=1: 行0偏移-4, 行8偏移+4 → 右倾
// shear=-1: 行0偏移+4, 行8偏移-4 → 左倾
for py, row := range pattern {
// 行偏移量 = (py - 4) * shear
rowShear := (py - 4) * shear
for px, v := range row {
if v == 1 {
ix := x + px*pixelSize + rowShear
iy := y + py*pixelSize
for dx := 0; dx < pixelSize; dx++ {
for dy := 0; dy < pixelSize; dy++ {
if ix+dx < img.Bounds().Max.X && iy+dy < img.Bounds().Max.Y {
img.Set(ix+dx, iy+dy, col)
}
}
}
}
}
}
}
func abs(x int) int {
if x < 0 {
return -x
@@ -4,6 +4,7 @@ import (
"net/http"
"net/url"
"sync"
"time"
)
// simpleCookieJar 简化的 CookieJar 实现
@@ -29,6 +30,20 @@ func (j *simpleCookieJar) SetCookies(u *url.URL, cookies []*http.Cookie) {
cookieMap[c.Name] = c
}
for _, c := range cookies {
// 删除指令(Max-Age<=0 或 Expires 已过)或空值:一律跳过,既不入库也不删除
// 已有同名 cookie。simpleCookieJar 不记录 Path/Domain,无法按 RFC 6265 精确
// 匹配删除指令的作用域。云大 CAS 登录 POST 成功后先发
// CASTGC=TGT-...; Path=/authserver
// 再发删除指令
// CASTGC=; Max-Age=0; path=/authserver/ (注意尾部斜杠,与 TGT 的 Path 不同)
// 浏览器依 RFC 6265 不会删除 TGT,后续 MFA 页面(reAuthLoginView.do)依赖该
// TGT 判定 SSO 会话;若按名字粗暴删除 TGT,MFA 页会被 302 踢回登录页。
// 若把空值 CASTGC 存进 jar 发送空值,同样会被判定会话异常(历史 bug)。
// 跳过删除指令可同时避免这两种问题;同名残留会在下次登录时被 CAS 重新
// Set-Cookie 覆盖,无实际副作用。
if c.MaxAge < 0 || (!c.Expires.IsZero() && c.Expires.Before(time.Now())) || c.Value == "" {
continue
}
cookieMap[c.Name] = c
}
@@ -67,3 +82,18 @@ func (j *simpleCookieJar) AllCookies() []*http.Cookie {
}
return result
}
// AllCookiesByHost 按域名分组返回所有 cookie(MFA 会话暂存用,保留域结构以便恢复)
func (j *simpleCookieJar) AllCookiesByHost() map[string][]*http.Cookie {
j.mu.RLock()
defer j.mu.RUnlock()
result := make(map[string][]*http.Cookie, len(j.cookies))
for host, cookies := range j.cookies {
if len(cookies) > 0 {
cp := make([]*http.Cookie, len(cookies))
copy(cp, cookies)
result[host] = cp
}
}
return result
}
+94 -20
View File
@@ -3,10 +3,13 @@ package cpdaily
import (
"bytes"
"crypto/tls"
"encoding/base64"
"encoding/json"
"fmt"
"io"
"mime/multipart"
"net/textproto"
"strings"
"time"
"github.com/go-resty/resty/v2"
@@ -14,11 +17,12 @@ import (
// CpImageUploadPolicy 今日校园 OBS 上传策略
type CpImageUploadPolicy struct {
AccessId string `json:"accessid"`
FileName string `json:"fileName"`
Policy string `json:"policy"`
Host string `json:"host"`
Signature string `json:"signature"`
AccessId string `json:"accessid"`
FileName string `json:"fileName"`
Policy string `json:"policy"`
Host string `json:"host"`
Signature string `json:"signature"`
FormData map[string]string `json:"formData"`
}
// getImageUrlPrefix 根据任务类型返回图片上传的 URL 前缀
@@ -26,6 +30,9 @@ func getImageUrlPrefix(taskType TaskType) string {
switch taskType {
case TaskTypeSleepCheck:
return "/wec-counselor-attendance-apps/student/attendance"
case TaskTypeLeave:
// 销假图片上传走请假模块
return "/wec-counselor-leave-apps/leave"
case TaskTypeSign:
return "/wec-counselor-sign-apps/stu"
default:
@@ -75,7 +82,11 @@ func GetUploadPolicy(cookieMap map[string]string, host string, taskType TaskType
return result.Datas, nil
}
// uploadToOBS 将文件上传到今日校园的 OBS
// uploadToOBS 将文件上传到今日校园的 OBS。
// 优先使用接口返回的 formData 字段集原样提交:不同任务类型的表单字段不同
// (查寝:AccessKeyId/signature/x-obs-acl;签到:q-ak/q-key-time/q-signature 等),
// 按旧方式自行拼装字段会导致签到类型上传被 OBS 拒绝(AccessDenied)。
// 仅当接口未返回 formData 时回退到通用字段拼装。
func uploadToOBS(policy *CpImageUploadPolicy, fileData []byte, contentType string) error {
client := resty.New().SetTimeout(30 * time.Second).SetTLSClientConfig(&tls.Config{InsecureSkipVerify: true})
@@ -87,15 +98,26 @@ func uploadToOBS(policy *CpImageUploadPolicy, fileData []byte, contentType strin
body := &bytes.Buffer{}
writer := multipart.NewWriter(body)
writer.WriteField("x-obs-acl", "public-read")
writer.WriteField("AccessKeyId", policy.AccessId)
writer.WriteField("key", policy.FileName)
writer.WriteField("signature", policy.Signature)
writer.WriteField("policy", policy.Policy)
writer.WriteField("content-type", contentType)
if len(policy.FormData) > 0 {
for k, v := range policy.FormData {
writer.WriteField(k, v)
}
header := make(textproto.MIMEHeader)
header.Set("Content-Disposition", `form-data; name="file"; filename="file.jpg"`)
header.Set("Content-Type", contentType)
part, _ := writer.CreatePart(header)
part.Write(fileData)
} else {
writer.WriteField("x-obs-acl", "public-read")
writer.WriteField("AccessKeyId", policy.AccessId)
writer.WriteField("key", policy.FileName)
writer.WriteField("signature", policy.Signature)
writer.WriteField("policy", policy.Policy)
writer.WriteField("content-type", contentType)
part, _ := writer.CreateFormFile("file", "file")
part.Write(fileData)
part, _ := writer.CreateFormFile("file", "file")
part.Write(fileData)
}
writer.Close()
resp, err := client.R().
@@ -123,6 +145,9 @@ func GetPreviewUrl(cookieMap map[string]string, host string, taskType TaskType,
switch taskType {
case TaskTypeSleepCheck:
apiPath = "/previewAttachment"
case TaskTypeLeave:
// 销假图片预览走请假模块 OBS 接口
apiPath = "/obs/previewAttachment"
case TaskTypeSign:
apiPath = "/sign/previewAttachment"
default:
@@ -145,15 +170,33 @@ func GetPreviewUrl(cookieMap map[string]string, host string, taskType TaskType,
body, _ := io.ReadAll(resp.RawBody())
var result struct {
ErrCode int `json:"errCode"`
Code json.RawMessage `json:"code"`
ErrMsg string `json:"errMsg"`
Message string `json:"message"`
Datas json.RawMessage `json:"datas"`
}
if err := json.Unmarshal(body, &result); err != nil {
return "", fmt.Errorf("解析预览 URL 响应失败: %w", err)
return "", fmt.Errorf("解析预览 URL 响应失败: %w (body: %.200s)", err, string(body))
}
if result.ErrCode != 0 {
return "", fmt.Errorf("获取预览 URL 错误(%d): %s", result.ErrCode, result.ErrMsg)
}
// 部分学校(如 cyprxy 代理域)返回 {"code":"0",...} 方言
if len(result.Code) > 0 {
var codeStr string
if json.Unmarshal(result.Code, &codeStr) == nil {
if codeStr != "" && codeStr != "0" {
msg := result.Message
if msg == "" {
msg = result.ErrMsg
}
return "", fmt.Errorf("获取预览 URL 错误(%s): %s", codeStr, msg)
}
}
}
if len(result.Datas) == 0 || string(result.Datas) == "null" {
return "", fmt.Errorf("获取预览 URL 为空(body: %.200s)", string(body))
}
var previewUrl string
if err := json.Unmarshal(result.Datas, &previewUrl); err != nil {
@@ -164,6 +207,7 @@ func GetPreviewUrl(cookieMap map[string]string, host string, taskType TaskType,
}
// UploadImage 完整的图片上传流程:获取策略 → 上传 OBS → 获取预览 URL
// 预览接口偶发返回空响应,做少量重试避免误判失败
func UploadImage(cookieMap map[string]string, host string, taskType TaskType, fileData []byte, contentType string) (string, error) {
policy, err := GetUploadPolicy(cookieMap, host, taskType)
if err != nil {
@@ -174,17 +218,47 @@ func UploadImage(cookieMap map[string]string, host string, taskType TaskType, fi
return "", err
}
previewUrl, err := GetPreviewUrl(cookieMap, host, taskType, policy.FileName)
if err != nil {
return "", err
var previewUrl string
var lastErr error
for i := 0; i < 3; i++ {
if i > 0 {
time.Sleep(time.Duration(i) * time.Second)
}
previewUrl, lastErr = GetPreviewUrl(cookieMap, host, taskType, policy.FileName)
if lastErr == nil {
return previewUrl, nil
}
// 业务性错误(错误码/数据为空)无需重试,仅空响应类瞬时错误重试
if !strings.Contains(lastErr.Error(), "解析预览 URL 响应失败") && !strings.Contains(lastErr.Error(), "获取预览 URL 为空") {
break
}
}
return previewUrl, nil
return "", lastErr
}
// IsDataURIImage 判断内容是否为 base64 图片(前端本地预览,尚未成功上传到 OBS)
func IsDataURIImage(content string) bool {
return strings.HasPrefix(content, "data:image")
}
// UploadDataURIImage 将 base64 图片解码后走正常 OBS 上传流程,返回正式预览 URL。
// 用于执行时自动修复历史表单中残留的 base64 照片(用户上传失败时保存的内容)。
func UploadDataURIImage(cookieMap map[string]string, host string, taskType TaskType, dataURI string) (string, error) {
idx := strings.Index(dataURI, ",")
if idx < 0 {
return "", fmt.Errorf("图片数据格式不正确")
}
imgBytes, err := base64.StdEncoding.DecodeString(dataURI[idx+1:])
if err != nil {
return "", fmt.Errorf("解码图片失败: %w", err)
}
return UploadImage(cookieMap, host, taskType, imgBytes, "image/jpeg")
}
func newImgClient() *resty.Client {
return resty.New().
SetTimeout(30*time.Second).
SetTimeout(30 * time.Second).
SetDoNotParseResponse(true).
SetTLSClientConfig(&tls.Config{InsecureSkipVerify: true})
}
+445
View File
@@ -0,0 +1,445 @@
package cpdaily
import (
"encoding/json"
"fmt"
"io"
"log/slog"
"strconv"
"strings"
)
// 销假(请假销假)功能。
// 对应今日校园 wec-counselor-leave-apps 模块:
//
// 待销假列表:POST {host}/wec-counselor-leave-apps/leave/stu/listOther
// 请假详情: POST {host}/wec-counselor-leave-apps/leave/stu/detailOther
// 审批人列表:POST {host}/wec-counselor-leave-apps/leave/stu/obtainApproveFlow
// 提交销假: POST {host}/wec-counselor-leave-apps/leave/report
const leaveAPIBase = "/wec-counselor-leave-apps/leave"
// LeaveReportPlace 学校规定的销假位置(详情 reportPlaces 元素)。
// 销假提交的位置应在其中一个位置的 radius(米)范围内;
// 老版本取 leaveExtends[0](续假位置)或 detail.applyLocation(请假提交时定位),
// 均非学校规定的销假位置,已按真实接口修正为 reportPlaces。
type LeaveReportPlace struct {
Wid string `json:"wid"`
Address string `json:"address"`
Longitude string `json:"longitude"`
Latitude string `json:"latitude"`
Radius string `json:"radius"` // 允许范围半径(米)
CounName string `json:"counName"` // 位置设置人
}
// LeaveApprover 销假可选审批人(leaveType.reportApplyFlow=1 时需要)
type LeaveApprover struct {
UserWid string `json:"userWid"`
UserId string `json:"userId"`
UserName string `json:"userName"`
}
// LeaveItem 待销假记录(列表展示 + 销假表单所需开关/规定位置)
type LeaveItem struct {
ID string `json:"id"` // 请假记录 ID
LeaveType string `json:"leaveType"` // 请假类型名称(如 事假/病假)
Reason string `json:"reason"` // 请假原因
CreateTime string `json:"createTime"` // 请假提交时间
ActEndTime string `json:"actEndTime"` // 预计结束时间
Status string `json:"status"` // 系统请假状态码(4=已完成,已过滤)
// 学校规定的销假位置(详情 reportPlaces)。有值时前端展示可选列表并预填经纬度;
// 无值时提示用户手动填写
ReportPlaces []LeaveReportPlace `json:"reportPlaces"`
// 销假是否需要选择审批人(leaveType.reportApplyFlow=1)
ReportApplyFlow bool `json:"reportApplyFlow"`
// 销假是否允许上传图片(leaveType.enableAttach=1)
EnableAttach bool `json:"enableAttach"`
}
// LeaveDetail 请假详情(提交销假所需字段)
type LeaveDetail struct {
ReportID string // datas.detail.id,提交表单的 id 字段
LeaveType string // datas.leaveType.name
LeaveCode string // datas.leaveType.code,提交表单 leaveType 字段(接口返回为数字,统一转纯文本)
LeaveCodeNum int // datas.leaveType.code 数字形式(obtainApproveFlow 请求用)
StartTime string // datas.detail.startDate(审批流请求用,提交前补秒)
EndTime string // datas.detail.endDate
ReportApplyFlow bool // 销假需要选择审批人
EnableAttach bool // 销假允许上传图片
ReportPlaces []LeaveReportPlace
}
// leaveAnyStr 将 interface{} 值转为纯文本字符串(数字类型避免科学计数法)
func leaveAnyStr(v interface{}) string {
if v == nil {
return ""
}
switch t := v.(type) {
case string:
return t
case float64:
return strconv.FormatFloat(t, 'f', -1, 64)
case json.Number:
return t.String()
case bool:
return strconv.FormatBool(t)
default:
return fmt.Sprintf("%v", v)
}
}
// leaveAnyBool 将 interface{} 值转为布尔(兼容数字/字符串/布尔三种返回风格)
func leaveAnyBool(v interface{}) bool {
switch t := v.(type) {
case bool:
return t
case float64:
return t != 0
case json.Number:
n, _ := t.Int64()
return n != 0
case string:
return t == "1" || strings.EqualFold(t, "true")
default:
return false
}
}
// leaveAnyInt 将 interface{} 值转为整数(兼容数字/字符串两种返回风格)
func leaveAnyInt(v interface{}) int {
switch t := v.(type) {
case float64:
return int(t)
case json.Number:
n, _ := t.Int64()
return int(n)
case string:
n, _ := strconv.Atoi(strings.TrimSpace(t))
return n
case bool:
if t {
return 1
}
return 0
default:
return 0
}
}
// leaveMapStr 从 map 中取字符串值
func leaveMapStr(m map[string]interface{}, key string) string {
if m == nil {
return ""
}
return leaveAnyStr(m[key])
}
// leaveTimeWithSeconds 将 "2025-10-24 20:45" 补秒为审批流接口要求的 "2025-10-24 20:45:00"
func leaveTimeWithSeconds(t string) string {
t = strings.TrimSpace(t)
if len(t) == 16 { // yyyy-MM-dd HH:mm
return t + ":00"
}
return t
}
// leavePost 通用 POST 请求(带移动端 UA 与 Cookie),返回响应 body;
// extension 非空时额外携带 Cpdaily-Extension 签名头(销假提交用)
func leavePost(cookieMap map[string]string, url string, body interface{}, extension string) ([]byte, error) {
client := newTaskClient()
req := client.R().
SetHeader("User-Agent", "Mozilla/5.0 (Linux; Android 15; Redmi Note 8 Pro) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/131.0.6778.81 Mobile Safari/537.36 cpdaily/9.9.6 wisedu/9.9.6").
SetHeader("CpdailyClientType", "CPDAILY").
SetHeader("Content-Type", "application/json; charset=UTF-8").
SetHeader("Cookie", cookiesToStr(cookieMap))
if extension != "" {
req = req.SetHeader("Cpdaily-Extension", extension)
}
resp, err := req.SetBody(body).Post(url)
if err != nil {
return nil, err
}
defer resp.RawBody().Close()
return io.ReadAll(resp.RawBody())
}
// FetchLeaveList 获取今日校园待销假列表。
// 过滤已完成(status=4)的记录;每条记录附带详情中的规定销假位置(reportPlaces)
// 与开关(reportApplyFlow/enableAttach)。
// 详情获取失败不阻塞列表展示(仅视为无规定位置、无开关)。
func FetchLeaveList(cookieMap map[string]string, host string) ([]LeaveItem, error) {
body, err := leavePost(cookieMap, host+leaveAPIBase+"/stu/listOther", map[string]interface{}{
"pageNumber": 1,
"pageSize": 20,
"needApproval": 1,
}, "")
if err != nil {
return nil, fmt.Errorf("获取待销假列表失败: %w", err)
}
var result struct {
Code json.Number `json:"code"`
ErrCode int `json:"errCode"`
Message string `json:"message"`
Datas struct {
Rows []map[string]interface{} `json:"rows"`
} `json:"datas"`
}
if err := json.Unmarshal(body, &result); err != nil {
return nil, fmt.Errorf("解析待销假列表失败: %w (body: %.200s)", err, string(body))
}
if result.ErrCode != 0 {
return nil, fmt.Errorf("获取待销假列表错误(%d): %s", result.ErrCode, result.Message)
}
code, _ := result.Code.Int64()
if code != 0 {
msg := result.Message
if msg == "" {
msg = "未知错误"
}
return nil, fmt.Errorf("%s", msg)
}
list := make([]LeaveItem, 0, len(result.Datas.Rows))
for _, row := range result.Datas.Rows {
status := leaveMapStr(row, "status")
if status == "4" { // 已完成,无需销假
continue
}
id := leaveMapStr(row, "id")
if id == "" {
continue
}
item := LeaveItem{
ID: id,
LeaveType: leaveMapStr(row, "leaveType"),
Reason: leaveMapStr(row, "leaveReason"),
CreateTime: leaveMapStr(row, "createTime"),
ActEndTime: leaveMapStr(row, "actEndTime"),
Status: status,
}
// 补充规定销假位置与开关(来自详情 reportPlaces / leaveType)
if d, err := FetchLeaveDetail(cookieMap, host, id); err == nil {
item.ReportPlaces = d.ReportPlaces
item.ReportApplyFlow = d.ReportApplyFlow
item.EnableAttach = d.EnableAttach
} else {
slog.Warn("获取请假详情失败,规定销假位置与开关视为空", "leaveId", id, "error", err)
}
list = append(list, item)
}
return list, nil
}
// FetchLeaveDetail 获取请假详情(提交销假所需字段 + 规定销假位置与开关)
func FetchLeaveDetail(cookieMap map[string]string, host, leaveID string) (*LeaveDetail, error) {
body, err := leavePost(cookieMap, host+leaveAPIBase+"/stu/detailOther", map[string]interface{}{
"id": leaveID,
"needApproval": 1,
"uaIsCpadaily": "true",
}, "")
if err != nil {
return nil, fmt.Errorf("获取请假详情失败: %w", err)
}
// 注意:leaveType.code 真实返回为数字(如 3/12),必须用 interface{} 承接
var result struct {
Code json.Number `json:"code"`
ErrCode int `json:"errCode"`
Message string `json:"message"`
Datas struct {
Detail struct {
ID string `json:"id"`
StartDate string `json:"startDate"`
EndDate string `json:"endDate"`
} `json:"detail"`
LeaveType struct {
Code interface{} `json:"code"`
Name string `json:"name"`
ReportApplyFlow interface{} `json:"reportApplyFlow"`
EnableAttach interface{} `json:"enableAttach"`
} `json:"leaveType"`
ReportPlaces []struct {
Wid interface{} `json:"wid"`
Address string `json:"address"`
Longitude interface{} `json:"longitude"`
Latitude interface{} `json:"latitude"`
Radius interface{} `json:"radius"`
CounName string `json:"counName"`
} `json:"reportPlaces"`
} `json:"datas"`
}
if err := json.Unmarshal(body, &result); err != nil {
return nil, fmt.Errorf("解析请假详情失败: %w (body: %.200s)", err, string(body))
}
if result.ErrCode != 0 {
return nil, fmt.Errorf("获取请假详情错误(%d): %s", result.ErrCode, result.Message)
}
code, _ := result.Code.Int64()
if code != 0 {
msg := result.Message
if msg == "" {
msg = "未知错误"
}
return nil, fmt.Errorf("%s", msg)
}
if result.Datas.Detail.ID == "" {
return nil, fmt.Errorf("没有获取到请假详情信息")
}
codeStr := leaveAnyStr(result.Datas.LeaveType.Code)
codeNum, _ := strconv.Atoi(codeStr)
d := &LeaveDetail{
ReportID: result.Datas.Detail.ID,
LeaveType: result.Datas.LeaveType.Name,
LeaveCode: codeStr,
LeaveCodeNum: codeNum,
StartTime: result.Datas.Detail.StartDate,
EndTime: result.Datas.Detail.EndDate,
ReportApplyFlow: leaveAnyBool(result.Datas.LeaveType.ReportApplyFlow),
EnableAttach: leaveAnyBool(result.Datas.LeaveType.EnableAttach),
}
for _, p := range result.Datas.ReportPlaces {
d.ReportPlaces = append(d.ReportPlaces, LeaveReportPlace{
Wid: leaveAnyStr(p.Wid),
Address: p.Address,
Longitude: leaveAnyStr(p.Longitude),
Latitude: leaveAnyStr(p.Latitude),
Radius: leaveAnyStr(p.Radius),
CounName: p.CounName,
})
}
return d, nil
}
// FetchReportApprovers 获取销假可选审批人(leaveType.reportApplyFlow=1 的请假类型使用)。
// 复用请假申请的审批流接口;时间必须精确到秒(yyyy-MM-dd HH:mm:ss)。
// 优先取 datas.approvers(首节点可选审批人),为空时兜底展开 approveNodes 中的全部审批人。
func FetchReportApprovers(cookieMap map[string]string, host string, leaveCodeNum int, startTime, endTime string) ([]LeaveApprover, error) {
body, err := leavePost(cookieMap, host+leaveAPIBase+"/stu/obtainApproveFlow", map[string]interface{}{
"leaveType": leaveCodeNum,
"startTime": leaveTimeWithSeconds(startTime),
"endTime": leaveTimeWithSeconds(endTime),
}, "")
if err != nil {
return nil, fmt.Errorf("获取销假审批人失败: %w", err)
}
var result struct {
Code json.Number `json:"code"`
Message string `json:"message"`
Datas struct {
Approvers []LeaveApprover `json:"approvers"`
ApproveNodes []struct {
ApproveNode []struct {
NodeName string `json:"nodeName"`
Approvers []LeaveApprover `json:"approvers"`
} `json:"approveNode"`
} `json:"approveNodes"`
} `json:"datas"`
}
if err := json.Unmarshal(body, &result); err != nil {
return nil, fmt.Errorf("解析销假审批人失败: %w (body: %.200s)", err, string(body))
}
code, _ := result.Code.Int64()
if code != 0 {
msg := result.Message
if msg == "" {
msg = "未知错误"
}
return nil, fmt.Errorf("%s", msg)
}
list := make([]LeaveApprover, 0, len(result.Datas.Approvers))
seen := make(map[string]bool)
for _, a := range result.Datas.Approvers {
if a.UserWid == "" || seen[a.UserWid] {
continue
}
seen[a.UserWid] = true
list = append(list, a)
}
if len(list) == 0 {
for _, n := range result.Datas.ApproveNodes {
for _, node := range n.ApproveNode {
for _, a := range node.Approvers {
if a.UserWid == "" || seen[a.UserWid] {
continue
}
seen[a.UserWid] = true
list = append(list, a)
}
}
}
}
return list, nil
}
// BuildLeaveReportForm 构造销假提交表单(bodyString 内容)。
// reportLocation 为用户选择/填写的销假地址,为空时兜底第一个规定销假位置地址;
// approverWid 为所选审批人(reportApplyFlow=1 时必填,其余传空)。
func BuildLeaveReportForm(d *LeaveDetail, address, imageUrl, approverWid string) map[string]interface{} {
if address == "" && len(d.ReportPlaces) > 0 {
address = d.ReportPlaces[0].Address
}
form := map[string]interface{}{
"id": d.ReportID,
"needApproval": 1,
"leaveType": d.LeaveCode,
"reportLocation": address,
"approverWid": approverWid,
"reportApplyExplain": nil,
"uaIsCpadaily": true,
}
if imageUrl != "" {
form["reportAttach"] = []map[string]interface{}{
{"type": 1, "url": imageUrl},
}
} else {
form["reportAttach"] = []interface{}{}
}
return form
}
// SubmitLeaveReport 提交销假(bodyString 加密 + Cpdaily-Extension 签名,与签到提交同构)
func SubmitLeaveReport(cookieMap map[string]string, host string, form map[string]interface{}, lon, lat, deviceId, username string) error {
bodyString := GetBodyString(form)
extensionString := GetExtensionString(lat, lon, deviceId, username)
submitData := map[string]interface{}{
"lon": lon,
"version": "first_v4",
"calVersion": "first_v4",
"deviceId": deviceId,
"systemName": "android",
"bodyString": bodyString,
"lat": lat,
"systemVersion": "15",
"appVersion": "9.9.6",
"model": "cpdaily",
}
submitData["userId"] = username
submitData["sign"] = GetSubmitSign(submitData)
body, err := leavePost(cookieMap, host+leaveAPIBase+"/report", submitData, extensionString)
if err != nil {
return fmt.Errorf("提交销假请求失败: %w", err)
}
var result struct {
Code json.Number `json:"code"`
Message string `json:"message"`
}
if err := json.Unmarshal(body, &result); err != nil {
return fmt.Errorf("解析销假响应失败: %w, body: %s", err, string(body))
}
code, _ := result.Code.Int64()
slog.Info("leave/report 响应", "code", result.Code.String(), "message", result.Message, "body", string(body))
if code != 0 {
msg := result.Message
if msg == "" {
msg = "未知错误"
}
return fmt.Errorf("%s", msg)
}
return nil
}
@@ -0,0 +1,288 @@
package cpdaily
import (
"encoding/json"
"fmt"
"log/slog"
"strconv"
)
// 请假申请功能(与销假同属 wec-counselor-leave-apps 模块):
//
// 请假类型列表:POST {host}/wec-counselor-leave-apps/leave/queryLeaveTypes
// 类型详细配置:POST {host}/wec-counselor-leave-apps/leave/stu/queryLeaveTypeCfg
// 可请假检查: POST {host}/wec-counselor-leave-apps/leave/stu/checkOnceLeave
// 审批人列表: POST {host}/wec-counselor-leave-apps/leave/stu/obtainApproveFlow
// 提交请假: POST {host}/wec-counselor-leave-apps/leave/apply
//
// 注意:真实接口中 queryLeaveTypes 只返回基础信息(needApproval/enableAttach 等
// 均为 null),权威开关全部来自 queryLeaveTypeCfg,故列表接口会把两者合并后返回。
// LeaveType 请假类型(queryLeaveTypes + queryLeaveTypeCfg 合并后的完整信息)
type LeaveType struct {
Code int `json:"code"` // 类型编码(提交与查询都用它)
Name string `json:"name"` // 类型名称
MustOut int `json:"mustOut"` // 1=必须离校(离校开关强制打开且不可改)
NeedApproval int `json:"needApproval"` // 1=需要选择审批人
EnableAttach int `json:"enableAttach"` // 1=允许上传图片附件
ReasonWordLimit int `json:"reasonWordLimit"` // 请假原因最少字数
AttachmentNumberLimit int `json:"attachmentNumberLimit"` // 附件数量上限
ChooseAttendance int `json:"chooseAttendance"` // 1=需要打卡相关核验(透传)
DisableTime int `json:"disableTime"` // 1=当前类型不可用
LimitDay int `json:"limitDay"` // 单次最长可请假天数(0=不限)
}
// CheckOnceLeave 检查是否允许请假(学校可能配置同一时间仅一条请假单等限制)
func CheckOnceLeave(cookieMap map[string]string, host string) error {
body, err := leavePost(cookieMap, host+leaveAPIBase+"/stu/checkOnceLeave", map[string]interface{}{}, "")
if err != nil {
return fmt.Errorf("检查请假资格失败: %w", err)
}
var result struct {
Code json.Number `json:"code"`
Message string `json:"message"`
}
if err := json.Unmarshal(body, &result); err != nil {
return fmt.Errorf("解析请假资格响应失败: %w (body: %.200s)", err, string(body))
}
code, _ := result.Code.Int64()
if code != 0 {
msg := result.Message
if msg == "" {
msg = "当前不可请假"
}
return fmt.Errorf("%s", msg)
}
return nil
}
// FetchLeaveTypes 获取请假类型列表(已合并 queryLeaveTypeCfg 的权威开关)。
// 单个类型的 cfg 查询失败不阻塞整体列表(该类型保留基础信息,开关取保守默认值)。
func FetchLeaveTypes(cookieMap map[string]string, host string) ([]LeaveType, error) {
body, err := leavePost(cookieMap, host+leaveAPIBase+"/queryLeaveTypes", map[string]interface{}{}, "")
if err != nil {
return nil, fmt.Errorf("获取请假类型失败: %w", err)
}
var result struct {
Code json.Number `json:"code"`
Message string `json:"message"`
Datas struct {
LeaveType []struct {
Code interface{} `json:"code"`
Name string `json:"name"`
MustOut interface{} `json:"mustOut"`
ReasonWordLimit interface{} `json:"reasonWordLimit"`
AttachmentNumberLimit interface{} `json:"attachmentNumberLimit"`
} `json:"leaveType"`
} `json:"datas"`
}
if err := json.Unmarshal(body, &result); err != nil {
return nil, fmt.Errorf("解析请假类型失败: %w (body: %.200s)", err, string(body))
}
code, _ := result.Code.Int64()
if code != 0 {
msg := result.Message
if msg == "" {
msg = "未知错误"
}
return nil, fmt.Errorf("%s", msg)
}
list := make([]LeaveType, 0, len(result.Datas.LeaveType))
for _, lt := range result.Datas.LeaveType {
codeNum, _ := strconv.Atoi(leaveAnyStr(lt.Code))
item := LeaveType{
Code: codeNum,
Name: lt.Name,
MustOut: leaveAnyInt(lt.MustOut),
ReasonWordLimit: leaveAnyInt(lt.ReasonWordLimit),
AttachmentNumberLimit: leaveAnyInt(lt.AttachmentNumberLimit),
}
if cfg, err := FetchLeaveTypeCfg(cookieMap, host, codeNum); err == nil {
item.MustOut = cfg.MustOut
item.NeedApproval = cfg.NeedApproval
item.EnableAttach = cfg.EnableAttach
item.ReasonWordLimit = cfg.ReasonWordLimit
item.AttachmentNumberLimit = cfg.AttachmentNumberLimit
item.ChooseAttendance = cfg.ChooseAttendance
item.DisableTime = cfg.DisableTime
item.LimitDay = cfg.LimitDay
if cfg.Name != "" {
item.Name = cfg.Name
}
} else {
slog.Warn("获取请假类型配置失败,使用基础信息", "code", codeNum, "error", err)
}
list = append(list, item)
}
return list, nil
}
// FetchLeaveTypeCfg 获取请假类型详细配置(needApproval/enableAttach 等权威开关)
func FetchLeaveTypeCfg(cookieMap map[string]string, host string, code int) (*LeaveType, error) {
body, err := leavePost(cookieMap, host+leaveAPIBase+"/stu/queryLeaveTypeCfg", map[string]interface{}{
"code": code,
}, "")
if err != nil {
return nil, fmt.Errorf("获取请假类型配置失败: %w", err)
}
var result struct {
Code json.Number `json:"code"`
Message string `json:"message"`
Datas struct {
Code interface{} `json:"code"`
Name string `json:"name"`
MustOut interface{} `json:"mustOut"`
NeedApproval interface{} `json:"needApproval"`
EnableAttach interface{} `json:"enableAttach"`
ReasonWordLimit interface{} `json:"reasonWordLimit"`
AttachmentNumberLimit interface{} `json:"attachmentNumberLimit"`
ChooseAttendance interface{} `json:"chooseAttendance"`
DisableTime interface{} `json:"disableTime"`
LimitDay interface{} `json:"limitDay"`
} `json:"datas"`
}
if err := json.Unmarshal(body, &result); err != nil {
return nil, fmt.Errorf("解析请假类型配置失败: %w (body: %.200s)", err, string(body))
}
codeVal, _ := result.Code.Int64()
if codeVal != 0 {
msg := result.Message
if msg == "" {
msg = "未知错误"
}
return nil, fmt.Errorf("%s", msg)
}
return &LeaveType{
Code: leaveAnyInt(result.Datas.Code),
Name: result.Datas.Name,
MustOut: leaveAnyInt(result.Datas.MustOut),
NeedApproval: leaveAnyInt(result.Datas.NeedApproval),
EnableAttach: leaveAnyInt(result.Datas.EnableAttach),
ReasonWordLimit: leaveAnyInt(result.Datas.ReasonWordLimit),
AttachmentNumberLimit: leaveAnyInt(result.Datas.AttachmentNumberLimit),
ChooseAttendance: leaveAnyInt(result.Datas.ChooseAttendance),
DisableTime: leaveAnyInt(result.Datas.DisableTime),
LimitDay: leaveAnyInt(result.Datas.LimitDay),
}, nil
}
// LeaveApplyParams 请假申请提交参数
type LeaveApplyParams struct {
LeaveType int // 类型编码
StartTime string // yyyy-MM-dd HH:mm:ss
EndTime string // yyyy-MM-dd HH:mm:ss
LeaveReason string // 请假原因
OutStatus bool // 是否离校
UrgencyMobile string // 紧急联系人手机号(离校必填)
Address string // 目的地(离校必填,如 xx省/xx市/xx区)
DetailAddress string // 详细地址(离校必填)
Longitude string // 定位经度
Latitude string // 定位纬度
LocationAddr string // 定位地址
ImageURL string // 附件图片 URL(可选)
ApproverWid string // 审批人 userWid(needApproval=1 时必填)
ApproverId string // 审批人 userId
ApproverName string // 审批人姓名
}
// BuildLeaveApplyForm 构造请假申请表单(/leave/apply 的明文 JSON body,
// 与销假的 bodyString 加密不同:该接口只携带 Cpdaily-Extension 头)。
func BuildLeaveApplyForm(p LeaveApplyParams, deviceId, systemVersion, appVersion string) map[string]interface{} {
urgencyMobile := p.UrgencyMobile
address := p.Address
detailAddress := p.DetailAddress
destination := ""
if p.OutStatus {
destination = address
if detailAddress != "" {
if destination != "" {
destination += "/"
}
destination += detailAddress
}
} else {
urgencyMobile = ""
address = ""
detailAddress = ""
}
form := map[string]interface{}{
"leaveType": p.LeaveType,
"startTime": p.StartTime,
"endTime": p.EndTime,
"urgencyMobile": urgencyMobile,
"leaveReason": p.LeaveReason,
"isAttendance": nil,
"outStatus": p.OutStatus,
"address": address,
"detailAddress": detailAddress,
"destination": destination,
"holidayAutoDefineVos": []interface{}{},
"appVersion": appVersion,
"deviceId": deviceId,
"ccInfo": map[string]interface{}{
"teachers": []interface{}{},
"students": []interface{}{},
},
"cheatType": nil,
"source": 1,
"location": map[string]interface{}{
"deviceId": deviceId,
"systemName": "Android",
"systemVersion": systemVersion,
"model": "cpdaily",
"address": p.LocationAddr,
"latitude": p.Latitude,
"longitude": p.Longitude,
},
"locationType": 0,
"disclaimers": "本人承诺填写的信息真实有效,并对本次提交请假申请的信息真实性负责。",
"uaIsCpadaily": "true",
"leaveApplyExplain": nil,
}
if p.ApproverWid != "" {
form["approverWid"] = p.ApproverWid
form["approverId"] = p.ApproverId
form["approverName"] = p.ApproverName
}
if p.ImageURL != "" {
form["applyAttach"] = []map[string]interface{}{
{"type": 1, "url": p.ImageURL},
}
} else {
form["applyAttach"] = []interface{}{}
}
return form
}
// SubmitLeaveApply 提交请假申请(明文 JSON body + Cpdaily-Extension 头)
func SubmitLeaveApply(cookieMap map[string]string, host string, form map[string]interface{}, lon, lat, deviceId, username string) error {
extensionString := GetExtensionString(lat, lon, deviceId, username)
body, err := leavePost(cookieMap, host+leaveAPIBase+"/apply", form, extensionString)
if err != nil {
return fmt.Errorf("提交请假请求失败: %w", err)
}
var result struct {
Code json.Number `json:"code"`
Message string `json:"message"`
}
if err := json.Unmarshal(body, &result); err != nil {
return fmt.Errorf("解析请假响应失败: %w, body: %s", err, string(body))
}
code, _ := result.Code.Int64()
slog.Info("leave/apply 响应", "code", result.Code.String(), "message", result.Message)
if code != 0 {
msg := result.Message
if msg == "" {
msg = "未知错误"
}
return fmt.Errorf("%s", msg)
}
return nil
}
@@ -0,0 +1,183 @@
package cpdaily
import (
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
)
// mock 请假申请接口:queryLeaveTypes 返回基础信息(开关全 null,复刻真实返回),
// queryLeaveTypeCfg 返回权威开关,checkOnceLeave 返回成功。
func newLeaveApplyMockServer(t *testing.T) *httptest.Server {
t.Helper()
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
switch r.URL.Path {
case "/wec-counselor-leave-apps/leave/queryLeaveTypes":
_ = json.NewEncoder(w).Encode(map[string]interface{}{
"code": "0",
"datas": map[string]interface{}{
"leaveType": []map[string]interface{}{
{
"id": "4443", "code": 3, "name": "外出申请(省内)", "mustOut": 1,
"needApproval": nil, "enableAttach": nil, "reasonWordLimit": 10, "attachmentNumberLimit": 4,
},
{
"id": "1225", "code": 1, "name": "事假", "mustOut": 0,
"needApproval": nil, "enableAttach": nil, "reasonWordLimit": 10, "attachmentNumberLimit": 4,
},
},
},
})
case "/wec-counselor-leave-apps/leave/stu/queryLeaveTypeCfg":
var body map[string]interface{}
_ = json.NewDecoder(r.Body).Decode(&body)
code := leaveAnyInt(body["code"])
// code=3:需要审批人 + 允许附件;code=1:均不需要(复刻实测差异)
needApproval, enableAttach := 0, 0
if code == 3 {
needApproval, enableAttach = 1, 1
}
_ = json.NewEncoder(w).Encode(map[string]interface{}{
"code": "0",
"datas": map[string]interface{}{
"wid": "4443", "code": code, "name": "外出申请(省内)",
"mustOut": 1, "needApproval": needApproval, "enableAttach": enableAttach,
"reasonWordLimit": 10, "attachmentNumberLimit": 4, "chooseAttendance": 0,
"disableTime": 0, "limitDay": 90,
},
})
case "/wec-counselor-leave-apps/leave/stu/checkOnceLeave":
_ = json.NewEncoder(w).Encode(map[string]interface{}{"code": "0", "message": "SUCCESS", "datas": nil})
case "/wec-counselor-leave-apps/leave/apply":
_ = json.NewEncoder(w).Encode(map[string]interface{}{"code": "0", "message": "SUCCESS"})
default:
http.NotFound(w, r)
}
}))
}
func TestFetchLeaveTypesMergesTypeCfg(t *testing.T) {
srv := newLeaveApplyMockServer(t)
defer srv.Close()
list, err := FetchLeaveTypes(map[string]string{}, srv.URL)
if err != nil {
t.Fatalf("FetchLeaveTypes 返回错误: %v", err)
}
if len(list) != 2 {
t.Fatalf("期望 2 个请假类型,实际 %d 个", len(list))
}
// code=3:权威开关来自 cfg(needApproval=1 / enableAttach=1)
var out LeaveType
var personal LeaveType
for _, lt := range list {
if lt.Code == 3 {
out = lt
}
if lt.Code == 1 {
personal = lt
}
}
if out.Name != "外出申请(省内)" || out.NeedApproval != 1 || out.EnableAttach != 1 || out.MustOut != 1 {
t.Errorf("类型合并结果错误: %+v", out)
}
if out.ReasonWordLimit != 10 || out.LimitDay != 90 {
t.Errorf("字数/天数限制错误: %+v", out)
}
if personal.NeedApproval != 0 || personal.EnableAttach != 0 {
t.Errorf("事假不应需要审批/附件: %+v", personal)
}
}
func TestCheckOnceLeave(t *testing.T) {
srv := newLeaveApplyMockServer(t)
defer srv.Close()
if err := CheckOnceLeave(map[string]string{}, srv.URL); err != nil {
t.Errorf("checkOnceLeave 应通过: %v", err)
}
}
func TestBuildLeaveApplyForm(t *testing.T) {
// 离校:紧急联系人/目的地/详细地址均保留,destination 由地址拼接
p := LeaveApplyParams{
LeaveType: 3,
StartTime: "2026-09-15 08:00:00",
EndTime: "2026-09-16 20:00:00",
LeaveReason: "回家处理家庭事务",
OutStatus: true,
UrgencyMobile: "13800000000",
Address: "福建省/厦门市/思明区",
DetailAddress: "某某路 1 号",
Longitude: "118.1",
Latitude: "24.6",
LocationAddr: "福建省厦门市思明区",
ImageURL: "https://obs.example.com/a.jpg",
ApproverWid: "93458825",
ApproverId: "20220109",
ApproverName: "罗亭",
}
form := BuildLeaveApplyForm(p, "device-1", "15", "9.9.6")
if form["leaveType"] != 3 || form["startTime"] != "2026-09-15 08:00:00" || form["outStatus"] != true {
t.Errorf("基础字段错误: %+v", form)
}
if form["destination"] != "福建省/厦门市/思明区/某某路 1 号" {
t.Errorf("destination 拼接错误: %v", form["destination"])
}
if form["approverWid"] != "93458825" || form["approverId"] != "20220109" || form["approverName"] != "罗亭" {
t.Errorf("审批人字段错误: %v/%v/%v", form["approverWid"], form["approverId"], form["approverName"])
}
attach, ok := form["applyAttach"].([]map[string]interface{})
if !ok || len(attach) != 1 || attach[0]["url"] != "https://obs.example.com/a.jpg" {
t.Errorf("附件结构错误: %v", form["applyAttach"])
}
loc, ok := form["location"].(map[string]interface{})
if !ok || loc["longitude"] != "118.1" || loc["latitude"] != "24.6" || loc["address"] != "福建省厦门市思明区" {
t.Errorf("location 结构错误: %v", form["location"])
}
// 不离校:紧急联系人/地址字段清空、destination 为空、无审批人/无附件
p2 := LeaveApplyParams{
LeaveType: 1,
StartTime: "2026-09-15 08:00:00",
EndTime: "2026-09-15 18:00:00",
LeaveReason: "身体不适需休息",
OutStatus: false,
UrgencyMobile: "13800000000", // 故意传入,应被清空
Address: "福建省/厦门市",
DetailAddress: "某某路",
Longitude: "118.1",
Latitude: "24.6",
LocationAddr: "校内",
}
form2 := BuildLeaveApplyForm(p2, "device-1", "15", "9.9.6")
if form2["urgencyMobile"] != "" || form2["address"] != "" || form2["detailAddress"] != "" {
t.Errorf("不离校时地址字段应清空: %v/%v/%v", form2["urgencyMobile"], form2["address"], form2["detailAddress"])
}
if form2["destination"] != "" {
t.Errorf("不离校时 destination 应为空: %v", form2["destination"])
}
if _, ok := form2["approverWid"]; ok {
t.Errorf("未选审批人时不应携带 approverWid")
}
if attach, ok := form2["applyAttach"].([]interface{}); !ok || len(attach) != 0 {
t.Errorf("无附件时 applyAttach 应为空数组: %v", form2["applyAttach"])
}
}
func TestSubmitLeaveApply(t *testing.T) {
srv := newLeaveApplyMockServer(t)
defer srv.Close()
form := BuildLeaveApplyForm(LeaveApplyParams{
LeaveType: 1, StartTime: "2026-09-15 08:00:00", EndTime: "2026-09-15 18:00:00",
LeaveReason: "身体不适需休息", Longitude: "118.1", Latitude: "24.6", LocationAddr: "校内",
}, "device-1", "15", "9.9.6")
if err := SubmitLeaveApply(map[string]string{}, srv.URL, form, "118.1", "24.6", "device-1", "20251960205"); err != nil {
t.Errorf("SubmitLeaveApply 应成功: %v", err)
}
}
@@ -0,0 +1,204 @@
package cpdaily
import (
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
)
// mock 销假模块接口:listOther 返回两行(一行待销假、一行已完成),
// detailOther 返回规定销假位置(reportPlaces)与开关(reportApplyFlow/enableAttach)。
// leaveType.code 故意用数字,复刻真实接口(此前声明为 string 导致解析失败)。
func newLeaveMockServer(t *testing.T) *httptest.Server {
t.Helper()
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
switch r.URL.Path {
case "/wec-counselor-leave-apps/leave/stu/listOther":
_ = json.NewEncoder(w).Encode(map[string]interface{}{
"code": "0",
"datas": map[string]interface{}{
"rows": []map[string]interface{}{
{
"id": "301",
"leaveType": "事假",
"leaveReason": "回家",
"createTime": "2026-09-13 10:00",
"actEndTime": "2026-09-14 08:00",
"status": "8",
},
{
"id": "302",
"leaveType": "病假",
"status": "4", // 已完成,应被过滤
},
},
},
})
case "/wec-counselor-leave-apps/leave/stu/detailOther":
_ = json.NewEncoder(w).Encode(map[string]interface{}{
"code": "0",
"datas": map[string]interface{}{
"detail": map[string]interface{}{
"id": "301",
"startDate": "2026-09-13 10:00",
"endDate": "2026-09-14 08:00",
},
"leaveType": map[string]interface{}{
"code": 1, // 数字,复刻真实返回
"name": "事假",
"reportApplyFlow": 1,
"enableAttach": 1,
},
"reportPlaces": []map[string]interface{}{
{
"wid": "1184",
"address": "云南大学东二院",
"longitude": 102.70567818032221,
"latitude": "25.059769487255817",
"radius": "360",
"counName": "ampadmin",
},
},
},
})
case "/wec-counselor-leave-apps/leave/stu/obtainApproveFlow":
var body map[string]interface{}
_ = json.NewDecoder(r.Body).Decode(&body)
// 时间必须带秒,否则真实服务端返回"日期时间格式错误"
start, _ := body["startTime"].(string)
end, _ := body["endTime"].(string)
if len(start) != 19 || len(end) != 19 {
_ = json.NewEncoder(w).Encode(map[string]interface{}{
"code": "2210990000", "message": "日期时间格式错误",
})
return
}
_ = json.NewEncoder(w).Encode(map[string]interface{}{
"code": "0",
"datas": map[string]interface{}{
"approvers": []map[string]interface{}{
{"userWid": "93458825", "userId": "20220109", "userName": "罗亭"},
{"userWid": "1246664953", "userId": "12024206007", "userName": "曾伟"},
},
},
})
default:
http.NotFound(w, r)
}
}))
}
func TestFetchLeaveListFiltersCompletedAndParsesReportPlaces(t *testing.T) {
srv := newLeaveMockServer(t)
defer srv.Close()
items, err := FetchLeaveList(map[string]string{"MOD_AUTH_CAS": "test"}, srv.URL)
if err != nil {
t.Fatalf("FetchLeaveList 返回错误: %v", err)
}
if len(items) != 1 {
t.Fatalf("期望过滤后剩 1 条待销假记录,实际 %d 条: %+v", len(items), items)
}
item := items[0]
if item.ID != "301" || item.LeaveType != "事假" || item.Reason != "回家" {
t.Errorf("列表字段解析错误: %+v", item)
}
if len(item.ReportPlaces) != 1 {
t.Fatalf("期望解析出 1 个规定销假位置,实际 %d 个", len(item.ReportPlaces))
}
p := item.ReportPlaces[0]
// 数字型经纬度应转为纯文本(避免科学计数法)
if p.Longitude != "102.70567818032221" || p.Latitude != "25.059769487255817" || p.Address != "云南大学东二院" || p.Radius != "360" {
t.Errorf("规定销假位置解析错误: %+v", p)
}
if !item.ReportApplyFlow || !item.EnableAttach {
t.Errorf("开关解析错误: reportApplyFlow=%v enableAttach=%v", item.ReportApplyFlow, item.EnableAttach)
}
}
func TestFetchLeaveDetailParsesSubmitFields(t *testing.T) {
srv := newLeaveMockServer(t)
defer srv.Close()
d, err := FetchLeaveDetail(map[string]string{}, srv.URL, "301")
if err != nil {
t.Fatalf("FetchLeaveDetail 返回错误: %v", err)
}
if d.ReportID != "301" {
t.Errorf("ReportID = %q, 期望 301", d.ReportID)
}
// 数字型 code 应转为纯文本 + 数字双形式
if d.LeaveCode != "1" {
t.Errorf("LeaveCode = %q, 期望 1", d.LeaveCode)
}
if d.LeaveCodeNum != 1 {
t.Errorf("LeaveCodeNum = %d, 期望 1", d.LeaveCodeNum)
}
if d.LeaveType != "事假" {
t.Errorf("LeaveType = %q, 期望 事假", d.LeaveType)
}
if !d.ReportApplyFlow || !d.EnableAttach {
t.Errorf("开关解析错误: reportApplyFlow=%v enableAttach=%v", d.ReportApplyFlow, d.EnableAttach)
}
if d.StartTime != "2026-09-13 10:00" || d.EndTime != "2026-09-14 08:00" {
t.Errorf("起止时间解析错误: %q ~ %q", d.StartTime, d.EndTime)
}
}
func TestFetchReportApprovers(t *testing.T) {
srv := newLeaveMockServer(t)
defer srv.Close()
list, err := FetchReportApprovers(map[string]string{}, srv.URL, 1, "2026-09-13 10:00", "2026-09-14 08:00")
if err != nil {
t.Fatalf("FetchReportApprovers 返回错误: %v", err)
}
if len(list) != 2 || list[0].UserName != "罗亭" || list[0].UserWid != "93458825" {
t.Errorf("审批人解析错误: %+v", list)
}
// 秒级时间:入参已含秒时原样透传
if got := leaveTimeWithSeconds("2026-09-13 10:00:30"); got != "2026-09-13 10:00:30" {
t.Errorf("已含秒时间不应改动,实际 %q", got)
}
}
func TestBuildLeaveReportForm(t *testing.T) {
d := &LeaveDetail{
ReportID: "301",
LeaveCode: "1",
ReportPlaces: []LeaveReportPlace{
{Address: "规定位置地址", Longitude: "118.1", Latitude: "24.6"},
},
}
// 无用户地址:兜底使用第一个规定销假位置地址;无图片:reportAttach 为空数组
form := BuildLeaveReportForm(d, "", "", "")
if form["reportLocation"] != "规定位置地址" {
t.Errorf("reportLocation 应兜底规定位置地址,实际 %v", form["reportLocation"])
}
if attach, ok := form["reportAttach"].([]interface{}); !ok || len(attach) != 0 {
t.Errorf("无图片时 reportAttach 应为空数组,实际 %v", form["reportAttach"])
}
if form["approverWid"] != "" {
t.Errorf("未选审批人时 approverWid 应为空字符串,实际 %v", form["approverWid"])
}
if form["id"] != "301" || form["leaveType"] != "1" {
t.Errorf("id/leaveType 字段错误: %v / %v", form["id"], form["leaveType"])
}
// 用户地址优先 + 有图片 + 选择了审批人
form2 := BuildLeaveReportForm(d, "用户填写地址", "https://obs.example.com/a.jpg", "93458825")
if form2["reportLocation"] != "用户填写地址" {
t.Errorf("用户填写的地址应优先,实际 %v", form2["reportLocation"])
}
if form2["approverWid"] != "93458825" {
t.Errorf("应携带所选审批人,实际 %v", form2["approverWid"])
}
attach, ok := form2["reportAttach"].([]map[string]interface{})
if !ok || len(attach) != 1 || attach[0]["type"] != 1 || attach[0]["url"] != "https://obs.example.com/a.jpg" {
t.Errorf("有图片时 reportAttach 结构错误: %v", form2["reportAttach"])
}
}
+77 -19
View File
@@ -35,8 +35,23 @@ func newSessionClient() *sessionClient {
}).
SetHeader("User-Agent", "Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1")
client.SetCookieJar(jar)
client.SetRedirectPolicy(resty.FlexibleRedirectPolicy(20))
// 自定义重定向策略:与 resty 默认 FlexibleRedirectPolicy 行为一致(最多 20 跳),
// 但跨 host 重定向时保留原 User-Agent。resty 默认策略会把跨 host 请求的 UA 换成
// "go-resty/2.x"(见 resty redirect.go checkHostAndAddHeaders),而 wisedu 定制 CAS
// 按请求 UA 渲染移动端/PC 模板。云南大学等学校从 campusphere 网关跨域跳到 CAS 时
// UA 被替换,CAS 渲染 PC 模板导致后续 MFA 页面(reAuthLoginView.do)校验失败被
// 302 踢回登录页,无法解析 reAuthType。保留 UA 后移动端模板正常渲染。
client.SetRedirectPolicy(resty.RedirectPolicyFunc(func(req *http.Request, via []*http.Request) error {
if len(via) >= 20 {
return fmt.Errorf("stopped after %d redirects", 20)
}
if len(via) > 0 && !strings.EqualFold(getHostnameForRedirect(req.URL.Host), getHostnameForRedirect(via[0].URL.Host)) {
if ua := via[0].Header.Get("User-Agent"); ua != "" {
req.Header.Set("User-Agent", ua)
}
}
return nil
}))
return &sessionClient{
Client: client,
jar: jar,
@@ -52,7 +67,15 @@ func readBody(resp *resty.Response) []byte {
return resp.Body()
}
func casLogin(ctx context.Context, school *SchoolDetailInfo, username, password string) ([]*http.Cookie, error) {
// getHostnameForRedirect 去掉端口后返回小写主机名(重定向跨域判断用)
func getHostnameForRedirect(host string) string {
if i := strings.Index(host, ":"); i > 0 {
host = host[:i]
}
return strings.ToLower(host)
}
func casLogin(ctx context.Context, school *SchoolDetailInfo, username, password string) ([]*http.Cookie, *MFAContext, error) {
sc := newSessionClient()
// 1. 获取合法的登录 URL
@@ -65,7 +88,7 @@ func casLogin(ctx context.Context, school *SchoolDetailInfo, username, password
loginPageResp, err := sc.R().SetContext(loginCtx).Get(loginUrl)
if err != nil {
return nil, fmt.Errorf("请求登录页面失败: %w", err)
return nil, nil, fmt.Errorf("请求登录页面失败: %w", err)
}
loginPageBytes := readBody(loginPageResp)
@@ -83,13 +106,25 @@ func casLogin(ctx context.Context, school *SchoolDetailInfo, username, password
needCaptcha, loginType := detectCaptcha(ctx, loginResponseUrl, username, sc.Client)
slog.Info("CAS验证码检测", "needCaptcha", needCaptcha, "loginType", loginType)
if needCaptcha {
captchaCode := resolveOcrCaptcha(ctx, loginResponseUrl, loginType, sc.Client)
slog.Info("CAS验证码OCR", "code", captchaCode)
switch loginType {
case 0:
allInputs["captchaResponse"] = captchaCode
default:
allInputs["captcha"] = captchaCode
if isSliderCaptchaSchool(school.ID) {
// 滑块验证码学校(wisedu 定制 CAS 滑块,如华侨大学):
// openSliderCaptcha 取图 → 打码平台识别 → 等激活窗口 → sign 协议提交 verifySliderCaptcha
//(提交打码值 v、v+1、v-1,失败换图重试,详见 slider.go),
// 通过后 captcha 字段留空提交
casBase := fmt.Sprintf("%s://%s/authserver", loginResponseUrl.Scheme, loginResponseUrl.Host)
if err := resolveWiseduSliderCaptcha(loginCtx, casBase, loginResponseUrl.String(), sc.Client); err != nil {
return nil, nil, fmt.Errorf("滑块验证失败: %w", err)
}
slog.Info("CAS滑块验证通过")
} else {
captchaCode := resolveOcrCaptcha(ctx, loginResponseUrl, loginType, sc.Client)
slog.Info("CAS验证码OCR", "code", captchaCode)
switch loginType {
case 0:
allInputs["captchaResponse"] = captchaCode
default:
allInputs["captcha"] = captchaCode
}
}
}
@@ -103,7 +138,7 @@ func casLogin(ctx context.Context, school *SchoolDetailInfo, username, password
if loginResp != nil && loginResp.StatusCode() == 302 {
slog.Info("CAS登录重定向(登录成功)", "location", loginResp.Header().Get("Location"))
} else {
return nil, fmt.Errorf("提交登录失败: %w", err)
return nil, nil, fmt.Errorf("提交登录失败: %w", err)
}
}
@@ -112,8 +147,20 @@ func casLogin(ctx context.Context, school *SchoolDetailInfo, username, password
// 7. 处理响应
switch loginResp.StatusCode() {
case 302:
slog.Info("CAS登录成功", "redirect", loginResp.Header().Get("Location"))
return handleCasLoginSuccess(ctx, loginResp, school, sc)
location := loginResp.Header().Get("Location")
// 多因子认证:302 到 reAuthCheck/reAuthLoginView.do?isMultifactor=true&service=...
// 此时 CAS 会话有效但未签发票据,需自动发送验证码并等待用户提交
if strings.Contains(location, "isMultifactor=true") || strings.Contains(location, "reAuthCheck") {
slog.Info("CAS触发多因子认证(MFA)", "location", location)
mfa, err := handleMFATriggered(ctx, loginResp, school, sc)
if err != nil {
return nil, nil, err
}
return nil, mfa, ErrMFARequired
}
slog.Info("CAS登录成功", "redirect", location)
cookies, err := handleCasLoginSuccess(ctx, loginResp, school, sc)
return cookies, nil, err
case 200, 401:
body := readBody(loginResp)
slog.Info("CAS登录失败响应体", "body", string(body[:min(len(body), 2000)]))
@@ -121,15 +168,16 @@ func casLogin(ctx context.Context, school *SchoolDetailInfo, username, password
if err == nil && isCasNeedKickOut(doc) {
slog.Info("CAS需要踢下线")
kickResp := submitCasKickOut(ctx, loginResponseUrl.String(), doc, sc.Client)
return handleCasLoginSuccess(ctx, kickResp, school, sc)
cookies, err := handleCasLoginSuccess(ctx, kickResp, school, sc)
return cookies, nil, err
}
msg := getCasErrorMessage(doc)
if msg == "" {
msg = "登录失败,请检查账号密码"
}
return nil, fmt.Errorf("%s", msg)
return nil, nil, fmt.Errorf("%s", msg)
default:
return nil, fmt.Errorf("登录返回异常状态码: %d", loginResp.StatusCode())
return nil, nil, fmt.Errorf("登录返回异常状态码: %d", loginResp.StatusCode())
}
}
@@ -140,6 +188,14 @@ func getCasValidLoginUrl(school *SchoolDetailInfo) string {
return school.IdsUrl
}
}
// 优先选择 campusphere / wec 网关入口:登录后 cookie 落在网关域,
// 后续签到/查寝等任务请求(wec-counselor-* 均在网关域)才能携带有效会话。
// 部分学校 AmpUrl 为学校自研门户(如 ehall),若登录到该域,cookie 与任务接口域不匹配。
for _, u := range []string{school.AmpUrl, school.AmpUrl2, school.AmpUrl3} {
if u != "" && (strings.Contains(u, "campusphere") || strings.Contains(u, "wec-portal-mobile")) {
return u
}
}
for _, u := range []string{school.AmpUrl, school.AmpUrl2, school.AmpUrl3} {
if u != "" {
return u
@@ -156,8 +212,10 @@ func parseLoginForm(page []byte, username, password string) map[string]string {
return inputs
}
// 优先选择密码登录表单(pwdFromId),其次是其他已知CAS表单ID
selectors := []string{"#pwdFromId", "#phoneFromId", "#casLoginForm", "#loginFromId", "#fm1", "form"}
// 优先选择密码登录表单(pwdFromId/pwdLoginDiv),其次是其他已知CAS表单ID。
// pwdLoginDiv 为 wisedu 定制 CAS 的账密区域(如华侨大学登录页含 3 个 id 均为
// loginFromId 的表单 fido/动态码/账密,账密字段位于 #pwdLoginDiv 内)
selectors := []string{"#pwdFromId", "#phoneFromId", "#casLoginForm", "#pwdLoginDiv", "#loginFromId", "#fm1", "form"}
var formSel *goquery.Selection
for _, sel := range selectors {
formSel = doc.Find(sel)
@@ -0,0 +1,193 @@
package cpdaily
import (
"context"
"fmt"
"log/slog"
"net/http"
"net/url"
"regexp"
"strings"
"time"
"github.com/PuerkitoBio/goquery"
"github.com/go-resty/resty/v2"
)
// ========== 合肥师范学院专用登录适配 ==========
//
// 该校(hfnu.campusphere.net)登录走 portal.hfnu.edu.cn 老式 jasig CAS
// (/sso/login,webflow lt/e1sX 风格),与通用 CAS 流程存在两处不兼容,
// 故单独适配:
// 1. 会话用 URL 重写传递(form action 形如 /sso/login;jsessionid=xxx?service=...),
// 服务器不通过 Set-Cookie 建立 session,POST 必须发到 form 的 action
// 地址(带 ;jsessionid),否则 webflow 会话丢失、静默回到登录页;
// 2. 每次登录都要求图形验证码(页面 JS: codeupdate==0 → 强制显示
// #code_validate),验证码图片 /sso/code/code.jsp,4 位纯数字 JPEG,
// 提交字段为 validateCode。通用流程探测的 /authserver/needCaptcha.html
// 在该校返回空 body,会被误判为"不需要验证码"。
//
// 密码为明文提交(页面无加密 salt),登录成功后 302 回跳
// ehall.hfnu.edu.cn/amp-auth-adapter/loginSuccess?sessionToken=...&ticket=...
// HfnuSchoolId 合肥师范学院 tenant id
const HfnuSchoolId = "hfnu"
var hfnuJsessionidRegexp = regexp.MustCompile(`;jsessionid=([A-Za-z0-9._-]+)`)
// hfnuLogin 合肥师范学院专用登录
func hfnuLogin(ctx context.Context, school *SchoolDetailInfo, username, password string) ([]*http.Cookie, error) {
sc := newSessionClient()
loginUrl := getCasValidLoginUrl(school)
if loginUrl == "" {
return nil, fmt.Errorf("该校登录配置不完整(缺少登录地址)")
}
slog.Info("合肥师范专用登录", "loginUrl", loginUrl)
loginCtx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
defer cancel()
// 1. GET 登录页(跟随重定向到 portal.hfnu.edu.cn/sso/login)
loginPageResp, err := sc.R().SetContext(loginCtx).Get(loginUrl)
if err != nil {
return nil, fmt.Errorf("请求登录页面失败: %w", err)
}
pageBody := readBody(loginPageResp)
pageURL := loginPageResp.RawResponse.Request.URL
slog.Info("合肥师范登录页", "finalUrl", pageURL.String(), "bodyLen", len(pageBody))
// 2. 解析 form action(含 ;jsessionid)与 lt
postUrl, lt, err := parseHfnuLoginForm(pageBody, pageURL)
if err != nil {
return nil, err
}
slog.Info("合肥师范CAS表单", "postUrl", postUrl, "lt", lt)
// 3. 获取验证码并 OCR(该校每次登录都需要)
captchaCode, err := resolveHfnuCaptcha(ctx, pageURL, postUrl, sc.Client)
if err != nil {
return nil, err
}
slog.Info("合肥师范验证码OCR", "code", captchaCode)
// 4. 提交登录(禁用重定向,登录成功返回 302)
sc.SetRedirectPolicy(resty.NoRedirectPolicy())
loginResp, err := sc.R().
SetContext(loginCtx).
SetFormData(map[string]string{
"username": username,
"password": password,
"validateCode": captchaCode,
"lt": lt,
"_eventId": "submit",
}).
Post(postUrl)
if err != nil {
if loginResp != nil && loginResp.StatusCode() == http.StatusFound {
slog.Info("合肥师范登录重定向(登录成功)", "location", loginResp.Header().Get("Location"))
} else {
return nil, fmt.Errorf("提交登录失败: %w", err)
}
}
slog.Info("合肥师范登录响应", "statusCode", loginResp.StatusCode())
switch loginResp.StatusCode() {
case http.StatusFound:
return handleCasLoginSuccess(ctx, loginResp, school, sc)
case http.StatusOK, http.StatusUnauthorized:
// 2026-09 排查:该校认证流程本身可用(账密/验证码校验均可通过),
// 但 CAS 认证通过后校方 amp-auth-adapter 单点跳转服务拒绝授权
//(用户在浏览器登录同样报"系统拒绝单点登录跳转"),导致无法换取网关会话。
// 这里区分两类失败:有明确错误文案的(账密错误等)原样提示;
// 无任何错误信息的(认证通过但学校端跳转被拒)给出明确的服务端故障提示。
msg := parseHfnuLoginError(readBody(loginResp))
if msg == "" {
return nil, fmt.Errorf("该校统一认证平台单点登录服务异常(学校端故障),请稍后重试;如持续失败请联系管理员")
}
return nil, fmt.Errorf("%s", msg)
default:
return nil, fmt.Errorf("登录返回异常状态码: %d", loginResp.StatusCode())
}
}
// parseHfnuLoginForm 从登录页解析 form action(绝对化,保留 ;jsessionid)与 lt
func parseHfnuLoginForm(page []byte, pageURL *url.URL) (string, string, error) {
doc, err := goquery.NewDocumentFromReader(strings.NewReader(string(page)))
if err != nil {
return "", "", fmt.Errorf("解析登录页失败: %w", err)
}
form := doc.Find("#credentials")
if form.Length() == 0 {
form = doc.Find("form").First()
}
if form.Length() == 0 {
return "", "", fmt.Errorf("登录页未找到登录表单")
}
action, _ := form.Attr("action")
postUrl := ""
if action != "" {
if u, err := pageURL.Parse(action); err == nil {
postUrl = u.String()
}
}
if postUrl == "" {
return "", "", fmt.Errorf("登录页未找到表单提交地址")
}
lt := ""
form.Find("input").Each(func(_ int, s *goquery.Selection) {
name, _ := s.Attr("name")
if name == "lt" {
lt, _ = s.Attr("value")
}
})
if lt == "" {
return "", "", fmt.Errorf("登录页未找到 lt 参数")
}
return postUrl, lt, nil
}
// resolveHfnuCaptcha 拉取 /sso/code/code.jsp 验证码并 OCR。
// 该校 session 走 URL 重写,验证码请求需带上与 form action 相同的 ;jsessionid。
func resolveHfnuCaptcha(ctx context.Context, pageURL *url.URL, postUrl string, client *resty.Client) (string, error) {
// 登录页路径为 /sso/login;相对路径 code/code.jsp 基于其解析 → /sso/code/code.jsp
captchaUrl, err := pageURL.Parse("code/code.jsp")
if err != nil {
return "", fmt.Errorf("解析验证码地址失败: %w", err)
}
// 该校不依赖 Cookie 而用 URL 重写:把 form action 中的 jsessionid 拼到验证码路径
if m := hfnuJsessionidRegexp.FindStringSubmatch(postUrl); len(m) > 1 {
captchaUrl.Path = captchaUrl.Path + ";jsessionid=" + m[1]
}
captchaUrl.RawQuery = fmt.Sprintf("t=%.3d", time.Now().UnixNano()%1000)
slog.Info("合肥师范验证码", "url", captchaUrl.String())
imgResp, err := client.R().SetContext(ctx).Get(captchaUrl.String())
if err != nil {
return "", fmt.Errorf("获取验证码失败: %w", err)
}
imgData := readBody(imgResp)
if len(imgData) == 0 {
return "", fmt.Errorf("验证码图片为空")
}
slog.Info("合肥师范验证码获取成功", "size", len(imgData))
code := captchaOCR(imgData)
if code == "" {
return "", fmt.Errorf("验证码识别失败,请重试")
}
return code, nil
}
// parseHfnuLoginError 解析登录失败页的错误提示
func parseHfnuLoginError(body []byte) string {
doc, err := goquery.NewDocumentFromReader(strings.NewReader(string(body)))
if err == nil {
if msg := strings.TrimSpace(doc.Find("li.err").Text()); msg != "" {
return msg
}
}
return "登录失败,请检查账号密码或稍后重试"
}
@@ -0,0 +1,485 @@
package cpdaily
import (
"context"
"encoding/json"
"fmt"
"log/slog"
"net/http"
"net/url"
"regexp"
"strings"
"time"
"github.com/PuerkitoBio/goquery"
"github.com/go-resty/resty/v2"
)
// ========== CAS 多因子认证(MFA / reAuth)==========
// 今日校园定制 CAS 的多因子认证扩展(reAuthCheck)。
// 触发场景:CAS 账密提交成功后不是直接签发 ST 票据,而是 302 到
// {base}/authserver/reAuthCheck/reAuthLoginView.do?isMultifactor=true&service=<encoded>
// 该页面渲染当前用户全部可用的二次认证方式(Tab / 更多菜单 / 旧版列表),
// 调用方应把方式列表返回给用户选择;用户选定后调用发码接口(按需先 changeReAuthType.do
// 切换服务端会话方式),用户取得验证码后再提交 reAuthSubmit.do 完成认证,
// 最后访问 /login?service= 完成票据交换。
// ErrMFARequired 表示本次登录触发了多因子认证,
// 调用方应为返回的 MFAContext 暂存并引导用户选择验证方式、获取验证码并提交。
var ErrMFARequired = fmt.Errorf("mfa_required")
// ErrMFASessionExpired 表示 MFA 中间会话已失效(Redis 过期 / reAuth_unauthorized / 会话被服务端作废),
// 调用方应引导用户重新登录(可用 errors.Is 判断)。
var ErrMFASessionExpired = fmt.Errorf("mfa_session_expired")
// MFAAuthMethod 一种可用的多因子验证方式(来自 MFA 页面渲染的可用方式列表)
type MFAAuthMethod struct {
Type string `json:"type"` // reAuthType 数字编码(3=短信 4=企业微信 5=今日校园 11=邮箱 12=钉钉 13=WeLink 15=公众号)
Name string `json:"name"` // 展示名(优先页面文案,缺省用内置映射)
Default bool `json:"default,omitempty"` // 页面默认选中项
}
// MFAContext 触发 MFA 后的中间会话状态(可 JSON 序列化存 Redis,TTL 内有效)
type MFAContext struct {
BaseURL string `json:"baseUrl"` // CAS 根地址,如 http://ids.hfpec.edu.cn
ContextPath string `json:"contextPath"` // CAS 上下文路径,默认 /authserver
Service string `json:"service"` // reAuth service(含 sessionToken)
ReAuthType string `json:"reAuthType"` // 当前/用户最后选定的方式:2=密码 3=短信 5=今日校园 11=邮箱 12=钉钉 13=WeLink 15=公众号
UserID string `json:"userId"` // 学工号
AuthMethods []MFAAuthMethod `json:"authMethods,omitempty"` // 页面支持的全部可用验证方式
WecloudUrl string `json:"wecloudUrl"` // 完成登录后 activateSession 所需
CookiesByHost map[string][]*http.Cookie `json:"cookiesByHost"` // CAS 阶段会话(按域保存)
}
// MFASendResult 发送 MFA 验证码的结果
type MFASendResult struct {
Mobile string `json:"mobile,omitempty"` // 收码目标(手机号等,可能带掩码)
CodeTime int `json:"codeTime,omitempty"` // 可再次发送的倒计时秒数(0 表示不限制/未提供)
Message string `json:"message,omitempty"` // 服务端提示文案
}
// reAuthType → 发码接口的 authCodeTypeName 映射(源自 mobile-reAuth.js sendDynamicCodeByPhone)
var reAuthCodeTypeNames = map[string]string{
"3": "reAuthDynamicCodeType", // 手机短信
"4": "reAuthWChatDynamicCodeType", // 企业微信
"5": "reAuthCpdailyDynamicCodeType", // 今日校园
"11": "reAuthEmailDynamicCodeType", // 邮箱
"12": "reAuthDingTalkDynamicCodeType", // 钉钉
"13": "reAuthWeLinkDynamicCodeType", // WeLink
"15": "reAuthWeChatServiceDynamicCodeType", // 微信公众号
}
// reAuthType → 默认展示名(页面 Tab 无文案时兜底)
var reAuthTypeNames = map[string]string{
"2": "密码验证",
"3": "短信验证码",
"4": "企业微信验证码",
"5": "今日校园验证码",
"6": "实人认证",
"7": "安全问题",
"9": "QQ扫码",
"10": "安全令牌OTP",
"11": "邮箱验证",
"12": "钉钉验证码",
"13": "WeLink验证码",
"15": "公众号验证码",
}
// 需要发送动态码的 reAuthType 集合(密码/人脸/密保问题等类型不走发码接口)
func reAuthTypeNeedsDynamicCode(reAuthType string) bool {
_, ok := reAuthCodeTypeNames[reAuthType]
return ok
}
var (
reAuthParamsBlockRegexp = regexp.MustCompile(`var\s+reAuthParams\s*=\s*\{([\s\S]*?)\n\s*\}`)
reAuthTypeRegexp = regexp.MustCompile(`"reAuthType"\s*:\s*"([^"]*)"`)
reAuthServiceRegexp = regexp.MustCompile(`"service"\s*:\s*"([^"]*)"`)
reAuthUserIdRegexp = regexp.MustCompile(`"reAuthUserId"\s*:\s*"([^"]*)"`)
contextPathRegexp = regexp.MustCompile(`contextPath\s*=\s*"([^"]*)"`)
trailingDigitsRegexp = regexp.MustCompile(`(\d+)$`)
)
// 页面可用验证方式选择器(兼容历代模板;注释块内的元素不会被 goquery 匹配):
// .mobile-reauth-tab-item[data-type] 移动端 Tab(如 id="mobile_tab_5")
// .reauth-tab-item[data-type] 新版统一模板 Tab(如 id="tab_13")
// .reauth-tab-more-item[id] "更多"菜单(id 即编号)
// .changeReAuthTypes[id] 旧版列表(id 即编号)
// .other_type_button[id] "选择认证方式"弹窗按钮(如许昌学院 xcuTheme,id 即编号)
const mfaMethodSelectors = ".mobile-reauth-tab-item, .reauth-tab-item, .reauth-tab-more-item, .changeReAuthTypes, .other_type_button"
// parseMFAAuthMethods 从 MFA 页面解析全部可用验证方式(返回页面渲染顺序)
func parseMFAAuthMethods(page string) []MFAAuthMethod {
doc, err := goquery.NewDocumentFromReader(strings.NewReader(page))
if err != nil {
return nil
}
var methods []MFAAuthMethod
seen := make(map[string]bool)
doc.Find(mfaMethodSelectors).Each(func(_ int, s *goquery.Selection) {
reAuthType := strings.TrimSpace(s.AttrOr("data-type", ""))
if reAuthType == "" {
// 无 data-type 时从 id 提取尾部数字:mobile_tab_5 / tab_13 / 5 均可
reAuthType = trailingDigitsRegexp.FindString(s.AttrOr("id", ""))
}
if reAuthType == "" || seen[reAuthType] {
return
}
name := strings.TrimSpace(s.Find(".mobile-reauth-tab-text").Text())
if name == "" {
name = strings.TrimSpace(s.Text())
}
if name == "" {
name = reAuthTypeNames[reAuthType]
}
if name == "" {
name = "验证方式" + reAuthType
}
seen[reAuthType] = true
methods = append(methods, MFAAuthMethod{
Type: reAuthType,
Name: name,
Default: s.HasClass("mobile-reauth-tab-active") || s.HasClass("reauth-tab-active"),
})
})
return methods
}
// normalizeMFAAuthMethods 规范化可用验证方式列表:
// 1. 仅保留能接收动态码、本流程可完成的方式(短信/企业微信/今日校园/邮箱/钉钉/WeLink/公众号);
// 2. 过滤后为空时保留原列表(页面异常时前端仍能看到可选方式,发码时会得到明确提示);
// 3. 列表整体为空(页面无 Tab / 解析失败)且当前方式支持动态码时,用当前方式兜底为唯一选项,
// 避免前端「选择验证方式」无项可选、流程走不通;
// 4. 页面未标记默认项时,按 defaultType 标记,最后兜底标记首项。
func normalizeMFAAuthMethods(methods []MFAAuthMethod, defaultType string) []MFAAuthMethod {
var supported []MFAAuthMethod
for _, m := range methods {
if reAuthTypeNeedsDynamicCode(m.Type) {
supported = append(supported, m)
}
}
if len(supported) > 0 {
methods = supported
}
if len(methods) == 0 {
if !reAuthTypeNeedsDynamicCode(defaultType) {
return methods
}
// 页面无 Tab(解析失败或旧版模板):用当前 reAuthType 兜底为唯一方式
name := reAuthTypeNames[defaultType]
if name == "" {
name = "验证方式" + defaultType
}
return []MFAAuthMethod{{Type: defaultType, Name: name, Default: true}}
}
if !hasDefaultMFAMethod(methods) {
for i := range methods {
if methods[i].Type == defaultType {
methods[i].Default = true
break
}
}
}
if !hasDefaultMFAMethod(methods) {
methods[0].Default = true
}
return methods
}
// hasDefaultMFAMethod 列表内是否已有默认项
func hasDefaultMFAMethod(methods []MFAAuthMethod) bool {
for _, m := range methods {
if m.Default {
return true
}
}
return false
}
// handleMFATriggered 处理 CAS 登录后 302 到 MFA 页面的情况:
// 解析 MFA 页面参数与全部可用验证方式,返回 MFAContext 供用户选择方式、发送并提交验证码。
func handleMFATriggered(ctx context.Context, resp *resty.Response, school *SchoolDetailInfo, sc *sessionClient) (*MFAContext, error) {
location, err := resp.RawResponse.Location()
if err != nil {
return nil, fmt.Errorf("获取MFA重定向地址失败: %w", err)
}
// 恢复自动重定向(后续请求页面需要跟随)
sc.SetRedirectPolicy(resty.FlexibleRedirectPolicy(10))
pageResp, err := sc.R().SetContext(ctx).Get(location.String())
if err != nil {
return nil, fmt.Errorf("请求MFA页面失败: %w", err)
}
page := string(readBody(pageResp))
slog.Info("MFA页面获取", "url", location.String(), "bodyLen", len(page))
mfa := &MFAContext{
WecloudUrl: school.WecloudUrl,
}
// CAS 根地址与上下文路径
if u, err := url.Parse(location.String()); err == nil {
mfa.BaseURL = u.Scheme + "://" + u.Host
}
mfa.ContextPath = "/authserver"
if m := contextPathRegexp.FindStringSubmatch(page); len(m) > 1 {
mfa.ContextPath = strings.ReplaceAll(m[1], `\/`, "/")
}
// 解析 reAuthParams 块内的关键字段
block := reAuthParamsBlockRegexp.FindStringSubmatch(page)
if len(block) > 1 {
params := block[1]
if m := reAuthTypeRegexp.FindStringSubmatch(params); len(m) > 1 {
mfa.ReAuthType = m[1]
}
if m := reAuthServiceRegexp.FindStringSubmatch(params); len(m) > 1 {
mfa.Service = strings.ReplaceAll(m[1], `\/`, "/")
}
if m := reAuthUserIdRegexp.FindStringSubmatch(params); len(m) > 1 {
mfa.UserID = m[1]
}
}
// 兜底:service 从 302 Location 的 query 取
if mfa.Service == "" {
mfa.Service = location.Query().Get("service")
}
if mfa.ReAuthType == "" {
mfa.ReAuthType = "5"
slog.Warn("MFA页面未解析到reAuthType,按动态码处理", "base", mfa.BaseURL)
}
// 解析页面全部可用验证方式(Tab / 更多菜单 / 旧版列表)
mfa.AuthMethods = normalizeMFAAuthMethods(parseMFAAuthMethods(page), mfa.ReAuthType)
slog.Info("MFA参数解析", "reAuthType", mfa.ReAuthType, "userId", mfa.UserID,
"service", mfa.Service, "methods", len(mfa.AuthMethods))
// 不再自动发送验证码:由用户在前端选择验证方式后调用 SendMFADynamicCode 发送
if !reAuthTypeNeedsDynamicCode(mfa.ReAuthType) {
slog.Warn("MFA默认验证方式非动态码类型,需用户选择其他方式", "reAuthType", mfa.ReAuthType)
}
// 保存当前 CAS 会话(按域)
mfa.CookiesByHost = sc.jar.AllCookiesByHost()
return mfa, nil
}
// restoreMFACasSession 把触发 MFA 时保存的 CAS 会话恢复到新的会话客户端
func restoreMFACasSession(sc *sessionClient, mfa *MFAContext) {
for host, cookies := range mfa.CookiesByHost {
u := &url.URL{Scheme: "http", Host: host}
if strings.HasPrefix(mfa.BaseURL, "https://") {
u.Scheme = "https"
}
sc.jar.SetCookies(u, cookies)
}
}
// changeMFAAuthType 调用 changeReAuthType.do 把服务端 MFA 会话切换到指定验证方式
// POST {base}{contextPath}/reAuthCheck/changeReAuthType.do
func changeMFAAuthType(ctx context.Context, client *resty.Client, mfa *MFAContext, reAuthType string) error {
changeUrl := mfa.BaseURL + mfa.ContextPath + "/reAuthCheck/changeReAuthType.do"
resp, err := client.R().SetContext(ctx).
SetFormData(map[string]string{
"isMultifactor": "true",
"reAuthType": reAuthType,
"service": mfa.Service,
}).
Post(changeUrl)
if err != nil {
return fmt.Errorf("切换MFA验证方式失败: %w", err)
}
body := readBody(resp)
slog.Info("MFA切换验证方式响应", "status", resp.StatusCode(), "reAuthType", reAuthType,
"body", string(body[:min(len(body), 500)]))
// code 兼容数字 1 与字符串 "1"(不同学校版本返回类型不一致)
var result struct {
Code interface{} `json:"code"`
Message string `json:"message"`
}
if err := json.Unmarshal(body, &result); err != nil {
return fmt.Errorf("切换MFA验证方式响应解析失败: %w", err)
}
if fmt.Sprintf("%v", result.Code) != "1" {
return fmt.Errorf("%s", orDefault(result.Message, "该验证方式暂不可用,请选择其他方式"))
}
return nil
}
// sendReAuthDynamicCodeResult 发码接口响应
type sendReAuthDynamicCodeResult struct {
Res string `json:"res"` // success/cpdaily_success/wechat_success/other_success/code_time_fail
ReturnMessage string `json:"returnMessage"` // 服务端提示文案
Mobile string `json:"mobile"` // 收码目标(手机号等)
CodeTime int `json:"codeTime"` // 可再次发送的倒计时秒数
ErrCode string `json:"errCode"` // 错误码(如 206302 会话失效)
}
// postReAuthDynamicCode 调用 CAS 发码接口发送验证码
// POST {base}{contextPath}/dynamicCode/getDynamicCodeByReauth.do
func postReAuthDynamicCode(ctx context.Context, client *resty.Client, mfa *MFAContext, authCodeTypeName string) (*sendReAuthDynamicCodeResult, error) {
sendUrl := mfa.BaseURL + mfa.ContextPath + "/dynamicCode/getDynamicCodeByReauth.do"
resp, err := client.R().SetContext(ctx).
SetFormData(map[string]string{
"userName": mfa.UserID,
"authCodeTypeName": authCodeTypeName,
}).
Post(sendUrl)
if err != nil {
return nil, fmt.Errorf("发送MFA验证码失败: %w", err)
}
body := readBody(resp)
slog.Info("MFA验证码发送响应", "status", resp.StatusCode(), "body", string(body[:min(len(body), 500)]))
var result sendReAuthDynamicCodeResult
if err := json.Unmarshal(body, &result); err != nil {
return nil, fmt.Errorf("发送MFA验证码响应解析失败: %w", err)
}
// 206302:服务端 MFA 会话已失效
if result.ErrCode == "206302" {
return nil, fmt.Errorf("%w: 验证会话已失效,请重新登录", ErrMFASessionExpired)
}
switch result.Res {
case "success", "cpdaily_success", "wechat_success", "other_success", "code_time_fail":
// 发送成功(code_time_fail 为倒计时未结束,验证码此前已发出)
return &result, nil
default:
msg := result.ReturnMessage
if msg == "" {
msg = "发送验证码失败,请稍后重试"
}
return nil, fmt.Errorf("%s", msg)
}
}
// SendMFADynamicCode 用户选定验证方式后发送动态验证码:
// 与当前方式不同时先调用 changeReAuthType.do 切换服务端会话,再调用发码接口;
// 成功后同步 mfa.ReAuthType,供后续 reAuthSubmit.do 提交使用。
func SendMFADynamicCode(ctx context.Context, mfa *MFAContext, reAuthType string) (*MFASendResult, error) {
if mfa == nil || len(mfa.CookiesByHost) == 0 {
return nil, fmt.Errorf("%w: 验证会话不存在,请重新登录", ErrMFASessionExpired)
}
if reAuthType == "" {
reAuthType = mfa.ReAuthType
}
if len(mfa.AuthMethods) > 0 && !mfaHasAuthMethod(mfa.AuthMethods, reAuthType) {
return nil, fmt.Errorf("不支持的验证方式,请选择其他方式")
}
if !reAuthTypeNeedsDynamicCode(reAuthType) {
name := reAuthTypeNames[reAuthType]
if name == "" {
name = "验证方式" + reAuthType
}
return nil, fmt.Errorf("「%s」暂不支持发送验证码,请选择其他方式", name)
}
loginCtx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
defer cancel()
sc := newSessionClient()
restoreMFACasSession(sc, mfa)
// 与当前会话方式不同时先切换(与官方页面 Tab 切换行为一致)
if reAuthType != mfa.ReAuthType {
if err := changeMFAAuthType(loginCtx, sc.Client, mfa, reAuthType); err != nil {
return nil, err
}
mfa.ReAuthType = reAuthType
slog.Info("MFA验证方式已切换", "reAuthType", reAuthType)
}
result, err := postReAuthDynamicCode(loginCtx, sc.Client, mfa, reAuthCodeTypeNames[reAuthType])
if err != nil {
return nil, err
}
return &MFASendResult{
Mobile: result.Mobile,
CodeTime: result.CodeTime,
Message: orDefault(result.ReturnMessage, "验证码已发送"),
}, nil
}
// mfaHasAuthMethod 判断方式编号是否在可用列表中
func mfaHasAuthMethod(methods []MFAAuthMethod, reAuthType string) bool {
for _, m := range methods {
if m.Type == reAuthType {
return true
}
}
return false
}
// CompleteMFALogin 提交用户输入的验证码,完成 MFA 认证并走完 CAS 票据交换,
// 返回登录成功后的全部 cookies。
func CompleteMFALogin(ctx context.Context, mfa *MFAContext, code string) ([]*http.Cookie, error) {
if mfa == nil || len(mfa.CookiesByHost) == 0 {
return nil, fmt.Errorf("%w: 验证会话不存在,请重新登录", ErrMFASessionExpired)
}
loginCtx, cancel := context.WithTimeout(context.Background(), 90*time.Second)
defer cancel()
sc := newSessionClient()
// 恢复 CAS 阶段会话
restoreMFACasSession(sc, mfa)
// 1. 提交验证码(reAuthSubmit.do 返回 JSON,不涉及重定向)
submitUrl := mfa.BaseURL + mfa.ContextPath + "/reAuthCheck/reAuthSubmit.do"
submitResp, err := sc.R().SetContext(loginCtx).
SetFormData(map[string]string{
"service": mfa.Service,
"reAuthType": mfa.ReAuthType,
"isMultifactor": "true",
"password": "",
"dynamicCode": code,
"uuid": "",
"answer1": "",
"answer2": "",
"otpCode": "",
}).
Post(submitUrl)
if err != nil {
return nil, fmt.Errorf("提交MFA验证码失败: %w", err)
}
body := readBody(submitResp)
slog.Info("MFA验证码提交响应", "status", submitResp.StatusCode(), "body", string(body[:min(len(body), 500)]))
var result struct {
Code string `json:"code"`
Msg string `json:"msg"`
}
_ = json.Unmarshal(body, &result)
switch result.Code {
case "reAuth_failed":
return nil, fmt.Errorf("%s", orDefault(result.Msg, "验证码错误或已过期"))
case "reAuth_unauthorized":
return nil, fmt.Errorf("%w: %s", ErrMFASessionExpired, orDefault(result.Msg, "验证会话已失效,请重新登录"))
}
// 2. 完成 CAS 票据交换:GET /login?service=... → 302(service?ticket=) → wecloud /iap/loginSuccess
exchangeUrl := mfa.BaseURL + mfa.ContextPath + "/login?service=" + url.QueryEscape(mfa.Service)
sc.SetRedirectPolicy(resty.FlexibleRedirectPolicy(20))
exchangeResp, err := sc.R().SetContext(loginCtx).Get(exchangeUrl)
if err != nil {
return nil, fmt.Errorf("MFA票据交换失败: %w", err)
}
_ = exchangeResp
// 3. 激活 wecloud 会话
activateSession(loginCtx, mfa.WecloudUrl, sc.Client)
cookies := sc.jar.AllCookies()
return cookies, nil
}
// orDefault 空串时取默认值
func orDefault(s, def string) string {
if strings.TrimSpace(s) == "" {
return def
}
return s
}
@@ -0,0 +1,394 @@
package cpdaily
import (
"context"
"errors"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
// ========== MFA 页面「可用验证方式」解析测试 ==========
// 移动端模板样本(结构取自真实 MFA 页面,已脱敏)
const mfaMobilePageSample = `<!DOCTYPE html><html><body>
<script>var reAuthParams = {
"reAuthType":"5",
"isMultifactor":"true",
"service":"https:\/\/school.campusphere.net\/iap\/loginSuccess?sessionToken=test-token",
"reAuthUserId":"20220000000"
};</script>
<div class="mobile-reauth-tab-list" id="mobileTabList">
<div class="mobile-reauth-tab-item mobile-reauth-tab-active" id="mobile_tab_5"
onclick="handleMobileTabClick(this)" data-type="5" data-index="0">
<span class="mobile-reauth-tab-text" title="今日校园验证码">今日校园验证码</span>
</div>
<div class="mobile-reauth-tab-item" id="mobile_tab_11" onclick="handleMobileTabClick(this)" data-type="11" data-index="1">
<span class="mobile-reauth-tab-text" title="邮箱验证">邮箱验证</span>
</div>
</div>
</body></html>`
func TestParseMFAAuthMethodsMobileTabs(t *testing.T) {
methods := parseMFAAuthMethods(mfaMobilePageSample)
if len(methods) != 2 {
t.Fatalf("期望解析出 2 个方式,实际 %d: %+v", len(methods), methods)
}
if methods[0].Type != "5" || methods[0].Name != "今日校园验证码" || !methods[0].Default {
t.Errorf("第 1 项解析错误: %+v", methods[0])
}
if methods[1].Type != "11" || methods[1].Name != "邮箱验证" || methods[1].Default {
t.Errorf("第 2 项解析错误: %+v", methods[1])
}
}
func TestParseMFAAuthMethodsNewLayoutWithMoreMenu(t *testing.T) {
page := `<html><body>
<div class="reauth-tab-item reauth-tab-active" id="tab_13" data-type="13"></div>
<div class="reauth-tab-item" id="tab_3" data-type="3"></div>
<a class="reauth-tab-more-item" id="10">OTP</a>
</body></html>`
methods := parseMFAAuthMethods(page)
if len(methods) != 3 {
t.Fatalf("期望解析出 3 个方式,实际 %d: %+v", len(methods), methods)
}
if methods[0].Type != "13" || !methods[0].Default || methods[0].Name != "WeLink验证码" {
t.Errorf("第 1 项解析错误(应取 data-type、active 标记、兜底名): %+v", methods[0])
}
if methods[1].Type != "3" || methods[1].Name != "短信验证码" {
t.Errorf("第 2 项解析错误: %+v", methods[1])
}
if methods[2].Type != "10" || methods[2].Name != "OTP" {
t.Errorf("更多菜单项解析错误(id 即编号、文本即名字): %+v", methods[2])
}
}
func TestParseMFAAuthMethodsLegacyLayout(t *testing.T) {
page := `<html><body>
<div class="changeReAuthTypes" id="3"></div>
<div class="changeReAuthTypes" id="10"></div>
</body></html>`
methods := parseMFAAuthMethods(page)
if len(methods) != 2 {
t.Fatalf("期望解析出 2 个方式,实际 %d: %+v", len(methods), methods)
}
if methods[0].Type != "3" || methods[1].Type != "10" {
t.Errorf("旧版列表解析错误: %+v", methods)
}
}
// 许昌学院 xcuTheme:「选择认证方式」弹窗(结构取自真实 MFA 页面,已脱敏)
const mfaOtherTypeDialogPageSample = `<html><body>
<script>var reAuthParams = {
"reAuthType":"3",
"isMultifactor":"true",
"service":"https:\/\/xcu.campusphere.net\/iap\/loginSuccess?sessionToken=test-token",
"reAuthUserId":"5810260823"
};</script>
<div id="other_dialog">
<div class="mask changeOtherType"></div>
<div class="dialog-reAuth changeOtherType" id="changeOtherType">
<div class="other_type_title other_type_header_title">选择认证方式</div>
<div id="3" class="other_type_button" onclick="changeMobileReAuthType(this)">短信验证码</div>
<div class="other_type_title cancel_bottom_btn" onclick="mobileChangeTypeCancel();">取消</div>
</div>
</div>
</body></html>`
func TestParseMFAAuthMethodsOtherTypeDialog(t *testing.T) {
methods := parseMFAAuthMethods(mfaOtherTypeDialogPageSample)
if len(methods) != 1 {
t.Fatalf("弹窗方式列表应解析出 1 个方式,实际 %d: %+v", len(methods), methods)
}
if methods[0].Type != "3" || methods[0].Name != "短信验证码" {
t.Errorf("弹窗方式解析错误(id 即编号、文本即名字): %+v", methods[0])
}
}
func TestParseMFAAuthMethodsOtherTypeDialogMulti(t *testing.T) {
// 多方式弹窗:标题不应被误解析,仅 .other_type_button 计入
page := `<html><body>
<div class="dialog-reAuth changeOtherType" id="changeOtherType">
<div class="other_type_title other_type_header_title">选择认证方式</div>
<div id="3" class="other_type_button">短信验证码</div>
<div id="11" class="other_type_button">邮箱验证</div>
<div class="other_type_title cancel_bottom_btn">取消</div>
</div>
</body></html>`
methods := parseMFAAuthMethods(page)
if len(methods) != 2 {
t.Fatalf("期望解析出 2 个方式,实际 %d: %+v", len(methods), methods)
}
if methods[0].Type != "3" || methods[0].Name != "短信验证码" || methods[1].Type != "11" || methods[1].Name != "邮箱验证" {
t.Errorf("多方式弹窗解析错误: %+v", methods)
}
}
func TestParseMFAAuthMethodsDedupeAcrossLayouts(t *testing.T) {
// 同一方式同时出现在 Tab 与弹窗时应去重(保留先出现项)
page := `<html><body>
<div class="reauth-tab-item" id="tab_3" data-type="3"></div>
<div class="other_type_button" id="3">短信验证码</div>
</body></html>`
methods := parseMFAAuthMethods(page)
if len(methods) != 1 || methods[0].Type != "3" {
t.Fatalf("跨布局重复方式应去重,实际: %+v", methods)
}
}
func TestParseMFAAuthMethodsIgnoresComments(t *testing.T) {
page := `<html><body>
<!-- <div class="mobile-reauth-tab-item" data-type="9"><span class="mobile-reauth-tab-text">QQ扫码</span></div> -->
<div class="mobile-reauth-tab-item" data-type="5"><span class="mobile-reauth-tab-text">今日校园验证码</span></div>
</body></html>`
methods := parseMFAAuthMethods(page)
if len(methods) != 1 || methods[0].Type != "5" {
t.Fatalf("注释块内的方式不应被解析,实际: %+v", methods)
}
}
func TestParseMFAAuthMethodsEmptyPage(t *testing.T) {
if methods := parseMFAAuthMethods("<html><body><p>no tabs</p></body></html>"); len(methods) != 0 {
t.Fatalf("无方式列表的页面应返回空,实际: %+v", methods)
}
}
// ========== 方式列表规范化测试 ==========
func TestNormalizeMFAAuthMethodsFiltersNonDynamicCode(t *testing.T) {
in := []MFAAuthMethod{
{Type: "2", Name: "密码验证"},
{Type: "5", Name: "今日校园验证码"},
{Type: "11", Name: "邮箱验证"},
{Type: "10", Name: "安全令牌OTP"},
}
out := normalizeMFAAuthMethods(in, "5")
if len(out) != 2 || out[0].Type != "5" || out[1].Type != "11" {
t.Fatalf("应过滤到仅动态码方式 [5,11],实际: %+v", out)
}
if !out[0].Default || out[1].Default {
t.Errorf("默认项标记错误: %+v", out)
}
}
func TestNormalizeMFAAuthMethodsKeepsAllWhenNoDynamicCode(t *testing.T) {
in := []MFAAuthMethod{
{Type: "2", Name: "密码验证"},
{Type: "6", Name: "实人认证"},
}
out := normalizeMFAAuthMethods(in, "2")
if len(out) != 2 {
t.Fatalf("全为非动态码时应保留原列表,实际: %+v", out)
}
if !out[0].Default {
t.Errorf("应按 defaultType 标记默认项: %+v", out)
}
}
func TestNormalizeMFAAuthMethodsDefaultFallbacks(t *testing.T) {
// 页面未标记默认 + defaultType 匹配第二项
in := []MFAAuthMethod{
{Type: "5", Name: "Today"},
{Type: "11", Name: "Email"},
}
out := normalizeMFAAuthMethods(in, "11")
if !out[1].Default || out[0].Default {
t.Fatalf("defaultType=11 应标记第二项: %+v", out)
}
// defaultType 不在列表时标记首项
in2 := []MFAAuthMethod{
{Type: "5", Name: "Today"},
{Type: "3", Name: "SMS"},
}
out2 := normalizeMFAAuthMethods(in2, "9")
if !out2[0].Default {
t.Fatalf("defaultType 不在列表时应标记首项: %+v", out2)
}
}
func TestNormalizeMFAAuthMethodsEmptyListFallback(t *testing.T) {
// 页面无 Tab / 解析失败且当前方式支持动态码:用当前方式兜底为唯一选项,避免前端无可选
out := normalizeMFAAuthMethods(nil, "5")
if len(out) != 1 || out[0].Type != "5" || out[0].Name != "今日校园验证码" || !out[0].Default {
t.Fatalf("空列表应按 reAuthType=5 兜底为唯一选项,实际: %+v", out)
}
// 当前方式不支持动态码(如实人认证)时不造选项,保持空
if out := normalizeMFAAuthMethods(nil, "6"); len(out) != 0 {
t.Fatalf("非动态码方式不应兜底,实际: %+v", out)
}
// defaultType 为空同样不猜测
if out := normalizeMFAAuthMethods(nil, ""); len(out) != 0 {
t.Fatalf("defaultType 为空不应兜底,实际: %+v", out)
}
}
// ========== SendMFADynamicCode 全流程测试(mock CAS 服务器) ==========
func TestSendMFADynamicCode(t *testing.T) {
var (
changeCalled bool
changeForm map[string]string
codeForm map[string]string
changeResp = `{"code":1,"data":{"reAuthType":"11"}}`
codeResp = `{"res":"other_success","returnMessage":"验证码已发送成功!","mobile":"138****0000","codeTime":120}`
)
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
switch r.URL.Path {
case "/authserver/reAuthCheck/changeReAuthType.do":
changeCalled = true
_ = r.ParseForm()
changeForm = map[string]string{
"isMultifactor": r.Form.Get("isMultifactor"),
"reAuthType": r.Form.Get("reAuthType"),
"service": r.Form.Get("service"),
}
_, _ = w.Write([]byte(changeResp))
case "/authserver/dynamicCode/getDynamicCodeByReauth.do":
_ = r.ParseForm()
codeForm = map[string]string{
"userName": r.Form.Get("userName"),
"authCodeTypeName": r.Form.Get("authCodeTypeName"),
}
_, _ = w.Write([]byte(codeResp))
default:
http.NotFound(w, r)
}
}))
defer server.Close()
host := strings.TrimPrefix(server.URL, "http://")
newMFA := func() *MFAContext {
return &MFAContext{
BaseURL: server.URL,
ContextPath: "/authserver",
Service: "https://school.campusphere.net/iap/loginSuccess?sessionToken=t",
ReAuthType: "5",
UserID: "20220000000",
AuthMethods: []MFAAuthMethod{
{Type: "5", Name: "今日校园验证码", Default: true},
{Type: "11", Name: "邮箱验证"},
},
CookiesByHost: map[string][]*http.Cookie{
host: {{Name: "CASTGC", Value: "TGT-test"}},
},
}
}
reset := func() {
changeCalled = false
changeForm = nil
codeForm = nil
changeResp = `{"code":1,"data":{"reAuthType":"11"}}`
codeResp = `{"res":"other_success","returnMessage":"验证码已发送成功!","mobile":"138****0000","codeTime":120}`
}
t.Run("切换到新方式并发送", func(t *testing.T) {
reset()
mfa := newMFA()
result, err := SendMFADynamicCode(context.Background(), mfa, "11")
if err != nil {
t.Fatalf("发送失败: %v", err)
}
if !changeCalled {
t.Error("选择了与默认不同的方式,应先调用 changeReAuthType.do")
}
if changeForm["isMultifactor"] != "true" || changeForm["reAuthType"] != "11" || changeForm["service"] != mfa.Service {
t.Errorf("切换参数错误: %+v", changeForm)
}
if codeForm["authCodeTypeName"] != "reAuthEmailDynamicCodeType" || codeForm["userName"] != "20220000000" {
t.Errorf("发码参数错误: %+v", codeForm)
}
if result.Mobile != "138****0000" || result.CodeTime != 120 || result.Message != "验证码已发送成功!" {
t.Errorf("发码结果解析错误: %+v", result)
}
if mfa.ReAuthType != "11" {
t.Errorf("发送成功后应同步 ReAuthType=11,实际 %s", mfa.ReAuthType)
}
})
t.Run("沿用默认方式不切换", func(t *testing.T) {
reset()
mfa := newMFA()
_, err := SendMFADynamicCode(context.Background(), mfa, "5")
if err != nil {
t.Fatalf("发送失败: %v", err)
}
if changeCalled {
t.Error("选择与默认相同的方式不应调用 changeReAuthType.do")
}
if codeForm["authCodeTypeName"] != "reAuthCpdailyDynamicCodeType" {
t.Errorf("发码参数错误: %+v", codeForm)
}
})
t.Run("切换响应 code 为字符串1也兼容", func(t *testing.T) {
reset()
changeResp = `{"code":"1","data":{"reAuthType":"11"}}`
mfa := newMFA()
if _, err := SendMFADynamicCode(context.Background(), mfa, "11"); err != nil {
t.Fatalf("字符串 code=1 应视为切换成功,实际报错: %v", err)
}
})
t.Run("不在可用列表的方式被拒绝", func(t *testing.T) {
reset()
mfa := newMFA()
if _, err := SendMFADynamicCode(context.Background(), mfa, "2"); err == nil {
t.Fatal("不在列表中的方式应报错")
}
})
t.Run("列表内非动态码方式被拒绝", func(t *testing.T) {
reset()
mfa := newMFA()
mfa.AuthMethods = append(mfa.AuthMethods, MFAAuthMethod{Type: "6", Name: "实人认证"})
_, err := SendMFADynamicCode(context.Background(), mfa, "6")
if err == nil || !strings.Contains(err.Error(), "暂不支持发送验证码") {
t.Fatalf("非动态码方式应明确报错,实际: %v", err)
}
})
t.Run("会话缺失返回过期错误", func(t *testing.T) {
reset()
mfa := newMFA()
mfa.CookiesByHost = nil
_, err := SendMFADynamicCode(context.Background(), mfa, "5")
if !errors.Is(err, ErrMFASessionExpired) {
t.Fatalf("会话缺失应返回 ErrMFASessionExpired,实际: %v", err)
}
})
t.Run("发码失败透传服务端文案", func(t *testing.T) {
reset()
codeResp = `{"res":"fail","returnMessage":"发送过于频繁,请稍后再试"}`
mfa := newMFA()
_, err := SendMFADynamicCode(context.Background(), mfa, "5")
if err == nil || !strings.Contains(err.Error(), "发送过于频繁") {
t.Fatalf("应透传服务端失败文案,实际: %v", err)
}
})
t.Run("倒计时未结束视为已发送", func(t *testing.T) {
reset()
codeResp = `{"res":"code_time_fail","returnMessage":"验证码已发送,请勿重复获取","codeTime":60}`
mfa := newMFA()
result, err := SendMFADynamicCode(context.Background(), mfa, "5")
if err != nil {
t.Fatalf("code_time_fail 应视为发送成功: %v", err)
}
if result.CodeTime != 60 {
t.Errorf("应回传倒计时秒数,实际: %+v", result)
}
})
t.Run("会话失效错误码映射为过期", func(t *testing.T) {
reset()
codeResp = `{"res":"fail","errCode":"206302","returnMessage":"会话失效"}`
mfa := newMFA()
_, err := SendMFADynamicCode(context.Background(), mfa, "5")
if !errors.Is(err, ErrMFASessionExpired) {
t.Fatalf("errCode=206302 应映射为 ErrMFASessionExpired,实际: %v", err)
}
})
}
@@ -0,0 +1,40 @@
package cpdaily
import (
"context"
"net/http"
"strings"
)
// ========== 许昌学院(xcu)单独适配 ==========
// XcuSchoolId 许昌学院 tenant id
const XcuSchoolId = "xcu"
// xcuCasLoginEntry 计算许昌学院 CAS 登录入口:
// 校园网关的 /portal/login 会 302 到学校 CAS(authserver.xcu.edu.cn),并在 service 中
// 携带网关回调(/iap/loginSuccess?sessionToken=...);走完 CAS 账密 + MFA 后回调该地址,
// cookie 才能正确落在 xcu.campusphere.net 域(wec-* 任务接口所在域,activateSession 依赖)。
// WecloudUrl 缺失时回退用学校 ampUrl(仅保证能走通 CAS 账密 + MFA)。
func xcuCasLoginEntry(school *SchoolDetailInfo) string {
if school == nil {
return ""
}
if school.WecloudUrl != "" {
return strings.TrimSuffix(school.WecloudUrl, "/") + "/portal/login"
}
return school.AmpUrl
}
// xcuCasLogin 许昌学院强制走 CAS:
// 平台接口返回 joinType=CLOUD(IsIap()=true),但其 IAP 账密接口 /iap/doLogin 与通用
// IAP 密码加密格式不兼容(实测 RSA hex/base64 等多种编码均返回 FAIL_UPNOTMATCH);
// 实际登录链路为 IAP 入口 302 → 学校 CAS 账密 → MFA → 回调 /iap/loginSuccess 建立网关会话。
// 因此不走通用 IAP 流程,强制用 CAS 流程并从校园网关入口进入(详见 xcuCasLoginEntry)。
func xcuCasLogin(ctx context.Context, school *SchoolDetailInfo, username, password string) ([]*http.Cookie, *MFAContext, error) {
xcu := *school
if entry := xcuCasLoginEntry(school); entry != "" {
xcu.AmpUrl = entry
}
return casLogin(ctx, &xcu, username, password)
}
@@ -0,0 +1,69 @@
package cpdaily
import "testing"
// TestXcuCasLoginEntry 覆盖许昌学院 CAS 登录入口计算逻辑
func TestXcuCasLoginEntry(t *testing.T) {
cases := []struct {
name string
school *SchoolDetailInfo
want string
}{
{
name: "正常wecloud",
school: &SchoolDetailInfo{WecloudUrl: "https://xcu.campusphere.net"},
want: "https://xcu.campusphere.net/portal/login",
},
{
name: "wecloud带尾斜杠",
school: &SchoolDetailInfo{WecloudUrl: "https://xcu.campusphere.net/"},
want: "https://xcu.campusphere.net/portal/login",
},
{
name: "wecloud缺失回退ampUrl",
school: &SchoolDetailInfo{
AmpUrl: "https://casp.xcu.edu.cn/newmobile/client",
},
want: "https://casp.xcu.edu.cn/newmobile/client",
},
{
name: "均为空",
school: &SchoolDetailInfo{},
want: "",
},
{
name: "nil学校",
school: nil,
want: "",
},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
if got := xcuCasLoginEntry(c.school); got != c.want {
t.Errorf("xcuCasLoginEntry() = %q, want %q", got, c.want)
}
})
}
}
// TestXcuCasLoginEntryOverridesAmpUrl 确认 xcuCasLogin 用网关入口覆盖 ampUrl 后走 CAS 流程
func TestXcuCasLoginEntryOverridesAmpUrl(t *testing.T) {
school := &SchoolDetailInfo{
ID: XcuSchoolId,
Name: "许昌学院",
JoinType: "CLOUD",
IdsUrl: "https://authserver.xcu.edu.cn/authserver",
WecloudUrl: "https://xcu.campusphere.net",
AmpUrl: "https://casp.xcu.edu.cn/newmobile/client",
}
// 模拟 xcuCasLogin 内部的入口覆盖(不发起网络请求)
clone := *school
clone.AmpUrl = xcuCasLoginEntry(school)
if got := getCasValidLoginUrl(&clone); got != "https://xcu.campusphere.net/portal/login" {
t.Errorf("getCasValidLoginUrl() = %q, want 网关入口", got)
}
// 原对象不被修改
if school.AmpUrl != "https://casp.xcu.edu.cn/newmobile/client" {
t.Errorf("原 school 对象 ampUrl 被意外修改: %q", school.AmpUrl)
}
}
+142
View File
@@ -0,0 +1,142 @@
package cpdaily
import (
"bytes"
"encoding/base64"
"fmt"
"image"
_ "image/gif"
_ "image/jpeg"
_ "image/png"
"regexp"
"strings"
"time"
"github.com/go-resty/resty/v2"
"github.com/makiuchi-d/gozxing"
"github.com/makiuchi-d/gozxing/qrcode"
"golang.org/x/image/draw"
_ "golang.org/x/image/webp"
)
// qrUuidPattern 匹配二维码内容中的 uuid 参数(如 ...?uuid=1583a6...&isNeedExtra=0)
var qrUuidPattern = regexp.MustCompile(`uuid=([0-9a-zA-Z_-]+)`)
// bareUuidPattern 匹配纯 uuid(32 位十六进制字符串)
var bareUuidPattern = regexp.MustCompile(`^[0-9a-fA-F]{32}$`)
// ExtractQrUuid 从二维码文本内容中提取签到二维码 uuid。
// 支持完整链接(取 uuid= 参数值)或纯 uuid 字符串。
func ExtractQrUuid(text string) string {
text = strings.TrimSpace(text)
if text == "" {
return ""
}
if m := qrUuidPattern.FindStringSubmatch(text); len(m) == 2 {
return m[1]
}
if bareUuidPattern.MatchString(text) {
return strings.ToLower(text)
}
return ""
}
// DecodeQRFromImage 解码图片数据中的二维码,返回其中文本内容。
// 先直接解码,失败后放大图片重试(应对小尺寸/低分辨率截图)。
func DecodeQRFromImage(data []byte) (string, error) {
img, _, err := image.Decode(bytes.NewReader(data))
if err != nil {
return "", fmt.Errorf("识别图片格式失败: %w", err)
}
text, err := decodeQRImage(img)
if err == nil {
return text, nil
}
// 放大 3 倍重试
if text, err2 := decodeQRImage(scaleImage(img, 3)); err2 == nil {
return text, nil
}
return "", fmt.Errorf("未能识别图片中的二维码: %w", err)
}
func decodeQRImage(img image.Image) (string, error) {
bmp, err := gozxing.NewBinaryBitmapFromImage(img)
if err != nil {
return "", err
}
hints := map[gozxing.DecodeHintType]interface{}{
gozxing.DecodeHintType_TRY_HARDER: true,
}
result, err := qrcode.NewQRCodeReader().Decode(bmp, hints)
if err != nil {
return "", err
}
return result.GetText(), nil
}
// scaleImage 将图片按整数倍放大(用于提高小图二维码的识别率)
func scaleImage(img image.Image, factor int) image.Image {
b := img.Bounds()
dst := image.NewRGBA(image.Rect(0, 0, b.Dx()*factor, b.Dy()*factor))
draw.CatmullRom.Scale(dst, dst.Bounds(), img, b, draw.Over, nil)
return dst
}
// ResolveQrUuidFromContent 从用户提供的二维码内容中解析出签到 uuid。
// 支持三种形式:
// 1. 文本直填(二维码链接或纯 uuid)
// 2. base64 图片(dataURI,前端上传的本地预览图)
// 3. http(s) 图片链接(下载后解码)
func ResolveQrUuidFromContent(content string) (string, error) {
content = strings.TrimSpace(content)
if content == "" {
return "", fmt.Errorf("尚未上传签到二维码")
}
// 1) 内容本身已是文本(链接或 uuid),直接提取
if uuid := ExtractQrUuid(content); uuid != "" {
return uuid, nil
}
// 2) base64 图片(dataURI)
if IsDataURIImage(content) {
idx := strings.Index(content, ",")
if idx < 0 {
return "", fmt.Errorf("二维码图片数据格式不正确")
}
imgBytes, err := base64.StdEncoding.DecodeString(content[idx+1:])
if err != nil {
return "", fmt.Errorf("解码二维码图片失败: %w", err)
}
return decodeQrTextToUuid(imgBytes)
}
// 3) http(s) 图片链接:下载后解码
if strings.HasPrefix(content, "http://") || strings.HasPrefix(content, "https://") {
client := resty.New().SetTimeout(30 * time.Second)
resp, err := client.R().
SetHeader("User-Agent", "Mozilla/5.0 (Linux; Android 15) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.6778.81 Mobile Safari/537.36").
Get(content)
if err != nil {
return "", fmt.Errorf("下载二维码图片失败: %w", err)
}
if resp.StatusCode() > 299 {
return "", fmt.Errorf("下载二维码图片失败(HTTP %d)", resp.StatusCode())
}
return decodeQrTextToUuid(resp.Body())
}
return "", fmt.Errorf("无法识别的二维码内容,请上传二维码图片或粘贴二维码链接")
}
func decodeQrTextToUuid(imgBytes []byte) (string, error) {
text, err := DecodeQRFromImage(imgBytes)
if err != nil {
return "", err
}
if uuid := ExtractQrUuid(text); uuid != "" {
return uuid, nil
}
return "", fmt.Errorf("二维码内容中未找到 uuid: %s", text)
}
@@ -0,0 +1,60 @@
package cpdaily
import (
"encoding/base64"
"os"
"testing"
)
const testQrUuid = "1583a66272854585833eb13b56fc1937"
func TestExtractQrUuid(t *testing.T) {
cases := []struct{ in, want string }{
{"https://ynu.campusphere.net/wec-counselor-sign-apps/stu/qrsign/index.html?uuid=1583a66272854585833eb13b56fc1937&isNeedExtra=0&schoolId=1018615911902606", "1583a66272854585833eb13b56fc1937"},
{"1583a66272854585833eb13b56fc1937", "1583a66272854585833eb13b56fc1937"},
{" xxxxx?uuid=abc123XYZ&y=1 ", "abc123XYZ"},
{"no uuid here", ""},
{"", ""},
}
for _, c := range cases {
if got := ExtractQrUuid(c.in); got != c.want {
t.Errorf("ExtractQrUuid(%q) = %q, want %q", c.in, got, c.want)
}
}
}
func TestResolveQrUuidFromLinkText(t *testing.T) {
uuid, err := ResolveQrUuidFromContent("https://ynu.campusphere.net/wec-counselor-sign-apps/stu/qrsign/index.html?uuid=1583a66272854585833eb13b56fc1937&isNeedExtra=0")
if err != nil || uuid != testQrUuid {
t.Fatalf("link text resolve = (%q, %v), want (%q, nil)", uuid, err, testQrUuid)
}
if _, err := ResolveQrUuidFromContent(""); err == nil {
t.Fatal("empty content should return error")
}
if _, err := ResolveQrUuidFromContent("some random text"); err == nil {
t.Fatal("unrecognized content should return error")
}
}
func TestDecodeQrImageAndResolve(t *testing.T) {
imgBytes, err := os.ReadFile("testdata/test_qr.png")
if err != nil {
t.Fatalf("read test qr image: %v", err)
}
// 1) 直接解码图片中的二维码
text, err := DecodeQRFromImage(imgBytes)
if err != nil {
t.Fatalf("decode qr image: %v", err)
}
if got := ExtractQrUuid(text); got != testQrUuid {
t.Fatalf("decoded text %q -> uuid %q, want %q", text, got, testQrUuid)
}
// 2) dataURI 形式(前端本地上传的图片)
dataURI := "data:image/png;base64," + base64.StdEncoding.EncodeToString(imgBytes)
uuid, err := ResolveQrUuidFromContent(dataURI)
if err != nil || uuid != testQrUuid {
t.Fatalf("dataURI resolve = (%q, %v), want (%q, nil)", uuid, err, testQrUuid)
}
}
+25 -6
View File
@@ -157,22 +157,41 @@ func hasUsableLoginUrl(school *SchoolDetailInfo) bool {
}
// CpdailyLogin 执行 cpdaily 登录
// 返回登录成功后的所有 cookies
func CpdailyLogin(ctx context.Context, school *SchoolDetailInfo, username, password string) ([]*http.Cookie, error) {
// 返回登录成功后的所有 cookies;若触发多因子认证(MFA),返回 (nil, mfaCtx, ErrMFARequired),
// 调用方应暂存 mfaCtx 并引导用户完成验证码校验(CompleteMFALogin)。
func CpdailyLogin(ctx context.Context, school *SchoolDetailInfo, username, password string) ([]*http.Cookie, *MFAContext, error) {
// 南昌理工学院:自研 CAS→IAP 混合认证,与通用 IAP/CAS 流程均不兼容,单独适配
if school.ID == NutSchoolId {
return nutCasIapLogin(ctx, school, username, password)
cookies, err := nutCasIapLogin(ctx, school, username, password)
return cookies, nil, err
}
// 合肥师范学院:学校统一身份认证 SSO 会拒绝单点登录跳转("系统拒绝单点登录跳转"),
// 但其 wecloud 网关(hfnu.campusphere.net)支持标准 IAP 账密登录,单独走 IAP
if school.ID == HfnuSchoolId {
cookies, err := iapLogin(ctx, school, username, password)
return cookies, nil, err
}
// 许昌学院:平台配置 joinType=CLOUD(IsIap=true),但 IAP 账密登录实际不兼容;
// 学校真实链路为 CAS 账密 + MFA + /iap/loginSuccess 回调,单独强制走 CAS(详见 login_xcu.go)
if school.ID == XcuSchoolId {
return xcuCasLogin(ctx, school, username, password)
}
// 华侨大学:写死强制走通用 CAS 流程——CAS 账密登录需 wisedu 定制滑块验证码
//(casLogin 内按 sliderIds 配置分流,见 slider.go),IAP 账密接口不兼容(FAIL_UPNOTMATCH)
if school.ID == HquSchoolId {
return casLogin(ctx, school, username, password)
}
if school.IsIap() || iapSchoolIds[school.ID] {
if !hasUsableLoginUrl(school) {
slog.Warn("IAP学校缺少登录地址", "school", school.Name, "schoolId", school.ID)
return nil, fmt.Errorf("该校登录配置不完整(缺少登录地址),暂不支持自动登录,请稍后重试或提交待适配反馈")
return nil, nil, fmt.Errorf("该校登录配置不完整(缺少登录地址),暂不支持自动登录,请稍后重试或提交待适配反馈")
}
return iapLogin(ctx, school, username, password)
cookies, err := iapLogin(ctx, school, username, password)
return cookies, nil, err
}
if !hasUsableLoginUrl(school) {
slog.Warn("CAS学校缺少登录地址", "school", school.Name, "schoolId", school.ID)
return nil, fmt.Errorf("该校暂未开放自动登录(待适配),请稍后重试或提交待适配反馈")
return nil, nil, fmt.Errorf("该校暂未开放自动登录(待适配),请稍后重试或提交待适配反馈")
}
return casLogin(ctx, school, username, password)
}
+341
View File
@@ -0,0 +1,341 @@
package cpdaily
import (
"context"
"crypto/aes"
"crypto/cipher"
"crypto/rand"
"encoding/base64"
"encoding/json"
"fmt"
"log/slog"
"strings"
"time"
"cpdaily-new/internal/config"
"github.com/go-resty/resty/v2"
)
// HquSchoolId 华侨大学 tenant id(CAS 登录需 wisedu 滑块验证码,IAP 账密接口不兼容)
const HquSchoolId = "hqdx"
// isSliderCaptchaSchool 判断学校是否为滑块验证码学校(wisedu 定制 CAS 滑块)。
// 学校列表来自配置 [cpdaily] sliderIds(与老版一致:1018764144934965/sdhyxy/hqdx)。
func isSliderCaptchaSchool(schoolID string) bool {
for _, id := range config.Global.Cpdaily.SliderIds {
if id == schoolID {
return true
}
}
return false
}
// ========== 滑块验证码识别(通用方案) ==========
//
// 与图片验证码 OCR(captchaOCR)对等的通用识别入口:sliderGapOCR。
// 打码平台 /slider 接口(与 /classifier 等共用同一个 X-Api-Key):
//
// POST {captchaBaseUrl}/slider JSON: {"big_image": "<b64>", "small_image": "<b64>"}
// 响应: {"code":200,"msg":"success","data":152}
// data = 滑块需横向移动的像素距离(wisedu 口径,与服务端 verifySliderCaptcha 期望值同尺度)
// sliderGapOCR 识别滑块缺口横向位置(通用入口)。
// bigImageB64/smallImageB64 为背景大图与滑块小图的 base64(不带 data: 前缀)。
// 返回滑块需横向移动的像素距离。
func sliderGapOCR(bigImageB64, smallImageB64 string) (int, error) {
cfg := config.Global.Cpdaily
resp, err := resty.New().
SetTimeout(15*time.Second).
R().
SetHeader("X-Api-Key", cfg.CaptchaApiKey).
SetHeader("Content-Type", "application/json").
SetBody(map[string]string{
"big_image": bigImageB64,
"small_image": smallImageB64,
}).
Post(strings.TrimSuffix(cfg.CaptchaBaseUrl, "/") + "/slider")
if err != nil {
return 0, fmt.Errorf("滑块识别请求失败: %w", err)
}
var result struct {
Code int `json:"code"`
Data int `json:"data"`
Msg string `json:"msg"`
}
bodyStr := resp.String()
if err := json.Unmarshal([]byte(bodyStr), &result); err != nil {
return 0, fmt.Errorf("滑块识别响应解析失败: %w, body: %s", err, bodyStr)
}
if result.Code != 200 {
return 0, fmt.Errorf("滑块识别失败: code=%d msg=%s", result.Code, result.Msg)
}
if result.Data <= 0 {
return 0, fmt.Errorf("滑块识别结果无效: %d", result.Data)
}
return result.Data, nil
}
// ========== wisedu CAS 滑块验证码(如华侨大学 id.hqu.edu.cn) ==========
//
// 前端协议(longbow sliderCaptcha v2.0 + encrypt.js,2026-02 逆向并实测调通):
//
// 1. GET {cas}/common/openSliderCaptcha.htl
// → {"bigImage":"<b64>","smallImage":"<b64>","tagWidth":..,"yHeight":..}
// 2. safeSecure = window.atob(smallImage) 末 16 字节(latin1→UTF-8 字符串,trim 后作 AES key)
// 3. 拖动结束后 POST {cas}/common/verifySliderCaptcha.htl,表单仅一个字段 sign:
// sign = encryptPassword(JSON.stringify({canvasLength,moveLength,tracks}), safeSecure)
// encryptPassword(data,key) = AES-128-CBC-PKCS7(key=Utf8(key.trim()),
// iv=randomString(16),明文=randomString(64)+data),输出标准 base64
// 4. 响应 {"errorCode":1,"errorMsg":"success"} = 通过;{"errorCode":0} = 失败
//
// 服务端校验特性(实测修正,推翻此前「无容差 + ±12 扫描」结论):
// - open 后存在约 1.3~1.7s 的激活延迟:延迟内提交任何值(含真值)都返回失败;
// - 激活后服务端接受以真值为中心的宽区间(实测上缘 +8、下缘 -6~-8,相对打码值),
// 打码平台返回值即为区间中心附近(误差 0±1),故等待激活后提交打码值本身即可通过;
// - 失败不消费图片,可在同一张图上反复提交;仅验证成功才作废该图;
// - 早期观察到的「打码偏差 -8~+9」为快速扫描跨过激活窗口的落点伪影(非真实识别误差)。
// wiseduSliderVerifyWidth 前端滑块容器宽度(canvasLength 固定上报值)
const wiseduSliderVerifyWidth = 280
// wiseduAesChars encrypt.js 随机字符集(用于 64 位随机前缀与 16 位随机 IV)
const wiseduAesChars = "ABCDEFGHJKMNPQRSTWXYZabcdefhijkmnprstwxyz2345678"
// wiseduSliderMaxRounds 换图重试上限(每张图内先提交窄候选序列)
const wiseduSliderMaxRounds = 5
// wiseduSliderActivateWait 服务端激活延迟等待时长:从 openSliderCaptcha 响应完成起算。
// 实测激活点约在 open 后 1.3~1.7s(期间提交任何值都被拒),2.5s 留足抖动裕度。
// 测试可替换为零。
var wiseduSliderActivateWait = 2500 * time.Millisecond
// wiseduSliderSubmitGap 同图候选值之间的提交间隔,进一步吸收激活窗口抖动。
// 测试可替换为零。
var wiseduSliderSubmitGap = 350 * time.Millisecond
// wiseduSliderCandidates 同图候选值偏移序列:以打码值本身为中心(贴合接受区间中心),
// ±1 仅作识别误差/激活抖动的兜底(激活后接受 ±8 宽区间,通常首个候选即命中)。
var wiseduSliderCandidates = []int{0, 1, -1}
// wiseduSliderRetryDelay 换图重试等待时长(测试可替换为零延迟)
var wiseduSliderRetryDelay = func(round int) time.Duration {
return time.Duration(500+round*300) * time.Millisecond
}
// wiseduSliderTrack 前端 tracks 元素(a=横向位移、b=纵向偏移、c=累计时间ms)
type wiseduSliderTrack struct {
A int `json:"a"`
B int `json:"b"`
C int `json:"c"`
}
// wiseduSliderTrackPayload verifySliderCaptcha 加密前的 JSON 载荷
type wiseduSliderTrackPayload struct {
CanvasLength int `json:"canvasLength"`
MoveLength int `json:"moveLength"`
Tracks []wiseduSliderTrack `json:"tracks"`
}
// resolveWiseduSliderCaptcha 完成 wisedu CAS 滑块验证(自动换图重试)。
// casBase 为 CAS 根路径(如 https://id.hqu.edu.cn/authserver);
// referer 为当前登录页 URL(模拟浏览器请求头);
// client 须与登录页共用同一会话(cookie jar)。
func resolveWiseduSliderCaptcha(ctx context.Context, casBase, referer string, client *resty.Client) error {
return resolveWiseduSliderCaptchaFn(ctx, casBase, referer, client, sliderGapOCR)
}
// resolveWiseduSliderCaptchaFn 允许注入缺口识别函数(测试用)
func resolveWiseduSliderCaptchaFn(ctx context.Context, casBase, referer string, client *resty.Client,
gapFn func(bigImageB64, smallImageB64 string) (int, error)) error {
base := strings.TrimSuffix(casBase, "/")
var lastErr error
for round := 0; round < wiseduSliderMaxRounds; round++ {
if round > 0 {
time.Sleep(wiseduSliderRetryDelay(round))
}
lastErr = wiseduSliderOnce(ctx, base, referer, client, gapFn, round)
if lastErr == nil {
return nil
}
slog.Info("wisedu滑块本轮失败,换图重试", "round", round+1, "err", lastErr.Error())
}
return lastErr
}
// wiseduSliderOnce 单轮滑块验证:取图 → 打码 → 等激活窗口 → 提交窄候选序列(v、v+1、v-1)
func wiseduSliderOnce(ctx context.Context, base, referer string, client *resty.Client,
gapFn func(string, string) (int, error), round int) error {
// 1. 获取滑块图片(同会话换图)
resp, err := client.R().SetContext(ctx).
SetHeader("X-Requested-With", "XMLHttpRequest").
SetHeader("Referer", referer).
Get(base + "/common/openSliderCaptcha.htl")
if err != nil {
return fmt.Errorf("获取滑块图片失败: %w", err)
}
openedAt := time.Now()
var slider struct {
BigImage string `json:"bigImage"`
SmallImage string `json:"smallImage"`
}
body := readBody(resp)
if err := json.Unmarshal(body, &slider); err != nil || slider.BigImage == "" || slider.SmallImage == "" {
return fmt.Errorf("解析滑块图片响应失败: %v, body: %s", err, string(body[:min(len(body), 200)]))
}
// 2. safeSecure(小图末 16 字节)与打码识别
safeSecure, err := sliderSafeSecure(slider.SmallImage)
if err != nil {
return err
}
gapX, err := gapFn(slider.BigImage, slider.SmallImage)
if err != nil {
return err
}
// 3. 等待服务端激活窗口:激活前提交任何值(含真值)都会被拒
if wait := wiseduSliderActivateWait - time.Since(openedAt); wait > 0 {
slog.Info("wisedu滑块等待激活窗口", "round", round+1, "wait", wait.String())
time.Sleep(wait)
}
// 4. 提交窄候选序列:打码值本身(接受区间中心),v±1 兜底激活抖动/识别误差
for i, off := range wiseduSliderCandidates {
if i > 0 && wiseduSliderSubmitGap > 0 {
time.Sleep(wiseduSliderSubmitGap)
}
moveLength := gapX + off
if moveLength <= 0 {
continue
}
payload, err := json.Marshal(wiseduSliderTrackPayload{
CanvasLength: wiseduSliderVerifyWidth,
MoveLength: moveLength,
Tracks: wiseduSliderTracks(moveLength),
})
if err != nil {
return err
}
sign, err := encryptSliderSign(string(payload), safeSecure)
if err != nil {
return err
}
vResp, err := client.R().SetContext(ctx).
SetHeader("X-Requested-With", "XMLHttpRequest").
SetHeader("Referer", referer).
SetFormData(map[string]string{"sign": sign}).
Post(base + "/common/verifySliderCaptcha.htl")
if err != nil {
return fmt.Errorf("提交滑块校验失败: %w", err)
}
var vResult struct {
ErrorCode int `json:"errorCode"`
ErrorMsg string `json:"errorMsg"`
}
vBody := readBody(vResp)
_ = json.Unmarshal(vBody, &vResult)
slog.Info("wisedu滑块校验", "round", round+1, "gapX", gapX, "offset", off,
"moveLength", moveLength, "errorCode", vResult.ErrorCode)
if vResult.ErrorCode == 1 {
return nil
}
}
return fmt.Errorf("本张图候选值全部未通过(打码值 %d)", gapX)
}
// sliderSafeSecure 取小图 base64 解码后的末 16 字节作为 safeSecure。
// 前端 ids-sliderCaptcha.js 中 safeSecure.value 由 atob(smallImage) 的末 16 个
// charCode 逐字节拼成;encrypt.js 使用时先 trim 再作 AES key(Utf8 编码)。
func sliderSafeSecure(smallImageB64 string) (string, error) {
raw, err := base64.StdEncoding.DecodeString(smallImageB64)
if err != nil {
return "", fmt.Errorf("小图 base64 解码失败: %w", err)
}
if len(raw) < 16 {
return "", fmt.Errorf("小图数据不足 16 字节: %d", len(raw))
}
var sb strings.Builder
for _, c := range raw[len(raw)-16:] {
sb.WriteRune(rune(c))
}
// 复刻 JS 正则 /(^\s+)|(\s+$)/g 的 trim(字节码点 ≤255,空白字符集有限)
return strings.Trim(sb.String(), "\t\n\v\f\r \u00a0"), nil
}
// encryptSliderSign 复刻 encrypt.js encryptPassword(data, key):
// AES-128-CBC-PKCS7,key=Utf8(key.trim()),iv=randomString(16),
// 明文=randomString(64)+data,输出标准 base64(仅密文,IV 不外传,与 CryptoJS 一致)。
//
// IV 随机且不随请求发送是安全的:CBC 解密时 IV 只影响第一块(16 字节)明文,
// 而首块落在 64 位随机前缀内部;服务端剥离前缀后从固定偏移解析 JSON,不受影响
// (已用真实 CryptoJS 输出 + openssl/node 解密交叉验证)。
func encryptSliderSign(data, safeSecure string) (string, error) {
key := []byte(safeSecure)
switch len(key) {
case 16, 24, 32:
default:
return "", fmt.Errorf("safeSecure 长度非法(%d 字节,须为 16/24/32)", len(key))
}
block, err := aes.NewCipher(key)
if err != nil {
return "", err
}
padded := pkcs7Pad([]byte(randomAesChars(64)+data), block.BlockSize())
iv := []byte(randomAesChars(aes.BlockSize))
ciphertext := make([]byte, len(padded))
cipher.NewCBCEncrypter(block, iv).CryptBlocks(ciphertext, padded)
return base64.StdEncoding.EncodeToString(ciphertext), nil
}
// wiseduSliderTracks 生成拟人化拖动轨迹,模拟 longbow.js 采集的 {a,b,c} 采样点:
// 首个点为 {0,0,0},随后按比例推进并带轻微抖动,最后一点精确对齐 moveLength
// (对应前端 mouseup 时的最终位移)。
func wiseduSliderTracks(move int) []wiseduSliderTrack {
steps := 8 + randSmallN(5) // 8~12 个中间采样点
tracks := make([]wiseduSliderTrack, 0, steps+2)
tracks = append(tracks, wiseduSliderTrack{A: 0, B: 0, C: 0})
a, c := 0, 0
for i := 1; i <= steps; i++ {
target := move * i / steps
if target <= a {
target = a + 1
}
if target > move {
target = move
}
a = target + randSmallN(3) - 1 // ±1 抖动
if a > move {
a = move
}
if a < 0 {
a = 0
}
c += 20 + randSmallN(45)
tracks = append(tracks, wiseduSliderTrack{A: a, B: randSmallN(3) - 1, C: c})
}
c += 30 + randSmallN(40)
tracks = append(tracks, wiseduSliderTrack{A: move, B: 0, C: c})
return tracks
}
// randomAesChars 生成 n 个 wiseduAesChars 字符集的随机字符(对应 encrypt.js randomString)
func randomAesChars(n int) string {
buf := make([]byte, n)
_, _ = rand.Read(buf)
for i, c := range buf {
buf[i] = wiseduAesChars[int(c)%len(wiseduAesChars)]
}
return string(buf)
}
// randSmallN 返回 [0,n) 的伪随机数(仅用于轨迹/时间的拟人化抖动,无安全要求)
func randSmallN(n int) int {
if n <= 0 {
return 0
}
b := make([]byte, 1)
_, _ = rand.Read(b)
return int(b[0]) % n
}
@@ -0,0 +1,497 @@
package cpdaily
import (
"context"
"crypto/aes"
"crypto/cipher"
"encoding/base64"
"encoding/json"
"fmt"
"net/http"
"net/http/cookiejar"
"net/http/httptest"
"strings"
"testing"
"time"
"cpdaily-new/internal/config"
"github.com/go-resty/resty/v2"
)
// ========== isSliderCaptchaSchool ==========
func TestIsSliderCaptchaSchool(t *testing.T) {
old := config.Global.Cpdaily.SliderIds
defer func() { config.Global.Cpdaily.SliderIds = old }()
config.Global.Cpdaily.SliderIds = []string{"1018764144934965", "sdhyxy", "hqdx"}
for _, id := range []string{"hqdx", "sdhyxy", "1018764144934965"} {
if !isSliderCaptchaSchool(id) {
t.Errorf("%s 应为滑块学校", id)
}
}
for _, id := range []string{"xcu", "nut", "hfnu", ""} {
if isSliderCaptchaSchool(id) {
t.Errorf("%s 不应为滑块学校", id)
}
}
}
// ========== safeSecure / sign 加密(encrypt.js 协议) ==========
// mockSliderSafe 模拟小图末 16 字节(hqu 实测为 ASCII 串)
const mockSliderSafe = "Ta1PFklnamXPYqZX"
// mockSmallImageB64 构造以 mockSliderSafe 结尾的 base64 小图
func mockSmallImageB64() string {
raw := append([]byte{0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x01}, []byte(mockSliderSafe)...)
return base64.StdEncoding.EncodeToString(raw)
}
func TestSliderSafeSecure(t *testing.T) {
got, err := sliderSafeSecure(mockSmallImageB64())
if err != nil {
t.Fatalf("sliderSafeSecure: %v", err)
}
if got != mockSliderSafe {
t.Fatalf("safeSecure=%q, want %q", got, mockSliderSafe)
}
// 首尾空白应被 trim(复刻 encrypt.js 的 key.replace 正则)
trimmed := base64.StdEncoding.EncodeToString([]byte("\tTa1PFklnamXPYqZ\n"))
if got, err := sliderSafeSecure(trimmed); err != nil || got != "Ta1PFklnamXPYqZ" {
t.Fatalf("trim 后 safeSecure=%q err=%v", got, err)
}
// 不足 16 字节应报错
short := base64.StdEncoding.EncodeToString([]byte("short"))
if _, err := sliderSafeSecure(short); err == nil {
t.Fatal("小图不足 16 字节应报错")
}
}
func TestEncryptSliderSign(t *testing.T) {
const key = "0123456789abcdef"
plain := `{"canvasLength":280,"moveLength":99,"tracks":[{"a":0,"b":0,"c":0}]}`
sign, err := encryptSliderSign(plain, key)
if err != nil {
t.Fatalf("encryptSliderSign: %v", err)
}
raw, err := base64.StdEncoding.DecodeString(sign)
if err != nil || len(raw)%aes.BlockSize != 0 {
t.Fatalf("sign base64/长度异常: err=%v len=%d", err, len(raw))
}
// 用零 IV 解密(IV 只影响首块 16 字节,落于前缀内,不影响前缀后半段与载荷)
block, _ := aes.NewCipher([]byte(key))
pt := make([]byte, len(raw))
cipher.NewCBCDecrypter(block, make([]byte, aes.BlockSize)).CryptBlocks(pt, raw)
pt = pkcs7Unpad(pt)
if len(pt) != 64+len(plain) {
t.Fatalf("解密明文长度=%d, want %d", len(pt), 64+len(plain))
}
if string(pt[64:]) != plain {
t.Fatalf("解密明文=%q, want %q", string(pt[64:]), plain)
}
// 首块因 IV 未知为乱码,第 16~63 字节应为合法前缀字符(48 个)
for _, c := range string(pt[16:64]) {
if !strings.ContainsRune(wiseduAesChars, c) {
t.Fatalf("随机前缀包含非法字符 %q", c)
}
}
}
// 真实 CryptoJS(/tmp/hqu_encrypt.js encryptPassword)产出的 sign 夹具:
// key=Ta1PFklnamXPYqZX,明文载荷为 canvasLength=280/moveLength=65/2 个 tracks 采样点。
// 用于锁定与前端 CryptoJS 的互通性(前缀长度、填充、base64、载荷可解析)。
const realCryptoJSSignFixture = "+vjAgkLevuKAVqM/x6dCJJwlR5xLcrJ0yW6tqTmeu8MXCCBJvY4rttiA4ouUBHYdCP2zjYzbGgKDyTvTQdmvPnOV6DlSiQvCIhtaQcBksVFjYsMi2i7hCaKu3sfA9SszYZVs/8AvSqPPcvr+MlvarV7fSPaJdtPpbI1lYSU30MiFHBTl5V8cZMdlA+Zj290xoQlg3niG2V3f0sLzEQ4Wrg=="
func TestDecryptRealCryptoJSFixture(t *testing.T) {
pt, err := decryptMockSign(realCryptoJSSignFixture)
if err != nil {
t.Fatalf("解密真实 CryptoJS sign 失败: %v", err)
}
p := mockDecryptSign(t, realCryptoJSSignFixture)
if p.CanvasLength != 280 || p.MoveLength != 65 {
t.Fatalf("载荷字段错误: %+v", p)
}
if len(p.Tracks) != 2 || p.Tracks[1].A != 65 {
t.Fatalf("tracks 错误: %+v", p.Tracks)
}
// 第 16~63 字节为合法前缀字符(48 个)
for _, c := range string(pt[16:64]) {
if !strings.ContainsRune(wiseduAesChars, c) {
t.Fatalf("随机前缀包含非法字符 %q", c)
}
}
}
func TestWiseduSliderTracks(t *testing.T) {
tracks := wiseduSliderTracks(65)
if len(tracks) < 2 {
t.Fatalf("tracks 采样点过少: %d", len(tracks))
}
if first := tracks[0]; first.A != 0 || first.B != 0 || first.C != 0 {
t.Fatalf("首个采样点应为 {0,0,0}: %+v", first)
}
if last := tracks[len(tracks)-1]; last.A != 65 {
t.Fatalf("末点 a=%d, want 65(对齐 moveLength)", last.A)
}
prevC := 0
for i, p := range tracks {
if p.C < prevC {
t.Fatalf("第 %d 点时间回退: %+v", i, p)
}
if p.A < 0 || p.A > 65 {
t.Fatalf("第 %d 点位移越界: %+v", i, p)
}
prevC = p.C
}
}
// ========== wisedu CAS 滑块流程(httptest mock,sign 协议) ==========
// disableSliderDelays 将换图重试延迟、激活等待与候选间隔全部置零(加速测试)
func disableSliderDelays(t *testing.T) {
t.Helper()
oldDelay := wiseduSliderRetryDelay
oldWait := wiseduSliderActivateWait
oldGap := wiseduSliderSubmitGap
wiseduSliderRetryDelay = func(int) time.Duration { return 0 }
wiseduSliderActivateWait = 0
wiseduSliderSubmitGap = 0
t.Cleanup(func() {
wiseduSliderRetryDelay = oldDelay
wiseduSliderActivateWait = oldWait
wiseduSliderSubmitGap = oldGap
})
}
// mockDecryptSign 用 mockSliderSafe 解密并解析 sign 载荷(模拟服务端行为)。
// 加密端 IV 随机且不外传:CBC 中 IV 只影响解密后第一块(16 字节),而该 16 字节
// 落在 64 位随机前缀内部,服务端剥掉前缀后解析 JSON 不受任何影响,故此处 IV 取
// 零向量即可,解密结果第 16 字节起与前端一致(已用真实 CryptoJS 输出交叉验证)。
func mockDecryptSign(t *testing.T, sign string) wiseduSliderTrackPayload {
t.Helper()
pt, err := decryptMockSign(sign)
if err != nil {
t.Fatalf("解密 sign 失败: %v", err)
}
var p wiseduSliderTrackPayload
if err := json.Unmarshal(pt[64:], &p); err != nil {
t.Fatalf("解密载荷 JSON 解析失败: %v, prefix64=%q body=%s", err, string(pt[:64]), string(pt[64:]))
}
return p
}
// decryptMockSign 用 mockSliderSafe 解密 sign(零 IV),返回去除 PKCS7 填充后的明文
func decryptMockSign(sign string) ([]byte, error) {
raw, err := base64.StdEncoding.DecodeString(sign)
if err != nil {
return nil, err
}
if len(raw) < 32 || len(raw)%aes.BlockSize != 0 {
return nil, fmt.Errorf("sign 长度异常: %d", len(raw))
}
block, err := aes.NewCipher([]byte(mockSliderSafe))
if err != nil {
return nil, err
}
pt := make([]byte, len(raw))
cipher.NewCBCDecrypter(block, make([]byte, aes.BlockSize)).CryptBlocks(pt, raw)
pt = pkcs7Unpad(pt)
if len(pt) < 64 {
return nil, fmt.Errorf("解密后明文过短: %d", len(pt))
}
return pt, nil
}
// mockSliderServer 起一个 wisedu CAS 滑块 mock:
// verify 接受 accept 集合中的 moveLength(errorCode=1),其余一律 errorCode=0
func mockSliderServer(t *testing.T, accept map[int]bool, wantGapX int,
openCalls, verifyCalls *int) *httptest.Server {
t.Helper()
small := mockSmallImageB64()
mux := http.NewServeMux()
mux.HandleFunc("/authserver/common/openSliderCaptcha.htl", func(w http.ResponseWriter, r *http.Request) {
*openCalls++
if r.Header.Get("X-Requested-With") != "XMLHttpRequest" {
t.Errorf("open 缺少 X-Requested-With 头")
}
if r.Header.Get("Referer") == "" {
t.Errorf("open 缺少 Referer 头")
}
writeJSON(w, map[string]string{"bigImage": "ZmFrZWJpZw==", "smallImage": small})
})
mux.HandleFunc("/authserver/common/verifySliderCaptcha.htl", func(w http.ResponseWriter, r *http.Request) {
*verifyCalls++
if r.Header.Get("X-Requested-With") != "XMLHttpRequest" {
t.Errorf("verify 缺少 X-Requested-With 头")
}
_ = r.ParseForm()
p := mockDecryptSign(t, r.PostFormValue("sign"))
if p.CanvasLength != 280 {
t.Errorf("canvasLength=%d, want 280", p.CanvasLength)
}
if len(p.Tracks) < 2 {
t.Errorf("tracks 采样点过少: %d", len(p.Tracks))
}
if last := p.Tracks[len(p.Tracks)-1]; last.A != p.MoveLength {
t.Errorf("tracks 末点 a=%d 应等于 moveLength=%d", last.A, p.MoveLength)
}
if accept[p.MoveLength] {
writeJSON(w, map[string]int{"errorCode": 1})
return
}
writeJSON(w, map[string]int{"errorCode": 0})
})
ts := httptest.NewServer(mux)
t.Cleanup(ts.Close)
return ts
}
// 打码值直接命中:1 次 open + 1 次 verify
func TestResolveWiseduSliderCaptchaMock(t *testing.T) {
disableSliderDelays(t)
var openCalls, verifyCalls int
ts := mockSliderServer(t, map[int]bool{65: true}, 65, &openCalls, &verifyCalls)
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
err := resolveWiseduSliderCaptchaFn(ctx, ts.URL+"/authserver", ts.URL+"/login", newTestRestyClient(),
func(string, string) (int, error) { return 65, nil })
if err != nil {
t.Fatalf("滑块流程失败: %v", err)
}
if openCalls != 1 || verifyCalls != 1 {
t.Fatalf("open=%d verify=%d, want 1/1", openCalls, verifyCalls)
}
}
// 打码值略偏(真值=打码值+1):同一张图第 2 个候选即命中,不应换图
func TestResolveWiseduSliderCaptchaCandidateHit(t *testing.T) {
disableSliderDelays(t)
var openCalls, verifyCalls int
ts := mockSliderServer(t, map[int]bool{66: true}, 65, &openCalls, &verifyCalls)
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
err := resolveWiseduSliderCaptchaFn(ctx, ts.URL+"/authserver", ts.URL+"/login", newTestRestyClient(),
func(string, string) (int, error) { return 65, nil })
if err != nil {
t.Fatalf("滑块流程失败: %v", err)
}
// 失败不换图(±1 由同图候选覆盖),成功即止
if openCalls != 1 {
t.Fatalf("open=%d, want 1(同图候选不应换图)", openCalls)
}
if verifyCalls != 2 {
t.Fatalf("verify=%d, want 2(65 失败后 66 命中)", verifyCalls)
}
}
// 真值=打码值-1:第 3 个候选(65,66,64 中 64)命中
func TestResolveWiseduSliderCaptchaCandidateHitBelow(t *testing.T) {
disableSliderDelays(t)
var openCalls, verifyCalls int
ts := mockSliderServer(t, map[int]bool{64: true}, 65, &openCalls, &verifyCalls)
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
err := resolveWiseduSliderCaptchaFn(ctx, ts.URL+"/authserver", ts.URL+"/login", newTestRestyClient(),
func(string, string) (int, error) { return 65, nil })
if err != nil {
t.Fatalf("滑块流程失败: %v", err)
}
if openCalls != 1 || verifyCalls != 3 {
t.Fatalf("open=%d verify=%d, want 1/3(65,66 失败后 64 命中)", openCalls, verifyCalls)
}
}
// 5 张图全失败:每图 3 个候选值(0,+1,-1),共 15 次 verify 后报错
func TestResolveWiseduSliderCaptchaExhausted(t *testing.T) {
disableSliderDelays(t)
var openCalls, verifyCalls int
ts := mockSliderServer(t, map[int]bool{}, 65, &openCalls, &verifyCalls)
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
defer cancel()
err := resolveWiseduSliderCaptchaFn(ctx, ts.URL+"/authserver", ts.URL+"/login", newTestRestyClient(),
func(string, string) (int, error) { return 65, nil })
if err == nil {
t.Fatal("持续失败应返回错误")
}
if openCalls != 5 {
t.Fatalf("open=%d, want 5", openCalls)
}
if verifyCalls != 3*5 {
t.Fatalf("verify=%d, want %d", verifyCalls, 3*5)
}
}
// 取图失败(非 JSON 响应)应报错并换图重试
func TestResolveWiseduSliderCaptchaBadResponse(t *testing.T) {
disableSliderDelays(t)
var openCalls, verifyCalls int
mux := http.NewServeMux()
mux.HandleFunc("/authserver/common/openSliderCaptcha.htl", func(w http.ResponseWriter, r *http.Request) {
openCalls++
_, _ = w.Write([]byte("<html>error</html>"))
})
mux.HandleFunc("/authserver/common/verifySliderCaptcha.htl", func(w http.ResponseWriter, r *http.Request) {
verifyCalls++
t.Error("取图失败时不应调用 verify")
})
ts := httptest.NewServer(mux)
defer ts.Close()
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
defer cancel()
if err := resolveWiseduSliderCaptchaFn(ctx, ts.URL+"/authserver", ts.URL+"/login", newTestRestyClient(),
func(string, string) (int, error) { return 65, nil }); err == nil {
t.Fatal("应返回错误")
}
if openCalls != 5 {
t.Fatalf("open=%d, want 5", openCalls)
}
if verifyCalls != 0 {
t.Fatalf("verify=%d, want 0", verifyCalls)
}
}
// 服务端激活延迟:mock 在 open 后 300ms 内拒绝一切值(复刻实测 1.3~1.7s 激活窗口),
// 等待逻辑(此处缩短为 400ms)应保证首个候选即命中:open=1、verify=1。
// 若等待缺失,首个候选会落在拒绝窗口内而多提交一轮,verify 数与 open 数都会变化。
func TestResolveWiseduSliderCaptchaActivationWait(t *testing.T) {
oldWait := wiseduSliderActivateWait
oldGap := wiseduSliderSubmitGap
oldDelay := wiseduSliderRetryDelay
wiseduSliderActivateWait = 400 * time.Millisecond
wiseduSliderSubmitGap = 0
wiseduSliderRetryDelay = func(int) time.Duration { return 0 }
t.Cleanup(func() {
wiseduSliderActivateWait = oldWait
wiseduSliderSubmitGap = oldGap
wiseduSliderRetryDelay = oldDelay
})
const rejectWithin = 300 * time.Millisecond
var openCalls, verifyCalls int
var openAt time.Time
mux := http.NewServeMux()
mux.HandleFunc("/authserver/common/openSliderCaptcha.htl", func(w http.ResponseWriter, r *http.Request) {
openCalls++
openAt = time.Now()
writeJSON(w, map[string]string{"bigImage": "ZmFrZWJpZw==", "smallImage": mockSmallImageB64()})
})
mux.HandleFunc("/authserver/common/verifySliderCaptcha.htl", func(w http.ResponseWriter, r *http.Request) {
verifyCalls++
_ = r.ParseForm()
p := mockDecryptSign(t, r.PostFormValue("sign"))
if time.Since(openAt) >= rejectWithin && p.MoveLength == 65 {
writeJSON(w, map[string]int{"errorCode": 1})
return
}
writeJSON(w, map[string]int{"errorCode": 0})
})
ts := httptest.NewServer(mux)
defer ts.Close()
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
err := resolveWiseduSliderCaptchaFn(ctx, ts.URL+"/authserver", ts.URL+"/login", newTestRestyClient(),
func(string, string) (int, error) { return 65, nil })
if err != nil {
t.Fatalf("滑块流程失败: %v", err)
}
if openCalls != 1 || verifyCalls != 1 {
t.Fatalf("open=%d verify=%d, want 1/1(激活等待后首个候选应命中)", openCalls, verifyCalls)
}
}
// ========== parseLoginForm 表单选择(#pwdLoginDiv 优先级) ==========
// 模拟华侨大学登录页:3 个 form(fido/动态码/账密),账密字段位于 #pwdLoginDiv
func TestParseLoginFormPwdLoginDiv(t *testing.T) {
inputs := parseLoginForm([]byte(hquLoginPageHTML), "2523132014", "testpwd123")
if inputs["cllt"] != "userNameLogin" {
t.Fatalf("应选中 #pwdLoginDiv 账密表单, cllt=%s", inputs["cllt"])
}
if inputs["username"] != "2523132014" {
t.Fatalf("username 错误: %s", inputs["username"])
}
if inputs["execution"] != "e1s1" {
t.Fatalf("execution 错误: %s", inputs["execution"])
}
// 密码应被 AES 加密(含 pwdEncryptSalt)
if inputs["password"] == "" || inputs["password"] == "testpwd123" {
t.Fatalf("password 应被加密: %q", inputs["password"])
}
// userPassword(明文框)应被删除
if _, ok := inputs["userPassword"]; ok {
t.Fatal("userPassword 字段应被删除")
}
// captcha 留空(滑块通过后提交)
if v, ok := inputs["captcha"]; !ok || v != "" {
t.Fatalf("captcha 应保留空值: %q %v", v, ok)
}
}
// ========== 辅助 ==========
func writeJSON(w http.ResponseWriter, v interface{}) {
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(v)
}
func newTestRestyClient() *resty.Client {
// 与 sessionClient 保持一致:DoNotParseResponse 模式(readBody 依赖 RawBody)
jar, _ := cookiejar.New(nil)
return resty.New().
SetTimeout(5*time.Second).
SetDoNotParseResponse(true).
SetCookieJar(jar).
SetHeader("User-Agent", "Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X)")
}
// 模拟华侨大学登录页 HTML(账密字段位于 #pwdLoginDiv,含 pwdEncryptSalt)
const hquLoginPageHTML = `<!DOCTYPE html>
<html><body>
<form method="post" id="loginFromId_fido" class="loginFromClass_fido" action="/authserver/login">
<input type="hidden" name="_eventId" value="submit"/>
<input type="text" name="username" value=""/>
<input type="hidden" name="responseJson" value=""/>
<input type="hidden" id="cllt" name="cllt" value="userNameLoginFido"/>
<input type="hidden" name="dllt" value="generalLogin"/>
<input type="hidden" id="lt" name="lt" value=""/>
<input type="hidden" name="execution" value="e1s1"/>
</form>
<form method="post" id="loginFromId" class="loginFromClass" action="/authserver/login">
<input type="text" name="username" value=""/>
<input type="text" id="captcha" name="captcha" placeholder="请输入验证码"/>
<input type="text" name="dynamicCode" value=""/>
<input type="hidden" name="_eventId" value="submit"/>
<input type="hidden" id="lt" name="lt" value=""/>
<input type="hidden" id="cllt" name="cllt" value="dynamicLogin"/>
<input type="hidden" name="dllt" value="generalLogin"/>
<input type="hidden" name="execution" value="e1s1"/>
</form>
<form method="post" id="loginFromId" class="loginFromClass" action="/authserver/login">
<div id="pwdLoginDiv">
<input type="text" name="username" value=""/>
<input type="password" name="userPassword" value=""/>
<input type="hidden" name="password" value=""/>
<input type="text" id="captcha" name="captcha" placeholder="请输入验证码"/>
<input type="hidden" name="_eventId" value="submit"/>
<input type="hidden" id="lt" name="lt" value=""/>
<input type="hidden" id="cllt" name="cllt" value="userNameLogin"/>
<input type="hidden" name="dllt" value="generalLogin"/>
<input type="hidden" name="execution" value="e1s1"/>
</div>
</form>
<input type="hidden" id="pwdEncryptSalt" value="sQJL9rnqFD65soWK"/>
</body></html>`
@@ -4,7 +4,6 @@ import (
"encoding/json"
"fmt"
"io"
"net/url"
"strings"
"time"
@@ -141,25 +140,22 @@ func (r *submitCaptchaResolver) GetCaptchaTicket(info *ValidationInfo) (string,
return ticket, nil
}
// createCaptcha 创建验证码(JSON body)
// createCaptcha 创建验证码(必须用 multipart,JSON body 会返回空壳响应导致验证码识别为空)
func (r *submitCaptchaResolver) createCaptcha(info *ValidationInfo) (*CpSubmitCaptcha, error) {
baseURL := strings.TrimSuffix(r.host, "/")
url := baseURL + "/captcha-open-api/v1/captcha/create/scenesImage"
client := newTaskClient()
reqData := map[string]string{
"accountKey": info.AccountKey,
"sceneCode": info.SceneCode,
"tenantId": info.TenantId,
"userId": info.UserId,
}
resp, err := client.R().
SetHeader("Cookie", r.cookieStr).
SetHeader("deviceId", r.deviceId).
SetHeader("Content-Type", "application/json; charset=UTF-8").
SetBody(reqData).
SetMultipartFormData(map[string]string{
"accountKey": info.AccountKey,
"sceneCode": info.SceneCode,
"tenantId": info.TenantId,
"userId": info.UserId,
}).
Post(url)
if err != nil {
return nil, fmt.Errorf("创建验证码请求失败: %w", err)
@@ -229,12 +225,11 @@ func (r *submitCaptchaResolver) getResultFromItStar(rawResponse json.RawMessage)
return itResp.Data, nil
}
// submitCaptcha 提交验证码结果(application/x-www-form-urlencoded)
// submitCaptcha 提交验证码结果(multipart,与旧版一致)
func (r *submitCaptchaResolver) submitCaptcha(info *ValidationInfo, captchaInfo *CpSubmitCaptcha, codes []string) (string, error) {
baseURL := strings.TrimSuffix(r.host, "/")
targetURL := baseURL + "/captcha-open-api/v1/captcha/validate/scenesImage"
// 构建 URL-encoded 表单数据
if captchaInfo.Code == "" {
return "", fmt.Errorf("创建验证码返回的场景码为空")
}
@@ -242,23 +237,22 @@ func (r *submitCaptchaResolver) submitCaptcha(info *ValidationInfo, captchaInfo
return "", fmt.Errorf("验证码识别结果为空,不提交空验证码")
}
form := url.Values{}
form.Set("accountKey", info.AccountKey)
form.Set("sceneCode", info.SceneCode)
form.Set("tenantId", info.TenantId)
form.Set("userId", info.UserId)
form.Set("scenesImageCode", captchaInfo.Code)
for _, code := range codes {
form.Add("scenesImageCodes", code)
}
client := newTaskClient()
resp, err := client.R().
req := client.R().
SetHeader("Cookie", r.cookieStr).
SetHeader("deviceId", r.deviceId).
SetHeader("Content-Type", "application/x-www-form-urlencoded").
SetBody(form.Encode()).
Post(targetURL)
SetMultipartFormData(map[string]string{
"accountKey": info.AccountKey,
"sceneCode": info.SceneCode,
"tenantId": info.TenantId,
"userId": info.UserId,
"scenesImageCode": captchaInfo.Code,
})
for _, code := range codes {
req = req.SetMultipartField("scenesImageCodes", "", "", strings.NewReader(code))
}
resp, err := req.Post(targetURL)
if err != nil {
return "", fmt.Errorf("提交验证码请求失败: %w", err)
}
+350 -64
View File
@@ -9,7 +9,9 @@ import (
"fmt"
"io"
"log/slog"
"math"
"net/url"
"strconv"
"strings"
"time"
@@ -24,6 +26,7 @@ type TaskType string
const (
TaskTypeSign TaskType = "sign"
TaskTypeSleepCheck TaskType = "sleep_check"
TaskTypeLeave TaskType = "leave" // 销假(请假销假)图片上传使用
)
// 各任务类型的 API 基础路径
@@ -49,20 +52,20 @@ type TodayTaskInfo struct {
// TaskFormDetail 签到任务表单详情
type TaskFormDetail struct {
Rows map[string]interface{} `json:"rows"`
RealTitle string `json:"realTitle"`
SignWid string `json:"signWid"`
SignInstanceWid string `json:"signInstanceWid"`
IsPhoto bool `json:"isPhoto"`
IsNeedExtra bool `json:"isNeedExtra"`
ExtraField []interface{} `json:"extraField,omitempty"`
HistoryRows map[string]interface{} `json:"historyRows,omitempty"`
Rows map[string]interface{} `json:"rows"`
RealTitle string `json:"realTitle"`
SignWid string `json:"signWid"`
SignInstanceWid string `json:"signInstanceWid"`
IsPhoto bool `json:"isPhoto"`
IsNeedExtra bool `json:"isNeedExtra"`
ExtraField []interface{} `json:"extraField,omitempty"`
HistoryRows map[string]interface{} `json:"historyRows,omitempty"`
}
// newRestyClient 创建 resty 客户端
func newTaskClient() *resty.Client {
return resty.New().
SetTimeout(30*time.Second).
SetTimeout(30 * time.Second).
SetDoNotParseResponse(true).
SetTLSClientConfig(&tls.Config{InsecureSkipVerify: true})
}
@@ -324,7 +327,7 @@ func FetchHistoryFormData(cookieMap map[string]string, host string, taskType Tas
todayTasks, _ := FetchTodayTasks(cookieMap, host, taskType)
for _, t := range todayTasks {
if t.IsSigned && globMatch(t.TaskName, realTitle) && t.SignInstanceWid != excludeSignInstanceWid {
detail, err := FetchTaskDetail(cookieMap, host, taskType, t.SignWid, t.SignInstanceWid, t.TaskName)
detail, err := GetTaskDetailByWid(cookieMap, host, taskType, t.SignWid, t.SignInstanceWid, t.TaskName)
if err == nil && detail != nil {
return detail.Rows
}
@@ -339,7 +342,7 @@ func FetchHistoryFormData(cookieMap map[string]string, host string, taskType Tas
monthTasks, _ := fetchTasksByYearMonth(cookieMap, host, taskType, year, month)
for _, t := range monthTasks {
if t.IsSigned && globMatch(t.TaskName, realTitle) && t.SignInstanceWid != excludeSignInstanceWid {
detail, err := FetchTaskDetail(cookieMap, host, taskType, t.SignWid, t.SignInstanceWid, t.TaskName)
detail, err := GetTaskDetailByWid(cookieMap, host, taskType, t.SignWid, t.SignInstanceWid, t.TaskName)
if err == nil && detail != nil {
return detail.Rows
}
@@ -363,60 +366,53 @@ func mapValueStr(m map[string]interface{}, key string) string {
return ""
}
// FetchTaskDetail 获取签到任务详情(表单字段)
// 任务实例过期(详情返回空)时自动匹配同名任务的最新可用实例重试
func FetchTaskDetail(cookieMap map[string]string, host string, taskType TaskType, signWid, signInstanceWid, title string) (*TaskFormDetail, error) {
return fetchTaskDetail(cookieMap, host, taskType, signWid, signInstanceWid, title, true)
// GetTaskDetailByWid 按 wid 直接拉取任务详情(表单预览、历史表单预填充等场景,无任何回退)
func GetTaskDetailByWid(cookieMap map[string]string, host string, taskType TaskType, signWid, signInstanceWid, title string) (*TaskFormDetail, error) {
return fetchTaskDetailByWid(cookieMap, host, taskType, signWid, signInstanceWid, title)
}
// findLatestTaskInstance 查找同名任务的最新可用实例
// 优先当天/近期列表中未签到的实例(循环任务当天实例,可正常提交);
// 否则回退到已签到实例(用于还原表单结构与历史默认值)
func findLatestTaskInstance(cookieMap map[string]string, host string, taskType TaskType, title, excludeInstanceWid string) (TodayTaskInfo, bool) {
collect := func(yearMonth string) ([]TodayTaskInfo, error) {
if yearMonth == "" {
return fetchTasksByEndpoint(cookieMap, host, taskType, "")
}
return fetchTasksByYearMonth(cookieMap, host, taskType, yearMonth[:4], yearMonth[5:])
// FetchTodayTaskDetail 任务执行专用:按任务名从当天列表实时匹配未签到的实例并拉取详情。
// wid 每次执行时自动获取,不依赖配置快照;匹配失败直接报明确错误,不做任何回退。
func FetchTodayTaskDetail(cookieMap map[string]string, host string, taskType TaskType, title string) (*TaskFormDetail, error) {
tasks, err := fetchTasksByEndpoint(cookieMap, host, taskType, "")
if err != nil {
return nil, fmt.Errorf("获取当日任务列表失败: %w", err)
}
// 依次:当天列表、本月列表、上月列表
now := time.Now()
months := []string{"", now.Format("2006-01"), now.AddDate(0, -1, 0).Format("2006-01")}
seen := map[string]bool{}
for _, ym := range months {
tasks, err := collect(ym)
if err != nil || len(tasks) == 0 {
var signed, target *TodayTaskInfo
for i := range tasks {
t := tasks[i]
if !globMatch(t.TaskName, title) || t.SignInstanceWid == "" {
continue
}
var signedFallback *TodayTaskInfo
for i := range tasks {
t := tasks[i]
if !globMatch(t.TaskName, title) || t.SignInstanceWid == "" || t.SignInstanceWid == excludeInstanceWid {
continue
}
if seen[t.SignInstanceWid] {
continue
}
seen[t.SignInstanceWid] = true
if !t.IsSigned {
// 未签到实例优先(当天循环任务可正常提交)
return t, true
}
if signedFallback == nil {
s := t
signedFallback = &s
}
if !t.IsSigned {
target = &t
break
}
if signedFallback != nil {
return *signedFallback, true
if signed == nil {
s := t
signed = &s
}
}
return TodayTaskInfo{}, false
if target == nil {
if signed != nil {
return nil, fmt.Errorf("「%s」今日已签到,无需重复执行", title)
}
return nil, fmt.Errorf("当日任务列表中未找到「%s」", title)
}
slog.Info("按任务名实时匹配到当日任务实例",
"title", title, "signInstanceWid", target.SignInstanceWid, "taskType", taskType)
detail, err := fetchTaskDetailByWid(cookieMap, host, taskType, target.SignWid, target.SignInstanceWid, target.TaskName)
if err != nil {
return nil, err
}
if detail.Rows == nil {
return nil, fmt.Errorf("获取「%s」任务详情为空", title)
}
return detail, nil
}
// fetchTaskDetail 拉取任务详情;allowFallback 为 true 且实例已过期(datas 为空)时自动匹配最新实例重试一次
func fetchTaskDetail(cookieMap map[string]string, host string, taskType TaskType, signWid, signInstanceWid, title string, allowFallback bool) (*TaskFormDetail, error) {
// fetchTaskDetailByWid 按 wid 拉取任务详情(纯查询,无任何回退逻辑)
func fetchTaskDetailByWid(cookieMap map[string]string, host string, taskType TaskType, signWid, signInstanceWid, title string) (*TaskFormDetail, error) {
client := newTaskClient()
cookieStr := cookiesToStr(cookieMap)
baseURL := taskBasePath(host, taskType)
@@ -451,13 +447,6 @@ func fetchTaskDetail(cookieMap map[string]string, host string, taskType TaskType
}
rows := result.Datas
if rows == nil && allowFallback {
// 实例已过期/当天实例未发布导致详情为空,自动匹配同名任务的最新可用实例重试
slog.Info("任务详情数据为空,自动匹配最新实例", "title", title, "signInstanceWid", signInstanceWid, "taskType", taskType)
if alt, ok := findLatestTaskInstance(cookieMap, host, taskType, title, signInstanceWid); ok {
return fetchTaskDetail(cookieMap, host, taskType, alt.SignWid, alt.SignInstanceWid, alt.TaskName, false)
}
}
detail := &TaskFormDetail{
Rows: rows,
@@ -497,6 +486,11 @@ func ParseTaskForm(detail *TaskFormDetail, historyRows map[string]interface{}) [
histRows = historyRows
}
// 扫码签到任务(signMode=2):只需要签到二维码(可选照片/额外字段),不需要定位信息
if isScanMode(rows) {
return parseScanTaskForm(detail, histRows)
}
// 提取历史值(与旧版系统格式一致:lon/lat/address 分开存储)
histLon := ""
histLat := ""
@@ -731,8 +725,125 @@ func ParseTaskForm(detail *TaskFormDetail, historyRows map[string]interface{}) [
return list
}
// parseScanTaskForm 生成扫码签到任务(signMode=2)的表单项:
// 签到二维码(必填,支持上传图片或粘贴链接)+ 可选签到照片 + 可选额外字段
func parseScanTaskForm(detail *TaskFormDetail, histRows map[string]interface{}) []cpdmodel.CpTaskFormItem {
rows := detail.Rows
var list []cpdmodel.CpTaskFormItem
// 签到二维码
histQr := ""
if histRows != nil {
if v, ok := histRows["qrCode"]; ok && v != nil {
histQr = fmt.Sprintf("%v", v)
}
}
list = append(list, cpdmodel.CpTaskFormItem{
Title: "签到二维码",
ColName: "qrCode",
Type: cpdmodel.ItemTypeImage,
Required: true,
Content: histQr,
Show: true,
Tip: "上传现场签到二维码图片(或直接粘贴二维码链接/内容),执行时自动解析出 uuid 提交",
})
// 签到照片(任务要求时保留)
isPhoto := detail.IsPhoto
if !isPhoto && histRows != nil {
if v, ok := histRows["isPhoto"]; ok {
if f, ok := v.(float64); ok && f == 1 {
isPhoto = true
}
}
}
if isPhoto {
histSignPhotoUrl := ""
if histRows != nil {
if photo, ok := histRows["signPhotoUrl"]; ok && photo != nil {
histSignPhotoUrl = fmt.Sprintf("%v", photo)
}
}
if histSignPhotoUrl == "" {
if photo, ok := rows["signPhotoUrl"]; ok && photo != nil {
histSignPhotoUrl = fmt.Sprintf("%v", photo)
}
}
list = append(list, cpdmodel.CpTaskFormItem{
Title: "签到照片",
ColName: "signImg",
Type: cpdmodel.ItemTypeImage,
Required: true,
Content: histSignPhotoUrl,
Default: histSignPhotoUrl,
Show: true,
})
}
// 额外字段
if detail.IsNeedExtra {
for i, field := range detail.ExtraField {
fm, ok := field.(map[string]interface{})
if !ok {
continue
}
extraTitle, _ := fm["title"].(string)
isReq, _ := fm["isRequired"].(bool)
hasOther, _ := fm["hasOtherItems"].(bool)
item := cpdmodel.CpTaskFormItem{
Title: extraTitle,
ColName: extraTitle,
Type: cpdmodel.ItemTypeSingle,
Number: uint(i + 1),
Required: isReq,
IsOther: hasOther,
Show: true,
}
if items, ok := fm["extraFieldItems"].([]interface{}); ok {
for _, it := range items {
if im, ok := it.(map[string]interface{}); ok {
content, _ := im["content"].(string)
otherItems, _ := im["isOtherItems"].(bool)
item.Options = append(item.Options, cpdmodel.CpTaskFormOption{
Content: content,
IsOtherItems: otherItems,
})
}
}
}
list = append(list, item)
}
}
return list
}
// FillSubmitForm 根据用户配置的表单项填充提交表单
func FillSubmitForm(rows map[string]interface{}, formItems []cpdmodel.CpTaskFormItem, deviceId string) map[string]interface{} {
// isScanMode 判断任务是否为扫码签到类型(signMode=2,兼容数字/字符串/json.Number)
func isScanMode(rows map[string]interface{}) bool {
if rows == nil {
return false
}
switch v := rows["signMode"].(type) {
case float64:
return int(v) == 2
case int:
return v == 2
case string:
return v == "2"
case json.Number:
return v.String() == "2"
}
return false
}
func FillSubmitForm(rows map[string]interface{}, formItems []cpdmodel.CpTaskFormItem, deviceId string) (map[string]interface{}, error) {
// 扫码签到任务(signMode=2):从签到二维码解析 uuid,提交体用 qrUuid 替代 signInstanceWid
if isScanMode(rows) {
return fillScanSubmitForm(rows, formItems)
}
result := make(map[string]interface{})
// 提取经纬度和地址(兼容旧版 lon/lat 和新版 latAndLon 两种格式)
@@ -805,6 +916,13 @@ func FillSubmitForm(rows map[string]interface{}, formItems []cpdmodel.CpTaskForm
}
// 额外字段
fillExtraFields(rows, formItems, result)
return result, nil
}
// fillExtraFields 填充任务额外字段(isNeedExtra)到提交表单
func fillExtraFields(rows map[string]interface{}, formItems []cpdmodel.CpTaskFormItem, result map[string]interface{}) {
isNeedExtra := false
if v, ok := rows["isNeedExtra"]; ok {
if f, ok := v.(float64); ok && f == 1 {
@@ -847,8 +965,175 @@ func FillSubmitForm(rows map[string]interface{}, formItems []cpdmodel.CpTaskForm
} else {
result["isNeedExtra"] = 0
}
}
return result
// fillScanSubmitForm 生成扫码签到(signMode=2)的提交表单:
// 解析签到二维码(图片/链接)得到 uuid,用 qrUuid 替代 signInstanceWid(两者不并存),
// 不携带经纬度等定位字段。
func fillScanSubmitForm(rows map[string]interface{}, formItems []cpdmodel.CpTaskFormItem) (map[string]interface{}, error) {
qrContent := ""
for _, v := range formItems {
if v.ColName == "qrCode" {
qrContent = strings.TrimSpace(v.Content)
break
}
}
uuid, err := ResolveQrUuidFromContent(qrContent)
if err != nil {
return nil, fmt.Errorf("二维码签到任务缺少有效签到二维码: %w", err)
}
result := map[string]interface{}{
"qrUuid": uuid,
"uaIsCpadaily": true,
}
// 签到照片(任务要求时保留,与普通签到一致)
isPhoto := false
if v, ok := rows["isPhoto"]; ok {
if f, ok := v.(float64); ok && f == 1 {
isPhoto = true
}
}
if isPhoto {
for _, v := range formItems {
if v.ColName == "signImg" && v.Content != "" {
result["signPhotoUrl"] = v.Content
break
}
}
}
// 额外字段(与普通签到保持一致的提交结构)
fillExtraFields(rows, formItems, result)
return result, nil
}
// SignPlace 查寝组/签到任务配置的签到范围点
type SignPlace struct {
Address string
Longitude string // 原始字符串,保持精度
Latitude string // 原始字符串,保持精度
LongitudeF float64 // 用于距离计算
LatitudeF float64
RadiusMeters float64
}
// parseSignPlaces 解析任务详情中的 signPlaceSelected(兼容 JSON 字符串与数组两种格式)
func parseSignPlaces(rows map[string]interface{}) []SignPlace {
if rows == nil {
return nil
}
var places []SignPlace
appendPlace := func(m map[string]interface{}) {
lon := mapValueStr(m, "longitude")
lat := mapValueStr(m, "latitude")
if lon == "" || lat == "" {
return
}
lonF, err1 := strconv.ParseFloat(lon, 64)
latF, err2 := strconv.ParseFloat(lat, 64)
if err1 != nil || err2 != nil {
return
}
radius := 0.0
if r, ok := m["radius"]; ok && r != nil {
if f, ok := r.(float64); ok {
radius = f
} else {
radius, _ = strconv.ParseFloat(fmt.Sprintf("%v", r), 64)
}
}
if radius <= 0 {
return
}
places = append(places, SignPlace{
Address: mapValueStr(m, "address"),
Longitude: lon,
Latitude: lat,
LongitudeF: lonF,
LatitudeF: latF,
RadiusMeters: radius,
})
}
if sps, ok := rows["signPlaceSelected"]; ok {
switch v := sps.(type) {
case string:
if v != "" && v != "[]" {
var arr []map[string]interface{}
if json.Unmarshal([]byte(v), &arr) == nil {
for _, m := range arr {
appendPlace(m)
}
}
}
case []interface{}:
for _, p := range v {
if m, ok := p.(map[string]interface{}); ok {
appendPlace(m)
}
}
}
}
return places
}
// haversineMeters 计算两个经纬度坐标之间的球面距离(米)
func haversineMeters(lat1, lon1, lat2, lon2 float64) float64 {
const earthRadius = 6371000.0
rad := math.Pi / 180.0
dLat := (lat2 - lat1) * rad
dLon := (lon2 - lon1) * rad
a := math.Sin(dLat/2)*math.Sin(dLat/2) +
math.Cos(lat1*rad)*math.Cos(lat2*rad)*math.Sin(dLon/2)*math.Sin(dLon/2)
return 2 * earthRadius * math.Asin(math.Sqrt(a))
}
// AdjustSubmitFormToSignRange 提交前校验历史位置是否落在查寝组签到范围内,
// 若超出所有签到点范围则自动改用距离最近的签到点坐标(避免服务端拒绝"位置不在签到范围内")。
// 返回 true 表示发生了坐标调整。
func AdjustSubmitFormToSignRange(submitForm map[string]interface{}, detailRows map[string]interface{}) bool {
places := parseSignPlaces(detailRows)
if len(places) == 0 {
return false
}
latStr, _ := submitForm["latitude"].(string)
lonStr, _ := submitForm["longitude"].(string)
lat, err1 := strconv.ParseFloat(strings.TrimSpace(latStr), 64)
lon, err2 := strconv.ParseFloat(strings.TrimSpace(lonStr), 64)
if err1 != nil || err2 != nil {
return false
}
best := -1
bestDist := math.MaxFloat64
for i, p := range places {
d := haversineMeters(lat, lon, p.LatitudeF, p.LongitudeF)
if d <= p.RadiusMeters {
// 历史位置在某个签到点范围内,正常沿用历史记录
return false
}
if d < bestDist {
bestDist = d
best = i
}
}
if best < 0 {
return false
}
p := places[best]
submitForm["latitude"] = p.Latitude
submitForm["longitude"] = p.Longitude
if p.Address != "" {
submitForm["position"] = p.Address
}
slog.Info("历史位置超出查寝组所有签到范围,自动改用最近签到点",
"origLat", latStr, "origLon", lonStr,
"newLat", p.Latitude, "newLon", p.Longitude,
"place", p.Address, "distM", int(bestDist), "radiusM", int(p.RadiusMeters))
return true
}
// GetBodyString 加密提交表单为 bodyString
@@ -967,6 +1252,7 @@ func SubmitSign(cookieMap map[string]string, host string, taskType TaskType, sub
return fmt.Errorf("解析签到响应失败: %w, body: %s", err, string(body))
}
code, _ := result.Code.Int64()
slog.Info("submitSign 响应", "code", result.Code.String(), "message", result.Message, "body", string(body))
if code != 0 {
msg := result.Message
if msg == "" {
@@ -0,0 +1,82 @@
package cpdaily
import (
"testing"
)
// 模拟阜阳职业技术学院查寝组真实返回的签到点配置
func mockSignPlacesRows() map[string]interface{} {
return map[string]interface{}{
"signPlaceSelected": `[{"address":"七渔河东区","longitude":"115.78725271446127","latitude":"32.90506452710031","radius":500},
{"address":"新建的签到地点","longitude":"115.78725271446127","latitude":"32.90506452710031","radius":790},
{"address":"新校区1000米","longitude":"115.77058016419615","latitude":"32.904624876025075","radius":1000},
{"address":"新校区边角修正2","longitude":"115.77471236954204","latitude":"32.9053826914217","radius":500},
{"address":"新校区边角修正1","longitude":"115.76709473881743","latitude":"32.90250295808209","radius":500}]`,
}
}
// 历史位置在"新校区1000米"点范围内(约932m)→ 不调整,沿用历史记录
func TestAdjustInKeepHistory(t *testing.T) {
form := map[string]interface{}{
"latitude": "32.898615",
"longitude": "115.763603",
"position": "七渔河东区",
}
adjusted := AdjustSubmitFormToSignRange(form, mockSignPlacesRows())
if adjusted {
t.Fatalf("历史位置在范围内不应调整, form=%v", form)
}
if form["latitude"] != "32.898615" || form["longitude"] != "115.763603" {
t.Fatalf("范围内历史坐标不应被修改: %v", form)
}
}
// 历史位置远离所有签到点(2km+)→ 调整为最近签到点坐标
func TestAdjustOutOfRange(t *testing.T) {
form := map[string]interface{}{
"latitude": "32.880000",
"longitude": "115.740000",
"position": "旧位置",
}
adjusted := AdjustSubmitFormToSignRange(form, mockSignPlacesRows())
if !adjusted {
t.Fatal("完全出界的历史位置应被调整")
}
if form["longitude"] != "115.76709473881743" || form["latitude"] != "32.90250295808209" {
t.Fatalf("应调整为最近的'边角修正1'坐标, got %v", form)
}
if form["position"] != "新校区边角修正1" {
t.Fatalf("地址应同步为最近签到点: %v", form)
}
}
// 无 signPlaceSelected / 无 radius 时保持不动
func TestAdjustNoPlaces(t *testing.T) {
form := map[string]interface{}{"latitude": "32.88", "longitude": "115.74"}
if AdjustSubmitFormToSignRange(form, map[string]interface{}{}) {
t.Fatal("无签到点配置不应调整")
}
if AdjustSubmitFormToSignRange(form, map[string]interface{}{"signPlaceSelected": "[]"}) {
t.Fatal("空签到点不应调整")
}
}
// 坐标解析异常时保持不动
func TestAdjustBadCoord(t *testing.T) {
form := map[string]interface{}{"latitude": "", "longitude": "abc"}
if AdjustSubmitFormToSignRange(form, mockSignPlacesRows()) {
t.Fatal("坐标异常不应调整")
}
}
// haversine 距离合理性:边角修正1距历史位置约540m,七渔河东区约2.26km
func TestHaversineMeters(t *testing.T) {
d1 := haversineMeters(32.898615, 115.763603, 32.90250295808209, 115.76709473881743)
if d1 < 480 || d1 > 600 {
t.Fatalf("距边角修正1应约540m, got %.0f", d1)
}
d2 := haversineMeters(32.898615, 115.763603, 32.90506452710031, 115.78725271446127)
if d2 < 2000 || d2 > 2500 {
t.Fatalf("距七渔河东区应约2.26km, got %.0f", d2)
}
}
@@ -0,0 +1,75 @@
package cpdaily
import (
"testing"
cpdmodel "cpdaily-new/internal/model"
)
// 扫码任务:提交表单必须用 qrUuid 替代 signInstanceWid,且不携带经纬度
func TestFillScanSubmitForm(t *testing.T) {
rows := map[string]interface{}{
"signMode": float64(2),
"signInstanceWid": "397161",
}
formItems := []cpdmodel.CpTaskFormItem{
{Title: "签到二维码", ColName: "qrCode", Type: cpdmodel.ItemTypeImage,
Content: "https://ynu.campusphere.net/wec-counselor-sign-apps/stu/qrsign/index.html?uuid=1583a66272854585833eb13b56fc1937&isNeedExtra=0"},
}
form, err := FillSubmitForm(rows, formItems, "device-1")
if err != nil {
t.Fatalf("FillSubmitForm error: %v", err)
}
if form["qrUuid"] != "1583a66272854585833eb13b56fc1937" {
t.Fatalf("qrUuid = %v", form["qrUuid"])
}
if _, ok := form["signInstanceWid"]; ok {
t.Fatal("scan submit form must NOT contain signInstanceWid")
}
if _, ok := form["latitude"]; ok {
t.Fatal("scan submit form must NOT contain latitude")
}
if _, ok := form["longitude"]; ok {
t.Fatal("scan submit form must NOT contain longitude")
}
if form["uaIsCpadaily"] != true {
t.Fatal("uaIsCpadaily missing")
}
// 缺少二维码 → 明确报错(不再静默生成会假成功的表单)
if _, err := FillSubmitForm(rows, nil, "device-1"); err == nil {
t.Fatal("missing qrCode should return error")
}
// 普通任务(无 signMode)不受影响:走原逻辑,且不含 qrUuid
normalRows := map[string]interface{}{"signInstanceWid": "123"}
normalItems := []cpdmodel.CpTaskFormItem{
{ColName: "lat", Content: "24.8"},
{ColName: "lon", Content: "102.8"},
{ColName: "address", Content: "云南大学"},
}
normalForm, err := FillSubmitForm(normalRows, normalItems, "device-1")
if err != nil {
t.Fatalf("normal FillSubmitForm error: %v", err)
}
if normalForm["latitude"] != "24.8" || normalForm["signInstanceWid"] != "123" {
t.Fatalf("normal form mismatch: %v", normalForm)
}
if _, ok := normalForm["qrUuid"]; ok {
t.Fatal("normal form must not contain qrUuid")
}
}
// 扫码任务表单项解析:ParseTaskForm 应生成「签到二维码」图片项,不含经纬度项
func TestParseScanTaskForm(t *testing.T) {
detail := &TaskFormDetail{
Rows: map[string]interface{}{"signMode": float64(2)},
}
items := ParseTaskForm(detail, nil)
if len(items) != 1 {
t.Fatalf("scan form items = %d, want 1: %+v", len(items), items)
}
if items[0].ColName != "qrCode" || items[0].Type != cpdmodel.ItemTypeImage || !items[0].Required {
t.Fatalf("unexpected qrCode item: %+v", items[0])
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

+56
View File
@@ -12,6 +12,10 @@ type Claims struct {
UserID uint `json:"userId"`
Username string `json:"username"`
SchoolID string `json:"schoolId"`
// MFA 标记:通过多因子认证通道登录的用户。此类账号第三方会话无法自动续期
// (重新登录需人工输入验证码),JWT 有效期与第三方 Cookie 对齐(短期),
// 刷新 token 也不会延长会话。
MFA bool `json:"mfa,omitempty"`
jwt.RegisteredClaims
}
@@ -64,6 +68,43 @@ func GenerateTokens(userID uint, username, schoolID string) (*TokenPair, error)
}, nil
}
// GenerateShortTokens 生成 MFA 通道登录的短期 token 对:
// access 与 refresh 有效期均为 ttl 秒(与第三方 Cookie 有效期对齐,当前默认配置 7200 即 2 小时,
// 见 config.JwtConfig.MfaAccessExpire),且携带 MFA 标记——刷新 token 时不会延长会话。
func GenerateShortTokens(userID uint, username, schoolID string, ttlSeconds int64) (*TokenPair, error) {
now := time.Now()
jwtCfg := config.Global.Jwt
accessClaims := Claims{
UserID: userID,
Username: username,
SchoolID: schoolID,
MFA: true,
RegisteredClaims: jwt.RegisteredClaims{
ExpiresAt: jwt.NewNumericDate(now.Add(time.Duration(ttlSeconds) * time.Second)),
IssuedAt: jwt.NewNumericDate(now),
Issuer: "cpdaily-new",
},
}
accessToken, err := jwt.NewWithClaims(jwt.SigningMethodHS256, accessClaims).SignedString([]byte(jwtCfg.Secret))
if err != nil {
return nil, err
}
refreshClaims := accessClaims
refreshClaims.Issuer = "cpdaily-new-refresh"
refreshToken, err := jwt.NewWithClaims(jwt.SigningMethodHS256, refreshClaims).SignedString([]byte(jwtCfg.Secret))
if err != nil {
return nil, err
}
return &TokenPair{
AccessToken: accessToken,
RefreshToken: refreshToken,
ExpiresIn: ttlSeconds,
}, nil
}
func ValidateToken(tokenString string) (*Claims, error) {
claims := &Claims{}
token, err := jwt.ParseWithClaims(tokenString, claims, func(token *jwt.Token) (interface{}, error) {
@@ -73,6 +114,17 @@ func ValidateToken(tokenString string) (*Claims, error) {
return []byte(config.Global.Jwt.Secret), nil
})
if err != nil {
// MFA access token 服务端校验宽限:MFA 学校第三方会话无法自动续期(重登需人工
// 输入验证码),已签发 token 无法修改;当 MfaAccessExpire 调整(如 1h → 2h)时,
// 对调整前签发的 MFA access token 按「签发时间 + 当前配置 TTL」放行,使存量用户
// 无需重新登录、平滑过渡到新有效期。仅放宽 access(issuer=cpdaily-new):
// refresh 严格按自身 exp 校验,避免出现刷新窗口导致会话被滑动静默续期,
// 违背 MFA 短会话设计。
if errors.Is(err, jwt.ErrTokenExpired) &&
claims.MFA && claims.Issuer == "cpdaily-new" && claims.IssuedAt != nil &&
time.Now().Before(claims.IssuedAt.Add(time.Duration(config.Global.Jwt.MfaAccessExpire)*time.Second)) {
return claims, nil
}
return nil, err
}
if !token.Valid {
@@ -86,6 +138,10 @@ func RefreshTokens(refreshToken string) (*TokenPair, error) {
if err != nil {
return nil, errors.New("refresh token invalid or expired")
}
// MFA 通道登录的用户:第三方会话无法自动续期,刷新也不延长有效期
if claims.MFA {
return GenerateShortTokens(claims.UserID, claims.Username, claims.SchoolID, int64(config.Global.Jwt.MfaAccessExpire))
}
// 刷新时生成全新 token 对
return GenerateTokens(claims.UserID, claims.Username, claims.SchoolID)
}
+134
View File
@@ -0,0 +1,134 @@
package service
import (
"testing"
"time"
"cpdaily-new/internal/config"
"github.com/golang-jwt/jwt/v5"
)
// 测试配置:MFA TTL 2 小时(与当前生产配置一致)
func setupJwtTestConfig() {
config.Global.Jwt.Secret = "unit-test-secret"
config.Global.Jwt.AccessExpire = 3600
config.Global.Jwt.RefreshExpire = 864000
config.Global.Jwt.MfaAccessExpire = 7200
}
// signClaims 用手写 Claims 直接签名,模拟不同时期/不同 TTL 签发的 token
func signClaims(t *testing.T, secret string, claims Claims) string {
t.Helper()
s, err := jwt.NewWithClaims(jwt.SigningMethodHS256, claims).SignedString([]byte(secret))
if err != nil {
t.Fatalf("签名失败: %v", err)
}
return s
}
// mfaClaims 构造 MFA token Claims:iat 为签发时间,ttl 为签发时的有效期
func mfaClaims(issuer string, iat time.Time, ttl time.Duration) Claims {
return Claims{
UserID: 1066,
Username: "ynu-20251960205",
SchoolID: "ynu",
MFA: true,
RegisteredClaims: jwt.RegisteredClaims{
IssuedAt: jwt.NewNumericDate(iat),
ExpiresAt: jwt.NewNumericDate(iat.Add(ttl)),
Issuer: issuer,
},
}
}
// 场景1:1h 配置时代签发的 MFA access token 已过期 30 分钟,但在「签发时间+2h」宽限内 → 放行
// (杨蕊宁存量 token 场景:部署 v1.2.39 后无需重新登录)
func TestMFAOldAccessTokenWithinGrace(t *testing.T) {
setupJwtTestConfig()
now := time.Now()
tokenStr := signClaims(t, config.Global.Jwt.Secret, mfaClaims("cpdaily-new", now.Add(-90*time.Minute), 1*time.Hour))
claims, err := ValidateToken(tokenStr)
if err != nil {
t.Fatalf("宽限窗口内(iat+2h)应放行: %v", err)
}
if claims.UserID != 1066 || !claims.MFA {
t.Fatalf("claims 解析异常: %+v", claims)
}
}
// 场景2:MFA access token 超出「签发时间+2h」宽限 → 拒绝
func TestMFAOldAccessTokenBeyondGrace(t *testing.T) {
setupJwtTestConfig()
now := time.Now()
tokenStr := signClaims(t, config.Global.Jwt.Secret, mfaClaims("cpdaily-new", now.Add(-150*time.Minute), 1*time.Hour))
if _, err := ValidateToken(tokenStr); err == nil {
t.Fatal("超出宽限窗口的 MFA access token 必须拒绝")
}
}
// 场景3:MFA refresh token 已过期 → 拒绝(refresh 不享受宽限,避免滑动续期)
func TestMFAOldRefreshTokenNotGraced(t *testing.T) {
setupJwtTestConfig()
now := time.Now()
tokenStr := signClaims(t, config.Global.Jwt.Secret, mfaClaims("cpdaily-new-refresh", now.Add(-90*time.Minute), 1*time.Hour))
if _, err := ValidateToken(tokenStr); err == nil {
t.Fatal("MFA refresh token 过期后必须拒绝(不得宽限)")
}
}
// 场景4:非 MFA 的普通 access token 过期 → 按原逻辑拒绝
func TestNormalExpiredAccessTokenRejected(t *testing.T) {
setupJwtTestConfig()
now := time.Now()
claims := Claims{
UserID: 1,
Username: "normal-user",
MFA: false,
RegisteredClaims: jwt.RegisteredClaims{
IssuedAt: jwt.NewNumericDate(now.Add(-90 * time.Minute)),
ExpiresAt: jwt.NewNumericDate(now.Add(-30 * time.Minute)),
Issuer: "cpdaily-new",
},
}
if _, err := ValidateToken(signClaims(t, config.Global.Jwt.Secret, claims)); err == nil {
t.Fatal("普通用户过期 access token 必须拒绝")
}
}
// 场景5:正常有效期内(TTL=2h)的 MFA access token → 放行(常规路径不受影响)
func TestMFAAccessTokenValidNormally(t *testing.T) {
setupJwtTestConfig()
now := time.Now()
tokenStr := signClaims(t, config.Global.Jwt.Secret, mfaClaims("cpdaily-new", now.Add(-10*time.Minute), 2*time.Hour))
if _, err := ValidateToken(tokenStr); err != nil {
t.Fatalf("有效期内 MFA access token 应放行: %v", err)
}
}
// 场景6:错误签名 → 拒绝(宽限不得绕过签名校验)
func TestMFAOldAccessTokenWrongSignatureRejected(t *testing.T) {
setupJwtTestConfig()
now := time.Now()
tokenStr := signClaims(t, "wrong-secret", mfaClaims("cpdaily-new", now.Add(-90*time.Minute), 1*time.Hour))
if _, err := ValidateToken(tokenStr); err == nil {
t.Fatal("错误签名必须拒绝")
}
}
// 场景7:MFA access token 缺少签发时间(iat 缺失)→ 拒绝(无法核定宽限窗口)
func TestMFAOldAccessTokenWithoutIssuedAtRejected(t *testing.T) {
setupJwtTestConfig()
now := time.Now()
claims := Claims{
UserID: 1066,
MFA: true,
RegisteredClaims: jwt.RegisteredClaims{
ExpiresAt: jwt.NewNumericDate(now.Add(-30 * time.Minute)),
Issuer: "cpdaily-new",
},
}
if _, err := ValidateToken(signClaims(t, config.Global.Jwt.Secret, claims)); err == nil {
t.Fatal("缺少 iat 的过期 MFA access token 必须拒绝")
}
}
+47
View File
@@ -5,6 +5,7 @@ import (
"encoding/base64"
"errors"
"fmt"
"html"
"log/slog"
"net"
"net/smtp"
@@ -294,3 +295,49 @@ func NotifySchoolAdapted(contactEmails []string, schoolName string) {
<p style="font-size:14px;color:#555">若仍有问题,欢迎在应用内提交工单反馈。</p>`
SendMailAsync(contactEmails, subject, EmailBody(inner))
}
// NotifySchoolRejected 学校适配申请拒绝通知(发给该学校提交者留下的全部联系邮箱,附拒绝理由)
func NotifySchoolRejected(contactEmails []string, schoolName, reason string) {
if len(contactEmails) == 0 {
slog.Info("学校无联系邮箱,跳过拒绝通知", "school", schoolName)
return
}
escapedReason := html.EscapeString(reason)
escapedReason = strings.ReplaceAll(escapedReason, "\n", "<br/>")
subject := "【小助手】" + schoolName + " 暂无法适配"
inner := `<h2 style="margin:0 0 16px;font-size:17px">很抱歉,` + schoolName + ` 暂无法适配</h2>
<p style="font-size:14px;color:#555;line-height:1.8">感谢您的反馈。经管理员核实,该学校暂时无法适配,具体原因如下:</p>
<div style="font-size:14px;color:#333;line-height:1.8;background:#f7f8fa;border-radius:8px;padding:12px 16px;margin:12px 0">` + escapedReason + `</div>
<p style="font-size:14px;color:#555">请核对账号信息后重新提交,或如有疑问欢迎在应用内提交工单反馈。</p>`
SendMailAsync(contactEmails, subject, EmailBody(inner))
}
// NotifyNewTicket 新工单提醒(发给管理员)
func NotifyNewTicket(ticketID uint, username, schoolName, realName, title, content, createdAt string) {
adminEmails := GetAdminEmails()
if len(adminEmails) == 0 {
slog.Info("未配置管理员邮箱,跳过新工单通知", "ticketId", ticketID)
return
}
// 内容摘要:HTML 转义防注入,换行转 <br>,超长截断
escaped := html.EscapeString(content)
if len([]rune(escaped)) > 500 {
escaped = string([]rune(escaped)[:500]) + "……"
}
escaped = strings.ReplaceAll(escaped, "\n", "<br/>")
displayName := realName
if displayName == "" {
displayName = username
}
subject := "【小助手】新的工单:" + title
inner := `<h2 style="margin:0 0 16px;font-size:17px">收到新的工单反馈</h2>
<table style="border-collapse:collapse;font-size:14px;color:#333;line-height:2">
<tr><td style="padding-right:16px;color:#888;vertical-align:top">标题</td><td><b>` + html.EscapeString(title) + `</b></td></tr>
<tr><td style="padding-right:16px;color:#888">用户</td><td>` + html.EscapeString(displayName) + `(` + html.EscapeString(username) + `)</td></tr>
<tr><td style="padding-right:16px;color:#888">学校</td><td>` + html.EscapeString(schoolName) + `</td></tr>
<tr><td style="padding-right:16px;color:#888;vertical-align:top">内容</td><td style="max-width:420px">` + escaped + `</td></tr>
<tr><td style="padding-right:16px;color:#888">提交时间</td><td>` + html.EscapeString(createdAt) + `</td></tr>
</table>
<p style="color:#888;font-size:13px;margin-top:12px">请到管理后台「工单」查看详情并回复处理。</p>`
SendMailAsync(adminEmails, subject, EmailBody(inner))
}
+35
View File
@@ -0,0 +1,35 @@
package service
import (
"context"
"log/slog"
"strconv"
"time"
"cpdaily-new/internal/config"
"github.com/redis/go-redis/v9"
)
// Redis 全局客户端(验证码存储/限流等)。启动时初始化,连接失败不阻断进程启动,
// go-redis 会在后续请求时自动重连。
var Redis *redis.Client
// InitRedis 初始化 Redis 客户端并做一次连通性探测
func InitRedis() {
cfg := config.Global.Redis
if cfg.Host == "" {
slog.Warn("未配置 [redis],验证码相关功能将不可用")
return
}
Redis = redis.NewClient(&redis.Options{
Addr: cfg.Host + ":" + strconv.Itoa(cfg.Port),
Password: cfg.Password,
DB: cfg.Db,
})
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
if err := Redis.Ping(ctx).Err(); err != nil {
slog.Error("Redis 连接失败(验证码功能暂不可用,请检查配置)", "addr", cfg.Host, "err", err)
}
}
+25
View File
@@ -0,0 +1,25 @@
package utils
import (
"strings"
"github.com/gofiber/fiber/v2"
)
// ClientIP 提取客户端真实 IP。
// 生产环境经 nginx 反代,nginx 已配置 proxy_set_header X-Real-IP $remote_addr
// 和 X-Forwarded-For $proxy_add_x_forwarded_for,因此优先取 X-Real-IP(nginx 直接写入,
// 最可信);其次取 X-Forwarded-For 第一段(多层代理链上最初的客户端 IP);
// 都没有时兜底 Fiber 默认的 RemoteAddr(直连场景,如本地开发)。
func ClientIP(c *fiber.Ctx) string {
if rip := strings.TrimSpace(c.Get("X-Real-IP")); rip != "" {
return rip
}
if xff := strings.TrimSpace(c.Get("X-Forwarded-For")); xff != "" {
if idx := strings.IndexByte(xff, ','); idx >= 0 {
xff = xff[:idx]
}
return strings.TrimSpace(xff)
}
return c.IP()
}
+13
View File
@@ -6,6 +6,19 @@ import (
"github.com/gofiber/fiber/v2"
)
// MFA 多因子认证专用业务码:HTTP 200 返回,前端按 code 弹出验证码弹窗而非错误弹窗
const (
CodeMFARequired = 4281 // 登录触发多因子认证,Data 含 needMFA/mfaToken/expiresIn/authMethods(可用验证方式列表)
CodeMFAVerifyFailed = 4282 // MFA 验证码错误或验证失败(弹窗保留可重试)
CodeMFAVerifySessionExpired = 4283 // MFA 验证会话不存在或已过期(前端应关闭弹窗引导重新登录)
CodeMFASendCodeFailed = 4284 // MFA 验证码发送失败(弹窗保留在选择界面,可重试或更换验证方式)
)
// RespondWithCode 以 HTTP 200 返回指定业务码(MFA 等需要前端精确分支处理的状态)
func RespondWithCode(c *fiber.Ctx, code int, msg string, data interface{}) error {
return c.JSON(Response{Code: code, Message: msg, Data: data})
}
// FormatFloat 格式化浮点数为字符串保留2位小数
func FormatFloat(f float64) string {
return fmt.Sprintf("%.2f", f)
+2
View File
@@ -5,6 +5,8 @@ services:
restart: unless-stopped
ports:
- "8884:8093"
environment:
- TZ=Asia/Shanghai
volumes:
- /opt/cpdaily-xiaozhushou/config.toml:/app/config.toml:ro
networks:
+12
View File
@@ -4,7 +4,19 @@
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover" />
<meta name="theme-color" content="#f5f5f5" />
<title>小助手</title>
<script>
// 暗色主题防闪烁:与 composables/useTheme.ts 保持同一时间规则
// (18:00–次日 06:00 暗色),在首屏渲染前预设 html.dark 与状态栏色
;(function () {
var h = new Date().getHours()
if (h >= 18 || h < 6) {
document.documentElement.classList.add('dark')
document.querySelector('meta[name="theme-color"]').setAttribute('content', '#15151f')
}
})()
</script>
</head>
<body>
<div id="app"></div>
+1 -1
View File
@@ -28,7 +28,7 @@ watch(
<!-- Toast 容器 -->
<Teleport to="body">
<div class="fixed top-4 right-4 z-50 flex flex-col gap-2 max-w-sm">
<div class="fixed top-4 right-4 z-[100] flex flex-col gap-2 max-w-sm">
<TransitionGroup name="toast">
<div
v-for="toast in toasts"
+98 -7
View File
@@ -137,6 +137,11 @@ export interface CDKCode {
price: number
costPrice: number
agentId: number
maxRuns: number
/** CDK类型:count=次数卡 / vip=VIP时长卡 */
type: 'count' | 'vip'
/** VIP 时长天数(type=vip 有效:7=周卡 30=月卡) */
vipDays: number
status: string
userId: number | null
usedAt: string | null
@@ -144,6 +149,28 @@ export interface CDKCode {
updatedAt: string
}
export interface CDKSummary {
total: number
unused: number
used: number
disabled: number
}
export interface CDKListResult {
total: number
items: CDKCode[]
page: number
pageSize: number
summary: CDKSummary
}
export interface CDKListParams {
page?: number
pageSize?: number
keyword?: string
status?: string
}
export interface GenerateCDKResult {
count: number
price: number
@@ -152,19 +179,39 @@ export interface GenerateCDKResult {
cdkCodes: CDKCode[]
}
/** 生成CDK */
export function generateCDK(count: number = 1, maxRuns: number = 1) {
return api.post<{ data: GenerateCDKResult }>('/admin/cdks', { count, maxRuns })
/** 生成CDK(type: count=次数 / vip=VIP时长,vipDays 默认 30 天) */
export function generateCDK(
count: number = 1,
maxRuns: number = 1,
type: 'count' | 'vip' = 'count',
vipDays: number = 30
) {
return api.post<{ data: GenerateCDKResult }>('/admin/cdks', { count, maxRuns, type, vipDays })
}
/** 获取自己生成的CDK列表 */
export function getMyCDKs() {
return api.get<{ data: CDKCode[] }>('/admin/cdks')
export function getMyCDKs(params: CDKListParams = {}) {
const qs = new URLSearchParams()
qs.set('page', String(params.page || 1))
qs.set('pageSize', String(params.pageSize || 20))
if (params.keyword) qs.set('keyword', params.keyword)
if (params.status) qs.set('status', params.status)
return api.get<{ data: CDKListResult }>(`/admin/cdks?${qs.toString()}`)
}
/** 获取所有CDK列表(admin only) */
export function getAllCDKs() {
return api.get<{ data: CDKCode[] }>('/admin/all-cdks')
export function getAllCDKs(params: CDKListParams = {}) {
const qs = new URLSearchParams()
qs.set('page', String(params.page || 1))
qs.set('pageSize', String(params.pageSize || 20))
if (params.keyword) qs.set('keyword', params.keyword)
if (params.status) qs.set('status', params.status)
return api.get<{ data: CDKListResult }>(`/admin/all-cdks?${qs.toString()}`)
}
/** 禁用/恢复CDK */
export function updateCDKStatus(id: number, action: 'disable' | 'enable') {
return api.put(`/admin/cdks/${id}/status`, { action })
}
// ======== 系统设置(admin only) ========
@@ -270,6 +317,8 @@ export interface UserListItem {
signCount: number
inviteReward: number
invitedCount: number
inviterId: number | null
inviterName: string
taskCount: number
status: string
createdAt: string
@@ -294,6 +343,8 @@ export interface UserDetail {
signCount: number
inviteReward: number
invitedCount: number
inviterId: number | null
inviterName: string
status: string
disabledReason: string
taskCount: number
@@ -321,3 +372,43 @@ export function getUserDetail(id: number) {
export function updateUserStatus(id: number, status: 'active' | 'disabled', disabledReason = '') {
return api.put(`/admin/users/${id}/status`, { status, disabledReason })
}
// ======== 任务执行日志(admin only) ========
export interface AdminTaskLogItem {
id: number
userId: number
username: string
realName: string
schoolName: string
taskType: string // sign / sleep_check
title: string
realTitle: string
isSuccess: boolean
errorMessage: string
createdAt: string
}
export interface AdminTaskLogResult {
total: number
items: AdminTaskLogItem[]
page: number
pageSize: number
}
/** 获取任务执行日志列表(默认当天) */
export function getAdminTaskLogs(params: {
date?: string
status?: 'all' | 'success' | 'fail'
keyword?: string
page?: number
pageSize?: number
}) {
const qs = new URLSearchParams()
if (params.date) qs.set('date', params.date)
qs.set('status', params.status || 'all')
if (params.keyword) qs.set('keyword', params.keyword)
qs.set('page', String(params.page || 1))
qs.set('pageSize', String(params.pageSize || 20))
return api.get<{ data: AdminTaskLogResult }>(`/admin/task-logs?${qs.toString()}`)
}
+90 -4
View File
@@ -22,6 +22,7 @@ export interface LoginParams {
}
export interface LoginResult {
firstLogin?: boolean
token: {
accessToken: string
refreshToken: string
@@ -55,9 +56,25 @@ export interface UserInfo {
mobileCode: string
mobileType: string
signCount: number
/** VIP 到期时间(null=从未开通) */
vipExpireAt: string | null
inviteCode: string
inviteReward: number
invitedCount: number
/** 已看过的新手引导版本(如 v2;空=未看过,登录后需展示引导) */
guideVersion: string
}
/** 我的邀请信息(含奖励规则,随系统配置变化) */
export interface MyInviteInfo {
inviteCode: string
inviteReward: number
invitedCount: number
canRedeem: boolean
/** 邀请人每邀请 1 人获得的奖励积分(系统配置) */
inviterRewardPerInvite: number
/** 被邀请人注册登录后获得的奖励积分(系统配置) */
inviteeRewardPerInvite: number
}
/** 搜索学校 */
@@ -77,6 +94,51 @@ export function login(params: LoginParams) {
return api.post<{ data: LoginResult }>('/auth/login', params)
}
/** MFA 可用验证方式(登录返回 4281 后由后端给出) */
export interface MFAAuthMethod {
/** reAuthType 编码:3=短信 4=企业微信 5=今日校园 11=邮箱 12=钉钉 13=WeLink 15=公众号 */
type: string
/** 展示名(如「今日校园验证码」) */
name: string
/** 是否为页面默认选中项 */
default?: boolean
}
/** MFA 发送验证码参数(用户选定验证方式后调用) */
export interface MFASendCodeParams {
mfaToken: string
reAuthType: string
}
/** MFA 验证码发送结果 */
export interface MFASendCodeResult {
/** 收码目标(手机号等,可能带掩码) */
mobile?: string
/** 可再次发送的倒计时秒数 */
codeTime?: number
}
/** 发送多因子认证验证码(用户选择验证方式后调用)
* 业务码约定(HTTP 200):0=发送成功进入输入阶段;4283=会话过期需重新登录;4284=发送失败可重试 */
export function sendMFACode(params: MFASendCodeParams) {
return api.post<{ code: number; message: string; data?: MFASendCodeResult }>(
'/auth/login/mfa/send',
params
)
}
/** MFA 验证码提交参数 */
export interface MFAVerifyParams {
mfaToken: string
code: string
}
/** 提交多因子认证验证码(登录返回 code 4281 后调用)
* 业务码约定(HTTP 200):0=成功;4281=需MFA;4282=验证码错误可重试;4283=会话过期需重新登录 */
export function verifyMFA(params: MFAVerifyParams) {
return api.post<{ code: number; message: string; data?: LoginResult }>('/auth/login/mfa', params)
}
/** 刷新 token */
export function refreshToken(refreshToken: string) {
return api.post<{ data: { accessToken: string; refreshToken: string } }>(
@@ -104,9 +166,28 @@ export function getDeviceStatus(token: string) {
})
}
/** 新 CDK 兑换(需验证码,由管理后台生成的 CDK,一次性消耗全部次数) */
/** 随机生成设备码并绑定到当前账号(个人中心「随机」按钮,需连续两次风险确认) */
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<{ data: { totalRuns: number } }>('/cdk/redeem', { code, captchaId, captchaCode })
return api.post<{
data: {
type: 'count' | 'vip'
totalRuns?: number
vipDays?: number
vipExpireAt?: string
}
}>('/cdk/redeem', { code, captchaId, captchaCode })
}
/** 当前用户 VIP 信息(是否 VIP、到期时间、系统配置单价) */
export function getVipInfo() {
return api.get<{ data: { isVip: boolean; vipExpireAt: string; vipWeekPrice: string; vipMonthPrice: string; vipPrice: string } }>(
'/cdk/vip-info'
)
}
// ======== 签到任务相关 ========
@@ -258,15 +339,20 @@ export function deleteTaskLog(id: number) {
/** 获取/生成邀请码 */
export function getMyInvite() {
return api.post<{ data: { inviteCode: string; inviteReward: number; invitedCount: number; canRedeem: boolean } }>('/auth/generate-invite')
return api.post<{ data: MyInviteInfo }>('/auth/generate-invite')
}
/** 查询邀请信息 */
export function getMyInviteInfo() {
return api.get<{ data: { inviteCode: string; inviteReward: number; invitedCount: number; canRedeem: boolean } }>('/auth/my-invite')
return api.get<{ data: MyInviteInfo }>('/auth/my-invite')
}
/** 兑换邀请奖励 */
export function redeemInviteReward() {
return api.post<{ data: { quota: number } }>('/auth/redeem-invite')
}
/** 上报已完成当前版本新手引导(数据库记录,跨设备不再重复展示) */
export function completeGuide() {
return api.post('/auth/guide/complete')
}
+267
View File
@@ -0,0 +1,267 @@
import api from './index'
// ======== 类型定义 ========
/** 学校规定的销假位置(详情 reportPlaces) */
export interface LeaveReportPlace {
wid: string
address: string
longitude: string
latitude: string
/** 允许范围半径(米):销假定位需在该位置半径内 */
radius: string
/** 位置设置人 */
counName: string
}
/** 待销假记录(含规定销假位置与销假开关) */
export interface LeaveItem {
id: string
leaveType: string
reason: string
createTime: string
actEndTime: string
status: string
/** 学校规定的销假位置列表;为空时需用户手动输入 */
reportPlaces: LeaveReportPlace[]
/** 该请假类型销假是否需要选择审批人(leaveType.reportApplyFlow=1) */
reportApplyFlow: boolean
/** 该请假类型销假是否允许上传图片(leaveType.enableAttach=1) */
enableAttach: boolean
}
/** 销假可选审批人 */
export interface LeaveApprover {
userWid: string
userId: string
userName: string
}
/** 销假执行参数 */
export interface ExecuteLeaveParams {
leaveId: string
longitude: string
latitude: string
address: string
imageUrl?: string
/** reportApplyFlow=true 时必填 */
approverWid?: string
}
/** 销假日志 */
export interface LeaveLog {
id: number
userId: number
leaveId: string
title: string
schoolName: string
/** report=销假 / apply=请假 */
action: string
isSuccess: boolean
errorMessage: string
createdAt: string
updatedAt: string
}
/** 请假类型(已合并 queryLeaveTypeCfg 的权威开关) */
export interface LeaveType {
code: number
name: string
/** 1=必须离校 */
mustOut: number
/** 1=需要选择审批人 */
needApproval: number
/** 1=允许上传图片附件 */
enableAttach: number
/** 请假原因最少字数 */
reasonWordLimit: number
/** 附件数量上限 */
attachmentNumberLimit: number
/** 1=当前类型不可用 */
disableTime: number
/** 单次最长可请假天数(0=不限) */
limitDay: number
}
/** 请假执行参数 */
export interface ExecuteLeaveApplyParams {
leaveType: number
startTime: string
endTime: string
leaveReason: string
outStatus: boolean
urgencyMobile?: string
address?: string
detailAddress?: string
longitude: string
latitude: string
locationAddr: string
imageUrl?: string
approverWid?: string
approverId?: string
approverName?: string
}
// ======== API ========
/** 获取今日校园待销假列表(实时请求,附带规定销假位置与开关) */
export function getPendingLeaves() {
return api.get<{ data: LeaveItem[] }>('/leave/pending-list')
}
/** 获取销假可选审批人(reportApplyFlow=true 的请假类型使用) */
export function getLeaveApprovers(leaveId: string) {
return api.post<{ data: LeaveApprover[] }>('/leave/approvers', { leaveId })
}
/** 获取请假类型列表(含权威开关:needApproval/enableAttach/reasonWordLimit 等) */
export function getLeaveTypes() {
return api.get<{ data: LeaveType[] }>('/leave/types')
}
/** 获取请假可选审批人(needApproval=1 的类型,需先选类型与起止时间) */
export function getApplyApprovers(params: { leaveType: number; startTime: string; endTime: string }) {
return api.post<{ data: LeaveApprover[] }>('/leave/apply-approvers', params)
}
/**
* 执行请假申请(POST + SSE 流式事件,与销假执行体验一致)。
* 事件流:connecting → submitting → success / task_error
*/
export function executeLeaveApplySSE(params: ExecuteLeaveApplyParams, callbacks: {
onConnecting?: () => void
onSubmitting?: () => void
onSuccess?: (data: any) => void
onError?: (message: string, data?: any) => void
}): AbortController {
return leaveSSE('/leave/apply', params, callbacks)
}
/** 获取销假日志列表 */
export function getLeaveLogs() {
return api.get<{ data: LeaveLog[] }>('/leave/logs')
}
/** 删除销假日志 */
export function deleteLeaveLog(id: number) {
return api.delete(`/leave/logs/${id}`)
}
/**
* 执行销假(POST + SSE 流式事件,与打卡执行体验一致)。
* 事件流:connecting → fetching_detail → submitting → success / task_error
* 返回 AbortController,可用于中断(如关闭弹窗时)。
*/
export function executeLeaveSSE(params: ExecuteLeaveParams, callbacks: {
onConnecting?: () => void
onFetchingDetail?: () => void
onSubmitting?: () => void
onSuccess?: (data: any) => void
onError?: (message: string, data?: any) => void
}): AbortController {
return leaveSSE('/leave/execute', params, callbacks)
}
/**
* 请假/销假共用的 SSE 执行封装。
* 事件流:connecting → (fetching_detail) → submitting → success / task_error
*/
function leaveSSE(path: string, params: object, callbacks: {
onConnecting?: () => void
onFetchingDetail?: () => void
onSubmitting?: () => void
onSuccess?: (data: any) => void
onError?: (message: string, data?: any) => void
}): AbortController {
const controller = new AbortController()
;(async () => {
let finished = false
const finish = (fn?: (...args: any[]) => void, ...args: any[]) => {
if (finished) return
finished = true
fn?.(...args)
}
try {
const baseUrl = import.meta.env.VITE_API_BASE_URL || '/api'
const token = localStorage.getItem('access_token') || ''
const res = await fetch(`${baseUrl}${path}`, {
method: 'POST',
headers: {
'Authorization': 'Bearer ' + token,
'Content-Type': 'application/json',
},
body: JSON.stringify(params),
signal: controller.signal,
})
// HTTP 错误:服务端以 JSON 返回(参数校验/鉴权等,非 SSE 阶段)
if (!res.ok || !res.body) {
let msg = '连接失败,请检查网络'
try {
const j = await res.json()
if (j?.message) msg = j.message
} catch { /* 保留默认提示 */ }
finish(callbacks.onError, msg, { message: msg })
return
}
const reader = res.body.getReader()
const decoder = new TextDecoder()
let buf = ''
for (;;) {
const { value, done } = await reader.read()
if (done) break
buf += decoder.decode(value, { stream: true })
// 按空行切分 SSE 事件块(event: xxx\ndata: xxx\n\n)
let sep: number
while ((sep = buf.indexOf('\n\n')) >= 0) {
const chunk = buf.slice(0, sep)
buf = buf.slice(sep + 2)
let event = 'message'
const dataLines: string[] = []
for (const line of chunk.split('\n')) {
if (line.startsWith('event:')) {
event = line.slice(6).trim()
} else if (line.startsWith('data:')) {
dataLines.push(line.slice(5).trim())
}
}
let data: any = {}
const dataRaw = dataLines.join('\n')
try { data = dataRaw ? JSON.parse(dataRaw) : {} } catch { /* 容忍非 JSON data */ }
switch (event) {
case 'connecting':
callbacks.onConnecting?.()
break
case 'fetching_detail':
callbacks.onFetchingDetail?.()
break
case 'submitting':
callbacks.onSubmitting?.()
break
case 'success':
finish(callbacks.onSuccess, data)
break
case 'task_error':
finish(callbacks.onError, data?.message || '执行失败', data)
break
}
if (finished) break
}
if (finished) break
}
// 流结束但未收到任何结果事件:视为异常中断
finish(callbacks.onError, '连接中断,请重试')
} catch (e: any) {
if (e?.name === 'AbortError') return // 主动取消,不回调
finish(callbacks.onError, e?.message || '连接失败,请检查网络')
}
})()
return controller
}
+77
View File
@@ -1,5 +1,9 @@
@import "tailwindcss";
/* 基于 class 的暗色变体:html.dark 时启用 dark:* 工具类
(切换逻辑见 composables/useTheme.ts,按浏览器系统时间自动切换) */
@custom-variant dark (&:where(.dark, .dark *));
@theme {
--color-background: #f5f5f5;
--color-foreground: #1a1a1a;
@@ -60,3 +64,76 @@ body {
box-shadow: none;
background-color: transparent;
}
/* ============================================================
* 暗色主题(html.dark,由 useTheme.ts 按系统时间自动切换)
*
* Tailwind v4 的颜色工具类均引用 --color-* CSS 变量(bg-white →
* var(--color-white)),此处整体覆盖变量即可让全站 1000+ 处硬编码
* 颜色类自动适配,无需逐个加 dark: 变体。深彩色(blue-500 等
* 品牌/状态色)与遮罩黑保持原值。彩底上的白色元素用类名级规则恢复。
* ============================================================ */
.dark {
/* —— 基础调色板:白与灰阶(越浅的灰在暗色下越亮,保持层次感)—— */
--color-white: #1e1e2a;
--color-gray-50: #232331;
--color-gray-100: #2a2a3a;
--color-gray-200: #36364a;
--color-gray-300: #474763;
--color-gray-400: #8f8fab;
--color-gray-500: #a5a5c3;
--color-gray-600: #bcbcd6;
--color-gray-700: #d4d4e6;
--color-gray-800: #e5e5f0;
--color-gray-900: #ededf6;
--color-gray-950: #f4f4fa;
/* —— 彩色浅底 → 对应暗色调 —— */
--color-blue-50: #1c2a4d;
--color-blue-100: #243660;
--color-red-50: #3d1c28;
--color-red-100: #522534;
--color-rose-50: #3a1c2b;
--color-rose-100: #4d2539;
--color-green-50: #173224;
--color-green-100: #1f4430;
--color-amber-50: #3a2f16;
--color-amber-100: #4d3f1e;
--color-purple-100: #322552;
/* —— 语义 token(ui/ 组件与语义类使用)—— */
--color-background: #15151f;
--color-foreground: #ededf6;
--color-card: #1e1e2a;
--color-card-foreground: #ededf6;
--color-secondary: #262636;
--color-secondary-foreground: #d4d4e6;
--color-muted: #262636;
--color-muted-foreground: #9c9cb8;
--color-accent: #262636;
--color-accent-foreground: #d4d4e6;
--color-border: #33334a;
--color-input: #3a3a55;
--color-ring: #3b82f6;
}
/* —— 彩底上的白色元素恢复纯白(--color-white 已被覆盖为卡片暗色)—— */
.dark .text-white { color: #fff; }
.dark .text-white\/95 { color: rgb(255 255 255 / 0.95); }
.dark .text-white\/80 { color: rgb(255 255 255 / 0.8); }
.dark .text-white\/70 { color: rgb(255 255 255 / 0.7); }
.dark .bg-white\/15 { background-color: rgb(255 255 255 / 0.15); }
.dark .bg-white\/20 { background-color: rgb(255 255 255 / 0.2); }
.dark .bg-white\/25 { background-color: rgb(255 255 255 / 0.25); }
.dark .hover\:bg-white\/25:hover { background-color: rgb(255 255 255 / 0.25); }
.dark .ring-white\/30 { --tw-ring-color: rgb(255 255 255 / 0.3); }
/* —— 全局容器暗色 —— */
.dark body {
background-color: #0d0d14;
}
.dark #app {
background-color: #15151f;
box-shadow: 0 0 20px rgba(0, 0, 0, 0.45);
}
.dark #app.admin-mode {
background-color: transparent;
}
+682
View File
@@ -0,0 +1,682 @@
<script setup lang="ts">
import { ref, computed, watch, onBeforeUnmount } from 'vue'
import { useRoute } from 'vue-router'
import { X, ChevronLeft, ChevronRight, Sparkles, Play } from 'lucide-vue-next'
const props = defineProps<{
show: boolean
/** 邀请人每邀请 1 人奖励积分(系统配置,可能尚未加载为 null) */
inviterRewardPerInvite?: number | null
/** 被邀请人注册后奖励积分(系统配置,可能尚未加载为 null) */
inviteeRewardPerInvite?: number | null
}>()
const emit = defineEmits<{
(e: 'close'): void
/** 请求切换到指定 tab(目标元素在另一页面时) */
(e: 'goto', tab: 'home' | 'profile'): void
}>()
const route = useRoute()
const step = ref(0)
const curTarget = ref<Element | null>(null)
const holeRect = ref<{ top: number; left: number; width: number; height: number; r: number } | null>(null)
const bubblePos = ref<'top' | 'bottom' | 'center'>('center')
const waitingTarget = ref(false)
const bubbleEl = ref<HTMLElement | null>(null)
let locateSeq = 0
let rafId = 0
let pollTimer: number | undefined
let lastFlipAt = 0
let bodyObs: ResizeObserver | null = null
let targetObs: ResizeObserver | null = null
let bubbleObs: ResizeObserver | null = null
const sleep = (ms: number) => new Promise<void>((r) => setTimeout(r, ms))
function fmtReward(v: number | null | undefined): string | null {
if (v === null || v === undefined) return null
const n = Math.round(v * 10) / 10
return Number.isInteger(n) ? String(n) : n.toFixed(1)
}
interface GuideStep {
tab: 'home' | 'profile'
title: string
/** 目标元素候选(按序取第一个存在的) */
targets: string[]
/** 不同目标命中时的说明文案 */
descByKind: (kind: string | null) => string
}
const steps: GuideStep[] = [
{
tab: 'profile',
title: '领取签到次数',
targets: ['[data-guide="profile-invite-redeem"]', '[data-guide="profile-invite-row"]'],
descByKind: () =>
'新用户注册即送奖励积分,邀请好友也能赚积分。\n积分满 1 后,点这里「兑换」按钮即可换成签到次数,1 积分 = 1 次签到,可重复兑换。',
},
{
tab: 'profile',
title: '先绑定设备码',
targets: ['[data-guide="profile-device-code"]'],
descByKind: () =>
'签到任务依赖设备码执行。到这一栏点「重新绑定」用手机扫码授权,或点「随机」直接生成设备码。\n注意:随机设备码与真实手机不一致,执行签到时现有手机会提示「频繁切换设备」无法签到,建议优先扫码授权。',
},
{
tab: 'home',
title: '新建每日签到任务',
targets: ['[data-guide="home-create-task"]'],
descByKind: () => '点击蓝色的「+ 新的签到任务」按钮,选择任务名称(如 晚签到),然后填好对应签到信息并保存,任务就创建好了。',
},
{
tab: 'home',
title: '执行签到',
targets: ['[data-guide="home-task-exec"]', '[data-guide="home-task-empty"]', '[data-guide="home-task-list"]'],
descByKind: (kind) =>
kind === 'home-task-exec'
? '任务创建后显示在下方列表。点任务右侧的「执行」按钮,即可完成今天的签到,之后每天无需再手动操作。'
: kind === 'home-task-empty'
? '你还没有真实任务,下面先展示一张示例任务卡。保存真实任务后它会出现在这里,点卡片右侧「执行」按钮即可完成每日签到。'
: '任务创建后显示在这个区域。点任务右侧的「执行」按钮即可完成每日签到,无需手动操作。',
},
{
tab: 'profile',
title: '次数不够?邀请好友赚积分',
targets: ['[data-guide="profile-invite-copy"]', '[data-guide="profile-invite-generate"]'],
descByKind: (kind) => {
const inviter = fmtReward(props.inviterRewardPerInvite)
const invitee = fmtReward(props.inviteeRewardPerInvite)
if (kind === 'profile-invite-generate') {
return '先点「生成邀请码」,生成后把链接发给同学;TA 通过你的链接注册登录后,你们都能获得奖励积分,多邀多得。'
}
if (inviter !== null && invitee !== null) {
return `把链接发给同学,TA 通过你的链接注册登录后,你可得 ${inviter} 积分、TA 得 ${invitee} 积分,多邀多得,随时可兑换签到次数。`
}
return '把链接发给同学,TA 通过你的链接注册登录后,你和 TA 都能获得一定积分奖励,多邀多得,随时可兑换签到次数。'
},
},
{
tab: 'profile',
title: 'VIP 会员:签到不限次',
targets: ['[data-guide="profile-vip-btn"]'],
descByKind: () =>
'普通签到次数:每执行一次任务扣 1 次,用完需要再兑换。\nVIP 会员:有效期内执行任务不限次数(无限次),不再扣签到次数。\n点「续费 VIP」输入 VIP 兑换码即可开通,到期时间自动顺延。',
},
]
const current = computed(() => steps[step.value])
const isLast = computed(() => step.value === steps.length - 1)
// 当前命中的目标类别(用于生成对应文案)
const resolvedKind = computed(() => {
const el = curTarget.value
if (!el) return null
for (const t of current.value.targets) {
if (el.matches(t)) return t.replace('[data-guide="', '').replace('"]', '')
}
return null
})
const guideDesc = computed(() => {
// 第四步命中示例卡自身的执行按钮(mock 场景)时的专属文案
const el = curTarget.value
if (step.value === 3 && el && resolvedKind.value === 'home-task-exec' && el.closest('.guide-root')) {
return '点这张示例卡片右侧的「执行」按钮,即可开始今天的签到,之后每天无需再手动操作。'
}
return current.value.descByKind(resolvedKind.value)
})
// 无真实任务时(第三步/第四步):展示示例任务卡引导;
// 第四步升级命中示例卡自身的执行按钮(位于 guide-root 内)时仍属 mock 场景
const mockMode = computed(() => {
if (!props.show || (step.value !== 2 && step.value !== 3)) return false
if (resolvedKind.value === 'home-task-empty') return true
const el = curTarget.value
return !!(el && resolvedKind.value === 'home-task-exec' && el.closest('.guide-root'))
})
// 示例卡锚定在空态区(空态元素 rect);不跟随升级后的按钮 rect,避免卡片随高亮框错位
const mockAnchor = ref<{ top: number; left: number; width: number; height: number } | null>(null)
function syncMockAnchor() {
const anchor = document.querySelector('[data-guide="home-task-empty"]')
if (anchor) {
const r = anchor.getBoundingClientRect()
mockAnchor.value = { top: r.top, left: r.left, width: r.width, height: r.height }
}
}
watch(mockMode, (v) => {
if (v) syncMockAnchor()
else mockAnchor.value = null
})
const mockCardStyle = computed(() => {
const r = mockAnchor.value
if (!r) return null
return {
top: `${Math.max(0, Math.round(r.top + (r.height - 76) / 2))}px`,
left: `${r.left}px`,
width: `${r.width}px`,
}
})
let lastLocateAt = 0
/* ================= 定位与高亮 ================= */
// 方位决策(measure 与 ensure 共用同一函数,避免两侧交替乒乓):
// 优先下方(移动端阅读流自然向下展开,也符合"上方放不下就放下方"的预期);
// 下方放不下才放上方;两侧都不能完整容纳时选"溢出更少"的一侧
function choosePos(r: { top: number; bottom: number }, bH: number, vh: number): 'top' | 'bottom' {
const gapTop = r.top - 12 // 目标上方可用高度(气泡底距目标顶 12px 间隙)
const gapBottom = vh - r.bottom - 12 // 目标下方可用高度
const need = Math.max(240, bH > 0 ? bH + 24 : 260)
if (gapBottom >= need) return 'bottom'
if (gapTop >= need) return 'top'
// 两侧都不足:比较溢出量(气泡被视口裁剪的部分),选溢出少的一侧
const topLost = Math.max(0, bH - gapTop)
const bottomLost = Math.max(0, bH - gapBottom)
return bottomLost <= topLost ? 'bottom' : 'top'
}
function measure(el: Element) {
const r = el.getBoundingClientRect()
const cs = getComputedStyle(el)
const br = parseFloat(cs.borderRadius) || 0
const vh = window.innerHeight
// 目标完全不可见(被遮罩同层遮挡或滚动出屏)时按中心兜底
const visibleH = Math.max(0, Math.min(r.bottom, vh) - Math.max(r.top, 0))
if (r.width <= 0 || r.height <= 0 || visibleH <= 0) {
if (holeRect.value || bubblePos.value !== 'center') {
holeRect.value = null
bubblePos.value = 'center'
}
return
}
// 位置/尺寸变化超过阈值才更新(异步数据/图片导致布局高度变化后自动纠正错位)
const next = {
top: r.top,
left: r.left,
width: r.width,
height: r.height,
r: Math.max(0, Math.min(br, r.width / 2, r.height / 2, 18)),
}
const old = holeRect.value
const moved =
!old ||
Math.abs(old.top - next.top) > 1 ||
Math.abs(old.left - next.left) > 1 ||
Math.abs(old.width - next.width) > 1 ||
Math.abs(old.height - next.height) > 1 ||
Math.abs(old.r - next.r) > 1
if (moved) holeRect.value = next
// 用气泡真实高度(内容/文案变化后实时跟随)决定放目标上方还是下方,始终与高亮框保留间隙
// need 采用保守估算:保证整颗气泡完整落在目标一侧,杜绝"放上方但上方放不下溢出到视口外"
const bH = bubbleEl.value?.offsetHeight || 0
const pos = choosePos({ top: r.top, bottom: r.bottom }, bH, vh)
if (pos !== bubblePos.value) bubblePos.value = pos
}
// 气泡渲染后自检:若气泡与真实目标元素相交、或被视口裁掉过多(布局错位/文案高度突变/
// 测量竞态等未及修正的场景),按真实尺寸重新走同一方位决策绕开目标
function ensureBubbleNotOverlapping(el: Element) {
requestAnimationFrame(() => {
const b = bubbleEl.value
if (!props.show || !b) return
const br = b.getBoundingClientRect()
const tr = el.getBoundingClientRect()
const vh = window.innerHeight
const overlapped =
br.right > tr.left + 2 &&
br.left < tr.right - 2 &&
br.bottom > tr.top + 2 &&
br.top < tr.bottom - 2
// top 模式顶部被视口裁切 / bottom 模式底部被裁切 / center 兜底样式压住了目标
const topClipped = bubblePos.value === 'top' && br.top < 4
const bottomClipped = bubblePos.value === 'bottom' && br.bottom > vh - 4
if (!overlapped && !topClipped && !bottomClipped) return
// 400ms 防抖:决策后让 measure/轮询先消化,避免高频翻转
const now = Date.now()
if (now - lastFlipAt < 400) return
lastFlipAt = now
if (bubblePos.value === 'center') {
// 等待/兜底样式与目标相交(目标已渲染):立即重新定位
void locate()
return
}
// 与 measure 相同的决策函数:相交/越界时按真实气泡高度重选方位,不会乒乓
const bH = b.offsetHeight || 240
const desired = choosePos({ top: tr.top, bottom: tr.bottom }, bH, vh)
if (desired !== bubblePos.value) bubblePos.value = desired
})
}
// 等待平滑滚动结束再测量(滚动中途测到的位置是错的,会导致气泡方位误判)
function waitScrollSettled(timeout: number): Promise<void> {
if (timeout <= 0) return Promise.resolve()
return new Promise((resolve) => {
let done = false
let t = 0
const onScroll = () => {
window.clearTimeout(t)
t = window.setTimeout(cleanup, 120)
}
const cleanup = () => {
if (done) return
done = true
window.clearTimeout(t)
window.removeEventListener('scroll', onScroll, true)
resolve()
}
window.addEventListener('scroll', onScroll, true)
t = window.setTimeout(cleanup, 120) // 120ms 无滚动即视为稳定
window.setTimeout(cleanup, timeout) // 最长等待兜底
})
}
async function locate() {
const seq = ++locateSeq
lastLocateAt = Date.now()
const s = current.value
const crossTab = route.name !== s.tab
const prevEl = curTarget.value
waitingTarget.value = crossTab
curTarget.value = null
if (crossTab) {
// 跨 tab:旧目标所在页面即将切换,清空高亮避免残影
holeRect.value = null
emit('goto', s.tab)
}
// 同 tab 切换步骤时保留旧高亮作为过渡锚点(找到新目标后平滑滑移过去),避免闪黑
let el: Element | null = null
for (let k = 0; k < 40; k++) {
if (seq !== locateSeq) return // 期间步骤已切换
if (!el) {
for (const sel of s.targets) {
el = document.querySelector(sel)
if (el) break
}
}
if (el) break
await sleep(250)
}
if (seq !== locateSeq) return
waitingTarget.value = false
if (!el) {
// 找不到任何目标:居中气泡引导
curTarget.value = null
holeRect.value = null
bubblePos.value = 'center'
return
}
curTarget.value = el
const moved = !prevEl || prevEl !== el
// 找到目标后先立即测量一次:即使后续需要滚动/等待,气泡也已按正确方位
// 排在目标上/下方,不会停留在顶部兜底样式遮住目标
measure(el)
try {
if (moved) el.scrollIntoView({ block: 'center', inline: 'center' })
} catch {
// ignore(个别浏览器不支持 options)
}
await waitScrollSettled(moved ? 800 : 0)
if (seq !== locateSeq) return
measure(el)
ensureBubbleNotOverlapping(el)
}
function setStep(i: number) {
step.value = Math.max(0, Math.min(steps.length - 1, i))
locateSeq++
void locate()
}
function prev() {
if (step.value > 0) setStep(step.value - 1)
}
function next() {
if (step.value < steps.length - 1) setStep(step.value + 1)
else emit('close')
}
function skip() {
emit('close')
}
// 目标随页面滚动/窗口/布局(异步数据渲染、图片加载)变化时重新测量校正
function scheduleMeasure() {
if (!props.show) return
cancelAnimationFrame(rafId)
rafId = requestAnimationFrame(() => {
const el = curTarget.value
if (el && el.isConnected && document.contains(el)) {
measure(el)
ensureBubbleNotOverlapping(el)
}
})
}
// 引导展示期间低频轮询校正:兜底 ResizeObserver 覆盖不到的变化
// (内容在 overflow 滚动容器内变化时 body 尺寸不变、目标被懒加载/接口数据渲染等)
function startLayoutPolling() {
stopLayoutPolling()
pollTimer = window.setInterval(() => {
if (!props.show) return
// mock 示例卡锚点持续校正(布局/异步数据导致空态区位置变化时卡片跟随)
if (mockMode.value) syncMockAnchor()
const el = curTarget.value
if (el && el.isConnected && document.contains(el)) {
measure(el)
// 目标升级:异步数据渲染出更高优先级的 v-if 目标(生成→复制 按钮、行→兑换 按钮)时重定位
const better = current.value.targets.find((sel) => document.querySelector(sel))
if (better && !el.matches(better) && Date.now() - lastLocateAt > 1000) {
void locate()
}
} else if (el) {
// 目标已从 DOM 消失(v-if 切换),重新定位到当前存在的最优目标
void locate()
}
}, 300)
}
function stopLayoutPolling() {
if (pollTimer) {
clearInterval(pollTimer)
pollTimer = undefined
}
}
// ResizeObserver:body(页面整体布局变化)/ 高亮目标自身(尺寸变化)/ 气泡(文案高度变化)→ 触发重定位
function observeResize() {
try {
bodyObs?.disconnect()
targetObs?.disconnect()
bubbleObs?.disconnect()
bodyObs = targetObs = bubbleObs = null
if (typeof ResizeObserver === 'undefined') return
const body = document.body
if (body) {
bodyObs = new ResizeObserver(scheduleMeasure)
bodyObs.observe(body)
}
} catch {
// ignore(个别环境不支持)
}
}
function observeTarget(el: Element | null) {
try {
targetObs?.disconnect()
targetObs = null
if (el && typeof ResizeObserver !== 'undefined') {
targetObs = new ResizeObserver(scheduleMeasure)
targetObs.observe(el)
}
} catch {
// ignore
}
}
function observeBubble(el: HTMLElement | null) {
try {
bubbleObs?.disconnect()
bubbleObs = null
if (el && typeof ResizeObserver !== 'undefined') {
bubbleObs = new ResizeObserver(scheduleMeasure)
bubbleObs.observe(el)
}
} catch {
// ignore
}
}
watch(
() => props.show,
(v) => {
if (v) {
step.value = 0
void locate()
startLayoutPolling()
} else {
locateSeq++
stopLayoutPolling()
holeRect.value = null
curTarget.value = null
}
},
)
watch(curTarget, (el) => observeTarget(el))
watch(bubbleEl, (el) => observeBubble(el))
observeResize()
window.addEventListener('scroll', scheduleMeasure, true)
window.addEventListener('resize', scheduleMeasure)
document.addEventListener('visibilitychange', scheduleMeasure)
onBeforeUnmount(() => {
locateSeq++
stopLayoutPolling()
window.removeEventListener('scroll', scheduleMeasure, true)
window.removeEventListener('resize', scheduleMeasure)
document.removeEventListener('visibilitychange', scheduleMeasure)
cancelAnimationFrame(rafId)
try {
bodyObs?.disconnect()
targetObs?.disconnect()
bubbleObs?.disconnect()
} catch {
// ignore
}
})
/* ================= 样式计算 ================= */
const BUBBLE_W = 300
const holeStyle = computed(() => {
const r = holeRect.value
if (!r) return null
return {
top: `${r.top}px`,
left: `${r.left}px`,
width: `${r.width}px`,
height: `${r.height}px`,
borderRadius: `${r.r}px`,
}
})
const bubbleStyle = computed(() => {
const r = holeRect.value
const vw = window.innerWidth
const vh = window.innerHeight
const bw = Math.min(BUBBLE_W, vw - 24)
let style: Record<string, string> = { width: `${bw}px` }
if (bubblePos.value === 'center' || !r) {
style.top = '96px'
style.left = `${Math.max(12, (vw - bw) / 2)}px`
return style
}
// 水平对齐目标中心(防溢出)
const left = Math.max(12, Math.min(vw - bw - 12, r.left + r.width / 2 - bw / 2))
style.left = `${left}px`
if (bubblePos.value === 'top') {
style.bottom = `${Math.max(12, vh - r.top + 12)}px`
} else {
// r 为 {top,left,width,height},底部需用 top+height 合成(r.bottom 不存在会算出 NaNpx 被浏览器丢弃)
style.top = `${Math.min(vh - 12, r.top + r.height + 12)}px`
}
return style
})
</script>
<template>
<Teleport to="body">
<transition name="guide-fade">
<div v-if="show" class="guide-root fixed inset-0 z-[80]" @touchmove.prevent @scroll.prevent>
<!-- 暗色遮罩:高亮框阴影法挖洞(高亮框本身透明露出真实页面元素);mock 时全屏压暗 -->
<div v-if="holeRect && !mockMode" :style="holeStyle" class="guide-hole" />
<div v-else class="absolute inset-0 bg-black/60" />
<!-- 无真实任务时的示例任务卡(替换空态区展示,引导指向"执行"按钮) -->
<div
v-if="mockMode && mockCardStyle"
:style="mockCardStyle"
class="guide-mock-card absolute pointer-events-none"
>
<div class="flex items-center justify-between p-3">
<div class="flex-1 min-w-0 mr-2">
<div class="flex items-center gap-1.5">
<span class="text-sm font-medium text-gray-900 whitespace-nowrap">晚签到</span>
<span class="text-[10px] px-1.5 py-0.5 rounded-full bg-purple-100 text-purple-600 whitespace-nowrap">晚归签到</span>
</div>
<div class="flex items-center gap-2 mt-1">
<span class="text-[10px] px-1.5 py-0.5 rounded-full font-medium bg-amber-100 text-amber-600 whitespace-nowrap">待签到</span>
<span class="text-[10px] text-gray-400 whitespace-nowrap">示例任务</span>
</div>
</div>
<button
type="button"
:data-guide="step === 3 ? 'home-task-exec' : undefined"
class="w-8 h-8 flex items-center justify-center rounded-lg bg-blue-500 text-white shadow-sm flex-shrink-0"
:class="step === 3 ? 'guide-mock-pulse' : ''"
title="执行签到"
>
<Play class="w-4 h-4" />
</button>
</div>
</div>
<!-- tooltip 气泡(切步时保留同一实例,仅位置平滑过渡,不重放入场动画) -->
<div ref="bubbleEl" :style="bubbleStyle" class="guide-bubble absolute z-10">
<!-- 顶部:标识 + 步骤 + 跳过 -->
<div class="flex items-center gap-2">
<span class="flex items-center gap-1 text-[10px] font-semibold text-blue-600 flex-shrink-0">
<Sparkles class="w-3 h-3" />
新手引导
</span>
<div class="flex items-center gap-1 flex-1">
<span
v-for="(s, i) in steps"
:key="i"
class="h-1 rounded-full transition-all duration-300"
:class="i === step ? 'w-5 bg-blue-500' : i < step ? 'w-1.5 bg-blue-300' : 'w-1.5 bg-gray-200'"
/>
</div>
<button
class="w-6 h-6 rounded-full flex items-center justify-center text-gray-300 hover:text-gray-500 hover:bg-gray-100 transition-colors flex-shrink-0"
title="跳过引导"
@click="skip"
>
<X class="w-3.5 h-3.5" />
</button>
</div>
<!-- 标题与说明 -->
<h3 class="text-[15px] font-bold text-gray-900 mt-2.5">{{ current.title }}</h3>
<p v-if="waitingTarget" class="text-[11px] text-blue-500 mt-1.5 font-medium">正在为你切换到目标页面…</p>
<p class="text-xs text-gray-500 leading-relaxed whitespace-pre-line mt-1.5 guide-desc">{{ guideDesc }}</p>
<!-- 操作按钮 -->
<div class="flex items-center gap-2 mt-3.5">
<button
v-if="step > 0"
class="flex items-center justify-center gap-0.5 h-9 px-3 rounded-lg border border-gray-200 text-gray-500 text-xs font-medium hover:bg-gray-50 transition-colors flex-shrink-0"
@click="prev"
>
<ChevronLeft class="w-3.5 h-3.5" />
上一步
</button>
<button
class="flex-1 h-9 rounded-lg bg-blue-500 hover:bg-blue-600 text-white text-[13px] font-medium shadow-sm active:scale-[0.99] transition-all flex items-center justify-center gap-1"
@click="next"
>
{{ isLast ? '完成' : '下一步' }}
<ChevronRight v-if="!isLast" class="w-4 h-4" />
</button>
</div>
</div>
</div>
</transition>
</Teleport>
</template>
<style scoped>
.guide-root {
background: transparent;
}
/* 高亮框:白色 ring + 全屏阴影挖洞(box-shadow 第一层 ring、第二层撑满全屏压暗) */
.guide-hole {
position: absolute;
box-shadow:
0 0 0 2px rgba(255, 255, 255, 0.95),
0 0 0 9999px rgba(0, 0, 0, 0.62);
transition:
top 0.25s ease,
left 0.25s ease,
width 0.25s ease,
height 0.25s ease;
}
.guide-mock-card {
background: #fff;
border-radius: 12px;
box-shadow:
0 0 0 2px rgba(255, 255, 255, 0.95),
0 10px 28px rgba(0, 0, 0, 0.35);
overflow: hidden;
}
/* 第四步:示例卡执行按钮脉冲强调(引导点击开始打卡) */
.guide-mock-pulse {
animation: guide-pulse 1.2s ease-in-out infinite;
}
@keyframes guide-pulse {
0%,
100% {
box-shadow: 0 0 0 0 rgba(59, 130, 246, 0.55);
transform: scale(1);
}
50% {
box-shadow: 0 0 0 8px rgba(59, 130, 246, 0);
transform: scale(1.08);
}
}
.guide-bubble {
background: #fff;
border-radius: 16px;
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.28);
padding: 14px 16px 12px;
/* 布局/文案变化实时重定位时平滑移动(动画只播放入场) */
transition:
top 0.25s ease,
left 0.25s ease,
bottom 0.25s ease;
animation: guide-pop-in 0.28s cubic-bezier(0.32, 0.72, 0, 1);
}
.guide-desc {
max-height: 132px;
overflow-y: auto;
}
.guide-fade-enter-active,
.guide-fade-leave-active {
transition: opacity 0.2s ease;
}
.guide-fade-enter-from,
.guide-fade-leave-to {
opacity: 0;
}
@keyframes guide-pop-in {
from {
transform: translateY(8px) scale(0.97);
opacity: 0;
}
to {
transform: translateY(0) scale(1);
opacity: 1;
}
}
</style>
+1
View File
@@ -30,6 +30,7 @@ export function useMessage() {
success: (msg: string) => addToast('success', msg),
error: (msg: string) => addToast('error', msg),
info: (msg: string) => addToast('info', msg),
warning: (msg: string) => addToast('info', msg),
removeToast,
}
}
+33
View File
@@ -0,0 +1,33 @@
// 亮暗主题:按浏览器系统时间自动切换(18:00–次日 06:00 为暗色,其余亮色)。
// 首屏防闪烁由 index.html 内联脚本完成(同一规则,渲染前预设 .dark);
// 本模块在应用启动后接管:立即校正一次,并按分钟轮询,跨过时间阈值时自动切换。
// 切换载体为 <html> 上的 .dark class,配合 globals.css 中的暗色变量板生效。
const DARK_START_HOUR = 18
const DARK_END_HOUR = 6
const THEME_COLOR_LIGHT = '#f5f5f5'
const THEME_COLOR_DARK = '#15151f'
export function isDarkTime(date: Date = new Date()): boolean {
const h = date.getHours()
return h >= DARK_START_HOUR || h < DARK_END_HOUR
}
/** 应用当前时段对应主题,返回是否处于暗色 */
export function applyTheme(): boolean {
const dark = isDarkTime()
document.documentElement.classList.toggle('dark', dark)
document
.querySelector('meta[name="theme-color"]')
?.setAttribute('content', dark ? THEME_COLOR_DARK : THEME_COLOR_LIGHT)
return dark
}
let timer: ReturnType<typeof setInterval> | null = null
/** 应用入口调用:立即应用主题并启动分钟级自动切换 */
export function initTheme(): void {
applyTheme()
if (timer) return
timer = setInterval(applyTheme, 60_000)
}
+124 -19
View File
@@ -1,9 +1,10 @@
<script setup lang="ts">
import { ref, computed, watch, onMounted, onUnmounted } from 'vue'
import { ref, computed, watch, onMounted, onUnmounted, provide } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { Home, User, Bell, X, CheckCircle, ChevronDown, MessageSquare } from 'lucide-vue-next'
import { CalendarCheck2, CalendarX2, User, Bell, X, CheckCircle, ChevronDown, MessageSquare, Sparkles } from 'lucide-vue-next'
import { useMessage } from '@/composables/useMessage'
import { Button } from '@/components/ui/button'
import GuideOverlay from '@/components/GuideOverlay.vue'
import * as authApi from '@/api/auth'
import * as announceApi from '@/api/announcement'
import * as ticketApi from '@/api/ticket'
@@ -18,15 +19,17 @@ const userInfo = ref<UserInfo | null>(null)
const avatarFailed = ref(false)
const tabs = [
{ name: 'home', label: '首页', icon: Home },
{ name: 'home', label: '打卡', icon: CalendarCheck2 },
{ name: 'leave', label: '请/销假', icon: CalendarX2 },
{ name: 'profile', label: '个人中心', icon: User },
]
const activeTab = ref(route.name as string || 'home')
// 路由变化时同步底部 tab 高亮
// 路由变化时同步底部 tab 高亮;回到首页时尝试加载公告强制弹窗(若引导已完成)
watch(() => route.name, (name) => {
if (name) activeTab.value = name as string
if (name === 'home') maybeLoadAnnouncementPopups()
})
// 公告相关
@@ -52,6 +55,75 @@ const currentForcePopup = computed(() => forcePopupQueue.value[0] || null)
const showDetail = ref(false)
const detailItem = ref<AnnouncementItem | null>(null)
// 新手引导:是否展示由后端数据库记录判定(用户 guideVersion 未达当前版本即展示,
// 完成/关闭后上报写库,跨设备不再重复打扰)。测试强播入口 ?guide=1 保留:
// main.ts 将其写入 sessionStorage(一次性,本组件读取即消费),仅当次加载生效。
const GUIDE_FORCE_KEY = 'xz_guide_force'
/** 与后端 handler.GuideCompleteVersion 一致 */
const GUIDE_VERSION = 'v2'
const showGuide = ref(false)
// 本次展示是否来自测试强播(?guide=1,仅当次加载):强播关闭时不写库,可反复重播
const guideForced = ref(false)
// 邀请奖励规则(供引导第 4 步动态展示;接口失败保持 null,气泡用泛指文案)
const inviterRewardPerInvite = ref<number | null>(null)
const inviteeRewardPerInvite = ref<number | null>(null)
async function loadInviteRewardRules() {
try {
const res = await authApi.getMyInviteInfo()
inviterRewardPerInvite.value = res.data.data?.inviterRewardPerInvite ?? null
inviteeRewardPerInvite.value = res.data.data?.inviteeRewardPerInvite ?? null
} catch {
// 静默失败,引导文案退化为"可获得一定积分"
}
}
// 公告强制弹窗是否已加载(须在引导完成后才查询,避免弹窗与引导冲突)
const announcementLoaded = ref(false)
function handleGuideAction(target: 'home' | 'profile') {
switchTab(target)
}
function closeGuide() {
showGuide.value = false
// 引导结束:补上公告未读数与强制弹窗队列
loadUnreadCount()
maybeLoadAnnouncementPopups()
// 上报完成状态到数据库(看过当前版本后任何设备都不再弹);
// 测试强播来源不写库,避免污染该账号的"首次引导"状态
if (!guideForced.value) {
authApi.completeGuide().catch(() => {
// 上报失败不打扰用户:下次登录该账号会再次展示引导
})
}
}
// 个人中心「再次查看引导」入口:重播新手引导。GuideOverlay 对 show 重新置真会自动
// 回到第一步;关闭时走 closeGuide 正常上报完成,顺带把未完成账号的引导版本补齐,
// 语义与首次观看一致,故不沿用 guideForced 的"关闭不写库"逻辑。
function restartGuide() {
showGuide.value = true
loadInviteRewardRules()
}
provide('restartGuide', restartGuide)
/** 数据库判定:guideVersion 未达当前引导版本的用户需展示引导 */
function needGuide(user: UserInfo): boolean {
return (user.guideVersion || '') !== GUIDE_VERSION
}
async function maybeLoadAnnouncementPopups() {
if (announcementLoaded.value || showGuide.value || route.name !== 'home') return
announcementLoaded.value = true
try {
const res = await announceApi.getMyAnnouncementInfo()
forcePopupQueue.value = res.data.data.popupQueue || []
} catch {
// ignore
}
}
function switchTab(name: string) {
activeTab.value = name
router.push({ name })
@@ -140,31 +212,36 @@ async function handleForcePopupDismiss() {
}
onMounted(async () => {
// 强播标记一次性消费:仅本次整页加载(URL 带 ?guide=1)内优先展示且关闭不写库;
// 读取后立即清除,避免会话残留把后续普通访问(刷新/重开)误判为强播——
// 否则会出现"看完引导后刷新又弹、且永不上报完成"的假象
guideForced.value = sessionStorage.getItem(GUIDE_FORCE_KEY) === '1'
sessionStorage.removeItem(GUIDE_FORCE_KEY)
try {
const res = await authApi.getUserInfo()
const data = res.data.data
userInfo.value = data
if (!data.mobileCode) {
router.replace('/bind-device')
return
// 未绑定设备码不再强制跳转绑定页(新建任务时会在入口处弹窗引导)
// 新手引导:数据库判定该用户未看过当前版本引导则展示(跨设备一致);
// 本次若来自 ?guide=1 强播则优先展示(guideForced 已消费,下次加载不再生效)
if (guideForced.value || needGuide(data)) {
showGuide.value = true
// 引导气泡中展示邀请奖励数值(第 4 步)
loadInviteRewardRules()
}
} catch {
message.error('获取用户信息失败')
}
// 加载未读公告数 + 待弹窗队列(仅首页触发弹窗)
await loadUnreadCount()
if (route.name === 'home') {
try {
const res = await announceApi.getMyAnnouncementInfo()
forcePopupQueue.value = res.data.data.popupQueue || []
} catch {
// ignore
}
// 引导展示中不查询公告信息(未读数/强制弹窗),避免公告弹窗与引导冲突;
// 引导关闭后由 closeGuide 统一补齐
if (!showGuide.value) {
loadUnreadCount()
maybeLoadAnnouncementPopups()
}
// 定时轮询未读数
// 定时轮询未读数(仅更新铃铛角标,不产生弹窗)
pollTimer = setInterval(loadUnreadCount, 30000)
})
@@ -201,8 +278,8 @@ onUnmounted(() => {
</div>
</div>
<!-- 中间内容区(可滚动) -->
<div class="flex-1 overflow-y-auto bg-[#f5f5f5]">
<!-- 中间内容区(可滚动;bg-background 语义类,亮暗主题自动切换) -->
<div class="flex-1 overflow-y-auto bg-background">
<router-view />
</div>
@@ -407,6 +484,25 @@ onUnmounted(() => {
</div>
</transition>
</Teleport>
<!-- ======== 新手引导入口(全局:所有布局内页面显示;引导展示期间隐藏) ======== -->
<button
v-if="!showGuide"
class="guide-entry fixed right-3 bottom-20 z-40 flex items-center gap-1.5 rounded-full bg-blue-500/95 pl-3 pr-3.5 py-2 text-white text-xs font-medium shadow-lg shadow-blue-500/30 hover:bg-blue-600 active:scale-95 transition-all"
@click="restartGuide"
>
<Sparkles class="w-3.5 h-3.5 flex-shrink-0" />
忘记操作?再次查看引导
</button>
<!-- ======== 首次登录新手引导(最高层:遮罩高亮真实页面元素 + 气泡推进) ======== -->
<GuideOverlay
:show="showGuide"
:inviter-reward-per-invite="inviterRewardPerInvite"
:invitee-reward-per-invite="inviteeRewardPerInvite"
@goto="handleGuideAction"
@close="closeGuide"
/>
</div>
</template>
@@ -428,4 +524,13 @@ onUnmounted(() => {
.slide-up-leave-to {
transition: translateY(100%);
}
/* 引导入口气泡:上下浮动跳动,吸引注意但不刺眼 */
@keyframes guide-bob {
0%, 100% { transform: translateY(0); }
50% { transform: translateY(-6px); }
}
.guide-entry {
animation: guide-bob 1.5s ease-in-out infinite;
}
</style>
+15
View File
@@ -2,6 +2,21 @@ import { createApp } from 'vue'
import App from './App.vue'
import router from './router'
import './assets/styles/globals.css'
import { initTheme } from './composables/useTheme'
// 亮暗主题自动切换(18:00–次日 06:00 暗色);首屏已由 index.html 内联脚本预设,
// 这里立即校正一次并启动分钟级轮询,跨过时间阈值时无刷新自动切换
initTheme()
// 测试入口:URL 带 ?guide=1 强制重播新手引导(不受本地完成标记影响)。
// 立即把标记落入 sessionStorage 并移除 URL 参数(登录跳转会丢失 query,故不能等到 MobileLayout)
const guideParams = new URLSearchParams(window.location.search)
if (guideParams.get('guide') === '1') {
sessionStorage.setItem('xz_guide_force', '1')
const url = new URL(window.location.href)
url.searchParams.delete('guide')
window.history.replaceState(null, '', url)
}
const app = createApp(App)
app.use(router)
+49 -4
View File
@@ -2,8 +2,10 @@ import { createRouter, createWebHistory } from 'vue-router'
import LoginView from '@/views/login/LoginView.vue'
import HomeView from '@/views/home/HomeView.vue'
import ProfileView from '@/views/profile/ProfileView.vue'
import LeaveView from '@/views/leave/LeaveView.vue'
import BindDeviceView from '@/views/bind/BindDeviceView.vue'
import MobileLayout from '@/layouts/MobileLayout.vue'
import BrowserGuideView from '@/views/guide/BrowserGuideView.vue'
import TicketList from '@/views/ticket/TicketList.vue'
import TicketDetail from '@/views/ticket/TicketDetail.vue'
@@ -65,31 +67,37 @@ const router = createRouter({
{
path: 'users',
name: 'admin-users',
meta: { adminOnly: true },
component: AdminUsers,
},
{
path: 'settings',
name: 'admin-settings',
meta: { adminOnly: true },
component: AdminSettings,
},
{
path: 'announcements',
name: 'admin-announcements',
meta: { adminOnly: true },
component: AdminAnnouncements,
},
{
path: 'tickets',
name: 'admin-tickets',
meta: { adminOnly: true },
component: AdminTicketList,
},
{
path: 'tickets/:id',
name: 'admin-ticket-detail',
meta: { adminOnly: true },
component: AdminTicketDetail,
},
{
path: 'pending-schools',
name: 'admin-pending-schools',
meta: { adminOnly: true },
component: AdminPendingSchools,
},
],
@@ -99,6 +107,12 @@ const router = createRouter({
path: '/',
redirect: '/login',
},
// 微信/QQ 内置浏览器引导页:提示复制链接用系统浏览器打开
{
path: '/open-in-browser',
name: 'open-in-browser',
component: BrowserGuideView,
},
{
path: '/login',
name: 'login',
@@ -132,6 +146,11 @@ const router = createRouter({
name: 'home',
component: HomeView,
},
{
path: 'leave',
name: 'leave',
component: LeaveView,
},
{
path: 'profile',
name: 'profile',
@@ -142,11 +161,31 @@ const router = createRouter({
],
})
// 微信/QQ 系内置浏览器检测:
// - MicroMessenger: 微信内置浏览器
// - QQ/: 手机 QQ 内置浏览器
// 注意:不拦纯 QQ 浏览器 App(MQQBrowser),因为安卓微信右上角
// "在浏览器打开"唤起的就是它,拦截会造成引导页死循环
function isBlockedBrowser(): boolean {
const ua = navigator.userAgent
return /MicroMessenger|QQ\//i.test(ua)
}
router.beforeEach(async (to, _from) => {
// 调试:记录当前路径
try {
await fetch(`/api/admin/setup/debug?path=${encodeURIComponent(to.path)}`)
} catch {}
// ======== 微信/QQ 浏览器拦截(优先于一切认证逻辑)========
// 引导页自身放行;但若当前已是正常浏览器(如从微信右上角
// "在浏览器打开"跳转过来),自动回到 redirect 指向的原页面
if (to.name === 'open-in-browser') {
if (!isBlockedBrowser()) {
const r = to.query.redirect
const target = typeof r === 'string' && r.startsWith('/') && r !== to.fullPath ? r : '/'
return target
}
return true
}
if (isBlockedBrowser()) {
return { name: 'open-in-browser', query: { redirect: to.fullPath } }
}
const token = localStorage.getItem('access_token')
const adminToken = localStorage.getItem('admin_token')
@@ -208,6 +247,12 @@ router.beforeEach(async (to, _from) => {
localStorage.removeItem('admin_info')
return '/admin/login'
}
// 代理(agent)只能访问仪表盘 / 代理管理 / CDK管理,其余页面重定向回仪表盘
const meData = await meRes.json()
const role = meData?.data?.role
if (role && role !== 'admin' && to.meta.adminOnly) {
return '/admin/dashboard'
}
} catch {
// 网络错误,放行让组件自己处理
}
+42
View File
@@ -0,0 +1,42 @@
/**
* 复制文本到剪贴板(兼容 HTTP / HTTPS / 移动端 WebView)
*
* 执行顺序刻意为先同步 execCommand:
* document.execCommand('copy') 必须在「用户手势调用栈」内同步执行才可靠,
* 若先 await Clipboard API 被拒(移动端 WebView 常见 NotAllowedError),
* 手势上下文已丢失,后续 execCommand 会静默失败(返回 false 但剪贴板无内容)。
*
* 返回是否复制成功
*/
export async function copyToClipboard(text: string): Promise<boolean> {
// 1) 同步路径:隐藏 textarea + execCommand(用户手势内,兼容性最好)
const ta = document.createElement('textarea')
ta.value = text
// 必须可见且在屏幕内(不可 display:none / 移出视口),否则 iOS Safari 复制失败;
// font-size ≥ 16px 避免 iOS focus 时触发页面缩放
ta.style.cssText = 'position:fixed;top:0;left:0;width:1px;height:1px;opacity:0;font-size:16px;border:0;padding:0;margin:0;'
ta.setAttribute('readonly', '')
document.body.appendChild(ta)
ta.focus()
ta.select()
ta.setSelectionRange(0, text.length)
let ok = false
try {
ok = document.execCommand('copy')
} catch {
ok = false
}
document.body.removeChild(ta)
if (ok) return true
// 2) 异步回退:Clipboard API(仅 HTTPS/localhost;注意此时手势可能已丢失,仅作兜底)
if (navigator.clipboard && window.isSecureContext) {
try {
await navigator.clipboard.writeText(text)
return true
} catch {
// 忽略,走失败返回
}
}
return false
}
+467 -86
View File
@@ -1,27 +1,66 @@
<script setup lang="ts">
import { ref, onMounted, computed } from 'vue'
import { QrCode, Plus, Search, X, LoaderCircle, Copy, CheckCheck } from 'lucide-vue-next'
import {
QrCode,
Plus,
Search,
X,
LoaderCircle,
Copy,
CheckCheck,
Ban,
RotateCcw,
ChevronLeft,
ChevronRight,
CheckCircle2,
} from 'lucide-vue-next'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { useMessage } from '@/composables/useMessage'
import { copyToClipboard } from '@/utils/clipboard'
import * as adminApi from '@/api/admin'
const message = useMessage()
const cdks = ref<adminApi.CDKCode[]>([])
const loading = ref(false)
const searchQuery = ref('')
const searchKeyword = ref('')
const keywordInput = ref('')
const statusFilter = ref('')
const copiedIndex = ref<number | null>(null)
const copiedResultIdx = ref<number | null>(null)
const copiedAll = ref(false)
// 分页
const page = ref(1)
const pageSize = ref(20)
const total = ref(0)
const totalPages = computed(() => Math.ceil(total.value / pageSize.value) || 1)
const pageSizeOptions = [20, 50, 100]
// 汇总(后端按可见范围统计)
const summary = ref<adminApi.CDKSummary>({ total: 0, unused: 0, used: 0, disabled: 0 })
const adminInfo = ref<adminApi.AgentInfo | null>(null)
const isAdmin = computed(() => adminInfo.value?.role === 'admin')
// 生成CDK弹窗
// 生成CDK弹窗(form 配置 / result 展示结果)
const showGenerateModal = ref(false)
const modalView = ref<'form' | 'result'>('form')
const generateCount = ref(1)
const generateMaxRuns = ref(1)
const generateType = ref<'count' | 'vip'>('count')
const generateVipDays = ref(30)
const generateLoading = ref(false)
const resultCdks = ref<adminApi.CDKCode[]>([])
const resultCost = ref(0)
// 禁用/恢复确认弹窗
const showStatusModal = ref(false)
const statusAction = ref<'disable' | 'enable'>('disable')
const statusTarget = ref<adminApi.CDKCode | null>(null)
const statusLoading = ref(false)
onMounted(async () => {
try {
@@ -34,44 +73,77 @@ onMounted(async () => {
async function loadCDKs() {
loading.value = true
try {
if (isAdmin.value) {
const res = await adminApi.getAllCDKs()
cdks.value = res.data?.data || []
} else {
const res = await adminApi.getMyCDKs()
cdks.value = res.data?.data || []
const params: adminApi.CDKListParams = {
page: page.value,
pageSize: pageSize.value,
keyword: searchKeyword.value,
status: statusFilter.value,
}
const res = isAdmin.value ? await adminApi.getAllCDKs(params) : await adminApi.getMyCDKs(params)
const data = res.data?.data
cdks.value = data?.items || []
total.value = data?.total || 0
if (data?.summary) summary.value = data.summary
} catch {
message.error('加载CDK列表失败')
cdks.value = []
} finally {
loading.value = false
}
}
const filteredCDKs = computed(() => {
if (!searchQuery.value.trim()) return cdks.value
const q = searchQuery.value.toLowerCase()
return cdks.value.filter((c) => c.code.toLowerCase().includes(q))
})
function handleSearch() {
page.value = 1
searchKeyword.value = keywordInput.value.trim()
loadCDKs()
}
function handleStatusChange() {
page.value = 1
loadCDKs()
}
function handlePage(p: number) {
if (p < 1 || p > totalPages.value) return
page.value = p
loadCDKs()
}
function handlePageSize(size: number) {
if (size === pageSize.value) return
pageSize.value = size
page.value = 1
loadCDKs()
}
async function handleGenerate() {
if (generateCount.value <= 0 || generateCount.value > 100) {
message.info('数量范围为 1-100')
return
}
if (generateMaxRuns.value <= 0 || generateMaxRuns.value > 999) {
if (generateType.value === 'vip') {
if (generateVipDays.value !== 7 && generateVipDays.value !== 30) {
message.info('VIP CDK 仅支持周卡(7 天)或月卡(30 天)')
return
}
} else if (generateMaxRuns.value <= 0 || generateMaxRuns.value > 999) {
message.info('运行次数范围为 1-999')
return
}
generateLoading.value = true
try {
const res = await adminApi.generateCDK(generateCount.value, generateMaxRuns.value)
const res = await adminApi.generateCDK(
generateCount.value,
generateMaxRuns.value,
generateType.value,
generateVipDays.value
)
const data = res.data?.data
message.success(`成功生成 ${data?.count || 0} 个CDK(每张 ${data?.maxRuns || 1} 次)`)
showGenerateModal.value = false
generateCount.value = 1
generateMaxRuns.value = 1
await loadCDKs()
resultCdks.value = data?.cdkCodes || []
resultCost.value = data?.costTotal || 0
copiedAll.value = false
// 切到结果视图,展示全部CDK供复制
modalView.value = 'result'
} catch (e: any) {
message.error(e?.response?.data?.message || e?.message || '生成失败')
} finally {
@@ -79,30 +151,99 @@ async function handleGenerate() {
}
}
function openGenerateModal() {
modalView.value = 'form'
generateCount.value = 1
generateMaxRuns.value = 1
generateType.value = 'count'
generateVipDays.value = 30
showGenerateModal.value = true
}
function closeGenerateModal() {
showGenerateModal.value = false
// 返回首页重新加载最新数据
page.value = 1
searchKeyword.value = ''
keywordInput.value = ''
statusFilter.value = ''
loadCDKs()
}
async function copyCode(code: string, index: number) {
try {
await navigator.clipboard.writeText(code)
copiedIndex.value = index
setTimeout(() => { copiedIndex.value = null }, 2000)
} catch {
// fallback
const ta = document.createElement('textarea')
ta.value = code
document.body.appendChild(ta)
ta.select()
document.execCommand('copy')
document.body.removeChild(ta)
if (await copyToClipboard(code)) {
copiedIndex.value = index
setTimeout(() => { copiedIndex.value = null }, 2000)
message.success('已复制')
} else {
message.error('复制失败,请长按 CDK 码手动复制')
}
}
/** 统计 */
const stats = computed(() => ({
total: cdks.value.length,
used: cdks.value.filter((c) => c.status === 'used').length,
unused: cdks.value.filter((c) => c.status === 'unused').length,
}))
async function copyResultCode(code: string, index: number) {
if (await copyToClipboard(code)) {
resultCopiedIdx.value = index
setTimeout(() => { resultCopiedIdx.value = null }, 2000)
message.success('已复制')
} else {
message.error('复制失败,请长按 CDK 码手动复制')
}
}
async function copyAllCodes() {
if (resultCdks.value.length === 0) return
const all = resultCdks.value.map((c) => c.code).join('\n')
if (await copyToClipboard(all)) {
copiedAll.value = true
message.success(`已复制全部 ${resultCdks.value.length} 个CDK`)
setTimeout(() => { copiedAll.value = false }, 2000)
} else {
message.error('复制失败,请长按 CDK 码手动复制')
}
}
function askDisable(cdk: adminApi.CDKCode) {
statusAction.value = 'disable'
statusTarget.value = cdk
showStatusModal.value = true
}
function askEnable(cdk: adminApi.CDKCode) {
statusAction.value = 'enable'
statusTarget.value = cdk
showStatusModal.value = true
}
async function confirmStatusChange() {
const target = statusTarget.value
if (!target) return
statusLoading.value = true
try {
await adminApi.updateCDKStatus(target.id, statusAction.value)
message.success(statusAction.value === 'disable' ? '已禁用该CDK' : '已恢复该CDK')
showStatusModal.value = false
statusTarget.value = null
await loadCDKs()
} catch (e: any) {
message.error(e?.response?.data?.message || '操作失败')
} finally {
statusLoading.value = false
}
}
const statusText = computed(() => (statusAction.value === 'disable' ? '禁用CDK' : '恢复CDK'))
function statusLabel(s: string) {
if (s === 'used') return '已使用'
if (s === 'disabled') return '已禁用'
return '未使用'
}
function statusClass(s: string) {
if (s === 'used') return 'bg-gray-100 text-gray-600'
if (s === 'disabled') return 'bg-red-50 text-red-600'
return 'bg-green-100 text-green-700'
}
</script>
<template>
@@ -110,32 +251,57 @@ const stats = computed(() => ({
<!-- 顶部 -->
<div class="flex items-center justify-between mb-6">
<h1 class="text-xl font-bold text-gray-900">CDK管理</h1>
<Button variant="default" size="sm" @click="showGenerateModal = true">
<Button variant="default" size="sm" @click="openGenerateModal">
<Plus class="w-4 h-4 mr-1" />
生成CDK
</Button>
</div>
<!-- 统计 -->
<div class="flex gap-4 mb-4">
<div class="flex gap-4 mb-4 flex-wrap">
<div class="bg-white rounded-lg border border-gray-200 px-4 py-2.5 flex items-center gap-2">
<span class="text-xs text-gray-500">总计</span>
<span class="text-lg font-bold text-gray-900">{{ stats.total }}</span>
<span class="text-lg font-bold text-gray-900">{{ summary.total }}</span>
</div>
<div class="bg-white rounded-lg border border-green-200 px-4 py-2.5 flex items-center gap-2">
<span class="text-xs text-green-600">未使用</span>
<span class="text-lg font-bold text-green-700">{{ stats.unused }}</span>
<span class="text-lg font-bold text-green-700">{{ summary.unused }}</span>
</div>
<div class="bg-white rounded-lg border border-gray-200 px-4 py-2.5 flex items-center gap-2">
<span class="text-xs text-gray-500">已使用</span>
<span class="text-lg font-bold text-gray-700">{{ stats.used }}</span>
<span class="text-lg font-bold text-gray-700">{{ summary.used }}</span>
</div>
<div class="bg-white rounded-lg border border-red-200 px-4 py-2.5 flex items-center gap-2">
<span class="text-xs text-red-500">已禁用</span>
<span class="text-lg font-bold text-red-600">{{ summary.disabled }}</span>
</div>
</div>
<!-- 搜索 -->
<div class="relative mb-4">
<Search class="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" />
<Input v-model="searchQuery" placeholder="搜索CDK码..." class="pl-9 h-10 text-sm max-w-xs" />
<!-- 筛选:搜索 + 状态 -->
<div class="flex items-center gap-3 mb-4">
<div class="relative flex-1 max-w-xs">
<Search class="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" />
<Input
v-model="keywordInput"
placeholder="搜索CDK码..."
class="pl-9 h-10 text-sm"
@keyup.enter="handleSearch"
/>
</div>
<Button variant="outline" class="h-10" @click="handleSearch">
<Search class="w-4 h-4 mr-1" />
搜索
</Button>
<select
v-model="statusFilter"
class="h-10 px-3 rounded-lg border border-gray-200 bg-white text-sm text-gray-600 focus:outline-none focus:ring-2 focus:ring-blue-500/20 focus:border-blue-400"
@change="handleStatusChange"
>
<option value="">全部状态</option>
<option value="unused">未使用</option>
<option value="used">已使用</option>
<option value="disabled">已禁用</option>
</select>
</div>
<!-- 列表 -->
@@ -144,7 +310,7 @@ const stats = computed(() => ({
<LoaderCircle class="w-6 h-6 animate-spin text-blue-500" />
</div>
<div v-else-if="filteredCDKs.length === 0" class="text-center py-16 text-sm text-gray-400">
<div v-else-if="cdks.length === 0" class="text-center py-16 text-sm text-gray-400">
<QrCode class="w-12 h-12 mx-auto mb-3 text-gray-200" />
<p>暂无CDK数据</p>
<p class="text-xs mt-1">点击右上角"生成CDK"开始创建</p>
@@ -154,76 +320,291 @@ const stats = computed(() => ({
<thead>
<tr class="bg-gray-50 text-left text-xs text-gray-500">
<th class="px-4 py-3 font-medium">CDK码</th>
<th v-if="isAdmin" class="px-4 py-3 font-medium">生成代理</th>
<th class="px-4 py-3 font-medium">售价</th>
<th class="px-4 py-3 font-medium">次数</th>
<th class="px-4 py-3 font-medium">类型</th>
<th class="px-4 py-3 font-medium">额度</th>
<th class="px-4 py-3 font-medium">状态</th>
<th class="px-4 py-3 font-medium">创建时间</th>
<th class="px-4 py-3 font-medium">操作</th>
</tr>
</thead>
<tbody>
<tr v-for="(cdk, idx) in filteredCDKs" :key="cdk.id" class="border-t border-gray-50 hover:bg-gray-50/50 transition-colors">
<tr
v-for="(cdk, idx) in cdks"
:key="cdk.id"
class="border-t border-gray-50 hover:bg-gray-50/50 transition-colors"
:class="cdk.status === 'disabled' ? 'opacity-60' : ''"
>
<td class="px-4 py-3 whitespace-nowrap">
<code class="font-mono text-xs bg-gray-100 px-2 py-1 rounded">{{ cdk.code }}</code>
</td>
<td v-if="isAdmin" class="px-4 py-3 text-xs text-gray-600 whitespace-nowrap">
{{ (cdk as any).agent?.username || cdk.agentId }}
</td>
<td class="px-4 py-3 font-mono whitespace-nowrap">{{ cdk.price.toFixed(2) }} 元</td>
<td class="px-4 py-3 whitespace-nowrap">
<span
v-if="cdk.type === 'vip'"
class="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-amber-100 text-amber-700 whitespace-nowrap"
>⭐ VIP{{ cdk.vipDays === 7 ? '周卡' : '月卡' }}</span>
<span
v-else
class="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-blue-100 text-blue-700 whitespace-nowrap"
>次数卡</span>
</td>
<td class="px-4 py-3 text-xs text-gray-600 whitespace-nowrap">
{{ cdk.maxRuns ?? 1 }}
{{ cdk.type === 'vip' ? `${cdk.vipDays} 天` : `${cdk.maxRuns ?? 1} 次` }}
</td>
<td class="px-4 py-3 whitespace-nowrap">
<span
class="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium whitespace-nowrap"
:class="cdk.status === 'used' ? 'bg-gray-100 text-gray-600' : 'bg-green-100 text-green-700'"
:class="statusClass(cdk.status)"
>
{{ cdk.status === 'used' ? '已使用' : '未使用' }}
{{ statusLabel(cdk.status) }}
</span>
</td>
<td class="px-4 py-3 text-gray-500 text-xs whitespace-nowrap">{{ new Date(cdk.createdAt).toLocaleString() }}</td>
<td class="px-4 py-3 whitespace-nowrap">
<button
class="inline-flex items-center gap-1 px-2 py-1 rounded-lg text-xs transition-colors"
:class="copiedIndex === idx ? 'bg-green-100 text-green-700' : 'bg-blue-50 text-blue-600 hover:bg-blue-100'"
@click="copyCode(cdk.code, idx)"
>
<Copy v-if="copiedIndex !== idx" class="w-3 h-3" />
<CheckCheck v-else class="w-3 h-3" />
{{ copiedIndex === idx ? '已复制' : '复制' }}
</button>
<div class="flex items-center gap-1">
<button
class="inline-flex items-center gap-1 px-2 py-1 rounded-lg text-xs transition-colors"
:class="copiedIndex === idx ? 'bg-green-100 text-green-700' : 'bg-blue-50 text-blue-600 hover:bg-blue-100'"
@click="copyCode(cdk.code, idx)"
>
<Copy v-if="copiedIndex !== idx" class="w-3 h-3" />
<CheckCheck v-else class="w-3 h-3" />
{{ copiedIndex === idx ? '已复制' : '复制' }}
</button>
<button
v-if="cdk.status === 'unused'"
class="inline-flex items-center gap-1 px-2 py-1 rounded-lg text-xs text-red-500 hover:bg-red-50 transition-colors"
@click="askDisable(cdk)"
>
<Ban class="w-3 h-3" />
禁用
</button>
<button
v-else-if="cdk.status === 'disabled'"
class="inline-flex items-center gap-1 px-2 py-1 rounded-lg text-xs text-green-600 hover:bg-green-50 transition-colors"
@click="askEnable(cdk)"
>
<RotateCcw class="w-3 h-3" />
恢复
</button>
</div>
</td>
</tr>
</tbody>
</table>
<!-- 分页 -->
<div
v-if="total > 0"
class="flex items-center justify-between px-4 py-3 border-t border-gray-100"
>
<div class="flex items-center gap-3">
<span class="text-xs text-gray-400">共 {{ total }} 条</span>
<select
:value="pageSize"
class="h-8 px-2 rounded-lg border border-gray-200 bg-white text-xs text-gray-500 focus:outline-none"
@change="handlePageSize(Number(($event.target as HTMLSelectElement).value))"
>
<option v-for="n in pageSizeOptions" :key="n" :value="n">{{ n }} 条/页</option>
</select>
</div>
<div class="flex items-center gap-2">
<button
class="w-8 h-8 rounded-lg flex items-center justify-center hover:bg-gray-100 disabled:opacity-30"
:disabled="page <= 1"
@click="handlePage(page - 1)"
>
<ChevronLeft class="w-4 h-4" />
</button>
<span class="text-sm text-gray-600">{{ page }}/{{ totalPages }}</span>
<button
class="w-8 h-8 rounded-lg flex items-center justify-center hover:bg-gray-100 disabled:opacity-30"
:disabled="page >= totalPages"
@click="handlePage(page + 1)"
>
<ChevronRight class="w-4 h-4" />
</button>
</div>
</div>
</div>
<!-- 生成CDK弹窗 -->
<!-- 生成CDK弹窗(form / result 两视图) -->
<Teleport to="body">
<div v-if="showGenerateModal" class="fixed inset-0 z-50 flex items-center justify-center bg-black/40" @click.self="showGenerateModal = false">
<div v-if="showGenerateModal" class="fixed inset-0 z-50 flex items-center justify-center bg-black/40" @click.self="modalView === 'form' ? (showGenerateModal = false) : null">
<div
class="bg-white rounded-xl shadow-xl w-full mx-4 p-6 flex flex-col"
:class="modalView === 'result' ? 'max-w-lg max-h-[80vh]' : 'max-w-sm'"
>
<!-- 配置视图 -->
<template v-if="modalView === 'form'">
<div class="flex items-center justify-between mb-4 flex-shrink-0">
<h2 class="text-lg font-semibold text-gray-900">生成CDK</h2>
<button class="text-gray-400 hover:text-gray-600" @click="closeGenerateModal">
<X class="w-5 h-5" />
</button>
</div>
<div class="space-y-3">
<div>
<Label class="mb-2 block text-sm text-gray-600">CDK 类型</Label>
<div class="grid grid-cols-2 gap-2">
<button
type="button"
class="h-10 rounded-md border text-sm font-medium transition-colors"
:class="generateType === 'count' ? 'bg-emerald-600 text-white border-emerald-600' : 'bg-white text-gray-700 border-gray-300 hover:border-emerald-400'"
@click="generateType = 'count'"
>次数 CDK</button>
<button
type="button"
class="h-10 rounded-md border text-sm font-medium transition-colors"
:class="generateType === 'vip' ? 'bg-amber-500 text-white border-amber-500' : 'bg-white text-gray-700 border-gray-300 hover:border-amber-400'"
@click="generateType = 'vip'"
>⭐ VIP CDK</button>
</div>
</div>
<div>
<Label class="mb-1 block text-sm text-gray-600">生成数量</Label>
<Input v-model.number="generateCount" type="number" min="1" max="100" class="h-10 text-sm" />
<p class="text-xs text-gray-400 mt-1">单次最多100个</p>
</div>
<template v-if="generateType === 'count'">
<div>
<Label class="mb-1 block text-sm text-gray-600">每张可运行次数</Label>
<Input v-model.number="generateMaxRuns" type="number" min="1" max="999" class="h-10 text-sm" />
<p class="text-xs text-gray-400 mt-1">每张CDK可兑换使用的次数,成本 = 基础单价 × 次数 × 费率</p>
</div>
</template>
<template v-else>
<div>
<Label class="mb-1 block text-sm text-gray-600">VIP 卡类型</Label>
<div class="grid grid-cols-2 gap-2">
<button
type="button"
class="h-10 rounded-md border text-sm font-medium transition-colors"
:class="generateVipDays === 7 ? 'bg-amber-500 text-white border-amber-500' : 'bg-white text-gray-700 border-gray-300 hover:border-amber-400'"
@click="generateVipDays = 7"
>周卡 · 7天</button>
<button
type="button"
class="h-10 rounded-md border text-sm font-medium transition-colors"
:class="generateVipDays === 30 ? 'bg-amber-500 text-white border-amber-500' : 'bg-white text-gray-700 border-gray-300 hover:border-amber-400'"
@click="generateVipDays = 30"
>月卡 · 30天</button>
</div>
<p class="text-xs text-gray-400 mt-1">兑换后从当前到期时间顺延,到期即享免扣次数执行任务</p>
</div>
</template>
</div>
<div class="flex gap-2 mt-6">
<Button variant="outline" class="flex-1 h-10" @click="showGenerateModal = false">取消</Button>
<Button class="flex-1 h-10" :disabled="generateLoading" @click="handleGenerate">
<LoaderCircle v-if="generateLoading" class="w-4 h-4 animate-spin mr-1" />
{{ generateLoading ? '生成中...' : '生成' }}
</Button>
</div>
</template>
<!-- 结果视图:展示全部CDK -->
<template v-else>
<div class="flex items-center justify-between mb-3 flex-shrink-0">
<div>
<h2 class="text-lg font-semibold text-gray-900 flex items-center gap-2">
<CheckCircle2 class="w-5 h-5 text-green-500" />
生成成功
</h2>
<p class="text-xs text-gray-400 mt-0.5">
共 {{ resultCdks.length }} 个CDK ·
<template v-if="generateType === 'vip'">{{ generateVipDays === 7 ? 'VIP 周卡 7 天' : 'VIP 月卡 30 天' }} · </template>
<template v-else>每张 {{ generateMaxRuns }} 次 · </template>
扣费
<span class="font-mono text-gray-600">{{ resultCost.toFixed(2) }} 元</span>
</p>
</div>
<button class="text-gray-400 hover:text-gray-600" @click="closeGenerateModal">
<X class="w-5 h-5" />
</button>
</div>
<!-- CDK 列表(可滚动) -->
<div class="flex-1 overflow-y-auto border border-gray-100 rounded-lg bg-gray-50/60 min-h-[120px] max-h-[40vh]">
<div
v-for="cdk in resultCdks"
:key="cdk.id"
class="flex items-center justify-between px-3 py-2 border-b border-gray-100 last:border-0 bg-white"
>
<code class="font-mono text-xs text-gray-800">{{ cdk.code }}</code>
<button
class="flex-shrink-0 inline-flex items-center gap-1 px-2 py-1 rounded-md text-[11px] transition-colors"
:class="copiedResultIdx === resultCdks.indexOf(cdk) ? 'text-green-600 bg-green-50' : 'text-blue-500 hover:bg-blue-50'"
@click="copyResultCode(cdk.code, resultCdks.indexOf(cdk))"
>
<Copy v-if="copiedResultIdx !== resultCdks.indexOf(cdk)" class="w-3 h-3" />
<CheckCheck v-else class="w-3 h-3" />
复制
</button>
</div>
</div>
<div class="flex gap-2 mt-4 flex-shrink-0">
<Button variant="outline" class="h-10 px-4" @click="openGenerateModal">
<Plus class="w-4 h-4 mr-1" />
继续生成
</Button>
<Button class="flex-1 h-10" @click="copyAllCodes">
<CheckCheck v-if="copiedAll" class="w-4 h-4 mr-1" />
<Copy v-else class="w-4 h-4 mr-1" />
{{ copiedAll ? '已复制全部' : '复制全部CDK' }}
</Button>
</div>
<p class="text-center text-[10px] text-gray-300 mt-2">
提示:CDK 按行分隔,可直接粘贴到表格/文档批量分发
</p>
</template>
</div>
</div>
</Teleport>
<!-- 禁用/恢复确认弹窗 -->
<Teleport to="body">
<div v-if="showStatusModal" class="fixed inset-0 z-50 flex items-center justify-center bg-black/40" @click.self="showStatusModal = false">
<div class="bg-white rounded-xl shadow-xl w-full max-w-sm mx-4 p-6">
<div class="flex items-center justify-between mb-4">
<h2 class="text-lg font-semibold text-gray-900">生成CDK</h2>
<button class="text-gray-400 hover:text-gray-600" @click="showGenerateModal = false">
<X class="w-5 h-5" />
</button>
</div>
<div class="space-y-3">
<div>
<Label class="mb-1 block text-sm text-gray-600">生成数量</Label>
<Input v-model.number="generateCount" type="number" min="1" max="100" class="h-10 text-sm" />
<p class="text-xs text-gray-400 mt-1">单次最多100个</p>
<div class="flex items-center gap-2.5 mb-3">
<div
class="w-9 h-9 rounded-xl flex items-center justify-center"
:class="statusAction === 'disable' ? 'bg-red-100' : 'bg-green-100'"
>
<Ban v-if="statusAction === 'disable'" class="w-4 h-4 text-red-500" />
<RotateCcw v-else class="w-4 h-4 text-green-600" />
</div>
<div>
<Label class="mb-1 block text-sm text-gray-600">每张可运行次数</Label>
<Input v-model.number="generateMaxRuns" type="number" min="1" max="999" class="h-10 text-sm" />
<p class="text-xs text-gray-400 mt-1">每张CDK可兑换使用的次数,成本 = 基础单价 × 次数 × 费率</p>
<h2 class="text-base font-semibold text-gray-900">{{ statusText }}</h2>
<p class="text-xs text-gray-400 mt-0.5">{{ statusTarget?.code }}</p>
</div>
</div>
<div class="flex gap-2 mt-6">
<Button variant="outline" class="flex-1 h-10" @click="showGenerateModal = false">取消</Button>
<Button class="flex-1 h-10" :disabled="generateLoading" @click="handleGenerate">
<LoaderCircle v-if="generateLoading" class="w-4 h-4 animate-spin mr-1" />
{{ generateLoading ? '生成中...' : '生成' }}
<p class="text-sm text-gray-500 leading-relaxed text-justify">
<template v-if="statusAction === 'disable'">
禁用后该CDK将<strong class="text-red-500">无法被用户兑换</strong>,如需使用可随时恢复。
</template>
<template v-else>
恢复后该CDK将重新变为<strong class="text-green-600">未使用</strong>状态,可被用户兑换。
</template>
</p>
<div class="flex gap-2 mt-5">
<Button variant="outline" class="flex-1 h-10" @click="showStatusModal = false">取消</Button>
<Button
class="flex-1 h-10"
:class="statusAction === 'disable' ? 'bg-red-500 hover:bg-red-600' : 'bg-green-500 hover:bg-green-600'"
:disabled="statusLoading"
@click="confirmStatusChange"
>
<LoaderCircle v-if="statusLoading" class="w-4 h-4 animate-spin mr-1" />
{{ statusAction === 'disable' ? '确认禁用' : '确认恢复' }}
</Button>
</div>
</div>
+6 -5
View File
@@ -31,14 +31,15 @@ onMounted(async () => {
me.value = meRes.data?.data || null
subAgents.value = agentRes.data?.data || []
cdks.value = cdkRes.data?.data || []
cdks.value = cdkRes.data?.data?.items || []
const agents = agentRes.data?.data || []
const summary = cdkRes.data?.data?.summary
stats.value = {
totalAgents: agents.length,
activeAgents: agents.filter((a) => a.status === 'active').length,
totalCDKs: cdks.value.length,
usedCDKs: cdks.value.filter((c) => c.status === 'used').length,
totalCDKs: summary?.total ?? cdks.value.length,
usedCDKs: summary?.used ?? cdks.value.filter((c) => c.status === 'used').length,
balance: me.value?.balance || 0,
rate: me.value?.rate ?? 1,
}
@@ -133,9 +134,9 @@ onMounted(async () => {
<td class="py-2.5">
<span
class="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium"
:class="cdk.status === 'used' ? 'bg-gray-100 text-gray-600' : 'bg-green-100 text-green-700'"
:class="cdk.status === 'used' ? 'bg-gray-100 text-gray-600' : cdk.status === 'disabled' ? 'bg-red-50 text-red-600' : 'bg-green-100 text-green-700'"
>
{{ cdk.status === 'used' ? '已使用' : '未使用' }}
{{ cdk.status === 'used' ? '已使用' : cdk.status === 'disabled' ? '已禁用' : '未使用' }}
</span>
</td>
<td class="py-2.5 text-gray-500">{{ new Date(cdk.createdAt).toLocaleString() }}</td>
+13 -8
View File
@@ -33,17 +33,22 @@ const adminInfo = ref<AgentInfo | null>(null)
const showUserMenu = ref(false)
const showSidebar = ref(false)
const navItems = [
const navItems: { name: string; label: string; icon: unknown; path: string; adminOnly?: boolean }[] = [
{ name: 'admin-dashboard', label: '仪表盘', icon: LayoutDashboard, path: '/admin/dashboard' },
{ name: 'admin-agents', label: '代理管理', icon: Users, path: '/admin/agents' },
{ name: 'admin-users', label: '用户管理', icon: UserCheck, path: '/admin/users' },
{ name: 'admin-users', label: '用户管理', icon: UserCheck, path: '/admin/users', adminOnly: true },
{ name: 'admin-cdks', label: 'CDK管理', icon: QrCode, path: '/admin/cdks' },
{ name: 'admin-announcements', label: '公告管理', icon: Megaphone, path: '/admin/announcements' },
{ name: 'admin-tickets', label: '工单管理', icon: MessageSquare, path: '/admin/tickets' },
{ name: 'admin-pending-schools', label: '待适配学校', icon: School, path: '/admin/pending-schools' },
{ name: 'admin-settings', label: '系统设置', icon: Settings, path: '/admin/settings' },
{ name: 'admin-announcements', label: '公告管理', icon: Megaphone, path: '/admin/announcements', adminOnly: true },
{ name: 'admin-tickets', label: '工单管理', icon: MessageSquare, path: '/admin/tickets', adminOnly: true },
{ name: 'admin-pending-schools', label: '待适配学校', icon: School, path: '/admin/pending-schools', adminOnly: true },
{ name: 'admin-settings', label: '系统设置', icon: Settings, path: '/admin/settings', adminOnly: true },
]
// 代理(agent)只能访问:仪表盘、代理管理、CDK管理
const visibleNavItems = computed(() =>
navItems.filter((item) => !item.adminOnly || adminInfo.value?.role === 'admin')
)
const activeNav = computed(() => route.name as string)
// 公告相关(代理端)
@@ -130,7 +135,7 @@ function dismissForcePopup() {
forcePopupQueue.value = forcePopupQueue.value.slice(1)
}
function navigateTo(item: typeof navItems[0]) {
function navigateTo(item: { name: string; label: string; icon: unknown; path: string; adminOnly?: boolean }) {
router.push({ name: item.name })
showSidebar.value = false
}
@@ -217,7 +222,7 @@ function handleLogout() {
<!-- 导航菜单 -->
<nav class="flex-1 py-3 px-3 space-y-1 overflow-y-auto">
<button
v-for="item in navItems"
v-for="item in visibleNavItems"
:key="item.name"
class="w-full flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm transition-colors"
:class="activeNav === item.name
+139 -27
View File
@@ -1,9 +1,10 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { Shield, RefreshCw, CheckCircle, Search, Eye, EyeOff, Copy } from 'lucide-vue-next'
import { Shield, RefreshCw, CheckCircle, XCircle, Search, Eye, EyeOff, Copy } from 'lucide-vue-next'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { useMessage } from '@/composables/useMessage'
import { copyToClipboard } from '@/utils/clipboard'
import api from '@/api/index'
interface PendingSchoolItem {
@@ -15,6 +16,7 @@ interface PendingSchoolItem {
email: string
ip: string
status: string
rejectReason: string
createdAt: string
}
@@ -34,6 +36,10 @@ const searchKeyword = ref('')
const showPasswordMap = ref<Record<number, boolean>>({})
// 已适配学校:schoolId -> 学校名(用于在待处理列表中标识已适配过的学校)
const adaptedSchools = ref<Map<string, string>>(new Map())
// 拒绝弹窗
const rejectTarget = ref<PendingSchoolItem | null>(null)
const rejectReason = ref('')
const rejectSubmitting = ref(false)
onMounted(() => {
loadData()
@@ -102,10 +108,45 @@ async function handleMarkDone(record: PendingSchoolItem) {
}
}
function copyText(text: string) {
navigator.clipboard.writeText(text).then(() => {
function openRejectModal(record: PendingSchoolItem) {
rejectTarget.value = record
rejectReason.value = ''
}
function closeRejectModal() {
if (rejectSubmitting.value) return
rejectTarget.value = null
rejectReason.value = ''
}
async function confirmReject() {
const target = rejectTarget.value
if (!target) return
const reason = rejectReason.value.trim()
if (!reason) {
message.error('拒绝时必须说明理由')
return
}
rejectSubmitting.value = true
try {
await api.post(`/admin/pending-schools/${target.id}/reject`, { reason })
message.success(`已拒绝该条记录,拒绝理由将邮件通知联系邮箱`)
closeRejectModal()
await loadData()
} catch (err: any) {
message.error(err?.response?.data?.message || '操作失败')
} finally {
rejectSubmitting.value = false
}
}
async function copyText(text: string) {
const ok = await copyToClipboard(text)
if (ok) {
message.success('已复制')
})
} else {
message.error('复制失败,请长按手动复制')
}
}
</script>
@@ -135,6 +176,11 @@ function copyText(text: string) {
:class="statusFilter === 'completed' ? 'bg-white text-gray-900 shadow-sm' : 'text-gray-500 hover:text-gray-700'"
@click="statusFilter = 'completed'; loadData()"
>已完成</button>
<button
class="px-3 py-1.5 text-xs font-medium rounded-md transition-colors"
:class="statusFilter === 'rejected' ? 'bg-white text-gray-900 shadow-sm' : 'text-gray-500 hover:text-gray-700'"
@click="statusFilter = 'rejected'; loadData()"
>已拒绝</button>
<button
class="px-3 py-1.5 text-xs font-medium rounded-md transition-colors"
:class="statusFilter === '' ? 'bg-white text-gray-900 shadow-sm' : 'text-gray-500 hover:text-gray-700'"
@@ -212,19 +258,32 @@ function copyText(text: string) {
</span>
<span class="text-[10px] text-gray-400 font-mono">{{ group.schoolId }}</span>
</div>
<Button
v-if="statusFilter !== 'completed'"
size="sm"
class="h-7 text-xs"
@click="handleMarkDone(group.records[0])"
>
<CheckCircle class="w-3 h-3 mr-1" />
标记完成
</Button>
<div class="flex items-center gap-1.5">
<Button
v-if="statusFilter !== 'completed' && statusFilter !== 'rejected'"
size="sm"
class="h-7 text-xs whitespace-nowrap"
@click="handleMarkDone(group.records[0])"
>
<CheckCircle class="w-3 h-3 mr-1 shrink-0" />
标记完成
</Button>
<Button
v-if="statusFilter === 'pending'"
variant="outline"
size="sm"
class="h-7 text-xs text-red-500 border-red-200 hover:bg-red-50 whitespace-nowrap"
@click="openRejectModal(group.records[0])"
>
<XCircle class="w-3 h-3 mr-1 shrink-0" />
拒绝
</Button>
</div>
</div>
<!-- 账号列表 -->
<table class="w-full text-sm">
<!-- 账号列表(小屏横向滚动) -->
<div class="overflow-x-auto">
<table class="w-full text-sm min-w-[760px]">
<thead>
<tr class="text-left text-xs text-gray-400 border-b border-gray-50">
<th class="px-5 py-2.5 font-medium w-[16%]">账号</th>
@@ -284,29 +343,82 @@ function copyText(text: string) {
<td class="px-5 py-3">
<span
class="inline-flex items-center px-1.5 py-0.5 rounded-full text-[10px] font-medium"
:class="r.status === 'pending' ? 'bg-amber-50 text-amber-600' : 'bg-green-50 text-green-600'"
:class="r.status === 'pending' ? 'bg-amber-50 text-amber-600' : r.status === 'rejected' ? 'bg-red-50 text-red-600' : 'bg-green-50 text-green-600'"
>
{{ r.status === 'pending' ? '待处理' : '已完成' }}
{{ r.status === 'pending' ? '待处理' : r.status === 'rejected' ? '已拒绝' : '已完成' }}
</span>
<p
v-if="r.status === 'rejected' && r.rejectReason"
class="text-[10px] text-red-400 mt-1 max-w-[180px] leading-snug"
:title="r.rejectReason"
>理由:{{ r.rejectReason }}</p>
</td>
<!-- 提交时间 -->
<td class="px-5 py-3 text-gray-500 text-xs">{{ r.createdAt }}</td>
<!-- 操作 -->
<td class="px-5 py-3">
<button
v-if="r.status === 'pending'"
class="inline-flex items-center gap-1 px-2 py-1 rounded-lg text-xs text-blue-500 hover:bg-blue-50 transition-colors"
@click="handleMarkDone(r)"
>
<CheckCircle class="w-3 h-3" />
完成
</button>
<td class="px-5 py-3 whitespace-nowrap">
<div v-if="r.status === 'pending'" class="flex items-center gap-1 whitespace-nowrap">
<button
class="inline-flex items-center gap-1 px-2 py-1 rounded-lg text-xs whitespace-nowrap text-blue-500 hover:bg-blue-50 transition-colors"
@click="handleMarkDone(r)"
>
<CheckCircle class="w-3 h-3 shrink-0" />
完成
</button>
<button
class="inline-flex items-center gap-1 px-2 py-1 rounded-lg text-xs whitespace-nowrap text-red-500 hover:bg-red-50 transition-colors"
@click="openRejectModal(r)"
>
<XCircle class="w-3 h-3 shrink-0" />
拒绝
</button>
</div>
</td>
</tr>
</tbody>
</table>
</table>
</div>
</div>
</div>
</div>
<!-- 拒绝理由弹窗 -->
<Teleport to="body">
<div
v-if="rejectTarget"
class="fixed inset-0 z-50 bg-black/40 flex items-center justify-center px-4"
@click.self="closeRejectModal"
>
<div class="bg-white rounded-2xl w-full max-w-md p-6 shadow-xl">
<div class="flex items-center gap-3 mb-4">
<div class="w-9 h-9 rounded-full bg-red-50 flex items-center justify-center">
<XCircle class="w-5 h-5 text-red-500" />
</div>
<div>
<h3 class="text-base font-bold text-gray-900">拒绝适配申请</h3>
<p class="text-xs text-gray-400">{{ rejectTarget.schoolName }}</p>
</div>
</div>
<label class="block text-sm text-gray-600 mb-1.5">拒绝理由 <span class="text-red-500">*</span></label>
<textarea
v-model="rejectReason"
rows="4"
maxlength="500"
placeholder="请说明拒绝理由(必填),理由将通过邮件通知该学校的联系邮箱"
class="w-full rounded-lg border border-gray-200 px-3 py-2.5 text-sm text-gray-900 placeholder:text-gray-300 focus:outline-none focus:ring-2 focus:ring-red-100 focus:border-red-300 resize-none"
></textarea>
<p class="text-[11px] text-gray-400 mt-1">发送后将邮件通知该条记录的联系邮箱</p>
<div class="flex justify-end gap-2 mt-5">
<Button variant="outline" size="sm" class="h-9 text-xs" :disabled="rejectSubmitting" @click="closeRejectModal">
取消
</Button>
<Button size="sm" class="h-9 text-xs bg-red-500 hover:bg-red-600 text-white" :disabled="rejectSubmitting" @click="confirmReject">
<XCircle class="w-3.5 h-3.5 mr-1" />
{{ rejectSubmitting ? '提交中...' : '确认拒绝' }}
</Button>
</div>
</div>
</div>
</Teleport>
</div>
</template>
@@ -13,6 +13,8 @@ const message = useMessage()
const loading = ref(false)
const saving = ref(false)
const baseDeduction = ref('')
const vipWeekPrice = ref('')
const vipMonthPrice = ref('')
const inviterReward = ref('')
const inviteeReward = ref('')
const registerReward = ref('')
@@ -39,6 +41,8 @@ async function loadSettings() {
const res = await adminApi.getSettings()
const settings = res.data?.data || {}
baseDeduction.value = settings['base_deduction_per_run'] || '1.00'
vipWeekPrice.value = settings['vip_price_week'] || '30.00'
vipMonthPrice.value = settings['vip_price_month'] || '100.00'
inviterReward.value = settings['inviter_reward_per_invite'] || '0.50'
inviteeReward.value = settings['invitee_reward_per_invite'] || '0.50'
registerReward.value = settings['register_reward'] || '1.00'
@@ -123,10 +127,22 @@ async function handleSave() {
message.info('直接注册奖励积分请输入有效的非负数')
return
}
const vipWeek = parseFloat(vipWeekPrice.value)
if (isNaN(vipWeek) || vipWeek <= 0) {
message.info('VIP 周卡售价请输入有效的正数')
return
}
const vipMonth = parseFloat(vipMonthPrice.value)
if (isNaN(vipMonth) || vipMonth <= 0) {
message.info('VIP 月卡售价请输入有效的正数')
return
}
saving.value = true
try {
await adminApi.updateSetting('base_deduction_per_run', deduction.toFixed(2))
await adminApi.updateSetting('vip_price_week', vipWeek.toFixed(2))
await adminApi.updateSetting('vip_price_month', vipMonth.toFixed(2))
await adminApi.updateSetting('inviter_reward_per_invite', invReward.toFixed(2))
await adminApi.updateSetting('invitee_reward_per_invite', ineeReward.toFixed(2))
await adminApi.updateSetting('register_reward', regReward.toFixed(2))
@@ -168,6 +184,34 @@ async function handleSave() {
</p>
</div>
<div>
<Label class="mb-1 block text-sm text-gray-600">⭐ VIP 周卡售价(元 / 7天)</Label>
<Input
v-model="vipWeekPrice"
type="number"
step="0.01"
min="0.01"
class="h-10 text-sm max-w-[200px]"
/>
<p class="text-xs text-gray-400 mt-1">
生成 VIP 周卡 CDK 的单张售价,VIP 有效期内执行任务免扣次数
</p>
</div>
<div>
<Label class="mb-1 block text-sm text-gray-600">⭐ VIP 月卡售价(元 / 30天)</Label>
<Input
v-model="vipMonthPrice"
type="number"
step="0.01"
min="0.01"
class="h-10 text-sm max-w-[200px]"
/>
<p class="text-xs text-gray-400 mt-1">
生成 VIP 月卡 CDK 的单张售价,前台开通/续费 VIP 展示此价格
</p>
</div>
<div class="flex gap-2 pt-2">
<Button :disabled="saving" @click="handleSave">
<Save class="w-4 h-4 mr-1" />
+364 -22
View File
@@ -25,6 +25,7 @@ import {
LogIn,
Copy,
CheckCircle2,
Globe,
} from 'lucide-vue-next'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
@@ -47,12 +48,17 @@ interface UserItem {
signCount: number
inviteReward: number
invitedCount: number
inviterId: number | null
inviterName: string
taskCount: number
executedCount: number
vipStatus: string
vipExpireAt: string
todayStatus: string
status: string
createdAt: string
lastLoginAt: string
lastLoginIp: string
}
interface TodayStats {
@@ -73,6 +79,8 @@ interface UserDetail {
signCount: number
inviteReward: number
invitedCount: number
inviterId: number | null
inviterName: string
status: string
disabledReason: string
taskCount: number
@@ -80,6 +88,7 @@ interface UserDetail {
mobileType: string
createdAt: string
lastLoginAt: string
lastLoginIp: string
}
const loading = ref(false)
@@ -103,7 +112,128 @@ const disableTarget = ref<UserItem | null>(null)
const disableReason = ref('')
const disabling = ref(false)
// 当天执行日志弹窗
interface AdminTaskLogItem {
id: number
userId: number
username: string
realName: string
schoolName: string
taskType: string
title: string
realTitle: string
isSuccess: boolean
errorMessage: string
createdAt: string
}
const showLogsModal = ref(false)
const logsLoading = ref(false)
const logItems = ref<AdminTaskLogItem[]>([])
const logsTotal = ref(0)
const logsPage = ref(1)
const logsPageSize = ref(20)
const logsDate = ref(todayStr())
const logsStatus = ref<'all' | 'success' | 'fail'>('all')
const logsKeyword = ref('')
const logsSearchKeyword = ref('')
const logsStatusOptions = [
{ key: 'all' as const, label: '全部' },
{ key: 'success' as const, label: '成功' },
{ key: 'fail' as const, label: '失败' },
]
const logsTotalPages = computed(() => Math.ceil(logsTotal.value / logsPageSize.value) || 1)
function todayStr() {
const d = new Date()
const m = String(d.getMonth() + 1).padStart(2, '0')
const day = String(d.getDate()).padStart(2, '0')
return `${d.getFullYear()}-${m}-${day}`
}
function openTodayLogs() {
logsDate.value = todayStr()
logsStatus.value = 'all'
logsKeyword.value = ''
logsSearchKeyword.value = ''
logsPage.value = 1
showLogsModal.value = true
loadTodayLogs()
}
async function loadTodayLogs() {
logsLoading.value = true
try {
const res = await adminApi.getAdminTaskLogs({
date: logsDate.value,
status: logsStatus.value,
keyword: logsSearchKeyword.value,
page: logsPage.value,
pageSize: logsPageSize.value,
})
const data = res.data?.data
logItems.value = data?.items || []
logsTotal.value = data?.total || 0
} catch {
message.error('加载执行日志失败')
logItems.value = []
} finally {
logsLoading.value = false
}
}
function handleLogsStatusChange(val: 'all' | 'success' | 'fail') {
if (logsStatus.value === val) return
logsStatus.value = val
logsPage.value = 1
loadTodayLogs()
}
function handleLogsDateChange(e: Event) {
logsDate.value = (e.target as HTMLInputElement).value || todayStr()
logsPage.value = 1
loadTodayLogs()
}
function handleLogsSearch() {
logsPage.value = 1
logsSearchKeyword.value = logsKeyword.value.trim()
loadTodayLogs()
}
function handleLogsPage(p: number) {
if (p < 1 || p > logsTotalPages.value) return
logsPage.value = p
loadTodayLogs()
}
function formatLogTime(iso: string) {
if (!iso) return '--'
const d = new Date(iso)
if (Number.isNaN(d.getTime())) return iso
const pad = (n: number) => String(n).padStart(2, '0')
return `${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`
}
// 当天已登录判定:lastLoginAt 为服务器时间 "YYYY-MM-DD HH:mm:ss",取日期部分与本地今天比对
function isTodayLogin(lastLoginAt: string) {
if (!lastLoginAt) return false
const d = new Date()
const pad = (n: number) => String(n).padStart(2, '0')
const today = `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`
return lastLoginAt.slice(0, 10) === today
}
function taskTypeLabel(t: string) {
if (t === 'sign') return '签到'
if (t === 'sleep_check') return '查寝'
return t
}
const totalPages = computed(() => Math.ceil(total.value / pageSize.value) || 1)
const pageSizeOptions = [20, 50, 100]
onMounted(() => {
loadUsers()
@@ -139,6 +269,13 @@ function handlePage(p: number) {
loadUsers()
}
function handlePageSize(size: number) {
if (size === pageSize.value) return
pageSize.value = size
page.value = 1
loadUsers()
}
async function openDetail(user: UserItem) {
showDetail.value = true
detailLoading.value = true
@@ -241,14 +378,19 @@ function todayStatusLabel(s: string) {
<!-- 当天统计卡片 -->
<div class="grid grid-cols-2 lg:grid-cols-4 gap-3 mb-5">
<div class="bg-white rounded-xl border border-gray-200 p-4 flex items-center gap-3">
<div
class="bg-white rounded-xl border border-gray-200 p-4 flex items-center gap-3 cursor-pointer transition-all hover:border-blue-300 hover:shadow-md group"
title="点击查看执行日志明细"
@click="openTodayLogs"
>
<div class="w-10 h-10 rounded-xl bg-blue-100 flex items-center justify-center flex-shrink-0">
<Activity class="w-5 h-5 text-blue-600" />
</div>
<div class="min-w-0">
<div class="min-w-0 flex-1">
<p class="text-xs text-gray-400">当天执行总次数</p>
<p class="text-xl font-bold text-gray-900 leading-tight">{{ todayStats.executeCount }}</p>
</div>
<ChevronRight class="w-4 h-4 text-gray-300 group-hover:text-blue-400 transition-colors flex-shrink-0" />
</div>
<div class="bg-white rounded-xl border border-gray-200 p-4 flex items-center gap-3">
<div class="w-10 h-10 rounded-xl bg-green-100 flex items-center justify-center flex-shrink-0">
@@ -311,51 +453,81 @@ function todayStatusLabel(s: string) {
<!-- 表格 -->
<div v-else class="overflow-x-auto">
<table class="w-full text-sm table-fixed min-w-[1080px]">
<table class="w-full text-sm table-fixed min-w-[1360px]">
<thead>
<tr class="bg-gray-50 text-left text-xs text-gray-500">
<th class="px-4 py-3 font-medium w-[11%]">学工号</th>
<th class="px-4 py-3 font-medium w-[8%]">真实姓名</th>
<th class="px-4 py-3 font-medium w-[15%]">设备码</th>
<th class="px-4 py-3 font-medium w-[10%]">可执行/已执行</th>
<th class="px-4 py-3 font-medium w-[10%]">积分/邀请数</th>
<th class="px-4 py-3 font-medium w-[10%]">今天执行状态</th>
<th class="px-4 py-3 font-medium w-[12%]">创建时间</th>
<th class="px-4 py-3 font-medium text-center w-[24%]">操作</th>
<th class="px-4 py-3 font-medium w-[10%]">学工号/姓名</th>
<th class="px-4 py-3 font-medium w-[12%]">学校/设备码</th>
<th class="px-4 py-3 font-medium w-[8%]">可执行/已执行</th>
<th class="px-4 py-3 font-medium w-[8%]">VIP</th>
<th class="px-4 py-3 font-medium w-[8%]">积分/邀请数</th>
<th class="px-4 py-3 font-medium w-[7%]">邀请人</th>
<th class="px-4 py-3 font-medium w-[8%]">今天执行状态</th>
<th class="px-4 py-3 font-medium w-[7%]">创建时间</th>
<th class="px-4 py-3 font-medium w-[10%]">最后登录</th>
<th class="px-4 py-3 font-medium text-center w-[15%]">操作</th>
</tr>
</thead>
<tbody>
<tr
v-for="user in users"
:key="user.id"
class="border-t border-gray-50 hover:bg-gray-50/50 transition-colors"
class="border-t border-gray-50 transition-colors"
:class="isTodayLogin(user.lastLoginAt)
? 'bg-green-50 hover:bg-green-100/70'
: 'hover:bg-gray-50/50'"
:title="isTodayLogin(user.lastLoginAt) ? '该用户当天已登录' : undefined"
>
<td class="px-4 py-3 truncate font-mono text-xs">{{ user.studentId }}</td>
<td class="px-4 py-3 truncate">{{ user.realName || '--' }}</td>
<td class="px-4 py-3 truncate font-mono text-xs">
<div class="flex items-center gap-1.5 min-w-0">
<span v-if="user.mobileCode" class="text-gray-700 truncate" :title="user.mobileCode">{{ maskDeviceCode(user.mobileCode) }}</span>
<span v-else class="text-gray-300">未绑定</span>
<td class="px-4 py-3">
<p class="font-mono text-xs text-gray-700 truncate" :title="user.studentId">{{ user.studentId || '--' }}</p>
<p class="text-[10px] text-gray-400 truncate">{{ user.realName || '--' }}</p>
</td>
<td class="px-4 py-3">
<p class="text-xs text-gray-700 truncate" :title="user.schoolName">{{ user.schoolName || '--' }}</p>
<p class="text-[10px] font-mono text-gray-400 flex items-center gap-1 min-w-0">
<span v-if="user.mobileCode" class="truncate" :title="user.mobileCode">{{ maskDeviceCode(user.mobileCode) }}</span>
<span v-else>未绑定</span>
<button
v-if="user.mobileCode"
class="flex-shrink-0 text-gray-400 hover:text-blue-500 transition-colors"
:title="'复制完整设备码'"
@click="copyDeviceCode(user.mobileCode)"
>
<Copy class="w-3.5 h-3.5" />
<Copy class="w-3 h-3" />
</button>
</div>
</p>
</td>
<td class="px-4 py-3">
<span class="font-medium text-gray-900">{{ user.signCount }}</span>
<span class="text-gray-300 mx-0.5">/</span>
<span class="text-gray-500">{{ user.executedCount }}</span>
</td>
<td class="px-4 py-3">
<span
v-if="user.vipStatus === 'active'"
class="text-[10px] px-1.5 py-0.5 rounded-full font-medium bg-amber-100 text-amber-700"
:title="`VIP 到期时间:${user.vipExpireAt}`"
>
VIP · {{ user.vipExpireAt.slice(5, 10) }}
</span>
<span
v-else-if="user.vipStatus === 'expired'"
class="text-[10px] px-1.5 py-0.5 rounded-full font-medium bg-gray-100 text-gray-400"
:title="`VIP 已于 ${user.vipExpireAt} 过期`"
>
VIP · 已过期
</span>
<span v-else class="text-xs text-gray-300">非VIP</span>
</td>
<td class="px-4 py-3">
<span class="font-medium text-gray-900">{{ user.inviteReward }}</span>
<span class="text-gray-300 mx-0.5">/</span>
<span class="text-gray-500">{{ user.invitedCount }}</span>
</td>
<td class="px-4 py-3 truncate">
<span v-if="user.inviterName" class="text-gray-700" :title="user.inviterName">{{ user.inviterName }}</span>
<span v-else class="text-gray-300">--</span>
</td>
<td class="px-4 py-3">
<span
class="text-[10px] px-1.5 py-0.5 rounded-full font-medium"
@@ -365,6 +537,10 @@ function todayStatusLabel(s: string) {
</span>
</td>
<td class="px-4 py-3 text-xs text-gray-500">{{ user.createdAt }}</td>
<td class="px-4 py-3">
<p class="font-mono text-xs text-gray-700" :title="user.lastLoginIp || undefined">{{ user.lastLoginIp || '--' }}</p>
<p class="text-[10px] text-gray-400">{{ user.lastLoginAt || '未登录' }}</p>
</td>
<td class="px-4 py-3 text-center whitespace-nowrap">
<div class="flex items-center justify-center gap-1">
<button
@@ -399,10 +575,19 @@ function todayStatusLabel(s: string) {
<!-- 分页 -->
<div
v-if="total > pageSize"
v-if="total > 0"
class="flex items-center justify-between px-4 py-3 border-t border-gray-100"
>
<span class="text-xs text-gray-400">共 {{ total }} 条</span>
<div class="flex items-center gap-3">
<span class="text-xs text-gray-400">共 {{ total }} 条</span>
<select
:value="pageSize"
class="h-8 px-2 rounded-lg border border-gray-200 bg-white text-xs text-gray-500 focus:outline-none"
@change="handlePageSize(Number(($event.target as HTMLSelectElement).value))"
>
<option v-for="n in pageSizeOptions" :key="n" :value="n">{{ n }} 条/页</option>
</select>
</div>
<div class="flex items-center gap-2">
<button
class="w-8 h-8 rounded-lg flex items-center justify-center hover:bg-gray-100 disabled:opacity-30"
@@ -506,6 +691,12 @@ function todayStatusLabel(s: string) {
</div>
<p class="text-sm font-medium text-gray-900">{{ detailUser.invitedCount }} 人</p>
</div>
<div class="bg-gray-50 rounded-xl p-3.5">
<div class="flex items-center gap-2 text-xs text-gray-400 mb-1">
<UserPlus class="w-3.5 h-3.5" /> 邀请人
</div>
<p class="text-sm font-medium text-gray-900 truncate">{{ detailUser.inviterName || '--' }}</p>
</div>
<div class="bg-gray-50 rounded-xl p-3.5">
<div class="flex items-center gap-2 text-xs text-gray-400 mb-1">
<ClipboardCheck class="w-3.5 h-3.5" /> 任务数
@@ -530,6 +721,12 @@ function todayStatusLabel(s: string) {
</div>
<p class="text-sm font-medium text-gray-900">{{ detailUser.lastLoginAt || '--' }}</p>
</div>
<div class="bg-gray-50 rounded-xl p-3.5">
<div class="flex items-center gap-2 text-xs text-gray-400 mb-1">
<Globe class="w-3.5 h-3.5" /> 最后登录IP
</div>
<p class="text-sm font-medium text-gray-900 font-mono">{{ detailUser.lastLoginIp || '--' }}</p>
</div>
</div>
</div>
</div>
@@ -583,6 +780,151 @@ function todayStatusLabel(s: string) {
</div>
</Transition>
</Teleport>
<!-- 当天执行日志弹窗 -->
<Teleport to="body">
<Transition name="fade">
<div v-if="showLogsModal" class="fixed inset-0 z-50 flex items-center justify-center px-4">
<div class="absolute inset-0 bg-black/40" @click="showLogsModal = false" />
<div class="relative w-full sm:max-w-3xl bg-white rounded-2xl shadow-xl overflow-hidden flex flex-col max-h-[85vh]">
<div class="h-1.5 bg-gradient-to-r from-blue-500 to-cyan-400 flex-shrink-0" />
<!-- 头部 -->
<div class="px-5 pt-4 pb-3 flex items-start justify-between flex-shrink-0">
<div>
<h3 class="text-base font-semibold text-gray-900">执行日志明细</h3>
<p class="text-xs text-gray-400 mt-0.5">{{ logsDate }} · 共 {{ logsTotal }} 条记录</p>
</div>
<button
class="p-1.5 rounded-lg text-gray-400 hover:text-gray-600 hover:bg-gray-100 transition-colors"
@click="showLogsModal = false"
>
<X class="w-4 h-4" />
</button>
</div>
<!-- 筛选栏 -->
<div class="px-5 pb-3 flex flex-wrap items-center gap-2 flex-shrink-0">
<input
type="date"
:value="logsDate"
class="h-9 px-2.5 rounded-lg border border-gray-200 text-sm text-gray-600 focus:outline-none focus:ring-2 focus:ring-blue-500/20 focus:border-blue-400"
@change="handleLogsDateChange"
/>
<div class="flex rounded-lg border border-gray-200 overflow-hidden">
<button
v-for="opt in logsStatusOptions"
:key="opt.key"
class="h-9 px-3 text-xs font-medium transition-colors"
:class="logsStatus === opt.key ? 'bg-blue-500 text-white' : 'bg-white text-gray-500 hover:bg-gray-50'"
@click="handleLogsStatusChange(opt.key)"
>
{{ opt.label }}
</button>
</div>
<div class="relative flex-1 min-w-[180px]">
<Search class="absolute left-3 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-gray-400" />
<input
v-model="logsKeyword"
placeholder="搜索用户名/姓名/任务标题"
class="w-full h-9 pl-8 pr-3 text-sm border border-gray-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500/20 focus:border-blue-400"
@keyup.enter="handleLogsSearch"
/>
</div>
<Button variant="outline" class="h-9 text-sm" @click="handleLogsSearch">
<Search class="w-3.5 h-3.5 mr-1" />
搜索
</Button>
</div>
<!-- 列表 -->
<div class="flex-1 overflow-y-auto border-t border-gray-100">
<!-- 加载骨架 -->
<div v-if="logsLoading" class="p-5 space-y-3">
<div v-for="i in 6" :key="i" class="h-10 bg-gray-50 rounded animate-pulse" />
</div>
<!-- 空状态 -->
<div v-else-if="logItems.length === 0" class="text-center py-16 text-gray-400">
<ClipboardCheck class="w-10 h-10 mx-auto mb-3 opacity-30" />
<p class="text-sm">该日期暂无执行记录</p>
</div>
<!-- 日志表格 -->
<table v-else class="w-full text-sm table-fixed min-w-[640px]">
<thead>
<tr class="bg-gray-50 text-left text-xs text-gray-500 sticky top-0">
<th class="px-4 py-2.5 font-medium w-[80px]">时间</th>
<th class="px-4 py-2.5 font-medium w-[130px]">用户</th>
<th class="px-4 py-2.5 font-medium w-[64px]">类型</th>
<th class="px-4 py-2.5 font-medium">任务标题</th>
<th class="px-4 py-2.5 font-medium w-[60px]">状态</th>
<th class="px-4 py-2.5 font-medium">说明</th>
</tr>
</thead>
<tbody>
<tr
v-for="log in logItems"
:key="log.id"
class="border-t border-gray-50 hover:bg-gray-50/50 transition-colors align-top"
>
<td class="px-4 py-2.5 text-xs text-gray-500 whitespace-nowrap tabular-nums">{{ formatLogTime(log.createdAt) }}</td>
<td class="px-4 py-2.5 truncate" :title="`${log.realName || ''}(${log.username})`">
<p class="text-gray-900 truncate leading-tight">{{ log.realName || log.username || '--' }}</p>
<p class="text-[11px] text-gray-400 truncate leading-tight">{{ log.schoolName || '--' }}</p>
</td>
<td class="px-4 py-2.5">
<span class="text-[10px] px-1.5 py-0.5 rounded-full font-medium bg-gray-100 text-gray-600">
{{ taskTypeLabel(log.taskType) }}
</span>
</td>
<td class="px-4 py-2.5 truncate text-gray-700" :title="log.realTitle || log.title">
{{ log.realTitle || log.title || '--' }}
</td>
<td class="px-4 py-2.5 whitespace-nowrap">
<span
class="text-[10px] px-1.5 py-0.5 rounded-full font-medium inline-block"
:class="log.isSuccess ? 'bg-green-100 text-green-700' : 'bg-red-100 text-red-700'"
>
{{ log.isSuccess ? '成功' : '失败' }}
</span>
</td>
<td class="px-4 py-2.5">
<span v-if="log.errorMessage" class="text-xs text-red-500 break-words">{{ log.errorMessage }}</span>
<span v-else class="text-xs text-gray-300">--</span>
</td>
</tr>
</tbody>
</table>
</div>
<!-- 分页 -->
<div
v-if="logsTotal > 0"
class="flex items-center justify-between px-5 py-3 border-t border-gray-100 flex-shrink-0"
>
<span class="text-xs text-gray-400">第 {{ logsPage }} / {{ logsTotalPages }} 页</span>
<div class="flex items-center gap-2">
<button
class="w-8 h-8 rounded-lg flex items-center justify-center hover:bg-gray-100 disabled:opacity-30"
:disabled="logsPage <= 1"
@click="handleLogsPage(logsPage - 1)"
>
<ChevronLeft class="w-4 h-4" />
</button>
<button
class="w-8 h-8 rounded-lg flex items-center justify-center hover:bg-gray-100 disabled:opacity-30"
:disabled="logsPage >= logsTotalPages"
@click="handleLogsPage(logsPage + 1)"
>
<ChevronRight class="w-4 h-4" />
</button>
</div>
</div>
</div>
</div>
</Transition>
</Teleport>
</div>
</template>
+1 -1
View File
@@ -249,7 +249,7 @@ onUnmounted(() => {
<CheckCircle class="w-8 h-8 text-green-500" />
</div>
<h2 class="text-lg font-bold text-gray-900 mb-1">绑定成功</h2>
<p class="text-sm text-gray-400">即将进入首页...</p>
<p class="text-sm text-gray-400">即将进入打卡页...</p>
</div>
<!-- 错误 / 过期 -->
@@ -0,0 +1,182 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useRoute } from 'vue-router'
import { Globe, Copy } from 'lucide-vue-next'
import { Button } from '@/components/ui/button'
import { useMessage } from '@/composables/useMessage'
const route = useRoute()
const message = useMessage()
// 原始地址(含参数),在系统浏览器中打开后由路由守卫自动回到原页面
const targetUrl = computed(() => {
const redirect = typeof route.query.redirect === 'string' ? route.query.redirect : '/'
const path = redirect.startsWith('/') ? redirect : `/${redirect}`
return `${window.location.origin}${path}`
})
function copyLink() {
const text = targetUrl.value
if (navigator.clipboard && window.isSecureContext) {
navigator.clipboard
.writeText(text)
.then(() => message.success('链接已复制,请在浏览器中打开'))
.catch(() => fallbackCopy(text))
return
}
fallbackCopy(text)
}
function fallbackCopy(text: string) {
const ta = document.createElement('textarea')
ta.value = text
ta.style.position = 'fixed'
ta.style.opacity = '0'
document.body.appendChild(ta)
ta.select()
try {
document.execCommand('copy')
message.success('链接已复制,请在浏览器中打开')
} catch {
message.error('复制失败,请手动复制地址栏链接')
}
document.body.removeChild(ta)
}
</script>
<template>
<div class="guide-wrap">
<div class="guide-card">
<!-- 右上角:模拟浏览器菜单 + 手指指向(业界通用引导样式)-->
<div class="top-hint" aria-hidden="true">
<div class="hand">👆</div>
<div class="fake-menu">···</div>
</div>
<div class="icon-circle">
<Globe :size="34" :stroke-width="1.8" />
</div>
<h2 class="title">请在浏览器中打开</h2>
<p class="desc">
请点击<b>右上角 ···</b>(或 <b>⋮</b>)<br />
选择「<b>在浏览器中打开</b>」
</p>
<Button class="open-btn" size="lg" @click="copyLink">
<Copy :size="18" />
复制链接去浏览器打开
</Button>
<p class="tip">如果上方按钮无效,也可以手动复制地址栏链接</p>
</div>
</div>
</template>
<style scoped>
.guide-wrap {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
padding: 24px;
background: linear-gradient(160deg, #eef4ff 0%, #f7f9fc 60%, #eefaf3 100%);
}
.guide-card {
position: relative;
width: 100%;
max-width: 360px;
background: #fff;
border-radius: 20px;
padding: 56px 24px 32px;
text-align: center;
box-shadow: 0 12px 40px rgba(31, 58, 122, 0.12);
}
/* 右上角菜单图标 + 手指:手指位于菜单左下方、指向右上角 */
.top-hint {
position: absolute;
top: 16px;
right: 20px;
display: flex;
align-items: flex-end;
}
.fake-menu {
width: 38px;
height: 38px;
border-radius: 10px;
background: #f3f4f6;
border: 1.5px solid #d1d5db;
color: #6b7280;
font-size: 17px;
font-weight: 700;
letter-spacing: 1px;
line-height: 34px;
text-align: center;
}
.hand {
font-size: 30px;
line-height: 1;
margin-right: 6px;
margin-bottom: -2px;
transform: rotate(38deg);
filter: drop-shadow(0 2px 3px rgba(0, 0, 0, 0.18));
animation: hand-tap 1.1s ease-in-out infinite;
transform-origin: 30% 90%;
}
@keyframes hand-tap {
0%,
100% {
transform: rotate(38deg) translate(0, 0);
}
50% {
transform: rotate(38deg) translate(5px, -5px);
}
}
.icon-circle {
width: 68px;
height: 68px;
margin: 0 auto 18px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
color: #3b82f6;
background: #eff6ff;
}
.title {
font-size: 19px;
font-weight: 600;
color: #1f2937;
margin: 0 0 10px;
}
.desc {
font-size: 14px;
line-height: 1.8;
color: #6b7280;
margin: 0 0 24px;
}
.desc b {
color: #1f2937;
font-weight: 600;
}
.open-btn {
width: 100%;
height: 46px;
font-size: 15px;
border-radius: 12px;
}
.tip {
margin: 14px 0 0;
font-size: 12.5px;
color: #9ca3af;
line-height: 1.6;
}
</style>
+323 -22
View File
@@ -1,7 +1,7 @@
<script setup lang="ts">
import { ref, onMounted, onUnmounted } from 'vue'
import { useRouter } from 'vue-router'
import { CheckCircle, Clock, Bell, Calendar, Plus, Play, Trash2, ListTodo, MapPin, LoaderCircle, Edit3, Upload, Pencil, X, Gift, Smartphone } from 'lucide-vue-next'
import { CheckCircle, Clock, Bell, Calendar, Plus, Play, Trash2, ListTodo, MapPin, LoaderCircle, Edit3, Upload, Pencil, X, Gift, Smartphone, Star } from 'lucide-vue-next'
import { Button } from '@/components/ui/button'
import * as authApi from '@/api/auth'
import { CPD_PASSWORD_ERROR } from '@/api'
@@ -15,6 +15,62 @@ const loading = ref(true)
const showNoRunsDialog = ref(false)
const noRunsMessage = ref('')
// ======== 已打卡再次执行确认弹窗 ========
const showConfirmExecDialog = ref(false)
const confirmExecTask = ref<authApi.TaskInfo | null>(null)
/** 确认再次打卡:以完全相同的执行规则重新执行该任务 */
function confirmExecAgain() {
const t = confirmExecTask.value
showConfirmExecDialog.value = false
confirmExecTask.value = null
if (t) executeTask(t, true)
}
// ======== 收藏本站 ========
// 浏览器安全限制不允许 JS 直接写入书签/收藏,点击后复制本站网址到剪贴板,
// 并按设备类型展示对应浏览器的手动添加步骤(手机:菜单添加书签/收藏或添加到主屏幕;电脑:Ctrl+D)
const showBookmarkDialog = ref(false)
const bookmarkUrlCopied = ref(false)
const isMobileDevice = /Android|iPhone|iPad|iPod|Mobile/i.test(navigator.userAgent)
const siteUrl = window.location.origin
function openBookmarkDialog() {
showBookmarkDialog.value = true
copySiteUrl()
}
function copySiteUrl() {
const url = window.location.origin
bookmarkUrlCopied.value = false
if (navigator.clipboard && navigator.clipboard.writeText) {
navigator.clipboard.writeText(url).then(() => {
bookmarkUrlCopied.value = true
}).catch(() => {
bookmarkUrlCopied.value = fallbackCopyText(url)
})
} else {
bookmarkUrlCopied.value = fallbackCopyText(url)
}
}
/** execCommand 降级复制(不支持 Clipboard API 的环境),返回是否成功 */
function fallbackCopyText(text: string): boolean {
try {
const ta = document.createElement('textarea')
ta.value = text
ta.style.position = 'fixed'
ta.style.left = '-9999px'
document.body.appendChild(ta)
ta.select()
const ok = document.execCommand('copy')
document.body.removeChild(ta)
return ok
} catch {
return false
}
}
// ======== 设备切换弹窗 ========
const showDeviceSwitchDialog = ref(false)
const deviceSwitchMessage = ref('')
@@ -88,9 +144,35 @@ async function removeTaskLog(id: number) {
}
}
// ======== 设备码绑定状态(新建任务入口拦截用) ========
const deviceCode = ref('')
const showBindDeviceDialog = ref(false)
// 执行成功后的延迟校正刷新定时器(今日校园侧状态同步延迟时兜底)
let refreshTimer: number | null = null
// ======== 切回页面自动刷新 ========
// 覆盖"在今日校园 App 里打卡后切回本站"的场景:列表/匹配状态自动刷新,无需手动重进页面
let lastVisibleRefreshAt = 0
function onVisibilityChange() {
if (document.visibilityState !== 'visible') return
// 节流 15 秒:避免浏览器内频繁切换标签页时重复请求今日校园接口
const now = Date.now()
if (now - lastVisibleRefreshAt < 15000) return
lastVisibleRefreshAt = now
loadTasks()
loadTaskLogs()
}
// ======== 初始化 ========
onMounted(async () => {
// MobileLayout 已加载用户信息并处理绑设备检查
// 拉取设备码绑定状态(新建任务入口检测未绑定时弹窗引导)
try {
const res = await authApi.getUserInfo()
deviceCode.value = res.data.data?.mobileCode || ''
} catch {
// 拉取失败不阻塞页面
}
// 这里直接加载任务数据和日志
await loadTasks()
await loadTaskLogs()
@@ -98,10 +180,13 @@ onMounted(async () => {
// 监听 cpdaily 密码错误事件(从 axios 拦截器发出)
window.addEventListener(CPD_PASSWORD_ERROR, onCpdPasswordError)
document.addEventListener('visibilitychange', onVisibilityChange)
})
onUnmounted(() => {
window.removeEventListener(CPD_PASSWORD_ERROR, onCpdPasswordError)
document.removeEventListener('visibilitychange', onVisibilityChange)
if (refreshTimer) clearTimeout(refreshTimer)
})
// cpdaily 密码错误处理:弹出重新登录确认框
@@ -142,6 +227,11 @@ async function loadTasks() {
// ======== Step 0: 选择任务标题 ========
async function startCreateTask() {
// 未绑定设备码:弹窗引导去个人中心绑定,不进入创建流程
if (!deviceCode.value) {
showBindDeviceDialog.value = true
return
}
showStep.value = true
step.value = 0
stepTitle.value = '选择签到任务'
@@ -261,6 +351,16 @@ async function handleImageUpload(index: number, isEdit: boolean) {
const taskType = isEdit ? (editTask.value?.taskType || 'sign') : (selectedItem.value?.taskType || 'sign')
const items = isEdit ? editFormItems.value : formItems.value
// 签到二维码仅用于后台解析 uuid,无需上传到今日校园 OBS,本地预览即可
if (items[index].colName === 'qrCode') {
const qrReader = new FileReader()
qrReader.onload = (e) => {
items[index].content = (e.target?.result as string) || ''
}
qrReader.readAsDataURL(file)
return
}
// 先显示本地预览
const reader = new FileReader()
reader.onload = (e) => {
@@ -284,13 +384,20 @@ async function handleImageUpload(index: number, isEdit: boolean) {
const policy = policyJson.data
// Step 2: 浏览器直传到今日校园 OBS(文件不经过我们的服务器)
// 使用接口返回的 formData 字段集原样提交:签到为 q-ak/q-key-time/q-signature 风格,
// 查寝为 AccessKeyId/signature 风格,字段不同必须按接口返回拼装
const obsForm = new FormData()
obsForm.append('x-obs-acl', 'public-read')
obsForm.append('AccessKeyId', policy.accessid || policy.AccessId)
obsForm.append('key', policy.fileName || policy.FileName)
obsForm.append('signature', policy.signature || policy.Signature)
obsForm.append('policy', policy.policy || policy.Policy)
obsForm.append('content-type', file.type || 'image/jpeg')
const fd = policy.formData || policy.FormData
if (fd && typeof fd === 'object' && Object.keys(fd).length) {
for (const [k, v] of Object.entries(fd)) obsForm.append(k, String(v))
} else {
obsForm.append('x-obs-acl', 'public-read')
obsForm.append('AccessKeyId', policy.accessid || policy.AccessId)
obsForm.append('key', policy.fileName || policy.FileName)
obsForm.append('signature', policy.signature || policy.Signature)
obsForm.append('policy', policy.policy || policy.Policy)
obsForm.append('content-type', file.type || 'image/jpeg')
}
obsForm.append('file', file)
const obsRes = await fetch(policy.host || policy.Host, {
@@ -330,6 +437,19 @@ async function handleImageUpload(index: number, isEdit: boolean) {
}
// ======== 保存表单(创建流程) ========
// ======== 表单完整性:必填项为空时提交必然失败(如签到地点未配置 → 服务端报"纬度不能为空") ========
function missingRequiredFields(items: authApi.FormItem[] | null | undefined): string[] {
if (!items?.length) return []
return items
.filter(i => i.required && i.show !== false && !String(i.content ?? '').trim())
.map(i => i.title || i.colName)
}
function parseFormItems(formData?: string | null): authApi.FormItem[] {
if (!formData) return []
try { return JSON.parse(formData) as authApi.FormItem[] } catch { return [] }
}
// ======== 范围外签到原因必填校验 ========
function validateMalpositionReason(items: authApi.FormItem[]): boolean {
const mal = items.find(i => i.colName === 'isMalposition')
@@ -350,6 +470,10 @@ async function saveForm() {
await authApi.saveTaskForm(formTaskId.value, formItems.value)
showStep.value = false
await loadTasks()
const missing = missingRequiredFields(formItems.value)
if (missing.length) {
message.warning(`已保存,但以下必填项为空:${missing.join('、')},执行签到前请在「编辑」中补全`)
}
} catch (e: any) {
alert(e?.response?.data?.message || e.message || '保存失败')
} finally {
@@ -397,6 +521,10 @@ async function saveEdit() {
await authApi.saveTaskForm(editTask.value.id, editFormItems.value)
showEditModal.value = false
await loadTasks()
const missing = missingRequiredFields(editFormItems.value)
if (missing.length) {
message.warning(`已保存,但以下必填项为空:${missing.join('、')},执行签到前请补全`)
}
} catch (e: any) {
alert(e?.response?.data?.message || e.message || '保存失败')
} finally {
@@ -409,9 +537,22 @@ function isDeviceSwitchError(msg: string): boolean {
return msg.includes('更换手机') || msg.includes('频繁切换设备') || msg.includes('使用了新的设备') || msg.includes('设备打卡失败') || msg.includes('手机授权')
}
function executeTask(task: authApi.TaskInfo) {
function executeTask(task: authApi.TaskInfo, confirmed = false) {
if (executingId.value) return
if (task.taskStatus === 'success') return
// 当天已成功打卡:默认拦截并弹出确认,用户确认后(confirmed=true)以完全相同的规则再次执行
if (task.taskStatus === 'success' && !confirmed) {
confirmExecTask.value = task
showConfirmExecDialog.value = true
return
}
// 表单完整性预检:必填项为空时直接引导去编辑补全(后端同样会拦截,此处提示更快更直观)
const missing = missingRequiredFields(parseFormItems(task.formData))
if (missing.length) {
message.warning(`签到信息未配置完整,缺少:${missing.join('、')},请先点「编辑」补全`)
openEditModal(task)
return
}
// 清除旧的错误信息
execStatus.value = ''
@@ -439,8 +580,22 @@ function executeTask(task: authApi.TaskInfo) {
onSuccess: () => {
execStatus.value = 'success'
execMessage.value = '签到成功!'
// 本地乐观更新:立即把该任务标记为当天已完成。
// 列表接口需实时请求今日校园,可能较慢或状态暂时滞后,先本地刷新保证用户马上看到正确状态
const t = tasks.value.find(x => x.id === task.id)
if (t) {
t.taskStatus = 'success'
t.errorMessage = ''
t.matchStatus = 'matched_signed'
}
loadTasks()
loadTaskLogs()
// 今日校园侧状态同步有延迟:稍后再拉一次列表校正匹配状态(如被判定为未签到/未匹配的兜底修正)
if (refreshTimer) clearTimeout(refreshTimer)
refreshTimer = window.setTimeout(() => {
refreshTimer = null
loadTasks()
}, 6000)
setTimeout(() => {
executingId.value = null
execStatus.value = ''
@@ -536,15 +691,17 @@ function statusLabel(status: string) {
return map[status] || status
}
// 匹配状态背景色
function matchBgClass(status: string) {
// 匹配状态背景色:当天已成功执行的任务一律显示绿色
// (今日校园侧状态同步存在延迟,不能因 matchStatus 未及时更新就显示成"未签到"的蓝色)
function matchBgClass(task: authApi.TaskInfo) {
if (task.taskStatus === 'success') return 'bg-green-50/60'
const map: Record<string, string> = {
matched_unsigned: 'bg-blue-50/60',
matched_signed: 'bg-green-50/60',
no_match: 'bg-amber-50/60',
check_failed: 'bg-gray-50',
}
return map[status] || 'bg-gray-50'
return map[task.matchStatus] || 'bg-gray-50'
}
function taskTypeLabel(t: string) {
@@ -714,6 +871,7 @@ function getGPSLocation(source: string) {
</div>
</div>
<button
data-guide="home-create-task"
@click="startCreateTask"
class="w-full flex items-center justify-center gap-2 bg-blue-500 hover:bg-blue-600 text-white rounded-lg py-2.5 text-sm font-medium transition-colors"
>
@@ -747,7 +905,7 @@ function getGPSLocation(source: string) {
</div>
<!-- 任务列表(不显示 WID) -->
<div class="bg-white rounded-xl border border-gray-100 p-4 shadow-sm">
<div data-guide="home-task-list" class="bg-white rounded-xl border border-gray-100 p-4 shadow-sm">
<h3 class="text-sm font-semibold text-gray-900 mb-3">签到任务</h3>
<div v-if="tasks.length > 0" class="space-y-2">
@@ -755,7 +913,7 @@ function getGPSLocation(source: string) {
v-for="task in tasks"
:key="task.id"
class="flex items-center justify-between p-3 rounded-lg transition-colors"
:class="matchBgClass(task.matchStatus)"
:class="matchBgClass(task)"
>
<div class="flex-1 min-w-0">
<div class="flex items-center gap-1.5">
@@ -763,8 +921,9 @@ function getGPSLocation(source: string) {
<span class="text-[10px] px-1.5 py-0.5 rounded-full shrink-0 whitespace-nowrap" :class="task.taskType === 'sleep_check' ? 'bg-purple-100 text-purple-600' : 'bg-blue-100 text-blue-600'">{{ taskTypeLabel(task.taskType) }}</span>
</div>
<div class="flex items-center gap-2 mt-1">
<!-- 每任务只显示一个最相关的状态标签 -->
<template v-if="task.matchStatus === 'matched_signed'">
<!-- 每任务只显示一个最相关的状态标签;当天已成功执行优先显示"已签到":
今日校园侧签到状态同步有延迟时 matchStatus 可能仍是未签到,应以本地成功为准 -->
<template v-if="task.taskStatus === 'success' || task.matchStatus === 'matched_signed'">
<span class="text-[10px] px-1.5 py-0.5 rounded-full font-medium bg-green-100 text-green-600">已签到</span>
</template>
<template v-else-if="task.matchStatus === 'matched_unsigned'">
@@ -801,6 +960,7 @@ function getGPSLocation(source: string) {
<!-- 执行按钮:pending 或 fail 时可执行 -->
<button
v-if="task.taskStatus === 'pending' || task.taskStatus === 'fail'"
data-guide="home-task-exec"
@click="executeTask(task)"
:disabled="executingId !== null"
class="w-8 h-8 flex items-center justify-center rounded-lg bg-blue-500 hover:bg-blue-600 disabled:bg-blue-300 text-white transition-colors"
@@ -809,12 +969,17 @@ function getGPSLocation(source: string) {
<Play v-if="executingId !== task.id" class="w-4 h-4" />
<LoaderCircle v-else class="w-4 h-4 animate-spin" />
</button>
<div
<!-- 已完成:对勾可点击,弹窗确认后可再次打卡(走相同执行规则) -->
<button
v-if="task.taskStatus === 'success'"
class="w-8 h-8 flex items-center justify-center"
@click="executeTask(task)"
:disabled="executingId !== null"
class="w-8 h-8 flex items-center justify-center rounded-lg transition-colors disabled:opacity-60 hover:bg-green-50"
:title="executingId === task.id ? '执行中…' : '已打卡,点击可再次打卡'"
>
<CheckCircle class="w-5 h-5 text-green-500" />
</div>
<LoaderCircle v-if="executingId === task.id" class="w-4 h-4 animate-spin" />
<CheckCircle v-else class="w-5 h-5 text-green-500" />
</button>
<!-- 删除 -->
<button
@click="removeTask(task.id)"
@@ -829,7 +994,7 @@ function getGPSLocation(source: string) {
</div>
</div>
<div v-else class="flex items-center justify-center py-8">
<div v-else data-guide="home-task-empty" class="flex items-center justify-center py-8">
<div class="text-center">
<ListTodo class="w-8 h-8 text-gray-200 mx-auto mb-2" />
<p class="text-xs text-gray-400">暂无签到任务</p>
@@ -890,6 +1055,15 @@ function getGPSLocation(source: string) {
</div>
</div>
<!-- 收藏本站:右侧贴边窄竖条(图标在上、文字竖排在下;避开新手引导入口按钮),点击展示添加书签指引 -->
<button
class="fixed right-0 bottom-32 z-40 flex flex-col items-center gap-1.5 rounded-l-md bg-amber-500/95 py-3 px-1.5 text-white text-xs font-medium shadow-lg shadow-amber-500/30 hover:bg-amber-600 active:scale-95 transition-all"
@click="openBookmarkDialog"
>
<Star class="w-3.5 h-3.5 flex-shrink-0" />
<span class="bookmark-vtext">收藏本站</span>
</button>
<!-- ======== 创建流程 - 步骤弹窗 ======== -->
<Teleport to="body">
<div
@@ -1255,6 +1429,93 @@ function getGPSLocation(source: string) {
</div>
</Teleport>
<!-- ======== 收藏本站指引弹窗 ======== -->
<Teleport to="body">
<div
v-if="showBookmarkDialog"
class="fixed inset-0 z-50 flex items-center justify-center"
>
<div class="absolute inset-0 bg-black/40" @click="showBookmarkDialog = false" />
<div class="relative w-[85%] sm:max-w-sm bg-white rounded-2xl shadow-xl overflow-hidden">
<div class="p-6">
<div class="flex items-center gap-3 mb-4">
<div class="w-10 h-10 rounded-full bg-amber-100 flex items-center justify-center shrink-0">
<Star class="w-5 h-5 text-amber-500" />
</div>
<div>
<h3 class="text-base font-semibold text-gray-900">收藏本站</h3>
<p class="text-xs text-gray-400 mt-0.5">添加到书签/主屏幕,下次一键直达</p>
</div>
</div>
<div class="bg-gray-50 rounded-xl p-4 text-sm text-gray-600 leading-relaxed">
<!-- 手机浏览器:菜单添加书签/收藏,或添加到主屏幕 -->
<template v-if="isMobileDevice">
<p class="font-medium text-gray-700 mb-2">手机浏览器操作步骤:</p>
<ol class="space-y-1.5 list-decimal list-inside">
<li>点击浏览器菜单(<span class="font-semibold">⋮</span> 或分享图标)</li>
<li>选择「<span class="font-semibold">添加书签</span>」/「<span class="font-semibold">收藏</span>」,或「<span class="font-semibold">添加到主屏幕</span>」</li>
<li>下次从书签或桌面图标即可直接打开本站</li>
</ol>
</template>
<!-- 电脑浏览器:快捷键 -->
<template v-else>
<p class="font-medium text-gray-700 mb-2">电脑浏览器:</p>
<p>按 <kbd class="px-1.5 py-0.5 bg-white border border-gray-300 rounded text-xs font-mono">Ctrl</kbd> + <kbd class="px-1.5 py-0.5 bg-white border border-gray-300 rounded text-xs font-mono">D</kbd>(Mac 为 <kbd class="px-1.5 py-0.5 bg-white border border-gray-300 rounded text-xs font-mono">⌘</kbd> + <kbd class="px-1.5 py-0.5 bg-white border border-gray-300 rounded text-xs font-mono">D</kbd>)即可添加书签</p>
</template>
</div>
<!-- 网址复制状态 -->
<p class="mt-3 text-xs flex items-center gap-1.5" :class="bookmarkUrlCopied ? 'text-green-600' : 'text-gray-400'">
<CheckCircle v-if="bookmarkUrlCopied" class="w-3.5 h-3.5" />
<span v-if="bookmarkUrlCopied">本站网址已复制到剪贴板,可在添加收藏时粘贴使用</span>
<span v-else>本站网址:{{ siteUrl }}</span>
</p>
<Button class="mt-4 w-full h-10 text-sm font-medium" @click="showBookmarkDialog = false">
我知道了
</Button>
</div>
</div>
</div>
</Teleport>
<!-- ======== 已打卡再次执行确认弹窗 ======== -->
<Teleport to="body">
<div
v-if="showConfirmExecDialog"
class="fixed inset-0 z-50 flex items-center justify-center"
>
<div class="absolute inset-0 bg-black/40" @click="showConfirmExecDialog = false" />
<div class="relative w-[85%] sm:max-w-sm bg-white rounded-2xl shadow-xl overflow-hidden">
<div class="p-6 text-center">
<div class="w-14 h-14 rounded-full bg-green-100 flex items-center justify-center mx-auto mb-4">
<CheckCircle class="w-7 h-7 text-green-500" />
</div>
<h3 class="text-base font-semibold text-gray-900 mb-2">您当前已经打卡</h3>
<p class="text-sm text-gray-500 leading-relaxed">
是否再次打卡?再次执行与正常执行规则一致,成功后将扣除一次签到次数。
</p>
<div class="mt-5 flex gap-2">
<Button
variant="outline"
class="flex-1 h-10 text-sm font-medium"
@click="showConfirmExecDialog = false"
>
取消
</Button>
<Button
class="flex-1 h-10 text-sm font-medium"
@click="confirmExecAgain"
>
再次打卡
</Button>
</div>
</div>
</div>
</div>
</Teleport>
<!-- ======== 无可用签到次数弹窗 ======== -->
<Teleport to="body">
<div
@@ -1293,6 +1554,38 @@ function getGPSLocation(source: string) {
</div>
</Teleport>
<!-- ======== 未绑定设备码弹窗(新建任务入口拦截) ======== -->
<Teleport to="body">
<div
v-if="showBindDeviceDialog"
class="fixed inset-0 z-50 flex items-center justify-center"
>
<div class="absolute inset-0 bg-black/40" @click="showBindDeviceDialog = false" />
<div class="relative w-[85%] sm:max-w-sm bg-white rounded-2xl shadow-xl overflow-hidden">
<div class="p-6 text-center">
<div class="w-14 h-14 rounded-full bg-indigo-100 flex items-center justify-center mx-auto mb-4">
<Smartphone class="w-7 h-7 text-indigo-500" />
</div>
<h3 class="text-base font-semibold text-gray-900 mb-2">未绑定设备码</h3>
<p class="text-sm text-gray-500 leading-relaxed">
请先在个人中心绑定设备码,绑定后才能创建和执行签到任务
</p>
<div class="flex gap-2 mt-5">
<Button variant="outline" class="flex-1 h-10 text-sm" @click="showBindDeviceDialog = false">
取消
</Button>
<Button
class="flex-1 h-10 text-sm font-medium"
@click="showBindDeviceDialog = false; router.push('/profile')"
>
去绑定
</Button>
</div>
</div>
</div>
</div>
</Teleport>
<!-- ======== 操作频繁弹窗(限流) ======== -->
<Teleport to="body">
<div
@@ -1378,3 +1671,11 @@ function getGPSLocation(source: string) {
</Teleport>
</div>
</template>
<style scoped>
/* 收藏按钮竖排文字:仅文字本身竖排(容器保持常规纵排,图标在上文字在下) */
.bookmark-vtext {
writing-mode: vertical-rl;
letter-spacing: 2px;
}
</style>
File diff suppressed because it is too large Load Diff
+326 -8
View File
@@ -1,7 +1,7 @@
<script setup lang="ts">
import { ref, onMounted, watch } from 'vue'
import { ref, onMounted, watch, onUnmounted } from 'vue'
import { useRouter } from 'vue-router'
import { Search, User, Lock, Eye, EyeOff, RefreshCw, LogIn, GraduationCap, LoaderCircle, AlertTriangle, ExternalLink, FileText } from 'lucide-vue-next'
import { Search, User, Lock, Eye, EyeOff, RefreshCw, LogIn, GraduationCap, LoaderCircle, AlertTriangle, ExternalLink, FileText, ShieldCheck, Smartphone } from 'lucide-vue-next'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
@@ -27,9 +27,156 @@ const captchaId = ref('')
const captchaImage = ref('')
const captchaCode = ref('')
const loadingCaptcha = ref(false)
const captchaError = ref('')
const logging = ref(false)
// 多因子认证(MFA)Dialog
const showMFADialog = ref(false)
const mfaToken = ref('')
const mfaMessage = ref('')
const mfaMethods = ref<authApi.MFAAuthMethod[]>([]) // 后端返回的全部可用验证方式
const mfaSelectedType = ref('') // 当前选中的验证方式(reAuthType)
const mfaStage = ref<'select' | 'input'>('select') // select=选择验证方式,input=输入验证码
const mfaSending = ref(false) // 发送验证码中
const mfaCode = ref('')
const mfaSubmitting = ref(false)
const mfaCountdown = ref(0) // MFA 会话剩余秒数
let mfaTimer: ReturnType<typeof setInterval> | null = null
function startMFACountdown(seconds: number) {
stopMFACountdown()
mfaCountdown.value = seconds
mfaTimer = setInterval(() => {
if (mfaCountdown.value > 0) {
mfaCountdown.value--
} else {
stopMFACountdown()
}
}, 1000)
}
function stopMFACountdown() {
if (mfaTimer) {
clearInterval(mfaTimer)
mfaTimer = null
}
}
onUnmounted(stopMFACountdown)
// 打开 MFA Dialog(登录返回 code 4281:先选择验证方式再发送验证码)
function openMFADialog(
token: string,
msg: string,
expiresIn: number,
methods?: authApi.MFAAuthMethod[],
defaultType?: string
) {
mfaToken.value = token
mfaMessage.value = msg || '请选择验证方式获取验证码'
mfaMethods.value = methods && methods.length > 0 ? methods : []
// 默认选中:页面 default 标记 > 后端给的 defaultType > 列表首项
mfaSelectedType.value =
mfaMethods.value.find((m) => m.default)?.type || defaultType || mfaMethods.value[0]?.type || ''
// 无方式列表时(理论上仅兼容旧后端)跳过选择阶段,直接进入验证码输入
mfaStage.value = mfaMethods.value.length > 0 ? 'select' : 'input'
mfaCode.value = ''
showMFADialog.value = true
startMFACountdown(expiresIn > 0 ? expiresIn : 600)
}
// 选择验证方式
function handleMFASelect(type: string) {
mfaSelectedType.value = type
}
// 发送 MFA 验证码(用户选定验证方式后)
async function handleMFASend() {
if (!mfaSelectedType.value) {
message.info('请选择验证方式')
return
}
mfaSending.value = true
try {
const res = await authApi.sendMFACode({
mfaToken: mfaToken.value,
reAuthType: mfaSelectedType.value,
})
const body = res.data
if (body?.code === 0) {
// 发送成功:进入验证码输入阶段
mfaMessage.value = body?.message || '验证码已发送,请查收后输入'
mfaStage.value = 'input'
mfaCode.value = ''
} else if (body?.code === 4283) {
// 验证会话过期:关闭弹窗,引导重新登录
stopMFACountdown()
showMFADialog.value = false
message.error(body?.message || '验证会话已过期,请重新登录')
} else {
// 4284 发送失败等:弹窗留在选择界面,可重试或更换验证方式
message.error(body?.message || '验证码发送失败,请重试')
}
} catch (e: any) {
message.error(e?.response?.data?.message || '验证码发送失败,请重试')
} finally {
mfaSending.value = false
}
}
// 返回验证方式选择(更换方式重新获取验证码)
function handleMFAReselect() {
mfaStage.value = 'select'
mfaCode.value = ''
mfaMessage.value = '请选择验证方式获取验证码'
}
// 提交 MFA 验证码
async function handleMFAVerify() {
const code = mfaCode.value.trim()
if (!code) {
message.info('请输入验证码')
return
}
mfaSubmitting.value = true
try {
const res = await authApi.verifyMFA({ mfaToken: mfaToken.value, code })
const body = res.data
const data = body?.data
if (body?.code === 0 && data?.token?.accessToken) {
// 验证成功,进入登录态
stopMFACountdown()
showMFADialog.value = false
localStorage.setItem('access_token', data.token.accessToken)
localStorage.setItem('refresh_token', data.token.refreshToken)
message.success('登录成功')
router.replace('/home')
} else if (body?.code === 4283) {
// 验证会话过期:关闭弹窗,引导重新登录
stopMFACountdown()
showMFADialog.value = false
message.error(body?.message || '验证会话已过期,请重新登录')
} else {
// 验证码错误(4282)等:输入态保留,清空输入可重试
message.error(body?.message || '验证失败,请重试')
mfaCode.value = ''
}
} catch (e: any) {
const msg = e?.response?.data?.message || '验证失败,请重试'
message.error(msg)
mfaCode.value = ''
} finally {
mfaSubmitting.value = false
}
}
function closeMFADialog() {
showMFADialog.value = false
stopMFACountdown()
message.info('如需登录请重新提交')
}
// 登录失败 Dialog
const showLoginFailDialog = ref(false)
const loginErrorMessage = ref('')
@@ -42,6 +189,7 @@ const reportCaptchaImage = ref('')
const reportCaptchaCode = ref('')
const reportLoadingCaptcha = ref(false)
const reportSubmitting = ref(false)
const reportCaptchaError = ref('')
// 联系邮箱:前缀 + 下拉选择国内邮箱后缀
const reportEmailPrefix = ref('')
@@ -112,14 +260,25 @@ function handleBlur() {
}, 200)
}
// 从 axios 错误中提取后端提示文案(429 限流/服务端错误等),失败兜底通用文案
function captchaFailMsg(e: unknown): string {
const err = e as { response?: { data?: { message?: string } } }
return err?.response?.data?.message || '验证码加载失败,请点击重试'
}
async function loadCaptcha() {
loadingCaptcha.value = true
captchaError.value = ''
try {
const res = await authApi.getCaptcha()
captchaId.value = res.data?.data?.captchaId || ''
captchaImage.value = res.data?.data?.imageBase64 || ''
} catch {
} catch (e) {
captchaId.value = ''
captchaImage.value = ''
const msg = captchaFailMsg(e)
captchaError.value = msg
message.error(msg)
} finally {
loadingCaptcha.value = false
}
@@ -155,10 +314,25 @@ async function handleLogin() {
params.inviteCode = inviteCode.value.trim()
}
const res = await authApi.login(params)
const data = res.data?.data
const body: any = res.data
// 多因子认证:专用业务码 4281(HTTP 200),弹出「选择验证方式」弹窗而非错误弹窗
if (body?.code === 4281 && body?.data?.mfaToken) {
openMFADialog(
body.data.mfaToken,
body.message,
body.data?.expiresIn || 0,
body.data?.authMethods,
body.data?.defaultType
)
return
}
const data = body?.data
if (data?.token?.accessToken) {
localStorage.setItem('access_token', data.token.accessToken)
localStorage.setItem('refresh_token', data.token.refreshToken)
// 新手引导:由 MobileLayout 按引导版本号控制展示(新老用户统一),此处无需标记
message.success('登录成功')
router.replace('/home')
} else {
@@ -167,11 +341,17 @@ async function handleLogin() {
} catch (e: any) {
const msg = e?.response?.data?.message || e?.message || '登录失败'
const httpStatus = e?.response?.status || 0
const data = e?.response?.data?.data || {}
// 多因子认证:触发 MFA,弹出「选择验证方式」Dialog(兼容旧 needMFA 标记)
if (data?.needMFA && data?.mfaToken) {
openMFADialog(data.mfaToken, msg, data?.expiresIn || 0, data?.authMethods, data?.defaultType)
return
}
// 401(密码错误等)弹出 Dialog 引导用户操作
if (httpStatus === 401) {
const url = e?.response?.data?.data?.schoolUrl || ''
schoolUrl.value = url
schoolUrl.value = data?.schoolUrl || ''
loginErrorMessage.value = msg
showLoginFailDialog.value = true
} else {
@@ -216,12 +396,17 @@ function handleSubmitTicket() {
async function loadReportCaptcha() {
reportLoadingCaptcha.value = true
reportCaptchaError.value = ''
try {
const res = await authApi.getCaptcha()
reportCaptchaId.value = res.data?.data?.captchaId || ''
reportCaptchaImage.value = res.data?.data?.imageBase64 || ''
} catch {
} catch (e) {
reportCaptchaId.value = ''
reportCaptchaImage.value = ''
const msg = captchaFailMsg(e)
reportCaptchaError.value = msg
message.error(msg)
} finally {
reportLoadingCaptcha.value = false
}
@@ -380,6 +565,9 @@ async function handleReportSubmit() {
alt="验证码"
class="max-w-full max-h-full object-contain"
/>
<div v-else-if="!loadingCaptcha && captchaError" class="w-full h-full px-1 flex items-center justify-center">
<span class="text-[10px] leading-tight text-red-400 text-center">{{ captchaError }}</span>
</div>
<div v-else class="w-full h-full flex items-center justify-center">
<RefreshCw v-if="loadingCaptcha" class="h-4 w-4 animate-spin text-gray-400" />
<span v-else class="text-xs text-gray-400">加载中</span>
@@ -411,6 +599,133 @@ async function handleReportSubmit() {
<p class="text-xs text-gray-300">copyright © 2026</p>
</div>
<!-- ======== 多因子认证(MFA)Dialog:选择验证方式 → 输入验证码 ======== -->
<Teleport to="body">
<Transition name="fade">
<div
v-if="showMFADialog"
class="fixed inset-0 z-50 flex items-center justify-center px-6"
>
<div class="absolute inset-0 bg-black/50" @click="closeMFADialog" />
<div class="relative w-full max-w-sm bg-white rounded-2xl shadow-2xl overflow-hidden">
<!-- 顶部装饰条 -->
<div class="h-1.5 bg-gradient-to-r from-blue-500 to-cyan-400" />
<div class="p-6">
<!-- 标题 -->
<div class="flex items-center gap-2.5 mb-3">
<div class="w-9 h-9 rounded-xl bg-blue-100 flex items-center justify-center">
<ShieldCheck class="w-5 h-5 text-blue-500" />
</div>
<div>
<h3 class="text-base font-bold text-gray-900">多因子认证</h3>
<p class="text-[10px] text-gray-400 mt-0.5">该校登录需校验动态验证码</p>
</div>
</div>
<!-- ===== 阶段一:选择验证方式 ===== -->
<template v-if="mfaStage === 'select'">
<div class="bg-blue-50 rounded-xl p-3.5 mb-4">
<div class="flex items-start gap-2.5">
<Smartphone class="w-4 h-4 text-blue-500 flex-shrink-0 mt-0.5" />
<p class="text-sm text-blue-700 leading-relaxed">
{{ mfaMessage || '请选择验证方式获取验证码' }}
</p>
</div>
<p class="text-[10px] text-blue-400 mt-1.5 pl-6.5">验证会话 10 分钟内有效</p>
</div>
<!-- 验证方式列表 -->
<div class="space-y-2">
<button
v-for="m in mfaMethods"
:key="m.type"
class="w-full flex items-center justify-between px-3.5 py-3 rounded-xl border text-sm transition-colors disabled:opacity-60"
:class="
mfaSelectedType === m.type
? 'border-blue-400 bg-blue-50/60 text-blue-700 font-medium'
: 'border-gray-200 text-gray-600 hover:border-blue-200 hover:bg-gray-50'
"
:disabled="mfaSending"
@click="handleMFASelect(m.type)"
>
<span>{{ m.name }}</span>
<span v-if="mfaSelectedType === m.type" class="text-blue-500 font-bold">✓</span>
</button>
</div>
<!-- 发送验证码 -->
<Button
class="mt-4 w-full h-11 text-sm font-medium rounded-xl"
:disabled="mfaSending || mfaCountdown <= 0 || !mfaSelectedType"
@click="handleMFASend"
>
<RefreshCw v-if="mfaSending" class="h-4 w-4 animate-spin" />
<template v-else-if="mfaCountdown <= 0">会话已过期,请重新登录</template>
<template v-else>发送验证码</template>
</Button>
</template>
<!-- ===== 阶段二:输入验证码 ===== -->
<template v-else>
<div class="bg-blue-50 rounded-xl p-3.5 mb-4">
<div class="flex items-start gap-2.5">
<Smartphone class="w-4 h-4 text-blue-500 flex-shrink-0 mt-0.5" />
<p class="text-sm text-blue-700 leading-relaxed">{{ mfaMessage }}</p>
</div>
<p class="text-[10px] text-blue-400 mt-1.5 pl-6.5">验证会话 10 分钟内有效,输错可重新输入</p>
</div>
<!-- 验证码输入 -->
<div>
<Label class="mb-1.5 block text-xs font-medium text-gray-500">验证码</Label>
<div class="relative">
<ShieldCheck class="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-gray-400" />
<Input
v-model="mfaCode"
placeholder="请输入收到的验证码"
class="pl-9 pr-9 h-10 text-sm tracking-widest"
maxlength="8"
@keyup.enter="handleMFAVerify"
/>
<span v-if="mfaCountdown > 0" class="absolute right-3 top-1/2 -translate-y-1/2 text-[10px] text-gray-300 tabular-nums">
{{ Math.floor(mfaCountdown / 60) }}:{{ String(mfaCountdown % 60).padStart(2, '0') }}
</span>
</div>
</div>
<!-- 提交按钮 -->
<Button
class="mt-4 w-full h-11 text-sm font-medium rounded-xl"
:disabled="mfaSubmitting || mfaCountdown <= 0"
@click="handleMFAVerify"
>
<RefreshCw v-if="mfaSubmitting" class="h-4 w-4 animate-spin" />
<template v-else-if="mfaCountdown <= 0">会话已过期,请重新登录</template>
<template v-else>验证并登录</template>
</Button>
<!-- 更换验证方式 -->
<button
class="mt-2.5 w-full text-center text-xs text-blue-400 hover:text-blue-600 transition-colors"
@click="handleMFAReselect"
>
更换验证方式
</button>
</template>
<!-- 取消 -->
<button
class="mt-2.5 w-full text-center text-xs text-gray-400 hover:text-gray-600 transition-colors"
@click="closeMFADialog"
>
重新登录
</button>
</div>
</div>
</div>
</Transition>
</Teleport>
<!-- ======== 登录失败 Dialog ======== -->
<Teleport to="body">
<Transition name="fade">
@@ -449,7 +764,7 @@ async function handleReportSubmit() {
</div>
<div class="flex-1 min-w-0">
<p class="text-sm font-medium text-gray-900">忘记密码?</p>
<p class="text-xs text-gray-500 mt-0.5">进入学校登录地址自行找回</p>
<p class="text-xs text-gray-500 mt-0.5">点击将会打开学校官方登录地址,您可以在官方地址测试登录或者找回密码,然后回到本网站继续登录</p>
</div>
</div>
@@ -563,6 +878,9 @@ async function handleReportSubmit() {
alt="验证码"
class="max-w-full max-h-full object-contain"
/>
<div v-else-if="!reportLoadingCaptcha && reportCaptchaError" class="w-full h-full px-1 flex items-center justify-center">
<span class="text-[10px] leading-tight text-red-400 text-center">{{ reportCaptchaError }}</span>
</div>
<div v-else class="w-full h-full flex items-center justify-center">
<RefreshCw v-if="reportLoadingCaptcha" class="h-4 w-4 animate-spin text-gray-400" />
<span v-else class="text-xs text-gray-400">加载中</span>
+314 -60
View File
@@ -1,12 +1,12 @@
<script setup lang="ts">
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 { 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'
import * as authApi from '@/api/auth'
import type { UserInfo } from '@/api/auth'
import type { UserInfo, MyInviteInfo } from '@/api/auth'
const router = useRouter()
const message = useMessage()
@@ -20,6 +20,29 @@ const invitedCount = ref(0)
const canRedeem = ref(false)
const generatingInvite = ref(false)
const redeemingInvite = ref(false)
// 邀请奖励规则(来自系统配置,非写死)
const inviterRewardPerInvite = ref<number | null>(null)
const inviteeRewardPerInvite = ref<number | null>(null)
// 格式化奖励数字:整数不带小数点(3 → "3"),小数保留原样(0.5 → "0.5")
function fmtReward(v: number | null): string {
if (v === null) return ''
const n = Math.round(v * 10) / 10
return Number.isInteger(n) ? String(n) : n.toFixed(1)
}
function applyInviteInfo(data: MyInviteInfo) {
inviteCode.value = data.inviteCode
inviteReward.value = data.inviteReward
invitedCount.value = data.invitedCount
canRedeem.value = data.canRedeem
if (typeof data.inviterRewardPerInvite === 'number') {
inviterRewardPerInvite.value = data.inviterRewardPerInvite
}
if (typeof data.inviteeRewardPerInvite === 'number') {
inviteeRewardPerInvite.value = data.inviteeRewardPerInvite
}
}
// 邀请链接 URL
const inviteUrl = computed(() => {
@@ -33,18 +56,41 @@ const showRedeemModal = ref(false)
const redeemCodeText = ref('')
const redeeming = ref(false)
const redeemError = ref('')
// 弹窗打开来源:count=兑换次数 / vip=开通或续费VIP(仅影响标题文案)
const redeemKind = ref<'count' | 'vip'>('count')
// VIP 信息
const isVip = ref(false)
const vipExpireAt = ref('')
// 验证码
const captchaId = ref('')
const captchaImage = ref('')
const captchaCode = ref('')
const captchaError = ref('')
// VIP 到期时间短格式(2026-10-08)
const vipExpireShort = computed(() => {
if (!vipExpireAt.value) return ''
return vipExpireAt.value.slice(0, 10)
})
// 从 axios 错误中提取后端提示文案(429 限流/服务端错误等),失败兜底通用文案
function captchaFailMsg(e: unknown): string {
const err = e as { response?: { data?: { message?: string } } }
return err?.response?.data?.message || '验证码加载失败,请点击重试'
}
async function refreshCaptcha() {
captchaError.value = ''
try {
const res = await authApi.getCaptcha()
captchaId.value = res.data?.data?.captchaId || ''
captchaImage.value = res.data?.data?.imageBase64 || ''
} catch {
// ignore
} catch (e) {
captchaId.value = ''
captchaImage.value = ''
const msg = captchaFailMsg(e)
captchaError.value = msg
message.error(msg)
}
}
@@ -62,16 +108,21 @@ onMounted(async () => {
// 加载邀请信息
try {
const inviteRes = await authApi.getMyInviteInfo()
inviteCode.value = inviteRes.data.data.inviteCode
inviteReward.value = inviteRes.data.data.inviteReward
invitedCount.value = inviteRes.data.data.invitedCount
canRedeem.value = inviteRes.data.data.canRedeem
applyInviteInfo(inviteRes.data.data)
} catch {
// invite info not yet generated
}
} catch {
message.error('获取用户信息失败')
}
// 加载 VIP 信息
try {
const vipRes = await authApi.getVipInfo()
isVip.value = vipRes.data.data.isVip
vipExpireAt.value = vipRes.data.data.vipExpireAt
} catch {
// VIP 信息加载失败不阻塞页面
}
})
function handleLogout() {
@@ -84,8 +135,43 @@ 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() {
function openRedeemModal(kind: 'count' | 'vip' = 'count') {
redeemKind.value = kind
showRedeemModal.value = true
redeemCodeText.value = ''
redeemError.value = ''
@@ -97,6 +183,17 @@ function closeRedeemModal() {
showRedeemModal.value = false
}
// 刷新 VIP 信息(兑换 VIP CDK 后调用)
async function reloadVipInfo() {
try {
const vipRes = await authApi.getVipInfo()
isVip.value = vipRes.data.data.isVip
vipExpireAt.value = vipRes.data.data.vipExpireAt
} catch {
// ignore
}
}
async function handleRedeem() {
if (!redeemCodeText.value.trim()) {
redeemError.value = '请输入兑换码'
@@ -111,12 +208,19 @@ async function handleRedeem() {
redeeming.value = true
try {
const res = await authApi.redeemCDK(redeemCodeText.value.trim(), captchaId.value, captchaCode.value.trim())
// 一次性消耗全部剩余次数
const totalRuns = res.data.data.totalRuns
if (userInfo.value) {
userInfo.value.signCount = (userInfo.value.signCount || 0) + totalRuns
const data = res.data.data
if (data.type === 'vip') {
// VIP CDK:延长 VIP 到期时间
await reloadVipInfo()
message.success(`VIP 兑换成功!已延长至 ${data.vipExpireAt || ''}`)
} else {
// 次数 CDK:一次性消耗全部剩余次数
const totalRuns = data.totalRuns ?? 0
if (userInfo.value) {
userInfo.value.signCount = (userInfo.value.signCount || 0) + totalRuns
}
message.success(`兑换成功!获得 ${totalRuns} 次签到次数`)
}
message.success(`兑换成功!获得 ${totalRuns} 次签到次数`)
closeRedeemModal()
} catch (err: any) {
redeemError.value = err.response?.data?.message || '兑换失败'
@@ -130,10 +234,7 @@ async function handleGenerateInvite() {
generatingInvite.value = true
try {
const res = await authApi.getMyInvite()
inviteCode.value = res.data.data.inviteCode
inviteReward.value = res.data.data.inviteReward
invitedCount.value = res.data.data.invitedCount
canRedeem.value = res.data.data.canRedeem
applyInviteInfo(res.data.data)
} catch {
message.error('生成邀请码失败')
} finally {
@@ -192,12 +293,34 @@ async function handleRedeemInvite() {
<div class="flex flex-col min-h-full">
<!-- 顶部大蓝色卡片:个人基础信息撑满上半部 -->
<div class="bg-gradient-to-r from-blue-500 to-blue-600 px-6 pt-10 pb-14 text-white text-center flex-shrink-0">
<div class="w-20 h-20 rounded-full bg-white/20 flex items-center justify-center mx-auto mb-4 overflow-hidden ring-4 ring-white/30">
<img v-show="userInfo?.avatar && !avatarFailed" :src="userInfo?.avatar" class="w-full h-full object-cover" @error="avatarFailed = true" />
<span v-if="!userInfo?.avatar || avatarFailed" class="text-3xl font-bold">{{ userInfo?.realName?.[0] || userInfo?.username?.[0] || '?' }}</span>
<!-- 头像 + VIP 徽标(头像右上角,反光动画) -->
<div class="relative w-20 h-20 rounded-full bg-white/20 flex items-center justify-center mx-auto mb-4 overflow-visible ring-4 ring-white/30">
<div class="w-full h-full rounded-full overflow-hidden flex items-center justify-center">
<img v-show="userInfo?.avatar && !avatarFailed" :src="userInfo?.avatar" class="w-full h-full object-cover" @error="avatarFailed = true" />
<span v-if="!userInfo?.avatar || avatarFailed" class="text-3xl font-bold">{{ userInfo?.realName?.[0] || userInfo?.username?.[0] || '?' }}</span>
</div>
<div v-if="isVip" class="vip-badge absolute -right-3 -top-1.5 z-10 whitespace-nowrap">VIP</div>
</div>
<p class="text-xl font-bold">{{ userInfo?.realName || userInfo?.username || '--' }}</p>
<p class="text-sm text-white/80 mt-1">{{ userInfo?.schoolName || '--' }}</p>
<!-- VIP 状态行:到期时间 + 开通/续费按钮 -->
<div class="mt-3 flex items-center justify-center gap-2">
<span
v-if="isVip"
class="text-xs bg-white/20 rounded-full px-3 py-1.5 text-white/95"
>
{{ vipExpireShort ? `VIP 至 ${vipExpireShort}` : 'VIP 会员' }}
</span>
<span v-else class="text-xs bg-white/20 rounded-full px-3 py-1.5 text-white/95">未开通 VIP</span>
<button
data-guide="profile-vip-btn"
class="vip-btn text-xs font-semibold px-3.5 py-1.5 rounded-full text-amber-900 shadow"
@click="openRedeemModal('vip')"
>
续费 VIP
</button>
</div>
</div>
<!-- 详情列表 -->
@@ -210,13 +333,13 @@ async function handleRedeemInvite() {
<ClipboardCheck class="w-4 h-4 text-green-500" />
</div>
<div>
<p class="text-xs text-gray-400">可签到次数</p>
<p class="text-sm font-medium text-gray-900">{{ (userInfo?.signCount ?? 0) }} 次</p>
<p class="text-xs text-gray-400">{{ isVip ? 'VIP 签到次数' : '可签到次数' }}</p>
<p class="text-sm font-medium text-gray-900">{{ isVip ? '无限次' : `${userInfo?.signCount ?? 0} 次` }}</p>
</div>
</div>
<button
class="flex items-center gap-1 px-3 py-1.5 rounded-full bg-gradient-to-r from-amber-400 to-orange-500 text-white text-xs font-medium shadow-sm hover:brightness-105 transition-all"
@click="openRedeemModal"
@click="openRedeemModal('count')"
>
<Gift class="w-3 h-3" />
兑换
@@ -234,16 +357,22 @@ async function handleRedeemInvite() {
<div class="flex items-center gap-1.5">
<code class="font-mono text-sm text-purple-600 bg-purple-50 px-2 py-1 rounded font-bold tracking-wider">{{ inviteCode }}</code>
<button
data-guide="profile-invite-copy"
class="flex-shrink-0 flex items-center gap-1 px-2 py-1 rounded bg-purple-100 text-purple-600 text-xs font-medium hover:bg-purple-200 transition-colors"
@click="copyInviteUrl"
>
复制
</button>
</div>
<p class="text-[10px] text-gray-400 mt-1 leading-relaxed">邀请好友可获得奖励积分,满 1 积分可兑换 1 次签到次数</p>
<p v-if="inviterRewardPerInvite !== null" class="text-[10px] text-gray-400 mt-1 leading-relaxed">
好友通过你的链接注册登录,你可得 <span class="text-amber-500 font-medium">{{ fmtReward(inviterRewardPerInvite) }}</span> 积分、TA 得
<span class="text-amber-500 font-medium">{{ fmtReward(inviteeRewardPerInvite) }}</span> 积分;满 1 积分可兑换 1 次签到次数
</p>
<p v-else class="text-[10px] text-gray-400 mt-1 leading-relaxed">邀请好友注册可获得奖励积分,满 1 积分可兑换 1 次签到次数</p>
</div>
<div v-else class="mt-1">
<button
data-guide="profile-invite-generate"
class="inline-flex items-center gap-1 px-2.5 py-1 rounded bg-purple-500 text-white text-xs font-medium hover:bg-purple-600 transition-colors"
:disabled="generatingInvite"
@click="handleGenerateInvite"
@@ -256,7 +385,7 @@ async function handleRedeemInvite() {
</div>
</div>
<!-- 邀请信息 + 兑换按钮 -->
<div class="flex items-center justify-between px-4 py-3.5">
<div data-guide="profile-invite-row" class="flex items-center justify-between px-4 py-3.5">
<div class="flex items-center gap-3">
<div class="w-8 h-8 rounded-full bg-amber-50 flex items-center justify-center">
<Gift class="w-4 h-4 text-amber-500" />
@@ -271,6 +400,7 @@ async function handleRedeemInvite() {
</div>
<button
v-if="inviteCode"
data-guide="profile-invite-redeem"
class="flex items-center gap-1 px-3 py-1.5 rounded-full text-xs font-medium transition-colors"
:class="canRedeem ? 'bg-amber-400 text-white hover:brightness-105 shadow-sm' : 'bg-gray-200 text-gray-400 cursor-not-allowed'"
:disabled="!canRedeem || redeemingInvite"
@@ -298,32 +428,9 @@ async function handleRedeemInvite() {
<path stroke-linecap="round" stroke-linejoin="round" d="M9 5l7 7-7 7" />
</svg>
</div>
<!-- 真实姓名 -->
<div class="flex items-center justify-between px-4 py-3.5">
<div class="flex items-center gap-3">
<div class="w-8 h-8 rounded-full bg-blue-50 flex items-center justify-center">
<User class="w-4 h-4 text-blue-500" />
</div>
<div>
<p class="text-xs text-gray-400">真实姓名</p>
<p class="text-sm font-medium text-gray-900">{{ userInfo?.realName || '--' }}</p>
</div>
</div>
</div>
<!-- 学号 -->
<div class="flex items-center justify-between px-4 py-3.5">
<div class="flex items-center gap-3">
<div class="w-8 h-8 rounded-full bg-blue-50 flex items-center justify-center">
<IdCard class="w-4 h-4 text-blue-500" />
</div>
<div>
<p class="text-xs text-gray-400">学号</p>
<p class="text-sm font-medium text-gray-900">{{ userInfo?.studentId || '--' }}</p>
</div>
</div>
</div>
<!-- 真实姓名/学号已取消展示:避免在页面明文暴露敏感信息 -->
<!-- 设备码 -->
<div class="flex items-center justify-between px-4 py-3.5">
<div data-guide="profile-device-code" class="flex items-center justify-between px-4 py-3.5">
<div class="flex items-center gap-3">
<div class="w-8 h-8 rounded-full bg-indigo-50 flex items-center justify-center">
<svg class="w-4 h-4 text-indigo-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
@@ -337,13 +444,22 @@ async function handleRedeemInvite() {
</p>
</div>
</div>
<button
class="text-xs text-blue-500 flex items-center gap-1 hover:text-blue-600"
@click="goRebind"
>
<RefreshCw class="w-3 h-3" />
重新绑定
</button>
<div class="flex items-center gap-2">
<button
class="text-xs text-amber-600 flex items-center gap-1 hover:text-amber-700"
@click="openRandomBind"
>
<Dices class="w-3 h-3" />
随机
</button>
<button
class="text-xs text-blue-500 flex items-center gap-1 hover:text-blue-600"
@click="goRebind"
>
<RefreshCw class="w-3 h-3" />
重新绑定
</button>
</div>
</div>
</div>
@@ -368,7 +484,9 @@ async function handleRedeemInvite() {
<div class="bg-white rounded-2xl shadow-xl w-full max-w-sm overflow-hidden">
<!-- 弹窗头部 -->
<div class="flex items-center justify-between px-5 pt-5 pb-2">
<h3 class="text-base font-semibold text-gray-900">兑换签到次数</h3>
<h3 class="text-base font-semibold text-gray-900">
{{ redeemKind === 'vip' ? '续费 VIP' : '兑换签到次数' }}
</h3>
<button class="w-7 h-7 rounded-full bg-gray-100 flex items-center justify-center text-gray-400 hover:text-gray-600" @click="closeRedeemModal">
<X class="w-4 h-4" />
</button>
@@ -378,7 +496,7 @@ async function handleRedeemInvite() {
<div class="px-5 pb-5 space-y-4">
<!-- 兑换码输入 -->
<div>
<label class="block text-xs font-medium text-gray-500 mb-1.5">兑换码</label>
<label class="block text-xs font-medium text-gray-500 mb-1.5">{{ redeemKind === 'vip' ? 'VIP 兑换码' : '兑换码' }}</label>
<Input
v-model="redeemCodeText"
placeholder="请输入 CDK 兑换码"
@@ -404,6 +522,14 @@ async function handleRedeemInvite() {
@click="refreshCaptcha"
title="点击刷新验证码"
/>
<div
v-else-if="captchaError"
class="h-10 w-24 rounded-lg border border-red-200 bg-red-50 flex items-center justify-center text-[10px] leading-tight text-red-400 text-center px-1 cursor-pointer"
title="点击重试"
@click="refreshCaptcha"
>
{{ captchaError }}
</div>
<div v-else class="h-10 w-24 rounded-lg border border-gray-200 bg-gray-50 flex items-center justify-center text-xs text-gray-400">
加载中
</div>
@@ -425,6 +551,66 @@ async function handleRedeemInvite() {
</div>
</Transition>
</Teleport>
<!-- 随机绑定设备码:连续两次风险确认弹窗 -->
<Teleport to="body">
<div v-if="randBindStep > 0" class="fixed inset-0 z-50 flex items-center justify-center bg-black/40 px-4" @click.self="closeRandomBind">
<div class="bg-white rounded-2xl shadow-xl w-full max-w-sm overflow-hidden">
<!-- 第一次确认 -->
<template v-if="randBindStep === 1">
<div class="px-5 pt-5 pb-2 flex items-center justify-between">
<h3 class="text-base font-semibold text-gray-900">随机生成设备码</h3>
<button class="w-7 h-7 rounded-full bg-gray-100 flex items-center justify-center text-gray-400 hover:text-gray-600" @click="closeRandomBind">
<X class="w-4 h-4" />
</button>
</div>
<div class="px-5 pb-5">
<div class="flex items-start gap-2.5 bg-amber-50 border border-amber-200 rounded-lg p-3">
<TriangleAlert class="w-4 h-4 text-amber-500 flex-shrink-0 mt-0.5" />
<p class="text-xs text-amber-700 leading-relaxed">
随机设备码与你真实手机不一致。若你已有绑定的手机在用本系统,随机后该手机执行签到时会出现
<strong>「频繁切换设备」提示,无法正常签到</strong>。
</p>
</div>
<p class="text-xs text-gray-400 mt-3">仅建议目前依旧因为其他人帮忙打卡/其他设备打卡导致已经出现「频繁切换设备」提示的用户使用。</p>
<div class="flex gap-2 mt-4">
<Button variant="outline" class="flex-1 h-10 text-sm" @click="closeRandomBind">取消</Button>
<Button class="flex-1 h-10 text-sm font-medium" @click="confirmRandomBind">继续</Button>
</div>
</div>
</template>
<!-- 第二次确认 -->
<template v-else>
<div class="px-5 pt-5 pb-2 flex items-center justify-between">
<h3 class="text-base font-semibold text-red-600">再次确认:确定随机?</h3>
<button class="w-7 h-7 rounded-full bg-gray-100 flex items-center justify-center text-gray-400 hover:text-gray-600" @click="closeRandomBind">
<X class="w-4 h-4" />
</button>
</div>
<div class="px-5 pb-5">
<p class="text-sm text-gray-600 leading-relaxed">
随机后,你现有手机登录 App 执行签到时将提示
<strong class="text-red-500">「频繁切换设备」而无法正常签到</strong>,
需要重新扫码授权绑定才能恢复。
</p>
<p class="text-xs text-gray-400 mt-2">这是最后一次确认,确定要随机生成设备码吗?</p>
<div class="flex gap-2 mt-4">
<Button variant="outline" class="flex-1 h-10 text-sm" @click="closeRandomBind">取消</Button>
<Button
class="flex-1 h-10 text-sm font-medium bg-red-500 hover:bg-red-600"
:disabled="randBinding"
@click="confirmRandomBind"
>
<RefreshCw v-if="randBinding" class="h-4 w-4 animate-spin" />
{{ randBinding ? '绑定中...' : '确定随机' }}
</Button>
</div>
</div>
</template>
</div>
</div>
</Teleport>
</div>
</template>
@@ -437,4 +623,72 @@ async function handleRedeemInvite() {
.fade-leave-to {
opacity: 0;
}
/* VIP 徽标:金色渐变 + 反光条扫过动画
注意:不可设置 position,徽标由模板上的 Tailwind absolute 类控制浮层定位,
若此处声明 position:relative 会覆盖 absolute,导致徽标挤进头像布局使其变形 */
.vip-badge {
overflow: hidden;
padding: 2px 8px;
border-radius: 999px;
font-size: 11px;
font-weight: 800;
font-style: italic;
letter-spacing: 0.5px;
color: #5b3a00;
background: linear-gradient(135deg, #ffe9a8 0%, #f6c445 45%, #e8a020 100%);
border: 1px solid rgba(255, 255, 255, 0.65);
box-shadow: 0 2px 6px rgba(160, 100, 0, 0.35);
text-shadow: 0 1px 0 rgba(255, 255, 255, 0.45);
}
.vip-badge::after {
content: '';
position: absolute;
top: -60%;
left: -45%;
width: 38%;
height: 220%;
background: linear-gradient(
100deg,
transparent 0%,
rgba(255, 255, 255, 0.85) 50%,
transparent 100%
);
transform: rotate(18deg);
animation: vip-shine 2.4s ease-in-out infinite;
pointer-events: none;
}
@keyframes vip-shine {
0% {
left: -45%;
}
55%,
100% {
left: 135%;
}
}
/* VIP 开通/续费按钮:金色渐变 + 周期性微光扫过 */
.vip-btn {
position: relative;
overflow: hidden;
background: linear-gradient(135deg, #ffe9a8 0%, #f6c445 45%, #e8a020 100%);
}
.vip-btn::after {
content: '';
position: absolute;
top: -60%;
left: -45%;
width: 30%;
height: 220%;
background: linear-gradient(
100deg,
transparent 0%,
rgba(255, 255, 255, 0.7) 50%,
transparent 100%
);
transform: rotate(18deg);
animation: vip-shine 3s ease-in-out infinite;
pointer-events: none;
}
</style>