feat(m0): 后端骨架 + 账号体系(M0 验收 36/36 通过)

技术栈(已在 4.1.1 上实测跑通,非纸面选型)
  Java 21 + Spring Boot 4.1.1 + MyBatis-Plus 3.5.17(boot4) + PostgreSQL 16.15
  + Redis 7 + Flyway 12.4 + springdoc 3.1.1;前端定 Vue 3 + Element Plus + uni-app

交付内容
  M0-01 基础设施     deploy/docker-compose.yml(PG + Redis + MinIO,容器名与端口避让本机既有项目)
  M0-02 后端骨架     xt-common + xt-server;统一响应 / 全局异常 / 58 个错误码 /
                     多租户拦截 / 软删除 / 字段自动填充 / AES-GCM / HMAC 索引 / RSA / JWT
  M0-05 租户        开通租户(一次完成建租户 + 发 6 个内置角色 + 建首个管理员)、列表、详情
  M0-06 组织        CRUD + 组织树 + ancestor_path 自动维护 + 占用校验
  M0-07 PC 登录     RSA 解密 + 图形验证码 + 失败锁定 + 等时比较防账号枚举 + 审计日志
  M0-08 登出/当前用户  Token 黑名单(存 SHA-256,TTL 到自然过期)
  M0-01 数据库      Flyway 2 个迁移 → 26 张表(20 业务 + 5 标准 + 1 迁移记录)

安全姿态
  · 租户拦截器【默认拒绝】:匿名请求触及租户表直接抛 40101,不做「不加条件放行」
  · 权限码定义在代码里(PermissionDef / BuiltinRole),不建 sys_permission 表,杜绝前后端漂移
  · 匿名接口只靠 @PublicApi 放行,不做路径白名单,避免两套机制各说各话
  · 账号不存在时也跑一次 BCrypt(假哈希),拉平响应时间防枚举
  · 登录密码 RSA 传输,防止网关/APM 把明文密码记进日志

脚本
  scripts/sync-ddl.py          文档 DDL → Flyway 迁移,带 --check 防漂移
  scripts/e2e-m0.py            M0 验收 36 项,可进 CI
  scripts/verify-docs.py       文档一致性(接口数【逐节】核对、错误码、跨文档数字、表格列数)
  scripts/dev-login.py         RSA 加密登录助手
  scripts/dev-clean-e2e.py     清理 E2E 测试数据

文档修正(实测暴露,非人工审查)
  · DDL 缺陷:sys_tenant.contact_phone / sys_user.phone / collect_roster.phone
    原 varchar(20) 按明文长度建,装不下 AES-GCM 密文(约 52 字符),改 varchar(128)(PRD V2.4)
  · 错误码 40007b/40007c 带字母后缀与「code 为数字」的约定冲突,规范化为 40071/40072
  · 07-开发实施计划 新增 §9 版本兼容踩坑记录(10 条实录)与 §10 M0 完成情况
