diff --git a/src/main/java/com/yunhai/formcenter/controller/api/FormMyAnswerController.java b/src/main/java/com/yunhai/formcenter/controller/api/FormMyAnswerController.java
new file mode 100644
index 0000000..dc3bc0a
--- /dev/null
+++ b/src/main/java/com/yunhai/formcenter/controller/api/FormMyAnswerController.java
@@ -0,0 +1,118 @@
+package com.yunhai.formcenter.controller.api;
+
+import com.yunhai.formcenter.model.MyModels;
+import com.yunhai.formcenter.model.ReadModels;
+import com.yunhai.formcenter.service.AnswerReadService;
+import com.yunhai.platform.apistd.BizException;
+import com.yunhai.platform.utils.exception.ErrorCode;
+import com.yunhai.platform.utils.model.BoaUser;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RequestParam;
+import org.springframework.web.bind.annotation.RestController;
+
+import java.util.List;
+
+/**
+ * 填报面的"我的"数据读改端点:T7 的填报页内嵌"我的提交"段(按单表单)+
+ * T8 的全局两视图 {@code /my/submit}(我提交的)与 {@code /my/about}(关于我的,V18)。
+ *
+ *
纪律:
+ *
+ * - 身份一律会话推导:不接受外部传 submitterId/schId(V17);
+ * - 数据面身份只用 uuid:answerId 是答卷 uuid,库内 id 不出现在任何外层(契约 §1/V5);
+ * - 权限闸门:普通用户只能看/改/删自己提交的答卷(submitter_id=会话用户),
+ * "关于我的"只读(我是被代填人时可看不可改——改是填表人或管理员的动作);
+ * 有管理权者另有 /api/form/admin/data/**(T4 已落地,scope 矩阵生效),两套口径不混用;
+ * - 本控制器没有状态端点:填表人改状态在接口层即不存在(V14 的实现形态)。
+ *
+ */
+@Slf4j
+@RestController
+@RequestMapping("/api/form/my")
+@RequiredArgsConstructor
+public class FormMyAnswerController {
+
+ private final AnswerReadService readService;
+
+ /** 我的答卷列表(按 submitter 过滤,"我的提交"段) */
+ @GetMapping("/list")
+ public List list(BoaUser user,
+ @RequestParam("formCode") String formCode,
+ @RequestParam(value = "appCode", required = false) String appCode) {
+ requireSession(user);
+ return readService.listBySubmitter(user, formCode, appCode);
+ }
+
+ /**
+ * "我提交的"视图({@code /my/submit},T8):跨表单、按 {@code submitter_id}=会话用户。
+ *
+ * 与 {@code /my/about} 是两个端点、两个谓词,不是一个端点加 view 参数
+ * (方案 §6.3 原文:"做成两个视图不做一个接口加参数(鉴权分支容易写漏)")。
+ * 这里不看模块行、不看契约 §8 范围矩阵——数据管理面那套范围管的是"管理员能看谁的行",
+ * 本面管的是"我自己的行",两套口径混用会让填表人不授权模块行就看不见自己的数据。
+ */
+ @GetMapping("/submit-list")
+ public List submitList(BoaUser user) {
+ requireSession(user);
+ return readService.listMine(user, false);
+ }
+
+ /** "关于我的"视图({@code /my/about},T8、V18):跨表单、按 {@code subject_id}=会话用户(含他人代填) */
+ @GetMapping("/about-list")
+ public List aboutList(BoaUser user) {
+ requireSession(user);
+ return readService.listMine(user, true);
+ }
+
+ /**
+ * 我的答卷详情(uuid 寻址):钉住版本的内联展开 schema + 值文档(形状与
+ * {@code /api/form/admin/data/detail} 同一个 {@link ReadModels.DetailView},渲染与校验同源)。
+ *
+ * 归属判定=我是提交人或我是被代填人,别人的答卷按"不存在"回答(不给探测面);
+ * 不在这里的动作:改状态(V14——本控制器没有任何状态端点,结构即证据)。
+ */
+ @GetMapping("/detail")
+ public ReadModels.DetailView detail(BoaUser user, @RequestParam("answerId") String answerId) {
+ requireSession(user);
+ return readService.myDetail(user, answerId);
+ }
+
+ /** 软删我的答卷(确认对话框后调用;软删释放限次名额,契约 §5) */
+ @PostMapping("/delete")
+ public void delete(BoaUser user, @RequestBody DeleteRequest body) {
+ requireSession(user);
+ readService.softDeleteBySubmitter(user, body.getAnswerId());
+ }
+
+ /** 编辑我的答卷(按钉住 version 校验;不改状态、不改身份) */
+ @PostMapping("/edit")
+ public ReadModels.RowView edit(BoaUser user, @RequestBody EditRequest body) {
+ requireSession(user);
+ return readService.editBySubmitter(user, body.getAnswerId(), body.getValues(), body.getSummary());
+ }
+
+ private static void requireSession(BoaUser user) {
+ if (user == null || user.getId() == null) {
+ throw new BizException(ErrorCode.UNAUTHORIZED, "未登录");
+ }
+ }
+
+ @lombok.Getter
+ @lombok.Setter
+ public static class DeleteRequest {
+ private String answerId;
+ }
+
+ @lombok.Getter
+ @lombok.Setter
+ public static class EditRequest {
+ private String answerId;
+ private com.fasterxml.jackson.databind.JsonNode values;
+ private String summary;
+ }
+}
diff --git a/src/main/java/com/yunhai/formcenter/model/MyModels.java b/src/main/java/com/yunhai/formcenter/model/MyModels.java
new file mode 100644
index 0000000..6899612
--- /dev/null
+++ b/src/main/java/com/yunhai/formcenter/model/MyModels.java
@@ -0,0 +1,48 @@
+package com.yunhai.formcenter.model;
+
+import lombok.Getter;
+import lombok.Setter;
+
+/**
+ * "我的填写"两视图({@code /my/submit} 与 {@code /my/about})的列表出参(方案 §6.3、任务书 T8)。
+ *
+ * 为什么不是 {@link ReadModels.RowView}:那两个视图是跨表单的——
+ * 一个学生/老师的名下可能挂着好几张表单的答卷,行上必须带表单身份(名称/code/归属应用)才认得出是哪张表。
+ * {@code RowView} 是"单表单数据列表"的行形状(列由前端按该表单 schema 展),没有这两列。
+ *
+ * 为什么不含 {@code values}:跨表单列表没有统一列可展,值文档在这里没有展示位;
+ * 要看值走 {@code /api/form/my/detail}(钉住版本 + 值文档一起给,渲染与校验同源)。
+ * 少带一个 MEDIUMTEXT 列也与方案 §5.5 铁律①同向。
+ *
+ * 对外身份仍然只有 uuid:{@code answerId}/{@code formUuid};库内 {@code id} 不外露(契约 §1、V5)。
+ * {@code submitterId}/{@code subjectId} 是用户 id(平台全局身份,不是本库自增主键),
+ * 与数据管理面 {@code RowView} 同口径。
+ */
+public final class MyModels {
+
+ private MyModels() {
+ }
+
+ /** 一行"我的答卷":答卷身份 + 归属表单身份 + 状态/报名号/提交时间 */
+ @Getter
+ @Setter
+ public static class ItemView {
+ private String answerId;
+ private String formUuid;
+ /** 归属应用(中心=form-center,快应用=该应用 app_code) */
+ private String appCode;
+ private String code;
+ /** 表单名称;归属表单已被彻底取不到时为 null,前端降级显示 code */
+ private String formName;
+ /** 该答卷钉住的版本号(详情/编辑按它取 schema) */
+ private Integer version;
+ private String status;
+ /** 状态 label;已废弃 code 为 null,前端标"已废弃:code"(方案 §5.3①) */
+ private String statusLabel;
+ private Integer submitterId;
+ private Integer subjectId;
+ private String answerNo;
+ /** yyyy-MM-dd HH:mm:ss */
+ private String submitTime;
+ }
+}
diff --git a/src/main/java/com/yunhai/formcenter/repository/AnswerRepository.java b/src/main/java/com/yunhai/formcenter/repository/AnswerRepository.java
index 25dc4ff..ae46372 100644
--- a/src/main/java/com/yunhai/formcenter/repository/AnswerRepository.java
+++ b/src/main/java/com/yunhai/formcenter/repository/AnswerRepository.java
@@ -57,4 +57,14 @@ public interface AnswerRepository extends JpaRepository {
+ "order by a.id asc")
List findLiveByFormUuidAfterId(@Param("formUuid") String formUuid, @Param("cursor") Long cursor,
Pageable pageable);
+
+ /** 我的提交段(T7)/ "我提交的"视图(T8):按 submitter 过滤本人答卷,按 id 降序(最新在前) */
+ List findBySchIdAndSubmitterIdOrderByIdDesc(Long schId, Integer submitterId);
+
+ /**
+ * "关于我的"视图(T8,V18):按 subject 过滤——别人代我填的答卷也在这里
+ * (方案 §6.3:谁提交与关于谁是两个角色,学生看到的是"关于我"的那几条,不限于自己填的)。
+ * 与上一条是两个方法而非一个方法加 flag(同视图侧"两个入口不做参数分支"的口径)。
+ */
+ List findBySchIdAndSubjectIdOrderByIdDesc(Long schId, Integer subjectId);
}
diff --git a/src/main/java/com/yunhai/formcenter/service/AnswerReadService.java b/src/main/java/com/yunhai/formcenter/service/AnswerReadService.java
index c05cdb8..a255f76 100644
--- a/src/main/java/com/yunhai/formcenter/service/AnswerReadService.java
+++ b/src/main/java/com/yunhai/formcenter/service/AnswerReadService.java
@@ -2,18 +2,27 @@ package com.yunhai.formcenter.service;
import com.fasterxml.jackson.databind.JsonNode;
import com.yunhai.formcenter.common.FormFilterOperators;
+import com.yunhai.formcenter.common.FormRuntimeRules;
import com.yunhai.formcenter.common.FormSchemaValidator;
import com.yunhai.formcenter.common.FormStatusDefs;
+import com.yunhai.formcenter.common.FormValueValidator;
import com.yunhai.formcenter.entity.Answer;
+import com.yunhai.formcenter.entity.AnswerOpLog;
import com.yunhai.formcenter.entity.Form;
+import com.yunhai.formcenter.entity.FormFile;
+import com.yunhai.formcenter.model.MyModels;
import com.yunhai.formcenter.model.ReadModels;
+import com.yunhai.formcenter.repository.AnswerOpLogRepository;
import com.yunhai.formcenter.repository.AnswerRepository;
+import com.yunhai.formcenter.repository.FillQuotaRepository;
+import com.yunhai.formcenter.repository.FormFileRepository;
import com.yunhai.platform.apistd.BizException;
import com.yunhai.platform.utils.exception.ErrorCode;
import com.yunhai.platform.utils.model.BoaUser;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
@@ -35,6 +44,11 @@ import java.util.Map;
* 三处纪律:列表走两段式(先取当页 id、再按 id 批量取值文档,铁律①);
* 翻页走 keyset 且游标对外是 uuid(铁律② + 契约 §1 id 不外露);
* 软删答卷在四条路径一律不可见(V30,谓词在构造器里)。
+ *
+ * 末尾的"我的填写"段是另一条线,不要与上面四条混读:那里的可见集来自
+ * {@code submitter_id}/{@code subject_id}=会话用户的归属判定,不是契约 §8 的范围矩阵
+ * (填表人不授权任何模块行也能看自己的数据,方案 §6.3"我的填写两层落点");
+ * 它也不接受 {@code schId} 外部传入,租户域同样只从会话推导(V17)。
*/
@Slf4j
@Service
@@ -48,6 +62,10 @@ public class AnswerReadService {
private final DataScopeService scopeService;
private final AnswerScopeQueryBuilder queryBuilder;
private final AnswerRepository answerRepository;
+ private final AnswerIndexService indexService;
+ private final FillQuotaRepository quotaRepository;
+ private final FormFileRepository fileRepository;
+ private final AnswerOpLogRepository opLogRepository;
// ==================== 规格构造(四路径共用) ====================
@@ -255,4 +273,230 @@ public class AnswerReadService {
}
return labels;
}
+
+ // ==================== 我的提交(T7 填报页内嵌段) ====================
+
+ /**
+ * 我的答卷列表(按 submitter 过滤,"我的提交"段)。
+ * 普通用户只能看自己的答卷(submitter_id=会话用户),无数据管理权。
+ */
+ public List listBySubmitter(BoaUser user, String formCode, String appCode) {
+ if (user == null || user.getId() == null) {
+ throw new BizException(ErrorCode.UNAUTHORIZED, "未登录");
+ }
+ Long schId = sessionSchId(user);
+ if (schId == null) {
+ throw new BizException(ErrorCode.FORBIDDEN, "当前身份无学校归属");
+ }
+ String app = (appCode == null || appCode.trim().isEmpty()) ? "form-center" : appCode.trim();
+ // 与填报页加载同一条解析(契约 §1 resolve 是读接口唯一入口):不存在=PARAM_ERROR、未发布/停用=CUSTOM
+ Form form = defService.resolvePublished(schId, app, formCode).form();
+ List answers = answerRepository.findBySchIdAndSubmitterIdOrderByIdDesc(schId, user.getId());
+ List rows = new ArrayList<>();
+ for (Answer answer : answers) {
+ if (!answer.getAppCode().equals(app) || !answer.getCode().equals(formCode)) {
+ continue;
+ }
+ if (Integer.valueOf(1).equals(answer.getIsDeleted())) {
+ continue;
+ }
+ rows.add(toRow(answer, form));
+ }
+ return rows;
+ }
+
+ /**
+ * 软删我的答卷(确认对话框后调用;软删释放限次名额,契约 §5)。
+ * 普通用户只能删自己提交的答卷(submitter_id=会话用户;被代填的答卷删不动——语义上是"我填的")。
+ */
+ @Transactional
+ public void softDeleteBySubmitter(BoaUser user, String answerId) {
+ if (user == null || user.getId() == null) {
+ throw new BizException(ErrorCode.UNAUTHORIZED, "未登录");
+ }
+ Long schId = sessionSchId(user);
+ if (schId == null) {
+ throw new BizException(ErrorCode.FORBIDDEN, "当前身份无学校归属");
+ }
+ Answer answer = requireLiveAnswer(schId, answerId);
+ if (!answer.getSubmitterId().equals(user.getId())) {
+ throw new BizException(ErrorCode.FORBIDDEN, "只能删除自己提交的答卷");
+ }
+ answer.setIsDeleted(1);
+ answerRepository.save(answer);
+ // 与管理员软删同口径:索引行同步删、限次名额释放(V30/V32),否则删了行仍挡着本人重填
+ indexService.clear(answer.getId());
+ quotaRepository.releaseQuota(
+ defService.pinnedForData(answer.getFormUuid(), answer.getVersion()).form().getId(),
+ FormRuntimeRules.quotaKey(answer.getSubjectId(), answer.getSubmitterId()));
+ opLog(answer.getId(), AnswerOpLog.OP_SOFT_DELETE, user.getId(), "本人删除自己的提交(软删)");
+ log.info("[我的提交] 软删答卷 answerId={} submitterId={}", answerId, user.getId());
+ }
+
+ /**
+ * 编辑我的答卷(按钉住 version 校验;不改状态、不改身份)。
+ * 普通用户只能编辑自己提交的答卷(submitter_id=会话用户)。
+ */
+ @Transactional
+ public ReadModels.RowView editBySubmitter(BoaUser user, String answerId, JsonNode values, String summary) {
+ if (user == null || user.getId() == null) {
+ throw new BizException(ErrorCode.UNAUTHORIZED, "未登录");
+ }
+ Long schId = sessionSchId(user);
+ if (schId == null) {
+ throw new BizException(ErrorCode.FORBIDDEN, "当前身份无学校归属");
+ }
+ Answer answer = requireLiveAnswer(schId, answerId);
+ if (!answer.getSubmitterId().equals(user.getId())) {
+ throw new BizException(ErrorCode.FORBIDDEN, "只能编辑自己提交的答卷");
+ }
+ FormDefService.Published pinned = defService.pinnedForData(answer.getFormUuid(), answer.getVersion());
+ Form form = pinned.form();
+ String valuesJson = values == null || values.isNull() ? "{}" : values.toString();
+ // 与管理员编辑同一条值契约(按钉住版本、字典放行集);P34:附件三件套必须对得上本校上传记录
+ FormValueValidator.validate(valuesJson, pinned.versionRow().getSchemaJson(),
+ dictCode -> dictService.allowedValues(answer.getSchId(), dictCode));
+ assertAttachmentsOwned(answer.getSchId(), valuesJson, pinned.versionRow().getSchemaJson());
+ answer.setValuesJson(valuesJson);
+ answerRepository.save(answer);
+ // 索引按钉住版本重建(方案 §5.4),否则改了可筛字段的值而筛选结果不变
+ indexService.rebuild(answer, pinned.versionRow().getSchemaJson());
+ opLog(answer.getId(), AnswerOpLog.OP_EDIT, user.getId(), summary == null || summary.trim().isEmpty()
+ ? "本人编辑自己的提交(钉住版本 v" + answer.getVersion() + ")"
+ : "本人编辑:钉住版本 v" + answer.getVersion() + ";" + summary.trim());
+ log.info("[我的提交] 编辑答卷 answerId={} submitterId={} v={}", answerId, user.getId(),
+ answer.getVersion());
+ return toRow(answer, form);
+ }
+
+ // ==================== 我的填写两视图(T8:/my/submit 按 submitter、/my/about 按 subject) ====================
+
+ /**
+ * 全局"我的填写"两视图的列表读取。两个视图 = 两个谓词、两个入口方法(方案 §6.3:
+ * "做成两个视图不做一个接口加参数"),鉴权分支不写在同一个方法体里靠 flag 分岔:
+ * {@code bySubject=false} 按 {@code submitter_id}=会话用户(我提交的),
+ * {@code bySubject=true} 按 {@code subject_id}=会话用户(关于我的,含管理员代填的那几条,V18)。
+ *
+ * 与数据管理面的区别:那条路径的范围来自契约 §8 矩阵({@link DataScopeService})且要先有
+ * {@code /admin/data} 模块行;这里不看模块行、不看范围矩阵,只看"这条数据是不是我的",
+ * 所以填表人(学生)不授权任何管理模块行也能用自己的两视图。
+ */
+ public List listMine(BoaUser user, boolean bySubject) {
+ if (user == null || user.getId() == null) {
+ throw new BizException(ErrorCode.UNAUTHORIZED, "未登录");
+ }
+ Long schId = sessionSchId(user);
+ if (schId == null) {
+ throw new BizException(ErrorCode.FORBIDDEN, "当前身份无学校归属");
+ }
+ List answers = bySubject
+ ? answerRepository.findBySchIdAndSubjectIdOrderByIdDesc(schId, user.getId())
+ : answerRepository.findBySchIdAndSubmitterIdOrderByIdDesc(schId, user.getId());
+ // 表头信息按 form_uuid 去重取(跨表单列表,一页内同表单多行时不重复查头表)
+ Map forms = new HashMap<>();
+ List items = new ArrayList<>();
+ for (Answer answer : answers) {
+ if (Integer.valueOf(1).equals(answer.getIsDeleted())) {
+ continue;
+ }
+ Form form = forms.computeIfAbsent(answer.getFormUuid(), uuid -> findFormQuietly(schId, uuid));
+ items.add(toMyItem(answer, form));
+ }
+ return items;
+ }
+
+ /**
+ * 我的答卷详情(uuid 寻址):钉住版本的内联展开 schema + 值文档,
+ * 与 {@link #detail} 同一份形状(渲染与校验同源——两视图的编辑弹层直接按它出控件)。
+ * 归属判定=我是提交人 或 我是被代填人;后者只读——读路径上没有 editable 之类的
+ * 服务端判定字段(P75 修正:前端按列表行的 {@code submitterId} 是否等于会话用户决定编辑入口显隐,
+ * 真正的拒绝在 {@link #editBySubmitter}/{@link #softDeleteBySubmitter} 的提交人判定里);
+ * 改状态在这一面压根没有入口——V14/V29。
+ */
+ public ReadModels.DetailView myDetail(BoaUser user, String answerId) {
+ if (user == null || user.getId() == null) {
+ throw new BizException(ErrorCode.UNAUTHORIZED, "未登录");
+ }
+ Long schId = sessionSchId(user);
+ if (schId == null) {
+ throw new BizException(ErrorCode.FORBIDDEN, "当前身份无学校归属");
+ }
+ Answer answer = requireLiveAnswer(schId, answerId);
+ boolean mine = user.getId().equals(answer.getSubmitterId()) || user.getId().equals(answer.getSubjectId());
+ if (!mine) {
+ // 别人的答卷与不存在的答卷同一口径回答,差别信息就是探测面(契约 §1:uuid 不可枚举)
+ throw new BizException(ErrorCode.PARAM_ERROR, "答卷不存在或已删除:" + answerId);
+ }
+ FormDefService.Published pinned = defService.pinnedForData(answer.getFormUuid(), answer.getVersion());
+ Form form = pinned.form();
+ ReadModels.DetailView view = new ReadModels.DetailView();
+ copyInto(answer, form, view);
+ view.setAppCode(form.getAppCode());
+ view.setCode(form.getCode());
+ view.setFormName(form.getName());
+ view.setGroups(groupsNode(dictService.expandSchema(form.getSchId(), pinned.versionRow().getSchemaJson())));
+ return view;
+ }
+
+ private MyModels.ItemView toMyItem(Answer answer, Form form) {
+ MyModels.ItemView item = new MyModels.ItemView();
+ item.setAnswerId(answer.getUuid());
+ item.setFormUuid(answer.getFormUuid());
+ item.setAppCode(answer.getAppCode());
+ item.setCode(answer.getCode());
+ item.setFormName(form == null ? null : form.getName());
+ item.setVersion(answer.getVersion());
+ item.setStatus(answer.getStatus());
+ item.setStatusLabel(form == null ? null : FormStatusDefs.labelOf(form.getStatusDefs(), answer.getStatus()));
+ item.setSubmitterId(answer.getSubmitterId());
+ item.setSubjectId(answer.getSubjectId());
+ item.setAnswerNo(answer.getAnswerNo());
+ item.setSubmitTime(format(answer.getCreateTime()));
+ return item;
+ }
+
+ /** 会话校(Integer→Long 换算走已有字段,契约 §7 身份行:外部不传) */
+ private static Long sessionSchId(BoaUser user) {
+ Integer schId = user.getCurrentRole() == null ? null : user.getCurrentRole().getSchId();
+ return schId == null ? null : Long.valueOf(schId);
+ }
+
+ /** 表单头表静默取数:答卷归属的表单理论上一定在(P32 连软删的都还在),取不到就让表名列降级为空 */
+ private Form findFormQuietly(Long schId, String formUuid) {
+ try {
+ return defService.readDomainForm(schId, formUuid);
+ } catch (BizException e) {
+ log.warn("[我的填写] 答卷归属表单取不到,表名列降级显示 code:formUuid={} 原因={}", formUuid, e.getMessage());
+ return null;
+ }
+ }
+
+ /** 附件归属与一致性(P24 同校 + P34 key/fileName 与上传记录一致),判据与管理员编辑侧同源 */
+ private void assertAttachmentsOwned(Long schId, String valuesJson, String schemaJson) {
+ for (JsonNode item : FormValueValidator.attachments(valuesJson, schemaJson)) {
+ long id = item.get("id").longValue();
+ FormFile file = fileRepository.findByIdAndSchId(id, schId).orElse(null);
+ if (file == null) {
+ throw new BizException(ErrorCode.PARAM_ERROR, "附件不存在或不属于本校:id=" + id);
+ }
+ String key = item.path("key").asText(null);
+ if (key == null || !key.equals(file.getStoreKey())) {
+ throw new BizException(ErrorCode.PARAM_ERROR, "附件 key 与上传记录不一致:id=" + id);
+ }
+ String fileName = item.path("fileName").asText(null);
+ if (fileName == null || !fileName.equals(file.getFileName())) {
+ throw new BizException(ErrorCode.PARAM_ERROR, "附件文件名与上传记录不一致:id=" + id);
+ }
+ }
+ }
+
+ /** 留痕形态与管理员侧同源(方案 §5.4:编辑/删除都进 answer_op_log),只是操作人=本人 */
+ private void opLog(Long answerId, String op, Integer operatorId, String summary) {
+ AnswerOpLog entry = new AnswerOpLog();
+ entry.setAnswerId(answerId);
+ entry.setOp(op);
+ entry.setOperatorId(operatorId);
+ entry.setSummary(AnswerOpLog.summarize(summary));
+ opLogRepository.save(entry);
+ }
}
diff --git a/src/test/java/com/yunhai/formcenter/service/MyAnswerIT.java b/src/test/java/com/yunhai/formcenter/service/MyAnswerIT.java
new file mode 100644
index 0000000..cb9e936
--- /dev/null
+++ b/src/test/java/com/yunhai/formcenter/service/MyAnswerIT.java
@@ -0,0 +1,500 @@
+package com.yunhai.formcenter.service;
+
+import com.yunhai.formcenter.controller.api.FormMyAnswerController;
+import com.yunhai.formcenter.entity.Answer;
+import com.yunhai.formcenter.entity.AnswerOpLog;
+import com.yunhai.formcenter.enums.FormEnums;
+import com.yunhai.formcenter.model.AnswerModels;
+import com.yunhai.formcenter.model.FormModels;
+import com.yunhai.formcenter.model.MyModels;
+import com.yunhai.formcenter.model.ReadModels;
+import com.yunhai.formcenter.repository.AnswerIndexRepository;
+import com.yunhai.formcenter.repository.AnswerOpLogRepository;
+import com.yunhai.formcenter.repository.FillQuotaRepository;
+import com.yunhai.platform.apistd.BizException;
+import com.yunhai.platform.utils.exception.ErrorCode;
+import com.yunhai.platform.utils.model.BoaUser;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.test.context.SpringBootTest;
+import org.springframework.context.annotation.Import;
+import org.springframework.transaction.annotation.Transactional;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.PostMapping;
+
+import java.lang.reflect.Field;
+import java.lang.reflect.Method;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.List;
+import java.util.stream.Collectors;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+import static org.assertj.core.api.Assertions.catchThrowableOfType;
+
+/**
+ * "我的填写"面六端点集成测试(真实 MySQL;问题单 P70/P75 的单测缺口在此闭环)。
+ *
+ * 打控制器方法而不是只打服务:六端点的身份闸门 {@code requireSession} 与"入参里没有身份位"
+ * 这两条纪律都落在控制器层,只测服务会把它们留在断言之外(P75 的教训正是"扩了面却没测面")。
+ * 这里不走 MockMvc——CAS 会话由 {@link BoaUser} 形参注入,直调方法就是线上的调用形状。
+ *
+ * 五组断言按 P75 裁决逐条落地:①身份全会话推导(外部传 id 结构性不可能,V17)②越权无探测面
+ * ③软删释放名额(删后可重填)④两视图谓词分离(submitter/subject 各归各、互不串,V18)
+ * ⑤myDetail 归属(提交人或被代填人可读,他人按"不存在"答)。
+ */
+@SpringBootTest
+@Import(AnswerTestSupport.TestGatewayConfiguration.class)
+@Transactional
+@DisplayName("FormMyAnswerController - 我的填写六端点(P70/P75:V17/V18/V29/V30/V32/V14/V5)")
+class MyAnswerIT extends AnswerTestSupport {
+
+ /** name/grade 声明 searchable(编辑后索引重建的断言靠它),remark 不声明(V3 半边) */
+ private static final String SCHEMA = "{\"groups\":[{\"key\":\"base\",\"label\":\"基本信息\",\"fields\":["
+ + "{\"key\":\"name\",\"label\":\"姓名\",\"type\":\"text\",\"required\":true,\"searchable\":true},"
+ + "{\"key\":\"grade\",\"label\":\"年级\",\"type\":\"select\",\"required\":true,\"searchable\":true,"
+ + "\"options\":[{\"label\":\"高二\",\"value\":\"g2\"},{\"label\":\"高三\",\"value\":\"g3\"}]},"
+ + "{\"key\":\"remark\",\"label\":\"备注\",\"type\":\"textarea\"}"
+ + "]}]}";
+
+ private static final String VALUES = "{\"name\":\"张三\",\"grade\":\"g2\",\"remark\":\"原文\"}";
+
+ /** 升版后新增必填字段——老答卷按钉住版本编辑时不该被它挡住(P20/方案 §5.4) */
+ private static final String SCHEMA_V2 = SCHEMA.replace(
+ "{\"key\":\"remark\"", "{\"key\":\"teacher\",\"label\":\"指导教师\",\"type\":\"text\",\"required\":true},"
+ + "{\"key\":\"remark\"");
+
+ /** 演练用的快应用归属码(同一 code 在中心与快应用各有一张表单,V6 的我的面形态) */
+ private static final String QUICK_APP = "honour-app";
+
+ @Autowired
+ private FormMyAnswerController controller;
+
+ @Autowired
+ private AnswerIndexRepository indexRepository;
+
+ @Autowired
+ private AnswerOpLogRepository opLogRepository;
+
+ @Autowired
+ private FillQuotaRepository quotaRepository;
+
+ @Autowired
+ private FormAdminGuard adminGuard;
+
+ // ==================== ① 身份全会话推导(V17) ====================
+
+ @Test
+ @DisplayName("V17 结构半边:六端点首参恒为会话用户、无 id 型入参,请求体只有 uuid/值/说明(外部传 id 不可能)")
+ void endpointsCarryNoIdentityParameter() {
+ List mapped = new ArrayList<>();
+ for (Method m : FormMyAnswerController.class.getDeclaredMethods()) {
+ GetMapping get = m.getAnnotation(GetMapping.class);
+ PostMapping post = m.getAnnotation(PostMapping.class);
+ if (get == null && post == null) {
+ continue;
+ }
+ mapped.add((get != null ? "GET " : "POST ")
+ + (get != null ? get.value()[0] : post.value()[0]));
+ assertThat(m.getParameterTypes()[0])
+ .as("端点 %s 的第一个入参必须是会话用户", m.getName())
+ .isEqualTo(BoaUser.class);
+ assertThat(Arrays.stream(m.getParameterTypes()).filter(t -> t == Long.class || t == Integer.class).count())
+ .as("端点 %s 不接受外部传入的 id 型身份参数", m.getName())
+ .isEqualTo(0L);
+ }
+ assertThat(mapped).containsExactlyInAnyOrder(
+ "GET /list", "GET /submit-list", "GET /about-list", "GET /detail",
+ "POST /delete", "POST /edit");
+ // V14 的结构半边:本面没有任何改状态端点
+ assertThat(mapped).noneMatch(p -> p.contains("status"));
+ assertThat(fieldNames(FormMyAnswerController.DeleteRequest.class)).containsExactly("answerId");
+ assertThat(fieldNames(FormMyAnswerController.EditRequest.class))
+ .containsExactlyInAnyOrder("answerId", "values", "summary");
+ }
+
+ @Test
+ @DisplayName("V17 行为半边:租户域取自会话——他校用户拿同一条 uuid 读不到,两视图里也没有它")
+ void tenantDomainComesFromSessionNotFromParameter() {
+ String code = "my_v17_tenant";
+ publishForm(code, SCHEMA);
+ BoaUser student = plainUser(3);
+ String answerId = answerService.submit(student, submit(code, VALUES)).getAnswerId();
+
+ BoaUser otherSchoolAdmin = userAtOtherSchool();
+ assertNotFoundShape(() -> controller.detail(otherSchoolAdmin, answerId), answerId);
+ assertThat(idsOf(controller.submitList(otherSchoolAdmin))).doesNotContain(answerId);
+ assertThat(idsOf(controller.aboutList(otherSchoolAdmin))).doesNotContain(answerId);
+ assertThat(idsOf(controller.submitList(user(student.getId(), OTHER_SCH, 3)))).doesNotContain(answerId);
+ // 他校管理员连"删除"都指不到这行(uuid 在同校域外不可定位)
+ assertNotFoundShape(() -> controller.delete(otherSchoolAdmin, deleteBody(answerId)), answerId);
+ }
+
+ @Test
+ @DisplayName("未登录(会话为空或无 id):六端点一律 UNAUTHORIZED,不查库不落库")
+ void everyEndpointRequiresSession() {
+ String code = "my_v17_session";
+ publishForm(code, SCHEMA);
+ String answerId = answerService.submit(plainUser(3), submit(code, VALUES)).getAnswerId();
+ BoaUser nobody = new BoaUser();
+
+ List calls = Arrays.asList(
+ () -> controller.list(null, code, null),
+ () -> controller.submitList(null),
+ () -> controller.aboutList(null),
+ () -> controller.detail(null, answerId),
+ () -> controller.delete(null, deleteBody(answerId)),
+ () -> controller.edit(null, editBody(answerId, VALUES)),
+ () -> controller.list(nobody, code, null),
+ () -> controller.submitList(nobody),
+ () -> controller.aboutList(nobody),
+ () -> controller.detail(nobody, answerId),
+ () -> controller.delete(nobody, deleteBody(answerId)),
+ () -> controller.edit(nobody, editBody(answerId, VALUES)));
+ for (Runnable call : calls) {
+ BizException e = catchThrowableOfType(call::run, BizException.class);
+ assertThat(e.getErrorCode()).isEqualTo(ErrorCode.UNAUTHORIZED);
+ assertThat(e).hasMessageContaining("未登录");
+ }
+ assertThat(answerRepository.findBySchIdAndAppCodeAndCodeOrderByIdAsc(SCH, "form-center", code)).hasSize(1);
+ }
+
+ // ==================== ② 越权:无探测面 ====================
+
+ @Test
+ @DisplayName("越权读他人答卷=与\"不存在\"同码同形(仅 uuid 不同)——不给\"存在但不属于你\"这个比特")
+ void foreignAnswerIsAnsweredExactlyLikeMissingOne() {
+ String code = "my_probe";
+ publishForm(code, SCHEMA);
+ String answerId = answerService.submit(plainUser(3), submit(code, VALUES)).getAnswerId();
+ BoaUser stranger = plainUser(3);
+ String bogus = "0000000000000000000000000000dead";
+
+ BizException foreign = catchThrowableOfType(() -> controller.detail(stranger, answerId), BizException.class);
+ BizException missing = catchThrowableOfType(() -> controller.detail(stranger, bogus), BizException.class);
+
+ assertThat(foreign.getErrorCode()).isEqualTo(ErrorCode.PARAM_ERROR);
+ assertThat(missing.getErrorCode()).isEqualTo(ErrorCode.PARAM_ERROR);
+ assertThat(foreign.getMessage().replace(answerId, ""))
+ .isEqualTo(missing.getMessage().replace(bogus, ""));
+ }
+
+ @Test
+ @DisplayName("被代填人可读不可改:改/删是提交人动作,非提交人 FORBIDDEN(V29 的我的面形态)")
+ void onlySubmitterCanMutate() {
+ String code = "my_mutate";
+ publishForm(code, SCHEMA);
+ BoaUser teacher = user(10001, SCH, 7);
+ Integer studentId = 20270001;
+ AnswerModels.SubmitRequest req = submit(code, VALUES);
+ req.setSubjectId(studentId);
+ String answerId = answerService.submit(teacher, req).getAnswerId();
+
+ BoaUser student = user(studentId, SCH, 3);
+ assertThat(controller.detail(student, answerId).getAnswerId()).isEqualTo(answerId);
+
+ BizException del = catchThrowableOfType(() -> controller.delete(student, deleteBody(answerId)),
+ BizException.class);
+ assertThat(del.getErrorCode()).isEqualTo(ErrorCode.FORBIDDEN);
+ assertThat(del).hasMessageContaining("只能删除自己提交的答卷");
+ BizException edit = catchThrowableOfType(
+ () -> controller.edit(student, editBody(answerId, "{\"name\":\"改\",\"grade\":\"g3\"}")),
+ BizException.class);
+ assertThat(edit.getErrorCode()).isEqualTo(ErrorCode.FORBIDDEN);
+ assertThat(edit).hasMessageContaining("只能编辑自己提交的答卷");
+
+ // 同校第三人同样改不动,且数据未被改动
+ BoaUser stranger = plainUser(3);
+ assertThatThrownBy(() -> controller.delete(stranger, deleteBody(answerId)))
+ .isInstanceOf(BizException.class).hasMessageContaining("只能删除自己提交的答卷");
+ assertThat(answerRepository.findByUuidAndSchId(answerId, SCH).get().getIsDeleted()).isZero();
+ }
+
+ // ==================== ③ 软删释放名额(删后可重填) ====================
+
+ @Test
+ @DisplayName("V32 我的面半边:本人软删释放限次名额+同步删索引行+留痕,删后可重填;已删行四视图皆不可见(V30)")
+ void selfDeleteReleasesQuotaAndIndexRows() {
+ String code = "my_release";
+ String uuid = publishForm(code, SCHEMA);
+ FormModels.FormConfigRequest once = new FormModels.FormConfigRequest();
+ once.setFillLimit(FormEnums.FillLimit.ONCE.getCode());
+ config(SCH, uuid, once);
+
+ BoaUser student = plainUser(3);
+ String answerId = answerService.submit(student, submit(code, VALUES)).getAnswerId();
+ Long answerDbId = answerRepository.findByUuidAndSchId(answerId, SCH).get().getId();
+ assertThat(indexRepository.findByAnswerIdOrderByIdAsc(answerDbId)).isNotEmpty();
+ assertThat(idsOf(controller.list(student, code, null))).containsExactly(answerId);
+
+ controller.delete(student, deleteBody(answerId));
+
+ Answer row = answerRepository.findByUuidAndSchId(answerId, SCH).get();
+ assertThat(row.getIsDeleted()).isEqualTo(1);
+ assertThat(indexRepository.findByAnswerIdOrderByIdAsc(answerDbId)).isEmpty();
+ assertThat(ops(answerDbId)).contains(AnswerOpLog.OP_SOFT_DELETE);
+ assertThat(quotaRepository.findByFormId(
+ defService.pinnedForData(row.getFormUuid(), row.getVersion()).form().getId())).isEmpty();
+
+ String refillId = answerService.submit(student, submit(code, "{\"name\":\"重填\",\"grade\":\"g3\"}"))
+ .getAnswerId();
+ assertThat(refillId).isNotEqualTo(answerId);
+ assertThat(idsOf(controller.list(student, code, null))).containsExactly(refillId);
+ assertThat(idsOf(controller.submitList(student))).containsExactly(refillId);
+ // 自填未指定被代填人(subject 为空)→"关于我的"按 subject 谓词查,这条不在那里(两谓词各归各,V18)
+ assertThat(idsOf(controller.aboutList(student))).isEmpty();
+ assertNotFoundShape(() -> controller.detail(student, answerId), answerId);
+ }
+
+ // ==================== ④ 两视图谓词分离(V18) ====================
+
+ @Test
+ @DisplayName("V18:代填行只进被代填人\"关于我的\"+操作人\"我提交的\",自填行两视图都在——两谓词互不串")
+ void twoViewsUseSeparatePredicates() {
+ String code = "my_twoview";
+ publishForm(code, SCHEMA);
+ BoaUser teacher = user(10001, SCH, 7);
+ Integer teacherId = teacher.getId();
+ Integer studentId = 20271001;
+
+ AnswerModels.SubmitRequest proxy = submit(code, VALUES);
+ proxy.setSubjectId(studentId);
+ String proxyAnswerId = answerService.submit(teacher, proxy).getAnswerId();
+
+ AnswerModels.SubmitRequest self = submit(code, "{\"name\":\"李四\",\"grade\":\"g3\"}");
+ self.setSubjectId(studentId);
+ String selfAnswerId = answerService.submit(user(studentId, SCH, 3), self).getAnswerId();
+
+ assertThat(idsOf(controller.submitList(teacher))).containsExactly(proxyAnswerId);
+ assertThat(idsOf(controller.aboutList(teacher))).isEmpty();
+
+ BoaUser student = user(studentId, SCH, 3);
+ assertThat(idsOf(controller.submitList(student))).containsExactly(selfAnswerId);
+ assertThat(idsOf(controller.aboutList(student))).containsExactlyInAnyOrder(proxyAnswerId, selfAnswerId);
+
+ MyModels.ItemView proxyRow = controller.aboutList(student).stream()
+ .filter(i -> i.getAnswerId().equals(proxyAnswerId)).findFirst().orElseThrow(AssertionError::new);
+ assertThat(proxyRow.getSubmitterId()).isEqualTo(teacherId);
+ assertThat(proxyRow.getSubjectId()).isEqualTo(studentId);
+ // 前端编辑/删除入口按 submitterId 判 isMine(P75 修正:DetailView 无 editable 字段)
+ assertThat(proxyRow.getSubmitterId()).isNotEqualTo(studentId);
+ assertThat(proxyRow.getFormName()).isEqualTo("测试 " + code);
+ assertThat(proxyRow.getVersion()).isEqualTo(1);
+ assertThat(proxyRow.getStatusLabel()).isEqualTo("待处理");
+ }
+
+ @Test
+ @DisplayName("两视图不看模块行、不看 §8 范围矩阵:零授权学生仍看得见自己的行;管理面同一人照样被拒")
+ void myViewsIgnoreModuleGrants() {
+ String code = "my_no_grant";
+ publishForm(code, SCHEMA);
+ BoaUser student = plainUser(3);
+ Integer studentId = student.getId();
+ // 自填并把被代填人写成自己:两视图都该有这条(subject=submitter=本人)
+ AnswerModels.SubmitRequest selfFill = submit(code, VALUES);
+ selfFill.setSubjectId(studentId);
+ String answerId = answerService.submit(student, selfFill).getAnswerId();
+
+ moduleTree.clear(studentId);
+ assertThat(idsOf(controller.submitList(student))).containsExactly(answerId);
+ assertThat(idsOf(controller.aboutList(student))).containsExactly(answerId);
+ assertThat(controller.detail(student, answerId).getAnswerId()).isEqualTo(answerId);
+ // 对照:数据管理面要求 /admin/data 模块行,同一个人过去就是 FORBIDDEN(两套口径不混用)
+ assertThatThrownBy(() -> adminGuard.requireAdmin(student, SCH, FormAdminGuard.MODULE_DATA_MANAGE))
+ .isInstanceOf(BizException.class);
+ }
+
+ @Test
+ @DisplayName("填报页内嵌\"我的提交\"段:只回本表单+本人行;同 code 的中心表单与快应用表单按归属应用分流不串")
+ void listSegmentIsPerFormAndPerApp() {
+ String code = "my_segment";
+ publishForm(code, SCHEMA);
+ publishFormUnder(QUICK_APP, code, SCHEMA);
+
+ BoaUser me = plainUser(3);
+ BoaUser other = plainUser(3);
+ String mine = answerService.submit(me, submit(code, VALUES)).getAnswerId();
+ String otherRow = answerService.submit(other, submit(code, VALUES)).getAnswerId();
+ AnswerModels.SubmitRequest quick = submit(code, "{\"name\":\"快应用里的我\",\"grade\":\"g2\"}");
+ quick.setAppCode(QUICK_APP);
+ String mineInQuickApp = answerService.submit(me, quick).getAnswerId();
+
+ List centerRows = controller.list(me, code, null);
+ assertThat(idsOf(centerRows)).containsExactly(mine);
+ assertThat(centerRows.get(0).getValues().path("name").asText()).isEqualTo("张三");
+ assertThat(idsOf(controller.list(me, code, QUICK_APP))).containsExactly(mineInQuickApp);
+ assertThat(idsOf(centerRows)).doesNotContain(otherRow, mineInQuickApp);
+ // 未发布/不存在的 code 走与填报页同一条 resolve 口径
+ assertThatThrownBy(() -> controller.list(me, "no_such_code", null)).isInstanceOf(BizException.class);
+ }
+
+ // ==================== ⑤ myDetail 归属与钉版 ====================
+
+ @Test
+ @DisplayName("myDetail:提交人与被代填人都可读,给钉住版本的展开 schema+值文档;外层无库内自增 id(V5)")
+ void detailIsSelfAddressedAndSelfContained() {
+ String code = "my_detail";
+ String uuid = publishForm(code, SCHEMA);
+ BoaUser teacher = user(10001, SCH, 7);
+ Integer studentId = 20272001;
+ AnswerModels.SubmitRequest req = submit(code, VALUES);
+ req.setSubjectId(studentId);
+ String answerId = answerService.submit(teacher, req).getAnswerId();
+
+ ReadModels.DetailView asSubmitter = controller.detail(teacher, answerId);
+ ReadModels.DetailView asSubject = controller.detail(user(studentId, SCH, 3), answerId);
+ assertThat(asSubmitter.getAnswerId()).isEqualTo(answerId);
+ assertThat(asSubmitter.getFormUuid()).isEqualTo(uuid);
+ assertThat(asSubmitter.getVersion()).isEqualTo(1);
+ assertThat(asSubmitter.getValues().path("name").asText()).isEqualTo("张三");
+ assertThat(asSubmitter.getGroups().get(0).get("fields").get(0).get("key").asText()).isEqualTo("name");
+ assertThat(asSubject.getGroups().toString()).isEqualTo(asSubmitter.getGroups().toString());
+ // 两个身份位是"用户 id",不是本库自增主键;出参对象里不存在名为 id 的外层字段
+ assertThat(asSubmitter.getSubmitterId()).isEqualTo(teacher.getId());
+ assertThat(asSubmitter.getSubjectId()).isEqualTo(studentId);
+ List outerFields = new ArrayList<>(fieldNames(ReadModels.RowView.class));
+ outerFields.addAll(fieldNames(ReadModels.DetailView.class));
+ assertThat(outerFields).doesNotContain("id");
+ }
+
+ @Test
+ @DisplayName("P20 钉版半边:升版后老答卷详情/编辑仍按提交时版本,v2 新增必填字段不挡历史行")
+ void detailPinsVersionAfterUpgrade() {
+ String code = "my_pin";
+ String uuid = publishForm(code, SCHEMA);
+ BoaUser student = plainUser(3);
+ String answerId = answerService.submit(student, submit(code, VALUES)).getAnswerId();
+
+ FormModels.FormSaveRequest save = new FormModels.FormSaveRequest();
+ save.setUuid(uuid);
+ save.setSchemaJson(SCHEMA_V2);
+ defService.save(SCH, save);
+ defService.publish(SCH, uuid, null);
+
+ ReadModels.DetailView view = controller.detail(student, answerId);
+ assertThat(view.getVersion()).isEqualTo(1);
+ assertThat(view.getGroups().toString()).doesNotContain("teacher");
+
+ ReadModels.RowView edited = controller.edit(student,
+ editBody(answerId, "{\"name\":\"张三改\",\"grade\":\"g3\",\"remark\":\"按 v1 改\"}"));
+ assertThat(edited.getVersion()).isEqualTo(1);
+ assertThat(edited.getValues().path("name").asText()).isEqualTo("张三改");
+ }
+
+ // ==================== ⑥ 本人编辑(值契约 / 索引 / 留痕 / 身份不动) ====================
+
+ @Test
+ @DisplayName("本人编辑:按钉住版本过值校验、searchable 索引随新值重建、写 answer_op_log、身份与状态列不动")
+ void editKeepsIdentityAndRebuildsIndex() {
+ String code = "my_edit";
+ publishForm(code, SCHEMA);
+ BoaUser student = plainUser(3);
+ Integer studentId = student.getId();
+ String answerId = answerService.submit(student, submit(code, VALUES)).getAnswerId();
+ Long answerDbId = answerRepository.findByUuidAndSchId(answerId, SCH).get().getId();
+
+ ReadModels.RowView row = controller.edit(student,
+ editBody(answerId, "{\"name\":\"张三改名\",\"grade\":\"g3\"}", "改成高三"));
+ assertThat(row.getAnswerId()).isEqualTo(answerId);
+ assertThat(row.getSubmitterId()).isEqualTo(studentId);
+ assertThat(row.getStatus()).isEqualTo("pending");
+
+ Answer stored = answerRepository.findByUuidAndSchId(answerId, SCH).get();
+ assertThat(stored.getValuesJson()).contains("张三改名");
+ assertThat(stored.getSubmitterId()).isEqualTo(studentId);
+ assertThat(stored.getStatus()).isEqualTo("pending");
+ assertThat(indexValue(stored.getId(), "name")).isEqualTo("张三改名");
+ assertThat(indexValue(stored.getId(), "grade")).isEqualTo("g3");
+ List ops = ops(answerDbId);
+ assertThat(ops.get(ops.size() - 1)).isEqualTo(AnswerOpLog.OP_EDIT);
+ assertThat(opLogRepository.findByAnswerIdOrderByIdAsc(answerDbId).get(ops.size() - 1).getSummary())
+ .contains("改成高三");
+
+ // 同一份 schema 也挡住非法值:required 缺失、伪造选项
+ assertThatThrownBy(() -> controller.edit(student, editBody(answerId, "{\"grade\":\"g2\"}")))
+ .isInstanceOf(BizException.class).hasMessageContaining("必填");
+ assertThatThrownBy(() -> controller.edit(student, editBody(answerId, "{\"name\":\"x\",\"grade\":\"g9\"}")))
+ .isInstanceOf(BizException.class).hasMessageContaining("不在候选列表内");
+ // 空 answerId 与缺 uuid 按参数段拒(不是 500)
+ assertThatThrownBy(() -> controller.detail(student, " "))
+ .isInstanceOf(BizException.class).hasMessageContaining("answerId 不能为空");
+ }
+
+ // ==================== 小工具 ====================
+
+ /** 在指定归属应用下建并发布一张同 code 表单(快应用表单与中心表单同 code 共存) */
+ private void publishFormUnder(String appCode, String code, String schemaJson) {
+ FormModels.FormCreateRequest create = new FormModels.FormCreateRequest();
+ create.setAppCode(appCode);
+ create.setCode(code);
+ create.setName("快应用 " + code);
+ String uuid = defService.create(SCH, create).getFormUuid();
+ FormModels.FormSaveRequest save = new FormModels.FormSaveRequest();
+ save.setUuid(uuid);
+ save.setSchemaJson(schemaJson);
+ defService.save(SCH, save);
+ defService.publish(SCH, uuid, null);
+ }
+
+ private FormMyAnswerController.DeleteRequest deleteBody(String answerId) {
+ FormMyAnswerController.DeleteRequest body = new FormMyAnswerController.DeleteRequest();
+ body.setAnswerId(answerId);
+ return body;
+ }
+
+ private FormMyAnswerController.EditRequest editBody(String answerId, String valuesJson) {
+ return editBody(answerId, valuesJson, null);
+ }
+
+ private FormMyAnswerController.EditRequest editBody(String answerId, String valuesJson, String summary) {
+ FormMyAnswerController.EditRequest body = new FormMyAnswerController.EditRequest();
+ body.setAnswerId(answerId);
+ body.setValues(json(valuesJson));
+ body.setSummary(summary);
+ return body;
+ }
+
+ private static com.fasterxml.jackson.databind.JsonNode json(String body) {
+ try {
+ return com.yunhai.formcenter.common.FormSchemaValidator.mapper().readTree(body);
+ } catch (Exception e) {
+ throw new IllegalStateException("测试 JSON 写错了:" + body, e);
+ }
+ }
+
+ private List ops(Long answerDbId) {
+ return opLogRepository.findByAnswerIdOrderByIdAsc(answerDbId).stream()
+ .map(AnswerOpLog::getOp).collect(Collectors.toList());
+ }
+
+ private String indexValue(Long answerDbId, String fieldKey) {
+ return indexRepository.findByAnswerIdOrderByIdAsc(answerDbId).stream()
+ .filter(x -> x.getFieldKey().equals(fieldKey))
+ .map(x -> x.getVStr())
+ .findFirst().orElse(null);
+ }
+
+ private static List idsOf(List> rows) {
+ return rows.stream().map(row -> {
+ try {
+ return (String) row.getClass().getMethod("getAnswerId").invoke(row);
+ } catch (Exception e) {
+ throw new IllegalStateException("行形状没有 answerId:" + row.getClass(), e);
+ }
+ }).collect(Collectors.toList());
+ }
+
+ private static List fieldNames(Class> type) {
+ return Arrays.stream(type.getDeclaredFields()).map(Field::getName).collect(Collectors.toList());
+ }
+
+ /** 越权与不存在的共同回答口径:PARAM_ERROR + "答卷不存在或已删除" */
+ private void assertNotFoundShape(Runnable call, String answerId) {
+ BizException e = catchThrowableOfType(call::run, BizException.class);
+ assertThat(e.getErrorCode()).isEqualTo(ErrorCode.PARAM_ERROR);
+ assertThat(e).hasMessageContaining("答卷不存在或已删除");
+ assertThat(e).hasMessageContaining(answerId);
+ }
+}