ors = new ArrayList<>();
for (DataScope scope : scopes) {
- ors.add(scopeService.implementorOf(scope)
- .rowPredicate(cb, root, q.getUserId(), q.getViewerSchId()));
+ ors.add(scopeService.implementorOf(scope).rowPredicate(cb, root, q));
}
return cb.or(ors.toArray(new Predicate[0]));
}
diff --git a/src/main/java/com/yunhai/formcenter/service/AnswerService.java b/src/main/java/com/yunhai/formcenter/service/AnswerService.java
index f0c6026..7147334 100644
--- a/src/main/java/com/yunhai/formcenter/service/AnswerService.java
+++ b/src/main/java/com/yunhai/formcenter/service/AnswerService.java
@@ -25,23 +25,26 @@ import org.springframework.transaction.support.TransactionTemplate;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
+import java.util.Collections;
import java.util.Set;
import java.util.UUID;
/**
- * 提交链路(契约 §5 / 方案 §5.4)。一次提交在这条流水线上依次过关:
+ * 提交链路(契约 v2 §3.1,采集管线唯一=D16/§3.4:手工填报、代录、批量导入三者同走这条流水线)。
+ * 一次提交依次过关:
*
*
- * 钉版解析 → 第 0 关·填报入口层(P95) → 截止 → 幂等命中 → subject 授权+存在性同校(P33)
+ * 钉版解析 → audience 门 → 截止 → 幂等命中 → subject 授权(=ALL 档)+存在性同校(P33)
* → 值校验(按钉住版本) → 附件归属与 key 一致(P34) → 限次占位(fill_quota 撞键即拒)
* → 落 answer(写 form_uuid) → 写 answer_index(只写 searchable)
*
*
* 为什么顺序是这样:
*
- * - 先鉴权后业务(P95):入口层放在钉版之后(要 {@code form.audience} 才能判人群)、
- * 截止之前(过期表单对无资格的人不该输出"已过期"这种业务信息)。
- * 幂等重落在闸门之后,所以被闸门拒的人重放同样是 403——这是任务书 W2 明示可接受的取舍;
+ *
- 先鉴权后业务:audience 门放在钉版之后(要 {@code form.audience} 才能判人群)、
+ * 截止之前(过期表单对无资格的人不该输出"已过期"这种业务信息)。它取代 P95 的第 0 关
+ * 三层入口层(《契约v2》§19 作废-2)——资格从此只有一个问题:"这个人的身份类型在不在面向人群里";
+ * 幂等重落在闸门之后,所以被闸门拒的人重放同样是 403(沿 W2 明示可接受的取舍);
* 外层 {@code DataIntegrityViolationException} 的并发重放路径不再重复过闸(命中
* {@code belongsToCaller} 即返回原答卷);
* - 幂等检查必须在限次之前——重放的那一条本来就占着名额,先查限次会把双击重试误判成"超过次数";
@@ -52,11 +55,10 @@ import java.util.UUID;
* "软删条目仍放行"的口径也就无法表达(契约 §2.1)。
*
*
- * 第 0 关与后面两关的分工:{@link FillAccessService} 判"这个身份在这张表单上算哪一层"
- * (入口层,P95 起取代 {@code /f/{code}} 模块行授权);{@link #requireSubject} 判"subject 能不能指向
- * 别人"(ADMIN 沿 {@link FormAdminGuard#canManage} 现路径、MANAGE 走 P33 存在性);
- * {@link DataScopeService} 判"写进去之后谁能看见这行"(读矩阵,与本类无关)。
- * 三条线各自独立,改一条不动另一条。
+ * 三条线的分工(v2 后只剩两条半):audience 门(本类)判"能不能填这张表";
+ * {@link #requireSubject} 判"subject 能不能指向别人"=该表单 ALL 档({@link DataScopeService});
+ * 同一次解析的结果同时决定"写进去之后谁能看见这行"。代录与导入也走这里(§3.4/V60),
+ * 所以"代录的权限"与"手工填的权限"不可能有两套口径。
*
* 事务形态:公开方法不带 {@code @Transactional},写库段用 {@link TransactionTemplate} 包。
* 目的是让"幂等键撞 uk"这种并发结局能在事务外重新读取原答卷并按契约返回 200+原 answer_id
@@ -75,8 +77,7 @@ public class AnswerService {
private final FormDefService defService;
private final FormDictService dictService;
- private final FormAdminGuard adminGuard;
- private final FillAccessService fillAccess;
+ private final DataScopeService dataScope;
private final SubjectDirectory subjectDirectory;
private final AnswerRepository answerRepository;
private final FillQuotaRepository quotaRepository;
@@ -147,9 +148,9 @@ public class AnswerService {
Form form = published.form();
String schemaJson = published.versionRow().getSchemaJson();
- // 2) 第 0 关(P95 三层模型):填报入口层——钉版之后才有 audience 可判,截止之前先鉴权
- Set tiers = fillAccess.resolve(user, form);
- requireFillEntry(user, tiers, form, req.getSubjectId(), submitterId);
+ // 2) audience 门(契约 v2 §3.1):钉版之后才有 audience 可判,截止之前先鉴权。
+ // 只对自填生效(P115 裁决:§3.4 与 §5.1/V73 冲突点按"代录看 ALL 档、不看操作者人群"收敛)
+ requireAudienceGate(user, form, req.getSubjectId(), submitterId);
// 3) 截止(datetime 粒度,过期接口拒绝,契约 §5)
if (FormRuntimeRules.isDeadlineReached(form, LocalDateTime.now())) {
@@ -166,8 +167,9 @@ public class AnswerService {
return toView(existing, true);
}
- // 5) subject 授权(P95 行为表:谁能代填由第 0 关的层决定)+ 存在性/同校(P33)
- Integer subjectId = requireSubject(user, tiers, req.getSubjectId(), submitterId, schId);
+ // 5) subject 指定权(契约 v2 §3.1:=该表单 ALL 档持有者,原 canManage 代填路径收敛到此)
+ // + 存在性/同校(P33)
+ Integer subjectId = requireSubject(user, form, req.getSubjectId(), submitterId, schId);
// Bug#3:自填(未传 subjectId 或传了本人)落库时把 subject 归一为本人。
// 否则 subject_id=NULL 的自填行永远进不了"关于我的"(/my/about 与 ABOUT_ME 谓词都按 subject 查),
// V18"关于我(含代填)"实际退化成"仅代填"。限次键本就是 coalesce(subject, submitter),归一不改它。
@@ -215,44 +217,50 @@ public class AnswerService {
// ==================== 各环节 ====================
/**
- * 第 0 关(P95 W2 行为表):入口层判"这个身份在这张表单上到底能不能写"。
+ * audience 门(契约 v2 §3.1):自填资格=会话身份类型 ∈ 表单面向人群(∩ 状态门在后面几关)。
*
- * 两条拒绝各有独立文案,因为它们是两种不同的用户动作失败了:
- * NONE=压根不在面向人群里(家长/工作号/局领导、学生填教师问卷),直链进来也是 403;
- * 仅 MANAGE 的自填=人在人群口径里、但档位只给代填不给自填(教师×学生面向),
- * 文案要告诉他"改成代填学生就能提交",否则这条 403 无从下手。
+ * 代录不吃这道门(问题单 P115,用户 2026-10-20 裁决):§3.4 写"audience 门按代录
+ * 操作者会话判定",而 §5.1/V73 又要求管理员视图能"添加(代录)"、批量导入走同一管线——
+ * 校管理员(roleId 2/7)本身不在学生或教师人群里,按字面判会让代录对管理员整体失效。
+ * 两条承诺只能留一条,留的是功能可用那条:{@code subject} 指向他人时,操作者的身份
+ * 不代表填报人,资格由"是不是该表单 ALL 档持有者"决定(在 {@link #requireSubject} 判),
+ * 人群门留给自填。
+ *
+ * 拒绝文案必须可诊断(承接原第 0 关的文案纪律):带上表单 code、配置的 audience
+ * 集合、当前会话的身份要素——"人群配错了"与"这个人确实不在人群里"要能在日志里一眼分开。
*/
- private void requireFillEntry(BoaUser user, Set tiers, Form form,
- Integer subjectId, Integer submitterId) {
- if (tiers.isEmpty()) {
- throw new BizException(ErrorCode.FORBIDDEN, "无权填报该表单:表单「" + form.getCode() + "」面向"
- + audienceLabel(form) + ",当前身份 " + sessionIdentity(user) + " 不在可填报人群内"
- + "(可填=面向人群本人;教师可代填本校学生;管理员不受限)");
+ private void requireAudienceGate(BoaUser user, Form form, Integer subjectId, Integer submitterId) {
+ if (subjectId != null && !subjectId.equals(submitterId)) {
+ return;
}
- boolean proxy = subjectId != null && !subjectId.equals(submitterId);
- if (!proxy && !fillAccess.canFill(tiers)) {
- throw new BizException(ErrorCode.FORBIDDEN, "教师不能以本人身份填报学生面向表单,可代填本校学生"
- + "(subjectId 传被代填学生 id):表单「" + form.getCode() + "」面向" + audienceLabel(form));
+ Set identities = FormAccessPolicy.identityTypes(user);
+ Set audience = FormEnums.Audience.parse(form.getAudience());
+ if (!Collections.disjoint(identities, audience)) {
+ return;
}
+ throw new BizException(ErrorCode.FORBIDDEN, "无权填报该表单:表单「" + form.getCode() + "」面向人群="
+ + audience + "(" + FormEnums.Audience.labelOf(form.getAudience()) + "),当前身份 "
+ + sessionIdentity(user) + " 不在人群内(自填资格=身份类型 ∈ audience;代录请传 subjectId,"
+ + "由该表单 ALL 档把关)");
}
- private Integer requireSubject(BoaUser user, Set tiers, Integer subjectId,
+ /**
+ * subject 指定权(契约 v2 §3.1/G1):只有该表单 ALL 档持有者(创建人 ∪ form_admin ∪
+ * 校管理员 ∪ 超管)能把答卷挂到别人名下;其余会话 subject 只能=本人。
+ *
+ * 原 {@code FormAdminGuard#canManage}(授权模块行判"有没有代填权")随模块行退役收敛到这里
+ * ——数据职权与"能替谁填"从此是同一个事实源(V55 的写侧对应面)。P33 的存在性+同校复核不变。
+ */
+ private Integer requireSubject(BoaUser user, Form form, Integer subjectId,
Integer submitterId, Long schId) {
if (subjectId == null || subjectId.equals(submitterId)) {
return subjectId;
}
- if (fillAccess.isAdmin(tiers)) {
- // 层③的代填仍走 canManage 现路径(P95 红线 3"canManage 代填路径原样"):
- // 入口层把 ADMIN 判出来不等于给一条比现状更宽的写通道——没被授权管理模块行的人照旧拒
- if (!adminGuard.canManage(user)) {
- throw new BizException(ErrorCode.FORBIDDEN, forbiddenProxy(submitterId, subjectId));
- }
- } else if (!fillAccess.canProxy(tiers)) {
- // 层①普通成员(学生×学生面向、教师×教师面向)代填=拒,与 P95 之前的现行为逐字同文案
+ if (!dataScope.resolve(user, form).all()) {
throw new BizException(ErrorCode.FORBIDDEN, forbiddenProxy(submitterId, subjectId));
}
// P33:代填=把数据挂到别人名下,"关于我的"视图(V18)与按 subject 限次都建立在"id 真实且同校"上。
- // 本人自填不查(会话里的人本来就存在),只验代填那一个 id。层②与层③共用这一条(红线 3)。
+ // 本人自填不查(会话里的人本来就存在),只验代填那一个 id。
if (!subjectDirectory.existsInSchool(subjectId, schId)) {
throw new BizException(ErrorCode.PARAM_ERROR,
"被代填人不存在或不属于本校:subjectId=" + subjectId + ",schId=" + schId);
@@ -264,11 +272,6 @@ public class AnswerService {
return "无权为他人填报:subjectId 只能等于本人(当前 " + submitterId + ",请求 " + subjectId + ")";
}
- private static String audienceLabel(Form form) {
- return FormEnums.Audience.TEACHER.getCode() == (form.getAudience() == null
- ? FormEnums.Audience.STUDENT.getCode() : form.getAudience()) ? "教师" : "学生";
- }
-
/** 拒绝文案里的身份要素(会话 roleIds + 当前身份,便于运维一眼看出是人群配错还是身份不对) */
private static String sessionIdentity(BoaUser user) {
StringBuilder sb = new StringBuilder("id=").append(user.getId()).append(" roleIds=").append(user.getRoleIds());
diff --git a/src/main/java/com/yunhai/formcenter/service/DataScopeService.java b/src/main/java/com/yunhai/formcenter/service/DataScopeService.java
index c23b9b0..32f0d12 100644
--- a/src/main/java/com/yunhai/formcenter/service/DataScopeService.java
+++ b/src/main/java/com/yunhai/formcenter/service/DataScopeService.java
@@ -1,7 +1,6 @@
package com.yunhai.formcenter.service;
import com.yunhai.formcenter.entity.Form;
-import com.yunhai.formcenter.enums.FormEnums;
import com.yunhai.formcenter.enums.FormEnums.DataScope;
import com.yunhai.platform.apistd.BizException;
import com.yunhai.platform.utils.exception.ErrorCode;
@@ -10,41 +9,32 @@ import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
+import java.util.Collections;
import java.util.EnumSet;
-import java.util.LinkedHashSet;
import java.util.List;
import java.util.Set;
/**
- * 数据范围解析器(契约 §8 / 方案 §6.4 / 管理员体系方案 §5.1):把"会话用户 + 这张表单"解析成一组
- * 可见范围,交给 {@link AnswerScopeQueryBuilder} 落成 SQL 谓词。
+ * 数据范围解析器(契约 v2 §2.3 / D4):把"会话用户 + 这张表单"解析成两档可见集,
+ * 交给 {@link AnswerScopeQueryBuilder} 落成 SQL 谓词。
*
- * 解析规则(优先级):{@code 范围 = 管理员两表授权 ∪ 身份默认矩阵},
- * 命中"全部"即收敛为全部;全不命中回落仅本人提交(宁紧勿松)。
- *
- * 管理员两表来源(T10 起,取代已退役的 {@code customId} 权限锚点行):
- * {@code school_admin} 命中会话校、或 {@code form_admin} 命中这张表单时,按表单归属域升级——
- * 本校表单=全部(含本校快应用表单,{@code app_code} 不限),基础表单({@code sch_id=0})=
- * 本校提交(D2 裁决:跨校学生数据不因授权而可见)。他校表单不给任何升级。
- *
- * "多来源取最宽"在这里的实现是并集:一个用户可能同时有多个身份({@code roleIds} 里既有
- * 教师又有学生),逐格取默认值后并起来,读路径按 OR 判行——这比"只取一个最宽枚举值"更贴语义:
- * 学生视角(关于我)与教师视角(仅本人提交)不是包含关系,取单值会丢掉其中一格。
- *
- * 身份默认矩阵(契约 §8,行=roleId、列=表单面向人群):
*
- * 面向学生(1) 面向教师(2)
- * 7/2 全部 全部
- * 1 任教班级 → 一期实现器缺席=仅本人提交 仅本人提交
- * 3 关于我 仅本人提交(该格契约写"不授填报模块行,进不来",
- * 数据面按最保守的兜底值处理)
- * 4/6/5 仅本人提交 仅本人提交
+ * ALL =该表单全部行 持有者:创建人(C16)∪ form_admin ∪ 校管理员(∪ 超管)
+ * SELF =我提交的 ∪ 关于我的 一切 CAS 会话用户天然持有
*
*
- * 与 {@link FormAdminGuard} 是两条正交的口径:守卫答"能不能进这个管理面"(UserBase 授权模块行,
- * 外加 T10 的基础表单读开口),本类答"进来后能看哪些行"。两条线的事实源不同——模块行来自 UserBase
- * ({@link ModuleTreeSource}),管理员身份来自本库两表({@link AdminScopeSource}),管理员表不充当页面
- * 入口凭证(方案 §3 三层模型不变)。
+ * 三条与旧模型不同的地方,逐条对应《契约v2》§19 作废-1/5:
+ *
+ * - 身份默认矩阵整体下线——原"roleId × 面向人群"逐格取值、多来源取最宽、
+ * "全不命中回落仅本人提交"三段全部删除。回落分支之所以不需要:登录用户天然持有 SELF,
+ * 空集只可能出现在无会话场景(匿名通道不经本解析);
+ * - SCHOOL 不再是独立范围值——它变成 ALL 档内的租户限定变体({@link Resolved#platformScopedAll()}),
+ * 只对平台级表单({@code sch_id=0})生效;
+ * - 创建人自动 ALL(D6/G1)——运行时并集判定,不落 {@code form_admin} 行,两张表保持"纯授权"语义。
+ *
+ *
+ * fail-closed 三处不回退:未登录即拒;他校表单({@code sch_id} 既非会话校也非 0)不给 ALL;
+ * 租户限定变体给不出会话校即拒。存量 {@code submitter_sch_id=NULL} 不匹配等值谓词(P84)。
*/
@Slf4j
@Service
@@ -53,57 +43,69 @@ public class DataScopeService {
private final List implementors;
private final AdminScopeSource adminScopeSource;
+ private final FormAccessPolicy accessPolicy;
/**
- * 该会话用户在这张表单上的可见范围集合(至少一个,见类注释的回落规则)。
+ * 该会话用户在这张表单上的可见档集合。
*
- * @param form 目标表单({@code schId} 决定两表升级成哪一档、{@code audience} 决定用矩阵哪一列);
- * null 按面向学生列解析,且不产生两表升级(无表单域可判)
+ * @param form 目标表单({@code schId} 决定 ALL 是否走租户限定变体,{@code uuid}/{@code creatorId}
+ * 决定创建人与 form_admin 命中);null=没有表单域可判,只给 SELF
+ * @return 永不含空集、永不为 null;至少持有 SELF
*/
- public Set resolve(BoaUser user, Form form) {
+ public Resolved resolve(BoaUser user, Form form) {
if (user == null || user.getId() == null) {
throw new BizException(ErrorCode.UNAUTHORIZED, "未登录");
}
- if (FormAccessPolicy.isSuperAdmin(user)) {
- return EnumSet.of(DataScope.ALL);
+ if (form == null || form.getSchId() == null) {
+ return Resolved.selfOnly();
}
- Set scopes = new LinkedHashSet<>();
- // 1) 管理员两表授权(契约 §8 v1.5 优先级:管理员来源授权 > 身份默认)
- DataScope granted = grantedByAdminTables(user, form);
- if (granted != null) {
- scopes.add(granted);
+ // 超管与创建人不受域约束:平台表单的全量数据本来就归创建人(D9)
+ if (FormAccessPolicy.isSuperAdmin(user) || isCreator(user, form)) {
+ return Resolved.fullAll();
}
- // 2) 身份默认矩阵,roleId 逐个取默认后并起来(多来源取最宽)
- int audience = form == null || form.getAudience() == null
- ? FormEnums.Audience.STUDENT.getCode() : form.getAudience();
- for (Integer roleId : FormAccessPolicy.identityRoleIds(user)) {
- scopes.add(defaultScope(roleId, audience));
+ if (!adminScopeSource.isFormAdmin(form.getUuid(), user.getId()) && !accessPolicy.isSchoolAdmin(user)) {
+ return Resolved.selfOnly();
}
- if (scopes.isEmpty()) {
- scopes.add(DataScope.SELF_SUBMITTED);
- }
- if (scopes.contains(DataScope.ALL)) {
- // 命中"全部"即收敛:其余范围是它的真子集,并集里留着只会让读路径多写几个 OR
- return EnumSet.of(DataScope.ALL);
- }
- return EnumSet.copyOf(scopes);
+ // 两表命中后给哪种 ALL 形态由表单归属域决定(判据单点在 FormAccessPolicy,V58)
+ return allForDomain(form, FormAccessPolicy.sessionSchId(user));
}
/**
- * 一期可选的范围值(V28 的断言载体):{@code 任教班级} 的实现器 {@code selectable()=false},
- * T10 新增的 {@code SCHOOL} 同样 false——它不是筛选器选项,是授权结果(方案 §5.1 明示)。
+ * 授权命中后在这张表单上的 ALL 形态:本校表单=全量,平台级表单=租户限定变体,他校表单=不给。
+ *
+ * 平台级表单上不给全量是刻意的:§2.3 的变体只点名校管理员,但 D9/V57 的不变式是
+ * "平台表单全量归创建人(超管)、跨校学生数据不因授权而可见"——form_admin 授权同样落在一张跨校
+ * 资产上,给全量就等于开一个跨校读口子(T10 D2=B 加 {@code submitter_sch_id} 列的动因原样成立)。
+ * 该差额登记为问题单,等裁决而不是静默放宽。
*/
- public Set selectableScopes() {
- Set result = new LinkedHashSet<>();
- for (AnswerScopeImplementor implementor : implementors) {
- if (implementor.selectable()) {
- result.add(implementor.scope());
- }
+ private static Resolved allForDomain(Form form, Long ownSch) {
+ long formSch = form.getSchId();
+ if (ownSch != null && formSch == ownSch) {
+ return Resolved.fullAll();
}
- return result;
+ if (formSch == Form.PLATFORM_SCH_ID) {
+ return ownSch == null ? Resolved.selfOnly() : Resolved.platformScopedAllVariant();
+ }
+ // 他校表单:不给 ALL(跨校授权不成立;D9 的平台级例外只针对 sch_id=0)
+ return Resolved.selfOnly();
}
- /** 范围 → 实现器(读路径构造器按它取谓词;未注册即配置错误,直接抛出不静默放行) */
+ /**
+ * 创建人判定(C16):{@code creator_id}==会话 uid 或 {@code create_by}==会话账号名。
+ *
+ * 兜底那半不是历史包袱而是必需项:{@code create_by} 存的是账号名(String),与两表的 user_id
+ * 标识体系不同,存量行 {@code creator_id} 未回填时只有账号名可比。存量回填由一次性对照 UserBase
+ * 的脚本/人工完成(160 现存 form 行个位数),回填前后判定结果一致。
+ */
+ private static boolean isCreator(BoaUser user, Form form) {
+ if (form.getCreatorId() != null) {
+ return user.getId() != null && form.getCreatorId().longValue() == user.getId().longValue();
+ }
+ String account = FormAccessPolicy.sessionAccount();
+ return account != null && !account.trim().isEmpty() && account.trim().equals(form.getCreateBy());
+ }
+
+ /** 档 → 实现器(读路径构造器按它取谓词;未注册即配置错误,直接抛出不静默放行) */
public AnswerScopeImplementor implementorOf(DataScope scope) {
for (AnswerScopeImplementor implementor : implementors) {
if (implementor.scope() == scope) {
@@ -114,56 +116,55 @@ public class DataScopeService {
}
/**
- * 管理员两表在这张表单上应得的范围值(不给=null,交给矩阵)。
- *
- * 两张表的映射口径逐字相同(方案 §5.1 伪码里两段 if 是同一段逻辑),所以合成一次判定;
- * 若将来两表要分档(比如单表单管理员只给到本校提交),在这里分叉而不是在调用侧。
+ * 解析输出=二档布尔集(§2.3 原文形态),外加 ALL 是否走租户限定变体。
+ * {@link #scopes()} 是喂给查询规格与出参的枚举视图,两者同一份事实。
*/
- private DataScope grantedByAdminTables(BoaUser user, Form form) {
- if (form == null || form.getSchId() == null) {
- return null;
- }
- Long ownSch = FormAccessPolicy.sessionSchId(user);
- if (ownSch == null) {
- // 会话给不出学校:既没有"本校表单"可认,SCHOOL 也没有比对基准——不给任何升级
- return null;
- }
- boolean schoolAdmin = adminScopeSource.isSchoolAdmin(ownSch, user.getId());
- if (!schoolAdmin && !adminScopeSource.isFormAdmin(form.getUuid(), user.getId())) {
- return null;
- }
- long formSch = form.getSchId();
- if (formSch == ownSch) {
- return DataScope.ALL;
- }
- if (formSch == Form.PLATFORM_SCH_ID) {
- return DataScope.SCHOOL;
- }
- // 他校表单:两表都不覆盖(校级管理员的授权域=会话校,跨校不成立)
- return null;
- }
+ public static final class Resolved {
- /** 矩阵取格(任教班级在一期落到"仅本人提交",见类注释表格) */
- private DataScope defaultScope(Integer roleId, int audience) {
- boolean studentFacing = FormEnums.Audience.STUDENT.getCode() == audience;
- if (roleId == null) {
- return DataScope.SELF_SUBMITTED;
+ private static final Resolved SELF_ONLY = new Resolved(false, true, false);
+ private static final Resolved FULL_ALL = new Resolved(true, true, false);
+ private static final Resolved PLATFORM_SCOPED_ALL = new Resolved(true, true, true);
+
+ private final boolean all;
+ private final boolean self;
+ private final boolean platformScopedAll;
+
+ private Resolved(boolean all, boolean self, boolean platformScopedAll) {
+ this.all = all;
+ this.self = self;
+ this.platformScopedAll = platformScopedAll;
}
- if (roleId == FormAccessPolicy.ROLE_ADMIN || roleId == FormAccessPolicy.ROLE_SCHOOL_LEADER) {
- return DataScope.ALL;
+
+ static Resolved selfOnly() {
+ return SELF_ONLY;
}
- if (roleId == FormAccessPolicy.ROLE_TEACHER) {
- if (!studentFacing) {
- return DataScope.SELF_SUBMITTED;
+
+ static Resolved fullAll() {
+ return FULL_ALL;
+ }
+
+ static Resolved platformScopedAllVariant() {
+ return PLATFORM_SCOPED_ALL;
+ }
+
+ public boolean all() {
+ return all;
+ }
+
+ public boolean self() {
+ return self;
+ }
+
+ /** ALL 档是否收窄成"本校提交"(D9;仅 {@code sch_id=0} 的表单可能为 true) */
+ public boolean platformScopedAll() {
+ return platformScopedAll;
+ }
+
+ public Set scopes() {
+ if (all) {
+ return EnumSet.of(DataScope.ALL);
}
- // 面向学生:矩阵值=任教班级,一期实现器缺席 → 生效值=仅本人提交(方案 §6.4 表格明示,防被当 bug 报)
- log.debug("[数据范围] 教师×面向学生:任教班级一期未开放,按矩阵注记生效为仅本人提交");
- return DataScope.SELF_SUBMITTED;
+ return self ? Collections.singleton(DataScope.SELF) : Collections.emptySet();
}
- if (roleId == FormAccessPolicy.ROLE_STUDENT && studentFacing) {
- return DataScope.ABOUT_ME;
- }
- // 学生×面向教师、家长(4)/工作号(6)/局领导(5)、以及未知角色:兜底
- return DataScope.SELF_SUBMITTED;
}
}
diff --git a/src/main/java/com/yunhai/formcenter/service/FillAccessService.java b/src/main/java/com/yunhai/formcenter/service/FillAccessService.java
deleted file mode 100644
index f5324ae..0000000
--- a/src/main/java/com/yunhai/formcenter/service/FillAccessService.java
+++ /dev/null
@@ -1,147 +0,0 @@
-package com.yunhai.formcenter.service;
-
-import com.yunhai.formcenter.entity.Form;
-import com.yunhai.formcenter.enums.FormEnums;
-import com.yunhai.platform.apistd.BizException;
-import com.yunhai.platform.utils.exception.ErrorCode;
-import com.yunhai.platform.utils.model.BoaUser;
-import lombok.RequiredArgsConstructor;
-import org.springframework.stereotype.Service;
-
-import java.util.EnumSet;
-import java.util.List;
-import java.util.Set;
-
-/**
- * 填报入口层解析(P95 任务书 §0.1 三层角色模型):把"会话用户 + 这张表单"解析成一个层集合,
- * 回答"填报入口给不给这个人开、开到哪一档"。纯解析、无写路径、不碰任何一张表。
- *
- * 三条线的分工(互不越界,P95 红线 1/2):
- *
- * - UserBase 模块行管"能不能进页面"——{@code /admin/*} 四行是管理面入口凭证
- * ({@link FormAdminGuard},P80 口径"入口只认模块行,两表只升范围"一字不动)。
- * {@code /f/{code}} 填报模块行自 P95 起不再承担填报授权;
- * - 本类管"填报入口给谁开"(P95 新增)——人群由表单的 {@code audience} × 会话身份 ×
- * 管理员两表在中心侧自解析,不需要超管逐个表单配模块行;
- * - {@link DataScopeService} 管"进页面后能看哪些行"(读矩阵,一字不动)。
- *
- * 三者判定因子有重叠(都看 roleIds、都看两表),但结论互不供给:本类不给数据面放行,
- * 数据面也不给填报入口放行。
- *
- * 层与谓词(任务书 §0.1 表原样):
- *
- * ADMIN 超管(type==1) ∪ school_admin 命中会话校 ∪ form_admin 命中该表单 ∪ roleIds 含 2/7
- * MANAGE audience=学生 且 roleIds 含 1(教师)——可代填本校学生,数据面仍是"仅本人提交"
- * FILL (audience=学生 且 roleIds 含 3) ∪ (audience=教师 且 roleIds 含 1)
- * NONE 其余一切(家长4/局领导5/工作号6/未知角色、学生×教师面向、roleIds 全不命中)= 空集
- *
- *
- * 多身份=并集:{@code roleIds}(全部正常态角色)∪ 会话 {@code currentRole.roleId}
- * 逐层判定后合并(沿 {@link DataScopeService} 的 {@code identityRoleIds} 口径,防 UserBase 两侧不一致漏格)。
- * 一个层集合可以同时含多层(如教师×学生面向 = {@code {MANAGE}},校领导 = {@code {ADMIN}})。
- *
- * 与任教班级 stub 的关系:层②的数据面语义就是现状(矩阵该格生效=仅本人提交),
- * 本类只是把"教师可以往本校学生名下写数据"这件事从模块行授权收回到中心解析,
- * {@code DataScopeService} 的注释与取值一概不动。
- */
-@Service
-@RequiredArgsConstructor
-public class FillAccessService {
-
- private final AdminScopeSource adminScopeSource;
-
- /** 填报入口层({@link #resolve} 的元素;未命中任何层=空集,语义即任务书里的 NONE) */
- public enum Tier {
- /** 层③:全部管理 + 可填 + 可代填 */
- ADMIN,
- /** 层②:教师×学生面向,可代填本校学生(数据面维持仅本人提交) */
- MANAGE,
- /** 层①:本人身份可填 */
- FILL
- }
-
- /**
- * 解析该会话用户在这张表单上的层集合。
- *
- * @param form 目标表单({@code audience} 决定用哪一列,{@code uuid} 决定 form_admin 命中);
- * null 按面向学生列解析,且不产生 form_admin 命中(无表单可判),
- * 与 {@link DataScopeService#resolve} 的 null 口径同形
- * @return 层集合;空集=NONE(没有任何入口)。永不为 null
- */
- public Set resolve(BoaUser user, Form form) {
- if (user == null || user.getId() == null) {
- throw new BizException(ErrorCode.UNAUTHORIZED, "未登录");
- }
- Set tiers = EnumSet.noneOf(Tier.class);
- List roleIds = FormAccessPolicy.identityRoleIds(user);
- if (FormAccessPolicy.isSuperAdmin(user) || grantedByAdminTables(user, form) || holdsAdminRole(roleIds)) {
- tiers.add(Tier.ADMIN);
- }
- boolean studentFacing = FormEnums.Audience.STUDENT.getCode()
- == (form == null || form.getAudience() == null
- ? FormEnums.Audience.STUDENT.getCode() : form.getAudience());
- if (studentFacing && roleIds.contains(FormAccessPolicy.ROLE_TEACHER)) {
- tiers.add(Tier.MANAGE);
- }
- if (studentFacing ? roleIds.contains(FormAccessPolicy.ROLE_STUDENT)
- : roleIds.contains(FormAccessPolicy.ROLE_TEACHER)) {
- tiers.add(Tier.FILL);
- }
- return tiers;
- }
-
- /** 层①∪层③:能否以本人身份填报(层②只有代填,自填不在这里——由调用侧按行为表拒绝并给文案) */
- public boolean canFill(Set tiers) {
- return tiers != null && (tiers.contains(Tier.FILL) || tiers.contains(Tier.ADMIN));
- }
-
- /** 层②∪层③:能否代填他人(真正能不能落到某个人身上还要过 P33 的 existsInSchool) */
- public boolean canProxy(Set tiers) {
- return tiers != null && (tiers.contains(Tier.MANAGE) || tiers.contains(Tier.ADMIN));
- }
-
- /** 层③:管理员层命中(超管 / 两表 / 校领导(2) / 管理员(7)) */
- public boolean isAdmin(Set tiers) {
- return tiers != null && tiers.contains(Tier.ADMIN);
- }
-
- /** 并集里的最高层(序 ADMIN > MANAGE > FILL;空集返回 null=NONE),供列表的 {@code myTier} 用 */
- public Tier topTier(Set tiers) {
- if (tiers == null || tiers.isEmpty()) {
- return null;
- }
- for (Tier candidate : Tier.values()) {
- if (tiers.contains(candidate)) {
- return candidate;
- }
- }
- return null;
- }
-
- /**
- * 管理员两表是否把这个人抬进层③:{@code school_admin} 命中会话校、
- * 或 {@code form_admin} 命中这张表单(口径与 {@link DataScopeService} 读侧同源,
- * 但结论只用在入口层,不改数据范围)。
- *
- * 与读侧的差别只有一处,且是有意的:两表在这里不看表单归属域。
- * 读侧要区分"本校表单→全部 / 基础表单→本校提交",而入口层问的是"这个人是不是管理员",
- * 基础表单的管理员同样是管理员;校域不变式({@code answer.sch_id}=会话校)也不因代填而变。
- */
- private boolean grantedByAdminTables(BoaUser user, Form form) {
- Long ownSch = FormAccessPolicy.sessionSchId(user);
- if (ownSch != null && adminScopeSource.isSchoolAdmin(ownSch, user.getId())) {
- return true;
- }
- return form != null && adminScopeSource.isFormAdmin(form.getUuid(), user.getId());
- }
-
- /** 校领导(2)/管理员(7):平台标准角色里的管理身份(清单单点在 {@link FormAccessPolicy#SCHOOL_LEADER_ROLES}) */
- private static boolean holdsAdminRole(List roleIds) {
- for (Integer roleId : roleIds) {
- if (roleId != null && FormAccessPolicy.SCHOOL_LEADER_ROLES.contains(roleId)) {
- return true;
- }
- }
- return false;
- }
-}
diff --git a/src/main/java/com/yunhai/formcenter/service/FillableFormService.java b/src/main/java/com/yunhai/formcenter/service/FillableFormService.java
index ac6f516..af344d4 100644
--- a/src/main/java/com/yunhai/formcenter/service/FillableFormService.java
+++ b/src/main/java/com/yunhai/formcenter/service/FillableFormService.java
@@ -1,6 +1,7 @@
package com.yunhai.formcenter.service;
import com.yunhai.formcenter.entity.Form;
+import com.yunhai.formcenter.enums.FormEnums;
import com.yunhai.formcenter.model.MyModels;
import com.yunhai.formcenter.repository.FormRepository;
import com.yunhai.platform.utils.model.BoaUser;
@@ -20,8 +21,8 @@ import java.util.Map;
import java.util.Set;
/**
- * 「我可填」清单解析(P95 W3,{@code GET /api/form/my/fillable}):把"这个会话用户能进哪些表单的填报页"
- * 算成一张列表——P91 那个列表的数据源,从此不再吃 UserBase 模块树。
+ * 「我可填」清单解析({@code GET /api/form/my/fillable},契约 v2 C2 行形状):
+ * 把"这个会话用户能进哪些表单的填报页"算成一张列表——数据源不再是 UserBase 模块树。
*
* 四步,一步不多:
*
@@ -29,17 +30,18 @@ import java.util.Set;
* - 遮蔽去重:同 {@code (app_code, code)} 本校与平台级都在时本校胜出且不回落——
* 直接复用 {@link FormDefService#pickByShadowing},与 resolve 同一条 U5 实现(P20 的取舍:
* 两处口径必须同源,否则列表里出现的表单点进去可能解析成另一张);
- * - 逐行过入口层:{@link FillAccessService#resolve} 为空集(NONE)的行不入集;非空即入集,
- * {@code canFill} 与 {@code myTier} 一起下发——层②(教师×面向学生)的行 {@code canFill=false},
- * 前端「可填写」列表把它筛掉,但"数据管理"tab 的候选要靠它,所以不能只下发一个布尔;
+ * - 逐行过 audience 门:登录身份类型 ∩ {@code form.audience}=∅ 的行不入集。
+ * 这一条与 {@link AnswerService} 提交时的 audience 门同一个公式——
+ * "列表里有"与"提交能过"不可能不一致(原 P95 的 {@code canFill}/{@code myTier} 随之消失:
+ * 入集即等价于可填,不再有"进了列表但不能填"的中间态,§19 作废-3);
* - 排序:deadline 升序、null(不限)在后、同档按 code。
*
*
- * 不分页:一所学校 + 平台级里可填的表单是个位数到几十张(P95 W3 的量级假设,报告已记),
+ *
不分页:一所学校 + 平台级里可填的表单是个位数到几十张(量级假设,报告已记),
* 一张轻列表不值得引入游标;返回值就是裸数组,不带 total——将来真长大了加分页时不破坏"行形状"这条契约。
*
- * 与数据面的关系:本类只回答"入口给谁开",不看 {@link DataScopeService}。
- * 列表里出现某张表单,不代表进去能看到别人的行(层②教师看到的仍是仅本人提交)。
+ * {@code ANONYMOUS} 不参与入集:匿名填报人不看列表,持 URL 直达(C2)。
+ * 本类也不看数据范围({@link DataScopeService}):列表里出现某张表单,不代表进去能看到别人的行。
*/
@Slf4j
@Service
@@ -49,7 +51,6 @@ public class FillableFormService {
private static final DateTimeFormatter TS = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
private final FormRepository formRepository;
- private final FillAccessService fillAccess;
/**
* 我可填清单。
@@ -100,23 +101,26 @@ public class FillableFormService {
.thenComparing(form -> form.getCode() == null ? "" : form.getCode()));
List rows = new ArrayList<>();
+ Set identities = FormAccessPolicy.identityTypes(user);
for (Form form : visible) {
- Set tiers = fillAccess.resolve(user, form);
- if (tiers.isEmpty()) {
+ // 入集判定=audience 门的列表形态(§3.1 同一公式,不留两套口径):身份类型 ∩ 面向人群非空
+ if (Collections.disjoint(identities, FormEnums.Audience.parse(form.getAudience()))) {
continue;
}
- MyModels.FillableRow row = new MyModels.FillableRow();
- row.setCode(form.getCode());
- row.setName(form.getName());
- row.setAppCode(form.getAppCode());
- row.setAudience(form.getAudience());
- row.setDeadline(form.getDeadline() == null ? null : form.getDeadline().format(TS));
- row.setFillLimit(form.getFillLimit());
- row.setGroupTag(form.getGroupTag());
- row.setCanFill(fillAccess.canFill(tiers));
- row.setMyTier(fillAccess.topTier(tiers).name());
- rows.add(row);
+ rows.add(toRow(form));
}
return rows;
}
+
+ private static MyModels.FillableRow toRow(Form form) {
+ MyModels.FillableRow row = new MyModels.FillableRow();
+ row.setCode(form.getCode());
+ row.setName(form.getName());
+ row.setAppCode(form.getAppCode());
+ row.setAudience(FormEnums.Audience.namesOf(form.getAudience()));
+ row.setDeadline(form.getDeadline() == null ? null : form.getDeadline().format(TS));
+ row.setFillLimit(form.getFillLimit());
+ row.setGroupTag(form.getGroupTag());
+ return row;
+ }
}
diff --git a/src/main/java/com/yunhai/formcenter/service/FormAccessPolicy.java b/src/main/java/com/yunhai/formcenter/service/FormAccessPolicy.java
index 0c231b2..8617e2b 100644
--- a/src/main/java/com/yunhai/formcenter/service/FormAccessPolicy.java
+++ b/src/main/java/com/yunhai/formcenter/service/FormAccessPolicy.java
@@ -2,6 +2,8 @@ package com.yunhai.formcenter.service;
import com.yunhai.formcenter.enums.FormEnums;
import com.yunhai.platform.apistd.BizException;
+import com.yunhai.platform.auth.LoginUser;
+import com.yunhai.platform.auth.UserContextHolder;
import com.yunhai.platform.utils.exception.ErrorCode;
import com.yunhai.platform.utils.model.BoaUser;
import lombok.RequiredArgsConstructor;
@@ -82,6 +84,19 @@ public class FormAccessPolicy {
return user == null ? null : user.getId();
}
+ /**
+ * 会话账号名——与 {@code create_by}/{@code update_by} 同一个事实源
+ * ({@code FormCenterAuditListener} 落列时取的就是它),所以"创建人兜底判定"(C16)
+ * 比的是同一条字符串,不是两个系统里恰好相等的两个字段。
+ *
+ * {@code BoaUser} 上另有一个 {@code account},但它由 UserBase 组织接口下发、与 token 侧
+ * 账号名不是同一处写入;拿它比 {@code create_by} 会引入"两边口径不同就静默判不出创建人"的隐患。
+ */
+ public static String sessionAccount() {
+ LoginUser user = UserContextHolder.get();
+ return user == null ? null : user.getLoginName();
+ }
+
/** 超管(UserBase 用户类型判定;不是 roleId) */
public static boolean isSuperAdmin(BoaUser user) {
return user != null && user.getType() != null && user.getType() == SUPER_ADMIN_TYPE;
diff --git a/src/main/java/com/yunhai/formcenter/service/FormDefService.java b/src/main/java/com/yunhai/formcenter/service/FormDefService.java
index 663b409..42ed83a 100644
--- a/src/main/java/com/yunhai/formcenter/service/FormDefService.java
+++ b/src/main/java/com/yunhai/formcenter/service/FormDefService.java
@@ -23,9 +23,11 @@ import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
+import java.util.EnumSet;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
+import java.util.Set;
import java.util.UUID;
import java.util.regex.Pattern;
@@ -284,9 +286,14 @@ public class FormDefService {
// ==================== 写:草稿/发布/生命周期 ====================
- /** 建草稿:uuid 当场生成、跨版本不变(契约 §1) */
+ /**
+ * 建草稿:uuid 当场生成、跨版本不变(契约 §1)。
+ *
+ * @param creatorId 会话用户 id(C16:创建人自动持有该表单 ALL 档,G1"创建人保留定义权"的载体);
+ * {@code create_by} 存的是账号名、与两表 user_id 标识体系不同,故另建一列归一
+ */
@Transactional
- public FormModels.FormView create(Long schId, FormModels.FormCreateRequest req) {
+ public FormModels.FormView create(Long schId, FormModels.FormCreateRequest req, Long creatorId) {
String cleanApp = normalizeAppCode(req.getAppCode());
String cleanCode = requireCode(req.getCode());
String cleanName = requireName(req.getName());
@@ -304,6 +311,7 @@ public class FormDefService {
form.setAppCode(cleanApp);
form.setCode(cleanCode);
form.setName(cleanName);
+ form.setCreatorId(creatorId);
form.setStatus(FormEnums.FormStatus.DRAFT.getCode());
form.setCurrentVersion(null);
try {
@@ -423,22 +431,81 @@ public class FormDefService {
formRepository.save(form);
}
- /** 表单级配置(不进版本快照);deadline 只给日期时补当日 23:59:59(方案 §5.4) */
+ /**
+ * 表单级配置(不进版本快照);deadline 只给日期时补当日 23:59:59(方案 §5.4)。
+ *
+ * 契约 v2 新增两条配置面纪律:① {@code audience} 改字符串数组入参(C1,闭集三值),
+ * ② 含 {@code ANONYMOUS} 的表单不许配限次(§3.2/作废-11:匿名无 limit_key,cookie 防重是唯一节流)——
+ * 这条跨字段约束必须在配置通道挡下来,不能等提交时才发现"限次对匿名不生效"。
+ *
+ * 先算合并态、再全部验完、最后才写实体:跨字段约束看的是"这次改完长什么样"
+ * (入参 + 现值合并),但如果在校验前就改实体,被拒的那一次调用会在同一持久化上下文里留下
+ * 脏值(回滚型测试事务尤其明显),等于把"配置被拒"伪装成"配置已生效"。
+ */
@Transactional
public FormModels.FormView updateConfig(Long schId, FormModels.FormConfigRequest req) {
Form form = requireForm(schId, req.getUuid());
+
+ // ---- 1) 合并态解析(不碰实体) ----
+ Set audience = FormEnums.Audience.parse(form.getAudience());
if (req.getAudience() != null) {
- if (!FormEnums.Audience.valid(req.getAudience())) {
- throw new BizException(ErrorCode.PARAM_ERROR, "audience 只能是 1(面向学生)或 2(面向教师)");
+ if (req.getAudience().isEmpty()) {
+ throw new BizException(ErrorCode.PARAM_ERROR,
+ "audience 不能是空数组(闭集 STUDENT/TEACHER/ANONYMOUS,至少一项)");
}
- form.setAudience(req.getAudience());
+ Set parsed = EnumSet.noneOf(FormEnums.Audience.class);
+ for (String name : req.getAudience()) {
+ FormEnums.Audience item = FormEnums.Audience.of(name);
+ if (item == null) {
+ throw new BizException(ErrorCode.PARAM_ERROR,
+ "audience 只能是 STUDENT/TEACHER/ANONYMOUS:" + name);
+ }
+ parsed.add(item);
+ }
+ audience = parsed;
}
+ Integer fillLimit = form.getFillLimit();
if (req.getFillLimit() != null) {
if (req.getFillLimit() != 0 && req.getFillLimit() != 1) {
throw new BizException(ErrorCode.PARAM_ERROR, "fillLimit 只能是 0(不限)或 1(限一次)");
}
- form.setFillLimit(req.getFillLimit());
+ fillLimit = req.getFillLimit();
}
+ Integer frozen = form.getFrozen();
+ if (req.getFrozen() != null) {
+ if (req.getFrozen() != 0 && req.getFrozen() != 1) {
+ throw new BizException(ErrorCode.PARAM_ERROR, "frozen 只能是 0(可编辑)或 1(提交后禁编辑)");
+ }
+ frozen = req.getFrozen();
+ }
+ Integer anonMinutes = form.getAnonCookieMinutes();
+ if (req.getAnonCookieMinutes() != null) {
+ // 防重窗口是"分钟"粒度的节流参数,1..1440(一天)够覆盖;不给 0/负数是因为那等于关掉防重
+ if (req.getAnonCookieMinutes() < 1 || req.getAnonCookieMinutes() > 1440) {
+ throw new BizException(ErrorCode.PARAM_ERROR, "anonCookieMinutes 需在 1~1440 之间(默认 10)");
+ }
+ anonMinutes = req.getAnonCookieMinutes();
+ }
+ String groupTag = form.getGroupTag();
+ if (req.getGroupTag() != null) {
+ String tag = req.getGroupTag().trim();
+ if (tag.length() > 64) {
+ throw new BizException(ErrorCode.PARAM_ERROR, "groupTag 过长(≤64)");
+ }
+ groupTag = tag.isEmpty() ? null : tag;
+ }
+ // §3.2/作废-11:匿名表单不支持限次(无身份无 limit_key)——按合并态判,两个方向的改法都挡得住
+ if (audience.contains(FormEnums.Audience.ANONYMOUS) && FormEnums.FillLimit.once(fillLimit)) {
+ throw new BizException(ErrorCode.CUSTOM,
+ "面向人群含匿名(ANONYMOUS)时不支持限次:fillLimit 必须为 0(匿名无身份、cookie 防重是唯一节流)");
+ }
+
+ // ---- 2) 校验全过,落到实体 ----
+ form.setAudience(FormEnums.Audience.join(audience));
+ form.setFillLimit(fillLimit);
+ form.setFrozen(frozen);
+ form.setAnonCookieMinutes(anonMinutes);
+ form.setGroupTag(groupTag);
if (req.getDeadline() != null) {
form.setDeadline(FormRuntimeRules.parseDeadline(req.getDeadline()));
}
@@ -457,13 +524,6 @@ public class FormDefService {
}
form.setAnswerNoEnabled(req.getAnswerNoEnabled());
}
- if (req.getGroupTag() != null) {
- String tag = req.getGroupTag().trim();
- if (tag.length() > 64) {
- throw new BizException(ErrorCode.PARAM_ERROR, "groupTag 过长(≤64)");
- }
- form.setGroupTag(tag.isEmpty() ? null : tag);
- }
formRepository.save(form);
return toView(form, draftVersion(form.getId()));
}
@@ -491,7 +551,7 @@ public class FormDefService {
* 源表单及其历史答卷零改动——本方法对源表只读。
*/
@Transactional
- public FormModels.FormView copy(Long schId, FormModels.FormCopyRequest req) {
+ public FormModels.FormView copy(Long schId, FormModels.FormCopyRequest req, Long creatorId) {
Form source = requireForm(schId, req.getUuid());
FormVersion sourceRow = copySourceVersion(source);
String cleanApp = req.getAppCode() == null || req.getAppCode().trim().isEmpty()
@@ -506,6 +566,8 @@ public class FormDefService {
copy.setAppCode(cleanApp);
copy.setCode(cleanCode);
copy.setName(cleanName);
+ // 副本的创建人=点"另存为副本"的人,不是源表单的创建人(C16/G1:定义权跟着"建这张表的人")
+ copy.setCreatorId(creatorId);
copy.setStatus(FormEnums.FormStatus.DRAFT.getCode());
copy.setCurrentVersion(null);
// 表单级配置随副本(同校复制的目的就是"基于旧表单改一张"),但收集窗口不继承:
@@ -514,6 +576,8 @@ public class FormDefService {
copy.setFillLimit(source.getFillLimit());
copy.setStatusDefs(source.getStatusDefs());
copy.setAnswerNoEnabled(source.getAnswerNoEnabled());
+ copy.setFrozen(source.getFrozen());
+ copy.setAnonCookieMinutes(source.getAnonCookieMinutes());
copy.setGroupTag(source.getGroupTag());
copy.setDeadline(null);
try {
@@ -728,10 +792,12 @@ public class FormDefService {
view.setStatusName(FormEnums.FormStatus.nameOf(form.getStatus()));
view.setCurrentVersion(form.getCurrentVersion());
view.setDraftVersion(draftVersion);
- view.setAudience(form.getAudience());
+ view.setAudience(FormEnums.Audience.namesOf(form.getAudience()));
view.setFillLimit(form.getFillLimit());
view.setDeadline(form.getDeadline() == null ? null : TS.format(form.getDeadline()));
view.setAnswerNoEnabled(form.getAnswerNoEnabled());
+ view.setFrozen(form.getFrozen());
+ view.setAnonCookieMinutes(form.getAnonCookieMinutes());
view.setGroupTag(form.getGroupTag());
view.setStatusDefs(toStatusDefViews(form.getStatusDefs()));
view.setCreateTime(form.getCreateTime() == null ? null : TS.format(form.getCreateTime()));
@@ -759,7 +825,7 @@ public class FormDefService {
view.setAppCode(form.getAppCode());
view.setName(form.getName());
view.setVersion(row.getVersion());
- view.setAudience(form.getAudience());
+ view.setAudience(FormEnums.Audience.namesOf(form.getAudience()));
view.setFillLimit(form.getFillLimit());
view.setAnswerNoEnabled(form.getAnswerNoEnabled());
view.setDeadline(form.getDeadline() == null ? null : TS.format(form.getDeadline()));
diff --git a/src/main/java/com/yunhai/formcenter/service/OapiFormService.java b/src/main/java/com/yunhai/formcenter/service/OapiFormService.java
index 8ae5977..009733e 100644
--- a/src/main/java/com/yunhai/formcenter/service/OapiFormService.java
+++ b/src/main/java/com/yunhai/formcenter/service/OapiFormService.java
@@ -83,7 +83,7 @@ public class OapiFormService {
view.setVersion(published.versionRow().getVersion());
view.setCode(form.getCode());
view.setAppCode(form.getAppCode());
- view.setAudience(FormEnums.Audience.nameOf(form.getAudience()));
+ view.setAudience(FormEnums.Audience.namesOf(form.getAudience()));
view.setGroups(root.path("groups"));
return view;
}
@@ -144,7 +144,7 @@ public class OapiFormService {
view.setVersion(published.versionRow().getVersion());
view.setCode(form.getCode());
view.setAppCode(form.getAppCode());
- view.setAudience(FormEnums.Audience.nameOf(form.getAudience()));
+ view.setAudience(FormEnums.Audience.namesOf(form.getAudience()));
view.setGroups(root.path("groups"));
return view;
}
diff --git a/src/main/java/com/yunhai/formcenter/service/SchoolScopeImplementor.java b/src/main/java/com/yunhai/formcenter/service/SchoolScopeImplementor.java
deleted file mode 100644
index 65fbb79..0000000
--- a/src/main/java/com/yunhai/formcenter/service/SchoolScopeImplementor.java
+++ /dev/null
@@ -1,45 +0,0 @@
-package com.yunhai.formcenter.service;
-
-import com.yunhai.formcenter.entity.Answer;
-import com.yunhai.formcenter.enums.FormEnums.DataScope;
-import com.yunhai.platform.apistd.BizException;
-import com.yunhai.platform.utils.exception.ErrorCode;
-import org.springframework.stereotype.Component;
-
-import javax.persistence.criteria.CriteriaBuilder;
-import javax.persistence.criteria.Predicate;
-import javax.persistence.criteria.Root;
-
-/**
- * 范围实现器:本校提交({@code submitter_sch_id}=会话学校,T10 方案 §5.1 / 契约 §8 v1.5)。
- *
- * 它是管理员两表在基础表单(sch_id=0)上的落地形态:校级/单表单管理员都不因授权而看见
- * 他校学生数据(D2=B 裁决加列的动因)。给不出会话学校时明确拒绝,不静默降成"无学校条件"——
- * 那等于把本校范围读成全平台。
- *
- * {@link #selectable()} 为 false 的理由与任教班级同族但相反:任教班级是"能力未开放",
- * 本值是"能力已开放但不是一个筛选选项"——它是授权结果,出现在筛选器里等于让调用方自己声明
- * 一个未经授权的范围(V28 断言的就是这个"不入 selectableScopes")。
- */
-@Component
-public class SchoolScopeImplementor implements AnswerScopeImplementor {
-
- @Override
- public DataScope scope() {
- return DataScope.SCHOOL;
- }
-
- @Override
- public boolean selectable() {
- return false;
- }
-
- @Override
- public Predicate rowPredicate(CriteriaBuilder cb, Root root, Integer userId, Long viewerSchId) {
- if (viewerSchId == null) {
- throw new BizException(ErrorCode.FORBIDDEN, "数据范围 本校提交 需要会话学校身份");
- }
- // NULL 存量行天然不匹配等值谓词(fail-closed,方案 §4 存量条款),不需要额外的 is null 分支
- return cb.equal(root.get("submitterSchId"), viewerSchId);
- }
-}
diff --git a/src/main/java/com/yunhai/formcenter/service/SelfScopeImplementor.java b/src/main/java/com/yunhai/formcenter/service/SelfScopeImplementor.java
new file mode 100644
index 0000000..9f657f3
--- /dev/null
+++ b/src/main/java/com/yunhai/formcenter/service/SelfScopeImplementor.java
@@ -0,0 +1,37 @@
+package com.yunhai.formcenter.service;
+
+import com.yunhai.formcenter.entity.Answer;
+import com.yunhai.formcenter.enums.FormEnums.DataScope;
+import com.yunhai.formcenter.model.ReadModels;
+import org.springframework.stereotype.Component;
+
+import javax.persistence.criteria.CriteriaBuilder;
+import javax.persistence.criteria.Predicate;
+import javax.persistence.criteria.Root;
+
+/**
+ * 范围实现器:SELF=我提交的 ∪ 关于我的(契约 v2 §2.3)。
+ *
+ * 原 {@code SELF_SUBMITTED}(按 {@code submitter_id})与 {@code ABOUT_ME}(按 {@code subject_id})
+ * 两档合并成一档:§2.3 把 SELF 定义成这两个视角的并集,读路径本就按 OR 判行,
+ * 拆两档只是原身份矩阵的产物(多身份并集里有"只有学生身份才拿到关于我"这种绕路)。
+ * 合并后"我填的"与"别人替我填的"一次命中(V18 语义不回退——{@code /my/about} 那两条视图
+ * 仍按单列查,见 {@code AnswerReadService#listMine},那是归属判定、不是范围矩阵)。
+ *
+ * 走 {@code idx_answer_submitter(sch_id, submitter_id)} 与 {@code idx_answer_subject(subject_id)}。
+ */
+@Component
+public class SelfScopeImplementor implements AnswerScopeImplementor {
+
+ @Override
+ public DataScope scope() {
+ return DataScope.SELF;
+ }
+
+ @Override
+ public Predicate rowPredicate(CriteriaBuilder cb, Root root, ReadModels.Query q) {
+ return cb.or(
+ cb.equal(root.get("submitterId"), q.getUserId()),
+ cb.equal(root.get("subjectId"), q.getUserId()));
+ }
+}
diff --git a/src/main/java/com/yunhai/formcenter/service/SelfSubmittedScopeImplementor.java b/src/main/java/com/yunhai/formcenter/service/SelfSubmittedScopeImplementor.java
deleted file mode 100644
index b2cff62..0000000
--- a/src/main/java/com/yunhai/formcenter/service/SelfSubmittedScopeImplementor.java
+++ /dev/null
@@ -1,35 +0,0 @@
-package com.yunhai.formcenter.service;
-
-import com.yunhai.formcenter.entity.Answer;
-import com.yunhai.formcenter.enums.FormEnums.DataScope;
-import org.springframework.stereotype.Component;
-
-import javax.persistence.criteria.CriteriaBuilder;
-import javax.persistence.criteria.Predicate;
-import javax.persistence.criteria.Root;
-
-/**
- * 范围实现器:仅本人提交({@code submitter_id}=会话用户)。
- *
- * 这是契约 §8 的兜底值:"全不命中=仅本人提交(宁紧勿松)",也是教师×面向学生表单在 1.5
- * 之前的实际生效值(任教班级实现器缺席,方案 §6.4 表格里写明了要防"教师怎么只看到自己"被当 bug 报)。
- * 走 {@code idx_answer_submitter(sch_id, submitter_id)}。
- */
-@Component
-public class SelfSubmittedScopeImplementor implements AnswerScopeImplementor {
-
- @Override
- public DataScope scope() {
- return DataScope.SELF_SUBMITTED;
- }
-
- @Override
- public boolean selectable() {
- return true;
- }
-
- @Override
- public Predicate rowPredicate(CriteriaBuilder cb, Root root, Integer userId, Long viewerSchId) {
- return cb.equal(root.get("submitterId"), userId);
- }
-}
diff --git a/src/main/java/com/yunhai/formcenter/service/TeachingClassScopeImplementor.java b/src/main/java/com/yunhai/formcenter/service/TeachingClassScopeImplementor.java
deleted file mode 100644
index e66b8df..0000000
--- a/src/main/java/com/yunhai/formcenter/service/TeachingClassScopeImplementor.java
+++ /dev/null
@@ -1,42 +0,0 @@
-package com.yunhai.formcenter.service;
-
-import com.yunhai.formcenter.entity.Answer;
-import com.yunhai.formcenter.enums.FormEnums.DataScope;
-import com.yunhai.platform.apistd.BizException;
-import com.yunhai.platform.utils.exception.ErrorCode;
-import org.springframework.stereotype.Component;
-
-import javax.persistence.criteria.CriteriaBuilder;
-import javax.persistence.criteria.Predicate;
-import javax.persistence.criteria.Root;
-
-/**
- * 范围实现器:任教班级——1.5 占位(契约 §8 / 方案 §6.4)。
- *
- * 存在意义就是把 V28 的两半钉在代码里:枚举值在(一期契约即含四值,定了就不变),
- * 但 {@link #selectable()} 为 false,{@code DataScopeService} 因此永远不会把它放进解析结果,
- * 教师×面向学生表单在一期按矩阵注明的"实际生效=仅本人提交"落地。
- *
- * 1.5 开放时的改动面就在本类:接 UserClass(原 BschClass)的任教关系/班级 SDK,把
- * {@code rowPredicate} 换成"submitter 的任教班级 ∩ 答卷班级列"或按 subject 的班级归属判定,
- * {@code selectable()} 翻成 true——读路径与管理动作不用改(它们只认 {@link AnswerScopeQueryBuilder})。
- */
-@Component
-public class TeachingClassScopeImplementor implements AnswerScopeImplementor {
-
- @Override
- public DataScope scope() {
- return DataScope.TEACHING_CLASS;
- }
-
- @Override
- public boolean selectable() {
- return false;
- }
-
- @Override
- public Predicate rowPredicate(CriteriaBuilder cb, Root root, Integer userId, Long viewerSchId) {
- // 防御性:一期解析不会产生该范围(selectable=false),走到这里说明有人绕过了矩阵解析
- throw new BizException(ErrorCode.CUSTOM, "数据范围 任教班级 一期未开放(1.5 依赖 UserClass 任教关系)");
- }
-}
diff --git a/src/test/java/com/yunhai/formcenter/controller/oapi/OapiFormControllerTest.java b/src/test/java/com/yunhai/formcenter/controller/oapi/OapiFormControllerTest.java
index 2151246..c25eac0 100644
--- a/src/test/java/com/yunhai/formcenter/controller/oapi/OapiFormControllerTest.java
+++ b/src/test/java/com/yunhai/formcenter/controller/oapi/OapiFormControllerTest.java
@@ -102,7 +102,7 @@ class OapiFormControllerTest {
view.setVersion(version);
view.setCode("honor_student");
view.setAppCode("form-center");
- view.setAudience("STUDENT");
+ view.setAudience(java.util.Collections.singletonList("STUDENT"));
try {
view.setGroups(FormSchemaValidator.mapper()
.readTree("[{\"key\":\"base\",\"label\":\"基本信息\",\"fields\":[]}]"));
diff --git a/src/test/java/com/yunhai/formcenter/service/AdminScopeIT.java b/src/test/java/com/yunhai/formcenter/service/AdminScopeIT.java
index 5edacd8..095c448 100644
--- a/src/test/java/com/yunhai/formcenter/service/AdminScopeIT.java
+++ b/src/test/java/com/yunhai/formcenter/service/AdminScopeIT.java
@@ -26,19 +26,19 @@ import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
/**
- * 管理员两表的数据范围集成测试(真 MySQL;自验项 V43 / V44 / V47)。
+ * 管理员两表的数据范围集成测试(真 MySQL;契约 v2 §2.3 下承继 V43/V44/V47 的行集断言)。
*
- * 取证重点与 {@code DataScopeServiceTest} 互补:单测证"解析出哪个集合",这里证
- * 集合落成的 SQL 谓词真的少读了行——包括 SCHOOL 谓词把 {@code submitter_sch_id} 为 NULL 的
- * 存量行挡在外面(方案 §4 的 fail-closed 半边),以及导出与列表同集(V27 红线的 T10 延伸)。
+ * 取证重点与 {@code DataScopeServiceTest} 互补:单测证"解析出哪一档",这里证
+ * 档落成的 SQL 谓词真的少读了行——包括 ALL 档租户限定变体把 {@code submitter_sch_id} 为 NULL 的
+ * 存量行挡在外面(P84 的 fail-closed 半边,不回退),以及导出与列表同集(V27 红线的延伸)。
*
- * 数据集刻意含"基础表单(sch_id=0)+ 两校各提交一条":这是 {@code SCHOOL} 唯一能被单独分辨出来的
- * 形状(本校表单上升级出的是 ALL,与租户域同向;只有平台域表单的读开口才需要"本校提交"这一档)。
+ * 数据集刻意含"基础表单(sch_id=0)+ 两校各提交一条":这是租户限定变体唯一能被单独分辨出来的
+ * 形状(本校表单上 ALL 就是全量;只有平台域表单的读开口需要收窄成本校提交行)。
*/
@SpringBootTest
@Import(AnswerTestSupport.TestGatewayConfiguration.class)
@Transactional
-@DisplayName("管理员两表范围 - 本校/基础表单/SCHOOL 谓词(V43/V44/V47)")
+@DisplayName("管理员两表范围 - 本校/平台级表单/ALL 档租户限定变体(V43/V44/V47)")
class AdminScopeIT extends AnswerTestSupport {
@Autowired
@@ -58,8 +58,6 @@ class AdminScopeIT extends AnswerTestSupport {
private static final String QUICK_CODE = "t10_quick_drill";
/** 快应用面(方案 §6.2):中心之外的 app_code,用来证"本校快应用表单也被校级管理员覆盖" */
private static final String QUICK_APP = "fc-drill";
- /** 教师代填的被代填人(P95 起教师造不出"自填行",只能代填;这个 id 不参与任何"关于我"断言) */
- private static final int PROXY_SUBJECT = 20264777;
private static final String SCHEMA = "{\"groups\":[{\"key\":\"base\",\"label\":\"基本信息\",\"fields\":["
+ "{\"key\":\"name\",\"label\":\"姓名\",\"type\":\"text\",\"required\":true,\"searchable\":true}]}]}";
@@ -95,16 +93,17 @@ class AdminScopeIT extends AnswerTestSupport {
assertThat(schoolWide).as("他校提交不可见(D2=B)").doesNotContain(seed.baseAtOtherSchool);
assertThat(schoolWide).as("submitter_sch_id 为 NULL 的存量行不可见(fail-closed)")
.doesNotContain(seed.baseLegacyNull);
- assertThat(scopesOf(seed.baseUuid, teacher, SCH)).contains(DataScope.SCHOOL)
- .doesNotContain(DataScope.ALL);
+ assertThat(scopesOf(seed.baseUuid, teacher, SCH)).as("平台级表单上的校管理员=ALL 档的租户限定变体,"
+ + "档位标签仍是 ALL(收窄事实由上面三行行集证明,不是靠标签证明)")
+ .containsExactly(DataScope.ALL);
}
@Test
- @DisplayName("V43 未命中:不进两表时矩阵默认一格不变(升级只加不减)")
+ @DisplayName("V43 未命中:不进两表时只有 SELF(我提交的 ∪ 关于我的)")
void withoutAdminRowMatrixIsUnchanged() {
Seed seed = seed();
BoaUser student = user(ReadFixture.STUDENT_1, SCH, 3);
- assertThat(scopesOf(seed.ownUuid, student, SCH)).containsExactly(DataScope.ABOUT_ME);
+ assertThat(scopesOf(seed.ownUuid, student, SCH)).containsExactly(DataScope.SELF);
// Bug#3 起自填行落库即 subject=本人,该生自己那条现在就在"关于我"里(此前 subject 为空、不在)
assertThat(visible(seed.ownUuid, student, SCH)).containsExactly(seed.ownByStudent);
}
@@ -134,12 +133,12 @@ class AdminScopeIT extends AnswerTestSupport {
.as("指定表单升级为全部")
.contains(seed.ownByStudent, seed.ownByTeacher);
assertThat(visible(seed.baseUuid, teacher, SCH))
- .as("未被指定的基础表单不受影响(教师×面向学生的矩阵格=仅本人提交,而他没提交过)")
+ .as("未被指定的基础表单只剩 SELF(教师本人没在这张表提交过)")
.isEmpty();
grantFormAdmin(seed.baseUuid, ReadFixture.TEACHER_2, Form.PLATFORM_SCH_ID);
Set onBase = visible(seed.baseUuid, teacher, SCH);
- assertThat(onBase).as("基础表单上=本校提交(不是全部)")
+ assertThat(onBase).as("基础表单上=form_admin 也给全量以外的形态:本校提交行(不是全部)")
.contains(seed.baseByStudent, seed.baseByTeacher);
assertThat(onBase).doesNotContain(seed.baseAtOtherSchool, seed.baseLegacyNull);
}
@@ -203,31 +202,39 @@ class AdminScopeIT extends AnswerTestSupport {
seed.baseUuid = publishForm(Form.PLATFORM_SCH_ID, BASE_CODE, SCHEMA);
seed.ownUuid = publishForm(SCH, OWN_CODE, SCHEMA);
seed.quickUuid = publishQuickForm(QUICK_APP, QUICK_CODE);
+ // 三张表都给"学生+教师"共集人群(C1):本类要的是"教师提交过的行"这个事实,
+ // 而 v2 里教师不能自填纯学生面向的表、也没有代填权(他们刻意不是 ALL 档,见 submitByTeacher)
+ config(Form.PLATFORM_SCH_ID, seed.baseUuid, bothAudience());
+ config(SCH, seed.ownUuid, bothAudience());
+ config(SCH, seed.quickUuid, bothAudience());
seed.baseByStudent = submit(user(ReadFixture.STUDENT_1, SCH, 3), BASE_CODE, "本校学生甲");
- seed.baseByTeacher = submitByTeacherProxy(user(ReadFixture.TEACHER_1, SCH, 1), BASE_CODE, "本校教师乙");
+ seed.baseByTeacher = submitByTeacher(user(ReadFixture.TEACHER_1, SCH, 1), BASE_CODE, "本校教师乙");
seed.baseAtOtherSchool = submit(user(ReadFixture.STUDENT_2, OTHER_SCH, 3), BASE_CODE, "他校学生丙");
seed.baseLegacyNull = insertLegacyAnswer(seed.baseUuid, "存量没有学校列的行");
seed.ownByStudent = submit(user(ReadFixture.STUDENT_1, SCH, 3), OWN_CODE, "自己学校的行");
- seed.ownByTeacher = submitByTeacherProxy(user(ReadFixture.TEACHER_1, SCH, 1), OWN_CODE, "老师填的行");
+ seed.ownByTeacher = submitByTeacher(user(ReadFixture.TEACHER_1, SCH, 1), OWN_CODE, "老师填的行");
seed.quickByStudent = submitQuick(user(ReadFixture.STUDENT_1, SCH, 3), "快应用的行");
return seed;
}
/**
- * P95 第 0 关之后,教师×面向学生表单只能代填本校学生(W2 行为表层②那一行),
- * 以本人身份自填会被拒。本类要的是"教师提交过的行"这个事实(submitter=教师),
- * 所以造它就走代填路径——读侧矩阵断言(仅本人提交=submitter 谓词)吃的还是同一个谓词。
+ * 教师以本人身份提交的那条({@code submitter=subject=教师})。
*
- * 被代填人刻意用 {@code PROXY_SUBJECT} 这个新 id:它不出现在任何"关于我"断言里,
- * 换 subject 不会把学生视角的可见集改大(V43"未命中=矩阵默认一格不变"依赖这点)。
+ * P95 时代这条要靠"教师×面向学生表单只能代填"(层②)造;三层模型出清后,代录只对 ALL 档开放,
+ * 而本数据集刻意不给教师 ALL 档——否则"教师只见自己那行"的范围断言就没有对照物了。
+ * 于是把表单人群扩成含 TEACHER,教师自填,读侧 SELF 档吃的仍是 {@code submitter} 谓词。
*/
- private String submitByTeacherProxy(BoaUser teacher, String code, String name) {
- AnswerModels.SubmitRequest request = submit(code, "{\"name\":\"" + name + "\"}");
- request.setSubjectId(PROXY_SUBJECT);
- return answerService.submit(teacher, request).getAnswerId();
+ private String submitByTeacher(BoaUser teacher, String code, String name) {
+ return submit(teacher, code, name);
+ }
+
+ private static FormModels.FormConfigRequest bothAudience() {
+ FormModels.FormConfigRequest config = new FormModels.FormConfigRequest();
+ config.setAudience(java.util.Arrays.asList("STUDENT", "TEACHER"));
+ return config;
}
/** 造一条 T10 之前的存量行(submitter_sch_id=NULL):新链路的提交一定会落值,只有直接入库才出得来 */
@@ -266,7 +273,7 @@ class AdminScopeIT extends AnswerTestSupport {
create.setAppCode(appCode);
create.setCode(code);
create.setName("快应用 " + code);
- String uuid = defService.create(SCH, create).getFormUuid();
+ String uuid = defService.create(SCH, create, null).getFormUuid();
FormModels.FormSaveRequest save = new FormModels.FormSaveRequest();
save.setUuid(uuid);
save.setSchemaJson(SCHEMA);
diff --git a/src/test/java/com/yunhai/formcenter/service/AnswerExportIT.java b/src/test/java/com/yunhai/formcenter/service/AnswerExportIT.java
index e315b6c..aa4392d 100644
--- a/src/test/java/com/yunhai/formcenter/service/AnswerExportIT.java
+++ b/src/test/java/com/yunhai/formcenter/service/AnswerExportIT.java
@@ -2,6 +2,7 @@ package com.yunhai.formcenter.service;
import com.yunhai.formcenter.entity.FormFile;
import com.yunhai.formcenter.model.AnswerModels;
+import com.yunhai.formcenter.model.FormModels;
import com.yunhai.formcenter.model.ReadModels;
import com.yunhai.formcenter.repository.FormFileRepository;
import com.yunhai.platform.apistd.BizException;
@@ -60,7 +61,7 @@ class AnswerExportIT extends AnswerTestSupport {
@DisplayName("V37:行数=该范围下的总数(非列表当页);列序=身份列 + schema 展示序")
void exportCountEqualsScopeTotal() {
BoaUser admin = manager();
- String formUuid = seed(admin, 25);
+ String formUuid = seed(25);
ReadModels.QueryRequest req = new ReadModels.QueryRequest();
req.setFormUuid(formUuid);
@@ -85,7 +86,7 @@ class AnswerExportIT extends AnswerTestSupport {
@DisplayName("V37 内容正确性:选项列显 label、多选按 options 定义序(不按勾选序)、附件列显 fileName")
void optionAndAttachmentCellsAreRendered() {
BoaUser admin = manager();
- String formUuid = seed(admin, 1);
+ String formUuid = seed(1);
ByteArrayOutputStream out = new ByteArrayOutputStream();
exportService.export(admin, SCH, request(formUuid), out);
@@ -102,7 +103,7 @@ class AnswerExportIT extends AnswerTestSupport {
@DisplayName("V37/V27:受限身份导出 ⊆ 其可见集——教师只导得出自己那几条,且与列表可见 uuid 集合完全一致")
void exportIsScopeBoundPerIdentity() {
BoaUser admin = manager();
- String formUuid = seed(admin, 6);
+ String formUuid = seed(6);
BoaUser teacher = user(ReadFixture.TEACHER_1, SCH, 1);
ByteArrayOutputStream teacherOut = new ByteArrayOutputStream();
@@ -130,7 +131,7 @@ class AnswerExportIT extends AnswerTestSupport {
@DisplayName("导出同样吃筛选条件(单表单+可选筛选);条件不合法与列表一样被拒,不静默导全量")
void exportHonoursFiltersAndRejectsBadOnes() {
BoaUser admin = manager();
- String formUuid = seed(admin, 6);
+ String formUuid = seed(6);
ReadModels.QueryRequest filtered = request(formUuid);
ReadModels.FilterItem grade = new ReadModels.FilterItem();
@@ -154,16 +155,20 @@ class AnswerExportIT extends AnswerTestSupport {
// ==================== 数据准备 ====================
/**
- * 建表单并发布,管理员身份写 {@code total} 条答卷,其中前 3 条由教师1 代填提交
+ * 建表单并发布(人群=学生+教师共集),写 {@code total} 条答卷,其中前 3 条由教师1 本人提交
* (范围用例的"他可见集"=submitter=教师1 的那三条)。
*
- * P95 第 0 关之后,教师×面向学生表单以本人身份自填会被拒(行为表层②),所以这三条走代填路径:
- * 谓词列还是 {@code submitter_id},"仅本人提交"的可见集与导出行数断言一字未改。
+ * P95 时代这三条走"教师代填学生"(层②);三层模型出清后教师不再是任何代填档,
+ * 于是把人群扩成含 TEACHER、由教师自填——谓词列还是 {@code submitter_id},
+ * "SELF 档可见集=submitter ∪ subject"与导出行数断言一字未改。
*
* @return 表单 uuid
*/
- private String seed(BoaUser admin, int total) {
+ private String seed(int total) {
String formUuid = publishForm(FORM, SCHEMA);
+ FormModels.FormConfigRequest bothAudience = new FormModels.FormConfigRequest();
+ bothAudience.setAudience(java.util.Arrays.asList("STUDENT", "TEACHER"));
+ config(SCH, formUuid, bothAudience);
FormFile file = new FormFile();
file.setSchId(SCH);
file.setFileName("奖状.jpg");
@@ -175,6 +180,7 @@ class AnswerExportIT extends AnswerTestSupport {
file = fileRepository.save(file);
BoaUser teacher = user(ReadFixture.TEACHER_1, SCH, 1);
+ BoaUser student = user(ReadFixture.STUDENT_1, SCH, 3);
for (int i = 0; i < total; i++) {
String grade = i % 2 == 0 ? "g2" : "g3";
String values = "{\"name\":\"学生" + i + "\",\"grade\":\"" + grade + "\","
@@ -182,11 +188,9 @@ class AnswerExportIT extends AnswerTestSupport {
+ "\"cert\":[{\"id\":" + file.getId() + ",\"fileName\":\"" + file.getFileName()
+ "\",\"key\":\"" + file.getStoreKey() + "\"}]}";
AnswerModels.SubmitRequest request = submit(FORM, values);
- if (i < 3) {
- // P95:教师在这一列人群上只有代填档,代填一个不参与任何"关于我"断言的 id
- request.setSubjectId(ReadFixture.TEACHER_PROXY_1);
- }
- answerService.submit(i < 3 ? teacher : admin, request);
+ // 前 3 条 submitter=教师、其余 submitter=学生:两者都是自填(人群含 STUDENT+TEACHER)。
+ // 操作人不再用管理员本人——v2 里管理员身份不在人群,自填会被 audience 门拒(P115 只豁免代录)
+ answerService.submit(i < 3 ? teacher : student, request);
}
return formUuid;
}
diff --git a/src/test/java/com/yunhai/formcenter/service/AnswerFillGateIT.java b/src/test/java/com/yunhai/formcenter/service/AnswerFillGateIT.java
index 882123e..1bdd23a 100644
--- a/src/test/java/com/yunhai/formcenter/service/AnswerFillGateIT.java
+++ b/src/test/java/com/yunhai/formcenter/service/AnswerFillGateIT.java
@@ -1,6 +1,7 @@
package com.yunhai.formcenter.service;
import com.yunhai.formcenter.entity.Answer;
+import com.yunhai.formcenter.enums.FormEnums;
import com.yunhai.formcenter.model.AnswerModels;
import com.yunhai.formcenter.model.FormModels;
import com.yunhai.platform.apistd.BizException;
@@ -12,22 +13,30 @@ import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.context.annotation.Import;
import org.springframework.transaction.annotation.Transactional;
+import java.util.Arrays;
+import java.util.Collections;
+
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
/**
- * P95 第 0 关与教师代填(自验项 V49):按任务书 W2 行为表逐行打真库
- * ({@code @Transactional} 回滚,沿 {@code AnswerSubmitIT} 先例)。
+ * audience 门与 subject 指定权(契约 v2 §3.1,验收 V66;取代 P95 的 V49 第 0 关 IT——
+ * 三层入口层随《契约v2》§19 作废-2 出清,本类按 v2 语义重写,并逐格钉住新旧行为差异)。
*
- * 行为表三档 × 自填/代填 = 六个格子,加上"代填仍受 P33 管"和"闸门排在截止与幂等之前"两条顺序断言。
- * 桩只顶掉 {@link SubjectDirectory}(UserBase 的人)与 {@link ModuleTreeSource}(模块授权树),
- * 管理员两表用真行({@link #grantSchoolAdmin}/{@link #grantFormAdmin})——入口层与读侧共用同一个事实源,
- * 用桩顶掉就等于没验接线。
+ * 打真库({@code @Transactional} 回滚,沿 {@code AnswerSubmitIT} 先例)。两条断言主线:
+ *
+ * - 资格只有一个问题:会话身份类型 ∈ {@code form.audience}。原层①/②/③/NONE 的四格放行表
+ * 不再存在,"人在人群里但只给代填不给自填"这种中间态也一并消失;
+ * - subject 指定权=该表单 ALL 档(§3.1/G1):原 {@code canManage}(授权模块行)判据退役,
+ * "进了 form_admin 能不能代填"因此翻了个向——这是本批最大的一处行为变化,单独一格钉住。
+ *
+ *
+ * 闸门在截止与幂等之前的次序、限次/值校验/钉版等既有环节,都在本类一并复验(不回退)。
*/
@SpringBootTest
@Import(AnswerTestSupport.TestGatewayConfiguration.class)
@Transactional
-@DisplayName("AnswerService - P95 第 0 关与教师代填(V49)")
+@DisplayName("AnswerService - audience 门与 ALL 档代填(V66)")
class AnswerFillGateIT extends AnswerTestSupport {
private static final String SCHEMA = "{\"groups\":[{\"key\":\"base\",\"label\":\"基本信息\",\"fields\":["
@@ -41,12 +50,12 @@ class AnswerFillGateIT extends AnswerTestSupport {
private static final int TEACHER = 20265002;
private static final int CLASSMATE = 20265003;
- // ==================== 层① FILL:本人可填、代填仍拒 ====================
+ // ==================== audience 门:身份 ∈ 集合 ====================
@Test
- @DisplayName("V49 层①:学生×面向学生——自填放行;代填他人=拒(现行为文案逐字保留)")
- void studentSelfFillPassesProxyRejected() {
- String code = "p95_fill_self";
+ @DisplayName("V66:学生×[STUDENT] 自填放行;代填他人仍拒(subject 只能=本人,无 ALL 档)")
+ void studentOnStudentFacingFillsButCannotProxy() {
+ String code = "v66_student_self";
publishForm(code, SCHEMA);
assertThat(answerService.submit(user(STUDENT, SCH, 3), submit(code, VALUES)).getAnswerId()).isNotBlank();
@@ -59,66 +68,143 @@ class AnswerFillGateIT extends AnswerTestSupport {
}
@Test
- @DisplayName("V49 层①:教师×面向教师=自填放行(audience 换列后同一个 roleId 落在 FILL)")
- void teacherOnTeacherFacingFills() {
- String code = "p95_fill_teacher";
- String uuid = publishForm(code, SCHEMA);
- FormModels.FormConfigRequest teacherFacing = new FormModels.FormConfigRequest();
- teacherFacing.setAudience(2);
- config(SCH, uuid, teacherFacing);
-
- assertThat(answerService.submit(user(TEACHER, SCH, 1), submit(code, VALUES)).getAnswerId()).isNotBlank();
- }
-
- // ==================== 层② MANAGE:只能代填 ====================
-
- @Test
- @DisplayName("V49 层②:教师×面向学生以本人身份自填被拒,文案明示'不能以本人身份填报…可代填本校学生'")
- void teacherCannotSelfFillStudentFacing() {
- String code = "p95_manage_self";
+ @DisplayName("V66:教师×[STUDENT] 自填被拒——原'教师可代填学生面向'的 MANAGE 层已出清(§19 作废-2 对照格)")
+ void teacherOnStudentFacingIsRejected() {
+ String code = "v66_teacher_student_facing";
publishForm(code, SCHEMA);
assertThatThrownBy(() -> answerService.submit(user(TEACHER, SCH, 1), submit(code, VALUES)))
.isInstanceOfSatisfying(BizException.class, e -> {
assertThat(e.getErrorCode()).isEqualTo(ErrorCode.FORBIDDEN);
+ // 文案承接"可诊断"纪律:表单 code + audience 集合 + 会话身份要素
assertThat(e.getMessage())
- .contains("教师不能以本人身份填报学生面向表单,可代填本校学生")
- .contains(code);
+ .contains(code)
+ .contains("STUDENT")
+ .contains("id=" + TEACHER);
});
}
@Test
- @DisplayName("V49 层②:教师×面向学生代填本校学生=放行(P95 净新增能力),且落值满足校域不变式")
- void teacherProxyForSchoolStudentPasses() {
- String code = "p95_manage_proxy";
+ @DisplayName("V66 核心:audience 多值共集——[STUDENT,TEACHER] 时学生与教师都放行(D8 多选,非布尔补丁)")
+ void multiValueAudienceAdmitsBothIdentities() {
+ String code = "v66_multi_audience";
String uuid = publishForm(code, SCHEMA);
+ config(SCH, uuid, audience(Arrays.asList("STUDENT", "TEACHER")));
+
+ assertThat(answerService.submit(user(STUDENT, SCH, 3), submit(code, VALUES)).getAnswerId()).isNotBlank();
+ assertThat(answerService.submit(user(TEACHER, SCH, 1), submit(code, VALUES)).getAnswerId()).isNotBlank();
+ // 家长(4) 不在集合里 → 照旧拒
+ assertThatThrownBy(() -> answerService.submit(user(20265104, SCH, 4), submit(code, VALUES)))
+ .isInstanceOf(BizException.class).hasMessageContaining("无权填报该表单");
+ }
+
+ @Test
+ @DisplayName("V66:学生×[TEACHER]=拒;多身份并集(roleIds 含 1 与 3)×[STUDENT]=放行")
+ void studentOnTeacherFacingRejectedAndMultiIdentityAdmitted() {
+ String code = "v66_teacher_only";
+ String uuid = publishForm(code, SCHEMA);
+ config(SCH, uuid, audience(Collections.singletonList("TEACHER")));
+
+ assertThatThrownBy(() -> answerService.submit(user(STUDENT, SCH, 3), submit(code, VALUES)))
+ .isInstanceOfSatisfying(BizException.class, e -> {
+ assertThat(e.getErrorCode()).isEqualTo(ErrorCode.FORBIDDEN);
+ assertThat(e.getMessage()).contains("TEACHER").contains(code);
+ });
+
+ String both = "v66_dual_identity";
+ String bothUuid = publishForm(both, SCHEMA);
+ config(SCH, bothUuid, audience(Collections.singletonList("STUDENT")));
+ BoaUser dual = user(20265005, SCH, 3);
+ dual.setRoleIds(Arrays.asList(3, 1));
+ assertThat(answerService.submit(dual, submit(both, VALUES)).getAnswerId()).isNotBlank();
+ }
+
+ @Test
+ @DisplayName("V66:ANONYMOUS 只对匿名通道生效——[ANONYMOUS] 表单对任何登录会话都拒(C1)")
+ void anonymousOnlyAudienceDoesNotAdmitSessions() {
+ String code = "v66_anon_only";
+ String uuid = publishForm(code, SCHEMA);
+ config(SCH, uuid, audience(Collections.singletonList("ANONYMOUS")));
+
+ assertThatThrownBy(() -> answerService.submit(user(STUDENT, SCH, 3), submit(code, VALUES)))
+ .isInstanceOf(BizException.class).hasMessageContaining("无权填报该表单");
+ assertThatThrownBy(() -> answerService.submit(adminUser(), submit(code, VALUES)))
+ .isInstanceOf(BizException.class).hasMessageContaining("无权填报该表单");
+ }
+
+ @Test
+ @DisplayName("V66 对照:管理员不再恒可填——超管/校管理员身份不在人群时同样被 audience 门拒(作废-2 的行为差)")
+ void adminsAreNoLongerAlwaysFillable() {
+ String code = "v66_admin_not_in_audience";
+ publishForm(code, SCHEMA);
+
+ // P95 的层③(ADMIN)恒放行自填;v2 的资格只看 audience,管理员身份不是填报身份
+ BoaUser root = user(1, SCH, 9);
+ root.setType(1);
+ assertThatThrownBy(() -> answerService.submit(root, submit(code, VALUES)))
+ .isInstanceOf(BizException.class).hasMessageContaining("无权填报该表单");
+ assertThatThrownBy(() -> answerService.submit(user(20265007, SCH, 7), submit(code, VALUES)))
+ .isInstanceOf(BizException.class).hasMessageContaining("无权填报该表单");
+ }
+
+ // ==================== subject 指定权=ALL 档 ====================
+
+ @Test
+ @DisplayName("V66 对照:进了 form_admin 且本人在人群内→可代填(原 canManage 模块行判据退役,方向翻转)")
+ void formAdminHolderCanProxyWithinAudience() {
+ String code = "v66_form_admin_proxy";
+ String uuid = publishForm(code, SCHEMA);
+ grantFormAdmin(uuid, STUDENT, SCH);
subjects.markSchool(CLASSMATE, SCH);
AnswerModels.SubmitRequest req = submit(code, VALUES);
req.setSubjectId(CLASSMATE);
- String answerId = answerService.submit(user(TEACHER, SCH, 1), req).getAnswerId();
+ String answerId = answerService.submit(user(STUDENT, SCH, 3), req).getAnswerId();
Answer stored = answerRepository.findByUuidAndSchId(answerId, SCH).orElse(null);
assertThat(stored).isNotNull();
- // 红线 4:answer.sch_id 恒=会话校,代填不改域(P33/P80 口径不回退)
+ // 校域不变式的登录分支:代填不改 answer.sch_id(P33/P80 不回退)
assertThat(stored.getSchId()).isEqualTo(SCH);
- assertThat(stored.getSubmitterId()).isEqualTo(TEACHER);
+ assertThat(stored.getSubmitterId()).isEqualTo(STUDENT);
assertThat(stored.getSubjectId()).isEqualTo(CLASSMATE);
assertThat(stored.getSubmitterSchId()).isEqualTo(SCH);
assertThat(stored.getFormUuid()).isEqualTo(uuid);
}
@Test
- @DisplayName("V49 层②:代填的 subject 查无此人/跨校仍被 P33 拦(PARAM_ERROR)——新能力没绕过存在性校验")
- void teacherProxyStillCheckedByP33() {
- String code = "p95_manage_p33";
- publishForm(code, SCHEMA);
+ @DisplayName("V66:创建人自动 ALL(C16/D6)——creator_id 命中即可代填,无需落 form_admin 行")
+ void creatorHoldsAllAndCanProxy() {
+ String code = "v66_creator_proxy";
+ int creator = 20265020;
+ FormModels.FormCreateRequest create = new FormModels.FormCreateRequest();
+ create.setCode(code);
+ create.setName("测试 " + code);
+ String uuid = defService.create(SCH, create, Long.valueOf(creator)).getFormUuid();
+ FormModels.FormSaveRequest save = new FormModels.FormSaveRequest();
+ save.setUuid(uuid);
+ save.setSchemaJson(SCHEMA);
+ defService.save(SCH, save);
+ defService.publish(SCH, uuid, null);
+ subjects.markSchool(CLASSMATE, SCH);
+
+ AnswerModels.SubmitRequest req = submit(code, VALUES);
+ req.setSubjectId(CLASSMATE);
+ // 创建人是学生身份 → audience(STUDENT) 放行;ALL 档来自 creator_id 而非两表
+ assertThat(answerService.submit(user(creator, SCH, 3), req).getAnswerId()).isNotBlank();
+ }
+
+ @Test
+ @DisplayName("V66:ALL 档代填仍受 P33 管——被代填人不存在/跨校=PARAM_ERROR(新判据不绕过存在性校验)")
+ void allHolderProxyStillCheckedByP33() {
+ String code = "v66_proxy_p33";
+ String uuid = publishForm(code, SCHEMA);
+ grantFormAdmin(uuid, STUDENT, SCH);
Integer missing = 20265999;
subjects.markMissing(missing);
AnswerModels.SubmitRequest toMissing = submit(code, VALUES);
toMissing.setSubjectId(missing);
- assertThatThrownBy(() -> answerService.submit(user(TEACHER, SCH, 1), toMissing))
+ assertThatThrownBy(() -> answerService.submit(user(STUDENT, SCH, 3), toMissing))
.isInstanceOfSatisfying(BizException.class, e -> {
assertThat(e.getErrorCode()).isEqualTo(ErrorCode.PARAM_ERROR);
assertThat(e.getMessage()).contains("被代填人不存在或不属于本校");
@@ -128,153 +214,89 @@ class AnswerFillGateIT extends AnswerTestSupport {
subjects.markSchool(foreign, OTHER_SCH);
AnswerModels.SubmitRequest toForeign = submit(code, VALUES);
toForeign.setSubjectId(foreign);
- assertThatThrownBy(() -> answerService.submit(user(TEACHER, SCH, 1), toForeign))
+ assertThatThrownBy(() -> answerService.submit(user(STUDENT, SCH, 3), toForeign))
.isInstanceOf(BizException.class).hasMessageContaining("被代填人不存在或不属于本校");
}
- // ==================== 层③ ADMIN:可填 + 代填沿 canManage 现路径 ====================
-
@Test
- @DisplayName("V49 层③:管理员(7)×面向学生自填与代填都放行(P95 之前就能填能代填,一格不减)")
- void adminFillsAndProxies() {
- String code = "p95_admin";
+ @DisplayName("P115 裁决:代录不过人群门——校管理员(身份不在学生人群)可给本校学生代录;无 ALL 档者代录仍被拒")
+ void proxySkipsAudienceGateButNeedsAllTier() {
+ String code = "v66_proxy_skip_gate";
publishForm(code, SCHEMA);
- Integer adminId = 10001;
+ subjects.markSchool(CLASSMATE, SCH);
- assertThat(answerService.submit(user(adminId, SCH, 7), submit(code, VALUES)).getAnswerId()).isNotBlank();
+ AnswerModels.SubmitRequest byAdmin = submit(code, VALUES);
+ byAdmin.setSubjectId(CLASSMATE);
+ // 校管理员(roleId 7 命中临时清单 {2,7})= ALL 档 → 代录放行,且落库身份两键各司其职
+ String answerId = answerService.submit(user(20265030, SCH, 7), byAdmin).getAnswerId();
+ Answer stored = answerRepository.findByUuidAndSchId(answerId, SCH).get();
+ assertThat(stored.getSubmitterId()).isEqualTo(20265030);
+ assertThat(stored.getSubjectId()).isEqualTo(CLASSMATE);
+ assertThat(stored.getSchId()).isEqualTo(SCH);
- AnswerModels.SubmitRequest proxy = submit(code, "{\"name\":\"李四\",\"grade\":\"g3\"}");
- proxy.setSubjectId(CLASSMATE);
- assertThat(answerService.submit(user(adminId, SCH, 7), proxy).getAnswerId()).isNotBlank();
- }
+ // 同一个人以本人身份提交(无 subject)→ 人群门照常拒:豁免只给"替别人采集"
+ assertThatThrownBy(() -> answerService.submit(user(20265030, SCH, 7), submit(code, VALUES)))
+ .isInstanceOf(BizException.class).hasMessageContaining("无权填报该表单");
- @Test
- @DisplayName("V49 层③ 反例:进了 form_admin(入口层=ADMIN)但没被授权管理模块行——代填仍被 canManage 拒(红线 3'代填路径原样')")
- void adminTierWithoutModuleRowKeepsCanManageGate() {
- String code = "p95_admin_no_module_row";
- String uuid = publishForm(code, SCHEMA);
- grantFormAdmin(uuid, STUDENT, SCH);
-
- // 入口层确实升到 ADMIN:自填放行(学生本来也能填,这里只是坐实"两表进了 ADMIN 档")
- assertThat(answerService.submit(user(STUDENT, SCH, 3), submit(code, VALUES)).getAnswerId()).isNotBlank();
-
- AnswerModels.SubmitRequest proxy = submit(code, VALUES);
- proxy.setSubjectId(CLASSMATE);
- assertThatThrownBy(() -> answerService.submit(user(STUDENT, SCH, 3), proxy))
+ // 无 ALL 档的人(普通学生)想借"传 subject"绕开人群门→被 requireSubject 拒(V17 文案原样)
+ AnswerModels.SubmitRequest byStudent = submit(code, VALUES);
+ byStudent.setSubjectId(CLASSMATE);
+ assertThatThrownBy(() -> answerService.submit(user(STUDENT, SCH, 3), byStudent))
.isInstanceOf(BizException.class)
.hasMessageContaining("无权为他人填报:subjectId 只能等于本人");
}
- @Test
- @DisplayName("V49 层③:超管(type==1)无角色无学校归属以外的一切限制,面向学生表单直填放行")
- void superAdminPassesEveryAudience() {
- String code = "p95_super";
- String uuid = publishForm(code, SCHEMA);
- BoaUser root = user(1, SCH, 9);
- root.setType(1);
- assertThat(answerService.submit(root, submit(code, VALUES)).getAnswerId()).isNotBlank();
-
- FormModels.FormConfigRequest teacherFacing = new FormModels.FormConfigRequest();
- teacherFacing.setAudience(2);
- config(SCH, uuid, teacherFacing);
- assertThat(answerService.submit(root, submit(code, VALUES)).getAnswerId()).isNotBlank();
- }
-
- // ==================== NONE:直链也 403 ====================
+ // ==================== 顺序与既有环节(不回退) ====================
@Test
- @DisplayName("V49 NONE:家长(4)/局领导(5)/工作号(6) 拿直链提交=FORBIDDEN,文案含表单 code 与会话身份要素")
- void peripheralRolesAreRejected() {
- String code = "p95_none";
- publishForm(code, SCHEMA);
-
- for (int roleId : new int[]{4, 5, 6}) {
- int id = 20265100 + roleId;
- assertThatThrownBy(() -> answerService.submit(user(id, SCH, roleId), submit(code, VALUES)))
- .as("roleId=" + roleId)
- .isInstanceOfSatisfying(BizException.class, e -> {
- assertThat(e.getErrorCode()).isEqualTo(ErrorCode.FORBIDDEN);
- assertThat(e.getMessage())
- .contains("不在可填报人群内")
- .contains(code)
- .contains("id=" + id)
- .contains("面向学生");
- });
- }
- }
-
- @Test
- @DisplayName("V49 NONE:学生×面向教师=FORBIDDEN(原'不授填报模块行进不来'的那格,现在由中心解析拒)")
- void studentOnTeacherFacingIsRejected() {
- String code = "p95_none_teacher_facing";
- String uuid = publishForm(code, SCHEMA);
- FormModels.FormConfigRequest teacherFacing = new FormModels.FormConfigRequest();
- teacherFacing.setAudience(2);
- config(SCH, uuid, teacherFacing);
-
- assertThatThrownBy(() -> answerService.submit(user(STUDENT, SCH, 3), submit(code, VALUES)))
- .isInstanceOf(BizException.class)
- .hasMessageContaining("不在可填报人群内")
- .hasMessageContaining("面向教师");
- }
-
- // ==================== 顺序:闸门在截止与幂等之前 ====================
-
- @Test
- @DisplayName("V49 顺序:无资格的人对已过期表单拿到的是 403,不是'已过收集截止时间'(先鉴权后业务)")
+ @DisplayName("V66 顺序:无资格的人对已过期表单拿到的是 403,不是'已过收集截止时间'(先鉴权后业务)")
void gateRunsBeforeDeadline() {
- String code = "p95_gate_order";
+ String code = "v66_gate_order";
String uuid = publishForm(code, SCHEMA);
FormModels.FormConfigRequest past = new FormModels.FormConfigRequest();
past.setDeadline("2020-01-01");
config(SCH, uuid, past);
- // 有资格的人仍按原语义拿到截止拒绝(既有环节不回退)
assertThatThrownBy(() -> answerService.submit(user(STUDENT, SCH, 3), submit(code, VALUES)))
.isInstanceOf(BizException.class).hasMessageContaining("已过收集截止时间");
- // 无资格的人连"这张表过期了"都问不出来
assertThatThrownBy(() -> answerService.submit(user(20265201, SCH, 4), submit(code, VALUES)))
- .isInstanceOf(BizException.class).hasMessageContaining("不在可填报人群内");
+ .isInstanceOf(BizException.class).hasMessageContaining("无权填报该表单");
}
@Test
- @DisplayName("V49 顺序:被闸门拒的人重放同一幂等键仍是 403(闸门在幂等之前,任务书 W2 明示的取舍)")
+ @DisplayName("V66 顺序:被门拒的人重放同一幂等键仍是 403(门在幂等之前,沿 P95 W2 明示的取舍)")
void rejectedReplayIsStillForbidden() {
- String code = "p95_gate_replay";
+ String code = "v66_gate_replay";
publishForm(code, SCHEMA);
String key = newIdemKey();
assertThatThrownBy(() -> answerService.submit(user(20265202, SCH, 4), submitWithKey(code, key, VALUES)))
- .isInstanceOf(BizException.class).hasMessageContaining("不在可填报人群内");
+ .isInstanceOf(BizException.class).hasMessageContaining("无权填报该表单");
assertThatThrownBy(() -> answerService.submit(user(20265202, SCH, 4), submitWithKey(code, key, VALUES)))
- .isInstanceOf(BizException.class).hasMessageContaining("不在可填报人群内");
+ .isInstanceOf(BizException.class).hasMessageContaining("无权填报该表单");
assertThat(answerRepository.findBySchIdAndAppCodeAndCodeOrderByIdAsc(SCH, "form-center", code)).isEmpty();
}
- // ==================== 既有环节不回退(代表性用例) ====================
-
@Test
- @DisplayName("V49 后半:闸门不吃既有环节——限一次、值校验、钉版顺序与语义原样")
+ @DisplayName("V66 后半:门不吃既有环节——限一次、值校验、钉版顺序与语义原样")
void existingStagesAreUntouched() {
- String code = "p95_stages";
+ String code = "v66_stages";
String uuid = publishForm(code, SCHEMA);
FormModels.FormConfigRequest once = new FormModels.FormConfigRequest();
- once.setFillLimit(com.yunhai.formcenter.enums.FormEnums.FillLimit.ONCE.getCode());
+ once.setFillLimit(FormEnums.FillLimit.ONCE.getCode());
config(SCH, uuid, once);
- BoaUser student = user(STUDENT, SCH, 3);
- assertThat(answerService.submit(student, submit(code, VALUES)).getAnswerId()).isNotBlank();
+ assertThat(answerService.submit(user(STUDENT, SCH, 3), submit(code, VALUES)).getAnswerId()).isNotBlank();
assertThatThrownBy(() -> answerService.submit(user(STUDENT, SCH, 3), submit(code, VALUES)))
.isInstanceOf(BizException.class).hasMessageContaining("限填一次");
- // 值校验仍按钉住版本(闸门放行之后才走到,顺序没被第 0 关挤掉)
assertThatThrownBy(() -> answerService.submit(user(CLASSMATE, SCH, 3), submit(code, "{\"grade\":\"g9\"}")))
.isInstanceOf(BizException.class).hasMessageContaining("必填");
}
@Test
- @DisplayName("V49 未登录/无学校归属两段的顺序不回退:先要得到会话,再谈入口层")
+ @DisplayName("V66 未登录/无学校归属两段顺序不回退:先要得到会话,再谈人群")
void sessionChecksStillFirst() {
- String code = "p95_session";
+ String code = "v66_session";
publishForm(code, SCHEMA);
assertThatThrownBy(() -> answerService.submit(null, submit(code, VALUES)))
@@ -282,8 +304,16 @@ class AnswerFillGateIT extends AnswerTestSupport {
BoaUser noSchool = new BoaUser();
noSchool.setId(20265301);
- noSchool.setRoleIds(java.util.Collections.singletonList(3));
+ noSchool.setRoleIds(Collections.singletonList(3));
assertThatThrownBy(() -> answerService.submit(noSchool, submit(code, VALUES)))
.isInstanceOf(BizException.class).hasMessageContaining("无学校归属");
}
+
+ // ==================== 入参小工具 ====================
+
+ private static FormModels.FormConfigRequest audience(java.util.List codes) {
+ FormModels.FormConfigRequest req = new FormModels.FormConfigRequest();
+ req.setAudience(codes);
+ return req;
+ }
}
diff --git a/src/test/java/com/yunhai/formcenter/service/AnswerFrozenIT.java b/src/test/java/com/yunhai/formcenter/service/AnswerFrozenIT.java
new file mode 100644
index 0000000..3023e10
--- /dev/null
+++ b/src/test/java/com/yunhai/formcenter/service/AnswerFrozenIT.java
@@ -0,0 +1,172 @@
+package com.yunhai.formcenter.service;
+
+import com.fasterxml.jackson.databind.JsonNode;
+import com.yunhai.formcenter.entity.Answer;
+import com.yunhai.formcenter.model.AnswerModels;
+import com.yunhai.formcenter.model.FormModels;
+import com.yunhai.formcenter.model.ReadModels;
+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.boot.test.context.SpringBootTest;
+import org.springframework.context.annotation.Import;
+import org.springframework.transaction.annotation.Transactional;
+
+import java.util.Collections;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+import static org.assertj.core.api.Assertions.catchThrowableOfType;
+
+/**
+ * 冻结开关(契约 v2 §4.2/D11,验收 V61/V68):{@code form.frozen=1} 时
+ * 提交人编辑与管理员编辑双写通道同关,唯一修正路径=软删(撤回)+ 重新提交。
+ *
+ * 为什么值得单独一个 IT:两条编辑通道分别落在 {@code AnswerReadService#editBySubmitter}
+ * 与 {@code AnswerAdminService#doEdit} 两个类里,只在一处加判据就等于"藏一半开一半"——
+ * 原件是档案这条承诺只在两边都关的时候成立(V61 断言的就是"两条通道"而不是"编辑")。
+ *
+ * 同时钉住未冻结那条腿:默认关(V68),不开时两条通道照常可编辑,
+ * 免得把"冻结"实现成"一律禁编辑"。
+ */
+@SpringBootTest
+@Import(AnswerTestSupport.TestGatewayConfiguration.class)
+@Transactional
+@DisplayName("答卷编辑双通道 - 提交后禁编辑(V61/V68)")
+class AnswerFrozenIT extends AnswerTestSupport {
+
+ @org.springframework.beans.factory.annotation.Autowired
+ private AnswerReadService readService;
+
+ private static final String SCHEMA = "{\"groups\":[{\"key\":\"base\",\"label\":\"基本信息\",\"fields\":["
+ + "{\"key\":\"name\",\"label\":\"姓名\",\"type\":\"text\",\"required\":true}]}]}";
+
+ private static final int STUDENT = 20266001;
+ private static final String FROZEN_MSG = "该表单已开启提交后禁编辑,修正请撤回后重新提交";
+
+ @Test
+ @DisplayName("V68:默认是关的——新建表单 frozen=0,两条编辑通道照常可用")
+ void defaultIsUnfrozenAndBothEditChannelsWork() {
+ String code = "v61_default_open_edit";
+ publishForm(code, SCHEMA);
+ String answerId = submitId(code, "{\"name\":\"张三\"}");
+
+ ReadModels.RowView mine = readService.editBySubmitter(user(STUDENT, SCH, 3), answerId,
+ values("{\"name\":\"张三(改过一次)\"}"), "自填改一次");
+ assertThat(mine.getValues().path("name").asText()).isEqualTo("张三(改过一次)");
+
+ adminService.edit(manager(), SCH, editRequest(answerId, "{\"name\":\"张三(管理员改)\"}"));
+ Answer stored = answerRepository.findByUuidAndSchId(answerId, SCH).get();
+ assertThat(stored.getValuesJson()).contains("管理员改");
+ }
+
+ @Test
+ @DisplayName("V61:冻结后本人编辑被拒(CUSTOM(5),文案指路撤回+重交),且值文档一个字节都没变")
+ void submitterEditRefusedWhenFrozen() {
+ String code = "v61_my_edit";
+ String uuid = publishForm(code, SCHEMA);
+ freeze(uuid);
+ String answerId = submitId(code, "{\"name\":\"李四\"}");
+
+ BizException e = catchThrowableOfType(
+ () -> readService.editBySubmitter(user(STUDENT, SCH, 3), answerId,
+ values("{\"name\":\"李四(想改)\"}"), null),
+ BizException.class);
+ assertThat(e).isNotNull();
+ assertThat(e.getErrorCode()).isEqualTo(ErrorCode.CUSTOM);
+ assertThat(e.getMessage()).contains(FROZEN_MSG).contains(code);
+ assertThat(answerRepository.findByUuidAndSchId(answerId, SCH).get().getValuesJson())
+ .as("被拒的编辑不能留下半改状态")
+ .isEqualTo("{\"name\":\"李四\"}");
+ }
+
+ @Test
+ @DisplayName("V61:管理员编辑通道同样被拒——同一条判据、同一份文案族(只关一半等于没关)")
+ void adminEditRefusedWhenFrozen() {
+ String code = "v61_admin_edit";
+ String uuid = publishForm(code, SCHEMA);
+ freeze(uuid);
+ String answerId = submitId(code, "{\"name\":\"王五\"}");
+
+ BizException e = catchThrowableOfType(
+ () -> adminService.edit(manager(), SCH, editRequest(answerId, "{\"name\":\"王五(管理员想改)\"}")),
+ BizException.class);
+ assertThat(e).isNotNull();
+ assertThat(e.getErrorCode()).isEqualTo(ErrorCode.CUSTOM);
+ assertThat(e.getMessage()).contains(FROZEN_MSG);
+ }
+
+ @Test
+ @DisplayName("V61 修正路径:冻结不禁软删与重新提交——撤回后重交照常成功(原件仍不可篡改,但业务能纠错)")
+ void withdrawAndResubmitStillWorks() {
+ String code = "v61_withdraw";
+ String uuid = publishForm(code, SCHEMA);
+ freeze(uuid);
+ String first = submitId(code, "{\"name\":\"赵六\"}");
+
+ adminService.softDelete(manager(), SCH, first);
+ BoaUser student = user(STUDENT, SCH, 3);
+ assertThatThrownBy(() -> readService.editBySubmitter(student, first,
+ values("{\"name\":\"赵六(改已删的行)\"}"), null))
+ .isInstanceOf(BizException.class).hasMessageContaining("答卷不存在或已删除");
+
+ String second = submitId(code, "{\"name\":\"赵六(重交)\"}");
+ Answer refilled = answerRepository.findByUuidAndSchId(second, SCH).get();
+ assertThat(refilled.getIsDeleted()).isZero();
+ assertThat(refilled.getValuesJson()).contains("重交");
+ assertThat(refilled.getFormUuid()).as("钉版两维照常落值(V56 不因冻结改变)")
+ .isEqualTo(uuid);
+ }
+
+ @Test
+ @DisplayName("V61:冻结只关编辑通道——编辑被拒、软删(撤回)与批量软删照常可用(顺序刻意:先编辑后删)")
+ void batchWithdrawUnaffectedByFreeze() {
+ String code = "v61_batch";
+ String uuid = publishForm(code, SCHEMA);
+ freeze(uuid);
+ String answerId = submitId(code, "{\"name\":\"孙七\"}");
+
+ // 先证编辑通道关着(若先删再编辑,拿到的是"答卷不存在",冻结判据根本没被执行到)
+ BizException e = catchThrowableOfType(
+ () -> adminService.edit(manager(), SCH, editRequest(answerId, "{\"name\":\"改不动\"}")),
+ BizException.class);
+ assertThat(e).isNotNull();
+ assertThat(e.getErrorCode()).isEqualTo(ErrorCode.CUSTOM);
+ assertThat(e.getMessage()).contains(FROZEN_MSG);
+
+ AnswerModels.BatchRequest batch = new AnswerModels.BatchRequest();
+ batch.setAnswerIds(Collections.singletonList(answerId));
+ AnswerModels.BatchItemView item = adminService.batchSoftDelete(manager(), SCH, batch).get(0);
+ assertThat(item.getSuccess()).as("软删(撤回)不受冻结影响——它是唯一的修正路径").isTrue();
+ }
+
+ // ==================== 脚手架 ====================
+
+ private void freeze(String formUuid) {
+ FormModels.FormConfigRequest config = new FormModels.FormConfigRequest();
+ config.setFrozen(1);
+ config(SCH, formUuid, config);
+ }
+
+ private String submitId(String code, String valuesJson) {
+ return answerService.submit(user(STUDENT, SCH, 3), submit(code, valuesJson)).getAnswerId();
+ }
+
+ private JsonNode values(String json) {
+ try {
+ return com.yunhai.formcenter.common.FormSchemaValidator.mapper().readTree(json);
+ } catch (com.fasterxml.jackson.core.JsonProcessingException e) {
+ throw new IllegalStateException("测试 JSON 写错了", e);
+ }
+ }
+
+ private AnswerModels.EditRequest editRequest(String answerId, String valuesJson) {
+ AnswerModels.EditRequest req = new AnswerModels.EditRequest();
+ req.setAnswerId(answerId);
+ req.setValues(values(valuesJson));
+ req.setSummary("冻结取证");
+ return req;
+ }
+}
diff --git a/src/test/java/com/yunhai/formcenter/service/AnswerTestSupport.java b/src/test/java/com/yunhai/formcenter/service/AnswerTestSupport.java
index a1c4e9f..e061cd0 100644
--- a/src/test/java/com/yunhai/formcenter/service/AnswerTestSupport.java
+++ b/src/test/java/com/yunhai/formcenter/service/AnswerTestSupport.java
@@ -170,7 +170,7 @@ abstract class AnswerTestSupport {
FormModels.FormCreateRequest create = new FormModels.FormCreateRequest();
create.setCode(code);
create.setName("测试 " + code);
- String uuid = defService.create(schId, create).getFormUuid();
+ String uuid = defService.create(schId, create, null).getFormUuid();
FormModels.FormSaveRequest save = new FormModels.FormSaveRequest();
save.setUuid(uuid);
save.setSchemaJson(schemaJson);
diff --git a/src/test/java/com/yunhai/formcenter/service/DataScopeServiceTest.java b/src/test/java/com/yunhai/formcenter/service/DataScopeServiceTest.java
index 84d0646..dfd56a5 100644
--- a/src/test/java/com/yunhai/formcenter/service/DataScopeServiceTest.java
+++ b/src/test/java/com/yunhai/formcenter/service/DataScopeServiceTest.java
@@ -1,8 +1,8 @@
package com.yunhai.formcenter.service;
import com.yunhai.formcenter.entity.Form;
-import com.yunhai.formcenter.enums.FormEnums;
import com.yunhai.formcenter.enums.FormEnums.DataScope;
+import com.yunhai.formcenter.model.ReadModels;
import com.yunhai.platform.apistd.BizException;
import com.yunhai.platform.utils.model.BoaRoleInfo;
import com.yunhai.platform.utils.model.BoaUser;
@@ -13,297 +13,211 @@ import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.List;
-import java.util.Set;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
/**
- * 数据范围解析单测(自验项 V28 全部 + V42 的"身份默认矩阵/来源升级/回落"三半边 +
- * V43/V44 的解析半边)。
+ * 数据范围两档解析单测(契约 v2 §2.3,验收 V55/V57 的解析半边)。
*
- * 矩阵(契约 §8)在这里逐格断言;"受限身份的统计/导出 ⊆ 列表可见集"那半边是接口级事实,
- * 在 {@code AnswerReadIT}/{@code AdminScopeIT} 打真库断言。桩只顶掉两表来源(本库事实源),
- * 解析规则本身全部走生产代码。
+ * 原"身份默认矩阵逐格断言"(V42/V28 的载体)随 §19 作废-1 出清,本类改按两档规则断言:
+ * {@code ALL=创建人 ∪ form_admin ∪ 校管理员(∪ 超管)}、{@code SELF=登录用户天然持有},
+ * 外加平台级表单上的租户限定变体(D9)与三处 fail-closed。
*
- * T10 改版:原"customId 锚点升级"两例(V42 扩展位 / P47 只认 path 空行)随锚点协议退役删除,
- * 同一位置改由管理员两表用例承担(V43/V44)。
+ * 桩只顶掉管理员两表来源({@link InMemoryAdminScopeSource}),解析规则本身全部走生产代码。
*/
-@DisplayName("DataScopeService - 契约 §8 矩阵与管理员两表升级(V28/V42/V43/V44)")
+@DisplayName("DataScopeService - 契约 v2 §2.3 两档解析(V55/V57/V58/V66)")
class DataScopeServiceTest {
- private static final int STUDENT_FACING = FormEnums.Audience.STUDENT.getCode();
- private static final int TEACHER_FACING = FormEnums.Audience.TEACHER.getCode();
private static final long SCH = 990301L;
private static final long OTHER_SCH = 990302L;
+ private static final String AUD_STUDENT = "STUDENT";
+ private static final String AUD_TEACHER = "TEACHER";
private final InMemoryAdminScopeSource admins = new InMemoryAdminScopeSource();
- private final DataScopeService service = new DataScopeService(implementors(), admins);
+ private final DataScopeService service =
+ new DataScopeService(implementors(), admins, new FormAccessPolicy(admins));
- // ==================== V42:身份默认矩阵 ====================
+ // ==================== 天然持有 SELF ====================
@Test
- @DisplayName("V42 矩阵:管理员(7)/校领导(2)=全部,两种面向人群都是全部")
- void adminAndLeaderSeeAll() {
- for (int roleId : Arrays.asList(7, 2)) {
- assertThat(service.resolve(user(roleId), form(SCH, "f-own", STUDENT_FACING)))
- .containsExactly(DataScope.ALL);
- assertThat(service.resolve(user(roleId), form(SCH, "f-own", TEACHER_FACING)))
- .containsExactly(DataScope.ALL);
+ @DisplayName("V55:普通学生(不在任何表、身份不在人群)也天然持有 SELF——不再有'全不命中回落'分支")
+ void everySessionHoldsSelf() {
+ assertThat(service.resolve(user(3), form(SCH, "f-own", AUD_STUDENT)).self()).isTrue();
+ assertThat(service.resolve(user(3), form(SCH, "f-own", AUD_STUDENT)).all()).isFalse();
+ assertThat(service.resolve(user(3), form(SCH, "f-own", AUD_STUDENT)).scopes())
+ .containsExactly(DataScope.SELF);
+ // 家长/局领导/工作号/未知角色同样拿到 SELF("关于我的"视图不因身份被没收)
+ for (int roleId : new int[]{4, 5, 6, 9}) {
+ assertThat(service.resolve(user(roleId), form(SCH, "f-own", AUD_STUDENT)).scopes())
+ .as("roleId=" + roleId)
+ .containsExactly(DataScope.SELF);
}
}
- @Test
- @DisplayName("V42 矩阵:教师(1)×面向学生=一期生效仅本人提交(任教班级实现器缺席);×面向教师=仅本人提交")
- void teacherScopes() {
- assertThat(service.resolve(user(1), form(SCH, "f-own", STUDENT_FACING)))
- .containsExactly(DataScope.SELF_SUBMITTED);
- assertThat(service.resolve(user(1), form(SCH, "f-own", TEACHER_FACING)))
- .containsExactly(DataScope.SELF_SUBMITTED);
- }
+ // ==================== ALL 档四个来源 ====================
@Test
- @DisplayName("V42 矩阵:学生(3)×面向学生=关于我(老师代填的那条也可见);×面向教师按最保守兜底")
- void studentScopes() {
- assertThat(service.resolve(user(3), form(SCH, "f-own", STUDENT_FACING)))
- .containsExactly(DataScope.ABOUT_ME);
- assertThat(service.resolve(user(3), form(SCH, "f-own", TEACHER_FACING)))
- .containsExactly(DataScope.SELF_SUBMITTED);
- }
-
- @Test
- @DisplayName("V42 矩阵:家长(4)/工作号(6)/局领导(5)=回落仅本人提交(两种人群都是)")
- void peripheralRolesFallBackToSelf() {
- for (int roleId : Arrays.asList(4, 6, 5)) {
- assertThat(service.resolve(user(roleId), form(SCH, "f-own", STUDENT_FACING)))
- .as("roleId=" + roleId + " 面向学生")
- .containsExactly(DataScope.SELF_SUBMITTED);
- assertThat(service.resolve(user(roleId), form(SCH, "f-own", TEACHER_FACING)))
- .as("roleId=" + roleId + " 面向教师")
- .containsExactly(DataScope.SELF_SUBMITTED);
- }
- }
-
- @Test
- @DisplayName("V42 全不命中:没有任何 roleIds(异常身份)也落到仅本人提交,不是全部")
- void noRolesAtAllFallsBackToSelf() {
- BoaUser nobody = new BoaUser();
- nobody.setId(20001);
- nobody.setCurrentRole(role(9, 990301));
- assertThat(service.resolve(nobody, form(SCH, "f-own", STUDENT_FACING)))
- .containsExactly(DataScope.SELF_SUBMITTED);
- }
-
- @Test
- @DisplayName("V42 多来源取最宽=并集:同时有学生与教师身份时,关于我与仅本人提交两格都在(不是二选一)")
- void multipleIdentitiesUnion() {
- BoaUser user = new BoaUser();
- user.setId(20002);
- user.setType(0);
- user.setRoleIds(Arrays.asList(1, 3));
- user.setCurrentRole(role(1, 990301));
- Set scopes = service.resolve(user, form(SCH, "f-own", STUDENT_FACING));
- assertThat(scopes).containsExactlyInAnyOrder(DataScope.SELF_SUBMITTED, DataScope.ABOUT_ME);
-
- // 其中一格是"全部"时收敛为全部(其余都是它的子集,留着只让读路径多写 OR)
- user.setRoleIds(Arrays.asList(1, 3, 7));
- assertThat(service.resolve(user, form(SCH, "f-own", STUDENT_FACING))).containsExactly(DataScope.ALL);
- }
-
- // ==================== V43:校级表单管理员 ====================
-
- @Test
- @DisplayName("V43 校级管理员:本校表单=全部(含本校快应用表单——解析不看 app_code)")
- void schoolAdminSeesAllOnOwnSchoolForms() {
- BoaUser teacher = user(1);
- assertThat(service.resolve(teacher, form(SCH, "f-own", STUDENT_FACING)))
- .as("未进表时仍是矩阵默认")
- .containsExactly(DataScope.SELF_SUBMITTED);
-
- admins.grantSchoolAdmin(SCH, teacher.getId());
- assertThat(service.resolve(teacher, form(SCH, "f-quick", STUDENT_FACING)))
- .containsExactly(DataScope.ALL);
- }
-
- @Test
- @DisplayName("V43 校级管理员:基础表单(sch_id=0)升出本校提交,不给全部(D2:跨校学生数据不因授权而可见)")
- void schoolAdminSeesSchoolScopeOnBaseForm() {
- BoaUser teacher = user(1);
- admins.grantSchoolAdmin(SCH, teacher.getId());
- // 矩阵那格(仅本人提交)按并集规则照常留着:SCHOOL 是它的超集,读路径 OR 起来不多给行
- assertThat(service.resolve(teacher, form(Form.PLATFORM_SCH_ID, "f-base", STUDENT_FACING)))
- .containsExactlyInAnyOrder(DataScope.SCHOOL, DataScope.SELF_SUBMITTED);
- assertThat(service.resolve(teacher, form(Form.PLATFORM_SCH_ID, "f-base", STUDENT_FACING)))
- .as("基础表单上不得出现全部")
- .doesNotContain(DataScope.ALL);
- }
-
- @Test
- @DisplayName("V43 会话域跟随:同一个人是 A 校校级管理员,会话切到 B 校后 A 校授权不跟着过来")
- void schoolAdminFollowsSessionDomain() {
- BoaUser teacher = user(1);
- admins.grantSchoolAdmin(SCH, teacher.getId());
-
- // 会话身份=B 校:B 校表单不给升级(他校表单更不给)
- BoaUser atOther = user(1);
- atOther.setCurrentRole(role(1, (int) OTHER_SCH));
- assertThat(service.resolve(atOther, form(SCH, "f-own", STUDENT_FACING)))
- .containsExactly(DataScope.SELF_SUBMITTED);
- assertThat(service.resolve(atOther, form(OTHER_SCH, "f-other", STUDENT_FACING)))
- .containsExactly(DataScope.SELF_SUBMITTED);
-
- // 会话身份=A 校:同一张 A 校表单即升级
- assertThat(service.resolve(teacher, form(SCH, "f-own", STUDENT_FACING)))
- .containsExactly(DataScope.ALL);
- }
-
- @Test
- @DisplayName("V43 无学校归属的会话不查两表(给不出本校域就没有可升级的授权)")
- void noSchoolBindingGrantsNothing() {
- BoaUser user = user(1);
- user.setCurrentRole(role(1, null));
- admins.grantSchoolAdmin(SCH, user.getId());
- assertThat(service.resolve(user, form(SCH, "f-own", STUDENT_FACING)))
- .containsExactly(DataScope.SELF_SUBMITTED);
- }
-
- // ==================== V44:单表单管理员 ====================
-
- @Test
- @DisplayName("V44 单表单管理员:指定表单=全部,未指定的表单不受影响")
- void formAdminScopedToItsOwnFormOnly() {
- BoaUser teacher = user(1);
- admins.grantFormAdmin("f-yes", teacher.getId());
-
- assertThat(service.resolve(teacher, form(SCH, "f-yes", STUDENT_FACING))).containsExactly(DataScope.ALL);
- assertThat(service.resolve(teacher, form(SCH, "f-no", STUDENT_FACING)))
- .as("同校但没被指定的表单不受影响")
- .containsExactly(DataScope.SELF_SUBMITTED);
- }
-
- @Test
- @DisplayName("V44 单表单管理员:基础表单上升出本校提交(管理员行不覆盖未指定的表单)")
- void formAdminOnBaseFormIsSchoolScope() {
- BoaUser teacher = user(1);
- admins.grantFormAdmin("f-base", teacher.getId());
- assertThat(service.resolve(teacher, form(Form.PLATFORM_SCH_ID, "f-base", STUDENT_FACING)))
- .containsExactlyInAnyOrder(DataScope.SCHOOL, DataScope.SELF_SUBMITTED);
- assertThat(service.resolve(teacher, form(Form.PLATFORM_SCH_ID, "f-other", STUDENT_FACING)))
- .as("同一张基础表单之外的表单不受影响")
- .containsExactly(DataScope.SELF_SUBMITTED);
- }
-
- @Test
- @DisplayName("V43/V44 收敛:升级出的全部与矩阵其它格并集后收敛为全部(命中 ALL 不留剩余 OR)")
- void upgradeToAllCollapsesTheUnion() {
- BoaUser studentAndTeacher = new BoaUser();
- studentAndTeacher.setId(20003);
- studentAndTeacher.setType(0);
- studentAndTeacher.setRoleIds(Arrays.asList(1, 3));
- studentAndTeacher.setCurrentRole(role(3, (int) SCH));
- admins.grantSchoolAdmin(SCH, studentAndTeacher.getId());
- assertThat(service.resolve(studentAndTeacher, form(SCH, "f-own", STUDENT_FACING)))
- .containsExactly(DataScope.ALL);
- }
-
- @Test
- @DisplayName("两表都进=同一个升级值,并集不产生重复;矩阵那格仍在(并集语义不因升级而丢格)")
- void bothTablesUnionWithMatrix() {
- BoaUser teacher = user(3); // 学生身份:矩阵给"关于我"
- admins.grantSchoolAdmin(SCH, teacher.getId());
- admins.grantFormAdmin("f-base", teacher.getId());
- assertThat(service.resolve(teacher, form(Form.PLATFORM_SCH_ID, "f-base", STUDENT_FACING)))
- .containsExactlyInAnyOrder(DataScope.SCHOOL, DataScope.ABOUT_ME);
- }
-
- // ==================== V28:可选集合与不可用实现器 ====================
-
- @Test
- @DisplayName("V28:枚举五值都在;一期可选集合只有三个(任教班级未开放、SCHOOL 是授权结果不是筛选项)")
- void teachingClassAndSchoolAreNotSelectable() {
- assertThat(DataScope.values()).containsExactly(
- DataScope.ALL, DataScope.TEACHING_CLASS, DataScope.SELF_SUBMITTED, DataScope.ABOUT_ME,
- DataScope.SCHOOL);
- assertThat(service.selectableScopes()).containsExactlyInAnyOrder(
- DataScope.ALL, DataScope.SELF_SUBMITTED, DataScope.ABOUT_ME);
- assertThat(service.selectableScopes()).doesNotContain(DataScope.TEACHING_CLASS, DataScope.SCHOOL);
- }
-
- @Test
- @DisplayName("V28:任何身份×任何人群都解析不出任教班级(矩阵直接给生效值)")
- void noCombinationResolvesToTeachingClass() {
- for (int roleId : new int[]{1, 2, 3, 4, 5, 6, 7}) {
- for (int audience : new int[]{STUDENT_FACING, TEACHER_FACING}) {
- assertThat(service.resolve(user(roleId), form(SCH, "f-own", audience)))
- .as("roleId=" + roleId + " audience=" + audience)
- .doesNotContain(DataScope.TEACHING_CLASS);
- }
- }
- }
-
- @Test
- @DisplayName("V28:任教班级实现器被误用时明确报错(1.5 开放前不存在可用谓词),不会静默按全部放行")
- void teachingClassImplementorRefusesToBuildPredicate() {
- assertThatThrownBy(() -> service.implementorOf(DataScope.TEACHING_CLASS)
- .rowPredicate(null, null, 1, SCH))
- .isInstanceOf(BizException.class)
- .hasMessageContaining("一期未开放");
- }
-
- @Test
- @DisplayName("V43 半边:本校提交实现器给不出会话学校时明确拒绝(不静默退化成全校)")
- void schoolImplementorRefusesWithoutSessionSchool() {
- assertThatThrownBy(() -> service.implementorOf(DataScope.SCHOOL).rowPredicate(null, null, 1, null))
- .isInstanceOf(BizException.class)
- .hasMessageContaining("需要会话学校");
- }
-
- // ==================== 其它 ====================
-
- @Test
- @DisplayName("超管(type==1)恒为全部:它不受单校开通约束(方案 §6.5),平台级行的读侧同口径")
- void superAdminSeesAll() {
- BoaUser root = new BoaUser();
- root.setId(1);
+ @DisplayName("V55:超管(type==1)在任何域都是全量 ALL,且不吃租户限定变体")
+ void superAdminHoldsFullAll() {
+ BoaUser root = user(9);
root.setType(1);
- assertThat(service.resolve(root, form(SCH, "f-own", TEACHER_FACING))).containsExactly(DataScope.ALL);
+ DataScopeService.Resolved own = service.resolve(root, form(SCH, "f-own", AUD_STUDENT));
+ assertThat(own.all()).isTrue();
+ assertThat(own.platformScopedAll()).isFalse();
+ assertThat(service.resolve(root, form(Form.PLATFORM_SCH_ID, "f-platform", AUD_STUDENT))
+ .platformScopedAll()).isFalse();
}
+ @Test
+ @DisplayName("V55:form_admin 命中本校表单=全量 ALL(本批不区分它与校管理员的形态差)")
+ void formAdminHoldsFullAll() {
+ BoaUser student = user(3);
+ Form own = form(SCH, "f-own", AUD_STUDENT);
+ admins.grantFormAdmin(own.getUuid(), student.getId());
+ assertThat(service.resolve(student, own).scopes()).containsExactly(DataScope.ALL);
+ }
+
+ @Test
+ @DisplayName("V57:form_admin 命中平台级表单同样走租户限定变体(跨校数据不因授权而可见,D9/V57)")
+ void formAdminOnPlatformFormIsTenantScopedToo() {
+ BoaUser student = user(3);
+ Form base = form(Form.PLATFORM_SCH_ID, "f-platform", AUD_STUDENT);
+ admins.grantFormAdmin(base.getUuid(), student.getId());
+ DataScopeService.Resolved resolved = service.resolve(student, base);
+ assertThat(resolved.all()).isTrue();
+ assertThat(resolved.platformScopedAll()).isTrue();
+ }
+
+ @Test
+ @DisplayName("V55/V58:校管理员(school_admin 表命中会话校)本校表单=全量 ALL")
+ void schoolAdminHoldsAllOnOwnSchoolForms() {
+ BoaUser leader = user(9);
+ admins.grantSchoolAdmin(SCH, leader.getId());
+ assertThat(service.resolve(leader, form(SCH, "f-own", AUD_TEACHER)).scopes())
+ .containsExactly(DataScope.ALL);
+ }
+
+ @Test
+ @DisplayName("V58:校领导角色清单(临时值 {2,7})也是校管理员——清单只在 FormAccessPolicy 一处,两表删空仍有兜底")
+ void leaderRolesAreSchoolAdminsByNature() {
+ for (int roleId : new int[]{2, 7}) {
+ assertThat(service.resolve(user(roleId), form(SCH, "f-own", AUD_STUDENT)).scopes())
+ .as("roleId=" + roleId)
+ .containsExactly(DataScope.ALL);
+ }
+ // 3=学生、1=教师不因"是老师"就升范围(原矩阵那格随出清条款消失)
+ for (int roleId : new int[]{1, 3}) {
+ assertThat(service.resolve(user(roleId), form(SCH, "f-own", AUD_STUDENT)).scopes())
+ .as("roleId=" + roleId)
+ .containsExactly(DataScope.SELF);
+ }
+ }
+
+ @Test
+ @DisplayName("V55:创建人自动 ALL(C16/D6/G1)——creator_id 命中即可,不落 form_admin 行")
+ void creatorHoldsAllWithoutAdminRows() {
+ BoaUser student = user(3);
+ Form created = form(SCH, "f-created", AUD_STUDENT);
+ assertThat(service.resolve(student, created).scopes())
+ .as("未回填 creator_id 时不该凭空给 ALL")
+ .containsExactly(DataScope.SELF);
+
+ created.setCreatorId(Long.valueOf(student.getId()));
+ assertThat(service.resolve(student, created).scopes()).containsExactly(DataScope.ALL);
+ assertThat(service.resolve(student, created).platformScopedAll()).isFalse();
+ }
+
+ @Test
+ @DisplayName("C16 兜底:creator_id 为 NULL 的存量行按 create_by 账号名比(回填前后判定结果一致)")
+ void creatorFallsBackToCreateByAccount() {
+ BoaUser student = user(3);
+ Form legacy = form(SCH, "f-legacy", AUD_STUDENT);
+ legacy.setCreatorId(null);
+ legacy.setCreateBy("stu_" + student.getId());
+ // 兜底读的是会话线程里的账号名(与 create_by 同一事实源),无会话时不成立
+ assertThat(service.resolve(student, legacy).scopes()).containsExactly(DataScope.SELF);
+ }
+
+ // ==================== 平台级表单的租户限定变体 ====================
+
+ @Test
+ @DisplayName("D9/V57:校管理员×平台级表单=sch_id=0 域下的本校提交行(ALL 档内的租户限定变体,不是第三个范围值)")
+ void schoolAdminOnPlatformFormIsTenantScoped() {
+ BoaUser leader = user(2);
+ DataScopeService.Resolved resolved = service.resolve(leader, form(Form.PLATFORM_SCH_ID, "f-platform", AUD_STUDENT));
+ assertThat(resolved.all()).isTrue();
+ assertThat(resolved.platformScopedAll()).isTrue();
+ assertThat(resolved.scopes()).containsExactly(DataScope.ALL);
+ }
+
+ @Test
+ @DisplayName("V57:他校表单不给任何 ALL(跨校授权不成立,宁紧勿松)")
+ void foreignFormGrantsNothing() {
+ assertThat(service.resolve(user(7), form(OTHER_SCH, "f-other", AUD_STUDENT)).scopes())
+ .containsExactly(DataScope.SELF);
+ admins.grantSchoolAdmin(SCH, 20007);
+ assertThat(service.resolve(user(7), form(OTHER_SCH, "f-other", AUD_STUDENT)).scopes())
+ .as("school_admin 命中会话校也不覆盖他校表单")
+ .containsExactly(DataScope.SELF);
+ }
+
+ @Test
+ @DisplayName("V57:租户限定变体给不出会话学校时退回 SELF(不静默按全平台放行)")
+ void platformScopedVariantWithoutSessionSchoolDegradesToSelf() {
+ BoaUser leader = user(7);
+ leader.setCurrentRole(null);
+ DataScopeService.Resolved resolved = service.resolve(leader, form(Form.PLATFORM_SCH_ID, "f-platform", AUD_STUDENT));
+ assertThat(resolved.scopes()).containsExactly(DataScope.SELF);
+ }
+
+ // ==================== fail-closed 与注册表 ====================
+
@Test
@DisplayName("未登录不解析范围(拒绝,而不是给一个兜底范围)")
void requiresSession() {
- assertThatThrownBy(() -> service.resolve(null, form(SCH, "f-own", STUDENT_FACING)))
+ assertThatThrownBy(() -> service.resolve(null, form(SCH, "f-own", AUD_STUDENT)))
.isInstanceOf(BizException.class).hasMessageContaining("未登录");
- assertThatThrownBy(() -> service.resolve(new BoaUser(), form(SCH, "f-own", STUDENT_FACING)))
+ assertThatThrownBy(() -> service.resolve(new BoaUser(), form(SCH, "f-own", AUD_STUDENT)))
.hasMessageContaining("未登录");
}
@Test
- @DisplayName("表单为 null(如平台级读上下文缺失)按面向学生列解析,不抛空指针、也不产生两表升级")
- void nullFormUsesStudentColumn() {
- BoaUser teacher = user(1);
- admins.grantSchoolAdmin(SCH, teacher.getId());
- assertThat(service.resolve(user(3), null)).containsExactly(DataScope.ABOUT_ME);
- assertThat(service.resolve(teacher, null))
- .as("表单为 null 时没有归属域可判,两表不升级")
- .containsExactly(DataScope.SELF_SUBMITTED);
+ @DisplayName("表单为 null(无归属域可判)只给 SELF,不产生任何 ALL 升级")
+ void nullFormGivesSelfOnly() {
+ BoaUser leader = user(7);
+ admins.grantSchoolAdmin(SCH, leader.getId());
+ assertThat(service.resolve(user(3), null).scopes()).containsExactly(DataScope.SELF);
+ assertThat(service.resolve(leader, null).scopes()).containsExactly(DataScope.SELF);
}
@Test
- @DisplayName("实现器注册表按枚举取用;未注册的范围视为配置缺失直接报错(不静默当全部)")
- void unknownScopeFailsLoud() {
+ @DisplayName("V55:两实现器收敛后注册表按枚举取用;未注册视为配置缺失直接报错(不静默当全部)")
+ void implementorRegistryFailsLoud() {
+ assertThat(service.implementorOf(DataScope.ALL).scope()).isEqualTo(DataScope.ALL);
+ assertThat(service.implementorOf(DataScope.SELF).scope()).isEqualTo(DataScope.SELF);
DataScopeService partial = new DataScopeService(
- new ArrayList<>(Collections.singletonList(new AllScopeImplementor())), admins);
- assertThatThrownBy(() -> partial.implementorOf(DataScope.ABOUT_ME))
+ new ArrayList<>(Collections.singletonList(new AllScopeImplementor())), admins,
+ new FormAccessPolicy(admins));
+ assertThatThrownBy(() -> partial.implementorOf(DataScope.SELF))
.isInstanceOf(BizException.class).hasMessageContaining("缺少实现器");
}
+ @Test
+ @DisplayName("V55/V57:ALL 档租户限定变体给不出会话校时明确拒绝(谓词层再兜一道,不静默退化成全校)")
+ void tenantScopedVariantRefusesWithoutSessionSchool() {
+ ReadModels.Query q = new ReadModels.Query();
+ q.setPlatformScopedAll(true);
+ q.setViewerSchId(null);
+ assertThatThrownBy(() -> service.implementorOf(DataScope.ALL).rowPredicate(null, null, q))
+ .isInstanceOf(BizException.class).hasMessageContaining("需要会话学校");
+ }
+
// ==================== 构造小工具 ====================
- /** 与生产装配一致的注册表:T10 起五值五实现器(任教班级仍是占位、SCHOOL 新增) */
+ /** 两档两实现器(§19 作废-1:任教班级/SCHOOL/仅本人提交/关于我四个实现器已收敛) */
private static List implementors() {
- return Arrays.asList(new AllScopeImplementor(), new SelfSubmittedScopeImplementor(),
- new AboutMeScopeImplementor(), new TeachingClassScopeImplementor(), new SchoolScopeImplementor());
+ return Arrays.asList(new AllScopeImplementor(), new SelfScopeImplementor());
}
private BoaUser user(int roleId) {
@@ -315,17 +229,19 @@ class DataScopeServiceTest {
return user;
}
- private BoaRoleInfo role(Integer roleId, Integer schId) {
+ private static BoaRoleInfo role(Integer roleId, Integer schId) {
BoaRoleInfo info = new BoaRoleInfo();
info.setRoleId(roleId);
info.setSchId(schId);
return info;
}
- private Form form(long schId, String uuid, int audience) {
+ private static Form form(long schId, String uuid, String audience) {
Form form = new Form();
form.setSchId(schId);
form.setUuid(uuid);
+ form.setAppCode(Form.CENTER_APP_CODE);
+ form.setCode(uuid);
form.setAudience(audience);
return form;
}
diff --git a/src/test/java/com/yunhai/formcenter/service/FillAccessServiceTest.java b/src/test/java/com/yunhai/formcenter/service/FillAccessServiceTest.java
deleted file mode 100644
index 204c3d9..0000000
--- a/src/test/java/com/yunhai/formcenter/service/FillAccessServiceTest.java
+++ /dev/null
@@ -1,263 +0,0 @@
-package com.yunhai.formcenter.service;
-
-import com.yunhai.formcenter.entity.Form;
-import com.yunhai.formcenter.enums.FormEnums;
-import com.yunhai.formcenter.service.FillAccessService.Tier;
-import com.yunhai.platform.apistd.BizException;
-import com.yunhai.platform.utils.model.BoaRoleInfo;
-import com.yunhai.platform.utils.model.BoaUser;
-import org.junit.jupiter.api.DisplayName;
-import org.junit.jupiter.api.Test;
-
-import java.util.Arrays;
-import java.util.Collections;
-import java.util.EnumSet;
-import java.util.Set;
-
-import static org.assertj.core.api.Assertions.assertThat;
-import static org.assertj.core.api.Assertions.assertThatThrownBy;
-
-/**
- * 填报入口层解析单测(自验项 V48:任务书 P95 §0.1 三层模型矩阵逐格 + 多身份并集 + 超管 +
- * 两表命中 + NONE 兜底)。
- *
- * 桩只顶掉管理员两表来源({@link InMemoryAdminScopeSource},同 {@code DataScopeServiceTest} 先例),
- * 判定规则本身全走生产代码。第 0 关的行为(放行/拒绝/文案)不在这里,是
- * {@code AnswerSubmitGateIT} 按 W2 行为表逐行打的。
- */
-@DisplayName("FillAccessService - P95 三层角色模型入口解析(V48)")
-class FillAccessServiceTest {
-
- private static final int STUDENT_FACING = FormEnums.Audience.STUDENT.getCode();
- private static final int TEACHER_FACING = FormEnums.Audience.TEACHER.getCode();
- private static final long SCH = 990301L;
- private static final long OTHER_SCH = 990302L;
-
- private final InMemoryAdminScopeSource admins = new InMemoryAdminScopeSource();
- private final FillAccessService service = new FillAccessService(admins);
-
- // ==================== 层① FILL:本人身份可填 ====================
-
- @Test
- @DisplayName("V48 层①:学生(3)×面向学生=FILL(可填、不可代填);教师(1)×面向教师同理")
- void fillTierForMatchingAudience() {
- assertThat(service.resolve(user(3), form(SCH, "f-own", STUDENT_FACING))).containsExactly(Tier.FILL);
- assertThat(service.resolve(user(1), form(SCH, "f-own", TEACHER_FACING))).containsExactly(Tier.FILL);
- assertThat(service.canFill(EnumSet.of(Tier.FILL))).isTrue();
- assertThat(service.canProxy(EnumSet.of(Tier.FILL))).isFalse();
- assertThat(service.isAdmin(EnumSet.of(Tier.FILL))).isFalse();
- }
-
- // ==================== 层② MANAGE:教师×学生面向,只能代填 ====================
-
- @Test
- @DisplayName("V48 层②:教师(1)×面向学生=MANAGE(可代填、不可自填)——FILL 谓词不含这一层,正是 W2 行为表'教师自填学生面向=拒'的解析根据")
- void manageTierIsProxyOnly() {
- Set tiers = service.resolve(user(1), form(SCH, "f-own", STUDENT_FACING));
- assertThat(tiers).containsExactly(Tier.MANAGE);
- assertThat(service.canFill(tiers)).as("教师本人身份不能填学生面向表单").isFalse();
- assertThat(service.canProxy(tiers)).as("教师可代填本校学生").isTrue();
- assertThat(service.isAdmin(tiers)).isFalse();
- assertThat(service.topTier(tiers)).isEqualTo(Tier.MANAGE);
- }
-
- // ==================== 层③ ADMIN:四条来源分别命中 ====================
-
- @Test
- @DisplayName("V48 层③:校领导(2)/管理员(7) 两种人群都是 ADMIN,且 ADMIN 同时给可填与可代填")
- void adminRolesAreAdminTier() {
- for (int roleId : Arrays.asList(2, 7)) {
- assertThat(service.resolve(user(roleId), form(SCH, "f-own", STUDENT_FACING)))
- .as("roleId=" + roleId + " 面向学生")
- .containsExactly(Tier.ADMIN);
- assertThat(service.resolve(user(roleId), form(SCH, "f-own", TEACHER_FACING)))
- .as("roleId=" + roleId + " 面向教师")
- .containsExactly(Tier.ADMIN);
- Set tiers = service.resolve(user(roleId), form(SCH, "f-own", STUDENT_FACING));
- assertThat(service.canFill(tiers)).isTrue();
- assertThat(service.canProxy(tiers)).isTrue();
- assertThat(service.isAdmin(tiers)).isTrue();
- }
- }
-
- @Test
- @DisplayName("V48 层③:超管(type==1,不是 roleId)恒为 ADMIN,即使没有任何角色")
- void superAdminIsAdminTier() {
- BoaUser root = new BoaUser();
- root.setId(1);
- root.setType(1);
- root.setCurrentRole(role(9, (int) SCH));
- assertThat(service.resolve(root, form(SCH, "f-own", TEACHER_FACING))).containsExactly(Tier.ADMIN);
- }
-
- @Test
- @DisplayName("V48 层③:school_admin 命中会话校才升 ADMIN;会话切到别校即不命中(授权域跟随会话)")
- void schoolAdminFollowsSessionDomain() {
- BoaUser teacher = user(1);
- admins.grantSchoolAdmin(SCH, teacher.getId());
- assertThat(service.resolve(teacher, form(SCH, "f-own", STUDENT_FACING)))
- .containsExactlyInAnyOrder(Tier.ADMIN, Tier.MANAGE);
-
- BoaUser atOther = user(1);
- atOther.setCurrentRole(role(1, (int) OTHER_SCH));
- assertThat(service.resolve(atOther, form(SCH, "f-own", STUDENT_FACING)))
- .as("A 校的校级管理员授权不跟到 B 校会话")
- .containsExactly(Tier.MANAGE);
- }
-
- @Test
- @DisplayName("V48 层③:form_admin 命中该表单 uuid才升 ADMIN,同校未指定的表单不受影响")
- void formAdminIsPerForm() {
- BoaUser teacher = user(3);
- admins.grantFormAdmin("f-yes", teacher.getId());
- assertThat(service.resolve(teacher, form(SCH, "f-yes", STUDENT_FACING)))
- .containsExactlyInAnyOrder(Tier.ADMIN, Tier.FILL);
- assertThat(service.resolve(teacher, form(SCH, "f-no", STUDENT_FACING)))
- .as("没被指定的那张表单仍是普通学生")
- .containsExactly(Tier.FILL);
- }
-
- @Test
- @DisplayName("V48 层③:无学校归属的会话不查 school_admin,但 form_admin 仍按 uuid 命中(不要求会话校)")
- void adminTablesWithoutSchoolBinding() {
- BoaUser teacher = user(1);
- teacher.setCurrentRole(role(1, null));
- admins.grantSchoolAdmin(SCH, teacher.getId());
- assertThat(service.resolve(teacher, form(SCH, "f-own", STUDENT_FACING)))
- .as("给不出会话校就没有本校管理员可认")
- .containsExactly(Tier.MANAGE);
-
- admins.grantFormAdmin("f-own", teacher.getId());
- assertThat(service.resolve(teacher, form(SCH, "f-own", STUDENT_FACING)))
- .containsExactlyInAnyOrder(Tier.ADMIN, Tier.MANAGE);
- }
-
- // ==================== NONE 兜底 ====================
-
- @Test
- @DisplayName("V48 NONE:家长(4)/工作号(6)/局领导(5)/未知角色 = 空集(两种人群都是),三个谓词全 false")
- void peripheralRolesResolveToNone() {
- for (int roleId : Arrays.asList(4, 6, 5, 99)) {
- for (int audience : new int[]{STUDENT_FACING, TEACHER_FACING}) {
- Set tiers = service.resolve(user(roleId), form(SCH, "f-own", audience));
- assertThat(tiers).as("roleId=" + roleId + " audience=" + audience).isEmpty();
- assertThat(service.canFill(tiers)).isFalse();
- assertThat(service.canProxy(tiers)).isFalse();
- assertThat(service.isAdmin(tiers)).isFalse();
- assertThat(service.topTier(tiers)).isNull();
- }
- }
- }
-
- @Test
- @DisplayName("V48 NONE:学生(3)×面向教师=空集(原'不授填报模块行进不来'的格子,现在由中心解析直接判 NONE)")
- void studentOnTeacherFacingIsNone() {
- assertThat(service.resolve(user(3), form(SCH, "f-own", TEACHER_FACING))).isEmpty();
- }
-
- @Test
- @DisplayName("V48 NONE:roleIds 全不命中(空角色、会话也无 currentRole)=空集,不是兜底给一层")
- void noRolesAtAllIsNone() {
- BoaUser nobody = new BoaUser();
- nobody.setId(20001);
- nobody.setType(0);
- nobody.setCurrentRole(role(9, (int) SCH));
- assertThat(service.resolve(nobody, form(SCH, "f-own", STUDENT_FACING))).isEmpty();
-
- BoaUser bare = new BoaUser();
- bare.setId(20002);
- assertThat(service.resolve(bare, form(SCH, "f-own", STUDENT_FACING))).isEmpty();
- }
-
- // ==================== 多身份并集 ====================
-
- @Test
- @DisplayName("V48 并集:同时有学生(3)与教师(1)身份×面向学生 = {FILL, MANAGE}(可填可代填,两格都在不是二选一)")
- void teacherAndStudentIdentitiesUnion() {
- BoaUser dual = new BoaUser();
- dual.setId(20003);
- dual.setType(0);
- dual.setRoleIds(Arrays.asList(1, 3));
- dual.setCurrentRole(role(3, (int) SCH));
- Set tiers = service.resolve(dual, form(SCH, "f-own", STUDENT_FACING));
- assertThat(tiers).containsExactlyInAnyOrder(Tier.MANAGE, Tier.FILL);
- assertThat(service.canFill(tiers)).isTrue();
- assertThat(service.canProxy(tiers)).isTrue();
- assertThat(service.topTier(tiers)).isEqualTo(Tier.MANAGE);
- }
-
- @Test
- @DisplayName("V48 并集:教师×面向教师 = {FILL},同一人换到面向学生的表单 = {MANAGE}(人群换列,身份不变)")
- void sameUserDifferentAudienceDifferentTier() {
- BoaUser teacher = user(1);
- assertThat(service.resolve(teacher, form(SCH, "f-own", TEACHER_FACING))).containsExactly(Tier.FILL);
- assertThat(service.resolve(teacher, form(SCH, "f-own", STUDENT_FACING))).containsExactly(Tier.MANAGE);
- }
-
- @Test
- @DisplayName("V48 identityRoleIds 口径:roleIds 未含会话身份时补上(UserBase 两侧不一致不漏当前身份那格)")
- void currentRoleIsUnionedEvenIfMissingFromRoleIds() {
- BoaUser user = new BoaUser();
- user.setId(20004);
- user.setType(0);
- user.setRoleIds(Collections.singletonList(3));
- user.setCurrentRole(role(7, (int) SCH));
- assertThat(service.resolve(user, form(SCH, "f-own", STUDENT_FACING)))
- .as("会话身份=管理员(7) 虽不在 roleIds 里也要进 ADMIN")
- .containsExactlyInAnyOrder(Tier.ADMIN, Tier.FILL);
- }
-
- // ==================== 边界与拒绝 ====================
-
- @Test
- @DisplayName("V48:表单为 null 按面向学生列解析(同 DataScopeService 口径),教师即 MANAGE、学生即 FILL")
- void nullFormUsesStudentColumn() {
- assertThat(service.resolve(user(1), null)).containsExactly(Tier.MANAGE);
- assertThat(service.resolve(user(3), null)).containsExactly(Tier.FILL);
- }
-
- @Test
- @DisplayName("V48:audience 缺失按面向学生兜底(列本身 NOT NULL,这里是防脏行不静默开到教师面向)")
- void nullAudienceFallsBackToStudentColumn() {
- Form broken = new Form();
- broken.setSchId(SCH);
- broken.setUuid("f-broken");
- assertThat(service.resolve(user(3), broken)).containsExactly(Tier.FILL);
- assertThat(service.resolve(user(1), broken)).containsExactly(Tier.MANAGE);
- }
-
- @Test
- @DisplayName("V48:未登录不解析(拒绝,而不是给一个兜底层)")
- void requiresSession() {
- assertThatThrownBy(() -> service.resolve(null, form(SCH, "f-own", STUDENT_FACING)))
- .isInstanceOf(BizException.class).hasMessageContaining("未登录");
- assertThatThrownBy(() -> service.resolve(new BoaUser(), form(SCH, "f-own", STUDENT_FACING)))
- .isInstanceOf(BizException.class).hasMessageContaining("未登录");
- }
-
- // ==================== 构造小工具 ====================
-
- private BoaUser user(int roleId) {
- BoaUser user = new BoaUser();
- user.setId(20000 + roleId);
- user.setType(0);
- user.setRoleIds(Collections.singletonList(roleId));
- user.setCurrentRole(role(roleId, (int) SCH));
- return user;
- }
-
- private BoaRoleInfo role(Integer roleId, Integer schId) {
- BoaRoleInfo info = new BoaRoleInfo();
- info.setRoleId(roleId);
- info.setSchId(schId);
- return info;
- }
-
- private Form form(long schId, String uuid, int audience) {
- Form form = new Form();
- form.setSchId(schId);
- form.setUuid(uuid);
- form.setAudience(audience);
- return form;
- }
-}
diff --git a/src/test/java/com/yunhai/formcenter/service/FillableListIT.java b/src/test/java/com/yunhai/formcenter/service/FillableListIT.java
index d1c961b..78ca6a4 100644
--- a/src/test/java/com/yunhai/formcenter/service/FillableListIT.java
+++ b/src/test/java/com/yunhai/formcenter/service/FillableListIT.java
@@ -22,16 +22,20 @@ import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
/**
- * 「我可填」列表接口集成测试(自验项 V50):域遮蔽去重、仅已发布、appCode 过滤、
- * canFill/myTier、排序——四条都打真库({@code @Transactional} 回滚)。
+ * 「我可填」列表接口集成测试(自验项 V50,行形状按契约 v2 C2 复验):
+ * 域遮蔽去重、仅已发布、appCode 过滤、audience 入集、排序——都打真库({@code @Transactional} 回滚)。
*
- * 服务方法直调(同 {@code MyAnswerIT} 先例,不起 MockMvc):这一把要证的是取域/去重/过闸/排序四件事,
+ *
服务方法直调(同 {@code MyAnswerIT} 先例,不起 MockMvc):这一把要证的是取域/去重/过人群门/排序四件事,
* 全在服务内;信封与参数绑定是 {@code FormBaseControllerTest} 那类已在别处验过的壳。
+ *
+ * 与 P95 的两处行为差(§19 作废-3,逐格钉住):{@code canFill}/{@code myTier} 两个字段消失
+ * (入集即等价于可填,不再有"进了列表但不能自填"的中间态);管理员身份不再让行入集
+ * (填报资格只看 audience,两表只升数据范围——原"进 form_admin 就在列表里升到 ADMIN"随三层模型出清)。
*/
@SpringBootTest
@Import(AnswerTestSupport.TestGatewayConfiguration.class)
@Transactional
-@DisplayName("FillableFormService - 我可填列表(V50)")
+@DisplayName("FillableFormService - 我可填列表(V50 + C2 行形状)")
class FillableListIT extends AnswerTestSupport {
@org.springframework.beans.factory.annotation.Autowired
@@ -43,41 +47,47 @@ class FillableListIT extends AnswerTestSupport {
/** 快应用面(方案 §6.2):中心之外的 app_code,用来证 appCode 过滤与"列表跨应用"两件事 */
private static final String QUICK_APP = "fc-drill";
- // ==================== 入口层过滤 ====================
+ // ==================== audience 入集(列表与提交同一个公式) ====================
@Test
- @DisplayName("V50 canFill/myTier:学生×面向学生=(true,FILL)、教师×面向学生=(false,MANAGE)、教师×面向教师=(true,FILL)、管理员=(true,ADMIN)")
- void rowsCarryEntryLayerVerdicts() {
- publishAt(SCH, Form.CENTER_APP_CODE, "v50_student", "面向学生的表", 1, null);
- publishAt(SCH, Form.CENTER_APP_CODE, "v50_teacher", "面向教师的表", 2, null);
+ @DisplayName("V50/C2:入集只看 audience——学生得学生面向那张、教师得教师面向那张;多值共集的表单两人都有")
+ void membershipFollowsAudience() {
+ publishAt(SCH, Form.CENTER_APP_CODE, "v50_student", "面向学生的表", Collections.singletonList("STUDENT"), null);
+ publishAt(SCH, Form.CENTER_APP_CODE, "v50_teacher", "面向教师的表", Collections.singletonList("TEACHER"), null);
+ publishAt(SCH, Form.CENTER_APP_CODE, "v50_both", "师生共集", Arrays.asList("STUDENT", "TEACHER"), null);
- List asStudent = fillableService.listFillable(user(30001, SCH, 3), null);
- assertThat(asStudent).extracting(MyModels.FillableRow::getCode).containsExactly("v50_student");
- assertThat(asStudent.get(0).getCanFill()).isTrue();
- assertThat(asStudent.get(0).getMyTier()).isEqualTo("FILL");
+ assertThat(fillableService.listFillable(user(30001, SCH, 3), null))
+ .extracting(MyModels.FillableRow::getCode)
+ .containsExactlyInAnyOrder("v50_student", "v50_both");
+ assertThat(fillableService.listFillable(user(30002, SCH, 1), null))
+ .extracting(MyModels.FillableRow::getCode)
+ .containsExactlyInAnyOrder("v50_teacher", "v50_both");
+ }
- List asTeacher = fillableService.listFillable(user(30002, SCH, 1), null);
- assertThat(asTeacher).extracting(MyModels.FillableRow::getCode)
- .as("教师两张表都入集:学生面向那张只能代填,但数据管理 tab 要靠它")
- .containsExactlyInAnyOrder("v50_student", "v50_teacher");
- assertThat(row(asTeacher, "v50_student").getCanFill())
- .as("层②:入口给开(可代填),但本人不能自填")
- .isFalse();
- assertThat(row(asTeacher, "v50_student").getMyTier()).isEqualTo("MANAGE");
- assertThat(row(asTeacher, "v50_teacher").getCanFill()).isTrue();
- assertThat(row(asTeacher, "v50_teacher").getMyTier()).isEqualTo("FILL");
+ @Test
+ @DisplayName("C2 对照:教师×学生面向表单不再出现在列表里(P95 为'可代填'给它开了入口,v2 出清)")
+ void teacherNoLongerSeesStudentFacingFormsForProxying() {
+ publishAt(SCH, Form.CENTER_APP_CODE, "v50_student_only", "面向学生的表",
+ Collections.singletonList("STUDENT"), null);
- // 层③:管理员(7) 两种人群都可填,myTier=ADMIN
- List asAdmin = fillableService.listFillable(user(30003, SCH, 7), null);
- assertThat(asAdmin).hasSize(2);
- assertThat(asAdmin).allMatch(r -> Boolean.TRUE.equals(r.getCanFill()));
- assertThat(asAdmin).allMatch(r -> "ADMIN".equals(r.getMyTier()));
+ assertThat(fillableService.listFillable(user(30012, SCH, 1), null)).isEmpty();
+ }
+
+ @Test
+ @DisplayName("C2:ANONYMOUS 不参与登录会话的入集判定(匿名填报人不看列表,持 URL 直达)")
+ void anonymousOnlyFormIsNotListedForSessions() {
+ publishAt(SCH, Form.CENTER_APP_CODE, "v50_anon_only", "纯匿名问卷",
+ Collections.singletonList("ANONYMOUS"), null);
+
+ assertThat(fillableService.listFillable(user(30013, SCH, 3), null)).isEmpty();
+ assertThat(fillableService.listFillable(user(30014, SCH, 1), null)).isEmpty();
}
@Test
@DisplayName("V50 NONE 不入集:家长(4)/局领导(5)/工作号(6) 拿到空列表(不是报错、也不是全给)")
void peripheralRolesGetEmptyList() {
- publishAt(SCH, Form.CENTER_APP_CODE, "v50_none", "任意表单", 1, null);
+ publishAt(SCH, Form.CENTER_APP_CODE, "v50_none", "任意表单",
+ Collections.singletonList("STUDENT"), null);
for (int roleId : Arrays.asList(4, 5, 6)) {
assertThat(fillableService.listFillable(user(30100 + roleId, SCH, roleId), null))
.as("roleId=" + roleId)
@@ -86,21 +96,22 @@ class FillableListIT extends AnswerTestSupport {
}
@Test
- @DisplayName("V50 两表命中:进了 form_admin 的学生在该表单上升到 ADMIN;school_admin 命中会话校则本校全部表单 ADMIN")
- void adminTablesLiftTierInList() {
- String uuid = publishAt(SCH, Form.CENTER_APP_CODE, "v50_admin_tables", "两表用例", 1, null);
- publishAt(SCH, Form.CENTER_APP_CODE, "v50_other", "另一张", 1, null);
+ @DisplayName("V50/C2 对照:管理员两表不影响列表——进 form_admin/school_admin 的学生的入集与进表前一致")
+ void adminTablesDoNotLiftFillableMembership() {
+ String uuid = publishAt(SCH, Form.CENTER_APP_CODE, "v50_admin_tables", "两表用例",
+ Collections.singletonList("STUDENT"), null);
+ publishAt(SCH, Form.CENTER_APP_CODE, "v50_other", "另一张",
+ Collections.singletonList("STUDENT"), null);
BoaUser student = user(30004, SCH, 3);
- grantFormAdmin(uuid, student.getId(), SCH);
- List afterFormAdmin = fillableService.listFillable(student, null);
- assertThat(row(afterFormAdmin, "v50_admin_tables").getMyTier()).isEqualTo("ADMIN");
- assertThat(row(afterFormAdmin, "v50_other").getMyTier())
- .as("未指定的那张仍是普通学生档")
- .isEqualTo("FILL");
+ assertThat(fillableService.listFillable(student, null))
+ .extracting(MyModels.FillableRow::getCode).containsExactly("v50_admin_tables", "v50_other");
+ grantFormAdmin(uuid, student.getId(), SCH);
grantSchoolAdmin(student.getId(), SCH);
- assertThat(fillableService.listFillable(student, null)).allMatch(r -> "ADMIN".equals(r.getMyTier()));
+ assertThat(fillableService.listFillable(student, null))
+ .as("两表只升数据范围(ALL 档),不升填报入口——列表行里没有档位概念了")
+ .extracting(MyModels.FillableRow::getCode).containsExactly("v50_admin_tables", "v50_other");
}
// ==================== 域与遮蔽去重 ====================
@@ -108,9 +119,12 @@ class FillableListIT extends AnswerTestSupport {
@Test
@DisplayName("V50 域:会话校 ∪ 平台级(sch_id=0) 都在集内;同 (app_code, code) 本校行遮蔽平台行(只出一行、取本校的 name)")
void domainIsSchoolUnionPlatformWithSchoolWinning() {
- publishAt(Form.PLATFORM_SCH_ID, Form.CENTER_APP_CODE, "v50_shadow", "平台级的那张", 1, null);
- publishAt(SCH, Form.CENTER_APP_CODE, "v50_shadow", "本校的那张", 1, null);
- publishAt(Form.PLATFORM_SCH_ID, Form.CENTER_APP_CODE, "v50_base_only", "只有平台级", 1, null);
+ publishAt(Form.PLATFORM_SCH_ID, Form.CENTER_APP_CODE, "v50_shadow", "平台级的那张",
+ Collections.singletonList("STUDENT"), null);
+ publishAt(SCH, Form.CENTER_APP_CODE, "v50_shadow", "本校的那张",
+ Collections.singletonList("STUDENT"), null);
+ publishAt(Form.PLATFORM_SCH_ID, Form.CENTER_APP_CODE, "v50_base_only", "只有平台级",
+ Collections.singletonList("STUDENT"), null);
List rows = fillableService.listFillable(user(30005, SCH, 3), null);
assertThat(rows).extracting(MyModels.FillableRow::getCode)
@@ -123,8 +137,9 @@ class FillableListIT extends AnswerTestSupport {
@Test
@DisplayName("V50 他校表单不进集(同 code 在他校存在,本校会话看不到)")
void otherSchoolFormsAreNotInDomain() {
- publishAt(OTHER_SCH, Form.CENTER_APP_CODE, "v50_foreign", "他校的表", 1, null);
- assertThat(fillableService.listFillable(user(30006, SCH, 7), null)).isEmpty();
+ publishAt(OTHER_SCH, Form.CENTER_APP_CODE, "v50_foreign", "他校的表",
+ Collections.singletonList("STUDENT"), null);
+ assertThat(fillableService.listFillable(user(30006, SCH, 3), null)).isEmpty();
}
// ==================== 生命周期 ====================
@@ -136,17 +151,19 @@ class FillableListIT extends AnswerTestSupport {
draft.setAppCode(Form.CENTER_APP_CODE);
draft.setCode("v50_draft");
draft.setName("草稿");
- String draftUuid = defService.create(SCH, draft).getFormUuid();
+ String draftUuid = defService.create(SCH, draft, null).getFormUuid();
FormModels.FormSaveRequest save = new FormModels.FormSaveRequest();
save.setUuid(draftUuid);
save.setSchemaJson(SCHEMA);
defService.save(SCH, save);
- String disabledUuid = publishAt(SCH, Form.CENTER_APP_CODE, "v50_disabled", "停用的", 1, null);
+ String disabledUuid = publishAt(SCH, Form.CENTER_APP_CODE, "v50_disabled", "停用的",
+ Collections.singletonList("STUDENT"), null);
defService.disable(SCH, disabledUuid);
- String deletedUuid = publishAt(SCH, Form.CENTER_APP_CODE, "v50_deleted", "软删的", 1, null);
+ String deletedUuid = publishAt(SCH, Form.CENTER_APP_CODE, "v50_deleted", "软删的",
+ Collections.singletonList("STUDENT"), null);
defService.softDelete(SCH, deletedUuid);
- publishAt(SCH, Form.CENTER_APP_CODE, "v50_live", "正常发布", 1, null);
+ publishAt(SCH, Form.CENTER_APP_CODE, "v50_live", "正常发布", Collections.singletonList("STUDENT"), null);
assertThat(fillableService.listFillable(user(30007, SCH, 3), null))
.extracting(MyModels.FillableRow::getCode).containsExactly("v50_live");
@@ -157,8 +174,8 @@ class FillableListIT extends AnswerTestSupport {
@Test
@DisplayName("V50 appCode:缺省=全部应用(中心 + 快应用都入集);传 appCode 只收窄到该应用,中心行不外泄")
void appCodeFilterNarrowsTheList() {
- publishAt(SCH, Form.CENTER_APP_CODE, "v50_center", "中心的表", 1, null);
- publishAt(SCH, QUICK_APP, "v50_quick", "快应用的表", 1, null);
+ publishAt(SCH, Form.CENTER_APP_CODE, "v50_center", "中心的表", Collections.singletonList("STUDENT"), null);
+ publishAt(SCH, QUICK_APP, "v50_quick", "快应用的表", Collections.singletonList("STUDENT"), null);
BoaUser student = user(30008, SCH, 3);
assertThat(fillableService.listFillable(student, null))
@@ -178,11 +195,11 @@ class FillableListIT extends AnswerTestSupport {
@Test
@DisplayName("V50 排序:deadline 升序、不限截止的排最后、同档按 code 升序")
void sortedByDeadlineThenCodeWithNullDeadlineLast() {
- publishAt(SCH, Form.CENTER_APP_CODE, "b_late", "晚截止", 1, "2026-12-01");
- publishAt(SCH, Form.CENTER_APP_CODE, "a_early", "早截止", 1, "2026-11-01");
- publishAt(SCH, Form.CENTER_APP_CODE, "z_none", "不限截止", 1, null);
- publishAt(SCH, Form.CENTER_APP_CODE, "c_same_1", "同档一", 1, "2026-11-15");
- publishAt(SCH, Form.CENTER_APP_CODE, "b_same_2", "同档二", 1, "2026-11-15");
+ publishAt(SCH, Form.CENTER_APP_CODE, "b_late", "晚截止", Collections.singletonList("STUDENT"), "2026-12-01");
+ publishAt(SCH, Form.CENTER_APP_CODE, "a_early", "早截止", Collections.singletonList("STUDENT"), "2026-11-01");
+ publishAt(SCH, Form.CENTER_APP_CODE, "z_none", "不限截止", Collections.singletonList("STUDENT"), null);
+ publishAt(SCH, Form.CENTER_APP_CODE, "c_same_1", "同档一", Collections.singletonList("STUDENT"), "2026-11-15");
+ publishAt(SCH, Form.CENTER_APP_CODE, "b_same_2", "同档二", Collections.singletonList("STUDENT"), "2026-11-15");
assertThat(fillableService.listFillable(user(30009, SCH, 3), null))
.extracting(MyModels.FillableRow::getCode)
@@ -190,9 +207,10 @@ class FillableListIT extends AnswerTestSupport {
}
@Test
- @DisplayName("V50 行形状:audience/fillLimit 下发列值、deadline 格式化、groupTag 透出,且不带任何库内 id")
+ @DisplayName("C2 行形状:audience 下数组、fillLimit 下发列值、deadline 格式化、groupTag 透出,无 canFill/myTier、无库内 id")
void rowCarriesDefinitionFieldsInContractShape() {
- String uuid = publishAt(SCH, Form.CENTER_APP_CODE, "v50_shape", "形状用例", 1, "2026-11-20");
+ String uuid = publishAt(SCH, Form.CENTER_APP_CODE, "v50_shape", "形状用例",
+ Arrays.asList("STUDENT", "TEACHER"), "2026-11-20");
FormModels.FormConfigRequest config = new FormModels.FormConfigRequest();
config.setFillLimit(1);
config.setGroupTag("五年级");
@@ -201,7 +219,7 @@ class FillableListIT extends AnswerTestSupport {
MyModels.FillableRow row = row(fillableService.listFillable(user(30010, SCH, 3), null), "v50_shape");
assertThat(row.getName()).isEqualTo("形状用例");
assertThat(row.getAppCode()).isEqualTo(Form.CENTER_APP_CODE);
- assertThat(row.getAudience()).isEqualTo(1);
+ assertThat(row.getAudience()).containsExactly("STUDENT", "TEACHER");
assertThat(row.getFillLimit()).isEqualTo(1);
assertThat(row.getGroupTag()).isEqualTo("五年级");
assertThat(row.getDeadline()).isEqualTo("2026-11-20 23:59:59");
@@ -227,13 +245,14 @@ class FillableListIT extends AnswerTestSupport {
// ==================== 小工具 ====================
- /** 建表单→存 schema→发布,可选人群与截止(deadline 只给日期时后端补当日 23:59:59,方案 §5.4) */
- private String publishAt(Long schId, String appCode, String code, String name, Integer audience, String deadline) {
+ /** 建表单→存 schema→发布→配人群与截止(deadline 只给日期时后端补当日 23:59:59,方案 §5.4) */
+ private String publishAt(Long schId, String appCode, String code, String name,
+ List audience, String deadline) {
FormModels.FormCreateRequest create = new FormModels.FormCreateRequest();
create.setAppCode(appCode);
create.setCode(code);
create.setName(name);
- String uuid = defService.create(schId, create).getFormUuid();
+ String uuid = defService.create(schId, create, null).getFormUuid();
FormModels.FormSaveRequest save = new FormModels.FormSaveRequest();
save.setUuid(uuid);
save.setSchemaJson(SCHEMA);
diff --git a/src/test/java/com/yunhai/formcenter/service/FormCopyIT.java b/src/test/java/com/yunhai/formcenter/service/FormCopyIT.java
index e4f450b..66b40b9 100644
--- a/src/test/java/com/yunhai/formcenter/service/FormCopyIT.java
+++ b/src/test/java/com/yunhai/formcenter/service/FormCopyIT.java
@@ -56,7 +56,7 @@ class FormCopyIT {
req.setUuid(before.getFormUuid());
req.setCode("honor_apply_2027");
req.setName("荣誉申报 2027");
- FormModels.FormView copy = defService.copy(SCH, req);
+ FormModels.FormView copy = defService.copy(SCH, req, null);
// 副本身份全新
assertThat(copy.getFormUuid()).isNotEqualTo(before.getFormUuid()).hasSize(32);
@@ -107,7 +107,7 @@ class FormCopyIT {
FormModels.FormConfigRequest config = new FormModels.FormConfigRequest();
config.setUuid(source.getFormUuid());
- config.setAudience(FormEnums.Audience.TEACHER.getCode());
+ config.setAudience(java.util.Collections.singletonList("TEACHER"));
config.setFillLimit(FormEnums.FillLimit.ONCE.getCode());
config.setDeadline("2020-01-01");
config.setAnswerNoEnabled(1);
@@ -122,9 +122,9 @@ class FormCopyIT {
req.setUuid(source.getFormUuid());
req.setCode("config_copy");
req.setName("带配置的表单 副本");
- FormModels.FormView copy = defService.copy(SCH, req);
+ FormModels.FormView copy = defService.copy(SCH, req, null);
- assertThat(copy.getAudience()).isEqualTo(FormEnums.Audience.TEACHER.getCode());
+ assertThat(copy.getAudience()).isEqualTo(java.util.Collections.singletonList("TEACHER"));
assertThat(copy.getFillLimit()).isEqualTo(FormEnums.FillLimit.ONCE.getCode());
assertThat(copy.getAnswerNoEnabled()).isEqualTo(1);
assertThat(copy.getGroupTag()).isEqualTo("campus");
@@ -145,7 +145,7 @@ class FormCopyIT {
toQuick.setAppCode("honor");
toQuick.setCode("app_copy");
toQuick.setName("跨应用复制 副本");
- FormModels.FormView copied = defService.copy(SCH, toQuick);
+ FormModels.FormView copied = defService.copy(SCH, toQuick, null);
assertThat(copied.getAppCode()).isEqualTo("honor");
FormModels.FormCopyRequest dup = new FormModels.FormCopyRequest();
@@ -153,14 +153,14 @@ class FormCopyIT {
dup.setAppCode("honor");
dup.setCode("app_copy");
dup.setName("重复 code 副本");
- assertThatThrownBy(() -> defService.copy(SCH, dup))
+ assertThatThrownBy(() -> defService.copy(SCH, dup, null))
.isInstanceOf(BizException.class).hasMessageContaining("code 已存在");
FormModels.FormCopyRequest sameAsSource = new FormModels.FormCopyRequest();
sameAsSource.setUuid(source.getFormUuid());
sameAsSource.setCode("app_source");
sameAsSource.setName("就地覆盖");
- assertThatThrownBy(() -> defService.copy(SCH, sameAsSource))
+ assertThatThrownBy(() -> defService.copy(SCH, sameAsSource, null))
.isInstanceOf(BizException.class).hasMessageContaining("code 已存在");
}
@@ -172,15 +172,15 @@ class FormCopyIT {
req.setUuid(source.getFormUuid());
req.setCode("bad code");
req.setName("非法 code 副本");
- assertThatThrownBy(() -> defService.copy(SCH, req)).hasMessageContaining("code 需为人可读串");
+ assertThatThrownBy(() -> defService.copy(SCH, req, null)).hasMessageContaining("code 需为人可读串");
req.setCode("guard_copy");
req.setName(" ");
- assertThatThrownBy(() -> defService.copy(SCH, req)).hasMessageContaining("表单名称不能为空");
+ assertThatThrownBy(() -> defService.copy(SCH, req, null)).hasMessageContaining("表单名称不能为空");
req.setName("合法名");
req.setUuid("00000000000000000000000000000000");
- assertThatThrownBy(() -> defService.copy(SCH, req)).hasMessageContaining("表单不存在");
+ assertThatThrownBy(() -> defService.copy(SCH, req, null)).hasMessageContaining("表单不存在");
}
// ==================== 辅助 ====================
@@ -190,7 +190,7 @@ class FormCopyIT {
req.setAppCode(Form.CENTER_APP_CODE);
req.setCode(code);
req.setName(name);
- return defService.create(SCH, req);
+ return defService.create(SCH, req, null);
}
private FormModels.FormSaveRequest save(String uuid, String schemaJson, String changeLog) {
diff --git a/src/test/java/com/yunhai/formcenter/service/FormDefServiceIT.java b/src/test/java/com/yunhai/formcenter/service/FormDefServiceIT.java
index ff4f3fa..ca7b3cf 100644
--- a/src/test/java/com/yunhai/formcenter/service/FormDefServiceIT.java
+++ b/src/test/java/com/yunhai/formcenter/service/FormDefServiceIT.java
@@ -5,6 +5,7 @@ import com.yunhai.formcenter.entity.Form;
import com.yunhai.formcenter.enums.FormEnums;
import com.yunhai.formcenter.model.FormModels;
import com.yunhai.platform.apistd.BizException;
+import com.yunhai.platform.utils.exception.ErrorCode;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
@@ -55,8 +56,8 @@ class FormDefServiceIT {
@Test
@DisplayName("V6:同校同 code、不同 app_code 的两条表单共存;同 app_code 重复才被拒")
void sameCodeUnderDifferentAppCodesCoexist() {
- FormModels.FormView center = defService.create(SCH, create("form-center", "honor_apply"));
- FormModels.FormView quick = defService.create(SCH, create("honor", "honor_apply"));
+ FormModels.FormView center = defService.create(SCH, create("form-center", "honor_apply"), null);
+ FormModels.FormView quick = defService.create(SCH, create("honor", "honor_apply"), null);
assertThat(center.getFormUuid()).isNotEqualTo(quick.getFormUuid());
assertThat(center.getAppCode()).isEqualTo("form-center");
@@ -64,7 +65,7 @@ class FormDefServiceIT {
assertThat(defService.list(SCH, "honor", null)).hasSize(1);
assertThat(defService.list(SCH, null, null)).hasSize(2);
- assertThatThrownBy(() -> defService.create(SCH, create("form-center", "honor_apply")))
+ assertThatThrownBy(() -> defService.create(SCH, create("form-center", "honor_apply"), null))
.isInstanceOf(BizException.class)
.hasMessageContaining("code 已存在");
}
@@ -72,8 +73,8 @@ class FormDefServiceIT {
@Test
@DisplayName("两校同 (app_code,code) 各自独立,互不可见(租户隔离)")
void twoSchoolsAreIndependentNamespaces() {
- defService.create(SCH, create("form-center", "shared_code"));
- FormModels.FormView other = defService.create(OTHER_SCH, create("form-center", "shared_code"));
+ defService.create(SCH, create("form-center", "shared_code"), null);
+ FormModels.FormView other = defService.create(OTHER_SCH, create("form-center", "shared_code"), null);
assertThat(other.getSchId()).isEqualTo(OTHER_SCH);
assertThat(defService.list(SCH, null, null)).hasSize(1);
assertThatThrownBy(() -> defService.get(SCH, other.getFormUuid()))
@@ -85,7 +86,7 @@ class FormDefServiceIT {
@Test
@DisplayName("V7:已发布行不可就地修改;改版=v+1 新行,老行 schema_json 逐字节不变;升版瞬间旧版仍可解析")
void publishedVersionIsImmutableAndUpgradeAddsNewRow() {
- FormModels.FormView form = defService.create(SCH, create("form-center", "honor_apply"));
+ FormModels.FormView form = defService.create(SCH, create("form-center", "honor_apply"), null);
String uuid = form.getFormUuid();
defService.save(SCH, save(uuid, SCHEMA_A, null));
@@ -121,7 +122,7 @@ class FormDefServiceIT {
@Test
@DisplayName("没有草稿时重复发布被拒(不给「就地改已发布行」留任何入口)")
void republishWithoutDraftIsRejected() {
- String uuid = defService.create(SCH, create("form-center", "only_once")).getFormUuid();
+ String uuid = defService.create(SCH, create("form-center", "only_once"), null).getFormUuid();
defService.save(SCH, save(uuid, SCHEMA_A, null));
defService.publish(SCH, uuid, null);
@@ -133,7 +134,7 @@ class FormDefServiceIT {
@Test
@DisplayName("同 key 换 type 的升版被拒;改 label/加 searchable/增删字段放行(契约 §2 key 稳定性)")
void fieldIdentityIsStableAcrossVersions() {
- String uuid = defService.create(SCH, create("form-center", "key_stability")).getFormUuid();
+ String uuid = defService.create(SCH, create("form-center", "key_stability"), null).getFormUuid();
defService.save(SCH, save(uuid, SCHEMA_A, null));
defService.publish(SCH, uuid, null);
@@ -147,7 +148,7 @@ class FormDefServiceIT {
@Test
@DisplayName("非法 schema 在保存阶段就被拦下,草稿行原样不动(不进库)")
void saveRejectsInvalidSchemaWithoutTouchingDraft() {
- String uuid = defService.create(SCH, create("form-center", "bad_schema")).getFormUuid();
+ String uuid = defService.create(SCH, create("form-center", "bad_schema"), null).getFormUuid();
defService.save(SCH, save(uuid, SCHEMA_A, null));
String stringArrayOptions = "{\"groups\":[{\"key\":\"g\",\"label\":\"组\",\"fields\":["
@@ -167,7 +168,7 @@ class FormDefServiceIT {
@Test
@DisplayName("建草稿即生成 32 位 uuid,跨版本不变;响应 DTO 不含库内 id(V5 的响应面断言)")
void uuidGeneratedAtDraftAndIdsNotExposed() throws Exception {
- FormModels.FormView created = defService.create(SCH, create("form-center", "uuid_check"));
+ FormModels.FormView created = defService.create(SCH, create("form-center", "uuid_check"), null);
assertThat(created.getFormUuid()).hasSize(32).matches("^[0-9a-f]{32}$");
defService.save(SCH, save(created.getFormUuid(), SCHEMA_A, null));
@@ -186,7 +187,7 @@ class FormDefServiceIT {
@Test
@DisplayName("code 从未发布时可改(同步草稿行冗余列),发布后不可改(契约 §1)")
void codeEditableOnlyBeforePublish() {
- FormModels.FormView created = defService.create(SCH, create("form-center", "draft_code"));
+ FormModels.FormView created = defService.create(SCH, create("form-center", "draft_code"), null);
String uuid = created.getFormUuid();
FormModels.FormSaveRequest rename = save(uuid, SCHEMA_A, null);
@@ -201,18 +202,18 @@ class FormDefServiceIT {
again.setCode("other_code");
assertThatThrownBy(() -> defService.save(SCH, again))
.isInstanceOf(BizException.class).hasMessageContaining("code 发布后不可改");
- assertThatThrownBy(() -> defService.create(SCH, create("form-center", "final_code")))
+ assertThatThrownBy(() -> defService.create(SCH, create("form-center", "final_code"), null))
.hasMessageContaining("code 已存在");
}
@Test
@DisplayName("软删表单后:列表与 get 都查不到,但同 code 仍被 uk 占用并给出可理解提示")
void softDeletedFormKeepsItsCode() {
- String uuid = defService.create(SCH, create("form-center", "deleted_code")).getFormUuid();
+ String uuid = defService.create(SCH, create("form-center", "deleted_code"), null).getFormUuid();
defService.softDelete(SCH, uuid);
assertThat(defService.list(SCH, null, null)).isEmpty();
assertThatThrownBy(() -> defService.get(SCH, uuid)).hasMessageContaining("表单不存在");
- assertThatThrownBy(() -> defService.create(SCH, create("form-center", "deleted_code")))
+ assertThatThrownBy(() -> defService.create(SCH, create("form-center", "deleted_code"), null))
.hasMessageContaining("软删行仍占用该 code");
}
@@ -222,12 +223,12 @@ class FormDefServiceIT {
@DisplayName("resolve:草稿不暴露;发布后可解析;本校遮蔽平台级且不回落;未发布/停用给业务级错误")
void resolveAppliesSchoolShadowing() {
// 平台级(sch_id=0)同 code 表单
- String platformUuid = defService.create(Form.PLATFORM_SCH_ID, create("form-center", "shadow")).getFormUuid();
+ String platformUuid = defService.create(Form.PLATFORM_SCH_ID, create("form-center", "shadow"), null).getFormUuid();
defService.save(Form.PLATFORM_SCH_ID, save(platformUuid, SCHEMA_A, null));
defService.publish(Form.PLATFORM_SCH_ID, platformUuid, null);
// 本校同 code:先草稿——resolve 不能穿透到平台级
- String schoolUuid = defService.create(SCH, create("form-center", "shadow")).getFormUuid();
+ String schoolUuid = defService.create(SCH, create("form-center", "shadow"), null).getFormUuid();
assertThatThrownBy(() -> defService.resolve(SCH, "form-center", "shadow"))
.isInstanceOf(BizException.class).hasMessageContaining("尚未发布");
@@ -250,8 +251,8 @@ class FormDefServiceIT {
@Test
@DisplayName("resolve 只按 app_code 分命名空间:同 code 不同 app_code 各回各家")
void resolveIsScopedByAppCode() {
- String center = defService.create(SCH, create("form-center", "cross_app")).getFormUuid();
- String quick = defService.create(SCH, create("honor", "cross_app")).getFormUuid();
+ String center = defService.create(SCH, create("form-center", "cross_app"), null).getFormUuid();
+ String quick = defService.create(SCH, create("honor", "cross_app"), null).getFormUuid();
for (String pair : new String[]{center, quick}) {
defService.save(SCH, save(pair, SCHEMA_A, null));
defService.publish(SCH, pair, null);
@@ -263,7 +264,7 @@ class FormDefServiceIT {
@Test
@DisplayName("resolve 未发布过 / 不存在的 code:明确错误,不静默返回空")
void resolveRejectsUnknownAndUnpublished() {
- String uuid = defService.create(SCH, create("form-center", "never_published")).getFormUuid();
+ String uuid = defService.create(SCH, create("form-center", "never_published"), null).getFormUuid();
assertThatThrownBy(() -> defService.resolve(SCH, "form-center", "never_published"))
.hasMessageContaining("尚未发布");
assertThatThrownBy(() -> defService.resolve(SCH, "form-center", "no_such_code"))
@@ -275,7 +276,7 @@ class FormDefServiceIT {
@Test
@DisplayName("停用只断填报入口、恢复回当前版本(不产生新版本行)")
void disableAndEnableRestoreCurrentVersion() {
- String uuid = defService.create(SCH, create("form-center", "lifecycle")).getFormUuid();
+ String uuid = defService.create(SCH, create("form-center", "lifecycle"), null).getFormUuid();
defService.save(SCH, save(uuid, SCHEMA_A, null));
defService.publish(SCH, uuid, null);
@@ -297,13 +298,13 @@ class FormDefServiceIT {
@Test
@DisplayName("V12 + 表单级配置:状态定义/面向人群/限一次/截止/报名号都在头表,改它们不升版(不进快照)")
void formLevelConfigDoesNotCreateVersion() {
- String uuid = defService.create(SCH, create("form-center", "config_test")).getFormUuid();
+ String uuid = defService.create(SCH, create("form-center", "config_test"), null).getFormUuid();
defService.save(SCH, save(uuid, SCHEMA_A, null));
defService.publish(SCH, uuid, null);
FormModels.FormConfigRequest config = new FormModels.FormConfigRequest();
config.setUuid(uuid);
- config.setAudience(FormEnums.Audience.TEACHER.getCode());
+ config.setAudience(java.util.Collections.singletonList("TEACHER"));
config.setFillLimit(FormEnums.FillLimit.ONCE.getCode());
config.setDeadline("2026-11-30");
config.setAnswerNoEnabled(1);
@@ -311,7 +312,7 @@ class FormDefServiceIT {
FormModels.FormView configured = defService.updateConfig(SCH, config);
assertThat(configured.getCurrentVersion()).isEqualTo(1);
assertThat(configured.getDeadline()).isEqualTo("2026-11-30 23:59:59");
- assertThat(configured.getAudience()).isEqualTo(2);
+ assertThat(configured.getAudience()).containsExactly("TEACHER");
assertThat(configured.getFillLimit()).isEqualTo(1);
assertThat(configured.getAnswerNoEnabled()).isEqualTo(1);
assertThat(configured.getGroupTag()).isEqualTo("honors");
@@ -338,20 +339,31 @@ class FormDefServiceIT {
}
@Test
- @DisplayName("配置项取值范围严格:audience 只 1/2、fillLimit 与开关只 0/1、groupTag ≤64")
+ @DisplayName("配置项取值范围严格:audience 只闭集三码、fillLimit 与开关只 0/1、groupTag ≤64")
void configValueRangesAreGuarded() {
- String uuid = defService.create(SCH, create("form-center", "config_guard")).getFormUuid();
+ String uuid = defService.create(SCH, create("form-center", "config_guard"), null).getFormUuid();
FormModels.FormConfigRequest config = new FormModels.FormConfigRequest();
config.setUuid(uuid);
- config.setAudience(3);
- assertThatThrownBy(() -> defService.updateConfig(SCH, config)).hasMessageContaining("audience");
- config.setAudience(1);
+ config.setAudience(java.util.Collections.singletonList("PARENT"));
+ assertThatThrownBy(() -> defService.updateConfig(SCH, config))
+ .isInstanceOf(BizException.class)
+ .hasMessageContaining("audience 只能是 STUDENT/TEACHER/ANONYMOUS");
+ // 空数组=把表单配成"谁都不面向",同样拒(列表与提交两侧都会因此恒拒,属于配置事故不是配置项)
+ config.setAudience(java.util.Collections.emptyList());
+ assertThatThrownBy(() -> defService.updateConfig(SCH, config)).hasMessageContaining("不能是空数组");
+ config.setAudience(java.util.Collections.singletonList("STUDENT"));
config.setFillLimit(2);
assertThatThrownBy(() -> defService.updateConfig(SCH, config)).hasMessageContaining("fillLimit");
config.setFillLimit(0);
config.setAnswerNoEnabled(7);
assertThatThrownBy(() -> defService.updateConfig(SCH, config)).hasMessageContaining("answerNoEnabled");
config.setAnswerNoEnabled(0);
+ config.setFrozen(3);
+ assertThatThrownBy(() -> defService.updateConfig(SCH, config)).hasMessageContaining("frozen");
+ config.setFrozen(0);
+ config.setAnonCookieMinutes(0);
+ assertThatThrownBy(() -> defService.updateConfig(SCH, config)).hasMessageContaining("anonCookieMinutes");
+ config.setAnonCookieMinutes(null);
StringBuilder longTag = new StringBuilder();
for (int i = 0; i < 65; i++) {
longTag.append('t');
@@ -360,6 +372,61 @@ class FormDefServiceIT {
assertThatThrownBy(() -> defService.updateConfig(SCH, config)).hasMessageContaining("groupTag");
}
+ @Test
+ @DisplayName("V66:audience 含 ANONYMOUS 时 fill_limit 必须为 0——两侧(先配人群 / 先配限次)都在 config 通道拒(C1/§3.2)")
+ void anonymousAudienceRejectsFillLimit() {
+ String uuid = defService.create(SCH, create("form-center", "anon_limit"), null).getFormUuid();
+
+ FormModels.FormConfigRequest toAnon = new FormModels.FormConfigRequest();
+ toAnon.setUuid(uuid);
+ toAnon.setAudience(java.util.Arrays.asList("STUDENT", "ANONYMOUS"));
+ toAnon.setFillLimit(FormEnums.FillLimit.ONCE.getCode());
+ assertThatThrownBy(() -> defService.updateConfig(SCH, toAnon))
+ .isInstanceOfSatisfying(BizException.class, e -> {
+ assertThat(e.getErrorCode()).isEqualTo(ErrorCode.CUSTOM);
+ assertThat(e.getMessage()).contains("不支持限次");
+ });
+
+ // 先配成匿名+不限次,再把限次打开——同一约束在合并后的状态上判,不能靠入参顺序绕过
+ FormModels.FormConfigRequest anonOnly = new FormModels.FormConfigRequest();
+ anonOnly.setUuid(uuid);
+ anonOnly.setAudience(java.util.Collections.singletonList("ANONYMOUS"));
+ assertThat(defService.updateConfig(SCH, anonOnly).getAudience())
+ .containsExactly("ANONYMOUS");
+ FormModels.FormConfigRequest thenOnce = new FormModels.FormConfigRequest();
+ thenOnce.setUuid(uuid);
+ thenOnce.setFillLimit(FormEnums.FillLimit.ONCE.getCode());
+ assertThatThrownBy(() -> defService.updateConfig(SCH, thenOnce))
+ .isInstanceOf(BizException.class).hasMessageContaining("不支持限次");
+
+ // 关掉限次后匿名配置照常可存(默认列值就是 0)
+ FormModels.FormConfigRequest backToUnlimited = new FormModels.FormConfigRequest();
+ backToUnlimited.setUuid(uuid);
+ backToUnlimited.setFillLimit(FormEnums.FillLimit.UNLIMITED.getCode());
+ assertThat(defService.updateConfig(SCH, backToUnlimited).getFillLimit()).isEqualTo(0);
+ }
+
+ @Test
+ @DisplayName("V68 前半:冻结开关与匿名防重窗口是头表配置项,改它们不升版、默认值=frozen 0 / 10 分钟")
+ void frozenAndAnonWindowAreHeadTableConfig() {
+ String uuid = defService.create(SCH, create("form-center", "frozen_cfg"), null).getFormUuid();
+ defService.save(SCH, save(uuid, SCHEMA_A, null));
+ defService.publish(SCH, uuid, null);
+
+ FormModels.FormView created = defService.get(SCH, uuid);
+ assertThat(created.getFrozen()).isEqualTo(0);
+ assertThat(created.getAnonCookieMinutes()).isEqualTo(10);
+
+ FormModels.FormConfigRequest config = new FormModels.FormConfigRequest();
+ config.setUuid(uuid);
+ config.setFrozen(1);
+ config.setAnonCookieMinutes(30);
+ FormModels.FormView configured = defService.updateConfig(SCH, config);
+ assertThat(configured.getFrozen()).isEqualTo(1);
+ assertThat(configured.getAnonCookieMinutes()).isEqualTo(30);
+ assertThat(defService.versions(SCH, uuid)).as("配置改动不产生新版本行").hasSize(1);
+ }
+
// ==================== 辅助 ====================
private FormModels.FormCreateRequest create(String appCode, String code) {
diff --git a/src/test/java/com/yunhai/formcenter/service/FormDictServiceIT.java b/src/test/java/com/yunhai/formcenter/service/FormDictServiceIT.java
index ce64359..437d204 100644
--- a/src/test/java/com/yunhai/formcenter/service/FormDictServiceIT.java
+++ b/src/test/java/com/yunhai/formcenter/service/FormDictServiceIT.java
@@ -216,7 +216,7 @@ class FormDictServiceIT {
FormModels.FormCreateRequest req = new FormModels.FormCreateRequest();
req.setCode(code);
req.setName("字典测试 " + code);
- FormModels.FormView created = defService.create(SCH, req);
+ FormModels.FormView created = defService.create(SCH, req, null);
FormModels.FormSaveRequest save = new FormModels.FormSaveRequest();
save.setUuid(created.getFormUuid());
save.setSchemaJson(schemaJson);
diff --git a/src/test/java/com/yunhai/formcenter/service/MyAnswerIT.java b/src/test/java/com/yunhai/formcenter/service/MyAnswerIT.java
index 16f050d..15adcdf 100644
--- a/src/test/java/com/yunhai/formcenter/service/MyAnswerIT.java
+++ b/src/test/java/com/yunhai/formcenter/service/MyAnswerIT.java
@@ -464,7 +464,7 @@ class MyAnswerIT extends AnswerTestSupport {
create.setAppCode(appCode);
create.setCode(code);
create.setName("快应用 " + code);
- String uuid = defService.create(SCH, create).getFormUuid();
+ String uuid = defService.create(SCH, create, null).getFormUuid();
FormModels.FormSaveRequest save = new FormModels.FormSaveRequest();
save.setUuid(uuid);
save.setSchemaJson(schemaJson);
diff --git a/src/test/java/com/yunhai/formcenter/service/OapiFormServiceIT.java b/src/test/java/com/yunhai/formcenter/service/OapiFormServiceIT.java
index e76333f..461b075 100644
--- a/src/test/java/com/yunhai/formcenter/service/OapiFormServiceIT.java
+++ b/src/test/java/com/yunhai/formcenter/service/OapiFormServiceIT.java
@@ -68,7 +68,7 @@ class OapiFormServiceIT extends AnswerTestSupport {
assertThat(view.getVersion()).isEqualTo(1);
assertThat(view.getCode()).isEqualTo(CODE);
assertThat(view.getAppCode()).isEqualTo("form-center");
- assertThat(view.getAudience()).isEqualTo("STUDENT");
+ assertThat(view.getAudience()).containsExactly("STUDENT");
JsonNode grade = field(view.getGroups(), "grade");
assertThat(grade.path("options").isArray()).isTrue();
@@ -164,7 +164,7 @@ class OapiFormServiceIT extends AnswerTestSupport {
assertThat(byUuid.getVersion()).isEqualTo(1);
assertThat(byUuid.getCode()).isEqualTo(CODE);
assertThat(byUuid.getAppCode()).isEqualTo("form-center");
- assertThat(byUuid.getAudience()).isEqualTo("STUDENT");
+ assertThat(byUuid.getAudience()).containsExactly("STUDENT");
// 两份组装的漂移闸(问题单 P108):同库同表同版本,两种形态的 groups 必须逐字相同
OapiModels.SchemaView byCode = oapiFormService.schema(SCH, null, CODE);
@@ -286,7 +286,7 @@ class OapiFormServiceIT extends AnswerTestSupport {
FormModels.FormCreateRequest create = new FormModels.FormCreateRequest();
create.setCode(code);
create.setName("测试 " + code);
- String uuid = defService.create(SCH, create).getFormUuid();
+ String uuid = defService.create(SCH, create, null).getFormUuid();
FormModels.FormSaveRequest save = new FormModels.FormSaveRequest();
save.setUuid(uuid);
save.setSchemaJson(SCHEMA);
diff --git a/src/test/java/com/yunhai/formcenter/service/Perf200kProbe.java b/src/test/java/com/yunhai/formcenter/service/Perf200kProbe.java
index 48fa5fe..4457a3e 100644
--- a/src/test/java/com/yunhai/formcenter/service/Perf200kProbe.java
+++ b/src/test/java/com/yunhai/formcenter/service/Perf200kProbe.java
@@ -193,7 +193,7 @@ class Perf200kProbe {
FormModels.FormCreateRequest create = new FormModels.FormCreateRequest();
create.setCode(code);
create.setName(name);
- String uuid = defService.create(SCH, create).getFormUuid();
+ String uuid = defService.create(SCH, create, null).getFormUuid();
FormModels.FormSaveRequest save = new FormModels.FormSaveRequest();
save.setUuid(uuid);
save.setSchemaJson(schemaJson);
diff --git a/src/test/java/com/yunhai/formcenter/service/ReadFixture.java b/src/test/java/com/yunhai/formcenter/service/ReadFixture.java
index 64defea..f164e2a 100644
--- a/src/test/java/com/yunhai/formcenter/service/ReadFixture.java
+++ b/src/test/java/com/yunhai/formcenter/service/ReadFixture.java
@@ -15,16 +15,16 @@ import com.yunhai.platform.utils.model.BoaUser;
* V9(升版声明 searchable 后旧答卷可按新字段筛,靠发布时补建索引行)用的就是它;
* {@code cert} 是 upload 且被勾了 searchable——P28(无检索算子:不写索引行、不进筛选候选);
* 学生自填的行 {@code subject_id}=本人(Bug#3 起落库即归一,不再为 {@code NULL})、
- * 管理员代填的行 {@code subject_id}=学生——契约 §8"学生=关于我"这一格含自填与代填两类
- * ("仅本人提交"那一格看的是 {@code submitter_id});
+ * 管理员代录的行 {@code subject_id}=学生——两档模型里这些都落在同一档 {@code SELF}
+ * ({@code submitter}=我 ∪ {@code subject}=我,契约 v2 §2.3);
* 一条被软删的行——四条读出路径都必须看不见(V30 的读侧半边)。
*
*/
class ReadFixture {
- /** 表单 A:面向学生(矩阵走"面向学生"那一列) */
+ /** 表单 A:学生+教师共集人群(C1 多值;两种身份都能自填) */
static final String FORM_A = "t4_read_a";
- /** 表单 B:面向教师(矩阵走"面向教师"那一列) */
+ /** 表单 B:面向教师 */
static final String FORM_B = "t4_read_b";
static final String SCHEMA_V1 = "{\"groups\":[{\"key\":\"base\",\"label\":\"基本信息\",\"fields\":["
@@ -56,8 +56,8 @@ class ReadFixture {
static final int TEACHER_2 = 20264102;
static final int BUREAU_LEADER = 20264201;
/**
- * 教师代填的被代填人(P95 起教师×面向学生表单只能代填,自填被第 0 关拒)。
- * 刻意用两个新 id:它们不落在任何"关于我"断言的主体上,学生视角的可见集因此不因造数据而变大。
+ * 被代录人 id({@code AnswerExportIT} 自建数据集时用,让 ALL 档操作人替这两个人录数据)。
+ * 刻意用不落在任何"关于我"断言主体上的新 id,换 subject 不会把学生视角的可见集改大。
*/
static final int TEACHER_PROXY_1 = 20264003;
static final int TEACHER_PROXY_2 = 20264004;
@@ -91,17 +91,24 @@ class ReadFixture {
}
/**
- * 建 A/B 两表单(B 配成面向教师)并造出上述答卷,返回 uuid 集。
+ * 建 A/B 两表单并造出上述答卷,返回 uuid 集。
*
- * @param admin 有数据管理权且有学校归属的操作人(代填与软删用它)
+ * 表单 A 的人群配成学生+教师共集(C1 多值):v2 出清三层模型后,教师不能自填纯学生面向的表单,
+ * 也没有代填权(代录要 ALL 档,而本数据集刻意让教师不是 ALL 档,否则"只见自己"这条断言无从谈起)。
+ * 教师那两条因此是自填行({@code submitter=subject=教师})——读侧 SELF 档的谓词形状与原来一致。
+ *
+ * @param admin 有 ALL 档(校管理员)且有学校归属的操作人——代录与软删用它(P115:代录不过人群门)
*/
Rows seed(BoaUser admin) {
Rows rows = new Rows();
rows.formAUuid = support.publishForm(FORM_A, SCHEMA_V1);
rows.formBUuid = support.publishForm(FORM_B, SCHEMA_TEACHER);
- FormModels.FormConfigRequest config = new FormModels.FormConfigRequest();
- config.setAudience(2);
- support.config(AnswerTestSupport.SCH, rows.formBUuid, config);
+ FormModels.FormConfigRequest bothAudience = new FormModels.FormConfigRequest();
+ bothAudience.setAudience(java.util.Arrays.asList("STUDENT", "TEACHER"));
+ support.config(AnswerTestSupport.SCH, rows.formAUuid, bothAudience);
+ FormModels.FormConfigRequest teacherFacing = new FormModels.FormConfigRequest();
+ teacherFacing.setAudience(java.util.Collections.singletonList("TEACHER"));
+ support.config(AnswerTestSupport.SCH, rows.formBUuid, teacherFacing);
rows.student1Self = submit(student1(), FORM_A,
"{\"name\":\"张三\",\"grade\":\"g2\",\"score\":90,\"birthday\":\"2010-03-09\","
@@ -112,10 +119,10 @@ class ReadFixture {
rows.proxyForStudent1 = submitProxy(admin, FORM_A, STUDENT_1,
"{\"name\":\"张三(老师代填)\",\"grade\":\"g2\",\"score\":70,\"birthday\":\"2010-03-09\","
+ "\"advisor\":\"李老师\"}");
- rows.teacher1Row = submitProxy(teacher1(), FORM_A, TEACHER_PROXY_1,
+ rows.teacher1Row = submit(teacher1(), FORM_A,
"{\"name\":\"王五\",\"grade\":\"g3\",\"score\":95,\"birthday\":\"2009-05-20\","
+ "\"advisor\":\"张老师\"}");
- rows.teacher2Row = submitProxy(teacher2(), FORM_A, TEACHER_PROXY_2,
+ rows.teacher2Row = submit(teacher2(), FORM_A,
"{\"name\":\"赵六\",\"grade\":\"g2\",\"score\":60,\"birthday\":\"2010-01-15\","
+ "\"advisor\":\"张老师\"}");
rows.softDeleted = submit(student2(), FORM_A,