diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 36ac36c..2fc347c 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -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' diff --git a/AGENTS.md b/AGENTS.md index 221a05d..99021ba 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -83,15 +83,15 @@ pnpm dev:mp-weixin # → dist/dev/mp-weixin(导入微信开发者工具) ## 三、怎么验证(本仓库最不显然的一点) -**后端没有单元测试**(`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 个脚本 / 1620 条断言**), + 或一条命令 `python scripts/verify-all.py --with-e2e` - 脚本**自己造 MP 的 JWT**:读 `application-dev.yml` 的 `fill.jwt.secret` + `application.yml` 的 `issuer` 手搓 HS512 —— 不需要先登录 - 改了后端**忘了重新打包/重启**,测试会对着旧 jar 通过,这是最容易踩的空转 @@ -103,6 +103,13 @@ python scripts/e2e-m3-file.py # 单跑一个;前提是后端 8080 在跑 `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` 是否对得上)。 + ⚠️ 这一族错**编译 / lint / review 都不会红**(类在、表在、常量在、注释还写得很清楚), + 只有"从入口走一遍"才暴露 —— 所以把它固化成命令;有意不接线的写在脚本上方的例外表里(带理由) +- 一条命令跑完上面这些:`python scripts/verify-all.py --with-e2e` + (9 项;**跳过项会如实列出来**,不是静默跳过) ## 四、接口与数据契约(不显然、容易做错) diff --git a/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/audit/service/AuditService.java b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/audit/service/AuditService.java index 73709c8..7ac10d5 100644 --- a/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/audit/service/AuditService.java +++ b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/audit/service/AuditService.java @@ -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(); + } + + /** + * 姓名(模板变量用)。 + * + *

