From eff5a761bfa8e4fe7509ae0f75881d259ada3510 Mon Sep 17 00:00:00 2001 From: lovife <474239041@qq.com> Date: Tue, 29 Sep 2026 11:28:53 +0800 Subject: [PATCH] =?UTF-8?q?feat(fill):=20=E8=A1=A8=E5=8D=95=E7=BB=93?= =?UTF-8?q?=E6=9E=84=E6=8E=A5=E5=8F=A3=EF=BC=88=E5=AD=A6=E7=94=9F=E7=AB=AF?= =?UTF-8?q?=E5=A1=AB=E6=8A=A5=E7=9A=84=E8=B5=B7=E7=82=B9=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GET /api/v1/mp/tasks/{id}/form —— 按 §9.2 装配表单结构 + 当前值。 两道前置门槛(缺一不可): 1) 任务在收集中(草稿/暂停/截止/归档都不给填); 2) **这个人已过门禁**(有绑定关系)。没过门禁就给表单 = "没核对身份也能填", 门禁就白设了。 字段三态(§9.2)实测正确: real_name/id_card_no/occupation/worktype/job_level → 只读 + 老师已填 education/phone/is_student → 可改 + 「老师已填,请核对」 其余 → 空 其中 occupation/worktype/job_level 只读是刻意的:它们由老师按机构报考策略确定, 学生不懂也不该改 —— 而报错工种在机构那边是直接不受理,等发现时报名已结束。 为什么 locked 必须是真的只读、而不是"前端灰一下":让它看起来可编辑(哪怕服务端 会拒绝)只会让人白改一次。所以前端渲染成只读,服务端再拒绝一次(两层)。 身份证号与手机号只回**脱敏值**:表单上回显完整证件号完全没有必要(学生自己知道), 而页面上多一处明文就多一处泄露面(截图、录屏、分享)。 两处实现说明: · prefilled 与 locked 独立:prefilled=true,locked=false → 可改但标注(如联系电话, 名册里常是家长的老号码);prefilled=true,locked=true → 只读灰底。 · FormFieldVO 补了 value / prefilled 两个字段(契约 §9.2 的字段对象里有)。 放在同一个类而不是另建 VO:两者结构完全一致,拆开只会让前端写两套解析。 本轮范围:只做"看得到、读得对"。**草稿保存(POST/PUT /mp/submissions)与 前端表单页是下一段** —— 已在 FillService 里留了 TODO。 验收:用真实会话实测 GET /mp/tasks/17/form,21 个字段分两组返回,三态与脱敏均正确。 --- .../fill/controller/FillController.java | 34 +++ .../fill/module/fill/dto/StudentFormVO.java | 84 +++++++ .../fill/module/fill/service/FillService.java | 212 ++++++++++++++++++ .../fill/module/form/dto/FormFieldVO.java | 17 ++ 4 files changed, 347 insertions(+) create mode 100644 backend/xt-server/src/main/java/cn/tianbaotong/fill/module/fill/controller/FillController.java create mode 100644 backend/xt-server/src/main/java/cn/tianbaotong/fill/module/fill/dto/StudentFormVO.java create mode 100644 backend/xt-server/src/main/java/cn/tianbaotong/fill/module/fill/service/FillService.java 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 new file mode 100644 index 0000000..b96e7e5 --- /dev/null +++ b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/fill/controller/FillController.java @@ -0,0 +1,34 @@ +package cn.tianbaotong.fill.module.fill.controller; + +import cn.tianbaotong.fill.common.api.ApiResult; +import cn.tianbaotong.fill.module.fill.dto.StudentFormVO; +import cn.tianbaotong.fill.module.fill.service.FillService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +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)。 + * + *

