Compare commits
10 Commits
f86a999c52
...
0bbac7d228
| Author | SHA1 | Date | |
|---|---|---|---|
| 0bbac7d228 | |||
| db8aecc30a | |||
| 9e78e1d239 | |||
| 4a5790d900 | |||
| f89cd998ef | |||
| 57bf39d5a1 | |||
| 8f80744d63 | |||
| b991e3d68e | |||
| 28dedb4c16 | |||
| 601714c476 |
+4
-2
@@ -2,8 +2,8 @@
|
|||||||
# GitLab CI(M0-04 / M7-10)
|
# GitLab CI(M0-04 / M7-10)
|
||||||
#
|
#
|
||||||
# 设计原则:**流水线跑的必须是我们真正依赖的那套验证**。
|
# 设计原则:**流水线跑的必须是我们真正依赖的那套验证**。
|
||||||
# 这个仓库没有单元测试(`src/test` 是空的),验证靠三样东西:
|
# 这个仓库的单元测试很少(`src/test` 只有 12 条纯函数),验证靠三样东西:
|
||||||
# ① 三个机械校验脚本(契约对账 / 实体列 / 文档一致性)
|
# ① 五个机械校验脚本(契约对账 / 实体列 / 文档一致性 / 手册文案 / **接线**)
|
||||||
# ② 编译与构建(后端 jar、PC、H5)
|
# ② 编译与构建(后端 jar、PC、H5)
|
||||||
# ③ **`scripts/e2e-*.py` 打真实接口 + 查库断言**(1500+ 条)
|
# ③ **`scripts/e2e-*.py` 打真实接口 + 查库断言**(1500+ 条)
|
||||||
# 所以 e2e 阶段不是"锦上添花",它是这个项目的主验证手段 ——
|
# 所以 e2e 阶段不是"锦上添花",它是这个项目的主验证手段 ——
|
||||||
@@ -58,6 +58,8 @@ variables:
|
|||||||
- python scripts/verify-docs.py
|
- python scripts/verify-docs.py
|
||||||
- echo "── 手册里引用的错误码与文案是否真的存在(客服照着它回答) ──"
|
- echo "── 手册里引用的错误码与文案是否真的存在(客服照着它回答) ──"
|
||||||
- python scripts/check-manual.py
|
- python scripts/check-manual.py
|
||||||
|
- echo "── 声明了但没接上的东西(权限码 / 错误码 / 通知模板 / 菜单键) ──"
|
||||||
|
- python scripts/check-wiring.py --quiet
|
||||||
- echo "── 全部 e2e 脚本的语法(引号写错会当场红,而不是跑到一半才红) ──"
|
- echo "── 全部 e2e 脚本的语法(引号写错会当场红,而不是跑到一半才红) ──"
|
||||||
- |
|
- |
|
||||||
python - <<'PY'
|
python - <<'PY'
|
||||||
|
|||||||
@@ -83,22 +83,33 @@ pnpm dev:mp-weixin # → dist/dev/mp-weixin(导入微信开发者工具)
|
|||||||
|
|
||||||
## 三、怎么验证(本仓库最不显然的一点)
|
## 三、怎么验证(本仓库最不显然的一点)
|
||||||
|
|
||||||
**后端没有单元测试**(`src/test` 是空的)。验证靠 `scripts/e2e-*.py`:
|
**后端的单元测试很少**(`backend/*/src/test` 只有 12 条纯函数断言,`mvn test` 跑)。
|
||||||
打真实接口 + `docker exec` 进 PG 查库断言。
|
主验证手段是 `scripts/e2e-*.py`:打真实接口 + `docker exec` 进 PG 查库断言。
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
python scripts/e2e-m3-file.py # 单跑一个;前提是后端 8080 在跑、xt-pg 容器在跑
|
python scripts/e2e-m3-file.py # 单跑一个;前提是后端 8080 在跑、xt-pg 容器在跑
|
||||||
```
|
```
|
||||||
|
|
||||||
- 全量:把 `scripts/e2e-*.py` 依次跑一遍(`e2e-m0*` / `e2e-m1-*` / `e2e-m2-roster` / `e2e-m3-*`),
|
- 全量:把 `scripts/e2e-*.py` 依次跑一遍(**38 个脚本 / 1620 条断言**),
|
||||||
目前合计 613 条断言
|
或一条命令 `python scripts/verify-all.py --with-e2e`
|
||||||
- 脚本**自己造 MP 的 JWT**:读 `application-dev.yml` 的 `fill.jwt.secret` +
|
- 脚本**自己造 MP 的 JWT**:读 `application-dev.yml` 的 `fill.jwt.secret` +
|
||||||
`application.yml` 的 `issuer` 手搓 HS512 —— 不需要先登录
|
`application.yml` 的 `issuer` 手搓 HS512 —— 不需要先登录
|
||||||
- 改了后端**忘了重新打包/重启**,测试会对着旧 jar 通过,这是最容易踩的空转
|
- 改了后端**忘了重新打包/重启**,测试会对着旧 jar 通过,这是最容易踩的空转
|
||||||
- 跑完 `e2e-m3-*` 会**清掉名册绑定**(`collect_roster.bind_user_id`);
|
- 跑完 `e2e-m3-*` 会**清掉名册绑定**(`collect_roster.bind_user_id`);
|
||||||
想继续用浏览器验演示态,要先重新绑定(见交接摘要的"演示态"一节)
|
想继续用浏览器验演示态,要先重新绑定(见交接摘要的"演示态"一节)
|
||||||
- 清理残留:`python scripts/dev-clean-e2e.py`、`python scripts/dev-clean-test-users.py`
|
- 清理残留:`python scripts/dev-clean-e2e.py`(**默认 dry-run**;
|
||||||
|
`--only-orphan-files --apply` 只清磁盘孤儿文件 —— 压测跑几轮就能攒下几百 MB;
|
||||||
|
`--apply` 清 e2e 租户与测试组织,**会先算「仍被存活任务引用的组织」并保留**)、
|
||||||
|
`python scripts/dev-clean-test-users.py`、`python scripts/dev-clean-qiniu.py`
|
||||||
|
(清真实桶里 `xiaotutongxue/` 前缀的测试对象,**默认 dry-run**,删前先核实库中零引用)
|
||||||
- 文档一致性:`python scripts/verify-docs.py`
|
- 文档一致性:`python scripts/verify-docs.py`
|
||||||
|
- 接线检查:`python scripts/check-wiring.py`(**"声明了但没接上"** ——
|
||||||
|
权限码有没有地方校验、错误码有没有被引用、站内信模板有没有发送点、
|
||||||
|
`bizType` 有没有被用过、菜单键 ↔ PC 端 `XtMenu` 是否对得上)。
|
||||||
|
⚠️ 这一族错**编译 / lint / review 都不会红**(类在、表在、常量在、注释还写得很清楚),
|
||||||
|
只有"从入口走一遍"才暴露 —— 所以把它固化成命令;有意不接线的写在脚本上方的例外表里(带理由)
|
||||||
|
- 一条命令跑完上面这些:`python scripts/verify-all.py --with-e2e`
|
||||||
|
(9 项;**跳过项会如实列出来**,不是静默跳过)
|
||||||
|
|
||||||
## 四、接口与数据契约(不显然、容易做错)
|
## 四、接口与数据契约(不显然、容易做错)
|
||||||
|
|
||||||
|
|||||||
@@ -30,6 +30,28 @@ public record LoginUser(
|
|||||||
return tenantId != null && tenantId == PLATFORM_TENANT_ID;
|
return tenantId != null && tenantId == PLATFORM_TENANT_ID;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 这个人是不是<b>平台运营</b> —— 包括"正在代管某所学校"的那种。
|
||||||
|
*
|
||||||
|
* <p>⚠️ 与 {@link #isPlatformAdmin()} 的区别非常重要,两者回答的是不同的问题:
|
||||||
|
* <ul>
|
||||||
|
* <li>{@code isPlatformAdmin()} = "当前上下文的租户<b>就是平台租户</b>" ——
|
||||||
|
* 它是租户拦截器要不要<b>整体关掉过滤</b>的开关(跨租户看数据);</li>
|
||||||
|
* <li>{@code isPlatformStaff()} = "这个人<b>是平台运营</b>" ——
|
||||||
|
* 数据范围是全量,但**租户过滤按当前上下文**照常生效。</li>
|
||||||
|
* </ul>
|
||||||
|
*
|
||||||
|
* <p>代管某所学校时(请求头 {@code X-Tenant-Id},见 {@code ActingTenantResolver})
|
||||||
|
* tenantId 被换成那所学校,于是 {@code isPlatformAdmin()} 变成 false。
|
||||||
|
* 这时如果各处只判前者,平台就会**退化成"某个普通用户"**:例如任务列表会走
|
||||||
|
* "班主任只看自己带的班"那条分支,而平台在该校没有班级 → 空列表(看起来像"这所学校没有任务")。
|
||||||
|
*
|
||||||
|
* <p>所以凡是"按身份决定数据范围"的判断,用这一个;凡是"决定要不要关掉租户过滤"的,用前者。
|
||||||
|
*/
|
||||||
|
public boolean isPlatformStaff() {
|
||||||
|
return isPlatformAdmin() || hasRole("PLATFORM_ADMIN");
|
||||||
|
}
|
||||||
|
|
||||||
public boolean hasRole(String roleCode) {
|
public boolean hasRole(String roleCode) {
|
||||||
return roles != null && roles.contains(roleCode);
|
return roles != null && roles.contains(roleCode);
|
||||||
}
|
}
|
||||||
|
|||||||
+104
-11
@@ -761,15 +761,99 @@ public class AuditService {
|
|||||||
}
|
}
|
||||||
boolean finalPass = newStatus == CollectSubmission.STATUS_APPROVED;
|
boolean finalPass = newStatus == CollectSubmission.STATUS_APPROVED;
|
||||||
String title = finalPass ? "你的材料已通过审核" : "初审已过,等待终审";
|
String title = finalPass ? "你的材料已通过审核" : "初审已过,等待终审";
|
||||||
String content = finalPass
|
// ⚠️ bizType 用 4(审核通过),**不是** 1(任务发布):
|
||||||
? "本次认定的材料已收妥,无需再操作。"
|
// 1 是"有新任务了",而"你的材料过审了"是审核进展。
|
||||||
: "班主任已通过你的材料,接下来由管理员终审。";
|
// 以前这里借用了 1,于是消息中心按"任务发布"筛会筛出初审通过(《08》F-105)。
|
||||||
safeNotify(NotificationService.NotifyRequest.of(
|
// 文案仍按节点区分 —— "等待终审"与"已通过"是两件不同的事,
|
||||||
sub.getSubmitterId(),
|
// 用同一句话会让他以为已经结束了。
|
||||||
finalPass ? CollectMessage.BIZ_APPROVE : CollectMessage.BIZ_TASK_PUBLISH,
|
safeNotify(NotificationService.NotifyRequest.ofTemplate(
|
||||||
title, content,
|
sub.getSubmitterId(), CollectMessage.BIZ_APPROVE,
|
||||||
|
"AUDIT_PASS",
|
||||||
|
NotificationService.NotifyRequest.vars(
|
||||||
|
"任务名", taskNameOf(sub.getTaskId()),
|
||||||
|
"姓名", nameOfSubmission(sub)),
|
||||||
|
title,
|
||||||
CollectMessage.LINK_SUBMISSION, sub.getId(),
|
CollectMessage.LINK_SUBMISSION, sub.getId(),
|
||||||
finalPass ? NotificationService.TPL_AUDIT_RESULT : NotificationService.TPL_SUBMIT_SUCCESS));
|
finalPass ? NotificationService.TPL_AUDIT_RESULT : NotificationService.TPL_SUBMIT_SUCCESS)
|
||||||
|
.withExtra(finalPass ? "本次认定的材料已收妥,无需再操作。"
|
||||||
|
: "班主任已通过你的材料,接下来由管理员终审。"));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 任务名(模板变量用;查不到就给个不空的兜底)。 */
|
||||||
|
private String taskNameOf(Long taskId) {
|
||||||
|
if (taskId == null) {
|
||||||
|
return "本次填报";
|
||||||
|
}
|
||||||
|
CollectTask t = taskMapper.selectById(taskId);
|
||||||
|
return t == null || t.getTaskName() == null ? "本次填报" : t.getTaskName();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 姓名(模板变量用)。
|
||||||
|
*
|
||||||
|
* <p>**先取名册上的名字**:那才是"他在这个任务里叫什么"(老师导入的名单),
|
||||||
|
* 而账号里的 {@code real_name} 可能还没填(学生扫码核验时只建了账号)。
|
||||||
|
* 两个都没有就不编 —— 模板里那个变量渲染成空(见 {@code NotifyRequest.vars})。
|
||||||
|
*/
|
||||||
|
private String nameOfSubmission(CollectSubmission sub) {
|
||||||
|
if (sub == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (sub.getRosterId() != null) {
|
||||||
|
CollectRoster r = rosterMapper.selectById(sub.getRosterId());
|
||||||
|
if (r != null && r.getRealName() != null && !r.getRealName().isBlank()) {
|
||||||
|
return r.getRealName();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nameOf(sub.getSubmitterId());
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 姓名(从账号取,兜底)。 */
|
||||||
|
private String nameOf(Long uid) {
|
||||||
|
if (uid == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
SysUser u = userMapper.selectById(uid);
|
||||||
|
return u == null ? null : u.getRealName();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 被驳回的材料名(模板的 {材料名}):多个就用顿号连起来。 */
|
||||||
|
private String materialTextOf(List<String> codes) {
|
||||||
|
if (codes == null || codes.isEmpty()) {
|
||||||
|
return "全部材料";
|
||||||
|
}
|
||||||
|
return String.join("、", codes);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 驳回次数达上限:**通知该校的管理员**(契约 §11.4「同时通知管理员」)。
|
||||||
|
*
|
||||||
|
* <p>《08》F-105:这里原来只有一行 {@code log.warn},而注释写着"站内信也发给管理员" ——
|
||||||
|
* 于是"需要管理员介入"这件事**没有任何人知道**(学生看到的是被驳回、
|
||||||
|
* 班主任看到的是"已达上限",而管理员什么都没看到)。日志不是通知。
|
||||||
|
*/
|
||||||
|
private void notifyEscalated(CollectSubmission sub, String reason) {
|
||||||
|
try {
|
||||||
|
var admins = userMapper.userIdsOfRole(UserContext.tenantId(),
|
||||||
|
cn.tianbaotong.fill.security.BuiltinRole.SCHOOL_ADMIN.getCode());
|
||||||
|
if (admins.isEmpty()) {
|
||||||
|
log.warn("需要管理员介入但没有管理员账号可通知:submission={}", sub.getId());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for (Long uid : admins) {
|
||||||
|
safeNotify(NotificationService.NotifyRequest.ofTemplate(
|
||||||
|
uid, CollectMessage.BIZ_REJECT,
|
||||||
|
"AUDIT_ESCALATE",
|
||||||
|
NotificationService.NotifyRequest.vars(
|
||||||
|
"任务名", taskNameOf(sub.getTaskId()),
|
||||||
|
"姓名", nameOfSubmission(sub),
|
||||||
|
"驳回原因", reason),
|
||||||
|
"有一条填报单需要你介入",
|
||||||
|
CollectMessage.LINK_SUBMISSION, sub.getId(), null));
|
||||||
|
}
|
||||||
|
} catch (RuntimeException e) {
|
||||||
|
log.warn("升级通知管理员失败(不影响驳回结果):submission={}", sub.getId(), e);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -946,10 +1030,18 @@ public class AuditService {
|
|||||||
boolean pushSent = false;
|
boolean pushSent = false;
|
||||||
if (sub.getSubmitterId() != null) {
|
if (sub.getSubmitterId() != null) {
|
||||||
try {
|
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,
|
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(),
|
CollectMessage.LINK_SUBMISSION, sub.getId(),
|
||||||
NotificationService.TPL_AUDIT_RESULT));
|
NotificationService.TPL_AUDIT_RESULT));
|
||||||
pushSent = r.pushSent();
|
pushSent = r.pushSent();
|
||||||
@@ -959,9 +1051,10 @@ public class AuditService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (escalated) {
|
if (escalated) {
|
||||||
// 驳回次数达上限:这条留给管理员,所以**站内信也发给管理员**。
|
// 驳回次数达上限:这条留给管理员,所以**也通知管理员**(不只是写日志)。
|
||||||
log.warn("驳回次数已达上限,需要管理员介入:submission={} 学生={} 第 {} 次驳回 原因={}",
|
log.warn("驳回次数已达上限,需要管理员介入:submission={} 学生={} 第 {} 次驳回 原因={}",
|
||||||
submissionId, sub.getRosterId(), rejectCount, code);
|
submissionId, sub.getRosterId(), rejectCount, code);
|
||||||
|
notifyEscalated(sub, fullText);
|
||||||
}
|
}
|
||||||
log.info("审核驳回:submission={} node={} 第 {} 次 原因={} 微信推送={}",
|
log.info("审核驳回:submission={} node={} 第 {} 次 原因={} 微信推送={}",
|
||||||
submissionId, node, rejectCount, code, pushSent);
|
submissionId, node, rejectCount, code, pushSent);
|
||||||
|
|||||||
+4
-4
@@ -43,7 +43,7 @@ public class CleanupController {
|
|||||||
private final CleanupImportService importService;
|
private final CleanupImportService importService;
|
||||||
|
|
||||||
@PostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
|
@PostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
|
||||||
@RequirePerm(PermissionDef.TASK_MANAGE)
|
@RequirePerm(PermissionDef.CLEANUP_RUN)
|
||||||
@Operation(summary = "创建整理任务(上传压缩包)",
|
@Operation(summary = "创建整理任务(上传压缩包)",
|
||||||
description = "用**与正式收集完全相同的规则**做规范化。"
|
description = "用**与正式收集完全相同的规则**做规范化。"
|
||||||
+ "⚠️ `rosterTaskId` 是**关键参数**:没有名册就只能做文件名规范化;"
|
+ "⚠️ `rosterTaskId` 是**关键参数**:没有名册就只能做文件名规范化;"
|
||||||
@@ -66,7 +66,7 @@ public class CleanupController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@PostMapping(value = "/json", consumes = MediaType.APPLICATION_JSON_VALUE)
|
@PostMapping(value = "/json", consumes = MediaType.APPLICATION_JSON_VALUE)
|
||||||
@RequirePerm(PermissionDef.TASK_MANAGE)
|
@RequirePerm(PermissionDef.CLEANUP_RUN)
|
||||||
@Operation(summary = "创建整理任务(JSON 形态,供脚本/联调使用)",
|
@Operation(summary = "创建整理任务(JSON 形态,供脚本/联调使用)",
|
||||||
description = "与 multipart 那个等价,只是把压缩包换成 base64 传 —— "
|
description = "与 multipart 那个等价,只是把压缩包换成 base64 传 —— "
|
||||||
+ "方便自动化脚本与排障,不必手搓 multipart")
|
+ "方便自动化脚本与排障,不必手搓 multipart")
|
||||||
@@ -133,7 +133,7 @@ public class CleanupController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@PostMapping("/{id}/import")
|
@PostMapping("/{id}/import")
|
||||||
@RequirePerm(PermissionDef.TASK_MANAGE)
|
@RequirePerm(PermissionDef.CLEANUP_RUN)
|
||||||
@Operation(summary = "把整理结果导入为正式任务",
|
@Operation(summary = "把整理结果导入为正式任务",
|
||||||
description = "名册 + 材料一次成型。**幂等**:同一个整理任务重复导入不会建出两个任务 —— "
|
description = "名册 + 材料一次成型。**幂等**:同一个整理任务重复导入不会建出两个任务 —— "
|
||||||
+ "客户手滑点两次是常事,而多出来的任务很难被发现(它会安静地躺在列表里)")
|
+ "客户手滑点两次是常事,而多出来的任务很难被发现(它会安静地躺在列表里)")
|
||||||
@@ -143,7 +143,7 @@ public class CleanupController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@PostMapping(value = "/parse-preview", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
|
@PostMapping(value = "/parse-preview", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
|
||||||
@RequirePerm(PermissionDef.TASK_MANAGE)
|
@RequirePerm(PermissionDef.CLEANUP_RUN)
|
||||||
@Operation(summary = "只解析不落库(排障用)",
|
@Operation(summary = "只解析不落库(排障用)",
|
||||||
description = "返回每个路径解析出的姓名/证件号(脱敏)/材料码/班级线索。"
|
description = "返回每个路径解析出的姓名/证件号(脱敏)/材料码/班级线索。"
|
||||||
+ "调规则或排查「为什么这个人没认出来」时用它 —— "
|
+ "调规则或排查「为什么这个人没认出来」时用它 —— "
|
||||||
|
|||||||
+43
@@ -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.file.storage.StorageService;
|
||||||
import cn.tianbaotong.fill.module.material.dto.MaterialDefVO;
|
import cn.tianbaotong.fill.module.material.dto.MaterialDefVO;
|
||||||
import cn.tianbaotong.fill.module.material.service.MaterialDefService;
|
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.entity.CollectRoster;
|
||||||
import cn.tianbaotong.fill.module.roster.mapper.CollectRosterMapper;
|
import cn.tianbaotong.fill.module.roster.mapper.CollectRosterMapper;
|
||||||
import cn.tianbaotong.fill.module.task.entity.CollectTask;
|
import cn.tianbaotong.fill.module.task.entity.CollectTask;
|
||||||
@@ -114,6 +116,7 @@ public class ExportService {
|
|||||||
private final SubmissionValidator validator;
|
private final SubmissionValidator validator;
|
||||||
private final MaterialDefService materialDefService;
|
private final MaterialDefService materialDefService;
|
||||||
private final StorageService storageService;
|
private final StorageService storageService;
|
||||||
|
private final NotificationService notificationService;
|
||||||
private final FillProperties props;
|
private final FillProperties props;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -682,6 +685,43 @@ public class ExportService {
|
|||||||
log.info("导出完成 exportId={} 人数={} 分卷={} 包体={}MB(原始 {}MB)",
|
log.info("导出完成 exportId={} 人数={} 分卷={} 包体={}MB(原始 {}MB)",
|
||||||
exportId, total, volumes.size(), round1(size / 1024.0 / 1024.0),
|
exportId, total, volumes.size(), round1(size / 1024.0 / 1024.0),
|
||||||
round1(written / 1024.0 / 1024.0));
|
round1(written / 1024.0 / 1024.0));
|
||||||
|
|
||||||
|
// ★ 通知发起人(《08》F-103):打包是异步的(几百份材料要十几秒到几分钟),
|
||||||
|
// 在此之前用户只能守着进度条 —— 而 `collect_message` 的 bizType 6
|
||||||
|
// (导出完成)一直没有代码写过,消息中心那个分类永远是空的。
|
||||||
|
notifyExportFinish(exportId, true, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 导出结束的通知(成功与失败都发)。
|
||||||
|
*
|
||||||
|
* <p>⚠️ **正文里不放下载链接**,只让他去「导出中心」取。
|
||||||
|
* 理由不是省事:七牛是私有空间,链接是**签名 URL**,而站内信是落库的 ——
|
||||||
|
* 把签名链接写进消息正文就等于"URL 入库",与《AGENTS.md》里
|
||||||
|
* 「URL 不能入库、不能发出去让人明天再打开」直接冲突(那个链接几十分钟后就失效)。
|
||||||
|
* 内置模板里原本写着 {@code {下载链接}},本轮一并改掉了(见《08》F-103)。
|
||||||
|
*/
|
||||||
|
private void notifyExportFinish(Long exportId, boolean ok, String failReason) {
|
||||||
|
try {
|
||||||
|
CollectExportTask t = exportTaskMapper.selectById(exportId);
|
||||||
|
if (t == null || t.getCreateBy() == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
notificationService.notify(NotificationService.NotifyRequest.ofTemplate(
|
||||||
|
t.getCreateBy(), CollectMessage.BIZ_EXPORT,
|
||||||
|
"EXPORT_DONE",
|
||||||
|
NotificationService.NotifyRequest.vars(
|
||||||
|
"导出包名", t.getExportName() == null ? "导出包" : t.getExportName()),
|
||||||
|
ok ? "导出完成" : "导出失败",
|
||||||
|
CollectMessage.LINK_NONE, null, null)
|
||||||
|
.withExtra(ok
|
||||||
|
? "共 " + nzInt(t.getFileCount()) + " 份材料,请到「导出中心」下载。"
|
||||||
|
: "原因:" + (failReason == null ? "打包失败" : failReason)
|
||||||
|
+ "。请到「导出中心」查看后重试。"));
|
||||||
|
} catch (RuntimeException e) {
|
||||||
|
// 通知失败不影响导出结果本身(与审核通知同一口径)
|
||||||
|
log.warn("导出完成的通知没发出去 exportId={}", exportId, e);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private long nzLong(Long v) {
|
private long nzLong(Long v) {
|
||||||
@@ -704,6 +744,9 @@ public class ExportService {
|
|||||||
} catch (RuntimeException inner) {
|
} catch (RuntimeException inner) {
|
||||||
log.error("标记导出失败时又出错 exportId={}", exportId, inner);
|
log.error("标记导出失败时又出错 exportId={}", exportId, inner);
|
||||||
}
|
}
|
||||||
|
// 失败也要通知:否则用户会一直等一个永远不会完成的包
|
||||||
|
notifyExportFinish(exportId, false,
|
||||||
|
e.getMessage() == null ? e.getClass().getSimpleName() : e.getMessage());
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
+1
-1
@@ -42,7 +42,7 @@ public class FormSchemaController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@PutMapping
|
@PutMapping
|
||||||
@RequirePerm(PermissionDef.TASK_MANAGE)
|
@RequirePerm(PermissionDef.FORM_MANAGE)
|
||||||
@Operation(summary = "保存任务表单定义(版本化)",
|
@Operation(summary = "保存任务表单定义(版本化)",
|
||||||
description = "每次保存插入一个新版本,旧版本保留。baseVersion 与当前最新不符时返回 40025,"
|
description = "每次保存插入一个新版本,旧版本保留。baseVersion 与当前最新不符时返回 40025,"
|
||||||
+ "并在 data 里带上最新定义供前端做差异合并(响应格式:{code:40025, data:最新schema})。"
|
+ "并在 data 里带上最新定义供前端做差异合并(响应格式:{code:40025, data:最新schema})。"
|
||||||
|
|||||||
+36
@@ -90,6 +90,42 @@ public class FormSchemaService {
|
|||||||
|
|
||||||
// ── 保存 ──────────────────────────────────────
|
// ── 保存 ──────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 用一份**已经存在**的 schemaJson 作为新版本保存(规则模板套用 / 任务克隆场景)。
|
||||||
|
*
|
||||||
|
* <p>与 {@link #save(Long, FormSchemaSaveReq)} 的区别:内容<b>不再校验与归一化</b> ——
|
||||||
|
* 它当初就是从某个任务按本服务的规则存出来的,再走一遍"解析 → 对象 → 序列化"
|
||||||
|
* 只会丢掉没建模的字段(与 {@code TaskCloneService} 复制表单是同一个理由)。
|
||||||
|
* <b>状态检查与版本管理保留</b>(与手工保存同一套,两处必须一致)。
|
||||||
|
*
|
||||||
|
* @return 新版本号
|
||||||
|
*/
|
||||||
|
@Transactional(rollbackFor = Exception.class)
|
||||||
|
public int saveRawVersion(Long taskId, String schemaJson) {
|
||||||
|
if (schemaJson == null || schemaJson.isBlank()) {
|
||||||
|
throw new BizException(ErrorCode.VALIDATION_BLOCKED, "表单定义为空,无可保存内容");
|
||||||
|
}
|
||||||
|
CollectTask task = requireTask(taskId);
|
||||||
|
int status = task.getStatus() == null ? -1 : task.getStatus();
|
||||||
|
if (!EDITABLE_TASK_STATUS.contains(status)) {
|
||||||
|
throw new BizException(ErrorCode.VALIDATION_BLOCKED,
|
||||||
|
"任务已" + statusText(status) + ",表单不可再改");
|
||||||
|
}
|
||||||
|
int nextVersion = latestVersion(taskId) + 1;
|
||||||
|
formSchemaMapper.update(null, Wrappers.<CollectFormSchema>lambdaUpdate()
|
||||||
|
.eq(CollectFormSchema::getTaskId, taskId)
|
||||||
|
.set(CollectFormSchema::getIsCurrent, CollectFormSchema.HISTORY));
|
||||||
|
CollectFormSchema entity = new CollectFormSchema();
|
||||||
|
entity.setTaskId(taskId);
|
||||||
|
entity.setVersion(nextVersion);
|
||||||
|
entity.setSchemaJson(schemaJson);
|
||||||
|
entity.setIsCurrent(CollectFormSchema.CURRENT);
|
||||||
|
formSchemaMapper.insert(entity);
|
||||||
|
log.info("任务 {}({})表单按已存定义保存为新版本 v{}(套用模板/克隆)",
|
||||||
|
taskId, task.getTaskCode(), nextVersion);
|
||||||
|
return nextVersion;
|
||||||
|
}
|
||||||
|
|
||||||
@Transactional(rollbackFor = Exception.class)
|
@Transactional(rollbackFor = Exception.class)
|
||||||
public FormSchemaVO save(Long taskId, FormSchemaSaveReq req) {
|
public FormSchemaVO save(Long taskId, FormSchemaSaveReq req) {
|
||||||
CollectTask task = requireTask(taskId);
|
CollectTask task = requireTask(taskId);
|
||||||
|
|||||||
+43
@@ -78,6 +78,49 @@ public interface CollectJoinRequestMapper extends BaseMapper<CollectJoinRequest>
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 该通知谁去审批这条申请(《08》F-102:以前一个都不通知)。
|
||||||
|
*
|
||||||
|
* <p>三类人,去重后返回:
|
||||||
|
* <ol>
|
||||||
|
* <li>这个任务覆盖班级的**班主任**({@code collect_task_org.head_user_id});</li>
|
||||||
|
* <li>这些班级的**副班主任**({@code deputy_user_ids} 存的是
|
||||||
|
* {@code ,1,2,} 这种带逗号的串,所以要用 {@code LIKE '%,id,%'} 判);</li>
|
||||||
|
* <li>本租户的**学校管理员** —— 兜底:任务没配班主任时也得有人管这件事。</li>
|
||||||
|
* </ol>
|
||||||
|
*
|
||||||
|
* <p>⚠️ 不去查"谁有 join:audit 权限":权限是"能不能做",收件人是"该谁做"。
|
||||||
|
* 一个学校有 50 个班主任,把申请推给全部 50 个等于推给没人。
|
||||||
|
*
|
||||||
|
* <p>⚠️ {@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<Long> auditUserIdsOfTask(@Param("tenantId") Long tenantId, @Param("taskId") Long taskId);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 审批通过后回写申请单。
|
* 审批通过后回写申请单。
|
||||||
*
|
*
|
||||||
|
|||||||
+93
-6
@@ -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.dto.JoinDtos;
|
||||||
import cn.tianbaotong.fill.module.join.entity.CollectJoinRequest;
|
import cn.tianbaotong.fill.module.join.entity.CollectJoinRequest;
|
||||||
import cn.tianbaotong.fill.module.join.mapper.CollectJoinRequestMapper;
|
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.entity.CollectRoster;
|
||||||
import cn.tianbaotong.fill.module.roster.mapper.CollectRosterMapper;
|
import cn.tianbaotong.fill.module.roster.mapper.CollectRosterMapper;
|
||||||
import cn.tianbaotong.fill.module.task.entity.CollectTask;
|
import cn.tianbaotong.fill.module.task.entity.CollectTask;
|
||||||
@@ -74,6 +76,7 @@ public class JoinService {
|
|||||||
private final TaskMapper taskMapper;
|
private final TaskMapper taskMapper;
|
||||||
private final UserMapper userMapper;
|
private final UserMapper userMapper;
|
||||||
private final SensitiveCodec codec;
|
private final SensitiveCodec codec;
|
||||||
|
private final NotificationService notificationService;
|
||||||
|
|
||||||
// ── 学生侧 ─────────────────────────────────────
|
// ── 学生侧 ─────────────────────────────────────
|
||||||
|
|
||||||
@@ -155,6 +158,7 @@ public class JoinService {
|
|||||||
joinMapper.insert(r);
|
joinMapper.insert(r);
|
||||||
|
|
||||||
log.info("加入申请已提交:task={} request={} name={}", req.getTaskId(), r.getId(), name);
|
log.info("加入申请已提交:task={} request={} name={}", req.getTaskId(), r.getId(), name);
|
||||||
|
notifyAuditors(task, r, name);
|
||||||
|
|
||||||
JoinDtos.SubmitVO vo = new JoinDtos.SubmitVO();
|
JoinDtos.SubmitVO vo = new JoinDtos.SubmitVO();
|
||||||
vo.setRequestId(r.getId());
|
vo.setRequestId(r.getId());
|
||||||
@@ -393,20 +397,82 @@ public class JoinService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
log.info("加入申请通过:request={} roster={} org={} by={}", id, row.getId(), orgId, uid);
|
log.info("加入申请通过:request={} roster={} org={} by={}", id, row.getId(), orgId, uid);
|
||||||
// ⚠️ 通知(M5-12)还没做:只能靠老师口头/群里说一声。
|
|
||||||
// 这里如实回 false,**不为了让契约好看而写 true**(同驳回接口的口径)。
|
// ★ 通知学生(《08》F-102):以前这里只写一行 log.warn,
|
||||||
log.warn("加入申请已通过但**没有发出通知**(通知渠道未实现):request={} 需人工告知学生", id);
|
// 学生要自己反复进小程序看结果 —— 而这条链路的另一端(通知渠道)
|
||||||
|
// 早在 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();
|
JoinDtos.ApproveVO vo = new JoinDtos.ApproveVO();
|
||||||
vo.setRequestId(id);
|
vo.setRequestId(id);
|
||||||
vo.setStatus(CollectJoinRequest.STATUS_APPROVED);
|
vo.setStatus(CollectJoinRequest.STATUS_APPROVED);
|
||||||
vo.setRosterId(row.getId());
|
vo.setRosterId(row.getId());
|
||||||
vo.setRosterCreated(true);
|
vo.setRosterCreated(true);
|
||||||
vo.setNotifySent(false);
|
// ⚠️ 语义与驳回接口一致:这是"**微信推送**发出去了没有"。
|
||||||
|
// 站内信一定写得进去,所以学生一定能在消息中心看到结果。
|
||||||
|
vo.setNotifySent(notifySent);
|
||||||
vo.setCanFillNow(r.getApplicantUid() != null);
|
vo.setCanFillNow(r.getApplicantUid() != null);
|
||||||
return vo;
|
return vo;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 通知该审批这条申请的人(班主任 / 副班主任 / 学校管理员兜底)。
|
||||||
|
*
|
||||||
|
* <p>《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 让管理员手选)。 */
|
/** 自述班级 → 组织 id(按名字精确或包含匹配;命中多个就不猜,返回 null 让管理员手选)。 */
|
||||||
private Long matchOrgByName(String orgHint) {
|
private Long matchOrgByName(String orgHint) {
|
||||||
if (orgHint == null || orgHint.isBlank()) {
|
if (orgHint == null || orgHint.isBlank()) {
|
||||||
@@ -449,12 +515,33 @@ public class JoinService {
|
|||||||
throw new BizException(ErrorCode.JOIN_STATUS_CHANGED);
|
throw new BizException(ErrorCode.JOIN_STATUS_CHANGED);
|
||||||
}
|
}
|
||||||
log.info("加入申请驳回:request={} by={} reason={}", id, uid, reason);
|
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();
|
JoinDtos.RejectVO vo = new JoinDtos.RejectVO();
|
||||||
vo.setRequestId(id);
|
vo.setRequestId(id);
|
||||||
vo.setStatus(CollectJoinRequest.STATUS_REJECTED);
|
vo.setStatus(CollectJoinRequest.STATUS_REJECTED);
|
||||||
vo.setNotifySent(false);
|
vo.setNotifySent(notifySent);
|
||||||
return vo;
|
return vo;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+50
-13
@@ -1,12 +1,15 @@
|
|||||||
package cn.tianbaotong.fill.module.log.controller;
|
package cn.tianbaotong.fill.module.log.controller;
|
||||||
|
|
||||||
import cn.tianbaotong.fill.common.api.ApiResult;
|
import cn.tianbaotong.fill.common.api.ApiResult;
|
||||||
|
import cn.tianbaotong.fill.common.api.ErrorCode;
|
||||||
import cn.tianbaotong.fill.common.api.PageResult;
|
import cn.tianbaotong.fill.common.api.PageResult;
|
||||||
import cn.tianbaotong.fill.common.context.LoginUser;
|
import cn.tianbaotong.fill.common.context.LoginUser;
|
||||||
import cn.tianbaotong.fill.common.context.UserContext;
|
import cn.tianbaotong.fill.common.context.UserContext;
|
||||||
|
import cn.tianbaotong.fill.common.exception.BizException;
|
||||||
import cn.tianbaotong.fill.module.log.dto.LogDtos;
|
import cn.tianbaotong.fill.module.log.dto.LogDtos;
|
||||||
import cn.tianbaotong.fill.module.log.mapper.LoginLogMapper;
|
import cn.tianbaotong.fill.module.log.mapper.LoginLogMapper;
|
||||||
import cn.tianbaotong.fill.module.log.mapper.OperationLogMapper;
|
import cn.tianbaotong.fill.module.log.mapper.OperationLogMapper;
|
||||||
|
import cn.tianbaotong.fill.security.PermissionChecker;
|
||||||
import cn.tianbaotong.fill.security.PermissionDef;
|
import cn.tianbaotong.fill.security.PermissionDef;
|
||||||
import cn.tianbaotong.fill.security.RequirePerm;
|
import cn.tianbaotong.fill.security.RequirePerm;
|
||||||
import io.swagger.v3.oas.annotations.Operation;
|
import io.swagger.v3.oas.annotations.Operation;
|
||||||
@@ -25,8 +28,8 @@ import java.util.List;
|
|||||||
/**
|
/**
|
||||||
* 日志查询(《03-接口设计》§13.4)。
|
* 日志查询(《03-接口设计》§13.4)。
|
||||||
*
|
*
|
||||||
* <p>权限用 {@code log:viewTenant}(本租户操作日志)。
|
* <p>权限是**两个码的任一个**:{@code log:viewTenant}(本租户操作日志,学校管理员/教务处)
|
||||||
* 平台运维要看全量日志时用 {@code log:viewAll} —— 那是另一个接口(P2)。
|
* 或 {@code log:viewAll}(全量,平台运营)。范围由 {@link #logScopeTenantId} 决定。
|
||||||
* 日志里含**别人的操作轨迹与 IP**,不是所有管理员都该看到。
|
* 日志里含**别人的操作轨迹与 IP**,不是所有管理员都该看到。
|
||||||
*
|
*
|
||||||
* <p>⚠️ 一期只做**查询**,不做导出:把操作日志导出成一个 Excel
|
* <p>⚠️ 一期只做**查询**,不做导出:把操作日志导出成一个 Excel
|
||||||
@@ -41,15 +44,19 @@ public class LogController {
|
|||||||
|
|
||||||
private final OperationLogMapper operationLogMapper;
|
private final OperationLogMapper operationLogMapper;
|
||||||
private final LoginLogMapper loginLogMapper;
|
private final LoginLogMapper loginLogMapper;
|
||||||
|
private final PermissionChecker permissionChecker;
|
||||||
|
|
||||||
@GetMapping("/operations")
|
@GetMapping("/operations")
|
||||||
@RequirePerm(PermissionDef.LOG_VIEW_TENANT)
|
@RequirePerm({PermissionDef.LOG_VIEW_TENANT, PermissionDef.LOG_VIEW_ALL})
|
||||||
@Operation(summary = "操作日志",
|
@Operation(summary = "操作日志",
|
||||||
description = "Query:`operatorId` `module` `action` `targetType` `targetId` `from` `to` + 分页。"
|
description = "Query:`tenantId` `operatorId` `module` `action` `targetType` `targetId` `from` `to` + 分页。"
|
||||||
+ "**按 targetType+targetId 查**是这个接口最重要的用法 —— "
|
+ "**按 targetType+targetId 查**是这个接口最重要的用法 —— "
|
||||||
+ "「这张名册项的证件号被谁从什么改成了什么」要靠它回答,"
|
+ "「这张名册项的证件号被谁从什么改成了什么」要靠它回答,"
|
||||||
+ "而不是拿 request_param 全文搜(那会把「改了它」和「顺带提到它」混在一起)")
|
+ "而不是拿 request_param 全文搜(那会把「改了它」和「顺带提到它」混在一起)。"
|
||||||
|
+ "⚠️ 范围:只有 `log:viewAll`(平台运营)能跨租户看**全量**,"
|
||||||
|
+ "也只有它能用 `tenantId` 指定某所学校;学校管理员恒为本租户")
|
||||||
public ApiResult<PageResult<LogDtos.OperationLogVO>> operations(
|
public ApiResult<PageResult<LogDtos.OperationLogVO>> operations(
|
||||||
|
@RequestParam(required = false) Long tenantId,
|
||||||
@RequestParam(required = false) Long operatorId,
|
@RequestParam(required = false) Long operatorId,
|
||||||
@RequestParam(required = false) String module,
|
@RequestParam(required = false) String module,
|
||||||
@RequestParam(required = false) String action,
|
@RequestParam(required = false) String action,
|
||||||
@@ -59,20 +66,48 @@ public class LogController {
|
|||||||
@RequestParam(required = false) String to,
|
@RequestParam(required = false) String to,
|
||||||
@RequestParam(defaultValue = "1") int pageNum,
|
@RequestParam(defaultValue = "1") int pageNum,
|
||||||
@RequestParam(defaultValue = "20") int pageSize) {
|
@RequestParam(defaultValue = "20") int pageSize) {
|
||||||
LoginUser me = UserContext.require();
|
Long scope = logScopeTenantId(tenantId);
|
||||||
int pn = Math.max(1, pageNum);
|
int pn = Math.max(1, pageNum);
|
||||||
int ps = Math.min(Math.max(1, pageSize), 200);
|
int ps = Math.min(Math.max(1, pageSize), 200);
|
||||||
LocalDateTime f = parseFrom(from);
|
LocalDateTime f = parseFrom(from);
|
||||||
LocalDateTime t = parseTo(to);
|
LocalDateTime t = parseTo(to);
|
||||||
|
|
||||||
List<LogDtos.OperationLogVO> list = operationLogMapper.listOperations(
|
List<LogDtos.OperationLogVO> list = operationLogMapper.listOperations(
|
||||||
me.tenantId(), operatorId, module, action, targetType, targetId,
|
scope, operatorId, module, action, targetType, targetId,
|
||||||
f, t, ps, (pn - 1) * ps);
|
f, t, ps, (pn - 1) * ps);
|
||||||
long total = operationLogMapper.countOperations(
|
long total = operationLogMapper.countOperations(
|
||||||
me.tenantId(), operatorId, module, action, targetType, targetId, f, t);
|
scope, operatorId, module, action, targetType, targetId, f, t);
|
||||||
return ApiResult.ok(PageResult.of(list, total, pn, ps));
|
return ApiResult.ok(PageResult.of(list, total, pn, ps));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 日志查询的<b>租户范围</b>:{@code null} = 全量(跨租户)。
|
||||||
|
*
|
||||||
|
* <p>这是"平台运营看不到任何日志"那个 bug 的修法(2026-10-09):
|
||||||
|
* 以前控制器恒传 {@code me.tenantId()},而平台运营的 tenantId 是 <b>0</b>,
|
||||||
|
* 日志却都挂在各租户下 → 列表永远空白、且不报错。
|
||||||
|
* 而 PRD §3.2 对平台运营要求的恰恰是「<b>全量</b>操作日志 ✅」。
|
||||||
|
*
|
||||||
|
* <p>规则三条:
|
||||||
|
* <ol>
|
||||||
|
* <li>有 {@code log:viewAll}(平台运营):<b>默认全量</b>;显式传 {@code tenantId} 时只看那一所;</li>
|
||||||
|
* <li>只有 {@code log:viewTenant}(学校管理员、教务处):<b>恒为本租户</b>;</li>
|
||||||
|
* <li>学校管理员传了**别的**租户 id → 直接 {@code 40301}。
|
||||||
|
* <b>不静默忽略</b>:静默忽略会让人以为"我按这个单位筛过了",其实没筛 ——
|
||||||
|
* 而这一屏是拿去回答合规问题的,不能有这种"看起来筛了"的错觉。</li>
|
||||||
|
* </ol>
|
||||||
|
*/
|
||||||
|
private Long logScopeTenantId(Long requested) {
|
||||||
|
LoginUser me = UserContext.require();
|
||||||
|
if (permissionChecker.has(PermissionDef.LOG_VIEW_ALL)) {
|
||||||
|
return requested;
|
||||||
|
}
|
||||||
|
if (requested != null && !requested.equals(me.tenantId())) {
|
||||||
|
throw new BizException(ErrorCode.FORBIDDEN, "只能查看本单位的日志");
|
||||||
|
}
|
||||||
|
return me.tenantId();
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 日期入参解析。
|
* 日期入参解析。
|
||||||
*
|
*
|
||||||
@@ -108,13 +143,15 @@ public class LogController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@GetMapping("/logins")
|
@GetMapping("/logins")
|
||||||
@RequirePerm(PermissionDef.LOG_VIEW_TENANT)
|
@RequirePerm({PermissionDef.LOG_VIEW_TENANT, PermissionDef.LOG_VIEW_ALL})
|
||||||
@Operation(summary = "登录日志",
|
@Operation(summary = "登录日志",
|
||||||
description = "Query:`userId` `userType` `result` `from` `to` + 分页。"
|
description = "Query:`tenantId` `userId` `userType` `result` `from` `to` + 分页。"
|
||||||
|
+ "范围同操作日志(`log:viewAll` 可跨租户,学校管理员恒为本租户)。"
|
||||||
+ "⚠️ 一期的登录日志**只有 PC 账号密码登录**(`loginType=PASSWORD`)—— "
|
+ "⚠️ 一期的登录日志**只有 PC 账号密码登录**(`loginType=PASSWORD`)—— "
|
||||||
+ "学生从小程序/H5 进来走的是微信授权,目前不写这张表(见 F-31)。"
|
+ "学生从小程序/H5 进来走的是微信授权,目前不写这张表(见 F-31)。"
|
||||||
+ "所以「学生登录记录为空」是已知情况,不是查询条件写错了")
|
+ "所以「学生登录记录为空」是已知情况,不是查询条件写错了")
|
||||||
public ApiResult<PageResult<LogDtos.LoginLogVO>> logins(
|
public ApiResult<PageResult<LogDtos.LoginLogVO>> logins(
|
||||||
|
@RequestParam(required = false) Long tenantId,
|
||||||
@RequestParam(required = false) Long userId,
|
@RequestParam(required = false) Long userId,
|
||||||
@RequestParam(required = false) Integer userType,
|
@RequestParam(required = false) Integer userType,
|
||||||
@RequestParam(required = false) Boolean result,
|
@RequestParam(required = false) Boolean result,
|
||||||
@@ -122,7 +159,7 @@ public class LogController {
|
|||||||
@RequestParam(required = false) String to,
|
@RequestParam(required = false) String to,
|
||||||
@RequestParam(defaultValue = "1") int pageNum,
|
@RequestParam(defaultValue = "1") int pageNum,
|
||||||
@RequestParam(defaultValue = "20") int pageSize) {
|
@RequestParam(defaultValue = "20") int pageSize) {
|
||||||
LoginUser me = UserContext.require();
|
Long scope = logScopeTenantId(tenantId);
|
||||||
int pn = Math.max(1, pageNum);
|
int pn = Math.max(1, pageNum);
|
||||||
int ps = Math.min(Math.max(1, pageSize), 200);
|
int ps = Math.min(Math.max(1, pageSize), 200);
|
||||||
LocalDateTime f = parseFrom(from);
|
LocalDateTime f = parseFrom(from);
|
||||||
@@ -131,8 +168,8 @@ public class LogController {
|
|||||||
Integer ok = result == null ? null : (result ? 1 : 0);
|
Integer ok = result == null ? null : (result ? 1 : 0);
|
||||||
|
|
||||||
List<LogDtos.LoginLogVO> list = loginLogMapper.listLogins(
|
List<LogDtos.LoginLogVO> list = loginLogMapper.listLogins(
|
||||||
me.tenantId(), userId, userType, ok, f, t, ps, (pn - 1) * ps);
|
scope, userId, userType, ok, f, t, ps, (pn - 1) * ps);
|
||||||
long total = loginLogMapper.countLogins(me.tenantId(), userId, userType, ok, f, t);
|
long total = loginLogMapper.countLogins(scope, userId, userType, ok, f, t);
|
||||||
return ApiResult.ok(PageResult.of(list, total, pn, ps));
|
return ApiResult.ok(PageResult.of(list, total, pn, ps));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+8
-2
@@ -60,7 +60,10 @@ public interface LoginLogMapper extends BaseMapper<SysLoginLog> {
|
|||||||
l.create_time AS loginTime
|
l.create_time AS loginTime
|
||||||
FROM sys_login_log l
|
FROM sys_login_log l
|
||||||
LEFT JOIN sys_user u ON u.id = l.user_id AND u.deleted = 0
|
LEFT JOIN sys_user u ON u.id = l.user_id AND u.deleted = 0
|
||||||
WHERE l.tenant_id = #{tenantId}
|
WHERE 1 = 1
|
||||||
|
<if test="tenantId != null">
|
||||||
|
AND l.tenant_id = #{tenantId}
|
||||||
|
</if>
|
||||||
<if test="userId != null">
|
<if test="userId != null">
|
||||||
AND l.user_id = #{userId}
|
AND l.user_id = #{userId}
|
||||||
</if>
|
</if>
|
||||||
@@ -96,7 +99,10 @@ public interface LoginLogMapper extends BaseMapper<SysLoginLog> {
|
|||||||
SELECT COUNT(*)
|
SELECT COUNT(*)
|
||||||
FROM sys_login_log l
|
FROM sys_login_log l
|
||||||
LEFT JOIN sys_user u ON u.id = l.user_id AND u.deleted = 0
|
LEFT JOIN sys_user u ON u.id = l.user_id AND u.deleted = 0
|
||||||
WHERE l.tenant_id = #{tenantId}
|
WHERE 1 = 1
|
||||||
|
<if test="tenantId != null">
|
||||||
|
AND l.tenant_id = #{tenantId}
|
||||||
|
</if>
|
||||||
<if test="userId != null">
|
<if test="userId != null">
|
||||||
AND l.user_id = #{userId}
|
AND l.user_id = #{userId}
|
||||||
</if>
|
</if>
|
||||||
|
|||||||
+17
-2
@@ -18,6 +18,15 @@ import java.util.List;
|
|||||||
* <p>查询走手写 SQL 并 {@code @InterceptorIgnore} + 显式 {@code tenant_id}:
|
* <p>查询走手写 SQL 并 {@code @InterceptorIgnore} + 显式 {@code tenant_id}:
|
||||||
* 靠拦截器隐式补条件会让"查不到数据"和"确实没有数据"分不清,
|
* 靠拦截器隐式补条件会让"查不到数据"和"确实没有数据"分不清,
|
||||||
* 而日志查询恰恰是要能对客户说清"这段时间有没有人动过这张名册"的场景。
|
* 而日志查询恰恰是要能对客户说清"这段时间有没有人动过这张名册"的场景。
|
||||||
|
*
|
||||||
|
* <p>⚠️ {@code tenantId} 是<b>可选</b>条件({@code <if test="tenantId != null">}):
|
||||||
|
* 契约 §13.4 本来就把 {@code tenantId} 定义成查询参数。不传 = <b>全量</b>,
|
||||||
|
* 只有平台运营({@code log:viewAll})能走到这一支;学校管理员由
|
||||||
|
* {@code LogController} 恒填本租户。
|
||||||
|
*
|
||||||
|
* <p>以前这里写死 {@code l.tenant_id = #{tenantId}}、控制器恒传 {@code me.tenantId()} ——
|
||||||
|
* 而平台运营的 tenantId 是 0,于是它<b>一条日志都看不到</b>(列表空白,
|
||||||
|
* 没有任何报错),而 PRD §3.2 对平台运营要求的恰恰是「<b>全量</b>操作日志 ✅」。
|
||||||
*/
|
*/
|
||||||
public interface OperationLogMapper extends BaseMapper<SysOperationLog> {
|
public interface OperationLogMapper extends BaseMapper<SysOperationLog> {
|
||||||
|
|
||||||
@@ -57,7 +66,10 @@ public interface OperationLogMapper extends BaseMapper<SysOperationLog> {
|
|||||||
l.create_time AS createTime
|
l.create_time AS createTime
|
||||||
FROM sys_operation_log l
|
FROM sys_operation_log l
|
||||||
LEFT JOIN sys_user u ON u.id = l.user_id AND u.deleted = 0
|
LEFT JOIN sys_user u ON u.id = l.user_id AND u.deleted = 0
|
||||||
WHERE l.tenant_id = #{tenantId}
|
WHERE 1 = 1
|
||||||
|
<if test="tenantId != null">
|
||||||
|
AND l.tenant_id = #{tenantId}
|
||||||
|
</if>
|
||||||
<if test="operatorId != null">
|
<if test="operatorId != null">
|
||||||
AND l.user_id = #{operatorId}
|
AND l.user_id = #{operatorId}
|
||||||
</if>
|
</if>
|
||||||
@@ -100,7 +112,10 @@ public interface OperationLogMapper extends BaseMapper<SysOperationLog> {
|
|||||||
<script>
|
<script>
|
||||||
SELECT COUNT(*)
|
SELECT COUNT(*)
|
||||||
FROM sys_operation_log l
|
FROM sys_operation_log l
|
||||||
WHERE l.tenant_id = #{tenantId}
|
WHERE 1 = 1
|
||||||
|
<if test="tenantId != null">
|
||||||
|
AND l.tenant_id = #{tenantId}
|
||||||
|
</if>
|
||||||
<if test="operatorId != null">
|
<if test="operatorId != null">
|
||||||
AND l.user_id = #{operatorId}
|
AND l.user_id = #{operatorId}
|
||||||
</if>
|
</if>
|
||||||
|
|||||||
+2
@@ -79,6 +79,8 @@ public class OperationLogService {
|
|||||||
/** 截止任务。同理:契约表里没有,但它是不可逆的时点,必须留痕。 */
|
/** 截止任务。同理:契约表里没有,但它是不可逆的时点,必须留痕。 */
|
||||||
public static final String ACTION_CLOSE_TASK = "CLOSE_TASK";
|
public static final String ACTION_CLOSE_TASK = "CLOSE_TASK";
|
||||||
public static final String ACTION_ARCHIVE_TASK = "ARCHIVE_TASK";
|
public static final String ACTION_ARCHIVE_TASK = "ARCHIVE_TASK";
|
||||||
|
/** 删除已归档任务(2026-09-30 客户确认,§17.2 D-9):级联软删 + 尽力删文件,必须留痕。 */
|
||||||
|
public static final String ACTION_DELETE_TASK = "DELETE_TASK";
|
||||||
public static final String ACTION_REVOKE_AUDIT = "REVOKE_AUDIT";
|
public static final String ACTION_REVOKE_AUDIT = "REVOKE_AUDIT";
|
||||||
public static final String ACTION_EXPORT_CREATE = "EXPORT_CREATE";
|
public static final String ACTION_EXPORT_CREATE = "EXPORT_CREATE";
|
||||||
public static final String ACTION_EXPORT_DOWNLOAD = "EXPORT_DOWNLOAD";
|
public static final String ACTION_EXPORT_DOWNLOAD = "EXPORT_DOWNLOAD";
|
||||||
|
|||||||
+64
@@ -0,0 +1,64 @@
|
|||||||
|
package cn.tianbaotong.fill.module.log.service;
|
||||||
|
|
||||||
|
import cn.tianbaotong.fill.module.log.entity.SysLoginLog;
|
||||||
|
import cn.tianbaotong.fill.module.log.mapper.LoginLogMapper;
|
||||||
|
import jakarta.servlet.http.HttpServletRequest;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.slf4j.MDC;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 学生侧(微信)登录也写 {@code sys_login_log}(2026-09-30 客户确认补上,见《08》F-31)。
|
||||||
|
*
|
||||||
|
* <h3>为什么此前一直只有 PC 的</h3>
|
||||||
|
*
|
||||||
|
* <p>{@code AuthService.writeLog} 只挂在账号密码登录上,而学生从小程序/H5 进来走的是
|
||||||
|
* 微信授权(无密码),那条链路没人写日志 —— 于是按 §13.4 查"学生登录记录"永远是空的,
|
||||||
|
* 而这在合规上是个缺口("谁在什么时候登录过"必须两类用户都答得出来)。
|
||||||
|
*
|
||||||
|
* <h3>login_type 的取值(与 DDL 注释对齐:1 PC 2 小程序 3 开放接口)</h3>
|
||||||
|
*
|
||||||
|
* <p>小程序与 H5 网页授权**共用 {@code 2}**:DDL 只定义到 3,
|
||||||
|
* 自造新值就是又一处"文档与库不一致"。要区分两端时看 {@code user_agent}
|
||||||
|
* —— H5 是浏览器 UA,小程序是 {@code miniProgram},字段里本来就带着。
|
||||||
|
*
|
||||||
|
* <p>⚠️ 与 PC 侧同样的纪律:**写失败绝不影响登录本身**(否则日志表一满就没人能登录),
|
||||||
|
* 也不记任何凭据(code / openid 都不落库,username 用姓名或 "wx-{uid}")。
|
||||||
|
*/
|
||||||
|
@Slf4j
|
||||||
|
@Service
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class WechatLoginLogger {
|
||||||
|
|
||||||
|
/** DDL:1 PC 2 小程序 3 开放接口。 */
|
||||||
|
public static final int LOGIN_TYPE_WECHAT = 2;
|
||||||
|
|
||||||
|
private final LoginLogMapper loginLogMapper;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 记一条成功的微信侧登录。
|
||||||
|
*
|
||||||
|
* @param displayName 姓名(没绑定名册的新用户可能是 null,此时记 "wx-{uid}")
|
||||||
|
*/
|
||||||
|
public void success(Long tenantId, Long userId, String displayName,
|
||||||
|
HttpServletRequest req) {
|
||||||
|
try {
|
||||||
|
SysLoginLog row = new SysLoginLog();
|
||||||
|
row.setTenantId(tenantId == null ? 0L : tenantId);
|
||||||
|
row.setUserId(userId);
|
||||||
|
String who = displayName == null || displayName.isBlank()
|
||||||
|
? ("wx-" + userId) : displayName;
|
||||||
|
row.setUsername(who.length() <= 64 ? who : who.substring(0, 64));
|
||||||
|
row.setLoginType(LOGIN_TYPE_WECHAT);
|
||||||
|
row.setSuccess(1);
|
||||||
|
row.setFailReason(null);
|
||||||
|
row.setIp(req == null ? null : cn.tianbaotong.fill.support.WebUtil.clientIp(req));
|
||||||
|
row.setUserAgent(req == null ? null : cn.tianbaotong.fill.support.WebUtil.userAgent(req));
|
||||||
|
row.setTraceId(MDC.get("traceId"));
|
||||||
|
loginLogMapper.insertIgnoreTenant(row);
|
||||||
|
} catch (Exception e) { // noqa: BLE001
|
||||||
|
log.warn("写微信侧登录日志失败(不影响登录): {}", e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+4
-4
@@ -47,7 +47,7 @@ public class MaterialDefController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@PostMapping("/tasks/{id}/materials")
|
@PostMapping("/tasks/{id}/materials")
|
||||||
@RequirePerm(PermissionDef.TASK_MANAGE)
|
@RequirePerm(PermissionDef.MATERIAL_MANAGE)
|
||||||
@Operation(summary = "新增材料项",
|
@Operation(summary = "新增材料项",
|
||||||
description = "材料码在任务内唯一;不传名称/别名/压缩档时会按 §2.1 的材料目录补齐默认值。"
|
description = "材料码在任务内唯一;不传名称/别名/压缩档时会按 §2.1 的材料目录补齐默认值。"
|
||||||
+ "压缩档位参数(固定尺寸/分辨率下限/目标DPI/输出格式)由档位决定,客户端不必也不应传")
|
+ "压缩档位参数(固定尺寸/分辨率下限/目标DPI/输出格式)由档位决定,客户端不必也不应传")
|
||||||
@@ -57,7 +57,7 @@ public class MaterialDefController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@PutMapping("/materials/{id}")
|
@PutMapping("/materials/{id}")
|
||||||
@RequirePerm(PermissionDef.TASK_MANAGE)
|
@RequirePerm(PermissionDef.MATERIAL_MANAGE)
|
||||||
@Operation(summary = "修改材料项", description = "材料码不可修改(它是文件的关联键)")
|
@Operation(summary = "修改材料项", description = "材料码不可修改(它是文件的关联键)")
|
||||||
public ApiResult<Void> update(@PathVariable Long id,
|
public ApiResult<Void> update(@PathVariable Long id,
|
||||||
@Valid @RequestBody MaterialDefSaveReq req) {
|
@Valid @RequestBody MaterialDefSaveReq req) {
|
||||||
@@ -66,7 +66,7 @@ public class MaterialDefController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@DeleteMapping("/materials/{id}")
|
@DeleteMapping("/materials/{id}")
|
||||||
@RequirePerm(PermissionDef.TASK_MANAGE)
|
@RequirePerm(PermissionDef.MATERIAL_MANAGE)
|
||||||
@Operation(summary = "删除材料项",
|
@Operation(summary = "删除材料项",
|
||||||
description = "已经上传过文件的材料项不可删除(40027);如果只是不想再收,"
|
description = "已经上传过文件的材料项不可删除(40027);如果只是不想再收,"
|
||||||
+ "请设为选传或加条件显示规则隐藏")
|
+ "请设为选传或加条件显示规则隐藏")
|
||||||
@@ -76,7 +76,7 @@ public class MaterialDefController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@PostMapping("/tasks/{id}/materials/apply-template")
|
@PostMapping("/tasks/{id}/materials/apply-template")
|
||||||
@RequirePerm(PermissionDef.TASK_MANAGE)
|
@RequirePerm(PermissionDef.MATERIAL_MANAGE)
|
||||||
@Operation(summary = "按内置模板初始化材料清单",
|
@Operation(summary = "按内置模板初始化材料清单",
|
||||||
description = "幂等:已存在的材料码会跳过。模板见 §7.10 的三个 TPL_*")
|
description = "幂等:已存在的材料码会跳过。模板见 §7.10 的三个 TPL_*")
|
||||||
public ApiResult<Map<String, Object>> applyTemplate(@PathVariable Long id,
|
public ApiResult<Map<String, Object>> applyTemplate(@PathVariable Long id,
|
||||||
|
|||||||
+12
-6
@@ -48,6 +48,7 @@ public class MpH5AuthController {
|
|||||||
private final MpH5AuthService authService;
|
private final MpH5AuthService authService;
|
||||||
private final JwtService jwtService;
|
private final JwtService jwtService;
|
||||||
private final cn.tianbaotong.fill.config.FillProperties props;
|
private final cn.tianbaotong.fill.config.FillProperties props;
|
||||||
|
private final cn.tianbaotong.fill.module.log.service.WechatLoginLogger wechatLoginLogger;
|
||||||
|
|
||||||
@PublicApi
|
@PublicApi
|
||||||
@GetMapping("/authorize")
|
@GetMapping("/authorize")
|
||||||
@@ -64,7 +65,8 @@ public class MpH5AuthController {
|
|||||||
@Operation(summary = "微信授权回调(匿名)",
|
@Operation(summary = "微信授权回调(匿名)",
|
||||||
description = "code 换 openid → 找/建学生 → 下发会话 → 302 回 H5(token 在 URL fragment 里)")
|
description = "code 换 openid → 找/建学生 → 下发会话 → 302 回 H5(token 在 URL fragment 里)")
|
||||||
public ResponseEntity<Void> callback(@RequestParam(required = false) String code,
|
public ResponseEntity<Void> callback(@RequestParam(required = false) String code,
|
||||||
@RequestParam(required = false) String state) {
|
@RequestParam(required = false) String state,
|
||||||
|
HttpServletRequest request) {
|
||||||
String scene = authService.consumeScene(state);
|
String scene = authService.consumeScene(state);
|
||||||
try {
|
try {
|
||||||
if (code == null || code.isBlank()) {
|
if (code == null || code.isBlank()) {
|
||||||
@@ -72,7 +74,7 @@ public class MpH5AuthController {
|
|||||||
return redirectToApp(null, scene, "你取消了微信授权,请重新进入");
|
return redirectToApp(null, scene, "你取消了微信授权,请重新进入");
|
||||||
}
|
}
|
||||||
String openId = authService.exchangeOpenId(code);
|
String openId = authService.exchangeOpenId(code);
|
||||||
return loginAndRedirect(openId, scene);
|
return loginAndRedirect(openId, scene, request);
|
||||||
} catch (BizException e) {
|
} catch (BizException e) {
|
||||||
// ⚠️ 这个地址是**浏览器直接跳转**过来的。抛业务错误的话,用户看到的是
|
// ⚠️ 这个地址是**浏览器直接跳转**过来的。抛业务错误的话,用户看到的是
|
||||||
// 一屏 {"code":40001,"msg":"..."} 的 JSON —— 一个学生完全无法理解的画面,
|
// 一屏 {"code":40001,"msg":"..."} 的 JSON —— 一个学生完全无法理解的画面,
|
||||||
@@ -99,12 +101,13 @@ public class MpH5AuthController {
|
|||||||
return redirectToApp(null, scene, "打桩登录需要带合法的 scene(t{租户id}...)");
|
return redirectToApp(null, scene, "打桩登录需要带合法的 scene(t{租户id}...)");
|
||||||
}
|
}
|
||||||
SysUser user = authService.stubLogin(tenantId, openid, request);
|
SysUser user = authService.stubLogin(tenantId, openid, request);
|
||||||
return redirectWithToken(user, scene);
|
ResponseEntity<Void> r = redirectWithToken(user, scene, request);
|
||||||
|
return r;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── 内部 ──────────────────────────────────────
|
// ── 内部 ──────────────────────────────────────
|
||||||
|
|
||||||
private ResponseEntity<Void> loginAndRedirect(String openId, String scene) {
|
private ResponseEntity<Void> loginAndRedirect(String openId, String scene, HttpServletRequest request) {
|
||||||
Long tenantId = MpSceneService.tenantIdOf(scene);
|
Long tenantId = MpSceneService.tenantIdOf(scene);
|
||||||
if (tenantId == null) {
|
if (tenantId == null) {
|
||||||
// 没带 scene 就没法定租户:这个人属于哪个学校还不知道。
|
// 没带 scene 就没法定租户:这个人属于哪个学校还不知道。
|
||||||
@@ -112,10 +115,13 @@ public class MpH5AuthController {
|
|||||||
return redirectToApp(null, scene, "请扫描班主任发的二维码进入");
|
return redirectToApp(null, scene, "请扫描班主任发的二维码进入");
|
||||||
}
|
}
|
||||||
SysUser user = authService.resolveUser(tenantId, openId);
|
SysUser user = authService.resolveUser(tenantId, openId);
|
||||||
return redirectWithToken(user, scene);
|
return redirectWithToken(user, scene, request);
|
||||||
}
|
}
|
||||||
|
|
||||||
private ResponseEntity<Void> redirectWithToken(SysUser user, String scene) {
|
private ResponseEntity<Void> redirectWithToken(SysUser user, String scene, HttpServletRequest request) {
|
||||||
|
// 学生侧登录也写 sys_login_log(2026-09-30 补上,F-31):H5 授权回调与打桩登录
|
||||||
|
// 都从这里下发 token,写一次就覆盖两条路径。写失败不影响登录
|
||||||
|
wechatLoginLogger.success(user.getTenantId(), user.getId(), user.getRealName(), request);
|
||||||
String token = jwtService.issue(
|
String token = jwtService.issue(
|
||||||
new LoginUser(user.getId(), user.getTenantId(), List.of(), java.util.Set.of("STUDENT"), 4),
|
new LoginUser(user.getId(), user.getTenantId(), List.of(), java.util.Set.of("STUDENT"), 4),
|
||||||
JwtService.Client.MP);
|
JwtService.Client.MP);
|
||||||
|
|||||||
+5
-3
@@ -41,11 +41,13 @@ public class MpLoginController {
|
|||||||
description = "`{code, scene}` → 令牌 + `needBindPhone` + `boundRoster`。"
|
description = "`{code, scene}` → 令牌 + `needBindPhone` + `boundRoster`。"
|
||||||
+ "⚠️ **scene 是必需的**:它决定你属于哪个学校(统一应用 + scene 识别租户)。"
|
+ "⚠️ **scene 是必需的**:它决定你属于哪个学校(统一应用 + scene 识别租户)。"
|
||||||
+ "scene 可穷举,所以这里**不返回任何业务数据**,只返回"
|
+ "scene 可穷举,所以这里**不返回任何业务数据**,只返回"
|
||||||
+ "「需不需要绑手机」与「你在这次任务里有没有绑定名册」")
|
+ "「需不需要绑手机」与「你在这次任务里有没有绑定名册」。"
|
||||||
public ApiResult<MpLoginService.LoginVO> login(@RequestBody(required = false) Map<String, Object> body) {
|
+ "登录成功会写 sys_login_log(login_type=2,2026-09-30 补上,见 F-31)")
|
||||||
|
public ApiResult<MpLoginService.LoginVO> login(@RequestBody(required = false) Map<String, Object> body,
|
||||||
|
jakarta.servlet.http.HttpServletRequest request) {
|
||||||
String code = str(body == null ? null : body.get("code"));
|
String code = str(body == null ? null : body.get("code"));
|
||||||
String scene = str(body == null ? null : body.get("scene"));
|
String scene = str(body == null ? null : body.get("scene"));
|
||||||
return ApiResult.ok(loginService.login(code, scene));
|
return ApiResult.ok(loginService.login(code, scene, request));
|
||||||
}
|
}
|
||||||
|
|
||||||
@PostMapping("/bind-phone")
|
@PostMapping("/bind-phone")
|
||||||
|
|||||||
+13
@@ -71,6 +71,7 @@ public class MpLoginService {
|
|||||||
private final TaskMapper taskMapper;
|
private final TaskMapper taskMapper;
|
||||||
private final JwtService jwtService;
|
private final JwtService jwtService;
|
||||||
private final SensitiveCodec codec;
|
private final SensitiveCodec codec;
|
||||||
|
private final cn.tianbaotong.fill.module.log.service.WechatLoginLogger wechatLoginLogger;
|
||||||
|
|
||||||
private final HttpClient http = HttpClient.newBuilder()
|
private final HttpClient http = HttpClient.newBuilder()
|
||||||
.connectTimeout(Duration.ofSeconds(10)).build();
|
.connectTimeout(Duration.ofSeconds(10)).build();
|
||||||
@@ -79,6 +80,14 @@ public class MpLoginService {
|
|||||||
|
|
||||||
@Transactional(rollbackFor = Exception.class)
|
@Transactional(rollbackFor = Exception.class)
|
||||||
public LoginVO login(String code, String scene) {
|
public LoginVO login(String code, String scene) {
|
||||||
|
return login(code, scene, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param request 用于登录日志取 IP / UA(可为 null:定时或内部调用没有请求上下文)
|
||||||
|
*/
|
||||||
|
@Transactional(rollbackFor = Exception.class)
|
||||||
|
public LoginVO login(String code, String scene, jakarta.servlet.http.HttpServletRequest request) {
|
||||||
if (code == null || code.isBlank()) {
|
if (code == null || code.isBlank()) {
|
||||||
throw new BizException(ErrorCode.VALIDATION_BLOCKED, "缺少 code");
|
throw new BizException(ErrorCode.VALIDATION_BLOCKED, "缺少 code");
|
||||||
}
|
}
|
||||||
@@ -130,6 +139,10 @@ public class MpLoginService {
|
|||||||
}
|
}
|
||||||
log.info("小程序登录:tenant={} uid={} taskId={} 需绑手机={}",
|
log.info("小程序登录:tenant={} uid={} taskId={} 需绑手机={}",
|
||||||
s.tenantId(), user.getId(), s.taskId(), vo.getNeedBindPhone());
|
s.tenantId(), user.getId(), s.taskId(), vo.getNeedBindPhone());
|
||||||
|
// 学生侧登录也写 sys_login_log(2026-09-30 补上,F-31):
|
||||||
|
// 以前只有 PC 账号密码登录留痕,按 §13.4 查"学生登录记录"永远是空的 —— 合规缺口。
|
||||||
|
// 写失败不影响登录(logger 内部吞掉);tenantId/姓名在这里才齐,所以放 service 而不是 controller
|
||||||
|
wechatLoginLogger.success(user.getTenantId(), user.getId(), user.getRealName(), request);
|
||||||
return vo;
|
return vo;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+25
@@ -32,6 +32,8 @@ public interface UrgeMapper {
|
|||||||
private String orgName;
|
private String orgName;
|
||||||
private Long taskId;
|
private Long taskId;
|
||||||
private String taskName;
|
private String taskName;
|
||||||
|
/** 截止时间:模板里的 {@code {截止日}} 与 {@code {剩余天数}} 靠它(以前没取,那两个变量永远空)。 */
|
||||||
|
private java.time.LocalDateTime endTime;
|
||||||
private Integer submitStatus;
|
private Integer submitStatus;
|
||||||
|
|
||||||
public Long getRosterId() { return rosterId; }
|
public Long getRosterId() { return rosterId; }
|
||||||
@@ -48,6 +50,8 @@ public interface UrgeMapper {
|
|||||||
public void setTaskId(Long v) { this.taskId = v; }
|
public void setTaskId(Long v) { this.taskId = v; }
|
||||||
public String getTaskName() { return taskName; }
|
public String getTaskName() { return taskName; }
|
||||||
public void setTaskName(String v) { this.taskName = v; }
|
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 Integer getSubmitStatus() { return submitStatus; }
|
||||||
public void setSubmitStatus(Integer v) { this.submitStatus = v; }
|
public void setSubmitStatus(Integer v) { this.submitStatus = v; }
|
||||||
}
|
}
|
||||||
@@ -68,6 +72,7 @@ public interface UrgeMapper {
|
|||||||
o.org_name AS orgName,
|
o.org_name AS orgName,
|
||||||
t.id AS taskId,
|
t.id AS taskId,
|
||||||
t.task_name AS taskName,
|
t.task_name AS taskName,
|
||||||
|
t.end_time AS endTime,
|
||||||
r.submit_status AS submitStatus
|
r.submit_status AS submitStatus
|
||||||
FROM collect_roster r
|
FROM collect_roster r
|
||||||
JOIN collect_task t
|
JOIN collect_task t
|
||||||
@@ -105,6 +110,25 @@ public interface UrgeMapper {
|
|||||||
@Param("orgIds") List<Long> orgIds,
|
@Param("orgIds") List<Long> orgIds,
|
||||||
@Param("scopeUserId") Long scopeUserId);
|
@Param("scopeUserId") Long scopeUserId);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 这个任务的提交进度("{已提交}/{应填}"),给模板变量 {@code {提交进度}} 用。
|
||||||
|
*
|
||||||
|
* <p>契约 §12.7 的变量白名单里 {@code {提交进度}} 的适用场景就是催报 ——
|
||||||
|
* 而在此之前它**永远渲染成空**:内置模板写着"目前提交进度 {提交进度}",
|
||||||
|
* 没有任何调用方传过这个变量,于是学生收到的是"目前提交进度 ,请…"。
|
||||||
|
* 一个白名单里有、实际没人填的变量,与"定义了没接上"是同一类问题。
|
||||||
|
*
|
||||||
|
* <p>"已提交"按 {@code submit_status >= 2}(已提交 / 初审中 / 已通过…)算,
|
||||||
|
* 与班主任看板的已交率同一口径。
|
||||||
|
*/
|
||||||
|
@InterceptorIgnore(tenantLine = "true")
|
||||||
|
@Select("""
|
||||||
|
SELECT COUNT(*) FILTER (WHERE r.submit_status >= 2) || '/' || COUNT(*)
|
||||||
|
FROM collect_roster r
|
||||||
|
WHERE r.tenant_id = #{tenantId} AND r.deleted = 0 AND r.task_id = #{taskId}
|
||||||
|
""")
|
||||||
|
String progressOf(@Param("tenantId") Long tenantId, @Param("taskId") Long taskId);
|
||||||
|
|
||||||
/** 一个名册项(定向催报要先确认"这个人归我管")。 */
|
/** 一个名册项(定向催报要先确认"这个人归我管")。 */
|
||||||
@InterceptorIgnore(tenantLine = "true")
|
@InterceptorIgnore(tenantLine = "true")
|
||||||
@Select("""
|
@Select("""
|
||||||
@@ -115,6 +139,7 @@ public interface UrgeMapper {
|
|||||||
o.org_name AS orgName,
|
o.org_name AS orgName,
|
||||||
t.id AS taskId,
|
t.id AS taskId,
|
||||||
t.task_name AS taskName,
|
t.task_name AS taskName,
|
||||||
|
t.end_time AS endTime,
|
||||||
r.submit_status AS submitStatus
|
r.submit_status AS submitStatus
|
||||||
FROM collect_roster r
|
FROM collect_roster r
|
||||||
JOIN collect_task t
|
JOIN collect_task t
|
||||||
|
|||||||
+124
-13
@@ -60,7 +60,21 @@ import java.util.Map;
|
|||||||
@RequiredArgsConstructor
|
@RequiredArgsConstructor
|
||||||
public class NotificationService {
|
public class NotificationService {
|
||||||
|
|
||||||
/** 模板码(契约 §13.6 的表)。 */
|
/**
|
||||||
|
* <b>微信订阅消息</b>的模板码(契约 §13.6 的「模板码与索取时机」表)。
|
||||||
|
*
|
||||||
|
* <p>⚠️ 这套码与 {@link NotifyTemplates} 里那套<b>站内信</b>模板码是
|
||||||
|
* <b>两个命名空间</b>,别混:
|
||||||
|
* <ul>
|
||||||
|
* <li>这一套({@code SUBMIT_SUCCESS} …)是**微信后台建好的订阅消息模板**的键,
|
||||||
|
* 用途有两个:查 {@code collect_subscribe_quota} 的额度、从
|
||||||
|
* {@code fill.mini-program.templates} 里取微信那边的 {@code templateId};</li>
|
||||||
|
* <li>{@link NotifyTemplates} 那套({@code TASK_PUBLISH / AUDIT_REJECT / URGE …})
|
||||||
|
* 是**站内信的文案模板**,管理员在「通知模板」页改的就是它。</li>
|
||||||
|
* </ul>
|
||||||
|
* 两套里 {@code JOIN_APPROVE} 恰好**同名**,纯属巧合 ——
|
||||||
|
* 下一个改代码的人很容易当成同一个东西,所以在这里写清楚(见《08》F-106)。
|
||||||
|
*/
|
||||||
public static final String TPL_SUBMIT_SUCCESS = "SUBMIT_SUCCESS";
|
public static final String TPL_SUBMIT_SUCCESS = "SUBMIT_SUCCESS";
|
||||||
public static final String TPL_AUDIT_RESULT = "AUDIT_RESULT";
|
public static final String TPL_AUDIT_RESULT = "AUDIT_RESULT";
|
||||||
public static final String TPL_DEADLINE_REMIND = "DEADLINE_REMIND";
|
public static final String TPL_DEADLINE_REMIND = "DEADLINE_REMIND";
|
||||||
@@ -72,10 +86,21 @@ public class NotificationService {
|
|||||||
private final NotifyRecordMapper notifyRecordMapper;
|
private final NotifyRecordMapper notifyRecordMapper;
|
||||||
private final UserMapper userMapper;
|
private final UserMapper userMapper;
|
||||||
private final WxMiniProgramService wxService;
|
private final WxMiniProgramService wxService;
|
||||||
|
private final NotifyTextResolver textResolver;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 发一条通知。
|
* 发一条通知。
|
||||||
*
|
*
|
||||||
|
* <h3>文案从哪来:模板优先(FR-1002)</h3>
|
||||||
|
*
|
||||||
|
* <p>{@code req.subjectCode()} 非空时,标题与正文**以模板为准** ——
|
||||||
|
* 库里改过就用库里的、没改过用内置的({@link NotifyTextResolver})。
|
||||||
|
* 调用方传的 {@code title/content} 只在"模板码不认识 / 取模板失败"时兜底。
|
||||||
|
* 这样「通知模板」页改完就真的生效,包括驳回、通过、催报这些自动通知。
|
||||||
|
*
|
||||||
|
* <p>模板**被停用**时这条通知**不发**(这正是那个开关的意义),
|
||||||
|
* 并把原因放进 {@code failReason} 让调用方如实回 {@code notifySent=false}。
|
||||||
|
*
|
||||||
* @param req 内容与收件人(见 {@link NotifyRequest})
|
* @param req 内容与收件人(见 {@link NotifyRequest})
|
||||||
* @return 结果:{@code pushSent} 是"微信推送有没有发出去",
|
* @return 结果:{@code pushSent} 是"微信推送有没有发出去",
|
||||||
* {@code messageId} 是站内信 id(**一定存在**,除非写库本身失败)
|
* {@code messageId} 是站内信 id(**一定存在**,除非写库本身失败)
|
||||||
@@ -84,13 +109,39 @@ public class NotificationService {
|
|||||||
Long tenantId = req.tenantId() == null ? UserContext.tenantId() : req.tenantId();
|
Long tenantId = req.tenantId() == null ? UserContext.tenantId() : req.tenantId();
|
||||||
Long userId = req.userId();
|
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();
|
CollectMessage msg = new CollectMessage();
|
||||||
msg.setUserId(userId);
|
msg.setUserId(userId);
|
||||||
msg.setBizType(req.bizType());
|
msg.setBizType(req.bizType());
|
||||||
msg.setTitle(clip(req.title(), 128));
|
msg.setTitle(clip(title, 128));
|
||||||
msg.setContent(clip(req.content(), 1024));
|
msg.setContent(clip(content, 1024));
|
||||||
msg.setLinkType(req.linkType() == null ? CollectMessage.LINK_NONE : req.linkType());
|
msg.setLinkType(req.linkType() == null ? CollectMessage.LINK_NONE : req.linkType());
|
||||||
msg.setLinkId(req.linkId());
|
msg.setLinkId(req.linkId());
|
||||||
msg.setIsRead(0);
|
msg.setIsRead(0);
|
||||||
@@ -115,7 +166,7 @@ public class NotificationService {
|
|||||||
} else {
|
} else {
|
||||||
try {
|
try {
|
||||||
wxService.sendSubscribeMessage(openid, wxTemplateIdOf(templateCode),
|
wxService.sendSubscribeMessage(openid, wxTemplateIdOf(templateCode),
|
||||||
"pages/tasks/tasks", buildTemplateData(req));
|
"pages/tasks/tasks", buildTemplateData(req, title, content));
|
||||||
pushSent = true;
|
pushSent = true;
|
||||||
// 落地通道改成微信推送(站内信仍然在,学生随时能回来看)
|
// 落地通道改成微信推送(站内信仍然在,学生随时能回来看)
|
||||||
messageMapper.update(null, Wrappers.<CollectMessage>lambdaUpdate()
|
messageMapper.update(null, Wrappers.<CollectMessage>lambdaUpdate()
|
||||||
@@ -137,7 +188,9 @@ public class NotificationService {
|
|||||||
|
|
||||||
// ③ 如实记录发送通道结果(collect_notify_record 的定位就是这个)
|
// ③ 如实记录发送通道结果(collect_notify_record 的定位就是这个)
|
||||||
// 注意:站内信本身"发成功"了,所以 status 记 1;推送那条另记一行。
|
// 注意:站内信本身"发成功"了,所以 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);
|
: CollectMessage.CHANNEL_IN_APP, pushSent, skippedNoQuota, failReason);
|
||||||
|
|
||||||
return new NotifyResult(msg.getId(), pushSent, failReason, msg.getChannel());
|
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) {
|
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,
|
public record NotifyRequest(Long tenantId, Long userId, int bizType, String title, String content,
|
||||||
|
String linkType, Long linkId, String templateCode,
|
||||||
|
Map<String, String> templateParams,
|
||||||
|
String subjectCode, Map<String, String> subjectVars, String extraText) {
|
||||||
|
|
||||||
|
/** 旧签名的兼容构造:不带站内信模板时走"调用方给文案"的老路(无模板可用)。 */
|
||||||
|
public NotifyRequest(Long tenantId, Long userId, int bizType, String title, String content,
|
||||||
String linkType, Long linkId, String templateCode,
|
String linkType, Long linkId, String templateCode,
|
||||||
Map<String, String> templateParams) {
|
Map<String, String> templateParams) {
|
||||||
|
this(tenantId, userId, bizType, title, content, linkType, linkId, templateCode,
|
||||||
|
templateParams, null, Map.of(), null);
|
||||||
|
}
|
||||||
|
|
||||||
/** 常用构造:学生端(带跳转)。{@code linkId} 在 {@code linkType=TASK} 时就是 taskId。 */
|
/** 常用构造:学生端(带跳转)。{@code linkId} 在 {@code linkType=TASK} 时就是 taskId。 */
|
||||||
public static NotifyRequest of(Long userId, int bizType, String title, String content,
|
public static NotifyRequest of(Long userId, int bizType, String title, String content,
|
||||||
String linkType, Long linkId, String templateCode) {
|
String linkType, Long linkId, String templateCode) {
|
||||||
return new NotifyRequest(null, userId, bizType, title, content, linkType, linkId,
|
return new NotifyRequest(null, userId, bizType, title, content, linkType, linkId,
|
||||||
templateCode, Map.of());
|
templateCode, Map.of(), null, Map.of(), null);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 这条通知属于哪个任务(发送记录表要它 —— 催报去重按任务)。
|
* 按模板发的常用构造:文案由模板决定,调用方只给变量。
|
||||||
|
*
|
||||||
|
* <p>{@code fallbackTitle} 只在模板取不到时兜底(正常情况下不会用到)。
|
||||||
|
*/
|
||||||
|
public static NotifyRequest ofTemplate(Long userId, int bizType, String subjectCode,
|
||||||
|
Map<String, String> vars, String fallbackTitle,
|
||||||
|
String linkType, Long linkId, String templateCode) {
|
||||||
|
return new NotifyRequest(null, userId, bizType, fallbackTitle, fallbackTitle,
|
||||||
|
linkType, linkId, templateCode, Map.of(),
|
||||||
|
subjectCode, vars == null ? Map.of() : vars, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 加一句模板之外的补充(不可变,返回新对象)。 */
|
||||||
|
public NotifyRequest withExtra(String extra) {
|
||||||
|
return new NotifyRequest(tenantId, userId, bizType, title, content, linkType, linkId,
|
||||||
|
templateCode, templateParams, subjectCode, subjectVars, extra);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 拼模板变量:`vars("任务名", name, "姓名", realName)` —— **null 值直接跳过**。
|
||||||
|
*
|
||||||
|
* <p>⚠️ 不要用 {@code Map.of(...)} 拼这个:它**不接受 null 值**(连 key 都不接受),
|
||||||
|
* 而"这个学生查不到姓名"是完全正常的情况 —— 于是它会抛 NPE,
|
||||||
|
* 而通知调用外面通常包着"通知失败不影响业务"的 try/catch →
|
||||||
|
* 表现是**一条通知都没发,日志里只有一行 warn**(这个坑在本轮真踩了一次:
|
||||||
|
* `Map.of("姓名", null)` → 驳回通知全丢,而驳回本身照样成功)。
|
||||||
|
* 模板渲染对"变量没有值"的处理本来就是渲染成空(见 {@link NotifyTemplates#render}),
|
||||||
|
* 所以跳过才是对的口径。
|
||||||
|
*/
|
||||||
|
public static Map<String, String> vars(String... kv) {
|
||||||
|
Map<String, String> m = new LinkedHashMap<>();
|
||||||
|
for (int i = 0; i + 1 < kv.length; i += 2) {
|
||||||
|
if (kv[i] != null && kv[i + 1] != null) {
|
||||||
|
m.put(kv[i], kv[i + 1]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return m;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 这条通知属于哪个任务(发送记录表要它 —— 催报去重按任务;模板也要按任务取覆盖)。
|
||||||
*
|
*
|
||||||
* <p>从 {@code linkType} 推:跳任务是任务本身,跳填报单则要靠调用方另外传
|
* <p>从 {@code linkType} 推:跳任务是任务本身,跳填报单则要靠调用方另外传
|
||||||
* (这里返回 null 也行,只是那条记录没法按任务筛)。
|
* (这里返回 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) {
|
boolean ok, boolean skippedNoQuota, String failReason) {
|
||||||
try {
|
try {
|
||||||
// biz_type 是 varchar(32)(V1 的 DDL),所以要转字符串 —— 见 NotifyRecordMapper
|
// biz_type 是 varchar(32)(V1 的 DDL),所以要转字符串 —— 见 NotifyRecordMapper
|
||||||
int status = ok ? 1 : (skippedNoQuota ? 3 : 2);
|
int status = ok ? 1 : (skippedNoQuota ? 3 : 2);
|
||||||
int n = notifyRecordMapper.insertRecord(tenantId, req.taskIdOrNull(),
|
int n = notifyRecordMapper.insertRecord(tenantId, req.taskIdOrNull(),
|
||||||
String.valueOf(req.bizType()),
|
String.valueOf(req.bizType()),
|
||||||
req.userId(), channel, req.templateCode(), req.title(), status,
|
req.userId(), channel, req.templateCode(), title, status,
|
||||||
clip(failReason, 512));
|
clip(failReason, 512));
|
||||||
if (n <= 0) {
|
if (n <= 0) {
|
||||||
log.warn("通知发送记录没写进去(返回 {} 行):user={} biz={}", n, req.userId(), req.bizType());
|
log.warn("通知发送记录没写进去(返回 {} 行):user={} biz={}", n, req.userId(), req.bizType());
|
||||||
@@ -210,14 +321,14 @@ public class NotificationService {
|
|||||||
* 所以只能由客户在微信后台建好模板后,用 {@code templateParams} 传进来。
|
* 所以只能由客户在微信后台建好模板后,用 {@code templateParams} 传进来。
|
||||||
* 这里做一个兜底:只有内容没有参数时,用通用字段名 —— 发不出去也不影响站内信。
|
* 这里做一个兜底:只有内容没有参数时,用通用字段名 —— 发不出去也不影响站内信。
|
||||||
*/
|
*/
|
||||||
private Map<String, Object> buildTemplateData(NotifyRequest req) {
|
private Map<String, Object> buildTemplateData(NotifyRequest req, String title, String content) {
|
||||||
Map<String, Object> data = new LinkedHashMap<>();
|
Map<String, Object> data = new LinkedHashMap<>();
|
||||||
if (req.templateParams() != null && !req.templateParams().isEmpty()) {
|
if (req.templateParams() != null && !req.templateParams().isEmpty()) {
|
||||||
req.templateParams().forEach((k, v) -> data.put(k, Map.of("value", v)));
|
req.templateParams().forEach((k, v) -> data.put(k, Map.of("value", v)));
|
||||||
return data;
|
return data;
|
||||||
}
|
}
|
||||||
data.put("thing1", Map.of("value", clip(req.title(), 20)));
|
data.put("thing1", Map.of("value", clip(title, 20)));
|
||||||
data.put("thing2", Map.of("value", clip(req.content(), 20)));
|
data.put("thing2", Map.of("value", clip(content, 20)));
|
||||||
return data;
|
return data;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+17
-3
@@ -62,13 +62,20 @@ public final class NotifyTemplates {
|
|||||||
"{任务名} 的填报已审核通过,无需再操作。", "终审通过"),
|
"{任务名} 的填报已审核通过,无需再操作。", "终审通过"),
|
||||||
new Def("URGE", 5, "请尽快完成填报",
|
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_APPROVE", 7, "加入名单申请已处理",
|
||||||
"你申请加入 {任务名} 名单的请求已处理,请查看结果。", "加入名单审批"),
|
"你申请加入 {任务名} 名单的请求已处理,请查看结果。", "加入名单审批"),
|
||||||
new Def("JOIN_SUBMIT", 7, "有新的加入名单申请",
|
new Def("JOIN_SUBMIT", 7, "有新的加入名单申请",
|
||||||
"{班级} 的 {姓名} 申请加入 {任务名} 名单,请审批。", "发给班主任/管理员")
|
"{班级} 的 {姓名} 申请加入 {任务名} 名单,请审批。", "发给班主任/管理员"),
|
||||||
|
new Def("AUDIT_ESCALATE", 3, "有一条填报单需要你介入",
|
||||||
|
"{任务名} 的 {姓名} 已被驳回达上限,最后一次原因是:{驳回原因}。请查看后决定怎么处理。",
|
||||||
|
"驳回次数达上限时发给学校管理员(契约 §11.4)")
|
||||||
);
|
);
|
||||||
|
|
||||||
public static List<Def> all() {
|
public static List<Def> all() {
|
||||||
@@ -87,6 +94,13 @@ public final class NotifyTemplates {
|
|||||||
/** 业务类型集合(用于校验 `POST /notify/send` 的 bizType)。 */
|
/** 业务类型集合(用于校验 `POST /notify/send` 的 bizType)。 */
|
||||||
public static final Set<Integer> BIZ_TYPES = Set.of(1, 2, 3, 4, 5, 6, 7);
|
public static final Set<Integer> BIZ_TYPES = Set.of(1, 2, 3, 4, 5, 6, 7);
|
||||||
|
|
||||||
|
/** 模板码全集(脚本与测试用它核对"每个内置模板都有发送点")。 */
|
||||||
|
public static Set<String> allCodes() {
|
||||||
|
Set<String> out = new java.util.LinkedHashSet<>();
|
||||||
|
BUILTIN.forEach(d -> out.add(d.code()));
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 渲染模板。
|
* 渲染模板。
|
||||||
*
|
*
|
||||||
|
|||||||
+131
@@ -0,0 +1,131 @@
|
|||||||
|
package cn.tianbaotong.fill.module.notify.service;
|
||||||
|
|
||||||
|
import cn.tianbaotong.fill.module.notify.entity.CollectNotifyTemplate;
|
||||||
|
import cn.tianbaotong.fill.module.notify.mapper.NotifyTemplateMapper;
|
||||||
|
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 按模板码取「真正要发出去的那段文案」。
|
||||||
|
*
|
||||||
|
* <h3>为什么需要这个类(FR-1002 的兑现点)</h3>
|
||||||
|
*
|
||||||
|
* <p>《08-完成度盘点》F-101:通知模板页能改文案,但**只有"手工发送"走模板** ——
|
||||||
|
* 驳回、审核通过、催报、临期提醒这些**自动**通知把文案硬编码在各自的 Service 里,
|
||||||
|
* 于是管理员把「驳回通知」改成自己学校的说法之后,学生收到的还是原来那句。
|
||||||
|
* 而 FR-1002 点名的 5 类通知(任务发布 / 临期提醒 / 驳回通知 / 审核通过 / 导出完成)
|
||||||
|
* **全是自动类** —— 也就是说这个 P0 需求当时只兑现了它没点名的那一半。
|
||||||
|
*
|
||||||
|
* <p>所以自动通知也必须先来这里取文案:<b>库里改过的 > 内置的</b>,
|
||||||
|
* 与模板列表页({@code GET /notify/templates})看到的**完全同一份口径** ——
|
||||||
|
* 否则"界面上显示这句话、实际发出去另一句话"就是同一类问题的另一种形态。
|
||||||
|
*
|
||||||
|
* <h3>为什么不直接用 {@code NotifyTemplateService}</h3>
|
||||||
|
*
|
||||||
|
* <p>它是**反方向**的依赖:{@code NotifyTemplateService} 手工发送时要调
|
||||||
|
* {@code NotificationService},而 {@code NotificationService} 现在要在发送前取文案 ——
|
||||||
|
* 两个 Service 互相注入会形成循环依赖(Spring 允许用 {@code @Lazy} 糊过去,
|
||||||
|
* 但那是把"设计上分不开"这件事藏起来)。取文案只需要一张表,
|
||||||
|
* 所以单独放一个类,依赖是单向的:
|
||||||
|
* {@code NotificationService → NotifyTextResolver → NotifyTemplateMapper}。
|
||||||
|
*
|
||||||
|
* <h3>失败一律退回内置文案</h3>
|
||||||
|
*
|
||||||
|
* <p>查库失败(没有租户上下文、表被锁、连接抖动…)**绝不能变成"通知不发"**:
|
||||||
|
* 那会让一次本该校验通过的审核动作连带丢掉通知。所以这里吞掉异常,
|
||||||
|
* 退回内置文案 + 一条 warn。
|
||||||
|
*/
|
||||||
|
@Slf4j
|
||||||
|
@Service
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class NotifyTextResolver {
|
||||||
|
|
||||||
|
private final NotifyTemplateMapper templateMapper;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 解析结果。
|
||||||
|
*
|
||||||
|
* @param enabled 模板有没有被停用(停用 = 这类通知不发,见 {@link #resolve} 的说明)
|
||||||
|
* @param customized 是不是用户改过的(用于日志与排查:"他收到的为什么是这句")
|
||||||
|
*/
|
||||||
|
public record Resolved(boolean enabled, String title, String content, boolean customized) {
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 取文案。变量按白名单渲染(越界变量原样保留,见 {@link NotifyTemplates#render})。
|
||||||
|
*
|
||||||
|
* @param code 站内信模板码({@link NotifyTemplates} 里的那一套,如 {@code AUDIT_REJECT})
|
||||||
|
* @param taskId 任务级覆盖优先;没有就退到租户级;都没有就用内置
|
||||||
|
* @return 找不到这个模板码时返回 {@code null}(调用方回退到自己拼的文案)
|
||||||
|
*/
|
||||||
|
public Resolved resolve(String code, Long taskId, Map<String, String> vars) {
|
||||||
|
NotifyTemplates.Def def = NotifyTemplates.byCode(code);
|
||||||
|
CollectNotifyTemplate over = findOverride(code, taskId);
|
||||||
|
|
||||||
|
if (over == null) {
|
||||||
|
if (def == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return new Resolved(true,
|
||||||
|
NotifyTemplates.render(def.title(), vars),
|
||||||
|
NotifyTemplates.render(def.content(), vars), false);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 库里这一行只在"它自己那条"上覆盖内置:标题/内容为空时逐项退回内置,
|
||||||
|
// 而不是发出一封没有标题的消息(那在消息中心里就是一个空白条目)
|
||||||
|
String title = blank(over.getTitle())
|
||||||
|
? (def == null ? "" : def.title()) : over.getTitle();
|
||||||
|
String content = blank(over.getContent())
|
||||||
|
? (def == null ? "" : def.content()) : over.getContent();
|
||||||
|
boolean enabled = Integer.valueOf(CollectNotifyTemplate.ENABLED_YES).equals(over.getEnabled());
|
||||||
|
if (!enabled) {
|
||||||
|
log.info("通知模板 {} 已被停用,这条自动通知不发(task={})", code, taskId);
|
||||||
|
}
|
||||||
|
return new Resolved(enabled,
|
||||||
|
NotifyTemplates.render(title, vars),
|
||||||
|
NotifyTemplates.render(content, vars), true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 取该模板码的覆盖行:**任务级优先于租户级**。
|
||||||
|
*
|
||||||
|
* <p>租户拦截器会补 {@code tenant_id},所以这里不用自己带租户条件 ——
|
||||||
|
* 但这也意味着**必须在租户上下文里调用**(自动通知都在:要么是租户内的用户动作,
|
||||||
|
* 要么是定时任务里 {@code TenantScope.callAs} 摆好的)。查不到/查失败都退回内置。
|
||||||
|
*/
|
||||||
|
private CollectNotifyTemplate findOverride(String code, Long taskId) {
|
||||||
|
try {
|
||||||
|
var q = Wrappers.<CollectNotifyTemplate>lambdaQuery()
|
||||||
|
.eq(CollectNotifyTemplate::getTemplateCode, code)
|
||||||
|
.eq(CollectNotifyTemplate::getChannel, CollectNotifyTemplate.CHANNEL_IN_APP);
|
||||||
|
if (taskId != null) {
|
||||||
|
q.and(w -> w.isNull(CollectNotifyTemplate::getTaskId)
|
||||||
|
.or().eq(CollectNotifyTemplate::getTaskId, taskId));
|
||||||
|
} else {
|
||||||
|
q.isNull(CollectNotifyTemplate::getTaskId);
|
||||||
|
}
|
||||||
|
List<CollectNotifyTemplate> rows = templateMapper.selectList(q);
|
||||||
|
CollectNotifyTemplate tenantLevel = null;
|
||||||
|
for (CollectNotifyTemplate row : rows) {
|
||||||
|
if (row.getTaskId() != null && row.getTaskId().equals(taskId)) {
|
||||||
|
return row;
|
||||||
|
}
|
||||||
|
tenantLevel = row;
|
||||||
|
}
|
||||||
|
return tenantLevel;
|
||||||
|
} catch (RuntimeException e) {
|
||||||
|
// 取模板失败不该让通知消失(退回内置文案)
|
||||||
|
log.warn("取通知模板失败,退回内置文案:code={} task={} 原因={}", code, taskId, e.toString());
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean blank(String s) {
|
||||||
|
return s == null || s.isBlank();
|
||||||
|
}
|
||||||
|
}
|
||||||
+97
-15
@@ -44,6 +44,17 @@ public class UrgeService {
|
|||||||
/** 默认模板码(契约 §8.6 的 `NOTIFY_UNSUBMITTED`)。 */
|
/** 默认模板码(契约 §8.6 的 `NOTIFY_UNSUBMITTED`)。 */
|
||||||
public static final String TPL_URGE = "NOTIFY_UNSUBMITTED";
|
public static final String TPL_URGE = "NOTIFY_UNSUBMITTED";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 站内信模板码({@link NotifyTemplates} 那套)。
|
||||||
|
*
|
||||||
|
* <p>⚠️ 与上面的 {@link #TPL_URGE} **不是一回事**:那个是**微信订阅消息**的模板码
|
||||||
|
* (决定从哪取 {@code templateId}、扣哪一份额度),这里两个才是"学生实际读到的那句话"。
|
||||||
|
* 草稿与"一次都没填"是两条不同的话,所以是两个模板码 ——
|
||||||
|
* 合成一个就得在模板里写条件,而变量渲染做不到条件。
|
||||||
|
*/
|
||||||
|
public static final String SUBJECT_URGE = "URGE";
|
||||||
|
public static final String SUBJECT_URGE_DRAFT = "URGE_DRAFT";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 催报用的业务类型码 = {@code 5}。
|
* 催报用的业务类型码 = {@code 5}。
|
||||||
*
|
*
|
||||||
@@ -60,9 +71,39 @@ public class UrgeService {
|
|||||||
/** 默认去重窗口(小时)。 */
|
/** 默认去重窗口(小时)。 */
|
||||||
private static final int DEFAULT_EXCLUDE_HOURS = 24;
|
private static final int DEFAULT_EXCLUDE_HOURS = 24;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 催报分钟级限流(契约 §3:「催报 10 次/分钟/任务」,2026-09-30 补上,见《08》F-29)。
|
||||||
|
*
|
||||||
|
* <p>与 24 小时去重是**两件事**:去重防"同一个学生被重复催",限流防"老师连点把系统打爆"。
|
||||||
|
* 没有它时连点 100 次不会造成伤害(第二次起全被去重跳过),但会白跑 100 次全表查询。
|
||||||
|
*/
|
||||||
|
private static final int URGES_PER_MINUTE_PER_TASK = 10;
|
||||||
|
|
||||||
private final UrgeMapper urgeMapper;
|
private final UrgeMapper urgeMapper;
|
||||||
private final AuditScopeMapper auditScopeMapper;
|
private final AuditScopeMapper auditScopeMapper;
|
||||||
private final NotificationService notificationService;
|
private final NotificationService notificationService;
|
||||||
|
private final org.springframework.data.redis.core.StringRedisTemplate redis;
|
||||||
|
|
||||||
|
/** 计一次催报动作,超限抛 {@code 42901}。键按任务(没有任务时按班级组合,见调用处)。 */
|
||||||
|
private void rateLimit(String scopeKey) {
|
||||||
|
String key = "xt:urge:" + scopeKey;
|
||||||
|
try {
|
||||||
|
Long n = redis.opsForValue().increment(key);
|
||||||
|
if (n != null && n == 1L) {
|
||||||
|
redis.expire(key, Duration.ofSeconds(60));
|
||||||
|
}
|
||||||
|
if (n != null && n > URGES_PER_MINUTE_PER_TASK) {
|
||||||
|
throw new BizException(ErrorCode.RATE_LIMITED,
|
||||||
|
"催报操作过于频繁(每分钟最多 " + URGES_PER_MINUTE_PER_TASK
|
||||||
|
+ " 次/任务),请稍后再试。同一学生 24 小时内不会被重复催,不必连点");
|
||||||
|
}
|
||||||
|
} catch (BizException e) {
|
||||||
|
throw e;
|
||||||
|
} catch (Exception e) { // noqa: BLE001
|
||||||
|
// Redis 不可用不该让催报瘫痪 —— 限流是保护,不是业务前提
|
||||||
|
log.warn("催报限流计数失败(放行本次):{}", e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ── 定向催报(§8.6 的 `/roster/{rid}/notify`)──
|
// ── 定向催报(§8.6 的 `/roster/{rid}/notify`)──
|
||||||
|
|
||||||
@@ -87,6 +128,7 @@ public class UrgeService {
|
|||||||
// 还没核对身份 → 没有账号可发。如实说明,别报"发送失败"
|
// 还没核对身份 → 没有账号可发。如实说明,别报"发送失败"
|
||||||
return result(List.of(detail(t, true, "这个学生还没扫码核对身份,无法触达(先让他扫二维码)")));
|
return result(List.of(detail(t, true, "这个学生还没扫码核对身份,无法触达(先让他扫二维码)")));
|
||||||
}
|
}
|
||||||
|
rateLimit("task:" + t.getTaskId());
|
||||||
|
|
||||||
String extra = req == null ? null : req.getExtraText();
|
String extra = req == null ? null : req.getExtraText();
|
||||||
NotifyOutcome o = send(t, req == null ? null : req.getTemplateCode(), extra, false);
|
NotifyOutcome o = send(t, req == null ? null : req.getTemplateCode(), extra, false);
|
||||||
@@ -125,6 +167,10 @@ public class UrgeService {
|
|||||||
return result(List.of());
|
return result(List.of());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 限流放在"确实有活可干"之后:空结果就连点也不计数 ——
|
||||||
|
// 惩罚"点了但没人可催"只会让人困惑(界面上明明什么都没发生)
|
||||||
|
rateLimit(taskId != null ? "task:" + taskId : "orgs:" + orgIds);
|
||||||
|
|
||||||
int hours = req == null || req.getExcludeRecentHours() == null
|
int hours = req == null || req.getExcludeRecentHours() == null
|
||||||
? DEFAULT_EXCLUDE_HOURS : Math.max(0, req.getExcludeRecentHours());
|
? DEFAULT_EXCLUDE_HOURS : Math.max(0, req.getExcludeRecentHours());
|
||||||
LocalDateTime now = LocalDateTime.now();
|
LocalDateTime now = LocalDateTime.now();
|
||||||
@@ -181,7 +227,12 @@ public class UrgeService {
|
|||||||
LocalDateTime now = LocalDateTime.now();
|
LocalDateTime now = LocalDateTime.now();
|
||||||
Set<Long> sentUsers = new LinkedHashSet<>();
|
Set<Long> sentUsers = new LinkedHashSet<>();
|
||||||
int sent = 0;
|
int sent = 0;
|
||||||
String extra = daysLeft <= 1
|
// 0 天 = 截止当日(FR-1003 要求"7/3/1 天 + 截止当日")。
|
||||||
|
// 三档文案必须分开:对"还有 1 天"和"就是今天"说同一句"今天是最后一天",
|
||||||
|
// 学生就无法判断到底还剩几个小时 —— 而那正是他此刻唯一需要知道的事。
|
||||||
|
String extra = daysLeft <= 0
|
||||||
|
? "今天是截止日,请在今天 24 点前提交,过后就不能再交了。"
|
||||||
|
: daysLeft == 1
|
||||||
? "今天是最后一天了,请务必今天提交。"
|
? "今天是最后一天了,请务必今天提交。"
|
||||||
: "距截止还有 " + daysLeft + " 天,请尽早提交。";
|
: "距截止还有 " + daysLeft + " 天,请尽早提交。";
|
||||||
for (UrgeMapper.UrgeTarget t : targets) {
|
for (UrgeMapper.UrgeTarget t : targets) {
|
||||||
@@ -259,25 +310,40 @@ public class UrgeService {
|
|||||||
/** 发一条催报。返回"跳过了没有 + 为什么"。 */
|
/** 发一条催报。返回"跳过了没有 + 为什么"。 */
|
||||||
private NotifyOutcome send(UrgeMapper.UrgeTarget t, String templateCode, String extra,
|
private NotifyOutcome send(UrgeMapper.UrgeTarget t, String templateCode, String extra,
|
||||||
boolean draft) {
|
boolean draft) {
|
||||||
String title = draft ? "你的填报还没有提交" : "请尽快完成填报";
|
// 文案走模板(FR-1002 的同一口径):管理员在「通知模板」页改了「催报」那句,
|
||||||
StringBuilder content = new StringBuilder(draft
|
// 学生收到的就是改过的那句。草稿与"一次都没填"是两条不同的话,
|
||||||
? "你已经开始填写《" + nz(t.getTaskName()) + "》,但还没有提交。填完记得点「提交」。"
|
// 所以是两个模板码 —— 合成一个就得在模板里加条件,那是模板做不到的事。
|
||||||
: "你还没有完成《" + nz(t.getTaskName()) + "》的填报,请尽快完成。");
|
java.util.Map<String, String> vars = new java.util.LinkedHashMap<>();
|
||||||
if (t.getOrgName() != null) {
|
vars.put("任务名", nz(t.getTaskName()));
|
||||||
content.append("(").append(t.getOrgName()).append(")");
|
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)));
|
||||||
}
|
}
|
||||||
if (extra != null && !extra.isBlank()) {
|
|
||||||
// 老师自己写的话原样附在后面 —— 它往往才是学生真正会看的那句
|
|
||||||
content.append("\n").append(extra.trim());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
var r = notificationService.notify(new NotificationService.NotifyRequest(
|
var r = notificationService.notify(NotificationService.NotifyRequest.ofTemplate(
|
||||||
null, t.getUserId(), BIZ_URGE, title, content.toString(),
|
t.getUserId(), BIZ_URGE,
|
||||||
|
draft ? SUBJECT_URGE_DRAFT : SUBJECT_URGE,
|
||||||
|
vars,
|
||||||
|
draft ? "你的填报还没有提交" : "请尽快完成填报",
|
||||||
CollectMessage.LINK_TASK, t.getTaskId(),
|
CollectMessage.LINK_TASK, t.getTaskId(),
|
||||||
templateCode == null || templateCode.isBlank() ? TPL_URGE : templateCode,
|
templateCode == null || templateCode.isBlank() ? TPL_URGE : templateCode)
|
||||||
java.util.Map.of()));
|
// 班级与老师附言是模板表达不了的"这一次"的信息,追加在后面
|
||||||
// 站内信一定发出去了 → 算 sent。微信推送没出去只影响"他多久看到"
|
.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());
|
return new NotifyOutcome(false, r.pushSent() ? null : r.failReason());
|
||||||
} catch (RuntimeException e) {
|
} catch (RuntimeException e) {
|
||||||
log.warn("催报失败:roster={} user={}", t.getRosterId(), t.getUserId(), e);
|
log.warn("催报失败:roster={} user={}", t.getRosterId(), t.getUserId(), e);
|
||||||
@@ -285,6 +351,22 @@ public class UrgeService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 这个任务的提交进度(`{提交进度}` 变量:已提交/应填)。查不出来就留空,不编一个数。 */
|
||||||
|
private String progressOf(Long taskId) {
|
||||||
|
try {
|
||||||
|
Long tenantId = UserContext.tenantId();
|
||||||
|
return urgeMapper.progressOf(tenantId, taskId);
|
||||||
|
} catch (RuntimeException e) {
|
||||||
|
log.debug("取提交进度失败(模板变量留空):task={} 原因={}", taskId, e.toString());
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 班级那一行补充说明(学生手里的任务可能不止一个班,说清是哪个班的事)。 */
|
||||||
|
private String orgPart(UrgeMapper.UrgeTarget t) {
|
||||||
|
return t.getOrgName() == null ? "" : "(" + t.getOrgName() + ")";
|
||||||
|
}
|
||||||
|
|
||||||
/** 范围判断:这个人所在的班归不归我。 */
|
/** 范围判断:这个人所在的班归不归我。 */
|
||||||
private boolean inMyScope(Long tenantId, Long uid, UrgeMapper.UrgeTarget t) {
|
private boolean inMyScope(Long tenantId, Long uid, UrgeMapper.UrgeTarget t) {
|
||||||
List<Long> mine = auditScopeMapper.classAuditOrgIds(tenantId, t.getTaskId(), uid);
|
List<Long> mine = auditScopeMapper.classAuditOrgIds(tenantId, t.getTaskId(), uid);
|
||||||
|
|||||||
+15
-1
@@ -11,7 +11,21 @@ import java.util.List;
|
|||||||
/** 推送记录(契约 §14.4)。 */
|
/** 推送记录(契约 §14.4)。 */
|
||||||
public interface OpenPushLogMapper extends BaseMapper<CollectOpenPushLog> {
|
public interface OpenPushLogMapper extends BaseMapper<CollectOpenPushLog> {
|
||||||
|
|
||||||
/** 到点该重试的(定时任务用)。只取"失败待重试"的,不碰成功与最终失败的。 */
|
/**
|
||||||
|
* 到点该重试的(定时任务用)。只取"失败待重试"的,不碰成功与最终失败的。
|
||||||
|
*
|
||||||
|
* <p>⚠️ {@code @InterceptorIgnore(tenantLine = "true")} <b>不能省</b>:
|
||||||
|
* 定时任务**没有租户上下文**(它是"没有请求也会发生的事"),而租户拦截器的姿态是
|
||||||
|
* <b>默认拒绝</b> —— 少了这个注解,这条查询每次都抛 {@code 40101},
|
||||||
|
* 被 {@code OpenPushService.retryDue} 的 {@code catch (RuntimeException)} 吞成一行 warn。
|
||||||
|
* 表现是:**"安排了下次重试"却从来没有重试过**,而日志里只有每 10 秒一次的噪音。
|
||||||
|
*
|
||||||
|
* <p>这与 {@link #findBySubmissionIgnoreTenant} 是同一个规律:
|
||||||
|
* <b>"先找归属、再摆上下文"里,找归属那一步必须能无租户执行</b>。
|
||||||
|
* 跨租户的候选扫描集中在这一个方法里,逐条处理时再由
|
||||||
|
* {@code TenantScope.runAs} 摆好目标租户的上下文 —— 保护没有被放弃。
|
||||||
|
*/
|
||||||
|
@InterceptorIgnore(tenantLine = "true")
|
||||||
@Select("""
|
@Select("""
|
||||||
SELECT * FROM collect_open_push_log
|
SELECT * FROM collect_open_push_log
|
||||||
WHERE deleted = 0 AND status = 2
|
WHERE deleted = 0 AND status = 2
|
||||||
|
|||||||
+8
-1
@@ -212,11 +212,18 @@ public class OpenPushService {
|
|||||||
// 定时任务没有租户上下文,用忽略租户的查询(范围仅此一处)
|
// 定时任务没有租户上下文,用忽略租户的查询(范围仅此一处)
|
||||||
CollectOpenApp app = appMapper.findByIdIgnoreTenant(row.getAppId());
|
CollectOpenApp app = appMapper.findByIdIgnoreTenant(row.getAppId());
|
||||||
if (app == null || !app.enabled()) {
|
if (app == null || !app.enabled()) {
|
||||||
// 接入方没了/停了:不再重试,标成最终失败并说明原因
|
// 接入方没了/停了:不再重试,标成最终失败并说明原因。
|
||||||
|
//
|
||||||
|
// ⚠️ 这一步**也必须摆租户上下文**:`updateById` 会走租户拦截器,
|
||||||
|
// 而在"接入方已不存在"的情况下我们不能从 app 拿租户 —— 所以用
|
||||||
|
// **推送记录自己的 tenant_id**。少了它,这条 update 会抛 40101,
|
||||||
|
// 被下面的 catch 吞成一行 warn,于是这条记录**每 10 秒重试一次、永远停不下来**。
|
||||||
|
TenantScope.runAs(row.getTenantId(), null, Set.of(), () -> {
|
||||||
row.setStatus(CollectOpenPushLog.STATUS_FAILED);
|
row.setStatus(CollectOpenPushLog.STATUS_FAILED);
|
||||||
row.setLastError("接入方不存在或已停用,停止重试");
|
row.setLastError("接入方不存在或已停用,停止重试");
|
||||||
row.setNextRetryAt(null);
|
row.setNextRetryAt(null);
|
||||||
pushLogMapper.updateById(row);
|
pushLogMapper.updateById(row);
|
||||||
|
});
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
TenantScope.runAs(app.getTenantId(), null, Set.of(), () -> attempt(app, row));
|
TenantScope.runAs(app.getTenantId(), null, Set.of(), () -> attempt(app, row));
|
||||||
|
|||||||
+10
@@ -42,7 +42,17 @@ public class OrgService {
|
|||||||
* 一次查完更快也更好控制;递归查询在深度上没有上界,容易被构造出深树打爆。
|
* 一次查完更快也更好控制;递归查询在深度上没有上界,容易被构造出深树打爆。
|
||||||
*/
|
*/
|
||||||
public List<OrgTreeVO> tree(boolean withCount, Long rootId) {
|
public List<OrgTreeVO> tree(boolean withCount, Long rootId) {
|
||||||
|
// ★ 显式带上租户条件,不依赖拦截器。
|
||||||
|
//
|
||||||
|
// 拦截器对**平台运营**(tenantId=0)整个关掉了过滤(它是唯一跨租户的身份),
|
||||||
|
// 于是这里会返回"所有租户的组织混在一棵树里" —— 而只有一所学校有数据时,
|
||||||
|
// 它看起来和学校管理员看到的完全一样,**看不出错**。
|
||||||
|
// 组织树本来就是租户内的东西:平台要看某所学校的组织,得先在页面上选定学校
|
||||||
|
// (请求头 X-Tenant-Id → 见 ActingTenantResolver),那时 tenantId 就是那所学校。
|
||||||
|
// 没选学校时这里返回空树,而不是"把所有人的组织端出来"。
|
||||||
|
long tenantId = UserContext.tenantId();
|
||||||
List<SysOrg> all = orgMapper.selectList(Wrappers.<SysOrg>lambdaQuery()
|
List<SysOrg> all = orgMapper.selectList(Wrappers.<SysOrg>lambdaQuery()
|
||||||
|
.eq(SysOrg::getTenantId, tenantId)
|
||||||
.orderByAsc(SysOrg::getSortNo)
|
.orderByAsc(SysOrg::getSortNo)
|
||||||
.orderByAsc(SysOrg::getId));
|
.orderByAsc(SysOrg::getId));
|
||||||
|
|
||||||
|
|||||||
+16
-2
@@ -62,11 +62,24 @@ public interface ScheduleMapper {
|
|||||||
List<Map<String, Object>> tasksToClose();
|
List<Map<String, Object>> tasksToClose();
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 截止日正好在某一天的任务(给"提前 7/3/1 天催报"用)。
|
* 截止日正好在某一天的任务(给"提前 7/3/1 天 + 截止当日催报"用)。
|
||||||
*
|
*
|
||||||
* <p>为什么按"正好那一天"而不是"小于等于":催报是**每天一次**的定时任务,
|
* <p>为什么按"正好那一天"而不是"小于等于":催报是**每天一次**的定时任务,
|
||||||
* 用 `<=` 会让"还剩 2 天"的任务在 7 天、3 天、1 天各被催一次(因为 2 < 3 也成立)
|
* 用 `<=` 会让"还剩 2 天"的任务在 7 天、3 天、1 天各被催一次(因为 2 < 3 也成立)
|
||||||
* —— 学生一天收到三条,然后关掉通知。
|
* —— 学生一天收到三条,然后关掉通知。
|
||||||
|
*
|
||||||
|
* <p>⚠️⚠️ **必须比 `end_time::date`,不能直接比 `end_time = #{date}::date`**:
|
||||||
|
* `collect_task.end_time` 存的是**当天 23:59:59**(见 TaskService 的写入),
|
||||||
|
* 而 `'2026-10-08'::date` 会被 PG 当成 `2026-10-08 00:00:00` ——
|
||||||
|
* 两者**永远不相等**,于是这个查询**一条都查不出来**:
|
||||||
|
* 自动催报(FR-1003)在真实数据上从来没发出过任何一条通知,
|
||||||
|
* 而日志里只有"没有任务"的沉默(`if (!tasks.isEmpty())` 才打日志)。
|
||||||
|
*
|
||||||
|
* <p>这个 bug 之所以活到今天,是因为**测试夹具用 SQL 摆截止日**
|
||||||
|
* (`update collect_task set end_time = CURRENT_DATE + 3` 存进去的是 00:00:00 的日期型),
|
||||||
|
* 那种形状恰好能匹配 —— 夹具与真实数据的形状不一致,把 bug 盖住了
|
||||||
|
* (与《08》F-99「假 JPEG」是同一族:**夹具不像真东西,测出来的绿灯就不算数**)。
|
||||||
|
* 现在 `e2e-m5-notify-wiring.py` 走**真实接口**建任务(end_time 自然是 23:59:59)来守它。
|
||||||
*/
|
*/
|
||||||
@InterceptorIgnore(tenantLine = "true")
|
@InterceptorIgnore(tenantLine = "true")
|
||||||
@Select("""
|
@Select("""
|
||||||
@@ -74,7 +87,8 @@ public interface ScheduleMapper {
|
|||||||
FROM collect_task
|
FROM collect_task
|
||||||
WHERE deleted = 0
|
WHERE deleted = 0
|
||||||
AND status = 1
|
AND status = 1
|
||||||
AND end_time = #{date}::date
|
AND end_time IS NOT NULL
|
||||||
|
AND end_time::date = #{date}::date
|
||||||
ORDER BY id
|
ORDER BY id
|
||||||
""")
|
""")
|
||||||
List<Map<String, Object>> tasksEndingOn(@Param("date") String date);
|
List<Map<String, Object>> tasksEndingOn(@Param("date") String date);
|
||||||
|
|||||||
+8
-2
@@ -78,8 +78,14 @@ public class ScheduleJobs {
|
|||||||
/** 审核超时提醒一轮最多处理多少条(防止某天积压上万条时把接口打爆)。 */
|
/** 审核超时提醒一轮最多处理多少条(防止某天积压上万条时把接口打爆)。 */
|
||||||
private static final int TIMEOUT_BATCH = 500;
|
private static final int TIMEOUT_BATCH = 500;
|
||||||
|
|
||||||
/** 提前几天催报(PRD 附录 E 的「7/3/1 天规则」)。 */
|
/**
|
||||||
private static final int[] URGE_DAYS_BEFORE = {7, 3, 1};
|
* 提前几天催报(PRD 附录 E 的「7/3/1 天规则」**+ 截止当日**)。
|
||||||
|
*
|
||||||
|
* <p>⚠️ {@code 0} 那一档是 FR-1003 原文里的「+ 截止当日」,本轮补上(《08》F-104):
|
||||||
|
* 原先只有 7/3/1,于是**截止那天一声不响** —— 而那天是唯一"再不做就来不及"的日子。
|
||||||
|
* 缺的是最该有的那一档。
|
||||||
|
*/
|
||||||
|
private static final int[] URGE_DAYS_BEFORE = {7, 3, 1, 0};
|
||||||
|
|
||||||
/** 租户到期前提醒的天数(PRD 附录 E 的「30/7/1 天」)。 */
|
/** 租户到期前提醒的天数(PRD 附录 E 的「30/7/1 天」)。 */
|
||||||
private static final int[] EXPIRE_REMIND_DAYS = {30, 7, 1};
|
private static final int[] EXPIRE_REMIND_DAYS = {30, 7, 1};
|
||||||
|
|||||||
+1
-1
@@ -39,7 +39,7 @@ public class PlanScopeController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@PutMapping("/{id}/plan-scope")
|
@PutMapping("/{id}/plan-scope")
|
||||||
@RequirePerm(PermissionDef.TASK_MANAGE)
|
@RequirePerm(PermissionDef.PLAN_IMPORT)
|
||||||
@Operation(summary = "确认 / 修正报考范围",
|
@Operation(summary = "确认 / 修正报考范围",
|
||||||
description = "rosterIds 的并集必须恰好覆盖名册全员,否则 40024 并返回差集 —— "
|
description = "rosterIds 的并集必须恰好覆盖名册全员,否则 40024 并返回差集 —— "
|
||||||
+ "漏一个人,导出包就会缺人,而人工核对发现不了")
|
+ "漏一个人,导出包就会缺人,而人工核对发现不了")
|
||||||
|
|||||||
+5
-2
@@ -107,7 +107,10 @@ public class TaskController {
|
|||||||
|
|
||||||
@DeleteMapping("/{id}")
|
@DeleteMapping("/{id}")
|
||||||
@RequirePerm(PermissionDef.TASK_MANAGE)
|
@RequirePerm(PermissionDef.TASK_MANAGE)
|
||||||
@Operation(summary = "删除任务", description = "只有草稿可以删除;已发布的请用截止或归档")
|
@Operation(summary = "删除任务",
|
||||||
|
description = "草稿直接删;**已归档**的任务可删(2026-09-30 客户确认,§17.2 D-9):"
|
||||||
|
+ "级联软删名册/填报单/材料/审核记录并尽力删除文件,全程留痕。"
|
||||||
|
+ "收集中/已截止的先「归档」再删 —— 归档是最后的后悔药")
|
||||||
public ApiResult<Void> delete(@PathVariable Long id) {
|
public ApiResult<Void> delete(@PathVariable Long id) {
|
||||||
taskService.delete(id);
|
taskService.delete(id);
|
||||||
return ApiResult.ok();
|
return ApiResult.ok();
|
||||||
@@ -164,7 +167,7 @@ public class TaskController {
|
|||||||
// ══════════════════ 克隆 ══════════════════
|
// ══════════════════ 克隆 ══════════════════
|
||||||
|
|
||||||
@PostMapping("/{id}/clone")
|
@PostMapping("/{id}/clone")
|
||||||
@RequirePerm(PermissionDef.TASK_MANAGE)
|
@RequirePerm(PermissionDef.TASK_CLONE)
|
||||||
@Operation(summary = "克隆任务",
|
@Operation(summary = "克隆任务",
|
||||||
description = "克隆**配置**(表单 / 材料清单 / 命名模板 / 审核配置 / 覆盖班级),"
|
description = "克隆**配置**(表单 / 材料清单 / 命名模板 / 审核配置 / 覆盖班级),"
|
||||||
+ "**刻意不克隆业务数据**(名册 / 填报单 / 材料 / 审核记录)。"
|
+ "**刻意不克隆业务数据**(名册 / 填报单 / 材料 / 审核记录)。"
|
||||||
|
|||||||
+95
@@ -0,0 +1,95 @@
|
|||||||
|
package cn.tianbaotong.fill.module.task.mapper;
|
||||||
|
|
||||||
|
import org.apache.ibatis.annotations.Delete;
|
||||||
|
import org.apache.ibatis.annotations.Param;
|
||||||
|
import org.apache.ibatis.annotations.Select;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 删除**已归档任务**时的级联清理({@code TaskService.delete},2026-09-30 客户确认,
|
||||||
|
* 见《03-接口设计》§17.2 D-9 与《08》F-14 —— 此前"已发布的任务删不掉",
|
||||||
|
* 演示/测试任务只能改名挪开,库会一直堆)。
|
||||||
|
*
|
||||||
|
* <h3>为什么集中在这么一个文件里</h3>
|
||||||
|
*
|
||||||
|
* <p>级联删除是"一次动作动十四张表"的地方,散在各 Mapper 里会让人
|
||||||
|
* 只看到其中一截而以为删干净了。集中放置 + 每个方法一行,
|
||||||
|
* review 时一眼能数全(与 {@code MpSceneMapper} 集中放匿名 SQL 是同一个道理)。
|
||||||
|
*
|
||||||
|
* <p><b>表清单来自事实</b>({@code information_schema.columns} 里带 {@code task_id} 列的
|
||||||
|
* 全部业务表,2026-09-30 核对为 14 张):任务本身不在此列(由 {@code TaskService} 删);
|
||||||
|
* {@code collect_message} 没有 {@code task_id} 列,学生收件箱是历史,保留。
|
||||||
|
*
|
||||||
|
* <p><b>为什么子表物理删、任务行却软删</b>:F-14 的动机是"库会一直堆"(演示/测试任务
|
||||||
|
* 只能改名挪开)—— 大头在子表(2000 行名册、材料、审核记录),物理删才真清掉;
|
||||||
|
* 而 {@code collect_task} 行本身必须<b>软删保留</b>,因为它要继续占住 {@code task_code}
|
||||||
|
* ({@code maxSeqByCodePrefix} 靠"含软删行的最大序号"保证编码不复用 ——
|
||||||
|
* 编码同时是文件目录名)。删除动作的<b>留痕在 {@code sys_operation_log}</b>
|
||||||
|
* ({@code DELETE_TASK},记了谁删的、删了多少行多少文件)。
|
||||||
|
*
|
||||||
|
* <p>⚠️ 全部 SQL 走 MyBatis-Plus 租户拦截器(自动补 {@code tenant_id},这些表都有该列),
|
||||||
|
* 删除动作在管理员的租户上下文里执行。
|
||||||
|
*/
|
||||||
|
public interface TaskCascadeMapper {
|
||||||
|
|
||||||
|
// ── 文件路径(先取再删行:物理删除要用,删完行就查不到了)──────
|
||||||
|
|
||||||
|
/** 材料文件路径(std 与 raw 用 | 分隔,可能为空串)。 */
|
||||||
|
@Select("""
|
||||||
|
SELECT coalesce(std_path, '') || '|' || coalesce(raw_path, '')
|
||||||
|
FROM collect_file WHERE task_id = #{taskId}
|
||||||
|
""")
|
||||||
|
List<String> filePaths(@Param("taskId") Long taskId);
|
||||||
|
|
||||||
|
/** 导出产物路径。 */
|
||||||
|
@Select("""
|
||||||
|
SELECT file_path FROM collect_export_file
|
||||||
|
WHERE task_id = #{taskId} AND file_path IS NOT NULL
|
||||||
|
""")
|
||||||
|
List<String> exportPaths(@Param("taskId") Long taskId);
|
||||||
|
|
||||||
|
// ── 物理删(这 5 张表没有 deleted 列)────────────────
|
||||||
|
|
||||||
|
@Delete("DELETE FROM collect_validation_issue WHERE task_id = #{taskId}")
|
||||||
|
int deleteValidationIssues(@Param("taskId") Long taskId);
|
||||||
|
|
||||||
|
@Delete("DELETE FROM collect_audit_record WHERE task_id = #{taskId}")
|
||||||
|
int deleteAuditRecords(@Param("taskId") Long taskId);
|
||||||
|
|
||||||
|
@Delete("DELETE FROM collect_notify_record WHERE task_id = #{taskId}")
|
||||||
|
int deleteNotifyRecords(@Param("taskId") Long taskId);
|
||||||
|
|
||||||
|
@Delete("DELETE FROM collect_open_push_log WHERE task_id = #{taskId}")
|
||||||
|
int deleteOpenPushLogs(@Param("taskId") Long taskId);
|
||||||
|
|
||||||
|
@Delete("DELETE FROM collect_file WHERE task_id = #{taskId}")
|
||||||
|
int deleteFiles(@Param("taskId") Long taskId);
|
||||||
|
|
||||||
|
@Delete("DELETE FROM collect_export_file WHERE task_id = #{taskId}")
|
||||||
|
int deleteExportFiles(@Param("taskId") Long taskId);
|
||||||
|
|
||||||
|
@Delete("DELETE FROM collect_export_task WHERE task_id = #{taskId}")
|
||||||
|
int deleteExportTasks(@Param("taskId") Long taskId);
|
||||||
|
|
||||||
|
@Delete("DELETE FROM collect_submission WHERE task_id = #{taskId}")
|
||||||
|
int deleteSubmissions(@Param("taskId") Long taskId);
|
||||||
|
|
||||||
|
@Delete("DELETE FROM collect_join_request WHERE task_id = #{taskId}")
|
||||||
|
int deleteJoinRequests(@Param("taskId") Long taskId);
|
||||||
|
|
||||||
|
@Delete("DELETE FROM collect_roster WHERE task_id = #{taskId}")
|
||||||
|
int deleteRoster(@Param("taskId") Long taskId);
|
||||||
|
|
||||||
|
@Delete("DELETE FROM collect_form_schema WHERE task_id = #{taskId}")
|
||||||
|
int deleteFormSchemas(@Param("taskId") Long taskId);
|
||||||
|
|
||||||
|
@Delete("DELETE FROM collect_material_def WHERE task_id = #{taskId}")
|
||||||
|
int deleteMaterialDefs(@Param("taskId") Long taskId);
|
||||||
|
|
||||||
|
@Delete("DELETE FROM collect_notify_template WHERE task_id = #{taskId}")
|
||||||
|
int deleteNotifyTemplates(@Param("taskId") Long taskId);
|
||||||
|
|
||||||
|
@Delete("DELETE FROM collect_task_org WHERE task_id = #{taskId}")
|
||||||
|
int deleteTaskOrgs(@Param("taskId") Long taskId);
|
||||||
|
}
|
||||||
+6
-1
@@ -58,6 +58,7 @@ public class RuleTemplateService {
|
|||||||
private final TaskMapper taskMapper;
|
private final TaskMapper taskMapper;
|
||||||
private final MaterialDefMapper materialDefMapper;
|
private final MaterialDefMapper materialDefMapper;
|
||||||
private final MaterialDefService materialDefService;
|
private final MaterialDefService materialDefService;
|
||||||
|
private final cn.tianbaotong.fill.module.form.service.FormSchemaService formSchemaService;
|
||||||
|
|
||||||
/** 可应用的三个部分(契约 §7.13 的 `parts`)。 */
|
/** 可应用的三个部分(契约 §7.13 的 `parts`)。 */
|
||||||
public static final Set<String> ALL_PARTS = new LinkedHashSet<>(
|
public static final Set<String> ALL_PARTS = new LinkedHashSet<>(
|
||||||
@@ -126,8 +127,12 @@ public class RuleTemplateService {
|
|||||||
// 空 = 用系统默认表单,**不覆盖**目标任务的既有表单
|
// 空 = 用系统默认表单,**不覆盖**目标任务的既有表单
|
||||||
r.getSkipped().add("FORM(模板没带表单定义,表示「用系统默认」,因此跳过)");
|
r.getSkipped().add("FORM(模板没带表单定义,表示「用系统默认」,因此跳过)");
|
||||||
} else {
|
} else {
|
||||||
|
// 2026-09-30 起接上真正的应用路径(此前只能"跳过并说明",见 F-57/F-64)。
|
||||||
|
// 走 FormSchemaService 的版本化保存:它保留"已发布任务不可改表单"的检查
|
||||||
|
// 与版本管理 —— 这里**不再自己写表**,两处逻辑必须只有一份。
|
||||||
assertNotPublished(target, "表单定义");
|
assertNotPublished(target, "表单定义");
|
||||||
r.getSkipped().add("FORM(一期还没有「保存表单定义」的服务端入口,见 F-57)");
|
formSchemaService.saveRawVersion(target.getId(), tpl.getFormSchema());
|
||||||
|
r.getApplied().add("FORM");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (want.contains("MATERIALS")) {
|
if (want.contains("MATERIALS")) {
|
||||||
|
|||||||
+10
@@ -258,6 +258,16 @@ public class TaskCloneService {
|
|||||||
row.setCertType(t.getCertType());
|
row.setCertType(t.getCertType());
|
||||||
row.setMaterials(arr.toString());
|
row.setMaterials(arr.toString());
|
||||||
row.setNamingTemplate(t.getNamingTemplate());
|
row.setNamingTemplate(t.getNamingTemplate());
|
||||||
|
// 表单定义:来源任务存过版本化表单就一并带上(2026-09-30 与「FORM 套用」一起补上 ——
|
||||||
|
// 此前存模板不存表单、套模板又用不上表单,两头各缺一半,见 F-57/F-64)。
|
||||||
|
// 没存过 = 用系统默认 → 存 null(套用时按「用默认,不覆盖」跳过)
|
||||||
|
var currentForm = formSchemaMapper.selectOne(Wrappers
|
||||||
|
.<cn.tianbaotong.fill.module.form.entity.CollectFormSchema>lambdaQuery()
|
||||||
|
.eq(cn.tianbaotong.fill.module.form.entity.CollectFormSchema::getTaskId, taskId)
|
||||||
|
.eq(cn.tianbaotong.fill.module.form.entity.CollectFormSchema::getIsCurrent,
|
||||||
|
cn.tianbaotong.fill.module.form.entity.CollectFormSchema.CURRENT)
|
||||||
|
.last("limit 1"));
|
||||||
|
row.setFormSchema(currentForm == null ? null : currentForm.getSchemaJson());
|
||||||
row.setIsPublic(pub ? CollectRuleTemplate.PUBLIC_YES : CollectRuleTemplate.PUBLIC_NO);
|
row.setIsPublic(pub ? CollectRuleTemplate.PUBLIC_YES : CollectRuleTemplate.PUBLIC_NO);
|
||||||
row.setUseCount(0);
|
row.setUseCount(0);
|
||||||
ruleTemplateMapper.insert(row);
|
ruleTemplateMapper.insert(row);
|
||||||
|
|||||||
+104
-7
@@ -73,6 +73,8 @@ public class TaskService {
|
|||||||
private final TaskReadinessMapper readinessMapper;
|
private final TaskReadinessMapper readinessMapper;
|
||||||
private final cn.tianbaotong.fill.module.material.service.MaterialDefService materialDefService;
|
private final cn.tianbaotong.fill.module.material.service.MaterialDefService materialDefService;
|
||||||
private final cn.tianbaotong.fill.module.log.service.OperationLogService operationLog;
|
private final cn.tianbaotong.fill.module.log.service.OperationLogService operationLog;
|
||||||
|
private final cn.tianbaotong.fill.module.task.mapper.TaskCascadeMapper cascadeMapper;
|
||||||
|
private final cn.tianbaotong.fill.module.file.storage.StorageService storageService;
|
||||||
|
|
||||||
// ── 列表 ──────────────────────────────────────
|
// ── 列表 ──────────────────────────────────────
|
||||||
|
|
||||||
@@ -213,10 +215,18 @@ public class TaskService {
|
|||||||
return o instanceof Number n ? n.longValue() : null;
|
return o instanceof Number n ? n.longValue() : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 是否看全租户:学校管理员与审核员。平台运营不分管具体学校的任务,但也不该被拦。 */
|
/**
|
||||||
|
* 是否看全租户:学校管理员与审核员。平台运营不分管具体学校的任务,但也不该被拦。
|
||||||
|
*
|
||||||
|
* <p>⚠️ 平台这一支必须用 {@link LoginUser#isPlatformStaff()} 而不是
|
||||||
|
* {@code isPlatformAdmin()}:平台**代管某所学校**时(请求头 {@code X-Tenant-Id})
|
||||||
|
* tenantId 被换成了那所学校,{@code isPlatformAdmin()} 就是 false 了 ——
|
||||||
|
* 那时会掉进下面"只看自己带的班"的分支,而平台在该校没有班级,
|
||||||
|
* 于是工作台的任务下拉变成**空列表**,看起来像"这所学校没有任务"。
|
||||||
|
*/
|
||||||
private boolean seesWholeTenant() {
|
private boolean seesWholeTenant() {
|
||||||
LoginUser me = UserContext.require();
|
LoginUser me = UserContext.require();
|
||||||
return me.isPlatformAdmin() || me.hasAnyRole("SCHOOL_ADMIN", "AUDITOR");
|
return me.isPlatformStaff() || me.hasAnyRole("SCHOOL_ADMIN", "AUDITOR");
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── 详情 ──────────────────────────────────────
|
// ── 详情 ──────────────────────────────────────
|
||||||
@@ -369,17 +379,104 @@ public class TaskService {
|
|||||||
taskMapper.updateById(patch);
|
taskMapper.updateById(patch);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── 删除(仅草稿)────────────────────────────
|
// ── 删除(草稿直接删;已归档的级联删,2026-09-30 客户确认)──────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 删除任务(《03-接口设计》§17.2 D-9;原口径"已发布即不可删"会让任务列表只增不减,
|
||||||
|
* 演示/测试数据只能改名挪开 —— 见《08》F-14)。
|
||||||
|
*
|
||||||
|
* <ul>
|
||||||
|
* <li><b>草稿</b>:直接删(没有业务数据,与原来一样);</li>
|
||||||
|
* <li><b>已归档(status=3)</b>:管理员显式删除 —— <b>子表级联物理删</b>
|
||||||
|
* (名册/填报单/材料/审核记录等 14 张表,清掉的是大头:证件号密文、材料、审核记录),
|
||||||
|
* 但<b>任务行本身软删</b> —— 它必须留下来占住 {@code task_code}:
|
||||||
|
* {@code TaskMapper.maxSeqByCodePrefix} 靠"含软删行的最大序号"保证
|
||||||
|
* <b>编码用过的永不复用</b>(编码同时是文件目录名,复用会把两批任务的文件
|
||||||
|
* 混进同一个目录)。E2E 抓到过这个坑:物理删任务行后新任务拿到重号。
|
||||||
|
* 物理文件尽力删(删不掉只记日志不阻断),<b>全程留痕</b>
|
||||||
|
* (sys_operation_log 记 DELETE_TASK + 行数/文件数);</li>
|
||||||
|
* <li><b>收集中/已截止</b>:仍然拒绝 —— 归档是"最后的后悔药",
|
||||||
|
* 直接删"还活着的数据"这个口子不开。</li>
|
||||||
|
* </ul>
|
||||||
|
*/
|
||||||
@Transactional(rollbackFor = Exception.class)
|
@Transactional(rollbackFor = Exception.class)
|
||||||
public void delete(Long id) {
|
public void delete(Long id) {
|
||||||
CollectTask exist = requireTask(id);
|
CollectTask exist = requireTask(id);
|
||||||
if (exist.getStatus() == null || exist.getStatus() != CollectTask.STATUS_DRAFT) {
|
int st = exist.getStatus() == null ? -1 : exist.getStatus();
|
||||||
throw new BizException(ErrorCode.VALIDATION_BLOCKED,
|
|
||||||
"只有草稿状态的任务可以删除;已发布的任务请用「截止」或「归档」");
|
if (st == CollectTask.STATUS_DRAFT) {
|
||||||
}
|
|
||||||
taskMapper.deleteById(id);
|
taskMapper.deleteById(id);
|
||||||
log.info("删除草稿任务 {}(id={})", exist.getTaskCode(), id);
|
log.info("删除草稿任务 {}(id={})", exist.getTaskCode(), id);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (st != CollectTask.STATUS_ARCHIVED) {
|
||||||
|
throw new BizException(ErrorCode.VALIDATION_BLOCKED,
|
||||||
|
"只有草稿或已归档的任务可以删除;收集中/已截止的任务请先「归档」再删"
|
||||||
|
+ "(删除会连带清掉名册、填报单、材料与文件,不可恢复)");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ★ 先取文件路径再删行 —— 行删了就查不到了(顺序反了 = 文件永远留在磁盘上)
|
||||||
|
List<String> filePaths = cascadeMapper.filePaths(id);
|
||||||
|
List<String> exportPaths = cascadeMapper.exportPaths(id);
|
||||||
|
|
||||||
|
int rows = cascadeMapper.deleteValidationIssues(id)
|
||||||
|
+ cascadeMapper.deleteAuditRecords(id)
|
||||||
|
+ cascadeMapper.deleteNotifyRecords(id)
|
||||||
|
+ cascadeMapper.deleteOpenPushLogs(id)
|
||||||
|
+ cascadeMapper.deleteFiles(id)
|
||||||
|
+ cascadeMapper.deleteExportFiles(id)
|
||||||
|
+ cascadeMapper.deleteExportTasks(id)
|
||||||
|
+ cascadeMapper.deleteSubmissions(id)
|
||||||
|
+ cascadeMapper.deleteJoinRequests(id)
|
||||||
|
+ cascadeMapper.deleteRoster(id)
|
||||||
|
+ cascadeMapper.deleteFormSchemas(id)
|
||||||
|
+ cascadeMapper.deleteMaterialDefs(id)
|
||||||
|
+ cascadeMapper.deleteNotifyTemplates(id)
|
||||||
|
+ cascadeMapper.deleteTaskOrgs(id);
|
||||||
|
// ★ 任务行**软删**(不是物理删):唯一约束 uk_task_tenant_code 不看 deleted,
|
||||||
|
// 软删行继续占住编码 → maxSeqByCodePrefix 的"编码不复用"不变量保持成立。
|
||||||
|
// 物理删它的话,下一个同批次任务会拿到重号(e2e-m1-task 的断言抓到过)。
|
||||||
|
taskMapper.deleteById(id);
|
||||||
|
|
||||||
|
int removed = deleteFilesQuietly(filePaths, exportPaths);
|
||||||
|
|
||||||
|
operationLog.record(
|
||||||
|
cn.tianbaotong.fill.module.log.service.OperationLogService.MODULE_TASK,
|
||||||
|
cn.tianbaotong.fill.module.log.service.OperationLogService.ACTION_DELETE_TASK,
|
||||||
|
"TASK", id,
|
||||||
|
"任务「" + exist.getTaskName() + "」(" + exist.getTaskCode() + ")已归档后删除",
|
||||||
|
"级联删除 " + rows + " 行;物理删除文件 " + removed + " 个");
|
||||||
|
log.info("删除已归档任务 {}(id={}):级联 {} 行,文件 {} 个,经办人={}",
|
||||||
|
exist.getTaskCode(), id, rows, removed, UserContext.uid());
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 尽力删除物理文件(本地磁盘或对象存储)。失败只记日志 —— 行已删,别让一个文件拖垮整个删除。 */
|
||||||
|
private int deleteFilesQuietly(List<String> filePaths, List<String> exportPaths) {
|
||||||
|
int ok = 0;
|
||||||
|
for (String pair : filePaths) {
|
||||||
|
// 形如 "std/1/xx.jpg|raw/1/xx.jpg"(两段都可能是空串)
|
||||||
|
for (String p : pair.split("\\|")) {
|
||||||
|
if (p != null && !p.isBlank() && deleteOneQuietly(p)) {
|
||||||
|
ok++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (String p : exportPaths) {
|
||||||
|
if (p != null && !p.isBlank() && deleteOneQuietly(p)) {
|
||||||
|
ok++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ok;
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean deleteOneQuietly(String path) {
|
||||||
|
try {
|
||||||
|
storageService.delete(path);
|
||||||
|
return true;
|
||||||
|
} catch (Exception e) { // noqa: BLE001
|
||||||
|
log.warn("删除任务时清理文件失败(不影响删除本身):{} → {}", path, e.getMessage());
|
||||||
|
return false;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── 发布 ──────────────────────────────────────
|
// ── 发布 ──────────────────────────────────────
|
||||||
|
|||||||
+4
-4
@@ -36,7 +36,7 @@ public class TenantController {
|
|||||||
private final TenantService tenantService;
|
private final TenantService tenantService;
|
||||||
|
|
||||||
@GetMapping
|
@GetMapping
|
||||||
@RequirePerm(PermissionDef.TENANT_MANAGE)
|
@RequirePerm(PermissionDef.TENANT_VIEW)
|
||||||
@Operation(summary = "租户列表")
|
@Operation(summary = "租户列表")
|
||||||
public ApiResult<PageResult<TenantVO>> list(
|
public ApiResult<PageResult<TenantVO>> list(
|
||||||
@RequestParam(required = false) String keyword,
|
@RequestParam(required = false) String keyword,
|
||||||
@@ -48,7 +48,7 @@ public class TenantController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@GetMapping("/{id}")
|
@GetMapping("/{id}")
|
||||||
@RequirePerm(PermissionDef.TENANT_MANAGE)
|
@RequirePerm(PermissionDef.TENANT_VIEW)
|
||||||
@Operation(summary = "租户详情")
|
@Operation(summary = "租户详情")
|
||||||
public ApiResult<TenantVO> detail(@PathVariable Long id) {
|
public ApiResult<TenantVO> detail(@PathVariable Long id) {
|
||||||
return ApiResult.ok(tenantService.detail(id));
|
return ApiResult.ok(tenantService.detail(id));
|
||||||
@@ -64,7 +64,7 @@ public class TenantController {
|
|||||||
|
|
||||||
|
|
||||||
@PutMapping("/{id}")
|
@PutMapping("/{id}")
|
||||||
@RequirePerm(PermissionDef.LOG_VIEW_ALL)
|
@RequirePerm(PermissionDef.TENANT_MANAGE)
|
||||||
@Operation(summary = "修改租户 / 套餐(§6.5)",
|
@Operation(summary = "修改租户 / 套餐(§6.5)",
|
||||||
description = "⚠️ `tenantCode` **不允许改**:它出现在域名解析与对接文档里,"
|
description = "⚠️ `tenantCode` **不允许改**:它出现在域名解析与对接文档里,"
|
||||||
+ "改了会让已经发出去的文档与配好的域名全部失效,"
|
+ "改了会让已经发出去的文档与配好的域名全部失效,"
|
||||||
@@ -77,7 +77,7 @@ public class TenantController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@GetMapping("/{id}/usage")
|
@GetMapping("/{id}/usage")
|
||||||
@RequirePerm(PermissionDef.LOG_VIEW_ALL)
|
@RequirePerm(PermissionDef.TENANT_VIEW)
|
||||||
@Operation(summary = "租户用量统计(§6.7)",
|
@Operation(summary = "租户用量统计(§6.7)",
|
||||||
description = "`period` 支持 `2026-09`(月)与 `2026Q3`(季);不传是当月。"
|
description = "`period` 支持 `2026-09`(月)与 `2026Q3`(季);不传是当月。"
|
||||||
+ "⚠️ `ocrCount` / `smsCount` **恒为 0 并带 notes 说明** —— "
|
+ "⚠️ `ocrCount` / `smsCount` **恒为 0 并带 notes 说明** —— "
|
||||||
|
|||||||
+18
@@ -119,4 +119,22 @@ public interface UserMapper extends BaseMapper<SysUser> {
|
|||||||
AND r.role_code IN ('PLATFORM_ADMIN', 'SCHOOL_ADMIN')
|
AND r.role_code IN ('PLATFORM_ADMIN', 'SCHOOL_ADMIN')
|
||||||
""")
|
""")
|
||||||
Long countAdminRoles(@Param("tenantId") Long tenantId, @Param("userId") Long userId);
|
Long countAdminRoles(@Param("tenantId") Long tenantId, @Param("userId") Long userId);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 本租户下持某个内置角色的用户 id(按角色找,而不是按固定账号 —— 换管理员不该改代码)。
|
||||||
|
*
|
||||||
|
* <p>用途:需要"管理员介入"的自动通知的收件人(例如驳回次数达上限,契约 §11.4)。
|
||||||
|
* 与 `ScheduleMapper.usersOfRole` 的区别:那条是**跨租户**的定时任务用的(忽略租户拦截器),
|
||||||
|
* 这条在租户上下文里用、租户条件显式给,并与拦截器互为印证。
|
||||||
|
*/
|
||||||
|
@InterceptorIgnore(tenantLine = "true")
|
||||||
|
@Select("""
|
||||||
|
SELECT DISTINCT u.id
|
||||||
|
FROM sys_user u
|
||||||
|
JOIN sys_user_role ur ON ur.user_id = u.id AND ur.deleted = 0
|
||||||
|
JOIN sys_role ro ON ro.id = ur.role_id AND ro.deleted = 0
|
||||||
|
WHERE u.tenant_id = #{tenantId} AND u.deleted = 0 AND u.status = 1
|
||||||
|
AND ro.role_code = #{roleCode}
|
||||||
|
""")
|
||||||
|
List<Long> userIdsOfRole(@Param("tenantId") Long tenantId, @Param("roleCode") String roleCode);
|
||||||
}
|
}
|
||||||
|
|||||||
+5
-1
@@ -164,7 +164,11 @@ public class UserService {
|
|||||||
final String kw = (raw == null || raw.isEmpty()) ? null : raw;
|
final String kw = (raw == null || raw.isEmpty()) ? null : raw;
|
||||||
|
|
||||||
LambdaQueryWrapper<SysUser> q = Wrappers.lambdaQuery();
|
LambdaQueryWrapper<SysUser> q = Wrappers.lambdaQuery();
|
||||||
q.eq(orgId != null, SysUser::getOrgId, orgId)
|
// ★ 显式租户条件(与 OrgService.tree 同一个理由):拦截器对平台运营
|
||||||
|
// (tenantId=0)整个关掉了过滤,不加这一条就会把**所有租户的账号**混在一起返回。
|
||||||
|
// 平台要看某所学校的账号,先在页面上选定学校(X-Tenant-Id),那时 tenantId 就是它。
|
||||||
|
q.eq(SysUser::getTenantId, UserContext.tenantId())
|
||||||
|
.eq(orgId != null, SysUser::getOrgId, orgId)
|
||||||
.eq(userType != null, SysUser::getUserType, userType)
|
.eq(userType != null, SysUser::getUserType, userType)
|
||||||
.eq(status != null, SysUser::getStatus, status);
|
.eq(status != null, SysUser::getStatus, status);
|
||||||
|
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ public class AuthInterceptor implements HandlerInterceptor {
|
|||||||
private final TokenBlacklist blacklist;
|
private final TokenBlacklist blacklist;
|
||||||
private final PermissionChecker permissionChecker;
|
private final PermissionChecker permissionChecker;
|
||||||
private final cn.tianbaotong.fill.support.TenantDomainResolver tenantDomainResolver;
|
private final cn.tianbaotong.fill.support.TenantDomainResolver tenantDomainResolver;
|
||||||
|
private final cn.tianbaotong.fill.support.ActingTenantResolver actingTenantResolver;
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) {
|
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) {
|
||||||
@@ -52,6 +53,21 @@ public class AuthInterceptor implements HandlerInterceptor {
|
|||||||
|
|
||||||
LoginUser user = jwtService.parse(token);
|
LoginUser user = jwtService.parse(token);
|
||||||
|
|
||||||
|
// ★ 平台运营的「当前学校」(请求头 X-Tenant-Id)。
|
||||||
|
//
|
||||||
|
// 平台是唯一跨租户的身份,租户拦截器因此对它关掉了过滤 —— 于是「组织架构」
|
||||||
|
// 「账号管理」这类租户内的页面会看到**所有租户混在一起**的数据,而且看不出错
|
||||||
|
// (只有一所学校有数据时,它和学校管理员看到的一模一样)。
|
||||||
|
// 所以要代管某所学校时必须显式指明:换掉 tenantId 之后 isPlatformAdmin() 变 false
|
||||||
|
// → 租户过滤自动生效,权限仍按 PLATFORM_ADMIN 那套判。
|
||||||
|
//
|
||||||
|
// ⚠️ 必须在下面的一致性校验**之前**:切换后要拿"生效租户"去和域名比,
|
||||||
|
// 否则在 t2 域名上代管 t1 会被放过去。
|
||||||
|
Long actingTenant = actingTenantResolver.resolve(request, user);
|
||||||
|
if (actingTenant != null) {
|
||||||
|
user = new LoginUser(user.uid(), actingTenant, user.orgIds(), user.roles(), user.userType());
|
||||||
|
}
|
||||||
|
|
||||||
// ★ 租户域名与 token 的租户必须一致 —— 这是"用域名区分租户"唯一必须守住的点。
|
// ★ 租户域名与 token 的租户必须一致 —— 这是"用域名区分租户"唯一必须守住的点。
|
||||||
//
|
//
|
||||||
// Host 只用来**定位**(这个请求落在谁的站点上),授权一律以 token 为准。
|
// Host 只用来**定位**(这个请求落在谁的站点上),授权一律以 token 为准。
|
||||||
|
|||||||
@@ -24,6 +24,15 @@ public enum BuiltinRole {
|
|||||||
PermissionDef.ORG_MANAGE, PermissionDef.ORG_VIEW,
|
PermissionDef.ORG_MANAGE, PermissionDef.ORG_VIEW,
|
||||||
PermissionDef.USER_MANAGE, PermissionDef.USER_VIEW,
|
PermissionDef.USER_MANAGE, PermissionDef.USER_VIEW,
|
||||||
PermissionDef.NOTIFY_SEND, PermissionDef.PROGRESS_VIEW_ALL,
|
PermissionDef.NOTIFY_SEND, PermissionDef.PROGRESS_VIEW_ALL,
|
||||||
|
// ⚠️ TASK_VIEW 是**读**权限(任务列表 / 详情 / 覆盖班级 / 规则模板 / 任务统计),
|
||||||
|
// PRD §3.2 的「查看填报进度看板」对平台运营是 ✅,而工作台的数据看板
|
||||||
|
// 要拿任务列表才能选任务 —— 少了它,平台点工作台会拿到 `/tasks` 40301。
|
||||||
|
//
|
||||||
|
// 它以前被"平台=超管"这个捷径掩盖着(`PermissionChecker.has` 对平台恒真),
|
||||||
|
// 直到平台开始**代管某所学校**(`X-Tenant-Id` → 不再是 isPlatformAdmin)
|
||||||
|
// 才暴露出来:那时只认角色声明的权限,于是任务列表直接 403。
|
||||||
|
// 只补读权限:TASK_MANAGE / TASK_CLONE 依旧不给(PRD 里平台运营是「—」)。
|
||||||
|
PermissionDef.TASK_VIEW,
|
||||||
PermissionDef.LOG_VIEW_ALL),
|
PermissionDef.LOG_VIEW_ALL),
|
||||||
Set.of("dashboard", "tenant", "org", "user", "log")),
|
Set.of("dashboard", "tenant", "org", "user", "log")),
|
||||||
|
|
||||||
@@ -41,8 +50,22 @@ public enum BuiltinRole {
|
|||||||
PermissionDef.FILE_PROXY_UPLOAD,
|
PermissionDef.FILE_PROXY_UPLOAD,
|
||||||
PermissionDef.EXPORT_DOWNLOAD, PermissionDef.EXPORT_UNMASKED, PermissionDef.CLEANUP_RUN,
|
PermissionDef.EXPORT_DOWNLOAD, PermissionDef.EXPORT_UNMASKED, PermissionDef.CLEANUP_RUN,
|
||||||
PermissionDef.LOG_VIEW_TENANT),
|
PermissionDef.LOG_VIEW_TENANT),
|
||||||
Set.of("dashboard", "org", "user", "role", "task", "form", "material",
|
Set.of("dashboard", "org", "user", "role", "task",
|
||||||
"roster", "join", "audit", "export", "cleanup", "log")),
|
"join", "audit", "export", "cleanup", "log",
|
||||||
|
// ⚠️ 顶层菜单里**没有**「名单管理 / 表单设计 / 材料规则」这三条。
|
||||||
|
// 它们的功能早就做好了,但都是**按任务**的:入口在「收集任务」
|
||||||
|
// 每一行的链接里(名单管理 / 表单设计 / 材料规则)。
|
||||||
|
// 以前菜单里挂着三条"功能在别处"的占位项,点进去只有一句指路 ——
|
||||||
|
// 那既占了导航,又让人以为"这里应该有事可做"。现在直接去掉。
|
||||||
|
// ⚠️ 这与权限无关:task/form/material/roster 相关权限码照旧保留,
|
||||||
|
// 只是不再有对应的菜单键。
|
||||||
|
//
|
||||||
|
// ⚠️ 这两个菜单键以前**没有任何角色持有** —— 而 PC 端的菜单可见性
|
||||||
|
// 就是拿这里拼出来的集合过滤的(`buildXtRoutes` 按 menuKeys 过滤),
|
||||||
|
// 于是「字典管理」与「通知记录」两个页面**做得再好也点不到**:
|
||||||
|
// 功能在、接口在、路由在,只是菜单里永远不出现(《08》F-106)。
|
||||||
|
// 这类错没有任何测试会红 —— 所以 `check-wiring.py` 现在会查它。
|
||||||
|
"dict", "notify")),
|
||||||
|
|
||||||
/** 班主任:只看自己带的班。 */
|
/** 班主任:只看自己带的班。 */
|
||||||
HEAD_TEACHER("HEAD_TEACHER", "班主任", UserType.TEACHER, DataScope.ORG_AND_CHILD, 20,
|
HEAD_TEACHER("HEAD_TEACHER", "班主任", UserType.TEACHER, DataScope.ORG_AND_CHILD, 20,
|
||||||
@@ -50,7 +73,7 @@ public enum BuiltinRole {
|
|||||||
PermissionDef.ROSTER_MANAGE_OWN, PermissionDef.ROSTER_VIEW,
|
PermissionDef.ROSTER_MANAGE_OWN, PermissionDef.ROSTER_VIEW,
|
||||||
PermissionDef.NOTIFY_SEND, PermissionDef.PROGRESS_VIEW_OWN,
|
PermissionDef.NOTIFY_SEND, PermissionDef.PROGRESS_VIEW_OWN,
|
||||||
PermissionDef.AUDIT_FIRST, PermissionDef.FILE_PROXY_UPLOAD),
|
PermissionDef.AUDIT_FIRST, PermissionDef.FILE_PROXY_UPLOAD),
|
||||||
Set.of("dashboard", "task", "roster", "audit")),
|
Set.of("dashboard", "task", "audit")),
|
||||||
|
|
||||||
/** 副班主任:协助初审与代传,但不发通知(避免一个班两个人都来催报)。 */
|
/** 副班主任:协助初审与代传,但不发通知(避免一个班两个人都来催报)。 */
|
||||||
DEPUTY_TEACHER("DEPUTY_TEACHER", "副班主任", UserType.TEACHER, DataScope.ORG_AND_CHILD, 30,
|
DEPUTY_TEACHER("DEPUTY_TEACHER", "副班主任", UserType.TEACHER, DataScope.ORG_AND_CHILD, 30,
|
||||||
@@ -58,14 +81,14 @@ public enum BuiltinRole {
|
|||||||
PermissionDef.ROSTER_VIEW,
|
PermissionDef.ROSTER_VIEW,
|
||||||
PermissionDef.PROGRESS_VIEW_OWN,
|
PermissionDef.PROGRESS_VIEW_OWN,
|
||||||
PermissionDef.AUDIT_FIRST, PermissionDef.FILE_PROXY_UPLOAD),
|
PermissionDef.AUDIT_FIRST, PermissionDef.FILE_PROXY_UPLOAD),
|
||||||
Set.of("dashboard", "task", "roster", "audit")),
|
Set.of("dashboard", "task", "audit")),
|
||||||
|
|
||||||
/** 审核员:专做审核,不给任何管理权限(最小权限原则)。 */
|
/** 审核员:专做审核,不给任何管理权限(最小权限原则)。 */
|
||||||
AUDITOR("AUDITOR", "审核员", UserType.SCHOOL_ADMIN, DataScope.TENANT, 40,
|
AUDITOR("AUDITOR", "审核员", UserType.SCHOOL_ADMIN, DataScope.TENANT, 40,
|
||||||
Set.of(PermissionDef.TASK_VIEW, PermissionDef.ROSTER_VIEW,
|
Set.of(PermissionDef.TASK_VIEW, PermissionDef.ROSTER_VIEW,
|
||||||
PermissionDef.PROGRESS_VIEW_ALL,
|
PermissionDef.PROGRESS_VIEW_ALL,
|
||||||
PermissionDef.AUDIT_FIRST, PermissionDef.AUDIT_FINAL),
|
PermissionDef.AUDIT_FIRST, PermissionDef.AUDIT_FINAL),
|
||||||
Set.of("dashboard", "task", "roster", "audit")),
|
Set.of("dashboard", "task", "audit")),
|
||||||
|
|
||||||
/** 学生:只能操作本人那一份。 */
|
/** 学生:只能操作本人那一份。 */
|
||||||
STUDENT("STUDENT", "学生", UserType.STUDENT, DataScope.SELF, 90,
|
STUDENT("STUDENT", "学生", UserType.STUDENT, DataScope.SELF, 90,
|
||||||
|
|||||||
@@ -0,0 +1,88 @@
|
|||||||
|
package cn.tianbaotong.fill.support;
|
||||||
|
|
||||||
|
import cn.tianbaotong.fill.common.api.ErrorCode;
|
||||||
|
import cn.tianbaotong.fill.common.context.LoginUser;
|
||||||
|
import cn.tianbaotong.fill.common.exception.BizException;
|
||||||
|
import cn.tianbaotong.fill.module.tenant.entity.SysTenant;
|
||||||
|
import cn.tianbaotong.fill.module.tenant.mapper.TenantMapper;
|
||||||
|
import jakarta.servlet.http.HttpServletRequest;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 平台运营的「当前学校」(请求头 {@code X-Tenant-Id})。
|
||||||
|
*
|
||||||
|
* <h3>它解决什么</h3>
|
||||||
|
*
|
||||||
|
* <p>平台运营({@code tenantId = 0})是唯一跨租户的身份,而租户拦截器对它
|
||||||
|
* <b>整个关掉了租户过滤</b>(见 {@code MybatisPlusConfig.FillTenantLineHandler})。
|
||||||
|
* 于是「组织架构」「账号管理」这类<b>本来就是租户内</b>的页面,在平台账号下看到的是
|
||||||
|
* <b>所有租户混在一起</b>的数据 —— 而这<b>看不出错</b>:当只有一所学校有数据时,
|
||||||
|
* 它和学校管理员看到的一模一样。
|
||||||
|
*
|
||||||
|
* <p>所以平台要操作某所学校的组织/账号时,必须<b>显式指明是哪一所</b>:
|
||||||
|
* 请求头带上目标租户 id,本类把它解析出来,{@code AuthInterceptor} 用它把
|
||||||
|
* {@link LoginUser} 的 tenantId 换成目标租户 —— 之后租户过滤、上下文写入、
|
||||||
|
* 权限判定全部按"我就是那所学校"走,<b>不需要每个接口各加一个参数</b>。
|
||||||
|
*
|
||||||
|
* <h3>四条约束(安全相关,不要松)</h3>
|
||||||
|
*
|
||||||
|
* <ol>
|
||||||
|
* <li><b>只认平台身份</b>:非平台用户带这个头一律忽略 —— 否则改一个数字就能越租户,
|
||||||
|
* 那正是本系统最不能出的一类漏洞;</li>
|
||||||
|
* <li>目标租户必须<b>存在且启用</b>,否则 {@code 40001},且不区分"不存在"与"被停用";</li>
|
||||||
|
* <li>换租户之后 {@code isPlatformAdmin()} 变成 false → 租户过滤自动生效。
|
||||||
|
* 这是<b>关键机制</b>(不是副作用):无需在别处再加一个"是否已切换"的判断;</li>
|
||||||
|
* <li><b>不带这个头时行为完全不变</b> —— 平台照旧可以跨租户看租户列表、全量日志。</li>
|
||||||
|
* </ol>
|
||||||
|
*
|
||||||
|
* <p>与 {@link TenantDomainResolver} 的分工:那个是"这条请求落在谁的域名上"(定位,匿名可用),
|
||||||
|
* 这个是"平台此刻要代管哪所学校"(显式选择,仅平台可用)。两者都会改变租户上下文,
|
||||||
|
* 所以都在 {@code AuthInterceptor} 里、<b>与 token 的租户一致性校验一起</b>完成。
|
||||||
|
*/
|
||||||
|
@Slf4j
|
||||||
|
@Component
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class ActingTenantResolver {
|
||||||
|
|
||||||
|
/** 平台运营指定"当前学校"的请求头。 */
|
||||||
|
public static final String HEADER = "X-Tenant-Id";
|
||||||
|
|
||||||
|
private final TenantMapper tenantMapper;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 解析本次请求的目标租户。
|
||||||
|
*
|
||||||
|
* @return 目标租户 id;<b>不是平台身份 / 没带头 / 头就是平台自身</b> 时返回 {@code null}
|
||||||
|
* (表示"不切换",按原有语义走)
|
||||||
|
*/
|
||||||
|
public Long resolve(HttpServletRequest req, LoginUser user) {
|
||||||
|
if (user == null || !user.isPlatformAdmin()) {
|
||||||
|
// 非平台身份带头一律忽略:这一条是安全边界,不是"顺手支持一下"
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
String raw = req == null ? null : req.getHeader(HEADER);
|
||||||
|
if (raw == null || raw.isBlank()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
long target;
|
||||||
|
try {
|
||||||
|
target = Long.parseLong(raw.trim());
|
||||||
|
} catch (NumberFormatException e) {
|
||||||
|
throw new BizException(ErrorCode.VALIDATION_BLOCKED,
|
||||||
|
HEADER + " 不是合法的租户号:" + raw);
|
||||||
|
}
|
||||||
|
if (target == LoginUser.PLATFORM_TENANT_ID) {
|
||||||
|
// 指着平台自己 = 不切换(而不是报错:前端清空选择时可能发 0)
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
SysTenant t = tenantMapper.selectById(target);
|
||||||
|
if (t == null || t.getStatus() == null || t.getStatus() != 1) {
|
||||||
|
// 两种原因给同一句话:不额外透露"这个租户存在但被停用了"
|
||||||
|
throw new BizException(ErrorCode.VALIDATION_BLOCKED, "目标单位不存在或已停用");
|
||||||
|
}
|
||||||
|
log.info("平台运营切换租户:uid={} → tenantId={}({})", user.uid(), target, t.getTenantName());
|
||||||
|
return target;
|
||||||
|
}
|
||||||
|
}
|
||||||
+88
@@ -0,0 +1,88 @@
|
|||||||
|
package cn.tianbaotong.fill.module.file.service;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 规范文件名引擎的最小单测(《08》F-9 / F-46)。
|
||||||
|
*
|
||||||
|
* <p>断言的是<b>有理由的规则</b>,不是覆盖率:
|
||||||
|
* <ul>
|
||||||
|
* <li>扩展名必须自带点号(F-46:少一个字符不报错,只会产出「身份证jpg」这种
|
||||||
|
* 客户双击打不开的文件);</li>
|
||||||
|
* <li>空变量用「未填」占位,而不是留空 —— 留空会出现「张同学--身份证.jpg」,
|
||||||
|
* 看起来像系统出错;</li>
|
||||||
|
* <li>文件名里的 {@code /} 必须被清掉(学生的「学籍表(1/2)」很常见,
|
||||||
|
* 带 / 的名字根本写不进磁盘);</li>
|
||||||
|
* <li>超长截断 —— Windows 路径 260 限制,目录还要留空间。</li>
|
||||||
|
* </ul>
|
||||||
|
*/
|
||||||
|
class FileProcessServiceTest {
|
||||||
|
|
||||||
|
private final FileProcessService svc = new FileProcessService();
|
||||||
|
|
||||||
|
private static Map<String, String> vars(String name, String id, String material) {
|
||||||
|
Map<String, String> m = new HashMap<>();
|
||||||
|
m.put("姓名", name);
|
||||||
|
m.put("证件号", id);
|
||||||
|
m.put("材料名", material);
|
||||||
|
return m;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void 默认模板_姓名证件号材料名_扩展名带点() {
|
||||||
|
String n = svc.renderName(null, vars("张三", "361029200907295110", "身份证"), ".jpg");
|
||||||
|
assertEquals("张三-361029200907295110-身份证.jpg", n);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void 模板带后缀占位符时不重复拼扩展名() {
|
||||||
|
String n = svc.renderName("{材料名}-{姓名}{后缀}",
|
||||||
|
vars("张三", "361029200907295110", "身份证"), ".jpg");
|
||||||
|
assertEquals("身份证-张三.jpg", n);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void 空变量用未填占位_不会出现双横线() {
|
||||||
|
String n = svc.renderName(null, vars("张三", "", "身份证"), ".jpg");
|
||||||
|
assertEquals("张三-未填-身份证.jpg", n);
|
||||||
|
assertFalse(n.contains("--"), "留空会出现「张--身份证.jpg」,看起来像系统出错: " + n);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void 文件名里的斜杠必须被清掉() {
|
||||||
|
// 「学籍表(1/2)」在真实学生的上传里很常见;带 / 的名字写不进磁盘
|
||||||
|
String n = svc.renderName(null, vars("张三", "361029200907295110", "学籍表(1/2)"), ".jpg");
|
||||||
|
assertFalse(n.contains("/"), "斜杠会导致写盘失败: " + n);
|
||||||
|
assertTrue(n.contains("学籍表(1_2)"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void 超长文件名截到安全长度() {
|
||||||
|
Map<String, String> m = vars("张".repeat(120), "361029200907295110", "身份证");
|
||||||
|
String n = svc.renderName(null, m, ".jpg");
|
||||||
|
assertTrue(n.length() <= 90, "Windows 路径 260 限制,正文要给目录留空间: " + n.length());
|
||||||
|
assertTrue(n.endsWith(".jpg"), "截断不能吃掉扩展名");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void sanitize_空白给未命名_全非法字符收拢成单个下划线() {
|
||||||
|
// "///" → "___" → 收拢成 "_"(不是「未命名」:未命名只留给纯空白,
|
||||||
|
// 全非法字符收拢后仍是可写盘的合法名字)
|
||||||
|
assertEquals("_", FileProcessService.sanitize("///"));
|
||||||
|
assertEquals("未命名", FileProcessService.sanitize(" "));
|
||||||
|
assertEquals(null, FileProcessService.sanitize(null));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void sanitize_连续分隔符收拢() {
|
||||||
|
assertEquals("a_b", FileProcessService.sanitize("a___b"));
|
||||||
|
assertEquals("a-b", FileProcessService.sanitize("a---b"));
|
||||||
|
}
|
||||||
|
}
|
||||||
+51
@@ -0,0 +1,51 @@
|
|||||||
|
package cn.tianbaotong.fill.support;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* {@link IdentityNormalizer} 的最小单测(《08》F-9:先给纯函数补单测,其余继续靠 E2E)。
|
||||||
|
*
|
||||||
|
* <p>选这三条规则断言,是因为每一条背后都有一个<b>真实踩过的坑</b>:
|
||||||
|
* <ul>
|
||||||
|
* <li>姓名去空白、但<b>不去间隔号</b> —— 少数民族姓名里的「·」是真实的;
|
||||||
|
* 顺手"清一清"会弄坏一部分人的名字(F-20 的另一面:规范化规则不能互相借用);</li>
|
||||||
|
* <li>全角转半角 —— 手机输入法常给全角数字,而它进校验位算法就是错的;</li>
|
||||||
|
* <li>证件号小写 {@code x} 收尾要统一成大写 —— 名册比对按字符串等值。</li>
|
||||||
|
* </ul>
|
||||||
|
*/
|
||||||
|
class IdentityNormalizerTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void 姓名去所有空白_含全角空格() {
|
||||||
|
assertEquals("张三", IdentityNormalizer.name("张 三"));
|
||||||
|
assertEquals("张三", IdentityNormalizer.name("张 三")); // 全角空格 U+3000
|
||||||
|
assertEquals("张三", IdentityNormalizer.name(" 张\t三\n"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void 姓名里的间隔号必须保留() {
|
||||||
|
// 阿依古丽·买买提:间隔号是姓名的一部分,清掉等于改了人家的名字
|
||||||
|
assertEquals("阿依古丽·买买提", IdentityNormalizer.name("阿依古丽·买买提"));
|
||||||
|
assertEquals("阿依古丽·买买提", IdentityNormalizer.name("阿依古丽 · 买买提"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void 全角ASCII转半角_其余不动() {
|
||||||
|
assertEquals("123X", IdentityNormalizer.toHalfWidth("123X"));
|
||||||
|
assertEquals("中文不变", IdentityNormalizer.toHalfWidth("中文不变"));
|
||||||
|
assertEquals("", IdentityNormalizer.toHalfWidth(null));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void 证件号去空白_转大写_全角可入() {
|
||||||
|
assertEquals("36102920090729511X", IdentityNormalizer.idCard("3610292009 0729511x"));
|
||||||
|
assertEquals("36102920090729511X", IdentityNormalizer.idCard("36102920090729511X"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void 姓名空值返回空串而不是NPE() {
|
||||||
|
assertEquals("", IdentityNormalizer.name(null));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -68,6 +68,7 @@
|
|||||||
| 2026-09-28 | **P5 开发任务拆解** | 完成 `05-开发任务拆解.md`(27.6KB / 468 行)。**94 个任务 / 380 人日 / 含缓冲 456**,工期 **14 周**。关键结论:**工期由关键路径(67 人日串行)决定,不能按人日总数摊人**;压缩周期只能砍范围不能加人。含 3 个客户演示点(A 第 5 周 / B 第 10 周 / C 第 14 周)与 §9 可复现核算口径 |
|
| 2026-09-28 | **P5 开发任务拆解** | 完成 `05-开发任务拆解.md`(27.6KB / 468 行)。**94 个任务 / 380 人日 / 含缓冲 456**,工期 **14 周**。关键结论:**工期由关键路径(67 人日串行)决定,不能按人日总数摊人**;压缩周期只能砍范围不能加人。含 3 个客户演示点(A 第 5 周 / B 第 10 周 / C 第 14 周)与 §9 可复现核算口径 |
|
||||||
| 2026-09-28 | **P4 核心模块设计** | 完成 `04-核心模块设计说明.md`(54KB / 1218 行)。六个模块全部伪代码级:M1 命名(含证件号三源裁决表)、M2 压缩(五档+二分降质+二次有损防护)、M3 识别(四层递进+成本闸)、M4 门禁(**并发绑定必须 UPDATE...WHERE bind_user_id IS NULL**)、M5 校验、M6 导出(快照先固化+中文文件名编码)。**校验中发现并修正三处真问题**,见下条教训 |
|
| 2026-09-28 | **P4 核心模块设计** | 完成 `04-核心模块设计说明.md`(54KB / 1218 行)。六个模块全部伪代码级:M1 命名(含证件号三源裁决表)、M2 压缩(五档+二分降质+二次有损防护)、M3 识别(四层递进+成本闸)、M4 门禁(**并发绑定必须 UPDATE...WHERE bind_user_id IS NULL**)、M5 校验、M6 导出(快照先固化+中文文件名编码)。**校验中发现并修正三处真问题**,见下条教训 |
|
||||||
| 2026-09-28 | **P3 接口设计** | 完成 `03-接口设计.md`(102KB / 2626 行)。**117 个接口**全部字段级定义(PRD 原 96 + 新增 21);新增 §2 枚举全集(含「无」的三条铁律)、§3 通用数据结构(12 个 VO,字段名与 DDL 列一一对应)、§15 错误码全集(50 个,新增 30);**自校验通过**:模块表合计=声称=实际唯一数=117,无真重复,引用字段名 116 个全部存在于 DDL |
|
| 2026-09-28 | **P3 接口设计** | 完成 `03-接口设计.md`(102KB / 2626 行)。**117 个接口**全部字段级定义(PRD 原 96 + 新增 21);新增 §2 枚举全集(含「无」的三条铁律)、§3 通用数据结构(12 个 VO,字段名与 DDL 列一一对应)、§15 错误码全集(50 个,新增 30);**自校验通过**:模块表合计=声称=实际唯一数=117,无真重复,引用字段名 116 个全部存在于 DDL |
|
||||||
|
| 2026-10-09 | **验收走查后的四项修正** | ① **去掉 3 条无用菜单**(名单管理 / 表单设计器 / 材料规则)—— 功能都在「收集任务」行内入口,菜单里那一条只是指路;后端菜单键同步删除;② **修掉班主任点工作台必弹 403**(工作台无条件调了要 `org:view` / `progress:viewAll` 的两个接口,班主任都没这两个权限)→ 改为按权限决定发不发请求;③ **新增请求头 `X-Tenant-Id`(§1.2)**:平台运营的「组织架构 / 账号管理 / 工作台」原先拿到的是**所有租户混在一起**的数据,现在必须先在页面上选定学校;④ **修掉开放接口的"推送重试"从未生效**(F-112:定时扫描缺 `@InterceptorIgnore`,异常被 warn 吞掉;E2E 补了"真的重试了"的断言)。详见 `交接摘要` §一·三十三 |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
+19
-3
@@ -217,7 +217,7 @@ B1 选 A,但希望增加审批环节
|
|||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| D1 | OCR 引擎选型 | 待比价(腾讯云 / 百度 / 本地模型) | 需同时评估**数据出境合规** |
|
| D1 | OCR 引擎选型 | 待比价(腾讯云 / 百度 / 本地模型) | 需同时评估**数据出境合规** |
|
||||||
| D2 | 视觉大模型(VLM)选型 | 抽成**可替换接口**,不绑定供应商 | 实测触发率仅 2%~10%,成本可控 |
|
| D2 | 视觉大模型(VLM)选型 | 抽成**可替换接口**,不绑定供应商 | 实测触发率仅 2%~10%,成本可控 |
|
||||||
| ~~D3~~ | ~~门禁限流阈值~~ | ⚠️ **口径已更新,见 E2**:改为"**只对失败计数**"(按人 10 次/小时、按 IP 20 次/分钟、成功侧 200 次/分钟)—— 旧口径会把整间教室挡住 | 压测后已改(2026-09-30) |
|
| ~~D3~~ | ~~门禁限流阈值~~ | ⚠️ **口径已更新,见 E2**:改为"**只对失败计数**"(按人 10 次/小时、按 IP 20 次/分钟;成功侧 600 次/分钟,2026-09-30 二次压测后由 200 上调)—— 旧口径会把整间教室挡住 | 压测后已改(2026-09-30,两次) |
|
||||||
| D4 | 材料识别强特征词表 | 平台预置 + 租户可自维护 | **需在"社会评价"批次数据上补充标定** |
|
| D4 | 材料识别强特征词表 | 平台预置 + 租户可自维护 | **需在"社会评价"批次数据上补充标定** |
|
||||||
| D5 | 一寸照片形态判定阈值 | 用真实样本标定(宽高比容差、背景纯净度) | — |
|
| D5 | 一寸照片形态判定阈值 | 用真实样本标定(宽高比容差、背景纯净度) | — |
|
||||||
| D6 | 微信三条来源通道的体积上限 | 需真机实测 | 决定超限提示与降级路径 |
|
| D6 | 微信三条来源通道的体积上限 | 需真机实测 | 决定超限提示与降级路径 |
|
||||||
@@ -267,7 +267,7 @@ B1 选 A,但希望增加审批环节
|
|||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| 失败(按人) | **只计失败** | 10 次/小时 | 防猜 |
|
| 失败(按人) | **只计失败** | 10 次/小时 | 防猜 |
|
||||||
| 失败(按 IP) | **只计失败** | 20 次/分钟 | 防枚举 |
|
| 失败(按 IP) | **只计失败** | 20 次/分钟 | 防枚举 |
|
||||||
| 成功(按 IP) | 只计成功 | 200 次/分钟 | 防"批量取数据" |
|
| 成功(按 IP) | 只计成功 | **600 次/分钟**(2026-09-30 由 200 上调,见下) | 防"批量取数据" |
|
||||||
|
|
||||||
**为什么必须改**:一个班的学生坐在同一间教室、连同一个 WiFi,出口是同一个公网 IP。
|
**为什么必须改**:一个班的学生坐在同一间教室、连同一个 WiFi,出口是同一个公网 IP。
|
||||||
按旧口径,60 人同时扫码时**只有 20 个能进**,其余 40 个看到「操作过于频繁」——
|
按旧口径,60 人同时扫码时**只有 20 个能进**,其余 40 个看到「操作过于频繁」——
|
||||||
@@ -275,9 +275,16 @@ B1 选 A,但希望增加审批环节
|
|||||||
|
|
||||||
关键区分:**枚举攻击产生的是失败,教室产生的是成功。**
|
关键区分:**枚举攻击产生的是失败,教室产生的是成功。**
|
||||||
|
|
||||||
|
> **2026-09-30 二次修正(备案,无需回复)**:成功侧上限原定 200 次/分钟,
|
||||||
|
> 实测**恰好卡在"一个年级"的量级** —— 250 个不同学生同时核验(全部正确)时
|
||||||
|
> **213 个成功、37 个被挡**。全校在同一个出口 IP 集中填报(开学第一课集体扫码)
|
||||||
|
> 就会撞上,而**没有人做错了什么**。已上调为 **600 次/分钟**,
|
||||||
|
> 复测 250 人 **250/250**、500 人 **500/500**;失败侧两个计数器原样未动
|
||||||
|
> (防枚举的强度没有降低)。详见《09-部署与运维》§9.3。
|
||||||
|
|
||||||
### E3. 二期(社会收集 / 社会评价 / 专项职业能力)的启动时点
|
### E3. 二期(社会收集 / 社会评价 / 专项职业能力)的启动时点
|
||||||
|
|
||||||
一期范围已按约定锁死为「学校收集 + 自主认定」(其他模式一律返回 `40044`)。
|
一期范围已按约定锁死为「学校收集 + 自助认定」(其他模式一律返回 `40044`)。
|
||||||
但**二期什么时候启动**会影响两件现在就能做、也可以等的事:
|
但**二期什么时候启动**会影响两件现在就能做、也可以等的事:
|
||||||
|
|
||||||
| 事项 | 如果二期在 3 个月内启动 | 如果更晚 |
|
| 事项 | 如果二期在 3 个月内启动 | 如果更晚 |
|
||||||
@@ -288,6 +295,15 @@ B1 选 A,但希望增加审批环节
|
|||||||
|
|
||||||
回复:☐ 二期预计 ____ 启动 ☐ 暂无计划
|
回复:☐ 二期预计 ____ 启动 ☐ 暂无计划
|
||||||
|
|
||||||
|
### E4. 国标职业库数据的来源(字典页「职业(国标)」目前是空的)
|
||||||
|
|
||||||
|
`sys_occupation_ref`(国标职业库)一期为 **0 行**:契约 §13.3 把 `OCCUPATION` 字典的数据来源
|
||||||
|
指定为这张表,而它是**国标数据**(GB/T 职业分类),需要贵方提供或采购后导入 ——
|
||||||
|
不是我们能自行生成的。现状:字典管理页里「职业(国标)」显示 0 项(如实展示,不假装有数据);
|
||||||
|
`itemCount` 直接用 `COUNT(*)`,数据导入后自动就对了,**不需要改代码**。
|
||||||
|
|
||||||
|
回复:☐ 我方提供数据(预计 ____) ☐ 采购(预算 ____) ☐ 一期不上这个字典
|
||||||
|
|
||||||
## 附:已闭环的决策(无需再讨论)
|
## 附:已闭环的决策(无需再讨论)
|
||||||
|
|
||||||
| 决策 | 内容 | 依据 |
|
| 决策 | 内容 | 依据 |
|
||||||
|
|||||||
+1
-1
@@ -19,7 +19,7 @@
|
|||||||
| FR-600 材料与规则 | 23 | 18 | ✅ | ✅ | ⚠️ | ✅ 识别结果已并入审核详情(P3) |
|
| FR-600 材料与规则 | 23 | 18 | ✅ | ✅ | ⚠️ | ✅ 识别结果已并入审核详情(P3) |
|
||||||
| FR-800 审核工作台 | 10 | 7 | ✅ | ✅ | ✅ | ✅ 撤回接口已补(P3) |
|
| FR-800 审核工作台 | 10 | 7 | ✅ | ✅ | ✅ | ✅ 撤回接口已补(P3) |
|
||||||
| FR-900 汇总导出 | 10 | 8 | ✅ | ✅ | ✅ | ✅ **全覆盖** |
|
| 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-1100 数据看板 | 5 | 2 | ✅ | ✅ | ✅ | ✅ **全覆盖** |
|
||||||
| FR-1200 系统管理 | 6 | 5 | ⚠️ | ✅ | ❌ | ⚠️ 接口已补(P3),原型仍缺 |
|
| FR-1200 系统管理 | 6 | 5 | ⚠️ | ✅ | ❌ | ⚠️ 接口已补(P3),原型仍缺 |
|
||||||
| FR-1300 SaaS 计费 | 4 | 2 | ⚠️ | ⚠️ | ❌ | ⚠️ 缺平台运营端 |
|
| FR-1300 SaaS 计费 | 4 | 2 | ⚠️ | ⚠️ | ❌ | ⚠️ 缺平台运营端 |
|
||||||
|
|||||||
+184
-34
@@ -70,6 +70,25 @@
|
|||||||
|
|
||||||
**Token 有效期**:PC 端 8 小时(可续期)/ 小程序端 7 天(静默续期)
|
**Token 有效期**:PC 端 8 小时(可续期)/ 小程序端 7 天(静默续期)
|
||||||
|
|
||||||
|
#### `X-Tenant-Id`:平台运营的「当前学校」(可选,仅平台运营)
|
||||||
|
|
||||||
|
平台运营(`tenantId = 0`)是唯一跨租户的身份,服务端的租户过滤对它整体关闭。于是
|
||||||
|
「组织架构」「账号管理」这类**本身就是租户内**的接口,平台不指明学校时会拿到
|
||||||
|
**所有租户混在一起**的数据 —— 而这看不出错(只有一所学校有数据时,它与学校管理员看到的完全一样)。
|
||||||
|
|
||||||
|
| 项 | 约定 |
|
||||||
|
|---|---|
|
||||||
|
| Header | `X-Tenant-Id: <tenantId>` |
|
||||||
|
| 谁能用 | **只认平台运营**。其它身份带这个头一律忽略(改了也不生效,不是"报错") |
|
||||||
|
| 作用 | 本次请求的租户上下文换成该租户:`/orgs/**`、`/users/**`、`/tasks/**` 等按它过滤与写入 |
|
||||||
|
| 不带 / 传 `0` | 不切换(`0` 视为"平台自己")。平台的租户列表、全量日志等跨租户能力照旧 |
|
||||||
|
| 非法值 | 非数字、或租户不存在/已停用 → `40001` |
|
||||||
|
| 与 token 的关系 | 只对平台生效,**不能**用来访问别的租户(学校管理员带它不会越权) |
|
||||||
|
| 权限怎么判 | 切换之后**不再享受"平台超管"捷径**,一律按 `PLATFORM_ADMIN` 声明的权限码判 —— 所以那些码必须真的够用(例如「查看填报进度看板」要 `task:view` + `progress:viewAll`) |
|
||||||
|
|
||||||
|
> 例:`GET /api/v1/orgs/tree` + `X-Tenant-Id: 1` → 返回租户 1 的组织树;
|
||||||
|
> 不带该头 → 返回平台自己租户的组织(空树),**不再**把各租户的组织混在一起。
|
||||||
|
|
||||||
### 1.3 统一响应
|
### 1.3 统一响应
|
||||||
|
|
||||||
```json
|
```json
|
||||||
@@ -252,24 +271,20 @@
|
|||||||
| `rosterSubmitStatus` | `0` 未提交 / `1` 已提交 / `2` 已通过 / `3` 已驳回 |
|
| `rosterSubmitStatus` | `0` 未提交 / `1` 已提交 / `2` 已通过 / `3` 已驳回 |
|
||||||
| `fileValidateStatus` | `0` 待校验 / `1` 通过 / `2` 警告 / `3` 不通过 / `4` 待人工 |
|
| `fileValidateStatus` | `0` 待校验 / `1` 通过 / `2` 警告 / `3` 不通过 / `4` 待人工 |
|
||||||
| `auditNode` | `1` 初审 / `2` 终审 |
|
| `auditNode` | `1` 初审 / `2` 终审 |
|
||||||
| `auditAction` | `1` 通过 / `2` 驳回 / `3` **撤回** / `4` 代传 |
|
| `auditAction` | `1` 通过 / `2` 驳回 / `3` 批量通过 / `4` 批量驳回 / `5` 转交 / `6` 代传 / `7` **撤回** |
|
||||||
| `detectSource` | `1` 文件名 / `2` 形态 / `3` OCR 内容 / `4` VLM / `5` 用户指定 |
|
| `detectSource` | `1` 文件名 / `2` 形态 / `3` OCR 内容 / `4` VLM / `5` 用户指定 |
|
||||||
| `compressProfile` | `P1` 证件照 / `P2` 证件翻拍 / `P3` A4 扫描件 / `P4` 截图 / `P5` 兜底 |
|
| `compressProfile` | `P1` 证件照 / `P2` 证件翻拍 / `P3` A4 扫描件 / `P4` 截图 / `P5` 兜底 |
|
||||||
| `notifyChannel` | `1` 微信订阅消息 / `2` 短信 / `3` 公众号模板 |
|
| `notifyChannel` | `1` 微信订阅消息 / `2` 短信 / `3` 公众号模板 |
|
||||||
| `notifyBizType` | `1` 任务发布 / `2` 临期提醒 / `3` 驳回通知 / `4` 审核通过 / `5` 导出完成 / `6` 催报 / `7` 加入名单审批 ⚠️ 见下方注 |
|
| `notifyBizType` | `1` 任务发布 / `2` 临期提醒 / `3` 驳回通知 / `4` 审核通过 / `5` **催报** / `6` **导出完成** / `7` 加入名单审批 |
|
||||||
| `exportStatus` | `0` 排队中 / `1` 打包中 / `2` 已完成 / `3` 失败 / `4` 已过期 |
|
| `exportStatus` | `0` 排队中 / `1` 打包中 / `2` 已完成 / `3` 失败 / `4` 已过期 |
|
||||||
| `cleanupSourceType` | `1` 压缩包 / `2` 文件夹 / `3` 云盘链接 |
|
| `cleanupSourceType` | `1` 压缩包 / `2` 文件夹 / `3` 云盘链接 |
|
||||||
| `cleanupStatus` | `0` 排队 / `1` 解析中 / `2` 处理中 / `3` 已完成 / `4` 失败 |
|
| `cleanupStatus` | `0` 排队 / `1` 解析中 / `2` 处理中 / `3` 已完成 / `4` 失败 |
|
||||||
|
|
||||||
> ⚠️ **`notifyBizType` 的 `5` / `6` 在本文件内部是不一致的**:本表写「`5` 导出完成 / `6` 催报」,
|
> ✅ **`notifyBizType` 的 `5`/`6` 曾经在本文件内部对调过(已修正,2026-09-30 客户确认)**:
|
||||||
> 而 §13.5 消息中心的示例(`2` 临期提醒 / `3` 驳回)与落库值 `collect_notify_record.biz_type`
|
> 本表旧版写「`5` 导出完成 / `6` 催报」,与 §13.5 消息中心示例、落库值
|
||||||
> 用的是「**`5` 催报 / `6` 导出完成**」。
|
> `collect_notify_record.biz_type`(`5` 催报 / `6` 导出完成)不一致 ——
|
||||||
>
|
> 若按旧表实现,催报会被写成"导出完成",**催报去重会静默失效**(老师点两下学生收到两条),
|
||||||
> **实现以 §13.5 为准**(代码里是 `CollectMessage.BIZ_URGE = 5` / `BIZ_EXPORT = 6`,
|
> 而排查时从数据上完全看不出是码对不上。现全文档统一为「`5` 催报 / `6` 导出完成」。
|
||||||
> 催报去重按 `5` 走)。这不是文案问题:若按本表实现,催报会被写成"导出完成",
|
|
||||||
> **去重会静默失效**(老师点两下学生收到两条),而排查时从数据上完全看不出是码对不上。
|
|
||||||
>
|
|
||||||
> 需要客户确认以哪一边为准;若确认以本表为准,要一起改的是两个常量 + 已落库的历史数据。
|
|
||||||
> 记录见《08-完成度盘点与问题清单》**F-30**。
|
> 记录见《08-完成度盘点与问题清单》**F-30**。
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -472,18 +487,14 @@
|
|||||||
| `vlmCalled` | bool | `vlm_called` | 是否调用过 VLM(**成本监控用**) |
|
| `vlmCalled` | bool | `vlm_called` | 是否调用过 VLM(**成本监控用**) |
|
||||||
| `validateStatus` | int | `validate_status` | 见 §2.7 `fileValidateStatus` |
|
| `validateStatus` | int | `validate_status` | 见 §2.7 `fileValidateStatus` |
|
||||||
| `issues` | array | — | 该文件的 `ValidationIssueVO[]` |
|
| `issues` | array | — | 该文件的 `ValidationIssueVO[]` |
|
||||||
| `source` | int | `source` | `1` 聊天记录 / `2` 相册 / `3` 手机文件 / `4` 代传 ⚠️ 见下方注 |
|
| `source` | int | `source` | `1` 本人上传 / `2` 班主任代传 / `3` 管理员代传 / `4` 整理器导入 |
|
||||||
| `rawExpireTime` | string | `raw_expire_time` | **原图过期时间(默认 30 天)** |
|
| `rawExpireTime` | string | `raw_expire_time` | **原图过期时间(默认 30 天)** |
|
||||||
|
|
||||||
> ⚠️ **`collect_file.source` 有两套互相冲突的口径**:本表写的是"从哪个入口选的"
|
> ✅ **`collect_file.source` 的口径曾经有两套,已确认(2026-09-30 客户确认)**:本表旧版写
|
||||||
> (`1` 聊天记录 / `2` 相册 / `3` 手机文件 / `4` 代传),而建表注释(`V1__business_tables.sql`)
|
> "从哪个入口选的"(`1` 聊天记录 / `2` 相册 / `3` 手机文件 / `4` 代传),与建表注释
|
||||||
> 与实现(`CollectFile.SOURCE_*`)写的是"**谁传的**":
|
> (`V1__business_tables.sql`)冲突。**以建表注释为准("谁传的")**:§10.6 要求代传可追溯,
|
||||||
> `1` 本人上传 / `2` 班主任代传 / `3` 管理员代传 / `4` 整理器导入。
|
> 要回答的是"**谁**替他传的、**为什么**",而"从哪个入口选的"会随同一个学生的操作路径变化,
|
||||||
>
|
> 统计上没有意义。实现见 `CollectFile.SOURCE_*` 常量。记录见《08》**F-38 / F-39**。
|
||||||
> **实现以建表注释为准**:§10.6 要求代传必须可追溯,要回答的是"**谁**替他传的、
|
|
||||||
> **为什么**",而不是"他从哪个入口选的"—— 后者在同一个学生身上还会随操作路径变化。
|
|
||||||
> 两个口径都是 4 个值、含义完全不同,所以"照字段表写"会得到一份**看起来有数据、
|
|
||||||
> 但答案是错的**统计。记录见《08-完成度盘点与问题清单》**F-38 / F-39**。
|
|
||||||
|
|
||||||
**`ownership.verdict` 取值**(对应 PRD §6.1 归属裁决矩阵)
|
**`ownership.verdict` 取值**(对应 PRD §6.1 归属裁决矩阵)
|
||||||
|
|
||||||
@@ -533,7 +544,7 @@
|
|||||||
| `id` | int64 | — |
|
| `id` | int64 | — |
|
||||||
| `auditNode` | int | `1` 初审 / `2` 终审 |
|
| `auditNode` | int | `1` 初审 / `2` 终审 |
|
||||||
| `auditorId` / `auditorName` | int64 / string | 审核人 |
|
| `auditorId` / `auditorName` | int64 / string | 审核人 |
|
||||||
| `action` | int | 见 §2.7 `auditAction`(含 `3` **撤回**) |
|
| `action` | int | 见 §2.7 `auditAction`(**撤回是 `7`**;`3` 是批量通过 —— 旧版此表把两者写混过) |
|
||||||
| `reasonCode` | string | 驳回原因码(见 PRD 附录 C) |
|
| `reasonCode` | string | 驳回原因码(见 PRD 附录 C) |
|
||||||
| `reasonText` | string | 手填原因 |
|
| `reasonText` | string | 手填原因 |
|
||||||
| `materialCodes` | string[] | 关联材料 |
|
| `materialCodes` | string[] | 关联材料 |
|
||||||
@@ -1792,7 +1803,18 @@ Excel 列:`层级` `组织名称` `组织编码` `上级编码` `负责人手
|
|||||||
|
|
||||||
**自动保存**:前端每 15 秒或失焦时调一次;服务端**不限流**(草稿合并是轻量操作)。
|
**自动保存**:前端每 15 秒或失焦时调一次;服务端**不限流**(草稿合并是轻量操作)。
|
||||||
|
|
||||||
### 9.5 ⭐ `POST /api/v1/mp/files/upload-token` 获取直传凭证
|
### 9.5 ⭐ `POST /api/v1/mp/files/upload-token` 上传前置检查(预检 / 秒传 / 压缩建议)
|
||||||
|
|
||||||
|
> ✅ **定位已确认(2026-09-30 客户确认,原「OSS 直传凭证」改为此口径)**:
|
||||||
|
> 一期实现是**服务端一步上传**(`POST /api/v1/mp/files/upload`,multipart,
|
||||||
|
> 收上来即压缩 + 按模板改名,见 §17.2 D-3),**不走对象存储直传** ——
|
||||||
|
> 直传要把上传凭证交给前端、且七牛直传必须配回调才能让服务端知道"传完了"。
|
||||||
|
> 本接口因此**不是安全边界**(客户端可以跳过它直接上传,服务端上传本身做全部校验),
|
||||||
|
> 它的价值是**在上传一个字节之前**完成三件事:
|
||||||
|
> ① 预检(格式 / 体积 / 份数,传 8MB 才发现格式不对就晚了);
|
||||||
|
> ② **秒传**(同 `fileHash` 已存在 → `existed: true`,网络断开重试时不用重传);
|
||||||
|
> ③ 端侧压缩建议(能大幅降低上传流量)。
|
||||||
|
> 大文件 / 弱网场景要恢复直传时再单独立项(含回调闭环),见《08》**F-3 / F-71 / F-72**。
|
||||||
|
|
||||||
**幂等**:是
|
**幂等**:是
|
||||||
|
|
||||||
@@ -1805,7 +1827,7 @@ Excel 列:`层级` `组织名称` `组织编码` `上级编码` `负责人手
|
|||||||
| `fileName` | string | ✅ | 原始文件名(**可能是乱的,正常**) |
|
| `fileName` | string | ✅ | 原始文件名(**可能是乱的,正常**) |
|
||||||
| `fileSize` | int64 | ✅ | 字节 |
|
| `fileSize` | int64 | ✅ | 字节 |
|
||||||
| `contentType` | string | ✅ | MIME |
|
| `contentType` | string | ✅ | MIME |
|
||||||
| `source` | int | — | `1` 聊天记录 / `2` 相册 / `3` 手机文件(⚠️ 落库口径见 §3.6 注:实际存的是「谁传的」= 1 本人上传 / 2 班主任代传 / 3 管理员代传 / 4 整理器导入) |
|
| `source` | int | — | `1` 本人上传 / `2` 班主任代传 / `3` 管理员代传 / `4` 整理器导入(口径见 §3.6 注) |
|
||||||
| `fileHash` | string | — | 客户端 SHA-256(**用于秒传与去重**) |
|
| `fileHash` | string | — | 客户端 SHA-256(**用于秒传与去重**) |
|
||||||
|
|
||||||
**响应**
|
**响应**
|
||||||
@@ -1815,10 +1837,9 @@ Excel 列:`层级` `组织名称` `组织编码` `上级编码` `负责人手
|
|||||||
"code": 0,
|
"code": 0,
|
||||||
"data": {
|
"data": {
|
||||||
"fileId": 987654321,
|
"fileId": 987654321,
|
||||||
"uploadUrl": "https://oss.xxx.com/raw/1001/2026090001/202609/9f3a....jpg?Signature=...",
|
"direct": false,
|
||||||
"objectKey": "raw/1001/2026090001/202609/9f3a....jpg",
|
"uploadUrl": "/api/v1/mp/files/upload",
|
||||||
"expireSeconds": 900,
|
"expireSeconds": 900,
|
||||||
"headers": { "Content-Type": "image/jpeg" },
|
|
||||||
"existed": false,
|
"existed": false,
|
||||||
"clientCompressHint": {
|
"clientCompressHint": {
|
||||||
"enabled": true, "maxLongSide": 1600, "quality": 80, "targetSizeKb": 1024
|
"enabled": true, "maxLongSide": 1600, "quality": 80, "targetSizeKb": 1024
|
||||||
@@ -1829,14 +1850,23 @@ Excel 列:`层级` `组织名称` `组织编码` `上级编码` `负责人手
|
|||||||
|
|
||||||
| 字段 | 说明 |
|
| 字段 | 说明 |
|
||||||
|---|---|
|
|---|---|
|
||||||
|
| `direct` | 恒为 `false`(一期不做对象存储直传)。`uploadUrl` 就是本项目自己的接收地址,学生端 `wx.uploadFile` 直接 POST 它,与 H5 路径完全一样 |
|
||||||
| `existed` | `true` = **秒传命中**(同 hash 已存在),前端跳过上传直接进入处理 |
|
| `existed` | `true` = **秒传命中**(同 hash 已存在),前端跳过上传直接进入处理 |
|
||||||
| `clientCompressHint` | **端侧预压缩建议**。这是第一道防线,能大幅降低上传流量与处理时长;**服务端仍会按规则重压**,端侧压缩只是加速 |
|
| `clientCompressHint` | **端侧预压缩建议**。这是第一道防线,能大幅降低上传流量与处理时长;**服务端仍会按规则重压**,端侧压缩只是加速 |
|
||||||
|
|
||||||
> **端侧压缩与「一次编码到位」的关系**:实测 300dpi/q85 即可达标,**端侧压缩只是减少上传量的优化,不是质量决定环节**。服务端始终以原图(或端侧压缩后的图)为输入重新规范,保证输出一致。
|
> **端侧压缩与「一次编码到位」的关系**:实测 300dpi/q85 即可达标,**端侧压缩只是减少上传量的优化,不是质量决定环节**。服务端始终以原图(或端侧压缩后的图)为输入重新规范,保证输出一致。
|
||||||
|
|
||||||
### 9.6 ⭐ `POST /api/v1/mp/files/{fileId}/process` 文件规范化(核心)
|
### 9.6 ⭐ `POST /api/v1/mp/files/{fileId}/process` 文件规范化(有原始件时的重新处理)
|
||||||
|
|
||||||
**说明**:这是**命名 + 压缩 + 识别 + 校验**的总入口。
|
> ✅ **语义已确认(2026-09-30,原「命名+压缩+识别+校验总入口」改为此口径)**:
|
||||||
|
> 一期上传是**一步**的 —— `POST /mp/files/upload` 收上来**当场**完成压缩、按模板改名、校验、
|
||||||
|
> 记录问题(含 VAL-06 自动修复),默认**只存规范件**(`fill.storage.keep-raw=false`)。
|
||||||
|
> 所以本接口**只在「保留了原始件」时有用**:对原始件重新跑一遍规范化
|
||||||
|
> (典型场景:改了材料规则后重压)。默认配置下多数文件没有原始件,调用会明确说明这一点,
|
||||||
|
> 而不是假装处理了一遍。识别 / OCR 的四层递进是二期(M3/M4),见 §17.2 D-12。
|
||||||
|
> 下面的响应结构保留作为「规范化产物」的字段参考(上传接口的产物与之同构)。
|
||||||
|
|
||||||
|
**说明**:对**有原始件**的文件重新执行规范化(压缩 + 改名 + 校验)。
|
||||||
|
|
||||||
**请求**
|
**请求**
|
||||||
|
|
||||||
@@ -1937,7 +1967,11 @@ Excel 列:`层级` `组织名称` `组织编码` `上级编码` `负责人手
|
|||||||
|
|
||||||
**删除约束**:已提交并进入审核的文件删除需先**撤回**(`40030`)。
|
**删除约束**:已提交并进入审核的文件删除需先**撤回**(`40030`)。
|
||||||
|
|
||||||
### 9.9 `POST /api/v1/mp/ocr/idcard`
|
### 9.9 `POST /api/v1/mp/ocr/idcard`(一期缓做)
|
||||||
|
|
||||||
|
> ⚠️ **一期未实现(等 OCR 供应商选型,《01-客户与机构确认函》D1)**。
|
||||||
|
> 这是 121 个契约接口里**唯一**没有实现的一个;表单预填与校验位校验在学生端走人工填写。
|
||||||
|
> 选型要同时评估**数据出境合规**。选型定了之后本节按原设计实现,以下为设计参考。
|
||||||
|
|
||||||
**说明**:身份证 OCR,用于表单预填与校验。**这是唯一返回明文姓名的 OCR 场景**。
|
**说明**:身份证 OCR,用于表单预填与校验。**这是唯一返回明文姓名的 OCR 场景**。
|
||||||
|
|
||||||
@@ -2287,11 +2321,13 @@ Excel 列:`层级` `组织名称` `组织编码` `上级编码` `负责人手
|
|||||||
| 条件 | 结果 |
|
| 条件 | 结果 |
|
||||||
|---|---|
|
|---|---|
|
||||||
| 当前状态 `3`(已通过)且未被导出 | ✅ 允许,回退到 `2` 或 `1` |
|
| 当前状态 `3`(已通过)且未被导出 | ✅ 允许,回退到 `2` 或 `1` |
|
||||||
|
| 当前状态 `2`(待终审) | ✅ 允许,回退到 `1`(撤回的是**初审**结论 —— 初审通过后发现填错,正是最需要撤的时刻) |
|
||||||
| **已被导出打包** | ❌ `40033`——**已交付的数据不可追溯修改** |
|
| **已被导出打包** | ❌ `40033`——**已交付的数据不可追溯修改** |
|
||||||
| 已归档 | ❌ `40034` |
|
| 已归档 | ❌ `40034` |
|
||||||
| 撤回者非原审核人且非管理员 | ❌ `40301` |
|
| 撤回者非原审核人且非管理员 | ❌ `40301` |
|
||||||
|
|
||||||
**留痕**:写入一条 `action = 3`(撤回)的 `collect_audit_record`,时间轴显示"李老师撤回了通过结论"。
|
**留痕**:写入一条 `action = 7`(撤回)的 `collect_audit_record`,时间轴显示"李老师撤回了通过结论"。
|
||||||
|
(旧版写 `action = 3` —— `3` 实际是**批量通过**,撤回恒为 `7`,与代码常量 `ACTION_REVOKE` 一致。)
|
||||||
|
|
||||||
### 11.5 `GET /api/v1/audit/submissions/{id}/timeline`
|
### 11.5 `GET /api/v1/audit/submissions/{id}/timeline`
|
||||||
|
|
||||||
@@ -2320,15 +2356,19 @@ Excel 列:`层级` `组织名称` `组织编码` `上级编码` `负责人手
|
|||||||
|
|
||||||
**说明**:任务级全量校验,产出问题清单。
|
**说明**:任务级全量校验,产出问题清单。
|
||||||
|
|
||||||
|
> ✅ **改为同步(2026-09-30 客户确认)**:旧版声明 `async` 默认 `true` 并返回 `jobId`,
|
||||||
|
> 但全契约**没有"查 jobId"的接口** —— 那个 jobId 查不到任何东西。
|
||||||
|
> 实测一份填报单的校验 <100ms、一个任务的量级秒级,**同步跑完直接回结果**
|
||||||
|
> 比返回一个查不到状态的作业号有用。保留 `jobId` 仅作本次运行的标识(幂等对账用)。
|
||||||
|
|
||||||
| 字段 | 类型 | 默认 | 说明 |
|
| 字段 | 类型 | 默认 | 说明 |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| `taskId` | int64 | ✅ | — |
|
| `taskId` | int64 | ✅ | — |
|
||||||
| `scope` | string | `ALL` | `ALL` / `ISSUES_ONLY` / `APPOINTED` |
|
| `scope` | string | `ALL` | `ALL` / `ISSUES_ONLY` / `APPOINTED` |
|
||||||
| `submissionIds` | int64[] | — | `scope=APPOINTED` 时必填 |
|
| `submissionIds` | int64[] | — | `scope=APPOINTED` 时必填 |
|
||||||
| `rules` | string[] | 全部 | 只跑指定规则(如 `["VAL-02","VAL-04"]`) |
|
| `rules` | string[] | 全部 | 只跑指定规则(如 `["VAL-02","VAL-04"]`) |
|
||||||
| `async` | bool | `true` | — |
|
|
||||||
|
|
||||||
**响应**:`{ "jobId": "v_3f2a", "estimatedSeconds": 30 }`
|
**响应**:`{ "jobId": "v_3f2a", "checkedSubmissions": 320, "issuesFound": 47, "unresolvedLeft": 31, "elapsedMs": 812 }`
|
||||||
|
|
||||||
### 12.2 `GET /api/v1/validation/issues`
|
### 12.2 `GET /api/v1/validation/issues`
|
||||||
|
|
||||||
@@ -2613,6 +2653,13 @@ Excel 列:`层级` `组织名称` `组织编码` `上级编码` `负责人手
|
|||||||
|
|
||||||
**Query**:`tenantId`、`operatorId`、`module`、`action`、`targetId`、`from` / `to`、分页
|
**Query**:`tenantId`、`operatorId`、`module`、`action`、`targetId`、`from` / `to`、分页
|
||||||
|
|
||||||
|
**范围**(权限二选一,`@RequirePerm({log:viewTenant, log:viewAll})`):
|
||||||
|
|
||||||
|
| 调用者 | 权限 | 看到什么 |
|
||||||
|
|---|---|---|
|
||||||
|
| 平台运营 | `log:viewAll` | **默认全量**(跨租户);传 `tenantId` 则只看该单位 |
|
||||||
|
| 学校管理员 / 教务处 | `log:viewTenant` | **恒为本租户**;传了别的 `tenantId` → `40301`(**不静默忽略**:静默会让人以为"筛过了") |
|
||||||
|
|
||||||
**响应**
|
**响应**
|
||||||
|
|
||||||
```json
|
```json
|
||||||
@@ -2648,7 +2695,9 @@ Excel 列:`层级` `组织名称` `组织编码` `上级编码` `负责人手
|
|||||||
|
|
||||||
#### `GET /api/v1/logs/logins`
|
#### `GET /api/v1/logs/logins`
|
||||||
|
|
||||||
**Query**:`userId`、`userType`、`result`、`from` / `to`、分页
|
**Query**:`tenantId`、`userId`、`userType`、`result`、`from` / `to`、分页
|
||||||
|
|
||||||
|
**范围**:与操作日志同一口径(`log:viewAll` 可跨租户,学校管理员恒为本租户)。
|
||||||
|
|
||||||
**响应**:`{ list: [{ id, userId, userName, userType, loginType, ip, location, device, result, failReason, loginTime }], total }`
|
**响应**:`{ list: [{ id, userId, userName, userType, loginType, ip, location, device, result, failReason, loginTime }], total }`
|
||||||
|
|
||||||
@@ -2883,6 +2932,7 @@ X-Sign: HMAC-SHA256(appSecret, appKey + timestamp + nonce + body)
|
|||||||
|
|
||||||
| 版本 | 日期 | 变更 |
|
| 版本 | 日期 | 变更 |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
|
| **V2.6** | **2026-09-30** | **六处口径修正 + 新增 §17(全部经客户确认)**:① §2.7 `notifyBizType` 5/6 对调修正(5 催报 / 6 导出完成,F-30);② §2.7 `auditAction` 补全为 7 个动作、撤回=7(F-7);③ §3.6/§9.5 `source` 改为建表口径「谁传的」(F-38/39);④ §9.5 改为「上传前置检查」定位 + §9.6 `process` 语义改为「有原始件时的重新规范化」(F-3/71/72);⑤ §9.9 OCR 标注一期缓做;⑥ §11.4 撤回约束补「状态 2 可撤(撤初审)」、留痕 `action=7`;⑦ §12.1 `validation/run` 改同步并返回统计(F-51);⑧ **新增 §17 实现补充与偏差记录**(41 个 §4 未列接口按六类分组 + 12 条已确认偏离 D-1~D-12)。同步记录见《08-完成度盘点与问题清单》 |
|
||||||
| **V2.5** | **2026-09-30** | **两处"文档内部口径冲突"加注(不改口径,只把冲突摆到明处)**:① §2 枚举 `notifyBizType` 的 `5`/`6`(本文件写「5 导出完成 / 6 催报」)与 §13.5 消息中心示例、落库值 `collect_notify_record.biz_type`(「**5 催报 / 6 导出完成**」)不一致 —— **实现以 §13.5 为准**,需要客户确认以哪边为准(若以 §2 为准,要同时改两个常量与已落库数据);② §3.6 与 §9.5 的 `collect_file.source` 字段表写的是"入口来源"(1 聊天记录 / 2 相册 / 3 手机文件 / 4 代传),而建表注释与实现(`CollectFile.SOURCE_*`)是"**谁传的**"(1 本人上传 / 2 班主任代传 / 3 管理员代传 / 4 整理器导入)—— **实现以建表口径为准**(§10.6 要求代传可追溯,要回答的是"谁替他传的")。两处均已在原文加 ⚠️ 注。记录见《08-完成度盘点与问题清单》**F-30 / F-38 / F-39**。 |
|
| **V2.5** | **2026-09-30** | **两处"文档内部口径冲突"加注(不改口径,只把冲突摆到明处)**:① §2 枚举 `notifyBizType` 的 `5`/`6`(本文件写「5 导出完成 / 6 催报」)与 §13.5 消息中心示例、落库值 `collect_notify_record.biz_type`(「**5 催报 / 6 导出完成**」)不一致 —— **实现以 §13.5 为准**,需要客户确认以哪边为准(若以 §2 为准,要同时改两个常量与已落库数据);② §3.6 与 §9.5 的 `collect_file.source` 字段表写的是"入口来源"(1 聊天记录 / 2 相册 / 3 手机文件 / 4 代传),而建表注释与实现(`CollectFile.SOURCE_*`)是"**谁传的**"(1 本人上传 / 2 班主任代传 / 3 管理员代传 / 4 整理器导入)—— **实现以建表口径为准**(§10.6 要求代传可追溯,要回答的是"谁替他传的")。两处均已在原文加 ⚠️ 注。记录见《08-完成度盘点与问题清单》**F-30 / F-38 / F-39**。 |
|
||||||
| **V2.4** | **2026-09-28** | **修正 3 处加密列宽不足(DDL 缺陷)**:`sys_tenant.contact_phone`、`sys_user.phone`、`collect_roster.phone` 原为 `varchar(20)`,是按**明文手机号**的长度建的;但 PRD §10.3 要求手机号 AES-256 加密存储,AES-GCM 密文约 **52 字符**,插入会直接报 `value too long for type character varying(20)`。(`id_card_no` 当初给了 `varchar(128)` 就是考虑到了加密,手机号三列漏了——属设计不一致。)三列统一改为 **`varchar(128)`**,与 `id_card_no` 对齐。**发现方式**:M0 开通租户实测时数据库报错暴露。**审计结论**:其余 `*_mask varchar(32)` 列存的是脱敏值(如 `138****8888`)而非密文,宽度足够,无需改动。 |
|
| **V2.4** | **2026-09-28** | **修正 3 处加密列宽不足(DDL 缺陷)**:`sys_tenant.contact_phone`、`sys_user.phone`、`collect_roster.phone` 原为 `varchar(20)`,是按**明文手机号**的长度建的;但 PRD §10.3 要求手机号 AES-256 加密存储,AES-GCM 密文约 **52 字符**,插入会直接报 `value too long for type character varying(20)`。(`id_card_no` 当初给了 `varchar(128)` 就是考虑到了加密,手机号三列漏了——属设计不一致。)三列统一改为 **`varchar(128)`**,与 `id_card_no` 对齐。**发现方式**:M0 开通租户实测时数据库报错暴露。**审计结论**:其余 `*_mask varchar(32)` 列存的是脱敏值(如 `138****8888`)而非密文,宽度足够,无需改动。 |
|
||||||
| **V1.2** | **2026-09-28** | **错误码规范化**:`40007b` / `40007c` 带字母后缀,与 §1.3「`code` 为数字」的约定冲突(JSON 里会变成字符串,前端 `code === 0` 判断失效)。改为纯数字:**`40007b` → `40071`**(门禁限流)、**`40007c` → `40072`**(名单项已被绑定)。总码数不变,仍 **58 个**。 |
|
| **V1.2** | **2026-09-28** | **错误码规范化**:`40007b` / `40007c` 带字母后缀,与 §1.3「`code` 为数字」的约定冲突(JSON 里会变成字符串,前端 `code === 0` 判断失效)。改为纯数字:**`40007b` → `40071`**(门禁限流)、**`40007c` → `40072`**(名单项已被绑定)。总码数不变,仍 **58 个**。 |
|
||||||
@@ -2902,3 +2952,103 @@ X-Sign: HMAC-SHA256(appSecret, appKey + timestamp + nonce + body)
|
|||||||
| 错误码数量 | 58 个(新增 38 个标 ⭐)✓ |
|
| 错误码数量 | 58 个(新增 38 个标 ⭐)✓ |
|
||||||
| 文件规模 | 约 100 KB / 2570 行 |
|
| 文件规模 | 约 100 KB / 2570 行 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## §17 实现补充与偏差记录
|
||||||
|
|
||||||
|
> **§4 的 121 个是设计口径**。本节回答两件事:① 代码里还有哪些 §4 没列的接口;
|
||||||
|
> ② 哪些地方**有意偏离**了本文档的原始写法、为什么、谁确认的。
|
||||||
|
>
|
||||||
|
> ⚠️ 17.1 的清单由工具生成:`python scripts/check-api-parity.py --extra`
|
||||||
|
> (当前 **41 个**,生成于 2026-09-30)。**手抄清单会过期,以命令输出为准**;
|
||||||
|
> 下表按"为什么不在 §4"分组,便于决定"补进 §4 还是留在本节"。
|
||||||
|
|
||||||
|
### 17.1 代码有、§4 清单没有的接口(41 个,按偏离原因分组)
|
||||||
|
|
||||||
|
**A · 材料访问通道(7 个)** —— 审核端与学生端各一条通道(审核端要看防伪纹,学生端只看自己的);上传是一步 multipart
|
||||||
|
|
||||||
|
| 接口 | 说明 |
|
||||||
|
|---|---|
|
||||||
|
| `POST /api/v1/mp/files/upload` | 实际的上传入口(一步 multipart,收上来即压缩+改名) |
|
||||||
|
| `GET /api/v1/mp/files/{id}/url` | 学生端取签名预览链接 |
|
||||||
|
| `GET /api/v1/mp/files/{id}/content` | 学生端直出内容 |
|
||||||
|
| `GET /api/v1/mp/files/submissions/{submissionId}` | 按提交单列出材料 |
|
||||||
|
| `GET /api/v1/audit/files/{id}/url` | 审核端签名链接(独立通道;支持 `?thumb=true` 取缩略图) |
|
||||||
|
| `GET /api/v1/audit/files/{id}/content` | 审核端直出内容 |
|
||||||
|
| `GET /api/v1/mp/teacher/files/{fileId}/preview` | 教师端(代传场景)预览学生材料 |
|
||||||
|
|
||||||
|
**B · 微信授权链路代替 `POST /mp/login`(4 个)**
|
||||||
|
|
||||||
|
| 接口 | 说明 |
|
||||||
|
|---|---|
|
||||||
|
| `GET /api/v1/mp/h5/authorize` | H5 微信网页授权跳转 |
|
||||||
|
| `GET /api/v1/mp/h5/callback` | 授权回调(token 放 URL fragment) |
|
||||||
|
| `GET /api/v1/mp/h5/stub-login` | **仅非生产**打桩登录(`stub-enabled` + profile 双重保护) |
|
||||||
|
| `GET /api/v1/mp/scene-context` | 匿名扫码落地页查询(scene 三段式,只返回最少信息 + 限流) |
|
||||||
|
|
||||||
|
**C · 签名 URL 下载(1 个)**
|
||||||
|
|
||||||
|
| 接口 | 说明 |
|
||||||
|
|---|---|
|
||||||
|
| `GET /api/v1/exports/{id}/file` | 导出包的流式下载。§12.6 只定义了"取下载信息";真正下文件走这条,**URL 有有效期、不能入库/外发** |
|
||||||
|
|
||||||
|
**D · 界面与接口必需、清单漏列(17 个)**
|
||||||
|
|
||||||
|
| 接口 | 说明 |
|
||||||
|
|---|---|
|
||||||
|
| `GET /api/v1/auth/captcha` | 图形验证码(连续失败 2 次后必填) |
|
||||||
|
| `GET /api/v1/auth/public-key` | 密码 RSA 加密传输用的公钥 |
|
||||||
|
| `GET /api/v1/material-catalog` | 材料目录(码/档位/格式/模板/体积范围) |
|
||||||
|
| `POST /api/v1/tasks/{id}/materials/apply-template` | 应用内置材料模板(幂等) |
|
||||||
|
| `POST /api/v1/tasks/{id}/save-as-template` | 保存为模板(§7.13 的缺口,F-62) |
|
||||||
|
| `POST /api/v1/audit/submissions/batch-approve` | 批量通过(§11.3 标题带过、清单未单列) |
|
||||||
|
| `GET /api/v1/mp/teacher/audit/reason-codes` | 教师端驳回原因字典(§11.7 有 PC 版) |
|
||||||
|
| `GET /api/v1/mp/teacher/submissions/{id}/proxy-context` | 代传前取上下文(哪个班、哪份草稿) |
|
||||||
|
| `GET /api/v1/qrcodes/{scene}` | 匿名出图 |
|
||||||
|
| `POST /api/v1/roster/{rid}/deactivate` | 名册项"标记不参与"(删除的可逆替代,§8.3/8.4) |
|
||||||
|
| `GET /api/v1/tenants/{id}` | 租户详情 |
|
||||||
|
| `PUT /api/v1/users/{id}/status` | 启用/停用(清单只有 DELETE 停用) |
|
||||||
|
| `DELETE /api/v1/dicts/items/{id}` | 删除自定义字典项(§13.3) |
|
||||||
|
| `POST /api/v1/dicts/{code}/items/{itemCode}/toggle` | 内置字典项停用/恢复(§13.3) |
|
||||||
|
| `GET /api/v1/exports/precheck` | 导出前置检查(只检查不创建;界面靠它禁用按钮) |
|
||||||
|
| `GET /api/v1/exports/preview` | 导出目录树预览(与真正打包共用同一份构建代码) |
|
||||||
|
| `GET /api/v1/cleanup/jobs/{id}/result` | 整理器报告(§13.2 界面轮询用) |
|
||||||
|
|
||||||
|
**E · 契约链条原本断在第一步(8 个,已实现)**
|
||||||
|
|
||||||
|
| 接口 | 说明 |
|
||||||
|
|---|---|
|
||||||
|
| `POST /api/v1/notify/templates/{code}/customize` | 把内置模板复制成可编辑的库行(F-52:内置模板没有 id,`PUT /{id}` 拿不出 id) |
|
||||||
|
| `DELETE /api/v1/notify/templates/{code}` | 恢复默认模板 |
|
||||||
|
| `GET /api/v1/notify/templates/variables` | 变量白名单(前端抄一份迟早不一致) |
|
||||||
|
| `POST /api/v1/open-apps` · `GET /api/v1/open-apps` | §14 的接入方管理(F-67:没有它,appSecret 只能改库产生) |
|
||||||
|
| `POST /api/v1/open-apps/{id}/reset-secret` | 重置密钥(显式动作,会让对方现有签名立即失效) |
|
||||||
|
| `POST /api/v1/open-apps/{id}/toggle` | 启停接入方 |
|
||||||
|
| `GET /api/v1/open-apps/push-logs` | 推送记录(人工重推的依据) |
|
||||||
|
|
||||||
|
**F · 运维与调试动作(4 个)** —— 定时任务在测试里"等不起",所以都有手工入口(权限走运营侧 `log:view:all`)
|
||||||
|
|
||||||
|
| 接口 | 说明 |
|
||||||
|
|---|---|
|
||||||
|
| `POST /api/v1/schedules/run` | 手工触发定时任务(`job` 取值 close/urge/timeout/raw/usage/retry/expire) |
|
||||||
|
| `POST /api/v1/exports/cleanup-run` | 手工触发导出包过期清理(可重复执行) |
|
||||||
|
| `POST /api/v1/cleanup/jobs/json` | 整理器:直接提交 JSON(不走上传) |
|
||||||
|
| `POST /api/v1/cleanup/jobs/parse-preview` | 整理器:解析预览(不落库) |
|
||||||
|
|
||||||
|
### 17.2 有意偏离契约的地方(均已确认,2026-09-30)
|
||||||
|
|
||||||
|
| # | 偏离点 | 契约原口径 | 实现口径 | 依据 |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| D-1 | `notifyBizType` 5/6 | §2.7 旧表「5 导出完成 / 6 催报」 | 「**5 催报 / 6 导出完成**」(与 §13.5、落库值一致) | F-30;按旧表实现催报去重会静默失效 |
|
||||||
|
| D-2 | `collect_file.source` | §3.6 旧表「入口来源」(聊天记录/相册/手机文件/代传) | 「**谁传的**」:1 本人 / 2 班主任代传 / 3 管理员代传 / 4 整理器(按建表注释) | F-38/F-39;§10.6 代传可追溯 |
|
||||||
|
| D-3 | 上传链路 | OSS 直传 + `upload-token` 凭证 | **服务端一步上传**;`upload-token` 定位为**预检+秒传+压缩建议**(非安全边界) | F-3/F-71/F-72;直传需配回调且把凭证交前端 |
|
||||||
|
| D-4 | `process` 语义 | 「命名+压缩+识别+校验总入口」 | 上传当场完成这些;`process` = **有原始件时的重新规范化** | F-3;默认 `keep-raw=false` |
|
||||||
|
| D-5 | `POST /validation/run` | `async=true` + jobId | **同步跑完**并返回 `checkedSubmissions/issuesFound/unresolvedLeft/elapsedMs` | F-51;全契约没有查 jobId 的接口 |
|
||||||
|
| D-6 | 撤回动作码 | §11.4 旧文 `action=3` | **`action=7`(撤回)**;`3` 是批量通过;状态 2/3 均可撤(2 撤的是初审) | F-7;时间轴不能混淆 |
|
||||||
|
| D-7 | §14 路径前缀 | §14 详情写 `POST /open/v1/...`(无 `/api/v1`) | 统一 `/api/v1/open/v1/...`(与 §1 及其余接口一致,网关一条规则) | F-65 |
|
||||||
|
| D-8 | `biz_type` 落库类型 | §6 字段表按数字码定义 | `collect_notify_record.biz_type` 是 `varchar(32)`,存**数字码的字符串形式**("3");接口层仍是数字 | F-26;DDL 不可改 |
|
||||||
|
| D-9 | 任务删除 | 已发布任务不可删(`40001`) | **已归档(status=3)的任务可删**:管理员显式动作 + 操作留痕(`DELETE_TASK`,记行数/文件数);**子表级联物理删**(名册/材料/审核记录等 14 张表)、**任务行本身软删**(占住 `task_code` 不复用 —— 编码是文件目录名) | F-14;否则任务列表只增不减 |
|
||||||
|
| D-10 | 租户域名 | 未写 | 能力已实现(Host 定位 + token 授权),**默认关闭**;启用四步见《交接摘要》§一·二十六 | F-10;客户决定先不做 |
|
||||||
|
| D-11 | 通知降级链 | 「订阅消息→公众号模板→短信→站内信」四级 | 一期**两级**(订阅消息→站内信);公众号模板需已认证服务号、短信需采购通道(商务动作),不假装可发 | F-28 |
|
||||||
|
| D-12 | OCR(§9.9) | 已定义 | **一期缓做**(等供应商选型,见《01》D1,需评估数据出境合规) | F-3;121 个接口里唯一未实现 |
|
||||||
|
|
||||||
|
|||||||
+70
-20
@@ -154,7 +154,7 @@
|
|||||||
- **影响**:占用端口 + 误导(新人会以为本地有对象存储可用)。
|
- **影响**:占用端口 + 误导(新人会以为本地有对象存储可用)。
|
||||||
- **建议**:`docker rm -f xt-minio`;若还有旧数据要留,先 `docker run --rm -v` 导出再删。同时在 `AGENTS.md` 的"环境与流程坑"里保留"MinIO 已删,别去找"这句(已有,够用)。
|
- **建议**:`docker rm -f xt-minio`;若还有旧数据要留,先 `docker run --rm -v` 导出再删。同时在 `AGENTS.md` 的"环境与流程坑"里保留"MinIO 已删,别去找"这句(已有,够用)。
|
||||||
|
|
||||||
### F-3【P1 · 文档与代码不一致】`/mp/files/upload-token` 与 `/mp/ocr/idcard`
|
### F-3【P1 · 文档与代码不一致】`/mp/files/upload-token` 与 `/mp/ocr/idcard` —— **已闭环**
|
||||||
|
|
||||||
- **现象**:契约(PRD §8 与 03 §9.5/§9.9)规定上传是「OS 直传 + `upload-token`」,OCR 走 `/mp/ocr/idcard`;实际实现是 `POST /mp/files/upload`(服务端压缩 + 按模板改名,**默认不留原始件**),OCR 完全没做。
|
- **现象**:契约(PRD §8 与 03 §9.5/§9.9)规定上传是「OS 直传 + `upload-token`」,OCR 走 `/mp/ocr/idcard`;实际实现是 `POST /mp/files/upload`(服务端压缩 + 按模板改名,**默认不留原始件**),OCR 完全没做。
|
||||||
- **影响**:前端按契约写会调不存在的接口;`upload-token` 是"防重复建文件记录"的关键设计(重复提交幂等),现在靠 `POST /mp/files/upload` 每次新建记录来承担。
|
- **影响**:前端按契约写会调不存在的接口;`upload-token` 是"防重复建文件记录"的关键设计(重复提交幂等),现在靠 `POST /mp/files/upload` 每次新建记录来承担。
|
||||||
@@ -163,7 +163,9 @@
|
|||||||
2. §9.9 OCR 标注「一期缓做(等 D1 选型)」,不要留着像已实现的样子;
|
2. §9.9 OCR 标注「一期缓做(等 D1 选型)」,不要留着像已实现的样子;
|
||||||
3. 若后续要恢复直传(大文件/弱网),再单独立任务,不要在这轮混做。
|
3. 若后续要恢复直传(大文件/弱网),再单独立任务,不要在这轮混做。
|
||||||
|
|
||||||
### F-4【P1 · 文档与代码不一致】契约里没有的接口(首次盘点 19 个,现在 41 个)
|
- ✅ **已闭环(2026-09-30,客户确认后按建议改文档)**:§9.5 重写为「上传前置检查(预检/秒传/压缩建议)」、§9.6 改为「有原始件时的重新规范化」、§9.9 标注一期缓做 —— 口径见契约 §17.2 D-3/D-4/D-12。
|
||||||
|
|
||||||
|
### F-4【P1 · 文档与代码不一致】契约里没有的接口(首次盘点 19 个,现在 41 个) —— **已闭环**
|
||||||
|
|
||||||
- **现象**(代码有、03 文档 §4 清单没有):最典型的是 `GET /mp/scene-context`、
|
- **现象**(代码有、03 文档 §4 清单没有):最典型的是 `GET /mp/scene-context`、
|
||||||
`GET /auth/captcha`、`GET /mp/h5/authorize|callback|stub-login`(代替 `/mp/login`)、
|
`GET /auth/captcha`、`GET /mp/h5/authorize|callback|stub-login`(代替 `/mp/login`)、
|
||||||
@@ -174,11 +176,30 @@
|
|||||||
所以它必须以命令的形式存在,不能以表格的形式存在 —— 见 §九。
|
所以它必须以命令的形式存在,不能以表格的形式存在 —— 见 §九。
|
||||||
- **建议**:在 03 文档**新增一节「§17 实现补充与偏差记录」**,逐条列出这些接口(路径 / 权限 / 是否要补进 §4 清单),并在 §4 的合计口径里说明"121 是设计口径,另有 N 个实现补充"。改动后必须重跑 `python scripts/verify-docs.py`(它逐节核对接口数,加节时注意别改动 §4 的声称数)。
|
- **建议**:在 03 文档**新增一节「§17 实现补充与偏差记录」**,逐条列出这些接口(路径 / 权限 / 是否要补进 §4 清单),并在 §4 的合计口径里说明"121 是设计口径,另有 N 个实现补充"。改动后必须重跑 `python scripts/verify-docs.py`(它逐节核对接口数,加节时注意别改动 §4 的声称数)。
|
||||||
|
|
||||||
### F-5【P2 · 数据卫生】E2E 租户残留到"每个请求多一次解析"
|
- ✅ **已闭环(2026-09-30)**:契约新增 **§17 实现补充与偏差记录** —— 41 个接口按六类分组(清单由 `check-api-parity.py --extra` 生成,写明「手抄件会过期,以命令输出为准」),外加 12 条已确认偏离 D-1~D-12(含租户域名一行、`biz_type` 落库字符串、§14 路径前缀)。
|
||||||
|
|
||||||
|
### F-5【P2 · 数据卫生】E2E 租户残留到"每个请求多一次解析" —— **清理工具已补齐(2026-09-30)**
|
||||||
|
|
||||||
- **现象**:`sys_tenant` 17 行、`sys_user` 300 行,而脚本每次跑都会建测试租户,清理只删自己建的那一个。
|
- **现象**:`sys_tenant` 17 行、`sys_user` 300 行,而脚本每次跑都会建测试租户,清理只删自己建的那一个。
|
||||||
|
⚠️ 现在回头看,这个数字被低估了:跑了几十轮之后是 **48 个 `e2e*` 租户 / 709 个用户 /
|
||||||
|
144 个挂在 demo 租户下的测试组织 / 82 个【演示·旧】任务**。
|
||||||
- **影响**:本地演示/截图时列表里全是 `e2e-*` 名字;租户域名解析与登录"跨单位重名"分支被无意义数据搅扰。
|
- **影响**:本地演示/截图时列表里全是 `e2e-*` 名字;租户域名解析与登录"跨单位重名"分支被无意义数据搅扰。
|
||||||
- **建议**:`scripts/dev-clean-e2e.py` 增加一节"按名字前缀删测试租户与账号"(默认 dry-run,`--yes` 才真删),并在 `AGENTS.md` 里把"跑完 E2E 顺手清一次"写成固定动作。
|
- **真正的窟窿(这一轮才看清)**:**删库不删盘**。`backend/data/uploads` 里 **281 个文件 / 276 MB**,
|
||||||
|
而库里只剩 16 行文件 —— 压测与 E2E 都只删自己的库行。
|
||||||
|
文件的归属**只有库知道**,所以这件事必须由"拿库做对照"的工具来收,不能靠 `rm -rf`。
|
||||||
|
- **已做**(`scripts/dev-clean-e2e.py`,**默认 dry-run**):
|
||||||
|
① `--only-orphan-files --apply`:删**没有任何库行指向**的磁盘文件 —— **已执行,255 个 / 275.4 MB,
|
||||||
|
uploads 从 276 MB 降到 0.3 MB**;
|
||||||
|
② `--apply`:清 e2e 租户 + 测试组织 + **它们的业务行**(业务表按 `information_schema` 动态枚举,
|
||||||
|
以后新增业务表不会漏);③ `--demo-tasks`:清【演示·旧】任务及其子表与文件。
|
||||||
|
- ⚠️ **后两项我没有执行**,原因具体:**当前演示态正挂在其中一个"E2E 24数控1班 645451"组织上**
|
||||||
|
(就是导出目录里那个班级名),删了组织等于把演示态弄坏。所以:
|
||||||
|
要清就先确认演示态迁到正式组织名下,再跑 `--apply`。
|
||||||
|
我也因此把 `--demo-tasks` 的匹配**收窄到只带「旧」的那批** ——
|
||||||
|
`【演示】审核台` 是当前在用的,原来那句 `LIKE '【演示】%'` 会把它一起删掉,
|
||||||
|
而这种错在预览里只表现为"83 个"(比 82 多一个),**极容易被放过**。
|
||||||
|
- **建议**:把 `--only-orphan-files --apply` 写进"跑完压测/E2E 的收尾动作"(见 `AGENTS.md` 第三节)。
|
||||||
|
- ✅ **已执行(2026-09-30,含「在用组织保护」)**:`--apply --demo-tasks --orphan-files` 清掉 **49 个 e2e 租户 + 147 个测试组织 + 84 个【演示·旧】任务,共 3714 行、27 个文件**;清理前先算「仍被存活任务引用的组织」并保留(**3 个**,其中就有当前演示态挂着的 E2E 24数控1班 —— 演示态完好:任务 1449 / 名册 3 人 / 组织 43 都在)。另外 **xt-minio 容器已删**(F-2)、**七牛桶已清**(F-93)。
|
||||||
|
|
||||||
### F-6【P2 · 文档误导】`frontend-pc/AGENTS.md` 里的 Mock 说明已过时 —— **已修(2026-09-30)**
|
### F-6【P2 · 文档误导】`frontend-pc/AGENTS.md` 里的 Mock 说明已过时 —— **已修(2026-09-30)**
|
||||||
|
|
||||||
@@ -194,11 +215,13 @@
|
|||||||
但路的尽头还是错的",所以在那份 skill 开头加了 ⚠️ 说明(保留原文,供 Gi 模板升级时做差异比对)。
|
但路的尽头还是错的",所以在那份 skill 开头加了 ⚠️ 说明(保留原文,供 Gi 模板升级时做差异比对)。
|
||||||
核实方式:`frontend-pc/` 下无 `mock/`,`package.json` 与 `vite.config.ts` 里都没有 mock 关键字。
|
核实方式:`frontend-pc/` 下无 `mock/`,`package.json` 与 `vite.config.ts` 里都没有 mock 关键字。
|
||||||
|
|
||||||
### F-7【P2 · 一致性】撤回的动作码与适用状态
|
### F-7【P2 · 一致性】撤回的动作码与适用状态 —— **已闭环**
|
||||||
|
|
||||||
- **现象**:DDL 注释 `3=批量通过` 与 03 §11.4「写入 `action = 3`(撤回)」冲突,代码取 `7`(已按"时间轴不能混淆"处理);§11.4 的状态表只列状态 3,代码额外允许状态 2。
|
- **现象**:DDL 注释 `3=批量通过` 与 03 §11.4「写入 `action = 3`(撤回)」冲突,代码取 `7`(已按"时间轴不能混淆"处理);§11.4 的状态表只列状态 3,代码额外允许状态 2。
|
||||||
- **建议**:客户确认后同步两处文档;若客户不同意状态 2 的撤回,删一个分支即可(代码里有注释标明)。
|
- **建议**:客户确认后同步两处文档;若客户不同意状态 2 的撤回,删一个分支即可(代码里有注释标明)。
|
||||||
|
|
||||||
|
- ✅ **已闭环(2026-09-30,客户确认按代码口径)**:§2.7 `auditAction` 补全 7 个动作(撤回=7),§11.4 约束表补「状态 2 可撤(撤的是初审)」、留痕改 `action=7`。DDL 注释少列 7(迁移已发布不改,以 §2.7 为准)。
|
||||||
|
|
||||||
### F-8【P2 · 安全/上线】打桩登录与存储密钥
|
### F-8【P2 · 安全/上线】打桩登录与存储密钥
|
||||||
|
|
||||||
- **现象**:`application-dev.yml` 里 `stub-enabled: ${WX_H5_STUB:true}`(**dev 默认开着**),而代码侧默认 `false`;`keep-raw: ${XT_STORAGE_KEEP_RAW:false}` 默认不留原始件;七牛 AK/SK 只在本地 `application-local.yml`。
|
- **现象**:`application-dev.yml` 里 `stub-enabled: ${WX_H5_STUB:true}`(**dev 默认开着**),而代码侧默认 `false`;`keep-raw: ${XT_STORAGE_KEEP_RAW:false}` 默认不留原始件;七牛 AK/SK 只在本地 `application-local.yml`。
|
||||||
@@ -209,13 +232,15 @@
|
|||||||
- **现象**:`src/test` 为空,验证全靠 E2E(很硬,但每次要起服务 + 连库)。
|
- **现象**:`src/test` 为空,验证全靠 E2E(很硬,但每次要起服务 + 连库)。
|
||||||
- **建议**:先给三个**纯函数**补最小单测(不引框架之外的东西):`IdentityNormalizer`、命名规范化引擎、压缩档位推导。理由是这三个"输入输出确定、没有 IO",单测成本最低、回归收益最高;其余继续靠 E2E。
|
- **建议**:先给三个**纯函数**补最小单测(不引框架之外的东西):`IdentityNormalizer`、命名规范化引擎、压缩档位推导。理由是这三个"输入输出确定、没有 IO",单测成本最低、回归收益最高;其余继续靠 E2E。
|
||||||
|
|
||||||
### F-10【P3 · 一致】租户域名相关文档暂缓
|
### F-10【P3 · 一致】租户域名相关文档暂缓 —— **已闭环**
|
||||||
|
|
||||||
- **现象**:§一·二十五 的能力已实现且默认关闭,PRD 与 03 文档未写(当时有意暂缓)。
|
- **现象**:§一·二十五 的能力已实现且默认关闭,PRD 与 03 文档未写(当时有意暂缓)。
|
||||||
- **建议**:保持暂缓,但**在 03 文档 §17 留一行**"租户域名能力:`Host 定位 + token 授权`,默认关闭,启用条件 4 步",避免将来重开时找不到入口。
|
- **建议**:保持暂缓,但**在 03 文档 §17 留一行**"租户域名能力:`Host 定位 + token 授权`,默认关闭,启用条件 4 步",避免将来重开时找不到入口。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
- ✅ **已闭环(留一行入口,按建议)**:契约 §17.2 **D-10** 写明「能力已实现(Host 定位 + token 授权),默认关闭,启用四步见《交接摘要》§一·二十六」。
|
||||||
|
|
||||||
## 六、施工记录(本轮自主完成的事)
|
## 六、施工记录(本轮自主完成的事)
|
||||||
|
|
||||||
> 客户要求:**"完成所有的开发计划,中途发现问题自己决定,记录出来让我知道即可。"**
|
> 客户要求:**"完成所有的开发计划,中途发现问题自己决定,记录出来让我知道即可。"**
|
||||||
@@ -296,7 +321,7 @@
|
|||||||
| F-11 | **`SubmissionValidator` 的 severity 口径有三处互相矛盾的注释**:`Issue` 的 JavaDoc 写"1信息 2警告 3错误",而 `Severity` 常量注释写"1=红 2=黄 3=蓝",实际代码是 `INFO=1 / WARN=2 / ERROR=3`(`SubmissionService` 数红黄就用它们)。**我第一版导出前置检查按"1=红"写,会把"提示"当成阻断 → 正常数据也导不出来** | 已改:导出按 `Issue.ERROR` 判定;前端 `ISSUE_SEVERITY` 与详情页配色改为 1 蓝 / 2 黄 / 3 红;`Issue` 的 JavaDoc 补上"不要按直觉改常量"。**建议**:把 `Severity` 常量注释也一起改掉(本轮未动,属纯注释) |
|
| F-11 | **`SubmissionValidator` 的 severity 口径有三处互相矛盾的注释**:`Issue` 的 JavaDoc 写"1信息 2警告 3错误",而 `Severity` 常量注释写"1=红 2=黄 3=蓝",实际代码是 `INFO=1 / WARN=2 / ERROR=3`(`SubmissionService` 数红黄就用它们)。**我第一版导出前置检查按"1=红"写,会把"提示"当成阻断 → 正常数据也导不出来** | 已改:导出按 `Issue.ERROR` 判定;前端 `ISSUE_SEVERITY` 与详情页配色改为 1 蓝 / 2 黄 / 3 红;`Issue` 的 JavaDoc 补上"不要按直觉改常量"。**建议**:把 `Severity` 常量注释也一起改掉(本轮未动,属纯注释) |
|
||||||
| F-12 | **`collect_export_task` 表与契约字段不一致**:契约 §12.5 有 `currentStep / stepText / stats.rawSizeMb`,表里三列都没有 | 不新建迁移,改为推导(D6)。已在实体注释里写明"不要再按契约建字段" |
|
| F-12 | **`collect_export_task` 表与契约字段不一致**:契约 §12.5 有 `currentStep / stepText / stats.rawSizeMb`,表里三列都没有 | 不新建迁移,改为推导(D6)。已在实体注释里写明"不要再按契约建字段" |
|
||||||
| F-13 | `RevokePolicy` 在"已导出"之后**不返回** `auditRecordId`(只把 `canRevoke` 置 false) | 这不是 bug,但意味着 **`40033` 在界面上不可达**(只会出现 40001「缺少要撤回的记录 id」)。已在 E2E 里按"用户真正看得见的东西"断言,并写清 40033 只在绕过前端时可达 |
|
| F-13 | `RevokePolicy` 在"已导出"之后**不返回** `auditRecordId`(只把 `canRevoke` 置 false) | 这不是 bug,但意味着 **`40033` 在界面上不可达**(只会出现 40001「缺少要撤回的记录 id」)。已在 E2E 里按"用户真正看得见的东西"断言,并写清 40033 只在绕过前端时可达 |
|
||||||
| F-14 | 已发布的任务**既删不掉也改不了名**(`40001`),演示/测试数据无法清理 | 开发种子脚本改为"SQL 清空 + 改名挪开"(夹具性质,已注明)。**建议**:给管理员留一个"归档后可删"的口径,否则本地库会一直堆任务 |
|
| F-14 | 已发布的任务**既删不掉也改不了名**(`40001`),演示/测试数据无法清理 | 开发种子脚本改为"SQL 清空 + 改名挪开"(夹具性质,已注明)。**建议**:给管理员留一个"归档后可删"的口径,否则本地库会一直堆任务 ✅ 已闭环(客户确认「归档后可删」):`DELETE /tasks/{id}` 支持 status=3 —— **级联物理删** 14 张表(刻意不软删:本条的动机就是库会一直堆)+ 尽力删物理文件 + `sys_operation_log` 记 `DELETE_TASK`;收集中/已截止仍拒删(归档是最后的后悔药)。验证:新增 `scripts/e2e-m1-task-delete.py` → **19/19**。 |
|
||||||
| F-15 | 我第一次汇总时把 753 读成了 771(**我的算术错,不是脚本的问题**)。逐脚本累加可复现:原 18 个脚本合计**恰好 753**,加上新增的 `e2e-m6-export.py`(60)为 **813** | 已更正为 813,并在正文给出"753 + 60"的算式。**建议**:这类数字一律附上算式,别只写结论 —— 否则下一次还是会算错 |
|
| F-15 | 我第一次汇总时把 753 读成了 771(**我的算术错,不是脚本的问题**)。逐脚本累加可复现:原 18 个脚本合计**恰好 753**,加上新增的 `e2e-m6-export.py`(60)为 **813** | 已更正为 813,并在正文给出"753 + 60"的算式。**建议**:这类数字一律附上算式,别只写结论 —— 否则下一次还是会算错 |
|
||||||
| F-16 | ⚠️ **我第一版把导出目录结构做错了**:做成"班级/姓名/文件"(更符合"给人翻"的直觉),而 PRD §5.9 明确是「`{批次号}-{任务名称}` / 班级 / **材料类型** / 文件」,根目录也不是用户填的导出名 | 已改回规范,并把"根目录名、第二层、第三层、汇总表位置"四条都写成 E2E 断言(`e2e-m6-export.py`),另加"预览节点与产物逐项一致"。**教训**:目录结构是**交付物的形状**,属于需求,不能按直觉设计 —— 这类错只在客户解压时暴露 |
|
| F-16 | ⚠️ **我第一版把导出目录结构做错了**:做成"班级/姓名/文件"(更符合"给人翻"的直觉),而 PRD §5.9 明确是「`{批次号}-{任务名称}` / 班级 / **材料类型** / 文件」,根目录也不是用户填的导出名 | 已改回规范,并把"根目录名、第二层、第三层、汇总表位置"四条都写成 E2E 断言(`e2e-m6-export.py`),另加"预览节点与产物逐项一致"。**教训**:目录结构是**交付物的形状**,属于需求,不能按直觉设计 —— 这类错只在客户解压时暴露 |
|
||||||
| F-17 | 汇总表文件名:契约 §12.4 只说"生成汇总 Excel",PRD §5.9 给的是机构叫法 `考生线下报名信息导入模板(汇总).xlsx` | 按 PRD 命名(机构按这个名字找它)。已写进 E2E |
|
| F-17 | 汇总表文件名:契约 §12.4 只说"生成汇总 Excel",PRD §5.9 给的是机构叫法 `考生线下报名信息导入模板(汇总).xlsx` | 按 PRD 命名(机构按这个名字找它)。已写进 E2E |
|
||||||
@@ -308,13 +333,13 @@
|
|||||||
| F-23 | ⚠️ **后端启动直接崩**:`TeacherDashboardMapper` 的 `<script>` 动态 SQL 里写了裸 `<>`(SQL 的"不等于"),而 `<script>` 块要按 **XML** 解析 —— `<` 必须转义成 `<`。错误是 `SAXParseException: 元素内容必须由格式正确的字符数据或标记组成`,**指不到具体那一行 SQL**,而我第一反应是去查 SQL 逻辑(走了一段弯路)。更危险的是:`mvn package` **照样成功**,只有启动时才炸 | 改成 `<>`(SQL 标准的"不等于"),并顺手扫了 `module/mp/mapper` 下所有动态 SQL,确认没有别的裸 `<`。**教训**:带 `<script>` 的 Mapper **编译期不校验 XML**,改完必须**真启动一次** —— 这类错的表现是"服务起不来",不是"接口报错" |
|
| F-23 | ⚠️ **后端启动直接崩**:`TeacherDashboardMapper` 的 `<script>` 动态 SQL 里写了裸 `<>`(SQL 的"不等于"),而 `<script>` 块要按 **XML** 解析 —— `<` 必须转义成 `<`。错误是 `SAXParseException: 元素内容必须由格式正确的字符数据或标记组成`,**指不到具体那一行 SQL**,而我第一反应是去查 SQL 逻辑(走了一段弯路)。更危险的是:`mvn package` **照样成功**,只有启动时才炸 | 改成 `<>`(SQL 标准的"不等于"),并顺手扫了 `module/mp/mapper` 下所有动态 SQL,确认没有别的裸 `<`。**教训**:带 `<script>` 的 Mapper **编译期不校验 XML**,改完必须**真启动一次** —— 这类错的表现是"服务起不来",不是"接口报错" |
|
||||||
| F-24 | ⚠️ **`collect_message` 少了两列审计字段**(`create_by` / `update_by`):实体继承 `TenantEntity`,而 `FillMetaObjectHandler` 会填这两列 → 插入报 `column "create_by" of relation "collect_message" does not exist`,**站内信一条都写不进去**。这个坑在 `collect_task_org` / `collect_export_task` 上都踩过,是第三次 | V8 **还没发布**(本轮才加、且只在开发库跑过一次),所以直接在迁移里补上两列——而不是像前两次那样"去掉实体字段"。**判断依据**:这张表以后要按 `create_by` 查"谁发的消息",少这列以后要再加就麻烦了。**教训**:新建业务表先问一句"它是 `TenantEntity` 还是普通实体",然后照着列清单核对一遍 |
|
| F-24 | ⚠️ **`collect_message` 少了两列审计字段**(`create_by` / `update_by`):实体继承 `TenantEntity`,而 `FillMetaObjectHandler` 会填这两列 → 插入报 `column "create_by" of relation "collect_message" does not exist`,**站内信一条都写不进去**。这个坑在 `collect_task_org` / `collect_export_task` 上都踩过,是第三次 | V8 **还没发布**(本轮才加、且只在开发库跑过一次),所以直接在迁移里补上两列——而不是像前两次那样"去掉实体字段"。**判断依据**:这张表以后要按 `create_by` 查"谁发的消息",少这列以后要再加就麻烦了。**教训**:新建业务表先问一句"它是 `TenantEntity` 还是普通实体",然后照着列清单核对一遍 |
|
||||||
| F-25 | ⚠️ **`@Update` 里的 `<` 不会被还原成 `<`**:`SubscribeQuotaMapper.consume` 写的是 `AND used < total`,而**没有 `<script>` 包裹的注解 SQL 不经过 XML 解析** → 字面量 `<` 原样发给 PG → `syntax error at or near "&"`。表现更绕:这个异常在 `NotificationService` 里被捕获降级成"仅站内信",所以**用户侧完全正常**,只有 `collect_notify_record` 里一行都没有 | 外面套一层 `<script>`(本意不是为了动态 SQL,就是为了转义),并写进注释。**连带教训**:这类"被吞掉的异常"最难查 —— 我一开始以为是"没被调用",查了三轮日志才看到真正的堆栈。**排查手法**:先把被吞的异常用 `System.err` 打出来,别信日志配置 |
|
| F-25 | ⚠️ **`@Update` 里的 `<` 不会被还原成 `<`**:`SubscribeQuotaMapper.consume` 写的是 `AND used < total`,而**没有 `<script>` 包裹的注解 SQL 不经过 XML 解析** → 字面量 `<` 原样发给 PG → `syntax error at or near "&"`。表现更绕:这个异常在 `NotificationService` 里被捕获降级成"仅站内信",所以**用户侧完全正常**,只有 `collect_notify_record` 里一行都没有 | 外面套一层 `<script>`(本意不是为了动态 SQL,就是为了转义),并写进注释。**连带教训**:这类"被吞掉的异常"最难查 —— 我一开始以为是"没被调用",查了三轮日志才看到真正的堆栈。**排查手法**:先把被吞的异常用 `System.err` 打出来,别信日志配置 |
|
||||||
| F-26 | ⚠️ **`collect_notify_record.biz_type` 是 `varchar(32)`,不是数字**:契约 §6 的字段表把 `bizType` 定义成数字码(1~7),而 V1 的 DDL 建的是 varchar + 注释只写"业务类型"。传 Integer 进去 PG 直接拒绝(`column "biz_type" is of type character varying but expression is of type integer`)—— 同样被降级逻辑吞掉,表现还是"表里一行都没有" | 按 DDL 走:`biz_type` 存**数字码的字符串形式**(`"3"`),接口层仍然是数字(契约不变)。`collect_message.biz_type` 我用的是 `SMALLINT`(新表,我自己定的),两者"A 通过 bizType 关联"依然成立。**建议**:契约 §6 补一句"`collect_notify_record.biz_type` 落库为字符串" |
|
| F-26 | ⚠️ **`collect_notify_record.biz_type` 是 `varchar(32)`,不是数字**:契约 §6 的字段表把 `bizType` 定义成数字码(1~7),而 V1 的 DDL 建的是 varchar + 注释只写"业务类型"。传 Integer 进去 PG 直接拒绝(`column "biz_type" is of type character varying but expression is of type integer`)—— 同样被降级逻辑吞掉,表现还是"表里一行都没有" | 按 DDL 走:`biz_type` 存**数字码的字符串形式**(`"3"`),接口层仍然是数字(契约不变)。`collect_message.biz_type` 我用的是 `SMALLINT`(新表,我自己定的),两者"A 通过 bizType 关联"依然成立。**建议**:契约 §6 补一句"`collect_notify_record.biz_type` 落库为字符串" ✅ 已闭环:契约 §17.2 **D-8** 写明「落库为数字码的字符串形式(varchar 列),接口层仍是数字」。 |
|
||||||
| F-27 | `notifySent` 的语义**被收窄了**:以前它表示"有没有通知渠道"(恒 `false`),现在表示"**微信推送**有没有真的发出去"。站内信一定写得进去,所以 `needManualNotice` 仍然可能为 `true`(学生不一定会进小程序) | 已在代码里写清,并且**没有**顺手把 `needManualNotice` 改成恒 `false`。E2E 断言"降级时如实告知:notifySent=false / needManualNotice=true",防止后人把它"优化"成乐观值。**这是对前端契约的语义变更,已在本文档记录** |
|
| F-27 | `notifySent` 的语义**被收窄了**:以前它表示"有没有通知渠道"(恒 `false`),现在表示"**微信推送**有没有真的发出去"。站内信一定写得进去,所以 `needManualNotice` 仍然可能为 `true`(学生不一定会进小程序) | 已在代码里写清,并且**没有**顺手把 `needManualNotice` 改成恒 `false`。E2E 断言"降级时如实告知:notifySent=false / needManualNotice=true",防止后人把它"优化"成乐观值。**这是对前端契约的语义变更,已在本文档记录** |
|
||||||
| F-28 | **降级链只做了两级(订阅消息 → 站内信),中间两级没做**:契约 §13.6 写的是「订阅消息 → 公众号模板 → 短信 → 仅站内信」。后两级的缺不是代码问题 —— 公众号模板消息要**已认证服务号 + 用户已关注**(一期没有公众号),短信要买通道与签名(按条计费,是商务动作)。我选择**只实现能真正走通的两级**,而不是写两级"看起来有、其实永远失败"的通道 | 在 `NotificationService` 的类注释里写清了为什么少做,并用 `channel` 字段如实标出这条消息最终怎么到的。**理由**:永远失败的通道会往日志里刷一片失败,把真正的原因(没配通道)淹掉,而运营看到"通知失败率 90%"会去查代码,实际上该去找商务 |
|
| F-28 | **降级链只做了两级(订阅消息 → 站内信),中间两级没做**:契约 §13.6 写的是「订阅消息 → 公众号模板 → 短信 → 仅站内信」。后两级的缺不是代码问题 —— 公众号模板消息要**已认证服务号 + 用户已关注**(一期没有公众号),短信要买通道与签名(按条计费,是商务动作)。我选择**只实现能真正走通的两级**,而不是写两级"看起来有、其实永远失败"的通道 | 在 `NotificationService` 的类注释里写清了为什么少做,并用 `channel` 字段如实标出这条消息最终怎么到的。**理由**:永远失败的通道会往日志里刷一片失败,把真正的原因(没配通道)淹掉,而运营看到"通知失败率 90%"会去查代码,实际上该去找商务。 ✅ **已决策(2026-09-30 客户确认:一期维持两级)**,备案进契约 §17.2 **D-11**;接公众号模板/短信是商务动作(认证服务号、短信通道采购),不是代码欠账 |
|
||||||
| F-29 | **催报的分钟级限流还没做**:契约 §3 的表里写了「催报 10 次/分钟/任务」,本轮只做了 Service 层的**去重**(`excludeRecentHours`,默认 24 小时),没有做分钟级限流。**两者的作用不同**:去重防的是"同一个学生被重复催",限流防的是"老师连点把系统打爆"。现在连点 100 次不会造成伤害(第二次起全被去重跳过),但会白跑 100 次全表查询 | 记为 P1 剩余项(与 §3 的其它限流一起做)。**不做的理由**:现在没有真实并发压力,而限流放哪个维度(租户/用户/任务)需要与 §3 的其它限流统一,单独加一个会分叉 |
|
| F-29 | **催报的分钟级限流还没做**:契约 §3 的表里写了「催报 10 次/分钟/任务」,本轮只做了 Service 层的**去重**(`excludeRecentHours`,默认 24 小时),没有做分钟级限流。**两者的作用不同**:去重防的是"同一个学生被重复催",限流防的是"老师连点把系统打爆"。现在连点 100 次不会造成伤害(第二次起全被去重跳过),但会白跑 100 次全表查询 | 记为 P1 剩余项(与 §3 的其它限流一起做)。**不做的理由**:现在没有真实并发压力,而限流放哪个维度(租户/用户/任务)需要与 §3 的其它限流统一,单独加一个会分叉 ✅ 已闭环:补上**分钟级限流**(10 次/分钟/任务,超限 `42901` 且话说清等多久;Redis 不可用时放行不瘫痪);`e2e-m5-urge.py` → **42/42**(新增「第 11 次被限流」断言)。 |
|
||||||
|
|
||||||
| F-30 | ⚠️ **契约里 `notifyBizType` 的 5/6 是对调的**:§259 那行写「`5` 导出完成 / `6` 催报」,而 §2655 的消息中心示例是「`bizType: 2` 临期提醒、`bizType: 3` 驳回」—— 后者能与代码对上,所以不是我看错行。**后果很具体**:我第一版按契约写 `biz_type = '6'` 做催报去重,而实际写入的是消息中心的 5,于是去重**静默失效**(老师点两下学生收到两条),排查时完全看不出是"码对不上" | 统一按**消息中心的码**(`5` 催报 / `6` 导出完成,见 `CollectMessage` 的常量与 `UrgeService.BIZ_URGE` 的注释),并把不一致记在这里。**建议**:契约 §6 的 `notifyBizType` 与 §5.9/§13.5 对齐成同一套码 —— 改哪一边要客户确认 |
|
| F-30 | ⚠️ **契约里 `notifyBizType` 的 5/6 是对调的**:§259 那行写「`5` 导出完成 / `6` 催报」,而 §2655 的消息中心示例是「`bizType: 2` 临期提醒、`bizType: 3` 驳回」—— 后者能与代码对上,所以不是我看错行。**后果很具体**:我第一版按契约写 `biz_type = '6'` 做催报去重,而实际写入的是消息中心的 5,于是去重**静默失效**(老师点两下学生收到两条),排查时完全看不出是"码对不上" | 统一按**消息中心的码**(`5` 催报 / `6` 导出完成,见 `CollectMessage` 的常量与 `UrgeService.BIZ_URGE` 的注释),并把不一致记在这里。**建议**:契约 §6 的 `notifyBizType` 与 §5.9/§13.5 对齐成同一套码 —— 改哪一边要客户确认 ✅ 已闭环:客户确认**以 §13.5 为准**(5 催报 / 6 导出完成),§2.7 表已改,全文档统一。 |
|
||||||
| F-31 | **登录日志只有 PC 账号密码登录**(`login_type` 恒为 1,库里 1101 行全是 `PASSWORD`):学生从小程序/H5 进来走的是**微信授权**(`/mp/h5/*`),那条链路**不写 `sys_login_log`**。所以按 §13.4 查"学生登录记录"会是空的 | 本轮**没有补**(补它要动 MP 授权链路,属于另一处改动),而是在接口与页面的说明里**如实写清**这是已知边界 —— 否则运维会以为日志功能坏了。记为 P1 剩余项。**风险**:合规上"谁在什么时候登录过"对学生侧是缺口 |
|
| F-31 | **登录日志只有 PC 账号密码登录**(`login_type` 恒为 1,库里 1101 行全是 `PASSWORD`):学生从小程序/H5 进来走的是**微信授权**(`/mp/h5/*`),那条链路**不写 `sys_login_log`**。所以按 §13.4 查"学生登录记录"会是空的 | 本轮**没有补**(补它要动 MP 授权链路,属于另一处改动),而是在接口与页面的说明里**如实写清**这是已知边界 —— 否则运维会以为日志功能坏了。记为 P1 剩余项。**风险**:合规上"谁在什么时候登录过"对学生侧是缺口 ✅ 已闭环:新增 `WechatLoginLogger`,小程序登录与 H5 授权回调/打桩登录都写 `sys_login_log`(`login_type=2` 与 DDL 注释对齐;H5/小程序共用 2,靠 userAgent 区分);`e2e-m3-h5-auth.py` → **24/24**。 |
|
||||||
|
|
||||||
| F-32 | ⚠️ **"发布"与"恢复"都是 `→收集中`**:我第一版让 `patchStatus` 从 `(原状态, 新状态)` **反推**动作名,于是 `resume` 被记成了 `PUBLISH_TASK` —— 而合规要区分的恰恰是"这个任务是第一次发布,还是被暂停后又恢复的"(恢复意味着它曾经被暂停,那段时间学生的填报是被关掉的) | 改成**由调用方显式给动作名**(`patchStatus(id, status, ACTION_*)`),并补了契约表里没有但必须有的两个动作:`RESUME_TASK` / `CLOSE_TASK`。**教训**:凡是从结果反推原因的地方,遇到"多对一"就一定会错 —— 而这类错通常表现为"日志看起来有、但答案是错的",比缺日志更难发现 |
|
| F-32 | ⚠️ **"发布"与"恢复"都是 `→收集中`**:我第一版让 `patchStatus` 从 `(原状态, 新状态)` **反推**动作名,于是 `resume` 被记成了 `PUBLISH_TASK` —— 而合规要区分的恰恰是"这个任务是第一次发布,还是被暂停后又恢复的"(恢复意味着它曾经被暂停,那段时间学生的填报是被关掉的) | 改成**由调用方显式给动作名**(`patchStatus(id, status, ACTION_*)`),并补了契约表里没有但必须有的两个动作:`RESUME_TASK` / `CLOSE_TASK`。**教训**:凡是从结果反推原因的地方,遇到"多对一"就一定会错 —— 而这类错通常表现为"日志看起来有、但答案是错的",比缺日志更难发现 |
|
||||||
|
|
||||||
@@ -325,7 +350,7 @@
|
|||||||
| F-36 | **写机器检查本身也会假阳性**:`check-entity-columns.py` 第一版直接搜 `@TableLogic` 字符串,结果把 4 个实体的**类注释**也算进去了 —— 而那四段注释恰恰在解释"为什么不继承基类"(里面当然会出现 `@TableLogic` 这个词)。表现是"4 个实体报错",而我差点据此去改那四个**本来就是对的**实体 | 改成**先去注释再找注解**。**教训**:静态检查一定要能区分"代码"与"解释代码的文字" —— 否则它会指向正好写着"这里不该这么做"的地方 |
|
| F-36 | **写机器检查本身也会假阳性**:`check-entity-columns.py` 第一版直接搜 `@TableLogic` 字符串,结果把 4 个实体的**类注释**也算进去了 —— 而那四段注释恰恰在解释"为什么不继承基类"(里面当然会出现 `@TableLogic` 这个词)。表现是"4 个实体报错",而我差点据此去改那四个**本来就是对的**实体 | 改成**先去注释再找注解**。**教训**:静态检查一定要能区分"代码"与"解释代码的文字" —— 否则它会指向正好写着"这里不该这么做"的地方 |
|
||||||
|
|
||||||
| F-37 | **`@Scheduled` 的定时任务在测试里等不起**:导出包过期清理每小时跑一次,而验证"过期之后下载会怎样"总不能把过期时间改成 7 天后再等一小时 | 加了 `POST /exports/cleanup-run`(手工触发,权限同导出管理员)。清理本身**可重复执行**(已清理的卷会跳过),所以手工触发没有副作用。**这是本项目第一个定时任务**,所以顺手在 `FillApplication` 上写了"别顺手加更多:定时任务是「没有请求也会发生的事」,每加一个都要能回答「它失败了谁会知道」" |
|
| F-37 | **`@Scheduled` 的定时任务在测试里等不起**:导出包过期清理每小时跑一次,而验证"过期之后下载会怎样"总不能把过期时间改成 7 天后再等一小时 | 加了 `POST /exports/cleanup-run`(手工触发,权限同导出管理员)。清理本身**可重复执行**(已清理的卷会跳过),所以手工触发没有副作用。**这是本项目第一个定时任务**,所以顺手在 `FillApplication` 上写了"别顺手加更多:定时任务是「没有请求也会发生的事」,每加一个都要能回答「它失败了谁会知道」" |
|
||||||
| F-38 | ⚠️ **`collect_file.source` 的口径在两份文档里冲突**:PRD 的建表注释(第 1894 行)是「1 本人上传 / 2 班主任代传 / 3 管理员代传 / 4 整理器导入」,而接口设计 §3.6 的字段表(第 464 行)是「1 聊天记录 / 2 相册 / 3 手机文件 / 4 代传」。两者都是 4 个值但含义完全不同 | **以 DDL 为准**(它是"这一列表示什么"的定义;已有数据全为 1 与它一致;PC 端 `FILE_SOURCE` 常量已按它写),并在列注释里写明冲突 —— 见 F-39 的同类处理。**建议**:契约 §3.6 的字段表要改成 DDL 口径,否则下一个人会按字段表写 |
|
| F-38 | ⚠️ **`collect_file.source` 的口径在两份文档里冲突**:PRD 的建表注释(第 1894 行)是「1 本人上传 / 2 班主任代传 / 3 管理员代传 / 4 整理器导入」,而接口设计 §3.6 的字段表(第 464 行)是「1 聊天记录 / 2 相册 / 3 手机文件 / 4 代传」。两者都是 4 个值但含义完全不同 | **以 DDL 为准**(它是"这一列表示什么"的定义;已有数据全为 1 与它一致;PC 端 `FILE_SOURCE` 常量已按它写),并在列注释里写明冲突 —— 见 F-39 的同类处理。**建议**:契约 §3.6 的字段表要改成 DDL 口径,否则下一个人会按字段表写 ✅ 已闭环:客户确认**以建表注释为准(谁传的)**,§3.6/§9.5 字段表已改。 |
|
||||||
|
|
||||||
| F-39 | ⚠️ **`collect_file.source` 从来没有被代码显式写过**:库里那 10 行全是**列默认值 1**,也就是「学生自己传的」与「老师代传的」在数据上**完全区分不出来**。而契约 §10.6 明确要求代传必须留痕(`source = 4`/我按 DDL 用 2) | V12 起写入侧显式赋值:学生上传写 1、代传在插入后改成 2 并记 `proxy_by` + `proxy_reason`。**为什么还加 `proxy_by`**:`source=2` 只说"是班主任代传的",说不出**是哪个**班主任、**为什么**替他传 —— 而契约要求时间轴显示「由李老师代传」,名字只能存在这里 |
|
| F-39 | ⚠️ **`collect_file.source` 从来没有被代码显式写过**:库里那 10 行全是**列默认值 1**,也就是「学生自己传的」与「老师代传的」在数据上**完全区分不出来**。而契约 §10.6 明确要求代传必须留痕(`source = 4`/我按 DDL 用 2) | V12 起写入侧显式赋值:学生上传写 1、代传在插入后改成 2 并记 `proxy_by` + `proxy_reason`。**为什么还加 `proxy_by`**:`source=2` 只说"是班主任代传的",说不出**是哪个**班主任、**为什么**替他传 —— 而契约要求时间轴显示「由李老师代传」,名字只能存在这里 |
|
||||||
|
|
||||||
@@ -349,7 +374,7 @@
|
|||||||
| F-49 | ⚠️ **参数校验排在幂等判断之后就等于没校验**:整理结果导入的 `importFiles=true`(一期做不到,本该 40044)在**第二次**导入时被安静接受 —— 因为请求先命中了"已经导过了,直接返回原任务"。**一次不合法的请求不该因为"恰好之前导过一次"而变成成功** | 参数校验提到幂等判断之前 |
|
| F-49 | ⚠️ **参数校验排在幂等判断之后就等于没校验**:整理结果导入的 `importFiles=true`(一期做不到,本该 40044)在**第二次**导入时被安静接受 —— 因为请求先命中了"已经导过了,直接返回原任务"。**一次不合法的请求不该因为"恰好之前导过一次"而变成成功** | 参数校验提到幂等判断之前 |
|
||||||
| F-50 | ⚠️ **`collect_validation_issue` 与 `collect_rule_template` 两张表 V1 就建好了,却一直没有代码用它们**(连 `expected_val`/`actual_val`/`auto_fixed`/`resolved_by` 都有,与契约 §3.9 逐列一致)。也就是"当初就设计好了",只是 §12.1~12.3 三个接口没做 —— 而盘点工具只比"契约 vs 控制器",**看不到"表建了但没人用"** | 本轮把问题清单做完;`collect_rule_template`(§4.3 的规则模板 2 个接口)仍待做。**教训**:盘点时要连"迁移里建好的表有没有代码在用"一起算,否则会重复设计 |
|
| F-50 | ⚠️ **`collect_validation_issue` 与 `collect_rule_template` 两张表 V1 就建好了,却一直没有代码用它们**(连 `expected_val`/`actual_val`/`auto_fixed`/`resolved_by` 都有,与契约 §3.9 逐列一致)。也就是"当初就设计好了",只是 §12.1~12.3 三个接口没做 —— 而盘点工具只比"契约 vs 控制器",**看不到"表建了但没人用"** | 本轮把问题清单做完;`collect_rule_template`(§4.3 的规则模板 2 个接口)仍待做。**教训**:盘点时要连"迁移里建好的表有没有代码在用"一起算,否则会重复设计 |
|
||||||
|
|
||||||
| F-51 | ⚠️ **契约的 `POST /validation/run` 声明 `async` 默认 true 并返回 `jobId`,但整个契约没有"查作业状态"的接口** —— 那个 jobId 查不到任何东西 | 一期**同步跑完**(实测 1 份填报单 <100ms),`jobId` 只表示"本次运行",并**加返回** `checkedSubmissions / issuesFound / unresolvedLeft / elapsedMs`,让界面能立刻显示结果 —— 比返回一个查不到状态的作业号有用(加字段记在 F-52) |
|
| F-51 | ⚠️ **契约的 `POST /validation/run` 声明 `async` 默认 true 并返回 `jobId`,但整个契约没有"查作业状态"的接口** —— 那个 jobId 查不到任何东西 | 一期**同步跑完**(实测 1 份填报单 <100ms),`jobId` 只表示"本次运行",并**加返回** `checkedSubmissions / issuesFound / unresolvedLeft / elapsedMs`,让界面能立刻显示结果 —— 比返回一个查不到状态的作业号有用(加字段记在 F-52) ✅ 已闭环:客户确认**改同步**并返回统计字段(`checkedSubmissions/issuesFound/unresolvedLeft/elapsedMs`),§12.1 已改。 |
|
||||||
|
|
||||||
| F-52 | ⚠️ **契约 §12.7 的模板接口链断在第一步**:`GET /notify/templates` 返回的内置模板**没有 id**(它不在库里),而 `PUT /notify/templates/{id}` 要 id —— 用户点"编辑"时前端根本拿不出一个 id。文档上看起来是通的 | 补 `POST /notify/templates/{code}/customize`(把内置文案复制成库里一行,幂等)与 `DELETE /notify/templates/{code}`(恢复默认);另补 `GET /notify/templates/variables`(变量白名单必须服务端给,前端抄一份迟早不一致)与 `POST /notify/send` 的`rosterIds` / 整任务发法 |
|
| F-52 | ⚠️ **契约 §12.7 的模板接口链断在第一步**:`GET /notify/templates` 返回的内置模板**没有 id**(它不在库里),而 `PUT /notify/templates/{id}` 要 id —— 用户点"编辑"时前端根本拿不出一个 id。文档上看起来是通的 | 补 `POST /notify/templates/{code}/customize`(把内置文案复制成库里一行,幂等)与 `DELETE /notify/templates/{code}`(恢复默认);另补 `GET /notify/templates/variables`(变量白名单必须服务端给,前端抄一份迟早不一致)与 `POST /notify/send` 的`rosterIds` / 整任务发法 |
|
||||||
|
|
||||||
@@ -361,7 +386,7 @@
|
|||||||
|
|
||||||
| F-56 | ⚠️ **软删除与业务唯一索引打架**:`@TableLogic` 让 `delete()` 变成 `UPDATE deleted=1`,而 `uk_notify_tpl` 唯一索引**不看 deleted** → "恢复默认 → 再自定义"撞唯一约束(500)。**这类冲突在"软删除 + 业务唯一键"的组合下必然出现**,只是要到第二次操作才暴露 | 模板覆盖行改为**物理删除**。**判断依据**:这张表的行是"配置"还是"业务数据" —— 前者的历史价值低、而唯一键冲突的代价高 |
|
| F-56 | ⚠️ **软删除与业务唯一索引打架**:`@TableLogic` 让 `delete()` 变成 `UPDATE deleted=1`,而 `uk_notify_tpl` 唯一索引**不看 deleted** → "恢复默认 → 再自定义"撞唯一约束(500)。**这类冲突在"软删除 + 业务唯一键"的组合下必然出现**,只是要到第二次操作才暴露 | 模板覆盖行改为**物理删除**。**判断依据**:这张表的行是"配置"还是"业务数据" —— 前者的历史价值低、而唯一键冲突的代价高 |
|
||||||
|
|
||||||
| F-57 | ⚠️ **`PUT /tasks/{id}/form-schema` 是有的**(`FormSchemaService.save` 实现了),但**规则模板的 `FORM` 部分没有落地的应用路径** —— `RuleTemplateService` 只能"跳过并说明原因"。所以模板的 FORM 部分现在等于装饰 | 如实 skipped + 原因(不假装套用了)。**建议**:下一步把「应用表单定义」接上,或把模板的 FORM 部分从契约里去掉 |
|
| F-57 | ⚠️ **`PUT /tasks/{id}/form-schema` 是有的**(`FormSchemaService.save` 实现了),但**规则模板的 `FORM` 部分没有落地的应用路径** —— `RuleTemplateService` 只能"跳过并说明原因"。所以模板的 FORM 部分现在等于装饰 | 如实 skipped + 原因(不假装套用了)。**建议**:下一步把「应用表单定义」接上,或把模板的 FORM 部分从契约里去掉 ✅ 已闭环:`FormSchemaService.saveRawVersion`(版本化保存只有一份),`RuleTemplateService` 的 FORM 分支真正应用;`e2e-m7-taskconfig.py` → **75/75**。 |
|
||||||
|
|
||||||
| F-58 | ⚠️ **契约 §7.13 只给了「列表」与「应用」,没有「保存为模板」** —— 而没有它,"用户自建模板"永远是一句空话:模板只能靠改库或写迁移产生,而 §7.13 明确说列表要"含公共模板与**租户自建**" | 补 `POST /tasks/{id}/save-as-template`,并加边界:**只有平台管理员能把模板设为公共**(公共模板对所有租户可见,否则任何一个学校都能把自己的配置推给所有人 —— 那不是权限问题,是产品问题) |
|
| F-58 | ⚠️ **契约 §7.13 只给了「列表」与「应用」,没有「保存为模板」** —— 而没有它,"用户自建模板"永远是一句空话:模板只能靠改库或写迁移产生,而 §7.13 明确说列表要"含公共模板与**租户自建**" | 补 `POST /tasks/{id}/save-as-template`,并加边界:**只有平台管理员能把模板设为公共**(公共模板对所有租户可见,否则任何一个学校都能把自己的配置推给所有人 —— 那不是权限问题,是产品问题) |
|
||||||
|
|
||||||
@@ -371,8 +396,8 @@
|
|||||||
| F-61 | ⚠️ **初审待办会列出"我批不了"的单子** —— 待办与审批用了**两套范围判据**:待办按 `s.org_id IN orgIds`,而 `orgIds` 在 `taskId` 为空时是「我在**任意任务**里负责过的班级」;审批按「这份单子**所属任务**里这个班归不归我」。于是「我在任务 A 是班级 43 的初审人、在任务 B 不是」时,待办照样列出任务 B 的单子,点通过回 `40301`。**真实场景里"同一个班在不同任务的初审人不同"很常见**(换班主任、分批指定),所以这不是脏数据才有的问题 —— 而表现是"我权限没配好",用户只能一条条点、一条条被拒(最糟糕的那种交互) | 待办改成与审批**同一套判据**:`EXISTS (select 1 from collect_task_org cto where cto.task_id = s.task_id and cto.org_id = s.org_id and 我是它的初审人)`。改动覆盖 `pageTodos` 与 `counts`(含四个档位计数),并在 E2E 里加了**性质断言**(待办里每一条都必须通过同一套 SQL 判据),而不是"待办有几条"这种随夹具变化的断言 |
|
| F-61 | ⚠️ **初审待办会列出"我批不了"的单子** —— 待办与审批用了**两套范围判据**:待办按 `s.org_id IN orgIds`,而 `orgIds` 在 `taskId` 为空时是「我在**任意任务**里负责过的班级」;审批按「这份单子**所属任务**里这个班归不归我」。于是「我在任务 A 是班级 43 的初审人、在任务 B 不是」时,待办照样列出任务 B 的单子,点通过回 `40301`。**真实场景里"同一个班在不同任务的初审人不同"很常见**(换班主任、分批指定),所以这不是脏数据才有的问题 —— 而表现是"我权限没配好",用户只能一条条点、一条条被拒(最糟糕的那种交互) | 待办改成与审批**同一套判据**:`EXISTS (select 1 from collect_task_org cto where cto.task_id = s.task_id and cto.org_id = s.org_id and 我是它的初审人)`。改动覆盖 `pageTodos` 与 `counts`(含四个档位计数),并在 E2E 里加了**性质断言**(待办里每一条都必须通过同一套 SQL 判据),而不是"待办有几条"这种随夹具变化的断言 |
|
||||||
| F-62 | ⚠️ **契约 §7.13 只给了「列表」与「应用」,没有「保存为模板」** —— 而没有它,"用户自建模板"永远是一句空话:模板只能靠改库或写迁移产生,而 §7.13 明确说列表要"含公共模板与**租户自建**" | 补 `POST /tasks/{id}/save-as-template`,并加边界:**只有平台管理员能把模板设为公共**(公共模板对所有租户可见,否则任何一个学校都能把自己的配置推给所有人 —— 那不是权限问题,是产品问题) |
|
| F-62 | ⚠️ **契约 §7.13 只给了「列表」与「应用」,没有「保存为模板」** —— 而没有它,"用户自建模板"永远是一句空话:模板只能靠改库或写迁移产生,而 §7.13 明确说列表要"含公共模板与**租户自建**" | 补 `POST /tasks/{id}/save-as-template`,并加边界:**只有平台管理员能把模板设为公共**(公共模板对所有租户可见,否则任何一个学校都能把自己的配置推给所有人 —— 那不是权限问题,是产品问题) |
|
||||||
| F-63 | ⚠️ **公共模板会被租户拦截器挡掉**:规则模板的公共模板是 `tenant_id = 0`,而拦截器会给查询补 `tenant_id = <当前租户>` → 公共模板一条都查不出来,**而表现是"列表空着但不报错"**(功能看起来没做) | `RuleTemplateMapper` 用 `@InterceptorIgnore(tenantLine="true")` + 手写 `(tenant_id = 本租户 OR (is_public = 1 AND tenant_id = 0))`,并把"关掉框架保护之后由这一行保证隔离"写进注释(与 `MpSceneMapper` 同一做法)。**这是"默认拒绝"体系里必须主动开的口子,而不是顺手关掉** |
|
| F-63 | ⚠️ **公共模板会被租户拦截器挡掉**:规则模板的公共模板是 `tenant_id = 0`,而拦截器会给查询补 `tenant_id = <当前租户>` → 公共模板一条都查不出来,**而表现是"列表空着但不报错"**(功能看起来没做) | `RuleTemplateMapper` 用 `@InterceptorIgnore(tenantLine="true")` + 手写 `(tenant_id = 本租户 OR (is_public = 1 AND tenant_id = 0))`,并把"关掉框架保护之后由这一行保证隔离"写进注释(与 `MpSceneMapper` 同一做法)。**这是"默认拒绝"体系里必须主动开的口子,而不是顺手关掉** |
|
||||||
| F-64 | ⚠️ **`PUT /tasks/{id}/form-schema` 声称实现,但规则模板的 `FORM` 部分没有落地的应用路径**:`RuleTemplateService` 只能"跳过并说明原因"。所以模板的 FORM 部分现在等于装饰 | 如实 skipped + 原因(不假装套用了)。**建议**:下一步把"应用表单定义"接上,或把模板的 FORM 部分从契约里去掉 |
|
| F-64 | ⚠️ **`PUT /tasks/{id}/form-schema` 声称实现,但规则模板的 `FORM` 部分没有落地的应用路径**:`RuleTemplateService` 只能"跳过并说明原因"。所以模板的 FORM 部分现在等于装饰 | 如实 skipped + 原因(不假装套用了)。**建议**:下一步把"应用表单定义"接上,或把模板的 FORM 部分从契约里去掉 ✅ 已闭环:同 F-57;顺带补上「存为模板**也存表单**」(此前存模板不存表单、套模板用不上,两头各缺一半)。 |
|
||||||
| F-65 | ⚠️ **契约 §14 的路径与 §1 的路径约定冲突**:§14 的详情节写 `POST /open/v1/auth/token`(**不带** `/api/v1`),而 §1 声明全部接口在生产是 `https://api.tianbaotong.cn/api/v1/**` | 按 §1 走,实现为 `/api/v1/open/v1/...`(与其它 110 多个接口同一前缀,网关只需一条规则)。`check-api-parity.py` 的 `norm()` 会去掉 `/api/v1` 前缀,两种写法都能对上,**所以工具不会替我们发现这个冲突** —— 它只能靠人读文档发现 |
|
| F-65 | ⚠️ **契约 §14 的路径与 §1 的路径约定冲突**:§14 的详情节写 `POST /open/v1/auth/token`(**不带** `/api/v1`),而 §1 声明全部接口在生产是 `https://api.tianbaotong.cn/api/v1/**` | 按 §1 走,实现为 `/api/v1/open/v1/...`(与其它 110 多个接口同一前缀,网关只需一条规则)。`check-api-parity.py` 的 `norm()` 会去掉 `/api/v1` 前缀,两种写法都能对上,**所以工具不会替我们发现这个冲突** —— 它只能靠人读文档发现 ✅ 已闭环:§17.2 **D-7** 写明统一 `/api/v1/open/v1/...` 前缀(网关一条规则)。 |
|
||||||
|
|
||||||
| F-66 | ⚠️ **契约 §14.4 把一个"我方 → 机构"的报文写成了我方的一个路径**:正文说"系统在终审通过后主动推送",请求示例也标注"我方 → 机构",但接口清单里它是 `POST /open/v1/callback/push`(一个我方路径) | 实现为**人工重推入口**(`{submissionId}`),并重置重试次数 —— 它是"人判断过该再试一次",不该被"已经失败 5 次"挡住(那正是需要人工的场景)。自动推送与自动重试分别发生在终审通过的 `afterCommit` 与定时任务里 |
|
| F-66 | ⚠️ **契约 §14.4 把一个"我方 → 机构"的报文写成了我方的一个路径**:正文说"系统在终审通过后主动推送",请求示例也标注"我方 → 机构",但接口清单里它是 `POST /open/v1/callback/push`(一个我方路径) | 实现为**人工重推入口**(`{submissionId}`),并重置重试次数 —— 它是"人判断过该再试一次",不该被"已经失败 5 次"挡住(那正是需要人工的场景)。自动推送与自动重试分别发生在终审通过的 `afterCommit` 与定时任务里 |
|
||||||
|
|
||||||
@@ -383,8 +408,8 @@
|
|||||||
| F-69 | ⚠️ **开放接口的令牌路径必然 500**:`appOfToken` 里用 `selectById` 回查接入方,而开放接口是匿名的 —— **请求进来时还没有租户上下文**,租户拦截器直接拒绝(`未登录或登录已失效`),对外是 50000「系统繁忙」。HMAC 路径不出现(它走 `findByAppKey`,已忽略租户),所以**只有令牌路径 500**,看起来像"令牌功能没做" | 凡在"还没有租户上下文"时查归属都用忽略租户的专用方法(`findByAppKey` / `findByIdIgnoreTenant` / `findBySubmissionIgnoreTenant`),拿到归属后立刻 `TenantScope.callAs` 摆上下文。**"先找归属、再摆上下文"这个模式里,找归属那一步必须能无租户执行** —— 范围就是那几个方法,逐个写清理由 |
|
| F-69 | ⚠️ **开放接口的令牌路径必然 500**:`appOfToken` 里用 `selectById` 回查接入方,而开放接口是匿名的 —— **请求进来时还没有租户上下文**,租户拦截器直接拒绝(`未登录或登录已失效`),对外是 50000「系统繁忙」。HMAC 路径不出现(它走 `findByAppKey`,已忽略租户),所以**只有令牌路径 500**,看起来像"令牌功能没做" | 凡在"还没有租户上下文"时查归属都用忽略租户的专用方法(`findByAppKey` / `findByIdIgnoreTenant` / `findBySubmissionIgnoreTenant`),拿到归属后立刻 `TenantScope.callAs` 摆上下文。**"先找归属、再摆上下文"这个模式里,找归属那一步必须能无租户执行** —— 范围就是那几个方法,逐个写清理由 |
|
||||||
|
|
||||||
| F-70 | ⚠️ **nonce 的占用顺序反了会变成拒绝服务入口**:我原来的顺序是"先占 nonce 再验签",注释还写了一句"反过来的话错误签名能消耗 nonce"—— **这句话恰好说反了**。先占的话,任何人发一个签名错误的请求就能把那个 nonce 消耗掉;先验签则签名错直接拒、nonce 不被碰 | 改成**先验签、后占 nonce**。E2E 有一条专门守它("错误签名不该消耗 nonce")—— 这条断言的价值在于:它测的是一个**副作用**,而不是返回值 |
|
| F-70 | ⚠️ **nonce 的占用顺序反了会变成拒绝服务入口**:我原来的顺序是"先占 nonce 再验签",注释还写了一句"反过来的话错误签名能消耗 nonce"—— **这句话恰好说反了**。先占的话,任何人发一个签名错误的请求就能把那个 nonce 消耗掉;先验签则签名错直接拒、nonce 不被碰 | 改成**先验签、后占 nonce**。E2E 有一条专门守它("错误签名不该消耗 nonce")—— 这条断言的价值在于:它测的是一个**副作用**,而不是返回值 |
|
||||||
| F-71 | ⚠️ **§9.5 的"直传凭证"给的是 S3 风格的签名 PUT URL,而本项目存储走七牛** —— 七牛直传是"POST 到 upload.qiniup.com + form 带 token",而且**必须配回调**(`callbackUrl`)才能让服务端知道"传完了、文件在哪"。没有回调,文件会安静地躺在存储里,而库里没有任何记录指向它 | 一期如实返回 `direct: false` + `uploadUrl` = 本项目的接收地址(学生端 `wx.uploadFile` 直接 POST 它,与 H5 路径**完全一样**)。凭证这一层仍保留它真正的价值:**预检 + 秒传 + 压缩提示** —— 这三件事都能在"不传一个字节"的情况下完成,而那正是它要解决的问题(传了 8MB 才发现格式不对/重复了) |
|
| F-71 | ⚠️ **§9.5 的"直传凭证"给的是 S3 风格的签名 PUT URL,而本项目存储走七牛** —— 七牛直传是"POST 到 upload.qiniup.com + form 带 token",而且**必须配回调**(`callbackUrl`)才能让服务端知道"传完了、文件在哪"。没有回调,文件会安静地躺在存储里,而库里没有任何记录指向它 | 一期如实返回 `direct: false` + `uploadUrl` = 本项目的接收地址(学生端 `wx.uploadFile` 直接 POST 它,与 H5 路径**完全一样**)。凭证这一层仍保留它真正的价值:**预检 + 秒传 + 压缩提示** —— 这三件事都能在"不传一个字节"的情况下完成,而那正是它要解决的问题(传了 8MB 才发现格式不对/重复了) ✅ 已闭环:§9.5 重写为 `direct:false` + 服务端接收地址;恢复直传的前置条件(含七牛回调)写进 §17.2 D-3。 |
|
||||||
| F-72 | ⚠️ **`uploadToken` 签发之后没有"消费"它的地方**:我按契约实现了凭证的签发与预检,但**上传接口没有接它**(`/mp/files/upload` 仍然只认 `submissionId + materialCode`)。于是凭证目前只用于"预检与秒传判断",**不构成一道校验** —— 客户端完全可以跳过凭证直接上传 | 如实记录(不假装它是安全机制)。**建议**:要么让 `/mp/files/upload` 接受并校验凭证(一次性、15 分钟、绑定到人),要么把它定位成"纯性能优化"并在契约里写明 —— 现在两种都不是 |
|
| F-72 | ⚠️ **`uploadToken` 签发之后没有"消费"它的地方**:我按契约实现了凭证的签发与预检,但**上传接口没有接它**(`/mp/files/upload` 仍然只认 `submissionId + materialCode`)。于是凭证目前只用于"预检与秒传判断",**不构成一道校验** —— 客户端完全可以跳过凭证直接上传 | 如实记录(不假装它是安全机制)。**建议**:要么让 `/mp/files/upload` 接受并校验凭证(一次性、15 分钟、绑定到人),要么把它定位成"纯性能优化"并在契约里写明 —— 现在两种都不是 ✅ 已闭环(客户确认定位为**预检/秒传/压缩建议**,非安全边界):契约 §9.5 明写「客户端可跳过;服务端上传本身做全部校验」。 |
|
||||||
| F-73 | ⚠️ **秒传检查排在份数检查之后** —— 而"同一个文件重传"恰恰是秒传要解决的场景(学生传完网络断了、客户端重试)。顺序反了会得到「身份证最多 2 份,你已经传了 2 份。要换请先删掉上一份」,让用户在"我明明刚传的"这件事上原地打转 | 改成**先秒传、后份数**。**顺序体现的是"哪个答案对用户更有用"**:「你已经传过了」永远比「你超了」更有用,因为前者不需要他做任何事 |
|
| F-73 | ⚠️ **秒传检查排在份数检查之后** —— 而"同一个文件重传"恰恰是秒传要解决的场景(学生传完网络断了、客户端重试)。顺序反了会得到「身份证最多 2 份,你已经传了 2 份。要换请先删掉上一份」,让用户在"我明明刚传的"这件事上原地打转 | 改成**先秒传、后份数**。**顺序体现的是"哪个答案对用户更有用"**:「你已经传过了」永远比「你超了」更有用,因为前者不需要他做任何事 |
|
||||||
| F-74 | ⚠️ **平台运营账号(`platform`)登录会撞图形验证码**:`LoginGuard` 在连续失败后要求验证码(`xt:login:fail:{account}`),而验证码是图片、脚本给不出来。这不是缺陷(那是防爆破的设计),但它让"验收运营类接口"卡在第一步 —— 而 §6.5/§6.7 的权限正是"运营" | E2E 先清 `xt:login:fail:platform` 再登录,并把这个 key 写进注释(真实验收时运营账号被锁,运维也要知道清哪个)。⚠️ 顺带确认:**平台账号的密码是 `Platform@123`**(在 `docs/07-开发实施计划.md`),不是学校管理员的 `Admin@123` —— 我先按 `Admin@123` 试,得到"账号或密码错误",很容易误判成"平台账号没建" |
|
| F-74 | ⚠️ **平台运营账号(`platform`)登录会撞图形验证码**:`LoginGuard` 在连续失败后要求验证码(`xt:login:fail:{account}`),而验证码是图片、脚本给不出来。这不是缺陷(那是防爆破的设计),但它让"验收运营类接口"卡在第一步 —— 而 §6.5/§6.7 的权限正是"运营" | E2E 先清 `xt:login:fail:platform` 再登录,并把这个 key 写进注释(真实验收时运营账号被锁,运维也要知道清哪个)。⚠️ 顺带确认:**平台账号的密码是 `Platform@123`**(在 `docs/07-开发实施计划.md`),不是学校管理员的 `Admin@123` —— 我先按 `Admin@123` 试,得到"账号或密码错误",很容易误判成"平台账号没建" |
|
||||||
| F-75 | ⚠️ **压缩率会出现负数**:一个 3 个小文件(各 40 字节)的包,`raw=120B`、`final=802B` → 页面显示 `-568.3%`。原因是"压缩"在小包上本来就不适用 —— 里面的文件已经是压过的(jpg),或者重新打 zip 的**包头开销**比省下来的还多。问题不在"不好看",而在**客户会以为系统算错了**(而它没算错) | 服务端对 `ratio < 0` 回 `—`(与"还没算"同一个符号:**都是"这个数说明不了什么"**)。⚠️ 顺带发现 **E2E 的断言太弱**:`"%" in compressRatio` 对 `-568.3%` 也成立 —— 它确实"是个百分比",只是没有意义。已改成"要么形如 `12.3%`(非负),要么是 `—`" |
|
| F-75 | ⚠️ **压缩率会出现负数**:一个 3 个小文件(各 40 字节)的包,`raw=120B`、`final=802B` → 页面显示 `-568.3%`。原因是"压缩"在小包上本来就不适用 —— 里面的文件已经是压过的(jpg),或者重新打 zip 的**包头开销**比省下来的还多。问题不在"不好看",而在**客户会以为系统算错了**(而它没算错) | 服务端对 `ratio < 0` 回 `—`(与"还没算"同一个符号:**都是"这个数说明不了什么"**)。⚠️ 顺带发现 **E2E 的断言太弱**:`"%" in compressRatio` 对 `-568.3%` 也成立 —— 它确实"是个百分比",只是没有意义。已改成"要么形如 `12.3%`(非负),要么是 `—`" |
|
||||||
@@ -405,12 +430,33 @@
|
|||||||
| F-90 | ⚠️ **"起来了"被误报成"起不来"**:`check-test-profile.py` 在 Tomcat 打印 `Tomcat started` 的**同一瞬间**就发健康检查,那时端口还没开始接受连接 → connection refused → 脚本报"起不来",而日志里明明是 `Tomcat started on port 18098`。**误报的方向恰好最危险**:它会让人去查一个不存在的问题 | 健康检查加 20 次重试。⚠️ 同一个脚本还有第二个缺陷:写死端口且 `kill()` 后不等退出,**第二次跑就撞端口**,报的错(`Port xxx already in use`)与"配置对不对"无关 —— **一个只能跑一次的检查等于没有检查**,改成每次 `socket` 要一个空闲端口 + `wait(timeout=15)`。并把它加进 CI(在 e2e 起服务**之前**单独跑一步:失败了就是"配置起不来",而不是"E2E 脚本有问题") |
|
| F-90 | ⚠️ **"起来了"被误报成"起不来"**:`check-test-profile.py` 在 Tomcat 打印 `Tomcat started` 的**同一瞬间**就发健康检查,那时端口还没开始接受连接 → connection refused → 脚本报"起不来",而日志里明明是 `Tomcat started on port 18098`。**误报的方向恰好最危险**:它会让人去查一个不存在的问题 | 健康检查加 20 次重试。⚠️ 同一个脚本还有第二个缺陷:写死端口且 `kill()` 后不等退出,**第二次跑就撞端口**,报的错(`Port xxx already in use`)与"配置对不对"无关 —— **一个只能跑一次的检查等于没有检查**,改成每次 `socket` 要一个空闲端口 + `wait(timeout=15)`。并把它加进 CI(在 e2e 起服务**之前**单独跑一步:失败了就是"配置起不来",而不是"E2E 脚本有问题") |
|
||||||
| F-91 | ⚠️ **PRD 附录 E 里有一条定时任务,对应的机制在 V1.5 就被废除了**:附录 E 第 6 条写着「待认领名册清理|每日|超时未认领的人员移出应填名单并通知管理员」,而 PRD 正文 §"容错与纠正对照表"明确写着:「📌 **已废除**:原矩阵中"不在名册 → 转待认领队列"—— **"自助认领"机制已在 V1.5 废除**(会绕过门禁)。证件号不在名册时的正确出路是引导走「申请加入名单」通道(FR-509),由管理员逐条审批后入册」 | **不做这一项**,并把 9 项核销为 8 项。⚠️ 为什么这条值得单独记:附录 E 是一张**看起来最像清单的清单**(9 行、有频率、有说明),而它漏跟了正文的一次机制废除 —— 照着它做,会**把一个已被产品明确废除的门禁旁路重新实现出来**。**清单类附录最容易过期,因为它没有"会被读到"的必然性**(正文条款在讨论时一定会被翻) |
|
| F-91 | ⚠️ **PRD 附录 E 里有一条定时任务,对应的机制在 V1.5 就被废除了**:附录 E 第 6 条写着「待认领名册清理|每日|超时未认领的人员移出应填名单并通知管理员」,而 PRD 正文 §"容错与纠正对照表"明确写着:「📌 **已废除**:原矩阵中"不在名册 → 转待认领队列"—— **"自助认领"机制已在 V1.5 废除**(会绕过门禁)。证件号不在名册时的正确出路是引导走「申请加入名单」通道(FR-509),由管理员逐条审批后入册」 | **不做这一项**,并把 9 项核销为 8 项。⚠️ 为什么这条值得单独记:附录 E 是一张**看起来最像清单的清单**(9 行、有频率、有说明),而它漏跟了正文的一次机制废除 —— 照着它做,会**把一个已被产品明确废除的门禁旁路重新实现出来**。**清单类附录最容易过期,因为它没有"会被读到"的必然性**(正文条款在讨论时一定会被翻) |
|
||||||
| F-92 | ⚠️⚠️ **门禁限流会把整间教室挡住**(压测抓到的真缺陷):IP 限流 `20 次/分钟`计的是**所有请求**(成功也算),于是 60 个**不同**学生同一出口 IP 同时核验时,**只有 20 个能进,其余 40 个收到 `40071 操作过于频繁`**。而"一个班的学生坐在同一间教室、连同一个 WiFi"正是**首次落地第一分钟就会发生的事** —— 这不是性能问题,是上线即事故 | 拆成两个计数器,因为**目的不同**:失败(按人 10/小时、按 IP 20/分钟)用于防猜与防枚举;成功(按 IP 200/分钟)只用于防"拿这个接口批量取数据"。**关键洞见:枚举攻击产生的是失败,而教室产生的是成功** —— 把成功也算进防枚举额度里,等于用防攻击的代价挡住正常用户,而挡住的量级恰好是"一个班"。⚠️ 另有一条**刻意不计**的失败:`GATE_ALREADY_BOUND`(两个人抢同一个名册项)——开学第一天必然出现几例,算进额度会让整间教室在几秒内被限流,**而没有人做错了什么** |
|
| F-92 | ⚠️⚠️ **门禁限流会把整间教室挡住**(压测抓到的真缺陷):IP 限流 `20 次/分钟`计的是**所有请求**(成功也算),于是 60 个**不同**学生同一出口 IP 同时核验时,**只有 20 个能进,其余 40 个收到 `40071 操作过于频繁`**。而"一个班的学生坐在同一间教室、连同一个 WiFi"正是**首次落地第一分钟就会发生的事** —— 这不是性能问题,是上线即事故 | 拆成两个计数器,因为**目的不同**:失败(按人 10/小时、按 IP 20/分钟)用于防猜与防枚举;成功(按 IP 200/分钟)只用于防"拿这个接口批量取数据"。**关键洞见:枚举攻击产生的是失败,而教室产生的是成功** —— 把成功也算进防枚举额度里,等于用防攻击的代价挡住正常用户,而挡住的量级恰好是"一个班"。⚠️ 另有一条**刻意不计**的失败:`GATE_ALREADY_BOUND`(两个人抢同一个名册项)——开学第一天必然出现几例,算进额度会让整间教室在几秒内被限流,**而没有人做错了什么** |
|
||||||
| F-93 | ⚠️ **压测会打到真实对象存储**:本机 `application-local.yml` 里 `provider: qiniu`,所以上传压测落的是**线上那个空间**(bucket=tools-matrix)—— 40 张图打过去,七牛回了 `status:614` | 上传压测改成默认不跑(`--with-upload` 才跑),并在脚本里打印"要跑请先 `XT_STORAGE_PROVIDER=local` 重启后端"。两件事都不对:**压测不该依赖外部服务**(网络抖动会让"性能结论"变成对七牛的评价),**也不该往真实桶里灌测试文件**(删库不删桶) |
|
| F-93 | ⚠️ **压测会打到真实对象存储**:本机 `application-local.yml` 里 `provider: qiniu`,所以上传压测落的是**线上那个空间**(bucket=tools-matrix)—— 40 张图打过去,七牛回了 `status:614` | 上传压测改成默认不跑(`--with-upload` 才跑),并在脚本里打印"要跑请先 `XT_STORAGE_PROVIDER=local` 重启后端"。两件事都不对:**压测不该依赖外部服务**(网络抖动会让"性能结论"变成对七牛的评价),**也不该往真实桶里灌测试文件**(删库不删桶) ✅ 已闭环:压测改为打**本地存储实例**(docs/09 §9.4 推荐跑法);真实桶里 2036 个测试对象 / 116 MB 已用 `scripts/dev-clean-qiniu.py` 清空(只动 `xiaotutongxue/` 前缀,删前核实库中零引用,删后复验 0 个)。 |
|
||||||
| F-94 | ⚠️ **我自己在压测里犯了三个度量错误**:① 吞吐算成了 `n / (avg × n)`,化简就是 `1/平均耗时` —— 那不是吞吐,而"一行看起来很像结论的数字"最容易被抄进汇报里;② 只报"混合 p95",看不出**哪个接口**慢(而"该优化哪个"才是压测的目的);③ **用两个并发数字推断出"每行约 2.8ms"**,而单请求复测(20/50/200 页 → 47/36/44ms)直接否掉了它 —— 并发下的差异是排队噪声,不是按行成本 | ①改成"请求数 / 墙钟耗时";②按端点分别统计;③把"单请求中位"也纳入报告,并在 §9 里写明"并发数字不能用来推断每行成本"。**教训:并发压测的数字只能回答"系统在并发下的表现",不能反推单请求的成本结构** —— 要问"哪里慢"就得用"数 SQL 条数 + 单请求计时 + EXPLAIN"这三样 |
|
| F-94 | ⚠️ **我自己在压测里犯了三个度量错误**:① 吞吐算成了 `n / (avg × n)`,化简就是 `1/平均耗时` —— 那不是吞吐,而"一行看起来很像结论的数字"最容易被抄进汇报里;② 只报"混合 p95",看不出**哪个接口**慢(而"该优化哪个"才是压测的目的);③ **用两个并发数字推断出"每行约 2.8ms"**,而单请求复测(20/50/200 页 → 47/36/44ms)直接否掉了它 —— 并发下的差异是排队噪声,不是按行成本 | ①改成"请求数 / 墙钟耗时";②按端点分别统计;③把"单请求中位"也纳入报告,并在 §9 里写明"并发数字不能用来推断每行成本"。**教训:并发压测的数字只能回答"系统在并发下的表现",不能反推单请求的成本结构** —— 要问"哪里慢"就得用"数 SQL 条数 + 单请求计时 + EXPLAIN"这三样 |
|
||||||
| F-95 | ⚠️ **手册初稿里有两处"按设想的系统写",而不是"按真的系统写"**:① 我写了「同一张图重复传会被自动识别(秒传)」—— 而 `/mp/files/upload` **只计算 `file_hash`、没有去重查询**(秒传逻辑在 `/mp/files/upload-token` 里,而 F-72 已记过:上传接口并不消费它)。手册这么写会让学生以为重复传不占份数,然后撞上份数上限;② 我写了「代传**要**填原因(这是审计要求)」—— 而 `proxyReason` 是**选填**,手册说"必须填"会让老师以为界面坏了(因为不填也能提交) | 两处都改成与实现一致的说法。**并把这件事故化**:新增 `scripts/check-manual.py`,把"手册引用的错误码与提示文案是否真的存在"变成机械检查并进 CI —— 因为**手册是文档、文案在代码里**,改一次文案手册就静默失准,而没有任何东西会红(与 F-82「首页在说一件不真的事」同一类)。⚠️ 但要说清它**查不到什么**:它拦的是"手册写了代码里没有的东西",**拦不住"手册漏了新增的东西"** —— 那仍然要靠人 |
|
| F-95 | ⚠️ **手册初稿里有两处"按设想的系统写",而不是"按真的系统写"**:① 我写了「同一张图重复传会被自动识别(秒传)」—— 而 `/mp/files/upload` **只计算 `file_hash`、没有去重查询**(秒传逻辑在 `/mp/files/upload-token` 里,而 F-72 已记过:上传接口并不消费它)。手册这么写会让学生以为重复传不占份数,然后撞上份数上限;② 我写了「代传**要**填原因(这是审计要求)」—— 而 `proxyReason` 是**选填**,手册说"必须填"会让老师以为界面坏了(因为不填也能提交) | 两处都改成与实现一致的说法。**并把这件事故化**:新增 `scripts/check-manual.py`,把"手册引用的错误码与提示文案是否真的存在"变成机械检查并进 CI —— 因为**手册是文档、文案在代码里**,改一次文案手册就静默失准,而没有任何东西会红(与 F-82「首页在说一件不真的事」同一类)。⚠️ 但要说清它**查不到什么**:它拦的是"手册写了代码里没有的东西",**拦不住"手册漏了新增的东西"** —— 那仍然要靠人 |
|
||||||
| F-96 | ⚠️ **本轮我连续犯了三个"没断言就往下走"的错**,形态不同、根因相同:① **文本补丁静默没生效**:`str.replace` 没命中时不报错、不返回失败,脚本照样打印"成功",而文件一个字没改 —— 随后的编译错("方法不存在")看起来像另一个问题;② **夹具构造失败却继续往下走**:`with_checksum` 传错位数后,失败发生在 300 行之外的名册导入报告里("当前 19 位");③ **`R.call` 的返回值没查 `code`**:建草稿失败时 `data` 是 `null`,报的是 `TypeError: 'NoneType' object is not subscriptable`,真正原因一个字都没打 | ①改成"每处替换都断言命中"(`must_replace`),并**回读校验**;②把 helper 改成**参数不合法就抛**(治本,前两次我只改调用点);③每个 `R.call` 先查 `code` 并把 `msg` 带进断言信息。**根因是同一条:把"我以为发生了"当成"确实发生了"** —— 而这三处的共同代价是**把排查引向错误的方向**(比直接报错贵得多) |
|
| F-96 | ⚠️ **本轮我连续犯了三个"没断言就往下走"的错**,形态不同、根因相同:① **文本补丁静默没生效**:`str.replace` 没命中时不报错、不返回失败,脚本照样打印"成功",而文件一个字没改 —— 随后的编译错("方法不存在")看起来像另一个问题;② **夹具构造失败却继续往下走**:`with_checksum` 传错位数后,失败发生在 300 行之外的名册导入报告里("当前 19 位");③ **`R.call` 的返回值没查 `code`**:建草稿失败时 `data` 是 `null`,报的是 `TypeError: 'NoneType' object is not subscriptable`,真正原因一个字都没打 | ①改成"每处替换都断言命中"(`must_replace`),并**回读校验**;②把 helper 改成**参数不合法就抛**(治本,前两次我只改调用点);③每个 `R.call` 先查 `code` 并把 `msg` 带进断言信息。**根因是同一条:把"我以为发生了"当成"确实发生了"** —— 而这三处的共同代价是**把排查引向错误的方向**(比直接报错贵得多) |
|
||||||
| F-97 | ⚠️ **我断言错了字段名,于是"缩略图功能"看起来没生效**:`FileUrl` 有 `url`(原图)与 `thumbUrl`(缩略图)两个字段,而我的验证脚本去检查 `d2["url"]` 里有没有 `imageView2` —— 按设计它**永远是原图链接**,所以那个断言必然为 false;更糟的是它连着把后面两条断言一起带错了(下载的是原图,于是"没变小") | 改成检查 `thumbUrl`,并**加一条反向断言**:`url` 不该带处理参数、`thumbUrl` 该带。⚠️ 这与 F-77(用错 CSS 选择器,差点修一个不存在的 bug)**是同一类**:**断言之前先确认"你在量的那个东西是对的"**。这次的隐蔽之处是字段名很像(`url` vs `thumbUrl`),而且接口确实返回 200、数据确实存在 —— 一切看起来都正常,只有断言是错的 |
|
| F-97 | ⚠️ **我断言错了字段名,于是"缩略图功能"看起来没生效**:`FileUrl` 有 `url`(原图)与 `thumbUrl`(缩略图)两个字段,而我的验证脚本去检查 `d2["url"]` 里有没有 `imageView2` —— 按设计它**永远是原图链接**,所以那个断言必然为 false;更糟的是它连着把后面两条断言一起带错了(下载的是原图,于是"没变小") | 改成检查 `thumbUrl`,并**加一条反向断言**:`url` 不该带处理参数、`thumbUrl` 该带。⚠️ 这与 F-77(用错 CSS 选择器,差点修一个不存在的 bug)**是同一类**:**断言之前先确认"你在量的那个东西是对的"**。这次的隐蔽之处是字段名很像(`url` vs `thumbUrl`),而且接口确实返回 200、数据确实存在 —— 一切看起来都正常,只有断言是错的 |
|
||||||
| F-98 | ⚠️ **"随机端口"这个防撞设计带来一个反直觉的副作用**:`check-test-profile.py` 每次取**随机空闲端口**,于是上一次失败留下的 JVM **永远撞不到**这个端口、也就永远不会被发现 —— 它会一直占着内存与 CPU,直到某一次"怎么都起不来"(实测:连跑几轮后本机攒了 5~7 个 java,启动从 10 秒变成 180 秒超时;而**下一次跑又好了**,这正是"时好时坏"的来源,很容易被归因成"配置有问题")。同时那次失败**没法排查**:子进程输出走 `PIPE` 而没人读,事后才读又已被 kill 截断,最后只剩一句"180 秒内没探活成功" | ①开跑前**按 jar + profile 清残留**(只杀本项目的 jar,且**保留 dev 后端** —— E2E 正打着它,顺手杀掉等于把后面的检查全废掉);②子进程输出**落盘**(`backend/_test-profile.txt`,已被 `.gitignore` 的 `backend/_*.txt` 覆盖),失败时打印"停在哪一行 + 关键字命中 + 尾部 20 行",成功时打印**启动用时**(基线 ~10 秒 / 72 行 / 9.5KB,一旦变成几十秒先怀疑本机负载);③等待期间每 15 秒报一次进度,**卡住时不留沉默**。**教训:给"防撞"加一层机制时,要问一句"那它把什么东西藏起来了"** |
|
| F-98 | ⚠️ **"随机端口"这个防撞设计带来一个反直觉的副作用**:`check-test-profile.py` 每次取**随机空闲端口**,于是上一次失败留下的 JVM **永远撞不到**这个端口、也就永远不会被发现 —— 它会一直占着内存与 CPU,直到某一次"怎么都起不来"(实测:连跑几轮后本机攒了 5~7 个 java,启动从 10 秒变成 180 秒超时;而**下一次跑又好了**,这正是"时好时坏"的来源,很容易被归因成"配置有问题")。同时那次失败**没法排查**:子进程输出走 `PIPE` 而没人读,事后才读又已被 kill 截断,最后只剩一句"180 秒内没探活成功" | ①开跑前**按 jar + profile 清残留**(只杀本项目的 jar,且**保留 dev 后端** —— E2E 正打着它,顺手杀掉等于把后面的检查全废掉);②子进程输出**落盘**(`backend/_test-profile.txt`,已被 `.gitignore` 的 `backend/_*.txt` 覆盖),失败时打印"停在哪一行 + 关键字命中 + 尾部 20 行",成功时打印**启动用时**(基线 ~10 秒 / 72 行 / 9.5KB,一旦变成几十秒先怀疑本机负载);③等待期间每 15 秒报一次进度,**卡住时不留沉默**。**教训:给"防撞"加一层机制时,要问一句"那它把什么东西藏起来了"** |
|
||||||
|
| 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` 的注释 |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -514,6 +560,10 @@ M7 收尾 52 人日、M5 剩余前端约 31 人日。**关键路径仍是"审核
|
|||||||
| 2026-09-30 | **缩略图(P2-7)+ H5 独立 dev server(P2-9)**:`StorageService.thumbUrl` / `FileUrl.thumbUrl` / 审核端 `?thumb=true`;`frontend-mp` 加 `/api` 代理与 `VITE_API_BASE`;新增 `scripts/e2e-m9-thumb.py`(12 条,打到真实七牛);**同步 AGENTS.md 里"H5 没有独立 dev server"那句过期说明**;新增 F-96(**三个"没断言就往下走"**:补丁静默无效 / 夹具静默失败 /不查 code)/F-97(**断言错了字段名**,与 F-77 同类) |
|
| 2026-09-30 | **缩略图(P2-7)+ H5 独立 dev server(P2-9)**:`StorageService.thumbUrl` / `FileUrl.thumbUrl` / 审核端 `?thumb=true`;`frontend-mp` 加 `/api` 代理与 `VITE_API_BASE`;新增 `scripts/e2e-m9-thumb.py`(12 条,打到真实七牛);**同步 AGENTS.md 里"H5 没有独立 dev server"那句过期说明**;新增 F-96(**三个"没断言就往下走"**:补丁静默无效 / 夹具静默失败 /不查 code)/F-97(**断言错了字段名**,与 F-77 同类) |
|
||||||
| 2026-09-30 | **对外待决事项归拢 + 一键交付自检**:`docs/01` 新增 **E 组·第二轮待确认**(E1 `union_id` 是"H5 与小程序不能同时上线"的阻断项,E2 D3 限流口径更新、E3 二期时点,D3 那行划掉);§四 的每条卡点补上"**找谁 / 编号**";交接摘要新增 §一·二十八(坐标刷新 + 这 20 多轮最该记住的 7 条);新增 `scripts/verify-all.py`(一条命令跑完交付自检,**跳过项如实列出、不算失败**)。全量回归 **36 脚本 / 1548 断言 / 0 失败** |
|
| 2026-09-30 | **对外待决事项归拢 + 一键交付自检**:`docs/01` 新增 **E 组·第二轮待确认**(E1 `union_id` 是"H5 与小程序不能同时上线"的阻断项,E2 D3 限流口径更新、E3 二期时点,D3 那行划掉);§四 的每条卡点补上"**找谁 / 编号**";交接摘要新增 §一·二十八(坐标刷新 + 这 20 多轮最该记住的 7 条);新增 `scripts/verify-all.py`(一条命令跑完交付自检,**跳过项如实列出、不算失败**)。全量回归 **36 脚本 / 1548 断言 / 0 失败** |
|
||||||
| 2026-09-30 | **文档小账清理 + `check-test-profile` 加固**:① **附录 A 改为"可再生成"** —— `check-api-parity.py` 加 `--extra` 现算"代码有、契约 §4 没有"的接口(**41 个**;旧手抄表写 83/62/21,已差近一倍),并按"谁去改什么"分六类(只有 E 类要客户定口径);② **F-6 已修**:`frontend-pc/AGENTS.md` 的 Mock 说明改正,**并连带修 `.agents/skills/project-api/SKILL.md`**(整篇仍以 `vite-plugin-mock` 为主线,而 `mock/` 目录与插件都已删除 —— 只改指路牌不改路的尽头等于没改);③ `docs/03` 给 **§2 `notifyBizType` 的 5/6** 与 **§3.6/§9.5 的 `source`** 加 ⚠️ 注(**不改口径**,只把文档内部冲突摆明 + 指出实现以哪边为准,待客户确认);④ `check-test-profile.py` 改为**日志落盘 + 按 jar/profile 清残留 JVM** —— 随机端口让残留**永远撞不到端口**,于是静静攒着,直到某次"怎么都起不来"(新增 F-98) |
|
| 2026-09-30 | **文档小账清理 + `check-test-profile` 加固**:① **附录 A 改为"可再生成"** —— `check-api-parity.py` 加 `--extra` 现算"代码有、契约 §4 没有"的接口(**41 个**;旧手抄表写 83/62/21,已差近一倍),并按"谁去改什么"分六类(只有 E 类要客户定口径);② **F-6 已修**:`frontend-pc/AGENTS.md` 的 Mock 说明改正,**并连带修 `.agents/skills/project-api/SKILL.md`**(整篇仍以 `vite-plugin-mock` 为主线,而 `mock/` 目录与插件都已删除 —— 只改指路牌不改路的尽头等于没改);③ `docs/03` 给 **§2 `notifyBizType` 的 5/6** 与 **§3.6/§9.5 的 `source`** 加 ⚠️ 注(**不改口径**,只把文档内部冲突摆明 + 指出实现以哪边为准,待客户确认);④ `check-test-profile.py` 改为**日志落盘 + 按 jar/profile 清残留 JVM** —— 随机端口让残留**永远撞不到端口**,于是静静攒着,直到某次"怎么都起不来"(新增 F-98) |
|
||||||
|
| 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 不更新用户名 → 看起来像密码错) |
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+152
-17
@@ -242,11 +242,19 @@ XT_TENANT_DOMAIN_ENABLED=true # 如果要按域名区分单位
|
|||||||
|
|
||||||
| 阶段 | 本地怎么验 | 结果 |
|
| 阶段 | 本地怎么验 | 结果 |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `check` | 直接跑那三个校验脚本 + 把 CI 里的 heredoc 片段原样执行 | ✅ |
|
| `check` | 直接跑那**五个**校验脚本(契约 / 实体列 / 文档 / 手册 / **接线**)+ 把 CI 里的 heredoc 片段原样执行 | ✅ |
|
||||||
| `build` | `mvn package`、`pnpm typecheck`、`pnpm build` | ✅ |
|
| `build` | `mvn package`、`pnpm typecheck`、`pnpm build` | ✅ |
|
||||||
| `e2e` | 起 `test` profile 确认能起来(`check-test-profile.py`);全套 E2E 在 `dev` 上跑 | ✅ |
|
| `e2e` | 起 `test` profile 确认能起来(`check-test-profile.py`);全套 E2E 在 `dev` 上跑 | ✅ |
|
||||||
| `生产环境自检` | **真的起三次 jar**(三种误配形态) | ✅ 9/9 |
|
| `生产环境自检` | **真的起三次 jar**(三种误配形态) | ✅ 9/9 |
|
||||||
|
|
||||||
|
> **接线检查(`scripts/check-wiring.py`)为什么值得单独放一个阶段**:它查的是
|
||||||
|
> "**声明了但没接上**"(权限码没人校验、错误码没人引用、通知模板没有发送点、
|
||||||
|
> 菜单键前端没有对应项)—— 这一族错**编译 / lint / review 全都不红**,
|
||||||
|
> 只有"从入口走一遍"才暴露(F-1/F-31/F-50/F-52/F-101/F-106 都是它)。
|
||||||
|
> 它自己的假阳性也要当心:本轮修了三次(只数注解漏掉服务层、按标识符数引用被同名常量骗过、
|
||||||
|
> **注释里的提及被当成接线**)—— 所以它现在**先去 Java 注释再数**,
|
||||||
|
> 并且有意不接线的逐条写在脚本上方的例外表里(带理由),**不静默跳过**。
|
||||||
|
|
||||||
**本地验不到的**(要在真实 runner 上跑):
|
**本地验不到的**(要在真实 runner 上跑):
|
||||||
`services:` 的容器别名解析、`apt-get`/`pip3` 在 runner 镜像里可用性、
|
`services:` 的容器别名解析、`apt-get`/`pip3` 在 runner 镜像里可用性、
|
||||||
artifacts 路径、以及**所有 shell 片段的语法**(开发机是 Windows,
|
artifacts 路径、以及**所有 shell 片段的语法**(开发机是 Windows,
|
||||||
@@ -257,23 +265,31 @@ artifacts 路径、以及**所有 shell 片段的语法**(开发机是 Windows
|
|||||||
|
|
||||||
## §9 压测记录(M7-09)
|
## §9 压测记录(M7-09)
|
||||||
|
|
||||||
脚本:`python scripts/load-test.py [--rows N] [--only gate|read|upload|export] [--with-upload]`
|
脚本:`python scripts/load-test.py [--rows N] [--only gate|read|upload|export|export-big|soak] [--with-upload] [--base URL] [--fixture-only] [--files-per-user K] [--split-mb M] [--soak 分钟] [--rps N]`
|
||||||
|
|
||||||
⚠️ **本机是"负载生成器 + 后端 + PG + Redis 全在同一台机器(且 PG/Redis 在 Docker 里)"**,
|
⚠️ **本机是"负载生成器 + 后端 + PG + Redis 全在同一台机器(且 PG/Redis 在 Docker 里)"**,
|
||||||
所以绝对值偏悲观;**看比例与形状,不要看绝对值**。
|
所以绝对值偏悲观;**看比例与形状,不要看绝对值**。
|
||||||
另一条同样重要:**谈响应时间必须带规模**(同一个接口在 60 行与 2000 行上是两件事),
|
另一条同样重要:**谈响应时间必须带规模**(同一个接口在 60 行与 2000 行上是两件事),
|
||||||
所以报告里每个数字都带规模。
|
所以报告里每个数字都带规模。
|
||||||
|
|
||||||
### 9.1 结果(规模:名册 2000 人)
|
**本机规格(2026-09-30 补记,读数字时一起看)**:AMD RYZEN AI MAX+ 392,
|
||||||
|
12 物理核 / 24 逻辑核,内存 59.6 GB;后端 JVM、PG、Redis、压测进程、浏览器全在同一台机器上。
|
||||||
|
|
||||||
|
### 9.1 结果(规模:名册 2000 人,除注明外)
|
||||||
|
|
||||||
| 场景 | 结果 |
|
| 场景 | 结果 |
|
||||||
|---|---|
|
|---|---|
|
||||||
| **门禁核验**(60 人同一出口 IP 同时扫码) | **60/60 成功**(修复前 **20/60**,见 9.3);并发下 p50 218ms / p95 327ms |
|
| **门禁核验**(60 人同一出口 IP 同时扫码) | **60/60 成功**(修复前 **20/60**,见 9.3);并发下 p50 218ms / p95 327ms |
|
||||||
|
| **门禁核验·学校规模**(250 / 500 人同一出口 IP) | **250/250、500/500 成功**(成功上限 200→600 之后;此前 250 人会挡 37 个,见 9.3)。500 并发 p50 1.4s / p95 2.5s —— 是排队,**无人被限流** |
|
||||||
| **读接口**(20 并发 × 8 轮 = 640 请求,四类混合) | **0 失败**;单请求中位 **36~47ms**(与页大小 20/50/200 **无关**);20 并发下 p50 156~375ms |
|
| **读接口**(20 并发 × 8 轮 = 640 请求,四类混合) | **0 失败**;单请求中位 **36~47ms**(与页大小 20/50/200 **无关**);20 并发下 p50 156~375ms |
|
||||||
| **名册列表的 SQL 条数** | **恒为 3 条**(`selectPage` + `count` + 组织名映射),**与 pageSize 无关 → 没有 N+1** |
|
| **名册列表的 SQL 条数** | **恒为 3 条**(`selectPage` + `count` + 组织名映射),**与 pageSize 无关 → 没有 N+1** |
|
||||||
| **DB 侧耗时** | 分页查一页 **1.2ms**、计数 **0.66ms**、组织映射 **0.06ms** |
|
| **DB 侧耗时** | 分页查一页 **1.2ms**、计数 **0.66ms**、组织映射 **0.06ms** |
|
||||||
| **导出** | 前置检查正确拦住(`40035`,见 9.4);大包打包**未测**(需要真实审批过的数据) |
|
| **材料上传**(20 并发,2850KB 真图) | **40/40 成功**;**串行单请求 321ms**(2850KB → 1329KB,**压到 47%**);20 并发 p50 **620ms** / p95 **801ms** / max 851ms;吞吐 **6/s**(见 9.6) |
|
||||||
| **材料上传** | **默认不跑**(见 9.4) |
|
| **名册导入规模**(5000 行,PRD 说的上限) | **导入接口 20.2s**(2000 行 7.7s);其中"造 Excel"是压测脚本自己的成本 **0.7s**,要分开看;导入后后端 RSS **782 MB** |
|
||||||
|
| **导出大包**(150 人 / 194.6 MB) | **打包墙钟 13.2s**,单卷 194.7 MB;150 个材料 + 汇总表共 **151 个条目**,ZIP 可解;30 人(38.9 MB)时 **2.8s** |
|
||||||
|
| **导出分卷**(同一批 150 人,`splitSizeMb=50`) | **4 卷**(49.3 / 49.3 / 49.3 / 46.7 MB),**每一卷都能单独解开**(39/38/38/36 个条目),汇总表在第 1 卷根目录 |
|
||||||
|
| **导出前置检查** | 不通过时正确拦成 `40035` 且**创建接口也拒绝**(见 9.4) |
|
||||||
|
| **长跑稳定性**(5 请求/秒 × 60 分钟,名册 2000 行) | 见 **9.5.4** |
|
||||||
|
|
||||||
### 9.2 结论:后端没有 N+1,DB 不是瓶颈
|
### 9.2 结论:后端没有 N+1,DB 不是瓶颈
|
||||||
|
|
||||||
@@ -287,6 +303,10 @@ artifacts 路径、以及**所有 shell 片段的语法**(开发机是 Windows
|
|||||||
所以并发下的 p50 156~375ms 是**排队效应**(20 个请求抢同一台机器的 CPU 与网络),
|
所以并发下的 p50 156~375ms 是**排队效应**(20 个请求抢同一台机器的 CPU 与网络),
|
||||||
不是单请求成本。**要提吞吐得先加机器/拆进程,而不是改 SQL。**
|
不是单请求成本。**要提吞吐得先加机器/拆进程,而不是改 SQL。**
|
||||||
|
|
||||||
|
同一条道理用在上传上:串行 321ms vs 20 并发 p50 620ms ——
|
||||||
|
差值就是排队。**压缩本身是 CPU 密集的单线程活**,所以"上传慢"的解法是加 CPU / 限并发,
|
||||||
|
不是改上传接口。
|
||||||
|
|
||||||
### 9.3 ⚠️ 门禁限流曾经会挡住整间教室(已修)
|
### 9.3 ⚠️ 门禁限流曾经会挡住整间教室(已修)
|
||||||
|
|
||||||
压测第一次跑出来的是:60 个**不同**学生同时核验(全部正确),
|
压测第一次跑出来的是:60 个**不同**学生同时核验(全部正确),
|
||||||
@@ -303,7 +323,7 @@ artifacts 路径、以及**所有 shell 片段的语法**(开发机是 Windows
|
|||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| 失败(按人) | 只计失败 | 防**猜** | 10 次/小时 |
|
| 失败(按人) | 只计失败 | 防**猜** | 10 次/小时 |
|
||||||
| 失败(按 IP) | 只计失败 | 防**枚举** | 20 次/分钟 |
|
| 失败(按 IP) | 只计失败 | 防**枚举** | 20 次/分钟 |
|
||||||
| 成功(按 IP) | 只计成功 | 防"批量取数据" | 200 次/分钟 |
|
| 成功(按 IP) | 只计成功 | 防"批量取数据" | **600 次/分钟**(2026-09-30 由 200 上调,见下) |
|
||||||
|
|
||||||
关键洞见:**枚举攻击产生的是失败,而教室产生的是成功。**
|
关键洞见:**枚举攻击产生的是失败,而教室产生的是成功。**
|
||||||
把成功也算进防枚举的额度里,等于用防攻击的代价挡住正常用户 ——
|
把成功也算进防枚举的额度里,等于用防攻击的代价挡住正常用户 ——
|
||||||
@@ -313,23 +333,138 @@ artifacts 路径、以及**所有 shell 片段的语法**(开发机是 Windows
|
|||||||
开学第一天几十人同时扫码必然出现几例,把它算进额度里会让整间教室在几秒内被限流,
|
开学第一天几十人同时扫码必然出现几例,把它算进额度里会让整间教室在几秒内被限流,
|
||||||
**而没有一个人做错了什么**。
|
**而没有一个人做错了什么**。
|
||||||
|
|
||||||
### 9.4 两条使用注意
|
⚠️ **成功上限经历过一次实测修正(200 → 600,2026-09-30)**:
|
||||||
|
契约 §1.6 只规定了失败侧(同 openid 10 次/小时、同 IP 20 次/分钟),成功侧上限是本项目
|
||||||
|
自己加的,而最初定的 **200 恰好卡在"一个年级"的量级** —— 实测 **250 个不同学生
|
||||||
|
同时核验(全部正确)时 213 个成功、37 个被挡**,提示「当前网络访问过于集中」。
|
||||||
|
全校在同一个出口 IP 集中填报(开学第一课集体扫码)就会撞上,而**没有人做错了什么**。
|
||||||
|
上调到 600 后复测:**250 人 250/250、500 人 500/500**,且失败侧两个计数器原样未动。
|
||||||
|
要再调这个数,先跑 `python scripts/load-test.py --only gate --rows 500` 量一遍。
|
||||||
|
|
||||||
- **上传压测默认关闭**(`--with-upload` 才跑):本机 `application-local.yml` 里
|
### 9.4 压测怎么跑(两条使用注意)
|
||||||
`provider: qiniu`,所以上传落的是**线上那个空间**(bucket=tools-matrix)。
|
|
||||||
一次压测 40 张图打过去,七牛回了 `status:614`。两件事都不对:
|
- **上传压测默认关闭**(`--with-upload` 才跑),因为本机 `application-local.yml` 里
|
||||||
|
`provider: qiniu`,上传落的是**线上那个空间**(bucket=tools-matrix):
|
||||||
|
一次压测 40 张图打过去,七牛回了 `status:614`。两件事都不对 ——
|
||||||
压测不该依赖外部服务(网络抖动会让"性能结论"变成对七牛的评价),
|
压测不该依赖外部服务(网络抖动会让"性能结论"变成对七牛的评价),
|
||||||
也不该往真实桶里灌测试文件(删库不删桶)。
|
也不该往真实桶里灌测试文件(**删库不删桶**)。
|
||||||
**要跑就先把存储切成本地**:`XT_STORAGE_PROVIDER=local` 重启后端。
|
|
||||||
|
**正确跑法(推荐)**:另起一个用本地存储的实例,把压测打过去,不碰线上桶:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd backend && java -jar xt-server/target/xt-server-1.0.0-SNAPSHOT.jar \
|
||||||
|
--spring.profiles.active=dev --server.port=18080 --fill.storage.provider=local
|
||||||
|
python scripts/load-test.py --base http://localhost:18080 --only upload --conc 20
|
||||||
|
python scripts/load-test.py --base http://localhost:18080 --only export-big --rows 150 --split-mb 50
|
||||||
|
```
|
||||||
|
|
||||||
|
⚠️ 这个实例**也会跑定时任务**(`@EnableScheduling` 没有开关),
|
||||||
|
所以量完就把它停掉,别让它长期和 dev 实例并行。
|
||||||
|
|
||||||
- **导出创建被 `40035` 拦住不是失败**:那是铁律(`canExport=false` 时禁止导出)
|
- **导出创建被 `40035` 拦住不是失败**:那是铁律(`canExport=false` 时禁止导出)
|
||||||
在服务端的正确生效。脚本会把这种情形报成「被正确拦住」,
|
在服务端的正确生效。脚本会把这种情形报成「被正确拦住」,
|
||||||
并**断言创建接口也必须拒绝** —— 界面禁用按钮只是第一道,服务端是最后一道。
|
并**断言创建接口也必须拒绝** —— 界面禁用按钮只是第一道,服务端是最后一道。
|
||||||
|
|
||||||
### 9.5 还没测的
|
### 9.5 补测记录(2026-09-30):原来"没测过"的四项
|
||||||
|
|
||||||
|
这一节记的是**怎么量出来的**,因为数字会过期、方法不会。
|
||||||
|
|
||||||
|
#### 9.5.1 上传并发(CPU 密集的压缩)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd backend && java -jar xt-server/target/xt-server-1.0.0-SNAPSHOT.jar \
|
||||||
|
--spring.profiles.active=dev --server.port=18080 --fill.storage.provider=local
|
||||||
|
python scripts/load-test.py --base http://localhost:18080 --only upload --conc 20
|
||||||
|
```
|
||||||
|
|
||||||
|
| 量 | 结果 |
|
||||||
|
|---|---|
|
||||||
|
| 夹具 | **真 JPEG 2000×1500 / 2850 KB**(噪声图;纯色量不出 CPU 成本) |
|
||||||
|
| **串行单请求** | **321 ms**,产物 **1329 KB → 压到 47%** |
|
||||||
|
| 20 并发(40 次上传) | **40/40 成功**;p50 **620 ms** / p95 **801 ms** / max 851 ms |
|
||||||
|
| 吞吐 | **6 次/秒**(40 次 / 6.6 秒墙钟) |
|
||||||
|
|
||||||
|
读法:**单请求 321ms vs 并发 p50 620ms,差值就是排队** —— 压缩是单线程 CPU 活,
|
||||||
|
所以"上传慢"的解法是加 CPU 或限并发,不是改接口。
|
||||||
|
⚠️ 上传场景有**前置条件**:学生必须先经过门禁绑定,否则 `POST /mp/submissions` 会被拒;
|
||||||
|
脚本现在会自己绑定并**断言草稿数**,不成立时明确报"前置不成立"而不是"上传失败"。
|
||||||
|
|
||||||
|
#### 9.5.2 名册导入规模(PRD 提到的 5000 行上限)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python scripts/load-test.py --rows 5000 --fixture-only
|
||||||
|
```
|
||||||
|
|
||||||
|
| 规模 | 造 Excel(脚本自己的成本) | **导入接口**(系统的成本) | 备注 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| 150 行 | 0.3 s | **0.8 s** | |
|
||||||
|
| 2000 行 | 0.4 s | **7.7 s** | |
|
||||||
|
| **5000 行** | 0.7 s | **20.2 s** | 导入后后端 RSS **782 MB** |
|
||||||
|
|
||||||
|
⚠️ 两段必须分开报:**造 Excel 是压测脚本的成本**(openpyxl),混在一起会把脚本的耗时
|
||||||
|
算到"名册导入"头上 —— 那正是"度量工具的偏差伪装成产品缺陷"。
|
||||||
|
|
||||||
|
导入是**同步**的:5000 行会让这个请求占用 20 秒,前端要按长请求处理(别设 10 秒超时)。
|
||||||
|
|
||||||
|
#### 9.5.3 导出大包(客户最关心的那条链)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python scripts/load-test.py --base http://localhost:18080 --only export-big \
|
||||||
|
--rows 150 --files-per-user 1 # 不分卷
|
||||||
|
python scripts/load-test.py --base http://localhost:18080 --only export-big \
|
||||||
|
--rows 150 --files-per-user 1 --split-mb 50 # 强制分卷
|
||||||
|
```
|
||||||
|
|
||||||
|
夹具会自己走完整条业务链:门禁绑定 → 建草稿 → 上传 → 提交 → **初审(班主任)→ 终审(管理员)**
|
||||||
|
→ 前置检查 → 创建导出 → 轮询到完成 → **逐卷解开验证**。
|
||||||
|
|
||||||
|
| 规模 | 原件 | 打包墙钟 | 产物 | 条目数 |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| 30 人 | 38.9 MB | **2.8 s** | 单卷 38.9 MB | 31(30 材料 + 汇总表) |
|
||||||
|
| 150 人 | 194.6 MB | **13.2 s** | 单卷 194.7 MB | 151 |
|
||||||
|
| 150 人 + `splitSizeMb=50` | 194.6 MB | **11.4 s** | **4 卷**(49.3 / 49.3 / 49.3 / 46.7 MB) | 39 / 38 / 38 / 36 |
|
||||||
|
|
||||||
|
三条结论:
|
||||||
|
1. **吞吐约 15 MB/s**,且"原件 → 产物"压缩率 **0.0%** —— JPEG 本来就压不动,
|
||||||
|
打包的瓶颈是磁盘 I/O 而不是 CPU;所以大包的耗时基本可以按体积线性估。
|
||||||
|
2. **每一卷都能单独解开**(脚本对每卷跑 `zipfile.testzip()`)—— 这是分卷的承诺,
|
||||||
|
而它只能靠真解一遍来验证,"文件存在"断言不到。
|
||||||
|
3. 目录结构符合 PRD §5.9:`{批次号}-{任务名}/班级/材料类型/文件`,
|
||||||
|
汇总 Excel 在**第 1 卷的根目录**。
|
||||||
|
|
||||||
|
#### 9.5.4 长跑稳定性(60 分钟)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python scripts/load-test.py --only soak --rows 2000 --soak 60 --rps 5
|
||||||
|
```
|
||||||
|
|
||||||
|
判据是**趋势**,不是"跑完没崩":每分钟记一行 RSS(从系统取,不是应用自报的堆用量)
|
||||||
|
与 PG 连接数,最后比较**前 1/3 与后 1/3** 的均值。
|
||||||
|
|
||||||
|
**结果(2026-09-30 实测)**:
|
||||||
|
|
||||||
|
| 量 | 前 1/3 均值 | 后 1/3 均值 | 结论 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| RSS | 603.9 MB | 582.9 MB | **-3.5%,平稳**(GC 还回收了一点) |
|
||||||
|
| PG 连接数 | 17.0 个 | 6.2 个 | **-63.7%,回落**(定时任务高峰把池撑到上限 20 后收回 minimumIdle≈6) |
|
||||||
|
| 延迟 p95 | 63.2 ms | 62.8 ms | **-0.7%,纹丝不动** |
|
||||||
|
|
||||||
|
样本 51 行(标称 60 分钟,实际每行约 70 秒:300 个请求 × 0.2s 均匀间隔)、
|
||||||
|
请求 **15300**、失败 **0**。RSS 峰值 622 MB 出现在**定时任务在跑的那几分钟**。
|
||||||
|
|
||||||
|
**结论:60 分钟内没有内存泄漏、没有连接泄漏、延迟没有退化。**
|
||||||
|
(更长的窗口见 9.6 —— 那是仍然没测的。)
|
||||||
|
|
||||||
|
⚠️ 读这条曲线时要先知道一件事:**每小时的第 5 分钟、以及每 10 分钟**有定时任务在跑
|
||||||
|
(`ScheduleJobs` 的 cron),那一刻 PG 连接数会**跳一下**再落回来。
|
||||||
|
实测见过 6 → 21 → 6 的形状 —— 那是任务在干活,**不是连接泄漏**。
|
||||||
|
真正的泄漏长这样:**回不去**,且后 1/3 的均值明显高于前 1/3。
|
||||||
|
|
||||||
|
### 9.6 仍然没测的
|
||||||
| 场景 | 为什么没测 | 怎么测 |
|
| 场景 | 为什么没测 | 怎么测 |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| 导出**大包打包**(几百人 / 1GB+) | 需要真实审批过的数据,而造那种数据要跑完整审核链路 | 用 staging 的真实批次,量 `ExportService` 的打包耗时与内存峰值 |
|
| **真机上的上传**(微信小程序 + 手机相册) | 需要真机与小程序资质 | 见 `docs/01` **D6**:量"选一张 5MB 照片到提交成功"的端到端时间 |
|
||||||
| 上传并发(CPU 密集的压缩) | 见 9.4(会打到外部存储) | 切本地存储后 `--with-upload` |
|
| **对象存储(七牛)的上传并发** | 本地跑会把测试文件灌进真实桶(见 9.4) | 用 staging 的独立 bucket,`provider=qiniu` + `--with-upload` |
|
||||||
| 5000 人名册(PRD 提到的导入上限) | 导入本身是同步的,压测里造 5000 行要 ~20s | `--rows 5000`,重点是**导入**耗时与内存 |
|
| **多副本部署下的导出打包** | 本机只有一个实例 | 两实例同时导出同一任务,确认不会互相覆盖产物(导出线程池与落盘路径都要看) |
|
||||||
| 长时间稳定性(内存泄漏、连接泄漏) | 一次性压测测不出来 | 持续 1~2 小时的低强度压测,看 RSS 与 Hikari 活跃连接数是否单调上升 |
|
| **5000 人**(不是 5000 行)**的全链路** | 5000 行导入已测(9.5.2),但"5000 人都提交 + 审核 + 导出"是另一件事 | 按 9.5.3 的 `export-big` 放大到 5000,注意先看导出分卷与磁盘占用 |
|
||||||
|
| **生产规模下的长跑(数小时~数天)** | 60 分钟只够发现"快速泄漏" | 在 staging 按 9.5.4 的 soak 跑 12~24 小时,看 RSS 与 PG 连接数是否仍然平稳 |
|
||||||
|
|||||||
+401
-1
@@ -2286,7 +2286,7 @@ M1 规则模板与任务克隆 → M2 报考范围与名册改删 → M3 提交
|
|||||||
**§14 开放接口(认定机构对接,HMAC)** → 小程序登录与组织导入 → 工作台数据看板 →
|
**§14 开放接口(认定机构对接,HMAC)** → 小程序登录与组织导入 → 工作台数据看板 →
|
||||||
CI/CD 与 test/prod 环境 → 压测 → 用户手册与客服话术 → 缩略图与 H5 dev server。
|
CI/CD 与 test/prod 环境 → 压测 → 用户手册与客服话术 → 缩略图与 H5 dev server。
|
||||||
|
|
||||||
**每一轮的"做了什么 / 发现了什么"都在 docs/08 §六**,包含 98 条编号问题(F-1~F-98)。
|
**每一轮的"做了什么 / 发现了什么"都在 docs/08 §六**,包含 100 条编号问题(F-1~F-100)。
|
||||||
|
|
||||||
### 这 20 多轮里最该记住的几条
|
### 这 20 多轮里最该记住的几条
|
||||||
|
|
||||||
@@ -2379,3 +2379,403 @@ CI/CD 与 test/prod 环境 → 压测 → 用户手册与客服话术 → 缩略
|
|||||||
`docs/08` §四 剩下的**全部是外部依赖**(OCR 选型 / 小程序类目 / `union_id` / 真机 / 二期),
|
`docs/08` §四 剩下的**全部是外部依赖**(OCR 选型 / 小程序类目 / `union_id` / 真机 / 二期),
|
||||||
另有 4 项"**没测过**":导出大包打包、上传并发、5000 行名册导入、长跑稳定性
|
另有 4 项"**没测过**":导出大包打包、上传并发、5000 行名册导入、长跑稳定性
|
||||||
(`docs/09` §9.5 逐条写明"没测就是没测")。
|
(`docs/09` §9.5 逐条写明"没测就是没测")。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## §一·三十 把"没测过的四项"测完 + 门禁上限二次修正(2026-09-30 上午)
|
||||||
|
|
||||||
|
> §一·二十九 里那 4 项"没测过",这一轮全部变成了"测过",数字都在 `docs/09` §9.5。
|
||||||
|
> 顺带抓到一个会挡住**一所学校**的真缺陷(F-100),并把本地磁盘的糊涂账清了(F-5 落地)。
|
||||||
|
|
||||||
|
### 一、四项补测的结果(`docs/09` §9.5 有复现命令)
|
||||||
|
|
||||||
|
| 场景 | 结果 |
|
||||||
|
|---|---|
|
||||||
|
| 上传并发(真 JPEG 2850KB / 20 并发) | **40/40 成功**;**串行单请求 321ms**(压到 **47%**);并发 p50 620ms / p95 801ms —— 差值是排队,压缩是单线程 CPU 活 |
|
||||||
|
| 名册导入 5000 行(PRD 上限) | **20.2s**("造 Excel 0.7s"是压测脚本自己的成本,已分开报);RSS 782MB |
|
||||||
|
| 导出大包 150 人 / 194.6MB | **打包墙钟 13.2s**(≈15MB/s,JPEG 压不动 → 瓶颈是磁盘 I/O);强制分卷 → **4 卷且每卷都能单独解开**,汇总表在第 1 卷根 |
|
||||||
|
| 长跑 60 分钟(5 请求/秒 / 名册 2000 行) | 51 样本 / **15300 请求 / 0 失败**;RSS **603.9 → 582.9MB(平稳)**;PG 连接 17.0 → 6.2(定时任务高峰后正常回落);p95 62ms 不变。**无泄漏迹象** |
|
||||||
|
|
||||||
|
### 二、门禁成功上限 200 → 600(F-100,F-92 的第二次复发)
|
||||||
|
|
||||||
|
- 契约 §1.6 只规定**失败侧**;成功侧 200/分钟是本项目自己加的,而它**恰好卡在"一个年级"**:
|
||||||
|
实测 **250 个不同学生同时核验 → 213 成功、37 被挡**(「当前网络访问过于集中」)。
|
||||||
|
全校在同一出口 IP 集中扫码(开学第一课集体填报)就会撞上,**而没有人做错了什么**。
|
||||||
|
- 已上调 600,复测 **250/250、500/500**,`e2e-m3-gate` 仍 **29/29**;
|
||||||
|
**失败侧两个计数器原样未动**(防枚举强度没有降低)。同步 `docs/01` E2 与 `docs/09` §9.3。
|
||||||
|
|
||||||
|
### 三、磁盘的糊涂账清了(F-5 落地)
|
||||||
|
|
||||||
|
`backend/data/uploads` 里 **281 个文件 / 276MB,而库里只剩 16 行** —— 压测与 E2E 都只删库行。
|
||||||
|
`dev-clean-e2e.py` 新增 `--orphan-files` / `--only-orphan-files` / `--demo-tasks`
|
||||||
|
(业务表按 `information_schema` **动态枚举**,以后加表不会漏),已清 **255 个 / 275.4MB**。
|
||||||
|
⚠️ e2e 租户与"E2E 24数控1班"组织**刻意没删**:当前演示态正挂在其中一个班下面,
|
||||||
|
删了会把演示态弄坏 —— 要清先确认演示态已迁到正式组织(预览:`python scripts/dev-clean-e2e.py`)。
|
||||||
|
|
||||||
|
### 四、这轮最该记住的两条
|
||||||
|
|
||||||
|
1. **补测的价值一半在数字,另一半在"原来夹具一直不成立"**(F-99,压测工具自己撒了 8 次谎:
|
||||||
|
前置未断言 / 假 JPEG / 猜列名 / 判据取错返回值 / 跨租户同名账号 / 不查返回码 /
|
||||||
|
PowerShell `-First` 截管道 / 清理不删文件)—— 其中"跨租户同名 admin"与"HTTP 码当业务码"
|
||||||
|
都是 F-84/F-96 的同类,**第三次出现**。
|
||||||
|
2. **自己加的防御性限额也要过一遍真实场景的量级**(F-100)——
|
||||||
|
否则它与它要防的东西一样,都是事故。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## §一·三十一 17 项决策全部执行完毕(2026-09-30 下午)
|
||||||
|
|
||||||
|
> 客户对 100 条问题逐项拍板后("都按照你的建议来"),A/B/C/D 四组 17 项一次做完。
|
||||||
|
> **至此 F-1~F-100 没有未决项**;剩下的全部等外部输入(OCR 选型 / 小程序类目 /
|
||||||
|
> `union_id` / 真机 / 二期时点 / 国标职业库数据)。
|
||||||
|
|
||||||
|
### 一、A 组:契约口径修正(V2.6,全部经客户确认)
|
||||||
|
|
||||||
|
| 处 | 改了什么 |
|
||||||
|
|---|---|
|
||||||
|
| §2.7 | `notifyBizType` 5/6 对调修正(**5 催报 / 6 导出完成**,F-30);`auditAction` 补全 7 个动作(**撤回=7**,F-7) |
|
||||||
|
| §3.6 / §9.5 | `collect_file.source` 改为建表口径「**谁传的**」(1 本人/2 班主任代传/3 管理员代传/4 整理器,F-38/39) |
|
||||||
|
| §9.5 / §9.6 / §9.9 | 上传凭证改为「**上传前置检查**(预检/秒传/压缩建议,非安全边界)」;`process` 改为「有原始件时的重新规范化」;OCR 标注一期缓做(F-3/71/72) |
|
||||||
|
| §11.4 / §12.1 | 撤回约束补「状态 2 可撤(撤的是初审)」;`validation/run` 改**同步**并返回统计(F-51) |
|
||||||
|
| **新增 §17** | **实现补充与偏差记录**:41 个 §4 未列接口按六类分组(清单由 `--extra` 生成,声明"手抄件会过期")+ **12 条已确认偏离 D-1~D-12**(含租户域名、`biz_type` 落库字符串、§14 路径、归档可删、通知两级、OCR 缓做)—— F-4/10/26/65 一次收口 |
|
||||||
|
|
||||||
|
### 二、B 组:补代码(每项都带 E2E)
|
||||||
|
|
||||||
|
| 项 | 实现 | 验证 |
|
||||||
|
|---|---|---|
|
||||||
|
| 规则模板 FORM 可套用(F-57/64) | `FormSchemaService.saveRawVersion`(版本化保存只有一份);顺带**存模板也存表单**(此前两头各缺一半) | `e2e-m7-taskconfig` **75/75** |
|
||||||
|
| 已归档任务可删(F-14) | `DELETE /tasks/{id}` 支持 status=3:**子表级联物理删**(14 张表,清掉大头)+ **任务行软删占住 `task_code`**(编码是文件目录名,复用会混两批文件 —— 第一版物理删被 E2E 抓到重号后改的)+ 尽力删文件 + 留痕 | 新增 `e2e-m1-task-delete` **21/21**(含"删除后新建不重号"回归) |
|
||||||
|
| 催报分钟级限流(F-29) | 10 次/分钟/任务,超限 `42901` 且说清等多久;Redis 不可用时放行不瘫痪 | `e2e-m5-urge` **42/42** |
|
||||||
|
| 学生微信登录留痕(F-31) | 新增 `WechatLoginLogger`:小程序登录 + H5 回调/打桩登录都写 `sys_login_log`(`login_type=2`,与 DDL 注释对齐) | `e2e-m3-h5-auth` **24/24** |
|
||||||
|
| 三个纯函数最小单测(F-9) | `IdentityNormalizerTest` + `FileProcessServiceTest`(每条断言都对应一个踩过的坑) | `mvn test` **12/12** |
|
||||||
|
|
||||||
|
### 三、C/D 组:备案与清环境
|
||||||
|
|
||||||
|
- **备案**:通知降级链一期维持两级(F-28 → §17.2 D-11);国标职业库待客户提供(`docs/01` 新增 **E4**,F-34)。
|
||||||
|
- **xt-minio 容器已删**(F-2,代码早已不用 S3)。
|
||||||
|
- **本地测试数据已清**(F-5):49 个 e2e 租户 + 147 个测试组织 + 84 个【演示·旧】任务,
|
||||||
|
共 3714 行 / 27 个文件;清理工具先算「仍被存活任务引用的组织」并保留(3 个)——
|
||||||
|
**演示态完好**(任务 1449 / 名册 3 人 / 组织 43 都在)。
|
||||||
|
- **七牛桶已清空本项目前缀**(F-93):`xiaotutongxue/` 下 **2036 个对象 / 116 MB**,
|
||||||
|
删前核实库中**零引用**、删后复验 **0 个**。新增 `scripts/dev-clean-qiniu.py`
|
||||||
|
(默认 dry-run;签名按官方文档逐字核对 —— 踩了两个 401:旧 QBox 格式、batch 的 Body 没进签名串)。
|
||||||
|
|
||||||
|
### 四、终验与提交
|
||||||
|
|
||||||
|
- `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。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## §一·三十三 验收走查后的三项修正 + 平台「选择学校」(2026-10-09)
|
||||||
|
|
||||||
|
> 起点是客户自己点着看了一圈,报了三件事:**菜单里有几条没用**、
|
||||||
|
> **班主任点工作台弹 403**、**平台运营与学校管理员看到的组织架构一模一样**。
|
||||||
|
> 三件事都不是"功能没做",而是"做完了但口径不对" —— 与前几轮同一族。
|
||||||
|
|
||||||
|
### 一、去掉 3 条无用菜单(客户明确要求)
|
||||||
|
|
||||||
|
| 菜单 | 为什么去掉 |
|
||||||
|
|---|---|
|
||||||
|
| 名单管理 | 名册按任务,入口在「收集任务」每行的「名单管理」 |
|
||||||
|
| 表单设计器 | 表单按任务,入口在任务行的「表单设计」 |
|
||||||
|
| 材料规则 | 材料按任务,入口在任务行的「材料规则」 |
|
||||||
|
|
||||||
|
⚠️ **这一条推翻了 §一·二十三 里"顶层菜单保留「待建」页更诚实"的取舍** ——
|
||||||
|
当时的理由是"说'还没做'而功能在隔壁会让人得出错误结论"。客户看了之后的判断是:
|
||||||
|
一条点进去只有指路、没有任何操作的菜单,**本身就是导航噪音**。于是直接删掉。
|
||||||
|
|
||||||
|
- 后端 `BuiltinRole` 的 `menus` 同步删除这三个键(`check-wiring.py` ⑤ 双向核对,
|
||||||
|
只删一边会红)。⚠️ **权限码一个都没动**:`task:manage` / `form:manage` /
|
||||||
|
`material:manage` / `roster:*` 照旧,删的只是菜单键(可见性)。
|
||||||
|
- PC 端 `XT_MENUS` 删掉三条;**顺带删掉 `entryAt` 机制**("功能已就绪 + 跳转按钮"
|
||||||
|
那段)—— 它当初就是为这三条写的,留着就是一段永远走不到的代码。
|
||||||
|
「待建」页现在只服务「角色与授权」(二期)。
|
||||||
|
- 班主任/副班主任/审核员的菜单也一起去掉了 `roster`:他们本来也点不进去(没有 `roster` 页)。
|
||||||
|
|
||||||
|
### 二、班主任点工作台必弹 403 —— 前端调了"自己没有权限"的接口
|
||||||
|
|
||||||
|
| 接口 | 要求权限 | 班主任有吗 |
|
||||||
|
|---|---|---|
|
||||||
|
| `GET /orgs/tree`(工作台的"组织节点"卡片) | `org:view` | ❌ |
|
||||||
|
| `GET /audit/statistics`(数据看板的"待审/审核情况") | `progress:view:all` | ❌(只有 `progress:view:own`)|
|
||||||
|
|
||||||
|
工作台是**所有角色共用的落地页**,而这两个调用是无条件的 —— 于是班主任一进首页就连吃两个 403。
|
||||||
|
|
||||||
|
⚠️ **`try/catch` 拦不住它**:403 走的是 HTTP 状态码,`utils/http.ts` 的传输层分支
|
||||||
|
**先 `Message.error('拒绝访问(403)')` 再 reject**,异常被 catch 了、提示仍然弹出来。
|
||||||
|
前端唯一正确的做法是**按权限决定发不发这个请求**:
|
||||||
|
|
||||||
|
- 卡片值如实显示 `—`,并把原因写在卡片脚注("需要「组织查看」权限");
|
||||||
|
- 「审核情况」对没有 `progress:view:all` 的角色显示一句说明,而不是一张空表。
|
||||||
|
|
||||||
|
> 教训(与 §一·三十二 的接线检查同一族):**"共用页面 + 接口级权限"是一对必踩的组合**。
|
||||||
|
> 页面对所有角色都渲染,但里面的接口各要各的权限 —— 这类错只有"换个角色登进去点一遍"
|
||||||
|
> 才看得见,E2E 全绿也抓不到(E2E 一直用管理员身份跑工作台)。
|
||||||
|
|
||||||
|
### 三、平台运营的「组织架构 / 账号管理」:新增 `X-Tenant-Id`
|
||||||
|
|
||||||
|
**根因**:平台运营(`tenantId = 0`)是唯一跨租户身份,租户拦截器对它
|
||||||
|
**整体关闭过滤**(`MybatisPlusConfig.FillTenantLineHandler.ignoreTable`)。
|
||||||
|
于是 `/orgs/tree` 返回的是**所有租户混在一棵树里**,`/users` 是**所有租户的账号**。
|
||||||
|
⚠️ 这**看不出错**:当时只有 demo 一所学校有数据,平台看到的正好就是那 22 个节点 ——
|
||||||
|
和学校管理员一模一样。等第二所学校建了组织,它会看到两所学校的班级混在同一层。
|
||||||
|
|
||||||
|
**做法(客户选定"先选学校,再看该校数据")**:
|
||||||
|
|
||||||
|
```
|
||||||
|
前端:平台运营的「组织架构 / 账号管理」页顶部多一个「选择学校」下拉(GiTenantPicker)
|
||||||
|
→ 选中后所有请求带 X-Tenant-Id: <tenantId>
|
||||||
|
后端:ActingTenantResolver 解析这个头(support/,与 TenantDomainResolver 并列)
|
||||||
|
→ AuthInterceptor 把 LoginUser 的 tenantId 换成目标租户
|
||||||
|
→ isPlatformAdmin() 随之变 false → 租户过滤自动生效(这是机制,不是副作用)
|
||||||
|
```
|
||||||
|
|
||||||
|
**四条必须守住的**(都写了断言,`scripts/_verify-fixes.py`):
|
||||||
|
|
||||||
|
1. **只认平台身份**:学校管理员/班主任带这个头一律忽略 —— 否则改一个数字就能越租户;
|
||||||
|
2. 目标租户必须存在且启用,否则 `40001`(不区分"不存在"与"停用");
|
||||||
|
3. 不带这个头时**平台行为完全不变**(租户列表、全量操作日志照旧跨租户);
|
||||||
|
4. `OrgService.tree()` 与 `UserService.list()` 补了**显式** `tenant_id` 条件 ——
|
||||||
|
原来它们只靠拦截器,而拦截器对平台是不生效的;显式写死之后
|
||||||
|
"平台没选学校"得到的是**空树/自己的账号**,而不是"所有人的数据"。
|
||||||
|
|
||||||
|
⚠️ 角色可选范围也跟着走:给某所学校建账号时只能发学校那套角色
|
||||||
|
(发 `PLATFORM_ADMIN` 会被服务端的租户白名单拒掉,报 40301)。
|
||||||
|
|
||||||
|
### 四、`OpenPushService` 的"推送重试"从来没重试过(**已修,F-112**)
|
||||||
|
|
||||||
|
后端每 10 秒刷一次堆栈:`OpenPushService 扫描推送重试失败 → 未登录或登录已失效`。
|
||||||
|
|
||||||
|
- 根因:`OpenPushService.retryDue()` 的扫描查询 `pushLogMapper.dueRetries()`
|
||||||
|
**没有租户上下文**,而该方法**漏了 `@InterceptorIgnore(tenantLine="true")`** ——
|
||||||
|
同一个文件里 `findBySubmissionIgnoreTenant` 有,注释还正好写着这条规律
|
||||||
|
("先找归属、再摆上下文"里找归属那一步必须能无租户执行)。
|
||||||
|
- 后果:`docs/08` 记的**第 8 项定时任务「开放接口推送重试」从未真正重试**,
|
||||||
|
只是每 10 秒打一行 warn(异常被 `catch (RuntimeException) → log.warn` 吞掉)。
|
||||||
|
- **顺带修掉同一个方法里的第二处**:`接入方已删除/停用` 那条分支直接
|
||||||
|
`pushLogMapper.updateById(row)`,它同样没有租户上下文 —— 少了上下文这句 update 会抛 40101
|
||||||
|
被同一个 catch 吞掉,于是那条记录**每 10 秒重试一次、永远停不下来**。
|
||||||
|
改成用**推送记录自己的 `tenant_id`** 摆上下文再写。
|
||||||
|
- **E2E 补了"真的发生了"的断言**:`e2e-m8-open.py` 原来只断言"失败后**安排了**下次重试"
|
||||||
|
(`next_retry_at` 被写上),现在多一条 —— 等 16 秒(扫描间隔 10s + 首次退避 1s + 余量),
|
||||||
|
断言 `attempts` **真的增加**。**"安排了"与"发生了"是两件事**,只断言前者的话,
|
||||||
|
60/60 全绿也照样漏掉这个 bug(F-104 是同一族的第二次)。
|
||||||
|
修后 `e2e-m8-open.py` → **61/61**。
|
||||||
|
|
||||||
|
### 五、平台运营的**工作台**也要"选择学校"
|
||||||
|
|
||||||
|
客户要求"工作台的数据看板也按学校看"。改完之后连着暴露了两个**更深的问题**:
|
||||||
|
|
||||||
|
| # | 现象 | 根因 | 修法 |
|
||||||
|
|:--:|---|---|---|
|
||||||
|
| 1 | 平台选了学校后,任务下拉**空**(`/tasks` 40301) | 切到代管租户后 `isPlatformAdmin()` 变 false,权限判定**只认平台角色声明的权限** —— 而 `PLATFORM_ADMIN` 里**没有 `TASK_VIEW`**。它以前被"平台=超管"(`PermissionChecker.has` 对平台恒真)这个捷径掩盖着 | 给 `PLATFORM_ADMIN` 补 **`TASK_VIEW`**(纯读:任务列表/详情/统计)。PRD §3.2「查看填报进度看板:平台运营 ✅」正是这件事;`TASK_MANAGE` / `TASK_CLONE` 依旧不给(PRD 里平台是「—」) |
|
||||||
|
| 2 | 补了权限后 `/tasks` 返回 200,但**还是 0 条** | `TaskService.seesWholeTenant()` 写的是 `isPlatformAdmin() \|\| SCHOOL_ADMIN/AUDITOR` —— 代管时前者为 false,于是**掉进"班主任只看自己带的班"那条分支**,而平台在该校没有班级 → 空列表(看起来像"这所学校没有任务") | 新增 `LoginUser.isPlatformStaff()`(= `isPlatformAdmin() \|\| hasRole("PLATFORM_ADMIN")`),并把这条判据换成它 |
|
||||||
|
|
||||||
|
⚠️ **`isPlatformAdmin()` 与 `isPlatformStaff()` 的区别要记住**(两者回答不同的问题):
|
||||||
|
|
||||||
|
- `isPlatformAdmin()` = "当前上下文的租户**就是平台租户**" → 它是租户拦截器要不要
|
||||||
|
**整体关掉过滤**的开关(跨租户看数据);
|
||||||
|
- `isPlatformStaff()` = "这个人**是平台运营**" → 数据范围是全量,但**租户过滤照常生效**。
|
||||||
|
|
||||||
|
**凡是"按身份决定数据范围"的判断用后者**;否则平台一代管某校就会**退化成普通用户**
|
||||||
|
(空列表、少一半数据,而界面上没有任何提示)。前端也一并按学校取:工作台顶部同样放
|
||||||
|
`GiTenantPicker`(与组织架构/账号管理共用 `useActingTenantStore`),任务列表、任务统计、
|
||||||
|
审核统计都带上 `X-Tenant-Id`。
|
||||||
|
|
||||||
|
### 六、⭐ 把"权限码写错"变成命令:`check-wiring.py` 第 ⑦ 项
|
||||||
|
|
||||||
|
本轮我自己踩了一个**最该被工具抓住**的错:给工作台加"按权限决定发不发请求"时,
|
||||||
|
把 `progress:viewAll` 写成了 **`progress:view:all`**(多一个冒号)。后果:
|
||||||
|
那个判断**恒为 false** →「审核情况」整块**对所有人**都不显示 ——
|
||||||
|
而编译、typecheck、lint、后端日志、E2E **全都不红**(字符串对不上是纯运行期的事;
|
||||||
|
"权限不足就少显示一块"看起来还很像设计)。
|
||||||
|
|
||||||
|
所以给 `check-wiring.py` 加了第 ⑦ 项:**PC 端引用的权限码必须存在于 `PermissionDef`**
|
||||||
|
(认两种写法:`permissions.includes('x')` 与 `v-hasPerm="['x']"`)。
|
||||||
|
|
||||||
|
两个"检查器自己"的细节值得记:
|
||||||
|
|
||||||
|
1. **它第一版报了 5 条假阳性** —— 全都来自 `directives/hasPerm.ts` 里的
|
||||||
|
**注释与示例字符串**(模板自带的 `home:btn:add`)。这与本脚本 2026-10-08 修过的
|
||||||
|
第 ③ 条(Java 注释里的提及被当成接线)是同一个教训:
|
||||||
|
**检查器必须能区分"代码"与"解释代码的文字"**。修法:先按引号感知地剥掉 JS 注释
|
||||||
|
(`strip_js_comments`,不能见 `//` 就砍),并且 `v-hasPerm` **只在 `.vue` 里找**
|
||||||
|
(那是模板语法,`.ts` 里的同名文本必然是示例或报错文案)。
|
||||||
|
2. **加完要拿一个故意写错的码试一次**(本仓库的老规矩):把 `progress:viewAll` 改回错的
|
||||||
|
→ 检查器红、指向 `dashboard/index.vue:196`;改回来 → 0 条。**它真的会红,才算加上。**
|
||||||
|
|
||||||
|
### 七、平台运营的「操作日志」永远是空的(**已修**)
|
||||||
|
|
||||||
|
客户点出来的现象:**平台运营进「操作日志」一条都没有**(页面正常、不报错、就是空表)。
|
||||||
|
|
||||||
|
- 根因:`LogController` 恒把 `me.tenantId()` 传给查询,SQL 又写死 `WHERE l.tenant_id = #{tenantId}` ——
|
||||||
|
而平台运营的 tenantId 是 **0**,日志却都挂在各租户下 → **恒为 0 条**。
|
||||||
|
- 而契约 §13.4 **本来就把 `tenantId` 定义成查询参数**,PRD §3.2 对平台运营要求的也是
|
||||||
|
「**全量**操作日志 ✅」(学校管理员才是「🔸 本租户」)。也就是说:**参数没实现、权限码只挂了一半**。
|
||||||
|
- 修法(两个接口一起:`/logs/operations` 与 `/logs/logins`):
|
||||||
|
1. 注解改成 **`@RequirePerm({LOG_VIEW_TENANT, LOG_VIEW_ALL})`**(任一即可);
|
||||||
|
2. 新增 `LogController#logScopeTenantId(tenantId)` —— 有 `log:viewAll` → **默认全量**、
|
||||||
|
显式传 `tenantId` 时只看那一所;只有 `log:viewTenant` → **恒为本租户**,
|
||||||
|
传别人的租户 **`40301`**(⚠️ **不静默忽略**:这一屏是拿去回答合规问题的,
|
||||||
|
"看起来筛过了"比报错更糟);
|
||||||
|
3. 两条手写 SQL 的租户条件改成可选(`WHERE 1 = 1` + `<if test="tenantId != null">`)。
|
||||||
|
- 前端:平台运营多一列「**租户**」(它看的是跨租户全量,不显示单位就分不清这条属于谁;
|
||||||
|
学校管理员永远是本租户,不显示这一列),单位名取自 `/tenants`,取不到就退回 `#id`。
|
||||||
|
- 按客户要求,**同时删掉了这一屏顶部那段说明**(脱敏值/指纹、IP 可伪造、真正的证据是…)——
|
||||||
|
那段话本身没错,但它是"给审查者看的免责声明",挂在日常操作界面上属于噪音。
|
||||||
|
⚠️ 结论性的知识(IP 可伪造、登录日志一期只有 PC)**保留在 `apis/xt/log.ts` 的注释与契约里**,
|
||||||
|
不是删掉了事。
|
||||||
|
|
||||||
|
### 八、验证
|
||||||
|
|
||||||
|
| 手段 | 结果 |
|
||||||
|
|---|---|
|
||||||
|
| `scripts/_verify-fixes.py`(本轮临时脚本,**34 条断言**) | 全过。含 4 条安全边界(非平台带头不生效、非法值 40001、`0` 视为不切换、平台跨租户能力不变)、4 条**写路径**断言(平台代管新建组织落在租户 1、不带头落在租户 0、代管时建班主任账号绑的是**租户 1 的角色行**、代管时发 `PLATFORM_ADMIN` 被 40301 拒)、**8 条日志范围断言**(平台不带头=全量 1250 条、有行、每条带 tenantId、`tenantId=159`→0 条;学校管理员=本租户、指定别家 → 40301;登录日志同口径) |
|
||||||
|
| `scripts/_check-platform-tasks.py` | 平台不带头的任务列表 31 条(跨租户);带 `X-Tenant-Id: 1` → 31 条(该校的);带 `159`(没有任务的租户)→ **0 条** → 头真的在按租户收窄 |
|
||||||
|
| 浏览器(真点,非"看代码") | ① 班主任工作台:**不再弹 403**,组织节点/待审显示 `—` 并写明原因,左侧菜单只剩「收集任务 / 审核」;② 学校管理员菜单:名单管理/表单设计器/材料规则三条消失,其余不变;③ 平台「组织架构」:未选学校时提示"请先选择学校",选定 demo 后拿到该校的组织树;切到「账号管理」**选择被保留**(共用 store);④ 平台**工作台**选定学校后:组织节点、任务下拉、待审 **3**、审核情况表出数(改前是空/`—`);⑤ 学校管理员工作台复验:照旧显示(说明权限码那个错**对所有人**的影响都已修回);⑥ 平台「操作日志」:**出数**、多一列「租户」(显示"示范职业学校")、顶部那段说明**已消失**;学校管理员同一页:只有本租户、**没有**「租户」列、说明同样已消失 |
|
||||||
|
| `pnpm typecheck` | 通过 |
|
||||||
|
| `mvn -DskipTests package` | 通过(jar 已重启) |
|
||||||
|
| E2E 回归 | `e2e-m0` 36/36(含 A8/A9/A10 菜单断言)、`e2e-m1-orgs` 48/48、`e2e-m0-user` 55/55、`e2e-m5-notify-wiring` 43/43(dict/notify 菜单仍在)、**`e2e-m5-logs` 44/44**(日志接口本尊)、`e2e-m1-tenant-domain` 6/6(就在我改动的 `AuthInterceptor` 旁边)、`e2e-m8-open` **61/61**(含新增的"真的重试了"断言) |
|
||||||
|
| `check-wiring.py` / `verify-docs.py` | 未接线声明 **0 条**(第 ⑦ 项:PC 端 7 个权限码全部命中 `PermissionDef`);文档一致性 22 项全过 |
|
||||||
|
|
||||||
|
> ⚠️ 三条**自己踩出来的**教训(都属于"看起来正常,其实不生效"):
|
||||||
|
> ① 把"平台看到 22 个节点""租户 5 个"**写死**在验证脚本里 —— 跑完 E2E 之后它自己变红了
|
||||||
|
> (E2E 会往 demo 租户里加组织、加租户),而**代码一行没改**。改成从库里算期望值(F-110 同族)。
|
||||||
|
> ② 「选择了学校」必须放在 store 而不是各页面 `ref`:否则切页面要重选,
|
||||||
|
> 更糟的是**两个页面各看一所学校**,看起来像数据不一致。
|
||||||
|
> ③ **平台的"超管"捷径会掩盖权限缺口**:只要平台还在自己的租户上下文里,
|
||||||
|
> `PermissionChecker` 对它恒真,缺哪个权限码都看不出来;一旦开始代管某所学校,
|
||||||
|
> 缺口立刻变成 403 或空列表。**要验平台,就得在"代管态"下验一遍。**
|
||||||
|
> ④ 同一族的第四例:**"按租户写死"的接口,对平台就是"永远查不到"**(本轮的操作日志)。
|
||||||
|
> 平台运营的 tenantId 是 0,任何 `WHERE tenant_id = 当前租户` 都会静默返回空。
|
||||||
|
> 已在 `OrgService.tree` / `UserService.list` / `TaskService.seesWholeTenant` / `LogController` 四处统一处理;
|
||||||
|
> **下次新增"租户内"的查询接口时,先问一句:平台运营调它会返回什么?**
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## §一·三十四 PC 端四项走查修复(2026-10-09 晚)
|
||||||
|
|
||||||
|
> 客户在验收里又点了 4 件事,三件已改;**第 3 件(配色)改了之后客户要求整体还原** ——
|
||||||
|
> 第四节专门记了这条"不要再动",免得下一个会话好心重做一遍。
|
||||||
|
|
||||||
|
### 一、平台运维「操作日志」:去掉用户 ID + 分页器根本看不见
|
||||||
|
|
||||||
|
| 现象 | 根因 | 修法 |
|
||||||
|
|---|---|---|
|
||||||
|
| 「操作人」列是 `张校长 / ID 2` 两行 | 数字 ID 对看日志的人没有意义(要 ID 的时候查库更快) | 只留账号/姓名(`operatorName`);接口照旧返回 `operatorId`,只是不显示 |
|
||||||
|
| **分页器"没有正确展示"** | 它**在 DOM 里一直是好的**(`共 1245 条 … 20 条/页`),但被裁在可视区之外:`GiPageLayout` 的内容区是 `overflow: hidden`(页面本身不滚),而「一页 20 条 + 工具栏 + 提示条」比屏幕高 → 卡片连着分页器一起被切掉,怎么滚都看不到 | 新增全局类 **`.g-list-card`**(`styles/global.scss`):卡片撑满内容区 → 表体自己滚 → 分页器钉在卡片底部;表格改 `:scroll="{ x, y: '100%' }"` |
|
||||||
|
| 「N 条/页」改了以后数字对不上 | Arco 表格在 `pagination` 非 false 时**自己也会再切一次数据**(内部 `flattenData` 会 `slice`),只改本地每页条数、不重新取数,就会得到"分页器说 50 条/页、表里还是 20 条" | 补 `@page-size-change` → 回到第 1 页重新取数 |
|
||||||
|
|
||||||
|
⚠️ `y: '100%'` 不是可选项:**字符串**形式的 `y` 才会让 Arco 把表头与表体**拆开**
|
||||||
|
(内部 `splitTable`,表头钉住、表体自己滚)。不写它时 `.arco-table-content` 的
|
||||||
|
`overflow-y` 是 `hidden`(那是给横向滚动条留的),多出来的行会被**直接裁掉且无法滚动**。
|
||||||
|
|
||||||
|
### 二、收集任务:状态切换改成单独一行的 tab
|
||||||
|
|
||||||
|
原来「全部 / 草稿 / …」是和「新建任务 / 批次号 / 搜索」挤在同一行的一排按钮 ——
|
||||||
|
一排长得一样的按钮分不清"哪个是当前视图、哪个是动作"。
|
||||||
|
现在 `<a-tabs hide-content>` 单独占一行,数据源仍是 `statusFilter`
|
||||||
|
(tab 的 key ↔ status 的映射只在 `STATUS_TABS` 一处定义,别再写第二份)。
|
||||||
|
|
||||||
|
⚠️ **加了这一行之后,任务页也踩了同一个分页器坑**(卡片变高 → 分页器被顶出可视区):
|
||||||
|
同一处 `.g-list-card` 一并修掉。**改这类"往卡片里加东西"的活,先看分页器还在不在视野里。**
|
||||||
|
|
||||||
|
### 三、通知铃铛:按客户要求先去掉
|
||||||
|
|
||||||
|
删掉顶栏铃铛与 `HeaderRightBar/Notice.vue`(那是 Gi 模板的假数据:写死 9 条、点了没反应,
|
||||||
|
和站内信没有任何关系)。将来要做,数据源是「通知记录」(`/collect/notify`,接口与页面都已就绪),
|
||||||
|
**不要恢复成写死的假列表**。
|
||||||
|
|
||||||
|
### 四、配色:改过一版腾讯云深蓝外壳,**客户要求整体还原**(不要再改)
|
||||||
|
|
||||||
|
本轮先按"左侧菜单栏 + 顶栏用腾讯云控制台深蓝、不要纯白"改了一版
|
||||||
|
(新增 `--xt-shell-*` 变量、`.g-shell`、菜单恒定 `theme="dark"`、并删掉设置抽屉里的「深色菜单」开关),
|
||||||
|
**客户看到后明确要求还原配色**,已按文件 `git checkout` 回原样:外壳仍是纯白,
|
||||||
|
`isMenuDark` / `.app-menu-dark` / 「深色菜单」开关**都保持原样**。
|
||||||
|
|
||||||
|
> **不要再"顺手"改配色。** 要改先问。
|
||||||
|
|
||||||
|
### 五、这一轮踩到的两个坑(都值得记)
|
||||||
|
|
||||||
|
1. **HMR 下 `Drawer.open({ content: () => h(SettingDrawerPanel) })` 不会热更新**:
|
||||||
|
该组件只出现在闭包里、不在任何模板里 —— 改了它,页面看起来毫无变化,
|
||||||
|
很容易把"没生效"误判成"改错了/改不动"。
|
||||||
|
**验证前先真重载**(`about:blank` → 目标 URL;同 URL 再 open 不一定会重新拉模块)。
|
||||||
|
2. **演示账号不是 `frontend-pc/README.md` 里那个**:那里的 `admin / 123456` 是 Gi 模板的旧值,
|
||||||
|
本项目是 **`admin / Admin@123` + 租户码 `demo`**
|
||||||
|
(`python scripts/dev-login.py` 用的就是它)。账号在多个单位下重名时,
|
||||||
|
登录页才会出现「租户码」输入框(服务端 `40012`)。
|
||||||
|
|
||||||
|
### 六、未做(同一族的问题,留给下一轮)
|
||||||
|
|
||||||
|
「通知记录」与「账号管理」两页的分页器**会被同样地裁掉**
|
||||||
|
(实测分页器顶边 1612 / 886,视口高 900)。修法是给这两页的卡片加同一个 `.g-list-card`
|
||||||
|
并把表格改成 `y: '100%'`,本轮没动(客户只报了操作日志那一处)。
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import type { AxiosRequestConfig } from 'axios'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 平台运营的「当前学校」——请求头 `X-Tenant-Id`。
|
||||||
|
*
|
||||||
|
* ## 为什么需要它
|
||||||
|
*
|
||||||
|
* 平台运营(`tenantId = 0`)是唯一跨租户的身份,服务端的租户拦截器因此对它
|
||||||
|
* **整个关掉了租户过滤**(`MybatisPlusConfig`)。于是「组织架构」「账号管理」
|
||||||
|
* 这类**本来就是租户内**的页面,在平台账号下拿到的是**所有租户混在一起**的数据 ——
|
||||||
|
* 而且看不出错:只有一所学校有数据时,它和学校管理员看到的完全一样。
|
||||||
|
*
|
||||||
|
* 所以平台要代管某所学校时,必须**显式指明是哪一所**:带上这个头,服务端会把
|
||||||
|
* 本次请求的租户上下文换成目标租户(`ActingTenantResolver` + `AuthInterceptor`),
|
||||||
|
* 之后租户过滤、写入归属、权限判定都按"我就是那所学校"走。
|
||||||
|
*
|
||||||
|
* ## 三条使用约定
|
||||||
|
*
|
||||||
|
* 1. **只有平台运营的页面才传它**(页面用 `userStore.roles.includes('PLATFORM_ADMIN')` 判断)。
|
||||||
|
* 非平台身份即使传了,服务端也一律忽略 —— 那是安全边界,不是"顺手支持一下"。
|
||||||
|
* 2. **不传 / 传空 = 不发这个头**:平台照旧可以跨租户看租户列表、全量日志。
|
||||||
|
* 3. 学校管理员的页面**不要**传(他们的租户由 token 决定,传了也没用)。
|
||||||
|
*/
|
||||||
|
export const ACTING_TENANT_HEADER = 'X-Tenant-Id'
|
||||||
|
|
||||||
|
/** 组装"代管租户"请求配置;没有租户时返回空对象(不发头) */
|
||||||
|
export function actingTenantConfig(tenantId?: number | null): AxiosRequestConfig {
|
||||||
|
if (!tenantId) {
|
||||||
|
return {}
|
||||||
|
}
|
||||||
|
return { headers: { [ACTING_TENANT_HEADER]: String(tenantId) } }
|
||||||
|
}
|
||||||
@@ -24,6 +24,7 @@
|
|||||||
* 数字只用于选色。这样即便枚举有调整,界面也不会说错话。
|
* 数字只用于选色。这样即便枚举有调整,界面也不会说错话。
|
||||||
*/
|
*/
|
||||||
import http from '@/utils/http'
|
import http from '@/utils/http'
|
||||||
|
import { actingTenantConfig } from './acting-tenant'
|
||||||
|
|
||||||
/** 带业务码的错误(`@/utils/http` 的拦截器会把 `code` 挂到 error 上)。 */
|
/** 带业务码的错误(`@/utils/http` 的拦截器会把 `code` 挂到 error 上)。 */
|
||||||
export type BizError = Error & { bizCode?: number, bizData?: unknown }
|
export type BizError = Error & { bizCode?: number, bizData?: unknown }
|
||||||
@@ -422,6 +423,6 @@ export function getAuditStatistics(params: {
|
|||||||
orgId?: number
|
orgId?: number
|
||||||
from?: string
|
from?: string
|
||||||
to?: string
|
to?: string
|
||||||
}) {
|
}, tenantId?: number | null) {
|
||||||
return http.get<AuditStatisticsVO>('/audit/statistics', params)
|
return http.get<AuditStatisticsVO>('/audit/statistics', params, actingTenantConfig(tenantId))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
* 分页 `{ list, total }`(适配层会补一份 `records`)。
|
* 分页 `{ list, total }`(适配层会补一份 `records`)。
|
||||||
*/
|
*/
|
||||||
import http from '@/utils/http'
|
import http from '@/utils/http'
|
||||||
|
import { actingTenantConfig } from './acting-tenant'
|
||||||
|
|
||||||
/** 组织树节点(§6.1) */
|
/** 组织树节点(§6.1) */
|
||||||
export interface OrgTreeVO {
|
export interface OrgTreeVO {
|
||||||
@@ -34,24 +35,28 @@ export interface OrgUpsertReq {
|
|||||||
/**
|
/**
|
||||||
* 组织树。
|
* 组织树。
|
||||||
* @param withCount 是否附带每节点学生数(会多一次聚合查询,列表页按需开)
|
* @param withCount 是否附带每节点学生数(会多一次聚合查询,列表页按需开)
|
||||||
|
* @param tenantId **仅平台运营**:代管哪个学校的组织(见 `acting-tenant.ts`)。
|
||||||
|
* 不传 = 不代管 —— 此时服务端只会返回平台自己租户的组织(空树),
|
||||||
|
* **不会**再把所有租户的组织混在一起端出来。
|
||||||
*/
|
*/
|
||||||
export function getOrgTree(params: { withCount?: boolean, rootId?: number } = {}) {
|
export function getOrgTree(params: { withCount?: boolean, rootId?: number, tenantId?: number | null } = {}) {
|
||||||
return http.get<OrgTreeVO[]>('/orgs/tree', params)
|
const { tenantId, ...query } = params
|
||||||
|
return http.get<OrgTreeVO[]>('/orgs/tree', query, actingTenantConfig(tenantId))
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 新增,返回新节点 id */
|
/** 新增,返回新节点 id(`tenantId` 仅平台运营:新建到哪个学校下) */
|
||||||
export function addOrg(data: OrgUpsertReq) {
|
export function addOrg(data: OrgUpsertReq, tenantId?: number | null) {
|
||||||
return http.post<number>('/orgs', data)
|
return http.post<number>('/orgs', data, actingTenantConfig(tenantId))
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 修改;变更层级时服务端会自动重算整棵子树的 ancestor_path */
|
/** 修改;变更层级时服务端会自动重算整棵子树的 ancestor_path */
|
||||||
export function updateOrg(id: number, data: OrgUpsertReq) {
|
export function updateOrg(id: number, data: OrgUpsertReq, tenantId?: number | null) {
|
||||||
return http.put<void>(`/orgs/${id}`, data)
|
return http.put<void>(`/orgs/${id}`, data, actingTenantConfig(tenantId))
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 删除;有下级组织或账号时返回 40015 并给出占用清单 */
|
/** 删除;有下级组织或账号时返回 40015 并给出占用清单 */
|
||||||
export function deleteOrg(id: number) {
|
export function deleteOrg(id: number, tenantId?: number | null) {
|
||||||
return http.delete<void>(`/orgs/${id}`)
|
return http.delete<void>(`/orgs/${id}`, actingTenantConfig(tenantId))
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 组织类型 → 中文名(与 DDL 的 org_type 对齐) */
|
/** 组织类型 → 中文名(与 DDL 的 org_type 对齐) */
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
* 分页 `{ list, total }`(适配层会补一份 `records`)。
|
* 分页 `{ list, total }`(适配层会补一份 `records`)。
|
||||||
*/
|
*/
|
||||||
import http from '@/utils/http'
|
import http from '@/utils/http'
|
||||||
|
import { actingTenantConfig } from './acting-tenant'
|
||||||
import { getUserList } from './user'
|
import { getUserList } from './user'
|
||||||
|
|
||||||
/** 审核流配置(§3.2) */
|
/** 审核流配置(§3.2) */
|
||||||
@@ -117,9 +118,14 @@ export interface TaskListQuery {
|
|||||||
pageSize?: number
|
pageSize?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 任务分页列表。班主任只会拿到自己带的班级的任务 */
|
/**
|
||||||
export function getTaskList(params: TaskListQuery) {
|
* 任务分页列表。班主任只会拿到自己带的班级的任务
|
||||||
return http.get<{ list: TaskVO[], total: number }>('/tasks', params)
|
*
|
||||||
|
* @param tenantId **仅平台运营**:看哪所学校的任务(见 `./acting-tenant`)。
|
||||||
|
* 不传 = 平台自己租户(空列表)—— 不再把各租户的任务混在一起。
|
||||||
|
*/
|
||||||
|
export function getTaskList(params: TaskListQuery, tenantId?: number | null) {
|
||||||
|
return http.get<{ list: TaskVO[], total: number }>('/tasks', params, actingTenantConfig(tenantId))
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getTaskDetail(id: number) {
|
export function getTaskDetail(id: number) {
|
||||||
@@ -343,6 +349,6 @@ export interface TaskStatisticsVO {
|
|||||||
* ⚠️ `submitRate`(已提交/应填)与 `passRate`(已通过/应填)**必须分开显示**:
|
* ⚠️ `submitRate`(已提交/应填)与 `passRate`(已通过/应填)**必须分开显示**:
|
||||||
* 已提交 ≠ 已通过,合并会让「快完成了」与「快通过了」混为一谈。
|
* 已提交 ≠ 已通过,合并会让「快完成了」与「快通过了」混为一谈。
|
||||||
*/
|
*/
|
||||||
export function getTaskStatistics(taskId: number) {
|
export function getTaskStatistics(taskId: number, tenantId?: number | null) {
|
||||||
return http.get<TaskStatisticsVO>(`/tasks/${taskId}/statistics`)
|
return http.get<TaskStatisticsVO>(`/tasks/${taskId}/statistics`, undefined, actingTenantConfig(tenantId))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
* 分页 `{ list, total }`(适配层会补一份 `records`)。
|
* 分页 `{ list, total }`(适配层会补一份 `records`)。
|
||||||
*/
|
*/
|
||||||
import http from '@/utils/http'
|
import http from '@/utils/http'
|
||||||
|
import { actingTenantConfig } from './acting-tenant'
|
||||||
|
|
||||||
/** 账号列表项(§5.6) */
|
/** 账号列表项(§5.6) */
|
||||||
export interface UserVO {
|
export interface UserVO {
|
||||||
@@ -75,43 +76,57 @@ export interface UserListQuery {
|
|||||||
pageSize?: number
|
pageSize?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── 账号接口 ────────────────────────────────────────
|
||||||
|
//
|
||||||
|
// ⚠️ 每个接口的最后都有一个可选 `tenantId`:**仅平台运营**用来指明"代管哪所学校"
|
||||||
|
// (见 `acting-tenant.ts`)。学校管理员的页面一律不传 —— 他们的租户由 token 决定。
|
||||||
|
// 不传时服务端只返回平台自己租户的账号,不会再把所有租户的账号混在一起。
|
||||||
|
|
||||||
/** 账号分页列表 */
|
/** 账号分页列表 */
|
||||||
export function getUserList(params: UserListQuery) {
|
export function getUserList(params: UserListQuery, tenantId?: number | null) {
|
||||||
return http.get<{ list: UserVO[], total: number }>('/users', params)
|
return http.get<{ list: UserVO[], total: number }>('/users', params, actingTenantConfig(tenantId))
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 新增账号,返回初始密码(仅此一次) */
|
/** 新增账号,返回初始密码(仅此一次) */
|
||||||
export function addUser(data: UserUpsertReq) {
|
export function addUser(data: UserUpsertReq, tenantId?: number | null) {
|
||||||
return http.post<UserCreateResult>('/users', data)
|
return http.post<UserCreateResult>('/users', data, actingTenantConfig(tenantId))
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 修改账号;登录名与密码不在此接口修改 */
|
/** 修改账号;登录名与密码不在此接口修改 */
|
||||||
export function updateUser(id: number, data: UserUpsertReq) {
|
export function updateUser(id: number, data: UserUpsertReq, tenantId?: number | null) {
|
||||||
return http.put<void>(`/users/${id}`, data)
|
return http.put<void>(`/users/${id}`, data, actingTenantConfig(tenantId))
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 停用(软删)。有在办任务返回 40014;不能停用自己或最后一个管理员 */
|
/** 停用(软删)。有在办任务返回 40014;不能停用自己或最后一个管理员 */
|
||||||
export function deleteUser(id: number) {
|
export function deleteUser(id: number, tenantId?: number | null) {
|
||||||
return http.delete<void>(`/users/${id}`)
|
return http.delete<void>(`/users/${id}`, actingTenantConfig(tenantId))
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 启用 / 停用(只改 status,数据保留可再启用) */
|
/** 启用 / 停用(只改 status,数据保留可再启用) */
|
||||||
export function setUserStatus(id: number, status: number) {
|
export function setUserStatus(id: number, status: number, tenantId?: number | null) {
|
||||||
// status 后端设计为查询参数(PUT /users/{id}/status?status=0),
|
// status 后端设计为查询参数(PUT /users/{id}/status?status=0),
|
||||||
// 所以走 config.params 而不是 body
|
// 所以走 config.params 而不是 body
|
||||||
return http.put<void>(`/users/${id}/status`, undefined, { params: { status } })
|
return http.put<void>(`/users/${id}/status`, undefined, {
|
||||||
|
...actingTenantConfig(tenantId),
|
||||||
|
params: { status }
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 重置密码。mode 省略即 GENERATE(服务端生成)会同时解除该账号的登录锁定 */
|
/** 重置密码。mode 省略即 GENERATE(服务端生成)会同时解除该账号的登录锁定 */
|
||||||
export function resetUserPassword(id: number, data: { mode?: string, newPassword?: string } = {}) {
|
export function resetUserPassword(
|
||||||
return http.post<UserResetResult>(`/users/${id}/reset-password`, data)
|
id: number,
|
||||||
|
data: { mode?: string, newPassword?: string } = {},
|
||||||
|
tenantId?: number | null
|
||||||
|
) {
|
||||||
|
return http.post<UserResetResult>(`/users/${id}/reset-password`, data, actingTenantConfig(tenantId))
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 批量导入账号(Excel ≤10MB),返回体检报告 */
|
/** 批量导入账号(Excel ≤10MB),返回体检报告 */
|
||||||
export function importUsers(file: File, defaultRoleCode?: string) {
|
export function importUsers(file: File, defaultRoleCode?: string, tenantId?: number | null) {
|
||||||
const fd = new FormData()
|
const fd = new FormData()
|
||||||
fd.append('file', file)
|
fd.append('file', file)
|
||||||
return http.post<UserImportResult>('/users/import', fd, {
|
return http.post<UserImportResult>('/users/import', fd, {
|
||||||
|
...actingTenantConfig(tenantId),
|
||||||
params: defaultRoleCode ? { defaultRoleCode } : undefined
|
params: defaultRoleCode ? { defaultRoleCode } : undefined
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
Vendored
+2
@@ -91,6 +91,7 @@ declare module 'vue' {
|
|||||||
GiTable: typeof import('./components/GiTable/GiTable.vue')['default']
|
GiTable: typeof import('./components/GiTable/GiTable.vue')['default']
|
||||||
GiTableSetting: typeof import('./components/GiTableSetting/GiTableSetting.vue')['default']
|
GiTableSetting: typeof import('./components/GiTableSetting/GiTableSetting.vue')['default']
|
||||||
GiTag: typeof import('./components/GiTag/GiTag.tsx')['default']
|
GiTag: typeof import('./components/GiTag/GiTag.tsx')['default']
|
||||||
|
GiTenantPicker: typeof import('./components/GiTenantPicker/GiTenantPicker.vue')['default']
|
||||||
GiThemeBtn: typeof import('./components/GiThemeBtn/GiThemeBtn.vue')['default']
|
GiThemeBtn: typeof import('./components/GiThemeBtn/GiThemeBtn.vue')['default']
|
||||||
RouterLink: typeof import('vue-router')['RouterLink']
|
RouterLink: typeof import('vue-router')['RouterLink']
|
||||||
RouterView: typeof import('vue-router')['RouterView']
|
RouterView: typeof import('vue-router')['RouterView']
|
||||||
@@ -179,6 +180,7 @@ declare global {
|
|||||||
const GiTable: typeof import('./components/GiTable/GiTable.vue')['default']
|
const GiTable: typeof import('./components/GiTable/GiTable.vue')['default']
|
||||||
const GiTableSetting: typeof import('./components/GiTableSetting/GiTableSetting.vue')['default']
|
const GiTableSetting: typeof import('./components/GiTableSetting/GiTableSetting.vue')['default']
|
||||||
const GiTag: typeof import('./components/GiTag/GiTag.tsx')['default']
|
const GiTag: typeof import('./components/GiTag/GiTag.tsx')['default']
|
||||||
|
const GiTenantPicker: typeof import('./components/GiTenantPicker/GiTenantPicker.vue')['default']
|
||||||
const GiThemeBtn: typeof import('./components/GiThemeBtn/GiThemeBtn.vue')['default']
|
const GiThemeBtn: typeof import('./components/GiThemeBtn/GiThemeBtn.vue')['default']
|
||||||
const RouterLink: typeof import('vue-router')['RouterLink']
|
const RouterLink: typeof import('vue-router')['RouterLink']
|
||||||
const RouterView: typeof import('vue-router')['RouterView']
|
const RouterView: typeof import('vue-router')['RouterView']
|
||||||
|
|||||||
@@ -0,0 +1,74 @@
|
|||||||
|
<template>
|
||||||
|
<a-select
|
||||||
|
v-model="model"
|
||||||
|
class="gi-tenant-picker"
|
||||||
|
:placeholder="props.placeholder"
|
||||||
|
:loading="loading"
|
||||||
|
:disabled="props.disabled"
|
||||||
|
:style="{ width: props.width }"
|
||||||
|
allow-search
|
||||||
|
allow-clear
|
||||||
|
>
|
||||||
|
<a-option v-for="t in options" :key="t.id" :value="t.id" :disabled="t.status === 0">
|
||||||
|
{{ t.tenantName }}({{ t.tenantCode }}){{ t.status === 0 ? ' · 已停用' : '' }}
|
||||||
|
</a-option>
|
||||||
|
</a-select>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import type { TenantVO } from '@/apis/xt/tenant'
|
||||||
|
import { getTenantList } from '@/apis/xt/tenant'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 「选择学校」——平台运营的当前租户。
|
||||||
|
*
|
||||||
|
* 为什么要有这个东西:平台运营是跨租户身份,服务端对它的租户过滤是**关掉的**,
|
||||||
|
* 所以它必须**先指明看哪所学校**,否则「组织架构」「账号管理」会拿到所有租户混在一起的数据。
|
||||||
|
* 选中的值由页面带进请求头 `X-Tenant-Id`(见 `@/apis/xt/acting-tenant`)。
|
||||||
|
*
|
||||||
|
* ⚠️ 只有平台运营的页面才渲染它 —— 学校管理员的租户由 token 决定,给他一个
|
||||||
|
* "选择学校"的下拉框是纯粹的困惑(而且服务端会忽略他传的值)。
|
||||||
|
*
|
||||||
|
* ⚠️ 租户列表接口只对平台运营开放(学校管理员调它得 40301),所以本组件
|
||||||
|
* **不要**在非平台页面里渲染。
|
||||||
|
*/
|
||||||
|
defineOptions({ name: 'GiTenantPicker' })
|
||||||
|
|
||||||
|
const model = defineModel<number | undefined>({ default: undefined })
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<{
|
||||||
|
placeholder?: string
|
||||||
|
disabled?: boolean
|
||||||
|
width?: string
|
||||||
|
}>(), {
|
||||||
|
placeholder: '选择学校',
|
||||||
|
disabled: false,
|
||||||
|
width: '100%'
|
||||||
|
})
|
||||||
|
|
||||||
|
const loading = ref(false)
|
||||||
|
const options = ref<TenantVO[]>([])
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
// 一期租户规模是"几十所"量级(接口上限 200),一次取完比做分页下拉简单得多。
|
||||||
|
// 已停用的也列出来但不可选 —— 服务端会拒("目标单位不存在或已停用"),
|
||||||
|
// 与其让人选完才知道,不如在选择时就说明。
|
||||||
|
const res = await getTenantList({ pageNum: 1, pageSize: 200 })
|
||||||
|
options.value = res.data?.list ?? []
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(load)
|
||||||
|
|
||||||
|
defineExpose({ reload: load })
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.gi-tenant-picker {
|
||||||
|
min-width: 180px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -30,20 +30,6 @@ export interface XtMenu {
|
|||||||
ready: boolean
|
ready: boolean
|
||||||
/** 待建说明 */
|
/** 待建说明 */
|
||||||
note?: string
|
note?: string
|
||||||
/**
|
|
||||||
* 这个功能**其实已经有了**,只是入口不在左侧菜单里(例如"名单管理"是按任务的,
|
|
||||||
* 入口在「收集任务」每一行的链接里)。
|
|
||||||
*
|
|
||||||
* ⚠️ 有它的时候,「待建」页就不再显示"还没做",而是给出真实入口的指路 + 跳转按钮。
|
|
||||||
* 没有它 = 确实还没做。两者的区别很重要:说"还没做"而功能其实在隔壁,
|
|
||||||
* 会让人**得出错误结论**(比找不到更糟)。
|
|
||||||
*/
|
|
||||||
entryAt?: {
|
|
||||||
/** 跳到哪个路由 */
|
|
||||||
path: string
|
|
||||||
/** 到了之后该点哪里(要具体到"点哪一行里的哪个链接") */
|
|
||||||
hint: string
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 分组顺序(决定左侧菜单顺序) */
|
/** 分组顺序(决定左侧菜单顺序) */
|
||||||
@@ -67,22 +53,6 @@ export const XT_MENUS: XtMenu[] = [
|
|||||||
sort: 11,
|
sort: 11,
|
||||||
ready: true
|
ready: true
|
||||||
},
|
},
|
||||||
{
|
|
||||||
key: 'roster',
|
|
||||||
title: '名单管理',
|
|
||||||
icon: 'icon-park-outline:user-business',
|
|
||||||
path: '/collect/roster',
|
|
||||||
component: 'roster/index',
|
|
||||||
group: 'collect',
|
|
||||||
sort: 12,
|
|
||||||
ready: false,
|
|
||||||
note: '名册是按任务的:入口在「收集任务」每一行里点「名单管理」。'
|
|
||||||
+ '导入、体检报告、名册列表都在那里;报考范围确认(M2-04/05)会接着做进同一个抽屉。',
|
|
||||||
entryAt: {
|
|
||||||
path: '/collect/task',
|
|
||||||
hint: '在「收集任务」里找到那个任务,点它这一行的「名单管理」'
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
key: 'join',
|
key: 'join',
|
||||||
title: '加入申请',
|
title: '加入申请',
|
||||||
@@ -128,36 +98,6 @@ export const XT_MENUS: XtMenu[] = [
|
|||||||
},
|
},
|
||||||
|
|
||||||
// ── 配置 ─────────────────────────────────────────
|
// ── 配置 ─────────────────────────────────────────
|
||||||
{
|
|
||||||
key: 'form',
|
|
||||||
title: '表单设计器',
|
|
||||||
icon: 'icon-park-outline:form-one',
|
|
||||||
path: '/setting/form',
|
|
||||||
component: 'form/index',
|
|
||||||
group: 'setting',
|
|
||||||
sort: 21,
|
|
||||||
ready: false,
|
|
||||||
note: '内置字段锁定、扩展字段自由 —— 按任务设计,入口在「收集任务」每一行的「表单设计」。',
|
|
||||||
entryAt: {
|
|
||||||
path: '/collect/task',
|
|
||||||
hint: '在「收集任务」里找到那个任务,点它这一行的「表单设计」'
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'material',
|
|
||||||
title: '材料规则',
|
|
||||||
icon: 'icon-park-outline:file-editing',
|
|
||||||
path: '/setting/material',
|
|
||||||
component: 'material/index',
|
|
||||||
group: 'setting',
|
|
||||||
sort: 22,
|
|
||||||
ready: false,
|
|
||||||
note: '命名模板、别名、格式白名单、体积上限、压缩档位 —— 按任务配置,入口在「收集任务」每一行的「材料规则」。',
|
|
||||||
entryAt: {
|
|
||||||
path: '/collect/task',
|
|
||||||
hint: '在「收集任务」里找到那个任务,点它这一行的「材料规则」'
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
key: 'cleanup',
|
key: 'cleanup',
|
||||||
title: '历史整理器',
|
title: '历史整理器',
|
||||||
@@ -338,17 +278,7 @@ function readyItem(menu: XtMenu): UserRouteItem {
|
|||||||
} as UserRouteItem
|
} as UserRouteItem
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 按菜单键取定义(给"待建"页显示说明用) */
|
/** 按路由路径取菜单定义(「待建」页要显示说明用) */
|
||||||
export function findMenu(key: string): XtMenu | undefined {
|
|
||||||
return XT_MENUS.find((m) => m.key === key)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 按路由路径取菜单定义(「待建」页要显示"去哪个页面")。
|
|
||||||
*
|
|
||||||
* ⚠️ 按钮上的目标页名**必须从这里取**,不要在页面里写死 ——
|
|
||||||
* 菜单哪天改名,写死的那个名字就会指向一个不存在的页面。
|
|
||||||
*/
|
|
||||||
export function findMenuByPath(path: string): XtMenu | undefined {
|
export function findMenuByPath(path: string): XtMenu | undefined {
|
||||||
return XT_MENUS.find((m) => m.path === path)
|
return XT_MENUS.find((m) => m.path === path)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,85 +0,0 @@
|
|||||||
<!--
|
|
||||||
@file Message 组件
|
|
||||||
@description 消息通知组件,包含通知、关注和待办三个标签页
|
|
||||||
-->
|
|
||||||
<template>
|
|
||||||
<div class="message">
|
|
||||||
<!-- 消息标签页 -->
|
|
||||||
<a-tabs :active-key="activeTab" style="background-color: transparent;" @change="handleTabChange">
|
|
||||||
<a-tab-pane v-for="tab in tabs" :key="tab.key">
|
|
||||||
<template #title>{{ tab.title }}({{ tab.count }})</template>
|
|
||||||
</a-tab-pane>
|
|
||||||
</a-tabs>
|
|
||||||
|
|
||||||
<!-- 消息列表 -->
|
|
||||||
<section class="message-list">
|
|
||||||
<a-comment v-for="item in messageList" :key="item.id" :author="item.name" :content="item.content"
|
|
||||||
:datetime="item.datetime">
|
|
||||||
<template #avatar>
|
|
||||||
<a-avatar>
|
|
||||||
<img :src="item.avatar" :alt="item.name">
|
|
||||||
</a-avatar>
|
|
||||||
</template>
|
|
||||||
</a-comment>
|
|
||||||
</section>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import type { TabsInstance } from '@arco-design/web-vue'
|
|
||||||
|
|
||||||
/** 组件名称 */
|
|
||||||
defineOptions({ name: 'Message' })
|
|
||||||
|
|
||||||
/** 消息类型定义 */
|
|
||||||
interface MessageItem {
|
|
||||||
id?: string | number
|
|
||||||
name: string
|
|
||||||
datetime: string
|
|
||||||
content: string
|
|
||||||
avatar: string
|
|
||||||
actionText?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 标签页配置 */
|
|
||||||
const tabs = [
|
|
||||||
{ key: '1', title: '通知', count: 1 },
|
|
||||||
{ key: '2', title: '关注', count: 1 },
|
|
||||||
{ key: '3', title: '待办', count: 2 }
|
|
||||||
]
|
|
||||||
|
|
||||||
/** 组件状态 */
|
|
||||||
const activeTab = ref('1')
|
|
||||||
|
|
||||||
/** 消息列表数据 */
|
|
||||||
const messageList = ref<MessageItem[]>([
|
|
||||||
{
|
|
||||||
id: 1,
|
|
||||||
name: 'Socrates',
|
|
||||||
datetime: '1小时之前',
|
|
||||||
content: 'Comment body content.',
|
|
||||||
avatar: 'https://img1.baidu.com/it/u=1817951587,3188870642&fm=253&fmt=auto&app=138&f=JPEG?w=500&h=500'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 2,
|
|
||||||
name: '木木糖醇',
|
|
||||||
datetime: '2小时之前',
|
|
||||||
content: '关注了你',
|
|
||||||
avatar: 'https://s1.ax1x.com/2022/06/14/XhteeO.jpg'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 3,
|
|
||||||
name: '徐欣',
|
|
||||||
datetime: '2个半小时之前',
|
|
||||||
content: '收藏了你的文章',
|
|
||||||
avatar: 'https://s1.ax1x.com/2022/06/14/XhtSwF.jpg'
|
|
||||||
}
|
|
||||||
])
|
|
||||||
|
|
||||||
/** 处理标签页切换 */
|
|
||||||
const handleTabChange: TabsInstance['onChange'] = (key) => {
|
|
||||||
activeTab.value = key as string
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style lang="scss" scoped></style>
|
|
||||||
@@ -14,19 +14,12 @@
|
|||||||
</a-button>
|
</a-button>
|
||||||
</a-tooltip>
|
</a-tooltip>
|
||||||
|
|
||||||
<!-- 消息通知下拉框 -->
|
<!--
|
||||||
<a-popover position="bottom" trigger="click">
|
消息通知(铃铛)**暂时去掉**(2026-10-09 客户要求):
|
||||||
<a-badge :count="9" dot>
|
原来的铃铛是一个写死 9 条假数据的下拉框,站内信还没接到这个入口上。
|
||||||
<a-button size="mini" class="g-hover-btn">
|
真要做的时候,数据源是「通知记录」(`/collect/notify`,接口已实现),
|
||||||
<template #icon>
|
不要恢复成写死的假列表。
|
||||||
<icon-notification :size="18" />
|
-->
|
||||||
</template>
|
|
||||||
</a-button>
|
|
||||||
</a-badge>
|
|
||||||
<template #content>
|
|
||||||
<Notice></Notice>
|
|
||||||
</template>
|
|
||||||
</a-popover>
|
|
||||||
|
|
||||||
<!-- 全屏切换按钮 -->
|
<!-- 全屏切换按钮 -->
|
||||||
<a-tooltip v-if="!['xs', 'sm'].includes(breakpoint)" content="全屏切换" position="bottom">
|
<a-tooltip v-if="!['xs', 'sm'].includes(breakpoint)" content="全屏切换" position="bottom">
|
||||||
@@ -87,7 +80,6 @@ import { useFullscreen } from '@vueuse/core'
|
|||||||
import { useBreakpoint, useTheme } from '@/hooks'
|
import { useBreakpoint, useTheme } from '@/hooks'
|
||||||
import { useAppStore, useUserStore } from '@/stores'
|
import { useAppStore, useUserStore } from '@/stores'
|
||||||
import ChangePasswordModal from './ChangePasswordModal.vue'
|
import ChangePasswordModal from './ChangePasswordModal.vue'
|
||||||
import Notice from './Notice.vue'
|
|
||||||
import SettingDrawerPanel from './SettingDrawerPanel.vue'
|
import SettingDrawerPanel from './SettingDrawerPanel.vue'
|
||||||
|
|
||||||
/** 组件名称 */
|
/** 组件名称 */
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { createPinia } from 'pinia'
|
import { createPinia } from 'pinia'
|
||||||
import piniaPluginPersistedstate from 'pinia-plugin-persistedstate'
|
import piniaPluginPersistedstate from 'pinia-plugin-persistedstate'
|
||||||
|
|
||||||
|
export * from './useActingTenantStore'
|
||||||
export * from './useAppStore'
|
export * from './useAppStore'
|
||||||
export * from './useDictStore'
|
export * from './useDictStore'
|
||||||
export * from './useMenuBadgeStore'
|
export * from './useMenuBadgeStore'
|
||||||
|
|||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import { defineStore } from 'pinia'
|
||||||
|
import { ref } from 'vue'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 平台运营的「当前学校」。
|
||||||
|
*
|
||||||
|
* ## 为什么要有这个状态
|
||||||
|
*
|
||||||
|
* 平台运营(`tenantId = 0`)是唯一跨租户的身份,服务端的租户过滤对它整体关闭。
|
||||||
|
* 所以「组织架构」「账号管理」这类**本身就是租户内**的页面,平台必须先指明看哪所学校 ——
|
||||||
|
* 否则拿到的是所有租户混在一起的数据,而且看不出错。
|
||||||
|
*
|
||||||
|
* 这个值是**跟着请求走的**(`X-Tenant-Id` 请求头,见 `@/apis/xt/acting-tenant`),
|
||||||
|
* 不是登录态的一部分:token 里的 tenantId 始终是 0。
|
||||||
|
*
|
||||||
|
* ## 为什么放在 store 而不是各页面自己 `ref`
|
||||||
|
*
|
||||||
|
* 两个页面都要用同一个选择。各写一份的话,用户在「组织架构」选了学校、
|
||||||
|
* 切到「账号管理」又得重选一次 —— 而更糟的是**看起来像是两所学校的数据不一致**。
|
||||||
|
*
|
||||||
|
* ⚠️ `persist: false`:刷新页面就忘掉。这是刻意的 ——
|
||||||
|
* 一个跨越登录会话的"当前学校"会在下次登录时静默生效,
|
||||||
|
* 而那时候用户心里想的可能是另一所学校。
|
||||||
|
*/
|
||||||
|
const storeSetup = () => {
|
||||||
|
/** 当前代管的租户 id;`undefined` = 未选择(此时不应请求租户内数据) */
|
||||||
|
const tenantId = ref<number | undefined>(undefined)
|
||||||
|
|
||||||
|
const setTenant = (id?: number) => {
|
||||||
|
tenantId.value = id
|
||||||
|
}
|
||||||
|
|
||||||
|
const clear = () => {
|
||||||
|
tenantId.value = undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
return { tenantId, setTenant, clear }
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useActingTenantStore = defineStore('actingTenant', storeSetup, { persist: false })
|
||||||
@@ -10,6 +10,7 @@ import { buildXtRoutes } from '@/config/xt-routes'
|
|||||||
import { resetRouter } from '@/router'
|
import { resetRouter } from '@/router'
|
||||||
import { clearToken, getToken, setToken } from '@/utils/auth'
|
import { clearToken, getToken, setToken } from '@/utils/auth'
|
||||||
import { isHttp } from '@/utils/validate'
|
import { isHttp } from '@/utils/validate'
|
||||||
|
import { useActingTenantStore } from './useActingTenantStore'
|
||||||
import { useRouteStore } from './useRouteStore'
|
import { useRouteStore } from './useRouteStore'
|
||||||
|
|
||||||
/** 登录参数:password 必须是 RSA 加密后的密文(见 @/utils/rsa) */
|
/** 登录参数:password 必须是 RSA 加密后的密文(见 @/utils/rsa) */
|
||||||
@@ -101,6 +102,9 @@ const storeSetup = () => {
|
|||||||
resetRouter()
|
resetRouter()
|
||||||
profile.value = null
|
profile.value = null
|
||||||
menuKeys.value = []
|
menuKeys.value = []
|
||||||
|
// 平台运营的"当前学校"属于**上一次身份**的上下文,退出时一起清掉:
|
||||||
|
// 留着它,下一个登录的人(可能是学校管理员)会继承一个自己没选过的租户
|
||||||
|
useActingTenantStore().clear()
|
||||||
}
|
}
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -190,6 +190,53 @@
|
|||||||
margin-bottom: 12px;
|
margin-bottom: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 列表页卡片(卡片里装着「工具栏 + 表格 + 分页器」)。
|
||||||
|
*
|
||||||
|
* 为什么必须有它:`GiPageLayout` 的内容区是 `overflow: hidden`(页面本身不滚),
|
||||||
|
* 而一页 10~20 条比屏幕高 —— 不加这层高度链路,**分页器会被裁到可视区之外**:
|
||||||
|
* DOM 里明明有(`共 1245 条 … 20 条/页`),人却看不见,看着就像"分页器没做/坏了"。
|
||||||
|
* 操作日志页(20 条/页)与收集任务页(加了状态 tab 之后)都踩过这一脚。
|
||||||
|
*
|
||||||
|
* 用法:`<a-card class="g-list-card">`,表格必须给 `:scroll="{ x: 宽度, y: '100%' }"`——
|
||||||
|
* `y` 是字符串时 Arco 会把表头与表体**拆开**(内部 `splitTable`),表体自己滚、表头钉住;
|
||||||
|
* 不写 y 的话 `.arco-table-content` 的 `overflow-y` 是 hidden(那是横向滚动条专用的),
|
||||||
|
* 多出来的行会被直接裁掉且无法滚动。
|
||||||
|
*/
|
||||||
|
.g-list-card {
|
||||||
|
display: flex;
|
||||||
|
flex: 1;
|
||||||
|
flex-direction: column;
|
||||||
|
min-height: 0;
|
||||||
|
|
||||||
|
.arco-card-body {
|
||||||
|
display: flex;
|
||||||
|
flex: 1;
|
||||||
|
flex-direction: column;
|
||||||
|
min-height: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ⚠️ `.arco-spin` 必须在链路里:Arco 用 Spin 包住「表体 + 分页器」
|
||||||
|
.arco-table,
|
||||||
|
.arco-spin {
|
||||||
|
display: flex;
|
||||||
|
flex: 1;
|
||||||
|
flex-direction: column;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.arco-table-container {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 分页器不参与伸缩,永远留在卡片底部
|
||||||
|
.arco-table-pagination {
|
||||||
|
flex: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 以下这些类名要放到下面,涉及到css权重优先级问题
|
// 以下这些类名要放到下面,涉及到css权重优先级问题
|
||||||
.g-mt {
|
.g-mt {
|
||||||
margin-top: var(--margin);
|
margin-top: var(--margin);
|
||||||
|
|||||||
@@ -8,43 +8,17 @@
|
|||||||
</a-space>
|
</a-space>
|
||||||
</template>
|
</template>
|
||||||
<template #extra>
|
<template #extra>
|
||||||
<a-tag :color="menu?.entryAt ? 'green' : 'orange'">
|
<a-tag color="orange">{{ milestone }}</a-tag>
|
||||||
{{ menu?.entryAt ? '功能已就绪' : milestone }}
|
|
||||||
</a-tag>
|
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<!--
|
<a-alert type="info" :show-icon="false">
|
||||||
⚠️ 两种说法必须分开,不能都说「还没做」。
|
|
||||||
|
|
||||||
名单管理 / 材料规则 / 表单设计 这三个菜单项对应的功能**早就做好了**,
|
|
||||||
只是它们**按任务**(入口在「收集任务」每一行的链接里)。
|
|
||||||
以前这里一律说"这个页面还没做" + 说"已实现的页面只有工作台、组织架构、租户管理"——
|
|
||||||
而那句话早就过期了,且**没有任何机制会让它不过期**。
|
|
||||||
|
|
||||||
说"还没做"而功能其实在隔壁,比找不到更糟:**它让人得出错误结论。**
|
|
||||||
-->
|
|
||||||
<a-alert v-if="menu?.entryAt" type="success" :show-icon="false">
|
|
||||||
<template #title>
|
|
||||||
<strong>这个功能已经有了,只是入口不在这里。</strong>
|
|
||||||
{{ menu?.note }}
|
|
||||||
</template>
|
|
||||||
</a-alert>
|
|
||||||
<a-alert v-else type="info" :show-icon="false">
|
|
||||||
<template #title>
|
<template #title>
|
||||||
<strong>这个页面还没做。</strong>
|
<strong>这个页面还没做。</strong>
|
||||||
{{ menu?.note ?? '该功能尚未实现。' }}
|
{{ menu?.note ?? '该功能尚未实现。' }}
|
||||||
</template>
|
</template>
|
||||||
</a-alert>
|
</a-alert>
|
||||||
|
|
||||||
<div v-if="menu?.entryAt" class="coming-soon__go">
|
<p class="coming-soon__why">
|
||||||
<a-button type="primary" @click="goEntry">
|
|
||||||
<template #icon><IconArrowRight /></template>
|
|
||||||
去「{{ entryTitle }}」
|
|
||||||
</a-button>
|
|
||||||
<div class="coming-soon__hint">{{ menu?.entryAt?.hint }}</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<p v-else class="coming-soon__why">
|
|
||||||
左侧菜单是按 <strong>服务端返回的角色权限</strong> 渲染的,所以你能看到它,
|
左侧菜单是按 <strong>服务端返回的角色权限</strong> 渲染的,所以你能看到它,
|
||||||
说明你的角色确实有这个权限 —— 只是界面还没排到。
|
说明你的角色确实有这个权限 —— 只是界面还没排到。
|
||||||
</p>
|
</p>
|
||||||
@@ -53,7 +27,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { IconArrowRight, IconClockCircle } from '@arco-design/web-vue/es/icon'
|
import { IconClockCircle } from '@arco-design/web-vue/es/icon'
|
||||||
import { findMenuByPath } from '@/config/xt-routes'
|
import { findMenuByPath } from '@/config/xt-routes'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -64,40 +38,27 @@ import { findMenuByPath } from '@/config/xt-routes'
|
|||||||
* 2. 隐藏会让人以为"没这个功能",而显示"排队中 + 属于哪个里程碑"是更准确的信息;
|
* 2. 隐藏会让人以为"没这个功能",而显示"排队中 + 属于哪个里程碑"是更准确的信息;
|
||||||
* 3. 一个页面服务所有占位路由,成本极低。
|
* 3. 一个页面服务所有占位路由,成本极低。
|
||||||
*
|
*
|
||||||
* ⚠️ 但这个页面**过去在说谎**:它对"功能已经有了、只是入口在别处"的菜单项
|
* ⚠️ 这一页**只服务"确实还没做"的菜单**(现在只剩「角色与授权」)。
|
||||||
* 也说"还没做",还列了一句早已过期的"已实现的页面"清单。
|
* 曾经它还要区分另一种情况 —— "功能已经有了、只是入口在别处"(名单管理 / 表单设计 /
|
||||||
* 现在按 `entryAt` 分两种说法,并**删掉了那句清单** ——
|
* 材料规则,都在「收集任务」行内的链接里)。那三条菜单已按客户要求**直接去掉**:
|
||||||
* 任何"现状清单"写死在代码里都会静默过期,而没人会记得回来改它。
|
* 导航里挂着一条点进去只有指路的条目,既占位置又让人以为这里应该有事可做。
|
||||||
*/
|
* 所以 `entryAt` 那套分支("功能已就绪"标签 + 跳转按钮)也一并删了 ——
|
||||||
defineOptions({ name: 'ComingSoon' })
|
* 留着就是一段永远不会被走到的代码。
|
||||||
|
*
|
||||||
const route = useRoute()
|
|
||||||
const router = useRouter()
|
|
||||||
|
|
||||||
/**
|
|
||||||
* ⚠️ 按**路径**找,不要按 `route.meta.menu`。
|
* ⚠️ 按**路径**找,不要按 `route.meta.menu`。
|
||||||
*
|
*
|
||||||
* 原来写的是 `findMenu(route.meta.menu ?? '')`,而 `formatAsyncRoutes` 构造的 meta 里
|
* 原来写的是 `findMenu(route.meta.menu ?? '')`,而 `formatAsyncRoutes` 构造的 meta 里
|
||||||
* **没有 `menu` 这个键**(只有 hidden/keepAlive/title/icon/... )——
|
* **没有 `menu` 这个键**(只有 hidden/keepAlive/title/icon/... )——
|
||||||
* 于是这里永远拿到 `undefined`,页面永远显示兜底文案
|
* 于是这里永远拿到 `undefined`,页面永远显示兜底文案
|
||||||
* 「功能 / 规划中 / 该功能尚未实现」,**每个菜单项自己的 note 从来没显示出来过**。
|
* 「功能 / 规划中 / 该功能尚未实现」,**每个菜单项自己的 note 从来没显示出来过**。
|
||||||
* 而 note 恰恰是这一页最有用的东西(它写着"真实入口在哪")。
|
* 而 note 恰恰是这一页最有用的东西。
|
||||||
*/
|
*/
|
||||||
|
defineOptions({ name: 'ComingSoon' })
|
||||||
|
|
||||||
|
const route = useRoute()
|
||||||
|
|
||||||
const menu = computed(() => findMenuByPath(route.path))
|
const menu = computed(() => findMenuByPath(route.path))
|
||||||
|
|
||||||
/** 跳过去之后那个页面的标题(从路由表里取,免得两处各写一遍)。 */
|
|
||||||
const entryTitle = computed(() => {
|
|
||||||
const path = menu.value?.entryAt?.path
|
|
||||||
return path ? (findMenuByPath(path)?.title ?? '目标页面') : '目标页面'
|
|
||||||
})
|
|
||||||
|
|
||||||
function goEntry() {
|
|
||||||
const path = menu.value?.entryAt?.path
|
|
||||||
if (path) {
|
|
||||||
router.push(path)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 各菜单对应的里程碑(与《05-开发任务拆解》对齐) */
|
/** 各菜单对应的里程碑(与《05-开发任务拆解》对齐) */
|
||||||
const MILESTONES: Record<string, string> = {
|
const MILESTONES: Record<string, string> = {
|
||||||
role: '二期'
|
role: '二期'
|
||||||
@@ -117,14 +78,4 @@ const milestone = computed(() => MILESTONES[menu.value?.key ?? ''] ?? '规划中
|
|||||||
color: var(--color-text-1);
|
color: var(--color-text-1);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.coming-soon__go {
|
|
||||||
margin-top: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.coming-soon__hint {
|
|
||||||
margin-top: 8px;
|
|
||||||
font-size: 13px;
|
|
||||||
color: var(--color-text-3);
|
|
||||||
}
|
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -57,20 +57,33 @@
|
|||||||
<template #title>数据看板</template>
|
<template #title>数据看板</template>
|
||||||
<template #extra>
|
<template #extra>
|
||||||
<a-space>
|
<a-space>
|
||||||
|
<!--
|
||||||
|
平台运营必须先选学校:任务、统计、审核数都是**租户内**的数据,
|
||||||
|
而服务端对平台的租户过滤是关掉的 —— 不选就会把各学校的任务混在一起。
|
||||||
|
-->
|
||||||
|
<GiTenantPicker
|
||||||
|
v-if="isPlatform"
|
||||||
|
v-model="actingStore.tenantId"
|
||||||
|
width="200px"
|
||||||
|
placeholder="选择学校(平台运营)"
|
||||||
|
/>
|
||||||
<a-select
|
<a-select
|
||||||
v-model="boardTaskId"
|
v-model="boardTaskId"
|
||||||
placeholder="选择一个任务"
|
placeholder="选择一个任务"
|
||||||
allow-search
|
allow-search
|
||||||
style="width: 260px"
|
style="width: 260px"
|
||||||
:loading="taskLoading"
|
:loading="taskLoading"
|
||||||
|
:disabled="needPickTenant"
|
||||||
:options="taskOptions"
|
:options="taskOptions"
|
||||||
@change="loadBoard"
|
@change="loadBoard"
|
||||||
/>
|
/>
|
||||||
<a-button :loading="boardLoading" @click="loadBoard">刷新</a-button>
|
<a-button :loading="boardLoading" :disabled="needPickTenant" @click="loadBoard">刷新</a-button>
|
||||||
</a-space>
|
</a-space>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<a-empty v-if="!boardTaskId" description="选一个任务,看它的填报进度与审核情况" />
|
<a-empty v-if="needPickTenant" description="平台运营看的是某一所学校的进度:请先选择学校" />
|
||||||
|
|
||||||
|
<a-empty v-else-if="!boardTaskId" description="选一个任务,看它的填报进度与审核情况" />
|
||||||
|
|
||||||
<a-spin v-else :loading="boardLoading" style="display: block">
|
<a-spin v-else :loading="boardLoading" style="display: block">
|
||||||
<!-- 进度四格:应填 / 已提交 / 已通过 / 待审 -->
|
<!-- 进度四格:应填 / 已提交 / 已通过 / 待审 -->
|
||||||
@@ -116,6 +129,7 @@
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<a-table
|
<a-table
|
||||||
|
v-if="canViewAllProgress"
|
||||||
:data="auditRows"
|
:data="auditRows"
|
||||||
:pagination="false"
|
:pagination="false"
|
||||||
size="small"
|
size="small"
|
||||||
@@ -127,6 +141,11 @@
|
|||||||
<a-table-column title="说明" data-index="foot" ellipsis tooltip />
|
<a-table-column title="说明" data-index="foot" ellipsis tooltip />
|
||||||
</template>
|
</template>
|
||||||
</a-table>
|
</a-table>
|
||||||
|
<!--
|
||||||
|
班主任(只有 progress:viewOwn)在这里看到的是**空表**,而空表不解释任何事。
|
||||||
|
如实说明"这里为什么不给你看",比留一张空表更清楚。
|
||||||
|
-->
|
||||||
|
<a-empty v-else description="审核统计按全租户口径,需要「全租户进度」权限" />
|
||||||
</a-col>
|
</a-col>
|
||||||
</a-row>
|
</a-row>
|
||||||
|
|
||||||
@@ -151,7 +170,7 @@ import * as orgApi from '@/apis/xt/org'
|
|||||||
import * as taskApi from '@/apis/xt/task'
|
import * as taskApi from '@/apis/xt/task'
|
||||||
import * as tenantApi from '@/apis/xt/tenant'
|
import * as tenantApi from '@/apis/xt/tenant'
|
||||||
import { ROLE_NAMES, XT_MENUS } from '@/config/xt-routes'
|
import { ROLE_NAMES, XT_MENUS } from '@/config/xt-routes'
|
||||||
import { useUserStore } from '@/stores'
|
import { useActingTenantStore, useUserStore } from '@/stores'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 工作台。
|
* 工作台。
|
||||||
@@ -177,6 +196,47 @@ const roleLabel = computed(() =>
|
|||||||
// 只会让这个判断恒为 false(之前就是这样:平台管理员也看到"仅平台运营可见")
|
// 只会让这个判断恒为 false(之前就是这样:平台管理员也看到"仅平台运营可见")
|
||||||
const isPlatform = computed(() => userStore.menuKeys?.includes('tenant') ?? false)
|
const isPlatform = computed(() => userStore.menuKeys?.includes('tenant') ?? false)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ⚠️ 两个"能不能调"的判断,缺一个就会在页面上弹 403。
|
||||||
|
*
|
||||||
|
* 工作台是所有角色共用的落地页,但它里面有两个接口是**按角色开放的**:
|
||||||
|
* · `GET /orgs/tree` 要 `org:view` —— 班主任/副班主任/审核员都没有;
|
||||||
|
* · `GET /audit/statistics` 要 `progress:viewAll` —— 班主任只有 `progress:viewOwn`。
|
||||||
|
*
|
||||||
|
* 以前这两个是无条件调的,于是**班主任一进工作台就弹「拒绝访问(403)」**(实测)。
|
||||||
|
* 而 403 来自 HTTP 状态码,`try/catch` 拦不住它的 toast —— 前端唯一正确的做法是
|
||||||
|
* **不去调它**,并把那一格如实显示成"—"(本页的取舍就是"只显示真实能查到的数字")。
|
||||||
|
*/
|
||||||
|
const canViewOrg = computed(() => userStore.permissions.includes('org:view'))
|
||||||
|
/**
|
||||||
|
* ⚠️ 权限码是 **`progress:viewAll`**(驼峰),不是 `progress:view:all` ——
|
||||||
|
* 权威来源是后端 `security/PermissionDef`(`PROGRESS_VIEW_ALL("progress:viewAll", ...)`)。
|
||||||
|
* 我第一版就写成了带冒号的,结果这个判断**恒为 false**:「审核情况」对**所有人**都不显示,
|
||||||
|
* 而 typecheck / lint / 后端全都不报错(字符串对不上是纯运行期的事)。
|
||||||
|
* 现在 `scripts/check-wiring.py` 会做这层对齐检查。
|
||||||
|
*/
|
||||||
|
const canViewAllProgress = computed(() => userStore.permissions.includes('progress:viewAll'))
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 平台运营的「当前学校」(请求头 `X-Tenant-Id`,见 `@/apis/xt/acting-tenant`)。
|
||||||
|
*
|
||||||
|
* ⚠️ 工作台也必须有这个选择:这里的**任务、统计、审核数**全是租户内的数据,
|
||||||
|
* 而服务端对平台的租户过滤是关掉的 —— 不给选择就会把各学校的任务混在一个下拉里,
|
||||||
|
* 选中之后看到的进度是"另一所学校的",而界面上没有任何地方会提示这件事。
|
||||||
|
*
|
||||||
|
* 选择与「组织架构 / 账号管理」共用同一个 store(见 `useActingTenantStore`)。
|
||||||
|
*/
|
||||||
|
const actingStore = useActingTenantStore()
|
||||||
|
const needPickTenant = computed(() => isPlatform.value && !actingStore.tenantId)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 「组织节点」这一格能不能显示真实数字。
|
||||||
|
*
|
||||||
|
* 平台运营要先选定学校(选定后就是那所学校的节点数);没选时显示"—",
|
||||||
|
* 而不是让 `/orgs/tree` 返回一个"0 个"(那会让人以为全平台没有组织)。
|
||||||
|
*/
|
||||||
|
const orgCardShowable = computed(() => canViewOrg.value && !needPickTenant.value)
|
||||||
|
|
||||||
/** 已实现且当前角色可访问的页面 */
|
/** 已实现且当前角色可访问的页面 */
|
||||||
const shortcuts = computed(() =>
|
const shortcuts = computed(() =>
|
||||||
XT_MENUS.filter((m) => m.ready && userStore.menuKeys.includes(m.key))
|
XT_MENUS.filter((m) => m.ready && userStore.menuKeys.includes(m.key))
|
||||||
@@ -186,9 +246,11 @@ const stats = computed(() => [
|
|||||||
{
|
{
|
||||||
label: '组织节点',
|
label: '组织节点',
|
||||||
icon: IconFolder,
|
icon: IconFolder,
|
||||||
value: loading.value ? '—' : (orgCount.value ?? '—'),
|
value: orgCardShowable.value && !loading.value ? (orgCount.value ?? '—') : '—',
|
||||||
unit: '个',
|
unit: orgCardShowable.value ? '个' : '',
|
||||||
foot: '学校 / 院系 / 班级',
|
foot: isPlatform.value
|
||||||
|
? (needPickTenant.value ? '选定学校后展示该校的组织' : '所选学校的节点数')
|
||||||
|
: (canViewOrg.value ? '学校 / 院系 / 班级' : '需要「组织查看」权限'),
|
||||||
tone: ''
|
tone: ''
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -250,10 +312,10 @@ const boardCards = computed(() => {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: '待审',
|
label: '待审',
|
||||||
value: audit.value?.pendingCount ?? '—',
|
value: canViewAllProgress.value ? (audit.value?.pendingCount ?? '—') : '—',
|
||||||
foot: audit.value?.timeoutCount
|
foot: canViewAllProgress.value
|
||||||
? `其中超时 ${audit.value.timeoutCount} 件`
|
? (audit.value?.timeoutCount ? `其中超时 ${audit.value.timeoutCount} 件` : '没有超时的')
|
||||||
: '没有超时的',
|
: '需要「全租户进度」权限',
|
||||||
tone: audit.value?.timeoutCount ? 'is-warn' : ''
|
tone: audit.value?.timeoutCount ? 'is-warn' : ''
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
@@ -294,10 +356,33 @@ function fmtHours(h: number | null | undefined): string {
|
|||||||
return h >= 10 ? `${Math.round(h)} 小时` : `${h} 小时`
|
return h >= 10 ? `${Math.round(h)} 小时` : `${h} 小时`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 组织节点数(平台运营按所选学校;其它角色就是自己租户的) */
|
||||||
|
async function loadOrgCount() {
|
||||||
|
// 组织树要 `org:view`(班主任/副班主任/审核员都没有);平台没选学校时也不该问
|
||||||
|
if (!orgCardShowable.value) {
|
||||||
|
orgCount.value = null
|
||||||
|
return
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const res = await orgApi.getOrgTree({ tenantId: actingStore.tenantId })
|
||||||
|
orgCount.value = countNodes(res.data ?? [])
|
||||||
|
} catch {
|
||||||
|
orgCount.value = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function loadTasks() {
|
async function loadTasks() {
|
||||||
|
// 平台没选学校时不发请求:拿到的是空列表,界面上会显示成"这所学校没有任务"
|
||||||
|
if (needPickTenant.value) {
|
||||||
|
taskOptions.value = []
|
||||||
|
boardTaskId.value = undefined
|
||||||
|
stat.value = null
|
||||||
|
audit.value = null
|
||||||
|
return
|
||||||
|
}
|
||||||
taskLoading.value = true
|
taskLoading.value = true
|
||||||
try {
|
try {
|
||||||
const res = await taskApi.getTaskList({ pageNum: 1, pageSize: 100 })
|
const res = await taskApi.getTaskList({ pageNum: 1, pageSize: 100 }, actingStore.tenantId)
|
||||||
const list = res.data?.list ?? []
|
const list = res.data?.list ?? []
|
||||||
taskOptions.value = list.map((x) => ({
|
taskOptions.value = list.map((x) => ({
|
||||||
label: `${x.taskName}(${taskApi.TASK_STATUS_TEXT[x.status] ?? ''})`,
|
label: `${x.taskName}(${taskApi.TASK_STATUS_TEXT[x.status] ?? ''})`,
|
||||||
@@ -308,6 +393,10 @@ async function loadTasks() {
|
|||||||
if (collecting) {
|
if (collecting) {
|
||||||
boardTaskId.value = collecting.id
|
boardTaskId.value = collecting.id
|
||||||
loadBoard()
|
loadBoard()
|
||||||
|
} else {
|
||||||
|
boardTaskId.value = undefined
|
||||||
|
stat.value = null
|
||||||
|
audit.value = null
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
taskLoading.value = false
|
taskLoading.value = false
|
||||||
@@ -318,13 +407,17 @@ async function loadBoard() {
|
|||||||
if (!boardTaskId.value) return
|
if (!boardTaskId.value) return
|
||||||
boardLoading.value = true
|
boardLoading.value = true
|
||||||
try {
|
try {
|
||||||
// 两个接口互不依赖,并行取;任一个失败不影响另一个显示
|
// ⚠️ 审核统计要 `progress:viewAll`,班主任没有 —— 不能"调了再 catch":
|
||||||
|
// 403 是 HTTP 状态码,`http.ts` 的传输层分支会**先弹一个「拒绝访问(403)」**,
|
||||||
|
// catch 只能拦住异常,拦不住那个提示。所以按权限**决定发不发这个请求**。
|
||||||
const [s, a] = await Promise.allSettled([
|
const [s, a] = await Promise.allSettled([
|
||||||
taskApi.getTaskStatistics(boardTaskId.value),
|
taskApi.getTaskStatistics(boardTaskId.value, actingStore.tenantId),
|
||||||
auditApi.getAuditStatistics({ taskId: boardTaskId.value })
|
canViewAllProgress.value
|
||||||
|
? auditApi.getAuditStatistics({ taskId: boardTaskId.value }, actingStore.tenantId)
|
||||||
|
: Promise.resolve(null)
|
||||||
])
|
])
|
||||||
stat.value = s.status === 'fulfilled' ? s.value.data : null
|
stat.value = s.status === 'fulfilled' ? s.value.data : null
|
||||||
audit.value = a.status === 'fulfilled' ? a.value.data : null
|
audit.value = a.status === 'fulfilled' ? (a.value?.data ?? null) : null
|
||||||
} finally {
|
} finally {
|
||||||
boardLoading.value = false
|
boardLoading.value = false
|
||||||
}
|
}
|
||||||
@@ -334,13 +427,15 @@ function countNodes(nodes: OrgTreeVO[]): number {
|
|||||||
return nodes.reduce((sum, n) => sum + 1 + countNodes(n.children ?? []), 0)
|
return nodes.reduce((sum, n) => sum + 1 + countNodes(n.children ?? []), 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 换学校 = 换一整套数字(组织节点、任务下拉、看板)。不清空的话,
|
||||||
|
// 上一所学校的任务还留在下拉里,选中它看到的是**另一所学校**的进度
|
||||||
|
watch(() => actingStore.tenantId, () => {
|
||||||
|
loadOrgCount()
|
||||||
|
loadTasks()
|
||||||
|
})
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
try {
|
await loadOrgCount()
|
||||||
const res = await orgApi.getOrgTree()
|
|
||||||
orgCount.value = countNodes(res.data ?? [])
|
|
||||||
} catch {
|
|
||||||
orgCount.value = null
|
|
||||||
}
|
|
||||||
if (isPlatform.value) {
|
if (isPlatform.value) {
|
||||||
try {
|
try {
|
||||||
const res = await tenantApi.getTenantList({ pageNum: 1, pageSize: 1 })
|
const res = await tenantApi.getTenantList({ pageNum: 1, pageSize: 1 })
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<template>
|
<template>
|
||||||
<GiPageLayout>
|
<GiPageLayout>
|
||||||
<a-card :bordered="false">
|
<a-card :bordered="false" class="g-list-card">
|
||||||
<template #title>操作日志</template>
|
<template #title>操作日志</template>
|
||||||
<template #extra>
|
<template #extra>
|
||||||
<span class="log__hint">
|
<span class="log__hint">
|
||||||
@@ -8,14 +8,6 @@
|
|||||||
</span>
|
</span>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<a-alert type="normal" class="log__tip">
|
|
||||||
<template #title>
|
|
||||||
证件号在这里**只有脱敏值与指纹**(日志泄露不该比业务库泄露更严重)。
|
|
||||||
IP 取代理链第一跳、**可以被伪造** —— 用于排查,不作合规证据;
|
|
||||||
真正的证据是「哪个账号在什么时候做了什么」。
|
|
||||||
</template>
|
|
||||||
</a-alert>
|
|
||||||
|
|
||||||
<div class="g-row-tool">
|
<div class="g-row-tool">
|
||||||
<a-space wrap>
|
<a-space wrap>
|
||||||
<a-input-number v-model="query.targetId" placeholder="对象 ID" size="small"
|
<a-input-number v-model="query.targetId" placeholder="对象 ID" size="small"
|
||||||
@@ -58,9 +50,27 @@
|
|||||||
</a-space>
|
</a-space>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!--
|
||||||
|
`y: '100%'` 不是装饰:它让 Arco 把表头与表体**拆开**
|
||||||
|
(内部 `splitTable`),表体自己滚、表头钉住、分页器留在卡片底部。
|
||||||
|
没有它的话,`.arco-table-content` 的 `overflow-y` 是 hidden(横向滚动条专用),
|
||||||
|
多出来的行会被**直接裁掉**,而且没有任何滚动条能救回来。
|
||||||
|
-->
|
||||||
<a-table :data="list" :loading="loading" row-key="id" :pagination="pagination"
|
<a-table :data="list" :loading="loading" row-key="id" :pagination="pagination"
|
||||||
:scroll="{ x: 1200 }" @page-change="onPageChange">
|
:scroll="{ x: isPlatform ? 1340 : 1200, y: '100%' }" @page-change="onPageChange"
|
||||||
|
@page-size-change="onPageSizeChange">
|
||||||
<template #columns>
|
<template #columns>
|
||||||
|
<!--
|
||||||
|
「租户」列**只给平台运营看**:它看的是全量日志(跨租户),
|
||||||
|
不显示单位就分不清"这条是哪个学校做的";学校管理员永远是本租户,列没有意义。
|
||||||
|
-->
|
||||||
|
<a-table-column v-if="isPlatform" title="租户" :width="140">
|
||||||
|
<template #cell="{ record }">
|
||||||
|
<span class="log__muted">
|
||||||
|
{{ tenantNames[record.tenantId] ?? (record.tenantId != null ? `#${record.tenantId}` : '—') }}
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
</a-table-column>
|
||||||
<a-table-column title="时间" :width="150">
|
<a-table-column title="时间" :width="150">
|
||||||
<template #cell="{ record }">
|
<template #cell="{ record }">
|
||||||
<span class="log__muted">{{ fmt(record.createTime) }}</span>
|
<span class="log__muted">{{ fmt(record.createTime) }}</span>
|
||||||
@@ -68,8 +78,12 @@
|
|||||||
</a-table-column>
|
</a-table-column>
|
||||||
<a-table-column title="操作人" :width="120">
|
<a-table-column title="操作人" :width="120">
|
||||||
<template #cell="{ record }">
|
<template #cell="{ record }">
|
||||||
<div>{{ record.operatorName || '—' }}</div>
|
<!--
|
||||||
<div class="log__muted">ID {{ record.operatorId ?? '—' }}</div>
|
⚠️ 只显示**账号/姓名**,不显示用户 ID(2026-10-09 客户要求)。
|
||||||
|
数字 ID 对看日志的人没有意义 —— 他要知道的是"哪个账号做的",
|
||||||
|
ID 只在排查时要,而那种时候查库比看这一列快。
|
||||||
|
-->
|
||||||
|
<span>{{ record.operatorName || '—' }}</span>
|
||||||
</template>
|
</template>
|
||||||
</a-table-column>
|
</a-table-column>
|
||||||
<a-table-column title="动作" :width="150">
|
<a-table-column title="动作" :width="150">
|
||||||
@@ -119,6 +133,8 @@ import {
|
|||||||
OPERATION_ACTIONS,
|
OPERATION_ACTIONS,
|
||||||
SENSITIVE_ACTIONS
|
SENSITIVE_ACTIONS
|
||||||
} from '@/apis/xt/log'
|
} from '@/apis/xt/log'
|
||||||
|
import { getTenantList } from '@/apis/xt/tenant'
|
||||||
|
import { useUserStore } from '@/stores'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 操作日志(《03-接口设计》§13.4)。
|
* 操作日志(《03-接口设计》§13.4)。
|
||||||
@@ -134,9 +150,22 @@ import {
|
|||||||
*
|
*
|
||||||
* 看这一屏的人 90% 就是为了看这两列。塞进"详情"弹窗里等于让每次查看多一次点击,
|
* 看这一屏的人 90% 就是为了看这两列。塞进"详情"弹窗里等于让每次查看多一次点击,
|
||||||
* 而合规核查往往是几十条连着看的。
|
* 而合规核查往往是几十条连着看的。
|
||||||
|
*
|
||||||
|
* ## 范围:平台运营看**全量**,学校管理员看**本租户**
|
||||||
|
*
|
||||||
|
* 由服务端按 `log:viewAll` / `log:viewTenant` 决定(`LogController.logScopeTenantId`)。
|
||||||
|
* ⚠️ 平台运营看到的每条日志属于哪个单位,靠这一列「租户」——它**只对平台显示**。
|
||||||
|
* 以前平台的 tenantId 是 0 而日志挂在各租户下,于是这一屏对平台**永远是空的**,
|
||||||
|
* 且不报任何错(PRD 要求的却是「全量操作日志 ✅」)。
|
||||||
*/
|
*/
|
||||||
defineOptions({ name: 'OperationLogs' })
|
defineOptions({ name: 'OperationLogs' })
|
||||||
|
|
||||||
|
const userStore = useUserStore()
|
||||||
|
|
||||||
|
/** 平台运营:看全量日志,因此要显示「租户」列并把 id 翻成单位名 */
|
||||||
|
const isPlatform = computed(() => userStore.roles.includes('PLATFORM_ADMIN'))
|
||||||
|
const tenantNames = ref<Record<number, string>>({})
|
||||||
|
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const list = ref<OperationLog[]>([])
|
const list = ref<OperationLog[]>([])
|
||||||
const total = ref(0)
|
const total = ref(0)
|
||||||
@@ -220,20 +249,44 @@ function onPageChange(page: number) {
|
|||||||
load()
|
load()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 改每页条数。
|
||||||
|
*
|
||||||
|
* ⚠️ **必须处理这个事件**,不能只处理 `page-change`:
|
||||||
|
* Arco 的表格在 `pagination` 非 false 时会**自己再切一次数据**
|
||||||
|
* (`flattenData`:`data.length > pageSize` 就 slice)。
|
||||||
|
* 只在本地改每页条数、不重新取数的话,会出现
|
||||||
|
* 「分页器显示 50 条/页、表里还是上一次那 20 条」这种自相矛盾的画面 ——
|
||||||
|
* 看起来就是"分页器坏了"。所以这里一律回到第 1 页重新取。
|
||||||
|
*/
|
||||||
|
function onPageSizeChange(size: number) {
|
||||||
|
query.pageSize = size
|
||||||
|
query.pageNum = 1
|
||||||
|
load()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 平台运营才需要"租户 id → 单位名"。取不到也不影响看日志:列里退回显示 #id
|
||||||
|
onMounted(async () => {
|
||||||
|
if (!isPlatform.value) return
|
||||||
|
try {
|
||||||
|
const res = await getTenantList({ pageNum: 1, pageSize: 200 })
|
||||||
|
tenantNames.value = Object.fromEntries((res.data?.list ?? []).map((t) => [t.id, t.tenantName]))
|
||||||
|
} catch {
|
||||||
|
tenantNames.value = {}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
load()
|
load()
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
<style lang="scss" scoped>
|
||||||
|
/* 卡片/表格的高度链路(分页器为什么必须钉在底部)见 `styles/global.scss` 的 `.g-list-card` */
|
||||||
.log {
|
.log {
|
||||||
&__hint {
|
&__hint {
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
color: var(--color-text-3);
|
color: var(--color-text-3);
|
||||||
}
|
}
|
||||||
|
|
||||||
&__tip {
|
|
||||||
margin-bottom: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
&__num {
|
&__num {
|
||||||
width: 110px;
|
width: 110px;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -52,9 +52,16 @@ import { ORG_TYPE_NAMES } from '@/apis/xt/org'
|
|||||||
* 1. 组织编码租户内唯一
|
* 1. 组织编码租户内唯一
|
||||||
* 2. 组织类型必须比上级更低一级
|
* 2. 组织类型必须比上级更低一级
|
||||||
* 3. 修改时不提供改上级(避免误操作把整棵子树搬走)
|
* 3. 修改时不提供改上级(避免误操作把整棵子树搬走)
|
||||||
|
*
|
||||||
|
* `tenantId` 由父页面传入,**只有平台运营的页面会给值**:它决定这个组织建到哪所学校下
|
||||||
|
* (服务端按请求头 `X-Tenant-Id` 摆租户上下文)。学校管理员传 null,行为与以前一致。
|
||||||
*/
|
*/
|
||||||
defineOptions({ name: 'OrgFormModal' })
|
defineOptions({ name: 'OrgFormModal' })
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<{ tenantId?: number | null }>(), {
|
||||||
|
tenantId: null
|
||||||
|
})
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
(e: 'save-success'): void
|
(e: 'save-success'): void
|
||||||
}>()
|
}>()
|
||||||
@@ -132,7 +139,7 @@ async function confirm() {
|
|||||||
orgType: form.orgType,
|
orgType: form.orgType,
|
||||||
orgCode: form.orgCode.trim(),
|
orgCode: form.orgCode.trim(),
|
||||||
sortNo: form.sortNo ?? 0
|
sortNo: form.sortNo ?? 0
|
||||||
})
|
}, props.tenantId)
|
||||||
Message.success('已保存')
|
Message.success('已保存')
|
||||||
} else {
|
} else {
|
||||||
await orgApi.addOrg({
|
await orgApi.addOrg({
|
||||||
@@ -141,7 +148,7 @@ async function confirm() {
|
|||||||
orgType: form.orgType,
|
orgType: form.orgType,
|
||||||
orgCode: form.orgCode.trim(),
|
orgCode: form.orgCode.trim(),
|
||||||
sortNo: form.sortNo ?? 0
|
sortNo: form.sortNo ?? 0
|
||||||
})
|
}, props.tenantId)
|
||||||
Message.success('已新增')
|
Message.success('已新增')
|
||||||
}
|
}
|
||||||
visible.value = false
|
visible.value = false
|
||||||
|
|||||||
@@ -13,11 +13,34 @@
|
|||||||
<span class="org__hint">{{ totalNodes }} 个节点</span>
|
<span class="org__hint">{{ totalNodes }} 个节点</span>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<!--
|
||||||
|
平台运营必须先选学校:组织树本来就是租户内的东西,而平台的租户过滤是关掉的。
|
||||||
|
不选就请求,拿到的是"所有租户的组织混在一棵树里"—— 只有一所学校有数据时,
|
||||||
|
它和学校管理员看到的完全一样,看不出错。
|
||||||
|
-->
|
||||||
|
<GiTenantPicker
|
||||||
|
v-if="isPlatform"
|
||||||
|
v-model="actingStore.tenantId"
|
||||||
|
class="org__tenant"
|
||||||
|
placeholder="选择学校(平台运营)"
|
||||||
|
/>
|
||||||
|
|
||||||
<a-input-search v-model="keyword" placeholder="搜索名称或编码" allow-clear class="org__search" />
|
<a-input-search v-model="keyword" placeholder="搜索名称或编码" allow-clear class="org__search" />
|
||||||
|
|
||||||
<a-spin v-if="loading" class="org__loading" />
|
<!--
|
||||||
|
⚠️ Arco 的 `a-empty` 里一旦给了默认插槽,`description` 就**不再渲染**
|
||||||
|
(它的实现是 `<slot>{{ description }}</slot>`)—— 所以文案要写在插槽里,
|
||||||
|
否则页面上只剩半句话。
|
||||||
|
-->
|
||||||
|
<a-empty v-if="needPickTenant">
|
||||||
|
<div class="org__empty-title">请先选择学校</div>
|
||||||
|
<span class="org__hint">组织架构按学校隔离,选定学校后即可查看与维护</span>
|
||||||
|
</a-empty>
|
||||||
|
|
||||||
<a-empty v-else-if="!tree.length" description="还没有组织">
|
<a-spin v-else-if="loading" class="org__loading" />
|
||||||
|
|
||||||
|
<a-empty v-else-if="!tree.length">
|
||||||
|
<div class="org__empty-title">还没有组织</div>
|
||||||
<a-button v-if="canManage" type="primary" size="small" @click="onAdd(null)">
|
<a-button v-if="canManage" type="primary" size="small" @click="onAdd(null)">
|
||||||
新增顶级组织
|
新增顶级组织
|
||||||
</a-button>
|
</a-button>
|
||||||
@@ -35,7 +58,7 @@
|
|||||||
</a-tree>
|
</a-tree>
|
||||||
|
|
||||||
<template #footer>
|
<template #footer>
|
||||||
<a-button v-if="canManage" long size="small" @click="onAdd(null)">
|
<a-button v-if="canManage && !needPickTenant" long size="small" @click="onAdd(null)">
|
||||||
<template #icon><IconPlus /></template>
|
<template #icon><IconPlus /></template>
|
||||||
新增顶级组织
|
新增顶级组织
|
||||||
</a-button>
|
</a-button>
|
||||||
@@ -111,7 +134,7 @@
|
|||||||
</a-card>
|
</a-card>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<OrgFormModal ref="formModalRef" @save-success="load" />
|
<OrgFormModal ref="formModalRef" :tenant-id="actingStore.tenantId" @save-success="load" />
|
||||||
</GiPageLayout>
|
</GiPageLayout>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -121,7 +144,7 @@ import { Message } from '@arco-design/web-vue'
|
|||||||
import { IconDelete, IconEdit, IconFolder, IconPlus } from '@arco-design/web-vue/es/icon'
|
import { IconDelete, IconEdit, IconFolder, IconPlus } from '@arco-design/web-vue/es/icon'
|
||||||
import * as orgApi from '@/apis/xt/org'
|
import * as orgApi from '@/apis/xt/org'
|
||||||
import { ORG_TYPE_NAMES } from '@/apis/xt/org'
|
import { ORG_TYPE_NAMES } from '@/apis/xt/org'
|
||||||
import { useUserStore } from '@/stores'
|
import { useActingTenantStore, useUserStore } from '@/stores'
|
||||||
import OrgFormModal from './OrgFormModal.vue'
|
import OrgFormModal from './OrgFormModal.vue'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -135,6 +158,18 @@ defineOptions({ name: 'Org' })
|
|||||||
|
|
||||||
const userStore = useUserStore()
|
const userStore = useUserStore()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 平台运营的「当前学校」。
|
||||||
|
*
|
||||||
|
* 服务端对平台关掉了租户过滤,所以平台**必须先指明看哪所学校**才能拿到租户内的数据
|
||||||
|
* (见 `@/apis/xt/acting-tenant`)。选择存在 `useActingTenantStore` 里,
|
||||||
|
* 与「账号管理」共用 —— 切页面不用重选,也不会出现两个页面各看一所学校。
|
||||||
|
* 学校管理员的 `isPlatform` 为 false,这个值恒为 undefined —— 不传头,行为与以前一致。
|
||||||
|
*/
|
||||||
|
const actingStore = useActingTenantStore()
|
||||||
|
const isPlatform = computed(() => userStore.roles.includes('PLATFORM_ADMIN'))
|
||||||
|
const needPickTenant = computed(() => isPlatform.value && !actingStore.tenantId)
|
||||||
|
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const tree = ref<OrgTreeVO[]>([])
|
const tree = ref<OrgTreeVO[]>([])
|
||||||
const selected = ref<OrgTreeVO | null>(null)
|
const selected = ref<OrgTreeVO | null>(null)
|
||||||
@@ -174,9 +209,16 @@ function findById(nodes: OrgTreeVO[], id: number): OrgTreeVO | null {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function load() {
|
async function load() {
|
||||||
|
// 平台没选学校时不发请求(服务端会返回空树,徒增一次往返与一次困惑)
|
||||||
|
if (needPickTenant.value) {
|
||||||
|
tree.value = []
|
||||||
|
selected.value = null
|
||||||
|
selectedKeys.value = []
|
||||||
|
return
|
||||||
|
}
|
||||||
loading.value = true
|
loading.value = true
|
||||||
try {
|
try {
|
||||||
const res = await orgApi.getOrgTree({ withCount: true })
|
const res = await orgApi.getOrgTree({ withCount: true, tenantId: actingStore.tenantId })
|
||||||
tree.value = res.data ?? []
|
tree.value = res.data ?? []
|
||||||
// 刷新后保持选中
|
// 刷新后保持选中
|
||||||
if (selected.value) {
|
if (selected.value) {
|
||||||
@@ -187,6 +229,15 @@ async function load() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 换学校 = 换一整棵树:不清空的话,上一所学校的节点会留在页面上,
|
||||||
|
// 而"点进去看到的是别人学校的组织"这种事没有任何报错会提醒你
|
||||||
|
watch(() => actingStore.tenantId, () => {
|
||||||
|
selected.value = null
|
||||||
|
selectedKeys.value = []
|
||||||
|
keyword.value = ''
|
||||||
|
load()
|
||||||
|
})
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 树节点选中。
|
* 树节点选中。
|
||||||
*
|
*
|
||||||
@@ -214,7 +265,7 @@ function onEdit(row: OrgTreeVO) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function onDelete(row: OrgTreeVO) {
|
async function onDelete(row: OrgTreeVO) {
|
||||||
await orgApi.deleteOrg(row.id)
|
await orgApi.deleteOrg(row.id, actingStore.tenantId)
|
||||||
Message.success('已删除')
|
Message.success('已删除')
|
||||||
if (selected.value?.id === row.id) {
|
if (selected.value?.id === row.id) {
|
||||||
selected.value = null
|
selected.value = null
|
||||||
@@ -236,6 +287,10 @@ onMounted(load)
|
|||||||
margin-bottom: 10px;
|
margin-bottom: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&__tenant {
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
&__loading {
|
&__loading {
|
||||||
display: block;
|
display: block;
|
||||||
padding: 40px 0;
|
padding: 40px 0;
|
||||||
@@ -247,6 +302,11 @@ onMounted(load)
|
|||||||
color: var(--color-text-3);
|
color: var(--color-text-3);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&__empty-title {
|
||||||
|
margin-bottom: 4px;
|
||||||
|
color: var(--color-text-1);
|
||||||
|
}
|
||||||
|
|
||||||
&__node {
|
&__node {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
gap: 6px;
|
gap: 6px;
|
||||||
|
|||||||
@@ -1,11 +1,20 @@
|
|||||||
<template>
|
<template>
|
||||||
<GiPageLayout>
|
<GiPageLayout>
|
||||||
<a-card :bordered="false">
|
<a-card :bordered="false" class="g-list-card">
|
||||||
<template #title>收集任务</template>
|
<template #title>收集任务</template>
|
||||||
<template #extra>
|
<template #extra>
|
||||||
<span class="task__hint">共 {{ total }} 个任务</span>
|
<span class="task__hint">共 {{ total }} 个任务</span>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<!--
|
||||||
|
状态切换:**单独一行的 tab**(2026-10-09 客户要求),不再和
|
||||||
|
「新建任务 / 批次号 / 搜索」挤在同一行做成一个个按钮 ——
|
||||||
|
一排长得一样的按钮分不清"哪个是当前视图、哪个是动作"。
|
||||||
|
-->
|
||||||
|
<a-tabs class="task__tabs" :active-key="activeStatusKey" hide-content @change="onStatusChange">
|
||||||
|
<a-tab-pane v-for="t in STATUS_TABS" :key="t.key" :title="t.label" />
|
||||||
|
</a-tabs>
|
||||||
|
|
||||||
<div class="g-row-tool">
|
<div class="g-row-tool">
|
||||||
<a-space wrap>
|
<a-space wrap>
|
||||||
<a-button v-if="canManage" type="primary" size="small" @click="onAdd">
|
<a-button v-if="canManage" type="primary" size="small" @click="onAdd">
|
||||||
@@ -15,10 +24,6 @@
|
|||||||
</a-space>
|
</a-space>
|
||||||
|
|
||||||
<a-space wrap>
|
<a-space wrap>
|
||||||
<a-button v-for="c in statusChips" :key="String(c.value)"
|
|
||||||
:type="statusFilter === c.value ? 'primary' : 'outline'" size="small" @click="setStatus(c.value)">
|
|
||||||
{{ c.label }}
|
|
||||||
</a-button>
|
|
||||||
<a-input v-model="query.batchNo" placeholder="批次号" allow-clear class="task__batch"
|
<a-input v-model="query.batchNo" placeholder="批次号" allow-clear class="task__batch"
|
||||||
@press-enter="search" />
|
@press-enter="search" />
|
||||||
<a-input-search v-model="query.keyword" placeholder="搜索任务名 / 编码 / 专业" allow-clear
|
<a-input-search v-model="query.keyword" placeholder="搜索任务名 / 编码 / 专业" allow-clear
|
||||||
@@ -38,8 +43,13 @@
|
|||||||
名册是准入名单,没有它整条链路走不通,它必须在一眼能看到的位置)
|
名册是准入名单,没有它整条链路走不通,它必须在一眼能看到的位置)
|
||||||
· 合计仍是 840,与改动前一样,所以横向滚动的行为没有变差。
|
· 合计仍是 840,与改动前一样,所以横向滚动的行为没有变差。
|
||||||
-->
|
-->
|
||||||
|
<!--
|
||||||
|
`y: '100%'`:让 Arco 拆开表头与表体(表体自己滚、表头钉住),
|
||||||
|
配合卡片上的 `.g-list-card` 把分页器留在卡片底部。原因见 `styles/global.scss`。
|
||||||
|
-->
|
||||||
<a-table :data="list" :loading="loading" row-key="id" :pagination="pagination"
|
<a-table :data="list" :loading="loading" row-key="id" :pagination="pagination"
|
||||||
:scroll="{ x: 820 }" @page-change="onPageChange">
|
:scroll="{ x: 820, y: '100%' }" @page-change="onPageChange"
|
||||||
|
@page-size-change="onPageSizeChange">
|
||||||
<template #columns>
|
<template #columns>
|
||||||
<a-table-column title="任务" :width="200" ellipsis tooltip>
|
<a-table-column title="任务" :width="200" ellipsis tooltip>
|
||||||
<template #cell="{ record }">
|
<template #cell="{ record }">
|
||||||
@@ -181,6 +191,13 @@ import TaskRosterDrawer from './TaskRosterDrawer.vue'
|
|||||||
*/
|
*/
|
||||||
defineOptions({ name: 'Task' })
|
defineOptions({ name: 'Task' })
|
||||||
|
|
||||||
|
/** 状态 tab 的一项:tab 的 key 是字符串,发给接口的是 status */
|
||||||
|
interface StatusTab {
|
||||||
|
key: string
|
||||||
|
label: string
|
||||||
|
value: number | undefined
|
||||||
|
}
|
||||||
|
|
||||||
const userStore = useUserStore()
|
const userStore = useUserStore()
|
||||||
|
|
||||||
const canManage = computed(() => userStore.permissions.includes('task:manage'))
|
const canManage = computed(() => userStore.permissions.includes('task:manage'))
|
||||||
@@ -214,14 +231,26 @@ const BLOCKING_HINTS: Record<number, string> = {
|
|||||||
40020: '命名规则有问题,请点「编辑」检查文件命名模板'
|
40020: '命名规则有问题,请点「编辑」检查文件命名模板'
|
||||||
}
|
}
|
||||||
|
|
||||||
const statusChips = [
|
/**
|
||||||
{ label: '全部', value: undefined },
|
* 状态 tab。
|
||||||
{ label: '草稿', value: TASK_STATUS_DRAFT },
|
*
|
||||||
{ label: '收集中', value: TASK_STATUS_COLLECTING },
|
* `key` 必须是字符串(Arco 的 tab key 不支持 undefined),
|
||||||
{ label: '已暂停', value: TASK_STATUS_PAUSED },
|
* `value` 才是发给接口的 `status` —— 「全部」= 不带 status 参数,
|
||||||
{ label: '已截止', value: TASK_STATUS_CLOSED }
|
* 这个映射只在这一处定义,别在别处再写一份 `?? undefined`。
|
||||||
|
*/
|
||||||
|
const STATUS_TABS: StatusTab[] = [
|
||||||
|
{ key: 'all', label: '全部', value: undefined },
|
||||||
|
{ key: String(TASK_STATUS_DRAFT), label: '草稿', value: TASK_STATUS_DRAFT },
|
||||||
|
{ key: String(TASK_STATUS_COLLECTING), label: '收集中', value: TASK_STATUS_COLLECTING },
|
||||||
|
{ key: String(TASK_STATUS_PAUSED), label: '已暂停', value: TASK_STATUS_PAUSED },
|
||||||
|
{ key: String(TASK_STATUS_CLOSED), label: '已截止', value: TASK_STATUS_CLOSED }
|
||||||
]
|
]
|
||||||
|
|
||||||
|
/** 当前选中的 tab(由 statusFilter 反推,保持单一数据源) */
|
||||||
|
const activeStatusKey = computed(() =>
|
||||||
|
STATUS_TABS.find((t) => t.value === statusFilter.value)?.key ?? 'all'
|
||||||
|
)
|
||||||
|
|
||||||
const pagination = computed(() => ({
|
const pagination = computed(() => ({
|
||||||
current: query.pageNum,
|
current: query.pageNum,
|
||||||
pageSize: query.pageSize,
|
pageSize: query.pageSize,
|
||||||
@@ -261,6 +290,23 @@ function setStatus(v: number | undefined) {
|
|||||||
search()
|
search()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 状态 tab 切换 */
|
||||||
|
function onStatusChange(key: string | number) {
|
||||||
|
setStatus(STATUS_TABS.find((t) => t.key === String(key))?.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 改每页条数。
|
||||||
|
*
|
||||||
|
* ⚠️ 与「操作日志」同一处坑:Arco 表格在 `pagination` 非 false 时**自己也会切数据**,
|
||||||
|
* 不重新取数就会出现「分页器说 50 条/页、表里还是 10 条」的自相矛盾画面。
|
||||||
|
*/
|
||||||
|
function onPageSizeChange(size: number) {
|
||||||
|
query.pageSize = size
|
||||||
|
query.pageNum = 1
|
||||||
|
load()
|
||||||
|
}
|
||||||
|
|
||||||
function onPageChange(page: number) {
|
function onPageChange(page: number) {
|
||||||
query.pageNum = page
|
query.pageNum = page
|
||||||
load()
|
load()
|
||||||
@@ -407,6 +453,17 @@ onMounted(load)
|
|||||||
color: var(--color-text-3);
|
color: var(--color-text-3);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 状态 tab 自己占一行(下面的工具栏是另一行) */
|
||||||
|
&__tabs {
|
||||||
|
:deep(.arco-tabs-nav) {
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.arco-tabs-nav::before) {
|
||||||
|
background-color: var(--color-border-2);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
&__batch {
|
&__batch {
|
||||||
width: 120px;
|
width: 120px;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -150,9 +150,17 @@ import { useUserStore } from '@/stores'
|
|||||||
* 3. **用户类型不让用户选,而是从角色推导** —— 二者一对一绑定,
|
* 3. **用户类型不让用户选,而是从角色推导** —— 二者一对一绑定,
|
||||||
* 让用户分别选就会产生"学生 + 学校管理员"这种自相矛盾的账号,
|
* 让用户分别选就会产生"学生 + 学校管理员"这种自相矛盾的账号,
|
||||||
* 而服务端还会因此拒掉整个提交。改成推导后这类组合在界面上产生不了。
|
* 而服务端还会因此拒掉整个提交。改成推导后这类组合在界面上产生不了。
|
||||||
|
*
|
||||||
|
* `tenantId` 由父页面传入,**只有平台运营的页面会给值**:表示"这个账号建到哪所学校下"
|
||||||
|
* (服务端按请求头 `X-Tenant-Id` 摆租户上下文)。它同时决定角色的可选范围:
|
||||||
|
* 给某所学校建账号时只能发**学校那套角色**,发 PLATFORM_ADMIN 会被服务端拒(40301)。
|
||||||
*/
|
*/
|
||||||
defineOptions({ name: 'UserFormModal' })
|
defineOptions({ name: 'UserFormModal' })
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<{ tenantId?: number | null }>(), {
|
||||||
|
tenantId: null
|
||||||
|
})
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
(e: 'save-success'): void
|
(e: 'save-success'): void
|
||||||
}>()
|
}>()
|
||||||
@@ -187,8 +195,17 @@ const getInitForm = () => ({
|
|||||||
// 顺序要紧:下面几个 computed 读的是 form,所以 form 必须在前
|
// 顺序要紧:下面几个 computed 读的是 form,所以 form 必须在前
|
||||||
const form = reactive(getInitForm())
|
const form = reactive(getInitForm())
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 可选角色。
|
||||||
|
*
|
||||||
|
* ⚠️ 判据不只是"我是不是平台租户",还要看**有没有代管学校**:
|
||||||
|
* · 平台运营 + 没选学校 → 建的是平台自己的账号 → 只能发 PLATFORM_ADMIN;
|
||||||
|
* · 平台运营 + 选了学校 → 建的是**那所学校**的账号 → 只能发学校那套角色
|
||||||
|
* (发 PLATFORM_ADMIN 会被服务端按租户白名单拒掉,报 40301);
|
||||||
|
* · 学校管理员 → 学校那套。
|
||||||
|
*/
|
||||||
const roleOptions = computed(() =>
|
const roleOptions = computed(() =>
|
||||||
isPlatformTenant.value ? PLATFORM_ROLE_OPTIONS : SCHOOL_ROLE_OPTIONS
|
isPlatformTenant.value && !props.tenantId ? PLATFORM_ROLE_OPTIONS : SCHOOL_ROLE_OPTIONS
|
||||||
)
|
)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -228,10 +245,16 @@ const rules: FormInstance['rules'] = {
|
|||||||
|
|
||||||
async function loadOrgTree() {
|
async function loadOrgTree() {
|
||||||
if (orgTree.value.length) return
|
if (orgTree.value.length) return
|
||||||
const res = await getOrgTree()
|
const res = await getOrgTree({ tenantId: props.tenantId })
|
||||||
orgTree.value = res.data ?? []
|
orgTree.value = res.data ?? []
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 换了学校就把缓存的组织树丢掉:留着上一所学校的树,用户会在"组织"下拉里
|
||||||
|
// 看到别人学校的班级,而选中它提交会得到一个指向不存在组织的 orgId
|
||||||
|
watch(() => props.tenantId, () => {
|
||||||
|
orgTree.value = []
|
||||||
|
})
|
||||||
|
|
||||||
/** 新增 */
|
/** 新增 */
|
||||||
async function add() {
|
async function add() {
|
||||||
detailId.value = null
|
detailId.value = null
|
||||||
@@ -306,7 +329,7 @@ async function confirm() {
|
|||||||
roleCodes: form.roleCodes,
|
roleCodes: form.roleCodes,
|
||||||
userType,
|
userType,
|
||||||
status: form.status
|
status: form.status
|
||||||
})
|
}, props.tenantId)
|
||||||
Message.success('已保存')
|
Message.success('已保存')
|
||||||
visible.value = false
|
visible.value = false
|
||||||
emit('save-success')
|
emit('save-success')
|
||||||
@@ -320,7 +343,7 @@ async function confirm() {
|
|||||||
orgId: form.orgId ?? null,
|
orgId: form.orgId ?? null,
|
||||||
roleCodes: form.roleCodes,
|
roleCodes: form.roleCodes,
|
||||||
userType
|
userType
|
||||||
})
|
}, props.tenantId)
|
||||||
const data = res.data
|
const data = res.data
|
||||||
if (!data) {
|
if (!data) {
|
||||||
Message.error('创建失败,请重试')
|
Message.error('创建失败,请重试')
|
||||||
|
|||||||
@@ -174,9 +174,16 @@ import { useUserStore } from '@/stores'
|
|||||||
* ② 哪几行没进去、为什么
|
* ② 哪几行没进去、为什么
|
||||||
* ③ 新账号的初始密码(只此一次)
|
* ③ 新账号的初始密码(只此一次)
|
||||||
* 三件事摊开给用户看,让他当场就能判断"这次导入能不能信"。
|
* 三件事摊开给用户看,让他当场就能判断"这次导入能不能信"。
|
||||||
|
*
|
||||||
|
* `tenantId` 由父页面传入,**只有平台运营的页面会给值**:表示"导进哪所学校"
|
||||||
|
* (服务端按请求头 `X-Tenant-Id` 摆租户上下文)。它同时决定默认角色的可选范围。
|
||||||
*/
|
*/
|
||||||
defineOptions({ name: 'UserImportModal' })
|
defineOptions({ name: 'UserImportModal' })
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<{ tenantId?: number | null }>(), {
|
||||||
|
tenantId: null
|
||||||
|
})
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
(e: 'save-success'): void
|
(e: 'save-success'): void
|
||||||
}>()
|
}>()
|
||||||
@@ -191,8 +198,11 @@ const report = ref<UserImportResult | null>(null)
|
|||||||
const fileInput = useTemplateRef<HTMLInputElement>('fileInput')
|
const fileInput = useTemplateRef<HTMLInputElement>('fileInput')
|
||||||
|
|
||||||
const isPlatformTenant = computed(() => userStore.roles.includes('PLATFORM_ADMIN'))
|
const isPlatformTenant = computed(() => userStore.roles.includes('PLATFORM_ADMIN'))
|
||||||
|
/** 与 UserFormModal 同一口径:代管某所学校时只能发学校那套角色 */
|
||||||
const roleOptions = computed(() =>
|
const roleOptions = computed(() =>
|
||||||
isPlatformTenant.value ? PLATFORM_ROLE_OPTIONS : SCHOOL_ROLE_OPTIONS.filter((r) => r.value !== 'STUDENT')
|
isPlatformTenant.value && !props.tenantId
|
||||||
|
? PLATFORM_ROLE_OPTIONS
|
||||||
|
: SCHOOL_ROLE_OPTIONS.filter((r) => r.value !== 'STUDENT')
|
||||||
)
|
)
|
||||||
|
|
||||||
/** a-form 需要一个 model;文件选择走原生 input,不进这个对象 */
|
/** a-form 需要一个 model;文件选择走原生 input,不进这个对象 */
|
||||||
@@ -228,7 +238,7 @@ async function submit() {
|
|||||||
if (!file.value) return
|
if (!file.value) return
|
||||||
submitting.value = true
|
submitting.value = true
|
||||||
try {
|
try {
|
||||||
const res = await userApi.importUsers(file.value, formModel.defaultRoleCode)
|
const res = await userApi.importUsers(file.value, formModel.defaultRoleCode, props.tenantId)
|
||||||
report.value = res.data ?? null
|
report.value = res.data ?? null
|
||||||
emit('save-success')
|
emit('save-success')
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
@@ -10,7 +10,17 @@
|
|||||||
</a-space>
|
</a-space>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<a-spin v-if="orgLoading" class="user__loading" />
|
<!-- 平台运营必须先选学校:账号与组织都是租户内的数据(见 @/apis/xt/acting-tenant) -->
|
||||||
|
<GiTenantPicker
|
||||||
|
v-if="isPlatformTenant"
|
||||||
|
v-model="actingStore.tenantId"
|
||||||
|
class="user__tenant"
|
||||||
|
placeholder="选择学校(平台运营)"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<a-empty v-if="needPickTenant" description="请先选择学校" />
|
||||||
|
|
||||||
|
<a-spin v-else-if="orgLoading" class="user__loading" />
|
||||||
|
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<a-button long size="small" :type="orgId === undefined ? 'primary' : 'outline'"
|
<a-button long size="small" :type="orgId === undefined ? 'primary' : 'outline'"
|
||||||
@@ -151,8 +161,8 @@
|
|||||||
</a-table>
|
</a-table>
|
||||||
</a-card>
|
</a-card>
|
||||||
|
|
||||||
<UserFormModal ref="formModalRef" @save-success="load" />
|
<UserFormModal ref="formModalRef" :tenant-id="actingStore.tenantId" @save-success="load" />
|
||||||
<UserImportModal ref="importModalRef" @save-success="load" />
|
<UserImportModal ref="importModalRef" :tenant-id="actingStore.tenantId" @save-success="load" />
|
||||||
|
|
||||||
<!-- 重置密码结果:与创建一样,只显示这一次 -->
|
<!-- 重置密码结果:与创建一样,只显示这一次 -->
|
||||||
<a-modal v-model:visible="resetVisible" title="密码已重置" :width="460" :mask-closable="false"
|
<a-modal v-model:visible="resetVisible" title="密码已重置" :width="460" :mask-closable="false"
|
||||||
@@ -192,7 +202,7 @@ import { IconCopy, IconFolder, IconPlus, IconUpload } from '@arco-design/web-vue
|
|||||||
import { getOrgTree } from '@/apis/xt/org'
|
import { getOrgTree } from '@/apis/xt/org'
|
||||||
import * as userApi from '@/apis/xt/user'
|
import * as userApi from '@/apis/xt/user'
|
||||||
import { PLATFORM_ROLE_OPTIONS, SCHOOL_ROLE_OPTIONS, USER_STATUS } from '@/apis/xt/user'
|
import { PLATFORM_ROLE_OPTIONS, SCHOOL_ROLE_OPTIONS, USER_STATUS } from '@/apis/xt/user'
|
||||||
import { useUserStore } from '@/stores'
|
import { useActingTenantStore, useUserStore } from '@/stores'
|
||||||
import UserFormModal from './UserFormModal.vue'
|
import UserFormModal from './UserFormModal.vue'
|
||||||
import UserImportModal from './UserImportModal.vue'
|
import UserImportModal from './UserImportModal.vue'
|
||||||
|
|
||||||
@@ -215,6 +225,17 @@ const userStore = useUserStore()
|
|||||||
const canManage = computed(() => userStore.permissions.includes('user:manage'))
|
const canManage = computed(() => userStore.permissions.includes('user:manage'))
|
||||||
const isPlatformTenant = computed(() => userStore.roles.includes('PLATFORM_ADMIN'))
|
const isPlatformTenant = computed(() => userStore.roles.includes('PLATFORM_ADMIN'))
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 平台运营的「当前学校」(请求头 `X-Tenant-Id`,见 `@/apis/xt/acting-tenant`)。
|
||||||
|
*
|
||||||
|
* 服务端对平台是关掉租户过滤的,不指明学校就会把**所有租户的账号**混在一起返回 ——
|
||||||
|
* 而那种错看起来很正常(列表有数据、分页也对)。学校管理员不带这个头,行为不变。
|
||||||
|
*
|
||||||
|
* 选择与「组织架构」共用同一个 store:切页面不用重选,两个页面也不会各看一所学校。
|
||||||
|
*/
|
||||||
|
const actingStore = useActingTenantStore()
|
||||||
|
const needPickTenant = computed(() => isPlatformTenant.value && !actingStore.tenantId)
|
||||||
|
|
||||||
const ORG_FIELD_NAMES = { key: 'id', title: 'orgName', children: 'children' }
|
const ORG_FIELD_NAMES = { key: 'id', title: 'orgName', children: 'children' }
|
||||||
|
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
@@ -255,9 +276,13 @@ const pagination = computed(() => ({
|
|||||||
}))
|
}))
|
||||||
|
|
||||||
async function loadOrgTree() {
|
async function loadOrgTree() {
|
||||||
|
if (needPickTenant.value) {
|
||||||
|
orgTree.value = []
|
||||||
|
return
|
||||||
|
}
|
||||||
orgLoading.value = true
|
orgLoading.value = true
|
||||||
try {
|
try {
|
||||||
const res = await getOrgTree()
|
const res = await getOrgTree({ tenantId: actingStore.tenantId })
|
||||||
orgTree.value = res.data ?? []
|
orgTree.value = res.data ?? []
|
||||||
} finally {
|
} finally {
|
||||||
orgLoading.value = false
|
orgLoading.value = false
|
||||||
@@ -265,6 +290,11 @@ async function loadOrgTree() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function load() {
|
async function load() {
|
||||||
|
if (needPickTenant.value) {
|
||||||
|
list.value = []
|
||||||
|
total.value = 0
|
||||||
|
return
|
||||||
|
}
|
||||||
loading.value = true
|
loading.value = true
|
||||||
try {
|
try {
|
||||||
const res = await userApi.getUserList({
|
const res = await userApi.getUserList({
|
||||||
@@ -274,7 +304,7 @@ async function load() {
|
|||||||
status: statusFilter.value,
|
status: statusFilter.value,
|
||||||
pageNum: query.pageNum,
|
pageNum: query.pageNum,
|
||||||
pageSize: query.pageSize
|
pageSize: query.pageSize
|
||||||
})
|
}, actingStore.tenantId)
|
||||||
list.value = res.data?.list ?? []
|
list.value = res.data?.list ?? []
|
||||||
total.value = res.data?.total ?? 0
|
total.value = res.data?.total ?? 0
|
||||||
} finally {
|
} finally {
|
||||||
@@ -282,6 +312,15 @@ async function load() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 换学校 = 换一整套数据(组织树、列表、已选的班级都要重来)。
|
||||||
|
// 留着上一所学校的 orgId 去查新学校,会得到"一个空的列表" —— 看起来像这所学校没有账号。
|
||||||
|
watch(() => actingStore.tenantId, () => {
|
||||||
|
orgId.value = undefined
|
||||||
|
query.pageNum = 1
|
||||||
|
loadOrgTree()
|
||||||
|
load()
|
||||||
|
})
|
||||||
|
|
||||||
function search() {
|
function search() {
|
||||||
query.pageNum = 1
|
query.pageNum = 1
|
||||||
load()
|
load()
|
||||||
@@ -325,7 +364,7 @@ async function onToggleStatus(row: UserVO, status: number) {
|
|||||||
content: '停用后该账号将无法登录,但数据保留,可以随时再启用。',
|
content: '停用后该账号将无法登录,但数据保留,可以随时再启用。',
|
||||||
hideCancel: false,
|
hideCancel: false,
|
||||||
onBeforeOk: async () => {
|
onBeforeOk: async () => {
|
||||||
await userApi.setUserStatus(row.id, status)
|
await userApi.setUserStatus(row.id, status, actingStore.tenantId)
|
||||||
Message.success(`已${action}`)
|
Message.success(`已${action}`)
|
||||||
await load()
|
await load()
|
||||||
resolve()
|
resolve()
|
||||||
@@ -335,7 +374,7 @@ async function onToggleStatus(row: UserVO, status: number) {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
await userApi.setUserStatus(row.id, status)
|
await userApi.setUserStatus(row.id, status, actingStore.tenantId)
|
||||||
Message.success(`已${action}`)
|
Message.success(`已${action}`)
|
||||||
await load()
|
await load()
|
||||||
}
|
}
|
||||||
@@ -346,7 +385,7 @@ async function onResetPassword(row: UserVO) {
|
|||||||
content: '系统会生成一个新的初始密码,旧密码立即失效。该账号的登录锁定也会一并解除。',
|
content: '系统会生成一个新的初始密码,旧密码立即失效。该账号的登录锁定也会一并解除。',
|
||||||
hideCancel: false,
|
hideCancel: false,
|
||||||
onBeforeOk: async () => {
|
onBeforeOk: async () => {
|
||||||
const res = await userApi.resetUserPassword(row.id, { mode: 'GENERATE' })
|
const res = await userApi.resetUserPassword(row.id, { mode: 'GENERATE' }, actingStore.tenantId)
|
||||||
resetResult.value = res.data ?? null
|
resetResult.value = res.data ?? null
|
||||||
resetVisible.value = true
|
resetVisible.value = true
|
||||||
return true
|
return true
|
||||||
|
|||||||
@@ -0,0 +1,546 @@
|
|||||||
|
# -*- 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 | 菜单永远不显示(键拼错),或前端有菜单谁都看不到 |
|
||||||
|
| 7 | **PC 端引用的权限码 ↔ `PermissionDef`** | 判断恒为 false/true:整块界面静默消失(见下) |
|
||||||
|
|
||||||
|
### 第 7 项是 2026-10-09 补的(自己踩了一次)
|
||||||
|
|
||||||
|
在工作台按权限决定"发不发请求"时,我把 `progress:viewAll` 写成了 `progress:view:all`
|
||||||
|
(多一个冒号)。后果是那个判断**恒为 false**:「审核情况」整块对**所有人**都不显示 ——
|
||||||
|
而编译、typecheck、lint、后端日志、甚至 E2E **全都不红**。
|
||||||
|
|
||||||
|
这一族错的特征与上面六项完全一样:**声明对不上,谁都不报错**;
|
||||||
|
而"权限不足就少显示一块"看起来又很像设计(不是崩溃,是安静地少一块)。
|
||||||
|
|
||||||
|
|
||||||
|
## 它**不查**什么(免得被当成"全绿就没事")
|
||||||
|
|
||||||
|
- 不查"逻辑对不对":它只回答"有没有被引用过",一个被引用了但用错地方的常量
|
||||||
|
它照样算通过;
|
||||||
|
- 不查前端有没有把权限码用上(`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 = {}
|
||||||
|
# PC 端引用的权限码:PermissionDef 里没有的(**应当保持为空** ——
|
||||||
|
# 写错一个码的后果是"那个判断恒为 false",整块界面静默消失,见文件头第 7 项)
|
||||||
|
FE_PERM_EXCEPTION = {}
|
||||||
|
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 strip_js_comments(src):
|
||||||
|
"""去掉 JS/TS 的注释,**但要认引号**(不能见到 `//` 就砍)。
|
||||||
|
|
||||||
|
⚠️ 为什么需要它:第 ⑦ 项第一版把 `directives/hasPerm.ts` 里的
|
||||||
|
**注释与示例字符串**(`<button v-hasPerm="['home:btn:add']">`、报错文案里的示例)
|
||||||
|
当成了真实用法,一口气报了 5 条假阳性 —— 模板作者的示例码当然不在 PermissionDef 里。
|
||||||
|
|
||||||
|
这与本脚本 2026-10-08 修过的第 ③ 条是同一个教训(当时是 Java 注释里的提及
|
||||||
|
被当成接线):**检查器必须能区分"代码"与"解释代码的文字"**,
|
||||||
|
否则它要么天天误报、要么被人加一堆例外表绕过去。
|
||||||
|
"""
|
||||||
|
out = []
|
||||||
|
i, n = 0, len(src)
|
||||||
|
quote = None # 当前是否在字符串里(' 或 ")
|
||||||
|
while i < n:
|
||||||
|
c = src[i]
|
||||||
|
if quote:
|
||||||
|
out.append(c)
|
||||||
|
if c == '\\' and i + 1 < n: # 转义:连同下一个字符一起吞掉
|
||||||
|
out.append(src[i + 1])
|
||||||
|
i += 2
|
||||||
|
continue
|
||||||
|
if c == quote:
|
||||||
|
quote = None
|
||||||
|
i += 1
|
||||||
|
continue
|
||||||
|
if c in '\'"`':
|
||||||
|
quote = c
|
||||||
|
out.append(c)
|
||||||
|
i += 1
|
||||||
|
continue
|
||||||
|
if c == '/' and i + 1 < n and src[i + 1] == '/':
|
||||||
|
while i < n and src[i] != '\n':
|
||||||
|
i += 1
|
||||||
|
continue
|
||||||
|
if c == '/' and i + 1 < n and src[i + 1] == '*':
|
||||||
|
i += 2
|
||||||
|
while i + 1 < n and not (src[i] == '*' and src[i + 1] == '/'):
|
||||||
|
i += 1
|
||||||
|
i += 2
|
||||||
|
out.append(' ')
|
||||||
|
continue
|
||||||
|
out.append(c)
|
||||||
|
i += 1
|
||||||
|
return ''.join(out)
|
||||||
|
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
|
# ── ⑦ PC 端引用的权限码 ↔ PermissionDef ─────────────────────
|
||||||
|
#
|
||||||
|
# 两个写法都认:`userStore.permissions.includes('org:view')` 与
|
||||||
|
# `v-hasPerm="['audit:final']"`(后者是模板里写的指令参数)。
|
||||||
|
#
|
||||||
|
# ⚠️ 这个检查器自己也要能被验:**故意写错一个码,它必须红**。
|
||||||
|
# 2026-10-09 加它的时候正是拿一个写错的码试过的(那时是真的写错了)。
|
||||||
|
perm_codes = set(re.findall(r'\(\s*"([^"]+)"\s*,\s*"', strip_java_comments(read(PERM_DEF))))
|
||||||
|
fe_codes = {} # code -> 出现位置(文件:行)
|
||||||
|
for p in sorted(glob.glob('frontend-pc/src/**/*.vue', recursive=True)
|
||||||
|
+ glob.glob('frontend-pc/src/**/*.ts', recursive=True)):
|
||||||
|
try:
|
||||||
|
raw = read(p)
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
# 先去掉注释,再找用法 —— 否则注释/示例文案里的码会被当成真用法(假阳性)
|
||||||
|
src = strip_js_comments(raw)
|
||||||
|
is_vue = p.endswith('.vue')
|
||||||
|
for i, line in enumerate(src.split('\n'), 1):
|
||||||
|
for m in re.finditer(r"permissions\.includes\(\s*'([^']+)'", line):
|
||||||
|
fe_codes.setdefault(m.group(1), []).append('%s:%d' % (p, i))
|
||||||
|
# `v-hasPerm` 是**模板**里的写法:只在 .vue 里找,别去 .ts 的示例/报错文案里找
|
||||||
|
if is_vue:
|
||||||
|
for m in re.finditer(r"v-hasPerm\s*=\s*\"\[([^\]]*)\]\"", line):
|
||||||
|
for c in re.findall(r"'([^']+)'", m.group(1)):
|
||||||
|
fe_codes.setdefault(c, []).append('%s:%d' % (p, i))
|
||||||
|
say('\n【⑦】PC 端引用的权限码 %d 个 / PermissionDef 声明 %d 个'
|
||||||
|
% (len(fe_codes), len(perm_codes)))
|
||||||
|
for code in sorted(fe_codes):
|
||||||
|
where = fe_codes[code][0]
|
||||||
|
if code in perm_codes:
|
||||||
|
say(' ✓ %-22s %s' % (code, where))
|
||||||
|
elif code in FE_PERM_EXCEPTION:
|
||||||
|
notes.append('PC 端权限码 %s —— 已知例外:%s' % (code, FE_PERM_EXCEPTION[code]))
|
||||||
|
say(' — %-22s %s' % (code, FE_PERM_EXCEPTION[code]))
|
||||||
|
else:
|
||||||
|
problems.append(('前端权限码', code,
|
||||||
|
'PermissionDef 里没有这个码(判断会恒为 false:%s)' % where))
|
||||||
|
say(' ✗ %-22s PermissionDef 里没有它 → 这个判断永远是 false %s' % (code, where))
|
||||||
|
|
||||||
|
# ── 汇总 ────────────────────────────────────────────────
|
||||||
|
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())
|
||||||
+191
-11
@@ -1,31 +1,47 @@
|
|||||||
#!/usr/bin/env python
|
#!/usr/bin/env python
|
||||||
# -*- coding: utf-8 -*-
|
# -*- coding: utf-8 -*-
|
||||||
"""
|
"""
|
||||||
清理 e2e-m0.py 产生的测试数据。
|
开发库与本地磁盘的**测试残留**清理。
|
||||||
|
|
||||||
E2E 每跑一次会:
|
E2E 每跑一次会:
|
||||||
1. 新建一个租户(编码 e2e######)并给它建角色与账号;
|
1. 新建一个租户(编码 e2e######)并给它建角色与账号;
|
||||||
2. **在 demo 租户下**建三个组织(学校/院系/班级,编码带时间戳)。
|
2. **在 demo 租户下**建三个组织(学校/院系/班级,编码带时间戳)。
|
||||||
|
|
||||||
第 2 点容易被忽略——这些组织挂在正常租户下,不清掉会让组织树里
|
第 2 点容易被忽略——这些组织挂在正常租户下,不清掉会让组织树里堆满
|
||||||
堆满 "E2E 示范学校 586923" 这种名字。
|
"E2E 示范学校 586923" 这种名字。
|
||||||
|
|
||||||
|
跑几十轮之后真正的问题不是"列表里有测试名字",而是两笔越滚越大的账:
|
||||||
|
|
||||||
|
· **租户垃圾**:`sys_tenant` 里 48 个 `e2e*`(每个还带着用户、角色、组织);
|
||||||
|
· **磁盘垃圾**:`backend/data/uploads` 里几百 MB 的文件 ——
|
||||||
|
**删库不删盘**。压测与 E2E 会删掉自己的库行,但文件留在磁盘上,
|
||||||
|
而"哪些文件还有主"只有库知道(`collect_file.std_path/raw_path`、
|
||||||
|
`collect_export_file.file_path`)。实测:库里 16 行文件、磁盘上 281 个文件。
|
||||||
|
|
||||||
用法
|
用法
|
||||||
----
|
----
|
||||||
python scripts/dev-clean-e2e.py # 只看会删什么(默认 dry-run)
|
python scripts/dev-clean-e2e.py # 只看会删什么(默认 dry-run)
|
||||||
python scripts/dev-clean-e2e.py --apply # 真删
|
python scripts/dev-clean-e2e.py --apply # 真删:e2e 租户 + 测试组织 + 它们的业务行
|
||||||
|
python scripts/dev-clean-e2e.py --apply --demo-tasks # 连【演示·旧】任务一起删
|
||||||
|
python scripts/dev-clean-e2e.py --apply --orphan-files # 连"没有库行指向"的磁盘文件一起删
|
||||||
|
|
||||||
⚠️ 只在开发库上跑,不要对生产执行。
|
⚠️ 只在开发库上跑,不要对生产执行。
|
||||||
|
⚠️ `--orphan-files` 删的是**磁盘文件**,不可恢复;它靠"库里没有任何一行指向它"来判定,
|
||||||
|
所以只要库是完整的,它就不会删到在用的文件。默认 dry-run,先看一眼再 `--apply`。
|
||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
|
import os
|
||||||
import subprocess
|
import subprocess
|
||||||
import sys
|
import sys
|
||||||
|
|
||||||
if hasattr(sys.stdout, "reconfigure"):
|
if hasattr(sys.stdout, "reconfigure"):
|
||||||
sys.stdout.reconfigure(encoding="utf-8")
|
sys.stdout.reconfigure(encoding="utf-8")
|
||||||
|
|
||||||
|
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||||
|
UPLOADS = os.path.join(ROOT, "backend", "data", "uploads")
|
||||||
|
|
||||||
# 子表在前、主表在后(无外键约束,顺序只为可读性与排查方便)
|
# 子表在前、主表在后(无外键约束,顺序只为可读性与排查方便)
|
||||||
TENANT_CHILD_TABLES = [
|
TENANT_CHILD_TABLES = [
|
||||||
("角色授权", "sys_role_permission"),
|
("角色授权", "sys_role_permission"),
|
||||||
@@ -45,6 +61,13 @@ DEMO_ORG_FILTER = (
|
|||||||
"OR org_name LIKE 'E2E %' OR org_name LIKE '重复编码%' OR org_name LIKE '层级颠倒%'"
|
"OR org_name LIKE 'E2E %' OR org_name LIKE '重复编码%' OR org_name LIKE '层级颠倒%'"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# 演示残留:给客户演示时反复摆过的任务,为了不污染演示态,已发布的那批被**改名挪开**
|
||||||
|
# 而不是删掉(F-14:已发布的任务删不掉)。它们属于"当时的演示数据",不是需求数据。
|
||||||
|
#
|
||||||
|
# ⚠️ 只匹配带「旧」的那一批:`【演示】审核台` 是**当前**演示态在用的任务,
|
||||||
|
# 删了它 = 把演示态弄坏(而且它的班级就是下面那批 "E2E 24数控1班",同理不能删)。
|
||||||
|
DEMO_TASK_FILTER = "task_name LIKE '【演示·旧】%'"
|
||||||
|
|
||||||
|
|
||||||
class Db:
|
class Db:
|
||||||
def __init__(self, container: str, user: str, name: str):
|
def __init__(self, container: str, user: str, name: str):
|
||||||
@@ -62,25 +85,114 @@ class Db:
|
|||||||
return [x for x in r.stdout.split("\n") if x.strip()]
|
return [x for x in r.stdout.split("\n") if x.strip()]
|
||||||
|
|
||||||
|
|
||||||
|
def tables_with(db: Db, column: str) -> list[str]:
|
||||||
|
"""
|
||||||
|
所有带这一列的 `collect_*` 表。
|
||||||
|
|
||||||
|
⚠️ **动态枚举,不写死清单**:写死的话,以后新增一张业务表就会漏清,
|
||||||
|
而"漏清"的表现是下一次跑 E2E 时某个统计数字莫名偏大 —— 排查方向完全错。
|
||||||
|
"""
|
||||||
|
sql = ("SELECT c.table_name FROM information_schema.columns c "
|
||||||
|
"JOIN information_schema.tables t "
|
||||||
|
" ON t.table_name = c.table_name AND t.table_schema = c.table_schema "
|
||||||
|
"WHERE c.table_schema = 'public' AND t.table_type = 'BASE TABLE' "
|
||||||
|
f" AND c.table_name LIKE 'collect\\_%' AND c.column_name = '{column}' "
|
||||||
|
"ORDER BY c.table_name")
|
||||||
|
return [r.split("|")[0] for r in db.rows(sql)]
|
||||||
|
|
||||||
|
|
||||||
|
def db_file_paths(db: Db) -> set[str]:
|
||||||
|
"""库里所有被引用的文件路径(相对 uploads 目录)。"""
|
||||||
|
known: set[str] = set()
|
||||||
|
for table, cols in (("collect_file", ("raw_path", "std_path")),
|
||||||
|
("collect_export_file", ("file_path",))):
|
||||||
|
for col in cols:
|
||||||
|
for r in db.rows(f"SELECT {col} FROM {table} WHERE {col} IS NOT NULL"):
|
||||||
|
p = r.split("|")[0].strip().replace("\\", "/")
|
||||||
|
if not p:
|
||||||
|
continue
|
||||||
|
known.add(p)
|
||||||
|
if os.path.isabs(p):
|
||||||
|
known.add(os.path.relpath(p, UPLOADS).replace("\\", "/"))
|
||||||
|
return known
|
||||||
|
|
||||||
|
|
||||||
|
def orgs_referenced_by_live_tasks(db: Db) -> set[int]:
|
||||||
|
"""
|
||||||
|
仍被**存活任务**引用的组织 id —— 这些组织绝不能删。
|
||||||
|
|
||||||
|
⚠️ 为什么必须有它:当前演示态就挂在其中一个 "E2E 24数控1班" 上
|
||||||
|
(历史原因:演示种子当时用了 E2E 建的班)。删了组织 = 演示态的审核范围、
|
||||||
|
导出目录全部失去归属,而**界面上一句话都不会提示**。
|
||||||
|
引用判断用任务表的 deleted(collect_task_org 没有 deleted 列,不能用它判活)。
|
||||||
|
"""
|
||||||
|
keep: set[int] = set()
|
||||||
|
for r in db.rows(
|
||||||
|
"SELECT DISTINCT cto.org_id FROM collect_task_org cto "
|
||||||
|
"JOIN collect_task t ON t.id = cto.task_id AND t.deleted = 0 "
|
||||||
|
"WHERE cto.org_id IS NOT NULL"):
|
||||||
|
try:
|
||||||
|
keep.add(int(r.split("|")[0]))
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
return keep
|
||||||
|
|
||||||
|
|
||||||
|
def scan_orphans(db: Db) -> tuple[list[str], int]:
|
||||||
|
"""磁盘上有、而库里没有任何一行指向的文件。"""
|
||||||
|
known = db_file_paths(db)
|
||||||
|
orphans, size = [], 0
|
||||||
|
for dirpath, _dirs, files in os.walk(UPLOADS):
|
||||||
|
for fn in files:
|
||||||
|
full = os.path.join(dirpath, fn)
|
||||||
|
rel = os.path.relpath(full, UPLOADS).replace("\\", "/")
|
||||||
|
if rel not in known:
|
||||||
|
orphans.append(rel)
|
||||||
|
try:
|
||||||
|
size += os.path.getsize(full)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
return sorted(orphans), size
|
||||||
|
|
||||||
|
|
||||||
def main() -> int:
|
def main() -> int:
|
||||||
ap = argparse.ArgumentParser()
|
ap = argparse.ArgumentParser()
|
||||||
ap.add_argument("--apply", action="store_true", help="真正执行删除(默认只预览)")
|
ap.add_argument("--apply", action="store_true", help="真正执行删除(默认只预览)")
|
||||||
|
ap.add_argument("--demo-tasks", action="store_true",
|
||||||
|
help="连【演示·旧】任务(及其名册/材料/填报单/文件)一起删")
|
||||||
|
ap.add_argument("--orphan-files", action="store_true",
|
||||||
|
help="连磁盘上「没有库行指向」的文件一起删(不可恢复)")
|
||||||
|
ap.add_argument("--only-orphan-files", action="store_true",
|
||||||
|
help="**只**清磁盘孤儿文件,不碰租户/组织/任务(最安全的一档)")
|
||||||
ap.add_argument("--container", default="xt-pg")
|
ap.add_argument("--container", default="xt-pg")
|
||||||
ap.add_argument("--user", default="fill")
|
ap.add_argument("--user", default="fill")
|
||||||
ap.add_argument("--db", default="filldb")
|
ap.add_argument("--db", default="filldb")
|
||||||
args = ap.parse_args()
|
args = ap.parse_args()
|
||||||
|
if args.only_orphan_files:
|
||||||
|
args.orphan_files = True
|
||||||
|
|
||||||
db = Db(args.container, args.user, args.db)
|
db = Db(args.container, args.user, args.db)
|
||||||
|
|
||||||
tenants = db.rows(
|
tenants = [] if args.only_orphan_files else db.rows(
|
||||||
f"SELECT id || '|' || tenant_code || '|' || tenant_name "
|
f"SELECT id || '|' || tenant_code || '|' || tenant_name "
|
||||||
f"FROM sys_tenant WHERE {TENANT_FILTER} ORDER BY id")
|
f"FROM sys_tenant WHERE {TENANT_FILTER} ORDER BY id")
|
||||||
demo_orgs = db.rows(
|
# 在用组织保护:先算好(任何删除发生之前),删除阶段只动"没被引用的"
|
||||||
|
keep_orgs = orgs_referenced_by_live_tasks(db) if not args.only_orphan_files else set()
|
||||||
|
demo_orgs = [] if args.only_orphan_files else [
|
||||||
|
r for r in db.rows(
|
||||||
f"SELECT id || '|' || org_name FROM sys_org "
|
f"SELECT id || '|' || org_name FROM sys_org "
|
||||||
f"WHERE deleted = 0 AND ({DEMO_ORG_FILTER}) ORDER BY id")
|
f"WHERE deleted = 0 AND ({DEMO_ORG_FILTER}) ORDER BY id")
|
||||||
|
if int(r.split("|")[0]) not in keep_orgs
|
||||||
|
]
|
||||||
|
kept_note = ("(另有 %d 个测试组织被存活任务引用,**保留**:其中就有当前演示态挂着的班)"
|
||||||
|
% len(keep_orgs)) if keep_orgs else ""
|
||||||
|
demo_tasks = (db.rows(f"SELECT id || '|' || task_name FROM collect_task "
|
||||||
|
f"WHERE deleted = 0 AND {DEMO_TASK_FILTER} ORDER BY id")
|
||||||
|
if (args.demo_tasks and not args.only_orphan_files) else [])
|
||||||
|
orphans, orphan_size = scan_orphans(db) if args.orphan_files else ([], 0)
|
||||||
|
|
||||||
if not tenants and not demo_orgs:
|
if not any((tenants, demo_orgs, demo_tasks, orphans)):
|
||||||
print("没有 e2e 测试数据,无需清理")
|
print("没有需要清理的测试数据")
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
if tenants:
|
if tenants:
|
||||||
@@ -92,13 +204,32 @@ def main() -> int:
|
|||||||
print(f" ... 另有 {len(tenants) - 15} 个")
|
print(f" ... 另有 {len(tenants) - 15} 个")
|
||||||
|
|
||||||
if demo_orgs:
|
if demo_orgs:
|
||||||
print(f"\n② 其它租户下的 e2e 测试组织 {len(demo_orgs)} 个:")
|
print(f"\n② 其它租户下的 e2e 测试组织 {len(demo_orgs)} 个{kept_note}:")
|
||||||
for r in demo_orgs[:15]:
|
for r in demo_orgs[:15]:
|
||||||
p = r.split("|")
|
p = r.split("|")
|
||||||
print(" id=%-5s %s" % (p[0], p[1] if len(p) > 1 else ""))
|
print(" id=%-5s %s" % (p[0], p[1] if len(p) > 1 else ""))
|
||||||
if len(demo_orgs) > 15:
|
if len(demo_orgs) > 15:
|
||||||
print(f" ... 另有 {len(demo_orgs) - 15} 个")
|
print(f" ... 另有 {len(demo_orgs) - 15} 个")
|
||||||
|
|
||||||
|
if demo_tasks:
|
||||||
|
print(f"\n③ 演示残留任务 {len(demo_tasks)} 个(--demo-tasks):")
|
||||||
|
for r in demo_tasks[:10]:
|
||||||
|
p = r.split("|")
|
||||||
|
print(" id=%-5s %s" % (p[0], p[1] if len(p) > 1 else ""))
|
||||||
|
if len(demo_tasks) > 10:
|
||||||
|
print(f" ... 另有 {len(demo_tasks) - 10} 个")
|
||||||
|
|
||||||
|
if args.orphan_files:
|
||||||
|
print(f"\n④ 磁盘上没有库行指向的文件 {len(orphans)} 个 / {orphan_size / 1048576.0:.1f} MB"
|
||||||
|
f"(--orphan-files)")
|
||||||
|
by_dir: dict[str, int] = {}
|
||||||
|
for rel in orphans:
|
||||||
|
by_dir[rel.split("/")[0]] = by_dir.get(rel.split("/")[0], 0) + 1
|
||||||
|
for k, v in sorted(by_dir.items(), key=lambda x: -x[1]):
|
||||||
|
print(" %-10s %d 个" % (k, v))
|
||||||
|
for rel in orphans[:5]:
|
||||||
|
print(" 例:%s" % rel)
|
||||||
|
|
||||||
if not args.apply:
|
if not args.apply:
|
||||||
print("\n这是预览。加 --apply 才会真正删除。")
|
print("\n这是预览。加 --apply 才会真正删除。")
|
||||||
return 0
|
return 0
|
||||||
@@ -106,20 +237,69 @@ def main() -> int:
|
|||||||
print("\n开始清理:")
|
print("\n开始清理:")
|
||||||
total = 0
|
total = 0
|
||||||
|
|
||||||
|
if not args.only_orphan_files:
|
||||||
for label, table in TENANT_CHILD_TABLES:
|
for label, table in TENANT_CHILD_TABLES:
|
||||||
n = len(db.rows(f"DELETE FROM {table} WHERE {SUB_FILTER} RETURNING id"))
|
n = len(db.rows(f"DELETE FROM {table} WHERE {SUB_FILTER} RETURNING id"))
|
||||||
total += n
|
total += n
|
||||||
print(" %-10s %-22s 删除 %d 行" % (label, table, n))
|
print(" %-10s %-22s 删除 %d 行" % (label, table, n))
|
||||||
|
|
||||||
|
# e2e 租户的**业务行**:不删的话,租户没了、任务还在,
|
||||||
|
# 于是"每个请求多一次租户解析"的旧账会一直留着(F-5)
|
||||||
|
for table in tables_with(db, "tenant_id"):
|
||||||
|
n = len(db.rows(f"DELETE FROM {table} WHERE tenant_id IN "
|
||||||
|
f"(SELECT id FROM sys_tenant WHERE {TENANT_FILTER}) RETURNING id"))
|
||||||
|
if n:
|
||||||
|
total += n
|
||||||
|
print(" %-10s %-22s 删除 %d 行" % ("业务行", table, n))
|
||||||
|
|
||||||
n = len(db.rows(f"DELETE FROM sys_tenant WHERE {TENANT_FILTER} RETURNING id"))
|
n = len(db.rows(f"DELETE FROM sys_tenant WHERE {TENANT_FILTER} RETURNING id"))
|
||||||
total += n
|
total += n
|
||||||
print(" %-10s %-22s 删除 %d 行" % ("租户", "sys_tenant", n))
|
print(" %-10s %-22s 删除 %d 行" % ("租户", "sys_tenant", n))
|
||||||
|
|
||||||
n = len(db.rows(f"DELETE FROM sys_org WHERE {DEMO_ORG_FILTER} RETURNING id"))
|
# 测试组织:按 id 删(keep_orgs 已在预览阶段剔除;这里再校验一次,
|
||||||
|
# 因为上面可能刚删过任务,引用集合应该只减不增 —— 双保险,宁可少删)
|
||||||
|
keep_orgs = orgs_referenced_by_live_tasks(db) if not args.only_orphan_files else set()
|
||||||
|
org_ids = [int(r.split("|")[0]) for r in db.rows(
|
||||||
|
f"SELECT id FROM sys_org WHERE deleted = 0 AND ({DEMO_ORG_FILTER}) ORDER BY id")
|
||||||
|
if int(r.split("|")[0]) not in keep_orgs]
|
||||||
|
if org_ids:
|
||||||
|
# 一次 IN 里塞太多 id 会撑爆语句,分批(144 个一批也行,但分批无成本)
|
||||||
|
for i in range(0, len(org_ids), 200):
|
||||||
|
batch = org_ids[i:i + 200]
|
||||||
|
n = len(db.rows("DELETE FROM sys_org WHERE id IN (%s) RETURNING id"
|
||||||
|
% ",".join(map(str, batch))))
|
||||||
total += n
|
total += n
|
||||||
print(" %-10s %-22s 删除 %d 行" % ("测试组织", "sys_org", n))
|
print(" %-10s %-22s 删除 %d 行" % ("测试组织", "sys_org", n))
|
||||||
|
else:
|
||||||
|
print(" %-10s %-22s 没有可删的(都在用或已清)" % ("测试组织", "sys_org"))
|
||||||
|
|
||||||
print(f"\n完成,共删除 {total} 行。")
|
if args.demo_tasks:
|
||||||
|
# 先按任务 id 删子表(同样动态枚举"带 task_id 的表")
|
||||||
|
for table in tables_with(db, "task_id"):
|
||||||
|
n = len(db.rows(f"DELETE FROM {table} WHERE task_id IN "
|
||||||
|
f"(SELECT id FROM collect_task WHERE {DEMO_TASK_FILTER}) RETURNING id"))
|
||||||
|
if n:
|
||||||
|
total += n
|
||||||
|
print(" %-10s %-22s 删除 %d 行" % ("演示子表", table, n))
|
||||||
|
n = len(db.rows(f"DELETE FROM collect_task WHERE {DEMO_TASK_FILTER} RETURNING id"))
|
||||||
|
total += n
|
||||||
|
print(" %-10s %-22s 删除 %d 行" % ("演示任务", "collect_task", n))
|
||||||
|
|
||||||
|
removed = 0
|
||||||
|
if args.orphan_files:
|
||||||
|
# ⚠️ 删文件前**重算一次**:上面刚删过库行,此时"还有主"的文件集合变小了,
|
||||||
|
# 但那些刚被删掉主的文件属于"任务数据",本该跟着一起清 —— 重算正好覆盖它们
|
||||||
|
orphans, orphan_size = scan_orphans(db)
|
||||||
|
for rel in orphans:
|
||||||
|
try:
|
||||||
|
os.remove(os.path.join(UPLOADS, rel))
|
||||||
|
removed += 1
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
print(" %-10s %-22s 删除 %d 个文件 / %.1f MB"
|
||||||
|
% ("磁盘文件", "backend/data/uploads", removed, orphan_size / 1048576.0))
|
||||||
|
|
||||||
|
print(f"\n完成,共删除 {total} 行" + (f"、{removed} 个文件。" if args.orphan_files else "。"))
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,167 @@
|
|||||||
|
#!/usr/bin/env python
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""
|
||||||
|
清理七牛**真实桶**里本项目灌进去的测试对象(《08》F-93:删库不删桶)。
|
||||||
|
|
||||||
|
## 背景
|
||||||
|
本机 `application-local.yml` 里 `provider: qiniu`(bucket=tools-matrix,私有),
|
||||||
|
历史上联调/压测把测试文件传进去过 —— 库行早删了,**对象还躺在桶里**。
|
||||||
|
|
||||||
|
## 安全边界(刻意做窄)
|
||||||
|
- 只碰 `prefix`(xiaotutongxue/)下的对象 —— 那是本项目的名字空间,
|
||||||
|
同桶其它项目的文件**一个都不动**;
|
||||||
|
- **默认 dry-run**:只列清单与体积;`--apply` 才真删;
|
||||||
|
- 删除走七牛 batch API(一批最多 200 个),逐批打印结果;
|
||||||
|
- 列表失败 / 数量异常(>5000)直接停,不猜。
|
||||||
|
|
||||||
|
## 用法
|
||||||
|
python scripts/dev-clean-qiniu.py # 只看(数量 / 总体积 / 样例)
|
||||||
|
python scripts/dev-clean-qiniu.py --apply # 真删
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import base64
|
||||||
|
import hashlib
|
||||||
|
import hmac
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import urllib.parse
|
||||||
|
import urllib.request
|
||||||
|
|
||||||
|
sys.stdout.reconfigure(encoding="utf-8")
|
||||||
|
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||||
|
LOCAL_YML = os.path.join(ROOT, "backend", "application-local.yml")
|
||||||
|
|
||||||
|
HOST = "rs.qiniuapi.com" # 单资源 / batch 操作
|
||||||
|
HOST_RSF = "rsf.qiniuapi.com" # ⚠️ 列举(/list)在 rsf,不在 rs —— 打错 host 会得到
|
||||||
|
# 400 "Bad method: list"(我踩过,别按直觉写)
|
||||||
|
|
||||||
|
|
||||||
|
def read_local_cfg() -> tuple[str, str, str, str]:
|
||||||
|
"""从 application-local.yml 读 AK/SK/bucket/prefix(那本来就是唯一放密钥的地方)。"""
|
||||||
|
import re
|
||||||
|
text = open(LOCAL_YML, encoding="utf-8").read()
|
||||||
|
|
||||||
|
def grab(key: str) -> str:
|
||||||
|
# 值可能带单引号(密钥)也可能不带(bucket/prefix)—— 两种都要认
|
||||||
|
m = re.search(rf"^\s*{key}:\s*['\"]?([^'\"\s]+)['\"]?\s*$", text, re.M)
|
||||||
|
return m.group(1) if m else ""
|
||||||
|
|
||||||
|
ak = grab("access-key")
|
||||||
|
sk = grab("secret-key")
|
||||||
|
bucket = grab("bucket")
|
||||||
|
prefix = grab("prefix") or "xiaotutongxue"
|
||||||
|
if not (ak and sk and bucket):
|
||||||
|
print("✗ 在 backend/application-local.yml 里读不到七牛 AK/SK/bucket")
|
||||||
|
sys.exit(2)
|
||||||
|
return ak, sk, bucket, prefix
|
||||||
|
|
||||||
|
|
||||||
|
def qbox_sign(sk: str, host: str, method: str, path_query: str,
|
||||||
|
content_type: str | None = None, body: bytes | None = None) -> str:
|
||||||
|
"""
|
||||||
|
七牛管理凭证签名(官方算法,2026-09-30 按文档逐字核对):
|
||||||
|
|
||||||
|
signingStr = Method + " " + Path[?query] + "\\n"
|
||||||
|
+ "Host: " + Host + "\\n"
|
||||||
|
[+ "Content-Type: " + ct + "\\n"] ← 设了才加
|
||||||
|
+ "\\n" ← ★ 结尾是**两个**换行
|
||||||
|
[+ Body] ← ★ 有 Body 且 ct 非 octet-stream 时必须加
|
||||||
|
Authorization: Qiniu <AK>:<urlsafe_base64(hmac_sha1(SK, signingStr))>
|
||||||
|
|
||||||
|
⚠️ 两个都踩过(都报 401 bad token,但原因不同):
|
||||||
|
① 旧 QBox 格式(一个换行 + "QBox " 前缀);
|
||||||
|
② /batch 的 Body 没进签名 —— 列举(无 Body)能过、批删全 401,
|
||||||
|
**同一个签名函数在两种请求上一好一坏**,线索就在 Body 上。
|
||||||
|
以 developer.qiniu.com/kodo/1201/access-token 的示例为准。
|
||||||
|
"""
|
||||||
|
data = f"{method} {path_query}\nHost: {host}\n"
|
||||||
|
if content_type:
|
||||||
|
data += f"Content-Type: {content_type}\n"
|
||||||
|
data += "\n"
|
||||||
|
if body and content_type and content_type != "application/octet-stream":
|
||||||
|
data += body.decode()
|
||||||
|
mac = hmac.new(sk.encode(), data.encode(), hashlib.sha1).digest()
|
||||||
|
return base64.urlsafe_b64encode(mac).decode()
|
||||||
|
|
||||||
|
|
||||||
|
def call_qiniu(ak: str, sk: str, method: str, path_query: str,
|
||||||
|
content_type: str | None = None, body: bytes | None = None,
|
||||||
|
host: str = HOST) -> tuple[int, bytes]:
|
||||||
|
url = f"http://{host}{path_query}"
|
||||||
|
req = urllib.request.Request(url, data=body, method=method, headers={
|
||||||
|
"Authorization": f"Qiniu {ak}:{qbox_sign(sk, host, method, path_query, content_type, body)}",
|
||||||
|
**({"Content-Type": content_type} if content_type else {}),
|
||||||
|
})
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(req, timeout=30) as r:
|
||||||
|
return r.status, r.read()
|
||||||
|
except urllib.error.HTTPError as e:
|
||||||
|
return e.code, e.read()
|
||||||
|
|
||||||
|
|
||||||
|
def list_objects(ak: str, sk: str, bucket: str, prefix: str) -> list[dict]:
|
||||||
|
out, marker = [], ""
|
||||||
|
while True:
|
||||||
|
q = f"bucket={urllib.parse.quote(bucket)}&prefix={urllib.parse.quote(prefix)}&limit=500"
|
||||||
|
if marker:
|
||||||
|
q += f"&marker={urllib.parse.quote(marker)}"
|
||||||
|
code, raw = call_qiniu(ak, sk, "GET", f"/list?{q}", host=HOST_RSF)
|
||||||
|
if code != 200:
|
||||||
|
print(f"✗ 列举失败:HTTP {code} {raw[:200]}")
|
||||||
|
sys.exit(2)
|
||||||
|
data = json.loads(raw)
|
||||||
|
out += data.get("items") or []
|
||||||
|
marker = data.get("marker") or ""
|
||||||
|
if not marker:
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
ap = argparse.ArgumentParser(description="清理七牛桶里本项目的测试对象(默认 dry-run)")
|
||||||
|
ap.add_argument("--apply", action="store_true", help="真删(默认只列清单)")
|
||||||
|
args = ap.parse_args()
|
||||||
|
|
||||||
|
ak, sk, bucket, prefix = read_local_cfg()
|
||||||
|
print(f"bucket={bucket} prefix={prefix}/")
|
||||||
|
|
||||||
|
objs = list_objects(ak, sk, bucket, prefix)
|
||||||
|
total = sum(o.get("fsize", 0) for o in objs)
|
||||||
|
print(f"共 {len(objs)} 个对象 / {total / 1048576.0:.1f} MB")
|
||||||
|
for o in objs[:10]:
|
||||||
|
print(f" {o.get('fsize', 0):>10} B {o.get('key')}")
|
||||||
|
if len(objs) > 10:
|
||||||
|
print(f" ... 另有 {len(objs) - 10} 个")
|
||||||
|
|
||||||
|
if not objs:
|
||||||
|
print("桶是干净的,无需清理")
|
||||||
|
return 0
|
||||||
|
if len(objs) > 5000:
|
||||||
|
print("✗ 数量异常(>5000),先人工确认再删 —— 不猜")
|
||||||
|
return 2
|
||||||
|
if not args.apply:
|
||||||
|
print("\n这是预览。加 --apply 才会真正删除(只删该前缀,不可恢复)。")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
# batch delete:一批最多 200 个 op
|
||||||
|
done = 0
|
||||||
|
for i in range(0, len(objs), 200):
|
||||||
|
batch = objs[i:i + 200]
|
||||||
|
ops = "&".join(
|
||||||
|
"op=/delete/" + base64.urlsafe_b64encode(
|
||||||
|
f"{bucket}:{o['key']}".encode()).decode()
|
||||||
|
for o in batch)
|
||||||
|
code, raw = call_qiniu(ak, sk, "POST", "/batch",
|
||||||
|
content_type="application/x-www-form-urlencoded",
|
||||||
|
body=ops.encode())
|
||||||
|
ok_n = code // 100 == 2
|
||||||
|
done += len(batch) if ok_n else 0
|
||||||
|
print(f" 批 {i // 200 + 1}:HTTP {code}({len(batch)} 个){'' if ok_n else raw[:160]}")
|
||||||
|
print(f"\n完成:请求删除 {len(objs)} 个,确认 {done} 个。")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
@@ -0,0 +1,256 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""
|
||||||
|
已归档任务可删(§17.2 D-9 / F-14,2026-09-30 客户确认)的 E2E。
|
||||||
|
|
||||||
|
## 背景
|
||||||
|
原口径"已发布的任务删不掉也改不了名(40001)"—— 于是演示/测试任务只能改名挪开,
|
||||||
|
任务列表只增不减。现在:**草稿直接删;已归档的显式删(级联软删 + 尽力删文件 + 留痕);
|
||||||
|
收集中/已截止仍然拒绝**(归档是"最后的后悔药")。
|
||||||
|
|
||||||
|
## 本脚本验什么
|
||||||
|
1. 收集中 → 删被拒,提示要说清"先归档"(不是一句笼统的 40001);
|
||||||
|
2. 已截止 → 仍然拒(活数据不提供删除口子);
|
||||||
|
3. 已归档 → 删成功:**子表级联物理删净**(名册/材料/覆盖班级/审核记录——大头清掉),
|
||||||
|
**任务行本身软删**(占住 `task_code` 不复用:编码是文件目录名,重号会混两批文件),
|
||||||
|
且 `sys_operation_log` 有一条 `DELETE_TASK`;
|
||||||
|
4. **删除后新建任务不重号**(编码不复用 —— 第一版做成物理删时,就是这条被
|
||||||
|
`e2e-m1-task` 的"编码递增"断言抓到的);
|
||||||
|
5. 删完再删 → 明确报错(不存在),不是 500;
|
||||||
|
6. 草稿 → 直接删(回归原行为)。
|
||||||
|
|
||||||
|
跑法:python scripts/e2e-m1-task-delete.py(后端 8080 + xt-pg 在跑)
|
||||||
|
"""
|
||||||
|
import argparse
|
||||||
|
import importlib.util
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
|
||||||
|
sys.stdout.reconfigure(encoding="utf-8")
|
||||||
|
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||||
|
BASE = "http://localhost:8080"
|
||||||
|
SUFFIX = os.urandom(3).hex()
|
||||||
|
TMP_ROSTER = os.path.join(ROOT, "scripts", "_e2e-roster-del-%s.xlsx" % SUFFIX)
|
||||||
|
|
||||||
|
PASSED = []
|
||||||
|
FAILED = []
|
||||||
|
CREATED = []
|
||||||
|
|
||||||
|
|
||||||
|
def _load(name, fn):
|
||||||
|
spec = importlib.util.spec_from_file_location(name, os.path.join(ROOT, "scripts", fn))
|
||||||
|
mod = importlib.util.module_from_spec(spec)
|
||||||
|
spec.loader.exec_module(mod)
|
||||||
|
return mod
|
||||||
|
|
||||||
|
|
||||||
|
R = _load("e2e_m2_roster", "e2e-m2-roster.py")
|
||||||
|
|
||||||
|
|
||||||
|
def ok(name):
|
||||||
|
PASSED.append(name)
|
||||||
|
print(" [OK] " + name)
|
||||||
|
|
||||||
|
|
||||||
|
def fail(name, detail):
|
||||||
|
FAILED.append((name, detail))
|
||||||
|
print(" [FAIL] " + name + "\n " + detail)
|
||||||
|
|
||||||
|
|
||||||
|
def check(name, cond, detail=""):
|
||||||
|
if cond:
|
||||||
|
ok(name)
|
||||||
|
else:
|
||||||
|
fail(name, detail or "断言不成立")
|
||||||
|
return bool(cond)
|
||||||
|
|
||||||
|
|
||||||
|
def sql(stmt):
|
||||||
|
_, out = R.sql(stmt)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def body_of(method, path, token=None, payload=None):
|
||||||
|
_, b = R.call(method, BASE + path, token, payload)
|
||||||
|
return b
|
||||||
|
|
||||||
|
|
||||||
|
def build_roster(rows):
|
||||||
|
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, "中专", "1380013770%d" % 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():
|
||||||
|
argparse.ArgumentParser(description="已归档任务删除 E2E").parse_args()
|
||||||
|
|
||||||
|
print("=" * 74)
|
||||||
|
print(" 已归档任务删除(§17.2 D-9 / F-14)E2E")
|
||||||
|
print("=" * 74)
|
||||||
|
|
||||||
|
_, adm = R.login(BASE, "admin", "Admin@123", "demo")
|
||||||
|
if adm.get("code") != 0:
|
||||||
|
print(" x 管理员登录失败:" + str(adm))
|
||||||
|
return 2
|
||||||
|
at = adm["data"]["token"]
|
||||||
|
|
||||||
|
# ── 0. 夹具:一个已发布的任务(材料 + 班级 + 名册 2 人)────────
|
||||||
|
print("\n[0] 摆夹具")
|
||||||
|
_, b = R.call("POST", BASE + "/api/v1/tasks", at, {
|
||||||
|
"taskName": "E2E删除 %s" % SUFFIX, "batchNo": "202609", "majorName": "数控加工",
|
||||||
|
"collectMode": 1, "certType": 1, "startTime": "2026-01-01", "endTime": "2099-12-31"
|
||||||
|
})
|
||||||
|
# ⚠️ 每一步都查 code:夹具不成立时越早报清楚,越好排查(F-96/F-99 的教训)
|
||||||
|
if b.get("code") != 0:
|
||||||
|
print(" x 建任务失败:" + json.dumps(b, ensure_ascii=False)[:200])
|
||||||
|
return 2
|
||||||
|
tid = b["data"]["id"]
|
||||||
|
CREATED.append(tid)
|
||||||
|
|
||||||
|
_, b = R.call("GET", BASE + "/api/v1/orgs/tree", at)
|
||||||
|
|
||||||
|
classes = []
|
||||||
|
|
||||||
|
def walk(nodes):
|
||||||
|
# 班级(orgType=3)在树的**下层**,只扫顶层会一个都找不到
|
||||||
|
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]
|
||||||
|
body_of("PUT", "/api/v1/tasks/%d/orgs" % tid, at,
|
||||||
|
{"orgs": [{"orgId": org["id"], "expectedCount": 2}]})
|
||||||
|
body_of("POST", "/api/v1/tasks/%d/materials/apply-template" % tid, at,
|
||||||
|
{"templateCode": "TPL_SCHOOL_ROLL"})
|
||||||
|
build_roster([("删除甲", R.with_checksum("36102920080909031")),
|
||||||
|
("删除乙", R.with_checksum("36102920080909032"))])
|
||||||
|
body = R.upload_roster(BASE, at, tid, TMP_ROSTER,
|
||||||
|
extra={"dryRun": "false", "mode": "APPEND", "orgId": org["id"]})
|
||||||
|
check("夹具:名册 2 人导入", body.get("code") == 0
|
||||||
|
and (body.get("data") or {}).get("created") == 2,
|
||||||
|
json.dumps(body, ensure_ascii=False)[:200])
|
||||||
|
body_of("POST", "/api/v1/tasks/%d/publish" % tid, at, {"force": True})
|
||||||
|
st = sql("select status from collect_task where id=%d;" % tid).strip()
|
||||||
|
check("夹具:任务已发布(status=1)", st == "1", "status=%s" % st)
|
||||||
|
|
||||||
|
# ── 1. 收集中:删除被拒,且提示要说清怎么办 ─────────────────
|
||||||
|
print("\n[1] 收集中 → 拒删")
|
||||||
|
d1 = body_of("DELETE", "/api/v1/tasks/%d" % tid, at)
|
||||||
|
check("收集中删除被拒", d1.get("code") != 0, json.dumps(d1, ensure_ascii=False)[:200])
|
||||||
|
check("⭐ 拒绝的话说清「先归档」(不是一句笼统的失败)",
|
||||||
|
"归档" in (d1.get("msg") or ""), "msg=%s" % d1.get("msg"))
|
||||||
|
check("任务还在(拒绝不是误删)",
|
||||||
|
sql("select count(*) from collect_task where id=%d and deleted=0;" % tid).strip() == "1")
|
||||||
|
|
||||||
|
# ── 2. 截止(status=2):仍然拒 ────────────────────────────
|
||||||
|
print("\n[2] 已截止 → 仍拒(活数据不给删除口子)")
|
||||||
|
body_of("POST", "/api/v1/tasks/%d/close" % tid, at, {})
|
||||||
|
st2 = sql("select status from collect_task where id=%d;" % tid).strip()
|
||||||
|
check("截止成功(status=2)", st2 == "2", "status=%s" % st2)
|
||||||
|
d2 = body_of("DELETE", "/api/v1/tasks/%d" % tid, at)
|
||||||
|
check("已截止仍然拒删", d2.get("code") != 0, json.dumps(d2, ensure_ascii=False)[:200])
|
||||||
|
|
||||||
|
# ── 3. 归档(status=3)→ 删除成功且级联 ────────────────────
|
||||||
|
print("\n[3] 已归档 → 删除成功(级联软删 + 留痕)")
|
||||||
|
body_of("POST", "/api/v1/tasks/%d/archive" % tid, at, {})
|
||||||
|
st3 = sql("select status from collect_task where id=%d;" % tid).strip()
|
||||||
|
check("归档成功(status=3)", st3 == "3", "status=%s" % st3)
|
||||||
|
|
||||||
|
d3 = body_of("DELETE", "/api/v1/tasks/%d" % tid, at)
|
||||||
|
check("删除成功", d3.get("code") == 0, json.dumps(d3, ensure_ascii=False)[:200])
|
||||||
|
|
||||||
|
# ★ 任务行**软删**而不是物理删:它要继续占住 task_code ——
|
||||||
|
# maxSeqByCodePrefix 靠"含软删行的最大序号"保证编码不复用(编码是文件目录名)。
|
||||||
|
# (第一版做成了物理删,e2e-m1-task 的"编码递增"断言当场抓到重号 —— 这就是那条回归)
|
||||||
|
check("⭐ 任务行软删(deleted=1,占住编码不复用;子表才是物理删)",
|
||||||
|
sql("select deleted from collect_task where id=%d;" % tid).strip() == "1")
|
||||||
|
check("⭐ 名册级联删净(含证件号密文的学生数据不留残留)",
|
||||||
|
sql("select count(*) from collect_roster where task_id=%d;" % tid).strip() == "0",
|
||||||
|
"残留=%s" % sql("select count(*) from collect_roster where task_id=%d;" % tid).strip())
|
||||||
|
check("⭐ 材料清单级联删净",
|
||||||
|
sql("select count(*) from collect_material_def where task_id=%d;" % tid).strip() == "0")
|
||||||
|
check("⭐ 覆盖班级级联删净",
|
||||||
|
sql("select count(*) from collect_task_org where task_id=%d;" % tid).strip() == "0")
|
||||||
|
check("⭐ 审核记录也一并删净(这些表没有 deleted 列,物理删;留痕在操作日志)",
|
||||||
|
sql("select count(*) from collect_audit_record where task_id=%d;" % tid).strip() == "0")
|
||||||
|
log_row = sql("select count(*) from sys_operation_log where action='DELETE_TASK' "
|
||||||
|
"and target_id=%d;" % tid).strip()
|
||||||
|
check("⭐ 留痕:sys_operation_log 有 DELETE_TASK(谁删的、删了多少,要答得出来)",
|
||||||
|
log_row >= "1", "行数=%s" % log_row)
|
||||||
|
|
||||||
|
# ── 3b. 编码不复用(删除腾出来的号不能发给下一个任务)────────
|
||||||
|
old_code = sql("select task_code from collect_task where id=%d;" % tid).strip()
|
||||||
|
_, nb = R.call("POST", BASE + "/api/v1/tasks", at, {
|
||||||
|
"taskName": "E2E删除后新建 %s" % SUFFIX, "batchNo": "202609", "majorName": "数控加工",
|
||||||
|
"collectMode": 1, "certType": 1, "startTime": "2026-01-01", "endTime": "2026-12-31"
|
||||||
|
})
|
||||||
|
new_code = ((nb.get("data") or {}).get("taskCode") or "")
|
||||||
|
check("⭐ 删除后新建任务不重号(编码不复用 —— 它是文件目录名,重号会混两批文件)",
|
||||||
|
nb.get("code") == 0 and new_code != "" and new_code != old_code,
|
||||||
|
f"删的={old_code} 新的={new_code}")
|
||||||
|
if (nb.get("data") or {}).get("id"):
|
||||||
|
CREATED.append(nb["data"]["id"])
|
||||||
|
|
||||||
|
# ── 4. 删完再删 → 明确报错,不是 500 ──────────────────────
|
||||||
|
print("\n[4] 删完再删")
|
||||||
|
d4 = body_of("DELETE", "/api/v1/tasks/%d" % tid, at)
|
||||||
|
check("再删 → 明确报错(不存在/已删)", d4.get("code") != 0,
|
||||||
|
json.dumps(d4, ensure_ascii=False)[:200])
|
||||||
|
check("报的不是系统错误(50000)", d4.get("code") != 50000,
|
||||||
|
"code=%s" % d4.get("code"))
|
||||||
|
|
||||||
|
# ── 5. 草稿:直接删(回归原行为)─────────────────────────
|
||||||
|
print("\n[5] 草稿 → 直接删")
|
||||||
|
_, b5 = R.call("POST", BASE + "/api/v1/tasks", at, {
|
||||||
|
"taskName": "E2E删除草稿 %s" % SUFFIX, "batchNo": "202609", "majorName": "数控加工",
|
||||||
|
"collectMode": 1, "certType": 1, "startTime": "2026-01-01", "endTime": "2026-12-31"
|
||||||
|
})
|
||||||
|
did = (b5.get("data") or {}).get("id")
|
||||||
|
check("草稿建好", did is not None, json.dumps(b5, ensure_ascii=False)[:160])
|
||||||
|
d5 = body_of("DELETE", "/api/v1/tasks/%d" % did, at)
|
||||||
|
check("草稿直接删成功", d5.get("code") == 0, json.dumps(d5, ensure_ascii=False)[:160])
|
||||||
|
|
||||||
|
cleanup()
|
||||||
|
|
||||||
|
print("\n" + "=" * 74)
|
||||||
|
print(" 通过 %d 失败 %d 共 %d" % (len(PASSED), len(FAILED),
|
||||||
|
len(PASSED) + len(FAILED)))
|
||||||
|
print("=" * 74)
|
||||||
|
return 1 if FAILED else 0
|
||||||
|
|
||||||
|
|
||||||
|
def cleanup():
|
||||||
|
# 正常路径已经把任务删干净了;这里兜底失败/中断的残留
|
||||||
|
for tid in CREATED:
|
||||||
|
for tbl in ("collect_validation_issue", "collect_audit_record", "collect_notify_record",
|
||||||
|
"collect_file", "collect_submission", "collect_roster",
|
||||||
|
"collect_task_org", "collect_material_def"):
|
||||||
|
R.sql("delete from %s where task_id=%d;" % (tbl, tid))
|
||||||
|
R.sql("delete from collect_task where id=%d;" % tid)
|
||||||
|
if CREATED:
|
||||||
|
R.sql("delete from sys_operation_log where action='DELETE_TASK' "
|
||||||
|
"and target_id in (%s);" % ",".join(str(t) for t in CREATED))
|
||||||
|
if os.path.exists(TMP_ROSTER):
|
||||||
|
os.remove(TMP_ROSTER)
|
||||||
|
print(" 已清理:任务与级联行 / 操作日志 / 临时 Excel")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
@@ -436,7 +436,10 @@ def main() -> int:
|
|||||||
f"code={body.get('code')} msg={body.get('msg')}")
|
f"code={body.get('code')} msg={body.get('msg')}")
|
||||||
|
|
||||||
status, body = call("DELETE", f"{base}/api/v1/tasks/{task_id}", token)
|
status, body = call("DELETE", f"{base}/api/v1/tasks/{task_id}", token)
|
||||||
check("已归档不可删除", body.get("code") != 0, f"code={body.get('code')}")
|
# 2026-09-30 客户确认(§17.2 D-9):已归档的任务**可以**删除(子表级联物理删、
|
||||||
|
# 任务行软删占住编码)。旧断言"已归档不可删除"随旧口径一起作废。
|
||||||
|
check("已归档可以删除(2026-09-30 起,级联清理 + 留痕)", body.get("code") == 0,
|
||||||
|
f"code={body.get('code')} msg={body.get('msg')}")
|
||||||
|
|
||||||
# ── 8. 草稿路径与删除 ───────────────────────────────
|
# ── 8. 草稿路径与删除 ───────────────────────────────
|
||||||
print("\n[8] 草稿路径与删除")
|
print("\n[8] 草稿路径与删除")
|
||||||
|
|||||||
@@ -192,6 +192,15 @@ def main() -> int:
|
|||||||
f"WHERE ur.user_id = {uid1.strip()} AND r.role_code = 'STUDENT'")
|
f"WHERE ur.user_id = {uid1.strip()} AND r.role_code = 'STUDENT'")
|
||||||
check("已授予学生角色(用户类型由角色推导)", okk4 and role.strip() == "1", f"{role}")
|
check("已授予学生角色(用户类型由角色推导)", okk4 and role.strip() == "1", f"{role}")
|
||||||
|
|
||||||
|
# ── 4b. 登录留痕(2026-09-30 补上,F-31)────────────────
|
||||||
|
# 此前只有 PC 账号密码登录写 sys_login_log,学生从微信进来完全无痕 ——
|
||||||
|
# 按 §13.4 查"学生登录记录"永远是空的,合规上是个缺口
|
||||||
|
okk5, lls = sql("SELECT count(*) FROM sys_login_log "
|
||||||
|
f"WHERE user_id = {uid1.strip()} AND login_type = 2 AND success = 1")
|
||||||
|
check("⭐ 微信登录也写 sys_login_log(login_type=2)",
|
||||||
|
okk5 and int(lls.strip()) >= 2,
|
||||||
|
f"行数={lls.strip()}(本脚本已做两次 stub-login,应各留一条)")
|
||||||
|
|
||||||
# ── 5. 没有 scene 时不猜租户 ─────────────────────────
|
# ── 5. 没有 scene 时不猜租户 ─────────────────────────
|
||||||
print("\n[5] 没带 scene 时")
|
print("\n[5] 没带 scene 时")
|
||||||
code3, headers3 = redirect_of(f"{base}/api/v1/mp/h5/stub-login?openid={openid}")
|
code3, headers3 = redirect_of(f"{base}/api/v1/mp/h5/stub-login?openid={openid}")
|
||||||
@@ -219,8 +228,9 @@ def main() -> int:
|
|||||||
print("\n[7] 清理测试数据")
|
print("\n[7] 清理测试数据")
|
||||||
for uid in CREATED_USER_IDS:
|
for uid in CREATED_USER_IDS:
|
||||||
sql(f"DELETE FROM sys_user_role WHERE user_id = {uid}")
|
sql(f"DELETE FROM sys_user_role WHERE user_id = {uid}")
|
||||||
|
sql(f"DELETE FROM sys_login_log WHERE user_id = {uid}")
|
||||||
sql(f"DELETE FROM sys_user WHERE id = {uid}")
|
sql(f"DELETE FROM sys_user WHERE id = {uid}")
|
||||||
ok(f"已清理 {len(CREATED_USER_IDS)} 个测试账号")
|
ok(f"已清理 {len(CREATED_USER_IDS)} 个测试账号(含其登录日志)")
|
||||||
|
|
||||||
print("\n" + "=" * 72)
|
print("\n" + "=" * 72)
|
||||||
print(f"通过 {len(PASSED)} 失败 {len(FAILED)} 共 {len(PASSED) + len(FAILED)}")
|
print(f"通过 {len(PASSED)} 失败 {len(FAILED)} 共 {len(PASSED) + len(FAILED)}")
|
||||||
|
|||||||
@@ -0,0 +1,527 @@
|
|||||||
|
# -*- 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] = []
|
||||||
|
CREATED_TPL_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])
|
||||||
|
if tpl.get("id"):
|
||||||
|
CREATED_TPL_IDS.append(tpl["id"])
|
||||||
|
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};")
|
||||||
|
# 模板的覆盖行:**只删本脚本建的那几行**(删掉 = 恢复内置)。
|
||||||
|
# ⚠️ 不能写成 `delete from collect_notify_template where tenant_id=1` ——
|
||||||
|
# 那会把演示态里别人改过的模板一起删掉(而"演示态不能被脚本弄坏"是这个仓库的硬约束)。
|
||||||
|
for tid in CREATED_TPL_IDS:
|
||||||
|
R.sql(f"delete from collect_notify_template where id={tid};")
|
||||||
|
# 本脚本造的学生/申请人/班主任
|
||||||
|
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())
|
||||||
@@ -344,6 +344,31 @@ def main() -> int:
|
|||||||
|
|
||||||
# ── 9. 边界与权限 ──
|
# ── 9. 边界与权限 ──
|
||||||
print("\n[9] 权限与边界")
|
print("\n[9] 权限与边界")
|
||||||
|
|
||||||
|
# ── 9a. 分钟级限流(§3「催报 10 次/分钟/任务」,2026-09-30 补上,F-29)──
|
||||||
|
# 先清计数再连打 11 次:断言必须**确定**第 11 次是 42901,
|
||||||
|
# 而"前面已经打了几次"取决于本脚本之前的用例 —— 靠清零把变量拿掉
|
||||||
|
print(" [9a] 分钟级限流(10 次/分钟/任务)")
|
||||||
|
os.system('docker exec xt-redis redis-cli -a redis_dev_pwd --no-auth-warning '
|
||||||
|
'DEL xt:urge:task:%d > NUL 2>&1' % task_id)
|
||||||
|
got_limit = None
|
||||||
|
for i in range(11):
|
||||||
|
rl = body_of("POST", f"/api/v1/mp/teacher/notify/unsubmitted", t1,
|
||||||
|
{"orgIds": [org_a["id"]], "taskId": task_id, "excludeRecentHours": 0})
|
||||||
|
if rl.get("code") == 42901:
|
||||||
|
got_limit = i + 1
|
||||||
|
break
|
||||||
|
check("⭐ 连点到第 11 次被限流(42901,而不是白跑 11 次全表查询)",
|
||||||
|
got_limit == 11, "第 %s 次触发(应为 11,前 10 次都要正常)" % got_limit)
|
||||||
|
check("限流的话说清等多久",
|
||||||
|
"频繁" in (body_of("POST", f"/api/v1/mp/teacher/notify/unsubmitted", t1,
|
||||||
|
{"orgIds": [org_a["id"]], "taskId": task_id,
|
||||||
|
"excludeRecentHours": 0}).get("msg") or ""),
|
||||||
|
"(再打一次仍应是 42901)")
|
||||||
|
# 清掉计数,别影响后续/重复运行
|
||||||
|
os.system('docker exec xt-redis redis-cli -a redis_dev_pwd --no-auth-warning '
|
||||||
|
'DEL xt:urge:task:%d > NUL 2>&1' % task_id)
|
||||||
|
|
||||||
stok = mp(S1)
|
stok = mp(S1)
|
||||||
b = body_of("POST", f"/api/v1/mp/teacher/notify/unsubmitted", stok, {"taskId": task_id})
|
b = body_of("POST", f"/api/v1/mp/teacher/notify/unsubmitted", stok, {"taskId": task_id})
|
||||||
check("学生不能催报 → 403", b.get("code") in (40101, 40301, 403),
|
check("学生不能催报 → 403", b.get("code") in (40101, 40301, 403),
|
||||||
|
|||||||
+21
-10
@@ -292,11 +292,21 @@ def main():
|
|||||||
# ── 4. 审核超时提醒 ──
|
# ── 4. 审核超时提醒 ──
|
||||||
print("\n[4] 审核超时提醒(时效与 §11.6 同口径)")
|
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"})
|
body_of("POST", "/api/v1/schedules/run", ops_token, {"job": "timeout"})
|
||||||
check("⭐ 刚提交的**不该**被提醒(否则每小时骚扰一次)",
|
check("⭐ 刚提交的**不该**被提醒(否则每小时骚扰一次)",
|
||||||
sql("select count(*) from collect_message where biz_type=2 "
|
timeout_msgs_to(STU2) == "0",
|
||||||
"and title like '%%审核超时%%';") == "0",
|
"乙收到的审核超时提醒 = " + timeout_msgs_to(STU2))
|
||||||
sql("select count(*) from collect_message where biz_type=2;"))
|
|
||||||
# 把提交时间改到 5 天前(> 48 小时)→ 应该提醒
|
# 把提交时间改到 5 天前(> 48 小时)→ 应该提醒
|
||||||
st = one("select status from collect_submission where task_id=%d and submitter_id=%d;"
|
st = one("select status from collect_submission where task_id=%d and submitter_id=%d;"
|
||||||
% (urge_task, STU2))
|
% (urge_task, STU2))
|
||||||
@@ -304,20 +314,21 @@ def main():
|
|||||||
"submission.status = " + st)
|
"submission.status = " + st)
|
||||||
R.sql("update collect_submission set submit_time = NOW() - INTERVAL '5 days' "
|
R.sql("update collect_submission set submit_time = NOW() - INTERVAL '5 days' "
|
||||||
"where task_id=%d and submitter_id=%d;" % (urge_task, STU2))
|
"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"})
|
body_of("POST", "/api/v1/schedules/run", ops_token, {"job": "timeout"})
|
||||||
n = sql("select count(*) from collect_message where biz_type=2 "
|
n = sql("select count(*) from collect_message where biz_type=2 "
|
||||||
"and title like '%%审核超时%%';")
|
"and title like '%%审核超时%%' and content like '%%E2E 自动催报%%';")
|
||||||
check("⭐ 超时 5 天的被提醒了", n != "0", "提醒条数 = " + n)
|
check("⭐ 超时 5 天的被提醒了", n != "0", "本次夹具的提醒条数 = " + n)
|
||||||
check("⭐ 文案里带**等了多久**与**时效是多少**(审核员才知道超了多少)",
|
check("⭐ 文案里带**等了多久**与**时效是多少**(审核员才知道超了多少)",
|
||||||
"48" in sql("select content from collect_message where biz_type=2 "
|
"48" in sql("select content from collect_message where biz_type=2 "
|
||||||
"and title like '%%审核超时%%' order by id desc limit 1;"),
|
"and title like '%%审核超时%%' and content like '%%E2E 自动催报%%' "
|
||||||
sql("select content from collect_message where biz_type=2 order by id desc limit 1;"))
|
"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("⭐ 提醒只发给**该动手的人**(班里没配负责人时不该发给所有人)",
|
check("⭐ 提醒只发给**该动手的人**(班里没配负责人时不该发给所有人)",
|
||||||
sql("select count(distinct user_id) from collect_message where biz_type=2 "
|
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 "
|
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. 原始件清理 ──
|
# ── 5. 原始件清理 ──
|
||||||
print("\n[5] 原始件清理(raw_expire_time 到期)")
|
print("\n[5] 原始件清理(raw_expire_time 到期)")
|
||||||
|
|||||||
@@ -262,12 +262,52 @@ def main():
|
|||||||
check("⭐ 模板的 useCount 被 +1(用户能看出哪个模板常用)",
|
check("⭐ 模板的 useCount 被 +1(用户能看出哪个模板常用)",
|
||||||
use_after == use_before + 1, "前 %d → 后 %d" % (use_before, use_after))
|
use_after == use_before + 1, "前 %d → 后 %d" % (use_before, use_after))
|
||||||
|
|
||||||
# 只应用 FORM:应该 skipped 且说明原因
|
# 只应用 FORM(公共模板没带表单定义):应该明确 skipped 且说明原因
|
||||||
ap2 = body_of("POST", "/api/v1/rule-templates/%d/apply" % tpl_id, at,
|
ap2 = body_of("POST", "/api/v1/rule-templates/%d/apply" % tpl_id, at,
|
||||||
{"targetTaskId": tgt, "parts": ["FORM"]})
|
{"targetTaskId": tgt, "parts": ["FORM"]})
|
||||||
check("⭐ 只应用 FORM 时明确 skipped + 原因(公共模板的 form_schema 为空)",
|
check("⭐ 只应用 FORM 时明确 skipped + 原因(公共模板的 form_schema 为空)",
|
||||||
len((ap2.get("data") or {}).get("skipped") or []) >= 1,
|
len((ap2.get("data") or {}).get("skipped") or []) >= 1,
|
||||||
json.dumps(ap2.get("data"), ensure_ascii=False)[:300])
|
json.dumps(ap2.get("data"), ensure_ascii=False)[:300])
|
||||||
|
|
||||||
|
# ── FORM 的完整链路(2026-09-30 接上,F-64:此前只能"跳过并说明",模板的 FORM 部分是装饰)──
|
||||||
|
print(" FORM 完整链路:存表单 → 存模板(带表单)→ 套用到另一个草稿")
|
||||||
|
fs = body_of("GET", "/api/v1/tasks/%d/form-schema" % tgt, at).get("data") or {}
|
||||||
|
save_fs = body_of("PUT", "/api/v1/tasks/%d/form-schema" % tgt, at, {
|
||||||
|
"baseVersion": fs.get("version"),
|
||||||
|
"fields": fs.get("fields") or [],
|
||||||
|
# ⚠️ 扩展字段的 key 必须以 extra_ 开头(服务端规则,写错会 40001)
|
||||||
|
"extras": [{"key": "extra_e2e", "label": "E2E扩展字段", "type": "text"}],
|
||||||
|
})
|
||||||
|
check("⭐ 给草稿任务存一份表单(v1,带一个扩展字段)",
|
||||||
|
save_fs.get("code") == 0 and (save_fs.get("data") or {}).get("version") == 1,
|
||||||
|
json.dumps(save_fs, ensure_ascii=False)[:200])
|
||||||
|
tpl2 = body_of("POST", "/api/v1/tasks/%d/save-as-template" % tgt, at,
|
||||||
|
{"templateName": "E2E表单模板" + suffix})
|
||||||
|
check("存为模板成功", tpl2.get("code") == 0, json.dumps(tpl2, ensure_ascii=False)[:160])
|
||||||
|
tpl2_id = ((tpl2.get("data") or {}).get("templateId"))
|
||||||
|
has_fs = sql("select (form_schema is not null)::text from collect_rule_template where id=%s;"
|
||||||
|
% tpl2_id).strip()
|
||||||
|
check("⭐ 模板把表单定义也存进去了(此前存模板不存表单,F-64 的前一半)",
|
||||||
|
has_fs == "true", "form_schema is not null = %s" % has_fs)
|
||||||
|
_, b3 = R.call("POST", BASE + "/api/v1/tasks", at, {
|
||||||
|
"taskName": "E2E套表单 " + suffix, "batchNo": "202609", "majorName": "数控加工",
|
||||||
|
"collectMode": 1, "certType": 1, "startTime": "2026-01-01", "endTime": "2026-12-31"
|
||||||
|
})
|
||||||
|
tgt2 = b3["data"]["id"]
|
||||||
|
CREATED_TASK_IDS.append(tgt2)
|
||||||
|
apf = body_of("POST", "/api/v1/rule-templates/%s/apply" % tpl2_id, at,
|
||||||
|
{"targetTaskId": tgt2, "parts": ["FORM"]})
|
||||||
|
check("⭐ FORM 部分真的应用了(不再「跳过并说明」)",
|
||||||
|
"FORM" in ((apf.get("data") or {}).get("applied") or []),
|
||||||
|
json.dumps(apf.get("data"), ensure_ascii=False)[:200])
|
||||||
|
rows2 = sql("select count(*) from collect_form_schema where task_id=%d;" % tgt2).strip()
|
||||||
|
check("⭐ 目标任务有了版本化的表单行(套用走 FormSchemaService 的版本化保存)",
|
||||||
|
rows2 >= "1", "collect_form_schema 行数=%s" % rows2)
|
||||||
|
# schema_json 是 jsonb:LIKE 之前要 ::text
|
||||||
|
got_extra = sql("select (schema_json::text like '%%extra_e2e%%')::text "
|
||||||
|
"from collect_form_schema where task_id=%d "
|
||||||
|
"order by version desc limit 1;" % tgt2).strip()
|
||||||
|
check("套过去的就是源任务那份定义(含 extra_e2e 扩展字段)", got_extra == "true", got_extra)
|
||||||
bad_parts = body_of("POST", "/api/v1/rule-templates/%d/apply" % tpl_id, at,
|
bad_parts = body_of("POST", "/api/v1/rule-templates/%d/apply" % tpl_id, at,
|
||||||
{"targetTaskId": tgt, "parts": ["NOPE"]})
|
{"targetTaskId": tgt, "parts": ["NOPE"]})
|
||||||
check("parts 写错 → 明确报错并列出合法值",
|
check("parts 写错 → 明确报错并列出合法值",
|
||||||
@@ -568,9 +608,10 @@ def cleanup():
|
|||||||
R.sql("delete from collect_roster where task_id=%d;" % tid)
|
R.sql("delete from collect_roster where task_id=%d;" % tid)
|
||||||
R.sql("delete from collect_task_org where task_id=%d;" % tid)
|
R.sql("delete from collect_task_org where task_id=%d;" % tid)
|
||||||
R.sql("delete from collect_material_def where task_id=%d;" % tid)
|
R.sql("delete from collect_material_def where task_id=%d;" % tid)
|
||||||
|
R.sql("delete from collect_form_schema where task_id=%d;" % tid)
|
||||||
R.sql("delete from collect_task where id=%d;" % tid)
|
R.sql("delete from collect_task where id=%d;" % tid)
|
||||||
R.sql("delete from collect_rule_template where template_name like 'E2E自建模板%' "
|
R.sql("delete from collect_rule_template where template_name like 'E2E自建模板%' "
|
||||||
"or template_name like 'E2E公共尝试%';")
|
"or template_name like 'E2E公共尝试%' or template_name like 'E2E表单模板%';")
|
||||||
R.sql("update collect_rule_template set use_count=0 where id in (1,2);")
|
R.sql("update collect_rule_template set use_count=0 where id in (1,2);")
|
||||||
R.sql("delete from collect_message where user_id in (995111, 995112);")
|
R.sql("delete from collect_message where user_id in (995111, 995112);")
|
||||||
R.sql("delete from sys_user_role where user_id = 995101;")
|
R.sql("delete from sys_user_role where user_id = 995101;")
|
||||||
|
|||||||
@@ -472,6 +472,26 @@ def main():
|
|||||||
rp2 = open_call("POST", "/api/v1/open/v1/callback/push", secret=secret, body={})
|
rp2 = open_call("POST", "/api/v1/open/v1/callback/push", secret=secret, body={})
|
||||||
check("人工重推不传 submissionId → 明确报错", (rp2[1] or {}).get("code") != 0,
|
check("人工重推不传 submissionId → 明确报错", (rp2[1] or {}).get("code") != 0,
|
||||||
json.dumps(rp2[1], ensure_ascii=False)[:200])
|
json.dumps(rp2[1], ensure_ascii=False)[:200])
|
||||||
|
|
||||||
|
# ⭐ 定时扫描**真的会重试**(F-112,2026-10-09 修)
|
||||||
|
#
|
||||||
|
# 这一条与上面那条"安排了下次重试"是**两件事**:上面断言的是 next_retry_at 被写上,
|
||||||
|
# 而这里断言的是到点之后**真的有人来重试**。
|
||||||
|
#
|
||||||
|
# 修之前:`OpenPushLogMapper.dueRetries()` 少了 @InterceptorIgnore,定时任务没有租户上下文
|
||||||
|
# → 每次扫描抛 40101 → 被 retryDue 的 catch 吞成一行 warn。于是"安排了"永远不等于"发生了",
|
||||||
|
# 而 E2E 只断言了前者,所以 60/60 全绿也照样漏掉了这个 bug。
|
||||||
|
#
|
||||||
|
# 等待时间 = 定时任务间隔(fixedDelay 10s)+ 首次退避(1s)+ 余量
|
||||||
|
attempts_before = int(sql("select attempts from collect_open_push_log "
|
||||||
|
"where submission_id=%d order by id desc limit 1;" % sid) or 0)
|
||||||
|
time.sleep(16)
|
||||||
|
attempts_after = int(sql("select attempts from collect_open_push_log "
|
||||||
|
"where submission_id=%d order by id desc limit 1;" % sid) or 0)
|
||||||
|
check("⭐ 定时扫描真的重试了:attempts 增加(不是只安排了下次重试)",
|
||||||
|
attempts_after > attempts_before,
|
||||||
|
"attempts %d → %d(16 秒内至少应被扫描到一次)" % (attempts_before, attempts_after))
|
||||||
|
|
||||||
check("⭐ 推送报文里没有明文证件号(与 §14.2 同口径)",
|
check("⭐ 推送报文里没有明文证件号(与 §14.2 同口径)",
|
||||||
"身份证" not in (sql("select coalesce(payload_json,'') from collect_open_push_log "
|
"身份证" not in (sql("select coalesce(payload_json,'') from collect_open_push_log "
|
||||||
"where submission_id=%d limit 1;" % sid) or "")
|
"where submission_id=%d limit 1;" % sid) or "")
|
||||||
|
|||||||
+522
-22
@@ -44,7 +44,13 @@ import uuid
|
|||||||
sys.stdout.reconfigure(encoding="utf-8")
|
sys.stdout.reconfigure(encoding="utf-8")
|
||||||
|
|
||||||
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||||
|
# 默认打 dev 后端;`--base` 可改(例如为了量上传而在 18080 另起一个
|
||||||
|
# `--fill.storage.provider=local` 的实例,避免把测试文件灌进真实对象存储)
|
||||||
BASE = "http://localhost:8080"
|
BASE = "http://localhost:8080"
|
||||||
|
# 演示班主任(scripts/dev-seed-audit.py 摆出来的)—— 一期初审恒为开启,
|
||||||
|
# 所以"审批闭环"这类场景必须有它
|
||||||
|
TEACHER_USER = "demot1"
|
||||||
|
TEACHER_PWD = "Demo1234"
|
||||||
SUFFIX = uuid.uuid4().hex[:6]
|
SUFFIX = uuid.uuid4().hex[:6]
|
||||||
TMP_ROSTER = os.path.join(ROOT, "scripts", f"_lt-roster-{SUFFIX}.xlsx")
|
TMP_ROSTER = os.path.join(ROOT, "scripts", f"_lt-roster-{SUFFIX}.xlsx")
|
||||||
TMP_IMG = os.path.join(ROOT, "scripts", f"_lt-img-{SUFFIX}.jpg")
|
TMP_IMG = os.path.join(ROOT, "scripts", f"_lt-img-{SUFFIX}.jpg")
|
||||||
@@ -141,7 +147,28 @@ def timed(fn):
|
|||||||
|
|
||||||
# ══════════════ 夹具 ══════════════
|
# ══════════════ 夹具 ══════════════
|
||||||
|
|
||||||
def build_fixture(admin):
|
def uid_of(token):
|
||||||
|
"""
|
||||||
|
从自己的 token 里取 uid(`sub`)。
|
||||||
|
|
||||||
|
⚠️ **不要按 username 查库**:库里同一个 username 在每个租户都有一行
|
||||||
|
(`admin` 有 50 多个,`select id from sys_user where username='admin'` 会随机命中
|
||||||
|
别人家的账号)。我第一版就是这么写的,于是把"初审人"设成了一个跨租户的用户 id。
|
||||||
|
"""
|
||||||
|
p = token.split(".")[1]
|
||||||
|
p += "=" * (-len(p) % 4)
|
||||||
|
return int(json.loads(base64.urlsafe_b64decode(p))["sub"])
|
||||||
|
|
||||||
|
|
||||||
|
def call_ok(method, path, token, body=None, what="", timeout=60):
|
||||||
|
"""打一个接口并**断言业务码为 0**(不查返回码的调用是 F-96 里最贵的那一个)。"""
|
||||||
|
c, b = call(method, path, token, body, timeout=timeout)
|
||||||
|
if not (isinstance(b, dict) and b.get("code") == 0):
|
||||||
|
raise SystemExit("夹具失败:%s → %s" % (what or path, json.dumps(b, ensure_ascii=False)[:200]))
|
||||||
|
return b.get("data")
|
||||||
|
|
||||||
|
|
||||||
|
def build_fixture(admin, teacher):
|
||||||
"""造一个任务 + N 个名册人(每人一个已知证件号)+ 对应 uid 的 MP token。"""
|
"""造一个任务 + N 个名册人(每人一个已知证件号)+ 对应 uid 的 MP token。"""
|
||||||
_, orgs = call("GET", "/api/v1/orgs/tree", admin)
|
_, orgs = call("GET", "/api/v1/orgs/tree", admin)
|
||||||
classes = []
|
classes = []
|
||||||
@@ -161,17 +188,32 @@ def build_fixture(admin):
|
|||||||
"taskName": f"压测任务 {SUFFIX}", "batchNo": "202609", "majorName": "数控加工",
|
"taskName": f"压测任务 {SUFFIX}", "batchNo": "202609", "majorName": "数控加工",
|
||||||
"collectMode": 1, "certType": 1,
|
"collectMode": 1, "certType": 1,
|
||||||
"startTime": "2026-01-01", "endTime": "2099-12-31",
|
"startTime": "2026-01-01", "endTime": "2099-12-31",
|
||||||
|
# ⚠️ **只走终审**(needClassAudit=false)是刻意的:初审的判据是"这份单在你负责的
|
||||||
|
# 班级里"(AuditService.auditDenyReason),而夹具的名册导进的是组织树里第一个班,
|
||||||
|
# admin 并不是那个班的班主任 —— 于是逐份通过会整齐地报 40301「不在你负责的班级里」。
|
||||||
|
# 要按真实两段审核走,就得再造一个"带班级范围的班主任"账号;
|
||||||
|
# 而导出这条路与"谁审的"无关,所以这里选便宜且不引入额外夹具的那条。
|
||||||
|
"auditConfig": {"needClassAudit": False, "needAdminAudit": True},
|
||||||
})
|
})
|
||||||
tid = b["data"]["id"]
|
tid = b["data"]["id"]
|
||||||
TASK_IDS.append(tid)
|
TASK_IDS.append(tid)
|
||||||
|
|
||||||
call("POST", f"/api/v1/tasks/{tid}/materials", admin, {
|
call("POST", f"/api/v1/tasks/{tid}/materials", admin, {
|
||||||
"materialCode": "IDCARD", "materialName": "身份证", "required": True,
|
"materialCode": "IDCARD", "materialName": "身份证", "required": True,
|
||||||
"fileTypes": ["jpg", "jpeg", "png"], "maxSizeKb": 2048, "maxCount": 3,
|
"fileTypes": ["jpg", "jpeg", "png"], "maxSizeKb": 4096, "maxCount": 3,
|
||||||
"minSizeKb": 1, "minLongSide": 100,
|
"minSizeKb": 1, "minLongSide": 100,
|
||||||
})
|
})
|
||||||
call("PUT", f"/api/v1/tasks/{tid}/orgs", admin,
|
# ⚠️ **必须指定 headUserId**:初审的判据是"这个班归不归我审"
|
||||||
{"orgs": [{"orgId": org["id"], "expectedCount": GATE_USERS}]})
|
# (`AuditScopeMapper.countMyOrg` 三级回退:head_user_id → deputy → 班级负责人),
|
||||||
|
# 而夹具的名册导进的是组织树里第一个班 —— 不指定的话,逐份通过会整齐地报
|
||||||
|
# 40301「这份填报单不在你负责的班级里」。
|
||||||
|
# 也**不能**靠"关掉初审"绕过:一期 `withAuditDefaults` 把 needClassAudit 强制为 true
|
||||||
|
# (契约 §3.2「社会收集恒为 false,一期不启用社会收集」),传 false 会被扳回来。
|
||||||
|
# 指定的人还得**真有班主任角色**,否则这次 PUT 会被服务端拒(我第一版就是被它拒的,
|
||||||
|
# 而当时没查返回码,表现成很远的地方"任务已截止"—— 40008)。
|
||||||
|
call_ok("PUT", f"/api/v1/tasks/{tid}/orgs", admin,
|
||||||
|
{"orgs": [{"orgId": org["id"], "expectedCount": GATE_USERS,
|
||||||
|
"headUserId": uid_of(teacher)}]}, "设置覆盖班级与初审人")
|
||||||
|
|
||||||
# N 个证件号(前 17 位递增,校验位由脚本补)
|
# N 个证件号(前 17 位递增,校验位由脚本补)
|
||||||
rows = []
|
rows = []
|
||||||
@@ -189,9 +231,16 @@ def build_fixture(admin):
|
|||||||
seq = "%03d" % (i % 1000)
|
seq = "%03d" % (i % 1000)
|
||||||
idc = R.with_checksum(area + "20080101" + seq)
|
idc = R.with_checksum(area + "20080101" + seq)
|
||||||
rows.append((f"压测{i:04d}", idc))
|
rows.append((f"压测{i:04d}", idc))
|
||||||
|
t_excel = time.perf_counter()
|
||||||
build_roster(rows)
|
build_roster(rows)
|
||||||
|
excel_s = time.perf_counter() - t_excel
|
||||||
|
t_imp = time.perf_counter()
|
||||||
body = R.upload_roster(BASE, admin, tid, TMP_ROSTER,
|
body = R.upload_roster(BASE, admin, tid, TMP_ROSTER,
|
||||||
extra={"dryRun": "false", "mode": "APPEND", "orgId": org["id"]})
|
extra={"dryRun": "false", "mode": "APPEND", "orgId": org["id"]})
|
||||||
|
imp_s = time.perf_counter() - t_imp
|
||||||
|
# ⚠️ 两段分开报:**造 Excel 是压测脚本自己的成本,不是系统的成本** ——
|
||||||
|
# 混在一起会把 openpyxl 的耗时算到"名册导入"头上(度量工具的偏差伪装成产品缺陷)
|
||||||
|
print(" 夹具计时:造 Excel %.1fs 导入接口 %.1fs(%d 行)" % (excel_s, imp_s, len(rows)))
|
||||||
# ⚠️ **判据是 data.created,不是 code**:导入了 0 行也是 code=0
|
# ⚠️ **判据是 data.created,不是 code**:导入了 0 行也是 code=0
|
||||||
# (体检报告在 data 里)。判错的话压测会"没有样本",
|
# (体检报告在 data 里)。判错的话压测会"没有样本",
|
||||||
# 而那个现象看起来像压测脚本坏了 —— 与 F-84 是同一个坑
|
# 而那个现象看起来像压测脚本坏了 —— 与 F-84 是同一个坑
|
||||||
@@ -212,7 +261,9 @@ def build_fixture(admin):
|
|||||||
if len(roster_rows) != GATE_USERS:
|
if len(roster_rows) != GATE_USERS:
|
||||||
raise SystemExit("夹具名册行数不对:期望 %d,实际 %d"
|
raise SystemExit("夹具名册行数不对:期望 %d,实际 %d"
|
||||||
% (GATE_USERS, len(roster_rows)))
|
% (GATE_USERS, len(roster_rows)))
|
||||||
call("POST", f"/api/v1/tasks/{tid}/publish", admin, {"force": True})
|
# ⚠️ 发布**必须查返回码**:它失败时任务还是草稿,而后续所有学生接口都报
|
||||||
|
# `40008 任务已截止、已归档或已暂停` —— 排查会一路查错方向(我踩过)
|
||||||
|
call_ok("POST", f"/api/v1/tasks/{tid}/publish", admin, {"force": True}, "发布任务")
|
||||||
cfg = R.read_dev_config()
|
cfg = R.read_dev_config()
|
||||||
tokens = []
|
tokens = []
|
||||||
for i, r in enumerate(roster_rows):
|
for i, r in enumerate(roster_rows):
|
||||||
@@ -245,15 +296,36 @@ def build_roster(rows):
|
|||||||
wb.save(TMP_ROSTER)
|
wb.save(TMP_ROSTER)
|
||||||
|
|
||||||
|
|
||||||
def make_img(size_kb=400):
|
def make_img(size_kb=400, px=(2000, 1500)):
|
||||||
"""造一张尽量接近真实照片的图(纯色会被压得很小,失真)。"""
|
"""
|
||||||
|
造一张**真实**的 JPEG(Pillow 噪声图)。
|
||||||
|
|
||||||
|
⚠️ 原来是"JPEG 头 + 随机字节"的假图。它能过扩展名与大小校验,
|
||||||
|
但**服务端做的根本不是同一件事**:真图要解码 → 按 EXIF 转正 → 重编码,
|
||||||
|
假图在解码那一步就失败了(然后走的是"压缩失败"的分支)。
|
||||||
|
用压测去回答"压缩是 CPU 密集",却喂它一个不需要压缩的文件 ——
|
||||||
|
量到的不是压缩成本,而是错误处理的成本。**这类"夹具不对"的红/绿都不算数。**
|
||||||
|
|
||||||
|
噪声图是有意选的:纯色图会被压到极小,量不出 CPU 成本
|
||||||
|
(与 e2e-m9-thumb.py 里那条"纯色会被压得很小、失真"同一个理由)。
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
from PIL import Image
|
||||||
|
import random
|
||||||
|
random.seed(7)
|
||||||
|
img = Image.new("RGB", px)
|
||||||
|
img.putdata([(random.randrange(256), random.randrange(256), random.randrange(256))
|
||||||
|
for _ in range(px[0] * px[1])])
|
||||||
|
img.save(TMP_IMG, "JPEG", quality=92)
|
||||||
|
return os.path.getsize(TMP_IMG)
|
||||||
|
except ImportError:
|
||||||
import random
|
import random
|
||||||
random.seed(7)
|
random.seed(7)
|
||||||
# 最小合法 JPEG 头 + 随机填充(不是真 JPEG,但上传接口只看扩展名与大小)
|
|
||||||
with open(TMP_IMG, "wb") as f:
|
with open(TMP_IMG, "wb") as f:
|
||||||
f.write(b"\xff\xd8\xff\xe0\x00\x10JFIF\x00\x01\x01\x00\x00\x01\x00\x01\x00\x00")
|
f.write(b"\xff\xd8\xff\xe0\x00\x10JFIF\x00\x01\x01\x00\x00\x01\x00\x01\x00\x00")
|
||||||
f.write(bytes(random.getrandbits(8) for _ in range(size_kb * 1024)))
|
f.write(bytes(random.getrandbits(8) for _ in range(size_kb * 1024)))
|
||||||
f.write(b"\xff\xd9")
|
f.write(b"\xff\xd9")
|
||||||
|
return os.path.getsize(TMP_IMG)
|
||||||
|
|
||||||
|
|
||||||
def reset_gate():
|
def reset_gate():
|
||||||
@@ -335,16 +407,57 @@ def scenario_read(tid, admin, conc, rounds):
|
|||||||
return stats
|
return stats
|
||||||
|
|
||||||
|
|
||||||
def scenario_upload(tid, tokens, conc):
|
def scenario_upload(tid, tokens, conc, px=(2000, 1500)):
|
||||||
"""上传:并发上传图片(压缩是 CPU 密集)。"""
|
"""上传:并发上传图片(压缩是 CPU 密集)。"""
|
||||||
st = Stat("材料上传")
|
st = Stat("材料上传")
|
||||||
make_img()
|
size = make_img(px=px)
|
||||||
|
print(" 夹具:真实 JPEG %d×%d / %.0f KB" % (px[0], px[1], size / 1024.0))
|
||||||
|
|
||||||
|
# ⚠️ 前置条件:**必须先把人绑到名册项上**,否则 `POST /mp/submissions` 会被拒
|
||||||
|
# ("没有草稿"),而 40 次上传会整齐地失败 —— 那不叫"上传慢",叫"夹具没成立"。
|
||||||
|
# 以前单独跑 `--only upload` 就是这个结果:全量压测里它跟在门禁后面,
|
||||||
|
# "恰好"能跑,于是这个前置依赖一直没被发现(F-96 的同一类:没断言前置就往下走)。
|
||||||
|
# 绑定逻辑与导出大包共用 `bind_users` —— 写两份必然分叉,而分叉的表现就是
|
||||||
|
# "另一个场景的前置悄悄失效"。
|
||||||
|
bound = bind_users(tid, tokens, conc)
|
||||||
|
print(" 前置:门禁绑定 %d/%d 人(上传必须先有绑定)" % (bound, len(tokens)))
|
||||||
|
if bound < conc:
|
||||||
|
raise SystemExit("上传压测的前置条件不成立:只有 %d 人绑定成功,需要 %d 人"
|
||||||
|
% (bound, conc))
|
||||||
|
|
||||||
sub_ids = {}
|
sub_ids = {}
|
||||||
# 每个用户先建草稿
|
for t in tokens[:conc + 1]: # 多要一份:串行基线占用第 0 个
|
||||||
for t in tokens[:conc]:
|
|
||||||
c, b = call("POST", "/api/v1/mp/submissions", t["token"], {"taskId": tid})
|
c, b = call("POST", "/api/v1/mp/submissions", t["token"], {"taskId": tid})
|
||||||
if c == 200 and isinstance(b, dict) and b.get("code") == 0:
|
if c == 200 and isinstance(b, dict) and b.get("code") == 0:
|
||||||
sub_ids[t["uid"]] = b["data"]["id"]
|
sub_ids[t["uid"]] = b["data"]["id"]
|
||||||
|
else:
|
||||||
|
print(" ⚠️ 建草稿失败:%s" % json.dumps(b, ensure_ascii=False)[:160])
|
||||||
|
# ⚠️ 同样要断言:建不出草稿时"40 个请求全失败"看起来像上传功能坏了
|
||||||
|
if len(sub_ids) < conc + 1:
|
||||||
|
raise SystemExit("上传压测的前置条件不成立:只建出 %d 份草稿,需要 %d 份"
|
||||||
|
% (len(sub_ids), conc + 1))
|
||||||
|
|
||||||
|
# ── 串行基线:单请求到底多贵 ──
|
||||||
|
# ⚠️ 必须有这一段。并发下的 p50/p95 里混着排队,**不能反推单请求成本**(F-94);
|
||||||
|
# 而"值不值得优化"只能由"单请求成本 vs 排队占比"回答。
|
||||||
|
warm = tokens[0]
|
||||||
|
w_ms, w_http, w_body = timed(lambda: upload_file(
|
||||||
|
"/api/v1/mp/files/upload", warm["token"], sub_ids[warm["uid"]], TMP_IMG))
|
||||||
|
# ⚠️ 判据是 body 里的业务码,**不是** upload_file 返回的第一个值 ——
|
||||||
|
# 那是 HTTP 状态码(200),拿它当"成功"判据会把成功报成失败(我自己刚踩过)
|
||||||
|
if isinstance(w_body, dict) and w_body.get("code") == 0:
|
||||||
|
# 尺寸以库里那行为准:接口回的是原始大小,规范件大小只有落库才知道
|
||||||
|
sizes = one("select raw_size || '/' || coalesce(final_size, 0) from collect_file "
|
||||||
|
"where submission_id=%d order by id desc limit 1;" % sub_ids[warm["uid"]])
|
||||||
|
try:
|
||||||
|
raw_kb, fin_kb = [int(x) / 1024.0 for x in sizes.split("/")]
|
||||||
|
ratio = (fin_kb / raw_kb * 100.0) if raw_kb else 0.0
|
||||||
|
print(" 串行基线:单请求 %.0fms 压缩 %.0fKB → %.0fKB(%.0f%%,服务端真的在压)"
|
||||||
|
% (w_ms, raw_kb, fin_kb, ratio))
|
||||||
|
except Exception: # noqa: BLE001
|
||||||
|
print(" 串行基线:单请求 %.0fms(尺寸没取到:%s)" % (w_ms, sizes))
|
||||||
|
else:
|
||||||
|
print(" 串行基线失败:HTTP %s %s" % (w_http, json.dumps(w_body, ensure_ascii=False)[:160]))
|
||||||
|
|
||||||
def one_req(t):
|
def one_req(t):
|
||||||
sid = sub_ids.get(t["uid"])
|
sid = sub_ids.get(t["uid"])
|
||||||
@@ -357,7 +470,9 @@ def scenario_upload(tid, tokens, conc):
|
|||||||
return ms, int(biz if biz is not None else code), body
|
return ms, int(biz if biz is not None else code), body
|
||||||
|
|
||||||
jobs = []
|
jobs = []
|
||||||
for t in tokens[:conc]:
|
# 并发用 tokens[1:conc+1]:第 0 个已经被串行基线用掉了,混进来会让"并发里的某个用户
|
||||||
|
# 比别人多传一次",而样本数对不上时最难判断的是"到底谁慢"
|
||||||
|
for t in tokens[1:conc + 1]:
|
||||||
for _ in range(UPLOAD_PER_USER):
|
for _ in range(UPLOAD_PER_USER):
|
||||||
jobs.append(t)
|
jobs.append(t)
|
||||||
with cf.ThreadPoolExecutor(max_workers=conc) as ex:
|
with cf.ThreadPoolExecutor(max_workers=conc) as ex:
|
||||||
@@ -400,6 +515,324 @@ def upload_file(path, token, submission_id, file_path, timeout=180):
|
|||||||
return -1, {"msg": str(e)}
|
return -1, {"msg": str(e)}
|
||||||
|
|
||||||
|
|
||||||
|
# ══════════════ 长时间稳定性 ══════════════
|
||||||
|
|
||||||
|
def base_port():
|
||||||
|
from urllib.parse import urlparse
|
||||||
|
return urlparse(BASE).port or 80
|
||||||
|
|
||||||
|
|
||||||
|
def app_pid(port):
|
||||||
|
"""监听该端口的进程 pid(没有就返回 None,不猜)。"""
|
||||||
|
try:
|
||||||
|
r = subprocess.run(["powershell", "-NoProfile", "-Command",
|
||||||
|
"(Get-NetTCPConnection -State Listen -LocalPort %d "
|
||||||
|
"-ErrorAction SilentlyContinue).OwningProcess" % port],
|
||||||
|
capture_output=True, text=True, timeout=30)
|
||||||
|
for tok in (r.stdout or "").split():
|
||||||
|
if tok.strip().isdigit():
|
||||||
|
return int(tok.strip())
|
||||||
|
except Exception: # noqa: BLE001
|
||||||
|
pass
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def rss_mb(pid):
|
||||||
|
"""
|
||||||
|
进程的物理内存(MB)。
|
||||||
|
|
||||||
|
⚠️ 从**系统**取,不从应用的指标取:应用自己报的堆用量看不到堆外内存、
|
||||||
|
元空间与线程栈 —— 而"内存慢慢涨"恰恰经常长在这些地方。
|
||||||
|
"""
|
||||||
|
if not pid:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
r = subprocess.run(["powershell", "-NoProfile", "-Command",
|
||||||
|
"(Get-Process -Id %d -ErrorAction SilentlyContinue).WorkingSet64" % pid],
|
||||||
|
capture_output=True, text=True, timeout=30)
|
||||||
|
v = (r.stdout or "").strip()
|
||||||
|
return int(v) / 1048576.0 if v.isdigit() else None
|
||||||
|
except Exception: # noqa: BLE001
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def db_conns():
|
||||||
|
"""PG 上到 filldb 的连接数 —— Hikari 连接泄漏会直接体现为它单调上升。"""
|
||||||
|
v = one("select count(*) from pg_stat_activity where datname='filldb';")
|
||||||
|
return int(v) if v.isdigit() else None
|
||||||
|
|
||||||
|
|
||||||
|
def scenario_soak(tid, admin, minutes, rps):
|
||||||
|
"""
|
||||||
|
长时间稳定性:低强度持续打,每分钟采样一行。
|
||||||
|
|
||||||
|
⚠️ 要回答的是"**有没有单调上升**",所以判据是**趋势**,不是"跑完没崩"。
|
||||||
|
泄漏的特征是"慢慢地越来越高",一次性压测永远测不出来 ——
|
||||||
|
所以这里每分钟记 RSS 与 PG 连接数,最后比较**前 1/3 与后 1/3** 的均值。
|
||||||
|
延迟趋势一起看:泄漏的另一个表现是"越跑越慢"。
|
||||||
|
"""
|
||||||
|
paths = [
|
||||||
|
("GET", "/api/v1/tasks?pageNum=1&pageSize=20", "任务列表"),
|
||||||
|
("GET", f"/api/v1/tasks/{tid}/roster?pageNum=1&pageSize=50", "名册列表"),
|
||||||
|
("GET", f"/api/v1/tasks/{tid}/statistics", "任务统计"),
|
||||||
|
("GET", "/api/v1/audit/todos?pageNum=1&pageSize=20", "审核待办"),
|
||||||
|
]
|
||||||
|
pid = app_pid(base_port())
|
||||||
|
print(" 目标后端 pid=%s(RSS 从系统取,PG 连接数从 pg_stat_activity 取)" % pid)
|
||||||
|
print(" 强度 %.1f 请求/秒 时长 %d 分钟,每分钟一行样本" % (rps, minutes))
|
||||||
|
rows = []
|
||||||
|
n = 0
|
||||||
|
t_end = time.time() + minutes * 60
|
||||||
|
while time.time() < t_end:
|
||||||
|
lat, fail = [], 0
|
||||||
|
for _ in range(max(1, int(rps * 60))):
|
||||||
|
m, p, _label = paths[n % len(paths)]
|
||||||
|
n += 1
|
||||||
|
ms, code, body = timed(lambda: call(m, p, admin))
|
||||||
|
biz = int((body or {}).get("code", code)) if isinstance(body, dict) else code
|
||||||
|
lat.append(ms)
|
||||||
|
if biz != 0:
|
||||||
|
fail += 1
|
||||||
|
# 均匀铺开:不要"一分钟里的前 10 秒打完全部请求",那测的是尖峰不是持续压力
|
||||||
|
time.sleep(max(0.0, 1.0 / rps))
|
||||||
|
s = sorted(lat)
|
||||||
|
row = {"min": len(rows) + 1, "n": len(lat), "fail": fail,
|
||||||
|
"p50": s[len(s) // 2], "p95": s[min(len(s) - 1, int(len(s) * 0.95))],
|
||||||
|
"rss": rss_mb(pid), "conns": db_conns()}
|
||||||
|
rows.append(row)
|
||||||
|
print(" %3d 分 请求 %-4d 失败 %-3d p50 %6.1fms p95 %7.1fms RSS %7s MB PG 连接 %4s"
|
||||||
|
% (row["min"], row["n"], row["fail"], row["p50"], row["p95"],
|
||||||
|
("%.0f" % row["rss"]) if row["rss"] else "—",
|
||||||
|
row["conns"] if row["conns"] is not None else "—"))
|
||||||
|
|
||||||
|
def avg(rs, key):
|
||||||
|
vals = [r[key] for r in rs if r.get(key) is not None]
|
||||||
|
return sum(vals) / len(vals) if vals else None
|
||||||
|
|
||||||
|
third = max(1, len(rows) // 3)
|
||||||
|
head, tail = rows[:third], rows[-third:]
|
||||||
|
print("\n 样本 %d 分钟 请求 %d 失败 %d(%s)"
|
||||||
|
% (len(rows), sum(r["n"] for r in rows), sum(r["fail"] for r in rows),
|
||||||
|
"强度 %.1f/s、规模 %d 行" % (rps, GATE_USERS)))
|
||||||
|
for key, unit, label, tol in (("rss", "MB", "RSS", 10.0), ("conns", "个", "PG 连接数", 20.0)):
|
||||||
|
a, b = avg(head, key), avg(tail, key)
|
||||||
|
if a is None or b is None:
|
||||||
|
print(" %-10s 样本不足,没测出来" % label)
|
||||||
|
continue
|
||||||
|
pct = ((b - a) / a * 100.0) if a else 0.0
|
||||||
|
verdict = "平稳" if abs(pct) < tol else ("**上升** ⚠️ 疑似泄漏" if b > a else "下降")
|
||||||
|
print(" %-10s 前 1/3 均值 %8.1f %s → 后 1/3 均值 %8.1f %s(%+.1f%%) %s"
|
||||||
|
% (label, a, unit, b, unit, pct, verdict))
|
||||||
|
a95, b95 = avg(head, "p95"), avg(tail, "p95")
|
||||||
|
if a95 and b95:
|
||||||
|
print(" %-10s 前 1/3 均值 %8.1f ms → 后 1/3 均值 %8.1f ms(%+.1f%%)"
|
||||||
|
% ("延迟 p95", a95, b95, (b95 - a95) / a95 * 100.0))
|
||||||
|
return rows
|
||||||
|
|
||||||
|
|
||||||
|
def bind_users(tid, tokens, workers=10):
|
||||||
|
"""
|
||||||
|
把 N 个人绑到名册项上(上传 / 提交 / 审核的**共同前置**)。
|
||||||
|
|
||||||
|
⚠️ 抽出来是有原因的:只在 `scenario_upload` 里写一份的话,新场景(导出大包)
|
||||||
|
就会忘 —— 而忘了的表现是"请求整齐地全失败",看起来像功能坏了(F-96 的同一类)。
|
||||||
|
"""
|
||||||
|
ok = 0
|
||||||
|
fail_sample = None
|
||||||
|
|
||||||
|
def one(t):
|
||||||
|
nonlocal fail_sample
|
||||||
|
c, b = call("POST", "/api/v1/mp/roster/match", t["token"],
|
||||||
|
{"taskId": tid, "realName": t["name"], "idCardNo": t["idCardNo"]})
|
||||||
|
code = b.get("code") if isinstance(b, dict) else -1
|
||||||
|
if code not in (0, 40072) and fail_sample is None: # 0 绑定成功 / 40072 已绑定
|
||||||
|
fail_sample = json.dumps(b, ensure_ascii=False)[:160]
|
||||||
|
return code
|
||||||
|
|
||||||
|
with cf.ThreadPoolExecutor(max_workers=workers) as ex:
|
||||||
|
for code in ex.map(one, tokens):
|
||||||
|
if code in (0, 40072):
|
||||||
|
ok += 1
|
||||||
|
if fail_sample is not None:
|
||||||
|
print(" ⚠️ 绑定失败样本:%s" % fail_sample)
|
||||||
|
return ok
|
||||||
|
|
||||||
|
|
||||||
|
def scenario_export_big(tid, tokens, admin, teacher, files_per_user=1, split_mb=0, conc=10):
|
||||||
|
"""
|
||||||
|
导出**大包**:造 N 份已通过的填报单,量打包墙钟、体积、分卷与产物的完整性。
|
||||||
|
|
||||||
|
这是"交付物本身"的那条链,也是唯一一条**没法用接口断言替代**的验证:
|
||||||
|
打包耗时、产物体积、分卷是否每卷都能单独解开,只有真跑一遍才知道。
|
||||||
|
"""
|
||||||
|
import zipfile
|
||||||
|
|
||||||
|
n = len(tokens)
|
||||||
|
print(" 目标:%d 人 × %d 个材料%s"
|
||||||
|
% (n, files_per_user, ",分卷 %dMB" % split_mb if split_mb else ",不分卷"))
|
||||||
|
|
||||||
|
bound = bind_users(tid, tokens, conc)
|
||||||
|
print(" ① 门禁绑定 %d/%d 人" % (bound, n))
|
||||||
|
if bound < n:
|
||||||
|
print(" ⚠️ 有 %d 人没绑上 —— 后面的上传会失败,先看失败码再判断是不是导出的问题"
|
||||||
|
% (n - bound))
|
||||||
|
|
||||||
|
sid = {}
|
||||||
|
draft_fail = None
|
||||||
|
|
||||||
|
def draft(t):
|
||||||
|
nonlocal draft_fail
|
||||||
|
c, b = call("POST", "/api/v1/mp/submissions", t["token"], {"taskId": tid})
|
||||||
|
if not (isinstance(b, dict) and b.get("code") == 0):
|
||||||
|
# ⚠️ 失败**必须打出来**:上一次它只有一句"草稿 0/150",
|
||||||
|
# 排查时完全看不出是绑定没生效、还是任务状态不对(F-96 的同一类)
|
||||||
|
if draft_fail is None:
|
||||||
|
draft_fail = json.dumps(b, ensure_ascii=False)[:200]
|
||||||
|
return t["uid"], None
|
||||||
|
return t["uid"], (b.get("data") or {}).get("id")
|
||||||
|
|
||||||
|
with cf.ThreadPoolExecutor(max_workers=conc) as ex:
|
||||||
|
for uid, s in ex.map(draft, tokens):
|
||||||
|
if s:
|
||||||
|
sid[uid] = s
|
||||||
|
print(" ② 草稿 %d/%d 份%s"
|
||||||
|
% (len(sid), n, "" if draft_fail is None else " 首个失败:%s" % draft_fail))
|
||||||
|
if len(sid) < n:
|
||||||
|
raise SystemExit("草稿没建齐(%d/%d),导出大包的夹具不成立" % (len(sid), n))
|
||||||
|
|
||||||
|
img_kb = make_img() / 1024.0
|
||||||
|
jobs = [(t, k) for t in tokens if t["uid"] in sid for k in range(files_per_user)]
|
||||||
|
|
||||||
|
def up(job):
|
||||||
|
t, _k = job
|
||||||
|
_c, b = upload_file("/api/v1/mp/files/upload", t["token"], sid[t["uid"]], TMP_IMG)
|
||||||
|
return (b or {}).get("code") if isinstance(b, dict) else -1
|
||||||
|
|
||||||
|
t_up = time.perf_counter()
|
||||||
|
up_fail = 0
|
||||||
|
with cf.ThreadPoolExecutor(max_workers=conc) as ex:
|
||||||
|
for code in ex.map(up, jobs):
|
||||||
|
if code != 0:
|
||||||
|
up_fail += 1
|
||||||
|
print(" ③ 上传 %d 张(失败 %d;%.0fKB/张;%.1fs)"
|
||||||
|
% (len(jobs), up_fail, img_kb, time.perf_counter() - t_up))
|
||||||
|
if up_fail:
|
||||||
|
raise SystemExit("上传有 %d 张失败,夹具不成立(先看失败码,别当成导出的问题)" % up_fail)
|
||||||
|
|
||||||
|
def submit(t):
|
||||||
|
c, b = call("POST", "/api/v1/mp/submissions/%d/submit" % sid[t["uid"]], t["token"],
|
||||||
|
{"confirmWarnings": True})
|
||||||
|
# ⚠️ 判据是 data.submitted(F-84):HTTP 200 + code 0 不等于"提交成功"
|
||||||
|
return ((b or {}).get("data") or {}).get("submitted") if isinstance(b, dict) else False
|
||||||
|
|
||||||
|
ok_sub = 0
|
||||||
|
with cf.ThreadPoolExecutor(max_workers=conc) as ex:
|
||||||
|
for v in ex.map(submit, [t for t in tokens if t["uid"] in sid]):
|
||||||
|
if v is True:
|
||||||
|
ok_sub += 1
|
||||||
|
print(" ④ 提交 %d/%d 份(判据 data.submitted)" % (ok_sub, len(sid)))
|
||||||
|
if ok_sub != len(sid):
|
||||||
|
raise SystemExit("有 %d 份没提交成功,夹具不成立" % (len(sid) - ok_sub))
|
||||||
|
|
||||||
|
ids = [sid[t["uid"]] for t in tokens if t["uid"] in sid]
|
||||||
|
|
||||||
|
def approve(sid_, token, body_, label):
|
||||||
|
c, b = call("POST", "/api/v1/audit/submissions/%d/approve" % sid_, token, body_, timeout=120)
|
||||||
|
return (b or {}).get("code") if isinstance(b, dict) else -1, b
|
||||||
|
|
||||||
|
# ⚠️ 一期**恒有两段审核**(needClassAudit 被强制 true,见 build_fixture 的说明),
|
||||||
|
# 所以"逐份通过"要走两次:班主任初审 → 管理员终审。
|
||||||
|
# 只做一段的表现是全部停在 40301「不在你负责的班级里」或"没有待你处理的审核动作"。
|
||||||
|
for stage, (tok, body_, label) in enumerate((
|
||||||
|
(teacher, {"actionSource": "BUTTON", "remark": "压测初审"}, "初审"),
|
||||||
|
(admin, {"actionSource": "BUTTON", "finalOpinion": "压测终审"}, "终审"))):
|
||||||
|
ok_n, first_fail = 0, None
|
||||||
|
with cf.ThreadPoolExecutor(max_workers=conc) as ex:
|
||||||
|
for code, b in ex.map(lambda s: approve(s, tok, body_, label), ids):
|
||||||
|
if code == 0:
|
||||||
|
ok_n += 1
|
||||||
|
elif first_fail is None:
|
||||||
|
first_fail = b
|
||||||
|
print(" ⑤-%d %s通过:%d/%d 份%s"
|
||||||
|
% (stage + 1, label, ok_n, len(ids),
|
||||||
|
"" if first_fail is None else " 首个失败:%s"
|
||||||
|
% json.dumps(first_fail, ensure_ascii=False)[:160]))
|
||||||
|
if first_fail is not None:
|
||||||
|
raise SystemExit("%s这一关没过,夹具不成立" % label)
|
||||||
|
|
||||||
|
_c, b = call("GET", "/api/v1/exports/precheck?taskId=%d&onlyApproved=true" % tid, admin)
|
||||||
|
pre = (b or {}).get("data") or {}
|
||||||
|
print(" ⑥ 前置检查:canExport=%s 候选 %s 人 / %s 个文件 / 原件 %.1f MB"
|
||||||
|
% (pre.get("canExport"), pre.get("candidateCount"), pre.get("fileCount"),
|
||||||
|
(pre.get("rawSizeBytes") or 0) / 1048576.0))
|
||||||
|
if pre.get("canExport") is not True:
|
||||||
|
raise SystemExit("前置检查不通过,大包测不了:%s"
|
||||||
|
% json.dumps(pre.get("blocking"), ensure_ascii=False)[:200])
|
||||||
|
|
||||||
|
body = {"taskId": tid, "exportName": "压测大包 %s" % SUFFIX, "scope": {"onlyApproved": True}}
|
||||||
|
if split_mb:
|
||||||
|
body["splitSizeMb"] = split_mb
|
||||||
|
t0 = time.perf_counter()
|
||||||
|
_c, b = call("POST", "/api/v1/exports", admin, body, timeout=600)
|
||||||
|
eid = ((b or {}).get("data") or {}).get("exportId")
|
||||||
|
if (b or {}).get("code") != 0 or not eid:
|
||||||
|
raise SystemExit("创建导出失败:%s" % json.dumps(b, ensure_ascii=False)[:200])
|
||||||
|
print(" ⑦ 导出 id=%s,创建耗时 %.1fs → 轮询到完成(打包是异步的,量墙钟)"
|
||||||
|
% (eid, time.perf_counter() - t0))
|
||||||
|
|
||||||
|
detail, seen = None, None
|
||||||
|
while time.perf_counter() - t0 < 1800:
|
||||||
|
_c, b = call("GET", "/api/v1/exports/%d" % eid, admin)
|
||||||
|
d = (b or {}).get("data") or {}
|
||||||
|
if d.get("status") in (2, 3, 4):
|
||||||
|
detail = d
|
||||||
|
break
|
||||||
|
if d.get("progress") != seen:
|
||||||
|
seen = d.get("progress")
|
||||||
|
print(" …%s%% %s" % (seen, d.get("stepText") or ""))
|
||||||
|
time.sleep(2)
|
||||||
|
wall = time.perf_counter() - t0
|
||||||
|
if not detail or detail.get("status") != 2:
|
||||||
|
print(" ✗ 导出没跑完:status=%s errorMsg=%s"
|
||||||
|
% (detail and detail.get("status"), detail and detail.get("errorMsg")))
|
||||||
|
return None
|
||||||
|
|
||||||
|
s = detail.get("stats") or {}
|
||||||
|
print(" ⑧ **打包墙钟 %.1fs** 文件 %s 个 原件 %.1f MB → 产物 %.1f MB(%s)"
|
||||||
|
% (wall, s.get("fileCount"), s.get("rawSizeMb") or 0, s.get("finalSizeMb") or 0,
|
||||||
|
s.get("compressRatio")))
|
||||||
|
|
||||||
|
# ⚠️ 列名以 DDL 为准(V11):`part_no` / `file_name` / `file_size` / `file_path` ——
|
||||||
|
# 按直觉写成 part/name/size/path 会直接 SQL 报错(我第一版就是这么写的)
|
||||||
|
vols = sql("select part_no, file_name, coalesce(file_size,0), file_path "
|
||||||
|
"from collect_export_file where export_id=%d and deleted=0 order by part_no;" % eid)
|
||||||
|
rows = [x for x in vols.split("\n") if x.strip()]
|
||||||
|
print(" ⑨ 分卷 %d 个:" % len(rows))
|
||||||
|
for line in rows:
|
||||||
|
part, name, size, path = (line.split("|") + ["", "", "", ""])[:4]
|
||||||
|
print(" %s 卷 %s %.1f MB" % (name, part, int(size or 0) / 1048576.0))
|
||||||
|
# ⚠️ 分卷的承诺是"**每一卷都是完整可解的 ZIP**"(不是 .z01 那种必须凑齐才能解)。
|
||||||
|
# 这条只能真去解一遍 —— 断言"文件存在"是断言不到它的。
|
||||||
|
base = os.path.join(ROOT, "backend", "data", "uploads")
|
||||||
|
for line in rows:
|
||||||
|
part, name, size, path = (line.split("|") + ["", "", "", ""])[:4]
|
||||||
|
full = path if os.path.isabs(path) else os.path.join(base, path)
|
||||||
|
if not os.path.exists(full):
|
||||||
|
# 存储是七牛时产物也可能不在本地(D5 说导出包落本地盘,所以这条算异常)
|
||||||
|
print(" ? %s 不在本地磁盘(路径 %s)" % (name, full))
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
with zipfile.ZipFile(full) as z:
|
||||||
|
names = z.namelist()
|
||||||
|
bad = z.testzip()
|
||||||
|
print(" ✓ %s 可解:%d 个条目,首条 %s%s"
|
||||||
|
% (name, len(names), names[0] if names else "(空)",
|
||||||
|
"" if bad is None else " ✗ CRC 错误:%s" % bad))
|
||||||
|
except Exception as e: # noqa: BLE001
|
||||||
|
print(" ✗ %s **解不开**:%s" % (name, e))
|
||||||
|
return detail
|
||||||
|
|
||||||
|
|
||||||
def scenario_export(tid, admin):
|
def scenario_export(tid, admin):
|
||||||
"""导出:前置检查(只读)+ 创建(同步打包)。"""
|
"""导出:前置检查(只读)+ 创建(同步打包)。"""
|
||||||
st_pre = Stat("导出前置检查")
|
st_pre = Stat("导出前置检查")
|
||||||
@@ -442,37 +875,80 @@ def scenario_export(tid, admin):
|
|||||||
# ══════════════ 清理 ══════════════
|
# ══════════════ 清理 ══════════════
|
||||||
|
|
||||||
def cleanup():
|
def cleanup():
|
||||||
|
# ⚠️ 删库行之前先把**磁盘上的文件路径**取出来:压测会传几百张图、打出几百 MB 的包,
|
||||||
|
# 只删库行的话这些文件会一直躺在 backend/data/uploads 里
|
||||||
|
# ("删库不删桶"的本地版本 —— 时间一长本地磁盘就是一本糊涂账)
|
||||||
|
disk = []
|
||||||
|
for tid in TASK_IDS:
|
||||||
|
for stmt in ("select coalesce(std_path, raw_path) from collect_file where task_id=%s;",
|
||||||
|
"select file_path from collect_export_file where task_id=%s;"):
|
||||||
|
for line in sql(stmt % tid).split("\n"):
|
||||||
|
p = line.strip()
|
||||||
|
if p and p != "null":
|
||||||
|
disk.append(p)
|
||||||
|
|
||||||
for tid in TASK_IDS:
|
for tid in TASK_IDS:
|
||||||
for tbl in ("collect_validation_issue", "collect_audit_record", "collect_notify_record",
|
for tbl in ("collect_validation_issue", "collect_audit_record", "collect_notify_record",
|
||||||
"collect_file", "collect_submission", "collect_roster",
|
"collect_export_file", "collect_file", "collect_submission", "collect_roster",
|
||||||
"collect_task_org", "collect_material_def"):
|
"collect_task_org", "collect_material_def"):
|
||||||
sql("delete from %s where task_id=%s;" % (tbl, tid))
|
sql("delete from %s where task_id=%s;" % (tbl, tid))
|
||||||
sql("delete from collect_task where id=%s;" % tid)
|
sql("delete from collect_task where id=%s;" % tid)
|
||||||
for uid in UIDS:
|
for uid in UIDS:
|
||||||
sql("delete from collect_message where user_id=%d;" % uid)
|
sql("delete from collect_message where user_id=%d;" % uid)
|
||||||
|
|
||||||
|
base = os.path.join(ROOT, "backend", "data", "uploads")
|
||||||
|
removed = 0
|
||||||
|
for p in disk:
|
||||||
|
full = p if os.path.isabs(p) else os.path.join(base, p)
|
||||||
|
# ⚠️ 只删**确实存在**的本地文件:存储是七牛时这些路径是 bucket key,
|
||||||
|
# 本地根本不存在 —— 那时候要清的是桶,不该在本地乱删(也删不到)
|
||||||
|
if os.path.isfile(full):
|
||||||
|
try:
|
||||||
|
os.remove(full)
|
||||||
|
removed += 1
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
for p in (TMP_ROSTER, TMP_IMG):
|
for p in (TMP_ROSTER, TMP_IMG):
|
||||||
if os.path.exists(p):
|
if os.path.exists(p):
|
||||||
os.remove(p)
|
os.remove(p)
|
||||||
print(" 已清理:压测任务 / 名册 / 材料 / 消息 / 临时文件")
|
print(" 已清理:压测任务 / 名册 / 材料 / 消息 / 临时文件(本地文件 %d 个)" % removed)
|
||||||
|
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
# ⚠️ `global` 必须出现在**同一函数里第一次使用该名字之前** ——
|
# ⚠️ `global` 必须出现在**同一函数里第一次使用该名字之前** ——
|
||||||
# 放晚了会报 "name 'X' is used prior to global declaration"
|
# 放晚了会报 "name 'X' is used prior to global declaration"
|
||||||
global GATE_USERS
|
global GATE_USERS
|
||||||
|
global BASE
|
||||||
|
|
||||||
ap = argparse.ArgumentParser(description="压测(门禁/上传/读/导出)")
|
ap = argparse.ArgumentParser(description="压测(门禁/上传/读/导出/长跑)")
|
||||||
ap.add_argument("--only", default="", help="只跑一个场景:gate / read / upload / export")
|
ap.add_argument("--only", default="",
|
||||||
|
help="只跑一个场景:gate / read / upload / export / export-big / soak")
|
||||||
ap.add_argument("--conc", type=int, default=0, help="覆盖并发数")
|
ap.add_argument("--conc", type=int, default=0, help="覆盖并发数")
|
||||||
ap.add_argument("--clean", action="store_true", help="只清理上次残留")
|
ap.add_argument("--clean", action="store_true", help="只清理上次残留")
|
||||||
ap.add_argument("--rows", type=int, default=0,
|
ap.add_argument("--rows", type=int, default=0,
|
||||||
help="名册人数(默认 %d)。⚠️ 谈响应时间必须带上规模" % GATE_USERS)
|
help="名册人数(默认 %d)。⚠️ 谈响应时间必须带上规模" % GATE_USERS)
|
||||||
|
ap.add_argument("--base", default="",
|
||||||
|
help="后端地址(默认 %s)。要量上传就另起一个 local 存储的实例" % BASE)
|
||||||
ap.add_argument("--with-upload", action="store_true",
|
ap.add_argument("--with-upload", action="store_true",
|
||||||
help="跑上传压测(⚠️ 会往真实对象存储写文件,默认关闭)")
|
help="跑上传压测(⚠️ 会往真实对象存储写文件,默认关闭)")
|
||||||
|
ap.add_argument("--img-px", default="2000x1500",
|
||||||
|
help="上传夹具的图片尺寸(真实 JPEG,压缩成本与它成正比)")
|
||||||
|
ap.add_argument("--fixture-only", action="store_true",
|
||||||
|
help="只造夹具并计时(量名册导入耗时用,不做任何压测)")
|
||||||
|
ap.add_argument("--soak", type=int, default=0,
|
||||||
|
help="长时间稳定性:持续 N 分钟,每分钟采一行 RSS/PG 连接数/延迟")
|
||||||
|
ap.add_argument("--rps", type=float, default=5.0, help="长跑强度(请求/秒,默认 5)")
|
||||||
|
ap.add_argument("--files-per-user", type=int, default=1,
|
||||||
|
help="导出大包场景:每人几个材料(默认 1)")
|
||||||
|
ap.add_argument("--split-mb", type=int, default=0,
|
||||||
|
help="导出大包场景:按 N MB 分卷(0 = 不分卷,用服务端默认)")
|
||||||
args = ap.parse_args()
|
args = ap.parse_args()
|
||||||
|
|
||||||
|
if args.base:
|
||||||
|
BASE = args.base.rstrip("/")
|
||||||
if args.rows > 0:
|
if args.rows > 0:
|
||||||
GATE_USERS = args.rows
|
GATE_USERS = args.rows
|
||||||
|
px = tuple(int(x) for x in args.img_px.lower().split("x"))
|
||||||
|
|
||||||
if args.clean:
|
if args.clean:
|
||||||
# 残留任务按名字前缀找
|
# 残留任务按名字前缀找
|
||||||
@@ -497,12 +973,29 @@ def main():
|
|||||||
print("管理员登录失败:" + str(adm))
|
print("管理员登录失败:" + str(adm))
|
||||||
return 2
|
return 2
|
||||||
admin = adm["data"]["token"]
|
admin = adm["data"]["token"]
|
||||||
|
# 班主任账号:压测的导出大包场景要真的走一遍"初审 → 终审",
|
||||||
|
# 而这个账号由 scripts/dev-seed-audit.py 摆出来(演示数据的一部分)
|
||||||
|
_, tea = R.login(BASE, TEACHER_USER, TEACHER_PWD, "demo")
|
||||||
|
if tea.get("code") != 0:
|
||||||
|
print("演示班主任 %s 登录失败(先跑 scripts/dev-seed-audit.py):%s" % (TEACHER_USER, tea))
|
||||||
|
return 2
|
||||||
|
teacher = tea["data"]["token"]
|
||||||
|
|
||||||
print("\n── 造夹具 ──")
|
print("\n── 造夹具 ──")
|
||||||
t0 = time.perf_counter()
|
t0 = time.perf_counter()
|
||||||
tid, tokens = build_fixture(admin)
|
tid, tokens = build_fixture(admin, teacher)
|
||||||
print(" 任务 %d,名册 %d 人,夹具耗时 %.1fs"
|
print(" 任务 %d,名册 %d 人,夹具耗时 %.1fs(含名册导入本身)"
|
||||||
% (tid, len(tokens), time.perf_counter() - t0))
|
% (tid, len(tokens), time.perf_counter() - t0))
|
||||||
|
if args.fixture_only:
|
||||||
|
# 量"导入 N 行"这件事:接口耗时在上面那条"夹具计时"里,这里补内存与库内行数
|
||||||
|
print("\n --fixture-only:夹具已造好,直接清理")
|
||||||
|
pid = app_pid(base_port())
|
||||||
|
print(" 后端 RSS:%.0f MB(pid=%s)" % (rss_mb(pid) or -1, pid))
|
||||||
|
print(" 库里名册行数:%s(应为 %d)"
|
||||||
|
% (one("select count(*) from collect_roster where task_id=%d and deleted=0;" % tid),
|
||||||
|
GATE_USERS))
|
||||||
|
cleanup()
|
||||||
|
return 0
|
||||||
|
|
||||||
stats = {}
|
stats = {}
|
||||||
conc = args.conc
|
conc = args.conc
|
||||||
@@ -516,15 +1009,22 @@ def main():
|
|||||||
stats["read"] = scenario_read(tid, admin, conc or READ_CONC, READ_ROUNDS)
|
stats["read"] = scenario_read(tid, admin, conc or READ_CONC, READ_ROUNDS)
|
||||||
if args.only == "upload" or (args.with_upload and args.only == ""):
|
if args.only == "upload" or (args.with_upload and args.only == ""):
|
||||||
print("\n── ③ 材料上传(%d 人同时传,压缩是 CPU 密集)──" % UPLOAD_USERS)
|
print("\n── ③ 材料上传(%d 人同时传,压缩是 CPU 密集)──" % UPLOAD_USERS)
|
||||||
stats["upload"] = scenario_upload(tid, tokens, conc or UPLOAD_USERS)
|
stats["upload"] = scenario_upload(tid, tokens, conc or UPLOAD_USERS, px)
|
||||||
if args.only == "" and not args.with_upload:
|
if args.only == "" and not args.with_upload:
|
||||||
print("\n── ③ 材料上传(**默认跳过**)──")
|
print("\n── ③ 材料上传(**默认跳过**)──")
|
||||||
print(" ⚠️ 当前 dev 的存储是七牛**真实空间**(见 backend/application-local.yml),")
|
print(" ⚠️ 当前 dev 的存储是七牛**真实空间**(见 backend/application-local.yml),")
|
||||||
print(" 压测上传会打到外部服务并把测试文件灌进真实桶。要跑就加 --with-upload,")
|
print(" 压测上传会打到外部服务并把测试文件灌进真实桶。两种跑法:")
|
||||||
print(" 且最好先把存储切成本地:XT_STORAGE_PROVIDER=local 重启后端。")
|
print(" ① `--base http://localhost:18080` 打另起的 local 实例(推荐,见 docs/09 §9.5);")
|
||||||
|
print(" ② `XT_STORAGE_PROVIDER=local` 重启当前后端后再加 --with-upload。")
|
||||||
if args.only in ("", "export"):
|
if args.only in ("", "export"):
|
||||||
print("\n── ④ 导出(前置检查 + 创建)──")
|
print("\n── ④ 导出(前置检查 + 创建)──")
|
||||||
scenario_export(tid, admin)
|
scenario_export(tid, admin)
|
||||||
|
if args.only == "export-big":
|
||||||
|
print("\n── ④' 导出大包(造 N 份已通过的单 → 打包 → 验产物)──")
|
||||||
|
scenario_export_big(tid, tokens, admin, teacher, args.files_per_user, args.split_mb)
|
||||||
|
if args.only == "soak" or args.soak > 0:
|
||||||
|
print("\n── ⑤ 长时间稳定性(%d 分钟 × %.1f 请求/秒)──" % (args.soak, args.rps))
|
||||||
|
scenario_soak(tid, admin, args.soak, args.rps)
|
||||||
finally:
|
finally:
|
||||||
print("\n── 清理 ──")
|
print("\n── 清理 ──")
|
||||||
cleanup()
|
cleanup()
|
||||||
|
|||||||
@@ -11,7 +11,8 @@
|
|||||||
| 实体 ↔ 建表 | `check-entity-columns.py` | 无 |
|
| 实体 ↔ 建表 | `check-entity-columns.py` | 无 |
|
||||||
| 文档自身一致 | `verify-docs.py` | 无 |
|
| 文档自身一致 | `verify-docs.py` | 无 |
|
||||||
| 手册 ↔ 代码文案 | `check-manual.py` | 无 |
|
| 手册 ↔ 代码文案 | `check-manual.py` | 无 |
|
||||||
| 接口行为(1548 条断言) | `scripts/e2e-*.py` | **后端在跑 + PG/Redis** |
|
| 接线(定义 ↔ 使用) | `check-wiring.py` | 无 |
|
||||||
|
| 接口行为(1577+ 条断言) | `scripts/e2e-*.py` | **后端在跑 + PG/Redis** |
|
||||||
| 生产配置拦截 | `e2e-prodguard.py` | **能起 jar** |
|
| 生产配置拦截 | `e2e-prodguard.py` | **能起 jar** |
|
||||||
| CI 那个 profile 能不能起 | `check-test-profile.py` | 后端 jar + PG/Redis |
|
| CI 那个 profile 能不能起 | `check-test-profile.py` | 后端 jar + PG/Redis |
|
||||||
|
|
||||||
@@ -87,6 +88,8 @@ def main():
|
|||||||
r"(\d+) 个实体全部与迁移一致((\d+) 张表)"),
|
r"(\d+) 个实体全部与迁移一致((\d+) 张表)"),
|
||||||
("文档自身一致", "python scripts/verify-docs.py", r"结果: (\d+) 项全部通过"),
|
("文档自身一致", "python scripts/verify-docs.py", r"结果: (\d+) 项全部通过"),
|
||||||
("手册 ↔ 代码文案", "python scripts/check-manual.py", r"结果:.*"),
|
("手册 ↔ 代码文案", "python scripts/check-manual.py", r"结果:.*"),
|
||||||
|
("接线(定义↔使用)", "python scripts/check-wiring.py --quiet",
|
||||||
|
r"未接线的声明 (\d+) 条"),
|
||||||
]
|
]
|
||||||
for name, cmd, pat in checks:
|
for name, cmd, pat in checks:
|
||||||
code, out = run(cmd, timeout=300)
|
code, out = run(cmd, timeout=300)
|
||||||
|
|||||||
+12
-2
@@ -50,6 +50,16 @@ def read(*parts: str) -> str:
|
|||||||
|
|
||||||
|
|
||||||
# ── 1. 表格列数 ────────────────────────────────────────
|
# ── 1. 表格列数 ────────────────────────────────────────
|
||||||
|
def table_pipes(line: str) -> int:
|
||||||
|
"""数表格竖线,**跳过转义过的 `\\|`**。
|
||||||
|
|
||||||
|
GFM 里 `\\|` 表示"内容里的竖线",不是列分隔符(本仓库用手写表格,
|
||||||
|
正文里写 `a \\|\\| b` 是很自然的事)。第一版直接 `line.count("|")`,
|
||||||
|
于是把这种写法报成"列数不一致" —— **检查器自己要先认得它检查的语法**。
|
||||||
|
"""
|
||||||
|
return len(re.findall(r"(?<!\\)\|", line))
|
||||||
|
|
||||||
|
|
||||||
def check_tables(name: str, text: str) -> None:
|
def check_tables(name: str, text: str) -> None:
|
||||||
lines = text.split("\n")
|
lines = text.split("\n")
|
||||||
bad_rows = 0
|
bad_rows = 0
|
||||||
@@ -57,10 +67,10 @@ def check_tables(name: str, text: str) -> None:
|
|||||||
while i < len(lines):
|
while i < len(lines):
|
||||||
if lines[i].strip().startswith("|") and i + 1 < len(lines) \
|
if lines[i].strip().startswith("|") and i + 1 < len(lines) \
|
||||||
and re.match(r"^\|[\s:|-]+\|$", lines[i + 1].strip()):
|
and re.match(r"^\|[\s:|-]+\|$", lines[i + 1].strip()):
|
||||||
n = lines[i].count("|")
|
n = table_pipes(lines[i])
|
||||||
j = i + 2
|
j = i + 2
|
||||||
while j < len(lines) and lines[j].strip().startswith("|"):
|
while j < len(lines) and lines[j].strip().startswith("|"):
|
||||||
if lines[j].count("|") != n:
|
if table_pipes(lines[j]) != n:
|
||||||
bad_rows += 1
|
bad_rows += 1
|
||||||
j += 1
|
j += 1
|
||||||
i = j
|
i = j
|
||||||
|
|||||||
Reference in New Issue
Block a user