Commit Graph

8 Commits

Author SHA1 Message Date
lovife fe7615d12b feat(h5): 学生端材料预览 + 删掉部署里的 MinIO
一、部署配置清理
  docker-compose.yml 里的 MinIO 删了(连 minio-data 卷)。它当初是
  "M0 阶段不写入但先起好 —— 本地/生产走同一套 S3 接口",但代码最终走
  七牛官方 SDK,那个容器**从来没被连过** —— 留着只会让人以为"本地还有个
  对象存储要用"。.env.example / .env 的 MINIO_* 换成 QINIU_* + XT_STORAGE_*。

  ⚠️ 删的时候我自己犯了个错:用 volumes:\n 当插入锚点,结果匹配到了
     **pg 服务内部的 `  volumes:`**,把注释插进 pg 定义里还把它顶到第 0 列,
     **YAML 结构就这么坏了**。好在写完立刻用 yaml.safe_load 复查(services: [pg, redis])
     才发现。教训:改 YAML 之后一定要解析一遍,别靠肉眼看缩进。

二、学生端材料预览(新功能)
  已传材料每行加「预览」,点开全屏浮层(深底 —— 材料照片多是白底证件件,
  浅底会看不清边界),点图片以外关闭。

  **两种存储两种取法,不能只写一种**:
    · 七牛:接口返回带签名的绝对 URL,直接给 <image src> 用
    · 本地磁盘:那个接口要带 Authorization,而 <image src> 发不了请求头 ——
      先用 uni.request 取字节,再转 blob URL
  只写七牛那一种,本地开发时预览会一片空白,而**原因完全看不出来**。
  签名链接有有效期(30 分钟),所以每次预览都现取,不缓存 URL。

  顺手修两个后端问题:
    · LocalStorageService.url() 返回的路径指向一个**不存在的接口**
      (我建的是 /{id}/content,不是 ?path=)→ 改由 MaterialFileService 按
      provider 决定:七牛给签名链接、本地给 /{id}/content(按 id 取能复用归属校验)
    · /content 原来固定 application/octet-stream —— 表现是**点开身份证照片
      变成下载一个文件**。现在按文件名推 Content-Type + inline

三、浏览器实测
  点预览 → 浮层打开、图片真的解码(naturalWidth=900、complete=true);
  直接 fetch 那个签名 URL → 200 / 17728 字节。
  中间发现一个视觉问题:**浮层标题与「关闭」被 uni-app 导航栏盖住了**
  (page-head 的 z-index 是 998,我给的 95)—— 看起来像"标题没渲染出来"。
  改成 9999 + padding-top 让出导航高度;问题清单浮层一并提到 9998。复验通过。

四、顺带
  dev-seed-warn-state.py 改为没有可用草稿时自己建一份 —— 原来写死 submission id,
  被 E2E 清理后就报"填报单不存在或不属于你",而那看起来像权限问题。

验收:e2e-m3-file 32/32、m3-submit 31/31、m1-storage 16/16
2026-09-29 15:16:36 +08:00
lovife ef903a55c3 feat(task): 表单设计器(M1-08)—— 填报表单在界面上也能配了
"能动态设置填报表单"是本产品的核心承诺之一,而在这之前表单定义只能用 SQL 改。
更要紧的是它有一个**不可逆的方向**:内置字段删不掉(40026),正确用法是
"用条件显示隐藏"—— 而只有界面上给这条路留了位置,用户才会照着做。

新增:
  · apis/xt/form-schema.ts —— 表单定义接口 + 字段类型 + 条件选项 + 扩展字段 key 生成
  · views/task/TaskFormSchemaDrawer.vue —— 21 项内置字段(必填/条件/排序/提示语就地改)
    + 扩展字段增删改 + 保存为新版本 + 版本冲突处理
  · task/index.vue 操作列加「表单设计」,240→300px

浏览器实测:
  · 版本标签「未保存过(当前为官方默认表单)」——服务端对没存过的任务返回内置默认定义,
    让设计器从一份完整的官方表单开始,而不是从空白开始
  · **21 项全在,且「条件显示」反查正确**:补考三科→仅补考时、
    在读学校/专业/入学时间→仅在校生、本职业工作年限必填开关是关的。
    把服务端任意 visibleWhen 对象映射回人话标签,是本界面最容易做错的一处
  · 保存→库里 v1 current=1 fields=21;三次保存得到 v1/v2/v3,
    旧的 is_current=0 全部保留(**版本只增不改**实测)
  · 「不收集」= {field:"_never_",...}(内置字段删不掉,用一条永远不成立的条件隐藏);
    保存后原有条件(is_retake/is_student)都保住了

