diff --git a/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/fill/controller/FillController.java b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/fill/controller/FillController.java index b96e7e5..a48d3bf 100644 --- a/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/fill/controller/FillController.java +++ b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/fill/controller/FillController.java @@ -10,7 +10,6 @@ import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; - /** * 学生端填报(《03-接口设计》§9.x)。 * @@ -23,6 +22,7 @@ import org.springframework.web.bind.annotation.RestController; public class FillController { private final FillService fillService; + private final cn.tianbaotong.fill.module.fill.service.SubmissionService submissionService; @GetMapping("/tasks/{id}/form") @Operation(summary = "表单结构 + 当前值", @@ -31,4 +31,28 @@ public class FillController { public ApiResult form(@PathVariable Long id) { return ApiResult.ok(fillService.getForm(id)); } + + @org.springframework.web.bind.annotation.PostMapping("/submissions") + @Operation(summary = "取草稿(幂等)", + description = "存在则返回既有草稿、不存在则创建。学生反复点「开始填写」不该产生多份") + public ApiResult createDraft( + @org.springframework.web.bind.annotation.RequestBody java.util.Map 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 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(); + } } diff --git a/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/fill/dto/DraftDtos.java b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/fill/dto/DraftDtos.java new file mode 100644 index 0000000..ef9df83 --- /dev/null +++ b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/fill/dto/DraftDtos.java @@ -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 { + /** + * 表单值,只传变化的字段,服务端做合并。 + * + *

键必须是表单定义里存在的字段 —— 传了不认识的键会拒绝而不是静默忽略: + * 静默忽略会让"我明明填了却没保存上"这种问题极难定位。 + */ + private Map formData; + + /** 扩展字段的值(只接受表单定义里声明过的扩展键)。 */ + private Map extrasJson; + + /** 客户端时间,用于冲突提示(本轮未使用,保留契约字段)。 */ + private String clientTime; + } +} diff --git a/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/fill/entity/CollectSubmission.java b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/fill/entity/CollectSubmission.java new file mode 100644 index 0000000..d6b6459 --- /dev/null +++ b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/fill/entity/CollectSubmission.java @@ -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})—— 一个学生在一个任务下的那一份材料。 + * + *

身份由名册决定,不由本人声明:{@code rosterId} 指向门禁核验时绑定上的 + * 名册行,姓名与证件号都是从那里来的。学生填的只是"这个人的其余信息"。 + * 所以这张表里没有 {@code real_name}/{@code id_card_no} —— 那两项始终以名册为准, + * 存两份就会有两份不一致的可能。 + * + *