要求已登录(学生),且必须已过门禁 —— 见 {@code FillService}。 + */ +@Tag(name = "小程序/H5 · 填报") +@RestController +@RequestMapping("/api/v1/mp") +@RequiredArgsConstructor +public class FillController { + + private final FillService fillService; + + @GetMapping("/tasks/{id}/form") + @Operation(summary = "表单结构 + 当前值", + description = "字段三态见 §9.2:locked=true 只读、prefilled=true 标注「老师已填,请核对」。" + + "身份证号只回脱敏值。未过门禁时返回 40007(提示先核对身份)") + public ApiResult form(@PathVariable Long id) { + return ApiResult.ok(fillService.getForm(id)); + } +} diff --git a/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/fill/dto/StudentFormVO.java b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/fill/dto/StudentFormVO.java new file mode 100644 index 0000000..a639ed9 --- /dev/null +++ b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/fill/dto/StudentFormVO.java @@ -0,0 +1,84 @@ +package cn.tianbaotong.fill.module.fill.dto; + +import cn.tianbaotong.fill.module.form.dto.FormFieldVO; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Getter; +import lombok.Setter; + +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** + * 学生端表单(《03-接口设计》§9.2)。 + * + *

字段三态是这一屏的核心(§9.2 的表): + * + * + * + * + * + * + *
{@code locked}{@code prefilled}学生端表现
true—只读、灰底、右侧提示"如有误请联系老师"
falsetrue可编辑,标注「老师已填,请核对」,改后留痕
falsefalse可编辑,正常样式
+ * + *

