feat(fill): 表单结构接口(学生端填报的起点)

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 个字段分两组返回,三态与脱敏均正确。
This commit is contained in:
2026-09-29 11:28:53 +08:00
parent aa2f7f175c
commit eff5a761bf
4 changed files with 347 additions and 0 deletions
@@ -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)。
*
* <p>要求已登录(学生),且<b>必须已过门禁</b> —— 见 {@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<StudentFormVO> form(@PathVariable Long id) {
return ApiResult.ok(fillService.getForm(id));
}
}
@@ -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)。
*
* <p><b>字段三态</b>是这一屏的核心(§9.2 的表):
*
* <table border="1">
* <tr><th>{@code locked}</th><th>{@code prefilled}</th><th>学生端表现</th></tr>
* <tr><td>true</td><td>—</td><td>只读、灰底、右侧提示"如有误请联系老师"</td></tr>
* <tr><td>false</td><td>true</td><td>可编辑,标注「<b>老师已填,请核对</b>」,改后留痕</td></tr>
* <tr><td>false</td><td>false</td><td>可编辑,正常样式</td></tr>
* </table>
*
* <p>⚠️ <b>为什么 locked 必须是真的只读、而不是"前端灰一下"</b>:
* 「报考职业/工种/等级」由老师按机构的报考策略确定,学生不懂也不该改。
* 让它看起来可编辑(哪怕服务端会拒绝),只会让人白改一次 ——
* 而<b>报错工种在机构那边是直接不受理</b>,等发现时报名已经结束。
* 所以前端渲染成只读,服务端<b>再拒绝一次</b>(两层)。
*/
@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<Section> sections = new ArrayList<>();
/** 材料项(结构见 §3.4)。材料上传属下一段,这里先给出清单。 */
private List<Object> materials = new ArrayList<>();
private SubmitPolicy submitPolicy;
/** 一组字段。 */
@Getter
@Setter
@Schema(description = "表单分组")
public static class Section {
private String title;
private List<FormFieldVO> fields = new ArrayList<>();
}
/**
* 提交策略。
*
* <p>{@code blockOnRed} 恒为 true:存在红色问题(如证件号与名册不一致)时<b>不允许提交</b>。
* 这不是可配置项 —— 红黄绿三级里,红的定义就是"不能继续"。
*/
@Getter
@Setter
@Schema(description = "提交策略")
public static class SubmitPolicy {
private Boolean blockOnRed = Boolean.TRUE;
private Boolean allowForceOnYellow = Boolean.TRUE;
private String deadlineTip;
}
/** 供服务层拼装用的辅助。 */
public static Map<String, Object> newMap() {
return new LinkedHashMap<>();
}
}
@@ -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)。
*
* <p><b>两道前置门槛</b>,缺一不可:
* <ol>
* <li><b>任务在收集中</b>(草稿/暂停/截止/归档都不给填)—— 与落地页的判断一致;</li>
* <li><b>这个人已经过了门禁</b>(有绑定关系)。
* 没过门禁就直接拿表单 = "没核对身份也能填",那门禁就白设了。</li>
* </ol>
*
* <p><b>字段值的三个来源</b>(优先级从高到低):
* <ol>
* <li>学生自己存过的草稿(本轮未做,见 {@code TODO});</li>
* <li>名册里老师已填的值 → 标 {@code prefilled},学生看到「老师已填,请核对」;</li>
* <li>空。</li>
* </ol>
*
* <p>⚠️ <b>身份证号只给脱敏值</b>({@code idCardMask})。表单里回显完整证件号是
* 完全没有必要的 —— 学生自己知道自己的号码,而页面上多一处明文就多一处泄露面
* (截图、录屏、分享)。这与 §3.1 的硬规则一致。
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class FillService {
/** 分组一:身份与联系。 */
private static final Set<String> 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<String, Object> 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<FormFieldVO> 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<MaterialDefVO> 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}。
*
* <p>键名与表单字段键、与 {@code collect_submission} 的列名三者一致 ——
* 这是「内置字段可统计」的实现基础,任何一处不一致都会让统计口径出现缺口。
*/
private static Map<String, Object> rosterValues(CollectRoster r) {
Map<String, Object> 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<String, Object> 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<String, Object> map, String key, Object value) {
if (value == null) {
return;
}
if (value instanceof String s && s.isBlank()) {
return;
}
map.put(key, value);
}
}
@@ -76,4 +76,21 @@ public class FormFieldVO {
* 见类注释:契约没写,但不写就得靠硬编码名单猜。 * 见类注释:契约没写,但不写就得靠硬编码名单猜。
*/ */
private String source; private String source;
/**
* 当前值。<b>只在学生端(§9.2)装配,设计器里恒为 null</b>。
*
* <p>放在同一个类里而不是另建一个 VO:契约 §9.2 的字段对象就是"表单字段 + 值",
* 两者结构完全一致,拆成两个类只会让前端要写两套解析。
*/
private Object value;
/**
* 这个值是不是名册里老师已经填好的。
*
* <p>与 {@link #locked} <b>互相独立</b>:
* {@code prefilled=true, locked=false} → 显示「老师已填,请核对」但可改(如联系电话);
* {@code prefilled=true, locked=true} → 只读灰底,提示"如有误请联系老师"。
*/
private Boolean prefilled;
} }