Compare commits

...

117 Commits

Author SHA1 Message Date
becomer ef03130980 请假/销假全部入口补齐独立学工系统分流,修复点销假仍报 420
ci/woodpecker/tag/build Pipeline was successful
问题:上一版只在 GetLeaveTypes 加了 xsgz/xsfw 分支,其余请假入口仍走辅导猫
旧链路。用户点销假按钮(走 /leave/pending-list)时仍拿到辅导猫的 HTTP 420
「产品已到期」,提示误导且无法自助解决。

改动:新增 rejectExternalLeaveSystem 统一守卫,接入全部 7 个请假相关入口:
- GetLeaveTypes / GetLeaveApplyApprovers / ExecuteLeaveApply(请假侧)
- GetPendingLeaves / GetLeaveApprovers / ExecuteLeave(销假侧)
- GetLeaveExtendPrefill(续假)
命中 xsgz/xsfw 时短路返回明确文案「XX大学 的请假由学校「学工系统」独立提供,
不支持在今日校园内提交,请前往学校学工系统办理」,不再让用户看到 420。

新增单测验证分流判定与文案(纯静态映射,不触发网络)。
2026-10-17 17:12:52 +08:00
becomer 17d3b07c84 CAS 登录按页面实际 captchaSwitch 选择验证码形态,不再只依赖学校静态配置
ci/woodpecker/tag/build Pipeline was successful
问题:isSliderCaptchaSchool 一旦命中就永远走滑块分支。但 wisedu CAS 的
captchaSwitch 会随风控状态变化——连续失败后该校可能从滑块(2)切换为
图片验证码(1),此时 openSliderCaptcha 返回空图、滑块必然失败,最终表现为
「登录失败,请检查账号密码」,掩盖了真实原因。

改动:新增 casCaptchaMode 从登录页读取 captchaSwitch(1=图片 2=滑块),
登录时优先按页面实际值分流;页面未声明时才回退到学校级 sliderIds 配置。
图片验证码分支复用已有 captchaOCR。

新增单测覆盖 captchaSwitch=1/2/缺失/未知取值/含空白等情形。
2026-10-17 17:05:18 +08:00
becomer 45212898ae 修复 xsfw 学工会话建立:改用 cookie jar 收集全链路 cookie
ci/woodpecker/tag/build Pipeline was successful
原实现只从最终响应 cb.Cookies() 取 cookie,会丢掉重定向链中间 302 下发的
MOD_AUTH_CAS / _WEU,导致后续 modules 请求 403。

- 改用 cookie jar 贯穿「CASTGC 换票 → 跟随回调」两步,最后从 jar 全量取;
- 换票与回调拆成两个 resty client:NoRedirectPolicy 影响 client 级且请求开始后
  不生效,同一 client 无法先取 302 再跟随;
- resty 在 NoRedirectPolicy 下遇 302 返回 error,但响应本身有效(含 Location),
  改为忽略该 error、只判断 resp == nil。
2026-10-17 17:03:29 +08:00
becomer f2d89a6d61 新增两套独立学工请假系统的 flavor 分流与接口封装
ci/woodpecker/tag/build Pipeline was successful
背景:山东农业大学、云南警官学院的辅导猫请假应用均为 HTTP 420「产品已到期」,
两校实际使用各自学校的学工平台请假,与辅导猫无关,需要按学校单独适配。

1. 新增 LeaveFlavorXsgz / LeaveFlavorXsfw 两个 flavor,leave_flavor.go 按
   schoolId 优先分流(静态映射,不参与探测缓存),其他学校仍走原 new/old 流程。

2. leave_xsgz.go —— 三易拓「新学工5」(山东农业大学 xsgz.sdau.edu.cn):
   CAS(含 wisedu 滑块) → ST → RSA 加密 ticket → /api/cas/callback 换 access_token
   → /api/leave/* 查询。关键细节:
   - /api/cas/callback 必须用 application/x-www-form-urlencoded 的 `ticket=xxx`
     表单串(用 JSON 会被服务端返回 500);
   - RSA 是 RSAUtils 自定义格式(非 PKCS#1):encodeURIComponent(DK+ticket) 按
     charCode 小端装 BigInt,chunkSize=模数字节数-2,输出 hex+空格分隔;
   - 公钥盐 DK 每次请求都变,必须与模数同批取用。
   已用真实前端 security.js 生成向量交叉验证算法逐字节一致;
   端到端实测登录/请假列表/类型/表单字段全部通过。

3. leave_xsfw.go —— 金智 xsfw 学工「学生请假」(云南警官学院):
   突破点:辅导猫登录拿到的 MOD_AUTH_CAS(ST-iap 格式)在 xg 学工域无效,
   需用 CASTGC 到 authserver 重新换一次 service 指向学工应用的票据,
   回调后 xg 域会话才建立(应用首页返回完整 HTML,含 WIS_CONFIG/APPID/APPNAME)。
   另修复该校响应的多重 UTF-8 BOM(标准 json.Unmarshal 会直接失败)。
   遗留:modules 业务接口仍 403,待真实抓包确认所需请求头。

4. 单测覆盖 RSA 加密向量、encodeURIComponent 边界、BOM 剥离(含前置空白组合)、
   schoolId 分流映射。go build/vet/test 全绿。
2026-10-17 16:59:13 +08:00
becomer 18ead961de 修复请假/销假提交偶发「连接失败,请检查网络」
ci/woodpecker/tag/build Pipeline was successful
现象:iOS 独立 QQ 浏览器 App 提交请假时偶发报「连接失败,请检查网络」,
重试十余次偶尔成功;同页其他接口(axios/XHR)与打卡(EventSource)均正常。

根因:请假/销假是唯一使用 fetch + res.body.getReader() 手动解析 SSE 的接口
(因需 POST + JSON body,无法用 EventSource)。这些浏览器对 fetch 响应流的
支持不稳定,偶发 res.body 为 null 或流读取失败,命中 !res.body 分支误报
「连接失败」;服务端实测始终返回 200 + 正确 SSE(生产日志 189 次全 200)。

修复:
- 前端 leaveSSE 改用 XMLHttpRequest 增量解析(onprogress 累积 responseText),
  回调签名与事件语义不变;XHR 在这些浏览器上已被证明可靠,且能取到
  状态码与错误响应体,错误提示更准确。
- 后端 gzip 排除列表由仅 /api/task/execute/ 扩大到全部 SSE 端点
  (/api/task/execute/、/api/leave/apply、/api/leave/execute),SSE 流保持明文。

排查过程:曾怀疑 gzip 缓冲破坏 SSE,写了两个探针测试(事件到达时序、
压缩 vs 不压缩),证伪——压缩下事件仍逐块即时到达,gzip 非根因。
2026-10-17 00:32:58 +08:00
becomer 57903d9b34 工单状态语义对齐:处理中改蓝、已完成/关闭文案、用户删除自动关闭
ci/woodpecker/tag/build Pipeline was successful
- 状态颜色:处理中由品牌青绿改为蓝色(adm-badge-sky),即
  待处理(黄)/处理中(蓝)/已完成(绿)/关闭(灰)。
- 状态文案:resolved「已解决」→「已完成」,closed「已关闭」→「关闭」;
  管理端列表筛选与详情按钮(标记已完成)同步。
- 用户删除自动关闭:DeleteTicket 软删除前先将 status 置为 closed,
  此后不可回复,管理端也不再计入未关闭待办。
- 回复权限维持:closed 阶段禁止回复,其余状态(待处理/处理中/已完成)
  用户均可在客户端回复(用户回复已完成工单会自动重开为待处理)。
2026-10-17 00:03:30 +08:00
becomer 486e7ede04 工单与公告问题修复:回复强制弹窗、消息左右顺序、指定用户搜索、用户回复邮件通知
ci/woodpecker/tag/build Pipeline was successful
1. 工单新回复任意页面强制弹出:通知新增 kind 字段(reply/status),
   MobileLayout 轮询检测本设备本账号新出现的未读 reply 通知并强制弹窗
   (打卡/请假/我的等所有页面均生效);首次加载只记基线不弹历史,
   基线按用户 id 分开存储避免多账号串扰;状态变更通知不弹窗。
2. 修复管理端工单对话左右顺序:此前 user/admin 位置对调(admin 在左、
   user 在右),现 manager 侧 admin 居右、user 居左,与用户端一致。
3. 公告指定用户改为搜索选择:新增 /admin/announcements/targets,
   按用户名/学号/姓名/学校(代理按用户名)模糊查询,前端改为
   搜索候选列表点选,替代手动输入用户ID。
4. 用户回复工单也邮件通知管理员:新增 NotifyTicketUserReply,
   AddTicketMessage 后异步发信。
2026-10-16 23:15:26 +08:00
becomer 27157a7b39 合肥工业大学登录适配 + 人脸复核等待序列改为 0/1/2/3 秒
ci/woodpecker/tag/build Pipeline was successful
合肥工业大学(school_id 46a6ac8d-...):平台配置 joinType=CLOUD、idsUrl=hfut.campusphere.net/iap,
但网关 IAP 账密接口不兼容,登录报「账号或密码错误」。实测真实链路为
/iap 302 → cas.hfut.edu.cn/cas/login(标准 Apereo CAS,表单 id=fm1):
- 密码需 AES-ECB/PKCS7 加密,key 来自 /cas/checkInitParams 下发的 LOGIN_FLAVORING cookie
  (通用 casLogin 从不调该接口,拿不到 key,password 加密错误导致误报密码错误);
- 提交账密后 302 回调 /iap/loginSuccess?sessionToken=...&ticket=ST-... 建立网关会话(MOD_AUTH_CAS)。
新增 login_hfut.go 单独适配(hfutCasLogin/hfutEncryptPassword/hfutCasEntry),并在
CpdailyLogin 分发中接入;已用真实账号实测登录成功。

另:人脸识别复核轮询等待序列由 0/1/2(累计3s)改为 0/1/2/3 秒,逐次等待递增到 3 秒。
2026-10-16 22:54:06 +08:00
becomer 44de36c47b 修复人脸识别签到「假成功」:提交后复核结果并按真实结果扣费
ci/woodpecker/tag/build Pipeline was successful
今日校园对开启人脸识别的查寝/签到任务,submitSign 返回 code=0 仅表示受理成功,
人脸核验在服务端异步进行,可能事后被系统自动驳回(如 FRS.0036 从url获取文件失败)。
此前仅凭 code=0 即判成功,导致日志记成功、照常扣费,而今日校园侧实为未签到。

- 后端:提交成功后若任务开启人脸识别,回查 detailSignInstance 的
  isFaceIdentify/recognitionProgress/recognitionProgressCode/signStatus 判定真实结果;
  立即查 → 无结果等 1s → 再无结果等 2s(累计≤3s,t≈0/1/3)。
  驳回则经 SSE 返回错误并标记失败,不扣费;明确成功才扣费;
  三轮后仍无明确结果按成功处理,避免误杀。
- 前端:上传/提交前校验图片非空且体积>0,压缩产物为空回退原文件,
  上传失败提示重新选择照片,防止 0 字节空对象入 OBS。
- 新增 evalFaceIdentify/NeedsFaceIdentify/rawCodeString 单测。
2026-10-16 22:47:16 +08:00
becomer 0ca3528afd 请假/续假与销假弹窗新增操作提示
ci/woodpecker/tag/build Pipeline was successful
- 请假/续假申请弹窗表单顶部提示:定位经纬度请使用微信小程序「经纬度查询定位助手」
- 销假表单弹窗顶部提示:销假请使用请假设备,授权设备码,否则无法正常销假
2026-10-15 09:34:24 +08:00
becomer c47fb0a66e 修复 cookie 过期且自动续期失败时不提示重新登录
ci/woodpecker/tag/build Pipeline was successful
手机号登录(无密码)账号 cookie 过期后自动续期失败时,错误链
只带普通错误、不带 needReLogin 信号,导致前端只显示普通错误、
不弹「重新登录」引导,用户无法跳转登录页。

- 新增 ErrNeedReLogin 哨兵 + needReLoginSignal() 统一判定:
  密码错误或无法自动续期都视为需要重新登录
- refreshCpdailyCookie 无密码/MFA 无法自动续期时返回 ErrNeedReLogin
- 所有执行路径(签到/请假/销假/返校/工作日志/表单/上传/预览)
  的续期失败分支统一升级为 wrapNeedReLogin,SSE task_error 与
  HTTP 401 均按 needReLoginSignal 判定
2026-10-14 22:09:33 +08:00
becomer 2a989a15ef 购买 CDK 入口改为原生锚点链接:修复手机浏览器 window.open 被强制当前页打开
ci/woodpecker/tag/build Pipeline was successful
手机浏览器(UC/夸克等)把程序化 window.open 当弹窗强制降级为当前页跳转,
换成 <a target=_blank rel=noopener> 走浏览器原生新标签页处理。
微信 WebView 无 tab 概念,仍为当前页打开(平台限制)。
2026-10-14 11:05:05 +08:00
becomer 150cd86a43 「我的」页新增购买 CDK 显眼入口:后台可配跳转链接与显隐,新标签页打开
ci/woodpecker/tag/build Pipeline was successful
- 用户端:VIP 卡下方展示渐变购买按钮(含 HOT 角标与光泽动画),
  点击新标签页打开后台配置的链接;开关关闭或链接为空时不渲染
- 后台:系统设置新增「CDK 购买入口」卡(滑动开关 + 链接输入),
  链接校验 http(s) 协议,开关值归一化 true/false
- 配置经既有 /cdk/vip-info 接口下发,零新增请求
2026-10-14 10:50:00 +08:00
becomer 43ca4df9eb 拍照签到执行时重签过期照片 URL:用对象 key 换新鲜签名,无需用户重传
任务里存的 signPhotoUrl 是云端签发的短时效预览 URL(约 5 分钟),
隔日执行时签名早已过期,直接提交被学校拒为「签到失败」。
执行时先从 URL 提取稳定对象 key 重签新鲜 URL(同一张照片),
重签失败回退原 URL 不阻断。
2026-10-14 10:49:50 +08:00
becomer 1724a37bcf 修复部分浏览器销假/请假上传图片「选完无任何反应」
ci/woodpecker/tag/build Pipeline was successful
用户反馈:销假时选完图片后无转圈、无预览、无报错,服务端也收不到
upload-policy 请求(访问日志可证)。

原实现用游离的 input(createElement 后未挂 DOM)且 accept 限定
image/png,image/jpeg,image/jpg;在部分第三方内核(如 iPhone 夸克)
下,游离 input 不触发 onchange,iOS 相册的 HEIC 等格式也会被判为
不匹配,导致 input.files 为空 → 命中 `if (!file) return` 静默返回。

抽出 pickImageFiles() 统一修复:
- accept 改 image/*(与首页上传一致);
- input 挂到 DOM,用完移除;
- 监听 cancel 区分「用户取消」(静默)与「选了但读不到文件」(可见提示);
- 销假图片、请假附件两处共用,并修正 uploading 标志在提前 return 之后才置位的问题。
2026-10-08 23:02:43 +08:00
becomer d56769b265 修复手机号登录账号无法自动续期:RefreshCasCookie 未使用 CASTGC
ci/woodpecker/tag/build Pipeline was successful
手机号验证码登录的账号没有 raw_password,cookie 过期后只能靠长期凭证
续期。但 RefreshCasCookie 只发 AmpCookies 头、未把长期凭证 CASTGC(TGC)
预置进客户端,且不跟随 CAS 重定向,导致请求被 302 到登录页,续期必然失败
(表现为「解析续期响应失败: unexpected end of JSON input」),用户被误判
为登录失效。

对齐 ExchangeCasCookie:预置 CASTGC/sessionToken/tenantId 等 cookie,并
跟随 CAS 重定向(跨跳保留原请求头),使网关能换回 MOD_AUTH_CAS。

新增 sms_login_test.go:以 mock 网关覆盖「带 CASTGC 续期成功」与
「缺 sessionToken 直接报错」;旧代码下前者复现同一失败,修复后通过。
2026-10-08 14:34:13 +08:00
becomer 7f39b39643 续假在后台执行日志中单独归类(新增 action=extend)
ci/woodpecker/tag/build Pipeline was successful
现象:后台用户管理「当天执行总次数」明细里,续假与请假同显示为「请假」。

根因:续假与新建请假都写 action='apply',后台 UNION SQL 的
CASE action WHEN 'apply' THEN 'leave_apply' 因此把两者归一。

改动:
- 模型新增 LeaveActionExtend="extend"
- 请假/续假写日志按 req.IsExtend 分流:续假记 extend,请假仍记 apply
- 后台 UNION SQL 映射 extend → leave_extend
- 前端执行日志类型标签新增「续假」(紫色徽标)

说明:存量历史的续假记录已写入 apply,无原始标记可回溯,不做数据迁移。
go build/vet/test 与前端 vue-tsc/build 通过。
2026-10-08 10:39:01 +08:00
becomer 4f6095f6ba 修复旧套续假报「请假定位参数错误」:坐标改放提交体顶层
ci/woodpecker/tag/build Pipeline was successful
根因:旧套续假走 /leave/saveExtend,官方提交体把定位放在**顶层**
address/latitude/longitude(由前端 locationData 展开),不使用 location 子对象。
此前续假表单直接复用请假的 location:{address,latitude,longitude} 结构,
服务端找不到顶层坐标,返回「请假定位参数错误」。

修复:BuildLeaveExtendForm 删除 location 子对象,改用顶层 address/latitude/longitude
(+ locationType),其余沿用请假体。依据:官方 home.*.js 中 saveExtend 请求体构造
({...model, address, latitude, longitude, locationType, leaveId, extendReason, ...})。

新增单测覆盖:顶层坐标存在、无 location 子对象、leaveId/extendReason 正确。
go build/vet/test 通过。
2026-10-08 10:24:37 +08:00
becomer 1fc54efad8 修复旧套(老版请假)续假列表为空:可续假判定改查详情 isAllowedExtend + 状态
ci/woodpecker/tag/build Pipeline was successful
现象:云南大学用户点续假显示「没有可以续假的请假单」,但其假单实际正在休假中。

根因:旧套可续假判定此前取列表行的 isAllowedExtend——该字段是新套的,
旧套列表行并不返回,导致 CanExtend 恒为 false,续假列表被前端过滤为空。

修复:
- 旧套详情 detailOther 顶层 isAllowedExtend 才是可续假开关(实测:在假期中的单为 true,
  已完成的单为 false),FetchLeaveDetail 解析并暴露该字段
- 列表改为查详情后经 leaveOldCanExtend(status, isAllowedExtend) 判定:
  状态在假期中(2 处理中/5 即将休假/6 正在休假中)且 isAllowedExtend=true

验证:该用户假单(status=6 正在休假中)现识别为 canExtend=true。
构建/go vet/test 通过;未做提交测试。
2026-10-08 09:45:44 +08:00
becomer 13faba27b6 修复请假自定义选项字段值类型:提交 optionSeq 数字而非选项文本
ci/woodpecker/tag/build Pipeline was successful
现象:请假成功但查看该单详情报 For input string: "是"(服务端返回 code -1),
且列表接口随之整体报错。

根因:自定义附加字段的选项值应提交 optionSeq(数字),此前提交 optionContent("是")。
服务端把文本存进数字字段,导致该单详情(及含它的列表)解析崩溃。
证据:官方 ReApply 分包提交前把服务端的 optionContent 反查成 optionSeq 再提交;
提交体 fieldValue 为数字。

改动:前端选项字段提交 Number(optionSeq);文本字段仍提交字符串。
验证:用该校真实类型配置在浏览器核对,提交体为 {"fieldValue":1,...}。

注:已被坏数据写入的那张单需要单独处理(见后续说明)。
2026-10-07 19:01:13 +08:00
becomer 46a4bd3b80 请假附加信息:selfDefineAnswers 补 fieldType,与官方前端一致
ci/woodpecker/tag/build Pipeline was successful
官方 Apply 分包构造:selfDefineAnswers = selfDefineFields.map(c => ({fieldWid, fieldName, fieldValue, fieldType}))。
此前漏了 fieldType,补上以降低服务端校验拒绝风险。构建/vue-tsc 通过。
2026-10-07 18:42:02 +08:00
becomer 248e28858b 修复新套请假「请检查附加信息是否全部填写正确」
ci/woodpecker/tag/build Pipeline was successful
根因(用该校真实类型配置核对):typeWid=1873「离校请假」详情里
- isRequiredEvidenceFile=true(证明材料必填),但类型列表接口返回 false,
  我们的映射只看了 isVisibledEvidenceFile,未透传必填开关 → 漏检附件
- selfDefineFields 有必填自定义字段「当天是否返校」(fieldWid=2422),
  我们完全未采集,提交时 selfDefineAnswers 恒为 [] → 服务端拒绝

改动:
- LeaveType 增 requireEvidence/evidenceMin/selfDefineFields 并映射(取详情字段)
- LeaveNewFormDetail 暴露 selfDefineFields;提交体 selfDefineAnswers 从请求透传
- ExecuteLeaveApplyRequest 增 selfDefineAnswers
- 前端:请假表单渲染自定义字段(单选/文本),必填校验(附加字段 + 证明材料最少张数),
  附件标签按 requireEvidence 显示「必传」
- api/leave.ts 补 SelfDefineField/SelfDefineAnswer 类型

验证:用该校真实类型配置在浏览器核对,表单出现「当天是否返校」、附件标注「必传」。
未做提交(按用户要求)。构建/go vet/test/vue-tsc 通过。
2026-10-07 18:40:37 +08:00
becomer 005ce14013 工作日志签到:改为选择校区,用该校区规定经纬度签到
ci/woodpecker/tag/build Pipeline was successful
按需求把签到项的「获取 GPS 定位」改为「选择校区」:用户选所在校区,直接使用该校区
规定的经纬度(学校按点位半径校验)。不再依赖设备定位。

- 后端透传签到范围点(地址/经纬度/半径)到 formItems;新增 WorklogScopePoints 复用
  查寝的 SignPlace 解析与 coordsWithinSignRange 距离判定;执行时校验坐标是否在范围内,
  不在则明确拒绝并提示(不伪造定位)
- 前端签到字段渲染校区下拉,选中即把该校区的 lat,lng 写入字段 content
- 移除 GPS 定位按钮与相关函数

验证:用真实范围数据(许东/奶槐等校区)在浏览器核对,下拉正确列出校区,选择后
显示"已选:某某校区"并写入坐标。构建/go vet/test/vue-tsc 均通过。
2026-10-06 19:44:24 +08:00
becomer d2d4847171 工作日志签到项:编辑页提供「获取当前位置」,坐标取真实 GPS
ci/woodpecker/tag/build Pipeline was successful
实测确认真实字段定义:值班签到 fieldType=5、signCondition=true,
templateScopes 给出 4 个校区点位(含经纬度与半径 500/600m)——即学校按点位半径校验,
与查寝同理,需真实定位;signScopeWids 是允许签到的范围点位 id(非坐标)。

- 前端:isWorklogSign 字段渲染「获取当前位置」按钮,调用 navigator.geolocation
  取真实经纬度写入该字段 content("lat,lng"),已定位显示回显与「重新定位」
- 后端:执行时从签到字段 content 解析坐标传 submitSign;缺定位时提示先获取定位
- FormItem 类型补 isWorklogSign/signScopeWids

验证:用任务 315 真实数据在浏览器核对,签到字段正确渲染为定位按钮,其余字段正常。
构建/go vet/test/vue-tsc 均通过。未做真实提交。
2026-10-06 19:37:00 +08:00
becomer 22e84f86a4 工作日志签到项修正:按 signCondition 判定,坐标取用户定位
ci/woodpecker/tag/build Pipeline was successful
用教师账号查真实 detail 字段定义,纠正两处误判:
- 「值班签到」(fieldType=5) 的 signCondition=true 且 signScopeWids 非空,
  确属签到项(此前误判为普通时间字段而移除了标记)
- signScopeWids 是「允许签到的范围点位 id」,不是坐标;submitSign 应传用户定位
  (与签到任务一致:取表单 lon/lat 字段),此前误用 templateScopes 取坐标

改动:
- WorklogField 增 SignCondition;formItems 透传 signCondition/signScopeWids
- 执行逻辑改按 SignCondition 判定签到项;坐标取表单 lon/lat;缺定位时给出明确
  提示(请先在编辑中填写定位),不再静默跳过
- 移除 worklogScopePoint(用范围点当坐标的错误实现)

未验证:签到项在编辑页的定位填写入口尚未在前端提供,端到端未实测。
2026-10-06 19:16:51 +08:00
becomer 7fb15d17c8 修复工作日志字段在打卡页不渲染,导致必填校验报「缺少值班时间/记录/签到」
ci/woodpecker/tag/build Pipeline was successful
现象:教师账号执行「学管值班」报「签到信息未配置完整,缺少:值班时间、值班记录、值班签到」。
根因:打卡页表单渲染只覆盖 type 0/1(文本)、2/3(单选多选)、图片、二维码;
工作日志用到的 type 5(日期时间)、7(日期时间)、9(多行文本)没有任何 v-else-if 匹配,
字段根本不渲染,用户填不了 → 保存为空 → 执行前必填校验拦截。

修复:
- 前端加三类渲染分支(5/7 → datetime-local 输入;9 → textarea),并加兜底分支
  (未识别类型按文本渲染),避免今后再出现「字段静默丢弃」;内置 toLocalInput 回显
- 模型枚举补 ItemTypeDate(5)/ItemTypeDateTime(7)/ItemTypeTextarea(9)
- 去掉误导性的 isWorklogSign 标记:实测「值班签到」signScopeWids 为空、是 type 5 普通
  可填字段,并非定位签到项(此前照第三方实现误判)

验证:用任务 315 真实 formData 在浏览器核对,编辑弹窗正确渲染全部 6 字段
(2 个 datetime + 1 个 textarea + 单选)。构建/vue-tsc 通过。

未验证:该 type 5「值班签到」的真实填写要求(时间点还是留空由后端处理)待真实使用确认。
2026-10-06 19:14:57 +08:00
becomer c8f77ca030 工作日志提交:回传完整字段对象(仅覆盖 value)
ci/woodpecker/tag/build Pipeline was successful
官方前端提交时把服务器返回的字段对象原样回传、只改 value;此前仅回传自建模型的
少数字段,字段不全可能导致提交失败或丢数据。改为 WorklogField 自定义 Unmarshal
保留完整原始对象,SubmitWorklog 优先回传该对象并覆盖 value,缺原始对象时退化为
最小结构 {wid,value}。构建/vet/test 通过。
2026-10-06 18:38:15 +08:00
becomer 145810759e 修复教师账号点「新的打卡任务」误报「登录凭证已过期」
ci/woodpecker/tag/build Pipeline was successful
根因:FetchAllRecentTasks 只看 sign/sleep_check 是否成功来判断会话有效性。
教师账号在学生端签到/查寝套件本无权限(返回 cpdaily_cookie_expired),
两类都失败 → 误判会话失效 → 上层报「登录凭证已过期」,而账号实际刚登录。

修复:会话有效判据改为「任一打卡套件成功」——签到/查寝、返校登记、工作日志
任一返回成功即认为会话有效;仅当全部失败且首个错误表明登录态失效才返回错误。
教师账号经工作日志返回成功,任务列表正常(实测识别到「学管值班」)。
新增 appendUnique 去重单测。
2026-10-06 18:26:07 +08:00
becomer 7110743cca 工作日志:按真实教师账号响应校正字段与结构(端到端验证通过)
ci/woodpecker/tag/build Pipeline was successful
用教师账号 18334727775 实测,纠正照第三方 Python 推断的错误假设:
- 模板为两级结构:分组 row + templateVoList[].wid,提交用子模板 wid(非分组 wid)
- list 行字段是 subjects(非 form),detail 返回 form
- 字段标识为数字 wid + colName(field001..);fieldType 1文本/2单选/5,7时间日期/9多行
- 单选项在 fieldItems[].content;isOtherItems 在 detail 为数字、template/detail 为布尔,
  新增 FlexBool 兼容,否则整体解析失败(实测踩到)

实测结果:5 个子模板、识别 1 个未提交「学管值班」(signWid=26:2234)、
详情 6 字段正确解析(含值班签到 type5 定位项标记 isWorklogSign)。
go build/vet/test 全过,vue-tsc 无新增错误。
2026-10-06 13:35:30 +08:00
becomer ab99efe877 工作日志:收窄权限判定,避免吞掉登录失效
isWorklogForbidden 只认「无权/没有权限」文案,不再把 HTTP 401 当权限拒绝:
401 既可能是学生账号被角色校验拒绝,也可能是教师登录态失效,一并吞掉会掩盖续期需求。
401(HTML 认证页)改由 isWorklogUnauthorized 识别并仅记 debug,聚合任务时不再告警。
新增权限判定单测。
2026-10-06 13:32:38 +08:00
becomer 3028dbfc86 工作日志定位签到:坐标改取字段 templateScopes(去掉占位空值)
ci/woodpecker/tag/build Pipeline was successful
定位签到项自带学校配置的签到范围点(templateScopes[0] 的 address/longitude/latitude),
据此提交;缺坐标则跳过该签到项并记日志,避免提交空坐标被判无效。
移除先前依赖用户定位的占位实现。
2026-10-06 13:30:15 +08:00
becomer de700ae6c8 接入教师端「工作日志」任务类型(集成进现有打卡链路)
ci/woodpecker/tag/build Pipeline was successful
接口来自公开逆向实现(两个开源项目互相印证,见 spec://worklog_api.md),
未再扫描 H5;独立套件 /wec-counselor-worklog-apps,服务端按角色校验(学生 401)。

- service/worklog.go:模板列表/日志列表/详情/模板详情/建单/定位签到/提交,
  响应解析含 code!=0 与空 message 回退;字段宽泛解析(isRequired/signScopeWids/templateScopes)
- FetchWorklogTasks 产出统一任务项,SignWid="formWid:worklogWid";学生账号被拒静默跳过
- handler:GetTaskForm 增 worklog 分支(字段转通用 formItems);新增 executeWorklogTask(SSE)
- 执行分流 ExecuteTask 增 TaskTypeWorklog;前端加「工作日志」标签与配色
- 单测:响应错误/空 message/非 JSON/成功解析/字段解析/提交体形状

未验证(无教师会话):真实字段名与流程、定位签到坐标来源(当前占位空值)。
2026-10-06 13:29:11 +08:00
becomer 47510c7848 修复通化师范学院(thnu)无法登录:CAS 请求补浏览器 Accept 头
ci/woodpecker/tag/build Pipeline was successful
根因:该校统一认证 authserver.thnu.edu.cn 前置 WAF 按请求头指纹判定客户端,
仅带 User-Agent、缺少 Accept 类头会被判为机器人,整站返回 429(空 body),
登录页取不到表单(bodyLen=262 的拦截响应)。

修复:newSessionClient 统一补 Accept / Accept-Language。实测分离验证——
移动 UA 无 Accept → 429;移动 UA + Accept/Accept-Language → 200。
保留原移动 UA,不影响 wisedu 按 UA 渲染移动/PC 模板。

实测 202318010111 登录成功(err=nil,拿到 MOD_AUTH_CAS/CASTGC/JSESSIONID 等 5 个 cookie)。
2026-10-06 13:16:41 +08:00
becomer b4357f746f v1.2.125:打卡页骨架与界面一致;后台执行日志类型不再换行
ci/woodpecker/tag/build Pipeline was successful
- 打卡页加载骨架改为与主内容同结构:品牌渐变头卡(标题+四宫格+主按钮)、
  带左侧色条的任务行、日志时间线;头卡占位用固定白色透明度,暗色下与真实卡片一致
- 后台「执行日志明细」类型列 64→104px,改用 adm-badge(nowrap)并按类型配色,
  「返校登记」等长标签单行显示
2026-10-05 22:59:14 +08:00
becomer 5042c869b1 v1.2.124:各页底部导航滚动收窄行为统一
ci/woodpecker/tag/build Pipeline was successful
收窄门槛 COLLAPSE_MIN_RANGE 200→80px:此前可滚动距离不足 200px 的页面(如「我的」约 170px)
从不收窄,与打卡/请假页表现不一致。方向阈值 32→24px、顶部展开区 40→24px,
保留同向累计阈值与回弹夹取,底部回弹/小幅来回不抖动。
2026-10-05 18:05:51 +08:00
becomer 96e1c3d1f2 v1.2.123:修复登录页选择学校后再次搜索并重新弹出下拉框
ci/woodpecker/tag/build Pipeline was successful
原因:选中学校时把输入框写成学校全名,watch 把这次写入当成用户输入,
以全名重新请求并重新打开下拉框;且无防抖、无过期响应保护。

- 写回全名时跳过搜索;选中后再改输入则清空选择并重新搜索
- 300ms 防抖 + 请求序号,丢弃过期响应
- 已选中学校时聚焦输入框不再弹出列表
2026-10-05 18:01:58 +08:00
becomer ad9b4f4ee3 v1.2.122:登录页文案调整;切换手机号登录时提示会踢下线 App
ci/woodpecker/tag/build Pipeline was successful
- 功能标签「自动打卡」改为「人脸签到」
- 去掉卡片副标题与底部密码说明
- 点「手机号登录」弹窗提示:会导致今日校园 App 被踢下线,重新登录 App 即可,不影响 App 功能
2026-10-05 17:55:09 +08:00
becomer ae4ca4a00e v1.2.121:请销假页新增「续假」入口;登录页重构美化
ci/woodpecker/tag/build Pipeline was successful
续假入口
- 头卡改为「请假 / 销假 / 续假」三按钮,续假打开只含可续假请假单的列表
- 列表按模式过滤:销假只看可销假单,续假只看可续假单;空态说明续假条件(休假中 + 学校允许)
- LeaveItem 增 canReport(列表含仅可续假的单,销假模式据此排除)

登录页
- 品牌渐变头图(问候语 + 功能标签 + 细网格),登录卡片上浮压住头图
- 滑块分段控件切换学工号/手机号;输入行改为图标 + 原生输入,整行聚焦高亮,已选学校带勾
- 验证码块统一尺寸并带刷新角标;短信按钮倒计时文案;主按钮固定品牌色
- 登录逻辑不变(已验证验证码加载、学校选择、/auth/login 提交体)
2026-10-05 17:41:01 +08:00
becomer e7dac20a02 v1.2.120:续假支持旧套(wec-counselor-leave-apps)
ci/woodpecker/tag/build Pipeline was successful
从旧套真实 H5 JS(刷新 cookie 后拉取 home.*.js)确认旧套续假为独立接口:
POST /wec-counselor-leave-apps/leave/saveExtend,body = 请假申请体 + leaveId + extendReason
+ extendHolidayAutoDefineVos,带 Cpdaily-Extension 头。

- service:BuildLeaveExtendForm + SubmitLeaveExtend(复用 BuildLeaveApplyForm)
- handler:ExecuteLeaveApply 旧套分支按 req.IsExtend 走 saveExtend;
  待销假列表 FetchLeaveList 从行 isAllowedExtend 置 canExtend
- extend-prefill:旧套用 FetchLeaveDetail 返回类型 code + 原结束时间作预填
- 前端续假入口已 flavor 无关,新旧套共用

注:旧套无「休假中可续假」真实假单实测,extendInfos 等按官方 JS 逻辑实现,上线后按真实报错微调。
2026-10-05 17:22:26 +08:00
becomer 8fd132fc64 v1.2.119:新增续假功能(新套 wec-counselor-leave-new-apps)
ci/woodpecker/tag/build Pipeline was successful
调研:从真实新套 H5 JS(ReApply chunk)确认续假 = 复用 /stu/leave/apply,
body 带 reApply:true/isExtend:true/leaveId/extendId;预填用 /stu/leave/queryReplyLeaveDetail。

后端:
- BuildLeaveNewApplyForm 补 reApply/extendId(LeaveID/IsExtend 已有)
- FetchLeaveNewReplyDetail(queryReplyLeaveDetail) 续假预填
- ExecuteLeaveApply 续假分支:取 extendId 后按续假体提交
- 新增 POST /leave/extend-prefill
- 待销假列表放宽为「可销假 或 isAllowExtend」,LeaveItem 增 canExtend/typeWid/isLeaveOut

前端:
- 待销假列表每项拆「销假 / 续假」按钮,仅 canExtend 显示续假
- 点续假调 extend-prefill 预填(类型锁定、开始时间=原结束时间、原因/离校带入),复用请假弹窗,标题/按钮随续假态切换
- 提交带 isExtend/extendLeaveId

限制:旧套(leave-apps)续假未实现(其 H5 走 OAuth 中转拿不到 JS),仅新套学校显示入口。
2026-10-05 17:02:30 +08:00
becomer f27603f724 v1.2.118:查寝二维码点击先选「拍照扫码 / 相册选图」
ci/woodpecker/tag/build Pipeline was successful
- 二维码字段改为单一入口「获取二维码」,点击弹出底部选择卡:
  拍照扫码(打开摄像头)/ 相册选图(原图片解析),不再默认直接开摄像头
- 选择弹层 z-[120] 高于创建任务表单弹窗,避免被盖住
2026-10-05 16:18:04 +08:00
becomer 5bb39454b8 v1.2.117:查寝二维码支持摄像头实时扫码
ci/woodpecker/tag/build Pipeline was successful
- 新增 QrScanner 组件:getUserMedia 调后置摄像头 + jsQR 每 120ms 识别一帧,命中即写入二维码ID
- 识别用整帧下采样(480→720 两档),取景框仅作视觉引导:object-cover 会放大裁切画面,
  只扫中心区时近距离对准反而扫不到(实测复现)
- 支持手电筒(设备支持时);非 HTTPS / 权限被拒 / 无设备分别给出可操作的提示
- 保留「选图」回退到原有图片解析;扫码页 z-[400] 且显式恢复 pointer-events
  (reka modal 会把 body 设为 pointer-events:none,否则扫码页按钮点不动)
2026-10-05 16:07:38 +08:00
becomer b928c7ceb0 v1.2.116:邀请码改为登录即自动生成,移除手动生成入口
ci/woodpecker/tag/build Pipeline was successful
后端
- 删除 POST /auth/generate-invite 及 GenerateMyInviteCode
- 新用户注册时生成邀请码;唯一索引冲突(仅 invite_code 索引)自动换码重试,最多 8 次
- 老用户登录(用户名 / 学工号匹配)缺邀请码时补发;GET /auth/my-invite 兜底补发
- 服务启动时批量补齐存量空邀请码(幂等;条件更新 invite_code 为空才写,并发安全)
- 邀请码改用 crypto/rand,字符集去掉 0/O/1/I/L;旧实现按纳秒取模,可预测且并发注册易撞码
- 测试:单元测试 + MySQL 集成测试(设置 INVITE_IT_DSN 时运行,已在 8.4.11 上通过)

前端
- 「我的」移除「生成我的邀请码」按钮与 getMyInvite API;新手引导去掉对应目标与文案
2026-10-04 11:09:47 +08:00
becomer 79d531f340 v1.2.115:修复后台页面无法向下滚动、公告弹窗底部按钮被截断
ci/woodpecker/tag/build Pipeline was successful
- 后台内容区 <main> 恢复为滚动容器(h-dvh + overflow-y-auto):html/body 为手机外壳设了 overflow:hidden,
  v1.2.114 去掉 main 的 overflow-y-auto 后长页面(用户管理等)超出部分无法滚动
- 新建/编辑公告弹窗:标题栏与底部操作栏固定、表单区独立滚动,面板高度限制在视口内,并补充「取消」按钮
2026-10-02 23:07:58 +08:00
becomer 99e01dc338 v1.2.114:后台管理界面重构美化与样式修复
ci/woodpecker/tag/build Pipeline was successful
- 新增后台组件类 adm-*(页头/卡片/统计卡/表格/徽标/行内操作/分段筛选/分页/空状态),亮暗两套,各页统一套用
- 外壳:侧栏分组(概览/运营/系统)+ 品牌区 + 选中左侧色条;桌面顶栏面包屑 + 公告入口;手机端玻璃顶栏显示当前页名
- 仪表盘:欢迎横幅展示余额与费率,统计卡新增 CDK 使用率;CDK 页统计卡可点击按状态筛选
- 工单详情改为卡片内对话视图;系统设置改两列网格;代理列表加头像首字母
- 修复:暗色下 bg-blue-500/bg-green-500 白字看不清(改固定品牌/成功色)、text-gray-300 占位符对比不足、
  残留的蓝色主题色、工单状态色映射统一
2026-10-02 22:54:01 +08:00
becomer 02dcd6fc32 v1.2.113:重构个人中心
ci/woodpecker/tag/build Pipeline was successful
- 头图改左对齐:头像 + 姓名 + 学校,补充学院/班级标签,细网格纹理
- 新增会员卡压在头图下沿:VIP 黑金(显示到期日与剩余天数)/ 普通用户珍珠白,主按钮「开通 VIP / 续费」
- 数据三宫格:可执行次数(点击兑换)/ 邀请积分(够 1 直接换次数,不够提示规则)/ 已邀请人数
- 邀请卡:薰衣草渐变,邀请码大字展示 + 复制按钮,奖励规则一句话说明,「点此填写」他人邀请码
- 功能菜单改 iOS 分组列表:设备码带「已授权/随机/未绑定」状态标签;工单显示新回复数
- 新手引导文案同步新布局;暗色下紫色按钮改固定色保证白字对比度
2026-10-02 19:58:12 +08:00
becomer 51f3641a0c v1.2.112:重构首页置顶公告;打卡日志改时间线、任务行精修
ci/woodpecker/tag/build Pipeline was successful
- 置顶公告新组件 PinnedNotice:一次一条上下翻页轮播(N/M 计数),长文本先停顿再横向滚到末尾,
  点击打开底部面板查看全部全文;切后台 / 减弱动态效果时暂停;柠檬暖色卡片,亮暗各一套配色
- 打卡日志:状态圆点 + 竖线时间线,相对时间(刚刚 / N 分钟前 / 今天 / 昨天),失败原因直接展示,空状态带说明
- 任务行:左侧状态色条,执行按钮改品牌渐变圆形主按钮,编辑/删除降级为图标按钮
- 贴边竖条(收藏本站 / 无法请销假 / 网课)位置统一上移避开引导入口;收藏本站改柠檬底深棕字
- 修复 scoped 样式中 `:global(.dark) .x` 被编译为仅 `.dark` 导致暗色覆盖失效(请销假头卡暗色此前未生效)
2026-10-02 19:38:36 +08:00
becomer 9f2f705457 v1.2.111:修复线上暗色主题全站灰底;重做外观切换控件
ci/woodpecker/tag/build Pipeline was successful
- 根因:postbuild-css 解包 :where() 时直接去壳,Tailwind 暗色变体 `.x:where(.dark, .dark *)` 被拆成
  `.x.dark, .dark *`,`.dark *` 成为全局规则,暗色下所有元素被刷上同一背景/边框(开发服务器不经后处理,故本地截图正常)。
  改为先展开逗号列表(`.dark .x`),并在产物中出现全局 `.dark *` 规则时直接构建失败
- 外观切换改为整宽三段分段控件 + 滑动高亮,窄屏不再挤压换行
- 我的页对比度:续费 VIP / 兑换按钮改固定深棕字(≥4.6:1),奖励数字改 amber-600
2026-10-02 15:21:16 +08:00
becomer 301a4fb971 v1.2.110:打卡/请销假页头像行上下间距统一为 16px(原上 12px、下 28px)
ci/woodpecker/tag/build Pipeline was successful
2026-10-02 14:31:36 +08:00
becomer df40b2ae73 v1.2.109:顶部安全区仅在主屏幕全屏模式生效,修复浏览器/App 内顶栏下移
ci/woodpecker/tag/build Pipeline was successful
部分浏览器与 App 内置 WebView 已把网页放在状态栏下方,却仍上报 safe-area-inset-top,
叠加后头像行凭空下移约 47px。改为 --safe-top 变量,仅 display-mode: standalone/fullscreen 时取 env() 值。
2026-10-02 14:13:41 +08:00
becomer 317bf4b5a1 v1.2.108:修复新手引导末步页面左移;新版本提示更及时
ci/woodpecker/tag/build Pipeline was successful
- 引导定位 scrollIntoView 改为 inline:nearest,不再横向滚动 overflow:hidden 的头卡容器(装饰光斑超出边界导致整块左移 45px);头卡改用 overflow:clip 兜底
- UpdateChecker:检查间隔 5 分钟→2 分钟、首检 30s→10s;页面回到前台(切回标签/唤起 App)时补查;修正 prompting 在 finally 中被清零导致的并发防护失效
2026-10-02 13:59:40 +08:00
becomer 1c112fe3dc v1.2.107:修复打卡页横向撑宽、底部导航滚动时缩放抖动
ci/woodpecker/tag/build Pipeline was successful
- 页面包裹层 grid 列宽锁定为容器宽度(grid-cols-1),置顶公告走马灯不再把整页撑到数千像素宽
- TabBar 收窄逻辑防抖:可滚动距离 <200px 的页面不收窄;scrollTop 夹在 [0,max] 忽略 iOS 回弹;同向滚动超过 32px 才切换;到底部不再强制展开
2026-10-02 13:47:41 +08:00
becomer 2f7113ba5e v1.2.106:前端改版——薄荷汽水配色 + 轻量液态玻璃(iOS 26 风格)
ci/woodpecker/tag/build Pipeline was successful
- 设计令牌:重映射 Tailwind 色阶为薄荷汽水色板(品牌薄荷青/天空蓝/柠檬/珊瑚),统一圆角、柔和阴影;亮暗两套色板按 WCAG AA 校验
- 外壳:悬浮液态玻璃胶囊 TabBar(滑动高亮、滚动收窄)、透明→玻璃顶栏、切页淡入、居中玻璃 Toast
- 主题:新增「自动/浅色/深色」三档切换(我的页),自动模式保留按时间切换
- 页面:首页/请销假渐变头卡、登录/绑定设备/工单/个人中心精修;后台换主色与导航样式
- 可读性:清理 8–11px 小字号(最小 12px),提高辅助文字灰度对比
- 降级:不支持 backdrop-filter / 减弱透明度 → 实底;减弱动态效果 → 关闭动画
2026-10-02 13:33:46 +08:00
becomer 441c40a8cc v1.2.105:封号(禁用)后所有接口即时拦截,返回自定义封号原因
ci/woodpecker/tag/build Pipeline was successful
- 现状:禁用仅在登录时校验,已登录用户持有效 token 仍可调用所有接口。
- 修复:JWT 中间件在每个受保护接口校验用户状态,禁用则返回 HTTP 403 + 业务码 4030,
  message 为封号时自定义的原因(为空时兜底文案)。
- 前端:拦截 403/4030,清除登录态、弹出封号原因并跳登录页,不触发 token 刷新死循环。
- 复用现有后台「禁用/启用」入口与 disabled_reason(用户管理页已具备)。
2026-10-02 11:09:29 +08:00
becomer 002c6b8570 v1.2.104:放开「今日已签到」拦截,允许对已签到实例重新提交
ci/woodpecker/tag/build Pipeline was successful
- 现象:用户想对已签到的任务重新打卡(如首次位置填错),被后台以
  「今日已签到,无需重复执行」拦截(该提示由 FetchTodayTaskDetail 本地判断返回,
  非今日校园)。
- 修复:执行打卡时若当天无未签到实例但存在已签到实例,不再拦截,改用该已签到实例
  重新提交;是否真正接受由今日校园服务端决定(实测已能发出请求,受学校侧风控约束)。
2026-10-02 10:49:11 +08:00
becomer 2a53ab7041 v1.2.103:修复范围外签到被强制改回学校坐标(打卡地点显示成学校)
ci/woodpecker/tag/build Pipeline was successful
- 现象:用户 2025050021(安康学院)签到任务填的是家(广东省广州市天河区大观路,
  且「是否范围外签到=是」),执行后实际打卡地点变成学校(安康学院)。
- 根因:执行打卡前无条件调用 AdjustSubmitFormToSignRange——当填写坐标不在任何
  学校签到范围内时,自动把坐标+地址改成最近的学校签到点。用户本就是「范围外签到」,
  被强行改回学校坐标,导致实际打卡点变成学校。
- 修复:仅在「范围内签到」时执行坐标自动回退;「范围外签到」(isMalposition=是/1)
  尊重用户填写的位置,不调整。新增 isOutOfRangeSign 判定与单测。
2026-10-02 10:24:10 +08:00
becomer 12db9217d1 v1.2.102:修复兰州理工大学登录(CAS-PaaS 定制 CAS,密码 RSA/base64 加密)
ci/woodpecker/tag/build Pipeline was successful
- 现象:用户 262030500019 登录报「当前学校已有用户正常登录,请检查…」,
  实际是账密登录失败(IAP doLogin 报 FAIL_UPNOTMATCH)。
- 根因:该校平台标 joinType=CLOUD(IsIap),但实际为 CAS-PaaS 定制统一身份认证
  (cas-paas.lut.edu.cn/cas/login),密码需 RSA(JSEncrypt)加密后以
  "__RSA__"+密文 提交;通用 casLogin 提交明文密码被拒。
  且 JSEncrypt 的密文是 base64,而本仓库 RSA 工具输出 hex,直接提交亦不通过。
- 修复:
  1) 新增 CAS-PaaS 适配(login_caspaas.go):按登录页特征(/cas/jwt/publicKey、
     __RSA__)自动识别,拉公钥、RSA(PKCS#1 v1.5) 加密密码并转 base64,前缀 "__RSA__"。
  2) casLogin 解析表单后自动命中 CAS-PaaS 加密。
  3) 新增兰州理工特判(login_lut.go)强制走 CAS(其 IAP 不可用)。
- 实测:真实账号 262030500019 登录成功,拿到 MOD_AUTH_CAS/CASTGC。
2026-09-30 23:52:25 +08:00
becomer bd274a0414 v1.2.101:销假错误透传学校原始 message,修正 2210990000 被误译为「需上传附件」
ci/woodpecker/tag/build Pipeline was successful
- 现象:用户 3241804227(旧套)已上传附件仍报「需要上传附件」。
- 根因:学校端错误码 2210990000 是通用码,可表示多种原因(实测:1161 为
  「需要添加附件才能完成销假」,1219 为「设备更换,无法查看,请联系管理老师」);
  原实现把所有 2210990000 一律硬编码为「该请假销假需要上传附件图片」,覆盖了
  学校真实 message,导致「设备更换」被误报成「需上传附件」。
- 修复:优先透传学校返回的 message;仅当 message 为空/failed 时按码兜底。
2026-09-30 15:46:28 +08:00
becomer ad9dd3e638 v1.2.100:验证码限流 IP 改用 utils.ClientIP,与登录 IP 取值一致
ci/woodpecker/tag/build Pipeline was successful
短信验证码与图片验证码的限流此前用 c.IP(),与登录 IP 记录所用的 utils.ClientIP 不一致,
导致限流按错误的 IP 计数(可能所有用户共用一个桶,出现"刚打开就提示需等 15 秒")。
统一改用 utils.ClientIP(c)。
2026-09-29 22:22:47 +08:00
becomer 0a49dc0080 v1.2.99:修复新套请假「是否离校」被类型默认值覆盖导致提交成「否」
ci/woodpecker/tag/build Pipeline was successful
- 根因:新套 leaveType.extendInfoBean.isLeaveOut 官方语义是「是否离校」的初始默认值
  (0=无默认 / 1=默认是 / 2=默认否),并非强制开关;且提交体 leaveOutInfo.isLeaveOut
  学校端只认 1=是 / 2=否。原实现把它当强制值,当类型默认值=0 时把用户选择的「是」
  覆盖成 0,学校端因 0 非合法枚举而显示为「否」(兰州交通大学 12261216 即此现象)。
- 修复:提交值以用户选择为准并规范为枚举 1/2(leaveNewOutStatusValue);不再用类型默认值
  覆盖用户选择;类型列表不再把 isLeaveOut 误映射为 mustOut,改为透传 OutStatusDefault
  供前端设置开关初始状态(1=默认是,其余=默认否,用户可改)。
- 新增单测锁定枚举映射。
2026-09-29 21:50:23 +08:00
becomer 48926ed72f v1.2.98:新手引导步骤调整(设备码前置 + 填写邀请码/兑换积分拆为两步)
ci/woodpecker/tag/build Pipeline was successful
- 前两步对调:第 1 步改为「先绑定设备码」,第 2 步拆分为两步——
  「填写邀请码」与「兑换积分」,其后依次为新建任务、执行签到、推广赚积分、VIP。
- 同步更新按步骤索引的硬编码逻辑(示例卡 mock 场景 2/3 → 3/4)。
- GUIDE_VERSION v3→v4(前后端),让老用户重新看到新引导。
2026-09-29 11:57:44 +08:00
becomer c1573f77ac v1.2.97:邀请逻辑整改(登录去邀请码 + 推广信息合并 + 填邀请码绑定)
ci/woodpecker/tag/build Pipeline was successful
1. 登录不再需要邀请码:登录页移除邀请码展示/URL ?invite= 传参;登录/注册不再携带
   邀请关系;取消「直接注册奖励(register_reward)」。新用户注册不发放任何积分。
2. 底部导航「个人中心」→「我的」。
3. 个人中心将「邀请链接」「邀请信息」合并为「推广信息」卡片:
   - 左:邀请码 + 复制按钮(改为只复制邀请码)
   - 右:奖励积分 + 兑换按钮
   - 下方:邀请好友即可获得 x 积分
   - 无邀请人用户显示「填写邀请码」→ 弹窗输入 → 提交校验设备码(未绑定提示
     「请先绑定设备码」)→ 绑定邀请人并给双方发积分(每人仅可绑定一次)。
4. 后端新增 POST /auth/bind-invite;GetMyInviteInfo/Generate 返回 hasInviter。
5. 新手引导文案与锚点更新(去掉登录携码/链接语义),GUIDE_VERSION v2→v3。
6. 后台设置移除「直接注册奖励积分」项。
2026-09-29 11:40:31 +08:00
becomer d588d10c4c v1.2.96:底部导航移除网课 + 「可执行次数」文案 + 置顶公告满宽右→左无缝滚动
ci/woodpecker/tag/build Pipeline was successful
1. 底部导航栏取消「网课」tab。
2. 个人中心「可签到次数」改为「可执行次数」(VIP 文案同步为「VIP 执行次数」)。
3. 置顶公告:去掉 warning 边框、整行满宽;改为右→左循环滚动,渲染两组内容
   translateX(-50%) 无缝接回开头,单条也持续滚动。
2026-09-29 10:39:38 +08:00
becomer 7dac485769 v1.2.95:兑换弹窗验证码错误改为在验证码行提示,并在失败后自动刷新验证码
ci/woodpecker/tag/build Pipeline was successful
- 问题:CDK 兑换/VIP 续费弹窗中,验证码错误或过期时错误红字显示在「兑换码」框下方,
  让用户误以为是兑换码出错。
- 修复:验证码类错误(message 含「验证码」)改为在「验证码」输入行下方红字提示,
  兑换码框不再误标红;验证码错误后自动刷新验证码并清空输入,便于直接重试。
2026-09-29 10:22:26 +08:00
becomer 195702c14f v1.2.94:修复销假图片上传入口被 enableAttach 隐藏导致无法上传附件
ci/woodpecker/tag/build Pipeline was successful
- 现象:用户 25074105001(吉林医药学院,旧套)销假时表单只显示经纬度/地址,
  提交报「该请假销假需要上传图片附件」,无法完成销假。
- 根因:旧套 leaveType.enableAttach 字段不可靠(多所旧套学校恒为 0),
  前端以此字段控制销假图片上传入口显隐,导致该字段为 0 时上传入口不显示;
  而部分学校销假实际强制要求附件(提交返回 2210990000)。
- 修复:销假图片上传入口改为始终显示(可选,不填则不携带),
  并附提示「部分学校销假要求上传,如提交提示需要附件请在此上传」。
2026-09-28 15:26:42 +08:00
becomer df550bf2ac v1.2.93:工单未读回复徽标 + 公告面板未关闭工单通知排序靠前
ci/woodpecker/tag/build Pipeline was successful
1. 个人中心「工单与反馈」入口新增未读回复条数徽标(工单通知未读数)。
2. 右上角公告(铃铛)面板:工单通知分区把「未关闭工单」的通知排到前面
   (后端 ListTicketNotifications 附带 ticketStatus,前端按 status≠closed 优先排序)。
2026-09-28 15:21:00 +08:00
becomer 75a642f3a2 v1.2.92:兑换签到次数弹窗增加「邀请积分可免费兑换」引导提示
ci/woodpecker/tag/build Pipeline was successful
当邀请奖励积分满 1(canRedeem,可免费兑换)时,在「兑换签到次数」弹窗内
提示用户关闭本弹窗,改用下方「邀请信息」处的兑换按钮用积分兑换(无需兑换码)。
2026-09-28 11:32:07 +08:00
becomer d2ca858816 v1.2.91:修复请假审批人获取失败被静默吞掉导致误报「未配置审批人」
ci/woodpecker/tag/build Pipeline was successful
- 现象:用户 124509440(西藏民族大学)提交请假时提示「未配置审批人」,
  实际学校有 2 个审批人(谷一墨、黄操)。经 headless 浏览器复现,根因是:
  当请假起止时间填写有误(如开始≥结束)时,后端 getApproveFlowList 返回
  「结束时间要晚于开始时间」,前端 loadApplyApprovers 的 catch 静默清空候选,
  界面遂显示「该校该请假类型未配置审批人,可不选」,误导用户以为接口取不到人。
- 修复:捕获并展示真实错误(applyApproversError),该错误时不再显示
  「未配置审批人」文案;切换类型/时间时重置错误态。
- 说明:后端各接口实测正常(对多种类型/时间均返回 2 个审批人),本次仅改前端提示。
2026-09-28 11:23:15 +08:00
becomer 84f746fa2d v1.2.90:后台用户管理执行次数弹窗补全「返校登记」类型中文
ci/woodpecker/tag/build Pipeline was successful
taskTypeLabel 未覆盖 return_register,导致该类型日志显示英文原名,
补上「返校登记」中文映射。
2026-09-27 18:25:20 +08:00
becomer 7f81d54ea2 v1.2.89:返校登记接入打卡任务体系(按 taskType 分流)
ci/woodpecker/tag/build Pipeline was successful
- 新增任务类型 return_register,与签到/查寝共用统一 task 接口
  (titles/create/list/form/update/delete/execute/logs),按 taskType 走不同逻辑。
- 后端:
  - TaskType 增加 return_register;FetchAllRecentTasks 聚合返校登记待办为任务标题
    (SignWid="taskWid:stuWid")。
  - ListTasks 对 return_register 按待办集合判定 matched_unsigned/matched_signed。
  - GetTaskForm 无形表单(返回空);ExecuteTask 分流到 executeReturnRegisterTask
    (取配置校区坐标提交确认到达,扣打卡次数、VIP 免扣、写日志,SSE 体验一致)。
  - 移除 v1.2.88 的 /leave/return-register/* 路由与 handler。
- 前端:打卡页(HomeView)接入——标题列表出现返校登记、选中即创建(跳过名字匹配校验、
  无形表单直接保存)、列表/日志显示"返校登记"标签(琥珀色)、执行复用同一 SSE。
  移除请销假页的返校登记卡片与 API 封装。
2026-09-27 18:06:31 +08:00
becomer a802912a55 v1.2.88:新增节假日离返校「返校登记」(确认到达)
ci/woodpecker/tag/build Pipeline was successful
- 背景:部分学校(如合肥大学)在「节假日离返校」应用要求假期结束做返校登记,
  原系统只支持 信息收集/签到/查寝 等,无法处理该类型。
- 通过应用大厅 getAppInfos 枚举定位到 wec-counselor-leave-return-apps 应用;
  实测该校接口:queryReturnConfigList(校区坐标+半径)、queryStuRegisterList(列表)、
  submitStuReturnRegisterInfo(提交返校位置)。
- 待办判定:status=="1" 且 returnRegisterInfo 为空;提交参数
  {taskWid,stuWid,returnAddress,returnLongitude,returnLatitude,returnSignType}。
- 后端:leave_return.go 服务 + handler + 路由 /leave/return-register/{pending,execute}
  (默认用学校配置校区坐标,范围内登记;支持范围外带原因)。
- 前端:请销假页展示「返校登记」卡片,有则显示、一键提交。
2026-09-27 17:31:24 +08:00
becomer 9074118889 v1.2.87:修复西藏民族大学登录(定制 CAS,教科书 RSA 小端 hex 密码)
ci/woodpecker/tag/build Pipeline was successful
- 该校平台标记 joinType=CLOUD(IsIap=true),但网关 IAP 账密接口不可用,
  提交报 FAIL_UPNOTMATCH("用户名或密码不匹配"),误导为密码错误。
- 实测真实链路:/iap 302 → cas.xzmu.edu.cn/lyuapServer/login(定制 CAS)→
  表单 POST /lyuapServer/v1/tickets(密码加密方式特殊)→ 得 TGT/ST →
  回调 /iap/loginSuccess?sessionToken=...&ticket=ST 建立网关会话(MOD_AUTH_CAS)。
- 关键差异:密码加密为该站前端库的"教科书式 RSA(无填充)+ 小端序零填充 + hex",
  与通用 wisedu 的 PKCS#1 v1.5 完全不同(用 PKCS#1 会被解成乱码 → PASSERROR)。
- 新增 login_xzmy.go 单独适配该校(真实账号 124509440 实测登录成功),
  并在 CpdailyLogin 分派;含加密/票据解析/错误码翻译单测。
2026-09-27 14:12:24 +08:00
becomer 176c43251c v1.2.86:新套请假支持手写承诺书签名(isSign 类型)
ci/woodpecker/tag/build Pipeline was successful
- 背景:部分新套学校(如成都职业技术学院)请假要求先手写《请假安全承诺书》
  并上传签名图,缺失时服务端报「手写承诺书不能为空」。
- 字段来源:抓取官方 H5(prod v0.0.279)SafetyLetter/Apply chunk 反查确认——
  签名图经 OBS getUploadPolicy 上传后,作为 attachs 中
  {attachType:1,attachUrl,attachSize,attachName:"学生手写承诺书",obsKey,isStuSign:true} 提交。
- 后端:类型列表透传 requireSign(queryLeaveType.isSign);提交表单支持
  signatureUrl 生成签名 attach;提交前复核未签名直接拦截。
- 前端:选中 requireSign 类型即弹出 canvas 手写签名板,签名图上传 OBS 后随提交携带。
2026-09-26 18:04:22 +08:00
becomer 839637b3d3 v1.2.85:置顶公告改为「标题:详情」循环滚动,且不进入用户公告列表
ci/woodpecker/tag/build Pipeline was successful
- 打卡页顶部置顶公告由「仅标题,点击弹详情」改为「标题:详情」整体左右循环滚动播放。
- 置顶公告从用户公告列表、未读数与聚合信息中排除,也不可被标记已读。
- 置顶仅对普通用户公告开放(后台开关在选代理时禁用并复位,后端同步校验)。
2026-09-25 13:31:35 +08:00
becomer 5ee8e47b0f v1.2.84:扣款额度可配置(支持小数/0)+ 置顶公告横向滚动
ci/woodpecker/tag/build Pipeline was successful
- 新增系统设置 deduct_checkin/deduct_leave/deduct_report(默认 1),
  打卡/请假/销假从数据库读取扣减额度,支持小数与 0(0=不扣费);
  sign_count 由 int 改为 decimal(12,2) 以支持小数次数。
- 新增置顶公告:announcements.is_pinned 字段 + /announcements/pinned 接口,
  打卡页顶部横向无缝滚动展示,点击查看详情;后台公告管理支持置顶开关与标识。
- 后台系统设置新增三项扣款额度配置;用户/后台的签到次数展示改为去尾零格式。
2026-09-25 13:07:14 +08:00
becomer af24472e2a v1.2.83:新建打卡任务名称增加匹配校验——改到匹配不上现有任务时直接提示并拦截
ci/woodpecker/tag/build Pipeline was successful
新建流程 Step 1 编辑任务名称后此前不校验,而执行打卡时后端按该名称 globMatch
实时匹配今日校园任务实例(FetchTodayTaskDetail),名称改坏即永远匹配失败。
现前端按与后端一致的 globMatch 规则、限同 taskType 比对现有任务名,命中则实时
提示,未命中在点「下一步」时弹窗拦截,不创建任务也不进入 Step 2。
2026-09-23 23:30:12 +08:00
becomer 518f3506df v1.2.82:修复新套请假审批人查询失败——时间格式统一裁秒
ci/woodpecker/tag/build Pipeline was successful
新套 getApproveFlowList 按 "yyyy-MM-dd HH:mm" 严格解析(如 hbgyzy 病假类型 7151
报 unparsed text found at index 16),前端补秒的 19 字符时间导致接口报错,
前端静默降级后误显示"该校该请假类型未配置审批人"。在 FetchLeaveNewApproveFlow
服务层统一裁秒,覆盖前端预取与提交执行两条调用路径。
2026-09-23 00:14:53 +08:00
becomer 21b37c6ad8 v1.2.81:请销假链路健壮性与设备码校验
ci/woodpecker/tag/build Pipeline was successful
- leavePost 统一校验 HTTP 状态码与空 body:非 2xx 报「学校服务端响应异常(HTTP xxx)」,
  空 body 报「可能触发风控或瞬时故障」,不再抛出无意义的 JSON 解析错误
- 旧套销假识别 2210990000(需附件):提示上传附件后重试
- 打卡/请假/销假执行入口补后端设备码硬校验;请假页前端入口补绑定引导弹窗
- SMS 注册密码不再使用设备码样式的随机值(避免与设备码绑定语义混淆)
2026-09-22 19:14:26 +08:00
becomer a7180eebba v1.2.80:根治 cookie 失效误报——统一识别登录态失效并自动续期
ci/woodpecker/tag/build Pipeline was successful
① 问题:用户赵佳威(芜湖职业技术大学)反馈「无法获取签到任务」。
   日志显示 whit.campusphere.net 两类查询均报
   "解析任务列表响应失败: invalid character '<' looking for beginning of value"
   —— 今日校园返回 HTML(请求被 302 到 authserver 登录页),即 cookie 已失效。
   其 MOD_AUTH_CAS 票为 09-11 签发、早已过期;而 last_login_at 显示 09-21 是
   jwt 中间件 touchDailyLogin 刷新的"当天使用"标记,并非真实重登,故造成
   "看起来刚登录过、却取不到任务"的假象。

② 根因:原 isCookieExpired 仅匹配错误文案「未登录/登录已过期」,而 HTML 响应的
   报错是 invalid character '<',匹配不到 → 既无法识别、更不会触发续期,
   用户只看到空任务列表。该问题影响所有「已登录后调用今日校园」的接口
   (任务列表、用户信息同步、请销假等)。

③ 修复:
   - cpdaily 层新增 ErrCookieExpired 与 unmarshalBody:解析响应体前先判断
     是否为 HTML,是则返回 ErrCookieExpired(42 处 body 解析统一接入;
     登录/发码/滑块等登录流程不涉及用户 cookie,保持原样不误伤);
   - FetchAllRecentTasks 改为返回 error(两类查询都失败时),供上层识别续期;
   - isCookieExpired 合并 ErrCookieExpired 判定 → 既有 25 处调用点自动受益;
   - 新增 withCookieRefresh(通用包装):检测到登录态失效 → refreshCpdailyCookie
     (长期凭证优先,其次密码重登)→ 用新 cookie 重试一次;续期失败返回
     ErrCookieExpired,上层转「登录凭证已过期,请重新登录」;
   - 应用于 GetTaskTitles、GetPendingLeaves(含新/旧套件)、syncUserProfile(用户信息)。
   - 防递归:refreshCpdailyCookie 内部同步用户信息时禁续期
     (syncUserProfileWith allowRefresh=false),避免「续期→同步→再续期」风暴。

④ MFA 学校说明:MFA 通道登录签发短期 JWT,到期即要求重新登录,本就不需自动续期;
   非 MFA 学校(如本例)的 cookie 由上述链路自动续期,用户不再被莫名踢出。

⑤ 验证(真实数据,只读):
   - 用赵佳威过期 cookie 调 FetchAllRecentTasks:由原来的 invalid character '<'
     变为 cpdaily_cookie_expired,errors.Is(err, ErrCookieExpired)=true;
   - 模拟完整链路:旧 cookie 失败 → 用其保存密码重登成功(6 个 cookie、含新 MOD_AUTH_CAS、
     无 MFA)→ 新 cookie 重试成功取到任务「查寝」;
   - go build / go vet / go test ./... 全过。
2026-09-21 16:57:21 +08:00
becomer 386c6e2bf8 v1.2.79:工单改为软删除——用户删除后管理员仍可见(保留处理痕迹)
ci/woodpecker/tag/build Pipeline was successful
① 问题:用户可删除自己的工单,且管理员完全看不到。根因是 Ticket 无软删除,
   删除为物理删除并级联删除全部消息,记录从表中消失,管理端列表/统计均无痕迹,
   用户可借此抹掉工单内容与管理员的全部回复。

② 修复:
   - model.Ticket 增加 DeletedAt gorm.DeletedAt(AutoMigrate 自动加列,
     既有数据 deleted_at 为 NULL 视为未删除,不受影响);
   - DeleteTicket 改为软删除,且不再级联删除 TicketMessage / TicketNotification,
     保留完整对话与通知,供管理端追溯;
   - 管理端 AdminListTickets / AdminGetTicket / AdminReplyTicket /
     AdminUpdateTicketStatus 改用 Unscoped(),可查看并继续处理已删工单,
     列表与详情响应新增 deleted 字段;
   - 用户端(ListMyTickets / GetTicketDetail / AddTicketMessage / CloseTicket)
     不加 Unscoped,GORM 默认过滤 deleted_at,用户看不到自己已删除的工单;
     未关闭工单计数同样自动排除已删除(不再计入"进行中")。

③ 前端:管理员工单列表与详情在状态标签旁展示「用户已删除」标记
   (含 title 提示);api/ticket.ts 的 AdminTicketListItem / TicketDetail 增加 deleted。

④ 验证:在真实 MySQL 上用临时表(tmp_softdel_probe,验证后已删除)确认三项语义——
   默认查询过滤软删除(count=0)、Unscoped 可见且 deleted_at 可读、
   管理端 Unscoped().Table().Select("t.*") + 内嵌结构体扫描能读出 deleted_at;
   go build / go vet / go test ./... 通过;vue-tsc --noEmit、npm run build 通过。

注:此前已被物理删除的工单无法找回,本改动仅对之后生效。
2026-09-21 13:35:39 +08:00
becomer 2dd1180bf8 v1.2.78:修复手机号登录失败无提示、页面被重置回登录页
ci/woodpecker/tag/build Pipeline was successful
① 现象(用户实测手机号 13551503012):点击登录后页面看似跳转,
   实则被重置回 /login、输入信息清空,且没有任何错误提示。

② 根因:后端 SmsLogin 登录失败返回 HTTP 401(如「登录失败: 验证码错误…」),
   而前端 axios 响应拦截器把 401 一律视为「登录态过期」→ 尝试用 refresh_token
   刷新 → 登录页无 refresh_token → catch 清除 access/refresh token 并
   window.location.href='/login'。
   既有白名单只排除了 /auth/login 与 /auth/refresh,新增的 /auth/sms/* 未覆盖
   ('/auth/sms/login' 并不包含子串 '/auth/login'),故被误判。
   真实错误消息(后端 message)也随之被吞掉,用户只看到页面被重置。

③ 修复(frondend/src/api/index.ts):401 刷新分支增加
   !originalRequest.url?.includes('/auth/sms/') 排除项,
   使手机号登录(发码/登录)的 401 作为普通业务错误抛给调用方 catch,
   由登录页 toast 展示真实原因。

④ 实测复现与验证:容器内用真实图片验证码调用 /api/auth/sms/login,
   服务端返回 401 {"code":401,"message":"登录失败: 验证码错误次数上限5次,您已输错1次"},
   与该手机号毕业/验证码状态一致的业务错误;修复后该消息会正常显示而非重置页面。
   vue-tsc --noEmit、npm run build 通过。
2026-09-20 09:42:16 +08:00
becomer a0064881b3 v1.2.77:手机号登录增加两个独立图片验证码(发码 / 登录各一)
ci/woodpecker/tag/build Pipeline was successful
① 需求:手机号登录的「获取验证码」与「输入验证码登录」两步都需要各自的图片验证码,
   且两者相互独立(不共用同一个 captchaId)。

② 后端(backed/internal/handler/sms_login.go):
   - SmsSendRequest / SmsLoginRequest 增加 captchaId + captchaCode 字段;
   - SmsSend 与 SmsLogin 各自调用 service.VerifyCaptcha 校验,
     缺失或错误分别返回「请输入图片验证码」/「验证码错误或已过期」。
   (原 SmsSend 仅有 IP 限流,现补上图片验证码,防脚本刷短信。)

③ 前端(LoginView.vue + api/auth.ts):
   - 新增两组独立验证码状态:发送用(smsSendCaptcha*)与登录用(smsLoginCaptcha*),
     各自有 id / image / 输入 / 错误 / loading;
   - 通用刷新函数 loadSmsCaptcha('send'|'login');进入手机号模式时两组各取一份;
   - 手机号模式下渲染两个验证码输入框(发送前一个、登录前一个);
   - sendSmsCode / smsLogin 增加 captchaId + captchaCode 参数;
   - 发码失败或登录失败分别刷新对应验证码。

④ 邀请码兼容:手机号登录路径继续透传 inviteCode(URL ?invite= 自动带入),
   新用户注册时经通用注册流程发放邀请/注册奖励;已有账号匹配时不重复发奖。

⑤ 验证:go build / go vet 通过;vue-tsc --noEmit、npm run build 通过。
2026-09-19 23:41:43 +08:00
becomer 4ca952f2ac v1.2.76:新增手机号验证码登录(无需选学校)+ 与学工号登录账号互通
ci/woodpecker/tag/build Pipeline was successful
① 手机号验证码登录(对标旧项目 likedream-go login/sms.go):
   - 短信发码:POST https://mobile.campushoy.com/app/auth/authentication/mobile/messageCode/v-8222
     (手机号 AES 加密后以 a=参数提交);
   - 验证码登录:POST .../mobileLogin/v-8222,返回 a=base64(AES(json)),解密后含
     tenantId(学校租户)、openId(学工号)、sessionToken、tgc(CASTGC);
     —— tenantId 由登录响应直接给出,故**无需用户先选择学校**;
   - 换取 CAS cookie:GET {学校域名}/wec-im-group/group/imUserToken/getImUserToken,
     带 AmpCookies 头(DES 加密 {AMP1,AMP2:[{name:sessionToken}]})与预置
     clientType/tenantId/standAlone/sessionToken/CASTGC cookie,换回含 MOD_AUTH_CAS 的 cookie。

② 长期凭证续期:cookie 含 sessionToken(长期有效),cookie 过期时优先用
   RefreshCasCookie 直接换取新 CAS cookie,不再依赖 raw_password——
   手机号验证码登录的账号没有密码,原「密码重登」路径无法续期;
   续期失败再回退原密码重登逻辑(学工号登录用户不受影响)。

③ 账号互通(兼容手机号与学工号登录指向同一用户):
   - 手机号登录:按「同校 + 同学工号(openId)」匹配已有账号并复用;未命中则按
     用户名(学校短码+学工号)再匹配;仍未命中才走通用注册流程(复用邀请/注册奖励);
   - 手机号登录后回填 student_id 与 mobile_phone,使其之后用学工号登录也能匹配到;
   - 学工号登录(findOrCreateUser)增加「同校 + 同学工号」兜底匹配,
     反向兼容「先手机号注册、后学工号登录」的场景。

④ 前端登录页:新增「学工号登录 / 手机号登录」切换;手机号模式下仅需
   手机号 + 短信验证码(含 60s 倒计时),不选学校。

⑤ 验证:go build / go vet / go test ./... 通过;vue-tsc --noEmit、npm run build 通过。
   加密键与旧项目一致(AES-128-CBC / DES-CBC,PKCS7),复用既有 crypto.go。
2026-09-19 23:14:29 +08:00
becomer edeabb0fc0 v1.2.75:回滚「每日签到积分」功能(保留验证码框修复)
ci/woodpecker/tag/build Pipeline was successful
v1.2.74 的签到积分功能经评估效果不佳,按需求回滚:
- 移除 User.checkin_points 字段;
- 移除 POST /api/auth/checkin 接口及路由;
- 打卡/请销假恢复为仅扣 sign_count(移除「优先消耗 1 积分」分支与 deductQuota);
- 移除个人中心「签到积分」入口、签到弹窗与 api/auth.ts 的 checkin 方法及类型字段。

保留 v1.2.74 中的「验证码框留白修复」(与签到积分无关):
- 个人中心验证码容器 w-24 → w-[120px](3:1 匹配生成图 180x60)、图片 object-cover;
- 登录页两处验证码图片 max-w-full/max-h-full object-contain → w-full h-full object-cover。

验证:grep 确认无 checkin 残留;go build / go vet 通过;vue-tsc --noEmit 通过。
2026-09-19 11:41:42 +08:00
becomer 559b146408 v1.2.74:新增每日签到积分(个人中心)+ 打卡/请销假优先消耗积分;修复验证码框空隙
ci/woodpecker/tag/build Pipeline was successful
① 签到积分体系:
   - User 新增 checkin_points(decimal(10,4),0~1),AutoMigrate 自动加列;
   - 新增 POST /api/auth/checkin:校验已有图片验证码后,随机发放 0.01~0.30
     积分(保留 4 位小数),封顶 1.0;达 1 后不可再签到(原子条件更新防并发超发);
   - 个人中心新增「签到积分」入口:显示 当前积分/1 与签到按钮(达上限置灰「已达上限」),
     弹窗内含积分规则、当前积分与图片验证码。

② 打卡 / 请销假改为优先消耗签到积分:
   - 额度检查与扣减逻辑由「仅 signCount」改为「积分≥1 优先扣 1 积分,否则扣 1 次数」;
   - 覆盖 3 处执行点:任务执行(task.go)、销假新套与旧套(leave.go),
     抽公共函数 deductQuota 复用;均用条件更新原子扣减;VIP 仍免扣。

③ 修复验证码框留白:
   - 生成图为 180x60(3:1,代码强制下限),而个人中心验证码容器原为 w-24 h-10(2.4:1),
     图片按宽度缩放后上下留空。统一容器宽度为 w-[120px](3:1),
     并把图片改为 w-full h-full object-cover 填满(登录页两处同改)。

④ 验证:go build / go vet 通过;vue-tsc --noEmit、npm run build 通过。
2026-09-19 11:15:25 +08:00
becomer d8f2c3676c v1.2.73:待销假列表过滤已驳回的请假记录
ci/woodpecker/tag/build Pipeline was successful
① 问题:销假时「待销假列表」出现已驳回状态的请假记录(用户 陈莎莎/1038
   即是此情况),这类记录无法销假,不应出现在列表中。

② 根因:旧套待销假列表 FetchLeaveList 只过滤了 status=="4"(已完成),
   未过滤驳回状态。实测该用户旧套接口返回的记录 status="3"(前端映射「已驳回」,
   事假/教资认定),被原样放进列表。
   (新套 fetchLeaveNewPendingList 已按 3/4 白名单过滤,无此问题;该校走旧套。)

③ 修复:FetchLeaveList 中一并过滤驳回状态——
   status=="3"(已驳回)与 status=="10"(逾期自动驳回)。

④ 验证:go build ./... 通过。只读实测确认该用户旧套记录 status=3 已被排除。
2026-09-19 10:54:12 +08:00
becomer cd963d3834 v1.2.72:修复用户管理执行弹窗「已删除」标签内部换行
ci/woodpecker/tag/build Pipeline was successful
v1.2.71 把状态列改为纵向排列后,标签仍缺 whitespace-nowrap,
在窄列内文字被折行(「已删/除」)。为「成功/失败」与「已删除」两个标签
补上 whitespace-nowrap,保证文字单行显示。
2026-09-19 10:44:01 +08:00
becomer c5e0888639 v1.2.71:修复后台 CDK 复制无效;用户管理执行弹窗状态标签换行
ci/woodpecker/tag/build Pipeline was successful
① 用户管理「当日执行」弹窗:状态列的「成功/失败」与「已删除」两个标签原在同一行
   (td 带 whitespace-nowrap,列宽 w-[60px]),在窄列下被挤到溢出、视觉上串到
   「说明」列。改为纵向排列(flex-col + gap-1,各占一行),表头宽度放宽到 w-[72px]。

② CDK 管理复制「提示成功但实际粘贴不出内容」:
   根因是 copyToClipboard 把临时 textarea 挂到 document.body,而复制按钮位于
   AppDialog(reka-ui)弹窗内——DialogContent 用 FocusScope 锁定焦点,document 上的
   focusin 监听发现焦点跑到 dialog 容器外就立即抢回,导致 ta.focus()/select() 失效,
   选区为空;execCommand('copy') 仍返回 true,于是「显示已复制、实际为空」。
   修复:
   - textarea 优先挂到当前打开的 dialog 容器内(role="dialog" 中最靠后可见者),
     避免焦点被 FocusScope 抢回;
   - 复制前校验选区确实落在 textarea 上(selectionStart/End 与 activeElement),
     校验不过则不再盲信 execCommand 的返回值,转而走 Clipboard API 兜底;
   - 移除 textarea 改为按实际父节点(不再假设是 body)。
   该修复同时惠及所有位于弹窗内的复制按钮(CDK 单个/全部复制、待审学校等)。

   注:AdminUsers 的设备码复制(copyDeviceCode)未在本次范围内,保持原样。
2026-09-19 10:38:42 +08:00
becomer 3bcc32b74e v1.2.70:网课页新增「我有网课,点击这里」贴边按钮(跳转工单)
ci/woodpecker/tag/build Pipeline was successful
模仿打卡页「收藏本站」与请假页「无法请/销假?提交工单」的右侧贴边窄竖条样式,
在网课页加入「我有网课,点击这里」按钮,点击跳转 /ticket 工单页。

- CourseView.vue:新增按钮(fixed right-0 bottom-32, bg-rose-500/95)与 goTicket();
- 复用同款竖排文字样式 .bookmark-vtext(writing-mode: vertical-rl)。
2026-09-18 22:36:27 +08:00
becomer 8de7470b9d v1.2.69:底部 TabBar 新增「网课」入口(开发中占位页)
ci/woodpecker/tag/build Pipeline was successful
① 需求:底部导航新增「网课」,放在个人中心左侧(第三个位置):
   打卡 → 请/销假 → 网课 → 个人中心。

② 改动:
   - 新增 src/views/course/CourseView.vue:顶部标题栏 + 居中占位「正在努力开发中」;
   - src/layouts/MobileLayout.vue:tabs 数组插入 { name: 'course', label: '网课', icon: BookOpen },
     并补充 BookOpen 的 import;
   - src/router/index.ts:MobileLayout 子路由新增 /course(置于 leave 与 profile 之间),
     注册 CourseView。

③ 说明:网课页不走 home/leave 的顶部蓝色渐变头(第 258 行仅这两者显示),
   使用自身标题栏。引导流程仅涉及 home/profile,不受影响。
2026-09-18 22:20:49 +08:00
becomer c90784de67 v1.2.68:编辑表单优先取历史记录的真实经纬度/地址(并校验在打卡范围内)
ci/woodpecker/tag/build Pipeline was successful
① 问题:编辑打卡任务表单时,经纬度与地址取到的是学校配置的"打卡范围信息"
   (如 signPlaceSelected[0].address = "新建的签到地点"),而非历史记录里的真实
   签到位置。实测用户 1095(兰州财经大学,晚归寝打卡)确认。

② 根因:ParseTaskForm 的定位取值顺序是「signPlaceSelected[0] 优先」——
   该字段是学校配置的范围点(含范围点名与范围点坐标),被排在历史记录的实际
   签到经纬度之前,于是历史真实坐标/地址永远取不到。

③ 修复(backed/internal/service/cpdaily/task.go):
   - 改为「历史记录优先」:先取 histRows 的 longitude/latitude 与 signAddress/position;
   - 但新增范围校验 coordsWithinSignRange:历史坐标必须落在打卡范围内
     (复用已有 parseSignPlaces + haversineMeters,逐点比对 radius);
     超出全部范围点时放弃历史坐标与地址,回退到 signPlaceSelected[0] 范围点,
     避免服务端以"位置不在签到范围内"拒绝提交;
   - 无范围点配置时不做限制(沿用历史坐标);历史坐标缺失同样回退范围点。
   - 无历史记录(histRows == rows)时行为与改动前一致,无回归。

④ 验证(真实数据,只读):
   - 修复前 lon=103.89427283364513 / lat=36.053086828712225 / address=新建的签到地点;
     修复后 lon=103.894854 / lat=36.052122 /
     address=中国甘肃省兰州市城关区拱星墩街道段家滩路426号(历史真实值);
   - 实测该历史坐标距范围点 119.3m < 240m,判定在范围内 → 正确采用历史值;
   - 超范围/空坐标/无范围点三类边界均按预期;
   - 无历史记录分支输出与改动前一致。
   - go build / go vet / go test ./... 全过。
     顺带修正 v1.2.66 调序后未同步的两个测试断言(二维码项已移至末尾)。
2026-09-18 21:48:33 +08:00
becomer 186af6b604 v1.2.67:修复 MFA 登录报「生成验证会话失败」——运维配置 Redis 改用容器名
ci/woodpecker/tag/build Pipeline was successful
① 问题:中国矿业大学(cumt,需 MFA)登录报「生成验证会话失败」。
   日志证据:MFA参数解析 userId=TS26040188P31(中国矿业大学)后,
   ERROR MFA会话暂存失败 error="dial tcp 172.22.0.7:6379: connect: connection refused"。

② 根因:宿主机 /opt/cpdaily-xiaozhushou/config.toml 的 [redis] host 写死为
   "172.22.0.7"。该 IP 曾是 Redis 容器地址,但容器重启后 IP 漂移,现已被
   likedream-go 占用;原 Redis 现为 172.22.0.8 / likedream-redis 为 172.22.0.6。
   即写死 IP 的经典陷阱:容器重建后 IP 易主,配置指向了非 Redis 的容器。
   MFA 中间会话必须跨请求保留(start → send → verify 三步),硬依赖 Redis,
   故 Redis 不可用时该步直接失败——captcha 有内存降级,MFA 没有(设计上不可降级)。

③ 修复(仅运维配置,不入库):
   /opt/cpdaily-xiaozhushou/config.toml 的 host 由 "172.22.0.7" 改为容器名
   "1Panel-redis-zDUv"(同属 likedream-net,容器名 DNS 稳定,不随 IP 漂移;
   端口 6379 与密码 redis_4APk8B 本就对应此实例,无需改动)。
   修改前已备份为 config.toml.bak.<时间戳>。

④ 验证:两个槽位(8884/8885)重启后,
   - 启动日志不再出现 "Redis 连接失败(...connection refused)";
   - 调用 /api/auth/captcha 后,Redis 相关警告行数由每次必现降为 0
     (此前每次操作必打「验证码写入 Redis 失败,降级内存存储」)。
   已确认 Redis 连通,未复测完整登录流程。

⑤ 本提交为空提交(--allow-empty):本次修复全部在宿主机运维配置,
   无仓库代码变更,仅以此 commit 记录版本事件与排查结论。
2026-09-18 21:28:43 +08:00
becomer a3ec85f4d3 v1.2.66:扫码签到表单项顺序调整——经纬度/地址在前,二维码置于最后
ci/woodpecker/tag/build Pipeline was successful
扫码签到表单原顺序为「二维码ID → 经度 → 纬度 → 地址 → 照片 → 额外字段」,
与普通签到(定位在前)不一致。现调整为「经度 → 纬度 → 地址 → 照片 → 额外字段 → 二维码ID」,
定位字段顺序与普通签到对齐,二维码作为扫码任务特有项放在最后。

仅调整 parseScanTaskForm 中表单项的 append 顺序;fillScanSubmitForm 按 colName 查找字段,
不依赖顺序,提交逻辑不变。go build ./... 通过。
2026-09-18 18:05:50 +08:00
becomer ee466b253d v1.2.65:修复扫码签到报错 empty String——补上经纬度/地址
ci/woodpecker/tag/build Pipeline was successful
① 问题:用户陈莎莎(昆明文理学院,任务 139「2027届毕业生体检签到」)扫码签到
   始终报 "empty String"。
   查日志:submitSign 响应 code=-1 message="empty String",即今日校园服务端返回的
   校验错误(我们代码里无此字符串,仅原样透传)。

② 根因:该任务 signMode=2(扫码签到)。v1.2.40 把扫码签到提交体改为
   「用 qrUuid 替代 signInstanceWid、不携带经纬度」,但实测服务端仍要求定位字段:
   SubmitSign 的提交信封里 lat/lon 取 submitForm 的 latitude/longitude,
   扫码分支被留空 → 提交外层 {"lon":"","lat":""} → 服务端把空串转数字抛
   NumberFormatException: empty String。对照官方 qrsign 页面 JS(counselor-front),
   其 submitSign 的 body 明确含 position 与 ...currentPosition(经纬度),
   印证定位字段是必需的。历史上扫码签到从未成功过一次(成功记录均为普通定位签到)。

③ 修复(对齐普通签到):
   - parseScanTaskForm 新增「经度/纬度/签到具体地址」三个表单项(lon/lat/address),
     取值优先级与普通签到一致:signPlaceSelected[0] → 历史/详情的 longitude|latitude
     → 详情的 position|signAddress;抽为 extractScanPosition 复用。
   - fillScanSubmitForm 提交体补上 longitude/latitude/position,
     表单未提供时回退任务详情的学校默认坐标。
   - 前端按 colName 渲染,新字段自动显示,无需改动。

④ 验证:go build ./... 、go vet ./... 、go test ./... 全过
   (同步更新扫码任务的表单/提交体断言)。不检查部署。
2026-09-18 18:00:41 +08:00
becomer 2effdbaaf9 v1.2.64:删除等操作的原生 confirm/alert 统一改为项目 AppDialog
ci/woodpecker/tag/build Pipeline was successful
① 问题:删除任务的确认框是浏览器自带弹窗,与全站统一样式不一致。
   排查确认这是 v1.2.55「弹窗统一化」的遗留缺口——那次只扫描并迁移了手写
   role="dialog" 元素(提交信息称「全项目手写 role=dialog 残留归零」),
   而 confirm()/alert() 是浏览器原生调用、不产生 role="dialog" 元素,
   故从未被纳入扫描范围。「归零」对 role="dialog" 属实,但原生弹窗不在其内。

② 全项目原生弹窗共 9 处 / 4 文件:
   HomeView:删日志、删任务(confirm);保存失败 ×2、获取表单失败(alert);
   TicketList:关闭工单、删除工单(confirm);
   LeaveView:删日志(confirm);AdminAnnouncements:删公告(confirm)。

③ 修复:
   - 新增 composables/useConfirm.ts:模块级单例服务(对齐既有 useMessage 风格),
     confirmDialog()/alertDialog() 返回 Promise;重复调用时旧的以 false 结清,
     避免 Promise 悬挂;respond 延迟 200ms 清空 state 以配合离场动画。
   - 新增 components/ConfirmHost.vue:全站唯一弹窗宿主,挂到 App.vue,
     z-index 取 z-[300](高于版本更新弹窗的 z-[200])。
     视觉对齐现有居中弹窗(圆形图标底 + 标题 + 灰色说明 + 双按钮);
     danger=true 时红色图标底 + destructive 确认按钮(--color-destructive #ef4444)。
   - 9 处调用点改为 await confirmDialog/alertDialog;
     删除类置 danger:true,关闭工单等非破坏性操作不置。

④ 验证(CDP 驱动真实 Chrome 实测,非仅静态检查):
   - 弹窗为 role="dialog" 的项目组件,原生 confirm/alert 调用次数均为 0;
   - 取消返回 false、确定返回 true、Esc 视为取消(false),按钮文案/颜色符合预期;
   - 截图确认三种形态(危险确认/普通确认/单按钮提示)视觉与现有弹窗一致;
   - 源码与构建产物双重扫描:代码级原生弹窗残留 0 处;
   - vue-tsc --noEmit、npm run build 通过。不检查部署。
2026-09-18 17:20:20 +08:00
becomer e979e3cc9f v1.2.63:修复二维码解析失败——改用两阶段多尺度扫描 + Otsu 兜底
ci/woodpecker/tag/build Pipeline was successful
① 问题:用户上传二维码报「未识别到二维码」,但同一张图用草料二维码能正常解析。
   实测该图(1176x1568,斜拍屏幕、二维码区域约 674x674,周围有深色文字噪声):
   jsQR 在原生分辨率与放大后均无法解码,仅在特定缩小档位可解。

② 根因:jsQR 使用固定的 8x8 分块自适应二值化,只有当"二维码模块边长"与块尺寸
   量级相当时才能正确二值化,因此对缩放极其敏感——同一张图某档能解、相邻档即失效。
   原实现只试 [1, 0.6, 0.4, 1.5] 四档,恰好只有 0.4 落在可解窗口内,属侥幸命中;
   而不同浏览器/WebView 的 canvas 重采样算法不同,可解档位会整体漂移,
   故出现"桌面偶尔能过、手机端始终失败"。实测在 Google Chrome 下将重采样设为
   nearest 时,原实现即失败。

③ 修复(HomeView.vue 的 decodeQrInBrowser 重写):
   - 多尺度细扫:按"目标长边"从 min(原图长边,1400) 起以 0.72 递减至 200,
     覆盖连续可解窗口,不再依赖单档侥幸命中;
   - 两阶段:阶段一逐档直接解码;阶段二对每档先做全局 Otsu 二值化再解码,
     补足对比度差/斜拍屏幕的图片(阶段一失败改为 break 而非 return,
     并只分配一半预算,确保 Otsu 兜底始终有机会执行);
   - 时间预算 3000ms 封顶;逐档 await 让出主线程,避免"解析中"loading 卡死。
   - 新增 loadImageFromFile / otsuBinarizeInPlace 辅助函数。

④ 验证(Google Chrome headless,用真实构建产物抽取解码函数运行):
   - 用户原图 + 10 个扰动变体(旋转/亮度/模糊/噪声/重压缩/低分辨率)共 12 张全部解出正确 uuid;
   - 2 张无二维码图片正确返回空;
   - 四种重采样模式(high/medium/low/nearest)全部通过(原实现 nearest 失败);
   - 真实 CPU 耗时:成功约 0.3–0.9s,失败(无二维码)约 4.6s 且全程逐档让出主线程;
   - vue-tsc --noEmit、npm run build 通过。不检查部署。
2026-09-18 16:57:27 +08:00
becomer 4ec3e55030 v1.2.62:二维码解析全面移到前端,移除后端解析接口与 gozxing 依赖
ci/woodpecker/tag/build Pipeline was successful
① 前端:取消二维码解析的后端降级。二维码一律由前端 jsQR 解码并提取 uuid;
   识别失败直接提示用户换更清晰的图片,不再把图片回传后端。
② 后端:删除 POST /task/qr/decode 接口(handler.DecodeTaskQr)及其路由;
   删除 qrcode.go 中所有图片解码相关代码(DecodeQRFromImage / decodeQRImage / scaleImage /
   ResolveQrUuidFromContent / decodeQrTextToUuid)——后端不再接收二维码图片。
   保留 ExtractQrUuid(纯正则文本提取),提交签到(fillScanSubmitForm)与历史值归一化仍在用。
③ 依赖:go mod tidy 移除 github.com/makiuchi-d/gozxing(不再被引用)。
   golang.org/x/image 因验证码字体(captcha.go)仍在用,保留。
   删除仅供已删测试使用的 internal/service/cpdaily/testdata/test_qr.png。
④ 前端 API:移除 auth.ts 的 decodeQr()。
⑤ 验证:go build / go vet / go test ./...、vue-tsc --noEmit、npm run build 全过。
2026-09-18 16:40:15 +08:00
becomer 0bd354cfee v1.2.61:二维码改前端解析(jsQR)+ 图片上传前压缩 + 修复 message 解构错误 + 上传 loading
ci/woodpecker/tag/build Pipeline was successful
① 修复 message 解构错误(导致「undefined is not an object (evaluating 't.warning')」):
   useMessage() 返回的是 { toasts, success, error, info, warning, removeToast } 对象本身,
   而 HomeView 与 LeaveView 写成了 const { message } = useMessage(),解构出的 message 恒为 undefined,
   所有 message.xxx() 调用都会抛错。改为 const message = useMessage()(其余 18 个文件均为正确写法)。

② 二维码改为前端 jsQR 解析(问题 3、4):
   上传二维码时不再走后端解析,直接在前端用 jsQR 从画布像素解码(多尺度尝试 + attemptBoth),
   提取 uuid 存表;前端识别失败时降级调用后端 /task/qr/decode(gozxing 多尺度重试)兜底。
   好处:免上传、免压缩、响应快;且能正确解析 https://...?uuid=xxx&isNeedExtra=0&schoolId=... 这类链接。
   新增依赖 jsqr@^1.4.0。

③ 图片上传前前端压缩到 ≤300KB(问题 4):
   签到照片等需上传 OBS 的图片,先经 canvas 逐步降质量/降尺寸压缩到 300KB 以下再上传,
   避免大图(如 4032x3024)直传。二维码字段不走压缩(不保存图片,只存 uuid)。

④ 二维码上传增加 loading(问题 1):
   原二维码分支从未设置 uploadingImage,故无 loading 效果。现统一走 uploadingImage + uploadingLabel
   (解析中/压缩中/上传中),二维码与图片两种字段的 loading 块都补上阶段文案。

⑤ 验证:vue-tsc --noEmit、npm run build 通过。不检查部署。
2026-09-18 16:36:37 +08:00
becomer 6e145f75e1 v1.2.60:修复签到照片可能以 base64 存入 formData
ci/woodpecker/tag/build Pipeline was successful
① 问题:签到照片(signImg)本应上传今日校园 OBS 后保存正式 URL,但实测存在 2.1MB 的 base64 记录(id=42)。
   根因在前端 handleImageUpload:先把所选图片读成 dataURL 写入 content 作本地预览,上传 OBS 成功后才覆盖为
   previewUrl;而以下任一情况都会把 base64 留在表单里并被保存:OBS 返回非 2xx(原代码直接 return 不回滚)、
   获取预览 URL 失败(同样不回滚)、网络异常、以及用户在上传完成前就点了保存。
   (后端 ExecuteTask 对 base64 有自动补传 OBS 的兜底,所以任务仍能成功,但表单里一直存着大字段。)

② 修复:
   - handleImageUpload 各失败路径回滚 content 为原值(prevContent),不再残留 base64;
   - 新增 guardBase64Images:保存前校验图片字段不得为 data: 开头(本地预览),
     在 saveForm / saveEdit 两处拦截并提示,避免上传未完成时保存。

③ 数据清理(已执行,不可回滚):清空 id=133(二维码 base64,2.9MB)与 id=42(签到照片 base64,2.1MB)
   的 form_data,让用户重新填写;全库已无 >50KB 的 formData 记录。
   同时删除调试探针 backed/internal/tmpprobe/(含硬编码用户数据,未被任何代码引用)。

④ 验证:vue-tsc --noEmit、npm run build 通过;go build ./... 通过。
2026-09-18 16:26:12 +08:00
becomer 879628d0eb v1.2.59:修复首页接口传输 2.9MB(列表瘦身 + gzip)+ 二维码只存 uuid
ci/woodpecker/tag/build Pipeline was successful
① 根因:GET /api/task/list 每次返回约 2.9MB。ListTasks 用 Find 返回整行 CpTask(含 longtext 的 formData),
   而历史数据里 formData 存过 base64 图片(id=133 的 qrCode 单条 2,959,961 字节,实为 4032x3024 现场照片),
   且 nginx gzip_types 未覆盖 application/json,明文传输。该任务创建于 09-18 13:25,与“今天突然变慢”吻合。
   实测:任务仅 62 条,form_data 合计 515 万字节;某 IP 单日拉取 53 次 ≈ 99MB。

② 列表瘦身:ListTasks 改返回轻量 DTO TaskListItem(不含 formData),并新增服务端预计算的 missingFields
   (用已加载的 formData 计算,零额外 IO)。新增 GET /task/form-data/:id 返回该任务已保存表单,供编辑弹窗预填
   (/task/form/:id 是从今日校园重拉模板会丢已填值,不能替代)。前端 executeTask 预检改用 missingFields,
   openEditModal 改走新接口;TaskInfo.formData 改为可选。

③ 二维码只存 uuid:新增 POST /task/qr/decode,复用后端已有 gozxing 解析(不引入新库),
   前端上传后仅保存 uuid(Prompt:中文名“二维码ID”,colName=uuid),不再写入整张 base64 图片。
   关键修正:ExtractQrUuid 原先只认 32 位 hex 裸串,而链接里的 uuid 允许 [0-9a-zA-Z_-]+,
   仅存裸 uuid 会导致非 hex 学校执行时解析失败 → 新增 bareTokenPattern(8~64 位 token)分支,
   并让 fillScanSubmitForm 兼容 uuid/qrCode 双字段名(旧数据不受影响)。

④ gzip:在应用层加 fiber compress 中间件(nginx 由 1Panel 托管、跨 5 站点、不进版本库,不宜直改),
   用 Next 排除 SSE 端点 /api/task/execute/*(流式响应不可缓冲)。

⑤ 验证:go build、go vet、go test ./...(新增裸 uuid/非 hex uuid/字段归一化用例)、vue-tsc --noEmit、npm run build 全过。
   不清理历史 base64 数据(按要求暂不动)。
2026-09-18 16:09:20 +08:00
becomer 8435095b4f v1.2.58:改为真·蓝绿部署(消除部署期 502)+ 设备码按钮文案随绑定状态变化
ci/woodpecker/tag/build Pipeline was successful
① 部署方式重构为真正的蓝绿(修复 v1.2.55~57 每次部署约 3 分钟的 502 窗口):
   根因:旧脚本是「翻到备用端口 → 1 秒后翻回 → 在活跃端口重启容器」,最终稳态只剩一个容器;且两次 nginx -s reload 仅隔 1 秒,openresty 未设 worker_shutdown_timeout(仅 keepalive_timeout 60),旧 worker 会继续持有连接并按旧配置往已删除的备用端口转发 → 502 持续到旧 worker 排空(实测 17:28:27~17:31:21,8885 Connection refused 86 次,8884 为 0)。
   新方案:两个固定槽位(blue=8884 / green=8885)常驻。每次部署只更新「当前无流量的备用槽」→ 容器内健康检查 → nginx -t 校验 → 翻一次反代指向它 → 等旧 worker 全部退出;活跃槽容器保留作为上一版本供秒级回滚。因两端口始终都有活容器,旧 worker 无论是否排空都不可能出现 502。
   另含:旧命名容器一次性收养(cpdaily-xiaozhushou → cpdaily-xiaozhushou-<端口>,避免下次部署端口冲突)、清理 cpdaily-blue 陈尸容器、反代配置切换前备份与 nginx -t 失败自动回滚、健康检查失败时不动反代。脚本已用 mock docker 干跑验证:两次连续部署正确轮换(8885→8884)、健康检查失败分支正确中止且不改反代。
② 个人中心设备码按钮:未绑定设备码时显示「立即绑定」,已绑定显示「重新绑定」(新增 hasDeviceCode computed)。
③ 验证:vue-tsc --noEmit、npm run build(含 postbuild-css)全过。前端改动 + CI 配置,无后端逻辑变更。
2026-09-17 18:07:21 +08:00
becomer 200edd75bb v1.2.57:设备绑定页文案与返回按手机端场景调整
ci/woodpecker/tag/build Pipeline was successful
① 面向手机端(主要客户群为手机用户)重写操作引导,三步简化为两步:第 1 步「保存二维码」(点击上方按钮保存二维码图片)、第 2 步「打开今日校园右上角扫一扫」(扫描二维码,绑定成功后自动跳转);删除原第 3 步「等待自动完成」(信息已并入第 2 步副文案)。
② 顶部说明由「请用今日校园 App 扫描下方二维码完成绑定」改为「请扫描二维码,绑定设备信息」。
③ 底部返回按钮改用 router.back()(原先写死 push('/profile'));因该页当前仅由个人中心「重新绑定」进入,back 即回个人中心,同时避免依赖硬编码路由。
④ 清理与手机端矛盾的文案(原「扫描电脑屏幕上的二维码」)。验证:vue-tsc --noEmit、npm run build 全过。纯前端改动,无后端变更。
2026-09-17 17:27:56 +08:00
becomer b39f57b879 v1.2.56:设备绑定页底部「退出登录」改为「返回」——点击回到个人中心
ci/woodpecker/tag/build Pipeline was successful
① 需求:绑定设备页(/bind-device)底部的「退出登录」按钮改为「返回」,点击回到个人中心。
② 改动:BindDeviceView.vue 底部按钮图标 LogOut→ArrowLeft、文案「退出登录」→「返回」、handleLogout()→handleBack()(router.push('/profile'))。用 push 固定目标而非 back(),因为本页由个人中心「重新绑定」进入(ProfileView.vue:136),返回目标明确。
③ 清理:移除 handleLogout 与 LogOut import(tsconfig 开启 noUnusedLocals,不清理会导致 vue-tsc 报错)。退出登录功能未丢失——个人中心 ProfileView.vue:467-475 保留独立退出入口;轮询/倒计时的清理仍由 onUnmounted 承担,导航离开即触发,无泄漏。
④ 验证:vue-tsc --noEmit、npm run build(含 postbuild-css)全过。纯前端改动,无后端变更。
2026-09-17 17:16:00 +08:00
becomer f20a6b57c1 v1.2.55:弹窗统一化——全站手写弹窗迁移到统一 AppDialog 组件 + 国产浏览器/微信内核兼容
ci/woodpecker/tag/build Pipeline was successful
① 弹窗统一化(核心):新增 AppDialog 组件(reka-ui Dialog 底座),把 13 个视图文件里 34 处手写弹窗全部迁移,全项目手写 role="dialog" 残留归零。
   - 目的:以 Dialog 原语换取焦点捕获、Esc 关闭、body 滚动锁定、aria 语义与 Portal 渲染,同时保持与手写弹窗一致的视觉(面板基类 w-full/bg-white/flex-col/overflow-hidden/shadow-xl/max-h-[85vh])。
   - 公开 API 与底座解耦:open / variant(sheet|center) / z-class / overlay-class / wrapper-class / panel-class / dismissable / fade / @update:open / 默认插槽。
   - 逐处核对:阴影(原无阴影者显式传 shadow-none 抵消基类 shadow-xl、shadow-2xl 显式覆盖)、圆角(非基类值显式覆盖)、fade(原 <Transition name="fade"> 者传 :fade=true)、守卫(dismissable:LeaveView executing/applying、AdminCdks result 视图、AdminPendingSchools rejectSubmitting、HomeView「重新登录」原遮罩不可点故 false)、z-index(z-[60] 等)、定位层边距(wrapper-class px-4/6)。
   - fade 实现说明:reka 的 Presence 只识别 CSS animation(读 computed animation-name 并监听节点自身 animationend),不认 Vue 的 class 过渡;故用 CSS animation + reka 输出的 data-state="open|closed" 实现,挂在 DismissableLayer 根节点(即 role="dialog" 节点)。实现在 fade 时额外给 content 根加 relative + 同层 z-class:opacity<1 会形成 z-index:0 的层叠上下文,否则内部 fixed 面板会被 overlay 盖住。
   - 迁移固有行为差异:MobileLayout 公告面板失去 slide-up 上滑动画改为 fade;各弹窗新增 Esc 关闭能力(reka 默认);面板固定 overflow-hidden+max-h-[85vh](原无此二类的面板有微调)。非弹窗遮罩(AdminLayout 侧边栏抽屉、右侧滑出公告面板;GuideOverlay 引导层)按判断保留未迁移。

② 国产浏览器/微信内核兼容(chrome78+):
   - vite build.target 降到 chrome78(微信 XWEB 常见下限),旧的默认 target 会产出 Chrome 80 以下无法解析的新语法导致白屏。
   - 新增 scripts/postbuild-css.mjs 并接入 build:解包 :where、降级 oklch 颜色、剥离 @layer,产出对老内核可用的 CSS;含兜底校验(残留 @layer/oklch/:where 直接失败)。
   - index.html 内联补齐 Object.hasOwn(Chrome 93+,reka-ui 与 @vueuse/shared 内部调用;esbuild 只降语法不降运行时 API)。

③ 收尾修复既有问题:AdminUsers 删除从未使用的 Card 死 import;AdminCdks 修正 copiedResultIdx/resultCopiedIdx 变量名不一致(导致结果视图复制高亮失效并抛 ReferenceError);MobileLayout 原 .slide-up-leave-to 误用 transition(随迁移一并移除,问题消失)。

④ 验证:vue-tsc --noEmit、vite build(含 postbuild-css)全过;headless Chrome 实测整站启动渲染正常;全项目残留扫描确认无遗留手写弹窗。

⑤ 本次为纯前端改动(含 reka-ui 依赖),不含后端变更。
2026-09-17 16:05:11 +08:00
becomer 55eff6e80a v1.2.54:修复请假附件区被隐藏——根因:旧套学校 enableAttach 恒为 0,不能作为显示/放行条件——① 实测复现:陈莎莎(ysdwl 昆明文理学院)名下 6 个请假类型,queryLeaveTypeCfg 全部返回 enableAttach=0、attachmentNumberLimit=4(学校实际允许传 4 张,app 内亦显示为可选附件),而 v1.2.53 前端显示条件为 enableAttach===1,导致所有旧套学校附件区被隐藏;多校只读采样(马鞍山/兰州财经/安徽师大/芜湖职大/蚌埠医科/新疆师大/矿大)确认旧套 enableAttach 全为 0、limit 全为 4,属该字段在旧套链路的语义问题而非个例;② 判定修正:可传附件 = enableAttach===1 或 attachmentNumberLimit>0(前端新增 applyAttachVisible;旧套服务端校验同步改为「两者都表明不可用才拒绝」);上限仍取 min(4, 学校值);③ 新套侧补漏:leaveNewToLeaveTypes 在 isVisibledEvidenceFile=false 时不再带出 evidenceFileCountMax(否则前端按「上限>0」会误显示上传入口),仅可见时带出;④ 新增回归测试 TestLeaveNewToLeaveTypesAttachGating(可见/不可见类型分别断言开关与上限);go build、go vet、go test -count=1 ./...、vue-tsc --noEmit、npm run build 全过;⑤ 全流程只读实测(未调用任何提交接口),真实提交由用户自测
ci/woodpecker/tag/build Pipeline was successful
2026-09-16 18:02:35 +08:00
becomer b436781d7d v1.2.53:请假申请附件支持多图上传(最多 4 张)——用户对比老版本发现「请假那里没有上传图片」——① 现状澄清:上传入口本就存在但被学校开关隐藏(v-if=enableAttach===1),且只能传 1 张;本版新增「附件」字段并支持多图,保留学校开关(用户确认);② 数量上限:min(4, 学校配置)——新增 LeaveMaxAttachCount(未配置/超 4 均按 4,否则取学校值),旧套取 queryLeaveTypeCfg.attachmentNumberLimit、新套取 getLeaveTypeDetail.evidenceFileCountMax;③ 协议确认:两套提交体字段本就是数组(旧套 applyAttach=[{type:1,url}]、新套 attachs=[{attachType:1,attachUrl,obsKey,...}]),多图为自然扩展,无需新增接口;上传/预览复用现有 /api/task/upload-policy、/api/task/preview-url(已含新旧套分流),每张图走一遍链路;④ 后端:LeaveApplyParams.ImageURL→ImageURLs、LeaveNewApplyParams.ImageURL/ImageKey→ImageURLs(每张 obsKey 由 URL 推导);请求体新增 imageUrls[],保留旧 imageUrl 字段做兼容合并(保序去重、剔除空串,避免旧缓存前端附件被静默丢弃);服务端复核:旧套校验 enableAttach 开关与数量上限,新套校验 isVisibledEvidenceFile 开关、数量上限、isRequiredEvidenceFile 必填与 evidenceFileCountMini 张数下限;⑤ 前端:附件区改多图网格(缩略图+单张 X 移除+未达上限显示上传方块),label 显示「最多 N 张,已传 x/N」;多选逐张顺序上传并复用 uploadImageToObs;切换类型时不支持附件则清空、上限变小则截断;提交前复核开关与上限;⑥ 销假保持单图不动(未在本次范围内);⑦ 新增测试:service 层多图断言(旧套 2 张 applyAttach、新套 2 张 attachs 逐张 obsKey)、LeaveMaxAttachCount 边界(0/-1/2/4/9)、空串剔除、handler mergeApplyImageURLs 7 用例(去重/追加/空白裁剪/全空);go build、go vet、go test -count=1 ./...、vue-tsc --noEmit、npm run build 全过
ci/woodpecker/tag/build Pipeline was successful
2026-09-16 17:48:17 +08:00
becomer ac743eda88 v1.2.52:执行日志软删除改造——用户删除仅标记 deleted_at(前台隐藏、任务状态不受影响),后台统计与明细计入已删除(标注「已删除」)——① 动机:日志删除原为物理删除,会连带从后台统计中消失,导致「已执行累计/今日统计」凭空减少,且删除当天成功日志会让任务被跨天重置逻辑误判为未执行、出现重复打卡;② 模型:CpTaskLog / CpLeaveLog 增加 DeletedAt gorm.DeletedAt(带索引),AutoMigrate 自动加列,存量数据 deleted_at 为 NULL 视为未删除,无需数据迁移;③ 前台:GORM 默认作用域自动过滤软删除,用户端日志列表(/task/logs、/leave/logs)与删除接口(DB.Delete 自动转为 UPDATE deleted_at)无需改动即符合预期;④ 任务完成状态:ListTasks 的「当天是否存在成功日志」跨天重置查询改用 Unscoped,删除当天日志不再触发状态回退(否则会重复打卡);⑤ 后台统计:已执行累计、今日状态、今日统计卡片(执行/成功总数)全部改用 Unscoped 并并入请假/销假(cp_leave_logs),口径统一为「签到+查寝+请假+销假」;⑥ 后台明细:AdminListTaskLogs 由单表查询改为两表 UNION(同一条常量 adminLogUnionSQL,事务探针复用),新增 deleted(已删除标注)与 source(1=任务 2=请假销假)字段,请假/销假映射 task_type=leave_apply/leave_report;⑦ 修复 UNION 真实报错:两表分属不同时期建表,字符串列 collation 不一致(cp_task_logs=utf8mb4_unicode_ci / cp_leave_logs=utf8mb4_general_ci),直接 UNION 报 MySQL 1271 Illegal mix of collations,已对请假侧字符串列显式 COLLATE utf8mb4_unicode_ci;⑧ 新增事务级探针 TestSoftDeleteProbe(默认跳过,PROBE_DB=1 时连真实库执行并整体回滚,不改动任何持久数据):覆盖前台隐藏、状态不受影响、后台计入、UNION 标注/deleted/source/success 过滤分支;⑨ 前端:AdminUsers.vue 日志明细类型标签补「请假/销假」、已删除记录追加灰色「已删除」徽标、列表 key 由 id 改为 source-id(两表 id 独立自增会撞键),api/admin.ts 类型同步;⑩ 验证:go build、go vet、go test ./...、PROBE_DB=1 事务探针、vue-tsc --noEmit、npm run build 全过
ci/woodpecker/tag/build Pipeline was successful
2026-09-15 18:38:16 +08:00
becomer 53cbedeb95 v1.2.51:泰州学院(tzxy)等新套学校请假/销假适配——新增 wec-counselor-leave-new-apps 全链路 + 按学校应用自动分流
ci/woodpecker/tag/build Pipeline was successful
① 根因与定位:tzxy 的旧前缀 wec-counselor-leave-apps 被网关整体拦截返回 HTTP 420「产品已到期」,
   但该校实际使用的是新一代「请销假」应用 wec-counselor-leave-new-apps/stu/mobile/index.html
   (由辅导猫首页 getAppInfos 应用列表确认;实际接口实测全部正常)。
   新旧两套是完全不同的 API:新套明文 JSON + CpdailyClientType、类型用 typeWid、
   审批流/抄送独立接口;旧套 bodyString 加密 + Cpdaily-Extension 签名、类型用 code。

② 自动分流:新增 handler/leave_flavor.go——辅导猫 getAppInfos 探测本校请假应用 openUrl
   判定 new/old,探测失败用新套 ping 兜底,结果按学校缓存 30 分钟;
   所有请假链路(类型/审批人/待销假列表/销假审批人/销假执行/请假执行/上传策略/预览)按 flavor 分发。

③ 新套实现(cpdaily/leave_new.go):queryLeaveType(分页拉全)、getLeaveTypeDetail(权威开关)、
   getApproveFlowList(审批人+flowData)、getCcSearchList/getLastCcObjects(抄送人)、
   /stu/leave/apply(明文 JSON 提交)、/stu/leave/list、reportList(规定销假位置)、
   getReportApprovers(销假确认人)、queryLeaveDetail、/stu/leave/report(销假提交)、
   OBS getUploadPolicy/previewAttachment(COS 风格,obsKey 从 URL 推导与官方前端一致)。

④ 契约翻译:新套数据在 handler 层翻译为现有前端契约(LeaveType/LeaveItem/LeaveApprover),
   前端零结构改动;新增 statusText 权威状态文案(新套 1审批中/2即将休假/3休假中/4销假逾期/
   5销假确认中/6销假已驳回/7已完成/8已驳回/9已撤销/10退回重填),前端优先展示、旧套回退本地映射;
   pending-list 仅对 status 3/4 补拉详情判断 canReport(reportMode 1/2),附图/审批人开关。

⑤ 服务端复核:新套请假执行重新拉类型详情校验(原因必填/字数/证明材料)、审批流候选
   (userWid 匹配完整对象、无候选不阻止)、抄送人完整对象解析、离校语义(isLeaveOut 强制时以类型为准);
   销假执行复核 canReport 状态与确认人。

⑥ 测试:新增 leave_new_test.go 7 个测试(obsKey 推导/canReport/状态文案/销假表单/请假表单
   离校与不离校两分支/响应码解析);go build/go vet/go test 全过;前端 vue-tsc + npm run build 通过;
   端到端只读实测 tzxy:flavor=new、5 类型、审批人张文烨、2 销假位置、销假确认人、详情与 OBS 策略全部正常。
2026-09-15 13:34:05 +08:00
becomer 42d3b332a5 v1.2.50:辽宁工程技术大学(lntu)登录适配——强制走 CAS + 滑块验证码——① 根因:平台接口返回 joinType=CLOUD(IsIap=true),但网关 /iap/doLogin 不存在(HTTP 405),IAP 账密提交必然失败并返回 FAIL_UPNOTMATCH("用户名或密码不匹配",误导用户以为密码错误);真实链路为 fdm.lntu.edu.cn/iap 302 → 学校 CAS(authserver.lntu.edu.cn/authserver/login)→ 账密 + 验证码 → 回调 /iap/loginSuccess?sessionToken=...&ticket=... 建立网关会话(cookie 落在 fdm.lntu.edu.cn,与 wec-* 任务接口同域);② 修复:新增 login_lntu.go 的 lntuCasLogin(同 xcu 模式,用 /iap 网关入口覆盖 ampUrl 后走通用 CAS 流程),CpdailyLogin 中对 LntuSchoolId 特判强制走 CAS;③ 验证码类型:抓取登录页 JS 确认 captchaSwitch="2" 且 startLogin 走 createSliderCaptcha()(wisedu 定制滑块,非图形验证码),协议与华侨大学完全同源(openSliderCaptcha.htl 取图、sign=AES(JSON.stringify({canvasLength,moveLength,tracks}), 小图 base64 末16字节) 提交 verifySliderCaptcha.htl、errorCode==1 通过),故仅需将 lntu 加入 sliderIds 配置即可复用现有滑块实现(config.toml.example 同步更新);④ 端到端实测:真实待适配账号两次登录均一次通过(gapX=92/149 首候选 offset=0 直接命中 errorCode=1),CAS 302 → 6 cookies(CASTGC/JSESSIONID/MOD_AUTH_CAS/happyVoyage/route/platformMultilingual)→ FetchUserProfile 会话有效;⑤ 按用户要求回滚此前 LoginUrl(忘记密码地址)改动——原地址打不开是因为走了 IAP 入口,改走 CAS 后浏览器可直接打开,无需特殊处理;⑥ 新增 login_lntu_test.go(入口计算 7 用例 + ampUrl 覆盖 + 协议常量),go test ./...、go vet 全过
ci/woodpecker/tag/build Pipeline was successful
2026-09-14 23:12:06 +08:00
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
134 changed files with 22589 additions and 4065 deletions
+112 -63
View File
@@ -29,37 +29,67 @@ steps:
# 应用配置由宿主机 /opt/cpdaily-xiaozhushou/config.toml 运维维护
# (已移出版本库,避免密钥入库;容器以只读挂载方式读取)
# 两个固定槽位(蓝/绿),任一时刻都保持"两个容器都活着":
# 活跃槽 = 反代当前指向的端口;备用槽 = 另一个端口(上一版,供秒级回滚)
SLOT_BLUE=8884
SLOT_GREEN=8885
# ============================================================
# 第一步:读取当前反代端口,确定蓝绿状态
# 第一步:读取当前反代指向的端口,确定活跃/备用槽
# 反代入口为 1Panel 站点 jinrixiaoyuan.top(frps 公网链路):
# /www/sites/jinrixiaoyuan.top/proxy/root.conf → proxy_pass http://127.0.0.1:8884|8885
# 该文件经 conf.d/jinrixiaoyuan.top.conf 的 include /www/sites/.../proxy/*.conf 载入。
# ============================================================
CURRENT_PORT=$(docker exec 1Panel-openresty-ESmn \
grep -oE 'proxy_pass http://[0-9.]+:[0-9]+' \
/www/sites/jinrixiaoyuan.top/proxy/root.conf | grep -o '[0-9]\+$')
echo "=== 当前反代端口: $CURRENT_PORT ==="
if [ "$CURRENT_PORT" = "8884" ]; then
PORT_ACTIVE=8884
PORT_STANDBY=8885
elif [ "$CURRENT_PORT" = "8885" ]; then
PORT_ACTIVE=8885
PORT_STANDBY=8884
if [ "$CURRENT_PORT" = "$SLOT_BLUE" ]; then
PORT_ACTIVE=$SLOT_BLUE
PORT_STANDBY=$SLOT_GREEN
elif [ "$CURRENT_PORT" = "$SLOT_GREEN" ]; then
PORT_ACTIVE=$SLOT_GREEN
PORT_STANDBY=$SLOT_BLUE
else
echo "ERROR: 未知反代端口 $CURRENT_PORT,中止部署(期望 8884/8885)"
echo "ERROR: 未知反代端口 $CURRENT_PORT,中止部署(期望 $SLOT_BLUE/$SLOT_GREEN)"
exit 1
fi
echo " 活跃: $PORT_ACTIVE | 备用: $PORT_STANDBY"
# 清理之前可能残留的临时容器
docker rm -f cpdaily-xiaozhushou-new 2>/dev/null || true
echo " 活跃槽: $PORT_ACTIVE | 备用槽: $PORT_STANDBY"
echo " 目标: 更新备用槽 :$PORT_STANDBY 到 $IMAGE_TAG 后,切换反代指向它(活跃槽保留,可回滚)"
# ============================================================
# 第二步:在备用端口启动新容器
# 第一步附:一次性「收养」旧命名容器
# 迁移前的容器名为 cpdaily-xiaozhushou(无端口后缀),本脚本改用
# cpdaily-xiaozhushou-<端口>。若不改名,下次部署 rm -f 找不到它 → 端口
# 被占用导致 docker run 冲突、部署失败。此处按它实际映射的端口改名(幂等)。
# 同时清理历史遗留的 cpdaily-blue 陈尸容器。
# ============================================================
echo "=== 蓝绿部署: 启动新容器 on :$PORT_STANDBY (image: $IMAGE_TAG) ==="
LEGACY_NAME=cpdaily-xiaozhushou
if docker inspect "$LEGACY_NAME" >/dev/null 2>&1; then
LEGACY_PORT=$(docker port "$LEGACY_NAME" 8093 2>/dev/null | head -1 | grep -oE '[0-9]+$' || true)
if [ -n "$LEGACY_PORT" ]; then
if docker inspect "cpdaily-xiaozhushou-$LEGACY_PORT" >/dev/null 2>&1; then
echo " 发现旧命名容器,但 cpdaily-xiaozhushou-$LEGACY_PORT 已存在,移除旧容器"
docker rm -f "$LEGACY_NAME" || true
else
echo " 收养旧命名容器: $LEGACY_NAME → cpdaily-xiaozhushou-$LEGACY_PORT"
docker rename "$LEGACY_NAME" "cpdaily-xiaozhushou-$LEGACY_PORT"
fi
else
echo " 旧命名容器未映射 8093 端口,跳过收养"
fi
fi
docker rm -f cpdaily-blue 2>/dev/null || true
docker run -d --name cpdaily-xiaozhushou-new \
# ============================================================
# 第二步:更新「备用槽」容器(此槽当前无流量,停它不会影响线上)
# ============================================================
echo "=== 停止并移除备用槽旧容器(:$PORT_STANDBY)==="
docker rm -f cpdaily-xiaozhushou-$PORT_STANDBY 2>/dev/null || true
echo "=== 在备用槽启动新容器 :$PORT_STANDBY (image: $IMAGE_TAG) ==="
docker run -d --name cpdaily-xiaozhushou-$PORT_STANDBY \
--restart unless-stopped \
--network likedream-net \
-e TZ=Asia/Shanghai \
@@ -68,73 +98,92 @@ steps:
cpdaily-xiaozhushou:$IMAGE_TAG
# ============================================================
# 第三步:健康检查
# 第三步:健康检查(新容器必须就绪,否则直接失败且不动反代)
# 用 docker exec 在容器内自测,避免依赖宿主机到容器的网络路径。
# ============================================================
HEALTHY=false
for i in $(seq 1 15); do
if wget -q --timeout=3 -O /dev/null http://10.10.10.128:$PORT_STANDBY/health 2>/dev/null; then
for i in $(seq 1 30); do
if docker exec cpdaily-xiaozhushou-$PORT_STANDBY \
wget -q --timeout=3 -O /dev/null http://127.0.0.1:8093/health 2>/dev/null; then
HEALTHY=true
break
fi
echo " 等待新容器就绪... ($i/15)"
echo " 等待新容器就绪... ($i/30)"
sleep 2
done
if [ "$HEALTHY" = false ]; then
echo "ERROR: 新容器未通过健康检查,回滚"
docker stop cpdaily-xiaozhushou-new
docker rm cpdaily-xiaozhushou-new
echo "ERROR: 新容器未通过健康检查,中止部署(线上仍由活跃槽 :$PORT_ACTIVE 提供服务,未受影响)"
docker rm -f cpdaily-xiaozhushou-$PORT_STANDBY 2>/dev/null || true
exit 1
fi
echo " 新容器已就绪 ✓"
# ============================================================
# 第四步:切换 1Panel 反代到备用端口
# 第四步:切换 1Panel 反代到备用槽(唯一一次代理切换)
# 切换前先备份配置,nginx -t 通过后再 reload;reload 后等待旧 worker 退出,
# 确保旧连接不再按旧配置转发。
# ============================================================
echo "=== 切换 1Panel 反代 :$PORT_ACTIVE → :$PORT_STANDBY ==="
PROXY_CONF=/www/sites/jinrixiaoyuan.top/proxy/root.conf
docker exec 1Panel-openresty-ESmn \
sed -i "s|:$PORT_ACTIVE|:$PORT_STANDBY|g" \
/www/sites/jinrixiaoyuan.top/proxy/root.conf
docker exec 1Panel-openresty-ESmn nginx -s reload
echo " Nginx 热加载完成(零中断)✓"
# ============================================================
# 第五步:停止旧容器
# ============================================================
echo "=== 清理旧容器 ==="
docker stop cpdaily-xiaozhushou 2>/dev/null || true
docker rm cpdaily-xiaozhushou 2>/dev/null || true
# ============================================================
# 第六步:在活跃端口启动正式容器
# ============================================================
echo "=== 启动正式容器 on :$PORT_ACTIVE ==="
docker run -d --name cpdaily-xiaozhushou \
--restart unless-stopped \
--network likedream-net \
-e TZ=Asia/Shanghai \
-p $PORT_ACTIVE:8093 \
-v /opt/cpdaily-xiaozhushou/config.toml:/app/config.toml:ro \
cpdaily-xiaozhushou:$IMAGE_TAG
# ============================================================
# 第七步:切回 1Panel 反代到活跃端口
# ============================================================
echo "=== 切回 1Panel 反代 :$PORT_STANDBY → :$PORT_ACTIVE ==="
cp $PROXY_CONF $PROXY_CONF.bak
docker exec 1Panel-openresty-ESmn \
sed -i "s|:$PORT_STANDBY|:$PORT_ACTIVE|g" \
/www/sites/jinrixiaoyuan.top/proxy/root.conf
docker exec 1Panel-openresty-ESmn nginx -s reload
echo " Nginx 热加载完成(零中断)✓"
sed -i "s|:$PORT_ACTIVE|:$PORT_STANDBY|g" $PROXY_CONF
# ============================================================
# 第八步:清理
# ============================================================
echo "=== 清理 ==="
docker rm -f cpdaily-xiaozhushou-new 2>/dev/null || true
docker images cpdaily-xiaozhushou --format '{{.Tag}}' | grep -v latest | head -n -3 | while read tag; do
docker rmi cpdaily-xiaozhushou:$tag 2>/dev/null || true
if ! docker exec 1Panel-openresty-ESmn nginx -t 2>&1; then
echo "ERROR: nginx 配置校验失败,回滚反代配置"
docker exec 1Panel-openresty-ESmn cp $PROXY_CONF.bak $PROXY_CONF
docker rm -f cpdaily-xiaozhushou-$PORT_STANDBY 2>/dev/null || true
exit 1
fi
# 记录 reload 前的 worker pid,reload 后等其全部退出,避免旧 worker 按旧配置继续转发
OLD_WORKERS=$(docker exec 1Panel-openresty-ESmn \
sh -c 'ps -eo pid,cmd | grep "[n]ginx: worker" | awk "{print \$1}"' | tr '\n' ' ')
docker exec 1Panel-openresty-ESmn nginx -s reload
echo " Nginx 热加载完成,等待旧 worker 退出..."
for i in $(seq 1 15); do
STILL=0
for p in $OLD_WORKERS; do
if docker exec 1Panel-openresty-ESmn sh -c "[ -d /proc/$p ]" 2>/dev/null; then
STILL=$((STILL + 1))
fi
done
if [ "$STILL" = "0" ]; then
echo " 旧 worker 已全部退出 ✓"
break
fi
echo " 等待旧 worker 释放连接... ($i/15, 剩余 $STILL)"
sleep 2
done
echo "=== 部署完成 ✓ ==="
echo " 反代已指向 :$PORT_STANDBY ✓"
# ============================================================
# 第五步:验证线上确实由新槽服务
# ============================================================
if ! docker exec 1Panel-openresty-ESmn \
sh -c "wget -q --timeout=5 -O /dev/null http://127.0.0.1:$PORT_STANDBY/health" 2>/dev/null; then
echo "WARN: 新槽健康检查异常,但反代已切换,请人工核查"
else
echo " 线上验证通过(:$PORT_STANDBY 正常服务)✓"
fi
# ============================================================
# 第六步:保留活跃槽用于回滚说明;清理多余历史镜像
# 注:旧槽容器不删除 —— 它即上一版本,仍需回滚时可直接把反代切回。
# 下次部署时旧槽会被当作「备用槽」重建,因此不会无限堆积。
# ============================================================
echo "=== 当前容器 ==="
docker ps --filter "name=cpdaily-xiaozhushou-" --format '{{.Names}} → {{.Ports}} ({{.Status}})'
echo "=== 清理历史镜像(保留 latest 与最近 2 个版本)==="
docker images cpdaily-xiaozhushou --format '{{.Tag}}' \
| grep -v '^latest$' | head -n -2 | while read tag; do
docker rmi cpdaily-xiaozhushou:$tag 2>/dev/null || true
done
echo "=== 部署完成 ✓ 活跃槽 :$PORT_STANDBY(旧槽 :$PORT_ACTIVE 保留,可回滚)==="
volumes:
- /var/run/docker.sock:/var/run/docker.sock
+45 -1
View File
@@ -4,6 +4,7 @@ import (
"fmt"
"log"
"net/http"
"strings"
"time"
"cpdaily-new/internal/config"
@@ -14,6 +15,7 @@ import (
"cpdaily-new/internal/service"
"github.com/gofiber/fiber/v2"
"github.com/gofiber/fiber/v2/middleware/compress"
"github.com/gofiber/fiber/v2/middleware/filesystem"
)
@@ -35,6 +37,8 @@ func main() {
// 连接数据库
database.Init()
// 存量用户补齐邀请码(每个用户都应有邀请码;无缺失时仅一次查询)
handler.BackfillInviteCodes()
// 连接 Redis(验证码存储/限流)
service.InitRedis()
@@ -55,6 +59,25 @@ func main() {
// 中间件
app.Use(middleware.CORS())
// gzip 压缩:nginx 侧 gzip_types 未覆盖 application/json,接口返回的 JSON
// (历史上曾出现单次 2.9MB 的任务列表)会明文传输。在此按内容协商压缩,
// 随镜像走版本控制、只影响本服务。
// 必须排除所有 SSE 端点(均用 SetBodyStreamWriter 流式推送):
// 压缩后的流在部分移动端浏览器(实测 iOS 独立 QQ 浏览器)增量读取不稳定,
// 且明文流对所有客户端的逐块解析都更可靠。
// - /api/task/execute/*:签到/查寝/返校登记/工作日志执行
// - /api/leave/apply: 请假申请
// - /api/leave/execute:销假
app.Use(compress.New(compress.Config{
Level: compress.LevelBestSpeed, // 纯 CPU 敏感场景,优先速度
Next: func(c *fiber.Ctx) bool {
p := c.Path()
return strings.HasPrefix(p, "/api/task/execute/") ||
p == "/api/leave/apply" ||
p == "/api/leave/execute"
},
}))
// 路由
api := app.Group("/api")
@@ -63,12 +86,15 @@ func main() {
auth.Get("/captcha", handler.GetCaptcha)
auth.Get("/schools", handler.SearchSchools)
auth.Post("/login", handler.Login)
// 手机号验证码登录(无需选择学校:tenantId 由登录响应返回)
auth.Post("/sms/send", handler.SmsSend)
auth.Post("/sms/login", handler.SmsLogin)
auth.Post("/login/mfa", handler.MFAVerify)
auth.Post("/login/mfa/send", handler.MFASendCode)
auth.Post("/refresh", handler.RefreshToken)
auth.Get("/userinfo", middleware.AuthRequired(), handler.GetUserInfo)
auth.Post("/generate-invite", middleware.AuthRequired(), handler.GenerateMyInviteCode)
auth.Get("/my-invite", middleware.AuthRequired(), handler.GetMyInviteInfo)
auth.Post("/bind-invite", middleware.AuthRequired(), handler.BindInviteCode)
auth.Post("/redeem-invite", middleware.AuthRequired(), handler.RedeemInviteReward)
auth.Post("/guide/complete", middleware.AuthRequired(), handler.CompleteGuide)
@@ -80,6 +106,7 @@ func main() {
task.Get("/form/:id", handler.GetTaskForm)
task.Post("/form/:id", handler.SaveTaskForm)
task.Put("/form/:id", handler.SaveTaskForm)
task.Get("/form-data/:id", handler.GetTaskSavedForm)
task.Put("/update/:id", handler.UpdateTask)
task.Delete("/delete/:id", handler.DeleteTask)
task.Get("/execute/:id", handler.ExecuteTask)
@@ -88,6 +115,20 @@ 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("/cc-search", handler.GetLeaveNewCcSearch)
leave.Post("/apply", handler.ExecuteLeaveApply)
leave.Post("/extend-prefill", handler.GetLeaveExtendPrefill)
// CDK 兑换
cdk := api.Group("/cdk", middleware.AuthRequired())
cdk.Post("/redeem", handler.UserRedeemCDK)
@@ -133,6 +174,7 @@ func main() {
// 管理后台 - 公告(公告面向全站代理/用户推送,管理操作仅超管;代理/管理员通过 /my-announcements 收取公告)
adminAuth.Post("/announcements", middleware.AdminOnly(), handler.CreateAnnouncement)
adminAuth.Get("/announcements", middleware.AdminOnly(), handler.ListAnnouncements)
adminAuth.Get("/announcements/targets", middleware.AdminOnly(), handler.SearchAnnouncementTargets)
adminAuth.Get("/announcements/:id", middleware.AdminOnly(), handler.GetAnnouncement)
adminAuth.Put("/announcements/:id", middleware.AdminOnly(), handler.UpdateAnnouncement)
adminAuth.Delete("/announcements/:id", middleware.AdminOnly(), handler.DeleteAnnouncement)
@@ -147,12 +189,14 @@ func main() {
adminAuth.Get("/my-announcements", handler.ListUserAnnouncements)
adminAuth.Get("/my-announcements/unread-count", handler.GetUnreadCount)
adminAuth.Get("/my-announcements/my-info", handler.GetMyAnnouncementInfo)
adminAuth.Get("/my-announcements/pinned", handler.ListPinnedAnnouncements)
adminAuth.Post("/my-announcements/:id/read", handler.MarkAnnouncementRead)
// 用户公告
api.Get("/announcements", middleware.AuthRequired(), handler.ListUserAnnouncements)
api.Get("/announcements/unread-count", middleware.AuthRequired(), handler.GetUnreadCount)
api.Get("/announcements/my-info", middleware.AuthRequired(), handler.GetMyAnnouncementInfo)
api.Get("/announcements/pinned", middleware.AuthRequired(), handler.ListPinnedAnnouncements)
api.Post("/announcements/:id/read", middleware.AuthRequired(), handler.MarkAnnouncementRead)
// 设备授权页面(纯 HTML)
+1 -1
View File
@@ -31,7 +31,7 @@ length = 4
appVersion = "9.8.8"
systemVersion = "iOS 17"
calVersion = "first_v4"
sliderIds = ["1018764144934965", "sdhyxy", "hqdx"]
sliderIds = ["1018764144934965", "sdhyxy", "hqdx", "lntu"]
captchaBaseUrl = "http://127.0.0.1:8890"
captchaApiKey = "your_captcha_api_key"
-2
View File
@@ -8,7 +8,6 @@ require (
github.com/go-resty/resty/v2 v2.16.5
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/image v0.46.0
@@ -37,5 +36,4 @@ require (
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
)
-4
View File
@@ -33,8 +33,6 @@ 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=
@@ -132,8 +130,6 @@ 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=
+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)
}
@@ -6,12 +6,70 @@ import (
"cpdaily-new/internal/utils"
"errors"
"log/slog"
"strings"
"time"
"github.com/gofiber/fiber/v2"
"gorm.io/gorm"
)
// AnnouncementTargetItem 公告指定对象候选项(供管理端搜索选择指定用户/代理)
type AnnouncementTargetItem struct {
ID uint `json:"id"`
Username string `json:"username"`
RealName string `json:"realName"`
StudentID string `json:"studentId,omitempty"`
SchoolName string `json:"schoolName,omitempty"`
Role string `json:"role,omitempty"`
}
// SearchAnnouncementTargets 搜索公告指定对象:按 targetType 在普通用户或代理中
// 按用户名/学号/姓名/学校模糊查询,返回轻量候选列表供前端选择(最多 20 条)。
func SearchAnnouncementTargets(c *fiber.Ctx) error {
targetType := c.Query("targetType", "user")
keyword := strings.TrimSpace(c.Query("keyword", ""))
const limit = 20
items := make([]AnnouncementTargetItem, 0, limit)
if targetType == "agent" {
q := database.DB.Model(&model.AgentUser{})
if keyword != "" {
like := "%" + keyword + "%"
q = q.Where("username LIKE ?", like)
}
var agents []model.AgentUser
q.Order("created_at DESC").Limit(limit).Find(&agents)
for _, a := range agents {
items = append(items, AnnouncementTargetItem{
ID: a.ID,
Username: a.Username,
Role: a.Role,
})
}
return utils.Success(c, items)
}
q := database.DB.Model(&model.SysUser{})
if keyword != "" {
like := "%" + keyword + "%"
q = q.Where("username LIKE ? OR real_name LIKE ? OR student_id LIKE ? OR school_name LIKE ?",
like, like, like, like)
}
var users []model.SysUser
q.Order("created_at DESC").Limit(limit).Find(&users)
for _, u := range users {
items = append(items, AnnouncementTargetItem{
ID: u.ID,
Username: u.Username,
RealName: u.RealName,
StudentID: u.StudentID,
SchoolName: u.SchoolName,
})
}
return utils.Success(c, items)
}
// CreateAnnouncementRequest 创建公告请求
type CreateAnnouncementRequest struct {
Title string `json:"title"`
@@ -20,6 +78,7 @@ type CreateAnnouncementRequest struct {
TargetUserID *uint `json:"targetUserId,omitempty"` // 指定单个用户(null=推送全部)
ForcePopup bool `json:"forcePopup"`
LoopPopup bool `json:"loopPopup"`
IsPinned bool `json:"isPinned"` // 置顶公告:打卡页顶部横向滚动
}
// CreateAnnouncement 创建公告
@@ -39,6 +98,10 @@ func CreateAnnouncement(c *fiber.Ctx) error {
if req.TargetType != "agent" && req.TargetType != "user" {
return utils.Fail(c, 400, "目标类型必须为 agent 或 user")
}
// 置顶公告仅在打卡页(普通用户端)展示,仅普通用户公告可置顶
if req.IsPinned && req.TargetType != "user" {
return utils.Fail(c, 400, "仅普通用户公告可设为置顶")
}
// 如果指定了用户,验证用户存在
if req.TargetUserID != nil {
@@ -63,6 +126,7 @@ func CreateAnnouncement(c *fiber.Ctx) error {
TargetUserID: req.TargetUserID,
ForcePopup: req.ForcePopup,
LoopPopup: req.LoopPopup,
IsPinned: req.IsPinned,
CreatedBy: adminID,
}
@@ -83,6 +147,7 @@ type UpdateAnnouncementRequest struct {
TargetUserID *uint `json:"targetUserId,omitempty"` // 指定单个用户(null=推送全部)
ForcePopup bool `json:"forcePopup"`
LoopPopup bool `json:"loopPopup"`
IsPinned bool `json:"isPinned"` // 置顶公告:打卡页顶部横向滚动
}
// UpdateAnnouncement 更新公告(仅手动创建的公告可编辑)
@@ -113,6 +178,10 @@ func UpdateAnnouncement(c *fiber.Ctx) error {
if req.TargetType != "agent" && req.TargetType != "user" {
return utils.Fail(c, 400, "目标类型必须为 agent 或 user")
}
// 置顶公告仅在打卡页(普通用户端)展示,仅普通用户公告可置顶
if req.IsPinned && req.TargetType != "user" {
return utils.Fail(c, 400, "仅普通用户公告可设为置顶")
}
// 如果指定了用户,验证用户存在
if req.TargetUserID != nil {
@@ -136,6 +205,7 @@ func UpdateAnnouncement(c *fiber.Ctx) error {
"target_user_id": req.TargetUserID,
"force_popup": req.ForcePopup,
"loop_popup": req.LoopPopup,
"is_pinned": req.IsPinned,
}
if err := database.DB.Model(&announcement).Updates(updates).Error; err != nil {
slog.Error("更新公告失败", "error", err)
+166 -51
View File
@@ -5,6 +5,7 @@ import (
"cpdaily-new/internal/model"
"cpdaily-new/internal/utils"
"log/slog"
"sort"
"strconv"
"strings"
"time"
@@ -22,7 +23,7 @@ type SysUserListItem struct {
RealName string `json:"realName"`
MobileCode string `json:"mobileCode"`
MobileType string `json:"mobileType"`
SignCount int `json:"signCount"`
SignCount float64 `json:"signCount"`
InviteReward float64 `json:"inviteReward"`
InvitedCount int `json:"invitedCount"`
InviterID *uint `json:"inviterId"` // 邀请人用户ID(null=非邀请注册)
@@ -103,7 +104,8 @@ func ListUsers(c *fiber.Ctx) error {
taskCountMap[tc.UserID] = tc.Count
}
// 累计成功执行次数(cp_task_logs is_success=1)
// 累计成功执行次数(cp_task_logs 签到/查寝 + cp_leave_logs 请假/销假,含用户已删除的软删除记录)
// 说明:用户在客户端"删除"日志只是前台不可见(软删除),后台统计必须计入,否则执行次数会凭空减少
executedMap := make(map[uint]int64)
// 今天执行状态(success/fail/none)
todayStatusMap := make(map[uint]string)
@@ -118,23 +120,46 @@ func ListUsers(c *fiber.Ctx) error {
Count int64
MaxStatus int
}
// 签到/查寝成功次数(Unscoped 包含软删除)
var logStats []LogStat
database.DB.Model(&model.CpTaskLog{}).
database.DB.Unscoped().Model(&model.CpTaskLog{}).
Select("user_id, COUNT(*) as count, MAX(is_success) as max_status").
Where("user_id IN ? AND is_success = 1", userIDs).
Group("user_id").
Scan(&logStats)
for _, ls := range logStats {
executedMap[ls.UserID] = ls.Count
executedMap[ls.UserID] += ls.Count
}
// 请假/销假成功次数(Unscoped 包含软删除)
var leaveStats []LogStat
database.DB.Unscoped().Model(&model.CpLeaveLog{}).
Select("user_id, COUNT(*) as count, MAX(is_success) as max_status").
Where("user_id IN ? AND is_success = 1", userIDs).
Group("user_id").
Scan(&leaveStats)
for _, ls := range leaveStats {
executedMap[ls.UserID] += ls.Count
}
// 今天 00:00 起的日志
// 今天 00:00 起的状态(含软删除):签到/查寝 + 请假/销假两类日志统一判定
// 任一成功 → success;否则任有失败 → fail;都无 → none
todayStart := time.Now().Format("2006-01-02") + " 00:00:00"
var todayLogs []model.CpTaskLog
database.DB.Select("user_id, is_success").
var todayTaskLogs []model.CpTaskLog
database.DB.Unscoped().Select("user_id, is_success").
Where("user_id IN ? AND created_at >= ?", userIDs, todayStart).
Find(&todayLogs)
for _, tl := range todayLogs {
Find(&todayTaskLogs)
var todayLeaveLogs []model.CpLeaveLog
database.DB.Unscoped().Select("user_id, is_success").
Where("user_id IN ? AND created_at >= ?", userIDs, todayStart).
Find(&todayLeaveLogs)
for _, tl := range todayTaskLogs {
if tl.IsSuccess {
todayStatusMap[tl.UserID] = "success"
} else if todayStatusMap[tl.UserID] != "success" {
todayStatusMap[tl.UserID] = "fail"
}
}
for _, tl := range todayLeaveLogs {
if tl.IsSuccess {
todayStatusMap[tl.UserID] = "success"
} else if todayStatusMap[tl.UserID] != "success" {
@@ -215,10 +240,16 @@ func ListUsers(c *fiber.Ctx) error {
}
// 当天统计:执行总次数 / 成功执行总次数 / 新增用户数 / 登录用户数
// 执行类统计 = 签到/查寝 + 请假/销假(均含用户已删除的软删除记录)
todayStart := time.Now().Format("2006-01-02") + " 00:00:00"
var todayExecuteCount, todaySuccessCount int64
database.DB.Model(&model.CpTaskLog{}).Where("created_at >= ?", todayStart).Count(&todayExecuteCount)
database.DB.Model(&model.CpTaskLog{}).Where("created_at >= ? AND is_success = 1", todayStart).Count(&todaySuccessCount)
database.DB.Unscoped().Model(&model.CpTaskLog{}).Where("created_at >= ?", todayStart).Count(&todayExecuteCount)
database.DB.Unscoped().Model(&model.CpTaskLog{}).Where("created_at >= ? AND is_success = 1", todayStart).Count(&todaySuccessCount)
var todayLeaveExecute, todayLeaveSuccess int64
database.DB.Unscoped().Model(&model.CpLeaveLog{}).Where("created_at >= ?", todayStart).Count(&todayLeaveExecute)
database.DB.Unscoped().Model(&model.CpLeaveLog{}).Where("created_at >= ? AND is_success = 1", todayStart).Count(&todayLeaveSuccess)
todayExecuteCount += todayLeaveExecute
todaySuccessCount += todayLeaveSuccess
var todayNewUsers, todayLoginUsers int64
database.DB.Model(&model.SysUser{}).Where("created_at >= ?", todayStart).Count(&todayNewUsers)
database.DB.Model(&model.SysUser{}).Where("last_login_at >= ?", todayStart).Count(&todayLoginUsers)
@@ -229,9 +260,9 @@ func ListUsers(c *fiber.Ctx) error {
"page": page,
"pageSize": pageSize,
"todayStats": fiber.Map{
"executeCount": todayExecuteCount,
"successCount": todaySuccessCount,
"newUserCount": todayNewUsers,
"executeCount": todayExecuteCount,
"successCount": todaySuccessCount,
"newUserCount": todayNewUsers,
"loginUserCount": todayLoginUsers,
},
})
@@ -274,26 +305,26 @@ func GetUserDetail(c *fiber.Ctx) error {
}
return utils.Success(c, fiber.Map{
"id": user.ID,
"username": user.Username,
"schoolId": user.SchoolID,
"schoolName": user.SchoolName,
"studentId": user.StudentID,
"realName": user.RealName,
"password": password,
"signCount": user.SignCount,
"inviteReward": user.InviteReward,
"invitedCount": user.InvitedCount,
"inviterId": user.InviterID,
"inviterName": inviterName,
"status": user.Status,
"id": user.ID,
"username": user.Username,
"schoolId": user.SchoolID,
"schoolName": user.SchoolName,
"studentId": user.StudentID,
"realName": user.RealName,
"password": password,
"signCount": user.SignCount,
"inviteReward": user.InviteReward,
"invitedCount": user.InvitedCount,
"inviterId": user.InviterID,
"inviterName": inviterName,
"status": user.Status,
"disabledReason": user.DisabledReason,
"taskCount": taskCount,
"mobileCode": user.MobileCode,
"mobileType": user.MobileType,
"createdAt": user.CreatedAt.Format("2006-01-02 15:04:05"),
"lastLoginAt": lastLogin,
"lastLoginIp": user.LastLoginIP,
"taskCount": taskCount,
"mobileCode": user.MobileCode,
"mobileType": user.MobileType,
"createdAt": user.CreatedAt.Format("2006-01-02 15:04:05"),
"lastLoginAt": lastLogin,
"lastLoginIp": user.LastLoginIP,
})
}
@@ -354,9 +385,35 @@ type AdminTaskLogItem struct {
IsSuccess bool `json:"isSuccess"`
ErrorMessage string `json:"errorMessage"`
CreatedAt time.Time `json:"createdAt"`
// Deleted 表示该记录已被用户在客户端删除(软删除),仅后台可见
Deleted bool `json:"deleted"`
// Source 数据来源:1=签到/查寝(cp_task_logs) 2=请假/销假(cp_leave_logs),两表 id 独立自增,前端 key 需组合去重
Source int `json:"source"`
}
// adminLogUnionSQL 后台执行日志明细的数据源:cp_task_logs(签到/查寝)+ cp_leave_logs(请假/销假)UNION。
// 原始 SQL 绕过 GORM 软删除过滤,故含用户已删除记录(deleted 标注);source 区分来源(1=任务 2=请假销假)。
// 占位符顺序:dayStart, dayEnd, dayStart, dayEnd
// 注意:两表由不同时期建表,字符串列 collation 不一致(utf8mb4_unicode_ci vs general_ci),
// 直接 UNION 会报 1271 Illegal mix of collations,故显式 COLLATE 统一到 utf8mb4_unicode_ci。
const adminLogUnionSQL = `
SELECT id, user_id, task_type, title, real_title, is_success, error_message, created_at, deleted, source FROM (
SELECT id, user_id, task_type, title, real_title, is_success, error_message, created_at,
(deleted_at IS NOT NULL) AS deleted, 1 AS source
FROM cp_task_logs
WHERE created_at >= ? AND created_at < ?
UNION ALL
SELECT id, user_id,
(CASE action WHEN 'apply' THEN 'leave_apply' WHEN 'extend' THEN 'leave_extend' ELSE 'leave_report' END) COLLATE utf8mb4_unicode_ci AS task_type,
title COLLATE utf8mb4_unicode_ci, '' COLLATE utf8mb4_unicode_ci AS real_title, is_success,
error_message COLLATE utf8mb4_unicode_ci, created_at,
(deleted_at IS NOT NULL) AS deleted, 2 AS source
FROM cp_leave_logs
WHERE created_at >= ? AND created_at < ?
) AS combined`
// AdminListTaskLogs 任务执行日志列表(仅admin,供"当天执行总次数"卡片点击查看)
// 数据源 = 签到/查寝(cp_task_logs)+ 请假/销假(cp_leave_logs),均含用户已删除的软删除记录(标注 deleted);
// 支持按日期(默认当天)、执行状态、关键字(用户名/姓名/任务标题)筛选,分页返回
func AdminListTaskLogs(c *fiber.Ctx) error {
// 日期(yyyy-MM-dd,默认当天)
@@ -387,32 +444,90 @@ func AdminListTaskLogs(c *fiber.Ctx) error {
pageSize = 20
}
query := database.DB.Table("cp_task_logs").
Joins("LEFT JOIN sys_user ON sys_user.id = cp_task_logs.user_id").
Where("cp_task_logs.created_at >= ? AND cp_task_logs.created_at < ?", dayStart, dayEnd)
type combinedRow struct {
ID uint
UserID uint
TaskType string
Title string
RealTitle string
IsSuccess bool
ErrorMessage string
CreatedAt time.Time
Deleted bool
Source int
}
var rows []combinedRow
raw := database.DB.Raw(adminLogUnionSQL, dayStart, dayEnd, dayStart, dayEnd)
if status == "success" {
query = query.Where("cp_task_logs.is_success = 1")
raw = database.DB.Raw("SELECT * FROM ("+adminLogUnionSQL+") t WHERE t.is_success = 1", dayStart, dayEnd, dayStart, dayEnd)
} else if status == "fail" {
query = query.Where("cp_task_logs.is_success = 0")
raw = database.DB.Raw("SELECT * FROM ("+adminLogUnionSQL+") t WHERE t.is_success = 0", dayStart, dayEnd, dayStart, dayEnd)
}
if keyword != "" {
like := "%" + keyword + "%"
query = query.Where("sys_user.username LIKE ? OR sys_user.real_name LIKE ? OR cp_task_logs.title LIKE ? OR cp_task_logs.real_title LIKE ?",
like, like, like, like)
raw.Scan(&rows)
// 用户信息一次性查询
userIDs := make([]uint, 0, len(rows))
seen := make(map[uint]bool, len(rows))
for _, r := range rows {
if !seen[r.UserID] {
seen[r.UserID] = true
userIDs = append(userIDs, r.UserID)
}
}
userMap := make(map[uint]model.SysUser, len(userIDs))
if len(userIDs) > 0 {
var us []model.SysUser
database.DB.Select("id, username, real_name, school_name").Where("id IN ?", userIDs).Find(&us)
for _, u := range us {
userMap[u.ID] = u
}
}
var total int64
query.Count(&total)
// 关键字过滤(用户名/姓名/标题)在内存中完成(跨表 UNION 后)
like := strings.ToLower(keyword)
filtered := make([]AdminTaskLogItem, 0, len(rows))
for _, r := range rows {
u := userMap[r.UserID]
if keyword != "" {
hay := strings.ToLower(u.Username + u.RealName + r.Title + r.RealTitle)
if !strings.Contains(hay, like) {
continue
}
}
filtered = append(filtered, AdminTaskLogItem{
ID: r.ID,
UserID: r.UserID,
Username: u.Username,
RealName: u.RealName,
SchoolName: u.SchoolName,
TaskType: r.TaskType,
Title: r.Title,
RealTitle: r.RealTitle,
IsSuccess: r.IsSuccess,
ErrorMessage: r.ErrorMessage,
CreatedAt: r.CreatedAt,
Deleted: r.Deleted,
Source: r.Source,
})
}
// 按时间倒序
sort.Slice(filtered, func(i, j int) bool {
return filtered[i].CreatedAt.After(filtered[j].CreatedAt)
})
var items []AdminTaskLogItem
query.Select("cp_task_logs.id, cp_task_logs.user_id, sys_user.username, sys_user.real_name, sys_user.school_name, cp_task_logs.task_type, cp_task_logs.title, cp_task_logs.real_title, cp_task_logs.is_success, cp_task_logs.error_message, cp_task_logs.created_at").
Order("cp_task_logs.created_at DESC").
Offset((page - 1) * pageSize).
Limit(pageSize).
Scan(&items)
total := int64(len(filtered))
start := (page - 1) * pageSize
if start > len(filtered) {
start = len(filtered)
}
end := start + pageSize
if end > len(filtered) {
end = len(filtered)
}
return utils.Success(c, fiber.Map{
"items": items,
"items": filtered[start:end],
"total": total,
"page": page,
"pageSize": pageSize,
+193 -177
View File
@@ -26,11 +26,8 @@ import (
// GetCaptcha 获取图片验证码(令牌桶限流:同 IP 5 分钟最多获取 10 次,防刷屏打爆资源)
func GetCaptcha(c *fiber.Ctx) error {
// 优先取 XFF 真实客户端 IP(openresty 反代受信);直连无 XFF 时回退 socket 源 IP
ip := c.IP()
if ip == "" {
ip = c.Context().RemoteIP().String()
}
// 限流 IP 与登录 IP 一致(utils.ClientIP 优先 X-Real-IP)
ip := utils.ClientIP(c)
ok, wait := service.AllowCaptchaToken(ip)
if !ok {
return utils.Fail(c, 429, fmt.Sprintf("验证码获取过于频繁,请 %s后再试", service.FormatWait(wait)))
@@ -67,7 +64,6 @@ type LoginRequest struct {
Password string `json:"password"`
CaptchaID string `json:"captchaId"`
CaptchaCode string `json:"captchaCode"`
InviteCode string `json:"inviteCode,omitempty"` // 可选邀请码
}
// Login 登录
@@ -95,8 +91,8 @@ func Login(c *fiber.Ctx) error {
// 执行 cpdaily 登录
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)
// 多因子认证:验证码已自动发送,暂存会话并返回 mfaToken 给前端
return startMFALogin(c, school, req.Username, req.Password, mfaCtx)
}
if err != nil {
slog.Error("cpdaily登录失败", "error", err)
@@ -110,7 +106,7 @@ func Login(c *fiber.Ctx) error {
}
// 登录成功:公共处理(MOD_ 检查、用户注册、JWT 签发)
return finalizeLogin(c, school, req.Username, req.Password, cookies, false, req.InviteCode)
return finalizeLogin(c, school, req.Username, req.Password, cookies, false)
}
// MFAVerifyRequest 提交多因子认证验证码
@@ -157,7 +153,7 @@ func MFAVerify(c *fiber.Ctx) error {
deleteMFASession(c.Context(), req.MfaToken)
// 短期 JWT:与第三方 Cookie 有效期对齐(MFA 登录无法自动续期)
return finalizeLogin(c, school, sess.Username, sess.Password, cookies, true, sess.InviteCode)
return finalizeLogin(c, school, sess.Username, sess.Password, cookies, true)
}
// MFASendCodeRequest 发送多因子认证验证码(用户已选定验证方式)
@@ -209,7 +205,7 @@ func MFASendCode(c *fiber.Ctx) error {
// 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 {
func finalizeLogin(c *fiber.Ctx, school *cpd.SchoolDetailInfo, username, password string, cookies []*http.Cookie, shortToken bool) error {
// 检查 MOD_ cookie
hasMOD := false
for _, cookie := range cookies {
@@ -235,7 +231,7 @@ func finalizeLogin(c *fiber.Ctx, school *cpd.SchoolDetailInfo, username, passwor
userName := buildUsername(school, username)
// 查找或创建用户
user, isNew, err := findOrCreateUser(userName, password, school.ID, school.Name, username, cookieStr, referralCode)
user, isNew, err := findOrCreateUser(userName, password, school.ID, school.Name, username, cookieStr)
if err != nil {
slog.Error("用户处理失败", "error", err)
return utils.FailServer(c, "用户处理失败")
@@ -341,7 +337,7 @@ func GetUserInfo(c *fiber.Ctx) error {
}
// GuideCompleteVersion 当前新手引导版本:看过该版本即不再重复展示(数据库记录,跨设备一致)
const GuideCompleteVersion = "v2"
const GuideCompleteVersion = "v4"
// CompleteGuide 记录用户已完成当前版本新手引导
func CompleteGuide(c *fiber.Ctx) error {
@@ -353,17 +349,47 @@ func CompleteGuide(c *fiber.Ctx) error {
return utils.Success(c, nil)
}
// syncUserProfile 同步 cpdaily 用户信息到数据库
// syncUserProfile 同步 cpdaily 用户信息到数据库。
// 登录态失效(cookie 过期)时自动续期并重试一次,避免用户信息长期同步不上。
func syncUserProfile(user *model.SysUser, cookieStr string, wecloudUrl string) {
syncUserProfileWith(user, cookieStr, wecloudUrl, true)
}
// syncUserProfileWith allowRefresh=false 时不做 cookie 续期,
// 供续期流程内部调用,避免「续期 → 同步 → 再续期」的递归风暴。
func syncUserProfileWith(user *model.SysUser, cookieStr string, wecloudUrl string, allowRefresh bool) {
cookieMap := cpd.ParseCookieJSON(cookieStr)
profile, err := cpd.FetchUserProfile(cookieMap, wecloudUrl)
if err != nil {
slog.Warn("同步用户信息失败", "userId", user.ID, "error", err)
fetch := func(cm map[string]string) (*cpd.CpdailyUserProfile, error) {
return cpd.FetchUserProfile(cm, wecloudUrl)
}
var profile *cpd.CpdailyUserProfile
if allowRefresh {
_, err := withCookieRefresh(user, cookieMap, wecloudUrl, func(cm map[string]string) error {
var e error
profile, e = fetch(cm)
return e
})
if err != nil {
slog.Warn("同步用户信息失败", "userId", user.ID, "error", err)
return
}
} else {
p, err := fetch(cookieMap)
if err != nil {
slog.Warn("同步用户信息失败(禁续期)", "userId", user.ID, "error", err)
return
}
profile = p
}
if profile == nil {
return
}
updates := syncProfileToUser(profile)
updates["cookie"] = cookieStr // 确保 cookie 最新
// 同步结果不覆盖 cookie:cookie 由调用方(登录/续期流程)负责维护,
// 避免异步同步与续期流程并发写 cookie 造成相互覆盖。
database.DB.Model(&user).Updates(updates)
slog.Info("用户信息同步成功", "userId", user.ID, "name", profile.Name)
}
@@ -417,18 +443,6 @@ func getInviteeRewardAmount() float64 {
return 0.5
}
// getRegisterRewardAmount 获取直接注册(无邀请码)获得的奖励积分(从系统设置读取,默认 1.0)
func getRegisterRewardAmount() float64 {
var cfg model.SystemConfig
result := database.DB.Where("`key` = ?", "register_reward").First(&cfg)
if result.Error == nil {
if v, err := strconv.ParseFloat(cfg.Value, 64); err == nil && v >= 0 {
return v
}
}
return 1.0
}
// createSystemAnnouncement 创建系统自动生成的公告(强制弹窗)
func createSystemAnnouncement(userID uint, userType string, title, content string) {
announcement := model.Announcement{
@@ -466,10 +480,12 @@ func createInviteNotifications(inviterID, inviteeID uint, inviterReward, invitee
// 查找或创建用户
// findOrCreateUser 查找或自动注册用户;返回 (用户, 是否首次注册, 错误)
func findOrCreateUser(username, password, schoolID, schoolName, studentID, cookie, referralCode string) (*model.SysUser, bool, error) {
// 查找顺序:用户名 → 「同校 + 同学工号」(兼容手机号登录创建的账号)。
func findOrCreateUser(username, password, schoolID, schoolName, studentID, cookie string) (*model.SysUser, bool, error) {
var user model.SysUser
err := database.DB.Where("username = ?", username).First(&user).Error
if err == nil {
ensureInviteCodeOnLogin(&user)
return &user, false, nil
}
@@ -477,27 +493,27 @@ func findOrCreateUser(username, password, schoolID, schoolName, studentID, cooki
return nil, false, err
}
// 自动注册:生成密码哈希和邀请码
// 兼容手机号登录:同一学校 + 同学工号 视为同一用户
if studentID != "" {
if e2 := database.DB.Where("school_id = ? AND student_id = ?", schoolID, studentID).First(&user).Error; e2 == nil {
slog.Info("按学工号匹配到已有账号(学工号登录)", "userId", user.ID, "studentId", studentID)
ensureInviteCodeOnLogin(&user)
return &user, false, nil
} else if !errors.Is(e2, gorm.ErrRecordNotFound) {
return nil, false, e2
}
}
// 自动注册:生成密码哈希和自身邀请码。
// 说明:登录不再携带邀请码,注册时不发放任何邀请/注册奖励、不写邀请关系;
// 用户可在登录后到「我的」填写他人邀请码完成绑定并领取奖励(见 BindInviteCode)。
hashedPwd, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil {
return nil, false, err
}
// 为新用户生成一个唯一的邀请码(避免空字符串与 uniqueIndex 冲突)
myInviteCode := generateRandomCode(6)
// 读取可配置的奖励积分
inviterReward := getInviterRewardAmount()
inviteeReward := getInviteeRewardAmount()
registerReward := getRegisterRewardAmount()
// 记录邀请人ID,在事务外创建通知
var inviterID uint
hasValidReferral := false
hasRegisterReward := false
// 使用事务包裹:用户创建 + 奖励发放,保证原子性
err = database.DB.Transaction(func(tx *gorm.DB) error {
// 注册即生成邀请码:唯一索引冲突时换码重试(见 withUniqueInviteCode)
myInviteCode, err := withUniqueInviteCode(func(code string) error {
user = model.SysUser{
Username: username,
Password: string(hashedPwd),
@@ -505,125 +521,24 @@ func findOrCreateUser(username, password, schoolID, schoolName, studentID, cooki
SchoolName: schoolName,
StudentID: studentID,
Cookie: cookie,
InviteCode: myInviteCode, // 始终设置邀请码,避免空字符串唯一索引冲突
InviteCode: code,
}
if err := tx.Create(&user).Error; err != nil {
return err
}
// 处理邀请码奖励
if referralCode != "" {
var inviter model.SysUser
if err := tx.Where("invite_code = ?", referralCode).First(&inviter).Error; err != nil {
// 邀请码无效,仅记录日志,不中断注册流程
slog.Warn("邀请码无效,跳过奖励", "referralCode", referralCode, "newUserId", user.ID)
return nil
}
// 自邀请防护
if inviter.ID == user.ID {
slog.Warn("检测到自邀请,跳过奖励", "userId", user.ID)
return nil
}
// 记录邀请关系(后台用户管理可展示"被谁邀请")
tx.Model(&user).Update("inviter_id", inviter.ID)
// 给邀请人发放奖励积分 + 增加邀请计数
if inviterReward > 0 {
tx.Model(&inviter).Updates(map[string]interface{}{
"invite_reward": gorm.Expr("invite_reward + ?", inviterReward),
"invited_count": gorm.Expr("invited_count + 1"),
})
}
// 给被邀请人(新用户)发放奖励积分
if inviteeReward > 0 {
tx.Model(&user).Update("invite_reward", gorm.Expr("invite_reward + ?", inviteeReward))
}
slog.Info("邀请奖励已发放",
"inviterId", inviter.ID,
"newUserId", user.ID,
"inviterReward", inviterReward,
"inviteeReward", inviteeReward,
)
inviterID = inviter.ID
hasValidReferral = true
} else if registerReward > 0 {
// 未填写邀请码:发放直接注册奖励积分
tx.Model(&user).Update("invite_reward", gorm.Expr("invite_reward + ?", registerReward))
slog.Info("直接注册奖励已发放",
"newUserId", user.ID,
"registerReward", registerReward,
)
hasRegisterReward = true
}
return nil
return database.DB.Create(&user).Error
})
if err != nil {
slog.Error("用户创建失败(可能用户名冲突)", "username", username, "error", err)
return nil, false, err
}
// 邀请成功后,在事务外创建系统通知(双方各一条强制弹窗公告)
if hasValidReferral {
createInviteNotifications(inviterID, user.ID, inviterReward, inviteeReward)
}
// 直接注册奖励到账通知
if hasRegisterReward {
createSystemAnnouncement(user.ID, "user",
"注册奖励到账",
"欢迎注册!您获得 "+strconv.FormatFloat(registerReward, 'f', 1, 64)+" 奖励积分。\n\n积分满 1 即可在个人中心兑换 1 次签到次数。",
)
}
slog.Info("新用户自动注册", "username", username, "school", schoolName, "referralCode", referralCode, "myInviteCode", myInviteCode)
slog.Info("新用户自动注册", "username", username, "school", schoolName, "myInviteCode", myInviteCode)
return &user, true, nil
}
// GenerateMyInviteCode 生成/获取当前用户的邀请码
func GenerateMyInviteCode(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, "用户不存在")
// ensureInviteCodeOnLogin 登录时为缺少邀请码的老用户补发;失败只记日志,不影响登录
func ensureInviteCodeOnLogin(user *model.SysUser) {
if err := ensureInviteCode(user); err != nil {
slog.Error("登录时补发邀请码失败", "userId", user.ID, "error", err)
}
// 已有邀请码直接返回
if user.InviteCode != "" {
return utils.Success(c, fiber.Map{
"inviteCode": user.InviteCode,
"inviteReward": user.InviteReward,
"invitedCount": user.InvitedCount,
"canRedeem": user.InviteReward >= 1.0,
"inviterRewardPerInvite": getInviterRewardAmount(),
"inviteeRewardPerInvite": getInviteeRewardAmount(),
})
}
// 生成新邀请码(6 位随机大写字母数字)
code := generateRandomCode(6)
user.InviteCode = code
if err := database.DB.Model(&user).Update("invite_code", code).Error; err != nil {
return utils.FailServer(c, "生成邀请码失败")
}
slog.Info("邀请码已生成", "userId", userID, "inviteCode", code)
return utils.Success(c, fiber.Map{
"inviteCode": code,
"inviteReward": 0,
"invitedCount": 0,
"canRedeem": false,
"inviterRewardPerInvite": getInviterRewardAmount(),
"inviteeRewardPerInvite": getInviteeRewardAmount(),
})
}
// GetMyInviteInfo 获取当前用户的邀请信息
@@ -633,15 +548,115 @@ func GetMyInviteInfo(c *fiber.Ctx) error {
if err := database.DB.First(&user, userID).Error; err != nil {
return utils.FailAuth(c, "用户不存在")
}
// 兜底:仍缺邀请码(如登录时补发失败)则在这里再补一次
ensureInviteCodeOnLogin(&user)
return utils.Success(c, fiber.Map{
return utils.Success(c, inviteInfoMap(&user))
}
// inviteInfoMap 组装邀请信息返回体(含是否已绑定邀请人,供前端控制「填写邀请码」入口显隐)
func inviteInfoMap(user *model.SysUser) fiber.Map {
return fiber.Map{
"inviteCode": user.InviteCode,
"inviteReward": user.InviteReward,
"invitedCount": user.InvitedCount,
"canRedeem": user.InviteReward >= 1.0,
"inviterRewardPerInvite": getInviterRewardAmount(),
"inviteeRewardPerInvite": getInviteeRewardAmount(),
"hasInviter": user.InviterID != nil,
}
}
// BindInviteCodeRequest 填写他人邀请码
type BindInviteCodeRequest struct {
InviteCode string `json:"inviteCode"`
}
// BindInviteCode 填写他人邀请码:绑定邀请关系并给双方发放邀请奖励。
// 约束:每人仅可绑定一次(已绑定邀请人则拒绝);需已绑定设备码;不可填自己的邀请码。
func BindInviteCode(c *fiber.Ctx) error {
userID := c.Locals("userId").(uint)
var req BindInviteCodeRequest
if err := c.BodyParser(&req); err != nil {
return utils.FailParam(c, "请求参数错误")
}
code := strings.TrimSpace(req.InviteCode)
if code == "" {
return utils.FailParam(c, "请输入邀请码")
}
var user model.SysUser
if err := database.DB.First(&user, userID).Error; err != nil {
return utils.FailAuth(c, "用户不存在")
}
// 仅可绑定一次
if user.InviterID != nil {
return utils.Fail(c, 400, "你已填写过邀请码,不可重复填写")
}
// 先校验设备码:未绑定设备码不允许绑定邀请关系
if strings.TrimSpace(user.MobileCode) == "" {
return utils.Fail(c, 400, "请先绑定设备码")
}
// 查找邀请人
var inviter model.SysUser
if err := database.DB.Where("invite_code = ?", code).First(&inviter).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return utils.Fail(c, 400, "邀请码无效")
}
return utils.FailServer(c, "查询邀请码失败")
}
if inviter.ID == user.ID {
return utils.Fail(c, 400, "不能填写自己的邀请码")
}
inviterReward := getInviterRewardAmount()
inviteeReward := getInviteeRewardAmount()
// 事务:绑定邀请关系 + 双方发放奖励积分
err := database.DB.Transaction(func(tx *gorm.DB) error {
// 再次校验(防止并发重复绑定)
var fresh model.SysUser
if err := tx.First(&fresh, user.ID).Error; err != nil {
return err
}
if fresh.InviterID != nil {
return gorm.ErrDuplicatedKey
}
if err := tx.Model(&model.SysUser{}).Where("id = ? AND inviter_id IS NULL", user.ID).
Update("inviter_id", inviter.ID).Error; err != nil {
return err
}
if inviterReward > 0 {
tx.Model(&model.SysUser{}).Where("id = ?", inviter.ID).Updates(map[string]interface{}{
"invite_reward": gorm.Expr("invite_reward + ?", inviterReward),
"invited_count": gorm.Expr("invited_count + 1"),
})
}
if inviteeReward > 0 {
tx.Model(&model.SysUser{}).Where("id = ?", user.ID).
Update("invite_reward", gorm.Expr("invite_reward + ?", inviteeReward))
}
return nil
})
if err != nil {
if errors.Is(err, gorm.ErrDuplicatedKey) {
return utils.Fail(c, 400, "你已填写过邀请码,不可重复填写")
}
slog.Error("绑定邀请码失败", "userId", userID, "code", code, "error", err)
return utils.FailServer(c, "绑定失败,请稍后重试")
}
createInviteNotifications(inviter.ID, user.ID, inviterReward, inviteeReward)
slog.Info("邀请码绑定成功", "userId", userID, "inviterId", inviter.ID,
"inviterReward", inviterReward, "inviteeReward", inviteeReward)
// 返回最新邀请信息(含新积分),供前端刷新
var refreshed model.SysUser
database.DB.First(&refreshed, userID)
return utils.Success(c, inviteInfoMap(&refreshed))
}
// RedeemInviteReward 兑换邀请奖励积分(满 1 兑换 1 次签到)
@@ -674,12 +689,16 @@ func RedeemInviteReward(c *fiber.Ctx) error {
// generateRandomCode 生成 N 位随机大写字母数字码
func generateRandomCode(n int) string {
const chars = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"
code := make([]byte, n)
for i := range code {
code[i] = chars[time.Now().UnixNano()%int64(len(chars))]
time.Sleep(1)
code, err := randomFromCharset(chars, n)
if err != nil {
// crypto/rand 读取失败极罕见;退回时间种子,保证调用方总能拿到非空串
b := make([]byte, n)
for i := range b {
b[i] = chars[(time.Now().UnixNano()+int64(i)*7919)%int64(len(chars))]
}
return string(b)
}
return string(code)
return code
}
// ========== 多因子认证(MFA)会话暂存 ==========
@@ -691,16 +710,15 @@ const (
// 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"`
SchoolID string `json:"schoolId"`
Username string `json:"username"`
Password string `json:"password"`
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 {
func startMFALogin(c *fiber.Ctx, school *cpd.SchoolDetailInfo, username, password string, mfaCtx *cpd.MFAContext) error {
if service.Redis == nil {
return utils.FailServer(c, "多因子认证暂不可用(存储服务未就绪),请稍后重试")
}
@@ -718,11 +736,10 @@ func startMFALogin(c *fiber.Ctx, school *cpd.SchoolDetailInfo, username, passwor
}
data, _ := json.Marshal(mfaSession{
SchoolID: school.ID,
Username: username,
Password: encPassword,
InviteCode: inviteCode,
MFA: mfaCtx,
SchoolID: school.ID,
Username: username,
Password: encPassword,
MFA: mfaCtx,
})
if err := service.Redis.Set(context.Background(), fmt.Sprintf(mfaSessionKeyFmt, mfaToken), data, mfaSessionTTL).Err(); err != nil {
slog.Error("MFA会话暂存失败", "error", err)
@@ -781,11 +798,10 @@ func updateMFASession(ctx context.Context, mfaToken string, sess *mfaSession) er
return err
}
data, err := json.Marshal(mfaSession{
SchoolID: sess.SchoolID,
Username: sess.Username,
Password: encPassword,
InviteCode: sess.InviteCode,
MFA: sess.MFA,
SchoolID: sess.SchoolID,
Username: sess.Username,
Password: encPassword,
MFA: sess.MFA,
})
if err != nil {
return err
+119 -8
View File
@@ -4,6 +4,7 @@ import (
"context"
"encoding/base64"
"errors"
"fmt"
"log/slog"
"strings"
@@ -15,6 +16,26 @@ import (
// ErrPasswordChanged 当 cpdaily 密码错误时返回此错误
var ErrPasswordChanged = errors.New("cpdaily_password_error")
// ErrNeedReLogin 当登录态失效且无法自动续期(如手机号登录无密码无法自动重登)、
// 需要用户手动重新登录时返回此错误。与 ErrPasswordChanged 一起作为前端「重新登录」引导的信号。
var ErrNeedReLogin = errors.New("cpdaily_need_relogin")
// needReLoginSignal 判定错误是否需要引导用户重新登录。
// 只要登录凭证已过期且无法自动续期(密码错误 或 无密码无法自动重登等),都返回 true。
func needReLoginSignal(err error) bool {
return errors.Is(err, ErrPasswordChanged) || errors.Is(err, ErrNeedReLogin)
}
// wrapNeedReLogin 把「cookie 过期后自动续期失败」的错误升级为「需要重新登录」信号:
// 密码错误保持 ErrPasswordChanged 原语义;其余失败(无密码、MFA、学校接口异常等)
// 统一包装为 ErrNeedReLogin,让上层能识别并引导用户重新登录。
func wrapNeedReLogin(e error) error {
if errors.Is(e, ErrPasswordChanged) {
return ErrPasswordChanged
}
return fmt.Errorf("%w: %v", ErrNeedReLogin, e)
}
// encryptRawPassword AES 加密明文密码并 base64 编码
func encryptRawPassword(rawPassword string) (string, error) {
encrypted := cpd.AesEncrypt([]byte(rawPassword))
@@ -46,29 +67,54 @@ func updateRawPassword(userID uint, rawPassword string) error {
return saveRawPassword(userID, rawPassword)
}
// isCookieExpired 判断错误是否由 cpdaily cookie 过期引起
// isCookieExpired 判断错误是否由 cpdaily 登录态失效(cookie 过期)引起。
// 两种来源:
// 1. cpd.ErrCookieExpired:响应体是 HTML(被重定向到统一身份认证登录页),
// 这是最常见、也最容易被漏判的情形(原文案匹配不到 "invalid character '<'");
// 2. 接口以 JSON 形式返回的「未登录 / 登录已过期」。
func isCookieExpired(err error) bool {
if err == nil {
return false
}
if errors.Is(err, cpd.ErrCookieExpired) {
return true
}
msg := err.Error()
return strings.Contains(msg, "未登录") ||
strings.Contains(msg, "登录已过期")
}
// isCpdailyAuthError 语义化别名:今日校园登录态失效。
func isCpdailyAuthError(err error) bool {
return isCookieExpired(err)
}
// refreshCpdailyCookie 使用数据库中的 raw_password 重新登录 cpdaily 并更新 cookie
// 密码错误时返回 ErrPasswordChanged
func refreshCpdailyCookie(user *model.SysUser) error {
// 重新从数据库加载用户(获取最新 raw_password)
// 重新从数据库加载用户(获取最新 raw_password 与 cookie)
var fresh model.SysUser
if err := database.DB.First(&fresh, user.ID).Error; err != nil {
slog.Error("刷新cookie: 查询用户失败", "userId", user.ID, "error", err)
return err
}
// 优先尝试用长期 cookie(sessionToken/CASTGC)直接换取新的 CAS cookie。
// 手机号验证码登录的账号没有 raw_password,只能走此路径自动续期。
if refreshed, err := refreshCookieByLongTermToken(&fresh); err == nil && refreshed != "" {
if err := database.DB.Model(&fresh).Update("cookie", refreshed).Error; err != nil {
slog.Warn("刷新cookie: 写回长期凭证续期结果失败", "userId", user.ID, "error", err)
} else {
slog.Info("刷新cookie: 已用长期凭证续期成功", "userId", user.ID)
return nil
}
} else if err != nil {
slog.Info("刷新cookie: 长期凭证续期未成功,回退密码重登", "userId", user.ID, "error", err)
}
if fresh.RawPassword == "" {
slog.Warn("刷新cookie: 未保存密码", "userId", user.ID)
return errors.New("未保存登录密码,无法自动重新登录")
return fmt.Errorf("%w: 未保存登录密码,无法自动重新登录", ErrNeedReLogin)
}
// 解密密码
@@ -105,7 +151,7 @@ func refreshCpdailyCookie(user *model.SysUser) error {
// 多因子认证学校:重新登录需人工输入验证码,无法自动续期
if errors.Is(err, cpd.ErrMFARequired) || mfaCtx != nil {
slog.Warn("刷新cookie: 该账号登录需多因子认证,无法自动续期", "userId", user.ID, "school", school.Name)
return errors.New("该账号所属学校开启登录验证码校验,需重新登录完成验证")
return fmt.Errorf("%w: 该账号所属学校开启登录验证码校验,需重新登录完成验证", ErrNeedReLogin)
}
// 检查 MOD_ cookie 确保登录成功
@@ -128,12 +174,77 @@ func refreshCpdailyCookie(user *model.SysUser) error {
return err
}
// 同步用户信息(异步)
go syncUserProfile(user, cookieStr, school.WecloudUrl)
// 更新传入用户的 cookie(引用),上层重试时使用
// 先更新传入用户的 cookie(引用),上层重试与后续同步均使用最新值
user.Cookie = cookieStr
// 同步用户信息(异步,禁续期:避免「续期 → 同步 → 再续期」递归)
go syncUserProfileWith(user, cookieStr, school.WecloudUrl, false)
slog.Info("刷新cookie: 成功", "userId", user.ID)
return nil
}
// refreshCookieByLongTermToken 用已保存的长期 cookie(sessionToken/CASTGC)重新换取 CAS cookie。
// 适用于手机号验证码登录(无 raw_password)的账号自动续期;失败时返回错误,由调用方回退。
func refreshCookieByLongTermToken(user *model.SysUser) (string, error) {
cookieMap := cpd.ParseCookieJSON(user.Cookie)
if cookieMap["sessionToken"] == "" {
return "", errors.New("缺少长期凭证 sessionToken")
}
school, err := cpd.GetSchoolDetail(user.SchoolID)
if err != nil {
return "", err
}
cookies, err := cpd.RefreshCasCookie(context.Background(), cookieMap, school.WecloudUrl, user.MobileCode)
if err != nil {
return "", err
}
hasMOD := false
for _, c := range cookies {
if strings.HasPrefix(c.Name, "MOD_") {
hasMOD = true
break
}
}
if !hasMOD {
return "", errors.New("续期未获取到 MOD_ cookie")
}
cookieStr := cpd.SerializeCookies(cookies)
// 回写传入用户,供上层重试使用
user.Cookie = cookieStr
return cookieStr, nil
}
// withCookieRefresh 执行一次今日校园调用;若因登录态失效失败,则尝试续期 cookie 后重试一次。
// - 返回 (调用结果, 续期后的 cookieMap, error)。cookieMap 有变化时调用方可用于后续调用。
// - 续期失败(如 MFA 学校无法自动续期、密码已改)时,返回错误里带上 ErrCookieExpired,
// 由上层转成「登录凭证已过期,请重新登录」引导用户重登。
//
// 说明:MFA 学校登录后签发的是短期 JWT,到期会要求重新登录,本就无需自动续期;
// 普通学校(非 MFA)的 cookie 会在此处用长期凭证/密码自动续期,避免用户被莫名踢出。
func withCookieRefresh(user *model.SysUser, cookieMap map[string]string, host string,
fn func(cm map[string]string) error) (map[string]string, error) {
err := fn(cookieMap)
if err == nil || !isCpdailyAuthError(err) {
return cookieMap, err
}
slog.Info("检测到今日校园登录态失效,尝试自动续期 cookie", "userId", user.ID, "host", host)
if refreshErr := refreshCpdailyCookie(user); refreshErr != nil {
// 续期失败:保留原始错误语义,让上层引导重新登录
slog.Warn("cookie 自动续期失败", "userId", user.ID, "error", refreshErr)
return cookieMap, fmt.Errorf("%w: %v", cpd.ErrCookieExpired, refreshErr)
}
// 用续期后的 cookie 重试一次
newMap := cpd.ParseCookieJSON(user.Cookie)
if retryErr := fn(newMap); retryErr != nil {
return newMap, retryErr
}
return newMap, nil
}
+21
View File
@@ -54,6 +54,25 @@ func getVipCardPrice(key string, def float64) float64 {
func getVipWeekPrice() float64 { return getVipCardPrice("vip_price_week", 30.00) }
func getVipMonthPrice() float64 { return getVipCardPrice("vip_price_month", 100.00) }
// getCdkBuyUrl 获取「购买 CDK」跳转链接(系统设置 cdk_buy_url,默认空 = 不展示按钮)
func getCdkBuyUrl() string {
var cfg model.SystemConfig
if database.DB.Where("`key` = ?", "cdk_buy_url").First(&cfg).Error == nil {
return strings.TrimSpace(cfg.Value)
}
return ""
}
// getCdkBuyVisible 获取「购买 CDK」按钮是否展示(系统设置 cdk_buy_visible,默认 false)
// 生效条件:开关为 true 且链接非空(链接为空时前端无处可跳,视为关闭)。
func getCdkBuyVisible() bool {
var cfg model.SystemConfig
if database.DB.Where("`key` = ?", "cdk_buy_visible").First(&cfg).Error == nil {
return cfg.Value == "true" && getCdkBuyUrl() != ""
}
return false
}
// GenerateCDK 生成CDK
func GenerateCDK(c *fiber.Ctx) error {
agentID := c.Locals("adminId").(uint)
@@ -502,5 +521,7 @@ func GetUserVipInfo(c *fiber.Ctx) error {
"vipWeekPrice": formatFloat(getVipWeekPrice()),
"vipMonthPrice": formatFloat(getVipMonthPrice()),
"vipPrice": formatFloat(getVipMonthPrice()), // 兼容旧字段:月卡价
"cdkBuyUrl": getCdkBuyUrl(),
"cdkBuyVisible": getCdkBuyVisible(),
})
}
+46
View File
@@ -0,0 +1,46 @@
package handler
import (
"cpdaily-new/internal/database"
"cpdaily-new/internal/model"
"strconv"
)
// 扣款额度系统设置 key(每次成功执行对应操作扣减的可签到次数)
const (
settingDeductCheckin = "deduct_checkin" // 打卡
settingDeductLeave = "deduct_leave" // 请假
settingDeductReport = "deduct_report" // 销假
)
// parseDeductValue 解析扣款额度字符串:非法或负数时回退到 def。
// 允许 0(0 表示不扣费),仅负数与解析失败视为无效。
func parseDeductValue(value string, def float64) float64 {
if v, err := strconv.ParseFloat(value, 64); err == nil && v >= 0 {
return v
}
return def
}
// getDeduct 从系统设置读取扣款额度;缺省/非法/负数时回退到 def,允许 0。
func getDeduct(key string, def float64) float64 {
var cfg model.SystemConfig
if database.DB.Where("`key` = ?", key).First(&cfg).Error == nil {
return parseDeductValue(cfg.Value, def)
}
return def
}
// getCheckinDeduct 打卡扣减额度(默认 1)
func getCheckinDeduct() float64 { return getDeduct(settingDeductCheckin, 1) }
// getLeaveDeduct 请假扣减额度(默认 1)
func getLeaveDeduct() float64 { return getDeduct(settingDeductLeave, 1) }
// getReportDeduct 销假扣减额度(默认 1)
func getReportDeduct() float64 { return getDeduct(settingDeductReport, 1) }
// fmtDeduct 额度展示:整数去尾零(1.00→1,0.50→0.5,0→0)
func fmtDeduct(v float64) string {
return strconv.FormatFloat(v, 'f', -1, 64)
}
+47
View File
@@ -0,0 +1,47 @@
package handler
import "testing"
// TestParseDeductValue 扣款额度解析:允许 0 与小数,非法/负数回退默认值。
func TestParseDeductValue(t *testing.T) {
cases := []struct {
name string
value string
def float64
want float64
}{
{"空字符串回退默认", "", 1, 1},
{"整数", "2", 1, 2},
{"小数", "0.5", 1, 0.5},
{"零不扣费", "0", 1, 0},
{"零带小数", "0.0", 1, 0},
{"负数回退默认", "-1", 1, 1},
{"非数字回退默认", "abc", 1, 1},
{"科学计数", "1e1", 1, 10},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
if got := parseDeductValue(c.value, c.def); got != c.want {
t.Fatalf("parseDeductValue(%q, %v) = %v, want %v", c.value, c.def, got, c.want)
}
})
}
}
// TestFmtDeduct 额度展示去尾零。
func TestFmtDeduct(t *testing.T) {
cases := []struct {
in float64
want string
}{
{1, "1"},
{0, "0"},
{0.5, "0.5"},
{2.25, "2.25"},
}
for _, c := range cases {
if got := fmtDeduct(c.in); got != c.want {
t.Fatalf("fmtDeduct(%v) = %q, want %q", c.in, got, c.want)
}
}
}
+139
View File
@@ -0,0 +1,139 @@
package handler
import (
"crypto/rand"
"errors"
"fmt"
"log/slog"
"math/big"
"strings"
"cpdaily-new/internal/database"
"cpdaily-new/internal/model"
)
// 邀请码规则:每个用户在注册(首次登录)时自动获得一个邀请码,不再提供手动生成接口。
// - 字符集去掉易混淆的 0/O、1/I/L,便于口头/截图分享;
// - 长度 6,字符集 31 个 → 约 8.9 亿种组合,配合唯一索引冲突重试,碰撞概率可忽略;
// - 使用 crypto/rand:旧实现以纳秒时间戳取模,可预测且并发注册时易生成相同码,
// 在 invite_code 唯一索引下会直接导致用户创建(登录)失败。
const (
inviteCodeChars = "ABCDEFGHJKMNPQRSTUVWXYZ23456789"
inviteCodeLength = 6
inviteCodeAttempts = 8
)
// newInviteCode 生成一个随机邀请码(不保证唯一,唯一性由调用方的写库重试保证)
func newInviteCode() (string, error) {
return randomFromCharset(inviteCodeChars, inviteCodeLength)
}
// randomFromCharset 用 crypto/rand 从字符集中均匀取 n 个字符
func randomFromCharset(charset string, n int) (string, error) {
max := big.NewInt(int64(len(charset)))
var sb strings.Builder
sb.Grow(n)
for i := 0; i < n; i++ {
idx, err := rand.Int(rand.Reader, max)
if err != nil {
return "", err
}
sb.WriteByte(charset[idx.Int64()])
}
return sb.String(), nil
}
// isInviteCodeConflict 判断是否为「邀请码」唯一索引冲突。
// 只认 invite_code 索引:用户名等其他唯一键冲突也会报 Duplicate entry,换邀请码重试毫无意义,
// 必须原样返回给调用方。MySQL 报错形如:
//
// Error 1062 (23000): Duplicate entry 'ABC123' for key 'sys_user.idx_sys_user_invite_code'
func isInviteCodeConflict(err error) bool {
if err == nil {
return false
}
msg := err.Error()
return strings.Contains(msg, "Duplicate entry") && strings.Contains(msg, "invite_code")
}
// withUniqueInviteCode 生成邀请码并执行写库操作 write(code);
// 若写库因唯一索引冲突失败,则换一个新码重试,最多 inviteCodeAttempts 次。
// 冲突判定交给数据库唯一索引(而非「先查后写」),并发下依然正确。
func withUniqueInviteCode(write func(code string) error) (string, error) {
var lastErr error
for i := 0; i < inviteCodeAttempts; i++ {
code, err := newInviteCode()
if err != nil {
return "", fmt.Errorf("生成邀请码失败: %w", err)
}
err = write(code)
if err == nil {
return code, nil
}
if !isInviteCodeConflict(err) {
return "", err
}
lastErr = err
}
return "", fmt.Errorf("邀请码重复次数过多: %w", lastErr)
}
// ensureInviteCode 确保用户拥有邀请码:已有则直接返回;没有则生成并写库。
// 写库条件带 invite_code 为空,避免并发登录时把已生成的码覆盖掉;
// 若条件未命中(被并发请求抢先写入),重新读取最新值。
func ensureInviteCode(user *model.SysUser) error {
if strings.TrimSpace(user.InviteCode) != "" {
return nil
}
code, err := withUniqueInviteCode(func(code string) error {
res := database.DB.Model(&model.SysUser{}).
Where("id = ? AND (invite_code IS NULL OR invite_code = '')", user.ID).
Update("invite_code", code)
if res.Error != nil {
return res.Error
}
if res.RowsAffected == 0 {
return errInviteCodeAlreadySet
}
return nil
})
if errors.Is(err, errInviteCodeAlreadySet) {
var fresh model.SysUser
if e := database.DB.Select("id", "invite_code").First(&fresh, user.ID).Error; e != nil {
return e
}
user.InviteCode = fresh.InviteCode
return nil
}
if err != nil {
return err
}
user.InviteCode = code
slog.Info("已为用户补发邀请码", "userId", user.ID, "inviteCode", code)
return nil
}
var errInviteCodeAlreadySet = errors.New("invite code already set")
// BackfillInviteCodes 启动时为所有缺少邀请码的存量用户补齐邀请码(幂等,无缺失时只执行一次计数查询)。
func BackfillInviteCodes() {
var users []model.SysUser
if err := database.DB.Select("id", "invite_code").
Where("invite_code IS NULL OR invite_code = ''").
Find(&users).Error; err != nil {
slog.Error("查询缺少邀请码的用户失败", "error", err)
return
}
if len(users) == 0 {
return
}
ok := 0
for i := range users {
if err := ensureInviteCode(&users[i]); err != nil {
slog.Error("补发邀请码失败", "userId", users[i].ID, "error", err)
continue
}
ok++
}
slog.Info("存量用户邀请码补齐完成", "total", len(users), "success", ok)
}
@@ -0,0 +1,186 @@
package handler
import (
"fmt"
"os"
"sync"
"testing"
"cpdaily-new/internal/database"
"cpdaily-new/internal/model"
"gorm.io/driver/mysql"
"gorm.io/gorm"
"gorm.io/gorm/logger"
)
// 集成测试:仅在设置 INVITE_IT_DSN 时运行(指向一次性测试库,会建表/清表)。
//
// INVITE_IT_DSN='root:test@tcp(127.0.0.1:33099)/invtest?charset=utf8mb4&parseTime=True&loc=Local' go test -run Integration ./internal/handler/
func setupInviteIT(t *testing.T) {
dsn := os.Getenv("INVITE_IT_DSN")
if dsn == "" {
t.Skip("未设置 INVITE_IT_DSN,跳过数据库集成测试")
}
db, err := gorm.Open(mysql.Open(dsn), &gorm.Config{Logger: logger.Default.LogMode(logger.Silent)})
if err != nil {
t.Fatalf("连接测试库失败: %v", err)
}
if err := db.Migrator().DropTable(&model.SysUser{}); err != nil {
t.Fatalf("清表失败: %v", err)
}
if err := db.AutoMigrate(&model.SysUser{}); err != nil {
t.Fatalf("建表失败: %v", err)
}
database.DB = db
}
func insertUser(t *testing.T, name, code string) model.SysUser {
u := model.SysUser{Username: name, Password: "x", SchoolID: "s", SchoolName: "S", StudentID: name}
if err := database.DB.Create(&u).Error; err != nil {
t.Fatalf("插入用户失败: %v", err)
}
// invite_code 为 varchar NULL + 唯一索引;用原生 SQL 精确构造 NULL / '' 两种缺失形态
if code == "<NULL>" {
database.DB.Exec("UPDATE sys_user SET invite_code = NULL WHERE id = ?", u.ID)
} else {
database.DB.Exec("UPDATE sys_user SET invite_code = ? WHERE id = ?", code, u.ID)
}
return u
}
// TestIntegrationConflictErrorText 真实 MySQL 的唯一索引冲突报错能被 isInviteCodeConflict 识别;
// 用户名冲突不会被误判
func TestIntegrationConflictErrorText(t *testing.T) {
setupInviteIT(t)
insertUser(t, "u1", "AAAAAA")
u2 := insertUser(t, "u2", "BBBBBB")
err := database.DB.Model(&model.SysUser{}).Where("id = ?", u2.ID).Update("invite_code", "AAAAAA").Error
if err == nil {
t.Fatal("应触发唯一索引冲突")
}
t.Logf("邀请码冲突真实报错: %v", err)
if !isInviteCodeConflict(err) {
t.Fatalf("未识别为邀请码冲突: %v", err)
}
err = database.DB.Create(&model.SysUser{Username: "u1", Password: "x", InviteCode: "CCCCCC"}).Error
if err == nil {
t.Fatal("应触发用户名唯一冲突")
}
t.Logf("用户名冲突真实报错: %v", err)
if isInviteCodeConflict(err) {
t.Fatalf("用户名冲突被误判为邀请码冲突: %v", err)
}
}
// TestIntegrationEnsureAndBackfill 已有码不覆盖;NULL 与空串都会被补齐;补齐后全部唯一
func TestIntegrationEnsureAndBackfill(t *testing.T) {
setupInviteIT(t)
keep := insertUser(t, "keep", "KEEP23")
insertUser(t, "n1", "<NULL>")
insertUser(t, "n2", "<NULL>")
insertUser(t, "e1", "") // 空串最多一行(唯一索引),正好覆盖历史数据形态
BackfillInviteCodes()
var users []model.SysUser
database.DB.Order("id").Find(&users)
seen := map[string]bool{}
for _, u := range users {
if u.InviteCode == "" {
t.Fatalf("用户 %s 仍无邀请码", u.Username)
}
if seen[u.InviteCode] {
t.Fatalf("邀请码重复: %s", u.InviteCode)
}
seen[u.InviteCode] = true
if u.ID == keep.ID && u.InviteCode != "KEEP23" {
t.Fatalf("已有邀请码被覆盖: %s", u.InviteCode)
}
}
// 再跑一次应为幂等(不改动任何人)
before := map[uint]string{}
for _, u := range users {
before[u.ID] = u.InviteCode
}
BackfillInviteCodes()
database.DB.Order("id").Find(&users)
for _, u := range users {
if before[u.ID] != u.InviteCode {
t.Fatalf("第二次补齐改动了用户 %s 的邀请码", u.Username)
}
}
}
// TestIntegrationEnsureConcurrent 同一用户并发补发:只写入一次,所有调用方拿到同一个码
func TestIntegrationEnsureConcurrent(t *testing.T) {
setupInviteIT(t)
u := insertUser(t, "race", "<NULL>")
const n = 20
codes := make([]string, n)
var wg sync.WaitGroup
for i := 0; i < n; i++ {
wg.Add(1)
go func(i int) {
defer wg.Done()
cp := model.SysUser{ID: u.ID}
if err := ensureInviteCode(&cp); err != nil {
t.Errorf("ensureInviteCode: %v", err)
}
codes[i] = cp.InviteCode
}(i)
}
wg.Wait()
var fresh model.SysUser
database.DB.First(&fresh, u.ID)
for i, c := range codes {
if c != fresh.InviteCode {
t.Fatalf("第 %d 个调用拿到 %q,库里是 %q", i, c, fresh.InviteCode)
}
}
}
// TestIntegrationRegisterAndLoginBackfill 新用户注册自带邀请码;老用户(无码)登录时补发
func TestIntegrationRegisterAndLoginBackfill(t *testing.T) {
setupInviteIT(t)
nu, isNew, err := findOrCreateUser("newbie", "pw", "s", "S", "2025001", "")
if err != nil || !isNew || nu.InviteCode == "" {
t.Fatalf("新用户注册应带邀请码: user=%+v isNew=%v err=%v", nu, isNew, err)
}
old := insertUser(t, "olduser", "<NULL>")
got, isNew, err := findOrCreateUser("olduser", "pw", "s", "S", "olduser", "")
if err != nil || isNew {
t.Fatalf("老用户登录: isNew=%v err=%v", isNew, err)
}
if got.ID != old.ID || got.InviteCode == "" {
t.Fatalf("老用户登录应补发邀请码: %+v", got)
}
var fresh model.SysUser
database.DB.First(&fresh, old.ID)
if fresh.InviteCode != got.InviteCode {
t.Fatalf("补发的邀请码未落库: 返回 %q 库里 %q", got.InviteCode, fresh.InviteCode)
}
// 并发注册多个新用户,邀请码互不重复
var wg sync.WaitGroup
errs := make(chan error, 30)
for i := 0; i < 30; i++ {
wg.Add(1)
go func(i int) {
defer wg.Done()
name := fmt.Sprintf("bulk%02d", i)
if _, _, e := findOrCreateUser(name, "pw", "s", "S", name, ""); e != nil {
errs <- e
}
}(i)
}
wg.Wait()
close(errs)
for e := range errs {
t.Fatalf("并发注册失败: %v", e)
}
var dup int64
database.DB.Raw("SELECT COUNT(*) FROM (SELECT invite_code FROM sys_user GROUP BY invite_code HAVING COUNT(*) > 1) t").Scan(&dup)
if dup != 0 {
t.Fatalf("存在 %d 组重复邀请码", dup)
}
}
+113
View File
@@ -0,0 +1,113 @@
package handler
import (
"errors"
"strings"
"testing"
)
// TestNewInviteCodeFormat 邀请码长度、字符集(不含易混淆的 0/O/1/I/L)
func TestNewInviteCodeFormat(t *testing.T) {
for i := 0; i < 2000; i++ {
code, err := newInviteCode()
if err != nil {
t.Fatalf("newInviteCode error: %v", err)
}
if len(code) != inviteCodeLength {
t.Fatalf("长度应为 %d,得到 %q", inviteCodeLength, code)
}
for _, ch := range code {
if !strings.ContainsRune(inviteCodeChars, ch) {
t.Fatalf("出现字符集外字符 %q(code=%q)", ch, code)
}
if strings.ContainsRune("0O1IL", ch) {
t.Fatalf("出现易混淆字符 %q(code=%q)", ch, code)
}
}
}
}
// TestNewInviteCodeRandomness 连续生成不应大量重复(旧实现按纳秒取模,快速连续调用会高度重复)
func TestNewInviteCodeRandomness(t *testing.T) {
const n = 5000
seen := make(map[string]struct{}, n)
for i := 0; i < n; i++ {
code, _ := newInviteCode()
seen[code] = struct{}{}
}
// 31^6 ≈ 8.9 亿空间,5000 个样本的期望碰撞数 ≈ 0.014;允许极少量
if dup := n - len(seen); dup > 2 {
t.Fatalf("5000 次生成出现 %d 个重复,随机性异常", dup)
}
}
func dupErr(key string) error {
return errors.New("Error 1062 (23000): Duplicate entry 'ABC123' for key '" + key + "'")
}
// TestWithUniqueInviteCodeRetriesOnConflict 邀请码唯一索引冲突时换码重试,成功返回最终写入的码
func TestWithUniqueInviteCodeRetriesOnConflict(t *testing.T) {
calls := 0
var tried []string
code, err := withUniqueInviteCode(func(c string) error {
calls++
tried = append(tried, c)
if calls < 3 {
return dupErr("sys_user.idx_sys_user_invite_code")
}
return nil
})
if err != nil {
t.Fatalf("应重试成功,得到错误 %v", err)
}
if calls != 3 {
t.Fatalf("应调用 3 次,实际 %d", calls)
}
if code != tried[len(tried)-1] {
t.Fatalf("返回的码应为最后一次写入的码:got %q, want %q", code, tried[len(tried)-1])
}
}
// TestWithUniqueInviteCodeNoRetryOnOtherErrors 用户名冲突等其他错误不重试,原样返回
func TestWithUniqueInviteCodeNoRetryOnOtherErrors(t *testing.T) {
cases := []error{
dupErr("sys_user.idx_sys_user_username"),
errors.New("connection refused"),
}
for _, want := range cases {
calls := 0
_, err := withUniqueInviteCode(func(string) error { calls++; return want })
if calls != 1 {
t.Fatalf("非邀请码冲突不应重试(%v),实际调用 %d 次", want, calls)
}
if !errors.Is(err, want) && err.Error() != want.Error() {
t.Fatalf("应原样返回错误 %v,得到 %v", want, err)
}
}
}
// TestWithUniqueInviteCodeGivesUp 持续冲突时有上限,不会死循环
func TestWithUniqueInviteCodeGivesUp(t *testing.T) {
calls := 0
_, err := withUniqueInviteCode(func(string) error {
calls++
return dupErr("sys_user.idx_sys_user_invite_code")
})
if err == nil {
t.Fatal("持续冲突应返回错误")
}
if calls != inviteCodeAttempts {
t.Fatalf("应尝试 %d 次,实际 %d", inviteCodeAttempts, calls)
}
}
// TestGenerateRandomCode 短信登录占位密码等仍使用该函数:长度正确且不重复
func TestGenerateRandomCode(t *testing.T) {
a, b := generateRandomCode(32), generateRandomCode(32)
if len(a) != 32 || len(b) != 32 {
t.Fatalf("长度应为 32:%q %q", a, b)
}
if a == b {
t.Fatal("两次生成不应相同")
}
}
+431
View File
@@ -0,0 +1,431 @@
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)
// 学校独立学工系统:销假不经辅导猫,直接给出明确提示(否则会得到误导性的 420)
if rejectExternalLeaveSystem(c, &user, cookieMap) {
return nil
}
// 应用套件分流:新套(leave-new)走独立链路
isNewFlavor := schoolLeaveFlavor(&user, cookieMap) == cpd.LeaveFlavorNew
var items []cpd.LeaveItem
_, err := withCookieRefresh(&user, cookieMap, host, func(cm map[string]string) error {
if isNewFlavor {
// 套件判定用的是会话开始时解析的 cookie;续期后重新判定以保持准确
if schoolLeaveFlavor(&user, cm) == cpd.LeaveFlavorNew {
var e error
items, e = fetchLeaveNewPendingList(cm, host, &user)
return e
}
}
var e error
items, e = cpd.FetchLeaveList(cm, host)
return e
})
if err != nil {
slog.Error("获取待销假列表失败", "userId", userID, "error", err)
if isCpdailyAuthError(err) {
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, "请先登录")
}
// 设备码后端硬校验:销假执行需携带设备码(对齐打卡侧拦截语义),
// 账密登录创建的账号可能未绑定设备码,空设备码会触发学校端风控异常
if strings.TrimSpace(user.MobileCode) == "" {
return utils.Fail(c, 400, "请先在个人中心绑定设备码后再执行销假")
}
// 学校独立学工系统:销假不经辅导猫
if rejectExternalLeaveSystem(c, &user, cpd.ParseCookieJSON(user.Cookie)) {
return nil
}
// 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())
// 本次销假扣减额度(后台可配置,允许小数与 0=不扣费)
deduct := getReportDeduct()
// 检查可用次数(VIP 免检查;0 额度时恒放行)
if !isVip && user.SignCount < deduct {
slog.Warn("销假次数不足,拒绝执行", "userId", userID, "signCount", user.SignCount, "deduct", deduct)
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)
// 新套(leave-new):独立销假链路(详情校验 → 销假提交)
if schoolLeaveFlavor(&user, cookieMap) == cpd.LeaveFlavorNew {
sendEvent("fetching_detail", map[string]interface{}{"status": "fetching_detail", "message": "正在获取请假详情..."})
title := "销假"
if d, derr := cpd.FetchLeaveNewDetail(cookieMap, host, req.LeaveID); derr == nil {
title = leaveLogTitle(&cpd.LeaveDetail{LeaveType: d.TopicName}, req.LeaveID)
}
sendEvent("submitting", map[string]interface{}{"status": "submitting", "message": "正在提交销假..."})
ok, errMsg, needReLogin := leaveNewExecuteReport(&user, cookieMap, host,
req.LeaveID, req.Address, req.Longitude, req.Latitude, req.ImageURL, req.ApproverWid)
if !ok {
slog.Error("销假失败(新套)", "userId", userID, "leaveId", req.LeaveID, "error", errMsg)
saveLog(false, errMsg, title)
sendEvent("task_error", map[string]interface{}{"status": "error", "message": errMsg, "needReLogin": needReLogin})
return
}
saveLog(true, "", title)
if !isVip {
database.DB.Model(&model.SysUser{}).Where("id = ? AND sign_count >= ?", userID, deduct).
Update("sign_count", gorm.Expr("sign_count - ?", deduct))
msg := "销假成功!(已扣 " + fmtDeduct(deduct) + " 次)"
if deduct <= 0 {
msg = "销假成功!(免扣次数)"
}
sendEvent("success", map[string]interface{}{"status": "success", "message": msg})
} else {
sendEvent("success", map[string]interface{}{"status": "success", "message": "销假成功!(VIP 免扣次数)"})
}
slog.Info("销假成功(新套)", "userId", userID, "leaveId", req.LeaveID, "title", title, "isVip", isVip)
return
}
// 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 {
err = wrapNeedReLogin(refreshErr)
}
}
if err != nil {
errMsg := "获取请假详情失败: " + err.Error()
if needReLoginSignal(err) {
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": needReLoginSignal(err)})
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 {
err = wrapNeedReLogin(refreshErr)
}
}
if err != nil {
errMsg := "销假失败: " + err.Error()
if needReLoginSignal(err) {
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": needReLoginSignal(err)})
return
}
// 4. 成功:写日志 + 扣减可用次数(VIP 有效期内免扣)
saveLog(true, "", title)
if !isVip {
database.DB.Model(&model.SysUser{}).Where("id = ? AND sign_count >= ?", userID, deduct).
Update("sign_count", gorm.Expr("sign_count - ?", deduct))
msg := "销假成功!(已扣 " + fmtDeduct(deduct) + " 次)"
if deduct <= 0 {
msg = "销假成功!(免扣次数)"
}
sendEvent("success", map[string]interface{}{
"status": "success",
"message": msg,
})
} 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)
// 学校独立学工系统:销假审批人也不经辅导猫
if rejectExternalLeaveSystem(c, &user, cookieMap) {
return nil
}
// 新套:销假确认人(getReportApprovers by leaveId)
if schoolLeaveFlavor(&user, cookieMap) == cpd.LeaveFlavorNew {
approvers, err := fetchLeaveNewReportApprovers(cookieMap, host, req.LeaveID, &user)
if err != nil && isCookieExpired(err) {
if refreshErr := refreshCpdailyCookie(&user); refreshErr == nil {
cookieMap = cpd.ParseCookieJSON(user.Cookie)
approvers, err = fetchLeaveNewReportApprovers(cookieMap, host, req.LeaveID, &user)
} else {
err = wrapNeedReLogin(refreshErr)
}
}
if err != nil {
slog.Error("获取销假审批人失败(新套)", "userId", userID, "leaveId", req.LeaveID, "error", err)
if needReLoginSignal(err) {
return utils.Fail(c, 401, "登录凭证已过期,请重新登录")
}
return utils.Fail(c, 503, "获取销假审批人失败: "+err.Error())
}
return utils.Success(c, approvers)
}
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 {
err = wrapNeedReLogin(refreshErr)
}
}
if err != nil {
slog.Error("获取请假详情失败(审批人)", "userId", userID, "leaveId", req.LeaveID, "error", err)
if needReLoginSignal(err) {
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 {
err = wrapNeedReLogin(refreshErr)
}
}
if err != nil {
slog.Error("获取销假审批人失败", "userId", userID, "leaveId", req.LeaveID, "error", err)
if needReLoginSignal(err) {
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)
}
+519
View File
@@ -0,0 +1,519 @@
package handler
import (
"bufio"
"encoding/json"
"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 获取请假类型列表(含全部权威开关,前端据此渲染表单)。
// 按学校应用的套件分流:新套(leave-new)翻译为同一契约返回。
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)
// 学校独立学工系统:不走辅导猫链路,返回明确提示(避免用户看到莫名的 420 到期错误)
if rejectExternalLeaveSystem(c, user, cookieMap) {
return nil
}
flavor := schoolLeaveFlavor(user, cookieMap)
var types []cpd.LeaveType
if flavor == cpd.LeaveFlavorNew {
types, err = fetchLeaveNewTypesWithDetail(cookieMap, host, user)
} else {
types, err = cpd.FetchLeaveTypes(cookieMap, host)
}
if err != nil && isCookieExpired(err) {
if refreshErr := refreshCpdailyCookie(user); refreshErr == nil {
cookieMap = cpd.ParseCookieJSON(user.Cookie)
if flavor == cpd.LeaveFlavorNew {
types, err = fetchLeaveNewTypesWithDetail(cookieMap, host, user)
} else {
types, err = cpd.FetchLeaveTypes(cookieMap, host)
}
} else {
err = wrapNeedReLogin(refreshErr)
}
}
if err != nil {
slog.Error("获取请假类型失败", "userId", user.ID, "flavor", flavor.String(), "error", err)
if needReLoginSignal(err) {
return utils.Fail(c, 401, "登录凭证已过期,请重新登录")
}
return utils.Fail(c, 503, "获取请假类型失败: "+err.Error())
}
return utils.Success(c, types)
}
// GetLeaveApplyApprovers 获取请假可选审批人(needApproval=1 的类型)。
// 请求:{ leaveType, startTime, endTime }(与销假审批人接口区分:此处选择类型发生在提交前)。
// 新套额外返回 flowData(提交请假时原样回传),前端不透明缓存即可。
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)
// 学校独立学工系统:请假审批人不经辅导猫
if rejectExternalLeaveSystem(c, user, cookieMap) {
return nil
}
flavor := schoolLeaveFlavor(user, cookieMap)
if flavor == cpd.LeaveFlavorNew {
res, err := fetchLeaveNewApplyApprovers(cookieMap, host, req.LeaveType, req.StartTime, req.EndTime)
if err != nil && isCookieExpired(err) {
if refreshErr := refreshCpdailyCookie(user); refreshErr == nil {
cookieMap = cpd.ParseCookieJSON(user.Cookie)
res, err = fetchLeaveNewApplyApprovers(cookieMap, host, req.LeaveType, req.StartTime, req.EndTime)
} else {
err = wrapNeedReLogin(refreshErr)
}
}
if err != nil {
slog.Error("获取请假审批人失败(新套)", "userId", user.ID, "leaveType", req.LeaveType, "error", err)
if needReLoginSignal(err) {
return utils.Fail(c, 401, "登录凭证已过期,请重新登录")
}
return utils.Fail(c, 503, "获取请假审批人失败: "+err.Error())
}
// 保持与旧套一致的数组契约(flowData 由服务端在执行时重新获取,前端无需感知)
return utils.Success(c, res.Approvers)
}
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 {
err = wrapNeedReLogin(refreshErr)
}
}
if err != nil {
slog.Error("获取请假审批人失败", "userId", user.ID, "leaveType", req.LeaveType, "error", err)
if needReLoginSignal(err) {
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"` // 定位地址
ImageURLs []string `json:"imageUrls"` // 附件图片 URL 列表(可选,最多4张且不超学校上限)
ImageURL string `json:"imageUrl"` // 兼容旧前端:单附件 URL(与 imageUrls 合并去重)
ApproverWid string `json:"approverWid"` // 审批人 userWid(候选为空时可不传)
ApproverId string `json:"approverId"` // 审批人 userId
ApproverName string `json:"approverName"` // 审批人姓名
CcUsers []cpd.LeaveApprover `json:"ccUsers"` // 抄送人(非必填,候选取自审批人列表)
SignatureURL string `json:"signatureUrl"` // 手写承诺书签名图 URL(requireSign=1 的类型必填)
// —— 续假(仅新套)——
IsExtend bool `json:"isExtend"` // true=续假(延长已有假单)
ExtendLeaveID string `json:"extendLeaveId"` // 续假的原假单 ID
// 自定义附加字段的作答(新套 selfDefineFields,如「当天是否返校」)
SelfDefineAnswers []map[string]interface{} `json:"selfDefineAnswers"`
}
// mergeApplyImageURLs 合并 imageUrls 与兼容字段 imageUrl,保序去重并剔除空串
func mergeApplyImageURLs(urls []string, legacy string) []string {
out := make([]string, 0, len(urls)+1)
seen := make(map[string]bool, len(urls)+1)
for _, u := range append(append([]string{}, urls...), legacy) {
u = strings.TrimSpace(u)
if u == "" || seen[u] {
continue
}
seen[u] = true
out = append(out, u)
}
return out
}
// 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)
// 附件:多图字段 + 旧前端兼容单图字段,去重合并
imageURLs := mergeApplyImageURLs(req.ImageURLs, req.ImageURL)
if len(imageURLs) > cpd.LeaveMaxAttachCount(0) {
return utils.FailParam(c, "附件最多 4 张")
}
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, "请先登录")
}
// 设备码后端硬校验:请假执行需携带设备码(对齐打卡侧拦截语义),
// 账密登录创建的账号可能未绑定设备码,空设备码会触发学校端风控异常
if strings.TrimSpace(user.MobileCode) == "" {
return utils.Fail(c, 400, "请先在个人中心绑定设备码后再执行请假")
}
// 学校独立学工系统:请假提交不经辅导猫
if rejectExternalLeaveSystem(c, &user, cpd.ParseCookieJSON(user.Cookie)) {
return nil
}
// 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)
// 续假与新建请假分开归档:action=extend / apply,后台据此分类型统计
applyAction := model.LeaveActionApply
if req.IsExtend {
applyAction = model.LeaveActionExtend
}
// 类型配置(拿 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: applyAction,
IsSuccess: isSuccess,
ErrorMessage: errMsg,
}
database.DB.Create(&logEntry)
}
isVip := user.VipExpireAt != nil && user.VipExpireAt.After(time.Now())
// 本次请假扣减额度(后台可配置,允许小数与 0=不扣费)
deduct := getLeaveDeduct()
if !isVip && user.SignCount < deduct {
slog.Warn("请假次数不足,拒绝执行", "userId", userID, "signCount", user.SignCount, "deduct", deduct)
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)
// 新套(leave-new):独立请假链路(类型详情复核 + 审批流 + 明文 JSON 提交)
if schoolLeaveFlavor(&user, cookieMap) == cpd.LeaveFlavorNew {
sendEvent("submitting", map[string]interface{}{"status": "submitting", "message": "正在提交请假申请..."})
ok, errMsg, needReLogin, needApprover := leaveNewExecuteApply(&user, cookieMap, host, req, imageURLs)
if !ok {
slog.Error("请假申请失败(新套)", "userId", userID, "error", errMsg)
saveLog(false, errMsg)
sendEvent("task_error", map[string]interface{}{
"status": "error",
"message": errMsg,
"needReLogin": needReLogin,
"needApprover": needApprover,
})
return
}
saveLog(true, "")
if !isVip {
database.DB.Model(&model.SysUser{}).Where("id = ? AND sign_count >= ?", userID, deduct).
Update("sign_count", gorm.Expr("sign_count - ?", deduct))
msg := "请假申请提交成功!(已扣 " + fmtDeduct(deduct) + " 次)"
if deduct <= 0 {
msg = "请假申请提交成功!(免扣次数)"
}
sendEvent("success", map[string]interface{}{"status": "success", "message": msg})
} 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
}
// 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 {
err = wrapNeedReLogin(refreshErr)
}
}
if err != nil {
errMsg := "获取请假类型配置失败: " + err.Error()
if needReLoginSignal(err) {
errMsg = "登录凭证已过期,请重新登录"
}
slog.Error("获取请假类型配置失败", "userId", userID, "leaveType", req.LeaveType, "error", err)
saveLog(false, errMsg)
sendEvent("task_error", map[string]interface{}{"status": "error", "message": errMsg, "needReLogin": needReLoginSignal(err)})
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)
}
// 附件复核:类型开关 + 数量上限(学校配置为准,产品上限 4 张)
// 注意:实测多所旧套学校 enableAttach 恒为 0(该字段在旧套不代表"可传附件"),
// 是否允许以 attachmentNumberLimit>0 为准;仅当两者都表明不可用时才拒绝。
if len(imageURLs) > 0 {
if cfg.EnableAttach != 1 && cfg.AttachmentNumberLimit <= 0 {
errMsg := "该请假类型不支持上传附件"
saveLog(false, errMsg)
sendEvent("task_error", map[string]interface{}{"status": "error", "message": errMsg})
return
}
if maxN := cpd.LeaveMaxAttachCount(cfg.AttachmentNumberLimit); len(imageURLs) > maxN {
errMsg := fmt.Sprintf("附件最多 %d 张", maxN)
saveLog(false, errMsg)
sendEvent("task_error", map[string]interface{}{"status": "error", "message": errMsg})
return
}
}
// 2. 可请假检查
if err := cpd.CheckOnceLeave(cookieMap, host); err != nil {
errMsg := "当前不可请假: " + err.Error()
slog.Warn("checkOnceLeave 拒绝", "userId", userID, "error", err)
saveLog(false, errMsg)
sendEvent("task_error", map[string]interface{}{"status": "error", "message": errMsg})
return
}
// 3. 构造表单并提交
sendEvent("submitting", map[string]interface{}{"status": "submitting", "message": "正在提交请假申请..."})
p := cpd.LeaveApplyParams{
LeaveType: req.LeaveType,
StartTime: req.StartTime,
EndTime: req.EndTime,
LeaveReason: req.LeaveReason,
OutStatus: req.OutStatus,
UrgencyMobile: req.UrgencyMobile,
Address: req.Address,
DetailAddress: req.DetailAddress,
Longitude: req.Longitude,
Latitude: req.Latitude,
LocationAddr: req.LocationAddr,
ImageURLs: imageURLs,
ApproverWid: req.ApproverWid,
ApproverId: req.ApproverId,
ApproverName: req.ApproverName,
CcUsers: req.CcUsers,
}
// 续假(旧套):改走 /saveExtend(body = 申请体 + leaveId + extendReason)
submit := func(cm map[string]string) error {
if req.IsExtend {
form := cpd.BuildLeaveExtendForm(p, user.MobileCode, "15", "9.9.6", req.ExtendLeaveID)
return cpd.SubmitLeaveExtend(cm, host, form, req.Longitude, req.Latitude, user.MobileCode, user.StudentID)
}
form := cpd.BuildLeaveApplyForm(p, user.MobileCode, "15", "9.9.6")
return cpd.SubmitLeaveApply(cm, host, form, req.Longitude, req.Latitude, user.MobileCode, user.StudentID)
}
err = submit(cookieMap)
if err != nil && isCookieExpired(err) {
slog.Info("提交请假cookie过期,尝试自动刷新", "userId", userID)
if refreshErr := refreshCpdailyCookie(&user); refreshErr == nil {
cookieMap = cpd.ParseCookieJSON(user.Cookie)
err = submit(cookieMap)
} else {
err = wrapNeedReLogin(refreshErr)
}
}
if err != nil {
errMsg := "请假申请失败: " + err.Error()
if needReLoginSignal(err) {
errMsg = "登录凭证已过期,请重新登录"
}
slog.Error("请假申请失败", "userId", userID, "error", err)
saveLog(false, errMsg)
sendEvent("task_error", map[string]interface{}{"status": "error", "message": errMsg, "needReLogin": needReLoginSignal(err)})
return
}
// 4. 成功:写日志 + 扣减可用次数(VIP 有效期内免扣)
saveLog(true, "")
if !isVip {
database.DB.Model(&model.SysUser{}).Where("id = ? AND sign_count >= ?", userID, deduct).
Update("sign_count", gorm.Expr("sign_count - ?", deduct))
msg := "请假申请提交成功!(已扣 " + fmtDeduct(deduct) + " 次)"
if deduct <= 0 {
msg = "请假申请提交成功!(免扣次数)"
}
sendEvent("success", map[string]interface{}{
"status": "success",
"message": msg,
})
} 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
}
@@ -0,0 +1,142 @@
package handler
import (
"testing"
cpd "cpdaily-new/internal/service/cpdaily"
)
// TestMergeApplyImageURLs 附件合并:多图字段 + 旧前端兼容单图字段,
// 保序去重、剔除空串与首尾空白(避免旧缓存包附件被静默丢弃或重复提交)。
func TestMergeApplyImageURLs(t *testing.T) {
cases := []struct {
name string
urls []string
legacy string
want []string
}{
{
name: "仅多图字段",
urls: []string{"a", "b"},
legacy: "",
want: []string{"a", "b"},
},
{
name: "仅旧单图字段",
urls: nil,
legacy: "a",
want: []string{"a"},
},
{
name: "旧字段与多图重复应去重",
urls: []string{"a", "b"},
legacy: "a",
want: []string{"a", "b"},
},
{
name: "旧字段不在多图中应追加",
urls: []string{"a"},
legacy: "c",
want: []string{"a", "c"},
},
{
name: "空串与空白剔除",
urls: []string{"", " ", "a"},
legacy: " ",
want: []string{"a"},
},
{
name: "首尾空白裁剪",
urls: []string{" a ", " b"},
legacy: "",
want: []string{"a", "b"},
},
{
name: "全部为空",
urls: []string{""},
legacy: "",
want: []string{},
},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
got := mergeApplyImageURLs(c.urls, c.legacy)
if len(got) != len(c.want) {
t.Fatalf("mergeApplyImageURLs(%v, %q) = %v, want %v", c.urls, c.legacy, got, c.want)
}
for i := range got {
if got[i] != c.want[i] {
t.Fatalf("mergeApplyImageURLs(%v, %q) = %v, want %v", c.urls, c.legacy, got, c.want)
}
}
})
}
}
// TestLeaveNewToLeaveTypesAttachGating 新套类型翻译:材料区不可见时不得带出数量上限,
// 否则前端按「上限>0」的条件会误显示上传入口(旧套学校 enableAttach 恒为 0 的同类问题)。
func TestLeaveNewToLeaveTypesAttachGating(t *testing.T) {
types := []cpd.LeaveNewType{
{TypeWid: 1, TypeName: "可见类型"},
{TypeWid: 2, TypeName: "不可见类型"},
}
details := map[int]*cpd.LeaveNewFormDetail{
1: {TypeName: "可见类型", IsVisibledEvidenceFile: true, EvidenceFileCountMax: 4},
2: {TypeName: "不可见类型", IsVisibledEvidenceFile: false, EvidenceFileCountMax: 10},
}
out := leaveNewToLeaveTypes(types, func(wid int) *cpd.LeaveNewFormDetail { return details[wid] })
if len(out) != 2 {
t.Fatalf("期望 2 个类型, got %d", len(out))
}
if out[0].EnableAttach != 1 || out[0].AttachmentNumberLimit != 4 {
t.Errorf("可见类型应带出开关与上限: %+v", out[0])
}
if out[1].EnableAttach != 0 || out[1].AttachmentNumberLimit != 0 {
t.Errorf("不可见类型不应带出上限(否则前端误显示): %+v", out[1])
}
}
// TestLeaveNewRequireSignMapping 手写承诺书需求透传:新套 queryLeaveType.isSign → LeaveType.requireSign
func TestLeaveNewRequireSignMapping(t *testing.T) {
types := []cpd.LeaveNewType{
{TypeWid: 8484, TypeName: "事假", IsSign: true},
{TypeWid: 6185, TypeName: "病假", IsSign: false},
}
out := leaveNewToLeaveTypes(types, func(int) *cpd.LeaveNewFormDetail { return nil })
if out[0].RequireSign != 1 {
t.Errorf("isSign=true 应标记 requireSign=1: %+v", out[0])
}
if out[1].RequireSign != 0 {
t.Errorf("isSign=false 不应标记 requireSign: %+v", out[1])
}
}
// TestBuildLeaveNewApplyFormSignature 手写承诺书作为 attachs 中 isStuSign 项附加
func TestBuildLeaveNewApplyFormSignature(t *testing.T) {
// 无签名时不应出现 isStuSign 项
without := cpd.BuildLeaveNewApplyForm(cpd.LeaveNewApplyParams{TypeWid: 1, ImageURLs: []string{"https://obs/a.jpg"}})
as := without["attachs"].([]map[string]interface{})
if len(as) != 1 {
t.Fatalf("无签名时期望 1 个附件, got %d", len(as))
}
if _, ok := as[0]["isStuSign"]; ok {
t.Errorf("普通附件不应带 isStuSign")
}
// 有签名时附加带 isStuSign 的项
with := cpd.BuildLeaveNewApplyForm(cpd.LeaveNewApplyParams{TypeWid: 1, SignatureURL: "https://obs/newBucket/sign.jpg?x=1"})
as2 := with["attachs"].([]map[string]interface{})
if len(as2) != 1 {
t.Fatalf("有签名时期望 1 个附件, got %d", len(as2))
}
sig := as2[0]
if sig["isStuSign"] != true {
t.Errorf("签名项应带 isStuSign=true: %+v", sig)
}
if sig["attachName"] != "学生手写承诺书" {
t.Errorf("签名项 attachName 应为主学生手写承诺书: %+v", sig)
}
if sig["obsKey"] != "newBucket/sign.jpg" {
t.Errorf("签名项 obsKey 应从 URL 推导: %+v", sig)
}
}
+102
View File
@@ -0,0 +1,102 @@
package handler
import (
"log/slog"
"strings"
cpd "cpdaily-new/internal/service/cpdaily"
"cpdaily-new/internal/utils"
"github.com/gofiber/fiber/v2"
)
// 续假(仅新套 wec-counselor-leave-new-apps)。
// 续假复用请假提交接口 /stu/leave/apply(body 带 reApply/isExtend/leaveId/extendId);
// 执行逻辑并入 ExecuteLeaveApply(见 leaveNewExecuteApply 的续假分支),本文件仅提供「续假预填」查询。
// GetLeaveExtendPrefill 续假预填:返回原假单的类型、原结束时间(续假开始时间)、原因、是否离校等。
// 前端据此预填续假表单(开始时间锁定为原结束时间,用户只改新的结束时间)。
func GetLeaveExtendPrefill(c *fiber.Ctx) error {
user, err := loadLeaveUser(c)
if err != nil {
return utils.FailAuth(c, err.Error())
}
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, "缺少请假单 ID")
}
cookieMap := cpd.ParseCookieJSON(user.Cookie)
host := getSchoolHost(user.SchoolID)
// 学校独立学工系统:续假也不经辅导猫
if rejectExternalLeaveSystem(c, user, cookieMap) {
return nil
}
// 旧套:续假预填取自请假详情(类型 code + 原结束时间作为续假开始时间)
if schoolLeaveFlavor(user, cookieMap) != cpd.LeaveFlavorNew {
d, err := cpd.FetchLeaveDetail(cookieMap, host, req.LeaveID)
if err != nil && isCookieExpired(err) {
if refreshErr := refreshCpdailyCookie(user); refreshErr == nil {
cookieMap = cpd.ParseCookieJSON(user.Cookie)
d, err = cpd.FetchLeaveDetail(cookieMap, host, req.LeaveID)
} else {
err = wrapNeedReLogin(refreshErr)
}
}
if err != nil {
slog.Error("获取续假预填失败(旧套)", "userId", user.ID, "leaveId", req.LeaveID, "error", err)
if needReLoginSignal(err) {
return utils.Fail(c, 401, "登录凭证已过期,请重新登录")
}
return utils.Fail(c, 503, "获取续假信息失败: "+err.Error())
}
return utils.Success(c, fiber.Map{
"leaveId": req.LeaveID,
"typeWid": d.LeaveCodeNum,
"beginTime": d.EndTime, // 续假开始时间 = 原假单结束时间
"endTime": d.EndTime,
"leaveReason": "",
"isLeaveOut": "",
})
}
rd, err := cpd.FetchLeaveNewReplyDetail(cookieMap, host, req.LeaveID)
if err != nil && isCookieExpired(err) {
if refreshErr := refreshCpdailyCookie(user); refreshErr == nil {
cookieMap = cpd.ParseCookieJSON(user.Cookie)
rd, err = cpd.FetchLeaveNewReplyDetail(cookieMap, host, req.LeaveID)
} else {
err = wrapNeedReLogin(refreshErr)
}
}
if err != nil {
slog.Error("获取续假预填失败", "userId", user.ID, "leaveId", req.LeaveID, "error", err)
if needReLoginSignal(err) {
return utils.Fail(c, 401, "登录凭证已过期,请重新登录")
}
return utils.Fail(c, 503, "获取续假信息失败: "+err.Error())
}
// extendId 为空通常意味着该假单当前不可续假(已销假/已结束/未到可续假状态)
if strings.TrimSpace(rd.ExtendID) == "" && strings.TrimSpace(rd.BeginTime) == "" {
return utils.Fail(c, 400, "该请假单当前不可续假")
}
return utils.Success(c, fiber.Map{
"leaveId": rd.LeaveID,
"typeWid": rd.TypeWid,
"beginTime": rd.BeginTime, // 续假开始时间 = 原假单结束时间
"endTime": rd.EndTime,
"leaveReason": rd.LeaveReason,
"isLeaveOut": rd.IsLeaveOut,
})
}
+192
View File
@@ -0,0 +1,192 @@
package handler
import (
"log/slog"
"sync"
"time"
"cpdaily-new/internal/model"
cpd "cpdaily-new/internal/service/cpdaily"
"cpdaily-new/internal/utils"
"github.com/gofiber/fiber/v2"
)
// rejectExternalLeaveSystem 拦截走学校独立学工系统的请假/销假请求。
//
// 背景:部分学校(如山东农业大学 xsgz、云南警官学院 xsfw)的请假由学校学工系统
// 独立提供,辅导猫侧请假应用为 HTTP 420「产品已到期」。这些学校的所有请假相关
// 入口(类型/审批人/待销假/执行/续假)都必须在此短路,否则会掉进辅导猫旧链路,
// 用户看到的只是误导性的 420 到期错误。
//
// 返回 true 表示已写出响应、调用方应立即 return。
func rejectExternalLeaveSystem(c *fiber.Ctx, user *model.SysUser, cookieMap map[string]string) bool {
switch schoolLeaveFlavor(user, cookieMap) {
case cpd.LeaveFlavorXsgz:
_ = utils.Fail(c, 400, leaveExternalSystemHint(user.SchoolName, "学工系统"))
return true
case cpd.LeaveFlavorXsfw:
_ = utils.Fail(c, 400, leaveExternalSystemHint(user.SchoolName, "学工平台"))
return true
}
return false
}
// leaveExternalSystemHint 生成「该校请假走独立学工系统」的提示文案。
// 这类学校的辅导猫请假应用为 HTTP 420「产品已到期」,与我们无关;
// 直连辅导猫只会得到误导性的到期错误,故在此明确告知用户实际入口。
func leaveExternalSystemHint(schoolName, systemName string) string {
if schoolName == "" {
schoolName = "贵校"
}
return schoolName + " 的请假由学校「" + systemName + "」独立提供,不支持在今日校园内提交,请前往学校学工系统办理"
}
// 请假应用套件分流(新旧两套 API 并存):
//
// 新套 wec-counselor-leave-new-apps(「请销假」应用,部分学校已迁移,旧前缀被 420 拦截)
// 旧套 wec-counselor-leave-apps(「请假」应用,默认)
//
// 分流依据:辅导猫 getAppInfos 返回的本校应用列表(openUrl 指向哪套);
// 探测失败时试探新套端点兜底;结果按学校缓存 30 分钟。
type leaveFlavorCacheEntry struct {
flavor cpd.LeaveAppFlavor
expire time.Time
}
var leaveFlavorCache sync.Map // schoolID -> leaveFlavorCacheEntry
const leaveFlavorCacheTTL = 30 * time.Minute
// schoolLeaveFlavor 获取(并缓存)学校请假应用套件类型
func schoolLeaveFlavor(user *model.SysUser, cookieMap map[string]string) cpd.LeaveAppFlavor {
// 按学校 ID 单独适配的独立学工系统优先判断(不计入缓存:
// 判定依据是静态映射,无探测成本,也避免多套系统间缓存串味)
// xsgz:三易拓「新学工5」(如山东农业大学 xsgz.sdau.edu.cn)
// xsfw:金智 xsfw 学工平台(如云南警官学院 xg.ynpc.edu.cn/xsfwdt)
if cpd.IsXsgzSchool(user.SchoolID) {
return cpd.LeaveFlavorXsgz
}
if cpd.IsXsfwSchool(user.SchoolID) {
return cpd.LeaveFlavorXsfw
}
if v, ok := leaveFlavorCache.Load(user.SchoolID); ok {
if e, ok2 := v.(leaveFlavorCacheEntry); ok2 && time.Now().Before(e.expire) {
return e.flavor
}
}
host := getSchoolHost(user.SchoolID)
flavor, openURL := cpd.FetchLeaveAppFlavor(cookieMap, host)
if flavor == cpd.LeaveFlavorUnknown {
// getAppInfos 失败/无请假应用:试探新套端点兜底
if cpd.PingLeaveNew(cookieMap, host) {
flavor = cpd.LeaveFlavorNew
} else {
// 默认按旧套处理(cookie 过期等场景下旧套错误的语义更准确)
flavor = cpd.LeaveFlavorOld
}
}
slog.Info("请假应用套件分流", "schoolId", user.SchoolID, "flavor", flavor.String(), "openUrl", openURL)
leaveFlavorCache.Store(user.SchoolID, leaveFlavorCacheEntry{flavor: flavor, expire: time.Now().Add(leaveFlavorCacheTTL)})
return flavor
}
// invalidateLeaveFlavor 清缓存(应用迁移后可强制重探)
func invalidateLeaveFlavor(schoolID string) {
leaveFlavorCache.Delete(schoolID)
}
// ---------- 新套 → 现有前端契约的翻译 ----------
// leaveNewToLeaveTypes 新套类型列表 → 旧契约 LeaveType(前端零改动消费)
func leaveNewToLeaveTypes(list []cpd.LeaveNewType, detailOf func(typeWid int) *cpd.LeaveNewFormDetail) []cpd.LeaveType {
out := make([]cpd.LeaveType, 0, len(list))
for _, t := range list {
item := cpd.LeaveType{
Code: t.TypeWid,
Name: t.TypeName,
}
// 需先手写承诺书签名后提交(新套 queryLeaveType.isSign)
if t.IsSign {
item.RequireSign = 1
}
if d := detailOf(t.TypeWid); d != nil {
if d.TypeName != "" {
item.Name = d.TypeName
}
// 注意:新套 extendInfoBean.isLeaveOut(0/1/2)只是表单「是否离校」的初始默认值,
// 不是强制开关,故不映射为 MustOut(旧套 mustOut 才是"必须离校"语义)。
// 透传为 OutStatusDefault,前端据此设置开关初始状态(1=默认是,2 或 0=默认否)。
item.OutStatusDefault = d.IsLeaveOut
// 是否需要审批人由 apply-approvers 实际候选判断;
// 类型列表阶段以审批流存在性做提示(有审批流则显示审批人区)
item.NeedApproval = 1
if d.IsVisibledEvidenceFile {
item.EnableAttach = 1
item.AttachmentNumberLimit = d.EvidenceFileCountMax
} else {
// 材料区不可见时不得带出上限,否则前端按「上限>0」会误显示上传入口
item.AttachmentNumberLimit = 0
}
// 证明材料是否必填(isRequiredEvidenceFile):类型列表接口不返回该开关,
// 只在详情里;此前未透传,导致必填附件被漏检、提交被服务端拒绝。
if d.IsRequiredEvidence {
item.RequireEvidence = 1
}
item.EvidenceMin = d.EvidenceFileCountMini
// 自定义附加字段(selfDefineFields):必填项需前端采集、提交时回填 selfDefineAnswers
if raw, ok := d.Raw["selfDefineFields"]; ok && raw != nil {
if arr, ok := raw.([]interface{}); ok {
fields := make([]map[string]interface{}, 0, len(arr))
for _, it := range arr {
if m, ok := it.(map[string]interface{}); ok {
fields = append(fields, m)
}
}
item.SelfDefineFields = fields
}
}
item.ReasonWordLimit = d.LeaveReasonWordsMini
item.DisableTime = 0
item.LimitDay = 0
} else {
// 详情获取失败:保守默认(不阻塞列表)
item.NeedApproval = 1
item.EnableAttach = 1
}
out = append(out, item)
}
return out
}
// leaveNewPlacesToLeaveReportPlaces 新套销假位置 → 旧契约 LeaveReportPlace
func leaveNewPlacesToLeaveReportPlaces(list []cpd.LeaveNewReportPlace) []cpd.LeaveReportPlace {
out := make([]cpd.LeaveReportPlace, 0, len(list))
for _, p := range list {
out = append(out, cpd.LeaveReportPlace{
Wid: p.Wid,
Address: p.Address,
Longitude: p.Longitude,
Latitude: p.Latitude,
Radius: p.Radius,
CounName: p.CounName,
})
}
return out
}
// leaveNewApproversToLeaveApprovers 新套审批人 → 旧契约 LeaveApprover(前端展示用)
func leaveNewApproversToLeaveApprovers(list []cpd.LeaveNewApprover) []cpd.LeaveApprover {
out := make([]cpd.LeaveApprover, 0, len(list))
for _, a := range list {
out = append(out, cpd.LeaveApprover{
UserWid: a.UserWid,
UserId: a.UserId,
UserName: a.UserName,
})
}
return out
}
@@ -0,0 +1,54 @@
package handler
import (
"strings"
"testing"
cpd "cpdaily-new/internal/service/cpdaily"
)
// 走独立学工系统的学校必须被守卫拦截,且提示文案要说明真实入口,
// 而不是让用户看到辅导猫的 420「产品已到期」。
func TestRejectExternalLeaveSystemFlavorMapping(t *testing.T) {
cases := []struct {
name string
schoolID string
schoolName string
wantReject bool
wantSubstr string
}{
{"山东农业大学走新学工5", "sdau", "山东农业大学", true, "学工"},
{"云南警官学院走 xsfw 学工", "2970d544-2917-44e6-85db-f3245d6e3b1d", "云南警官学院", true, "学工"},
{"普通学校不拦截", "ynu", "云南大学", false, ""},
{"空学校不拦截", "", "", false, ""},
}
// 用静态映射判定,避免触发辅导猫网络探测(普通学校会走 getAppInfos)
expectExternal := func(schoolID string) bool {
return cpd.IsXsgzSchool(schoolID) || cpd.IsXsfwSchool(schoolID)
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
gotReject := expectExternal(c.schoolID)
if gotReject != c.wantReject {
t.Fatalf("schoolID=%q wantReject=%v gotReject=%v", c.schoolID, c.wantReject, gotReject)
}
if c.wantSubstr != "" {
hint := leaveExternalSystemHint(c.schoolName, "学工系统")
if !strings.Contains(hint, c.wantSubstr) {
t.Fatalf("提示文案缺少 %q: %s", c.wantSubstr, hint)
}
if !strings.Contains(hint, c.schoolName) {
t.Fatalf("提示文案应包含学校名: %s", hint)
}
}
})
}
}
// 提示文案在缺学校名时也要可读。
func TestLeaveExternalSystemHintEmptySchool(t *testing.T) {
got := leaveExternalSystemHint("", "学工系统")
if !strings.Contains(got, "贵校") {
t.Fatalf("空学校名应回退为「贵校」: %s", got)
}
}
+311
View File
@@ -0,0 +1,311 @@
package handler
import (
"fmt"
"log/slog"
"strings"
"cpdaily-new/internal/model"
cpd "cpdaily-new/internal/service/cpdaily"
)
// ---------- 新套执行链路(请假/销假) ----------
// leaveNewResolveApproverAndFlow 服务端自取审批流:按 userWid 匹配完整审批人对象 + flowData。
// 返回 (approver, flowData, candidates, err):
// - approver 为 nil 表示未提供 userWid 或未匹配(调用方决定是否放行)
// - candidates 为全部候选(用于「无候选可放行」判定)
func leaveNewResolveApproverAndFlow(cookieMap map[string]string, host string, typeWid int, startTime, endTime, approverWid string) (*cpd.LeaveNewApprover, map[string]interface{}, []cpd.LeaveNewApprover, error) {
flow, err := cpd.FetchLeaveNewApproveFlow(cookieMap, host, typeWid, startTime, endTime, "")
if err != nil {
return nil, nil, nil, err
}
if approverWid == "" {
return nil, flow.FlowData, flow.ApproveUsers, nil
}
for i := range flow.ApproveUsers {
if flow.ApproveUsers[i].UserWid == approverWid {
return &flow.ApproveUsers[i], flow.FlowData, flow.ApproveUsers, nil
}
}
return nil, flow.FlowData, flow.ApproveUsers, nil
}
// leaveNewResolveCcUsers 抄送人 userWid 列表 → 完整对象(新套 getCcSearchList 全量匹配)
func leaveNewResolveCcUsers(cookieMap map[string]string, host string, widList []string) []cpd.LeaveNewApprover {
if len(widList) == 0 {
return nil
}
all, err := cpd.FetchLeaveNewCcSearch(cookieMap, host, "")
if err != nil {
slog.Warn("获取抄送人候选失败,抄送人将被忽略", "error", err)
return nil
}
want := make(map[string]bool, len(widList))
for _, w := range widList {
if w != "" {
want[w] = true
}
}
out := make([]cpd.LeaveNewApprover, 0, len(widList))
seen := make(map[string]bool, len(widList))
for _, a := range all {
if want[a.UserWid] && !seen[a.UserWid] {
seen[a.UserWid] = true
out = append(out, a)
}
}
return out
}
// leaveNewTypeIsSign 该类型是否需手写承诺书(queryLeaveType.isSign;查询失败按 false 容错)
func leaveNewTypeIsSign(cookieMap map[string]string, host string, typeWid int) bool {
types, err := cpd.FetchLeaveNewTypes(cookieMap, host)
if err != nil {
slog.Warn("获取请假类型列表失败,跳过手写承诺书校验", "typeWid", typeWid, "error", err)
return false
}
for _, t := range types {
if t.TypeWid == typeWid {
return t.IsSign
}
}
return false
}
// leaveNewOutStatusValue 用户「是否离校」布尔 → 学校端 isLeaveOut 枚举值(1=是,2=否)。
// 学校端仅接受 1/2,切勿用 0(0 非合法枚举,会被判为「否」或异常)。
func leaveNewOutStatusValue(outStatus bool) int {
if outStatus {
return 1
}
return 2
}
// leaveNewValidateApplyForm 新套请假表单的服务端复核(标题/原因字数/证明材料)
func leaveNewValidateApplyForm(d *cpd.LeaveNewFormDetail, req ExecuteLeaveApplyRequest, imageURLs []string) string {
if d == nil {
return ""
}
if d.IsRequiredLeaveReason && strings.TrimSpace(req.LeaveReason) == "" {
return "请填写请假原因"
}
if d.LeaveReasonWordsMini > 0 && len([]rune(req.LeaveReason)) < d.LeaveReasonWordsMini {
return fmt.Sprintf("请假原因不能少于 %d 字", d.LeaveReasonWordsMini)
}
// 证明材料(附件):多图复核 —— 可见性开关 / 必填 / 张数下限 / 学校上限(产品上限 4)
if len(imageURLs) > 0 {
if !d.IsVisibledEvidenceFile {
return "该请假类型不支持上传附件"
}
if maxN := cpd.LeaveMaxAttachCount(d.EvidenceFileCountMax); len(imageURLs) > maxN {
return fmt.Sprintf("附件最多 %d 张", maxN)
}
}
if d.IsRequiredEvidence {
if len(imageURLs) == 0 {
return "请上传证明材料"
}
if d.EvidenceFileCountMini > 0 && len(imageURLs) < d.EvidenceFileCountMini {
return fmt.Sprintf("证明材料至少 %d 张", d.EvidenceFileCountMini)
}
}
return ""
}
// ---------- 新套销假执行 ----------
// leaveNewExecuteReport 新套销假执行(供 handler SSE 调用)。
// 返回 (是否成功, 用户可见错误信息, 需要重新登录)
func leaveNewExecuteReport(user *model.SysUser, cookieMap map[string]string, host string,
leaveID, address, longitude, latitude, imageURL, approverWid string) (bool, string, bool) {
// 1. 详情 + 状态复核(是否处于可销假状态)
detail, err := cpd.FetchLeaveNewDetail(cookieMap, host, leaveID)
if err != nil {
if isCookieExpired(err) {
if refreshErr := refreshCpdailyCookie(user); refreshErr == nil {
cookieMap = cpd.ParseCookieJSON(user.Cookie)
detail, err = cpd.FetchLeaveNewDetail(cookieMap, host, leaveID)
} else {
return false, "登录凭证已过期,请重新登录", true
}
}
if err != nil {
return false, "获取请假详情失败: " + err.Error(), false
}
}
if !detail.CanReport {
return false, fmt.Sprintf("当前请假单不可销假(状态:%s)", detail.StatusText), false
}
// 2. 确认人(reportMode=2 老师审批销假时需要)
var approver *cpd.LeaveNewApprover
if detail.ReportMode == "2" && approverWid != "" {
list, err := cpd.FetchLeaveNewReportApprovers(cookieMap, host, leaveID)
if err == nil {
for i := range list {
if list[i].UserWid == approverWid {
approver = &list[i]
break
}
}
}
}
// 3. 构造并提交
form := cpd.BuildLeaveNewReportForm(cpd.LeaveNewReportParams{
LeaveID: leaveID,
Address: address,
Longitude: longitude,
Latitude: latitude,
ImageURL: imageURL,
Approver: approver,
UserID: user.StudentID,
DeviceID: user.MobileCode,
Model: "cpdaily",
})
err = cpd.SubmitLeaveNewReport(cookieMap, host, form)
if err != nil && isCookieExpired(err) {
if refreshErr := refreshCpdailyCookie(user); refreshErr == nil {
cookieMap = cpd.ParseCookieJSON(user.Cookie)
err = cpd.SubmitLeaveNewReport(cookieMap, host, form)
} else {
return false, "登录凭证已过期,请重新登录", true
}
}
if err != nil {
return false, "销假失败: " + err.Error(), false
}
return true, "", false
}
// ---------- 新套请假执行 ----------
// leaveNewExecuteApply 新套请假执行(供 handler SSE 调用)。
// 返回 (是否成功, 用户可见错误信息, 需要重新登录, 需要选择审批人)
func leaveNewExecuteApply(user *model.SysUser, cookieMap map[string]string, host string, req ExecuteLeaveApplyRequest, imageURLs []string) (bool, string, bool, bool) {
// 1. 类型详情复核
detail, err := cpd.FetchLeaveNewTypeDetail(cookieMap, host, req.LeaveType)
if err != nil {
if isCookieExpired(err) {
if refreshErr := refreshCpdailyCookie(user); refreshErr == nil {
cookieMap = cpd.ParseCookieJSON(user.Cookie)
detail, err = cpd.FetchLeaveNewTypeDetail(cookieMap, host, req.LeaveType)
} else {
return false, "登录凭证已过期,请重新登录", true, false
}
}
if err != nil {
return false, "获取请假类型配置失败: " + err.Error(), false, false
}
}
if msg := leaveNewValidateApplyForm(detail, req, imageURLs); msg != "" {
return false, msg, false, false
}
// 手写承诺书:isSign=1 的类型必须携带签名图。
// isSign 只出现在 queryLeaveType(详情接口不返回),故单独取一次类型列表匹配。
if leaveNewTypeIsSign(cookieMap, host, req.LeaveType) && strings.TrimSpace(req.SignatureURL) == "" {
return false, "请先手写承诺书签名", false, false
}
// 2. 审批流:取 flowData + 匹配完整审批人
approver, flowData, candidates, err := leaveNewResolveApproverAndFlow(cookieMap, host, req.LeaveType, req.StartTime, req.EndTime, req.ApproverWid)
if err != nil {
if isCookieExpired(err) {
if refreshErr := refreshCpdailyCookie(user); refreshErr == nil {
cookieMap = cpd.ParseCookieJSON(user.Cookie)
approver, flowData, candidates, err = leaveNewResolveApproverAndFlow(cookieMap, host, req.LeaveType, req.StartTime, req.EndTime, req.ApproverWid)
} else {
return false, "登录凭证已过期,请重新登录", true, false
}
}
if err != nil {
return false, "获取请假审批人失败: " + err.Error(), false, false
}
}
if req.ApproverWid == "" && len(candidates) > 0 {
return false, "该请假类型需要选择审批人", false, true
}
// 3. 抄送人解析(非必填)
ccWids := make([]string, 0, len(req.CcUsers))
for _, cc := range req.CcUsers {
ccWids = append(ccWids, cc.UserWid)
}
ccUsers := leaveNewResolveCcUsers(cookieMap, host, ccWids)
// 4. 离校语义:学校端 isLeaveOut 为枚举 1=是 / 2=否,以用户选择为准。
// 注意:类型配置 extendInfoBean.isLeaveOut(0=无默认/1=默认是/2=默认否)仅是表单初始默认值,
// 不表示"强制";此前把它当作"强制离校/强制不离校",导致默认值为 0 的类型会把用户选择的
// "是"覆盖成 0,学校端因 0 非合法枚举而显示为"否"。
isLeaveOut := leaveNewOutStatusValue(req.OutStatus)
// 续假:取原假单的 extendId(queryReplyLeaveDetail),审批流/类型沿用原假单
var extendID string
if req.IsExtend {
rd, rerr := cpd.FetchLeaveNewReplyDetail(cookieMap, host, req.ExtendLeaveID)
if rerr != nil && isCookieExpired(rerr) {
if refreshErr := refreshCpdailyCookie(user); refreshErr == nil {
cookieMap = cpd.ParseCookieJSON(user.Cookie)
rd, rerr = cpd.FetchLeaveNewReplyDetail(cookieMap, host, req.ExtendLeaveID)
} else {
return false, "登录凭证已过期,请重新登录", true, false
}
}
if rerr != nil {
return false, "获取续假信息失败: " + rerr.Error(), false, false
}
extendID = rd.ExtendID
}
form := cpd.BuildLeaveNewApplyForm(cpd.LeaveNewApplyParams{
TypeWid: req.LeaveType,
LeaveID: req.ExtendLeaveID,
IsExtend: req.IsExtend,
ExtendID: extendID,
ReApply: req.IsExtend,
BeginTime: trimSeconds(req.StartTime),
EndTime: trimSeconds(req.EndTime),
LeaveReason: req.LeaveReason,
ImageURLs: imageURLs,
SignatureURL: req.SignatureURL,
LocationAddr: req.LocationAddr,
Longitude: req.Longitude,
Latitude: req.Latitude,
IsLeaveOut: isLeaveOut,
Destination: req.Address,
Detail: req.DetailAddress,
EmergencyName: req.UrgencyMobile,
EmergencyMobile: req.UrgencyMobile,
Approver: approver,
CcUsers: ccUsers,
FlowData: flowData,
UserID: user.StudentID,
DeviceID: user.MobileCode,
Model: "cpdaily",
SelfDefineAnswers: req.SelfDefineAnswers,
})
err = cpd.SubmitLeaveNewApply(cookieMap, host, form)
if err != nil && isCookieExpired(err) {
if refreshErr := refreshCpdailyCookie(user); refreshErr == nil {
cookieMap = cpd.ParseCookieJSON(user.Cookie)
err = cpd.SubmitLeaveNewApply(cookieMap, host, form)
} else {
return false, "登录凭证已过期,请重新登录", true, false
}
}
if err != nil {
return false, "请假申请失败: " + err.Error(), false, false
}
return true, "", false, false
}
// trimSeconds "yyyy-MM-dd HH:mm:ss" → "yyyy-MM-dd HH:mm"(新套时间格式)
func trimSeconds(t string) string {
t = strings.TrimSpace(t)
if len(t) == 19 && t[16] == ':' && t[13] == ':' {
return t[:16]
}
return t
}
@@ -0,0 +1,14 @@
package handler
import "testing"
// TestLeaveNewOutStatusValue 用户「是否离校」布尔 → 学校端 isLeaveOut 枚举(1=是 / 2=否)。
// 关键回归:选「是」必须得到 1(此前被类型默认值覆盖成 0,学校端显示为「否」)。
func TestLeaveNewOutStatusValue(t *testing.T) {
if got := leaveNewOutStatusValue(true); got != 1 {
t.Errorf("选择离校应得到 1,got %d", got)
}
if got := leaveNewOutStatusValue(false); got != 2 {
t.Errorf("选择不离校应得到 2(不可为 0),got %d", got)
}
}
@@ -0,0 +1,230 @@
package handler
import (
"fmt"
"log/slog"
"cpdaily-new/internal/database"
"cpdaily-new/internal/model"
cpd "cpdaily-new/internal/service/cpdaily"
"cpdaily-new/internal/utils"
"github.com/gofiber/fiber/v2"
)
// ---------- 新套(wec-counselor-leave-new-apps)handler 适配 ----------
//
// 设计:新套数据在 handler 层翻译为现有前端契约(LeaveType/LeaveItem/LeaveApprover),
// 前端无需感知新旧差异;老套逻辑保持原样,仅按 flavor 分流。
// refreshAfterCookieExpired 通用「cookie 过期→刷新→重试一次」包装
func refreshAfterCookieExpired(user *model.SysUser, err error, retry func() error) error {
if err == nil || !isCookieExpired(err) {
return err
}
slog.Info("cookie过期,尝试刷新", "userId", user.ID)
if refreshErr := refreshCpdailyCookie(user); refreshErr != nil {
// 续期失败:无论原因(密码错/无密码无法自动重登等)都升级为「需要重新登录」
return wrapNeedReLogin(refreshErr)
}
return retry()
}
// ---------- 销假:待销假列表(新套) ----------
// fetchLeaveNewPendingList 新套待销假列表:拉全部记录 → 过滤可销假 → 补位置与开关
func fetchLeaveNewPendingList(cookieMap map[string]string, host string, user *model.SysUser) ([]cpd.LeaveItem, error) {
records, err := cpd.FetchLeaveNewList(cookieMap, host, 1, 50, "1")
if err != nil {
return nil, err
}
// 规定销假位置全局一份(reportList 与记录无关)
places := []cpd.LeaveReportPlace{}
if p, err := cpd.FetchLeaveNewReportPlaces(cookieMap, host); err == nil {
places = leaveNewPlacesToLeaveReportPlaces(p)
} else {
slog.Warn("获取新套规定销假位置失败", "userId", user.ID, "error", err)
}
list := make([]cpd.LeaveItem, 0, len(records))
for _, r := range records {
// 列表接口不返回 reportMode/reportUploadMode/isLeaveOut,需补拉详情判断;
// 仅对可能可销假的状态(3 休假中 / 4 销假逾期)补详情,减少请求
if r.Status != "3" && r.Status != "4" {
continue
}
detail, derr := cpd.FetchLeaveNewDetail(cookieMap, host, r.LeaveID)
if derr != nil {
slog.Warn("获取新套请假详情失败,跳过该记录", "leaveId", r.LeaveID, "error", derr)
continue
}
// 可销假 或 可续假(isAllowExtend)其一满足即展示
if !detail.CanReport && !detail.IsAllowExtend {
continue
}
// 销假需要确认人:reportMode=2(老师审批销假)
needApprover := detail.ReportMode == "2"
enableAttach := detail.ReportUploadMode == "2" || (detail.ReportUploadMode == "1" && detail.IsLeaveOut == "1")
list = append(list, cpd.LeaveItem{
ID: detail.LeaveID,
LeaveType: leaveNewTypeNameOfTopic(detail.TopicName, detail.TypeWid),
Reason: detail.LeaveReason,
CreateTime: r.ApplyTime,
ActEndTime: detail.EndTime,
Status: detail.Status,
StatusText: detail.StatusText,
ReportPlaces: places,
ReportApplyFlow: needApprover,
EnableAttach: enableAttach,
CanReport: detail.CanReport,
CanExtend: detail.IsAllowExtend,
TypeWid: detail.TypeWid,
IsLeaveOut: detail.IsLeaveOut,
})
}
return list, nil
}
// leaveNewTypeNameOfTopic 从 topicName(如「李闯闯的事假」)提取类型名兜底
func leaveNewTypeNameOfTopic(topicName, typeIcon string) string {
if topicName == "" {
return "请假"
}
return topicName
}
// ---------- 请假类型(新套) ----------
// fetchLeaveNewTypesWithDetail 新套类型列表,逐类型补详情(权威开关)
func fetchLeaveNewTypesWithDetail(cookieMap map[string]string, host string, user *model.SysUser) ([]cpd.LeaveType, error) {
types, err := cpd.FetchLeaveNewTypes(cookieMap, host)
if err != nil {
return nil, err
}
details := make(map[int]*cpd.LeaveNewFormDetail, len(types))
for _, t := range types {
d, err := cpd.FetchLeaveNewTypeDetail(cookieMap, host, t.TypeWid)
if err != nil {
slog.Warn("获取新套类型详情失败,使用基础信息", "typeWid", t.TypeWid, "error", err)
continue
}
details[t.TypeWid] = d
}
return leaveNewToLeaveTypes(types, func(wid int) *cpd.LeaveNewFormDetail {
return details[wid]
}), nil
}
// ---------- 销假审批人(新套) ----------
// fetchLeaveNewReportApprovers 新套销假确认人(getReportApprovers by leaveId)
func fetchLeaveNewReportApprovers(cookieMap map[string]string, host, leaveID string, user *model.SysUser) ([]cpd.LeaveApprover, error) {
list, err := cpd.FetchLeaveNewReportApprovers(cookieMap, host, leaveID)
if err != nil {
return nil, err
}
return leaveNewApproversToLeaveApprovers(list), nil
}
// ---------- 请假审批人(新套) ----------
// leaveNewApplyApproversResult 新套请假审批人结果(含 flowData 透传)
type leaveNewApplyApproversResult struct {
Approvers []cpd.LeaveApprover `json:"approvers"`
NeedApproval bool `json:"needApproval"`
InFlowScope bool `json:"inFlowScope"`
FlowData map[string]interface{} `json:"flowData"`
}
// fetchLeaveNewApplyApprovers 新套请假审批人 + 审批流数据
func fetchLeaveNewApplyApprovers(cookieMap map[string]string, host string, typeWid int, startTime, endTime string) (*leaveNewApplyApproversResult, error) {
flow, err := cpd.FetchLeaveNewApproveFlow(cookieMap, host, typeWid, startTime, endTime, "")
if err != nil {
return nil, err
}
return &leaveNewApplyApproversResult{
Approvers: leaveNewApproversToLeaveApprovers(flow.ApproveUsers),
NeedApproval: flow.IsNeedApproval,
InFlowScope: flow.IsInFlowScope,
FlowData: flow.FlowData,
}, nil
}
// GetLeaveNewCcSearch 抄送人搜索(新套;searchKey 为空返回全部)
func GetLeaveNewCcSearch(c *fiber.Ctx) error {
user, err := loadLeaveUser(c)
if err != nil {
return utils.FailAuth(c, err.Error())
}
var req struct {
SearchKey string `json:"searchKey"`
}
if err := c.BodyParser(&req); err != nil {
return utils.FailParam(c, "请求参数错误")
}
cookieMap := cpd.ParseCookieJSON(user.Cookie)
host := getSchoolHost(user.SchoolID)
list, err := cpd.FetchLeaveNewCcSearch(cookieMap, host, req.SearchKey)
if err != nil {
_ = refreshAfterCookieExpired(user, err, func() error {
cookieMap = cpd.ParseCookieJSON(user.Cookie)
var e error
list, e = cpd.FetchLeaveNewCcSearch(cookieMap, host, req.SearchKey)
return e
})
}
if err != nil {
slog.Error("获取抄送人失败", "userId", user.ID, "error", err)
if needReLoginSignal(err) {
return utils.Fail(c, 401, "登录凭证已过期,请重新登录")
}
return utils.Fail(c, 503, "获取抄送人失败: "+err.Error())
}
return utils.Success(c, leaveNewApproversToLeaveApprovers(list))
}
// buildLeaveNewApproverByWid 从审批人列表找 userWid 对应完整对象(提交表单用)
func buildLeaveNewApproverByWid(list []cpd.LeaveNewApprover, userWid string) *cpd.LeaveNewApprover {
if userWid == "" {
return nil
}
for i := range list {
if list[i].UserWid == userWid {
return &list[i]
}
}
return nil
}
// leaveNewCheckCookieFresh 简单的 cookie 新鲜度检查(新套首次调用失败即触发刷新重试)
func leaveNewRetryOnExpired(user *model.SysUser, err error, retry func() error) error {
return refreshAfterCookieExpired(user, err, retry)
}
// leaveNewFriendlyErr 新套错误统一包装(带 401 语义)
func leaveNewFriendlyErr(c *fiber.Ctx, action string, err error) error {
if needReLoginSignal(err) {
return utils.Fail(c, 401, "登录凭证已过期,请重新登录")
}
return utils.Fail(c, 503, fmt.Sprintf("%s: %v", action, err))
}
// ---------- 数据库日志辅助 ----------
// saveLeaveLogEntry 请假/销假日志落库(两套共用)
func saveLeaveLogEntry(userID uint, leaveID, title, schoolName, action string, isSuccess bool, errMsg string) {
database.DB.Create(&model.CpLeaveLog{
UserID: userID,
LeaveID: leaveID,
Title: title,
SchoolName: schoolName,
Action: action,
IsSuccess: isSuccess,
ErrorMessage: errMsg,
})
}
+75 -4
View File
@@ -6,10 +6,36 @@ import (
"cpdaily-new/internal/service"
"cpdaily-new/internal/utils"
"log/slog"
"net/url"
"strconv"
"strings"
"github.com/gofiber/fiber/v2"
)
// isValidHttpUrl 校验字符串是否为合法的 http(s) 链接(空串合法 = 清空场景)
func isValidHttpUrl(s string) bool {
if s == "" {
return true
}
u, err := url.Parse(s)
if err != nil || u.Host == "" {
return false
}
return u.Scheme == "http" || u.Scheme == "https"
}
// normalizeBoolSetting 将布尔设置值归一化为 "true"/"false"(支持 1/0、on/off 等常见写法)
func normalizeBoolSetting(s string) (string, bool) {
switch strings.ToLower(strings.TrimSpace(s)) {
case "true", "1", "on", "yes":
return "true", true
case "false", "0", "off", "no", "":
return "false", true
}
return "", false
}
// GetSettings 获取所有系统设置
func GetSettings(c *fiber.Ctx) error {
var configs []model.SystemConfig
@@ -31,15 +57,28 @@ func GetSettings(c *fiber.Ctx) error {
if _, ok := settings["invitee_reward_per_invite"]; !ok {
settings["invitee_reward_per_invite"] = "0.50"
}
if _, ok := settings["register_reward"]; !ok {
settings["register_reward"] = "1.00"
}
if _, ok := settings["vip_price_week"]; !ok {
settings["vip_price_week"] = "30.00"
}
if _, ok := settings["vip_price_month"]; !ok {
settings["vip_price_month"] = "100.00"
}
if _, ok := settings[settingDeductCheckin]; !ok {
settings[settingDeductCheckin] = "1.00"
}
if _, ok := settings[settingDeductLeave]; !ok {
settings[settingDeductLeave] = "1.00"
}
if _, ok := settings[settingDeductReport]; !ok {
settings[settingDeductReport] = "1.00"
}
// 「购买 CDK」入口(个人中心显眼按钮):链接为空 + 默认关闭
if _, ok := settings["cdk_buy_url"]; !ok {
settings["cdk_buy_url"] = ""
}
if _, ok := settings["cdk_buy_visible"]; !ok {
settings["cdk_buy_visible"] = "false"
}
return utils.Success(c, settings)
}
@@ -65,9 +104,12 @@ func UpdateSetting(c *fiber.Ctx) error {
"base_deduction_per_run": true,
"inviter_reward_per_invite": true,
"invitee_reward_per_invite": true,
"register_reward": true,
"vip_price_week": true,
"vip_price_month": true,
// 扣款额度(打卡/请假/销假,每次成功执行扣减的可签到次数,允许小数与 0)
settingDeductCheckin: true,
settingDeductLeave: true,
settingDeductReport: true,
// 邮件通知(SMTP)
"smtp_host": true,
"smtp_port": true,
@@ -77,11 +119,40 @@ func UpdateSetting(c *fiber.Ctx) error {
"smtp_from_name": true,
"smtp_secure": true,
"admin_emails": true,
// 「购买 CDK」入口(个人中心显眼按钮的跳转链接与显隐)
"cdk_buy_url": true,
"cdk_buy_visible": true,
}
if !allowedKeys[req.Key] {
return utils.Fail(c, 400, "不支持的设置项: "+req.Key)
}
// 扣款额度:必须为非负数字(允许 0 = 不扣费)
if req.Key == settingDeductCheckin || req.Key == settingDeductLeave || req.Key == settingDeductReport {
v, err := strconv.ParseFloat(req.Value, 64)
if err != nil || v < 0 {
return utils.FailParam(c, "扣款额度必须为不小于 0 的数字")
}
}
// 「购买 CDK」链接:允许清空;非空时必须是 http(s) 链接(前端将以新标签页打开)
if req.Key == "cdk_buy_url" {
url := strings.TrimSpace(req.Value)
if !isValidHttpUrl(url) {
return utils.FailParam(c, "购买链接必须以 http:// 或 https:// 开头")
}
req.Value = url
}
// 「购买 CDK」开关:归一化为 true / false 存储
if req.Key == "cdk_buy_visible" {
v, ok := normalizeBoolSetting(req.Value)
if !ok {
return utils.FailParam(c, "展示开关仅支持 true / false")
}
req.Value = v
}
var cfg model.SystemConfig
result := database.DB.Where("`key` = ?", req.Key).First(&cfg)
if result.Error != nil {
@@ -0,0 +1,60 @@
package handler
import "testing"
// TestIsValidHttpUrl 「购买 CDK」链接校验:仅允许空串(清空)与合法 http(s) 链接。
func TestIsValidHttpUrl(t *testing.T) {
cases := []struct {
name string
value string
want bool
}{
{"空串=清空场景合法", "", true},
{"https 链接", "https://shop.example.com/cdk", true},
{"http 链接", "http://shop.example.com/cdk?from=profile", true},
{"带路径与参数", "https://example.com/a/b?x=1&y=2#frag", true},
{"端口号", "https://example.com:8443/cdk", true},
{"纯域名无协议", "shop.example.com/cdk", false},
{"javascript 协议", "javascript:alert(1)", false},
{"data 协议", "data:text/html;base64,xxxx", false},
{"file 协议", "file:///etc/passwd", false},
{"只有协议无主机", "https://", false},
{"中文字符串", "不是一个链接", false},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
if got := isValidHttpUrl(c.value); got != c.want {
t.Fatalf("isValidHttpUrl(%q) = %v, want %v", c.value, got, c.want)
}
})
}
}
// TestNormalizeBoolSetting 布尔设置归一化:常见写法统一为 true/false,其余报错。
func TestNormalizeBoolSetting(t *testing.T) {
cases := []struct {
name string
value string
want string
ok bool
}{
{"true", "true", "true", true},
{"TRUE 大写", "TRUE", "true", true},
{"1", "1", "true", true},
{"on", "on", "true", true},
{"带空白", " true ", "true", true},
{"false", "false", "false", true},
{"0", "0", "false", true},
{"off", "off", "false", true},
{"空串按关闭", "", "false", true},
{"乱值报错", "maybe", "", false},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
got, ok := normalizeBoolSetting(c.value)
if ok != c.ok || got != c.want {
t.Fatalf("normalizeBoolSetting(%q) = (%q, %v), want (%q, %v)", c.value, got, ok, c.want, c.ok)
}
})
}
}
+223
View File
@@ -0,0 +1,223 @@
package handler
import (
"cpdaily-new/internal/database"
"cpdaily-new/internal/model"
"cpdaily-new/internal/service"
cpd "cpdaily-new/internal/service/cpdaily"
"cpdaily-new/internal/utils"
"errors"
"log/slog"
"strings"
"time"
"github.com/gofiber/fiber/v2"
"gorm.io/gorm"
)
// ========== 手机号验证码登录(无需选择学校) ==========
// SmsSendRequest 发送短信验证码请求(需独立的图片验证码)
type SmsSendRequest struct {
Phone string `json:"phone"`
CaptchaID string `json:"captchaId"`
CaptchaCode string `json:"captchaCode"`
}
// SmsSend 发送手机短信验证码(对接今日校园)
func SmsSend(c *fiber.Ctx) error {
var req SmsSendRequest
if err := c.BodyParser(&req); err != nil {
return utils.FailParam(c, "请求参数错误")
}
phone := strings.TrimSpace(req.Phone)
if phone == "" {
return utils.FailParam(c, "请输入手机号")
}
// 独立的图片验证码校验(防脚本刷短信)
if req.CaptchaID == "" || req.CaptchaCode == "" {
return utils.FailParam(c, "请输入图片验证码")
}
if !service.VerifyCaptcha(req.CaptchaID, req.CaptchaCode) {
return utils.FailParam(c, "验证码错误或已过期")
}
// 按 IP 限流,避免短信轰炸(复用验证码令牌桶);IP 取值与登录 IP 一致
ip := utils.ClientIP(c)
if ok, wait := service.AllowCaptchaToken("sms:" + ip); !ok {
return utils.Fail(c, 429, "验证码发送过于频繁,请 "+service.FormatWait(wait)+"后再试")
}
result, err := cpd.SendSmsCode(c.Context(), phone)
if err != nil {
slog.Warn("短信验证码发送失败", "phone", maskPhone(phone), "error", err)
return utils.Fail(c, 400, err.Error())
}
return utils.Success(c, fiber.Map{
"countdown": result.Countdown,
"tipMsg": result.TipMsg,
})
}
// SmsLoginRequest 手机号登录请求(需独立的图片验证码)
type SmsLoginRequest struct {
Phone string `json:"phone"`
Code string `json:"code"`
CaptchaID string `json:"captchaId"`
CaptchaCode string `json:"captchaCode"`
}
// SmsLogin 手机号 + 短信验证码登录。
// 登录响应自带 tenantId(学校)与 openId(学工号),故无需用户选择学校。
// 用户匹配:优先「同校 + 同学工号」复用已有账号,保证与学工号登录指向同一用户。
func SmsLogin(c *fiber.Ctx) error {
var req SmsLoginRequest
if err := c.BodyParser(&req); err != nil {
return utils.FailParam(c, "请求参数错误")
}
phone := strings.TrimSpace(req.Phone)
code := strings.TrimSpace(req.Code)
if phone == "" || code == "" {
return utils.FailParam(c, "请输入手机号和验证码")
}
// 独立的图片验证码校验(登录使用与发码不同的验证码)
if req.CaptchaID == "" || req.CaptchaCode == "" {
return utils.FailParam(c, "请输入图片验证码")
}
if !service.VerifyCaptcha(req.CaptchaID, req.CaptchaCode) {
return utils.FailParam(c, "验证码错误或已过期")
}
loginData, err := cpd.MobileLoginByCode(c.Context(), phone, code)
if err != nil {
slog.Warn("手机号登录失败", "phone", maskPhone(phone), "error", err)
return utils.Fail(c, 401, "登录失败: "+err.Error())
}
if loginData.OpenID == "" {
return utils.FailServer(c, "登录失败:未获取到学工号")
}
// tenantId 即学校 ID
school, err := cpd.GetSchoolDetail(loginData.TenantID)
if err != nil {
slog.Error("获取学校详情失败", "tenantId", loginData.TenantID, "error", err)
return utils.FailServer(c, "获取学校信息失败")
}
// 换取 CAS cookie(含 MOD_AUTH_CAS)
deviceID := cpd.CreateDeviceID()
cookies, err := cpd.ExchangeCasCookie(c.Context(), school, loginData, deviceID)
if err != nil {
slog.Warn("换取 CAS cookie 失败", "tenantId", loginData.TenantID, "error", err)
return utils.Fail(c, 401, "登录失败: "+err.Error())
}
// 校验是否拿到 MOD_ 认证 cookie
hasMOD := false
for _, ck := range cookies {
if strings.HasPrefix(ck.Name, "MOD_") {
hasMOD = true
break
}
}
if !hasMOD {
return utils.Fail(c, 401, "登录失败,未获取到有效的认证信息")
}
cookieStr := cpd.SerializeCookies(cookies)
// 用户匹配:同校 + 同学工号 复用已有账号,否则走通用注册流程新建
user, isNew, err := findUserByStudentIDOrRegister(school, loginData.OpenID, phone, cookieStr)
if err != nil {
slog.Error("手机号登录用户处理失败", "error", err)
return utils.FailServer(c, "用户处理失败")
}
if user.Status == "disabled" {
reason := user.DisabledReason
if reason == "" {
reason = "您的账号已被管理员禁用,如有疑问请联系客服"
}
return c.Status(403).JSON(utils.Response{Code: 403, Message: reason})
}
// 更新 cookie / 登录时间 / 学工号回填
now := time.Now()
updates := map[string]interface{}{
"cookie": cookieStr,
"last_login_at": now,
"last_login_ip": utils.ClientIP(c),
}
// 手机号登录用户也保存其学工号,便于之后用学工号登录时能匹配到同一账号
if user.StudentID != loginData.OpenID {
updates["student_id"] = loginData.OpenID
}
if user.MobilePhone == "" {
updates["mobile_phone"] = phone
}
database.DB.Model(user).Updates(updates)
user.StudentID = loginData.OpenID
// 同步 cpdaily 主页信息(头像/姓名等)
if school.WecloudUrl != "" {
go syncUserProfile(user, cookieStr, school.WecloudUrl)
}
tokenPair, err := service.GenerateTokens(user.ID, user.Username, user.SchoolID)
if err != nil {
return utils.FailServer(c, "生成token失败")
}
slog.Info("手机号登录成功", "userId", user.ID, "tenantId", loginData.TenantID,
"studentId", loginData.OpenID, "isNew", isNew)
return utils.Success(c, fiber.Map{
"user": user,
"token": tokenPair,
"firstLogin": isNew,
})
}
// findUserByStudentIDOrRegister 按「学校 + 学工号」查找用户并复用;
// 未命中则调用通用注册流程(findOrCreateUser,复用邀请/注册奖励逻辑),
// 用户名同样由「学校短码 + 学工号」构成,保证与学工号登录是同一账号。
func findUserByStudentIDOrRegister(school *cpd.SchoolDetailInfo, studentID, phone, cookie string) (*model.SysUser, bool, error) {
// 1. 同校 + 同学工号
var user model.SysUser
err := database.DB.Where("school_id = ? AND student_id = ?", school.ID, studentID).First(&user).Error
if err == nil {
return &user, false, nil
}
if !errors.Is(err, gorm.ErrRecordNotFound) {
return nil, false, err
}
// 2. 按 username(学校短码 + 学工号)匹配历史数据,命中则回填 school_id/student_id
username := buildUsername(school, studentID)
err = database.DB.Where("username = ?", username).First(&user).Error
if err == nil {
database.DB.Model(&user).Updates(map[string]interface{}{
"school_id": school.ID,
"student_id": studentID,
})
return &user, false, nil
}
if !errors.Is(err, gorm.ErrRecordNotFound) {
return nil, false, err
}
// 3. 未命中:走通用注册流程(仅创建账号,不发放邀请/注册奖励)。
// 密码用随机串(SMS 登录用户无密码凭证,仅占位满足非空约束);
// 注意不要用设备码样式的值,避免与 MobileCode 绑定语义混淆——
// 设备码只能由用户在设备页主动绑定,登录流程不做任何自动绑定。
return findOrCreateUser(username, generateRandomCode(32), school.ID, school.Name, studentID, cookie)
}
// maskPhone 手机号脱敏(日志用)
func maskPhone(p string) string {
if len(p) <= 4 {
return "****"
}
return p[:3] + "****" + p[len(p)-4:]
}
@@ -0,0 +1,178 @@
package handler
// 事务级软删除探针:验证「用户删除日志 → 仅软删除」的四处语义。
// 默认跳过;仅在显式设置 PROBE_DB=1 时连接真实数据库执行(需要 backed/config.toml)。
// 全过程在单个事务内进行并最终回滚,不写入任何持久数据(AutoMigrate 除外,与 server 启动行为一致)。
//
// go test ./internal/handler -run TestSoftDeleteProbe -v # 需 PROBE_DB=1
//
// 覆盖点:
// 1. 前台列表(GORM 默认作用域)不展示已删除记录
// 2. 任务完成状态判定(Unscoped 查当天成功日志)不受删除影响
// 3. 后台统计(已执行累计 / 今日状态 / 今日卡片)计入已删除记录
// 4. 后台明细 UNION SQL(adminLogUnionSQL,与 handler 同一常量)含已删除记录并正确标注 deleted/source
import (
"fmt"
"os"
"testing"
"time"
"cpdaily-new/internal/config"
"cpdaily-new/internal/database"
"cpdaily-new/internal/model"
)
func TestSoftDeleteProbe(t *testing.T) {
if os.Getenv("PROBE_DB") != "1" {
t.Skip("跳过:需要 PROBE_DB=1 且可连接数据库")
}
if err := config.Load("../../config.toml"); err != nil {
t.Fatalf("加载配置失败: %v", err)
}
database.Init()
tx := database.DB.Begin()
if tx.Error != nil {
t.Fatalf("开启事务失败: %v", tx.Error)
}
defer tx.Rollback() // 无论成败一律回滚,探针不留痕
// ---------- 构造数据(全部在事务内,ID 由自增分配) ----------
user := model.SysUser{
Username: fmt.Sprintf("sdprobe_%d", time.Now().UnixNano()),
Password: "probe",
SchoolID: "probe",
StudentID: "probe",
InviteCode: fmt.Sprintf("p%09d", time.Now().UnixNano()%1000000000), // 避免唯一索引冲突
}
if err := tx.Create(&user).Error; err != nil {
t.Fatalf("创建探针用户失败: %v", err)
}
uid := user.ID
taskLog := model.CpTaskLog{
CpTaskID: 1, UserID: uid, TaskType: "sign",
Title: "探针签到", RealTitle: "探针签到", IsSuccess: true,
}
leaveLog := model.CpLeaveLog{
UserID: uid, LeaveID: "probe_leave", Title: "探针请假",
SchoolName: "探针学校", Action: model.LeaveActionApply, IsSuccess: true,
}
if err := tx.Create(&taskLog).Error; err != nil {
t.Fatalf("创建任务日志失败: %v", err)
}
if err := tx.Create(&leaveLog).Error; err != nil {
t.Fatalf("创建请假日志失败: %v", err)
}
// 用户端「删除」= GORM 软删除(handler 中即 database.DB.Delete(&log))
if err := tx.Delete(&taskLog).Error; err != nil {
t.Fatalf("软删除任务日志失败: %v", err)
}
if err := tx.Delete(&leaveLog).Error; err != nil {
t.Fatalf("软删除请假日志失败: %v", err)
}
// 回读确认 deleted_at 已置位(而非物理删除)
var rawCount int64
if err := tx.Unscoped().Model(&model.CpTaskLog{}).Where("id = ? AND deleted_at IS NOT NULL", taskLog.ID).Count(&rawCount).Error; err != nil || rawCount != 1 {
t.Fatalf("任务日志未软删除: count=%d err=%v", rawCount, err)
}
if err := tx.Unscoped().Model(&model.CpLeaveLog{}).Where("id = ? AND deleted_at IS NOT NULL", leaveLog.ID).Count(&rawCount).Error; err != nil || rawCount != 1 {
t.Fatalf("请假日志未软删除: count=%d err=%v", rawCount, err)
}
// ---------- 1. 前台列表不可见(GORM 默认作用域过滤 deleted_at) ----------
var frontTask []model.CpTaskLog
tx.Where("user_id = ?", uid).Order("created_at DESC").Limit(10).Find(&frontTask)
if len(frontTask) != 0 {
t.Errorf("[1] 前台任务日志应不可见已删除记录, got %d 条", len(frontTask))
}
var frontLeave []model.CpLeaveLog
tx.Where("user_id = ?", uid).Order("created_at DESC").Limit(10).Find(&frontLeave)
if len(frontLeave) != 0 {
t.Errorf("[1] 前台请假日志应不可见已删除记录, got %d 条", len(frontLeave))
}
// ---------- 2. 任务完成状态不受删除影响(task.go ListTasks 的跨天重置查询) ----------
now := time.Now()
startOfToday := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location())
var todaySuccessIDs []uint
tx.Unscoped().Model(&model.CpTaskLog{}).
Where("user_id = ? AND is_success = 1 AND created_at >= ?", uid, startOfToday).
Distinct("cp_task_id").Pluck("cp_task_id", &todaySuccessIDs)
if len(todaySuccessIDs) != 1 || todaySuccessIDs[0] != 1 {
t.Errorf("[2] 删除当天成功日志后任务完成状态被误重置: IDs=%v", todaySuccessIDs)
}
// ---------- 3. 后台统计计入已删除 ----------
var taskSuccess, leaveSuccess int64
tx.Unscoped().Model(&model.CpTaskLog{}).Where("user_id = ? AND is_success = 1", uid).Count(&taskSuccess)
tx.Unscoped().Model(&model.CpLeaveLog{}).Where("user_id = ? AND is_success = 1", uid).Count(&leaveSuccess)
if taskSuccess != 1 || leaveSuccess != 1 {
t.Errorf("[3] 后台已执行累计应计入已删除: task=%d leave=%d", taskSuccess, leaveSuccess)
}
var todayTask, todayLeave int64
tx.Unscoped().Model(&model.CpTaskLog{}).Where("user_id = ? AND created_at >= ?", uid, startOfToday).Count(&todayTask)
tx.Unscoped().Model(&model.CpLeaveLog{}).Where("user_id = ? AND created_at >= ?", uid, startOfToday).Count(&todayLeave)
if todayTask != 1 || todayLeave != 1 {
t.Errorf("[3] 今日统计卡片应计入已删除: task=%d leave=%d", todayTask, todayLeave)
}
// ---------- 4. 后台明细 UNION(同一 adminLogUnionSQL 常量) ----------
dayStart := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location())
dayEnd := dayStart.AddDate(0, 0, 1)
type combinedRow struct {
ID uint
UserID uint
TaskType string
Deleted bool
Source int
IsSuccess bool
}
var rows []combinedRow
if err := tx.Raw(adminLogUnionSQL, dayStart, dayEnd, dayStart, dayEnd).Scan(&rows).Error; err != nil {
t.Fatalf("[4] UNION 查询失败: %v", err)
}
var mine []combinedRow
for _, r := range rows {
if r.UserID == uid {
mine = append(mine, r)
}
}
if len(mine) != 2 {
t.Fatalf("[4] UNION 应返回 2 条(任务+请假,均含已删除), got %d", len(mine))
}
sourceSeen := map[int]bool{}
taskTypeSeen := map[string]bool{}
for _, r := range mine {
if !r.Deleted {
t.Errorf("[4] 已删除记录在明细中未标注 deleted: %+v", r)
}
sourceSeen[r.Source] = true
taskTypeSeen[r.TaskType] = true
}
if !sourceSeen[1] || !sourceSeen[2] {
t.Errorf("[4] source 标注缺失: %v", sourceSeen)
}
if !taskTypeSeen["sign"] || !taskTypeSeen["leave_apply"] {
t.Errorf("[4] task_type 映射缺失: %v", taskTypeSeen)
}
// 用户端状态筛选(success=1)走 UNION 包裹分支,确认占位符与包裹 SQL 兼容
var successRows []combinedRow
tx.Raw("SELECT * FROM ("+adminLogUnionSQL+") t WHERE t.is_success = 1", dayStart, dayEnd, dayStart, dayEnd).Scan(&successRows)
found := 0
for _, r := range successRows {
if r.UserID == uid {
found++
}
}
if found != 2 {
t.Errorf("[4] success 过滤分支应保留 2 条已删除成功记录, got %d", found)
}
t.Log("软删除探针全部通过:前台隐藏 / 状态不受影响 / 后台计入 / 明细标注")
// defer 回滚,探针不落库
}
+326 -33
View File
@@ -36,7 +36,20 @@ func GetTaskTitles(c *fiber.Ctx) error {
cookieMap := cpd.ParseCookieJSON(user.Cookie)
host := getSchoolHost(user.SchoolID)
tasks := cpd.FetchAllRecentTasks(cookieMap, host)
// 登录态失效时自动续期并重试一次;仍失败则明确提示重新登录
var tasks []cpd.TodayTaskInfo
_, err := withCookieRefresh(&user, cookieMap, host, func(cm map[string]string) error {
var fetchErr error
tasks, fetchErr = cpd.FetchAllRecentTasks(cm, host)
return fetchErr
})
if err != nil {
if isCpdailyAuthError(err) {
return utils.Fail(c, 401, "登录凭证已过期,请重新登录")
}
return utils.Fail(c, 503, "获取打卡任务失败: "+err.Error())
}
return utils.Success(c, tasks)
}
@@ -148,6 +161,49 @@ func GetTaskForm(c *fiber.Ctx) error {
return utils.FailServer(c, "查询失败")
}
// 返校登记无形表单:直接返回空表单,前端保存空 formData 即可
if cpd.TaskType(task.TaskType) == cpd.TaskTypeReturnRegister {
return utils.Success(c, fiber.Map{
"formItems": []model.CpTaskFormItem{},
"realTitle": task.RealTitle,
"isPhoto": false,
})
}
// 工作日志(教师端):表单来自 worklog/detail,字段结构与签到不同,单独转换
if cpd.TaskType(task.TaskType) == cpd.TaskTypeWorklog {
var user model.SysUser
if err := database.DB.First(&user, userID).Error; err != nil {
return utils.FailAuth(c, "用户不存在")
}
cookieMap := cpd.ParseCookieJSON(user.Cookie)
host := getSchoolHost(user.SchoolID)
_, wid, perr := cpd.ParseWorklogSignWid(task.SignWid)
if perr != nil {
return utils.FailParam(c, "任务标识无效")
}
fields, err := cpd.FetchWorklogDetail(cookieMap, host, wid)
if err != nil && isCookieExpired(err) {
if refreshErr := refreshCpdailyCookie(&user); refreshErr == nil {
cookieMap = cpd.ParseCookieJSON(user.Cookie)
fields, err = cpd.FetchWorklogDetail(cookieMap, host, wid)
} else {
err = wrapNeedReLogin(refreshErr)
}
}
if err != nil {
if needReLoginSignal(err) {
return utils.Fail(c, 401, "登录凭证已过期,请重新登录")
}
return utils.Fail(c, 503, "获取工作日志表单失败: "+err.Error())
}
return utils.Success(c, fiber.Map{
"formItems": cpd.WorklogFieldsToFormItems(fields),
"realTitle": task.RealTitle,
"isPhoto": false,
})
}
var user model.SysUser
if err := database.DB.First(&user, userID).Error; err != nil {
return utils.FailAuth(c, "用户不存在")
@@ -165,12 +221,14 @@ func GetTaskForm(c *fiber.Ctx) error {
if refreshErr := refreshCpdailyCookie(&user); refreshErr == nil {
cookieMap = cpd.ParseCookieJSON(user.Cookie)
detail, err = cpd.GetTaskDetailByWid(cookieMap, host, cpd.TaskType(task.TaskType), task.SignWid, task.SignInstanceWid, task.Title)
} else {
err = wrapNeedReLogin(refreshErr)
}
}
}
if err != nil {
slog.Warn("获取任务表单失败", "taskId", id, "error", err)
if errors.Is(err, ErrPasswordChanged) {
if needReLoginSignal(err) {
return utils.Fail(c, 401, "登录凭证已过期,请重新登录")
}
return utils.Fail(c, 503, "获取任务表单失败: "+err.Error())
@@ -242,10 +300,20 @@ func SaveTaskForm(c *fiber.Ctx) error {
return utils.Success(c, nil)
}
// TaskWithMatch 任务列表附带匹配状态
type TaskWithMatch struct {
model.CpTask
MatchStatus string `json:"matchStatus"` // matched_unsigned, matched_signed, no_match, check_failed
// TaskListItem 任务列表的轻量响应项。
// 关键:**不包含 formData**。formData 可能是 longtext(历史数据里存过 base64 图片,
// 单条曾达 2.9MB),把它放进列表接口会让首页每次多传数 MB。
// 列表渲染只需要下面这些字段;表单内容另走 /task/form-data/:id 获取。
type TaskListItem struct {
ID uint `json:"id"`
TaskType string `json:"taskType"`
Title string `json:"title"`
RealTitle string `json:"realTitle"`
TaskStatus string `json:"taskStatus"`
ErrorMessage string `json:"errorMessage"`
CreatedAt time.Time `json:"createdAt"`
MatchStatus string `json:"matchStatus"`
MissingFields []string `json:"missingFields"` // 必填但为空的字段标题(服务端预计算,前端无需解析 formData)
}
// globMatch 简单通配符匹配(仅支持 *)
@@ -295,7 +363,9 @@ func ListTasks(c *fiber.Ctx) error {
now := time.Now()
startOfToday := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location())
var todaySuccessIDs []uint
database.DB.Model(&model.CpTaskLog{}).
// Unscoped:用户在客户端"删除"日志只是软删除,不代表当天没执行过;
// 若漏加会因当天成功日志被删而把任务重置回 pending,导致重复打卡
database.DB.Unscoped().Model(&model.CpTaskLog{}).
Where("user_id = ? AND is_success = 1 AND created_at >= ?", userID, startOfToday).
Distinct("cp_task_id").
Pluck("cp_task_id", &todaySuccessIDs)
@@ -315,6 +385,8 @@ func ListTasks(c *fiber.Ctx) error {
// 尝试获取今日任务进行匹配
var user model.SysUser
var todayTasks []cpd.TodayTaskInfo
// 返校登记待办(taskWid:stuWid 集合),用于匹配 return_register 任务
var returnRegisterPending map[string]bool
fetchOk := false
if err := database.DB.First(&user, userID).Error; err == nil && user.Cookie != "" {
@@ -345,12 +417,46 @@ func ListTasks(c *fiber.Ctx) error {
fetchOk = true
}
}
// 返校登记待办(供 return_register 任务匹配;失败不影响签到类)
returnRegisterPending = map[string]bool{}
if rr, rerr := cpd.FetchReturnRegisterTasks(cookieMap, host); rerr == nil {
for _, t := range rr {
returnRegisterPending[t.SignWid] = true
}
} else {
slog.Warn("获取返校登记待办失败", "userId", userID, "error", rerr)
}
}
// 构造返回结果
result := make([]TaskWithMatch, len(tasks))
// 构造返回结果(轻量 DTO:不含 formData)
result := make([]TaskListItem, len(tasks))
for i, task := range tasks {
item := TaskWithMatch{CpTask: task, MatchStatus: "check_failed"}
item := TaskListItem{
ID: task.ID,
TaskType: task.TaskType,
Title: task.Title,
RealTitle: task.RealTitle,
TaskStatus: task.TaskStatus,
ErrorMessage: task.ErrorMessage,
CreatedAt: task.CreatedAt,
MatchStatus: "check_failed",
MissingFields: missingRequiredFormFields(task.FormData),
}
// 返校登记:按待办集合判断(在待办中=未登记;不在=已登记/已过期)
if task.TaskType == string(cpd.TaskTypeReturnRegister) {
if returnRegisterPending != nil {
item.MatchStatus = "no_match"
if returnRegisterPending[task.SignWid] {
item.MatchStatus = "matched_unsigned"
} else {
item.MatchStatus = "matched_signed"
}
}
item.MissingFields = nil // 返校登记无形表单
result[i] = item
continue
}
if fetchOk {
item.MatchStatus = "no_match"
// 用 title 做 glob 匹配(支持 * 通配符)
@@ -374,6 +480,25 @@ func ListTasks(c *fiber.Ctx) error {
return utils.Success(c, result)
}
// GetTaskSavedForm 返回该任务**已保存**的表单内容(formData),供编辑弹窗预填。
// 与 GetTaskForm 的区别:后者是从今日校园重新拉取表单模板(会丢失用户已填值)。
// 单独成接口,是为了让列表接口(/task/list)不必携带大字段 formData。
func GetTaskSavedForm(c *fiber.Ctx) error {
userID := c.Locals("userId").(uint)
id := c.Params("id")
var task model.CpTask
if err := database.DB.Select("id", "form_data").
Where("id = ? AND user_id = ?", id, userID).First(&task).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return utils.Fail(c, 404, "任务不存在")
}
return utils.FailServer(c, "查询失败")
}
return utils.Success(c, fiber.Map{"formData": task.FormData})
}
// DeleteTask 删除任务
func DeleteTask(c *fiber.Ctx) error {
userID := c.Locals("userId").(uint)
@@ -387,7 +512,8 @@ func DeleteTask(c *fiber.Ctx) error {
return utils.Success(c, nil)
}
// ExecuteTask 同步执行签到(SSE 流式返回状态)
// ExecuteTask 执行任务(按 taskType 分流)。
// sign / sleep_check → 签到提交流程;return_register → 返校登记(确认到达)。
func ExecuteTask(c *fiber.Ctx) error {
userID := c.Locals("userId").(uint)
id := c.Params("id")
@@ -400,6 +526,53 @@ func ExecuteTask(c *fiber.Ctx) error {
return utils.FailServer(c, "查询失败")
}
switch cpd.TaskType(task.TaskType) {
case cpd.TaskTypeReturnRegister:
return executeReturnRegisterTask(c, &task)
case cpd.TaskTypeWorklog:
return executeWorklogTask(c, &task)
default:
return executeSignTask(c)
}
}
// isOutOfRangeSign 判断提交表单是否为「范围外签到」。
// 值来源:cpd 组装 submitForm 时 isMalposition 为 1(范围外)或 0(范围内);
// 兼容字符串 "是"/"1"/"true" 与数值 1。
func isOutOfRangeSign(submitForm map[string]interface{}) bool {
v, ok := submitForm["isMalposition"]
if !ok || v == nil {
return false
}
switch t := v.(type) {
case bool:
return t
case int:
return t == 1
case int64:
return t == 1
case float64:
return t == 1
case string:
s := strings.TrimSpace(t)
return s == "是" || s == "1" || s == "true" || s == "True"
}
return false
}
// executeSignTask 同步执行签到/查寝(SSE 流式返回状态)
func executeSignTask(c *fiber.Ctx) error {
userID := c.Locals("userId").(uint)
id := c.Params("id")
var task model.CpTask
if err := database.DB.Where("id = ? AND user_id = ?", id, userID).First(&task).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return utils.Fail(c, 404, "任务不存在")
}
return utils.FailServer(c, "查询失败")
}
if task.TaskStatus == "executing" {
return utils.Fail(c, 400, "任务正在执行中")
}
@@ -414,6 +587,11 @@ func ExecuteTask(c *fiber.Ctx) error {
if err := database.DB.First(&user, userID).Error; err != nil {
return utils.FailAuth(c, "用户不存在")
}
// 设备码后端硬校验:打卡执行需携带设备码。此前仅前端新建任务入口弹窗引导,
// 账密登录创建的账号可能未绑定设备码,空设备码会触发学校端风控异常
if strings.TrimSpace(user.MobileCode) == "" {
return utils.Fail(c, 400, "请先在个人中心绑定设备码后再执行签到")
}
// 设置 SSE 头
c.Set("Content-Type", "text/event-stream")
@@ -446,9 +624,12 @@ func ExecuteTask(c *fiber.Ctx) error {
// VIP 判定:VIP 且未过期则免扣次数直接执行
isVip := user.VipExpireAt != nil && user.VipExpireAt.After(time.Now())
// 检查可用次数(VIP 免检查)
if !isVip && user.SignCount <= 0 {
slog.Warn("签到次数不足,拒绝执行", "userId", userID, "taskId", id)
// 本次打卡扣减额度(后台可配置,允许小数与 0=不扣费)
deduct := getCheckinDeduct()
// 检查可用次数(VIP 免检查;0 额度时恒放行)
if !isVip && user.SignCount < deduct {
slog.Warn("签到次数不足,拒绝执行", "userId", userID, "taskId", id, "signCount", user.SignCount, "deduct", deduct)
sendEvent("task_error", map[string]interface{}{"message": "可用次数不足,请购买CDK或通过邀请获得次数"})
return
}
@@ -486,13 +667,13 @@ func ExecuteTask(c *fiber.Ctx) error {
if refreshErr := refreshCpdailyCookie(&user); refreshErr == nil {
cookieMap = cpd.ParseCookieJSON(user.Cookie)
detail, err = cpd.FetchTodayTaskDetail(cookieMap, host, cpd.TaskType(task.TaskType), task.Title)
} else if errors.Is(refreshErr, ErrPasswordChanged) {
err = ErrPasswordChanged
} else {
err = wrapNeedReLogin(refreshErr)
}
}
if err != nil {
errMsg := "获取签到表单失败: " + err.Error()
if errors.Is(err, ErrPasswordChanged) {
if needReLoginSignal(err) {
errMsg = "登录凭证已过期,请重新登录"
}
slog.Error("获取表单失败", "taskId", id, "error", err)
@@ -501,7 +682,7 @@ func ExecuteTask(c *fiber.Ctx) error {
"error_message": errMsg,
})
saveLog(false, errMsg, taskTitle(task))
sendEvent("task_error", map[string]interface{}{"status": "error", "message": errMsg, "needReLogin": errors.Is(err, ErrPasswordChanged)})
sendEvent("task_error", map[string]interface{}{"status": "error", "message": errMsg, "needReLogin": needReLoginSignal(err)})
return
}
@@ -540,8 +721,12 @@ func ExecuteTask(c *fiber.Ctx) error {
sendEvent("task_error", map[string]interface{}{"status": "error", "message": errMsg})
return
}
// 历史位置超出查寝组所有签到范围时自动改用最近签到点,避免"位置不在签到范围内"
cpd.AdjustSubmitFormToSignRange(submitForm, detail.Rows)
// 历史位置超出签到范围时自动改用最近签到点,避免"位置不在签到范围内"。
// 但用户主动选择「范围外签到=是」时不得调整:此时用户就是要填范围外的位置
// (如回家/实习),强行改回学校坐标会导致实际打卡地点变成学校。
if !isOutOfRangeSign(submitForm) {
cpd.AdjustSubmitFormToSignRange(submitForm, detail.Rows)
}
// 照片仍是本地预览(base64)时自动上传换取正式 URL(用户上传失败保存的表单/历史遗留数据)
if photo, ok := submitForm["signPhotoUrl"].(string); ok && cpd.IsDataURIImage(photo) {
sendEvent("submitting", map[string]interface{}{"status": "submitting", "message": "正在上传照片..."})
@@ -558,6 +743,16 @@ func ExecuteTask(c *fiber.Ctx) error {
return
}
submitForm["signPhotoUrl"] = previewUrl
} else if photo, ok := submitForm["signPhotoUrl"].(string); ok && photo != "" {
// 照片是已上传的 OBS 预览 URL:其签名由云端签发、有效期很短,而任务是持久化的,
// 隔日执行时原签名多已过期,直接提交会被学校判为签到失败(如兰州交大 2210010000)。
// 用 URL 里稳定的对象 key 重签一个未过期的 URL(同一张照片,无需用户重传)。
// 重签失败不阻断:回退使用原 URL,由学校端判定,避免因预览接口偶发故障误杀签到。
if refreshed, rerr := cpd.RefreshPreviewURL(cookieMap, host, cpd.TaskType(task.TaskType), photo); rerr == nil {
submitForm["signPhotoUrl"] = refreshed
} else {
slog.Warn("签到照片重签失败,回退原URL", "taskId", id, "error", rerr)
}
}
err = cpd.SubmitSign(cookieMap, host, cpd.TaskType(task.TaskType), submitForm, user.MobileCode, user.StudentID)
if err != nil && isCookieExpired(err) {
@@ -565,13 +760,13 @@ func ExecuteTask(c *fiber.Ctx) error {
if refreshErr := refreshCpdailyCookie(&user); refreshErr == nil {
cookieMap = cpd.ParseCookieJSON(user.Cookie)
err = cpd.SubmitSign(cookieMap, host, cpd.TaskType(task.TaskType), submitForm, user.MobileCode, user.StudentID)
} else if errors.Is(refreshErr, ErrPasswordChanged) {
err = ErrPasswordChanged
} else {
err = wrapNeedReLogin(refreshErr)
}
}
if err != nil {
errMsg := fmt.Sprintf("签到失败(%s): %s", taskTitle(task), err.Error())
if errors.Is(err, ErrPasswordChanged) {
if needReLoginSignal(err) {
errMsg = "登录凭证已过期,请重新登录"
}
slog.Error("签到失败", "taskId", id, "error", err)
@@ -580,10 +775,62 @@ func ExecuteTask(c *fiber.Ctx) error {
"error_message": errMsg,
})
saveLog(false, errMsg, taskTitle(task))
sendEvent("task_error", map[string]interface{}{"status": "error", "message": errMsg, "needReLogin": errors.Is(err, ErrPasswordChanged)})
sendEvent("task_error", map[string]interface{}{"status": "error", "message": errMsg, "needReLogin": needReLoginSignal(err)})
return
}
// 4.5 人脸识别复核(重要):今日校园对开启人脸识别的任务,submitSign 返回 code=0
// 仅表示「受理成功」,人脸核验在服务端异步进行,可能事后被系统自动驳回
// (如 FRS.0036)。仅凭 code=0 判定成功会导致「假成功」:日志记成功、照常扣费,
// 但今日校园侧实为未签到。故此处提交成功后回查详情确认结果,只有真正成功才扣费。
if cpd.NeedsFaceIdentify(detail.Rows) {
sendEvent("verifying", map[string]interface{}{"status": "verifying", "message": "正在核验人脸识别结果..."})
// 轮询等待:0s → 1s → 2s → 3s(即查询时刻 ≈ t0/t1/t3/t6,逐次间隔递增到 3 秒)
delays := []time.Duration{0, 1 * time.Second, 2 * time.Second, 3 * time.Second}
rejected := false
var rejectMsg string
settledOK := false
for _, d := range delays {
if d > 0 {
time.Sleep(d)
}
res, cerr := cpd.CheckFaceIdentifyResult(cookieMap, host, cpd.TaskType(task.TaskType),
detail.SignWid, detail.SignInstanceWid, detail.RealTitle)
if cerr != nil {
slog.Warn("人脸识别结果复核失败,稍后重试", "taskId", id, "error", cerr)
continue
}
if res.Rejected {
rejected = true
rejectMsg = res.Message
break
}
if res.Settled {
settledOK = true
break
}
// 尚未出结果:继续等待下一轮
}
if rejected {
if rejectMsg == "" {
rejectMsg = "系统自动驳回了本次签到(人脸识别未通过),请重新尝试"
}
errMsg := fmt.Sprintf("签到失败(%s): %s", taskTitle(task), rejectMsg)
slog.Error("人脸识别被系统驳回,判定签到失败", "taskId", id, "userId", userID, "message", rejectMsg)
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, "faceRejected": true})
return // 不扣费
}
if !settledOK {
// 三轮后仍无明确结果:不阻断,按成功处理(避免误杀正常签到)
slog.Warn("人脸识别复核超时未出结果,按提交成功处理", "taskId", id, "userId", userID)
}
}
// 5. 成功
database.DB.Model(&task).Updates(map[string]interface{}{
"task_status": "success",
@@ -592,11 +839,15 @@ func ExecuteTask(c *fiber.Ctx) error {
// 扣减可用次数(原子操作,防止并发超卖;VIP 有效期内免扣)
if !isVip {
database.DB.Model(&model.SysUser{}).Where("id = ? AND sign_count > 0", userID).
Update("sign_count", gorm.Expr("sign_count - 1"))
database.DB.Model(&model.SysUser{}).Where("id = ? AND sign_count >= ?", userID, deduct).
Update("sign_count", gorm.Expr("sign_count - ?", deduct))
msg := "签到成功!(已扣 " + fmtDeduct(deduct) + " 次)"
if deduct <= 0 {
msg = "签到成功!(免扣次数)"
}
sendEvent("success", map[string]interface{}{
"status": "success",
"message": "签到成功!",
"message": msg,
})
} else {
sendEvent("success", map[string]interface{}{
@@ -716,19 +967,40 @@ func GetUploadPolicy(c *fiber.Ctx) error {
cookieMap := cpd.ParseCookieJSON(user.Cookie)
host := getSchoolHost(user.SchoolID)
// 请假类图片:新套学校走 leave-new OBS 协议(COS 风格),旧套保持原路径
if req.TaskType == string(cpd.TaskTypeLeave) && schoolLeaveFlavor(&user, cookieMap) == cpd.LeaveFlavorNew {
policy, err := cpd.GetLeaveNewUploadPolicy(cookieMap, host)
if err != nil && isCookieExpired(err) {
if refreshErr := refreshCpdailyCookie(&user); refreshErr == nil {
cookieMap = cpd.ParseCookieJSON(user.Cookie)
policy, err = cpd.GetLeaveNewUploadPolicy(cookieMap, host)
} else {
err = wrapNeedReLogin(refreshErr)
}
}
if err != nil {
slog.Error("获取上传策略失败(新套)", "error", err)
if needReLoginSignal(err) {
return utils.Fail(c, 401, "登录凭证已过期,请重新登录")
}
return utils.Fail(c, 503, "获取上传策略失败: "+err.Error())
}
return utils.Success(c, policy)
}
policy, err := cpd.GetUploadPolicy(cookieMap, host, cpd.TaskType(req.TaskType))
if err != nil && isCookieExpired(err) {
slog.Info("获取上传策略cookie过期,尝试刷新", "userId", userID)
if refreshErr := refreshCpdailyCookie(&user); refreshErr == nil {
cookieMap = cpd.ParseCookieJSON(user.Cookie)
policy, err = cpd.GetUploadPolicy(cookieMap, host, cpd.TaskType(req.TaskType))
} else if errors.Is(refreshErr, ErrPasswordChanged) {
err = ErrPasswordChanged
} else {
err = wrapNeedReLogin(refreshErr)
}
}
if err != nil {
slog.Error("获取上传策略失败", "error", err)
if errors.Is(err, ErrPasswordChanged) {
if needReLoginSignal(err) {
return utils.Fail(c, 401, "登录凭证已过期,请重新登录")
}
return utils.Fail(c, 503, "获取上传策略失败: "+err.Error())
@@ -760,19 +1032,40 @@ func GetPreviewUrl(c *fiber.Ctx) error {
cookieMap := cpd.ParseCookieJSON(user.Cookie)
host := getSchoolHost(user.SchoolID)
// 请假类图片预览:新套学校走 leave-new OBS 协议(参数 obsKey)
if req.TaskType == string(cpd.TaskTypeLeave) && schoolLeaveFlavor(&user, cookieMap) == cpd.LeaveFlavorNew {
previewUrl, err := cpd.GetLeaveNewPreviewURL(cookieMap, host, req.FileKey)
if err != nil && isCookieExpired(err) {
if refreshErr := refreshCpdailyCookie(&user); refreshErr == nil {
cookieMap = cpd.ParseCookieJSON(user.Cookie)
previewUrl, err = cpd.GetLeaveNewPreviewURL(cookieMap, host, req.FileKey)
} else {
err = wrapNeedReLogin(refreshErr)
}
}
if err != nil {
slog.Error("获取预览 URL 失败(新套)", "error", err)
if needReLoginSignal(err) {
return utils.Fail(c, 401, "登录凭证已过期,请重新登录")
}
return utils.Fail(c, 503, "获取预览 URL 失败: "+err.Error())
}
return utils.Success(c, fiber.Map{"previewUrl": previewUrl})
}
previewUrl, err := cpd.GetPreviewUrl(cookieMap, host, cpd.TaskType(req.TaskType), req.FileKey)
if err != nil && isCookieExpired(err) {
slog.Info("获取预览URL cookie过期,尝试刷新", "userId", userID)
if refreshErr := refreshCpdailyCookie(&user); refreshErr == nil {
cookieMap = cpd.ParseCookieJSON(user.Cookie)
previewUrl, err = cpd.GetPreviewUrl(cookieMap, host, cpd.TaskType(req.TaskType), req.FileKey)
} else if errors.Is(refreshErr, ErrPasswordChanged) {
err = ErrPasswordChanged
} else {
err = wrapNeedReLogin(refreshErr)
}
}
if err != nil {
slog.Error("获取预览 URL 失败", "error", err)
if errors.Is(err, ErrPasswordChanged) {
if needReLoginSignal(err) {
return utils.Fail(c, 401, "登录凭证已过期,请重新登录")
}
return utils.Fail(c, 503, "获取预览 URL 失败: "+err.Error())
@@ -0,0 +1,31 @@
package handler
import "testing"
// TestIsOutOfRangeSign 判定「范围外签到」:值来源为 cpd 组装 submitForm 时的 isMalposition(1=范围外/0=范围内),
// 兼容字符串。用于决定是否跳过坐标自动回退到学校签到点。
func TestIsOutOfRangeSign(t *testing.T) {
cases := []struct {
name string
form map[string]interface{}
want bool
}{
{"缺字段默认范围内", map[string]interface{}{}, false},
{"数值1=范围外", map[string]interface{}{"isMalposition": 1}, true},
{"数值0=范围内", map[string]interface{}{"isMalposition": 0}, false},
{"int64 1", map[string]interface{}{"isMalposition": int64(1)}, true},
{"float64 1", map[string]interface{}{"isMalposition": float64(1)}, true},
{"字符串是", map[string]interface{}{"isMalposition": "是"}, true},
{"字符串否", map[string]interface{}{"isMalposition": "否"}, false},
{"字符串1", map[string]interface{}{"isMalposition": "1"}, true},
{"布尔true", map[string]interface{}{"isMalposition": true}, true},
{"nil", map[string]interface{}{"isMalposition": nil}, false},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
if got := isOutOfRangeSign(c.form); got != c.want {
t.Fatalf("isOutOfRangeSign(%v) = %v, want %v", c.form, got, c.want)
}
})
}
}
@@ -0,0 +1,166 @@
package handler
import (
"bufio"
"encoding/json"
"fmt"
"log/slog"
"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"
)
// executeReturnRegisterTask 执行返校登记任务(SSE 流式返回,与签到执行体验一致)。
// 返校登记无形表单:直接取学校配置的校区坐标,对 signWid 中的 (taskWid, stuWid) 提交确认到达。
// 事件流:connecting → submitting → success / task_error
func executeReturnRegisterTask(c *fiber.Ctx, task *model.CpTask) error {
userID := c.Locals("userId").(uint)
// 解析任务标识 "taskWid:stuWid"(在打开 SSE 流前完成,参数错误可直接返回 JSON)
taskWid, stuWid, perr := cpd.ParseLeaveReturnSignWid(task.SignWid)
if perr != nil {
return utils.FailParam(c, "任务配置无效,请删除后重新创建")
}
if task.TaskStatus == "executing" {
return utils.Fail(c, 400, "任务正在执行中")
}
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()
}
saveLog := func(isSuccess bool, errMsg string) {
database.DB.Create(&model.CpTaskLog{
CpTaskID: task.ID,
UserID: userID,
TaskType: task.TaskType,
Title: task.Title,
RealTitle: task.RealTitle,
IsSuccess: isSuccess,
ErrorMessage: errMsg,
})
}
isVip := user.VipExpireAt != nil && user.VipExpireAt.After(time.Now())
deduct := getCheckinDeduct()
if !isVip && user.SignCount < deduct {
slog.Warn("返校登记次数不足,拒绝执行", "userId", userID, "taskId", task.ID, "signCount", user.SignCount, "deduct", deduct)
sendEvent("task_error", map[string]interface{}{"message": "可用次数不足,请购买CDK或通过邀请获得次数"})
return
}
// 限流:同一用户同一任务
rateKey := fmt.Sprintf("task:%d:%d", userID, task.ID)
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, "taskId", task.ID, "msg", msg)
sendEvent("task_error", map[string]interface{}{"message": msg, "rateLimit": true})
return
}
database.DB.Model(task).Updates(map[string]interface{}{
"task_status": "executing",
"error_message": "",
})
sendEvent("connecting", map[string]interface{}{"status": "connecting", "message": "正在连接今日校园..."})
cookieMap := cpd.ParseCookieJSON(user.Cookie)
host := getSchoolHost(user.SchoolID)
// 取学校配置的校区坐标(范围内登记)
places, err := cpd.FetchLeaveReturnPlaces(cookieMap, host)
if err != nil && isCookieExpired(err) {
if refreshErr := refreshCpdailyCookie(&user); refreshErr == nil {
cookieMap = cpd.ParseCookieJSON(user.Cookie)
places, err = cpd.FetchLeaveReturnPlaces(cookieMap, host)
} else {
err = wrapNeedReLogin(refreshErr)
}
}
if err != nil {
errMsg := "获取返校位置配置失败: " + err.Error()
slog.Error("返校登记失败", "taskId", task.ID, "error", err)
database.DB.Model(task).Updates(map[string]interface{}{"task_status": "fail", "error_message": errMsg})
saveLog(false, errMsg)
sendEvent("task_error", map[string]interface{}{"status": "error", "message": errMsg})
return
}
if len(places) == 0 {
errMsg := "学校未配置返校确认位置,无法自动登记"
database.DB.Model(task).Updates(map[string]interface{}{"task_status": "fail", "error_message": errMsg})
saveLog(false, errMsg)
sendEvent("task_error", map[string]interface{}{"status": "error", "message": errMsg})
return
}
place := places[0]
sendEvent("submitting", map[string]interface{}{"status": "submitting", "message": "正在提交返校登记..."})
err = cpd.SubmitLeaveReturnRegister(cookieMap, host, taskWid, stuWid, place.Address, place.Longitude, place.Latitude, 1, "")
if err != nil && isCookieExpired(err) {
if refreshErr := refreshCpdailyCookie(&user); refreshErr == nil {
cookieMap = cpd.ParseCookieJSON(user.Cookie)
err = cpd.SubmitLeaveReturnRegister(cookieMap, host, taskWid, stuWid, place.Address, place.Longitude, place.Latitude, 1, "")
} else {
err = wrapNeedReLogin(refreshErr)
}
}
if err != nil {
errMsg := "返校登记失败: " + err.Error()
slog.Error("返校登记失败", "taskId", task.ID, "error", err)
database.DB.Model(task).Updates(map[string]interface{}{"task_status": "fail", "error_message": errMsg})
saveLog(false, errMsg)
sendEvent("task_error", map[string]interface{}{"status": "error", "message": errMsg})
return
}
database.DB.Model(task).Updates(map[string]interface{}{"task_status": "success"})
saveLog(true, "")
if !isVip {
database.DB.Model(&model.SysUser{}).Where("id = ? AND sign_count >= ?", userID, deduct).
Update("sign_count", gorm.Expr("sign_count - ?", deduct))
msg := "返校登记成功!(已扣 " + fmtDeduct(deduct) + " 次)"
if deduct <= 0 {
msg = "返校登记成功!(免扣次数)"
}
sendEvent("success", map[string]interface{}{"status": "success", "message": msg})
} else {
sendEvent("success", map[string]interface{}{"status": "success", "message": "返校登记成功!(VIP 免扣次数)"})
}
slog.Info("返校登记成功", "taskId", task.ID, "userId", userID, "taskWid", taskWid, "stuWid", stuWid)
})
return nil
}
+158
View File
@@ -0,0 +1,158 @@
package handler
import (
"bufio"
"encoding/json"
"fmt"
"log/slog"
"strconv"
"strings"
"cpdaily-new/internal/database"
"cpdaily-new/internal/model"
cpd "cpdaily-new/internal/service/cpdaily"
"cpdaily-new/internal/utils"
"github.com/gofiber/fiber/v2"
)
// 教师端「工作日志」执行(SSE)。
// 流程:取表单 → 文本项回填 → 定位签到项走 submitSign → update(operationType=1) 提交。
// 事件流与其他任务一致:connecting → submitting → success / task_error。
//
// 注意:本流程依据公开逆向实现(spec://worklog_api.md)编写,尚未经真实教师会话端到端验证。
func executeWorklogTask(c *fiber.Ctx, task *model.CpTask) 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 strings.TrimSpace(user.Cookie) == "" {
return utils.Fail(c, 400, "请先登录")
}
formWid, wid, perr := cpd.ParseWorklogSignWid(task.SignWid)
if perr != nil {
return utils.FailParam(c, "任务标识无效")
}
// 保存的表单值(colName=字段 wid → content),用于回填
saved := map[string]string{}
for _, it := range parseSavedFormItems(task.FormData) {
saved[it.ColName] = it.Content
}
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()
}
saveLog := func(isSuccess bool, errMsg string) {
database.DB.Create(&model.CpTaskLog{
UserID: userID, CpTaskID: task.ID, TaskType: string(cpd.TaskTypeWorklog),
Title: task.Title, RealTitle: task.RealTitle, IsSuccess: isSuccess, ErrorMessage: errMsg,
})
}
host := getSchoolHost(user.SchoolID)
sendEvent("connecting", map[string]interface{}{"status": "connecting", "message": "正在连接今日校园..."})
cookieMap := cpd.ParseCookieJSON(user.Cookie)
fields, err := cpd.FetchWorklogDetail(cookieMap, host, wid)
if err != nil && isCookieExpired(err) {
if refreshErr := refreshCpdailyCookie(&user); refreshErr == nil {
cookieMap = cpd.ParseCookieJSON(user.Cookie)
fields, err = cpd.FetchWorklogDetail(cookieMap, host, wid)
} else {
err = wrapNeedReLogin(refreshErr)
}
}
if err != nil {
msg := "获取工作日志表单失败: " + err.Error()
if needReLoginSignal(err) {
msg = "登录凭证已过期,请重新登录"
}
saveLog(false, msg)
sendEvent("task_error", map[string]interface{}{"status": "error", "message": msg, "needReLogin": needReLoginSignal(err)})
return
}
sendEvent("submitting", map[string]interface{}{"status": "submitting", "message": "正在提交工作日志..."})
// 逐字段回填:普通字段赋值;签到项(signCondition=true)走 submitSign。
// 签到项的坐标由用户在编辑时选择校区写入该字段 content("lat,lng",取该校区的规定经纬度)。
for i := range fields {
f := &fields[i]
if v, ok := saved[strconv.FormatInt(f.Wid, 10)]; ok && strings.TrimSpace(v) != "" {
f.Value = v
}
if !f.SignCondition {
continue
}
lat, lon := parseWorklogLatLon(f.Value)
if lon == "" || lat == "" {
msg := "工作日志「" + f.Title + "」需要选择签到校区,请在编辑任务时选择后重试"
saveLog(false, msg)
sendEvent("task_error", map[string]interface{}{"status": "error", "message": msg})
return
}
// 范围校验:不在允许的校区范围内则明确拒绝(不伪造定位)
if scopes := cpd.WorklogScopePoints(*f); len(scopes) > 0 && !cpd.WorklogWithinRange(*f, lat, lon) {
msg := "工作日志「" + f.Title + "」当前定位不在允许的签到范围内,请到指定校区后重新获取定位"
saveLog(false, msg)
sendEvent("task_error", map[string]interface{}{"status": "error", "message": msg})
return
}
if serr := cpd.SubmitWorklogSign(cookieMap, host, f.Wid, wid, "", lon, lat); serr != nil {
msg := "签到失败: " + serr.Error()
saveLog(false, msg)
sendEvent("task_error", map[string]interface{}{"status": "error", "message": msg})
return
}
}
if err := cpd.SubmitWorklog(cookieMap, host, formWid, wid, fields); err != nil {
msg := "提交工作日志失败: " + err.Error()
saveLog(false, msg)
sendEvent("task_error", map[string]interface{}{"status": "error", "message": msg})
return
}
saveLog(true, "")
database.DB.Model(&model.CpTask{}).Where("id = ?", task.ID).
Updates(map[string]interface{}{"task_status": "success", "error_message": ""})
slog.Info("工作日志提交成功", "userId", userID, "taskId", task.ID)
sendEvent("success", map[string]interface{}{"status": "success", "message": "工作日志提交成功!"})
})
return nil
}
// parseSavedFormItems 解析任务已保存表单(best-effort,解析失败返回空)
func parseSavedFormItems(raw string) []model.CpTaskFormItem {
if strings.TrimSpace(raw) == "" {
return nil
}
var items []model.CpTaskFormItem
if err := json.Unmarshal([]byte(raw), &items); err != nil {
return nil
}
return items
}
// parseWorklogLatLon 解析签到字段 content 中的 "lat,lng"。
// 前端由用户选择校区后写入该校区的规定经纬度(lat,lng),学校按点位半径校验。
func parseWorklogLatLon(v string) (lat, lon string) {
parts := strings.SplitN(strings.TrimSpace(v), ",", 2)
if len(parts) != 2 {
return "", ""
}
return strings.TrimSpace(parts[0]), strings.TrimSpace(parts[1])
}
+72 -21
View File
@@ -44,6 +44,8 @@ type AdminTicketListItem struct {
Username string `json:"username"`
RealName string `json:"realName"`
SchoolName string `json:"schoolName"`
// 用户是否已删除该工单(软删除);管理端仍展示,便于追溯
Deleted bool `json:"deleted"`
}
type TicketDetail struct {
@@ -69,18 +71,29 @@ type TicketMessageRes struct {
type TicketNotificationRes struct {
ID uint `json:"id"`
TicketID uint `json:"ticketId"`
Kind string `json:"kind"` // reply(管理员回复) / status(状态变更)
Title string `json:"title"`
Content string `json:"content"`
IsRead bool `json:"isRead"`
CreatedAt string `json:"createdAt"`
// 关联工单当前状态(pending/processing/resolved/closed);用于前端排序:
// 未关闭工单的回复通知排在前面,已关闭的排在后面
TicketStatus string `json:"ticketStatus"`
}
// ---------- 通知辅助函数 ----------
func createTicketNotification(ticketID, userID uint, title, content string) {
// 通知类型:reply=管理员回复 / status=状态变更
const (
ticketNotifKindReply = "reply"
ticketNotifKindStatus = "status"
)
func createTicketNotification(ticketID, userID uint, kind, title, content string) {
notif := model.TicketNotification{
UserID: userID,
TicketID: ticketID,
Kind: kind,
Title: title,
Content: content,
}
@@ -242,6 +255,14 @@ func AddTicketMessage(c *fiber.Ctx) error {
database.DB.Model(&ticket).Update("status", "pending")
}
// 异步邮件提醒管理员:工单对话有新回复
go func() {
var u model.SysUser
if err := database.DB.Select("username, real_name, school_name").First(&u, userID).Error; err == nil {
service.NotifyTicketUserReply(ticket.ID, u.Username, u.SchoolName, u.RealName, ticket.Title, msg.Content, msg.CreatedAt.Format("2006-01-02 15:04"))
}
}()
return utils.Success(c, fiber.Map{
"id": msg.ID,
})
@@ -272,7 +293,11 @@ func CloseTicket(c *fiber.Ctx) error {
return utils.Success(c, fiber.Map{"status": "closed"})
}
// DeleteTicket 用户删除工单
// DeleteTicket 用户删除工单(软删除 + 自动关闭)。
// 1. 标记 tickets.deleted_at:用户端列表不再展示,管理端仍可见并可查看完整对话,
// 因此不级联删除消息/通知,避免用户删除后抹掉处理痕迹;
// 2. 同时将状态置为 closed(用户删除视为自动关闭),此后用户无法再回复,
// 管理端也不再将其计入「未关闭」待办。
func DeleteTicket(c *fiber.Ctx) error {
userID := c.Locals("userId").(uint)
ticketID, err := c.ParamsInt("id")
@@ -288,12 +313,16 @@ func DeleteTicket(c *fiber.Ctx) error {
return utils.FailServer(c, "查询失败")
}
// 级联删除:消息、通知
database.DB.Where("ticket_id = ?", ticket.ID).Delete(&model.TicketMessage{})
database.DB.Where("ticket_id = ?", ticket.ID).Delete(&model.TicketNotification{})
database.DB.Delete(&ticket)
// 先置为关闭,再软删除(两条 UPDATE;软删除后 GORM 默认作用域将查不到该行)
if ticket.Status != "closed" {
database.DB.Model(&ticket).Update("status", "closed")
}
// 软删除(GORM 自动转为 UPDATE deleted_at);消息与通知保留,供管理端追溯
if err := database.DB.Delete(&ticket).Error; err != nil {
return utils.FailServer(c, "删除失败")
}
return utils.Success(c, fiber.Map{"deleted": true})
return utils.Success(c, fiber.Map{"deleted": true, "status": "closed"})
}
// ---------- 工单通知 API ----------
@@ -305,15 +334,31 @@ func ListTicketNotifications(c *fiber.Ctx) error {
var notifs []model.TicketNotification
database.DB.Where("user_id = ?", userID).Order("created_at DESC").Limit(50).Find(&notifs)
// 批量取关联工单状态(用于前端把「未关闭工单」的通知排在前面)
ticketIDs := make([]uint, 0, len(notifs))
for _, n := range notifs {
ticketIDs = append(ticketIDs, n.TicketID)
}
statusByTicket := map[uint]string{}
if len(ticketIDs) > 0 {
var tickets []model.Ticket
database.DB.Unscoped().Select("id", "status").Where("id IN ?", ticketIDs).Find(&tickets)
for _, t := range tickets {
statusByTicket[t.ID] = t.Status
}
}
items := make([]TicketNotificationRes, 0, len(notifs))
for _, n := range notifs {
items = append(items, TicketNotificationRes{
ID: n.ID,
TicketID: n.TicketID,
Title: n.Title,
Content: n.Content,
IsRead: n.IsRead,
CreatedAt: n.CreatedAt.Format("2006-01-02 15:04"),
ID: n.ID,
TicketID: n.TicketID,
Kind: n.Kind,
Title: n.Title,
Content: n.Content,
IsRead: n.IsRead,
CreatedAt: n.CreatedAt.Format("2006-01-02 15:04"),
TicketStatus: statusByTicket[n.TicketID],
})
}
@@ -346,11 +391,12 @@ func MarkTicketNotificationRead(c *fiber.Ctx) error {
// ---------- 管理端 API ----------
// AdminListTickets 管理端工单列表
// 使用 Unscoped 包含用户已软删除的工单,便于管理员追溯(列表中标记「用户已删除」)。
func AdminListTickets(c *fiber.Ctx) error {
status := c.Query("status")
keyword := c.Query("keyword")
query := database.DB.Table("tickets").
query := database.DB.Unscoped().Table("tickets").
Select("tickets.*, sys_user.username, sys_user.real_name, sys_user.school_id").
Joins("LEFT JOIN sys_user ON sys_user.id = tickets.user_id").
Order("tickets.created_at DESC")
@@ -389,6 +435,7 @@ func AdminListTickets(c *fiber.Ctx) error {
Username: r.Username,
RealName: r.RealName,
SchoolName: r.SchoolID,
Deleted: r.DeletedAt.Valid,
})
}
@@ -403,7 +450,8 @@ func AdminGetTicket(c *fiber.Ctx) error {
}
var ticket model.Ticket
if err := database.DB.First(&ticket, ticketID).Error; err != nil {
// Unscoped:管理员可查看用户已软删除的工单(含完整对话)
if err := database.DB.Unscoped().First(&ticket, ticketID).Error; err != nil {
if err == gorm.ErrRecordNotFound {
return utils.Fail(c, 404, "工单不存在")
}
@@ -441,6 +489,7 @@ func AdminGetTicket(c *fiber.Ctx) error {
"schoolName": user.SchoolID,
"createdAt": ticket.CreatedAt.Format("2006-01-02 15:04"),
"updatedAt": ticket.UpdatedAt.Format("2006-01-02 15:04"),
"deleted": ticket.DeletedAt.Valid,
"messages": msgRes,
})
}
@@ -462,7 +511,8 @@ func AdminReplyTicket(c *fiber.Ctx) error {
}
var ticket model.Ticket
if err := database.DB.First(&ticket, ticketID).Error; err != nil {
// Unscoped:允许对用户已软删除的工单继续回复(工单内容仍保留可追溯)
if err := database.DB.Unscoped().First(&ticket, ticketID).Error; err != nil {
if err == gorm.ErrRecordNotFound {
return utils.Fail(c, 404, "工单不存在")
}
@@ -491,7 +541,7 @@ func AdminReplyTicket(c *fiber.Ctx) error {
// 发送通知给用户
title := fmt.Sprintf("工单 #%d 有新回复", ticket.ID)
content := truncateString(req.Content, 100)
createTicketNotification(ticket.ID, ticket.UserID, title, content)
createTicketNotification(ticket.ID, ticket.UserID, ticketNotifKindReply, title, content)
return utils.Success(c, fiber.Map{
"id": msg.ID,
@@ -521,7 +571,8 @@ func AdminUpdateTicketStatus(c *fiber.Ctx) error {
}
var ticket model.Ticket
if err := database.DB.First(&ticket, ticketID).Error; err != nil {
// Unscoped:允许对用户已软删除的工单更新状态
if err := database.DB.Unscoped().First(&ticket, ticketID).Error; err != nil {
if err == gorm.ErrRecordNotFound {
return utils.Fail(c, 404, "工单不存在")
}
@@ -534,12 +585,12 @@ func AdminUpdateTicketStatus(c *fiber.Ctx) error {
statusLabels := map[string]string{
"pending": "待处理",
"processing": "处理中",
"resolved": "已解决",
"closed": "已关闭",
"resolved": "已完成",
"closed": "关闭",
}
title := fmt.Sprintf("工单 #%d 状态变更", ticket.ID)
content := fmt.Sprintf("工单状态已更新为:%s", statusLabels[req.Status])
createTicketNotification(ticket.ID, ticket.UserID, title, content)
createTicketNotification(ticket.ID, ticket.UserID, ticketNotifKindStatus, title, content)
return utils.Success(c, fiber.Map{
"status": req.Status,
+43 -4
View File
@@ -38,7 +38,7 @@ func ListUserAnnouncements(c *fiber.Ctx) error {
var announcements []model.Announcement
database.DB.Where(
"target_type = ? AND (target_user_id IS NULL OR target_user_id = ?)",
"is_pinned = 0 AND target_type = ? AND (target_user_id IS NULL OR target_user_id = ?)",
userType, userID,
).Order("created_at DESC").Find(&announcements)
@@ -66,6 +66,40 @@ func ListUserAnnouncements(c *fiber.Ctx) error {
return utils.Success(c, items)
}
// PinnedAnnouncementItem 置顶公告项(打卡页顶部横向滚动展示)
type PinnedAnnouncementItem struct {
ID uint `json:"id"`
Title string `json:"title"`
Content string `json:"content"`
CreatedAt string `json:"createdAt"`
}
// ListPinnedAnnouncements 获取当前用户可见的置顶公告(打卡页顶部横向滚动)
func ListPinnedAnnouncements(c *fiber.Ctx) error {
userID, userType, err := getCurrentUser(c)
if err != nil {
return utils.FailAuth(c, "未认证")
}
var announcements []model.Announcement
database.DB.Where(
"is_pinned = 1 AND target_type = ? AND (target_user_id IS NULL OR target_user_id = ?)",
userType, userID,
).Order("created_at DESC").Find(&announcements)
items := make([]PinnedAnnouncementItem, 0, len(announcements))
for _, a := range announcements {
items = append(items, PinnedAnnouncementItem{
ID: a.ID,
Title: a.Title,
Content: a.Content,
CreatedAt: a.CreatedAt.Format("2006-01-02 15:04:05"),
})
}
return utils.Success(c, items)
}
// GetUnreadCount 获取未读公告数
func GetUnreadCount(c *fiber.Ctx) error {
userID, userType, err := getCurrentUser(c)
@@ -75,7 +109,7 @@ func GetUnreadCount(c *fiber.Ctx) error {
var totalCount int64
database.DB.Model(&model.Announcement{}).
Where("target_type = ? AND (target_user_id IS NULL OR target_user_id = ?)", userType, userID).
Where("is_pinned = 0 AND target_type = ? AND (target_user_id IS NULL OR target_user_id = ?)", userType, userID).
Count(&totalCount)
var readCount int64
@@ -118,10 +152,10 @@ func GetMyAnnouncementInfo(c *fiber.Ctx) error {
return utils.FailAuth(c, "未认证")
}
// 1. 总公告数(包含指定给当前用户的)
// 1. 总公告数(包含指定给当前用户的;不含置顶公告)
var totalCount int64
database.DB.Model(&model.Announcement{}).
Where("target_type = ? AND (target_user_id IS NULL OR target_user_id = ?)", userType, userID).
Where("is_pinned = 0 AND target_type = ? AND (target_user_id IS NULL OR target_user_id = ?)", userType, userID).
Count(&totalCount)
// 2. 已读数
@@ -185,6 +219,11 @@ func MarkAnnouncementRead(c *fiber.Ctx) error {
return utils.Fail(c, 404, "公告不存在")
}
// 置顶公告不进入用户公告列表,不可被标记已读
if announcement.IsPinned {
return utils.Fail(c, 400, "置顶公告无需标记已读")
}
// 验证公告目标类型匹配(agent 公告仅代理/管理员可见,user 公告仅用户可见)
if announcement.TargetType != userType {
return utils.Fail(c, 403, "无权操作此公告")
+14
View File
@@ -54,6 +54,20 @@ func AuthRequired() fiber.Handler {
if err != nil {
return utils.FailAuth(c, "token无效或已过期")
}
// 封号拦截:token 有效但账号已被封禁时,所有接口一律拒绝并返回封号原因。
// 注意:必须用 query 只取 status/disabled_reason,避免加载长字段;用户不存在按放行处理
//(由各 handler 自行兜底),避免误伤。
var u model.SysUser
if err := database.DB.Select("id", "status", "disabled_reason").First(&u, claims.UserID).Error; err == nil {
if u.Status == "disabled" {
reason := strings.TrimSpace(u.DisabledReason)
if reason == "" {
reason = "您的账号已被管理员封禁,如有疑问请联系客服"
}
// HTTP 403 + 业务码 4030:前端据此弹出封号原因并登出,不触发 token 刷新
return c.Status(403).JSON(utils.Response{Code: 4030, Message: reason})
}
}
// 将用户信息存入 context
c.Locals("userId", claims.UserID)
c.Locals("username", claims.Username)
+5 -3
View File
@@ -12,9 +12,11 @@ type Announcement struct {
TargetUserID *uint `gorm:"index" json:"targetUserId"` // 指定单个用户(null=推送全部)
ForcePopup bool `gorm:"default:false" json:"forcePopup"`
LoopPopup bool `gorm:"default:false" json:"loopPopup"`
CreatedBy uint `gorm:"not null" json:"createdBy"` // admin_id (system公告为0)
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
// 置顶公告:在打卡页顶部横向滚动展示
IsPinned bool `gorm:"default:false;index" json:"isPinned"`
CreatedBy uint `gorm:"not null" json:"createdBy"` // admin_id (system公告为0)
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
}
func (Announcement) TableName() string {
+35
View File
@@ -0,0 +1,35 @@
package model
import (
"time"
"gorm.io/gorm"
)
// CpLeaveLog 请假/销假执行日志
// 说明:DeletedAt 软删除字段——用户在客户端"删除"日志时仅标记 deleted_at,
// 前台日志列表不再展示,但后台统计仍计入。
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"`
DeletedAt gorm.DeletedAt `gorm:"index" json:"deletedAt"`
}
func (CpLeaveLog) TableName() string {
return "cp_leave_logs"
}
// 日志动作类型:请假申请 / 销假
const (
LeaveActionApply = "apply" // 请假申请
LeaveActionExtend = "extend" // 续假(延长已有假单,与新建请假区分)
LeaveActionReport = "report" // 销假
)
+6 -2
View File
@@ -6,10 +6,10 @@ import "time"
type CpTask struct {
ID uint `gorm:"primaryKey;autoIncrement" json:"id"`
UserID uint `gorm:"not null;index" json:"userId"`
TaskType string `gorm:"type:varchar(32);not null" json:"taskType"` // sign, sleep_check
TaskType string `gorm:"type:varchar(32);not null" json:"taskType"` // sign, sleep_check, return_register
Title string `gorm:"type:varchar(255);not null" json:"title"` // 用户选择的标题(支持通配符)
RealTitle string `gorm:"type:varchar(255)" json:"realTitle"` // cpdaily 返回的实际标题
SignWid string `gorm:"type:varchar(255)" json:"signWid"` // cpdaily 任务标识
SignWid string `gorm:"type:varchar(255)" json:"signWid"` // cpdaily 任务标识(return_register 为 "taskWid:stuWid")
SignInstanceWid string `gorm:"type:varchar(255)" json:"signInstanceWid"` // cpdaily 实例标识
FormData string `gorm:"type:longtext" json:"formData"` // JSON: []CpTaskFormItem
TaskStatus string `gorm:"type:varchar(32);default:pending" json:"taskStatus"` // pending, executing, success, fail
@@ -47,6 +47,10 @@ const (
ItemTypeMulti CpTaskItemType = 3 // 多选
ItemTypeImage CpTaskItemType = 4 // 图片
ItemTypeLatAndLon CpTaskItemType = 6 // 经纬度
// 以下类型来自今日校园字段定义(工作日志等场景):原样透传,前端按类型渲染控件
ItemTypeDate CpTaskItemType = 5 // 日期/时间
ItemTypeDateTime CpTaskItemType = 7 // 日期时间
ItemTypeTextarea CpTaskItemType = 9 // 多行文本
)
// CpTaskFormOption 表单选项
+18 -11
View File
@@ -1,19 +1,26 @@
package model
import "time"
import (
"time"
"gorm.io/gorm"
)
// CpTaskLog 任务执行日志
// 说明:DeletedAt 软删除字段——用户在客户端"删除"日志时仅标记 deleted_at,
// 前台日志列表不再展示,但后台统计(已执行次数/今日状态/执行总次数)仍计入。
type CpTaskLog struct {
ID uint `gorm:"primaryKey;autoIncrement" json:"id"`
CpTaskID uint `gorm:"not null;index" json:"cpTaskId"`
UserID uint `gorm:"not null;index" json:"userId"`
TaskType string `gorm:"type:varchar(32);not null" json:"taskType"` // sign / sleep_check
Title string `gorm:"type:varchar(255);not null" json:"title"` // 用户设置的任务标题(支持通配符)
RealTitle string `gorm:"type:varchar(255)" json:"realTitle"` // 本次执行实际匹配的标题
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"`
ID uint `gorm:"primaryKey;autoIncrement" json:"id"`
CpTaskID uint `gorm:"not null;index" json:"cpTaskId"`
UserID uint `gorm:"not null;index" json:"userId"`
TaskType string `gorm:"type:varchar(32);not null" json:"taskType"` // sign / sleep_check / return_register
Title string `gorm:"type:varchar(255);not null" json:"title"` // 用户设置的任务标题(支持通配符)
RealTitle string `gorm:"type:varchar(255)" json:"realTitle"` // 本次执行实际匹配的标题
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"`
DeletedAt gorm.DeletedAt `gorm:"index" json:"deletedAt"`
}
func (CpTaskLog) TableName() string {
+2 -2
View File
@@ -35,8 +35,8 @@ type SysUser struct {
MobileCode string `gorm:"type:varchar(255)" json:"mobileCode"`
// 设备码类型 random/authorize/manual
MobileType string `gorm:"type:varchar(32)" json:"mobileType"`
// 可签到次数
SignCount int `gorm:"type:int;default:0" json:"signCount"`
// 可签到次数(支持小数;每次打卡/请假/销假按后台配置的额度扣减,0 表示不扣费)
SignCount float64 `gorm:"type:decimal(12,2);default:0" json:"signCount"`
// VIP 到期时间(null=从未开通;VIP 有效期内执行任务免扣次数)
VipExpireAt *time.Time `gorm:"type:datetime" json:"vipExpireAt"`
// 邀请码
+13 -7
View File
@@ -2,17 +2,23 @@ package model
import (
"time"
"gorm.io/gorm"
)
// Ticket 工单
// 说明:DeletedAt 软删除字段——用户在客户端"删除"工单时仅标记 deleted_at,
// 用户端列表不再展示,但管理端仍可查看(并标记「用户已删除」),
// 保留工单内容与其全部消息记录,避免用户删除后抹掉处理痕迹。
type Ticket struct {
ID uint `gorm:"primaryKey;autoIncrement" json:"id"`
UserID uint `gorm:"not null;index" json:"userId"`
Title string `gorm:"size:200;not null" json:"title"`
Content string `gorm:"type:text;not null" json:"content"`
Status string `gorm:"size:20;not null;default:pending;index" json:"status"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
ID uint `gorm:"primaryKey;autoIncrement" json:"id"`
UserID uint `gorm:"not null;index" json:"userId"`
Title string `gorm:"size:200;not null" json:"title"`
Content string `gorm:"type:text;not null" json:"content"`
Status string `gorm:"size:20;not null;default:pending;index" json:"status"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
DeletedAt gorm.DeletedAt `gorm:"index" json:"deletedAt"`
}
func (Ticket) TableName() string {
@@ -9,6 +9,7 @@ type TicketNotification struct {
ID uint `gorm:"primaryKey;autoIncrement" json:"id"`
UserID uint `gorm:"not null;index" json:"userId"`
TicketID uint `gorm:"not null;index" json:"ticketId"`
Kind string `gorm:"type:varchar(16);not null;default:reply;index" json:"kind"` // reply(管理员回复) / status(状态变更)
Title string `gorm:"size:200;not null" json:"title"`
Content string `gorm:"size:500;not null" json:"content"`
IsRead bool `gorm:"not null;default:false;index" json:"isRead"`
@@ -0,0 +1,122 @@
package cpdaily
import "testing"
// evalFaceIdentify 判定人脸识别复核结果。
func TestEvalFaceIdentify(t *testing.T) {
cases := []struct {
name string
rows map[string]interface{}
wantFace bool
wantRejected bool
wantSettled bool
}{
{
name: "被系统驳回(FRS.0036)",
rows: map[string]interface{}{
"isFaceIdentify": float64(1),
"recognitionProgressCode": float64(2),
"recognitionProgress": "系统自动驳回。被驳回原因:网络连接故障,请重新尝试签到(FRS.0036)",
"signStatus": "0",
},
wantFace: true,
wantRejected: true,
wantSettled: true,
},
{
name: "人脸核验成功(progress=null/code=0, signStatus=4)",
rows: map[string]interface{}{
"isFaceIdentify": float64(1),
"recognitionProgressCode": nil,
"recognitionProgress": nil,
"signStatus": "4",
},
wantFace: true,
wantRejected: false,
wantSettled: true,
},
{
name: "尚未出结果(进行中, signStatus=0)",
rows: map[string]interface{}{
"isFaceIdentify": float64(1),
"recognitionProgressCode": float64(0),
"recognitionProgress": "",
"signStatus": "0",
},
wantFace: true,
wantRejected: false,
wantSettled: false,
},
{
name: "非人脸任务(无 isFaceIdentify)",
rows: map[string]interface{}{
"signStatus": "4",
},
wantFace: false,
wantRejected: false,
wantSettled: true,
},
{
name: "rows 为 nil",
rows: nil,
wantFace: false,
wantRejected: false,
wantSettled: false,
},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
got := evalFaceIdentify(c.rows)
if got.IsFaceIdentify != c.wantFace {
t.Fatalf("IsFaceIdentify=%v, want %v", got.IsFaceIdentify, c.wantFace)
}
if got.Rejected != c.wantRejected {
t.Fatalf("Rejected=%v, want %v", got.Rejected, c.wantRejected)
}
if got.Settled != c.wantSettled {
t.Fatalf("Settled=%v, want %v", got.Settled, c.wantSettled)
}
if c.wantRejected && got.Message == "" {
t.Fatal("驳回时 Message 不应为空")
}
})
}
}
// NeedsFaceIdentify 判断任务是否开启人脸识别。
func TestNeedsFaceIdentify(t *testing.T) {
if !NeedsFaceIdentify(map[string]interface{}{"isFaceIdentify": float64(1)}) {
t.Fatal("isFaceIdentify=1 应判为需要人脸识别")
}
if NeedsFaceIdentify(map[string]interface{}{"isFaceIdentify": float64(0)}) {
t.Fatal("isFaceIdentify=0 不应判为需要人脸识别")
}
if NeedsFaceIdentify(map[string]interface{}{"signStatus": "4"}) {
t.Fatal("无 isFaceIdentify 字段不应判为需要人脸识别")
}
if NeedsFaceIdentify(nil) {
t.Fatal("nil rows 不应判为需要人脸识别")
}
}
// rawCodeString 归一化 code 方言(字符串/数字/缺省)。
func TestRawCodeString(t *testing.T) {
cases := []struct {
in string
want string
}{
{`"0"`, "0"},
{`"5"`, "5"},
{`0`, "0"},
{`1`, "1"},
{``, ""},
{`null`, ""},
}
for _, c := range cases {
got := rawCodeString([]byte(c.in))
if got != c.want {
t.Fatalf("rawCodeString(%s)=%q, want %q", c.in, got, c.want)
}
}
}
+39 -2
View File
@@ -9,6 +9,7 @@ import (
"io"
"mime/multipart"
"net/textproto"
"net/url"
"strings"
"time"
@@ -30,6 +31,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:
@@ -69,7 +73,7 @@ func GetUploadPolicy(cookieMap map[string]string, host string, taskType TaskType
ErrMsg string `json:"errMsg"`
Datas *CpImageUploadPolicy `json:"datas"`
}
if err := json.Unmarshal(body, &result); err != nil {
if err := unmarshalBody(body, &result); err != nil {
return nil, fmt.Errorf("解析上传策略失败: %w", err)
}
if result.ErrCode != 0 || result.Datas == nil {
@@ -142,6 +146,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:
@@ -169,7 +176,7 @@ func GetPreviewUrl(cookieMap map[string]string, host string, taskType TaskType,
Message string `json:"message"`
Datas json.RawMessage `json:"datas"`
}
if err := json.Unmarshal(body, &result); err != nil {
if err := unmarshalBody(body, &result); err != nil {
return "", fmt.Errorf("解析预览 URL 响应失败: %w (body: %.200s)", err, string(body))
}
if result.ErrCode != 0 {
@@ -236,6 +243,36 @@ func IsDataURIImage(content string) bool {
return strings.HasPrefix(content, "data:image")
}
// OssKeyFromURL 从已存的 OBS 预览 URL 中提取稳定的对象 key(即 URL path,去掉前导 "/")。
// 预览 URL 形如:
//
// https://<host>/<key>?AccessKeyId=..&Expires=..&Signature=..
// https://<host>/<key>?sign=q-sign-algorithm%3D..
//
// 其中 query(AccessKeyId/Expires/Signature 或 sign)是**一次性签名**,会过期;
// 而 path(对象 key)指向同一张图、长期不变。取出 key 后可反复向预览接口换取
// 未过期的新 URL(同一张图,无需用户重传)。
func OssKeyFromURL(raw string) string {
u, err := url.Parse(strings.TrimSpace(raw))
if err != nil || u.Host == "" {
return ""
}
return strings.TrimPrefix(u.Path, "/")
}
// RefreshPreviewURL 用已存预览 URL 里稳定的对象 key,向预览接口重新换取一个未过期的 URL。
//
// 场景:签到照片的签名由云端签发、有效期很短(可能仅数分钟),而签到任务是持久化的。
// 用户建任务当天上传照片、隔日再执行时,原签名早已过期,直接提交会被学校判为签到失败
// (如兰州交大返回 2210010000 签到失败)。此处用对象 key 重签即可继续使用同一张照片。
func RefreshPreviewURL(cookieMap map[string]string, host string, taskType TaskType, storedURL string) (string, error) {
key := OssKeyFromURL(storedURL)
if key == "" {
return "", fmt.Errorf("无法从图片 URL 解析对象 key")
}
return GetPreviewUrl(cookieMap, host, taskType, key)
}
// UploadDataURIImage 将 base64 图片解码后走正常 OBS 上传流程,返回正式预览 URL。
// 用于执行时自动修复历史表单中残留的 base64 照片(用户上传失败时保存的内容)。
func UploadDataURIImage(cookieMap map[string]string, host string, taskType TaskType, dataURI string) (string, error) {
@@ -0,0 +1,71 @@
package cpdaily
import (
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
)
// OssKeyFromURL 从已存预览 URL 提取稳定对象 key(query 里的签名会过期,path 不会)。
func TestOssKeyFromURL(t *testing.T) {
cases := []struct {
name string
in string
want string
}{
{
name: "腾讯COS风格签名URL(真实样本)",
in: `https://wecres.campusphere.net/counselor/sign/1019669221816027/attachment/2e6daa899c974a2cbb71a3243c50ddb1?AccessKeyId=HPUAX9XCPLECZ7RMD26U&Expires=1791877432&Signature=r7nEh%2BauSO2d4lsga5fzPugOhzw%3D`,
want: "counselor/sign/1019669221816027/attachment/2e6daa899c974a2cbb71a3243c50ddb1",
},
{
name: "华为OBS风格 sign= 签名URL(真实样本)",
in: `https://counselor-cos-wecres.campusphere.net/counselor/sign/1019669221816027/attachment/730b54175bc249a783d69124e7ecb81e.png?sign=q-sign-algorithm%3Dsha1%26q-ak%3DAKIDMaaNyLVdgmr59AC6qZ7a5QjsnbIo8MOF`,
want: "counselor/sign/1019669221816027/attachment/730b54175bc249a783d69124e7ecb81e.png",
},
{name: "无query", in: "https://h.example.com/a/b/c.jpg", want: "a/b/c.jpg"},
{name: "非URL", in: "data:image/png;base64,AAAA", want: ""},
{name: "空串", in: "", want: ""},
}
for _, c := range cases {
if got := OssKeyFromURL(c.in); got != c.want {
t.Errorf("%s: OssKeyFromURL=%q, want %q", c.name, got, c.want)
}
}
}
// RefreshPreviewURL 用存储URL里的对象key重新换取未过期URL(走 mock 预览接口)。
func TestRefreshPreviewURL(t *testing.T) {
const key = "counselor/sign/1019669221816027/attachment/2e6daa899c974a2cbb71a3243c50ddb1"
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// 断言用的是提取出的 key,而非把整条旧URL再传回去
var body map[string]interface{}
_ = json.NewDecoder(r.Body).Decode(&body)
if got, _ := body["ossKey"].(string); got != key {
t.Errorf("预览接口收到 ossKey=%q, want %q", got, key)
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]interface{}{
"errCode": 0,
"datas": "https://wecres.campusphere.net/" + key + "?AccessKeyId=x&Expires=9999999999&Signature=y",
})
}))
defer srv.Close()
stored := "https://wecres.campusphere.net/" + key + "?AccessKeyId=old&Expires=1&Signature=old"
got, err := RefreshPreviewURL(map[string]string{}, srv.URL, TaskTypeSign, stored)
if err != nil {
t.Fatalf("RefreshPreviewURL 失败: %v", err)
}
if got == "" || got == stored {
t.Fatalf("应返回新的预览URL,实际: %q", got)
}
}
// key 解析不出时明确报错(不静默用坏URL提交)。
func TestRefreshPreviewURLBadInput(t *testing.T) {
if _, err := RefreshPreviewURL(map[string]string{}, "https://h.example.com", TaskTypeSign, "not-a-url"); err == nil {
t.Fatal("无法解析 key 时应报错")
}
}
+501
View File
@@ -0,0 +1,501 @@
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=已完成,已过滤)
// 状态文案(新套有明确文案映射;旧套为空,前端回退本地映射)
StatusText string `json:"statusText,omitempty"`
// 学校规定的销假位置(详情 reportPlaces)。有值时前端展示可选列表并预填经纬度;
// 无值时提示用户手动填写
ReportPlaces []LeaveReportPlace `json:"reportPlaces"`
// 销假是否需要选择审批人(leaveType.reportApplyFlow=1)
ReportApplyFlow bool `json:"reportApplyFlow"`
// 销假是否允许上传图片(leaveType.enableAttach=1)
EnableAttach bool `json:"enableAttach"`
// —— 续假(仅新套)——
CanReport bool `json:"canReport"` // 是否可销假(列表含仅可续假的单,前端销假模式据此过滤)
CanExtend bool `json:"canExtend"` // 是否可续假(休假中/逾期 且 isAllowExtend)
TypeWid string `json:"typeWid,omitempty"`
IsLeaveOut string `json:"isLeaveOut,omitempty"`
}
// 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
// IsAllowedExtend 旧套详情顶层字段:当前是否允许续假(列表接口不返回,只能查详情)
IsAllowedExtend bool
}
// 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()
respBody, err := io.ReadAll(resp.RawBody())
if err != nil {
return nil, err
}
// HTTP 状态码异常:学校网关/WAF 拦截或上游故障常返回无 body 的 4xx/5xx,
// 若不在这里拦截,上层会把空 body 报成无意义的 JSON 解析错误。
if code := resp.StatusCode(); code < 200 || code >= 300 {
return respBody, fmt.Errorf("学校服务端响应异常(HTTP %d): %s", code, strings.TrimSpace(string(respBody)))
}
// 状态码正常但 body 为空:多见于重复提交触发风控或网关瞬时故障
if len(respBody) == 0 {
return respBody, fmt.Errorf("学校服务端返回空响应(HTTP %d),可能触发风控或瞬时故障,请稍后重试", resp.StatusCode())
}
return respBody, nil
}
// 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 := unmarshalBody(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")
// 待销假列表只保留可销假的记录:
// 4=已完成、3=已驳回、10=逾期自动驳回 均无需(也不应)销假,直接过滤。
// 注:驳回状态若不过滤,已驳回的请假会出现在待销假列表中(用户无法对其销假)。
if status == "4" || status == "3" || status == "10" {
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,
// 旧套列表已过滤为可销假记录
CanReport: true,
// 旧套续假:列表行不含该字段,需由详情 detailOther 的 isAllowedExtend 判定(见下)
}
// 补充规定销假位置与开关(来自详情 reportPlaces / leaveType),以及可续假标记
if d, err := FetchLeaveDetail(cookieMap, host, id); err == nil {
item.CanExtend = leaveOldCanExtend(status, d.IsAllowedExtend)
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
}
// leaveOldCanExtend 旧套「是否可续假」判定:与销假同理,需同时满足
// 1. 状态处于假期中/待处理阶段(5=即将休假、6=正在休假中;其余 3 已驳回/4 已完成/10 逾期驳回等不可续);
// 2. 详情接口返回 isAllowedExtend=true(学校放开该单的续假)。
//
// 仅凭 isAllowedExtend 不够:已结束/驳回的单服务端也可能不返回该标志,但显式加状态判断更稳。
func leaveOldCanExtend(status string, allowedExtend bool) bool {
if !allowedExtend {
return false
}
switch status {
case "2", "5", "6": // 处理中/即将休假/正在休假中
return true
default:
return false
}
}
// 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 {
// 顶层 isAllowedExtend:当前是否允许续假(与 isExtend 不同,isExtend 表示是否为续假单)
IsAllowedExtend interface{} `json:"isAllowedExtend"`
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 := unmarshalBody(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),
IsAllowedExtend: leaveAnyBool(result.Datas.IsAllowedExtend),
}
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 := unmarshalBody(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 := unmarshalBody(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 {
// 优先透传学校返回的 message:该校 2210990000 是通用错误码,可能表示
// 「需要添加附件」「设备更换,无法查看,请联系管理老师」等多种原因,
// 不能一律翻译成"需要附件"(曾因此误导用户以为已上传附件仍报错)。
msg := strings.TrimSpace(result.Message)
if msg == "" || msg == "failed" {
if code == 2210990000 {
msg = "销假失败,请检查是否已按要求上传附件或设备信息是否有效,或联系管理老师"
} else {
msg = "未知错误"
}
}
return fmt.Errorf("%s", msg)
}
return nil
}
@@ -0,0 +1,404 @@
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=不限)
RequireSign int `json:"requireSign"` // 1=需先手写承诺书签名后提交
OutStatusDefault int `json:"outStatusDefault"` // 「是否离校」默认值(0=无默认 1=默认是 2=默认否;仅新套)
// —— 新套「附加信息」相关 ——
RequireEvidence int `json:"requireEvidence"` // 1=必须上传证明材料(isRequiredEvidenceFile)
EvidenceMin int `json:"evidenceMin"` // 证明材料最少张数
SelfDefineFields []map[string]interface{} `json:"selfDefineFields"` // 自定义必填字段(如「当天是否返校」),提交时回填 selfDefineAnswers
}
// 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 := unmarshalBody(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 := unmarshalBody(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 := unmarshalBody(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 // 定位地址
ImageURLs []string // 附件图片 URL 列表(可选,最多 4 张且不超学校上限)
ApproverWid string // 审批人 userWid(needApproval=1 且学校配置了候选审批人时必填;候选为空可不选)
ApproverId string // 审批人 userId
ApproverName string // 审批人姓名
CcUsers []LeaveApprover // 抄送人(非必填;候选取自审批人列表)
}
// LeaveMaxAttachCount 附件数量上限:学校配置为准,但产品上限 4 张。
// limit<=0(学校未配置)按 4;limit>4 收敛为 4;否则取学校值。
func LeaveMaxAttachCount(schoolLimit int) int {
if schoolLimit <= 0 || schoolLimit > 4 {
return 4
}
return schoolLimit
}
// 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 len(p.ImageURLs) > 0 {
attachs := make([]map[string]interface{}, 0, len(p.ImageURLs))
for _, u := range p.ImageURLs {
if u == "" {
continue
}
attachs = append(attachs, map[string]interface{}{"type": 1, "url": u})
}
if len(attachs) > 0 {
form["applyAttach"] = attachs
} else {
form["applyAttach"] = []interface{}{}
}
} 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 := unmarshalBody(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
}
// BuildLeaveExtendForm 构造旧套续假提交体(/saveExtend)。
// 续假体 = 普通请假申请体 + leaveId + extendReason + extendHolidayAutoDefineVos(见官方 home.*.js)。
// extendReason 复用 leaveReason;holiday 自动定义暂传空数组(与官方默认一致)。
func BuildLeaveExtendForm(p LeaveApplyParams, deviceId, systemVersion, appVersion, leaveID string) map[string]interface{} {
form := BuildLeaveApplyForm(p, deviceId, systemVersion, appVersion)
form["leaveId"] = leaveID
form["extendReason"] = p.LeaveReason
form["extendHolidayAutoDefineVos"] = []interface{}{}
// 续假(/leave/saveExtend)定位参数与普通请假不同:官方提交体把坐标放在**顶层**
// address/latitude/longitude(由前端 locationData 展开),而**不使用 location 子对象**。
// 此前沿用请假的 location:{...},服务端报「请假定位参数错误」。
// 实测官方 saveExtend 提交体字段:{...model, address, latitude, longitude, locationType, leaveId, extendReason, ...}
delete(form, "location")
form["address"] = p.LocationAddr
form["latitude"] = p.Latitude
form["longitude"] = p.Longitude
form["locationType"] = 0
return form
}
// SubmitLeaveExtend 提交旧套续假(POST /saveExtend,明文 JSON + Cpdaily-Extension 头)。
// 与 SubmitLeaveApply 同构,仅接口路径不同。
func SubmitLeaveExtend(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+"/saveExtend", form, extensionString)
if err != nil {
return fmt.Errorf("提交续假请求失败: %w", err)
}
var result struct {
Code json.Number `json:"code"`
Message string `json:"message"`
}
if err := unmarshalBody(body, &result); err != nil {
return fmt.Errorf("解析续假响应失败: %w, body: %s", err, string(body))
}
code, _ := result.Code.Int64()
slog.Info("leave/saveExtend 响应", "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,304 @@
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: "福建省厦门市思明区",
ImageURLs: []string{"https://obs.example.com/a.jpg", "https://obs.example.com/b.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) != 2 || attach[0]["url"] != "https://obs.example.com/a.jpg" || attach[1]["url"] != "https://obs.example.com/b.jpg" {
t.Errorf("多附件结构错误: %v", form["applyAttach"])
}
if attach[0]["type"] != 1 || attach[1]["type"] != 1 {
t.Errorf("附件 type 应为 1: %v", attach)
}
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)
}
}
// TestLeaveMaxAttachCount 附件数量上限:学校未配置/超 4 张收敛为 4,否则取学校值
func TestLeaveMaxAttachCount(t *testing.T) {
cases := []struct {
schoolLimit int
want int
}{
{0, 4}, // 未配置
{-1, 4}, // 异常值
{2, 2},
{4, 4},
{9, 4}, // 超产品上限
}
for _, c := range cases {
if got := LeaveMaxAttachCount(c.schoolLimit); got != c.want {
t.Errorf("LeaveMaxAttachCount(%d) = %d, want %d", c.schoolLimit, got, c.want)
}
}
}
// TestBuildLeaveApplyFormEmptyAttachURLs 多图字段中的空串应被剔除,全空时仍输出空数组
func TestBuildLeaveApplyFormEmptyAttachURLs(t *testing.T) {
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: "校内",
ImageURLs: []string{"", "https://obs.example.com/only.jpg", ""},
}, "device-1", "15", "9.9.6")
attach, ok := form["applyAttach"].([]map[string]interface{})
if !ok || len(attach) != 1 || attach[0]["url"] != "https://obs.example.com/only.jpg" {
t.Errorf("空串应被剔除: %v", form["applyAttach"])
}
form2 := 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: "校内",
ImageURLs: []string{"", ""},
}, "device-1", "15", "9.9.6")
if attach2, ok := form2["applyAttach"].([]interface{}); !ok || len(attach2) != 0 {
t.Errorf("全空时应为空数组: %v", form2["applyAttach"])
}
}
// 续假(旧套 saveExtend)提交体:坐标必须放顶层 latitude/longitude/address,
// 且不得携带请假用的 location 子对象(否则服务端报「请假定位参数错误」)。
func TestBuildLeaveExtendForm_LocationTopLevel(t *testing.T) {
p := LeaveApplyParams{
LeaveType: 12, StartTime: "2026-10-08 22:30", EndTime: "2026-10-10 22:30",
LeaveReason: "续假三天", OutStatus: false,
Longitude: "102.85939585", Latitude: "24.82989733", LocationAddr: "云南省昆明市呈贡区洋浦路",
}
form := BuildLeaveExtendForm(p, "dev-uuid", "15", "9.9.6", "3d917d58abcd")
if _, has := form["location"]; has {
t.Fatal("续假提交体不应包含 location 子对象")
}
if form["latitude"] != "24.82989733" || form["longitude"] != "102.85939585" {
t.Fatalf("坐标应在顶层 latitude/longitude,实际 lat=%v lon=%v", form["latitude"], form["longitude"])
}
if form["address"] != "云南省昆明市呈贡区洋浦路" {
t.Fatalf("address 应为定位地址,实际 %v", form["address"])
}
if form["leaveId"] != "3d917d58abcd" || form["extendReason"] != "续假三天" {
t.Fatalf("leaveId/extendReason 错误: %v / %v", form["leaveId"], form["extendReason"])
}
if _, has := form["extendHolidayAutoDefineVos"]; !has {
t.Fatal("应携带 extendHolidayAutoDefineVos")
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,165 @@
package cpdaily
import (
"testing"
)
// TestLeaveNewObsKeyOf 验证 obsKey 推导(与官方前端 AA 函数一致)
func TestLeaveNewObsKeyOf(t *testing.T) {
cases := []struct {
name string
url string
want string
}{
{"普通路径", "https://counselor-cos-wecres.campusphere.net/counselor/leave/1020385267004784/attachment/abc.jpeg?sign=xxx", "/counselor/leave/1020385267004784/attachment/abc.jpeg"},
{"newBucket形态", "https://cdn.example.com/foo/newBucket/leave/abc.png", "newBucket/leave/abc.png"},
{"空URL", "", ""},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
if got := LeaveNewObsKeyOf(c.url); got != c.want {
t.Errorf("LeaveNewObsKeyOf(%q) = %q, want %q", c.url, got, c.want)
}
})
}
}
// TestLeaveAppFlavorFromApps 验证应用列表 → 套件判定
func TestLeaveNewCanReport(t *testing.T) {
cases := []struct {
status, reportMode string
want bool
}{
{"3", "1", true},
{"4", "1", true},
{"3", "2", true},
{"4", "2", true},
{"3", "0", false},
{"4", "3", false},
{"2", "1", false},
{"7", "1", false},
}
for _, c := range cases {
got := leaveNewCanReport(LeaveNewItem{Status: c.status, ReportMode: c.reportMode})
if got != c.want {
t.Errorf("canReport(status=%s, reportMode=%s) = %v, want %v", c.status, c.reportMode, got, c.want)
}
}
}
// TestLeaveNewStatusText 验证状态文案映射完整
func TestLeaveNewStatusText(t *testing.T) {
for _, code := range []string{"1", "2", "3", "4", "5", "6", "7", "8", "9", "10"} {
if LeaveNewStatusText[code] == "" {
t.Errorf("status %s 缺少文案映射", code)
}
}
}
// TestBuildLeaveNewReportForm 验证销假表单结构(含确认人与附件)
func TestBuildLeaveNewReportForm(t *testing.T) {
approver := &LeaveNewApprover{UserWid: "92271929", UserId: "2022110006", UserName: "张文烨", RoleCode: "1003", RoleName: "辅导员", DeptName: "智能制造学院"}
form := BuildLeaveNewReportForm(LeaveNewReportParams{
LeaveID: "e1529da9d4c94341b0b1f7f2278eb2ab",
Address: "泰州学院",
Longitude: "119.948267",
Latitude: "32.474102",
Approver: approver,
})
if form["leaveId"] != "e1529da9d4c94341b0b1f7f2278eb2ab" {
t.Errorf("leaveId 缺失: %v", form["leaveId"])
}
loc, ok := form["location"].(map[string]interface{})
if !ok || loc["address"] != "泰州学院" {
t.Errorf("location 结构错误: %v", form["location"])
}
if form["approverWid"] != "92271929" || form["approverRoleCode"] != "1003" {
t.Errorf("确认人字段错误: approverWid=%v approverRoleCode=%v", form["approverWid"], form["approverRoleCode"])
}
if attachs, ok := form["attachs"].([]interface{}); !ok || len(attachs) != 0 {
t.Errorf("无附件时 attachs 应为空数组: %v", form["attachs"])
}
form2 := BuildLeaveNewReportForm(LeaveNewReportParams{
LeaveID: "x", Address: "a", Longitude: "1", Latitude: "2",
ImageURL: "https://cos.example.com/counselor/leave/1/attachment/a.jpeg?sign=1",
})
attachs2 := form2["attachs"].([]map[string]interface{})
if len(attachs2) != 1 || attachs2[0]["obsKey"] != "/counselor/leave/1/attachment/a.jpeg" {
t.Errorf("附件结构错误: %v", attachs2)
}
}
// TestBuildLeaveNewApplyForm 验证请假表单结构(离校/不离校两分支 + 抄送人)
func TestBuildLeaveNewApplyForm(t *testing.T) {
approver := &LeaveNewApprover{UserWid: "92271929", UserId: "2022110006", UserName: "张文烨"}
form := BuildLeaveNewApplyForm(LeaveNewApplyParams{
TypeWid: 8214,
BeginTime: "2026-09-16 08:00",
EndTime: "2026-09-16 18:00",
LeaveReason: "测试原因",
LocationAddr: "泰州学院",
Longitude: "119.9",
Latitude: "32.4",
IsLeaveOut: 1,
Destination: "江苏省/南京市/玄武区",
Detail: "某地",
EmergencyName: "张三",
EmergencyMobile: "13800000000",
Approver: approver,
CcUsers: []LeaveNewApprover{{UserWid: "1", UserId: "s1", UserName: "李四", RoleCode: "1003"}},
ImageURLs: []string{
"https://cos.example.com/counselor/leave/1/attachment/a.jpeg?sign=1",
"https://cos.example.com/counselor/leave/1/attachment/b.png?sign=2",
},
})
if form["typeWid"] != 8214 {
t.Errorf("typeWid 错误: %v", form["typeWid"])
}
attachs := form["attachs"].([]map[string]interface{})
if len(attachs) != 2 {
t.Fatalf("多附件应为 2 张: %v", attachs)
}
if attachs[0]["attachUrl"] != "https://cos.example.com/counselor/leave/1/attachment/a.jpeg?sign=1" ||
attachs[0]["obsKey"] != "/counselor/leave/1/attachment/a.jpeg" {
t.Errorf("第 1 张附件结构错误: %v", attachs[0])
}
if attachs[1]["attachUrl"] != "https://cos.example.com/counselor/leave/1/attachment/b.png?sign=2" ||
attachs[1]["obsKey"] != "/counselor/leave/1/attachment/b.png" {
t.Errorf("第 2 张附件结构错误: %v", attachs[1])
}
if attachs[0]["attachType"] != 1 || attachs[1]["attachType"] != 1 {
t.Errorf("attachType 应为 1: %v", attachs)
}
lo := form["leaveOutInfo"].(map[string]interface{})
if lo["isLeaveOut"] != 1 || lo["destination"] != "江苏省/南京市/玄武区" || lo["emergencyConcat"] != "张三" {
t.Errorf("leaveOutInfo 错误: %v", lo)
}
cc := form["ccObjects"].([]map[string]interface{})
if len(cc) != 1 || cc[0]["userWid"] != "1" || cc[0]["source"] != "2" {
t.Errorf("ccObjects 错误: %v", cc)
}
// 不离校分支:离校字段应清空
form2 := BuildLeaveNewApplyForm(LeaveNewApplyParams{TypeWid: 8216, IsLeaveOut: 0, Destination: "不该出现", EmergencyMobile: "138"})
lo2 := form2["leaveOutInfo"].(map[string]interface{})
if lo2["destination"] != "" || lo2["emergencyMobile"] != "" {
t.Errorf("不离校时离校字段应清空: %v", lo2)
}
if attachs2, ok := form2["attachs"].([]map[string]interface{}); !ok || len(attachs2) != 0 {
t.Errorf("无附件时 attachs 应为空数组: %v", form2["attachs"])
}
}
// TestLeaveNewResultErr 验证响应码解析(code 为字符串 "0")
func TestLeaveNewResultErr(t *testing.T) {
if _, err := leaveNewResultErr([]byte(`{"code":"0","message":"SUCCESS","datas":{}}`), "测试"); err != nil {
t.Errorf("code=0 不应报错: %v", err)
}
if _, err := leaveNewResultErr([]byte(`{"code":"-1","message":"参数错误"}`), "测试"); err == nil || err.Error() != "参数错误" {
t.Errorf("非 0 应返回 message: %v", err)
}
if _, err := leaveNewResultErr([]byte(`<html>404</html>`), "测试"); err == nil {
t.Error("非 JSON 应报错")
}
}
@@ -0,0 +1,239 @@
package cpdaily
import (
"encoding/json"
"fmt"
"io"
"log/slog"
"strconv"
"strings"
)
// ========== 节假日离返校(wec-counselor-leave-return-apps)返校登记 ==========
//
// 该应用为部分学校(如合肥大学)的"节假日离返校"应用,学生端流程为:
// 离校登记(登记假期去向)→ 假期结束返校时「返校登记 / 确认到达」(提交返校位置)。
// 本文件只覆盖「返校登记/确认到达」:列出待办 + 提交返校位置。
//
// 接口(明文 JSON,Header 与签到一致:CpdailyClientType: CPDAILY):
// POST /stu/leave/queryReturnConfigList 返校确认位置配置(校区坐标 + 半径)
// POST /stu/leave/queryStuRegisterList 登记列表(hasHistory=false 取当前待办)
// POST /stu/leave/submitStuReturnRegisterInfo 提交返校登记
//
// 待办判定:status=="1"(返校登记阶段)且 returnRegisterInfo==null(尚未登记返校)。
const leaveReturnAPIBase = "/wec-counselor-leave-return-apps"
// LeaveReturnPlace 返校确认位置(校区坐标 + 允许半径)
type LeaveReturnPlace struct {
Wid int `json:"wid"`
Address string `json:"address"`
Longitude string `json:"longitude"`
Latitude string `json:"latitude"`
Scope float64 `json:"scope"` // 允许半径(米)
}
// LeaveReturnTask 待办返校登记(一名学生在一期离返校任务中的返校登记)
type LeaveReturnTask struct {
TaskWid int `json:"taskWid"`
StuWid int `json:"stuWid"`
LeaveName string `json:"leaveName"` // 如「2026年中秋节离返校」
StartTime string `json:"startTime"` // 假期开始
EndTime string `json:"endTime"` // 假期结束
SignType int `json:"signType"` // 1=范围内 2=范围外(提交时按坐标计算)
Disclaimer string `json:"disclaimer"` // 承诺书文案(透传给前端展示)
}
// leaveReturnPost 通用 POST(明文 JSON,与签到同 Header)
func leaveReturnPost(cookieMap map[string]string, url string, body interface{}) ([]byte, error) {
client := newTaskClient()
resp, err := 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)).
SetBody(body).
Post(url)
if err != nil {
return nil, err
}
defer resp.RawBody().Close()
respBody, err := io.ReadAll(resp.RawBody())
if err != nil {
return nil, err
}
if code := resp.StatusCode(); code < 200 || code >= 300 {
return respBody, fmt.Errorf("学校服务端响应异常(HTTP %d): %s", code, strings.TrimSpace(string(respBody)))
}
if len(respBody) == 0 {
return respBody, fmt.Errorf("学校服务端返回空响应(HTTP %d)", resp.StatusCode())
}
return respBody, nil
}
// FetchLeaveReturnPlaces 返校确认位置配置(取第一条;该校通常一个校区)
func FetchLeaveReturnPlaces(cookieMap map[string]string, host string) ([]LeaveReturnPlace, error) {
body, err := leaveReturnPost(cookieMap, host+leaveReturnAPIBase+"/stu/leave/queryReturnConfigList", map[string]interface{}{
"pageNumber": 1,
"pageSize": 20,
})
if err != nil {
return nil, fmt.Errorf("获取返校位置配置失败: %w", err)
}
var result struct {
Code string `json:"code"`
Datas struct {
Rows []LeaveReturnPlace `json:"rows"`
} `json:"datas"`
}
if err := unmarshalBody(body, &result); err != nil {
return nil, fmt.Errorf("解析返校位置配置失败: %w (body: %.200s)", err, string(body))
}
if result.Code != "0" {
return nil, fmt.Errorf("返校位置配置接口返回异常(code=%s)", result.Code)
}
return result.Datas.Rows, nil
}
// leaveReturnListRow queryStuRegisterList 行
type leaveReturnListRow struct {
TaskWid int `json:"taskWid"`
StuWid int `json:"stuWid"`
Status string `json:"status"`
LeaveName string `json:"leaveName"`
StartTime string `json:"startTime"`
EndTime string `json:"endTime"`
ReturnSignType string `json:"returnSignType"`
Disclaimer string `json:"disclaimer"`
ReturnRegisterInfo json.RawMessage `json:"returnRegisterInfo"`
}
// FetchLeaveReturnPending 待办返校登记:status=="1" 且 returnRegisterInfo 为空的行。
// hasHistory=false 表示只取当前(未归档)任务。
func FetchLeaveReturnPending(cookieMap map[string]string, host string) ([]LeaveReturnTask, error) {
body, err := leaveReturnPost(cookieMap, host+leaveReturnAPIBase+"/stu/leave/queryStuRegisterList", map[string]interface{}{
"hasHistory": false,
"pageNumber": 1,
"pageSize": 20,
})
if err != nil {
return nil, fmt.Errorf("获取返校登记列表失败: %w", err)
}
var result struct {
Code string `json:"code"`
Datas struct {
Rows []leaveReturnListRow `json:"rows"`
} `json:"datas"`
}
if err := unmarshalBody(body, &result); err != nil {
return nil, fmt.Errorf("解析返校登记列表失败: %w (body: %.200s)", err, string(body))
}
if result.Code != "0" {
return nil, fmt.Errorf("返校登记列表接口返回异常(code=%s)", result.Code)
}
return leaveReturnTasksFromRows(result.Datas.Rows), nil
}
// leaveReturnTasksFromRows 从列表行中筛出待办返校登记:
// status=="1"(返校登记阶段)且 returnRegisterInfo 为空(尚未登记返校)。
func leaveReturnTasksFromRows(rows []leaveReturnListRow) []LeaveReturnTask {
out := make([]LeaveReturnTask, 0)
for _, r := range rows {
if r.Status != "1" {
continue
}
ri := strings.TrimSpace(string(r.ReturnRegisterInfo))
if ri != "" && ri != "null" {
continue // 已登记返校
}
signType := 1
if strings.TrimSpace(r.ReturnSignType) == "2" {
signType = 2
}
out = append(out, LeaveReturnTask{
TaskWid: r.TaskWid,
StuWid: r.StuWid,
LeaveName: r.LeaveName,
StartTime: r.StartTime,
EndTime: r.EndTime,
SignType: signType,
Disclaimer: r.Disclaimer,
})
}
return out
}
// LeaveReturnSignWidOf 从返校登记任务派生 SignWid("taskWid:stuWid",供统一任务体系存取)
func LeaveReturnSignWidOf(taskWid, stuWid int) string {
return fmt.Sprintf("%d:%d", taskWid, stuWid)
}
// ParseLeaveReturnSignWid 解析 SignWid → (taskWid, stuWid)
func ParseLeaveReturnSignWid(signWid string) (int, int, error) {
parts := strings.SplitN(strings.TrimSpace(signWid), ":", 2)
if len(parts) != 2 {
return 0, 0, fmt.Errorf("返校登记标识格式错误: %q", signWid)
}
taskWid, err1 := strconv.Atoi(parts[0])
stuWid, err2 := strconv.Atoi(parts[1])
if err1 != nil || err2 != nil {
return 0, 0, fmt.Errorf("返校登记标识解析失败: %q", signWid)
}
return taskWid, stuWid, nil
}
// FetchReturnRegisterTasks 返校登记待办 → 统一任务项(供打卡任务标题来源)。
// TaskName=假期名;SignWid="taskWid:stuWid"。
func FetchReturnRegisterTasks(cookieMap map[string]string, host string) ([]TodayTaskInfo, error) {
pending, err := FetchLeaveReturnPending(cookieMap, host)
if err != nil {
return nil, err
}
out := make([]TodayTaskInfo, 0, len(pending))
for _, t := range pending {
out = append(out, TodayTaskInfo{
TaskName: t.LeaveName,
SignWid: LeaveReturnSignWidOf(t.TaskWid, t.StuWid),
IsSigned: false,
TaskType: string(TaskTypeReturnRegister),
})
}
return out, nil
}
// SubmitLeaveReturnRegister 提交返校登记(确认到达)
// taskWid/stuWid 取自列表;address/longitude/latitude 为返校位置。
// 范围内使用配置坐标(signType=1);范围外(signType=2)须填原因。
func SubmitLeaveReturnRegister(cookieMap map[string]string, host string, taskWid, stuWid int, address, longitude, latitude string, signType int, outsideReason string) error {
payload := map[string]interface{}{
"taskWid": taskWid,
"stuWid": stuWid,
"returnAddress": address,
"returnLongitude": longitude,
"returnLatitude": latitude,
"returnSignType": fmt.Sprintf("%d", signType),
}
if signType == 2 {
payload["outsideSignReason"] = outsideReason
}
body, err := leaveReturnPost(cookieMap, host+leaveReturnAPIBase+"/stu/leave/submitStuReturnRegisterInfo", payload)
if err != nil {
return fmt.Errorf("提交返校登记失败: %w", err)
}
var result struct {
Code string `json:"code"`
Message string `json:"message"`
}
if err := unmarshalBody(body, &result); err != nil {
return fmt.Errorf("解析返校登记响应失败: %w (body: %.300s)", err, string(body))
}
slog.Info("leave-return/submit 响应", "code", result.Code, "message", result.Message)
if result.Code != "0" {
msg := strings.TrimSpace(result.Message)
if msg == "" || msg == "failed" {
msg = "返校登记失败,请稍后重试"
}
return fmt.Errorf("%s", msg)
}
return nil
}
@@ -0,0 +1,40 @@
package cpdaily
import "testing"
// TestLeaveReturnSignWidRoundTrip 任务标识 "taskWid:stuWid" 编解码
func TestLeaveReturnSignWidRoundTrip(t *testing.T) {
s := LeaveReturnSignWidOf(25, 471179)
if s != "25:471179" {
t.Fatalf("LeaveReturnSignWidOf = %q", s)
}
tw, sw, err := ParseLeaveReturnSignWid(s)
if err != nil || tw != 25 || sw != 471179 {
t.Fatalf("ParseLeaveReturnSignWid(%q) = %d,%d,%v", s, tw, sw, err)
}
if _, _, err := ParseLeaveReturnSignWid("bad"); err == nil {
t.Fatalf("非法标识应报错")
}
}
// TestLeaveReturnTasksFromRows 待办筛选:仅 status=1 且 returnRegisterInfo 为空才算待办。
func TestLeaveReturnTasksFromRows(t *testing.T) {
rows := []leaveReturnListRow{
{TaskWid: 25, StuWid: 471179, Status: "1", LeaveName: "2026年中秋节离返校", ReturnRegisterInfo: []byte("null")}, // 待办
{TaskWid: 25, StuWid: 471179, Status: "0", LeaveName: "2026年中秋节离返校"}, // 非返校阶段
{TaskWid: 24, StuWid: 455702, Status: "1", LeaveName: "26年暑假离返校",
ReturnRegisterInfo: []byte(`{"returnTime":"2026-09-04 11:21"}`)}, // 已登记
{TaskWid: 22, StuWid: 1, Status: "1", LeaveName: "五一离返校",
ReturnSignType: "2"}, // 待办,范围外
}
got := leaveReturnTasksFromRows(rows)
if len(got) != 2 {
t.Fatalf("期望 2 条待办, got %d: %+v", len(got), got)
}
if got[0].TaskWid != 25 || got[0].StuWid != 471179 || got[0].SignType != 1 {
t.Errorf("第 1 条待办不符: %+v", got[0])
}
if got[1].TaskWid != 22 || got[1].SignType != 2 {
t.Errorf("第 2 条待办(范围外)不符: %+v", got[1])
}
}
@@ -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"])
}
}
@@ -0,0 +1,299 @@
package cpdaily
import (
"bytes"
"context"
"encoding/json"
"fmt"
"net/url"
"strconv"
"time"
"github.com/go-resty/resty/v2"
)
// ========== 金智 xsfw 学工平台「学生请假」应用(xsfw) ==========
//
// 适用学校:云南警官学院(xg.ynpc.edu.cn/xsfwdt/sys/xsqjapp)等使用
// 金智 wisedu xsfw/EMAP 学工平台的学校。辅导猫侧请假(wec-counselor-leave-*/)
// 在这些学校为 HTTP 420「产品已到期」,与该应用无关。
//
// 链路(只读部分已实测跑通):
//
// CAS 登录 https://authserver.<domain>/authserver/login?service=... → MOD_AUTH_CAS
// 应用首页 {host}/xsfwdt/sys/xsqjapp/*default/index.do → WIS_CONFIG{APPID,APPNAME,TITLE:"学生请假"}
// 请假列表 POST {host}/xsfwdt/sys/xsqjapp/modules/wdqj/wdqjbg.do → requestParamStr=<JSON>
// 请假详情 GET {host}/xsfwdt/sys/xsqjapp/modules/wdqj/qjxqbd.do?*json=1&SQBH=<申请编号>
// 请假类型 POST {host}/xsfwdt/sys/xsqjapp/modules/leaveApply/getLeaveType.do → {"XSBH":"<学号>"}
// 审批路由 POST {host}/xsfwdt/sys/xsqjapp/modules/leaveApply/getCaseByType.do → {"QJLX":..,"QJTS":..}
// 配额 POST {host}/xsfwdt/sys/xsqjapp/modules/leaveApply/getAppliedRss.do
// 已请天数 POST {host}/xsfwdt/sys/xsqjapp/modules/leaveApply/getAppliedDays.do
//
// 注意:参数名必须是 requestParamStr(不是 requestParams,后者返回 HTML 错误页)。
// XsfwSchoolIDs 使用金智 xsfw 学工平台独立请假应用的学校 ID 集合
var XsfwSchoolIDs = map[string]bool{
"2970d544-2917-44e6-85db-f3245d6e3b1d": true, // 云南警官学院
}
// IsXsfwSchool 判断学校是否使用金智 xsfw 学工请假应用
func IsXsfwSchool(schoolID string) bool { return XsfwSchoolIDs[schoolID] }
// xsfwAppRoot 返回该校「学生请假」应用根路径(含协议与端口)
func xsfwAppRoot(schoolID string) string {
switch schoolID {
case "2970d544-2917-44e6-85db-f3245d6e3b1d": // 云南警官学院
// 该校 https 会被 TLS 重置(校内白名单),必须用 http
return "http://xg.ynpc.edu.cn/xsfwdt/sys/xsqjapp"
default:
return ""
}
}
// XsfwSession 学工平台会话(依赖 CAS 会话 cookie)
type XsfwSession struct {
cookieMap map[string]string
root string
client *resty.Client
}
// NewXsfwSession 用已有 cookie(含 MOD_AUTH_CAS/_WEU)构造会话
func NewXsfwSession(schoolID string, cookieMap map[string]string) (*XsfwSession, error) {
root := xsfwAppRoot(schoolID)
if root == "" {
return nil, fmt.Errorf("该校未配置学工请假应用地址(schoolId=%s)", schoolID)
}
client := resty.New().SetTimeout(30*time.Second).
// 该平台对 UA 敏感:空/默认 UA 会被重定向到 CAS 登录页(返回 HTML)
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").
SetHeader("Accept", "application/json, text/javascript, */*; q=0.01").
SetHeader("X-Requested-With", "XMLHttpRequest").
// 会话靠 cookie 传递:统一挂到 client 上,避免每个请求手写时漏带
SetHeader("Cookie", cookiesToStr(cookieMap))
return &XsfwSession{
cookieMap: cookieMap,
root: root,
client: client,
}, nil
}
func (s *XsfwSession) cookieStr() string {
return cookiesToStr(s.cookieMap)
}
// xsfwUnmarshal 解析学工平台响应。
// 该校响应可能带**多重** UTF-8 BOM(实测 \xEF\xBB\xBF 重复两次),
// 标准 json.Unmarshal 会报 "Unexpected UTF-8 BOM" / "invalid character 'ï'",
// 故循环剥离所有前导 BOM 后再解析。
func xsfwUnmarshal(body []byte, v interface{}) error {
b := body
// 反复剔除前导空白与 BOM,直到不再变化(两者可能交替出现)
for {
prev := len(b)
b = bytes.TrimLeft(b, " \t\r\n")
b = bytes.TrimPrefix(b, []byte{0xEF, 0xBB, 0xBF})
if len(b) == prev {
break
}
}
return json.Unmarshal(b, v)
}
// callModule 以 requestParamStr 形式调用应用模块接口
func (s *XsfwSession) callModule(ctx context.Context, module, action string, params map[string]interface{}) ([]byte, error) {
raw, err := json.Marshal(params)
if err != nil {
return nil, err
}
resp, err := s.client.R().SetContext(ctx).
SetHeader("Cookie", s.cookieStr()).
SetHeader("Content-Type", "application/x-www-form-urlencoded; charset=UTF-8").
SetHeader("X-Requested-With", "XMLHttpRequest").
SetHeader("Referer", s.root+"/*default/index.do").
SetBody("requestParamStr=" + url.QueryEscape(string(raw))).
Post(fmt.Sprintf("%s/modules/%s.do", s.root, module))
if err != nil {
return nil, err
}
return resp.Body(), nil
}
// XsfwFetchLeaveList 查询我的请假列表(wdqjbg,只读)
func (s *XsfwSession) XsfwFetchLeaveList(ctx context.Context, page, size int) ([]map[string]interface{}, int, error) {
if page <= 0 {
page = 1
}
if size <= 0 {
size = 20
}
body, err := s.callModule(ctx, "wdqj/wdqjbg", "", map[string]interface{}{
"pageNumber": page,
"pageSize": size,
"querySetting": "[]",
"action": "QUERY",
})
if err != nil {
return nil, 0, err
}
var result struct {
TotalSize int `json:"totalSize"`
Rows []map[string]interface{} `json:"rows"`
}
if err := xsfwUnmarshal(body, &result); err != nil {
return nil, 0, fmt.Errorf("解析请假列表失败: %w", err)
}
return result.Rows, result.TotalSize, nil
}
// XsfwFetchLeaveTypes 查询可请的请假类型(getLeaveType,必须传学号)
func (s *XsfwSession) XsfwFetchLeaveTypes(ctx context.Context, studentID string) ([]map[string]interface{}, error) {
body, err := s.callModule(ctx, "leaveApply/getLeaveType", "", map[string]interface{}{
"XSBH": studentID,
})
if err != nil {
return nil, err
}
var result struct {
Rows []map[string]interface{} `json:"rows"`
}
if err := xsfwUnmarshal(body, &result); err != nil {
return nil, fmt.Errorf("解析请假类型失败: %w", err)
}
return result.Rows, nil
}
// XsfwFetchAppliedDays 查询已请天数与上限(getAppliedDays)
func (s *XsfwSession) XsfwFetchAppliedDays(ctx context.Context) (map[string]interface{}, error) {
body, err := s.callModule(ctx, "leaveApply/getAppliedDays", "", map[string]interface{}{})
if err != nil {
return nil, err
}
var result map[string]interface{}
if err := xsfwUnmarshal(body, &result); err != nil {
return nil, fmt.Errorf("解析已请天数失败: %w", err)
}
return result, nil
}
// XsfwFetchLeaveDetail 查询请假详情(qjxqbd,按申请编号)
func (s *XsfwSession) XsfwFetchLeaveDetail(ctx context.Context, applyNo string) (map[string]interface{}, error) {
resp, err := s.client.R().SetContext(ctx).
SetHeader("Cookie", s.cookieStr()).
SetHeader("X-Requested-With", "XMLHttpRequest").
SetHeader("Referer", s.root+"/*default/index.do").
SetQueryParams(map[string]string{"*json": "1", "SQBH": applyNo}).
Get(s.root + "/modules/wdqj/qjxqbd.do")
if err != nil {
return nil, err
}
var result map[string]interface{}
if err := xsfwUnmarshal(resp.Body(), &result); err != nil {
return nil, fmt.Errorf("解析请假详情失败: %w", err)
}
return result, nil
}
// XsfwLogin 建立学工应用会话。
//
// 关键(已实测确认):该校学工应用 `xg.ynpc.edu.cn` 与辅导猫 `ynpc.campusphere.net`
// 是两个不同的 cookie 域,辅导猫登录拿到的 `MOD_AUTH_CAS`(值形如 `ST-iap:...`)
// 在学工域**不被认可**,直接用会 302 回 CAS 登录页。
// 正确做法:用辅导猫登录得到的 **CASTGC**(CAS 的 TGT)到 authserver 重新换一次
// service 指向学工应用的票据,回调后即可在 xg 域建立会话,应用首页返回完整 HTML
// (含 WIS_CONFIG: APPID=4810794463325921, APPNAME=xsqjapp)。
//
// baseCookies:辅导猫登录后的 cookie(必须含 CASTGC)。
func XsfwLogin(ctx context.Context, schoolID string, baseCookies map[string]string) (*XsfwSession, error) {
root := xsfwAppRoot(schoolID)
if root == "" {
return nil, fmt.Errorf("该校未配置学工请假应用地址(schoolId=%s)", schoolID)
}
castgc := baseCookies["CASTGC"]
if castgc == "" {
return nil, fmt.Errorf("缺少 CASTGC,无法换取学工应用会话")
}
service := root + "/*default/index.do"
host := xsfwCasBase(schoolID)
ua := "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"
// 1. 用 CASTGC 换票,只取 302 的 Location。
// resty 在 NoRedirectPolicy 下遇到 302 会返回 error,但响应本身有效
// (StatusCode/Location 都可读),因此这里忽略该 error。
jar := newCookieJar()
casClient := resty.New().SetTimeout(30*time.Second).
SetCookieJar(jar).
SetRedirectPolicy(resty.NoRedirectPolicy()).
SetHeader("User-Agent", ua).
SetHeader("Cookie", "CASTGC="+castgc)
loginURL := host + "/login?service=" + url.QueryEscape(service)
resp, err := casClient.R().SetContext(ctx).Get(loginURL)
if resp == nil {
return nil, fmt.Errorf("请求 CAS 失败: %w", err)
}
loc := resp.Header().Get("Location")
if loc == "" && resp.RawResponse != nil {
loc = resp.RawResponse.Header.Get("Location")
}
if loc == "" {
return nil, fmt.Errorf("未取得 CAS 回调地址(CASTGC 可能已失效,status=%d)", resp.StatusCode())
}
// 2. 跟随回调:另一个 client(允许重定向),共享同一个 jar 以收集全链路 cookie。
// 注意 MOD_AUTH_CAS/_WEU 常在中间跳 302 时下发,只取最终响应会丢失。
cbClient := resty.New().SetTimeout(30*time.Second).
SetCookieJar(jar).
SetRedirectPolicy(resty.FlexibleRedirectPolicy(20)).
SetHeader("User-Agent", ua).
SetHeader("Cookie", "CASTGC="+castgc)
cb, err := cbClient.R().SetContext(ctx).Get(loc)
if err != nil {
return nil, fmt.Errorf("跟随 CAS 回调失败: %w", err)
}
if cb.StatusCode() != 200 || len(cb.Body()) == 0 {
return nil, fmt.Errorf("学工应用入口无内容(status=%d len=%d)", cb.StatusCode(), len(cb.Body()))
}
// 3. 从 jar 取全链路 cookie
sessionCookies := map[string]string{}
for _, c := range jar.AllCookies() {
sessionCookies[c.Name] = c.Value
}
sessionCookies["CASTGC"] = castgc
apiClient := resty.New().SetTimeout(30*time.Second).
SetHeader("User-Agent", ua).
SetHeader("Accept", "application/json, text/javascript, */*; q=0.01").
SetHeader("X-Requested-With", "XMLHttpRequest").
SetHeader("Cookie", cookiesToStr(sessionCookies))
return &XsfwSession{
cookieMap: sessionCookies,
root: root,
client: apiClient,
}, nil
}
// xsfwCasBase 返回该校 CAS 服务根地址
func xsfwCasBase(schoolID string) string {
switch schoolID {
case "2970d544-2917-44e6-85db-f3245d6e3b1d": // 云南警官学院
return "https://authserver.ynpc.edu.cn/authserver"
default:
return ""
}
}
// XsfwCasEntry 返回该校 xsfw 请假应用的 CAS 登录入口。
func XsfwCasEntry(schoolID string) string {
root := xsfwAppRoot(schoolID)
if root == "" {
return ""
}
return root + "/*default/index.do"
}
// XsfwPage 分页辅助
func XsfwPage(page, size int) string {
return strconv.Itoa(page) + "/" + strconv.Itoa(size)
}
@@ -0,0 +1,58 @@
package cpdaily
import "testing"
// TestIsXsfwSchool ynpc 走 xsfw 学工平台,其他学校不走。
func TestIsXsfwSchool(t *testing.T) {
const ynpc = "2970d544-2917-44e6-85db-f3245d6e3b1d"
if !IsXsfwSchool(ynpc) {
t.Fatal("云南警官学院应走 xsfw 学工平台")
}
if IsXsfwSchool("ynu") {
t.Fatal("普通学校不应走 xsfw")
}
if xsfwAppRoot(ynpc) == "" {
t.Fatal("ynpc 学工应用地址未配置")
}
if xsfwCasBase(ynpc) == "" {
t.Fatal("ynpc CAS 地址未配置")
}
if XsfwCasEntry(ynpc) != "http://xg.ynpc.edu.cn/xsfwdt/sys/xsqjapp/*default/index.do" {
t.Fatalf("xsfw CAS 入口错误: %s", XsfwCasEntry(ynpc))
}
}
// TestXsfwUnmarshal 学工平台响应带多重 UTF-8 BOM,必须能正确解析。
func TestXsfwUnmarshal(t *testing.T) {
bom := []byte{0xEF, 0xBB, 0xBF}
cases := []struct {
name string
in []byte
}{
{"无BOM", []byte(`{"totalSize":8}`)},
{"单BOM", append(append([]byte{}, bom...), []byte(`{"totalSize":8}`)...)},
{"双BOM", append(append(append([]byte{}, bom...), bom...), []byte(`{"totalSize":8}`)...)},
{"BOM+前后空白", append(append(append([]byte{0x20}, bom...), bom...), []byte(" {\"totalSize\":8} ")...)},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
var v struct {
TotalSize int `json:"totalSize"`
}
if err := xsfwUnmarshal(c.in, &v); err != nil {
t.Fatalf("解析失败: %v (input=%q)", err, string(c.in))
}
if v.TotalSize != 8 {
t.Fatalf("totalSize=%d, want 8", v.TotalSize)
}
})
}
}
// TestXsfwUnmarshalHTML 非 JSON(登录页 HTML)应报错而非静默成功。
func TestXsfwUnmarshalHTML(t *testing.T) {
var v map[string]interface{}
if err := xsfwUnmarshal([]byte("<!DOCTYPE html><html></html>"), &v); err == nil {
t.Fatal("HTML 应解析失败")
}
}
@@ -0,0 +1,380 @@
package cpdaily
import (
"context"
"encoding/hex"
"encoding/json"
"fmt"
"math/big"
"net/url"
"regexp"
"strconv"
"strings"
"time"
"github.com/go-resty/resty/v2"
)
// ========== 三易拓「新学工5」独立请假系统(xsgz) ==========
//
// 适用学校:山东农业大学(xsgz.sdau.edu.cn)等采用 sanyth-xuegong-5 系统的学校。
// 与辅导猫完全无关:辅导猫侧请假应用(wec-counselor-leave-*/)在该校为 HTTP 420「产品已到期」。
//
// 链路(全部已实测跑通):
//
// POST {host}/api/cas/loginUrl → 返回 CAS 登录地址(service={host}/oauth/callback)
// CAS 账密登录(wisedu 滑块) → 302 {host}/oauth/callback?ticket=ST-xxx
// GET {host}/api/sec_js → RSA 公钥 {_DK 盐, _M 模数, _E 指数}(DK 每次变,须现取)
// POST {host}/api/cas/callback → ★ form-urlencoded `ticket=<RSA密文>`(不是 JSON!)
// → {access_token, userInfo{realName, roleId, roleName}}
// 后续所有请求头:Authorization: <access_token> + roleId: <roleId>
//
// 注意:access_token 是 Bearer 风格的自定义头,不走 cookie 会话。
// XsgzSchoolIDs 使用三易拓「新学工5」系统的学校 ID 集合(按学校单独适配)
var XsgzSchoolIDs = map[string]bool{
"sdau": true, // 山东农业大学
}
// IsXsgzSchool 判断学校是否使用三易拓新学工5请假系统
func IsXsgzSchool(schoolID string) bool { return XsgzSchoolIDs[schoolID] }
// xsgzBaseURL 返回该校新学工系统根地址(不同学校域名不同,按 schoolID 映射)
func xsgzBaseURL(schoolID string) string {
switch schoolID {
case "sdau":
return "https://xsgz.sdau.edu.cn"
default:
return ""
}
}
// XsgzSession 新学工5 会话(access_token 驱动的鉴权上下文)
type XsgzSession struct {
Token string
RoleID string
RoleName string
RealName string
baseURL string
client *resty.Client
}
// XsgzLogin 用 CAS ticket 换取 access_token,建立新学工5会话。
// casTicket 由调用方通过 CAS 登录取得(service 必须指向 {host}/oauth/callback)。
func XsgzLogin(ctx context.Context, schoolID, casTicket string) (*XsgzSession, error) {
base := xsgzBaseURL(schoolID)
if base == "" {
return nil, fmt.Errorf("该校未配置新学工5地址(schoolId=%s)", schoolID)
}
client := resty.New().SetTimeout(30 * time.Second)
rsa, err := xsgzFetchRSA(client, base)
if err != nil {
return nil, fmt.Errorf("获取加密公钥失败: %w", err)
}
enc, err := xsgzEncryptTicket(casTicket, rsa)
if err != nil {
return nil, fmt.Errorf("ticket 加密失败: %w", err)
}
// ★ 必须是 form-urlencoded 的 `ticket=xxx`;用 JSON 会被服务端以 500 拒绝
resp, err := client.R().SetContext(ctx).
SetHeader("Content-Type", "application/x-www-form-urlencoded; charset=UTF-8").
SetBody("ticket=" + url.QueryEscape(enc)).
Post(base + "/api/cas/callback")
if err != nil {
return nil, fmt.Errorf("换取访问令牌失败: %w", err)
}
var result struct {
Code int `json:"code"`
Data struct {
AccessToken string `json:"access_token"`
UserInfo struct {
RealName string `json:"realName"`
RoleId string `json:"roleId"`
RoleName string `json:"roleName"`
} `json:"userInfo"`
} `json:"data"`
Message string `json:"message"`
}
body := resp.Body()
if err := json.Unmarshal(body, &result); err != nil {
return nil, fmt.Errorf("解析登录响应失败: %w", err)
}
if result.Code != 0 || result.Data.AccessToken == "" {
msg := result.Message
if msg == "" {
msg = "未知错误"
}
return nil, fmt.Errorf("登录失败: %s", msg)
}
return &XsgzSession{
Token: result.Data.AccessToken,
RoleID: result.Data.UserInfo.RoleId,
RoleName: result.Data.UserInfo.RoleName,
RealName: result.Data.UserInfo.RealName,
baseURL: base,
client: client,
}, nil
}
// do 发起带鉴权的请求
func (s *XsgzSession) do(ctx context.Context, method, path string, query map[string]string, form url.Values) ([]byte, error) {
r := s.client.R().SetContext(ctx).
SetHeader("Authorization", s.Token).
SetHeader("roleId", s.RoleID)
if len(query) > 0 {
r = r.SetQueryParams(query)
}
if form != nil {
r = r.SetHeader("Content-Type", "application/x-www-form-urlencoded; charset=UTF-8").SetBody(form.Encode())
}
var resp *resty.Response
var err error
switch method {
case "POST":
resp, err = r.Post(s.baseURL + path)
default:
resp, err = r.Get(s.baseURL + path)
}
if err != nil {
return nil, err
}
return resp.Body(), nil
}
// XsgzLeaveRecord 新学工5 请假记录
type XsgzLeaveRecord struct {
ID string `json:"id"`
ProjectName string `json:"projectName"`
RequestType string `json:"requestTypeText"`
SubmitTime string `json:"submitTime"`
StartTime string `json:"startTime"`
EndTime string `json:"endTime"`
DurationDays int `json:"durationDays"`
Status string `json:"status"`
Approved bool `json:"approved"`
Cancelled bool `json:"cancelled"`
}
// XsgzFetchLeavePage 查询请假记录列表(只读)。
func (s *XsgzSession) XsgzFetchLeavePage(ctx context.Context, page, limit int) ([]XsgzLeaveRecord, int, error) {
if page <= 0 {
page = 1
}
if limit <= 0 {
limit = 20
}
body, err := s.do(ctx, "GET", "/api/leave/record/leave_page",
map[string]string{"page": strconv.Itoa(page), "limit": strconv.Itoa(limit)}, nil)
if err != nil {
return nil, 0, err
}
var result struct {
Code int `json:"code"`
Data struct {
List []XsgzLeaveRecord `json:"list"`
Count int `json:"count"`
} `json:"data"`
Message string `json:"message"`
}
if err := json.Unmarshal(body, &result); err != nil {
return nil, 0, fmt.Errorf("解析请假列表失败: %w", err)
}
if result.Code != 0 {
return nil, 0, fmt.Errorf("查询请假列表失败: %s", result.Message)
}
return result.Data.List, result.Data.Count, nil
}
// XsgzLeaveType 新学工5 请假类型(项目)
type XsgzLeaveType struct {
ID string `json:"id"`
Text string `json:"text"`
}
// XsgzFetchLeaveTypes 查询可用的请假项目/类型(只读)。
func (s *XsgzSession) XsgzFetchLeaveTypes(ctx context.Context) ([]XsgzLeaveType, error) {
body, err := s.do(ctx, "GET", "/api/leave/student_action/optional_project_all", nil, nil)
if err != nil {
return nil, err
}
var result struct {
Code int `json:"code"`
Data []XsgzLeaveType `json:"data"`
Message string `json:"message"`
}
if err := json.Unmarshal(body, &result); err != nil {
return nil, fmt.Errorf("解析请假类型失败: %w", err)
}
if result.Code != 0 {
return nil, fmt.Errorf("查询请假类型失败: %s", result.Message)
}
return result.Data, nil
}
// XsgzFetchFormFields 查询指定项目/类型下的表单字段(只读)。
// requestType 必传(LEAVE),否则返回空数组。
func (s *XsgzSession) XsgzFetchFormFields(ctx context.Context, projectID, requestType string) ([]map[string]interface{}, error) {
if requestType == "" {
requestType = "LEAVE"
}
body, err := s.do(ctx, "GET", "/api/leave/student_action/form_fields_for_add",
map[string]string{"projectId": projectID, "requestType": requestType}, nil)
if err != nil {
return nil, err
}
var result struct {
Code int `json:"code"`
Data []map[string]interface{} `json:"data"`
Message string `json:"message"`
}
if err := json.Unmarshal(body, &result); err != nil {
return nil, fmt.Errorf("解析表单字段失败: %w", err)
}
if result.Code != 0 {
return nil, fmt.Errorf("查询表单字段失败: %s", result.Message)
}
return result.Data, nil
}
// ---------- RSA 加密(与前端 security.js 的 RSAUtils 完全一致) ----------
type xsgzRSA struct {
DK string // 明文盐前缀(每次 /api/sec_js 都不同)
M string
E string
}
// xsgzDecodeJSHex 把 JS 字符串里的 \xNN 转义还原为原始字符
func xsgzDecodeJSHex(s string) string {
var sb strings.Builder
for i := 0; i < len(s); {
if s[i] == '\\' && i+3 < len(s) && s[i+1] == 'x' {
if b, err := hex.DecodeString(s[i+2 : i+4]); err == nil {
sb.Write(b)
i += 4
continue
}
}
sb.WriteByte(s[i])
i++
}
return sb.String()
}
var (
xsgzDKRe = regexp.MustCompile(`function _DK\(\)\{return "([^"]+)"`)
xsgzMRe = regexp.MustCompile(`function _M\(\)\{return "([^"]+)"`)
xsgzERe = regexp.MustCompile(`function _E\(\)\{return "([^"]+)"`)
)
// xsgzFetchRSA 拉取 RSA 公钥(模数固定、盐每次变化,必须与加密同批使用)
func xsgzFetchRSA(client *resty.Client, base string) (*xsgzRSA, error) {
resp, err := client.R().Get(base + "/api/sec_js")
if err != nil {
return nil, err
}
js := string(resp.Body())
r := &xsgzRSA{}
if m := xsgzDKRe.FindStringSubmatch(js); len(m) > 1 {
r.DK = xsgzDecodeJSHex(m[1])
}
if m := xsgzMRe.FindStringSubmatch(js); len(m) > 1 {
r.M = xsgzDecodeJSHex(m[1])
}
if m := xsgzERe.FindStringSubmatch(js); len(m) > 1 {
r.E = xsgzDecodeJSHex(m[1])
}
if r.DK == "" || r.M == "" || r.E == "" {
return nil, fmt.Errorf("解析公钥参数失败")
}
return r, nil
}
// xsgzEncodeURIComponent 严格等价于 JS encodeURIComponent:
// 保留 A-Za-z0-9 与 -_.!~*'(),其余按 UTF-8 逐字节 %XX。
// 注意不能用 url.QueryEscape(它把空格编成 '+',服务端解不出原文)。
func xsgzEncodeURIComponent(s string) string {
const keep = "-_.!~*'()"
var sb strings.Builder
for _, b := range []byte(s) {
if (b >= 'A' && b <= 'Z') || (b >= 'a' && b <= 'z') || (b >= '0' && b <= '9') ||
strings.IndexByte(keep, b) >= 0 {
sb.WriteByte(b)
continue
}
fmt.Fprintf(&sb, "%%%02X", b)
}
return sb.String()
}
// xsgzEncryptTicket 按前端 RSAUtils.encryptedString 语义加密 ticket。
//
// 与标准 PKCS#1 不同(Dave Shapiro RSAUtils 的自定义格式):
// 1. 明文 = encodeURIComponent(DK + ticket),按 charCode 双字节**小端**装入 BigInt;
// 2. chunkSize = 模数字节数 - 2(1024 位密钥 → 126);
// 3. 末尾补 0x00 到 chunkSize 整数倍;
// 4. 每块 m^e mod n,输出小写 hex,块间用单个空格连接。
//
// 已用真实前端 security.js 在 Node 中生成的向量交叉验证一致。
func xsgzEncryptTicket(ticket string, r *xsgzRSA) (string, error) {
plain := xsgzEncodeURIComponent(r.DK + ticket)
n := new(big.Int)
if _, ok := n.SetString(r.M, 16); !ok {
return "", fmt.Errorf("模数格式错误")
}
e := new(big.Int)
if _, ok := e.SetString(r.E, 16); !ok {
return "", fmt.Errorf("指数格式错误")
}
k := (n.BitLen() + 7) / 8
chunk := k - 2
if chunk <= 0 {
return "", fmt.Errorf("密钥长度异常")
}
// JS charCode 序列(UTF-16 code unit),非 BMP 字符拆代理对
codes := make([]int, 0, len(plain))
for _, ch := range plain {
if ch > 0xFFFF {
v := int(ch) - 0x10000
codes = append(codes, 0xD800+(v>>10), 0xDC00+(v&0x3FF))
continue
}
codes = append(codes, int(ch))
}
for len(codes)%chunk != 0 {
codes = append(codes, 0)
}
blocks := make([]string, 0, len(codes)/chunk)
for i := 0; i < len(codes); i += chunk {
var m big.Int
// digits[j] = codes[k] + codes[k+1]<<8,低位 digit 在数组前 → 反向组装
for j := chunk/2 - 1; j >= 0; j-- {
v := codes[i+2*j]
if i+2*j+1 < len(codes) {
v += codes[i+2*j+1] << 8
}
m.Lsh(&m, 16)
m.Or(&m, big.NewInt(int64(v)))
}
c := new(big.Int).Exp(&m, e, n)
blocks = append(blocks, c.Text(16))
}
return strings.Join(blocks, " "), nil
}
// XsgzCasServiceURL 返回该校新学工5的 CAS 回调 service 地址(供 CAS 登录使用)
func XsgzCasServiceURL(schoolID string) string {
base := xsgzBaseURL(schoolID)
if base == "" {
return ""
}
return base + "/oauth/callback"
}
@@ -0,0 +1,99 @@
package cpdaily
import "testing"
// 参考向量由真实前端 security.js(RSAUtils.encryptedString)在 Node 中生成,
// 用于确保 Go 实现与浏览器行为逐字节一致。
// 注意:模数 M 是学校固定的;DK 盐每次 /api/sec_js 都变,此处为取样时的一次值。
const (
vecXsgzDK = "54b6fay56a10ef75coxxjxl4yryye31u"
vecXsgzE = "010001"
vecXsgzM = "00b36e050adb7347cacb80ea0f7831ff64cdf8569adfb5a19099e95870f4b6aab43f75a221b1090e3a9ed91b420c72a6e38dc3b5594ddc30908db5266d5bf4474586080d0eb425bbc185185eec64c4596e6113d08fba77337b4171d91daad26b4571f1108ddf687fca25d8d5a75e3ec5dd698dff8446329c78e63d170eb401da55"
vecXsgzTicket = "ST-UNIT-TEST-0001"
vecXsgzWant = "3651cc9f29c5ff7522f648c0bd2fc671bb2009e82b7860bc2714238dbaf55a90a811dcc0d736d5e6185c084720b72a17b059d5dd2558ce44ed8a2f39aa60c85212acd8c6778377e4e80fb631a5a5259e557cf44750cf500bf0100860d0afb0027d8f1a93f5d725703a54bc3b0412c7e9e75a1bf4b4abce044a91d5acaadd0d19"
)
// TestXsgzEncryptTicket 校验 RSA 加密与前端 RSAUtils 完全一致。
func TestXsgzEncryptTicket(t *testing.T) {
r := &xsgzRSA{DK: vecXsgzDK, M: vecXsgzM, E: vecXsgzE}
got, err := xsgzEncryptTicket(vecXsgzTicket, r)
if err != nil {
t.Fatalf("加密失败: %v", err)
}
if got != vecXsgzWant {
t.Fatalf("密文与前端不一致:\n got=%s\nwant=%s", got, vecXsgzWant)
}
}
// TestXsgzEncodeURIComponent 校验与 JS encodeURIComponent 等价(空格必须编 %20 而非 +)。
func TestXsgzEncodeURIComponent(t *testing.T) {
cases := []struct{ in, want string }{
{"abcXYZ019", "abcXYZ019"},
{"-_.!~*'()", "-_.!~*'()"},
{"a b", "a%20b"}, // 关键:不能是 a+b
{"a+b", "a%2Bb"}, // + 必须被编码
{"ST-123", "ST-123"}, // ticket 常见字符
{"中文", "%E4%B8%AD%E6%96%87"}, // UTF-8 逐字节
}
for _, c := range cases {
if got := xsgzEncodeURIComponent(c.in); got != c.want {
t.Fatalf("encodeURIComponent(%q)=%q, want %q", c.in, got, c.want)
}
}
}
// TestXsgzEncryptChunkSize 校验分块长度 = 模数字节数-2。
func TestXsgzEncryptChunkSize(t *testing.T) {
r := &xsgzRSA{DK: vecXsgzDK, M: vecXsgzM, E: vecXsgzE}
// 构造足够长的 ticket 触发多块
long := vecXsgzTicket
for i := 0; i < 10; i++ {
long += "0123456789"
}
got, err := xsgzEncryptTicket(long, r)
if err != nil {
t.Fatalf("加密失败: %v", err)
}
blocks := splitSpace(got)
if len(blocks) < 2 {
t.Fatalf("长输入应产生多块,实际 %d 块", len(blocks))
}
for _, b := range blocks {
if len(b) > 256 {
t.Fatalf("块长度 %d 超过模数 hex 长度 256", len(b))
}
}
}
func splitSpace(s string) []string {
var out []string
cur := ""
for _, c := range s {
if c == ' ' {
out = append(out, cur)
cur = ""
continue
}
cur += string(c)
}
if cur != "" {
out = append(out, cur)
}
return out
}
// TestIsXsgzSchool 校验按学校 ID 分流。
func TestIsXsgzSchool(t *testing.T) {
if !IsXsgzSchool("sdau") {
t.Fatal("sdau 应走新学工5")
}
if IsXsgzSchool("ynu") {
t.Fatal("普通学校不应走新学工5")
}
if xsgzBaseURL("sdau") == "" {
t.Fatal("sdau 新学工地址未配置")
}
if XsgzCasServiceURL("sdau") != "https://xsgz.sdau.edu.cn/oauth/callback" {
t.Fatalf("CAS service 地址错误: %s", XsgzCasServiceURL("sdau"))
}
}
@@ -0,0 +1,48 @@
package cpdaily
import "testing"
// casCaptchaMode 从登录页解析验证码形态。
// wisedu CAS 用 captchaSwitch 区分:1=图片验证码,2=滑块。
// 该值会随风控状态变化(连续失败后可能由滑块切为图片验证码),
// 必须按页面实际值处理,否则会一直走滑块分支而失败。
func TestCasCaptchaMode(t *testing.T) {
cases := []struct {
name string
page string
want casCaptchaKind
}{
{
name: "captchaSwitch=1 图片验证码",
page: `<script>var captchaSwitch = "1"; badCredentialsCount="3";</script>`,
want: casCaptchaImage,
},
{
name: "captchaSwitch=2 滑块",
page: `<script>var captchaSwitch = "2"; badCredentialsCount="0";</script>`,
want: casCaptchaSlider,
},
{
name: "无 captchaSwitch 字段",
page: `<html><body>login</body></html>`,
want: casCaptchaUnknown,
},
{
name: "带空格与制表符",
page: "<script>\n\tvar captchaSwitch\t= \"2\" ;\n</script>",
want: casCaptchaSlider,
},
{
name: "未知取值按 unknown 处理",
page: `<script>var captchaSwitch = "9";</script>`,
want: casCaptchaUnknown,
},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
if got := casCaptchaMode([]byte(c.page)); got != c.want {
t.Fatalf("casCaptchaMode=%v, want %v", got, c.want)
}
})
}
}
+75 -11
View File
@@ -9,6 +9,7 @@ import (
"log/slog"
"net/http"
"net/url"
"regexp"
"strings"
"time"
@@ -33,7 +34,13 @@ func newSessionClient() *sessionClient {
InsecureSkipVerify: true,
MinVersion: tls.VersionTLS10,
}).
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")
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").
// 浏览器常见头:部分学校(如通化师范学院 authserver.thnu.edu.cn)前置 WAF 按请求头
// 指纹判定客户端,仅带 UA、缺少 Accept 类头会被判为机器人直接返回 429(空 body)。
// 仅补 Accept/Accept-Language 即可通过校验(实测移动 UA + 这两项 → 200);
// 保留原移动 UA,不影响 wisedu 按 UA 渲染移动/PC 模板。
SetHeader("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8").
SetHeader("Accept-Language", "zh-CN,zh;q=0.9,en;q=0.8")
client.SetCookieJar(jar)
// 自定义重定向策略:与 resty 默认 FlexibleRedirectPolicy 行为一致(最多 20 跳),
// 但跨 host 重定向时保留原 User-Agent。resty 默认策略会把跨 host 请求的 UA 换成
@@ -100,19 +107,41 @@ func casLogin(ctx context.Context, school *SchoolDetailInfo, username, password
// 4. 解析表单
allInputs := parseLoginForm(loginPageBytes, username, password)
// CAS-PaaS 定制 CAS:密码需 RSA 加密("__RSA__"+密文),覆盖上面解出的明文密码
casPaasEncryptIfNeeded(sc.Client, loginResponseUrl, loginPageBytes, allInputs, password)
slog.Info("CAS表单解析", "inputs", fmt.Sprintf("%v", allInputs))
// 5. 判断是否需要验证码
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
// 验证码形态以登录页实际配置为准,而不是只看学校静态配置:
// 同一学校的 captchaSwitch 会随风控状态变化(如连续失败后由滑块切换为图片验证码),
// 若仍按滑块处理会因 openSliderCaptcha 返回空图而失败,表现为"登录失败/密码错误"。
mode := casCaptchaMode(loginPageBytes)
slog.Info("CAS验证码形态", "mode", mode, "isSliderSchool", isSliderCaptchaSchool(school.ID))
if mode == casCaptchaSlider {
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 if mode == casCaptchaImage || !isSliderCaptchaSchool(school.ID) {
captchaCode := resolveOcrCaptcha(ctx, loginResponseUrl, loginType, sc.Client)
slog.Info("CAS验证码OCR", "code", captchaCode)
switch loginType {
case 0:
allInputs["captchaResponse"] = captchaCode
default:
allInputs["captcha"] = captchaCode
}
} else {
// 页面未声明形态时,沿用学校级滑块配置
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滑块验证通过")
}
}
@@ -200,8 +229,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)
@@ -251,7 +282,40 @@ func parseLoginForm(page []byte, username, password string) map[string]string {
return inputs
}
// 检测是否需要验证码
// CAS 验证码形态
type casCaptchaKind int
const (
// casCaptchaUnknown 页面未声明(按学校级配置兜底)
casCaptchaUnknown casCaptchaKind = iota
// casCaptchaImage 图片验证码(captchaSwitch=1)
casCaptchaImage
// casCaptchaSlider 滑块验证码(captchaSwitch=2)
casCaptchaSlider
)
var casCaptchaSwitchRe = regexp.MustCompile(`captchaSwitch\s*=\s*"(\d+)"`)
// casCaptchaMode 从登录页读取当前要求的验证码形态。
// wisedu CAS 用 captchaSwitch 区分:1=图片验证码,2=滑块。
// 该值会随风控状态变化(连续失败后可能由滑块切换为图片验证码),
// 因此必须以页面实际值为准。
func casCaptchaMode(page []byte) casCaptchaKind {
m := casCaptchaSwitchRe.FindSubmatch(page)
if len(m) < 2 {
return casCaptchaUnknown
}
switch string(m[1]) {
case "1":
return casCaptchaImage
case "2":
return casCaptchaSlider
default:
return casCaptchaUnknown
}
}
// detectCaptcha 判断是否需要验证码
func detectCaptcha(ctx context.Context, loginUrl *url.URL, username string, client *resty.Client) (bool, int) {
checkUrl := fmt.Sprintf("%s://%s/authserver/needCaptcha.html?username=%s",
loginUrl.Scheme, loginUrl.Host, username)
@@ -0,0 +1,84 @@
package cpdaily
import (
"encoding/base64"
"encoding/hex"
"fmt"
"log/slog"
"net/url"
"strings"
"github.com/go-resty/resty/v2"
)
// ========== CAS-PaaS(深信息 suncaper 定制 CAS)适配 ==========
//
// 部分学校(如兰州理工大学 cas-paas.lut.edu.cn)使用 CAS-PaaS 统一身份认证:
// 登录页为 JS 渲染,密码需用 RSA(JSEncrypt)加密后提交,格式为 "__RSA__" + RSA密文。
// 公钥来自登录页同域接口 /cas/jwt/publicKey(PEM 格式)。
//
// 判定:登录页 HTML 同时含 "/cas/jwt/publicKey" 或 "__RSA__" 特征。
// isCasPaasLoginPage 登录页是否为 CAS-PaaS 定制 CAS
func isCasPaasLoginPage(page []byte) bool {
s := string(page)
return strings.Contains(s, "/cas/jwt/publicKey") || strings.Contains(s, "__RSA__")
}
// casPaasEncryptPassword 拉取 CAS-PaaS 公钥并加密密码,返回 "__RSA__"+密文。
// casHost 形如 https://cas-paas.lut.edu.cn
func casPaasEncryptPassword(client *resty.Client, casHost, password string) (string, error) {
resp, err := client.R().Get(casHost + "/cas/jwt/publicKey")
if err != nil {
return "", fmt.Errorf("获取 CAS-PaaS 公钥失败: %w", err)
}
pub := strings.TrimSpace(string(readBody(resp)))
// 去掉 PEM 头尾,取 base64 主体(NewRSAKeyPairFromPublicKey 接收 base64)
pub = strings.ReplaceAll(pub, "-----BEGIN PUBLIC KEY-----", "")
pub = strings.ReplaceAll(pub, "-----END PUBLIC KEY-----", "")
pub = strings.ReplaceAll(pub, "\n", "")
pub = strings.ReplaceAll(pub, "\r", "")
pub = strings.TrimSpace(pub)
if pub == "" {
return "", fmt.Errorf("CAS-PaaS 公钥为空")
}
key, err := NewRSAKeyPairFromPublicKey(pub)
if err != nil {
return "", fmt.Errorf("解析 CAS-PaaS 公钥失败: %w", err)
}
encHex, err := key.Encrypt(password)
if err != nil {
return "", fmt.Errorf("CAS-PaaS 密码加密失败: %w", err)
}
// JSEncrypt 的 encrypt() 返回 base64;本仓库 RSAKeyPair.Encrypt 返回 hex,需转 base64。
// Go 侧 hex→bytes→base64 与 JS 的 b64tohex 逆运算一致(均为无符号大端字节序列)。
raw, err := hex.DecodeString(encHex)
if err != nil {
return "", fmt.Errorf("CAS-PaaS 密文转码失败: %w", err)
}
enc := base64.StdEncoding.EncodeToString(raw)
return "__RSA__" + enc, nil
}
// casPaasBaseOf 由登录页 URL 推导 CAS-PaaS 基础地址(scheme://host)
func casPaasBaseOf(loginURL *url.URL) string {
if loginURL == nil {
return ""
}
return loginURL.Scheme + "://" + loginURL.Host
}
// casPaasEncryptIfNeeded 若登录页为 CAS-PaaS,则把表单密码替换为加密值,返回是否已处理。
func casPaasEncryptIfNeeded(client *resty.Client, loginURL *url.URL, page []byte, inputs map[string]string, password string) bool {
if !isCasPaasLoginPage(page) {
return false
}
enc, err := casPaasEncryptPassword(client, casPaasBaseOf(loginURL), password)
if err != nil {
slog.Warn("CAS-PaaS 密码加密失败", "error", err)
return false
}
inputs["password"] = enc
slog.Info("CAS-PaaS 密码已加密")
return true
}
@@ -0,0 +1,49 @@
package cpdaily
import (
"encoding/base64"
"encoding/hex"
"testing"
)
// TestIsCasPaasLoginPage CAS-PaaS 登录页特征识别
func TestIsCasPaasLoginPage(t *testing.T) {
if !isCasPaasLoginPage([]byte(`<script src="/cas/deps/js/jsencrypt.min.js"></script>var url="/cas/jwt/publicKey"`)) {
t.Error("含 /cas/jwt/publicKey 应判定为 CAS-PaaS")
}
if !isCasPaasLoginPage([]byte(`var encodedPassword = "__RSA__"+encrypt.encrypt(password);`)) {
t.Error("含 __RSA__ 应判定为 CAS-PaaS")
}
if isCasPaasLoginPage([]byte(`<form id="casLoginForm" action="/authserver/login">`)) {
t.Error("普通 wisedu CAS 不应判定为 CAS-PaaS")
}
}
// TestCasPaasEncryptFormat CAS-PaaS 密码密文格式:__RSA__ + base64(PKCS#1 v1.5)。
// 用固定 PEM 公钥验证:前缀正确、密文 base64 可解码且长度等于模长。
func TestCasPaasEncryptFormat(t *testing.T) {
// 用仓库内 RSA 工具直接产出一个 base64 密文,校验与 CAS-PaaS 一致(hex→base64)
key, err := NewRSAKeyPairFromPublicKey(testPubKeyB64)
if err != nil {
t.Fatalf("构造公钥失败: %v", err)
}
encHex, err := key.Encrypt("pw")
if err != nil {
t.Fatalf("加密失败: %v", err)
}
raw, err := hex.DecodeString(encHex)
if err != nil {
t.Fatalf("hex 解码失败: %v", err)
}
b64 := base64.StdEncoding.EncodeToString(raw)
if _, err := base64.StdEncoding.DecodeString(b64); err != nil {
t.Fatalf("base64 解码失败: %v", err)
}
// 长度应为模长(2048 位 → 256 字节);此处用 1024 位测试密钥 → 128 字节
if len(raw) != 128 {
t.Fatalf("密文长度应为 128(1024 位模长),got %d", len(raw))
}
}
// testPubKeyB64 1024 位 RSA 公钥(base64 DER/PKIX),仅测试用
const testPubKeyB64 = "MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQCqGKukO1De7zhZj6+H0qtjTkVxwTCpvKe4eCZ0FPqri0cb2JZfXJ/DgYSF6vUpwmJG8wVQZKjeGcjDOL5UlsuusFncCzWBQ7RKNUSesmQRMSGkVb1/3j+skZ6UtW+5u09lHNsj6tQ51s1SPrCBkedbNf0Tp0GbMJDyR4e9T04ZZwIDAQAB"
@@ -0,0 +1,207 @@
package cpdaily
import (
"context"
"crypto/aes"
"encoding/base64"
"encoding/json"
"fmt"
"net/http"
"regexp"
"strings"
"github.com/go-resty/resty/v2"
)
// ========== 合肥工业大学(hfut)单独适配 ==========
// HfutSchoolId 合肥工业大学 tenant id
const HfutSchoolId = "46a6ac8d-0795-4759-b777-c9aa0148b959"
// hfutCasEntry 计算合肥工业大学 CAS 登录入口:
// 网关域 hfut.campusphere.net 的 /iap(school.IdsUrl)会 302 到学校 CAS
// (cas.hfut.edu.cn/cas/login),service 参数携带网关回调
// /iap/loginSuccess?sessionToken=...;走完 CAS 账密后回调该地址,
// cookie 才能正确落在 hfut.campusphere.net 域(wec-* 任务接口所在域)。
func hfutCasEntry(school *SchoolDetailInfo) string {
if school == nil {
return ""
}
if school.IdsUrl != "" && strings.Contains(school.IdsUrl, "/iap") {
return school.IdsUrl
}
if school.WecloudUrl != "" {
return strings.TrimSuffix(school.WecloudUrl, "/") + "/iap"
}
return school.AmpUrl
}
// hfutEncryptPassword 合肥工业大学 CAS 密码加密:
// 前端 encryptionPwd() 用 AES-ECB/PKCS7,key = cookie LOGIN_FLAVORING(该 cookie 由
// /cas/checkInitParams 下发),明文密码 UTF-8 编码后加密,输出 Base64。
func hfutEncryptPassword(password, key string) (string, error) {
if key == "" {
return "", fmt.Errorf("缺少密码加密密钥(LOGIN_FLAVORING)")
}
block, err := aes.NewCipher([]byte(key))
if err != nil {
return "", fmt.Errorf("初始化 AES 失败: %w", err)
}
plain := pkcs7Pad([]byte(password), block.BlockSize())
encrypted := make([]byte, len(plain))
// AES-ECB:逐块独立加密
for i := 0; i < len(plain); i += block.BlockSize() {
block.Encrypt(encrypted[i:i+block.BlockSize()], plain[i:i+block.BlockSize()])
}
return base64.StdEncoding.EncodeToString(encrypted), nil
}
var hfutExecutionRe = regexp.MustCompile(`name="execution"\s+value="([^"]*)"`)
// hfutCasLogin 合肥工业大学强制走其 CAS:
// 平台接口返回 joinType=CLOUD(IsIap()=true),idsUrl=hfut.campusphere.net/iap,
// 但网关 IAP 账密接口与本链路不兼容;实际链路为 /iap 302 → cas.hfut.edu.cn/cas/login
// (标准 Apereo CAS,表单 id=fm1)→ 密码 AES-ECB(LOGIN_FLAVORING) 加密后 POST 登录
// → 302 回调 /iap/loginSuccess?sessionToken=...&ticket=ST-... 建立网关会话(MOD_AUTH_CAS)。
//
// 关键差异(通用 casLogin 会报"账号或密码错误"的原因):
// 1. 密码必须 AES-ECB/PKCS7 加密,key 来自 /cas/checkInitParams 下发的 LOGIN_FLAVORING cookie;
// 2. CAS 有自研 /cas/checkInitParams 初始化接口,通用流程从不调用,故拿不到加密 key。
func hfutCasLogin(ctx context.Context, school *SchoolDetailInfo, username, password string) ([]*http.Cookie, *MFAContext, error) {
entry := hfutCasEntry(school)
if entry == "" {
return nil, nil, fmt.Errorf("合肥工业大学登录入口缺失")
}
sc := newSessionClient()
// 1. 从网关 /iap 进入,302 到 CAS 登录页(带 service 回调地址)
pageResp, err := sc.R().SetContext(ctx).Get(entry)
if err != nil {
return nil, nil, fmt.Errorf("请求登录入口失败: %w", err)
}
loginURL := pageResp.RawResponse.Request.URL
if loginURL == nil || !strings.Contains(loginURL.String(), "/cas/login") {
// 未落到 CAS 登录页(网关异常),退回通用 CAS 流程兜底
return casLogin(ctx, school, username, password)
}
loginURLStr := loginURL.String()
pageBody := readBody(pageResp)
casBase := fmt.Sprintf("%s://%s/cas", loginURL.Scheme, loginURL.Host)
// 2. 调 checkInitParams 获取密码加密密钥(LOGIN_FLAVORING cookie)
if _, cerr := sc.R().SetContext(ctx).Get(casBase + "/checkInitParams"); cerr != nil {
return nil, nil, fmt.Errorf("获取登录初始化参数失败: %w", cerr)
}
flavoring := ""
for _, ck := range sc.jar.AllCookies() {
if ck.Name == "LOGIN_FLAVORING" {
flavoring = ck.Value
break
}
}
if flavoring == "" {
return nil, nil, fmt.Errorf("获取密码加密密钥失败(LOGIN_FLAVORING 缺失)")
}
encPwd, err := hfutEncryptPassword(password, flavoring)
if err != nil {
return nil, nil, err
}
// 3. 解析表单 execution(Apereo CAS CSRF token)
execution := ""
if m := hfutExecutionRe.FindSubmatch(pageBody); len(m) > 1 {
execution = string(m[1])
}
submitData := map[string]string{
"username": username,
"password": encPwd,
"execution": execution,
"_eventId": "submit",
"geolocation": "",
}
// 4. 提交登录。禁用自动重定向以捕获 302 回调地址与 200 失败页。
sc.SetRedirectPolicy(resty.NoRedirectPolicy())
loginResp, err := sc.R().SetContext(ctx).SetFormData(submitData).Post(loginURLStr)
if err != nil && (loginResp == nil || loginResp.StatusCode() != 302) {
return nil, nil, fmt.Errorf("提交登录失败: %w", err)
}
switch loginResp.StatusCode() {
case 302:
location := loginResp.Header().Get("Location")
// 多因子认证(如有)
if strings.Contains(location, "isMultifactor=true") || strings.Contains(location, "reAuthCheck") {
mfa, merr := handleMFATriggered(ctx, loginResp, school, sc)
if merr != nil {
return nil, nil, merr
}
return nil, mfa, ErrMFARequired
}
if location == "" {
return nil, nil, fmt.Errorf("登录失败,未获取到回调地址")
}
// 跟随回调建立网关会话
sc.SetRedirectPolicy(resty.FlexibleRedirectPolicy(20))
if _, cerr := sc.R().SetContext(ctx).Get(location); cerr != nil {
return nil, nil, fmt.Errorf("跟随登录回调失败: %w", cerr)
}
case 200:
// 登录失败:尝试识别错误信息(含验证码要求时重试)
body := readBody(loginResp)
msg := hfutCasError(body)
if msg == "" {
msg = "登录失败,请检查账号密码"
}
return nil, nil, fmt.Errorf("%s", msg)
default:
return nil, nil, fmt.Errorf("登录返回异常状态码: %d", loginResp.StatusCode())
}
sc.SetRedirectPolicy(resty.FlexibleRedirectPolicy(20))
activateSession(ctx, school.WecloudUrl, sc.Client)
cookies := sc.jar.AllCookies()
hasMOD := false
for _, ck := range cookies {
if strings.HasPrefix(ck.Name, "MOD_") {
hasMOD = true
break
}
}
if !hasMOD {
return nil, nil, fmt.Errorf("登录失败,未获取到有效的认证信息")
}
return cookies, nil, nil
}
// hfutCasError 从 CAS 登录失败页提取可读错误信息(简化实现,够用即止)。
func hfutCasError(body []byte) string {
s := string(body)
// 常见错误容器:#msg / #errorMsg / 类 errMessage
for _, key := range []string{`id="msg"`, `id="errorMsg"`, `class="errMessage"`} {
if i := strings.Index(s, key); i >= 0 {
seg := s[i:]
if j := strings.Index(seg, ">"); j >= 0 {
rest := seg[j+1:]
if k := strings.Index(rest, "<"); k > 0 {
txt := strings.TrimSpace(rest[:k])
if txt != "" {
return txt
}
}
}
}
}
// 兜底:JSON 错误体
var r struct {
Code int `json:"code"`
Msg string `json:"msg"`
}
if json.Unmarshal(body, &r) == nil && r.Msg != "" {
return r.Msg
}
return ""
}
@@ -0,0 +1,89 @@
package cpdaily
import (
"encoding/base64"
"testing"
)
// hfutEncryptPassword 合肥工业大学 CAS 密码加密:AES-ECB/PKCS7,Base64 输出。
func TestHfutEncryptPassword(t *testing.T) {
// 16 字节 key(LOGIN_FLAVORING 为 16 位字母数字)
key := "63gmtb2np0obvcy4"
got, err := hfutEncryptPassword("Lidan20051225", key)
if err != nil {
t.Fatalf("加密失败: %v", err)
}
// 13 字节明文 → PKCS7 补齐到 16 字节 → Base64 后 24 字符
if len(got) != 24 {
t.Fatalf("密文长度=%d, want 24(16 字节块 Base64)", len(got))
}
raw, derr := base64.StdEncoding.DecodeString(got)
if derr != nil {
t.Fatalf("密文非合法 Base64: %v", derr)
}
if len(raw) != 16 {
t.Fatalf("解出字节数=%d, want 16", len(raw))
}
// 相同输入应产生相同密文(ECB 无随机 IV,确定性)
got2, _ := hfutEncryptPassword("Lidan20051225", key)
if got != got2 {
t.Fatalf("ECB 加密应为确定性:%q != %q", got, got2)
}
// 16 字节明文 → 再补一整块 → 32 字节 → Base64 44 字符
got3, err := hfutEncryptPassword("abcdefghijklmnop", key)
if err != nil {
t.Fatalf("加密失败: %v", err)
}
raw3, _ := base64.StdEncoding.DecodeString(got3)
if len(raw3) != 32 {
t.Fatalf("整块明文应补一整块,解出=%d, want 32", len(raw3))
}
}
// 缺少 key 应报错(通用 casLogin 未取 LOGIN_FLAVORING 时会命中此路径)。
func TestHfutEncryptPasswordMissingKey(t *testing.T) {
if _, err := hfutEncryptPassword("pwd", ""); err == nil {
t.Fatal("缺少 key 应返回错误")
}
}
// hfutCasEntry 优先取 /iap 网关入口。
func TestHfutCasEntry(t *testing.T) {
cases := []struct {
name string
school *SchoolDetailInfo
want string
}{
{
name: "idsUrl 含 /iap 时取 idsUrl",
school: &SchoolDetailInfo{IdsUrl: "https://hfut.campusphere.net/iap", WecloudUrl: "https://hfut.campusphere.net"},
want: "https://hfut.campusphere.net/iap",
},
{
name: "无 idsUrl 时由 wecloudUrl 拼 /iap",
school: &SchoolDetailInfo{WecloudUrl: "https://hfut.campusphere.net"},
want: "https://hfut.campusphere.net/iap",
},
{
name: "wecloudUrl 带尾斜杠",
school: &SchoolDetailInfo{WecloudUrl: "https://hfut.campusphere.net/"},
want: "https://hfut.campusphere.net/iap",
},
{
name: "全空回退 AmpUrl",
school: &SchoolDetailInfo{AmpUrl: "https://hfut.campusphere.net/wec-portal-mobile/client"},
want: "https://hfut.campusphere.net/wec-portal-mobile/client",
},
{name: "nil 学校", school: nil, want: ""},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
if got := hfutCasEntry(c.school); got != c.want {
t.Fatalf("hfutCasEntry=%q, want %q", got, c.want)
}
})
}
}
@@ -0,0 +1,43 @@
package cpdaily
import (
"context"
"net/http"
"strings"
)
// ========== 辽宁工程技术大学(lntu)单独适配 ==========
// LntuSchoolId 辽宁工程技术大学 tenant id
const LntuSchoolId = "lntu"
// lntuCasLoginEntry 计算辽宁工程技术大学 CAS 登录入口:
// 网关域 fdm.lntu.edu.cn 的 /iap(school.IdsUrl)会 302 到学校 CAS
// (authserver.lntu.edu.cn/authserver/login),service 参数携带网关回调
// /iap/loginSuccess?sessionToken=...;走完 CAS 账密后回调该地址,
// cookie 才能正确落在 fdm.lntu.edu.cn 域(wec-* 任务接口所在域)。
func lntuCasLoginEntry(school *SchoolDetailInfo) string {
if school == nil {
return ""
}
if school.IdsUrl != "" && strings.Contains(school.IdsUrl, "/iap") {
return school.IdsUrl
}
if school.WecloudUrl != "" {
return strings.TrimSuffix(school.WecloudUrl, "/") + "/iap"
}
return school.AmpUrl
}
// lntuCasLogin 辽宁工程技术大学强制走 CAS:
// 平台接口返回 joinType=CLOUD(IsIap()=true),但网关 /iap/doLogin 不存在(HTTP 405),
// IAP 账密提交必然失败并报 FAIL_UPNOTMATCH("用户名或密码不匹配",误导用户);
// 实际链路为 IAP 入口(fdm.lntu.edu.cn/iap)302 → 学校 CAS 账密 → 回调
// /iap/loginSuccess 建立网关会话。因此强制走 CAS 流程(同许昌学院 xcu 模式)。
func lntuCasLogin(ctx context.Context, school *SchoolDetailInfo, username, password string) ([]*http.Cookie, *MFAContext, error) {
lntu := *school
if entry := lntuCasLoginEntry(school); entry != "" {
lntu.AmpUrl = entry
}
return casLogin(ctx, &lntu, username, password)
}
@@ -0,0 +1,91 @@
package cpdaily
import "testing"
// TestLntuCasLoginEntry 覆盖辽宁工程技术大学 CAS 登录入口计算逻辑
func TestLntuCasLoginEntry(t *testing.T) {
cases := []struct {
name string
school *SchoolDetailInfo
want string
}{
{
name: "正常idsUrl(/iap 网关入口)",
school: &SchoolDetailInfo{IdsUrl: "https://fdm.lntu.edu.cn/iap"},
want: "https://fdm.lntu.edu.cn/iap",
},
{
name: "idsUrl缺失回退wecloud+/iap",
school: &SchoolDetailInfo{WecloudUrl: "https://fdm.lntu.edu.cn"},
want: "https://fdm.lntu.edu.cn/iap",
},
{
name: "wecloud带尾斜杠",
school: &SchoolDetailInfo{WecloudUrl: "https://fdm.lntu.edu.cn/"},
want: "https://fdm.lntu.edu.cn/iap",
},
{
name: "idsUrl非iap路径回退wecloud",
school: &SchoolDetailInfo{IdsUrl: "https://ids.lntu.edu.cn/authserver", WecloudUrl: "https://fdm.lntu.edu.cn"},
want: "https://fdm.lntu.edu.cn/iap",
},
{
name: "均缺失回退ampUrl",
school: &SchoolDetailInfo{AmpUrl: "https://fdm.lntu.edu.cn/wec-portal-mobile/client"},
want: "https://fdm.lntu.edu.cn/wec-portal-mobile/client",
},
{
name: "均为空",
school: &SchoolDetailInfo{},
want: "",
},
{
name: "nil学校",
school: nil,
want: "",
},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
if got := lntuCasLoginEntry(c.school); got != c.want {
t.Errorf("lntuCasLoginEntry() = %q, want %q", got, c.want)
}
})
}
}
// TestLntuCasLoginEntryOverridesAmpUrl 确认 lntuCasLogin 用网关入口覆盖 ampUrl 后走 CAS 流程
func TestLntuCasLoginEntryOverridesAmpUrl(t *testing.T) {
school := &SchoolDetailInfo{
ID: LntuSchoolId,
Name: "辽宁工程技术大学",
JoinType: "CLOUD", // 平台标记为 CLOUD,但 IAP 不可用,需强制 CAS
IdsUrl: "https://fdm.lntu.edu.cn/iap",
WecloudUrl: "https://fdm.lntu.edu.cn",
AmpUrl: "https://fdm.lntu.edu.cn/wec-portal-mobile/client",
}
// 模拟 lntuCasLogin 内部的入口覆盖(不发起网络请求)
clone := *school
clone.AmpUrl = lntuCasLoginEntry(school)
if got := getCasValidLoginUrl(&clone); got != "https://fdm.lntu.edu.cn/iap" {
t.Errorf("getCasValidLoginUrl() = %q, want 网关 /iap 入口", got)
}
// 原对象不被修改
if school.AmpUrl != "https://fdm.lntu.edu.cn/wec-portal-mobile/client" {
t.Errorf("原 school 对象 ampUrl 被意外修改: %q", school.AmpUrl)
}
}
// TestLntuIsSliderCaptchaSchool 确认 lntu 归入滑块验证码学校需通过配置声明
// (captchaSwitch=2 + createSliderCaptcha → /common/toSliderCaptcha.htl,与 hqdx 同协议)
func TestLntuSliderProtocolSameAsHqu(t *testing.T) {
// 协议一致性由配置驱动:lntu 与 hqdx 共用 resolveWiseduSliderCaptcha
//(openSliderCaptcha.htl 取图 → 打码 → verifySliderCaptcha.htl sign 提交)。
// 此处仅断言常量存在且不与其他学校 ID 冲突。
if LntuSchoolId == "" || LntuSchoolId != "lntu" {
t.Errorf("LntuSchoolId = %q, want lntu", LntuSchoolId)
}
if HquSchoolId == LntuSchoolId {
t.Errorf("lntu 与 hqdx 学校 ID 冲突")
}
}
@@ -0,0 +1,45 @@
package cpdaily
import (
"context"
"net/http"
"strings"
)
// ========== 兰州理工大学(lut)单独适配 ==========
// LutSchoolId 兰州理工大学 tenant id
const LutSchoolId = "28fbe96b-cb5e-44a1-b31a-e6a44a556fe8"
// lutCasLoginEntry 兰州理工大学 CAS 登录入口:
// wecloud/ampUrl(fdm.lut.edu.cn)的 /iap 或 /wec-portal-mobile/client 会 302 到
// 学校统一身份认证(cas-paas.lut.edu.cn/cas/login),service 携带网关回调
// /iap/loginSuccess?sessionToken=...;从网关入口进入才能让回调后 cookie 落在
// fdm.lut.edu.cn 域(wec-* 任务接口所在域)。
func lutCasLoginEntry(school *SchoolDetailInfo) string {
if school == nil {
return ""
}
if school.AmpUrl != "" {
return school.AmpUrl
}
if school.WecloudUrl != "" {
return strings.TrimSuffix(school.WecloudUrl, "/") + "/iap"
}
return school.IdsUrl
}
// lutCasLogin 兰州理工大学强制走 CAS:
// 平台配置 joinType=CLOUD(IsIap=true),但学校实际使用 CAS-PaaS 统一身份认证
// (cas-paas.lut.edu.cn/cas/login,密码需 RSA 加密,前缀 "__RSA__"),
// IAP 账密接口提交报 FAIL_UPNOTMATCH(误导为"密码错误")。
// 真实链路为网关入口 302 → CAS-PaaS 账密(RSA 加密密码)→ 回调 /iap/loginSuccess。
// casLogin 内已按登录页特征自动识别 CAS-PaaS 并加密密码(见 login_caspaas.go)。
func lutCasLogin(ctx context.Context, school *SchoolDetailInfo, username, password string) ([]*http.Cookie, *MFAContext, error) {
lut := *school
if entry := lutCasLoginEntry(school); entry != "" {
lut.AmpUrl = entry
lut.IdsUrl = entry
}
return casLogin(ctx, &lut, username, password)
}
@@ -0,0 +1,206 @@
package cpdaily
import (
"context"
"encoding/hex"
"fmt"
"math/big"
"net/http"
"net/url"
"strings"
"github.com/go-resty/resty/v2"
)
// ========== 西藏民族大学(xzmy)单独适配 ==========
// XzmySchoolId 西藏民族大学 tenant id
const XzmySchoolId = "1019389288913029"
// 该校统一身份认证平台公钥(来自 cas.xzmu.edu.cn 前端 app.js 的 config 对象)
const (
xzmyRsaModulusHex = "00b5eeb166e069920e80bebd1fea4829d3d1f3216f2aabe79b6c47a3c18dcee5fd22c2e7ac519cab59198ece036dcf289ea8201e2a0b9ded307f8fb704136eaeb670286f5ad44e691005ba9ea5af04ada5367cd724b5a26fdb5120cc95b6431604bd219c6b7d83a6f8f24b43918ea988a76f93c333aa5a20991493d4eb1117e7b1"
xzmyRsaExpHex = "010001"
)
// xzmyCasEntry 该校 CAS 登录入口:wecloud 网关的 /iap 会 302 到学校 CAS
// (cas.xzmu.edu.cn/lyuapServer/login),service 携带网关回调
// /iap/loginSuccess?sessionToken=...;从网关入口进入才能让回调后 cookie 落在
// xzmy.campusphere.net 域(wec-* 任务接口所在域)。
func xzmyCasEntry(school *SchoolDetailInfo) string {
if school == nil {
return ""
}
if school.WecloudUrl != "" {
return strings.TrimSuffix(school.WecloudUrl, "/") + "/iap"
}
if school.IdsUrl != "" {
return school.IdsUrl
}
return school.AmpUrl
}
// xzmyEncryptPassword 该校(Leyan/lyuapServer 定制 CAS)密码加密:
// 教科书式 RSA(无填充)——明文按字符铺入 chunkSize 字节块(小端序),
// m^e mod n 后输出小写 hex。与通用 wisedu 的 PKCS#1 v1.5(crypto/rsa.EncryptPKCS1v15)
// 完全不同:该校后端按小端 hex 解出原文,用 PKCS#1 填充会解成乱码 → PASSERROR。
// 对应前端库 app.js 模块 24 的加密函数 W(barrett.powMod(m, e) → hex)。
func xzmyEncryptPassword(password string) (string, error) {
modBytes, err := hex.DecodeString(xzmyRsaModulusHex)
if err != nil {
return "", fmt.Errorf("解析公钥 modulus 失败: %w", err)
}
n := new(big.Int).SetBytes(modBytes)
chunkSize := (n.BitLen() + 7) / 8
block := make([]byte, chunkSize)
copy(block, []byte(password))
// 小端序组装明文块
m := new(big.Int)
for i := chunkSize - 1; i >= 0; i-- {
m.Lsh(m, 8)
m.Or(m, big.NewInt(int64(block[i])))
}
expBytes, _ := hex.DecodeString(xzmyRsaExpHex)
e := new(big.Int).SetBytes(expBytes)
c := new(big.Int).Exp(m, e, n)
return c.Text(16), nil
}
// xzmyCasLogin 西藏民族大学强制走其定制 CAS:
// 平台接口返回 joinType=CLOUD(IsIap()=true),但网关 /iap/doLogin 实际不可用,
// IAP 账密提交报 FAIL_UPNOTMATCH(误导为"密码错误");
// 真实链路为 /iap 302 → cas.xzmu.edu.cn/lyuapServer/login →(表单 POST
// /lyuapServer/v1/tickets,密码=教科书RSA小端hex)→ 得 TGT/ST →
// 回调 /iap/loginSuccess?sessionToken=...&ticket=ST 建立网关会话(MOD_AUTH_CAS)。
func xzmyCasLogin(ctx context.Context, school *SchoolDetailInfo, username, password string) ([]*http.Cookie, *MFAContext, error) {
entry := xzmyCasEntry(school)
if entry == "" {
return nil, nil, fmt.Errorf("西藏民族大学登录入口缺失")
}
sc := newSessionClient()
// 1. 从网关 /iap 进入,302 到 CAS,拿到带 service 的登录页地址
pageResp, err := sc.R().SetContext(ctx).Get(entry)
if err != nil {
return nil, nil, fmt.Errorf("请求登录入口失败: %w", err)
}
loginURL := pageResp.RawResponse.Request.URL.String()
if !strings.Contains(loginURL, "/lyuapServer/login") {
// 未落到 CAS 登录页(网关异常),退回通用 CAS 流程兜底
return casLogin(ctx, school, username, password)
}
casBase := fmt.Sprintf("%s://%s/lyuapServer", pageResp.RawResponse.Request.URL.Scheme, pageResp.RawResponse.Request.URL.Host)
service := ""
if u, perr := url.Parse(loginURL); perr == nil {
service = u.Query().Get("service")
}
if service == "" {
return nil, nil, fmt.Errorf("未获取到登录回调 service")
}
encPwd, err := xzmyEncryptPassword(password)
if err != nil {
return nil, nil, err
}
// 2. 提交账密到 /lyuapServer/v1/tickets(表单编码)
ticketResp, err := sc.R().SetContext(ctx).
SetHeader("Content-Type", "application/x-www-form-urlencoded").
SetFormData(map[string]string{
"username": username,
"password": encPwd,
"service": service,
"loginType": "",
"id": "",
"code": "",
}).
Post(casBase + "/v1/tickets")
if err != nil {
return nil, nil, fmt.Errorf("提交登录失败: %w", err)
}
respBody := readBody(ticketResp)
tkt := parseXzmyTicket(string(respBody))
if tkt.ticket == "" {
// 有业务码则翻译为可读错误(NOAUTH/PASSERROR 等)
if msg := xzmyLoginError(string(respBody)); msg != "" {
return nil, nil, fmt.Errorf("%s", msg)
}
return nil, nil, fmt.Errorf("登录失败,请检查账号密码")
}
// 3. 回调 service + ticket 建立网关会话
sep := "&"
if !strings.Contains(service, "?") {
sep = "?"
}
sc.SetRedirectPolicy(resty.FlexibleRedirectPolicy(20))
cbResp, err := sc.R().SetContext(ctx).Get(service + sep + "ticket=" + tkt.ticket)
if err != nil {
return nil, nil, fmt.Errorf("回调建立会话失败: %w", err)
}
_ = cbResp
activateSession(ctx, school.WecloudUrl, sc.Client)
cookies := sc.jar.AllCookies()
hasMOD := false
for _, ck := range cookies {
if strings.HasPrefix(ck.Name, "MOD_") {
hasMOD = true
break
}
}
if !hasMOD {
return nil, nil, fmt.Errorf("登录失败,未获取到有效的认证信息")
}
return cookies, nil, nil
}
// xzmyTicketResult v1/tickets 成功响应
type xzmyTicketResult struct {
tgt string
ticket string
}
// parseXzmyTicket 解析 {"tgt":"...","ticket":"..."}
func parseXzmyTicket(body string) xzmyTicketResult {
return xzmyTicketResult{
tgt: jsonStrField(body, "tgt"),
ticket: jsonStrField(body, "ticket"),
}
}
// xzmyLoginError 解析登录失败的 meta.message / data.code,返回可读错误(无则空串)
func xzmyLoginError(body string) string {
if code := jsonStrField(body, "code"); code != "" {
switch code {
case "PASSERROR", "PWDERROR", "PASSWORDERROR":
return "用户名或密码不匹配,请检查后重试"
case "NOAUTH", "USERNOTFOUND", "NOUSER":
return "账号不存在或未注册统一身份认证"
case "CODEFALSE":
return "验证码错误"
}
return code
}
if msg := jsonStrField(body, "message"); msg != "" && msg != "ok" {
return msg
}
return ""
}
// jsonStrField 从(可能嵌套的)JSON 文本中提取指定 key 的字符串值(简化实现,够用即止)
func jsonStrField(body, key string) string {
needle := "\"" + key + "\":\""
i := strings.Index(body, needle)
if i < 0 {
return ""
}
i += len(needle)
j := strings.Index(body[i:], "\"")
if j < 0 {
return ""
}
return body[i : i+j]
}
@@ -0,0 +1,54 @@
package cpdaily
import (
"encoding/hex"
"math/big"
"testing"
)
// TestXzmyEncryptPassword 西藏民族大学密码加密:
// 教科书式 RSA(无填充)+ 小端序零填充 + 小写 hex,与站点前端库一致。
func TestXzmyEncryptPassword(t *testing.T) {
got, err := xzmyEncryptPassword("zzynb666")
if err != nil {
t.Fatalf("加密失败: %v", err)
}
// 金标准:由同一算法对 "zzynb666" 计算得到(该校 RSA 无填充,结果确定)
const golden = "1605e4173d74759db8ed176c1ba5c2e7a1fed2e1e9fb081977e1debb659868c6287c563b8baa74274339618bae9806defd96b3c4e779ac09f5e26ce03d593057998e212e271dacf547e5c53fded7d912113f8cbb52b8fc1f7ecbfda4ba0fd30d2f812ba4f667c29a1d8082169b5c9261babdeb56f7a255d1a27968e99a66083d"
if got != golden {
t.Fatalf("密文与预期不符\n got=%s\nwant=%s", got, golden)
}
// 复原:密文按公钥长度应为 chunkSize(=modulus 字节数) 的 hex(无前导零时位数可少)
modBytes, _ := hex.DecodeString(xzmyRsaModulusHex)
n := new(big.Int).SetBytes(modBytes)
if len(got) == 0 || len(got) > 2*((n.BitLen()+7)/8) {
t.Fatalf("密文长度异常: %d", len(got))
}
}
// TestXzmyTicketParse 票据解析
func TestXzmyTicketParse(t *testing.T) {
body := `{"tgt":"TGT-1-abc","ticket":"ST-1-xyz"}`
r := parseXzmyTicket(body)
if r.tgt != "TGT-1-abc" || r.ticket != "ST-1-xyz" {
t.Fatalf("解析错误: %+v", r)
}
}
// TestXzmyLoginError 登录失败业务码翻译
func TestXzmyLoginError(t *testing.T) {
cases := map[string]string{
`{"meta":{"success":true,"statusCode":200,"message":"ok"},"data":{"code":"PASSERROR","data":"5,1","userId":"1"}}`: "用户名或密码不匹配,请检查后重试",
`{"meta":{"success":true,"statusCode":200,"message":"ok"},"data":{"code":"NOAUTH"}}`: "账号不存在或未注册统一身份认证",
`{"meta":{"success":true,"statusCode":200,"message":"ok"},"data":{"code":"CODEFALSE"}}`: "验证码错误",
}
for body, want := range cases {
if got := xzmyLoginError(body); got != want {
t.Fatalf("xzmyLoginError(%s) = %q, want %q", body, got, want)
}
}
// 成功响应应返回空串(不误判为错误)
if got := xzmyLoginError(`{"tgt":"T","ticket":"ST"}`); got != "" {
t.Fatalf("成功响应不应报错, got %q", got)
}
}
+1 -1
View File
@@ -57,7 +57,7 @@ func fetchFromWecloud(wecloudUrl string, cookieMap map[string]string) (*CpdailyU
MobilePhone string `json:"mobilePhone"`
} `json:"data"`
}
if err := json.Unmarshal(body, &result); err != nil {
if err := unmarshalBody(body, &result); err != nil {
return nil, fmt.Errorf("解析 wecloud 用户信息失败: %w", err)
}
if result.ErrCode != 0 || result.Data == nil {
+13 -116
View File
@@ -1,32 +1,26 @@
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 位十六进制字符串)
// bareUuidPattern 匹配纯 uuid(32 位十六进制字符串,如 1583a66272854585833eb13b56fc1937)
var bareUuidPattern = regexp.MustCompile(`^[0-9a-fA-F]{32}$`)
// bareTokenPattern 匹配"整串即 uuid 的裸值",用于存库后回读的场景。
// 链接里的 uuid 允许 [0-9a-zA-Z_-]+(见 qrUuidPattern),并非都是 32 位 hex,
// 因此仅靠 bareUuidPattern 无法反解非 hex 的 uuid,会导致签到失败。
// 长度限制 8~64 且不含空白/URL 符号,避免把普通文本误判为 uuid。
var bareTokenPattern = regexp.MustCompile(`^[0-9a-zA-Z_-]{8,64}$`)
// ExtractQrUuid 从二维码文本内容中提取签到二维码 uuid。
// 支持完整链接(取 uuid= 参数值)或纯 uuid 字符串。
// 支持三种形式:完整链接(取 uuid= 参数值)、32 位 hex 裸 uuid、其它形式的裸 uuid token。
//
// 二维码图片的识别在前端完成(jsQR),后端只接收解析后的 uuid 文本,故此处不涉及图片解码。
func ExtractQrUuid(text string) string {
text = strings.TrimSpace(text)
if text == "" {
@@ -38,105 +32,8 @@ func ExtractQrUuid(text string) string {
if bareUuidPattern.MatchString(text) {
return strings.ToLower(text)
}
if bareTokenPattern.MatchString(text) {
return 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)
}
+7 -38
View File
@@ -1,8 +1,6 @@
package cpdaily
import (
"encoding/base64"
"os"
"testing"
)
@@ -15,6 +13,13 @@ func TestExtractQrUuid(t *testing.T) {
{" xxxxx?uuid=abc123XYZ&y=1 ", "abc123XYZ"},
{"no uuid here", ""},
{"", ""},
// 裸 uuid 回读(formData 只存 uuid 后的解析路径)
{"abc123XYZ", "abc123XYZ"}, // 非 32-hex 的裸 uuid
{"1583a66272854585833eb13b56fc1937", "1583a66272854585833eb13b56fc1937"}, // 32-hex
{"uuid_with-dash", "uuid_with-dash"}, // 含 _ 和 -
{"ab", ""}, // 太短,不认为是 uuid
{"has space x", ""}, // 含空白,不认为是 uuid
{"https://x.edu.cn/a/b", ""}, // URL 含符号,不走裸 token 分支
}
for _, c := range cases {
if got := ExtractQrUuid(c.in); got != c.want {
@@ -22,39 +27,3 @@ func TestExtractQrUuid(t *testing.T) {
}
}
}
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)
}
}
@@ -17,6 +17,44 @@ import (
"github.com/go-resty/resty/v2"
)
// ========== 登录态失效的统一识别 ==========
// ErrCookieExpired 表示今日校园的登录态(cookie)已失效:
// 请求被重定向到统一身份认证登录页,返回 HTML 而非接口 JSON。
// handler 层据此触发 cookie 续期(raw_password 重登 或 长期凭证换取),
// 续期失败则引导用户重新登录,而不是把「解析失败」这种模糊错误抛给用户。
var ErrCookieExpired = fmt.Errorf("cpdaily_cookie_expired")
// looksLikeHTML 判断响应体是否为一个 HTML 页面(而非接口 JSON)。
// 登录态失效时,今日校园会把请求 302 到 authserver 登录页,响应体是 HTML。
func looksLikeHTML(body []byte) bool {
head := body
if len(head) > 512 {
head = head[:512]
}
trimmed := strings.TrimSpace(string(head))
if trimmed == "" {
return false
}
if strings.HasPrefix(trimmed, "<") {
return true
}
lower := strings.ToLower(trimmed)
return strings.Contains(lower, "<!doctype html") ||
strings.Contains(lower, "<html")
}
// unmarshalBody 解析接口 JSON 响应体。
// 若响应体是 HTML(登录态失效被重定向到认证页),返回 ErrCookieExpired,
// 以便上层触发 cookie 续期或引导重新登录,而不是抛出难以理解的
// "invalid character '<' looking for beginning of value"。
func unmarshalBody(body []byte, v interface{}) error {
if looksLikeHTML(body) {
return ErrCookieExpired
}
return json.Unmarshal(body, v)
}
// ========== HTTP 客户端 ==========
var httpClient *resty.Client
@@ -176,6 +214,37 @@ func CpdailyLogin(ctx context.Context, school *SchoolDetailInfo, username, passw
if school.ID == XcuSchoolId {
return xcuCasLogin(ctx, school, username, password)
}
// 辽宁工程技术大学:平台配置 joinType=CLOUD(IsIap=true),但网关 /iap/doLogin 不存在(405),
// IAP 提交报 FAIL_UPNOTMATCH 误导为"密码错误";实际链路为 /iap 302 → 学校 CAS(authserver.lntu.edu.cn)
// → 回调 /iap/loginSuccess 建立网关会话,同 xcu 模式单独强制走 CAS(详见 login_lntu.go)
if school.ID == LntuSchoolId {
return lntuCasLogin(ctx, school, username, password)
}
// 西藏民族大学:平台配置 joinType=CLOUD(IsIap=true),但网关 IAP 账密不可用,
// IAP 提交报 FAIL_UPNOTMATCH("密码错误");实际为定制 CAS(cas.xzmu.edu.cn/lyuapServer,
// 密码=教科书RSA小端hex,POST /v1/tickets),单独适配(详见 login_xzmy.go)
if school.ID == XzmySchoolId {
return xzmyCasLogin(ctx, school, username, password)
}
// 兰州理工大学:平台配置 joinType=CLOUD(IsIap=true),但实际为 CAS-PaaS 统一身份认证
//(cas-paas.lut.edu.cn/cas/login,密码 RSA 加密 "__RSA__"+密文),IAP 提交报 FAIL_UPNOTMATCH
//("密码错误");单独强制走 CAS(详见 login_lut.go;CAS-PaaS 加密见 login_caspaas.go)
if school.ID == LutSchoolId {
return lutCasLogin(ctx, school, username, password)
}
// 华侨大学:写死强制走通用 CAS 流程——CAS 账密登录需 wisedu 定制滑块验证码
//(casLogin 内按 sliderIds 配置分流,见 slider.go),IAP 账密接口不兼容(FAIL_UPNOTMATCH)
if school.ID == HquSchoolId {
return casLogin(ctx, school, username, password)
}
// 合肥工业大学:平台配置 joinType=CLOUD(IsIap=true),idsUrl=hfut.campusphere.net/iap,
// 但网关 IAP 账密接口不兼容;实际链路为 /iap 302 → cas.hfut.edu.cn/cas/login
// (标准 Apereo CAS),密码 AES-ECB 加密、key 由 /cas/checkInitParams 下发的
// LOGIN_FLAVORING cookie 提供,通用 casLogin 拿不到该 key 故报"账号或密码错误";
// 单独适配(详见 login_hfut.go)
if school.ID == HfutSchoolId {
return hfutCasLogin(ctx, school, username, password)
}
if school.IsIap() || iapSchoolIds[school.ID] {
if !hasUsableLoginUrl(school) {
slog.Warn("IAP学校缺少登录地址", "school", school.Name, "schoolId", school.ID)
+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>`
@@ -0,0 +1,396 @@
package cpdaily
import (
"context"
"crypto/tls"
"encoding/base64"
"encoding/json"
"fmt"
"net/http"
"net/http/cookiejar"
"net/url"
"strings"
"time"
"github.com/go-resty/resty/v2"
)
// ========== 手机号验证码登录(无需选择学校) ==========
//
// 与 CAS 登录的差别:直接调用今日校园移动端鉴权接口,登录成功后响应里就带有
// tenantId(学校租户)与 openId(学工号),因此**无需用户先选择学校**。
//
// 流程(对标旧项目 likedream-go login/sms.go):
// 1. SendSmsCode(phone) → 今日校园下发短信验证码
// 2. MobileLoginByCode(phone, code) → 返回 tenantId/openId/sessionToken/tgc(CASTGC)
// 3. ExchangeCasCookie(loginData) → 用 sessionToken 换取学校 CAS cookie(含 MOD_AUTH_CAS)
//
// 该 cookie(含 CASTGC,长期有效)后续可反复换取 CAS cookie,即「长期凭证」。
// 加密方式与项目既有 crypto.go 一致(AES-128-CBC / DES-CBC,PKCS7)。
const (
smsSendURL = "https://mobile.campushoy.com/app/auth/authentication/mobile/messageCode/v-8222"
smsLoginURL = "https://mobile.campushoy.com/app/auth/authentication/mobileLogin/v-8222"
encryptVerion = "first_v4"
)
// SmsSendResult 发送短信验证码结果
type SmsSendResult struct {
Countdown int `json:"countdown"` // 可再次发送的倒计时(秒)
TipMsg string `json:"tipMsg"`
Status int `json:"status"`
}
// SmsLoginResult 手机号验证码登录结果(mobileLogin 响应解密后)
type SmsLoginResult struct {
AuthID string `json:"authId"`
AuthStatus string `json:"authStatus"`
CompleteUserInfoStatus string `json:"completeUserInfoStatus"`
DeviceExceptionMsg string `json:"deviceExceptionMsg"`
DeviceStatus string `json:"deviceStatus"`
FirstLogin bool `json:"firstLogin"`
Mobile string `json:"mobile"`
MsgTip string `json:"msgTip"`
Name string `json:"name"`
OpenID string `json:"openId"` // 学工号
PersonID string `json:"personId"`
SessionToken string `json:"sessionToken"`
Status string `json:"status"`
TenantID string `json:"tenantId"` // 学校租户 ID(= sys_user.school_id)
TenantName string `json:"tenantName"`
Tgc string `json:"tgc"` // CASTGC(长期凭证)
TgcStatus string `json:"tgcStatus"`
UserID string `json:"userId"` // 今日校园内部用户 ID(非学工号)
UserType string `json:"userType"`
}
// smsCommonResponse 今日校园短信接口通用响应
type smsCommonResponse[T any] struct {
ErrCode int `json:"errCode"`
ErrMsg string `json:"errMsg"`
Data T `json:"data"`
}
func newSmsClient() *resty.Client {
c := resty.New().
SetTimeout(20*time.Second).
SetTLSClientConfig(&tls.Config{InsecureSkipVerify: true})
// resty 默认 http.Client 无 CookieJar,需显式设置,否则 Set-Cookie 不会被保存
jar, _ := cookiejar.New(nil)
c.SetCookieJar(jar)
return c
}
// setSmsHeaders 设置短信接口所需的公共请求头(deviceId 可空)
func setSmsHeaders(req *resty.Request, deviceID, sessionToken string) {
if sessionToken != "" {
enc := base64.StdEncoding.EncodeToString(DesEncryptForCat([]byte(sessionToken)))
req.SetHeader("SessionToken", enc)
req.SetHeader("sessionTokenKey", enc)
}
cpdailyInfo := map[string]interface{}{
"deviceId": deviceID,
"systemName": "android",
"userId": "",
"appVersion": "9.8.8",
"model": "Unknown",
"lon": "0",
"lat": "0",
"cpdailyVersion": "9.8.8",
"systemVersion": "11",
}
infoBytes, _ := json.Marshal(cpdailyInfo)
encodedInfo := base64.StdEncoding.EncodeToString(DesEncryptForCat(infoBytes))
req.SetHeaders(map[string]string{
"clientType": "cpdaily_student",
"deviceType": "1",
"CpdailyClientType": "CPDAILY",
"CpdailyStandAlone": "0",
"User-Agent": "Mozilla/5.0 (Linux; Android 11; Redmi Note 8 Pro Build/RP1A.230805.003; wv) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/83.0.4103.106 Mobile Safari/537.36 cpdaily/9.8.8 wisedu/9.8.8",
"CpdailyInfo": encodedInfo,
})
}
// CreateDeviceID 生成一个今日校园设备码(用于短信登录,避免设备异常校验)
func CreateDeviceID() string {
s := fmt.Sprintf("%x", time.Now().UnixNano())
if len(s) < 32 {
s = s + strings.Repeat("0", 32-len(s))
}
return s[0:8] + "-" + s[8:12] + "-" + s[12:16] + "-" + s[16:20]
}
// SendSmsCode 发送手机短信验证码
func SendSmsCode(ctx context.Context, phone string) (*SmsSendResult, error) {
phoneEncrypted := base64.StdEncoding.EncodeToString(AesEncrypt([]byte(phone)))
client := newSmsClient()
req := client.R().
SetContext(ctx).
SetBody(map[string]string{
"a": phoneEncrypted,
"b": encryptVerion,
})
setSmsHeaders(req, "", "")
resp, err := req.Post(smsSendURL)
if err != nil {
return nil, fmt.Errorf("发送验证码请求失败: %w", err)
}
body := resp.Body()
var result smsCommonResponse[SmsSendResult]
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("%s", result.ErrMsg)
}
return &result.Data, nil
}
// MobileLoginByCode 手机号 + 短信验证码登录,返回 tenantId/openId/sessionToken/tgc 等
func MobileLoginByCode(ctx context.Context, phone, code string) (*SmsLoginResult, error) {
payload := map[string]any{
"d": code,
"e": "CPDAILY",
"c": phone,
"f": "86",
}
payloadBytes, _ := json.Marshal(payload)
encodedPayload := base64.StdEncoding.EncodeToString(AesEncrypt(payloadBytes))
client := newSmsClient()
req := client.R().
SetContext(ctx).
SetBody(map[string]any{
"a": encodedPayload,
"b": encryptVerion,
})
setSmsHeaders(req, "", "")
resp, err := req.Post(smsLoginURL)
if err != nil {
return nil, fmt.Errorf("登录请求失败: %w", err)
}
body := resp.Body()
var result smsCommonResponse[string]
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("%s", result.ErrMsg)
}
// data 为 base64(AES(json))
decoded, err := base64.StdEncoding.DecodeString(result.Data)
if err != nil {
return nil, fmt.Errorf("登录数据 base64 解码失败: %w", err)
}
plain := AesDecrypt(decoded)
var loginData SmsLoginResult
if err := json.Unmarshal(plain, &loginData); err != nil {
return nil, fmt.Errorf("登录数据解析失败: %w (raw: %.200s)", err, string(plain))
}
if loginData.TenantID == "" {
return nil, fmt.Errorf("登录响应缺少 tenantId")
}
return &loginData, nil
}
// ExchangeCasCookie 用短信登录得到的 sessionToken 换取学校 CAS cookie(含 MOD_AUTH_CAS)。
// 返回可直接序列化保存的 cookie 列表;其中 CASTGC(loginData.Tgc)为长期凭证。
func ExchangeCasCookie(ctx context.Context, school *SchoolDetailInfo, loginData *SmsLoginResult, deviceID string) ([]*http.Cookie, error) {
if school.WecloudUrl == "" {
return nil, fmt.Errorf("学校无可用域名")
}
host := strings.TrimSuffix(school.WecloudUrl, "/")
// AmpCookies 头:DES 加密后的 {AMP1,AMP2:[{name:sessionToken,value:...}]}
ampCookieMap := map[string]any{
"AMP2": []map[string]any{
{"path": "/", "value": loginData.SessionToken, "domain": "", "name": "sessionToken"},
},
"AMP1": []map[string]any{
{"path": "/", "value": loginData.SessionToken, "domain": "", "name": "sessionToken"},
},
}
ampBytes, _ := json.Marshal(ampCookieMap)
ampToken := base64.StdEncoding.EncodeToString(DesEncryptForCat(ampBytes))
client := newSmsClient()
// 不复制 Cookie 头,交由 resty 默认 cookie jar 管理(避免重定向时旧 cookie 覆盖新 cookie)
client.SetRedirectPolicy(resty.RedirectPolicyFunc(func(req *http.Request, via []*http.Request) error {
if len(via) >= 50 {
return fmt.Errorf("重定向次数过多")
}
for key, val := range via[0].Header {
if !strings.EqualFold(key, "Cookie") {
req.Header[key] = val
}
}
return nil
}))
// 预置基础 cookie(clientType/tenantId/standAlone/sessionToken;CASTGC 为长期凭证)
presetCookies := map[string]string{
"clientType": "cpdaily_student",
"tenantId": school.ID,
"standAlone": "0",
"sessionToken": loginData.SessionToken,
}
if loginData.Tgc != "" {
presetCookies["CASTGC"] = loginData.Tgc
}
targetSchoolURL, err := url.Parse(host)
if err != nil {
return nil, fmt.Errorf("解析学校地址失败: %w", err)
}
targetAPIURL, _ := url.Parse("https://api.campushoy.com")
for name, value := range presetCookies {
c := &http.Cookie{Name: name, Value: value}
if jar := client.GetClient().Jar; jar != nil {
jar.SetCookies(targetSchoolURL, []*http.Cookie{c})
if targetAPIURL != nil {
jar.SetCookies(targetAPIURL, []*http.Cookie{c})
}
}
}
req := client.R().
SetContext(ctx).
SetHeader("AmpCookies", ampToken)
setSmsHeaders(req, deviceID, loginData.SessionToken)
resp, err := req.Get(host + "/wec-im-group/group/imUserToken/getImUserToken")
if err != nil {
return nil, fmt.Errorf("换取 CAS cookie 请求失败: %w", err)
}
var common smsCommonResponse[json.RawMessage]
if err := json.Unmarshal(resp.Body(), &common); err != nil {
return nil, fmt.Errorf("解析换取 cookie 响应失败: %w (body: %.200s)", err, string(resp.Body()))
}
if common.ErrCode != 0 {
return nil, fmt.Errorf("换取 CAS cookie 失败: %s", common.ErrMsg)
}
// 从 jar 收集所有 cookie
var cookies []*http.Cookie
seen := map[string]bool{}
hosts := []string{host, "https://api.campushoy.com"}
for _, h := range hosts {
u, err := url.Parse(h)
if err != nil {
continue
}
for _, c := range client.GetClient().Jar.Cookies(u) {
if c.Name == "" || seen[c.Name] {
continue
}
seen[c.Name] = true
cookies = append(cookies, c)
}
}
if len(cookies) == 0 {
return nil, fmt.Errorf("未获取到有效 cookie")
}
return cookies, nil
}
// RefreshCasCookie 用已保存的长期 cookie(含 CASTGC/sessionToken)重新换取 CAS cookie。
// 适用于 cookie 过期时自动续期,避免要求用户重新登录。
func RefreshCasCookie(ctx context.Context, cookieMap map[string]string, host string, deviceID string) ([]*http.Cookie, error) {
sessionToken := cookieMap["sessionToken"]
if sessionToken == "" {
return nil, fmt.Errorf("缺少 sessionToken,无法续期")
}
client := newSmsClient()
// 与 ExchangeCasCookie 保持一致:跟随 CAS 重定向,且跨跳时保留原请求头
// (否则 UPDATE 网关 302 到 /iap/login 后不跟随,或跟随中丢失 AmpCookies/SessionToken,
// 都会落到登录页 HTML,无法换回 MOD_AUTH_CAS)。
client.SetRedirectPolicy(resty.RedirectPolicyFunc(func(req *http.Request, via []*http.Request) error {
if len(via) >= 50 {
return fmt.Errorf("重定向次数过多")
}
for key, val := range via[0].Header {
if !strings.EqualFold(key, "Cookie") {
req.Header[key] = val
}
}
return nil
}))
// 关键:把长期凭证(CASTGC 等)连同基础 cookie 预置进 jar。
// 手机号登录时 CASTGC(TGC)会随登录响应一起保存,是真正的长期 SSO 凭证;
// 仅凭 sessionToken 头(AmpCookies)无法续期——网关会 302 到登录页。
// 此前这里未预置 CASTGC,导致手机号登录账号一旦 MOD_AUTH_CAS 过期便无法自动续期。
presetCookies := map[string]string{
"clientType": "cpdaily_student",
"standAlone": "0",
"sessionToken": sessionToken,
}
if v := cookieMap["tenantId"]; v != "" {
presetCookies["tenantId"] = v
}
if v := cookieMap["CASTGC"]; v != "" {
presetCookies["CASTGC"] = v
}
targetSchoolURL, err := url.Parse(strings.TrimSuffix(host, "/"))
if err != nil {
return nil, fmt.Errorf("解析学校地址失败: %w", err)
}
targetAPIURL, _ := url.Parse("https://api.campushoy.com")
for name, value := range presetCookies {
c := &http.Cookie{Name: name, Value: value}
if jar := client.GetClient().Jar; jar != nil {
jar.SetCookies(targetSchoolURL, []*http.Cookie{c})
if targetAPIURL != nil {
jar.SetCookies(targetAPIURL, []*http.Cookie{c})
}
}
}
ampCookieMap := map[string]any{
"AMP2": []map[string]any{
{"path": "/", "value": sessionToken, "domain": "", "name": "sessionToken"},
},
"AMP1": []map[string]any{
{"path": "/", "value": sessionToken, "domain": "", "name": "sessionToken"},
},
}
ampBytes, _ := json.Marshal(ampCookieMap)
ampToken := base64.StdEncoding.EncodeToString(DesEncryptForCat(ampBytes))
req := client.R().
SetContext(ctx).
SetHeader("AmpCookies", ampToken)
setSmsHeaders(req, deviceID, sessionToken)
resp, err := req.Get(strings.TrimSuffix(host, "/") + "/wec-im-group/group/imUserToken/getImUserToken")
if err != nil {
return nil, fmt.Errorf("续期请求失败: %w", err)
}
var common smsCommonResponse[json.RawMessage]
if err := json.Unmarshal(resp.Body(), &common); err != nil {
return nil, fmt.Errorf("解析续期响应失败: %w", err)
}
if common.ErrCode != 0 {
return nil, fmt.Errorf("续期失败: %s", common.ErrMsg)
}
var cookies []*http.Cookie
u, err := url.Parse(host)
if err != nil {
return nil, err
}
for _, c := range client.GetClient().Jar.Cookies(u) {
cookies = append(cookies, c)
}
return cookies, nil
}
@@ -0,0 +1,78 @@
package cpdaily
import (
"context"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
// 复刻学校网关行为:未携带有效 CASTGC 时,getImUserToken 会 302 到 CAS 登录页;
// 携带 CASTGC 时,CAS 登录页换发一次性 ST 并回调原 service(下发 MOD_AUTH_CAS)。
// 用于验证 RefreshCasCookie 是否(1)把长期凭证 CASTGC 预置进客户端、(2)跟随重定向。
func newRefreshMockServer(t *testing.T) *httptest.Server {
t.Helper()
const validTGT = "iap-test-TGT-abc"
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch {
// CAS 登录入口:带有效 TGT 则换发 ST 回调 service;否则踢回登录页
case r.URL.Path == "/iap/login":
service := r.URL.Query().Get("service")
if c, err := r.Cookie("CASTGC"); err == nil && c.Value == validTGT {
w.Header().Set("Location", service+"?ticket=ST-iap:test:ST:x:20260101120000")
w.WriteHeader(http.StatusFound)
return
}
w.Header().Set("Location", "/iap/login/pc.html")
w.WriteHeader(http.StatusFound)
// service 回调:带 ticket 时下发 MOD_AUTH_CAS(模拟 CAS 建立会话)
case r.URL.Path == "/wec-im-group/group/imUserToken/getImUserToken":
if strings.HasPrefix(r.URL.Query().Get("ticket"), "ST-iap:") {
http.SetCookie(w, &http.Cookie{Name: "MOD_AUTH_CAS", Value: r.URL.Query().Get("ticket"), Path: "/"})
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"errCode":0,"errMsg":"","data":{}}`))
return
}
// 无有效会话:302 到 CAS 登录入口
w.Header().Set("Location", "/iap/login?service="+r.URL.Path)
w.WriteHeader(http.StatusFound)
default:
w.WriteHeader(http.StatusNotFound)
}
}))
}
// 携带 CASTGC 时应能续期成功并拿回 MOD_AUTH_CAS。
// 修复前 RefreshCasCookie 未预置 CASTGC,会落到登录页、解析 HTML 失败。
func TestRefreshCasCookiePresetsCASTGC(t *testing.T) {
srv := newRefreshMockServer(t)
defer srv.Close()
cookies, err := RefreshCasCookie(context.Background(), map[string]string{
"sessionToken": "st-test",
"tenantId": "lzufe",
"CASTGC": "iap-test-TGT-abc",
}, srv.URL, "device-1")
if err != nil {
t.Fatalf("续期应成功,实际报错: %v", err)
}
hasMOD := false
for _, c := range cookies {
if c.Name == "MOD_AUTH_CAS" {
hasMOD = true
}
}
if !hasMOD {
t.Fatalf("续期后应取得 MOD_AUTH_CAS,实际 cookies: %+v", cookies)
}
}
// 缺少 sessionToken 时保持既有保护:直接报错,不发请求。
func TestRefreshCasCookieRequiresSessionToken(t *testing.T) {
if _, err := RefreshCasCookie(context.Background(), map[string]string{
"CASTGC": "iap-test-TGT-abc",
}, "https://school.campusphere.net", "device-1"); err == nil {
t.Fatal("缺少 sessionToken 应报错")
}
}
@@ -97,7 +97,7 @@ func (r *submitCaptchaResolver) GetValidationInfo() (*ValidationInfo, error) {
body, _ := io.ReadAll(resp.RawBody())
var apiResp apiResponse
if err := json.Unmarshal(body, &apiResp); err != nil {
if err := unmarshalBody(body, &apiResp); err != nil {
return nil, fmt.Errorf("解析验证信息响应失败: %w", err)
}
@@ -165,7 +165,7 @@ func (r *submitCaptchaResolver) createCaptcha(info *ValidationInfo) (*CpSubmitCa
body, _ := io.ReadAll(resp.RawBody())
var apiResp apiResponse
if err := json.Unmarshal(body, &apiResp); err != nil {
if err := unmarshalBody(body, &apiResp); err != nil {
return nil, fmt.Errorf("解析创建验证码响应失败: %w", err)
}
@@ -261,7 +261,7 @@ func (r *submitCaptchaResolver) submitCaptcha(info *ValidationInfo, captchaInfo
body, _ := io.ReadAll(resp.RawBody())
var apiResp apiResponse
if err := json.Unmarshal(body, &apiResp); err != nil {
if err := unmarshalBody(body, &apiResp); err != nil {
return "", fmt.Errorf("解析提交验证码响应失败: %w", err)
}
+478 -79
View File
@@ -26,6 +26,11 @@ type TaskType string
const (
TaskTypeSign TaskType = "sign"
TaskTypeSleepCheck TaskType = "sleep_check"
TaskTypeLeave TaskType = "leave" // 销假(请假销假)图片上传使用
// TaskTypeReturnRegister 节假日离返校的返校登记(确认到达)
TaskTypeReturnRegister TaskType = "return_register"
// TaskTypeWorklog 教师端「工作日志」(独立套件 wec-counselor-worklog-apps,仅教师账号可用)
TaskTypeWorklog TaskType = "worklog"
)
// 各任务类型的 API 基础路径
@@ -160,7 +165,7 @@ func fetchTasksByEndpoint(cookieMap map[string]string, host string, taskType Tas
ErrMsg string `json:"errMsg"`
Datas map[string]interface{} `json:"datas"`
}
if err := json.Unmarshal(body, &result); err != nil {
if err := unmarshalBody(body, &result); err != nil {
return nil, fmt.Errorf("解析任务列表响应失败: %w", err)
}
if result.ErrCode != 0 {
@@ -240,23 +245,26 @@ func parseTasksFromDatas(datas map[string]interface{}) []TodayTaskInfo {
return tasks
}
// FetchAllRecentTasks 并发查询 sign + sleep_check 两种任务类型,聚合去重后返回
func FetchAllRecentTasks(cookieMap map[string]string, host string) []TodayTaskInfo {
// FetchAllRecentTasks 聚合各打卡套件的待办:签到(sign) + 查寝(sleep_check) +
// 返校登记(return_register) + 教师端工作日志(worklog),去重后返回。
//
// 会话失效判定(重要):教师账号在学生端套件(sign/sleep_check/return_register)本无权限,
// 调用会被网关拒绝;若只以签到/查寝是否成功来判断会话有效性,教师账号会被误判为
// 「登录凭证已过期」。故只要**任一**套件成功返回,即认为会话有效;仅当全部套件都失败
// 且首个错误表明登录态失效时才返回错误。
func FetchAllRecentTasks(cookieMap map[string]string, host string) ([]TodayTaskInfo, error) {
type result struct {
tasks []TodayTaskInfo
err error
}
ch := make(chan result, 2)
types := []TaskType{TaskTypeSign, TaskTypeSleepCheck}
ch := make(chan result, len(types))
for _, tt := range types {
go func(t TaskType) {
tasks, err := FetchTodayTasks(cookieMap, host, t)
if err != nil {
// 记录失败原因,避免签到/查寝某一类型静默失败(如接口错误、cookie 过期等)
slog.Warn("获取近期任务失败", "taskType", t, "host", host, "error", err)
}
// 标记任务类型
for i := range tasks {
tasks[i].TaskType = string(t)
}
@@ -266,23 +274,60 @@ func FetchAllRecentTasks(cookieMap map[string]string, host string) []TodayTaskIn
seen := map[string]bool{}
var all []TodayTaskInfo
for i := 0; i < 2; i++ {
var firstErr error
signSuccess := 0
for i := 0; i < len(types); i++ {
r := <-ch
for _, t := range r.tasks {
key := t.TaskName + "|" + t.TaskType
if !seen[key] {
seen[key] = true
all = append(all, t)
if r.err != nil {
if firstErr == nil {
firstErr = r.err
}
} else {
signSuccess++
}
appendUnique(&all, seen, r.tasks)
}
// 返校登记:成功也作为「会话有效」的证据(教师/学生均可能开通)
rrSuccess := false
if rr, rerr := FetchReturnRegisterTasks(cookieMap, host); rerr == nil {
rrSuccess = true
appendUnique(&all, seen, rr)
} else {
slog.Warn("获取返校登记待办失败", "host", host, "error", rerr)
}
// 工作日志(教师端):成功同样作为会话有效证据
wlSuccess := false
if wl, werr := FetchWorklogTasks(cookieMap, host); werr == nil {
wlSuccess = true
appendUnique(&all, seen, wl)
} else if isWorklogForbidden(werr) {
slog.Debug("该账号无工作日志权限(正常现象)", "host", host)
} else if isWorklogUnauthorized(werr) {
slog.Debug("工作日志接口未授权(非教师账号)", "host", host)
} else {
slog.Warn("获取工作日志任务失败", "host", host, "error", werr)
}
// 会话有效:任一学生端套件成功,或返校登记/工作日志成功
if signSuccess == 0 && !rrSuccess && !wlSuccess && firstErr != nil {
return nil, firstErr
}
return all, nil
}
// appendUnique 按 "TaskName|TaskType" 去重追加
func appendUnique(dst *[]TodayTaskInfo, seen map[string]bool, items []TodayTaskInfo) {
for _, t := range items {
key := t.TaskName + "|" + t.TaskType
if !seen[key] {
seen[key] = true
*dst = append(*dst, t)
}
}
if len(all) == 0 {
return []TodayTaskInfo{}
}
return all
}
func fetchTasksByYearMonth(cookieMap map[string]string, host string, taskType TaskType, year, month string) ([]TodayTaskInfo, error) {
yearMonth := fmt.Sprintf("%s-%s", year, month)
return fetchTasksByEndpoint(cookieMap, host, taskType, yearMonth)
@@ -370,8 +415,11 @@ func GetTaskDetailByWid(cookieMap map[string]string, host string, taskType TaskT
return fetchTaskDetailByWid(cookieMap, host, taskType, signWid, signInstanceWid, title)
}
// FetchTodayTaskDetail 任务执行专用:按任务名从当天列表实时匹配未签到的实例并拉取详情。
// FetchTodayTaskDetail 任务执行专用:按任务名从当天列表实时匹配任务实例并拉取详情。
// wid 每次执行时自动获取,不依赖配置快照;匹配失败直接报明确错误,不做任何回退。
// 匹配优先级:优先未签到实例(正常签到);若无未签到实例但存在已签到实例,则用已签到实例
// 重新提交(允许重签,便于用户在首次位置填错、或需范围外重签时再次执行;是否真正接受
// 由今日校园服务端决定)。
func FetchTodayTaskDetail(cookieMap map[string]string, host string, taskType TaskType, title string) (*TaskFormDetail, error) {
tasks, err := fetchTasksByEndpoint(cookieMap, host, taskType, "")
if err != nil {
@@ -392,14 +440,22 @@ func FetchTodayTaskDetail(cookieMap map[string]string, host string, taskType Tas
signed = &s
}
}
// 无未签到实例但有已签到实例:放开重签,用已签到实例继续提交
resign := false
if target == nil {
if signed != nil {
return nil, fmt.Errorf("「%s」今日已签到,无需重复执行", title)
if signed == nil {
return nil, fmt.Errorf("当日任务列表中未找到「%s」", title)
}
return nil, fmt.Errorf("当日任务列表中未找到「%s」", title)
target = signed
resign = true
}
if resign {
slog.Info("无未签到实例,允许对已签到实例重新提交",
"title", title, "signInstanceWid", target.SignInstanceWid, "taskType", taskType)
} else {
slog.Info("按任务名实时匹配到当日任务实例",
"title", title, "signInstanceWid", target.SignInstanceWid, "taskType", taskType)
}
slog.Info("按任务名实时匹配到当日任务实例",
"title", title, "signInstanceWid", target.SignInstanceWid, "taskType", taskType)
detail, err := fetchTaskDetailByWid(cookieMap, host, taskType, target.SignWid, target.SignInstanceWid, target.TaskName)
if err != nil {
return nil, err
@@ -438,7 +494,7 @@ func fetchTaskDetailByWid(cookieMap map[string]string, host string, taskType Tas
ErrMsg string `json:"errMsg"`
Datas map[string]interface{} `json:"datas"`
}
if err := json.Unmarshal(body, &result); err != nil {
if err := unmarshalBody(body, &result); err != nil {
return nil, fmt.Errorf("解析任务详情响应失败: %w", err)
}
if result.ErrCode != 0 {
@@ -477,6 +533,168 @@ func fetchTaskDetailByWid(cookieMap map[string]string, host string, taskType Tas
return detail, nil
}
// SignCheckResult 人脸识别复核结果
type SignCheckResult struct {
IsFaceIdentify bool // 该任务是否开启人脸识别
ProgressCode int // recognitionProgressCode
Progress string // recognitionProgress(非空=被驳回文案)
SignStatus string // signStatus
Rejected bool // 是否被系统驳回(人脸核验失败)
Settled bool // 是否已出明确结果(成功或驳回)
Message string // 驳回文案(供 SSE 展示)
}
// evalFaceIdentify 判定人脸识别复核结果(纯函数,便于单测)。
//
// 依据(detailSignInstance 响应 datas 顶层字段):
// - recognitionProgress 非空 → 系统已给出驳回/进展文案,判为驳回
// - recognitionProgressCode 非 0 → 已出明确进展(2=驳回);0/缺省=无异常
// - isFaceIdentify=1 → 该任务开启人脸识别
//
// 返回 Settled=true 表示已出明确结果(Rejected 指示成功/驳回);Settled=false 表示
// 人脸识别仍在进行中(尚无结果),调用方应继续等待轮询。
func evalFaceIdentify(rows map[string]interface{}) SignCheckResult {
res := SignCheckResult{}
if rows == nil {
return res
}
if v, ok := rows["isFaceIdentify"]; ok {
res.IsFaceIdentify = mapValueInt(v) == 1
}
res.SignStatus = mapValueStr(rows, "signStatus")
res.Progress = strings.TrimSpace(mapValueStr(rows, "recognitionProgress"))
res.ProgressCode = mapValueInt(rows["recognitionProgressCode"])
// 有驳回文案 → 明确驳回
if res.Progress != "" {
res.Rejected = true
res.Settled = true
res.Message = res.Progress
return res
}
// 有明确进展码(非 0)→ 已出结果;2 为驳回,其余视为已处理
if res.ProgressCode != 0 {
res.Settled = true
if res.ProgressCode == 2 {
res.Rejected = true
res.Message = "系统自动驳回了本次签到(人脸识别未通过),请重新尝试"
}
return res
}
// 无异常文案、无进展码:若已签到成功(signStatus=4)视为明确成功;
// 否则视为人脸识别仍在进行中(未出结果),交由上层继续轮询。
if res.SignStatus == "4" {
res.Settled = true
}
return res
}
// mapValueInt 兼容提取 map 中的整数值(float64/json.Number/string 等)
func mapValueInt(v interface{}) int {
switch t := v.(type) {
case nil:
return 0
case float64:
return int(t)
case int:
return t
case int64:
return int(t)
case json.Number:
n, _ := t.Int64()
return int(n)
case string:
n, _ := strconv.Atoi(strings.TrimSpace(t))
return n
default:
n, _ := strconv.Atoi(strings.TrimSpace(fmt.Sprintf("%v", t)))
return n
}
}
// NeedsFaceIdentify 判断任务详情是否开启人脸识别(isFaceIdentify=1)。
// 用于提交后是否需要回查人工核验结果。
func NeedsFaceIdentify(rows map[string]interface{}) bool {
if rows == nil {
return false
}
v, ok := rows["isFaceIdentify"]
if !ok {
return false
}
return mapValueInt(v) == 1
}
// CheckFaceIdentifyResult 提交后复核人脸识别是否被系统驳回。
//
// detailSignInstance 响应形如 {"code":"0","message":"SUCCESS","datas":{...}},
// 人脸字段在 datas 顶层:isFaceIdentify / recognitionProgress /
// recognitionProgressCode / signStatus。注意该接口用 code(字符串)而非 errCode。
func CheckFaceIdentifyResult(cookieMap map[string]string, host string, taskType TaskType,
signWid, signInstanceWid, title string) (*SignCheckResult, error) {
client := newTaskClient()
cookieStr := cookiesToStr(cookieMap)
baseURL := taskBasePath(host, taskType)
resp, err := 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", cookieStr).
SetBody(map[string]interface{}{
"taskName": title,
"signWid": signWid,
"signInstanceWid": signInstanceWid,
}).
Post(baseURL + "/detailSignInstance")
if err != nil {
return nil, fmt.Errorf("请求人脸识别结果失败: %w", err)
}
defer resp.RawBody().Close()
body, _ := io.ReadAll(resp.RawBody())
var result struct {
Code json.RawMessage `json:"code"`
ErrCode int `json:"errCode"`
ErrMsg string `json:"errMsg"`
Message string `json:"message"`
Datas map[string]interface{} `json:"datas"`
}
if err := unmarshalBody(body, &result); err != nil {
return nil, fmt.Errorf("解析人脸识别结果响应失败: %w", err)
}
// code 为字符串("0")或数字(0)两种方言,统一判定
if codeStr := rawCodeString(result.Code); codeStr != "" && codeStr != "0" {
msg := result.Message
if msg == "" {
msg = result.ErrMsg
}
return nil, fmt.Errorf("人脸识别结果接口错误(%s): %s", codeStr, msg)
}
if result.ErrCode != 0 {
return nil, fmt.Errorf("人脸识别结果接口错误(%d): %s", result.ErrCode, result.ErrMsg)
}
res := evalFaceIdentify(result.Datas)
return &res, nil
}
// rawCodeString 归一化响应 code 字段("0"/0/"1"/1 等)为字符串
func rawCodeString(raw json.RawMessage) string {
if len(raw) == 0 || string(raw) == "null" {
return ""
}
var s string
if json.Unmarshal(raw, &s) == nil {
return strings.TrimSpace(s)
}
var n json.Number
if json.Unmarshal(raw, &n) == nil {
return n.String()
}
return ""
}
// ParseTaskForm 将任务详情解析为前端可渲染的表单项列表
func ParseTaskForm(detail *TaskFormDetail, historyRows map[string]interface{}) []cpdmodel.CpTaskFormItem {
rows := detail.Rows
@@ -497,47 +715,59 @@ func ParseTaskForm(detail *TaskFormDetail, historyRows map[string]interface{}) [
histSignPhotoUrl := ""
histIsMalposition := ""
// 优先从 signPlaceSelected 提取(学校配置的签到范围点,cpdaily 返回 JSON 字符串或数组两种格式)
// 有历史表单时是历史已选地点,无历史表单时是学校设置的默认签到范围,用于兜底填充
if sps, ok := histRows["signPlaceSelected"]; ok {
var places []map[string]interface{}
switch v := sps.(type) {
case string:
if v != "" && v != "[]" {
if err := json.Unmarshal([]byte(v), &places); err != nil {
places = nil
}
}
case []interface{}:
for _, p := range v {
if m, ok := p.(map[string]interface{}); ok {
places = append(places, m)
}
}
}
if len(places) > 0 {
// 取第一个签到范围点(主校区);无历史记录时作为默认坐标兜底
if lng := mapValueStr(places[0], "longitude"); lng != "" {
histLon = lng
}
if lat := mapValueStr(places[0], "latitude"); lat != "" {
histLat = lat
}
if addr := mapValueStr(places[0], "address"); addr != "" {
histAddress = addr
}
}
// 签到范围点(校验坐标是否在打卡范围内):优先当前任务详情,其次历史详情
rangePlaces := parseSignPlaces(rows)
if len(rangePlaces) == 0 {
rangePlaces = parseSignPlaces(histRows)
}
// 降级到 histRows 中的 longitude/latitude
// 优先使用历史记录中的真实签到位置(坐标 + 地址),
// 但坐标必须仍落在打卡范围内——超出范围的坐标提交会被服务端以「位置不在签到范围内」拒绝,
// 此时丢弃历史坐标,改由下方回退到学校配置的签到范围点。
histLon = mapValueStr(histRows, "longitude")
histLat = mapValueStr(histRows, "latitude")
if histLon != "" && histLat != "" && coordsWithinSignRange(histLat, histLon, rangePlaces) {
histAddress = mapValueStr(histRows, "signAddress")
if histAddress == "" {
histAddress = mapValueStr(histRows, "position")
}
} else {
// 历史坐标缺失或超出范围:不采用历史坐标(地址一并丢弃,保持与坐标同源)
histLon, histLat = "", ""
}
// 回退:学校配置的签到范围点(signPlaceSelected[0],主校区),无历史记录时作为默认坐标
if histLon == "" || histLat == "" {
if lon, ok := histRows["longitude"]; ok && lon != nil {
histLon = fmt.Sprintf("%v", lon)
}
if lat, ok := histRows["latitude"]; ok && lat != nil {
histLat = fmt.Sprintf("%v", lat)
if sps, ok := histRows["signPlaceSelected"]; ok {
var places []map[string]interface{}
switch v := sps.(type) {
case string:
if v != "" && v != "[]" {
if err := json.Unmarshal([]byte(v), &places); err != nil {
places = nil
}
}
case []interface{}:
for _, p := range v {
if m, ok := p.(map[string]interface{}); ok {
places = append(places, m)
}
}
}
if len(places) > 0 {
if lng := mapValueStr(places[0], "longitude"); lng != "" {
histLon = lng
}
if lat := mapValueStr(places[0], "latitude"); lat != "" {
histLat = lat
}
if addr := mapValueStr(places[0], "address"); addr != "" {
histAddress = addr
}
}
}
}
// 如果还是空的,再降级到当前表单中的 longitude/latitude(任务默认坐标)
// 再降级到当前表单中的 longitude/latitude(任务默认坐标)
if histLon == "" || histLat == "" {
if lon, ok := rows["longitude"]; ok && lon != nil {
histLon = fmt.Sprintf("%v", lon)
@@ -546,7 +776,7 @@ func ParseTaskForm(detail *TaskFormDetail, historyRows map[string]interface{}) [
histLat = fmt.Sprintf("%v", lat)
}
}
// 地址降级链:signPlaceSelected[].address → histRows.position → histRows.signAddress → rows.position → rows.signAddress
// 地址兜底链:histRows.position → histRows.signAddress → rows.position → rows.signAddress
if histAddress == "" {
if pos, ok := histRows["position"]; ok && pos != nil {
histAddress = fmt.Sprintf("%v", pos)
@@ -724,27 +954,124 @@ func ParseTaskForm(detail *TaskFormDetail, historyRows map[string]interface{}) [
return list
}
// extractScanPosition 提取扫码签到的经纬度与地址,供表单预填与提交使用。
// 取值优先级与普通签到一致:signPlaceSelected[0](学校配置/历史已选范围点)
// → 历史表单 / 详情的 longitude|latitude → 详情的 position|signAddress(地址)。
// 扫码签到若不带定位,服务端会因经度/纬度为空串而拒绝提交(报 "empty String")。
func extractScanPosition(detail *TaskFormDetail, histRows map[string]interface{}) (lon, lat, address string) {
rows := detail.Rows
if histRows != nil {
if sps, ok := histRows["signPlaceSelected"]; ok {
var places []map[string]interface{}
switch v := sps.(type) {
case string:
if v != "" && v != "[]" {
if err := json.Unmarshal([]byte(v), &places); err != nil {
places = nil
}
}
case []interface{}:
for _, p := range v {
if m, ok := p.(map[string]interface{}); ok {
places = append(places, m)
}
}
}
if len(places) > 0 {
lon = mapValueStr(places[0], "longitude")
lat = mapValueStr(places[0], "latitude")
address = mapValueStr(places[0], "address")
}
}
}
// 降级:历史表单里的 longitude/latitude
if histRows != nil {
if lon == "" {
lon = mapValueStr(histRows, "longitude")
}
if lat == "" {
lat = mapValueStr(histRows, "latitude")
}
}
// 再降级:当前任务详情里的 longitude/latitude(学校默认坐标)
if lon == "" {
lon = mapValueStr(rows, "longitude")
}
if lat == "" {
lat = mapValueStr(rows, "latitude")
}
// 地址降级链
if address == "" && histRows != nil {
address = mapValueStr(histRows, "position")
}
if address == "" && histRows != nil {
address = mapValueStr(histRows, "signAddress")
}
if address == "" {
address = mapValueStr(rows, "position")
}
if address == "" {
address = mapValueStr(rows, "signAddress")
}
return lon, lat, address
}
// parseScanTaskForm 生成扫码签到任务(signMode=2)的表单项:
// 签到二维码(必填,支持上传图片或粘贴链接)+ 可选签到照片 + 可选额外字段
// 经纬度/地址 + 可选签到照片 + 可选额外字段 + 签到二维码(放最后)。
// 字段顺序与普通签到保持一致(定位在前),二维码作为扫码任务特有项置于末尾。
func parseScanTaskForm(detail *TaskFormDetail, histRows map[string]interface{}) []cpdmodel.CpTaskFormItem {
rows := detail.Rows
var list []cpdmodel.CpTaskFormItem
// 签到二维码
// 二维码ID(uuid):历史值若为可解析的链接/文本,统一归一化为 uuid 存储,
// 避免把整条链接(乃至 base64 图片)写进 formData。
histQr := ""
if histRows != nil {
if v, ok := histRows["qrCode"]; ok && v != nil {
histQr = fmt.Sprintf("%v", v)
raw := fmt.Sprintf("%v", v)
if uuid := ExtractQrUuid(raw); uuid != "" {
histQr = uuid
} else if !IsDataURIImage(raw) {
histQr = raw
}
}
}
// 经纬度与地址:扫码签到接口同样要求提交定位(缺省会因空串被服务端拒绝,
// 报 "empty String"),且用户可在表单中选择签到范围点,故与普通签到保持一致的字段。
scanLon, scanLat, scanAddress := extractScanPosition(detail, histRows)
list = append(list, cpdmodel.CpTaskFormItem{
Title: "签到二维码",
ColName: "qrCode",
Type: cpdmodel.ItemTypeImage,
Title: "经度",
ColName: "lon",
Type: cpdmodel.ItemTypeInput,
Required: true,
Content: histQr,
Content: scanLon,
Default: scanLon,
Show: true,
Tip: "上传现场签到二维码图片(或直接粘贴二维码链接/内容),执行时自动解析出 uuid 提交",
Tip: "签到地点经度坐标",
})
list = append(list, cpdmodel.CpTaskFormItem{
Title: "纬度",
ColName: "lat",
Type: cpdmodel.ItemTypeInput,
Required: true,
Content: scanLat,
Default: scanLat,
Show: true,
Tip: "签到地点纬度坐标",
})
list = append(list, cpdmodel.CpTaskFormItem{
Title: "签到具体地址",
ColName: "address",
Type: cpdmodel.ItemTypeInput,
Required: true,
Content: scanAddress,
Default: scanAddress,
Show: true,
Tip: "签到完毕后查看时最底部那个最详细的地址信息",
})
// 签到照片(任务要求时保留)
@@ -815,6 +1142,17 @@ func parseScanTaskForm(detail *TaskFormDetail, histRows map[string]interface{})
}
}
// 二维码ID(uuid)放最后:扫码任务特有项,置于定位/照片/额外字段之后
list = append(list, cpdmodel.CpTaskFormItem{
Title: "二维码ID",
ColName: "uuid",
Type: cpdmodel.ItemTypeImage,
Required: true,
Content: histQr,
Show: true,
Tip: "上传现场签到二维码图片,自动解析并保存二维码ID(uuid)",
})
return list
}
@@ -967,24 +1305,66 @@ func fillExtraFields(rows map[string]interface{}, formItems []cpdmodel.CpTaskFor
}
// fillScanSubmitForm 生成扫码签到(signMode=2)的提交表单:
// 解析签到二维码(图片/链接)得到 uuid,用 qrUuid 替代 signInstanceWid(两者不并存),
// 不携带经纬度等定位字段。
// 取二维码ID(uuid)字段,提取 uuid 后用 qrUuid 替代 signInstanceWid(两者不并存)。
// 定位字段与普通签到一致地提交:服务端对扫码签到的提交体同样要求经度/纬度,
// 缺失会因空串转数字失败而返回 "empty String",故此处带上 lon/lat/position。
// 字段名兼容:新数据为 uuid,历史数据为 qrCode。
// 二维码图片的识别在前端完成,这里只处理已解析出的 uuid 文本。
func fillScanSubmitForm(rows map[string]interface{}, formItems []cpdmodel.CpTaskFormItem) (map[string]interface{}, error) {
qrContent := ""
for _, v := range formItems {
if v.ColName == "qrCode" {
if v.ColName == "uuid" || v.ColName == "qrCode" {
qrContent = strings.TrimSpace(v.Content)
break
}
}
uuid, err := ResolveQrUuidFromContent(qrContent)
if err != nil {
return nil, fmt.Errorf("二维码签到任务缺少有效签到二维码: %w", err)
uuid := ExtractQrUuid(qrContent)
if uuid == "" {
return nil, fmt.Errorf("二维码签到任务缺少有效的二维码ID(uuid),请重新上传二维码解析")
}
// 经纬度与地址(与普通签到一致):缺失会被服务端判定为参数为空
var scanLon, scanLat, scanAddress string
for _, v := range formItems {
switch v.ColName {
case "lon":
scanLon = strings.TrimSpace(v.Content)
case "lat":
scanLat = strings.TrimSpace(v.Content)
case "address":
scanAddress = strings.TrimSpace(v.Content)
case "latAndLon":
parts := strings.Split(v.Content, ",")
if len(parts) == 2 {
if scanLon == "" {
scanLon = strings.TrimSpace(parts[0])
}
if scanLat == "" {
scanLat = strings.TrimSpace(parts[1])
}
}
}
}
// 表单未提供定位时,回退到任务详情中的学校默认坐标
if scanLon == "" {
scanLon = mapValueStr(rows, "longitude")
}
if scanLat == "" {
scanLat = mapValueStr(rows, "latitude")
}
if scanAddress == "" {
scanAddress = mapValueStr(rows, "position")
}
if scanAddress == "" {
scanAddress = mapValueStr(rows, "signAddress")
}
result := map[string]interface{}{
"qrUuid": uuid,
"uaIsCpadaily": true,
"longitude": scanLon,
"latitude": scanLat,
"position": scanAddress,
}
// 签到照片(任务要求时保留,与普通签到一致)
@@ -1078,6 +1458,25 @@ func parseSignPlaces(rows map[string]interface{}) []SignPlace {
return places
}
// coordsWithinSignRange 判断坐标是否落在任一签到范围点内。
// places 为空(学校未配置范围点)时返回 true——无范围信息则不做限制,沿用历史坐标。
func coordsWithinSignRange(latStr, lonStr string, places []SignPlace) bool {
if len(places) == 0 {
return true
}
lat, err1 := strconv.ParseFloat(strings.TrimSpace(latStr), 64)
lon, err2 := strconv.ParseFloat(strings.TrimSpace(lonStr), 64)
if err1 != nil || err2 != nil {
return false
}
for _, p := range places {
if haversineMeters(lat, lon, p.LatitudeF, p.LongitudeF) <= p.RadiusMeters {
return true
}
}
return false
}
// haversineMeters 计算两个经纬度坐标之间的球面距离(米)
func haversineMeters(lat1, lon1, lat2, lon2 float64) float64 {
const earthRadius = 6371000.0
@@ -1247,7 +1646,7 @@ func SubmitSign(cookieMap map[string]string, host string, taskType TaskType, sub
Code json.Number `json:"code"`
Message string `json:"message"`
}
if err := json.Unmarshal(body, &result); err != nil {
if err := unmarshalBody(body, &result); err != nil {
return fmt.Errorf("解析签到响应失败: %w, body: %s", err, string(body))
}
code, _ := result.Code.Int64()
@@ -0,0 +1,22 @@
package cpdaily
import "testing"
// appendUnique 去重:同名同类型只保留一次,不同类型同名分别保留
func TestAppendUnique(t *testing.T) {
seen := map[string]bool{}
var dst []TodayTaskInfo
appendUnique(&dst, seen, []TodayTaskInfo{
{TaskName: "学管值班", TaskType: "worklog"},
{TaskName: "学管值班", TaskType: "worklog"}, // 重复
{TaskName: "晚查寝", TaskType: "sleep_check"},
})
if len(dst) != 2 {
t.Fatalf("应保留 2 条,得到 %d: %+v", len(dst), dst)
}
// 同名不同类型应共存
appendUnique(&dst, seen, []TodayTaskInfo{{TaskName: "学管值班", TaskType: "sign"}})
if len(dst) != 3 {
t.Fatalf("同名不同类型应共存,得到 %d", len(dst))
}
}
@@ -6,14 +6,17 @@ import (
cpdmodel "cpdaily-new/internal/model"
)
// 扫码任务:提交表单必须用 qrUuid 替代 signInstanceWid,且不携带经纬度
// 扫码任务:提交表单必须用 qrUuid 替代 signInstanceWid,并携带经纬度/地址
func TestFillScanSubmitForm(t *testing.T) {
rows := map[string]interface{}{
"signMode": float64(2),
"signInstanceWid": "397161",
"longitude": "102.7",
"latitude": "25.0",
"position": "昆明文理学院",
}
formItems := []cpdmodel.CpTaskFormItem{
{Title: "签到二维码", ColName: "qrCode", Type: cpdmodel.ItemTypeImage,
{Title: "二维码ID", ColName: "uuid", 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")
@@ -26,11 +29,12 @@ func TestFillScanSubmitForm(t *testing.T) {
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")
// 定位字段必须携带(缺失会因空串被服务端拒绝,报 "empty String")
if form["longitude"] != "102.7" || form["latitude"] != "25.0" {
t.Fatalf("scan form must carry lon/lat, got lon=%v lat=%v", form["longitude"], form["latitude"])
}
if _, ok := form["longitude"]; ok {
t.Fatal("scan submit form must NOT contain longitude")
if form["position"] != "昆明文理学院" {
t.Fatalf("scan form must carry position, got %v", form["position"])
}
if form["uaIsCpadaily"] != true {
t.Fatal("uaIsCpadaily missing")
@@ -38,7 +42,29 @@ func TestFillScanSubmitForm(t *testing.T) {
// 缺少二维码 → 明确报错(不再静默生成会假成功的表单)
if _, err := FillSubmitForm(rows, nil, "device-1"); err == nil {
t.Fatal("missing qrCode should return error")
t.Fatal("missing uuid should return error")
}
// 只存 uuid 的裸值(新流程):必须能正确解析并提交(含非 32-hex 的 uuid)
for _, bare := range []string{"1583a66272854585833eb13b56fc1937", "abc123XYZ"} {
items := []cpdmodel.CpTaskFormItem{
{Title: "二维码ID", ColName: "uuid", Type: cpdmodel.ItemTypeImage, Content: bare},
}
f, err := FillSubmitForm(rows, items, "device-1")
if err != nil {
t.Fatalf("bare uuid %q should resolve, got error: %v", bare, err)
}
if f["qrUuid"] != bare {
t.Fatalf("bare uuid %q → qrUuid = %v", bare, f["qrUuid"])
}
}
// 历史数据(colName 仍为 qrCode)继续兼容
oldItems := []cpdmodel.CpTaskFormItem{
{Title: "签到二维码", ColName: "qrCode", Type: cpdmodel.ItemTypeImage, Content: "1583a66272854585833eb13b56fc1937"},
}
if f, err := FillSubmitForm(rows, oldItems, "device-1"); err != nil || f["qrUuid"] != "1583a66272854585833eb13b56fc1937" {
t.Fatalf("legacy qrCode field should still work: form=%v err=%v", f, err)
}
// 普通任务(无 signMode)不受影响:走原逻辑,且不含 qrUuid
@@ -60,16 +86,49 @@ func TestFillScanSubmitForm(t *testing.T) {
}
}
// 扫码任务表单项解析:ParseTaskForm 应生成「签到二维码」图片项,不含经纬度项
// 扫码任务表单项解析:ParseTaskForm 应生成经纬度/地址项 + 「二维码ID」图片项(二维码在最后)
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)
// 经度 + 纬度 + 地址 + 二维码ID
if len(items) != 4 {
t.Fatalf("scan form items = %d, want 4: %+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])
// 二维码ID 位于最后
last := items[len(items)-1]
if last.ColName != "uuid" || last.Type != cpdmodel.ItemTypeImage || !last.Required {
t.Fatalf("unexpected uuid item (should be last): %+v", last)
}
wantPos := map[string]bool{"lon": false, "lat": false, "address": false}
for _, it := range items[:len(items)-1] {
if _, ok := wantPos[it.ColName]; ok {
wantPos[it.ColName] = true
}
}
for k, ok := range wantPos {
if !ok {
t.Fatalf("scan form missing position item %q: %+v", k, items)
}
}
}
// 历史行数据里的二维码链接应被归一化为 uuid 存储(不把整条链接写入 formData)
func TestParseScanTaskFormNormalizesUuid(t *testing.T) {
detail := &TaskFormDetail{Rows: map[string]interface{}{"signMode": float64(2)}}
histRows := map[string]interface{}{
"qrCode": "https://ynu.campusphere.net/wec-counselor-sign-apps/stu/qrsign/index.html?uuid=1583a66272854585833eb13b56fc1937&isNeedExtra=0",
}
items := ParseTaskForm(detail, histRows)
if len(items) < 1 {
t.Fatalf("unexpected items: %+v", items)
}
uuidItem := items[len(items)-1]
if uuidItem.ColName != "uuid" {
t.Fatalf("last item should be uuid, got %+v", uuidItem)
}
if uuidItem.Content != "1583a66272854585833eb13b56fc1937" {
t.Fatalf("history link should normalize to uuid, got %q", uuidItem.Content)
}
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

+407
View File
@@ -0,0 +1,407 @@
package cpdaily
import (
"encoding/json"
"fmt"
"log/slog"
"strconv"
"strings"
)
// 教师端「工作日志」套件(wec-counselor-worklog-apps)。
//
// 学生端签到/查寝同域名同会话,但服务端按角色校验:学生账号调用返回 401,仅教师账号可用。
// 字段结构已用教师账号实测校正(原照第三方 Python 推断的字段名与实际不符)。
//
// 流程:
// 1. listActiveTemplate 取模板(两级:分组 row + templateVoList 子模板,提交用子模板 wid);
// 2. list(formWid=子模板wid) 取该模板下的日志单(status=0 未提交),行内含 subjects 字段;
// 3. detail(wid=单wid) 取完整字段定义(wid/colName/fieldType/isRequired/fieldItems/value);
// 4. 回填 value(单选用 fieldItems 的 contendExtend/wid),定位签到项走 sign/submitSign;
// 5. update(operationType=1) 提交。
const worklogAPIBase = "/wec-counselor-worklog-apps"
// WorklogSubTemplate 子模板(真正用于建单/查询的 formWid)
type WorklogSubTemplate struct {
Wid int64 `json:"wid"`
TemplateName string `json:"templateName"`
}
// WorklogTemplateGroup 模板分组(listActiveTemplate 的行)
type WorklogTemplateGroup struct {
Wid int64 `json:"wid"`
TemplateName string `json:"templateName"`
TemplateVoList []WorklogSubTemplate `json:"templateVoList"`
}
// WorklogOption 单选项
type WorklogOption struct {
Wid int64 `json:"wid"`
FieldWid int64 `json:"fieldWid"`
Content string `json:"content"`
ContendExtend string `json:"contendExtend"`
IsOtherItems FlexBool `json:"isOtherItems"`
}
// FlexBool 兼容 JSON 的 true/false 与 0/1(含字符串形式)。
// detail 返回 isOtherItems 为数字,template/detail 返回布尔,统一兼容避免整体解析失败。
type FlexBool bool
func (b *FlexBool) UnmarshalJSON(data []byte) error {
s := strings.Trim(strings.TrimSpace(string(data)), `"`)
switch s {
case "true", "1":
*b = true
default:
*b = false
}
return nil
}
// WorklogField 工作日志字段(detail.form / template detail.content 元素)
type WorklogField struct {
Wid int64 `json:"wid"` // 字段标识(提交用)
FormWid int64 `json:"formWid"`
ColName string `json:"colName"`
Title string `json:"title"`
FieldType int `json:"fieldType"` // 1文本 2单选 5/7时间日期 9多行
Value string `json:"value"`
IsRequired bool `json:"isRequired"`
SignCondition bool `json:"signCondition"` // true=签到项(需走 submitSign)
SignScopeWids string `json:"signScopeWids"` // 允许签到的范围点位 id(非坐标)
TemplateScopes []map[string]interface{} `json:"templateScopes"` // 签到范围点
FieldItems []WorklogOption `json:"fieldItems"`
Sort int `json:"sort"`
// raw 保留服务器返回的完整字段对象(含未建模字段)。
// 提交时按官方前端做法把整个对象回传、仅覆盖 value,避免模型字段不全导致丢字段。
raw map[string]interface{}
}
// UnmarshalJSON 解析已知字段的同时保留完整原始对象
func (f *WorklogField) UnmarshalJSON(data []byte) error {
type alias WorklogField
var a alias
if err := json.Unmarshal(data, &a); err != nil {
return err
}
*f = WorklogField(a)
var m map[string]interface{}
if err := json.Unmarshal(data, &m); err == nil {
f.raw = m
}
return nil
}
// RawObject 返回字段完整原始对象副本(无原始数据时 nil)
func (f *WorklogField) RawObject() map[string]interface{} {
if f.raw == nil {
return nil
}
out := make(map[string]interface{}, len(f.raw))
for k, v := range f.raw {
out[k] = v
}
return out
}
// WorklogItem 工作日志单(list 行)
type WorklogItem struct {
Wid int64 `json:"wid"`
Status int `json:"status"` // 0=未提交
CreateTime string `json:"createTime"`
Subjects []WorklogField `json:"subjects"` // 列表里已带字段骨架
}
// worklogResultErr 解析 {code,message,datas} 通用响应
func worklogResultErr(body []byte, action string, out interface{}) error {
var envelope struct {
Code string `json:"code"`
Message string `json:"message"`
Datas json.RawMessage `json:"datas"`
}
if err := unmarshalBody(body, &envelope); err != nil {
return fmt.Errorf("解析%s响应失败: %w (body: %.200s)", action, err, string(body))
}
if envelope.Code != "" && envelope.Code != "0" {
msg := strings.TrimSpace(envelope.Message)
if msg == "" {
msg = "未知错误"
}
return fmt.Errorf("%s", msg)
}
if out != nil && len(envelope.Datas) > 0 {
if err := json.Unmarshal(envelope.Datas, out); err != nil {
return fmt.Errorf("解析%s数据失败: %w", action, err)
}
}
return nil
}
// isWorklogForbidden 明确权限拒绝(仅文案)
func isWorklogForbidden(err error) bool {
if err == nil {
return false
}
msg := err.Error()
return strings.Contains(msg, "无权") || strings.Contains(msg, "没有权限")
}
// isWorklogUnauthorized 网关 401(学生账号正常现象)
func isWorklogUnauthorized(err error) bool {
if err == nil {
return false
}
return strings.Contains(err.Error(), "401")
}
// FetchWorklogTemplates 取模板(返回展开后的子模板列表:wid + 名称)
func FetchWorklogTemplates(cookieMap map[string]string, host string) ([]WorklogSubTemplate, error) {
body, err := leavePost(cookieMap, host+worklogAPIBase+"/worklog/template/listActiveTemplate", map[string]interface{}{
"pageNumber": "1", "pageSize": "9999999", "status": "1",
}, "")
if err != nil {
return nil, fmt.Errorf("获取工作日志模板失败: %w", err)
}
var datas struct {
Rows []WorklogTemplateGroup `json:"rows"`
}
if err := worklogResultErr(body, "工作日志模板", &datas); err != nil {
return nil, err
}
var out []WorklogSubTemplate
for _, g := range datas.Rows {
for _, sub := range g.TemplateVoList {
if sub.Wid == 0 {
continue
}
out = append(out, sub)
}
}
return out, nil
}
// FetchWorklogItems 取模板下的日志单(status==0 为未提交)
func FetchWorklogItems(cookieMap map[string]string, host string, formWid int64) ([]WorklogItem, error) {
body, err := leavePost(cookieMap, host+worklogAPIBase+"/worklog/list", map[string]interface{}{
"formWid": strconv.FormatInt(formWid, 10), "pageNumber": "1", "pageSize": "9999999",
}, "")
if err != nil {
return nil, fmt.Errorf("获取工作日志列表失败: %w", err)
}
var datas struct {
Rows []WorklogItem `json:"rows"`
}
if err := worklogResultErr(body, "工作日志列表", &datas); err != nil {
return nil, err
}
return datas.Rows, nil
}
// FetchWorklogDetail 取日志单完整字段(提交用)
func FetchWorklogDetail(cookieMap map[string]string, host string, wid int64) ([]WorklogField, error) {
body, err := leavePost(cookieMap, host+worklogAPIBase+"/worklog/detail", map[string]interface{}{
"wid": strconv.FormatInt(wid, 10),
}, "")
if err != nil {
return nil, fmt.Errorf("获取工作日志详情失败: %w", err)
}
var datas struct {
Form []WorklogField `json:"form"`
}
if err := worklogResultErr(body, "工作日志详情", &datas); err != nil {
return nil, err
}
return datas.Form, nil
}
// SubmitWorklogSign 定位签到项提交
func SubmitWorklogSign(cookieMap map[string]string, host string, fieldWid int64, worklogWid int64, signPlace, longitude, latitude string) error {
body, err := leavePost(cookieMap, host+worklogAPIBase+"/worklog/sign/submitSign", map[string]interface{}{
"fieldWid": fieldWid,
"worklogWid": worklogWid,
"signPlace": signPlace,
"longitude": longitude,
"latitude": latitude,
}, "")
if err != nil {
return fmt.Errorf("工作日志定位签到失败: %w", err)
}
var envelope struct {
Message string `json:"message"`
}
if err := unmarshalBody(body, &envelope); err != nil {
return fmt.Errorf("解析定位签到响应失败: %w", err)
}
if envelope.Message != "SUCCESS" {
msg := strings.TrimSpace(envelope.Message)
if msg == "" {
msg = "定位信息提交失败"
}
return fmt.Errorf("%s", msg)
}
return nil
}
// SubmitWorklog 提交工作日志(operationType=1,form 为回填后的字段列表)
func SubmitWorklog(cookieMap map[string]string, host string, formWid, wid int64, form []WorklogField) error {
// 优先回传字段的完整原始对象(仅覆盖 value),与官方前端一致;
// 缺原始对象的字段退化为最小结构 {wid,value}。
formOut := make([]map[string]interface{}, 0, len(form))
for i := range form {
if raw := form[i].RawObject(); raw != nil {
raw["value"] = form[i].Value
formOut = append(formOut, raw)
} else {
formOut = append(formOut, map[string]interface{}{"wid": form[i].Wid, "value": form[i].Value})
}
}
body, err := leavePost(cookieMap, host+worklogAPIBase+"/worklog/update", map[string]interface{}{
"formWid": formWid,
"wid": wid,
"form": formOut,
"operationType": 1,
}, "")
if err != nil {
return fmt.Errorf("提交工作日志失败: %w", err)
}
var envelope struct {
Code string `json:"code"`
Message string `json:"message"`
}
if err := unmarshalBody(body, &envelope); err != nil {
return fmt.Errorf("解析工作日志提交响应失败: %w (body: %.200s)", err, string(body))
}
slog.Info("worklog/update 响应", "code", envelope.Code, "message", envelope.Message)
if envelope.Code != "" && envelope.Code != "0" {
msg := strings.TrimSpace(envelope.Message)
if msg == "" {
msg = "提交失败"
}
return fmt.Errorf("%s", msg)
}
return nil
}
// WorklogSignWidOf 组合标识 "formWid:wid"
func WorklogSignWidOf(formWid, wid int64) string {
return strconv.FormatInt(formWid, 10) + ":" + strconv.FormatInt(wid, 10)
}
// ParseWorklogSignWid 解析 "formWid:wid"
func ParseWorklogSignWid(signWid string) (int64, int64, error) {
parts := strings.SplitN(strings.TrimSpace(signWid), ":", 2)
if len(parts) != 2 {
return 0, 0, fmt.Errorf("工作日志标识格式错误: %q", signWid)
}
f, err1 := strconv.ParseInt(parts[0], 10, 64)
w, err2 := strconv.ParseInt(parts[1], 10, 64)
if err1 != nil || err2 != nil {
return 0, 0, fmt.Errorf("工作日志标识解析失败: %q", signWid)
}
return f, w, nil
}
// FetchWorklogTasks 工作日志未提交单 → 统一任务项(TaskName=模板名,SignWid="formWid:wid")
func FetchWorklogTasks(cookieMap map[string]string, host string) ([]TodayTaskInfo, error) {
templates, err := FetchWorklogTemplates(cookieMap, host)
if err != nil {
return nil, err
}
var out []TodayTaskInfo
for _, tpl := range templates {
items, ierr := FetchWorklogItems(cookieMap, host, tpl.Wid)
if ierr != nil {
slog.Warn("获取工作日志列表失败,跳过该模板", "formWid", tpl.Wid, "error", ierr)
continue
}
for _, it := range items {
if it.Status != 0 {
continue
}
out = append(out, TodayTaskInfo{
TaskName: tpl.TemplateName,
SignWid: WorklogSignWidOf(tpl.Wid, it.Wid),
IsSigned: false,
TaskType: string(TaskTypeWorklog),
})
}
}
return out, nil
}
// WorklogFieldsToFormItems 工作日志字段 → 通用表单项(供打卡页复用表单渲染)。
// fieldType 映射:1/9=文本(textarea 由前端按多行)、2=单选、5/7=时间/日期、其余按文本。
// 定位签到项(signScopeWids 非空)标记 type=7 并附 signScope 标记,前端渲染签到入口。
func WorklogFieldsToFormItems(fields []WorklogField) []map[string]interface{} {
out := make([]map[string]interface{}, 0, len(fields))
for _, f := range fields {
item := map[string]interface{}{
"title": f.Title,
"colName": strconv.FormatInt(f.Wid, 10), // 提交按字段 wid 回填
"type": f.FieldType,
"content": f.Value,
"required": f.IsRequired,
"show": true,
}
if len(f.FieldItems) > 0 {
opts := make([]map[string]interface{}, 0, len(f.FieldItems))
for _, o := range f.FieldItems {
opts = append(opts, map[string]interface{}{"content": o.Content, "isOtherItems": bool(o.IsOtherItems)})
}
item["options"] = opts
}
// 签到项(signCondition=true):需在编辑时提供定位,执行时由后端调 submitSign 提交定位。
// signScopeWids 是允许签到的范围点位 id(非坐标),一并透传供前端展示。
if f.SignCondition {
item["isWorklogSign"] = true
item["signScopeWids"] = f.SignScopeWids
// 透传签到范围点(地址/经纬度/半径),供前端展示与"是否在范围内"提示
scopes := make([]map[string]interface{}, 0, len(f.TemplateScopes))
for _, sc := range f.TemplateScopes {
scopes = append(scopes, map[string]interface{}{
"address": mapValueStr(sc, "address"),
"latitude": mapValueStr(sc, "latitude"),
"longitude": mapValueStr(sc, "longitude"),
"radius": sc["radius"],
})
}
item["signScopes"] = scopes
}
out = append(out, item)
}
return out
}
// WorklogScopePoints 把签到字段的 templateScopes 解析为与查寝一致的 SignPlace 列表
// (字段键名实测一致:address/latitude/longitude/radius),从而可复用 coordsWithinSignRange。
func WorklogScopePoints(f WorklogField) []SignPlace {
places := make([]SignPlace, 0, len(f.TemplateScopes))
for _, m := range f.TemplateScopes {
lat := mapValueStr(m, "latitude")
lon := mapValueStr(m, "longitude")
radius := 0.0
if r, ok := m["radius"]; ok && r != nil {
radius, _ = strconv.ParseFloat(fmt.Sprintf("%v", r), 64)
}
latF, e1 := strconv.ParseFloat(lat, 64)
lonF, e2 := strconv.ParseFloat(lon, 64)
if e1 != nil || e2 != nil || radius <= 0 {
continue
}
places = append(places, SignPlace{
Address: mapValueStr(m, "address"),
Longitude: lon,
Latitude: lat,
LongitudeF: lonF,
LatitudeF: latF,
RadiusMeters: radius,
})
}
return places
}
// WorklogWithinRange 判断坐标是否落在该签到字段的任一范围内(无范围点则视为通过)
func WorklogWithinRange(f WorklogField, lat, lon string) bool {
return coordsWithinSignRange(lat, lon, WorklogScopePoints(f))
}
@@ -0,0 +1,83 @@
package cpdaily
import (
"encoding/json"
"testing"
)
// 工作日志:响应解析与字段契约(结构按教师账号实测校正)。
func TestWorklogResultErr(t *testing.T) {
if err := worklogResultErr([]byte(`{"code":"401","message":"您无权获取工作日志列表"}`), "模板", nil); err == nil || err.Error() != "您无权获取工作日志列表" {
t.Fatalf("应透传错误文案,得到 %v", err)
}
if err := worklogResultErr([]byte(`{"code":"500"}`), "列表", nil); err == nil || err.Error() != "未知错误" {
t.Fatalf("空 message 应回退,得到 %v", err)
}
if err := worklogResultErr([]byte(`<html>waf</html>`), "详情", nil); err == nil {
t.Fatal("非 JSON 应报错")
}
}
// 模板两级结构:真正可用的是 templateVoList[].wid
func TestWorklogTemplateGroupParse(t *testing.T) {
body := []byte(`{"code":"0","datas":{"rows":[{"wid":200011199,"templateName":"学管值班","templateVoList":[{"wid":26,"templateName":"学管值班"},{"wid":3,"templateName":"谈心谈话记录"}]},{"wid":200011041,"templateName":"未分类","templateVoList":[]}]}}`)
var datas struct {
Rows []WorklogTemplateGroup `json:"rows"`
}
if err := worklogResultErr(body, "模板", &datas); err != nil {
t.Fatalf("解析失败: %v", err)
}
if len(datas.Rows) != 2 {
t.Fatalf("应 2 个分组,得到 %d", len(datas.Rows))
}
if datas.Rows[0].TemplateVoList[0].Wid != 26 {
t.Fatalf("子模板 wid 应为 26,得到 %d", datas.Rows[0].TemplateVoList[0].Wid)
}
}
// 字段契约:wid(数字)/colName/fieldType/isRequired/fieldItems/value
func TestWorklogFieldParse(t *testing.T) {
raw := `[{"wid":428,"formWid":26,"fieldType":7,"colName":"field001","title":"值班时间","isRequired":true,"signScopeWids":"","value":""},
{"wid":425,"formWid":26,"fieldType":2,"colName":"field002","title":"值班校区","isRequired":true,"fieldItems":[{"wid":91,"content":"唐槐校区"}],"value":""}]`
var fields []WorklogField
if err := json.Unmarshal([]byte(raw), &fields); err != nil {
t.Fatalf("解析失败: %v", err)
}
if fields[0].Wid != 428 || fields[0].ColName != "field001" || !fields[0].IsRequired {
t.Fatalf("字段解析异常: %+v", fields[0])
}
if len(fields[1].FieldItems) != 1 || fields[1].FieldItems[0].Content != "唐槐校区" {
t.Fatalf("单选项解析异常: %+v", fields[1].FieldItems)
}
}
// 提交:form 按真实字段序列化(wid/value 字段名与接口一致)
func TestWorklogSubmitFormSerialization(t *testing.T) {
form := []WorklogField{{Wid: 428, FormWid: 26, ColName: "field001", FieldType: 7, Value: "2026-10-06 21:30", IsRequired: true}}
payload := map[string]interface{}{"formWid": int64(26), "wid": int64(2234), "form": form, "operationType": 1}
b, err := json.Marshal(payload)
if err != nil {
t.Fatalf("序列化失败: %v", err)
}
var back map[string]interface{}
json.Unmarshal(b, &back)
if back["operationType"].(float64) != 1 {
t.Fatal("operationType 应为 1")
}
first := back["form"].([]interface{})[0].(map[string]interface{})
if first["wid"].(float64) != 428 || first["value"] != "2026-10-06 21:30" {
t.Fatalf("提交字段形状异常: %v", first)
}
}
func TestWorklogSignWidRoundTrip(t *testing.T) {
str := WorklogSignWidOf(26, 2234)
f, w, err := ParseWorklogSignWid(str)
if err != nil || f != 26 || w != 2234 {
t.Fatalf("往返失败: %q -> %d,%d err=%v", str, f, w, err)
}
if _, _, err := ParseWorklogSignWid("bad"); err == nil {
t.Fatal("非法标识应报错")
}
}
+37 -7
View File
@@ -18,14 +18,14 @@ import (
// 系统配置 key(与 system_configs 表对应,后台「系统设置」页可编辑)
const (
CfgSmtpHost = "smtp_host" // SMTP 服务器地址
CfgSmtpPort = "smtp_port" // SMTP 端口
CfgSmtpUser = "smtp_user" // SMTP 用户名
CfgSmtpPassword = "smtp_password" // SMTP 密码/授权码
CfgSmtpFrom = "smtp_from" // 发件人地址(默认取 smtp_user)
CfgSmtpHost = "smtp_host" // SMTP 服务器地址
CfgSmtpPort = "smtp_port" // SMTP 端口
CfgSmtpUser = "smtp_user" // SMTP 用户名
CfgSmtpPassword = "smtp_password" // SMTP 密码/授权码
CfgSmtpFrom = "smtp_from" // 发件人地址(默认取 smtp_user)
CfgSmtpFromName = "smtp_from_name" // 发件人显示名称
CfgSmtpSecure = "smtp_secure" // ssl / starttls / none,留空按端口自动判断
CfgAdminEmails = "admin_emails" // 管理员收件邮箱(多个用英文逗号分隔)
CfgSmtpSecure = "smtp_secure" // ssl / starttls / none,留空按端口自动判断
CfgAdminEmails = "admin_emails" // 管理员收件邮箱(多个用英文逗号分隔)
)
type smtpSettings struct {
@@ -341,3 +341,33 @@ func NotifyNewTicket(ticketID uint, username, schoolName, realName, title, conte
<p style="color:#888;font-size:13px;margin-top:12px">请到管理后台「工单」查看详情并回复处理。</p>`
SendMailAsync(adminEmails, subject, EmailBody(inner))
}
// NotifyTicketUserReply 用户回复工单提醒(发给管理员)。
// 与 NotifyNewTicket 同样是"有新内容待处理",用于工单对话中用户追加回复时再次邮件提醒管理员。
func NotifyTicketUserReply(ticketID uint, username, schoolName, realName, ticketTitle, replyContent, replyAt string) {
adminEmails := GetAdminEmails()
if len(adminEmails) == 0 {
slog.Info("未配置管理员邮箱,跳过工单回复通知", "ticketId", ticketID)
return
}
escaped := html.EscapeString(replyContent)
if len([]rune(escaped)) > 500 {
escaped = string([]rune(escaped)[:500]) + "……"
}
escaped = strings.ReplaceAll(escaped, "\n", "<br/>")
displayName := realName
if displayName == "" {
displayName = username
}
subject := fmt.Sprintf("【小助手】工单 #%d 有新回复:%s", ticketID, ticketTitle)
inner := `<h2 style="margin:0 0 16px;font-size:17px">工单收到用户的新回复</h2>
<table style="border-collapse:collapse;font-size:14px;color:#333;line-height:2">
<tr><td style="padding-right:16px;color:#888;vertical-align:top">工单</td><td><b>#` + fmt.Sprintf("%d", ticketID) + ` ` + html.EscapeString(ticketTitle) + `</b></td></tr>
<tr><td style="padding-right:16px;color:#888">用户</td><td>` + html.EscapeString(displayName) + `(` + html.EscapeString(username) + `)</td></tr>
<tr><td style="padding-right:16px;color:#888">学校</td><td>` + html.EscapeString(schoolName) + `</td></tr>
<tr><td style="padding-right:16px;color:#888;vertical-align:top">回复内容</td><td style="max-width:420px">` + escaped + `</td></tr>
<tr><td style="padding-right:16px;color:#888">回复时间</td><td>` + html.EscapeString(replyAt) + `</td></tr>
</table>
<p style="color:#888;font-size:13px;margin-top:12px">请到管理后台「工单」查看详情并继续处理。</p>`
SendMailAsync(adminEmails, subject, EmailBody(inner))
}
+29 -5
View File
@@ -4,16 +4,40 @@
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover" />
<meta name="theme-color" content="#f5f5f5" />
<meta name="theme-color" content="#f3faf7" />
<title>小助手</title>
<script>
// 暗色主题防闪烁:与 composables/useTheme.ts 保持同一时间规则
// (18:00–次日 06:00 暗色),在首屏渲染前预设 html.dark 与状态栏色
// 老内核(Chrome 78~92,即微信 XWEB / 国产浏览器)缺失的 ES2022 运行时 API 补齐。
// 背景:reka-ui(其弹窗组件)与 @vueuse/shared 内部直接调用 Object.hasOwn
// (Chrome 93+),而 esbuild/vite 的 target 只降语法、不降运行时 API,
// 构建产物在 Chrome 78 上会在弹窗挂载时抛 TypeError。
// 该内联脚本先于 module 产物执行(module 默认 defer),故此处按需补齐即可;
// 现代内核因特性检测成立而跳过,零开销。写法保持 ES5,避免老内核解析失败。
;(function () {
if (typeof Object.hasOwn !== 'function') {
Object.defineProperty(Object, 'hasOwn', {
value: function (obj, prop) {
if (obj === null || obj === undefined) {
throw new TypeError('Cannot convert undefined or null to object')
}
return Object.prototype.hasOwnProperty.call(Object(obj), prop)
},
configurable: true,
writable: true,
})
}
})()
</script>
<script>
// 暗色主题防闪烁:与 composables/useTheme.ts 保持同一规则
// (用户选择 light/dark 优先;auto 时 18:00–次日 06:00 暗色),在首屏渲染前预设 html.dark 与状态栏色
;(function () {
var mode = 'auto'
try { mode = localStorage.getItem('xz_theme') || 'auto' } catch (e) {}
var h = new Date().getHours()
if (h >= 18 || h < 6) {
if (mode === 'dark' || (mode !== 'light' && (h >= 18 || h < 6))) {
document.documentElement.classList.add('dark')
document.querySelector('meta[name="theme-color"]').setAttribute('content', '#15151f')
document.querySelector('meta[name="theme-color"]').setAttribute('content', '#0f1815')
}
})()
</script>
+495 -50
View File
@@ -11,8 +11,10 @@
"axios": "^1.13.6",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"jsqr": "^1.4.0",
"lucide-vue-next": "^1.0.0",
"qrcode": "^1.5.4",
"reka-ui": "^2.10.4",
"tailwind-merge": "^3.5.0",
"vue": "^3.5.30",
"vue-router": "^5.0.4"
@@ -22,6 +24,7 @@
"@types/node": "^26.0.1",
"@types/qrcode": "^1.5.6",
"@vitejs/plugin-vue": "^6.0.5",
"lightningcss": "^1.33.0",
"tailwindcss": "^4.2.2",
"typescript": "~5.9.3",
"vite": "^8.0.1",
@@ -159,6 +162,79 @@
"tslib": "^2.4.0"
}
},
"node_modules/@floating-ui/core": {
"version": "1.8.0",
"resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.8.0.tgz",
"integrity": "sha512-0CIZ5itps/8x7BG8dEIhs53BvCUH2PCoogtakwRTut+Arm58sJooJ0AuZhLw2HJYIR5cMLNPBSS728sPho2khQ==",
"dependencies": {
"@floating-ui/utils": "^0.2.12"
}
},
"node_modules/@floating-ui/dom": {
"version": "1.8.0",
"resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.8.0.tgz",
"integrity": "sha512-yXSrzeHZBTZadLOlfyhCkJHNeLJnHRnRInwdZ40L7ZiaAtrBwoYlsDrX3v5zB1Utk7CLfzcOVnVVWoXEky7Ceg==",
"dependencies": {
"@floating-ui/core": "^1.8.0",
"@floating-ui/utils": "^0.2.12"
}
},
"node_modules/@floating-ui/utils": {
"version": "0.2.12",
"resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.12.tgz",
"integrity": "sha512-HpCo8tmWzLVad5s2d19EhAz5zqrrQ6s69qd6moPMQvkOuSwDT1YgRfWSVuc4ennqrgv3OHppiOGMQ7oC13yIww=="
},
"node_modules/@floating-ui/vue": {
"version": "1.1.11",
"resolved": "https://registry.npmjs.org/@floating-ui/vue/-/vue-1.1.11.tgz",
"integrity": "sha512-HzHKCNVxnGS35r9fCHBc3+uCnjw9IWIlCPL683cGgM9Kgj2BiAl8x1mS7vtvP6F9S/e/q4O6MApwSHj8hNLGfw==",
"dependencies": {
"@floating-ui/dom": "^1.7.6",
"@floating-ui/utils": "^0.2.11",
"vue-demi": ">=0.13.0"
}
},
"node_modules/@floating-ui/vue/node_modules/vue-demi": {
"version": "0.14.10",
"resolved": "https://registry.npmjs.org/vue-demi/-/vue-demi-0.14.10.tgz",
"integrity": "sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg==",
"hasInstallScript": true,
"bin": {
"vue-demi-fix": "bin/vue-demi-fix.js",
"vue-demi-switch": "bin/vue-demi-switch.js"
},
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/sponsors/antfu"
},
"peerDependencies": {
"@vue/composition-api": "^1.0.0-rc.1",
"vue": "^3.0.0-0 || ^2.6.0"
},
"peerDependenciesMeta": {
"@vue/composition-api": {
"optional": true
}
}
},
"node_modules/@internationalized/date": {
"version": "3.12.4",
"resolved": "https://registry.npmjs.org/@internationalized/date/-/date-3.12.4.tgz",
"integrity": "sha512-M1dEn4c1U1HsSlaVR8upZtSqvXrTkHDfv18H01uCSJyjVLDxnBR38v/fMxecmlwXKR4i9HeZcmgQAPE6A+aGJQ==",
"dependencies": {
"@swc/helpers": "^0.5.0"
}
},
"node_modules/@internationalized/number": {
"version": "3.6.8",
"resolved": "https://registry.npmjs.org/@internationalized/number/-/number-3.6.8.tgz",
"integrity": "sha512-8UmMFia46DUt+k97zKd9fKWXcWHR+k8ae3eYzILETuT2KbIvLyOfac7zesw+sJdRAAZ7Q9pM1Mk22aXp2LD0Ig==",
"dependencies": {
"@swc/helpers": "^0.5.0"
}
},
"node_modules/@jridgewell/gen-mapping": {
"version": "0.3.13",
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
@@ -474,6 +550,14 @@
"integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==",
"devOptional": true
},
"node_modules/@swc/helpers": {
"version": "0.5.23",
"resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.23.tgz",
"integrity": "sha512-5lSsMOTXURePglDfvuAQUqkGek9Hg2kksOYay2m0+XR++b2NWYL/4sWyuvVBIs8oKnJaxkdi9whaL/sqN13afw==",
"dependencies": {
"tslib": "^2.8.0"
}
},
"node_modules/@tailwindcss/node": {
"version": "4.3.2",
"resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.3.2.tgz",
@@ -489,6 +573,255 @@
"tailwindcss": "4.3.2"
}
},
"node_modules/@tailwindcss/node/node_modules/lightningcss": {
"version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz",
"integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==",
"dev": true,
"dependencies": {
"detect-libc": "^2.0.3"
},
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
},
"optionalDependencies": {
"lightningcss-android-arm64": "1.32.0",
"lightningcss-darwin-arm64": "1.32.0",
"lightningcss-darwin-x64": "1.32.0",
"lightningcss-freebsd-x64": "1.32.0",
"lightningcss-linux-arm-gnueabihf": "1.32.0",
"lightningcss-linux-arm64-gnu": "1.32.0",
"lightningcss-linux-arm64-musl": "1.32.0",
"lightningcss-linux-x64-gnu": "1.32.0",
"lightningcss-linux-x64-musl": "1.32.0",
"lightningcss-win32-arm64-msvc": "1.32.0",
"lightningcss-win32-x64-msvc": "1.32.0"
}
},
"node_modules/@tailwindcss/node/node_modules/lightningcss-android-arm64": {
"version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz",
"integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==",
"cpu": [
"arm64"
],
"dev": true,
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/@tailwindcss/node/node_modules/lightningcss-darwin-arm64": {
"version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz",
"integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==",
"cpu": [
"arm64"
],
"dev": true,
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/@tailwindcss/node/node_modules/lightningcss-darwin-x64": {
"version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz",
"integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==",
"cpu": [
"x64"
],
"dev": true,
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/@tailwindcss/node/node_modules/lightningcss-freebsd-x64": {
"version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz",
"integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==",
"cpu": [
"x64"
],
"dev": true,
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/@tailwindcss/node/node_modules/lightningcss-linux-arm-gnueabihf": {
"version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz",
"integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==",
"cpu": [
"arm"
],
"dev": true,
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/@tailwindcss/node/node_modules/lightningcss-linux-arm64-gnu": {
"version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz",
"integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==",
"cpu": [
"arm64"
],
"dev": true,
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/@tailwindcss/node/node_modules/lightningcss-linux-arm64-musl": {
"version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz",
"integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==",
"cpu": [
"arm64"
],
"dev": true,
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/@tailwindcss/node/node_modules/lightningcss-linux-x64-gnu": {
"version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz",
"integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==",
"cpu": [
"x64"
],
"dev": true,
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/@tailwindcss/node/node_modules/lightningcss-linux-x64-musl": {
"version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz",
"integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==",
"cpu": [
"x64"
],
"dev": true,
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/@tailwindcss/node/node_modules/lightningcss-win32-arm64-msvc": {
"version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz",
"integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==",
"cpu": [
"arm64"
],
"dev": true,
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/@tailwindcss/node/node_modules/lightningcss-win32-x64-msvc": {
"version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz",
"integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==",
"cpu": [
"x64"
],
"dev": true,
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/@tailwindcss/oxide": {
"version": "4.3.2",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.3.2.tgz",
@@ -731,6 +1064,30 @@
"vite": "^5.2.0 || ^6 || ^7 || ^8"
}
},
"node_modules/@tanstack/virtual-core": {
"version": "3.17.11",
"resolved": "https://registry.npmjs.org/@tanstack/virtual-core/-/virtual-core-3.17.11.tgz",
"integrity": "sha512-+ILjvtHup6Y2hzQ6YzwMgX1Q+oQpxEGOXCEsCNaPoIP0VxMbizIBTmYTDtkerkIQS8/CbP1BRuyt8V/8BCsy1g==",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/tannerlinsley"
}
},
"node_modules/@tanstack/vue-virtual": {
"version": "3.13.39",
"resolved": "https://registry.npmjs.org/@tanstack/vue-virtual/-/vue-virtual-3.13.39.tgz",
"integrity": "sha512-AG8sI+6nfrDvN6ixuHJL9LaJTf3//0V5VITZYW3UOaIuSVexnEzsbMu4LH/JhMfRTxwhUXPnLqkl1DlzkZbRtg==",
"dependencies": {
"@tanstack/virtual-core": "3.17.11"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/tannerlinsley"
},
"peerDependencies": {
"vue": "^2.7.0 || ^3.0.0"
}
},
"node_modules/@tybys/wasm-util": {
"version": "0.10.3",
"resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz",
@@ -764,6 +1121,11 @@
"@types/node": "*"
}
},
"node_modules/@types/web-bluetooth": {
"version": "0.0.21",
"resolved": "https://registry.npmjs.org/@types/web-bluetooth/-/web-bluetooth-0.0.21.tgz",
"integrity": "sha512-oIQLCGWtcFZy2JW77j9k8nHzAOpqMHLQejDA48XXMWH6tjCQHz5RCFz1bzsmROyL6PUm+LLnUiI4BCn221inxA=="
},
"node_modules/@vitejs/plugin-vue": {
"version": "6.0.7",
"resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-6.0.7.tgz",
@@ -962,6 +1324,41 @@
"resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.39.tgz",
"integrity": "sha512-l1rrBtBfTnmxvtsvdQDXltUUy8S1Y+ZaqdfUzmAnJkTd8Z8rv5v/ytW+TKiqEOWyHPoqtPlNFSs0lhRmYVSHVA=="
},
"node_modules/@vueuse/core": {
"version": "14.4.0",
"resolved": "https://registry.npmjs.org/@vueuse/core/-/core-14.4.0.tgz",
"integrity": "sha512-X4WHz1HlCzCBoYXesUkifzzWBAcZgXG8Fi5iNPQg/epdzOB3gu8Fawj3hvuwYR1nGcXGnvxwYYcUC/71++svtQ==",
"dependencies": {
"@types/web-bluetooth": "^0.0.21",
"@vueuse/metadata": "14.4.0",
"@vueuse/shared": "14.4.0"
},
"funding": {
"url": "https://github.com/sponsors/antfu"
},
"peerDependencies": {
"vue": "^3.5.0"
}
},
"node_modules/@vueuse/metadata": {
"version": "14.4.0",
"resolved": "https://registry.npmjs.org/@vueuse/metadata/-/metadata-14.4.0.tgz",
"integrity": "sha512-swx/255R6JyHZFJhx845iz5CRWDZdCfvkZOpACWc5+c5WHcG24mv8gUT1WIdFQaHt6dq79rvILd9QnCWiyVm9g==",
"funding": {
"url": "https://github.com/sponsors/antfu"
}
},
"node_modules/@vueuse/shared": {
"version": "14.4.0",
"resolved": "https://registry.npmjs.org/@vueuse/shared/-/shared-14.4.0.tgz",
"integrity": "sha512-JRgY90Sz8DDtPMsaDflvPMp9xYk69JZAmbuDvAquUVXKr2gEjqtzGNTTthLfckH0BzBqvnu31gb4a8TGLRe79g==",
"funding": {
"url": "https://github.com/sponsors/antfu"
},
"peerDependencies": {
"vue": "^3.5.0"
}
},
"node_modules/acorn": {
"version": "8.17.0",
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.17.0.tgz",
@@ -1012,6 +1409,17 @@
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
}
},
"node_modules/aria-hidden": {
"version": "1.2.6",
"resolved": "https://registry.npmjs.org/aria-hidden/-/aria-hidden-1.2.6.tgz",
"integrity": "sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==",
"dependencies": {
"tslib": "^2.0.0"
},
"engines": {
"node": ">=10"
}
},
"node_modules/ast-kit": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/ast-kit/-/ast-kit-2.2.0.tgz",
@@ -1191,6 +1599,11 @@
"node": ">=0.10.0"
}
},
"node_modules/defu": {
"version": "6.1.7",
"resolved": "https://registry.npmjs.org/defu/-/defu-6.1.7.tgz",
"integrity": "sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ=="
},
"node_modules/delayed-stream": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
@@ -1542,10 +1955,15 @@
"node": ">=6"
}
},
"node_modules/jsqr": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/jsqr/-/jsqr-1.4.0.tgz",
"integrity": "sha512-dxLob7q65Xg2DvstYkRpkYtmKm2sPJ9oFhrhmudT1dZvNFFTlroai3AWSpLey/w5vMcLBXRgOJsbXpdN9HzU/A=="
},
"node_modules/lightningcss": {
"version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz",
"integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==",
"version": "1.33.0",
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz",
"integrity": "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==",
"devOptional": true,
"dependencies": {
"detect-libc": "^2.0.3"
@@ -1558,23 +1976,23 @@
"url": "https://opencollective.com/parcel"
},
"optionalDependencies": {
"lightningcss-android-arm64": "1.32.0",
"lightningcss-darwin-arm64": "1.32.0",
"lightningcss-darwin-x64": "1.32.0",
"lightningcss-freebsd-x64": "1.32.0",
"lightningcss-linux-arm-gnueabihf": "1.32.0",
"lightningcss-linux-arm64-gnu": "1.32.0",
"lightningcss-linux-arm64-musl": "1.32.0",
"lightningcss-linux-x64-gnu": "1.32.0",
"lightningcss-linux-x64-musl": "1.32.0",
"lightningcss-win32-arm64-msvc": "1.32.0",
"lightningcss-win32-x64-msvc": "1.32.0"
"lightningcss-android-arm64": "1.33.0",
"lightningcss-darwin-arm64": "1.33.0",
"lightningcss-darwin-x64": "1.33.0",
"lightningcss-freebsd-x64": "1.33.0",
"lightningcss-linux-arm-gnueabihf": "1.33.0",
"lightningcss-linux-arm64-gnu": "1.33.0",
"lightningcss-linux-arm64-musl": "1.33.0",
"lightningcss-linux-x64-gnu": "1.33.0",
"lightningcss-linux-x64-musl": "1.33.0",
"lightningcss-win32-arm64-msvc": "1.33.0",
"lightningcss-win32-x64-msvc": "1.33.0"
}
},
"node_modules/lightningcss-android-arm64": {
"version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz",
"integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==",
"version": "1.33.0",
"resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.33.0.tgz",
"integrity": "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==",
"cpu": [
"arm64"
],
@@ -1592,9 +2010,9 @@
}
},
"node_modules/lightningcss-darwin-arm64": {
"version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz",
"integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==",
"version": "1.33.0",
"resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.33.0.tgz",
"integrity": "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==",
"cpu": [
"arm64"
],
@@ -1612,9 +2030,9 @@
}
},
"node_modules/lightningcss-darwin-x64": {
"version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz",
"integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==",
"version": "1.33.0",
"resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.33.0.tgz",
"integrity": "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==",
"cpu": [
"x64"
],
@@ -1632,9 +2050,9 @@
}
},
"node_modules/lightningcss-freebsd-x64": {
"version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz",
"integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==",
"version": "1.33.0",
"resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.33.0.tgz",
"integrity": "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==",
"cpu": [
"x64"
],
@@ -1652,9 +2070,9 @@
}
},
"node_modules/lightningcss-linux-arm-gnueabihf": {
"version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz",
"integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==",
"version": "1.33.0",
"resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.33.0.tgz",
"integrity": "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==",
"cpu": [
"arm"
],
@@ -1672,9 +2090,9 @@
}
},
"node_modules/lightningcss-linux-arm64-gnu": {
"version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz",
"integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==",
"version": "1.33.0",
"resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.33.0.tgz",
"integrity": "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==",
"cpu": [
"arm64"
],
@@ -1692,9 +2110,9 @@
}
},
"node_modules/lightningcss-linux-arm64-musl": {
"version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz",
"integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==",
"version": "1.33.0",
"resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.33.0.tgz",
"integrity": "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==",
"cpu": [
"arm64"
],
@@ -1712,9 +2130,9 @@
}
},
"node_modules/lightningcss-linux-x64-gnu": {
"version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz",
"integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==",
"version": "1.33.0",
"resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.33.0.tgz",
"integrity": "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==",
"cpu": [
"x64"
],
@@ -1732,9 +2150,9 @@
}
},
"node_modules/lightningcss-linux-x64-musl": {
"version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz",
"integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==",
"version": "1.33.0",
"resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.33.0.tgz",
"integrity": "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==",
"cpu": [
"x64"
],
@@ -1752,9 +2170,9 @@
}
},
"node_modules/lightningcss-win32-arm64-msvc": {
"version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz",
"integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==",
"version": "1.33.0",
"resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.33.0.tgz",
"integrity": "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==",
"cpu": [
"arm64"
],
@@ -1772,9 +2190,9 @@
}
},
"node_modules/lightningcss-win32-x64-msvc": {
"version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz",
"integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==",
"version": "1.33.0",
"resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.33.0.tgz",
"integrity": "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==",
"cpu": [
"x64"
],
@@ -1929,6 +2347,11 @@
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
}
},
"node_modules/ohash": {
"version": "2.0.12",
"resolved": "https://registry.npmjs.org/ohash/-/ohash-2.0.12.tgz",
"integrity": "sha512-65S/5gk9YSsaRjcyf7Nfa6h/d3E8/1gslpXfI4W7Dxn/oap8IKRuNT5VXkLQ1YFKIEg4apRY4Pj6aiwFzrDdmw=="
},
"node_modules/p-limit": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz",
@@ -2098,6 +2521,30 @@
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/reka-ui": {
"version": "2.10.4",
"resolved": "https://registry.npmjs.org/reka-ui/-/reka-ui-2.10.4.tgz",
"integrity": "sha512-kbS5GAbkHkYj0EVKAg5ZPEPndhBjeUFSa1Aq5m5ftQwyHnB1v5tw8X6fcwfKH/ry3StPXblMWM1DW82MVS71cw==",
"dependencies": {
"@floating-ui/dom": "^1.6.13",
"@floating-ui/vue": "^1.1.6",
"@internationalized/date": "^3.5.0",
"@internationalized/number": "^3.5.0",
"@tanstack/vue-virtual": "^3.12.0",
"@vueuse/core": "^14.1.0",
"@vueuse/shared": "^14.1.0",
"aria-hidden": "^1.2.4",
"defu": "^6.1.5",
"ohash": "^2.0.11"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/zernonia"
},
"peerDependencies": {
"vue": ">= 3.4.0"
}
},
"node_modules/require-directory": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
@@ -2232,9 +2679,7 @@
"node_modules/tslib": {
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"dev": true,
"optional": true
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="
},
"node_modules/typescript": {
"version": "5.9.3",
+4 -1
View File
@@ -5,15 +5,17 @@
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"build": "vite build && node scripts/postbuild-css.mjs",
"preview": "vite preview"
},
"dependencies": {
"axios": "^1.13.6",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"jsqr": "^1.4.0",
"lucide-vue-next": "^1.0.0",
"qrcode": "^1.5.4",
"reka-ui": "^2.10.4",
"tailwind-merge": "^3.5.0",
"vue": "^3.5.30",
"vue-router": "^5.0.4"
@@ -23,6 +25,7 @@
"@types/node": "^26.0.1",
"@types/qrcode": "^1.5.6",
"@vitejs/plugin-vue": "^6.0.5",
"lightningcss": "^1.33.0",
"tailwindcss": "^4.2.2",
"typescript": "~5.9.3",
"vite": "^8.0.1",
+256
View File
@@ -0,0 +1,256 @@
// 老内核兼容后处理(构建期执行):
//
// 背景:Tailwind v4 的产物大量使用较新的 CSS 能力,老内核(国产浏览器/微信内置
// 内核,实测线上存在 Chrome 78/85/91/97)会丢弃不认识的部分,表现为“只剩基础
// 元素、没有样式”。逐项处理如下:
//
// ① CSS 级联层 @layer(Chrome 99+):老内核不认识该 at-rule,会把整块工具类
// 直接丢弃 —— 这是“无样式”的主因,必须剥离;
// ② oklch() 颜色(Chrome 111+):降级为十六进制色值(lightningcss);
// ③ :where() 选择器(Chrome 88+):其特异性为 0 的设计依赖较新引擎,
// 老内核整条规则丢弃;解包为等价选择器(保持原有特异性位置);
// ④ JS 语法(可选链等)由 vite build.target 统一降级,不在此处处理。
//
// 处理顺序:先解包 :where(lightningcss 不处理它),再整体按目标内核降级,
// 最后剥离 @layer 并做完整性校验。
import { readdir, readFile, writeFile } from 'node:fs/promises'
import { join, resolve } from 'node:path'
import { transform } from 'lightningcss'
const assetsDir = resolve(process.cwd(), 'dist/assets')
// lightningcss 目标版本:Chrome 78(微信 XWEB 常见下限,线上真实存在)
const TARGETS = {
chrome: 78 << 16,
safari: 12 << 16,
ios_saf: 12 << 16,
android: 78 << 16,
}
/** 按顶层逗号拆分(忽略括号/方括号内的逗号) */
function splitTopLevel(s) {
const parts = []
let depth = 0
let start = 0
for (let k = 0; k < s.length; k++) {
const ch = s[k]
if (ch === '(' || ch === '[') depth++
else if (ch === ')' || ch === ']') depth--
else if (ch === ',' && depth === 0) {
parts.push(s.slice(start, k))
start = k + 1
}
}
parts.push(s.slice(start))
return parts.map((p) => p.trim())
}
/**
* 把 :where 的单个备选项 alt 合并回原选择器(before + :where(alt) + after)。
* - alt 是单个复合选择器(如 `.dark`、`[type=button]`):直接拼进同一复合选择器;
* - alt 形如 `A *`(祖先 A 的任意后代,Tailwind dark 变体即 `.dark *`):
* 语义为「元素满足 before/after,且有祖先满足 A」→ `A ` + before + after。
* 若 before 自身含组合符(祖先链),A 放在最前面;dark 变体的 `.dark` 在 <html> 上,
* 是所有元素的祖先,结果与原语义一致。
* - 其他带组合符的形式无法在不支持 :is() 的老内核上等价表达 → 直接报错,绝不静默产出错误 CSS。
*/
function expandAlternative(before, alt, after, whole) {
const parts = alt.split(/\s+/).filter(Boolean)
if (parts.length === 1 && !/[>+~]/.test(alt)) return before + alt + after
if (parts.length === 2 && parts[1] === '*' && !/[>+~]/.test(parts[0])) {
// `before` 为空(规则本身就是 `:where(A *)`)时退化为 `A *`
const rest = before + after
return parts[0] + ' ' + (rest.trim() ? rest : '*')
}
throw new Error(`[postbuild-css] 无法安全展开的 :where 备选项「${alt}」(选择器:${whole})`)
}
/**
* 展开选择器里「带逗号列表」的 :where(A, B):
* X:where(A, B)Y → XAY, XBY(含组合符的备选项见 expandAlternative)
* 直接去壳会得到 `XA, BY`:逗号把选择器拆开,后半段 B 变成一条独立的全局规则。
* 典型事故:dark 变体 `.dark\:bg-x:where(.dark, .dark *)` 去壳成
* `.dark\:bg-x.dark, .dark *` —— `.dark *` 命中暗色下所有元素,全站被刷成同一背景色。
* 只处理规则选择器(`{` 之前的部分),声明块内容原样保留。
*/
function expandWhereLists(css) {
return css.replace(/([^{}]+)\{/g, (whole, selectorList) => {
if (!selectorList.includes(':where(')) return whole
// 跳过 at-rule 前缀(如 @media (...) / @supports (...)),这些不是选择器
if (selectorList.trim().startsWith('@')) return whole
const expanded = []
for (const sel of splitTopLevel(selectorList)) {
let queue = [sel]
for (let guard = 0; guard < 8; guard++) {
let changed = false
const next = []
for (const s of queue) {
const m = s.indexOf(':where(')
if (m < 0) {
next.push(s)
continue
}
let depth = 0
let end = -1
for (let k = m + 6; k < s.length; k++) {
if (s[k] === '(') depth++
else if (s[k] === ')') {
depth--
if (depth === 0) {
end = k
break
}
}
}
if (end < 0) {
next.push(s)
continue
}
const inner = splitTopLevel(s.slice(m + 7, end))
if (inner.length < 2) {
next.push(s)
continue
}
changed = true
const before = s.slice(0, m)
const after = s.slice(end + 1)
for (const alt of inner) next.push(expandAlternative(before, alt, after, s))
}
queue = next
if (!changed) break
}
expanded.push(...queue)
}
return expanded.join(',') + '{'
})
}
/** 解包 :where(...) → 内部选择器(老内核不支持 :where,会整条丢弃规则)。
* 须先经 expandWhereLists 展开逗号列表,此处只剩单项 :where(A),去壳即等价 */
function unwrapWhere(css) {
let out = ''
let i = 0
while (i < css.length) {
const m = css.indexOf(':where(', i)
if (m < 0) {
out += css.slice(i)
break
}
// 避免误伤字符串/注释中的 :where(本项目 CSS 产物中无此类内容,保守处理仍做括号配对)
out += css.slice(i, m)
let depth = 0
let end = -1
for (let k = m + 6; k < css.length; k++) {
if (css[k] === '(') depth++
else if (css[k] === ')') {
depth--
if (depth === 0) {
end = k
break
}
}
}
if (end < 0) {
out += css.slice(m)
break
}
out += css.slice(m + 7, end) // 去掉 ":where(" 与末端 ")"
i = end + 1
}
return out
}
/** 剥离 @layer 包裹:保留块内规则与顺序(层叠仍由源顺序 + 选择器优先级决定) */
function unwrapCascadeLayers(css) {
let out = css
const re = /@layer\s+[A-Za-z0-9_,\s-]+?\{/g
// 反复处理,兼容理论上的嵌套 @layer
for (let pass = 0; pass < 8; pass++) {
const heads = []
let m
re.lastIndex = 0
while ((m = re.exec(out)) !== null) {
heads.push({ head: m.index, bodyStart: re.lastIndex - 1 })
}
if (heads.length === 0) break
for (let i = heads.length - 1; i >= 0; i--) {
const { head, bodyStart } = heads[i]
let depth = 0
let end = -1
for (let k = bodyStart; k < out.length; k++) {
if (out[k] === '{') depth++
else if (out[k] === '}') {
depth--
if (depth === 0) {
end = k
break
}
}
}
if (end < 0) continue
out = out.slice(0, head) + out.slice(bodyStart + 1, end) + out.slice(end + 1)
}
}
// 移除裸 @layer 声明语句(如 `@layer components;`)
out = out.replace(/@layer\s+[A-Za-z0-9_,\s-]+;/g, '')
return out
}
/** 校验:括号闭合(剥离过程中出现异常时立刻失败,避免把坏 CSS 发上线) */
function assertBalanced(css, file) {
let depth = 0
for (const ch of css) {
if (ch === '{') depth++
else if (ch === '}') {
depth--
if (depth < 0) throw new Error(`[postbuild-css] ${file} 括号不闭合(出现多余的 })`)
}
}
if (depth !== 0) throw new Error(`[postbuild-css] ${file} 括号不闭合(缺少 ${depth} 个 })`)
}
const files = (await readdir(assetsDir)).filter((f) => f.endsWith('.css'))
if (files.length === 0) {
throw new Error('[postbuild-css] dist/assets 下未找到 CSS 产物,构建流程异常')
}
for (const file of files) {
const path = join(assetsDir, file)
const src = await readFile(path)
// ① 解包 :where()(lightningcss 不会处理它,需先做):
// 先把 :where(A, B) 展开成多条选择器,再对剩余单项 :where(A) 去壳
let prepared = Buffer.from(unwrapWhere(expandWhereLists(src.toString('utf8'))))
const whereCount = (src.toString('utf8').match(/:where\(/g) || []).length
// ② 按目标内核降级(oklch 等新颜色函数 → 老内核可识别的色值)
const { code } = transform({
filename: file,
code: prepared,
minify: true,
targets: TARGETS,
})
let out = code.toString()
// ③ 剥离 @layer 包裹
out = unwrapCascadeLayers(out)
// ④ 兜底校验:任何一项残留都说明处理逻辑失效,直接失败避免上线残缺样式
if (/@layer/.test(out)) throw new Error(`[postbuild-css] ${file} 仍残留 @layer`)
if (/oklch\(/.test(out)) throw new Error(`[postbuild-css] ${file} 仍残留 oklch(`)
if (/:where\(/.test(out)) throw new Error(`[postbuild-css] ${file} 仍残留 :where(`)
// 暗色变体去壳错误的特征:出现一条以 `.dark *` 开头的独立全局规则(会把暗色下所有元素刷成同一样式)
if (/(^|[{},])\s*\.dark \*\s*\{/.test(out)) {
throw new Error(`[postbuild-css] ${file} 出现全局 \`.dark *\` 规则,:where 逗号列表展开失败`)
}
assertBalanced(out, file)
await writeFile(path, out)
console.log(
`[postbuild-css] ${file}: ${src.length} → ${out.length} bytes` +
`(解包 ${whereCount} 处 :where + 降级颜色 + 剥离 @layer)`
)
}
+37 -14
View File
@@ -2,6 +2,9 @@
import { watch } from 'vue'
import { useRoute } from 'vue-router'
import { useMessage } from '@/composables/useMessage'
import UpdateChecker from '@/components/UpdateChecker.vue'
import ConfirmHost from '@/components/ConfirmHost.vue'
import { Check, X, Info } from 'lucide-vue-next'
const route = useRoute()
const { toasts, removeToast } = useMessage()
@@ -26,25 +29,45 @@ watch(
<template>
<router-view />
<!-- 全局新版本检测(无 UI,发现更新时弹窗提示刷新) -->
<UpdateChecker />
<!-- 全局确认/提示弹窗宿主(confirmDialog / alertDialog) -->
<ConfirmHost />
<!-- Toast 容器 -->
<Teleport to="body">
<div class="fixed top-4 right-4 z-[100] flex flex-col gap-2 max-w-sm">
<!-- 顶部居中胶囊 Toast(液态玻璃,iOS 风格灵动提示) -->
<div
class="fixed inset-x-0 top-0 z-[100] flex flex-col items-center gap-2 px-4 pointer-events-none"
style="padding-top: calc(var(--safe-top) + 12px)"
role="status"
aria-live="polite"
>
<TransitionGroup name="toast">
<div
v-for="toast in toasts"
:key="toast.id"
:class="[
'px-4 py-3 rounded-lg shadow-lg border text-sm flex items-center gap-2 transition-all',
toast.type === 'success' && 'bg-green-50 border-green-200 text-green-700',
toast.type === 'error' && 'bg-red-50 border-red-200 text-red-700',
toast.type === 'info' && 'bg-blue-50 border-blue-200 text-blue-700',
]"
class="glass pointer-events-auto max-w-sm w-fit pl-3 pr-2.5 py-2.5 rounded-full text-sm font-medium text-gray-800 flex items-center gap-2 transition-all"
>
<span class="flex-1">{{ toast.message }}</span>
<span
:class="[
'w-5 h-5 rounded-full flex items-center justify-center flex-shrink-0 text-white',
toast.type === 'success' && 'bg-green-500',
toast.type === 'error' && 'bg-red-500',
toast.type === 'info' && 'bg-brand-500',
]"
>
<Check v-if="toast.type === 'success'" class="w-3.5 h-3.5" :stroke-width="3" />
<X v-else-if="toast.type === 'error'" class="w-3.5 h-3.5" :stroke-width="3" />
<Info v-else class="w-3.5 h-3.5" :stroke-width="3" />
</span>
<span class="flex-1 min-w-0 break-words">{{ toast.message }}</span>
<button
class="text-current opacity-50 hover:opacity-100 text-lg leading-none"
class="w-6 h-6 rounded-full flex items-center justify-center text-gray-400 hover:text-gray-600 flex-shrink-0"
aria-label="关闭提示"
@click="removeToast(toast.id)"
>&times;</button>
><X class="w-3.5 h-3.5" /></button>
</div>
</TransitionGroup>
</div>
@@ -53,17 +76,17 @@ watch(
<style scoped>
.toast-enter-active {
transition: all 0.3s ease-out;
transition: all 0.42s cubic-bezier(0.34, 1.36, 0.64, 1);
}
.toast-leave-active {
transition: all 0.2s ease-in;
transition: all 0.22s ease-in;
}
.toast-enter-from {
opacity: 0;
transform: translateX(40px);
transform: translateY(-16px) scale(0.92);
}
.toast-leave-to {
opacity: 0;
transform: translateX(40px);
transform: translateY(-10px) scale(0.96);
}
</style>
+21 -1
View File
@@ -246,6 +246,7 @@ export interface Announcement {
targetUserId: number | null
forcePopup: boolean
loopPopup: boolean
isPinned: boolean
createdBy: number
createdAt: string
updatedAt: string
@@ -265,6 +266,7 @@ export interface CreateAnnouncementParams {
targetUserId?: number
forcePopup: boolean
loopPopup: boolean
isPinned: boolean
}
export interface ReadUser {
@@ -300,6 +302,22 @@ export function getAnnouncementDetail(id: number) {
return api.get<{ data: AnnouncementDetail }>(`/admin/announcements/${id}`)
}
export interface AnnouncementTargetItem {
id: number
username: string
realName?: string
studentId?: string
schoolName?: string
role?: string
}
/** 搜索公告指定对象(按用户名/学号/姓名/学校模糊查询) */
export function searchAnnouncementTargets(targetType: 'user' | 'agent', keyword: string) {
return api.get<{ data: AnnouncementTargetItem[] }>('/admin/announcements/targets', {
params: { targetType, keyword },
})
}
/** 删除公告 */
export function deleteAnnouncement(id: number) {
return api.delete(`/admin/announcements/${id}`)
@@ -381,12 +399,14 @@ export interface AdminTaskLogItem {
username: string
realName: string
schoolName: string
taskType: string // sign / sleep_check
taskType: string // sign / sleep_check / leave_apply / leave_report
title: string
realTitle: string
isSuccess: boolean
errorMessage: string
createdAt: string
deleted: boolean // 用户已在客户端删除(软删除),仅后台可见
source: number // 1=签到/查寝 2=请假/销假(两表 id 独立自增,列表 key 需组合)
}
export interface AdminTaskLogResult {
+14
View File
@@ -33,6 +33,20 @@ export function getUnreadCount() {
return api.get<{ data: UnreadCount }>('/announcements/unread-count')
}
// ======== 置顶公告(打卡页顶部横向滚动) ========
export interface PinnedAnnouncement {
id: number
title: string
content: string
createdAt: string
}
/** 获取当前用户可见的置顶公告 */
export function getPinnedAnnouncements() {
return api.get<{ data: PinnedAnnouncement[] }>('/announcements/pinned')
}
// ======== 公告聚合信息 ========
export interface PopupAnnouncement {
+65 -12
View File
@@ -18,7 +18,6 @@ export interface LoginParams {
password: string
captchaId: string
captchaCode: string
inviteCode?: string
}
export interface LoginResult {
@@ -75,6 +74,8 @@ export interface MyInviteInfo {
inviterRewardPerInvite: number
/** 被邀请人注册登录后获得的奖励积分(系统配置) */
inviteeRewardPerInvite: number
/** 是否已绑定邀请人(已填写过他人邀请码);用于控制「填写邀请码」入口显隐 */
hasInviter: boolean
}
/** 搜索学校 */
@@ -139,6 +140,32 @@ export function verifyMFA(params: MFAVerifyParams) {
return api.post<{ code: number; message: string; data?: LoginResult }>('/auth/login/mfa', params)
}
// ---- 手机号验证码登录(无需选择学校:tenantId 由登录响应返回) ----
/** 发送手机短信验证码(需图片验证码) */
export function sendSmsCode(phone: string, captchaId: string, captchaCode: string) {
return api.post<{ data: { countdown: number; tipMsg: string } }>('/auth/sms/send', {
phone,
captchaId,
captchaCode,
})
}
/** 手机号 + 短信验证码登录(需图片验证码) */
export function smsLogin(
phone: string,
code: string,
captchaId: string,
captchaCode: string,
) {
return api.post<{ code: number; message: string; data?: LoginResult }>('/auth/sms/login', {
phone,
code,
captchaId,
captchaCode,
})
}
/** 刷新 token */
export function refreshToken(refreshToken: string) {
return api.post<{ data: { accessToken: string; refreshToken: string } }>(
@@ -183,11 +210,19 @@ export function redeemCDK(code: string, captchaId: string, captchaCode: string)
}>('/cdk/redeem', { code, captchaId, captchaCode })
}
/** 当前用户 VIP 信息(是否 VIP、到期时间、系统配置单价) */
/** 当前用户 VIP 信息(是否 VIP、到期时间、系统配置单价;含「购买 CDK」入口配置) */
export function getVipInfo() {
return api.get<{ data: { isVip: boolean; vipExpireAt: string; vipWeekPrice: string; vipMonthPrice: string; vipPrice: string } }>(
'/cdk/vip-info'
)
return api.get<{
data: {
isVip: boolean
vipExpireAt: string
vipWeekPrice: string
vipMonthPrice: string
vipPrice: string
cdkBuyUrl?: string
cdkBuyVisible?: boolean
}
}>('/cdk/vip-info')
}
// ======== 签到任务相关 ========
@@ -200,12 +235,15 @@ export interface TaskInfo {
realTitle: string
signWid: string
signInstanceWid: string
formData: string
/** 列表接口不再返回(可能达 MB 级);需要时走 getTaskSavedForm */
formData?: string
taskStatus: string
errorMessage: string
createdAt: string
updatedAt: string
matchStatus: string
/** 服务端预计算的「必填但为空」字段标题,替代前端解析 formData */
missingFields?: string[]
}
export interface TaskTitleItem {
@@ -229,6 +267,12 @@ export interface FormItem {
number?: number
show: boolean
tip?: string
/** 工作日志签到项(需获取当前位置,学校按点位半径校验) */
isWorklogSign?: boolean
/** 工作日志签到项允许的范围点位 id(展示用,非坐标) */
signScopeWids?: string
/** 工作日志签到项允许的签到范围点(地址/经纬度/半径) */
signScopes?: { address: string; latitude: string; longitude: string; radius?: number }[]
}
/** 获取所有可用的签到/查寝任务标题列表(后台并发聚合) */
@@ -241,11 +285,16 @@ export function createTask(params: { taskType: string; title: string; realTitle:
return api.post<{ data: TaskInfo }>('/task/create', params)
}
/** 获取任务表单 */
/** 获取任务表单(从今日校园重新拉取模板,不含用户已填值) */
export function getTaskForm(id: number) {
return api.get<{ data: { formItems: FormItem[]; realTitle: string; isPhoto: boolean } }>(`/task/form/${id}`)
}
/** 获取该任务已保存的表单内容(编辑弹窗预填用,避免列表接口携带大字段) */
export function getTaskSavedForm(id: number) {
return api.get<{ data: { formData: string } }>(`/task/form-data/${id}`)
}
/** 保存任务表单 */
export function saveTaskForm(id: number, formData: FormItem[]) {
return api.post(`/task/form/${id}`, { formData })
@@ -266,6 +315,7 @@ export function executeTaskSSE(id: number, callbacks: {
onConnecting?: () => void
onFetchingForm?: () => void
onSubmitting?: () => void
onVerifying?: () => void
onSuccess?: (data: any) => void
onError?: (message: string, data?: any) => void
}) {
@@ -283,6 +333,9 @@ export function executeTaskSSE(id: number, callbacks: {
es.addEventListener('submitting', () => {
callbacks.onSubmitting?.()
})
es.addEventListener('verifying', () => {
callbacks.onVerifying?.()
})
es.addEventListener('success', (e) => {
const data = JSON.parse(e.data)
callbacks.onSuccess?.(data)
@@ -337,11 +390,6 @@ export function deleteTaskLog(id: number) {
return api.delete(`/task/logs/${id}`)
}
/** 获取/生成邀请码 */
export function getMyInvite() {
return api.post<{ data: MyInviteInfo }>('/auth/generate-invite')
}
/** 查询邀请信息 */
export function getMyInviteInfo() {
return api.get<{ data: MyInviteInfo }>('/auth/my-invite')
@@ -352,6 +400,11 @@ export function redeemInviteReward() {
return api.post<{ data: { quota: number } }>('/auth/redeem-invite')
}
/** 填写他人邀请码(绑定邀请关系,双方发放积分) */
export function bindInviteCode(inviteCode: string) {
return api.post<{ data: MyInviteInfo }>('/auth/bind-invite', { inviteCode })
}
/** 上报已完成当前版本新手引导(数据库记录,跨设备不再重复展示) */
export function completeGuide() {
return api.post('/auth/guide/complete')
+29
View File
@@ -2,6 +2,8 @@ import axios from 'axios'
// 自定义事件:cpdaily 密码错误,通知组件弹窗
export const CPD_PASSWORD_ERROR = 'cpd-password-error'
// 自定义事件:账号被封禁(携带 detail: { message }),通知组件弹出封号原因
export const ACCOUNT_BANNED = 'account-banned'
const api = axios.create({
baseURL: import.meta.env.VITE_API_BASE_URL || '/api',
@@ -44,6 +46,14 @@ api.interceptors.response.use(
async (error) => {
const originalRequest = error.config
// 账号封禁:后台在所有接口统一返回 HTTP 403 + code 4030,message 为封号原因。
// 清除登录态、广播事件让界面弹出封号原因并跳登录页;不触发 token 刷新。
if (error.response?.status === 403 && error.response?.data?.code === 4030) {
const message = error.response.data.message || '您的账号已被封禁'
handleAccountBanned(message)
return Promise.reject(error)
}
// admin API 401:直接清理 admin token 并跳管理后台登录页,
// 不触碰用户端 access_token/refresh_token(两端登录互不影响)
if (error.response?.status === 401 && originalRequest.url?.startsWith('/admin/')) {
@@ -69,11 +79,15 @@ api.interceptors.response.use(
}
// 用户端 401 且不是刷新/登录的请求,尝试刷新 token
// 注意:登录类接口(含手机号验证码登录 /auth/sms/*)返回的 401 表示「登录凭据错误」,
// 与「登录态过期」语义不同,必须排除,否则会被误判为 token 过期 →
// 清空登录态并跳回 /login(表现为:登录失败却无任何提示、页面被重置)。
if (
error.response?.status === 401 &&
!originalRequest._retry &&
!originalRequest.url?.includes('/auth/refresh') &&
!originalRequest.url?.includes('/auth/login') &&
!originalRequest.url?.includes('/auth/sms/') &&
!originalRequest.url?.includes('/report-failed-login')
) {
originalRequest._retry = true
@@ -124,4 +138,19 @@ function handleCpdailyPasswordError() {
}, 500)
}
// handleAccountBanned 处理账号封禁:清除登录态、广播封号原因并跳登录页
function handleAccountBanned(message: string) {
if ((window as any).__accountBannedHandled) return
;(window as any).__accountBannedHandled = true
localStorage.removeItem('access_token')
localStorage.removeItem('refresh_token')
// 广播事件(若页面有监听则弹窗);同时用原生 alert 兜底,确保封号原因一定可见
window.dispatchEvent(new CustomEvent(ACCOUNT_BANNED, { detail: { message } }))
try { window.alert(message) } catch { /* ignore */ }
window.location.href = '/login'
}
export default api
+346
View File
@@ -0,0 +1,346 @@
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
/** 状态文案(服务端权威映射,新套学校返回;旧套为空时前端本地映射兜底) */
statusText?: string
/** 学校规定的销假位置列表;为空时需用户手动输入 */
reportPlaces: LeaveReportPlace[]
/** 该请假类型销假是否需要选择审批人(leaveType.reportApplyFlow=1) */
reportApplyFlow: boolean
/** 是否可销假(列表含仅可续假的单) */
canReport?: boolean
/** 是否可续假(休假中/逾期 且学校允许续假) */
canExtend?: boolean
/** 续假用:原假单的请假类型 ID(typeWid) */
typeWid?: string
/** 续假用:原假单是否离校("1"=离校) */
isLeaveOut?: string
/** 该请假类型销假是否允许上传图片(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
/** 1=需先手写承诺书签名后提交(新套学校) */
requireSign: number
/** 「是否离校」默认值(0=无默认 1=默认是 2=默认否;仅新套类型返回,非强制) */
outStatusDefault: number
/** 1=必须上传证明材料(isRequiredEvidenceFile,仅新套) */
requireEvidence?: number
/** 证明材料最少张数 */
evidenceMin?: number
/** 自定义附加字段(如「当天是否返校」),提交时回填 selfDefineAnswers */
selfDefineFields?: SelfDefineField[]
}
/** 请假自定义附加字段(新套 selfDefineFields) */
export interface SelfDefineField {
fieldWid: number
fieldName: string
fieldType: number
fieldTip?: string
fieldValue?: string | null
isRequired: boolean
optionList?: { optionContent: string; optionSeq: number }[]
wordsMax?: number
wordsMini?: number
}
/** 自定义附加字段作答(提交 selfDefineAnswers 项) */
export interface SelfDefineAnswer {
fieldWid: number
fieldName: string
fieldValue: string
/** 字段类型(官方前端一并回传) */
fieldType?: 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
/** 附件图片 URL 列表(最多 4 张且不超学校上限;后端兼容旧 imageUrl 单图字段) */
imageUrls?: string[]
approverWid?: string
approverId?: string
approverName?: string
/** 抄送人(非必填;候选取自审批人列表) */
ccUsers?: LeaveApprover[]
/** 手写承诺书签名图 URL(requireSign=1 的类型必填) */
signatureUrl?: string
/** 续假:true=延长已有假单(仅新套) */
isExtend?: boolean
/** 续假:原假单 ID */
extendLeaveId?: string
/** 自定义附加字段作答(新套 selfDefineFields) */
selfDefineAnswers?: SelfDefineAnswer[]
}
/** 续假预填:取原假单类型/原结束时间/原因等(仅新套) */
export interface LeaveExtendPrefill {
leaveId: string
typeWid: number
beginTime: string
endTime: string
leaveReason: string
isLeaveOut: string
}
export function getLeaveExtendPrefill(leaveId: string) {
return api.post<{ data: LeaveExtendPrefill }>('/leave/extend-prefill', { leaveId })
}
// ======== 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
*
* 实现说明(为什么用 XHR 而非 fetch + ReadableStream):
* 部分移动端浏览器(实测 iOS 独立 QQ 浏览器 App)对 `fetch` 响应的 `res.body`
* 支持不稳定:偶发返回 null 或流读取失败,导致本接口误报「连接失败,请检查网络」,
* 而同一页面的 axios(XHR) 接口全部正常。改用 XMLHttpRequest 的增量
* onprogress(responseText 累积)解析 SSE——XHR 在这些浏览器上已被证明可靠,
* 且能获取 HTTP 状态码与错误响应体,错误提示更准确。
*/
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()
let finished = false
const finish = (fn?: (...args: any[]) => void, ...args: any[]) => {
if (finished) return
finished = true
fn?.(...args)
}
const baseUrl = import.meta.env.VITE_API_BASE_URL || '/api'
const token = localStorage.getItem('access_token') || ''
const xhr = new XMLHttpRequest()
let handled = 0 // 已解析到的 responseText 位置
let buf = ''
// 解析自上次以来的增量文本,切分并派发完整 SSE 事件块
const drain = () => {
buf += xhr.responseText.slice(handled)
handled = xhr.responseText.length
let sep: number
while (!finished && (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
}
}
}
xhr.open('POST', `${baseUrl}${path}`, true)
xhr.setRequestHeader('Authorization', 'Bearer ' + token)
xhr.setRequestHeader('Content-Type', 'application/json')
xhr.onprogress = () => {
if (xhr.status >= 200 && xhr.status < 300) drain()
}
xhr.onload = () => {
if (finished) return
if (xhr.status >= 200 && xhr.status < 300) {
drain()
// 正常结束但未收到结果事件:异常中断
if (!finished) finish(callbacks.onError, '连接中断,请重试')
return
}
// HTTP 错误:服务端以 JSON 返回(参数校验/鉴权等,非 SSE 阶段)
let msg = '连接失败,请检查网络'
try {
const j = JSON.parse(xhr.responseText)
if (j?.message) msg = j.message
} catch { /* 保留默认提示 */ }
finish(callbacks.onError, msg, { message: msg })
}
xhr.onerror = () => {
if (finished) return
finish(callbacks.onError, '连接失败,请检查网络')
}
controller.signal.addEventListener('abort', () => {
finished = true // 主动取消,不回调
try { xhr.abort() } catch { /* ignore */ }
})
xhr.send(JSON.stringify(params))
return controller
}
+15 -7
View File
@@ -15,6 +15,8 @@ export interface AdminTicketListItem extends TicketListItem {
username: string
realName: string
schoolName: string
/** 用户是否已删除该工单(软删除);管理端仍可见 */
deleted?: boolean
}
export interface TicketMessageRes {
@@ -37,16 +39,22 @@ export interface TicketDetail {
schoolName?: string
createdAt: string
updatedAt: string
/** 用户是否已删除该工单(软删除);管理端仍可查看 */
deleted?: boolean
messages: TicketMessageRes[]
}
export interface TicketNotificationItem {
id: number
ticketId: number
/** reply=管理员回复 / status=状态变更 */
kind: string
title: string
content: string
isRead: boolean
createdAt: string
/** 关联工单当前状态(pending/processing/resolved/closed);用于把未关闭工单的通知排前 */
ticketStatus: string
}
// ======== 用户端 API ========
@@ -138,14 +146,14 @@ export function updateTicketStatus(ticketId: number, status: string) {
export const TICKET_STATUS_MAP: Record<string, string> = {
pending: '待处理',
processing: '处理中',
resolved: '已解决',
closed: '已关闭',
resolved: '已完成',
closed: '关闭',
}
/** 工单状态颜色映射 */
/** 工单状态颜色映射:待处理=黄 / 处理中=蓝 / 已完成=绿 / 关闭=灰 */
export const TICKET_STATUS_COLOR: Record<string, string> = {
pending: 'bg-amber-100 text-amber-700',
processing: 'bg-blue-100 text-blue-700',
resolved: 'bg-green-100 text-green-700',
closed: 'bg-gray-100 text-gray-500',
pending: 'adm-badge-amber',
processing: 'adm-badge-sky',
resolved: 'adm-badge-green',
closed: 'adm-badge-gray',
}
+711 -67
View File
@@ -1,34 +1,180 @@
@import "tailwindcss";
/* 基于 class 的暗色变体:html.dark 时启用 dark:* 工具类
(切换逻辑见 composables/useTheme.ts,按浏览器系统时间自动切换) */
(切换逻辑见 composables/useTheme.ts:自动 / 亮 / 暗 三档) */
@custom-variant dark (&:where(.dark, .dark *));
/* ============================================================
* 设计令牌 · 薄荷汽水(Mint Soda)× 轻量液态玻璃
*
* 换肤方式:直接重映射 Tailwind 色阶 —— blue → 薄荷品牌色、gray → 薄荷中性灰、
* red/rose → 珊瑚、amber → 柠檬、green → 清新绿。全站 1000+ 处色阶类无需逐个
* 改写即可统一换肤(历史代码里的 blue-* 即品牌色);新代码请优先用 brand-*。
*
* 对比度(WCAG AA,已逐色计算):亮色下 *-500/600 作文字或白字底色均 ≥ 4.5;
* 暗色下品牌按钮白字 ≥ 3(AA 大字/UI 组件级),正文文字 ≥ 4.5。
* ============================================================ */
@theme {
--color-background: #f5f5f5;
--color-foreground: #1a1a1a;
--font-sans: -apple-system, BlinkMacSystemFont, "SF Pro Text", "PingFang SC", "HarmonyOS Sans SC",
"MiSans", "Hiragino Sans GB", "Microsoft YaHei", "Helvetica Neue", Arial, sans-serif;
/* —— 品牌 · 薄荷青(brand 与 blue 同值;blue 为历史类名兼容)—— */
--color-brand-50: #eafaf5;
--color-brand-100: #d2f4ea;
--color-brand-200: #a8e9d7;
--color-brand-300: #74dac2;
--color-brand-400: #2cc5a8;
--color-brand-500: #0b8672;
--color-brand-600: #0a7866;
--color-brand-700: #08604f;
--color-brand-800: #0a4d41;
--color-brand-900: #0b3f36;
--color-blue-50: #eafaf5;
--color-blue-100: #d2f4ea;
--color-blue-200: #a8e9d7;
--color-blue-300: #74dac2;
--color-blue-400: #2cc5a8;
--color-blue-500: #0b8672;
--color-blue-600: #0a7866;
--color-blue-700: #08604f;
--color-blue-800: #0a4d41;
--color-blue-900: #0b3f36;
/* —— 点缀 · 天空蓝(信息/次强调,取代原 indigo/sky 的位置)—— */
--color-sky-50: #eef6ff;
--color-sky-100: #dcecfe;
--color-sky-400: #5aa9f5;
--color-sky-500: #2373c4;
--color-sky-600: #1d63a9;
--color-indigo-50: #eef6ff;
--color-indigo-100: #dcecfe;
--color-indigo-500: #2373c4;
--color-cyan-400: #5aa9f5;
/* —— 中性 · 薄荷灰(带一丝青调,比纯灰更通透)—— */
--color-gray-50: #f6faf8;
--color-gray-100: #edf3f0;
--color-gray-200: #dfe8e4;
--color-gray-300: #c9d5d0;
--color-gray-400: #808e89;
--color-gray-500: #66756f;
--color-gray-600: #4f5d58;
--color-gray-700: #3a4743;
--color-gray-800: #263330;
--color-gray-900: #17221f;
--color-gray-950: #0e1714;
/* —— 语义 · 珊瑚(危险/错误;red 与 rose 合一,比纯红柔和)—— */
--color-red-50: #fff1f2;
--color-red-100: #ffe1e4;
--color-red-200: #ffc7cd;
--color-red-300: #ffa1ab;
--color-red-400: #f77a87;
--color-red-500: #d63f52;
--color-red-600: #bf3446;
--color-red-700: #9e2a3a;
--color-rose-50: #fff1f4;
--color-rose-100: #ffe1e8;
--color-rose-200: #ffc8d5;
--color-rose-300: #ffa3b8;
--color-rose-400: #f7809c;
--color-rose-500: #d23c68;
--color-rose-600: #b8325a;
--color-rose-700: #96284a;
/* —— 语义 · 清新绿(成功)—— */
--color-green-50: #ecfaf2;
--color-green-100: #d5f4e2;
--color-green-200: #ade8c6;
--color-green-400: #4ccb86;
--color-green-500: #1f9d58;
--color-green-600: #16864a;
--color-green-700: #126b3b;
--color-emerald-50: #ecfaf2;
--color-emerald-200: #ade8c6;
--color-emerald-400: #4ccb86;
--color-emerald-600: #16864a;
/* —— 语义 · 柠檬(提醒/公告/积分)—— */
--color-amber-50: #fffaeb;
--color-amber-100: #fff1c7;
--color-amber-200: #ffe39a;
--color-amber-300: #ffd45c;
--color-amber-400: #f5bd2e;
--color-amber-500: #e3a008;
--color-amber-600: #9a6200;
--color-amber-700: #8a5a00;
--color-amber-800: #6e4700;
--color-amber-900: #573800;
--color-orange-50: #fff5ec;
--color-orange-100: #ffe8d4;
--color-orange-400: #ffa45c;
--color-orange-500: #f08a3c;
--color-orange-600: #b35a12;
/* —— 点缀 · 薰衣草(VIP/特殊标签)—— */
--color-purple-50: #f5f2ff;
--color-purple-100: #ebe5ff;
--color-purple-200: #d9cfff;
--color-purple-500: #8b6ce6;
--color-purple-600: #6a4bcb;
/* —— 语义 token(ui/ 组件与语义类使用)—— */
--color-background: #f3faf7;
--color-foreground: #17221f;
--color-card: #ffffff;
--color-card-foreground: #1a1a1a;
--color-primary: #2563eb;
--color-card-foreground: #17221f;
--color-primary: #0b8672;
--color-primary-foreground: #ffffff;
--color-secondary: #f3f4f6;
--color-secondary-foreground: #1f2937;
--color-muted: #f3f4f6;
--color-muted-foreground: #6b7280;
--color-accent: #f3f4f6;
--color-accent-foreground: #1f2937;
--color-destructive: #ef4444;
--color-secondary: #edf3f0;
--color-secondary-foreground: #263330;
--color-muted: #edf3f0;
--color-muted-foreground: #66756f;
--color-accent: #eafaf5;
--color-accent-foreground: #08604f;
--color-destructive: #d63f52;
--color-destructive-foreground: #ffffff;
--color-border: #e5e7eb;
--color-input: #e5e7eb;
--color-ring: #2563eb;
--radius: 0.5rem;
--color-border: #dfe8e4;
--color-input: #dfe8e4;
--color-ring: #2cc5a8;
/* —— 圆角:控件 / 卡片 / 浮层(同心圆角:内层 = 外层 − 内边距)—— */
--radius: 0.75rem;
--radius-md: 0.5rem;
--radius-lg: 0.75rem;
--radius-xl: 1.25rem;
--radius-2xl: 1.5rem;
--radius-3xl: 1.75rem;
/* —— 阴影:柔和、带一点品牌色调 —— */
--shadow-sm: 0 1px 2px rgb(14 40 34 / 0.04), 0 2px 8px rgb(14 40 34 / 0.04);
--shadow: 0 2px 6px rgb(14 40 34 / 0.05), 0 6px 18px rgb(14 40 34 / 0.06);
--shadow-md: 0 4px 12px rgb(14 40 34 / 0.07), 0 10px 28px rgb(14 40 34 / 0.06);
--shadow-lg: 0 8px 24px rgb(14 40 34 / 0.09), 0 18px 44px rgb(14 40 34 / 0.08);
--shadow-xl: 0 12px 32px rgb(14 40 34 / 0.12), 0 24px 60px rgb(14 40 34 / 0.1);
--shadow-2xl: 0 20px 50px rgb(14 40 34 / 0.18);
/* —— 动效 —— */
--ease-spring: cubic-bezier(0.34, 1.36, 0.64, 1);
--ease-out-soft: cubic-bezier(0.22, 1, 0.36, 1);
}
* {
border-color: var(--color-border);
}
/* 顶部安全区:只在「添加到主屏幕」全屏打开(standalone)时避让状态栏。
普通浏览器与 App 内置 WebView(今日校园/微信等)本身已把网页放在状态栏下方,
但部分内核仍会上报 safe-area-inset-top,直接叠加会让顶部内容凭空下移几十像素。 */
:root {
--safe-top: 0px;
}
@media (display-mode: standalone), (display-mode: fullscreen) {
:root {
--safe-top: env(safe-area-inset-top, 0px);
}
}
html, body {
margin: 0;
padding: 0;
@@ -37,12 +183,18 @@ html, body {
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
background-color: #e8e8e8;
font-family: var(--font-sans);
background-color: #e4efea;
color: var(--color-foreground);
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
-webkit-tap-highlight-color: transparent;
text-rendering: optimizeLegibility;
}
/* 手机容器 — 固定最大宽度,全屏高度,水平居中 */
/* 手机容器 — 固定最大宽度,全屏高度,水平居中。
背景 = 底色 + 三处柔光斑(薄荷 / 天空蓝 / 柠檬),为玻璃层提供可折射的底图;
纯 radial-gradient 实现,无 filter: blur,低端机零额外开销。 */
#app {
max-width: 430px;
width: 100%;
@@ -51,89 +203,581 @@ body {
height: 100vh;
height: 100dvh;
margin: 0 auto;
background-color: #ffffff;
background-color: var(--color-background);
background-image:
radial-gradient(70% 38% at 0% 0%, rgb(44 197 168 / 0.22), transparent 72%),
radial-gradient(55% 32% at 100% 18%, rgb(90 169 245 / 0.16), transparent 72%),
radial-gradient(65% 36% at 60% 100%, rgb(255 212 92 / 0.16), transparent 72%);
background-attachment: local;
overflow: hidden;
display: flex;
flex-direction: column;
box-shadow: 0 0 20px rgba(0, 0, 0, 0.08);
box-shadow: 0 0 40px rgb(14 40 34 / 0.1);
position: relative;
}
/* 管理后台模式 — 全屏宽度 */
/* 管理后台模式 — 全屏宽度,不使用光斑背景 */
#app.admin-mode {
max-width: none;
box-shadow: none;
background-color: transparent;
background-image: none;
}
/* ============================================================
* 暗色主题(html.dark,由 useTheme.ts 按系统时间自动切换)
* 液态玻璃(Liquid Glass,网页近似实现)
* 仅用于「浮在内容之上的控件层」:底部胶囊 Tab、顶栏、弹层/抽屉。
* 内容卡片保持不透明,保证可读性与低端机滚动性能。
* 降级:不支持 backdrop-filter / 系统「减弱透明度」→ 高不透明实底。
* ============================================================ */
.glass {
background-color: rgb(255 255 255 / 0.66);
-webkit-backdrop-filter: blur(22px) saturate(180%);
backdrop-filter: blur(22px) saturate(180%);
border: 1px solid rgb(255 255 255 / 0.6);
box-shadow:
inset 0 1px 0 rgb(255 255 255 / 0.75),
inset 0 -1px 0 rgb(255 255 255 / 0.25),
0 8px 28px rgb(14 40 34 / 0.12);
}
.glass-bar {
background-color: rgb(243 250 247 / 0.72);
-webkit-backdrop-filter: blur(20px) saturate(180%);
backdrop-filter: blur(20px) saturate(180%);
}
.glass-panel {
background-color: rgb(255 255 255 / 0.86);
-webkit-backdrop-filter: blur(28px) saturate(180%);
backdrop-filter: blur(28px) saturate(180%);
}
.dark .glass {
background-color: rgb(30 44 40 / 0.62);
border-color: rgb(255 255 255 / 0.09);
box-shadow:
inset 0 1px 0 rgb(255 255 255 / 0.08),
0 10px 30px rgb(0 0 0 / 0.45);
}
.dark .glass-bar {
background-color: rgb(15 24 21 / 0.72);
}
.dark .glass-panel {
background-color: rgb(26 38 34 / 0.88);
}
@supports not ((backdrop-filter: blur(1px)) or (-webkit-backdrop-filter: blur(1px))) {
.glass { background-color: rgb(255 255 255 / 0.96); }
.glass-bar { background-color: rgb(243 250 247 / 0.97); }
.glass-panel { background-color: #ffffff; }
.dark .glass { background-color: rgb(30 44 40 / 0.98); }
.dark .glass-bar { background-color: rgb(15 24 21 / 0.98); }
.dark .glass-panel { background-color: #1a2622; }
}
@media (prefers-reduced-transparency: reduce) {
.glass, .glass-bar, .glass-panel {
-webkit-backdrop-filter: none;
backdrop-filter: none;
}
.glass { background-color: rgb(255 255 255 / 0.97); }
.glass-bar { background-color: rgb(243 250 247 / 0.98); }
.glass-panel { background-color: #ffffff; }
.dark .glass { background-color: #1e2c28; }
.dark .glass-bar { background-color: #0f1815; }
.dark .glass-panel { background-color: #1a2622; }
}
/* 品牌渐变(顶栏/头图/主按钮):薄荷 → 青绿,右上带一点天空蓝 */
.bg-brand-gradient {
background-color: #0b8672;
background-image: linear-gradient(135deg, #12a58b 0%, #0b8672 55%, #0a7a6e 100%);
}
.dark .bg-brand-gradient {
background-color: #0e6f60;
background-image: linear-gradient(135deg, #10856f 0%, #0c6d5d 60%, #0b5f55 100%);
}
/* 头卡装饰光斑容器:overflow:hidden 仍可被 scrollIntoView 等程序化滚动横向推动;
clip 彻底禁止滚动(老内核不识别 clip 时回落为同元素上的 overflow-hidden) */
.overflow-hidden.hero-clip {
overflow: clip;
}
/* 按压反馈:轻微缩放,iOS 风格 */
.press {
transition: transform 0.18s var(--ease-out-soft), opacity 0.18s ease, background-color 0.2s ease, color 0.2s ease;
}
.press:active {
transform: scale(0.96);
}
/* 内容进场:轻微上浮淡入 */
@keyframes rise-in {
from { opacity: 0; transform: translateY(8px); }
to { opacity: 1; transform: none; }
}
.rise-in {
animation: rise-in 0.42s var(--ease-out-soft) both;
}
/* 系统「减弱动态效果」:关闭位移/弹簧动画(保留极短时长,保证 animationend 仍会触发) */
@media (prefers-reduced-motion: reduce) {
*, ::before, ::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
scroll-behavior: auto !important;
}
}
/* ============================================================
* 暗色主题(html.dark)· 墨绿夜
*
* Tailwind v4 的颜色工具类均引用 --color-* CSS 变量(bg-white →
* var(--color-white)),此处整体覆盖变量即可让全站 1000+ 处硬编码
* 颜色类自动适配,无需逐个加 dark: 变体。深彩色(blue-500 等
* 品牌/状态色)与遮罩黑保持原值。彩底上的白色元素用类名级规则恢复。
* var(--color-white)),此处整体覆盖变量即可让全站硬编码颜色类自动适配。
* 规则:
* - 白 / 灰阶反转(越浅的灰在暗色下越亮,保持层次感);
* - 彩色 50/100/200 浅底 → 对应深色调底;
* - 彩色 400–700 文字色提亮,保证在暗卡片上 ≥ 4.5 对比度;
* - 彩底上的白色元素用类名级规则恢复为纯白(见下方)。
* ============================================================ */
.dark {
/* —— 基础调色板:白与灰阶(越浅的灰在暗色下越亮,保持层次感)—— */
--color-white: #1e1e2a;
--color-gray-50: #232331;
--color-gray-100: #2a2a3a;
--color-gray-200: #36364a;
--color-gray-300: #474763;
--color-gray-400: #8f8fab;
--color-gray-500: #a5a5c3;
--color-gray-600: #bcbcd6;
--color-gray-700: #d4d4e6;
--color-gray-800: #e5e5f0;
--color-gray-900: #ededf6;
--color-gray-950: #f4f4fa;
/* —— 彩色浅底 → 对应暗色调 —— */
--color-blue-50: #1c2a4d;
--color-blue-100: #243660;
--color-red-50: #3d1c28;
--color-red-100: #522534;
--color-white: #18221f;
--color-gray-50: #1c2724;
--color-gray-100: #22302c;
--color-gray-200: #2c3b36;
--color-gray-300: #3b4c46;
--color-gray-400: #7f908a;
--color-gray-500: #98aba4;
--color-gray-600: #b6c5bf;
--color-gray-700: #cbd8d3;
--color-gray-800: #dde7e3;
--color-gray-900: #eaf2ef;
--color-gray-950: #f4f8f6;
--color-brand-50: #163a33;
--color-brand-100: #1b4840;
--color-brand-200: #22594f;
--color-brand-300: #2d7a6b;
--color-brand-400: #3ad3b6;
--color-brand-500: #16a88d;
--color-brand-600: #0e9079;
--color-brand-700: #7fe6d0;
--color-blue-50: #163a33;
--color-blue-100: #1b4840;
--color-blue-200: #22594f;
--color-blue-300: #2d7a6b;
--color-blue-400: #3ad3b6;
--color-blue-500: #16a88d;
--color-blue-600: #0e9079;
--color-blue-700: #7fe6d0;
--color-blue-800: #a6efdf;
--color-sky-50: #172c40;
--color-sky-100: #1d3850;
--color-sky-500: #6cb4ff;
--color-sky-600: #8cc4ff;
--color-indigo-50: #172c40;
--color-indigo-100: #1d3850;
--color-indigo-500: #6cb4ff;
--color-red-50: #3a1d24;
--color-red-100: #4a232c;
--color-red-200: #5e2b36;
--color-red-300: #7a3442;
--color-red-400: #ff8c96;
--color-red-500: #f2636f;
--color-red-600: #ff8c96;
--color-red-700: #ffb3ba;
--color-rose-50: #3a1c2b;
--color-rose-100: #4d2539;
--color-green-50: #173224;
--color-green-100: #1f4430;
--color-amber-50: #3a2f16;
--color-amber-100: #4d3f1e;
--color-purple-100: #322552;
/* —— 语义 token(ui/ 组件与语义类使用)—— */
--color-background: #15151f;
--color-foreground: #ededf6;
--color-card: #1e1e2a;
--color-card-foreground: #ededf6;
--color-secondary: #262636;
--color-secondary-foreground: #d4d4e6;
--color-muted: #262636;
--color-muted-foreground: #9c9cb8;
--color-accent: #262636;
--color-accent-foreground: #d4d4e6;
--color-border: #33334a;
--color-input: #3a3a55;
--color-ring: #3b82f6;
--color-rose-100: #4a2337;
--color-rose-200: #5e2c45;
--color-rose-300: #7a3758;
--color-rose-400: #ff8fab;
--color-rose-500: #ef6a8e;
--color-rose-600: #ff8fab;
--color-rose-700: #ffb8cb;
--color-green-50: #14301f;
--color-green-100: #183b27;
--color-green-200: #1f4d33;
--color-green-400: #4ccb86;
--color-green-500: #3fbf79;
--color-green-600: #4ccb86;
--color-green-700: #8fe3b5;
--color-emerald-50: #14301f;
--color-emerald-200: #1f4d33;
--color-emerald-600: #4ccb86;
--color-amber-50: #332a16;
--color-amber-100: #40331a;
--color-amber-200: #52411f;
--color-amber-300: #b08a2e;
--color-amber-400: #f7c54a;
--color-amber-500: #e9b23a;
--color-amber-600: #f7c54a;
--color-amber-700: #ffd978;
--color-amber-800: #ffe39a;
--color-amber-900: #fff0c2;
--color-orange-50: #35251a;
--color-orange-100: #44301f;
--color-orange-600: #ffb47a;
--color-purple-50: #261f3d;
--color-purple-100: #2f264d;
--color-purple-200: #3b3061;
--color-purple-600: #b9a5ff;
--color-background: #0f1815;
--color-foreground: #eaf2ef;
--color-card: #18221f;
--color-card-foreground: #eaf2ef;
--color-primary: #0e9079;
--color-secondary: #22302c;
--color-secondary-foreground: #cbd8d3;
--color-muted: #22302c;
--color-muted-foreground: #98aba4;
--color-accent: #163a33;
--color-accent-foreground: #7fe6d0;
--color-destructive: #e0505e;
--color-border: #2a3934;
--color-input: #33443e;
--color-ring: #3ad3b6;
}
/* —— 彩底上的白色元素恢复纯白(--color-white 已被覆盖为卡片暗色)—— */
.dark .text-white { color: #fff; }
.dark .text-white\/95 { color: rgb(255 255 255 / 0.95); }
.dark .text-white\/90 { color: rgb(255 255 255 / 0.9); }
.dark .text-white\/80 { color: rgb(255 255 255 / 0.8); }
.dark .text-white\/75 { color: rgb(255 255 255 / 0.75); }
.dark .text-white\/70 { color: rgb(255 255 255 / 0.7); }
.dark .bg-white\/10 { background-color: rgb(255 255 255 / 0.1); }
.dark .bg-white\/15 { background-color: rgb(255 255 255 / 0.15); }
.dark .bg-white\/20 { background-color: rgb(255 255 255 / 0.2); }
.dark .bg-white\/25 { background-color: rgb(255 255 255 / 0.25); }
.dark .hover\:bg-white\/25:hover { background-color: rgb(255 255 255 / 0.25); }
.dark .ring-white\/30 { --tw-ring-color: rgb(255 255 255 / 0.3); }
.dark .border-white\/30 { border-color: rgb(255 255 255 / 0.3); }
/* —— 全局容器暗色 —— */
.dark body {
background-color: #0d0d14;
background-color: #070c0a;
}
.dark #app {
background-color: #15151f;
box-shadow: 0 0 20px rgba(0, 0, 0, 0.45);
background-color: var(--color-background);
background-image:
radial-gradient(70% 38% at 0% 0%, rgb(44 197 168 / 0.14), transparent 72%),
radial-gradient(55% 32% at 100% 18%, rgb(90 169 245 / 0.09), transparent 72%),
radial-gradient(65% 36% at 60% 100%, rgb(255 212 92 / 0.06), transparent 72%);
box-shadow: 0 0 40px rgb(0 0 0 / 0.5);
}
.dark #app.admin-mode {
background-color: transparent;
background-image: none;
}
/* ============================================================
* 管理后台组件类(adm-*)
* 后台以信息密度与可读性优先:不用玻璃,实底卡片 + 细边框 + 柔和阴影;
* 统一页头、卡片、表格、徽标、工具栏、分页、空状态,各页只需套类名。
* ============================================================ */
.adm-page {
padding: 16px;
max-width: 1480px;
margin: 0 auto;
}
@media (min-width: 768px) {
.adm-page { padding: 28px 32px 40px; }
}
/* 页头:图标 + 标题 + 副标题,右侧放主操作 */
.adm-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
flex-wrap: wrap;
margin-bottom: 20px;
}
.adm-head-icon {
width: 42px;
height: 42px;
border-radius: 14px;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
color: #fff;
background-image: linear-gradient(135deg, #12a58b 0%, #0b8672 100%);
box-shadow: 0 6px 16px rgb(11 134 114 / 0.22);
}
.adm-title {
font-size: 20px;
line-height: 1.25;
font-weight: 700;
letter-spacing: -0.01em;
color: var(--color-gray-900);
}
.adm-sub {
margin-top: 2px;
font-size: 13px;
color: var(--color-gray-500);
}
/* 卡片 */
.adm-card {
background-color: var(--color-card);
border: 1px solid var(--color-gray-200);
border-radius: 18px;
box-shadow: 0 1px 2px rgb(14 40 34 / 0.04), 0 4px 14px rgb(14 40 34 / 0.04);
}
.adm-card-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 14px 18px;
border-bottom: 1px solid var(--color-gray-100);
}
.adm-card-title {
font-size: 15px;
font-weight: 600;
color: var(--color-gray-900);
}
/* 统计卡 */
.adm-stat {
display: flex;
align-items: center;
gap: 14px;
padding: 16px 18px;
}
.adm-stat-icon {
width: 44px;
height: 44px;
border-radius: 14px;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.adm-stat-label {
font-size: 12px;
color: var(--color-gray-500);
}
.adm-stat-value {
margin-top: 2px;
font-size: 22px;
line-height: 1.2;
font-weight: 700;
color: var(--color-gray-900);
font-variant-numeric: tabular-nums;
}
/* 工具栏(搜索 + 筛选 + 操作) */
.adm-toolbar {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
margin-bottom: 14px;
}
/* 表格 */
.adm-table {
width: 100%;
font-size: 13px;
border-collapse: separate;
border-spacing: 0;
}
.adm-table thead th {
position: sticky;
top: 0;
z-index: 1;
background-color: var(--color-gray-50);
color: var(--color-gray-500);
font-size: 12px;
font-weight: 600;
text-align: left;
white-space: nowrap;
border-bottom: 1px solid var(--color-gray-200);
}
.adm-table tbody tr {
transition: background-color 0.15s ease;
}
.adm-table tbody tr:hover {
background-color: var(--color-gray-50);
}
.adm-table tbody td {
border-bottom: 1px solid var(--color-gray-100);
color: var(--color-gray-700);
vertical-align: middle;
}
.adm-table tbody tr:last-child td {
border-bottom: 0;
}
/* 徽标 */
.adm-badge {
display: inline-flex;
align-items: center;
gap: 4px;
height: 22px;
padding: 0 8px;
border-radius: 999px;
font-size: 12px;
font-weight: 600;
white-space: nowrap;
line-height: 1;
}
.adm-badge::before {
content: '';
width: 6px;
height: 6px;
border-radius: 999px;
background-color: currentColor;
opacity: 0.85;
}
.adm-badge.no-dot::before { display: none; }
.adm-badge-green { background-color: var(--color-green-50); color: var(--color-green-700); }
.adm-badge-red { background-color: var(--color-red-50); color: var(--color-red-600); }
.adm-badge-amber { background-color: var(--color-amber-50); color: var(--color-amber-700); }
.adm-badge-brand { background-color: var(--color-brand-50); color: var(--color-brand-700); }
.adm-badge-purple { background-color: var(--color-purple-50); color: var(--color-purple-600); }
.adm-badge-sky { background-color: var(--color-sky-50); color: var(--color-sky-600); }
.adm-badge-gray { background-color: var(--color-gray-100); color: var(--color-gray-600); }
/* 行内文字按钮(表格操作) */
.adm-link {
display: inline-flex;
align-items: center;
gap: 4px;
height: 28px;
padding: 0 8px;
border-radius: 8px;
font-size: 12px;
font-weight: 600;
white-space: nowrap;
transition: background-color 0.15s ease, color 0.15s ease;
}
.adm-link-brand { color: var(--color-brand-600); }
.adm-link-brand:hover { background-color: var(--color-brand-50); }
.adm-link-red { color: var(--color-red-600); }
.adm-link-red:hover { background-color: var(--color-red-50); }
.adm-link-green { color: var(--color-green-600); }
.adm-link-green:hover { background-color: var(--color-green-50); }
.adm-link-gray { color: var(--color-gray-600); }
.adm-link-gray:hover { background-color: var(--color-gray-100); }
/* 弱化占位(--、/、未设置):比 gray-300 深,保证 ≥ 3:1 */
.adm-muted { color: var(--color-gray-400); }
/* 分段筛选(全部 / 状态) */
.adm-seg {
display: inline-flex;
padding: 3px;
gap: 2px;
border-radius: 12px;
background-color: var(--color-gray-100);
}
.adm-seg > button {
height: 30px;
padding: 0 12px;
border-radius: 9px;
font-size: 13px;
font-weight: 500;
color: var(--color-gray-600);
white-space: nowrap;
transition: background-color 0.2s ease, color 0.2s ease, box-shadow 0.2s ease;
}
.adm-seg > button.is-active {
background-color: var(--color-card);
color: var(--color-brand-600);
font-weight: 600;
box-shadow: 0 1px 2px rgb(14 40 34 / 0.08), 0 0 0 1px rgb(14 40 34 / 0.04);
}
/* 空状态 */
.adm-empty {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 56px 16px;
color: var(--color-gray-500);
font-size: 13px;
text-align: center;
}
.adm-empty-icon {
width: 52px;
height: 52px;
border-radius: 16px;
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 10px;
background-color: var(--color-gray-100);
color: var(--color-gray-400);
}
.dark .adm-card {
box-shadow: 0 1px 2px rgb(0 0 0 / 0.3);
}
.dark .adm-seg > button.is-active {
background-color: var(--color-gray-300);
color: var(--color-brand-700);
}
.dark .adm-head-icon {
background-image: linear-gradient(135deg, #10856f 0%, #0c6d5d 100%);
box-shadow: none;
}
/* 分页 */
.adm-pager {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
flex-wrap: wrap;
padding: 12px 18px;
border-top: 1px solid var(--color-gray-100);
font-size: 12px;
color: var(--color-gray-500);
}
.adm-pager-btn {
width: 32px;
height: 32px;
border-radius: 10px;
display: inline-flex;
align-items: center;
justify-content: center;
color: var(--color-gray-600);
border: 1px solid var(--color-gray-200);
background-color: var(--color-card);
transition: background-color 0.15s ease, border-color 0.15s ease;
}
.adm-pager-btn:hover:not(:disabled) {
background-color: var(--color-gray-50);
border-color: var(--color-gray-300);
}
.adm-pager-btn:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.adm-select {
height: 32px;
padding: 0 8px;
border-radius: 10px;
border: 1px solid var(--color-gray-200);
background-color: var(--color-card);
color: var(--color-gray-600);
font-size: 12px;
}
.adm-select:focus {
outline: none;
border-color: var(--color-brand-400);
box-shadow: 0 0 0 3px rgb(44 197 168 / 0.15);
}
/* 今日已登录用户行:浅绿底 + 左侧色条 */
.adm-row-today td {
background-color: var(--color-green-50);
}
.adm-row-today td:first-child {
box-shadow: inset 3px 0 0 var(--color-green-500);
}
/* 成功操作按钮:固定绿(白字 ≥ 4.5:1)。bg-green-500 在暗色色板下会被提亮,白字看不清 */
.adm-btn-success {
color: #fff;
background-image: linear-gradient(135deg, #1f9d58 0%, #16864a 100%);
}
/* 品牌实色按钮/开关(固定色,白字 ≥ 4.5:1;bg-blue-500 在暗色色板下被提亮,不宜放白字) */
.adm-btn-brand {
color: #fff;
background-color: #0b8672;
}
+61
View File
@@ -0,0 +1,61 @@
<script setup lang="ts">
// ConfirmHost —— 全局确认/提示弹窗宿主,挂在 App.vue,全站仅此一个实例。
//
// 与 useConfirm 的单例状态配合:任何组件 await confirmDialog()/alertDialog() 都会
// 反映到这里渲染的 AppDialog 上。视觉对齐 HomeView「确认再次打卡 / 签到次数不足」那类
// 居中弹窗(圆形图标底 + 标题 + 灰色说明 + 双按钮)。
import AppDialog from '@/components/ui/dialog/AppDialog.vue'
import { Button } from '@/components/ui/button'
import { AlertTriangle, CircleAlert } from 'lucide-vue-next'
import { useConfirm } from '@/composables/useConfirm'
const { state, respond } = useConfirm()
</script>
<template>
<AppDialog
:open="state?.open || false"
variant="center"
z-class="z-[300]"
panel-class="w-[85%] sm:max-w-sm"
:fade="true"
:title="state?.title || '提示'"
@update:open="(v: boolean) => { if (!v) respond(false) }"
>
<div v-if="state" class="p-6 text-center">
<div
:class="[
'w-14 h-14 rounded-full flex items-center justify-center mx-auto mb-4',
state.danger ? 'bg-red-100' : 'bg-brand-100',
]"
>
<AlertTriangle v-if="state.danger" class="w-7 h-7 text-red-500" />
<CircleAlert v-else class="w-7 h-7 text-blue-500" />
</div>
<h3 class="text-base font-semibold text-gray-900 mb-2">{{ state.title }}</h3>
<p
v-if="state.content"
class="text-sm text-gray-500 leading-relaxed whitespace-pre-wrap"
>
{{ state.content }}
</p>
<div class="mt-5 flex gap-2">
<Button
v-if="!state.alertOnly"
variant="outline"
class="flex-1 h-11 text-sm"
@click="respond(false)"
>
{{ state.cancelText || '取消' }}
</Button>
<Button
:variant="state.danger ? 'destructive' : 'default'"
class="flex-1 h-11 text-sm"
@click="respond(true)"
>
{{ state.confirmText || '确定' }}
</Button>
</div>
</div>
</AppDialog>
</template>
+41 -30
View File
@@ -50,25 +50,34 @@ interface GuideStep {
}
const steps: GuideStep[] = [
{
tab: 'profile',
title: '领取签到次数',
targets: ['[data-guide="profile-invite-redeem"]', '[data-guide="profile-invite-row"]'],
descByKind: () =>
'新用户注册即送奖励积分,邀请好友也能赚积分。\n积分满 1 后,点这里「兑换」按钮即可换成签到次数,1 积分 = 1 次签到,可重复兑换。',
},
{
tab: 'profile',
title: '先绑定设备码',
targets: ['[data-guide="profile-device-code"]'],
descByKind: () =>
'签到任务依赖设备码执行。到这一栏点「重新绑定」用手机扫码授权,或点「随机」直接生成设备码。\n注意:随机设备码与真实手机不一致,执行签到时现有手机会提示「频繁切换设备」无法签到,建议优先扫码授权。',
'签到任务依赖设备码执行。到这一栏点「去绑定 / 重绑」用手机扫码授权,或点「随机」直接生成设备码。\n注意:随机设备码与真实手机不一致,执行签到时现有手机会提示「频繁切换设备」无法签到,建议优先扫码授权。',
},
{
tab: 'profile',
title: '填写邀请码',
targets: ['[data-guide="profile-fill-invite"]', '[data-guide="profile-invite-row"]'],
descByKind: (kind) =>
kind === 'profile-fill-invite'
? '如果你有好友的邀请码,点这里填写后,你和好友都能获得奖励积分。'
: '如果你有好友的邀请码,可在「邀请同学」卡片里点「点此填写」输入,你和好友都能获得奖励积分。',
},
{
tab: 'profile',
title: '兑换积分',
targets: ['[data-guide="profile-invite-redeem"]'],
descByKind: () =>
'邀请好友可赚取积分,积分满 1 后,点这个「邀请积分」格子即可换成执行次数,1 积分 = 1 次,可重复兑换。',
},
{
tab: 'home',
title: '新建每日签到任务',
targets: ['[data-guide="home-create-task"]'],
descByKind: () => '点击蓝色的「+ 新的签到任务」按钮,选择任务名称(如 晚签到),然后填好对应签到信息并保存,任务就创建好了。',
descByKind: () => '点击蓝色的「+ 新的打卡任务」按钮,选择任务名称(如 晚签到),然后填好对应签到信息并保存,任务就创建好了。',
},
{
tab: 'home',
@@ -83,26 +92,26 @@ const steps: GuideStep[] = [
},
{
tab: 'profile',
title: '次数不够?邀请好友赚积分',
targets: ['[data-guide="profile-invite-copy"]', '[data-guide="profile-invite-generate"]'],
title: '推广赚积分',
targets: ['[data-guide="profile-invite-copy"]', '[data-guide="profile-fill-invite"]'],
descByKind: (kind) => {
const inviter = fmtReward(props.inviterRewardPerInvite)
const invitee = fmtReward(props.inviteeRewardPerInvite)
if (kind === 'profile-invite-generate') {
return '先点「生成邀请码」,生成后把链接发给同学;TA 通过你的链接注册登录后,你们都能获得奖励积分,多邀多得。'
if (kind === 'profile-fill-invite') {
return '你还没填写邀请码。点「点此填写」输入好友的邀请码,双方都能获得奖励积分。'
}
if (inviter !== null && invitee !== null) {
return `把链接发给同学,TA 通过你的链接注册登录后,你可得 ${inviter} 积分、TA 得 ${invitee} 积分,多邀多得,随时可兑换签到次数。`
return `把邀请码复制发给同学,TA 在「我的」填写后,你可得 ${inviter} 积分、TA 得 ${invitee} 积分,多邀多得,随时可兑换执行次数。`
}
return '把链接发给同学,TA 通过你的链接注册登录后,你和 TA 都能获得一定积分奖励,多邀多得,随时可兑换签到次数。'
return '把邀请码发给同学,TA 填写后你和 TA 都能获得奖励积分,多邀多得,随时可兑换执行次数。'
},
},
{
tab: 'profile',
title: 'VIP 会员:签到不限次',
title: 'VIP 会员:执行不限次',
targets: ['[data-guide="profile-vip-btn"]'],
descByKind: () =>
'普通签到次数:每执行一次任务扣 1 次,用完需要再兑换。\nVIP 会员:有效期内执行任务不限次数(无限次),不再扣签到次数。\n点「续费 VIP」输入 VIP 兑换码即可开通,到期时间自动顺延。',
'普通执行次数:每执行一次任务扣 1 次,用完需要再兑换。\nVIP 会员:有效期内执行任务不限次数(无限次),不再扣执行次数。\n点会员卡上的「开通 VIP / 续费」输入 VIP 兑换码即可开通,到期时间自动顺延。',
},
]
@@ -120,18 +129,18 @@ const resolvedKind = computed(() => {
})
const guideDesc = computed(() => {
// 第四步命中示例卡自身的执行按钮(mock 场景)时的专属文案
// 执行签到步命中示例卡自身的执行按钮(mock 场景)时的专属文案
const el = curTarget.value
if (step.value === 3 && el && resolvedKind.value === 'home-task-exec' && el.closest('.guide-root')) {
if (step.value === 4 && el && resolvedKind.value === 'home-task-exec' && el.closest('.guide-root')) {
return '点这张示例卡片右侧的「执行」按钮,即可开始今天的签到,之后每天无需再手动操作。'
}
return current.value.descByKind(resolvedKind.value)
})
// 无真实任务时(第三步/第四步):展示示例任务卡引导;
// 第四步升级命中示例卡自身的执行按钮(位于 guide-root 内)时仍属 mock 场景
// 无真实任务时(新建任务/执行签到两步):展示示例任务卡引导;
// 执行签到步升级命中示例卡自身的执行按钮(位于 guide-root 内)时仍属 mock 场景
const mockMode = computed(() => {
if (!props.show || (step.value !== 2 && step.value !== 3)) return false
if (!props.show || (step.value !== 3 && step.value !== 4)) return false
if (resolvedKind.value === 'home-task-empty') return true
const el = curTarget.value
return !!(el && resolvedKind.value === 'home-task-exec' && el.closest('.guide-root'))
@@ -317,7 +326,9 @@ async function locate() {
// 排在目标上/下方,不会停留在顶部兜底样式遮住目标
measure(el)
try {
if (moved) el.scrollIntoView({ block: 'center', inline: 'center' })
// 只做纵向居中(inline: 'nearest'):inline: 'center' 会把 overflow:hidden 的祖先
// (如头卡装饰光斑超出边界的容器)横向滚动,导致整块内容向左错位
if (moved) el.scrollIntoView({ block: 'center', inline: 'nearest' })
} catch {
// ignore(个别浏览器不支持 options)
}
@@ -528,18 +539,18 @@ const bubbleStyle = computed(() => {
<div class="flex-1 min-w-0 mr-2">
<div class="flex items-center gap-1.5">
<span class="text-sm font-medium text-gray-900 whitespace-nowrap">晚签到</span>
<span class="text-[10px] px-1.5 py-0.5 rounded-full bg-purple-100 text-purple-600 whitespace-nowrap">晚归签到</span>
<span class="text-xs px-1.5 py-0.5 rounded-full bg-purple-100 text-purple-600 whitespace-nowrap">晚归签到</span>
</div>
<div class="flex items-center gap-2 mt-1">
<span class="text-[10px] px-1.5 py-0.5 rounded-full font-medium bg-amber-100 text-amber-600 whitespace-nowrap">待签到</span>
<span class="text-[10px] text-gray-400 whitespace-nowrap">示例任务</span>
<span class="text-xs px-1.5 py-0.5 rounded-full font-medium bg-amber-100 text-amber-600 whitespace-nowrap">待签到</span>
<span class="text-xs text-gray-400 whitespace-nowrap">示例任务</span>
</div>
</div>
<button
type="button"
:data-guide="step === 3 ? 'home-task-exec' : undefined"
:data-guide="step === 4 ? 'home-task-exec' : undefined"
class="w-8 h-8 flex items-center justify-center rounded-lg bg-blue-500 text-white shadow-sm flex-shrink-0"
:class="step === 3 ? 'guide-mock-pulse' : ''"
:class="step === 4 ? 'guide-mock-pulse' : ''"
title="执行签到"
>
<Play class="w-4 h-4" />
@@ -551,7 +562,7 @@ const bubbleStyle = computed(() => {
<div ref="bubbleEl" :style="bubbleStyle" class="guide-bubble absolute z-10">
<!-- 顶部:标识 + 步骤 + 跳过 -->
<div class="flex items-center gap-2">
<span class="flex items-center gap-1 text-[10px] font-semibold text-blue-600 flex-shrink-0">
<span class="flex items-center gap-1 text-xs font-semibold text-blue-600 flex-shrink-0">
<Sparkles class="w-3 h-3" />
新手引导
</span>
@@ -574,7 +585,7 @@ const bubbleStyle = computed(() => {
<!-- 标题与说明 -->
<h3 class="text-[15px] font-bold text-gray-900 mt-2.5">{{ current.title }}</h3>
<p v-if="waitingTarget" class="text-[11px] text-blue-500 mt-1.5 font-medium">正在为你切换到目标页面…</p>
<p v-if="waitingTarget" class="text-xs text-blue-500 mt-1.5 font-medium">正在为你切换到目标页面…</p>
<p class="text-xs text-gray-500 leading-relaxed whitespace-pre-line mt-1.5 guide-desc">{{ guideDesc }}</p>
<!-- 操作按钮 -->
+267
View File
@@ -0,0 +1,267 @@
<script setup lang="ts">
// 置顶公告条(首页顶部)。
// 交互:一次展示一条,每 4 秒向上翻页切换;内容过长时先横向缓慢滚动到末尾再翻页;
// 点击整条打开底部面板查看全部置顶公告全文。页面切到后台 / 系统「减弱动态效果」时暂停轮播。
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue'
import { Megaphone, ChevronRight, X } from 'lucide-vue-next'
import AppDialog from '@/components/ui/dialog/AppDialog.vue'
import type { PinnedAnnouncement } from '@/api/announcement'
const props = defineProps<{ items: PinnedAnnouncement[] }>()
const index = ref(0)
const showAll = ref(false)
const current = computed(() => props.items[index.value] || null)
// —— 长文本横向滚动:测量溢出宽度,按 40px/s 速度滚动到末尾 ——
const viewport = ref<HTMLElement | null>(null)
const track = ref<HTMLElement | null>(null)
const overflow = ref(0)
const DWELL = 4000 // 短内容停留时长
const SCROLL_SPEED = 55 // px/s(长文本按此速度滚动,总时长再夹在 3–9 秒之间)
const reduceMotion = typeof window !== 'undefined' && window.matchMedia?.('(prefers-reduced-motion: reduce)').matches
let timer: ReturnType<typeof setTimeout> | null = null
function clearTimer() {
if (timer) clearTimeout(timer)
timer = null
}
async function measure() {
overflow.value = 0
await nextTick()
const v = viewport.value
const t = track.value
if (!v || !t) return
overflow.value = Math.max(0, Math.ceil(t.scrollWidth - v.clientWidth))
}
/** 当前条展示时长:短内容固定停留;长内容 = 起步停顿 + 滚动时长 + 末尾停顿 */
const scrollDuration = computed(() => (overflow.value > 0 ? Math.min(9, Math.max(3, overflow.value / SCROLL_SPEED)) : 0))
function schedule() {
clearTimer()
if (props.items.length < 2 && overflow.value === 0) return
if (reduceMotion || document.visibilityState !== 'visible' || showAll.value) return
const ms = overflow.value > 0 ? 1000 + scrollDuration.value * 1000 + 1200 : DWELL
timer = setTimeout(next, ms)
}
function next() {
if (props.items.length > 1) {
index.value = (index.value + 1) % props.items.length
} else {
// 只有一条且内容过长:重置后重新滚动一遍
overflow.value = 0
void measure().then(schedule)
}
}
watch(index, async () => {
await measure()
schedule()
})
watch(
() => props.items,
async () => {
if (index.value >= props.items.length) index.value = 0
await measure()
schedule()
},
{ deep: false },
)
function onVisibility() {
if (document.visibilityState === 'visible') schedule()
else clearTimer()
}
function openAll() {
showAll.value = true
clearTimer()
}
function closeAll(v: boolean) {
if (v) return
showAll.value = false
schedule()
}
function fmtDate(s: string) {
if (!s) return ''
const d = new Date(s)
if (Number.isNaN(d.getTime())) return ''
return `${d.getMonth() + 1}月${d.getDate()}日`
}
onMounted(async () => {
await measure()
schedule()
document.addEventListener('visibilitychange', onVisibility)
window.addEventListener('resize', measure)
})
onUnmounted(() => {
clearTimer()
document.removeEventListener('visibilitychange', onVisibility)
window.removeEventListener('resize', measure)
})
</script>
<template>
<button
v-if="current"
type="button"
class="press notice-card w-full flex items-center gap-2.5 rounded-2xl pl-2 pr-2.5 py-2 text-left"
:aria-label="`置顶公告:${current.title},点击查看全部`"
@click="openAll"
>
<!-- 图标:柠檬色圆底 + 轻微摆动 -->
<span class="notice-icon w-8 h-8 rounded-xl flex items-center justify-center flex-shrink-0">
<Megaphone class="w-4 h-4" :stroke-width="2.4" />
</span>
<!-- 内容视窗:上下翻页切换 + 长文本横向滚动 -->
<span ref="viewport" class="relative flex-1 min-w-0 h-5 overflow-hidden">
<Transition name="notice-flip">
<span
:key="current.id + '-' + index"
class="absolute inset-0 flex items-center"
>
<span
ref="track"
class="notice-track whitespace-nowrap text-[13px] leading-5"
:class="overflow > 0 && 'is-scrolling'"
:style="overflow > 0 ? { '--notice-shift': `-${overflow}px`, '--notice-dur': `${scrollDuration}s` } : undefined"
>
<span class="notice-title font-semibold">{{ current.title }}</span>
<span class="notice-sep mx-1.5">·</span>
<span class="notice-text">{{ current.content }}</span>
</span>
</span>
</Transition>
</span>
<!-- 计数 + 箭头 -->
<span class="flex items-center gap-0.5 flex-shrink-0">
<span v-if="items.length > 1" class="notice-count text-xs tabular-nums font-medium">{{ index + 1 }}/{{ items.length }}</span>
<ChevronRight class="notice-arrow w-4 h-4" />
</span>
</button>
<!-- 全部置顶公告(底部面板) -->
<AppDialog
:open="showAll"
overlay-class="bg-black/40"
panel-class="sm:max-w-sm max-h-[75vh] shadow-2xl"
:fade="true"
title="置顶公告"
@update:open="closeAll"
>
<div class="flex justify-center pt-2.5 pb-1 flex-shrink-0">
<div class="w-9 h-1 rounded-full bg-gray-300" />
</div>
<div class="flex items-center justify-between px-5 py-2 flex-shrink-0">
<div class="flex items-center gap-2">
<span class="notice-icon w-8 h-8 rounded-xl flex items-center justify-center">
<Megaphone class="w-4 h-4" :stroke-width="2.4" />
</span>
<span class="font-bold text-gray-900 text-base">置顶公告</span>
</div>
<button class="w-8 h-8 rounded-full hover:bg-gray-100 flex items-center justify-center" aria-label="关闭" @click="closeAll(false)">
<X class="w-4 h-4 text-gray-400" />
</button>
</div>
<div class="flex-1 overflow-y-auto px-5 pt-1 pb-6 space-y-3">
<article
v-for="(a, i) in items"
:key="a.id"
class="rounded-2xl border p-4"
:class="i === index ? 'border-amber-200 bg-amber-50' : 'border-gray-100 bg-gray-50'"
>
<div class="flex items-start justify-between gap-3 mb-1.5">
<h4 class="text-sm font-semibold text-gray-900 leading-snug">{{ a.title }}</h4>
<span v-if="fmtDate(a.createdAt)" class="text-xs text-gray-400 flex-shrink-0 mt-0.5">{{ fmtDate(a.createdAt) }}</span>
</div>
<p class="text-sm text-gray-600 leading-relaxed whitespace-pre-wrap break-words">{{ a.content }}</p>
</article>
</div>
</AppDialog>
</template>
<style scoped>
/* 卡片:柠檬暖色玻璃(亮)/ 暗金半透明(暗),与品牌薄荷色区分开,表达「提醒」语义 */
.notice-card {
background: linear-gradient(135deg, rgb(255 246 214 / 0.92), rgb(255 250 235 / 0.86));
border: 1px solid rgb(245 189 46 / 0.32);
box-shadow: 0 1px 2px rgb(138 90 0 / 0.06), 0 6px 16px rgb(227 160 8 / 0.1);
-webkit-backdrop-filter: blur(12px) saturate(160%);
backdrop-filter: blur(12px) saturate(160%);
}
.notice-icon {
color: #8a5a00;
background: linear-gradient(135deg, #ffe08a, #f5bd2e);
box-shadow: inset 0 1px 0 rgb(255 255 255 / 0.6);
animation: notice-wiggle 4s ease-in-out infinite;
transform-origin: 60% 60%;
}
.notice-title { color: #6e4700; }
.notice-sep { color: rgb(138 90 0 / 0.7); }
.notice-text { color: #8a5a00; }
.notice-count { color: rgb(138 90 0 / 0.75); }
.notice-arrow { color: rgb(138 90 0 / 0.55); }
/* 喇叭轻摆:每 4 秒摆两下,吸引注意但不吵闹 */
@keyframes notice-wiggle {
0%, 86%, 100% { transform: rotate(0); }
89% { transform: rotate(-12deg); }
92% { transform: rotate(9deg); }
95% { transform: rotate(-5deg); }
}
/* 长文本:起步停顿 1s → 匀速滚到末尾 → 停住(翻页由脚本计时) */
.notice-track.is-scrolling {
animation: notice-scroll var(--notice-dur, 6s) linear 1s forwards;
}
@keyframes notice-scroll {
to { transform: translateX(var(--notice-shift, 0)); }
}
/* 上下翻页切换 */
.notice-flip-enter-active,
.notice-flip-leave-active {
transition: transform 0.45s cubic-bezier(0.22, 1, 0.36, 1), opacity 0.35s ease;
}
.notice-flip-enter-from {
transform: translateY(100%);
opacity: 0;
}
.notice-flip-leave-to {
transform: translateY(-100%);
opacity: 0;
}
@media (prefers-reduced-motion: reduce) {
.notice-icon,
.notice-track.is-scrolling {
animation: none;
}
}
</style>
<style>
/* 暗色覆盖:放在非 scoped 块里(scoped 中的 :global(.dark) .x 会被编译成仅 `.dark`,规则失效);
用 html.dark 提高优先级,稳定压过 scoped 的 .x[data-v] */
html.dark .notice-card {
background: linear-gradient(135deg, rgb(64 51 26 / 0.85), rgb(51 42 22 / 0.8));
border-color: rgb(247 197 74 / 0.22);
box-shadow: 0 6px 18px rgb(0 0 0 / 0.3);
}
html.dark .notice-title { color: #ffe39a; }
html.dark .notice-sep { color: rgb(255 217 120 / 0.65); }
html.dark .notice-text { color: #f2d48a; }
html.dark .notice-count { color: rgb(255 217 120 / 0.7); }
html.dark .notice-arrow { color: rgb(255 217 120 / 0.5); }
</style>

Some files were not shown because too many files have changed in this diff Show More