⚠️ 内置字段落列、扩展字段落 {@code extras_json},这是架构约束不是取舍: + * 只有落列的字段才可查询、可索引、可统计。界面上一直明示"扩展字段不参与核心统计", + * 否则校长会发现报表里少了一列,而那时已经填了几百份。 + */ +@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; + + /** + * 学生改过的预填字段,逗号分隔。 + * + *

「老师已填、学生改过」这件事必须留痕 —— 否则审核时看到的是一个数字, + * 无法判断它是老师填的还是学生改的。而这两种情况的处理完全不同: + * 老师填错要找老师,学生改错直接驳回就行。 + */ + 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; +} diff --git a/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/fill/mapper/SubmissionMapper.java b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/fill/mapper/SubmissionMapper.java new file mode 100644 index 0000000..1c86226 --- /dev/null +++ b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/fill/mapper/SubmissionMapper.java @@ -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 { + + /** + * 取某人在某任务下的填报单(一个学生在一个任务下只有一份)。 + * + *

用显式 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 取,且必须属于我。 + * + *

不要写成"先按 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); +} diff --git a/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/fill/service/FillService.java b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/fill/service/FillService.java index f43170a..71080dd 100644 --- a/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/fill/service/FillService.java +++ b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/fill/service/FillService.java @@ -5,6 +5,8 @@ import cn.tianbaotong.fill.common.context.LoginUser; import cn.tianbaotong.fill.common.context.UserContext; import cn.tianbaotong.fill.common.exception.BizException; 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.FormSchemaVO; import cn.tianbaotong.fill.module.form.service.FormSchemaService; @@ -20,6 +22,7 @@ import org.springframework.stereotype.Service; import java.util.ArrayList; import java.util.LinkedHashMap; +import java.util.LinkedHashSet; import java.util.List; import java.util.Map; import java.util.Set; @@ -58,6 +61,7 @@ public class FillService { private final FormSchemaService formSchemaService; private final MaterialDefService materialDefService; private final MpRosterMapper rosterMapper; + private final SubmissionMapper submissionMapper; public StudentFormVO getForm(Long taskId) { LoginUser me = UserContext.require(); @@ -79,14 +83,59 @@ public class FillService { } FormSchemaVO schema = formSchemaService.get(taskId); - Map values = rosterValues(roster); + Map values = rosterValuesOf(roster); StudentFormVO vo = new StudentFormVO(); vo.setTaskId(taskId); vo.setTaskName(task.getTaskName()); vo.setFormVersion(schema.getVersion()); - vo.setSubmissionStatus(0); - // TODO(下一段):取草稿单,把学生存过的值覆盖到 values 上 + vo.setSubmissionStatus(CollectSubmission.STATUS_DRAFT); + + // 学生自己存过的草稿覆盖名册值。 + // ⚠️ 关键在**怎么判断"这个字段学生动过"**:草稿列是 null 时无法区分 + // "从没填过"和"填了又清空"。所以用 changed_fields —— 它记录的正是 + // "学生动过的预填字段"。在里面的字段以草稿为准(哪怕是空), + // 不在里面的字段以名册为准。这也是 changed_fields 的第二个用途。 + CollectSubmission draft = submissionMapper.selectMine(me.tenantId(), taskId, me.uid()); + Set 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 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(); basic.setTitle("基本信息"); @@ -134,8 +183,12 @@ public class FillService { * *

键名与表单字段键、与 {@code collect_submission} 的列名三者一致 —— * 这是「内置字段可统计」的实现基础,任何一处不一致都会让统计口径出现缺口。 + * + *

public 是给 {@code SubmissionService} 复用的:保存草稿时要用同一份 + * 映射去判断"锁定字段有没有被改"以及"哪些预填字段被改过"。 + * 两处各写一份的话,改了名册映射就会漏掉一处,而漏掉的表现是留痕不准。 */ - private static Map rosterValues(CollectRoster r) { + public static Map rosterValuesOf(CollectRoster r) { Map m = new LinkedHashMap<>(); putIfPresent(m, "seq_no", r.getSeqNo()); putIfPresent(m, "real_name", r.getRealName()); diff --git a/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/fill/service/SubmissionService.java b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/fill/service/SubmissionService.java new file mode 100644 index 0000000..a6f1e39 --- /dev/null +++ b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/fill/service/SubmissionService.java @@ -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)。 + * + *

三处不能省的判断: + *

    + *
  1. 填报单必须属于调用者 —— 这个条件写在 SQL 的 WHERE 里 + * (见 {@code SubmissionMapper.selectMineById}),不是"查出来再判断"。 + * 后者一旦判断漏写就是越权。
  2. + *
  3. 锁定字段服务端再拒一次。前端已经渲染成只读了,但服务端不能因此放行: + * 请求是可以伪造的,而「报考工种」被改错的后果是机构直接不受理。 + * 两层防护里,只有服务端这层是真的。
  4. + *
  5. 改动要留痕({@code changed_fields}):老师填的值被学生改过, + * 审核时看到的是一个数字,无法判断该找谁 —— 老师填错要找老师, + * 学生改错直接驳回就行。
  6. + *
+ */ +@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 defs = new LinkedHashMap<>(); + for (FormFieldVO f : schema.getFields()) { + defs.put(f.getKey(), f); + } + Map rv = FillService.rosterValuesOf(roster); + for (Map.Entry 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 defs = new LinkedHashMap<>(); + for (FormFieldVO f : schema.getFields()) { + defs.put(f.getKey(), f); + } + for (FormFieldVO f : schema.getExtras()) { + defs.put(f.getKey(), f); + } + Map rosterVals = FillService.rosterValuesOf(roster); + + Map incoming = req.getFormData() == null ? Map.of() : req.getFormData(); + for (Map.Entry 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 extras = new LinkedHashMap<>(); + for (Map.Entry 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 changed = new LinkedHashSet<>(); + for (Map.Entry 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.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}、空串、纯空白视作同一个"没填"。 + * + *

不这么做的话,学生把输入框里老师填的内容删空再原样打一遍(或者前后多一个空格) + * 就会被判成"改过" — 留痕会充满噪音,而留痕的价值就在于让人一眼看到真正的改动。 + */ + 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 读/写"。 + * + *

抽出来是因为读写各要一遍 switch,散在两个方法里迟早漏字段 —— + * 而漏掉的表现是"某一项填了没保存",且不报错。 + */ + static final class SubValues { + + /** + * 这个字段在 {@code collect_submission} 里有没有对应的可写列。 + * + *

⚠️ 姓名、身份证号、联系电话只存在于名册,提交表里没有这三列 —— + * 所以它们既不该被比较(会造成假的"改动留痕"),也不该被写入。 + * 判断"列存在"而不是"我实现了读写的分支",是为了让这个事实只有一个来源: + * 加字段时先看表有没有这一列,而不是看代码里有没有 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"); + } + } + } +} diff --git a/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/form/service/BuiltinFields.java b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/form/service/BuiltinFields.java index 32d2655..43333d7 100644 --- a/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/form/service/BuiltinFields.java +++ b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/form/service/BuiltinFields.java @@ -83,9 +83,14 @@ public final class BuiltinFields { list.add(submission("education", "学历", "select", true, false, ++sort * 10, opts(EDU_LEVEL), null, null)); - // E 联系电话:来自名册(预填),但学生可以改 —— 名册里的号码常是家长的老号码 - final FormFieldVO phone = roster("phone", "联系电话", "phone", true, false, ++sort * 10); - phone.setType("phone"); + // 联系电话:来自名册(预填),但**提交表没有 phone 列** —— 学生改了没地方存。 + // ⚠️ 这是字段目录与 DDL 的一处真不一致(PRD 把"联系电话"列在「预填可改」里)。 + // 一期先设为**只读**,与"如号码有误请联系班主任"一致; + // 要真正开放修改,需要给 collect_submission 加一列 phone + // (或把这类"无列可存的预填项"统一落到 form_data)。 + // 先记下来,不要用"前端允许改、后端悄悄丢掉"来糊过去 —— + // 那会让学生以为改成功了,而实际提交的还是老号码。 + final FormFieldVO phone = roster("phone", "联系电话", "phone", true, true, ++sort * 10); list.add(phone); // F 工作单位:官方原文「没有则填"无"」。 diff --git a/scripts/e2e-m3-draft.py b/scripts/e2e-m3-draft.py new file mode 100644 index 0000000..b5c9352 --- /dev/null +++ b/scripts/e2e-m3-draft.py @@ -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,'') FROM collect_submission WHERE id={sid}") + check("库里真的变成 NULL(不是静默没生效)", v == "", 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()) diff --git a/scripts/e2e-m3-h5-auth.py b/scripts/e2e-m3-h5-auth.py index 67e9478..fb7129a 100644 --- a/scripts/e2e-m3-h5-auth.py +++ b/scripts/e2e-m3-h5-auth.py @@ -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])