test(my): 我的填写六端点单测闭环 P70/P75(my 面实现随批入库)

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)
This commit is contained in:
chenxing
2026-10-15 10:29:39 +08:00
parent a4b185b1bb
commit 12a8df4fef
5 changed files with 920 additions and 0 deletions
@@ -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)。
*
* <p>纪律:</p>
* <ul>
* <li><b>身份一律会话推导</b>:不接受外部传 submitterId/schId(V17);</li>
* <li><b>数据面身份只用 uuid</b>:answerId 是答卷 uuid,库内 id 不出现在任何外层(契约 §1/V5);</li>
* <li><b>权限闸门</b>:普通用户只能看/改/删<b>自己提交</b>的答卷(submitter_id=会话用户),
* "关于我的"只读(我是被代填人时可看不可改——改是填表人或管理员的动作);
* 有管理权者另有 /api/form/admin/data/**(T4 已落地,scope 矩阵生效),两套口径不混用;</li>
* <li><b>本控制器没有状态端点</b>:填表人改状态在接口层即不存在(V14 的实现形态)。</li>
* </ul>
*/
@Slf4j
@RestController
@RequestMapping("/api/form/my")
@RequiredArgsConstructor
public class FormMyAnswerController {
private final AnswerReadService readService;
/** 我的答卷列表(按 submitter 过滤,"我的提交"段) */
@GetMapping("/list")
public List<ReadModels.RowView> 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}=会话用户。
*
* <p>与 {@code /my/about} 是<b>两个端点、两个谓词</b>,不是一个端点加 view 参数
* (方案 §6.3 原文:"做成两个视图不做一个接口加参数(鉴权分支容易写漏)")。
* 这里<b>不看模块行、不看契约 §8 范围矩阵</b>——数据管理面那套范围管的是"管理员能看谁的行",
* 本面管的是"我自己的行",两套口径混用会让填表人不授权模块行就看不见自己的数据。</p>
*/
@GetMapping("/submit-list")
public List<MyModels.ItemView> submitList(BoaUser user) {
requireSession(user);
return readService.listMine(user, false);
}
/** "关于我的"视图({@code /my/about},T8、V18):跨表单、按 {@code subject_id}=会话用户(含他人代填) */
@GetMapping("/about-list")
public List<MyModels.ItemView> aboutList(BoaUser user) {
requireSession(user);
return readService.listMine(user, true);
}
/**
* 我的答卷详情(uuid 寻址):<b>钉住版本</b>的内联展开 schema + 值文档(形状与
* {@code /api/form/admin/data/detail} 同一个 {@link ReadModels.DetailView},渲染与校验同源)。
*
* <p>归属判定=我是提交人或我是被代填人,别人的答卷按"不存在"回答(不给探测面);
* 不在这里的动作:改状态(V14——本控制器没有任何状态端点,结构即证据)。</p>
*/
@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;
}
}
@@ -0,0 +1,48 @@
package com.yunhai.formcenter.model;
import lombok.Getter;
import lombok.Setter;
/**
* "我的填写"两视图({@code /my/submit} 与 {@code /my/about})的列表出参(方案 §6.3、任务书 T8)。
*
* <p><b>为什么不是 {@link ReadModels.RowView}</b>:那两个视图是<b>跨表单</b>的——
* 一个学生/老师的名下可能挂着好几张表单的答卷,行上必须带表单身份(名称/code/归属应用)才认得出是哪张表。
* {@code RowView} 是"单表单数据列表"的行形状(列由前端按该表单 schema 展),没有这两列。</p>
*
* <p><b>为什么不含 {@code values}</b>:跨表单列表没有统一列可展,值文档在这里没有展示位;
* 要看值走 {@code /api/form/my/detail}(钉住版本 + 值文档一起给,渲染与校验同源)。
* 少带一个 MEDIUMTEXT 列也与方案 §5.5 铁律①同向。</p>
*
* <p>对外身份仍然只有 uuid:{@code answerId}/{@code formUuid};库内 {@code id} 不外露(契约 §1、V5)。
* {@code submitterId}/{@code subjectId} 是<b>用户</b> id(平台全局身份,不是本库自增主键),
* 与数据管理面 {@code RowView} 同口径。</p>
*/
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;
}
}
@@ -57,4 +57,14 @@ public interface AnswerRepository extends JpaRepository<Answer, Long> {
+ "order by a.id asc")
List<Answer> findLiveByFormUuidAfterId(@Param("formUuid") String formUuid, @Param("cursor") Long cursor,
Pageable pageable);
/** 我的提交段(T7)/ "我提交的"视图(T8):按 submitter 过滤本人答卷,按 id 降序(最新在前) */
List<Answer> findBySchIdAndSubmitterIdOrderByIdDesc(Long schId, Integer submitterId);
/**
* "关于我的"视图(T8,V18):按 subject 过滤——别人<b>代我填</b>的答卷也在这里
* (方案 §6.3:谁提交与关于谁是两个角色,学生看到的是"关于我"的那几条,不限于自己填的)。
* 与上一条是两个方法而非一个方法加 flag(同视图侧"两个入口不做参数分支"的口径)。
*/
List<Answer> findBySchIdAndSubjectIdOrderByIdDesc(Long schId, Integer subjectId);
}
@@ -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;
* <p>三处纪律:列表走<b>两段式</b>(先取当页 id、再按 id 批量取值文档,铁律①);
* 翻页走 <b>keyset</b> 且游标对外是 uuid(铁律② + 契约 §1 id 不外露);
* 软删答卷在四条路径一律不可见(V30,谓词在构造器里)。</p>
*
* <p><b>末尾的"我的填写"段是另一条线,不要与上面四条混读</b>:那里的可见集来自
* {@code submitter_id}/{@code subject_id}=会话用户的<b>归属判定</b>,不是契约 §8 的范围矩阵
* (填表人不授权任何模块行也能看自己的数据,方案 §6.3"我的填写两层落点");
* 它也不接受 {@code schId} 外部传入,租户域同样只从会话推导(V17)。</p>
*/
@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<ReadModels.RowView> 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<Answer> answers = answerRepository.findBySchIdAndSubmitterIdOrderByIdDesc(schId, user.getId());
List<ReadModels.RowView> 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) ====================
/**
* 全局"我的填写"两视图的列表读取。<b>两个视图 = 两个谓词、两个入口方法</b>(方案 §6.3:
* "做成两个视图不做一个接口加参数"),鉴权分支不写在同一个方法体里靠 flag 分岔:
* {@code bySubject=false} 按 {@code submitter_id}=会话用户(我提交的),
* {@code bySubject=true} 按 {@code subject_id}=会话用户(关于我的,含管理员代填的那几条,V18)。
*
* <p>与数据管理面的区别:那条路径的范围来自契约 §8 矩阵({@link DataScopeService})且要先有
* {@code /admin/data} 模块行;这里<b>不看模块行、不看范围矩阵</b>,只看"这条数据是不是我的",
* 所以填表人(学生)不授权任何管理模块行也能用自己的两视图。</p>
*/
public List<MyModels.ItemView> 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<Answer> answers = bySubject
? answerRepository.findBySchIdAndSubjectIdOrderByIdDesc(schId, user.getId())
: answerRepository.findBySchIdAndSubmitterIdOrderByIdDesc(schId, user.getId());
// 表头信息按 form_uuid 去重取(跨表单列表,一页内同表单多行时不重复查头表)
Map<String, Form> forms = new HashMap<>();
List<MyModels.ItemView> 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 寻址):<b>钉住版本</b>的内联展开 schema + 值文档,
* 与 {@link #detail} 同一份形状(渲染与校验同源——两视图的编辑弹层直接按它出控件)。
* 归属判定=我是提交人 <em>或</em> 我是被代填人;后者只读——读路径上<b>没有</b> 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);
}
}
@@ -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;问题单 <b>P70/P75</b> 的单测缺口在此闭环)。
*
* <p><b>打控制器方法而不是只打服务</b>:六端点的身份闸门 {@code requireSession} 与"入参里没有身份位"
* 这两条纪律都落在控制器层,只测服务会把它们留在断言之外(P75 的教训正是"扩了面却没测面")。
* 这里不走 MockMvc——CAS 会话由 {@link BoaUser} 形参注入,直调方法就是线上的调用形状。</p>
*
* <p>五组断言按 P75 裁决逐条落地:①身份全会话推导(外部传 id 结构性不可能,V17)②越权无探测面
* ③软删释放名额(删后可重填)④两视图谓词分离(submitter/subject 各归各、互不串,V18)
* ⑤myDetail 归属(提交人或被代填人可读,他人按"不存在"答)。</p>
*/
@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<String> 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<Runnable> 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, "<id>"))
.isEqualTo(missing.getMessage().replace(bogus, "<id>"));
}
@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<ReadModels.RowView> 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<String> 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<String> 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<String> 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<String> 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<String> 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);
}
}