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:
@@ -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")
|
+ "order by a.id asc")
|
||||||
List<Answer> findLiveByFormUuidAfterId(@Param("formUuid") String formUuid, @Param("cursor") Long cursor,
|
List<Answer> findLiveByFormUuidAfterId(@Param("formUuid") String formUuid, @Param("cursor") Long cursor,
|
||||||
Pageable pageable);
|
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.fasterxml.jackson.databind.JsonNode;
|
||||||
import com.yunhai.formcenter.common.FormFilterOperators;
|
import com.yunhai.formcenter.common.FormFilterOperators;
|
||||||
|
import com.yunhai.formcenter.common.FormRuntimeRules;
|
||||||
import com.yunhai.formcenter.common.FormSchemaValidator;
|
import com.yunhai.formcenter.common.FormSchemaValidator;
|
||||||
import com.yunhai.formcenter.common.FormStatusDefs;
|
import com.yunhai.formcenter.common.FormStatusDefs;
|
||||||
|
import com.yunhai.formcenter.common.FormValueValidator;
|
||||||
import com.yunhai.formcenter.entity.Answer;
|
import com.yunhai.formcenter.entity.Answer;
|
||||||
|
import com.yunhai.formcenter.entity.AnswerOpLog;
|
||||||
import com.yunhai.formcenter.entity.Form;
|
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.model.ReadModels;
|
||||||
|
import com.yunhai.formcenter.repository.AnswerOpLogRepository;
|
||||||
import com.yunhai.formcenter.repository.AnswerRepository;
|
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.apistd.BizException;
|
||||||
import com.yunhai.platform.utils.exception.ErrorCode;
|
import com.yunhai.platform.utils.exception.ErrorCode;
|
||||||
import com.yunhai.platform.utils.model.BoaUser;
|
import com.yunhai.platform.utils.model.BoaUser;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
import java.time.LocalDateTime;
|
import java.time.LocalDateTime;
|
||||||
import java.time.format.DateTimeFormatter;
|
import java.time.format.DateTimeFormatter;
|
||||||
@@ -35,6 +44,11 @@ import java.util.Map;
|
|||||||
* <p>三处纪律:列表走<b>两段式</b>(先取当页 id、再按 id 批量取值文档,铁律①);
|
* <p>三处纪律:列表走<b>两段式</b>(先取当页 id、再按 id 批量取值文档,铁律①);
|
||||||
* 翻页走 <b>keyset</b> 且游标对外是 uuid(铁律② + 契约 §1 id 不外露);
|
* 翻页走 <b>keyset</b> 且游标对外是 uuid(铁律② + 契约 §1 id 不外露);
|
||||||
* 软删答卷在四条路径一律不可见(V30,谓词在构造器里)。</p>
|
* 软删答卷在四条路径一律不可见(V30,谓词在构造器里)。</p>
|
||||||
|
*
|
||||||
|
* <p><b>末尾的"我的填写"段是另一条线,不要与上面四条混读</b>:那里的可见集来自
|
||||||
|
* {@code submitter_id}/{@code subject_id}=会话用户的<b>归属判定</b>,不是契约 §8 的范围矩阵
|
||||||
|
* (填表人不授权任何模块行也能看自己的数据,方案 §6.3"我的填写两层落点");
|
||||||
|
* 它也不接受 {@code schId} 外部传入,租户域同样只从会话推导(V17)。</p>
|
||||||
*/
|
*/
|
||||||
@Slf4j
|
@Slf4j
|
||||||
@Service
|
@Service
|
||||||
@@ -48,6 +62,10 @@ public class AnswerReadService {
|
|||||||
private final DataScopeService scopeService;
|
private final DataScopeService scopeService;
|
||||||
private final AnswerScopeQueryBuilder queryBuilder;
|
private final AnswerScopeQueryBuilder queryBuilder;
|
||||||
private final AnswerRepository answerRepository;
|
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;
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user