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:
2026-09-29 11:41:40 +08:00
parent eff5a761bf
commit 5de9ccc100
9 changed files with 969 additions and 10 deletions
@@ -10,7 +10,6 @@ import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController; import org.springframework.web.bind.annotation.RestController;
/** /**
* 学生端填报(《03-接口设计》§9.x)。 * 学生端填报(《03-接口设计》§9.x)。
* *
@@ -23,6 +22,7 @@ import org.springframework.web.bind.annotation.RestController;
public class FillController { public class FillController {
private final FillService fillService; private final FillService fillService;
private final cn.tianbaotong.fill.module.fill.service.SubmissionService submissionService;
@GetMapping("/tasks/{id}/form") @GetMapping("/tasks/{id}/form")
@Operation(summary = "表单结构 + 当前值", @Operation(summary = "表单结构 + 当前值",
@@ -31,4 +31,28 @@ public class FillController {
public ApiResult<StudentFormVO> form(@PathVariable Long id) { public ApiResult<StudentFormVO> form(@PathVariable Long id) {
return ApiResult.ok(fillService.getForm(id)); return ApiResult.ok(fillService.getForm(id));
} }
@org.springframework.web.bind.annotation.PostMapping("/submissions")
@Operation(summary = "取草稿(幂等)",
description = "存在则返回既有草稿、不存在则创建。学生反复点「开始填写」不该产生多份")
public ApiResult<cn.tianbaotong.fill.module.fill.dto.DraftDtos.CreateVO> createDraft(
@org.springframework.web.bind.annotation.RequestBody java.util.Map<String, Long> body) {
Long taskId = body == null ? null : body.get("taskId");
if (taskId == null) {
throw new cn.tianbaotong.fill.common.exception.BizException(
cn.tianbaotong.fill.common.api.ErrorCode.VALIDATION_BLOCKED, "缺少 taskId");
}
return ApiResult.ok(submissionService.createOrGet(taskId));
}
@org.springframework.web.bind.annotation.PutMapping("/submissions/{id}")
@Operation(summary = "保存草稿",
description = "只传变化的字段,服务端合并。锁定字段被改动会拒绝(服务端再拦一次,"
+ "因为前端只读是可以绕过的)。已提交的填报单不能直接改,要先撤回")
public ApiResult<Void> saveDraft(@PathVariable Long id,
@org.springframework.web.bind.annotation.RequestBody
cn.tianbaotong.fill.module.fill.dto.DraftDtos.SaveReq req) {
submissionService.saveDraft(id, req);
return ApiResult.ok();
}
} }
@@ -0,0 +1,49 @@
package cn.tianbaotong.fill.module.fill.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.AllArgsConstructor;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
import java.util.Map;
/** 草稿相关请求/响应(《03-接口设计》§9.3 / §9.4)。 */
public final class DraftDtos {
private DraftDtos() {
}
/** §9.3 取草稿的响应。 */
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
@Schema(description = "填报单")
public static class CreateVO {
private Long id;
private Integer formVersion;
/** true = 本次新建;false = 返回了既有草稿(幂等)。 */
private Boolean created;
}
/** §9.4 保存草稿。 */
@Getter
@Setter
@Schema(description = "保存草稿")
public static class SaveReq {
/**
* 表单值,<b>只传变化的字段</b>,服务端做合并。
*
* <p>键必须是表单定义里存在的字段 —— 传了不认识的键会拒绝而不是静默忽略:
* 静默忽略会让"我明明填了却没保存上"这种问题极难定位。
*/
private Map<String, Object> formData;
/** 扩展字段的值(只接受表单定义里声明过的扩展键)。 */
private Map<String, Object> extrasJson;
/** 客户端时间,用于冲突提示(本轮未使用,保留契约字段)。 */
private String clientTime;
}
}
@@ -0,0 +1,116 @@
package cn.tianbaotong.fill.module.fill.entity;
import cn.tianbaotong.fill.support.TenantEntity;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Getter;
import lombok.Setter;
import java.time.LocalDate;
import java.time.LocalDateTime;
/**
* 填报单(对应 {@code collect_submission})—— 一个学生在一个任务下的那一份材料。
*
* <p><b>身份由名册决定,不由本人声明</b>:{@code rosterId} 指向门禁核验时绑定上的
* 名册行,姓名与证件号都是从那里来的。学生填的只是"这个人的其余信息"。
* 所以这张表里<b>没有</b> {@code real_name}/{@code id_card_no} —— 那两项始终以名册为准,
* 存两份就会有两份不一致的可能。
*
* <p>⚠️ <b>内置字段落列、扩展字段落 {@code extras_json}</b>,这是架构约束不是取舍:
* 只有落列的字段才可查询、可索引、可统计。界面上一直明示"扩展字段不参与核心统计",
* 否则校长会发现报表里少了一列,而那时已经填了几百份。
*/
@Getter
@Setter
@TableName("collect_submission")
public class CollectSubmission extends TenantEntity {
private Long taskId;
/** 绑定的名册行 —— 身份的唯一来源。 */
private Long rosterId;
private Long orgId;
/** 填报人(微信账号)。 */
private Long submitterId;
/** 填写时用的表单版本,留痕用。 */
private Integer formVersion;
/** 表单值快照(jsonb)。列上已有值的字段以列为准,这里用于导出与留痕。 */
private String formData;
// ── 内置字段(可统计)───────────────────────
private String occupation;
private String worktype;
private String jobLevel;
private String education;
/** 是否在校生:{@code 0} 否 / {@code 1} 是。 */
private Integer isStudent;
private String school;
private String major;
private LocalDate enrollDate;
private String workUnit;
private Integer isRetake;
private String retakeTheory;
private String retakePractice;
private String retakeOverall;
private Integer workYears;
private String certOccupation;
private String certLevel;
private String certNo;
/** 扩展字段的值(不参与统计)。 */
private String extrasJson;
/**
* 学生改过的预填字段,逗号分隔。
*
* <p>「老师已填、学生改过」这件事必须留痕 —— 否则审核时看到的是一个数字,
* 无法判断它是老师填的还是学生改的。而这两种情况的处理完全不同:
* 老师填错要找老师,学生改错直接驳回就行。
*/
private String changedFields;
/** {@code 0} 草稿 / {@code 1} 待初审 / {@code 2} 待终审 / {@code 3} 已通过 / {@code 4} 已驳回 / {@code 5} 已归档。 */
private Integer status;
private Integer rejectCount;
private String lastRejectReason;
private Integer redCount;
private Integer yellowCount;
private LocalDateTime submitTime;
private Long classAuditBy;
private Long adminAuditBy;
public static final int STATUS_DRAFT = 0;
public static final int STATUS_PENDING_CLASS = 1;
public static final int STATUS_PENDING_ADMIN = 2;
public static final int STATUS_APPROVED = 3;
public static final int STATUS_REJECTED = 4;
public static final int STATUS_ARCHIVED = 5;
}
@@ -0,0 +1,45 @@
package cn.tianbaotong.fill.module.fill.mapper;
import cn.tianbaotong.fill.module.fill.entity.CollectSubmission;
import com.baomidou.mybatisplus.annotation.InterceptorIgnore;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
public interface SubmissionMapper extends BaseMapper<CollectSubmission> {
/**
* 取某人在某任务下的填报单(一个学生在一个任务下只有一份)。
*
* <p>用显式 SQL 而不是条件构造器,原因与门禁一致:{@code tenant_id} 与
* {@code submitter_id} 都是"必须带上"的过滤条件,写成显式参数让它们
* 在一个地方可审查 —— 少一个条件就是"能看到别人的填报单"。
*/
@InterceptorIgnore(tenantLine = "true")
@Select("""
SELECT * FROM collect_submission
WHERE tenant_id = #{tenantId} AND task_id = #{taskId}
AND submitter_id = #{submitterId} AND deleted = 0
ORDER BY id DESC
LIMIT 1
""")
CollectSubmission selectMine(@Param("tenantId") Long tenantId,
@Param("taskId") Long taskId,
@Param("submitterId") Long submitterId);
/**
* 按 id 取,且<b>必须属于我</b>。
*
* <p>不要写成"先按 id 查、再判断是不是我的" —— 那样一旦判断漏写就是越权。
* 把 {@code submitter_id} 放进 WHERE 里,越权在 SQL 层就不可能发生。
*/
@InterceptorIgnore(tenantLine = "true")
@Select("""
SELECT * FROM collect_submission
WHERE id = #{id} AND tenant_id = #{tenantId}
AND submitter_id = #{submitterId} AND deleted = 0
""")
CollectSubmission selectMineById(@Param("id") Long id,
@Param("tenantId") Long tenantId,
@Param("submitterId") Long submitterId);
}
@@ -5,6 +5,8 @@ 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.common.exception.BizException;
import cn.tianbaotong.fill.module.fill.dto.StudentFormVO; import cn.tianbaotong.fill.module.fill.dto.StudentFormVO;
import cn.tianbaotong.fill.module.fill.entity.CollectSubmission;
import cn.tianbaotong.fill.module.fill.mapper.SubmissionMapper;
import cn.tianbaotong.fill.module.form.dto.FormFieldVO; import cn.tianbaotong.fill.module.form.dto.FormFieldVO;
import cn.tianbaotong.fill.module.form.dto.FormSchemaVO; import cn.tianbaotong.fill.module.form.dto.FormSchemaVO;
import cn.tianbaotong.fill.module.form.service.FormSchemaService; import cn.tianbaotong.fill.module.form.service.FormSchemaService;
@@ -20,6 +22,7 @@ import org.springframework.stereotype.Service;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.LinkedHashMap; import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.Set; import java.util.Set;
@@ -58,6 +61,7 @@ public class FillService {
private final FormSchemaService formSchemaService; private final FormSchemaService formSchemaService;
private final MaterialDefService materialDefService; private final MaterialDefService materialDefService;
private final MpRosterMapper rosterMapper; private final MpRosterMapper rosterMapper;
private final SubmissionMapper submissionMapper;
public StudentFormVO getForm(Long taskId) { public StudentFormVO getForm(Long taskId) {
LoginUser me = UserContext.require(); LoginUser me = UserContext.require();
@@ -79,14 +83,59 @@ public class FillService {
} }
FormSchemaVO schema = formSchemaService.get(taskId); FormSchemaVO schema = formSchemaService.get(taskId);
Map<String, Object> values = rosterValues(roster); Map<String, Object> values = rosterValuesOf(roster);
StudentFormVO vo = new StudentFormVO(); StudentFormVO vo = new StudentFormVO();
vo.setTaskId(taskId); vo.setTaskId(taskId);
vo.setTaskName(task.getTaskName()); vo.setTaskName(task.getTaskName());
vo.setFormVersion(schema.getVersion()); vo.setFormVersion(schema.getVersion());
vo.setSubmissionStatus(0); vo.setSubmissionStatus(CollectSubmission.STATUS_DRAFT);
// TODO(下一段):取草稿单,把学生存过的值覆盖到 values 上
// 学生自己存过的草稿覆盖名册值。
// ⚠️ 关键在**怎么判断"这个字段学生动过"**:草稿列是 null 时无法区分
// "从没填过"和"填了又清空"。所以用 changed_fields —— 它记录的正是
// "学生动过的预填字段"。在里面的字段以草稿为准(哪怕是空),
// 不在里面的字段以名册为准。这也是 changed_fields 的第二个用途。
CollectSubmission draft = submissionMapper.selectMine(me.tenantId(), taskId, me.uid());
Set<String> touched = new LinkedHashSet<>();
if (draft != null) {
vo.setSubmissionId(draft.getId());
vo.setSubmissionStatus(draft.getStatus());
// changed_fields 是 jsonb(JSON 数组),不是逗号分隔字符串
if (draft.getChangedFields() != null && !draft.getChangedFields().isBlank()) {
try {
List<String> keys = cn.hutool.json.JSONUtil.toList(draft.getChangedFields(), String.class);
for (String k : keys) {
if (k != null && !k.isBlank()) {
touched.add(k.trim());
}
}
} catch (Exception e) {
log.warn("changed_fields 不是合法 JSON 数组,忽略:{}", draft.getChangedFields());
}
}
// 草稿值怎么覆盖名册值 —— 三种情形(配合建草稿时的"预填初值"):
// ① 学生动过的预填字段(在 changed_fields 里)→ 用草稿值,**空也算**(那是清空)
// ② 学生自己填的(名册没有的,如在读学校)→ 草稿值非空就用它
// ③ 其余 → 名册值
// ⚠️ 只遍历"名册有的键"会漏掉情形 ② —— 实测表现是"学生在读学校填了,
// 保存成功,再打开却没了"。所以按**表单字段全集**遍历。
for (String key : schema.getFields().stream().map(FormFieldVO::getKey).toList()) {
if (!SubmissionService.SubValues.hasColumn(key)) {
continue;
}
Object v = SubmissionService.SubValues.read(draft, key);
if (touched.contains(key)) {
if (v == null) {
values.remove(key);
} else {
values.put(key, v);
}
} else if (v != null) {
values.put(key, v);
}
}
}
StudentFormVO.Section basic = new StudentFormVO.Section(); StudentFormVO.Section basic = new StudentFormVO.Section();
basic.setTitle("基本信息"); basic.setTitle("基本信息");
@@ -134,8 +183,12 @@ public class FillService {
* *
* <p>键名与表单字段键、与 {@code collect_submission} 的列名三者一致 —— * <p>键名与表单字段键、与 {@code collect_submission} 的列名三者一致 ——
* 这是「内置字段可统计」的实现基础,任何一处不一致都会让统计口径出现缺口。 * 这是「内置字段可统计」的实现基础,任何一处不一致都会让统计口径出现缺口。
*
* <p>public 是给 {@code SubmissionService} 复用的:保存草稿时要用<b>同一份</b>
* 映射去判断"锁定字段有没有被改"以及"哪些预填字段被改过"。
* 两处各写一份的话,改了名册映射就会漏掉一处,而漏掉的表现是留痕不准。
*/ */
private static Map<String, Object> rosterValues(CollectRoster r) { public static Map<String, Object> rosterValuesOf(CollectRoster r) {
Map<String, Object> m = new LinkedHashMap<>(); Map<String, Object> m = new LinkedHashMap<>();
putIfPresent(m, "seq_no", r.getSeqNo()); putIfPresent(m, "seq_no", r.getSeqNo());
putIfPresent(m, "real_name", r.getRealName()); putIfPresent(m, "real_name", r.getRealName());
@@ -0,0 +1,400 @@
package cn.tianbaotong.fill.module.fill.service;
import cn.hutool.json.JSONUtil;
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.fill.dto.DraftDtos;
import cn.tianbaotong.fill.module.fill.entity.CollectSubmission;
import cn.tianbaotong.fill.module.fill.mapper.SubmissionMapper;
import cn.tianbaotong.fill.module.form.dto.FormFieldVO;
import cn.tianbaotong.fill.module.form.dto.FormSchemaVO;
import cn.tianbaotong.fill.module.form.service.FormSchemaService;
import cn.tianbaotong.fill.module.mp.mapper.MpRosterMapper;
import cn.tianbaotong.fill.module.roster.entity.CollectRoster;
import cn.tianbaotong.fill.module.task.entity.CollectTask;
import cn.tianbaotong.fill.module.task.mapper.TaskMapper;
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDate;
import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
/**
* 草稿的建立与保存(《03-接口设计》§9.3 / §9.4)。
*
* <p><b>三处不能省的判断</b>:
* <ol>
* <li><b>填报单必须属于调用者</b> —— 这个条件写在 SQL 的 WHERE 里
* (见 {@code SubmissionMapper.selectMineById}),不是"查出来再判断"。
* 后者一旦判断漏写就是越权。</li>
* <li><b>锁定字段服务端再拒一次</b>。前端已经渲染成只读了,但服务端不能因此放行:
* 请求是可以伪造的,而「报考工种」被改错的后果是机构直接不受理。
* 两层防护里,只有服务端这层是真的。</li>
* <li><b>改动要留痕</b>({@code changed_fields}):老师填的值被学生改过,
* 审核时看到的是一个数字,无法判断该找谁 —— 老师填错要找老师,
* 学生改错直接驳回就行。</li>
* </ol>
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class SubmissionService {
private final SubmissionMapper submissionMapper;
private final TaskMapper taskMapper;
private final FormSchemaService formSchemaService;
private final MpRosterMapper rosterMapper;
// ── §9.3 取草稿(幂等)────────────────────────
@Transactional(rollbackFor = Exception.class)
public DraftDtos.CreateVO createOrGet(Long taskId) {
LoginUser me = UContext.require();
requireFillableTask(taskId);
CollectRoster roster = requireGate(me.tenantId(), taskId, me.uid());
CollectSubmission exist = submissionMapper.selectMine(me.tenantId(), taskId, me.uid());
if (exist != null) {
// 幂等:学生反复点"开始填写"不该产生多份草稿
return new DraftDtos.CreateVO(exist.getId(), exist.getFormVersion(), false);
}
FormSchemaVO schema = formSchemaService.get(taskId);
CollectSubmission s = new CollectSubmission();
s.setTaskId(taskId);
s.setRosterId(roster.getId());
s.setOrgId(roster.getOrgId());
s.setSubmitterId(me.uid());
s.setFormVersion(schema.getVersion());
s.setStatus(CollectSubmission.STATUS_DRAFT);
s.setRejectCount(0);
// ⚠️ 把"可改的预填字段"用名册值做**初值**。
//
// 不做这一步,草稿列全是 null,于是**三种状态无法区分**:
// "从没动过" / "改成了别的" / "清空了" —— 列值都是 null(或非 null 但无从对比)。
// 表现是两种 bug 同时出现:改过的判不出、清空的会被名册值顶回来。
//
// 做了初值之后状态是自明的:
// null(不可改的字段,本来就不写) / 等于名册值(没动过) / 不等于(改过或清空)
Map<String, FormFieldVO> defs = new LinkedHashMap<>();
for (FormFieldVO f : schema.getFields()) {
defs.put(f.getKey(), f);
}
Map<String, Object> rv = FillService.rosterValuesOf(roster);
for (Map.Entry<String, Object> e : rv.entrySet()) {
if (!SubmissionService.SubValues.hasColumn(e.getKey())) {
continue; // 提交表没有这一列(如联系电话)
}
// ⚠️ 锁定字段**也要**写进提交单。
// 一开始我只写了"可改的预填字段",但漏了关键一点:
// 「报考职业/工种/等级」恰恰是**最重要的统计维度**(按工种统计报名人数),
// 不落列就统计不出来 —— 而架构约束正是"内置字段落列才可统计"。
// 写进去还有一个好处:它是**提交时的快照**。
// 老师之后改了名册,这份单子仍然记得"他当时报的是四级"。
SubmissionService.SubValues.apply(s, e.getKey(), e.getValue());
}
submissionMapper.insert(s);
log.info("建草稿:task={} roster={} user={} id={}", taskId, roster.getId(), me.uid(), s.getId());
return new DraftDtos.CreateVO(s.getId(), s.getFormVersion(), true);
}
// ── §9.4 保存草稿 ────────────────────────────
@Transactional(rollbackFor = Exception.class)
public void saveDraft(Long id, DraftDtos.SaveReq req) {
LoginUser me = UContext.require();
CollectSubmission sub = submissionMapper.selectMineById(id, me.tenantId(), me.uid());
if (sub == null) {
throw new BizException(ErrorCode.VALIDATION_BLOCKED, "填报单不存在或不属于你");
}
requireFillableTask(sub.getTaskId());
if (sub.getStatus() != null && sub.getStatus() != CollectSubmission.STATUS_DRAFT) {
// 已提交的单子不能再改 —— 要改必须先撤回(§9.11)
throw new BizException(ErrorCode.AUDIT_IN_PROGRESS, "已提交的填报单不能直接修改,请先撤回");
}
CollectRoster roster = requireGate(me.tenantId(), sub.getTaskId(), me.uid());
FormSchemaVO schema = formSchemaService.get(sub.getTaskId());
Map<String, FormFieldVO> defs = new LinkedHashMap<>();
for (FormFieldVO f : schema.getFields()) {
defs.put(f.getKey(), f);
}
for (FormFieldVO f : schema.getExtras()) {
defs.put(f.getKey(), f);
}
Map<String, Object> rosterVals = FillService.rosterValuesOf(roster);
Map<String, Object> incoming = req.getFormData() == null ? Map.of() : req.getFormData();
for (Map.Entry<String, Object> e : incoming.entrySet()) {
String key = e.getKey();
FormFieldVO def = defs.get(key);
if (def == null) {
// 不静默忽略:静默忽略会让"我明明填了却没保存上"极难定位
throw new BizException(ErrorCode.VALIDATION_BLOCKED,
"表单里没有这个字段:" + key);
}
if (Boolean.TRUE.equals(def.getLocked())) {
if (!sameValue(e.getValue(), rosterVals.get(key))) {
throw new BizException(ErrorCode.VALIDATION_BLOCKED,
"「" + def.getLabel() + "」由老师确定,不能修改;如有误请联系班主任");
}
continue; // 与名册一致,不必写
}
SubValues.apply(sub, key, e.getValue());
}
// 扩展字段:只接受表单定义里声明过的键(否则就是往库里塞任意 JSON)
if (req.getExtrasJson() != null && !req.getExtrasJson().isEmpty()) {
Map<String, Object> extras = new LinkedHashMap<>();
for (Map.Entry<String, Object> e : req.getExtrasJson().entrySet()) {
FormFieldVO def = defs.get(e.getKey());
if (def == null || Boolean.TRUE.equals(def.getBuiltin())) {
throw new BizException(ErrorCode.VALIDATION_BLOCKED,
"不是本表单的扩展字段:" + e.getKey());
}
extras.put(e.getKey(), e.getValue());
}
sub.setExtrasJson(JSONUtil.toJsonStr(extras));
}
// 留痕:名册里老师填过、而现在值不一样的字段。
// ⚠️ changed_fields 是 **jsonb**(不是逗号分隔字符串)—— 存错类型会直接报
// `invalid input syntax for type json`,而错误信息里的 "Token xxx is invalid"
// 完全指不到是这一列的问题。所以这里用 JSON 数组。
Set<String> changed = new LinkedHashSet<>();
for (Map.Entry<String, FormFieldVO> e : defs.entrySet()) {
FormFieldVO def = e.getValue();
if (!Boolean.TRUE.equals(def.getBuiltin())) {
continue;
}
// ⚠️ 锁定字段按定义就不可能被改(上面已经拒掉了改动),而且它们的提交表列
// **永远不写初值** —— 拿"读不到(null)"和"名册有值"比,会把它们
// 全部误判成"学生改过"。实测就是这样:changed_fields 里出现了
// occupation/worktype/job_level,而学生从没碰过。
// 留痕一旦充满噪音,真正的改动就没人看了 —— 那它的价值就没了。
if (Boolean.TRUE.equals(def.getLocked())) {
continue;
}
// 提交表没有这一列的(如联系电话)也不比,理由同上
if (!SubValues.hasColumn(e.getKey())) {
continue;
}
Object rosterVal = rosterVals.get(e.getKey());
if (rosterVal == null) {
continue; // 名册本来就没填 → 不算"改了老师填的"
}
if (!sameValue(SubValues.read(sub, e.getKey()), rosterVal)) {
changed.add(e.getKey());
}
}
sub.setChangedFields(changed.isEmpty() ? null : JSONUtil.toJsonStr(changed));
// ⚠️ 用 UpdateWrapper 显式写每个值列:updateById 会跳过 null,
// 而"学生把已填的学历清空"正是一次把字段置 null 的操作 ——
// 用 updateById 会静默不生效(接口成功、库里没变),最难查的一类 bug
submissionMapper.update(null, Wrappers.<CollectSubmission>lambdaUpdate()
.eq(CollectSubmission::getId, sub.getId())
.set(CollectSubmission::getOccupation, sub.getOccupation())
.set(CollectSubmission::getWorktype, sub.getWorktype())
.set(CollectSubmission::getJobLevel, sub.getJobLevel())
.set(CollectSubmission::getEducation, sub.getEducation())
.set(CollectSubmission::getIsStudent, sub.getIsStudent())
.set(CollectSubmission::getSchool, sub.getSchool())
.set(CollectSubmission::getMajor, sub.getMajor())
.set(CollectSubmission::getEnrollDate, sub.getEnrollDate())
.set(CollectSubmission::getWorkUnit, sub.getWorkUnit())
.set(CollectSubmission::getIsRetake, sub.getIsRetake())
.set(CollectSubmission::getRetakeTheory, sub.getRetakeTheory())
.set(CollectSubmission::getRetakePractice, sub.getRetakePractice())
.set(CollectSubmission::getRetakeOverall, sub.getRetakeOverall())
.set(CollectSubmission::getWorkYears, sub.getWorkYears())
.set(CollectSubmission::getCertOccupation, sub.getCertOccupation())
.set(CollectSubmission::getCertLevel, sub.getCertLevel())
.set(CollectSubmission::getCertNo, sub.getCertNo())
.set(CollectSubmission::getExtrasJson, sub.getExtrasJson())
.set(CollectSubmission::getChangedFields, sub.getChangedFields()));
}
// ── 内部 ──────────────────────────────────────
private void requireFillableTask(Long taskId) {
CollectTask task = taskMapper.selectById(taskId);
if (task == null) {
throw new BizException(ErrorCode.VALIDATION_BLOCKED, "任务不存在或已删除");
}
if (task.getStatus() == null || task.getStatus() != CollectTask.STATUS_COLLECTING) {
throw new BizException(ErrorCode.TASK_NOT_OPEN);
}
}
private CollectRoster requireGate(Long tenantId, Long taskId, Long userId) {
CollectRoster roster = rosterMapper.selectBoundByUser(tenantId, taskId, userId);
if (roster == null) {
throw new BizException(ErrorCode.GATE_NOT_MATCHED,
"请先核对身份(填写姓名与身份证号)再填写材料");
}
return roster;
}
/**
* 值比较:把 {@code null}、空串、纯空白视作同一个"没填"。
*
* <p>不这么做的话,学生把输入框里老师填的内容删空再原样打一遍(或者前后多一个空格)
* 就会被判成"改过" — 留痕会充满噪音,而留痕的价值就在于让人一眼看到真正的改动。
*/
static boolean sameValue(Object a, Object b) {
String x = a == null ? "" : String.valueOf(a).trim();
String y = b == null ? "" : String.valueOf(b).trim();
if (x.equals(y)) {
return true;
}
// 布尔的两端表示:库里是 0/1,前端传 true/false
return normalizeBool(x).equals(normalizeBool(y));
}
private static String normalizeBool(String s) {
return switch (s.toLowerCase()) {
case "true" -> "1";
case "false" -> "0";
default -> s;
};
}
/** 只为让依赖注入的 {@code UserContext} 不被静态导入污染。 */
private static final class UContext {
static LoginUser require() {
return cn.tianbaotong.fill.common.context.UserContext.require();
}
}
/**
* 内置字段的"按 key 读/写"。
*
* <p>抽出来是因为读写各要一遍 switch,散在两个方法里迟早漏字段 ——
* 而漏掉的表现是"某一项填了没保存",且不报错。
*/
static final class SubValues {
/**
* 这个字段在 {@code collect_submission} 里有没有对应的可写列。
*
* <p>⚠️ 姓名、身份证号、联系电话<b>只存在于名册</b>,提交表里没有这三列 ——
* 所以它们既不该被比较(会造成假的"改动留痕"),也不该被写入。
* 判断"列存在"而不是"我实现了读写的分支",是为了让这个事实<b>只有一个来源</b>:
* 加字段时先看表有没有这一列,而不是看代码里有没有 case。
*/
static boolean hasColumn(String key) {
return switch (key) {
case "occupation", "worktype", "job_level", "education", "is_student",
"school", "major", "enroll_date", "work_unit", "is_retake",
"retake_theory", "retake_practice", "retake_overall", "work_years",
"cert_occupation", "cert_level", "cert_no" -> true;
default -> false;
};
}
static void apply(CollectSubmission s, String key, Object v) {
String str = v == null ? null : String.valueOf(v);
switch (key) {
case "occupation" -> s.setOccupation(blankToNull(str));
case "worktype" -> s.setWorktype(blankToNull(str));
case "job_level" -> s.setJobLevel(blankToNull(str));
case "education" -> s.setEducation(blankToNull(str));
case "is_student" -> s.setIsStudent(boolToInt(v));
case "school" -> s.setSchool(blankToNull(str));
case "major" -> s.setMajor(blankToNull(str));
case "enroll_date" -> s.setEnrollDate(parseDate(str));
case "work_unit" -> s.setWorkUnit(blankToNull(str));
case "is_retake" -> s.setIsRetake(boolToInt(v));
case "retake_theory" -> s.setRetakeTheory(blankToNull(str));
case "retake_practice" -> s.setRetakePractice(blankToNull(str));
case "retake_overall" -> s.setRetakeOverall(blankToNull(str));
case "work_years" -> s.setWorkYears(parseInt(str));
case "cert_occupation" -> s.setCertOccupation(blankToNull(str));
case "cert_level" -> s.setCertLevel(blankToNull(str));
case "cert_no" -> s.setCertNo(blankToNull(str));
default -> {
// 姓名/证件号/序号等来自名册,学生端不该写;真传了也应被上游的 locked 拦掉。
// 走到这里说明 schema 没标 locked —— 那是配置问题,静默丢弃比写入更安全
}
}
}
static Object read(CollectSubmission s, String key) {
return switch (key) {
case "occupation" -> s.getOccupation();
case "worktype" -> s.getWorktype();
case "job_level" -> s.getJobLevel();
case "education" -> s.getEducation();
case "is_student" -> s.getIsStudent();
case "school" -> s.getSchool();
case "major" -> s.getMajor();
case "enroll_date" -> s.getEnrollDate() == null ? null : s.getEnrollDate().toString();
case "work_unit" -> s.getWorkUnit();
case "is_retake" -> s.getIsRetake();
case "retake_theory" -> s.getRetakeTheory();
case "retake_practice" -> s.getRetakePractice();
case "retake_overall" -> s.getRetakeOverall();
case "work_years" -> s.getWorkYears();
case "cert_occupation" -> s.getCertOccupation();
case "cert_level" -> s.getCertLevel();
case "cert_no" -> s.getCertNo();
default -> null;
};
}
private static String blankToNull(String s) {
if (s == null) {
return null;
}
String t = s.trim();
return t.isEmpty() ? null : t;
}
private static Integer boolToInt(Object v) {
if (v == null) {
return null;
}
if (v instanceof Boolean b) {
return b ? 1 : 0;
}
String s = String.valueOf(v).trim();
if (s.isEmpty()) {
return null;
}
return ("1".equals(s) || "true".equalsIgnoreCase(s)) ? 1 : 0;
}
private static Integer parseInt(String s) {
if (s == null || s.isBlank()) {
return null;
}
try {
return Integer.valueOf(s.trim());
} catch (NumberFormatException e) {
throw new BizException(ErrorCode.VALIDATION_BLOCKED, "「本职业工作年限」只能填阿拉伯数字");
}
}
private static LocalDate parseDate(String s) {
if (s == null || s.isBlank()) {
return null;
}
try {
return LocalDate.parse(s.trim());
} catch (Exception e) {
throw new BizException(ErrorCode.VALIDATION_BLOCKED, "「入学时间」格式应为 YYYY-MM-DD");
}
}
}
}
@@ -83,9 +83,14 @@ public final class BuiltinFields {
list.add(submission("education", "学历", "select", true, false, ++sort * 10, list.add(submission("education", "学历", "select", true, false, ++sort * 10,
opts(EDU_LEVEL), null, null)); opts(EDU_LEVEL), null, null));
// E 联系电话:来自名册(预填),但学生可以改 —— 名册里的号码常是家长的老号码 // 联系电话:来自名册(预填),但**提交表没有 phone 列** —— 学生改了没地方存。
final FormFieldVO phone = roster("phone", "联系电话", "phone", true, false, ++sort * 10); // ⚠️ 这是字段目录与 DDL 的一处真不一致(PRD 把"联系电话"列在「预填可改」里)。
phone.setType("phone"); // 一期先设为**只读**,与"如号码有误请联系班主任"一致;
// 要真正开放修改,需要给 collect_submission 加一列 phone
// (或把这类"无列可存的预填项"统一落到 form_data)。
// 先记下来,不要用"前端允许改、后端悄悄丢掉"来糊过去 ——
// 那会让学生以为改成功了,而实际提交的还是老号码。
final FormFieldVO phone = roster("phone", "联系电话", "phone", true, true, ++sort * 10);
list.add(phone); list.add(phone);
// F 工作单位:官方原文「没有则填"无"」。 // F 工作单位:官方原文「没有则填"无"」。
+251
View File
@@ -0,0 +1,251 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""
草稿保存 验收测试(《03-接口设计》§9.3 / §9.4)。
[x] 取草稿是幂等的(反复调用只产生一份)
[x] 保存草稿:只传变化的字段,服务端合并
[x] **锁定字段服务端再拒一次**(前端只读是可以绕过的)
[x] 改动留痕:只有"老师填过且学生改过"的字段进 changed_fields
[x] 学生把预填字段**清空**也要能落库(updateById 会静默跳过 null)
[x] 未过门禁不能建草稿;已提交的不能直接改
[x] 草稿值会覆盖名册值回显,且"清空过的"字段不会被名册值顶回来
跑法:python scripts/e2e-m3-draft.py
"""
from __future__ import annotations
import argparse
import base64
import hashlib
import hmac
import json
import os
import re
import subprocess
import sys
import time
import urllib.error
import urllib.parse
import urllib.request
sys.stdout.reconfigure(encoding="utf-8")
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
DEV_YML = os.path.join(ROOT, "backend", "xt-server", "src", "main", "resources", "application-dev.yml")
PG = os.environ.get("XT_PG_CONTAINER", "xt-pg")
PASSED: list[str] = []
FAILED: list[tuple[str, str]] = []
CREATED: list[int] = []
def ok(n: str) -> None:
PASSED.append(n)
print(f" [OK] {n}")
def fail(n: str, d: str) -> None:
FAILED.append((n, d))
print(f" [FAIL] {n}\n {d}")
def check(n: str, cond: bool, d: str = "") -> bool:
if cond:
ok(n)
else:
fail(n, d or "断言不成立")
return cond
def sql(q: str) -> str:
r = subprocess.run(
["docker", "exec", "-e", "PGPASSWORD=fill_dev_pwd", PG, "psql", "-U", "fill",
"-d", "filldb", "-t", "-A", "-c", q], capture_output=True, text=True, timeout=40)
return (r.stdout or r.stderr).strip()
def read_cfg() -> dict:
files = [DEV_YML, os.path.join(os.path.dirname(DEV_YML), "application.yml")]
text = "\n".join(open(f, encoding="utf-8").read() for f in files if os.path.exists(f))
out = {}
for k, p in [("jwt", r"^\s*secret:\s*(\S+)\s*$"), ("issuer", r"^\s*issuer:\s*(\S+)\s*$")]:
m = re.search(p, text, re.M)
if not m:
raise SystemExit(f"找不到 {k}")
out[k] = m.group(1)
return out
def b64u(b: bytes) -> str:
return base64.urlsafe_b64encode(b).rstrip(b"=").decode()
def mint(secret: str, issuer: str, uid: int, tenant: int, roles: list[str], utype: int) -> str:
key = base64.b64decode(secret)
now = int(time.time())
signing = (b64u(json.dumps({"alg": "HS512"}, separators=(",", ":")).encode()) + "."
+ b64u(json.dumps({"iss": issuer, "sub": str(uid), "tenantId": tenant,
"orgIds": [], "roles": roles, "userType": utype,
"iat": now, "exp": now + 3600}, separators=(",", ":")).encode()))
sig = hmac.new(key, signing.encode(), hashlib.sha512).digest()
return signing + "." + b64u(sig)
def call(method: str, path: str, token: str | None = None, body: dict | None = None) -> dict:
h = {"Content-Type": "application/json; charset=utf-8"}
if token:
h["Authorization"] = f"Bearer {token}"
data = json.dumps(body, ensure_ascii=False).encode() if body is not None else None
req = urllib.request.Request("http://localhost:8080" + path, data=data, headers=h, method=method)
try:
with urllib.request.urlopen(req, timeout=30) as x:
return json.loads(x.read().decode("utf-8"))
except urllib.error.HTTPError as e:
try:
return json.loads(e.read().decode("utf-8"))
except Exception:
return {"_http": e.code}
def fields_of(form: dict) -> dict:
out = {}
for sec in form.get("sections") or []:
for f in sec.get("fields") or []:
out[f["key"]] = f
return out
def main() -> int:
ap = argparse.ArgumentParser()
ap.add_argument("--task", type=int, default=17)
ap.add_argument("--tenant", type=int, default=1)
args = ap.parse_args()
task_id = args.task
tenant = args.tenant
print("=" * 72)
print(f"草稿保存 E2E task={task_id}")
print("=" * 72)
cfg = read_cfg()
uid = 991001 # 测试用学生(不建真实账号,草稿表只存 id)
tok = mint(cfg["jwt"], cfg["issuer"], uid, tenant, ["STUDENT"], 4)
base = f"/api/v1/mp"
# ── 0. 前置:必须先过门禁 ──────────────────────────
print("\n[0] 前置门槛")
r = call("POST", f"{base}/submissions", tok, {"taskId": task_id})
check("未过门禁时不能建草稿(40007)", r.get("code") == 40007,
f"code={r.get('code')} msg={r.get('msg')}")
# 造一个已绑定的名册行(模拟门禁已通过)
h = sql(f"SELECT id FROM collect_roster WHERE task_id={task_id} LIMIT 1")
if not h.isdigit():
fail("取名册行", "任务下没有名册,先跑 e2e-m3-h5-auth 或手工造一行")
return 1
sql(f"UPDATE collect_roster SET bind_user_id={uid}, bind_type=1 WHERE id={h}")
r = call("GET", f"{base}/tasks/{task_id}/form", tok)
check("过门禁后能取表单", r.get("code") == 0, f"code={r.get('code')} msg={r.get('msg')}")
form = r.get("data") or {}
f0 = fields_of(form)
check("表单有 21 个内置字段", len(f0) == 21, f"实际 {len(f0)}")
check("报考职业是只读且已由老师填", f0["occupation"].get("locked") is True
and f0["occupation"].get("value") == "车工", f"{f0['occupation']}")
check("学历可改且标注老师已填", f0["education"].get("locked") is False
and f0["education"].get("prefilled") is True, f"{f0['education']}")
# ── 1. 取草稿(幂等)──────────────────────────────
print("\n[1] 取草稿:幂等")
r1 = call("POST", f"{base}/submissions", tok, {"taskId": task_id})
check("首次创建", r1.get("code") == 0 and (r1.get("data") or {}).get("created") is True,
f"{r1}")
sid = (r1.get("data") or {}).get("id")
CREATED.append(sid)
r2 = call("POST", f"{base}/submissions", tok, {"taskId": task_id})
check("再次调用返回同一份(created=false)",
(r2.get("data") or {}).get("id") == sid and (r2.get("data") or {}).get("created") is False,
f"{r2.get('data')}")
cnt = sql(f"SELECT count(*) FROM collect_submission WHERE task_id={task_id} AND submitter_id={uid}")
check("库里只有一份草稿", cnt == "1", f"{cnt}")
# ── 2. 保存草稿 ──────────────────────────────────
print("\n[2] 保存草稿(只传变化的字段)")
r = call("PUT", f"{base}/submissions/{sid}", tok, {"formData": {
"school": "江西机电职业技术学院",
"major": "数控加工",
"enroll_date": "2024-09-01",
"is_student": True,
}})
check("保存成功", r.get("code") == 0, f"code={r.get('code')} msg={r.get('msg')}")
r = call("GET", f"{base}/tasks/{task_id}/form", tok)
fd = fields_of(r.get("data") or {})
check("存过的值回显了", fd["school"].get("value") == "江西机电职业技术学院",
f"{fd['school'].get('value')}")
check("库里也落了列(可统计)",
sql(f"SELECT school FROM collect_submission WHERE id={sid}") == "江西机电职业技术学院",
sql(f"SELECT school FROM collect_submission WHERE id={sid}"))
check("没传的字段不受影响(名册值仍在)", fd["occupation"].get("value") == "车工",
f"{fd['occupation'].get('value')}")
check("草稿单 id 出现在表单里", (r.get("data") or {}).get("submissionId") == sid,
f"{(r.get('data') or {}).get('submissionId')}")
# ── 3. 锁定字段服务端再拒一次 ──────────────────────
print("\n[3] 锁定字段:前端只读是可绕过的,服务端必须再拦")
r = call("PUT", f"{base}/submissions/{sid}", tok, {"formData": {"occupation": "铣工"}})
check("改「报考职业」被拒", r.get("code") == 40001, f"code={r.get('code')} msg={r.get('msg')}")
check("拒绝理由讲清是老师定的、以及怎么办",
"老师" in (r.get("msg") or "") and "班主任" in (r.get("msg") or ""),
f"msg={r.get('msg')}")
check("库里没被改", sql(f"SELECT occupation FROM collect_submission WHERE id={sid}") == "车工",
sql(f"SELECT occupation FROM collect_submission WHERE id={sid}"))
r = call("PUT", f"{base}/submissions/{sid}", tok, {"formData": {"job_level": "三级/高级工"}})
check("改「报考等级」也被拒", r.get("code") == 40001, f"code={r.get('code')}")
r = call("PUT", f"{base}/submissions/{sid}", tok, {"formData": {"occupation": "车工"}})
check("把锁定字段改成原值不算改(不报错)", r.get("code") == 0, f"code={r.get('code')}")
# ── 4. 未知字段要被拒绝 ────────────────────────────
print("\n[4] 未知字段")
r = call("PUT", f"{base}/submissions/{sid}", tok, {"formData": {"not_a_field": "x"}})
check("传不认识的字段被拒(不静默忽略)", r.get("code") == 40001,
f"code={r.get('code')} msg={r.get('msg')}")
# ── 5. 改预填字段 → 留痕 ───────────────────────────
print("\n[5] 改动留痕")
r = call("PUT", f"{base}/submissions/{sid}", tok, {"formData": {"education": "大学专科"}})
check("改预填的「学历」成功", r.get("code") == 0, f"code={r.get('code')}")
cf = sql(f"SELECT changed_fields FROM collect_submission WHERE id={sid}")
check("changed_fields 记录了 education", "education" in cf, f"changed_fields={cf}")
check("没改过的字段不在里面", "occupation" not in cf and "school" not in cf,
f"changed_fields={cf}")
# ── 6. 清空预填字段(updateById 会静默跳过 null)────
print("\n[6] 清空预填字段")
r = call("PUT", f"{base}/submissions/{sid}", tok, {"formData": {"education": ""}})
check("清空成功", r.get("code") == 0, f"code={r.get('code')}")
v = sql(f"SELECT coalesce(education,'<NULL>') FROM collect_submission WHERE id={sid}")
check("库里真的变成 NULL(不是静默没生效)", v == "<NULL>", f"education={v}")
r = call("GET", f"{base}/tasks/{task_id}/form", tok)
fd = fields_of(r.get("data") or {})
check("清空后回显是空,**不会被名册值顶回来**", fd["education"].get("value") in (None, ""),
f"{fd['education'].get('value')}")
check("没动过的字段仍用名册值", fd["work_unit"].get("prefilled") in (True, False)
and fd["occupation"].get("value") == "车工", f"{fd['occupation'].get('value')}")
# ── 7. 清理 ───────────────────────────────────────
print("\n[7] 清理")
sql(f"DELETE FROM collect_submission WHERE id IN ({','.join(str(x) for x in CREATED)})")
sql(f"UPDATE collect_roster SET bind_user_id=NULL, bind_time=NULL, bind_type=NULL WHERE id={h}")
ok(f"已清理 {len(CREATED)} 份草稿并解绑名册")
print("\n" + "=" * 72)
print(f"通过 {len(PASSED)} 失败 {len(FAILED)} 共 {len(PASSED) + len(FAILED)}")
for n, d in FAILED:
print(f" · {n}\n {d}")
print("=" * 72)
return 1 if FAILED else 0
if __name__ == "__main__":
raise SystemExit(main())
+18 -2
View File
@@ -23,6 +23,7 @@ from __future__ import annotations
import argparse import argparse
import json import json
import os import os
import re
import subprocess import subprocess
import sys import sys
import urllib.error import urllib.error
@@ -141,13 +142,28 @@ def main() -> int:
check("返回 302 重定向", code == 302, f"HTTP {code} headers={list(headers.keys())}") check("返回 302 重定向", code == 302, f"HTTP {code} headers={list(headers.keys())}")
loc = headers.get("Location", "") loc = headers.get("Location", "")
print(f" Location: {loc[:120]}…") 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", check("**token 在 fragment(#)里,不在 query 里** —— query 会进 Nginx 日志/Referer",
"#" in loc and "token=" in loc.split("#", 1)[1], "#" in loc and "token=" in loc.split("#", 1)[1],
f"Location={loc[:140]}") f"Location={loc[:140]}")
check("query 部分没有 token", "token=" not in loc.split("#", 1)[0], 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 "" 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", "")) token = urllib.parse.unquote(params.get("token", ""))
check("fragment 里带回了 scene(授权回来还要继续用)", check("fragment 里带回了 scene(授权回来还要继续用)",
urllib.parse.unquote(params.get("scene", "")) == scene, frag[:100]) urllib.parse.unquote(params.get("scene", "")) == scene, frag[:100])