From 12a8df4fefb6a90e624340452d377eb3117807fa Mon Sep 17 00:00:00 2001 From: chenxing Date: Thu, 15 Oct 2026 10:29:39 +0800 Subject: [PATCH] =?UTF-8?q?test(my):=20=E6=88=91=E7=9A=84=E5=A1=AB?= =?UTF-8?q?=E5=86=99=E5=85=AD=E7=AB=AF=E7=82=B9=E5=8D=95=E6=B5=8B=E9=97=AD?= =?UTF-8?q?=E7=8E=AF=20P70/P75=EF=BC=88my=20=E9=9D=A2=E5=AE=9E=E7=8E=B0?= =?UTF-8?q?=E9=9A=8F=E6=89=B9=E5=85=A5=E5=BA=93=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit MyAnswerIT 12 例(真库 @Transactional 回滚,沿 T3/T4 IT 惯例): - 身份全会话推导:六端点首参恒为 BoaUser、入参无 id 型身份位、请求体只有 answerId/values/summary;未登录一律 UNAUTHORIZED;跨校同 uuid 读不到(V17) - 越权无探测面:他人答卷与"不存在"同码同形(PARAM_ERROR + 同一句话,仅 uuid 不同); 非提交人改/删 FORBIDDEN;被代填人可读不可改(V29 的我的面形态) - 软删释放名额:本人删自己的行 → isDeleted=1、索引行清空、名额释放、可重填, 已删行在列表/两视图/详情四处置为不可见(V30/V32 我的面半边) - 两视图谓词分离:代填行只进操作人 submitList 与被代填人 aboutList, 自填行(subject=本人)两视图都在;零模块授权学生两视图照常(与数据管理面相反) - myDetail 归属:提交人与被代填人都可读,返回钉住版本 groups + 值文档,外层无库内自增 id(V5); 升版后老行仍按 v1 呈现与校验(P20) - V14 结构半边:本面无任何改状态端点(映射表断言死锁为六条) - AnswerReadService.myDetail javadoc 修正:读路径没有 editable 字段, 前端按 ItemView.submitterId 判 isMine,拒绝在 editBySubmitter/softDeleteBySubmitter(P75 附带项) 门禁:mvn -B -o clean test → Tests run: 190, Failures: 0, Errors: 0, Skipped: 0(原 178 + 本批 12) --- .../api/FormMyAnswerController.java | 118 +++++ .../com/yunhai/formcenter/model/MyModels.java | 48 ++ .../repository/AnswerRepository.java | 10 + .../formcenter/service/AnswerReadService.java | 244 +++++++++ .../yunhai/formcenter/service/MyAnswerIT.java | 500 ++++++++++++++++++ 5 files changed, 920 insertions(+) create mode 100644 src/main/java/com/yunhai/formcenter/controller/api/FormMyAnswerController.java create mode 100644 src/main/java/com/yunhai/formcenter/model/MyModels.java create mode 100644 src/test/java/com/yunhai/formcenter/service/MyAnswerIT.java 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)。 + * + *

纪律:

+ * + */ +@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); + } +}