feat(fill): 草稿保存(§9.3 / §9.4)
POST /mp/submissions 取草稿(幂等)+ PUT /mp/submissions/{id} 保存草稿。
学生填了一半能存下来,再打开还在。
四条守住的规则:
1) **填报单必须属于调用者** —— 这个条件写进 SQL 的 WHERE(selectMineById),
不是"查出来再判断"。后者一旦判断漏写就是越权。
2) **锁定字段服务端再拒一次**:前端已经渲染成只读,但请求可以伪造,
而「报考工种」被改错的后果是机构直接不受理。两层防护里只有服务端这层是真的。
3) **改动留痕**(changed_fields):老师填的值被学生改过要记下来 ——
审核时看到的是一个数字,无法判断该找谁(老师填错找老师,学生改错直接驳回)。
4) 已提交的填报单不能直接改,要先撤回。
本轮修掉的 4 个真问题(全是"不报错但结果错"的类型):
1) **changed_fields 是 jsonb,我当 varchar 用了**。存了逗号分隔字符串,
PostgreSQL 报 `invalid input syntax for type json`,而错误信息里的
"Token xxx is invalid" 完全指不到是这一列。已改为存 JSON 数组。
⚠️ 这已经是第三次被"列的真实类型与我的假设不符"咬到
(前两次:expected_count NOT NULL、min_long_side NOT NULL)。
教训:**写列之前查 information_schema 的实际类型,不要只读 DDL 文档。**
2) **changed_fields 把没动过的字段也标成"改过"**。原因是我拿"提交表的列值"
和"名册值"比,而锁定字段的列**永远不写初值**、姓名/证件号/电话**根本没有列** ——
全都读成 null,于是全被判成"改过"。留痕一旦充满噪音,真正的改动就没人看了。
已改为:排除锁定字段(按定义不可能被改)与没有列的字段。
3) **学生自己填的"非预填字段"根本没被读回来**。我的合并只遍历了"名册有的键",
于是在读学校填了、保存成功、再打开却没了。已改为按**表单字段全集**遍历,
并明确三种情形:动过的预填字段用草稿值(空也算,那是清空)、
自己填的用草稿值、其余用名册值。
4) **建草稿时没做初值,导致三种状态无法区分**(从没动过 / 改成了别的 / 清空了)——
列值都是 null。表现是改动判不出、清空会被名册值顶回来。
已在建草稿时把名册值写入"可改的预填字段"做初值,状态就自明了。
顺带修正一处**字段目录与 DDL 的真不一致**:
表单定义把「联系电话」标为可改(PRD 把它列在「预填可改」里),
但 collect_submission **没有 phone 列** —— 学生改了没地方存。
一期先设为**只读**,并在代码里写明要真正开放修改需要加一列。
不用"前端允许改、后端悄悄丢掉"糊过去 —— 那会让学生以为改成功了,
实际提交的还是老号码。
另一处实现选择修正:锁定字段(报考职业/工种/等级)**要写进提交单**。
它们恰恰是最重要的统计维度(按工种统计报名人数),不落列就统计不出来 ——
而架构约束正是"内置字段落列才可统计"。写进去还带来一个好处:
它是**提交时的快照**,老师之后改了名册,这份单子仍记得"他当时报的是四级"。
顺带修 E2E 的两条过时期望(回归抓到的):
· 写死了 127.0.0.1,而 H5 应用地址已改成穿透域名 → 改为从配置读期望值;
· fragment 改成 hash 路由格式后,token 解析没跟上。
验收:e2e-m3-draft 27/27、e2e-m3-gate 29/29、e2e-m3-h5-auth 23/23。
This commit is contained in:
@@ -23,6 +23,7 @@ from __future__ import annotations
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
import urllib.error
|
||||
@@ -141,13 +142,28 @@ def main() -> int:
|
||||
check("返回 302 重定向", code == 302, f"HTTP {code} headers={list(headers.keys())}")
|
||||
loc = headers.get("Location", "")
|
||||
print(f" Location: {loc[:120]}…")
|
||||
check("回到 H5 应用地址", loc.startswith("http://127.0.0.1:5174"), loc[:80])
|
||||
# H5 应用地址是可配置的(本地开发用穿透域名),所以不能写死 127.0.0.1 ——
|
||||
# 从配置里读期望值,改配置不会让这条断言假失败
|
||||
# H5 应用地址是**可配置的**(本地开发用内网穿透域名),所以从配置里读期望值,
|
||||
# 别写死 127.0.0.1 —— 改了配置这条断言就会假失败(实测踩过)
|
||||
yml = open(os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "backend", "xt-server", "src", "main",
|
||||
"resources", "application-dev.yml"), encoding="utf-8").read()
|
||||
mm = re.search(r"^\s*h5-base-url:\s*\$\{[^:}]+:([^}]+)\}", yml, re.M)
|
||||
expect_base = mm.group(1).strip() if mm else ""
|
||||
check("回到配置的 H5 应用地址", bool(expect_base) and loc.startswith(expect_base),
|
||||
f"Location={loc[:80]} 期望前缀={expect_base or '(配置里没读到)'}")
|
||||
check("**token 在 fragment(#)里,不在 query 里** —— query 会进 Nginx 日志/Referer",
|
||||
"#" in loc and "token=" in loc.split("#", 1)[1],
|
||||
f"Location={loc[:140]}")
|
||||
check("query 部分没有 token", "token=" not in loc.split("#", 1)[0], loc[:140])
|
||||
# ⚠️ fragment 是 **hash 路由格式**(/pages/index/index?token=X&scene=Y),
|
||||
# 不是裸 hash。uni-app H5 的 onLoad 只解析路由的 query ——
|
||||
# 写成裸 hash 时两边都不认,参数会全丢(踩过)。
|
||||
frag = loc.split("#", 1)[1] if "#" in loc else ""
|
||||
params = dict(p.split("=", 1) for p in frag.split("&") if "=" in p)
|
||||
check("fragment 是 hash 路由格式(uni-app 能解析)",
|
||||
frag.startswith("/pages/index/index?"), f"fragment={frag[:60]}")
|
||||
qs = frag.split("?", 1)[1] if "?" in frag else ""
|
||||
params = dict(p.split("=", 1) for p in qs.split("&") if "=" in p)
|
||||
token = urllib.parse.unquote(params.get("token", ""))
|
||||
check("fragment 里带回了 scene(授权回来还要继续用)",
|
||||
urllib.parse.unquote(params.get("scene", "")) == scene, frag[:100])
|
||||
|
||||
Reference in New Issue
Block a user