⭐ 端到端:学生端 字段 21→17(已获证书编号消失、补考三科消失、在校生三项保留)、
   材料 4→3(工作证明消失)

⚠️ 顺带修掉一个真 bug:**材料侧的条件没求值**
   上面这次验证暴露的:表单字段的条件前端有求值(21→17),材料的没有 ——
   「工作证明(仅非在校生)」照样显示给在校生;而后端校验是排除它的,
   于是表现成"系统让我传,我不传也不报错",学生只能自己猜。
   修法:把 visible() 抽成 whenVisible(),**字段与材料共用同一份**。
   教训:同一套规则出现在两处时必须共用同一个函数 ——
   抄一份的代价不是多几行,而是两边行为不一致,而两边各自看都没错。

修掉一条过期的 E2E 断言:e2e-m1-form.py 断言「联系电话不锁定」,
   与实现刻意的只读相反(BuiltinFields L88-93:collect_submission 没有 phone 列,
   所以一期只读;反过来做"前端允许改、后端悄悄丢掉"最糟 ——
   学生以为改成功,实际提交的还是老号码)。改断言并写明理由,48/48 恢复。

frontend-mp 类型检查修干净:原先 fill.vue 还有 3 条(输入框事件、日期选择、
   FormData 缺 submissionStatus),现在只剩 config/index.ts 的 2 条 ——
   那是 uni-app 的 #ifdef H5/#ifndef H5 声明同一变量,vue-tsc 看不懂条件编译,设计如此。

验收:e2e-m1-form 48/48、materials 68/68、task 71/71、orgs 48/48、
   m3-submit 31/31、file 29/29、draft 27/27、gate 29/29(合计 351 条断言全绿)
2026-09-29 13:48:11 +08:00
lovife 9911ff01f1 feat(submit): 提交 + 红黄绿校验(PRD §5.7 的 VAL 系列)
一次给全,不"改一条再拒一次":学生一次可能同时缺 3 项材料、2 个字段没填,
逐条拒绝会让他改 5 次提交 5 次。所以 validate 收全部问题后一次性返回
{ruleCode, severity, title, detail},ruleCode 用 PRD 的 VAL-xx 编号。

两段式提交:只有黄色警告时先返回 submitted=false 让学生确认,
再带 confirmWarnings=true 才真的提交。为什么不前端自己判断 ——
只有服务端的校验结果算数,两次请求之间学生可能又改坏了什么。

实现的规则:VAL-01 证件号 / VAL-05 材料完整性 / VAL-06 格式 /
VAL-07 体积 / VAL-08 清晰度 / VAL-09 重复文件 / VAL-13 报考三项 /
VAL-15 补考科目数按等级 / VAL-16 在校生三项 / VAL-17 工作年限 /
VAL-18 未规范化 / VAL-20 体积下限 / VAL-22 表单必填(**新增**:
PRD 的 VAL-01~21 是具体业务规则,没覆盖动态表单的必填字段,按系列顺延补的)。

═══ 本轮抓到的四个真问题(都记在代码注释里了)═══

① **校验器不能用列名当字段 key**。字段 key 是 snake_case
   (work_unit / job_level / enroll_date),不是列名;而且报考三项
   存在 ext_json 里、**不是独立列**(契约 §2.4)。写 camelCase 的表现是
   "明明填了却说没填"。更糟的是 VAL-16 读列、VAL-22 读表单装配值 ——
   **两条规则对同一份数据给出相反结论**,学生不知道该信哪一条。
   现在统一用 FillService.getForm 装配出来的那一份作为唯一取值口径。

② **VAL-22 没判可见性**。不判可见就会要求一个在校生填「补考理论」——
   而那个字段在他页面上根本不存在。求值口径已与前端 fill.vue 的 visible()
   逐字对齐(norm 归一 true/false↔1/0、未知 op 一律可见)。

③ **VAL-01 读错了列**:collect_roster.id_card_no 是 **AES 密文**
   (列注释写明的),明文只在导入与门禁时短暂存在。一开始拿它算校验位,
   于是每一份填报单都被判"身份证号未通过校验" —— 100% 阻断。
   这种"规则很对但读错列"的错,反而容易被当成"校验太严"而不是 bug。
   现在只确认名册里有证件号(用脱敏列),校验位交给导入与门禁。

