diff --git a/sql/t12_access_simplification.sql b/sql/t12_access_simplification.sql new file mode 100644 index 0000000..4193687 --- /dev/null +++ b/sql/t12_access_simplification.sql @@ -0,0 +1,74 @@ +-- ============================================================ +-- 表单中心 T13(契约 v2 §17.3)· 权限简化与匿名面 DDL(MySQL 5.7 兼容复核版) +-- 范围:《契约v2-权限简化与开放接入.md》§17.3 三笔 = +-- ① form.audience TINYINT→VARCHAR(64) + 存量迁移(1→'STUDENT'、2→'TEACHER',C1) +-- ② form 加 creator_id / frozen / anon_cookie_minutes(C16、§4.2、C7) +-- ③ answer 加 anon_ip / anon_ua + 复合索引 idx_answer_form_utime(C8/C12、§6.1) +-- 批次硬前置:**T13 部署前必须执行**(audience 列型改后新代码按 VARCHAR 码串读写, +-- ddl-auto=update 不会改已存在列的类型——本地/160 都必须手工跑本脚本)。 +-- +-- ⚠ 本脚本【不是幂等的】(沿 t10 先例:MySQL 5.7 无 ADD COLUMN IF NOT EXISTS / 无 MODIFY 判存在): +-- 执行前逐条先查—— +-- SHOW COLUMNS FROM `form` LIKE 'audience'; -- Type 应为 tinyint;已是 varchar(64) 则跳过 ① +-- SHOW COLUMNS FROM `form` LIKE 'creator_id'; -- 有结果则跳过 ② 的第一条 +-- SHOW COLUMNS FROM `form` LIKE 'frozen'; -- 有结果则跳过 ② 的第二条 +-- SHOW COLUMNS FROM `form` LIKE 'anon_cookie_minutes'; -- 有结果则跳过 ② 的第三条 +-- SHOW COLUMNS FROM `answer` LIKE 'anon_ip'; -- 有结果则跳过 ③ 的加列 +-- SHOW INDEX FROM `answer` WHERE Key_name='idx_answer_form_utime'; -- 有结果则跳过 ③ 的加索引 +-- 备份:`mysqld --single-transaction` 前先 mysqldump 本库(本地 bsch_formcenter / 160 容器网同名库)。 +-- +-- 5.7 复核要点(沿 t10):①无 CTE/WITH、无函数索引、无生成列、无 CHECK 约束;②utf8mb4 显式 CHARSET/COLLATE; +-- ③InnoDB、无物理外键(creator_id→UserBase 用户主键、form_uuid→form.uuid 都是逻辑外键)。 +-- +-- 存量与语义: +-- · audience 迁移后**不允许出现逗号串之外的形态**;空串/NULL 视为脏数据,读侧按默认 STUDENT 解释(C1)。 +-- · creator_id 存量=NULL:解析期按 `create_by`(账号名)兜底判定创建人(C16), +-- 一次性回填对照 UserBase 由人工/脚本另做(160 现存 form 行个位数);未回填行由兜底覆盖,不阻塞发布。 +-- · frozen/anon_cookie_minutes 有默认值(0 / 10),存量行即"未冻结、默认 10 分钟",无需回填。 +-- · anon_ip/anon_ua 只由 T15 匿名面写入,列先建不启用=部署即安全(§17.3 注)。 +-- ============================================================ + +-- ① 面向人群改多值码串(C1:逗号分隔枚举码闭集 STUDENT/TEACHER/ANONYMOUS;禁止布尔补丁 D8) +-- 顺序刻意:先改列型(tinyint 值 1/2 转成字符串 '1'/'2'),再把数字串映射成枚举码。 +ALTER TABLE `form` + MODIFY COLUMN `audience` VARCHAR(64) NOT NULL DEFAULT 'STUDENT' + COMMENT '面向人群(C1 多选,逗号分隔枚举码:STUDENT/TEACHER/ANONYMOUS;原 TINYINT 1/2 由本脚本迁移)'; + +UPDATE `form` SET `audience` = 'STUDENT' WHERE `audience` = '1'; +UPDATE `form` SET `audience` = 'TEACHER' WHERE `audience` = '2'; + +-- ②-a 创建人归一列(C16:现网 create_by=账号名字符串,与两表 user_id 标识体系不同,建列归一) +ALTER TABLE `form` + ADD COLUMN `creator_id` BIGINT NULL DEFAULT NULL + COMMENT '创建人 user_id(BoaUser.id,新建时从会话落列;NULL=存量未回填,解析期按 create_by 账号名兜底)' + AFTER `name`; + +-- ②-b 冻结开关(§4.2/D11:1=提交后禁编辑,双写通道同关;修正路径=撤回+重交) +ALTER TABLE `form` + ADD COLUMN `frozen` TINYINT NOT NULL DEFAULT 0 + COMMENT '提交后禁编辑(1=开启,喂外部业务应用的表单建议开启;§4.2)' + AFTER `answer_no_enabled`; + +-- ②-c 匿名防重窗口(C7:cookie 有效期分钟数,表单级可配,默认 10) +ALTER TABLE `form` + ADD COLUMN `anon_cookie_minutes` INT NOT NULL DEFAULT 10 + COMMENT '匿名 cookie 防重窗口分钟数(C7,默认 10;仅 audience 含 ANONYMOUS 的表单有意义)' + AFTER `frozen`; + +-- ③ 答卷匿名留痕两列 + 非冻结增量拉取索引(§6.1:现状 answer 表无任何 update_time 索引,已核实) +ALTER TABLE `answer` + ADD COLUMN `anon_ip` VARCHAR(64) NULL DEFAULT NULL + COMMENT '匿名提交来源 IP(C8/C12,仅匿名答卷填充,登录答卷恒 NULL)' + AFTER `idempotency_key`, + ADD COLUMN `anon_ua` VARCHAR(512) NULL DEFAULT NULL + COMMENT '匿名提交 User-Agent(C8/C12,只进独立列,不进 values_json)' + AFTER `anon_ip`; + +ALTER TABLE `answer` + ADD KEY `idx_answer_form_utime` (`form_uuid`, `update_time`, `id`); + +-- 自验(执行后逐条核对,V66/V67/V70 的库侧半边): +-- SHOW CREATE TABLE form\G -- audience varchar(64) NOT NULL DEFAULT 'STUDENT' + creator_id/frozen/anon_cookie_minutes +-- SHOW CREATE TABLE answer\G -- anon_ip/anon_ua + KEY idx_answer_form_utime(form_uuid,update_time,id) +-- SELECT audience, COUNT(*) FROM form GROUP BY audience; -- 只应出现 STUDENT/TEACHER/组合码串,不得再有 '1'/'2' +-- EXPLAIN SELECT id FROM answer WHERE form_uuid='<32位>' ORDER BY update_time, id; -- 应命中 idx_answer_form_utime diff --git a/src/main/java/com/yunhai/formcenter/controller/api/FormAdminController.java b/src/main/java/com/yunhai/formcenter/controller/api/FormAdminController.java index d40e4e7..c7a99b5 100644 --- a/src/main/java/com/yunhai/formcenter/controller/api/FormAdminController.java +++ b/src/main/java/com/yunhai/formcenter/controller/api/FormAdminController.java @@ -2,6 +2,7 @@ package com.yunhai.formcenter.controller.api; import com.yunhai.formcenter.common.FormSchemaValidator; import com.yunhai.formcenter.model.FormModels; +import com.yunhai.formcenter.service.FormAccessPolicy; import com.yunhai.formcenter.service.FormAdminGuard; import com.yunhai.formcenter.service.FormDefService; import com.yunhai.formcenter.service.FormDictService; @@ -88,7 +89,14 @@ public class FormAdminController { public FormModels.FormView create(BoaUser user, @RequestParam(required = false) Long schId, @RequestBody FormModels.FormCreateRequest body) { - return defService.create(adminGuard.requireAdmin(user, schId, FormAdminGuard.MODULE_FORM_DESIGN), body); + return defService.create(adminGuard.requireAdmin(user, schId, FormAdminGuard.MODULE_FORM_DESIGN), + body, creatorId(user)); + } + + /** 创建人会话自取(C16:创建人自动持有该表单 ALL 档,G1"创建人保留定义权"的落列来源) */ + private static Long creatorId(BoaUser user) { + Integer uid = FormAccessPolicy.uid(user); + return uid == null ? null : Long.valueOf(uid); } /** 保存:草稿直改;已发布表单=开新版本行,头表指针不动(V7) */ @@ -132,15 +140,16 @@ public class FormAdminController { defService.softDelete(adminGuard.requireAdmin(user, schId, FormAdminGuard.MODULE_FORM_DESIGN), uuid); } - /** 另存为副本(V41):新 code/新 uuid/version 从 1 起/schema 深拷贝、字典引用随行 */ + /** 另存为副本(V41):新 code/新 uuid/version 从 1 起/schema 深拷贝、字典引用随行;创建人=点副本的人 */ @PostMapping("/copy") public FormModels.FormView copy(BoaUser user, @RequestParam(required = false) Long schId, @RequestBody FormModels.FormCopyRequest body) { - return defService.copy(adminGuard.requireAdmin(user, schId, FormAdminGuard.MODULE_FORM_DESIGN), body); + return defService.copy(adminGuard.requireAdmin(user, schId, FormAdminGuard.MODULE_FORM_DESIGN), + body, creatorId(user)); } - /** 表单级配置:面向人群/限一次/截止时间(只给日期补 23:59:59)/报名号开关/分组标签 */ + /** 表单级配置:面向人群(数组,C1)/限一次/截止时间(只给日期补 23:59:59)/报名号开关/分组标签/冻结开关/匿名防重窗口 */ @PostMapping("/config") public FormModels.FormView config(BoaUser user, @RequestParam(required = false) Long schId, diff --git a/src/main/java/com/yunhai/formcenter/controller/api/FormMyAnswerController.java b/src/main/java/com/yunhai/formcenter/controller/api/FormMyAnswerController.java index c52f65c..606ebaa 100644 --- a/src/main/java/com/yunhai/formcenter/controller/api/FormMyAnswerController.java +++ b/src/main/java/com/yunhai/formcenter/controller/api/FormMyAnswerController.java @@ -99,13 +99,13 @@ public class FormMyAnswerController { } /** - * 「可填写」列表(P95 W3,V50):填报入口清单,不是答卷清单—— - * 域=会话校 ∪ 平台级且本校遮蔽平台、仅已发布未软删,逐行按 P95 三层模型过入口层,NONE 不入集。 + * 「可填写」列表({@code V50},行形状按契约 v2 C2):填报入口清单,不是答卷清单—— + * 域=会话校 ∪ 平台级且本校遮蔽平台、仅已发布未软删,逐行过 audience 门(身份类型 ∈ 面向人群), + * 不中的不入集。 * *

这一把查询把 P91 那个"从会话菜单树挑 {@code /f/} 行"的前端推导整条换掉: - * 谁能填什么从此由中心按 {@code audience × 身份} 自解析,UserBase 侧不再需要逐表单配模块行 - * (任务书 P95 §0)。它不看模块行、也不看数据范围矩阵:入口层与那两条线正交 - * ({@code FillAccessService} 类注释记了三条线的分工)。

+ * 谁能填什么由中心按 {@code audience × 会话身份} 自解析,UserBase 侧不需要逐表单配模块行。 + * 它不看数据范围({@code DataScopeService}):列表里出现某张表,不代表进去能看到别人的行。

* * @param appCode 可选,缺省=全部应用;快应用上下文({@code /app/{appCode}/})传它自身以收窄清单 */ diff --git a/src/main/java/com/yunhai/formcenter/entity/Answer.java b/src/main/java/com/yunhai/formcenter/entity/Answer.java index 594492a..3789560 100644 --- a/src/main/java/com/yunhai/formcenter/entity/Answer.java +++ b/src/main/java/com/yunhai/formcenter/entity/Answer.java @@ -18,8 +18,10 @@ import javax.persistence.UniqueConstraint; * (P25;{@code (sch_id, app_code, code, version)} 四列继续冗余留存,供按 code 的入口视角与 * 索引表同源定位用,方案 §5.2,svc-hall {@code ServiceApply} 先例);升版窗口旧版仍可填(P20), * 在途提交天然按旧版落地,编辑/详情/统计/导出也都按这一坐标取 schema。 - *
  • 身份三键(方案 §6.3):{@code submitter_id}=谁提交、{@code subject_id}=关于谁(可空)、 - * {@code sch_id}=会话校,全部会话推导不可外部传入(契约 §5、V17)。
  • + *
  • 身份三键(方案 §6.3):{@code submitter_id}=谁提交(匿名=哨兵 0)、 + * {@code subject_id}=关于谁(匿名恒 NULL)、{@code sch_id}=租户域(登录答卷恒=会话校; + * 匿名答卷=表单归属校,契约 v2 §3.2/C8 取代原"恒=会话校"单句), + * 全部会话或表单推导,不可外部传入(契约 §5、V17/V57)。
  • *
  • 幂等键是库级唯一:防双击/重放只有 DB 唯一索引靠得住(方案 §5.1), * 与"限一次"({@code fill_quota})是两个机制——幂等防重复提交,限次防多次提交。
  • * @@ -50,7 +52,10 @@ import javax.persistence.UniqueConstraint; // ——当页只取 id 时该索引即覆盖索引(index-only,不回表、不碰 values_json)。 // 契约 §10 草案的 idx_answer_form 以 status 结尾,只对"按状态筛/分组"是覆盖的, // 不含 app_code 且无法在跨状态时给出 id 序 → 新增本索引,登记问题单。 - @Index(name = "idx_answer_form_time", columnList = "sch_id, app_code, code, is_deleted, id") + @Index(name = "idx_answer_form_time", columnList = "sch_id, app_code, code, is_deleted, id"), + // 契约 v2 §6.1:非冻结表单的 updated_since 增量拉取按 (form_uuid, update_time, id) 推进, + // 现状 answer 表无任何 update_time 索引(t3+t10 全查过)——本索引是 T17 拉取端点的前置 + @Index(name = "idx_answer_form_utime", columnList = "form_uuid, update_time, id") }) public class Answer extends BaseEntity { @@ -120,6 +125,17 @@ public class Answer extends BaseEntity { @Column(name = "idempotency_key", nullable = false, length = 64, updatable = false) private String idempotencyKey; + /** + * 匿名提交来源 IP(契约 v2 C8/C12):只由匿名面(T15)写入,登录答卷恒 NULL; + * 不进 {@code values_json}(留痕是采集事实之外的东西,混进值文档就没法比对了)。 + */ + @Column(name = "anon_ip", length = 64) + private String anonIp; + + /** 匿名提交 User-Agent(同上,列宽 512 够装常规 UA 串;超长由服务层截断) */ + @Column(name = "anon_ua", length = 512) + private String anonUa; + /** 软删=唯一删除形态(无物理删除接口,P22/V30);同步删 answer_index 行、释放 fill_quota 名额 */ @Column(name = "is_deleted", nullable = false, columnDefinition = "tinyint") private Integer isDeleted = 0; diff --git a/src/main/java/com/yunhai/formcenter/entity/Form.java b/src/main/java/com/yunhai/formcenter/entity/Form.java index 6dc7b78..c468d22 100644 --- a/src/main/java/com/yunhai/formcenter/entity/Form.java +++ b/src/main/java/com/yunhai/formcenter/entity/Form.java @@ -65,6 +65,13 @@ public class Form extends BaseEntity { @Column(name = "name", nullable = false, length = 128) private String name; + /** + * 创建人 user_id(C16:{@code create_by} 存的是账号名字符串,与管理员两表的 user_id 标识体系不同, + * 建列归一)。新建时从会话落列;存量行=NULL,创建人判定按 {@code create_by} 账号名兜底。 + */ + @Column(name = "creator_id", updatable = false) + private Long creatorId; + /** 当前发布版本指针,null=从未发布(resolve 只认它,方案 §5.1) */ @Column(name = "current_version") private Integer currentVersion; @@ -77,9 +84,13 @@ public class Form extends BaseEntity { @Column(name = "status_defs", columnDefinition = "text") private String statusDefs; - /** 面向人群:1 学生 / 2 教师(数据范围默认矩阵的输入,契约 §10) */ - @Column(name = "audience", nullable = false, columnDefinition = "tinyint") - private Integer audience = FormEnums.Audience.STUDENT.getCode(); + /** + * 面向人群(契约 v2 C1 多选):逗号分隔枚举码串 {@code STUDENT,TEACHER,ANONYMOUS}, + * 列型 VARCHAR(64)(原 TINYINT 1/2 由 {@code sql/t12_access_simplification.sql} 迁移)。 + * 读写一律过 {@link FormEnums.Audience} 的 parse/join,不在别处拼字符串。 + */ + @Column(name = "audience", nullable = false, length = 64) + private String audience = FormEnums.Audience.STUDENT.name(); /** 限填次数:0 不限 / 1 限一次(去重维度按 subject/submitter 推导,方案 §5.4) */ @Column(name = "fill_limit", nullable = false, columnDefinition = "tinyint") @@ -93,6 +104,17 @@ public class Form extends BaseEntity { @Column(name = "answer_no_enabled", nullable = false, columnDefinition = "tinyint") private Integer answerNoEnabled = 0; + /** + * 提交后禁编辑(契约 v2 §4.2/D11):1=开启,提交人编辑与管理员编辑双写通道同关(V61), + * 修正路径只剩"软删(撤回)+ 重新提交"。默认关——喂外部业务应用的表单由设计者显式开启。 + */ + @Column(name = "frozen", nullable = false, columnDefinition = "tinyint") + private Integer frozen = 0; + + /** 匿名 cookie 防重窗口分钟数(C7,默认 10);仅 audience 含 ANONYMOUS 的表单消费 */ + @Column(name = "anon_cookie_minutes", nullable = false) + private Integer anonCookieMinutes = 10; + /** 多表单快应用的菜单分组标签(方案 §5.1,可选) */ @Column(name = "group_tag", length = 64) private String groupTag; diff --git a/src/main/java/com/yunhai/formcenter/enums/FormEnums.java b/src/main/java/com/yunhai/formcenter/enums/FormEnums.java index f86ad30..2cad069 100644 --- a/src/main/java/com/yunhai/formcenter/enums/FormEnums.java +++ b/src/main/java/com/yunhai/formcenter/enums/FormEnums.java @@ -1,5 +1,11 @@ package com.yunhai.formcenter.enums; +import java.util.ArrayList; +import java.util.Collection; +import java.util.EnumSet; +import java.util.List; +import java.util.Set; + /** * 定义域枚举(契约 §1/§10 + 方案 §5.1/§5.4,一期冻结)。 * 列值直接用契约 §10 DDL 草案里的数字口径,避免库内值与文档两套。 @@ -35,42 +41,112 @@ public final class FormEnums { } } - /** 面向人群:1 面向学生 / 2 面向教师(数据范围默认矩阵的输入,方案 §6.4;只决定默认,授权仍看模块行) */ + /** + * 面向人群(契约 v2 C1:多选,列存逗号分隔枚举码 {@code VARCHAR(64)},JSON 面一律字符串数组)。 + * + *

    闭集三值:学生 / 教师 / 匿名可共集于一张表单(D8:禁止布尔补丁,不新增 {@code is_anonymous} 列)。 + * {@code ANONYMOUS} 只对匿名通道生效,登录会话不可能持有它({@code FormAccessPolicy#identityTypes})。

    + * + *

    存量迁移由 {@code sql/t12_access_simplification.sql} 完成({@code 1→STUDENT}、{@code 2→TEACHER})。

    + */ public enum Audience { - STUDENT(1), TEACHER(2); + STUDENT, TEACHER, ANONYMOUS; - private final int code; - - Audience(int code) { - this.code = code; - } - - public int getCode() { - return code; - } - - public static boolean valid(Integer code) { - if (code != null) { - for (Audience a : values()) { - if (a.code == code) { - return true; - } - } + /** 枚举名解析(未知=null,调用侧按拒绝处理,不静默当成某个身份) */ + public static Audience of(String code) { + String clean = code == null ? null : code.trim(); + if (clean == null || clean.isEmpty()) { + return null; } - return false; - } - - /** 对外下发用枚举名(契约 §4 的 {@code "audience":"STUDENT"}),不外露数字 */ - public static String nameOf(Integer code) { - if (code != null) { - for (Audience a : values()) { - if (a.code == code) { - return a.name(); - } + for (Audience a : values()) { + if (a.name().equalsIgnoreCase(clean)) { + return a; } } return null; } + + public static boolean validName(String code) { + return of(code) != null; + } + + /** + * 列值码串 → 身份集合(按枚举定义序去重)。 + * + *

    空/NULL=按默认 {@code STUDENT} 解释(C1 注记"存量脏值不放宽":宁少给一个人群, + * 也不因脏列值把表单暴露给更多人)。非法码直接拒——不静默忽略,那会把"配错了人群" + * 伪装成"这些人本来就不能填"。

    + */ + public static Set parse(String columnValue) { + String clean = columnValue == null ? null : columnValue.trim(); + Set result = EnumSet.noneOf(Audience.class); + if (clean == null || clean.isEmpty()) { + result.add(STUDENT); + return result; + } + for (String token : clean.split(",")) { + String name = token.trim(); + if (name.isEmpty()) { + continue; + } + Audience audience = of(name); + if (audience == null) { + throw new IllegalArgumentException("audience 只能是 STUDENT/TEACHER/ANONYMOUS:" + name); + } + result.add(audience); + } + if (result.isEmpty()) { + result.add(STUDENT); + } + return result; + } + + /** 身份集合 → 列值码串(按枚举定义序,稳定可比对) */ + public static String join(Collection audiences) { + StringBuilder sb = new StringBuilder(); + for (Audience a : values()) { + if (audiences != null && audiences.contains(a)) { + if (sb.length() > 0) { + sb.append(','); + } + sb.append(a.name()); + } + } + return sb.length() == 0 ? STUDENT.name() : sb.toString(); + } + + /** 列值码串 → 枚举名数组(对外出参一律数组,C1/C5;顺序=枚举定义序) */ + public static List namesOf(String columnValue) { + List names = new ArrayList<>(); + for (Audience a : parse(columnValue)) { + names.add(a.name()); + } + return names; + } + + /** 列值码串 → 中文文案(audience 门拒绝文案要能诊断出配了哪些人群,§3.1) */ + public static String labelOf(String columnValue) { + StringBuilder sb = new StringBuilder(); + for (Audience a : parse(columnValue)) { + if (sb.length() > 0) { + sb.append('/'); + } + sb.append(a.label()); + } + return sb.toString(); + } + + /** 展示名(只用于文案,不参与存储与线格式) */ + public String label() { + switch (this) { + case TEACHER: + return "教师"; + case ANONYMOUS: + return "匿名"; + default: + return "学生"; + } + } } /** 限填次数:0 不限 / 1 限一次(去重键见 {@link LimitScope},与幂等键是两个机制,方案 §5.4) */ @@ -101,22 +177,21 @@ public final class FormEnums { } /** - * 数据范围(契约 §8 / 方案 §6.4):一期契约的四值闭集 + T10 的 {@link #SCHOOL}—— - * "定了就不变"指既不删改既有值、也不改既有语义,扩展走"加枚举=加实现器"这条既定通道 - * (管理员体系方案 §5.1)。 + * 数据范围两档(契约 v2 §2.3 / D4): + *
      + *
    • {@link #ALL}=该表单全部行;持有者=创建人 ∪ form_admin ∪ 校管理员(∪ 超管)。 + * 校管理员×平台级表单({@code sch_id=0})是 ALL 档内的租户限定变体(D9:只给本校提交行, + * 谓词 {@code submitter_sch_id=会话校}),不是第三个范围值;
    • + *
    • {@link #SELF}=我提交的 ∪ 关于我的({@code subject_id}=会话用户)——登录用户天然持有, + * 因此不存在"全不命中回落"分支。
    • + *
    * - *

    {@link #TEACHING_CLASS} 是 1.5 开放项(依赖 UserClass 任教关系/班级 SDK): - * 枚举里有、一期不可选(V28),其实现器只是占位。

    - * - *

    {@link #SCHOOL} 与任教班级同一类"枚举里有但不入 {@code selectableScopes()}"(V28 口径): - * 它不是筛选器选项,而是管理员两表授权后的解析结果(谓词 {@code submitter_sch_id}=会话学校)。

    + *

    原四值闭集(全部/任教班级/仅本人提交/关于我)+ {@code SCHOOL} + 身份默认矩阵随 + * 《契约v2》§19 作废-1 退役;任教班级/按组圈人属业务语义推断,需要时任课数据源落实后单独开批次。

    */ public enum DataScope { ALL("全部"), - TEACHING_CLASS("任教班级"), - SELF_SUBMITTED("仅本人提交"), - ABOUT_ME("关于我"), - SCHOOL("本校提交"); + SELF("我相关"); private final String label; @@ -127,15 +202,5 @@ public final class FormEnums { public String getLabel() { return label; } - - /** 枚举名解析(对外只暴露名字,数字序号不外露——加值时不破坏兼容) */ - public static DataScope of(String name) { - for (DataScope scope : values()) { - if (scope.name().equalsIgnoreCase(name == null ? null : name.trim())) { - return scope; - } - } - return null; - } } } diff --git a/src/main/java/com/yunhai/formcenter/model/FormModels.java b/src/main/java/com/yunhai/formcenter/model/FormModels.java index c25dfb9..07aab94 100644 --- a/src/main/java/com/yunhai/formcenter/model/FormModels.java +++ b/src/main/java/com/yunhai/formcenter/model/FormModels.java @@ -34,11 +34,16 @@ public final class FormModels { private Integer currentVersion; /** 待发布的草稿版本号,null=无草稿 */ private Integer draftVersion; - private Integer audience; + /** 面向人群(C1 多选,一律字符串数组:{@code ["STUDENT","TEACHER","ANONYMOUS"]}) */ + private List audience; private Integer fillLimit; /** yyyy-MM-dd HH:mm:ss,null=不限 */ private String deadline; private Integer answerNoEnabled; + /** 提交后禁编辑(契约 v2 §4.2):1=双写编辑通道同关 */ + private Integer frozen; + /** 匿名 cookie 防重窗口分钟数(C7) */ + private Integer anonCookieMinutes; private String groupTag; private List statusDefs; private String createTime; @@ -73,7 +78,8 @@ public final class FormModels { private String appCode; private String name; private Integer version; - private Integer audience; + /** 面向人群(C1 数组形态;填报页据此判"我这个身份能不能填",判定权威仍在后端 audience 门) */ + private List audience; private Integer fillLimit; private Integer answerNoEnabled; private String deadline; @@ -145,11 +151,16 @@ public final class FormModels { @Setter public static class FormConfigRequest { private String uuid; - private Integer audience; + /** 面向人群(C1 入参数组形态;null=不改这一项) */ + private List audience; private Integer fillLimit; /** yyyy-MM-dd(补 23:59:59)或 yyyy-MM-dd HH:mm:ss;空串/null=不限 */ private String deadline; private Integer answerNoEnabled; + /** 提交后禁编辑(契约 v2 §4.2):0/1,null=不改 */ + private Integer frozen; + /** 匿名 cookie 防重窗口分钟数(C7):>0,null=不改 */ + private Integer anonCookieMinutes; private String groupTag; } diff --git a/src/main/java/com/yunhai/formcenter/model/MyModels.java b/src/main/java/com/yunhai/formcenter/model/MyModels.java index 1d880f1..327361f 100644 --- a/src/main/java/com/yunhai/formcenter/model/MyModels.java +++ b/src/main/java/com/yunhai/formcenter/model/MyModels.java @@ -3,6 +3,8 @@ package com.yunhai.formcenter.model; import lombok.Getter; import lombok.Setter; +import java.util.List; + /** * "我的填写"两视图({@code /my/submit} 与 {@code /my/about})的列表出参(方案 §6.3、任务书 T8)。 * @@ -47,17 +49,15 @@ public final class MyModels { } /** - * 一行"我可填的表单"(P95 W3,{@code GET /api/form/my/fillable}):填报入口清单的行形状。 + * 一行"我可填的表单"({@code GET /api/form/my/fillable},契约 v2 C2 行形状)。 * - *

    它不是答卷行:这一把查询还没写任何数据,只回答"这个人能进哪张表的填报页、 - * 进完之后按 P95 三层模型他算哪一层"。所以字段是表单身份 + 两个入口层结论, - * 与 {@link ItemView} 没有共同祖先。

    + *

    它不是答卷行:这一把查询还没写任何数据,只回答"这个人能进哪张表的填报页"。 + * 字段=表单身份 + 配置项,不含任何入口层结论:原 P95 的 {@code canFill}/{@code myTier} + * 随三层模型出清(§19 作废-3)——入集本身就等价于"能填"(登录身份类型 ∈ audience), + * 不再有"进了列表但不能填"的中间态。

    * - *

    {@code canFill} 与 {@code myTier} 是两件事,前端两处消费: - * {@code canFill=false} 且 {@code myTier=MANAGE} 的行(教师×面向学生)在「可填写」列表里不出现, - * 但要留在"数据管理"tab 的候选里——这正是 P95 把入口层结论下发而不是只下发一个布尔的原因。 - * 数字口径与定义域一致:{@code audience}/{@code fillLimit} 下发列值(1/2、0/1), - * {@code myTier} 下发枚举名(ADMIN/MANAGE/FILL,NONE 的行根本不入集)。

    + *

    {@code audience} 下数组(C1);{@code ANONYMOUS} 不参与登录会话的入集判定 + * (匿名填报人不看列表,持 URL 直达)。

    */ @Getter @Setter @@ -65,16 +65,12 @@ public final class MyModels { private String code; private String name; private String appCode; - /** 面向人群:1 学生 / 2 教师(契约 §10 列值) */ - private Integer audience; + /** 面向人群(C1 数组形态) */ + private List audience; /** yyyy-MM-dd HH:mm:ss;null=不限截止 */ private String deadline; /** 限填次数:0 不限 / 1 限一次 */ private Integer fillLimit; private String groupTag; - /** 能否以本人身份填报(层①∪层③) */ - private Boolean canFill; - /** 并集里的最高层:ADMIN > MANAGE > FILL(NONE 不入集,故不会为 null) */ - private String myTier; } } diff --git a/src/main/java/com/yunhai/formcenter/model/OapiModels.java b/src/main/java/com/yunhai/formcenter/model/OapiModels.java index a563c43..ed11e55 100644 --- a/src/main/java/com/yunhai/formcenter/model/OapiModels.java +++ b/src/main/java/com/yunhai/formcenter/model/OapiModels.java @@ -4,6 +4,8 @@ import com.fasterxml.jackson.databind.JsonNode; import lombok.Getter; import lombok.Setter; +import java.util.List; + /** * 一期对外读接口(oapi)的响应模型(契约 §9 两端点、§4 下发形状)。 * @@ -27,8 +29,12 @@ public final class OapiModels { private Integer version; private String code; private String appCode; - /** STUDENT / TEACHER(枚举名下发,不外露数字,契约 §4 示例) */ - private String audience; + /** + * 面向人群(契约 v2 C5/G3:breaking——由单值串改字符串数组,与 T13 改库同批切换, + * 不加适配层;闭集 {@code STUDENT}/{@code TEACHER}/{@code ANONYMOUS},可共集于一张表单)。 + * 消费方通告:flow-center 只在流程定义发布期解析本字段,部署前发通告。 + */ + private List audience; /** {@code [{key,label,fields:[{… , options:[{label,value}], searchable, valueSchema:{…}}]}]} */ private JsonNode groups; } diff --git a/src/main/java/com/yunhai/formcenter/model/ReadModels.java b/src/main/java/com/yunhai/formcenter/model/ReadModels.java index 0c60dc7..27ae189 100644 --- a/src/main/java/com/yunhai/formcenter/model/ReadModels.java +++ b/src/main/java/com/yunhai/formcenter/model/ReadModels.java @@ -178,8 +178,16 @@ public final class ReadModels { private String code; private Set scopes; private Integer userId; - /** 会话学校(T10 方案 §5.1:{@code SCHOOL} 范围的谓词基准,其余范围不消费) */ + /** 会话学校(契约 v2 §2.3:ALL 档租户限定变体的谓词基准,SELF 档不消费) */ private Long viewerSchId; + /** + * ALL 档是否为租户限定变体(契约 v2 D9:校管理员 × 平台级表单 {@code sch_id=0} 只看 + * 本校提交行,谓词 {@code submitter_sch_id=会话校})。 + * + *

    它是 ALL 档内部的收窄,不是第三个范围值——所以放在规格位上而不是枚举里; + * 超管/创建人/form_admin 命中时恒 false(全量)。

    + */ + private boolean platformScopedAll; private String status; private List filters; /** 游标:库内 id(对外是 uuid,服务端换);null=首屏 */ diff --git a/src/main/java/com/yunhai/formcenter/service/AboutMeScopeImplementor.java b/src/main/java/com/yunhai/formcenter/service/AboutMeScopeImplementor.java deleted file mode 100644 index 978231d..0000000 --- a/src/main/java/com/yunhai/formcenter/service/AboutMeScopeImplementor.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 subject_id}=会话用户)。 - * - *

    与"仅本人提交"是两个视角(方案 §6.3 拆分 submitter/subject 的动因):学生看到的是 - * 关于自己的数据,包含老师代填的那一条——老师代填时 {@code submitter_id} 是老师、 - * {@code subject_id} 是学生,按本范围才可见(V18)。

    - */ -@Component -public class AboutMeScopeImplementor implements AnswerScopeImplementor { - - @Override - public DataScope scope() { - return DataScope.ABOUT_ME; - } - - @Override - public boolean selectable() { - return true; - } - - @Override - public Predicate rowPredicate(CriteriaBuilder cb, Root root, Integer userId, Long viewerSchId) { - return cb.equal(root.get("subjectId"), userId); - } -} diff --git a/src/main/java/com/yunhai/formcenter/service/AllScopeImplementor.java b/src/main/java/com/yunhai/formcenter/service/AllScopeImplementor.java index e9c6ecd..3729a26 100644 --- a/src/main/java/com/yunhai/formcenter/service/AllScopeImplementor.java +++ b/src/main/java/com/yunhai/formcenter/service/AllScopeImplementor.java @@ -2,13 +2,29 @@ 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 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; -/** 范围实现器:全部——不加行级条件(契约 §8 管理员/校领导默认,或管理员两表授权升级)。 */ +/** + * 范围实现器:ALL——该表单全部行(契约 v2 §2.3)。 + * + *

    持有者=创建人 ∪ form_admin ∪ 校管理员(∪ 超管)。两种落点:

    + *
      + *
    • 全量 ALL:不加行级条件(超管、平台表单的创建人、本校表单的授权者);
    • + *
    • 租户限定变体(D9):校管理员/form_admin 面对平台级表单({@code sch_id=0})时, + * 只给本校提交行——谓词 {@code submitter_sch_id=会话校}。平台表单是跨校资产, + * 授权不跨过租户边界(T10 D2=B 加列的动因,本批原样承继);给不出会话校一律拒绝, + * 不静默降成"无学校条件"(那等于把本校范围读成全平台)。
    • + *
    + * + *

    {@code submitter_sch_id} 为 NULL 的存量行天然不匹配等值谓词=fail-closed(P84 口径不回退)。

    + */ @Component public class AllScopeImplementor implements AnswerScopeImplementor { @@ -18,12 +34,13 @@ public class AllScopeImplementor implements AnswerScopeImplementor { } @Override - public boolean selectable() { - return true; - } - - @Override - public Predicate rowPredicate(CriteriaBuilder cb, Root root, Integer userId, Long viewerSchId) { - return cb.conjunction(); + public Predicate rowPredicate(CriteriaBuilder cb, Root root, ReadModels.Query q) { + if (!q.isPlatformScopedAll()) { + return cb.conjunction(); + } + if (q.getViewerSchId() == null) { + throw new BizException(ErrorCode.FORBIDDEN, "ALL 档的租户限定变体需要会话学校身份"); + } + return cb.equal(root.get("submitterSchId"), q.getViewerSchId()); } } diff --git a/src/main/java/com/yunhai/formcenter/service/AnswerAdminService.java b/src/main/java/com/yunhai/formcenter/service/AnswerAdminService.java index 64d2ba0..d1dca82 100644 --- a/src/main/java/com/yunhai/formcenter/service/AnswerAdminService.java +++ b/src/main/java/com/yunhai/formcenter/service/AnswerAdminService.java @@ -120,6 +120,12 @@ public class AnswerAdminService { Answer answer = requireManageableAnswer(operator, schId, req.getAnswerId()); FormDefService.Published pinned = defService.pinnedForData(answer.getFormUuid(), answer.getVersion()); String schemaJson = pinned.versionRow().getSchemaJson(); + // 冻结开关(契约 v2 §4.2/V61):与本人编辑侧同一条判据、同一份文案族—— + // "原件是档案"这件事不能只在提交人那侧生效,否则管理员改值就绕开了冻结语义 + if (Integer.valueOf(1).equals(pinned.form().getFrozen())) { + throw new BizException(ErrorCode.CUSTOM, + "该表单已开启提交后禁编辑,修正请撤回后重新提交:" + pinned.form().getCode()); + } // P33:编辑一条"代填来的"答卷时,被代填人必须仍然真实存在且属本校—— // 提交时验过一次不代表现在还在(用户可能被删/转校)。身份列本接口不改,但脏引用不能靠编辑继续留档。 diff --git a/src/main/java/com/yunhai/formcenter/service/AnswerReadService.java b/src/main/java/com/yunhai/formcenter/service/AnswerReadService.java index 8727f3c..f171ba6 100644 --- a/src/main/java/com/yunhai/formcenter/service/AnswerReadService.java +++ b/src/main/java/com/yunhai/formcenter/service/AnswerReadService.java @@ -72,19 +72,21 @@ public class AnswerReadService { /** * 会话身份 + 表单 + 请求 → 读路径查询规格。 * - *

    范围在这里定:{@link DataScopeService} 按契约 §8 矩阵 + 管理员两表解析,解析不出来 - * (未登录等)即拒,不存在"默认全部"的分支。筛选条件按当前发布版本的 - * {@code filterableFields} 收敛(V3 未声明 searchable 不进候选、P28 附件无算子), - * 传一个不在候选里的 key 直接拒——不能默默忽略,那会把"筛选没生效"伪装成"没有匹配数据"。

    + *

    范围在这里定:{@link DataScopeService} 按契约 v2 §2.3 两档(ALL/SELF)解析,未登录即拒, + * 不存在"默认全部"的分支。筛选条件按当前发布版本的 {@code filterableFields} 收敛 + * (V3 未声明 searchable 不进候选、P28 附件无算子),传一个不在候选里的 key 直接拒—— + * 不能默默忽略,那会把"筛选没生效"伪装成"没有匹配数据"。

    */ public ReadModels.Query scopeQuery(BoaUser user, Long domainSchId, Form form, ReadModels.QueryRequest req) { ReadModels.Query query = new ReadModels.Query(); query.setSchId(domainSchId); query.setAppCode(form.getAppCode()); query.setCode(form.getCode()); - query.setScopes(scopeService.resolve(user, form)); + DataScopeService.Resolved resolved = scopeService.resolve(user, form); + query.setScopes(resolved.scopes()); + // 校管理员×平台级表单的 ALL 收窄成"本校提交"(D9);谓词基准=会话学校,不是查询域 + query.setPlatformScopedAll(resolved.platformScopedAll()); query.setUserId(user == null ? null : user.getId()); - // T10 方案 §5.1:SCHOOL 范围的谓词基准=会话学校(不是查询域,两者同源于会话校但语义不同) query.setViewerSchId(FormAccessPolicy.sessionSchId(user)); query.setStatus(req == null ? null : trimToNull(req.getStatus())); query.setFilters(resolveFilters(form, req == null ? null : req.getFilters())); @@ -336,6 +338,12 @@ public class AnswerReadService { } FormDefService.Published pinned = defService.pinnedForData(answer.getFormUuid(), answer.getVersion()); Form form = pinned.form(); + // 冻结开关(契约 v2 §4.2/V61):表单开启"提交后禁编辑"时,本人编辑与管理员编辑两条写通道同关, + // 唯一修正路径=软删(撤回)+ 重新提交——文案直接指路,不让用户对着 403 猜下一步做什么 + if (Integer.valueOf(1).equals(form.getFrozen())) { + throw new BizException(ErrorCode.CUSTOM, + "该表单已开启提交后禁编辑,修正请撤回后重新提交:" + form.getCode()); + } // Bug#2:编辑与提交同口径——过截止/表单停用(或已软删)就不再接收修改。 // 否则"先交个占位、截止后随便改"能绕过 deadline,截止语义形同虚设(停用同理:填报入口已失效)。 if (FormRuntimeRules.isDeadlineReached(form, LocalDateTime.now())) { diff --git a/src/main/java/com/yunhai/formcenter/service/AnswerScopeImplementor.java b/src/main/java/com/yunhai/formcenter/service/AnswerScopeImplementor.java index 4cc23bd..5fefe9d 100644 --- a/src/main/java/com/yunhai/formcenter/service/AnswerScopeImplementor.java +++ b/src/main/java/com/yunhai/formcenter/service/AnswerScopeImplementor.java @@ -1,6 +1,7 @@ package com.yunhai.formcenter.service; import com.yunhai.formcenter.enums.FormEnums.DataScope; +import com.yunhai.formcenter.model.ReadModels; import javax.persistence.criteria.CriteriaBuilder; import javax.persistence.criteria.Predicate; @@ -9,32 +10,26 @@ import javax.persistence.criteria.Root; import com.yunhai.formcenter.entity.Answer; /** - * 数据范围实现器(方案 §6.4"枚举 + 实现器(策略接口)"):一个枚举值一个实现器, - * 后续加枚举=加实现器;实现器内部要去调 SDK(如 UserClass 的任教关系)也只在自己这一格里长, - * 接口隔离保证读路径不跟着改。 + * 数据范围实现器(契约 v2 §2.3:两档=两个实现器):一个枚举值一个实现器, + * 谓词只回答"这条答卷在不在这一档里"。 * - *

    唯一出口约束:实现器只产出"这条答卷在不在该范围内"的 SQL 谓词, - * 由 {@link AnswerScopeQueryBuilder} 在四条读出路径(列表/详情/统计/导出)与管理动作行级把关上 - * 复用同一次构造(方案 §6.4 红线,V27/V37/V42)。实现器里不允许出现"顺手把 values 取出来判"这类分支。

    + *

    唯一出口约束(V55):实现器只产出 SQL 谓词,由 {@link AnswerScopeQueryBuilder} + * 在四条读出路径(列表/详情/统计/导出)与管理动作行级把关上复用同一次构造。 + * 绕开构造器另写查询=越权后门;实现器里也不允许出现"顺手把 values 取出来判"这类分支。

    + * + *

    原"加枚举=加实现器"的开放项(任教班级)随 §19 作废-1 出清:业务语义范围需要时任课数据源 + * 落实后单独开批次,不在两档模型里留占位。

    */ public interface AnswerScopeImplementor { - /** 本实现器负责的范围值 */ + /** 本实现器负责的档 */ DataScope scope(); /** - * 一期是否可选。契约 §8 的四值闭集里 {@code 任教班级} 属 1.5 开放项(依赖 UserClass 任教关系/班级 SDK) - * ——枚举里有、一期不可选(V28),故其实现器返回 false 且不参与解析结果。 - */ - boolean selectable(); - - /** - * 产出该范围对单条答卷的行级谓词。 + * 产出该档对单条答卷的行级谓词。 * - * @param userId 会话用户 id({@code submitter_id}/{@code subject_id} 的比对基准) - * @param viewerSchId 会话学校({@code submitter_sch_id} 的比对基准,T10 方案 §5.1; - * 只有 {@code SCHOOL} 消费它,其余范围按各自基准忽略即可) - * @return 恒真({@code cb.conjunction()})=该范围不额外收窄行 + * @param q 查询规格(会话用户 id、会话学校、ALL 档是否租户限定变体都在里面) + * @return 恒真({@code cb.conjunction()})=该档不额外收窄行 */ - Predicate rowPredicate(CriteriaBuilder cb, Root root, Integer userId, Long viewerSchId); + Predicate rowPredicate(CriteriaBuilder cb, Root root, ReadModels.Query q); } diff --git a/src/main/java/com/yunhai/formcenter/service/AnswerScopeQueryBuilder.java b/src/main/java/com/yunhai/formcenter/service/AnswerScopeQueryBuilder.java index 7ee07e3..5fdeb53 100644 --- a/src/main/java/com/yunhai/formcenter/service/AnswerScopeQueryBuilder.java +++ b/src/main/java/com/yunhai/formcenter/service/AnswerScopeQueryBuilder.java @@ -181,6 +181,7 @@ public class AnswerScopeQueryBuilder { copy.setScopes(q.getScopes()); copy.setUserId(q.getUserId()); copy.setViewerSchId(q.getViewerSchId()); + copy.setPlatformScopedAll(q.isPlatformScopedAll()); copy.setStatus(q.getStatus()); copy.setFilters(q.getFilters()); copy.setCursorId(q.getCursorId()); @@ -220,14 +221,15 @@ public class AnswerScopeQueryBuilder { return ps; } - /** 范围段:多来源的并集(OR);命中"全部"即不加行级条件 */ + /** 范围段:两档的并集(OR);ALL 档自身可能是租户限定变体,收窄谓词在实现器里(V55 唯一出口) */ private Predicate scopePredicate(CriteriaBuilder cb, Root root, ReadModels.Query q) { Set scopes = q.getScopes(); if (scopes == null || scopes.isEmpty()) { - // 空范围不放行——"没解析出范围"与"范围是全部"是两回事,缺省必须是仅本人提交 + // 空范围不放行——"没解析出范围"与"范围是全部"是两回事,缺省必须拒绝 throw new BizException(ErrorCode.FORBIDDEN, "未解析出数据范围,拒绝按无范围读取"); } - if (scopes.contains(DataScope.ALL)) { + if (scopes.contains(DataScope.ALL) && !q.isPlatformScopedAll()) { + // 全量 ALL 已经覆盖 SELF:留着 SELF 那半只会让读路径多写几个 OR return cb.conjunction(); } if (q.getUserId() == null) { @@ -235,8 +237,7 @@ public class AnswerScopeQueryBuilder { } List 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; *
    1. 遮蔽去重:同 {@code (app_code, code)} 本校与平台级都在时本校胜出且不回落—— * 直接复用 {@link FormDefService#pickByShadowing},与 resolve 同一条 U5 实现(P20 的取舍: * 两处口径必须同源,否则列表里出现的表单点进去可能解析成另一张);
    2. - *
    3. 逐行过入口层:{@link FillAccessService#resolve} 为空集(NONE)的行不入集;非空即入集, - * {@code canFill} 与 {@code myTier} 一起下发——层②(教师×面向学生)的行 {@code canFill=false}, - * 前端「可填写」列表把它筛掉,但"数据管理"tab 的候选要靠它,所以不能只下发一个布尔;
    4. + *
    5. 逐行过 audience 门:登录身份类型 ∩ {@code form.audience}=∅ 的行不入集。 + * 这一条与 {@link AnswerService} 提交时的 audience 门同一个公式—— + * "列表里有"与"提交能过"不可能不一致(原 P95 的 {@code canFill}/{@code myTier} 随之消失: + * 入集即等价于可填,不再有"进了列表但不能填"的中间态,§19 作废-3);
    6. *
    7. 排序:deadline 升序、null(不限)在后、同档按 code。
    8. *
    * - *

    不分页:一所学校 + 平台级里可填的表单是个位数到几十张(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,