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:
+34
@@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
+84
@@ -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<>();
|
||||||
|
}
|
||||||
|
}
|
||||||
+212
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user