Compare commits

...

40 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
46 changed files with 2814 additions and 185 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"
+3
View File
@@ -96,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)
// 签到任务相关
@@ -210,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)
+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 {
+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)
}
})
}
}
+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"
@@ -194,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": "正在连接今日校园..."})
+12 -2
View File
@@ -11,6 +11,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"
@@ -341,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)
@@ -479,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)
@@ -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)
}
}
+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,
})
}
+13
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",
@@ -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})
+85 -5
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 {
@@ -161,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,
})
}
@@ -183,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)
@@ -207,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)
@@ -250,10 +305,14 @@ 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() {
@@ -436,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,
})
}
@@ -462,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)
@@ -490,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,
})
}
@@ -533,10 +603,14 @@ 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)
@@ -579,7 +653,13 @@ 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{
+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"`
+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("被限流时应返回提示文案")
}
}
+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
@@ -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 子对象")
@@ -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)).
@@ -909,6 +909,19 @@ func xsgzFetchRSA(client *resty.Client, base string) (*xsgzRSA, error) {
if r.DK == "" || r.M == "" || r.E == "" {
return nil, fmt.Errorf("解析公钥参数失败")
}
// 形态校验:/api/sec_js 每次返回都带新盐,若响应被截断或格式变化,
// 正则会匹配到残缺值,加密出的密文服务端必然解不开(表现为
// 「参数解码错误,刷新页面后尝试」)。这里提前拦住,报错更贴近真因,
// 也避免把无效密文发出去。
if len(r.DK) < 16 {
return nil, fmt.Errorf("DK 盐长度异常(len=%d),sec_js 响应可能不完整", len(r.DK))
}
if len(r.M) < 64 {
return nil, fmt.Errorf("模数长度异常(hex len=%d),sec_js 响应可能不完整", len(r.M))
}
if r.E != "010001" && r.E != "10001" {
return nil, fmt.Errorf("指数形态异常(%q),sec_js 响应可能不完整", r.E)
}
return r, nil
}
@@ -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())
}
}
@@ -242,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")
@@ -282,6 +290,40 @@ 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
@@ -437,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())
+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)
}
})
}
}
@@ -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, // 河北石油职业技术大学
@@ -251,6 +268,12 @@ func CpdailyLogin(ctx context.Context, school *SchoolDetailInfo, username, passw
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)
+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 重定向,且跨跳时保留原请求头
+9 -9
View File
@@ -149,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).
@@ -474,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).
@@ -650,7 +650,7 @@ func CheckFaceIdentifyResult(cookieMap map[string]string, host string, taskType
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).
@@ -1559,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))
@@ -1631,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
@@ -1642,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).
@@ -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
}
+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)
}
+5
View File
@@ -400,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 })
+15
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,6 +47,10 @@ export interface TicketDetail {
updatedAt: string
/** 用户是否已删除该工单(软删除);管理端仍可查看 */
deleted?: boolean
/** 管理员最近一次操作后用户是否已读 */
userRead?: boolean
/** 用户最近一次回复后管理员是否已读 */
adminRead?: boolean
messages: TicketMessageRes[]
}
@@ -84,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}`)
+8 -2
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() {
+17 -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,10 @@ 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 列新增默认值
@@ -189,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()
@@ -240,9 +250,11 @@ async function checkTicketReplyPopups() {
ticketNotifSeenId = stored
}
// 新出现(id > 基线)且未读的回复通知 → 入队弹窗(仅 kind=reply;状态变更不强制弹窗)
// 新出现(id > 基线)且未读的通知 → 入队弹窗。
// 回复与状态变更都要弹(管理员改状态同样是用户需要知道的事);
// 用户在详情页里的回复会推进基线,不会重复弹。
const fresh = list
.filter((n) => n.id > ticketNotifSeenId && !n.isRead && n.kind === 'reply')
.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))
@@ -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'"
@@ -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'"
+1
View File
@@ -5,6 +5,7 @@ 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'
+153 -52
View File
@@ -1,8 +1,9 @@
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { LogOut, RefreshCw, Gift, X, Dices, TriangleAlert, SunMoon, Sun, Moon, Crown, Copy, ChevronRight, Smartphone, Coins, UserPlus, GraduationCap, Headset, ShoppingCart, Flame } from 'lucide-vue-next'
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'
@@ -38,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('')
@@ -92,6 +94,18 @@ 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() {
@@ -149,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
@@ -368,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 = ''
@@ -419,54 +470,52 @@ 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">{{ isVip ? '不限' : fmtCount(userInfo?.signCount ?? 0) }}</span>
<span class="font-bold tabular-nums leading-none">{{ isVip ? '不限' : fmtCount(userInfo?.signCount ?? 0) }}</span>
</p>
</div>
<!-- 统一为「兑换 CDK」:既能兑 VIP 也能兑次数,由 CDK 类型自动判断 -->
@@ -541,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"
@@ -732,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>
@@ -950,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),
@@ -960,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);
@@ -982,13 +1069,24 @@ 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 {
@@ -1066,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: '';
@@ -1094,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