**先取名册上的名字**:那才是"他在这个任务里叫什么"(老师导入的名单), + * 而账号里的 {@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 codes) { + if (codes == null || codes.isEmpty()) { + return "全部材料"; + } + return String.join("、", codes); + } + + /** + * 驳回次数达上限:**通知该校的管理员**(契约 §11.4「同时通知管理员」)。 + * + *

《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); diff --git a/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/cleanup/controller/CleanupController.java b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/cleanup/controller/CleanupController.java index 6abb02c..95e5549 100644 --- a/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/cleanup/controller/CleanupController.java +++ b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/cleanup/controller/CleanupController.java @@ -43,7 +43,7 @@ public class CleanupController { private final CleanupImportService importService; @PostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE) - @RequirePerm(PermissionDef.TASK_MANAGE) + @RequirePerm(PermissionDef.CLEANUP_RUN) @Operation(summary = "创建整理任务(上传压缩包)", description = "用**与正式收集完全相同的规则**做规范化。" + "⚠️ `rosterTaskId` 是**关键参数**:没有名册就只能做文件名规范化;" @@ -66,7 +66,7 @@ public class CleanupController { } @PostMapping(value = "/json", consumes = MediaType.APPLICATION_JSON_VALUE) - @RequirePerm(PermissionDef.TASK_MANAGE) + @RequirePerm(PermissionDef.CLEANUP_RUN) @Operation(summary = "创建整理任务(JSON 形态,供脚本/联调使用)", description = "与 multipart 那个等价,只是把压缩包换成 base64 传 —— " + "方便自动化脚本与排障,不必手搓 multipart") @@ -133,7 +133,7 @@ public class CleanupController { } @PostMapping("/{id}/import") - @RequirePerm(PermissionDef.TASK_MANAGE) + @RequirePerm(PermissionDef.CLEANUP_RUN) @Operation(summary = "把整理结果导入为正式任务", description = "名册 + 材料一次成型。**幂等**:同一个整理任务重复导入不会建出两个任务 —— " + "客户手滑点两次是常事,而多出来的任务很难被发现(它会安静地躺在列表里)") @@ -143,7 +143,7 @@ public class CleanupController { } @PostMapping(value = "/parse-preview", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) - @RequirePerm(PermissionDef.TASK_MANAGE) + @RequirePerm(PermissionDef.CLEANUP_RUN) @Operation(summary = "只解析不落库(排障用)", description = "返回每个路径解析出的姓名/证件号(脱敏)/材料码/班级线索。" + "调规则或排查「为什么这个人没认出来」时用它 —— " diff --git a/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/export/service/ExportService.java b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/export/service/ExportService.java index bbfc5db..d657173 100644 --- a/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/export/service/ExportService.java +++ b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/export/service/ExportService.java @@ -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); + } + + /** + * 导出结束的通知(成功与失败都发)。 + * + *

⚠️ **正文里不放下载链接**,只让他去「导出中心」取。 + * 理由不是省事:七牛是私有空间,链接是**签名 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()); } /** diff --git a/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/form/controller/FormSchemaController.java b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/form/controller/FormSchemaController.java index 1857e74..4e69adf 100644 --- a/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/form/controller/FormSchemaController.java +++ b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/form/controller/FormSchemaController.java @@ -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})。" diff --git a/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/join/mapper/CollectJoinRequestMapper.java b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/join/mapper/CollectJoinRequestMapper.java index 4c886c6..f18002e 100644 --- a/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/join/mapper/CollectJoinRequestMapper.java +++ b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/join/mapper/CollectJoinRequestMapper.java @@ -78,6 +78,49 @@ public interface CollectJoinRequestMapper extends BaseMapper } } + /** + * 该通知谁去审批这条申请(《08》F-102:以前一个都不通知)。 + * + *

三类人,去重后返回: + *

    + *
  1. 这个任务覆盖班级的**班主任**({@code collect_task_org.head_user_id});
  2. + *
  3. 这些班级的**副班主任**({@code deputy_user_ids} 存的是 + * {@code ,1,2,} 这种带逗号的串,所以要用 {@code LIKE '%,id,%'} 判);
  4. + *
  5. 本租户的**学校管理员** —— 兜底:任务没配班主任时也得有人管这件事。
  6. + *
+ * + *

⚠️ 不去查"谁有 join:audit 权限":权限是"能不能做",收件人是"该谁做"。 + * 一个学校有 50 个班主任,把申请推给全部 50 个等于推给没人。 + * + *

⚠️ {@code collect_task_org} **没有 {@code deleted} 列**(V1 建表就没给, + * 全项目只有 5 张表这样,见《08》F-14 的排查记录)—— 所以这里不能写 + * {@code cto.deleted = 0},否则整条 SQL 直接报"列不存在"。 + */ + @Select(""" + SELECT DISTINCT uid FROM ( + SELECT cto.head_user_id AS uid + FROM collect_task_org cto + WHERE cto.tenant_id = #{tenantId} AND cto.task_id = #{taskId} + AND cto.status = 1 + UNION + SELECT CAST(TRIM(pid) AS BIGINT) AS uid + FROM collect_task_org cto, + LATERAL regexp_split_to_table(COALESCE(cto.deputy_user_ids, ''), ',') AS pid + WHERE cto.tenant_id = #{tenantId} AND cto.task_id = #{taskId} + AND cto.status = 1 + AND TRIM(pid) <> '' + UNION + SELECT u.id AS uid + 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 = 'SCHOOL_ADMIN' + ) x + WHERE uid IS NOT NULL + """) + List auditUserIdsOfTask(@Param("tenantId") Long tenantId, @Param("taskId") Long taskId); + /** * 审批通过后回写申请单。 * diff --git a/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/join/service/JoinService.java b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/join/service/JoinService.java index a29889a..dad769f 100644 --- a/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/join/service/JoinService.java +++ b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/join/service/JoinService.java @@ -8,6 +8,8 @@ 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.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; @@ -74,6 +76,7 @@ public class JoinService { private final TaskMapper taskMapper; private final UserMapper userMapper; private final SensitiveCodec codec; + private final NotificationService notificationService; // ── 学生侧 ───────────────────────────────────── @@ -155,6 +158,7 @@ public class JoinService { joinMapper.insert(r); log.info("加入申请已提交:task={} request={} name={}", req.getTaskId(), r.getId(), name); + notifyAuditors(task, r, name); JoinDtos.SubmitVO vo = new JoinDtos.SubmitVO(); vo.setRequestId(r.getId()); @@ -393,20 +397,82 @@ public class JoinService { } log.info("加入申请通过:request={} roster={} org={} by={}", id, row.getId(), orgId, uid); - // ⚠️ 通知(M5-12)还没做:只能靠老师口头/群里说一声。 - // 这里如实回 false,**不为了让契约好看而写 true**(同驳回接口的口径)。 - log.warn("加入申请已通过但**没有发出通知**(通知渠道未实现):request={} 需人工告知学生", id); + + // ★ 通知学生(《08》F-102):以前这里只写一行 log.warn, + // 学生要自己反复进小程序看结果 —— 而这条链路的另一端(通知渠道) + // 早在 M5 就做好了,只是没接上。 + boolean notifySent = false; + if (r.getApplicantUid() != null) { + try { + var nr = notificationService.notify(NotificationService.NotifyRequest.ofTemplate( + r.getApplicantUid(), CollectMessage.BIZ_JOIN_AUDIT, + "JOIN_APPROVE", + NotificationService.NotifyRequest.vars( + "任务名", taskNameOf(r.getTaskId()), + "姓名", name), + "加入名单申请已通过", + CollectMessage.LINK_TASK, r.getTaskId(), + NotificationService.TPL_JOIN_APPROVE) + .withExtra("你现在可以扫码核验身份,然后开始填报了。")); + notifySent = nr.pushSent(); + } catch (RuntimeException e) { + log.warn("加入申请通过的通知失败(不影响审批结果):request={}", id, e); + } + } 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.setNotifySent(notifySent); vo.setCanFillNow(r.getApplicantUid() != null); return vo; } + /** + * 通知该审批这条申请的人(班主任 / 副班主任 / 学校管理员兜底)。 + * + *

《08》F-102:提交申请之后**一个通知都没有** —— 老师只能自己想到去翻审批页。 + * 而"申请加入名单"本来就是一条"学生等着、老师不知道"的链路, + * 没有通知等于这条路走不通(学生会一直等,或者去问班主任"你看到我申请了吗")。 + */ + private void notifyAuditors(CollectTask task, CollectJoinRequest r, String name) { + try { + var uids = joinMapper.auditUserIdsOfTask(UserContext.tenantId(), r.getTaskId()); + if (uids.isEmpty()) { + log.warn("加入申请无人可通知(该任务没配班主任、租户也没有管理员):request={}", r.getId()); + return; + } + for (Long to : uids) { + notificationService.notify(NotificationService.NotifyRequest.ofTemplate( + to, CollectMessage.BIZ_JOIN_AUDIT, + "JOIN_SUBMIT", + NotificationService.NotifyRequest.vars( + "任务名", task.getTaskName(), + "姓名", name, + "班级", r.getOrgHint()), + "有新的加入名单申请", + // 跳任务而不是跳申请单:审批页是按任务筛的(学生端 SCENE 也只带 taskId) + CollectMessage.LINK_TASK, r.getTaskId(), null)); + } + log.info("加入申请已通知 {} 位审批人:request={}", uids.size(), r.getId()); + } catch (RuntimeException e) { + log.warn("加入申请的通知失败(不影响学生提交):request={}", r.getId(), e); + } + } + + private String taskNameOf(Long taskId) { + CollectTask t = taskId == null ? null : taskMapper.selectById(taskId); + return t == null ? null : t.getTaskName(); + } + + private String nz(String s) { + return s == null ? "" : s; + } + /** 自述班级 → 组织 id(按名字精确或包含匹配;命中多个就不猜,返回 null 让管理员手选)。 */ private Long matchOrgByName(String orgHint) { if (orgHint == null || orgHint.isBlank()) { @@ -449,12 +515,33 @@ public class JoinService { throw new BizException(ErrorCode.JOIN_STATUS_CHANGED); } log.info("加入申请驳回:request={} by={} reason={}", id, uid, reason); - log.warn("加入申请已驳回但**没有发出通知**(通知渠道未实现):request={}", id); + + // ★ 通知学生(《08》F-102):驳回原因学生必须看到, + // 否则他会以为"申请丢了"而反复重提(每人每任务只能提 2 次)。 + boolean notifySent = false; + if (r.getApplicantUid() != null) { + try { + var nr = notificationService.notify(NotificationService.NotifyRequest.ofTemplate( + r.getApplicantUid(), CollectMessage.BIZ_JOIN_AUDIT, + "JOIN_APPROVE", + NotificationService.NotifyRequest.vars( + "任务名", taskNameOf(r.getTaskId()), + "姓名", r.getRealName(), + "驳回原因", reason), + "加入名单申请未通过", + CollectMessage.LINK_TASK, r.getTaskId(), + NotificationService.TPL_JOIN_APPROVE) + .withExtra("原因:" + reason)); + notifySent = nr.pushSent(); + } catch (RuntimeException e) { + log.warn("加入申请驳回的通知失败(不影响驳回结果):request={}", id, e); + } + } JoinDtos.RejectVO vo = new JoinDtos.RejectVO(); vo.setRequestId(id); vo.setStatus(CollectJoinRequest.STATUS_REJECTED); - vo.setNotifySent(false); + vo.setNotifySent(notifySent); return vo; } } diff --git a/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/material/controller/MaterialDefController.java b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/material/controller/MaterialDefController.java index 4ff0fcb..c5c931d 100644 --- a/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/material/controller/MaterialDefController.java +++ b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/material/controller/MaterialDefController.java @@ -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 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> applyTemplate(@PathVariable Long id, diff --git a/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/notify/mapper/UrgeMapper.java b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/notify/mapper/UrgeMapper.java index aa18131..2ca022e 100644 --- a/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/notify/mapper/UrgeMapper.java +++ b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/notify/mapper/UrgeMapper.java @@ -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 @@ -105,6 +110,25 @@ public interface UrgeMapper { @Param("orgIds") List orgIds, @Param("scopeUserId") Long scopeUserId); + /** + * 这个任务的提交进度("{已提交}/{应填}"),给模板变量 {@code {提交进度}} 用。 + * + *

契约 §12.7 的变量白名单里 {@code {提交进度}} 的适用场景就是催报 —— + * 而在此之前它**永远渲染成空**:内置模板写着"目前提交进度 {提交进度}", + * 没有任何调用方传过这个变量,于是学生收到的是"目前提交进度 ,请…"。 + * 一个白名单里有、实际没人填的变量,与"定义了没接上"是同一类问题。 + * + *

"已提交"按 {@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 +139,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 diff --git a/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/notify/service/NotificationService.java b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/notify/service/NotificationService.java index 0798390..e95f3c4 100644 --- a/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/notify/service/NotificationService.java +++ b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/notify/service/NotificationService.java @@ -60,7 +60,21 @@ import java.util.Map; @RequiredArgsConstructor public class NotificationService { - /** 模板码(契约 §13.6 的表)。 */ + /** + * 微信订阅消息的模板码(契约 §13.6 的「模板码与索取时机」表)。 + * + *

⚠️ 这套码与 {@link NotifyTemplates} 里那套站内信模板码是 + * 两个命名空间,别混: + *

+ * 两套里 {@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"; @@ -72,10 +86,21 @@ public class NotificationService { private final NotifyRecordMapper notifyRecordMapper; private final UserMapper userMapper; private final WxMiniProgramService wxService; + private final NotifyTextResolver textResolver; /** * 发一条通知。 * + *

文案从哪来:模板优先(FR-1002)

+ * + *

{@code req.subjectCode()} 非空时,标题与正文**以模板为准** —— + * 库里改过就用库里的、没改过用内置的({@link NotifyTextResolver})。 + * 调用方传的 {@code title/content} 只在"模板码不认识 / 取模板失败"时兜底。 + * 这样「通知模板」页改完就真的生效,包括驳回、通过、催报这些自动通知。 + * + *

模板**被停用**时这条通知**不发**(这正是那个开关的意义), + * 并把原因放进 {@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.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 templateParams) { + Map templateParams, + String subjectCode, Map subjectVars, String extraText) { + + /** 旧签名的兼容构造:不带站内信模板时走"调用方给文案"的老路(无模板可用)。 */ + public NotifyRequest(Long tenantId, Long userId, int bizType, String title, String content, + String linkType, Long linkId, String templateCode, + Map 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); } /** - * 这条通知属于哪个任务(发送记录表要它 —— 催报去重按任务)。 + * 按模板发的常用构造:文案由模板决定,调用方只给变量。 + * + *

{@code fallbackTitle} 只在模板取不到时兜底(正常情况下不会用到)。 + */ + public static NotifyRequest ofTemplate(Long userId, int bizType, String subjectCode, + Map 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 值直接跳过**。 + * + *

⚠️ 不要用 {@code Map.of(...)} 拼这个:它**不接受 null 值**(连 key 都不接受), + * 而"这个学生查不到姓名"是完全正常的情况 —— 于是它会抛 NPE, + * 而通知调用外面通常包着"通知失败不影响业务"的 try/catch → + * 表现是**一条通知都没发,日志里只有一行 warn**(这个坑在本轮真踩了一次: + * `Map.of("姓名", null)` → 驳回通知全丢,而驳回本身照样成功)。 + * 模板渲染对"变量没有值"的处理本来就是渲染成空(见 {@link NotifyTemplates#render}), + * 所以跳过才是对的口径。 + */ + public static Map vars(String... kv) { + Map 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; + } + + /** + * 这条通知属于哪个任务(发送记录表要它 —— 催报去重按任务;模板也要按任务取覆盖)。 * *

从 {@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 buildTemplateData(NotifyRequest req) { + private Map buildTemplateData(NotifyRequest req, String title, String content) { Map 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; } diff --git a/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/notify/service/NotifyTemplates.java b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/notify/service/NotifyTemplates.java index 1370c59..49584da 100644 --- a/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/notify/service/NotifyTemplates.java +++ b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/notify/service/NotifyTemplates.java @@ -62,13 +62,20 @@ 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 all() { @@ -87,6 +94,13 @@ public final class NotifyTemplates { /** 业务类型集合(用于校验 `POST /notify/send` 的 bizType)。 */ public static final Set BIZ_TYPES = Set.of(1, 2, 3, 4, 5, 6, 7); + /** 模板码全集(脚本与测试用它核对"每个内置模板都有发送点")。 */ + public static Set allCodes() { + Set out = new java.util.LinkedHashSet<>(); + BUILTIN.forEach(d -> out.add(d.code())); + return out; + } + /** * 渲染模板。 * diff --git a/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/notify/service/NotifyTextResolver.java b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/notify/service/NotifyTextResolver.java new file mode 100644 index 0000000..bb58d86 --- /dev/null +++ b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/notify/service/NotifyTextResolver.java @@ -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; + +/** + * 按模板码取「真正要发出去的那段文案」。 + * + *

为什么需要这个类(FR-1002 的兑现点)

+ * + *

《08-完成度盘点》F-101:通知模板页能改文案,但**只有"手工发送"走模板** —— + * 驳回、审核通过、催报、临期提醒这些**自动**通知把文案硬编码在各自的 Service 里, + * 于是管理员把「驳回通知」改成自己学校的说法之后,学生收到的还是原来那句。 + * 而 FR-1002 点名的 5 类通知(任务发布 / 临期提醒 / 驳回通知 / 审核通过 / 导出完成) + * **全是自动类** —— 也就是说这个 P0 需求当时只兑现了它没点名的那一半。 + * + *

所以自动通知也必须先来这里取文案:库里改过的 > 内置的, + * 与模板列表页({@code GET /notify/templates})看到的**完全同一份口径** —— + * 否则"界面上显示这句话、实际发出去另一句话"就是同一类问题的另一种形态。 + * + *

为什么不直接用 {@code NotifyTemplateService}

+ * + *

它是**反方向**的依赖:{@code NotifyTemplateService} 手工发送时要调 + * {@code NotificationService},而 {@code NotificationService} 现在要在发送前取文案 —— + * 两个 Service 互相注入会形成循环依赖(Spring 允许用 {@code @Lazy} 糊过去, + * 但那是把"设计上分不开"这件事藏起来)。取文案只需要一张表, + * 所以单独放一个类,依赖是单向的: + * {@code NotificationService → NotifyTextResolver → NotifyTemplateMapper}。 + * + *

失败一律退回内置文案

+ * + *

查库失败(没有租户上下文、表被锁、连接抖动…)**绝不能变成"通知不发"**: + * 那会让一次本该校验通过的审核动作连带丢掉通知。所以这里吞掉异常, + * 退回内置文案 + 一条 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 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); + } + + /** + * 取该模板码的覆盖行:**任务级优先于租户级**。 + * + *

租户拦截器会补 {@code tenant_id},所以这里不用自己带租户条件 —— + * 但这也意味着**必须在租户上下文里调用**(自动通知都在:要么是租户内的用户动作, + * 要么是定时任务里 {@code TenantScope.callAs} 摆好的)。查不到/查失败都退回内置。 + */ + private CollectNotifyTemplate findOverride(String code, Long taskId) { + try { + var q = Wrappers.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 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(); + } +} diff --git a/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/notify/service/UrgeService.java b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/notify/service/UrgeService.java index 05d2f65..f2f96e9 100644 --- a/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/notify/service/UrgeService.java +++ b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/notify/service/UrgeService.java @@ -44,6 +44,17 @@ public class UrgeService { /** 默认模板码(契约 §8.6 的 `NOTIFY_UNSUBMITTED`)。 */ public static final String TPL_URGE = "NOTIFY_UNSUBMITTED"; + /** + * 站内信模板码({@link NotifyTemplates} 那套)。 + * + *

⚠️ 与上面的 {@link #TPL_URGE} **不是一回事**:那个是**微信订阅消息**的模板码 + * (决定从哪取 {@code templateId}、扣哪一份额度),这里两个才是"学生实际读到的那句话"。 + * 草稿与"一次都没填"是两条不同的话,所以是两个模板码 —— + * 合成一个就得在模板里写条件,而变量渲染做不到条件。 + */ + public static final String SUBJECT_URGE = "URGE"; + public static final String SUBJECT_URGE_DRAFT = "URGE_DRAFT"; + /** * 催报用的业务类型码 = {@code 5}。 * @@ -216,9 +227,14 @@ public class UrgeService { LocalDateTime now = LocalDateTime.now(); Set 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) @@ -294,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 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); @@ -320,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 mine = auditScopeMapper.classAuditOrgIds(tenantId, t.getTaskId(), uid); diff --git a/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/schedule/mapper/ScheduleMapper.java b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/schedule/mapper/ScheduleMapper.java index fefd9ec..6312232 100644 --- a/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/schedule/mapper/ScheduleMapper.java +++ b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/schedule/mapper/ScheduleMapper.java @@ -62,11 +62,24 @@ public interface ScheduleMapper { List> tasksToClose(); /** - * 截止日正好在某一天的任务(给"提前 7/3/1 天催报"用)。 + * 截止日正好在某一天的任务(给"提前 7/3/1 天 + 截止当日催报"用)。 * *

为什么按"正好那一天"而不是"小于等于":催报是**每天一次**的定时任务, * 用 `<=` 会让"还剩 2 天"的任务在 7 天、3 天、1 天各被催一次(因为 2 < 3 也成立) * —— 学生一天收到三条,然后关掉通知。 + * + *

⚠️⚠️ **必须比 `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())` 才打日志)。 + * + *

这个 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> tasksEndingOn(@Param("date") String date); diff --git a/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/schedule/service/ScheduleJobs.java b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/schedule/service/ScheduleJobs.java index e5a6e61..72f639e 100644 --- a/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/schedule/service/ScheduleJobs.java +++ b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/schedule/service/ScheduleJobs.java @@ -78,8 +78,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 天规则」**+ 截止当日**)。 + * + *

⚠️ {@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}; diff --git a/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/task/controller/PlanScopeController.java b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/task/controller/PlanScopeController.java index 9afe66c..f00d0d6 100644 --- a/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/task/controller/PlanScopeController.java +++ b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/task/controller/PlanScopeController.java @@ -39,7 +39,7 @@ public class PlanScopeController { } @PutMapping("/{id}/plan-scope") - @RequirePerm(PermissionDef.TASK_MANAGE) + @RequirePerm(PermissionDef.PLAN_IMPORT) @Operation(summary = "确认 / 修正报考范围", description = "rosterIds 的并集必须恰好覆盖名册全员,否则 40024 并返回差集 —— " + "漏一个人,导出包就会缺人,而人工核对发现不了") diff --git a/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/task/controller/TaskController.java b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/task/controller/TaskController.java index 56116e5..97d6af4 100644 --- a/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/task/controller/TaskController.java +++ b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/task/controller/TaskController.java @@ -167,7 +167,7 @@ public class TaskController { // ══════════════════ 克隆 ══════════════════ @PostMapping("/{id}/clone") - @RequirePerm(PermissionDef.TASK_MANAGE) + @RequirePerm(PermissionDef.TASK_CLONE) @Operation(summary = "克隆任务", description = "克隆**配置**(表单 / 材料清单 / 命名模板 / 审核配置 / 覆盖班级)," + "**刻意不克隆业务数据**(名册 / 填报单 / 材料 / 审核记录)。" diff --git a/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/tenant/controller/TenantController.java b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/tenant/controller/TenantController.java index e4fe136..6d28714 100644 --- a/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/tenant/controller/TenantController.java +++ b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/tenant/controller/TenantController.java @@ -36,7 +36,7 @@ public class TenantController { private final TenantService tenantService; @GetMapping - @RequirePerm(PermissionDef.TENANT_MANAGE) + @RequirePerm(PermissionDef.TENANT_VIEW) @Operation(summary = "租户列表") public ApiResult> 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 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 说明** —— " diff --git a/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/user/mapper/UserMapper.java b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/user/mapper/UserMapper.java index 3bd947f..7236374 100644 --- a/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/user/mapper/UserMapper.java +++ b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/user/mapper/UserMapper.java @@ -119,4 +119,22 @@ public interface UserMapper extends BaseMapper { AND r.role_code IN ('PLATFORM_ADMIN', 'SCHOOL_ADMIN') """) Long countAdminRoles(@Param("tenantId") Long tenantId, @Param("userId") Long userId); + + /** + * 本租户下持某个内置角色的用户 id(按角色找,而不是按固定账号 —— 换管理员不该改代码)。 + * + *

用途:需要"管理员介入"的自动通知的收件人(例如驳回次数达上限,契约 §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 userIdsOfRole(@Param("tenantId") Long tenantId, @Param("roleCode") String roleCode); } diff --git a/backend/xt-server/src/main/java/cn/tianbaotong/fill/security/BuiltinRole.java b/backend/xt-server/src/main/java/cn/tianbaotong/fill/security/BuiltinRole.java index 637c639..56cf528 100644 --- a/backend/xt-server/src/main/java/cn/tianbaotong/fill/security/BuiltinRole.java +++ b/backend/xt-server/src/main/java/cn/tianbaotong/fill/security/BuiltinRole.java @@ -42,7 +42,13 @@ public enum BuiltinRole { PermissionDef.EXPORT_DOWNLOAD, PermissionDef.EXPORT_UNMASKED, PermissionDef.CLEANUP_RUN, PermissionDef.LOG_VIEW_TENANT), Set.of("dashboard", "org", "user", "role", "task", "form", "material", - "roster", "join", "audit", "export", "cleanup", "log")), + "roster", "join", "audit", "export", "cleanup", "log", + // ⚠️ 这两个菜单键以前**没有任何角色持有** —— 而 PC 端的菜单可见性 + // 就是拿这里拼出来的集合过滤的(`buildXtRoutes` 按 menuKeys 过滤), + // 于是「字典管理」与「通知记录」两个页面**做得再好也点不到**: + // 功能在、接口在、路由在,只是菜单里永远不出现(《08》F-106)。 + // 这类错没有任何测试会红 —— 所以 `check-wiring.py` 现在会查它。 + "dict", "notify")), /** 班主任:只看自己带的班。 */ HEAD_TEACHER("HEAD_TEACHER", "班主任", UserType.TEACHER, DataScope.ORG_AND_CHILD, 20, diff --git a/docs/02-需求追溯矩阵.md b/docs/02-需求追溯矩阵.md index 987f98a..85eb49d 100644 --- a/docs/02-需求追溯矩阵.md +++ b/docs/02-需求追溯矩阵.md @@ -19,7 +19,7 @@ | FR-600 材料与规则 | 23 | 18 | ✅ | ✅ | ⚠️ | ✅ 识别结果已并入审核详情(P3) | | FR-800 审核工作台 | 10 | 7 | ✅ | ✅ | ✅ | ✅ 撤回接口已补(P3) | | FR-900 汇总导出 | 10 | 8 | ✅ | ✅ | ✅ | ✅ **全覆盖** | -| FR-1000 通知催报 | 6 | 4 | ✅ | ✅ | ⚠️ | ✅ 消息中心与额度已补(P3) | +| FR-1000 通知催报 | 6 | 4 | ✅ | ✅ | ⚠️ | ✅ 消息中心与额度已补(P3);⚠️ **FR-1002 / FR-1003 在 2026-10-08 才真正兑现** —— 模板原先只对手工发送生效、自动催报因日期比较写错**一条都没发过**(见《08》F-101 / F-104) | | FR-1100 数据看板 | 5 | 2 | ✅ | ✅ | ✅ | ✅ **全覆盖** | | FR-1200 系统管理 | 6 | 5 | ⚠️ | ✅ | ❌ | ⚠️ 接口已补(P3),原型仍缺 | | FR-1300 SaaS 计费 | 4 | 2 | ⚠️ | ⚠️ | ❌ | ⚠️ 缺平台运营端 | diff --git a/docs/08-完成度盘点与问题清单.md b/docs/08-完成度盘点与问题清单.md index 1ffb225..d3303ac 100644 --- a/docs/08-完成度盘点与问题清单.md +++ b/docs/08-完成度盘点与问题清单.md @@ -439,6 +439,25 @@ | F-99 | ⚠️ **补测"没测过的场景"时,压测工具自己连续撒了 8 次谎** —— 它们的共同点是"**度量/夹具出问题时,现象长得像产品缺陷**"。① **夹具前置没断言**:单独跑 `--only upload` 时 40 张图**全部**报「没有草稿」—— 因为上传需要先经门禁绑定,而以前它总是跟在门禁场景后面跑、"恰好"能跑;② **假 JPEG**:夹具图是"JPEG 头 + 随机字节",能过扩展名与大小校验,但服务端**解码即失败**,走的根本不是压缩路径 —— 换上真图后立刻量到 2850KB → 1329KB(**47%**);③ **猜列名**:`collect_export_file` 的列是 `part_no / file_name / file_path`,我按直觉写成 `part / name / path`,SQL 直接报错;④ **判据取错返回值**:`upload_file` 返回的是 `(HTTP 码, body)`,我拿 HTTP 码当业务判据,把 `code: 0` 的成功报成"串行基线失败"(与 F-84 / F-96 同一类,**第三次**);⑤ **跨租户同名账号**:`select id from sys_user where username='admin'` 命中的是**别人家**的 admin(库里有 50 多个同名),于是"初审人"被设成跨租户用户 id —— 正确做法是**从 token 的 `sub` 取 uid**;⑥ **不查接口返回码**:`PUT /tasks/{id}/orgs` 被服务端拒(`headUserId` 必须是**真持班主任角色**的账号)而我没查,"发布"失败也没查,最后学生端整齐地报 `40008 任务已截止、已归档或已暂停`,排查方向整个错了;⑦ **PowerShell `Select-Object -First N` 会提前停止读取上游**:被截断的进程**仍然活着**,看起来就是"卡住",我为此白等了十几分钟 —— 而外层工具又把输出缓冲到命令结束,于是"慢"与"被堵"根本分不开;⑧ **清理只删库行不删磁盘文件**:压测跑几轮就在 `backend/data/uploads` 攒下几百 MB | ①在上传/导出场景里**显式绑定 + 断言前置**(不成立就报"前置不成立",而不是"上传失败");②`make_img` 改用 Pillow 生成**真 JPEG**(噪声图,纯色量不出 CPU 成本);③按 DDL 写列名;④改判 `body["code"]`;⑤新增 `uid_of(token)`(解 JWT 的 `sub`);⑥新增 `call_ok()`:夹具里每个接口调用**都断言业务码为 0**;⑦长跑/压测/起服务**一律把日志落盘**,不用管道截断(与 F-98 同一个手段);⑧`cleanup()` 先取出文件路径再删行,**连磁盘文件一起删**(存储是七牛时那些路径本地不存在,是安全的 no-op)。**教训:补测的价值一半在数字,另一半在"原来夹具一直不成立"** —— 而后者只有在**断言前置**之后才会暴露 | | F-100 | ⚠️ **门禁"成功侧上限"卡在年级量级,会挡住全校集中填报**(F-92 的第二次复发,量级更大):契约 §1.6 只规定**失败侧**(同 openid 10 次/小时、同 IP 20 次/分钟),成功侧 200 次/分钟是本项目自己加的(防"拿匹配接口批量取数据"),而当初注释里写的本意是"一个 200 人的年级同时扫码也不该被挡"。实测 **250 个不同学生同时核验(全部正确)→ 213 成功、37 被挡**,提示「当前网络访问过于集中,请稍等 1 分钟再试」。"全校在同一个出口 IP 集中扫码"(开学第一课集体填报)正是会发生的场景,而**这些人没有一个做错了什么** —— 与 F-92「教室被挡住」是同一类,只是从"一个班"放大到"一所学校" | 上调 `IP_OK_LIMIT_PER_MINUTE` **200 → 600**(依据:一所学校一次集中填报的峰值;它仍然是限速,防枚举靠的是失败侧那两个计数器,**原样未动**)。**复测**:250 人 **250/250**、500 人 **500/500**(500 并发 p50 1.4s / p95 2.5s,是排队不是限流),`e2e-m3-gate.py` 仍 **29/29**。已在 `MpRosterService` 常量注释里写明依据与"改之前先量"的命令,并同步 `docs/01` E2、`docs/09` §9.3。**教训:自己加的"防御性限额"也要过一遍真实场景的量级 —— 否则它与它要防的东西一样,都是事故** | +> **以下 F-101 ~ F-111 是"把 F-1~F-100 全部核销之后"新起的一轮**。 +> 起点是一个问题:**还有没有同一族的错?** —— F-1(日志没人写)、F-31(微信登录不写日志)、 +> F-50(表建好没人用)、F-52/F-67(契约定义了但链路没接上)都是**同一个形状**: +> **每一处单看都是对的**(类在、表在、常量在、注释写得很清楚), +> 所以编译、typecheck、code review **全都不会红**,只有"从入口走一遍"才暴露。 +> 于是先把这一类固化成命令(`scripts/check-wiring.py`),再按它的输出逐条修。 + +| F-101 | ⚠️⚠️ **FR-1002「通知模板可配置」只兑现了一半,而且是不点名的那一半**:模板页能改文案、`POST /notify/send`(**手工**发送)也确实按模板渲染 —— 但驳回、审核通过、催报、临期这些**自动**通知把文案硬编码在各自的 Service 里(`AuditService` 拼 `"你的材料被退回了"`、`UrgeService` 拼 `"请尽快完成填报"`…)。而 FR-1002 点名的 5 类通知(**任务发布 / 临期提醒 / 驳回通知 / 审核通过 / 导出完成**)**全是自动类**。后果很具体:管理员把「驳回通知」改成自己学校的说法,学生收到的还是原来那句,而**界面上显示的是改过的**(模板列表页读的正是被改过的那行)—— 这种"显示一套、发出去另一套"没有任何测试会红 | 新增 `NotifyTextResolver`(单向依赖,避免 `NotificationService` ↔ `NotifyTemplateService` 循环注入):**库里改过的 > 内置**,与模板列表页**同一份口径**;`NotifyRequest` 增加 `subjectCode` / `subjectVars` / `extraText`(模板之外的"这一次"的补充:班级、老师附言、最后一天提醒);驳回/通过/催报/升级/加入申请/导出完成**全部改走模板**。**模板被停用 = 这类通知不发**(那正是开关的意义),并如实回 `notifySent=false` + `needManualNotice=true`。验证:新增 `scripts/e2e-m5-notify-wiring.py`(**43 条**,含"改掉模板 → 触发真驳回 → 断言学生收到的是改过的那句"与"停用 → 一条都不发") | +| F-102 | ⚠️ **加入名单申请一个通知都不发**:`JoinService` 里三处写着 `log.warn("…没有发出通知(通知渠道未实现)")`,而通知渠道**早在 M5 就做好了**(`NotificationService`),只是没人接上;`CollectMessage.BIZ_JOIN_AUDIT=7` 零使用、`JOIN_SUBMIT` / `JOIN_APPROVE` 两个内置模板零发送点。后果:学生提交申请后**老师不知道**(只能自己想到去翻审批页),审批通过/驳回后**学生不知道**(他会以为申请丢了而反复重提,而每人每任务只能提 2 次) | 提交后通知**该审批的人**(`collect_task_org.head_user_id` + `deputy_user_ids` + 本租户学校管理员兜底 —— 不是"所有有 `join:audit` 权限的人",一个学校 50 个班主任等于推给没人);通过/驳回后通知学生(含驳回原因)。验证:`e2e-m5-notify-wiring.py` 第 [3] 节 | +| F-103 | ⚠️ **导出完成后不通知**(`BIZ_EXPORT=6` 零使用、`EXPORT_DONE` 模板零发送点):打包是**异步**的(几百份材料十几秒到几分钟),用户只能守着进度条;失败也不通知,于是"包永远不会好"这件事没人知道 | `pack()` 成功与 `markFailed()` 各发一条给**发起人**。⚠️ **正文里不放下载链接**:七牛是私有空间、链接是**签名 URL**,而站内信是**落库**的 —— 把签名链接写进正文就等于"URL 入库",与铁律直接冲突(那个链接几十分钟后就失效)。内置模板里原本写着 `{下载链接}`,**一并改掉**。验证:`e2e-m5-notify-wiring.py` 第 [4] 节(含"正文里没有 http"断言) | +| F-104 | ⚠️⚠️ **自动催报从来没有发出过任何一条通知**(FR-1003 的 P0 功能实际是"零"):`tasksEndingOn` 写的是 `end_time = #{date}::date`,而 `collect_task.end_time` 存的是**当天 23:59:59**,`'2026-10-08'::date` 是 `00:00:00` —— **两者永不相等**。它活了这么久是因为**测试夹具用 SQL 摆截止日**(`update collect_task set end_time = CURRENT_DATE + 3` 存进去的是 00:00:00 的日期型),那种形状恰好能匹配;而日志里只有 `if (!tasks.isEmpty())` 的沉默 | SQL 改为 `end_time::date = #{date}::date`;同时补上 **FR-1003 原文里的「+ 截止当日」那一档**(`URGE_DAYS_BEFORE` 由 `{7,3,1}` 改 `{7,3,1,0}`)—— 缺的恰恰是"再不做就来不及"的那天,且 0 天与 1 天的文案必须分开("今天是截止日,24 点前提交" vs "今天是最后一天")。验证:`e2e-m5-notify-wiring.py` 第 [5] 节(**走真实接口建任务**,end_time 自然是 23:59:59) | +| F-105 | ⚠️ **驳回次数达上限时,"需要管理员介入"没有任何人知道**:契约 §11.4 写「`escalated: true`,**同时通知管理员**」,代码注释也写着"站内信也发给管理员",而实际只有一行 `log.warn` —— **日志不是通知**:学生看到被驳回、班主任看到"已达上限",管理员什么都没看到 | 新增 `AUDIT_ESCALATE` 模板 + `notifyEscalated()`:发给**本租户的学校管理员**(`UserMapper.userIdsOfRole`,按角色找而不是固定账号);`40010 REJECT_LIMIT_EXCEEDED` **不回错误码**(驳回本身成功了,这是响应字段而不是错误)—— 已在例外表里写明理由。验证:`e2e-m5-notify-wiring.py` 第 [6] 节(连驳 3 次) | +| F-106 | ⚠️ **两个做好的页面**(字典管理、通知记录)**从菜单点不到**:PC 端菜单可见性是拿 `/auth/me` 返回的 `menus[]` 过滤 `XtMenu` 的(`buildXtRoutes`),而 `BuiltinRole` 里**没有任何角色声明 `dict` / `notify`** → 页面、路由、接口都在,菜单里永远不出现。这是 F-78「待建页在说谎」的镜像:**那次是"说没做其实做了",这次是"做了但没人看得见"**。⚠️ **手册早就把它们写进去了**(`docs/10` 的"遇到这事去哪办"表里写着「加一个下拉选项 → 配置 → 字典管理」「查通知到底发出去没有 → 收集管理 → 通知记录」)—— 也就是说**用户按手册找不到那个菜单**,而客服会收到"我这里没有这一项" | 给 `SCHOOL_ADMIN` 补这两个菜单键;`check-wiring.py` 增加"菜单键 ↔ XtMenu"的双向核对(角色声明了但前端没有 / 前端有但没角色声明,两种都报)。验证:`e2e-m5-notify-wiring.py` 第 [7] 节读真实 `/auth/me` | +| F-107 | ⚠️ **10 个权限码定义了却没有任何地方校验**(`task:clone` / `form:manage` / `material:manage` / `plan:import` / `cleanup:run` / `tenant:view` / …):接口挂的是更宽的 `task:manage`,于是"克隆任务"和"改任务名"是同一个权限;`PUT /tenants/{id}` 与"租户用量"甚至挂着 `log:view:all`(复制粘贴留下的)。**权限码写宽了不会报错,只会让本该分开的能力分不开** | 6 个能明确对应的接口改挂它自己的码(克隆 → `task:clone`、表单 → `form:manage`、材料 → `material:manage`、报考范围 → `plan:import`、整理器 → `cleanup:run`、租户读 → `tenant:view`、租户改 → `tenant:manage`);其余 4 个(`roster:manageOwn` / `progress:viewOwn` / `submission:own` / `export:unmasked`)是**范围类或二期开关**,写进 `check-wiring.py` 的例外表并说明理由。验证:`e2e-m5-notify-wiring.py` 第 [8] 节(班主任克隆/整理器 → `40301`,管理员 → 成功) | +| F-108 | 📌 **两个内置模板"零发送点"是刻意的**(`TASK_PUBLISH` / `TASK_DEADLINE`,连带 `BIZ_TASK_PUBLISH=1`):"发布即通知学生"在本系统的流程里**没有收件人** —— 学生要先扫码核验才会绑定账号,而发布之前没有人会去扫(扫码落地页对草稿任务显示"本次收集尚未开始")。发出去只会是 0 条。**所以不做**,并在 `check-wiring.py` 的例外表里写明原因(连同"什么时候该重新考虑它") | 不做,登记例外。**理由要留痕**:否则下一个人看到模板里有它就以为该发,而实现出来是个永远 0 条的功能 | +| F-109 | ⚠️ **`Map.of(...)` 拼模板变量 → 一条通知都没发**:`Map.of` **不接受 null 值**,而"这个学生查不到姓名"完全正常(学生账号是扫码核验时建的,`real_name` 可能还没填)→ 抛 NPE → 被外层"通知失败不影响业务"的 try/catch 吞成**一行 warn**,于是**驳回通知全丢,而驳回本身照样成功**。这是 F-25「被吞掉的异常」的第二次:**降级逻辑越完善,这类错越安静** | 新增 `NotifyRequest.vars(...)`(null 值直接跳过 —— 模板渲染对"没有值"的处理本来就是渲染成空);姓名改为**优先取名册上的名字**(那才是"他在这个任务里叫什么")。发现方式:`e2e-m5-notify-wiring.py` 第一次跑就红了(标题对、正文里姓名是空的) | +| F-110 | ⚠️ **一条断言写成了"全库计数",于是它会在几天后突然变红**:`e2e-m7-schedule.py` 的"刚提交的不该被提醒"数的是**全库**的「审核超时」消息,而超时扫描是**跨租户**的 —— 库里只要存在别的老填报单(几天前别的脚本留下的),它就会照样提醒。本轮真的红了,而**代码一行都没改**(那些单子的 `submit_time` 终于超过 48 小时了) | 断言**限定到本次夹具的收件人与任务**(`user_id` + 正文里的任务名)。**教训:跨租户扫描类功能的断言必须"限定到自己的夹具"** —— 否则它测的是"库里干不干净",而那是另一件事(与 F-42「cleanup 静默失败污染后续脚本」同一族) | +| F-111 | ⚠️ **`ON CONFLICT (id) DO UPDATE` 不更新用户名 → 看起来像密码错**:E2E 夹具按 id upsert 测试账号,只更新了 `real_name`,于是上一轮残留的行保留着**旧用户名**,而脚本按新用户名登录 → `40012 账号或密码错误`。第一反应会去查密码哈希(而它是对的) | 夹具改为**固定用户名 + `username = EXCLUDED.username`**(幂等);已写进 `e2e-m5-notify-wiring.py` 的注释 | + --- ## 七、建议的执行顺序(按依赖,不按重要性) @@ -544,6 +563,7 @@ M7 收尾 52 人日、M5 剩余前端约 31 人日。**关键路径仍是"审核 | 2026-09-30 | **补测 `docs/09` §9.5 的"没测过"项(上传并发 / 名册导入规模 / 导出大包 / 长跑稳定性)**,`scripts/load-test.py` 加 `--base` / `--fixture-only` / `--files-per-user` / `--split-mb` / `--soak` / `--rps`,上传夹具换成**真 JPEG**,并新增 `export-big` 场景(造 N 份已通过的单 → 打包 → 逐卷验证 ZIP)。结果:**上传 20 并发 40/40**(串行 321ms / 并发 p50 620ms、p95 801ms;2850KB → 1329KB = **47%**)、**5000 行名册导入 20.2s**(RSS 782MB)、**导出 150 人 194.6MB 打包 13.2s**、**分卷 4 卷每卷可单独解开**、**长跑 60 分钟**(RSS / PG 连接数趋势见 `docs/09` §9.6)。**同一轮里压测工具自己撒了 8 次谎**(前置未断言 / 假 JPEG / 猜列名 / 判据取错返回值 / 跨租户同名账号 / 不查返回码 / 管道截断 / 清理不删文件)→ 新增 **F-99**,并把结论写进 `docs/09` §9.1 / §9.4 / §9.5 / §9.6 | | 2026-09-30 | **17 项决策全部执行(A 组改契约 / B 组补代码 / C 组备案 / D 组清环境)**:① **契约 V2.6**:§2.7 `notifyBizType` 5/6 修正(F-30)、`auditAction` 补全 7 动作(F-7)、§3.6/§9.5 `source` 改建表口径(F-38/39)、§9.5 重写为「上传前置检查」+ §9.6 `process` 改语义 + §9.9 标注缓做(F-3/71/72)、§11.4 撤回约束补状态 2、§12.1 `validation/run` 改同步(F-51)、**新增 §17 实现补充与偏差记录**(41 接口六类 + D-1~D-12,F-4/10/26/65);② **代码**:规则模板 FORM 真正可套用 + 存模板也存表单(F-57/64,`e2e-m7-taskconfig` 75/75)、已归档任务可删——级联**物理删** 14 表 + 留痕(F-14,新增 `e2e-m1-task-delete` 19/19)、催报分钟级限流 10 次/分/任务(F-29,`e2e-m5-urge` 42/42)、学生微信登录写 `sys_login_log`(F-31,`WechatLoginLogger`,`e2e-m3-h5-auth` 24/24)、三个纯函数最小单测 12 条(F-9);③ **备案**:通知降级链一期维持两级(F-28→D-11)、国标职业库待客户提供(`docs/01` 新增 **E4**,F-34);④ **环境**:`xt-minio` 已删(F-2)、本地清 49 e2e 租户/147 测试组织/84 演示旧任务(3714 行 + 27 文件,**在用组织保护**保住演示态,F-5)、七牛桶 `xiaotutongxue/` 前缀 **2036 个对象/116MB 清空**(删前核实库中零引用,F-93,新增 `dev-clean-qiniu.py`)——至此 **F-1~F-100 无未决项,剩余全部等外部输入** | | 2026-09-30 | **长跑稳定性测完 + 门禁成功上限 200→600(F-100)+ 磁盘清理(F-5 落地)**:① 长跑 51 个样本 / 15300 请求 / **0 失败**,RSS 前 1/3 均值 603.9MB → 后 1/3 582.9MB(**-3.5% 平稳**)、PG 连接 17.0 → 6.2(**定时任务高峰后正常回落**)、p95 62ms 纹丝不动 → 写进 `docs/09` §9.5.4;② **门禁成功侧上限实测卡在年级量级**(250 人 → 37 被挡)→ 上调 600,复测 250/250、500/500,`e2e-m3-gate` 29/29(**F-100**),同步 `docs/01` E2、`docs/09` §9.3;③ `dev-clean-e2e.py` 扩展(`--demo-tasks` / `--orphan-files` / `--only-orphan-files`,业务表按 `information_schema` 动态枚举),**已清磁盘孤儿文件 255 个 / 275.4MB**(uploads 276MB → 0.3MB);e2e 租户与"E2E 24数控1班"组织**刻意不删**(当前演示态挂在其中一个班下面,已记录原因)——问题清单累计 **F-1~F-100** | +| 2026-10-08 | **新增接线检查 `scripts/check-wiring.py` + 修 F-101~F-111(11 条,全部带 E2E)**。起点:F-1/F-31/F-50/F-52 是**同一个形状**(每一处单看都对 → 编译/lint/review 全不红 → 只有从入口走一遍才暴露),所以先把它变成命令:查**权限码有没有地方校验、错误码有没有被引用、站内信模板有没有发送点、`bizType` 有没有被用过、菜单键 ↔ PC 端 `XtMenu` 是否对得上**,查不到的逐条由人判断"忘记接线"还是"有意留的"(有意的写进脚本上方的例外表 + 理由,**不靠下次再想一遍**)。⚠️ 检查器自己也修了三次假阳性:① 只数 `@RequirePerm` → 把服务层 `permissionChecker.require` 守着的 `AUDIT_WITHDRAW` 报成"没人用";② 按标识符数引用 → `JOIN_APPROVE` 撞上操作日志的动作码,**零发送点的模板被算成有 3 处引用**;③ **注释里的提及被当成接线**(`ExportController` 的类注释写着"权限只有两个:export:download 与 export:unmasked",而代码从没校验过它)→ 改为**先去 Java 注释再数**(F-36 的老教训:静态检查要能区分"代码"与"解释代码的文字")。**修掉的真问题**:F-101 自动通知改走可配置模板(FR-1002 原来只兑现了手工发送那一半)、F-102 加入申请三处通知(代码里还写着"通知渠道未实现",而渠道早做好了)、F-103 导出完成/失败通知(正文**不放签名 URL**,模板里原本写着 `{下载链接}`)、**F-104 自动催报从来没发出过任何一条**(`end_time = date::date` 与库里的 `23:59:59` 永不相等,而测试夹具用 SQL 摆的日期型恰好能匹配)+ 补 FR-1003 的「截止当日」那一档、F-105 驳回达上限通知管理员(契约与注释都写了,代码只有一行日志)、F-106 字典/通知记录两个菜单**没有任何角色持有**(页面做得再好也点不到)、F-107 6 个权限码挂到自己的接口(克隆/表单/材料/报考范围/整理器/租户)。**工具链**:`check-wiring.py` 进 `verify-all.py`(第 5 项机械校验)与 CI 的 check 阶段。**验证**:新增 `scripts/e2e-m5-notify-wiring.py`(**43 条断言**,全部打真实接口:改模板→真驳回→断言学生收到改过的那句、停用→一条不发、申请/审批/导出/催报/升级/菜单/权限各一节),并修 F-110(schedule 断言全库计数 → 限定到夹具,它在几天后会自己变红)/F-111(夹具 upsert 不更新用户名 → 看起来像密码错) | diff --git a/docs/交接摘要.md b/docs/交接摘要.md index bd8f320..e0fc940 100644 --- a/docs/交接摘要.md +++ b/docs/交接摘要.md @@ -2464,3 +2464,66 @@ CI/CD 与 test/prod 环境 → 压测 → 用户手册与客服话术 → 缩略 - `verify-all --with-e2e` → 见提交信息里的最终数字(37 个 E2E 脚本全绿)。 - E2E 总数 36 → **37**(新增 `e2e-m1-task-delete.py`),断言数相应增加。 + +--- + +## §一·三十二 接线检查:把"定义了但没接上"变成命令(2026-10-08) + +> F-1~F-100 全部核销之后,这一轮只做一件事:**问"还有没有同一族的错"**。 +> F-1(日志没人写)、F-31(微信登录不写日志)、F-50(表建好没人用)、 +> F-52/F-67(契约定义了但链路没接上)是**同一个形状** —— +> 每一处单看都对(类在、表在、常量在、注释写得很清楚), +> 所以编译 / typecheck / lint / code review **全都不红**,只有"从入口走一遍"才暴露。 +> 结论:**先把它变成命令**(`scripts/check-wiring.py`),再按输出逐条修。 +> 本轮共修 **11 条(F-101~F-111)**,新增 E2E **43 条**,终验 `verify-all --with-e2e` +> → **9 项全过 / 0 跳过**(38 个 E2E 脚本 / **1620 条断言** / 0 失败)。 + +### 一、`check-wiring.py` 查五件事(进 `verify-all.py` 与 CI 的 check 阶段) + +| # | 查什么 | 没接上的后果 | +|:--:|---|---| +| 1 | 权限码有没有地方校验(`@RequirePerm` **或**服务层 `permissionChecker`) | 权限码是死的 | +| 2 | 权限码有没有被任何内置角色分配 | **这个接口谁都调不了** | +| 3 | `ErrorCode` 的码有没有被引用 | 文档里有、代码里没有对应分支 | +| 4 | 站内信模板有没有发送点 | 模板页能改,但那条通知根本不发 | +| 5 | 菜单键 ↔ PC 端 `XtMenu` 双向核对 | 菜单永远不显示 / 前端有菜单没人看得见 | + +**例外表写在脚本里**(连同理由):有意不接线的(二期功能、范围类权限、M4 等选型) +逐条登记并打印出来 —— **不靠下次再想一遍**,也不静默跳过。 + +⚠️ **检查器自己修了三次假阳性**(这段比结论更值得记): +① 只数 `@RequirePerm` → 把服务层守着的 `AUDIT_WITHDRAW` 报成"没人用"; +② 按标识符数引用 → `JOIN_APPROVE` 撞上操作日志的动作码,**零发送点的模板被算成有引用**; +③ **注释里的提及被当成接线**(`ExportController` 类注释写着"权限只有两个: +`export:download` 与 `export:unmasked`",而代码从没校验过它)→ 改成**先去 Java 注释再数**。 +> 静态检查的通用教训(F-36 的老话):**要能区分"代码"与"解释代码的文字"**, +> 而且写完要**自己验一遍会不会漏**(本轮拿一个故意写错的模板码试了一次,确认它真的会红)。 + +### 二、修掉的 11 条(每条都有 E2E 断言) + +| # | 问题一句话 | 修法 | +|:--:|---|---| +| **F-101** | **FR-1002「通知模板可配置」只兑现了手工发送那一半** —— 驳回/通过/催报这些**自动**通知把文案硬编码在 Java 里,而 FR-1002 点名的 5 类**全是自动类** | 新增 `NotifyTextResolver`(单向依赖,避开循环注入):**库里改过的 > 内置**,与模板列表页同一份口径;`NotifyRequest` 加 `subjectCode/subjectVars/extraText`;**模板停用 = 这类通知不发** | +| **F-102** | 加入名单申请**一个通知都不发**(代码里写着"通知渠道未实现",而渠道早做好了) | 提交 → 通知该审批的人(班主任/副班主任 + 学校管理员兜底);通过/驳回 → 通知学生(含原因) | +| **F-103** | 导出完成/失败不通知(异步打包,用户只能守进度条) | 通知发起人;⚠️ **正文不放签名 URL**(站内信落库,链接几十分钟就失效)—— 内置模板里原本写着 `{下载链接}`,一并改掉 | +| **F-104** | ⚠️⚠️ **自动催报从来没发出过任何一条**:`end_time = date::date` 与库里的 `23:59:59` **永不相等** | 改 `end_time::date = #{date}::date`;并补 FR-1003 原文的**「+ 截止当日」**(0 天,文案与"最后一天"分开) | +| **F-105** | 驳回达上限只写一行日志,而契约说"**同时通知管理员**" | 新增 `AUDIT_ESCALATE` 模板 + 发给本租户学校管理员(按角色找,不写死账号) | +| **F-106** | 字典管理 / 通知记录**没有任何角色持有菜单键** → 页面做得好也点不到(**手册还写着"去那里办"**) | 给 `SCHOOL_ADMIN` 补 `dict` / `notify` | +| **F-107** | 10 个权限码没挂到任何接口(克隆/表单/材料/报考范围/整理器/租户…) | 6 个改挂自己的码;4 个范围类/二期开关进例外表 | +| **F-108** | `TASK_PUBLISH` / `TASK_DEADLINE` 零发送点 | **不做**(发布时刻没有收件人:学生要先扫码才绑账号,而发布前没人会扫)→ 登记例外 + 理由 | +| **F-109** | `Map.of(...)` 不接受 null → 拼模板变量抛 NPE → 被"通知失败不影响业务"吞成一行 warn(**驳回通知全丢而驳回照样成功**) | 新增 `NotifyRequest.vars(...)`(null 跳过);姓名优先取**名册**上的名字 | +| **F-110** | schedule 的断言数的是**全库**「审核超时」消息 → 几天后因别的老数据自己变红(代码一行没改) | 断言**限定到本次夹具**(跨租户扫描类功能的断言必须这么做) | +| **F-111** | 夹具 `ON CONFLICT (id) DO UPDATE` 不更新用户名 → 登录报"账号或密码错误"(看起来像哈希不对) | 固定用户名 + `username = EXCLUDED.username` | + +### 三、这一轮最值得记住的三件事 + +1. **F-104 是被"夹具不像真东西"盖住的**:测试用 `update collect_task set end_time = CURRENT_DATE + 3` + 摆截止日(存进去是 00:00:00 的日期型),恰好能匹配那个写错的 SQL; + 而真实接口存的是 `23:59:59` → **生产上一条都不会发**。 + 与 F-99「假 JPEG」是同一族:**夹具不像真东西,绿灯就不算数**。 + 现在这条断言**走真实接口建任务**(`e2e-m5-notify-wiring.py`)。 +2. **"改了就生效"必须验到"发出去的那句话"上**:F-101 的关键断言不是"接口返回 0", + 而是"**改掉模板 → 触发真驳回 → 查库看学生收到的那条标题**"。 +3. **降级逻辑越完善,接线类的错越安静**:F-109 的 NPE 被 try/catch 吞掉、 + F-104 的沉默被 `if (!tasks.isEmpty())` 挡住、F-106 的错只在"点菜单"时才出现 —— + 所以"从入口走一遍"这件事只能靠 E2E,不能靠 review。 diff --git a/scripts/check-wiring.py b/scripts/check-wiring.py new file mode 100644 index 0000000..756fd76 --- /dev/null +++ b/scripts/check-wiring.py @@ -0,0 +1,445 @@ +# -*- coding: utf-8 -*- +"""接线检查:找「定义好了、代码里也写了,但**从来没有被接上**」的东西。 + +## 为什么需要它(这一族错在本仓库反复出现) + +盘点这个项目时抓到的 P0/P1 问题里,有一大类长着同一个样子: + +| 例子 | 现象 | +|---|---| +| F-1 | `OperationLogMapper` 存在,但**全仓库只有它自己 import** → 敏感操作一个字都没留痕 | +| F-31 | `LoginLogMapper` 在写,但**微信登录那条链路不写** → 合规上查不到学生登录 | +| F-50 | `collect_validation_issue` 表 V1 就建好了,**一直没有代码用它** | +| F-24/35 | 实体要的审计列,建表时漏了 → 插入报错(四次) | +| F-52/67 | 契约里定义了接口,但没有"把链路接上"的那一步,功能等于装饰 | + +它们的共同点:**每一处单看都是对的**(类在、表在、常量在、注释写得很清楚), +所以 code review、typecheck、编译、单元测试**全都不会红** —— 只有"从入口走一遍" +才会暴露。而人不会每次都走一遍全链路。 + +所以把它固化成命令:**列出"声明了但没有任何地方引用"的那些声明**, +逐条由人判断是"忘记接线"还是"有意留的"(有意留的要写进下面的已知例外表, +而不是靠下次再想一遍)。 + +## 查五件事 + +| # | 查什么 | 没接上的后果 | +|:--:|---|---| +| 1 | `PermissionDef` 的权限码没有任何 `@RequirePerm` 用它 | 权限码是死的(或接口忘了标权限) | +| 2 | `PermissionDef` 的权限码没有被任何内置角色分配 | **这个接口谁都调不了**(只有平台租户能绕过) | +| 3 | `ErrorCode` 的码零引用 | 文档里有、代码里没有对应分支 | +| 4 | `NotifyTemplates` 的站内信模板零发送点 | 模板页能改,但那条通知根本不发 / 不走模板 | +| 5 | `CollectMessage.BIZ_*` 零使用 | 消息中心那个分类永远是空的 | +| 6 | 内置角色声明的菜单键 ↔ PC 端 `XtMenu` 的 key | 菜单永远不显示(键拼错),或前端有菜单谁都看不到 | + +## 它**不查**什么(免得被当成"全绿就没事") + +- 不查"逻辑对不对":它只回答"有没有被引用过",一个被引用了但用错地方的常量 + 它照样算通过; +- 不查前端有没有把权限码用上(`v-permission` 之类)—— 那要跨语言解析模板; +- 不查服务层"备好了没人调"的公开方法(如本轮发现的 + `NotifyTemplateService.templateOf`)—— 这类误报太多,靠人去读; + 所以第 4 项查的是"**模板码有没有发送点**",那是它能可靠判定的那一层。 + +跑法:`python scripts/check-wiring.py`(`--quiet` 只打摘要,给 `verify-all.py` 用) +退出码:0 = 没有未接线的声明;1 = 有(每条都打出来)。 +""" + +import argparse +import glob +import io +import os +import re +import sys + +sys.stdout.reconfigure(encoding="utf-8") +ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +os.chdir(ROOT) + +PERM_DEF = 'backend/xt-server/src/main/java/cn/tianbaotong/fill/security/PermissionDef.java' +BUILTIN_ROLE = 'backend/xt-server/src/main/java/cn/tianbaotong/fill/security/BuiltinRole.java' +ERROR_CODE = 'backend/xt-common/src/main/java/cn/tianbaotong/fill/common/api/ErrorCode.java' +NOTIFY_TPL = 'backend/xt-server/src/main/java/cn/tianbaotong/fill/module/notify/service/NotifyTemplates.java' +MESSAGE_ENTITY = ('backend/xt-server/src/main/java/cn/tianbaotong/fill/module/notify/' + 'entity/CollectMessage.java') +XT_ROUTES = 'frontend-pc/src/config/xt-routes.ts' + +# ── 已知例外:**有意不接线的**,写在这里(连同理由),不要靠下次再想一遍 ── +# 权限码:没有任何地方校验它(既没有 @RequirePerm,也没有服务层的 permissionChecker) +PERM_NO_CHECK = { + 'ROLE_MANAGE': '「角色与授权」页是二期功能(权限码先定下来,见 08 文档 6n):' + '一期不开放自定义角色,所以没有任何接口需要它', + 'ROSTER_MANAGE_OWN': '「本班名册」不是独立接口,而是 roster 接口里的**数据范围**分支' + '(服务层按角色判 orgIds),接口级权限用 ROSTER_MANAGE / ROSTER_VIEW', + 'PROGRESS_VIEW_OWN': '同上:进度看板的"本班/本人"是同一接口的范围分支(见 ProgressService)', + 'SUBMISSION_OWN': '学生端接口靠**登录态 + 只能操作本人那一份**保证,' + '不走权限码(学生是 MP 端唯一角色,加一道权限码只会多一个出错点)', + 'EXPORT_UNMASKED': '脱敏导出开关 `desensitize` 是**显式保留到二期**的(名册里本来就是' + '脱敏值,见 ExportDtos 的注释),所以这个码暂时没有对应接口', +} +# 权限码:被校验了,但没有被任何内置角色分配(= 谁都调不了) +PERM_NO_ROLE = {} +ERROR_CODE_UNUSED = { + 'ID_CARD_CHECKSUM_FAILED': 'M4 识别与校验(等 OCR 选型,见 docs/01 D1)', + 'ID_CARD_MISMATCH_ROSTER': '同上(M4)', + 'SUSPECTED_WRONG_OWNER': '同上(M4,串档检测)', + 'FILE_OWNERSHIP_DOUBT': '同上(M4,VAL-21 归属校验)', + 'OCR_FAILED': '同上(M4,OCR 服务异常)', + 'IMAGE_TOO_LARGE_CIRCUIT_BREAK': '同上(M4,图片熔断)', + 'VLM_FAILED': '同上(M4,VLM 识别)', + 'FILE_PROCESS_FAILED': '内部错误:统一由 GlobalExceptionHandler 兜成 50000 返回,' + '细分码留在文档里给排查用(客户端拿到的仍是"系统繁忙")', + 'NOTIFY_FAILED': '同上(通知失败不影响主流程,只记日志)', + 'STORAGE_FAILED': '同上(存储失败由 FileProcessService 转成 50001 口径的业务提示)', + 'SIGNED_URL_EXPIRED': '签名链接过期由**存储层**返回(七牛 401 → StorageService 转成' + '可读提示),没有单独抛这个码;契约 §12.6 的语义已由提示覆盖', + 'IDEMPOTENT_REPLAY': '契约 §9.4 的幂等键回放:一期用"重复提交返回既有结果"实现,' + '没有单独回这个码(要回它得先有幂等键,属二期)', + 'REJECT_LIMIT_EXCEEDED': '驳回达上限**不是错误**:驳回本身成功了,' + '只是把 escalated 标出来(契约 §11.4「超限升级管理员」),' + '所以走的是响应字段而不是错误码 —— 见 docs/08 F-105', +} +NOTIFY_TPL_UNUSED = { + 'TASK_DEADLINE': '临期提醒的自动发送走「自动催报」(BIZ 5 + URGE / URGE_DRAFT 模板),' + '所以这个码只在模板页里可编辑;要单独发"临期提醒"得先有"未绑定学生的账号",' + '见 docs/08 F-108 的记录', + 'TASK_PUBLISH': '「发布即通知学生」在本系统的流程里**没有收件人**:' + '学生要先扫码核验才会绑定账号,而发布之前没有人会去扫 —— ' + '所以这条通知发出去只会是 0 条(见 docs/08 F-108)', +} +BIZ_UNUSED = { + 'BIZ_TASK_PUBLISH': '同上:bizType 1(任务发布)与 TASK_PUBLISH 模板同因,' + '在"发布时刻没有可发的收件人"这件事解决之前不发', +} + + +def read(path): + return io.open(path, encoding='utf-8').read() + + +def strip_java_comments(src): + """把注释替换成空白(**保留换行**,行号才不会错位)。 + + ⚠️ 必须先去注释再数引用:本仓库的注释极其啰嗦,而且**专门在解释"为什么不这么做"** —— + 例如 `ExportController` 的类注释里写着"权限只有两个:export:download 与 export:unmasked", + 而代码里从来没校验过 `EXPORT_UNMASKED`。不剥注释的话,这个检查会被自己的文档满足 + (假阴性),而那正是《08》F-36 踩过的坑:**静态检查要能区分"代码"与"解释代码的文字"**。 + """ + out = [] + i, n = 0, len(src) + state = None # None / 'line' / 'block' / 'str' / 'char' + while i < n: + c = src[i] + nxt = src[i + 1] if i + 1 < n else '' + if state is None: + if c == '/' and nxt == '/': + state = 'line' + out.append(' ') + i += 2 + continue + if c == '/' and nxt == '*': + state = 'block' + out.append(' ') + i += 2 + continue + if c == '"': + state = 'str' + elif c == "'": + state = 'char' + out.append(c) + i += 1 + continue + if state == 'line': + if c == '\n': + state = None + out.append('\n') + else: + out.append(' ') + i += 1 + continue + if state == 'block': + if c == '*' and nxt == '/': + state = None + out.append(' ') + i += 2 + continue + out.append('\n' if c == '\n' else ' ') + i += 1 + continue + # 字符串 / 字符字面量里:原样保留(模板码就是字符串),只处理转义与结束符 + if c == '\\': + out.append(src[i:i + 2]) + i += 2 + continue + if (state == 'str' and c == '"') or (state == 'char' and c == "'"): + state = None + out.append(c) + i += 1 + return ''.join(out) + + +def java_sources(): + out = {} + for p in glob.glob('backend/**/*.java', recursive=True): + try: + out[p] = strip_java_comments(read(p)) + except Exception: + pass + return out + + +def refs(sources, name, skip_files): + """名字作为**整个标识符**(不是子串)在其它文件里出现过几次。""" + pat = re.compile(r'\b' + re.escape(name) + r'\b') + hits = [] + for p, src in sources.items(): + if os.path.abspath(p) in skip_files: + continue + for i, line in enumerate(src.split('\n'), 1): + if pat.search(line): + hits.append((p, i, line.strip())) + return hits + + +def set_of_blocks(src): + """把每个 `Set.of( ... )` 的参数原文取出来(括号配对,能吃下注释与换行)。 + + ⚠️ 第一版用正则 `Set\\.of\\(\\s*((?:"[^"]*"\\s*,?\\s*)+)\\)` 匹配 —— + 往菜单集合里加一行 `//` 注释之后它就**静默不匹配了**,于是 8 个菜单键 + 全被报成"没有任何角色声明"(假阳性)。静态检查里凡是"匹配不到就当没有"的写法, + 都要先问一句"匹配不到时会发生什么"。 + """ + out = [] + for m in re.finditer(r'Set\.of\(', src): + i = m.end() + depth = 1 + while i < len(src) and depth > 0: + if src[i] == '(': + depth += 1 + elif src[i] == ')': + depth -= 1 + i += 1 + out.append(src[m.end():i - 1]) + return out + + +def strip_line_comments(block): + return '\n'.join(re.sub(r'//.*$', '', ln) for ln in block.split('\n')) + + +def template_send_points(sources, code, tpl_file): + """站内信模板的"发送点" = 代码里有人把这个码当成模板码用。 + + ⚠️ 判据不能用"标识符出现次数":`JOIN_APPROVE` 同时是 + `OperationLogService.ACTION_JOIN_APPROVE`(操作日志动作码)与 + `NotificationService.TPL_JOIN_APPROVE`(微信订阅消息模板码)的值 —— + 按标识符数会得出"3 处引用",而它当时**一个发送点都没有**。 + + 所以认两件事: + ① 字面量 `"CODE"` 出现在非"常量声明"的行上; + ② 以 `"CODE"` 为值的常量被**在通知调用的上下文里**引用 + (±6 行窗口里出现 `ofTemplate` / `NotifyRequest` / `notify(` / `subjectCode`)—— + 因为发送点常常写成 `SUBJECT_URGE` 这种具名常量,字面量根本不出现。 + + ②是**启发式**:它可能被别的写法骗过(窗口里恰好有这些词)。它是烟雾报警器,不是证明。 + """ + hits = [] + aliases = {} + for p, s in sources.items(): + for line in s.split('\n'): + m = re.search(r'static final String\s+(\w+)\s*=\s*"' + re.escape(code) + r'"', line) + if m: + aliases[m.group(1)] = p + lit = re.compile(r'"' + re.escape(code) + r'"') + for p, s in sources.items(): + if os.path.abspath(p) == os.path.abspath(tpl_file): + continue + for i, line in enumerate(s.split('\n'), 1): + if lit.search(line) and not re.search(r'static final String\s+\w+\s*=', line): + hits.append((p, i, line.strip())) + ctx = re.compile(r'ofTemplate|NotifyRequest|notify\(|subjectCode') + for name in aliases: + pat = re.compile(r'\b' + name + r'\b') + for p, s in sources.items(): + lines = s.split('\n') + for i, line in enumerate(lines, 1): + if not pat.search(line) or re.search(r'static final String\s+' + name + r'\b', line): + continue + window = '\n'.join(lines[max(0, i - 7):i + 5]) + if ctx.search(window): + hits.append((p, i, line.strip())) + return hits + + +def menu_exception(key): + """菜单键的已知例外(不是 PC 端菜单,或由基础路由提供)。""" + if key == 'dashboard': + return 'PC 工作台是**基础路由**(不在 XtMenu 里,所有登录用户都能看到)' + if key.startswith('mp-'): + return '这是**小程序**的 tabBar 键(学生端菜单),PC 端当然没有' + return None + + +def main(): + ap = argparse.ArgumentParser(description='接线检查') + ap.add_argument('--quiet', action='store_true') + args = ap.parse_args() + + def say(*a): + if not args.quiet: + print(*a) + + src = java_sources() + problems = [] # (类别, 名字, 说明) + notes = [] # 已知例外,照样打出来(不能静默) + + say('=' * 88) + say(' 接线检查 「声明了、但没有任何地方引用它」') + say('=' * 88) + + # ── ① 权限码:有没有地方校验它 ──────────────────────────── + perm_src = read(PERM_DEF) + perms = re.findall(r'^\s{4}([A-Z][A-Z0-9_]*)\(', perm_src, re.M) + # @RequirePerm 的用法(可能跨行、可能 `{A, B}` 多条) + require_perm = set() + for s in src.values(): + for block in re.findall(r'@RequirePerm\s*\(([^)]*)\)', s, re.S): + require_perm.update(re.findall(r'PermissionDef\.([A-Z][A-Z0-9_]*)', block)) + role_perm = set(re.findall(r'PermissionDef\.([A-Z][A-Z0-9_]*)', read(BUILTIN_ROLE))) + # 服务层校验(permissionChecker.has / require):**同样是校验点** —— + # ⚠️ 第一版只数 @RequirePerm,于是把 AUDIT_WITHDRAW 报成"没有接口用它", + # 而它明明在 AuditService 里用 permissionChecker.require 守了两处(F-36 的教训: + # 静态检查必须先确认"它数的那件事"就是"要判定的那件事")。 + service_perm = set() + skip_perm_files = {os.path.abspath(PERM_DEF), os.path.abspath(BUILTIN_ROLE)} + for p, s in src.items(): + if os.path.abspath(p) in skip_perm_files: + continue + service_perm.update(re.findall(r'PermissionDef\.([A-Z][A-Z0-9_]*)', s)) + + say('\n【①】权限码 %d 个(PermissionDef)' % len(perms)) + for name in perms: + checked = name in service_perm or name in require_perm + in_role = name in role_perm + if checked and in_role: + where = '接口注解' if name in require_perm else '服务层' + say(' ✓ %-22s %s' % (name, where)) + continue + # 有例外登记的照实打出来(**不能打 ✓**:那会让"未接线"看起来像已接线) + why = [] + if not checked: + why.append(PERM_NO_CHECK.get(name) or '没有任何地方校验它(@RequirePerm 与服务层都没有)') + if not in_role: + why.append(PERM_NO_ROLE.get(name) or '没有被任何内置角色分配(这个接口谁都调不了)') + if (not checked and name in PERM_NO_CHECK) or (not in_role and name in PERM_NO_ROLE): + notes.append('权限码 %s —— 已知例外:%s' % (name, ';'.join(why))) + say(' — %-22s 已知例外:%s' % (name, ';'.join(why)[:60])) + else: + problems.append(('权限码', name, ';'.join(why))) + say(' ✗ %-22s %s' % (name, ';'.join(why))) + + # ── ② 错误码:有没有被引用 ──────────────────────────────── + ec_src = read(ERROR_CODE) + codes = re.findall(r'^\s{4}([A-Z][A-Z0-9_]*)\((\d+),', ec_src, re.M) + skip = {os.path.abspath(ERROR_CODE)} + say('\n【②】错误码 %d 个(ErrorCode)' % len(codes)) + unused_codes = [] + for name, num in codes: + if name == 'SUCCESS': + continue + if not refs(src, 'ErrorCode.' + name, skip): + unused_codes.append((name, num)) + for name, num in unused_codes: + if name in ERROR_CODE_UNUSED: + notes.append('错误码 %s(%s) 零引用 —— 已知例外:%s' % (name, num, ERROR_CODE_UNUSED[name])) + else: + problems.append(('错误码', '%s(%s)' % (name, num), '全仓库零引用(除定义处)')) + say(' 零引用的码:%s' % ('、'.join('%s(%s)' % (n, c) for n, c in unused_codes) or '无')) + + # ── ③ 站内信模板:有没有发送点 ──────────────────────────── + tpl_src = read(NOTIFY_TPL) + tpls = re.findall(r'new Def\("([A-Z_]+)",\s*(\d+)', tpl_src) + say('\n【③】站内信模板 %d 个(NotifyTemplates)' % len(tpls)) + for code, biz in tpls: + hits = template_send_points(src, code, NOTIFY_TPL) + if hits: + say(' ✓ %-14s biz=%s %d 处引用(%s)' + % (code, biz, len(hits), os.path.basename(hits[0][0]))) + elif code in NOTIFY_TPL_UNUSED: + notes.append('站内信模板 %s 零发送点 —— 已知例外:%s' % (code, NOTIFY_TPL_UNUSED[code])) + say(' — %-14s biz=%s 零发送点(已知例外)' % (code, biz)) + else: + problems.append(('站内信模板', code, '没有任何发送点(模板页能改,但这条通知不发)')) + say(' ✗ %-14s biz=%s **零发送点**' % (code, biz)) + + # 撞名:同一个字面量被两个命名空间当成"自己的码" + say('\n【③b】模板码撞名(不同命名空间、同一个字符串)') + for code, biz in tpls: + owners = [] + for p, s in src.items(): + for m in re.finditer(r'static final String\s+([A-Z_]+)\s*=\s*"%s"' % re.escape(code), s): + owners.append(m.group(1)) + if len(owners) > 1: + say(' ! %-14s 同时是 %s 的值 —— 不是 bug,但下一个改代码的人会当成同一个东西' + % (code, '、'.join(sorted(set(owners))))) + + # ── ④ 消息中心 bizType:有没有被用过 ────────────────────── + msg_src = read(MESSAGE_ENTITY) + bizs = re.findall(r'public static final int (BIZ_[A-Z_]+) = (\d+);', msg_src) + skip_msg = {os.path.abspath(MESSAGE_ENTITY)} + say('\n【④】消息中心 bizType %d 个(CollectMessage)' % len(bizs)) + for name, num in bizs: + hits = refs(src, name, skip_msg) + if hits: + say(' ✓ %-18s = %s %d 处引用' % (name, num, len(hits))) + elif name in BIZ_UNUSED: + notes.append('bizType %s(%s) 零使用 —— 已知例外:%s' % (name, num, BIZ_UNUSED[name])) + else: + problems.append(('bizType', '%s(%s)' % (name, num), + '零使用(消息中心这个分类永远是空的)')) + say(' ✗ %-18s = %s **零使用**' % (name, num)) + + # ── ⑤ 内置角色菜单键 ↔ PC 端菜单 ────────────────────────── + role_src = read(BUILTIN_ROLE) + menu_keys = set() + for block in set_of_blocks(role_src): + cleaned = strip_line_comments(block) + # 权限集合里是 `PermissionDef.X`(没有引号)→ 只有"全是字符串字面量"的才是菜单 + if re.search(r'PermissionDef\.', cleaned): + continue + menu_keys.update(re.findall(r'"([^"]+)"', cleaned)) + pc_keys = set(re.findall(r"key:\s*'([a-zA-Z-]+)'", read(XT_ROUTES))) + say('\n【⑤】菜单键:内置角色声明 %d 个 / PC 端 XtMenu %d 个' + % (len(menu_keys), len(pc_keys))) + for k in sorted(menu_keys - pc_keys): + ex = menu_exception(k) + if ex: + notes.append('菜单键 %s —— 已知例外:%s' % (k, ex)) + say(' — %-14s %s' % (k, ex)) + else: + problems.append(('菜单键', k, '内置角色声明了它,但 PC 端 XtMenu 里没有这个 key')) + say(' ✗ %-14s 角色声明了,PC 端没有这个 key' % k) + for k in sorted(pc_keys - menu_keys): + problems.append(('菜单键', k, 'PC 端有菜单,但没有任何内置角色声明它(谁都看不到)')) + say(' ✗ %-14s PC 端有,但没有任何角色能看到' % k) + for k in sorted(menu_keys & pc_keys): + say(' ✓ %s' % k) + + # ── 汇总 ──────────────────────────────────────────────── + print('\n' + '=' * 88) + if notes: + print(' 已知例外 %d 条(有意不接线,写在本脚本上方的表里):' % len(notes)) + for n in notes: + print(' — %s' % n) + print(' 未接线的声明 %d 条:' % len(problems)) + for kind, name, why in problems: + print(' ✗ [%s] %s —— %s' % (kind, name, why)) + if not problems: + print(' (无)') + print('=' * 88) + sys.stdout.flush() + return 1 if problems else 0 + + +if __name__ == '__main__': + raise SystemExit(main()) diff --git a/scripts/e2e-m5-notify-wiring.py b/scripts/e2e-m5-notify-wiring.py new file mode 100644 index 0000000..87ec850 --- /dev/null +++ b/scripts/e2e-m5-notify-wiring.py @@ -0,0 +1,521 @@ +# -*- coding: utf-8 -*- +"""通知接线 E2E(《08-完成度盘点》F-101 ~ F-107)。 + +这个脚本验的不是"某个接口返回 200",而是**"配置改了之后,系统真的按新配置做事"** —— +也就是本轮修掉的那一类"看起来有、其实没用": + +| 编号 | 原来是什么样 | 这个脚本怎么证明它现在真的生效 | +|---|---|---| +| F-101 | 「通知模板」页能改文案,但**只有手工发送走模板**;驳回/通过/催报这些自动通知把文案硬编码在 Java 里 | 改掉「驳回通知」→ 触发一次真驳回 → 断言学生收到的是**改过的那句** | +| F-101b | 模板停用开关对自动通知没有任何作用 | 停用模板 → 触发 → 断言**一条都没发**,且接口如实回 `notifySent=false` | +| F-102 | 加入名单申请**一个通知都不发**(代码里写着"通知渠道未实现") | 学生申请 → 断言班主任收到;审批通过/驳回 → 断言学生收到 | +| F-103 | 导出完成后不通知(异步打包,用户只能守着进度条) | 建导出 → 等完成 → 断言发起人收到,且**正文里没有签名 URL** | +| F-104 | 自动催报只有 7/3/1 天,**缺"截止当日"**(FR-1003 原文有) | 截止日=今天的任务 → 手工触发催报 → 断言未提交的人收到、已提交的人没收到 | +| F-105 | 驳回达上限只写一行日志,而契约说"同时通知管理员" | 连驳 3 次 → 断言管理员收到升级通知 | +| F-106 | 「字典管理」「通知记录」两个菜单**没有任何角色持有** → 页面点不到 | 管理员登录 → 断言 `/auth/me` 的 menus 里有这两个键 | +| F-107 | 6 个权限码定义了却没挂到任何接口(如 `task:clone`) | 断言班主任做克隆动作被 `40301` 拦住 | + +跑法:python scripts/e2e-m5-notify-wiring.py +前置:后端 8080 在跑、xt-pg 在跑(与其它 e2e 相同)。 +""" +from __future__ import annotations + +import argparse +import importlib.util +import json +import os +import sys +import time + +sys.stdout.reconfigure(encoding="utf-8") + +ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +PASSED: list[str] = [] +FAILED: list[tuple[str, str]] = [] +CREATED_TASK_IDS: list[int] = [] +TMP_ROSTER = os.path.join(ROOT, "scripts", "_e2e-wiring-roster.xlsx") + +T1 = 993801 # 班主任(初审人) +S1, S2, S3 = 993811, 993812, 993813 # 三个学生(各自不同用途) +JOINER = 993814 # 走「申请加入名单」的学生(不在名册里) + + +def _load(module_name: str, filename: str): + path = os.path.join(ROOT, "scripts", filename) + spec = importlib.util.spec_from_file_location(module_name, path) + mod = importlib.util.module_from_spec(spec) + spec.loader.exec_module(mod) + return mod + + +R = _load("e2e_m2_roster", "e2e-m2-roster.py") +S = _load("e2e_m3_submit", "e2e-m3-submit.py") +A = _load("e2e_m5_audit", "e2e-m5-audit.py") + +BASE = "http://localhost:8080" + + +def ok(name: str) -> None: + PASSED.append(name) + print(f" [OK] {name}") + + +def fail(name: str, detail: str) -> None: + FAILED.append((name, detail)) + print(f" [FAIL] {name}\n {detail}") + + +def check(name: str, cond: bool, detail: str = "") -> bool: + if cond: + ok(name) + else: + fail(name, detail or "断言不成立") + return cond + + +def sql(stmt: str) -> str: + _, out = R.sql(stmt) + return out + + +def body_of(method: str, path: str, token: str | None = None, payload: dict | None = None) -> dict: + _, b = R.call(method, f"{BASE}{path}", token, payload) + return b + + +def mp(uid: int) -> str: + cfg = R.read_dev_config() + return R.mint_mp_token(cfg["jwt_secret"], cfg["issuer"], uid, 1, ["STUDENT"], 4) + + +def latest_msg(uid: int, biz: int) -> tuple[str, str]: + """某人某类的最新一条站内信(标题, 正文)。没有就回空串。""" + out = sql(f"select title || '\t' || content from collect_message " + f"where user_id={uid} and biz_type={biz} and deleted=0 order by id desc limit 1;") + parts = out.strip().split("\t") + return (parts[0], parts[1]) if len(parts) == 2 else ("", "") + + +def msg_count(uid: int, biz: int) -> int: + v = sql(f"select count(*) from collect_message where user_id={uid} and biz_type={biz} " + f"and deleted=0;").strip() + return int(v or 0) + + +def find_template(token: str, code: str, task_id: int | None = None) -> dict: + q = f"/api/v1/notify/templates?taskId={task_id}" if task_id else "/api/v1/notify/templates" + for t in (body_of("GET", q, token).get("data") or []): + if t.get("templateCode") == code: + return t + return {} + + +def customize_template(token: str, code: str) -> dict: + """把内置模板复制成库里一行(契约漏了这一步,见 F-52),返回可编辑的那一行。""" + body_of("POST", f"/api/v1/notify/templates/{code}/customize", token) + return find_template(token, code) + + +def ensure_teacher(uid: int, username: str, name: str, org_id: int) -> str: + # ⚠️ `ON CONFLICT ... DO UPDATE` 必须**把 username 也更新**: + # 只更新 real_name 时,上一轮留下的行会保持旧用户名, + # 而脚本按新用户名登录 → "账号或密码错误"(看起来像密码哈希不对,其实是用户名没换)。 + sql(f""" + INSERT INTO sys_user (id, tenant_id, username, real_name, password, org_id, user_type, status) + VALUES ({uid}, 1, '{username}', '{name}', + '$2a$10$6NB7sObvoHRwzhLJwZ2CHOAbdECgMJGiiTeMY6.zo3IJyd8TEKLEO', {org_id}, 3, 1) + ON CONFLICT (id) DO UPDATE SET username = EXCLUDED.username, + real_name = EXCLUDED.real_name, + org_id = EXCLUDED.org_id, + deleted = 0, status = 1 + """) + role_id = sql("select id from sys_role where tenant_id=1 and role_code='HEAD_TEACHER' " + "and deleted=0 limit 1;").strip() + sql(f"delete from sys_user_role where user_id={uid};") + sql(f"INSERT INTO sys_user_role (user_id, role_id, tenant_id) VALUES ({uid}, {role_id}, 1);") + _, b = R.login(BASE, username, "Admin@123", "demo") + if b.get("code") != 0: + raise SystemExit(f" 班主任 {username} 登录失败:{b}") + return b["data"]["token"] + + +def build_roster(rows: list[tuple[str, str]]) -> None: + from openpyxl import Workbook + wb = Workbook() + ws = wb.active + ws.title = "通知班" + header = ["序号", "姓名", "身份证号", "学历", "联系电话", "工作单位", + "报考职业", "报考工种", "报考等级", "是否补考", + "补考理论", "补考实操", "补考综合", "是否在校生", + "在读学校", "所学专业", "入学时间"] + for c, t in enumerate(header, start=1): + ws.cell(row=1, column=c, value=t) + for i, (name, idc) in enumerate(rows): + vals = [i + 1, name, idc, "中专", f"138001377{i}", "无", + "车工", "数控车工", "四级", "否", "", "", "", "是", + "示范职业学校", "数控加工", "2024-09-01"] + for c, v in enumerate(vals, start=1): + if v != "": + ws.cell(row=i + 2, column=c, value=v) + wb.save(TMP_ROSTER) + + +def main() -> int: + ap = argparse.ArgumentParser(description="通知接线 E2E") + ap.parse_args() + + print("=" * 78) + print(" 通知接线 E2E(F-101 ~ F-107:模板真的生效 / 该发的通知真的发了)") + print("=" * 78) + + _, adm = R.login(BASE, "admin", "Admin@123", "demo") + if adm.get("code") != 0: + print(f" ✗ 管理员登录失败:{adm}") + return 2 + at = adm["data"]["token"] + # ⚠️ 管理员的 uid 要**从登录响应里取**,不能写死 1 —— demo 租户里 id=1 是 platform、 + # 学校管理员 admin 是 id=2。写死 1 会让"通知发到 uid=2"看起来像"没发" + # (第一版就是这么错的:功能是好的,断言查错了人)。 + admin_uid = adm["data"]["user"]["id"] + + # ── 0. 夹具:任务(**截止日 = 今天**,用于验 FR-1003 的「截止当日」)、一个班、一个班主任 ── + print("\n[0] 摆夹具(任务截止日故意设成今天)") + suffix = os.urandom(3).hex() + today = time.strftime("%Y-%m-%d") + _, b = R.call("POST", f"{BASE}/api/v1/tasks", at, { + "taskName": f"E2E通知接线 {suffix}", "batchNo": "202609", "majorName": "数控加工", + "collectMode": 1, "certType": 1, "startTime": "2026-01-01", "endTime": today + }) + if b.get("code") != 0: + print(f" ✗ 建任务失败:{b}") + return 2 + task_id = b["data"]["id"] + CREATED_TASK_IDS.append(task_id) + check("任务创建成功,且截止日 = 今天", True) + + _, b = R.call("GET", f"{BASE}/api/v1/orgs/tree", at) + classes = [] + + def walk(nodes): + for n in nodes or []: + if n.get("orgType") == 3: + classes.append(n) + walk(n.get("children")) + + walk(b.get("data") or []) + org = classes[0] + + t1 = ensure_teacher(T1, "wireT1", "接线班主任", org["id"]) + R.call("PUT", f"{BASE}/api/v1/tasks/{task_id}/orgs", at, {"orgs": [ + {"orgId": org["id"], "expectedCount": 3, "headUserId": T1} + ]}) + R.call("POST", f"{BASE}/api/v1/tasks/{task_id}/materials/apply-template", at, + {"templateCode": "TPL_SCHOOL_ROLL"}) + + rows = [("接线甲", R.with_checksum("36102920080101041")), + ("接线乙", R.with_checksum("36102920080202042")), + ("接线丙", R.with_checksum("36102920080303043"))] + build_roster(rows) + body = R.upload_roster(BASE, at, task_id, TMP_ROSTER, + extra={"dryRun": "false", "mode": "APPEND", "orgId": org["id"]}) + check("名册导入成功(3 人)", body.get("code") == 0, + json.dumps(body, ensure_ascii=False)[:200]) + R.call("POST", f"{BASE}/api/v1/tasks/{task_id}/publish", at, {"force": True}) + check("任务已发布(收集中)", sql( + f"select status from collect_task where id={task_id};").strip() == "1") + + # 甲、乙、丙都绑定身份(后面各自有不同的用途) + for uid, (name, idc) in zip((S1, S2, S3), rows): + tok = mp(uid) + _, b = R.call("POST", f"{BASE}/api/v1/mp/roster/match", tok, + {"taskId": task_id, "realName": name, "idCardNo": idc}) + if b.get("code") != 0: + print(f" ✗ {name} 门禁绑定失败:{b}") + return 2 + check("三个学生都绑定了身份", True) + + # ⚠️ 加入名单申请要求申请人有 **openid**(契约:同 openid 每任务 ≤2 次, + # 没有 openid 就没法按人限次 → 服务端明确拒绝,见 JoinService.submit)。 + # 门禁绑定建出来的账号没有 openid,所以这里显式补一个 —— 这是夹具,不是产品行为。 + # ⚠️ 这两位**不在名册里**,也就不会被门禁绑定建号 → 必须先自己建出来再补 openid。 + for uid, uname, rname in ((JOINER, "wireJoin1", "接线申请者"), + (993815, "wireJoin2", "接线被拒者")): + sql(f""" + INSERT INTO sys_user (id, tenant_id, username, real_name, password, org_id, + user_type, status, open_id) + VALUES ({uid}, 1, '{uname}', '{rname}', + '$2a$10$6NB7sObvoHRwzhLJwZ2CHOAbdECgMJGiiTeMY6.zo3IJyd8TEKLEO', {org["id"]}, 4, 1, + 'e2e_wire_{uid}') + ON CONFLICT (id) DO UPDATE SET open_id = EXCLUDED.open_id, deleted = 0, status = 1 + """) + + # ══════════════ F-101:改模板 → 自动驳回真的用新文案 ══════════════ + print("\n[1] F-101 模板可配置:自动通知是否真的走模板") + tpl = customize_template(at, "AUDIT_REJECT") + check("「驳回通知」可以自定义(内置 → 库里一行)", + bool(tpl.get("id")) and tpl.get("customized") is True, + json.dumps(tpl, ensure_ascii=False)[:200]) + new_title = f"【E2E改过】材料要重传 {suffix}" + new_content = "【E2E改过】{姓名} 的《{任务名}》被退回:{驳回原因}。涉及:{材料名}" + body_of("PUT", f"/api/v1/notify/templates/{tpl.get('id')}", at, + {"title": new_title, "content": new_content}) + after = find_template(at, "AUDIT_REJECT") + check("改完读回来就是新文案", after.get("title") == new_title, f"{after.get('title')}") + + # 甲:提交 → 驳回 + tok1 = mp(S1) + _, b = R.call("POST", f"{BASE}/api/v1/mp/submissions", tok1, {"taskId": task_id}) + sid1 = (b.get("data") or {}).get("id") + res = A.prepare_student(tok1, sid1, task_id, {}, False) + check("甲已提交", (res.get("data") or {}).get("submitted") is True, + json.dumps(res, ensure_ascii=False)[:200]) + rej = body_of("POST", f"/api/v1/audit/submissions/{sid1}/reject", t1, + {"reasonCode": "R001", "reasonText": "E2E 驳回原因", "materialCodes": ["IDCARD"]}) + check("班主任驳回成功", rej.get("code") == 0, json.dumps(rej, ensure_ascii=False)[:200]) + + title1, content1 = latest_msg(S1, 3) + check("⭐ 学生收到的**是改过的那句标题**(模板真的生效)", + title1 == new_title, f"实际={title1!r}") + check("⭐ 正文也来自模板,且 {姓名}/{任务名}/{驳回原因} 都被替换了", + content1.startswith("【E2E改过】") and "{驳回原因}" not in content1 + and "E2E 驳回原因" in content1 and "接线甲" in content1, + f"实际={content1!r}") + rec = sql(f"select content from collect_notify_record where receiver_id={S1} " + f"and biz_type='3' order by id desc limit 1;").strip() + check("通知记录里记的也是渲染后的文案(两个口径不分叉)", + rec.startswith("【E2E改过】") or rec == "", f"实际={rec!r}") + + # ── F-101b:模板停用 → 这条自动通知不发 ── + print("\n[2] F-101b 模板停用:自动通知应当不发") + body_of("PUT", f"/api/v1/notify/templates/{tpl.get('id')}", at, {"enabled": False}) + tok2 = mp(S2) + _, b = R.call("POST", f"{BASE}/api/v1/mp/submissions", tok2, {"taskId": task_id}) + sid2 = (b.get("data") or {}).get("id") + A.prepare_student(tok2, sid2, task_id, {}, False) + before = msg_count(S2, 3) + rej2 = body_of("POST", f"/api/v1/audit/submissions/{sid2}/reject", t1, + {"reasonCode": "R001", "reasonText": "E2E 停用测试"}) + d2 = rej2.get("data") or {} + check("驳回本身仍然成功(停用模板不该让业务动作失败)", rej2.get("code") == 0, + json.dumps(rej2, ensure_ascii=False)[:200]) + check("⭐ 学生**没有**收到站内信(停用 = 不发)", + msg_count(S2, 3) == before, f"驳回前={before} 驳回后={msg_count(S2, 3)}") + check("⭐ 接口如实回 notifySent=false + needManualNotice=true(不假装发了)", + d2.get("notifySent") is False and d2.get("needManualNotice") is True, + json.dumps(d2, ensure_ascii=False)[:200]) + + # 恢复默认(把库里那行删掉 = 内置文案回来) + body_of("DELETE", f"/api/v1/notify/templates/AUDIT_REJECT", at) + back = find_template(at, "AUDIT_REJECT") + check("恢复默认后读回内置文案", back.get("customized") is False, f"{back.get('customized')}") + + # ══════════════ F-102:加入名单申请的通知 ══════════════ + print("\n[3] F-102 加入名单申请:提交要通知老师、审批要通知学生") + joiner = mp(JOINER) + _, b = R.call("POST", f"{BASE}/api/v1/mp/roster/join-request", joiner, { + "taskId": task_id, "realName": "接线申请者", "idCardNo": R.with_checksum("36102920080404044"), + "phone": "13800137888", "orgHint": org.get("orgName"), "reason": "E2E 申请" + }) + check("学生提交加入申请成功", b.get("code") == 0, json.dumps(b, ensure_ascii=False)[:200]) + req_id = (b.get("data") or {}).get("requestId") + + t_title, t_content = latest_msg(T1, 7) + check("⭐ 班主任收到了申请通知(以前一个都不发)", bool(t_title), f"title={t_title!r}") + check("通知里说清是谁、哪个班、哪个任务", + "接线申请者" in t_content and (org.get("orgName") or "") in t_content + and "E2E通知接线" in t_content, f"实际={t_content!r}") + a_title, _ = latest_msg(admin_uid, 7) # 学校管理员(SCHOOL_ADMIN,兜底收件人) + check("学校管理员也收到了(任务没配班主任时的兜底)", bool(a_title), f"title={a_title!r}") + + before_join = msg_count(JOINER, 7) + ap_body = body_of("POST", f"/api/v1/roster/join-requests/{req_id}/approve", at, + {"orgId": org["id"]}) + check("审批通过成功", ap_body.get("code") == 0, json.dumps(ap_body, ensure_ascii=False)[:200]) + check("⭐ 学生收到了审批结果(站内信 bizType=7)", + msg_count(JOINER, 7) > before_join, + f"审批前={before_join} 审批后={msg_count(JOINER, 7)}") + j_title, j_content = latest_msg(JOINER, 7) + check("结果通知里带任务名,并告诉他可以开始填报了", + "E2E通知接线" in j_content and "填报" in j_content, f"实际={j_content!r}") + + # 驳回那条路(换个学生,避免"每任务 2 次"的上限) + JO = 993815 + joiner2 = mp(JO) + _, b = R.call("POST", f"{BASE}/api/v1/mp/roster/join-request", joiner2, { + "taskId": task_id, "realName": "接线被拒者", "idCardNo": R.with_checksum("36102920080505045"), + "phone": "13800137999", "orgHint": org.get("orgName"), "reason": "E2E 申请2" + }) + req2 = (b.get("data") or {}).get("requestId") + body_of("POST", f"/api/v1/roster/join-requests/{req2}/reject", at, + {"reasonCode": "NOT_STUDENT", "reason": "E2E 不是本校学生"}) + _, rej_content = latest_msg(JO, 7) + check("⭐ 驳回也通知学生,且把原因说清(否则他会反复重提)", + "E2E 不是本校学生" in rej_content, f"实际={rej_content!r}") + + # ══════════════ F-103:导出完成的通知 ══════════════ + print("\n[4] F-103 导出完成:通知发起人(且正文里不放签名 URL)") + # 先把甲、乙审到通过,导出才有东西可打。 + # ⚠️ 两位刚被驳回过(上面两节),必须先**重新提交**才能再进审核队列 —— + # 直接调 approve 会得到"当前状态不对"。重交不用重传材料(材料还在)。 + for tok, sid in ((tok1, sid1), (tok2, sid2)): + S.sub(tok, sid, True) + for sid in (sid1, sid2): + body_of("POST", f"/api/v1/audit/submissions/{sid}/approve", t1, + {"actionSource": "BUTTON"}) + body_of("POST", f"/api/v1/audit/submissions/{sid}/approve", at, + {"actionSource": "BUTTON", "finalOpinion": "E2E 终审"}) + passed = sql(f"select count(*) from collect_submission where task_id={task_id} " + f"and status=3 and deleted=0;").strip() + check("两份填报单已终审通过(导出有料可打)", passed == "2", f"status=3 的份数={passed}") + + before_exp = msg_count(admin_uid, 6) + created = body_of("POST", "/api/v1/exports", at, { + "taskId": task_id, "exportName": f"E2E通知-{suffix}", + "scope": {"onlyApproved": True}, "format": "ZIP", "generateSummaryExcel": True + }) + export_id = (created.get("data") or {}).get("exportId") + check("导出任务已创建", created.get("code") == 0 and isinstance(export_id, int), + json.dumps(created, ensure_ascii=False)[:200]) + detail = {} + for _ in range(40): + detail = body_of("GET", f"/api/v1/exports/{export_id}", at).get("data") or {} + if detail.get("status") in (2, 3): + break + time.sleep(1) + check("导出完成", detail.get("status") == 2, + f"status={detail.get('status')} err={detail.get('errorMsg')}") + e_title, e_content = latest_msg(admin_uid, 6) + check("⭐ 发起人收到了「导出完成」(以前只有进度条可守)", + msg_count(admin_uid, 6) > before_exp, + f"导出前={before_exp} 导出后={msg_count(admin_uid, 6)}") + check("通知里带导出包名", f"E2E通知-{suffix}" in (e_title + e_content), + f"title={e_title!r} content={e_content!r}") + check("⭐ 正文里**没有** URL(签名链接不入库、不外发)", + "http" not in e_content and "://" not in e_content, f"实际={e_content!r}") + + # ══════════════ F-104:自动催报的「截止当日」 ══════════════ + print("\n[5] F-104 自动催报:截止当日那一档(FR-1003)") + # ⚠️ /schedules/run 是**运维动作**(权限走运营侧的 log:view:all), + # 学校管理员调用会被 40301 拦住 —— 所以这里必须用平台账号(与 e2e-m7-schedule 一致) + _, ops = R.login(BASE, "platform", "Platform@123", None) + if ops.get("code") != 0: + fail("平台账号登录", json.dumps(ops, ensure_ascii=False)[:200]) + return 2 + ops_token = ops["data"]["token"] + # 丙还没提交 → 该被催;甲、乙已提交 → 不该被催 + sql(f"delete from collect_message where user_id in ({S1}, {S2}, {S3}) and biz_type=5;") + run = body_of("POST", "/api/v1/schedules/run", ops_token, {"job": "urge"}) + check("手工触发自动催报成功", run.get("code") == 0, + json.dumps(run, ensure_ascii=False)[:200]) + c_title, c_content = latest_msg(S3, 5) + check("⭐ 截止当日(0 天)也会催 —— 这一档以前是缺的", + msg_count(S3, 5) == 1, f"丙的催报条数={msg_count(S3, 5)}") + check("文案说的是「今天是截止日」而不是「最后一天」(两种急迫程度不同)", + "今天是截止日" in c_content, f"实际={c_content!r}") + check("⭐ 已提交的甲没有被催(催了会让他以为没交成功)", + msg_count(S1, 5) == 0, f"甲的催报条数={msg_count(S1, 5)}") + check("催报正文里带任务名(学生一眼知道是哪件事)", + "E2E通知接线" in c_content, f"实际={c_content!r}") + check("模板变量 {提交进度} 真的被填了(白名单里有、以前永远空)", + "/" in c_content.split("提交进度")[-1][:12] if "提交进度" in c_content else True, + f"实际={c_content!r}") + + # ══════════════ F-105:驳回达上限 → 通知管理员 ══════════════ + print("\n[6] F-105 驳回达上限:升级通知管理员(契约 §11.4)") + max_reject = 3 # 任务的 audit_config.maxRejectCount 默认 3(见 AuditService.DEFAULT_MAX_REJECT_COUNT) + before_esc = msg_count(admin_uid, 3) + # 丙:提交 → 驳回,重复到上限 + tok3 = mp(S3) + _, b = R.call("POST", f"{BASE}/api/v1/mp/submissions", tok3, {"taskId": task_id}) + sid3 = (b.get("data") or {}).get("id") + A.prepare_student(tok3, sid3, task_id, {}, False) + for i in range(max_reject): + r = body_of("POST", f"/api/v1/audit/submissions/{sid3}/reject", t1, + {"reasonCode": "R001", "reasonText": f"E2E 第 {i + 1} 次驳回"}) + if r.get("code") != 0: + fail(f"第 {i + 1} 次驳回", json.dumps(r, ensure_ascii=False)[:200]) + break + if i < max_reject - 1: + # 驳回后学生重新提交,才能再驳一次。 + # ⚠️ 只调 submit,**不要**再走 prepare_student —— 它会重新上传一遍材料, + # 而"同一材料最多 2 份"的上限会把重交顶掉,于是下一次驳回报"当前状态不对"。 + S.sub(tok3, sid3, True) + esc_title, esc_content = latest_msg(admin_uid, 3) + check(f"⭐ 连驳 {max_reject} 次后,管理员收到升级通知(以前只有一行日志)", + msg_count(admin_uid, 3) > before_esc, + f"升级前={before_esc} 升级后={msg_count(admin_uid, 3)}") + check("升级通知里带学生姓名与最后一次驳回原因", + "接线丙" in esc_content and "驳回" in esc_content, f"实际={esc_content!r}") + + # ══════════════ F-106:菜单键 ══════════════ + print("\n[7] F-106 菜单可见性:字典管理与通知记录") + menus = set((adm.get("data") or {}).get("menus") or []) + check("⭐ 管理员菜单里有「字典管理」(dict)", "dict" in menus, f"menus={sorted(menus)}") + check("⭐ 管理员菜单里有「通知记录」(notify)", "notify" in menus, f"menus={sorted(menus)}") + check("菜单键与 PC 端 XtMenu 对得上(dict/notify 都在路由表里)", + all(k in open(os.path.join(ROOT, "frontend-pc/src/config/xt-routes.ts"), + encoding="utf-8").read() for k in ("'dict'", "'notify'"))) + + # ══════════════ F-107:权限码真的挂在接口上 ══════════════ + print("\n[8] F-107 权限码挂到接口:克隆与整理器") + cl = body_of("POST", f"/api/v1/tasks/{task_id}/clone", t1, {"taskName": "不该建出来"}) + check("⭐ 班主任(无 task:clone)克隆任务被 40301 拦住", + cl.get("code") == 40301, f"code={cl.get('code')} msg={cl.get('msg')}") + cl2 = body_of("POST", f"/api/v1/tasks/{task_id}/clone", at, {"taskName": f"接线副本{suffix}"}) + check("管理员(有 task:clone)克隆成功", cl2.get("code") == 0, + json.dumps(cl2, ensure_ascii=False)[:200]) + if cl2.get("code") == 0: + CREATED_TASK_IDS.append((cl2.get("data") or {}).get("id")) + cu = body_of("POST", "/api/v1/cleanup/jobs/json", t1, {"jobName": "不该建出来"}) + check("⭐ 班主任(无 cleanup:run)调整理器被 40301 拦住", + cu.get("code") == 40301, f"code={cu.get('code')} msg={cu.get('msg')}") + + cleanup() + print("\n" + "=" * 78) + print(f"通过 {len(PASSED)} 失败 {len(FAILED)} 共 {len(PASSED) + len(FAILED)}") + print("=" * 78) + for n, det in FAILED: + print(f" ✗ {n}\n {det}") + sys.stdout.flush() + return 1 if FAILED else 0 + + +def cleanup() -> None: + for tid in [t for t in CREATED_TASK_IDS if t]: + R.sql(f"delete from collect_message where user_id in " + f"(select bind_user_id from collect_roster where task_id={tid} " + f"and bind_user_id is not null);") + R.sql(f"delete from collect_notify_record where receiver_id in " + f"(select bind_user_id from collect_roster where task_id={tid} " + f"and bind_user_id is not null);") + R.sql(f"delete from collect_export_file where export_id in " + f"(select id from collect_export_task where task_id={tid});") + R.sql(f"delete from collect_export_task where task_id={tid};") + R.sql(f"delete from collect_audit_record where submission_id in " + f"(select id from collect_submission where task_id={tid});") + R.sql(f"delete from collect_file where submission_id in " + f"(select id from collect_submission where task_id={tid});") + R.sql(f"delete from collect_submission where task_id={tid};") + R.sql(f"delete from collect_join_request where task_id={tid};") + R.sql(f"delete from collect_roster where task_id={tid};") + R.sql(f"delete from collect_task_org where task_id={tid};") + R.sql(f"delete from collect_material_def where task_id={tid};") + R.sql(f"delete from collect_form_schema where task_id={tid};") + R.sql(f"delete from collect_task where id={tid};") + # 模板的覆盖行:删掉 = 恢复内置(**不能留**,否则后面的脚本会读到改过的文案) + R.sql("delete from collect_notify_template where tenant_id=1;") + # 本脚本造的学生/申请人/班主任 + R.sql(f"delete from collect_message where user_id in " + f"({S1}, {S2}, {S3}, {JOINER}, 993815, {T1});") + R.sql(f"delete from collect_notify_record where receiver_id in " + f"({S1}, {S2}, {S3}, {JOINER}, 993815, {T1});") + R.sql(f"delete from sys_user_role where user_id in " + f"({S1}, {S2}, {S3}, {JOINER}, 993815, {T1});") + R.sql(f"delete from sys_user where id in ({S1}, {S2}, {S3}, {JOINER}, 993815, {T1});") + if os.path.exists(TMP_ROSTER): + os.remove(TMP_ROSTER) + print(" 已清理:任务 / 名册 / 填报单 / 导出 / 申请 / 消息 / 通知记录 / 模板覆盖行 / 测试账号") + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/e2e-m7-schedule.py b/scripts/e2e-m7-schedule.py index 553e06a..a381a8d 100644 --- a/scripts/e2e-m7-schedule.py +++ b/scripts/e2e-m7-schedule.py @@ -292,11 +292,21 @@ def main(): # ── 4. 审核超时提醒 ── print("\n[4] 审核超时提醒(时效与 §11.6 同口径)") # 乙的填报单刚提交 → 还没超时,先跑一次证明不发 + # + # ⚠️ 断言必须**限定到本次夹具的收件人**,不能数全库的"审核超时"消息: + # 超时扫描是跨租户的,库里只要存在**别的**老填报单(几天前别的脚本留下的、 + # 或者上一轮跑到一半留下的),它就会照样提醒 —— 于是这条断言会在 + # **几天之后突然变红**(那些单子的 submit_time 终于超过 48 小时了), + # 而代码一行都没改。第一版就是这么写的,它在本轮真的红了。 + # 判据改成"这次涉及的两个人有没有收到",就与库里的历史数据无关了。 + def timeout_msgs_to(uid: int) -> str: + return sql("select count(*) from collect_message where user_id=%d and biz_type=2 " + "and title like '%%审核超时%%';" % uid) + body_of("POST", "/api/v1/schedules/run", ops_token, {"job": "timeout"}) check("⭐ 刚提交的**不该**被提醒(否则每小时骚扰一次)", - sql("select count(*) from collect_message where biz_type=2 " - "and title like '%%审核超时%%';") == "0", - sql("select count(*) from collect_message where biz_type=2;")) + timeout_msgs_to(STU2) == "0", + "乙收到的审核超时提醒 = " + timeout_msgs_to(STU2)) # 把提交时间改到 5 天前(> 48 小时)→ 应该提醒 st = one("select status from collect_submission where task_id=%d and submitter_id=%d;" % (urge_task, STU2)) @@ -304,20 +314,21 @@ def main(): "submission.status = " + st) R.sql("update collect_submission set submit_time = NOW() - INTERVAL '5 days' " "where task_id=%d and submitter_id=%d;" % (urge_task, STU2)) - R.sql("delete from collect_message where biz_type=2 and title like '%%审核超时%%';") body_of("POST", "/api/v1/schedules/run", ops_token, {"job": "timeout"}) n = sql("select count(*) from collect_message where biz_type=2 " - "and title like '%%审核超时%%';") - check("⭐ 超时 5 天的被提醒了", n != "0", "提醒条数 = " + n) + "and title like '%%审核超时%%' and content like '%%E2E 自动催报%%';") + check("⭐ 超时 5 天的被提醒了", n != "0", "本次夹具的提醒条数 = " + n) check("⭐ 文案里带**等了多久**与**时效是多少**(审核员才知道超了多少)", "48" in sql("select content from collect_message where biz_type=2 " - "and title like '%%审核超时%%' order by id desc limit 1;"), - sql("select content from collect_message where biz_type=2 order by id desc limit 1;")) + "and title like '%%审核超时%%' and content like '%%E2E 自动催报%%' " + "order by id desc limit 1;"), + sql("select content from collect_message where biz_type=2 " + "and content like '%%E2E 自动催报%%' order by id desc limit 1;")) check("⭐ 提醒只发给**该动手的人**(班里没配负责人时不该发给所有人)", sql("select count(distinct user_id) from collect_message where biz_type=2 " - "and title like '%%审核超时%%';") != "0", + "and title like '%%审核超时%%' and content like '%%E2E 自动催报%%';") != "0", sql("select user_id, count(*) from collect_message where biz_type=2 " - "group by user_id;").replace("\n", " | ")) + "and content like '%%E2E 自动催报%%' group by user_id;").replace("\n", " | ")) # ── 5. 原始件清理 ── print("\n[5] 原始件清理(raw_expire_time 到期)") diff --git a/scripts/verify-all.py b/scripts/verify-all.py index 8a21681..fcb2198 100644 --- a/scripts/verify-all.py +++ b/scripts/verify-all.py @@ -11,7 +11,8 @@ | 实体 ↔ 建表 | `check-entity-columns.py` | 无 | | 文档自身一致 | `verify-docs.py` | 无 | | 手册 ↔ 代码文案 | `check-manual.py` | 无 | -| 接口行为(1548 条断言) | `scripts/e2e-*.py` | **后端在跑 + PG/Redis** | +| 接线(定义 ↔ 使用) | `check-wiring.py` | 无 | +| 接口行为(1577+ 条断言) | `scripts/e2e-*.py` | **后端在跑 + PG/Redis** | | 生产配置拦截 | `e2e-prodguard.py` | **能起 jar** | | CI 那个 profile 能不能起 | `check-test-profile.py` | 后端 jar + PG/Redis | @@ -87,6 +88,8 @@ def main(): r"(\d+) 个实体全部与迁移一致((\d+) 张表)"), ("文档自身一致", "python scripts/verify-docs.py", r"结果: (\d+) 项全部通过"), ("手册 ↔ 代码文案", "python scripts/check-manual.py", r"结果:.*"), + ("接线(定义↔使用)", "python scripts/check-wiring.py --quiet", + r"未接线的声明 (\d+) 条"), ] for name, cmd, pat in checks: code, out = run(cmd, timeout=300)