Commit Graph

25 Commits

Author SHA1 Message Date
lovife 2ff17cd0c9 refactor(h5)!: H5 改由 nginx 托管,后端退回纯 API
用户决定:后端不再对外发静态文件,学生端 H5 交给网关。两者**仍然同源**
(微信只给一个授权回调域名;H5 的接口地址取自 location.origin),
所以前端业务代码、微信后台、application-dev.yml 的 h5-base-url 都不用改。

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

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

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

验证
- mvn clean package 通过(必须 clean:删掉的旧类会留在 target/classes 被组件扫描)
- 后端直连:/ 与 /assets/* → 404({"code":40001,"msg":"接口不存在"})、
  /api/.../captcha → 200 + no-store、/doc.html → 302→200、/v3/api-docs 与 /actuator/health → 200
- 经 nginx(80):首页 200 + no-cache、/assets/*.js 200 + immutable、/nope.js 404(无回退)、
  /api 200 + no-store、gzip 生效;2MB multipart 上传 → 401(与直连一致 ⇒ 未踩 1m 的坑)、
  55MB → 413
- e2e(XT_BASE=http://localhost,全程走 nginx):e2e-m0.py 37/37、e2e-m3-h5-auth.py 24/24、
  e2e-m2-scope.py 38/38
- verify-all.py --quick 6/6;check-test-profile.py:test profile 仍能起来
2026-10-19 17:35:33 +08:00
lovife 1f3c485ceb feat(role,scope)!: 角色与授权可配 + 需求收敛(4 角色 / 10 菜单 / 删两模块)
角色与授权(交接摘要 §一·三十五):
- 权限判定合并两个来源:内置角色按代码(BuiltinRole)、自定义角色按库
  (sys_role_permission)。实现放 module 层的 DbRoleGrantSource,security 只依赖
  接口 RoleGrantSource —— 否则角色服务要调 permissionChecker,两个包迟早成环
- 权限判定必须加缓存:此前是零 IO,而压测基线是"SQL 条数恒定"。缓存放 Redis,
  空结果也缓存(否则已删角色的 token 会把库打穿)、Redis 挂了退化成直读库、TTL 只兜底
- V18 建 sys_role.user_type(账号的 user_type 一直从角色反推,自定义角色不在代码映射里)
  与 sys_role_menu;8 个接口 + 错误码 40045;契约 119 -> 129
- 内置角色只读(权限码是前后端契约,做成可改 = 一次误操作就能把管理员锁在系统外);
  平台级能力两道防线(界面禁用 + 服务端 40301);防自锁;改完立即生效(写授权清缓存)
- RoleProvisioner 从"已存在就 continue"改成幂等回填 + 每日 03:20 定时任务 ——
  修的是"代码改了权限、已开通租户永远停在发放那一刻",这种漂移不报错也没测试会红
- scripts/e2e-m10-role.py 77/77

需求收敛(交接摘要 §一·三十六,本节的数字为准):
- 删副班主任 / 审核员两个内置角色(6 -> 4)、历史整理器(module/cleanup)、
  加入申请(module/join + 学生端 pages/join)、collect_task_org.deputy_user_ids 整列;
  审核范围三级回退 -> 两级,连带 6 个 mapper 的 LIKE '%,id,%' 分支
- V19 迁移处理存量:先解 sys_user_role 绑定再软删角色;账号只在"除这两个角色外没有
  别的角色"时才软删。必须迁而不能只删代码 —— 角色码删掉后 mergePermissions 返回空集,
  存量账号会"能登录、处处 40301",不抛异常不打日志
- 契约 129 -> 119、错误码 59 -> 54、权限码 30 -> 28、PC 菜单 12 -> 10、
  小程序页 10 -> 9、原型 32 -> 29 屏、E2E 39 -> 37 个(断言 1695 条)
- UserService.list 对不认识的角色码由"静默丢掉过滤条件"改成失败关闭
  (之前传个不存在的 roleCode 会返回全校账号,且看起来完全正常)
- 品牌元信息同步改名「深学在线」:pom 名称、/doc.html 标题、配置与启动类注释、
  存储自检探针内容;学生端 app 名与全局导航栏标题
2026-10-10 16:48:54 +08:00
lovife 6945f194b5 chore(ci): CI/CD 三阶段 6 job + test/prod 环境 + 启动前的配置自检
.gitlab-ci.yml:契约与实体对账 / 后端编译 / 管理端类型检查与构建 / 学生端 H5 构建 /
接口断言(全套 E2E 打真库)/ 生产环境自检;密钥每次随机生成,顺带证明应用没有偷偷
依赖 dev 默认值。

ProdEnvCheck 是 EnvironmentPostProcessor,跑在任何 bean 之前,拦 6 类「配错了也能启动」;
判「没配」与「配了」要 try { getProperty } catch(F-86)——
getProperty 会解析占位符、失败就抛,只读 PropertySource 又读不到 env 的值。

删掉 Gi 模板残留的 GitHub Pages 工作流(远端是 GitLab、用 pnpm,且它发布的是没有 API 的静态页)。

验证:scripts/e2e-prodguard.py 9/9(三种误配形态都拒绝启动)。
2026-09-30 09:02:57 +08:00
lovife 9216bd66d2 feat(schedule): 定时任务 8 项 + 用量快照/压缩重试/到期提醒(V17)
PRD 附录 E。每个任务都是两段式:先以平台身份(tenantId=0)扫候选,再逐个租户摆上下文
干活——写数据必须在第二段,否则 tenant_id 被写成 0,通知进平台租户且不报错。

V17 新建 sys_tenant_usage_snapshot(唯一索引保证幂等;tenant_id 必须显式写)并给
collect_file 补 process_retry_count / process_error / last_retry_time + 只索引
「还没处理出规范件」的部分索引;压缩失败重试必须封顶,否则原图损坏的文件会被每 10
分钟重试一次而永远不成功。

跨租户的候选查询全部集中在 ScheduleMapper 一个文件、一律 @InterceptorIgnore + 只读;
Map 结果的别名一律 AS "camelCase"(F-83:PG 会把未加引号的别名折成小写,
表现是任务照跑、日志一行 warn、什么都没做)。

验证:e2e-m7-schedule 38/38(含快照幂等与一致性、重试累加与封顶、到期提醒窗口)。
2026-09-30 09:02:55 +08:00
lovife efde4627eb feat(task): 规则模板、任务克隆与统计、组织导入、租户用量(V15)
克隆**配置**(表单/材料清单/命名模板/审核配置/覆盖班级)、刻意不克隆业务数据——
名册克隆过去会让同一证件号在两个任务里各有一行。
统计补上 abnormalCount 与 avgAuditHours(这两个字段以前恒为 null,因为问题表与审核记录
还不存在);规则模板可按 parts 只应用一部分、材料清单覆盖式合并。

顺带修 F-44:TaskService.create 对缺必填项抛 NPE(对外 50000「系统繁忙」),
改为集中校验并指出是哪个字段;F-45:历史整理器导入改走 TaskService.create,
避免两处各生成一次 task_code 迟早撞号。

验证:e2e-m7-taskconfig 69/69(含待办范围判据的回归防线)。
2026-09-30 09:02:55 +08:00
lovife aa30fd2383 feat(open): §14 开放接口(V16)—— HMAC 签名对接认定机构
密钥存 AES 密文(HMAC 要能还原,不能只存哈希);签名 = HMAC-SHA256 + 5 分钟时间窗
+ nonce 防重放,先验签再占 nonce;数据接口一律不返回明文证件号,文件走 30 分钟签名 URL;
推送重试的 next_retry_at 落库(内存定时器重启后就没了),推送挂在 afterCommit 上。

契约漏掉的接入方管理(F-67)一并补上;规则模板两个接口修回契约路径(F-68)。

验证:e2e-m8-open 60/60。
2026-09-30 09:02:54 +08:00
lovife 0d3aae75fa feat(cleanup): 历史数据整理器(§13.2,V13)—— 解压解析、体检报告、导入为正式任务
从「人长出来的」历史路径里解析姓名/证件号/材料/班级 → 与名册比对 → 按模板重打包 →
体检报告(autoHandled / needManual 由服务端算,前端只展示,不自己减)→ 幂等导入。

F-48 的教训:中文人名与业务短词无法用「包含」区分——噪声词只做精确匹配,
候选名按「完整中文串 2~4 字 + 不含数字 + 不含班级词」判定。
F-49:参数校验必须排在幂等判断之前(否则不合法的请求会因「之前导过一次」变成成功)。

验证:e2e-m7-cleanup 54/54(含「坏包要明确失败」与「无名单时串档恒为 0」)。
2026-09-30 09:02:54 +08:00
lovife 7090ee7d65 feat(ops): 操作日志留痕、字典管理、名册改删(V9 / V10)
PRD FR-506/FR-1202 的 P0 项:V9 给 sys_operation_log 补 target_type/target_id/
before_val/after_val,OperationLogService 显式留痕并统一强制脱敏;
已接入的敏感动作:名册改证件号/改名/删名册项、任务发布/暂停/恢复/截止/归档。

F-32 的教训落地:动作名由调用方显式给,不从 (原状态, 新状态) 反推——
「发布」与「恢复」都是 →收集中,反推必然把 resume 记成 PUBLISH_TASK。

字典:内置字典写在代码里(DictCatalog),库里只存客户自定义项与内置项停用开关,
两表分开才能「一键恢复默认」;表单下拉与字典共用同一份口径(BuiltinFields 加访问器)。

验证:e2e-m5-logs 44/44、e2e-m1-dict 53/53。
2026-09-30 09:02:53 +08:00
lovife 29c8e932ae feat(notify): 消息中心、催报、通知模板与微信回调 + 校验问题清单(V8 / V14)
消息中心 + 订阅额度(原子扣减,先扣再发——订阅消息是一次性授权)+ 催报(定向/一键,
按 bizType 去重 24 小时);通知降级链只做能真正走通的两级(订阅消息 → 站内信),
公众号模板与短信如实少做并写明理由:永远失败的通道会把真正的原因淹掉。

校验问题清单对齐语义:已有→只更新内容不碰 resolved / 新增→插入 / 消失→标已解决;
上传时把「系统改过文件名」记成 VAL-06 自动修复,让 byRule.autoFixed 有真实数据。

微信回调必须偏离两条全局约定(匿名入口、响应体是 {"errcode":0}):只写推送状态不读
业务数据 + 必须验签 + 凭发出时记的 id 反查租户(查不到就报错,绝不猜一个租户去写)。

验证:e2e-m5-notify 48/48、e2e-m5-urge 40/40、e2e-m7-validation 70/70。
2026-09-30 09:02:53 +08:00
lovife bb91515590 feat(join): B1 名单加入申请 —— 学生提交 + 管理端审批(V7 存 AES 密文)
按契约「服务端转 HMAC、不落明文」设计会写不进名册:collect_roster.id_card_no 是
NOT NULL 的密文列,而申请表只有 hash 与 mask(三条替代路都被排除)。
V7 给 collect_join_request 补 id_card_cipher(与名册同一把密钥/算法),审批时原样搬进名册,
明文全程只在提交请求的内存里存在过。V7 之前的老申请明确提示重新提交,不静默建一条
永远过不了校验的名册行。

另修 F-20:自述班级不能借用姓名规范化(姓名要「张 三」→「张三」,班级名里的空格有意义)。

验证:scripts/e2e-m2-join.py 50/50。
2026-09-30 09:02:52 +08:00
lovife ad86e2d521 feat(mp): 学生端「我的填报 / 撤回 / 进度」与班主任端看板、审核卡片流、代传材料
学生端:待办数、状态、驳回原因、截止提醒、撤回;进度页的材料清单与进度条
分母用「这个人本次真的要交什么」(不适用的必传材料不进分母,否则永远填不满)。

班主任端:已交率与已通过分开呈现(已提交 ≠ 已通过)、右滑通过 / 左滑不绑驳回、
一键通过只带无问题项;代传走一步 multipart,三道闸门(只给自负责的班 / 只给草稿单 /
必须留痕且学生可见),V12 给 collect_file 补 proxy_by / proxy_reason。

验证:e2e-m3-mytask 42/42、e2e-m5-teacher-dashboard 24/24、e2e-m5-proxy 33/33;
H5 build:h5 产物含新增各页。
2026-09-30 09:02:52 +08:00
lovife 6cca97efe6 feat(export): 导出与交付(M6)—— 前置检查、目录树预览、分卷打包与 7 天过期清理
客户最关心的那条链:precheck(不通过就没有 force 分支,铁律 10)→ 目录树预览
(与真正打包共用同一份构建代码,预览不会说谎)→ 异步分卷(每卷都是完整可解的 ZIP)
→ 产物落表按行判可下载(过期回「已被清理、请重新导出」)。

契约没有但界面必需的 GET /exports/precheck 一并补上(否则按钮要点一次才知道能不能导)。

顺带修 F-11:severity 口径统一为 INFO=1/WARN=2/ERROR=3(三处注释曾互相矛盾,
按直觉写会把「提示」当阻断,正常数据也导不出来)。

验证:scripts/e2e-m6-export.py 60/60、e2e-m6-export-split.py 33/33;
ZIP 的 UTF-8 标志位(general purpose bit 11)与 sha256 都有断言。
2026-09-30 09:02:50 +08:00
lovife e6cd1049d4 chore(tenant): 租户域名改为默认关闭(客户决定先不做)—— 能力保留,关得干净
客户:先不做多租户了。

处理:fill.tenant-domain.enabled 默认 true → **false**,代码全部保留。
理由:
  · 这个能力是隔离的:关掉后 TenantDomainResolver 一律返回 null,
    登录走老路径、AuthInterceptor 那段校验根本不触发 —— 不影响任何既有行为
  · 它已经验过(§一·二十五 16/16),删掉等于扔掉一份验过、将来要用的东西
  · 要真用上它,前提是有正式域名 + 泛域名解析 + 泛域名证书;这些没定之前
    开它只会让人以为"域名能区分单位了",实际并不能。等定了改一个配置 + 重启即可。

「关得干净」是本轮的验收点:E2E 改成**两种模式都能跑**,按当前模式选断言。
现在跑的是关闭态这一节(6 条):
  ✓ t1.localhost + 不填租户码 → 40012(域名不参与判断)
  ✓ 同一域名 + 填租户码 → 照样能登;主域名 + 租户码 → 照样能登
  ✓ 主域名 + 不填租户码 → 仍是 40012(跨单位重名的原行为)
  ✓ 关掉之后不引入任何新校验:带 t1 的 token 访问 t14 域名照常通过
脚本自己探测模式(免租户码登录成功=开、40012=关),所以开着时跑它会跑那 16 条,
不会因为模式不对而假绿。

将来开启:填 base → 配泛域名解析+证书 → XT_TENANT_DOMAIN_ENABLED=true → 重启 → 跑 E2E。
两条硬约束不变:微信授权回调域名只能配一个(回调统一落主域名再按 scene 跳回)、
小程序域名固定白名单(小程序端永远靠 scene)。

随之暂缓:§一·二十五 的三条待拍板(正式域名 / 手填租户码 / 小程序确认)现在都不用追了;
PRD 与 03-接口设计的补文档也等真要做时再写(现在写进去只会多一条"设计了但没启用")。

全量 666 条断言、0 失败(租户域名 16 → 6,因为跑的是关闭态那节)。
2026-09-29 21:42:00 +08:00
lovife d05cec0a02 feat(tenant): 租户域名 —— 用子域前缀判断"这是哪个单位的站点"(t1.xxx.com / t2.xxx.com)
客户新提:不同的租户采用不同的地址前缀来获取租户ID。

一、它解决什么
   在此之前"这次是哪个单位"都要人填或扫:PC 登录要手填租户码(跨单位重名时报 40012
   「请填写租户码」,而用户根本不知道该填什么)、H5 与小程序靠扫码 scene。
   而域名本身就是"我是谁"最自然的答案 —— 在 t1.xxx.com 上打开页面时,
   系统不该再问"你是哪个单位的"。
   前缀用 t{tenantId},与扫码 scene 的 t{tid} **同一套约定**(scene 早就是 t1-k17),
   不引入第二套编号。

二、★ 唯一必须守住的规则:Host 定位,token 授权
   未登录 → Host 决定"去哪个单位里找这个人"
   已登录 → 一律以 token 里的 tenantId 为准,且 Host 不一致就拒绝
   不校验后者的话:在 t1.xxx.com 上拿 t2 的 token 就能读 t2 的数据 ——
   而界面一切正常(前端本来就会带 token),是个安静且极难发现的越权。
   所以 AuthInterceptor 里那段判断比"登录免填租户码"重要得多,E2E 专门守着它。

三、落地
   FillProperties.TenantDomain(enabled/base/scheme)  默认关,siteOf(tid) 拼站点地址
   support/TenantDomainResolver                      解析不出来返回 null(不是错误)
   TenantMapper.selectActiveById                     停用/已删的租户不解析
   UserMapper.selectByTenantIdAndUsername            域名路径按 id 找用户
   AuthService.locateUser                            域名优先;手填租户码与域名矛盾 → 明确报错
   AuthInterceptor                                   Host 与 token 租户不一致 → 403
   MpH5AuthController.redirectToApp                  按 scene 的 t{tid} 跳回该单位站点
   application-dev.yml                               enabled/base=localhost/scheme=http

   null 的语义很重要:解析不出来 ≠ 出错,而是"这是统一入口/单域名部署",回退老路径。
   所以单域名部署仍然完全合法(enabled=false 即可)。

四、两条来自微信的硬约束(决定架构,不是可选)
   1. 公众号网页授权的「授权回调页面域名」**只能配一个** → 每租户一个子域时,
      回调统一落在主域名,再按 scene 里的 t{tid} 302 回该单位站点。
      正好复用既有的 state(scene 随 state 走)+ token 放 fragment 的机制。
   2. 小程序的 request 合法域名是**固定白名单**,配不了 *.xxx.com →
      小程序端用不了域名,继续靠 scene 里的 t{tid}。这点客户需要知道。

五、本地开发的坑(已处理)
   开发期 base 是 localhost,真按它回跳会把手机上的学生从穿透域名扔到 t1.localhost ——
   打不开,且是浏览器级失败,看不出是我们跳错了。所以回跳加了本机地址判断
   (localhost/127.0.0.1/192.168.*/10.*/*.local),本机 base 一律回退到 h5-base-url。

六、E2E:scripts/e2e-m1-tenant-domain.py → 16/16
   [1] t1.localhost + 不填租户码 → 登录成功(对照:不带域名 → 40012);
       带端口/大小写/多一层子域都能解析
   [2] 域名与手填租户码矛盾 → 明确报错;一致则正常
   [3] ★ 拿 t1 的 token 访问 t14 域名 → 403,提示可理解
   [4] 边界:x1.localhost / t999999.localhost / example.com → 都回退老路径
   用 http.client 伪造 Host 头(urllib 会按 URL 自己算 Host,覆盖不掉)。

七、全量 676 条断言、0 失败(新增 16);verify-docs 19 项通过

八、⏳ 需要拍板的三件事(已写进交接摘要 §一·二十五)
   1. 正式域名(base)+ 泛域名解析 + 泛域名证书 *.xxx.com(备案一个主域名即可覆盖子域)
   2. 要不要保留"手填租户码"(我保留了:统一入口、老客户端、运维排查都需要)
   3. 小程序端确认继续靠 scene
   另:这条新需求应补进 PRD 与 03-接口设计(目前只落在代码与本摘要里)——下一轮补。
2026-09-29 21:37:11 +08:00
lovife 12067b8ae4 fix(file): 修掉 EXIF 朝向 bug(竖拍照片会躺倒)+ 上传改一步 + 默认不留原始件
一、客户决定:不用保持原件,只要压缩满足效果
   fill.storage.keep-raw 开关,默认 false。交付物就是压缩后的规范件。
   顺带把上传从两步变一步:
     原来 upload(存原图)→ process(读原图→压缩→存规范件)
     现在 upload(收上来→立刻压缩→只存规范件)
   留原始件是两步存在的**唯一理由**;不要它之后这步只剩代价
   (多一个"传了但没处理"的中间态、多一次往返、多一份存储)。
   process 保留为"重新规范化",但没有原始件时**直接返回现状而不报错**。

二、🐛 抓到一个会命中绝大多数真实照片的 bug:EXIF 朝向
   手机竖拍时**像素其实是横的** + 一个 Orientation=6(显示时转 90°)。
   浏览器认这个标记所以看着是正的;而 ImageIO **不读 EXIF**、重编码又丢掉它 →
   输出成了"像素横着、也没有标记"的图 → **机构看到的是一张转了 90° 的身份证**。

   实测(修前):输入 3200×2400(横)+ O=6 → 输出 1600×1200(仍横)、EXIF=None ❌
   修后:1200×1600(竖)✓ 且输出不再带 EXIF(否则会被转两次)

   **为什么 E2E 一直没抓到**:测试图都是程序生成的、不带 EXIF;
   而真实学生手机拍的竖拍照片几乎全带 —— 这个 bug 会命中绝大多数真实材料,
   是靠"想到这一层"发现的,不是靠测试。

   实现:module/file/service/ExifOrientation.java
     · 自己解析 JPEG 的 APP1/EXIF(只用一个 tag,三十行,不引新依赖)
     · **读不出来时一律"不转"** —— 宁可保持原样,也不凭猜测把图转错
     · 八种朝向用**逐像素索引映射**,不用 AffineTransform:
       索引一看就懂、容易对着规范核;AffineTransform 写错了很难发现(图还是能出,只是方向不对)
     · **转正必须在任何缩放之前** —— 转置会互换宽高,尺寸计算得基于转正后的

三、为"前端压缩"铺路的关键改动
   compress() 现在会判断"是不是已经达标了":
     没触发缩放 && 没有 EXIF 需转正 && 体积本来就在上限内 → **原样返回,不重编码**
   因为前端马上会按材料项目标先压一遍(会转正并去掉 EXIF),服务端再压就是
   **JPEG 二次编码** —— 白掉一次画质。quality 用 NOT_REENCODED=0 标记"没重编码过",
   排查"这张图为什么有点糊"时能一眼看出被压过几次。
   这条对 P4 截图尤其合适:原样保持 PNG,表格线一点不糊。

四、顺手修的
   · url/content 的 original=true:没有原始件时**优雅退回到交付件**并如实标注
     (FileUrl 加 original 字段)—— 学生点"看原图"只是想看自己传的那份,
     报"文件不存在"是纯粹的困惑
   · sniffExt():按**字节幻数**判类型,不信文件名后缀(学生传的 .jpg 里装 PNG 是常事,
     而这个结论会写进数据库、决定导出包的扩展名)

五、E2E
   e2e-m3-file 37/37,新增第 7 节专守 EXIF:
     输入 3200×2400(横)+ O=6 → 输出 1200x1600
     ✓ 输出是竖的 ✓ 长边仍受 P2 约束 ✓ 不再带 EXIF
   原始件相关断言改成**按策略断言**(开/关两种都对),而不是写死"必须保留"——
   那会让"改配置"看起来像"代码坏了"。
   全量 467 条断言、0 失败。

⏳ 还没做:**前端压缩**("传 800KB 而不是 3MB")。本轮做的是为它铺路:
   服务端不再二次编码 + EXIF 转正机制已就位(前端要用 JS 做同样的事)。
   前端要做:照片类读 EXIF→canvas 旋正→长边 1600→q85;P1/P4 不压直传;
   压完仍超就回退原图让服务端兜底。

⚠️ 流程坑(本轮白花三次编译):**不要把 Java 字符串字面量放进 PowerShell here-string
   传给 Python** —— @"..."@ 会把 " 吃掉(return "bin"; 变成 return bin;)。
   带引号的代码一律用 edit 工具改,不要过转义层。
2026-09-29 15:58:08 +08:00
lovife b9373d6ebd fix(config): 本地私有配置改用 profile 机制(spring.config.import 优先级不够)
起因:用户把七牛 AK/SK 填进了 application-dev.yml(**会提交的文件**)。
已把密钥挪到 jar 外的 backend/application-local.yml(gitignore),
并把 dev 文件还原成空占位符 —— 确认过待提交差异里没有密钥。

⚠️ 排查中发现一个**沉默的坑**:一开始用
  spring.config.import: optional:file:./application-local.yml
加载本地密钥,结果**启动正常、日志不报错、但配的 provider 完全没生效**
(还是本地磁盘)。三步定位:
  ① 确认 YAML 合法(结构/编码/缩进都对)→ 不是文件问题
  ② 换 --spring.config.additional-location=file:./... → **生效了**
     → 文件与内容没问题,问题在 import 这条路
  ③ 把 import 改成**非可选 + 指向不存在的文件** → 启动失败并报
     "Config data resource ... does not exist"
     → 证明 import **确实被处理了**,是**优先级**问题

根因:spring.config.import 导入的文件排在 **profile 文件之后(优先级更低)**,
于是 application-dev.yml 里的 storage.provider 把导入文件里的 provider: qiniu
盖掉了 —— 而这一切没有任何报错。

修法:改用标准机制 —— 文件恢复成 profile 名 application-local.yml,
application.yml 里加 spring.profiles.include: local。
profile 文件后加载、优先级更高。启动日志现在打印
  The following 2 profiles are active: "local", "dev"
那个坑已写进 application.yml 的注释里,免得后人再踩。

教训:**"配置了但没生效、且不报错"是最难查的一类问题。**
判断某个配置机制到底有没有被执行,有个通用办法:
**把它改成一个必然会失败的值** —— 如果照样跑起来,说明它根本没被处理。

⛔ 仍阻塞:用户填的 AK/SK **验证无效**(七牛 401 bad token)。
两个互相独立的签名实现都失败,所以不是代码问题:
  ① 七牛官方 SDK 的 Auth.uploadToken(自检走的就是它)
  ② 手写的管理 API 签名(GET https://rs.qiniu.com/buckets)
线索:AK 40 字符正常、**SK 41 字符**(七牛的 AK/SK 都是 40 字符的 URL 安全 base64);
但逐字符试"去掉首/尾字符"三种组合都被拒 → 不是收尾多一个字符。
需要用户到七牛控制台 → 密钥管理重新完整复制一对。

为不阻塞本地开发,application-local.yml 里 provider 先留 local(密钥保留着,
换成有效的之后去掉一行注释即可)。回归:e2e-m3-file 29/29、m3-submit 31/31。
2026-09-29 14:38:37 +08:00
lovife 65ebf8de90 feat(storage): 对象存储接七牛云 —— 解除"不能上多副本"
文件以前直接写本地磁盘:两个实例各存各的,学生这次传到 A、下次请求落到 B,
文件就"不见了"。这不是性能问题,是正确性问题 —— 它一直卡着不上多副本。

新增:
  · module/file/storage/{StorageService,LocalStorageService,QiniuStorageService}
  · config/StorageConfig(装配 + 可选启动自检)
  · FillProperties 新增 fill.storage
  · MaterialFileController 新增 GET /{id}/url(签名链接)与 GET /{id}/content
  · scripts/e2e-m1-storage.py(存储自检,没配七牛时跳过而非失败)

关键设计:
  ① 业务只认 path(std/1/17/张同学-…-身份证.jpg),各实现自己映射成真实位置:
     本地 ./data/uploads/std/...、七牛 xiaotutongxue/std/...
     **换存储不用改数据库、不用改业务代码**
  ② 空间私有 → 下载必须签名。GET /{id}/url 每次现签(默认 30 分钟),
     所以这个 URL 绝不能长期保存:写进库、塞进导出的 Excel、发给学生明天再打开,都是 403
  ③ 对象键里有中文,拼 URL **必须逐段编码** —— 整串 encode 会把 / 编成 %2F,
     那在七牛那边是另一个键,表现是"传上去了却打不开",且报错什么都没有
  ④ 区域默认 auto:华东-浙江在新旧控制台对应 z0 与 cn-east-2,
     写错了的症状是**上传超时**——一个从报错完全看不出原因的失败
  ⑤ AK/SK 不进仓库也不进 jar:dev.yml 只放占位符;
     真密钥在 backend/application-local.yml(已 gitignore),
     用 spring.config.import: optional:**file**:./application-local.yml
     —— 刻意用 file: 而不是 classpath:,后者会被打进 jar,密钥就跟着构建产物走了
  ⑥ 配了 qiniu 却没密钥 → 启动就炸(fail-fast):起得来的话第一个受害的是
     正在填材料的学生,而排查要从"文件保存失败"一路追到环境变量

已验证:存储抽象改造没破坏任何现有链路 —— 本地磁盘路径下
  e2e-m3-file 29/29、m3-submit 31/31、m1-materials 68/68、m3-draft 27/27 全绿;
  启动日志打出「本地磁盘(多副本部署下会丢文件)」;git check-ignore 确认
  application-local.yml 被忽略而 .example 不被忽略。

⏳ 未验证:**七牛的真实链路没跑过** —— 用户给了域名/空间/区域/私有/目录前缀,
   但没给 AccessKey/SecretKey,没有它生成不了上传凭证。
   补上后跑 python scripts/e2e-m1-storage.py 即可(它会验:配置齐全 →
   上传处理落进七牛 → 取签名链接 → 真的下载回来比字节 → 不带签名被拒
   → 对象键带前缀 → 原始件一字不差)。没配时脚本跳过并 exit 0,不误报失败。
2026-09-29 14:09:13 +08:00
lovife 2659cd5a8c feat(h5): H5 交给后端托管 + 修掉"相对地址在 uni.request 下失败"
背景:配好了花生壳穿透 http://394nb68658le.vicp.fun → 本机 8080。
但 H5 原来跑在 127.0.0.1:5174,手机访问不到 —— 所以把 H5 静态文件
交给后端一起服务(同一个域名)。这样顺带也不再需要 CORS,
而且正是正式部署的形态。

改动:
· application-dev.yml 加 spring.web.resources.static-locations,
  默认指向 frontend-mp/dist/build/h5/,可用 H5_STATIC_DIR 覆盖。
  ⚠️ 前缀必须是 spring.(我一开始写成了 fill.,表现是"页面 404 而应用
  照常启动、没有任何报错")。
· fill.mp-h5.callback-url / h5-base-url 改成穿透域名。
· config/index.ts:API 地址按端区分(api 地址两端策略不同,写死一个值
  一定会在某一边出错):
    H5     → location.origin(绝对同源)
    小程序 → http://localhost:8080(没有"同域"概念,必须绝对地址)
  用 uni-app 条件编译区分,而不是运行期判断(后者会把两套地址都打进包里)。

⚠️ 修掉一个只在真实穿透下才会暴露的 bug:
· 原来 H5 硬编码 http://localhost:8080。从公网域名打开时变成跨域请求 → 被 CORS 拦
  → 页面显示"网络请求失败",而**从本机打开却是好的**。这个 bug 只有走真实
  穿透/真机才会暴露 —— 这正是"要在真环境验证"的价值。
· 改成相对路径后**仍然失败**(request:fail),但同样地址用原生 XHR / fetch
  都是 200。也就是说这是 uni.request 自身对相对路径的处理差异,不是服务端
  或跨域问题 —— 而它长得像"网络不通",很容易往 CORS / 域名 / 后端去查,白绕一大圈。
  最终用 location.origin(绝对同源)绕开。

验证:经公网 http://394nb68658le.vicp.fun/?scene=t1-k17
→ 显示「示范职业学校 / 2026年9月机电专业认定材料收集 / 尚未发布」,
无头浏览器截图确认(不依赖人工)。
※ 首次加载约 27 秒 —— 穿透的带宽限制(花生壳免费档),不是代码问题。
2026-09-29 11:11:49 +08:00
lovife 6e7597531a feat(mp): M3-02 H5 微信网页授权(公众号 OAuth)
方向决定了 H5 优先:资质门槛低得多、且前端可自动化测试。
小程序端代码不浪费 —— uni-app 一套代码多端,build:h5 直接出 H5。

交付:
· GET /mp/h5/authorize —— 生成微信授权地址(公众号 appid,snsapi_base 静默授权)
· GET /mp/h5/callback —— code 换 openid → 找/建学生 → 下发会话 → 302 回 H5
· GET /mp/h5/stub-login —— 【仅开发/测试】跳过微信授权的登录,供自动化测试
· fill.mp-h5 配置段(公众号凭据)

关键事实:H5 必须用**公众号**的 appid —— 网页授权与小程序码是两套凭据、两套接口,
不能混用。所以配置分成 fill.mini-program(小程序码用)与 fill.mp-h5(网页授权用)。

授权流程的三处设计:
1) token 放在 URL 的 fragment(#)里,不放 query。fragment 不发给服务器 ——
   不会进 Nginx access log、不会进 Referer、不会进浏览器历史。
   放 query 等于把 token 写进四五处日志。
2) scene 不裸放在 state 里,而是存 Redis 用一次性随机串指代 ——
   state 会进 URL、浏览器历史与日志,放业务参数容易被人拿来构造请求。
3) 回调必须"永远重定向回应用",绝不抛业务错误。那是浏览器直接跳转的地址,
   抛错用户看到的是一屏 {"code":40001,...} JSON —— 一个学生完全无法理解、
   也不知道该怎么办的画面。

打桩(为了自动化)与四道约束:
   微信授权那一步必须真人在微信里点,脚本永远做不了,所以要有打桩;
   而打桩是危险动作 —— 一个"跳过微信授权"的开关漏进生产就是
   "任何人可登录任何学生"的后门。约束:
   ① 配置开关默认 false,只有 dev 打开;
   ② 代码里再判一次 profile 不含 prod(配置写错也进不来,双保险);
   ③ 接口路径独立(/mp/h5/stub-login),不是"真实回调里加个 if"——
      那种写法一旦判断写反就是灾难;
   ④ 每次调用都 WARN 日志,不留静默通道。

本轮修掉 2 个真问题:
1) RoleProvisioner.bindRole 在匿名请求下不可用:它内部走 MP 的 BaseMapper,
   插入时租户拦截器拿不到 tenantId、直接抛「未登录」。症状是 500 且角色没建上
   (用户建了却没角色,后面每个接口都会以"无权限"拒绝他,排查毫无头绪)。
   已改为自己写:@InterceptorIgnore + 显式 tenant_id。
2) 授权回调抛业务错误 → 用户看到一屏 JSON。已改成 try/catch 后重定向回应用,
   把 e.getMessage() 作为 authError 带上(那句话本来就是写给人看的)。
   E2E 实测:用户看到的是「网页授权换 openid失败:code 无效或已使用过
   (code 只能用一次,且 5 分钟过期)(微信原文:40029 invalid code...)」。

⚠️ 上线前必须解决:H5 与小程序是**两个 openid**
   同一个人的小程序 openid 与公众号 openid 是不同的(不同 appid 各自签发),
   只有 union_id 一致 —— 而它需要两个账号都绑到同一个微信开放平台账号。
   后果:学生先用 H5 填、后来又用小程序填,系统当成两个用户,
   第二个渠道撞 40072(已被登记)。一期只走 H5 不会碰到,
   但两条腿都上线前必须解决(按 union_id 认人)。

已知缺口:
· H5 核验页未做(落地页的「开始填报」仍是"开发中"提示)。
· H5 桌面全宽铺开,该加 max-width 居中。
· 真实微信授权未走通:需要公众号后台填「授权回调页面域名」+ 域名解析到能跑服务的
  机器(或内网穿透)。打桩跳过的是微信那一步,后面全走真逻辑。
· /mp/login(小程序登录)仍未做,可与 H5 授权共用 resolveUser。

顺带确认:frontend-mp 的 build:h5 已能出 H5 产物,
已在无头浏览器里跑通「URL 带 scene → 读 scene → 跨域调后端 → 显示学校/任务/状态」,
CORS(后端早已配 localhost:*)也通。这条是选 H5 的核心收益:前端交互层可自动化验证。

验收:e2e-m3-h5-auth.py 22/22;受影响的四条回归全绿
(落地页 31 / 门禁 29 / M0 账号 55 / 二维码 32)。
2026-09-29 11:00:18 +08:00
lovife eb352e3537 feat(qrcode): M1-06 填报二维码(接入真实微信小程序)
学生端的入口终于通了:班主任把码发到班级群,学生扫码进填报页。
这条 E2E 是本仓库唯一打外网的测试 —— 真实调微信生成小程序码并取回图片。

交付:
· GET /tasks/{id}/qrcode?type=MINI[&orgId]  (§7.12)
· GET /qrcodes/{scene}  匿名图片接口(<img src> 带不了 Authorization 头,
  必须匿名。图里只有一串场景值,不含学生信息)
· 微信小程序凭据接入 + access_token 与二维码的 Redis 缓存

微信账号(已实测确认,不是靠猜):
· wxf01d3fa60f87f0b7 是【小程序】:小程序码可生成、小程序订阅消息可用、
  公众号的用户管理接口返回 48001(交叉验证)
· wx5d3ae5c1c21fef24 是【公众号测试号】:做不了小程序码,但能做网页授权
  + JS-SDK —— 正好是「H5 保底」需要的能力。两条路都有号了

三处刻意的设计:
1) access_token 走 Redis 缓存(TTL 7000 秒,比官方 7200 少 200 避开边界)。
   token 获取接口有日调用限额(约 2000 次),每次生成码都取一次,
   几十个班一起用就会撞限额 —— 表现是"早上还能用,下午全班的码都生成不出来"。
2) 同一个 scene 的图缓存 30 天:图是一样的,而生成接口本身也有日限额,
   列表页每渲染一次就可能请求一次。
3) 微信 errcode 翻译成人话:40164(IP 不在白名单)与 40001(appsecret 错)
   是完全不同的排查方向,只给一个数字等于让运维从错的方向查起。

匿名图片接口**只读缓存、绝不现场调微信**:否则任何人都能用构造出来的 scene
刷爆当天额度,而额度用完是全租户的码都生成不出来 —— 等于整个填报入口瘫痪。

开发期 env-version 设成 develop(配置项 WX_MP_ENV_VERSION)。这个小程序若尚未
发布,用 release 生成的码扫开提示「小程序未发布」,而接口不会报错 ——
只会让人以为"码生成得不对"。

本轮踩的 3 个坑,都是"假设错了但不报错":
1) 微信返回的是 JPEG 不是 PNG。探测脚本按 PNG 魔数判断,把一次成功报成了失败
   (接口 200、91KB、看着一切正常)。服务代码里按 Content-Type/是不是图片判断。
2) 业务错误返回 HTTP 200 —— 项目约定只有 401/403/429/5xxxx 才占用真 HTTP 状态码,
   业务码在响应体的 code 里(ErrorCode.httpStatus())。我的 E2E 按 HTTP 状态码判
   业务失败,于是把"业务失败"当成了"成功"。
3) 我写的断言又一次恒为真:check(..., "max-age" in (...) or True, "")。
   这与上一轮刚记进摘要的"断言要能真的失败"是同一类错 —— 说明那条教训我只是
   写下来了,没有真的在写断言时自查。已改成真的读响应头来判。
   另外 fetch_* 辅助函数成功时返回 err=None,而断言在成功路径上写 err[:120],
   把"本该通过的用例"崩掉了;已改成成功时返回空串。

⚠️ 凭据位置与安全:
app-secret 放在 application-dev.yml(会入库),用 ${WX_MP_APP_SECRET:...} 形式,
test/prod 必须用环境变量覆盖。它与 jwt/crypto/rsa 那几把自研密钥性质不同:
那几个泄露只影响本系统,appsecret 泄露等于别人能以这个小程序身份调用所有接口。
生产环境务必单独申请凭据 + 走环境变量。

已知缺口:
· shortUrl 恒为 null:短链需要短链服务,而「H5 保底」路线还没定。
  不返回一个"其实很长"的链接冒充短链 —— 字段叫 shortUrl 却给 80 个字符,
  会让调用方以为可以直接打印。
· 图存在 Redis,没走对象存储(后端还没接 MinIO,容器在跑代码没接)。
· 小程序正式使用还需两步人工操作:服务器域名白名单加 API 域名、确认小程序已发布。
· 前端还没有二维码入口(属 M1-07~M1-10)。

验收:e2e-m1-qrcode.py 32/32(真实调微信);七条 E2E 合计 358 项断言全通过。
2026-09-29 10:08:02 +08:00
lovife 870c388287 feat(form): M1-03 表单 schema 版本化与冲突检测
学生端看到的表单终于可以按任务配置了。按计划表这一项是后端
(4 人日),表单设计器界面属 M1-07。

交付:
· GET/PUT /tasks/{id}/form-schema(§7.9 / §3.5)
· BuiltinFields:官方《考生线下报名信息导入模板》的 21 个内置字段(A~U 列)
  —— 选项集逐字照抄,级联规则照官方原文

四条守住的规则:
1) 版本只增不改:每次保存插一行新版本,旧的保留(is_current=0)。
   学生是按某一版填的,事后必须能回答"他当时看到的是什么"。表上没有 deleted
   列也是这个道理 —— 表单版本不需要软删,它本来就不该删。
   顺带:collect_form_schema 没有 deleted 列,实体刻意不继承基类
   (collect_task_org 踩过同一个坑)。
2) baseVersion 是乐观锁,不符则 40025 并把最新定义一起回传。
   设计器是"打开 → 改很多处 → 保存",中间可能好几分钟;直接覆盖会静默丢掉
   别人刚加的字段,而且丢失发生在"另一个人"那里。只说"版本冲突"而不给最新内容,
   等于把合并推给一个信息不全的调用方 —— 所以 40025 的 data 里带着最新 schema。
3) 内置字段不可删(40026)的判断依据是「内置目录」而不是「上一版定义」:
   拿上一版当依据,客户端只要让上一版先缺一个就绕过去了。E2E 也验了
   把 builtin 传成 false 无法绕过 —— builtin 与 source 一律以内置目录为准。
4) 扩展字段键必须带 extra_ 前缀、不可与内置重名:扩展字段的值落 extras_json,
   与内置列同处一份 form_data,一个叫 education 的扩展字段会让"这个值到底是
   内置的还是扩展的"永远说不清。

契约之外必须补的一个字段 source:
   §3.5 说"内置字段与 collect_submission 列名一致",但「姓名」「身份证号」
   「联系电话」「序号」根本不在 submission 里,在名册里(collect_roster)。
   不标来源,填报引擎就得靠硬编码名单去猜"这个 key 去哪取值",猜错的表现是
   "学生端这两栏永远是空的"——一个不报错、只让人困惑的失败。
   ⚠️ source 与 locked 互相独立:「联系电话」source=ROSTER 但 locked=false
   (预填可改,名册里常是家长的老号码);「报考职业」source=SUBMISSION 且
   locked=true(由计划定,学生不懂也不该改)。

21 项内置字段的口径:
· 选项集逐字照抄官方(学历 11 / 等级 5 / 补考理论 4 / 实操 6 / 综合 2)。
  官方原文写着"若手动输入则只能填写下拉选项中包含的值",多一个少一个都算改了规则。
· 级联:补考三科仅 is_retake=true 显示;在校生三项仅 is_student=true 显示;
  入学时间带 ^\d{4}-\d{2}-\d{2}$ 正则(官方原文「格式必须 YYYY-MM-DD」)。
· 工作单位保留官方原文提示「没有工作单位请填「无」」。
  ⚠️「无」是合法业务值,不是空值,校验时不能把它当成"没填"拦掉。
· 锁定项(姓名/身份证号/报考职业/工种/等级)与 PRD 一致,另把「序号」一并锁定
  (来自名册,让学生编辑"自己是第几号"没有意义)。

顺带:V6 更正 collect_form_schema.schema_json 的列注释(V1 写的是"字段定义数组",
实际存 {"fields":[...],"extras":[...]})。合成一个文档而不是拆两列,是因为
一版表单就是一份快照 —— 拆开就存在"只写成功一半"的可能,而"学生看到的那版"
必须是原子的。

本轮踩的坑(记进交接摘要):
· 我的 E2E 又写错一条:测「已截止不可改表单」时先调 close,但**草稿是不能直接
  截止的**(close 只允许 收集中/已暂停)。改成先用 SQL 夹具把发布前置备齐 →
  发布 → 再截止。这反而验证了一件对的事:没配齐就发布,学生填了也没人审,
  所以 close 把草稿挡住是合理的。
· 写 Java 时把 Map 声明成了 String,编译器直接拦住,没有代价。

已知缺口:
· 表单设计器界面未做(M1-07)。
· 报考职业/工种/等级的 options 为空:它们来自"评价计划"(由名册聚合,M2),
  不是固定选项。属预期,等 M2 接上计划级联。
· 库里没有字典表,所以官方选项内联在 options 里,dictCode 暂未启用。
· 条件显示的求值引擎属 M3-10,本轮只做存取与形状校验。

验收:e2e-m1-form.py 48/48;六条 E2E 合计 326 项断言全通过;Flyway 迁移 1~6 success。
2026-09-29 09:47:09 +08:00
lovife d32969d766 feat(material): M1-04 材料项定义与内置模板
发布前置校验里最后缺的一项(40018「未配置必传材料项」)终于能配了。
按计划表这一项是后端(4 人日),材料规则的界面属 M1-09。

交付:
· 材料项 CRUD(《03-接口设计》§7.10):GET/POST /tasks/{id}/materials、
  PUT/DELETE /materials/{id}
· 三个内置模板(TPL_SCHOOL_ROLL / TPL_SOCIAL / TPL_ZHUANXIANG),幂等应用
· GET /material-catalog:材料码/档位/格式白名单/模板的静态清单,
  供界面下拉使用(接口是契约外的,理由见 Controller 注释:把这份常量在前端
  再抄一份会造成两份真相,而别名正是识别历史文件名用的)

落在代码里的三件事:
1) 默认值按两个维度分别取(MaterialCatalog):
   · 按材料码定 名字/别名/默认压缩档/份数/是否合并
   · 按压缩档定 固定尺寸(P1)/长边下限(P2)/目标DPI(P3)/输出格式
   把份数塞进档位默认里是很容易犯的错 —— 那样所有 P2 材料都会变成"要传 2 份",
   而"身份证要正反面"是这件材料的事实,不是档位的规则。
2) WORK_PROOF 的名字就是「工作证明」。机构模板里「职业技能等级个人申报表」
   的规范文件名就是「工作证明」,这是机构既有习惯,不要"修正" —— 改了会让
   导出包与该机构一直以来的归档口径对不上,带教老师只认这个名字。
3) 关联键是 (task_id, material_code),不是 material_def_id(表里没有那个外键)。
   所以 40027「已有文件不可删」必须按码查 —— 用 id 查会永远返回 0,
   这道校验形同虚设,后果是"删了材料定义,文件成了孤儿"。

本轮修掉的 2 个真问题:
1) NOT NULL DEFAULT 与"档位不适用就置空"打架(同一类 bug 的第二次):
   min_long_side(NOT NULL DEFAULT 1400)与 output_format(NOT NULL DEFAULT 'jpg')
   在切档位时被写成 null,PostgreSQL 直接 500。而更根本的是:契约 §3.4 明确写着
   outputFormat 的 null = 保持原格式 —— 这一列本就不该是 NOT NULL。
   已由 V5__material_profile_params_nullable.sql 把四个档位参数统一成
   "不适用时为空"(另两个本来就可空),并同步更正两份 DDL 文档。
   ⚠️ 这与上一轮的 expected_count 是同一类问题:NOT NULL DEFAULT x 看起来无害,
   但它让"未设置/不适用"无法表达,而契约往往又要求能表达。
   动"可选数字/字符串"字段前,先查 information_schema.columns 的 is_nullable。
2) 我的两条 E2E 断言本身是坏的:
   · 把 lower_case 断言成"应被拒" —— 实际服务端把它规范化成 LOWER_CASE 后
     接受了,这是对输入友好的正确行为,错的是断言;
   · "改档位后参数跟着变"用了 P3,而 OTHER_PROOF 的默认档本来就是 P3,
     于是无论改没改都会通过(假通过)。
   教训:断言要能真的失败。

顺带整理:
· 抽出 support/NamingTemplate:任务级 naming_template 与材料级 name_pattern
  必须共用同一套校验规则。两处各写一套会出现"任务级认的变量材料级不认",
  而这类不一致的表现是"发布时通过、导出时文件名叫错",最难在测试里撞上。
  TaskService 已改为委托。
· materialTemplateId(契约里是 int64)不再空转:§7.10 的三个模板在文档与代码里
  都是具名常量,库里也没有"材料模板表",拿 id 指代常量就得先造一张表放它们。
  新增 materialTemplateCode(字符串)承接内置模板,materialTemplateId 留给
  M1-05 的 collect_rule_template(那是真正的一行一条,id 才有意义)。

已知缺口(写进了代码注释与交接摘要):
· minSizeKb 恒为 null:契约 §3.4 给了字段但"对应列"是 —,而《04》又用到
  materialDef.minSizeKb,两侧口径没对齐;且具体阈值属待办 D5「一寸照片阈值标定」,
  没有实测数据。编一个数会让它看起来"已实现",卡住真材料时没人说得清为什么。
· 条件显示只做存取与形状校验,求值在填报引擎 M3-10。
· 40030(审核中不可删材料)属 M5,当前删除只查 40027。

验收:e2e-m1-materials.py 68/68;五条 E2E 合计 278 项断言全通过;
Flyway 迁移 1~5 全部 success。
2026-09-29 09:42:08 +08:00
lovife 7523ebe789 feat(task): M1-02 覆盖班级与班主任配置
补上发布前置校验的最后一环(40019),同时确立任务的数据隔离边界 ——
班主任能看到哪些任务、能审哪些单子,全由 collect_task_org 决定。

后端 GET/PUT /tasks/{id}/orgs(整体替换语义 + 软移除 + 历史行复用);
前端「配置班级」抽屉:组织树只允许勾选班级,主班主任留空时显式提示
「将由该班负责人初审」而非留白。发布被拦时给出「去哪里补」的指引
(BLOCKING_HINTS),而不是只说"未设置覆盖班级"。

三条守住的规则:
1) 班级与初审人都必须在本租户内。跨租户的 id 一律当作"不存在或已删除",
   不透露"它存在但不属于你" —— 漏了的后果是 A 校能把 B 校的班挂到自己
   的任务上,B 校的班主任就出现在 A 校的审批流里。
2) 初审人必须持有班主任/副班主任角色。指定初审人本质上是一次授权,
   那个人将能看到该班学生的身份证号与学籍材料;不校验角色就等于把
   "谁能看学生隐私"变成任意授权。报错要给出下一步,不能只说"校验失败"。
3) 移除是软移除(status=0),且「移除再加回」必须复用原行。
   后者不是优化而是必须:uk_task_org UNIQUE (task_id, org_id) 不带 status
   条件,一个班在同一任务下只能有一行,直接 insert 会违反唯一约束。

本轮修掉的 3 个真问题:
1) 我此前误判 uk_task_org 不存在 —— grep 只匹配含表名的行,而
   CONSTRAINT uk_task_org UNIQUE (task_id, org_id) 那行没有表名。
   基于这个错误前提,我写了一个多余的部分唯一索引 uk_task_org_active
   和一段基于错误前提的代码,已全部撤掉并更正注释。
   教训:查约束要看 CREATE TABLE 整块,不能只 grep 表名。
2) collect_task_org.expected_count 是 NOT NULL DEFAULT 0,与接口契约矛盾:
   DDL 注释说它是"由名册汇总"(系统算的),但《03-接口设计》§7.6 把它放在
   PUT 请求体里、说"用于与名册实际人数比对" —— 只有"管理员填的期望值 vs
   名册实际值"才需要比对。而 0 是有意义的业务值("预期 0 人"),拿它兼职
   表示"没填"会让每个没填的班都触发"应填 0 人、实际 31 人"的假告警;告警
   变噪音就没人看了,真正"应填 28、名册却有 31"(名册导错)反而被漏掉,
   而这条告警的全部价值就在那一次。已由 V4 迁移改为可空 + 去掉默认值,
   并同步更正 docs/db/schema-postgresql.sql 与 PRD 里的内联 DDL。
3) 删掉迁移文件后必须 mvn clean,否则它会"复活":我只跑 mvn package,
   target/classes 里的旧副本被打进 jar,Flyway 发现历史里没有 v4 就又执行
   了一遍 —— 索引回来了、历史行填回去了。之后又建了同名版本号的新 V4,
   于是"两个 v4"→"checksum 冲突",排查绕了好几圈。

顺带修掉:
· 任务列表操作列从 3 个链接涨到 4 个,160px 会挤成竖排 —— 把「进度」并进
  「状态」列腾出 40px,操作列给到 200px,合计 820px(可用约 824px)。
· defineEmits 必须用函数调用签名(frontend-pc/.agents/skills/code-vue)。
  我此前写的 5 个组件都是被禁的 tuple 形式,一并改正:
  OrgFormModal / TaskFormModal / TaskOrgDrawer / UserFormModal / UserImportModal。
· e2e-m1-orgs.py 的清理抽成幂等的 cleanup_all(),并在 __main__ 的 finally
  里兜一次 —— 该脚本首次运行时崩在准备阶段、清理段没执行,把两个测试账号
  留在了库里(是在浏览器里看到下拉框里有个「E2E班主任」才发现的)。
  其余三条 E2E 脚本仍是老样子,下一轮一并改。

验收:e2e-m1-orgs.py 48/48(含跨租户隔离);e2e-m1-task.py 71/71、
e2e-m0-user.py 55/55、e2e-m0.py 36/36 回归通过,共 210 项断言;
前端 typecheck / eslint / lint:style 0 错、build 通过;
浏览器实测抽屉的开合、树的勾选规则与保存落库。
2026-09-29 09:32:48 +08:00
lovife 8480f431ba feat(user): 账号管理(CRUD、Excel 导入、改密)并修掉 5 处缺陷
后端补齐 /users 全套(列表/新增/修改/软删/启停/重置密码/批量导入)与
PUT /auth/password;前端加「组织与账号 → 账号管理」一屏(左组织树 + 右列表、
新增编辑弹窗、导入体检报告、初始密码只显示一次的强确认)。

验收:e2e-m0-user.py 55/55;e2e-m0.py 回归 36/36;typecheck 0 错、build 通过。
账号管理页已在浏览器肉眼确认(首屏与切页均可见、数据与分页正确)。

本轮修掉的 5 个真问题 —— 都不是功能没做完,而是做完了但错的:

1) 学校租户也发放 PLATFORM_ADMIN 角色行
   学校管理员在新增账号时传 roleCodes=[PLATFORM_ADMIN] 就能造出带
   tenant:manage 的账号,而 /tenants 只校验权限码、不校验是否真在平台租户下
   → 任何学校管理员都能开通租户、拉全平台租户列表(租户级越权到平台级)。
   RoleProvisioner 不再给非平台租户发该角色行 + 服务端按租户白名单校验。

2) UserUpsertReq.username 的 @NotBlank 对修改也生效
   PUT /users/{id} 永远返回 40001,且明细只说存在阻断性校验问题,
   看不出是登录名 —— 改账号功能整体不可用。去掉该注解,
   必填性改由 UserService.create 判定。

3) Main.vue 的 <transition appear>
   首次渲染被当成一次过渡,元素被加上 zoom-fade-enter-from(opacity:0)后
   永不清理 → DOM 内容齐全、接口数据正常、文字可选中,但整块内容区肉眼不可见。
   极易误判成页面没渲染而往路由/接口方向排查。去掉 appear。

4) assertAnotherAdminExists 在停用/删除路径上不可达
   能调这两个接口的人自己必然是活跃管理员,所以除目标外还有管理员恒成立;
   而真正会锁死租户的路径 —— 把管理员改成普通角色(目标可以是操作者自己)——
   当时没有拦。补 losingAdminRole() + UserMapper.countAdminRoles(),
   并在方法注释里写明哪条路径真生效、哪条只是防御性的。

5) sys_user.user_type 有两套互相冲突的枚举
   DDL/实体注释写「1教师/管理员 2填报人(学生) 3社会人员」,而权威契约
   (03-接口设计 §2.7)与 BuiltinRole.UserType 是
   「1平台运营 2学校管理员 3班主任 4学生」—— 2 和 3 完全错位。
   后果:userType=3(班主任,最高频的账号)被当成社会人员(二期)直接拒掉,
   建班主任账号会失败;前端还把学校管理员显示成填报人(学生)。
   改法:后端从角色反推用户类型再比对;新增 V3 迁移更正库注释
   (V1 已应用不能改,Flyway 会校验 checksum);更正 PRD 与 schema DDL 的同一行;
   前端「用户类型」从手选改为由角色推导,从根上消除矛盾组合;
   E2E 加 3 条回归断言锁住这个语义。

顺带修掉:
  · components/index.ts 里 6 个指向已删组件的悬空导出(构建不报错、用到才炸)
  · tsconfig 的 lib 停在 ES2020 而代码用了 Array.prototype.at
  · 两处 .at() 的空值风险(useBreakpoint / useTabsStore)
  · dashboard 的 userStore.menus 拼错,导致平台判断恒为 false
  · http.ts 里两个永不命中的错误码常量
  · 账号列表的 fixed 列缺 :scroll 配置,会浮在其它列上面
  · .gitignore 补 __pycache__

新增脚本:
  · scripts/e2e-m0-user.py            账号管理 55 项验收(含越权与自锁的负向断言)
  · scripts/dev-clean-test-users.py   清理 E2E 残留的测试账号
2026-09-28 22:25:01 +08:00
lovife 9d7a8ca845 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 完成情况
2026-09-28 17:28:21 +08:00