④ **静态资源没有 Cache-Control/ETag**(新增 StaticCacheHeaderFilter)。
   没有它浏览器会走启发式缓存:旧 index.html → 旧 chunk →
   **学生发版后看到的是上个月的页面**,而开发机硬刷新一次就没了,
   极容易被当成"偶发"。现在 /assets/** 一年 immutable(文件名带内容哈希)、
   *.html no-cache(每次校验)、/api/** no-store。

排查过程里还确认了一件事:材料上传那轮的"浏览器加载旧 chunk"
不是构建问题 —— 从 8080 下载的 chunk 与磁盘文件 SHA-256 一致、里面确实有新代码,
是缓存头缺失导致的浏览器行为。

验收:e2e-m3-submit.py **30/30**(含"该拦的确实拦了"和"修好后确实放过")。
回归:草稿 27 / 门禁 29 / 材料上传 29 / 材料目录 68 全绿。
2026-09-29 12:39:16 +08:00
lovife 8ef09733b1 feat(file): 材料上传的前端界面 + 列出/删除接口
后端:GET /mp/files/submissions/{id}(列出我的材料)+ DELETE /mp/files/{id}(软删)。
  两者都把归属条件(submitter_id)写进 SQL 的 WHERE —— 与门禁、草稿同一套写法,
  少一个条件就是"能看/能删别人的材料"。

前端(填报页的材料区):
  · 按材料项列出:名称、必传标记、格式白名单与体积上限、来源引导语
  · 已传的显示「原名 → 规范名」与「压缩前 → 压缩后」体积(学生要能看见系统做了什么)
  · 传完**立刻处理**并显示结果

  ⚠️ 为什么上传后马上处理而不是留到提交时:学生要能看到"压到了多大、叫什么名字"
     才算完成。若把处理留到提交时,他上传时会以为"传上去了",提交时才发现体积超标 ——
     而那时他可能已经在回家的路上。

  · 学生能自己删(传错了不该只能找班主任)。已提交的填报单不给增删,与草稿同口径。

⚠️ 本轮**未完成浏览器肉眼验证**:构建产物里确实有材料区的代码
   (产物 grep 到「需要上传的材料」),但浏览器加载的是**缓存的旧 chunk**
   (实测 .fill__wrap 只有 4 个子元素,没有材料卡),我用带时间戳的方式绕缓存没成功。
   代码与产物是对的,需要一次硬刷新(Ctrl+Shift+R)或清缓存后再确认。
   这条记下来,不要当成"已验证"。
2026-09-29 11:59:58 +08:00
lovife 465700969a feat(h5): 填报页(三态 + 条件级联 + 失焦自动存草稿)
学生终于能在手机上真正填一份表了。

三态渲染(契约 §9.2):
  locked=true                → 只读灰底 + 「如有误请联系班主任」
  prefilled=true,locked=false → 可编辑 + 橙字「老师已填,请核对」
  其余                        → 普通空白
实测:姓名/身份证号/联系电话/报考职业/工种/等级 只读,学历可改并标注来源。

条件级联(visibleWhen)在前端求值,实测正确:
  「是否在校生=是」→ 在读学校/所学专业/入学时间 出现;
  「是否补考」未填 → 补考三科不出现。

**隐藏 ≠ 清空**:学生把「是否补考」改回"否",已填的补考科目仍留在草稿里
(万一他改回来)。要不要清空由后端在提交时判断 —— 前端不该替用户做不可逆的事。

保存策略:**失焦即存单个字段**。不做"整表提交"——学生填到一半切出去接电话,
回来整表丢失是最让人恼火的失败。单字段保存的代价是一次请求,
远小于"白填一遍"。
进页面时若还没有草稿会先建一份(否则第一次失焦保存会因 submissionId 为空而静默不存)。

前端拦住只读字段的保存请求(能省一次必然失败的请求),但**服务端那道拒绝才是真的**。

顺带:核验页的「开始填写材料」接上了(带 taskId + scene —— H5 跳转不保留 URL 参数)。

验收:构建产物含 pages-fill-fill;用无头浏览器实测渲染,
三态与两处级联均正确,抬头显示「请在 2026-10-15 前提交」。
2026-09-29 11:43:45 +08:00
lovife aa2f7f175c feat(h5): 身份核验页(门禁)—— 学生流程首个可用闭环
学生填材料之前的唯一一道门:姓名 + 身份证号与老师提交的名单核对,
通过后把微信账号与名单里那一行钉死。后面所有材料都归属那个人。

交付:
· pages/verify/verify.vue —— 核验页
· 落地页「开始填报」→ 核验页(带 scene)
· 两页都加 max-width 居中(桌面查看/自动化验证时不至于拉满全屏)

三处刻意的设计:
1) 失败提示不区分"姓名错"还是"号码错"(后端统一 40007),前端也只显示
   后端那一句话 —— **不在前端加"可能是身份证号错了"这类推断**。
   分开提示等于给出一个身份证号枚举器。
2) 预填项标注「老师已填,请核对」并明确「学生不能改」:报考职业/工种/等级
   由老师按报考计划确定(名单里实测 28/28 都有值)。让学生重填只会制造错误,
   而报错工种在机构那边是直接不受理。
3) 前端只做"非空 + 长度"这类明显笔误的提示,真正的判定(校验位、名册匹配、
   绑定状态)全交给后端。

修掉一个 H5 特有的坑:
   **H5 的页面跳转不保留 URL 参数** —— 直接 navigateTo 到核验页的话,
   那边读不到 scene、就不知道是哪个任务(小程序没有这个问题)。
   所以落地页跳转时显式拼上 scene,核验页再从自己的 URL 里读出来。

验收(全程无头浏览器自动完成,除"微信授权"那一步)。
现在 task 17 已发布(收集中)并带测试名册,手机上可走完整流程。

注:task 17 现在是「收集中」并带 1 行测试名册(张同学 / 361029200912100015),
   是我为验证留的演示数据。
2026-09-29 11:24:32 +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 0bc7ee02dc feat(mp): 小程序端骨架 + 扫码落地页(scene 识别租户)
小程序端从零建起来了,并在微信开发者工具里真机模拟跑通:
扫 scene=t1-k17 → 显示「示范职业学校 / 2026年9月机电专业认定材料收集 / 尚未发布」。

交付:
· frontend-mp/(uni-app,官方 uni-preset-vue#vite-ts 模板),appid 已填
· src/pages/index/index.vue 扫码落地页:读 scene → 调后端 → 显示
  「哪个学校 / 填什么 / 现在能不能填」
· GET /api/v1/mp/scene-context(匿名):登录前的落地页查询
· 主题色与 PC 管理端同一套(腾讯云 TDesign 色板),两端视觉一致

为什么落地页必然是匿名接口:
学生扫开的第一屏要回答"这是哪个学校、要填什么、能不能填",
而这发生在绑定身份之前。它与契约里的 POST /mp/login(code+scene 换 session)
不是一回事 —— 那个要换 openid,属 M3。

⚠️ 一个必踩的坑(撞上并绕开了):
FillTenantLineHandler.getTenantId() 在**匿名请求下直接抛 40101**,
所以不能用 taskMapper.selectById()。正确写法是专用 Mapper + 每条查询
@InterceptorIgnore(tenantLine="true") 并显式带 tenant_id(项目注释称此为
一次代码审查点)。见 module/mp/mapper/MpSceneMapper.java —— 把这几条集中在
一个文件里,正是为了让"关掉框架保护后由人保证隔离"这件事有地方审。

⚠️ scene 可穷举(t1-k1、t1-k2…),所以匿名接口:
① 只返回落地页必须的最少信息,不往里加"顺带能查出来"的东西;
② 按 IP 限流(20 次/分钟,Redis INCR,复用项目的 GATE_RATE_LIMITED 40071)。
E2E 专门验了"前 20 次通过、第 21 次起被限、清掉计数器立刻恢复"。

状态文案分五种而不是合并成"当前不可填报":学生的下一步动作完全不同 ——
「已截止」要找班主任、「已暂停」要等通知、「尚未发布」是根本不该现在扫。

顺带记两个环境坑(写进交接摘要):
· 微信开发者工具的「服务端口」默认关闭,HBuilderX 运行时报
  `IDE service port disabled` 然后退出,**调不起开发者工具**。
  解法:开发者工具 → 设置 → 安全设置 → 服务端口 → 开启;
  或直接「导入项目」dist/dev/mp-weixin 绕过。
· HBuilderX 把这个项目当 CLI 型项目,产物在 dist/dev/mp-weixin,
  **不是** unpackage/。模拟扫码要在「编译模式」里设 进入场景=扫描二维码、
  启动参数 scene=t1-k17。
· <style lang="scss"> 需要装 sass(模板没带),已 pnpm add -D sass。

⚠️ 对上线计划最重要的一条结论(已写进交接摘要):
开发版(develop)只有开发者能扫、且会过期;体验版(trial)只有体验成员能扫;
**只有正式版(release)学生才能扫**,而它需要提交审核,涉及小程序类目与资质。
所以 C1「小程序资质法务确认」不是"以后再说",它直接决定学生能不能开始填报。

已知缺口:
· 填报主流程(/mp/login 门禁核验 表单填写)属 M3;
  现在点「开始填报」给的是明确的"开发中"提示,不是静默无反应。
· frontend-mp 还没套项目既有的 ESLint/Stylelint 规范。

验收:e2e-m1-scene.py 31/31;八条 E2E 合计 389 项断言全通过;
微信开发者工具里人工确认页面渲染与 scene 传递正确。
2026-09-29 10:29:14 +08:00