Compare commits

...

26 Commits

Author SHA1 Message Date
lovife 50ad577b29 fix(cors): 修复生产登录 403「Invalid CORS request」
- 根因:浏览器对**同源**的 POST/PUT/DELETE 也会带 Origin,而 Spring MVC 只看
  "请求里有没有 Origin"(Spring 7.0.9 的 DefaultCorsProcessor 没有同源短路),
  带了又不在白名单里 → 进 Controller 之前直接 403
- WebConfig 的白名单写死为 localhost/内网;注释声称"生产配置见 application-prod.yml
  的 fill.cors.allowed-origins",但该属性根本不存在、也没人读它 ——
  于是"前后端同源部署"的登录/上传/提交必然登不上(生产 2026-10-20 事故)
- 改为配置驱动:FillProperties.Cors + fill.cors.allowed-origins
  (可用 XT_CORS_ALLOWED_ORIGINS 覆盖);prod 默认
  shenxue.jxit.com / admin.shenxue.jxit.com / *.shenxue.jxit.com;
  dev 补上花生壳穿透域名(H5 走穿透时 POST 同样会被拦)
- 启动日志打印生效白名单,排障先看那一行
- 新增 CorsWhitelistTest(3 条):生产 yml 真能绑定、通配符匹配租户子域、陌生源仍被拒
- docs/09 §2.4:本站地址必须在白名单 + 反代必须传
  Host/X-Real-IP/X-Forwarded-For/X-Forwarded-Proto(缺了登录日志 IP 全是 127.0.0.1);
  交接摘要 §一·四十四 记录事故,并写清"1700+ 条 e2e 全绿为什么还是漏了"

为什么 e2e 没抓到:脚本是 Python requests,不带 Origin 头,永远走不到 CORS 分支;
本地浏览器用的 localhost 正好命中写死的 dev 白名单。唯一能暴露它的是"浏览器 + 正式域名"。

验证:生产白名单下 admin / 主域名 / t1 / t12 → 200,evil / 后缀拼接 / http → 403;
真实 RSA 登录 + Origin: https://admin.shenxue.jxit.com → 200、code=0、拿到 token;
mvn test 15/15;verify-all --quick 6/6;check-test-profile 通过。
接口、库表、契约均无变化。
2026-10-20 09:22:16 +08:00
lovife 739c0a34a4 fix:正式环境API地址 2026-10-20 08:46:56 +08:00
lovife 1e71c164af fix(mp): 接通微信小程序登录并补齐生产配置
- 前端落地页调用 wx.login → /mp/login,保存 token,并在每次带 scene 进入时刷新登录(F-130 前端断点)
- 后端 /mp/login 的名册/任务查询放入 TenantScope,修复带 taskId 的 scene 必然 500(F-130 后端断点)
- 小程序生产 API 改为 VITE_MP_API_BASE(.env.production),不再写死 localhost
- 生产配置补全:七牛目录 shenxue、连接池/健康检查/优雅停机等;学生端只发小程序,移除 H5 段
- PC 生产 API 改到 admin.shenxue.jxit.com;新增 deploy/prod.env.example 并忽略 prod.env
- ⚠️ application-prod.yml 含生产凭据(按部署要求直接写入),仓库访问范围需收紧
2026-10-20 07:25:57 +08:00
lovife 17c92b29c3 fix:正式环境API地址 2026-10-19 21:39:24 +08:00
lovife 2ff17cd0c9 refactor(h5)!: H5 改由 nginx 托管,后端退回纯 API
用户决定:后端不再对外发静态文件,学生端 H5 交给网关。两者**仍然同源**
(微信只给一个授权回调域名;H5 的接口地址取自 location.origin),
所以前端业务代码、微信后台、application-dev.yml 的 h5-base-url 都不用改。

后端
- 删掉 dev/test/prod 三份 profile 里的 spring.web.resources.static-locations
- 主配置加 spring.web.resources.add-mappings=false —— 用开关而不是"指到空目录":
  后者会连带启用 Spring Boot 的默认位置(classpath:/static 等),谁丢个文件进去就悄悄对外可见
- StaticCacheHeaderFilter → ApiNoStoreFilter,只剩 /api/** 的 no-store
  (静态资源的缓存策略整体归 nginx,避免"改一处漏一处")
- 补 NoHandlerFoundException → 404:关掉静态映射后未匹配路径不再经过
  ResourceHttpRequestHandler,抛的是 NoHandlerFoundException,只处理
  NoResourceFoundException 时访问 / 会变成 500「系统繁忙」并刷 ERROR 日志

deploy
- 新增 xt-nginx(80):发 frontend-mp/dist/build/h5 + /api 反代 8080(host.docker.internal,
  Linux 靠 extra_hosts host-gateway),端口与产物目录走 .env 的 H5_PORT/H5_DIR
- 三条"删了就会出事"的都已带上理由:client_max_body_size 50m(默认 1m,超了回 413
  而后端日志一个字都没有)、/assets 的 immutable 与 *.html 的 no-cache(§一·十 那个
  "发版后学生看到旧页面"的坑)、健康检查写 127.0.0.1(容器里 localhost 先解析到 IPv6,
  nginx 只听 IPv4 → 永远 starting 而首页是好的)

CI 与文档
- CI:学生端 H5 构建从"e2e 的输入"改为编译检查,不再留 artifact(e2e 不消费它)
- 同步 AGENTS.md / README.md / docs/09(启动步骤、CI 表、发布注意、排障表)/ docs/11
- 交接摘要新增 §一·四十三(含理由、验证数字、要人工做的一步与环境限制)

验证
- mvn clean package 通过(必须 clean:删掉的旧类会留在 target/classes 被组件扫描)
- 后端直连:/ 与 /assets/* → 404({"code":40001,"msg":"接口不存在"})、
  /api/.../captcha → 200 + no-store、/doc.html → 302→200、/v3/api-docs 与 /actuator/health → 200
- 经 nginx(80):首页 200 + no-cache、/assets/*.js 200 + immutable、/nope.js 404(无回退)、
  /api 200 + no-store、gzip 生效;2MB multipart 上传 → 401(与直连一致 ⇒ 未踩 1m 的坑)、
  55MB → 413
- e2e(XT_BASE=http://localhost,全程走 nginx):e2e-m0.py 37/37、e2e-m3-h5-auth.py 24/24、
  e2e-m2-scope.py 38/38
- verify-all.py --quick 6/6;check-test-profile.py:test profile 仍能起来
2026-10-19 17:35:33 +08:00
lovife a9b2dd2a16 docs: 登记 F-125~F-130 并同步脚本数 37→38
三个 P0 本轮只测不改、先记账:F-125 学生被驳回后改不了任何东西(改字段/传材料/
删材料全 40030、撤回 40001,而重新提交是允许的,四处信号都说他能改)、
F-128 扩展字段存得进读不出、必填扩展字段会让整个任务永久无法提交(内置表单一个
扩展字段都没有,所以从来没有数据走过这条路)、F-130 小程序端登录断成两截
(前端从没调过 /mp/login,后端 scene 带 taskId 必然 500)。

README 补 docs/11 的入口,AGENTS.md 补小程序端现状与 38 个脚本的新口径,
问题清单补 F-126/F-127/F-129 三条与 F-125/F-128/F-130 的修法方向。
2026-10-14 15:40:18 +08:00
lovife 4e68d0e5da test(e2e): 新增填报流程端到端脚本与测试计划
既有 37 个脚本是按模块切片的(门禁/草稿/提交/审核各一个),有三处固定盲区:
跨步骤的状态轨迹没人断言、切片之间那条缝(驳回后改了字段再交)、以及脚本
docstring 自述与实际断言不符(e2e-m3-draft.py:11 自述的那条断言全文没有)。

docs/11 给出 A~J 十组覆盖矩阵与判据口径(含用 grep 核对实际断言、不读自述的
规程),scripts/e2e-m3-flow.py 只补这三处、其余交给既有脚本,并钉住 F-125 /
F-128 的现状(XFAIL)。verify-all 的脚本数口径随之变成 38。

配套 scripts/dev-reveal-roster-idcard.py:L3-B 走查要在门禁界面手输身份证,
而明文只在建夹具那一刻存在过(库里是 AES 密文 + 脱敏 + HMAC 索引),
此脚本只读解密打印,不入库;用完连同夹具任务一起 --clean。
2026-10-14 15:40:18 +08:00
lovife 623e6393d5 fix(pc): 弹窗遮罩改毛玻璃并清掉 9 处提示折行
遮罩底色被 Naive 写死在 css-render 里(Modal / Drawer 主题没有 mask token,
themeOverrides 改不到),只能上 CSS;加 backdrop-filter 后遮罩后面的页面糊掉、
弹层本体仍是纯白,NModal / NDrawer / useDialog 共用 .n-modal-mask 一条规则覆盖。

折行的根因是格子宽度写死(半宽 264px / span 8 192px / 定宽 160px),与窗口宽度无关:
能压短的压短;「文件名预览」那处是 .n-form-item-blank 属于 flex 行、把提示挤成 3 行,
改成包一层块级容器;材料预览失败的占位在 108px 小格里曾折 3 行且被裁掉。

约定写进 frontend-pc/AGENTS.md 第 11 条与 code-css skill;实测与刻意不改的清单见
docs/交接摘要.md 的 §一·四十二。
2026-10-14 15:34:40 +08:00
lovife e94e76ba0f fix(pc): 找回被 #feedback 吞掉的表单校验错误 + 收集任务文案精简
naive 的 NFormItem 一旦用了 #feedback 插槽,renderExplains 就永不渲染
(children || feedback ? feedback : …),校验错误被完全遮住 —— 用户看到的是
"红框 + 一句无关的正常提示",现象上等价于"点了保存没反应"。RoleFormModal 里
早就记录过这个机制,但只在自己那个文件绕开了,没固化成约定,于是扩散到 7 处。

- 7 处 validate() 的 catch 改成 message.error(firstError(errors) ?? 兜底)
  (login / org / role / user / task 建改任务 / 材料规则 / 表单设计)
- ⚠️ firstError 必须摊平后再取:naive reject 出来的是嵌套数组,
  errors[0].message 恒为 undefined,兜底文案会把每条错误都吞成"请检查表单填写"
- 批次号在"修改"时置灰(task_code = 批次号 + 4 位序号,且同时是文件目录名,
  而服务端 update 无条件写 batch_no、没有"编码已生成"的保护)
- 删掉「本期范围」区块(收集模式 / 认定类型恒定,请求体里仍写死 1)
- 清掉教材式描述:extras_json / extra_ 前缀 / 幂等 / 接收能力 / 识别结果只用于提示
- 规则沉淀进 frontend-pc/AGENTS.md 第 10 条与交接摘要 §一·四十
2026-10-13 15:06:34 +08:00
lovife 31e7483464 docs: 同步契约 §6.5~6.9 / §13.4、问题清单 F-112~F-124、交接摘要 §一·三十九
1) docs/03-接口设计.md:
   - §6.5 补 expireBefore **含当天**的语义与"格式不对返回 40001,不静默忽略筛选";补
     "列表 contactPhone 恒为 null"与"packageName 与内嵌 usage{} 不实现(200 行内嵌用量会变成
     N+1 统计)"。
   - §6.6 的 tenantType 由 3 值改回真实的 **4 值**(1 学校 / 2 认定机构 / 3 报名点 / 4 企业)、
     packageId 由 ✅ 必填改为可选(DTO 里本来就是可选),并补副作用(发放 6 个内置角色 + 建管理员
     + 写 CREATE_TENANT 日志)。
   - §6.7 的响应示例原来写着 ocrCount=3105 / smsCount=420,**与实现恒 0 矛盾** —— 改成 0 并补齐
     契约没写的 notes 与 alerts.used/limit,再加三条口径:实时值 vs 周期、恒 0 的原因(编数字会让
     运营拿假数据去谈续费)、**只算这一个租户**(含"库里只有一所学校时看不出"这一点)。
   - **新增 §6.8 / §6.9**(PUT /tenants/{id} 与 GET /tenants/{id}):PUT 此前只在 §4.2 的总览表里
     出现过一次、全文**没有详情节**;GET 更是只登记在 §17.1 的"清单漏列"组里。§6.8 含字段表、
     "不传的字段不修改"、"tenantCode 不可修改"的理由,以及留痕对照表(status→0 是 DISABLE_TENANT,
     不是 UPDATE_TENANT)+ "这些日志行的 tenant_id 是 0,要用 target_type/target_id 查"的警告。
   - §13.4 的 Query 补 targetType(并说明为什么 targetType+targetId 才是最重要的查询方式:
     关键字全文匹配会把"改了它"和"顺带提到它"混在一起,而合规结论不能建立在这种匹配上);
     响应补 request_method / request_uri / extraJson;动作表由 9 行扩到**全集 27 个**并按来源分组
     (任务 / 名册 / 导出 / 角色 / 租户);写明 request_param / duration_ms 刻意不采集;
     并记下"一期只做查询、不做日志导出"这条**此前只写在 LogController 类注释里**的口径
     (导出等于把"谁在什么时候看了谁的身份证"这份名单落到本地磁盘,而导出行为本身也要留痕)。
   - 登录日志响应补 tenantId,并写明 userType 的权威枚举与 location 恒为 null。

2) docs/08-完成度盘点与问题清单.md:新增 **F-112~F-124** —— 用量越界(含"为什么本地完全看不出来")、
   PUT 明文写入加密列、两个枚举能建不能改、租户零留痕、expireBefore 差一天、AUTO_CLOSE 字面量、
   模块下拉里有死项、LogController 描述与事实相反、request_param/duration_ms 有列无写入点、
   schema-postgresql.sql 缺三张表、**"平台专属权限码 + 查带 tenant_id 的表"这个风险面**、
   登录日志缺租户列、userType 注释错位。F-112 标注为本轮最严重的一条。

3) docs/10-用户手册与客服话术.md:「三种角色,三个入口」→ **四种角色**(补平台运营,并写明它是
   唯一跨单位的角色、用"组织与账号/收集管理"这类单位内页面之前要先选"当前学校");
   §4 速查表的菜单名与路径同步(平台运维 → 日志,含两个页签;新增租户管理一行);
   附录的排查话术补 traceId("日志详情里能拿到,那是后端排查时最有用的一个线索")。

4) docs/交接摘要.md:新增 **§一·三十九**,九节 —— 一、口径变更(旧注释原文 + 推翻理由);
   二、用量越界那个 bug 的完整来龙去脉(权限码 → ignoreTable → 实测数据分布 → 修法与夹具);
   三、另外四条后端缺陷表;四、日志详情为什么不新增接口(轻字段 vs 全库 0 行有值的重字段);
   五、AUTO_CLOSE 与检查 ⑩ 的关系(**"这一族检查的价值取决于被检查的那一侧有没有被规范化"**);
   六、顺带修掉的五处;七、前端刻意用分段按钮而不是 NTabs 的两条原因;八、验证表 +
   "改了后端必须重新打包重启、判据是看返回体里有没有新字段"的提醒;九、约定同步。
   另把 E2E 断言总数 1595 → **1617**(四处)。

5) 根 AGENTS.md:接线检查说明补第 ⑩ 项,并写明做这一项时才发现 AUTO_CLOSE 是字符串字面量写的
   —— "提成常量"是让这个检查真正生效的前提。

验证:python scripts/verify-docs.py **22/22**;python scripts/check-wiring.py 未接线 **0 条**
(⑦ 11 个权限码 / ⑧ 0 处漏 import / ⑨ 3 个 SVG 全合法 / ⑩ 27 个动作码两端逐字一致);
python scripts/check-api-parity.py 契约 **119** / 已实现 118 / 缺口 1 —— **没变**,符合本轮
"不新增后端接口"的决定。
2026-10-13 14:04:59 +08:00
lovife 624a05aa66 feat(pc): 平台运营的租户管理(详情/用量/编辑/停用)与日志详情
需求:「设计开发平台运营人员对租户的管理功能,还有日志的详情查看功能。」

⚠️ **口径变更**(覆盖 2026-10 走查的旧结论):views/tenant/index.vue 原先写着「列表**没有操作列**:
不提供编辑 / 停用入口(客户 2026-10 走查确认的口径)」。本轮用户明确选了全量范围,所以列表现在
有操作列(详情 / 编辑 / 停用·启用)。旧注释是**改写而不是删掉**,并写明推翻理由 —— 运营的真实
诉求是:① 到期日变了要能自己改,而不是每次找开发;② 欠费 / 停止合作时要能当场停掉;
③ 每周核对"谁快到期了"时要能点进详情看用量。

1) 租户管理:操作列用 tenant:view 门控「详情」、tenant:manage 门控「编辑 / 停用 / 启用」,
   拿不到管理权限时整列显示"只读",而不是留一个点了报 403 的按钮。停用走 NPopconfirm 二次确认
   且文案写明后果("停用后该校所有学生与老师会立刻无法登录");只发 {status} 一个字段 ——
   PUT 是部分更新,回传整行会把列表里看不见的字段覆盖成空。

2) 详情抽屉 TenantDetailDrawer:打开时**同时**调 getTenantDetail 与 getTenantUsage ——
   列表接口的 contactPhone **恒为 null**(电话以密文入库,列表不能成为批量拉取联系方式的通道),
   脱敏值只有详情接口给,拿行数据凑详情会显示成"没有联系电话"。用量区写明"学生数与存储是**实时值**,
   周期只影响计费项",否则会以为"2026Q3 的学生数"是历史值;notes 如实全列(它解释"为什么这几项是 0")。
   统计周期只给本月 / 上月 / 本季三个固定选项,不做自由文本输入。

3) 编辑弹窗 TenantEditModal:类型 4 选项、状态 3 选项;数字下拉的校验规则必须带 type: 'number'
   (只写 required 会"看起来填了但校验不过",而且 #feedback 会把报错遮住);联系电话留空 = 不修改;
   租户编码只读展示并注明"出现在扫码落地页与对接文档里,开通后不可修改"。不做"清空到期日"
   (MyBatis-Plus 忽略 null 更新,清不掉就不在界面上承诺)。

4) 日志页:操作日志 / 登录日志两个模式,各自维护 list / total / pageNum / pageSize / loading /
   筛选条件,切换时重置到第 1 页。⚠️ 页签用工具条上的**分段按钮**而不是 NTabs / NTabPane:
   ① NTabPane 的内容要额外 flex 化才能维持 .page-card 的高度链,否则表格高度算成 0、
   **一行数据都看不见**(本仓踩过);② naive 2.45.3 的 tab="..." 属性写法本身也是坏的,
   必须用 #tab 插槽。

5) 日志详情 LogDetailDrawer:变更前后**完整不截断** + break-all + 各一个复制按钮;
   extraJson 格式化成 <pre>(解析失败原样展示);traceId 带复制;request_param / duration_ms
   如实写"本版本未采集"(这两列从来没有写入点),不编。抽屉**不再打接口** —— 列表 SELECT 已经
   把抽屉要的字段都带出来了。

6) 「单位」列只对平台显示(0 → 「平台」),两张表统一用「单位」这个词:这一列要装得下
   tenant_id = 0 的「平台」,表头写「租户」而格子里写「平台」是自相矛盾的;且与筛选栏两个下拉的
   占位符、租户管理页的「单位名称」一致。登录日志刻意**不要「归属地」列**(location 恒为 null,
   整列空白看起来像功能坏了)。

7) 日志页的模块下拉换成导出的 MODULE_OPTIONS / TARGET_TYPE_OPTIONS:原先那 4 项里 AUDIT / EXPORT
   **后端一个写入点都没有**,而真会写的 FILE(代传材料)与 ROLE(6 个角色动作)**不在里面** ——
   选中死项只会得到一张空表,让人以为"这段时间没有审核动作"。

8) 菜单显示名「操作日志」→「日志」(key 保持 'log' 不动:后端 BuiltinRole 的菜单键与
   check-wiring 第 ⑥ 项都按它核对,改 key 会让平台角色的菜单静默少一项)。

同步 frontend-pc/AGENTS.md 与 .agents/:「最容易写错的约定」由 7 条扩到 **9 条** —— 新增
第 8 条「详情用抽屉、不要为详情新开路由」(含"列表接口可能刻意不返回某个字段"的坑)与
第 9 条「页面内的页签用工具条分段按钮」(写明高度链的原因)。

验证:pnpm typecheck **0 错**、pnpm lint **0 错 0 警告**、pnpm build 通过。
浏览器实测(平台账号,5173 + 8080):租户页出现操作列且三档到期配色正确、卡片上内边距仍是 16px、
高度链未破(表格 570px / 日志表 528px);详情抽屉显示脱敏电话 138****8000 —— 而库里存的是
**历史明文** 13800138000,也就是说这条同时验了"读路径容错"(否则是永久 500);停用二次确认文案
正确;清空单位名称提交后弹窗不关且错误可见;登录日志同一张表里同时出现「示范职业学校」
(tenant_id=1)与「平台」(tenant_id=0)两种映射;**学校管理员视角两张表都没有「单位」列**。
2026-10-13 14:04:53 +08:00
lovife b516a7c47b fix(tenant,log)!: 平台身份下用量统计算成全平台 + 5 处租户/日志缺陷
做「平台运营的租户管理」时挖出来的,其中第 1 条与那个功能本身无关、但严重得多。

1) 🔴 GET /tenants/{id}/usage 一直算的是**全平台**的数。平台运营的 tenantId 是 0,而
   FillTenantLineHandler.ignoreTable() 对平台身份直接返回 true —— 整张表的租户过滤被关掉,
   于是 distinctStudents()(查 collect_roster)与存储量(查 collect_file)都不带租户条件,
   返回全平台数字却标着某一所学校的名字,运营会拿它去谈续费。而这个接口的调用者**一定是**
   平台运营(tenant:view 在 platformOnlyCodes 里),所以它从来只在"过滤被关掉"的上下文里跑过。
   ⚠️ 本地完全看不出来:实测 34 行 collect_roster、22 行 collect_file 全在 tenant_id=1,
   只有一所学校有数据时错误值与正确值**完全相等**。修法:统计段包进既有的 TenantScope.callAs,
   切走后 isPlatformAdmin() 变 false、过滤自动恢复;**没有**手写 tenant_id 条件 —— 那等于
   放弃拦截器的保护。

2) PUT /tenants/{id} 把手机号**明文**写进加密列(create 走 codec.encrypt,update 是裸赋值)。
   AesGcmCipher.decrypt 对非 Base64 直接抛(该类没有 dev 旁路)→ 存过一次电话,这所学校的
   详情页就永远打不开。库里 id=1 存的就是明文 13800138000。改为非空才加密写入(空 = 不修改),
   详情返回脱敏值;**读路径必须容错**(解密失败按明文处理 + WARN),否则故障形态是"永久 500",
   看起来像系统坏了、而不是"库里有一行旧数据"。历史明文行不做迁移(迁移里拿不到密钥)。

3) 租户两个枚举"能建不能改":tenantType 只放行 1~3(实体/DDL/契约/前端都是 4 值)、
   status 只放行 0/1(实体有 2 试用,而前端的状态筛选按钮里就有「试用」,选了永远是空列表)。
   已按真实枚举放开,报错文案与实体注释逐字对齐。

4) 开通/修改/停用租户原先一条日志都不写(只有给运维看的 log.warn/log.info)。新增
   MODULE_TENANT 与 CREATE_TENANT/UPDATE_TENANT/DISABLE_TENANT/ENABLE_TENANT —— 停用与启用
   不并进 UPDATE_TENANT:停用是这套系统里影响面最大的单个动作,出争议时第一个要看的就是这个
   区别。⚠️ 这些行的 tenant_id 是 0(写日志用的是**操作者**的上下文),查"这所学校被谁停过"
   要用 target_type=TENANT + target_id,**不能**用 tenantId 筛(反直觉,已写进常量注释)。

5) expireBefore 用 atStartOfDay() 与存进去的当天 23:59:59 比 → **差一天**,「今天到期」的租户
   查不出来(同型的坑在 LogController.parseTo 上修过一次,那次是"查 9 月 30 日查不到当天")。
   改为补到当天末尾。格式不对返回 40001 并说明格式 —— **刻意不学** parseTo 的"当没传":
   那侧是界面上的自由文本输入,静默忽略是为了"输错了也别让人看不到日志";这侧的值只由界面按
   当前日期算出来,静默忽略筛选会让人以为"我按到期日筛过了"。

6) 顺带:
   - AUTO_CLOSE(定时任务自动截止)在 TaskService 里是**字符串字面量**、不在常量表里 ——
     也就是说"后端常量 ↔ 前端文案"这类检查**本来就扫不到它**,而它正是量最大的那类任务日志,
     界面上会原样回显英文码。提成常量(连带 STATUS_CHANGE 兜底名)。
   - 日志列表 SELECT 补 request_method/request_uri/extra_json 三个**轻字段**(实测一页 20 行
     约 2 KB),日志详情因此不需要新开接口;真正重的 request_param/duration_ms 全库 1758 行里
     **0 行有值**(从来没有 setXxx 过),不采集。
   - LoginLogVO 补 tenantId:平台运营看到的登录日志是**跨单位**的,没有这一列就分不清一行是
     哪所学校的,而"某个账号什么时候登进来的"一定得连着"哪个单位"一起回答。
   - 修正 LoginLogVO.userType 的 Javadoc:原来写着「1 超管 / 2 管理员 / 3 教师 / 4 学生」,
     与权威口径(1 平台运营 / 2 学校管理员 / 3 班主任 / 4 学生)错位 —— **同一个坑第三次出现**,
     该错位枚举曾导致"把班主任当成社会人员拒掉"。

新增检查 scripts/check-wiring.py 第 ⑩ 项:后端会写的动作码 ↔ PC 端 OPERATION_ACTIONS 的文案,
逐字核对(缺一个文案,日志页就会原样回显英文码)。**已反向验证**:删掉一条文案 → 报红 + exit 1。
⚠️ 写这个检查时踩到的坑:同文件里 MODULE_OPTIONS / TARGET_TYPE_OPTIONS 也是 { value: 'TASK' }
的形状,整文件扫会把模块名当成动作码收进来 —— 所以只从 OPERATION_ACTIONS 那个数组里取。

验证:python scripts/verify-all.py --with-e2e → **9 项全过 / 0 失败 / 0 跳过**(37 脚本 /
1617 断言)。e2e-m8-orgfile.py **74/74** —— 其中"用量只算这一所学校"这条**必须造夹具**才
证伪得了:它往新建的 E2E 租户**直插一行 collect_roster**(该表 NOT NULL 且无默认值的列只有 5 个,
不需要走导入夹具),再断言"接口值 == 库里 where tenant_id=1 的去重数",并**额外断言
"全平台去重数 > 一号学校去重数"**,否则这条断言是空转的。e2e-m5-logs.py 44/44(回归)。
重打包重启后再验一次:/logs/logins 返回 tenantId、/tenants/1 返回 138****8000(而不是 500)——
判据是**看返回体里有没有新字段**,而不是"接口通了就算"。
2026-10-13 14:04:48 +08:00
lovife 9f6d26c650 fix(e2e,pc): 不再产生跨租户同名的登录账号
账号唯一性是 (tenant_id, username),跨租户重名本身是合法行为(登录靠租户码区分);但默认值与 e2e 残留会让它变成默认状态:不带租户码登录会撞上 40012(该账号存在于多个单位),平台侧看账号也分不清哪个 admin 属于哪所学校。三处一起收口 ——

1) scripts/e2e-m0.py 跑完不再留租户:以前每跑一次就留下一个 e2e###### 租户 + 里面叫 admin 的管理员 + demo 下 3 个测试组织,库里那两个孤儿租户就是这么来的。现在按本次跑出的 id 自清(崩溃路径也兜住),不越界成 LIKE 'e2e%' 全清。

2) scripts/e2e-m1-tenant-domain.py 自己造、自己清「另一个租户里也有 admin」的夹具:它原本靠第 1 条的残留凑出 40012 这个前提,残留一被清掉就会静默反转成 8 条 FAIL。顺带修掉同族的第二处隐式依赖 —— 它假定 t14.localhost 是个真实租户,而那个租户早就不存在了。

3) PC 端「开通租户」的登录账号默认值不再写死 admin,改为跟随租户编码生成 admin_<编码>;运营一旦手改就不再被编码覆盖。

验证:全量 python scripts/verify-all.py --with-e2e 通过(37 脚本 / 1597 断言 / 0 失败),跑完库里仍只有 1 个租户、0 处跨租户重名;PC 端 pnpm typecheck 0 错、pnpm lint 干净、check-wiring 未接线 0 条,浏览器实演默认值跟随与手改不回退。
2026-10-13 10:21:40 +08:00
lovife 7df078e0ea docs: 同步交接摘要 / PRD / 原型,并记录品牌改名「深学在线」
- 交接摘要补 §一·三十五~§一·三十八:角色与授权、需求收敛(4 角色 / 10 菜单 / 取消二期)、
  PC 端重搭后的 UI 修复与卡片式基线、品牌改名与新标识
- PRD 与功能逻辑卷只改标题行与产品代号线;**文件名不动** —— verify-docs.py 按路径读,
  全库还有 20+ 处按文件名引用,改名会连带炸脚本
- 原型 index / admin-web / teacher-app / student-app 的品牌区改「深学在线」,
  admin-web 的 .mark(10 处)换成内嵌的新标 data URI,保持原型"单文件自包含"的约定
- 根 AGENTS.md 与 frontend-pc 的规范同步:接线检查补第 ⑨ 项(SVG 资产是不是合法 XML ——
  SVG 注释里出现连续两个减号就 not well-formed,图标裂开而控制台一句话都不报)、
  「最容易写错的约定」6 -> 7 条(新增品牌标)
- deploy/docker-compose.yml 只改头注释里的项目名(compose 的 name 与七牛对象前缀都没动)
2026-10-10 16:49:04 +08:00
lovife 1f3c485ceb feat(role,scope)!: 角色与授权可配 + 需求收敛(4 角色 / 10 菜单 / 删两模块)
角色与授权(交接摘要 §一·三十五):
- 权限判定合并两个来源:内置角色按代码(BuiltinRole)、自定义角色按库
  (sys_role_permission)。实现放 module 层的 DbRoleGrantSource,security 只依赖
  接口 RoleGrantSource —— 否则角色服务要调 permissionChecker,两个包迟早成环
- 权限判定必须加缓存:此前是零 IO,而压测基线是"SQL 条数恒定"。缓存放 Redis,
  空结果也缓存(否则已删角色的 token 会把库打穿)、Redis 挂了退化成直读库、TTL 只兜底
- V18 建 sys_role.user_type(账号的 user_type 一直从角色反推,自定义角色不在代码映射里)
  与 sys_role_menu;8 个接口 + 错误码 40045;契约 119 -> 129
- 内置角色只读(权限码是前后端契约,做成可改 = 一次误操作就能把管理员锁在系统外);
  平台级能力两道防线(界面禁用 + 服务端 40301);防自锁;改完立即生效(写授权清缓存)
- RoleProvisioner 从"已存在就 continue"改成幂等回填 + 每日 03:20 定时任务 ——
  修的是"代码改了权限、已开通租户永远停在发放那一刻",这种漂移不报错也没测试会红
- scripts/e2e-m10-role.py 77/77

需求收敛(交接摘要 §一·三十六,本节的数字为准):
- 删副班主任 / 审核员两个内置角色(6 -> 4)、历史整理器(module/cleanup)、
  加入申请(module/join + 学生端 pages/join)、collect_task_org.deputy_user_ids 整列;
  审核范围三级回退 -> 两级,连带 6 个 mapper 的 LIKE '%,id,%' 分支
- V19 迁移处理存量:先解 sys_user_role 绑定再软删角色;账号只在"除这两个角色外没有
  别的角色"时才软删。必须迁而不能只删代码 —— 角色码删掉后 mergePermissions 返回空集,
  存量账号会"能登录、处处 40301",不抛异常不打日志
- 契约 129 -> 119、错误码 59 -> 54、权限码 30 -> 28、PC 菜单 12 -> 10、
  小程序页 10 -> 9、原型 32 -> 29 屏、E2E 39 -> 37 个(断言 1695 条)
- UserService.list 对不认识的角色码由"静默丢掉过滤条件"改成失败关闭
  (之前传个不存在的 roleCode 会返回全校账号,且看起来完全正常)
- 品牌元信息同步改名「深学在线」:pom 名称、/doc.html 标题、配置与启动类注释、
  存储自检探针内容;学生端 app 名与全局导航栏标题
2026-10-10 16:48:54 +08:00
lovife 5b877451eb refactor(pc): 从 Gi Admin Pro + Arco 重搭为纯 Vue 3 + Naive UI
旧模板的 193 个文件整体删除,换成纯 Vue 3.5 + Naive UI 2.42 的树。

- 去掉 auto-import(组件与 API 全显式 import)、v-hasPerm(改 user.has('码'),全库 8 处)、
  Stylelint;n-tag 的 type 统一过 utils/tag.ts 转换(Arco 色词直接透传会静默变灰)
- 图标改为构建期抽取:scripts/extract-pc-icons.py -> icons/icon-data.ts(37 个),
  内网离线部署不会白屏
- 重搭后修掉 6 类"编译 / lint / typecheck 全绿、界面上就是坏的"故障:
  ① NGrid 的 cols 写成像素值(不是断点名 -> 永远只 1 列,工作台因此错乱)
  ② n-tab-pane 用 tab 属性(只渲染空注释节点,整排 tab 一个字都不显示)
  ③ 漏 import 被当未知自定义元素(样式整块丢) ④ .n-card__content 类名不存在
  (高度链第一环失效,表格行在 DOM 里但屏幕上没有) ⑤ 填报单状态映射错位一格
  ⑥ 有标题的卡片没有上内边距(naive 的 padding-top 只给"内容区是长子"的卡片)
- 卡片式版面提成全局基线:styles/index.scss 的 xt-* 令牌 + .page-card / .toolbar /
  .section-title,页面只引用、不再各写一套
- 品牌:改名「深学在线」,文字标换成几何标(方 = 卷宗 / 环 = 在线,不用文字字形);
  index.html 的页签图标从 404 的 /favicon.ico 改指真实存在的 /favicon.svg
2026-10-10 16:48:46 +08:00
lovife 0bbac7d228 feat(pc,platform): 验收走查修复(平台按学校代管 + 菜单/403/日志范围/分页)
客户在验收里点出来的问题,一次改完。两段改动:

一、平台运营与租户范围(后端为主)
- 新增请求头 X-Tenant-Id:平台运营是唯一跨租户身份,租户拦截器对它整体关掉了过滤,
  于是「组织架构 / 账号管理 / 工作台」拿到的是所有租户混在一起的数据 —— 而只有一所
  学校有数据时,它和学校管理员看到的完全一样,看不出错(这是最危险的一点)。
  现在这些页面必须先选定学校,服务端再把本次请求的租户上下文换成目标租户:
  只认平台身份(其它身份带头一律忽略)、目标租户必须存在且启用。
- OrgService.tree / UserService.list 补显式 tenant_id 条件(原来只靠拦截器,而拦截器对
  平台不生效);TaskService.seesWholeTenant 改用新的 LoginUser.isPlatformStaff() ——
  isPlatformAdmin 回答"当前租户是不是平台租户",代管时它为 false,会让平台退化成普通
  用户(任务列表掉进"班主任只看自己带的班"分支,得到空列表);PLATFORM_ADMIN 补
  TASK_VIEW(PRD §3.2「查看填报进度看板:平台运营 ✅」)。
- 操作日志对平台永远是空的:SQL 写死 tenant_id = 当前租户,而平台的 tenantId 是 0。
  按契约 §13.4 补 tenantId 参数与 log:viewAll:平台默认看全量、可指定某所学校;
  学校管理员恒为本租户,传别家返回 40301(不静默忽略,否则会让人以为"筛过了")。
- 开放接口"推送重试"从未生效(F-112):定时扫描缺 @InterceptorIgnore,异常被 log.warn
  吞掉;同一方法里"接入方已停用"那条 update 也没有租户上下文,会每 10 秒重试到永远。
  E2E 补了"真的重试了"的断言(原来只断言"安排了下次重试")。

二、PC 端走查体验
- 去掉 3 条无用菜单(名单管理 / 表单设计器 / 材料规则):功能都在「收集任务」行内入口,
  菜单里那一条点进去只有指路;后端菜单键同步删除,entryAt 那套机制一并删掉。
- 班主任点工作台必弹 403:工作台无条件调了要 org:view / progress:viewAll 的两个接口。
  403 走 HTTP 状态码,try/catch 拦不住它的弹窗 —— 改成按权限决定发不发请求。
- 操作日志:分页器被裁在可视区外(新增 .g-list-card 让表体自己滚、表头钉住、分页器留在
  卡片底部)+ 去掉页面上那段免责声明;收集任务的状态切换改成单独一行 tab;去掉顶栏假数据
  铃铛;配色按客户要求还原。

顺带修两处"检查器自己不严":check-wiring 新增第 ⑦ 项(PC 端引用的权限码必须存在于
PermissionDef —— 本轮我自己把 progress:viewAll 写成 progress:view:all,判断恒为 false,
而编译 / typecheck / lint / E2E 全都不红);verify-docs 的表格列数检查不再把 GFM 转义
竖线当成列分隔符。两个检查器都拿"故意写错的样例"试过,确认真的会红。

验证:typecheck / mvn package / check-wiring(未接线声明 0 条)/ verify-docs(22 项)全过;
e2e-m0 36、e2e-m1-orgs 48、e2e-m0-user 55、e2e-m5-logs 44、e2e-m5-notify-wiring 43、
e2e-m1-tenant-domain 6、e2e-m8-open 61 全绿;浏览器按角色逐个真点复验。
详见 docs/交接摘要.md §一·三十三 与 §一·三十四。
2026-10-09 17:29:20 +08:00
lovife db8aecc30a chore(e2e,docs): 新脚本的清理只删自己建的模板行(不碰演示态)+ docs/09 §8 补接线检查 2026-10-08 14:47:11 +08:00
lovife 9e78e1d239 feat(notify): 通知接线检查 + 修 F-101~F-111(模板对自动通知生效 / 催报真的会发 / 该发的通知都发了)
起点:F-1/F-31/F-50/F-52 是同一个形状 —— 每处单看都对,编译/lint/review 全不红,只有从入口走一遍才暴露。先把这一类固化成命令,再按输出逐条修。

新增 scripts/check-wiring.py:权限码有没有地方校验、错误码有没有被引用、站内信模板有没有发送点、bizType 有没有被用过、菜单键 ↔ PC 端 XtMenu 双向核对。有意不接线的写进脚本上方例外表(带理由)。进 verify-all(第 5 项)与 CI 的 check 阶段。检查器自身修了三次假阳性:只数 @RequirePerm 漏掉服务层校验、按标识符数引用被同名常量骗过、注释里的提及被当成接线(改为先去 Java 注释再数)。

F-101 自动通知改走可配置模板(FR-1002 原来只兑现了手工发送那一半):新增 NotifyTextResolver(单向依赖避开循环注入),库里改过的 > 内置;模板停用 = 这类通知不发且如实回 notifySent=false。F-102 加入申请三处通知(提交→审批人、通过/驳回→学生)。F-103 导出完成/失败通知发起人,正文不放签名 URL(模板里原本写着 {下载链接},一并改掉)。F-104 自动催报从来没发出过任何一条(end_time = date::date 与库里的 23:59:59 永不相等,测试夹具用 SQL 摆的日期型恰好能匹配)+ 补 FR-1003 的「截止当日」。F-105 驳回达上限通知管理员(契约与注释都写了,代码只有一行日志)。F-106 字典/通知记录两个菜单没有任何角色持有(手册还写着去那里办)。F-107 6 个权限码挂到自己的接口。F-109 Map.of 不接受 null → 通知被吞成一行 warn。F-110/F-111 测试自身的两个坑。

验证:新增 e2e-m5-notify-wiring.py 43 条(改模板→真驳回→断言学生收到改过的那句、停用→一条不发、申请/审批/导出/催报/升级/菜单/权限各一节);verify-all --with-e2e → 9 项全过 0 跳过,38 个脚本 / 1620 条断言 / 0 失败。
2026-10-08 14:46:11 +08:00
lovife 4a5790d900 chore(clean): 测试数据三本账清完(库 / 磁盘 / 七牛桶)+ 100 条问题全部核销
一、dev-clean-e2e.py:加「在用组织保护」后执行
  · 先算「仍被存活任务引用的组织」并保留 —— 当前演示态就挂在其中一个
    「E2E 24数控1班」上,删了它演示态就坏(保留 3 个,演示态完好:
    任务 1449 / 名册 3 人 / 组织 43 都在)
  · 已执行 --apply --demo-tasks --orphan-files:
    49 个 e2e 租户 + 147 个测试组织 + 84 个【演示·旧】任务,
    共 3714 行 / 27 个文件(业务表按 information_schema 动态枚举,不写死清单)

二、dev-clean-qiniu.py(新增):清真实桶里本项目前缀的测试对象
  · 只动 xiaotutongxue/ 前缀;默认 dry-run;删前核实库中零引用、
    数量异常(>5000)直接停
  · 已执行:2036 个对象 / 116 MB 全部删除,删后复验 0 个(F-93「删库不删桶」)
  · 签名按官方文档逐字核对 —— 踩了两个 401 才对:旧 QBox 格式(一个换行 +
    "QBox " 前缀)不对,是「两个换行 + Qiniu 前缀」;batch 的 Body 必须进
    签名串(列举无 Body 能过、批删全 401 —— 同一签名函数一好一坏,线索在 Body)
  · 列举在 rsf.qiniuapi.com,不在 rs(打错 host 报「Bad method: list」)

三、xt-minio 容器已删(F-2)—— 代码早已不用 S3,deploy 里也没有它

四、文档核销与记录
  · docs/08:F-3/4/7/10(小节)与 F-14/26/29/30/31/38/51/57/64/65/71/72/93
    (表格行)全部标「已闭环」;F-5 补执行记录;F-28 备案维持两级(→D-11);
    变更记录补两行 —— **F-1~F-100 至此无未决项**,剩余全部等外部输入
  · docs/01:新增 E4「国标职业库数据来源待客户确认」(F-34,字典页如实显示 0 项)
  · 交接摘要:新增 §一·三十一(17 项决策执行记录)
  · AGENTS.md:清理一节写清三个工具与「在用组织保护」

验证:python scripts/verify-all.py --with-e2e →
      通过 8 / 失败 0 / 跳过 0(37 个 E2E 脚本 / 1577 条断言 / 0 失败,291s;
      prodguard 9/9;test profile 能起)
2026-09-30 12:38:55 +08:00
lovife f89cd998ef test(unit): 三个纯函数的最小单测(12 条)—— 本仓库第一批单测(F-9)
后端验证一直靠 E2E(打真实接口,很硬但要起服务连库)。这批先给
「输入输出确定、没有 IO」的纯函数补最小单测,成本最低、回归收益最高;
其余继续靠 E2E,不为了覆盖率硬凑。

· IdentityNormalizerTest:姓名去所有空白但**保留间隔号**(少数民族姓名的
  「·」是真实的,顺手清掉等于改人名字)、全角转半角、证件号小写 x 收尾统一、
  null 不抛 NPE
· FileProcessServiceTest:扩展名必须自带点号(少一个字符不报错,只会产出
  「身份证jpg」这种客户双击打不开的文件 —— F-46)、空变量用「未填」占位
  (留空会出现「张--身份证.jpg」)、文件名里的 / 必须清掉(「学籍表(1/2)」
  带 / 根本写不进磁盘)、超长截到 90 且不吃掉扩展名、sanitize 收拢连续分隔符

每条断言都对应一个真实踩过的坑 —— 断言的是「有理由的规则」,不是覆盖率。

验证:mvn test → 12/12 通过(exit 0)
2026-09-30 12:38:48 +08:00
lovife 57bf39d5a1 feat(notify,log): 催报分钟级限流 + 学生微信登录写登录日志(两处合规补漏)
一、催报分钟级限流(F-29,契约 §3「10 次/分钟/任务」此前未实现)
  · UrgeService 加 Redis 计数:定向与一键催报共用,超限 42901
    且话说清等多久、24h 去重还在(去重防「学生被重复催」,限流防「老师连点
    把系统打爆」—— 两件事);限流放在「确实有活可干」之后(空结果连点不计数)
  · Redis 不可用时放行不瘫痪(限流是保护,不是业务前提)

二、学生微信登录也写 sys_login_log(F-31 —— 此前只有 PC 账号密码登录留痕,
   按 §13.4 查「学生登录记录」永远为空,合规缺口)
  · 新增 WechatLoginLogger:小程序登录(MpLoginService)与 H5 授权回调/
    打桩登录(MpH5AuthController)三条路径都写
  · login_type=2 与 DDL 注释对齐(1 PC 2 小程序 3 开放接口);H5 与小程序
    共用 2(DDL 只定义到 3,不造新值),要区分看 userAgent —— 字段里本来就带着
  · 写失败不影响登录(日志表满了不能没人能登录);不记 code/openid

验证:e2e-m5-urge → 42/42(新增「连点第 11 次被限流」断言,先清计数保证确定性)
      e2e-m3-h5-auth → 24/24(新增「两次 stub-login 各留一条 login_type=2」断言,
      清理时连登录日志一起清)
2026-09-30 12:38:48 +08:00
lovife 8f80744d63 feat(task): 规则模板 FORM 真正可套用 + 已归档任务可删(客户确认的两项)
一、规则模板的 FORM 部分(F-57/F-64)—— 此前只能「跳过并说明」,等于装饰
  · FormSchemaService.saveRawVersion:用已存在的 schemaJson 存为新版本。
    与手工保存的区别:内容不再校验/归一化(它当初就是按本服务规则存出来的,
    再走一遍解析→序列化只会丢未建模字段);状态检查与版本管理保留 —— 版本化
    逻辑只有一份
  · RuleTemplateService 的 FORM 分支接上它(不再 skipped)
  · 顺带补上另一半:saveAsTemplate 此前不存表单(存模板不存、套模板用不上,
    两头各缺一半)—— 现在把源任务的当前表单一并带走;没存过 = null =
    套用时按「用系统默认,不覆盖」跳过

二、已归档任务可删(F-14 / §17.2 D-9)—— 此前「已发布即删不掉」,
   演示/测试任务只能改名挪开,库只增不减
  · DELETE /tasks/{id} 支持 status=3:收集中/已截止仍拒(归档是最后的后悔药),
    拒绝的话说清「先归档」
  · 子表级联物理删 14 张表(TaskCascadeMapper 集中放置,表清单来自
    information_schema 的事实核对)—— 大头是名册(证件号密文)、材料、审核记录
  · ★ 任务行本身**软删**:它必须占住 task_code —— maxSeqByCodePrefix 靠
    「含软删行的最大序号」保证编码永不复用(编码同时是文件目录名,重号会把
    两批任务的文件混进同一目录)。第一版做成了物理删,e2e-m1-task 的
    「编码递增」断言当场抓到重号 —— 这条回归现在被 e2e-m1-task-delete
    的「删除后新建不重号」断言守住
  · 物理文件尽力删(先取路径再删行,顺序反了文件就永远留在磁盘上;
    删不掉只记日志不阻断);全程留痕(sys_operation_log 记 DELETE_TASK +
    行数/文件数)

验证:e2e-m7-taskconfig → 75/75(新增 FORM 完整链路 7 条断言)
      e2e-m1-task-delete(新增)→ 20/20(拒删/删净/占号软删/留痕/再删报错/草稿直删)
      e2e-m1-task → 71/71(旧口径断言更新为新口径)
2026-09-30 12:38:47 +08:00
lovife b991e3d68e docs(contract): V2.6 六处口径修正 + 新增 §17 实现补充与偏差记录(客户确认)
17 项决策里的 A 组:契约按已确认的口径改写,不动代码。

一、六处口径修正
  · §2.7 notifyBizType:5/6 对调修正为「5 催报 / 6 导出完成」——
    旧表若按写实现,催报去重会静默失效(F-30)
  · §2.7 auditAction:补全 7 个动作,撤回=7(3 是批量通过;F-7);
    §11.4 约束补「状态 2 可撤(撤的是初审)」、留痕改 action=7
  · §3.6/§9.5 collect_file.source:改为建表口径「谁传的」
    (1 本人/2 班主任代传/3 管理员代传/4 整理器;F-38/39 —— 代传可追溯)
  · §9.5 重写为「上传前置检查(预检/秒传/压缩建议)」,direct:false,
    明写「客户端可跳过;服务端上传本身做全部校验」(F-3/71/72);
    §9.6 process 改为「有原始件时的重新规范化」;§9.9 OCR 标注一期缓做
  · §12.1 validation/run 改同步并返回统计
    (checkedSubmissions/issuesFound/unresolvedLeft/elapsedMs;F-51 ——
    全契约本就没有查 jobId 的接口,返回一个查不到的作业号没有意义)

二、新增 §17 实现补充与偏差记录
  · 17.1:41 个「代码有、§4 清单没有」的接口按六类分组
    (材料通道/微信授权/签名URL下载/界面必需/契约链条断在第一步/运维动作);
    清单声明由 check-api-parity.py --extra 生成 —— 手抄件会过期,命令不会
  · 17.2:12 条已确认偏离 D-1~D-12(notifyBizType、source、上传链路、process、
    validation 同步、撤回动作码、§14 路径前缀、biz_type 落库字符串、归档可删、
    租户域名、通知两级、OCR 缓做)—— 每条带契约原口径/实现口径/依据编号

验证:python scripts/verify-docs.py → 22 项全部通过(含表格列数一致性);
      check-api-parity.py --quiet → 契约 121 / 已实现 120(§17 不影响 §4 计数)
2026-09-30 12:38:47 +08:00
lovife 28dedb4c16 docs: 补测结论回填与限流口径同步(docs/01 E2 / docs/08 F-99·F-100 / docs/09 §9 / 交接摘要 §一·三十)
- docs/09 §9 重排为 9.1 结果 / 9.2 结论 / 9.3 门禁两次修正 / 9.4 怎么跑 /
  9.5 补测记录(上传并发、5000 行导入、导出大包、长跑)/ 9.6 仍然没测的;
  补记机器规格(12 物理核 / 59.6GB,全在同一台机器上)。
- docs/01 E2 二次修正备案:成功侧 200→600,附实测证据。
- docs/08:F-99(压测工具自己撒的 8 次谎)、F-100(门禁成功上限卡年级量级)、
  F-5/F-6 核销与更新,变更记录补 3 行;问题清单到 F-1~F-100。
- 交接摘要:§一·二十九(提交入库+文档小账)之后补 §一·三十(四项补测+门禁二次修正+磁盘清理)。
- AGENTS.md:清理残留一节写清 dry-run 默认与「演示态挂在 E2E 班下面」的风险。

验证:python scripts/verify-all.py --quick → 通过 5 / 失败 0
2026-09-30 11:10:06 +08:00
lovife 601714c476 test(scripts): 补测"没测过的四项"+ 清理工具扩展(F-99 / F-5)
一、load-test.py:把 docs/09 §9.5 里四项"没测过"的变成"测过"
   · --base:另起一个 --fill.storage.provider=local 的实例(18080)量上传,
     不把测试文件灌进真实七牛桶;
   · --fixture-only:量名册导入本身(造 Excel 与导入接口分开计时 ——
     脚本自己的成本不能算到系统头上);
   · --only export-big:造 N 份已通过的单(门禁绑定→草稿→上传→提交→
     初审(班主任)→终审(管理员)→前置检查→创建→轮询),再逐卷解开验证 ZIP;
   · --soak/--rps:长跑,每分钟采 RSS(从系统取,不是应用自报的堆)与
     PG 连接数,比较前 1/3 与后 1/3 的均值 —— 判据是趋势,不是"跑完没崩";
   · 上传夹具换成真 JPEG(原来的"JPEG 头+随机字节"在服务端解码即失败,
     量到的根本不是压缩成本)。

   数字(docs/09 §9.5 有完整复现命令):
     上传 20 并发 40/40,串行 321ms(2850KB→1329KB,47%),并发 p50 620ms;
     5000 行导入 20.2s(RSS 782MB);150 人 194.6MB 导出打包 13.2s,
     分卷 4 卷每卷可单独解开;长跑 51 样本 15300 请求 0 失败,
     RSS 603.9→582.9MB 平稳,PG 连接定时任务高峰后正常回落。

二、F-99:这一轮压测工具自己撒了 8 次谎(都是"度量/夹具出问题时
   现象长得像产品缺陷"):前置未断言(--only upload 单跑全报"没有草稿")、
   假 JPEG、猜列名(part_no/file_name/file_path)、拿 HTTP 码当业务码、
   跨租户同名 admin(库里 50 多个,改从 token 的 sub 取 uid)、
   夹具接口不查返回码(PUT orgs 被拒→任务没发布→学生端整齐报 40008)、
   PowerShell -First 截管道把"被堵"变成"卡住"、清理只删库行不删文件。
   修法:bind_users/call_ok(夹具每个调用都断言业务码)、make_img 真图、
   列名按 DDL、cleanup 连磁盘文件一起删。

三、dev-clean-e2e.py(F-5 落地):
   · --orphan-files:清"库里没有任何一行指向"的磁盘文件(判据来自
     collect_file.std_path/raw_path + collect_export_file.file_path)——
     实测 uploads 里 281 个文件/276MB 而库里只剩 16 行,已清 255 个/275.4MB;
   · --demo-tasks:清【演示·旧】任务(只匹配带「旧」的 ——
     【演示】审核台是当前演示态在用的,原来那句 LIKE 会把它一起删掉);
   · e2e 租户的业务行按 information_schema 动态枚举(写死清单必然漏新表);
   · --only-orphan-files:最安全的一档,只碰磁盘不碰任何库行。
   ⚠️ e2e 租户与"E2E 24数控1班"组织刻意未删:当前演示态正挂在其中一个班下面。

验证:python scripts/verify-all.py --quick → 通过 5 / 失败 0 / 跳过 0;
      docs/09 §9.1~§9.5 已同步(含机器规格:12 物理核 / 59.6GB)。
2026-09-30 11:10:05 +08:00
444 changed files with 28578 additions and 32003 deletions
+4
View File
@@ -70,3 +70,7 @@ backend/data/
application-local.yml
# 本地私有配置(云存储密钥等)—— 只提交 .example 模板
xt-local.yml
# 生产环境变量(含 JWT / AES / RSA / 七牛真凭据)—— 只提交 .example 模板
deploy/prod.env
deploy/prod-rsa.pem
+9 -8
View File
@@ -2,8 +2,8 @@
# GitLab CI(M0-04 / M7-10)
#
# 设计原则:**流水线跑的必须是我们真正依赖的那套验证**。
# 这个仓库没有单元测试(`src/test` 是空的),验证靠三样东西:
# ① 三个机械校验脚本(契约对账 / 实体列 / 文档一致性)
# 这个仓库的单元测试很少(`src/test` 只有 12 条纯函数),验证靠三样东西:
# ① 五个机械校验脚本(契约对账 / 实体列 / 文档一致性 / 手册文案 / **接线**)
# ② 编译与构建(后端 jar、PC、H5)
# ③ **`scripts/e2e-*.py` 打真实接口 + 查库断言**(1500+ 条)
# 所以 e2e 阶段不是"锦上添花",它是这个项目的主验证手段 ——
@@ -58,6 +58,8 @@ variables:
- python scripts/verify-docs.py
- echo "── 手册里引用的错误码与文案是否真的存在(客服照着它回答) ──"
- python scripts/check-manual.py
- echo "── 声明了但没接上的东西(权限码 / 错误码 / 通知模板 / 菜单键) ──"
- python scripts/check-wiring.py --quiet
- echo "── 全部 e2e 脚本的语法(引号写错会当场红,而不是跑到一半才红) ──"
- |
python - <<'PY'
@@ -129,13 +131,12 @@ variables:
script:
- cd frontend-mp
- pnpm install --frozen-lockfile
# ⚠️ H5 是**后端托管**的(没有独立 dev server),所以它的构建产物是
# e2e 阶段起服务时的输入,不能跳过
# ⚠️ 2026-10 起 H5 **不再由后端托管**(改由 nginx/网关托管,见 deploy/nginx/xt.conf),
# 所以这个 job 的定位变了:它不再是 e2e 的输入,而是**编译检查** ——
# 学生端没有单独的 typecheck job(vue-tsc 有 2 条已知报错),
# "能不能 build 出 H5"就是它最实际的把关点。
# (产物也不留 artifact:e2e 不消费它,发版时由部署流程自己构建。)
- pnpm build:h5
artifacts:
paths:
- frontend-mp/dist/build/h5/
expire_in: 1 day
# ══════════════ 阶段 3:全套接口断言 ══════════════
+70 -26
View File
@@ -3,33 +3,43 @@
面向 AI 编码助手的项目指南。**用简体中文交流、用中文写文档。**
需求已冻结:**文档是需求源,代码是它的实现**,不要按"更合理"去改业务口径。
`docs/交接摘要.md` 是**唯一状态源** —— 动手前先读它最近的两节(§一·二十、§一·二十一)
`docs/交接摘要.md` 是**唯一状态源** —— 动手前先读它最近的两节(**§一·四十二、§一·四十三**)
与 `## 二、已锁定的铁律`;那里面大量决定"读代码看不出来、但都有理由"。
> ⚠️ **2026-10 需求收敛(见 §一·三十六)**:只做 **4 个角色**(平台运营 / 学校管理员 / 班主任 / 学生)、
> **10 个 PC 菜单**、**不设二期**。删掉的东西不要再"顺手加回来":副班主任 / 审核员两个角色、
> 历史整理器、加入申请(含学生端自助申请入口)、`collect_task_org.deputy_user_ids`。
## 一、四个可运行部分
| 目录 | 是什么 | 入口 |
|---|---|---|
| `backend/` | Java 21 + Spring Boot 4.1.1 + MyBatis-Plus + PG16 + Redis7 | `xt-server/`(`xt-common/` 放响应体/错误码/加解密) |
| `frontend-pc/` | 管理端 Vue3 + Arco Design Vue(基于 **Gi Admin Pro**) | `pnpm dev` → 5173 |
| `frontend-pc/` | 管理端 **纯 Vue 3 + Naive UI 2.42**(2026-10 从 Gi Admin Pro + Arco 重搭) | `pnpm dev` → 5173 |
| `frontend-mp/` | 学生端 **uni-app**(H5 与微信小程序同一套代码) | `pnpm build:h5` / `pnpm dev:mp-weixin` |
| `deploy/` | 本地基础设施(PG + Redis,**没有对象存储**) | `docker compose up -d` |
| `deploy/` | 本地基础设施(PG + Redis + **nginx**,**没有对象存储**) | `docker compose up -d` |
`docs/prototype/` 是视觉与交互的**唯一基准**(单文件自包含、零外部依赖、**不用 emoji**)。
**一期范围只做「学校收集 + 自主认定」**:`collectMode=2` / `certType=2,3` 一律返回 `40044`,
别顺手把二期功能实现出来。
**范围只做「学校收集 + 自主认定」**:`collectMode=2` / `certType=2,3` 一律返回 `40044`。
**不设二期** —— 与「二期」有关的任何设计(社会收集、社会评价、专项职业能力、数据范围可配等)都**不做**,
别顺手实现;OCR 与材料识别是「缓做,等供应商选型」。
## 二、常用命令(备注里是容易猜错的地方)
### 基础设施
```bash
cd deploy && docker compose up -d # xt-pg(5432) + xt-redis(16379)
cd deploy && docker compose up -d # xt-pg(5432) + xt-redis(16379) + xt-nginx(80)
```
⚠️ 本机还有别的项目的 `pg-verify`(55432) 与 `redis-local`(6379),**不要动它们**。
⚠️ **H5 由 `xt-nginx` 托管(80),不由后端托管**(2026-10 调整,见 §一·四十三):
它把 `frontend-mp/dist/build/h5` 当静态站发出去,并把 `/api` 反代到后端 8080 ——
两者**同源**,这是微信授权与 H5 的接口地址(`location.origin`)共同要求的。
起它之前先 `cd frontend-mp && pnpm build:h5`(挂的是产物目录,空目录 = 页面 404)。
### 后端
```bash
@@ -38,11 +48,10 @@ mvn -DskipTests package # 改了 Java 必须重新打包
java -jar xt-server/target/xt-server-1.0.0-SNAPSHOT.jar --spring.profiles.active=dev
```
- ⚠️ **工作目录必须是 `backend/`**,两处相对路径依赖它:
`application-local.yml`(不提交的本地私有配置)就在 `backend/`;
H5 静态目录配的是 `file:../frontend-mp/dist/build/h5/`
- ⚠️ **工作目录必须是 `backend/`**:`application-local.yml`(不提交的本地私有配置)在那里,
是靠相对路径找到的(H5 静态目录那条相对路径已于 2026-10 随托管一起移走,见 §一·四十三)
- 跑的是 **jar 不是热加载** → 改后端要重新 package(先停进程,否则 jar 被占用)
- **动过 `db/migration/` 必须 `mvn clean package`**(Flyway 已到 V6;迁移文件发布后不许改)
- **动过 `db/migration/` 必须 `mvn clean package`**(Flyway 已到 **V19**;迁移文件发布后不许改,只能新加)
- 本地私有配置机制是 `profiles.active=dev` + `spring.profiles.include: local`
(模板 `backend/application-local.yml.example`)。
**不要改用 `spring.config.import`** —— 它优先级低于 profile 文件,会静默失效(见 `application.yml` 注释)
@@ -56,18 +65,23 @@ pnpm typecheck # vue-tsc pnpm lint # eslint
- 用 **pnpm**(仓库里是 `pnpm-lock.yaml`;别 `npm install`,会多出 package-lock.json)
- dev 通过 vite proxy 把 `/api` 转发到 `http://localhost:8080`(见 `.env.development`),
**没有 Mock**(`mock/` 与 `vite-plugin-mock` 都已删除)→ 后端不起着就没有数据。
⚠️ `frontend-pc/.agents/skills/project-api/SKILL.md` 仍以 Mock 为主线(Gi 模板原文),
该文件开头已加"本仓库不用 Mock"的说明 —— 看它时先看那段
- 我们的接口模块都在 `src/apis/xt/`;`apis/system|user|person|cate|area|test` 是 Gi 模板自带的,别往那儿加
- **PC 前端有独立的规范来源:`frontend-pc/.agents/`**(`AGENTS.md` + `skills/*`)。
改那里的代码前按它的表加载对应 skill,不要另起一套写法
**没有 Mock**(`mock/` 与 `vite-plugin-mock` 都已删除)→ 后端不起着就没有数据
- 接口模块都在 `src/apis/xt/`(按业务域分文件);**没有 auto-import**,组件与 API 全显式 `import`
- ⚠️ **2026-10 已从 Gi Admin Pro + Arco 重搭为纯 Vue 3 + Naive UI**,因此:
- **`pnpm lint:style`(Stylelint)已移除**,只剩 `pnpm lint`(ESLint);`pnpm typecheck` 现在 **0 错**
- 权限判断统一写 `userStore.has('码')`(**没有 `v-hasPerm` 指令**),字符串必须与后端 `PermissionDef` **逐字一致**
- `n-tag` 的 `type` 必须过 **`@/utils/tag.ts`** 转换:`apis/xt/*.ts` 里的展示映射仍是 Arco 色词
(`green`/`arcoblue`/`gold`/`gray`),直接透传会**静默变灰**且不报错
- 图标:`scripts/extract-pc-icons.py` 生成 `src/icons/icon-data.ts`(37 个);
`AppIcon` 对缺失图标是**空占位、不报错** → **图标集升级后要重跑该脚本**
- **PC 前端有独立的规范来源:`frontend-pc/AGENTS.md` + `frontend-pc/.agents/`**(已按 Naive UI 重写)。
改那里的代码前先读它,不要另起一套写法
### 学生端(uni-app)
```bash
cd frontend-mp && pnpm install
pnpm build:h5 # → dist/build/h5(由后端托管)
pnpm build:h5 # → dist/build/h5(由 xt-nginx 托管,不由后端托管)
pnpm dev:mp-weixin # → dist/dev/mp-weixin(导入微信开发者工具)
```
@@ -75,30 +89,59 @@ pnpm dev:mp-weixin # → dist/dev/mp-weixin(导入微信开发者工具)
`vite.config.ts` 加了 `/api` 代理,`.env.development` 把 `VITE_API_BASE` 设成空串,
于是请求走相对路径、由代理转发到 8080。**改一行不用再 `build:h5`**。
⚠️ 三端行为不同,改动这块前先看 `src/config/index.ts` 的注释:
dev server → 代理;构建产物(后端托管)→ `location.origin`;小程序 → 写死地址
dev server → 代理;构建产物(由 nginx 托管、与 `/api` 同源)→ `location.origin`;小程序 → 写死地址
- 改前端后浏览器要**硬刷新**(`/assets/**` 是 immutable 缓存)
- 构建时 Dart Sass 的 `legacy-js-api` DEPRECATION 属正常
- `vue-tsc` 有 2 条**已知**报错(`src/config/index.ts` 的 `#ifdef` 重复导出),不是新引入的
- `vue-tsc` 有 2 条**已知**报错(`src/config/index.ts` 的 `#ifdef` 重复导出),不是新引入的。
⚠️ PC 端不同:重搭后 `pnpm typecheck` 是 **0 错** —— 那边一有报错就是新引入的
- 当前节奏:**先做 H5**(可自动化测试、不依赖小程序资质);小程序端代码同步维护
- ⚠️ **小程序端目前学生用不了(F-130,P0)**:前端**从来没有调用过 `/mp/login`**(token 永远是空 → 门禁 40101),
而后端那条路也不通 —— `scene` 带 taskId 时 `/mp/login` 必然 500(`MpLoginService.java:127` 匿名查租户表被租户拦截器拒)。
实测细节见 `docs/11` §5.4 / §6。**除登录外整条链路(落地页/门禁/表单/压缩上传/红黄校验/提交)都是好的**
—— 用开发者工具自动化(`wmp_*` 工具族)可以跑,`wx.login()` 在页面上下文里能拿到**真 code**
## 三、怎么验证(本仓库最不显然的一点)
**后端没有单元测试**(`src/test` 是空的)。验证靠 `scripts/e2e-*.py`:
打真实接口 + `docker exec` 进 PG 查库断言。
**后端的单元测试很少**(`backend/*/src/test` 只有 12 条纯函数断言,`mvn test` 跑)。
主验证手段是 `scripts/e2e-*.py`:打真实接口 + `docker exec` 进 PG 查库断言。
```bash
python scripts/e2e-m3-file.py # 单跑一个;前提是后端 8080 在跑、xt-pg 容器在跑
```
- 全量:把 `scripts/e2e-*.py` 依次跑一遍(`e2e-m0*` / `e2e-m1-*` / `e2e-m2-roster` / `e2e-m3-*`),
目前合计 613 条断言
- 全量:把 `scripts/e2e-*.py` 依次跑一遍(**38 个脚本 / 1712 条断言**,以最近一次
`verify-all --with-e2e` 的输出为准;2026-10 需求收敛删掉了 `e2e-m2-join.py` 与 `e2e-m7-cleanup.py`,
之后又补了 `e2e-m3-flow.py` —— 填报链路的覆盖缺口,见 `docs/11-填报流程端到端测试计划.md`),
或一条命令 `python scripts/verify-all.py --with-e2e`
- 脚本**自己造 MP 的 JWT**:读 `application-dev.yml` 的 `fill.jwt.secret` +
`application.yml` 的 `issuer` 手搓 HS512 —— 不需要先登录
- 改了后端**忘了重新打包/重启**,测试会对着旧 jar 通过,这是最容易踩的空转
- 跑完 `e2e-m3-*` 会**清掉名册绑定**(`collect_roster.bind_user_id`);
想继续用浏览器验演示态,要先重新绑定(见交接摘要的"演示态"一节)
- 清理残留:`python scripts/dev-clean-e2e.py`、`python scripts/dev-clean-test-users.py`
- 清理残留:`python scripts/dev-clean-e2e.py`(**默认 dry-run**;
`--only-orphan-files --apply` 只清磁盘孤儿文件 —— 压测跑几轮就能攒下几百 MB;
`--apply` 清 e2e 租户与测试组织,**会先算「仍被存活任务引用的组织」并保留**)、
`python scripts/dev-clean-test-users.py`、`python scripts/dev-clean-qiniu.py`
(清真实桶里 `xiaotutongxue/` 前缀的测试对象,**默认 dry-run**,删前先核实库中零引用)
- 文档一致性:`python scripts/verify-docs.py`
- 接线检查:`python scripts/check-wiring.py`(**"声明了但没接上"** ——
权限码有没有地方校验、错误码有没有被引用、站内信模板有没有发送点、
`bizType` 有没有被用过、菜单键 ↔ PC 端 `XtMenu` 是否对得上、
**PC 端 `user.has('码')` 引用的权限码 ↔ `PermissionDef`**(第 ⑦ 项)、
**模板里的 `<n-*>` 组件有没有漏 import**(第 ⑧ 项 —— 漏了会被当未知元素渲染,样式整块丢、
编译与 lint 都不红)、
**`.svg` 资产是不是合法 XML**(第 ⑨ 项 —— SVG 注释里出现连续两个减号就 not well-formed,
图标变裂图而控制台一句话都不打;2026-10 换品牌标时连踩两次)、
**后端会写的日志动作码 ↔ PC 端 `OPERATION_ACTIONS` 的文案**(第 ⑩ 项 —— 缺一个文案,
日志页会**原样回显英文码**;做这一项时才发现 `AUTO_CLOSE` 是字符串字面量写的,
所以"提成常量"是让这个检查真正生效的前提)。
⚠️ 这一族错**编译 / lint / review 都不会红**(类在、表在、常量在、注释还写得很清楚),
只有"从入口走一遍"才暴露 —— 所以把它固化成命令;有意不接线的写在脚本上方的例外表里(带理由)。
⚠️ **检查器自己也会失效**:PC 端重搭后权限码的写法从 `permissions.includes` / `v-hasPerm`
改成了 `user.has`,第 ⑦ 项曾因此"一个码都扫不到却判为通过"。
加/改这类检查时**必须反向验证一次**(故意写错,它得红)
- 一条命令跑完上面这些:`python scripts/verify-all.py --with-e2e`
(9 项;**跳过项会如实列出来**,不是静默跳过)
## 四、接口与数据契约(不显然、容易做错)
@@ -125,7 +168,8 @@ python scripts/e2e-m3-file.py # 单跑一个;前提是后端 8080 在跑
## 五、环境与流程坑
- 本地 H5 走花生壳穿透(`http://394nb68658le.vicp.fun` → `192.168.3.139:8080`,
- 本地 H5 走花生壳穿透(`http://394nb68658le.vicp.fun` → `192.168.3.139:80`,
即 `xt-nginx`;**不是后端的 8080** —— 指到 8080 的表现是"接口好用、页面 404"。
配在 `application-dev.yml` 的 `mp-h5`)。**硬刷新后首屏常要 1~2 分钟**,
探测要给足,否则会把"还在加载"误判成失败
- 打桩登录(仅 dev:`fill.mp-h5.stub-enabled`,**生产必须 false**,代码里还会再判一次 profile):
@@ -145,7 +189,7 @@ python scripts/e2e-m3-file.py # 单跑一个;前提是后端 8080 在跑
- 不要提交:`backend/data/`、`dist/`、`application-local.yml`、`deploy/.env`
(都已在 `.gitignore`,但 `git add -A` 容易顺手带上 —— 先 `git status` 看一眼)
- `deploy/` 里**只有** `.env.example` 与 `docker-compose.yml` 被 git 跟踪
- `deploy/` 里被 git 跟踪的只有 `.env.example`、`docker-compose.yml` 与 `nginx/xt.conf`
(`*.pem` 在 ignore 里,`deploy/rsa-public-key-dev.pem` 是本地开发公钥)
- 改动接口 / 库表 / FR-VAL 编号 → 重跑 `python scripts/verify-docs.py`,
并同步 `docs/00-交付计划.md` 与 `docs/交接摘要.md`
+22 -19
View File
@@ -1,7 +1,7 @@
# 数据信息填报系统
# 深学在线
> 职业技能等级认定**报名材料收集 SaaS**
> **一期范围**:学校收集 + 自主认定(4 类材料,全部有实测样本)。社会收集 / 社会评价 / 专项职业能力后置二期
> **范围(2026-10 收敛)**:只做「学校收集 + 自主认定」(4 类材料,全部有实测样本)。社会收集 / 社会评价 / 专项职业能力 **不做**(传 `collectMode=2`、`certType=2,3` 返回 `40044`);**不设二期**
> 学生在微信小程序填报 · 班主任在手机初审 · 管理员在 PC 终审与导出
> 最后更新:2026-09-28 | **状态:需求已冻结(客户全部确认,可开工)**
@@ -28,7 +28,7 @@
| **产品 / 需求** | `数据信息填报系统PRD.md` → `数据信息填报系统PRD-功能逻辑卷.md` | 前者含库表接口,后者是纯功能逻辑 |
| **后端开发** | `03-接口设计.md` → `04-核心模块设计说明.md` → `db/schema-postgresql.sql` | 接口收发 → 引擎怎么做 → 建表 |
| **前端开发** | `prototype/` → `03-接口设计.md` | 原型是视觉与交互的**唯一基准** |
| **测试** | `06-验收测试清单.md` → `04-核心模块设计说明.md` 附录 B | 29 条 Golden 断言 + 85 条功能验收 |
| **测试** | `11-填报流程端到端测试计划.md`(填报链路 E2E)→ `06-验收测试清单.md` → `04-核心模块设计说明.md` 附录 B | 链路覆盖矩阵 + 判据口径;36 条 Golden 断言 + 111 条功能验收 |
| **新加入的人** | `交接摘要.md` → 本文件 | 一份文档看清全部状态 |
### 全部文档
@@ -38,15 +38,16 @@
| 00 | `00-交付计划.md` | 8 KB | 7 项计划与进度追踪、风险跟踪 | ✅ |
| 01 | `01-客户与机构确认函.md` | 18 KB | **23 项已全部确认** + 客户回执 + 落地动作清单 | ✅ **需求冻结** |
| 02 | `02-需求追溯矩阵.md` | 10 KB | 115 条 FR ↔ 原型/库表/接口 的自审报告 | ✅ |
| 03 | `03-接口设计.md` | 112 KB | **121 个接口**字段级定义、枚举全集、12 个 VO、**57 个错误码** | ✅ |
| 03 | `03-接口设计.md` | — | **119 个接口**字段级定义、枚举全集、12 个 VO、**54 个错误码** | ✅ |
| 04 | `04-核心模块设计说明.md` | 56 KB | **6 个模块**伪代码级说明 + 依赖图 + 26 项回归基线 | ✅ |
| 05 | `05-开发任务拆解.md` | 31 KB | **100 个任务 / 387 人日 / 15 周**,含关键路径、一期范围与风险 | ✅ |
| 06 | `06-验收测试清单.md` | 29 KB | **36 条 Golden 断言** + 111 条功能验收 + 签署表 | ✅ |
| 11 | `11-填报流程端到端测试计划.md` | 45 KB | **填报链路覆盖矩阵(A~J 十组)+ 判据速查 + 已定案缺陷 F-125~F-129**;配套 `scripts/e2e-m3-flow.py` | ✅ |
| — | `交接摘要.md` | 11 KB | **状态源**:客户回执、13 条铁律、已完成、待办、环境、教训 | ✅ |
| — | `数据信息填报系统PRD.md` | 190 KB | 主 PRD V2.3+:**125 条 FR**、**21 条 VAL**、**20 张表 DDL**、验收标准 | ✅ |
| — | `数据信息填报系统PRD-功能逻辑卷.md` | 88 KB | 纯功能运转逻辑(**不含库表/接口/代码**) | ✅ |
| — | `db/schema-postgresql.sql` | 36 KB | 可执行 PG DDL(20 表),**已在 PostgreSQL 16 实测零错误** | ✅ |
| — | `prototype/`(4 份) | 240 KB | **31 屏**交互原型:学生端 11 / 班主任端 9 / 管理端 11 | ✅ |
| — | `prototype/`(4 份) | — | **29 屏**交互原型:学生端 10 / 班主任端 9 / 管理端 10 | ✅ |
---
@@ -57,7 +58,7 @@
```
┌─ 学生端(小程序)─┐ ┌─ 班主任端(手机)─┐ ┌─ 管理端(PC)─┐
名单 ──→ 门禁核验 ──→ 填报 ──→ 卡片流初审 ──→ 终审 ──→ 导出规范包
└─✗ 失败 → 申请加入名单 → 管理员审批 → 入册放行
└─✗ 失败 → 只提示核对 + "请联系老师加你进名单"(老师手工加人)
▲ ▲ ▲ ▲
│ │ │ │
报考范围 识别 命名+压缩 汇总 Excel
@@ -95,9 +96,9 @@
| 滑动交互 | 班主任端**只做右滑通过**,驳回必须点按钮 |
| 批量通过 | **只带无问题项**,红黄一律排除 |
| 动态表单 | 内置字段(固定列可统计)+ 扩展字段(`extras_json`) |
| 名单外的人 | **申请加入名单 → 管理员逐条审批**;通道只解决"进名单",**不绕过门禁** |
| 名单外的人 | **不做自助通道**(2026-10 撤销):由老师在任务里**手工加人**(名册追加导入),**不绕过门禁** |
| 归属判定优先级 | **证件号反查名册 > OCR 内容 > 姓名(仅辅助,不做独立规则)** |
| 联系方式 | **系统内不展示任何班主任/管理员联系方式**——学生本来就有;门禁失败只给「重新填写 / 申请加入名单」两条系统内出路 |
| 联系方式 | **系统内不展示任何班主任/管理员联系方式**——学生本来就有;门禁失败只给「重新填写」一条系统内出路 + 一句"请联系老师" |
| 校验位的作用 | **失败 = 不是真实号码 → 可自动改**;**通过 = 真人 → 改了就是改身份 → 必须阻断** |
| 审批粒度 | 批量通过**不提供"全选"**,驳回**必须逐条填原因**(身份认定误操作代价极高) |
@@ -167,7 +168,9 @@ python check2.py docs/prototype/admin-web.html
## 六·二、把系统跑起来(三条命令)
```bash
# 1) 基础设施:PG 16 + Redis 7 + MinIO
# 1) 基础设施:PG 16 + Redis 7 + nginx(没有对象存储,MinIO 已删)
# nginx(:80)发学生端 H5 并把 /api 反代到后端 8080 —— 起它之前先
# `cd frontend-mp && pnpm build:h5`,否则它挂的是个空目录(页面 404)
cd deploy
copy .env.example .env # 首次
docker compose up -d
@@ -194,12 +197,12 @@ python scripts/e2e-m0.py
```
backend/ Java 21 + Spring Boot 4.1.1
├── xt-common/ 统一响应 / 58 个错误码 / AES-GCM / HMAC / RSA
└── xt-server/ 配置 / 鉴权 / 多租户拦截 / 业务模块
deploy/ docker-compose + RSA 开发公钥
scripts/ DDL 同步、登录助手、M0 验收测试
frontend-pc/ (待建)Vue 3 + Element Plus
frontend-mp/ (待建)uni-app
├── xt-common/ 统一响应 / 54 个错误码 / AES-GCM / HMAC / RSA
└── xt-server/ 配置 / 鉴权 / 多租户拦截 / 业务模块(26 张表,Flyway 已到 V19)
deploy/ docker-compose(PG/Redis/nginx)+ nginx/xt.conf(H5 站点)+ RSA 开发公钥
scripts/ E2E 验收(38 个脚本)、接线检查、DDL 同步、图标抽取
frontend-pc/ PC 管理端:**纯 Vue 3 + Naive UI**(pnpm dev → 5173)
frontend-mp/ 学生端 / 教师端:uni-app(H5 与微信小程序同一套代码)
```
### ⚠️ 搭环境前请先读
@@ -214,8 +217,8 @@ frontend-mp/ (待建)uni-app
| 阶段 | 内容 | 状态 |
|---|---|---|
| **需求** | 主 PRD(**125 FR** / 20 VAL / **20 张表**)+ 功能逻辑卷 | ✅ |
| **设计** | 数据库 DDL(PG 16 实测)+ 接口设计(**121**)+ 模块设计(6) | ✅ |
| **原型** | **31 屏**(学生 **11** / 班主任 9 / 管理 **11**)= 31 | ✅ |
| **设计** | 数据库 DDL(PG 16 实测)+ 接口设计(**119**)+ 模块设计(6) | ✅ |
| **原型** | **29 屏**(学生 **10** / 班主任 9 / 管理 **10**)= 29 | ✅ |
| **计划** | **100 任务 / 387 人日 / 15 周**(一期范围已收窄) | ✅ |
| **测试** | **36** 条 Golden 断言 + **111** 条功能验收 | ✅ |
| **待外部** | ~~客户确认函(23 项)~~ | ✅ **已确认,按建议执行** |
@@ -225,7 +228,7 @@ frontend-mp/ (待建)uni-app
| 项 | 确认结论 |
|---|---|
| **A1** | ✅ 认定机构接受 300dpi 输出 |
| **B1** | ✅ 增加「申请加入名单 → 管理员审批」通道(**唯一功能增量,已落地**) |
| **B1** | ⚠️ **已回退**(2026-10 需求收敛):加入申请通道整体撤销(模块/表/接口/页面/错误码一并删除)。它是确认函里唯一的功能增量,建议补变更确认单 |
| **B3** | ✅ 字段三分类:姓名/证件号/报考三项锁定,其余可核对修改并留痕 |
**其余 20 项均与现有设计一致,零改动。** 需求已冻结,可按 `05-开发任务拆解.md` 的 **M0** 开工。
@@ -257,7 +260,7 @@ frontend-mp/ (待建)uni-app
|---|---|
| 数据库 | PostgreSQL 16(DDL 已实测通过) |
| 开发顺序 | **M1 命名引擎 → M4 门禁 → M2 压缩 → M5 校验 → M3 识别 → M6 导出** |
| 客户回执 | 全部 23 项按建议执行;唯一功能增量 B1(申请加入名单)已落地 |
| 客户回执 | 全部 23 项按建议执行;唯一功能增量 B1(申请加入名单)**已于 2026-10 需求回退取消** |
| 原型约束 | 单文件自包含、零外部依赖、内联 SVG 图标、**无 emoji** |
| 最长的两个单点 | 压缩引擎 12 人日、打包引擎 10 人日(**加人无效**) |
| 回归门禁 | 改动后必须复现 **15 项基线指标**(对应 29 条断言),偏差即回归 |
+1 -1
View File
@@ -15,7 +15,7 @@
<artifactId>fill-parent</artifactId>
<version>1.0.0-SNAPSHOT</version>
<packaging>pom</packaging>
<name>数据信息填报系统</name>
<name>深学在线</name>
<description>职业技能等级认定 · 报名材料收集 SaaS</description>
<modules>
@@ -5,7 +5,7 @@ import lombok.Getter;
/**
* 错误码全集。
*
* <p>权威来源:《03-接口设计》§15「错误码全集」,共 <b>58 个</b>(含成功码 {@code 0})。
* <p>权威来源:《03-接口设计》§15「错误码全集」,共 <b>59 个</b>(含成功码 {@code 0})。
* <b>改这里之前先改文档,不要只改代码。</b>
*
* <p><b>为什么 code 是 int 而不是 String</b>:§1.3 约定响应形如
@@ -101,22 +101,14 @@ public enum ErrorCode {
INVALID_FILES_BLOCK_EXPORT(40035, "存在不合格文件,禁止导出"),
/** 字典项有下级,不可停用 */
DICT_HAS_CHILDREN(40036, "字典项有下级,不可停用"),
/** 加入申请次数超限 */
JOIN_REQUEST_LIMIT(40037, "加入申请次数已达上限"),
/** 该证件号已有待审申请 */
JOIN_REQUEST_DUPLICATE(40038, "你已提交过申请,请耐心等待"),
/** 该证件号已在名册中 */
ALREADY_IN_ROSTER(40039, "你已在名单中,请返回重新核验"),
/** 申请状态已变更(非待审) */
JOIN_STATUS_CHANGED(40040, "申请状态已变更"),
/** 名册中该证件号已被占用 */
ROSTER_ID_CARD_TAKEN(40041, "名册中该证件号已被占用"),
/** 申请已通过,不可再驳回 */
JOIN_ALREADY_APPROVED(40042, "申请已通过,不可再驳回"),
/** 文件归属存疑,阻断提交且无「仍然继续」 */
FILE_OWNERSHIP_DOUBT(40043, "这份材料可能不是你的,请重新上传"),
/** 一期不支持该收集模式 / 认定类型 */
SCOPE_NOT_SUPPORTED(40044, "一期仅支持学校收集 + 自主认定"),
/** 角色还有账号在用,不可删除 */
ROLE_IN_USE(40045, "该角色还有账号在用,请先改掉这些账号的角色"),
// ── 认证与权限 ────────────────────────────────
/** 未登录 / token 失效(真 HTTP 401) */
@@ -146,7 +138,7 @@ public enum ErrorCode {
STORAGE_FAILED(50007, "存储写入失败,请重传"),
// ── 兜底 ──────────────────────────────────────
/** 系统内部错误(不在文档的 58 个码内,仅用于未预期异常兜底) */
/** 系统内部错误(不在文档的 59 个码内,仅用于未预期异常兜底) */
INTERNAL_ERROR(50000, "系统繁忙,请稍后再试");
/** 业务码。0 表示成功;4xxxx 客户端;5xxxx 服务端。 */
@@ -30,6 +30,28 @@ public record LoginUser(
return tenantId != null && tenantId == PLATFORM_TENANT_ID;
}
/**
* 这个人是不是<b>平台运营</b> —— 包括"正在代管某所学校"的那种。
*
* <p>⚠️ 与 {@link #isPlatformAdmin()} 的区别非常重要,两者回答的是不同的问题:
* <ul>
* <li>{@code isPlatformAdmin()} = "当前上下文的租户<b>就是平台租户</b>" ——
* 它是租户拦截器要不要<b>整体关掉过滤</b>的开关(跨租户看数据);</li>
* <li>{@code isPlatformStaff()} = "这个人<b>是平台运营</b>" ——
* 数据范围是全量,但**租户过滤按当前上下文**照常生效。</li>
* </ul>
*
* <p>代管某所学校时(请求头 {@code X-Tenant-Id},见 {@code ActingTenantResolver})
* tenantId 被换成那所学校,于是 {@code isPlatformAdmin()} 变成 false。
* 这时如果各处只判前者,平台就会**退化成"某个普通用户"**:例如任务列表会走
* "班主任只看自己带的班"那条分支,而平台在该校没有班级 → 空列表(看起来像"这所学校没有任务")。
*
* <p>所以凡是"按身份决定数据范围"的判断,用这一个;凡是"决定要不要关掉租户过滤"的,用前者。
*/
public boolean isPlatformStaff() {
return isPlatformAdmin() || hasRole("PLATFORM_ADMIN");
}
public boolean hasRole(String roleCode) {
return roles != null && roles.contains(roleCode);
}
@@ -46,9 +68,24 @@ public record LoginUser(
return false;
}
/**
* 这个人是不是<b>租户级管理员</b>({@code user_type = 2},即学校 / 机构管理员)。
*
* <p>⚠️ 判 {@code user_type} 而<b>不判角色码</b>。上一版这里写的是
* {@code hasAnyRole("SCHOOL_ADMIN", "AUDITOR")},有两个问题:
* <ol>
* <li>「审核员」({@code AUDITOR})角色已删除;</li>
* <li>租户可以自建角色,而"自定义角色 + {@code user_type=2}"同样是租户管理员 ——
* 按角色码判会让这批人退化成普通用户(表现是"什么都点不动",且不报任何错)。</li>
* </ol>
*/
public boolean isTenantAdmin() {
return userType != null && userType == 2;
}
/** 数据范围是否覆盖指定组织。平台运营与租户管理员恒为 true。 */
public boolean coversOrg(Long orgId) {
if (isPlatformAdmin() || hasAnyRole("SCHOOL_ADMIN", "AUDITOR")) {
if (isPlatformAdmin() || isTenantAdmin()) {
return true;
}
return orgId != null && orgIds != null && orgIds.contains(orgId);
@@ -18,6 +18,7 @@ import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException;
import org.springframework.web.multipart.MaxUploadSizeExceededException;
import org.springframework.web.servlet.NoHandlerFoundException;
import org.springframework.web.servlet.resource.NoResourceFoundException;
import java.util.ArrayList;
@@ -107,6 +108,26 @@ public class GlobalExceptionHandler {
.body(ApiResult.fail(ErrorCode.VALIDATION_BLOCKED, "接口不存在"));
}
/**
* 没有任何 handler 能匹配这个路径。
*
* <p><b>⚠️ 为什么和上面那个分开写</b>:两者对客户端是同一件事(404),触发条件却不同 ——
* {@link NoResourceFoundException} 由<b>静态资源处理器</b>抛出(映射在,文件不在),
* 而本异常是<b>连 handler 都没有</b>时由 {@code DispatcherServlet.noHandlerFound} 抛出。
*
* <p>2026-10 关掉静态资源映射({@code spring.web.resources.add-mappings=false},
* H5 改由 nginx 托管)之后,落到这里的情况变多了:以前 {@code /**} 上挂着资源处理器,
* 任何路径都有 handler,所以走的是上面那条;现在凡是没匹配上 Controller 的路径
* (比如老的 H5 地址 {@code /}、{@code /assets/xxx.js})都走这条。
* 当时没补这条的表现是:<b>访问一个不存在的路径得到 500「系统繁忙」</b>,
* 还在日志里刷 {@code ERROR 未预期异常} —— 一个正常的 404 被报成了服务端故障。
*/
@ExceptionHandler(NoHandlerFoundException.class)
public ResponseEntity<ApiResult<Void>> handleNoHandler(NoHandlerFoundException e) {
return ResponseEntity.status(HttpStatus.NOT_FOUND)
.body(ApiResult.fail(ErrorCode.VALIDATION_BLOCKED, "接口不存在"));
}
@ExceptionHandler(MaxUploadSizeExceededException.class)
public ResponseEntity<ApiResult<Void>> handleUploadTooLarge(MaxUploadSizeExceededException e) {
return ResponseEntity.ok(ApiResult.fail(ErrorCode.FILE_TOO_LARGE));
@@ -0,0 +1,50 @@
package cn.tianbaotong.fill.support;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.core.Ordered;
import org.springframework.core.annotation.Order;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;
import java.io.IOException;
/**
* 接口响应禁止缓存:{@code /api/**} → {@code Cache-Control: no-store}。
*
* <h3>为什么必须有这个类</h3>
* 接口响应一旦被某个中间层缓存,就会出现"刚提交完再进去看还是旧状态"——
* 微信 webview、运营商代理、企业网关都可能干这件事,而且<b>只在真机/穿透上复现</b>,
* 本机开发机上永远看不到。所以这里显式声明,不指望中间层"默认不缓存"。
*
* <h3>它现在只管接口了(2026-10 架构调整)</h3>
* 它曾经还管静态资源({@code /assets/**} 永久缓存、{@code *.html} 每次回源),
* 那时 H5 是<b>后端托管</b>的({@code spring.web.resources.static-locations})。
* 现在 H5 由 nginx 提供({@code deploy/nginx/xt.conf},与 {@code /api} 同源),
* 所以<b>静态资源的缓存策略整体归 nginx</b> —— 静态文件的事由静态服务器负责,
* 拆成两处只会"改一处漏一处"。后端因此退回纯 API 服务
* ({@code spring.web.resources.add-mappings=false},见 {@code application.yml})。
*
* <p>⚠️ 但这段历史不是废话,它解释了 nginx 那份配置里 <b>为什么 {@code /assets/}
* 与 {@code *.html} 必须显式写缓存头</b>:没有显式过期时间时,浏览器会启用
* <b>启发式缓存</b>(RFC 9111 §4.2.2:按 {@code Last-Modified} 年龄的 10% 自行推断保鲜期),
* 后果是<b>发版后学生继续打开旧页面</b>(旧 {@code index.html} 里写死了旧 chunk 名),
* 而且只在"已经打开过页面的设备"上复现,开发机上硬刷新一次就没了,极易被当成偶发。
* 搬配置时别把这两条丢掉。
*/
@Component
@Order(Ordered.HIGHEST_PRECEDENCE)
public class ApiNoStoreFilter extends OncePerRequestFilter {
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response,
FilterChain chain) throws ServletException, IOException {
// 只有接口响应禁止缓存。静态资源(H5)不经过本服务,走 nginx。
if (request.getRequestURI().startsWith("/api/")) {
response.setHeader("Cache-Control", "no-store");
}
chain.doFilter(request, response);
}
}
@@ -1,65 +0,0 @@
package cn.tianbaotong.fill.support;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.core.Ordered;
import org.springframework.core.annotation.Order;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;
import java.io.IOException;
/**
* 静态资源的缓存策略 + 接口响应禁止缓存。
*
* <h3>为什么必须有这个类</h3>
* 没有它的时候,静态资源只有 {@code Last-Modified}、<b>没有 {@code Cache-Control}、没有 {@code ETag}</b>。
* 这会触发浏览器的 <b>启发式缓存</b>(RFC 9111 §4.2.2:没有显式过期时间时,
* 浏览器按 {@code Last-Modified} 年龄的 10% 自行推断一个保鲜期)。
*
* <p>后果不是"慢一点",而是<b>发版后学生看到旧页面</b>:
* 旧 {@code index.html} 被缓存 → 它里面写死了旧 {@code /assets/index-<旧哈希>.js} →
* 学生打开的是我们上个月的那一版,而我们在后台怎么改都没用。
* 排查这个现象极其费时 —— 因为它<b>只在"已经打开过页面的设备"上复现</b>,
* 开发机上硬刷新一次就没了,于是很容易被当成"偶发"。
*
* <p>实测踩过:材料上传的前端界面明明已构建、chunk 也已正确下发
* (从 8080 下载 9606 字节,与磁盘文件 SHA-256 一致、里面确实有那段代码),
* 但浏览器执行的是旧 chunk —— 页面里就是没有材料区。
*
* <h3>策略</h3>
* <ul>
* <li>{@code /assets/**}:文件名带内容哈希,内容变了名字就变,
* 所以可以<b>永久缓存</b>({@code immutable})。这是唯一的性能红利所在:
* 这些包加起来好几 MB,学生不该每次发版都重下。</li>
* <li>{@code *.html} 与 {@code /}:<b>每次都必须回源校验</b>({@code no-cache})。
* 注意 {@code no-cache} ≠ 不缓存,而是"用之前先问一下"——
* 配合 {@code Last-Modified} 会走 304,只花一个往返、不重传正文,这是对的。</li>
* <li>{@code /api/**}:{@code no-store}。接口响应<b>绝不能</b>被缓存 ——
* 微信 webview 里"刚提交完再进去看还是旧状态"这种问题,
* 十有八九是某个中间层把 GET 响应缓存了。</li>
* </ul>
*/
@Component
@Order(Ordered.HIGHEST_PRECEDENCE)
public class StaticCacheHeaderFilter extends OncePerRequestFilter {
/** 一年。带内容哈希的资源可以按"永久"对待。 */
private static final String IMMUTABLE = "public, max-age=31536000, immutable";
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response,
FilterChain chain) throws ServletException, IOException {
String uri = request.getRequestURI();
if (uri.startsWith("/assets/")) {
response.setHeader("Cache-Control", IMMUTABLE);
} else if (uri.startsWith("/api/")) {
response.setHeader("Cache-Control", "no-store");
} else if (uri.equals("/") || uri.endsWith(".html")) {
response.setHeader("Cache-Control", "no-cache");
}
chain.doFilter(request, response);
}
}
@@ -7,7 +7,7 @@ import org.springframework.boot.context.properties.ConfigurationPropertiesScan;
import org.springframework.scheduling.annotation.EnableScheduling;
/**
* 数据信息填报系统 · 主应用。
* 深学在线 · 主应用。
*
* <p>一期范围:<b>学校收集 + 自主认定</b>(见 PRD「一期范围声明」)。
* 社会收集 / 社会评价 / 专项职业能力的数据结构与枚举保留但不启用,传入返回 {@code 40044}。
@@ -2,6 +2,7 @@ package cn.tianbaotong.fill.config;
import org.springframework.boot.context.properties.ConfigurationProperties;
import java.util.List;
import java.util.Map;
/**
@@ -25,6 +26,7 @@ import java.util.Map;
* @param miniProgram 微信小程序(填报二维码)
* @param mpH5 H5 填报端(网页授权)
* @param tenantDomain 租户域名(用子域前缀区分租户,如 t1.xxx.com)
* @param cors 跨域白名单
*/
@ConfigurationProperties(prefix = "fill")
public record FillProperties(
@@ -36,9 +38,46 @@ public record FillProperties(
Storage storage,
MiniProgram miniProgram,
MpH5 mpH5,
TenantDomain tenantDomain
TenantDomain tenantDomain,
Cors cors
) {
/**
* 跨域白名单(Spring 的 {@code allowedOriginPatterns} 语义:可写
* {@code http://localhost:*}、{@code https://*.example.com})。
*
* <h3>⚠️ "本站自己的地址"也必须在这里 —— 这条最反直觉,但它是确定的</h3>
*
* <p>浏览器的 CORS <b>只约束跨源请求</b>,可它<b>给同源的 POST/PUT/DELETE 也带
* {@code Origin} 头</b>(GET 不带)。而 Spring MVC 的判据是"请求里有没有 Origin",
* <b>不是</b>"是不是真跨源":只要带了 Origin 又不在白名单里,直接回
* {@code 403 Invalid CORS request}({@code DefaultCorsProcessor#processRequest},
* Spring Framework 7.0.9 里<b>没有</b>同源短路)。
*
* <p>于是"前后端同源部署 = 不需要 CORS"是错的:登录、上传、提交全是 POST,
* 全都会被这一条拒掉。更麻烦的是它<b>只在浏览器里复现</b> ——
* 所有 E2E 脚本都是 Python,不带 Origin 头,所以测试全绿
* (2026-10 生产事故,见 {@code docs/交接摘要.md} §一·四十四)。
*
* <p>所以配置要按"浏览器地址栏里会出现哪些源"来写,而不是按"谁会跨域调我"来写。
*
* @param allowedOrigins 允许的源(pattern)。生产写真实域名,
* 换域名/加域名时改这里或给 {@code XT_CORS_ALLOWED_ORIGINS}
*/
public record Cors(List<String> allowedOrigins) {
/** 本机开发用的兜底值(base 的 {@code application.yml} 里也有一份,这里防 null)。 */
public static final List<String> DEFAULTS = List.of(
"http://localhost:*",
"http://127.0.0.1:*",
"http://192.168.*.*:*");
/** 没配时退回本机开发值;**生产不许为空**,否则同源 POST 会 403。 */
public List<String> patternsOrDefault() {
return allowedOrigins == null || allowedOrigins.isEmpty() ? DEFAULTS : allowedOrigins;
}
}
/**
* 租户域名:用<b>子域前缀</b>区分租户({@code t1.xxx.com} / {@code t2.xxx.com})。
*
@@ -26,7 +26,7 @@ public class OpenApiConfig {
public OpenAPI fillOpenApi() {
return new OpenAPI()
.info(new Info()
.title("数据信息填报系统 API")
.title("深学在线 API")
.version("1.0.0")
.description("""
职业技能等级认定 · 报名材料收集 SaaS
@@ -66,7 +66,7 @@ public class StorageConfig {
*/
private void selfCheck(QiniuStorageService svc) {
String path = "_selfcheck/" + System.currentTimeMillis() + ".txt";
byte[] payload = "填报通存储自检 ok".getBytes(StandardCharsets.UTF_8);
byte[] payload = "深学在线存储自检 ok".getBytes(StandardCharsets.UTF_8);
try {
svc.put(path, payload, "text/plain; charset=utf-8");
if (!svc.exists(path)) {
@@ -2,11 +2,14 @@ package cn.tianbaotong.fill.config;
import cn.tianbaotong.fill.security.AuthInterceptor;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.CorsRegistry;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
import java.util.List;
/**
* Web 层装配。
*
@@ -14,11 +17,13 @@ import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
* {@code /doc.html}、{@code /v3/api-docs/**} 不走业务鉴权,
* 它们由各自的机制保护(actuator 只在内网暴露,文档在生产环境关闭)。
*/
@Slf4j
@Configuration
@RequiredArgsConstructor
public class WebConfig implements WebMvcConfigurer {
private final AuthInterceptor authInterceptor;
private final FillProperties props;
@Override
public void addInterceptors(InterceptorRegistry registry) {
@@ -29,25 +34,38 @@ public class WebConfig implements WebMvcConfigurer {
}
/**
* 跨域配置。
* 跨域配置(白名单来自 {@code fill.cors.allowed-origins})。
*
* <p><b>⚠️ 生产环境必须收紧 {@code allowedOriginPatterns}</b>——写 {@code *} 配合
* {@code allowCredentials} 在浏览器规范下无效,而改成 {@code originPatterns} 放行
* 全部则等于取消同源策略。生产配置见 {@code application-prod.yml} 里的
* {@code fill.cors.allowed-origins}。
* <h3>⚠️ 为什么"同源部署"也离不开这张白名单</h3>
*
* <p>浏览器的 CORS 只约束跨源请求,但<b>同源的 POST/PUT/DELETE 也会带
* {@code Origin} 头</b>;而 Spring MVC 只看"有没有 Origin",不看"是不是真跨源",
* 不在白名单里就回 {@code 403 Invalid CORS request}。登录、上传、提交全是 POST ——
* 于是<b>本站自己的地址必须列在这里</b>,否则"前后端同源"这个部署形态反而一定登不上。
*
* <p>这条曾经写死成 localhost/内网,生产域名 {@code https://admin.shenxue.jxit.com}
* 不在里面,表现就是"接口地址明明通、POST 一律 403",而后端日志里一个字都没有
* (403 是 CORS 处理器在进 Controller 之前回的)。详见 {@code docs/交接摘要.md} §一·四十四。
*
* <p>⚠️ 启动时把生效的白名单打出来(见下面的 info 日志):这类事故里最先要看的就是
* "到底放了谁",而白名单藏在配置里、又只在浏览器里才生效,不打印就只能猜。
*/
@Override
public void addCorsMappings(CorsRegistry registry) {
FillProperties.Cors cors = props.cors();
List<String> patterns = cors == null
? FillProperties.Cors.DEFAULTS
: cors.patternsOrDefault();
registry.addMapping("/api/**")
.allowedOriginPatterns(
"http://localhost:*",
"http://127.0.0.1:*",
"http://192.168.*.*:*"
)
.allowedOriginPatterns(patterns.toArray(String[]::new))
.allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS")
.allowedHeaders("*")
.exposedHeaders("X-Trace-Id")
.allowCredentials(true)
.maxAge(3600);
log.info("CORS 白名单(/api/**):{} —— 浏览器的同源 POST 也会校验,"
+ "部署域名必须在这里,否则登录会 403「Invalid CORS request」", patterns);
}
}
@@ -10,18 +10,17 @@ import java.util.List;
* 审核范围:<b>"这个人能审哪些班"</b>。
*
* <p>《03-接口设计》§7.6 定了覆盖班级与初审人的关系({@code collect_task_org}),
* 这里把它翻译成一次查询。三级回退的顺序是<b>刻意的</b>:
* 这里把它翻译成一次查询。两级回退的顺序是<b>刻意的</b>:
*
* <ol>
* <li>{@code head_user_id} —— 任务里为该班指定的初审人(最具体,优先)</li>
* <li>{@code deputy_user_ids} —— 副班主任 / 协办人</li>
* <li>{@code sys_org.leader_user_id} —— 任务没指定时,回退到班级负责人的身份</li>
* </ol>
*
* <p>⚠️ <b>{@code deputy_user_ids} 用 {@code LIKE '%,3,%'} 匹配,这是有前提的</b>:
* 库里存的是**首尾都带逗号**的形式({@code ,3,7,})。裸逗号分隔的 {@code 3,17}
* 会被 {@code %,3,%} 误命中 —— 表现是"莫名其妙的协办人能看到别人的班"。
* 写入端在 {@code TaskOrgService.joinIds},两处必须一致。
* <p>⚠️ 这里以前还有一级 {@code deputy_user_ids}(副班主任 / 协办人)。
* 该字段与副班主任角色已一并删除,所以**一个班只有一个初审人**;
* 需要多人协审时,正确做法是给其中一人指定为该班初审人,
* 而不是再引入一个"协办人"字段。
*
* <p>⚠️ 本章手写 SQL 关掉了租户拦截器,<b>隔离条件由本方法自己保证</b>
* (每个表都显式带了 {@code tenant_id})。这是本仓库对"关掉框架保护"的统一要求:
@@ -48,7 +47,6 @@ public interface AuditScopeMapper {
</if>
AND (
cto.head_user_id = #{userId}
OR cto.deputy_user_ids LIKE ('%,' || #{userId} || ',%')
OR (cto.head_user_id IS NULL AND o.leader_user_id = #{userId})
)
ORDER BY cto.org_id
@@ -72,7 +70,6 @@ public interface AuditScopeMapper {
AND cto.status = 1
AND (
cto.head_user_id = #{userId}
OR cto.deputy_user_ids LIKE ('%,' || #{userId} || ',%')
OR (cto.head_user_id IS NULL AND o.leader_user_id = #{userId})
)
""")
@@ -102,7 +102,6 @@ public interface AuditTodoMapper {
AND cto.status = 1
AND (
cto.head_user_id = #{auditorId}
OR cto.deputy_user_ids LIKE ('%,' || #{auditorId} || ',%')
OR (cto.head_user_id IS NULL AND so.leader_user_id = #{auditorId})
)
)
@@ -193,7 +192,6 @@ public interface AuditTodoMapper {
AND cto.status = 1
AND (
cto.head_user_id = #{auditorId}
OR cto.deputy_user_ids LIKE ('%,' || #{auditorId} || ',%')
OR (cto.head_user_id IS NULL AND so.leader_user_id = #{auditorId})
)
)
@@ -761,15 +761,99 @@ public class AuditService {
}
boolean finalPass = newStatus == CollectSubmission.STATUS_APPROVED;
String title = finalPass ? "你的材料已通过审核" : "初审已过,等待终审";
String content = finalPass
? "本次认定的材料已收妥,无需再操作。"
: "班主任已通过你的材料,接下来由管理员终审。";
safeNotify(NotificationService.NotifyRequest.of(
sub.getSubmitterId(),
finalPass ? CollectMessage.BIZ_APPROVE : CollectMessage.BIZ_TASK_PUBLISH,
title, content,
// ⚠️ bizType 用 4(审核通过),**不是** 1(任务发布):
// 1 是"有新任务了",而"你的材料过审了"是审核进展。
// 以前这里借用了 1,于是消息中心按"任务发布"筛会筛出初审通过(《08》F-105)。
// 文案仍按节点区分 —— "等待终审"与"已通过"是两件不同的事,
// 用同一句话会让他以为已经结束了。
safeNotify(NotificationService.NotifyRequest.ofTemplate(
sub.getSubmitterId(), CollectMessage.BIZ_APPROVE,
"AUDIT_PASS",
NotificationService.NotifyRequest.vars(
"任务名", taskNameOf(sub.getTaskId()),
"姓名", nameOfSubmission(sub)),
title,
CollectMessage.LINK_SUBMISSION, sub.getId(),
finalPass ? NotificationService.TPL_AUDIT_RESULT : NotificationService.TPL_SUBMIT_SUCCESS));
finalPass ? NotificationService.TPL_AUDIT_RESULT : NotificationService.TPL_SUBMIT_SUCCESS)
.withExtra(finalPass ? "本次认定的材料已收妥,无需再操作。"
: "班主任已通过你的材料,接下来由管理员终审。"));
}
/** 任务名(模板变量用;查不到就给个不空的兜底)。 */
private String taskNameOf(Long taskId) {
if (taskId == null) {
return "本次填报";
}
CollectTask t = taskMapper.selectById(taskId);
return t == null || t.getTaskName() == null ? "本次填报" : t.getTaskName();
}
/**
* 姓名(模板变量用)。
*
* <p>**先取名册上的名字**:那才是"他在这个任务里叫什么"(老师导入的名单),
* 而账号里的 {@code real_name} 可能还没填(学生扫码核验时只建了账号)。
* 两个都没有就不编 —— 模板里那个变量渲染成空(见 {@code NotifyRequest.vars})。
*/
private String nameOfSubmission(CollectSubmission sub) {
if (sub == null) {
return null;
}
if (sub.getRosterId() != null) {
CollectRoster r = rosterMapper.selectById(sub.getRosterId());
if (r != null && r.getRealName() != null && !r.getRealName().isBlank()) {
return r.getRealName();
}
}
return nameOf(sub.getSubmitterId());
}
/** 姓名(从账号取,兜底)。 */
private String nameOf(Long uid) {
if (uid == null) {
return null;
}
SysUser u = userMapper.selectById(uid);
return u == null ? null : u.getRealName();
}
/** 被驳回的材料名(模板的 {材料名}):多个就用顿号连起来。 */
private String materialTextOf(List<String> codes) {
if (codes == null || codes.isEmpty()) {
return "全部材料";
}
return String.join("、", codes);
}
/**
* 驳回次数达上限:**通知该校的管理员**(契约 §11.4「同时通知管理员」)。
*
* <p>《08》F-105:这里原来只有一行 {@code log.warn},而注释写着"站内信也发给管理员" ——
* 于是"需要管理员介入"这件事**没有任何人知道**(学生看到的是被驳回、
* 班主任看到的是"已达上限",而管理员什么都没看到)。日志不是通知。
*/
private void notifyEscalated(CollectSubmission sub, String reason) {
try {
var admins = userMapper.userIdsOfRole(UserContext.tenantId(),
cn.tianbaotong.fill.security.BuiltinRole.SCHOOL_ADMIN.getCode());
if (admins.isEmpty()) {
log.warn("需要管理员介入但没有管理员账号可通知:submission={}", sub.getId());
return;
}
for (Long uid : admins) {
safeNotify(NotificationService.NotifyRequest.ofTemplate(
uid, CollectMessage.BIZ_REJECT,
"AUDIT_ESCALATE",
NotificationService.NotifyRequest.vars(
"任务名", taskNameOf(sub.getTaskId()),
"姓名", nameOfSubmission(sub),
"驳回原因", reason),
"有一条填报单需要你介入",
CollectMessage.LINK_SUBMISSION, sub.getId(), null));
}
} catch (RuntimeException e) {
log.warn("升级通知管理员失败(不影响驳回结果):submission={}", sub.getId(), e);
}
}
/**
@@ -946,10 +1030,18 @@ public class AuditService {
boolean pushSent = false;
if (sub.getSubmitterId() != null) {
try {
var r = notificationService.notify(NotificationService.NotifyRequest.of(
// 文案走模板(FR-1002):管理员在「通知模板」页改了「被退回」那句,
// 学生收到的就是改过的那句 —— 而不是这里硬编码的旧文案(《08》F-101)。
// 提交进度/材料名这些模板表达不了的细节,用 extra 追加。
var r = notificationService.notify(NotificationService.NotifyRequest.ofTemplate(
sub.getSubmitterId(), CollectMessage.BIZ_REJECT,
"AUDIT_REJECT",
NotificationService.NotifyRequest.vars(
"任务名", taskNameOf(sub.getTaskId()),
"驳回原因", fullText,
"材料名", materialTextOf(req.getMaterialCodes()),
"姓名", nameOfSubmission(sub)),
"你的材料被退回了",
fullText,
CollectMessage.LINK_SUBMISSION, sub.getId(),
NotificationService.TPL_AUDIT_RESULT));
pushSent = r.pushSent();
@@ -959,9 +1051,10 @@ public class AuditService {
}
if (escalated) {
// 驳回次数达上限:这条留给管理员,所以**站内信也发给管理员**。
// 驳回次数达上限:这条留给管理员,所以**也通知管理员**(不只是写日志)。
log.warn("驳回次数已达上限,需要管理员介入:submission={} 学生={} 第 {} 次驳回 原因={}",
submissionId, sub.getRosterId(), rejectCount, code);
notifyEscalated(sub, fullText);
}
log.info("审核驳回:submission={} node={} 第 {} 次 原因={} 微信推送={}",
submissionId, node, rejectCount, code, pushSent);
@@ -158,6 +158,13 @@ public class AuthService {
Set.copyOf(roleCodes),
user.getUserType());
// ⚠️ 把上下文**升级成完整的 principal**(上面那个是"只有租户、没有角色"的临时的)。
// 必须在这里做:下面的 buildProfile 会用 PermissionChecker 装配菜单与权限,
// 而它是**从上下文里的 roles 算的** —— 上下文还是空角色的话,
// 管理员登录响应里的 permissions 会是空数组,前端于是把所有按钮都藏起来
// (现象是"刚登录什么都没有,刷新一下又好了")。
UserContext.set(principal);
String token = jwtService.issue(principal, cn.tianbaotong.fill.security.JwtService.Client.PC);
SysUser touch = new SysUser();
@@ -183,9 +190,9 @@ public class AuthService {
throw new BizException(ErrorCode.UNAUTHORIZED);
}
List<String> roleCodes = userRoleMapper.selectRoleCodesByUserId(user.getId());
LoginVO vo = buildProfile(user, roleCodes);
vo.setPermissions(permissionChecker.currentPermissions());
return vo;
// 权限与菜单由 buildProfile 统一装配(含自定义角色的授权),这里不再单独覆盖一次 ——
// 两处各写一份的话,登录响应与 /auth/me 迟早会不一致,而那种不一致只在"刷新一下就好了"里表现
return buildProfile(user, roleCodes);
}
// ── 修改自己的密码 ─────────────────────────────
@@ -358,8 +365,13 @@ public class AuthService {
}
vo.setRoleCodes(roleCodes);
vo.setMenus(BuiltinRole.mergeMenus(roleCodes));
vo.setPermissions(BuiltinRole.mergePermissions(roleCodes));
// ⚠️ 菜单与权限都走 PermissionChecker,**不要**直接取 BuiltinRole:
// 二期的自定义角色,它的授权在库里(sys_role_permission / sys_role_menu),
// 直接取 BuiltinRole 会让"自定义角色的人登录后左侧菜单是空的"——
// 而登录响应里的这两项前端会直接落库用(省一次 /auth/me 往返),
// 所以漏在这里就等于"要刷新一次才看得见菜单"。
vo.setMenus(permissionChecker.currentMenus());
vo.setPermissions(permissionChecker.currentPermissions());
return vo;
}
@@ -1,155 +0,0 @@
package cn.tianbaotong.fill.module.cleanup.controller;
import cn.tianbaotong.fill.common.api.ApiResult;
import cn.tianbaotong.fill.module.cleanup.dto.CleanupDtos;
import cn.tianbaotong.fill.module.cleanup.service.CleanupImportService;
import cn.tianbaotong.fill.module.cleanup.service.CleanupService;
import cn.tianbaotong.fill.security.PermissionDef;
import cn.tianbaotong.fill.security.RequirePerm;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.multipart.MultipartFile;
import java.io.IOException;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import java.util.List;
import java.util.Map;
/**
* 历史数据整理器(《03-接口设计》§13.2)—— 差异化获客功能。
*
* <p>权限用 {@code task:manage}:整理出来的东西最终会变成正式任务(名册 + 材料),
* 与"创建与配置收集任务"是同一批人的职责。
*/
@Tag(name = "历史数据整理器")
@RestController
@RequestMapping("/api/v1/cleanup/jobs")
@RequiredArgsConstructor
public class CleanupController {
private final CleanupService cleanupService;
private final CleanupImportService importService;
@PostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
@RequirePerm(PermissionDef.TASK_MANAGE)
@Operation(summary = "创建整理任务(上传压缩包)",
description = "用**与正式收集完全相同的规则**做规范化。"
+ "⚠️ `rosterTaskId` 是**关键参数**:没有名册就只能做文件名规范化;"
+ "**有名单才能做证件号校验、疑似串档检测** —— 而「疑似串档 1 例」"
+ "正是报告里最有价值的一条。"
+ "一期只支持 zip(云盘链接与文件夹上传返回 40044)")
public ApiResult<CleanupDtos.CreateVO> create(
@RequestParam String jobName,
@RequestParam(defaultValue = "1") Integer sourceType,
@RequestParam(value = "file", required = false) MultipartFile file,
@RequestParam(required = false) String namingTemplate,
@RequestParam(required = false) Long rosterTaskId) throws IOException {
CleanupDtos.CreateReq req = new CleanupDtos.CreateReq();
req.setJobName(jobName);
req.setSourceType(sourceType);
req.setNamingTemplate(namingTemplate);
req.setRosterTaskId(rosterTaskId);
return ApiResult.ok(cleanupService.create(req,
file == null ? null : file.getBytes()));
}
@PostMapping(value = "/json", consumes = MediaType.APPLICATION_JSON_VALUE)
@RequirePerm(PermissionDef.TASK_MANAGE)
@Operation(summary = "创建整理任务(JSON 形态,供脚本/联调使用)",
description = "与 multipart 那个等价,只是把压缩包换成 base64 传 —— "
+ "方便自动化脚本与排障,不必手搓 multipart")
public ApiResult<CleanupDtos.CreateVO> createJson(@RequestBody Map<String, Object> body)
throws IOException {
CleanupDtos.CreateReq req = new CleanupDtos.CreateReq();
req.setJobName(body.get("jobName") == null ? null : String.valueOf(body.get("jobName")));
req.setSourceType(body.get("sourceType") == null ? 1
: Integer.valueOf(String.valueOf(body.get("sourceType"))));
req.setNamingTemplate(body.get("namingTemplate") == null ? null
: String.valueOf(body.get("namingTemplate")));
req.setRosterTaskId(body.get("rosterTaskId") == null ? null
: Long.valueOf(String.valueOf(body.get("rosterTaskId"))));
String b64 = body.get("zipBase64") == null ? null : String.valueOf(body.get("zipBase64"));
byte[] bytes = b64 == null || b64.isBlank()
? null : java.util.Base64.getDecoder().decode(b64);
return ApiResult.ok(cleanupService.create(req, bytes));
}
@GetMapping
@RequirePerm(PermissionDef.TASK_VIEW)
@Operation(summary = "整理任务列表")
public ApiResult<CleanupDtos.JobPageVO> list(
@RequestParam(required = false) Integer status,
@RequestParam(required = false) String keyword,
@RequestParam(defaultValue = "1") int pageNum,
@RequestParam(defaultValue = "20") int pageSize) {
return ApiResult.ok(cleanupService.list(status, keyword, pageNum, pageSize));
}
@GetMapping("/{id}")
@RequirePerm(PermissionDef.TASK_VIEW)
@Operation(summary = "整理进度",
description = "compressRatio 在没算出来时给「—」而不是 0% —— "
+ "0% 看起来像「一点没压缩」,而真相是「还没算」")
public ApiResult<CleanupDtos.JobVO> detail(@PathVariable Long id) {
return ApiResult.ok(cleanupService.detail(id));
}
@GetMapping("/{id}/report")
@RequirePerm(PermissionDef.TASK_VIEW)
@Operation(summary = "体检报告 ⭐",
description = "报告里最重要的两个数字是 **autoHandled / needManual** ——"
+ "「112 个文件里 90 个系统全自动处理,只有 1 个必须你亲自判断」。"
+ "它们由服务端算出来,不让前端去减(前端减出来的数字早晚会分叉)。"
+ "⚠️ 一期没有 OCR,所以 `idcardConflicts` 的判定一律是 `NEED_CONFIRM` ——"
+ "唯一的证据是文件名,而文件名本身正是这次要修的东西(用它改数据是循环论证)")
public ApiResult<CleanupDtos.ReportVO> report(@PathVariable Long id) {
return ApiResult.ok(cleanupService.report(id));
}
@GetMapping("/{id}/result")
@RequirePerm(PermissionDef.EXPORT_DOWNLOAD)
@Operation(summary = "下载整理结果(zip 流)")
public ResponseEntity<byte[]> result(@PathVariable Long id) {
byte[] bytes = cleanupService.resultBytes(id);
String name = "cleanup-" + id + "-result.zip";
String encoded = URLEncoder.encode(name, StandardCharsets.UTF_8).replace("+", "%20");
return ResponseEntity.ok()
.contentType(MediaType.APPLICATION_OCTET_STREAM)
.header(HttpHeaders.CONTENT_DISPOSITION,
"attachment; filename=\"" + encoded + "\"; filename*=UTF-8''" + encoded)
.body(bytes);
}
@PostMapping("/{id}/import")
@RequirePerm(PermissionDef.TASK_MANAGE)
@Operation(summary = "把整理结果导入为正式任务",
description = "名册 + 材料一次成型。**幂等**:同一个整理任务重复导入不会建出两个任务 —— "
+ "客户手滑点两次是常事,而多出来的任务很难被发现(它会安静地躺在列表里)")
public ApiResult<CleanupDtos.ImportVO> importJob(@PathVariable Long id,
@RequestBody CleanupDtos.ImportReq req) {
return ApiResult.ok(importService.importAsTask(id, req));
}
@PostMapping(value = "/parse-preview", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
@RequirePerm(PermissionDef.TASK_MANAGE)
@Operation(summary = "只解析不落库(排障用)",
description = "返回每个路径解析出的姓名/证件号(脱敏)/材料码/班级线索。"
+ "调规则或排查「为什么这个人没认出来」时用它 —— "
+ "不然只能看最终报告,而报告是**汇总后的结果**,看不出是哪一步错了")
public ApiResult<List<Map<String, Object>>> parsePreview(
@RequestParam("file") MultipartFile file) throws IOException {
return ApiResult.ok(cleanupService.dryRunParse(file.getBytes()));
}
}
@@ -1,282 +0,0 @@
package cn.tianbaotong.fill.module.cleanup.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Getter;
import lombok.Setter;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.List;
/**
* 历史数据整理器(《03-接口设计》§13.2)。
*/
public final class CleanupDtos {
private CleanupDtos() {
}
/** 创建整理任务。 */
@Getter
@Setter
@Schema(description = "创建历史整理任务")
public static class CreateReq {
private String jobName;
/** {@code 1} 压缩包 / {@code 2} 文件夹 / {@code 3} 云盘链接。 */
private Integer sourceType;
private String uploadToken;
private String objectKey;
private String sourceUrl;
private String namingTemplate;
private Object compressConfig;
/**
* 关联到**已有任务的名册**。
*
* <p>⚠️ 关键参数:没有名册就只能做文件名规范化;<b>有名单才能做证件号校验、
* 疑似串档检测</b> —— 而"疑似串档 1 例"正是报告里最有价值的一条。
*/
private Long rosterTaskId;
}
/** 创建结果。 */
@Getter
@Setter
public static class CreateVO {
private Long jobId;
private Integer status;
/** 估时(秒)。按文件数估:解析 + 压缩,经验值约每 10 个文件 1 秒 */
private Integer estimatedSeconds;
}
/** 整理任务(列表与详情共用)。 */
@Getter
@Setter
public static class JobVO {
private Long id;
private String jobName;
private Integer sourceType;
private Long rosterTaskId;
private Integer status;
private String statusText;
private Integer progress;
private Integer totalFiles;
private Integer processedFiles;
private Integer parsedStudents;
private Integer parsedMaterials;
private Long rawSizeBytes;
private Long finalSizeBytes;
/** 形如 {@code 91.5%};没算出来时给 {@code —} 而不是 0%(0% 看起来像"没压缩") */
private String compressRatio;
/** 磁盘上的整理结果路径(不是可下载 URL —— 下载走 `/jobs/{id}/result`)。 */
private String resultPath;
private String errorMsg;
private Long targetTaskId;
private LocalDateTime createTime;
private LocalDateTime finishTime;
}
/** 列表。 */
@Getter
@Setter
public static class JobPageVO {
private List<JobVO> list = new ArrayList<>();
private long total;
}
// ── 体检报告(契约 §13.2 的核心)──────────────
/** 报告汇总。 */
@Getter
@Setter
public static class ReportSummary {
private int totalFiles;
/** 文件名不规范(双扩展名、缺姓名/证件号等)。 */
private int nameInvalid;
private int sizeExceeded;
private int formatConverted;
/** 证件号与名册不一致的份数。 */
private int idcardMismatch;
/** 其中系统自动修正的。 */
private int idcardAutoFixed;
/** 其中需要人工确认的。 */
private int idcardNeedConfirm;
/**
* 疑似串档。
*
* <p>判据(契约 §13.2 的例子):某个学生的**多项**材料证件号与名册**均不同**,
* 且这些材料彼此一致 —— 那说明"整个文件夹可能被换过"。
* 单项不符只是录入笔误,多项一致才指向"人不对"。
*/
private int crossFileSuspected;
private int lowResolution;
/** 无法归位(既没匹配到名册,也没解析出姓名)。 */
private int orphanImages;
}
/** 报告里的一条重点。 */
@Getter
@Setter
public static class Highlight {
/** {@code CROSS_FILE} / {@code LOW_RESOLUTION} / {@code IDCARD_MISMATCH} / {@code ORPHAN}。 */
private String type;
/** {@code 1} 提示 / {@code 2} 警告 / {@code 3} 必须人工。 */
private Integer level;
private Long rosterId;
private String realName;
private String title;
private String materialCode;
private String detail;
private String suggestion;
}
/** 改名样例。 */
@Getter
@Setter
public static class NameFixSample {
private String original;
private String fixed;
private String rule;
}
/** 证件号冲突明细。 */
@Getter
@Setter
public static class IdcardConflict {
private Long rosterId;
private String realName;
/** 名册里的脱敏值。 */
private String rosterValue;
/** 从文件名里读到的脱敏值。 */
private String fileNameValue;
/** 按文件名修正 / 按名册修正 / 待人工。 */
private String verdict;
private String verdictText;
}
/** 体检报告。 */
@Getter
@Setter
public static class ReportVO {
private Long jobId;
private ReportSummary summary = new ReportSummary();
/**
* 系统全自动处理了多少个文件。
*
* <p>契约里写明:<b>报告里最重要的一句话是 {@code autoHandled / needManual}</b>
* ——「112 个文件里 90 个系统全自动处理,只有 1 个必须你亲自判断」。
* 所以这两个数字**必须由服务端算出来**,不能让前端自己减。
*/
private int autoHandled;
private int needManual;
private List<Highlight> highlights = new ArrayList<>();
private List<NameFixSample> nameFixedSamples = new ArrayList<>();
private List<IdcardConflict> idcardConflicts = new ArrayList<>();
}
/** 导入为正式任务。 */
@Getter
@Setter
@Schema(description = "把整理结果导入为正式任务")
public static class ImportReq {
private String taskName;
private Integer collectMode;
private String batchNo;
/**
* 专业名称(选填)。
*
* <p>契约 §13.2 的导入请求里没有这个字段,但 `TaskService.create` 要求它 ——
* 而"专业名"必须有人给。所以:给了就用它,没给就**继承关联名册任务的专业**
* (历史材料与那份名册本来就属于同一个专业),都没有时明确报错让用户填。
* 这条补字段记在 F-44。
*/
private String majorName;
private Boolean importRoster;
private Boolean importFiles;
private Boolean markAsSubmitted;
}
/** 导入结果。 */
@Getter
@Setter
public static class ImportVO {
private Long taskId;
private int rosterCount;
private int fileCount;
}
}
@@ -1,89 +0,0 @@
package cn.tianbaotong.fill.module.cleanup.entity;
import cn.tianbaotong.fill.support.BaseEntity;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Getter;
import lombok.Setter;
import java.time.LocalDateTime;
/**
* 历史整理任务({@code collect_cleanup_job},《03-接口设计》§13.2)。
*
* <p>⚠️ V1 建这张表时漏了 {@code update_time} / {@code deleted},
* 而实体继承 {@code BaseEntity}(带 {@code @TableLogic} 与自动填充)——
* 少了这两列的表现是**运行时**才炸。V13 补齐,并被
* {@code scripts/check-entity-columns.py} 提前拦住过(F-24/F-35 的同类)。
*/
@Getter
@Setter
@TableName("collect_cleanup_job")
public class CollectCleanupJob extends BaseEntity {
/** {@code 0} 排队 / {@code 1} 解析中 / {@code 2} 处理中 / {@code 3} 已完成 / {@code 4} 失败。 */
public static final int STATUS_QUEUED = 0;
public static final int STATUS_PARSING = 1;
public static final int STATUS_PROCESSING = 2;
public static final int STATUS_DONE = 3;
public static final int STATUS_FAILED = 4;
/** 来源:{@code 1} 压缩包 / {@code 2} 文件夹 / {@code 3} 云盘链接。 */
public static final int SOURCE_ZIP = 1;
public static final int SOURCE_FOLDER = 2;
public static final int SOURCE_CLOUD = 3;
private String jobName;
private Integer sourceType;
/** 压缩包在存储里的相对路径({@code sourceType=1})。 */
private String sourcePath;
/** 云盘链接({@code sourceType=3},一期不启用)。 */
private String sourceUrl;
/**
* 关联的已有任务名册(契约 §13.2 的 {@code rosterTaskId})。
*
* <p>⚠️ **这是关键参数**:没有名册就只能做文件名规范化;
* <b>有名单才能做证件号校验、疑似串档检测</b> ——
* 而"疑似串档 1 例"正是报告里最有价值的那一条。
*/
private Long rosterTaskId;
private String namingTemplate;
/** 压缩参数(JSON,缺省见契约 §3.11)。 */
private String compressConfig;
private Integer status;
private Integer progress;
/** 体检报告(JSON)—— 契约 §13.2 的 report 结构整体存这里。 */
private String reportJson;
private Integer totalFiles;
private Integer processedFiles;
private Integer fixedFiles;
private Integer failedFiles;
private Integer parsedStudents;
private Integer parsedMaterials;
private Long rawSizeBytes;
private Long finalSizeBytes;
/** 整理结果的压缩包路径。 */
private String resultPath;
/** 导入成正式任务后的任务 id。 */
private Long targetTaskId;
private LocalDateTime finishTime;
}
@@ -1,13 +0,0 @@
package cn.tianbaotong.fill.module.cleanup.mapper;
import cn.tianbaotong.fill.module.cleanup.entity.CollectCleanupJob;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
/**
* 历史整理任务。
*
* <p>只需要 MyBatis-Plus 的默认方法 —— 列表用 `selectPage` + 条件构造器就够了,
* 不为一个查询再写一段手写 SQL(那会多一处要维护的口径)。
*/
public interface CleanupJobMapper extends BaseMapper<CollectCleanupJob> {
}
@@ -1,284 +0,0 @@
package cn.tianbaotong.fill.module.cleanup.service;
import cn.hutool.json.JSONUtil;
import cn.tianbaotong.fill.common.api.ErrorCode;
import cn.tianbaotong.fill.common.context.LoginUser;
import cn.tianbaotong.fill.common.context.UserContext;
import cn.tianbaotong.fill.common.exception.BizException;
import cn.tianbaotong.fill.module.cleanup.dto.CleanupDtos;
import cn.tianbaotong.fill.module.cleanup.entity.CollectCleanupJob;
import cn.tianbaotong.fill.module.cleanup.mapper.CleanupJobMapper;
import cn.tianbaotong.fill.module.file.service.FileProcessService;
import cn.tianbaotong.fill.module.file.storage.StorageService;
import cn.tianbaotong.fill.module.roster.entity.CollectRoster;
import cn.tianbaotong.fill.module.roster.mapper.CollectRosterMapper;
import cn.tianbaotong.fill.module.task.dto.TaskUpsertReq;
import cn.tianbaotong.fill.module.task.mapper.TaskMapper;
import cn.tianbaotong.fill.module.task.service.TaskService;
import cn.tianbaotong.fill.support.SensitiveCodec;
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.time.LocalDate;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.zip.ZipEntry;
import java.util.zip.ZipInputStream;
/**
* 把整理结果导入为正式任务(《03-接口设计》§13.2 的 `/jobs/{id}/import`)。
*
* <h3>这个动作的分量:它把"一堆历史文件"变成"一份可审核的交付"</h3>
*
* <p>导入之后,客户拿到的是一个真实的任务:名册、材料、填报单、状态齐备,
* 可以走审核、可以导出。所以这里做的是"按解析结果重建一套数据",
* 而重建最容易出的两类错必须在这里挡住:
*
* <ol>
* <li><b>重复导入</b>:客户手滑点两次是常事。多出来的任务很难被发现
* (它会安静地躺在任务列表里),而"同一批人出现两次"会让导出重复计人。
* 所以导入是**幂等**的(看 {@code target_task_id})。</li>
* <li><b>证件号缺失</b>:解析不出证件号的人**不能建名册行** ——
* `collect_roster.id_card_no` 是 NOT NULL 的密文列(F-19 那个坑),
* 而且没有证件号的人永远过不了门禁、也过不了名册校验。
* 所以这类人**跳过并计入报告**,而不是塞一条空号的行进去。</li>
* </ol>
*
* <h3>一期能做到哪一步(说清边界,不假装完整)</h3>
*
* <p>能建:任务、名册(仅"解析出姓名 **且** 证件号"的人)、材料定义(按解析出的材料码)。
* <b>不能建</b>:填报单与挂文件 —— 那需要把整理结果里的每个文件重新走一遍
* "压缩 + 按模板改名 + 挂到某个填报单",而一期还没有"把外部文件挂到指定填报单"
* 的服务端入口(代传接口要 multipart 逐个传)。
* 所以 {@code importFiles} 参数**会被如实拒绝**(而不是安静地忽略它)——
* 安静忽略会让客户以为文件已经进去了。
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class CleanupImportService {
private final CleanupJobMapper jobMapper;
private final TaskService taskService;
private final TaskMapper taskMapper;
private final CollectRosterMapper rosterMapper;
private final StorageService storageService;
private final SensitiveCodec codec;
private final FileProcessService processService;
private final CleanupService cleanupService;
@Transactional(rollbackFor = Exception.class)
public CleanupDtos.ImportVO importAsTask(Long jobId, CleanupDtos.ImportReq req) {
LoginUser me = UserContext.require();
CollectCleanupJob job = jobMapper.selectById(jobId);
if (job == null) {
throw new BizException(ErrorCode.VALIDATION_BLOCKED, "整理任务不存在或已删除");
}
if (job.getStatus() == null || job.getStatus() != CollectCleanupJob.STATUS_DONE) {
throw new BizException(ErrorCode.VALIDATION_BLOCKED, "整理还没完成,暂时不能导入");
}
// ★ 参数校验必须在**幂等判断之前**。
//
// 原来它排在幂等分支之后,于是同一个整理任务第二次导入时,
// 请求会先命中"已经导过了,直接返回原任务",参数校验压根没跑到 ——
// `importFiles=true`(一期做不到)因此被安静地接受。
// 一次不合法的请求不该因为"恰好之前导过一次"而变成成功。
if (req == null || req.getTaskName() == null || req.getTaskName().isBlank()) {
throw new BizException(ErrorCode.VALIDATION_BLOCKED, "请填写新任务名称");
}
if (req.getBatchNo() == null || req.getBatchNo().isBlank()) {
throw new BizException(ErrorCode.VALIDATION_BLOCKED, "请填写批次号");
}
if (Boolean.TRUE.equals(req.getImportFiles())) {
// 如实拒绝而不是安静忽略 —— 见类注释
throw new BizException(ErrorCode.SCOPE_NOT_SUPPORTED,
"一期导入整理结果时还不能自动挂文件(需要把每个文件按模板重新规范化后挂到填报单,"
+ "那条服务端入口还没做)。请先导入名册,再让学生/老师把文件传上来");
}
if (req.getCollectMode() != null && req.getCollectMode() != 1 && req.getCollectMode() != 2) {
throw new BizException(ErrorCode.VALIDATION_BLOCKED,
"collectMode 只能是 1(学校收集)或 2(自主认定)");
}
// ★ 幂等:已经导过就直接回原来的任务,不新建
if (job.getTargetTaskId() != null) {
var existed = taskMapper.selectById(job.getTargetTaskId());
if (existed != null) {
CleanupDtos.ImportVO vo = new CleanupDtos.ImportVO();
vo.setTaskId(existed.getId());
vo.setRosterCount((int) nz(rosterMapper.selectCount(
Wrappers.<CollectRoster>lambdaQuery()
.eq(CollectRoster::getTaskId, existed.getId()))));
vo.setFileCount(0);
log.info("整理结果重复导入,返回已有任务 jobId={} taskId={}", jobId, existed.getId());
return vo;
}
}
// 一期范围:只做学校收集(契约 §13.2 的 collectMode 也是 1/2)
// 缺省给 1(学校收集):一期**只**支持它,而缺省值若是 2,
// 不传这个字段的调用方会收到一个"你传了二期功能"的 40044 —— 那读起来像调用方的错。
//
// mode=2 由 `TaskService.create` 的 assertScopeSupported 拦(40044),
// 不在这里再拦一遍:范围的权威定义只有一处,两处早晚会不一致。
int mode = req.getCollectMode() == null ? 1 : req.getCollectMode();
// 建任务(草稿状态:让用户先去核对名册与材料,确认后再发布)
//
// ⚠️ **必须走 TaskService.create,不能自己 insert**:
// `collect_task.task_code` 是 NOT NULL 且由它生成,绕过它插入会报
// `null value in column "task_code"`,而对外表现只是"系统繁忙"(50000)。
// 更不能自己再写一份编码生成逻辑 —— 两处各生成一次编码迟早会撞号。
TaskUpsertReq treq = new TaskUpsertReq();
treq.setTaskName(req.getTaskName().trim());
treq.setBatchNo(req.getBatchNo().trim());
treq.setCollectMode(mode);
// ⚠️ 一期范围是 collectMode=1(学校收集)+ **certType=1(自主认定)** ——
// 权威定义在 `TaskService.assertScopeSupported`。
// 我按"2=自主认定"的直觉写过 2,被 40044 拦下(拦得对)。
treq.setCertType(1);
treq.setMajorName(majorNameOf(job, req));
treq.setStartTime(LocalDate.now().toString());
treq.setEndTime(LocalDate.now().plusMonths(6).toString());
if (job.getNamingTemplate() != null && !job.getNamingTemplate().isBlank()) {
treq.setNamingTemplate(job.getNamingTemplate());
}
Long newTaskId = taskService.create(treq).getId();
// 名册:按整理结果里"能确定是谁"的人建
int rosterCount = 0;
int skipped = 0;
if (req.getImportRoster() == null || Boolean.TRUE.equals(req.getImportRoster())) {
Map<String, String[]> students = studentsOf(jobId, job);
Set<String> seen = new LinkedHashSet<>();
for (Map.Entry<String, String[]> en : students.entrySet()) {
String name = en.getKey();
String idCard = en.getValue()[0];
String orgHint = en.getValue()[1];
if (name == null || name.isBlank() || idCard == null || idCard.isBlank()) {
skipped++;
continue;
}
// 同一个人只建一行(历史包里同一个人常有多个文件夹)
if (!seen.add(name + "|" + idCard)) {
continue;
}
CollectRoster r = new CollectRoster();
r.setTaskId(newTaskId);
r.setRealName(name);
// 三列一起写:密文 + hash(门禁靠它查)+ 脱敏值(展示)
r.setIdCardNo(codec.encrypt(idCard));
r.setIdCardHash(codec.hash(idCard));
r.setIdCardMask(maskOf(idCard));
r.setSeqNo(rosterCount + 1);
r.setSubmitStatus(CollectRoster.SUBMIT_NONE);
rosterMapper.insert(r);
rosterCount++;
}
}
CollectCleanupJob upd = new CollectCleanupJob();
upd.setId(jobId);
upd.setTargetTaskId(newTaskId);
jobMapper.updateById(upd);
log.info("整理结果导入为任务:jobId={} taskId={} 名册 {} 人(跳过 {})经办人={}",
jobId, newTaskId, rosterCount, skipped, me.uid());
CleanupDtos.ImportVO vo = new CleanupDtos.ImportVO();
vo.setTaskId(newTaskId);
vo.setRosterCount(rosterCount);
vo.setFileCount(0);
return vo;
}
/**
* 从整理结果里取出「姓名 → (证件号, 班级线索)」。
*
* <p>⚠️ 直接从**整理结果包**里重新解析,而不是读报告 JSON:
* 报告里的证件号是**脱敏值**(那是给人看的),拿它建名册会建成一堆星号。
* 这条"报告不能当数据源"的界线要说清 —— 否则以后有人图省事去读报告。
*/
private Map<String, String[]> studentsOf(Long jobId, CollectCleanupJob job) {
Map<String, String[]> out = new LinkedHashMap<>();
byte[] zip = storageService.getBytes(job.getResultPath());
try (ZipInputStream zin = new ZipInputStream(new java.io.ByteArrayInputStream(zip),
StandardCharsets.UTF_8)) {
ZipEntry e;
while ((e = zin.getNextEntry()) != null) {
if (e.isDirectory()) {
continue;
}
// 结果包的目录结构是我们自己定的:班级/姓名/材料/规范文件名
// 而规范文件名里带证件号 → 从路径就能取回"姓名 + 证件号"
HistoryNameParser.Hint h = HistoryNameParser.parse(e.getName());
String name = h.studentName();
if (name == null || h.idCard() == null) {
continue;
}
out.putIfAbsent(name, new String[]{h.idCard(), h.orgHint()});
drain(zin);
}
} catch (IOException ex) {
throw new BizException(ErrorCode.VALIDATION_BLOCKED,
"读整理结果失败:" + ex.getMessage());
}
return out;
}
private void drain(java.io.InputStream in) throws IOException {
byte[] buf = new byte[8192];
while (in.read(buf) > 0) {
// 只为了推进 zip 流
}
}
private long nz(Long v) {
return v == null ? 0L : v;
}
/**
* 新任务的"专业名称"从哪来。
*
* <p>三级回退:
* <ol>
* <li>请求里显式给了就用它;</li>
* <li>否则**继承关联名册任务的专业** —— 历史材料与那份名册本来就属于同一个专业,
* 而这是最可信的来源(比让用户再填一遍更不容易填错);</li>
* <li>都没有就明确报错,让用户填。</li>
* </ol>
*
* <p>为什么不猜一个默认值(比如"未分类"):专业名会进任务名、进导出汇总、
* 也会被用来筛人。猜错的话错误会一路带到交付物里,而"让你现在填一下"只是一秒的事。
*/
private String majorNameOf(CollectCleanupJob job, CleanupDtos.ImportReq req) {
if (req.getMajorName() != null && !req.getMajorName().isBlank()) {
return req.getMajorName().trim();
}
if (job.getRosterTaskId() != null) {
var src = taskMapper.selectById(job.getRosterTaskId());
if (src != null && src.getMajorName() != null && !src.getMajorName().isBlank()) {
return src.getMajorName();
}
}
throw new BizException(ErrorCode.VALIDATION_BLOCKED,
"请填写专业名称(也可以给整理任务关联一份已有名册,专业名会从它继承)");
}
private String maskOf(String idCard) {
if (idCard == null || idCard.length() < 14) {
return idCard;
}
return idCard.substring(0, 6) + "********" + idCard.substring(idCard.length() - 4);
}
}
@@ -1,765 +0,0 @@
package cn.tianbaotong.fill.module.cleanup.service;
import cn.hutool.json.JSONUtil;
import cn.tianbaotong.fill.common.api.ErrorCode;
import cn.tianbaotong.fill.common.context.LoginUser;
import cn.tianbaotong.fill.common.context.UserContext;
import cn.tianbaotong.fill.common.exception.BizException;
import cn.tianbaotong.fill.common.util.IdCardUtil;
import cn.tianbaotong.fill.module.cleanup.dto.CleanupDtos;
import cn.tianbaotong.fill.module.cleanup.entity.CollectCleanupJob;
import cn.tianbaotong.fill.module.cleanup.mapper.CleanupJobMapper;
import cn.tianbaotong.fill.module.file.service.FileProcessService;
import cn.tianbaotong.fill.module.roster.entity.CollectRoster;
import cn.tianbaotong.fill.module.roster.mapper.CollectRosterMapper;
import cn.tianbaotong.fill.support.IdentityNormalizer;
import cn.tianbaotong.fill.support.TenantScope;
import cn.tianbaotong.fill.support.SensitiveCodec;
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.ExecutorService;
import java.util.zip.ZipEntry;
import java.util.zip.ZipInputStream;
/**
* 历史数据整理(《03-接口设计》§13.2)—— 差异化获客功能。
*
* <h3>它解决的真实痛点</h3>
*
* <p>客户手里已经有几百个 G 的历史材料,形态是"人长出来的":
* 一个班一个文件夹、里面一个人一个文件夹、文件名五花八门。
* 要收进系统得先规范化,而**人工改名几百份是不可能的**。
*
* <h3>这一版做什么 / 不做什么(必须说清,否则会被当成"功能不完整")</h3>
*
* <p><b>做</b>:
* <ul>
* <li>解压 zip → 逐条路径解析(姓名 / 证件号 / 材料类型 / 班级线索);</li>
* <li>与名册比对:证件号一致 / 不一致 / 名册里没这个人;</li>
* <li><b>疑似串档检测</b>:某人的多项材料证件号与名册<b>均不同</b>且彼此一致 →
* "整个文件夹可能被换过"(这是报告里最有价值的一条);</li>
* <li>按模板生成规范文件名,产出整理后的 zip;</li>
* <li>体检报告(含 {@code autoHandled / needManual} 两个关键数字)。</li>
* </ul>
*
* <p><b>不做</b>(并且理由都不是"来不及"):
* <ul>
* <li><b>不读图片内容</b>(没有 OCR/VLM,M4 未做)——
* 所以报告里的 {@code ocrValue} 一律为空,判定只用"文件名 + 名册"两条证据。
* 编一个假的 OCR 结果会让 {@code idcardAutoFixed} 这个数字失去意义;</li>
* <li><b>不做云盘链接</b>({@code sourceType=3} → 40044)——
* 要接网盘 SDK 与各家授权,一期不做;</li>
* <li><b>不做文件夹上传</b>({@code sourceType=2} → 40044)——
* 浏览器上传文件夹要 {@code webkitdirectory} + 后端逐文件落盘,
* 而现在客户手上给的形态就是压缩包。**但列已经建好了**(V13),
* 以后接上不用再动表。</li>
* </ul>
*
* <h3>为什么"有名单 vs 没名单"是两种完全不同的结果</h3>
*
* <p>没有 {@code rosterTaskId} 时只能做文件名规范化,报告里
* {@code idcardMismatch / crossFileSuspected} 恒为 0 ——
* 而这恰恰是客户愿意为这个功能付钱的那条信息。所以创建接口要**主动提示**
* (见 {@code create} 里的 warnings),而不是安静地少做一半。
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class CleanupService {
/** 单个文件体积上限(解压炸弹防护):50MB。 */
private static final long MAX_ENTRY_BYTES = 50L * 1024 * 1024;
/** 整包解压后的总上限(解压炸弹防护):2GB。 */
private static final long MAX_TOTAL_BYTES = 2L * 1024 * 1024 * 1024;
/** 最多处理多少个文件(防止误传一个几万文件的包把服务拖死)。 */
private static final int MAX_FILES = 20000;
private final CleanupJobMapper jobMapper;
private final CollectRosterMapper rosterMapper;
private final SensitiveCodec codec;
private final FileProcessService processService;
private final cn.tianbaotong.fill.module.file.storage.StorageService storageService;
private final ExecutorService exportExecutor;
// ── 创建 ──────────────────────────────────────
@Transactional(rollbackFor = Exception.class)
public CleanupDtos.CreateVO create(CleanupDtos.CreateReq req, byte[] zipBytes) {
LoginUser me = UserContext.require();
if (req == null || req.getJobName() == null || req.getJobName().isBlank()) {
throw new BizException(ErrorCode.VALIDATION_BLOCKED, "请填写整理任务名称");
}
int sourceType = req.getSourceType() == null ? CollectCleanupJob.SOURCE_ZIP : req.getSourceType();
if (sourceType == CollectCleanupJob.SOURCE_CLOUD || sourceType == CollectCleanupJob.SOURCE_FOLDER) {
// 一期只做压缩包 —— 理由见类注释。**不静默降级**:
// 安静地按"压缩包"处理一个云盘链接,用户会以为功能坏了
throw new BizException(ErrorCode.SCOPE_NOT_SUPPORTED,
sourceType == CollectCleanupJob.SOURCE_CLOUD
? "一期暂不支持云盘链接,请把材料打包成 zip 上传"
: "一期暂不支持直接上传文件夹,请压缩成 zip 再上传");
}
if (zipBytes == null || zipBytes.length == 0) {
throw new BizException(ErrorCode.VALIDATION_BLOCKED, "请上传要整理的压缩包");
}
if (req.getRosterTaskId() != null) {
// 名册必须真的存在,否则报告会安静地少掉最关键的两项
Long n = rosterMapper.selectCount(Wrappers.<CollectRoster>lambdaQuery()
.eq(CollectRoster::getTaskId, req.getRosterTaskId()));
if (n == null || n == 0) {
throw new BizException(ErrorCode.VALIDATION_BLOCKED,
"指定的名册任务里一个人都没有,无法做证件号校验");
}
}
// 压缩包落到存储里(整理是异步的,请求线程里的字节数组活不到那时)
String rel = String.format("cleanup/%d/%d-src.zip", me.tenantId(), System.currentTimeMillis());
storageService.put(rel, zipBytes, "application/zip");
CollectCleanupJob job = new CollectCleanupJob();
job.setJobName(req.getJobName().trim().length() > 120
? req.getJobName().trim().substring(0, 120) : req.getJobName().trim());
job.setSourceType(sourceType);
job.setSourcePath(rel);
job.setRosterTaskId(req.getRosterTaskId());
job.setNamingTemplate(req.getNamingTemplate() == null || req.getNamingTemplate().isBlank()
? "{姓名}-{证件号}-{材料名}" : req.getNamingTemplate().trim());
job.setCompressConfig(req.getCompressConfig() == null
? null : JSONUtil.toJsonStr(req.getCompressConfig()));
job.setStatus(CollectCleanupJob.STATUS_QUEUED);
job.setProgress(0);
jobMapper.insert(job);
Long tenantId = me.tenantId();
Long uid = me.uid();
final Long jobId = job.getId();
// ★ 异步要把租户上下文带过去:拦截器从 ThreadLocal 取 tenantId,
// 新线程里没有 → 一句 SQL 都跑不了(表现为 40101 或"查不到数据")
//
// ⚠️ 并且要把 **job 对象本身**传进去,不要在异步线程里回查 ——
// 异步线程与请求线程是两个事务,而请求线程此刻**还没提交**,
// 回查必然查不到(表现是"整理任务不存在",整次整理白跑)。
// 这类时序问题不能用 sleep 解决(它只是把概率变小),
// 去掉"回查"这个动作才是真的解决。
exportExecutor.execute(() -> TenantScope.runAs(tenantId, uid, Set.of(), () -> {
try {
run(job);
} catch (Throwable e) {
log.error("历史整理失败 jobId={}", jobId, e);
markFailed(jobId, e);
}
}));
CleanupDtos.CreateVO vo = new CleanupDtos.CreateVO();
vo.setJobId(jobId);
vo.setStatus(CollectCleanupJob.STATUS_QUEUED);
// 估时:解压 + 解析 + 压缩,经验值约每 10 个文件 1 秒(下限 10 秒)
vo.setEstimatedSeconds(Math.max(10, zipBytes.length / 1024 / 100));
log.info("创建历史整理任务 jobId={} 名称={} 包体={}KB 名册任务={}",
jobId, job.getJobName(), zipBytes.length / 1024, req.getRosterTaskId());
return vo;
}
// ── 查询 ──────────────────────────────────────
public CleanupDtos.JobPageVO list(Integer status, String keyword, int pageNum, int pageSize) {
var q = Wrappers.<CollectCleanupJob>lambdaQuery()
.eq(status != null, CollectCleanupJob::getStatus, status)
.like(keyword != null && !keyword.isBlank(), CollectCleanupJob::getJobName, keyword)
.orderByDesc(CollectCleanupJob::getId);
var page = jobMapper.selectPage(
new com.baomidou.mybatisplus.extension.plugins.pagination.Page<>(
Math.max(1, pageNum), Math.min(Math.max(1, pageSize), 100)), q);
CleanupDtos.JobPageVO vo = new CleanupDtos.JobPageVO();
vo.setList(page.getRecords().stream().map(this::toVo).toList());
vo.setTotal(page.getTotal());
return vo;
}
public CleanupDtos.JobVO detail(Long id) {
CollectCleanupJob j = requireJob(id);
return toVo(j);
}
public CleanupDtos.ReportVO report(Long id) {
CollectCleanupJob j = requireJob(id);
if (j.getStatus() == null || j.getStatus() != CollectCleanupJob.STATUS_DONE) {
throw new BizException(ErrorCode.VALIDATION_BLOCKED,
"整理还没完成(" + statusText(j.getStatus()) + "),暂时没有报告");
}
if (j.getReportJson() == null || j.getReportJson().isBlank()) {
throw new BizException(ErrorCode.VALIDATION_BLOCKED, "这份整理没有留下报告");
}
try {
return JSONUtil.toBean(j.getReportJson(), CleanupDtos.ReportVO.class);
} catch (RuntimeException e) {
throw new BizException(ErrorCode.VALIDATION_BLOCKED, "报告内容损坏,无法解析");
}
}
/** 整理结果的压缩包字节(下载用)。 */
public byte[] resultBytes(Long id) {
CollectCleanupJob j = requireJob(id);
if (j.getStatus() == null || j.getStatus() != CollectCleanupJob.STATUS_DONE
|| j.getResultPath() == null) {
throw new BizException(ErrorCode.VALIDATION_BLOCKED, "整理结果还没生成");
}
return storageService.getBytes(j.getResultPath());
}
// ── 主体:解压 → 解析 → 比对 → 重打包 ────────────
private void run(CollectCleanupJob job) {
Long jobId = job.getId();
progress(jobId, CollectCleanupJob.STATUS_PARSING, 5);
byte[] zip = storageService.getBytes(job.getSourcePath());
List<Entry> entries = readZip(zip);
if (entries.isEmpty()) {
throw new IllegalStateException("压缩包里没有文件(是不是传了一个空包?)");
}
progress(jobId, CollectCleanupJob.STATUS_PARSING, 20);
// 名册:有 rosterTaskId 才有。按"姓名 → 名册行"和"证件号 hash → 名册行"两路索引,
// 因为解析出来的线索可能只有姓名(文件名里没号码)或只有号码
Map<String, CollectRoster> byName = new HashMap<>();
Map<String, CollectRoster> byHash = new HashMap<>();
if (job.getRosterTaskId() != null) {
for (CollectRoster r : rosterMapper.selectList(Wrappers.<CollectRoster>lambdaQuery()
.eq(CollectRoster::getTaskId, job.getRosterTaskId()))) {
if (r.getRealName() != null) {
byName.putIfAbsent(r.getRealName().trim(), r);
}
if (r.getIdCardHash() != null) {
byHash.putIfAbsent(r.getIdCardHash(), r);
}
}
}
List<Resolved> resolved = new ArrayList<>();
Set<String> usedNames = new java.util.LinkedHashSet<>();
for (Entry e : entries) {
resolved.add(resolve(e, byName, byHash, job.getNamingTemplate(), usedNames));
}
progress(jobId, CollectCleanupJob.STATUS_PROCESSING, 60);
// 重打包(按解析出的"班级/姓名/材料"建目录,文件名用规范名)
String outRel = String.format("cleanup/%d/%d-result.zip", UserContext.tenantId(), jobId);
long finalBytes;
try {
finalBytes = writeResultZip(resolved);
} catch (IOException ex) {
throw new IllegalStateException("生成整理结果失败:" + ex.getMessage(), ex);
}
storageService.put(outRel, lastResult, "application/zip");
// 体检报告
CleanupDtos.ReportVO report = buildReport(jobId, resolved);
long rawBytes = entries.stream().mapToLong(x -> x.bytes.length).sum();
int students = (int) resolved.stream().map(Resolved::rosterId).filter(java.util.Objects::nonNull)
.distinct().count();
int materials = (int) resolved.stream().map(Resolved::materialCode)
.filter(java.util.Objects::nonNull).distinct().count();
CollectCleanupJob upd = new CollectCleanupJob();
upd.setId(jobId);
upd.setStatus(CollectCleanupJob.STATUS_DONE);
upd.setProgress(100);
upd.setTotalFiles(entries.size());
upd.setProcessedFiles(resolved.size());
upd.setFixedFiles((int) resolved.stream().filter(Resolved::renamed).count());
upd.setFailedFiles((int) resolved.stream().filter(x -> x.rosterId == null).count());
upd.setParsedStudents(students);
upd.setParsedMaterials(materials);
upd.setRawSizeBytes(rawBytes);
upd.setFinalSizeBytes(finalBytes);
upd.setResultPath(outRel);
upd.setReportJson(JSONUtil.toJsonStr(report));
upd.setFinishTime(java.time.LocalDateTime.now());
jobMapper.updateById(upd);
log.info("历史整理完成 jobId={} 文件={} 自动处理={} 待人工={} 包体 {}KB → {}KB",
jobId, entries.size(), report.getAutoHandled(), report.getNeedManual(),
rawBytes / 1024, finalBytes / 1024);
}
/** 一条 zip 条目。 */
private record Entry(String path, byte[] bytes) {
}
/**
* 读 zip 到内存。
*
* <p>为什么整包进内存而不是流式:整理要**两遍**(先解析全部、再按解析结果重打包),
* 而 zip 不能随机访问(除非落盘再读)。客户的历史包按 PRD 的规模是几百 MB,
* 内存够;但**必须有上限**,否则一个恶意的解压炸弹能把服务打死 ——
* 见 {@link #MAX_TOTAL_BYTES} 与 {@link #MAX_ENTRY_BYTES}。
*/
private List<Entry> readZip(byte[] zip) {
List<Entry> out = new ArrayList<>();
long total = 0;
try (ZipInputStream zin = new ZipInputStream(new java.io.ByteArrayInputStream(zip),
StandardCharsets.UTF_8)) {
ZipEntry e;
while ((e = zin.getNextEntry()) != null) {
if (e.isDirectory()) {
continue;
}
// 跳过 macOS 的元数据文件:客户用 Mac 压的包一定会带,混进来会让报告虚高
String name = e.getName();
if (name.startsWith("__MACOSX/") || name.endsWith(".DS_Store")
|| name.contains("/._")) {
continue;
}
long declared = e.getSize();
if (declared > MAX_ENTRY_BYTES) {
throw new IllegalStateException("包里的单个文件超过 " + (MAX_ENTRY_BYTES / 1024 / 1024)
+ "MB:" + name + "(请先在本地压缩一下)");
}
byte[] data = readEntry(zin);
if (data.length > MAX_ENTRY_BYTES) {
throw new IllegalStateException("解压后单个文件过大:" + name);
}
total += data.length;
if (total > MAX_TOTAL_BYTES) {
throw new IllegalStateException("解压后总量超过 "
+ (MAX_TOTAL_BYTES / 1024 / 1024 / 1024) + "GB,请分批整理");
}
if (out.size() >= MAX_FILES) {
throw new IllegalStateException("包里的文件超过 " + MAX_FILES + " 个,请分批整理");
}
out.add(new Entry(name, data));
}
} catch (IOException ex) {
throw new IllegalStateException("压缩包读不了(是不是坏了或不是 zip):" + ex.getMessage(), ex);
}
return out;
}
private byte[] readEntry(InputStream in) throws IOException {
ByteArrayOutputStream bos = new ByteArrayOutputStream();
byte[] buf = new byte[8192];
int n;
// 边读边判上限:不能等读完再看长度(那时内存已经吃掉了)
while ((n = in.read(buf)) > 0) {
bos.write(buf, 0, n);
if (bos.size() > MAX_ENTRY_BYTES) {
throw new IOException("条目解压后超过 " + (MAX_ENTRY_BYTES / 1024 / 1024) + "MB");
}
}
return bos.toByteArray();
}
/**
* 一个文件的解析与比对结果。
*
* <p>用"普通类 + 字段"而不是 record:这个对象在解析过程中是**逐步填**的
* (先填解析结果、再填名册匹配、最后填规范文件名),record 那种一次成型的形状不合适。
*
* <p>⚠️ 但有访问器:方法引用(`Resolved::rosterId`)需要它 ——
* 直接引用字段在 Java 里是要写 lambda 的,而 lambda 在这里读起来更啰嗦。
*/
private static final class Resolved {
String path;
byte[] bytes;
String studentName;
String idCard;
String materialCode;
Long rosterId;
String rosterName;
String orgHint;
String standardName;
boolean renamed;
String issue;
Long rosterId() {
return rosterId;
}
String materialCode() {
return materialCode;
}
boolean renamed() {
return renamed;
}
}
private Resolved resolve(Entry e, Map<String, CollectRoster> byName,
Map<String, CollectRoster> byHash, String template,
Set<String> usedNames) {
HistoryNameParser.Hint h = HistoryNameParser.parse(e.path());
Resolved r = new Resolved();
r.path = e.path();
r.bytes = e.bytes();
r.studentName = h.studentName();
r.idCard = h.idCard();
r.materialCode = h.materialCode();
r.orgHint = h.orgHint();
// 与名册比对:先用证件号(更可靠),再用姓名
CollectRoster matched = null;
if (r.idCard != null) {
matched = byHash.get(codec.hash(r.idCard));
}
if (matched == null && r.studentName != null) {
matched = byName.get(r.studentName);
}
if (matched != null) {
r.rosterId = matched.getId();
r.rosterName = matched.getRealName();
// 证件号冲突:文件名里的号码与名册不一致。
// ⚠️ 这里**只记冲突、不改数据** —— 改谁都需要证据(没有 OCR,
// 我们只有"文件名"一条证据,而文件名本身就是要修的东西)
if (r.idCard != null && matched.getIdCardHash() != null
&& !codec.hash(r.idCard).equals(matched.getIdCardHash())) {
r.issue = "IDCARD_MISMATCH";
}
} else if (r.studentName != null || r.idCard != null) {
r.issue = "ORPHAN";
} else {
r.issue = "ORPHAN";
}
// 规范文件名:优先用**名册**里的姓名与证件号(名册才是权威),
// 名册里没有这个人时退回文件名里解析到的
String name = r.rosterName != null ? r.rosterName : r.studentName;
String idc = r.idCard;
String matName = r.materialCode == null ? "待归位"
: HistoryNameParser.materialNameOf(r.materialCode);
String ext = extOf(e.path());
Map<String, String> vars = new LinkedHashMap<>();
vars.put("姓名", name == null ? "未知姓名" : name);
vars.put("证件号", idc == null ? "未知证件号" : idc);
vars.put("材料名", matName);
vars.put("班级", r.orgHint == null ? "" : r.orgHint);
// ⚠️ ext 必须自带点号:renderName 的既有调用点是 `"." + ext`,
// 传裸扩展名不会报错,只会产出"身份证jpg"这种打不开的文件
String rendered = processService.renderName(template, vars, "." + ext);
// 同名去重:历史包里"身份证(1).jpg"这种重复很常见,
// 直接覆盖会让客户少一份材料(而报告上看起来一切正常)
r.standardName = uniqueName(usedNames, rendered);
r.renamed = !r.standardName.equals(lastSegment(e.path()));
return r;
}
private static String lastSegment(String path) {
String p = path.replace('\\', '/');
int i = p.lastIndexOf('/');
return i < 0 ? p : p.substring(i + 1);
}
private static String extOf(String path) {
String s = lastSegment(path);
int dot = s.lastIndexOf('.');
return dot > 0 ? s.substring(dot + 1) : "bin";
}
private static String uniqueName(Set<String> used, String name) {
if (used.add(name)) {
return name;
}
int dot = name.lastIndexOf('.');
String base = dot > 0 ? name.substring(0, dot) : name;
String ext = dot > 0 ? name.substring(dot) : "";
for (int i = 2; i < 1000; i++) {
String cand = base + "(" + i + ")" + ext;
if (used.add(cand)) {
return cand;
}
}
return name;
}
/** 上一次生成的整理结果(供 put 用;避免在 record 里传引用的别扭写法)。 */
private byte[] lastResult;
private long writeResultZip(List<Resolved> resolved) throws IOException {
ByteArrayOutputStream bos = new ByteArrayOutputStream();
try (org.apache.commons.compress.archivers.zip.ZipArchiveOutputStream zip =
new org.apache.commons.compress.archivers.zip.ZipArchiveOutputStream(bos)) {
// ★ UTF-8 文件名:与导出包同口径。不设 EFS 标志位时 Windows 会按本地代码页猜,
// 那个 bug 只在客户机器上出现
zip.setEncoding(StandardCharsets.UTF_8.name());
zip.setUseZip64(org.apache.commons.compress.archivers.zip.Zip64Mode.AsNeeded);
for (Resolved r : resolved) {
// 目录:班级/姓名/材料 —— 与导出包同一形状,客户拿到就能直接用
String org = r.orgHint == null || r.orgHint.isBlank() ? "未分班" : r.orgHint;
String name = r.rosterName != null ? r.rosterName
: (r.studentName == null ? "未知姓名" : r.studentName);
String mat = r.materialCode == null ? "待归位"
: HistoryNameParser.materialNameOf(r.materialCode);
String entryName = org + "/" + name + "/" + mat + "/" + r.standardName;
var ze = new org.apache.commons.compress.archivers.zip.ZipArchiveEntry(entryName);
ze.setSize(r.bytes.length);
zip.putArchiveEntry(ze);
zip.write(r.bytes);
zip.closeArchiveEntry();
}
}
byte[] out = bos.toByteArray();
lastResult = out;
return out.length;
}
/**
* 体检报告。
*
* <p>两个关键数字必须算对:
* <ul>
* <li>{@code autoHandled}:系统全自动处理了 —— 即"规范化成功且能对上名册"的;
* <li>{@code needManual}:必须人工判断的 —— 串档 + 无法归位 + 待人工确认的证件号冲突。
* </ul>
* 契约里写明「报告里最重要的一句话是 autoHandled / needManual」,
* 所以它们**由服务端算**,不让前端去减(前端减出来的数字早晚会分叉)。
*/
private CleanupDtos.ReportVO buildReport(Long jobId, List<Resolved> resolved) {
CleanupDtos.ReportVO vo = new CleanupDtos.ReportVO();
vo.setJobId(jobId);
CleanupDtos.ReportSummary s = vo.getSummary();
s.setTotalFiles(resolved.size());
// 串档检测:同一个人有 ≥2 项材料,**全部**与名册证件号不一致,
// 且这些材料彼此一致(说明"整个文件夹被换过")
Map<Long, List<Resolved>> byRoster = new HashMap<>();
for (Resolved r : resolved) {
if (r.rosterId != null) {
byRoster.computeIfAbsent(r.rosterId, k -> new ArrayList<>()).add(r);
}
}
int auto = 0;
int manual = 0;
for (Resolved r : resolved) {
if (r.issue == null) {
auto++;
} else if ("IDCARD_MISMATCH".equals(r.issue)) {
s.setIdcardMismatch(s.getIdcardMismatch() + 1);
s.setIdcardNeedConfirm(s.getIdcardNeedConfirm() + 1);
manual++;
} else {
s.setOrphanImages(s.getOrphanImages() + 1);
manual++;
}
}
for (Map.Entry<Long, List<Resolved>> en : byRoster.entrySet()) {
List<Resolved> list = en.getValue();
long mismatched = list.stream().filter(x -> "IDCARD_MISMATCH".equals(x.issue)).count();
if (list.size() >= 2 && mismatched == list.size()) {
s.setCrossFileSuspected(s.getCrossFileSuspected() + 1);
CollectRoster ro = rosterMapper.selectById(en.getKey());
CleanupDtos.Highlight hl = new CleanupDtos.Highlight();
hl.setType("CROSS_FILE");
hl.setLevel(3);
hl.setRosterId(en.getKey());
hl.setRealName(ro == null ? null : ro.getRealName());
hl.setTitle("疑似串档");
hl.setDetail("该生 " + list.size() + " 项材料的证件号与名册均不同,且 "
+ list.size() + " 项彼此一致");
hl.setSuggestion("可能整个文件夹被换过,请核对学生本人");
vo.getHighlights().add(hl);
}
}
// 改名样例(给客户看"系统到底做了什么"):最多 5 条
for (Resolved r : resolved) {
if (vo.getNameFixedSamples().size() >= 5) {
break;
}
if (r.renamed) {
CleanupDtos.NameFixSample sample = new CleanupDtos.NameFixSample();
sample.setOriginal(lastSegment(r.path));
sample.setFixed(r.standardName);
sample.setRule("VAL-06");
vo.getNameFixedSamples().add(sample);
}
}
// 证件号冲突明细
for (Resolved r : resolved) {
if (!"IDCARD_MISMATCH".equals(r.issue)) {
continue;
}
CollectRoster ro = r.rosterId == null ? null : rosterMapper.selectById(r.rosterId);
CleanupDtos.IdcardConflict c = new CleanupDtos.IdcardConflict();
c.setRosterId(r.rosterId);
c.setRealName(ro == null ? r.studentName : ro.getRealName());
c.setRosterValue(ro == null ? null : ro.getIdCardMask());
c.setFileNameValue(maskOf(r.idCard));
// ⚠️ 判定只能是"待人工":没有 OCR,唯一的证据是文件名,
// 而文件名本身正是这次要修的东西 —— 用它去改数据是循环论证
c.setVerdict("NEED_CONFIRM");
c.setVerdictText("文件名里的证件号与名册不一致;没有识别结果可交叉验证,请人工确认");
vo.getIdcardConflicts().add(c);
CleanupDtos.Highlight hl = new CleanupDtos.Highlight();
hl.setType("IDCARD_MISMATCH");
hl.setLevel(2);
hl.setRosterId(r.rosterId);
hl.setRealName(c.getRealName());
hl.setTitle("证件号与名册不一致");
hl.setDetail("文件名:" + c.getFileNameValue() + ",名册:" + c.getRosterValue());
hl.setSuggestion("核对本人证件号后改名册或改文件名");
vo.getHighlights().add(hl);
}
// 无法归位
for (Resolved r : resolved) {
if (!"ORPHAN".equals(r.issue)) {
continue;
}
CleanupDtos.Highlight hl = new CleanupDtos.Highlight();
hl.setType("ORPHAN");
hl.setLevel(2);
hl.setRealName(r.studentName);
hl.setTitle("无法归位");
hl.setDetail("从路径「" + r.path + "」里没能确定这是谁的材料");
hl.setSuggestion("把文件名改成「姓名-证件号-材料名」或放进以姓名命名的文件夹");
vo.getHighlights().add(hl);
}
vo.setAutoHandled(auto);
vo.setNeedManual(manual);
return vo;
}
private String maskOf(String idCard) {
if (idCard == null || idCard.length() < 14) {
return null;
}
return idCard.substring(0, 6) + "********" + idCard.substring(idCard.length() - 4);
}
// ── 内部 ──────────────────────────────────────
private CollectCleanupJob requireJob(Long id) {
CollectCleanupJob j = jobMapper.selectById(id);
if (j == null) {
throw new BizException(ErrorCode.VALIDATION_BLOCKED, "整理任务不存在或已删除");
}
return j;
}
private void progress(Long jobId, int status, int pct) {
CollectCleanupJob upd = new CollectCleanupJob();
upd.setId(jobId);
upd.setStatus(status);
upd.setProgress(pct);
jobMapper.updateById(upd);
}
private void markFailed(Long jobId, Throwable e) {
try {
CollectCleanupJob upd = new CollectCleanupJob();
upd.setId(jobId);
upd.setStatus(CollectCleanupJob.STATUS_FAILED);
upd.setProgress(0);
String msg = e.getMessage() == null ? e.getClass().getSimpleName() : e.getMessage();
upd.setReportJson(JSONUtil.createObj().set("error", clip(msg, 400)).toString());
upd.setFinishTime(java.time.LocalDateTime.now());
jobMapper.updateById(upd);
} catch (RuntimeException inner) {
log.error("标记整理失败时又出错 jobId={}", jobId, inner);
}
}
private static String clip(String s, int max) {
return s == null ? null : (s.length() <= max ? s : s.substring(0, max));
}
private CleanupDtos.JobVO toVo(CollectCleanupJob j) {
CleanupDtos.JobVO vo = new CleanupDtos.JobVO();
vo.setId(j.getId());
vo.setJobName(j.getJobName());
vo.setSourceType(j.getSourceType());
vo.setRosterTaskId(j.getRosterTaskId());
vo.setStatus(j.getStatus());
vo.setStatusText(statusText(j.getStatus()));
vo.setProgress(j.getProgress());
vo.setTotalFiles(j.getTotalFiles());
vo.setProcessedFiles(j.getProcessedFiles());
vo.setParsedStudents(j.getParsedStudents());
vo.setParsedMaterials(j.getParsedMaterials());
vo.setRawSizeBytes(j.getRawSizeBytes());
vo.setFinalSizeBytes(j.getFinalSizeBytes());
vo.setResultPath(j.getResultPath());
vo.setTargetTaskId(j.getTargetTaskId());
vo.setCreateTime(j.getCreateTime());
vo.setFinishTime(j.getFinishTime());
if (j.getRawSizeBytes() != null && j.getRawSizeBytes() > 0 && j.getFinalSizeBytes() != null) {
double ratio = 100.0 * (1 - (double) j.getFinalSizeBytes() / j.getRawSizeBytes());
if (ratio < 0) {
// ⚠️ **负数压缩率是没有意义的,不能就这么显示出去。**
//
// 什么时候会负:包里的文件**本身就已经是压缩过的**(jpg/zip 里的图片),
// 或者总量很小 —— 重新打一个 zip 的**包头开销**比省下来的还多。
// 实测一个 3 个小文件(各 40 字节)的包:raw=120B、final=802B → `-568.3%`。
//
// 显示 `-568.3%` 的问题不是"不好看",而是**客户会以为系统算错了**
// (而它没算错,"压缩"这个词在小包上本来就不适用)。
// 所以这里给 `—` —— 与"还没算"同一个符号:**都是"这个数说明不了什么"**。
vo.setCompressRatio("—");
} else {
vo.setCompressRatio(Math.round(ratio * 10) / 10.0 + "%");
}
} else {
// — 而不是 0%:0% 看起来像"一点没压缩",而真相是"还没算"
vo.setCompressRatio("—");
}
if (j.getStatus() != null && j.getStatus() == CollectCleanupJob.STATUS_FAILED
&& j.getReportJson() != null && j.getReportJson().contains("\"error\"")) {
vo.setErrorMsg(JSONUtil.parseObj(j.getReportJson()).getStr("error"));
}
return vo;
}
private String statusText(Integer status) {
return switch (status == null ? 0 : status) {
case CollectCleanupJob.STATUS_QUEUED -> "排队中";
case CollectCleanupJob.STATUS_PARSING -> "解析中";
case CollectCleanupJob.STATUS_PROCESSING -> "处理中";
case CollectCleanupJob.STATUS_DONE -> "已完成";
case CollectCleanupJob.STATUS_FAILED -> "失败";
default -> "未知";
};
}
/** 供测试/排查用:只跑解析这一步(不落库、不打包)。 */
public List<Map<String, Object>> dryRunParse(byte[] zip) {
List<Map<String, Object>> out = new ArrayList<>();
for (Entry e : readZip(zip)) {
HistoryNameParser.Hint h = HistoryNameParser.parse(e.path());
Map<String, Object> m = new LinkedHashMap<>();
m.put("path", e.path());
m.put("studentName", h.studentName());
m.put("idCard", h.idCard() == null ? null : maskOf(h.idCard()));
m.put("idCardValid", h.idCard() != null && IdCardUtil.isValid(h.idCard()));
m.put("materialCode", h.materialCode());
m.put("orgHint", h.orgHint());
out.add(m);
}
return out;
}
/** 保留:给"文件夹来源"分支留的口子(一期走不到)。 */
static String normalizePath(String p) {
return IdentityNormalizer.toHalfWidth(p).replace('\\', '/');
}
}
@@ -1,296 +0,0 @@
package cn.tianbaotong.fill.module.cleanup.service;
import cn.tianbaotong.fill.support.IdentityNormalizer;
import java.util.ArrayList;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Set;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
/**
* 从**历史文件名与目录名**里猜出"这是谁、哪项材料"。
*
* <h3>这个类的全部难点:客户的历史文件夹是"人长出来的",不是机器生成的</h3>
*
* <p>实测(PRD §5.9 的背景)见到的形态:
* <pre>
* 24中技数控1班/张三/身份证.jpg.jpg
* 24中技数控1班/张三-361029200907295110/一寸照片.png
* 24数控/张三 身份证/IMG_2034.jpg
* .\李四\学籍表\学籍表(1).JPG
* </pre>
* 所以解析必须**从右往左找最有信息量的那段**,而不是"按固定层级取"——
* 后者在换一个班之后一定失效。
*
* <h3>两条不准做的事</h3>
*
* <ol>
* <li><b>不猜身份证号</b>:只认**完整 18 位且校验位正确**的。文件名里出现
* 一串 18 位数字很可能是别的编号(学号、订单号),猜错会把材料挂到别人身上 ——
* 而"疑似串档"恰恰是这份报告最有价值的一条,不能自己先制造出来。</li>
* <li><b>不把"猜不出"当成失败</b>:猜不出就归到"待人工归位",
* 由报告里那一条 {@code needManual} 说清。硬塞一个分类会让报告撒谎。</li>
* </ol>
*/
public final class HistoryNameParser {
private HistoryNameParser() {
}
/** 18 位身份证号(末位可为 X)。 */
private static final Pattern ID_CARD = Pattern.compile("(\\d{17}[\\dXx])");
/** 材料关键词 → 材料名(客户的历史命名习惯,与内置材料的别名有重叠但不完全相同)。 */
private static final String[][] MATERIAL_HINTS = {
{"身份证", "IDCARD"}, {"正面", "IDCARD"}, {"反面", "IDCARD"}, {"人像面", "IDCARD"},
{"国徽面", "IDCARD"},
{"一寸", "PHOTO1"}, {"照片", "PHOTO1"}, {"证件照", "PHOTO1"}, {"免冠", "PHOTO1"},
{"学籍", "SCHOOL_ROLL"}, {"学籍表", "SCHOOL_ROLL"},
{"工作证明", "WORK_PROOF"}, {"在职", "WORK_PROOF"}, {"证明", "WORK_PROOF"},
{"备案", "XJW_BA"},
{"其他", "OTHER_PROOF"}, {"其它", "OTHER_PROOF"}, {"佐证", "OTHER_PROOF"},
};
/** 一个文件路径解析出来的线索。 */
public record Hint(String studentName, String idCard, String materialCode,
String materialName, String orgHint, String fileName) {
}
/**
* 解析一条相对路径。
*
* @param relativePath 形如 {@code 24中技数控1班/张三/身份证.jpg}
*/
public static Hint parse(String relativePath) {
String p = relativePath.replace('\\', '/');
String[] segs = p.split("/");
// 去掉空段(形如 `./李四/...` 会切出一个空串)
List<String> parts = new ArrayList<>();
for (String s : segs) {
if (s != null && !s.isBlank() && !".".equals(s.trim())) {
parts.add(s.trim());
}
}
if (parts.isEmpty()) {
return new Hint(null, null, null, null, null, null);
}
String fileName = parts.get(parts.size() - 1);
// 目录段(不含文件名)
List<String> dirs = parts.size() > 1 ? parts.subList(0, parts.size() - 1) : List.of();
String idCard = null;
String name = null;
String orgHint = null;
// ① 身份证号:从**文件名与所有目录段**里找第一个合法的 18 位
//
// 为什么目录段也要找:常见的形态是"张三-361029200907295110"当文件夹名,
// 此时文件名只是 `身份证.jpg`。
Set<String> pool = new LinkedHashSet<>();
pool.add(fileName);
pool.addAll(dirs);
for (String s : pool) {
Matcher m = ID_CARD.matcher(s);
while (m.find()) {
String cand = m.group(1);
if (cn.tianbaotong.fill.common.util.IdCardUtil.isValid(cand)) {
idCard = cand.toUpperCase();
break;
}
}
if (idCard != null) {
break;
}
}
// ② 姓名:优先"带身份证号的那一段"里、号码前面的中文
// 其次取最靠近文件的目录名(班级 → 姓名 → 文件 是最常见的结构)
for (String s : pool) {
if (idCard != null && s.contains(idCard)) {
String before = s.substring(0, s.indexOf(idCard));
name = chineseName(before);
if (name != null) {
break;
}
}
}
if (name == null && looksLikePersonSegment(stripExt(fileName))) {
// 从文件名里找(`张三-身份证.jpg`)
name = chineseName(stripExt(fileName));
}
if (name == null && !dirs.isEmpty()) {
// 从最深的目录往上找第一个像姓名的(2~4 个汉字)
for (int i = dirs.size() - 1; i >= 0; i--) {
if (!looksLikePersonSegment(dirs.get(i))) {
continue;
}
String cand = chineseName(dirs.get(i));
if (cand != null) {
name = cand;
break;
}
}
}
// ③ 班级:最浅的那一层目录(通常就是班级),但**排除**看起来是姓名的那一层
for (String s : dirs) {
if (name != null && s.contains(name)) {
continue;
}
if (s.contains("班") || s.contains("级") || s.contains("数控")
|| s.contains("机电") || s.contains("专业")) {
orgHint = s;
break;
}
}
// ④ 材料:在"文件名 + 各级目录名"里找关键词(**文件名优先**,它更具体)
String materialCode = null;
String materialName = null;
for (String s : pool) {
for (String[] kv : MATERIAL_HINTS) {
if (s.contains(kv[0])) {
materialCode = kv[1];
materialName = kv[0];
break;
}
}
if (materialCode != null) {
break;
}
}
return new Hint(name, idCard, materialCode, materialName, orgHint, fileName);
}
/** 这段文本是不是"材料文件夹名"(里面出现了材料关键词)。 */
private static boolean looksLikeMaterial(String s) {
if (s == null) {
return false;
}
for (String[] kv : MATERIAL_HINTS) {
if (s.contains(kv[0])) {
return true;
}
}
return false;
}
/**
/**
* 这个片段**整体**看起来像"一个人的名字"吗。
*
* <p>与 {@link #chineseName} 的分工:这里判断"这段文本是不是人名所在的片段",
* 那里从片段里抠出候选字。两条排除规则:
* <ol>
* <li>**含数字 → 不是人名**:"24中技数控1班" 会被抠出 "中技数控",
* 而那是班级不是人(真实人名里不会出现阿拉伯数字);</li>
* <li>**含班级/学校词 → 不是人名**("班"、"级"、"专业"、"学校"、"学院")。</li>
* </ol>
*/
static boolean looksLikePersonSegment(String s) {
if (s == null || s.isBlank()) {
return false;
}
if (s.matches(".*\\d.*")) {
return false;
}
for (String marker : new String[]{"班", "级", "专业", "学校", "学院", "系", "组"}) {
if (s.contains(marker)) {
return false;
}
}
return true;
}
/**
* 从一段文本里取"看起来是人名"的中文片段。
*
* <p>规则保守:**2~4 个连续汉字**,且不包含材料/班级关键词。
* 放宽到 1 个汉字会把"张"这种姓当成名字;
* 放宽到 5 个会把"数控加工中心"(专业名)当成名字。
*/
static String chineseName(String s) {
if (s == null || s.isBlank()) {
return null;
}
// 去掉常见的分隔符与噪声
String t = s.replaceAll("[-_()()【】\\[\\]\\s]+", "");
// ⚠️ 取**完整的**中文串,不要 {2,4} 截断 ——
// "新建文件夹"(5 字)被截成 "新建文件" 之后,噪声词表里查不到它,
// 于是那个文件被归到"新建文件"名下,而报告上看起来还挺像回事。
Matcher m = Pattern.compile("[\\u4e00-\\u9fa5]+").matcher(t);
while (m.find()) {
String cand = m.group();
// 人名是 2~4 个汉字;连续中文超过 4 个字说明这是个标签("新建文件夹"),不是人
if (cand.length() < 2 || cand.length() > 4) {
continue;
}
if (isNoiseWord(cand)) {
continue;
}
// 材料词("身份证"、"一寸照片"、"学籍表")出现在目录名里时,
// 它指的是"这是哪项材料",不是"这是谁" —— 由材料词表负责判断
if (looksLikeMaterial(s)) {
continue;
}
return cand;
}
return null;
}
/**
* 明显不是姓名的词(材料名 / 班级词 / 目录名)。
*
* <p>⚠️ <b>判据必须收得很紧,因为中文人名与业务词高度重叠</b>。
* 我第一版写的是 {@code w.contains(n)},于是「<b>整理</b>甲」这种名字里
* 只要含"整理"就被当噪声滤掉 —— 表现是**所有文件都归不了位**,
* 报告里冒出一堆 orphan,而串档检测也因此失去基准(全都对不上名册)。
*
* <p>⚠️ <b>只做精确匹配,不做包含</b>。我中间写过一版"长度 ≤2 的词被包含就算噪声",
* 而"整理"正好是 2 个字 —— 于是「<b>整理</b>甲」又被滤掉了。
* 教训是:<b>中文人名与业务短词无法用"包含"区分</b>,
* 这条规则只要存在,早晚会吃掉一个真名,而失败是静默的(文件变成"无法归位")。
*
* <p>"是不是材料/班级词"由 {@link #MATERIAL_HINTS} 与下面这份词表**各自**判断:
* 前者管材料目录名("身份证"、"一寸照片"),后者管纯噪声词。
* 职责分开之后,这边只需要回答"这个字面量本身是不是一个业务词"。
*/
private static boolean isNoiseWord(String w) {
String[] noise = {"身份证", "一寸", "照片", "学籍", "工作", "证明", "备案", "其他", "其它",
"佐证", "材料", "扫描", "复印", "副本", "正本", "原图", "最终", "新版", "旧版",
"中技", "数控", "机电", "专业", "班级", "学院", "学校", "高级", "中级", "初级",
"认定", "报名", "收集", "整理", "待整理", "已整理", "未整理", "补充", "补交",
// 目录名噪声:这些是"文件夹"而不是人,混进候选会让归位结果看起来可信但实际是错的
"新建", "文件夹", "新建文件夹", "未命名", "待定", "临时", "归档", "历史",
"材料汇总", "待整理材料", "杂项", "其它材料", "其他材料"};
for (String n : noise) {
if (w.equals(n)) {
return true;
}
}
return false;
}
private static String stripExt(String s) {
int dot = s.lastIndexOf('.');
return dot > 0 ? s.substring(0, dot) : s;
}
/** 材料码 → 中文名(写进规范文件名用)。 */
public static String materialNameOf(String code) {
for (String[] kv : MATERIAL_HINTS) {
if (kv[1].equals(code)) {
return kv[0];
}
}
return code;
}
/** 把解析到的证件号规范化(全角转半角 + 去空白),与其它入口同口径。 */
public static String normalizeIdCard(String raw) {
return IdentityNormalizer.idCard(raw);
}
}
@@ -16,6 +16,8 @@ import cn.tianbaotong.fill.module.fill.service.SubmissionValidator;
import cn.tianbaotong.fill.module.file.storage.StorageService;
import cn.tianbaotong.fill.module.material.dto.MaterialDefVO;
import cn.tianbaotong.fill.module.material.service.MaterialDefService;
import cn.tianbaotong.fill.module.notify.entity.CollectMessage;
import cn.tianbaotong.fill.module.notify.service.NotificationService;
import cn.tianbaotong.fill.module.roster.entity.CollectRoster;
import cn.tianbaotong.fill.module.roster.mapper.CollectRosterMapper;
import cn.tianbaotong.fill.module.task.entity.CollectTask;
@@ -114,6 +116,7 @@ public class ExportService {
private final SubmissionValidator validator;
private final MaterialDefService materialDefService;
private final StorageService storageService;
private final NotificationService notificationService;
private final FillProperties props;
/**
@@ -682,6 +685,43 @@ public class ExportService {
log.info("导出完成 exportId={} 人数={} 分卷={} 包体={}MB(原始 {}MB)",
exportId, total, volumes.size(), round1(size / 1024.0 / 1024.0),
round1(written / 1024.0 / 1024.0));
// ★ 通知发起人(《08》F-103):打包是异步的(几百份材料要十几秒到几分钟),
// 在此之前用户只能守着进度条 —— 而 `collect_message` 的 bizType 6
// (导出完成)一直没有代码写过,消息中心那个分类永远是空的。
notifyExportFinish(exportId, true, null);
}
/**
* 导出结束的通知(成功与失败都发)。
*
* <p>⚠️ **正文里不放下载链接**,只让他去「导出中心」取。
* 理由不是省事:七牛是私有空间,链接是**签名 URL**,而站内信是落库的 ——
* 把签名链接写进消息正文就等于"URL 入库",与《AGENTS.md》里
* 「URL 不能入库、不能发出去让人明天再打开」直接冲突(那个链接几十分钟后就失效)。
* 内置模板里原本写着 {@code {下载链接}},本轮一并改掉了(见《08》F-103)。
*/
private void notifyExportFinish(Long exportId, boolean ok, String failReason) {
try {
CollectExportTask t = exportTaskMapper.selectById(exportId);
if (t == null || t.getCreateBy() == null) {
return;
}
notificationService.notify(NotificationService.NotifyRequest.ofTemplate(
t.getCreateBy(), CollectMessage.BIZ_EXPORT,
"EXPORT_DONE",
NotificationService.NotifyRequest.vars(
"导出包名", t.getExportName() == null ? "导出包" : t.getExportName()),
ok ? "导出完成" : "导出失败",
CollectMessage.LINK_NONE, null, null)
.withExtra(ok
? "共 " + nzInt(t.getFileCount()) + " 份材料,请到「导出中心」下载。"
: "原因:" + (failReason == null ? "打包失败" : failReason)
+ "。请到「导出中心」查看后重试。"));
} catch (RuntimeException e) {
// 通知失败不影响导出结果本身(与审核通知同一口径)
log.warn("导出完成的通知没发出去 exportId={}", exportId, e);
}
}
private long nzLong(Long v) {
@@ -704,6 +744,9 @@ public class ExportService {
} catch (RuntimeException inner) {
log.error("标记导出失败时又出错 exportId={}", exportId, inner);
}
// 失败也要通知:否则用户会一直等一个永远不会完成的包
notifyExportFinish(exportId, false,
e.getMessage() == null ? e.getClass().getSimpleName() : e.getMessage());
}
/**
@@ -42,7 +42,7 @@ public class FormSchemaController {
}
@PutMapping
@RequirePerm(PermissionDef.TASK_MANAGE)
@RequirePerm(PermissionDef.FORM_MANAGE)
@Operation(summary = "保存任务表单定义(版本化)",
description = "每次保存插入一个新版本,旧版本保留。baseVersion 与当前最新不符时返回 40025,"
+ "并在 data 里带上最新定义供前端做差异合并(响应格式:{code:40025, data:最新schema})。"
@@ -90,6 +90,42 @@ public class FormSchemaService {
// ── 保存 ──────────────────────────────────────
/**
* 用一份**已经存在**的 schemaJson 作为新版本保存(规则模板套用 / 任务克隆场景)。
*
* <p>与 {@link #save(Long, FormSchemaSaveReq)} 的区别:内容<b>不再校验与归一化</b> ——
* 它当初就是从某个任务按本服务的规则存出来的,再走一遍"解析 → 对象 → 序列化"
* 只会丢掉没建模的字段(与 {@code TaskCloneService} 复制表单是同一个理由)。
* <b>状态检查与版本管理保留</b>(与手工保存同一套,两处必须一致)。
*
* @return 新版本号
*/
@Transactional(rollbackFor = Exception.class)
public int saveRawVersion(Long taskId, String schemaJson) {
if (schemaJson == null || schemaJson.isBlank()) {
throw new BizException(ErrorCode.VALIDATION_BLOCKED, "表单定义为空,无可保存内容");
}
CollectTask task = requireTask(taskId);
int status = task.getStatus() == null ? -1 : task.getStatus();
if (!EDITABLE_TASK_STATUS.contains(status)) {
throw new BizException(ErrorCode.VALIDATION_BLOCKED,
"任务已" + statusText(status) + ",表单不可再改");
}
int nextVersion = latestVersion(taskId) + 1;
formSchemaMapper.update(null, Wrappers.<CollectFormSchema>lambdaUpdate()
.eq(CollectFormSchema::getTaskId, taskId)
.set(CollectFormSchema::getIsCurrent, CollectFormSchema.HISTORY));
CollectFormSchema entity = new CollectFormSchema();
entity.setTaskId(taskId);
entity.setVersion(nextVersion);
entity.setSchemaJson(schemaJson);
entity.setIsCurrent(CollectFormSchema.CURRENT);
formSchemaMapper.insert(entity);
log.info("任务 {}({})表单按已存定义保存为新版本 v{}(套用模板/克隆)",
taskId, task.getTaskCode(), nextVersion);
return nextVersion;
}
@Transactional(rollbackFor = Exception.class)
public FormSchemaVO save(Long taskId, FormSchemaSaveReq req) {
CollectTask task = requireTask(taskId);
@@ -1,72 +0,0 @@
package cn.tianbaotong.fill.module.join.controller;
import cn.tianbaotong.fill.common.api.ApiResult;
import cn.tianbaotong.fill.module.join.dto.JoinDtos;
import cn.tianbaotong.fill.module.join.service.JoinService;
import cn.tianbaotong.fill.security.PermissionDef;
import cn.tianbaotong.fill.security.RequirePerm;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
/**
* 管理侧的加入申请审批(《03-接口设计》§8.7.3 ~ §8.7.5)。
*
* <p>权限码是现成的 {@code join:audit}({@code PermissionDef.JOIN_REQUEST_AUDIT})——
* 不需要新加:它就是为这条通道定义的。
*
* <p>⚠️ <b>没有"批量通过"接口</b>,这是刻意的(契约 §8.7.5 的取舍):
* 批量通过要求管理员先逐条看过(列表已提供信息),而**驳回必须逐条填原因**;
* 且批量通过**不提供"全选"快捷键**。理由与"批量通过只带绿色项"同源 ——
* 身份认定环节的误操作代价极高。
*/
@Tag(name = "管理端 · 名单加入申请")
@RestController
@RequestMapping("/api/v1/roster/join-requests")
@RequiredArgsConstructor
public class JoinRequestController {
private final JoinService joinService;
@GetMapping
@RequirePerm(PermissionDef.JOIN_REQUEST_AUDIT)
@Operation(summary = "申请待办列表",
description = "默认只看待审(status=0)。每条带 waitHours(等最久的排最前)与 "
+ "duplicateHint(该证件号在**其他任务**的名册里出现过,帮判断是不是本校学生)")
public ApiResult<JoinDtos.AdminListVO> list(@RequestParam(required = false) Long taskId,
@RequestParam(required = false) Integer status,
@RequestParam(required = false) Long orgId,
@RequestParam(required = false) String keyword,
@RequestParam(required = false) Long pageNum,
@RequestParam(required = false) Long pageSize) {
return ApiResult.ok(joinService.list(taskId, status, orgId, keyword, pageNum, pageSize));
}
@PostMapping("/{id}/approve")
@RequirePerm(PermissionDef.JOIN_REQUEST_AUDIT)
@Operation(summary = "通过(建名册项 + 自动绑定)",
description = "事务内做三件事:建名册项、把申请人绑上去(免他重输)、回写申请。"
+ "不传 orgId 时用学生的自述班级匹配组织名,匹配不上(或撞名)会要求手选。"
+ "notifySent 恒为 false(通知渠道未实现),需要人工告知学生")
public ApiResult<JoinDtos.ApproveVO> approve(@PathVariable Long id,
@RequestBody(required = false) JoinDtos.ApproveReq req) {
return ApiResult.ok(joinService.approve(id, req));
}
@PostMapping("/{id}/reject")
@RequirePerm(PermissionDef.JOIN_REQUEST_AUDIT)
@Operation(summary = "驳回(原因必填)",
description = "原因会原样给学生看,所以要说清「哪里不对、该怎么办」。"
+ "已通过的申请不能驳回(40042)—— 要撤销请删名册项")
public ApiResult<JoinDtos.RejectVO> reject(@PathVariable Long id,
@RequestBody JoinDtos.RejectReq req) {
return ApiResult.ok(joinService.reject(id, req));
}
}
@@ -1,48 +0,0 @@
package cn.tianbaotong.fill.module.join.controller;
import cn.tianbaotong.fill.common.api.ApiResult;
import cn.tianbaotong.fill.module.join.dto.JoinDtos;
import cn.tianbaotong.fill.module.join.service.JoinService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import java.util.Map;
/**
* 学生侧的名单加入申请(《03-接口设计》§8.7.1 / §8.7.2)。
*
* <p>权限:登录即可(学生)。**不额外挂权限码** —— 这条通道就是给
* "门禁没匹配上、什么权限都没有"的人用的,要求权限码等于把门关上。
* 限次(同 openid 每任务 ≤2 次)在服务层做,那才是这条通道的闸门。
*/
@Tag(name = "填报端 · 名单加入申请")
@RestController
@RequestMapping("/api/v1/mp/roster/join-request")
@RequiredArgsConstructor
public class MpJoinRequestController {
private final JoinService joinService;
@PostMapping
@Operation(summary = "提交加入申请",
description = "**只解决「进名单」,不绕过门禁** —— 申请期间学生仍不能填报。"
+ "证件号立刻转 HMAC + 脱敏,明文不落库。"
+ "已在名册(40039)/ 已有待审申请(40038)/ 超过 2 次(40037)都会被拒")
public ApiResult<JoinDtos.SubmitVO> submit(@RequestBody JoinDtos.SubmitReq req) {
return ApiResult.ok(joinService.submit(req));
}
@GetMapping
@Operation(summary = "我的申请(含状态与驳回原因)",
description = "通过之后 canFillNow=true:说明已经自动绑定,学生返回重新核验就能直接进")
public ApiResult<Map<String, Object>> mine(@RequestParam(required = false) Long taskId) {
return ApiResult.ok(joinService.myRequests(taskId));
}
}
@@ -1,252 +0,0 @@
package cn.tianbaotong.fill.module.join.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Getter;
import lombok.Setter;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.List;
/**
* 名单加入申请(《03-接口设计》§8.7.1 ~ §8.7.5)。
*
* <p>学生侧与管理侧各有一份形状:学生只看得到自己的申请与状态,
* 管理侧要看到"谁、什么证件号、等多久、有没有重复线索"。
*/
public final class JoinDtos {
private JoinDtos() {
}
// ── 学生侧 ────────────────────────────────────
/** 提交申请(§8.7.1)。 */
@Getter
@Setter
@Schema(description = "提交加入申请")
public static class SubmitReq {
private Long taskId;
/** 姓名(明文存储,业务必需)。 */
private String realName;
/** 身份证号 —— **服务端立刻转 HMAC + 脱敏,明文不落库**。 */
private String idCardNo;
private String phone;
/** 自述班级。 */
private String orgHint;
private String reason;
}
/** 提交结果。 */
@Getter
@Setter
public static class SubmitVO {
private Long requestId;
private Integer status;
private String statusText;
/** 预计多久处理(给个量级,不是承诺)。 */
private Integer estimatedHours;
/** 还剩几次申请机会 —— 说清上限,比等他撞上 40037 再报错友好。 */
private Integer remainAttempts;
}
/** 学生看自己的申请(§8.7.2)。 */
@Getter
@Setter
public static class MyRequestVO {
private Long requestId;
private Long taskId;
private String taskName;
private Integer status;
private String statusText;
private String realName;
private String idCardMask;
private String orgHint;
private LocalDateTime createTime;
private LocalDateTime auditTime;
/** 驳回原因(学生需要知道为什么) */
private String rejectReason;
/** 通过后写入的名册项 */
private Long rosterId;
/** 通过之后能不能马上填 —— 界面据此给"返回重新核验"的按钮 */
private Boolean canFillNow;
}
// ── 管理侧 ────────────────────────────────────
/** 待办列表里的一条。 */
@Getter
@Setter
public static class AdminItemVO {
private Long requestId;
private Long taskId;
private String taskName;
private String realName;
private String idCardMask;
private String phoneMask;
private String orgHint;
private String reason;
private Integer status;
private String statusText;
private LocalDateTime createTime;
/** 已等待小时数(保留 1 位)—— 列表按它倒序,等最久的在最前 */
private Double waitHours;
/** 该证件号在**其他任务**的名册里出现过(帮管理员判断是不是本校学生) */
private DupHint duplicateHint;
private Long rosterId;
}
/** 重复线索。 */
@Getter
@Setter
public static class DupHint {
/** 一期只有 {@code IN_OTHER_TASK} */
private String type;
private String taskName;
private String realName;
}
/** 待办列表。 */
@Getter
@Setter
public static class AdminListVO {
private List<AdminItemVO> list = new ArrayList<>();
private long total;
private Counts counts;
/** 待审太多时的提醒(FR-509c:单任务待审 > 50 条) */
private Alert alert;
}
/** 三个计数。 */
@Getter
@Setter
public static class Counts {
private long pending;
private long approved;
private long rejected;
}
/** 提醒。 */
@Getter
@Setter
public static class Alert {
/** {@code 2} = 警告级 */
private Integer level;
private String text;
}
/** 审批通过(§8.7.4)。 */
@Getter
@Setter
@Schema(description = "通过加入申请")
public static class ApproveReq {
/** 指定班级。不传则取学生的自述班级(匹配不上就报错让他手选)。 */
private Long orgId;
/** 修正姓名(管理员核对后可改) */
private String realName;
/** 修正证件号 */
private String idCardNo;
/** 序号(默认追加到末尾) */
private Integer seqNo;
}
/** 通过结果。 */
@Getter
@Setter
public static class ApproveVO {
private Long requestId;
private Integer status;
private Long rosterId;
private Boolean rosterCreated;
/** ⚠️ 一期恒为 false(通知渠道 M5-12 还没做)—— **不写 true** */
private Boolean notifySent;
/** true = 该 openid 已绑定,学生再次进入直接放行 */
private Boolean canFillNow;
}
/** 驳回(§8.7.5)。 */
@Getter
@Setter
@Schema(description = "驳回加入申请")
public static class RejectReq {
/** **必填** —— 会原样推送给学生 */
private String reason;
/** 快捷原因码:{@code NOT_THIS_SCHOOL} / {@code INFO_MISMATCH} / {@code NOT_IN_SCOPE} / {@code OTHER} */
private String reasonCode;
}
/** 驳回结果。 */
@Getter
@Setter
public static class RejectVO {
private Long requestId;
private Integer status;
/** 一期恒为 false,见 {@link ApproveVO#getNotifySent()} */
private Boolean notifySent;
}
}
@@ -1,100 +0,0 @@
package cn.tianbaotong.fill.module.join.entity;
import cn.tianbaotong.fill.support.TenantEntity;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Getter;
import lombok.Setter;
import java.time.LocalDateTime;
/**
* 名单加入申请(对应 {@code collect_join_request},FR-509 · 客户确认后新增)。
*
* <h3>这条通道解决什么、不解决什么</h3>
*
* <p>门禁不通过的人原先只能"联系老师",老师再手工加名单 —— 几百人的批次下老师负担极重。
* 这里给一条<b>受控通道</b>:学生自助申请 → 管理员审批 → 自动入册。
*
* <p>⚠️ <b>关键边界:它只解决"进名单",不绕过门禁。</b>
* 申请期间学生仍然不能填报 —— 否则门禁就不再是准入门槛,
* 而是"填个表就能过"的形式主义,那整个产品的根基(凭证件号认人)就没了。
*
* <h3>证件号与手机号都只有 HMAC 与脱敏值</h3>
*
* <p>与 {@code collect_roster} 同一口径:{@code id_card_hash} 用于等值查重与审批后写名册,
* {@code id_card_mask} 用于展示。<b>明文不落库</b> —— 申请来自未核验的人,
* 更不该把明文留在库里。
*
* <p>唯一索引 {@code uk_join_task_idcard} 的作用:一个任务下同一证件号
* 只能有一条"待审或已通过"的申请,避免同一个人反复堆申请。
*/
@Getter
@Setter
@TableName("collect_join_request")
public class CollectJoinRequest extends TenantEntity {
/** {@code 0} 待审核 / {@code 1} 已通过 / {@code 2} 已驳回。 */
public static final int STATUS_PENDING = 0;
public static final int STATUS_APPROVED = 1;
public static final int STATUS_REJECTED = 2;
/** 来源:{@code 1} 学生自助申请(一期只有这一种)。 */
public static final int SOURCE_STUDENT = 1;
/** 同任务最多申请几次(契约 §8.7.1:同 openid 每任务 ≤2 次)。 */
public static final int MAX_ATTEMPTS = 2;
private Long taskId;
private String realName;
/** HMAC 的证件号索引(无法反推,用于查重与写名册时定位)。 */
private String idCardHash;
/**
* 证件号 <b>AES-GCM 密文</b>(V7 迁移新增)。
*
* <p>为什么申请表也要存密文:审批通过要往 {@code collect_roster} 插一行,
* 而那一行的 {@code id_card_no} 是 <b>NOT NULL</b> 的密文列。
* 只存 hash 的话,审批那一步会直接撞
* {@code null value in column "id_card_no" violates not-null constraint}(500)。
*
* <p>三条替代路都被排除:让学生先过门禁(先有鸡还是先有蛋)、
* 让管理员重输(等于把学生写错一位的风险复制给管理员)、
* 不写这一列(DDL 不允许,且审核看完整号时正是解这一列)。
*
* <p>⚠️ <b>是密文,不是明文</b> —— 明文只在提交请求的内存里存在过。
*/
private String idCardCipher;
private String idCardMask;
private String phoneHash;
private String phoneMask;
/** 自述班级(老师核对时用)。 */
private String orgHint;
private String reason;
private Integer status;
/** 驳回原因:<b>会原样推送给学生</b>,所以要写得能看懂。 */
private String rejectReason;
/** 通过后写入的名册项 id。 */
private Long rosterId;
private Long auditBy;
private LocalDateTime auditTime;
/** 申请人 openid(这条通道按 openid 限次与去重)。 */
private String applicantOpenid;
/** 申请人 uid(通过后写入名册的 {@code bind_user_id})。 */
private Long applicantUid;
private Integer source;
}
@@ -1,106 +0,0 @@
package cn.tianbaotong.fill.module.join.mapper;
import cn.tianbaotong.fill.module.join.entity.CollectJoinRequest;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import org.apache.ibatis.annotations.Update;
import java.util.List;
/**
* 加入申请。
*
* <p>带 SQL 的地方一律**显式写 {@code tenant_id}**:这些查询会在
* "还没登录"或"跨任务核对"的路径上用到,而多租户拦截器只能保证
* "有登录上下文时"注入条件 —— 显式写一遍是给自己留的证据。
* (对照 {@code MpSceneMapper}:匿名接口那一族也是这个口径。)
*/
public interface CollectJoinRequestMapper extends BaseMapper<CollectJoinRequest> {
/** 同一个人(openid)在这个任务下已经申请过几次 —— 含已驳回的(驳回后重填是允许的)。 */
@Select("""
SELECT COUNT(*) FROM collect_join_request
WHERE tenant_id = #{tenantId} AND task_id = #{taskId}
AND applicant_openid = #{openid} AND deleted = 0
""")
int countByApplicant(@Param("tenantId") Long tenantId,
@Param("taskId") Long taskId,
@Param("openid") String openid);
/** 某个证件号在本任务下有没有"待审或已通过"的申请(唯一索引同口径)。 */
@Select("""
SELECT COUNT(*) FROM collect_join_request
WHERE tenant_id = #{tenantId} AND task_id = #{taskId}
AND id_card_hash = #{hash} AND status IN (0, 1) AND deleted = 0
""")
int countActiveByIdCard(@Param("tenantId") Long tenantId,
@Param("taskId") Long taskId,
@Param("hash") String hash);
/**
* 这个证件号在**其他任务**的名册里出现过吗(帮管理员判断是不是本校学生)。
*
* <p>返回 taskId 与姓名;没有就返回空列表。**只查同一租户** ——
* 跨租户的"是不是本校学生"是别人的数据,不能拿来做判断依据。
*/
@Select("""
SELECT r.task_id AS taskId, r.real_name AS realName
FROM collect_roster r
WHERE r.tenant_id = #{tenantId} AND r.id_card_hash = #{hash}
AND r.task_id <> #{taskId} AND r.deleted = 0
ORDER BY r.task_id DESC
LIMIT 1
""")
List<DupHint> findInOtherTasks(@Param("tenantId") Long tenantId,
@Param("taskId") Long taskId,
@Param("hash") String hash);
/** 跨任务核对的返回行。 */
class DupHint {
private Long taskId;
private String realName;
public Long getTaskId() {
return taskId;
}
public void setTaskId(Long v) {
this.taskId = v;
}
public String getRealName() {
return realName;
}
public void setRealName(String v) {
this.realName = v;
}
}
/**
* 审批通过后回写申请单。
*
* <p>条件里带 {@code status = 0}:**"查"与"写"之间可能有人已经处理过**,
* 影响行数为 0 就说明被别人抢先了(并发下两个管理员同时点通过)。
* 这比"先查后写"可靠 —— 后者在并发时会给同一个人建两条名册。
*/
@Update("""
UPDATE collect_join_request
SET status = 1, roster_id = #{rosterId}, audit_by = #{auditor},
audit_time = NOW(), update_time = NOW()
WHERE id = #{id} AND tenant_id = #{tenantId} AND status = 0 AND deleted = 0
""")
int markApproved(@Param("tenantId") Long tenantId, @Param("id") Long id,
@Param("rosterId") Long rosterId, @Param("auditor") Long auditor);
/** 驳回的回写(同样带状态条件)。 */
@Update("""
UPDATE collect_join_request
SET status = 2, reject_reason = #{reason}, audit_by = #{auditor},
audit_time = NOW(), update_time = NOW()
WHERE id = #{id} AND tenant_id = #{tenantId} AND status = 0 AND deleted = 0
""")
int markRejected(@Param("tenantId") Long tenantId, @Param("id") Long id,
@Param("reason") String reason, @Param("auditor") Long auditor);
}
@@ -1,460 +0,0 @@
package cn.tianbaotong.fill.module.join.service;
import cn.hutool.json.JSONUtil;
import cn.tianbaotong.fill.common.api.ErrorCode;
import cn.tianbaotong.fill.common.context.UserContext;
import cn.tianbaotong.fill.common.exception.BizException;
import cn.tianbaotong.fill.common.util.MaskUtil;
import cn.tianbaotong.fill.module.join.dto.JoinDtos;
import cn.tianbaotong.fill.module.join.entity.CollectJoinRequest;
import cn.tianbaotong.fill.module.join.mapper.CollectJoinRequestMapper;
import cn.tianbaotong.fill.module.roster.entity.CollectRoster;
import cn.tianbaotong.fill.module.roster.mapper.CollectRosterMapper;
import cn.tianbaotong.fill.module.task.entity.CollectTask;
import cn.tianbaotong.fill.module.task.mapper.TaskMapper;
import cn.tianbaotong.fill.module.user.entity.SysUser;
import cn.tianbaotong.fill.module.user.mapper.UserMapper;
import cn.tianbaotong.fill.support.IdentityNormalizer;
import cn.tianbaotong.fill.support.SensitiveCodec;
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.Duration;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.regex.Pattern;
/**
* 名单加入申请(FR-509,《03-接口设计》§8.7)。
*
* <h3>这条通道的四条边界(都是刻意的,不要"顺手放宽")</h3>
*
* <ol>
* <li><b>只解决"进名单",不绕过门禁。</b>申请期间学生仍然不能填报 ——
* 否则门禁就变成"填个表就能过"的形式主义,整套"凭证件号认人"的根基就没了。</li>
* <li><b>每人每任务最多 2 次</b>({@code 40037})。不设上限的话,
* 一个输错号码的学生能刷出几十条申请,管理员的待办里全是同一个人。</li>
* <li><b>证件号已在名册 → {@code 40039} 让他回去重新核验</b>,而不是受理申请。
* 这种情况说明他<b>只是姓名输错了</b>,受理申请等于让管理员去纠正一个
* 本来自己就能改对的问题。这一步不违反"统一失败提示":
* 学生已经主动提供了正确证件号,此时说"你已在名单中"**不泄露任何新信息**。</li>
* <li><b>审批通过时自动绑定</b>({@code bind_user_id} = 申请人)——
* 不然学生还得再输一遍姓名与证件号,而这两项刚刚才由他提供过。</li>
* </ol>
*
* <h3>明文只在内存里存在</h3>
*
* <p>证件号与手机号在这里<b>立刻</b>转成 HMAC + 脱敏值,
* 落库的只有 hash 与 mask(与 {@code collect_roster} 同一口径)。
* 申请来自**尚未核验**的人,更不该把明文留在库里。
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class JoinService {
/** 与账号模块同一口径的手机号校验。 */
private static final Pattern PHONE = Pattern.compile("^1[3-9]\\d{9}$");
/** 预计处理时长(给个量级,不是承诺)。 */
private static final int ESTIMATED_HOURS = 24;
/** 待审超过这个数就在列表上提醒(FR-509c)。 */
private static final int ALERT_PENDING = 50;
private final CollectJoinRequestMapper joinMapper;
private final CollectRosterMapper rosterMapper;
private final TaskMapper taskMapper;
private final UserMapper userMapper;
private final SensitiveCodec codec;
// ── 学生侧 ─────────────────────────────────────
/** 提交申请(§8.7.1)。 */
public JoinDtos.SubmitVO submit(JoinDtos.SubmitReq req) {
if (req == null || req.getTaskId() == null) {
throw new BizException(ErrorCode.VALIDATION_BLOCKED, "缺少 taskId");
}
String name = IdentityNormalizer.name(req.getRealName());
if (name == null || name.isBlank()) {
throw new BizException(ErrorCode.VALIDATION_BLOCKED, "请填写姓名");
}
String idCard = IdentityNormalizer.idCard(req.getIdCardNo());
if (idCard == null || !idCard.matches("^\\d{17}[\\dXx]$")) {
throw new BizException(ErrorCode.VALIDATION_BLOCKED, "请填写 18 位身份证号");
}
String phone = req.getPhone() == null ? "" : req.getPhone().trim();
if (!PHONE.matcher(phone).matches()) {
throw new BizException(ErrorCode.VALIDATION_BLOCKED, "请填写正确的手机号(老师要能联系到你)");
}
Long tenantId = UserContext.tenantId();
Long uid = UserContext.uid();
CollectTask task = taskMapper.selectById(req.getTaskId());
if (task == null) {
throw new BizException(ErrorCode.VALIDATION_BLOCKED, "任务不存在或已删除");
}
if (task.getStatus() == null || task.getStatus() != CollectTask.STATUS_COLLECTING) {
throw new BizException(ErrorCode.TASK_NOT_OPEN, "任务当前不在收集期,暂时不能申请加入");
}
SysUser me = userMapper.selectById(uid);
String openid = me == null || me.getOpenId() == null ? "" : me.getOpenId();
if (openid.isBlank()) {
// 没有 openid 就没法按"人"限次(契约是"同 openid 每任务 ≤2 次")。
// 与其放宽成"不限次",不如明确拒绝 —— 放宽的口子会被反复利用。
throw new BizException(ErrorCode.VALIDATION_BLOCKED,
"当前登录方式不支持提交申请,请从微信里重新进入");
}
int used = joinMapper.countByApplicant(tenantId, req.getTaskId(), openid);
if (used >= CollectJoinRequest.MAX_ATTEMPTS) {
throw new BizException(ErrorCode.JOIN_REQUEST_LIMIT);
}
String hash = codec.hash(idCard);
// ① 已在名册 → 让他回去重新核验(不是受理申请)
List<CollectRoster> inRoster = rosterMapper.selectByHash(tenantId, req.getTaskId(), hash);
if (!inRoster.isEmpty()) {
throw new BizException(ErrorCode.ALREADY_IN_ROSTER);
}
// ② 同证件号已有待审或已通过的申请
if (joinMapper.countActiveByIdCard(tenantId, req.getTaskId(), hash) > 0) {
throw new BizException(ErrorCode.JOIN_REQUEST_DUPLICATE);
}
CollectJoinRequest r = new CollectJoinRequest();
r.setTaskId(req.getTaskId());
r.setRealName(name);
r.setIdCardHash(hash);
// ★ 密文也要存:审批通过时要原样写进名册的 id_card_no(NOT NULL 的密文列)。
// 只存 hash 的话审批那一步会撞非空约束 —— 这正是本轮发现并修掉的 F-19。
r.setIdCardCipher(codec.encrypt(idCard));
r.setIdCardMask(MaskUtil.maskIdCard(idCard));
r.setPhoneHash(codec.hash(phone));
r.setPhoneMask(MaskUtil.maskPhone(phone));
r.setOrgHint(req.getOrgHint() == null ? null
// ⚠️ 自述班级**不能**用 IdentityNormalizer.name() 规范化:那个方法会去掉空格
// (姓名要"张 三"→"张三"),而班级名里的空格是**有意义**的
// ("24中技数控1班 645451" 去掉空格就再也匹配不上组织了)。
// 这里只做全角转半角 + 去首尾空白。
: IdentityNormalizer.toHalfWidth(req.getOrgHint()).trim());
r.setReason(req.getReason() == null ? null : req.getReason().trim());
r.setStatus(CollectJoinRequest.STATUS_PENDING);
r.setApplicantOpenid(openid);
r.setApplicantUid(uid);
r.setSource(CollectJoinRequest.SOURCE_STUDENT);
joinMapper.insert(r);
log.info("加入申请已提交:task={} request={} name={}", req.getTaskId(), r.getId(), name);
JoinDtos.SubmitVO vo = new JoinDtos.SubmitVO();
vo.setRequestId(r.getId());
vo.setStatus(CollectJoinRequest.STATUS_PENDING);
vo.setStatusText("已提交,等待学校审核");
vo.setEstimatedHours(ESTIMATED_HOURS);
vo.setRemainAttempts(CollectJoinRequest.MAX_ATTEMPTS - used - 1);
return vo;
}
/** 学生看自己的申请(§8.7.2)。 */
public Map<String, Object> myRequests(Long taskId) {
Long uid = UserContext.uid();
var q = Wrappers.<CollectJoinRequest>lambdaQuery()
.eq(CollectJoinRequest::getApplicantUid, uid)
.eq(taskId != null, CollectJoinRequest::getTaskId, taskId)
.orderByDesc(CollectJoinRequest::getId);
List<CollectJoinRequest> rows = joinMapper.selectList(q);
Map<Long, String> taskNames = new HashMap<>();
List<Long> taskIds = rows.stream().map(CollectJoinRequest::getTaskId).distinct().toList();
if (!taskIds.isEmpty()) {
for (CollectTask t : taskMapper.selectByIds(taskIds)) {
taskNames.put(t.getId(), t.getTaskName());
}
}
List<JoinDtos.MyRequestVO> list = new ArrayList<>();
for (CollectJoinRequest r : rows) {
JoinDtos.MyRequestVO vo = new JoinDtos.MyRequestVO();
vo.setRequestId(r.getId());
vo.setTaskId(r.getTaskId());
vo.setTaskName(taskNames.get(r.getTaskId()));
vo.setStatus(r.getStatus());
vo.setStatusText(statusText(r.getStatus()));
vo.setRealName(r.getRealName());
vo.setIdCardMask(r.getIdCardMask());
vo.setOrgHint(r.getOrgHint());
vo.setCreateTime(r.getCreateTime());
vo.setAuditTime(r.getAuditTime());
vo.setRejectReason(r.getRejectReason());
vo.setRosterId(r.getRosterId());
// 通过 = 已经绑好了 → 学生重新核验就能直接进
vo.setCanFillNow(r.getStatus() != null && r.getStatus() == CollectJoinRequest.STATUS_APPROVED
&& r.getRosterId() != null);
list.add(vo);
}
Map<String, Object> out = new LinkedHashMap<>();
out.put("list", list);
out.put("total", list.size());
return out;
}
private String statusText(Integer status) {
return switch (status == null ? 0 : status) {
case CollectJoinRequest.STATUS_PENDING -> "待审核";
case CollectJoinRequest.STATUS_APPROVED -> "已通过,可以开始填报了";
case CollectJoinRequest.STATUS_REJECTED -> "未通过";
default -> "未知";
};
}
// ── 管理侧 ─────────────────────────────────────
/** 待办列表(§8.7.3)。 */
public JoinDtos.AdminListVO list(Long taskId, Integer status, Long orgId, String keyword,
Long pageNum, Long pageSize) {
Long tenantId = UserContext.tenantId();
int st = status == null ? CollectJoinRequest.STATUS_PENDING : status;
long pn = pageNum == null || pageNum < 1 ? 1 : pageNum;
long ps = pageSize == null || pageSize < 1 ? 10 : Math.min(pageSize, 100);
// orgId 筛的是"自述班级":申请还没有正式班级,只能按学生自己写的筛
var q = Wrappers.<CollectJoinRequest>lambdaQuery()
.eq(CollectJoinRequest::getStatus, st)
.eq(taskId != null, CollectJoinRequest::getTaskId, taskId)
.like(keyword != null && !keyword.isBlank(), CollectJoinRequest::getRealName,
keyword == null ? "" : keyword.trim())
.orderByAsc(CollectJoinRequest::getCreateTime);
var page = joinMapper.selectPage(
new com.baomidou.mybatisplus.extension.plugins.pagination.Page<>(pn, ps), q);
Map<Long, String> taskNames = new HashMap<>();
List<Long> taskIds = page.getRecords().stream()
.map(CollectJoinRequest::getTaskId).distinct().toList();
if (!taskIds.isEmpty()) {
for (CollectTask t : taskMapper.selectByIds(taskIds)) {
taskNames.put(t.getId(), t.getTaskName());
}
}
JoinDtos.AdminListVO out = new JoinDtos.AdminListVO();
List<JoinDtos.AdminItemVO> list = new ArrayList<>();
LocalDateTime now = LocalDateTime.now();
for (CollectJoinRequest r : page.getRecords()) {
JoinDtos.AdminItemVO vo = new JoinDtos.AdminItemVO();
vo.setRequestId(r.getId());
vo.setTaskId(r.getTaskId());
vo.setTaskName(taskNames.get(r.getTaskId()));
vo.setRealName(r.getRealName());
vo.setIdCardMask(r.getIdCardMask());
vo.setPhoneMask(r.getPhoneMask());
vo.setOrgHint(r.getOrgHint());
vo.setReason(r.getReason());
vo.setStatus(r.getStatus());
vo.setStatusText(statusText(r.getStatus()));
vo.setCreateTime(r.getCreateTime());
vo.setRosterId(r.getRosterId());
if (r.getCreateTime() != null) {
long mins = Duration.between(r.getCreateTime(), now).toMinutes();
vo.setWaitHours(Math.round(mins / 6.0) / 10.0);
}
// 重复线索:这个证件号在别的任务的名册里出现过吗
List<CollectJoinRequestMapper.DupHint> dup =
joinMapper.findInOtherTasks(tenantId, r.getTaskId(), r.getIdCardHash());
if (!dup.isEmpty()) {
JoinDtos.DupHint hint = new JoinDtos.DupHint();
hint.setType("IN_OTHER_TASK");
hint.setTaskName(taskNames.getOrDefault(dup.get(0).getTaskId(),
"任务 #" + dup.get(0).getTaskId()));
hint.setRealName(dup.get(0).getRealName());
vo.setDuplicateHint(hint);
}
list.add(vo);
}
out.setList(list);
out.setTotal(page.getTotal());
JoinDtos.Counts counts = new JoinDtos.Counts();
counts.setPending(countByStatus(taskId, CollectJoinRequest.STATUS_PENDING));
counts.setApproved(countByStatus(taskId, CollectJoinRequest.STATUS_APPROVED));
counts.setRejected(countByStatus(taskId, CollectJoinRequest.STATUS_REJECTED));
out.setCounts(counts);
if (counts.getPending() > ALERT_PENDING) {
JoinDtos.Alert alert = new JoinDtos.Alert();
alert.setLevel(2);
alert.setText("待审申请已达 " + counts.getPending() + " 条,建议集中处理");
out.setAlert(alert);
}
return out;
}
private long countByStatus(Long taskId, int status) {
return joinMapper.selectCount(Wrappers.<CollectJoinRequest>lambdaQuery()
.eq(taskId != null, CollectJoinRequest::getTaskId, taskId)
.eq(CollectJoinRequest::getStatus, status));
}
/**
* 审批通过(§8.7.4):建名册项 + 自动绑定 + 回写申请。
*
* <p><b>事务</b>:名册建了而申请没回写(或反过来),学生会看到"申请还在待审"
* 但名册里已经有他了 —— 他接着再申请一次就撞 {@code 40038}。两件事必须同生同死。
*/
@Transactional(rollbackFor = Exception.class)
public JoinDtos.ApproveVO approve(Long id, JoinDtos.ApproveReq req) {
Long tenantId = UserContext.tenantId();
Long uid = UserContext.uid();
CollectJoinRequest r = joinMapper.selectById(id);
if (r == null) {
throw new BizException(ErrorCode.VALIDATION_BLOCKED, "申请不存在或已删除");
}
if (r.getStatus() == null || r.getStatus() != CollectJoinRequest.STATUS_PENDING) {
throw new BizException(ErrorCode.JOIN_STATUS_CHANGED);
}
// 班级:优先用管理员指定的;没指定就用学生的自述班级去匹配组织名(匹配不上必须手选)
Long orgId = req == null ? null : req.getOrgId();
if (orgId == null) {
orgId = matchOrgByName(r.getOrgHint());
}
if (orgId == null) {
throw new BizException(ErrorCode.VALIDATION_BLOCKED,
"请指定这个学生要加入的班级"
+ (r.getOrgHint() == null ? "" : "(他自述是「" + r.getOrgHint() + "」)"));
}
if (rosterMapper.countOrg(tenantId, orgId) == 0) {
throw new BizException(ErrorCode.VALIDATION_BLOCKED, "班级不存在或不属于本单位");
}
// 证件号:允许管理员核对后修正(学生可能把号码写错了)
String idCard = req != null && req.getIdCardNo() != null && !req.getIdCardNo().isBlank()
? IdentityNormalizer.idCard(req.getIdCardNo()) : null;
String hash = r.getIdCardHash();
if (idCard != null) {
hash = codec.hash(idCard);
}
String name = req != null && req.getRealName() != null && !req.getRealName().isBlank()
? IdentityNormalizer.name(req.getRealName()) : r.getRealName();
// 名册里这个证件号被占了吗(可能刚被老师手工加进去)
if (!rosterMapper.selectByHash(tenantId, r.getTaskId(), hash).isEmpty()) {
throw new BizException(ErrorCode.ROSTER_ID_CARD_TAKEN);
}
CollectRoster row = new CollectRoster();
row.setTaskId(r.getTaskId());
row.setOrgId(orgId);
row.setSeqNo(req == null ? null : req.getSeqNo());
row.setRealName(name);
row.setIdCardMask(MaskUtil.maskIdCard(idCard != null ? idCard : r.getIdCardMask()));
row.setIdCardHash(hash);
// 名册的 id_card_no 是 **NOT NULL 的密文列**:管理员修正了就用新的,否则搬申请里那份
if (idCard != null) {
row.setIdCardNo(codec.encrypt(idCard));
} else if (r.getIdCardCipher() != null && !r.getIdCardCipher().isBlank()) {
row.setIdCardNo(r.getIdCardCipher());
} else {
// V7 之前提交的申请没有密文列。**不能静默建一条没有证件号的名册行** ——
// 那条行以后永远过不了名册校验,而且没人知道为什么。
throw new BizException(ErrorCode.VALIDATION_BLOCKED,
"这条申请缺少证件号信息,请让申请人在小程序里重新提交一次");
}
row.setPhone(r.getPhoneMask());
row.setPhoneHash(r.getPhoneHash());
// 来源留痕:老师日后问"这个人是哪来的",能答得上来
row.setExtJson(JSONUtil.createObj()
.set("source", "JOIN_REQUEST")
.set("joinRequestId", r.getId())
.set("orgHint", r.getOrgHint())
.toString());
// ★ 自动绑定(FR-509g):学生刚提供过姓名与证件号,不该让他再输一遍
row.setBindUserId(r.getApplicantUid());
row.setBindTime(LocalDateTime.now());
// bind_type = 3(来自申请)—— 与契约 §8.7.4 一致;1 门禁 / 2 管理员指定
row.setBindType(3);
row.setSubmitStatus(CollectRoster.SUBMIT_NONE);
rosterMapper.insert(row);
int affected = joinMapper.markApproved(tenantId, id, row.getId(), uid);
if (affected == 0) {
// 并发下别人先处理了 —— 整笔回滚(名册那一行也不能留)
throw new BizException(ErrorCode.JOIN_STATUS_CHANGED);
}
log.info("加入申请通过:request={} roster={} org={} by={}", id, row.getId(), orgId, uid);
// ⚠️ 通知(M5-12)还没做:只能靠老师口头/群里说一声。
// 这里如实回 false,**不为了让契约好看而写 true**(同驳回接口的口径)。
log.warn("加入申请已通过但**没有发出通知**(通知渠道未实现):request={} 需人工告知学生", id);
JoinDtos.ApproveVO vo = new JoinDtos.ApproveVO();
vo.setRequestId(id);
vo.setStatus(CollectJoinRequest.STATUS_APPROVED);
vo.setRosterId(row.getId());
vo.setRosterCreated(true);
vo.setNotifySent(false);
vo.setCanFillNow(r.getApplicantUid() != null);
return vo;
}
/** 自述班级 → 组织 id(按名字精确或包含匹配;命中多个就不猜,返回 null 让管理员手选)。 */
private Long matchOrgByName(String orgHint) {
if (orgHint == null || orgHint.isBlank()) {
return null;
}
Long tenantId = UserContext.tenantId();
var rows = orgNameToId(tenantId, orgHint.trim());
return rows.size() == 1 ? rows.get(0) : null;
}
private List<Long> orgNameToId(Long tenantId, String name) {
// 直接查 sys_org:几十个节点,一次取回来在内存里匹配
return rosterMapper.selectOrgIdsByName(tenantId, name);
}
/** 驳回(§8.7.5)。 */
@Transactional(rollbackFor = Exception.class)
public JoinDtos.RejectVO reject(Long id, JoinDtos.RejectReq req) {
Long tenantId = UserContext.tenantId();
Long uid = UserContext.uid();
if (req == null || req.getReason() == null || req.getReason().isBlank()) {
throw new BizException(ErrorCode.VALIDATION_BLOCKED, "请填写驳回原因(学生要知道为什么)");
}
CollectJoinRequest r = joinMapper.selectById(id);
if (r == null) {
throw new BizException(ErrorCode.VALIDATION_BLOCKED, "申请不存在或已删除");
}
if (r.getStatus() != null && r.getStatus() == CollectJoinRequest.STATUS_APPROVED) {
// 已通过不能"驳回" —— 要撤销请删名册项(否则名册里那个人就成了没人认领的行)
throw new BizException(ErrorCode.JOIN_ALREADY_APPROVED);
}
if (r.getStatus() == null || r.getStatus() != CollectJoinRequest.STATUS_PENDING) {
throw new BizException(ErrorCode.JOIN_STATUS_CHANGED);
}
String reason = req.getReasonCode() == null || req.getReasonCode().isBlank()
? req.getReason().trim()
: "[" + req.getReasonCode() + "] " + req.getReason().trim();
int affected = joinMapper.markRejected(tenantId, id, reason, uid);
if (affected == 0) {
throw new BizException(ErrorCode.JOIN_STATUS_CHANGED);
}
log.info("加入申请驳回:request={} by={} reason={}", id, uid, reason);
log.warn("加入申请已驳回但**没有发出通知**(通知渠道未实现):request={}", id);
JoinDtos.RejectVO vo = new JoinDtos.RejectVO();
vo.setRequestId(id);
vo.setStatus(CollectJoinRequest.STATUS_REJECTED);
vo.setNotifySent(false);
return vo;
}
}
@@ -1,12 +1,15 @@
package cn.tianbaotong.fill.module.log.controller;
import cn.tianbaotong.fill.common.api.ApiResult;
import cn.tianbaotong.fill.common.api.ErrorCode;
import cn.tianbaotong.fill.common.api.PageResult;
import cn.tianbaotong.fill.common.context.LoginUser;
import cn.tianbaotong.fill.common.context.UserContext;
import cn.tianbaotong.fill.common.exception.BizException;
import cn.tianbaotong.fill.module.log.dto.LogDtos;
import cn.tianbaotong.fill.module.log.mapper.LoginLogMapper;
import cn.tianbaotong.fill.module.log.mapper.OperationLogMapper;
import cn.tianbaotong.fill.security.PermissionChecker;
import cn.tianbaotong.fill.security.PermissionDef;
import cn.tianbaotong.fill.security.RequirePerm;
import io.swagger.v3.oas.annotations.Operation;
@@ -25,8 +28,8 @@ import java.util.List;
/**
* 日志查询(《03-接口设计》§13.4)。
*
* <p>权限用 {@code log:viewTenant}(本租户操作日志)。
* 平台运维要看全量日志时用 {@code log:viewAll} —— 那是另一个接口(P2)。
* <p>权限是**两个码的任一个**:{@code log:viewTenant}(本租户操作日志,学校管理员/教务处)
* 或 {@code log:viewAll}(全量,平台运营)。范围由 {@link #logScopeTenantId} 决定。
* 日志里含**别人的操作轨迹与 IP**,不是所有管理员都该看到。
*
* <p>⚠️ 一期只做**查询**,不做导出:把操作日志导出成一个 Excel
@@ -41,15 +44,19 @@ public class LogController {
private final OperationLogMapper operationLogMapper;
private final LoginLogMapper loginLogMapper;
private final PermissionChecker permissionChecker;
@GetMapping("/operations")
@RequirePerm(PermissionDef.LOG_VIEW_TENANT)
@RequirePerm({PermissionDef.LOG_VIEW_TENANT, PermissionDef.LOG_VIEW_ALL})
@Operation(summary = "操作日志",
description = "Query:`operatorId` `module` `action` `targetType` `targetId` `from` `to` + 分页。"
description = "Query:`tenantId` `operatorId` `module` `action` `targetType` `targetId` `from` `to` + 分页。"
+ "**按 targetType+targetId 查**是这个接口最重要的用法 —— "
+ "「这张名册项的证件号被谁从什么改成了什么」要靠它回答,"
+ "而不是拿 request_param 全文搜(那会把「改了它」和「顺带提到它」混在一起)")
+ "而不是拿 request_param 全文搜(那会把「改了它」和「顺带提到它」混在一起)。"
+ "⚠️ 范围:只有 `log:viewAll`(平台运营)能跨租户看**全量**,"
+ "也只有它能用 `tenantId` 指定某所学校;学校管理员恒为本租户")
public ApiResult<PageResult<LogDtos.OperationLogVO>> operations(
@RequestParam(required = false) Long tenantId,
@RequestParam(required = false) Long operatorId,
@RequestParam(required = false) String module,
@RequestParam(required = false) String action,
@@ -59,20 +66,48 @@ public class LogController {
@RequestParam(required = false) String to,
@RequestParam(defaultValue = "1") int pageNum,
@RequestParam(defaultValue = "20") int pageSize) {
LoginUser me = UserContext.require();
Long scope = logScopeTenantId(tenantId);
int pn = Math.max(1, pageNum);
int ps = Math.min(Math.max(1, pageSize), 200);
LocalDateTime f = parseFrom(from);
LocalDateTime t = parseTo(to);
List<LogDtos.OperationLogVO> list = operationLogMapper.listOperations(
me.tenantId(), operatorId, module, action, targetType, targetId,
scope, operatorId, module, action, targetType, targetId,
f, t, ps, (pn - 1) * ps);
long total = operationLogMapper.countOperations(
me.tenantId(), operatorId, module, action, targetType, targetId, f, t);
scope, operatorId, module, action, targetType, targetId, f, t);
return ApiResult.ok(PageResult.of(list, total, pn, ps));
}
/**
* 日志查询的<b>租户范围</b>:{@code null} = 全量(跨租户)。
*
* <p>这是"平台运营看不到任何日志"那个 bug 的修法(2026-10-09):
* 以前控制器恒传 {@code me.tenantId()},而平台运营的 tenantId 是 <b>0</b>,
* 日志却都挂在各租户下 → 列表永远空白、且不报错。
* 而 PRD §3.2 对平台运营要求的恰恰是「<b>全量</b>操作日志 ✅」。
*
* <p>规则三条:
* <ol>
* <li>有 {@code log:viewAll}(平台运营):<b>默认全量</b>;显式传 {@code tenantId} 时只看那一所;</li>
* <li>只有 {@code log:viewTenant}(学校管理员、教务处):<b>恒为本租户</b>;</li>
* <li>学校管理员传了**别的**租户 id → 直接 {@code 40301}。
* <b>不静默忽略</b>:静默忽略会让人以为"我按这个单位筛过了",其实没筛 ——
* 而这一屏是拿去回答合规问题的,不能有这种"看起来筛了"的错觉。</li>
* </ol>
*/
private Long logScopeTenantId(Long requested) {
LoginUser me = UserContext.require();
if (permissionChecker.has(PermissionDef.LOG_VIEW_ALL)) {
return requested;
}
if (requested != null && !requested.equals(me.tenantId())) {
throw new BizException(ErrorCode.FORBIDDEN, "只能查看本单位的日志");
}
return me.tenantId();
}
/**
* 日期入参解析。
*
@@ -108,13 +143,14 @@ public class LogController {
}
@GetMapping("/logins")
@RequirePerm(PermissionDef.LOG_VIEW_TENANT)
@RequirePerm({PermissionDef.LOG_VIEW_TENANT, PermissionDef.LOG_VIEW_ALL})
@Operation(summary = "登录日志",
description = "Query:`userId` `userType` `result` `from` `to` + 分页。"
+ "⚠️ 一期的登录日志**只有 PC 账号密码登录**(`loginType=PASSWORD`)—— "
+ "学生从小程序/H5 进来走的是微信授权,目前不写这张表(见 F-31)。"
+ "所以「学生登录记录为空」是已知情况,不是查询条件写错了")
description = "Query:`tenantId` `userId` `userType` `result` `from` `to` + 分页。"
+ "范围同操作日志(`log:viewAll` 可跨租户,学校管理员恒为本租户)。"
+ "登录方式 `loginType`:`PASSWORD`(PC 账号密码)/ `WECHAT`(学生从小程序、H5 走微信授权)。"
+ "⚠️ `location`(IP 归属地)一期**恒为 null** —— 没接 IP 库,返回 null 而不是编一个")
public ApiResult<PageResult<LogDtos.LoginLogVO>> logins(
@RequestParam(required = false) Long tenantId,
@RequestParam(required = false) Long userId,
@RequestParam(required = false) Integer userType,
@RequestParam(required = false) Boolean result,
@@ -122,7 +158,7 @@ public class LogController {
@RequestParam(required = false) String to,
@RequestParam(defaultValue = "1") int pageNum,
@RequestParam(defaultValue = "20") int pageSize) {
LoginUser me = UserContext.require();
Long scope = logScopeTenantId(tenantId);
int pn = Math.max(1, pageNum);
int ps = Math.min(Math.max(1, pageSize), 200);
LocalDateTime f = parseFrom(from);
@@ -131,8 +167,8 @@ public class LogController {
Integer ok = result == null ? null : (result ? 1 : 0);
List<LogDtos.LoginLogVO> list = loginLogMapper.listLogins(
me.tenantId(), userId, userType, ok, f, t, ps, (pn - 1) * ps);
long total = loginLogMapper.countLogins(me.tenantId(), userId, userType, ok, f, t);
scope, userId, userType, ok, f, t, ps, (pn - 1) * ps);
long total = loginLogMapper.countLogins(scope, userId, userType, ok, f, t);
return ApiResult.ok(PageResult.of(list, total, pn, ps));
}
}
@@ -49,6 +49,25 @@ public final class LogDtos {
private String userAgent;
/**
* 请求方法({@code GET} / {@code POST} / {@code PUT} / {@code DELETE})。
*
* <p>与 {@link #requestUri}、{@link #extraJson} 一起是「日志详情」要用的三列 ——
* 它们都是**轻字段**(实测最大 45 / 125 字节级),所以列表直接带上,
* 不需要为详情再开一个按 id 查的接口。
*/
private String requestMethod;
/** 请求路径(不含 query string)。 */
private String requestUri;
/**
* 补充上下文(JSON 串),如批量操作命中的 id 列表 / 停用组织时的 {@code deactivated}。
*
* <p>⚠️ 不是每一条都有(一期只有一部分动作带它)。
*/
private String extraJson;
private Integer bizCode;
private String traceId;
@@ -74,11 +93,28 @@ public final class LogDtos {
private Long id;
/**
* 登录者所属租户({@code 0} = 平台)。
*
* <p>平台运营看到的登录日志是**跨单位**的,没有这一列就分不清一行是哪所学校的 ——
* 而"某个账号什么时候登进来的"这个问题,在平台视角下**一定**要连着"哪个单位"一起回答。
* {@code sys_login_log.tenant_id} 本来就有(V2 建表时的 {@code NOT NULL DEFAULT 0}),
* 只是这个 VO 此前没把它带出来。
*/
private Long tenantId;
private Long userId;
private String userName;
/** 用户类型:1 超管 / 2 管理员 / 3 教师 / 4 学生 */
/**
* 用户类型:{@code 1} 平台运营 / {@code 2} 学校管理员 / {@code 3} 班主任 / {@code 4} 学生。
*
* <p>⚠️ 这里以前写的是「1 超管 / 2 管理员 / 3 教师 / 4 学生」,**是错的**。
* 权威来源是 {@code BuiltinRole.UserType} 与 {@code UserService.USER_TYPE_NAMES},
* 库列注释({@code sys_user.user_type})也与它们一致 ——
* 别按 `教师/学生` 那套去映射,本仓库曾因此把班主任当成社会人员拒掉过一次。
*/
private Integer userType;
/** {@code PASSWORD} / {@code WECHAT} / {@code PHONE} */
@@ -45,7 +45,7 @@ public interface LoginLogMapper extends BaseMapper<SysLoginLog> {
@InterceptorIgnore(tenantLine = "true")
@Select("""
<script>
SELECT l.id, l.user_id AS userId,
SELECT l.id, l.tenant_id AS tenantId, l.user_id AS userId,
COALESCE(u.real_name, l.username) AS userName,
u.user_type AS userType,
CASE l.login_type
@@ -60,7 +60,10 @@ public interface LoginLogMapper extends BaseMapper<SysLoginLog> {
l.create_time AS loginTime
FROM sys_login_log l
LEFT JOIN sys_user u ON u.id = l.user_id AND u.deleted = 0
WHERE l.tenant_id = #{tenantId}
WHERE 1 = 1
<if test="tenantId != null">
AND l.tenant_id = #{tenantId}
</if>
<if test="userId != null">
AND l.user_id = #{userId}
</if>
@@ -96,7 +99,10 @@ public interface LoginLogMapper extends BaseMapper<SysLoginLog> {
SELECT COUNT(*)
FROM sys_login_log l
LEFT JOIN sys_user u ON u.id = l.user_id AND u.deleted = 0
WHERE l.tenant_id = #{tenantId}
WHERE 1 = 1
<if test="tenantId != null">
AND l.tenant_id = #{tenantId}
</if>
<if test="userId != null">
AND l.user_id = #{userId}
</if>
@@ -18,6 +18,15 @@ import java.util.List;
* <p>查询走手写 SQL 并 {@code @InterceptorIgnore} + 显式 {@code tenant_id}:
* 靠拦截器隐式补条件会让"查不到数据"和"确实没有数据"分不清,
* 而日志查询恰恰是要能对客户说清"这段时间有没有人动过这张名册"的场景。
*
* <p>⚠️ {@code tenantId} 是<b>可选</b>条件({@code <if test="tenantId != null">}):
* 契约 §13.4 本来就把 {@code tenantId} 定义成查询参数。不传 = <b>全量</b>,
* 只有平台运营({@code log:viewAll})能走到这一支;学校管理员由
* {@code LogController} 恒填本租户。
*
* <p>以前这里写死 {@code l.tenant_id = #{tenantId}}、控制器恒传 {@code me.tenantId()} ——
* 而平台运营的 tenantId 是 0,于是它<b>一条日志都看不到</b>(列表空白,
* 没有任何报错),而 PRD §3.2 对平台运营要求的恰恰是「<b>全量</b>操作日志 ✅」。
*/
public interface OperationLogMapper extends BaseMapper<SysOperationLog> {
@@ -54,10 +63,15 @@ public interface OperationLogMapper extends BaseMapper<SysOperationLog> {
l.module, l.action, l.target_type AS targetType, l.target_id AS targetId,
l.before_val AS beforeVal, l.after_val AS afterVal,
l.ip, l.user_agent AS userAgent, l.biz_code AS bizCode, l.trace_id AS traceId,
l.request_method AS requestMethod, l.request_uri AS requestUri,
l.extra_json AS extraJson,
l.create_time AS createTime
FROM sys_operation_log l
LEFT JOIN sys_user u ON u.id = l.user_id AND u.deleted = 0
WHERE l.tenant_id = #{tenantId}
WHERE 1 = 1
<if test="tenantId != null">
AND l.tenant_id = #{tenantId}
</if>
<if test="operatorId != null">
AND l.user_id = #{operatorId}
</if>
@@ -100,7 +114,10 @@ public interface OperationLogMapper extends BaseMapper<SysOperationLog> {
<script>
SELECT COUNT(*)
FROM sys_operation_log l
WHERE l.tenant_id = #{tenantId}
WHERE 1 = 1
<if test="tenantId != null">
AND l.tenant_id = #{tenantId}
</if>
<if test="operatorId != null">
AND l.user_id = #{operatorId}
</if>
@@ -67,6 +67,16 @@ public class OperationLogService {
public static final String MODULE_JOIN = "JOIN";
public static final String MODULE_USER = "USER";
public static final String MODULE_FILE = "FILE";
/** 角色与授权。改授权等于改"谁能做什么",是这套系统里最敏感的一类动作。 */
public static final String MODULE_ROLE = "ROLE";
/**
* 租户(平台运营的「开通 / 修改 / 停用学校」)。
*
* <p>⚠️ 这类日志的 {@code tenant_id} 是 <b>0</b>(平台身份),不是被操作的学校 ——
* 因为写日志用的是<b>操作者</b>的上下文。查"这所学校被谁停过"要用
* {@code target_type=TENANT & target_id=<租户 id>},不能用 tenant_id 筛。
*/
public static final String MODULE_TENANT = "TENANT";
public static final String ACTION_UPDATE_IDCARD = "UPDATE_IDCARD";
public static final String ACTION_UPDATE_ROSTER_NAME = "UPDATE_ROSTER_NAME";
@@ -78,23 +88,63 @@ public class OperationLogService {
public static final String ACTION_RESUME_TASK = "RESUME_TASK";
/** 截止任务。同理:契约表里没有,但它是不可逆的时点,必须留痕。 */
public static final String ACTION_CLOSE_TASK = "CLOSE_TASK";
/**
* 定时任务**自动**截止。
*
* <p>⚠️ 必须与 {@link #ACTION_CLOSE_TASK} 分开:出了争议时第一个要看的就是
* 「这个任务是到点自己关的,还是管理员手动关的」。
*/
public static final String ACTION_AUTO_CLOSE = "AUTO_CLOSE";
/**
* 任务状态变更(兜底动作名)。
*
* <p>只有在调用方**说不出**是哪个动作时才会用到;`patchStatus` 的注释解释了
* 为什么"发布"与"恢复"不能共用它(两者都是 →收集中,靠状态推会记成同一个动作)。
*/
public static final String ACTION_STATUS_CHANGE = "STATUS_CHANGE";
public static final String ACTION_ARCHIVE_TASK = "ARCHIVE_TASK";
/** 删除已归档任务(2026-09-30 客户确认,§17.2 D-9):级联软删 + 尽力删文件,必须留痕。 */
public static final String ACTION_DELETE_TASK = "DELETE_TASK";
public static final String ACTION_REVOKE_AUDIT = "REVOKE_AUDIT";
public static final String ACTION_EXPORT_CREATE = "EXPORT_CREATE";
public static final String ACTION_EXPORT_DOWNLOAD = "EXPORT_DOWNLOAD";
public static final String ACTION_PROXY_UPLOAD = "PROXY_UPLOAD";
public static final String ACTION_VIEW_IDCARD_FULL = "VIEW_IDCARD_FULL";
public static final String ACTION_CONFIG_COMPRESS = "CONFIG_COMPRESS";
public static final String ACTION_JOIN_APPROVE = "JOIN_APPROVE";
public static final String ACTION_JOIN_REJECT = "JOIN_REJECT";
/** 新建自定义角色。 */
public static final String ACTION_CREATE_ROLE = "CREATE_ROLE";
/** 改角色名称 / 用户类型 / 备注(角色码不可改)。 */
public static final String ACTION_UPDATE_ROLE = "UPDATE_ROLE";
/** 删除自定义角色(软删)。 */
public static final String ACTION_DELETE_ROLE = "DELETE_ROLE";
/** 改角色的权限码(授权变更 —— 留痕里记变更前后的码清单)。 */
public static final String ACTION_GRANT_ROLE = "GRANT_ROLE";
/** 改角色的可见菜单。 */
public static final String ACTION_UPDATE_ROLE_MENU = "UPDATE_ROLE_MENU";
/** 停用 / 启用角色。 */
public static final String ACTION_TOGGLE_ROLE = "TOGGLE_ROLE";
/** 开通租户(新建一所学校 + 它的内置角色与管理员账号)。 */
public static final String ACTION_CREATE_TENANT = "CREATE_TENANT";
/** 改租户信息与套餐(名称 / 类型 / 联系人 / 到期)。 */
public static final String ACTION_UPDATE_TENANT = "UPDATE_TENANT";
/**
* 停用租户 —— <b>该校所有学生与老师立刻登不进来</b>,
* 是这套系统里单个动作影响面最大的一类,所以单独一个动作码而不是并进 UPDATE。
*/
public static final String ACTION_DISABLE_TENANT = "DISABLE_TENANT";
/** 恢复启用租户。 */
public static final String ACTION_ENABLE_TENANT = "ENABLE_TENANT";
public static final String TARGET_ROSTER = "ROSTER";
public static final String TARGET_TASK = "TASK";
public static final String TARGET_SUBMISSION = "SUBMISSION";
public static final String TARGET_FILE = "FILE";
public static final String TARGET_EXPORT = "EXPORT";
public static final String TARGET_JOIN_REQUEST = "JOIN_REQUEST";
public static final String TARGET_USER = "USER";
/** 角色({@code sys_role.id})。 */
public static final String TARGET_ROLE = "ROLE";
/** 租户({@code sys_tenant.id})。平台级动作的 target,见 {@link #MODULE_TENANT}。 */
public static final String TARGET_TENANT = "TENANT";
/**
* 记一条操作日志(**不抛异常**)。
@@ -0,0 +1,64 @@
package cn.tianbaotong.fill.module.log.service;
import cn.tianbaotong.fill.module.log.entity.SysLoginLog;
import cn.tianbaotong.fill.module.log.mapper.LoginLogMapper;
import jakarta.servlet.http.HttpServletRequest;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.slf4j.MDC;
import org.springframework.stereotype.Service;
/**
* 学生侧(微信)登录也写 {@code sys_login_log}(2026-09-30 客户确认补上,见《08》F-31)。
*
* <h3>为什么此前一直只有 PC 的</h3>
*
* <p>{@code AuthService.writeLog} 只挂在账号密码登录上,而学生从小程序/H5 进来走的是
* 微信授权(无密码),那条链路没人写日志 —— 于是按 §13.4 查"学生登录记录"永远是空的,
* 而这在合规上是个缺口("谁在什么时候登录过"必须两类用户都答得出来)。
*
* <h3>login_type 的取值(与 DDL 注释对齐:1 PC 2 小程序 3 开放接口)</h3>
*
* <p>小程序与 H5 网页授权**共用 {@code 2}**:DDL 只定义到 3,
* 自造新值就是又一处"文档与库不一致"。要区分两端时看 {@code user_agent}
* —— H5 是浏览器 UA,小程序是 {@code miniProgram},字段里本来就带着。
*
* <p>⚠️ 与 PC 侧同样的纪律:**写失败绝不影响登录本身**(否则日志表一满就没人能登录),
* 也不记任何凭据(code / openid 都不落库,username 用姓名或 "wx-{uid}")。
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class WechatLoginLogger {
/** DDL:1 PC 2 小程序 3 开放接口。 */
public static final int LOGIN_TYPE_WECHAT = 2;
private final LoginLogMapper loginLogMapper;
/**
* 记一条成功的微信侧登录。
*
* @param displayName 姓名(没绑定名册的新用户可能是 null,此时记 "wx-{uid}")
*/
public void success(Long tenantId, Long userId, String displayName,
HttpServletRequest req) {
try {
SysLoginLog row = new SysLoginLog();
row.setTenantId(tenantId == null ? 0L : tenantId);
row.setUserId(userId);
String who = displayName == null || displayName.isBlank()
? ("wx-" + userId) : displayName;
row.setUsername(who.length() <= 64 ? who : who.substring(0, 64));
row.setLoginType(LOGIN_TYPE_WECHAT);
row.setSuccess(1);
row.setFailReason(null);
row.setIp(req == null ? null : cn.tianbaotong.fill.support.WebUtil.clientIp(req));
row.setUserAgent(req == null ? null : cn.tianbaotong.fill.support.WebUtil.userAgent(req));
row.setTraceId(MDC.get("traceId"));
loginLogMapper.insertIgnoreTenant(row);
} catch (Exception e) { // noqa: BLE001
log.warn("写微信侧登录日志失败(不影响登录): {}", e.getMessage());
}
}
}
@@ -47,7 +47,7 @@ public class MaterialDefController {
}
@PostMapping("/tasks/{id}/materials")
@RequirePerm(PermissionDef.TASK_MANAGE)
@RequirePerm(PermissionDef.MATERIAL_MANAGE)
@Operation(summary = "新增材料项",
description = "材料码在任务内唯一;不传名称/别名/压缩档时会按 §2.1 的材料目录补齐默认值。"
+ "压缩档位参数(固定尺寸/分辨率下限/目标DPI/输出格式)由档位决定,客户端不必也不应传")
@@ -57,7 +57,7 @@ public class MaterialDefController {
}
@PutMapping("/materials/{id}")
@RequirePerm(PermissionDef.TASK_MANAGE)
@RequirePerm(PermissionDef.MATERIAL_MANAGE)
@Operation(summary = "修改材料项", description = "材料码不可修改(它是文件的关联键)")
public ApiResult<Void> update(@PathVariable Long id,
@Valid @RequestBody MaterialDefSaveReq req) {
@@ -66,7 +66,7 @@ public class MaterialDefController {
}
@DeleteMapping("/materials/{id}")
@RequirePerm(PermissionDef.TASK_MANAGE)
@RequirePerm(PermissionDef.MATERIAL_MANAGE)
@Operation(summary = "删除材料项",
description = "已经上传过文件的材料项不可删除(40027);如果只是不想再收,"
+ "请设为选传或加条件显示规则隐藏")
@@ -76,7 +76,7 @@ public class MaterialDefController {
}
@PostMapping("/tasks/{id}/materials/apply-template")
@RequirePerm(PermissionDef.TASK_MANAGE)
@RequirePerm(PermissionDef.MATERIAL_MANAGE)
@Operation(summary = "按内置模板初始化材料清单",
description = "幂等:已存在的材料码会跳过。模板见 §7.10 的三个 TPL_*")
public ApiResult<Map<String, Object>> applyTemplate(@PathVariable Long id,
@@ -104,7 +104,9 @@ public class MaterialDefController {
Map<String, Object> out = new LinkedHashMap<>();
List<Map<String, Object>> codes = new java.util.ArrayList<>();
for (String code : MaterialCatalog.allCodes()) {
// ⚠️ 用 selectableCodes() 而不是 allCodes():学信网备案表 / 其它佐证材料
// 属已取消的二期,**读得到、选不到**(历史任务里配过的材料项仍能显示名字)。
for (String code : MaterialCatalog.selectableCodes()) {
MaterialCatalog.Kind kind = MaterialCatalog.of(code);
Map<String, Object> m = new LinkedHashMap<>();
m.put("code", code);
@@ -73,11 +73,29 @@ public final class MaterialCatalog {
}
}
/** 全部已知材料码,按 §2.1 的顺序。 */
/**
* 全部已知材料码,按 §2.1 的顺序。
*
* <p>⚠️ 这是<b>全集</b>(含只用于读历史数据的两类),不是"能新建的清单"。
* 界面下拉要用 {@link #selectableCodes()}。
*/
public static List<String> allCodes() {
return KINDS.stream().map(Kind::code).toList();
}
/**
* <b>一期实际可选</b>的材料码(4 类,全都有实测样本)。
*
* <p>为什么与 {@link #allCodes()} 分开:{@code XJW_BA}(学信网备案表)与
* {@code OTHER_PROOF}(其它佐证材料)属于**已取消的二期**,样本为零。
* 它们保留在 {@link #KINDS} 里只为了一件真实的事 ——
* 历史任务可能配过这两类材料,删掉目录会让那些材料项**悬空**(界面显示不出名字)。
* 所以:**读得到、选不到**。
*/
public static List<String> selectableCodes() {
return List.of("PHOTO1", "IDCARD", "SCHOOL_ROLL", "WORK_PROOF");
}
public static Kind of(String code) {
return code == null ? null : BY_CODE.get(code);
}
@@ -134,10 +152,10 @@ public final class MaterialCatalog {
public record TemplateItem(String code, String visibleWhen) {
}
/** §7.10 的三个内置模板。 */
/** 内置模板。**一期只有学校收集一种** —— 社会收集与专项职业能力已随二期一起取消。 */
private static final Map<String, List<TemplateItem>> TEMPLATES = new LinkedHashMap<>();
/** FR-402 的级联:在校生看学籍表,非在校生看工作证明 / 学信网备案表。 */
/** FR-402 的级联:在校生看学籍表,非在校生看工作证明。 */
private static final String WHEN_STUDENT = "{\"field\":\"is_student\",\"op\":\"eq\",\"value\":true}";
private static final String WHEN_NOT_STUDENT = "{\"field\":\"is_student\",\"op\":\"eq\",\"value\":false}";
@@ -150,17 +168,6 @@ public final class MaterialCatalog {
new TemplateItem("IDCARD", null),
new TemplateItem("SCHOOL_ROLL", WHEN_STUDENT),
new TemplateItem("WORK_PROOF", WHEN_NOT_STUDENT)));
// 社会收集:没有学籍表,多一张学信网备案表(非在校生才有)
TEMPLATES.put("TPL_SOCIAL", List.of(
new TemplateItem("PHOTO1", null),
new TemplateItem("IDCARD", null),
new TemplateItem("WORK_PROOF", null),
new TemplateItem("XJW_BA", WHEN_NOT_STUDENT)));
// 专项职业能力:只要三样
TEMPLATES.put("TPL_ZHUANXIANG", List.of(
new TemplateItem("PHOTO1", null),
new TemplateItem("IDCARD", null),
new TemplateItem("WORK_PROOF", null)));
}
public static List<TemplateItem> template(String templateCode) {
@@ -179,8 +186,6 @@ public final class MaterialCatalog {
m.put("templateCode", code);
m.put("label", switch (code) {
case "TPL_SCHOOL_ROLL" -> "学校收集(照片+身份证+学籍表+工作证明)";
case "TPL_SOCIAL" -> "社会收集(照片+身份证+工作证明+学信网备案表)";
case "TPL_ZHUANXIANG" -> "专项职业能力(照片+身份证+工作证明)";
default -> code;
});
m.put("materialCodes", items.stream().map(TemplateItem::code).toList());
@@ -48,6 +48,7 @@ public class MpH5AuthController {
private final MpH5AuthService authService;
private final JwtService jwtService;
private final cn.tianbaotong.fill.config.FillProperties props;
private final cn.tianbaotong.fill.module.log.service.WechatLoginLogger wechatLoginLogger;
@PublicApi
@GetMapping("/authorize")
@@ -64,7 +65,8 @@ public class MpH5AuthController {
@Operation(summary = "微信授权回调(匿名)",
description = "code 换 openid → 找/建学生 → 下发会话 → 302 回 H5(token 在 URL fragment 里)")
public ResponseEntity<Void> callback(@RequestParam(required = false) String code,
@RequestParam(required = false) String state) {
@RequestParam(required = false) String state,
HttpServletRequest request) {
String scene = authService.consumeScene(state);
try {
if (code == null || code.isBlank()) {
@@ -72,7 +74,7 @@ public class MpH5AuthController {
return redirectToApp(null, scene, "你取消了微信授权,请重新进入");
}
String openId = authService.exchangeOpenId(code);
return loginAndRedirect(openId, scene);
return loginAndRedirect(openId, scene, request);
} catch (BizException e) {
// ⚠️ 这个地址是**浏览器直接跳转**过来的。抛业务错误的话,用户看到的是
// 一屏 {"code":40001,"msg":"..."} 的 JSON —— 一个学生完全无法理解的画面,
@@ -99,12 +101,13 @@ public class MpH5AuthController {
return redirectToApp(null, scene, "打桩登录需要带合法的 scene(t{租户id}...)");
}
SysUser user = authService.stubLogin(tenantId, openid, request);
return redirectWithToken(user, scene);
ResponseEntity<Void> r = redirectWithToken(user, scene, request);
return r;
}
// ── 内部 ──────────────────────────────────────
private ResponseEntity<Void> loginAndRedirect(String openId, String scene) {
private ResponseEntity<Void> loginAndRedirect(String openId, String scene, HttpServletRequest request) {
Long tenantId = MpSceneService.tenantIdOf(scene);
if (tenantId == null) {
// 没带 scene 就没法定租户:这个人属于哪个学校还不知道。
@@ -112,10 +115,13 @@ public class MpH5AuthController {
return redirectToApp(null, scene, "请扫描班主任发的二维码进入");
}
SysUser user = authService.resolveUser(tenantId, openId);
return redirectWithToken(user, scene);
return redirectWithToken(user, scene, request);
}
private ResponseEntity<Void> redirectWithToken(SysUser user, String scene) {
private ResponseEntity<Void> redirectWithToken(SysUser user, String scene, HttpServletRequest request) {
// 学生侧登录也写 sys_login_log(2026-09-30 补上,F-31):H5 授权回调与打桩登录
// 都从这里下发 token,写一次就覆盖两条路径。写失败不影响登录
wechatLoginLogger.success(user.getTenantId(), user.getId(), user.getRealName(), request);
String token = jwtService.issue(
new LoginUser(user.getId(), user.getTenantId(), List.of(), java.util.Set.of("STUDENT"), 4),
JwtService.Client.MP);
@@ -41,11 +41,13 @@ public class MpLoginController {
description = "`{code, scene}` → 令牌 + `needBindPhone` + `boundRoster`。"
+ "⚠️ **scene 是必需的**:它决定你属于哪个学校(统一应用 + scene 识别租户)。"
+ "scene 可穷举,所以这里**不返回任何业务数据**,只返回"
+ "「需不需要绑手机」与「你在这次任务里有没有绑定名册」")
public ApiResult<MpLoginService.LoginVO> login(@RequestBody(required = false) Map<String, Object> body) {
+ "「需不需要绑手机」与「你在这次任务里有没有绑定名册」。"
+ "登录成功会写 sys_login_log(login_type=2,2026-09-30 补上,见 F-31)")
public ApiResult<MpLoginService.LoginVO> login(@RequestBody(required = false) Map<String, Object> body,
jakarta.servlet.http.HttpServletRequest request) {
String code = str(body == null ? null : body.get("code"));
String scene = str(body == null ? null : body.get("scene"));
return ApiResult.ok(loginService.login(code, scene));
return ApiResult.ok(loginService.login(code, scene, request));
}
@PostMapping("/bind-phone")
@@ -84,7 +84,6 @@ public interface TeacherDashboardMapper {
</if>
AND (
cto.head_user_id = #{userId}
OR cto.deputy_user_ids LIKE ('%,' || #{userId} || ',%')
OR (cto.head_user_id IS NULL AND o.leader_user_id = #{userId})
)
GROUP BY o.id, o.org_name
@@ -114,7 +113,6 @@ public interface TeacherDashboardMapper {
</if>
AND (
cto.head_user_id = #{userId}
OR cto.deputy_user_ids LIKE ('%,' || #{userId} || ',%')
OR (cto.head_user_id IS NULL AND o.leader_user_id = #{userId})
)
</script>
@@ -142,7 +140,6 @@ public interface TeacherDashboardMapper {
</if>
AND (
cto.head_user_id = #{userId}
OR cto.deputy_user_ids LIKE ('%,' || #{userId} || ',%')
OR (cto.head_user_id IS NULL AND o.leader_user_id = #{userId})
)
</script>
@@ -177,7 +174,6 @@ public interface TeacherDashboardMapper {
</if>
AND (
cto.head_user_id = #{userId}
OR cto.deputy_user_ids LIKE ('%,' || #{userId} || ',%')
OR (cto.head_user_id IS NULL AND o.leader_user_id = #{userId})
)
</script>
@@ -200,7 +196,6 @@ public interface TeacherDashboardMapper {
LEFT JOIN sys_org o ON o.id = cto.org_id AND o.deleted = 0
WHERE cto.task_id = t.id AND cto.status = 1
AND (cto.head_user_id = #{userId}
OR cto.deputy_user_ids LIKE ('%,' || #{userId} || ',%')
OR (cto.head_user_id IS NULL AND o.leader_user_id = #{userId}))
)
ORDER BY t.end_time ASC
@@ -167,7 +167,7 @@ public class MpH5AuthService {
if (nz(h5Mapper.countUserRole(tenantId, created.getId(), roleId)) == 0) {
h5Mapper.insertUserRole(tenantId, created.getId(), roleId);
}
log.info("H5 首次登录,已建学生账号:tenant={} uid={} openid={}***",
log.info("微信首次登录(H5 网页授权 / 小程序共用),已建学生账号:tenant={} uid={} openid={}***",
tenantId, created.getId(), openId.substring(0, Math.min(6, openId.length())));
return created;
}
@@ -14,6 +14,7 @@ import cn.tianbaotong.fill.module.task.mapper.TaskMapper;
import cn.tianbaotong.fill.module.user.entity.SysUser;
import cn.tianbaotong.fill.security.JwtService;
import cn.tianbaotong.fill.support.SensitiveCodec;
import cn.tianbaotong.fill.support.TenantScope;
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
@@ -31,6 +32,7 @@ import java.time.Duration;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
/**
* 小程序端登录与手机号绑定(《03-接口设计》§5.3)。
@@ -71,6 +73,7 @@ public class MpLoginService {
private final TaskMapper taskMapper;
private final JwtService jwtService;
private final SensitiveCodec codec;
private final cn.tianbaotong.fill.module.log.service.WechatLoginLogger wechatLoginLogger;
private final HttpClient http = HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(10)).build();
@@ -79,6 +82,14 @@ public class MpLoginService {
@Transactional(rollbackFor = Exception.class)
public LoginVO login(String code, String scene) {
return login(code, scene, null);
}
/**
* @param request 用于登录日志取 IP / UA(可为 null:定时或内部调用没有请求上下文)
*/
@Transactional(rollbackFor = Exception.class)
public LoginVO login(String code, String scene, jakarta.servlet.http.HttpServletRequest request) {
if (code == null || code.isBlank()) {
throw new BizException(ErrorCode.VALIDATION_BLOCKED, "缺少 code");
}
@@ -114,22 +125,40 @@ public class MpLoginService {
// 扫的是"任务码"时,顺带把他在这份任务里的名册绑定带回去 ——
// 前端据此决定进"填报页"还是"先核验身份",省掉一次请求
//
// ⚠️ 这段查询**必须**放进租户上下文:roster/task 都是租户表,
// 而登录是匿名请求(还没有登录态)。不做 TenantScope 的话,
// 租户拦截器会因为"拿不到 tenantId"直接拒绝 ——
// 症状是 /mp/login 带 taskId 的 scene 一律 500,而只带 t{tid} 的码却正常
// (那条路径根本不走到这里),很难从现象定位到原因。
if (s.taskId() != null) {
CollectRoster r = rosterMapper.selectOne(Wrappers.<CollectRoster>lambdaQuery()
.eq(CollectRoster::getTaskId, s.taskId())
.eq(CollectRoster::getBindUserId, user.getId())
.last("limit 1"));
if (r != null) {
CollectTask t = taskMapper.selectById(s.taskId());
Map<String, Object> br = new LinkedHashMap<>();
br.put("taskId", s.taskId());
br.put("taskName", t == null ? null : t.getTaskName());
br.put("rosterId", r.getId());
Map<String, Object> br = TenantScope.callAs(
s.tenantId(), user.getId(), Set.of("STUDENT"), () -> {
CollectRoster r = rosterMapper.selectOne(
Wrappers.<CollectRoster>lambdaQuery()
.eq(CollectRoster::getTaskId, s.taskId())
.eq(CollectRoster::getBindUserId, user.getId())
.last("limit 1"));
if (r == null) {
return null;
}
CollectTask t = taskMapper.selectById(s.taskId());
Map<String, Object> m = new LinkedHashMap<>();
m.put("taskId", s.taskId());
m.put("taskName", t == null ? null : t.getTaskName());
m.put("rosterId", r.getId());
return m;
});
if (br != null) {
vo.setBoundRoster(br);
}
}
log.info("小程序登录:tenant={} uid={} taskId={} 需绑手机={}",
s.tenantId(), user.getId(), s.taskId(), vo.getNeedBindPhone());
// 学生侧登录也写 sys_login_log(2026-09-30 补上,F-31):
// 以前只有 PC 账号密码登录留痕,按 §13.4 查"学生登录记录"永远是空的 —— 合规缺口。
// 写失败不影响登录(logger 内部吞掉);tenantId/姓名在这里才齐,所以放 service 而不是 controller
wechatLoginLogger.success(user.getTenantId(), user.getId(), user.getRealName(), request);
return vo;
}
@@ -86,7 +86,7 @@ public final class NotifyDtos {
@Schema(description = "登记订阅消息额度")
public static class SubscribeReq {
/** 模板码:SUBMIT_SUCCESS / AUDIT_RESULT / DEADLINE_REMIND / EXPORT_READY / JOIN_APPROVE */
/** 模板码:SUBMIT_SUCCESS / AUDIT_RESULT / DEADLINE_REMIND / EXPORT_READY */
private String templateCode;
/** 本次授权次数(微信返回,默认 1) */
@@ -30,7 +30,6 @@ public class CollectMessage extends TenantEntity {
public static final int BIZ_APPROVE = 4;
public static final int BIZ_URGE = 5;
public static final int BIZ_EXPORT = 6;
public static final int BIZ_JOIN_AUDIT = 7;
/** 点开消息跳哪儿。 */
public static final String LINK_SUBMISSION = "SUBMISSION";
@@ -32,6 +32,8 @@ public interface UrgeMapper {
private String orgName;
private Long taskId;
private String taskName;
/** 截止时间:模板里的 {@code {截止日}} 与 {@code {剩余天数}} 靠它(以前没取,那两个变量永远空)。 */
private java.time.LocalDateTime endTime;
private Integer submitStatus;
public Long getRosterId() { return rosterId; }
@@ -48,6 +50,8 @@ public interface UrgeMapper {
public void setTaskId(Long v) { this.taskId = v; }
public String getTaskName() { return taskName; }
public void setTaskName(String v) { this.taskName = v; }
public java.time.LocalDateTime getEndTime() { return endTime; }
public void setEndTime(java.time.LocalDateTime v) { this.endTime = v; }
public Integer getSubmitStatus() { return submitStatus; }
public void setSubmitStatus(Integer v) { this.submitStatus = v; }
}
@@ -68,6 +72,7 @@ public interface UrgeMapper {
o.org_name AS orgName,
t.id AS taskId,
t.task_name AS taskName,
t.end_time AS endTime,
r.submit_status AS submitStatus
FROM collect_roster r
JOIN collect_task t
@@ -94,7 +99,6 @@ public interface UrgeMapper {
</if>
<if test="scopeUserId != null">
AND (cto.head_user_id = #{scopeUserId}
OR cto.deputy_user_ids LIKE ('%,' || #{scopeUserId} || ',%')
OR (cto.head_user_id IS NULL AND o.leader_user_id = #{scopeUserId}))
</if>
ORDER BY o.org_name, r.id
@@ -105,6 +109,25 @@ public interface UrgeMapper {
@Param("orgIds") List<Long> orgIds,
@Param("scopeUserId") Long scopeUserId);
/**
* 这个任务的提交进度("{已提交}/{应填}"),给模板变量 {@code {提交进度}} 用。
*
* <p>契约 §12.7 的变量白名单里 {@code {提交进度}} 的适用场景就是催报 ——
* 而在此之前它**永远渲染成空**:内置模板写着"目前提交进度 {提交进度}",
* 没有任何调用方传过这个变量,于是学生收到的是"目前提交进度 ,请…"。
* 一个白名单里有、实际没人填的变量,与"定义了没接上"是同一类问题。
*
* <p>"已提交"按 {@code submit_status >= 2}(已提交 / 初审中 / 已通过…)算,
* 与班主任看板的已交率同一口径。
*/
@InterceptorIgnore(tenantLine = "true")
@Select("""
SELECT COUNT(*) FILTER (WHERE r.submit_status >= 2) || '/' || COUNT(*)
FROM collect_roster r
WHERE r.tenant_id = #{tenantId} AND r.deleted = 0 AND r.task_id = #{taskId}
""")
String progressOf(@Param("tenantId") Long tenantId, @Param("taskId") Long taskId);
/** 一个名册项(定向催报要先确认"这个人归我管")。 */
@InterceptorIgnore(tenantLine = "true")
@Select("""
@@ -115,6 +138,7 @@ public interface UrgeMapper {
o.org_name AS orgName,
t.id AS taskId,
t.task_name AS taskName,
t.end_time AS endTime,
r.submit_status AS submitStatus
FROM collect_roster r
JOIN collect_task t
@@ -289,7 +313,6 @@ public interface UrgeMapper {
AND r.task_id = #{taskId}
</if>
AND (cto.head_user_id = #{userId}
OR cto.deputy_user_ids LIKE ('%,' || #{userId} || ',%')
OR (cto.head_user_id IS NULL AND o.leader_user_id = #{userId}))
</script>
""")
@@ -60,22 +60,47 @@ import java.util.Map;
@RequiredArgsConstructor
public class NotificationService {
/** 模板码(契约 §13.6 的表)。 */
/**
* <b>微信订阅消息</b>的模板码(契约 §13.6 的「模板码与索取时机」表)。
*
* <p>⚠️ 这套码与 {@link NotifyTemplates} 里那套<b>站内信</b>模板码是
* <b>两个命名空间</b>,别混:
* <ul>
* <li>这一套({@code SUBMIT_SUCCESS} …)是**微信后台建好的订阅消息模板**的键,
* 用途有两个:查 {@code collect_subscribe_quota} 的额度、从
* {@code fill.mini-program.templates} 里取微信那边的 {@code templateId};</li>
* <li>{@link NotifyTemplates} 那套({@code TASK_PUBLISH / AUDIT_REJECT / URGE …})
* 是**站内信的文案模板**,管理员在「通知模板」页改的就是它。</li>
* </ul>
* ⚠️ 删掉「加入申请」之后,两套同名的那一个({@code JOIN_APPROVE})已经不存在了:
* 上一版这里专门写过"同名纯属巧合",现在两套码**没有任何重名**,
* 但仍要记住它们是两个命名空间(见《08》F-106)。
*/
public static final String TPL_SUBMIT_SUCCESS = "SUBMIT_SUCCESS";
public static final String TPL_AUDIT_RESULT = "AUDIT_RESULT";
public static final String TPL_DEADLINE_REMIND = "DEADLINE_REMIND";
public static final String TPL_EXPORT_READY = "EXPORT_READY";
public static final String TPL_JOIN_APPROVE = "JOIN_APPROVE";
private final CollectMessageMapper messageMapper;
private final SubscribeQuotaMapper quotaMapper;
private final NotifyRecordMapper notifyRecordMapper;
private final UserMapper userMapper;
private final WxMiniProgramService wxService;
private final NotifyTextResolver textResolver;
/**
* 发一条通知。
*
* <h3>文案从哪来:模板优先(FR-1002)</h3>
*
* <p>{@code req.subjectCode()} 非空时,标题与正文**以模板为准** ——
* 库里改过就用库里的、没改过用内置的({@link NotifyTextResolver})。
* 调用方传的 {@code title/content} 只在"模板码不认识 / 取模板失败"时兜底。
* 这样「通知模板」页改完就真的生效,包括驳回、通过、催报这些自动通知。
*
* <p>模板**被停用**时这条通知**不发**(这正是那个开关的意义),
* 并把原因放进 {@code failReason} 让调用方如实回 {@code notifySent=false}。
*
* @param req 内容与收件人(见 {@link NotifyRequest})
* @return 结果:{@code pushSent} 是"微信推送有没有发出去",
* {@code messageId} 是站内信 id(**一定存在**,除非写库本身失败)
@@ -84,13 +109,39 @@ public class NotificationService {
Long tenantId = req.tenantId() == null ? UserContext.tenantId() : req.tenantId();
Long userId = req.userId();
// ⓪ 文案:模板优先。这一步必须在写站内信之前 —— 模板停用意味着"根本不发"。
String title = req.title();
String content = req.content();
if (req.subjectCode() != null) {
NotifyTextResolver.Resolved r =
textResolver.resolve(req.subjectCode(), req.taskIdOrNull(), req.subjectVars());
if (r == null) {
log.warn("不认识的通知模板码 {},退回调用方给的文案(bizType={})",
req.subjectCode(), req.bizType());
} else if (!r.enabled()) {
// 停用是用户的显式选择:不发,也不写站内信与发送记录。
// 调用方会因此回 notifySent=false + needManualNotice=true,
// 于是老师知道"系统没发,你得自己说一声"。
return new NotifyResult(null, false,
"通知模板「" + req.subjectCode() + "」已被停用,这条通知未发出", null);
} else {
title = r.title();
content = r.content();
}
}
if (req.extraText() != null && !req.extraText().isBlank()) {
// 模板之外还要补一句(班级、老师附言、"今天是最后一天"这类上下文):
// 追加而不是替换 —— 模板是权威,但模板表达不了"这一次"的临时信息
content = (content == null ? "" : content + "\n") + req.extraText().trim();
}
// ① 先写站内信 —— 这是"用户一定能看到"的那条路,所以放在最前面。
// 顺序很关键:先试推送再写站内信的话,推送抛异常就可能把站内信也丢了。
CollectMessage msg = new CollectMessage();
msg.setUserId(userId);
msg.setBizType(req.bizType());
msg.setTitle(clip(req.title(), 128));
msg.setContent(clip(req.content(), 1024));
msg.setTitle(clip(title, 128));
msg.setContent(clip(content, 1024));
msg.setLinkType(req.linkType() == null ? CollectMessage.LINK_NONE : req.linkType());
msg.setLinkId(req.linkId());
msg.setIsRead(0);
@@ -115,7 +166,7 @@ public class NotificationService {
} else {
try {
wxService.sendSubscribeMessage(openid, wxTemplateIdOf(templateCode),
"pages/tasks/tasks", buildTemplateData(req));
"pages/tasks/tasks", buildTemplateData(req, title, content));
pushSent = true;
// 落地通道改成微信推送(站内信仍然在,学生随时能回来看)
messageMapper.update(null, Wrappers.<CollectMessage>lambdaUpdate()
@@ -137,7 +188,9 @@ public class NotificationService {
// ③ 如实记录发送通道结果(collect_notify_record 的定位就是这个)
// 注意:站内信本身"发成功"了,所以 status 记 1;推送那条另记一行。
recordNotify(tenantId, req, pushSent ? CollectMessage.CHANNEL_WECHAT_SUB
// ⚠️ 标题要记**渲染后**的那一份:用户改过模板时,
// "记录里是旧文案、消息中心是新文案"是查不出来的那种不一致。
recordNotify(tenantId, req, title, pushSent ? CollectMessage.CHANNEL_WECHAT_SUB
: CollectMessage.CHANNEL_IN_APP, pushSent, skippedNoQuota, failReason);
return new NotifyResult(msg.getId(), pushSent, failReason, msg.getChannel());
@@ -153,20 +206,78 @@ public class NotificationService {
public record NotifyResult(Long messageId, boolean pushSent, String failReason, String channel) {
}
/** 通知请求。 */
/**
* 通知请求。
*
* @param subjectCode 站内信**文案模板**码({@link NotifyTemplates},如 {@code AUDIT_REJECT})。
* 非空时标题与正文以模板为准(FR-1002)。为 {@code null} 表示"用调用方给的文案"
* @param subjectVars 模板变量(白名单见 {@link NotifyTemplates#VARIABLES})
* @param extraText 模板之外要补的一句(班级、老师附言、"今天是最后一天")。追加,不替换
* @param templateCode **微信订阅消息**的模板码(契约 §13.6,如 {@code AUDIT_RESULT})——
* 与 {@code subjectCode} 不是一回事,见 {@link #TPL_AUDIT_RESULT} 的注释
*/
public record NotifyRequest(Long tenantId, Long userId, int bizType, String title, String content,
String linkType, Long linkId, String templateCode,
Map<String, String> templateParams) {
Map<String, String> templateParams,
String subjectCode, Map<String, String> subjectVars, String extraText) {
/** 旧签名的兼容构造:不带站内信模板时走"调用方给文案"的老路(无模板可用)。 */
public NotifyRequest(Long tenantId, Long userId, int bizType, String title, String content,
String linkType, Long linkId, String templateCode,
Map<String, String> templateParams) {
this(tenantId, userId, bizType, title, content, linkType, linkId, templateCode,
templateParams, null, Map.of(), null);
}
/** 常用构造:学生端(带跳转)。{@code linkId} 在 {@code linkType=TASK} 时就是 taskId。 */
public static NotifyRequest of(Long userId, int bizType, String title, String content,
String linkType, Long linkId, String templateCode) {
return new NotifyRequest(null, userId, bizType, title, content, linkType, linkId,
templateCode, Map.of());
templateCode, Map.of(), null, Map.of(), null);
}
/**
* 这条通知属于哪个任务(发送记录表要它 —— 催报去重按任务)。
* 按模板发的常用构造:文案由模板决定,调用方只给变量。
*
* <p>{@code fallbackTitle} 只在模板取不到时兜底(正常情况下不会用到)。
*/
public static NotifyRequest ofTemplate(Long userId, int bizType, String subjectCode,
Map<String, String> vars, String fallbackTitle,
String linkType, Long linkId, String templateCode) {
return new NotifyRequest(null, userId, bizType, fallbackTitle, fallbackTitle,
linkType, linkId, templateCode, Map.of(),
subjectCode, vars == null ? Map.of() : vars, null);
}
/** 加一句模板之外的补充(不可变,返回新对象)。 */
public NotifyRequest withExtra(String extra) {
return new NotifyRequest(tenantId, userId, bizType, title, content, linkType, linkId,
templateCode, templateParams, subjectCode, subjectVars, extra);
}
/**
* 拼模板变量:`vars("任务名", name, "姓名", realName)` —— **null 值直接跳过**。
*
* <p>⚠️ 不要用 {@code Map.of(...)} 拼这个:它**不接受 null 值**(连 key 都不接受),
* 而"这个学生查不到姓名"是完全正常的情况 —— 于是它会抛 NPE,
* 而通知调用外面通常包着"通知失败不影响业务"的 try/catch →
* 表现是**一条通知都没发,日志里只有一行 warn**(这个坑在本轮真踩了一次:
* `Map.of("姓名", null)` → 驳回通知全丢,而驳回本身照样成功)。
* 模板渲染对"变量没有值"的处理本来就是渲染成空(见 {@link NotifyTemplates#render}),
* 所以跳过才是对的口径。
*/
public static Map<String, String> vars(String... kv) {
Map<String, String> m = new LinkedHashMap<>();
for (int i = 0; i + 1 < kv.length; i += 2) {
if (kv[i] != null && kv[i + 1] != null) {
m.put(kv[i], kv[i + 1]);
}
}
return m;
}
/**
* 这条通知属于哪个任务(发送记录表要它 —— 催报去重按任务;模板也要按任务取覆盖)。
*
* <p>从 {@code linkType} 推:跳任务是任务本身,跳填报单则要靠调用方另外传
* (这里返回 null 也行,只是那条记录没法按任务筛)。
@@ -184,14 +295,14 @@ public class NotificationService {
* 运营要看的是"多少人因为没授权而收不到推送"(该去引导授权),
* 而不是"推送失败了多少次"(那要查通道故障)。两者混在一起就分不出来。
*/
private void recordNotify(Long tenantId, NotifyRequest req, String channel,
private void recordNotify(Long tenantId, NotifyRequest req, String title, String channel,
boolean ok, boolean skippedNoQuota, String failReason) {
try {
// biz_type 是 varchar(32)(V1 的 DDL),所以要转字符串 —— 见 NotifyRecordMapper
int status = ok ? 1 : (skippedNoQuota ? 3 : 2);
int n = notifyRecordMapper.insertRecord(tenantId, req.taskIdOrNull(),
String.valueOf(req.bizType()),
req.userId(), channel, req.templateCode(), req.title(), status,
req.userId(), channel, req.templateCode(), title, status,
clip(failReason, 512));
if (n <= 0) {
log.warn("通知发送记录没写进去(返回 {} 行):user={} biz={}", n, req.userId(), req.bizType());
@@ -210,14 +321,14 @@ public class NotificationService {
* 所以只能由客户在微信后台建好模板后,用 {@code templateParams} 传进来。
* 这里做一个兜底:只有内容没有参数时,用通用字段名 —— 发不出去也不影响站内信。
*/
private Map<String, Object> buildTemplateData(NotifyRequest req) {
private Map<String, Object> buildTemplateData(NotifyRequest req, String title, String content) {
Map<String, Object> data = new LinkedHashMap<>();
if (req.templateParams() != null && !req.templateParams().isEmpty()) {
req.templateParams().forEach((k, v) -> data.put(k, Map.of("value", v)));
return data;
}
data.put("thing1", Map.of("value", clip(req.title(), 20)));
data.put("thing2", Map.of("value", clip(req.content(), 20)));
data.put("thing1", Map.of("value", clip(title, 20)));
data.put("thing2", Map.of("value", clip(content, 20)));
return data;
}
@@ -62,13 +62,16 @@ public final class NotifyTemplates {
"{任务名} 的填报已审核通过,无需再操作。", "终审通过"),
new Def("URGE", 5, "请尽快完成填报",
"{任务名} 目前提交进度 {提交进度},请在 {截止日} 前完成填报。", "催报"),
new Def("EXPORT_DONE", 6, "导出完成",
"导出包 {导出包名} 已生成,请及时下载:{下载链接}(链接有有效期,过期请重新导出)",
new Def("URGE_DRAFT", 5, "你的填报还没有提交",
"你已经开始填写《{任务名}》,但还没有提交。填完记得点「提交」。",
"催报(只建了草稿、没提交的人)"),
new Def("EXPORT_DONE", 6, "{导出包名} 已生成",
"导出包 {导出包名} 已生成,请到「导出中心」下载。"
+ "(下载链接有有效期,过期请重新导出)",
"导出完成"),
new Def("JOIN_APPROVE", 7, "加入名单申请已处理",
"你申请加入 {任务名} 名单的请求已处理,请查看结果。", "加入名单审批"),
new Def("JOIN_SUBMIT", 7, "有新的加入名单申请",
"{班级} 的 {姓名} 申请加入 {任务名} 名单,请审批。", "发给班主任/管理员")
new Def("AUDIT_ESCALATE", 3, "有一条填报单需要你介入",
"{任务名} 的 {姓名} 已被驳回达上限,最后一次原因是:{驳回原因}。请查看后决定怎么处理。",
"驳回次数达上限时发给学校管理员(契约 §11.4)")
);
public static List<Def> all() {
@@ -87,6 +90,13 @@ public final class NotifyTemplates {
/** 业务类型集合(用于校验 `POST /notify/send` 的 bizType)。 */
public static final Set<Integer> BIZ_TYPES = Set.of(1, 2, 3, 4, 5, 6, 7);
/** 模板码全集(脚本与测试用它核对"每个内置模板都有发送点")。 */
public static Set<String> allCodes() {
Set<String> out = new java.util.LinkedHashSet<>();
BUILTIN.forEach(d -> out.add(d.code()));
return out;
}
/**
* 渲染模板。
*
@@ -0,0 +1,131 @@
package cn.tianbaotong.fill.module.notify.service;
import cn.tianbaotong.fill.module.notify.entity.CollectNotifyTemplate;
import cn.tianbaotong.fill.module.notify.mapper.NotifyTemplateMapper;
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import java.util.List;
import java.util.Map;
/**
* 按模板码取「真正要发出去的那段文案」。
*
* <h3>为什么需要这个类(FR-1002 的兑现点)</h3>
*
* <p>《08-完成度盘点》F-101:通知模板页能改文案,但**只有"手工发送"走模板** ——
* 驳回、审核通过、催报、临期提醒这些**自动**通知把文案硬编码在各自的 Service 里,
* 于是管理员把「驳回通知」改成自己学校的说法之后,学生收到的还是原来那句。
* 而 FR-1002 点名的 5 类通知(任务发布 / 临期提醒 / 驳回通知 / 审核通过 / 导出完成)
* **全是自动类** —— 也就是说这个 P0 需求当时只兑现了它没点名的那一半。
*
* <p>所以自动通知也必须先来这里取文案:<b>库里改过的 &gt; 内置的</b>,
* 与模板列表页({@code GET /notify/templates})看到的**完全同一份口径** ——
* 否则"界面上显示这句话、实际发出去另一句话"就是同一类问题的另一种形态。
*
* <h3>为什么不直接用 {@code NotifyTemplateService}</h3>
*
* <p>它是**反方向**的依赖:{@code NotifyTemplateService} 手工发送时要调
* {@code NotificationService},而 {@code NotificationService} 现在要在发送前取文案 ——
* 两个 Service 互相注入会形成循环依赖(Spring 允许用 {@code @Lazy} 糊过去,
* 但那是把"设计上分不开"这件事藏起来)。取文案只需要一张表,
* 所以单独放一个类,依赖是单向的:
* {@code NotificationService → NotifyTextResolver → NotifyTemplateMapper}。
*
* <h3>失败一律退回内置文案</h3>
*
* <p>查库失败(没有租户上下文、表被锁、连接抖动…)**绝不能变成"通知不发"**:
* 那会让一次本该校验通过的审核动作连带丢掉通知。所以这里吞掉异常,
* 退回内置文案 + 一条 warn。
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class NotifyTextResolver {
private final NotifyTemplateMapper templateMapper;
/**
* 解析结果。
*
* @param enabled 模板有没有被停用(停用 = 这类通知不发,见 {@link #resolve} 的说明)
* @param customized 是不是用户改过的(用于日志与排查:"他收到的为什么是这句")
*/
public record Resolved(boolean enabled, String title, String content, boolean customized) {
}
/**
* 取文案。变量按白名单渲染(越界变量原样保留,见 {@link NotifyTemplates#render})。
*
* @param code 站内信模板码({@link NotifyTemplates} 里的那一套,如 {@code AUDIT_REJECT})
* @param taskId 任务级覆盖优先;没有就退到租户级;都没有就用内置
* @return 找不到这个模板码时返回 {@code null}(调用方回退到自己拼的文案)
*/
public Resolved resolve(String code, Long taskId, Map<String, String> vars) {
NotifyTemplates.Def def = NotifyTemplates.byCode(code);
CollectNotifyTemplate over = findOverride(code, taskId);
if (over == null) {
if (def == null) {
return null;
}
return new Resolved(true,
NotifyTemplates.render(def.title(), vars),
NotifyTemplates.render(def.content(), vars), false);
}
// 库里这一行只在"它自己那条"上覆盖内置:标题/内容为空时逐项退回内置,
// 而不是发出一封没有标题的消息(那在消息中心里就是一个空白条目)
String title = blank(over.getTitle())
? (def == null ? "" : def.title()) : over.getTitle();
String content = blank(over.getContent())
? (def == null ? "" : def.content()) : over.getContent();
boolean enabled = Integer.valueOf(CollectNotifyTemplate.ENABLED_YES).equals(over.getEnabled());
if (!enabled) {
log.info("通知模板 {} 已被停用,这条自动通知不发(task={})", code, taskId);
}
return new Resolved(enabled,
NotifyTemplates.render(title, vars),
NotifyTemplates.render(content, vars), true);
}
/**
* 取该模板码的覆盖行:**任务级优先于租户级**。
*
* <p>租户拦截器会补 {@code tenant_id},所以这里不用自己带租户条件 ——
* 但这也意味着**必须在租户上下文里调用**(自动通知都在:要么是租户内的用户动作,
* 要么是定时任务里 {@code TenantScope.callAs} 摆好的)。查不到/查失败都退回内置。
*/
private CollectNotifyTemplate findOverride(String code, Long taskId) {
try {
var q = Wrappers.<CollectNotifyTemplate>lambdaQuery()
.eq(CollectNotifyTemplate::getTemplateCode, code)
.eq(CollectNotifyTemplate::getChannel, CollectNotifyTemplate.CHANNEL_IN_APP);
if (taskId != null) {
q.and(w -> w.isNull(CollectNotifyTemplate::getTaskId)
.or().eq(CollectNotifyTemplate::getTaskId, taskId));
} else {
q.isNull(CollectNotifyTemplate::getTaskId);
}
List<CollectNotifyTemplate> rows = templateMapper.selectList(q);
CollectNotifyTemplate tenantLevel = null;
for (CollectNotifyTemplate row : rows) {
if (row.getTaskId() != null && row.getTaskId().equals(taskId)) {
return row;
}
tenantLevel = row;
}
return tenantLevel;
} catch (RuntimeException e) {
// 取模板失败不该让通知消失(退回内置文案)
log.warn("取通知模板失败,退回内置文案:code={} task={} 原因={}", code, taskId, e.toString());
return null;
}
}
private boolean blank(String s) {
return s == null || s.isBlank();
}
}
@@ -44,6 +44,17 @@ public class UrgeService {
/** 默认模板码(契约 §8.6 的 `NOTIFY_UNSUBMITTED`)。 */
public static final String TPL_URGE = "NOTIFY_UNSUBMITTED";
/**
* 站内信模板码({@link NotifyTemplates} 那套)。
*
* <p>⚠️ 与上面的 {@link #TPL_URGE} **不是一回事**:那个是**微信订阅消息**的模板码
* (决定从哪取 {@code templateId}、扣哪一份额度),这里两个才是"学生实际读到的那句话"。
* 草稿与"一次都没填"是两条不同的话,所以是两个模板码 ——
* 合成一个就得在模板里写条件,而变量渲染做不到条件。
*/
public static final String SUBJECT_URGE = "URGE";
public static final String SUBJECT_URGE_DRAFT = "URGE_DRAFT";
/**
* 催报用的业务类型码 = {@code 5}。
*
@@ -60,9 +71,39 @@ public class UrgeService {
/** 默认去重窗口(小时)。 */
private static final int DEFAULT_EXCLUDE_HOURS = 24;
/**
* 催报分钟级限流(契约 §3:「催报 10 次/分钟/任务」,2026-09-30 补上,见《08》F-29)。
*
* <p>与 24 小时去重是**两件事**:去重防"同一个学生被重复催",限流防"老师连点把系统打爆"。
* 没有它时连点 100 次不会造成伤害(第二次起全被去重跳过),但会白跑 100 次全表查询。
*/
private static final int URGES_PER_MINUTE_PER_TASK = 10;
private final UrgeMapper urgeMapper;
private final AuditScopeMapper auditScopeMapper;
private final NotificationService notificationService;
private final org.springframework.data.redis.core.StringRedisTemplate redis;
/** 计一次催报动作,超限抛 {@code 42901}。键按任务(没有任务时按班级组合,见调用处)。 */
private void rateLimit(String scopeKey) {
String key = "xt:urge:" + scopeKey;
try {
Long n = redis.opsForValue().increment(key);
if (n != null && n == 1L) {
redis.expire(key, Duration.ofSeconds(60));
}
if (n != null && n > URGES_PER_MINUTE_PER_TASK) {
throw new BizException(ErrorCode.RATE_LIMITED,
"催报操作过于频繁(每分钟最多 " + URGES_PER_MINUTE_PER_TASK
+ " 次/任务),请稍后再试。同一学生 24 小时内不会被重复催,不必连点");
}
} catch (BizException e) {
throw e;
} catch (Exception e) { // noqa: BLE001
// Redis 不可用不该让催报瘫痪 —— 限流是保护,不是业务前提
log.warn("催报限流计数失败(放行本次):{}", e.getMessage());
}
}
// ── 定向催报(§8.6 的 `/roster/{rid}/notify`)──
@@ -87,6 +128,7 @@ public class UrgeService {
// 还没核对身份 → 没有账号可发。如实说明,别报"发送失败"
return result(List.of(detail(t, true, "这个学生还没扫码核对身份,无法触达(先让他扫二维码)")));
}
rateLimit("task:" + t.getTaskId());
String extra = req == null ? null : req.getExtraText();
NotifyOutcome o = send(t, req == null ? null : req.getTemplateCode(), extra, false);
@@ -125,6 +167,10 @@ public class UrgeService {
return result(List.of());
}
// 限流放在"确实有活可干"之后:空结果就连点也不计数 ——
// 惩罚"点了但没人可催"只会让人困惑(界面上明明什么都没发生)
rateLimit(taskId != null ? "task:" + taskId : "orgs:" + orgIds);
int hours = req == null || req.getExcludeRecentHours() == null
? DEFAULT_EXCLUDE_HOURS : Math.max(0, req.getExcludeRecentHours());
LocalDateTime now = LocalDateTime.now();
@@ -181,9 +227,14 @@ public class UrgeService {
LocalDateTime now = LocalDateTime.now();
Set<Long> sentUsers = new LinkedHashSet<>();
int sent = 0;
String extra = daysLeft <= 1
? "今天是最后一天了,请务必今天提交。"
: "距截止还有 " + daysLeft + " 天,请尽早提交。";
// 0 天 = 截止当日(FR-1003 要求"7/3/1 天 + 截止当日")。
// 三档文案必须分开:对"还有 1 天"和"就是今天"说同一句"今天是最后一天",
// 学生就无法判断到底还剩几个小时 —— 而那正是他此刻唯一需要知道的事。
String extra = daysLeft <= 0
? "今天是截止日,请在今天 24 点前提交,过后就不能再交了。"
: daysLeft == 1
? "今天是最后一天了,请务必今天提交。"
: "距截止还有 " + daysLeft + " 天,请尽早提交。";
for (UrgeMapper.UrgeTarget t : targets) {
// 还没扫码核验的人**没有账号可发**,跳过(一键催报会给老师看这个理由,
// 自动催报没人看,所以只记 debug)
@@ -259,25 +310,40 @@ public class UrgeService {
/** 发一条催报。返回"跳过了没有 + 为什么"。 */
private NotifyOutcome send(UrgeMapper.UrgeTarget t, String templateCode, String extra,
boolean draft) {
String title = draft ? "你的填报还没有提交" : "请尽快完成填报";
StringBuilder content = new StringBuilder(draft
? "你已经开始填写《" + nz(t.getTaskName()) + "》,但还没有提交。填完记得点「提交」。"
: "你还没有完成《" + nz(t.getTaskName()) + "》的填报,请尽快完成。");
if (t.getOrgName() != null) {
content.append("(").append(t.getOrgName()).append(")");
}
if (extra != null && !extra.isBlank()) {
// 老师自己写的话原样附在后面 —— 它往往才是学生真正会看的那句
content.append("\n").append(extra.trim());
// 文案走模板(FR-1002 的同一口径):管理员在「通知模板」页改了「催报」那句,
// 学生收到的就是改过的那句。草稿与"一次都没填"是两条不同的话,
// 所以是两个模板码 —— 合成一个就得在模板里加条件,那是模板做不到的事。
java.util.Map<String, String> vars = new java.util.LinkedHashMap<>();
vars.put("任务名", nz(t.getTaskName()));
vars.put("姓名", t.getRealName());
vars.put("班级", t.getOrgName());
if (t.getTaskId() != null) {
vars.put("提交进度", progressOf(t.getTaskId()));
if (t.getEndTime() != null) {
vars.put("截止日", t.getEndTime().toLocalDate()
.format(java.time.format.DateTimeFormatter.ofPattern("yyyy年M月d日")));
long days = java.time.temporal.ChronoUnit.DAYS
.between(java.time.LocalDate.now(), t.getEndTime().toLocalDate());
vars.put("剩余天数", String.valueOf(Math.max(0, days)));
}
}
try {
var r = notificationService.notify(new NotificationService.NotifyRequest(
null, t.getUserId(), BIZ_URGE, title, content.toString(),
var r = notificationService.notify(NotificationService.NotifyRequest.ofTemplate(
t.getUserId(), BIZ_URGE,
draft ? SUBJECT_URGE_DRAFT : SUBJECT_URGE,
vars,
draft ? "你的填报还没有提交" : "请尽快完成填报",
CollectMessage.LINK_TASK, t.getTaskId(),
templateCode == null || templateCode.isBlank() ? TPL_URGE : templateCode,
java.util.Map.of()));
// 站内信一定发出去了 → 算 sent。微信推送没出去只影响"他多久看到"
templateCode == null || templateCode.isBlank() ? TPL_URGE : templateCode)
// 班级与老师附言是模板表达不了的"这一次"的信息,追加在后面
.withExtra(orgPart(t) + (extra == null || extra.isBlank() ? "" : extra.trim())));
// 站内信一定发出去了 → 算 sent;模板被停用则 messageId 为 null(什么都没发),
// 这时要如实归到"跳过"并给出理由,否则老师会以为催过了(《08》F-101 的连带)。
if (r.messageId() == null) {
return new NotifyOutcome(true, r.failReason());
}
// 微信推送没出去只影响"他多久看到"
return new NotifyOutcome(false, r.pushSent() ? null : r.failReason());
} catch (RuntimeException e) {
log.warn("催报失败:roster={} user={}", t.getRosterId(), t.getUserId(), e);
@@ -285,6 +351,22 @@ public class UrgeService {
}
}
/** 这个任务的提交进度(`{提交进度}` 变量:已提交/应填)。查不出来就留空,不编一个数。 */
private String progressOf(Long taskId) {
try {
Long tenantId = UserContext.tenantId();
return urgeMapper.progressOf(tenantId, taskId);
} catch (RuntimeException e) {
log.debug("取提交进度失败(模板变量留空):task={} 原因={}", taskId, e.toString());
return null;
}
}
/** 班级那一行补充说明(学生手里的任务可能不止一个班,说清是哪个班的事)。 */
private String orgPart(UrgeMapper.UrgeTarget t) {
return t.getOrgName() == null ? "" : "(" + t.getOrgName() + ")";
}
/** 范围判断:这个人所在的班归不归我。 */
private boolean inMyScope(Long tenantId, Long uid, UrgeMapper.UrgeTarget t) {
List<Long> mine = auditScopeMapper.classAuditOrgIds(tenantId, t.getTaskId(), uid);
@@ -11,7 +11,21 @@ import java.util.List;
/** 推送记录(契约 §14.4)。 */
public interface OpenPushLogMapper extends BaseMapper<CollectOpenPushLog> {
/** 到点该重试的(定时任务用)。只取"失败待重试"的,不碰成功与最终失败的。 */
/**
* 到点该重试的(定时任务用)。只取"失败待重试"的,不碰成功与最终失败的。
*
* <p>⚠️ {@code @InterceptorIgnore(tenantLine = "true")} <b>不能省</b>:
* 定时任务**没有租户上下文**(它是"没有请求也会发生的事"),而租户拦截器的姿态是
* <b>默认拒绝</b> —— 少了这个注解,这条查询每次都抛 {@code 40101},
* 被 {@code OpenPushService.retryDue} 的 {@code catch (RuntimeException)} 吞成一行 warn。
* 表现是:**"安排了下次重试"却从来没有重试过**,而日志里只有每 10 秒一次的噪音。
*
* <p>这与 {@link #findBySubmissionIgnoreTenant} 是同一个规律:
* <b>"先找归属、再摆上下文"里,找归属那一步必须能无租户执行</b>。
* 跨租户的候选扫描集中在这一个方法里,逐条处理时再由
* {@code TenantScope.runAs} 摆好目标租户的上下文 —— 保护没有被放弃。
*/
@InterceptorIgnore(tenantLine = "true")
@Select("""
SELECT * FROM collect_open_push_log
WHERE deleted = 0 AND status = 2
@@ -212,11 +212,18 @@ public class OpenPushService {
// 定时任务没有租户上下文,用忽略租户的查询(范围仅此一处)
CollectOpenApp app = appMapper.findByIdIgnoreTenant(row.getAppId());
if (app == null || !app.enabled()) {
// 接入方没了/停了:不再重试,标成最终失败并说明原因
row.setStatus(CollectOpenPushLog.STATUS_FAILED);
row.setLastError("接入方不存在或已停用,停止重试");
row.setNextRetryAt(null);
pushLogMapper.updateById(row);
// 接入方没了/停了:不再重试,标成最终失败并说明原因。
//
// ⚠️ 这一步**也必须摆租户上下文**:`updateById` 会走租户拦截器,
// 而在"接入方已不存在"的情况下我们不能从 app 拿租户 —— 所以用
// **推送记录自己的 tenant_id**。少了它,这条 update 会抛 40101,
// 被下面的 catch 吞成一行 warn,于是这条记录**每 10 秒重试一次、永远停不下来**。
TenantScope.runAs(row.getTenantId(), null, Set.of(), () -> {
row.setStatus(CollectOpenPushLog.STATUS_FAILED);
row.setLastError("接入方不存在或已停用,停止重试");
row.setNextRetryAt(null);
pushLogMapper.updateById(row);
});
continue;
}
TenantScope.runAs(app.getTenantId(), null, Set.of(), () -> attempt(app, row));
@@ -42,7 +42,17 @@ public class OrgService {
* 一次查完更快也更好控制;递归查询在深度上没有上界,容易被构造出深树打爆。
*/
public List<OrgTreeVO> tree(boolean withCount, Long rootId) {
// ★ 显式带上租户条件,不依赖拦截器。
//
// 拦截器对**平台运营**(tenantId=0)整个关掉了过滤(它是唯一跨租户的身份),
// 于是这里会返回"所有租户的组织混在一棵树里" —— 而只有一所学校有数据时,
// 它看起来和学校管理员看到的完全一样,**看不出错**。
// 组织树本来就是租户内的东西:平台要看某所学校的组织,得先在页面上选定学校
// (请求头 X-Tenant-Id → 见 ActingTenantResolver),那时 tenantId 就是那所学校。
// 没选学校时这里返回空树,而不是"把所有人的组织端出来"。
long tenantId = UserContext.tenantId();
List<SysOrg> all = orgMapper.selectList(Wrappers.<SysOrg>lambdaQuery()
.eq(SysOrg::getTenantId, tenantId)
.orderByAsc(SysOrg::getSortNo)
.orderByAsc(SysOrg::getId));
@@ -62,11 +62,24 @@ public interface ScheduleMapper {
List<Map<String, Object>> tasksToClose();
/**
* 截止日正好在某一天的任务(给"提前 7/3/1 天催报"用)。
* 截止日正好在某一天的任务(给"提前 7/3/1 天 + 截止当日催报"用)。
*
* <p>为什么按"正好那一天"而不是"小于等于":催报是**每天一次**的定时任务,
* 用 `<=` 会让"还剩 2 天"的任务在 7 天、3 天、1 天各被催一次(因为 2 < 3 也成立)
* —— 学生一天收到三条,然后关掉通知。
*
* <p>⚠️⚠️ **必须比 `end_time::date`,不能直接比 `end_time = #{date}::date`**:
* `collect_task.end_time` 存的是**当天 23:59:59**(见 TaskService 的写入),
* 而 `'2026-10-08'::date` 会被 PG 当成 `2026-10-08 00:00:00` ——
* 两者**永远不相等**,于是这个查询**一条都查不出来**:
* 自动催报(FR-1003)在真实数据上从来没发出过任何一条通知,
* 而日志里只有"没有任务"的沉默(`if (!tasks.isEmpty())` 才打日志)。
*
* <p>这个 bug 之所以活到今天,是因为**测试夹具用 SQL 摆截止日**
* (`update collect_task set end_time = CURRENT_DATE + 3` 存进去的是 00:00:00 的日期型),
* 那种形状恰好能匹配 —— 夹具与真实数据的形状不一致,把 bug 盖住了
* (与《08》F-99「假 JPEG」是同一族:**夹具不像真东西,测出来的绿灯就不算数**)。
* 现在 `e2e-m5-notify-wiring.py` 走**真实接口**建任务(end_time 自然是 23:59:59)来守它。
*/
@InterceptorIgnore(tenantLine = "true")
@Select("""
@@ -74,7 +87,8 @@ public interface ScheduleMapper {
FROM collect_task
WHERE deleted = 0
AND status = 1
AND end_time = #{date}::date
AND end_time IS NOT NULL
AND end_time::date = #{date}::date
ORDER BY id
""")
List<Map<String, Object>> tasksEndingOn(@Param("date") String date);
@@ -148,17 +162,18 @@ public interface ScheduleMapper {
/**
* 某个任务下"该审批的人"(初审=班级负责人,终审=学校管理员)。
*
* <p>找人的口径沿用审核台的范围查询(`collect_task_org.head_user_id` /
* `deputy_user_ids` / 组织的 `leader_user_id`)—— 提醒要发给**真的要动手的那个人**,
* <p>找人的口径沿用审核台的范围查询(`collect_task_org.head_user_id` 优先,
* 为空回退到组织的 `leader_user_id`)—— 提醒要发给**真的要动手的那个人**,
* 发给所有人等于没人负责。
*/
@InterceptorIgnore(tenantLine = "true")
@Select("""
SELECT DISTINCT u.id AS "userId", u.real_name AS "realName"
FROM collect_task_org cto
LEFT JOIN sys_org o
ON o.id = cto.org_id AND o.deleted = 0 AND o.tenant_id = cto.tenant_id
JOIN sys_user u ON u.deleted = 0 AND u.status = 1
AND (u.id = cto.head_user_id
OR cto.deputy_user_ids LIKE ('%,' || u.id || ',%'))
AND u.id = COALESCE(cto.head_user_id, o.leader_user_id)
WHERE cto.task_id = #{taskId} AND cto.status = 1 AND cto.tenant_id = #{tenantId}
""")
List<Map<String, Object>> classAuditors(@Param("tenantId") Long tenantId,
@@ -71,6 +71,7 @@ public class ScheduleJobs {
private final cn.tianbaotong.fill.module.tenant.service.TenantUsageService usageService;
private final cn.tianbaotong.fill.module.file.service.MaterialFileService fileService;
private final cn.tianbaotong.fill.module.file.mapper.FileMapper fileMapper;
private final cn.tianbaotong.fill.support.RoleProvisioner roleProvisioner;
/** 原始件一次最多清多少行(分轮清完,别一次把磁盘 IO 打满)。 */
private static final int RAW_CLEAN_BATCH = 200;
@@ -78,8 +79,14 @@ public class ScheduleJobs {
/** 审核超时提醒一轮最多处理多少条(防止某天积压上万条时把接口打爆)。 */
private static final int TIMEOUT_BATCH = 500;
/** 提前几天催报(PRD 附录 E 的「7/3/1 天规则」)。 */
private static final int[] URGE_DAYS_BEFORE = {7, 3, 1};
/**
* 提前几天催报(PRD 附录 E 的「7/3/1 天规则」**+ 截止当日**)。
*
* <p>⚠️ {@code 0} 那一档是 FR-1003 原文里的「+ 截止当日」,本轮补上(《08》F-104):
* 原先只有 7/3/1,于是**截止那天一声不响** —— 而那天是唯一"再不做就来不及"的日子。
* 缺的是最该有的那一档。
*/
private static final int[] URGE_DAYS_BEFORE = {7, 3, 1, 0};
/** 租户到期前提醒的天数(PRD 附录 E 的「30/7/1 天」)。 */
private static final int[] EXPIRE_REMIND_DAYS = {30, 7, 1};
@@ -481,6 +488,50 @@ public class ScheduleJobs {
}
}
// ══════════════ ⑨ 内置角色授权同步(每日 03:20)══════════════
/**
* 把每个租户的**内置角色授权对齐到代码**(二期「角色与授权」的前置)。
*
* <p>为什么必须有这一步:内置角色的权限由代码决定({@code BuiltinRole}),
* 而库里的 {@code sys_role_permission} 只在**开通租户那一刻**写过一次。
* 于是代码里给某个内置角色加了/减了一条权限之后,**已经开通的租户永远停在旧的那套** ——
* 而权限判定本身不读这张表(它按代码算),所以这个不一致**不会报错、也不会被任何测试抓到**,
* 只会在有人查库、或将来改成"从库读"的时候炸出来。
*
* <p>幂等:{@code provisionBuiltinRoles} 只在值真的不同才写。
* 03:20 挑的是所有定时任务都不在的时间段(口径见各任务的 cron)。
*/
@Scheduled(cron = "0 20 3 * * *")
public void syncBuiltinRoleGrants() {
try {
List<Map<String, Object>> tenants = TenantScope.callAs(PLATFORM, PLATFORM, Set.of(),
snapshotMapper::activeTenants);
assertTenantPresent(tenants, "role");
int done = 0;
for (Map<String, Object> row : tenants) {
Long tenantId = longOf(row, "tenantId");
if (tenantId == null) {
continue;
}
try {
// 写数据必须在"摆好租户上下文"这一步:否则 tenant_id 会被写成 0
// (角色发到平台租户下,那个学校的人永远看不到,而且不报错)
TenantScope.runAs(tenantId, PLATFORM, Set.of(SYSTEM_ROLE),
() -> roleProvisioner.provisionBuiltinRoles(tenantId));
done++;
} catch (RuntimeException e) {
log.warn("内置角色授权同步失败:tenant={} 原因={}", tenantId, e.toString());
}
}
if (done > 0) {
log.info("内置角色授权同步:{} 个租户", done);
}
} catch (RuntimeException e) {
log.warn("内置角色授权同步任务失败(下一轮会重试)", e);
}
}
private int remindExpire(Long tenantId, String tenantName, int daysLeft) {
var receivers = scheduleMapper.usersOfRole(tenantId,
cn.tianbaotong.fill.security.BuiltinRole.SCHOOL_ADMIN.getCode());
@@ -521,13 +572,14 @@ public class ScheduleJobs {
* <p>为什么要这个入口:**定时任务的"等不起"是测试与运维的共同问题** ——
* 验证"截止后会怎样"总不能在测试里等到明天 09:00(F-37 已经踩过一次)。
*
* @param job 任务名({@code close} / {@code urge} / {@code timeout} / {@code raw})
* @param job 任务名({@code close} / {@code urge} / {@code timeout} / {@code raw}
* / {@code usage} / {@code retry} / {@code expire} / {@code role})
* @return 这一轮做了什么(给人看的说明)
*/
public String runNow(String job) {
if (job == null || job.isBlank()) {
throw new BizException(ErrorCode.VALIDATION_BLOCKED,
"请指定要跑的任务:close / urge / timeout / raw / usage / retry / expire");
"请指定要跑的任务:close / urge / timeout / raw / usage / retry / expire / role");
}
switch (job.trim().toLowerCase()) {
case "close" -> autoCloseExpiredTasks();
@@ -537,8 +589,9 @@ public class ScheduleJobs {
case "usage" -> snapshotUsage();
case "retry" -> retryFailedProcess();
case "expire" -> remindTenantExpiring();
case "role" -> syncBuiltinRoleGrants();
default -> throw new BizException(ErrorCode.VALIDATION_BLOCKED,
"不认识的任务名「" + job + "」:只有 close / urge / timeout / raw / usage / retry / expire");
"不认识的任务名「" + job + "」:只有 close / urge / timeout / raw / usage / retry / expire / role");
}
return "已手动触发一轮:" + job;
}
@@ -39,7 +39,7 @@ public class PlanScopeController {
}
@PutMapping("/{id}/plan-scope")
@RequirePerm(PermissionDef.TASK_MANAGE)
@RequirePerm(PermissionDef.PLAN_IMPORT)
@Operation(summary = "确认 / 修正报考范围",
description = "rosterIds 的并集必须恰好覆盖名册全员,否则 40024 并返回差集 —— "
+ "漏一个人,导出包就会缺人,而人工核对发现不了")
@@ -107,7 +107,10 @@ public class TaskController {
@DeleteMapping("/{id}")
@RequirePerm(PermissionDef.TASK_MANAGE)
@Operation(summary = "删除任务", description = "只有草稿可以删除;已发布的请用截止或归档")
@Operation(summary = "删除任务",
description = "草稿直接删;**已归档**的任务可删(2026-09-30 客户确认,§17.2 D-9):"
+ "级联软删名册/填报单/材料/审核记录并尽力删除文件,全程留痕。"
+ "收集中/已截止的先「归档」再删 —— 归档是最后的后悔药")
public ApiResult<Void> delete(@PathVariable Long id) {
taskService.delete(id);
return ApiResult.ok();
@@ -164,7 +167,7 @@ public class TaskController {
// ══════════════════ 克隆 ══════════════════
@PostMapping("/{id}/clone")
@RequirePerm(PermissionDef.TASK_MANAGE)
@RequirePerm(PermissionDef.TASK_CLONE)
@Operation(summary = "克隆任务",
description = "克隆**配置**(表单 / 材料清单 / 命名模板 / 审核配置 / 覆盖班级),"
+ "**刻意不克隆业务数据**(名册 / 填报单 / 材料 / 审核记录)。"
@@ -52,15 +52,11 @@ public class TaskOrgSaveReq {
* 该班初审人(班主任)。
*
* <p>可空 —— 为空表示取班级负责人。指定时必须是本租户内
* <b>持有班主任或副班主任角色</b>的启用账号(见 {@code TaskOrgService})。
* <b>持有班主任角色</b>的启用账号(见 {@code TaskOrgService})。
*/
@Schema(description = "初审人;为空表示取班级负责人 sys_org.leader_user_id")
private Long headUserId;
/** 副班主任。同一人不能既是主又是副(服务端会拒)。 */
@Schema(description = "副班主任")
private List<Long> deputyUserIds = new ArrayList<>();
@Min(value = 0, message = "应填人数不能为负")
@Schema(description = "应填人数,用于与名册实际人数比对告警")
private Integer expectedCount;
@@ -4,9 +4,6 @@ import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Getter;
import lombok.Setter;
import java.util.ArrayList;
import java.util.List;
/**
* 任务覆盖的班级与班主任(《03-接口设计》§7.6)。
*
@@ -34,11 +31,6 @@ public class TaskOrgVO {
private String headUserName;
/** 副班主任 / 协办人。 */
private List<Long> deputyUserIds = new ArrayList<>();
private List<String> deputyUserNames = new ArrayList<>();
/**
* 应填人数。
*
@@ -44,17 +44,6 @@ public class CollectTaskOrg implements Serializable {
/** 该班初审人(班主任);为空则回退到 {@code sys_org.leader_user_id}。 */
private Long headUserId;
/**
* 副班主任 / 协办人,逗号分隔。
*
* <p>⚠️ 存的是<b>首尾都带逗号</b>的形式,如 {@code ,3,7,},不是 {@code 3,7}。
* 原因:用 {@code LIKE '%,3,%'} 精确匹配某个 id 时,
* 裸逗号分隔的 {@code 3,17} 会被子串误命中。
* 首尾补逗号后每个 id 都被逗号夹住,不可能误匹配 ——
* 这是个很小的选择,但错了会表现为"莫名其妙的协办人能看到别人的班"。
*/
private String deputyUserIds;
/** 应填人数(冗余,由名册汇总)。 */
private Integer expectedCount;
@@ -0,0 +1,97 @@
package cn.tianbaotong.fill.module.task.mapper;
import org.apache.ibatis.annotations.Delete;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import java.util.List;
/**
* 删除**已归档任务**时的级联清理({@code TaskService.delete},2026-09-30 客户确认,
* 见《03-接口设计》§17.2 D-9 与《08》F-14 —— 此前"已发布的任务删不掉",
* 演示/测试任务只能改名挪开,库会一直堆)。
*
* <h3>为什么集中在这么一个文件里</h3>
*
* <p>级联删除是"一次动作动十四张表"的地方,散在各 Mapper 里会让人
* 只看到其中一截而以为删干净了。集中放置 + 每个方法一行,
* review 时一眼能数全(与 {@code MpSceneMapper} 集中放匿名 SQL 是同一个道理)。
*
* <p><b>表清单来自事实</b>({@code information_schema.columns} 里带 {@code task_id} 列的
* 全部业务表):任务本身不在此列(由 {@code TaskService} 删);
* {@code collect_message} 没有 {@code task_id} 列,学生收件箱是历史,保留。
*
* <p>⚠️ <b>删模块时必须回来删这里的一行</b>:2026-10 删掉「加入申请」模块、
* V19 把 {@code collect_join_request} 表 DROP 掉之后,这里的
* {@code deleteJoinRequests} 还在删那张表 —— 表现是**归档任务删不掉,接口返回 50000**,
* 而报错信息完全不提是哪一行 SQL 引起的。表清单少了这张之后是 13 张。
*
* <p><b>为什么子表物理删、任务行却软删</b>:F-14 的动机是"库会一直堆"(演示/测试任务
* 只能改名挪开)—— 大头在子表(2000 行名册、材料、审核记录),物理删才真清掉;
* 而 {@code collect_task} 行本身必须<b>软删保留</b>,因为它要继续占住 {@code task_code}
* ({@code maxSeqByCodePrefix} 靠"含软删行的最大序号"保证编码不复用 ——
* 编码同时是文件目录名)。删除动作的<b>留痕在 {@code sys_operation_log}</b>
* ({@code DELETE_TASK},记了谁删的、删了多少行多少文件)。
*
* <p>⚠️ 全部 SQL 走 MyBatis-Plus 租户拦截器(自动补 {@code tenant_id},这些表都有该列),
* 删除动作在管理员的租户上下文里执行。
*/
public interface TaskCascadeMapper {
// ── 文件路径(先取再删行:物理删除要用,删完行就查不到了)──────
/** 材料文件路径(std 与 raw 用 | 分隔,可能为空串)。 */
@Select("""
SELECT coalesce(std_path, '') || '|' || coalesce(raw_path, '')
FROM collect_file WHERE task_id = #{taskId}
""")
List<String> filePaths(@Param("taskId") Long taskId);
/** 导出产物路径。 */
@Select("""
SELECT file_path FROM collect_export_file
WHERE task_id = #{taskId} AND file_path IS NOT NULL
""")
List<String> exportPaths(@Param("taskId") Long taskId);
// ── 物理删(这 5 张表没有 deleted 列)────────────────
@Delete("DELETE FROM collect_validation_issue WHERE task_id = #{taskId}")
int deleteValidationIssues(@Param("taskId") Long taskId);
@Delete("DELETE FROM collect_audit_record WHERE task_id = #{taskId}")
int deleteAuditRecords(@Param("taskId") Long taskId);
@Delete("DELETE FROM collect_notify_record WHERE task_id = #{taskId}")
int deleteNotifyRecords(@Param("taskId") Long taskId);
@Delete("DELETE FROM collect_open_push_log WHERE task_id = #{taskId}")
int deleteOpenPushLogs(@Param("taskId") Long taskId);
@Delete("DELETE FROM collect_file WHERE task_id = #{taskId}")
int deleteFiles(@Param("taskId") Long taskId);
@Delete("DELETE FROM collect_export_file WHERE task_id = #{taskId}")
int deleteExportFiles(@Param("taskId") Long taskId);
@Delete("DELETE FROM collect_export_task WHERE task_id = #{taskId}")
int deleteExportTasks(@Param("taskId") Long taskId);
@Delete("DELETE FROM collect_submission WHERE task_id = #{taskId}")
int deleteSubmissions(@Param("taskId") Long taskId);
@Delete("DELETE FROM collect_roster WHERE task_id = #{taskId}")
int deleteRoster(@Param("taskId") Long taskId);
@Delete("DELETE FROM collect_form_schema WHERE task_id = #{taskId}")
int deleteFormSchemas(@Param("taskId") Long taskId);
@Delete("DELETE FROM collect_material_def WHERE task_id = #{taskId}")
int deleteMaterialDefs(@Param("taskId") Long taskId);
@Delete("DELETE FROM collect_notify_template WHERE task_id = #{taskId}")
int deleteNotifyTemplates(@Param("taskId") Long taskId);
@Delete("DELETE FROM collect_task_org WHERE task_id = #{taskId}")
int deleteTaskOrgs(@Param("taskId") Long taskId);
}
@@ -13,7 +13,7 @@ public interface TaskMapper extends BaseMapper<CollectTask> {
/**
* 某人在本租户下"能看到的"任务 id。
*
* <p><b>谁需要这个</b>:班主任与副班主任。《03-接口设计》§7.1 明确
* <p><b>谁需要这个</b>:班主任。《03-接口设计》§7.1 明确
* 「班主任**只看自己带的班级的任务**(由 {@code collect_task_org.head_user_id} 决定)」,
* 不能让一个班主任从列表里看到全校的任务名与批次 —— 那本身就是信息泄露。
*
@@ -21,8 +21,8 @@ public interface TaskMapper extends BaseMapper<CollectTask> {
* 为空时回退到 {@code sys_org.leader_user_id}。绝大多数班主任就是班级负责人,
* 不该要求每条都显式指定一遍。
*
* <p><b>副班主任</b>走 {@code deputy_user_ids} 的逗号匹配。该列存的是
* 首尾带逗号的 {@code ,3,7,} 形式,所以 {@code '%,3,%'} 不会误命中 {@code 17}。
* <p>⚠️ 这里以前还有一条 {@code deputy_user_ids}(副班主任)分支,
* 随该字段与角色一起删掉了 —— 现在**一个班只有一个初审人**。
*
* <p><b>为什么显式传 tenantId 并关掉拦截器</b>:这是一条<b>权限过滤</b>用的查询。
* 漏掉租户条件会返回别校的任务 id,而调用方是拿它去 {@code IN (...)} 的 ——
@@ -38,7 +38,6 @@ public interface TaskMapper extends BaseMapper<CollectTask> {
AND cto.tenant_id = #{tenantId}
AND (
COALESCE(cto.head_user_id, o.leader_user_id) = #{userId}
OR cto.deputy_user_ids LIKE '%,' || #{userId} || ',%'
)
""")
List<Long> selectVisibleTaskIds(@Param("tenantId") Long tenantId,
@@ -58,6 +58,7 @@ public class RuleTemplateService {
private final TaskMapper taskMapper;
private final MaterialDefMapper materialDefMapper;
private final MaterialDefService materialDefService;
private final cn.tianbaotong.fill.module.form.service.FormSchemaService formSchemaService;
/** 可应用的三个部分(契约 §7.13 的 `parts`)。 */
public static final Set<String> ALL_PARTS = new LinkedHashSet<>(
@@ -126,8 +127,12 @@ public class RuleTemplateService {
// 空 = 用系统默认表单,**不覆盖**目标任务的既有表单
r.getSkipped().add("FORM(模板没带表单定义,表示「用系统默认」,因此跳过)");
} else {
// 2026-09-30 起接上真正的应用路径(此前只能"跳过并说明",见 F-57/F-64)。
// 走 FormSchemaService 的版本化保存:它保留"已发布任务不可改表单"的检查
// 与版本管理 —— 这里**不再自己写表**,两处逻辑必须只有一份。
assertNotPublished(target, "表单定义");
r.getSkipped().add("FORM(一期还没有「保存表单定义」的服务端入口,见 F-57)");
formSchemaService.saveRawVersion(target.getId(), tpl.getFormSchema());
r.getApplied().add("FORM");
}
}
if (want.contains("MATERIALS")) {
@@ -136,9 +136,6 @@ public class TaskCloneService {
copy.setOrgId(o.getOrgId());
copy.setExpectedCount(o.getExpectedCount());
copy.setHeadUserId(o.getHeadUserId());
// ⚠️ 是 `deputy_user_ids`(逗号分隔的多个人),不是单数的 deputy_user_id ——
// 按"应该叫这个"猜字段名,编译期才会发现
copy.setDeputyUserIds(o.getDeputyUserIds());
copy.setStatus(o.getStatus());
// 刻意**不**复制二维码 URL:二维码是"每个任务的入口",来源任务的码
// 扫进去是旧任务 —— 复制过来会出现"扫新任务的码、进旧任务"这种最难查的错
@@ -258,6 +255,16 @@ public class TaskCloneService {
row.setCertType(t.getCertType());
row.setMaterials(arr.toString());
row.setNamingTemplate(t.getNamingTemplate());
// 表单定义:来源任务存过版本化表单就一并带上(2026-09-30 与「FORM 套用」一起补上 ——
// 此前存模板不存表单、套模板又用不上表单,两头各缺一半,见 F-57/F-64)。
// 没存过 = 用系统默认 → 存 null(套用时按「用默认,不覆盖」跳过)
var currentForm = formSchemaMapper.selectOne(Wrappers
.<cn.tianbaotong.fill.module.form.entity.CollectFormSchema>lambdaQuery()
.eq(cn.tianbaotong.fill.module.form.entity.CollectFormSchema::getTaskId, taskId)
.eq(cn.tianbaotong.fill.module.form.entity.CollectFormSchema::getIsCurrent,
cn.tianbaotong.fill.module.form.entity.CollectFormSchema.CURRENT)
.last("limit 1"));
row.setFormSchema(currentForm == null ? null : currentForm.getSchemaJson());
row.setIsPublic(pub ? CollectRuleTemplate.PUBLIC_YES : CollectRuleTemplate.PUBLIC_NO);
row.setUseCount(0);
ruleTemplateMapper.insert(row);
@@ -27,7 +27,6 @@ import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
/**
@@ -43,9 +42,11 @@ import java.util.Set;
* 不透露"它存在但不属于你"。这是最容易漏的一环 ——
* 漏了的后果是 A 校可以把 B 校的班挂到自己的任务上,然后 B 校的班主任
* 就出现在 A 校的审批流里。</li>
* <li><b>初审人必须是班主任</b>({@code HEAD_TEACHER} 或 {@code DEPUTY_TEACHER})。
* <li><b>初审人必须是班主任</b>({@code HEAD_TEACHER})。
* 指定初审人本质上是一次<b>授权</b>:那个人将能看到该班学生的身份证号、
* 学籍材料。放开这一条等于把授权能力变成任意授权。</li>
* 学籍材料。放开这一条等于把授权能力变成任意授权。
* ⚠️ 这里以前还接受 {@code DEPUTY_TEACHER}(副班主任);
* 该角色与「协办人」字段一起删掉了,现在是**一个班一个初审人**。</li>
* <li><b>移除是软移除</b>({@code status = 0}),不是物理删除。
* 名册与提交单都可能挂在覆盖关系下,删掉行会让历史数据失去归属。</li>
* <li><b>「移除再加回」必须复用原来那一行</b>。表上有
@@ -60,8 +61,8 @@ import java.util.Set;
@RequiredArgsConstructor
public class TaskOrgService {
/** 可以被指定为初审人的角色。 */
private static final Set<String> TEACHER_ROLE_CODES = Set.of("HEAD_TEACHER", "DEPUTY_TEACHER");
/** 可以被指定为初审人的角色。删掉副班主任后只剩班主任一个。 */
private static final Set<String> TEACHER_ROLE_CODES = Set.of("HEAD_TEACHER");
/**
* 覆盖范围可改的任务状态:草稿 / 收集中 / 已暂停。
@@ -122,9 +123,9 @@ public class TaskOrgService {
"任务已" + statusText(status) + ",覆盖班级不可再改");
}
Map<Long, Set<Long>> deputiesOf = normalizeAndValidate(req);
normalizeAndValidate(req);
persist(taskId, req, deputiesOf);
persist(taskId, req);
log.info("任务 {}(id={})覆盖范围已更新:{} 个班", task.getTaskCode(), taskId,
req.getOrgs().size());
@@ -133,9 +134,10 @@ public class TaskOrgService {
/**
* 参数归一化与全部校验。
*
* @return 每个班归一化后的副班主任 id 集合(已去重、去掉 null、并校验过与主班主任不重复)
* <p>删掉「协办人」之后,这里只剩一件事:<b>每个班最多一个初审人</b>,
* 且他必须是在本租户内、已启用、持班主任角色的账号。
*/
private Map<Long, Set<Long>> normalizeAndValidate(TaskOrgSaveReq req) {
private void normalizeAndValidate(TaskOrgSaveReq req) {
// 同一个班只能出现一次。不拦的话会因为唯一索引报错,而错误信息
// 完全指不出是哪个班的问题(PG 只说 duplicate key)
Map<Long, TaskOrgSaveReq.Item> wanted = new LinkedHashMap<>();
@@ -148,32 +150,15 @@ public class TaskOrgService {
assertOrgsAreClasses(wanted.keySet());
Map<Long, Set<Long>> deputiesOf = new LinkedHashMap<>();
Set<Long> allUserIds = new LinkedHashSet<>();
for (Map.Entry<Long, TaskOrgSaveReq.Item> entry : wanted.entrySet()) {
TaskOrgSaveReq.Item item = entry.getValue();
LinkedHashSet<Long> deputies = new LinkedHashSet<>();
if (item.getDeputyUserIds() != null) {
deputies.addAll(item.getDeputyUserIds());
}
deputies.removeIf(Objects::isNull);
if (item.getHeadUserId() != null && deputies.contains(item.getHeadUserId())) {
throw new BizException(ErrorCode.VALIDATION_BLOCKED,
"同一个班的主班主任与副班主任不能是同一人(userId=" + item.getHeadUserId() + ")");
}
deputiesOf.put(entry.getKey(), deputies);
Set<Long> headUserIds = new LinkedHashSet<>();
for (TaskOrgSaveReq.Item item : wanted.values()) {
if (item.getHeadUserId() != null) {
allUserIds.add(item.getHeadUserId());
headUserIds.add(item.getHeadUserId());
}
allUserIds.addAll(deputies);
}
if (!allUserIds.isEmpty()) {
assertAssignableTeachers(allUserIds);
if (!headUserIds.isEmpty()) {
assertAssignableTeachers(headUserIds);
}
return deputiesOf;
}
/** 班级必须存在、在同一个租户内、且确实是"班级"。 */
@@ -237,7 +222,7 @@ public class TaskOrgService {
SysUser user = users.get(userId);
throw new BizException(ErrorCode.VALIDATION_BLOCKED,
"「" + user.getRealName() + "」不是班主任,不能作为初审人;"
+ "请先在「组织与账号 → 账号管理」里给他分配「班主任」或「副班主任」角色");
+ "请先在「组织与账号 → 账号管理」里给他分配「班主任」角色");
}
}
}
@@ -251,7 +236,7 @@ public class TaskOrgService {
* 用 {@code updateById} 会静默不生效:接口返回成功、库里没变、界面刷新后又出现,
* 这种"保存成功但没保存"是最难查的一类 bug。
*/
private void persist(Long taskId, TaskOrgSaveReq req, Map<Long, Set<Long>> deputiesOf) {
private void persist(Long taskId, TaskOrgSaveReq req) {
Map<Long, TaskOrgSaveReq.Item> wanted = new LinkedHashMap<>();
for (TaskOrgSaveReq.Item item : req.getOrgs()) {
wanted.put(item.getOrgId(), item);
@@ -283,7 +268,6 @@ public class TaskOrgService {
for (Map.Entry<Long, TaskOrgSaveReq.Item> entry : wanted.entrySet()) {
Long orgId = entry.getKey();
TaskOrgSaveReq.Item item = entry.getValue();
String deputies = joinIds(deputiesOf.get(orgId));
CollectTaskOrg reuse = activeByOrg.get(orgId);
if (reuse == null) {
@@ -299,7 +283,6 @@ public class TaskOrgService {
insert.setTaskId(taskId);
insert.setOrgId(orgId);
insert.setHeadUserId(item.getHeadUserId());
insert.setDeputyUserIds(deputies);
// expectedCount 为 null 就是"未设置",**不能写成 0** ——
// 0 是有意义的业务值("预期 0 人"),兼职表示"没填"会让
// "期望 0 人、实际 31 人"这种假告警出现在每个没填的班上,
@@ -311,7 +294,6 @@ public class TaskOrgService {
taskOrgMapper.update(null, Wrappers.<CollectTaskOrg>lambdaUpdate()
.eq(CollectTaskOrg::getId, reuse.getId())
.set(CollectTaskOrg::getHeadUserId, item.getHeadUserId())
.set(CollectTaskOrg::getDeputyUserIds, deputies)
.set(CollectTaskOrg::getExpectedCount, item.getExpectedCount())
.set(CollectTaskOrg::getStatus, STATUS_ACTIVE));
}
@@ -327,15 +309,11 @@ public class TaskOrgService {
Set<Long> orgIds = new LinkedHashSet<>();
Set<Long> userIds = new LinkedHashSet<>();
Map<Long, List<Long>> deputiesOf = new LinkedHashMap<>();
for (CollectTaskOrg row : rows) {
orgIds.add(row.getOrgId());
List<Long> deputies = parseIds(row.getDeputyUserIds());
deputiesOf.put(row.getId(), deputies);
if (row.getHeadUserId() != null) {
userIds.add(row.getHeadUserId());
}
userIds.addAll(deputies);
}
Map<Long, String> orgNames = loadOrgNames(orgIds);
@@ -348,11 +326,6 @@ public class TaskOrgService {
vo.setOrgName(orgNames.getOrDefault(row.getOrgId(), "(班级已删除)"));
vo.setHeadUserId(row.getHeadUserId());
vo.setHeadUserName(userNames.get(row.getHeadUserId()));
List<Long> deputies = deputiesOf.get(row.getId());
vo.setDeputyUserIds(deputies);
vo.setDeputyUserNames(deputies.stream()
.map(id -> userNames.getOrDefault(id, "(账号已删除)"))
.toList());
vo.setExpectedCount(row.getExpectedCount());
vo.setStatus(row.getStatus());
out.add(vo);
@@ -396,44 +369,6 @@ public class TaskOrgService {
return task;
}
/**
* 逗号分隔 id 的序列化。
*
* <p>存的是<b>首尾都带逗号</b>的形式({@code ,3,7,}),不是 {@code 3,7}。
* 原因:用 {@code LIKE '%,3,%'} 精确匹配时,裸逗号分隔的 {@code 3,17}
* 会被子串误命中,让"莫名其妙的协办人能看到别人的班"。
*/
private static String joinIds(Collection<Long> ids) {
if (ids == null || ids.isEmpty()) {
return null;
}
StringBuilder sb = new StringBuilder(",");
for (Long id : ids) {
sb.append(id).append(',');
}
return sb.toString();
}
private static List<Long> parseIds(String raw) {
List<Long> out = new ArrayList<>();
if (raw == null || raw.isBlank()) {
return out;
}
for (String part : raw.split(",")) {
String trimmed = part.trim();
if (trimmed.isEmpty()) {
continue;
}
try {
out.add(Long.valueOf(trimmed));
} catch (NumberFormatException e) {
// 库里存了脏数据不该让整个列表 500;跳过并在日志里留痕
log.warn("collect_task_org.deputy_user_ids 含非法 id:{}", trimmed);
}
}
return out;
}
private static String statusText(int status) {
return switch (status) {
case CollectTask.STATUS_DRAFT -> "草稿";
@@ -73,6 +73,8 @@ public class TaskService {
private final TaskReadinessMapper readinessMapper;
private final cn.tianbaotong.fill.module.material.service.MaterialDefService materialDefService;
private final cn.tianbaotong.fill.module.log.service.OperationLogService operationLog;
private final cn.tianbaotong.fill.module.task.mapper.TaskCascadeMapper cascadeMapper;
private final cn.tianbaotong.fill.module.file.storage.StorageService storageService;
// ── 列表 ──────────────────────────────────────
@@ -213,10 +215,22 @@ public class TaskService {
return o instanceof Number n ? n.longValue() : null;
}
/** 是否看全租户:学校管理员与审核员。平台运营不分管具体学校的任务,但也不该被拦。 */
/**
* 是否看全租户:学校 / 机构管理员({@code user_type = 2})。平台运营不分管具体学校的任务,但也不该被拦。
*
* <p>⚠️ 这里以前判角色码 {@code hasAnyRole("SCHOOL_ADMIN", "AUDITOR")}:
* 「审核员」角色已删除,且租户自建的管理类角色也应看全租户 ——
* 所以改成 {@link LoginUser#isTenantAdmin()}(判 {@code user_type})。
*
* <p>⚠️ 平台这一支必须用 {@link LoginUser#isPlatformStaff()} 而不是
* {@code isPlatformAdmin()}:平台**代管某所学校**时(请求头 {@code X-Tenant-Id})
* tenantId 被换成了那所学校,{@code isPlatformAdmin()} 就是 false 了 ——
* 那时会掉进下面"只看自己带的班"的分支,而平台在该校没有班级,
* 于是工作台的任务下拉变成**空列表**,看起来像"这所学校没有任务"。
*/
private boolean seesWholeTenant() {
LoginUser me = UserContext.require();
return me.isPlatformAdmin() || me.hasAnyRole("SCHOOL_ADMIN", "AUDITOR");
return me.isPlatformStaff() || me.isTenantAdmin();
}
// ── 详情 ──────────────────────────────────────
@@ -369,17 +383,103 @@ public class TaskService {
taskMapper.updateById(patch);
}
// ── 删除(仅草稿)────────────────────────────
// ── 删除(草稿直接删;已归档的级联删,2026-09-30 客户确认)──────
/**
* 删除任务(《03-接口设计》§17.2 D-9;原口径"已发布即不可删"会让任务列表只增不减,
* 演示/测试数据只能改名挪开 —— 见《08》F-14)。
*
* <ul>
* <li><b>草稿</b>:直接删(没有业务数据,与原来一样);</li>
* <li><b>已归档(status=3)</b>:管理员显式删除 —— <b>子表级联物理删</b>
* (名册/填报单/材料/审核记录等 14 张表,清掉的是大头:证件号密文、材料、审核记录),
* 但<b>任务行本身软删</b> —— 它必须留下来占住 {@code task_code}:
* {@code TaskMapper.maxSeqByCodePrefix} 靠"含软删行的最大序号"保证
* <b>编码用过的永不复用</b>(编码同时是文件目录名,复用会把两批任务的文件
* 混进同一个目录)。E2E 抓到过这个坑:物理删任务行后新任务拿到重号。
* 物理文件尽力删(删不掉只记日志不阻断),<b>全程留痕</b>
* (sys_operation_log 记 DELETE_TASK + 行数/文件数);</li>
* <li><b>收集中/已截止</b>:仍然拒绝 —— 归档是"最后的后悔药",
* 直接删"还活着的数据"这个口子不开。</li>
* </ul>
*/
@Transactional(rollbackFor = Exception.class)
public void delete(Long id) {
CollectTask exist = requireTask(id);
if (exist.getStatus() == null || exist.getStatus() != CollectTask.STATUS_DRAFT) {
throw new BizException(ErrorCode.VALIDATION_BLOCKED,
"只有草稿状态的任务可以删除;已发布的任务请用「截止」或「归档」");
int st = exist.getStatus() == null ? -1 : exist.getStatus();
if (st == CollectTask.STATUS_DRAFT) {
taskMapper.deleteById(id);
log.info("删除草稿任务 {}(id={})", exist.getTaskCode(), id);
return;
}
if (st != CollectTask.STATUS_ARCHIVED) {
throw new BizException(ErrorCode.VALIDATION_BLOCKED,
"只有草稿或已归档的任务可以删除;收集中/已截止的任务请先「归档」再删"
+ "(删除会连带清掉名册、填报单、材料与文件,不可恢复)");
}
// ★ 先取文件路径再删行 —— 行删了就查不到了(顺序反了 = 文件永远留在磁盘上)
List<String> filePaths = cascadeMapper.filePaths(id);
List<String> exportPaths = cascadeMapper.exportPaths(id);
int rows = cascadeMapper.deleteValidationIssues(id)
+ cascadeMapper.deleteAuditRecords(id)
+ cascadeMapper.deleteNotifyRecords(id)
+ cascadeMapper.deleteOpenPushLogs(id)
+ cascadeMapper.deleteFiles(id)
+ cascadeMapper.deleteExportFiles(id)
+ cascadeMapper.deleteExportTasks(id)
+ cascadeMapper.deleteSubmissions(id)
+ cascadeMapper.deleteRoster(id)
+ cascadeMapper.deleteFormSchemas(id)
+ cascadeMapper.deleteMaterialDefs(id)
+ cascadeMapper.deleteNotifyTemplates(id)
+ cascadeMapper.deleteTaskOrgs(id);
// ★ 任务行**软删**(不是物理删):唯一约束 uk_task_tenant_code 不看 deleted,
// 软删行继续占住编码 → maxSeqByCodePrefix 的"编码不复用"不变量保持成立。
// 物理删它的话,下一个同批次任务会拿到重号(e2e-m1-task 的断言抓到过)。
taskMapper.deleteById(id);
log.info("删除草稿任务 {}(id={})", exist.getTaskCode(), id);
int removed = deleteFilesQuietly(filePaths, exportPaths);
operationLog.record(
cn.tianbaotong.fill.module.log.service.OperationLogService.MODULE_TASK,
cn.tianbaotong.fill.module.log.service.OperationLogService.ACTION_DELETE_TASK,
"TASK", id,
"任务「" + exist.getTaskName() + "」(" + exist.getTaskCode() + ")已归档后删除",
"级联删除 " + rows + " 行;物理删除文件 " + removed + " 个");
log.info("删除已归档任务 {}(id={}):级联 {} 行,文件 {} 个,经办人={}",
exist.getTaskCode(), id, rows, removed, UserContext.uid());
}
/** 尽力删除物理文件(本地磁盘或对象存储)。失败只记日志 —— 行已删,别让一个文件拖垮整个删除。 */
private int deleteFilesQuietly(List<String> filePaths, List<String> exportPaths) {
int ok = 0;
for (String pair : filePaths) {
// 形如 "std/1/xx.jpg|raw/1/xx.jpg"(两段都可能是空串)
for (String p : pair.split("\\|")) {
if (p != null && !p.isBlank() && deleteOneQuietly(p)) {
ok++;
}
}
}
for (String p : exportPaths) {
if (p != null && !p.isBlank() && deleteOneQuietly(p)) {
ok++;
}
}
return ok;
}
private boolean deleteOneQuietly(String path) {
try {
storageService.delete(path);
return true;
} catch (Exception e) { // noqa: BLE001
log.warn("删除任务时清理文件失败(不影响删除本身):{} → {}", path, e.getMessage());
return false;
}
}
// ── 发布 ──────────────────────────────────────
@@ -588,7 +688,8 @@ public class TaskService {
* **"系统到点关的"与"管理员手动关的"** —— 出了争议时这是第一个要看的信息。
*/
public void autoClose(Long taskId) {
patchStatus(taskId, CollectTask.STATUS_CLOSED, "AUTO_CLOSE");
patchStatus(taskId, CollectTask.STATUS_CLOSED,
cn.tianbaotong.fill.module.log.service.OperationLogService.ACTION_AUTO_CLOSE);
}
/**
@@ -618,7 +719,9 @@ public class TaskService {
if (from == status) {
return;
}
String act = action == null ? "STATUS_CHANGE" : action;
String act = action == null
? cn.tianbaotong.fill.module.log.service.OperationLogService.ACTION_STATUS_CHANGE
: action;
operationLog.record(cn.tianbaotong.fill.module.log.service.OperationLogService.MODULE_TASK,
act, cn.tianbaotong.fill.module.log.service.OperationLogService.TARGET_TASK, id,
String.valueOf(from), String.valueOf(status));
@@ -36,7 +36,7 @@ public class TenantController {
private final TenantService tenantService;
@GetMapping
@RequirePerm(PermissionDef.TENANT_MANAGE)
@RequirePerm(PermissionDef.TENANT_VIEW)
@Operation(summary = "租户列表")
public ApiResult<PageResult<TenantVO>> list(
@RequestParam(required = false) String keyword,
@@ -48,7 +48,7 @@ public class TenantController {
}
@GetMapping("/{id}")
@RequirePerm(PermissionDef.TENANT_MANAGE)
@RequirePerm(PermissionDef.TENANT_VIEW)
@Operation(summary = "租户详情")
public ApiResult<TenantVO> detail(@PathVariable Long id) {
return ApiResult.ok(tenantService.detail(id));
@@ -64,7 +64,7 @@ public class TenantController {
@PutMapping("/{id}")
@RequirePerm(PermissionDef.LOG_VIEW_ALL)
@RequirePerm(PermissionDef.TENANT_MANAGE)
@Operation(summary = "修改租户 / 套餐(§6.5)",
description = "⚠️ `tenantCode` **不允许改**:它出现在域名解析与对接文档里,"
+ "改了会让已经发出去的文档与配好的域名全部失效,"
@@ -77,7 +77,7 @@ public class TenantController {
}
@GetMapping("/{id}/usage")
@RequirePerm(PermissionDef.LOG_VIEW_ALL)
@RequirePerm(PermissionDef.TENANT_VIEW)
@Operation(summary = "租户用量统计(§6.7)",
description = "`period` 支持 `2026-09`(月)与 `2026Q3`(季);不传是当月。"
+ "⚠️ `ocrCount` / `smsCount` **恒为 0 并带 notes 说明** —— "
@@ -4,6 +4,7 @@ import cn.tianbaotong.fill.common.api.ErrorCode;
import cn.tianbaotong.fill.common.api.PageResult;
import cn.tianbaotong.fill.common.exception.BizException;
import cn.tianbaotong.fill.common.util.MaskUtil;
import cn.tianbaotong.fill.module.log.service.OperationLogService;
import cn.tianbaotong.fill.module.tenant.dto.TenantCreateReq;
import cn.tianbaotong.fill.module.tenant.dto.TenantVO;
import cn.tianbaotong.fill.module.tenant.entity.SysTenant;
@@ -48,6 +49,7 @@ public class TenantService {
private final UserMapper userMapper;
private final RoleProvisioner roleProvisioner;
private final SensitiveCodec codec;
private final OperationLogService operationLog;
private final PasswordEncoder passwordEncoder;
// ── 列表 ──────────────────────────────────────
@@ -65,7 +67,10 @@ public class TenantService {
.orderByDesc(SysTenant::getId);
if (expireBefore != null && !expireBefore.isBlank()) {
query.le(SysTenant::getExpireTime, LocalDate.parse(expireBefore).atStartOfDay());
// ⚠️ **含当天**:expire_time 存的是当天 23:59:59(见 parseExpire),
// 曾经用 atStartOfDay() 比 —— 于是"今天到期"的租户查不出来(差一天)。
// 同型的坑在 LogController.parseTo 上已经修过一次。
query.le(SysTenant::getExpireTime, parseExpireBefore(expireBefore));
}
IPage<SysTenant> page = tenantMapper.selectPage(new Page<>(num, size), query);
@@ -120,19 +125,76 @@ public class TenantService {
log.info("已开通租户 {}(id={}),管理员账号 {}", code, tid, req.getAdminUsername());
// 留痕(F-1 点名「租户开通」是优先留痕点)。
// ⚠️ 这行的 tenant_id 是 **0**(平台身份,不是刚建的这所学校)—— 写日志用的是
// 操作者的上下文,而 TenantScope.runAs 已在 finally 里恢复了。
// 要查"这所学校是谁开通的",用 target_type=TENANT & target_id=tid。
operationLog.record(OperationLogService.MODULE_TENANT, OperationLogService.ACTION_CREATE_TENANT,
OperationLogService.TARGET_TENANT, tid, null,
"tenantCode=" + code + "; name=" + t.getTenantName() + "; type=" + t.getTenantType());
return new CreateResult(tid, initialPassword, "t" + tid);
}
/**
* 租户详情。
*
* <p>与列表的唯一区别:<b>这里返回联系电话的脱敏值</b>(列表恒为 {@code null},见 {@link #toVo})。
*
* <p>为什么给脱敏值而不是明文:这是全系统唯一一处「加密存储的联系方式」,
* 若在详情里可读且不留痕,就与「看证件号明文必须留痕」({@code VIEW_IDCARD_FULL})
* 这条既有规矩自相矛盾。脱敏号足够确认"档里有号",要改直接填新号 ——
* {@code PUT} 留空 = 不修改,所以界面不需要回填明文。
*/
public TenantVO detail(Long id) {
SysTenant t = tenantMapper.selectById(id);
if (t == null) {
throw new BizException(ErrorCode.VALIDATION_BLOCKED, "租户不存在");
}
return toVo(t);
TenantVO vo = toVo(t);
String plain = plainContactPhone(t.getContactPhone());
vo.setContactPhone(plain == null ? null : MaskUtil.maskPhone(plain));
return vo;
}
// ── 内部 ──────────────────────────────────────
/**
* 解析 {@code expireBefore}({@code yyyy-MM-dd},**含当天**)。
*
* <p>⚠️ 格式不对时**明确报错**,不学 {@code LogController.parseTo} 的"当没传":
* 那两个是界面上的文本框自由输入,静默忽略是为了"输错了也别让人看不到日志";
* 而这里的值只由界面按当前日期算出来,非法值只可能来自手搓请求 ——
* 静默忽略筛选会让人以为"我按到期日筛过了",那正是本仓反复批评的模式。
*/
private static LocalDateTime parseExpireBefore(String s) {
try {
return LocalDate.parse(s.trim()).atTime(23, 59, 59);
} catch (Exception e) {
throw new BizException(ErrorCode.VALIDATION_BLOCKED, "到期日期格式不对,应为 2026-12-31");
}
}
/**
* 取联系电话明文(**只给详情用**)。
*
* <p>这一列是加密列,但库里存在历史明文行(本机 id=1 就是),直接 {@code decrypt}
* 会在 GCM 的 Base64 解码处抛 —— 而"解密失败"在这里不该变成 500:
* 那会让一所学校的详情页**永远打不开**。所以失败时按明文返回,
* 并记一条 WARN 让"还剩多少明文行"可见(历史行不做数据迁移:迁移里拿不到 AES 密钥)。
*/
private String plainContactPhone(String stored) {
if (stored == null || stored.isBlank()) {
return null;
}
try {
return codec.decrypt(stored);
} catch (RuntimeException e) {
log.warn("租户联系电话不是密文(历史明文行或密钥变更过),按明文返回:{}", e.toString());
return stored;
}
}
private static LocalDateTime parseExpire(String s) {
try {
// 允许 "2027-12-31" 或 "2027-12-31T23:59:59"
@@ -151,8 +213,8 @@ public class TenantService {
vo.setTenantName(t.getTenantName());
vo.setTenantType(t.getTenantType());
vo.setContactName(t.getContactName());
// 联系电话以密文入库,列表页要求脱敏展示时的处理在导出/详情里做,
// 这里不返回明文,避免列表接口成为批量拉取联系方式的通道
// 联系电话以密文入库:**列表恒不返回**(脱敏值也不行),
// 避免列表接口成为批量拉取联系方式的通道。脱敏值只有 detail() 给。
vo.setContactPhone(null);
vo.setPackageId(t.getPackageId());
vo.setExpireTime(t.getExpireTime());
@@ -4,10 +4,13 @@ import cn.tianbaotong.fill.common.api.ErrorCode;
import cn.tianbaotong.fill.common.exception.BizException;
import cn.tianbaotong.fill.module.file.entity.CollectFile;
import cn.tianbaotong.fill.module.file.mapper.FileMapper;
import cn.tianbaotong.fill.module.log.service.OperationLogService;
import cn.tianbaotong.fill.module.roster.mapper.CollectRosterMapper;
import cn.tianbaotong.fill.module.roster.entity.CollectRoster;
import cn.tianbaotong.fill.module.tenant.entity.SysTenant;
import cn.tianbaotong.fill.module.tenant.mapper.TenantMapper;
import cn.tianbaotong.fill.support.SensitiveCodec;
import cn.tianbaotong.fill.support.TenantScope;
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
@@ -18,6 +21,7 @@ import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
import java.util.List;
import java.util.Set;
/**
* 租户用量统计(《03-接口设计》§6.7)。
@@ -45,6 +49,8 @@ public class TenantUsageService {
private final TenantMapper tenantMapper;
private final CollectRosterMapper rosterMapper;
private final FileMapper fileMapper;
private final SensitiveCodec codec;
private final OperationLogService operationLog;
/** 默认套餐额度(没有配套餐时的兜底)。 */
private static final long DEFAULT_STUDENT_LIMIT = 2000L;
@@ -64,15 +70,22 @@ public class TenantUsageService {
vo.setTenantName(t.getTenantName());
vo.setPeriod(p.text());
// 学生数:**按证件号去重**(同一个人可能在多个任务里各有一行名册)
vo.setStudentCount(distinctStudents());
// 存储:材料规范件的体积合计(原始件默认不保留,所以它才是真实占用)
Long bytes = fileMapper.selectList(Wrappers.<CollectFile>lambdaQuery()
.select(CollectFile::getFinalSize))
.stream().map(CollectFile::getFinalSize)
.filter(java.util.Objects::nonNull).mapToLong(Long::longValue).sum();
vo.setStorageMb(bytes / 1024 / 1024);
// ⚠️ 这两项必须**切到目标租户的上下文里**再查,不能直接用当前请求的身份查。
//
// 原因:本接口只有平台运营能调(@RequirePerm(TENANT_VIEW),那个码在
// PermissionDef.platformOnlyCodes() 里),而平台运营的 tenantId 是 0 ——
// FillTenantLineHandler.ignoreTable() 对平台身份直接返回 true,
// **整张表的租户过滤被关掉**。于是"学生数 / 存储量"会算成**全平台**的数,
// 却挂在某一所学校的名下:运营拿着它去谈续费,而数字是假的。
//
// 这个 bug 在本地看不见 —— 库里只有一所学校有数据时,它与正确值完全相等
// (ActingTenantResolver 的注释里描述的是同一个陷阱)。
// 切成目标租户之后 isPlatformAdmin() 变 false,租户过滤自动恢复,
// 不需要给这些查询手写 tenant_id 条件(那样等于放弃拦截器的保护)。
Scope scope = TenantScope.callAs(tenantId, 0L, Set.of(),
() -> new Scope(distinctStudents(), storageMb()));
vo.setStudentCount(scope.students());
vo.setStorageMb(scope.storageMb());
// OCR / 短信:一期没有通道,如实返回 0(见类注释)
vo.setOcrCount(0);
@@ -94,6 +107,19 @@ public class TenantUsageService {
return vo;
}
/** 一次上下文切换里取到的两个「本来就是租户内」的数字。 */
private record Scope(long students, long storageMb) {
}
/** 存储:材料规范件的体积合计(原始件默认不保留,所以它才是真实占用)。 */
private long storageMb() {
Long bytes = fileMapper.selectList(Wrappers.<CollectFile>lambdaQuery()
.select(CollectFile::getFinalSize))
.stream().map(CollectFile::getFinalSize)
.filter(java.util.Objects::nonNull).mapToLong(Long::longValue).sum();
return bytes / 1024 / 1024;
}
/** 学生数:按 `id_card_hash` 去重(同一个人在多任务里只算一次)。 */
private long distinctStudents() {
// 单独写一句 SQL 更省事,但这里刻意用 MyBatis-Plus 的写法:
@@ -152,6 +178,7 @@ public class TenantUsageService {
if (req == null) {
throw new BizException(ErrorCode.VALIDATION_BLOCKED, "请求体为空");
}
Integer oldStatus = exist.getStatus();
SysTenant upd = new SysTenant();
upd.setId(id);
if (req.getTenantName() != null) {
@@ -161,21 +188,34 @@ public class TenantUsageService {
upd.setTenantName(req.getTenantName().trim());
}
if (req.getTenantType() != null) {
if (req.getTenantType() < 1 || req.getTenantType() > 3) {
// 取值以 SysTenant.tenantType 的注释为准(1 学校 / 2 认定机构 / 3 报名点 / 4 企业)。
// ⚠️ 这里曾经只放行 1~3,而 DDL 默认值与实体注释都是 4 值 ——
// 于是"认定机构 / 报名点 / 企业"在前端下拉里选得到,保存却报错。
if (req.getTenantType() < 1 || req.getTenantType() > 4) {
throw new BizException(ErrorCode.VALIDATION_BLOCKED,
"租户类型只能是 1(学校)/ 2(机构)/ 3(企业)");
"租户类型只能是 1(学校)/ 2(认定机构)/ 3(报名点)/ 4(企业)");
}
upd.setTenantType(req.getTenantType());
}
upd.setContactName(req.getContactName());
upd.setContactPhone(req.getContactPhone());
// ⚠️ contact_phone 是**加密列**(见 V1__business_tables.sql 的列注释)。
// 这里曾经是 `upd.setContactPhone(req.getContactPhone())` —— 明文直接落库,
// 而读回来的路径会在 AesGcmCipher.decrypt 里 Base64 解不开直接抛,
// 表现是「保存成功,但详情页从此打不开」。空值 = **不修改**(不是清空)。
if (req.getContactPhone() != null && !req.getContactPhone().isBlank()) {
upd.setContactPhone(codec.encrypt(req.getContactPhone().trim()));
}
upd.setPackageId(req.getPackageId());
upd.setExpireTime(req.getExpireTime());
upd.setLogoUrl(req.getLogoUrl());
upd.setConfigJson(req.getConfigJson());
if (req.getStatus() != null) {
if (req.getStatus() != 0 && req.getStatus() != 1) {
throw new BizException(ErrorCode.VALIDATION_BLOCKED, "状态只能是 0(停用)或 1(启用)");
// 取值以 SysTenant.status 的注释为准(0 停用 / 1 正常 / 2 试用)。
// ⚠️ 这里曾经只放行 0/1 —— 而前端的状态筛选按钮组与 TENANT_STATUS 都有"试用",
// 结果是"试用"建不出来也改不出来。
if (req.getStatus() != 0 && req.getStatus() != 1 && req.getStatus() != 2) {
throw new BizException(ErrorCode.VALIDATION_BLOCKED,
"状态只能是 0(停用)/ 1(正常)/ 2(试用)");
}
upd.setStatus(req.getStatus());
if (req.getStatus() == 0) {
@@ -187,7 +227,52 @@ public class TenantUsageService {
tenantMapper.updateById(upd);
log.info("修改租户 id={} 名称={} 状态={} 到期={}", id, upd.getTenantName(),
upd.getStatus(), upd.getExpireTime());
return tenantMapper.selectById(id);
SysTenant now = tenantMapper.selectById(id);
recordChange(id, oldStatus, exist, now, req);
return now;
}
/**
* 租户变更留痕。
*
* <p>《08-完成度盘点》的 F-1 点名「租户开通」是优先留痕点,而本模块此前
* <b>一条日志都不写</b> —— 开通一所学校、把到期日往后挪、停用一所学校,
* 都不留痕,这是整套系统里影响面最大的几类动作之一。
*/
private void recordChange(Long id, Integer oldStatus, SysTenant before, SysTenant after, SysTenant req) {
Integer newStatus = req.getStatus();
if (newStatus != null && !newStatus.equals(oldStatus)) {
String action = switch (newStatus) {
case 0 -> OperationLogService.ACTION_DISABLE_TENANT;
case 1 -> OperationLogService.ACTION_ENABLE_TENANT;
default -> OperationLogService.ACTION_UPDATE_TENANT;
};
operationLog.record(OperationLogService.MODULE_TENANT, action,
OperationLogService.TARGET_TENANT, id,
"status=" + oldStatus, "status=" + newStatus);
return;
}
// 手机号在库里是密文,摘要里只记「有没有设置」(写密文进日志既没用又难看),
// 所以"换了一个号"这件事比较不出来 —— 用请求里有没有带它来补一条。
boolean phoneChanged = req.getContactPhone() != null && !req.getContactPhone().isBlank();
String b = summary(before);
String a = summary(after) + (phoneChanged ? "; phone=已更新" : "");
if (!b.equals(a)) {
operationLog.record(OperationLogService.MODULE_TENANT, OperationLogService.ACTION_UPDATE_TENANT,
OperationLogService.TARGET_TENANT, id, b, a);
}
}
/** 变更摘要(`k=v; k=v`)。`OperationLogService.mask` 会再兜底脱敏一次并截断到列宽。 */
private static String summary(SysTenant t) {
return "name=" + t.getTenantName()
+ "; type=" + t.getTenantType()
+ "; contact=" + t.getContactName()
+ "; phone=" + (t.getContactPhone() == null || t.getContactPhone().isBlank() ? "-" : "已设置")
+ "; package=" + t.getPackageId()
+ "; expire=" + t.getExpireTime()
+ "; status=" + t.getStatus();
}
// ── 周期解析 ──
@@ -0,0 +1,114 @@
package cn.tianbaotong.fill.module.user.controller;
import cn.tianbaotong.fill.common.api.ApiResult;
import cn.tianbaotong.fill.module.user.dto.RoleDtos;
import cn.tianbaotong.fill.module.user.service.RoleService;
import cn.tianbaotong.fill.security.PermissionDef;
import cn.tianbaotong.fill.security.RequirePerm;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import java.util.List;
/**
* 角色与授权(二期)。
*
* <p><b>权限怎么分</b>:读列表用 {@code role:manage} **或** {@code user:view}
* (账号管理页的角色下拉也要这份数据,没有它那个下拉就是空的);
* 看权限码目录与所有写操作用 {@code role:manage}。
*
* <p>内置角色只读({@code roleType=1}):可以看它的权限矩阵,改不了 ——
* 这是"管理员把自己锁在系统外"的唯一入口,而这类错误不可逆。
*/
@Tag(name = "角色与授权")
@RestController
@RequestMapping("/api/v1")
@RequiredArgsConstructor
public class RoleController {
private final RoleService roleService;
@GetMapping("/roles")
@RequirePerm({PermissionDef.ROLE_MANAGE, PermissionDef.USER_VIEW, PermissionDef.USER_MANAGE})
@Operation(summary = "角色列表",
description = "内置角色 + 自定义角色。内置角色的权限与菜单取代码定义(库里那份只是可审计),"
+ "所以它永远显示「现在真正生效」的那一套")
public ApiResult<List<RoleDtos.RoleVO>> list() {
return ApiResult.ok(roleService.list());
}
@GetMapping("/permissions")
@RequirePerm(PermissionDef.ROLE_MANAGE)
@Operation(summary = "权限码目录",
description = "按分组返回全部权限码(含中文名与适用端)。platformOnly=true 的码学校租户不可授予")
public ApiResult<List<RoleDtos.PermGroupVO>> permissions() {
return ApiResult.ok(roleService.permissionCatalog());
}
@PostMapping("/roles")
@RequirePerm(PermissionDef.ROLE_MANAGE)
@Operation(summary = "新增自定义角色",
description = "角色码创建后不可改;学校租户不能授予平台级权限,也不能建平台运营类的角色")
public ApiResult<Long> create(@Valid @RequestBody RoleDtos.RoleCreateReq req) {
return ApiResult.ok(roleService.create(req));
}
@PutMapping("/roles/{id}")
@RequirePerm(PermissionDef.ROLE_MANAGE)
@Operation(summary = "修改自定义角色",
description = "只改名称/用户类型/备注;角色码与权限走各自的接口。"
+ "角色还有账号在用时不改「用户类型」(会让账号与角色对不上)")
public ApiResult<Void> update(@PathVariable Long id, @Valid @RequestBody RoleDtos.RoleUpdateReq req) {
roleService.update(id, req);
return ApiResult.ok();
}
@DeleteMapping("/roles/{id}")
@RequirePerm(PermissionDef.ROLE_MANAGE)
@Operation(summary = "删除自定义角色",
description = "还有账号在用返回 40045;不允许删掉自己进入本页所依赖的最后一个角色")
public ApiResult<Void> delete(@PathVariable Long id) {
roleService.delete(id);
return ApiResult.ok();
}
@PutMapping("/roles/{id}/permissions")
@RequirePerm(PermissionDef.ROLE_MANAGE)
@Operation(summary = "设置角色的权限码(覆盖式)",
description = "改完立即生效(服务端会清掉该角色的授权缓存),不需要重新登录")
public ApiResult<Void> setPermissions(@PathVariable Long id,
@Valid @RequestBody RoleDtos.RolePermReq req) {
roleService.setPermissions(id, req.getPermCodes());
return ApiResult.ok();
}
@PutMapping("/roles/{id}/menus")
@RequirePerm(PermissionDef.ROLE_MANAGE)
@Operation(summary = "设置角色的可见菜单(覆盖式)",
description = "菜单只决定「看得见哪些入口」,不代表有权限操作;改完立即生效")
public ApiResult<Void> setMenus(@PathVariable Long id,
@Valid @RequestBody RoleDtos.RoleMenuReq req) {
roleService.setMenus(id, req.getMenus());
return ApiResult.ok();
}
@PutMapping("/roles/{id}/status")
@RequirePerm(PermissionDef.ROLE_MANAGE)
@Operation(summary = "启用 / 停用角色",
description = "停用后该角色立刻不再给权限(不必等 token 过期);内置角色不可停用")
public ApiResult<Void> setStatus(@PathVariable Long id, @RequestParam Integer status) {
roleService.setStatus(id, status);
return ApiResult.ok();
}
}
@@ -0,0 +1,196 @@
package cn.tianbaotong.fill.module.user.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.Max;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotEmpty;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Pattern;
import jakarta.validation.constraints.Size;
import lombok.Getter;
import lombok.Setter;
import java.util.List;
/**
* 「角色与授权」(二期)的请求与响应。
*
* <p>⚠️ <b>内置角色只读</b>:{@code roleType=1} 的角色在界面上可以看、不可以改
* (权限由代码决定,见 {@code BuiltinRole})。这不是"暂时没做",是刻意的 ——
* 一次误操作就能把管理员锁在系统外,而这类错误不可逆。
*
* <p>⚠️ <b>{@code dataScope} 本期只展示、不可配</b>:数据范围(本人/本班/本院系/全租户)
* 目前由"拿到了哪些权限码 + 任务里被指派了哪些班"共同决定,散在审核/催报/代传/看板各处;
* 把它做成可配需要先把那些判据收口(单独一批做)。所以这里**不提供**修改入口 ——
* 让界面出现一个"改了不生效"的开关,比没有这个开关更糟。
*/
public final class RoleDtos {
private RoleDtos() {
}
@Getter
@Setter
@Schema(description = "角色列表项")
public static class RoleVO {
private Long id;
private String roleCode;
private String roleName;
/** {@code 1} 内置(只读)/ {@code 2} 自定义。 */
private Integer roleType;
/** {@code 1} 平台运营 / {@code 2} 学校管理员 / {@code 3} 班主任 / {@code 4} 学生。 */
private Integer userType;
/** {@code 1} 全平台 / {@code 2} 本租户 / {@code 3} 本组织及下级 / {@code 4} 仅本人(本期只展示)。 */
private Integer dataScope;
/** {@code 0} 停用 / {@code 1} 正常。 */
private Integer status;
private Integer sortNo;
private String remark;
/** 有多少账号在用(删除前的依据)。 */
private Integer userCount;
/** 能否编辑 / 删除 / 停用(内置角色恒 {@code false})。 */
private Boolean editable;
/**
* 能否发给账号。
*
* <p>与 {@link #editable} 是两件事:内置的「平台运营」角色**可以看**(列表里列出来),
* 但在学校租户里**发不出去**(服务端 40301 —— 拿到它会直接越权到平台级)。
* 界面据此不显示没得选的项,而不是让人选完再收到一个错误。
*/
private Boolean assignable;
/** 权限码:内置角色取代码定义,自定义角色取库里的授权。 */
private List<String> permCodes;
/** 可见菜单键(与前端 {@code XtMenu.key} 一致)。 */
private List<String> menus;
}
@Getter
@Setter
@Schema(description = "新增自定义角色")
public static class RoleCreateReq {
/**
* 角色码。
*
* <p>格式对齐内置角色的风格(大写 + 下划线):它会进 JWT 的 {@code roles}、
* 出现在日志与排查对话里,混进小写或中文会让"这串东西是什么"变得难以检索。
*/
@NotBlank(message = "请填写角色码")
@Size(max = 64, message = "角色码过长")
@Pattern(regexp = "^[A-Z][A-Z0-9_]{1,63}$",
message = "角色码只能是大写字母、数字与下划线,且以字母开头")
@Schema(description = "角色码,租户内唯一,创建后不可改", example = "CLASS_AUDITOR",
requiredMode = Schema.RequiredMode.REQUIRED)
private String roleCode;
@NotBlank(message = "请填写角色名称")
@Size(max = 64, message = "角色名称过长")
@Schema(description = "角色名称(显示用,可改)", example = "年级审核员",
requiredMode = Schema.RequiredMode.REQUIRED)
private String roleName;
@NotNull(message = "请选择用户类型")
@Min(value = 1, message = "用户类型不合法")
@Max(value = 4, message = "用户类型不合法")
@Schema(description = "该角色对应的用户类型(1 平台运营 / 2 学校管理员 / 3 班主任 / 4 学生);"
+ "账号的 userType 由它推导,所以必须选对", example = "3",
requiredMode = Schema.RequiredMode.REQUIRED)
private Integer userType;
@Size(max = 255, message = "备注过长")
@Schema(description = "备注")
private String remark;
@NotEmpty(message = "请至少勾选一项权限")
@Schema(description = "权限码清单(学校租户不能包含平台级权限,见 /permissions 的 platformOnly)",
requiredMode = Schema.RequiredMode.REQUIRED)
private List<String> permCodes;
@NotEmpty(message = "请至少勾选一个菜单")
@Schema(description = "可见菜单键(决定登录后左侧能看到哪些入口,不代表有权限操作)",
requiredMode = Schema.RequiredMode.REQUIRED)
private List<String> menus;
}
@Getter
@Setter
@Schema(description = "修改自定义角色(角色码不可改)")
public static class RoleUpdateReq {
@NotBlank(message = "请填写角色名称")
@Size(max = 64, message = "角色名称过长")
@Schema(description = "角色名称", requiredMode = Schema.RequiredMode.REQUIRED)
private String roleName;
@NotNull(message = "请选择用户类型")
@Min(value = 1, message = "用户类型不合法")
@Max(value = 4, message = "用户类型不合法")
@Schema(description = "该角色对应的用户类型", requiredMode = Schema.RequiredMode.REQUIRED)
private Integer userType;
@Size(max = 255, message = "备注过长")
@Schema(description = "备注")
private String remark;
}
@Getter
@Setter
@Schema(description = "设置角色的权限码(覆盖式)")
public static class RolePermReq {
@NotNull(message = "请传 permCodes(可以是空数组)")
@Schema(description = "权限码清单,覆盖式替换", requiredMode = Schema.RequiredMode.REQUIRED)
private List<String> permCodes;
}
@Getter
@Setter
@Schema(description = "设置角色的可见菜单(覆盖式)")
public static class RoleMenuReq {
@NotNull(message = "请传 menus(可以是空数组)")
@Schema(description = "菜单键清单,覆盖式替换", requiredMode = Schema.RequiredMode.REQUIRED)
private List<String> menus;
}
@Getter
@Setter
@Schema(description = "权限码目录(按分组,供角色授权界面渲染)")
public static class PermGroupVO {
private String group;
private List<PermVO> items;
}
@Getter
@Setter
@Schema(description = "权限码")
public static class PermVO {
private String code;
private String label;
/** {@code PC} / {@code MP} / {@code BOTH}。 */
private String platform;
/** 平台级权限:学校租户不可授予(界面要标出来并禁止勾选)。 */
private Boolean platformOnly;
}
}
@@ -24,6 +24,18 @@ public class SysRole extends TenantEntity {
/** {@code 1} 内置(不可删,权限由代码决定)/ {@code 2} 自定义。 */
private Integer roleType;
/**
* 角色对应的用户类型:{@code 1} 平台运营 / {@code 2} 学校管理员 / {@code 3} 班主任 / {@code 4} 学生。
*
* <p>存在的理由:{@code sys_user.user_type} 是 NOT NULL,而它的值一直是"从角色反推"的
* (见 {@code BuiltinRole.UserType})。自定义角色不在代码映射里,反推不出来 ——
* 没有这一列,"角色建得出来、却发不出去"。
*
* <p>⚠️ 内置角色的权威仍是代码({@code BuiltinRole.getUserType()});
* 这列对内置角色只用于"看得见、可审计"。
*/
private Integer userType;
/** {@code 1} 全平台 / {@code 2} 本租户 / {@code 3} 本组织及下级 / {@code 4} 仅本人。 */
private Integer dataScope;
@@ -0,0 +1,26 @@
package cn.tianbaotong.fill.module.user.entity;
import cn.tianbaotong.fill.support.TenantEntity;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Getter;
import lombok.Setter;
/**
* 角色与菜单键的关联(对应 {@code sys_role_menu},二期「角色与授权」)。
*
* <p>菜单键本身的定义在代码里({@code BuiltinRole} 的菜单集合,与前端
* {@code XtMenu.key} 逐字一致);这张表只存「这个角色能看见哪些入口」。
*
* <p>⚠️ <b>看得见 ≠ 有权限</b>:真正的访问控制仍在服务端的权限码校验上,
* 菜单只是"不显示没权限的入口"(绕过前端只会拿到 40301)。
*/
@Getter
@Setter
@TableName("sys_role_menu")
public class SysRoleMenu extends TenantEntity {
private Long roleId;
/** 菜单键,如 {@code task}。 */
private String menuKey;
}
@@ -2,6 +2,51 @@ package cn.tianbaotong.fill.module.user.mapper;
import cn.tianbaotong.fill.module.user.entity.SysRole;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import java.util.List;
public interface RoleMapper extends BaseMapper<SysRole> {
/**
* 每个角色被多少账号使用(角色列表页展示"能不能删"的依据)。
*
* <p>一次查完再在内存里配对,而不是"每个角色查一次" —— 角色列表是
* "1 个页面 N 行"的形状,逐行查就是 N+1(本项目对 N+1 有明确的压测结论,见《09》§9.5)。
*
* <p>⚠️ 别名写成 {@code user_count}(蛇形)而不是 {@code userCount}:
* PostgreSQL 会把**不加引号的别名折成小写**(`userCount` → `usercount`),
* 而 MyBatis 的蛇形→驼峰映射只认 `user_count`。这个坑本项目踩过两次(《08》F-54 / F-83)。
*/
@Select("""
SELECT ur.role_id, COUNT(*) AS user_count
FROM sys_user_role ur
JOIN sys_user u ON u.id = ur.user_id AND u.deleted = 0
WHERE ur.deleted = 0 AND ur.tenant_id = #{tenantId}
GROUP BY ur.role_id
""")
List<RoleUserCount> countUsersByRole(@Param("tenantId") Long tenantId);
/** 上面那条查询的结果行。 */
class RoleUserCount {
private Long roleId;
private Integer userCount;
public Long getRoleId() {
return roleId;
}
public void setRoleId(Long roleId) {
this.roleId = roleId;
}
public Integer getUserCount() {
return userCount;
}
public void setUserCount(Integer userCount) {
this.userCount = userCount;
}
}
}
@@ -0,0 +1,13 @@
package cn.tianbaotong.fill.module.user.mapper;
import cn.tianbaotong.fill.module.user.entity.SysRoleMenu;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
/**
* 角色菜单({@code sys_role_menu})。
*
* <p>与 {@code RolePermissionMapper} 一样,只做最基础的事 —— 菜单键的定义在代码里,
* 所以没有"菜单字典表",也没有联表查询。
*/
public interface RoleMenuMapper extends BaseMapper<SysRoleMenu> {
}
@@ -119,4 +119,22 @@ public interface UserMapper extends BaseMapper<SysUser> {
AND r.role_code IN ('PLATFORM_ADMIN', 'SCHOOL_ADMIN')
""")
Long countAdminRoles(@Param("tenantId") Long tenantId, @Param("userId") Long userId);
/**
* 本租户下持某个内置角色的用户 id(按角色找,而不是按固定账号 —— 换管理员不该改代码)。
*
* <p>用途:需要"管理员介入"的自动通知的收件人(例如驳回次数达上限,契约 §11.4)。
* 与 `ScheduleMapper.usersOfRole` 的区别:那条是**跨租户**的定时任务用的(忽略租户拦截器),
* 这条在租户上下文里用、租户条件显式给,并与拦截器互为印证。
*/
@InterceptorIgnore(tenantLine = "true")
@Select("""
SELECT DISTINCT u.id
FROM sys_user u
JOIN sys_user_role ur ON ur.user_id = u.id AND ur.deleted = 0
JOIN sys_role ro ON ro.id = ur.role_id AND ro.deleted = 0
WHERE u.tenant_id = #{tenantId} AND u.deleted = 0 AND u.status = 1
AND ro.role_code = #{roleCode}
""")
List<Long> userIdsOfRole(@Param("tenantId") Long tenantId, @Param("roleCode") String roleCode);
}
@@ -0,0 +1,236 @@
package cn.tianbaotong.fill.module.user.service;
import cn.tianbaotong.fill.module.user.entity.SysRole;
import cn.tianbaotong.fill.module.user.entity.SysRoleMenu;
import cn.tianbaotong.fill.module.user.entity.SysRolePermission;
import cn.tianbaotong.fill.module.user.mapper.RoleMapper;
import cn.tianbaotong.fill.module.user.mapper.RoleMenuMapper;
import cn.tianbaotong.fill.module.user.mapper.RolePermissionMapper;
import cn.tianbaotong.fill.security.BuiltinRole;
import cn.tianbaotong.fill.security.PermissionDef;
import cn.tianbaotong.fill.security.RoleGrantSource;
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
import lombok.extern.slf4j.Slf4j;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Component;
import java.time.Duration;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Set;
import java.util.regex.Pattern;
/**
* 自定义角色授权(权限码 + 菜单)的库实现({@link RoleGrantSource})。
*
* <h3>为什么必须有缓存</h3>
*
* <p>权限判定发生在**每一个**受保护请求上({@code AuthInterceptor} → {@code PermissionChecker}),
* 而它此前是**零 IO** 的(权限由代码 {@code BuiltinRole} 决定)。开放自定义角色之后,
* "这个角色被授了什么"只能从库里读 —— 不加缓存就是每个请求多 1~2 条 SQL,
* 与本项目的压测基线(SQL 条数恒定、DB ≤2ms,见《09-部署与运维》§9.5)直接冲突。
*
* <p>缓存放 Redis(不是 JVM 内存):① 多实例部署时各存一份内存缓存,
* "改了权限但只有一台机器生效"是那种最难查的错;② 本项目**本来就每请求读一次 Redis**
* (登出黑名单 {@code TokenBlacklist} 用的是 {@code hasKey}),所以这是同一量级的开销。
* 权限与菜单合并成**一个**缓存项:{@code /auth/me} 两样都要,分开存就是两次往返。
*
* <h3>三个必须守住的细节</h3>
*
* <ol>
* <li><b>空结果也要缓存</b>({@link #EMPTY})。不缓存"没有"的话,
* 一个已被删除角色的 token(8 小时内仍有效)会把库打穿 —— 每个请求都回查一次。</li>
* <li><b>Redis 挂了要退化成直读库</b>,不能当成"没有权限"(那会让全校忽然登不进去),
* 也不能当成"有权限"(那是越权)。所以缓存读写失败只记一行 warn,然后照常查库。</li>
* <li><b>TTL 是兜底,不是机制</b>:正常路径靠写操作后的 {@link #evict} 立即失效;
* TTL 只保证"万一漏了 evict"也不会永久生效一个错的值。</li>
* </ol>
*
* <p>⚠️ 内置角色({@code role_type=1})**不读这两张表**:它们的权限与菜单由代码决定。
* 库里那些行是为了"看得见、可审计",不是生效依据 —— 详见 {@link RoleGrantSource}。
*/
@Slf4j
@Component
public class DbRoleGrantSource implements RoleGrantSource {
/** 缓存键:{@code xt:role:grant:{tenantId}:{roleCode}}。与 {@code xt:jwt:bl:} 等同一套命名。 */
static final String KEY_PREFIX = "xt:role:grant:";
/** 空结果标记。用 {@code -} 而不是空串:空串与"没缓存"在 Redis 里不好区分。 */
static final String EMPTY = "-";
/** 集合内部的分隔符。权限码与菜单键都不含逗号,所以拼接是安全的(省掉一次 JSON 解析)。 */
static final String SEP = ",";
/** 权限与菜单之间的分隔符。 */
static final String PART_SEP = "|";
/** 兜底 TTL:漏了 evict 也不会永久生效一个错的值。 */
private static final Duration TTL = Duration.ofMinutes(5);
private final StringRedisTemplate redis;
private final RoleMapper roleMapper;
private final RolePermissionMapper rolePermissionMapper;
private final RoleMenuMapper roleMenuMapper;
public DbRoleGrantSource(StringRedisTemplate redis, RoleMapper roleMapper,
RolePermissionMapper rolePermissionMapper, RoleMenuMapper roleMenuMapper) {
this.redis = redis;
this.roleMapper = roleMapper;
this.rolePermissionMapper = rolePermissionMapper;
this.roleMenuMapper = roleMenuMapper;
}
@Override
public Set<String> permsOf(long tenantId, String roleCode) {
return grant(tenantId, roleCode).perms();
}
@Override
public Set<String> menusOf(long tenantId, String roleCode) {
return grant(tenantId, roleCode).menus();
}
/**
* 让某个角色的授权缓存立即失效(权限与菜单一起)。
*
* <p>**每一次**改授权、改菜单、改状态、删角色之后都必须调它 ——
* 否则"我明明把权限去掉了,他怎么还能点"会持续到 TTL 到期(最多 5 分钟),
* 而这类现象会被当成"系统没保存"。
*/
public void evict(long tenantId, String roleCode) {
if (roleCode == null || roleCode.isBlank()) {
return;
}
try {
redis.delete(KEY_PREFIX + tenantId + ":" + roleCode);
} catch (RuntimeException e) {
// 删不掉不是致命错误:TTL 会兜住(5 分钟后自然失效)
log.warn("清理角色授权缓存失败(TTL 到期后会自然失效):tenant={} role={} 原因={}",
tenantId, roleCode, e.toString());
}
}
// ── 内部 ──────────────────────────────────────
private Grant grant(long tenantId, String roleCode) {
if (roleCode == null || roleCode.isBlank()) {
return Grant.EMPTY;
}
String key = KEY_PREFIX + tenantId + ":" + roleCode;
String cached = readCache(key);
if (cached != null) {
return decode(cached);
}
Grant fresh = loadFromDb(tenantId, roleCode);
writeCache(key, encode(fresh));
return fresh;
}
private Grant loadFromDb(long tenantId, String roleCode) {
// 角色是租户隔离的,而这里可能跑在平台上下文(定时任务)里 —— 拦截器那时不生效,
// 所以 tenant_id 必须**显式**写进条件,不能只靠拦截器。
SysRole role = roleMapper.selectOne(Wrappers.<SysRole>lambdaQuery()
.eq(SysRole::getTenantId, tenantId)
.eq(SysRole::getRoleCode, roleCode)
.last("LIMIT 1"));
// 只有自定义角色(role_type=2)的授权存在库里;
// 内置角色即使库里有行也不作数(权限与菜单都由代码决定)。
if (role == null || role.getRoleType() == null || role.getRoleType() != 2) {
return Grant.EMPTY;
}
// 停用的角色 = 不生效。放在这里而不是只在登录时判:停用要**立刻**生效,
// 否则"把他那个角色停掉"要等到 token 过期(PC 最长 8 小时)。
if (role.getStatus() != null && role.getStatus() == 0) {
return Grant.EMPTY;
}
return new Grant(loadPerms(tenantId, role.getId()), loadMenus(tenantId, role.getId()));
}
private Set<String> loadPerms(long tenantId, Long roleId) {
List<SysRolePermission> rows = rolePermissionMapper.selectList(
Wrappers.<SysRolePermission>lambdaQuery()
.eq(SysRolePermission::getTenantId, tenantId)
.eq(SysRolePermission::getRoleId, roleId));
Set<String> codes = new LinkedHashSet<>();
for (SysRolePermission rp : rows) {
// 只认代码里还存在的权限码:{@code @RequirePerm} 只可能校验枚举里的码,
// 把库里残留的一个已删除码塞进"当前用户的权限全集"只会污染 /auth/me。
if (rp.getPermCode() != null && PermissionDef.ofCode(rp.getPermCode()) != null) {
codes.add(rp.getPermCode());
}
}
return codes;
}
private Set<String> loadMenus(long tenantId, Long roleId) {
List<SysRoleMenu> rows = roleMenuMapper.selectList(
Wrappers.<SysRoleMenu>lambdaQuery()
.eq(SysRoleMenu::getTenantId, tenantId)
.eq(SysRoleMenu::getRoleId, roleId));
Set<String> keys = new LinkedHashSet<>();
Set<String> known = BuiltinRole.allMenuKeys();
for (SysRoleMenu rm : rows) {
// 同上:只认现存的菜单键(XT_MENUS 里没有的键,前端渲染不出来)
if (rm.getMenuKey() != null && known.contains(rm.getMenuKey())) {
keys.add(rm.getMenuKey());
}
}
return keys;
}
private String readCache(String key) {
try {
return redis.opsForValue().get(key);
} catch (RuntimeException e) {
// 降级为直读库:**不能**当成"没有权限"(全校忽然登不进去),
// 也**不能**当成"有权限"(越权)。查库是唯一两个方向都安全的选择。
log.warn("读角色授权缓存失败,本次直读数据库:key={} 原因={}", key, e.toString());
return null;
}
}
private void writeCache(String key, String value) {
try {
redis.opsForValue().set(key, value, TTL);
} catch (RuntimeException e) {
log.warn("写角色授权缓存失败(不影响本次判定):key={} 原因={}", key, e.toString());
}
}
private static String encode(Grant g) {
return encodeSet(g.perms()) + PART_SEP + encodeSet(g.menus());
}
private static String encodeSet(Set<String> values) {
return values.isEmpty() ? EMPTY : String.join(SEP, values);
}
private static Grant decode(String raw) {
String[] parts = raw.split(Pattern.quote(PART_SEP), -1);
Set<String> perms = decodeSet(parts.length > 0 ? parts[0] : null);
Set<String> menus = decodeSet(parts.length > 1 ? parts[1] : null);
return new Grant(perms, menus);
}
private static Set<String> decodeSet(String part) {
if (part == null || part.isBlank() || EMPTY.equals(part)) {
return Set.of();
}
Set<String> values = new LinkedHashSet<>();
for (String v : part.split(SEP)) {
if (!v.isBlank()) {
values.add(v);
}
}
return values;
}
/** 一个自定义角色的生效内容:能做什么(perms)+ 看得见什么(menus)。 */
record Grant(Set<String> perms, Set<String> menus) {
static final Grant EMPTY = new Grant(Set.of(), Set.of());
}
}
@@ -0,0 +1,92 @@
package cn.tianbaotong.fill.module.user.service;
import cn.tianbaotong.fill.module.user.entity.SysRoleMenu;
import cn.tianbaotong.fill.module.user.entity.SysRolePermission;
import cn.tianbaotong.fill.module.user.mapper.RoleMenuMapper;
import cn.tianbaotong.fill.module.user.mapper.RolePermissionMapper;
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Component;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Set;
/**
* 角色授权(权限码 + 菜单)的**唯一写入口**。
*
* <p>为什么抽出来:同一件事有两个调用方 ——
* {@code RoleProvisioner}(开通租户与每日同步内置角色)与 {@code RoleService}(租户自己配自定义角色)。
* 两边各写一份"缺的补上、多的删掉"必然分叉,而分叉的表现是
* 「内置角色同步得好好的、自定义角色改完却多出一条旧权限」这种一半对一半错的现象。
*
* <p><b>幂等是硬要求</b>:它每天会被定时任务调一次({@code ScheduleJobs} 的「内置角色授权同步」),
* 两边集合一样时**一行都不写**。
*/
@Component
@RequiredArgsConstructor
public class RoleGrantWriter {
private final RolePermissionMapper rolePermissionMapper;
private final RoleMenuMapper roleMenuMapper;
/** 把角色的权限码对齐到给定集合(覆盖式)。 */
public void replacePerms(long tenantId, Long roleId, Set<String> want) {
List<SysRolePermission> exist = rolePermissionMapper.selectList(
Wrappers.<SysRolePermission>lambdaQuery()
.eq(SysRolePermission::getTenantId, tenantId)
.eq(SysRolePermission::getRoleId, roleId));
Set<String> have = new LinkedHashSet<>();
for (SysRolePermission rp : exist) {
if (rp.getPermCode() != null) {
have.add(rp.getPermCode());
}
}
for (String code : want) {
if (!have.contains(code)) {
SysRolePermission rp = new SysRolePermission();
rp.setTenantId(tenantId);
rp.setRoleId(roleId);
rp.setPermCode(code);
rolePermissionMapper.insert(rp);
}
}
for (SysRolePermission rp : exist) {
if (rp.getPermCode() != null && !want.contains(rp.getPermCode())) {
// 软删(实体基类带 @TableLogic):硬删会让 uk_roleperm_role_code
// 与"这条授权什么时候没的"对不上
rolePermissionMapper.deleteById(rp.getId());
}
}
}
/** 把角色的可见菜单对齐到给定集合(覆盖式)。 */
public void replaceMenus(long tenantId, Long roleId, Set<String> want) {
List<SysRoleMenu> exist = roleMenuMapper.selectList(
Wrappers.<SysRoleMenu>lambdaQuery()
.eq(SysRoleMenu::getTenantId, tenantId)
.eq(SysRoleMenu::getRoleId, roleId));
Set<String> have = new LinkedHashSet<>();
for (SysRoleMenu rm : exist) {
if (rm.getMenuKey() != null) {
have.add(rm.getMenuKey());
}
}
for (String key : want) {
if (!have.contains(key)) {
SysRoleMenu rm = new SysRoleMenu();
rm.setTenantId(tenantId);
rm.setRoleId(roleId);
rm.setMenuKey(key);
roleMenuMapper.insert(rm);
}
}
for (SysRoleMenu rm : exist) {
if (rm.getMenuKey() != null && !want.contains(rm.getMenuKey())) {
roleMenuMapper.deleteById(rm.getId());
}
}
}
}
@@ -0,0 +1,524 @@
package cn.tianbaotong.fill.module.user.service;
import cn.tianbaotong.fill.common.api.ErrorCode;
import cn.tianbaotong.fill.common.context.LoginUser;
import cn.tianbaotong.fill.common.context.UserContext;
import cn.tianbaotong.fill.common.exception.BizException;
import cn.tianbaotong.fill.module.log.service.OperationLogService;
import cn.tianbaotong.fill.module.user.dto.RoleDtos;
import cn.tianbaotong.fill.module.user.entity.SysRole;
import cn.tianbaotong.fill.module.user.entity.SysRoleMenu;
import cn.tianbaotong.fill.module.user.entity.SysRolePermission;
import cn.tianbaotong.fill.module.user.mapper.RoleMapper;
import cn.tianbaotong.fill.module.user.mapper.RoleMenuMapper;
import cn.tianbaotong.fill.module.user.mapper.RolePermissionMapper;
import cn.tianbaotong.fill.security.BuiltinRole;
import cn.tianbaotong.fill.security.PermissionDef;
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.ArrayList;
import java.util.Collection;
import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
/**
* 角色与授权(二期)。
*
* <h3>四条口径(都不是"随便定的",每条都对应一类会出事的做法)</h3>
*
* <ol>
* <li><b>内置角色只读</b>:{@code role_type=1} 的角色可以看、不可以改。
* 它是"管理员把自己锁在系统外"的唯一入口,而这类错误不可逆。</li>
* <li><b>权限码的白名单在服务端</b>:学校租户不能授予平台级权限({@code tenant:*} 等)。
* 界面上不显示那些勾选项只是"不提示",绕过前端直接调接口才是真正的攻击面。</li>
* <li><b>防自锁</b>:不允许把"自己管理角色所依赖的最后一个来源"改掉/删掉/停用 ——
* 包括那条 {@code role:manage} 权限与 {@code role} 菜单键。</li>
* <li><b>改动立即生效</b>:每次写授权都清缓存({@code DbRoleGrantSource.evict})。
* 不清的话"我把权限去掉了,他怎么还能点"会持续到 TTL 到期,而用户会认为"没保存"。</li>
* </ol>
*
* <p>⚠️ {@code data_scope} 本期不提供修改入口:数据范围目前由"拿到了哪些权限码 +
* 任务里被指派了哪些班"共同决定,散在审核/催报/代传/看板各处,做成可配要先把那些判据收口。
* 让界面出现一个"改了不生效"的开关,比没有这个开关更糟。
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class RoleService {
/** 自定义角色的默认数据范围:{@code 4} 仅本人(最保守的那一档)。 */
private static final int DEFAULT_DATA_SCOPE = 4;
/** 自定义角色的排序值:排在 6 个内置角色(1~90)之后。 */
private static final int CUSTOM_SORT_NO = 500;
private final RoleMapper roleMapper;
private final RolePermissionMapper rolePermissionMapper;
private final RoleMenuMapper roleMenuMapper;
private final RoleGrantWriter grantWriter;
private final DbRoleGrantSource grantSource;
private final OperationLogService operationLogService;
// ── 查询 ──────────────────────────────────────
/** 角色列表(内置 + 自定义)。 */
public List<RoleDtos.RoleVO> list() {
Long tenantId = UserContext.tenantId();
List<SysRole> roles = roleMapper.selectList(Wrappers.<SysRole>lambdaQuery()
.eq(SysRole::getTenantId, tenantId)
.orderByAsc(SysRole::getRoleType)
.orderByAsc(SysRole::getSortNo)
.orderByAsc(SysRole::getId));
if (roles.isEmpty()) {
return List.of();
}
// 三条批量查询,避免"每个角色查一次"的 N+1
List<Long> ids = new ArrayList<>();
roles.forEach(r -> ids.add(r.getId()));
Map<Long, List<String>> permsByRole = permsOfRoles(tenantId, ids);
Map<Long, List<String>> menusByRole = menusOfRoles(tenantId, ids);
Map<Long, Integer> counts = new HashMap<>();
for (RoleMapper.RoleUserCount row : roleMapper.countUsersByRole(tenantId)) {
if (row.getRoleId() != null) {
counts.put(row.getRoleId(), row.getUserCount() == null ? 0 : row.getUserCount());
}
}
List<RoleDtos.RoleVO> out = new ArrayList<>();
boolean platform = isPlatformTenant();
Set<String> assignableBuiltins = BuiltinRole.assignableCodes(platform);
for (SysRole role : roles) {
BuiltinRole br = BuiltinRole.ofCode(role.getRoleCode());
boolean builtin = br != null && role.getRoleType() != null && role.getRoleType() == 1;
boolean enabled = role.getStatus() != null && role.getStatus() == 1;
RoleDtos.RoleVO vo = new RoleDtos.RoleVO();
vo.setId(role.getId());
vo.setRoleCode(role.getRoleCode());
vo.setRoleName(role.getRoleName());
vo.setRoleType(role.getRoleType());
vo.setUserType(builtin ? br.getUserType().code() : role.getUserType());
vo.setDataScope(role.getDataScope());
vo.setStatus(role.getStatus());
vo.setSortNo(role.getSortNo());
vo.setRemark(role.getRemark());
vo.setUserCount(counts.getOrDefault(role.getId(), 0));
vo.setEditable(!builtin);
// 能不能发给账号:停用的不行;内置角色还要过"这个租户能不能发它"的白名单
// (学校租户发不了 PLATFORM_ADMIN —— 那是越权到平台级的入口)
vo.setAssignable(enabled && (!builtin || assignableBuiltins.contains(role.getRoleCode())));
// 内置角色的权限与菜单**取代码**:库里的行只是"看得见、可审计",
// 若这里读库,代码改了权限而库没同步时,界面就会显示一套过期的授权
vo.setPermCodes(builtin ? List.copyOf(br.permissionCodes())
: permsByRole.getOrDefault(role.getId(), List.of()));
vo.setMenus(builtin ? List.copyOf(br.getMenus())
: menusByRole.getOrDefault(role.getId(), List.of()));
out.add(vo);
}
return out;
}
/** 权限码目录(按分组),供角色授权界面渲染勾选项。 */
public List<RoleDtos.PermGroupVO> permissionCatalog() {
Map<String, List<RoleDtos.PermVO>> groups = new LinkedHashMap<>();
Set<String> platformOnly = PermissionDef.platformOnlyCodes();
for (PermissionDef def : PermissionDef.values()) {
RoleDtos.PermVO item = new RoleDtos.PermVO();
item.setCode(def.getCode());
item.setLabel(def.getLabel());
item.setPlatform(def.getPlatform().name());
item.setPlatformOnly(platformOnly.contains(def.getCode()));
groups.computeIfAbsent(def.getGroup(), k -> new ArrayList<>()).add(item);
}
List<RoleDtos.PermGroupVO> out = new ArrayList<>();
groups.forEach((group, items) -> {
RoleDtos.PermGroupVO vo = new RoleDtos.PermGroupVO();
vo.setGroup(group);
vo.setItems(items);
out.add(vo);
});
return out;
}
// ── 写 ──────────────────────────────────────
@Transactional(rollbackFor = Exception.class)
public Long create(RoleDtos.RoleCreateReq req) {
Long tenantId = UserContext.tenantId();
String code = req.getRoleCode() == null ? "" : req.getRoleCode().trim();
if (BuiltinRole.ofCode(code) != null) {
throw new BizException(ErrorCode.VALIDATION_BLOCKED,
"「" + code + "」是内置角色码,不能占用");
}
if (findByCode(tenantId, code) != null) {
throw new BizException(ErrorCode.VALIDATION_BLOCKED, "角色码已存在:" + code);
}
assertUserTypeAllowed(req.getUserType());
Set<String> perms = validatePerms(req.getPermCodes());
Set<String> menus = validateMenus(req.getMenus());
SysRole role = new SysRole();
role.setTenantId(tenantId);
role.setRoleCode(code);
role.setRoleName(req.getRoleName().trim());
role.setRoleType(2);
role.setUserType(req.getUserType());
role.setDataScope(DEFAULT_DATA_SCOPE);
role.setSortNo(CUSTOM_SORT_NO);
role.setStatus(1);
role.setRemark(req.getRemark());
roleMapper.insert(role);
grantWriter.replacePerms(tenantId, role.getId(), perms);
grantWriter.replaceMenus(tenantId, role.getId(), menus);
grantSource.evict(tenantId, code);
operationLogService.record(OperationLogService.MODULE_ROLE,
OperationLogService.ACTION_CREATE_ROLE, OperationLogService.TARGET_ROLE, role.getId(),
null, code + " " + role.getRoleName() + " 权限 " + perms.size() + " 项");
return role.getId();
}
@Transactional(rollbackFor = Exception.class)
public void update(Long id, RoleDtos.RoleUpdateReq req) {
Long tenantId = UserContext.tenantId();
SysRole role = requireCustomRole(tenantId, id);
assertUserTypeAllowed(req.getUserType());
// 改了用户类型而角色还挂在账号上 → 那些账号的 user_type 会与角色对不上,
// 而 user_type 决定"他算学生还是老师"(学生走小程序、老师走 PC)。
// 与其静默制造不一致,不如让他先解绑(这是一次很少发生的操作)。
if (!java.util.Objects.equals(role.getUserType(), req.getUserType())) {
int used = countUsers(tenantId, role.getId());
if (used > 0) {
throw new BizException(ErrorCode.VALIDATION_BLOCKED,
"该角色还有 " + used + " 个账号在用,改「用户类型」会让这些账号与角色不一致;"
+ "请先给这些账号换角色,或另建一个角色");
}
}
String before = role.getRoleCode() + " " + role.getRoleName() + " / userType=" + role.getUserType();
role.setRoleName(req.getRoleName().trim());
role.setUserType(req.getUserType());
role.setRemark(req.getRemark());
roleMapper.updateById(role);
grantSource.evict(tenantId, role.getRoleCode());
operationLogService.record(OperationLogService.MODULE_ROLE,
OperationLogService.ACTION_UPDATE_ROLE, OperationLogService.TARGET_ROLE, role.getId(),
before, role.getRoleCode() + " " + role.getRoleName() + " / userType=" + role.getUserType());
}
@Transactional(rollbackFor = Exception.class)
public void delete(Long id) {
Long tenantId = UserContext.tenantId();
SysRole role = requireCustomRole(tenantId, id);
int used = countUsers(tenantId, role.getId());
if (used > 0) {
throw new BizException(ErrorCode.ROLE_IN_USE,
"该角色还有 " + used + " 个账号在用,请先改掉这些账号的角色");
}
assertNotSelfLock(tenantId, role, Set.of(), Set.of());
roleMapper.deleteById(role.getId());
// 授权与菜单一起软删:留着孤儿行会让"这个角色曾经能做什么"与"现在还有什么"混在一起
grantWriter.replacePerms(tenantId, role.getId(), Set.of());
grantWriter.replaceMenus(tenantId, role.getId(), Set.of());
grantSource.evict(tenantId, role.getRoleCode());
operationLogService.record(OperationLogService.MODULE_ROLE,
OperationLogService.ACTION_DELETE_ROLE, OperationLogService.TARGET_ROLE, role.getId(),
role.getRoleCode() + " " + role.getRoleName(), null);
}
@Transactional(rollbackFor = Exception.class)
public void setPermissions(Long id, List<String> permCodes) {
Long tenantId = UserContext.tenantId();
SysRole role = requireCustomRole(tenantId, id);
Set<String> perms = validatePerms(permCodes);
assertNotSelfLock(tenantId, role, perms, currentMenus(tenantId, role));
Set<String> before = currentPerms(tenantId, role);
grantWriter.replacePerms(tenantId, role.getId(), perms);
grantSource.evict(tenantId, role.getRoleCode());
operationLogService.record(OperationLogService.MODULE_ROLE,
OperationLogService.ACTION_GRANT_ROLE, OperationLogService.TARGET_ROLE, role.getId(),
String.join(",", before), String.join(",", perms));
}
@Transactional(rollbackFor = Exception.class)
public void setMenus(Long id, List<String> menus) {
Long tenantId = UserContext.tenantId();
SysRole role = requireCustomRole(tenantId, id);
Set<String> keys = validateMenus(menus);
assertNotSelfLock(tenantId, role, currentPerms(tenantId, role), keys);
Set<String> before = currentMenus(tenantId, role);
grantWriter.replaceMenus(tenantId, role.getId(), keys);
grantSource.evict(tenantId, role.getRoleCode());
operationLogService.record(OperationLogService.MODULE_ROLE,
OperationLogService.ACTION_UPDATE_ROLE_MENU, OperationLogService.TARGET_ROLE, role.getId(),
String.join(",", before), String.join(",", keys));
}
@Transactional(rollbackFor = Exception.class)
public void setStatus(Long id, Integer status) {
Long tenantId = UserContext.tenantId();
SysRole role = requireCustomRole(tenantId, id);
if (status == null || (status != 0 && status != 1)) {
throw new BizException(ErrorCode.VALIDATION_BLOCKED, "状态只能是 0(停用)或 1(正常)");
}
if (status == 0) {
// 停用 = 这个角色立刻不再给权限(见 DbRoleGrantSource),所以要过防自锁
assertNotSelfLock(tenantId, role, Set.of(), Set.of());
}
role.setStatus(status);
roleMapper.updateById(role);
grantSource.evict(tenantId, role.getRoleCode());
operationLogService.record(OperationLogService.MODULE_ROLE,
OperationLogService.ACTION_TOGGLE_ROLE, OperationLogService.TARGET_ROLE, role.getId(),
null, role.getRoleCode() + " → " + (status == 1 ? "正常" : "停用"));
}
// ── 内部:取数与校验 ────────────────────────────
private SysRole findByCode(Long tenantId, String roleCode) {
return roleMapper.selectOne(Wrappers.<SysRole>lambdaQuery()
.eq(SysRole::getTenantId, tenantId)
.eq(SysRole::getRoleCode, roleCode)
.last("LIMIT 1"));
}
/**
* 取一个**自定义**角色;内置角色直接拒绝。
*
* <p>⚠️ 这里额外比一次 {@code tenant_id}:平台运营身份下租户拦截器是关闭的
* (它是唯一跨租户的身份),只靠 {@code selectById} 就能拿到别的租户的角色行,
* 而随后写回去会把它写成"平台租户的角色"。所以平台身份走这条路时也要挡住 ——
* 平台想改哪个学校,必须先像其它页面一样选定学校({@code X-Tenant-Id})。
*/
private SysRole requireCustomRole(Long tenantId, Long id) {
SysRole role = roleMapper.selectById(id);
if (role == null || (role.getTenantId() != null && !role.getTenantId().equals(tenantId))) {
throw new BizException(ErrorCode.VALIDATION_BLOCKED, "角色不存在");
}
if (role.getRoleType() == null || role.getRoleType() != 2) {
throw new BizException(ErrorCode.VALIDATION_BLOCKED,
"「" + role.getRoleName() + "」是内置角色,权限由代码决定,不可修改");
}
return role;
}
/** 学校租户不能创建"平台运营"那一类的角色(与 {@code UserService.resolveAssignableRoles} 同一条防线)。 */
private void assertUserTypeAllowed(Integer userType) {
if (!isPlatformTenant() && userType != null
&& userType == BuiltinRole.UserType.PLATFORM.code()) {
throw new BizException(ErrorCode.FORBIDDEN, "无权创建平台运营类的角色");
}
}
private Set<String> validatePerms(List<String> codes) {
Set<String> out = new LinkedHashSet<>();
if (codes == null) {
return out;
}
boolean platform = isPlatformTenant();
Set<String> platformOnly = PermissionDef.platformOnlyCodes();
for (String raw : codes) {
if (raw == null || raw.isBlank()) {
continue;
}
String code = raw.trim();
PermissionDef def = PermissionDef.ofCode(code);
if (def == null) {
throw new BizException(ErrorCode.VALIDATION_BLOCKED, "权限码不存在:" + code);
}
if (!platform && platformOnly.contains(code)) {
throw new BizException(ErrorCode.FORBIDDEN,
"无权授予平台级权限:" + def.getLabel() + "(" + code + ")");
}
out.add(code);
}
return out;
}
private Set<String> validateMenus(List<String> keys) {
Set<String> known = BuiltinRole.allMenuKeys();
Set<String> platformOnly = BuiltinRole.platformOnlyMenuKeys();
boolean platform = isPlatformTenant();
Set<String> out = new LinkedHashSet<>();
if (keys == null) {
return out;
}
for (String raw : keys) {
if (raw == null || raw.isBlank()) {
continue;
}
String key = raw.trim();
if (!known.contains(key)) {
throw new BizException(ErrorCode.VALIDATION_BLOCKED, "菜单键不存在:" + key);
}
// 与权限码同一类防线:把平台专用的入口配给学校的角色,
// 左侧会多出一个点进去必然 403 的菜单 —— 用户只会认为"系统坏了"
if (!platform && platformOnly.contains(key)) {
throw new BizException(ErrorCode.FORBIDDEN,
"无权配置平台级菜单:" + key + "(学校租户看不到这个入口)");
}
out.add(key);
}
return out;
}
/**
* 防自锁:不允许把"自己管理角色所依赖的最后一个来源"拿掉。
*
* <p>两类自锁各对应一个具体现象:
* <ul>
* <li>去掉 {@code role:manage} → 本页所有接口立刻 40301,只能找人用 SQL 救;</li>
* <li>去掉 {@code role} 菜单键 → 菜单里不再有这一项,前端连路由都不会注册
* ({@code buildXtRoutes} 按菜单键过滤),页面在界面上彻底消失。</li>
* </ul>
*
* <p>判据是"**别的**角色里还有没有这一条":内置角色(如学校管理员)天然有,
* 所以正常情况下这个校验永远不会触发 —— 它防的是"管理员把自己的自定义角色改秃了"。
*/
private void assertNotSelfLock(Long tenantId, SysRole role, Set<String> newPerms, Set<String> newMenus) {
LoginUser me = UserContext.get();
if (me == null || me.isPlatformAdmin() || me.roles() == null) {
return;
}
if (!me.roles().contains(role.getRoleCode())) {
return;
}
if (!otherRolesGrant(tenantId, me.roles(), role.getRoleCode(),
PermissionDef.ROLE_MANAGE.getCode(), false)
&& !newPerms.contains(PermissionDef.ROLE_MANAGE.getCode())) {
throw new BizException(ErrorCode.VALIDATION_BLOCKED,
"这是你自己进入「角色与授权」所依赖的最后一个来源:去掉「角色与授权」权限后,"
+ "你将无法再打开本页。请先给另一个角色授予该权限");
}
if (!otherRolesGrant(tenantId, me.roles(), role.getRoleCode(),
ROLE_MENU_KEY, true) && !newMenus.contains(ROLE_MENU_KEY)) {
throw new BizException(ErrorCode.VALIDATION_BLOCKED,
"这是你自己进入「角色与授权」所依赖的最后一个来源:去掉「角色与授权」菜单后,"
+ "左侧菜单里将不再出现本页。请先给另一个角色配上该菜单");
}
}
/** {@code role} 菜单键 —— 与前端 {@code XtMenu.key} 逐字一致。 */
private static final String ROLE_MENU_KEY = "role";
/**
* 当前用户的**其它**角色里,有没有哪个角色带这一条(权限码或菜单键)。
*
* @param menu true = 查菜单键,false = 查权限码
*/
private boolean otherRolesGrant(Long tenantId, Collection<String> myRoles, String excludeCode,
String want, boolean menu) {
for (String rc : myRoles) {
if (rc == null || rc.equals(excludeCode)) {
continue;
}
BuiltinRole br = BuiltinRole.ofCode(rc);
if (br != null) {
boolean has = menu ? br.getMenus().contains(want) : br.permissionCodes().contains(want);
if (has) {
return true;
}
continue;
}
boolean has = menu ? grantSource.menusOf(tenantId, rc).contains(want)
: grantSource.permsOf(tenantId, rc).contains(want);
if (has) {
return true;
}
}
return false;
}
private Set<String> currentPerms(Long tenantId, SysRole role) {
Set<String> out = new LinkedHashSet<>();
for (SysRolePermission rp : rolePermissionMapper.selectList(
Wrappers.<SysRolePermission>lambdaQuery()
.eq(SysRolePermission::getTenantId, tenantId)
.eq(SysRolePermission::getRoleId, role.getId()))) {
if (rp.getPermCode() != null) {
out.add(rp.getPermCode());
}
}
return out;
}
private Set<String> currentMenus(Long tenantId, SysRole role) {
Set<String> out = new LinkedHashSet<>();
for (SysRoleMenu rm : roleMenuMapper.selectList(
Wrappers.<SysRoleMenu>lambdaQuery()
.eq(SysRoleMenu::getTenantId, tenantId)
.eq(SysRoleMenu::getRoleId, role.getId()))) {
if (rm.getMenuKey() != null) {
out.add(rm.getMenuKey());
}
}
return out;
}
private int countUsers(Long tenantId, Long roleId) {
for (RoleMapper.RoleUserCount row : roleMapper.countUsersByRole(tenantId)) {
if (roleId.equals(row.getRoleId())) {
return row.getUserCount() == null ? 0 : row.getUserCount();
}
}
return 0;
}
private Map<Long, List<String>> permsOfRoles(Long tenantId, List<Long> roleIds) {
Map<Long, List<String>> out = new HashMap<>();
if (roleIds.isEmpty()) {
return out;
}
for (SysRolePermission rp : rolePermissionMapper.selectList(
Wrappers.<SysRolePermission>lambdaQuery()
.eq(SysRolePermission::getTenantId, tenantId)
.in(SysRolePermission::getRoleId, roleIds))) {
if (rp.getPermCode() != null) {
out.computeIfAbsent(rp.getRoleId(), k -> new ArrayList<>()).add(rp.getPermCode());
}
}
return out;
}
private Map<Long, List<String>> menusOfRoles(Long tenantId, List<Long> roleIds) {
Map<Long, List<String>> out = new HashMap<>();
if (roleIds.isEmpty()) {
return out;
}
for (SysRoleMenu rm : roleMenuMapper.selectList(
Wrappers.<SysRoleMenu>lambdaQuery()
.eq(SysRoleMenu::getTenantId, tenantId)
.in(SysRoleMenu::getRoleId, roleIds))) {
if (rm.getMenuKey() != null) {
out.computeIfAbsent(rm.getRoleId(), k -> new ArrayList<>()).add(rm.getMenuKey());
}
}
return out;
}
private boolean isPlatformTenant() {
Long tenantId = UserContext.tenantId();
return tenantId != null && tenantId == LoginUser.PLATFORM_TENANT_ID;
}
}
@@ -13,10 +13,13 @@ import cn.tianbaotong.fill.module.user.dto.ResetPasswordReq;
import cn.tianbaotong.fill.module.user.dto.UserImportResult;
import cn.tianbaotong.fill.module.user.dto.UserUpsertReq;
import cn.tianbaotong.fill.module.user.dto.UserVO;
import cn.tianbaotong.fill.module.user.entity.SysRole;
import cn.tianbaotong.fill.module.user.entity.SysUser;
import cn.tianbaotong.fill.module.user.mapper.RoleMapper;
import cn.tianbaotong.fill.module.user.mapper.UserMapper;
import cn.tianbaotong.fill.module.user.mapper.UserRoleMapper;
import cn.tianbaotong.fill.security.BuiltinRole;
import cn.tianbaotong.fill.security.PermissionDef;
import cn.tianbaotong.fill.support.RoleProvisioner;
import cn.tianbaotong.fill.support.SensitiveCodec;
import com.alibaba.excel.EasyExcel;
@@ -73,18 +76,11 @@ public class UserService {
private static final Pattern PHONE_RE = Pattern.compile("^1[3-9]\\d{9}$");
/** 学校租户可分配的角色。 */
private static final Set<String> SCHOOL_ASSIGNABLE = Set.of(
"SCHOOL_ADMIN", "HEAD_TEACHER", "DEPUTY_TEACHER", "AUDITOR", "STUDENT");
/** 平台租户可分配的角色。 */
private static final Set<String> PLATFORM_ASSIGNABLE = Set.of("PLATFORM_ADMIN");
/** 拥有 {@code user:manage} 的角色。判断"还有没有人能管账号"只需看这两个。 */
private static final Set<String> ADMIN_ROLE_CODES = Set.of("PLATFORM_ADMIN", "SCHOOL_ADMIN");
/**
* 角色 → 用户类型(《03-接口设计》§2.7,与 {@code BuiltinRole.UserType} 一致)。
* 用户类型 → 中文名(用于错误提示)。
*
* <p>⚠️ <b>不要照 {@code sys_user} 那行 DDL 注释写</b>。那行注释写的是
* 「1教师/管理员 2填报人(学生) 3社会人员」,与接口契约的
@@ -94,16 +90,12 @@ public class UserService {
*
* <p>这个不一致已经造成过一次真实误判:把 {@code 3} 当成"社会人员(二期)"而拒掉,
* 而 {@code 3} 恰恰是<b>班主任</b> —— 最高频的一种账号。
*
* <p>二期改动:原先这里还有一张 {@code ROLE_TO_USER_TYPE} 的"角色码 → 用户类型"表,
* 现在删了 —— 用户类型改由 {@link #effectiveUserType} 取(内置角色看代码的
* {@code BuiltinRole.UserType},自定义角色看 {@code sys_role.user_type})。
* 留两张表迟早会分叉,而分叉的表现是"新建账号报类型不符、但看不出为什么"。
*/
private static final Map<String, Integer> ROLE_TO_USER_TYPE = Map.of(
"PLATFORM_ADMIN", 1,
"SCHOOL_ADMIN", 2,
"AUDITOR", 2,
"HEAD_TEACHER", 3,
"DEPUTY_TEACHER", 3,
"STUDENT", 4);
/** 用户类型 → 中文名(用于错误提示)。与 {@link #ROLE_TO_USER_TYPE} 同一套口径。 */
private static final Map<Integer, String> USER_TYPE_NAMES = Map.of(
1, "平台运营",
2, "学校管理员",
@@ -138,7 +130,9 @@ public class UserService {
private final UserMapper userMapper;
private final UserRoleMapper userRoleMapper;
private final OrgMapper orgMapper;
private final RoleMapper roleMapper;
private final RoleProvisioner roleProvisioner;
private final DbRoleGrantSource roleGrantSource;
private final SensitiveCodec codec;
private final PasswordEncoder passwordEncoder;
private final LoginGuard loginGuard;
@@ -164,7 +158,11 @@ public class UserService {
final String kw = (raw == null || raw.isEmpty()) ? null : raw;
LambdaQueryWrapper<SysUser> q = Wrappers.lambdaQuery();
q.eq(orgId != null, SysUser::getOrgId, orgId)
// ★ 显式租户条件(与 OrgService.tree 同一个理由):拦截器对平台运营
// (tenantId=0)整个关掉了过滤,不加这一条就会把**所有租户的账号**混在一起返回。
// 平台要看某所学校的账号,先在页面上选定学校(X-Tenant-Id),那时 tenantId 就是它。
q.eq(SysUser::getTenantId, UserContext.tenantId())
.eq(orgId != null, SysUser::getOrgId, orgId)
.eq(userType != null, SysUser::getUserType, userType)
.eq(status != null, SysUser::getStatus, status);
@@ -180,9 +178,18 @@ public class UserService {
}
}
if (roleCode != null && BuiltinRole.ofCode(roleCode) != null) {
// roleCode 已经过内置角色白名单校验,拼串是安全的。
// 用子查询而不是 JOIN,是为了让 MP 的租户过滤与逻辑删除条件继续作用于主表。
if (roleCode != null) {
// ⚠️ 角色码**不认识时必须返回空**,绝不能"忽略这个过滤条件"。
// 忽略的后果是:调用方以为筛出了"班主任",实际拿到**全校账号(含学生)**——
// 而且不报任何错。这个坑真实发生过:2026-10 删掉「副班主任」角色之后,
// 前端还在传 `roleCode=DEPUTY_TEACHER`,内置枚举里已经没有它了,
// 于是"选初审人"的下拉里出现了学生。
if (!isKnownRoleCode(roleCode)) {
log.warn("按未知角色码过滤账号,按失败关闭返回空:roleCode={}", roleCode);
return PageResult.of(List.of(), 0, num, size);
}
// 角色码已经过白名单校验(内置枚举或库里真实存在),拼串是安全的。
// 用子查询而不是 JOIN,是为了让租户过滤与逻辑删除条件继续作用于主表。
q.inSql(SysUser::getId,
"SELECT ur.user_id FROM sys_user_role ur"
+ " JOIN sys_role r ON r.id = ur.role_id"
@@ -211,7 +218,7 @@ public class UserService {
String username = req.getUsername().trim();
assertUsernameUnique(username, null);
List<BuiltinRole> roles = resolveAssignableRoles(req.getRoleCodes());
List<SysRole> roles = resolveAssignableRoles(req.getRoleCodes());
assertRoleMatchesUserType(req.getUserType(), roles);
String initialPassword = MaskUtil.generateInitialPassword();
@@ -226,12 +233,12 @@ public class UserService {
codec.applyPhone(u, req.getPhone());
userMapper.insert(u);
for (BuiltinRole br : roles) {
roleProvisioner.bindRole(tenantId, u.getId(), br);
for (SysRole role : roles) {
roleProvisioner.bindRoleByCode(tenantId, u.getId(), role.getRoleCode());
}
log.info("新增账号 {}(id={}, 角色={})", username, u.getId(), roles.stream()
.map(BuiltinRole::getCode).toList());
.map(SysRole::getRoleCode).toList());
return new CreateResult(u.getId(), initialPassword);
}
@@ -250,7 +257,7 @@ public class UserService {
SysUser exist = requireUser(id);
long tenantId = UserContext.tenantId();
List<BuiltinRole> roles = resolveAssignableRoles(req.getRoleCodes());
List<SysRole> roles = resolveAssignableRoles(req.getRoleCodes());
assertRoleMatchesUserType(req.getUserType(), roles);
// 停用会被改成停用时,先过一遍"别把自己/最后一个管理员锁死"
@@ -272,6 +279,12 @@ public class UserService {
assertAnotherAdminExists(exist, "取消管理员角色");
}
// 二期新增的一条自锁:**自定义角色**让"管理员"不再等于那两个内置角色码。
// 上面的判据只看内置管理员角色,所以「自己靠自定义角色管账号、再把自己换成别的角色」
// 会绕过它 —— 账号还在、还能登录,但再也进不了账号管理与角色授权。
// 这类错误不可逆(只能找人用 SQL 救),所以单独判一次。
assertKeepsAccountManage(exist, roles);
SysUser patch = new SysUser();
patch.setId(id);
patch.setRealName(req.getRealName().trim());
@@ -378,7 +391,7 @@ public class UserService {
public UserImportResult importExcel(MultipartFile file, String defaultRoleCode) {
assertImportFile(file);
BuiltinRole fallbackRole = resolveFallbackRole(defaultRoleCode);
SysRole fallbackRole = resolveFallbackRole(defaultRoleCode);
List<Map<Integer, String>> rows;
try (InputStream in = file.getInputStream()) {
@@ -468,7 +481,7 @@ public class UserService {
}
}
BuiltinRole role = fallbackRole;
SysRole role = fallbackRole;
if (err == null && roleText != null && !roleText.isBlank()) {
role = resolveRoleByText(roleText);
if (role == null) {
@@ -477,7 +490,7 @@ public class UserService {
}
// 用户类型从角色推导(二者一对一绑定)—— 导入文件里没有这一列,
// 也不该要求用户去填:填错就是整行白导。
Integer userType = role == null ? null : ROLE_TO_USER_TYPE.get(role.getCode());
Integer userType = role == null ? null : effectiveUserType(role);
if (err == null && userType == null) {
err = new UserImportResult.RowError(excelRow, "role", roleText,
"该角色没有对应的用户类型");
@@ -502,7 +515,7 @@ public class UserService {
u.setOrgId(orgId != null ? orgId : orgByCode.get(orgText.trim()));
}
userMapper.insert(u);
roleProvisioner.bindRole(tenantId, u.getId(), role);
roleProvisioner.bindRoleByCode(tenantId, u.getId(), role.getRoleCode());
seenInFile.add(username.trim());
result.setSuccess(result.getSuccess() + 1);
@@ -517,6 +530,28 @@ public class UserService {
// ── 护栏 ──────────────────────────────────────
/**
* 角色码是否真实存在:**内置枚举** 或 **本租户的自定义角色**。
*
* <p>它有两层用途:
* <ol>
* <li>它是下面那段拼串 SQL 的**白名单** —— 只有"库里确实存在的角色码"才允许被拼进去,
* 所以天然挡住注入;</li>
* <li>它把"传了一个不存在的角色码"变成**失败关闭**。上一版对这种情况是
* <b>静默忽略过滤条件</b>,于是调用方以为筛出了"班主任",
* 实际拿到全校账号(含学生)—— 而且不报任何错。
* 2026-10 删掉「副班主任」角色后就真实发生过一次(前端还在传那个码)。</li>
* </ol>
*/
private boolean isKnownRoleCode(String roleCode) {
if (BuiltinRole.ofCode(roleCode) != null) {
return true;
}
Long n = roleMapper.selectCount(
Wrappers.<SysRole>lambdaQuery().eq(SysRole::getRoleCode, roleCode));
return n != null && n > 0;
}
/**
* 校验并解析可分配的角色。
*
@@ -532,11 +567,19 @@ public class UserService {
* <p>两道防线:本方法按"当前租户能分配什么角色"白名单校验;
* 同时 {@code RoleProvisioner} 不再给学校租户发放 {@code PLATFORM_ADMIN} 角色行。
* 任一道单独都够用,但都留着 —— 这类错误一旦发生是不可逆的信息泄露。
*
* <p><b>二期(角色与授权)的改法</b>:返回类型从 {@code BuiltinRole} 换成 {@code SysRole} ——
* 因为可选角色不再只有 6 个内置角色,租户自己建的角色也要能发出去。
* 内置角色那条白名单**一个字都没改**(它防的是越权到平台级);
* 自定义角色不需要白名单:它是本租户的行(上面的查询本身就带 {@code tenant_id}),
* 而它能包含哪些权限码在 {@code RoleService.validatePerms} 里已经挡过平台级权限。
*/
private List<BuiltinRole> resolveAssignableRoles(List<String> codes) {
private List<SysRole> resolveAssignableRoles(List<String> codes) {
Long tenantId = UserContext.tenantId();
boolean platform = tenantId != null && tenantId == LoginUser.PLATFORM_TENANT_ID;
Set<String> allowed = platform ? PLATFORM_ASSIGNABLE : SCHOOL_ASSIGNABLE;
// 白名单规则只有一处(BuiltinRole.assignableCodes):界面上的 assignable 标志也用它,
// 两处各写一份必然分叉成"界面能选、点了 40301"
Set<String> allowed = BuiltinRole.assignableCodes(platform);
LinkedHashSet<String> distinct = new LinkedHashSet<>();
if (codes != null) {
@@ -550,16 +593,25 @@ public class UserService {
throw new BizException(ErrorCode.VALIDATION_BLOCKED, "请至少选择一个角色");
}
List<BuiltinRole> roles = new ArrayList<>();
// 一次读出本租户全部启用中的角色(内置 + 自定义),再按码取 —— 避免逐条查库
Map<String, SysRole> byCode = new LinkedHashMap<>();
for (SysRole r : roleMapper.selectList(Wrappers.<SysRole>lambdaQuery()
.eq(SysRole::getTenantId, tenantId)
.eq(SysRole::getStatus, 1))) {
byCode.put(r.getRoleCode(), r);
}
List<SysRole> roles = new ArrayList<>();
for (String c : distinct) {
BuiltinRole br = BuiltinRole.ofCode(c);
if (br == null) {
throw new BizException(ErrorCode.VALIDATION_BLOCKED, "角色不存在:" + c);
SysRole role = byCode.get(c);
if (role == null) {
throw new BizException(ErrorCode.VALIDATION_BLOCKED, "角色不存在或已停用:" + c);
}
if (!allowed.contains(c)) {
BuiltinRole br = BuiltinRole.ofCode(c);
if (br != null && !allowed.contains(c)) {
throw new BizException(ErrorCode.FORBIDDEN, "无权分配角色:" + br.getName());
}
roles.add(br);
roles.add(role);
}
return roles;
}
@@ -568,20 +620,20 @@ public class UserService {
* 用户类型必须与所选角色自洽。
*
* <p>做法不是"逐条 if",而是<b>从角色反推用户类型再比对</b>:
* 角色与用户类型本来就是一对一绑定的(见 {@link #ROLE_TO_USER_TYPE}),
* 角色与用户类型本来就是一对一绑定的(见 {@link #effectiveUserType}),
* 反过来推只有一个正确答案,写成一堆 if 迟早会漏掉某个组合。
*
* <p>多角色时要求它们属于同一类(比如「班主任 + 副班主任」都是 3 可以,
* 「班主任 + 审核员」是 3 与 2 就不能共存)—— 否则"这个人到底是几"没有答案。
*/
private void assertRoleMatchesUserType(Integer userType, List<BuiltinRole> roles) {
private void assertRoleMatchesUserType(Integer userType, List<SysRole> roles) {
if (userType == null) {
throw new BizException(ErrorCode.VALIDATION_BLOCKED, "请选择用户类型");
}
Set<Integer> expected = new LinkedHashSet<>();
for (BuiltinRole br : roles) {
Integer t = ROLE_TO_USER_TYPE.get(br.getCode());
for (SysRole role : roles) {
Integer t = effectiveUserType(role);
if (t != null) {
expected.add(t);
}
@@ -607,6 +659,18 @@ public class UserService {
}
}
/**
* 角色对应的用户类型。
*
* <p>内置角色**取代码**({@link BuiltinRole#getUserType()}),自定义角色取
* {@code sys_role.user_type}。与权限判定同一套口径:内置角色的权威在代码,
* 库里的行只是"看得见、可审计"。
*/
private Integer effectiveUserType(SysRole role) {
BuiltinRole br = BuiltinRole.ofCode(role.getRoleCode());
return br != null ? br.getUserType().code() : role.getUserType();
}
private void assertUsernameUnique(String username, Long excludeId) {
SysUser dup = userMapper.selectOne(Wrappers.<SysUser>lambdaQuery()
.eq(SysUser::getUsername, username)
@@ -659,9 +723,9 @@ public class UserService {
* <p>只有在目标<b>原本确实是管理员</b>时才返回 true ——
* 否则把一个普通班主任换成另一个普通角色也会被上面那次检查拦住,那是误伤。
*/
private boolean losingAdminRole(SysUser target, List<BuiltinRole> newRoles) {
private boolean losingAdminRole(SysUser target, List<SysRole> newRoles) {
boolean stillAdmin = newRoles.stream()
.anyMatch(r -> ADMIN_ROLE_CODES.contains(r.getCode()));
.anyMatch(r -> ADMIN_ROLE_CODES.contains(r.getRoleCode()));
if (stillAdmin) {
return false;
}
@@ -669,6 +733,64 @@ public class UserService {
return now != null && now > 0;
}
/**
* 改角色后,操作者自己是否还留着「账号管理」这条能力(**只判自己**)。
*
* <p>为什么必须单独判:{@link #losingAdminRole} 只看那两个内置管理员角色码,
* 而二期之后"能管账号"也可能来自一个自定义角色(它被授了 {@code user:manage})。
* 于是「自己靠自定义角色管账号 → 把自己换成别的角色」会绕过上面那条检查:
* 账号还在、还能登录,但再也打不开账号管理与角色授权,而**这类错误不可逆**。
*
* <p>为什么只判自己:别人的权限由别人负责,拦下来只会让正常的人员交接做不了;
* 而"把自己改秃"的代价是他一个人再也回不来。
*/
private void assertKeepsAccountManage(SysUser target, List<SysRole> newRoles) {
if (!Objects.equals(target.getId(), UserContext.uid())) {
return;
}
Long tenantId = UserContext.tenantId();
if (tenantId == null || tenantId == LoginUser.PLATFORM_TENANT_ID) {
return; // 平台运营不受权限码限制,锁不住自己
}
String want = PermissionDef.USER_MANAGE.getCode();
if (rolesGrant(tenantId, newRoles, want)) {
return;
}
// 改之前本来就没有 → 他不是靠这条权限进来的,不拦
if (!rolesGrant(tenantId, currentRolesOf(tenantId, target.getId()), want)) {
return;
}
throw new BizException(ErrorCode.VALIDATION_BLOCKED,
"这是你自己进入「账号管理」所依赖的最后一个来源:改完之后你将无法再管理本单位的账号。"
+ "请先给另一个账号配上带「账号管理」的角色");
}
/** 这些角色里有没有哪个带这条权限(内置看代码,自定义看库)。 */
private boolean rolesGrant(Long tenantId, List<SysRole> roles, String permCode) {
for (SysRole role : roles) {
BuiltinRole br = BuiltinRole.ofCode(role.getRoleCode());
if (br != null) {
if (br.permissionCodes().contains(permCode)) {
return true;
}
} else if (roleGrantSource.permsOf(tenantId, role.getRoleCode()).contains(permCode)) {
return true;
}
}
return false;
}
/** 某个人现在挂着的角色行。 */
private List<SysRole> currentRolesOf(Long tenantId, Long userId) {
List<String> codes = userRoleMapper.selectRoleCodesByUserId(userId);
if (codes.isEmpty()) {
return List.of();
}
return roleMapper.selectList(Wrappers.<SysRole>lambdaQuery()
.eq(SysRole::getTenantId, tenantId)
.in(SysRole::getRoleCode, codes));
}
private void assertPasswordStrength(String plain) {
cn.tianbaotong.fill.common.util.MaskUtil.assertPasswordStrong(plain);
}
@@ -695,11 +817,11 @@ public class UserService {
return u;
}
private void rebindRoles(long tenantId, Long userId, List<BuiltinRole> roles) {
private void rebindRoles(long tenantId, Long userId, List<SysRole> roles) {
userRoleMapper.delete(Wrappers.<cn.tianbaotong.fill.module.user.entity.SysUserRole>lambdaQuery()
.eq(cn.tianbaotong.fill.module.user.entity.SysUserRole::getUserId, userId));
for (BuiltinRole br : roles) {
roleProvisioner.bindRole(tenantId, userId, br);
for (SysRole role : roles) {
roleProvisioner.bindRoleByCode(tenantId, userId, role.getRoleCode());
}
}
@@ -720,17 +842,26 @@ public class UserService {
}
}
private BuiltinRole resolveFallbackRole(String defaultRoleCode) {
BuiltinRole br = defaultRoleCode == null || defaultRoleCode.isBlank()
? BuiltinRole.HEAD_TEACHER
: BuiltinRole.ofCode(defaultRoleCode.trim());
if (br == null) {
/**
* 导入时的默认角色。
*
* <p>二期改成查库:可选角色不再只有 6 个内置角色,租户自己建的角色也要能当默认值。
* 返回 {@code SysRole}(内置与自定义同一形状),后面的绑定与用户类型推导都用它。
*/
private SysRole resolveFallbackRole(String defaultRoleCode) {
String code = defaultRoleCode == null || defaultRoleCode.isBlank()
? BuiltinRole.HEAD_TEACHER.getCode()
: defaultRoleCode.trim();
List<SysRole> roles;
try {
roles = resolveAssignableRoles(List.of(code));
} catch (BizException e) {
throw new BizException(ErrorCode.FORBIDDEN, "无权分配角色:" + code);
}
if (roles.isEmpty()) {
throw new BizException(ErrorCode.VALIDATION_BLOCKED, "默认角色不存在:" + defaultRoleCode);
}
if (resolveAssignableRoles(List.of(br.getCode())).isEmpty()) {
throw new BizException(ErrorCode.FORBIDDEN, "无权分配角色:" + br.getName());
}
return br;
return roles.get(0);
}
/**
@@ -821,31 +952,46 @@ public class UserService {
return s.isEmpty() ? null : s;
}
/** 角色列既可能写中文名(「班主任」)也可能写角色码(HEAD_TEACHER),两种都认。 */
private BuiltinRole resolveRoleByText(String text) {
/**
* 角色列既可能写中文名(「班主任」)也可能写角色码({@code HEAD_TEACHER}),两种都认。
*
* <p>二期改成按库里的角色认(内置 + 自定义都在 {@code sys_role} 里),
* 所以"租户自己建的角色"也能在导入文件里按名字写。
* 返回 {@code null} 表示"认不出或不可分配",调用方据此把该行标成错误行。
*/
private SysRole resolveRoleByText(String text) {
String t = text.trim();
BuiltinRole byCode = BuiltinRole.ofCode(t.toUpperCase());
if (byCode != null) {
try {
resolveAssignableRoles(List.of(byCode.getCode()));
return byCode;
} catch (BizException e) {
return null;
for (SysRole role : tenantRoles()) {
if (role.getRoleCode() != null && role.getRoleCode().equalsIgnoreCase(t)) {
return assignableRole(role.getRoleCode());
}
}
for (BuiltinRole br : BuiltinRole.all()) {
if (br.getName().equals(t)) {
try {
resolveAssignableRoles(List.of(br.getCode()));
return br;
} catch (BizException e) {
return null;
}
for (SysRole role : tenantRoles()) {
if (role.getRoleName() != null && role.getRoleName().equals(t)) {
return assignableRole(role.getRoleCode());
}
}
return null;
}
/** 本租户启用中的角色(按内置优先、排序值排)。 */
private List<SysRole> tenantRoles() {
return roleMapper.selectList(Wrappers.<SysRole>lambdaQuery()
.eq(SysRole::getTenantId, UserContext.tenantId())
.eq(SysRole::getStatus, 1)
.orderByAsc(SysRole::getSortNo));
}
/** 这个角色码当前能不能分配;不能就返回 {@code null}(而不是抛异常 —— 导入要逐行报错)。 */
private SysRole assignableRole(String roleCode) {
try {
List<SysRole> roles = resolveAssignableRoles(List.of(roleCode));
return roles.isEmpty() ? null : roles.get(0);
} catch (BizException e) {
return null;
}
}
/** 表头原文,用 | 连接,供界面展示确认。 */
private static String joinHeaderText(Map<Integer, String> headerCells) {
if (headerCells == null || headerCells.isEmpty()) {
@@ -933,6 +1079,17 @@ public class UserService {
}
}
// 角色码 → 角色名(一次查完)。二期自定义角色的名字只在库里,
// 而内置角色的名字库里也有(RoleProvisioner 发放时写的),所以不必再分两路。
Long tenantId = UserContext.tenantId();
Map<String, String> roleNames = new HashMap<>();
for (SysRole r : roleMapper.selectList(Wrappers.<SysRole>lambdaQuery()
.eq(tenantId != null, SysRole::getTenantId, tenantId))) {
if (r.getRoleCode() != null) {
roleNames.put(r.getRoleCode(), r.getRoleName() == null ? r.getRoleCode() : r.getRoleName());
}
}
List<UserVO> list = new ArrayList<>(users.size());
for (SysUser u : users) {
UserVO vo = new UserVO();
@@ -949,13 +1106,11 @@ public class UserService {
List<String> codes = rolesByUser.getOrDefault(u.getId(), List.of());
vo.setRoleCodes(codes);
// 一期角色全部内置(没有自定义角色的界面),名字直接从代码取;
// 二期支持自定义角色后,这里要改成从 sys_role.role_name 读。
// 二期:角色名读库(sys_role.role_name)—— 自定义角色的名字不在代码里。
// 内置角色的行也在库里(开通租户时由 RoleProvisioner 写入),所以一条查询覆盖两种;
// 查不到(角色被删)时回落成角色码,至少不会显示一片空白。
vo.setRoleNames(codes.stream()
.map(c -> {
BuiltinRole br = BuiltinRole.ofCode(c);
return br == null ? c : br.getName();
})
.map(c -> roleNames.getOrDefault(c, c))
.toList());
list.add(vo);
}
@@ -31,6 +31,7 @@ public class AuthInterceptor implements HandlerInterceptor {
private final TokenBlacklist blacklist;
private final PermissionChecker permissionChecker;
private final cn.tianbaotong.fill.support.TenantDomainResolver tenantDomainResolver;
private final cn.tianbaotong.fill.support.ActingTenantResolver actingTenantResolver;
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) {
@@ -52,6 +53,21 @@ public class AuthInterceptor implements HandlerInterceptor {
LoginUser user = jwtService.parse(token);
// ★ 平台运营的「当前学校」(请求头 X-Tenant-Id)。
//
// 平台是唯一跨租户的身份,租户拦截器因此对它关掉了过滤 —— 于是「组织架构」
// 「账号管理」这类租户内的页面会看到**所有租户混在一起**的数据,而且看不出错
// (只有一所学校有数据时,它和学校管理员看到的一模一样)。
// 所以要代管某所学校时必须显式指明:换掉 tenantId 之后 isPlatformAdmin() 变 false
// → 租户过滤自动生效,权限仍按 PLATFORM_ADMIN 那套判。
//
// ⚠️ 必须在下面的一致性校验**之前**:切换后要拿"生效租户"去和域名比,
// 否则在 t2 域名上代管 t1 会被放过去。
Long actingTenant = actingTenantResolver.resolve(request, user);
if (actingTenant != null) {
user = new LoginUser(user.uid(), actingTenant, user.orgIds(), user.roles(), user.userType());
}
// ★ 租户域名与 token 的租户必须一致 —— 这是"用域名区分租户"唯一必须守住的点。
//
// Host 只用来**定位**(这个请求落在谁的站点上),授权一律以 token 为准。
@@ -24,6 +24,15 @@ public enum BuiltinRole {
PermissionDef.ORG_MANAGE, PermissionDef.ORG_VIEW,
PermissionDef.USER_MANAGE, PermissionDef.USER_VIEW,
PermissionDef.NOTIFY_SEND, PermissionDef.PROGRESS_VIEW_ALL,
// ⚠️ TASK_VIEW 是**读**权限(任务列表 / 详情 / 覆盖班级 / 规则模板 / 任务统计),
// PRD §3.2 的「查看填报进度看板」对平台运营是 ✅,而工作台的数据看板
// 要拿任务列表才能选任务 —— 少了它,平台点工作台会拿到 `/tasks` 40301。
//
// 它以前被"平台=超管"这个捷径掩盖着(`PermissionChecker.has` 对平台恒真),
// 直到平台开始**代管某所学校**(`X-Tenant-Id` → 不再是 isPlatformAdmin)
// 才暴露出来:那时只认角色声明的权限,于是任务列表直接 403。
// 只补读权限:TASK_MANAGE / TASK_CLONE 依旧不给(PRD 里平台运营是「—」)。
PermissionDef.TASK_VIEW,
PermissionDef.LOG_VIEW_ALL),
Set.of("dashboard", "tenant", "org", "user", "log")),
@@ -34,15 +43,28 @@ public enum BuiltinRole {
PermissionDef.TASK_MANAGE, PermissionDef.TASK_VIEW, PermissionDef.TASK_CLONE,
PermissionDef.FORM_MANAGE, PermissionDef.MATERIAL_MANAGE, PermissionDef.PLAN_IMPORT,
PermissionDef.ROSTER_MANAGE, PermissionDef.ROSTER_VIEW,
PermissionDef.JOIN_REQUEST_AUDIT,
PermissionDef.NOTIFY_SEND,
PermissionDef.PROGRESS_VIEW_ALL, PermissionDef.PROGRESS_VIEW_OWN,
PermissionDef.AUDIT_FIRST, PermissionDef.AUDIT_FINAL, PermissionDef.AUDIT_WITHDRAW,
PermissionDef.FILE_PROXY_UPLOAD,
PermissionDef.EXPORT_DOWNLOAD, PermissionDef.EXPORT_UNMASKED, PermissionDef.CLEANUP_RUN,
PermissionDef.EXPORT_DOWNLOAD, PermissionDef.EXPORT_UNMASKED,
PermissionDef.LOG_VIEW_TENANT),
Set.of("dashboard", "org", "user", "role", "task", "form", "material",
"roster", "join", "audit", "export", "cleanup", "log")),
Set.of("dashboard", "org", "user", "role", "task",
"audit", "export", "log",
// ⚠️ 顶层菜单里**没有**「名单管理 / 表单设计 / 材料规则」这三条。
// 它们的功能早就做好了,但都是**按任务**的:入口在「收集任务」
// 每一行的链接里(名单管理 / 表单设计 / 材料规则)。
// 以前菜单里挂着三条"功能在别处"的占位项,点进去只有一句指路 ——
// 那既占了导航,又让人以为"这里应该有事可做"。现在直接去掉。
// ⚠️ 这与权限无关:task/form/material/roster 相关权限码照旧保留,
// 只是不再有对应的菜单键。
//
// ⚠️ 这两个菜单键以前**没有任何角色持有** —— 而 PC 端的菜单可见性
// 就是拿这里拼出来的集合过滤的(`buildXtRoutes` 按 menuKeys 过滤),
// 于是「字典管理」与「通知记录」两个页面**做得再好也点不到**:
// 功能在、接口在、路由在,只是菜单里永远不出现(《08》F-106)。
// 这类错没有任何测试会红 —— 所以 `check-wiring.py` 现在会查它。
"dict", "notify")),
/** 班主任:只看自己带的班。 */
HEAD_TEACHER("HEAD_TEACHER", "班主任", UserType.TEACHER, DataScope.ORG_AND_CHILD, 20,
@@ -50,22 +72,7 @@ public enum BuiltinRole {
PermissionDef.ROSTER_MANAGE_OWN, PermissionDef.ROSTER_VIEW,
PermissionDef.NOTIFY_SEND, PermissionDef.PROGRESS_VIEW_OWN,
PermissionDef.AUDIT_FIRST, PermissionDef.FILE_PROXY_UPLOAD),
Set.of("dashboard", "task", "roster", "audit")),
/** 副班主任:协助初审与代传,但不发通知(避免一个班两个人都来催报)。 */
DEPUTY_TEACHER("DEPUTY_TEACHER", "副班主任", UserType.TEACHER, DataScope.ORG_AND_CHILD, 30,
Set.of(PermissionDef.TASK_VIEW,
PermissionDef.ROSTER_VIEW,
PermissionDef.PROGRESS_VIEW_OWN,
PermissionDef.AUDIT_FIRST, PermissionDef.FILE_PROXY_UPLOAD),
Set.of("dashboard", "task", "roster", "audit")),
/** 审核员:专做审核,不给任何管理权限(最小权限原则)。 */
AUDITOR("AUDITOR", "审核员", UserType.SCHOOL_ADMIN, DataScope.TENANT, 40,
Set.of(PermissionDef.TASK_VIEW, PermissionDef.ROSTER_VIEW,
PermissionDef.PROGRESS_VIEW_ALL,
PermissionDef.AUDIT_FIRST, PermissionDef.AUDIT_FINAL),
Set.of("dashboard", "task", "roster", "audit")),
Set.of("dashboard", "task", "audit")),
/** 学生:只能操作本人那一份。 */
STUDENT("STUDENT", "学生", UserType.STUDENT, DataScope.SELF, 90,
@@ -138,7 +145,7 @@ public enum BuiltinRole {
return codes;
}
/** 开通租户时默认创建的 6 个内置角色。 */
/** 开通租户时默认创建的 4 个内置角色。 */
public static Set<BuiltinRole> all() {
return new LinkedHashSet<>(Arrays.asList(values()));
}
@@ -173,4 +180,64 @@ public enum BuiltinRole {
}
return merged;
}
/**
* 这些**内置**角色码在当前租户类型下可以发给账号。
*
* <p>平台运营角色只发平台租户 —— 学校管理员拿到它会直接越权到平台级
* ({@code /tenants} 只校验权限码,不校验"是否真的在平台租户下")。
*
* <p>这条规则有两个使用方:{@code UserService.resolveAssignableRoles}(写时的防线)
* 与 {@code RoleService.list}(读时给界面一个 {@code assignable} 标志)。
* 抽到这里是为了**只有一处**:两处各写一份,界面会显示一个"点了必然 403"的选项。
*/
public static Set<String> assignableCodes(boolean platformTenant) {
Set<String> codes = new LinkedHashSet<>();
for (BuiltinRole r : values()) {
if (!platformTenant && r == PLATFORM_ADMIN) {
continue;
}
if (platformTenant && r != PLATFORM_ADMIN) {
continue;
}
codes.add(r.code);
}
return codes;
}
/**
* 系统现有的全部菜单键(= 所有内置角色声明过的入口之并集)。
*
* <p>用途是**校验自定义角色配的菜单**(二期):菜单键是前后端契约
* (后端这里与前端 {@code XtMenu.key} 逐字一致,由 {@code check-wiring.py} ⑤
* 双向核对),所以"合法取值"就是这份并集 —— 不需要再造一张菜单字典表
* (多一张表就多一处会漂移的地方)。
*/
public static Set<String> allMenuKeys() {
Set<String> keys = new LinkedHashSet<>();
for (BuiltinRole r : values()) {
keys.addAll(r.getMenus());
}
return keys;
}
/**
* **只有平台运营**声明过的菜单键(学校租户不可配置)。
*
* <p>现在只有 {@code tenant}(租户管理)。{@code log}(操作日志)不算 ——
* 学校管理员也有本租户的操作日志({@code log:viewTenant}),只是看的范围不同。
*
* <p>为什么要有这条:把 {@code tenant} 菜单配给学校的自定义角色,左侧会多出一个
* 点进去必然 403 的入口 —— 而用户只会得出"系统坏了"的结论。
* 与权限码的 {@code platformOnlyCodes} 是同一类防线(那里挡的是能力,这里挡的是入口)。
*/
public static Set<String> platformOnlyMenuKeys() {
Set<String> only = new LinkedHashSet<>(PLATFORM_ADMIN.getMenus());
for (BuiltinRole r : values()) {
if (r != PLATFORM_ADMIN) {
only.removeAll(r.getMenus());
}
}
return only;
}
}
@@ -4,21 +4,35 @@ import cn.tianbaotong.fill.common.api.ErrorCode;
import cn.tianbaotong.fill.common.context.LoginUser;
import cn.tianbaotong.fill.common.context.UserContext;
import cn.tianbaotong.fill.common.exception.BizException;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Component;
import java.util.LinkedHashSet;
import java.util.Set;
/**
* 权限判定。
*
* <p><b>为什么不查库</b>:内置角色的权限由代码决定({@link BuiltinRole}),
* 直接从 JWT 里的 {@code roles} 推导即可,零 IO。自定义角色的授权在 DB 里,
* 但一期不开放自定义角色,故暂不需要查询。二期开放自定义角色时,
* 在这里加一层"角色码 → DB 授权"的补充查询即可,调用方无感。
* <p><b>两个来源,各有各的权威</b>:
*
* <ul>
* <li><b>内置角色</b>({@code PLATFORM_ADMIN} / {@code SCHOOL_ADMIN} / …)的权限由代码决定
* ({@link BuiltinRole}),从 JWT 里的 {@code roles} 直接推导,**不查库**。
* 库里 {@code sys_role_permission} 的那些行是"看得见、可审计",不是生效依据 ——
* 否则代码改了权限集而库没同步,就会出现"界面上显示 A、实际生效 B"。</li>
* <li><b>自定义角色</b>(二期)的授权在库里,走 {@link RoleGrantSource} 补一层查询,
* 实现自带缓存(见 {@code DbRoleGrantSource} 的类注释:为什么必须有缓存、
* 为什么空结果也要缓存、Redis 挂了为什么退化成直读库)。</li>
* </ul>
*
* <p>调用方完全无感:{@code @RequirePerm} 与服务层的 {@code require} 一行都不用改。
*/
@Component
@RequiredArgsConstructor
public class PermissionChecker {
private final RoleGrantSource roleGrantSource;
/** 当前用户是否拥有指定权限。 */
public boolean has(PermissionDef perm) {
LoginUser user = UserContext.get();
@@ -29,7 +43,7 @@ public class PermissionChecker {
if (user.isPlatformAdmin()) {
return true;
}
return BuiltinRole.mergePermissions(user.roles()).contains(perm.getCode());
return effective(user).contains(perm.getCode());
}
public boolean hasAny(PermissionDef... perms) {
@@ -63,12 +77,49 @@ public class PermissionChecker {
if (user.isPlatformAdmin()) {
return PermissionDef.allCodes();
}
return BuiltinRole.mergePermissions(user.roles());
return effective(user);
}
/** 当前用户的菜单键集合(用于 {@code GET /auth/me} 与前端菜单渲染)。 */
public Set<String> currentMenus() {
LoginUser user = UserContext.require();
return BuiltinRole.mergeMenus(user.roles());
Set<String> menus = new LinkedHashSet<>(BuiltinRole.mergeMenus(user.roles()));
Long tenantId = user.tenantId();
if (tenantId == null || user.roles() == null) {
return menus;
}
// 与权限同一套口径:内置角色的菜单看代码,自定义角色的菜单看库里的配置
for (String roleCode : user.roles()) {
if (BuiltinRole.ofCode(roleCode) == null) {
menus.addAll(roleGrantSource.menusOf(tenantId, roleCode));
}
}
return menus;
}
/**
* 生效的权限码全集 = 内置角色(按代码)+ 自定义角色(按库里的授权)。
*
* <p>⚠️ 只对"代码里不存在的角色码"查库。反过来写(先查库、查不到再按代码)
* 会让内置角色也被库里的历史授权影响 —— 那条路一旦走通,
* "把 SCHOOL_ADMIN 的某条权限收紧"就要同时改代码与每个租户的库。
*/
private Set<String> effective(LoginUser user) {
Set<String> codes = new LinkedHashSet<>();
if (user.roles() == null || user.roles().isEmpty()) {
return codes;
}
codes.addAll(BuiltinRole.mergePermissions(user.roles()));
Long tenantId = user.tenantId();
if (tenantId == null) {
return codes;
}
for (String roleCode : user.roles()) {
if (BuiltinRole.ofCode(roleCode) == null) {
codes.addAll(roleGrantSource.permsOf(tenantId, roleCode));
}
}
return codes;
}
}
@@ -19,55 +19,57 @@ import java.util.Set;
*
* <p>菜单与权限的关系:一个菜单可能由多个权限共同决定可见性(如「审核」菜单
* 初审和终审权限都能看到),所以菜单单独建模,不做成权限的附属。
*
* <p>{@link #group} 是给「角色与授权」页的权限矩阵分组用的:
* 28 条权限平铺成一张表没人看得懂,而分组**只在展示层有意义**,不影响任何校验 ——
* 所以它是枚举上的一个字符串,而不是一张表。
*/
@Getter
public enum PermissionDef {
// ── 平台运营 ──────────────────────────────────
TENANT_MANAGE("tenant:manage", "租户与套餐管理", Platform.PC),
TENANT_VIEW("tenant:view", "租户查看", Platform.PC),
LOG_VIEW_ALL("log:viewAll", "全量操作日志", Platform.PC),
TENANT_MANAGE("tenant:manage", "租户与套餐管理", Platform.PC, "平台运营"),
TENANT_VIEW("tenant:view", "租户查看", Platform.PC, "平台运营"),
LOG_VIEW_ALL("log:viewAll", "全量操作日志", Platform.PC, "平台运营"),
// ── 组织与账号 ────────────────────────────────
ORG_MANAGE("org:manage", "组织架构管理", Platform.PC),
ORG_VIEW("org:view", "组织架构查看", Platform.PC),
USER_MANAGE("user:manage", "账号管理(含批量导入)", Platform.PC),
USER_VIEW("user:view", "账号查看", Platform.PC),
ROLE_MANAGE("role:manage", "角色与授权", Platform.PC),
LOG_VIEW_TENANT("log:viewTenant", "本租户操作日志", Platform.PC),
ORG_MANAGE("org:manage", "组织架构管理", Platform.PC, "组织与账号"),
ORG_VIEW("org:view", "组织架构查看", Platform.PC, "组织与账号"),
USER_MANAGE("user:manage", "账号管理(含批量导入)", Platform.PC, "组织与账号"),
USER_VIEW("user:view", "账号查看", Platform.PC, "组织与账号"),
ROLE_MANAGE("role:manage", "角色与授权", Platform.PC, "组织与账号"),
LOG_VIEW_TENANT("log:viewTenant", "本租户操作日志", Platform.PC, "组织与账号"),
// ── 收集任务 ──────────────────────────────────
TASK_MANAGE("task:manage", "创建与配置收集任务", Platform.PC),
TASK_VIEW("task:view", "任务查看", Platform.PC),
TASK_CLONE("task:clone", "任务克隆与复用", Platform.PC),
FORM_MANAGE("form:manage", "表单设计", Platform.PC),
MATERIAL_MANAGE("material:manage", "材料规则配置", Platform.PC),
PLAN_IMPORT("plan:import", "评价计划导入", Platform.PC),
TASK_MANAGE("task:manage", "创建与配置收集任务", Platform.PC, "收集任务"),
TASK_VIEW("task:view", "任务查看", Platform.PC, "收集任务"),
TASK_CLONE("task:clone", "任务克隆与复用", Platform.PC, "收集任务"),
FORM_MANAGE("form:manage", "表单设计", Platform.PC, "收集任务"),
MATERIAL_MANAGE("material:manage", "材料规则配置", Platform.PC, "收集任务"),
PLAN_IMPORT("plan:import", "评价计划导入", Platform.PC, "收集任务"),
// ── 名册 ──────────────────────────────────────
ROSTER_MANAGE("roster:manage", "名册导入与修改(全租户)", Platform.PC),
ROSTER_MANAGE_OWN("roster:manageOwn", "名册修改(仅本班)", Platform.PC),
ROSTER_VIEW("roster:view", "名册查看", Platform.PC),
JOIN_REQUEST_AUDIT("join:audit", "名单加入申请审批", Platform.BOTH),
ROSTER_MANAGE("roster:manage", "名册导入与修改(全租户)", Platform.PC, "名册"),
ROSTER_MANAGE_OWN("roster:manageOwn", "名册修改(仅本班)", Platform.PC, "名册"),
ROSTER_VIEW("roster:view", "名册查看", Platform.PC, "名册"),
// ── 通知与进度 ────────────────────────────────
NOTIFY_SEND("notify:send", "催报与发送通知", Platform.BOTH),
PROGRESS_VIEW_ALL("progress:viewAll", "填报进度看板(全租户)", Platform.BOTH),
PROGRESS_VIEW_OWN("progress:viewOwn", "填报进度看板(本班/本人)", Platform.BOTH),
NOTIFY_SEND("notify:send", "催报与发送通知", Platform.BOTH, "通知与进度"),
PROGRESS_VIEW_ALL("progress:viewAll", "填报进度看板(全租户)", Platform.BOTH, "通知与进度"),
PROGRESS_VIEW_OWN("progress:viewOwn", "填报进度看板(本班/本人)", Platform.BOTH, "通知与进度"),
// ── 审核 ──────────────────────────────────────
AUDIT_FIRST("audit:first", "初审(班主任)", Platform.BOTH),
AUDIT_FINAL("audit:final", "终审(管理员)", Platform.PC),
AUDIT_WITHDRAW("audit:withdraw", "撤回审核", Platform.PC),
FILE_PROXY_UPLOAD("file:proxyUpload", "教师代传材料", Platform.BOTH),
AUDIT_FIRST("audit:first", "初审(班主任)", Platform.BOTH, "审核"),
AUDIT_FINAL("audit:final", "终审(管理员)", Platform.PC, "审核"),
AUDIT_WITHDRAW("audit:withdraw", "撤回审核", Platform.PC, "审核"),
FILE_PROXY_UPLOAD("file:proxyUpload", "教师代传材料", Platform.BOTH, "审核"),
// ── 导出 ──────────────────────────────────────
EXPORT_DOWNLOAD("export:download", "汇总导出与下载", Platform.PC),
EXPORT_UNMASKED("export:unmasked", "数据脱敏导出(需二次确认)", Platform.PC),
CLEANUP_RUN("cleanup:run", "历史数据整理器", Platform.PC),
EXPORT_DOWNLOAD("export:download", "汇总导出与下载", Platform.PC, "导出"),
EXPORT_UNMASKED("export:unmasked", "数据脱敏导出(需二次确认)", Platform.PC, "导出"),
// ── 学生端 ────────────────────────────────────
SUBMISSION_OWN("submission:own", "填报、修改与撤回(仅本人)", Platform.MP);
SUBMISSION_OWN("submission:own", "填报、修改与撤回(仅本人)", Platform.MP, "学生端");
/** 端类型。 */
public enum Platform {
@@ -88,10 +90,14 @@ public enum PermissionDef {
/** 适用范围。 */
private final Platform platform;
PermissionDef(String code, String label, Platform platform) {
/** 展示分组(只影响「角色与授权」页的矩阵排版,不参与任何校验)。 */
private final String group;
PermissionDef(String code, String label, Platform platform, String group) {
this.code = code;
this.label = label;
this.platform = platform;
this.group = group;
}
public static PermissionDef ofCode(String code) {
@@ -106,4 +112,23 @@ public enum PermissionDef {
Arrays.stream(values()).forEach(p -> set.add(p.code));
return set;
}
/**
* 学校租户**不可授予**的权限码(平台级能力)。
*
* <p>这三条能让持有者跨租户:{@code tenant:manage} 能开通/改租户,
* {@code log:viewAll} 能读全平台日志,{@code tenant:view} 能列举所有租户。
* 自定义角色的可勾选项里必须把它们排除 —— 界面不显示只是"不提示",
* 服务端这一层才是防线(与 {@code UserService.resolveAssignableRoles} 同一条思路:
* 传了白名单之外的东西要拿到 40301,而不是"界面没这个选项所以不会发生")。
*/
public static Set<String> platformOnlyCodes() {
Set<String> set = new LinkedHashSet<>();
for (PermissionDef p : values()) {
if (p.code.startsWith("tenant:") || p == LOG_VIEW_ALL) {
set.add(p.code);
}
}
return set;
}
}
@@ -0,0 +1,38 @@
package cn.tianbaotong.fill.security;
import java.util.Set;
/**
* 自定义角色的授权来源(「角色与授权」二期)。
*
* <p><b>为什么是接口,而不是让 {@link PermissionChecker} 直接查库</b>:
* 「角色被授了哪些权限码」存在 {@code sys_role_permission}({@code module.user} 的东西),
* 而 {@code security} 这一层被 {@code module} 依赖。让 security 反过来依赖 module,
* 会形成一个方向相反的依赖,并且**迟早成环** —— 角色管理服务自己就要调
* {@code permissionChecker.require(ROLE_MANAGE)}。所以这里只声明"能问什么",
* 实现放在 {@code module.user.service.DbRoleGrantSource}。
*
* <p><b>实现必须只回答自定义角色</b>:内置角色的权限由代码决定({@link BuiltinRole}),
* 对内置角色码或不存在的角色码一律返回空集 —— 否则库里的历史授权会盖住代码,
* 而那正是"界面上显示一套、实际生效另一套"的来源。
*/
public interface RoleGrantSource {
/**
* 这个角色码被授予的权限码。
*
* @param tenantId 角色所属租户(角色是租户隔离的,必须显式带上)
* @param roleCode 角色码
* @return 权限码集合;内置角色、不存在的角色、已停用的角色一律返回空集
*/
Set<String> permsOf(long tenantId, String roleCode);
/**
* 这个角色码可见的菜单键。
*
* <p>与 {@link #permsOf} 同一套口径(只回答自定义角色、停用即空集)。
* 「看得见」与「有权限」是两件事:菜单只影响前端显示哪些入口,
* 真正的访问控制在权限码校验上(绕过前端只会拿到 40301)。
*/
Set<String> menusOf(long tenantId, String roleCode);
}
@@ -0,0 +1,88 @@
package cn.tianbaotong.fill.support;
import cn.tianbaotong.fill.common.api.ErrorCode;
import cn.tianbaotong.fill.common.context.LoginUser;
import cn.tianbaotong.fill.common.exception.BizException;
import cn.tianbaotong.fill.module.tenant.entity.SysTenant;
import cn.tianbaotong.fill.module.tenant.mapper.TenantMapper;
import jakarta.servlet.http.HttpServletRequest;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
/**
* 平台运营的「当前学校」(请求头 {@code X-Tenant-Id})。
*
* <h3>它解决什么</h3>
*
* <p>平台运营({@code tenantId = 0})是唯一跨租户的身份,而租户拦截器对它
* <b>整个关掉了租户过滤</b>(见 {@code MybatisPlusConfig.FillTenantLineHandler})。
* 于是「组织架构」「账号管理」这类<b>本来就是租户内</b>的页面,在平台账号下看到的是
* <b>所有租户混在一起</b>的数据 —— 而这<b>看不出错</b>:当只有一所学校有数据时,
* 它和学校管理员看到的一模一样。
*
* <p>所以平台要操作某所学校的组织/账号时,必须<b>显式指明是哪一所</b>:
* 请求头带上目标租户 id,本类把它解析出来,{@code AuthInterceptor} 用它把
* {@link LoginUser} 的 tenantId 换成目标租户 —— 之后租户过滤、上下文写入、
* 权限判定全部按"我就是那所学校"走,<b>不需要每个接口各加一个参数</b>。
*
* <h3>四条约束(安全相关,不要松)</h3>
*
* <ol>
* <li><b>只认平台身份</b>:非平台用户带这个头一律忽略 —— 否则改一个数字就能越租户,
* 那正是本系统最不能出的一类漏洞;</li>
* <li>目标租户必须<b>存在且启用</b>,否则 {@code 40001},且不区分"不存在"与"被停用";</li>
* <li>换租户之后 {@code isPlatformAdmin()} 变成 false → 租户过滤自动生效。
* 这是<b>关键机制</b>(不是副作用):无需在别处再加一个"是否已切换"的判断;</li>
* <li><b>不带这个头时行为完全不变</b> —— 平台照旧可以跨租户看租户列表、全量日志。</li>
* </ol>
*
* <p>与 {@link TenantDomainResolver} 的分工:那个是"这条请求落在谁的域名上"(定位,匿名可用),
* 这个是"平台此刻要代管哪所学校"(显式选择,仅平台可用)。两者都会改变租户上下文,
* 所以都在 {@code AuthInterceptor} 里、<b>与 token 的租户一致性校验一起</b>完成。
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class ActingTenantResolver {
/** 平台运营指定"当前学校"的请求头。 */
public static final String HEADER = "X-Tenant-Id";
private final TenantMapper tenantMapper;
/**
* 解析本次请求的目标租户。
*
* @return 目标租户 id;<b>不是平台身份 / 没带头 / 头就是平台自身</b> 时返回 {@code null}
* (表示"不切换",按原有语义走)
*/
public Long resolve(HttpServletRequest req, LoginUser user) {
if (user == null || !user.isPlatformAdmin()) {
// 非平台身份带头一律忽略:这一条是安全边界,不是"顺手支持一下"
return null;
}
String raw = req == null ? null : req.getHeader(HEADER);
if (raw == null || raw.isBlank()) {
return null;
}
long target;
try {
target = Long.parseLong(raw.trim());
} catch (NumberFormatException e) {
throw new BizException(ErrorCode.VALIDATION_BLOCKED,
HEADER + " 不是合法的租户号:" + raw);
}
if (target == LoginUser.PLATFORM_TENANT_ID) {
// 指着平台自己 = 不切换(而不是报错:前端清空选择时可能发 0)
return null;
}
SysTenant t = tenantMapper.selectById(target);
if (t == null || t.getStatus() == null || t.getStatus() != 1) {
// 两种原因给同一句话:不额外透露"这个租户存在但被停用了"
throw new BizException(ErrorCode.VALIDATION_BLOCKED, "目标单位不存在或已停用");
}
log.info("平台运营切换租户:uid={} → tenantId={}({})", user.uid(), target, t.getTenantName());
return target;
}
}
@@ -1,11 +1,10 @@
package cn.tianbaotong.fill.support;
import cn.tianbaotong.fill.module.user.entity.SysRole;
import cn.tianbaotong.fill.module.user.entity.SysRolePermission;
import cn.tianbaotong.fill.module.user.entity.SysUserRole;
import cn.tianbaotong.fill.module.user.mapper.RoleMapper;
import cn.tianbaotong.fill.module.user.mapper.RolePermissionMapper;
import cn.tianbaotong.fill.module.user.mapper.UserRoleMapper;
import cn.tianbaotong.fill.module.user.service.RoleGrantWriter;
import cn.tianbaotong.fill.security.BuiltinRole;
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
import lombok.RequiredArgsConstructor;
@@ -28,10 +27,20 @@ import java.util.Set;
public class RoleProvisioner {
private final RoleMapper roleMapper;
private final RolePermissionMapper rolePermissionMapper;
private final UserRoleMapper userRoleMapper;
private final RoleGrantWriter grantWriter;
/** 幂等:已存在的角色跳过。 */
/**
* 幂等:角色已存在就补齐定义,并把**授权对齐到代码**。
*
* <p>⚠️ 第一版是「已存在就 {@code continue}」,看起来更省事,但结果是:
* 代码里改了某条权限之后,**已经开通的租户永远停在发放那一刻** ——
* 库里的授权与代码不一致,而且没有任何地方会报错。
* 所以现在即使角色已存在,也要走一遍 {@link #syncGrants}(值一样时一行都不写)。
*
* <p>内置角色的权限**永远以代码为准**(见 {@code PermissionChecker}):
* 这里的同步是为了"库里看到的 = 实际生效的",让人查库时不会被误导。
*/
public void provisionBuiltinRoles(long tenantId) {
// ⚠️ 平台运营角色只发给平台租户(tenant_id=0)。
// 以前这里是「所有租户都发全部内置角色」,看起来更"整齐",
@@ -47,30 +56,57 @@ public class RoleProvisioner {
if (!platformTenant && br == BuiltinRole.PLATFORM_ADMIN) {
continue;
}
if (findRole(tenantId, br.getCode()) != null) {
continue;
}
SysRole role = new SysRole();
role.setTenantId(tenantId);
role.setRoleCode(br.getCode());
role.setRoleName(br.getName());
role.setRoleType(1);
role.setDataScope(br.getDataScope().code());
role.setSortNo(br.getSortNo());
role.setStatus(1);
role.setRemark("内置角色,权限由代码决定,不可修改");
roleMapper.insert(role);
for (String permCode : br.permissionCodes()) {
SysRolePermission rp = new SysRolePermission();
rp.setTenantId(tenantId);
rp.setRoleId(role.getId());
rp.setPermCode(permCode);
rolePermissionMapper.insert(rp);
SysRole role = findRole(tenantId, br.getCode());
if (role == null) {
role = new SysRole();
role.setTenantId(tenantId);
role.setRoleCode(br.getCode());
role.setRoleName(br.getName());
role.setRoleType(1);
role.setDataScope(br.getDataScope().code());
role.setSortNo(br.getSortNo());
role.setUserType(br.getUserType().code());
role.setStatus(1);
role.setRemark("内置角色,权限由代码决定,不可修改");
roleMapper.insert(role);
} else if (alignToCode(role, br)) {
roleMapper.updateById(role);
}
grantWriter.replacePerms(tenantId, role.getId(), br.permissionCodes());
grantWriter.replaceMenus(tenantId, role.getId(), br.getMenus());
}
}
/**
* 把库里那一行按代码对齐(名字 / 数据范围 / 排序 / 用户类型),**有变化才写库**。
*
* <p>为什么不连 {@code status} 一起刷:内置角色在界面上不可停用,
* 但万一有人用 SQL 停用了它,每天定时任务把它悄悄打开会更糟 ——
* 那等于"系统自己改了别人的决定"。所以状态不在同步范围内。
*
* @return 是否需要 updateById
*/
private boolean alignToCode(SysRole role, BuiltinRole br) {
boolean dirty = false;
if (!java.util.Objects.equals(role.getRoleName(), br.getName())) {
role.setRoleName(br.getName());
dirty = true;
}
if (!java.util.Objects.equals(role.getDataScope(), br.getDataScope().code())) {
role.setDataScope(br.getDataScope().code());
dirty = true;
}
if (!java.util.Objects.equals(role.getSortNo(), br.getSortNo())) {
role.setSortNo(br.getSortNo());
dirty = true;
}
if (!java.util.Objects.equals(role.getUserType(), br.getUserType().code())) {
role.setUserType(br.getUserType().code());
dirty = true;
}
return dirty;
}
public SysRole findRole(long tenantId, String roleCode) {
return roleMapper.selectOne(Wrappers.<SysRole>lambdaQuery()
.eq(SysRole::getTenantId, tenantId)
@@ -84,6 +120,26 @@ public class RoleProvisioner {
if (role == null) {
throw new IllegalStateException("内置角色未创建:" + br.getCode());
}
bind(tenantId, userId, role);
}
/**
* 按角色码绑定(内置与自定义都走这里)。
*
* <p>二期新增:账号管理页可选的角色不再只有 6 个内置角色,所以绑定不能再只认
* {@link BuiltinRole} —— 传角色码、按租户查行、绑行 id,对两种角色是同一条路径。
* 「这个角色能不能发给这个租户」由调用方({@code UserService.resolveAssignableRoles})判定,
* 这里只负责"查到就绑"。
*/
public void bindRoleByCode(long tenantId, long userId, String roleCode) {
SysRole role = findRole(tenantId, roleCode);
if (role == null) {
throw new IllegalStateException("角色未创建:" + roleCode);
}
bind(tenantId, userId, role);
}
private void bind(long tenantId, long userId, SysRole role) {
SysUserRole ur = new SysUserRole();
ur.setTenantId(tenantId);
ur.setUserId(userId);
@@ -19,16 +19,15 @@ spring:
flyway:
# dev 允许 clean 后重建,方便反复试 DDL
clean-disabled: false
web:
resources:
# H5 静态文件位置。默认指向 uni-app 的 H5 构建产物;
# 部署时用环境变量 H5_STATIC_DIR 指到实际目录即可,
# **不需要把构建产物提交进仓库**(那是产物,不是源码)。
# 开发前先在 frontend-mp 里跑一次 `pnpm build:h5`。
#
# ⚠️ 它的前缀是 spring.(不是 fill.)——写错前缀的表现是"页面 404
# 而应用照常启动、没有任何报错",很难一眼看出来。
static-locations: ${H5_STATIC_DIR:file:../frontend-mp/dist/build/h5/},classpath:/static/
# ⚠️ 这里**没有** spring.web.resources.static-locations(2026-10 架构调整):
# H5 不再由后端托管,改由网关 **nginx** 提供(deploy/nginx/xt.conf + xt-nginx 容器,
# 见 docs/09-部署与运维.md §2.1)。后端是**纯 API 服务**。
# 静态资源的缓存头也跟着搬到了 nginx —— 静态文件的事由静态服务器负责。
#
# 历史(留着是有用的):它曾经是 `file:../frontend-mp/dist/build/h5/`,
# 也就是"后端顺便把 H5 发了"。那是为了**与接口同域**(微信网页授权只给一个回调域名、
# 同域省掉 CORS)。这个目标现在由 nginx 在同一台机器的 80 端口上实现:
# H5 与 /api 仍然同源,前端与微信后台的配置都不用动。
mybatis-plus:
configuration:
@@ -51,6 +50,16 @@ fill:
# PKCS#8 私钥(单行 Base64,无 PEM 头尾)
private-key: MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQCeHytLx+201AZKhds9B+3LWoVYDJisyimtysGpGip6JLT3iiB2tpK2QL4OAopI/8gaY1iyoxmxU5fUEWa+mPspszciNot4bAdDRZMiCkGOj0zEKJRrTDctnvLVyB6lSd89FX8yD/LS131Big57u9Sk5BYCMO9ZhgCrN1p1qbf/FoJ16zZ3NWSLtGdqVUx2BQjnSRp4Rzf8fq/49MWSXnBmsVm543B85mZMARp24rZ5SwpMwhtrAfVihwqkfaWAfu51oiIwjf0Q8s/wsfth60WoWipG56onwuPtSECKW/Weg5SUQiwoj95iWae+yQFGXRy7hkjk3FhmQArhoK3jiJbrAgMBAAECggEAA3AoD9jECWeGz34Dm1sdx4srVRPJChDHQTkkZtnjzI4ZxTwiopIrtHzJ5SIXAalWH4TDe/bh6yInLfxG4KQQ6gI0k7Dd7Xtj2QmdPmM8s63X/Hfpaj/Y/0vyecWFLRDjIr/h6SPBouX+mjs3P2eBIyi2dcu8mJS6VP66H87V6lz5LyPraFC3ocXTMCabJh+i/Xy4EqV26Mhv+kSrE0CLB2ngfQW98+RgPo5Ndk/A826eGVA16tKsylcTfq3SB/yMSy1xHv/p/IF+PK+Twl3GmJB2jf/dzZpteJIZrPRz+AJ9VJQa35/Chre57JFcA50Avkf5iySzzVfYZDXd0qk6GQKBgQDMD3opopECI5lo21p9+KUZ6poW8iO/Yp2FNYBldMnk+oHcCL6H92dGIZNu0mvcumjEstKH4X9OoI5qV2FFMUrFc6E7NfXWNKOj+eJtLn3WzNRJp6n4x8W8AEeAXzHuv8IbSlG8nsG/g49Tdnaxr2y/iWNj1qGnTfaSyhGRRh133wKBgQDGXla5EmtD9TpCpl7/OSWG8qzv4GfRTaVjC+qz2KnfU5cqiFKLK27uxkP9F8Kdh6EkvOltRA8024FeafpLgTjH3fEKHFywwXSVvssp94IZdoyuxrAqMccReE7akS6IImawm8fu9U0bAwi7WShZHaw2hS9ScM/9cATkDRfRm0DydQKBgHtcQ+rVlzom0UAdk2giH5U6SSrj+Q5jDH0SKeurnb3qXwTo2F4UB3/THnDZjlFi86Etxa5VeQPMdiv9ZRVSRjR1wZus9FAphF/F2cdNWs+3BgL88/kss/ICbTdC1UnS7QJYfG89vj0LyGKZdAnY1jI4WThq8VDWBeEZfWNIctobAoGBAJMoZRmCqoSNpZzQI2UfFdHYsaZ6SUoiWfDyZDjrIIoGTvxEiUeo/vCvsLxPJhcLCOZiJqDifo1t0bki0YW15VOQOEKR68lMmpqEId+ON7uAELmPuBx1NQKmx4ipb2Wab6fQyS9iVsMVKyMg7ye9NYZ0ozh7bDDNKzOq6uKaVEIJAoGAbulymgadkLNVksxPW0O0qIlLVfaOzOeDLGi6ZO4ZBt9wyKgbyMcfwjvuylerkLF/zZqLQ79sOd4JO15ssqHK/YVKiON/A7pDDgxAJpOXxGxy9RXpZWhHL1KBjI9Gcq0gyMSnQbiwpB1Wx3qmjNGDynclW1kNFCQKQpW9xTs4NI0=
# ── 跨域白名单(本地开发)────────────────────────────────────────────
#
# ⚠️ 见 application.yml 里的完整说明:**同源的 POST 也会被 CORS 校验**,
# 所以"H5 由 nginx 托管、页面与接口同源"还不够 ——
# 浏览器给的 Origin 是**页面地址**(穿透域名 / localhost),必须列在这里,
# 否则 H5 的登录、上传、提交会在浏览器里 403(而后端日志里一个字都没有)。
# 这份名单对应三种打开方式:pnpm dev:h5(5174) / xt-nginx(80) / 花生壳穿透。
cors:
allowed-origins: "${XT_CORS_ALLOWED_ORIGINS:http://localhost:*,http://127.0.0.1:*,http://192.168.*.*:*,http://394nb68658le.vicp.fun}"
# ── 材料文件存储(原图 + 压缩改名后的规范件)──────────────────────────
#
# ⚠️ provider=local 只适合本地开发:**多副本部署下必然丢文件** ——
@@ -130,8 +139,11 @@ fill:
# 而那个域名要能解析到这台机器(本地开发用内网穿透)。
callback-url: ${WX_H5_CALLBACK:http://394nb68658le.vicp.fun/api/v1/mp/h5/callback}
# H5 应用地址:授权成功后 302 回这里。
# 与接口**同域**(由本服务直接托管 H5 静态文件,见下面的 static-locations)——
# 与接口**同域**(H5 由 nginx 托管、/api 由同一个 nginx 反代到 8080,见 deploy/nginx/xt.conf)——
# 这样既省掉 CORS,也和正式部署的形态一致。
#
# ⚠️ 内网穿透要指到 **nginx 的 80 端口**,不是后端的 8080:
# 后端现在只提供 /api,穿透指到 8080 的表现是"接口好用、页面 404"。
h5-base-url: ${WX_H5_BASE_URL:http://394nb68658le.vicp.fun}
# ⚠️⚠️ 跳过微信授权直接登录的开关。**生产必须为 false**。
# 开了它就是"任何人可登录任何学生"的后门。
@@ -1,45 +1,67 @@
# ═══════════════════════════════════════════════════════════
# 生产环境(prod)
#
# ⚠️⚠️ 这个文件里**一个密钥都没有,也永远不要加**。
# 全部由环境变量提供;缺失时 **ProdGuard 会拒绝启动**
# (见 config/ProdGuard.java —— 那 6 条都是"配错了也能启动、
# 而且运行时完全不报错"的配置)。
# ⚠️⚠️ 本文件按部署要求**直接写入了**数据库、Redis、七牛与
# 应用密钥(JWT / AES / HMAC / RSA)—— 它属于**敏感文件**:
# · 仓库可见范围必须收紧(能读它 = 能伪造 token、能解证件号)
# · 不要发到公开渠道,也不要复制到别处
# · 轮换密钥时优先用同名环境变量覆盖(环境变量优先级高于本文件)
#
# ── 必需的环境变量 ──────────────────────────────────────
# 数据库 PG_HOST PG_PORT PG_DB PG_USER PG_PASSWORD
# 缓存 REDIS_HOST REDIS_PORT REDIS_PASSWORD
# 密钥 XT_JWT_SECRET HS512,≥ 64 字节随机值
# XT_AES_KEY AES-256,base64,**用于证件号加密**
# XT_HMAC_KEY HMAC,用于开放接口签名
# XT_RSA_PRIVATE_KEY PKCS#8 单行 base64(登录密码解密)
# 存储 XT_STORAGE_PROVIDER=qiniu
# QINIU_ACCESS_KEY QINIU_SECRET_KEY QINIU_BUCKET QINIU_DOMAIN
# 微信 WX_MP_APP_ID WX_MP_APP_SECRET (小程序)
# WX_H5_APP_ID WX_H5_APP_SECRET (公众号网页授权)
# WX_H5_CALLBACK WX_H5_BASE_URL (必须是 https 正式域名)
# 其他 H5_STATIC_DIR(H5 构建产物目录)
# ── 仍然建议用环境变量提供的项 ─────────────────────────
# 微信小程序 WX_MP_APP_ID WX_MP_APP_SECRET WX_MP_PUBLIC_BASE_URL
# WX_MP_CALLBACK_TOKEN
# 其他 XT_LOG_DIR XT_UPLOAD_DIR XT_SEED_ENABLED
# XT_TENANT_DOMAIN_ENABLED XT_TENANT_DOMAIN_BASE
# XT_CORS_ALLOWED_ORIGINS(**换域名/加域名时必看**:浏览器同源 POST 也走 CORS,
# 漏了本站地址 = 登录 403,见 §一·四十四)
#
# ⚠️ 学生端**只发布微信小程序,不部署 H5**:
# 本文件没有 fill.mp-h5 段。后端 /api/v1/mp/h5/** 接口仍在,
# 未配置时会明确报错,但生产不提供 H5 页面与公众号网页授权。
#
# ⚠️ 密钥怎么生成、怎么轮换、轮换时要注意什么,见
# docs/09-部署与运维.md §3「密钥与凭据」。
# **XT_AES_KEY 一旦加密过证件号就不能直接换**(旧密文解不开且不报错)。
#
# ⚠️ 生产的工作目录里不要放 application-local.yml:
# 基础配置里有 spring.profiles.include: local,它会被加载并可能覆盖本文件。
# ═══════════════════════════════════════════════════════════
spring:
datasource:
url: jdbc:postgresql://${PG_HOST}:${PG_PORT:5432}/${PG_DB:filldb}?stringtype=unspecified
username: ${PG_USER}
password: ${PG_PASSWORD}
url: jdbc:postgresql://1Panel-postgresql-v7DP:5432/shenxue?stringtype=unspecified
username: shenxue
password: RC6Bhnmbz8mk4p4z
driver-class-name: org.postgresql.Driver
hikari:
pool-name: xt-hikari-prod
# 生产把池子放大一些:导出打包是长事务,会占住连接
minimum-idle: 10
maximum-pool-size: 40
connection-timeout: 10000
idle-timeout: 600000
max-lifetime: 1700000
# 慢 SQL / 连接未归还从应用侧记一份,方便和 PG 的
# log_min_duration_statement 对照
leak-detection-threshold: 20000
data:
redis:
host: ${REDIS_HOST}
port: ${REDIS_PORT:6379}
password: ${REDIS_PASSWORD}
host: 1Panel-redis-mIkk
port: 6379
password: redis_GWnh6E
database: 0
timeout: 3s
lettuce:
pool:
max-active: 32
max-idle: 16
min-idle: 4
max-wait: 3s
flyway:
enabled: true
locations: classpath:db/migration
baseline-on-migrate: false
table: flyway_schema_history
# ⚠️⚠️ 生产**禁止 clean**。它是"清空整个库再重建",
# 在生产上执行等于把所有学校的填报数据删掉。
# 流水线里也不要用 flyway clean —— 见部署文档的回滚一节。
@@ -47,101 +69,206 @@ spring:
# 迁移文件发布后不许改(改了校验失败)。这是保护:它保证
# "生产跑的迁移"与"仓库里的迁移"逐字节一致
validate-on-migrate: true
jackson:
time-zone: Asia/Shanghai
default-property-inclusion: always
deserialization:
fail-on-unknown-properties: false
lifecycle:
# 配合 server.shutdown=graceful:等在途请求处理完再退出
timeout-per-shutdown-phase: 30s
servlet:
multipart:
max-file-size: 50MB
max-request-size: 60MB
web:
resources:
# H5 产物由部署流程放到这个目录(**不进仓库**)
static-locations: ${H5_STATIC_DIR:file:/opt/xt/h5/},classpath:/static/
# ⚠️ 后端是**纯 API 服务**:PC 管理端与学生端(微信小程序)都不由后端托管,
# 页面由 nginx/网关/微信客户端提供(见 application.yml 的 add-mappings 注释)。
# 发布时仍然要"和后端一起发" —— 前后端不同步会出现"页面调用了还不存在的接口"。
add-mappings: false
server:
port: 8080
# 优雅停机。没有它,spring.lifecycle.timeout-per-shutdown-phase 不生效
shutdown: graceful
# 生产在反向代理后面:让 Spring 认 X-Forwarded-* ,
# 否则生成的回调地址会是 http://内网IP:8080
forward-headers-strategy: framework
compression:
enabled: true
mime-types: application/json,text/html,text/css,application/javascript
min-response-size: 2048
error:
# 不要把异常栈与内部错误信息回给调用方
include-stacktrace: never
include-message: never
tomcat:
# 上传是长连接,生产放宽(学生可能从聊天记录里选一个 40MB 的文件)
connection-timeout: 120s
max-swallow-size: 100MB
threads:
max: 200
min-spare: 20
mybatis-plus:
configuration:
map-underscore-to-camel-case: true
# 生产环境关掉 SQL 日志
log-impl: org.apache.ibatis.logging.nologging.NoLoggingImpl
global-config:
banner: false
db-config:
# 软删除字段名,客户明确要求就叫 deleted
logic-delete-field: deleted
logic-delete-value: 1
logic-not-delete-value: 0
id-type: auto
logging:
level:
root: warn
cn.tianbaotong.fill: info
# 迁移记录值得留在日志里(root=warn 会把它压掉)
org.flywaydb: info
file:
# ⚠️ 日志按天滚动并保留 30 天:审计要能回答"上周三谁看过那份证件号"
name: ${XT_LOG_DIR:/var/log/xt}/xt-server.log
name: "${XT_LOG_DIR:/var/log/xt}/xt-server.log"
logback:
rollingpolicy:
max-history: 30
max-file-size: 200MB
total-size-cap: 20GB
management:
endpoints:
web:
exposure:
# 生产只暴露健康检查(给负载均衡探活:/actuator/health)
include: health
endpoint:
health:
show-details: never
# 同时提供 /actuator/health/liveness 与 /actuator/health/readiness
probes:
enabled: true
health:
# 探活会实际探测 PG 与 Redis(依赖挂了不能还报 UP)
db:
enabled: true
redis:
enabled: true
# 生产关掉接口文档:它会把全部接口与字段暴露给公网。
# ProdGuard 会检查这一条 —— 开着就拒绝启动。
# ProdEnvCheck 会检查这一条 —— 开着就拒绝启动。
springdoc:
api-docs:
enabled: false
swagger-ui:
enabled: false
management:
endpoints:
web:
exposure:
# 生产只暴露健康检查(给负载均衡探活)
include: health
endpoint:
health:
show-details: never
# ── 本项目业务配置 ────────────────────────────
fill:
jwt:
secret: ${XT_JWT_SECRET}
issuer: tianbaotong-fill
# HS512 签名密钥(≥ 64 字节随机值)。环境变量优先,便于轮换
secret: "${XT_JWT_SECRET:2gtJ3sKcegiZj8SbFuLOPRA8jF6tOwLwV16QzDtLZfaLIKEq0ZHEdneJjg8EDHUJ+CH7coufso0+2qD8dkxoKQ==}"
# PC 8 小时 / 小程序 7 天(《03-接口设计》§1.2)
pc-expire-seconds: 28800
mp-expire-seconds: 604800
crypto:
aes-key: ${XT_AES_KEY}
hmac-key: ${XT_HMAC_KEY}
# AES-256(base64,32 字节),**用于证件号加密**
aes-key: "${XT_AES_KEY:N645XQtx+BDMlXTGe8YkJxUvesj9BYUPNQXLjQUiC/Q=}"
# HMAC,用于开放接口签名与证件号去重 hash
hmac-key: "${XT_HMAC_KEY:1eTpdihb/nZOh2PCpTE5o9SrLr9oGS7EqtbUM/3DNFe98CHxauViRL6beXQB8xci}"
rsa:
private-key: ${XT_RSA_PRIVATE_KEY}
# PKCS#8 私钥(单行 Base64,无 PEM 头尾),用于登录密码解密。
# 公钥由 GET /api/v1/auth/public-key 动态下发,轮换后前端无需重新构建。
private-key: "${XT_RSA_PRIVATE_KEY:MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQDnWFBpGQU+ZaSBBe8XWSbSgwlOn5fOr+LH2I4lHtgoOWECKZLL8Ltf/cdRL0cc7ZkMEyE2xvidW+G1ADEGKyy/5LIWZ/+37KfM25JHPhE3v3+K9j05mPG5Yq7Qwdaf9OKkwmL/qc7fPUVyzw/uNL8+oU7JP6Vw1weIxXLjGUHwmTcwGjEXAMiu39xnhxXtUa7oywa2XnGAFZm+bt/YoLFbI04SQ9/o5oFnr7KAUBPW3V8kVejuMMQK/qCy06xhzDBJUw+jYwM3Tu9otlPykZXiVrDC7psImoaFIbvOMRh1PILOa70eHgr86++ZKyyiGqFl5bQ/5jURKfKxHrw7BagbAgMBAAECggEAGj0viEQQuVuGQCYKpf90F530y8iZIMBdPI/Iy4Kx6e9cG9vYQlj6/5pakCWIb3UEDf+2HYQ4Gtea2XsnPvey+yQhY3OHPaFSBOUF3x5gjeNNQCxz6Zj5wwOOKATxkVyRP4ur9oBtg29ryuebpcewxes+HYr3pOfr+wIeE44//DLv/PfGfSnkb4n+eILrm6H4qWfTbZnBwUv1P6F4YJvknuVytkCLtRiZRcmZ/BXD1Uldcb9zQbeGylvjqPTO1wo1Rdq8IhC9ggfJIDMznRb7CMkEN5nE1W8VZgk5adJBoHGXFukQyq7ne5p3HODPrySn5TYBoyH2lo4bDpmIkLYcAQKBgQD4t/gwRxDiWV4R06Vc3kdQ5/C0A5221m19b5d2kGld+W48URbZsZbkGjYQ5eQ+5hDzUnP29kToQUzWjJFP89G1xqep93OzQdVMmTtKakuA+3T+LudvzNmwB/nT+4StwdXQdgImCqkQfVHmBVcmv0pyJP+yHCIT+X3vv33YxNF8AQKBgQDuHiMczAswsV4E4QmPdXgA10udOr1OrGv582adDHzIpAUExPvv1iUBQ3r1lcTYbEzUUbGl+d5xnkmxgc1ulzC0lgNgCrIcCWIJ/nCo8L09lvA4Q/R239i70QldU2Hd8UEL7hzyqr8DeEjyOIwHfqkpl7nGbVI+ukfSLNbv0T2UGwKBgQDssh+WyjruPkBA1Ns9SygxfdnbZsmT8/LU+fr50asEzNi0vDLzz4ca4zrqz+1sUoR1G9XShEHFikd2suWjYhUEWd676QuJ36//KqB7TDc4s5V+ROLpyditJpoBvA5jUeGKdpPK4kBMugbT0edDr65IfJ9+QDnKKphILkO0pHH8AQKBgAqLPxbJWj1/vSAOLvQxSW0QG4MM1NElBWr+Ai82HFhuGuLJLjbCjsTtjgZN6XeD5CsKqwkkdVsmnTDFBKm+/CMi+WdJUfW33KWApNB2SIrvohd7utmAtAItEXMWfCDty+mRU6rTML4gl2pSYrD6RNdYpptIv/mTGhcOcT4RT969AoGBAIZ6boFBwyt3zRD71SMHonujfU3ldeodAtNrpXovAHs3iwNpOUvjYSOAcUSTKB+FcKcR/xk5L9PlskmumNP/DdUXgPyPocjITdSwPT5JCXffgw3p50o+gxvw52EkPPHO9M9gXFZRCE04KF0ToQoYkX267ct/erlddBrbzsVJLCHZ}"
security:
# 登录失败 5 次锁 15 分钟;连续失败 2 次后要求图形验证码(§1.6)
login-max-fail: 5
login-lock-minutes: 15
captcha-after-fails: 2
# ── 跨域白名单(**同源也要配**,见 application.yml 里的完整说明)────────
#
# ⚠️ 浏览器的同源 POST/PUT/DELETE 也会带 Origin,而 Spring MVC 只看"有没有 Origin":
# 不在白名单里 → 直接 403「Invalid CORS request」→ **登录不上**。
# PC 端是 https://admin.shenxue.jxit.com,接口是同一个域名下的 /api,
# 所以这个地址**必须在**名单里(2026-10-20 生产事故的根因)。
#
# 三个来源分别是:主域名(统一入口)/ PC 管理端 / 所有子域(将来的 t1、t2 租户站点)。
# 换域名或加穿透域名时:改这里,或给 XT_CORS_ALLOWED_ORIGINS(逗号分隔)。
cors:
allowed-origins: "${XT_CORS_ALLOWED_ORIGINS:https://shenxue.jxit.com,https://admin.shenxue.jxit.com,https://*.shenxue.jxit.com}"
upload:
# 单文件上限 50MB(PRD §10.1)
max-file-size-mb: 50
# 本地落盘目录。七牛只存学生材料;导出包等临时文件仍然写本地盘,
# 所以这个目录在生产上必须可写、且不要放在会被清理的 /tmp 下。
dir: "${XT_UPLOAD_DIR:./data/uploads}"
storage:
# 生产必须 qiniu(local 在多副本下必然丢文件,ProdGuard 会拦)
# 生产必须 qiniu(local 在多副本下必然丢文件,ProdEnvCheck 会拦)
provider: qiniu
prefix: xiaotutongxue
keep-raw: ${XT_STORAGE_KEEP_RAW:false}
self-check-on-startup: ${XT_STORAGE_SELF_CHECK:true}
# bucket 内的目录前缀 —— 本项目的文件都放在 shenxue/ 下
prefix: shenxue
# 默认只保留压缩改名后的规范件(客户确认过,不需要原图当凭证)
keep-raw: "${XT_STORAGE_KEEP_RAW:false}"
# 启动时做一次真实的 写入 → 存在 → 读回 → 删除 自检。
# 换存储 / 换密钥 / 换空间之后值得打开:它能在启动时就把
# "密钥错/空间名错/没有写权限"暴露出来,而不是等第一个学生上传时才失败。
self-check-on-startup: "${XT_STORAGE_SELF_CHECK:true}"
qiniu:
access-key: ${QINIU_ACCESS_KEY}
secret-key: ${QINIU_SECRET_KEY}
bucket: ${QINIU_BUCKET}
domain: ${QINIU_DOMAIN}
region: ${QINIU_REGION:auto}
access-key: "t1PIPGcvBY9lJVXFZFb48maTQsGGhvLsR5QQlNq0"
secret-key: "KGooFdF5eCLdCIMCOD6x5ofMzu4vYE17T5Mvp9qC"
bucket: "file-jxit-private"
domain: "https://file.jxit.com"
# auto = 让 SDK 自己查空间在哪个区(推荐)。
# 「华东-浙江」在新旧控制台对应 z0 与 cn-east-2 两个 id,写错了的症状是上传超时。
region: "${QINIU_REGION:auto}"
# ⚠️ 空间是**私有**的 → 下载走签名链接(带有效期)。
# 因此 URL 不能入库、不能发出去让人明天再打开,每次要用现取。
signed-url-expire-seconds: 1800
# ⚠️ 生产默认关闭初始数据装配,避免默认口令(platform / Platform@123)进生产。
# 首次部署需要建平台管理员时,用 XT_SEED_ENABLED=true 启动一次;
# 它会同时建一个 demo 示范租户,登录后请改掉默认密码并清理 demo 租户,
# 然后把这里改回 false 再重启。
seed:
enabled: "${XT_SEED_ENABLED:false}"
mini-program:
app-id: ${WX_MP_APP_ID}
app-secret: ${WX_MP_APP_SECRET}
app-id: "${WX_MP_APP_ID:wxf01d3fa60f87f0b7}"
app-secret: "${WX_MP_APP_SECRET:d208f690aab50ec7c5c63e6bbeca6a27}"
# ⚠️ 正式发布后用 release。用 trial/develop 生成的码扫开会提示
# 「小程序未发布」,而**接口不报错** —— 只会让人以为码生成得不对
env-version: ${WX_MP_ENV_VERSION:release}
env-version: "${WX_MP_ENV_VERSION:release}"
page: pages/index/index
public-base-url: ${WX_MP_PUBLIC_BASE_URL}
# 图片对外前缀。留空时 qrcodeUrl 返回相对路径(/api/v1/qrcodes/xxx.jpg),
# 由前端用当前域名去拼;前后端同域时这样最省事。
public-base-url: "${WX_MP_PUBLIC_BASE_URL:}"
code-cache-days: 30
# 微信「服务器配置」里的 Token(**不是** appSecret),用于消息推送回调验签。
# 下面已生成一个随机值;要与微信后台填的**完全一致**,否则回调验签失败。
# 不用消息推送回调时可以留空 —— 回调接口会明确拒绝,而不是跳过验签。
callback-token: "${WX_MP_CALLBACK_TOKEN:7c6f88f58fc2a60eefb0b96094d6a935}"
# 微信订阅消息模板 id(形如 ZG8k...):模板码 → 模板 id。
# 没配置的模板码会自动降级为站内信,不会报错;不要在没建模板时填占位值。
# 可用模板码:SUBMIT_SUCCESS / AUDIT_RESULT / DEADLINE_REMIND / EXPORT_READY / NOTIFY_UNSUBMITTED
templates: {}
mp-h5:
app-id: ${WX_H5_APP_ID}
app-secret: ${WX_H5_APP_SECRET}
callback-url: ${WX_H5_CALLBACK}
h5-base-url: ${WX_H5_BASE_URL}
# ⚠️⚠️ 生产**必须** false。开了它就是"任何人可登录任何学生"。
# 这是 ProdGuard 的第一条检查,写成 true 直接拒绝启动。
stub-enabled: false
# 不配置 mp-h5:学生端只发布微信小程序,不做 H5 网页授权。
# 后端 /api/v1/mp/h5/** 接口仍在,未配置时会明确报错;
# 生产不提供 H5 页面,也不再需要公众号的 appid / appsecret。
# 租户域名(用子域前缀区分租户):t{tenantId}.{base},如 t1.shenxue.jxit.com。
# 客户 2026-09-29 决定先不做,所以默认关闭;能力本身保留,
# 将来要有正式域名 + 泛域名解析 + 泛域名证书之后才打开。
tenant-domain:
enabled: ${XT_TENANT_DOMAIN_ENABLED:false}
base: ${XT_TENANT_DOMAIN_BASE:}
enabled: "${XT_TENANT_DOMAIN_ENABLED:false}"
base: "${XT_TENANT_DOMAIN_BASE:shenxue.jxit.com}"
scheme: https
@@ -32,10 +32,9 @@ spring:
# **这样才能验证"所有迁移从 V1 依次跑到最新"是通的** ——
# 而增量环境里永远验不到这件事(旧库已经是最新了)
clean-disabled: false
web:
resources:
# H5 产物在 CI 里是构建出来的;本地指向同一个相对路径
static-locations: ${H5_STATIC_DIR:file:../frontend-mp/dist/build/h5/},classpath:/static/
# ⚠️ 这里**没有** static-locations:H5 不再由后端托管(2026-10 架构调整,
# 见 application.yml 的 spring.web.resources.add-mappings 与 docs/09 §2.1)。
# CI 因此不再需要 H5 产物才能跑 e2e —— 那个 job 现在只是**编译检查**。
logging:
level:
@@ -1,5 +1,5 @@
# ═══════════════════════════════════════════════════════════
# 数据信息填报系统 · 主配置
# 深学在线 · 主配置
# 环境差异全部放 application-{dev,test,prod}.yml
# ⚠️ 生产密钥一律走环境变量,不写在这里
# ═══════════════════════════════════════════════════════════
@@ -40,6 +40,20 @@ spring:
multipart:
max-file-size: 50MB
max-request-size: 60MB
web:
resources:
# ⚠️ **关掉 Spring 的静态资源映射**(2026-10 架构调整):后端不再对外发任何静态文件,
# 它是**纯 API 服务**;H5 由网关 nginx 托管(deploy/nginx/xt.conf,与 /api 同源)。
#
# 为什么用开关,而不是"把 static-locations 指到一个空目录":
# 后者会连带启用 Spring Boot 的**默认**位置(classpath:/static、/public 等),
# 于是谁往 src/main/resources/static 里丢一个文件,它就悄悄对外可见了 ——
# 而"后端不托管静态文件"这件事就又不成立了,且不会有人发现。
#
# 影响面(都已实测):/api/** 由 Controller 提供、/actuator/** 由 actuator 提供、
# /doc.html 由 springdoc 自己注册的资源处理器提供 —— 都不走这个开关。
# 变的只有 `/`:它现在返回 404(探活一直用的是 /actuator/health,不是 /)。
add-mappings: false
datasource:
driver-class-name: org.postgresql.Driver
hikari:
@@ -119,3 +133,19 @@ fill:
upload:
# 单文件上限 50MB(PRD §10.1)
max-file-size-mb: 50
# ── 跨域白名单(Spring 的 allowedOriginPatterns 语义)────────────
#
# ⚠️⚠️ **"前后端同源就不用配 CORS"是错的**,这条已经用一次生产事故验证过:
# 浏览器的 CORS 只约束跨源请求,但它**给同源的 POST/PUT/DELETE 也带上 Origin 头**;
# 而 Spring MVC 只看"请求里有没有 Origin"、不看"是不是真跨源" ——
# 带了 Origin 又不在白名单里,就在进 Controller 之前直接 403「Invalid CORS request」。
# 登录/上传/提交全是 POST,所以**本站自己的地址也必须列在这儿**。
#
# ⚠️ 它只在**浏览器**里复现:e2e 脚本是 Python,不带 Origin 头 → 1700+ 条断言全绿也发现不了
# (2026-10-20 生产登录 403 的原因,见 docs/交接摘要.md §一·四十四)。
#
# ⚠️ 换域名、加域名、加穿透域名 → 改这里或给 XT_CORS_ALLOWED_ORIGINS(逗号分隔)。
# 启动日志会打印生效的白名单,排障先看那一行。
cors:
allowed-origins: "${XT_CORS_ALLOWED_ORIGINS:http://localhost:*,http://127.0.0.1:*,http://192.168.*.*:*}"
@@ -0,0 +1,51 @@
-- 角色与授权(二期):① sys_role 补 user_type;② 新建 sys_role_menu。
--
-- 为什么现在才补:一期只有 6 个**内置**角色,权限由代码决定(BuiltinRole),
-- 于是有两件事一直由代码隐式表达、库里没有:
-- ① 这个角色对应哪一类用户(sys_user.user_type 是 NOT NULL,而它一直是"从角色反推"的);
-- ② 这个角色能看见哪些菜单(一直只有 BuiltinRole 里的一个字符串集合)。
-- 二期开放自定义角色之后,这两件事必须能由租户自己配 —— 所以要落到库里。
--
-- ⚠️ 内置角色在这两处的**权威仍然是代码**(见 PermissionChecker 与 RoleProvisioner 的注释):
-- 库里的行是为了「看得见、可审计、可同步」,不是生效依据。
-- 否则代码收紧一条权限、而库里没同步,就会出现"界面显示一套、实际生效另一套"。
-- ── 1) sys_role.user_type ───────────────────────────────────────────
-- 不加这一列的后果很具体:给账号发一个自定义角色时,服务端要反推 user_type
--(UserService.assertRoleMatchesUserType),而自定义角色不在代码映射里 → 直接 40001,
-- 也就是"角色建得出来、却发不出去"。
ALTER TABLE sys_role ADD COLUMN user_type smallint;
-- 回填:此刻库里只有内置角色(自定义角色的界面还没上线),按 BuiltinRole 的映射逐行写。
-- 刻意写成 role_code 条件而不是全表刷一个值:映射关系就在代码里,这里只是把它落到行上。
UPDATE sys_role SET user_type = 1 WHERE role_code = 'PLATFORM_ADMIN';
UPDATE sys_role SET user_type = 2 WHERE role_code IN ('SCHOOL_ADMIN', 'AUDITOR');
UPDATE sys_role SET user_type = 3 WHERE role_code IN ('HEAD_TEACHER', 'DEPUTY_TEACHER');
UPDATE sys_role SET user_type = 4 WHERE role_code = 'STUDENT';
-- 兜底:万一有上面没覆盖到的行(例如某个租户手工插过自定义角色),
-- 给最保守的一类(学生=只能操作本人那一份),而不是留 NULL 让 NOT NULL 失败。
UPDATE sys_role SET user_type = 4 WHERE user_type IS NULL;
ALTER TABLE sys_role ALTER COLUMN user_type SET NOT NULL;
COMMENT ON COLUMN sys_role.user_type IS '角色对应的用户类型 1平台运营 2学校管理员 3班主任 4学生(契约 §2.7)';
-- ── 2) sys_role_menu 角色可见菜单 ───────────────────────────────────
-- 菜单**不做成权限的附属**:一个菜单可能由多个权限共同决定可见性
--(「审核」菜单初审与终审权限都能看到),所以单独一张表,与 sys_role_permission 同构。
CREATE TABLE sys_role_menu (
id bigint GENERATED BY DEFAULT AS IDENTITY NOT NULL,
tenant_id bigint NOT NULL,
role_id bigint NOT NULL,
menu_key varchar(64) NOT NULL,
create_by bigint,
create_time timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,
update_by bigint,
update_time timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,
deleted smallint NOT NULL DEFAULT 0,
PRIMARY KEY (id)
);
CREATE UNIQUE INDEX uk_rolemenu_role_key ON sys_role_menu (role_id, menu_key) WHERE deleted = 0;
CREATE INDEX idx_rolemenu_tenant_role ON sys_role_menu (tenant_id, role_id);
COMMENT ON TABLE sys_role_menu IS '角色与菜单键的关联(菜单键定义在代码 BuiltinRole,与前端 XtMenu.key 逐字一致)';
COMMENT ON COLUMN sys_role_menu.menu_key IS '菜单键,如 task;只影响"看得见哪些入口",不代表有权限操作';
@@ -0,0 +1,86 @@
-- V19:需求收敛(2026-10 客户确认)
--
-- 删掉四样东西,一次做完:
-- ① 内置角色「副班主任 DEPUTY_TEACHER」「审核员 AUDITOR」——连同**只挂这两个角色的账号**
-- ② collect_task_org.deputy_user_ids("协办人"列)——审核范围由三级回退收窄为两级
-- ③ 「历史整理器」表(collect_cleanup_job)
-- ④ 「加入申请」表(collect_join_request)
--
-- ⚠️ 三条刻意的写法,别"顺手改成更干净的写法":
-- 1. **账号是软删(deleted=1 + status=0),不是物理 DELETE。**
-- 本系统的"删除"一律是软删;而历史填报单、审核记录、操作日志里都留着他们的 user_id,
-- 物理删会让那些行变成"指向一个不存在的用户"(审核时间轴显示不出人)。
-- 2. **只删"除这两个角色外没有别的角色"的账号。** 一个班主任如果同时被挂了副班主任,
-- 删掉他等于把一个还在用的人踢出系统 —— 那种错在库里看不出来,只有当事人来问。
-- 3. **顺序:先处理绑定关系,再软删角色行。** 反过来的话,后面的语句按 role_code 找不到角色
-- (本迁移里所有查询都带 deleted = 0),会静默什么都不做。
--
-- ⚠️ 若某租户的**唯一管理账号**恰好是审核员(异常情况,正常开通租户时必定有一个学校管理员),
-- 删掉后该租户会没有人能进后台。上线前用这条查一遍:
-- SELECT r.tenant_id FROM sys_role r WHERE r.role_code = 'AUDITOR' AND r.deleted = 0;
-- ── ① 找出"只挂被删角色"的账号 ──────────────────────────────
CREATE TEMP TABLE tmp_dead_user ON COMMIT DROP AS
SELECT u.id AS user_id
FROM sys_user u
WHERE u.deleted = 0
AND EXISTS (
SELECT 1
FROM sys_user_role ur
JOIN sys_role r ON r.id = ur.role_id
WHERE ur.user_id = u.id AND ur.deleted = 0 AND r.deleted = 0
AND r.role_code IN ('DEPUTY_TEACHER', 'AUDITOR'))
AND NOT EXISTS (
SELECT 1
FROM sys_user_role ur2
JOIN sys_role r2 ON r2.id = ur2.role_id
WHERE ur2.user_id = u.id AND ur2.deleted = 0 AND r2.deleted = 0
AND r2.role_code NOT IN ('DEPUTY_TEACHER', 'AUDITOR'));
-- 软删这些账号(顺手停用:软删后仍可能被历史记录引用,停用让它不可能再登录)
UPDATE sys_user
SET deleted = 1, status = 0, update_time = CURRENT_TIMESTAMP
WHERE id IN (SELECT user_id FROM tmp_dead_user);
-- ── ② 解除这两个角色的全部绑定(含"多角色用户"身上的那一条)──────
UPDATE sys_user_role
SET deleted = 1, update_time = CURRENT_TIMESTAMP
WHERE deleted = 0
AND role_id IN (SELECT id FROM sys_role
WHERE deleted = 0 AND role_code IN ('DEPUTY_TEACHER', 'AUDITOR'));
UPDATE sys_role_permission
SET deleted = 1, update_time = CURRENT_TIMESTAMP
WHERE deleted = 0
AND role_id IN (SELECT id FROM sys_role
WHERE deleted = 0 AND role_code IN ('DEPUTY_TEACHER', 'AUDITOR'));
UPDATE sys_role_menu
SET deleted = 1, update_time = CURRENT_TIMESTAMP
WHERE deleted = 0
AND role_id IN (SELECT id FROM sys_role
WHERE deleted = 0 AND role_code IN ('DEPUTY_TEACHER', 'AUDITOR'));
-- ── ③ 软删两个内置角色本身 ─────────────────────────────────
UPDATE sys_role
SET deleted = 1, status = 0, update_time = CURRENT_TIMESTAMP
WHERE deleted = 0 AND role_code IN ('DEPUTY_TEACHER', 'AUDITOR');
-- ── ④ 删掉「协办人」列 ─────────────────────────────────────
-- 该列存的是首尾带逗号的 id 串(,3,7,),被 6 个模块的 SQL 用来做"这份单归不归我"的判定。
-- 删掉后审核范围只剩两级:collect_task_org.head_user_id → sys_org.leader_user_id。
ALTER TABLE collect_task_org DROP COLUMN IF EXISTS deputy_user_ids;
-- ── ⑤ 删掉两个模块的表 ─────────────────────────────────────
DROP TABLE IF EXISTS collect_cleanup_job;
DROP TABLE IF EXISTS collect_join_request;
-- ── ⑥ 两处注释对齐(值本身仍在历史行里,注释别说谎)────────────
-- collect_file.source = 4 曾经是"整理器导入";整理器下线后这个值不会再产生,
-- 但历史行里可能还有,所以保留在注释里说明它是历史值。
COMMENT ON COLUMN collect_file.source IS
'1本人上传 2班主任代传 3管理员代传 4整理器导入(历史值:整理器已下线,不再产生新值)';
-- 操作日志的对象类型:JOIN_REQUEST 已不再产生新记录(历史行保留)
COMMENT ON COLUMN sys_operation_log.target_type IS
'被操作对象的类型:ROSTER/TASK/SUBMISSION/FILE/EXPORT/USER/ROLE(历史值另有 JOIN_REQUEST)';
@@ -0,0 +1,83 @@
package cn.tianbaotong.fill.config;
import org.junit.jupiter.api.Test;
import org.springframework.boot.context.properties.bind.Binder;
import org.springframework.boot.env.YamlPropertySourceLoader;
import org.springframework.core.env.PropertySource;
import org.springframework.core.env.StandardEnvironment;
import org.springframework.core.io.ClassPathResource;
import org.springframework.web.cors.CorsConfiguration;
import java.io.IOException;
import java.util.List;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
/**
* CORS 白名单的最小单测(2026-10-20 生产事故的护栏,见 {@code docs/交接摘要.md} §一·四十四)。
*
* <p>为什么这条要写成单测:它<b>只在浏览器里才会发生</b> ——
* 浏览器的同源 POST 也会带 {@code Origin},而 Spring 只看"有没有 Origin"。
* 所有 e2e 脚本都是 Python(不带 Origin),所以 1712 条断言全绿也发现不了
* "生产域名没进白名单"。能自动化的只有两件事,都放在这里:
*
* <ol>
* <li><b>生产配置里确实有这份名单,且它覆盖管理端域名</b>("声明了但没接上"的另一面:
* 名单写在注释里、代码里却读不到);</li>
* <li><b>通配符真的能匹配子域,而陌生源仍然被拒</b>(既不能漏,也不能变成"全放开")。</li>
* </ol>
*/
class CorsWhitelistTest {
@Test
void 生产白名单必须覆盖管理端域名与租户子域() throws IOException {
CorsConfiguration cfg = new CorsConfiguration();
cfg.setAllowedOriginPatterns(生产白名单());
// PC 管理端的真实 Origin(同源部署下,登录/上传/提交的 POST 都会带它)
assertEquals("https://admin.shenxue.jxit.com",
cfg.checkOrigin("https://admin.shenxue.jxit.com"));
// 主域名(统一入口)
assertEquals("https://shenxue.jxit.com",
cfg.checkOrigin("https://shenxue.jxit.com"));
// 将来的租户站点(t1./t12.)走的是同一条 POST 链路
assertEquals("https://t1.shenxue.jxit.com",
cfg.checkOrigin("https://t1.shenxue.jxit.com"));
}
@Test
void 陌生源仍然被拒_白名单不是全放开() throws IOException {
CorsConfiguration cfg = new CorsConfiguration();
cfg.setAllowedOriginPatterns(生产白名单());
assertNull(cfg.checkOrigin("https://evil.example.com"));
// 后缀拼骗:admin.shenxue.jxit.com.evil.com 不是我们的子域
assertNull(cfg.checkOrigin("https://admin.shenxue.jxit.com.evil.com"));
// 生产是 https,http 版本不放开(避免"降级到 http 的页面也能调接口")
assertNull(cfg.checkOrigin("http://admin.shenxue.jxit.com"));
}
@Test
void 没配时退回本机开发值_不会是空名单() {
assertTrue(new FillProperties.Cors(null).patternsOrDefault().contains("http://localhost:*"));
assertTrue(new FillProperties.Cors(List.of()).patternsOrDefault()
.contains("http://127.0.0.1:*"));
}
/** 把 {@code application-prod.yml} 里的 {@code fill.cors} 真绑一遍(保证"配了就能读到")。 */
private static List<String> 生产白名单() throws IOException {
StandardEnvironment env = new StandardEnvironment();
List<PropertySource<?>> sources = new YamlPropertySourceLoader()
.load("application-prod.yml", new ClassPathResource("application-prod.yml"));
sources.forEach(env.getPropertySources()::addFirst);
FillProperties.Cors cors = Binder.get(env)
.bind("fill.cors", FillProperties.Cors.class)
.orElseThrow(() -> new AssertionError(
"application-prod.yml 里读不到 fill.cors.allowed-origins —— "
+ "同源 POST 会 403「Invalid CORS request」"));
return cors.patternsOrDefault();
}
}
@@ -0,0 +1,88 @@
package cn.tianbaotong.fill.module.file.service;
import org.junit.jupiter.api.Test;
import java.util.HashMap;
import java.util.Map;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
/**
* 规范文件名引擎的最小单测(《08》F-9 / F-46)。
*
* <p>断言的是<b>有理由的规则</b>,不是覆盖率:
* <ul>
* <li>扩展名必须自带点号(F-46:少一个字符不报错,只会产出「身份证jpg」这种
* 客户双击打不开的文件);</li>
* <li>空变量用「未填」占位,而不是留空 —— 留空会出现「张同学--身份证.jpg」,
* 看起来像系统出错;</li>
* <li>文件名里的 {@code /} 必须被清掉(学生的「学籍表(1/2)」很常见,
* 带 / 的名字根本写不进磁盘);</li>
* <li>超长截断 —— Windows 路径 260 限制,目录还要留空间。</li>
* </ul>
*/
class FileProcessServiceTest {
private final FileProcessService svc = new FileProcessService();
private static Map<String, String> vars(String name, String id, String material) {
Map<String, String> m = new HashMap<>();
m.put("姓名", name);
m.put("证件号", id);
m.put("材料名", material);
return m;
}
@Test
void 默认模板_姓名证件号材料名_扩展名带点() {
String n = svc.renderName(null, vars("张三", "361029200907295110", "身份证"), ".jpg");
assertEquals("张三-361029200907295110-身份证.jpg", n);
}
@Test
void 模板带后缀占位符时不重复拼扩展名() {
String n = svc.renderName("{材料名}-{姓名}{后缀}",
vars("张三", "361029200907295110", "身份证"), ".jpg");
assertEquals("身份证-张三.jpg", n);
}
@Test
void 空变量用未填占位_不会出现双横线() {
String n = svc.renderName(null, vars("张三", "", "身份证"), ".jpg");
assertEquals("张三-未填-身份证.jpg", n);
assertFalse(n.contains("--"), "留空会出现「张--身份证.jpg」,看起来像系统出错: " + n);
}
@Test
void 文件名里的斜杠必须被清掉() {
// 「学籍表(1/2)」在真实学生的上传里很常见;带 / 的名字写不进磁盘
String n = svc.renderName(null, vars("张三", "361029200907295110", "学籍表(1/2)"), ".jpg");
assertFalse(n.contains("/"), "斜杠会导致写盘失败: " + n);
assertTrue(n.contains("学籍表(1_2)"));
}
@Test
void 超长文件名截到安全长度() {
Map<String, String> m = vars("张".repeat(120), "361029200907295110", "身份证");
String n = svc.renderName(null, m, ".jpg");
assertTrue(n.length() <= 90, "Windows 路径 260 限制,正文要给目录留空间: " + n.length());
assertTrue(n.endsWith(".jpg"), "截断不能吃掉扩展名");
}
@Test
void sanitize_空白给未命名_全非法字符收拢成单个下划线() {
// "///" → "___" → 收拢成 "_"(不是「未命名」:未命名只留给纯空白,
// 全非法字符收拢后仍是可写盘的合法名字)
assertEquals("_", FileProcessService.sanitize("///"));
assertEquals("未命名", FileProcessService.sanitize(" "));
assertEquals(null, FileProcessService.sanitize(null));
}
@Test
void sanitize_连续分隔符收拢() {
assertEquals("a_b", FileProcessService.sanitize("a___b"));
assertEquals("a-b", FileProcessService.sanitize("a---b"));
}
}

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