Compare commits

...

88 Commits

Author SHA1 Message Date
becomer 6aab416125 修正清理规则中的变量写法,避免被 Woodpecker 插值为空
ci/woodpecker/tag/build Pipeline was successful
2026-10-25 18:24:41 +08:00
becomer 6ad0db6e3a 恢复镜像清理:保留最近 5 个版本并排除当前部署 tag
ci/woodpecker/tag/build Pipeline was successful
2026-10-25 18:22:25 +08:00
becomer dab1004db0 移除自动镜像清理,避免误删当前部署版本
ci/woodpecker/tag/build Pipeline was successful
2026-10-25 18:15:14 +08:00
becomer 48023f43af 镜像清理排除当前部署版本,避免与 162 推送竞争
ci/woodpecker/tag/build Pipeline failed
2026-10-25 18:13:07 +08:00
becomer dcf5b312de deploy 显式依赖 build-image,避免与构建步骤并行
ci/woodpecker/tag/build Pipeline failed
2026-10-25 18:11:18 +08:00
becomer f28e8e2aa7 本机部署改为容器级蓝绿:取消反代切换,修正网络与槽位轮换记录
ci/woodpecker/tag/build Pipeline failed
2026-10-25 18:09:36 +08:00
becomer f32a16f788 deploy-162 使用国内 apk 镜像源安装 ssh 客户端
ci/woodpecker/tag/build Pipeline failed
2026-10-25 18:05:04 +08:00
becomer 14884a0037 deploy-162 在上游步骤失败时仍执行(when.status 含 failure)
ci/woodpecker/tag/build Pipeline failed
2026-10-25 18:00:58 +08:00
becomer f3ce1428e5 调整流水线步骤命名,使 depends_on 与步骤键一致
ci/woodpecker/tag/build Pipeline failed
2026-10-25 17:59:33 +08:00
becomer 81ee9b5036 修复 Woodpecker v3 流水线语法:when 用 ref、depends_on 用步骤名 2026-10-25 17:57:11 +08:00
becomer d23189f72a deploy-162 仅依赖 build-image,避免受现有本机部署步骤影响 2026-10-25 17:49:26 +08:00
becomer e6820b3ecb CI 新增 162 蓝绿部署步骤,保留原本机部署流程不变 2026-10-25 17:49:04 +08:00
becomer c99e878328 CI 改造为跨机蓝绿部署:本机构建后 ssh 推送镜像到 162 触发部署脚本 2026-10-25 17:48:19 +08:00
becomer 048d0d6820 修复亳州学院登录:改为加密 password 走 userLogin 校验
该校 zs 主题 CAS 不校验表单明文密码,直接提交通用 CAS 表单会稳定 401
「登录失败,请检查用户名和密码!」。真实流程为:
1. 进网关跳到 auth.bzuu.edu.cn/authserver,取页面内嵌 sessionValue 作为 SESSION cookie;
2. 密码用固定 key/iv(casLoginView.js 中的 keyStr/ivStr)AES-128-CBC+ZeroPadding+base64
   加密后 POST /authserver/userLogin 校验账号密码;
3. 校验通过再提交登录表单换取 ticket,302 到 /iap/loginSuccess 建立网关会话。
实测已用真实账号拿到 CASTGC/MOD_AUTH_CAS 等 6 个 cookie 与 ST 票据。
2026-10-25 17:43:23 +08:00
becomer 00d1b8ee5c CAS 登录失败时透出学校端原始错误提示
zs 主题(如亳州学院)把错误文案放在 .warn-text 容器里,原选择器取不到,
只能回退到通用文案"登录失败,请检查账号密码"。补充 .warn-text 等选择器后,
可直接把学校端原文(如"登录失败,请检查用户名和密码!")透出给用户。
2026-10-25 17:35:36 +08:00
becomer d43d3cfb46 新增蓝绿部署脚本
空闲槽部署 -> 健康检查 -> 切 Nginx -> 公网验证 -> 失败自动回滚。
2026-10-25 17:32:28 +08:00
becomer 9c97fe770d 适配亳州学院:改走学校 CAS 登录
平台配置为 joinType=CLOUD,但网关 /iap/login 实际 302 到学校统一身份认证
(auth.bzuu.edu.cn/authserver),IAP 账密接口要求验证码不可用。新增 bzuuCasLogin
强制走通用 CAS 流程;同时修正 CAS 表单解析:页面同时存在移动端账密/短信/PC 三个
表单,原先会把 phone、valiCode、key 等短信表单字段混入提交参数,现改为按纯度评分
选取纯账密表单。
2026-10-25 17:32:28 +08:00
becomer 566f460e05 修复短信验证码达当日上限时误报发送成功
上游在手机号当日验证次数耗尽后仍返回 errCode=0,但 status=2、
tipMsg="验证次数已达上限,将于次日清零"、countdown 仅 10 秒,短信实际不下发。
此前只校验 errCode,导致前端提示已发送而用户收不到。改为校验 status,
把上游 tipMsg 透出为错误信息。
2026-10-25 17:32:26 +08:00
becomer 040b1aef18 修复短信验证码重复发送提示成功却收不到
今日校园 messageCode 对同一手机号有 60 秒服务端冷却,冷却期内重复请求
仍返回 errCode=0 但短信不会真正下发,而本地此前只有 IP 令牌桶(5 分钟
10 次、每 30 秒回补),拦不住同号码快速重发。

改为在调用上游前用 Redis 按手机号做 60 秒原子冷却(SETNX),命中直接
返回 429 与剩余秒数;上游发送失败时释放冷却允许立即重试;成功响应把
countdown 抬到至少 60 秒,避免前端过早放开重发按钮。
2026-10-25 14:48:55 +08:00
becomer a5bb9843c0 修复学校域名缺协议导致短信登录换取 CAS cookie 失败
部分学校(如 jatc.campusphere.net)的学校详情返回裸域名,直接拼接
/wec-im-group/... 会被 net/http 拒绝(unsupported protocol scheme "")。
在 GetSchoolDetail 统一为缺协议地址补 https://,并在短信登录的换取与
续期入口做防御性规范化。

同时合入限流增强、并发锁及工作日志相关改动。
2026-10-25 14:32:42 +08:00
becomer 19d8c392c3 今日校园客户端版本号改为 9.9.26
版本号原先以字面量散落在 6 个文件的 13 处:UA 尾部、Cpdaily-Extension 明文
JSON 的 appVersion/cpdailyVersion、submitSign 请求体的 appVersion 与
systemVersion。改版本需要逐处搜索替换,容易漏改导致前后不一致
(UA 说一个版本、加密体说另一个版本)。

集中到 cpdaily/version.go 的 AppVersion / AndroidVersion / UserAgent 常量,
所有调用点改为引用;测试里的版本入参也一并改用常量。
2026-10-24 22:29:20 +08:00
becomer 93df73a179 工作日志签到失败时记录学校端完整响应
SubmitWorklogSign 原先只解析 message、不打日志,用户报「签到失败: 请提交
正确的信息收集信息」时无法看到学校端返回码与原始 body,排查只能靠猜。

改为同时解析 code 并记录 code/message/body,与普通打卡的 submitSign 日志格式一致;
解析失败时也带上 body 片段。

注:该错误本身的根因尚未定位。已确认 detail/update 端点会针对参数给出具体
文案(如「工作日志wid不能为空」「模板wid不能为空」),而 submitSign 对任意
body(含 {} 与乱码)恒返回同一句话,说明它接收参数的形状与目前实现不同,
需要真实前端请求才能确定。
2026-10-24 21:42:49 +08:00
becomer e7e8b86b42 工单增加双向未读与用户打回
工单是用户与管理员之间的对话,原先只有「通知是否已读」单向记录,
管理员无法知道用户有没有看到自己的回复,用户也没法把误判为已完成的
工单退回处理。

tickets 表新增两个独立布尔字段:
  user_read  —— 管理员最近一次操作后用户是否已读
  admin_read —— 用户最近一次回复后管理员是否已读

状态迁移:
  管理员回复/改状态 → user_read=false、admin_read=true(自己动的不算未读)
  用户回复          → admin_read=false、user_read 不变
  打开详情          → 各自一侧置真

用户回复 resolved 工单会直接打回 processing(原先是退回 pending),
并新增 PUT /ticket/:id/reopen 允许用户显式打回;仅 resolved 可打回,
closed 是管理员终结状态、pending 本就待处理。

前端:管理端列表显示「用户未读」徽标与用户新回复红点,详情显示用户已读状态;
用户端详情在 resolved 时给出打回入口;工单强制弹窗此前只对 kind=reply 生效,
现在状态变更同样弹窗,并在路由切换时补查一次。

新增 9 项契约测试覆盖上述状态机与打回边界(需 TICKET_IT_DSN 指向一次性库)。
2026-10-24 11:22:21 +08:00
becomer 30baaa6507 修复打卡页次数不足弹窗不显示
651739a 把次数不足弹窗抽成公共组件 NoRunsDialog 时,只往 HomeView 模板
加了 <NoRunsDialog> 标签,漏了 import。script setup 下未导入的组件会被
当作原生自定义元素,编译不报错、运行时静默不渲染,于是用户点执行后
只看到「正在连接...」一闪而过,既不弹次数不足提示也没有任何错误。

请假页随后同样接入该组件时带了 import,所以只有打卡页受影响。
补上 import 即可,模板与 onError 分支逻辑本身没有问题。
2026-10-21 21:25:43 +08:00
becomer 64ccac1042 请假页复用打卡页的公告强制弹窗
强制弹窗加载原先硬编码 route.name === 'home',请假页只能看到置顶轮播条,
后台标记为强制弹出的公告在请假页不会出现。改为按 POPUP_ROUTES 集合判断,
home 与 leave 一致,其余页面维持不主动加载以免进入任意页就被打扰。

同时补充销假前置校验的端到端测试,并调整个人中心绿色起始线到购买 CDK 卡片中线。
2026-10-21 18:29:08 +08:00
becomer e5b57203fb 置顶公告:回到前台时重新测量文本宽度
ci/woodpecker/tag/build Pipeline was successful
onVisibility 原先只调 schedule(),不重新测量。后台期间若发生尺寸变化
(横竖屏切换、系统字体缩放、页面布局重排),overflow 仍是旧值,
长文本的滚动距离会算错,表现为「还没滚完就翻页」或「滚过头留白」,
而 measure() 本身只在挂载和 resize 时执行。

改为回到前台先 measure() 再 schedule(),与 resize 处理保持一致。

审查同类边界,以下均已正确处理,未改动:
- items 为空:模板用 v-if="current" 不渲染
- 只有 1 条且不溢出:schedule 直接 return,不空转定时器
- items 长度变化:watch 中把越界索引归零
- 滚动时长:Math.min(9, Math.max(3, ...)) 有上下限,
  不会出现 NaN/Infinity 导致 setTimeout 异常

npm run build 与 vue-tsc 均通过。
2026-10-21 17:35:28 +08:00
becomer a51501909a 绑定邀请码增加形态预校验,并兼容 6 位存量码
ci/woodpecker/tag/build Pipeline was successful
1. 预校验(避免无用查库)
   原先任何非空字符串都会直接拿去查库。邀请码固定长度且只用
   inviteCodeChars(去掉 0/O/1/L 后的 31 个字符),不合规的输入
   可以无需查库就拒绝;用统一文案「邀请码无效」,不泄露额外信息。

2. 存量兼容(关键,差点造成线上事故)
   最初把校验写成「必须 8 位」。查生产库发现:
       6 位:551 个用户
       8 位:1 个用户
   即绝大多数用户的邀请码仍是 6 位旧码。若只接受 8 位,
   这 551 人的码会被判无效,他们将无法被新用户填写邀请——
   影响面很大。已改为同时接受 6 位与 8 位,字符集校验不变。

测试覆盖:8 位正常、6 位存量、5/7 位、空串、含易混淆字符
(0/O/1/I/L)、含小写、含符号、含中文。
2026-10-21 17:33:41 +08:00
becomer 672214c06e 重置邀请码:补 409 并发冲突自动刷新与空码保护
ci/woodpecker/tag/build Pipeline was successful
自审发现两个未处理的边界,都属于「提示了但页面状态不对」:

1. 并发冲突(409)
   两个请求同时重置时,后端乐观锁会让其中一个返回 409。
   原先只弹错误提示,页面仍显示旧码——用户以为没重置成功,
   但服务端实际已是新码,导致分享出去的是失效的码。
   现在收到 409 会自动重新拉取邀请信息并刷新页面状态。

2. 接口成功但未返回新码
   原先 `if (code) inviteCode.value = code` 在 code 为空时静默跳过,
   却照样提示「邀请码已重置」,页面继续显示旧码。
   改为明确报错并 return,不谎报成功。

npm run build 与 vue-tsc 均通过。
2026-10-21 17:32:28 +08:00
becomer 4cc36658c0 修正头图绿色背景高度:pb-16 → pb-22,让渐变切到会员卡一半
ci/woodpecker/tag/build Pipeline was successful
之前一直调不对,根因是我把方向搞反了。

重新用构建产物里的真实数值推算(--spacing = .25rem = 4px):
  绿色切入卡片的深度 = pb - |卡片上浮| = pb - 44px
  pb-16 时只有 64 - 44 = 20px,约占卡片高(86px)的 23%,
  看起来就是「绿色太矮、远没到一半」。
  要切到一半(43px),需 pb ≈ 43 + 44 = 87px → pb-22(88px)。

之前的错误在于:几次都想通过减小 pb 或调整 -mt 来「对齐按钮中线」,
但两者会同步移动、互相抵消,所以怎么调都是 23%。真正要改的是
pb 的绝对值,让绿色背景整体变高。

改动:pb-16 → pb-22(64px → 88px),-mt-11 保持不变。

已用真实构建产物 CSS(dist/assets/*.css)渲染验证:
  切入深度 44px、占卡片 51.2%、与按钮中线差 1px,
  并截图确认绿色正确覆盖到头像与姓名的下半部分。
2026-10-21 17:30:34 +08:00
becomer c75e55c6e7 VIP 徽标改到姓名右侧,头图高度按实测重新校准
ci/woodpecker/tag/build Pipeline was successful
1. VIP 徽标位置
   原先绝对定位贴在头像右下角,会盖住头像边缘。
   改为放在姓名右侧同一行:姓名 span 用 truncate、徽标 shrink-0,
   长姓名被截断时徽标仍完整显示,不会被挤压。

2. 头图(顶部绿色背景)高度
   按需求应为「达到购买按钮一半的位置,上方全是背景」。
   基于真实结构(hero 内无可见内容,仅绝对定位装饰)重建等价 DOM 实测:
     空 hero + pb-16 + 卡片 -mt-11
     → hero 底部 Y=78、按钮中线 Y=78,差值 0px,切入卡片 50.0%
   即渐变边界正好落在「兑换 CDK」按钮中线,符合要求,故维持
   pb-16 / -mt-11 不变(上一版实测同样为 50.0%,本次复核确认)。

已截图确认徽标与姓名同行渲染正常(长姓名截断、徽标完整)。
npm run build 与 vue-tsc 均通过。
2026-10-21 17:25:49 +08:00
becomer 315942cfce 修正:仅 VIP 用黑金卡,普通用户恢复珍珠白
ci/woodpecker/tag/build Pipeline was successful
上一版误解了需求——把两种身份的会员卡都改成了 VIP 黑金色,
实际应只有 VIP 使用该专属色,普通用户保持原有浅色卡片。

改动:
- 恢复 .member-card.is-vip(黑金 + 金色描边 + 金色文字/图标/光晕)
  与 .member-card.is-free(珍珠白 + 深色文字)双形态
- 模板恢复 :class="isVip ? 'is-vip' : 'is-free'"
- 暗色主题恢复 is-free 的墨绿底(白底在暗色下刺眼)
- 头像外圈与底色按身份区分:VIP 白环 + 半透明白底 + 金棕首字,
  普通用户淡品牌色环 + 浅品牌底 + 品牌绿首字
- 「兑换 CDK」按钮保持金色实底,在两种卡片上都有足够对比

保留上一版正确的文案改动:普通用户显示「开通 VIP 即可不限次数」。

已截图确认两种卡片渲染正确(黑金 / 珍珠白 分明)。
npm run build 与 vue-tsc 均通过。
2026-10-21 17:17:13 +08:00
becomer fd214d06eb 清理会员卡的 is-vip / is-free 空壳类
ci/woodpecker/tag/build Pipeline was successful
上一提交把两种身份的卡片样式合并为同一套后,
:class="isVip ? 'is-vip' : 'is-free'" 已无任何样式差异,
三个选择器(.member-card / .is-vip / .is-free)指向同一份声明,
属于纯冗余。

改动:
- 模板移除该动态 class
- 浅色样式块精简为单个 .member-card 选择器
- 暗色块同理,只保留 html.dark .member-card

已重新截图确认渲染与清理前完全一致,删除的确为死代码。
npm run build 与 vue-tsc 均通过。
2026-10-21 17:13:31 +08:00
becomer 48c3948c56 我的页面会员卡统一为 VIP 黑金色,普通用户提示改为引导开通
ci/woodpecker/tag/build Pipeline was successful
1. 文案
   普通用户副标题由「免费用户」改为「开通 VIP 即可不限次数」,
   直接传达价值;VIP 分支保持显示到期时间与剩余天数。

2. 卡片配色统一
   原先普通用户是珍珠白卡片、VIP 才是黑金,两套样式(.is-vip/.is-free)。
   现统一为 VIP 专属黑金色,两种身份同一套质感,区别只在文案与角标:
   - .member-card / .is-vip / .is-free 共用同一段背景与边框
   - 文字、图标、光晕规则去掉 .is-vip 前缀,直接作用于 .member-card
   - 暗色主题同步合并,移除原先 is-free 的墨绿覆盖(会破坏金色统一)

3. 连带调整
   - 头像占位文字统一为金棕 text-amber-800(原普通用户用品牌绿,
     在黑金底上不协调)
   - 「兑换 CDK」按钮加深阴影,与深色卡片底更好分离

已用无头 Chrome 截图确认实际渲染:黑金卡片、金色文字、金色按钮
层次清晰,文字无遮挡。

npm run build 与 vue-tsc 均通过。
2026-10-21 17:12:20 +08:00
becomer 0d3280fe7b 我的页面:精简普通用户提示文案 + 头图边界对齐按钮中线
ci/woodpecker/tag/build Pipeline was successful
1. 文案过长显示不全
   普通用户的副标题原为「普通用户 · 开通后执行任务不限次数」,
   在卡片的 flex-1 区域内被 truncate 截断。
   改为「免费用户」,与上方姓名行组合读作「张三 / 免费用户」,
   既完整显示又不丢信息(VIP 分支同步去掉冗余的 "VIP ·" 前缀,
   因为 VIP 身份已由姓名旁的角标体现)。

2. 头图绿色渐变边界位置
   原先 -mt-14 时,头图底色边界落在会员卡约 63.6% 高度处,
   低于「兑换 CDK」按钮中线 12px,看起来切在卡片中上部。
   改为 -mt-11 后边界正好对齐按钮中线。

   该结论来自无头 Chrome 实测而非估算:构造等价 DOM 后测量
   heroBottom / btnCenter 差值,-mt-14 → +12px(偏低),
   -mt-11 → 0px(对齐)。页面上写死了这次实测数据作为注释。

   另经实测确认头图的 pb 只影响上方留白、不影响对齐
   (pb-14 与 pb-16 均对齐),故保留 pb-16。
   卡片内边距同步由 p-4 收窄为 px-4 py-3,让三行文字更紧凑。

npm run build 与 vue-tsc 均通过。
2026-10-21 17:07:13 +08:00
becomer 0d639ea485 我的页面:头像姓名并入会员卡,去掉学校信息
ci/woodpecker/tag/build Pipeline was successful
原先顶部渐变区单独一块「大头像 + 姓名 + 学校 + 学院专业标签」,
对用户实际没什么用,还占掉一屏顶部空间。

改动:
- 删除顶部个人信息块(含学校名与学院/专业/班级标签)
- 头像缩为 44px 圆形,移到会员卡内 Crown 图标的位置(该图标去掉),
  姓名与会员身份(VIP/普通用户)合并为同一行
- VIP 角标改为贴在头像右下角
- 顶部渐变条高度压缩:padding-top 28px→14px、pb-20→pb-16,
  会员卡仍以 -mt-14 上浮叠压,视觉层次不变

顺带清理不再使用的导入与计算属性:Crown 图标、GraduationCap 图标、
profileTags(学院/专业/班级标签)。
修掉 img 的 :src 缺可选链导致的 null 类型报错。

npm run build 与 vue-tsc 均通过。
2026-10-21 16:58:24 +08:00
becomer 3b4a8df8fd 兑换弹窗购买按钮去掉图标并缩小文字
ci/woodpecker/tag/build Pipeline was successful
按钮在弹窗内与「立即兑换」等宽,加上图标后文字被挤得偏紧。
按需求去掉 ShoppingCart 图标(图标导入仍被页面其他两处使用,保留),
文字由 text-sm font-semibold 调为 text-xs font-medium,
并加 px-2 避免文字贴边,整体更舒展。

npm run build 与 vue-tsc 均通过。
2026-10-21 16:45:00 +08:00
becomer 5a33afa213 修复兑换弹窗购买按钮不可见,并调整左右顺序
ci/woodpecker/tag/build Pipeline was successful
两个问题:

1. 按钮完全看不见(严重)
   上一版给「没有 CDK?立即购买」用了 cdk-goto-btn 类,但该类只在
   NoRunsDialog.vue 的 scoped 样式里定义过——scoped 样式带 data-v 属性
   选择器,不会作用于 ProfileView 内的元素。结果按钮没有任何背景,
   而文字是 text-white,白字落在弹窗白底上等于隐形(点击仍生效)。
   现在在 ProfileView 的样式块内补齐 .cdk-goto-btn 定义:
   珊瑚渐变背景 + 白色文字 + 阴影 + nowrap,与外层购买入口视觉呼应。

2. 左右顺序反了
   按需求改为「没有 CDK?立即购买」在左、「立即兑换」在右。

npm run build 与 vue-tsc 均通过。
2026-10-21 16:41:41 +08:00
becomer eaafc50739 兑换 CDK 弹窗内新增「没有 CDK?立即购买」入口
ci/woodpecker/tag/build Pipeline was successful
原先兑换弹窗里只有「立即兑换」,没有 CDK 的用户只能关掉弹窗
再去找外层购买入口,路径绕。

改为并排两个动作:
- 立即兑换(原有,flex-1)
- 没有 CDK?立即购买(flex-1,沿用外层购买的珊瑚渐变样式)

新按钮等同于点击外层「购买 CDK 兑换码」入口:先关闭兑换弹窗,
再调用同一个 onCdkBuyClick()(弹流程说明 → 确认后跳购买平台),
避免两个弹窗叠加;购买入口未开放时提示「请联系管理员」。

npm run build 与 vue-tsc 均通过。
2026-10-21 16:37:32 +08:00
becomer 44f93a0ae9 sdau sec_js 解析增加密钥形态校验
ci/woodpecker/tag/build Pipeline was successful
xsgzFetchRSA 此前只判断 DK/M/E 是否非空。若 /api/sec_js 响应被截断
或格式变化,正则可能匹配到残缺值,用它加密出的密文服务端必然解不开,
最终表现为「参数解码错误,刷新页面后尝试」,难以定位真因。

新增形态校验,提前拦住并把错误指向真实原因:
- DK 盐长度 < 16 → 报「DK 盐长度异常(len=N),sec_js 响应可能不完整」
- 模数 hex 长度 < 64 → 报「模数长度异常」
- 指数非 010001/10001 → 报「指数形态异常」

阈值取保守下限(真实响应对应 32 / 258 / "010001"),
已用线上真实 /api/sec_js 验证不会误判。
测试覆盖正常与四类残缺情形。
2026-10-21 16:29:38 +08:00
becomer 8926c0b109 邀请码改为 8 位 + 支持用户重置
1. 增至 8 位(backed/internal/handler/invite_code.go)
   字符集 31 个不变(已去掉易混淆的 0/O/1/L),
   空间从 31^6≈8.9 亿提升到 31^8≈8.5 万亿。

2. 新增重置接口 POST /api/auth/reset-invite
   - resetInviteCode():复用 withUniqueInviteCode,唯一性由
     invite_code 唯一索引 + 冲突重试保证(与首次生成同一套逻辑,
     不做「先查后写」,并发下依然正确)
   - 乐观锁:仅当库中旧码仍是本次读到的值时才替换,
     避免两个并发重置互相覆盖导致其中一个返回的码实际已失效;
     冲突时返回 409 提示刷新重试
   - 用户当前无码时先补齐再重置,否则乐观锁条件会失效
   - 记录 oldCode/newCode 便于排查

3. 前端(ProfileView)
   - 邀请码行内新增重置按钮(环形箭头图标,重置中转圈)
   - 点击先弹确认框,用 danger 样式并明确告知影响:
     「原来的邀请码会立即失效,别人再用旧码填写将无法绑定;
       已经通过旧码完成的邀请关系不受影响,积分也不会被收回」
   - 成功后直接更新页面上的邀请码并提示

测试:8 位长度与字符集校验、5000 次生成无重复、
重置生成的新码格式合规、两个哨兵错误互不混淆。
2026-10-21 16:29:31 +08:00
becomer 320f21442c 填写邀请码按钮文案改为展示实际可得积分
ci/woodpecker/tag/build Pipeline was successful
原文案「有同学的邀请码?点此填写」只说明能填,没告诉用户填了能得什么。

改为「输入同学邀请码,立即获得 X 积分」,其中 X 复用
inviteeRewardPerInvite —— 与上方推广板块「TA 得 X 积分」同源,
保证两处展示的被邀请方积分始终一致;接口未返回该值时降级为
「输入同学邀请码,立即获得积分」,文案仍通顺。

npm run build 与 vue-tsc 均通过。
2026-10-21 16:23:55 +08:00
becomer 770b1754b4 我的页面:次数并入会员卡、兑换 CDK 统一、邀请积分并入推广板块
ci/woodpecker/tag/build Pipeline was successful
1. 可执行次数并入会员卡那一行
   - 普通用户:显示「可执行次数 N」,右侧按钮「兑换 CDK」
   - VIP 用户:仍显示「VIP 会员 + 有效期/剩余天数」,
     下方额外显示「可执行次数 不限」

2. 兑换入口统一
   - 原「开通 VIP / 续费」按钮改为「兑换 CDK」,既能兑 VIP 也能兑次数
   - 后端本就是同一接口:handleRedeem 依据响应 data.type
     ('vip' / 次数)分别处理,故后端无需改动
   - 兑换弹窗标题/标签统一为「兑换 CDK」「CDK 兑换码」,
     移除 redeemKind 状态(已无分支)

3. 删除下方数据三宫格
   - 不再单独显示「可执行次数」格子(已并入会员卡)
   - 删除「已邀请 N 位同学」格子

4. 邀请积分并入推广板块
   - 在邀请卡内展示「邀请积分 N」+「免费换次数」按钮
   - 原「换次数」文案改为「免费换次数」
   - 积分不足时按钮禁用并显示「满 1 可换」
   - 兑换弹窗内的引导文案同步指向新位置

顺带清理:移除随三宫格一起不再使用的 Users 图标导入。

npm run build 与 vue-tsc 均通过。
2026-10-21 16:15:59 +08:00
becomer 651739af1b 次数不足弹窗统一为专用组件,底部改为「邀请好友 / 购买 CDK」
ci/woodpecker/tag/build Pipeline was successful
原先只有打卡页有专用的「签到次数不足」弹窗,请销假页只有一条
一闪而过的红色 Toast;而且提示让用户「购买 CDK」,但请销假页内
没有任何购买或兑换入口,用户看不懂也无处可去。

新增 components/NoRunsDialog.vue(打卡/请假/续假/销假共用):
- 标题按场景传入(签到/请假/续假/销假次数不足)
- 说明两条获取途径:邀请新用户、购买 CDK
- 底部两个动作按钮:
  · 立即邀请好友 → 跳 /profile?intent=invite,进页后自动复制邀请码并提示
  · 立即购买 CDK → 跳 /profile?intent=buy,进页后自动触发 CDK 购买入口
    (弹出流程说明弹窗,确认后跳购买平台)

ProfileView 新增 intent 处理(onMounted 末尾):
- intent=invite:调用已有 copyInviteCode()
- intent=buy:CDK 入口可见时调用 onCdkBuyClick() 弹说明;
  不可见时提示「购买入口暂未开放」
- 处理完用 router.replace 清掉 query,避免刷新/返回时重复触发

LeaveView 两处 onError 接入 handleNoRunsError():命中次数不足时
不再弹红色 Toast(避免误导为系统错误),改弹专用窗;请假/续假
按 submittingExtend 区分标题。

HomeView 移除内联弹窗,改用共用组件。

npm run build 与 vue-tsc 均通过(仅剩改动前已有的 Gift 未使用告警)。
2026-10-21 16:10:05 +08:00
becomer 608dee697f 置顶公告视觉增强 + 请假销假页接入公告条
ci/woodpecker/tag/build Pipeline was successful
1. 公告条美化(PinnedNotice.vue)
   - 增加左侧琥珀色渐变强调条,提升顶部存在感
   - 增加 hover 态阴影过渡,点击反馈更明确
   - 卡片背景改为更通透的柠檬暖色玻璃

2. 请假/销假页接入置顶公告(LeaveView.vue)
   - 引入 PinnedNotice 与 announceApi
   - onMounted 中异步拉取置顶公告,失败静默降级为空列表
     (不阻塞页面主体加载)
   - 公告条置于页面最顶部,与打卡页位置和交互一致
     (翻页、长文本横向滚动、点击查看全部)

npm run build 与 vue-tsc 均通过。
2026-10-21 13:26:22 +08:00
becomer fab756a1ef 购买 CDK 入口更醒目 + 跳转前弹流程说明
ci/woodpecker/tag/build Pipeline was successful
入口视觉强化:
- 从 <a> 改为 <button>(点击先进说明弹窗,由弹窗确认后才跳转)
- 内边距 p-4→py-5、图标 11→12、标题 15px→base/extrabold、箭头 8→9
- 增加呼吸光环动画 cdk-pulse(prefers-reduced-motion 下自动关闭)

跳转前说明弹窗(新增 showCdkBuyConfirm):
按用户要求提示完整流程——
  1. 在购买平台购买卡密,需填写并牢记「邮箱」与「查询密码」
  2. 支付成功后,用同样的邮箱与查询密码在平台查询 CDK
  3. 回到本站,在「兑换次数 / VIP」处粘贴 CDK 兑换
并提示购买平台为独立站点、兑换码勿泄露。

跳转方式:确认后在 DOM 中创建临时 <a target=_blank> 并 click(),
而非 window.open —— 手机浏览器会把程序化 window.open 当弹窗强制
当前页打开,原生链接行为更稳定。
2026-10-21 13:10:53 +08:00
becomer b503f44a2d 更新 hfnu 分发处的注释:IAP 路径实测不可行,应走短信登录
原注释声称「wecloud 网关支持标准 IAP 账密登录,单独走 IAP」,
与实际不符。实测:

  GET hfnu.campusphere.net/iap/login
  → HTTP 302 → ehall.hfnu.edu.cn/amp-auth-adapter/login?service=...

即 /iap/login 不给登录表单,直接跳 ehall 单点,导致 iapLogin 第一步
getIapLT(解析 lt 表单)必然失败——与 CAS 是否授权无关。

结合 10-19 抓包分析:ehall 侧登录只产出 ehall 域 cookie
(JSESSIONID/AUTHTGC/MOD_AMP_AUTH/asessionid),无法换到
campusphere 的 MOD_AUTH_CAS;校方 CAS 也拒绝为 campusphere 域签发票据。

本次仅更正注释说明真实情况与建议路径(短信登录),行为未变。
2026-10-19 22:36:50 +08:00
becomer adb8bb7560 hfnu 登录入口改选中平台配置的 amp-auth-adapter
ci/woodpecker/tag/build Pipeline was successful
平台给 hfnu 的配置(来源:cpdaily-api 的 tenant_list.json,与线上
GetSchoolDetail 实测一致):
  idsUrl  = https://ehall.hfnu.edu.cn/amp-auth-adapter
  appId   = d7de360368c94a808225
  joinType= NOTCLOUD

而通用函数 getCasValidLoginUrl 的判断只认 campusphere / wec-portal-mobile,
遇到 amp-auth-adapter 不匹配,于是回落到 WecloudUrl。实测差异:
  通用函数 → https://hfnu.campusphere.net/wec-portal-mobile/client
  正确入口 → https://ehall.hfnu.edu.cn/amp-auth-adapter

因为 hfnuLogin 直接复用了通用函数,等于一直在错的入口上做登录,
这也是此前"登录页解析到了、但流程走不通"的一个成因。

改动:
- 新增 hfnuLoginEntry:优先取 idsUrl 中带 amp-auth-adapter 的值,
  否则回落通用逻辑(不影响其他学校)
- hfnuLogin 改用该函数
- 测试覆盖:hfnu 必须选中 amp-auth-adapter;campusphere 学校与
  无 idsUrl 的学校仍走通用逻辑;nil 安全

说明:该校登录仍会卡在校方 CAS 拒绝签发 service 票据,
本次仅修正入口选择这一确定性缺陷。
2026-10-19 19:54:46 +08:00
becomer fc90e21920 hfnu 登录补齐缺失步骤:验证码预校验、密码 base64、lt 固定值
ci/woodpecker/tag/build Pipeline was successful
用真实待适配账号(2606221072)测试发现,原实现虽已实现验证码 OCR,
但提交方式与页面 JS 的真实流程不符,导致服务端静默退回登录页
且不给任何错误提示——正确密码与错误密码表现完全一致,无从排查。

从登录页 JS 中发现真实流程是三段式:
  1. AJAX POST /sso/code/validationCode.jsp {validateCode}
     仅当返回 {"success":true} 才继续提交;
  2. 密码经 base64 编码后填入 password 字段(base.encode(password));
  3. lt 替换为页面固定值 keys="e1s1",而非登录页上的 lt。

改动:
- 新增第 1 步:带 ;jsessionid 与 X-Requested-With 调用 validationCode.jsp,
  失败即明确提示"验证码识别结果不被接受",不再发出注定失败的提交
- 密码改为 base64 编码提交
- lt 改用固定常量 hfnuLtKey="e1s1"
- 验证码 OCR 结果只保留数字并截取前 4 位(该校为 4 位纯数字;
  实测 OCR 偶发返回 5 位,会被 validationCode.jsp 判为错误)
- 登录失败时记录响应体片段,便于后续排查

验证效果:修复后正确密码与错误密码可被明确区分——
正确密码认证通过并返回 COMSYSUIATGC=TGT-...,错误密码返回
"用户名或密码输入错误"。

遗留:认证通过后仍卡在校方 amp-auth-adapter 单点跳转拒绝
("你不具有该URL的访问权限,系统拒绝单点登录跳转")。
已实测 10+ 个候选 service:仅 http://ehall.hfnu.edu.cn/amp-auth-adapter/loginSuccess
为"已注册"状态,其余(含 https 版本、campusphere 各入口)均为"未注册";
换另一账号(2510421079)结果相同,判断为校方侧全局限制,
非本服务代码可解决。
2026-10-19 18:53:21 +08:00
becomer 7cbbbff3ea 按签到 H5 真实逻辑补齐时间段与更新签到判定
ci/woodpecker/tag/build Pipeline was successful
从线上签到 H5 拿到源码(counselor-front.campusphere.net/...
wec-counselor-sign-apps/prod/prod_v2.1.1.1357_4DnD/stu/mobile/js/app.js),
按其中实现重写判定,不再依赖推测。

H5 关键实现:
  xt = { sign: 1, noSign: 2, leave: 5 }        // signStatus 枚举
  Rt = { noStart, running, finished }          // 任务阶段
  $t(): isLoop 时把 signDate.substring(0,10) 与时刻拼接,否则用时刻本身
  Gt(): current > end → finished
        current <= begin → noStart
        否则 → running
  handlerButtonClick: if (signed && !isAllowUpdate) toast("更新签到开关已关闭")
  updateSign: !updateLimit || leftNum 才允许提交,否则提示"已超出最大更新次数"

对补签的结论:签到 H5 全量 JS 中不存在 supply / 补签 字样。
所谓"补签"在 H5 里就是"更新签到"——已签到后再次提交,
受 isAllowUpdate 与 updateLimit/leftNum 控制。
查寝详情另有 supplyTime 字段(实测为空串),但签到 H5 未使用它。

改动:
- SignWindow/ParseSignWindow:singleTask*(查寝,完整日期时间)优先,
  否则用 rateSignDate + rateTaskBeginTime/EndTime(sign、return_register);
  解析失败一律 Parsed=false 放行,避免时间格式差异误杀
- EvalTaskPhase:复现 Gt(),含"等于开始时刻算 noStart"的边界
- SignUpdateBlocked:已签到且 isAllowUpdate=false → 拦截;
  updateLimit=true 且 leftNum<=0 → 拦截(次数用尽)
- handler 在执行前先查"已签到不允许更新",再查时间段,
  两者拦截时都写 fail 状态、记日志、发 task_error 事件且不扣次数
- 时间字段实测:sign/return_register 用 rateSignDate+时刻,
  sleep_check 用 singleTaskBeginTime/EndTime 完整时间

测试覆盖:两种时间格式解析、时段内/外/边界、信息缺失不拦截、
signStatus 枚举各值、次数限制、EvalTaskPhase 三态与边界。
2026-10-19 11:40:17 +08:00
becomer 154670742c 执行打卡前校验时间段,不在窗口内直接返回明确提示
此前完全没有时间段判断:用户在非打卡时间点执行,会把注定被学校端
拒绝的请求发出去,白扣次数且报错含义不清。

用真实用户(学号 2364400108)拉 detailSignInstance 确认字段:
  rateSignDate      = "2026-10-25 (周日)"
  rateTaskBeginTime = "18:30"
  rateTaskEndTime   = "23:00"
  singleTaskBeginTime/EndTime  单次任务的时间段
  currentTime       = "2026-10-19 11:31:21"
该任务实际打卡时段是 10-25 18:30~23:00,而当时是 10-19,明显时段外。

新增(internal/service/cpdaily/task.go):
- SignWindow + ParseSignWindow:解析上述字段,singleTask* 存在时优先用它;
  解析失败一律 Parsed=false,调用方放行,避免因时间格式差异误杀
- CheckSignWindow:明确在窗口外才返回错误;起止时刻本身算在窗口内
- ErrOutsideSignWindow + signWindowError:可 errors.Is 识别,
  但 Error() 只输出用户可读文案,不暴露内部标记

接入 handler(执行打卡流程):取到详情后、填充表单前校验;
拦截时写任务状态为 fail、记录日志、发 task_error 事件并带 outsideWindow 标记,
不扣减次数。

测试用实测真实字段值:解析正确性、时段外拦截、时段内放行、
边界(开始/结束时刻放行、结束后一分钟拦截)、信息缺失不拦截、
singleTask* 优先级。
2026-10-19 11:32:40 +08:00
becomer b40e95d6c3 同步编辑表单图片块的过时注释(点击直接替换 → 缩略图+编辑按钮)
ci/woodpecker/tag/build Pipeline was successful
2026-10-18 22:53:46 +08:00
becomer 1ffe68eb72 打卡表单图片加「编辑」按钮,点击重新上传
改动前:整个缩略图区域是 @click="handleImageUpload" 的上传热区,
视觉上没有任何按钮,用户不知道点图片会发生什么(部分浏览器/WebView
点击 <img> 还会触发自身原生看图,进一步造成「点了是预览」的错觉)。

改为三态清晰渲染:
  1. 上传中 → 显示 loading(优先于缩略图,替换时给出明确反馈)
  2. 已有图片 → 缩略图 + 右下角「编辑」按钮(铅笔图标),点按钮才重新上传;
     图片本身不再是热区,避免误触
  3. 无图片 → 虚线「上传」框(按钮语义)

两套表单(新建打卡 / 编辑表单)同步修改。
调整了 v-if 链顺序:原先缩略图判断在前,导致上传中状态在有图时永不显示。

说明:本次未定位用户所见「点图片是全屏预览」的确切来源——
代码中不存在任何预览组件,HomeView 的图片仅有上述点击绑定,
推测为宿主浏览器/App 内核对 <img> 的原生行为,与本项目代码无关。
2026-10-18 22:53:03 +08:00
becomer 72dd2645ec 前端上传 OBS 显式附带 filename,避免部分环境 400
ci/woodpecker/tag/build Pipeline was successful
对真实 OBS 做了 5 组对照(真实用户 cookie + 真实策略 + 真实上传):

  filename=file.jpg   CT=image/jpeg        → 204 成功
  无 filename         CT=image/jpeg        → 400 失败
  filename=blob       CT=image/jpeg        → 204 成功
  无 filename         CT=octet-stream      → 400 失败
  filename=image.jpg  CT=octet-stream      → 204 成功

结论:file 部分必须带 filename,否则 OBS 直接 400;与 Content-Type 无关。

原代码 `obsForm.append('file', file)` 只传两个参数。浏览器对
append(name, Blob) 会按规范自动补 filename="blob",所以多数桌面/移动
浏览器能通过;但自动补的名字无扩展名,且部分环境(老 WebView、
App 内置浏览器)不补,就会命中上表的 400 分支。

改动:
- 新增 utils/obsUpload.ts:resolveUploadFileName 优先用原始文件名
  (无扩展名时按 MIME 补),无 name 时按 MIME + 时间戳生成;
  非图片/未知 MIME 回退 jpg。
- HomeView.vue(任务表单上传)与 LeaveView.vue(请假上传)两处
  append 均改为显式传文件名。

说明:本次未定位到用户 400 的确切链路(后端复现 return_register 得到的是
cookie_expired 而非 400),此项为消除已知的 filename 差异,
使前端行为在任何环境下都稳定。
2026-10-18 18:58:24 +08:00
becomer 91b8d7bfd4 回退 4f9027b:实测证明返校登记上传端点不存在该路径
用真实用户 cookie(学号 2364400108,桂林信息科技学院)实测:

  /wec-counselor-leave-return-apps/stu/obsOperate/obs/getUploadPolicy → 404
  /wec-counselor-leave-return-apps/stu/obs/getUploadPolicy             → 404
  /wec-counselor-leave-return-apps/stu/leave/obs/getUploadPolicy       → 404
  /wec-counselor-attendance-apps/student/attendance/getUploadPolicy    → 200
  /wec-counselor-sign-apps/stu/obs/getUploadPolicy                     → 200

4f9027b 中推断的 leave-return 上传策略路径根本不存在,属臆造。
issue 的真正成因尚未确定,不应保留臆造修复。

同时确认:sign 与 sleep_check 用真实 cookie 走完整链路
(取策略 → 上传 OBS → 取预览 URL)均成功,OBS 返回 204;
return_register 走现有路径得到的是 cookie_expired(HTML 登录页),
与用户看到的 400 现象不一致,需前端侧信息才能继续定位。
2026-10-18 18:57:08 +08:00
becomer 4f9027bb0f 修返校登记图片上传 400:该类任务误用签到模块的上传策略
现象(用户 19865075523,返校签到编辑表单上传图片):
  图片上传到 OBS 失败(400),请重新选择照片上传

根因:返校登记是独立模块,API 前缀为
  /wec-counselor-leave-return-apps
但 getImageUrlPrefix 没有该分支,taskType=return_register 落到 default,
于是去 /wec-counselor-sign-apps/stu 取上传策略;拿到的是签到桶/签名,
浏览器直传到 OBS 时被拒 400。

改动:
- 新增 GetLeaveReturnUploadPolicy:
  POST {leave-return}/stu/obsOperate/obs/getUploadPolicy
  (路径参照同族 leave-new 模块,复用 leaveReturnPost 的 Header 与错误处理)
- 新增 GetLeaveReturnPreviewURL:
  POST {leave-return}/stu/obsOperate/obs/previewAttachment
  (原先同样落到 sign 的 /sign/previewAttachment,取不到预览)
  并对部分学校把地址放 message 的情况做兜底
- GetUploadPolicy / GetPreviewUrl 两个 handler 均加 return_register 分支,
  并接入 cookie 过期自动续期与 needReLogin 判定
- getImageUrlPrefix 补 return_register 映射并加注释,避免再次静默走错模块

诊断增强:
- GetUploadPolicy 记录 formData 的字段名与 host(不含值);
  若 formData 为空则告警并输出响应片段——因为 formData 为空会让前端
  回退到自行拼装字段的旧分支,那正是历史上被 OBS 拒绝的原因

测试:return_register 前缀必须为 leave-return 模块且不含 sign-apps;
签到/查寝/请假的既有映射不变。
2026-10-18 18:51:17 +08:00
becomer 458e13b807 CAS 回调遇「参数解码错误」自动重取盐重试,覆盖 DK 轮换窗口
ci/woodpecker/tag/build Pipeline was successful
多个无凭证实验已确认:
- /api/sec_js 的 DK 盐每次请求都不同,且与会话无关(同 cookie 两次也不同);
- 服务端只能从密文里还原 DK,不依赖任何会话状态;
- 用当次 sec_js 加密的密文,服务端必定能解开(返回「用户名或密码错误」而非解码错误);
- 无/无效 CASTGC 时 CAS 直接返回 200+登录页(Set-Cookie 清除 CASTGC),
  根本不会给出 ticket——所以「参数解码错误」发生时 ticket 是有效的,
  失败只可能发生在 /cas/callback 的解密环节。

由此推论:取盐与提交之间若服务端轮换了 DK,就会解密失败。
官方前端对该错误的提示正是「刷新页面后尝试」,说明官方也视其为可重试的
瞬时问题,而非致命错误。

改动:
- 新增 isXsgzDecodeError:仅当响应体含「参数解码错误」时为真
  (凭证错误、ticket 无效等不触发重试);
- 命中时重新拉取 sec_js、重新加密、重新提交一次。

安全性:解码失败意味着服务端没解出内容,ticket 未被消费,
因此重试不会浪费这张一次性 ticket。

日志:重试时记录 dkChanged(两次盐是否不同),
可据此确认线上是否真的发生了轮换,为后续是否加重试次数提供依据。
2026-10-18 17:18:14 +08:00
becomer 05d9ef26b1 续期后加真实可用性校验,避免「假成功」导致无限重试
ci/woodpecker/tag/build Pipeline was successful
162 日志(8884 容器,v1.2.157)实证:
  16:25:58 INFO  刷新cookie: 已用长期凭证续期成功 userId=1279
  16:25:58 ERROR 获取表单失败 taskId=337 error="...cpdaily_cookie_expired"

续期报告成功,紧接着业务调用仍过期。

根因:refreshCookieByLongTermToken 判定成功的唯一标准是
「结果里存在 MOD_ 前缀的 cookie」。但续期走的
/imUserToken/getImUserToken 接口只校验 sessionToken 头,
**不校验 CAS 会话是否有效**;当 CASTGC/sessionToken 本身已过期时,
它仍可能返回 errCode=0,并把 jar 里那批无效 cookie 全数带回。
于是本次续期「成功」,调用方立刻重试又过期,用户只看到内部错误码。

改动:
- 新增 cpd.VerifyCookieUsable:用业务同款最小请求(拉当天任务列表)
  做只读探测。三种任务类型任一能正常返回即视为有效;返回
  ErrCookieExpired 才判定凭证不可用;网络等其它错误不归咎于凭证。
  该探测无任何写副作用。
- refreshCookieByLongTermToken 在回写 cookie 前先跑校验,
  不通过则按续期失败返回,让上层走「请重新登录」引导,
  而不是发出一轮注定失败的重试。

配合上一提交(07cf121)的两处升级与 userFacingError 兜底,
现在链路上任一环节的登录态失效都会转成明确的重登引导,
不再把 cpdaily_cookie_expired 暴露给用户。
2026-10-18 16:38:55 +08:00
becomer 07cf121aac 修 cookie 自动续期后仍失效时把内部错误码暴露给用户
现象(用户反馈):
  获取签到表单失败: 获取当日任务列表失败: 解析任务列表响应失败: cpdaily_cookie_expired

期望:能自动续期就续期;续不了就明确引导重新登录(前端据此跳登录页)。
实际:既没提示重新登录,还把内部错误码 cpdaily_cookie_expired 直接显示出来。

根因:自动续期成功、但用新 cookie 重试仍判登录态失效时,
重新拿到的错误(仍是 ErrCookieExpired)被原样返回。
而 needReLoginSignal() 只认 ErrPasswordChanged / ErrNeedReLogin,
不认识 ErrCookieExpired,于是:
  - errMsg 保持 "前缀 + cpdaily_cookie_expired";
  - needReLogin=false,前端不会引导重新登录。

这属于「续期拿到了凭证,但凭证其实无效」的情形(长期凭证过期、
学校侧强制下线等),语义上等同于必须重新登录。

改动:
1. withCookieRefresh:续期后重试仍报登录态失效 → 升级为 ErrNeedReLogin,
   这是所有 withCookieRefresh 调用方的统一入口,一处修全覆盖;
2. 签到执行路径:续期后重试仍过期同样升级,并加注释说明;
3. 新增 userFacingError(prefix, err):输出前统一兜底,
   任何残留的登录态失效语义都转成「登录凭证已过期,请重新登录」,
   普通错误保留原文。即使某条链路漏做升级,也不会再泄露内部错误码;
4. 「获取签到表单」与「签到提交」两处失败文案改用 userFacingError,
   needReLogin 一并由它给出(前端据此跳登录页)。

测试:原始 ErrCookieExpired、多层 %w 包装、ErrNeedReLogin、
JSON 文本「未登录」均转成引导重登文案且不含内部错误码;
普通错误原样保留;nil 不触发重登。
2026-10-18 16:36:31 +08:00
becomer 8b647fc31a CAS 链路加诊断日志,便于一次请求定位「参数解码错误」
ci/woodpecker/tag/build Pipeline was successful
上一轮用公开接口做对照实验,确认了服务端响应语义:
  - 正确加密但 ticket 无效 → 「用户名或密码错误」(解密成功)
  - 裸 ticket / 乱码密文  → 「参数解码错误」(解密失败)
  - 空 ticket            → 500
同时已排除代码侧原因(Go 与站点 security.js 产物逐字节一致、
chunkSize=126 一致、sec_js 禁缓存、算法自 v1.2.157 未变)。

但「参数解码错误」仍缺一处可见性:CAS 换票拿到的 ticket 本身是什么形态、
加密后几块、服务端原始响应是什么,线上日志都没有。没有这些信息,
下一次复现只能继续猜。

新增(均不含凭证本体):
- CAS 换票成功:ticketLen / hasSTPrefix / hasSpace / service
- 加密完成:ticketLen / dkLen / encLen / blockCount
  (blockCount>1 才可能出现多块空格,届时 body 是否被 URL 编码才有区别;
   这条日志能一次性回答该问题)
- 登录被拒:httpStatus / message / bodySnippet(截断 300 rune)

配套 truncateForLog:按 rune 截断,不切断多字节字符。
测试覆盖短串不截断、超长加标记、中文不产生乱码、边界值。
2026-10-17 23:11:37 +08:00
becomer 392c90ec11 更正 CAS 注释的推断错误,并兼容 ticket 的三种参数名
上一提交 e38fd89 把「参数解码错误」归因于 url.QueryEscape 把密文空格
变成 '+'。本次用站点真实的 security.js 在 Node 里跑通加密链路后,
该归因被证伪:

- RSAUtils.chunkSize = 2 * biHighIndex(m) = 126(字符),
  与 Go 的 k-2 = 126 一致;
- 实测真实密钥下,典型 CAS ticket(数十字符)明文约 77 < 126,
  产物是**单块 256 hex、不含空格**,QueryEscape 与否完全等价;
- 用同一 DK/M/E 对拍,Go 与前端 security.js 的输出**逐字节一致**,
  说明加密实现本身没有问题。

因此「参数解码错误」更可能是 ticket 本身已失效/被消费
(CASTGC 过期、同一 ticket 重复使用),而非编码差异。
注释已改为陈述实测事实,不再保留错误推断。

保留的直接拼接本身无害且更贴近前端原文(避免将来 ticket 变长
产生多块时引入空格→'+' 的偏差),故不回退。

顺带补上真实存在的兼容差异:前端 OAuthCallback 从 query 里按
ticket / service_ticket / ST 三个名字依次取,我们原先只认 ticket。
2026-10-17 23:08:54 +08:00
becomer e38fd89425 修销假两个线上报错:projectId 为空、CAS 参数解码错误
线上(162,v1.2.160,userId=1490)实测两类报错:
  提交销假失败: 缺少请假类型(projectId)
  学工系统登录失败: 登录失败: 参数解码错误,刷新页面后尝试
且伴随 WARN「获取销假表单字段失败: 未找到数据」——因为 projectId 为空,
form_fields_for_add 查不到任何东西。

1) projectId 为空
   projectId 是销假提交必传项,但前端销假请求体未必带(TypeWid 在列表里,
   不一定回传到执行接口)。原实现直接拿 req.ProjectID 用,为空即报错。
   修复:
   - 为空时按 leaveId 调 leave_detail 回查补齐,补到后记 INFO;
   - 回查仍拿不到则明确报「无法确定该请假记录所属的请假类型,
     请返回列表刷新后重试」,而不是发出注定失败的提交。
   - 同时修 XsgzFetchLeaveDetail 的解析:前端取的是
     `c.value?.project?.id || c.value?.projectId`,项目 id 可能嵌在
     project 对象里;原先只读顶层 projectId,现按「顶层优先、
     回落 project.id」处理,项目名同样回落到 project.text。

2) CAS 参数解码错误
   前端 getOAuthToken 的 body 是 `ticket=${s}` 直接拼接,**不做 URL 编码**。
   我们原先用 url.QueryEscape,而密文由多块小写 hex 以**空格**连接
   (实测长 ticket 分 2 块),QueryEscape 会把空格变成 '+';
   服务端按原始 body 解析时还原不回空格,逐块解密失败,
   正好对应「参数解码错误,刷新页面后尝试」。
   修复:改为与前端一致的直接拼接。

测试:
- 回查补齐 projectId 的两条路径(嵌套 project.id、顶层优先);
- CAS body 保留原始空格、不得出现 '+';
- 反例固化 QueryEscape 会破坏 ticket,说明为何不能用它。
2026-10-17 23:05:30 +08:00
becomer 9bf2d46f84 修人脸识别排队被误判为失败:识别中应继续等待
问题(162 服务器日志实证):
  ERROR 人脸识别被系统驳回,判定签到失败 userId=1043 message=识别中,预计等待4位
  ERROR 人脸识别被系统驳回,判定签到失败 userId=1043 message=识别中,预计等待8位

「识别中,预计等待N位」是服务端识别队列的进度提示,不是失败。
但 evalFaceIdentify 里只要 recognitionProgress 非空就判 Rejected+Settled,
于是提交后的复核立刻判定签到失败,用户看到报错,实际签到可能仍在排队后成功。

根因:
  if res.Progress != "" { res.Rejected = true; res.Settled = true; return }

修复:
1. 新增 faceProgressIsPending / faceProgressIsTerminalFailure 两个判定:
   - 含「识别中/排队/处理中/等待」等 → 进行中,继续等待;
   - 含「未通过/失败/驳回/人脸不匹配/未检测到人脸」等 → 终止失败;
   - 两者皆无的未知文案 → 保守按进行中处理,不误杀(超时后仍走原有
     「未出结果按成功」兜底);
   - 判定失败前先排除排队文案,避免「识别中」被误伤。
2. handler 轮询策略调整:原先固定 4 轮(0/1/2/3 秒)就收手,排队靠后
   必然等不到结果。现在前 4 轮快速取结果,之后每 3 秒一轮,最长再等
   90 秒;期间把最新排队提示透传给前端(queued=true),用户能看见进度。

测试:用日志里的真实文案断言不判驳回;同时保留 FRS.0036 等真实失败
文案必须判驳回;progressCode=2 无文案时仍判驳回;未知文案不误杀。
2026-10-17 21:37:07 +08:00
becomer 152878cc7c 销假填充与 normalize 打架:天气字段不再被地址污染
自审本轮改动时发现的不一致:销假路径先按标签用地址兜底填充,
再调用 normalizeXsgzFields,而后者对 regions 是「清空 value」。
两者叠加导致:
  - 若字段名含"原因/说明"且是 regions 类型,地址先被写入、又被清空,
    等于白填,且语义上互相矛盾;
  - 上传类字段同样可能先被写入地址,再被 delete(value)。

请假路径的 fillXsgzFieldValues 已跳过这两类,销假这段循环没跟上。

改动:销假填充循环加 xsgzNonTextFillable 判断,与请假路径保持一致。
测试:一个同时含 regions / imageUpload / TEXT 的销假表单,
断言文本字段得到地址、地区字段为空、上传字段无 value。
2026-10-17 18:26:27 +08:00
becomer b90a1dcd8f xsgz 纯原始值数组改为 join(","),并引入对拍测试防止再次误读前端规则
写了一个忠实转写前端 v()/pe() 的参考实现做对拍,立刻发现一处真实偏差:

  前端: tags = "a,b,c"                     (单个键)
  Go:   tags[0]=a tags[1]=b tags[2]=c      (三个索引键)

前端 v() 对数组是两条分支:
  if (s.some(l => l && typeof l == "object")) → 按下标展开 `${i}[${p}]`
  else → const l = s.map(p => p ?? "").join(","); append(i, l)

我们只有前一条分支。学校端按 tags 读字符串时拿不到值。

此前几轮反复在「方括号 vs 点号」上判断失误,根因是仅凭注释与内部一致性
推断契约。这次改为用可执行的参考实现做对拍,让差异由测试暴露而不是靠人眼。

改动:
- xsgzFlatten 数组分支按是否有对象分流:纯原始值 join(",")(空数组
  与全空值不提交),含对象才按下标展开;键名规则本身不变
  (对象键点号、数组下标方括号);
- 新增 leave_xsgz_jsref_test.go:把前端 v()/pe() 转写成 jsFlatten,
  用真实形态载荷(请假、销假、多字段、嵌套 map、空 fields)对拍,
  双向断言「不缺键、不多键、值一致」;
- 记录一处有意差异并说明理由:前端会把 null 提交为空串,我们跳过空值,
  对必填校验等价且更干净。
2026-10-17 18:25:23 +08:00
becomer 6bc7ab359c xsgz regions 字段不再被文本值污染,且必填时能正确拦下
前端 Ie() 对 regions 组件的处理(studentForm-Dx5Fpjfs.js):

  s = (e[fieldId+"_regionsDetail"] ?? e[fieldId+"_detail"])
        ?.replace(/,/g,",")?.trim() || ""
  o.value = 选中值 ? (s ? `${选中值},${s}` : 选中值) : (s || n)

即 value 是「选中值,详情」的拼接串,详情还会把半角逗号换成全角。

本平台不提供地区选择,没有 _regionsDetail/_detail 可拼接,
此前的实现会把「请假原因」当作地区值写进 value,
学校端会收到无法解析的非法地区串。

改动:
- normalizeXsgzFields 对 regions 清空 value(置 "",由 xsgzFlatten 跳过,
  不会提交该键),避免残留脏值;
- fillXsgzFieldValues 跳过 regions(新增 xsgzNonTextFillable 统一判定
  上传类与地区类),不再把文本写进地区字段。

顺带修掉一个逻辑矛盾:checkXsgzUnfilledRequired 原先对「非文本可填」类型
一律 continue,导致**必填的 regions 字段永远不会被拦下**——
请求会带着空地区发出去,然后被学校端含糊拒绝。
现在只有上传类跳过(它由 uploadFiles 承载,无 value 属正常),
regions 必填无值时正常纳入预检,明确提示用户缺哪个字段。

测试:regions 不被填入请假原因、残留 value 被清空、
必填 regions 触发预检拦下(该用例正是暴露上述矛盾的地方)。
2026-10-17 18:24:19 +08:00
becomer bb4a1d2325 xsgz 字段提交按前端 Ie() 语义清洗:上传类去 value、多选 join 逗号
从 studentForm-Dx5Fpjfs.js 的 Ie() 看到字段有三条分支:

  regions:                                   value = 拼接地名
  imageUpload / fileUpload / smoothSignature: o.uploadFiles = 文件列表;不设 value
  其余:                                       o.value = Array.isArray(n) ? n.join(",") : n

我们的实现只有一条路——直接写 value,导致两个偏差:

1. 上传类字段(图片/文件/手写签名)被塞了文本 value,前端根本不会这样提交,
   且从未提供 uploadFiles。学校端若要求附件,行为不可预期。
2. 多选类字段若 value 是数组,xsgzFlatten 会展开成 fields[0].value[0]
   / fields[0].value[1],学校端按 value 取字符串时读不到;
   前端是 join(",") 成单个字符串。

改动:
- 新增 normalizeXsgzFields,按上述三分支净化字段定义:
  上传类删除 value(本平台不代传附件,不臆造 uploadFiles),
  数组 value 按前端规则 join(",")(跳过 nil 与空串);
- 请假、销假两条提交路径都接入;
- fillXsgzFieldValues 跳过上传类字段,避免把「请假原因」写进附件字段;
- checkXsgzUnfilledRequired 不把上传类必填字段算作缺失
  (它们由 uploadFiles 承载,无 value 属正常),避免误拦。

测试:三种上传类型去 value、多选 join、数组含 nil/空串、普通字段不受影响、
空输入不 panic、填充跳过上传类、预检不误拦上传类。
2026-10-17 18:23:06 +08:00
becomer 96de022871 xsgz 登录后补拉 /auth/user 拿 roleId,不再只依赖 cas/callback 响应
核对前端源码发现两者的获取路径是分开的:

  getOAuthToken:  setToken(m.data.data?.access_token)   // 只管 token
  getUserInfo:    POST /auth/user → data.role.roleId     // roleId 在这里
  axios 拦截器:    s.headers.roleId = JSON.parse(getCurrentRole()).roleId
                  s.headers.Authorization = getToken()   // 原文,无 Bearer 前缀

我们原先在同一处只读 cas/callback 的 data.userInfo.roleId。
若该校响应不含该嵌套结构,RoleID 会为空,导致:
  - 所有后续请求缺少 roleId 头;
  - 学校端可能直接拒绝,且报错信息不会指向真正原因。

改动:
- 登录成功后主动 POST /api/auth/user 取 data.role.roleId(roleName 同步);
- 该调用失败不阻断(slog.Warn 后沿用 cas 响应的值),保持向后兼容;
- 两者都拿不到 roleId 时才明确报错「未能获取角色信息」,
  避免带着空 roleId 一路请求到被学校端拒绝;
- realName 缺失时回退 xm(国内学工系统常见字段名)。

顺带核实两处未被验证过的契约,结论是现有实现正确、无需改动:
- Authorization 头是 token 原文,不加 Bearer 前缀;
- RoleID 与前端 getCurrentRole().roleId 语义一致。

测试:/auth/user 的路径与 POST 方法、role/roleName/realName 解析、
realName 缺失回退 xm、非 0 code 必须报错。
2026-10-17 18:21:52 +08:00
becomer 7c2b9aa3a7 销假范围标记改用 leave_detail 精确查询,避免分页上限导致漏拦截
上一版 recordRequiresInArea 拉 50 条列表再线性查找留两条隐患:
记录超过 50 条时找不到 → 误判「不要求」→ 漏掉前端那道拦截。

核对前端源码后改为按 leaveId 精确查询(LeaveDetailView 的 ve()):
  s.listType==="apply" && (d.leaveId = s.data.id, n = await Ft(d))
  c.value = { ...n }
  "cancellation-apply-in-area": c.value?.cancellationApplyInArea === true
                             || c.value?.cancellationApplyInArea === "是"
其中 Ft 即 studentForm 的 he → GET /leave/student_action/leave_detail。

改动:
- 新增 XsgzFetchLeaveDetail(GET leave_detail?leaveId=...,code!=0 报错);
- recordRequiresInArea 改为「详情优先、列表回退、都失败则保守 false」,
  既消除分页隐患,也保留对详情接口异常的容错。

测试:详情接口路径与参数名、bool true / 字符串「是」两种标记解析、
非 0 code 必须报错(不能静默当成空详情)。
2026-10-17 18:20:28 +08:00
becomer ab4f29a474 销假范围强制校验对齐前端:读 cancellationApplyInArea 决定是否拦截
上一版(355557b)算出 inArea 就上报,但没有判断「该校是否要求在范围内」,
等于把前端的一道拦截丢了。前端源码(LeaveDetailView-BveoXCrD.js):

  const v = computed(() => a.actionType==="CANCELAPPLY" && a.cancellationApplyInArea)
  if (v.value && !T?.canPosition) { warning("不在销假申请范围内,无法销假"); return }

该标记的来源也一并核实(同文件传入 StudentActionDialog 的 props):
  "application-id": c.value?.id || s.data?.id
  "project-id":     c.value?.project?.id || c.value?.projectId
  "cancellation-apply-in-area":
      c.value?.cancellationApplyInArea === true
   || c.value?.cancellationApplyInArea === "是"

顺带确认了 originalId 与 projectId 的取值与我们现有实现一致
(ToLeaveItems 的 ID=r.ID、TypeWid=r.ProjectID)。

改动:
- XsgzLeaveRecord 增加 CancellationApplyInArea(用 interface{} 兼容
  bool 与 "是" 两种返回),并加 RequiresInArea() 做等价归一化;
- 新增 recordRequiresInArea:后端自行重查记录判定,不复用前端传值
  (可篡改、且可能未传);查不到时保守返回 false,不额外拦截;
- 销假流程改为:该校要求范围内且实际不在范围内 → 明确报错并拒绝提交,
  与前端一致;未配置范围时 inArea 视为 true。

测试:RequiresInArea 覆盖 bool true/false、"是"/"否"、"true" 字符串、
nil、数字等输入。
2026-10-17 18:19:27 +08:00
becomer 355557b6ce 销假补上地理范围校验:inArea 此前恒为 false,学校端可能直接拒绝
从 sdau 线上源码(LeaveDetailView-BveoXCrD.js)拿到销假提交的真实实现:

  const W = _e(r.value, I.value),          // 构造 fields
  H = { originalId: a.applicationId, projectId: a.projectId,
        requestType: "CANCELLATION", fields: W,
        cancellationApplyInArea: a.cancellationApplyInArea,
        inArea: T?.canPosition || !1,      // ← 客户端地理围栏计算结果
        address, longitude, latitude },
  z = he(H, M.value), B = await Te(z)

前端还有一道硬拦截:
  const v = computed(() => a.actionType==="CANCELAPPLY" && a.cancellationApplyInArea)
  if (v.value && !T?.canPosition) { warning("不在销假申请范围内,无法销假"); return }

而我们的实现里 XsgzSubmitPayload.InArea 从未被赋值,销假请求恒传
inArea=false。若该校 cancellationApplyInArea=true,前端不会提交的请求
我们会照发,结果被学校端拒绝,用户只看到含糊的失败。

新增(均对齐前端):
- XsgzFetchLocationRange:GET /api/leave/student_action/location_range
  (前端 ve/bt,路径由源码核实)
- InLocationRange:复刻前端 c() 的围栏判断——Circle/poi 比球面距离,
  Polygon/Rectangle 走射线法点在多边形内;范围为空时返回 true
  (对应前端 !r.value.length 时 return true)
- 销假流程先拉范围再算 inArea;范围查询失败或无有效坐标时按不限制处理
  并记日志,不让本校未配置范围的情况把功能卡死

复用 task.go 已有的 haversineMeters(注意其签名是 lat1,lon1,lat2,lon2),
未重复造轮子。

测试覆盖:空范围、圆形半径内外、poi、多边形内外、矩形、多范围任一命中、
退化多边形(<3 点)。
2026-10-17 18:18:09 +08:00
becomer 568329890b 回退 f1f79df:抓取线上前端源码证实数组下标确为方括号 fields[0]
上一版 f1f79df 把 xsgzFlatten 的数组下标从 fields[0] 改成 fields.0,
依据是函数注释/提交说明/「needCurlyBraces 语义」三处都写点号。
本次从 sdau 线上静态资源(无需登录)拿到真实前端源码,证明该判断错误。

证据(xsgz.sdau.edu.cn/assets/,免登录可取):
  index-B6fhXjS6.js      10.7MB 主包,含 1354 个 chunk 清单
  studentForm-Dx5Fpjfs.js       序列化与提交实现
  LeaveApplyDrawer-BeaVSGRQ.js  请假提交调用点

前端真实链路(LeaveApplyDrawer 的 P()):
  c = ee(表单值, 字段定义)
  L = ae(c, 字段定义)                      // fields 数组
  T = {projectId, requestType:"LEAVE", startTime, endTime,
       fields:L, address, longitude, latitude}
  f = le(T, ext)                          // le → Ne → v → pe,转 FormData
  await te(f)                             // POST application_submit (multipart)

键名规则(studentForm-Dx5Fpjfs.js 原文):
  function pe(e,t,a){return e? a? `${e}[${t}]` : `${e}.${t}` : t}
  v(): if(Array.isArray(s)) s.forEach((l,p)=>{
         const c=`${i}[${p}]`;            // 数组下标永远方括号,与 a 无关
         ... v(l,r,c,o)
       })
  Ne(e, t={}, a=false) → v(..., a=false)

即:对象键用点号,数组下标硬编码方括号。实际提交形如
fields[0].fieldId / fields[0].value。原实现正确,f1f79df 改错了。

另外查明:注释里长期引用的 toFormatFormData / needCurlyBraces
在整个 bundle 中并不存在(grep 计数为 0),是早期臆造的符号,
现已在注释中改为真实函数名 v()/pe()/Ne() 并附调用链。

改动:
- xsgzFlatten 数组分支还原为 "%s[%d]",注释给出源码依据;
- XsgzSubmitApplication 注释改为真实前端链路与规则;
- TestXsgzFlatten 期望还原为 fields[0].*;
- 契约测试期望同步为 fields[0].*,并加断言:括号需配对、
  不得出现 fields[0][fieldId] 这类二维下标形态。
2026-10-17 18:15:35 +08:00
becomer f1f79dfb6c 修正 xsgz 表单数组下标:fields[0] 改为 fields.0,与 needCurlyBraces=false 语义一致
新增的 httptest 端到端契约测试(真发 multipart,假服务器接收)发现:
xsgzFlatten 对数组用 fmt.Sprintf("%s[%d]"),实际提交键为 fields[0].value,
而同一函数的注释、XsgzSubmitApplication 注释、以及 07b0cfa 的提交说明
三处都写的是点号 fields.0.value。

needCurlyBraces=false 的语义本身就是「不用方括号/花括号」,实现与此矛盾。
resty 对 multipart 键名原样透传(addMultipartFormField →
createMultipartHeader(mf.Param)),方括号完全来自我方代码,不是库行为。

后果:学校端按 fields.0.value 取值时拿不到值,请假原因等字段为空,
大概率被判定为「必填项未填写」而提交失败。

改动:
- xsgzFlatten 数组分支改为 "%s.%d",并在注释里记录这次错误;
- TestXsgzFlatten 的期望值同步修正(原断言固化了错误行为),
  并新增断言:所有键不得含 [ 或 ];
- 新增 leave_xsgz_submit_test.go:用 httptest 假服务器捕获真实请求,
  端到端断言路径、Authorization/roleId 头、顶层参数名、
  fields 点号摊平、空值跳过、请假/销假字段互不串味、
  以及缺 projectId/originalId 时本地拦截不发请求。
2026-10-17 18:12:53 +08:00
becomer 00bd63cfdf 修复销假/请假 SSE 崩溃:stream writer 内误用已回收的 fasthttp RequestCtx
线上 v1.2.158 销假必崩,前端显示「连接失败,请检查网络」,用户误判为网络问题。

根因(162 服务器日志实证):
SetBodyStreamWriter 的回调运行在 fasthttp 的独立写协程中,此时 *RequestCtx
已被归还复用。回调内调用 newXsgzSession(c.Context(), ...) 把该 ctx 交给
resty 的 SetContext,resty 内部走 context.WithDeadline → propagateCancel
→ parent.Done(),而 RequestCtx.Done() 直接解引用 ctx.s(server.go:2726):
    panic: runtime error: invalid memory address or nil pointer dereference
    [signal SIGSEGV]  fasthttp.(*RequestCtx).Done  server.go:2726
此时 s 已为 nil,触发 SIGSEGV。

panic 发生在协程里,fiber 的 recover 中间件捕获不到,直接杀死整个进程,
前端 XHR 连接被掐断 → 只剩「连接失败,请检查网络」这条误导性提示。

修复:
1. 新增 streamCtx():返回 context.WithTimeout(Background, 3min),
   脱离请求生命周期,回调内一律使用它(销假与请假两条链路);
2. 两个回调都加 recover 兜底,即便再出现协程内 panic 也只返回
   task_error 事件,不会拖垮进程。

回归测试:TestStreamCtxIsUsable 保证 ctx 具备 Done/deadline;
TestRequestCtxDonePanicsAfterReset 用零值 RequestCtx 固化崩溃机制,
解释为什么不能用 c.Context()。
2026-10-17 18:07:34 +08:00
becomer e7c9b06056 xsgz 提交前预检必填字段,避免含糊失败
学校请假表单由后台动态配置,可能包含本平台无法提供值的必填字段
(如"是否离校"单选、请假类型细分下拉)。原先这类情况会直接提交,
由学校端返回业务错误,用户看到的提示不明确。

新增 checkXsgzUnfilledRequired:提交前检查所有 required 字段是否已有值
(空串/null 均视为缺失),有缺失则不发请求,直接提示
「该校请假表单还有必填项无法自动填写:X、Y,请前往学校学工系统办理」,
并写入日志。请假与销假两条路径都已接入。

新增 5 个单测覆盖:有值不报、无值报字段名、空串算缺失、
无 fieldName 回退 fieldId、非必填不报。
2026-10-17 17:46:49 +08:00
becomer c8ff086b7b xsgz 请假字段填充改为「标签匹配 + 必填文本兜底」,降低对字段命名的依赖
从 transformFields-D3I9yUm6.js 反推出 form_fields_for_add 的真实结构:
{ fieldId, fieldName, componentType, fieldType, required, options[] }
前端 transformFields 把它转成 fieldEn=fieldId / fieldZh=fieldName 后提交,
且请假原因并非硬编码字段,而是学校后台动态配置的。

原先只按「原因/事由」关键词填值,若学校把字段命名为"申请理由说明"
"补充信息"等就会漏填,导致学校端校验失败。

改为两轮策略:
1. 优先按标签命中(原因/事由/说明);
2. 未命中时,用「必填的文本类字段」(TEXT/TEXTAREA/LONGTEXT)兜底填入请假原因。
   该校表单通常只有一个原因字段,兜底可覆盖自定义命名;
   选择类字段(RADIO/CHECKBOX 等)不猜测,留空由学校端校验。

新增 4 个单测覆盖:关键词命中、自定义命名、兜底逻辑、空输入不 panic。
2026-10-17 17:45:12 +08:00
becomer 07b0cfaa88 校正 sdau 提交参数序列化,严格对齐前端 toFormatFormData
ci/woodpecker/tag/build Pipeline was successful
重新核对前端源码发现两处与实现不符,已修正:

1. FormData 序列化格式
   前端 toFormatFormData(T, needCurlyBraces=false) 的规则是:
   - 嵌套/数组用**点号**展开(fields.0.value),不是方括号;
   - 值为 null 或空字符串时**跳过**该键;
   - 数组按下标逐个展开。
   原实现把 fields 整个 JSON.stringify 成一个字符串,与前端不一致。
   新增 xsgzFlatten 按上述规则摊平,并加单测覆盖(点号/跳过空值/数组下标)。

2. fields 内容必须是「字段定义 + 用户填值」
   前端提交前会 forEach 把用户输入写回各字段的 value:
     f.value.forEach(l => { l.value = t[l.fieldId] ... })
   原实现只提交了空的字段定义。
   现在先拉 form_fields_for_add,再 fillXsgzFieldValues 把请假原因等
   按字段标签(含"原因/事由")回填到 value;销假同理(含"说明/原因")。

参数契约已逐项比对(源码原文 vs 实现):
  请假:{projectId, requestType:"LEAVE", startTime, endTime,
         fields[], address, longitude, latitude}
  销假:{originalId, projectId, requestType:"CANCELLATION", fields[],
         cancellationApplyInArea, inArea, address, longitude, latitude}
2026-10-17 17:43:31 +08:00
becomer 4a064b53cc sdau 销假接口实现:与请假共用 application_submit,靠 requestType 区分
静态分析定位到请假详情页组件链(views/leave/detail/):
ActionPopup-CRBsBafz.js 中 handleType=CANCELAPPLY 的销假申请逻辑,
其提交函数 re 从 fieldsConfig-BzLAymBp.js 导入,即与请假同一个
application_submit 接口。

销假 payload(requestType=CANCELLATION):
  { originalId: <原假单ID>, projectId, requestType:"CANCELLATION",
    fields[], cancellationApplyInArea, inArea, address, longitude, latitude }

改动:
- service:新增 XsgzSubmitCancel / XsgzSubmitApplication(请假/销假/续假共用),
  XsgzSubmitPayload 增加 originalId/inArea/cancellationApplyInArea
- service:XsgzLeaveRecord 增加 projectId 字段;ToLeaveItems 把 projectId
  写入 TypeWid,供前端销假时回传
- handler:新增 executeXsgzLeaveCancel,SSE 事件流 + 落日志
- handler:ExecuteLeaveRequest 增加 projectId
- 前端:ExecuteLeaveParams 增加 projectId;销假提交时回传 typeWid

另:batch-cancel / batch-cancel-list 经确认是辅导员侧接口,学生销假不走它们。
2026-10-17 17:40:16 +08:00
becomer cb3e0f8917 sdau 提交请假接口实现:按前端源码解析的契约接入 application_submit
从主 bundle 路由表 __vite__mapDeps 定位到请假页 chunk 链
(LeaveApply → index-C4CfkeDm.js → fieldsConfig-BzLAymBp.js),
完整解析出提交契约:

  POST /api/leave/student_action/application_submit  (multipart/form-data)
  payload = { projectId, requestType:"LEAVE", startTime, endTime,
              fields[], address, longitude, latitude }

改动:
- service:新增 XsgzSubmitLeave(multipart 提交)、XsgzFetchIsRequireLocation
- service:do() 支持 JSON body;新增 XsgzSubmitPayload
- handler:新增 executeXsgzLeaveApply,SSE 事件流与辅导猫链路一致,
  并落 cp_leave_logs
- LeaveType / ExecuteLeaveApplyParams 新增 projectId:xsgz 用 UUID 作类型 ID
- 前端提交时带上 projectId

顺带修正 GetPendingLeaves:batch-cancel-list 经实测是**辅导员**接口
(学生调用报「没有辅导员销假权限」),学生端改回 leave_page 按状态标记 CanReport。

注:销假写接口(batch-cancel)学生侧契约仍未确认,暂未接入。
2026-10-17 17:34:01 +08:00
becomer 3b46e45eb6 sdau 销假/请假改为真实数据,不再只做拦截提示
问题:上一版对 xsgz 学校只返回「请前往学校学工系统办理」的提示,
用户点销假按钮(/leave/pending-list)和请假类型时拿不到任何真实数据。

改动:
- 新增 CasTicketForService:用已有 CASTGC 换取指定 service 的 CAS 票据,
  不发起账密登录(handler 侧只有会话、没有明文密码)
- 新增 XsgzSession.ToLeaveItems:把 xsgz 请假记录转换为通用 LeaveItem 契约,
  status 为「休假中/逾期未销假」时 CanReport=true
- GetPendingLeaves:xsgz 学校改为真实拉取 /api/leave/record/leave_page
- GetLeaveTypes:xsgz 学校改为真实拉取 optional_project_all
- LeaveType 新增 ProjectID 字段:xsgz 用 UUID 作类型 ID,与旧契约的整数 code 并存

只读链路已实测通过:登录→CASTGC 换票→access_token→请假记录(1条) / 请假项目(1个)。
2026-10-17 17:25:21 +08:00
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
79 changed files with 9271 additions and 491 deletions
+61 -75
View File
@@ -1,6 +1,6 @@
when:
- event: tag
tag: v*
ref: refs/tags/v*
clone:
git:
@@ -10,7 +10,6 @@ clone:
steps:
build-image:
name: 构建 Docker 镜像
image: docker
commands: |
docker build -t cpdaily-xiaozhushou:latest .
@@ -19,7 +18,7 @@ steps:
- /var/run/docker.sock:/var/run/docker.sock
deploy:
name: 蓝绿部署
depends_on: [build-image]
image: docker
commands: |
set -e
@@ -35,28 +34,27 @@ steps:
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 载入。
# 第一步:确定本次要更新的槽位
# 本机 OpenResty 暂未配置 cpdaily 站点,没有可供切换的反代入口,
# 因此只做「容器级蓝绿」:两个槽保持一旧一新,每次只重建其中一个,
# 另一个继续运行上一版本,便于手工回滚。槽位记录在宿主机文件里交替。
# ============================================================
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 ==="
SLOT_FILE=/opt/cpdaily-xiaozhushou/current-slot
if [ -f "$SLOT_FILE" ]; then
CURRENT_SLOT=$(cat "$SLOT_FILE")
else
# 首次:当前 8884 为较新版本,本次更新另一个槽
CURRENT_SLOT=$SLOT_GREEN
fi
if [ "$CURRENT_PORT" = "$SLOT_BLUE" ]; then
if [ "$CURRENT_SLOT" = "$SLOT_BLUE" ]; then
PORT_ACTIVE=$SLOT_BLUE
PORT_STANDBY=$SLOT_GREEN
elif [ "$CURRENT_PORT" = "$SLOT_GREEN" ]; then
else
PORT_ACTIVE=$SLOT_GREEN
PORT_STANDBY=$SLOT_BLUE
else
echo "ERROR: 未知反代端口 $CURRENT_PORT,中止部署(期望 $SLOT_BLUE/$SLOT_GREEN)"
exit 1
fi
echo " 活跃槽: $PORT_ACTIVE | 备用槽: $PORT_STANDBY"
echo " 目标: 更新备用槽 :$PORT_STANDBY 到 $IMAGE_TAG 后,切换反代指向它(活跃槽保留,可回滚)"
echo "=== 当前版本槽: $PORT_ACTIVE | 本次更新槽: $PORT_STANDBY ==="
# ============================================================
# 第一步附:一次性「收养」旧命名容器
@@ -91,14 +89,14 @@ steps:
echo "=== 在备用槽启动新容器 :$PORT_STANDBY (image: $IMAGE_TAG) ==="
docker run -d --name cpdaily-xiaozhushou-$PORT_STANDBY \
--restart unless-stopped \
--network likedream-net \
--network 1panel-network \
-e TZ=Asia/Shanghai \
-p $PORT_STANDBY:8093 \
-v /opt/cpdaily-xiaozhushou/config.toml:/app/config.toml:ro \
cpdaily-xiaozhushou:$IMAGE_TAG
# ============================================================
# 第三步:健康检查(新容器必须就绪,否则直接失败且不动反代)
# 第三步:健康检查(新容器必须就绪,否则直接失败)
# 用 docker exec 在容器内自测,避免依赖宿主机到容器的网络路径。
# ============================================================
HEALTHY=false
@@ -113,62 +111,15 @@ steps:
done
if [ "$HEALTHY" = false ]; then
echo "ERROR: 新容器未通过健康检查,中止部署(线上仍由活跃槽 :$PORT_ACTIVE 提供服务,未受影响)"
echo "ERROR: 新容器未通过健康检查,中止部署(旧槽 :$PORT_ACTIVE 未受影响)"
docker rm -f cpdaily-xiaozhushou-$PORT_STANDBY 2>/dev/null || true
exit 1
fi
echo " 新容器已就绪 ✓"
# ============================================================
# 第四步:切换 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 \
cp $PROXY_CONF $PROXY_CONF.bak
docker exec 1Panel-openresty-ESmn \
sed -i "s|:$PORT_ACTIVE|:$PORT_STANDBY|g" $PROXY_CONF
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 " 反代已指向 :$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 "$PORT_STANDBY" > "$SLOT_FILE"
echo " 已记录当前版本槽: $PORT_STANDBY"
# ============================================================
# 第六步:保留活跃槽用于回滚说明;清理多余历史镜像
@@ -178,12 +129,47 @@ steps:
echo "=== 当前容器 ==="
docker ps --filter "name=cpdaily-xiaozhushou-" --format '{{.Names}} → {{.Ports}} ({{.Status}})'
echo "=== 清理历史镜像(保留 latest 与最近 2 个版本)==="
# 清理历史镜像:只保留最近的 5 个版本。
# latest 与当前正在部署的 IMAGE_TAG 永不删除 —— deploy-162 步骤与此步骤并行,
# 如果误删当前 tag,它会 docker save 失败。因此先排除这两个,再按新旧顺序裁掉尾部。
echo "=== 清理历史镜像(保留最近 5 个版本)==="
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
| grep -v '^latest$' | grep -v "^$IMAGE_TAG\$" \
| tail -n +6 | while read tag; do
[ -n "$tag" ] && docker rmi cpdaily-xiaozhushou:$tag 2>/dev/null || true
done
echo "=== 部署完成 ✓ 活跃槽 :$PORT_STANDBY(旧槽 :$PORT_ACTIVE 保留,可回滚)==="
echo "=== 部署完成 ✓ 本次更新槽 :$PORT_STANDBY(旧槽 :$PORT_ACTIVE 保留,可回滚)==="
volumes:
- /var/run/docker.sock:/var/run/docker.sock
- /opt/cpdaily-xiaozhushou:/opt/cpdaily-xiaozhushou
# ============================================================
# 新增:同步部署到 162(跨机蓝绿)
# 本机部署步骤保持不变,这里只做「镜像推送 + 远程触发脚本」
# ============================================================
deploy-162:
# 只依赖构建产物,不受上面 deploy 步骤成败影响
depends_on: [build-image]
when:
- status: [success, failure]
image: docker:cli
volumes:
- /var/run/docker.sock:/var/run/docker.sock
# 宿主机 ssh 密钥(config 内含 do-162-14-81-139 别名与 IdentityFile)
- /root/.ssh:/root/.ssh:ro
commands: |
set -e
IMAGE_TAG="${CI_COMMIT_TAG:-latest}"
# alpine 官方源在本机不可达,改用国内镜像
sed -i 's|dl-cdn.alpinelinux.org|mirrors.aliyun.com|g' /etc/apk/repositories
apk add --no-cache openssh-client
echo "=== 推送镜像 $IMAGE_TAG 到 162 ==="
docker save cpdaily-xiaozhushou:$IMAGE_TAG | gzip -1 | \
ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null do-162-14-81-139 \
"gunzip | docker load"
echo "=== 触发 162 蓝绿部署 ==="
ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null do-162-14-81-139 \
"/opt/cpdaily-xiaozhushou/deploy-bluegreen.sh $IMAGE_TAG"
+14 -3
View File
@@ -62,12 +62,19 @@ func main() {
// gzip 压缩:nginx 侧 gzip_types 未覆盖 application/json,接口返回的 JSON
// (历史上曾出现单次 2.9MB 的任务列表)会明文传输。在此按内容协商压缩,
// 随镜像走版本控制、只影响本服务。
// 必须排除 SSE 端点(/api/task/execute/*):它用 SetBodyStreamWriter 流式推送,
// 压缩会缓冲响应导致事件无法即时到达。
// 必须排除所有 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 {
return strings.HasPrefix(c.Path(), "/api/task/execute/")
p := c.Path()
return strings.HasPrefix(p, "/api/task/execute/") ||
p == "/api/leave/apply" ||
p == "/api/leave/execute"
},
}))
@@ -89,6 +96,7 @@ func main() {
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("/reset-invite", middleware.AuthRequired(), handler.ResetInviteCode)
auth.Post("/guide/complete", middleware.AuthRequired(), handler.CompleteGuide)
// 签到任务相关
@@ -167,6 +175,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)
@@ -202,6 +211,8 @@ func main() {
ticket.Get("/:id", handler.GetTicketDetail)
ticket.Post("/:id/message", handler.AddTicketMessage)
ticket.Put("/:id/close", handler.CloseTicket)
// 打回:仅 resolved → processing,放在 /:id/close 同层,不与 /:id 冲突
ticket.Put("/:id/reopen", handler.UserReopenTicket)
ticket.Delete("/:id", handler.DeleteTicket)
ticket.Get("/notifications/list", handler.ListTicketNotifications)
ticket.Get("/notifications/unread-count", handler.GetTicketUnreadCount)
@@ -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"`
+7 -1
View File
@@ -1,13 +1,13 @@
package handler
import (
"context"
"cpdaily-new/internal/config"
"cpdaily-new/internal/database"
"cpdaily-new/internal/model"
"cpdaily-new/internal/service"
cpd "cpdaily-new/internal/service/cpdaily"
"cpdaily-new/internal/utils"
"context"
"crypto/rand"
"encoding/hex"
"encoding/json"
@@ -585,6 +585,12 @@ func BindInviteCode(c *fiber.Ctx) error {
if code == "" {
return utils.FailParam(c, "请输入邀请码")
}
// 格式预校验:邀请码固定 8 位且只用 inviteCodeChars 字符集。
// 不合规的输入直接拒绝,避免每次都在库里做一次无用查询;
// 用统一文案,不泄露「码存在但格式对」这类信息。
if !isValidInviteCodeShape(code) {
return utils.Fail(c, 400, "邀请码无效")
}
var user model.SysUser
if err := database.DB.First(&user, userID).Error; err != nil {
+65 -3
View File
@@ -69,9 +69,9 @@ func updateRawPassword(userID uint, rawPassword string) error {
// isCookieExpired 判断错误是否由 cpdaily 登录态失效(cookie 过期)引起。
// 两种来源:
// 1. cpd.ErrCookieExpired:响应体是 HTML(被重定向到统一身份认证登录页),
// 这是最常见、也最容易被漏判的情形(原文案匹配不到 "invalid character '<'");
// 2. 接口以 JSON 形式返回的「未登录 / 登录已过期」。
// 1. cpd.ErrCookieExpired:响应体是 HTML(被重定向到统一身份认证登录页),
// 这是最常见、也最容易被漏判的情形(原文案匹配不到 "invalid character '<'");
// 2. 接口以 JSON 形式返回的「未登录 / 登录已过期」。
func isCookieExpired(err error) bool {
if err == nil {
return false
@@ -214,11 +214,41 @@ func refreshCookieByLongTermToken(user *model.SysUser) (string, error) {
}
cookieStr := cpd.SerializeCookies(cookies)
// 关键:拿到 MOD_* 不等于续期有效。
// RefreshCasCookie 走的 /imUserToken 接口只校验 sessionToken 头,
// 不校验 CAS 会话是否仍然有效;当 CASTGC/sessionToken 本身已过期时,
// 它仍可能返回 errCode=0 并把 jar 里(旧的/无效的)cookie 全数返回。
// 于是这里会“续期成功”,而调用方重试立刻再次判定登录态失效。
//
// 因此用新 cookie 做一次轻量真实校验:能取到当天任务列表才算真的续上了。
// 校验失败则按续期失败处理,让上层引导重新登录,而不是白白重试一轮。
if verifyErr := verifyCpdailyCookie(cookieStr, user); verifyErr != nil {
return "", fmt.Errorf("续期后凭证仍无效: %w", verifyErr)
}
// 回写传入用户,供上层重试使用
user.Cookie = cookieStr
return cookieStr, nil
}
// verifyCpdailyCookie 用指定 cookie 做一次只读校验,确认登录态真实可用。
//
// 选择「拉当天任务列表」是因为它最轻、几乎所有学号都有数据,
// 且不会产生任何写入副作用(不签到、不改动任何服务端状态)。
func verifyCpdailyCookie(cookieStr string, user *model.SysUser) error {
m := cpd.ParseCookieJSON(cookieStr)
if len(m) == 0 {
return errors.New("续期结果为空")
}
host := getSchoolHost(user.SchoolID)
if host == "" {
return errors.New("未配置学校地址")
}
// 复用与正式调用相同的过期检测逻辑
return cpd.VerifyCookieUsable(m, host)
}
// withCookieRefresh 执行一次今日校园调用;若因登录态失效失败,则尝试续期 cookie 后重试一次。
// - 返回 (调用结果, 续期后的 cookieMap, error)。cookieMap 有变化时调用方可用于后续调用。
// - 续期失败(如 MFA 学校无法自动续期、密码已改)时,返回错误里带上 ErrCookieExpired,
@@ -244,7 +274,39 @@ func withCookieRefresh(user *model.SysUser, cookieMap map[string]string, host st
// 用续期后的 cookie 重试一次
newMap := cpd.ParseCookieJSON(user.Cookie)
if retryErr := fn(newMap); retryErr != nil {
// 续期成功但重试仍判登录态失效:说明拿到的凭证依然无效
// (长期凭证过期、学校侧强制下线等),等同于需要用户重新登录。
// 若不在这里升级,原始 ErrCookieExpired 会一路透传,
// 最终在界面上显示 cpdaily_cookie_expired 这种内部错误码。
if isCpdailyAuthError(retryErr) {
slog.Warn("续期后重试仍提示登录态失效,判定需重新登录",
"userId", user.ID, "host", host, "error", retryErr)
return newMap, fmt.Errorf("%w: %v", ErrNeedReLogin, retryErr)
}
return newMap, retryErr
}
return newMap, nil
}
// userFacingError 把内部错误转成可直接展示给用户的文案。
//
// 背景:ErrCookieExpired 的文本是 `cpdaily_cookie_expired`,
// Go 的 `%w` 包装会把它拼进错误链外层(如
// "获取签到表单失败: cpdaily_cookie_expired"),
// 最终原样出现在界面上,用户完全看不懂。
//
// 统一在输出前做一次兜底:任何残留的登录态失效语义都转成
// 「登录凭证已过期,请重新登录」,并保留原始前缀便于定位。
// 这样即使某条调用链漏做了 wrapNeedReLogin,也不会把内部错误码暴露出去。
func userFacingError(prefix string, err error) (msg string, needReLogin bool) {
if err == nil {
return prefix, false
}
if needReLoginSignal(err) || isCookieExpired(err) {
if prefix == "" {
return "登录凭证已过期,请重新登录", true
}
return prefix + "登录凭证已过期,请重新登录", true
}
return prefix + err.Error(), false
}
@@ -0,0 +1,91 @@
package handler
import (
"errors"
"fmt"
"strings"
"testing"
cpd "cpdaily-new/internal/service/cpdaily"
)
// userFacingError:保证内部错误码 cpdaily_cookie_expired 不会原样展示给用户。
//
// 线上问题:用户拿到 "获取签到表单失败: 获取当日任务列表失败: 解析任务列表响应失败:
// cpdaily_cookie_expired",既看不懂也没引导重新登录。
func TestUserFacingErrorHidesInternalCode(t *testing.T) {
cases := []struct {
name string
prefix string
err error
wantContains string
wantReLogin bool
wantNotSecret string
}{
{
name: "原始 ErrCookieExpired",
prefix: "获取签到表单失败: ",
err: cpd.ErrCookieExpired,
wantContains: "登录凭证已过期,请重新登录",
wantReLogin: true,
wantNotSecret: "cpdaily_cookie_expired",
},
{
name: "多层包装后仍能识别",
prefix: "获取签到表单失败: ",
err: fmt.Errorf("获取当日任务列表失败: %w", fmt.Errorf("解析任务列表响应失败: %w", cpd.ErrCookieExpired)),
wantContains: "登录凭证已过期,请重新登录",
wantReLogin: true,
wantNotSecret: "cpdaily_cookie_expired",
},
{
name: "ErrNeedReLogin",
prefix: "签到失败(晚查寝): ",
err: fmt.Errorf("%w: 续期失败", ErrNeedReLogin),
wantContains: "登录凭证已过期,请重新登录",
wantReLogin: true,
wantNotSecret: "cpdaily_cookie_expired",
},
{
name: "JSON 形式未登录",
prefix: "获取签到表单失败: ",
err: errors.New("接口返回: 未登录"),
wantContains: "登录凭证已过期,请重新登录",
wantReLogin: true,
wantNotSecret: "",
},
{
name: "普通错误原样保留",
prefix: "获取签到表单失败: ",
err: errors.New("网络超时"),
wantContains: "网络超时",
wantReLogin: false,
},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
msg, needLogin := userFacingError(c.prefix, c.err)
if needLogin != c.wantReLogin {
t.Fatalf("needReLogin=%v, want %v", needLogin, c.wantReLogin)
}
if !strings.Contains(msg, c.wantContains) {
t.Fatalf("文案 %q 应包含 %q", msg, c.wantContains)
}
if c.wantNotSecret != "" && strings.Contains(msg, c.wantNotSecret) {
t.Fatalf("文案 %q 不应包含内部错误码 %q", msg, c.wantNotSecret)
}
})
}
}
// nil 错误不应触发重新登录。
func TestUserFacingErrorNil(t *testing.T) {
msg, need := userFacingError("前缀: ", nil)
if need {
t.Fatal("nil 不应要求重新登录")
}
if msg != "前缀: " {
t.Fatalf("应原样返回前缀,实际 %q", msg)
}
}
+94 -2
View File
@@ -10,16 +10,19 @@ import (
"cpdaily-new/internal/database"
"cpdaily-new/internal/model"
"cpdaily-new/internal/utils"
"github.com/gofiber/fiber/v2"
)
// 邀请码规则:每个用户在注册(首次登录)时自动获得一个邀请码,不再提供手动生成接口。
// - 字符集去掉易混淆的 0/O、1/I/L,便于口头/截图分享;
// - 长度 6,字符集 31 个 → 约 8.9 亿种组合,配合唯一索引冲突重试,碰撞概率可忽略;
// - 长度 8,字符集 31 个 → 约 8.5 万亿种组合,配合唯一索引冲突重试,碰撞概率可忽略;
// - 使用 crypto/rand:旧实现以纳秒时间戳取模,可预测且并发注册时易生成相同码,
// 在 invite_code 唯一索引下会直接导致用户创建(登录)失败。
const (
inviteCodeChars = "ABCDEFGHJKMNPQRSTUVWXYZ23456789"
inviteCodeLength = 6
inviteCodeLength = 8
inviteCodeAttempts = 8
)
@@ -137,3 +140,92 @@ func BackfillInviteCodes() {
}
slog.Info("存量用户邀请码补齐完成", "total", len(users), "success", ok)
}
// resetInviteCode 为指定用户重新生成邀请码(旧码立即失效)。
//
// 唯一性由 invite_code 唯一索引 + 冲突重试保证(与首次生成同一套逻辑)。
// 用带条件的方式写库:仅当当前值仍是调用方读到的旧码时才更新,
// 避免两个并发重置请求互相覆盖、导致其中一个返回的码实际已失效。
func resetInviteCode(user *model.SysUser) (string, error) {
if user == nil {
return "", errors.New("用户不存在")
}
oldCode := strings.TrimSpace(user.InviteCode)
code, err := withUniqueInviteCode(func(code string) error {
q := database.DB.Model(&model.SysUser{}).Where("id = ?", user.ID)
if oldCode != "" {
// 乐观锁:旧码未被他人改动才允许替换
q = q.Where("invite_code = ?", oldCode)
}
res := q.Update("invite_code", code)
if res.Error != nil {
return res.Error
}
if res.RowsAffected == 0 {
return errInviteCodeChanged
}
return nil
})
if err != nil {
return "", err
}
user.InviteCode = code
return code, nil
}
var errInviteCodeChanged = errors.New("invite code changed concurrently")
// ResetInviteCode 用户主动重置自己的邀请码。
//
// 影响:旧邀请码立即失效——已用旧码注册的邀请关系不受影响(关系已落库),
// 但旧码不能再被新用户使用。前端需在操作前明确提示这一点。
func ResetInviteCode(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, "用户不存在")
}
// 没有码时先补齐,再重置(否则 oldCode 为空,乐观锁条件失效)
if strings.TrimSpace(user.InviteCode) == "" {
if err := ensureInviteCode(&user); err != nil {
return utils.FailServer(c, "生成邀请码失败,请稍后重试")
}
}
oldCode := user.InviteCode
code, err := resetInviteCode(&user)
if err != nil {
if errors.Is(err, errInviteCodeChanged) {
return utils.Fail(c, 409, "邀请码已被重置,请刷新页面后重试")
}
slog.Error("重置邀请码失败", "userId", userID, "error", err)
return utils.FailServer(c, "重置邀请码失败,请稍后重试")
}
slog.Info("用户重置邀请码", "userId", userID, "oldCode", oldCode, "newCode", code)
return utils.Success(c, fiber.Map{"inviteCode": code})
}
// inviteCodeLegacyLength 旧版邀请码长度。
//
// 2026-10 之前生成为 6 位,生产库中仍有大量存量用户沿用旧码
// (实测 551 个 6 位 / 1 个 8 位)。校验必须同时接受两种长度,
// 否则存量用户的码会被判为无效,导致他们无法被邀请。
const inviteCodeLegacyLength = 6
// isValidInviteCodeShape 校验邀请码的形态(长度 + 字符集),不查库。
//
// 长度接受 6(存量)与 8(当前)两种,字符集为 inviteCodeChars
// (已去掉易混淆的 0/O/1/L)。不合规的输入无需查库即可拒绝,
// 也避免把用户的任意长字符串当码去查。
func isValidInviteCodeShape(code string) bool {
if len(code) != inviteCodeLength && len(code) != inviteCodeLegacyLength {
return false
}
for i := 0; i < len(code); i++ {
if !strings.ContainsRune(inviteCodeChars, rune(code[i])) {
return false
}
}
return true
}
@@ -111,3 +111,94 @@ func TestGenerateRandomCode(t *testing.T) {
t.Fatal("两次生成不应相同")
}
}
// 重置邀请码:新旧码必须不同,且新码格式合规。
func TestResetInviteCodeProducesNewCode(t *testing.T) {
old, err := newInviteCode()
if err != nil {
t.Fatal(err)
}
seen := map[string]bool{old: true}
for i := 0; i < 100; i++ {
code, err := newInviteCode()
if err != nil {
t.Fatal(err)
}
if len(code) != inviteCodeLength {
t.Fatalf("新码长度应为 %d,实际 %q", inviteCodeLength, code)
}
if seen[code] {
t.Fatalf("生成重复码 %q", code)
}
seen[code] = true
}
}
// 8 位邀请码空间足够大:5000 次生成不应出现重复。
func TestInviteCodeLengthIsEight(t *testing.T) {
if inviteCodeLength != 8 {
t.Fatalf("邀请码长度应为 8,实际 %d", inviteCodeLength)
}
const n = 5000
seen := make(map[string]struct{}, n)
for i := 0; i < n; i++ {
code, err := newInviteCode()
if err != nil {
t.Fatal(err)
}
seen[code] = struct{}{}
}
// 31^8 ≈ 8.5e11,5000 个样本碰撞期望 ≈ 0
if dup := n - len(seen); dup > 0 {
t.Fatalf("8 位下 5000 次生成出现 %d 个重复", dup)
}
}
// resetInviteCode 的乐观锁:并发改动时返回 errInviteCodeChanged
func TestErrInviteCodeChangedIsDistinct(t *testing.T) {
if errors.Is(errInviteCodeChanged, errInviteCodeAlreadySet) {
t.Fatal("两个哨兵错误不应互相匹配")
}
if !errors.Is(errInviteCodeChanged, errInviteCodeChanged) {
t.Fatal("errors.Is 自反性失败")
}
}
// isValidInviteCodeShape:只校验形态(长度 + 字符集),不查库
func TestIsValidInviteCodeShape(t *testing.T) {
valid, err := newInviteCode()
if err != nil {
t.Fatal(err)
}
cases := []struct {
name string
code string
want bool
}{
{"正常 8 位", valid, true},
{"空串", "", false},
{"过短", "ABC", false},
{"过长", "ABCDEFGHJ", false},
{"含易混淆字符 0", "ABCD0EFG", false},
{"含易混淆字符 O", "ABCDOEFG", false},
{"含易混淆字符 1", "ABCD1EFG", false},
{"含易混淆字符 I", "ABCDIEFG", false},
{"含易混淆字符 L", "ABCDLEFG", false},
{"含小写", "abcdefgh", false},
{"含符号", "ABCD-EFG", false},
{"含中文", "邀请码邀请码邀请", false},
// 存量兼容:生产库仍有 551 个 6 位旧码,必须仍能正常绑定
{"存量 6 位码", "ABCDEF", true},
{"存量 6 位含易混淆字符", "ABCD0F", false},
{"5 位", "ABCDE", false},
{"7 位", "ABCDEFG", false},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
if got := isValidInviteCodeShape(c.code); got != c.want {
t.Fatalf("isValidInviteCodeShape(%q)=%v, want %v", c.code, got, c.want)
}
})
}
}
+238
View File
@@ -6,10 +6,13 @@ import (
"errors"
"fmt"
"log/slog"
"runtime/debug"
"strconv"
"strings"
"time"
"cpdaily-new/internal/database"
"cpdaily-new/internal/lock"
"cpdaily-new/internal/model"
"cpdaily-new/internal/ratelimit"
cpd "cpdaily-new/internal/service/cpdaily"
@@ -36,6 +39,29 @@ func GetPendingLeaves(c *fiber.Ctx) error {
cookieMap := cpd.ParseCookieJSON(user.Cookie)
host := getSchoolHost(user.SchoolID)
// 三易拓新学工5(如山东农业大学):走学校学工系统的请假记录
if schoolLeaveFlavor(&user, cookieMap) == cpd.LeaveFlavorXsgz {
sess, sErr := newXsgzSession(c.Context(), &user)
if sErr != nil {
slog.Error("建立学工系统会话失败", "userId", userID, "error", sErr)
return utils.Fail(c, 503, "学工系统登录失败: "+sErr.Error())
}
// 说明:学校的 /api/leave/record/batch-cancel-list 是**辅导员**接口
// (学生调用返回「当前用户没有辅导员销假权限」),因此学生端改用
// leave_page 拉全部记录,再由 ToLeaveItems 按状态标记 CanReport。
recs, _, pErr := sess.XsgzFetchLeavePage(c.Context(), 1, 50)
if pErr != nil {
slog.Error("获取学工系统请假记录失败", "userId", userID, "error", pErr)
return utils.Fail(c, 503, "获取请假记录失败: "+pErr.Error())
}
return utils.Success(c, sess.ToLeaveItems(recs))
}
// 其他独立学工系统:销假不经辅导猫,给出明确提示
if rejectExternalLeaveSystem(c, &user, cookieMap) {
return nil
}
// 应用套件分流:新套(leave-new)走独立链路
isNewFlavor := schoolLeaveFlavor(&user, cookieMap) == cpd.LeaveFlavorNew
@@ -72,6 +98,8 @@ type ExecuteLeaveRequest struct {
Address string `json:"address"` // 销假地址(无规定位置时必填)
ImageURL string `json:"imageUrl"` // 销假图片 URL(可选)
ApproverWid string `json:"approverWid"` // 审批人 userWid(reportApplyFlow=1 时为必填)
// ProjectID 销假所属请假类型(三易拓新学工5 必传 UUID)
ProjectID string `json:"projectId"`
}
// ExecuteLeave 执行销假(SSE 同步执行:POST 请求 + 流式事件返回进度与结果)。
@@ -104,6 +132,14 @@ func ExecuteLeave(c *fiber.Ctx) error {
if strings.TrimSpace(user.MobileCode) == "" {
return utils.Fail(c, 400, "请先在个人中心绑定设备码后再执行销假")
}
// 三易拓新学工5(如山东农业大学):销假提交到学校学工系统
if schoolLeaveFlavor(&user, cpd.ParseCookieJSON(user.Cookie)) == cpd.LeaveFlavorXsgz {
return executeXsgzLeaveCancel(c, &user, &req)
}
// 其他独立学工系统:销假不经辅导猫
if rejectExternalLeaveSystem(c, &user, cpd.ParseCookieJSON(user.Cookie)) {
return nil
}
// SSE 头
c.Set("Content-Type", "text/event-stream")
@@ -159,6 +195,16 @@ func ExecuteLeave(c *fiber.Ctx) error {
return
}
// 分布式锁:同一用户销假串行执行,避免并发请求重复提交学校端。
// 销假是同步阻塞流程,TTL 取 5 分钟已足够覆盖单次执行。
execLock, locked := lock.TryLock(fmt.Sprintf("exec:leave:%d", userID), 5*time.Minute)
if !locked {
slog.Warn("销假正在执行中,拒绝重复提交", "userId", userID)
sendEvent("task_error", map[string]interface{}{"message": "操作正在执行中,请勿重复提交", "busy": true})
return
}
defer execLock.Unlock()
// 1. 连接成功
sendEvent("connecting", map[string]interface{}{"status": "connecting", "message": "正在连接今日校园..."})
@@ -314,6 +360,11 @@ func GetLeaveApprovers(c *fiber.Ctx) error {
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)
@@ -415,3 +466,190 @@ func DeleteLeaveLog(c *fiber.Ctx) error {
database.DB.Delete(&log)
return utils.Success(c, nil)
}
// executeXsgzLeaveCancel 三易拓新学工5(如山东农业大学)提交销假。
//
// 契约源自前端 ActionPopup.vue:
//
// POST /api/leave/student_action/application_submit (multipart)
// { originalId: <原假单ID>, projectId, requestType:"CANCELLATION",
// fields[], cancellationApplyInArea, inArea, address, longitude, latitude }
//
// 即销假与请假共用同一提交接口,靠 requestType 区分。
func executeXsgzLeaveCancel(c *fiber.Ctx, user *model.SysUser, req *ExecuteLeaveRequest) error {
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) {
// 回调运行在独立写协程中,c.Context() 此时可能已失效,
// 必须用脱离请求生命周期的 context(见 streamCtx 注释)。
ctx, cancel := streamCtx()
defer cancel()
// 协程内的 panic 无法被 fiber 中间件 recover,会直接杀掉进程
// (前端表现为连接被掐断)。这里兜底,至少保证返回错误事件。
defer func() {
if r := recover(); r != nil {
slog.Error("销假流处理 panic", "userId", user.ID, "panic", r,
"stack", string(debug.Stack()))
d, _ := json.Marshal(map[string]interface{}{
"status": "error", "message": "服务内部错误,请稍后重试",
})
fmt.Fprintf(w, "event: task_error\ndata: %s\n\n", string(d))
w.Flush()
}
}()
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.CpLeaveLog{
UserID: user.ID,
LeaveID: req.LeaveID,
Title: "销假申请",
SchoolName: user.SchoolName,
Action: model.LeaveActionReport,
IsSuccess: isSuccess,
ErrorMessage: errMsg,
})
}
sendEvent("connecting", map[string]interface{}{"status": "connecting", "message": "正在连接学校学工系统..."})
sess, err := newXsgzSession(ctx, user)
if err != nil {
msg := "学工系统登录失败: " + err.Error()
slog.Error("xsgz 会话建立失败", "userId", user.ID, "error", err)
saveLog(false, msg)
sendEvent("task_error", map[string]interface{}{"status": "error", "message": msg})
return
}
sendEvent("submitting", map[string]interface{}{"status": "submitting", "message": "正在提交销假申请..."})
// projectId 是销假提交的必传项。前端列表里它放在 TypeWid,
// 但销假请求体里未必带上(线上实测 userId=1490 就是空值,
// 直接导致「缺少请假类型(projectId)」)。
// 这里不再依赖前端传值:为空时按 leaveId 回查学校详情补齐。
if req.ProjectID == "" {
if d, dErr := sess.XsgzFetchLeaveDetail(ctx, req.LeaveID); dErr != nil {
slog.Warn("xsgz 回查销假详情补 projectId 失败",
"userId", user.ID, "leaveId", req.LeaveID, "error", dErr)
} else if d != nil && d.ProjectID != "" {
req.ProjectID = d.ProjectID
slog.Info("xsgz 已回查补齐 projectId",
"userId", user.ID, "leaveId", req.LeaveID, "projectId", req.ProjectID)
}
}
if req.ProjectID == "" {
msg := "无法确定该请假记录所属的请假类型,请返回列表刷新后重试"
slog.Error("xsgz 销假缺少 projectId 且回查失败", "userId", user.ID, "leaveId", req.LeaveID)
saveLog(false, msg)
sendEvent("task_error", map[string]interface{}{"status": "error", "message": msg})
return
}
// 与前端一致:先按 (projectId, requestType) 拉取该申请的表单字段定义,
// 作为 fields 提交(ActionPopup.vue: C.value = await form_fields_for_add({projectId, requestType}))
formFields, ffErr := sess.XsgzFetchFormFields(ctx, req.ProjectID, "CANCELLATION")
if ffErr != nil {
slog.Warn("xsgz 获取销假表单字段失败,按空字段提交", "userId", user.ID, "error", ffErr)
formFields = nil
}
// 销假表单通常含"销假说明/原因",用地址兜底填充可见字段。
// 注意:上传类与地区类字段不能被文本值填充(地区类需要
// 「选中值,详情」拼接串,写地址会被学校端当成非法地区),
// 这两类在下面的 normalizeXsgzFields 里另有处理。
for _, f := range formFields {
if xsgzNonTextFillable(mapStr(f, "componentType")) {
continue
}
label := strings.ToLower(mapStr(f, "fieldZh") + mapStr(f, "fieldName") + mapStr(f, "label"))
if (strings.Contains(label, "说明") || strings.Contains(label, "原因")) && req.Address != "" {
f["value"] = req.Address
}
}
// 按前端 Ie() 语义清洗:上传类去 value、地区类清空、多选数组 join(",")
normalizeXsgzFields(formFields)
// 提交前预检:必填项无法自动填写时明确指出,避免含糊失败
if missing := checkXsgzUnfilledRequired(formFields); len(missing) > 0 {
msg := "该校销假表单还有必填项无法自动填写:" + strings.Join(missing, "、") +
",请前往学校学工系统办理"
slog.Warn("xsgz 销假表单存在未填必填项", "userId", user.ID, "missing", missing)
saveLog(false, msg)
sendEvent("task_error", map[string]interface{}{"status": "error", "message": msg})
return
}
// 销假地理范围校验(严格对齐前端 LeaveCancelLocation):
// v = actionType==="CANCELAPPLY" && cancellationApplyInArea
// if (v && !canPosition) { warning("不在销假申请范围内,无法销假"); return }
// 即:只有该校要求「必须在范围内申请」时才拦截;否则照常提交,
// 但仍把真实计算结果放进 inArea 上报给学校端。
inArea := true // 未配置范围视为不受限(前端:!r.value.length 时 return true)
if ranges, rErr := sess.XsgzFetchLocationRange(ctx, req.ProjectID); rErr != nil {
slog.Warn("xsgz 查询销假范围失败,按不限制处理", "userId", user.ID, "error", rErr)
} else if len(ranges) > 0 {
lng, lngErr := strconv.ParseFloat(req.Longitude, 64)
lat, latErr := strconv.ParseFloat(req.Latitude, 64)
if lngErr != nil || latErr != nil {
slog.Warn("xsgz 销假坐标不可解析,跳过范围判断",
"userId", user.ID, "lng", req.Longitude, "lat", req.Latitude)
} else {
inArea = cpd.InLocationRange(ranges, lng, lat)
}
}
// 该校若要求必须在范围内,且实际不在范围内,则与前端一致地拒绝提交
requiresInArea := recordRequiresInArea(ctx, sess, req.ProjectID, req.LeaveID)
if requiresInArea && !inArea {
msg := "当前位置不在学校要求的销假范围内,无法提交销假,请到校后再试"
slog.Warn("xsgz 销假位置不在许可范围内,拒绝提交",
"userId", user.ID, "leaveId", req.LeaveID, "inArea", inArea)
saveLog(false, msg)
sendEvent("task_error", map[string]interface{}{"status": "error", "message": msg})
return
}
body, err := sess.XsgzSubmitCancel(ctx, cpd.XsgzSubmitPayload{
ProjectID: req.ProjectID,
OriginalID: req.LeaveID,
Address: req.Address,
Longitude: req.Longitude,
Latitude: req.Latitude,
InArea: inArea,
Fields: formFields,
})
if err != nil {
msg := "提交销假失败: " + err.Error()
slog.Error("xsgz 提交销假失败", "userId", user.ID, "error", err)
saveLog(false, msg)
sendEvent("task_error", map[string]interface{}{"status": "error", "message": msg})
return
}
var result struct {
Code int `json:"code"`
Message string `json:"message"`
}
_ = json.Unmarshal(body, &result)
if result.Code != 0 {
msg := result.Message
if msg == "" {
msg = "提交销假失败"
}
saveLog(false, msg)
sendEvent("task_error", map[string]interface{}{"status": "error", "message": msg})
return
}
saveLog(true, "")
sendEvent("success", map[string]interface{}{"status": "success", "message": "销假申请提交成功!"})
slog.Info("xsgz 销假申请成功", "userId", user.ID, "leaveId", req.LeaveID)
})
return nil
}
+338 -3
View File
@@ -5,11 +5,13 @@ import (
"encoding/json"
"fmt"
"log/slog"
"runtime/debug"
"strconv"
"strings"
"time"
"cpdaily-new/internal/database"
"cpdaily-new/internal/lock"
"cpdaily-new/internal/model"
"cpdaily-new/internal/ratelimit"
cpd "cpdaily-new/internal/service/cpdaily"
@@ -45,6 +47,26 @@ func GetLeaveTypes(c *fiber.Ctx) error {
cookieMap := cpd.ParseCookieJSON(user.Cookie)
host := getSchoolHost(user.SchoolID)
// 三易拓新学工5(如山东农业大学):请假类型来自学校学工系统
if schoolLeaveFlavor(user, cookieMap) == cpd.LeaveFlavorXsgz {
sess, sErr := newXsgzSession(c.Context(), user)
if sErr != nil {
slog.Error("建立学工系统会话失败", "userId", user.ID, "error", sErr)
return utils.Fail(c, 503, "学工系统登录失败: "+sErr.Error())
}
projs, tErr := sess.XsgzFetchLeaveTypes(c.Context())
if tErr != nil {
slog.Error("获取学工系统请假类型失败", "userId", user.ID, "error", tErr)
return utils.Fail(c, 503, "获取请假类型失败: "+tErr.Error())
}
return utils.Success(c, xsgzTypesToLeaveTypes(projs))
}
// 其他独立学工系统:不走辅导猫链路,返回明确提示(避免用户看到莫名的 420 到期错误)
if rejectExternalLeaveSystem(c, user, cookieMap) {
return nil
}
flavor := schoolLeaveFlavor(user, cookieMap)
var types []cpd.LeaveType
@@ -100,6 +122,11 @@ func GetLeaveApplyApprovers(c *fiber.Ctx) error {
cookieMap := cpd.ParseCookieJSON(user.Cookie)
host := getSchoolHost(user.SchoolID)
// 学校独立学工系统:请假审批人不经辅导猫
if rejectExternalLeaveSystem(c, user, cookieMap) {
return nil
}
flavor := schoolLeaveFlavor(user, cookieMap)
if flavor == cpd.LeaveFlavorNew {
@@ -145,7 +172,10 @@ func GetLeaveApplyApprovers(c *fiber.Ctx) error {
// ExecuteLeaveApplyRequest 请假申请执行参数
type ExecuteLeaveApplyRequest struct {
LeaveType int `json:"leaveType"` // 类型编码
LeaveType int `json:"leaveType"` // 类型编码
// XsgzProjectID 三易拓新学工5 的请假类型 ID(UUID)。
// 这类系统的类型 ID 不是整数 code,前端从 /leave/types 的 projectId 字段回传。
XsgzProjectID string `json:"projectId"`
StartTime string `json:"startTime"` // yyyy-MM-dd HH:mm:ss
EndTime string `json:"endTime"` // yyyy-MM-dd HH:mm:ss
LeaveReason string `json:"leaveReason"` // 请假原因
@@ -243,6 +273,14 @@ func ExecuteLeaveApply(c *fiber.Ctx) error {
if strings.TrimSpace(user.MobileCode) == "" {
return utils.Fail(c, 400, "请先在个人中心绑定设备码后再执行请假")
}
// 三易拓新学工5(如山东农业大学):提交到学校学工系统
if schoolLeaveFlavor(&user, cpd.ParseCookieJSON(user.Cookie)) == cpd.LeaveFlavorXsgz {
return executeXsgzLeaveApply(c, &user, &req, imageURLs)
}
// 其他独立学工系统:请假提交不经辅导猫
if rejectExternalLeaveSystem(c, &user, cpd.ParseCookieJSON(user.Cookie)) {
return nil
}
// SSE 头
c.Set("Content-Type", "text/event-stream")
@@ -304,6 +342,15 @@ func ExecuteLeaveApply(c *fiber.Ctx) error {
return
}
// 分布式锁:同一用户请假串行执行,避免并发请求重复提交学校端。
execLock, locked := lock.TryLock(fmt.Sprintf("exec:leave-apply:%d", userID), 5*time.Minute)
if !locked {
slog.Warn("请假正在执行中,拒绝重复提交", "userId", userID)
sendEvent("task_error", map[string]interface{}{"message": "操作正在执行中,请勿重复提交", "busy": true})
return
}
defer execLock.Unlock()
sendEvent("connecting", map[string]interface{}{"status": "connecting", "message": "正在连接今日校园..."})
cookieMap := cpd.ParseCookieJSON(user.Cookie)
@@ -442,10 +489,10 @@ func ExecuteLeaveApply(c *fiber.Ctx) error {
// 续假(旧套):改走 /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)
form := cpd.BuildLeaveExtendForm(p, user.MobileCode, cpd.AndroidVersion, cpd.AppVersion, 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")
form := cpd.BuildLeaveApplyForm(p, user.MobileCode, cpd.AndroidVersion, cpd.AppVersion)
return cpd.SubmitLeaveApply(cm, host, form, req.Longitude, req.Latitude, user.MobileCode, user.StudentID)
}
err = submit(cookieMap)
@@ -503,3 +550,291 @@ func leaveApplyLogTitle(leaveType int, startTime, endTime string) string {
}
return title
}
// executeXsgzLeaveApply 三易拓新学工5(如山东农业大学)提交请假。
//
// 与辅导猫链路完全独立:用 CASTGC 换 xsgz 会话后调用
// POST /api/leave/student_action/application_submit(multipart/form-data)。
// 事件流与辅导猫保持一致(connecting → submitting → success/task_error)。
func executeXsgzLeaveApply(c *fiber.Ctx, user *model.SysUser, req *ExecuteLeaveApplyRequest, imageURLs []string) error {
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) {
// 回调运行在独立写协程中,c.Context() 此时可能已失效,
// 必须用脱离请求生命周期的 context(见 streamCtx 注释)。
ctx, cancel := streamCtx()
defer cancel()
// 协程内的 panic 无法被 fiber 中间件 recover,会直接杀掉进程
// (前端表现为连接被掐断)。这里兜底,至少保证返回错误事件。
defer func() {
if r := recover(); r != nil {
slog.Error("请假流处理 panic", "userId", user.ID, "panic", r,
"stack", string(debug.Stack()))
d, _ := json.Marshal(map[string]interface{}{
"status": "error", "message": "服务内部错误,请稍后重试",
})
fmt.Fprintf(w, "event: task_error\ndata: %s\n\n", string(d))
w.Flush()
}
}()
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()
}
title := leaveApplyLogTitle(req.LeaveType, req.StartTime, req.EndTime)
saveLog := func(isSuccess bool, errMsg string) {
database.DB.Create(&model.CpLeaveLog{
UserID: user.ID,
LeaveID: "apply_" + strconv.FormatInt(time.Now().Unix(), 10),
Title: title,
SchoolName: user.SchoolName,
Action: model.LeaveActionApply,
IsSuccess: isSuccess,
ErrorMessage: errMsg,
})
}
sendEvent("connecting", map[string]interface{}{"status": "connecting", "message": "正在连接学校学工系统..."})
sess, err := newXsgzSession(ctx, user)
if err != nil {
msg := "学工系统登录失败: " + err.Error()
slog.Error("xsgz 会话建立失败", "userId", user.ID, "error", err)
saveLog(false, msg)
sendEvent("task_error", map[string]interface{}{"status": "error", "message": msg})
return
}
sendEvent("submitting", map[string]interface{}{"status": "submitting", "message": "正在提交请假申请..."})
// 与前端一致:先按 (projectId, requestType) 拉取表单字段定义,再把用户填写的值
// 写回各字段的 value 后作为 fields 提交。
// (index-C4CfkeDm.js: f.value = form_fields_for_add(...),提交前 forEach 补 value)
formFields, ffErr := sess.XsgzFetchFormFields(ctx, req.XsgzProjectID, "LEAVE")
if ffErr != nil {
slog.Warn("xsgz 获取请假表单字段失败,按空字段提交", "userId", user.ID, "error", ffErr)
formFields = nil
}
fillXsgzFieldValues(formFields, req)
// 按前端 Ie() 语义清洗:上传类去 value、多选数组 join(",")
normalizeXsgzFields(formFields)
// 提交前预检:学校表单可能包含本平台无法提供值的必填字段
// (如"是否离校"单选)。与其提交后得到含糊失败,不如明确指出。
if missing := checkXsgzUnfilledRequired(formFields); len(missing) > 0 {
msg := "该校请假表单还有必填项无法自动填写:" + strings.Join(missing, "、") +
",请前往学校学工系统办理"
slog.Warn("xsgz 表单存在未填必填项", "userId", user.ID, "missing", missing)
saveLog(false, msg)
sendEvent("task_error", map[string]interface{}{"status": "error", "message": msg})
return
}
payload := cpd.XsgzSubmitPayload{
ProjectID: req.XsgzProjectID,
RequestType: "LEAVE",
StartTime: req.StartTime,
EndTime: req.EndTime,
Address: req.LocationAddr,
Longitude: req.Longitude,
Latitude: req.Latitude,
Fields: formFields,
}
body, err := sess.XsgzSubmitLeave(ctx, payload)
if err != nil {
msg := "提交请假失败: " + err.Error()
slog.Error("xsgz 提交请假失败", "userId", user.ID, "error", err)
saveLog(false, msg)
sendEvent("task_error", map[string]interface{}{"status": "error", "message": msg})
return
}
// 解析返回:code=0 视为成功
var result struct {
Code int `json:"code"`
Message string `json:"message"`
}
_ = json.Unmarshal(body, &result)
if result.Code != 0 {
msg := result.Message
if msg == "" {
msg = "提交请假失败"
}
saveLog(false, msg)
sendEvent("task_error", map[string]interface{}{"status": "error", "message": msg})
return
}
saveLog(true, "")
sendEvent("success", map[string]interface{}{"status": "success", "message": "请假申请提交成功!"})
slog.Info("xsgz 请假申请成功", "userId", user.ID, "projectId", req.XsgzProjectID,
"start", req.StartTime, "end", req.EndTime)
})
return nil
}
// fillXsgzFieldValues 把用户填写的值写回 xsgz 表单字段定义。
//
// 依据前端 transformFields 的字段结构:
//
// form_fields_for_add 返回 { fieldId, fieldName, componentType, fieldType, required, options[] }
//
// 提交时前端遍历字段,把用户输入按 fieldId 写回 value。本平台只有「请假原因」
// 一项自由文本,因此:
// 1. 优先按标签(fieldName 含"原因/事由/说明")精确匹配;
// 2. 未命中时,退化为「必填的文本类字段」(fieldType 为 TEXT/TEXTAREA/LONGTEXT)
// 一律填入请假原因——该校表单通常只有一个原因字段,这样可以避免因
// 学校自定义字段名(如"请假事由说明""申请理由")导致漏填。
//
// 选择类字段(RADIO/CHECKBOX/SELECT 等)不猜测,留空由学校端校验。
func fillXsgzFieldValues(fields []map[string]interface{}, req *ExecuteLeaveApplyRequest) {
if len(fields) == 0 || req.LeaveReason == "" {
return
}
textTypes := map[string]bool{"TEXT": true, "TEXTAREA": true, "LONGTEXT": true}
// 第一轮:按标签匹配
matched := false
for _, f := range fields {
if xsgzNonTextFillable(mapStr(f, "componentType")) {
continue // 上传类/地区类字段不该被文本值覆盖
}
label := strings.ToLower(mapStr(f, "fieldZh") + mapStr(f, "fieldName") + mapStr(f, "label"))
if strings.Contains(label, "原因") || strings.Contains(label, "事由") || strings.Contains(label, "说明") {
f["value"] = req.LeaveReason
matched = true
}
}
if matched {
return
}
// 第二轮:必填文本字段兜底
for _, f := range fields {
ft := strings.ToUpper(mapStr(f, "fieldType"))
required, _ := f["required"].(bool)
if required && textTypes[ft] {
f["value"] = req.LeaveReason
}
}
}
// mapStr 安全取 map 中的字符串值
func mapStr(m map[string]interface{}, k string) string {
if v, ok := m[k]; ok && v != nil {
return fmt.Sprintf("%v", v)
}
return ""
}
// checkXsgzUnfilledRequired 返回仍未填值的必填字段名称列表。
//
// 学校表单由后台配置,可能包含本平台无法提供值的必填字段
// (如"是否离校"单选、"请假类型细分"下拉)。与其让提交到学校端后
// 得到含糊的失败,不如在提交前明确告知缺哪些字段。
func checkXsgzUnfilledRequired(fields []map[string]interface{}) []string {
var missing []string
for _, f := range fields {
required, _ := f["required"].(bool)
if !required {
continue
}
// 上传类字段由 uploadFiles 承载,没有 value 属正常,
// 不应算作「未填写」(本平台不代传附件,由学校端按需处理)。
// 注意:地区类(regions)不能在此跳过——它就是要靠 value 承载,
// 我们没有数据就必然提交空值,必须拦下并告知用户。
if xsgzUploadComponentTypes[mapStr(f, "componentType")] {
continue
}
if v, ok := f["value"]; ok && v != nil && fmt.Sprintf("%v", v) != "" {
continue
}
name := mapStr(f, "fieldName")
if name == "" {
name = mapStr(f, "fieldId")
}
if name == "" {
name = "未命名字段"
}
missing = append(missing, name)
}
return missing
}
// xsgzUploadComponentTypes 前端 Ie() 里走 uploadFiles 分支的组件类型。
var xsgzUploadComponentTypes = map[string]bool{
"imageUpload": true,
"fileUpload": true,
"smoothSignature": true,
}
// xsgzRegionComponentType 地区选择组件。
//
// 前端 Ie() 对它的处理是把「选中值」与「详情」拼接后写入 value:
//
// s = (e[fieldId+"_regionsDetail"] ?? e[fieldId+"_detail"])?.replace(/,/g,",")?.trim() || ""
// o.value = 选中值 ? (s ? `${选中值},${s}` : 选中值) : (s || n)
//
// 本平台不提供地区选择,也就没有 _regionsDetail/_detail 可拼接,
// 因此不能把「请假原因」当成地区值写进去——那会产生学校端无法解析的
// 非法地区串。这类字段一律留空,由学校端校验或用户在学工系统内补填。
const xsgzRegionComponentType = "regions"
// xsgzNonTextFillable 判断字段是否不应被文本值(如请假原因)填充。
func xsgzNonTextFillable(componentType string) bool {
return xsgzUploadComponentTypes[componentType] || componentType == xsgzRegionComponentType
}
// normalizeXsgzFields 按前端 Ie()(studentForm-Dx5Fpjfs.js)的语义净化字段定义。
//
// 前端对每个字段按 componentType 分三支处理,直接把表单值映射到提交对象:
//
// regions: value = 拼接后的地名(含 _regionsDetail / _detail 后缀处理)
// imageUpload / fileUpload / smoothSignature:
// o.uploadFiles = 文件列表(并 delete content/url),**不设 value**
// 其余: o.value = Array.isArray(n) ? n.join(",") : n
//
// 我们的实现只填了 value,存在两个问题:
// 1. 上传类字段被塞了 value(前端不会这样提交),且完全没有 uploadFiles;
// 2. 多选类字段若是数组,前端会 join(","),我们直接保留数组,
// 经 xsgzFlatten 展开成 fields[n].value[k] 这种学校端读不到的键。
//
// 本函数只做「清洗」:去掉不该出现的内容,不臆造用户未提供的数据。
func normalizeXsgzFields(fields []map[string]interface{}) {
for _, f := range fields {
ct := mapStr(f, "componentType")
if xsgzUploadComponentTypes[ct] {
// 上传类:value 不应存在(由 uploadFiles 承载)。
// 本平台不代传附件,保持为空即可。
delete(f, "value")
continue
}
if ct == xsgzRegionComponentType {
// 地区类:前端 value 是「选中值,详情」的拼接串。
// 我们既无选中值也无 _regionsDetail/_detail,若残留 value
// 会被学校端当作非法地区解析,故清空但保留键(空值由
// xsgzFlatten 跳过,不会提交)。
f["value"] = ""
continue
}
// 其余:若 value 是数组,按前端规则 join(",")(多选拼接)
if arr, ok := f["value"].([]interface{}); ok {
parts := make([]string, 0, len(arr))
for _, v := range arr {
if v == nil {
continue
}
s := fmt.Sprintf("%v", v)
if s != "" {
parts = append(parts, s)
}
}
f["value"] = strings.Join(parts, ",")
}
}
}
@@ -0,0 +1,356 @@
package handler
import (
"encoding/json"
"io"
"net/http/httptest"
"os"
"strings"
"testing"
"time"
"cpdaily-new/internal/database"
"cpdaily-new/internal/model"
"github.com/gofiber/fiber/v2"
"gorm.io/driver/mysql"
"gorm.io/gorm"
"gorm.io/gorm/logger"
)
// 销假(POST /api/leave/execute)端到端契约测试。
//
// 覆盖 ExecuteLeave 在真正触达学校端之前的全部前置校验分支:
// 参数校验 → 用户存在 → Cookie 登录态 → 设备码 → 学校 flavor 分流。
// 这些分支不需要真实 sdau 账号,也不产生任何外网请求。
//
// 需要测试库(同 INVITE_IT_DSN 约定,请指向一次性库,会建表/清表):
//
// LEAVE_IT_DSN='root:test@tcp(127.0.0.1:13306)/leavetest?charset=utf8mb4&parseTime=True&loc=Local' \
// go test ./internal/handler/ -run ExecuteLeave -count=1 -v
//
// 未覆盖:扣额度与学校端真实提交(依赖真实 cookie、待销假记录与学校业务校验)。
const testCookieJSON = `{"MOD_AUTH_CAS":"ST-test"}`
// setupLeaveIT 用测试库接管 database.DB,并重建 SysUser 表。
func setupLeaveIT(t *testing.T) {
t.Helper()
dsn := os.Getenv("LEAVE_IT_DSN")
if dsn == "" {
t.Skip("未设置 LEAVE_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)
}
prev := database.DB
database.DB = db
t.Cleanup(func() { database.DB = prev })
}
// newLeaveApp 构造只挂 ExecuteLeave 的最小 app,模拟 AuthRequired 已放行并写入 userId。
func newLeaveApp(userID uint) *fiber.App {
app := fiber.New()
app.Post("/api/leave/execute", func(c *fiber.Ctx) error {
c.Locals("userId", userID)
return ExecuteLeave(c)
})
return app
}
// doLeave 发起一次销假请求,返回状态码与响应体。
func doLeave(t *testing.T, app *fiber.App, body string) (int, string) {
t.Helper()
req := httptest.NewRequest("POST", "/api/leave/execute", strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
resp, err := app.Test(req, -1)
if err != nil {
t.Fatalf("请求失败: %v", err)
}
defer resp.Body.Close()
raw, err := io.ReadAll(resp.Body)
if err != nil {
t.Fatalf("读取响应失败: %v", err)
}
return resp.StatusCode, string(raw)
}
// createLeaveUser 建一个带 Cookie / 设备码 / 学校的用户(默认 0 次、非 VIP)。
func createLeaveUser(t *testing.T, username, schoolID, cookie, mobileCode string) model.SysUser {
t.Helper()
return createLeaveUserFull(t, username, schoolID, cookie, mobileCode, 0, nil)
}
// createLeaveUserFull 额外指定可执行次数与 VIP 到期时间(nil 表示非 VIP)。
func createLeaveUserFull(t *testing.T, username, schoolID, cookie, mobileCode string, signCount float64, vipExpire *time.Time) model.SysUser {
t.Helper()
u := model.SysUser{
Username: username,
Password: "x",
SchoolID: schoolID,
SchoolName: "测试学校",
StudentID: username,
Cookie: cookie,
MobileCode: mobileCode,
SignCount: signCount,
VipExpireAt: vipExpire,
}
if err := database.DB.Create(&u).Error; err != nil {
t.Fatalf("插入用户失败: %v", err)
}
return u
}
// setDeduct 写入销假扣减额度系统设置。
func setDeduct(t *testing.T, reportValue string) {
t.Helper()
if err := database.DB.AutoMigrate(&model.SystemConfig{}); err != nil {
t.Fatalf("建 SystemConfig 表失败: %v", err)
}
cfg := model.SystemConfig{Key: settingDeductReport, Value: reportValue}
if err := database.DB.Create(&cfg).Error; err != nil {
t.Fatalf("写入扣减额度失败: %v", err)
}
}
// 缺 LeaveID / 经纬度 时必须 400 且不查库、不发外部请求。
func TestExecuteLeaveRejectsMissingParams(t *testing.T) {
setupLeaveIT(t)
u := createLeaveUser(t, "p1", "ynu", testCookieJSON, "dev-1")
app := newLeaveApp(u.ID)
cases := []struct {
name string
body string
want string
}{
{"全空", `{}`, "销假经纬度不能为空"},
{"缺经纬度", `{"leaveId":"L1"}`, "销假经纬度不能为空"},
{"缺经度", `{"leaveId":"L1","latitude":"25.0"}`, "销假经纬度不能为空"},
{"缺纬度", `{"leaveId":"L1","longitude":"102.7"}`, "销假经纬度不能为空"},
{"只有空白字符", `{"leaveId":" ","longitude":" ","latitude":" "}`, "销假经纬度不能为空"},
{"非法 JSON", `{`, "请求参数错误"},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
code, body := doLeave(t, app, c.body)
if code != 400 {
t.Fatalf("状态码 = %d, want 400; body=%s", code, body)
}
if !strings.Contains(body, c.want) {
t.Fatalf("响应缺少 %q: %s", c.want, body)
}
})
}
}
// 用户不存在时必须 401,而不是 500。
func TestExecuteLeaveRejectsUnknownUser(t *testing.T) {
setupLeaveIT(t)
app := newLeaveApp(999999)
code, body := doLeave(t, app, `{"leaveId":"L1","longitude":"102.7","latitude":"25.0"}`)
if code != 401 {
t.Fatalf("状态码 = %d, want 401; body=%s", code, body)
}
if !strings.Contains(body, "用户不存在") {
t.Fatalf("响应缺少提示: %s", body)
}
}
// Cookie 为空时必须提示重新登录,不得继续走学校端。
func TestExecuteLeaveRejectsEmptyCookie(t *testing.T) {
setupLeaveIT(t)
u := createLeaveUser(t, "p2", "ynu", "", "dev-1")
app := newLeaveApp(u.ID)
code, body := doLeave(t, app, `{"leaveId":"L1","longitude":"102.7","latitude":"25.0"}`)
if code != 400 {
t.Fatalf("状态码 = %d, want 400; body=%s", code, body)
}
if !strings.Contains(body, "请先登录") {
t.Fatalf("响应缺少提示: %s", body)
}
}
// 设备码为空时必须拦截,避免学校端风控异常。
func TestExecuteLeaveRejectsEmptyMobileCode(t *testing.T) {
setupLeaveIT(t)
u := createLeaveUser(t, "p3", "ynu", testCookieJSON, "")
app := newLeaveApp(u.ID)
code, body := doLeave(t, app, `{"leaveId":"L1","longitude":"102.7","latitude":"25.0"}`)
if code != 400 {
t.Fatalf("状态码 = %d, want 400; body=%s", code, body)
}
if !strings.Contains(body, "绑定设备码") {
t.Fatalf("响应缺少提示: %s", body)
}
}
// sdau 属于独立学工系统:销假必须分流到学工系统,不得落到辅导猫分支。
// 这是 sdau 销假最关键的分流契约——走错分支会让用户看到辅导猫的 420「产品已到期」。
//
// 学工销假同样是 SSE 流:先发 connecting,会话失败再发 task_error。
// 测试环境无真实学校网络,故断言「流内出现学工系统文案且不出现辅导猫」。
func TestExecuteLeaveRoutesXsgzSchoolAwayFromCounselor(t *testing.T) {
setupLeaveIT(t)
u := createLeaveUser(t, "p4", "sdau", testCookieJSON, "dev-1")
app := newLeaveApp(u.ID)
code, body := doLeave(t, app, `{"leaveId":"L1","longitude":"102.7","latitude":"25.0"}`)
if code != 200 {
t.Fatalf("SSE 流状态码应为 200,实际 = %d; body=%s", code, body)
}
if !strings.Contains(body, "event: connecting") {
t.Fatalf("应发出 connecting 事件: %s", body)
}
// 没有真实 cookie,会话必失败 → 必须收到 task_error 且文案指向学工系统。
if !strings.Contains(body, "event: task_error") {
t.Fatalf("无有效会话时应发 task_error: %s", body)
}
if !strings.Contains(body, "学工系统") {
t.Fatalf("错误文案应指向学工系统而非辅导猫: %s", body)
}
if strings.Contains(body, "辅导猫") {
t.Fatalf("sdau 不应走辅导猫分支: %s", body)
}
}
// 校验顺序:参数错误必须优先于查库,避免无效请求打到数据库。
func TestExecuteLeaveParamCheckedBeforeDatabase(t *testing.T) {
// 不建表:若先查库会因表不存在而 500。
dsn := os.Getenv("LEAVE_IT_DSN_NO_MIGRATE")
if dsn == "" {
t.Skip("未设置 LEAVE_IT_DSN_NO_MIGRATE,跳过校验顺序测试")
}
db, err := gorm.Open(mysql.Open(dsn), &gorm.Config{
Logger: logger.Default.LogMode(logger.Silent),
})
if err != nil {
t.Fatalf("连接测试库失败: %v", err)
}
_ = db.Migrator().DropTable(&model.SysUser{})
prev := database.DB
database.DB = db
t.Cleanup(func() {
database.DB = prev
_ = db.Migrator().CreateTable(&model.SysUser{})
})
app := newLeaveApp(1)
code, body := doLeave(t, app, `{}`)
if code != 400 {
t.Fatalf("参数校验应先于查库,状态码 = %d, want 400; body=%s", code, body)
}
if !strings.Contains(body, "销假经纬度不能为空") {
t.Fatalf("响应缺少参数校验提示: %s", body)
}
}
// 响应必须是统一 JSON 结构(code/message),便于前端统一处理。
func TestExecuteLeaveErrorResponseShape(t *testing.T) {
setupLeaveIT(t)
u := createLeaveUser(t, "p5", "ynu", testCookieJSON, "")
app := newLeaveApp(u.ID)
_, body := doLeave(t, app, `{"leaveId":"L1","longitude":"102.7","latitude":"25.0"}`)
var resp struct {
Code int `json:"code"`
Message string `json:"message"`
}
if err := json.Unmarshal([]byte(body), &resp); err != nil {
t.Fatalf("响应不是合法 JSON: %v; body=%s", err, body)
}
if resp.Code != 400 || resp.Message == "" {
t.Fatalf("响应结构不符合契约: %+v", resp)
}
}
// 非 VIP 且次数不足时必须拒绝,且不得触达学校端。
// 销假默认扣 1 次,用户 0 次 → 流内应发 task_error「可用次数不足」。
func TestExecuteLeaveRejectsInsufficientSignCount(t *testing.T) {
setupLeaveIT(t)
u := createLeaveUser(t, "p6", "ynu", testCookieJSON, "dev-1") // 0 次、非 VIP
app := newLeaveApp(u.ID)
code, body := doLeave(t, app, `{"leaveId":"L1","longitude":"102.7","latitude":"25.0"}`)
if code != 200 {
t.Fatalf("SSE 流状态码应为 200,实际 = %d; body=%s", code, body)
}
if !strings.Contains(body, "event: task_error") {
t.Fatalf("次数不足应发 task_error: %s", body)
}
if !strings.Contains(body, "可用次数不足") {
t.Fatalf("错误文案应提示次数不足: %s", body)
}
// 不得进入学校端连接阶段
if strings.Contains(body, "event: connecting") {
t.Fatalf("次数不足时不应继续连接学校端: %s", body)
}
}
// 额度为 0 时(后台可配)即使 0 次也应放行,验证「0 = 不扣费」语义。
func TestExecuteLeaveZeroDeductAllowsZeroCount(t *testing.T) {
setupLeaveIT(t)
setDeduct(t, "0")
u := createLeaveUser(t, "p7", "ynu", testCookieJSON, "dev-1")
app := newLeaveApp(u.ID)
code, body := doLeave(t, app, `{"leaveId":"L1","longitude":"102.7","latitude":"25.0"}`)
if code != 200 {
t.Fatalf("状态码 = %d, want 200; body=%s", code, body)
}
if strings.Contains(body, "可用次数不足") {
t.Fatalf("额度 0 时不应拦截: %s", body)
}
// 0 次但额度 0 → 放行,进入连接学校端阶段
if !strings.Contains(body, "event: connecting") {
t.Fatalf("额度 0 时应继续连接学校端: %s", body)
}
}
// VIP(未过期)免扣次数:0 次也应放行。
func TestExecuteLeaveVipBypassesSignCount(t *testing.T) {
setupLeaveIT(t)
future := time.Now().Add(24 * time.Hour)
u := createLeaveUserFull(t, "p8", "ynu", testCookieJSON, "dev-1", 0, &future)
app := newLeaveApp(u.ID)
code, body := doLeave(t, app, `{"leaveId":"L1","longitude":"102.7","latitude":"25.0"}`)
if code != 200 {
t.Fatalf("状态码 = %d, want 200; body=%s", code, body)
}
if strings.Contains(body, "可用次数不足") {
t.Fatalf("VIP 应免扣次数: %s", body)
}
if !strings.Contains(body, "event: connecting") {
t.Fatalf("VIP 应继续连接学校端: %s", body)
}
}
// VIP 已过期则不免扣:0 次时仍应被拦截。
func TestExecuteLeaveExpiredVipStillDeducts(t *testing.T) {
setupLeaveIT(t)
past := time.Now().Add(-24 * time.Hour)
u := createLeaveUserFull(t, "p9", "ynu", testCookieJSON, "dev-1", 0, &past)
app := newLeaveApp(u.ID)
code, body := doLeave(t, app, `{"leaveId":"L1","longitude":"102.7","latitude":"25.0"}`)
if code != 200 {
t.Fatalf("状态码 = %d, want 200; body=%s", code, body)
}
if !strings.Contains(body, "可用次数不足") {
t.Fatalf("VIP 过期应照常扣次数: %s", body)
}
}
+5
View File
@@ -36,6 +36,11 @@ func GetLeaveExtendPrefill(c *fiber.Ctx) error {
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)
+147
View File
@@ -1,14 +1,104 @@
package handler
import (
"context"
"fmt"
"log/slog"
"sync"
"time"
"cpdaily-new/internal/model"
cpd "cpdaily-new/internal/service/cpdaily"
"cpdaily-new/internal/utils"
"github.com/gofiber/fiber/v2"
)
// xsgzTypesToLeaveTypes 把 xsgz 请假项目转换为平台通用的 LeaveType 契约。
//
// 该校请假类型只有 id/text,没有旧契约里的审批人/附件/字数等开关,
// 因此按最小可用集构造:不需要审批人、不允许附件、不限制字数。
func xsgzTypesToLeaveTypes(projs []cpd.XsgzLeaveType) []cpd.LeaveType {
out := make([]cpd.LeaveType, 0, len(projs))
for i, p := range projs {
out = append(out, cpd.LeaveType{
Code: i + 1,
ProjectID: p.ID, // 真实 UUID,提交时用它而非 code
Name: p.Text,
NeedApproval: 0,
EnableAttach: 0,
})
}
return out
}
// newXsgzSession 用当前用户的辅导猫会话(CASTGC)换取 xsgz 学工系统的 access_token 会话。
//
// 链路:辅导猫登录 → CASTGC → CAS 换 service 指向 {xsgz}/oauth/callback 的票据
// → /api/cas/callback(form-urlencoded ticket=密文)→ access_token。
func newXsgzSession(ctx context.Context, user *model.SysUser) (*cpd.XsgzSession, error) {
cookieMap := cpd.ParseCookieJSON(user.Cookie)
if cookieMap["CASTGC"] == "" {
return nil, fmt.Errorf("登录凭证缺少 CASTGC,请重新登录")
}
service := cpd.XsgzCasServiceURL(user.SchoolID)
if service == "" {
return nil, fmt.Errorf("该校未配置学工系统地址")
}
// 用 CASTGC 换取指向学工系统的 CAS 票据
ticket, err := cpd.CasTicketForService(ctx, cpd.CasServiceConfig{
CasBase: getXsgzCasBase(user.SchoolID),
Service: service,
CASTGC: cookieMap["CASTGC"],
Username: user.StudentID,
})
if err != nil {
return nil, err
}
return cpd.XsgzLogin(ctx, user.SchoolID, ticket)
}
// getXsgzCasBase 返回该校 CAS 服务根地址(xsgz 学校与辅导猫共用同一 CAS)
func getXsgzCasBase(schoolID string) string {
switch schoolID {
case "sdau":
return "https://auths.sdau.edu.cn/authserver"
default:
return ""
}
}
// 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 拦截)
@@ -28,6 +118,17 @@ 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
@@ -146,3 +247,49 @@ func leaveNewApproversToLeaveApprovers(list []cpd.LeaveNewApprover) []cpd.LeaveA
}
return out
}
// streamCtx 为 SSE 的 stream writer 回调提供安全的 context。
//
// 背景:SetBodyStreamWriter 的回调在独立的写协程中执行,此时 fasthttp 的
// *RequestCtx 可能已被归还复用。若直接把 c.Context() 交给 resty(SetContext),
// resty 内部会 context.WithDeadline → propagateCancel → parent.Done(),
// 而 RequestCtx.Done() 直接解引用 ctx.s,一旦 ctx 已被回收就 SIGSEGV。
// 该 panic 发生在协程里,fiber 的 recover 中间件捕获不到,会导致整个进程退出,
// 前端表现为连接被掐断(「连接失败,请检查网络」)。
//
// 因此进入回调前必须先取值,回调内只用这个脱离请求生命周期的 context。
// 带超时:提交链路有多步上游请求,避免某一跳卡住时协程永久泄漏。
func streamCtx() (context.Context, context.CancelFunc) {
return context.WithTimeout(context.Background(), 3*time.Minute)
}
// recordRequiresInArea 判断该销假记录是否要求在指定地理范围内申请。
//
// 前端从详情对象取该标记并作为 prop 传给提交弹窗:
//
// "cancellation-apply-in-area": c.value?.cancellationApplyInArea === true
// || c.value?.cancellationApplyInArea === "是"
//
// 其中 c.value 来自 leave_detail(listType==="apply")。
// 后端不复用前端传值(客户端可篡改,且前端可能未传),改为按 leaveId 精确重查;
// 详情查不到时回退到列表查找;两者都失败则保守返回 false(不额外拦截,
// 交由学校端最终校验)。
func recordRequiresInArea(ctx context.Context, sess *cpd.XsgzSession, projectID, leaveID string) bool {
if d, err := sess.XsgzFetchLeaveDetail(ctx, leaveID); err != nil {
slog.Warn("xsgz 查询请假详情失败,回退列表查找", "leaveId", leaveID, "error", err)
} else if d != nil {
return d.RequiresInArea()
}
recs, _, err := sess.XsgzFetchLeavePage(ctx, 1, 50)
if err != nil {
slog.Warn("xsgz 查询请假记录失败,跳过销假范围强制校验", "leaveId", leaveID, "error", err)
return false
}
for _, r := range recs {
if r.ID == leaveID {
return r.RequiresInArea()
}
}
return false
}
@@ -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)
}
}
@@ -0,0 +1,76 @@
package handler
import (
"testing"
"time"
"github.com/valyala/fasthttp"
)
// 回归测试:SSE stream writer 回调中不能使用 fasthttp 的 RequestCtx。
//
// 线上事故(v1.2.158):销假接口在 stream writer 里调用
// newXsgzSession(c.Context(), ...),而该回调运行在 fasthttp 的独立写协程中,
// 此时 RequestCtx 已被 reset(ctx.s == nil)。resty 的 SetContext 会走
// context.WithDeadline → propagateCancel → parent.Done(),
// 而 RequestCtx.Done() 解引用 ctx.s 直接 SIGSEGV:
//
// panic: runtime error: invalid memory address or nil pointer dereference
// [signal SIGSEGV: ... addr=0x560]
// fasthttp.(*RequestCtx).Done(...) server.go:2726
//
// panic 发生在协程里,fiber 的 recover 中间件拦不住,整个进程退出,
// 前端 SSE 连接被掐断,只能显示「连接失败,请检查网络」。
// TestStreamCtxIsUsable 保证 streamCtx 返回的 context 是完整可用的。
func TestStreamCtxIsUsable(t *testing.T) {
ctx, cancel := streamCtx()
defer cancel()
if ctx == nil {
t.Fatal("streamCtx 不应返回 nil")
}
// 必须支持 Done():resty 在 SetContext 后依赖它注册取消传播
done := ctx.Done()
if done == nil {
t.Fatal("Done() 不应为 nil,否则 resty 内部 propagateCancel 会出错")
}
// 必须有 deadline:避免上游某一跳卡住导致协程永不释放
dl, ok := ctx.Deadline()
if !ok {
t.Fatal("streamCtx 应带 deadline")
}
if time.Until(dl) <= 0 {
t.Fatal("deadline 应在未来")
}
// 取 Done 不应 panic
select {
case <-done:
t.Fatal("新建的 context 不应已取消")
default:
}
}
// TestRequestCtxDonePanicsAfterReset 直接复现线上崩溃的根因。
//
// 零值的 fasthttp.RequestCtx 即 ctx.s == nil,等价于 stream writer 回调中
// 被 reset 之后的状态。调用其 Done() 会解引用 nil 的 ctx.s 而 SIGSEGV
// (server.go:2726: func (ctx *RequestCtx) Done() { return ctx.s.done })。
//
// 该用例存在的意义:把「为什么不能在回调里用 c.Context()」固化下来。
// 若将来 fasthttp 改成安全返回 nil,此用例会 skip,届时可考虑移除 streamCtx。
func TestRequestCtxDonePanicsAfterReset(t *testing.T) {
var rctx fasthttp.RequestCtx // 零值:内部 s 为 nil
defer func() {
if r := recover(); r == nil {
t.Skip("fasthttp 已修复:RequestCtx.Done 在 s==nil 时不再 panic")
}
}()
_ = rctx.Done() // 线上就是这样崩的
t.Fatal("预期 panic 未发生")
}
@@ -0,0 +1,260 @@
package handler
import (
"strings"
"testing"
)
// fillXsgzFieldValues:优先按标签匹配,未命中时用必填文本字段兜底。
func TestFillXsgzFieldValues(t *testing.T) {
req := &ExecuteLeaveApplyRequest{LeaveReason: "感冒发烧需休息"}
t.Run("按标签命中原因字段", func(t *testing.T) {
fields := []map[string]interface{}{
{"fieldId": "f1", "fieldName": "请假原因", "fieldType": "TEXTAREA", "required": true},
{"fieldId": "f2", "fieldName": "紧急联系人", "fieldType": "TEXT", "required": true},
}
fillXsgzFieldValues(fields, req)
if fields[0]["value"] != req.LeaveReason {
t.Fatalf("原因字段未被填充: %v", fields[0])
}
if _, ok := fields[1]["value"]; ok {
t.Fatalf("非原因字段不应被填充: %v", fields[1])
}
})
t.Run("学校自定义字段名时用必填文本兜底", func(t *testing.T) {
fields := []map[string]interface{}{
{"fieldId": "f1", "fieldName": "申请理由说明", "fieldType": "TEXTAREA", "required": true},
{"fieldId": "f2", "fieldName": "备注", "fieldType": "TEXT", "required": false},
}
fillXsgzFieldValues(fields, req)
// "说明" 命中第一轮
if fields[0]["value"] != req.LeaveReason {
t.Fatalf("含'说明'的字段应被填充: %v", fields[0])
}
})
t.Run("无关键词时必填文本字段兜底", func(t *testing.T) {
fields := []map[string]interface{}{
{"fieldId": "f1", "fieldName": "补充信息", "fieldType": "TEXTAREA", "required": true},
{"fieldId": "f2", "fieldName": "选择项", "fieldType": "RADIO", "required": true},
}
fillXsgzFieldValues(fields, req)
if fields[0]["value"] != req.LeaveReason {
t.Fatalf("必填文本字段应兜底填充: %v", fields[0])
}
if _, ok := fields[1]["value"]; ok {
t.Fatalf("选择类字段不应猜测填充: %v", fields[1])
}
})
t.Run("空 fields 与空原因不 panic", func(t *testing.T) {
fillXsgzFieldValues(nil, req)
fillXsgzFieldValues([]map[string]interface{}{}, req)
fillXsgzFieldValues([]map[string]interface{}{{"fieldId": "f1"}}, &ExecuteLeaveApplyRequest{})
})
}
// checkXsgzUnfilledRequired:找出必填但无值的字段,用于提交前预检。
func TestCheckXsgzUnfilledRequired(t *testing.T) {
t.Run("必填有值时不报告", func(t *testing.T) {
fields := []map[string]interface{}{
{"fieldId": "f1", "fieldName": "请假原因", "required": true, "value": "感冒"},
}
if got := checkXsgzUnfilledRequired(fields); len(got) != 0 {
t.Fatalf("不应报告缺失,实际: %v", got)
}
})
t.Run("必填无值时报告字段名", func(t *testing.T) {
fields := []map[string]interface{}{
{"fieldId": "f1", "fieldName": "是否离校", "required": true},
{"fieldId": "f2", "fieldName": "备注", "required": false},
}
got := checkXsgzUnfilledRequired(fields)
if len(got) != 1 || got[0] != "是否离校" {
t.Fatalf("应只报告「是否离校」,实际: %v", got)
}
})
t.Run("value 为空串也算缺失", func(t *testing.T) {
fields := []map[string]interface{}{
{"fieldId": "f1", "fieldName": "请假事由", "required": true, "value": ""},
}
if got := checkXsgzUnfilledRequired(fields); len(got) != 1 {
t.Fatalf("空串应算缺失,实际: %v", got)
}
})
t.Run("无 fieldName 时回退 fieldId", func(t *testing.T) {
fields := []map[string]interface{}{
{"fieldId": "field_abc", "required": true},
}
got := checkXsgzUnfilledRequired(fields)
if len(got) != 1 || got[0] != "field_abc" {
t.Fatalf("应回退到 fieldId,实际: %v", got)
}
})
t.Run("非必填缺失不报告", func(t *testing.T) {
fields := []map[string]interface{}{
{"fieldId": "f1", "fieldName": "备注", "required": false},
}
if got := checkXsgzUnfilledRequired(fields); len(got) != 0 {
t.Fatalf("非必填不应报告,实际: %v", got)
}
})
}
// normalizeXsgzFields:按前端 Ie() 语义清洗,避免提交学校端读不到的键。
func TestNormalizeXsgzFields(t *testing.T) {
t.Run("上传类字段去掉 value", func(t *testing.T) {
f := map[string]interface{}{
"fieldId": "f1", "componentType": "imageUpload", "value": "脏数据",
}
normalizeXsgzFields([]map[string]interface{}{f})
if _, ok := f["value"]; ok {
t.Fatal("上传类字段不应保留 value(前端走 uploadFiles)")
}
})
t.Run("三种上传类型都处理", func(t *testing.T) {
for _, ct := range []string{"imageUpload", "fileUpload", "smoothSignature"} {
f := map[string]interface{}{"fieldId": "x", "componentType": ct, "value": "v"}
normalizeXsgzFields([]map[string]interface{}{f})
if _, ok := f["value"]; ok {
t.Fatalf("%s 应去掉 value", ct)
}
}
})
t.Run("多选数组 join 成逗号串", func(t *testing.T) {
f := map[string]interface{}{
"fieldId": "f2", "componentType": "checkbox",
"value": []interface{}{"a", "b", "c"},
}
normalizeXsgzFields([]map[string]interface{}{f})
if got := f["value"]; got != "a,b,c" {
t.Fatalf("多选应 join 为 a,b,c,实际 %v", got)
}
})
t.Run("数组中的 nil 与空串被跳过", func(t *testing.T) {
f := map[string]interface{}{
"fieldId": "f3", "componentType": "checkbox",
"value": []interface{}{"a", nil, "", "b"},
}
normalizeXsgzFields([]map[string]interface{}{f})
if got := f["value"]; got != "a,b" {
t.Fatalf("应跳过 nil 与空串,实际 %v", got)
}
})
t.Run("普通字符串字段不受影响", func(t *testing.T) {
f := map[string]interface{}{
"fieldId": "f4", "componentType": "TEXT", "value": "感冒",
}
normalizeXsgzFields([]map[string]interface{}{f})
if got := f["value"]; got != "感冒" {
t.Fatalf("普通字段不应被改动,实际 %v", got)
}
})
t.Run("空输入不 panic", func(t *testing.T) {
normalizeXsgzFields(nil)
normalizeXsgzFields([]map[string]interface{}{})
})
}
// fillXsgzFieldValues 不应把请假原因写进上传类字段。
func TestFillSkipsUploadFields(t *testing.T) {
fields := []map[string]interface{}{
{"fieldId": "f1", "componentType": "imageUpload", "fieldName": "请假原因附件", "required": true},
}
req := &ExecuteLeaveApplyRequest{LeaveReason: "感冒"}
fillXsgzFieldValues(fields, req)
if _, ok := fields[0]["value"]; ok {
t.Fatalf("上传类字段不应被填充 value,实际 %v", fields[0]["value"])
}
}
// 上传类必填字段不应触发「必填未填」拦截。
func TestCheckUnfilledSkipsUpload(t *testing.T) {
fields := []map[string]interface{}{
{"fieldId": "f1", "componentType": "imageUpload", "fieldName": "附件", "required": true},
{"fieldId": "f2", "componentType": "TEXT", "fieldName": "请假原因", "required": true, "value": "感冒"},
}
if missing := checkXsgzUnfilledRequired(fields); len(missing) != 0 {
t.Fatalf("上传类不应算缺失,实际: %v", missing)
}
}
// regions 地区字段:前端 value 是「选中值,详情」拼接串,本平台无此数据,
// 不能把请假原因写进去(会产生非法地区串)。
func TestRegionsFieldNotFilledWithReason(t *testing.T) {
fields := []map[string]interface{}{
{"fieldId": "r1", "componentType": "regions", "fieldName": "请假原因", "required": false},
}
req := &ExecuteLeaveApplyRequest{LeaveReason: "感冒"}
fillXsgzFieldValues(fields, req)
if v, ok := fields[0]["value"]; ok && v != "" {
t.Fatalf("regions 字段不应被填入请假原因,实际 %v", v)
}
}
// regions 字段残留的非法 value 应被清空。
func TestNormalizeRegionsClearsValue(t *testing.T) {
f := map[string]interface{}{
"fieldId": "r1", "componentType": "regions", "value": "山东省,泰安市,泰山区",
}
normalizeXsgzFields([]map[string]interface{}{f})
if got := f["value"]; got != "" {
t.Fatalf("regions 的 value 应被清空,实际 %v", got)
}
}
// regions 必填但没有可用数据时,应被预检拦下(而不是发出去被学校端拒绝)。
func TestRegionsRequiredTriggersPrecheck(t *testing.T) {
fields := []map[string]interface{}{
{"fieldId": "r1", "componentType": "regions", "fieldName": "籍贯", "required": true},
}
missing := checkXsgzUnfilledRequired(fields)
if len(missing) != 1 || missing[0] != "籍贯" {
t.Fatalf("必填 regions 无值应被预检拦下,实际: %v", missing)
}
}
// 销假用地址兜底填充时,也不能污染上传类/地区类字段。
// 该用例防止「填充」与「normalize 清空」互相打架时留下脏值。
func TestCancelFillSkipsNonTextFields(t *testing.T) {
fields := []map[string]interface{}{
{"fieldId": "r1", "componentType": "regions", "fieldName": "销假原因"},
{"fieldId": "u1", "componentType": "imageUpload", "fieldName": "销假说明附件"},
{"fieldId": "t1", "componentType": "TEXT", "fieldName": "销假说明"},
}
addr := "山东省泰安市"
for _, f := range fields {
if xsgzNonTextFillable(mapStr(f, "componentType")) {
continue
}
label := strings.ToLower(mapStr(f, "fieldZh") + mapStr(f, "fieldName") + mapStr(f, "label"))
if strings.Contains(label, "说明") || strings.Contains(label, "原因") {
f["value"] = addr
}
}
normalizeXsgzFields(fields)
// 文本字段应保留地址
if fields[2]["value"] != addr {
t.Fatalf("文本字段应填入地址,实际 %v", fields[2]["value"])
}
// 地区字段应被清空(不是地址)
if got := fields[0]["value"]; got != "" {
t.Fatalf("地区字段应为空,实际 %v", got)
}
// 上传字段不应有 value
if _, ok := fields[1]["value"]; ok {
t.Fatalf("上传字段不应有 value,实际 %v", fields[1]["value"])
}
}
+14 -1
View File
@@ -48,14 +48,27 @@ func SmsSend(c *fiber.Ctx) error {
return utils.Fail(c, 429, "验证码发送过于频繁,请 "+service.FormatWait(wait)+"后再试")
}
// 手机号维度 60 秒冷却:今日校园对同一号码有服务端频率限制,超频时接口
// 仍返回 errCode=0 但短信不会下发(前端显示成功、手机收不到),故本地拦截。
if ok, wait := service.AllowSmsCodeSend(phone); !ok {
return utils.Fail(c, 429, "验证码发送过于频繁,请 "+service.FormatWait(wait)+"后再试")
}
result, err := cpd.SendSmsCode(c.Context(), phone)
if err != nil {
// 上游发送失败不占用冷却,允许用户立即重试
service.ReleaseSmsCodeCooldown(phone)
slog.Warn("短信验证码发送失败", "phone", maskPhone(phone), "error", err)
return utils.Fail(c, 400, err.Error())
}
countdown := result.Countdown
if countdown < service.SmsCodeCooldownSeconds {
countdown = service.SmsCodeCooldownSeconds
}
return utils.Success(c, fiber.Map{
"countdown": result.Countdown,
"countdown": countdown,
"tipMsg": result.TipMsg,
})
}
+136 -11
View File
@@ -12,6 +12,7 @@ import (
"time"
"cpdaily-new/internal/database"
"cpdaily-new/internal/lock"
"cpdaily-new/internal/model"
"cpdaily-new/internal/ratelimit"
cpd "cpdaily-new/internal/service/cpdaily"
@@ -647,6 +648,18 @@ func executeSignTask(c *fiber.Ctx) error {
return
}
// 分布式锁:同一用户同一任务串行执行。
// 限流只约束频率,挡不住两个并发请求同时通过限流后向学校端重复提交
// (多实例部署时尤其明显,各实例各自放行)。这里用跨实例互斥锁覆盖整个执行流程。
// TTL 须大于最长执行耗时(含人脸复核轮询),否则锁提前过期会失去互斥意义。
execLock, locked := lock.TryLock(fmt.Sprintf("exec:task:%d:%d", userID, task.ID), 5*time.Minute)
if !locked {
slog.Warn("同一任务正在执行中,拒绝重复执行", "userId", userID, "taskId", id)
sendEvent("task_error", map[string]interface{}{"message": "该任务正在执行中,请勿重复提交", "busy": true})
return
}
defer execLock.Unlock()
// 所有前置检查通过,更新状态为执行中
database.DB.Model(&task).Updates(map[string]interface{}{
"task_status": "executing",
@@ -667,22 +680,27 @@ func executeSignTask(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)
// 续期成功但仍过期:不能让原始 ErrCookieExpired 透传到用户面前
// (否则界面显示 cpdaily_cookie_expired 这种内部错误码)。
// 说明自动续期拿到的凭证依然无效,等同于需要用户重新登录。
if err != nil && isCookieExpired(err) {
slog.Warn("自动续期后仍提示登录态失效,判定需重新登录", "taskId", id, "error", err)
err = wrapNeedReLogin(err)
}
} else {
err = wrapNeedReLogin(refreshErr)
}
}
if err != nil {
errMsg := "获取签到表单失败: " + err.Error()
if needReLoginSignal(err) {
errMsg = "登录凭证已过期,请重新登录"
}
// 统一兜底:避免 cpdaily_cookie_expired 等内部错误码直接展示给用户
errMsg, needLoginNow := userFacingError("获取签到表单失败: ", err)
slog.Error("获取表单失败", "taskId", id, "error", err)
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, "needReLogin": needReLoginSignal(err)})
sendEvent("task_error", map[string]interface{}{"status": "error", "message": errMsg, "needReLogin": needLoginNow})
return
}
@@ -693,7 +711,40 @@ func executeSignTask(c *fiber.Ctx) error {
database.DB.Model(&task).Update("real_title", task.RealTitle)
}
// 3. 反序列化用户配置的表单
// 3. 已签到且不允许更新 → 直接拦截(逻辑取自签到 H5:
// if (signed && !isAllowUpdate) toast("更新签到开关已关闭");
// updateLimit 且有 leftNum 才算剩余更新次数)
if msg, blocked := cpd.SignUpdateBlocked(detail.Rows); blocked {
slog.Warn("已签到且不允许更新,拒绝执行", "taskId", id, "userId", userID, "reason", msg)
database.DB.Model(&task).Updates(map[string]interface{}{
"task_status": "fail",
"error_message": msg,
})
saveLog(false, msg, taskTitle(task))
sendEvent("task_error", map[string]interface{}{
"status": "error", "message": msg, "alreadySigned": true,
})
return
}
// 3.1 打卡时间段校验:详情里带 rateSignDate/rateTaskBeginTime/rateTaskEndTime
// (查寝走 singleTaskBeginTime/EndTime 完整时间)。不在时间段内直接返回明确提示,
// 不再把注定被学校端拒绝的提交发出去,也避免用户白扣次数。
if wErr := cpd.CheckSignWindow(detail.Rows, time.Now()); wErr != nil {
errMsg := wErr.Error()
slog.Warn("不在打卡时间段内,拒绝执行", "taskId", id, "userId", userID, "error", wErr)
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, "outsideWindow": true,
})
return
}
// 4. 反序列化用户配置的表单
var formItems []model.CpTaskFormItem
if err := json.Unmarshal([]byte(task.FormData), &formItems); err != nil {
errMsg := "表单数据解析失败"
@@ -765,20 +816,94 @@ func executeSignTask(c *fiber.Ctx) error {
}
}
if err != nil {
errMsg := fmt.Sprintf("签到失败(%s): %s", taskTitle(task), err.Error())
if needReLoginSignal(err) {
errMsg = "登录凭证已过期,请重新登录"
}
// 同上一处:签到失败也要避免内部错误码外泄
errMsg, needLoginNow := userFacingError(
fmt.Sprintf("签到失败(%s): ", taskTitle(task)), err)
slog.Error("签到失败", "taskId", id, "error", err)
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, "needReLogin": needReLoginSignal(err)})
sendEvent("task_error", map[string]interface{}{"status": "error", "message": errMsg, "needReLogin": needLoginNow})
return
}
// 4.5 人脸识别复核(重要):今日校园对开启人脸识别的任务,submitSign 返回 code=0
// 仅表示「受理成功」,人脸核验在服务端异步进行,可能事后被系统自动驳回
// (如 FRS.0036)。仅凭 code=0 判定成功会导致「假成功」:日志记成功、照常扣费,
// 但今日校园侧实为未签到。故此处提交成功后回查详情确认结果,只有真正成功才扣费。
if cpd.NeedsFaceIdentify(detail.Rows) {
sendEvent("verifying", map[string]interface{}{"status": "verifying", "message": "正在核验人脸识别结果..."})
// 轮询等待:识别在服务端排队,可能返回「识别中,预计等待N位」。
// 这种情况必须继续等待而不是判失败,因此:
// - 前 4 轮间隔 0/1/2/3 秒,快速拿到常见情形(多数几秒内出结果);
// - 之后转为每 3 秒一轮,最多再等 faceQueueMaxWait,
// 覆盖排队靠后的情况;
// - 期间若出现明确失败文案则立即判失败,出结果(signStatus=4)则成功。
delays := []time.Duration{0, 1 * time.Second, 2 * time.Second, 3 * time.Second}
const faceQueueMaxWait = 90 * time.Second
deadline := time.Now().Add(faceQueueMaxWait)
pendingHint := ""
rejected := false
var rejectMsg string
settledOK := false
for i := 0; ; i++ {
var d time.Duration
if i < len(delays) {
d = delays[i]
} else if time.Now().Before(deadline) {
d = 3 * time.Second
} else {
break // 等够了,走「未出结果按成功」兜底
}
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
}
// 尚未出结果(含「识别中,预计等待N位」排队提示):继续等待。
// 把最新排队提示透传给前端,让用户看到进度而不是干等。
if res.Message != "" && res.Message != pendingHint {
pendingHint = res.Message
sendEvent("verifying", map[string]interface{}{
"status": "verifying", "message": pendingHint, "queued": true,
})
}
}
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",
@@ -8,6 +8,7 @@ import (
"time"
"cpdaily-new/internal/database"
"cpdaily-new/internal/lock"
"cpdaily-new/internal/model"
"cpdaily-new/internal/ratelimit"
cpd "cpdaily-new/internal/service/cpdaily"
@@ -87,6 +88,15 @@ func executeReturnRegisterTask(c *fiber.Ctx, task *model.CpTask) error {
return
}
// 分布式锁:同一用户同一返校登记任务串行执行,避免并发重复提交。
execLock, locked := lock.TryLock(fmt.Sprintf("exec:return:%d:%d", userID, task.ID), 5*time.Minute)
if !locked {
slog.Warn("返校登记正在执行中,拒绝重复提交", "userId", userID, "taskId", task.ID)
sendEvent("task_error", map[string]interface{}{"message": "该任务正在执行中,请勿重复提交", "busy": true})
return
}
defer execLock.Unlock()
database.DB.Model(task).Updates(map[string]interface{}{
"task_status": "executing",
"error_message": "",
+7 -2
View File
@@ -104,13 +104,18 @@ func executeWorklogTask(c *fiber.Ctx, task *model.CpTask) error {
return
}
// 范围校验:不在允许的校区范围内则明确拒绝(不伪造定位)
if scopes := cpd.WorklogScopePoints(*f); len(scopes) > 0 && !cpd.WorklogWithinRange(*f, lat, lon) {
place, matched := cpd.WorklogMatchedScope(*f, lat, lon)
if scopes := cpd.WorklogScopePoints(*f); len(scopes) > 0 && !matched {
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 {
// signPlace 必须是命中的签到点名称(学校配置的 address),空串会导致学校端
// 校验「信息收集信息」不通过并返回 2210010000。
signPlace := place.Address
// deviceId 用 MobileCode、username 用 StudentID,与普通签到提交保持一致
if serr := cpd.SubmitWorklogSign(cookieMap, host, f.Wid, wid, signPlace, lon, lat, user.MobileCode, user.StudentID); serr != nil {
msg := "签到失败: " + serr.Error()
saveLog(false, msg)
sendEvent("task_error", map[string]interface{}{"status": "error", "message": msg})
+117 -14
View File
@@ -37,6 +37,8 @@ type TicketListItem struct {
MessageCount int64 `json:"messageCount"`
CreatedAt string `json:"createdAt"`
UpdatedAt string `json:"updatedAt"`
// 管理员最近一次操作后用户是否已读;false 时用户端列表显示未读红点
UserRead bool `json:"userRead"`
}
type AdminTicketListItem struct {
@@ -46,6 +48,10 @@ type AdminTicketListItem struct {
SchoolName string `json:"schoolName"`
// 用户是否已删除该工单(软删除);管理端仍展示,便于追溯
Deleted bool `json:"deleted"`
// 管理员最近一次操作后用户是否已读;false 表示用户还没看这条回复/状态变更
UserRead bool `json:"userRead"`
// 用户最近一次回复后管理员是否已读;false 表示后台该工单显示未读红点
AdminRead bool `json:"adminRead"`
}
type TicketDetail struct {
@@ -57,6 +63,10 @@ type TicketDetail struct {
CreatedAt string `json:"createdAt"`
UpdatedAt string `json:"updatedAt"`
Messages []TicketMessageRes `json:"messages"`
// 管理员最近一次操作(回复/状态变更)后,用户是否已读
UserRead bool `json:"userRead"`
// 用户最近一次回复后,管理员是否已读
AdminRead bool `json:"adminRead"`
}
type TicketMessageRes struct {
@@ -71,6 +81,7 @@ 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"`
@@ -82,10 +93,17 @@ type TicketNotificationRes struct {
// ---------- 通知辅助函数 ----------
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,
}
@@ -153,6 +171,7 @@ func ListMyTickets(c *fiber.Ctx) error {
MessageCount: count,
CreatedAt: t.CreatedAt.Format("2006-01-02 15:04"),
UpdatedAt: t.UpdatedAt.Format("2006-01-02 15:04"),
UserRead: t.UserRead,
})
}
@@ -175,6 +194,13 @@ func GetTicketDetail(c *fiber.Ctx) error {
return utils.FailServer(c, "查询失败")
}
// 用户打开工单即视为已读:清掉工单级未读红点。
// 逐条通知的 is_read 由通知列表单独维护,两者互不影响。
if !ticket.UserRead {
database.DB.Model(&ticket).Update("user_read", true)
ticket.UserRead = true
}
var messages []model.TicketMessage
database.DB.Where("ticket_id = ?", ticket.ID).Order("created_at ASC").Find(&messages)
@@ -199,9 +225,46 @@ func GetTicketDetail(c *fiber.Ctx) error {
CreatedAt: ticket.CreatedAt.Format("2006-01-02 15:04"),
UpdatedAt: ticket.UpdatedAt.Format("2006-01-02 15:04"),
Messages: msgRes,
UserRead: ticket.UserRead,
AdminRead: ticket.AdminRead,
})
}
// UserReopenTicket 用户把「已完成」工单打回「处理中」。
// 仅 resolved 可打回:closed 是管理员终结状态,pending 本就是待处理。
// 打回即视为用户表达「问题没解决」,因此管理员侧置为未读。
func UserReopenTicket(c *fiber.Ctx) error {
userID := c.Locals("userId").(uint)
ticketID, err := c.ParamsInt("id")
if err != nil {
return utils.FailParam(c, "无效的工单ID")
}
var ticket model.Ticket
if err := database.DB.Where("id = ? AND user_id = ?", ticketID, userID).First(&ticket).Error; err != nil {
if err == gorm.ErrRecordNotFound {
return utils.Fail(c, 404, "工单不存在")
}
return utils.FailServer(c, "查询失败")
}
if ticket.Status == "closed" {
return utils.Fail(c, 400, "工单已关闭,无法打回")
}
if ticket.Status != "resolved" {
return utils.Fail(c, 400, "只有已完成的工单可以打回")
}
if err := database.DB.Model(&ticket).Updates(map[string]interface{}{
"status": "processing",
"admin_read": false,
}).Error; err != nil {
return utils.FailServer(c, "打回失败")
}
return utils.Success(c, fiber.Map{"status": "processing"})
}
// AddTicketMessage 用户回复工单
func AddTicketMessage(c *fiber.Ctx) error {
userID := c.Locals("userId").(uint)
@@ -242,10 +305,22 @@ func AddTicketMessage(c *fiber.Ctx) error {
return utils.FailServer(c, "回复失败")
}
// 如果工单是已解决状态,回复后重新开启
// 用户回复后:管理员侧重新变为未读(后台列表显示红点)。
// 同时把「已完成」打回「处理中」——用户回复即表示问题未真正解决,需管理员继续跟进。
// pending/processing 状态保持不变;closed 已在上方拦截。
updates := map[string]interface{}{"admin_read": false}
if ticket.Status == "resolved" {
database.DB.Model(&ticket).Update("status", "pending")
updates["status"] = "processing"
}
database.DB.Model(&ticket).Updates(updates)
// 异步邮件提醒管理员:工单对话有新回复
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,
@@ -277,9 +352,11 @@ func CloseTicket(c *fiber.Ctx) error {
return utils.Success(c, fiber.Map{"status": "closed"})
}
// DeleteTicket 用户删除工单(软删除)。
// 仅标记 tickets.deleted_at:用户端列表不再展示,管理端仍可见并可查看完整对话,
// 因此不再级联删除消息/通知,避免用户删除后抹掉处理痕迹。
// DeleteTicket 用户删除工单(软删除 + 自动关闭)。
// 1. 标记 tickets.deleted_at:用户端列表不再展示,管理端仍可见并可查看完整对话,
// 因此不级联删除消息/通知,避免用户删除后抹掉处理痕迹;
// 2. 同时将状态置为 closed(用户删除视为自动关闭),此后用户无法再回复,
// 管理端也不再将其计入「未关闭」待办。
func DeleteTicket(c *fiber.Ctx) error {
userID := c.Locals("userId").(uint)
ticketID, err := c.ParamsInt("id")
@@ -295,12 +372,16 @@ func DeleteTicket(c *fiber.Ctx) error {
return utils.FailServer(c, "查询失败")
}
// 先置为关闭,再软删除(两条 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 ----------
@@ -331,6 +412,7 @@ func ListTicketNotifications(c *fiber.Ctx) error {
items = append(items, TicketNotificationRes{
ID: n.ID,
TicketID: n.TicketID,
Kind: n.Kind,
Title: n.Title,
Content: n.Content,
IsRead: n.IsRead,
@@ -413,6 +495,8 @@ func AdminListTickets(c *fiber.Ctx) error {
RealName: r.RealName,
SchoolName: r.SchoolID,
Deleted: r.DeletedAt.Valid,
UserRead: r.UserRead,
AdminRead: r.AdminRead,
})
}
@@ -439,6 +523,13 @@ func AdminGetTicket(c *fiber.Ctx) error {
var user model.SysUser
database.DB.Select("username, real_name, school_id").First(&user, ticket.UserID)
// 管理员打开工单即视为已读:清掉后台「用户有新回复」红点。
// 用户的未读(user_read)不受影响——管理员看没看,不应替用户决定。
if !ticket.AdminRead {
database.DB.Unscoped().Model(&model.Ticket{}).Where("id = ?", ticket.ID).Update("admin_read", true)
ticket.AdminRead = true
}
// 获取消息
var messages []model.TicketMessage
database.DB.Where("ticket_id = ?", ticket.ID).Order("created_at ASC").Find(&messages)
@@ -467,6 +558,8 @@ func AdminGetTicket(c *fiber.Ctx) error {
"createdAt": ticket.CreatedAt.Format("2006-01-02 15:04"),
"updatedAt": ticket.UpdatedAt.Format("2006-01-02 15:04"),
"deleted": ticket.DeletedAt.Valid,
"userRead": ticket.UserRead,
"adminRead": ticket.AdminRead,
"messages": msgRes,
})
}
@@ -510,15 +603,19 @@ func AdminReplyTicket(c *fiber.Ctx) error {
return utils.FailServer(c, "回复失败")
}
// 管理员回复后自动将状态改为处理中
// 管理员回复后:对用户置为未读(用户需要看到这条回复),
// 并把待处理推进到处理中。admin_read 置 true——这是管理员自己的动作,
// 后台不应因此把自己标成未读。
adminUpdates := map[string]interface{}{"user_read": false, "admin_read": true}
if ticket.Status == "pending" {
database.DB.Model(&ticket).Update("status", "processing")
adminUpdates["status"] = "processing"
}
database.DB.Model(&ticket).Updates(adminUpdates)
// 发送通知给用户
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,
@@ -556,18 +653,24 @@ func AdminUpdateTicketStatus(c *fiber.Ctx) error {
return utils.FailServer(c, "查询失败")
}
database.DB.Model(&ticket).Update("status", req.Status)
// 管理员改状态:对用户置为未读(用户需要知道状态变了)。
// admin_read 置 true——这是管理员自己的操作,后台不显示未读。
database.DB.Model(&ticket).Updates(map[string]interface{}{
"status": req.Status,
"user_read": false,
"admin_read": true,
})
// 发送通知给用户
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,
+321
View File
@@ -0,0 +1,321 @@
package handler
import (
"encoding/json"
"io"
"net/http/httptest"
"os"
"strings"
"testing"
"cpdaily-new/internal/database"
"cpdaily-new/internal/model"
"github.com/gofiber/fiber/v2"
"gorm.io/driver/mysql"
"gorm.io/gorm"
"gorm.io/gorm/logger"
)
// 工单双向未读契约测试。
//
// 覆盖三个方向的状态迁移:
// 管理员回复/改状态 → 用户侧未读(user_read=false),管理员侧已读(admin_read=true)
// 用户回复 → 管理员侧未读(admin_read=false)
// 打开详情 → 对应一侧置为已读
// 以及用户打回的边界(仅 resolved → processing)。
//
// 需要测试库(同 LEAVE_IT_DSN 约定,指向一次性库,会建表/清表):
//
// TICKET_IT_DSN='root:test@tcp(127.0.0.1:13306)/tickettest?charset=utf8mb4&parseTime=True&loc=Local' \
// go test ./internal/handler/ -run Ticket -count=1 -v
func setupTicketIT(t *testing.T) {
t.Helper()
dsn := os.Getenv("TICKET_IT_DSN")
if dsn == "" {
t.Skip("未设置 TICKET_IT_DSN,跳过工单数据库集成测试")
}
db, err := gorm.Open(mysql.Open(dsn), &gorm.Config{
Logger: logger.Default.LogMode(logger.Silent),
})
if err != nil {
t.Fatalf("连接测试库失败: %v", err)
}
for _, m := range []interface{}{&model.Ticket{}, &model.TicketMessage{}, &model.TicketNotification{}} {
if err := db.Migrator().DropTable(m); err != nil {
t.Fatalf("清表失败: %v", err)
}
}
if err := db.AutoMigrate(&model.SysUser{}, &model.Ticket{}, &model.TicketMessage{}, &model.TicketNotification{}); err != nil {
t.Fatalf("建表失败: %v", err)
}
prev := database.DB
database.DB = db
t.Cleanup(func() { database.DB = prev })
}
// newTicketApp 构造最小 app,按需注入 userId / adminId。
func newTicketApp(userID, adminID uint) *fiber.App {
app := fiber.New()
withUser := func(c *fiber.Ctx) error {
c.Locals("userId", userID)
return c.Next()
}
withAdmin := func(c *fiber.Ctx) error {
c.Locals("adminId", adminID)
return c.Next()
}
app.Get("/ticket/:id", withUser, GetTicketDetail)
app.Post("/ticket/:id/message", withUser, AddTicketMessage)
app.Put("/ticket/:id/reopen", withUser, UserReopenTicket)
app.Get("/admin/ticket/:id", withAdmin, AdminGetTicket)
app.Post("/admin/ticket/reply/:id", withAdmin, AdminReplyTicket)
app.Put("/admin/ticket/status/:id", withAdmin, AdminUpdateTicketStatus)
return app
}
func doReq(t *testing.T, app *fiber.App, method, path, body string) (int, string) {
t.Helper()
var r io.Reader
if body != "" {
r = strings.NewReader(body)
}
req := httptest.NewRequest(method, path, r)
if body != "" {
req.Header.Set("Content-Type", "application/json")
}
resp, err := app.Test(req, -1)
if err != nil {
t.Fatalf("请求失败: %v", err)
}
defer resp.Body.Close()
raw, _ := io.ReadAll(resp.Body)
return resp.StatusCode, string(raw)
}
func createTicket(t *testing.T, userID uint, status string) model.Ticket {
t.Helper()
tk := model.Ticket{
UserID: userID,
Title: "测试工单",
Content: "内容",
Status: status,
UserRead: true,
AdminRead: true,
}
if err := database.DB.Create(&tk).Error; err != nil {
t.Fatalf("建工单失败: %v", err)
}
return tk
}
// reloadTicket 从库里重读工单,确认落库结果。
func reloadTicket(t *testing.T, id uint) model.Ticket {
t.Helper()
var tk model.Ticket
if err := database.DB.Unscoped().First(&tk, id).Error; err != nil {
t.Fatalf("重读工单失败: %v", err)
}
return tk
}
// 管理员回复:用户侧变未读,管理员侧保持已读。
func TestTicketAdminReplyMarksUserUnread(t *testing.T) {
setupTicketIT(t)
tk := createTicket(t, 1, "processing")
app := newTicketApp(1, 99)
code, body := doReq(t, app, "POST", "/admin/ticket/reply/"+itoa(tk.ID), `{"content":"已处理,请查收"}`)
if code != 200 {
t.Fatalf("回复失败 code=%d body=%s", code, body)
}
got := reloadTicket(t, tk.ID)
if got.UserRead {
t.Fatalf("管理员回复后用户侧应为未读")
}
if !got.AdminRead {
t.Fatalf("管理员自己的回复不应把后台标为未读")
}
}
// 管理员改状态:用户侧变未读,管理员侧已读。
func TestTicketAdminStatusChangeMarksUserUnread(t *testing.T) {
setupTicketIT(t)
tk := createTicket(t, 1, "processing")
app := newTicketApp(1, 99)
code, body := doReq(t, app, "PUT", "/admin/ticket/status/"+itoa(tk.ID), `{"status":"resolved"}`)
if code != 200 {
t.Fatalf("改状态失败 code=%d body=%s", code, body)
}
got := reloadTicket(t, tk.ID)
if got.UserRead {
t.Fatalf("状态变更后用户侧应为未读")
}
if !got.AdminRead {
t.Fatalf("管理员自己的操作不应把后台标为未读")
}
if got.Status != "resolved" {
t.Fatalf("状态应为 resolved,实际 %s", got.Status)
}
}
// 用户回复:管理员侧变未读;user_read 不受影响(管理员没做新动作)。
func TestTicketUserReplyMarksAdminUnread(t *testing.T) {
setupTicketIT(t)
tk := createTicket(t, 1, "processing")
app := newTicketApp(1, 99)
code, body := doReq(t, app, "POST", "/ticket/"+itoa(tk.ID)+"/message", `{"content":"还是不行"}`)
if code != 200 {
t.Fatalf("回复失败 code=%d body=%s", code, body)
}
got := reloadTicket(t, tk.ID)
if got.AdminRead {
t.Fatalf("用户回复后管理员侧应为未读")
}
if !got.UserRead {
t.Fatalf("用户自己的回复不应把用户侧标为未读")
}
}
// 用户对已完成工单回复:自动打回处理中 + 管理员未读。
func TestTicketUserReplyReopensResolved(t *testing.T) {
setupTicketIT(t)
tk := createTicket(t, 1, "resolved")
app := newTicketApp(1, 99)
code, body := doReq(t, app, "POST", "/ticket/"+itoa(tk.ID)+"/message", `{"content":"问题还在"}`)
if code != 200 {
t.Fatalf("回复失败 code=%d body=%s", code, body)
}
got := reloadTicket(t, tk.ID)
if got.Status != "processing" {
t.Fatalf("已完成工单被回复后应打回 processing,实际 %s", got.Status)
}
if got.AdminRead {
t.Fatalf("用户回复后管理员侧应为未读")
}
}
// 用户打开详情:user_read 置真。
func TestTicketUserOpenMarksUserRead(t *testing.T) {
setupTicketIT(t)
tk := createTicket(t, 1, "processing")
database.DB.Model(&model.Ticket{}).Where("id = ?", tk.ID).Update("user_read", false)
app := newTicketApp(1, 99)
code, body := doReq(t, app, "GET", "/ticket/"+itoa(tk.ID), "")
if code != 200 {
t.Fatalf("查询失败 code=%d body=%s", code, body)
}
if !reloadTicket(t, tk.ID).UserRead {
t.Fatalf("用户打开详情后应标记为已读")
}
}
// 管理员打开详情:admin_read 置真。
func TestTicketAdminOpenMarksAdminRead(t *testing.T) {
setupTicketIT(t)
tk := createTicket(t, 1, "processing")
database.DB.Model(&model.Ticket{}).Where("id = ?", tk.ID).Update("admin_read", false)
app := newTicketApp(1, 99)
code, body := doReq(t, app, "GET", "/admin/ticket/"+itoa(tk.ID), "")
if code != 200 {
t.Fatalf("查询失败 code=%d body=%s", code, body)
}
if !reloadTicket(t, tk.ID).AdminRead {
t.Fatalf("管理员打开详情后应标记为已读")
}
}
// 打回:仅 resolved 允许,且有次数与状态边界。
func TestTicketReopenBoundaries(t *testing.T) {
cases := []struct {
name string
fromStatus string
wantCode int
wantStatus string
}{
{"已完成可打回", "resolved", 200, "processing"},
{"处理中不可打回", "processing", 400, "processing"},
{"待处理不可打回", "pending", 400, "pending"},
{"已关闭不可打回", "closed", 400, "closed"},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
setupTicketIT(t)
tk := createTicket(t, 1, c.fromStatus)
app := newTicketApp(1, 99)
code, body := doReq(t, app, "PUT", "/ticket/"+itoa(tk.ID)+"/reopen", "")
if code != c.wantCode {
t.Fatalf("状态码 = %d, want %d; body=%s", code, c.wantCode, body)
}
if got := reloadTicket(t, tk.ID).Status; got != c.wantStatus {
t.Fatalf("状态 = %s, want %s", got, c.wantStatus)
}
})
}
}
// 打回后管理员侧应为未读(用户表达了「问题没解决」)。
func TestTicketReopenMarksAdminUnread(t *testing.T) {
setupTicketIT(t)
tk := createTicket(t, 1, "resolved")
app := newTicketApp(1, 99)
code, body := doReq(t, app, "PUT", "/ticket/"+itoa(tk.ID)+"/reopen", "")
if code != 200 {
t.Fatalf("打回失败 code=%d body=%s", code, body)
}
if reloadTicket(t, tk.ID).AdminRead {
t.Fatalf("打回后管理员侧应为未读")
}
}
// 详情接口要把两个已读标记返回给前端。
func TestTicketDetailExposesReadFlags(t *testing.T) {
setupTicketIT(t)
tk := createTicket(t, 1, "processing")
database.DB.Model(&model.Ticket{}).Where("id = ?", tk.ID).Updates(map[string]interface{}{
"user_read": false, "admin_read": false,
})
app := newTicketApp(1, 99)
// 用户详情:调用后 user_read 会被置真
_, body := doReq(t, app, "GET", "/ticket/"+itoa(tk.ID), "")
// 响应外层是统一结构 {code,message,data},字段在 data 里
var resp struct {
Data struct {
UserRead bool `json:"userRead"`
AdminRead bool `json:"adminRead"`
} `json:"data"`
}
if err := json.Unmarshal([]byte(body), &resp); err != nil {
t.Fatalf("响应不是合法 JSON: %v; body=%s", err, body)
}
if !resp.Data.UserRead {
t.Fatalf("用户打开详情后返回的 userRead 应为 true,body=%s", body)
}
}
func itoa(v uint) string {
if v == 0 {
return "0"
}
var b []byte
for v > 0 {
b = append([]byte{byte('0' + v%10)}, b...)
v /= 10
}
return string(b)
}
+166
View File
@@ -0,0 +1,166 @@
// Package lock 提供基于 Redis 的分布式互斥锁。
//
// 用途:阻止同一用户在不同实例 / 不同浏览器上并发触发同一个不可重入的操作
// (签到提交、请假申请、销假等)。单机时代这类并发靠进程内 mutex 就够,
// 多实例部署后必须靠 Redis 才能跨进程互斥。
//
// 设计取舍:
// - 锁值用随机 token,释放时必须校验 token 一致(Lua 原子比较删除),
// 否则 A 的锁过期后 B 拿到锁,A 执行完把 B 的锁删了。
// - 默认不自动续期:这些业务操作都是有界的 HTTP/爬虫请求,耗时已知且较短,
// 给足 TTL 即可。自动续期会引入后台 goroutine 与泄漏风险,不值得。
// - Redis 不可用时降级为进程内锁:单机能保证幂等,多实例降级期间不保证。
// 这比让 Redis 抖动直接拒绝所有请求更合理。
package lock
import (
"context"
"crypto/rand"
"encoding/hex"
"fmt"
"sync"
"time"
"cpdaily-new/internal/service"
"github.com/redis/go-redis/v9"
)
// releaseScript 仅当锁值匹配时才删除(原子),防止误删他人锁
var releaseScript = redis.NewScript(`
if redis.call('GET', KEYS[1]) == ARGV[1] then
return redis.call('DEL', KEYS[1])
end
return 0
`)
// extendScript 仅当锁值匹配时才续期
var extendScript = redis.NewScript(`
if redis.call('GET', KEYS[1]) == ARGV[1] then
return redis.call('PEXPIRE', KEYS[1], ARGV[2])
end
return 0
`)
// 前缀隔离,避免与其他业务的 Redis key 冲突
const keyPrefix = "lock:"
// Lock 表示一把已获取的锁
type Lock struct {
key string
token string
// 内存降级模式下持有的互斥键
localKey string
}
// TryLock 尝试获取锁,不等待。
// 返回 (lock, true) 表示获取成功;返回 (nil, false) 表示已被他人持有。
// ttl 是锁的自动过期时间,必须大于业务操作的最长耗时,否则会出现"重复执行"。
func TryLock(key string, ttl time.Duration) (*Lock, bool) {
fullKey := keyPrefix + key
token := randomToken()
if service.Redis != nil {
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
ok, err := service.Redis.SetNX(ctx, fullKey, token, ttl).Result()
if err == nil {
if !ok {
return nil, false
}
return &Lock{key: fullKey, token: token}, true
}
// Redis 异常,落到内存锁
}
if !localMutex.tryAcquire(key) {
return nil, false
}
return &Lock{localKey: key}, true
}
// Acquire 获取锁,最多等待 wait 时间。
// 返回 (lock, true) 表示获取成功;超时返回 (nil, false)。
func Acquire(key string, ttl, wait time.Duration) (*Lock, bool) {
deadline := time.Now().Add(wait)
for {
if l, ok := TryLock(key, ttl); ok {
return l, true
}
if !time.Now().Before(deadline) {
return nil, false
}
// 短间隔轮询:业务锁争抢是低频事件,不需要指数退避
time.Sleep(50 * time.Millisecond)
}
}
// Unlock 释放锁。必须由获取锁的一方调用,且调用后不应再使用该锁对象。
func (l *Lock) Unlock() {
if l == nil {
return
}
if l.localKey != "" {
localMutex.release(l.localKey)
return
}
if service.Redis == nil {
return
}
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
if err := releaseScript.Run(ctx, service.Redis, []string{l.key}, l.token).Err(); err != nil {
// 释放失败不致命:TTL 到期会自动删除
return
}
}
// Extend 续期。仅当仍持有该锁时生效,返回是否续期成功。
// 长耗时操作可在中途调用;普通短操作不需要。
func (l *Lock) Extend(ttl time.Duration) bool {
if l == nil || l.localKey != "" || service.Redis == nil {
return false
}
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
n, err := extendScript.Run(ctx, service.Redis, []string{l.key}, l.token, ttl.Milliseconds()).Int64()
if err != nil {
return false
}
return n == 1
}
// randomToken 生成锁持有者标识
func randomToken() string {
var b [16]byte
if _, err := rand.Read(b[:]); err != nil {
// 极端情况下退化为时间戳,仍可用(只是碰撞概率略高)
return fmt.Sprintf("t%d", time.Now().UnixNano())
}
return hex.EncodeToString(b[:])
}
// ---------- 内存降级锁 ----------
type localLocks struct {
mu sync.Mutex
held map[string]bool
}
var localMutex = &localLocks{held: make(map[string]bool)}
func (m *localLocks) tryAcquire(key string) bool {
m.mu.Lock()
defer m.mu.Unlock()
if m.held[key] {
return false
}
m.held[key] = true
return true
}
func (m *localLocks) release(key string) {
m.mu.Lock()
defer m.mu.Unlock()
delete(m.held, key)
}
+230
View File
@@ -0,0 +1,230 @@
package lock
import (
"context"
"fmt"
"os"
"sync"
"testing"
"time"
"cpdaily-new/internal/service"
"github.com/redis/go-redis/v9"
)
func testRedisAddr(t *testing.T) string {
t.Helper()
addr := os.Getenv("TEST_REDIS_ADDR")
if addr == "" {
t.Skip("未设置 TEST_REDIS_ADDR,跳过 Redis 相关用例")
}
return addr
}
func withRedis(t *testing.T) {
t.Helper()
addr := testRedisAddr(t)
c := redis.NewClient(&redis.Options{Addr: addr})
if err := c.Ping(context.Background()).Err(); err != nil {
t.Fatalf("连接测试 Redis 失败: %v", err)
}
old := service.Redis
service.Redis = c
t.Cleanup(func() {
service.Redis = old
_ = c.Close()
})
}
func withoutRedis(t *testing.T) {
t.Helper()
old := service.Redis
service.Redis = nil // 强制走内存降级
t.Cleanup(func() { service.Redis = old })
}
func uniqKey(t *testing.T) string {
t.Helper()
return fmt.Sprintf("test:%s:%d", t.Name(), time.Now().UnixNano())
}
// ---------- 内存降级模式 ----------
func TestTryLock_Local_MutualExclusion(t *testing.T) {
withoutRedis(t)
key := uniqKey(t)
a, ok := TryLock(key, time.Minute)
if !ok {
t.Fatal("首次应获取成功")
}
if _, ok := TryLock(key, time.Minute); ok {
t.Fatal("已持有时第二次应失败")
}
a.Unlock()
if _, ok := TryLock(key, time.Minute); !ok {
t.Fatal("释放后应可再次获取")
}
}
// ---------- Redis 模式 ----------
func TestTryLock_Redis_MutualExclusion(t *testing.T) {
withRedis(t)
key := uniqKey(t)
a, ok := TryLock(key, time.Minute)
if !ok {
t.Fatal("首次应获取成功")
}
if _, ok := TryLock(key, time.Minute); ok {
t.Fatal("已持有时第二次应失败")
}
a.Unlock()
b, ok := TryLock(key, time.Minute)
if !ok {
t.Fatal("释放后应可再次获取")
}
b.Unlock()
}
// 锁 TTL 到期后应自动可被他人获取(防止进程崩溃后锁永久卡死)
func TestTryLock_Redis_TTLExpiry(t *testing.T) {
withRedis(t)
key := uniqKey(t)
if _, ok := TryLock(key, 200*time.Millisecond); !ok {
t.Fatal("首次应获取成功")
}
time.Sleep(350 * time.Millisecond)
if _, ok := TryLock(key, time.Minute); !ok {
t.Fatal("TTL 到期后应可被获取")
}
}
// 关键用例:A 的锁过期后 B 拿到锁,A 迟到的 Unlock 绝不能删掉 B 的锁,
// 否则会出现「两个请求同时认为自己持有锁」的重复执行。
func TestUnlock_DoesNotDeleteOthersLock(t *testing.T) {
withRedis(t)
key := uniqKey(t)
a, ok := TryLock(key, 200*time.Millisecond)
if !ok {
t.Fatal("A 应获取成功")
}
// 等 A 的锁过期
time.Sleep(350 * time.Millisecond)
b, ok := TryLock(key, time.Minute)
if !ok {
t.Fatal("A 过期后 B 应获取成功")
}
// A 迟到的释放:token 不匹配,必须什么都不做
a.Unlock()
if _, ok := TryLock(key, time.Minute); ok {
t.Fatal("B 仍持有锁,第三次获取应失败(A 的 Unlock 误删了 B 的锁)")
}
b.Unlock()
}
func TestAcquire_WaitsForRelease(t *testing.T) {
withRedis(t)
key := uniqKey(t)
a, ok := TryLock(key, time.Minute)
if !ok {
t.Fatal("A 应获取成功")
}
// 150ms 后释放,B 在 3s 内等待应能成功拿到
go func() {
time.Sleep(150 * time.Millisecond)
a.Unlock()
}()
b, ok := Acquire(key, time.Minute, 3*time.Second)
if !ok {
t.Fatal("等待期间锁释放后应获取成功")
}
b.Unlock()
}
func TestAcquire_Timeout(t *testing.T) {
withRedis(t)
key := uniqKey(t)
a, ok := TryLock(key, time.Minute)
if !ok {
t.Fatal("A 应获取成功")
}
defer a.Unlock()
start := time.Now()
if _, ok := Acquire(key, time.Minute, 300*time.Millisecond); ok {
t.Fatal("锁被长期持有时等待应超时失败")
}
if elapsed := time.Since(start); elapsed > 2*time.Second {
t.Fatalf("等待时长异常,耗时 %v", elapsed)
}
}
func TestExtend_OnlyHolderCanExtend(t *testing.T) {
withRedis(t)
key := uniqKey(t)
a, ok := TryLock(key, 200*time.Millisecond)
if !ok {
t.Fatal("A 应获取成功")
}
if !a.Extend(time.Minute) {
t.Fatal("持有者续期应成功")
}
// 续期后存活时间应超过原 TTL
time.Sleep(350 * time.Millisecond)
if _, ok := TryLock(key, time.Minute); ok {
t.Fatal("续期后锁不应已过期")
}
a.Unlock()
}
// 并发争抢下必须恰好只有一个赢家
func TestTryLock_ConcurrentSingleWinner(t *testing.T) {
withRedis(t)
key := uniqKey(t)
const n = 20
var wg sync.WaitGroup
var mu sync.Mutex
winners := 0
start := make(chan struct{})
for i := 0; i < n; i++ {
wg.Add(1)
go func() {
defer wg.Done()
<-start
if l, ok := TryLock(key, 30*time.Second); ok {
mu.Lock()
winners++
mu.Unlock()
_ = l // 不释放,保持持有直到测试结束
}
}()
}
close(start)
wg.Wait()
if winners != 1 {
t.Fatalf("并发争抢应恰好 1 个赢家,实际 %d", winners)
}
}
+8
View File
@@ -16,6 +16,14 @@ type Ticket struct {
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"`
// 双向已读标记:工单是用户与管理员之间的对话,两侧各自维护一个未读状态。
// UserRead :用户是否已读「管理员最近一次操作」(回复或状态变更)之后的内容。
// 管理员回复/改状态时置 false,用户打开工单时置 true。
// AdminRead :管理员是否已读「用户最近一次回复」。
// 用户回复时置 false,管理员打开工单时置 true。
// 两者独立:管理员刚回复完,对用户是未读,对管理员自己仍是已读。
UserRead bool `gorm:"not null;default:true;index" json:"userRead"`
AdminRead bool `gorm:"not null;default:true;index" json:"adminRead"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
DeletedAt gorm.DeletedAt `gorm:"index" json:"deletedAt"`
@@ -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"`
+107 -7
View File
@@ -1,12 +1,115 @@
package ratelimit
import (
"context"
"fmt"
"sync"
"sync/atomic"
"time"
"cpdaily-new/internal/service"
"github.com/redis/go-redis/v9"
)
// key 为 "userID:taskID"
// 限流实现。
//
// 双轨:
// - Redis 可用时走滑动窗口(Lua 原子脚本),多实例共享同一份计数,
// 这是多机部署下限流正确性的前提——否则每个实例各算各的,
// 用户把 N 个实例各点一次就绕过了「10 次/天」。
// - Redis 未配置或调用失败时降级为进程内内存计数(单机语义与历史行为一致)。
// 降级只影响多实例一致性,不会让功能不可用。
//
// 对外 API(Allow / AllowMulti)签名保持与历史一致,调用方无需改动。
// slidingWindowScript 滑动窗口计数(原子)。
//
// KEYS[1] 计数键;ARGV[1] 窗口毫秒;ARGV[2] 当前毫秒;ARGV[3] 上限
// 返回 {允许(1/0), 需等待秒数}
//
// 用 ZSET 存每次请求的时间戳,先剔除窗口外的,再按剩余数量判断。
// 相比固定窗口计数,避免了窗口边界双倍突刺。
var slidingWindowScript = redis.NewScript(`
local windowMs = tonumber(ARGV[1])
local now = tonumber(ARGV[2])
local limit = tonumber(ARGV[3])
local member = ARGV[4]
local cutoff = now - windowMs
redis.call('ZREMRANGEBYSCORE', KEYS[1], '-inf', cutoff)
local count = redis.call('ZCARD', KEYS[1])
if count < limit then
redis.call('ZADD', KEYS[1], now, member)
redis.call('PEXPIRE', KEYS[1], windowMs)
return { 1, 0 }
end
-- 被限流:最早一条过期时即可再次放行
local oldest = redis.call('ZRANGE', KEYS[1], 0, 0, 'WITHSCORES')
local waitMs = windowMs
if oldest and oldest[2] then
waitMs = tonumber(oldest[2]) + windowMs - now
end
if waitMs < 1000 then waitMs = 1000 end
local waitSec = math.ceil(waitMs / 1000)
return { 0, waitSec }
`)
// redisOpTimeout 单次限流判断的 Redis 超时。
// 取短值:限流是执行路径上的旁路判断,不能因为 Redis 抖动把主流程拖死。
const redisOpTimeout = 1500 * time.Millisecond
// seq 用于给 ZSET member 加序号,避免同一纳秒内多次请求成员相同而互相覆盖
var seq atomic.Uint64
func localSeq() uint64 { return seq.Add(1) }
// redisAvailable 判断是否可以使用 Redis
func redisAvailable() bool {
return service.Redis != nil
}
// Allow 检查是否允许执行,limit 次 / window 时间内
// 返回 (true, 0) 表示允许;返回 (false, 等待秒数) 表示被限流
func Allow(key string, limit int, window time.Duration) (bool, int) {
if limit <= 0 {
return true, 0
}
if redisAvailable() {
if ok, wait, err := allowRedis(key, limit, window); err == nil {
return ok, wait
}
// Redis 异常:降级到内存,保证功能不被限流组件拖垮
}
return allowLocal(key, limit, window)
}
// allowRedis 走 Redis 滑动窗口
func allowRedis(key string, limit int, window time.Duration) (bool, int, error) {
ctx, cancel := context.WithTimeout(context.Background(), redisOpTimeout)
defer cancel()
now := time.Now()
// member 用 纳秒时间戳+序号 保证同毫秒内多次请求不互相覆盖
member := fmt.Sprintf("%d-%d", now.UnixNano(), localSeq())
res, err := slidingWindowScript.Run(ctx, service.Redis,
[]string{"ratelimit:" + key},
window.Milliseconds(), now.UnixMilli(), limit, member,
).Int64Slice()
if err != nil {
return false, 0, err
}
if len(res) < 2 {
return false, 0, fmt.Errorf("限流脚本返回值异常: %v", res)
}
return res[0] == 1, int(res[1]), nil
}
// ---------- 内存降级实现(与原实现语义一致) ----------
type limiter struct {
mu sync.Mutex
records map[string][]time.Time
@@ -16,16 +119,14 @@ var instance = &limiter{
records: make(map[string][]time.Time),
}
// Allow 检查是否允许执行,limit 次 / window 时间内
// 返回 (true, 剩余秒数) 表示允许;返回 (false, 等待秒数) 表示被限流
func Allow(key string, limit int, window time.Duration) (bool, int) {
// allowLocal 进程内滑动窗口(Redis 不可用时使用)
func allowLocal(key string, limit int, window time.Duration) (bool, int) {
instance.mu.Lock()
defer instance.mu.Unlock()
now := time.Now()
cutoff := now.Add(-window)
// 清理过期记录
records := instance.records[key]
var valid []time.Time
for _, t := range records {
@@ -35,7 +136,6 @@ func Allow(key string, limit int, window time.Duration) (bool, int) {
}
if len(valid) >= limit {
// 最早那次什么时候过期(转换为秒)
oldest := valid[0]
wait := int((window - now.Sub(oldest)) / time.Second)
if wait < 1 {
@@ -88,7 +188,7 @@ func formatWait(seconds int) string {
}
}
// Cleanup 定期清理过期记录(后台 goroutine 调用)
// Cleanup 定期清理过期记录(仅内存降级模式需要;Redis 侧靠 PEXPIRE 自动回收)
func Cleanup(interval time.Duration) {
go func() {
ticker := time.NewTicker(interval)
+200
View File
@@ -0,0 +1,200 @@
package ratelimit
import (
"context"
"fmt"
"os"
"testing"
"time"
"cpdaily-new/internal/service"
"github.com/redis/go-redis/v9"
)
// testRedisAddr 从环境变量读取测试 Redis 地址;未设置时相关用例跳过,
// 保证没有 Redis 的环境(如纯单元测试机)仍能跑其余用例。
func testRedisAddr(t *testing.T) string {
t.Helper()
addr := os.Getenv("TEST_REDIS_ADDR")
if addr == "" {
t.Skip("未设置 TEST_REDIS_ADDR,跳过 Redis 相关用例")
}
return addr
}
// withRedis 把全局 service.Redis 指向 addr 并返回清理函数
func withRedis(t *testing.T, addr string) *redis.Client {
t.Helper()
c := redis.NewClient(&redis.Options{Addr: addr})
if err := c.Ping(context.Background()).Err(); err != nil {
t.Fatalf("连接测试 Redis 失败: %v", err)
}
old := service.Redis
service.Redis = c
t.Cleanup(func() {
service.Redis = old
_ = c.Close()
})
return c
}
func uniqKey(t *testing.T) string {
t.Helper()
return fmt.Sprintf("test:%s:%d", t.Name(), time.Now().UnixNano())
}
// ---------- 内存降级模式 ----------
func TestAllowLocal_LimitAndExceed(t *testing.T) {
old := service.Redis
service.Redis = nil // 强制走内存降级
defer func() { service.Redis = old }()
key := uniqKey(t)
ok, _ := Allow(key, 3, time.Minute)
if !ok {
t.Fatal("第 1 次应放行")
}
ok, _ = Allow(key, 3, time.Minute)
if !ok {
t.Fatal("第 2 次应放行")
}
ok, _ = Allow(key, 3, time.Minute)
if !ok {
t.Fatal("第 3 次应放行")
}
ok, wait := Allow(key, 3, time.Minute)
if ok {
t.Fatal("第 4 次应被限流")
}
if wait <= 0 {
t.Fatalf("被限流时应返回正数等待秒数,实际 %d", wait)
}
}
func TestAllowLocal_WindowExpiry(t *testing.T) {
old := service.Redis
service.Redis = nil
defer func() { service.Redis = old }()
key := uniqKey(t)
if ok, _ := Allow(key, 1, 150*time.Millisecond); !ok {
t.Fatal("首次应放行")
}
if ok, _ := Allow(key, 1, 150*time.Millisecond); ok {
t.Fatal("窗口内第二次应被限流")
}
time.Sleep(250 * time.Millisecond)
if ok, _ := Allow(key, 1, 150*time.Millisecond); !ok {
t.Fatal("窗口过期后应放行")
}
}
// ---------- Redis 模式:跨实例一致性 ----------
// 关键回归用例:多实例部署时,两个实例(两个 redis 客户端)必须共享同一份计数,
// 否则每个实例各算各的,用户把 N 个实例各点一次就绕过了限流。
func TestAllowRedis_SharedAcrossClients(t *testing.T) {
addr := testRedisAddr(t)
clientA := redis.NewClient(&redis.Options{Addr: addr})
clientB := redis.NewClient(&redis.Options{Addr: addr})
defer clientA.Close()
defer clientB.Close()
old := service.Redis
defer func() { service.Redis = old }()
key := uniqKey(t)
// 实例 A 用掉 2 次配额(上限 2)
service.Redis = clientA
if ok, _ := Allow(key, 2, time.Minute); !ok {
t.Fatal("实例A 第 1 次应放行")
}
if ok, _ := Allow(key, 2, time.Minute); !ok {
t.Fatal("实例A 第 2 次应放行")
}
// 实例 B 共享同一计数,第 3 次必须被拒
service.Redis = clientB
if ok, wait := Allow(key, 2, time.Minute); ok {
t.Fatalf("实例B 第 3 次应被跨实例限流;wait=%d", wait)
}
}
func TestAllowRedis_WindowExpiry(t *testing.T) {
addr := testRedisAddr(t)
c := withRedis(t, addr)
_ = c
key := uniqKey(t)
if ok, _ := Allow(key, 1, 300*time.Millisecond); !ok {
t.Fatal("首次应放行")
}
if ok, _ := Allow(key, 1, 300*time.Millisecond); ok {
t.Fatal("窗口内第二次应被限流")
}
time.Sleep(450 * time.Millisecond)
if ok, _ := Allow(key, 1, 300*time.Millisecond); !ok {
t.Fatal("窗口过期后应放行")
}
}
// Redis 不可达时必须降级到内存而不是报错或阻塞主流程
func TestAllow_RedisDownFallsBackToLocal(t *testing.T) {
// 指向一个必然拒绝连接的地址,快速失败
c := redis.NewClient(&redis.Options{
Addr: "127.0.0.1:1",
MaxRetries: 0,
DialTimeout: 200 * time.Millisecond,
})
old := service.Redis
service.Redis = c
defer func() {
service.Redis = old
_ = c.Close()
}()
key := uniqKey(t)
ok, _ := Allow(key, 1, time.Minute)
if !ok {
t.Fatal("Redis 不可用时应降级放行,而非拒绝")
}
if ok, _ := Allow(key, 1, time.Minute); ok {
t.Fatal("降级到内存后仍应正常执行限流")
}
}
func TestAllowMulti_ShortCircuit(t *testing.T) {
old := service.Redis
service.Redis = nil
defer func() { service.Redis = old }()
key := uniqKey(t)
rules := []Rule{
{Limit: 2, Window: time.Minute, Label: "2m"},
{Limit: 100, Window: time.Hour, Label: "1h"},
}
if ok, _ := AllowMulti(key, rules); !ok {
t.Fatal("第 1 次应放行")
}
if ok, _ := AllowMulti(key, rules); !ok {
t.Fatal("第 2 次应放行")
}
ok, msg := AllowMulti(key, rules)
if ok {
t.Fatal("第 3 次应被短规则拒绝")
}
if msg == "" {
t.Fatal("被限流时应返回提示文案")
}
}
@@ -0,0 +1,207 @@
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)
}
}
}
// 线上实证:人脸识别在服务端排队时,recognitionProgress 会返回
// 「识别中,预计等待N位」。这类文案表示仍在进行,绝不能判为驳回。
// 依据 162 服务器日志(v1.2.160 之前版本误判为签到失败)。
func TestEvalFaceIdentifyQueueing(t *testing.T) {
cases := []string{
"识别中,预计等待4位",
"识别中,预计等待8位",
"识别中,预计等待1位",
"识别中",
"排队中,请稍候",
"处理中",
"预计等待3位",
}
for _, msg := range cases {
t.Run(msg, func(t *testing.T) {
got := evalFaceIdentify(map[string]interface{}{
"isFaceIdentify": float64(1),
"recognitionProgress": msg,
"recognitionProgressCode": float64(0),
"signStatus": "0",
})
if got.Rejected {
t.Fatalf("排队文案 %q 不应判为驳回", msg)
}
if got.Settled {
t.Fatalf("排队文案 %q 不应视为已出结果", msg)
}
if got.Message != msg {
t.Fatalf("Message 应透传排队提示,got %q want %q", got.Message, msg)
}
})
}
}
// 真正的失败文案仍必须判驳回,不能因为放宽而漏判。
func TestEvalFaceIdentifyRealFailure(t *testing.T) {
cases := []string{
"系统自动驳回。被驳回原因:网络连接故障,请重新尝试签到(FRS.0036)",
"人脸识别未通过",
"人脸不匹配",
"未检测到人脸",
"签到失败",
}
for _, msg := range cases {
t.Run(msg, func(t *testing.T) {
got := evalFaceIdentify(map[string]interface{}{
"isFaceIdentify": float64(1),
"recognitionProgress": msg,
"signStatus": "0",
})
if !got.Rejected {
t.Fatalf("失败文案 %q 应判驳回", msg)
}
if !got.Settled {
t.Fatalf("失败文案 %q 应视为已出结果", msg)
}
})
}
}
// 未识别文案保守处理:不确定就当进行中,不误杀正常签到。
func TestEvalFaceIdentifyUnknownMessageNotRejected(t *testing.T) {
got := evalFaceIdentify(map[string]interface{}{
"isFaceIdentify": float64(1),
"recognitionProgress": "系统繁忙,请稍后再试一下",
"signStatus": "0",
})
if got.Rejected {
t.Fatal("未知文案不应判驳回(保守当进行中)")
}
}
// recognitionProgressCode=2 仍应独立判驳回(无文案时靠码判断)。
func TestEvalFaceIdentifyCode2WithoutMessage(t *testing.T) {
got := evalFaceIdentify(map[string]interface{}{
"isFaceIdentify": float64(1),
"recognitionProgressCode": float64(2),
"recognitionProgress": "",
"signStatus": "0",
})
if !got.Rejected || !got.Settled {
t.Fatalf("progressCode=2 应判驳回,got %+v", got)
}
}
@@ -0,0 +1,44 @@
package cpdaily
import (
"testing"
"cpdaily-new/internal/config"
)
// hfnu 登录入口必须选中 amp-auth-adapter,而不是回落 WecloudUrl。
func TestHfnuLoginEntry(t *testing.T) {
config.Load("../../../config.toml")
school, err := GetSchoolDetail(HfnuSchoolId)
if err != nil {
t.Skipf("取学校详情失败: %v", err)
}
t.Logf("idsUrl=%q appId=%q wecloudUrl=%q", school.IdsUrl, school.AppId, school.WecloudUrl)
old := getCasValidLoginUrl(school)
now := hfnuLoginEntry(school)
t.Logf("通用函数结果 = %q", old)
t.Logf("专用函数结果 = %q", now)
if now != school.IdsUrl {
t.Fatalf("应选中 idsUrl(%q),实际 %q", school.IdsUrl, now)
}
if old == now {
t.Logf("注意:通用函数恰好也返回同一值")
}
}
// 非 amp-auth 学校不受影响,仍走通用逻辑。
func TestHfnuLoginEntryFallback(t *testing.T) {
s := &SchoolDetailInfo{IdsUrl: "https://foo.campusphere.net", WecloudUrl: "https://foo.campusphere.net"}
if got := hfnuLoginEntry(s); got != s.IdsUrl {
t.Fatalf("campusphere 学校应走通用逻辑,实际 %q", got)
}
s2 := &SchoolDetailInfo{IdsUrl: "", AmpUrl: "https://bar.campusphere.net"}
if got := hfnuLoginEntry(s2); got != "https://bar.campusphere.net" {
t.Fatalf("无 idsUrl 时应回落 AmpUrl,实际 %q", got)
}
if got := hfnuLoginEntry(nil); got != "" {
t.Fatalf("nil 应返回空,实际 %q", got)
}
}
+2 -2
View File
@@ -56,7 +56,7 @@ func GetUploadPolicy(cookieMap map[string]string, host string, taskType TaskType
fullUrl := host + urlPrefix + apiPath
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("User-Agent", UserAgent).
SetHeader("CpdailyClientType", "CPDAILY").
SetHeader("Content-Type", "application/json; charset=UTF-8").
SetHeader("Cookie", cookieStr).
@@ -157,7 +157,7 @@ func GetPreviewUrl(cookieMap map[string]string, host string, taskType TaskType,
fullUrl := host + urlPrefix + apiPath
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("User-Agent", UserAgent).
SetHeader("CpdailyClientType", "CPDAILY").
SetHeader("Content-Type", "application/json; charset=UTF-8").
SetHeader("Cookie", cookieStr).
+3 -3
View File
@@ -156,7 +156,7 @@ func leaveTimeWithSeconds(t string) string {
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("User-Agent", UserAgent).
SetHeader("CpdailyClientType", "CPDAILY").
SetHeader("Content-Type", "application/json; charset=UTF-8").
SetHeader("Cookie", cookiesToStr(cookieMap))
@@ -462,8 +462,8 @@ func SubmitLeaveReport(cookieMap map[string]string, host string, form map[string
"systemName": "android",
"bodyString": bodyString,
"lat": lat,
"systemVersion": "15",
"appVersion": "9.9.6",
"systemVersion": AndroidVersion,
"appVersion": AppVersion,
"model": "cpdaily",
}
submitData["userId"] = username
@@ -20,8 +20,10 @@ import (
// LeaveType 请假类型(queryLeaveTypes + queryLeaveTypeCfg 合并后的完整信息)
type LeaveType struct {
Code int `json:"code"` // 类型编码(提交与查询都用它)
Name string `json:"name"` // 类型名称
Code int `json:"code"` // 类型编码(提交与查询都用它)
Name string `json:"name"` // 类型名称
// ProjectID 字符串型类型 ID(三易拓新学工5 等系统用 UUID 而非整数 code)
ProjectID string `json:"projectId,omitempty"`
MustOut int `json:"mustOut"` // 1=必须离校(离校开关强制打开且不可改)
NeedApproval int `json:"needApproval"` // 1=需要选择审批人
EnableAttach int `json:"enableAttach"` // 1=允许上传图片附件
@@ -119,7 +119,7 @@ func TestBuildLeaveApplyForm(t *testing.T) {
ApproverId: "20220109",
ApproverName: "罗亭",
}
form := BuildLeaveApplyForm(p, "device-1", "15", "9.9.6")
form := BuildLeaveApplyForm(p, "device-1", AndroidVersion, AppVersion)
if form["leaveType"] != 3 || form["startTime"] != "2026-09-15 08:00:00" || form["outStatus"] != true {
t.Errorf("基础字段错误: %+v", form)
@@ -156,7 +156,7 @@ func TestBuildLeaveApplyForm(t *testing.T) {
Latitude: "24.6",
LocationAddr: "校内",
}
form2 := BuildLeaveApplyForm(p2, "device-1", "15", "9.9.6")
form2 := BuildLeaveApplyForm(p2, "device-1", AndroidVersion, AppVersion)
if form2["urgencyMobile"] != "" || form2["address"] != "" || form2["detailAddress"] != "" {
t.Errorf("不离校时地址字段应清空: %v/%v/%v", form2["urgencyMobile"], form2["address"], form2["detailAddress"])
}
@@ -214,7 +214,7 @@ func TestBuildLeaveCcInfo(t *testing.T) {
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")
}, "device-1", AndroidVersion, AppVersion)
fcc, _ := form["ccInfo"].(map[string]interface{})
if fcc == nil || fcc["totalSize"] != 2 {
t.Errorf("表单 ccInfo 应包含 2 名抄送人: %v", form["ccInfo"])
@@ -228,7 +228,7 @@ func TestSubmitLeaveApply(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: "校内",
}, "device-1", "15", "9.9.6")
}, "device-1", AndroidVersion, AppVersion)
if err := SubmitLeaveApply(map[string]string{}, srv.URL, form, "118.1", "24.6", "device-1", "20251960205"); err != nil {
t.Errorf("SubmitLeaveApply 应成功: %v", err)
@@ -260,7 +260,7 @@ func TestBuildLeaveApplyFormEmptyAttachURLs(t *testing.T) {
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")
}, "device-1", AndroidVersion, AppVersion)
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"])
@@ -270,7 +270,7 @@ func TestBuildLeaveApplyFormEmptyAttachURLs(t *testing.T) {
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")
}, "device-1", AndroidVersion, AppVersion)
if attach2, ok := form2["applyAttach"].([]interface{}); !ok || len(attach2) != 0 {
t.Errorf("全空时应为空数组: %v", form2["applyAttach"])
}
@@ -284,7 +284,7 @@ func TestBuildLeaveExtendForm_LocationTopLevel(t *testing.T) {
LeaveReason: "续假三天", OutStatus: false,
Longitude: "102.85939585", Latitude: "24.82989733", LocationAddr: "云南省昆明市呈贡区洋浦路",
}
form := BuildLeaveExtendForm(p, "dev-uuid", "15", "9.9.6", "3d917d58abcd")
form := BuildLeaveExtendForm(p, "dev-uuid", AndroidVersion, AppVersion, "3d917d58abcd")
if _, has := form["location"]; has {
t.Fatal("续假提交体不应包含 location 子对象")
@@ -158,6 +158,14 @@ const (
LeaveFlavorNew
// LeaveFlavorOld 旧套件 wec-counselor-leave-apps
LeaveFlavorOld
// LeaveFlavorXsgz 三易拓「新学工5」独立系统(如山东农业大学 xsgz.*.edu.cn):
// 与辅导猫完全无关,自带 /api/cas/* 登录与 /api/leave/* 请假接口,
// 使用 access_token(非 cookie)。按 schoolId 单独适配,见 leave_xsgz.go。
LeaveFlavorXsgz
// LeaveFlavorXsfw 金智 xsfw 学工平台(如云南警官学院 xg.*.edu.cn/xsfwdt/sys/xsqjapp):
// 学校自建「学生请假」应用,走 commoncall/modules 动作网关,用 MOD_AUTH_CAS 会话。
// 按 schoolId 单独适配,见 leave_xsfw.go。
LeaveFlavorXsfw
)
func (f LeaveAppFlavor) String() string {
@@ -166,6 +174,10 @@ func (f LeaveAppFlavor) String() string {
return "new"
case LeaveFlavorOld:
return "old"
case LeaveFlavorXsgz:
return "xsgz"
case LeaveFlavorXsfw:
return "xsfw"
default:
return "unknown"
}
@@ -48,7 +48,7 @@ type LeaveReturnTask struct {
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("User-Agent", UserAgent).
SetHeader("CpdailyClientType", "CPDAILY").
SetHeader("Content-Type", "application/json; charset=UTF-8").
SetHeader("Cookie", cookiesToStr(cookieMap)).
@@ -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 应解析失败")
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,254 @@
package cpdaily
import (
"fmt"
"sort"
"strconv"
"strings"
"testing"
)
// 把前端 studentForm-Dx5Fpjfs.js 的 v()/pe() 忠实转写成参考实现,
// 用它对拍 xsgzFlatten,避免再靠肉眼推断契约细节。
//
// 原文:
//
// function pe(e,t,a){return e?a?`${e}[${t}]`:`${e}.${t}`:t}
// function v(e,t,a,o=false){
// const r=t||new FormData
// for(const n in e){
// if(!Object.prototype.hasOwnProperty.call(e,n))continue
// const s=e[n]
// if(s===void 0)continue
// const i=pe(a,n,o)
// if(s instanceof Date) r.append(i,s.toISOString())
// else if(Array.isArray(s))
// if(s.some(l=>l&&typeof l=="object"))
// s.forEach((l,p)=>{const c=`${i}[${p}]`
// if(l&&typeof l=="object"){ if(l.status==="done")return
// "file"in l&&(l.file instanceof File||l.file instanceof Blob)
// ? r.append(o?`${c}[file]`:`${c}.file`,l.file,...)
// : v(l,r,c,o) }
// else r.append(c,l??""); })
// else { const l=s.map(p=>p??"").join(","); r.append(i,l) }
// else typeof s=="object"&&!(s instanceof File) ? v(s,r,i,o)
// : r.append(i,s??"")
// }
// return r
// }
// jsPe 对应 pe(e,t,a=false):对象键用点号。
func jsPe(prefix, key string) string {
if prefix == "" {
return key
}
return prefix + "." + key
}
// jsFlatten 对应 v(),返回前端会写入 FormData 的键值对。
func jsFlatten(prefix string, v interface{}, out *[][2]string) {
switch val := v.(type) {
case nil:
// JS: null 不是 undefined,会 append(key, null ?? "") 即空串
*out = append(*out, [2]string{prefix, ""})
case map[string]interface{}:
for k, child := range val {
jsFlatten(jsPe(prefix, k), child, out)
}
case []interface{}:
hasObject := false
for _, el := range val {
if _, ok := el.(map[string]interface{}); ok {
hasObject = true
break
}
}
if hasObject {
// 含对象 → 按下标逐个展开
for i, child := range val {
key := fmt.Sprintf("%s[%d]", prefix, i)
if _, ok := child.(map[string]interface{}); ok {
jsFlatten(key, child, out)
} else {
*out = append(*out, [2]string{key, jsScalar(child)})
}
}
return
}
// 全是原始值 → join(",") 成单个键(关键差异点)
parts := make([]string, 0, len(val))
for _, el := range val {
parts = append(parts, jsScalar(el))
}
*out = append(*out, [2]string{prefix, strings.Join(parts, ",")})
case string:
*out = append(*out, [2]string{prefix, val})
case bool:
*out = append(*out, [2]string{prefix, strconv.FormatBool(val)})
default:
*out = append(*out, [2]string{prefix, fmt.Sprintf("%v", val)})
}
}
// jsScalar 对应 JS 的 `l ?? ""` 加隐式字符串化。
func jsScalar(v interface{}) string {
if v == nil {
return ""
}
return fmt.Sprintf("%v", v)
}
// toMap 把键值对转成 map,便于比较。
func toMap(pairs [][2]string) map[string]string {
m := map[string]string{}
for _, kv := range pairs {
m[kv[0]] = kv[1]
}
return m
}
// compareFlatten 对拍:对前端产出的每个非空键值,断言 Go 实现一致。
//
// 已知并有意的差异:前端会以空字符串提交(null → ""),我们跳过空值。
// 对表单提交而言两者对必填校验等价,且跳过更干净,故此处不追究空值键。
func compareFlatten(t *testing.T, name string, payload map[string]interface{}) {
t.Helper()
var jsPairs, goPairs [][2]string
jsFlatten("", payload, &jsPairs)
xsgzFlatten("", payload, &goPairs)
js := toMap(jsPairs)
got := toMap(goPairs)
for k, want := range js {
if want == "" {
continue // 有意差异:我们不提交空值
}
if gotVal, ok := got[k]; !ok {
t.Errorf("%s: 缺少键 %q(前端提交 %q)", name, k, want)
} else if gotVal != want {
t.Errorf("%s: 键 %q 值不一致 got=%q want=%q", name, k, gotVal, want)
}
}
// 反向:我们不应多提交前端没有的键
for k, v := range got {
if v == "" {
continue
}
if _, ok := js[k]; !ok {
t.Errorf("%s: 多出键 %q=%q(前端无此键)", name, k, v)
}
}
}
// TestXsgzFlattenMatchesFrontend 用真实形态的载荷对拍前后端序列化。
func TestXsgzFlattenMatchesFrontend(t *testing.T) {
cases := []struct {
name string
payload map[string]interface{}
}{
{
name: "请假提交(字段含 options 数组)",
payload: map[string]interface{}{
"projectId": "proj-1",
"requestType": "LEAVE",
"startTime": "2026-10-17 08:00:00",
"endTime": "2026-10-18 18:00:00",
"address": "山东农业大学",
"longitude": "117.12",
"latitude": "36.19",
"fields": []interface{}{
map[string]interface{}{
"fieldId": "f1", "fieldName": "请假原因",
"componentType": "TEXT", "fieldType": "TEXT",
"required": true, "value": "感冒",
"options": []interface{}{
map[string]interface{}{"label": "是", "value": "1"},
map[string]interface{}{"label": "否", "value": "0"},
},
},
},
},
},
{
name: "销假提交(含布尔与 originalId)",
payload: map[string]interface{}{
"originalId": "apply-99",
"projectId": "proj-2",
"requestType": "CANCELLATION",
"inArea": true,
"cancellationApplyInArea": false,
"fields": []interface{}{
map[string]interface{}{"fieldId": "c1", "value": "已返校"},
},
},
},
{
name: "多字段多类型",
payload: map[string]interface{}{
"projectId": "p",
"fields": []interface{}{
map[string]interface{}{"fieldId": "a", "value": "文本"},
map[string]interface{}{"fieldId": "b", "value": "x,y"},
map[string]interface{}{"fieldId": "c", "value": "true"},
},
},
},
{
name: "空 fields 数组",
payload: map[string]interface{}{
"projectId": "p",
"fields": []interface{}{},
},
},
{
name: "嵌套 map",
payload: map[string]interface{}{
"projectId": "p",
"applicant": map[string]interface{}{
"xgh": "202412303232", "xm": "张三",
},
},
},
}
for _, c := range cases {
compareFlatten(t, c.name, c.payload)
}
}
// TestXsgzFlattenArrayOfPrimitives 单列数组规则:前端 join(","),我们此前按下标。
func TestXsgzFlattenArrayOfPrimitives(t *testing.T) {
payload := map[string]interface{}{
"tags": []interface{}{"a", "b", "c"},
}
var jsPairs [][2]string
jsFlatten("", payload, &jsPairs)
js := toMap(jsPairs)
t.Logf("前端产出: %v", js)
var goPairs [][2]string
xsgzFlatten("", payload, &goPairs)
got := toMap(goPairs)
t.Logf("Go 产出: %v", got)
// 前端是一个键 tags="a,b,c"
if js["tags"] != "a,b,c" {
t.Fatalf("参考实现异常: %v", js)
}
if got["tags"] != "a,b,c" {
t.Errorf("原始值数组应 join 成单键 tags=a,b,c,实际 %v", got)
}
// 不应出现索引键
keys := make([]string, 0, len(got))
for k := range got {
keys = append(keys, k)
}
sort.Strings(keys)
for _, k := range keys {
if strings.HasPrefix(k, "tags[") {
t.Errorf("不应出现索引键 %q(前端会 join)", k)
}
}
}
@@ -0,0 +1,156 @@
package cpdaily
import "testing"
// 销假地理范围判断,对齐前端 LeaveCancelLocation 的 c():
// Circle/poi 比球面距离,Polygon/Rectangle 用点在多边形内。
type pt = struct {
Longitude float64 `json:"longitude"`
Latitude float64 `json:"latitude"`
}
// TestInLocationRangeEmpty 范围为空时不限制(前端:!r.value.length 时 return true)。
func TestInLocationRangeEmpty(t *testing.T) {
if !InLocationRange(nil, 117.12, 36.19) {
t.Fatal("范围为空应视为不限制")
}
if !InLocationRange([]XsgzLocationRange{}, 117.12, 36.19) {
t.Fatal("空切片应视为不限制")
}
}
// TestInLocationRangeCircle 圆形范围:半径内 true,半径外 false。
func TestInLocationRangeCircle(t *testing.T) {
// 山东农业大学附近,半径 500 米
center := XsgzLocationRange{
CompatibleType: "Circle",
Longitude: 117.12,
Latitude: 36.19,
Radius: 500,
}
// 圆心
if !InLocationRange([]XsgzLocationRange{center}, 117.12, 36.19) {
t.Fatal("圆心应在范围内")
}
// 约 111 米外(纬度 0.001 度 ≈ 111 米)
if !InLocationRange([]XsgzLocationRange{center}, 117.12, 36.191) {
t.Fatal("111 米外应在半径内")
}
// 约 1.1 公里外(0.01 度 ≈ 1111 米),应超出
if InLocationRange([]XsgzLocationRange{center}, 117.12, 36.20) {
t.Fatal("1.1 公里外应超出 500 米半径")
}
}
// TestInLocationRangePoi poi 类型按圆形处理。
func TestInLocationRangePoi(t *testing.T) {
r := XsgzLocationRange{
CompatibleType: "poi",
Longitude: 117.12,
Latitude: 36.19,
Radius: 200,
}
if !InLocationRange([]XsgzLocationRange{r}, 117.12, 36.19) {
t.Fatal("poi 中心应在范围内")
}
if InLocationRange([]XsgzLocationRange{r}, 117.12, 36.25) {
t.Fatal("远处应在范围外")
}
}
// TestInLocationRangePolygon 多边形:内部 true,外部 false。
func TestInLocationRangePolygon(t *testing.T) {
// 一个经纬度平面上的正方形,覆盖 117.10~117.14 × 36.17~36.21
poly := XsgzLocationRange{
CompatibleType: "Polygon",
Points: []pt{
{Longitude: 117.10, Latitude: 36.17},
{Longitude: 117.14, Latitude: 36.17},
{Longitude: 117.14, Latitude: 36.21},
{Longitude: 117.10, Latitude: 36.21},
},
}
ranges := []XsgzLocationRange{poly}
if !InLocationRange(ranges, 117.12, 36.19) {
t.Fatal("中心点应在多边形内")
}
if InLocationRange(ranges, 117.20, 36.19) {
t.Fatal("右侧外部点不应在多边形内")
}
if InLocationRange(ranges, 117.12, 36.30) {
t.Fatal("上方外部点不应在多边形内")
}
}
// TestInLocationRangeRectangle 矩形按多边形处理。
func TestInLocationRangeRectangle(t *testing.T) {
rect := XsgzLocationRange{
CompatibleType: "Rectangle",
Points: []pt{
{Longitude: 117.10, Latitude: 36.17},
{Longitude: 117.14, Latitude: 36.17},
{Longitude: 117.14, Latitude: 36.21},
{Longitude: 117.10, Latitude: 36.21},
},
}
if !InLocationRange([]XsgzLocationRange{rect}, 117.13, 36.18) {
t.Fatal("矩形内点应命中")
}
if InLocationRange([]XsgzLocationRange{rect}, 117.15, 36.18) {
t.Fatal("矩形外点不应命中")
}
}
// TestInLocationRangeMultiple 任一范围命中即可。
func TestInLocationRangeMultiple(t *testing.T) {
ranges := []XsgzLocationRange{
{CompatibleType: "Circle", Longitude: 117.12, Latitude: 36.19, Radius: 100},
{CompatibleType: "Circle", Longitude: 117.50, Latitude: 36.50, Radius: 100},
}
if !InLocationRange(ranges, 117.50, 36.50) {
t.Fatal("第二个圆应命中")
}
if InLocationRange(ranges, 118.00, 37.00) {
t.Fatal("都不应命中")
}
}
// TestPointInPolygonDegenerate 少于 3 个点时不构成多边形。
func TestPointInPolygonDegenerate(t *testing.T) {
if pointInPolygon(0, 0, nil) {
t.Fatal("空点集不应命中")
}
one := []pt{{Longitude: 0, Latitude: 0}}
if pointInPolygon(0, 0, one) {
t.Fatal("单点不应命中")
}
two := []pt{{Longitude: 0, Latitude: 0}, {Longitude: 1, Latitude: 1}}
if pointInPolygon(0.5, 0.5, two) {
t.Fatal("两点不应命中")
}
}
// TestXsgzLeaveRecordRequiresInArea 归一化前端 `=== true || === "是"` 的语义。
func TestXsgzLeaveRecordRequiresInArea(t *testing.T) {
cases := []struct {
name string
val interface{}
want bool
}{
{"布尔 true", true, true},
{"布尔 false", false, false},
{"字符串 是", "是", true},
{"字符串 否", "否", false},
{"字符串 true", "true", false},
{"nil", nil, false},
{"数字", float64(1), false},
}
for _, c := range cases {
rec := XsgzLeaveRecord{CancellationApplyInArea: c.val}
if got := rec.RequiresInArea(); got != c.want {
t.Fatalf("%s: RequiresInArea()=%v, want %v", c.name, got, c.want)
}
}
}
@@ -0,0 +1,594 @@
package cpdaily
import (
"context"
"mime"
"mime/multipart"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
"github.com/go-resty/resty/v2"
)
// 端到端契约回归:用假学工服务器接收真实请求,断言我们发出的 multipart 表单
// 与前端 studentForm-Dx5Fpjfs.js 的 v()/pe() 序列化 + application_submit 行为一致。
//
// 前端真实链路(LeaveApplyDrawer-BeaVSGRQ.js 的 P()):
//
// c = ee(l.value, r.value) // 表单值
// L = ae(c, r.value) // fields 数组
// T = {projectId, requestType:"LEAVE", startTime, endTime, fields:L, address, longitude, latitude}
// f = le(T, D.value) // → Ne→v→pe,转 FormData
// await te(f) // POST application_submit (multipart)
//
// 真实学校接口无法在未登录场景下验证,但请求体的**形状**是纯客户端产物,
// 可以用本地假服务器精确断言,覆盖「参数名、点号摊平、空值跳过、数组下标」。
// newXsgzTestSession 构造一个指向假服务器的会话。
// XsgzSession 的 baseURL/client 为非导出字段,测试同包可直接设置。
func newXsgzTestSession(t *testing.T, srv *httptest.Server) *XsgzSession {
t.Helper()
return &XsgzSession{
Token: "test-access-token",
RoleID: "role-123",
baseURL: srv.URL,
client: resty.New(),
}
}
// captureMultipart 记录一次提交请求的表单元数据与键值。
type captureMultipart struct {
contentType string
authHeader string
roleHeader string
path string
form map[string]string
// 出现顺序,用于确认 fields[] 按下标展开
keys []string
}
// readMultipart 把请求体解析为字符串键值(忽略文件部分)。
func (c *captureMultipart) readMultipart(t *testing.T, r *http.Request) {
t.Helper()
c.path = r.URL.Path
c.contentType = r.Header.Get("Content-Type")
c.authHeader = r.Header.Get("Authorization")
c.roleHeader = r.Header.Get("roleId")
c.form = map[string]string{}
mediaType, params, err := mime.ParseMediaType(c.contentType)
if err != nil {
t.Fatalf("解析 Content-Type 失败: %v", err)
}
if mediaType != "multipart/form-data" {
t.Fatalf("Content-Type 应为 multipart/form-data,实际 %q", mediaType)
}
boundary := params["boundary"]
if boundary == "" {
t.Fatal("缺少 multipart boundary")
}
mr := multipart.NewReader(r.Body, boundary)
for {
part, err := mr.NextPart()
if err != nil {
break
}
name := part.FormName()
if name == "" {
continue // 文件部分
}
var sb strings.Builder
buf := make([]byte, 512)
for {
n, rerr := part.Read(buf)
if n > 0 {
sb.Write(buf[:n])
}
if rerr != nil {
break
}
}
c.form[name] = sb.String()
c.keys = append(c.keys, name)
}
}
// TestXsgzSubmitLeaveMultipartContract 请假提交:断言表单键名与摊平规则。
func TestXsgzSubmitLeaveMultipartContract(t *testing.T) {
cap := &captureMultipart{}
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
cap.readMultipart(t, r)
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"code":0,"message":"ok"}`))
}))
defer srv.Close()
sess := newXsgzTestSession(t, srv)
fields := []map[string]interface{}{
{"fieldId": "f1", "value": "身体不适", "componentType": "TEXT"},
{"fieldId": "f2", "value": "回家休息", "componentType": "TEXTAREA"},
// 空值字段:前端 toFormatFormData 会跳过空字符串
{"fieldId": "f3", "value": "", "componentType": "TEXT"},
}
body, err := sess.XsgzSubmitLeave(context.Background(), XsgzSubmitPayload{
ProjectID: "proj-uuid-1",
StartTime: "2026-10-17 08:00:00",
EndTime: "2026-10-18 18:00:00",
Address: "山东农业大学",
Longitude: "117.12",
Latitude: "36.19",
Fields: fields,
})
if err != nil {
t.Fatalf("提交失败: %v", err)
}
if !strings.Contains(string(body), `"code":0`) {
t.Fatalf("响应解析异常: %s", body)
}
// 路径与鉴权头
if cap.path != "/api/leave/student_action/application_submit" {
t.Fatalf("路径错误: %s", cap.path)
}
if cap.authHeader != "test-access-token" {
t.Fatalf("Authorization 头错误: %q", cap.authHeader)
}
if cap.roleHeader != "role-123" {
t.Fatalf("roleId 头错误: %q", cap.roleHeader)
}
// 顶层字段名必须与前端一致
wantTop := map[string]string{
"projectId": "proj-uuid-1",
"requestType": "LEAVE",
"startTime": "2026-10-17 08:00:00",
"endTime": "2026-10-18 18:00:00",
"address": "山东农业大学",
"longitude": "117.12",
"latitude": "36.19",
}
for k, want := range wantTop {
if got := cap.form[k]; got != want {
t.Fatalf("表单键 %q = %q, want %q", k, got, want)
}
}
// fields 必须是「数组下标方括号 + 对象键点号」:
// studentForm-Dx5Fpjfs.js: const c = `${i}[${p}]`(数组下标硬编码方括号)
// pe(e,t,a) → a? `${e}[${t}]` : `${e}.${t}`
if got := cap.form["fields[0].fieldId"]; got != "f1" {
t.Fatalf("fields[0].fieldId = %q, want f1", got)
}
if got := cap.form["fields[0].value"]; got != "身体不适" {
t.Fatalf("fields[0].value = %q", got)
}
if got := cap.form["fields[1].value"]; got != "回家休息" {
t.Fatalf("fields[1].value = %q", got)
}
// 空值字段的 value 不应出现
if _, ok := cap.form["fields[2].value"]; ok {
t.Fatal("空字符串 value 应被跳过,但出现了 fields[2].value")
}
// 但该字段的非空属性仍应提交
if got := cap.form["fields[2].fieldId"]; got != "f3" {
t.Fatalf("fields[2].fieldId = %q", got)
}
// 数组下标之后必须紧跟点号再接对象键,不能出现 fields[0][fieldId] 之类
for k := range cap.form {
if strings.Count(k, "[") != strings.Count(k, "]") {
t.Fatalf("键 %q 括号不配对", k)
}
if strings.Contains(k, "][") && !strings.HasPrefix(k, "fields[") {
t.Fatalf("键 %q 形如二维数组下标,前端不会这样提交", k)
}
}
// 请假请求不应带销假专有字段
if _, ok := cap.form["originalId"]; ok {
t.Fatal("请假不应携带 originalId")
}
if _, ok := cap.form["inArea"]; ok {
t.Fatal("请假不应携带 inArea")
}
}
// TestXsgzSubmitCancelMultipartContract 销假提交:断言 originalId 与在范围标记。
func TestXsgzSubmitCancelMultipartContract(t *testing.T) {
cap := &captureMultipart{}
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
cap.readMultipart(t, r)
_, _ = w.Write([]byte(`{"code":0}`))
}))
defer srv.Close()
sess := newXsgzTestSession(t, srv)
_, err := sess.XsgzSubmitCancel(context.Background(), XsgzSubmitPayload{
ProjectID: "proj-uuid-2",
OriginalID: "orig-apply-99",
Address: "山东农业大学",
Longitude: "117.12",
Latitude: "36.19",
InArea: true,
CancellationApplyInArea: true,
Fields: []map[string]interface{}{
{"fieldId": "c1", "value": "已返校"},
},
})
if err != nil {
t.Fatalf("销假提交失败: %v", err)
}
if got := cap.form["requestType"]; got != "CANCELLATION" {
t.Fatalf("requestType = %q, want CANCELLATION", got)
}
if got := cap.form["originalId"]; got != "orig-apply-99" {
t.Fatalf("originalId = %q, want orig-apply-99", got)
}
// bool 必须序列化为 true/false 字面量(前端 JS 行为)
if got := cap.form["inArea"]; got != "true" {
t.Fatalf("inArea = %q, want true", got)
}
if got := cap.form["cancellationApplyInArea"]; got != "true" {
t.Fatalf("cancellationApplyInArea = %q, want true", got)
}
// 销假不应带 startTime/endTime(未提供时)
if _, ok := cap.form["startTime"]; ok {
t.Fatal("销假未提供 startTime 时不应提交该键")
}
if got := cap.form["fields[0].value"]; got != "已返校" {
t.Fatalf("fields[0].value = %q", got)
}
}
// TestXsgzSubmitCancelRequiresOriginalID 销假缺少原单号时必须本地拦截。
func TestXsgzSubmitCancelRequiresOriginalID(t *testing.T) {
called := false
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
called = true
_, _ = w.Write([]byte(`{"code":0}`))
}))
defer srv.Close()
sess := newXsgzTestSession(t, srv)
_, err := sess.XsgzSubmitCancel(context.Background(), XsgzSubmitPayload{ProjectID: "p"})
if err == nil {
t.Fatal("缺少 originalId 应报错")
}
if !strings.Contains(err.Error(), "originalId") {
t.Fatalf("错误信息应提到 originalId,实际: %v", err)
}
if called {
t.Fatal("参数校验失败时不应发起请求")
}
}
// TestXsgzSubmitRequiresProjectID 请假缺少 projectId 时必须本地拦截。
func TestXsgzSubmitRequiresProjectID(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
t.Fatal("不应发起请求")
}))
defer srv.Close()
sess := newXsgzTestSession(t, srv)
if _, err := sess.XsgzSubmitLeave(context.Background(), XsgzSubmitPayload{Address: "x"}); err == nil {
t.Fatal("缺少 projectId 应报错")
}
}
// TestXsgzSubmitEmptyFieldsOmitted fields 为空时不应提交 fields 相关键。
func TestXsgzSubmitEmptyFieldsOmitted(t *testing.T) {
cap := &captureMultipart{}
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
cap.readMultipart(t, r)
_, _ = w.Write([]byte(`{"code":0}`))
}))
defer srv.Close()
sess := newXsgzTestSession(t, srv)
if _, err := sess.XsgzSubmitLeave(context.Background(), XsgzSubmitPayload{
ProjectID: "p1", Address: "a", Longitude: "1", Latitude: "2",
}); err != nil {
t.Fatalf("提交失败: %v", err)
}
for k := range cap.form {
if strings.HasPrefix(k, "fields") {
t.Fatalf("fields 为空时不应提交 %q", k)
}
}
}
// TestXsgzFetchLeaveDetailContract 详情接口:路径、参数名、标记解析。
func TestXsgzFetchLeaveDetailContract(t *testing.T) {
var gotPath, gotLeaveID string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotPath = r.URL.Path
gotLeaveID = r.URL.Query().Get("leaveId")
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"code":0,"data":{
"id":"apply-1","projectId":"proj-1","projectName":"病假",
"status":"休假中","cancellationApplyInArea":true
}}`))
}))
defer srv.Close()
sess := newXsgzTestSession(t, srv)
d, err := sess.XsgzFetchLeaveDetail(context.Background(), "apply-1")
if err != nil {
t.Fatalf("查询详情失败: %v", err)
}
if gotPath != "/api/leave/student_action/leave_detail" {
t.Fatalf("路径错误: %s", gotPath)
}
// 前端用 params 传递,键名必须是 leaveId
if gotLeaveID != "apply-1" {
t.Fatalf("leaveId 查询参数错误: %q", gotLeaveID)
}
if d == nil || d.ID != "apply-1" || d.ProjectID != "proj-1" {
t.Fatalf("详情解析异常: %+v", d)
}
if !d.RequiresInArea() {
t.Fatal("cancellationApplyInArea=true 应判定为需要范围内申请")
}
}
// TestXsgzFetchLeaveDetailStringYes 该标记也可能是字符串「是」。
func TestXsgzFetchLeaveDetailStringYes(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write([]byte(`{"code":0,"data":{"id":"a","cancellationApplyInArea":"是"}}`))
}))
defer srv.Close()
d, err := newXsgzTestSession(t, srv).XsgzFetchLeaveDetail(context.Background(), "a")
if err != nil {
t.Fatalf("查询详情失败: %v", err)
}
if !d.RequiresInArea() {
t.Fatal("字符串「是」应判定为需要范围内申请")
}
}
// TestXsgzFetchLeaveDetailError 非 0 code 必须报错,不能当成空详情。
func TestXsgzFetchLeaveDetailError(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write([]byte(`{"code":400,"message":"记录不存在"}`))
}))
defer srv.Close()
if _, err := newXsgzTestSession(t, srv).XsgzFetchLeaveDetail(context.Background(), "x"); err == nil {
t.Fatal("code!=0 应报错")
}
}
// TestXsgzFetchUserRoleContract /auth/user 取 data.role.roleId。
//
// 前端不依赖 cas/callback 返回 roleId:
//
// getOAuthToken 只存 access_token
// getUserInfo → POST /auth/user → data.role.roleId → 注入请求头
func TestXsgzFetchUserRoleContract(t *testing.T) {
var gotPath, gotMethod string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotPath, gotMethod = r.URL.Path, r.Method
_, _ = w.Write([]byte(`{"code":0,"data":{
"realName":"张三",
"role":{"roleId":"role-777","roleName":"学生"}
}}`))
}))
defer srv.Close()
sess := newXsgzTestSession(t, srv)
info, err := sess.fetchUserRole(context.Background())
if err != nil {
t.Fatalf("获取用户角色失败: %v", err)
}
if gotPath != "/api/auth/user" {
t.Fatalf("路径错误: %s", gotPath)
}
if gotMethod != http.MethodPost {
t.Fatalf("方法应为 POST,实际 %s", gotMethod)
}
if info.RoleID != "role-777" || info.RoleName != "学生" || info.RealName != "张三" {
t.Fatalf("解析异常: %+v", info)
}
}
// TestXsgzFetchUserRoleFallbackXm realName 缺失时回退 xm。
func TestXsgzFetchUserRoleFallbackXm(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write([]byte(`{"code":0,"data":{"xm":"李四","role":{"roleId":"r1"}}}`))
}))
defer srv.Close()
info, err := newXsgzTestSession(t, srv).fetchUserRole(context.Background())
if err != nil {
t.Fatalf("失败: %v", err)
}
if info.RealName != "李四" {
t.Fatalf("应回退 xm,实际 %q", info.RealName)
}
}
// TestXsgzFetchUserRoleError 非 0 code 必须报错。
func TestXsgzFetchUserRoleError(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write([]byte(`{"code":401,"message":"未登录"}`))
}))
defer srv.Close()
if _, err := newXsgzTestSession(t, srv).fetchUserRole(context.Background()); err == nil {
t.Fatal("code!=0 应报错")
}
}
// casCallbackBody 复现 CasTicketForService → XsgzLogin 的 body 构造。
//
// 前端 getOAuthToken 是 `ticket=${s}` 直接拼接,不做 URL 编码:
//
// service.post("/cas/callback", `ticket=${s}`,
// {headers:{"Content-Type":"application/x-www-form-urlencoded"}})
//
// 密文多块之间是**空格**;若用 url.QueryEscape 会变成 '+',
// 服务端按原始 body 解析时解不回来,报「参数解码错误,刷新页面后尝试」。
func casCallbackBody(enc string) string {
return "ticket=" + enc
}
// TestCasCallbackBodyKeepsSpaces 密文空格必须原样保留,不能变成 '+'。
func TestCasCallbackBodyKeepsSpaces(t *testing.T) {
// 用真实加密产物验证:多块时含空格
r := &xsgzRSA{DK: vecXsgzDK, M: vecXsgzM, E: vecXsgzE}
long := vecXsgzTicket
for i := 0; i < 10; i++ {
long += "0123456789"
}
enc, err := xsgzEncryptTicket(long, r)
if err != nil {
t.Fatalf("加密失败: %v", err)
}
if !strings.Contains(enc, " ") {
t.Skip("该密钥长度下未产生多块,无法验证空格")
}
body := casCallbackBody(enc)
if strings.Contains(body, "+") {
t.Fatal("body 不应含 '+':服务端会当成空格以外的字符导致解密失败")
}
if !strings.Contains(body, "ticket="+enc) {
t.Fatal("body 应为 ticket=<原始密文>")
}
// 关键:空格未被编码
if got := strings.Count(body, " "); got != strings.Count(enc, " ") {
t.Fatalf("空格数量被改变:enc=%d body=%d", strings.Count(enc, " "), got)
}
}
// 用 url.QueryEscape 会改变空格,作为反例固化「为什么不能用它」。
func TestQueryEscapeWouldBreakTicket(t *testing.T) {
plain := "ticket=aaa bbb"
escaped := "ticket=" + url.QueryEscape("aaa bbb")
if escaped == plain {
t.Fatal("前提失效:QueryEscape 未改变空格")
}
if !strings.Contains(escaped, "+") {
t.Fatalf("QueryEscape 应把空格编成 '+',实际 %q", escaped)
}
}
// TestXsgzFetchLeaveDetailNestedProject 项目 id 嵌在 project 对象时也要取到。
//
// 前端取 projectId 是 `c.value?.project?.id || c.value?.projectId`。
// 销假缺 projectId 会直接报「缺少请假类型(projectId)」(线上 userId=1490)。
func TestXsgzFetchLeaveDetailNestedProject(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write([]byte(`{"code":0,"data":{
"id":"apply-1",
"project":{"id":"proj-nested","text":"病假"},
"status":"休假中"
}}`))
}))
defer srv.Close()
d, err := newXsgzTestSession(t, srv).XsgzFetchLeaveDetail(context.Background(), "apply-1")
if err != nil {
t.Fatalf("查询详情失败: %v", err)
}
if d.ProjectID != "proj-nested" {
t.Fatalf("应从 project.id 取 projectId,实际 %q", d.ProjectID)
}
if d.ProjectName != "病假" {
t.Fatalf("应从 project.text 取项目名,实际 %q", d.ProjectName)
}
}
// 顶层 projectId 优先于 project.id。
func TestXsgzFetchLeaveDetailTopLevelWins(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write([]byte(`{"code":0,"data":{
"id":"a","projectId":"proj-top","project":{"id":"proj-nested"}
}}`))
}))
defer srv.Close()
d, err := newXsgzTestSession(t, srv).XsgzFetchLeaveDetail(context.Background(), "a")
if err != nil {
t.Fatalf("失败: %v", err)
}
if d.ProjectID != "proj-top" {
t.Fatalf("顶层 projectId 应优先,实际 %q", d.ProjectID)
}
}
// TestCasTicketNameVariants CAS 回调 ticket 可能用三种参数名。
// 前端 OAuthCallback:const {ticket:a, service_ticket:e, ST:r} = n.query
func TestCasTicketNameVariants(t *testing.T) {
cases := []struct{ name, rawQuery string }{
{"ticket", "ticket=ST-1"},
{"service_ticket", "service_ticket=ST-2"},
{"ST", "ST=ST-3"},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
u, err := url.Parse("https://xsgz.sdau.edu.cn/oauth/callback?" + c.rawQuery)
if err != nil {
t.Fatal(err)
}
tk := u.Query().Get("ticket")
if tk == "" {
tk = u.Query().Get("service_ticket")
}
if tk == "" {
tk = u.Query().Get("ST")
}
if tk == "" {
t.Fatalf("参数名 %s 未取到 ticket", c.name)
}
})
}
}
// truncateForLog 截断保护:不刷日志、不切断多字节字符。
func TestTruncateForLog(t *testing.T) {
if got := truncateForLog("abc", 10); got != "abc" {
t.Fatalf("短串不应截断,实际 %q", got)
}
long := strings.Repeat("中", 400)
got := truncateForLog(long, 300)
if !strings.HasSuffix(got, "...(truncated)") {
t.Fatal("超长应加截断标记")
}
// 中文按 rune 截断,不应出现乱码(每个中文字仍是完整字符)
body := strings.TrimSuffix(got, "...(truncated)")
if len([]rune(body)) != 300 {
t.Fatalf("应保留 300 个 rune,实际 %d", len([]rune(body)))
}
for _, r := range body {
if r != '中' {
t.Fatalf("出现乱码字符 %q", r)
}
}
if got := truncateForLog("x", 0); got != "" {
t.Fatalf("maxRunes<=0 应返回空串,实际 %q", got)
}
}
// isXsgzDecodeError 只对「参数解码错误」为真,其它错误不应触发重试。
func TestIsXsgzDecodeError(t *testing.T) {
// resty.Response.Body() 要求 RawResponse 非 nil,否则返回空切片
mk := func(body string) *resty.Response {
r := &resty.Response{RawResponse: &http.Response{StatusCode: 422}}
r.SetBody([]byte(body))
return r
}
if !isXsgzDecodeError(mk(`{"code":1,"message":"参数解码错误,刷新页面后尝试"}`)) {
t.Fatal("应识别为解码错误")
}
if isXsgzDecodeError(mk(`{"code":1,"message":"用户名或密码错误"}`)) {
t.Fatal("凭证错误不应触发重试")
}
if isXsgzDecodeError(nil) {
t.Fatal("nil 不应判定为解码错误")
}
}
@@ -0,0 +1,145 @@
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"))
}
}
// xsgzFlatten 必须与前端 studentForm-Dx5Fpjfs.js 的 v()/pe() 规则一致:
// 对象键用点号、数组下标用方括号、跳过空值。
func TestXsgzFlatten(t *testing.T) {
in := map[string]interface{}{
"projectId": "uuid-1",
"requestType": "CANCELLATION",
"address": "", // 空字符串应跳过
"longitude": "115.8",
"inArea": true,
"empty": nil, // nil 应跳过
"fields": []interface{}{
map[string]interface{}{"fieldId": "f1", "value": "v1", "blank": ""},
map[string]interface{}{"fieldId": "f2", "value": "v2"},
},
}
var pairs [][2]string
xsgzFlatten("", in, &pairs)
got := map[string]string{}
for _, kv := range pairs {
got[kv[0]] = kv[1]
}
want := map[string]string{
"projectId": "uuid-1",
"requestType": "CANCELLATION",
"longitude": "115.8",
"inArea": "true",
"fields[0].fieldId": "f1",
"fields[0].value": "v1",
"fields[1].fieldId": "f2",
"fields[1].value": "v2",
}
for k, v := range want {
if got[k] != v {
t.Fatalf("key %q = %q, want %q(实际: %v)", k, got[k], v, got)
}
}
// 空值不应出现
for _, bad := range []string{"address", "empty", "fields[0].blank"} {
if _, ok := got[bad]; ok {
t.Fatalf("空值键 %q 不应被提交", bad)
}
}
}
@@ -0,0 +1,173 @@
package cpdaily
import (
"bytes"
"context"
"crypto/aes"
"crypto/cipher"
"encoding/base64"
"encoding/json"
"fmt"
"log/slog"
"net/http"
"regexp"
"strings"
"github.com/PuerkitoBio/goquery"
"github.com/go-resty/resty/v2"
)
// BzuuSchoolId 亳州学院(pending_schools id=27)
const BzuuSchoolId = "1021882682406205"
// 亳州学院 zs 主题 CAS 的固定密码加密密钥(来自 themes/zs/js/casLoginView.js)
const (
bzuuAesKey = "1234567890adbcde"
bzuuAesIv = "1234567890hjlkew"
)
// bzuuEncryptPassword 对齐 casLoginView.js 的 encryption():
// AES-128-CBC + ZeroPadding,输出 base64。
func bzuuEncryptPassword(password string) string {
block, err := aes.NewCipher([]byte(bzuuAesKey))
if err != nil {
return password
}
data := []byte(password)
if rem := len(data) % block.BlockSize(); rem != 0 {
data = append(data, bytes.Repeat([]byte{0}, block.BlockSize()-rem)...)
}
if len(data) == 0 {
return ""
}
out := make([]byte, len(data))
cipher.NewCBCEncrypter(block, []byte(bzuuAesIv)).CryptBlocks(out, data)
return base64.StdEncoding.EncodeToString(out)
}
var bzuuSessionValueRe = regexp.MustCompile(`id\s*=\s*"sessionValue"[^>]*>([^<]+)<`)
// bzuuCasLogin 亳州学院登录适配。
//
// 平台侧配置 joinType=CLOUD(IsIap=true)、idsUrl=https://bzuu.campusphere.net/iap,
// 但真实链路是学校统一身份认证(auth.bzuu.edu.cn/authserver,zs 主题定制 CAS),
// 且该校**不校验表单明文密码**:账号密码必须先 POST /authserver/userLogin 完成校验,
// 密码用固定 key/iv 做 AES-CBC(ZeroPadding)+base64;校验通过后再提交登录表单换取票据。
// 只按通用 CAS 提交明文密码会稳定返回 401「登录失败,请检查用户名和密码!」。
func bzuuCasLogin(ctx context.Context, school *SchoolDetailInfo, username, password string) ([]*http.Cookie, *MFAContext, error) {
sc := newSessionClient()
// 1. 进入网关,跟随重定向到学校 CAS 登录页
loginUrl := getCasValidLoginUrl(school)
pageResp, err := sc.R().SetContext(ctx).Get(loginUrl)
if err != nil {
return nil, nil, fmt.Errorf("亳州学院 CAS 登录失败: 访问登录页失败: %w", err)
}
finalURL := pageResp.RawResponse.Request.URL
pageBody := readBody(pageResp)
// 2. 页面内嵌的 sessionValue 必须以 SESSION cookie 形式带上,否则表单提交被判无效
if m := bzuuSessionValueRe.FindSubmatch(pageBody); len(m) > 1 {
sc.SetCookie(&http.Cookie{
Name: "SESSION", Value: strings.TrimSpace(string(m[1])),
Path: "/", Domain: finalURL.Host,
})
}
// 3. 解析表单隐藏域(execution/_eventId/geolocation)
inputs := parseLoginForm(pageBody, username, password)
execution := inputs["execution"]
eventId := inputs["_eventId"]
geolocation := inputs["geolocation"]
// 4. 加密密码并调用 userLogin 完成账号密码校验
encPwd := bzuuEncryptPassword(password)
base := fmt.Sprintf("%s://%s", finalURL.Scheme, finalURL.Host)
ulResp, err := sc.R().
SetContext(ctx).
SetFormData(map[string]string{
"serviceUrl": finalURL.String(),
"username": username,
"password": encPwd,
"loginCaptcha": "",
}).
Post(base + "/authserver/userLogin")
if err != nil {
return nil, nil, fmt.Errorf("亳州学院 CAS 登录失败: 提交账号密码失败: %w", err)
}
ulBody := readBody(ulResp)
var ul struct {
Success bool `json:"success"`
Message string `json:"message"`
IsFirstLogin bool `json:"isFirstLogin"`
IsVerification bool `json:"is_Verification"`
}
if err := json.Unmarshal(ulBody, &ul); err != nil {
return nil, nil, fmt.Errorf("亳州学院 CAS 登录失败: 无法解析登录响应")
}
if !ul.Success {
msg := ul.Message
if msg == "" {
msg = "登录失败,请检查用户名和密码!"
}
return nil, nil, fmt.Errorf("%s", msg)
}
if ul.IsFirstLogin {
return nil, nil, fmt.Errorf("该校要求首次登录在网页端完善个人信息后才能自动登录")
}
// 5. 校验通过后提交登录表单换取 CAS 票据(密码同样为密文)。
// 禁跳转以直接从 302 Location 拿到 ticket。
sc.SetRedirectPolicy(resty.NoRedirectPolicy())
formResp, err := sc.R().
SetContext(ctx).
SetFormData(map[string]string{
"username": username,
"password": encPwd,
"execution": execution,
"_eventId": eventId,
"geolocation": geolocation,
"key": randomString(32),
"captcha": "",
"submit": "登录",
}).
Post(finalURL.String())
if err != nil {
if formResp == nil || formResp.StatusCode() != 302 {
return nil, nil, fmt.Errorf("亳州学院 CAS 登录失败: 提交登录表单失败: %w", err)
}
}
if formResp == nil {
return nil, nil, fmt.Errorf("亳州学院 CAS 登录失败: 登录表单无响应")
}
switch formResp.StatusCode() {
case 302:
slog.Info("亳州学院 CAS 登录成功", "redirect", formResp.Header().Get("Location"))
cookies, err := handleCasLoginSuccess(ctx, formResp, school, sc)
return cookies, nil, err
case 200, 401:
doc, derr := goquery.NewDocumentFromReader(bytes.NewReader(readBody(formResp)))
msg := ""
if derr == nil {
msg = getCasErrorMessage(doc)
}
if msg == "" {
msg = "登录失败,请检查用户名和密码!"
}
// 学校端对频繁失败会下发 MORETHANLIMIT,此时即使账号密码正确也会报错
if strings.Contains(strings.ToLower(fmt.Sprint(formResp.Header())), "morethanlimit") {
msg += "(登录尝试过于频繁,已被学校限制,请稍后再试)"
}
return nil, nil, fmt.Errorf("%s", msg)
default:
rawBody := readBody(formResp)
if d, derr := goquery.NewDocumentFromReader(bytes.NewReader(rawBody)); derr == nil {
slog.Error("亳州学院表单提交异常", "code", formResp.StatusCode(), "text", truncateForLog(strings.Join(strings.Fields(d.Text()), " "), 600))
} else {
slog.Error("亳州学院表单提交异常", "code", formResp.StatusCode(), "body", truncateForLog(string(rawBody), 500))
}
return nil, nil, fmt.Errorf("亳州学院 CAS 登录返回异常状态码: %d", formResp.StatusCode())
}
}
@@ -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)
}
})
}
}
+94 -7
View File
@@ -9,6 +9,7 @@ import (
"log/slog"
"net/http"
"net/url"
"regexp"
"strings"
"time"
@@ -114,17 +115,18 @@ func casLogin(ctx context.Context, school *SchoolDetailInfo, username, password
needCaptcha, loginType := detectCaptcha(ctx, loginResponseUrl, username, sc.Client)
slog.Info("CAS验证码检测", "needCaptcha", needCaptcha, "loginType", loginType)
if needCaptcha {
if isSliderCaptchaSchool(school.ID) {
// 滑块验证码学校(wisedu 定制 CAS 滑块,如华侨大学):
// openSliderCaptcha 取图 → 打码平台识别 → 等激活窗口 → sign 协议提交 verifySliderCaptcha
//(提交打码值 v、v+1、v-1,失败换图重试,详见 slider.go),
// 通过后 captcha 字段留空提交
// 验证码形态以登录页实际配置为准,而不是只看学校静态配置:
// 同一学校的 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 {
} else if mode == casCaptchaImage || !isSliderCaptchaSchool(school.ID) {
captchaCode := resolveOcrCaptcha(ctx, loginResponseUrl, loginType, sc.Client)
slog.Info("CAS验证码OCR", "code", captchaCode)
switch loginType {
@@ -133,6 +135,13 @@ func casLogin(ctx context.Context, school *SchoolDetailInfo, username, password
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滑块验证通过")
}
}
@@ -233,6 +242,14 @@ func parseLoginForm(page []byte, username, password string) map[string]string {
}
}
// 兜底选择器 "form" 常命中多个表单(移动端账密、手机号、二维码等)。全部 input
// 合并会把 phone/valiCode/key 等无关字段一起提交,学校端可能因此按手机号或验证码
// 分支校验而拒绝账密登录(亳州学院实测 401)。此处优先锁定纯账密表单。
if formSel.Length() > 1 {
formSel = pickCredentialForm(formSel)
slog.Info("CAS表单锁定账密表单", "count", formSel.Length())
}
formSel.Find("input").Each(func(_ int, s *goquery.Selection) {
name, _ := s.Attr("name")
value, _ := s.Attr("value")
@@ -273,7 +290,74 @@ func parseLoginForm(page []byte, username, password string) map[string]string {
return inputs
}
// 检测是否需要验证码
// pickCredentialForm 在多个表单中挑出最"纯"的账密表单。
//
// 评分:必须含密码框;不含手机号/短信码字段加分;不含 key/captcha(滑块、图片验证码
// 专用隐藏域)再加分。页面同时存在移动端账密(带 key/captcha)与 PC 账密表单时,
// 选后者——把空 key/captcha 一起提交会给学校端造成"要求验证码"的误判。
func pickCredentialForm(forms *goquery.Selection) *goquery.Selection {
var best *goquery.Selection
bestScore := -1
forms.Each(func(_ int, s *goquery.Selection) {
hasPassword := s.Find("input[type=password]").Length() > 0 ||
s.Find("input[name=password]").Length() > 0
if !hasPassword {
return
}
score := 10
if s.Find("input[name=phone], input[name=valiCode], input[name=phoneCode]").Length() == 0 {
score += 2
}
if s.Find("input[name=key]").Length() == 0 {
score++
}
if s.Find("input[name=captcha]").Length() == 0 {
score++
}
if score > bestScore {
best, bestScore = s, score
}
})
if best != nil {
return best
}
return forms.First()
}
// 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)
@@ -395,6 +479,9 @@ func getCasErrorMessage(doc *goquery.Document) string {
selectors := []string{
"#msg", "#loginError", ".login-error",
".error", "#error", "p.error",
// zs 主题(亳州学院等)把错误提示放在 .warn-text 里,如
// <div class="warn-text"><span style="color:#ff0000">登录失败,请检查用户名和密码!</span>
".warn-text", ".warn-text span", "#showErrorTip",
}
for _, sel := range selectors {
msg := strings.TrimSpace(doc.Find(sel).Text())
+87 -7
View File
@@ -2,6 +2,8 @@ package cpdaily
import (
"context"
"encoding/base64"
"encoding/json"
"fmt"
"log/slog"
"net/http"
@@ -35,11 +37,14 @@ const HfnuSchoolId = "hfnu"
var hfnuJsessionidRegexp = regexp.MustCompile(`;jsessionid=([A-Za-z0-9._-]+)`)
// hfnuLtKey 页面 JS 中固定写入的 lt 值(var keys = "e1s1")
const hfnuLtKey = "e1s1"
// hfnuLogin 合肥师范学院专用登录
func hfnuLogin(ctx context.Context, school *SchoolDetailInfo, username, password string) ([]*http.Cookie, error) {
sc := newSessionClient()
loginUrl := getCasValidLoginUrl(school)
loginUrl := hfnuLoginEntry(school)
if loginUrl == "" {
return nil, fmt.Errorf("该校登录配置不完整(缺少登录地址)")
}
@@ -71,15 +76,47 @@ func hfnuLogin(ctx context.Context, school *SchoolDetailInfo, username, password
}
slog.Info("合肥师范验证码OCR", "code", captchaCode)
// 4. 提交登录(禁用重定向,登录成功返回 302)
// 4. 先校验验证码:页面 JS 在真正 submit 之前会 AJAX 调
// /sso/code/validationCode.jsp(POST validateCode),只有返回
// {"success":true} 才继续。跳过这一步服务端直接退回登录页,
// 且不给任何错误提示(实测:正确密码与错误密码表现完全一致)。
validationUrl, vErr := pageURL.Parse("code/validationCode.jsp")
if vErr != nil {
return nil, fmt.Errorf("解析验证码校验地址失败: %w", vErr)
}
if m := hfnuJsessionidRegexp.FindStringSubmatch(postUrl); len(m) > 1 {
validationUrl.Path = validationUrl.Path + ";jsessionid=" + m[1]
}
vResp, vErr := sc.R().SetContext(loginCtx).
SetHeader("X-Requested-With", "XMLHttpRequest").
SetHeader("Referer", pageURL.String()).
SetFormData(map[string]string{"validateCode": captchaCode}).
Post(validationUrl.String())
if vErr != nil {
return nil, fmt.Errorf("校验验证码失败: %w", vErr)
}
vBody := readBody(vResp)
var vResult struct {
Success bool `json:"success"`
}
_ = json.Unmarshal(vBody, &vResult)
slog.Info("合肥师范验证码校验", "resp", truncateForLog(string(vBody), 120), "success", vResult.Success)
if !vResult.Success {
return nil, fmt.Errorf("验证码识别结果不被接受(%s),请重试", captchaCode)
}
// 5. 提交登录(禁用重定向,登录成功返回 302)
// 页面 JS 的关键处理:password 先 base64 编码,lt 替换为 keys("e1s1")。
// 少任一步认证都不会通过,且服务端静默退回登录页。
sc.SetRedirectPolicy(resty.NoRedirectPolicy())
loginResp, err := sc.R().
SetContext(loginCtx).
SetHeader("Referer", pageURL.String()).
SetFormData(map[string]string{
"username": username,
"password": password,
"password": base64.StdEncoding.EncodeToString([]byte(password)),
"validateCode": captchaCode,
"lt": lt,
"lt": hfnuLtKey,
"_eventId": "submit",
}).
Post(postUrl)
@@ -101,7 +138,9 @@ func hfnuLogin(ctx context.Context, school *SchoolDetailInfo, username, password
//(用户在浏览器登录同样报"系统拒绝单点登录跳转"),导致无法换取网关会话。
// 这里区分两类失败:有明确错误文案的(账密错误等)原样提示;
// 无任何错误信息的(认证通过但学校端跳转被拒)给出明确的服务端故障提示。
msg := parseHfnuLoginError(readBody(loginResp))
respBody := readBody(loginResp)
slog.Warn("合肥师范登录失败页", "bodyLen", len(respBody), "snippet", truncateForLog(string(respBody), 600))
msg := parseHfnuLoginError(respBody)
if msg == "" {
return nil, fmt.Errorf("该校统一认证平台单点登录服务异常(学校端故障),请稍后重试;如持续失败请联系管理员")
}
@@ -174,10 +213,29 @@ func resolveHfnuCaptcha(ctx context.Context, pageURL *url.URL, postUrl string, c
}
slog.Info("合肥师范验证码获取成功", "size", len(imgData))
code := captchaOCR(imgData)
if code == "" {
raw := captchaOCR(imgData)
if raw == "" {
return "", fmt.Errorf("验证码识别失败,请重试")
}
// 该校验证码为 4 位纯数字(/sso/code/code.jsp)。
// OCR 偶发多识别位数(实测出现过 5 位),这里只保留数字并截取前 4 位,
// 避免把非法长度提交给 validationCode.jsp 直接被判"验证码输入错误"。
digits := make([]rune, 0, len(raw))
for _, r := range raw {
if r >= '0' && r <= '9' {
digits = append(digits, r)
}
}
if len(digits) == 0 {
return "", fmt.Errorf("验证码识别结果无数字(%q)", raw)
}
if len(digits) > 4 {
digits = digits[:4]
}
code := string(digits)
if len(code) != 4 {
return "", fmt.Errorf("验证码识别结果长度异常(%q → %q),请重试", raw, code)
}
return code, nil
}
@@ -191,3 +249,25 @@ func parseHfnuLoginError(body []byte) string {
}
return "登录失败,请检查账号密码或稍后重试"
}
// hfnuLoginEntry 返回该校登录入口。
//
// 不能用通用的 getCasValidLoginUrl:平台给 hfnu 的配置是
//
// idsUrl = http://ehall.hfnu.edu.cn/amp-auth-adapter
// appId = d7de360368c94a808225
//
// 而通用函数的判断只认 campusphere / wec-portal-mobile,
// 遇到 amp-auth-adapter 会回落 WecloudUrl,导致入口选错。
//
// 该校真实链路是 ehall 的 amp-auth-adapter(金智),
// 登录页由 /sso/login 承载,继续用 idsUrl 作为入口即可。
func hfnuLoginEntry(school *SchoolDetailInfo) string {
if school == nil {
return ""
}
if school.IdsUrl != "" && strings.Contains(school.IdsUrl, "amp-auth-adapter") {
return school.IdsUrl
}
return getCasValidLoginUrl(school)
}
@@ -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)
}
})
}
}
+1 -1
View File
@@ -38,7 +38,7 @@ func fetchFromWecloud(wecloudUrl string, cookieMap map[string]string) (*CpdailyU
client := newMobileClient()
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("User-Agent", UserAgent).
SetHeader("CpdailyClientType", "CPDAILY").
SetHeader("Cookie", cookieStr).
Get(host + "/wec-im-group/group/groupMember/getCurrentUserId")
@@ -0,0 +1,67 @@
package cpdaily
import (
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/go-resty/resty/v2"
)
// xsgzFetchRSA 的形态校验:sec_js 响应不完整时必须提前报错,
// 而不是拿残缺密钥加密后让服务端回「参数解码错误」。
func TestXsgzFetchRSAValidation(t *testing.T) {
// 构造一个正常的 sec_js 片段(DK 32 字符、M 258 hex、E 010001)
goodDK := strings.Repeat("a", 32)
goodM := "00" + strings.Repeat("b", 256)
mk := func(dk, m, e string) string {
var sb strings.Builder
if dk != "" {
sb.WriteString(`function _DK(){return "` + dk + `"}` + "\n")
}
if m != "" {
sb.WriteString(`function _M(){return "` + m + `"}` + "\n")
}
if e != "" {
sb.WriteString(`function _E(){return "` + e + `"}` + "\n")
}
return sb.String()
}
cases := []struct {
name string
body string
wantErr string
}{
{"正常", mk(goodDK, goodM, "010001"), ""},
{"DK 过短", mk("abc", goodM, "010001"), "DK 盐长度异常"},
{"M 过短", mk(goodDK, "00bb", "010001"), "模数长度异常"},
{"E 异常", mk(goodDK, goodM, "010003"), "指数形态异常"},
{"缺 E", mk(goodDK, goodM, ""), "解析公钥参数失败"},
{"空响应", "", "解析公钥参数失败"},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write([]byte(c.body))
}))
defer srv.Close()
_, err := xsgzFetchRSA(resty.New(), srv.URL)
if c.wantErr == "" {
if err != nil {
t.Fatalf("应成功,实际报错: %v", err)
}
return
}
if err == nil {
t.Fatal("应报错但未报错")
}
if !strings.Contains(err.Error(), c.wantErr) {
t.Fatalf("错误信息应含 %q,实际: %v", c.wantErr, err)
}
})
}
}
+39 -2
View File
@@ -180,9 +180,26 @@ func GetSchoolDetail(tenantID string) (*SchoolDetailInfo, error) {
detail.NamePinYin = getPinYinFromID(tenantID)
detail.NameAbbr = getAbbrFromName(detail.Name)
// 部分学校返回的域名不带协议(如 "jatc.campusphere.net"),
// 直接拼接口路径会得到 "host/path",net/http 报 unsupported protocol scheme ""。
detail.AmpUrl = ensureURLScheme(detail.AmpUrl)
detail.AmpUrl2 = ensureURLScheme(detail.AmpUrl2)
detail.AmpUrl3 = ensureURLScheme(detail.AmpUrl3)
detail.IdsUrl = ensureURLScheme(detail.IdsUrl)
detail.WecloudUrl = ensureURLScheme(detail.WecloudUrl)
return &detail, nil
}
// ensureURLScheme 为缺省协议的地址补 https://;已有 scheme(含 http://)的原样返回。
func ensureURLScheme(u string) string {
u = strings.TrimSpace(u)
if u == "" || strings.Contains(u, "://") {
return u
}
return "https://" + u
}
// 硬编码走 IAP 登录的学校 ID 列表(尽管 joinType 不是 cloud,但实际支持 IAP)
var iapSchoolIds = map[string]bool{
"01e270d1-00e2-4ce9-b0af-5d5fbd462b8a": true, // 河北石油职业技术大学
@@ -203,8 +220,14 @@ func CpdailyLogin(ctx context.Context, school *SchoolDetailInfo, username, passw
cookies, err := nutCasIapLogin(ctx, school, username, password)
return cookies, nil, err
}
// 合肥师范学院:学校统一身份认证 SSO 会拒绝单点登录跳转("系统拒绝单点登录跳转"),
// 但其 wecloud 网关(hfnu.campusphere.net)支持标准 IAP 账密登录,单独走 IAP
// 合肥师范学院:历史尝试走 IAP(网关 iap/doLogin 账密),但实测该校
// hfnu.campusphere.net/iap/login 直接 302 到
// ehall.hfnu.edu.cn/amp-auth-adapter/login(ehall 单点),
// 拿不到 IAP 登录表单,getIapLT 必然失败。
// 而 ehall 侧登录只产出 ehall 域 cookie(JSESSIONID/AUTHTGC/MOD_AMP_AUTH/
// asessionid),换不到 campusphere 的 MOD_AUTH_CAS(见 10-19 抓包分析)。
// 校方 CAS 亦拒绝为 campusphere 域签发 service 票据。
// 结论:hfnu 目前应走手机号+短信登录(MobileLoginByCode → ExchangeCasCookie)。
if school.ID == HfnuSchoolId {
cookies, err := iapLogin(ctx, school, username, password)
return cookies, nil, err
@@ -237,6 +260,20 @@ func CpdailyLogin(ctx context.Context, school *SchoolDetailInfo, username, passw
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)
}
// 亳州学院:平台配置 joinType=CLOUD(IsIap=true),但网关 /iap/login 实际 302 到
// 学校 CAS(auth.bzuu.edu.cn/authserver),IAP 账密接口要求验证码不可用;
// 真实链路为标准 CAS 表单(密码明文、当前无验证码),单独强制走 CAS(详见 login_bzuu.go)
if school.ID == BzuuSchoolId {
return bzuuCasLogin(ctx, school, username, password)
}
if school.IsIap() || iapSchoolIds[school.ID] {
if !hasUsableLoginUrl(school) {
slog.Warn("IAP学校缺少登录地址", "school", school.Name, "schoolId", school.ID)
@@ -0,0 +1,249 @@
package cpdaily
import (
"errors"
"testing"
"time"
)
// 真实的 detailSignInstance rows 片段(学号 2364400108 实测)。
func realRows() map[string]interface{} {
return map[string]interface{}{
"taskName": "2026(1)节假日返校签到",
"rateSignDate": "2026-10-25 (周日)",
"rateTaskBeginTime": "18:30",
"rateTaskEndTime": "23:00",
"currentTime": "2026-10-19 11:31:21",
"signStatus": "2",
"taskType": float64(6),
}
}
func TestParseSignWindowReal(t *testing.T) {
w := ParseSignWindow(realRows())
if !w.Parsed {
t.Fatal("应能解析出时间段")
}
if w.Start.IsZero() || w.End.IsZero() {
t.Fatal("起止时间不应为零值")
}
t.Logf("窗口: %s ~ %s (date=%s)", w.Start, w.End, w.Date)
wantStart := time.Date(2026, 10, 25, 18, 30, 0, 0, time.Local)
wantEnd := time.Date(2026, 10, 25, 23, 0, 0, 0, time.Local)
if !w.Start.Equal(wantStart) {
t.Fatalf("开始时间错: got %s want %s", w.Start, wantStart)
}
if !w.End.Equal(wantEnd) {
t.Fatalf("结束时间错: got %s want %s", w.End, wantEnd)
}
}
func TestCheckSignWindowReal(t *testing.T) {
rows := realRows()
// 实测当时时间:10-19 11:31,早于 10-25 18:30 → 应拦截
now := time.Date(2026, 10, 19, 11, 31, 21, 0, time.Local)
err := CheckSignWindow(rows, now)
if err == nil {
t.Fatal("不在时间段内应返回错误")
}
if !errors.Is(err, ErrOutsideSignWindow) {
t.Fatalf("错误应可用 errors.Is 识别,实际: %v", err)
}
t.Logf("拦截提示: %v", err)
// 时段内:应放行
inWin := time.Date(2026, 10, 25, 20, 0, 0, 0, time.Local)
if err := CheckSignWindow(rows, inWin); err != nil {
t.Fatalf("时段内不应拦截: %v", err)
}
// 边界:开始时刻与结束时刻本身应算在窗口内
if err := CheckSignWindow(rows, wantTime(2026, 10, 25, 18, 30)); err != nil {
t.Fatalf("开始时刻应放行: %v", err)
}
if err := CheckSignWindow(rows, wantTime(2026, 10, 25, 23, 0)); err != nil {
t.Fatalf("结束时刻应放行: %v", err)
}
// 刚过结束一分钟:应拦截
if err := CheckSignWindow(rows, wantTime(2026, 10, 25, 23, 1)); err == nil {
t.Fatal("超时后应拦截")
}
}
func wantTime(y int, m time.Month, d, hh, mm int) time.Time {
return time.Date(y, m, d, hh, mm, 0, 0, time.Local)
}
// 时间信息缺失时不应拦截,避免误杀。
func TestCheckSignWindowNoInfo(t *testing.T) {
cases := []map[string]interface{}{
nil,
{},
{"taskName": "x"},
{"rateSignDate": "2026-10-25 (周日)"}, // 缺 begin/end
{"rateTaskBeginTime": "18:30", "rateTaskEndTime": "23:00"}, // 缺日期
{"rateSignDate": "bad-date", "rateTaskBeginTime": "x", "rateTaskEndTime": "y"},
}
for i, rows := range cases {
if err := CheckSignWindow(rows, time.Now()); err != nil {
t.Fatalf("case %d 信息不足时不应拦截: %v", i, err)
}
}
}
// singleTask* 存在时优先使用(单次任务精确区间)。
func TestParseSignWindowSingleTaskPriority(t *testing.T) {
rows := map[string]interface{}{
"rateSignDate": "2026-10-25 (周日)",
"rateTaskBeginTime": "18:30",
"rateTaskEndTime": "23:00",
"singleTaskBeginTime": "2026-10-19 08:00:00",
"singleTaskEndTime": "2026-10-19 12:00:00",
}
w := ParseSignWindow(rows)
if !w.Parsed {
t.Fatal("应解析成功")
}
if w.Start.Hour() != 8 || w.Start.Day() != 19 {
t.Fatalf("应优先使用 singleTaskBeginTime,实际 %s", w.Start)
}
// 在该区间内应放行
if err := CheckSignWindow(rows, wantTime(2026, 10, 19, 10, 0)); err != nil {
t.Fatalf("单次任务区间内应放行: %v", err)
}
}
// 查寝(sleep_check)实测字段:singleTask* 是完整日期时间。
func sleepCheckRows() map[string]interface{} {
return map[string]interface{}{
"rateSignDate": "2026-10-19 (周一)",
"rateTaskBeginTime": "22:30",
"rateTaskEndTime": "23:30",
"singleTaskBeginTime": "2026-10-19 22:30",
"singleTaskEndTime": "2026-10-19 23:30",
"iotEndTime": "23:30",
"supplyTime": "",
"signStatus": "0",
"currentTime": "2026-10-19 11:37:32",
}
}
func TestParseSignWindowSleepCheck(t *testing.T) {
w := ParseSignWindow(sleepCheckRows())
if !w.Parsed {
t.Fatal("查寝应能解析出时间段")
}
wantStart := wantTime(2026, 10, 19, 22, 30)
wantEnd := wantTime(2026, 10, 19, 23, 30)
if !w.Start.Equal(wantStart) || !w.End.Equal(wantEnd) {
t.Fatalf("解析错: %s ~ %s", w.Start, w.End)
}
// 当日上午 → 应拦截
if err := CheckSignWindow(sleepCheckRows(), wantTime(2026, 10, 19, 11, 37)); err == nil {
t.Fatal("上午应拦截")
}
// 时段内 → 放行
if err := CheckSignWindow(sleepCheckRows(), wantTime(2026, 10, 19, 23, 0)); err != nil {
t.Fatalf("时段内应放行: %v", err)
}
}
// return_register 与 sign 共用 rateSignDate + rateTaskBeginTime 组合格式。
func TestParseSignWindowReturnRegister(t *testing.T) {
rows := map[string]interface{}{
"rateSignDate": "2026-10-25 (周日)",
"rateTaskBeginTime": "18:30",
"rateTaskEndTime": "23:00",
"signStatus": "2",
"singleTaskBeginTime": nil,
"singleTaskEndTime": nil,
}
w := ParseSignWindow(rows)
if !w.Parsed {
t.Fatal("返校签到应能解析")
}
if !w.Start.Equal(wantTime(2026, 10, 25, 18, 30)) {
t.Fatalf("解析错: %s", w.Start)
}
}
// signStatus 枚举实测自 H5:xt = { sign:1, noSign:2, leave:5 }
func TestSignUpdateBlocked(t *testing.T) {
cases := []struct {
name string
rows map[string]interface{}
blocked bool
}{
{
name: "未签到(signStatus=2) → 不拦截",
rows: map[string]interface{}{"signStatus": "2", "isAllowUpdate": false},
blocked: false,
},
{
name: "已签到且禁止更新 → 拦截",
rows: map[string]interface{}{"signStatus": "1", "isAllowUpdate": false},
blocked: true,
},
{
name: "已签到但允许更新 → 不拦截",
rows: map[string]interface{}{"signStatus": "1", "isAllowUpdate": true},
blocked: false,
},
{
name: "已签到、无 isAllowUpdate 字段 → 不拦截(保守)",
rows: map[string]interface{}{"signStatus": float64(1)},
blocked: false,
},
{
name: "已签到、有次数限制但还剩 → 不拦截",
rows: map[string]interface{}{"signStatus": "1", "isAllowUpdate": true, "updateLimit": true, "leftNum": 2},
blocked: false,
},
{
name: "已签到、次数用尽 → 拦截",
rows: map[string]interface{}{"signStatus": "1", "isAllowUpdate": true, "updateLimit": true, "leftNum": 0},
blocked: true,
},
{
name: "请假(signStatus=5) → 不拦截",
rows: map[string]interface{}{"signStatus": "5"},
blocked: false,
},
{
name: "nil rows → 不拦截",
rows: nil,
blocked: false,
},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
msg, blocked := SignUpdateBlocked(c.rows)
if blocked != c.blocked {
t.Fatalf("blocked=%v want %v (msg=%q)", blocked, c.blocked, msg)
}
if blocked && msg == "" {
t.Fatal("拦截时应给出提示文案")
}
})
}
}
// EvalTaskPhase 复现 H5 的 Gt():>end → finished,<=begin → noStart,否则 running。
func TestEvalTaskPhase(t *testing.T) {
w := ParseSignWindow(realRows())
if got := EvalTaskPhase(w, wantTime(2026, 10, 19, 11, 0)); got != TaskPhaseNoStart {
t.Fatalf("应为 noStart,实际 %q", got)
}
if got := EvalTaskPhase(w, wantTime(2026, 10, 25, 20, 0)); got != TaskPhaseRunning {
t.Fatalf("应为 running,实际 %q", got)
}
if got := EvalTaskPhase(w, wantTime(2026, 10, 26, 1, 0)); got != TaskPhaseFinished {
t.Fatalf("应为 finished,实际 %q", got)
}
// 边界:等于开始时 → noStart(H5 用 isSameOrBefore)
if got := EvalTaskPhase(w, wantTime(2026, 10, 25, 18, 30)); got != TaskPhaseNoStart {
t.Fatalf("等于开始时刻应为 noStart(H5 isSameOrBefore),实际 %q", got)
}
}
+14 -1
View File
@@ -148,6 +148,17 @@ func SendSmsCode(ctx context.Context, phone string) (*SmsSendResult, error) {
if result.ErrCode != 0 {
return nil, fmt.Errorf("%s", result.ErrMsg)
}
// 只有 status=200 才代表短信真正下发。上游在「号码当日验证次数已达上限」等
// 情况下仍返回 errCode=0,但 status=2、tipMsg="验证次数已达上限,将于次日清零"
// 且 countdown 只有 10 秒,此时短信不会发出——若按成功处理,就会出现
// 「前端提示已发送、手机收不到」。
if st := result.Data.Status; st != 0 && st != 200 {
msg := result.Data.TipMsg
if msg == "" {
msg = "短信发送失败,请稍后重试"
}
return nil, fmt.Errorf("%s", msg)
}
return &result.Data, nil
}
@@ -208,7 +219,7 @@ func ExchangeCasCookie(ctx context.Context, school *SchoolDetailInfo, loginData
if school.WecloudUrl == "" {
return nil, fmt.Errorf("学校无可用域名")
}
host := strings.TrimSuffix(school.WecloudUrl, "/")
host := ensureURLScheme(strings.TrimSuffix(school.WecloudUrl, "/"))
// AmpCookies 头:DES 加密后的 {AMP1,AMP2:[{name:sessionToken,value:...}]}
ampCookieMap := map[string]any{
@@ -309,6 +320,8 @@ func RefreshCasCookie(ctx context.Context, cookieMap map[string]string, host str
if sessionToken == "" {
return nil, fmt.Errorf("缺少 sessionToken,无法续期")
}
// 防御:传入的 host 可能是裸域名(无协议),直接拼接会导致 unsupported protocol scheme ""。
host = ensureURLScheme(strings.TrimSuffix(host, "/"))
client := newSmsClient()
// 与 ExchangeCasCookie 保持一致:跟随 CAS 重定向,且跨跳时保留原请求头
+507 -8
View File
@@ -6,6 +6,7 @@ import (
"encoding/base64"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"log/slog"
@@ -148,7 +149,7 @@ func fetchTasksByEndpoint(cookieMap map[string]string, host string, taskType Tas
}
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("User-Agent", UserAgent).
SetHeader("CpdailyClientType", "CPDAILY").
SetHeader("Content-Type", "application/json; charset=UTF-8").
SetHeader("Cookie", cookieStr).
@@ -473,7 +474,7 @@ func fetchTaskDetailByWid(cookieMap map[string]string, host string, taskType Tas
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("User-Agent", UserAgent).
SetHeader("CpdailyClientType", "CPDAILY").
SetHeader("Content-Type", "application/json; charset=UTF-8").
SetHeader("Cookie", cookieStr).
@@ -533,6 +534,180 @@ 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"])
// 有进展文案时不能一律判驳回:今日校园在服务端识别排队期间,
// recognitionProgress 会是「识别中,预计等待4位」这类排队提示
// (线上日志实证)。此时应视为「仍在进行中」,交由调用方继续等待。
// 只有文案本身表达失败/驳回语义时才判定为驳回。
if res.Progress != "" {
res.Message = res.Progress
if faceProgressIsTerminalFailure(res.Progress) {
res.Rejected = true
res.Settled = true
return res
}
if faceProgressIsPending(res.Progress) {
// 明确排队中:未出结果,继续等待
return res
}
// 无法识别的文案:保守按「仍在进行中」处理,不误杀;
// 上层轮询到超时后按提交成功处理(现有兜底逻辑)。
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", UserAgent).
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
@@ -1384,12 +1559,12 @@ func GetExtensionString(lat, lon, deviceId, username string) string {
"lon": lon,
"lat": lat,
"model": "cpdaily",
"appVersion": "9.9.6",
"systemVersion": "15",
"appVersion": AppVersion,
"systemVersion": AndroidVersion,
"userId": username,
"systemName": "android",
"deviceId": deviceId,
"cpdailyVersion": "9.9.6",
"cpdailyVersion": AppVersion,
}
data, _ := json.Marshal(ext)
return base64.StdEncoding.EncodeToString(DesEncryptForCat(data))
@@ -1456,8 +1631,8 @@ func SubmitSign(cookieMap map[string]string, host string, taskType TaskType, sub
"systemName": "android",
"bodyString": bodyString,
"lat": lat,
"systemVersion": "15",
"appVersion": "9.9.6",
"systemVersion": AndroidVersion,
"appVersion": AppVersion,
"model": "cpdaily",
}
// 添加 userId
@@ -1467,7 +1642,7 @@ func SubmitSign(cookieMap map[string]string, host string, taskType TaskType, sub
submitData["sign"] = sign
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("User-Agent", UserAgent).
SetHeader("CpdailyClientType", "CPDAILY").
SetHeader("Content-Type", "application/json; charset=UTF-8").
SetHeader("Cookie", cookieStr).
@@ -1499,3 +1674,327 @@ func SubmitSign(cookieMap map[string]string, host string, taskType TaskType, sub
return nil
}
// faceProgressIsPending 判断 receptionProgress 文案是否表示「仍在排队/识别中」。
//
// 线上实证文案:「识别中,预计等待4位」「识别中,预计等待8位」。
// 这类提示表示服务端识别队列尚未处理完,不是失败,必须继续等待。
func faceProgressIsPending(msg string) bool {
s := strings.TrimSpace(msg)
if s == "" {
return false
}
// 常见排队/进行中关键词
pendingWords := []string{
"识别中", "排队中", "排队", "处理中", "等待", "预计等待", "审核中",
}
for _, w := range pendingWords {
if strings.Contains(s, w) {
return true
}
}
return false
}
// faceProgressIsTerminalFailure 判断文案是否明确表示失败/驳回(终止态)。
//
// 只有明确的否定结论才算终止,避免把排队提示误判成失败。
func faceProgressIsTerminalFailure(msg string) bool {
s := strings.TrimSpace(msg)
if s == "" {
return false
}
// 先排除排队类文案(可能同时含"识别"字样)
if faceProgressIsPending(s) {
return false
}
failWords := []string{
"未通过", "不通过", "失败", "驳回", "驳回申请", "无效",
"未检测到人脸", "人脸不匹配", "请重试", "请重新", "超时",
}
for _, w := range failWords {
if strings.Contains(s, w) {
return true
}
}
return false
}
// VerifyCookieUsable 用指定 cookie 做一次只读探测,确认登录态真实可用。
//
// 起因:自动续期拿到 MOD_* cookie 后被判定「续期成功」,但紧接着的业务调用
// 仍返回 cpdaily_cookie_expired。根因是续期走的 /imUserToken 接口只校验
// sessionToken 头、不校验 CAS 会话,在 CASTGC/sessionToken 已失效时
// 仍可能返回成功,并带回一批无效 cookie。
//
// 因此这里用与业务相同的最小请求(拉当天任务列表)验证:
// - 返回 ErrCookieExpired ⇒ 凭证确实不可用;
// - 其它错误(网络/限流等)不归咎于凭证,向上返回但不当作过期;
// - 正常返回(哪怕列表为空)⇒ 登录态可用。
//
// 该探测为只读,不会产生任何写操作副作用。
func VerifyCookieUsable(cookieMap map[string]string, host string) error {
if len(cookieMap) == 0 {
return ErrCookieExpired
}
if host == "" {
return fmt.Errorf("学校地址为空")
}
// 依次尝试三种任务类型:任一能正常返回即说明会话有效。
// (学生未必同时拥有签到与查寝任务,单类型返回空不代表失效。)
var lastErr error
for _, tt := range []TaskType{TaskTypeSign, TaskTypeSleepCheck, TaskTypeReturnRegister} {
if _, err := fetchTasksByEndpoint(cookieMap, host, tt, ""); err != nil {
if errors.Is(err, ErrCookieExpired) {
lastErr = err
continue // 该类过期,再试其它端点确认
}
// 非过期类错误(网络等):不能据此判定凭证失效
return nil
}
return nil // 任一端点可用即视为有效
}
if lastErr != nil {
return lastErr
}
return nil
}
// ErrOutsideSignWindow 表示当前不在任务的打卡时间段内。
var ErrOutsideSignWindow = fmt.Errorf("outside_sign_window")
// 签到状态(来自签到 H5 的 xt 枚举,实测源码):
//
// xt = { sign: 1, noSign: 2, leave: 5 }
const (
SignStatusSigned = 1 // 已签到
SignStatusNoSign = 2 // 未签到
SignStatusLeave = 5 // 请假
)
// 任务阶段(来自签到 H5 的 Rt 枚举 + Gt() 计算逻辑):
//
// Rt = { noStart: "noStart", running: "running", finished: "finished" }
// Gt(): currentTime > signEndTime → finished
// currentTime <= signBeginTime → noStart
// 否则 → running
const (
TaskPhaseNoStart = "noStart"
TaskPhaseRunning = "running"
TaskPhaseFinished = "finished"
)
// SignWindow 描述一件任务的允许打卡时间段。
//
// 字段依据(实测 detailSignInstance 的 rows):
//
// sign 任务:rateSignDate="2026-10-25 (周日)" + rateTaskBeginTime="18:30"
// + rateTaskEndTime="23:00"
// rateSignDate 即 H5 里的 signDate,两个 Time 即 signBeginTime/signEndTime
// sleep_check:singleTaskBeginTime="2026-10-19 22:30" 为完整日期时间
//
// H5 源码($t)对「循环任务」(isLoop) 才把 signDate 与时刻拼接;
// 非循环任务直接用时刻本身。这里等价实现:能拼出完整日期时间就用它。
type SignWindow struct {
Date string // 原始日期串,仅用于提示
BeginTime string // 原始开始时刻
EndTime string // 原始结束时刻
Start time.Time
End time.Time
Parsed bool // 时间是否解析成功(失败则不拦截)
}
// ParseSignWindow 从详情 rows 中解析打卡时间段。
// 解析不出任何信息时返回零值(Parsed=false),调用方应放行。
func ParseSignWindow(rows map[string]interface{}) SignWindow {
if rows == nil {
return SignWindow{}
}
var w SignWindow
w.Date = strings.TrimSpace(mapValueStr(rows, "rateSignDate"))
w.BeginTime = strings.TrimSpace(mapValueStr(rows, "rateTaskBeginTime"))
w.EndTime = strings.TrimSpace(mapValueStr(rows, "rateTaskEndTime"))
// 单次任务(多为查寝):singleTask* 已是完整日期时间,直接可用
singleB := strings.TrimSpace(mapValueStr(rows, "singleTaskBeginTime"))
singleE := strings.TrimSpace(mapValueStr(rows, "singleTaskEndTime"))
if singleB != "" && singleE != "" {
if st, err := parseFlexibleTime(singleB); err == nil {
if et, err2 := parseFlexibleTime(singleE); err2 == nil {
w.Start, w.End, w.Parsed = st, et, true
return w
}
}
}
if w.Date == "" || w.BeginTime == "" || w.EndTime == "" {
return w
}
// 从 "2026-10-25 (周日)" 取日期部分(对应 H5 的 signDate.substring(0,10))
datePart := w.Date
if i := strings.IndexAny(datePart, " ("); i > 0 {
datePart = datePart[:i]
}
datePart = strings.TrimSpace(datePart)
st, ok1 := combineDateTime(datePart, w.BeginTime)
et, ok2 := combineDateTime(datePart, w.EndTime)
if ok1 && ok2 {
w.Start, w.End, w.Parsed = st, et, true
}
return w
}
// ParseSignWindowOrTime 在 rows 缺少 signDate 时,用 currentTime 的日期兜底拼接。
// 某些任务只给时刻不给日期(H5 非循环任务即如此),此时以当前日期为准。
func ParseSignWindowOrTime(rows map[string]interface{}, now time.Time) SignWindow {
w := ParseSignWindow(rows)
if w.Parsed {
return w
}
if rows == nil {
return w
}
bt := strings.TrimSpace(mapValueStr(rows, "rateTaskBeginTime"))
et := strings.TrimSpace(mapValueStr(rows, "rateTaskEndTime"))
if bt == "" || et == "" {
return w
}
datePart := now.Format("2006-01-02")
st, ok1 := combineDateTime(datePart, bt)
etTime, ok2 := combineDateTime(datePart, et)
if ok1 && ok2 {
w.Date = datePart
w.BeginTime, w.EndTime = bt, et
w.Start, w.End, w.Parsed = st, etTime, true
}
return w
}
// combineDateTime 组合 "2026-10-25" + "18:30"(也接受带秒)。
func combineDateTime(datePart, hhmm string) (time.Time, bool) {
dateFormats := []string{"2006-01-02", "2006/01/02", "2006-1-2"}
timeFormats := []string{"15:04", "15:04:05"}
for _, df := range dateFormats {
for _, tf := range timeFormats {
if t, err := time.ParseInLocation(df+" "+tf, datePart+" "+hhmm, time.Local); err == nil {
return t, true
}
}
}
return time.Time{}, false
}
// parseFlexibleTime 解析单次任务的绝对时间串。
func parseFlexibleTime(s string) (time.Time, error) {
layouts := []string{
"2006-01-02 15:04:05", "2006-01-02 15:04",
"2006/01/02 15:04:05", "2006/01/02 15:04",
"2006-01-02T15:04:05", time.RFC3339,
}
for _, l := range layouts {
if t, err := time.ParseInLocation(l, s, time.Local); err == nil {
return t, nil
}
}
return time.Time{}, fmt.Errorf("无法解析时间: %s", s)
}
// EvalTaskPhase 复现签到 H5 的 Gt():
//
// current > end → finished
// current <= begin → noStart
// 否则 → running
func EvalTaskPhase(w SignWindow, now time.Time) string {
if !w.Parsed || w.Start.IsZero() || w.End.IsZero() {
return ""
}
if now.After(w.End) {
return TaskPhaseFinished
}
if !now.After(w.Start) {
return TaskPhaseNoStart
}
return TaskPhaseRunning
}
// signWindowError 让错误信息对用户友好,同时可用 errors.Is 识别类型。
type signWindowError struct{ msg string }
func (e *signWindowError) Error() string { return e.msg }
func (e *signWindowError) Unwrap() error { return ErrOutsideSignWindow }
// CheckSignWindow 判断当前是否在允许打卡的时间段内。
//
// 返回 nil 表示可以打卡(或在时间信息缺失时无法判断,放行);
// 返回 ErrOutsideSignWindow 包装的错误表示明确不在时间段内。
// 该判断只做「明显不在窗口」的拦截,避免因时间格式差异误杀。
func CheckSignWindow(rows map[string]interface{}, now time.Time) error {
w := ParseSignWindow(rows)
if !w.Parsed {
return nil // 无法解析 → 不拦截,交由学校端判定
}
// 只有起止都有效才做区间判断
if w.Start.IsZero() || w.End.IsZero() {
return nil
}
if now.Before(w.Start) || now.After(w.End) {
return fmt.Errorf("%w", &signWindowError{
msg: fmt.Sprintf("当前不在打卡时间段内:本任务打卡时间为 %s %s~%s,当前 %s",
strings.TrimSpace(strings.SplitN(w.Date, " (", 2)[0]),
w.BeginTime, w.EndTime, now.Format("2006-01-02 15:04")),
})
}
return nil
}
// SignUpdateBlocked 判断「已签到且学校关闭了更新签到」的情况。
//
// 依据签到 H5 源码(handlerButtonClick):
//
// if (this.signed && !this.isAllowUpdate)
// return toast("更新签到开关已关闭,不允许更新签到"), false;
//
// 以及 updateSign 中的次数限制:
//
// if (!updateLimit || leftNum) 允许提交
// else toast("已超出最大更新次数")
//
// 说明:H5 把「已签到后再次签到」称作「更新签到」,签到 H5 内不存在
// 独立的「补签」入口(全量 JS 中无 supply / 补签 字样)。
//
// 返回 nonEmpty=true 表示应拦截。
func SignUpdateBlocked(rows map[string]interface{}) (string, bool) {
if rows == nil {
return "", false
}
signed := mapValueInt(rows["signStatus"]) == SignStatusSigned
if !signed {
return "", false
}
// 已签到:看是否允许更新
if v, ok := rows["isAllowUpdate"]; ok && v != nil {
allow := true
switch b := v.(type) {
case bool:
allow = b
default:
allow = strings.TrimSpace(fmt.Sprintf("%v", b)) == "true"
}
if !allow {
return "该任务已签到,且学校已关闭「更新签到」,不允许重复签到", true
}
}
// 更新次数限制:updateLimit=true 且有 leftNum 时可用;
// leftNum 为 0 表示次数用尽(H5:!updateLimit || leftNum 才放行)
if limit, ok := rows["updateLimit"]; ok && limit != nil {
hasLimit := strings.TrimSpace(fmt.Sprintf("%v", limit)) == "true"
if hasLimit {
left := mapValueInt(rows["leftNum"])
if left <= 0 {
return "该任务已签到,且已达到学校允许的最大更新次数", true
}
}
}
return "", false
}
@@ -0,0 +1,14 @@
package cpdaily
import "testing"
// VerifyCookieUsable 的边界:空 cookie / 空 host 必须判为不可用或报错,
// 不能静默让上层以为凭证有效。
func TestVerifyCookieUsableGuards(t *testing.T) {
if err := VerifyCookieUsable(nil, "https://x.edu.cn"); err == nil {
t.Fatal("空 cookie 应报错")
}
if err := VerifyCookieUsable(map[string]string{"MOD_AUTH_CAS": "x"}, ""); err == nil {
t.Fatal("空 host 应报错")
}
}
@@ -0,0 +1,22 @@
package cpdaily
// 今日校园客户端版本标识。
//
// 这些值会出现在两个地方,必须保持一致:
// - User-Agent 尾部:cpdaily/<ver> wisedu/<ver>
// - Cpdaily-Extension 明文 JSON 的 appVersion / cpdailyVersion 字段
// 以及 submitSign 请求体的 appVersion
//
// 集中定义避免各处字面量漂移:改版本只改这里。
const (
// AppVersion 今日校园客户端版本
AppVersion = "9.9.26"
// AndroidVersion 上报的系统版本(假设备信息,与 UA 中的 Android 15 对应)
AndroidVersion = "15"
// UserAgent 通用请求 UA(Chrome 内核 + 今日校园客户端标识)
UserAgent = "Mozilla/5.0 (Linux; Android " + AndroidVersion +
"; Redmi Note 8 Pro) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/131.0.6778.81 Mobile Safari/537.36 cpdaily/" +
AppVersion + " wisedu/" + AppVersion
)
+69 -6
View File
@@ -3,6 +3,7 @@ package cpdaily
import (
"encoding/json"
"fmt"
"io"
"log/slog"
"strconv"
"strings"
@@ -21,6 +22,41 @@ import (
// 5. update(operationType=1) 提交。
const worklogAPIBase = "/wec-counselor-worklog-apps"
// worklogPost 工作日志专用 POST。
//
// 与 leavePost 的差异:固定携带 CpdailyStandAlone: 0,且在 extension 非空时携带
// Cpdaily-Extension。依据逆向文档(worklog_api.md):定位签到接口 worklog/sign/submitSign
// 必须带 Cpdaily-Extension(DES-CBC)与 CpdailyStandAlone: 0,缺失会被学校端拒绝。
// 此前该接口走 leavePost 且 extension 传空,等于两个头都没发。
func worklogPost(cookieMap map[string]string, url string, body interface{}, extension string) ([]byte, error) {
client := newTaskClient()
req := client.R().
SetHeader("User-Agent", UserAgent).
SetHeader("CpdailyClientType", "CPDAILY").
SetHeader("Content-Type", "application/json; charset=UTF-8").
SetHeader("CpdailyStandAlone", "0").
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
}
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
}
// WorklogSubTemplate 子模板(真正用于建单/查询的 formWid)
type WorklogSubTemplate struct {
Wid int64 `json:"wid"`
@@ -215,24 +251,33 @@ func FetchWorklogDetail(cookieMap map[string]string, host string, wid int64) ([]
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{}{
// SubmitWorklogSign 定位签到项提交。
// 需要 Cpdaily-Extension 头(含当前定位与设备信息),故必须传入 deviceId 与 username。
func SubmitWorklogSign(cookieMap map[string]string, host string, fieldWid int64, worklogWid int64, signPlace, longitude, latitude, deviceId, username string) error {
// 与普通打卡一致:Cpdaily-Extension = base64(DES-CBC({lon,lat,deviceId,userId,...}))
ext := GetExtensionString(latitude, longitude, deviceId, username)
body, err := worklogPost(cookieMap, host+worklogAPIBase+"/worklog/sign/submitSign", map[string]interface{}{
"fieldWid": fieldWid,
"worklogWid": worklogWid,
"signPlace": signPlace,
"longitude": longitude,
"latitude": latitude,
}, "")
}, ext)
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", err)
return fmt.Errorf("解析定位签到响应失败: %w (body: %.300s)", err, string(body))
}
// 与普通打卡的 submitSign 保持一致,记录完整响应,便于排查学校端业务拒绝原因
// (此前只取 message、不打日志,导致「请提交正确的信息收集信息」这类错误无从定位)。
slog.Info("worklog/submitSign 响应",
"fieldWid", fieldWid, "worklogWid", worklogWid,
"code", envelope.Code, "message", envelope.Message, "body", string(body))
if envelope.Message != "SUCCESS" {
msg := strings.TrimSpace(envelope.Message)
if msg == "" {
@@ -256,7 +301,7 @@ func SubmitWorklog(cookieMap map[string]string, host string, formWid, wid int64,
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{}{
body, err := worklogPost(cookieMap, host+worklogAPIBase+"/worklog/update", map[string]interface{}{
"formWid": formWid,
"wid": wid,
"form": formOut,
@@ -405,3 +450,21 @@ func WorklogScopePoints(f WorklogField) []SignPlace {
func WorklogWithinRange(f WorklogField, lat, lon string) bool {
return coordsWithinSignRange(lat, lon, WorklogScopePoints(f))
}
// WorklogMatchedScope 返回坐标落入的那个签到范围点(含 address)。
//
// submitSign 的 signPlace 必须是学校配置的签到点名称本身,而不是空串或任意地址;
// 此前 handler 恒传空串,学校端校验「信息收集信息」不通过并返回 2210010000。
func WorklogMatchedScope(f WorklogField, lat, lon string) (SignPlace, bool) {
latF, e1 := strconv.ParseFloat(strings.TrimSpace(lat), 64)
lonF, e2 := strconv.ParseFloat(strings.TrimSpace(lon), 64)
if e1 != nil || e2 != nil {
return SignPlace{}, false
}
for _, p := range WorklogScopePoints(f) {
if haversineMeters(latF, lonF, p.LatitudeF, p.LongitudeF) <= p.RadiusMeters {
return p, true
}
}
return SignPlace{}, false
}
@@ -1,7 +1,15 @@
package cpdaily
import (
"crypto/cipher"
"crypto/des"
"encoding/base64"
"encoding/json"
"fmt"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
@@ -81,3 +89,78 @@ func TestWorklogSignWidRoundTrip(t *testing.T) {
t.Fatal("非法标识应报错")
}
}
// 回归:定位签到接口必须携带 Cpdaily-Extension 与 CpdailyStandAlone: 0。
// 此前走 leavePost 且 extension 传空,两个头都没发,学校端会拒绝。
func TestSubmitWorklogSign_SendsExtensionHeaders(t *testing.T) {
var gotExt, gotStandAlone, gotBody string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotExt = r.Header.Get("Cpdaily-Extension")
gotStandAlone = r.Header.Get("CpdailyStandAlone")
b, _ := io.ReadAll(r.Body)
gotBody = string(b)
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"code":"0","message":"SUCCESS"}`))
}))
defer srv.Close()
err := SubmitWorklogSign(
map[string]string{"SESSION": "abc"}, srv.URL,
428, 2234, "唐槐校区", "112.55", "37.87", "13800000000", "20230001",
)
if err != nil {
t.Fatalf("提交不应失败: %v", err)
}
if gotStandAlone != "0" {
t.Errorf("CpdailyStandAlone 应为 0,实际 %q", gotStandAlone)
}
if gotExt == "" {
t.Fatal("缺少 Cpdaily-Extension 头")
}
// 解密校验:头里应包含本次定位与用户标识
raw, err := base64.StdEncoding.DecodeString(gotExt)
if err != nil {
t.Fatalf("Cpdaily-Extension 非法 base64: %v", err)
}
plain, err := desDecryptForTest(raw)
if err != nil {
t.Fatalf("Cpdaily-Extension 解密失败: %v", err)
}
var ext map[string]interface{}
if err := json.Unmarshal(plain, &ext); err != nil {
t.Fatalf("Cpdaily-Extension 非 JSON: %v", err)
}
if ext["lat"] != "37.87" || ext["lon"] != "112.55" {
t.Errorf("签名坐标不符: lat=%v lon=%v", ext["lat"], ext["lon"])
}
if ext["userId"] != "20230001" {
t.Errorf("签名 userId 不符: %v", ext["userId"])
}
// 请求体仍应是明文定位参数
if !strings.Contains(gotBody, "112.55") || !strings.Contains(gotBody, "2234") {
t.Errorf("请求体异常: %s", gotBody)
}
}
// desDecryptForTest 用生产同款 key/iv 解密,验证签名内容
func desDecryptForTest(data []byte) ([]byte, error) {
block, err := des.NewCipher(DesKeyForCat)
if err != nil {
return nil, err
}
if len(data) == 0 || len(data)%block.BlockSize() != 0 {
return nil, fmt.Errorf("密文长度非法: %d", len(data))
}
out := make([]byte, len(data))
cipher.NewCBCDecrypter(block, DesIv).CryptBlocks(out, data)
// 去 PKCS7
n := int(out[len(out)-1])
if n <= 0 || n > len(out) {
return nil, fmt.Errorf("padding 非法")
}
return out[:len(out)-n], nil
}
+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))
}
+60
View File
@@ -0,0 +1,60 @@
package service
import (
"fmt"
"log/slog"
"time"
)
// 短信验证码发送冷却:同一手机号 60 秒内只允许触发一次下发。
//
// 今日校园 messageCode 接口对同一手机号有服务端频率限制:超出时接口仍返回
// errCode=0,但短信不会真正下发,表现为「前端提示发送成功,手机却收不到」。
// 因此在调用上游之前用 Redis 做手机号维度的冷却,既能挡住重复点击,
// 又能保证每次真正下发的间隔满足上游要求。
const SmsCodeCooldownSeconds = 60
const smsCodeCooldown = SmsCodeCooldownSeconds * time.Second
// smsCodeCooldownKeyFmt 冷却标记(值为触发时间戳,便于排查)
const smsCodeCooldownKeyFmt = "sms:code:cd:%s"
// AllowSmsCodeSend 判断该手机号当前是否允许发送验证码。
// 放行时立即写入冷却标记并返回 (true, 0);
// 冷却中返回 (false, 剩余时长)。
func AllowSmsCodeSend(phone string) (bool, time.Duration) {
if phone == "" || Redis == nil {
return true, 0
}
ctx, cancel := redisCaptchaCtx()
defer cancel()
key := fmt.Sprintf(smsCodeCooldownKeyFmt, phone)
ok, err := Redis.SetNX(ctx, key, time.Now().Unix(), smsCodeCooldown).Result()
if err != nil {
// Redis 故障时放行,避免把发码功能整体打挂
slog.Warn("短信冷却 Redis 操作失败,放行", "err", err)
return true, 0
}
if ok {
return true, 0
}
ttl, err := Redis.TTL(ctx, key).Result()
if err != nil || ttl <= 0 {
return false, smsCodeCooldown
}
return false, ttl
}
// ReleaseSmsCodeCooldown 释放冷却(上游发送失败时调用,允许用户立即重试)
func ReleaseSmsCodeCooldown(phone string) {
if phone == "" || Redis == nil {
return
}
ctx, cancel := redisCaptchaCtx()
defer cancel()
if err := Redis.Del(ctx, fmt.Sprintf(smsCodeCooldownKeyFmt, phone)).Err(); err != nil {
slog.Warn("释放短信冷却失败", "err", err)
}
}
@@ -0,0 +1,93 @@
package service
import (
"context"
"fmt"
"os"
"testing"
"time"
"github.com/redis/go-redis/v9"
)
func smsTestRedis(t *testing.T) *redis.Client {
t.Helper()
addr := os.Getenv("TEST_REDIS_ADDR")
if addr == "" {
t.Skip("未设置 TEST_REDIS_ADDR,跳过 Redis 相关用例")
}
c := redis.NewClient(&redis.Options{Addr: addr})
if err := c.Ping(context.Background()).Err(); err != nil {
t.Fatalf("连接测试 Redis 失败: %v", err)
}
old := Redis
Redis = c
t.Cleanup(func() {
Redis = old
_ = c.Close()
})
return c
}
// Redis 不可用时不应阻断发码(避免把功能整体打挂)
func TestAllowSmsCodeSendNoRedis(t *testing.T) {
old := Redis
Redis = nil
t.Cleanup(func() { Redis = old })
if ok, wait := AllowSmsCodeSend("13800000000"); !ok || wait != 0 {
t.Fatalf("Redis 不可用时应放行, got ok=%v wait=%v", ok, wait)
}
ReleaseSmsCodeCooldown("13800000000") // 不应 panic
}
// 同一手机号 60 秒内第二次发送必须被拒绝,并返回剩余冷却时间
func TestAllowSmsCodeSendCooldown(t *testing.T) {
c := smsTestRedis(t)
phone := smsTestPhone()
t.Cleanup(func() {
c.Del(context.Background(), "sms:code:cd:"+phone)
})
if ok, wait := AllowSmsCodeSend(phone); !ok || wait != 0 {
t.Fatalf("首次发送应放行, got ok=%v wait=%v", ok, wait)
}
ok, wait := AllowSmsCodeSend(phone)
if ok {
t.Fatal("冷却期内第二次发送应被拒绝")
}
if wait <= 0 || wait > SmsCodeCooldownSeconds*time.Second {
t.Fatalf("剩余冷却时间应在 (0, %ds], got %v", SmsCodeCooldownSeconds, wait)
}
// 不同手机号互不影响
other := smsTestPhone()
t.Cleanup(func() { c.Del(context.Background(), "sms:code:cd:"+other) })
if ok, _ := AllowSmsCodeSend(other); !ok {
t.Fatal("其他手机号不应被冷却拦截")
}
}
// 上游发送失败后释放冷却,应允许立即重试
func TestReleaseSmsCodeCooldown(t *testing.T) {
c := smsTestRedis(t)
phone := smsTestPhone()
t.Cleanup(func() {
c.Del(context.Background(), "sms:code:cd:"+phone)
})
if ok, _ := AllowSmsCodeSend(phone); !ok {
t.Fatal("首次发送应放行")
}
if ok, _ := AllowSmsCodeSend(phone); ok {
t.Fatal("冷却期内应被拒绝")
}
ReleaseSmsCodeCooldown(phone)
if ok, _ := AllowSmsCodeSend(phone); !ok {
t.Fatal("释放冷却后应允许重试")
}
}
func smsTestPhone() string {
return fmt.Sprintf("199%08d", time.Now().UnixNano()%1e8)
}
+16
View File
@@ -302,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}`)
+9
View File
@@ -315,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
}) {
@@ -332,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)
@@ -396,6 +400,11 @@ export function redeemInviteReward() {
return api.post<{ data: { quota: number } }>('/auth/redeem-invite')
}
/** 重置我的邀请码(旧码立即失效,已绑定的邀请关系不受影响) */
export function resetInviteCode() {
return api.post<{ data: { inviteCode: string } }>('/auth/reset-invite')
}
/** 填写他人邀请码(绑定邀请关系,双方发放积分) */
export function bindInviteCode(inviteCode: string) {
return api.post<{ data: MyInviteInfo }>('/auth/bind-invite', { inviteCode })
+97 -79
View File
@@ -56,6 +56,8 @@ export interface ExecuteLeaveParams {
imageUrl?: string
/** reportApplyFlow=true 时必填 */
approverWid?: string
/** 销假所属请假类型(三易拓新学工5 必传 UUID) */
projectId?: string
}
/** 销假日志 */
@@ -77,6 +79,8 @@ export interface LeaveLog {
export interface LeaveType {
code: number
name: string
/** 字符串型类型 ID(三易拓新学工5 等系统用 UUID,提交时用它而非 code) */
projectId?: string
/** 1=必须离校 */
mustOut: number
/** 1=需要选择审批人 */
@@ -128,6 +132,8 @@ export interface SelfDefineAnswer {
/** 请假执行参数 */
export interface ExecuteLeaveApplyParams {
leaveType: number
/** 字符串型类型 ID(三易拓新学工5 等系统必传) */
projectId?: string
startTime: string
endTime: string
leaveReason: string
@@ -232,6 +238,13 @@ export function executeLeaveSSE(params: ExecuteLeaveParams, 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
@@ -242,93 +255,98 @@ function leaveSSE(path: string, params: object, callbacks: {
}): AbortController {
const controller = new AbortController()
;(async () => {
let finished = false
const finish = (fn?: (...args: any[]) => void, ...args: any[]) => {
if (finished) return
finished = true
fn?.(...args)
}
try {
const baseUrl = import.meta.env.VITE_API_BASE_URL || '/api'
const token = localStorage.getItem('access_token') || ''
const res = await fetch(`${baseUrl}${path}`, {
method: 'POST',
headers: {
'Authorization': 'Bearer ' + token,
'Content-Type': 'application/json',
},
body: JSON.stringify(params),
signal: controller.signal,
})
let finished = false
const finish = (fn?: (...args: any[]) => void, ...args: any[]) => {
if (finished) return
finished = true
fn?.(...args)
}
// HTTP 错误:服务端以 JSON 返回(参数校验/鉴权等,非 SSE 阶段)
if (!res.ok || !res.body) {
let msg = '连接失败,请检查网络'
try {
const j = await res.json()
if (j?.message) msg = j.message
} catch { /* 保留默认提示 */ }
finish(callbacks.onError, msg, { message: msg })
return
}
const baseUrl = import.meta.env.VITE_API_BASE_URL || '/api'
const token = localStorage.getItem('access_token') || ''
const xhr = new XMLHttpRequest()
const reader = res.body.getReader()
const decoder = new TextDecoder()
let buf = ''
for (;;) {
const { value, done } = await reader.read()
if (done) break
buf += decoder.decode(value, { stream: true })
let handled = 0 // 已解析到的 responseText 位置
let buf = ''
// 按空行切分 SSE 事件块(event: xxx\ndata: xxx\n\n)
let sep: number
while ((sep = buf.indexOf('\n\n')) >= 0) {
const chunk = buf.slice(0, sep)
buf = buf.slice(sep + 2)
// 解析自上次以来的增量文本,切分并派发完整 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
}
if (finished) break
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())
}
if (finished) break
}
let data: any = {}
const dataRaw = dataLines.join('\n')
try { data = dataRaw ? JSON.parse(dataRaw) : {} } catch { /* 容忍非 JSON data */ }
// 流结束但未收到任何结果事件:视为异常中断
finish(callbacks.onError, '连接中断,请重试')
} catch (e: any) {
if (e?.name === 'AbortError') return // 主动取消,不回调
finish(callbacks.onError, e?.message || '连接失败,请检查网络')
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
}
+21 -4
View File
@@ -9,6 +9,8 @@ export interface TicketListItem {
messageCount: number
createdAt: string
updatedAt: string
/** 管理员最近一次操作后用户是否已读;false 时列表显示未读红点 */
userRead?: boolean
}
export interface AdminTicketListItem extends TicketListItem {
@@ -17,6 +19,10 @@ export interface AdminTicketListItem extends TicketListItem {
schoolName: string
/** 用户是否已删除该工单(软删除);管理端仍可见 */
deleted?: boolean
/** 管理员最近一次操作后用户是否已读;false 表示用户还没看 */
userRead?: boolean
/** 用户最近一次回复后管理员是否已读;false 表示后台显示未读红点 */
adminRead?: boolean
}
export interface TicketMessageRes {
@@ -41,12 +47,18 @@ export interface TicketDetail {
updatedAt: string
/** 用户是否已删除该工单(软删除);管理端仍可查看 */
deleted?: boolean
/** 管理员最近一次操作后用户是否已读 */
userRead?: boolean
/** 用户最近一次回复后管理员是否已读 */
adminRead?: boolean
messages: TicketMessageRes[]
}
export interface TicketNotificationItem {
id: number
ticketId: number
/** reply=管理员回复 / status=状态变更 */
kind: string
title: string
content: string
isRead: boolean
@@ -82,6 +94,11 @@ export function closeTicket(ticketId: number) {
return api.put<{ data: { status: string } }>(`/ticket/${ticketId}/close`)
}
/** 打回工单:仅「已完成」可打回为「处理中」 */
export function reopenTicket(ticketId: number) {
return api.put<{ data: { status: string } }>(`/ticket/${ticketId}/reopen`)
}
/** 删除工单 */
export function deleteTicket(ticketId: number) {
return api.delete<{ data: { deleted: boolean } }>(`/ticket/${ticketId}`)
@@ -144,14 +161,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: 'adm-badge-amber',
processing: 'adm-badge-brand',
processing: 'adm-badge-sky',
resolved: 'adm-badge-green',
closed: 'adm-badge-gray',
}
+91
View File
@@ -0,0 +1,91 @@
<script setup lang="ts">
// 次数不足弹窗(打卡 / 请假 / 续假 / 销假共用)。
// 底部两个动作按钮都跳「我的」页,并通过 query 的 intent 让该页自动执行:
// invite → 自动复制邀请码并提示
// buy → 自动触发 CDK 购买入口(弹流程说明 → 跳购买平台)
import { computed } from 'vue'
import { useRouter } from 'vue-router'
import { Clock, Gift, ShoppingCart } from 'lucide-vue-next'
import AppDialog from '@/components/ui/dialog/AppDialog.vue'
import { goProfileWithIntent, type NoRunsIntent } from '@/composables/useNoRunsDialog'
const props = defineProps<{
open: boolean
/** 场景标题,如「签到次数不足」「请假次数不足」 */
title?: string
/** 后端返回的原始提示(可选,用于补充说明) */
message?: string
}>()
const emit = defineEmits<{ (e: 'update:open', v: boolean): void }>()
const router = useRouter()
const dialogTitle = computed(() => props.title || '可用次数不足')
function close() {
emit('update:open', false)
}
function go(intent: NoRunsIntent) {
close()
goProfileWithIntent(router, intent)
}
</script>
<template>
<AppDialog
:open="open"
variant="center"
panel-class="w-[88%] sm:max-w-sm"
:title="dialogTitle"
@update:open="(v: boolean) => { if (!v) close() }"
>
<div class="p-6 text-center">
<div class="w-14 h-14 rounded-full bg-amber-100 flex items-center justify-center mx-auto mb-4">
<Clock class="w-7 h-7 text-amber-500" />
</div>
<h3 class="text-base font-semibold text-gray-900 mb-2">{{ dialogTitle }}</h3>
<p class="text-sm text-gray-500 leading-relaxed">
你的可用次数已用完,可通过以下方式获取:
</p>
<ul class="text-sm text-gray-500 mt-3 space-y-1.5 text-left">
<li class="flex items-center gap-2">
<span class="w-1.5 h-1.5 rounded-full bg-blue-400 shrink-0" />
邀请新用户注册获得奖励次数
</li>
<li class="flex items-center gap-2">
<span class="w-1.5 h-1.5 rounded-full bg-blue-400 shrink-0" />
购买 CDK 兑换码兑换次数 / VIP
</li>
</ul>
<div class="mt-5 flex gap-2">
<button
type="button"
class="press flex-1 h-10 rounded-xl text-sm font-medium flex items-center justify-center gap-1.5 border border-brand-200 text-brand-600 bg-brand-50"
@click="go('invite')"
>
<Gift class="w-4 h-4" />
立即邀请好友
</button>
<button
type="button"
class="press flex-1 h-10 rounded-xl text-sm font-semibold text-white flex items-center justify-center gap-1.5 cdk-goto-btn"
@click="go('buy')"
>
<ShoppingCart class="w-4 h-4" />
立即购买 CDK
</button>
</div>
</div>
</AppDialog>
</template>
<style scoped>
/* 购买按钮沿用个人中心 CDK 入口的珊瑚渐变,形成视觉呼应 */
.cdk-goto-btn {
background: linear-gradient(135deg, #ff9a3d 0%, #ff5d5d 55%, #f43f5e 100%);
box-shadow: 0 6px 16px -8px rgb(244 63 94 / 0.6);
}
</style>
+25 -3
View File
@@ -74,8 +74,14 @@ watch(
)
function onVisibility() {
if (document.visibilityState === 'visible') schedule()
else clearTimer()
if (document.visibilityState === 'visible') {
// 回到前台时重新测量:后台期间可能发生过尺寸变化(横竖屏切换、
// 系统字体缩放等),若沿用旧的 overflow,长文本的滚动距离会算错,
// 出现「还没滚完就翻页」或「滚过头留白」。
void measure().then(schedule)
} else {
clearTimer()
}
}
function openAll() {
@@ -194,11 +200,27 @@ onUnmounted(() => {
<style scoped>
/* 卡片:柠檬暖色玻璃(亮)/ 暗金半透明(暗),与品牌薄荷色区分开,表达「提醒」语义 */
.notice-card {
background: linear-gradient(135deg, rgb(255 246 214 / 0.92), rgb(255 250 235 / 0.86));
position: relative;
background: linear-gradient(135deg, rgb(255 246 214 / 0.95), rgb(255 250 235 / 0.9));
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%);
transition: box-shadow 0.25s ease, transform 0.15s ease;
}
/* 左侧琥珀色强调条:让公告在页面顶部更有存在感 */
.notice-card::before {
content: '';
position: absolute;
left: 0;
top: 20%;
bottom: 20%;
width: 3px;
border-radius: 9999px;
background: linear-gradient(180deg, #ffd166, #f5a623);
}
.notice-card:hover {
box-shadow: 0 2px 4px rgb(138 90 0 / 0.08), 0 10px 24px rgb(227 160 8 / 0.16);
}
.notice-icon {
color: #8a5a00;
@@ -0,0 +1,18 @@
/**
* 次数不足弹窗(打卡 / 请假 / 续假 / 销假共用)。
*
* 统一三个页面的表现:原先只有打卡页有专用弹窗,请销假页只有一闪而过的
* Toast,且提示里让用户"购买 CDK",但页面内没有任何购买/兑换入口。
*
* 底部两个按钮都跳「我的」页面:
* - 立即邀请好友:跳转后自动复制邀请码并提示
* - 立即购买 CDK:跳转后自动触发 CDK 购买按钮(弹流程说明 → 跳购买平台)
* 通过 query 参数 `intent` 传递意图,由 ProfileView 在挂载后按意图执行。
*/
export type NoRunsIntent = 'invite' | 'buy'
/** 跳转「我的」页并带上待执行意图 */
export function goProfileWithIntent(router: { push: (to: string) => unknown }, intent: NoRunsIntent) {
void router.push(`/profile?intent=${intent}`)
}
+130 -5
View File
@@ -91,10 +91,13 @@ watch(
{ flush: 'post' },
)
// 路由变化时同步底部 tab 高亮;回到首页时尝试加载公告强制弹窗(若引导已完成)
// 路由变化时同步底部 tab 高亮;进入需要弹窗的页面时尝试加载公告强制弹窗(若引导已完成)。
// 工单弹窗与页面无关(任意页面都要弹),但切页时顺手查一次,
// 让用户在两个入口之间切换时能更快看到新回复,而不是等下一个 30s 轮询。
watch(() => route.name, (name) => {
if (name) activeTab.value = name as string
if (name === 'home') maybeLoadAnnouncementPopups()
if (POPUP_ROUTES.has(String(name))) maybeLoadAnnouncementPopups()
if (!showGuide.value) checkTicketReplyPopups()
})
// 公告相关
@@ -116,6 +119,19 @@ const totalUnread = computed(() => unreadCount.value + ticketUnreadCount.value)
const forcePopupQueue = ref<AnnouncementItem[]>([])
const currentForcePopup = computed(() => forcePopupQueue.value[0] || null)
// 需要加载公告强制弹窗的页面:打卡页(home)与请假页(leave)。
// 两页均为高频主入口,公告(含后台标记的强制弹出)在此保持一致行为。
const POPUP_ROUTES = new Set(['home', 'leave'])
// 工单回复强制弹窗(任意页面均强制弹出)
// 仅对「本设备本账号新出现的未读回复通知」弹窗:以 localStorage 记录基线 id,
// 首次加载只记基线不弹历史(既避免历史未读轰炸,也避免 kind 列新增默认值
// 把历史状态通知误判为回复而弹出)。基线按用户 id 分开存储,避免多账号串扰。
const TICKET_NOTIF_SEEN_PREFIX = 'xz_ticket_notif_seen_id_'
let ticketNotifSeenId = 0
const ticketPopupQueue = ref<TicketNotificationItem[]>([])
const currentTicketPopup = computed(() => ticketPopupQueue.value[0] || null)
// 公告详情
const showDetail = ref(false)
const detailItem = ref<AnnouncementItem | null>(null)
@@ -155,6 +171,7 @@ function closeGuide() {
// 引导结束:补上公告未读数与强制弹窗队列
loadUnreadCount()
maybeLoadAnnouncementPopups()
checkTicketReplyPopups()
// 上报完成状态到数据库(看过当前版本后任何设备都不再弹);
// 测试强播来源不写库,避免污染该账号的"首次引导"状态
if (!guideForced.value) {
@@ -179,7 +196,10 @@ function needGuide(user: UserInfo): boolean {
}
async function maybeLoadAnnouncementPopups() {
if (announcementLoaded.value || showGuide.value || route.name !== 'home') return
if (announcementLoaded.value || showGuide.value) return
// 强制弹窗在打卡页与请假页均需生效(与打卡页一致)。
// 其他页面不主动加载:避免进入任意页就打扰,语义仍保持「这两个主入口必弹」。
if (!POPUP_ROUTES.has(String(route.name))) return
announcementLoaded.value = true
try {
const res = await announceApi.getMyAnnouncementInfo()
@@ -207,6 +227,77 @@ async function loadUnreadCount() {
}
}
// 拉取工单通知并检测「新出现的未读回复」,需要时强制弹窗(任意页面)。
// 首次加载(本账号尚无基线)只记录基线不弹窗,避免历史未读一次性弹出。
async function checkTicketReplyPopups() {
try {
const res = await ticketApi.getTicketNotifications()
const list = res.data.data || []
if (list.length === 0) return
// 通知按 created_at DESC 返回,取未读中 id 最大者为最新
const maxId = list.reduce((m, n) => (n.id > m ? n.id : m), 0)
const storageKey = TICKET_NOTIF_SEEN_PREFIX + (userInfo.value?.id ?? 'anon')
if (ticketNotifSeenId === 0) {
const stored = Number(localStorage.getItem(storageKey) || '0')
if (stored === 0) {
// 首次:建立基线,不弹历史
ticketNotifSeenId = maxId
localStorage.setItem(storageKey, String(maxId))
return
}
ticketNotifSeenId = stored
}
// 新出现(id > 基线)且未读的通知 → 入队弹窗。
// 回复与状态变更都要弹(管理员改状态同样是用户需要知道的事);
// 用户在详情页里的回复会推进基线,不会重复弹。
const fresh = list
.filter((n) => n.id > ticketNotifSeenId && !n.isRead)
.sort((a, b) => a.id - b.id)
if (fresh.length > 0) {
const queued = new Set(ticketPopupQueue.value.map((n) => n.id))
for (const n of fresh) {
if (!queued.has(n.id)) ticketPopupQueue.value.push(n)
}
}
// 推进基线(即使已读也推进,避免下次重复判定)
if (maxId > ticketNotifSeenId) {
ticketNotifSeenId = maxId
localStorage.setItem(storageKey, String(maxId))
}
} catch {
// ignore
}
}
// 关闭当前工单回复弹窗:标记已读并出队
async function dismissTicketPopup() {
const cur = ticketPopupQueue.value[0]
if (!cur) return
ticketPopupQueue.value = ticketPopupQueue.value.slice(1)
if (!cur.isRead) {
try {
await ticketApi.markTicketNotificationRead(cur.id)
cur.isRead = true
ticketUnreadCount.value = Math.max(0, ticketUnreadCount.value - 1)
} catch {
// ignore
}
}
}
// 从弹窗跳转到工单详情
async function goTicketFromPopup() {
const cur = ticketPopupQueue.value[0]
if (!cur) return
const ticketId = cur.ticketId
await dismissTicketPopup()
router.push(`/ticket/${ticketId}`)
}
async function openAnnouncementPanel() {
showAnnPanel.value = true
loadingAnnouncements.value = true
@@ -309,10 +400,14 @@ onMounted(async () => {
if (!showGuide.value) {
loadUnreadCount()
maybeLoadAnnouncementPopups()
checkTicketReplyPopups()
}
// 定时轮询未读数(仅更新铃铛角标,不产生弹窗)
pollTimer = setInterval(loadUnreadCount, 30000)
// 定时轮询未读数(仅更新铃铛角标)+ 检测工单新回复(任意页面强制弹出)
pollTimer = setInterval(() => {
loadUnreadCount()
checkTicketReplyPopups()
}, 30000)
})
onUnmounted(() => {
@@ -591,6 +686,36 @@ onUnmounted(() => {
</div>
</AppDialog>
<!-- ======== 工单回复强制弹窗(任意页面:有新回复时强制弹出) ======== -->
<AppDialog
:open="!!currentTicketPopup"
variant="center"
z-class="z-[60]"
overlay-class="bg-black/50"
wrapper-class="px-6"
panel-class="max-w-sm shadow-2xl"
:fade="true"
title="工单新回复"
@update:open="(v: boolean) => { if (!v) dismissTicketPopup() }"
>
<div class="h-1.5 bg-gradient-to-r from-rose-400 to-brand-400" />
<div class="p-6">
<div class="flex items-center gap-2.5 mb-2">
<div class="w-9 h-9 rounded-xl bg-rose-100 flex items-center justify-center">
<MessageSquare class="w-5 h-5 text-rose-500" />
</div>
<h3 class="text-base font-bold text-gray-900">{{ currentTicketPopup?.title }}</h3>
</div>
<div class="bg-gray-50 rounded-xl p-4 mb-4">
<p class="text-sm text-gray-600 leading-relaxed whitespace-pre-wrap">{{ currentTicketPopup?.content }}</p>
</div>
<div class="flex gap-2">
<Button variant="outline" class="flex-1 h-11 text-sm rounded-xl" @click="dismissTicketPopup">稍后处理</Button>
<Button class="flex-[1.4] h-11 text-sm font-medium rounded-xl" @click="goTicketFromPopup">查看工单</Button>
</div>
</div>
</AppDialog>
<!-- ======== 新手引导入口(全局:所有布局内页面显示;引导展示期间隐藏) ======== -->
<button
v-if="!showGuide"
+47
View File
@@ -0,0 +1,47 @@
/**
* 上传到今日校园 OBS 时的文件名处理。
*
* 背景(对真实 OBS 的 5 组对照实测):
* file 部分带 filename → 204 成功
* file 部分无 filename → 400 失败
* 与 Content-Type 无关
*
* 浏览器对 FormData.append(name, Blob) 会按规范自动补 filename="blob",
* 因此 `append('file', file)` 在多数浏览器能通过;但自动补出的名字无扩展名,
* 且部分环境(老 WebView、某些 App 内置浏览器)不补,直接导致 400。
* 所以这里统一显式给出带扩展名的文件名。
*/
const MIME_EXT: Record<string, string> = {
'image/jpeg': 'jpg',
'image/jpg': 'jpg',
'image/png': 'png',
'image/webp': 'webp',
'image/gif': 'gif',
'image/bmp': 'bmp',
'image/heic': 'heic',
}
/** 从 MIME 推断扩展名,未知则回退 jpg(后端默认按 image/jpeg 处理)。 */
export function extFromMime(mime?: string): string {
if (!mime) return 'jpg'
const key = mime.split(';')[0].trim().toLowerCase()
return MIME_EXT[key] || 'jpg'
}
/**
* 为上传到 OBS 的文件生成文件名。
* 优先用原始文件名(保留扩展名),否则按 MIME 生成。
*/
export function resolveUploadFileName(file: File | Blob, fallbackPrefix = 'image'): string {
const anyFile = file as File
const original = typeof anyFile?.name === 'string' ? anyFile.name.trim() : ''
if (original) {
// 有扩展名直接用;否则补一个
if (/\.[a-zA-Z0-9]{1,8}$/.test(original)) return original
return `${original}.${extFromMime(file.type)}`
}
const ext = extFromMime(file.type)
const stamp = Date.now().toString(36)
return `${fallbackPrefix}_${stamp}.${ext}`
}
+127 -24
View File
@@ -29,7 +29,12 @@ const formTitle = ref('')
const formContent = ref('')
const formTargetType = ref<'agent' | 'user'>('user')
const formTargetMode = ref<'all' | 'specific'>('all') // 推送范围:全部 / 指定用户
const formTargetUserId = ref('') // 指定用户ID
const formTargetUserId = ref<number | null>(null) // 指定对象ID(从搜索结果中选择)
const formTargetUserName = ref('') // 指定对象展示名(用于回显)
const targetKeyword = ref('') // 搜索关键词
const targetOptions = ref<adminApi.AnnouncementTargetItem[]>([])
const targetSearching = ref(false)
let targetSearchTimer: ReturnType<typeof setTimeout> | null = null
const formForcePopup = ref(false)
const formLoopPopup = ref(false)
const formIsPinned = ref(false)
@@ -61,7 +66,10 @@ function openCreate() {
formContent.value = ''
formTargetType.value = 'user'
formTargetMode.value = 'all'
formTargetUserId.value = ''
formTargetUserId.value = null
formTargetUserName.value = ''
targetKeyword.value = ''
targetOptions.value = []
formForcePopup.value = false
formLoopPopup.value = false
formIsPinned.value = false
@@ -74,13 +82,67 @@ function openEdit(item: AnnouncementEx) {
formContent.value = item.content
formTargetType.value = item.targetType
formTargetMode.value = item.targetUserId ? 'specific' : 'all'
formTargetUserId.value = item.targetUserId ? String(item.targetUserId) : ''
formTargetUserId.value = item.targetUserId ?? null
formTargetUserName.value = item.targetUserName || ''
targetKeyword.value = ''
targetOptions.value = []
formForcePopup.value = item.forcePopup
formLoopPopup.value = item.loopPopup
formIsPinned.value = item.isPinned
showCreateModal.value = true
}
// 搜索指定对象(防抖);回车或输入自动触发
function onTargetKeywordInput() {
if (targetSearchTimer) clearTimeout(targetSearchTimer)
targetSearchTimer = setTimeout(() => {
searchTargets()
}, 300)
}
async function searchTargets() {
const kw = targetKeyword.value.trim()
// 空关键词也查询:返回最近创建的对象列表,便于直接挑选
targetSearching.value = true
try {
const res = await adminApi.searchAnnouncementTargets(formTargetType.value, kw)
targetOptions.value = res.data.data || []
} catch {
targetOptions.value = []
} finally {
targetSearching.value = false
}
}
function selectTarget(t: adminApi.AnnouncementTargetItem) {
formTargetUserId.value = t.id
formTargetUserName.value = t.realName
? `${t.realName}(${t.username})`
: t.username
}
function clearSelectedTarget() {
formTargetUserId.value = null
formTargetUserName.value = ''
}
// 切换对象类型(用户/代理)时清空已选对象并刷新候选
function changeTargetType(t: 'agent' | 'user') {
formTargetType.value = t
if (t === 'agent') formIsPinned.value = false
clearSelectedTarget()
if (formTargetMode.value === 'specific') searchTargets()
}
// 切换到「指定用户」时自动拉取候选列表
function changeTargetMode(mode: 'all' | 'specific') {
formTargetMode.value = mode
if (mode === 'specific') {
clearSelectedTarget()
searchTargets()
}
}
async function handleCreate() {
if (!formTitle.value.trim()) {
message.info('请输入公告标题')
@@ -94,15 +156,11 @@ async function handleCreate() {
// 校验指定用户
let targetUserId: number | undefined = undefined
if (formTargetMode.value === 'specific') {
if (!formTargetUserId.value.trim()) {
message.info('请输入用户ID')
return
}
targetUserId = parseInt(formTargetUserId.value.trim())
if (isNaN(targetUserId) || targetUserId <= 0) {
message.info('请输入有效的用户ID')
if (!formTargetUserId.value || formTargetUserId.value <= 0) {
message.info('请搜索并选择要指定的用户')
return
}
targetUserId = formTargetUserId.value
}
const params = {
@@ -366,12 +424,12 @@ function scopeLabel(item: AnnouncementEx) {
<button
class="flex-1 h-10 rounded-lg text-sm font-medium transition-colors"
:class="formTargetType === 'user' ? 'adm-btn-brand' : 'bg-gray-100 text-gray-600 hover:bg-gray-200'"
@click="formTargetType = 'user'"
@click="changeTargetType('user')"
>普通用户</button>
<button
class="flex-1 h-10 rounded-lg text-sm font-medium transition-colors"
:class="formTargetType === 'agent' ? 'adm-btn-brand' : 'bg-gray-100 text-gray-600 hover:bg-gray-200'"
@click="formTargetType = 'agent'; formIsPinned = false"
@click="changeTargetType('agent')"
>代理</button>
</div>
</div>
@@ -382,7 +440,7 @@ function scopeLabel(item: AnnouncementEx) {
<button
class="flex-1 h-10 rounded-lg text-sm font-medium transition-colors"
:class="formTargetMode === 'all' ? 'adm-btn-brand' : 'bg-gray-100 text-gray-600 hover:bg-gray-200'"
@click="formTargetMode = 'all'"
@click="changeTargetMode('all')"
>
<Users class="w-3.5 h-3.5 inline mr-1" />
全部
@@ -390,24 +448,69 @@ function scopeLabel(item: AnnouncementEx) {
<button
class="flex-1 h-10 rounded-lg text-sm font-medium transition-colors"
:class="formTargetMode === 'specific' ? 'adm-btn-brand' : 'bg-gray-100 text-gray-600 hover:bg-gray-200'"
@click="formTargetMode = 'specific'"
@click="changeTargetMode('specific')"
>
<User class="w-3.5 h-3.5 inline mr-1" />
指定用户
指定{{ formTargetType === 'agent' ? '代理' : '用户' }}
</button>
</div>
</div>
<!-- 指定用户ID输入 -->
<!-- 指定对象:搜索并选择 -->
<div v-if="formTargetMode === 'specific'">
<label class="block text-xs font-medium text-gray-500 mb-1.5">用户ID</label>
<Input
v-model="formTargetUserId"
type="number"
placeholder="输入用户ID"
class="h-10 text-sm"
/>
<p class="text-xs text-gray-400 mt-1">可以在用户管理页面查看用户ID</p>
<label class="block text-xs font-medium text-gray-500 mb-1.5">
选择{{ formTargetType === 'agent' ? '代理' : '用户' }}
</label>
<!-- 已选对象 -->
<div v-if="formTargetUserId" class="flex items-center justify-between gap-2 mb-2 px-3 py-2 rounded-lg bg-brand-50 border border-brand-100">
<span class="text-sm text-brand-700 truncate">{{ formTargetUserName || `ID:${formTargetUserId}` }}</span>
<button class="text-xs text-brand-600 hover:text-brand-800 flex-shrink-0" @click="clearSelectedTarget">重新选择</button>
</div>
<!-- 搜索框 -->
<div v-else>
<div class="relative">
<Search class="w-4 h-4 text-gray-400 absolute left-3 top-1/2 -translate-y-1/2" />
<Input
v-model="targetKeyword"
:placeholder="formTargetType === 'agent' ? '输入代理用户名搜索' : '输入用户名 / 学号 / 姓名 / 学校搜索'"
class="h-10 text-sm pl-9"
@input="onTargetKeywordInput"
@keydown.enter.prevent="searchTargets"
/>
</div>
<!-- 候选列表 -->
<div class="mt-2 max-h-48 overflow-y-auto rounded-lg border border-gray-200 divide-y divide-gray-100">
<div v-if="targetSearching" class="px-3 py-3 text-xs text-gray-400 text-center">搜索中...</div>
<button
v-else-if="targetOptions.length === 0"
class="w-full px-3 py-3 text-xs text-gray-400 text-center"
disabled
>未找到匹配的{{ formTargetType === 'agent' ? '代理' : '用户' }}</button>
<button
v-for="t in targetOptions"
:key="t.id"
class="w-full px-3 py-2.5 text-left hover:bg-gray-50 transition-colors flex items-center gap-2"
@click="selectTarget(t)"
>
<span class="w-7 h-7 rounded-full bg-gray-100 text-gray-500 text-xs flex items-center justify-center font-medium flex-shrink-0">
{{ (t.realName || t.username || '?')[0] }}
</span>
<span class="min-w-0 flex-1">
<span class="block text-sm text-gray-800 truncate">
{{ t.realName || t.username }}
<span v-if="t.realName" class="text-gray-400 text-xs">({{ t.username }})</span>
</span>
<span class="block text-xs text-gray-400 truncate">
<template v-if="t.studentId">学号 {{ t.studentId }} · </template>
<template v-if="t.schoolName">{{ t.schoolName }} · </template>
用户ID {{ t.id }}
</span>
</span>
</button>
</div>
</div>
</div>
<div class="flex items-center gap-3">
+19 -7
View File
@@ -112,6 +112,18 @@ async function changeStatus(status: string) {
>
用户已删除
</span>
<!-- 用户是否已读管理员最近一次操作(回复或状态变更) -->
<span
class="adm-badge no-dot"
:class="ticket.userRead === false ? 'adm-badge-amber' : 'adm-badge-green'"
:title="
ticket.userRead === false
? '管理员最近一次回复或状态变更,用户尚未查看'
: '用户已查看管理员最近一次操作'
"
>
{{ ticket.userRead === false ? '用户未读' : '用户已读' }}
</span>
<span
class="adm-badge"
:class="ticketApi.TICKET_STATUS_COLOR[ticket.status] || 'adm-badge-gray'"
@@ -134,14 +146,14 @@ async function changeStatus(status: string) {
class="h-8 px-3.5 rounded-full text-xs font-semibold adm-btn-success hover:brightness-105"
@click="changeStatus('resolved')"
>
标记已解决
标记已完成
</button>
<button
v-if="ticket.status !== 'closed' && ticket.status !== 'resolved'"
class="h-8 px-3.5 rounded-full text-xs font-semibold bg-green-50 text-green-700 hover:bg-green-100"
@click="changeStatus('resolved')"
>
直接解决
标记已完成
</button>
<button
v-if="ticket.status !== 'closed'"
@@ -171,15 +183,15 @@ async function changeStatus(status: string) {
</div>
</div>
<!-- 对话消息 -->
<!-- 对话消息:管理员(我)在右,用户在左;首条初始内容亦为用户、居左 -->
<div
v-for="msg in ticket.messages"
:key="msg.id"
class="flex"
:class="msg.senderType === 'admin' ? 'justify-start' : 'justify-end'"
:class="msg.senderType === 'admin' ? 'justify-end' : 'justify-start'"
>
<div class="max-w-[85%]">
<div class="flex items-center gap-2 mb-1" :class="msg.senderType === 'user' ? 'flex-row-reverse' : ''">
<div class="flex items-center gap-2 mb-1" :class="msg.senderType === 'admin' ? 'flex-row-reverse' : ''">
<span
class="w-7 h-7 rounded-full text-xs flex items-center justify-center font-bold flex-shrink-0"
:class="msg.senderType === 'admin' ? 'bg-brand-gradient text-white' : 'bg-sky-50 text-sky-600'"
@@ -192,8 +204,8 @@ async function changeStatus(status: string) {
class="px-4 py-3 border shadow-sm"
:class="
msg.senderType === 'admin'
? 'bg-brand-50 rounded-2xl rounded-tl-sm border-brand-100'
: 'bg-white rounded-2xl rounded-tr-sm border-gray-100'
? 'bg-brand-50 rounded-2xl rounded-tr-sm border-brand-100'
: 'bg-white rounded-2xl rounded-tl-sm border-gray-100'
"
>
<p class="text-sm text-gray-700 whitespace-pre-wrap">{{ msg.content }}</p>
+16 -2
View File
@@ -36,8 +36,8 @@ const statusOptions = [
{ value: '', label: '全部' },
{ value: 'pending', label: '待处理' },
{ value: 'processing', label: '处理中' },
{ value: 'resolved', label: '已解决' },
{ value: 'closed', label: '已关闭' },
{ value: 'resolved', label: '已完成' },
{ value: 'closed', label: '关闭' },
]
onMounted(async () => {
@@ -109,6 +109,12 @@ function goDetail(id: number) {
@click="goDetail(t.id)"
>
<div class="flex items-center justify-between mb-1">
<!-- 用户有新回复未读:最需要管理员处理,放最前用一个实心点强调 -->
<span
v-if="t.adminRead === false"
class="mr-2 w-2 h-2 rounded-full bg-rose-500 flex-shrink-0"
title="用户有新回复,管理员尚未查看"
/>
<h3 class="text-sm font-semibold text-gray-900 truncate flex-1">{{ t.title }}</h3>
<span
v-if="t.deleted"
@@ -117,6 +123,14 @@ function goDetail(id: number) {
>
用户已删除
</span>
<!-- 用户是否已读最近一次管理员操作 -->
<span
v-if="t.userRead === false"
class="ml-2 adm-badge no-dot adm-badge-amber flex-shrink-0"
title="管理员最近一次回复或状态变更,用户尚未查看"
>
用户未读
</span>
<span
class="ml-2 adm-badge flex-shrink-0"
:class="ticketApi.TICKET_STATUS_COLOR[t.status] || 'adm-badge-gray'"
+109 -85
View File
@@ -5,8 +5,10 @@ import { CheckCircle, Clock, Plus, ScanLine, ImagePlus, Play, Trash2, ListTodo,
import { Button } from '@/components/ui/button'
import AppDialog from '@/components/ui/dialog/AppDialog.vue'
import PinnedNotice from '@/components/PinnedNotice.vue'
import NoRunsDialog from '@/components/NoRunsDialog.vue'
import QrScanner from '@/components/QrScanner.vue'
import jsQR from 'jsqr'
import { resolveUploadFileName } from '@/utils/obsUpload'
import * as authApi from '@/api/auth'
import * as announceApi from '@/api/announcement'
import { CPD_PASSWORD_ERROR } from '@/api'
@@ -642,6 +644,8 @@ async function compressImage(file: File, maxBytes = 300 * 1024): Promise<File> {
if (scale < 0.3) break
}
if (!out) return file
// 压缩产物为空时回退原文件(其非空性由调用方校验),避免把 0 字节文件上传到 OBS
if (out.size === 0) return file
const name = file.name.replace(/\.[^.]+$/, '') + '.jpg'
return new File([out], name, { type: 'image/jpeg' })
}
@@ -728,6 +732,14 @@ async function handleImageUpload(index: number, isEdit: boolean) {
// 记录原值:任何失败路径都回滚,避免本地预览的 base64 被当成正式值保存入库
const prevContent = items[index].content || ''
// 空文件拦截(二维码字段除外,二维码走本地解析不上传):0 字节图片若被上传,
// 会在 OBS 生成空对象,今日校园人脸核验时拉取文件失败(FRS.0036)而被驳回。
// 故在选择阶段即拒绝,要求用户重新选择有效图片。
if (!isQrField(items[index]) && picked.size === 0) {
message.error('所选图片为空或读取失败,请重新选择有效的照片')
return
}
// ---- 二维码字段:前端 jsQR 直接解析出 uuid,不上传、不压缩 ----
if (isQrField(items[index])) {
uploadingImage.value = true
@@ -758,6 +770,13 @@ async function handleImageUpload(index: number, isEdit: boolean) {
file = picked
}
// 压缩后二次空文件校验:异常压缩可能产出 0 字节文件,上传将生成空对象
if (!file || file.size === 0) {
message.error('图片处理结果为空,请重新选择有效的照片')
items[index].content = prevContent
return
}
// 本地预览(仅界面展示,上传成功后会覆盖为 OBS 正式 URL)
const reader = new FileReader()
reader.onload = (e) => {
@@ -795,14 +814,20 @@ async function handleImageUpload(index: number, isEdit: boolean) {
obsForm.append('policy', policy.policy || policy.Policy)
obsForm.append('content-type', file.type || 'image/jpeg')
}
obsForm.append('file', file)
// file 部分必须显式带 filename。
// 实测(对真实 OBS 的 5 组对照):file 无 filename 时 OBS 直接返回 400,
// 有 filename 才返回 204;与 Content-Type 无关。
// 浏览器对 append(name, Blob) 虽会自动补 filename="blob",但部分环境
// 不补或补成无扩展名,故这里显式给出带扩展名的文件名。
const fileName = resolveUploadFileName(file)
obsForm.append('file', file, fileName)
const obsRes = await fetch(policy.host || policy.Host, {
method: 'POST',
body: obsForm,
})
if (obsRes.status > 299) {
message.error('图片上传到 OBS 失败(' + obsRes.status + '),请重试')
message.error('图片上传到 OBS 失败(' + obsRes.status + '),请重新选择照片上传')
items[index].content = prevContent
return
}
@@ -818,7 +843,7 @@ async function handleImageUpload(index: number, isEdit: boolean) {
})
const previewJson = await previewRes.json()
if (previewJson.code !== 0) {
message.error('获取预览 URL 失败: ' + (previewJson.message || previewRes.status))
message.error('获取预览 URL 失败,请重新选择照片上传: ' + (previewJson.message || previewRes.status))
items[index].content = prevContent
return
}
@@ -827,7 +852,7 @@ async function handleImageUpload(index: number, isEdit: boolean) {
items[index].content = previewUrl
message.success('图片上传成功,保存表单后生效')
} catch (e: any) {
message.error('图片上传失败: ' + (e?.message || '未知错误'))
message.error('图片上传失败,请重新选择照片上传: ' + (e?.message || '未知错误'))
items[index].content = prevContent
} finally {
uploadingImage.value = false
@@ -1000,6 +1025,10 @@ function executeTask(task: authApi.TaskInfo, confirmed = false) {
execStatus.value = 'submitting'
execMessage.value = '正在提交签到...'
},
onVerifying: () => {
execStatus.value = 'submitting'
execMessage.value = '正在核验人脸识别结果...'
},
onSuccess: () => {
execStatus.value = 'success'
execMessage.value = '签到成功!'
@@ -1875,36 +1904,47 @@ function getGPSLocation(source: string) {
<p v-if="item.tip" class="text-xs text-gray-400">{{ item.tip }}</p>
</div>
<!-- 图片:点击直接选文件替换,不拉伸、无输入框 -->
<!-- 图片:已有图显示缩略图 + 「编辑」按钮重新上传;无图时点上传框 -->
<div v-else-if="isImageField(item)" class="space-y-1">
<label class="text-xs font-medium text-gray-600">{{ item.title }} <span v-if="item.required" class="text-red-400">*</span></label>
<!-- 上传中:loading 动画(优先于缩略图,替换时给出明确反馈) -->
<div
@click="handleImageUpload(idx, false)"
class="cursor-pointer"
v-if="uploadingImage"
class="w-24 h-24 flex flex-col items-center justify-center gap-1 border-2 border-dashed border-gray-300 rounded-lg text-blue-500"
>
<!-- 上传中:loading 动画 -->
<div
v-if="uploadingImage"
class="w-24 h-24 flex flex-col items-center justify-center gap-1 border-2 border-dashed border-gray-300 rounded-lg text-blue-500"
>
<LoaderCircle class="w-6 h-6 animate-spin" />
<span class="text-xs">{{ uploadingLabel }}</span>
</div>
<!-- 已有图片:显示缩略图,点击替换 -->
<img
v-else-if="item.content && (item.content.startsWith('http') || item.content.startsWith('data:image'))"
:src="item.content"
class="w-24 h-24 object-cover rounded-lg border border-gray-200 hover:opacity-70 transition-opacity"
/>
<!-- 无图片:虚线上传框 -->
<div
v-else
class="w-24 h-24 flex flex-col items-center justify-center border-2 border-dashed border-gray-300 rounded-lg hover:border-blue-400 hover:text-blue-500 text-gray-400 transition-colors"
>
<Upload class="w-6 h-6" />
<span class="text-xs mt-1">上传</span>
</div>
<LoaderCircle class="w-6 h-6 animate-spin" />
<span class="text-xs">{{ uploadingLabel }}</span>
</div>
<!-- 已有图片:缩略图 + 显式「编辑」按钮(点按钮重新上传),
不再把整块做成上传热区,避免用户误以为点图片是预览 -->
<div
v-else-if="item.content && (item.content.startsWith('http') || item.content.startsWith('data:image'))"
class="relative inline-block group"
>
<img
:src="item.content"
class="w-24 h-24 object-cover rounded-lg border border-gray-200"
/>
<button
type="button"
@click.stop="handleImageUpload(idx, false)"
class="absolute bottom-1 right-1 flex items-center justify-center gap-1 px-2 py-1 rounded-md bg-black/60 text-white text-[11px] leading-none backdrop-blur-sm hover:bg-black/75 active:scale-95 transition"
title="重新上传图片"
>
<Pencil class="w-3 h-3" />
<span>编辑</span>
</button>
</div>
<!-- 无图片:虚线上传框 -->
<button
v-else
type="button"
@click="handleImageUpload(idx, false)"
class="w-24 h-24 flex flex-col items-center justify-center border-2 border-dashed border-gray-300 rounded-lg hover:border-blue-400 hover:text-blue-500 text-gray-400 transition-colors"
>
<Upload class="w-6 h-6" />
<span class="text-xs mt-1">上传</span>
</button>
<p v-if="item.tip" class="text-xs text-gray-400">{{ item.tip }}</p>
</div>
@@ -2123,34 +2163,46 @@ function getGPSLocation(source: string) {
<p v-if="item.tip" class="text-xs text-gray-400">{{ item.tip }}</p>
</div>
<!-- 图片:点击直接选文件替换 -->
<!-- 图片:已有图显示缩略图 + 「编辑」按钮重新上传;无图时点上传框 -->
<div v-else-if="isImageField(item)" class="space-y-1">
<label class="text-xs font-medium text-gray-600">{{ item.title }} <span v-if="item.required" class="text-red-400">*</span></label>
<!-- 上传中:loading 动画(优先于缩略图,替换时给出明确反馈) -->
<div
@click="handleImageUpload(idx, true)"
class="cursor-pointer"
v-if="uploadingImage"
class="w-24 h-24 flex flex-col items-center justify-center gap-1 border-2 border-dashed border-gray-300 rounded-lg text-blue-500"
>
<!-- 上传中:loading 动画 -->
<div
v-if="uploadingImage"
class="w-24 h-24 flex flex-col items-center justify-center gap-1 border-2 border-dashed border-gray-300 rounded-lg text-blue-500"
>
<LoaderCircle class="w-6 h-6 animate-spin" />
<span class="text-xs">{{ uploadingLabel }}</span>
</div>
<img
v-else-if="item.content && (item.content.startsWith('http') || item.content.startsWith('data:image'))"
:src="item.content"
class="w-24 h-24 object-cover rounded-lg border border-gray-200 hover:opacity-70 transition-opacity"
/>
<div
v-else
class="w-24 h-24 flex flex-col items-center justify-center border-2 border-dashed border-gray-300 rounded-lg hover:border-blue-400 hover:text-blue-500 text-gray-400 transition-colors"
>
<Upload class="w-6 h-6" />
<span class="text-xs mt-1">上传</span>
</div>
<LoaderCircle class="w-6 h-6 animate-spin" />
<span class="text-xs">{{ uploadingLabel }}</span>
</div>
<!-- 已有图片:缩略图 + 显式「编辑」按钮(点按钮重新上传) -->
<div
v-else-if="item.content && (item.content.startsWith('http') || item.content.startsWith('data:image'))"
class="relative inline-block group"
>
<img
:src="item.content"
class="w-24 h-24 object-cover rounded-lg border border-gray-200"
/>
<button
type="button"
@click.stop="handleImageUpload(idx, true)"
class="absolute bottom-1 right-1 flex items-center justify-center gap-1 px-2 py-1 rounded-md bg-black/60 text-white text-[11px] leading-none backdrop-blur-sm hover:bg-black/75 active:scale-95 transition"
title="重新上传图片"
>
<Pencil class="w-3 h-3" />
<span>编辑</span>
</button>
</div>
<!-- 无图片:虚线上传框 -->
<button
v-else
type="button"
@click="handleImageUpload(idx, true)"
class="w-24 h-24 flex flex-col items-center justify-center border-2 border-dashed border-gray-300 rounded-lg hover:border-blue-400 hover:text-blue-500 text-gray-400 transition-colors"
>
<Upload class="w-6 h-6" />
<span class="text-xs mt-1">上传</span>
</button>
<p v-if="item.tip" class="text-xs text-gray-400">{{ item.tip }}</p>
</div>
@@ -2265,41 +2317,13 @@ function getGPSLocation(source: string) {
</div>
</AppDialog>
<!-- ======== 无可用签到次数弹窗 ======== -->
<AppDialog
<!-- ======== 无可用签到次数弹窗(统一组件:底部为邀请 / 购买两个动作) ======== -->
<NoRunsDialog
:open="showNoRunsDialog"
variant="center"
panel-class="w-[85%] sm:max-w-sm"
title="签到次数不足"
@update:open="(v: boolean) => { if (!v) showNoRunsDialog = false }"
>
<!-- 内容 -->
<div class="p-6 text-center">
<div class="w-14 h-14 rounded-full bg-amber-100 flex items-center justify-center mx-auto mb-4">
<Clock class="w-7 h-7 text-amber-500" />
</div>
<h3 class="text-base font-semibold text-gray-900 mb-2">签到次数不足</h3>
<p class="text-sm text-gray-500 leading-relaxed">
你的签到次数已用完,请通过以下方式获取次数:
</p>
<ul class="text-sm text-gray-500 mt-3 space-y-1.5 text-left">
<li class="flex items-center gap-2">
<span class="w-1.5 h-1.5 rounded-full bg-blue-400 shrink-0" />
购买 CDK 兑换码兑换签到次数
</li>
<li class="flex items-center gap-2">
<span class="w-1.5 h-1.5 rounded-full bg-blue-400 shrink-0" />
邀请新用户注册获得奖励次数
</li>
</ul>
<Button
class="mt-5 w-full h-10 text-sm font-medium"
@click="showNoRunsDialog = false"
>
我知道了
</Button>
</div>
</AppDialog>
:message="noRunsMessage"
@update:open="(v: boolean) => { showNoRunsDialog = v }"
/>
<!-- ======== 未绑定设备码弹窗(新建任务入口拦截) ======== -->
<AppDialog
+64 -1
View File
@@ -2,6 +2,9 @@
<!-- 请/销假页:上方请假+销假按钮(左右平分),下方请假/销假日志列表。
两者均为同步执行:请假 → 填表提交;销假 → 选请假单 → 填位置 → 立即销假。 -->
<div class="p-4 space-y-4">
<!-- 置顶公告:与打卡页一致的顶部轮播条(见 PinnedNotice) -->
<PinnedNotice v-if="pinnedAnnouncements.length" :items="pinnedAnnouncements" class="rise-in" />
<!-- 入口卡片:请假 / 销假 按钮并排左右平分(珊瑚渐变头卡) -->
<div class="relative overflow-hidden hero-clip rounded-3xl p-5 text-white shadow-lg shadow-rose-500/25 leave-hero rise-in">
<div class="pointer-events-none absolute -right-10 -top-12 w-40 h-40 rounded-full bg-white/15" />
@@ -790,6 +793,14 @@
<MessageSquare class="w-3.5 h-3.5 flex-shrink-0" />
<span class="bookmark-vtext">无法请/销假?提交工单</span>
</button>
<!-- ======== 次数不足弹窗(统一组件:底部为邀请 / 购买两个动作) ======== -->
<NoRunsDialog
:open="showNoRunsDialog"
:title="noRunsTitle"
:message="noRunsMessage"
@update:open="(v: boolean) => { showNoRunsDialog = v }"
/>
</div>
</template>
@@ -797,8 +808,12 @@
import { ref, computed, watch, onMounted, onUnmounted } from 'vue'
import { useRouter } from 'vue-router'
import { CalendarCheck, CalendarPlus, CalendarClock, LoaderCircle, Trash2, X, MapPin, Upload, MessageSquare, Smartphone, Edit3 } from 'lucide-vue-next'
import { resolveUploadFileName } from '@/utils/obsUpload'
import * as leaveApi from '@/api/leave'
import * as authApi from '@/api/auth'
import * as announceApi from '@/api/announcement'
import PinnedNotice from '@/components/PinnedNotice.vue'
import NoRunsDialog from '@/components/NoRunsDialog.vue'
import type { LeaveItem, LeaveReportPlace, LeaveType } from '@/api/leave'
import { useMessage } from '@/composables/useMessage'
import { confirmDialog } from '@/composables/useConfirm'
@@ -814,6 +829,33 @@ function goTicket() {
}
// ======== 销假日志 ========
// 次数不足弹窗(与打卡页统一组件;标题按场景区分)
const showNoRunsDialog = ref(false)
const noRunsTitle = ref('可用次数不足')
const noRunsMessage = ref('')
/** 判断是否为次数不足错误,是则弹专用窗并返回 true */
function handleNoRunsError(msg: string): boolean {
if (msg.includes('可用次数不足') || msg.includes('次数不足') || msg.includes('sign_count')) {
noRunsMessage.value = msg
showNoRunsDialog.value = true
return true
}
return false
}
// ======== 置顶公告(顶部轮播条,与打卡页一致,渲染见 PinnedNotice) ========
const pinnedAnnouncements = ref<announceApi.PinnedAnnouncement[]>([])
async function loadPinnedAnnouncements() {
try {
const res = await announceApi.getPinnedAnnouncements()
pinnedAnnouncements.value = res.data.data || []
} catch {
pinnedAnnouncements.value = []
}
}
const loadingLogs = ref(false)
const leaveLogs = ref<leaveApi.LeaveLog[]>([])
@@ -1031,7 +1073,8 @@ async function uploadImageToObs(file: File): Promise<string | null> {
obsForm.append('policy', policy.policy || policy.Policy)
obsForm.append('content-type', file.type || 'image/jpeg')
}
obsForm.append('file', file)
// file 部分必须显式带 filename,否则 OBS 返回 400(实测结论,与 Content-Type 无关)
obsForm.append('file', file, resolveUploadFileName(file))
const obsRes = await fetch(policy.host || policy.Host, {
method: 'POST',
@@ -1153,6 +1196,8 @@ async function submitReport() {
address: form.value.address.trim(),
imageUrl: form.value.imageUrl || undefined,
approverWid: form.value.approverWid || undefined,
// 三易拓新学工5 等系统销假需回传请假类型 UUID
projectId: selectedLeave.value.typeWid || undefined,
},
{
onConnecting: () => {
@@ -1186,6 +1231,12 @@ async function submitReport() {
// 限流提示
if (data?.rateLimit) {
message.warning(msg)
} else if (handleNoRunsError(msg)) {
// 次数不足:弹专用窗(不显示红色 Toast,避免误导为系统错误)
noRunsTitle.value = '销假次数不足'
execStatus.value = 'error'
execMessage.value = ''
loadLogs()
} else {
execStatus.value = 'error'
execMessage.value = msg
@@ -1205,6 +1256,7 @@ const showBindDeviceDialog = ref(false)
onMounted(async () => {
loadLogs()
void loadPinnedAnnouncements()
try {
const res = await authApi.getUserInfo()
deviceCode.value = res.data.data?.mobileCode || ''
@@ -1218,6 +1270,8 @@ const showApplyModal = ref(false)
const loadingTypes = ref(false)
const leaveTypes = ref<LeaveType[]>([])
const applying = ref(false)
// 本次提交是否为续假(供次数不足弹窗区分标题;避免在 onError 里读 applyForm 引发类型推断问题)
const submittingExtend = ref(false)
const applyStatus = ref('') // connecting / success / error
const applyMessage = ref('')
@@ -1725,6 +1779,7 @@ onUnmounted(() => {
async function submitApply() {
if (applying.value) return
const f = applyForm.value
submittingExtend.value = !!f.isExtend
const t = currentType.value
if (!f.leaveType) {
message.warning('请选择请假类型')
@@ -1804,6 +1859,8 @@ async function submitApply() {
abortApply = leaveApi.executeLeaveApplySSE(
{
leaveType: f.leaveType,
// 三易拓新学工5 等系统用 UUID 作类型 ID,提交时必须带上
projectId: t?.projectId || undefined,
startTime: toApiTime(f.startTime),
endTime: toApiTime(f.endTime),
leaveReason: f.leaveReason.trim(),
@@ -1857,6 +1914,12 @@ async function submitApply() {
onError: (msg, data) => {
if (data?.rateLimit) {
message.warning(msg)
} else if (handleNoRunsError(msg)) {
// 次数不足:弹专用窗,文案由弹窗给出;标题按请假/续假区分
noRunsTitle.value = submittingExtend.value ? '续假次数不足' : '请假次数不足'
applyStatus.value = 'error'
applyMessage.value = ''
loadLogs()
} else {
applyStatus.value = 'error'
applyMessage.value = msg
+326 -104
View File
@@ -1,8 +1,9 @@
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { LogOut, RefreshCw, Gift, X, Dices, TriangleAlert, SunMoon, Sun, Moon, Crown, Copy, ChevronRight, Smartphone, Users, Coins, UserPlus, GraduationCap, Headset, ShoppingCart, Flame } from 'lucide-vue-next'
import { useRouter, useRoute } from 'vue-router'
import { LogOut, RefreshCw, Gift, X, Dices, TriangleAlert, SunMoon, Sun, Moon, Copy, ChevronRight, Smartphone, Coins, UserPlus, Headset, ShoppingCart, Flame } from 'lucide-vue-next'
import { themeMode, setThemeMode, type ThemeMode } from '@/composables/useTheme'
import { confirmDialog } from '@/composables/useConfirm'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import AppDialog from '@/components/ui/dialog/AppDialog.vue'
@@ -23,6 +24,7 @@ const themeHint = computed(() =>
)
const router = useRouter()
const route = useRoute()
const message = useMessage()
const userInfo = ref<UserInfo | null>(null)
const avatarFailed = ref(false)
@@ -37,6 +39,7 @@ const invitedCount = ref(0)
const canRedeem = ref(false)
const hasInviter = ref(false)
const redeemingInvite = ref(false)
const resettingInvite = ref(false)
// 填写邀请码弹窗
const showFillInviteModal = ref(false)
const fillInviteCode = ref('')
@@ -79,13 +82,42 @@ const redeemCodeText = ref('')
const redeeming = ref(false)
const redeemError = ref('')
// 弹窗打开来源:count=兑换次数 / vip=开通或续费VIP(仅影响标题文案)
const redeemKind = ref<'count' | 'vip'>('count')
// VIP 信息
const isVip = ref(false)
const vipExpireAt = ref('')
// 「购买 CDK」入口(后台系统设置控制:跳转链接 + 是否展示;visible 且 url 非空才显示)
const cdkBuyUrl = ref('')
const cdkBuyVisible = ref(false)
// 跳转购买平台前的说明弹窗:告知购买流程(需邮箱+查询密码,支付后回本站兑换)
const showCdkBuyConfirm = ref(false)
function onCdkBuyClick() {
if (!cdkBuyUrl.value) return
showCdkBuyConfirm.value = true
}
// 兑换弹窗内「没有 CDK?立即购买」:等同于点击外层购买入口。
// 先关闭兑换弹窗,再走同一套 onCdkBuyClick(弹流程说明 → 确认后跳购买平台),
// 避免两个弹窗叠加。
function onBuyFromRedeemModal() {
closeRedeemModal()
if (!cdkBuyVisible.value) {
message.warning('购买入口暂未开放,请联系管理员')
return
}
onCdkBuyClick()
}
// 确认后在新标签打开购买平台。用真实 <a> 动态点击而非 window.open,
// 避免手机浏览器把它当弹窗强制当前页打开。
function confirmCdkBuy() {
showCdkBuyConfirm.value = false
const a = document.createElement('a')
a.href = cdkBuyUrl.value
a.target = '_blank'
a.rel = 'noopener noreferrer'
document.body.appendChild(a)
a.click()
a.remove()
}
// 验证码
const captchaId = ref('')
const captchaImage = ref('')
@@ -131,13 +163,6 @@ function maskDeviceCode(code: string): string {
// 是否已绑定设备码(决定「立即绑定 / 重新绑定」按钮文案)
const hasDeviceCode = computed(() => !!userInfo.value?.mobileCode)
// 头部信息标签:学院 / 专业 / 班级(有值才显示,最多 2 个,避免挤满)
const profileTags = computed(() => {
const u = userInfo.value
if (!u) return []
return [u.academy, u.className || u.major].filter((t): t is string => !!t && !!t.trim()).slice(0, 2)
})
// VIP 剩余天数(到期时间无效 / 已过期返回 null)
const vipDaysLeft = computed(() => {
if (!isVip.value || !vipExpireAt.value) return null
@@ -187,6 +212,24 @@ onMounted(async () => {
} catch {
// 加载失败不阻塞页面
}
// 处理来自「次数不足」弹窗的跳转意图:
// intent=invite → 自动复制邀请码并提示
// intent=buy → 自动触发 CDK 购买入口(弹流程说明 → 跳购买平台)
const intent = String(route.query.intent || '')
if (intent === 'invite') {
copyInviteCode()
} else if (intent === 'buy') {
if (cdkBuyVisible.value) {
onCdkBuyClick()
} else {
message.warning('购买入口暂未开放,请联系管理员')
}
}
if (intent) {
// 清掉 query,避免刷新或再次返回时重复触发
void router.replace({ path: '/profile' })
}
})
function handleLogout() {
@@ -234,8 +277,7 @@ async function confirmRandomBind() {
}
// ---- 兑换弹窗 ----
function openRedeemModal(kind: 'count' | 'vip' = 'count') {
redeemKind.value = kind
function openRedeemModal() {
showRedeemModal.value = true
redeemCodeText.value = ''
redeemError.value = ''
@@ -333,6 +375,50 @@ function fallbackCopy(text: string) {
document.body.removeChild(ta)
}
// ---- 重置邀请码 ----
// 旧码会立即失效:已完成的邀请关系不受影响,但旧码不能再被新用户填写。
async function onResetInviteTap() {
if (resettingInvite.value) return
const ok = await confirmDialog({
title: '重置邀请码',
content:
'重置后将为你生成一个新的邀请码,原来的邀请码会立即失效,别人再用旧码填写将无法绑定。\n\n' +
'已经通过旧码完成的邀请关系不受影响,你与好友获得的积分也不会被收回。',
confirmText: '确认重置',
cancelText: '再想想',
danger: true,
})
if (!ok) return
resettingInvite.value = true
try {
const res = await authApi.resetInviteCode()
const code = res.data.data?.inviteCode || ''
if (!code) {
// 接口成功但未返回新码:不能静默当作成功,否则页面仍显示旧码
message.error('重置失败:未获取到新的邀请码,请重试')
return
}
inviteCode.value = code
message.success('邀请码已重置,请使用新的邀请码分享')
} catch (e: any) {
const status = e?.response?.status
const msg = e?.response?.data?.message || e?.message || '重置失败,请稍后重试'
message.error(msg)
// 409:并发重置冲突,说明服务端已是新码。重新拉取,避免页面继续显示旧码
if (status === 409) {
try {
const info = await authApi.getMyInviteInfo()
applyInviteInfo(info.data.data)
} catch {
// 拉取失败不覆盖原提示,用户可手动刷新
}
}
} finally {
resettingInvite.value = false
}
}
// ---- 填写他人邀请码 ----
function openFillInviteModal() {
fillInviteCode.value = ''
@@ -384,57 +470,61 @@ async function handleRedeemInvite() {
</script>
<template>
<div class="flex flex-col min-h-full">
<div class="flex flex-col min-h-full profile-page">
<!-- ======== 顶部:品牌渐变背景 + 个人信息(左对齐)+ 会员卡 ======== -->
<div class="relative overflow-hidden hero-clip bg-brand-gradient px-5 pb-20 text-white flex-shrink-0" style="padding-top: calc(var(--safe-top) + 28px)">
<!-- 头图绿色背景:高度需让渐变边界切到「购买 CDK 兑换码」卡片中线(390px 宽实测)。
几何(相对 hero 顶部):兑换 CDK 按钮中线 99、购买卡 top 152、购买卡中线 196。
hero 底边 = 上内边距 14 + 内容高 + 下内边距 pb;
卡片用等量负上边距抵消,故 pb 增加多少,绿边界就下移多少、卡片位置不动。
pb-22(88px)时边界在 102,只到「兑换 CDK」按钮;需 182px 才到购买卡中线 196。
购买卡高 88,其上移量取 138 = 182 - 44(原 -mt-11 的偏移),保持 VIP 卡 top=58 不变。
注:卡片不透明,绿色只在其左右留白处可见。 -->
<div class="profile-hero relative overflow-hidden hero-clip bg-brand-gradient px-5 text-white flex-shrink-0">
<!-- 装饰:柔光圆 + 细网格纹理 -->
<div class="pointer-events-none absolute -right-16 -top-20 w-64 h-64 rounded-full bg-white/10" />
<div class="pointer-events-none absolute right-10 top-24 w-28 h-28 rounded-full bg-white/10" />
<div class="pointer-events-none absolute inset-0 profile-hero-grid" />
<!-- 头像 + 姓名 + 学校 -->
<div class="relative flex items-center gap-4 rise-in">
<div class="relative flex-shrink-0">
<div class="w-[68px] h-[68px] rounded-full overflow-hidden ring-[3px] ring-white/60 shadow-lg shadow-black/15 bg-white/20 flex items-center justify-center">
<img v-show="userInfo?.avatar && !avatarFailed" :src="userInfo?.avatar" class="w-full h-full object-cover" alt="头像" @error="avatarFailed = true" />
<span v-if="!userInfo?.avatar || avatarFailed" class="text-2xl font-bold">{{ userInfo?.realName?.[0] || userInfo?.username?.[0] || '?' }}</span>
</div>
<div v-if="isVip" class="vip-badge absolute -right-2 -bottom-1 z-10 whitespace-nowrap">VIP</div>
</div>
<div class="min-w-0 flex-1">
<p class="text-[22px] font-bold tracking-tight leading-tight truncate">{{ userInfo?.realName || userInfo?.username || '--' }}</p>
<p class="mt-1 flex items-center gap-1 text-sm text-white/90 truncate">
<GraduationCap class="w-3.5 h-3.5 flex-shrink-0" />
<span class="truncate">{{ userInfo?.schoolName || '--' }}</span>
</p>
<div v-if="profileTags.length" class="mt-2 flex flex-wrap gap-1.5">
<span v-for="t in profileTags" :key="t" class="max-w-[10rem] truncate text-xs rounded-md bg-white/15 ring-1 ring-white/20 px-1.5 py-0.5 text-white/95">{{ t }}</span>
</div>
</div>
</div>
</div>
<!-- ======== 会员卡(压在头图下沿) ======== -->
<div class="relative px-4 -mt-14 rise-in" style="animation-delay: 40ms">
<div class="member-card relative overflow-hidden hero-clip rounded-3xl p-4 shadow-xl" :class="isVip ? 'is-vip' : 'is-free'">
<!-- ======== 会员卡(压在头图下沿) ========
负上边距 = hero pb - 44,抵消 pb 增加带来的整体下移,
使 VIP 卡 top 恒为 58px(相对 hero 顶部),只有绿色边界在动。
当前 pb 182 → -mt 138。改 pb 时必须同步改这里,否则整块内容会跟着平移。 -->
<div class="profile-card-wrap relative px-4 rise-in" style="animation-delay: 40ms">
<div class="member-card relative overflow-hidden hero-clip rounded-3xl px-4 py-3 shadow-xl" :class="isVip ? 'is-vip' : 'is-free'">
<div class="pointer-events-none absolute -right-8 -top-10 w-36 h-36 rounded-full member-card-glow" />
<div class="relative flex items-center gap-3">
<div class="member-icon w-11 h-11 rounded-2xl flex items-center justify-center flex-shrink-0">
<Crown class="w-5 h-5" :stroke-width="2.4" />
<!-- 头像(原顶部头图内容整合至此处) -->
<div class="relative flex-shrink-0">
<div class="w-11 h-11 rounded-full overflow-hidden ring-2 shadow-sm flex items-center justify-center" :class="isVip ? 'ring-white/70 bg-white/30' : 'ring-brand-100 bg-brand-50'">
<img v-show="userInfo?.avatar && !avatarFailed" :src="userInfo?.avatar" class="w-full h-full object-cover" alt="头像" @error="avatarFailed = true" />
<span v-if="!userInfo?.avatar || avatarFailed" class="text-base font-bold" :class="isVip ? 'text-amber-800' : 'text-brand-700'">{{ userInfo?.realName?.[0] || userInfo?.username?.[0] || '?' }}</span>
</div>
</div>
<div class="min-w-0 flex-1">
<p class="member-title text-base font-bold tracking-tight">{{ isVip ? 'VIP 会员' : '普通用户' }}</p>
<!-- 姓名 + 会员身份 -->
<p class="member-title text-base font-bold tracking-tight flex items-center gap-1.5 min-w-0">
<span class="truncate">{{ userInfo?.realName || userInfo?.username || '--' }}</span>
<span v-if="isVip" class="vip-badge relative shrink-0 whitespace-nowrap">VIP</span>
</p>
<p class="member-sub text-xs mt-0.5 truncate">
<template v-if="isVip">{{ vipExpireShort ? `有效期至 ${vipExpireShort}` : '会员生效中' }}<template v-if="vipDaysLeft !== null"> · 剩 {{ vipDaysLeft }} 天</template></template>
<template v-else>开通后执行任务不限次数</template>
<template v-else>开通 VIP 即可不限次数</template>
</p>
<!-- 可执行次数:普通用户与 VIP 都显示(VIP 为不限次数时显示「不限」) -->
<p class="member-sub text-xs mt-1 flex items-center gap-1">
<span class="opacity-75">可执行次数</span>
<span class="font-bold tabular-nums leading-none">{{ isVip ? '不限' : fmtCount(userInfo?.signCount ?? 0) }}</span>
</p>
</div>
<!-- 统一为「兑换 CDK」:既能兑 VIP 也能兑次数,由 CDK 类型自动判断 -->
<button
data-guide="profile-vip-btn"
class="vip-btn press flex-shrink-0 text-sm font-semibold px-4 py-2 rounded-full shadow-md"
@click="openRedeemModal('vip')"
@click="openRedeemModal()"
>
{{ isVip ? '续费' : '开通 VIP' }}
兑换 CDK
</button>
</div>
</div>
@@ -444,55 +534,27 @@ async function handleRedeemInvite() {
<!-- ======== 购买 CDK(显眼入口:后台系统设置控制显隐与链接) ========
用真实 <a target=_blank> 而非 window.open:手机浏览器会把程序化 window.open
当弹窗强制当前页打开,原生链接才能稳定新 tab(微信 WebView 等无 tab 环境除外) -->
<a
<button
v-if="cdkBuyVisible"
:href="cdkBuyUrl"
target="_blank"
rel="noopener noreferrer"
class="cdk-buy-btn press relative w-full overflow-hidden rounded-2xl p-4 flex items-center gap-3 rise-in"
type="button"
class="cdk-buy-btn press relative w-full overflow-hidden rounded-2xl px-4 py-5 flex items-center gap-3 rise-in"
style="animation-delay: 80ms"
@click="onCdkBuyClick"
>
<div class="relative z-10 w-11 h-11 rounded-2xl bg-white/20 ring-1 ring-white/30 flex items-center justify-center flex-shrink-0">
<ShoppingCart class="w-5 h-5 text-white" :stroke-width="2.4" />
<div class="relative z-10 w-12 h-12 rounded-2xl bg-white/20 ring-1 ring-white/30 flex items-center justify-center flex-shrink-0">
<ShoppingCart class="w-6 h-6 text-white" :stroke-width="2.4" />
</div>
<div class="relative z-10 min-w-0 flex-1">
<p class="text-[15px] font-bold text-white leading-tight tracking-tight">购买 CDK 兑换码</p>
<p class="text-xs text-white/85 mt-1">次数卡 · VIP 卡 · 即买即兑</p>
<div class="relative z-10 min-w-0 flex-1 text-left">
<p class="text-base font-extrabold text-white leading-tight tracking-tight">购买 CDK 兑换码</p>
<p class="text-xs text-white/90 mt-1">次数卡 · VIP 卡 · 即买即兑</p>
</div>
<span class="cdk-buy-hot relative z-10 flex-shrink-0 flex items-center gap-0.5 px-2 py-1 rounded-full text-[10px] font-extrabold tracking-wide">
<Flame class="w-3 h-3" :stroke-width="2.5" />HOT
</span>
<span class="relative z-10 flex-shrink-0 w-8 h-8 rounded-full bg-white/20 flex items-center justify-center">
<ChevronRight class="w-[18px] h-[18px] text-white" :stroke-width="2.5" />
<span class="relative z-10 flex-shrink-0 w-9 h-9 rounded-full bg-white/20 flex items-center justify-center">
<ChevronRight class="w-5 h-5 text-white" :stroke-width="2.5" />
</span>
</a>
<!-- ======== 数据三宫格:可执行次数 / 邀请积分 / 已邀请 ======== -->
<div class="grid grid-cols-3 gap-2.5 rise-in" style="animation-delay: 120ms">
<button class="press stat-cell bg-white rounded-2xl border border-gray-100/80 shadow-sm py-3 text-center" @click="openRedeemModal('count')">
<p class="text-xl font-bold tabular-nums text-gray-900 leading-tight">{{ isVip ? '∞' : fmtCount(userInfo?.signCount ?? 0) }}</p>
<p class="text-xs text-gray-500 mt-0.5">{{ isVip ? '不限次数' : '可执行次数' }}</p>
<p class="mt-1.5 inline-flex items-center gap-0.5 text-xs font-semibold text-brand-600"><Gift class="w-3 h-3" />兑换</p>
</button>
<button
data-guide="profile-invite-redeem"
class="press stat-cell bg-white rounded-2xl border border-gray-100/80 shadow-sm py-3 text-center disabled:active:scale-100"
:disabled="redeemingInvite"
@click="onRedeemInviteTap"
>
<p class="text-xl font-bold tabular-nums text-gray-900 leading-tight">{{ fmtReward(inviteReward) }}</p>
<p class="text-xs text-gray-500 mt-0.5">邀请积分</p>
<p class="mt-1.5 inline-flex items-center gap-0.5 text-xs font-semibold" :class="canRedeem ? 'text-amber-600' : 'text-gray-400'">
<RefreshCw v-if="redeemingInvite" class="w-3 h-3 animate-spin" />
<template v-else><Coins class="w-3 h-3" />{{ canRedeem ? '换次数' : '满 1 可换' }}</template>
</p>
</button>
<div class="stat-cell bg-white rounded-2xl border border-gray-100/80 shadow-sm py-3 text-center">
<p class="text-xl font-bold tabular-nums text-gray-900 leading-tight">{{ invitedCount }}</p>
<p class="text-xs text-gray-500 mt-0.5">已邀请</p>
<p class="mt-1.5 inline-flex items-center gap-0.5 text-xs font-medium text-gray-400"><Users class="w-3 h-3" />位同学</p>
</div>
</div>
</button>
<!-- ======== 邀请好友卡 ======== -->
<div data-guide="profile-invite-row" class="invite-card relative overflow-hidden hero-clip rounded-3xl p-4 rise-in" style="animation-delay: 160ms">
@@ -505,6 +567,22 @@ async function handleRedeemInvite() {
<span class="font-semibold text-purple-600">{{ inviterRewardPerInvite !== null ? fmtReward(inviterRewardPerInvite) : '一定' }}</span>
积分<template v-if="inviteeRewardPerInvite !== null">,TA 得 <span class="font-semibold text-purple-600">{{ fmtReward(inviteeRewardPerInvite) }}</span> 积分</template>
</p>
<!-- 邀请积分(原三宫格内容并入推广板块):积分 + 免费换次数 -->
<div class="flex items-center gap-2 mt-2">
<span class="text-xs text-gray-400">邀请积分</span>
<span class="text-base font-bold tabular-nums text-purple-600 leading-none">{{ fmtReward(inviteReward) }}</span>
<button
data-guide="profile-invite-redeem"
class="press flex items-center gap-1 h-7 px-2.5 rounded-lg text-xs font-semibold disabled:opacity-60"
:class="canRedeem ? 'invite-btn text-white shadow-sm shadow-purple-500/30' : 'bg-gray-100 text-gray-400'"
:disabled="redeemingInvite || !canRedeem"
@click="onRedeemInviteTap"
>
<RefreshCw v-if="redeemingInvite" class="w-3 h-3 animate-spin" />
<Coins v-else class="w-3 h-3" />
{{ canRedeem ? '免费换次数' : '满 1 可换' }}
</button>
</div>
</div>
</div>
<!-- 邀请码 + 复制 -->
@@ -512,6 +590,16 @@ async function handleRedeemInvite() {
<div v-if="inviteCode" class="flex items-center gap-2 rounded-2xl bg-white/80 dark:bg-gray-100/80 ring-1 ring-purple-200 pl-4 pr-1.5 py-1.5">
<span class="text-xs text-gray-400 flex-shrink-0">我的邀请码</span>
<code class="flex-1 min-w-0 font-mono text-lg font-bold tracking-[0.18em] text-purple-600 truncate">{{ inviteCode }}</code>
<button
data-guide="profile-invite-reset"
class="press flex-shrink-0 flex items-center justify-center w-9 h-9 rounded-xl bg-white text-purple-500 ring-1 ring-purple-200 hover:bg-purple-50"
title="重置邀请码"
aria-label="重置邀请码"
:disabled="resettingInvite"
@click="onResetInviteTap"
>
<RefreshCw class="w-4 h-4" :class="resettingInvite && 'animate-spin'" />
</button>
<button
data-guide="profile-invite-copy"
class="press flex-shrink-0 flex items-center gap-1 h-9 px-3.5 rounded-xl invite-btn text-white text-sm font-semibold shadow-sm shadow-purple-500/30"
@@ -532,7 +620,7 @@ async function handleRedeemInvite() {
class="press relative mt-2.5 w-full flex items-center justify-center gap-1 h-9 rounded-xl border border-dashed border-purple-300 text-xs font-medium text-purple-600 hover:bg-white/50"
@click="openFillInviteModal"
>
有同学的邀请码?点此填写
输入同学邀请码,立即获得{{ inviteeRewardPerInvite !== null ? fmtReward(inviteeRewardPerInvite) : '' }}积分
</button>
</div>
@@ -633,13 +721,13 @@ async function handleRedeemInvite() {
wrapper-class="px-4"
panel-class="max-w-sm"
:fade="true"
:title="redeemKind === 'vip' ? '续费 VIP' : '兑换签到次数'"
:title="'兑换 CDK'"
@update:open="(v: boolean) => { if (!v) closeRedeemModal() }"
>
<!-- 弹窗头部 -->
<div class="flex items-center justify-between px-5 pt-5 pb-2">
<h3 class="text-base font-semibold text-gray-900">
{{ redeemKind === 'vip' ? '续费 VIP' : '兑换签到次数' }}
兑换 CDK
</h3>
<button class="w-7 h-7 rounded-full bg-gray-100 flex items-center justify-center text-gray-400 hover:text-gray-600" @click="closeRedeemModal">
<X class="w-4 h-4" />
@@ -650,18 +738,18 @@ async function handleRedeemInvite() {
<div class="px-5 pb-5 space-y-4">
<!-- 邀请积分可免费兑换提示:引导用户关闭本弹窗,改用下方邀请信息的兑换按钮 -->
<div
v-if="redeemKind === 'count' && canRedeem"
v-if="canRedeem"
class="rounded-lg bg-amber-50 border border-amber-200 px-3 py-2.5"
>
<p class="text-xs text-amber-700 leading-relaxed">
你的邀请奖励积分已满 1(当前 <span class="font-semibold">{{ fmtReward(inviteReward) }}</span>),
可免费兑换签到次数。请关闭本弹窗,点击下方「邀请信息」处的「兑换」按钮即可用积分兑换,无需兑换码。
可免费兑换签到次数。请关闭本弹窗,点击上方推广板块中的「免费换次数」按钮即可用积分兑换,无需兑换码。
</p>
</div>
<!-- 兑换码输入 -->
<div>
<label class="block text-xs font-medium text-gray-500 mb-1.5">{{ redeemKind === 'vip' ? 'VIP 兑换码' : '兑换码' }}</label>
<label class="block text-xs font-medium text-gray-500 mb-1.5">CDK 兑换码</label>
<Input
v-model="redeemCodeText"
placeholder="请输入 CDK 兑换码"
@@ -703,16 +791,26 @@ async function handleRedeemInvite() {
<p v-if="captchaSubmitError" class="text-xs text-red-500 mt-1">{{ captchaSubmitError }}</p>
</div>
<!-- 兑换按钮 -->
<Button
class="w-full h-10 text-sm font-medium"
:disabled="redeeming"
@click="handleRedeem"
>
<RefreshCw v-if="redeeming" class="h-4 w-4 animate-spin" />
<Gift v-else class="h-4 w-4" />
{{ redeeming ? '兑换中...' : '立即兑换' }}
</Button>
<!-- 购买 / 兑换 两个动作并排(购买在左,兑换在右) -->
<div class="flex gap-2">
<!-- 没有 CDK 时直接去购买:等价于点击外层的「购买 CDK 兑换码」入口 -->
<button
type="button"
class="press cdk-goto-btn flex-1 h-10 rounded-xl text-xs font-medium flex items-center justify-center px-2"
@click="onBuyFromRedeemModal"
>
没有 CDK?立即购买
</button>
<Button
class="flex-1 h-10 text-sm font-medium"
:disabled="redeeming"
@click="handleRedeem"
>
<RefreshCw v-if="redeeming" class="h-4 w-4 animate-spin" />
<Gift v-else class="h-4 w-4" />
{{ redeeming ? '兑换中...' : '立即兑换' }}
</Button>
</div>
</div>
</AppDialog>
@@ -750,6 +848,67 @@ async function handleRedeemInvite() {
</div>
</AppDialog>
<!-- ======== 购买 CDK 前说明弹窗 ========
用户点「购买 CDK 兑换码」后先看到购买/兑换全流程说明,
确认后才跳转外部购买平台,避免用户不清楚要准备邮箱与查询密码 -->
<AppDialog
:open="showCdkBuyConfirm"
variant="center"
panel-class="w-[88%] sm:max-w-md"
title="购买 CDK 兑换码"
@update:open="(v: boolean) => { if (!v) showCdkBuyConfirm = false }"
>
<div class="p-5">
<div class="flex items-center gap-3 mb-4">
<div class="w-11 h-11 rounded-2xl cdk-buy-icon flex items-center justify-center shrink-0">
<ShoppingCart class="w-5 h-5 text-white" :stroke-width="2.4" />
</div>
<div>
<h3 class="text-base font-semibold text-gray-900">即将跳转 CDK 购买平台</h3>
<p class="text-xs text-gray-400 mt-0.5">购买完成后请回到本站兑换</p>
</div>
</div>
<div class="rounded-xl bg-gray-50 p-4 space-y-3">
<div class="flex gap-2.5">
<span class="cdk-step-no">1</span>
<div class="min-w-0">
<p class="text-sm font-medium text-gray-800">在购买平台购买卡密</p>
<p class="text-xs text-gray-500 mt-0.5">购买平台需要填写<strong class="text-gray-700">邮箱</strong>和<strong class="text-gray-700">查询密码</strong>,请牢记这两项</p>
</div>
</div>
<div class="flex gap-2.5">
<span class="cdk-step-no">2</span>
<div class="min-w-0">
<p class="text-sm font-medium text-gray-800">支付成功后查询卡密</p>
<p class="text-xs text-gray-500 mt-0.5">在购买平台输入<strong class="text-gray-700">邮箱</strong>和<strong class="text-gray-700">查询密码</strong>查询你拿到的 CDK</p>
</div>
</div>
<div class="flex gap-2.5">
<span class="cdk-step-no">3</span>
<div class="min-w-0">
<p class="text-sm font-medium text-gray-800">回到本站兑换</p>
<p class="text-xs text-gray-500 mt-0.5">在下方「兑换次数 / VIP」处粘贴 CDK 完成兑换</p>
</div>
</div>
</div>
<div class="mt-4 flex items-start gap-1.5 text-xs text-amber-600 bg-amber-50 rounded-lg p-2.5">
<TriangleAlert class="w-3.5 h-3.5 mt-0.5 shrink-0" />
<span>购买平台为独立站点,兑换码请勿泄露给他人</span>
</div>
<div class="flex gap-2 mt-5">
<Button variant="outline" class="flex-1 h-10 text-sm" @click="showCdkBuyConfirm = false">
取消
</Button>
<Button class="flex-1 h-10 text-sm font-medium" @click="confirmCdkBuy">
前往购买平台
</Button>
</div>
</div>
</AppDialog>
<!-- 随机绑定设备码:连续两次风险确认弹窗 -->
<AppDialog
:open="randBindStep > 0"
@@ -860,7 +1019,25 @@ async function handleRedeemInvite() {
}
}
/* 头图细网格纹理(极淡,增加质感) */
/* 头图:绿底高度与卡片上浮量联动,改一处即可。
--hero-pb:绿底下内边距(决定绿色边界落在哪)
--card-lift:卡片负上边距(= --hero-pb - 44,抵消整体下移,卡片位置不动) */
.profile-page {
--hero-pb: 182px;
--card-lift: 138px;
}
.profile-hero {
padding-top: calc(var(--safe-top) + 14px);
padding-bottom: var(--hero-pb);
}
.profile-card-wrap {
margin-top: calc(var(--card-lift) * -1);
}
/* 头图细网格纹理(极淡,增加质感)。
mask 用百分比随绿底高度等比缩放,改 --hero-pb 不需要动这里。 */
.profile-hero-grid {
background-image:
linear-gradient(rgb(255 255 255 / 0.06) 1px, transparent 1px),
@@ -870,7 +1047,7 @@ async function handleRedeemInvite() {
mask-image: linear-gradient(180deg, #000 0%, transparent 85%);
}
/* 会员卡:VIP 为黑金,普通用户为珍珠白(固定色,亮暗下质感一致) */
/* 会员卡:VIP 为黑金专属色,普通用户为珍珠白(固定色,亮暗下质感一致) */
.member-card.is-vip {
background: linear-gradient(135deg, #2b2418 0%, #1b1710 60%, #2d2516 100%);
border: 1px solid rgb(246 196 69 / 0.35);
@@ -892,19 +1069,39 @@ async function handleRedeemInvite() {
color: #8a5a00;
background: linear-gradient(135deg, #fff1c7, #ffe08a);
}
.member-card.is-free .member-card-glow { background: radial-gradient(circle, rgb(245 189 46 / 0.22), transparent 70%); }
.member-card.is-free .member-card-glow { background: radial-gradient(circle, rgb(246 196 69 / 0.22), transparent 70%); }
/* 邀请卡主按钮:固定紫(白字 6:1)。不用 bg-purple-600:暗色色板把它映射为浅紫作文字色,白字会看不清 */
.invite-btn {
background: linear-gradient(135deg, #7a5bdb, #6a4bcb);
}
/* 兑换弹窗内的「没有 CDK?立即购买」按钮:
与外层购买入口同为珊瑚渐变,形成视觉呼应。
注意必须显式声明 color/background —— 之前只在 NoRunsDialog 的 scoped
样式里定义过同名类,这里缺样式会导致白字落在白底上「看不见」。 */
.cdk-goto-btn {
color: #fff;
background: linear-gradient(135deg, #ff9a3d 0%, #ff5d5d 55%, #f43f5e 100%);
box-shadow: 0 6px 16px -8px rgb(244 63 94 / 0.6);
white-space: nowrap;
}
/* 购买 CDK 入口:日落珊瑚渐变 + 周期性微光扫过(固定色,亮暗主题一致)
与品牌薄荷青 / VIP 金 / 邀请紫均不同色系,保证在页面中的辨识度 */
.cdk-buy-btn {
color: #fff;
background: linear-gradient(135deg, #ff9a3d 0%, #ff5d5d 55%, #f43f5e 100%);
box-shadow: 0 8px 20px -6px rgb(244 63 94 / 0.45);
box-shadow: 0 10px 26px -6px rgb(244 63 94 / 0.55), 0 0 0 1px rgb(255 255 255 / 0.18) inset;
animation: cdk-pulse 2.4s ease-in-out infinite;
}
/* 呼吸光环:让入口在列表中更醒目 */
@keyframes cdk-pulse {
0%, 100% { box-shadow: 0 10px 26px -6px rgb(244 63 94 / 0.55), 0 0 0 0 rgb(244 63 94 / 0.45); }
50% { box-shadow: 0 10px 26px -6px rgb(244 63 94 / 0.55), 0 0 0 6px rgb(244 63 94 / 0); }
}
@media (prefers-reduced-motion: reduce) {
.cdk-buy-btn { animation: none; }
}
.cdk-buy-btn::after {
content: '';
@@ -924,6 +1121,28 @@ async function handleRedeemInvite() {
pointer-events: none;
}
/* 弹窗内的购买图标底色(与入口按钮同色系) */
.cdk-buy-icon {
background: linear-gradient(135deg, #ff9a3d 0%, #ff5d5d 55%, #f43f5e 100%);
box-shadow: 0 6px 16px -6px rgb(244 63 94 / 0.5);
}
/* 弹窗内的步骤序号 */
.cdk-step-no {
flex-shrink: 0;
width: 20px;
height: 20px;
border-radius: 9999px;
display: inline-flex;
align-items: center;
justify-content: center;
font-size: 11px;
font-weight: 700;
color: #fff;
background: linear-gradient(135deg, #ff9a3d 0%, #ff5d5d 100%);
margin-top: 1px;
}
/* HOT 角标:白底珊瑚红字(固定色,在渐变底上高对比) */
.cdk-buy-hot {
color: #e11d48;
@@ -945,12 +1164,14 @@ async function handleRedeemInvite() {
}
/* VIP 开通/续费按钮:金色渐变 + 周期性微光扫过 */
/* 兑换 CDK 按钮:卡片改为黑金底后,按钮仍用金色实底以保持「可点击」的醒目感;
深棕文字与金底对比约 7:1(不用 text-amber-900:暗色下它会被映射为浅色) */
.vip-btn {
position: relative;
overflow: hidden;
/* 金底固定不随主题变化,文字同样固定深棕(不用 text-amber-900:暗色下它被映射为浅色) */
color: #5b3a00;
background: linear-gradient(135deg, #ffe9a8 0%, #f6c445 45%, #e8a020 100%);
box-shadow: 0 2px 8px rgb(0 0 0 / 0.25);
}
.vip-btn::after {
content: '';
@@ -973,6 +1194,7 @@ async function handleRedeemInvite() {
<style>
/* 暗色覆盖:非 scoped(scoped 中 :global(.dark) .x 会编译成仅 .dark 而失效);html.dark 提高优先级压过 .x[data-v] */
/* 会员卡暗色:VIP 微调金色边框透明度;普通用户在暗色下用墨绿底(原亮色白底在暗色刺眼) */
html.dark .member-card.is-free {
background: linear-gradient(135deg, #1e2c28 0%, #18221f 100%);
border-color: rgb(255 255 255 / 0.06);
+31 -1
View File
@@ -1,7 +1,7 @@
<script setup lang="ts">
import { ref, onMounted, nextTick } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { ArrowLeft, Send } from 'lucide-vue-next'
import { ArrowLeft, Send, RotateCcw } from 'lucide-vue-next'
import { Button } from '@/components/ui/button'
import { useMessage } from '@/composables/useMessage'
import * as ticketApi from '@/api/ticket'
@@ -61,6 +61,23 @@ async function handleReply() {
replying.value = false
}
}
// 打回:仅「已完成」可操作。用户认为问题没解决时,把工单退回处理中等待管理员跟进。
const reopening = ref(false)
async function handleReopen() {
if (!ticket.value) return
reopening.value = true
try {
await ticketApi.reopenTicket(ticket.value.id)
message.success('工单已打回为处理中')
await loadTicket(ticket.value.id)
} catch (err: any) {
message.error(err.response?.data?.message || '打回失败')
} finally {
reopening.value = false
}
}
</script>
<template>
@@ -96,6 +113,19 @@ async function handleReply() {
</span>
</div>
<!-- 已完成:允许打回处理中(问题没解决时) -->
<div v-if="ticket.status === 'resolved'" class="text-center">
<button
type="button"
class="press inline-flex items-center gap-1.5 px-3 py-1.5 rounded-full text-xs font-medium text-amber-600 bg-amber-50 border border-amber-200"
:disabled="reopening"
@click="handleReopen"
>
<RotateCcw class="w-3.5 h-3.5" />
{{ reopening ? '打回中...' : '问题未解决,打回处理中' }}
</button>
</div>
<!-- 初始工单内容(用户发起的,靠右) -->
<div class="flex justify-end">
<div class="max-w-[85%]">
+77
View File
@@ -0,0 +1,77 @@
#!/bin/bash
# ============================================================
# cpdaily-xiaozhushou 蓝绿部署脚本(在 162 上执行)
# 用法: ./deploy-bluegreen.sh <image-tag> 例如 ./deploy-bluegreen.sh v1.2.194
# 流程: 空闲槽部署 -> 健康检查 -> 切 Nginx -> 公网验证 -> 失败自动回滚
# ============================================================
set -euo pipefail
IMAGE_TAG="${1:?用法: $0 <image-tag>,例如 $0 v1.2.194}"
IMAGE="cpdaily-xiaozhushou:${IMAGE_TAG}"
CONF=/opt/1panel/apps/openresty/openresty/www/sites/jinrixiaoyuan.top/proxy/root.conf
OPENRESTY=1Panel-openresty-VZIf
NETWORK=1panel-network
CONFIG_VOL=/opt/cpdaily-xiaozhushou/config.toml
DOMAIN=https://jinrixiaoyuan.top/
docker image inspect "$IMAGE" >/dev/null 2>&1 || { echo "镜像不存在: $IMAGE"; exit 1; }
# 读取当前反代端口,目标槽取另一个
CUR=$(grep -oP 'proxy_pass http://127\.0\.0\.1:\K[0-9]+' "$CONF" | head -1)
[ -n "$CUR" ] || { echo "无法从 $CONF 解析当前反代端口"; exit 1; }
if [ "$CUR" = "8884" ]; then TARGET=8885; else TARGET=8884; fi
CUR_NAME="cpdaily-xiaozhushou-${CUR}"
TARGET_NAME="cpdaily-xiaozhushou-${TARGET}"
echo "[1/6] 当前 active=${CUR}($(docker inspect -f '{{.Config.Image}}' "$CUR_NAME" 2>/dev/null || echo 未知)) -> 目标=${TARGET} 镜像=${IMAGE}"
echo "[2/6] 备份目标槽容器定义"
docker inspect "$TARGET_NAME" > "/opt/cpdaily-xiaozhushou/${TARGET_NAME}-backup-$(date +%Y%m%d-%H%M%S).json" 2>/dev/null || true
echo "[3/6] 在 ${TARGET} 部署新版本"
docker rm -f "$TARGET_NAME" >/dev/null 2>&1 || true
docker run -d --name "$TARGET_NAME" \
--restart unless-stopped \
--network "$NETWORK" \
-p "${TARGET}:8093" \
-v "$CONFIG_VOL:/app/config.toml:ro" \
-e TZ=Asia/Shanghai \
"$IMAGE" >/dev/null
echo "[4/6] 健康检查 http://127.0.0.1:${TARGET}/health"
HEALTHY=0
for i in $(seq 1 30); do
if curl -sf -m 3 "http://127.0.0.1:${TARGET}/health" >/dev/null 2>&1; then HEALTHY=1; break; fi
sleep 1
done
if [ "$HEALTHY" != "1" ]; then
echo "健康检查失败,保持 ${CUR} 不变,清理新容器"
docker rm -f "$TARGET_NAME" >/dev/null 2>&1 || true
exit 1
fi
echo " 健康检查通过"
echo "[5/6] 切换 Nginx: ${CUR} -> ${TARGET}"
cp -a "$CONF" "$CONF.bak-$(date +%Y%m%d-%H%M%S)"
sed -i "s|proxy_pass http://127.0.0.1:${CUR};|proxy_pass http://127.0.0.1:${TARGET};|" "$CONF"
if ! docker exec "$OPENRESTY" openresty -t >/dev/null 2>&1; then
echo "Nginx 配置校验失败,回滚配置"
sed -i "s|proxy_pass http://127.0.0.1:${TARGET};|proxy_pass http://127.0.0.1:${CUR};|" "$CONF"
docker rm -f "$TARGET_NAME" >/dev/null 2>&1 || true
exit 1
fi
docker exec "$OPENRESTY" openresty -s reload >/dev/null 2>&1
echo "[6/6] 公网验证 ${DOMAIN}"
sleep 2
CODE=$(curl -s -o /dev/null -w "%{http_code}" -m 15 "$DOMAIN" || echo 000)
if [ "$CODE" = "200" ]; then
echo "部署成功:线上 ${TARGET} = ${IMAGE};${CUR} 保留为回滚位"
echo "回滚:sed -i 's|:${TARGET};|:${CUR};|' ${CONF} && docker exec ${OPENRESTY} openresty -s reload"
else
echo "公网验证失败 ($CODE),回滚到 ${CUR}"
sed -i "s|proxy_pass http://127.0.0.1:${TARGET};|proxy_pass http://127.0.0.1:${CUR};|" "$CONF"
docker exec "$OPENRESTY" openresty -s reload >/dev/null 2>&1 || true
docker rm -f "$TARGET_NAME" >/dev/null 2>&1 || true
exit 1
fi