Compare commits

...

27 Commits

Author SHA1 Message Date
becomer 021ac7f7bd v1.2.49:请假审批人「候选为空可不选」+ 新增抄送人(非必填,候选取自审批人列表)——① 审批人校验放宽(实测驱动):ysdwl「大四学生请假」needApproval=1 但审批节点(政工队伍应用管理员)未配置人员,obtainApproveFlow 返回空列表,旧逻辑强制必选导致用户无法提交;现前端在候选为空时不显示必选星号、提示「该校未配置审批人,可不选」,校验仅在有候选时要求选择;后端 execute 校验同步改为「needApproval=1 且候选非空才必填」(服务端自行查候选,避免前端被绕过),候选为空时记录日志放行;② 抄送人功能:ccInfo 从硬编码空结构改为按用户选择构造(BuildLeaveCcInfo),按 userWid 去重、按 userId 是否纯数字分组 teachers/students(personType 0/1,官方结构含 name2Id/dept/sex/teacherName 字段);前端抄送人区显示条件与 needApproval 解耦(候选同源 obtainApproveFlow,类型+时间齐全即加载),蓝色复选框多选、非必填,类型/时间变化后自动清理失效选择;③ 静默降级:候选加载失败不再弹错(避免每次切换类型红字打扰),需审批时提交由后端复核并给明确提示;④ 新增 TestBuildLeaveCcInfo 覆盖学生/教师分组、空列表、表单携带;go test ./...、go vet、vue-tsc、npm run build 全过
ci/woodpecker/tag/build Pipeline was successful
2026-09-14 14:47:26 +08:00
becomer b75e1f9bd9 v1.2.48:请/销假入口卡片副标题改为「异地一键请假销假,自行设置位置」——原文案「提交请假申请,或返校后一键销假」仅描述操作流程,未体现可异地执行与手动指定位置的核心能力;新文案同时覆盖请假与销假两个按钮(位置在按钮上方,两按钮共用),突出「异地」与「自行设置位置」卖点。vue-tsc、npm run build 通过
ci/woodpecker/tag/build Pipeline was successful
2026-09-14 12:57:22 +08:00
becomer c41176af8d v1.2.47:请/销假页新增「无法请/销假?提交工单」贴边按钮 + 核对请/销假扣费与打卡一致——① 贴边按钮:位置/样式/交互对齐打卡页「收藏本站」竖条(fixed right-0 bottom-32、图标在上竖排文字在下、rounded-l-md、active:scale-95),配色改用玫红(与请/销假页主色一致),点击 router.push('/ticket') 跳转工单列表页,便于用户在无法请/销假时快速反馈;② 扣费核对(本次无代码改动,仅确认):请假/销假与打卡执行三处逻辑完全一致——VIP 未过期免扣,非 VIP 执行前检查 sign_count<=0 则拒绝并返回「可用次数不足」提示,成功后以 WHERE sign_count > 0 原子扣减 1 次防并发超卖;限流三档规则亦相同(2m×3 / 1h×6 / 24h×10),差异仅在限流键维度(打卡按用户+任务、请假/销假按用户,因其无任务维度);③ vue-tsc、npm run build 通过
ci/woodpecker/tag/build Pipeline was successful
2026-09-14 12:39:46 +08:00
becomer 26c5115a32 v1.2.46:打卡页文案统一为「打卡」口径 + 请/销假页顶部补齐用户信息栏——① 文案:打卡页「签到管理」→「打卡管理」、「签到任务」→「打卡任务」、「执行日志」→「打卡日志」、「新的签到任务」→「新的打卡任务」(含任务列表空态、日志空态;同步修正新手引导中指向该按钮的文案「+ 新的签到任务」→「+ 新的打卡任务」,避免引导指向不存在的按钮);② 用户栏:MobileLayout 顶部用户信息栏(头像/姓名/学校/公告铃铛)展示条件由仅首页扩展为首页与请/销假页,两页视觉一致(同一组件,无重复实现);③ 说明:页面内功能性文案(如「编辑签到信息」「执行签到」「范围外签到原因」等描述具体动作的词)保持不变,仅统一入口与列表标题口径。vue-tsc 与 npm run build 通过
ci/woodpecker/tag/build Pipeline was successful
2026-09-14 12:21:58 +08:00
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
58 changed files with 7706 additions and 405 deletions
+14
View File
@@ -63,6 +63,8 @@ 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)
@@ -86,6 +88,18 @@ 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)
+2
View File
@@ -19,6 +19,8 @@ dbname = "xiaozhushou"
secret = "dev-only-change-me-please"
accessExpire = 3600
refreshExpire = 864000
# MFA 通道登录短期 token 有效期(秒):与第三方 Cookie 对齐,默认 7200(2 小时)
mfaAccessExpire = 7200
[captcha]
width = 150
+7 -6
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
)
@@ -21,6 +22,7 @@ require (
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
@@ -28,13 +30,12 @@ require (
github.com/mattn/go-colorable v0.1.13 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-runewidth v0.0.16 // indirect
github.com/redis/go-redis/v9 v9.7.0 // 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
)
+19 -6
View File
@@ -8,6 +8,10 @@ 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=
@@ -29,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=
@@ -36,12 +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=
@@ -57,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=
@@ -93,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=
@@ -113,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=
@@ -123,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.
+8
View File
@@ -47,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 {
@@ -87,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)
}
+3
View File
@@ -35,6 +35,7 @@ type SysUserListItem struct {
Status string `json:"status"`
CreatedAt string `json:"createdAt"`
LastLoginAt string `json:"lastLoginAt"`
LastLoginIP string `json:"lastLoginIp"`
}
// inviterDisplay 邀请人展示名:姓名优先,其次用户名
@@ -209,6 +210,7 @@ func ListUsers(c *fiber.Ctx) error {
Status: u.Status,
CreatedAt: u.CreatedAt.Format("2006-01-02 15:04:05"),
LastLoginAt: lastLogin,
LastLoginIP: u.LastLoginIP,
})
}
@@ -291,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,
})
}
+235 -7
View File
@@ -1,14 +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"
@@ -87,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{
@@ -99,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 {
@@ -121,10 +232,10 @@ func Login(c *fiber.Ctx) error {
cookieStr := cpd.SerializeCookies(cookies)
// 构建用户名: 学校简称 + 学号
userName := buildUsername(school, req.Username)
userName := buildUsername(school, username)
// 查找或创建用户
user, isNew, 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, "用户处理失败")
@@ -143,23 +254,29 @@ func Login(c *fiber.Ctx) error {
})
}
// 更新 cookie 和最后登录时间
// 更新 cookie 和最后登录时间(含登录 IP)
now := time.Now()
database.DB.Model(&user).Updates(map[string]interface{}{
"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失败")
}
@@ -564,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
+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)
}
+369
View File
@@ -0,0 +1,369 @@
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"` // 审批人姓名
CcUsers []cpd.LeaveApprover `json:"ccUsers"` // 抄送人(非必填,候选取自审批人列表)
}
// 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
}
// 审批人:needApproval=1 且学校配置了候选审批人时必选;
// 候选列表为空(学校未配置审批节点人员,如 ysdwl「大四学生请假」)时允许不选,跳过即可
if cfg.NeedApproval == 1 && req.ApproverWid == "" {
candidates, err := cpd.FetchReportApprovers(cookieMap, host, req.LeaveType, req.StartTime, req.EndTime)
if err != nil {
errMsg := "获取请假审批人失败: " + err.Error()
slog.Warn("校验审批人候选失败", "userId", userID, "error", err)
saveLog(false, errMsg)
sendEvent("task_error", map[string]interface{}{"status": "error", "message": errMsg})
return
}
if len(candidates) > 0 {
errMsg := "该请假类型需要选择审批人"
saveLog(false, errMsg)
sendEvent("task_error", map[string]interface{}{"status": "error", "message": errMsg, "needApprover": true})
return
}
slog.Info("该类型无候选审批人,允许不选提交", "userId", userID, "leaveType", req.LeaveType)
}
// 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,
CcUsers: req.CcUsers,
}
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
}
+12 -1
View File
@@ -528,7 +528,18 @@ 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(用户上传失败保存的表单/历史遗留数据)
+8 -4
View File
@@ -15,13 +15,17 @@ import (
// 用户当天在用平台也不会真正重新登录,后台“当天登录人数”会因此偏少。
// 这里在认证通过后做一次条件更新:仅当 last_login_at 为空或还停留在昨天及以前时才写入今天,
// 即每个用户每天最多产生一次真实写库;并发请求重复触发也只会写同一天,其余请求仅做主键定位、零写入。
// 写入当天登录时间的同时同步记录该次访问的客户端 IP(utils.ClientIP),供后台列表展示来源。
// 当天起点用 Go 本地时区(与后台统计的 “YYYY-MM-DD 00:00:00” 口径一致)。
func touchDailyLogin(userID uint) {
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).
Update("last_login_at", now)
Updates(map[string]interface{}{
"last_login_at": now,
"last_login_ip": utils.ClientIP(c),
})
}
func AuthRequired() fiber.Handler {
@@ -54,8 +58,8 @@ func AuthRequired() fiber.Handler {
c.Locals("userId", claims.UserID)
c.Locals("username", claims.Username)
c.Locals("schoolId", claims.SchoolID)
// 当天首次使用平台时把 last_login_at 顶到今天(详见 touchDailyLogin 注释)
touchDailyLogin(claims.UserID)
// 当天首次使用平台时把 last_login_at 顶到今天并记录 IP(详见 touchDailyLogin 注释)
touchDailyLogin(c, claims.UserID)
return c.Next()
}
}
+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" // 销假
)
+3
View File
@@ -56,6 +56,9 @@ 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"`
+300 -272
View File
@@ -3,6 +3,7 @@ package service
import (
"bytes"
"context"
"cpdaily-new/internal/assets"
"cpdaily-new/internal/config"
"encoding/base64"
"fmt"
@@ -11,12 +12,16 @@ import (
"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 (
@@ -28,6 +33,9 @@ const (
captchaKeyFmt = "captcha:%s" // 验证码内容
captchaRLKey = "captcha:rl:%s"
// 验证码四周统一留白(px):文字、干扰线、装饰全部限制在该留白区内
captchaMargin = 15
// 令牌桶:同 IP 5 分钟最多获取 10 次(桶容量 10,速率 10/5min = 每 30s 回补 1 个)
// 容忍共享出口 IP(宿舍/校园网多人)短时突发,又能约束爆破频率
captchaBucketCapacity = 10
@@ -100,6 +108,39 @@ type CaptchaResult struct {
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}, // 深粉红
@@ -126,6 +167,22 @@ 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"
@@ -136,105 +193,102 @@ func generateCode(length int) string {
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
}
// 随机马卡龙色背景
// 生成图片
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++ {
// ── 四周统一留白 15px:所有内容元素均限制在留白区内 ──
m := captchaMargin
ix0, iy0 := m, m
ix1, iy1 := w-m, h-m
boxW, boxH := ix1-ix0, iy1-iy0
// ── 背景装饰:白色半透明大圆(软糖气泡感)──
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 < 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)
}
// ── 字符: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))]
col.A = uint8(rand.Intn(80) + 150) // alpha 150~230
x1 := rand.Intn(cfg.Width)
y1 := rand.Intn(cfg.Height)
x2 := rand.Intn(cfg.Width)
y2 := rand.Intn(cfg.Height)
thick := rand.Intn(2) + 2 // 2~3px 粗
drawThickLine(img, x1, y1, x2, y2, col, thick)
angle := rand.Float64()*20 - 10 // -10° ~ +10°
drawStickerChar(img, face, centerX, centerY, string(c), col, angle)
}
// ── 二次元风格彩色折线 ──
for i := 0; i < 6; i++ {
col := animeColors[rand.Intn(len(animeColors))]
col.A = uint8(rand.Intn(60) + 130)
x1 := rand.Intn(cfg.Width)
y1 := rand.Intn(cfg.Height)
xm := rand.Intn(cfg.Width)
ym := rand.Intn(cfg.Height)
x2 := rand.Intn(cfg.Width)
y2 := rand.Intn(cfg.Height)
drawThickLine(img, x1, y1, xm, ym, col, 2)
drawThickLine(img, xm, ym, x2, y2, col, 2)
// 少量柔和彩色噪点(半透明小点)
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 < 4; i++ {
cx := rand.Intn(cfg.Width)
cy := rand.Intn(cfg.Height)
radius := rand.Intn(5) + 3
col := animeColors[rand.Intn(len(animeColors))]
col.A = 40
drawCircle(img, cx, cy, radius, col)
}
// 小星星/闪光装饰点
for i := 0; i < 6; i++ {
x := rand.Intn(cfg.Width)
y := rand.Intn(cfg.Height)
starCol := animeColors[rand.Intn(len(animeColors))]
starCol.A = 100
drawStarDot(img, x, y, starCol)
}
// 彩色噪点
for i := 0; i < 100; i++ {
x := rand.Intn(cfg.Width)
y := rand.Intn(cfg.Height)
col := animeColors[rand.Intn(len(animeColors))]
col.A = uint8(rand.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 += rand.Intn(5) - 2
charY += rand.Intn(3) - 1
// 每个字符选择鲜艳的二次元颜色
charColor := animeColors[rand.Intn(len(animeColors))]
// 随机倾斜程度 (shear: 每行偏移像素数)
shear := rand.Intn(2) + 1 // 1~2
// 1/3 概率向右倾斜,2/3 向左倾斜
if rand.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
@@ -340,50 +394,183 @@ func FormatWait(d time.Duration) string {
}
}
// 画粗线(绘制多条平行线模拟粗线)
func drawThickLine(img *image.RGBA, x1, y1, x2, y2 int, col color.Color, thickness int) {
// 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++ {
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)
}
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)
}
}
}
// 划线(单像素)
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 {
// 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
}
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)
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 drawCircle(img *image.RGBA, cx, cy, radius int, col color.Color) {
for dy := -radius; dy <= radius; dy++ {
@@ -399,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
}
+6
View File
@@ -30,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:
@@ -142,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:
+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,330 @@
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 // 审批人姓名
CcUsers []LeaveApprover // 抄送人(非必填;候选取自审批人列表)
}
// BuildLeaveCcInfo 构造抄送人信息(ccInfo)。
// 官方结构:{totalSize, teachers:[], students:[]},人员含 personType(0=老师 1=学生)。
// 审批人接口不返回 personType,按 userId 是否纯数字推断(学号纯数字,教师工号常含字母)。
func BuildLeaveCcInfo(users []LeaveApprover) map[string]interface{} {
teachers := make([]interface{}, 0, len(users))
students := make([]interface{}, 0, len(users))
for _, u := range users {
if u.UserWid == "" {
continue
}
personType := 1 // 默认学生
if u.UserId == "" {
personType = 0
} else {
for _, ch := range u.UserId {
if ch < '0' || ch > '9' {
personType = 0 // 含非数字字符 → 教师
break
}
}
}
person := map[string]interface{}{
"userWid": u.UserWid,
"userId": u.UserId,
"userName": u.UserName,
"name2Id": u.UserName + "/" + u.UserId,
"personType": personType,
"dept": nil,
"sex": nil,
"teacherName": u.UserName,
}
if personType == 0 {
teachers = append(teachers, person)
} else {
students = append(students, person)
}
}
return map[string]interface{}{
"totalSize": len(teachers) + len(students),
"teachers": teachers,
"students": students,
}
}
// 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": BuildLeaveCcInfo(p.CcUsers),
"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,233 @@
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"])
}
// 无抄送人:ccInfo 仍必须存在且为空结构
cc, ok := form2["ccInfo"].(map[string]interface{})
if !ok || cc["totalSize"] != 0 {
t.Errorf("无抄送人时 ccInfo 应为空结构: %v", form2["ccInfo"])
}
}
func TestBuildLeaveCcInfo(t *testing.T) {
// 学生(纯数字 userId)+ 教师(含字母 userId)分组
users := []LeaveApprover{
{UserWid: "92274797", UserId: "02727", UserName: "王欣然"}, // 纯数字 → students
{UserWid: "915560902", UserId: "kjxy0003", UserName: "杨瑚"}, // 含字母 → teachers
}
cc := BuildLeaveCcInfo(users)
if cc["totalSize"] != 2 {
t.Errorf("totalSize 应为 2,实际 %v", cc["totalSize"])
}
students, _ := cc["students"].([]interface{})
teachers, _ := cc["teachers"].([]interface{})
if len(students) != 1 || len(teachers) != 1 {
t.Fatalf("分组错误: students=%d teachers=%d", len(students), len(teachers))
}
st, _ := students[0].(map[string]interface{})
if st["userWid"] != "92274797" || st["name2Id"] != "王欣然/02727" || st["personType"] != 1 {
t.Errorf("学生抄送人字段错误: %v", st)
}
tc, _ := teachers[0].(map[string]interface{})
if tc["userWid"] != "915560902" || tc["personType"] != 0 {
t.Errorf("教师抄送人字段错误: %v", tc)
}
// 空列表:结构完整但 totalSize=0
empty := BuildLeaveCcInfo(nil)
if empty["totalSize"] != 0 {
t.Errorf("空抄送人 totalSize 应为 0: %v", empty["totalSize"])
}
if arr, ok := empty["teachers"].([]interface{}); !ok || len(arr) != 0 {
t.Errorf("空抄送人 teachers 应为空数组: %v", empty["teachers"])
}
// 提交表单携带抄送人
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: "校内",
CcUsers: users,
}, "device-1", "15", "9.9.6")
fcc, _ := form["ccInfo"].(map[string]interface{})
if fcc == nil || fcc["totalSize"] != 2 {
t.Errorf("表单 ccInfo 应包含 2 名抄送人: %v", form["ccInfo"])
}
}
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"])
}
}
+69 -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())
}
}
@@ -164,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,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)
}
}
+21 -7
View File
@@ -157,27 +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 {
return iapLogin(ctx, school, username, password)
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>`
+174 -2
View File
@@ -26,6 +26,7 @@ type TaskType string
const (
TaskTypeSign TaskType = "sign"
TaskTypeSleepCheck TaskType = "sleep_check"
TaskTypeLeave TaskType = "leave" // 销假(请假销假)图片上传使用
)
// 各任务类型的 API 基础路径
@@ -485,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 := ""
@@ -719,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 两种格式)
@@ -793,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 {
@@ -835,8 +965,49 @@ 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 查寝组/签到任务配置的签到范围点
@@ -1081,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,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 必须拒绝")
}
}
+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)
+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>
+45
View File
@@ -94,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 } }>(
+269
View File
@@ -0,0 +1,269 @@
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
/** 抄送人(非必填;候选取自审批人列表) */
ccUsers?: LeaveApprover[]
}
// ======== 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;
}
+62 -10
View File
@@ -68,18 +68,18 @@ const steps: GuideStep[] = [
tab: 'home',
title: '新建每日签到任务',
targets: ['[data-guide="home-create-task"]'],
descByKind: () => '点击蓝色的「+ 新的签到任务」按钮,选择任务名称(如 晚签到),然后填好对应签到信息并保存,任务就创建好了。',
descByKind: () => '点击蓝色的「+ 新的打卡任务」按钮,选择任务名称(如 晚签到),然后填好对应签到信息并保存,任务就创建好了。',
},
{
tab: 'home',
title: '执行任务,自动签到',
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',
@@ -119,12 +119,40 @@ const resolvedKind = computed(() => {
return null
})
const guideDesc = computed(() => current.value.descByKind(resolvedKind.value))
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 mockMode = computed(() => props.show && step.value === 2 && resolvedKind.value === 'home-task-empty')
const mockCardStyle = computed(() => {
const r = holeRect.value
const r = mockAnchor.value
if (!r) return null
return {
top: `${Math.max(0, Math.round(r.top + (r.height - 76) / 2))}px`,
@@ -335,6 +363,8 @@ 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)
@@ -505,7 +535,13 @@ const bubbleStyle = computed(() => {
<span class="text-[10px] text-gray-400 whitespace-nowrap">示例任务</span>
</div>
</div>
<button type="button" class="w-8 h-8 flex items-center justify-center rounded-lg bg-blue-500 text-white shadow-sm flex-shrink-0" title="执行签到">
<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>
@@ -591,6 +627,22 @@ const bubbleStyle = computed(() => {
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;
+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)
}
+27 -7
View File
@@ -1,7 +1,7 @@
<script setup lang="ts">
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'
@@ -19,7 +19,8 @@ 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 },
]
@@ -251,8 +252,8 @@ onUnmounted(() => {
<template>
<div class="flex flex-col h-full">
<!-- 顶部用户信息栏(仅在首页显示) -->
<div v-if="activeTab === 'home'" class="flex-shrink-0 bg-gradient-to-r from-blue-500 to-blue-600 text-white px-4 pt-3 pb-3">
<!-- 顶部用户信息栏(首页与请/销假页显示,两页保持一致) -->
<div v-if="activeTab === 'home' || activeTab === 'leave'" class="flex-shrink-0 bg-gradient-to-r from-blue-500 to-blue-600 text-white px-4 pt-3 pb-3">
<div v-if="userInfo" class="flex items-center gap-3">
<div class="w-10 h-10 rounded-full bg-white/20 flex items-center justify-center flex-shrink-0 overflow-hidden">
<img v-show="userInfo.avatar && !avatarFailed" :src="userInfo.avatar" class="w-full h-full object-cover" @error="avatarFailed = true" />
@@ -262,7 +263,7 @@ onUnmounted(() => {
<p class="font-semibold text-sm truncate">{{ userInfo.realName || userInfo.username }}</p>
<p class="text-xs text-white/70 truncate">{{ userInfo.schoolName }}</p>
</div>
<!-- 公告铃铛(仅首页显示) -->
<!-- 公告铃铛(首页与请/销假页均显示) -->
<button class="relative w-9 h-9 rounded-full bg-white/15 flex items-center justify-center hover:bg-white/25 transition-colors flex-shrink-0" @click="openAnnouncementPanel">
<Bell class="w-5 h-5 text-white" />
<span v-if="totalUnread > 0" class="absolute -top-0.5 -right-0.5 min-w-[16px] h-4 px-1 rounded-full bg-red-500 text-white text-[9px] font-bold flex items-center justify-center leading-none">{{ totalUnread > 99 ? '99+' : totalUnread }}</span>
@@ -277,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>
@@ -484,6 +485,16 @@ onUnmounted(() => {
</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"
@@ -513,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>
+5
View File
@@ -2,6 +2,11 @@ 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)
+6
View File
@@ -2,6 +2,7 @@ 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'
@@ -145,6 +146,11 @@ const router = createRouter({
name: 'home',
component: HomeView,
},
{
path: 'leave',
name: 'leave',
component: LeaveView,
},
{
path: 'profile',
name: 'profile',
+30 -16
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'
@@ -57,6 +58,7 @@ interface UserItem {
status: string
createdAt: string
lastLoginAt: string
lastLoginIp: string
}
interface TodayStats {
@@ -86,6 +88,7 @@ interface UserDetail {
mobileType: string
createdAt: string
lastLoginAt: string
lastLoginIp: string
}
const loading = ref(false)
@@ -450,20 +453,19 @@ function todayStatusLabel(s: string) {
<!-- 表格 -->
<div v-else class="overflow-x-auto">
<table class="w-full text-sm table-fixed min-w-[1260px]">
<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-[8%]">学工号</th>
<th class="px-4 py-3 font-medium w-[6%]">真实姓名</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 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-[9%]">创建时间</th>
<th class="px-4 py-3 font-medium text-center w-[18%]">操作</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>
@@ -476,22 +478,24 @@ function todayStatusLabel(s: string) {
: '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 text-xs text-gray-600" :title="user.schoolName">{{ user.schoolName || '--' }}</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>
@@ -533,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
@@ -713,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>
+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>
<!-- 错误 / 过期 -->
+193 -13
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('')
@@ -295,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) => {
@@ -471,9 +537,14 @@ 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))
@@ -792,7 +863,7 @@ function getGPSLocation(source: string) {
<div class="bg-white rounded-xl border border-gray-100 p-4 shadow-sm">
<div class="flex items-center justify-between mb-3">
<div>
<p class="text-sm font-semibold text-gray-900">签到管理</p>
<p class="text-sm font-semibold text-gray-900">打卡管理</p>
<p class="text-xs text-gray-400 mt-0.5">创建并执行每日签到任务</p>
</div>
<div class="w-10 h-10 rounded-full bg-blue-50 flex items-center justify-center">
@@ -805,7 +876,7 @@ function getGPSLocation(source: string) {
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"
>
<Plus class="w-4 h-4" />
新的签到任务
新的打卡任务
</button>
</div>
@@ -835,7 +906,7 @@ function getGPSLocation(source: string) {
<!-- 任务列表(不显示 WID) -->
<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>
<h3 class="text-sm font-semibold text-gray-900 mb-3">打卡任务</h3>
<div v-if="tasks.length > 0" class="space-y-2">
<div
@@ -898,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)"
@@ -921,14 +997,14 @@ function getGPSLocation(source: string) {
<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>
<p class="text-xs text-gray-400">暂无打卡任务</p>
</div>
</div>
</div>
<!-- 任务日志列表 -->
<div class="bg-white rounded-xl border border-gray-100 p-4 shadow-sm mt-4">
<h3 class="text-sm font-semibold text-gray-900 mb-3">执行日志</h3>
<h3 class="text-sm font-semibold text-gray-900 mb-3">打卡日志</h3>
<div v-if="loadingLogs" class="flex items-center justify-center py-6">
<LoaderCircle class="w-5 h-5 text-blue-400 animate-spin" />
@@ -975,10 +1051,19 @@ function getGPSLocation(source: string) {
</div>
<div v-else class="flex items-center justify-center py-6">
<p class="text-xs text-gray-400">暂无执行日志</p>
<p class="text-xs text-gray-400">暂无打卡日志</p>
</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
@@ -1344,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
@@ -1499,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
+298 -5
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'
@@ -31,6 +31,152 @@ 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('')
@@ -168,7 +314,21 @@ 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)
@@ -181,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 {
@@ -433,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">
+2 -26
View File
@@ -1,7 +1,7 @@
<script setup lang="ts">
import { ref, computed, onMounted, inject } from 'vue'
import { ref, computed, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { LogOut, RefreshCw, ClipboardCheck, Gift, Share2, X, MessageSquare, Dices, TriangleAlert, Sparkles } 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'
@@ -13,9 +13,6 @@ const message = useMessage()
const userInfo = ref<UserInfo | null>(null)
const avatarFailed = ref(false)
// 「再次查看引导」:注入 MobileLayout 提供的重播函数(重新弹出新手引导,从第一步开始)
const restartGuide = inject<() => void>('restartGuide', () => {})
// 邀请信息
const inviteCode = ref('')
const inviteReward = ref(0)
@@ -480,15 +477,6 @@ async function handleRedeemInvite() {
<p class="text-center text-xs text-gray-300 pb-2">copyright © 2026</p>
</div>
<!-- 忘记操作?再次查看引导:右侧悬浮气泡(跳动吸引注意),点击重播新手引导 -->
<button
class="guide-bubble 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>
<!-- 兑换弹窗遮罩 -->
<Teleport to="body">
<Transition name="fade">
@@ -627,18 +615,6 @@ async function handleRedeemInvite() {
</template>
<style scoped>
/* 引导气泡:上下浮动跳动,吸引注意但不刺眼 */
@keyframes guide-bob {
0%, 100% { transform: translateY(0); }
50% { transform: translateY(-6px); }
}
.guide-bubble {
animation: guide-bob 1.5s ease-in-out infinite;
}
@media (prefers-reduced-motion: reduce) {
.guide-bubble { animation: none; }
}
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.2s ease;