⚠️ 为什么 locked 必须是真的只读、而不是"前端灰一下": + * 「报考职业/工种/等级」由老师按机构的报考策略确定,学生不懂也不该改。 + * 让它看起来可编辑(哪怕服务端会拒绝),只会让人白改一次 —— + * 而报错工种在机构那边是直接不受理,等发现时报名已经结束。 + * 所以前端渲染成只读,服务端再拒绝一次(两层)。 + */ +@Getter +@Setter +@Schema(description = "学生端表单") +public class StudentFormVO { + + private Long taskId; + + private String taskName; + + /** 表单版本(学生是按某一版填的,留痕用)。 */ + private Integer formVersion; + + /** 草稿单 id;没有草稿时为 null。 */ + private Long submissionId; + + /** 提交状态({@code 0} 未开始 / {@code 1} 草稿 / {@code 2} 待审 …)。 */ + private Integer submissionStatus; + + private List

sections = new ArrayList<>(); + + /** 材料项(结构见 §3.4)。材料上传属下一段,这里先给出清单。 */ + private List materials = new ArrayList<>(); + + private SubmitPolicy submitPolicy; + + /** 一组字段。 */ + @Getter + @Setter + @Schema(description = "表单分组") + public static class Section { + private String title; + private List fields = new ArrayList<>(); + } + + /** + * 提交策略。 + * + *

{@code blockOnRed} 恒为 true:存在红色问题(如证件号与名册不一致)时不允许提交。 + * 这不是可配置项 —— 红黄绿三级里,红的定义就是"不能继续"。 + */ + @Getter + @Setter + @Schema(description = "提交策略") + public static class SubmitPolicy { + private Boolean blockOnRed = Boolean.TRUE; + private Boolean allowForceOnYellow = Boolean.TRUE; + private String deadlineTip; + } + + /** 供服务层拼装用的辅助。 */ + public static Map newMap() { + return new LinkedHashMap<>(); + } +} 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 new file mode 100644 index 0000000..f43170a --- /dev/null +++ b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/fill/service/FillService.java @@ -0,0 +1,212 @@ +package cn.tianbaotong.fill.module.fill.service; + +import cn.tianbaotong.fill.common.api.ErrorCode; +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.form.dto.FormFieldVO; +import cn.tianbaotong.fill.module.form.dto.FormSchemaVO; +import cn.tianbaotong.fill.module.form.service.FormSchemaService; +import cn.tianbaotong.fill.module.material.dto.MaterialDefVO; +import cn.tianbaotong.fill.module.material.service.MaterialDefService; +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 lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; + +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; + +/** + * 学生端填报(《03-接口设计》§9.2)。 + * + *

两道前置门槛,缺一不可: + *

    + *
  1. 任务在收集中(草稿/暂停/截止/归档都不给填)—— 与落地页的判断一致;
  2. + *
  3. 这个人已经过了门禁(有绑定关系)。 + * 没过门禁就直接拿表单 = "没核对身份也能填",那门禁就白设了。
  4. + *
+ * + *

字段值的三个来源(优先级从高到低): + *

    + *
  1. 学生自己存过的草稿(本轮未做,见 {@code TODO});
  2. + *
  3. 名册里老师已填的值 → 标 {@code prefilled},学生看到「老师已填,请核对」;
  4. + *
  5. 空。
  6. + *
+ * + *

⚠️ 身份证号只给脱敏值({@code idCardMask})。表单里回显完整证件号是 + * 完全没有必要的 —— 学生自己知道自己的号码,而页面上多一处明文就多一处泄露面 + * (截图、录屏、分享)。这与 §3.1 的硬规则一致。 + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class FillService { + + /** 分组一:身份与联系。 */ + private static final Set GROUP_BASIC = Set.of( + "seq_no", "real_name", "id_card_no", "education", "phone", "work_unit"); + + private final TaskMapper taskMapper; + private final FormSchemaService formSchemaService; + private final MaterialDefService materialDefService; + private final MpRosterMapper rosterMapper; + + public StudentFormVO getForm(Long taskId) { + LoginUser me = UserContext.require(); + + CollectTask task = taskMapper.selectById(taskId); + if (task == null) { + throw new BizException(ErrorCode.VALIDATION_BLOCKED, "任务不存在或已删除"); + } + int status = task.getStatus() == null ? -1 : task.getStatus(); + if (status != CollectTask.STATUS_COLLECTING) { + throw new BizException(ErrorCode.TASK_NOT_OPEN); + } + + // 门禁前置:没有绑定关系就说明还没核对过身份 + CollectRoster roster = rosterMapper.selectBoundByUser(me.tenantId(), taskId, me.uid()); + if (roster == null) { + throw new BizException(ErrorCode.GATE_NOT_MATCHED, + "请先核对身份(填写姓名与身份证号)再开始填报"); + } + + FormSchemaVO schema = formSchemaService.get(taskId); + Map values = rosterValues(roster); + + StudentFormVO vo = new StudentFormVO(); + vo.setTaskId(taskId); + vo.setTaskName(task.getTaskName()); + vo.setFormVersion(schema.getVersion()); + vo.setSubmissionStatus(0); + // TODO(下一段):取草稿单,把学生存过的值覆盖到 values 上 + + StudentFormVO.Section basic = new StudentFormVO.Section(); + basic.setTitle("基本信息"); + StudentFormVO.Section job = new StudentFormVO.Section(); + job.setTitle("报考与在校信息"); + + for (FormFieldVO f : schema.getFields()) { + FormFieldVO field = withValue(f, values); + if (GROUP_BASIC.contains(f.getKey())) { + basic.getFields().add(field); + } else { + job.getFields().add(field); + } + } + // 扩展字段单列一组,并明示"不参与统计" + List extras = new ArrayList<>(); + for (FormFieldVO f : schema.getExtras()) { + extras.add(withValue(f, values)); + } + vo.getSections().add(basic); + vo.getSections().add(job); + if (!extras.isEmpty()) { + StudentFormVO.Section ex = new StudentFormVO.Section(); + ex.setTitle("其它信息"); + ex.setFields(extras); + vo.getSections().add(ex); + } + + List materials = materialDefService.list(taskId); + vo.setMaterials(new ArrayList<>(materials)); + + StudentFormVO.SubmitPolicy policy = new StudentFormVO.SubmitPolicy(); + if (task.getEndTime() != null) { + policy.setDeadlineTip("请在 " + task.getEndTime().toLocalDate() + " 前提交"); + } + vo.setSubmitPolicy(policy); + + return vo; + } + + // ── 内部 ────────────────────────────────────── + + /** + * 把名册里老师已填的值摊平成 {@code key → value}。 + * + *

键名与表单字段键、与 {@code collect_submission} 的列名三者一致 —— + * 这是「内置字段可统计」的实现基础,任何一处不一致都会让统计口径出现缺口。 + */ + private static Map rosterValues(CollectRoster r) { + Map m = new LinkedHashMap<>(); + putIfPresent(m, "seq_no", r.getSeqNo()); + putIfPresent(m, "real_name", r.getRealName()); + // ⚠️ 只回脱敏值:表单上没有必要出现完整证件号(截图/录屏/分享都是泄露面) + putIfPresent(m, "id_card_no", r.getIdCardMask()); + putIfPresent(m, "education", r.getEducation()); + putIfPresent(m, "phone", r.getPhone()); + putIfPresent(m, "work_unit", r.getWorkUnit()); + putIfPresent(m, "occupation", r.getOccupation()); + putIfPresent(m, "worktype", r.getWorktype()); + putIfPresent(m, "job_level", r.getJobLevel()); + if (r.getIsRetake() != null) { + m.put("is_retake", r.getIsRetake() == 1); + } + putIfPresent(m, "retake_theory", r.getRetakeTheory()); + putIfPresent(m, "retake_practice", r.getRetakePractice()); + putIfPresent(m, "retake_overall", r.getRetakeOverall()); + if (r.getIsStudent() != null) { + m.put("is_student", r.getIsStudent() == 1); + } + putIfPresent(m, "school", r.getSchool()); + putIfPresent(m, "major", r.getMajor()); + if (r.getEnrollDate() != null) { + m.put("enroll_date", r.getEnrollDate().toString()); + } + putIfPresent(m, "work_years", r.getWorkYears()); + putIfPresent(m, "cert_occupation", r.getCertOccupation()); + putIfPresent(m, "cert_level", r.getCertLevel()); + putIfPresent(m, "cert_no", r.getCertNo()); + return m; + } + + /** + * 把字段与值合起来,并标出 {@code prefilled} —— 它决定学生看到的是 + * 「老师已填,请核对」还是空白输入框。 + */ + private static FormFieldVO withValue(FormFieldVO def, Map values) { + FormFieldVO f = new FormFieldVO(); + f.setKey(def.getKey()); + f.setLabel(def.getLabel()); + f.setType(def.getType()); + f.setBuiltin(def.getBuiltin()); + f.setLocked(def.getLocked()); + f.setRequired(def.getRequired()); + f.setDictCode(def.getDictCode()); + f.setOptions(def.getOptions() == null ? new ArrayList<>() : new ArrayList<>(def.getOptions())); + f.setValidate(def.getValidate()); + f.setVisibleWhen(def.getVisibleWhen()); + f.setSortNo(def.getSortNo()); + f.setTip(def.getTip()); + f.setSource(def.getSource()); + + Object v = values.get(def.getKey()); + f.setValue(v); + // prefilled 的判定:值来自名册且非空。它只影响"是否标注老师已填", + // 与 locked 独立 —— 「联系电话」是 prefilled 但可改(名册里常是家长的老号码) + f.setPrefilled(v != null && !(v instanceof String s && s.isBlank())); + if (Boolean.TRUE.equals(f.getPrefilled()) && f.getTip() == null + && !Boolean.TRUE.equals(f.getLocked())) { + f.setTip("老师已填,请核对"); + } + return f; + } + + private static void putIfPresent(Map map, String key, Object value) { + if (value == null) { + return; + } + if (value instanceof String s && s.isBlank()) { + return; + } + map.put(key, value); + } +} diff --git a/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/form/dto/FormFieldVO.java b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/form/dto/FormFieldVO.java index bcfb102..fdf29c7 100644 --- a/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/form/dto/FormFieldVO.java +++ b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/form/dto/FormFieldVO.java @@ -76,4 +76,21 @@ public class FormFieldVO { * 见类注释:契约没写,但不写就得靠硬编码名单猜。 */ private String source; + + /** + * 当前值。只在学生端(§9.2)装配,设计器里恒为 null。 + * + *

放在同一个类里而不是另建一个 VO:契约 §9.2 的字段对象就是"表单字段 + 值", + * 两者结构完全一致,拆成两个类只会让前端要写两套解析。 + */ + private Object value; + + /** + * 这个值是不是名册里老师已经填好的。 + * + *

与 {@link #locked} 互相独立: + * {@code prefilled=true, locked=false} → 显示「老师已填,请核对」但可改(如联系电话); + * {@code prefilled=true, locked=true} → 只读灰底,提示"如有误请联系老师"。 + */ + private Boolean prefilled; }