Compare commits

...

18 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
43 changed files with 6835 additions and 129 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
+5 -5
View File
@@ -6,11 +6,12 @@ require (
github.com/BurntSushi/toml v1.6.0
github.com/PuerkitoBio/goquery v1.12.0
github.com/go-resty/resty/v2 v2.16.5
github.com/go-sql-driver/mysql v1.8.1
github.com/gofiber/fiber/v2 v2.52.13
github.com/golang-jwt/jwt/v5 v5.3.1
github.com/makiuchi-d/gozxing v0.1.1
github.com/redis/go-redis/v9 v9.7.0
golang.org/x/crypto v0.50.0
golang.org/x/time v0.6.0
golang.org/x/image v0.46.0
gorm.io/driver/mysql v1.6.0
gorm.io/gorm v1.31.2
)
@@ -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,14 +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/image v0.46.0 // indirect
golang.org/x/net v0.52.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
)
+13 -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=
@@ -95,8 +101,6 @@ 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=
@@ -117,10 +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=
@@ -129,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=
+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)
}
+233 -6
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, "用户处理失败")
@@ -152,15 +263,20 @@ func Login(c *fiber.Ctx) error {
})
// 保存明文密码(用于 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失败")
}
@@ -565,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(用户上传失败保存的表单/历史遗留数据)
+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" // 销假
)
@@ -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 必须拒绝")
}
}
+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)
+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
}
+1 -1
View File
@@ -68,7 +68,7 @@ const steps: GuideStep[] = [
tab: 'home',
title: '新建每日签到任务',
targets: ['[data-guide="home-create-task"]'],
descByKind: () => '点击蓝色的「+ 新的签到任务」按钮,选择任务名称(如 晚签到),然后填好对应签到信息并保存,任务就创建好了。',
descByKind: () => '点击蓝色的「+ 新的打卡任务」按钮,选择任务名称(如 晚签到),然后填好对应签到信息并保存,任务就创建好了。',
},
{
tab: 'home',
+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,
}
}
+4 -4
View File
@@ -20,7 +20,7 @@ const avatarFailed = ref(false)
const tabs = [
{ name: 'home', label: '打卡', icon: CalendarCheck2 },
{ name: 'leave', label: '销假', icon: CalendarX2 },
{ name: 'leave', label: '请/销假', icon: CalendarX2 },
{ name: 'profile', label: '个人中心', icon: User },
]
@@ -252,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" />
@@ -263,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>
+13 -13
View File
@@ -456,10 +456,8 @@ function todayStatusLabel(s: string) {
<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>
@@ -480,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>
+23 -20
View File
@@ -351,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) => {
@@ -853,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">
@@ -866,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>
@@ -896,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
@@ -987,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" />
@@ -1041,18 +1051,17 @@ 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="bookmark-bubble fixed right-0 bottom-32 z-40 flex flex-col items-center gap-1 rounded-l-full 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"
style="writing-mode: vertical-rl; letter-spacing: 2px"
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>
<!-- ======== 创建流程 - 步骤弹窗 ======== -->
@@ -1664,15 +1673,9 @@ function getGPSLocation(source: string) {
</template>
<style scoped>
/* 收藏气泡:上下浮动跳动,吸引注意但不刺眼(贴边竖排标签样式) */
@keyframes bookmark-bob {
0%, 100% { transform: translateY(0); }
50% { transform: translateY(-6px); }
}
.bookmark-bubble {
animation: bookmark-bob 1.5s ease-in-out infinite;
}
@media (prefers-reduced-motion: reduce) {
.bookmark-bubble { animation: none; }
/* 收藏按钮竖排文字:仅文字本身竖排(容器保持常规纵排,图标在上文字在下) */
.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">