This commit is contained in:
2026-09-28 17:28:21 +08:00
parent 63aec55329
commit 9d7a8ca845
88 changed files with 7564 additions and 32 deletions
+8 -6
View File
@@ -139,7 +139,7 @@
| 场景 | 阈值 | 超限响应 |
|---|---|---|
| 登录 | 5 次/分钟/IP + 账号锁定 5 次 | `42901` |
| **门禁核验** | **同 openid 10 次/小时;同 IP 20 次/分钟** | `40007b` |
| **门禁核验** | **同 openid 10 次/小时;同 IP 20 次/分钟** | `40071` |
| 上传凭证 | 200 次/分钟/用户 | `42901` |
| OCR | 30 次/分钟/用户 | `42901` |
| 催报 | 10 次/分钟/任务 | `42901` |
@@ -878,7 +878,7 @@
**匹配逻辑**(服务端)
```
1. 限流检查 → 超限返回 40007b
1. 限流检查 → 超限返回 40071
2. 规范化:姓名去空格、全角转半角;证件号转大写
3. 按 (task_id, id_card_hash) 定位名册项
├─ 未命中 → 记录失败尝试(不记明文) → 40007 统一提示
@@ -887,7 +887,7 @@
├─ 形近字/同音字 → 返回候选让用户确认(FR-504c)
└─ 不一致 → 40007 统一提示
4. 检查绑定状态
├─ 已被他人绑定 → 40007c
├─ 已被他人绑定 → 40072
└─ 未绑定 → 写入 bind_user_id / bind_time / bind_type
```
@@ -2798,8 +2798,8 @@ X-Sign: HMAC-SHA256(appSecret, appKey + timestamp + nonce + body)
| `40005` | 证件号与名册不一致 | 展示名册值,要求确认 |
| `40006` | 疑似串档 | 转人工,**不自动修正** |
| `40007` | 门禁未匹配 | **统一提示**"没有找到你的信息",不透露哪项错 |
| `40007b` | 门禁尝试过于频繁 | 提示"操作过于频繁,请稍后再试" |
| `40007c` | 名单项已被他人绑定 | 提示"该信息已被登记" |
| `40071` | 门禁尝试过于频繁 | 提示"操作过于频繁,请稍后再试" |
| `40072` | 名单项已被他人绑定 | 提示"该信息已被登记" |
| `40008` | 任务已截止 / 已归档 / **已暂停** | 禁止提交 |
| `40009` | 重复提交(幂等键冲突) | 返回既有结果 |
| `40010` | 驳回次数超上限 | 提示联系班主任 |
@@ -2835,8 +2835,8 @@ X-Sign: HMAC-SHA256(appSecret, appKey + timestamp + nonce + body)
| ⭐ `40040` | 申请状态已变更(非待审) | 刷新列表 |
| ⭐ `40041` | **名册中该证件号已被占用** | 不覆盖,提示管理员核查 |
| ⭐ `40042` | 申请已通过,不可再驳回 | 如需撤销请走名册删除 |
| ⭐ `40044` | **一期不支持该收集模式 / 认定类型** | 请使用学校收集 + 自主认定(见 PRD「一期范围声明」) |
| ⭐ `40043` | **文件归属存疑**(文件名指向名册中的另一个人,或归属无法确证) | **阻断提交**,提示"这份材料可能不是你的",引导重新上传(**无"仍然继续"选项**) |
| ⭐ `40044` | **一期不支持该收集模式 / 认定类型** | 请使用学校收集 + 自主认定(见 PRD「一期范围声明」) |
| `40101` | 未登录 / token 失效 | 静默重新登录 |
| `40301` | 无权限(含跨租户) | 跳转 403 |
| `40302` | 签名 URL 已过期 | 重新获取 |
@@ -2862,6 +2862,8 @@ X-Sign: HMAC-SHA256(appSecret, appKey + timestamp + nonce + body)
| 版本 | 日期 | 变更 |
|---|---|---|
| **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.0 | 2026-09-28 | 首版。PRD §8 的 96 个接口全部展开字段级定义;**补齐追溯矩阵审计发现的 10 个缺口(新增 21 个接口,含 1 个登记遗漏)**;错误码由 20 个扩至 **50 个**;新增 §2 枚举全集、§3 通用数据结构、§15 错误码全集 |
| **V1.1** | **2026-09-28** | **客户要求移除「联系方式展示」**:删除 `GET /api/v1/mp/roster/contact`(原用于门禁失败页展示班主任脱敏手机号)。<br>**理由**:学生在班级群里本来就有班主任联系方式,系统内再放一份既不必要(用户已知怎么找人)、有维护成本(换人换号要同步)、又多一处隐私暴露面。<br>门禁失败页改为只保留两条**系统内动作**:重新填写 / 申请加入名单(FR-509)。纯文案提示("如有疑问请联系老师")保留,因**不涉及任何联系方式数据**。<br>**接口总数 122 → 121** |
+15 -7
View File
@@ -814,7 +814,7 @@ FUNCTION gate_match(realName, idCardNo, taskId, openid, ip):
r2 = rateLimit.check("gate:ip:" + ip, limit=20, window=60)
IF NOT r1.allowed OR NOT r2.allowed:
audit.log(attempt, openid, ip, result=RATE_LIMITED)
RETURN ERROR(40007b, "操作过于频繁,请稍后再试",
RETURN ERROR(40071, "操作过于频繁,请稍后再试",
data={ retryAfterSec: max(r1.retryAfter, r2.retryAfter) })
# ── 步骤 2:规范化 ───────────────────────────
@@ -866,7 +866,7 @@ FUNCTION gate_match(realName, idCardNo, taskId, openid, ip):
IF r.bind_user_id == currentUserId:
PASS # 幂等:自己绑过就放行
ELSE:
RETURN ERROR(40007c, "该信息已被登记,如有疑问请联系老师")
RETURN ERROR(40072, "该信息已被登记,如有疑问请联系老师")
# 原子抢占:把"检查"和"写入"合成一条语句,杜绝并发窗口
affected = UPDATE collect_roster
@@ -876,7 +876,7 @@ FUNCTION gate_match(realName, idCardNo, taskId, openid, ip):
AND deleted = 0
IF affected == 0:
# 说明在"检查"和"写入"之间被别人抢了
RETURN ERROR(40007c, "该信息已被登记,如有疑问请联系老师")
RETURN ERROR(40072, "该信息已被登记,如有疑问请联系老师")
# ── 步骤 6:返回预填数据 ─────────────────────
RETURN OK({
@@ -910,7 +910,7 @@ IF r.bind_user_id IS NULL: # 检查通过
| 场景 | 处理 |
|---|---|
| 名单有重复证件号 | 记数据问题、通知管理员,对外统一失败 |
| 名册项已被他人绑定 | `40007c`(**这一条可以明确提示**——因为用户已经提供对了信息,提示不泄露新信息) |
| 名册项已被他人绑定 | `40072`(**这一条可以明确提示**——因为用户已经提供对了信息,提示不泄露新信息) |
| openid 绑定过但名册项被删 | 解绑,重新走门禁 |
| 用户换微信(openid 变了) | 走"申请换绑"流程 → 管理员审批(见确认函 B1) |
| 任务已截止/暂停 | 在门禁**之前**拦截,`40008` |
@@ -950,10 +950,10 @@ FUNCTION record_failure(taskId, openid, ip, hash):
| 正确姓名+号码 | 通过,返回脱敏号与预填 |
| 号码对、姓名错 | **统一失败提示**(文案与"号码错"完全一致) |
| 号码错 | 统一失败提示 |
| 限流触发 | `40007b`,提示剩余等待时间 |
| 限流触发 | `40071`,提示剩余等待时间 |
| 形近字(曾依豪 vs 曾伊豪) | 返回确认候选,`needConfirmName: true` |
| 员工 A 先绑,员工 B 再试 | B 得 `40007c` |
| **并发 100 个请求抢同一名册项** | **恰好 1 个成功,99 个得 `40007c`**(压测必测) |
| 员工 A 先绑,员工 B 再试 | B 得 `40072` |
| **并发 100 个请求抢同一名册项** | **恰好 1 个成功,99 个得 `40072`**(压测必测) |
| 失败尝试查库 | **无明文证件号** |
| 名单 5000 条时匹配耗时 | < 50ms(走 hash 索引) |
| 同 IP 20 人同时填报 | **全部通过**(不误伤 NAT) |
@@ -1441,4 +1441,12 @@ FUNCTION create_export(taskId, scope, options):
|---|---|---|
| V1.0 | 2026-09-28 | 首版。覆盖 6 个模块(M1 命名 / M2 压缩 / M3 识别 / M4 门禁 / M5 校验 / M6 导出),全部为伪代码级说明;含模块依赖图、开发顺序建议、Golden Test Set 基线(15 项指标)、三条贯穿原则 |
---
> **实现说明(2026-09-28)**:本文件中的 `40007b` / `40007c` 已规范化为 **`40071`** / **`40072`**。
> 原因:`code` 按 §1.3 约定是数字,带字母后缀会在 JSON 里变成字符串,破坏前端 `code === 0` 的判断。
---
> **DDL 修正(2026-09-28)**:`sys_tenant.contact_phone` / `sys_user.phone` / `collect_roster.phone` 由 `varchar(20)` 放宽到 `varchar(128)`。
> 原因:这三列存的是 AES-256-GCM 密文(约 52 字符),`varchar(20)` 是按明文长度建的,插入必失败。
+8 -3
View File
@@ -289,9 +289,9 @@ with unzip(result.zip) as z:
| # | 场景 | 期望 |
|:--:|---|---|
| E1 | 门禁连错 11 次 | 第 11 次返回 `40007b`,提示稍后再试 |
| E1 | 门禁连错 11 次 | 第 11 次返回 `40071`,提示稍后再试 |
| E2 | 同一 IP 20 人同时门禁 | **全部通过**(NAT 不误伤) |
| E3 | 并发 100 请求抢同一名册项 | **恰好 1 个成功**,99 个 `40007c` |
| E3 | 并发 100 请求抢同一名册项 | **恰好 1 个成功**,99 个 `40072` |
| E4 | 门禁失败后查库 | **无明文证件号**,只有 hash |
| E5 | 学籍表 4 项材料证件号均与名册不符 | 标 `VAL-19` 疑似串档,**不自动改** |
| E6 | 学生提交后撤回,再改再提交 | 正常;驳回次数不因撤回增加 |
@@ -382,7 +382,7 @@ with unzip(result.zip) as z:
| F-500-10 | 门禁限流 | openid 10 次/小时;IP 20 次/分钟 |
| F-500-11 | **并发绑定** | 100 并发抢同一项,恰好 1 个成功 |
| F-500-12 | 失败不留明文 | 查库确认只有 hash |
| F-500-13 | 已绑定提示 | `40007c`,**这一条明确提示** |
| F-500-13 | 已绑定提示 | `40072`,**这一条明确提示** |
### F-509 名单加入申请(客户确认新增)
@@ -608,3 +608,8 @@ with unzip(result.zip) as z:
|---|---|---|
| **V1.1** | **2026-09-28** | **客户确认后修订:修正「文件归属」设计缺陷**。原设计把"文件名姓名/证件号与名册不符"处理为"自动按名册修正"(信息级),**会把传错的文件静默洗白**。修正为:<br>① 新增 **7 条归属断言 G-14b~G-14h**(Golden 29 → **36**);② 判定优先级改为 **证件号反查名册 > OCR 内容 > 姓名(仅辅助)**;③ 新增 **VAL-21 文件归属校验(阻断,且无"仍然继续")**;④ **姓名不再作为独立规则**(客户确认);⑤ 新增 **7 条功能验收 F-600-14~20**(104 → **111**)与 **4 条异常边界 E24~E27**(23 → **27**)<br>**修订后总计:36 条断言 / 111 条功能验收 / 27 条异常边界 / 6 条视觉 / 9 项性能 / 12 项安全** |
| V1.0 | 2026-09-28 | 首版。**29 条 Golden Assertions**(可直接写成自动化)、3 条端到端主流程(32 步)、23 条异常边界、**104 条功能验收项**(含 F-509 共 19 条)、6 条视觉验收、9 项性能、12 项安全;含里程碑签署表与 7 条 Go/No-Go 条件 |
---
> **实现说明(2026-09-28)**:本文件中的 `40007b` / `40007c` 已规范化为 **`40071`** / **`40072`**。
> 原因:`code` 按 §1.3 约定是数字,带字母后缀会在 JSON 里变成字符串,破坏前端 `code === 0` 的判断。
+299
View File
@@ -0,0 +1,299 @@
# 数据信息填报系统 · 开发实施计划
> **版本**:V1.1 **日期**:2026-09-28 **状态**:M0 后端骨架已完成并实测通过(35/35 验收项)
>
> 本文是**开工计划**,回答三件事:用什么技术、代码怎么放、M0 怎么落地。
> 任务清单以《05-开发任务拆解》为准(100 任务 / 387 人日 / 15 周),本文不重复。
> 一期范围以 PRD「一期范围声明」为准(**学校收集 + 自主认定**)。
---
## 1. 技术选型(已确认)
| 层 | 选型 | 版本 | 理由 |
|---|---|---|---|
| 语言 | **Java** | 21(LTS) | 本机已装;Boot 4 一等公民 |
| 框架 | **Spring Boot** | **4.1.1** | 当前 GA(2026-08-20 发补丁),OSS 支持到 ~2028。<br>⚠️ 3.5.x 的免费补丁已于 2026-06 停止,故不选 |
| ORM | **MyBatis-Plus** | 3.5.17 | 用 `mybatis-plus-spring-boot4-starter`(官方 Boot 4 线) |
| 数据库 | **PostgreSQL** | 16 | 客户已确认纯 PG;DDL 已实测 |
| 连接池 | HikariCP | Boot 托管 | 不引 Druid,少一层配置 |
| 缓存 | **Redis** | 7 | 图形验证码 / 登录锁定 / Token 黑名单 / 限流 |
| 对象存储 | MinIO(本地)/ OSS(生产) | — | 走 **S3 兼容接口**抽象,本地 MinIO 零成本切换 |
| 鉴权 | **JWT**(jjwt) | 0.13.0 | 载荷见《03-接口设计》§1.2 |
| 密码 | BCrypt | spring-security-crypto | 只用加密库,**不引完整 Spring Security**(权限模型是自研的多租户 + 数据权限,用 Security 反而别扭) |
| 敏感字段 | AES-256-GCM + HMAC 索引 | JDK 自带 | PRD §10.3:证件号/手机号加密存储,`*_hash` 用于检索 |
| 图形验证码 | Hutool Captcha | 5.8.47 | 避免自己画 Java2D |
| Excel | EasyExcel | 4.0.3 | 名单/用户导入导出 |
| API 文档 | springdoc-openapi | 3.1.1 | Boot 4 线(**2.x 是 Boot 3 的,别混**) |
| PC 前端 | **Vue 3 + Vite + TS + Element Plus + Pinia** | — | 管理员/班主任 PC 端 |
| 小程序 | **uni-app**(Vue 3 + TS) | — | **一份代码 → 微信小程序 + H5**,直接落地 M0-13 的 C1 保底 |
| 图片处理 | 独立服务,**libvips** | M2 决定 | R11(1.8 亿像素 OOM)要求内存隔离,**不放进主应用** |
> ⚠️ **两个不要混的坑**
> 1. **springdoc**:Boot 3 用 2.x,Boot 4 用 3.x。混用会启动即报错。
> 2. **MyBatis-Plus**:`mybatis-plus-spring-boot3-starter` 与 `-boot4-starter` 是两个包。
---
## 2. 工程结构
```
xiaotutongxue/
├── docs/ # 产品文档(P1–P7 已交付,只读参考)
├── backend/ # Java 后端
│ ├── pom.xml # 父 POM(依赖版本统一在此)
│ ├── xt-common/ # 通用层:响应/异常/错误码/加密/工具
│ └── xt-server/ # 主应用:config/security/module + 启动类
├── frontend-pc/ # Vue 3 管理端
├── frontend-mp/ # uni-app(学生端 + 班主任端)
├── deploy/
│ ├── docker-compose.yml # PG16 + Redis + MinIO
│ └── db/ # DDL 与种子数据
└── README.md
```
**包名**:`cn.tianbaotong.fill`(域名 `tianbaotong.cn` 反转)
**为什么后端要拆模块**:M2 的图片处理服务必须**独立部署**(R11 内存隔离),
从第一天就留好父 POM 的位置,比 M2 再搬家便宜得多。
---
## 3. 需要补的表(PRD 未给 DDL)
PRD §7.2 明确标注:`从略 = 标准表(角色/字典/日志),沿用现有框架实现`。
DDL 里的 **20 张是业务表**,M0 还需要 **6 张标准表**:
| 表 | 用途 | 为什么 M0 就要 |
|---|---|---|
| `sys_role` | 角色定义 | 登录要返回 `roleCodes`,菜单渲染靠它 |
| `sys_user_role` | 用户 ↔ 角色 | 一个用户可多角色 |
| `sys_permission` | 权限码 / 菜单树 | 登录返回 `menus[]` + `permissions[]` |
| `sys_role_permission` | 角色 ↔ 权限 | 菜单按角色过滤 |
| `sys_login_log` | 登录日志 | PRD §10.3 审计留痕硬要求 |
| `sys_operation_log` | 操作日志 | 同上 |
> ⚠️ **这 6 张不计入 PRD 的「20 张表」**,那是业务表口径。两处分开数,别混。
**内置角色**(来自《03-接口设计》§2.7):
| roleCode | 名称 | userType | 数据范围 |
|---|---|:--:|---|
| `PLATFORM_ADMIN` | 平台运营 | 1 | 全平台(跨租户) |
| `SCHOOL_ADMIN` | 学校管理员 | 2 | 本租户全部 |
| `HEAD_TEACHER` | 班主任 | 3 | 本人所带班级 |
| `DEPUTY_TEACHER` | 副班主任 | 3 | 本人所带班级 |
| `STUDENT` | 学生 | 4 | 仅本人 |
| `AUDITOR` | 审核员 | 2 | 本租户(只读 + 审核) |
---
## 4. 编码约定(M0 定死,后续不变)
### 4.1 统一响应
```json
{ "code": 0, "msg": "success", "data": { } }
```
- **业务错误不占用 HTTP 状态码**——一律 HTTP 200 + `code: 4xxxx`
- 只有 `40101`(未登录)/ `40301`(无权限)/ `42901`(限流)用真 HTTP 状态码
- 分页响应直接放 `data` 里:`{ list, total, pageNum, pageSize }`
### 4.2 多租户
- `tenantId` 从 **JWT 解出**,**绝不接受客户端传参**
- MyBatis-Plus 拦截器自动给所有 `sys_*` / `collect_*` 查询追加 `tenant_id = ?`
- **平台运营(`tenantId=0`)例外**,可跨租户
- 任何 `UPDATE` / `DELETE` 也必须带租户条件(拦截器统一加,不靠人记得写)
### 4.3 软删除
- 字段名 **`deleted`**(`smallint`,`0` 正常 / `1` 已删)
- MyBatis-Plus 逻辑删除插件统一处理,业务 SQL **不写 `deleted = 0`**(手写 SQL 除外)
- **唯一索引必须带 `WHERE deleted = 0`**(PG 部分唯一索引),否则删了不能重建同名
### 4.4 命名
- 表:`sys_` / `collect_` 前缀,蛇形
- **PG 的索引名与约束名必须 schema 内全局唯一**(不是表内唯一),一律加表缩写前缀
- 时间统一 UTC+8;JSON 用 ISO 8601 带时区
### 4.5 提交规范
```
<类型>(<范围>): <简述>
类型: feat / fix / docs / refactor / test / chore / perf
范围: m0 / tenant / org / user / auth / mp / file / export ...
```
---
## 5. M0 执行计划(13 任务 / 40 人日)
分 4 批做,每批结束都有**可演示的东西**。
### 批 1 · 地基(M0-01 / 02 / 03)
| 任务 | 交付物 | 验收 |
|---|---|---|
| M0-01 | `deploy/docker-compose.yml`(PG16 + Redis + MinIO)<br>`deploy/db/` 下 DDL 与种子数据 | 一条 `docker compose up -d` 起全套;<br>`schema-postgresql.sql` + 标准表**零错误执行** |
| M0-02 | 父 POM + `xt-common` + `xt-server` 骨架<br>统一响应、全局异常、错误码枚举<br>多租户拦截器、软删除、字段自动填充<br>JWT 工具、AES/HMAC 工具 | `mvn package` 通过;<br>应用能启动连上 PG + Redis;<br>`/actuator/health` 返回 UP |
| M0-03 | `frontend-pc` 与 `frontend-mp` 骨架<br>请求封装(统一解 `code`)、路由、权限指令 | 两个前端都能 `dev` 起来并调通 `/auth/me` |
### 批 2 · 数据与接口(M0-05 / 06 / 07 / 08)
| 任务 | 交付物 | 验收 |
|---|---|---|
| M0-05 | 租户 / 组织 CRUD(接口 §6.1~§6.7) | 能开通租户 → 建三级组织树 |
| M0-06 | 用户与角色 CRUD + Excel 批量导入(接口 §5.6~§5.9) | 导入 42 个用户,返回体检报告(含 `headerRow`) |
| M0-07 | PC 登录(RSA 解密 + 图形验证码 + 失败锁定 + 审计日志) | 连错 5 次锁定;验证码 2 次后必填 |
| M0-08 | 小程序登录 + 手机号绑定(接口 §5.3 / `POST /mp/bind-phone`) | `code2session` 换 token(本地用 mock) |
### 批 3 · 界面(M0-09 / 10 / 11 / 12)
| 任务 | 交付物 | 验收 |
|---|---|---|
| M0-09 | 管理端框架:布局 / 侧边导航 / 表格 / 表单组件 | 视觉对齐 `prototype/admin-web.html` |
| M0-10 | 组织管理页 + 用户管理页 | 增删改查 + 批量导入全通 |
| M0-11 | 登录页 + 按权限渲染菜单 | 三种角色登录看到不同菜单 |
| M0-12 | 小程序框架与首页骨架 | 视觉对齐 `prototype/student-app.html` |
### 批 4 · 工程化(M0-04 / 13)
| 任务 | 交付物 | 验收 |
|---|---|---|
| M0-04 | CI/CD + 多环境(dev / test / prod)配置 | 一条命令发布到测试环境 |
| M0-13 | **H5 扫码填报保底预研**(只预研不开发) | 出一份结论:uni-app 的 H5 产物能否覆盖 `chooseMessageFile` 的替代路径 |
### M0 总验收(《05-开发任务拆解》原文)
- [ ] 能开通租户 → 建组织树 → 导入 42 个用户 → 各角色登录看到不同菜单
- [ ] 跨租户访问被拦截
- [ ] `schema-postgresql.sql` 在测试环境零错误执行
---
## 6. 关键实现约束(写代码前必读)
这四条是文档里踩过坑总结的,**实现时不许放水**:
| # | 约束 | 出处 |
|:--:|---|---|
| 1 | **并发绑定必须写成 `UPDATE ... WHERE bind_user_id IS NULL`** | 门禁核验,防两人同时抢一个名册项 |
| 2 | **审批通过必须 `SELECT ... FOR UPDATE`** | 防重复审批 |
| 3 | **「无」是合法业务值,不是空值** | 学历"无"存 `"无"`;专项无工种存 `null` + `certType=3`;补考"无"存 `[]` |
| 4 | **导入类接口一律返回体检报告结构**,必须回传 `headerRow` | 实测两份真实文件表头在第 1 行 vs 第 6 行 |
> ⚠️ **一期范围**:`collectMode=2`(社会收集)与 `certType=2/3`(社会评价/专项职业能力)
> **一期不启用**,传入返回 `40044`。字段与枚举保留,二期直接开。
---
## 7. 里程碑节奏(15 周)
| 里程碑 | 人日 | 累计 | 出口条件 |
|---|---:|---:|---|
| **M0** 基础设施与账号 | 40 | 40 | 三端能登录,账号体系可用 |
| M1 任务与配置闭环 | 44 | 84 | 能建任务、配表单与材料规则 |
| M2 名册与门禁 | 58 | 142 | 名单导入 + 门禁核验可用 |
| M3 填报主流程 | 71 | 213 | 学生能传材料、系统能改名压缩 |
| M4 识别与校验 | 34 | 247 | 4 类材料识别 + 21 条 VAL 校验 |
| M5 审核闭环 | 56 | 303 | 班主任初审 / 管理员终审可用 |
| M6 导出与交付 | 32 | 335 | 汇总包可按规范打包下载 |
| M7 收尾与上线 | 52 | 387 | Golden Set 回归通过,上线 |
> **两个无法靠加人加速的单点**:压缩引擎(12 人日)、打包引擎(10 人日)。
---
## 8. 变更记录
| 版本 | 日期 | 变更 |
|---|---|---|
| V1.0 | 2026-09-28 | 初稿。技术选型定版(Boot 4.1.1 + Java 21 + MP 3.5.17 + Vue 3 + uni-app);工程结构定版;补 6 张标准表清单;M0 分 4 批执行计划 |
---
## 9. 版本兼容踩坑记录(M0 实测,全部真实踩过)
> 这一节的价值在于**每一条都是"不报错但行为不对"或"报错信息指向完全错误方向"的类型**,
> 靠读文档和凭经验都躲不掉。团队新同学搭环境前请先读这一节。
| # | 现象 | 根因 | 修法 |
|:--:|---|---|---|
| 1 | `spring-boot-starter-aop` 报 *'dependencies.dependency.version' is missing* | **Boot 4 把 starter 改名了** | 换成 `spring-boot-starter-aspectj` |
| 2 | Flyway 依赖在 classpath 里,**但迁移根本不跑,且不报任何错** | **Boot 4 把自动配置拆成了独立模块**(`spring-boot-flyway`),`flyway-core` 自己不会再触发自动装配 | 用 `spring-boot-starter-flyway` |
| 3 | 加了 starter 后启动报 *Unsupported Database: PostgreSQL 16.15* | **Flyway 10+ 把「数据库支持」也拆成了独立模块** | 再加 `flyway-database-postgresql` |
| 4 | 编译报 *找不到符号 TenantLineInnerInterceptor / PaginationInnerInterceptor* | **MyBatis-Plus 3.5.9+ 把 jsqlparser 相关插件抽到了独立模块**,`extension` 里不再有 | 再加 `mybatis-plus-jsqlparser`(Java 11+ 用这个;老项目用 `-4.9` 变体) |
| 5 | springdoc 启动即错 | **springdoc 3.x 对应 Boot 4,2.x 对应 Boot 3**,混用必炸 | Boot 4 用 `3.1.1` |
| 6 | 自定义 JSON 序列化器写不出来 | **Boot 4 默认 Jackson 3(`tools.jackson`)**,Jackson 2 并存 | 通用层刻意<b>不 import 任何 Jackson 类</b>,序列化配置集中在 `xt-server` |
| 7 | 登录时租户拦截器抛 `40101`,堆栈指向"未登录" | 登录流程走到"查角色"时**还没有登录上下文**,而租户拦截器是**默认拒绝**的 | 密码校验通过后**先 `UserContext.set`**,再查角色与租户 |
| 8 | 新开通租户的管理员**登不进去**,换个租户查又"数据不存在" | 平台运营(`tenantId=0`)建数据时,拦截器把调用者自己的 tenantId 填进了 INSERT | 用 `TenantScope.runAs(目标租户, ...)` 临时切上下文 |
| 9 | 开通租户报 *value too long for type character varying(20)* | **DDL 的 `phone` 列按明文长度建**,装不下 AES-GCM 密文(约 52 字符) | 三列改 `varchar(128)`(见 PRD V2.4 变更记录) |
| 10 | 响应体里多出一个契约之外的 `success` 字段 | Jackson 把 `ApiResult.isSuccess()` 当成布尔属性**自动序列化**了 | 删掉该方法,判断成功一律用 `code == 0` |
### 9.1 一条通用经验
> **Boot 4 / Flyway 10 / MP 3.5.9 这一批版本的改动方向是一致的:把单体拆成模块,让"不用的东西不进 classpath"。**
> 好处是包体积和启动速度;代价是**「依赖在、功能不在」这类静默失败变多了**。
>
> 应对方式:**每加一个新中间件,不要只看"依赖有没有解析成功",必须实测一次它的核心动作有没有发生**
> (Flyway 要看表建出来没有,MP 插件要看 SQL 里租户条件加上没有)。
> 本项目的做法是把这些验证固化成了 `scripts/e2e-m0.py`,一条命令跑完。
### 9.2 本地启动(三条命令)
```bash
# 1) 起基础设施(PG16 + Redis + MinIO)
cd deploy && copy .env.example .env && docker compose up -d
# 2) 起后端(Flyway 自动建库 + 自动初始化账号)
cd backend && mvn spring-boot:run
# 3) 验收自测
python scripts/e2e-m0.py
```
初始化账号(仅 dev,由 `fill.seed.enabled` 控制):
| 账号 | 密码 | 角色 |
|---|---|---|
| `admin` | `Admin@123` | 学校管理员(租户 `demo`) |
| `platform` | `Platform@123` | 平台运营 |
---
## 10. M0 完成情况(2026-09-28)
| 任务 | 交付物 | 状态 |
|---|---|---|
| M0-01 | `deploy/docker-compose.yml`(PG16.15 + Redis7 + MinIO)<br>`docs/db/schema-postgresql.sql` 零错误执行 | ✅ 实测:26 张表(20 业务 + 5 标准 + 1 迁移记录) |
| M0-02 | 父 POM + `xt-common` + `xt-server`<br>统一响应 / 全局异常 / 58 个错误码 / 多租户拦截 / 软删除 / 字段填充 / AES-GCM / HMAC 索引 / RSA | ✅ 编译通过,应用 6.2 秒启动 |
| M0-05 | 租户开通与列表(§6.5~§6.6) | ✅ 含"一次完成建租户 + 发角色 + 建管理员" |
| M0-06 | 组织 CRUD 与组织树(§6.1~§6.3) | ✅ 三层树 + `ancestor_path` 自动维护 + 占用校验 |
| M0-07 | PC 登录(RSA + 图形验证码 + 失败锁定 + 等时比较防枚举 + 审计日志) | ✅ |
| M0-08 | 登出(Token 黑名单)、`/auth/me` | ✅ |
| M0-09~12 | PC / 小程序前端 | ⏳ 未开始(后端接口已就绪) |
| M0-03 / 04 / 13 | 前端骨架 / CI / H5 预研 | ⏳ 未开始 |
### 10.1 验收结果
`python scripts/e2e-m0.py` → **35 / 35 通过**,覆盖:
- 认证:登录 / 错误密码 / 账号枚举防护 / 未登录拦截 / 伪造 token
- 权限:两种角色登录看到不同菜单;学校管理员无平台权限
- 开通租户:建租户 + 发角色 + 建管理员;初始密码可登录;编码重复被拒
- 组织树:三层结构;编码唯一性;类型层级校验;有下级不可删
- **跨租户隔离**:新租户看不到其他租户的组织;越权调平台接口得 `40301`
### 10.2 尚未实现(不属 M0 后端范围)
| 项 | 计划 |
|---|---|
| 用户 CRUD 与 Excel 批量导入(§5.6~§5.9) | M0 剩余 |
| 图形验证码接口的端到端联调(接口已通) | 随前端一起验 |
| PC 管理端 / 小程序前端 | M0-03/09/10/11/12 |
| CI/CD、H5 预研 | M0-04 / M0-13 |
+5 -5
View File
@@ -6,7 +6,7 @@ CREATE TABLE sys_tenant (
tenant_name varchar(128) NOT NULL,
tenant_type smallint NOT NULL DEFAULT 1,
contact_name varchar(64),
contact_phone varchar(20),
contact_phone varchar(128),
package_id bigint,
expire_time timestamp,
status smallint NOT NULL DEFAULT 1,
@@ -26,7 +26,7 @@ COMMENT ON COLUMN sys_tenant.tenant_code IS '租户编码(短码,用于小程序
COMMENT ON COLUMN sys_tenant.tenant_name IS '租户名称(学校/机构)';
COMMENT ON COLUMN sys_tenant.tenant_type IS '1学校 2认定机构 3报名点 4企业';
COMMENT ON COLUMN sys_tenant.contact_name IS '联系人';
COMMENT ON COLUMN sys_tenant.contact_phone IS '联系电话';
COMMENT ON COLUMN sys_tenant.contact_phone IS '联系电话(AES加密存储;varchar(128)容纳密文,非明文长度)';
COMMENT ON COLUMN sys_tenant.package_id IS '套餐ID';
COMMENT ON COLUMN sys_tenant.expire_time IS '到期时间';
COMMENT ON COLUMN sys_tenant.status IS '0停用 1正常 2试用';
@@ -85,7 +85,7 @@ CREATE TABLE sys_user (
username varchar(64) NOT NULL,
password varchar(128),
real_name varchar(64) NOT NULL,
phone varchar(20),
phone varchar(128),
phone_hash char(64),
open_id varchar(64),
union_id varchar(64),
@@ -111,7 +111,7 @@ COMMENT ON COLUMN sys_user.tenant_id IS '0=平台运营';
COMMENT ON COLUMN sys_user.org_id IS '所属组织';
COMMENT ON COLUMN sys_user.username IS '登录名(教师)';
COMMENT ON COLUMN sys_user.password IS 'BCrypt';
COMMENT ON COLUMN sys_user.phone IS '手机号(AES加密存储)';
COMMENT ON COLUMN sys_user.phone IS '手机号(AES加密存储;varchar(128)容纳密文,非明文长度)';
COMMENT ON COLUMN sys_user.phone_hash IS '手机号HMAC,用于唯一检索';
COMMENT ON COLUMN sys_user.open_id IS '微信openid';
COMMENT ON COLUMN sys_user.id_card_no IS '证件号(AES加密存储)';
@@ -263,7 +263,7 @@ CREATE TABLE collect_roster (
id_card_no varchar(128) NOT NULL,
id_card_hash char(64) NOT NULL,
id_card_mask varchar(32),
phone varchar(20),
phone varchar(128),
phone_hash char(64),
ext_json jsonb,
bind_user_id bigint,
+57 -7
View File
@@ -35,6 +35,39 @@
---
## 一·三、开发进展(2026-09-28 起)
> **代码位置**:`backend/`(Java 21 + Spring Boot 4.1.1)、`deploy/`(基础设施)、`scripts/`(工具)
| 项 | 状态 |
|---|---|
| 技术栈定版 | ✅ Java 21 + **Spring Boot 4.1.1** + MyBatis-Plus 3.5.17(boot4) + PG16 + Redis7<br>PC: Vue3 + Element Plus;小程序: uni-app |
| M0 后端骨架 | ✅ 统一响应/异常/58 错误码/多租户拦截/软删除/字段填充/AES-GCM/HMAC/RSA/JWT/权限模型 |
| M0 数据库 | ✅ Flyway 2 个迁移;26 张表(20 业务 + 5 标准 + 1 迁移记录) |
| M0 后端接口 | ✅ 租户开通与列表、组织 CRUD 与树、PC 登录/登出/当前用户 |
| M0 验收 | ✅ **`python scripts/e2e-m0.py` → 35/35 通过** |
| 待做 | 用户 CRUD 与 Excel 导入;PC/小程序前端;CI/CD;H5 预研 |
**关键实现决策(不要在后继开发中改回去)**
| 决策 | 理由 |
|---|---|
| **权限码定义在代码里**(`PermissionDef` / `BuiltinRole`),不建 `sys_permission` 表 | 权限码是前后端契约,放 DB 必然与代码漂移;一期无"权限配置界面"需求 |
| **租户拦截器默认拒绝**:匿名请求触及租户表直接抛 40101 | 漏加租户条件 = 越租户泄露全校身份证号,不可接受的失败模式;宁可让开发者立刻撞墙 |
| 匿名接口只靠 `@PublicApi` 放行,**不做路径白名单** | 两套机制会让"哪些接口公开"出现两个来源,改一处漏一处 |
| **登录时账号不存在也跑一次 BCrypt**(假哈希) | 否则响应时间差会泄露"账号是否存在",直接把密码喷洒成功率提高几个数量级 |
| 密码 RSA 加密传输 | 不是替代 HTTPS,而是防止网关/APM/链路追踪把明文密码记进日志 |
| 登出黑名单**存 token 的 SHA-256**,TTL 到自然过期 | token 本身是凭证,明文写进 Redis 等于复制了一份到别处 |
**四个必须实测的坑**(详见 `07-开发实施计划.md` §9,10 条实录)
Boot 4 / Flyway 10 / MP 3.5.9 这一批版本都在"把单体拆成模块",
导致**「依赖在、功能不在」的静默失败变多**。典型:只加 `flyway-core` 时迁移**不跑也不报错**。
**规则:每加一个中间件,必须实测它的核心动作真的发生了,不能只看依赖解析成功。**
---
## 一·五、客户回执(2026-09-28 · 需求已冻结)
> **客户全部 23 项按建议方案确认。** 唯一功能增量是 **B1:名单加入申请通道**。
@@ -82,21 +115,21 @@
| 文件 | 版本/大小 | 状态 |
|---|---|---|
| `docs/数据信息填报系统PRD.md` | V2.3 / 173KB / 2816 行 | ✅ 含 115 条 FR、17 条 VAL、96 接口、19 表 DDL、实测附录 |
| `docs/数据信息填报系统PRD.md` | V2.4 / 189KB / 3019 行 | ✅ 含 **125 条 FR**、**21 条 VAL**、**121 个接口**、**20 表 DDL**、实测附录 |
| `docs/数据信息填报系统PRD-功能逻辑卷.md` | V1.9 / 88KB | ✅ 纯功能逻辑,**不含库表/接口/代码** |
| `docs/db/schema-postgresql.sql` | 34KB | ✅ **PG 16.15 实测零错误**(19 表 / 167 列注释 / 61 索引) |
| `docs/db/schema-postgresql.sql` | 37KB | ✅ **PG 16.15 实测零错误**(20 表 / 177 列注释 / 65 索引 / 20 表注释) |
| `docs/prototype/student-app.html` | **11 屏** / 57KB | ✅ 含 STEP 03B「申请加入名单」 |
| `docs/prototype/teacher-app.html` | 9 屏 / 63KB | ✅ |
| `docs/prototype/admin-web.html` | **11 屏** / 114KB | ✅ 含表单设计器、材料规则配置、历史整理器、**申请审批** |
| `docs/prototype/index.html` | 7KB | ✅ 三端入口 |
| `docs/00-交付计划.md` | — | ✅ 7 项计划与进度记录 |
| `docs/01-客户与机构确认函.md` | 18KB | ✅ **P1** 23 项**已全部确认**(客户按建议执行,需求冻结) |
| `docs/01-客户与机构确认函.md` | 23KB | ✅ **P1** 23 项**已全部确认**(客户按建议执行,需求冻结) |
| `docs/02-需求追溯矩阵.md` | 10KB | ✅ **P2** 审计报告,含 10 个接口缺口 |
| `docs/03-接口设计.md` | **102KB / 2626 行** | ✅ **P3 117 个接口**字段级定义 + 枚举全集 + 12 个 VO + 50 个错误码;**自校验通过** |
| `docs/06-验收测试清单.md` | **29KB** | ✅ **P6 36 条 Golden 断言** + **111 条功能验收** + **27 条异常边界** |
| `docs/03-接口设计.md` | **115KB / 2883 行** | ✅ **P3 121 个接口**字段级定义 + 枚举全集 + 12 个 VO + **58 个错误码**;§4 逐节声称数与表行数一致 |
| `docs/06-验收测试清单.md` | **33KB** | ✅ **P6 36 条 Golden 断言** + **111 条功能验收** + **27 条异常边界** |
| `../README.md` | **10KB** | ✅ **P7 文档导航**,按角色读法 + 快速上手 |
| `docs/04-核心模块设计说明.md` | **56KB / 1218 行** | ✅ **P4 六个模块**伪代码级说明 + 依赖图 + Golden 基线 |
| `docs/05-开发任务拆解.md` | **28KB / 468 行** | ✅ **P5 94 个任务 / 380 人日 / 14 周**,含关键路径与核算口径 |
| `docs/04-核心模块设计说明.md` | **69KB / 1453 行** | ✅ **P4 六个模块**伪代码级说明 + 依赖图 + Golden 基线 |
| `docs/05-开发任务拆解.md` | **31KB / 506 行** | ✅ **P5 100 个任务 / 387 人日 / 15 周**,含关键路径、一期范围与核算口径 |
---
@@ -190,6 +223,23 @@ A4 扫描件 300dpi(2480×3508)/q85 · 一寸照片固定 295×413 · 学籍表
### 教训一:脚本审计前必须先验证脚本本身
P2 审计时脚本因未匹配加粗编号 ``| **FR-614** |``,误报 3 个"需求条目问题"(实为假警报);若直接采信并"修复",会引入 3 处新错误。同时 FR 总数由误算的 102 修正为 **115**。
> **⚠️ 这条教训在 2026-09-28 写开发实施计划时又复现了一次。**
> 我为交接摘要重算交付物体量,用了三个正则去数"接口数",分别得到 **74 / 95 / 121**——
> 三个都"看起来对",但没有一个经过验证。
>
> 定位过程:**逐节对比「标题声称数」与「实际表行数」**,发现
> ① 我漏了 `| ⭐ GET | ... |` 这种 ⭐ 在方法**前面**的行;
> ② §4.9 / §4.11 是**索引节**(交叉引用别处已计数的接口),本就不该计入。
> 修正后逐节声称数与表行数**全部为 0 差**,合计 **121**。
>
> **加固**:`scripts/verify-docs.py` 现在会做「**逐节声称数 vs 实际行数**」的一致性检查,
> 而不是只看总数对不对——总数相等也可能是个别节多算、个别节少算恰好抵消。
>
> **可复用的规则**:
> 1. 数字类断言必须**交叉核对至少两种独立方法**;
> 2. 核对要**分块**(逐节/逐表),不能只看总和;
> 3. 相互矛盾的中间值**一个都不要先写进文档**——写错再改,比一开始就空着更容易误导人。
### 教训二:写新文档必须回查已有编号体系,不能凭记忆
P4 写 M5 校验引擎时,我凭记忆用了一套 VAL 编号,**与 PRD 权威清单整体错位**:
+8 -2
View File
@@ -11,6 +11,7 @@
| 版本 | 日期 | 变更内容 | 变更人 |
|---|---|---|---|
| **V2.4** | **2026-09-28** | **修正 3 处加密列宽不足(DDL 缺陷,M0 实测暴露)**:`sys_tenant.contact_phone`、`sys_user.phone`、`collect_roster.phone` 原为 `varchar(20)`,是按<b>明文手机号</b>长度建的;但 §10.3 要求手机号 AES-256 加密存储,AES-GCM 密文约 <b>52 字符</b>,插入直接报 `value too long for type character varying(20)`。(`id_card_no` 当初给了 `varchar(128)` 就是考虑到了加密,手机号三列漏了——属设计不一致。)三列统一改为 <b>`varchar(128)`</b> 与 `id_card_no` 对齐。<br>**发现方式**:M0 开通租户实测时数据库报错暴露,非人工审查发现。<br>**审计结论**:其余 `*_mask varchar(32)` 列存的是脱敏值(如 `138****8888`)而非密文,宽度足够,无需改动。 | 开发 |
| V1.0 | 2026-09-24 | 初稿:背景/流程/功能/规则引擎/库表/接口 | 产品 |
| **V1.1** | 2026-09-24 | **复审修订**(用 112 个真实文件重新实测验证):<br>① **勘误**:命名违规率 98.2% → **52.7%**(59/112);双扩展名 55 → **56**;原始总量 436MB → **475MB**(436MB 实为 .rar 包体积);需处理并集 62 → **59**(原为重复计数)<br>② **推翻参数假设**:工作证明是 1272dpi A4 扫描件(非手机拍照)→ §6.3 压缩策略由"按长边"重写为"按材料形态分五档"<br>③ 学籍表是 16:9 截图 → 新增 P4 截图档,禁止缩放与转 JPG<br>④ 新增 §6.1 证件号裁决矩阵,人工量由 5 例降至 3 例<br>⑤ **新增风险 R11**(1.8 亿像素导致 OOM)、**R12**(微信订阅消息次数限制)<br>⑥ **补全 §9.4 班主任移动端页面清单**(初稿遗漏)<br>⑦ 修正 §1.4 指标口径混淆(班主任操作时长 vs 端到端周期)<br>⑧ 补充小程序隐私保护指引、DPA、KMS 等合规项<br>⑨ 补 `collect_task_org` 表、名册 `student_no` 字段、附录 D 错误码、附录 E 定时任务 | 产品 |
| **V1.2** | 2026-09-24 | **压缩决策 D1 落定**:① §6.3 P3 扫描件档由"建议放宽至 2M"改为 **≤1M 硬约束、无例外**;② 压缩机制改为"体积优先、清晰度可牺牲,但必须标记 + 人工抽检";③ 风险 R1 由"要不要压"改为"压完机构认不认";④ 待确认项 Q1 / Q2-d1 关闭;⑤ **拆出《功能逻辑卷》**,本文不再承载功能运转细节 | 产品 |
@@ -2911,8 +2912,8 @@ export/{tenantId}/{taskId}/{exportId}/{批次号}-{专业}-{班级}.zip
| `40005` | 证件号与名册不一致 | 展示名册值,要求确认 |
| `40006` | 疑似串档(校验位均通过但与他人不符) | 转人工,不自动修正 |
| `40007` | 门禁未匹配(姓名或身份证号与名单不符) | **统一提示**"没有找到你的信息",提供**重新填写 / 申请加入名单**(不透露是哪一项错) |
| `40007b` | 门禁尝试过于频繁(触发限流) | 提示"操作过于频繁,请稍后再试" |
| `40007c` | 名单项已被他人绑定 | 提示"该信息已被登记,如有疑问请联系老师" |
| `40071` | 门禁尝试过于频繁(触发限流) | 提示"操作过于频繁,请稍后再试" |
| `40072` | 名单项已被他人绑定 | 提示"该信息已被登记,如有疑问请联系老师" |
| `40008` | 任务已截止 / 已归档 | 禁止提交,提示联系管理员 |
| `40009` | 重复提交(幂等键冲突) | 返回既有结果 |
| `40010` | 驳回次数超上限 | 提示联系班主任 |
@@ -3010,3 +3011,8 @@ ERROR: relation "collect_task" does not exist ← 上面第 1 个错误的
---
**文档结束**
---
> **实现说明(2026-09-28)**:本文件中的 `40007b` / `40007c` 已规范化为 **`40071`** / **`40072`**。
> 原因:`code` 按 §1.3 约定是数字,带字母后缀会在 JSON 里变成字符串,破坏前端 `code === 0` 的判断。