t13:所有权模型切换——audience 多值门 + ALL/SELF 两档 + 冻结开关,三层模型与身份矩阵出清

- DDL sql/t12_access_simplification.sql(非幂等,先查列):form.audience TINYINT→VARCHAR(64)
  + 存量迁移、form 加 creator_id/frozen/anon_cookie_minutes、answer 加 anon_ip/anon_ua
  + idx_answer_form_utime;实体与 @Index 同步(本地库已备份并手工执行)
- audience 多值(C1/C2/C5):FormEnums.Audience 扩 ANONYMOUS + parse/join/namesOf 单点工具;
  配置通道收数组并加跨字段约束(含 ANONYMOUS 时 fill_limit 必 0,CUSTOM(5));
  FormView/ResolvedFormView/FillableRow/oapi SchemaView 出参一律数组(flow-center 需通告)
- 两档范围(§2.3/D4):DataScopeService 重写为 {all,self} + 平台级表单的租户限定变体(D9),
  身份默认矩阵与回落分支删除;5 实现器收敛为 ALL/SELF 两个,SCHOOL 谓词并入 ALL 变体、
  TEACHING_CLASS 摘除;AnswerScopeQueryBuilder 改吃查询规格(V55 唯一出口不变)
- audience 门替第 0 关(§3.1):AnswerService 的 fillAccess.resolve+requireFillEntry 换成
  人群门,requireSubject 指定权收敛为"该表单 ALL 档";FillAccessService 全类删除;
  代录/导入不过人群门(P116 用户裁决:§3.4 与 §5.1/V73 冲突取"功能可用"一侧)
- fillable 行形状(C2):删 canFill/myTier,入集=身份类型 ∈ audience(与提交同一公式)
- 冻结开关(§4.2/V61):config 通道 + 两条编辑通道同关,文案指路"撤回后重新提交";
  新增 AnswerFrozenIT 钉双通道与修正路径
- 创建人识别(C16):creator_id 建列 + create_by 账号名兜底(兜底取会话线程账号,
  与 create_by 同一事实源,不拿 BoaUser.account 跨系统比串)
- 测试:FillAccessServiceTest 整类删除(V48 作废)、AnswerFillGateIT 按 v2 语义重写(V66)、
  DataScopeServiceTest 按两档重写、AdminScopeIT/AnswerExportIT/ReadFixture 造数改自填路径
This commit is contained in:
chenxing
2026-10-20 12:54:07 +08:00
parent fa4318cfc2
commit f10b367bbc
43 changed files with 1454 additions and 1439 deletions
+74
View File
@@ -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
@@ -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,
@@ -99,13 +99,13 @@ public class FormMyAnswerController {
}
/**
* 「可填写」列表(P95 W3,V50):<b>填报入口</b>清单,不是答卷清单——
* 域=会话校 ∪ 平台级且本校遮蔽平台、仅已发布未软删,逐行按 P95 三层模型过入口层,NONE 不入集。
* 「可填写」列表({@code V50},行形状按契约 v2 <b>C2</b>):<b>填报入口</b>清单,不是答卷清单——
* 域=会话校 ∪ 平台级且本校遮蔽平台、仅已发布未软删,逐行过 <b>audience 门</b>(身份类型 ∈ 面向人群),
* 不中的不入集。
*
* <p>这一把查询把 P91 那个"从会话菜单树挑 {@code /f/} 行"的前端推导整条换掉:
* 谁能填什么从此由中心按 {@code audience × 身份} 自解析,UserBase 侧不再需要逐表单配模块行
* (任务书 P95 §0)。<b>它不看模块行、也不看数据范围矩阵</b>:入口层与那两条线正交
* ({@code FillAccessService} 类注释记了三条线的分工)。</p>
* 谁能填什么由中心按 {@code audience × 会话身份} 自解析,UserBase 侧不需要逐表单配模块行。
* <b>它不看数据范围</b>({@code DataScopeService}):列表里出现某张表,不代表进去能看到别人的行。</p>
*
* @param appCode 可选,缺省=全部应用;快应用上下文({@code /app/{appCode}/})传它自身以收窄清单
*/
@@ -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。</li>
* <li><b>身份三键</b>(方案 §6.3):{@code submitter_id}=谁提交、{@code subject_id}=关于谁(可空)、
* {@code sch_id}=会话校,<b>全部会话推导不可外部传入</b>(契约 §5、V17)。</li>
* <li><b>身份三键</b>(方案 §6.3):{@code submitter_id}=谁提交(匿名=哨兵 0)、
* {@code subject_id}=关于谁(匿名恒 NULL)、{@code sch_id}=租户域(登录答卷恒=会话校;
* <b>匿名答卷=表单归属校</b>,契约 v2 §3.2/C8 取代原"恒=会话校"单句),
* <b>全部会话或表单推导,不可外部传入</b>(契约 §5、V17/V57)。</li>
* <li><b>幂等键是库级唯一</b>:防双击/重放只有 DB 唯一索引靠得住(方案 §5.1),
* 与"限一次"({@code fill_quota})是两个机制——幂等防重复提交,限次防多次提交。</li>
* </ul>
@@ -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;
@@ -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=开启,提交人编辑与管理员编辑<b>双写通道同关</b>(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;
@@ -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 面一律字符串数组)。
*
* <p>闭集三值:学生 / 教师 / 匿名可共集于一张表单(D8:禁止布尔补丁,不新增 {@code is_anonymous} 列)。
* {@code ANONYMOUS} 只对匿名通道生效,登录会话不可能持有它({@code FormAccessPolicy#identityTypes})。</p>
*
* <p>存量迁移由 {@code sql/t12_access_simplification.sql} 完成({@code 1→STUDENT}、{@code 2→TEACHER})。</p>
*/
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;
}
/**
* 列值码串 → 身份集合(按枚举定义序去重)。
*
* <p>空/NULL=按默认 {@code STUDENT} 解释(C1 注记"存量脏值不放宽":宁少给一个人群,
* 也不因脏列值把表单暴露给更多人)。非法码直接拒——不静默忽略,那会把"配错了人群"
* 伪装成"这些人本来就不能填"。</p>
*/
public static Set<Audience> parse(String columnValue) {
String clean = columnValue == null ? null : columnValue.trim();
Set<Audience> 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<Audience> 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<String> namesOf(String columnValue) {
List<String> 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)。
* 数据范围<b>两档</b>(契约 v2 §2.3 / D4):
* <ul>
* <li>{@link #ALL}=该表单全部行;持有者=创建人 ∪ form_admin ∪ 校管理员(∪ 超管)。
* 校管理员×平台级表单({@code sch_id=0})是 <b>ALL 档内的租户限定变体</b>(D9:只给本校提交行,
* 谓词 {@code submitter_sch_id=会话校}),不是第三个范围值;</li>
* <li>{@link #SELF}=我提交的 ∪ 关于我的({@code subject_id}=会话用户)——登录用户天然持有,
* 因此不存在"全不命中回落"分支。</li>
* </ul>
*
* <p>{@link #TEACHING_CLASS} 是 1.5 开放项(依赖 UserClass 任教关系/班级 SDK):
* <b>枚举里有、一期不可选</b>(V28),其实现器只是占位。</p>
*
* <p>{@link #SCHOOL} 与任教班级同一类"枚举里有但不入 {@code selectableScopes()}"(V28 口径):
* 它不是筛选器选项,而是管理员两表授权后的<b>解析结果</b>(谓词 {@code submitter_sch_id}=会话学校)。</p>
* <p>原四值闭集(全部/任教班级/仅本人提交/关于我)+ {@code SCHOOL} + 身份默认矩阵随
* 《契约v2》§19 作废-1 退役;任教班级/按组圈人属业务语义推断,需要时任课数据源落实后单独开批次。</p>
*/
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;
}
}
}
@@ -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<String> 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<StatusDefView> 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<String> 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<String> 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;
}
@@ -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}):填报<b>入口</b>清单的行形状。
* 一行"我可填的表单"({@code GET /api/form/my/fillable},契约 v2 C2 行形状)。
*
* <p><b>它不是答卷行</b>:这一把查询还没写任何数据,只回答"这个人能进哪张表的填报页、
* 进完之后按 P95 三层模型他算哪一层"。所以字段是<b>表单</b>身份 + 两个入口层结论,
* 与 {@link ItemView} 没有共同祖先。</p>
* <p><b>它不是答卷行</b>:这一把查询还没写任何数据,只回答"这个人能进哪张表的填报页"。
* 字段=表单身份 + 配置项,<b>不含任何入口层结论</b>:原 P95 的 {@code canFill}/{@code myTier}
* 随三层模型出清(§19 作废-3)——入集本身就等价于"能填"(登录身份类型 ∈ audience),
* 不再有"进了列表但不能填"的中间态。</p>
*
* <p>{@code canFill} 与 {@code myTier} 是<b>两件事</b>,前端两处消费:
* {@code canFill=false} 且 {@code myTier=MANAGE} 的行(教师×面向学生)在「可填写」列表里不出现,
* 但要留在"数据管理"tab 的候选里——这正是 P95 把入口层结论下发而不是只下发一个布尔的原因。
* 数字口径与定义域一致:{@code audience}/{@code fillLimit} 下发列值(1/2、0/1),
* {@code myTier} 下发枚举名(ADMIN/MANAGE/FILL,NONE 的行根本不入集)。</p>
* <p>{@code audience} 下数组(C1);{@code ANONYMOUS} 不参与登录会话的入集判定
* (匿名填报人不看列表,持 URL 直达)。</p>
*/
@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<String> audience;
/** yyyy-MM-dd HH:mm:ss;null=不限截止 */
private String deadline;
/** 限填次数:0 不限 / 1 限一次 */
private Integer fillLimit;
private String groupTag;
/** 能否以本人身份填报(层①∪层③) */
private Boolean canFill;
/** 并集里的最高层:ADMIN &gt; MANAGE &gt; FILL(NONE 不入集,故不会为 null) */
private String myTier;
}
}
@@ -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:<b>breaking</b>——由单值串改字符串数组,与 T13 改库同批切换,
* 不加适配层;闭集 {@code STUDENT}/{@code TEACHER}/{@code ANONYMOUS},可共集于一张表单)。
* 消费方通告:flow-center 只在流程定义<b>发布期</b>解析本字段,部署前发通告。
*/
private List<String> audience;
/** {@code [{key,label,fields:[{… , options:[{label,value}], searchable, valueSchema:{…}}]}]} */
private JsonNode groups;
}
@@ -178,8 +178,16 @@ public final class ReadModels {
private String code;
private Set<DataScope> scopes;
private Integer userId;
/** 会话学校(T10 方案 §5.1:{@code SCHOOL} 范围的谓词基准,其余范围不消费) */
/** 会话学校(契约 v2 §2.3:ALL 档租户限定变体的谓词基准,SELF 档不消费) */
private Long viewerSchId;
/**
* ALL 档是否为<b>租户限定变体</b>(契约 v2 D9:校管理员 × 平台级表单 {@code sch_id=0} 只看
* 本校提交行,谓词 {@code submitter_sch_id=会话校})。
*
* <p>它是 ALL 档内部的收窄,不是第三个范围值——所以放在规格位上而不是枚举里;
* 超管/创建人/form_admin 命中时恒 false(全量)。</p>
*/
private boolean platformScopedAll;
private String status;
private List<FormFilterOperators.Spec> filters;
/** 游标:库内 id(对外是 uuid,服务端换);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;
/**
* 范围实现器:<b>关于我</b>({@code subject_id}=会话用户)。
*
* <p>与"仅本人提交"是两个视角(方案 §6.3 拆分 submitter/subject 的动因):学生看到的是
* <em>关于自己</em>的数据,<b>包含老师代填的那一条</b>——老师代填时 {@code submitter_id} 是老师、
* {@code subject_id} 是学生,按本范围才可见(V18)。</p>
*/
@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<Answer> root, Integer userId, Long viewerSchId) {
return cb.equal(root.get("subjectId"), userId);
}
}
@@ -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;
/** 范围实现器:<b>全部</b>——不加行级条件(契约 §8 管理员/校领导默认,或管理员两表授权升级)。 */
/**
* 范围实现器:<b>ALL</b>——该表单全部行(契约 v2 §2.3)。
*
* <p>持有者=创建人 ∪ form_admin ∪ 校管理员(∪ 超管)。两种落点:</p>
* <ul>
* <li><b>全量 ALL</b>:不加行级条件(超管、平台表单的创建人、本校表单的授权者);</li>
* <li><b>租户限定变体</b>(D9):校管理员/form_admin 面对<b>平台级表单</b>({@code sch_id=0})时,
* 只给本校提交行——谓词 {@code submitter_sch_id=会话校}。平台表单是跨校资产,
* 授权不跨过租户边界(T10 D2=B 加列的动因,本批原样承继);给不出会话校一律拒绝,
* 不静默降成"无学校条件"(那等于把本校范围读成全平台)。</li>
* </ul>
*
* <p>{@code submitter_sch_id} 为 NULL 的存量行天然不匹配等值谓词=fail-closed(P84 口径不回退)。</p>
*/
@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<Answer> root, Integer userId, Long viewerSchId) {
return cb.conjunction();
public Predicate rowPredicate(CriteriaBuilder cb, Root<Answer> 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());
}
}
@@ -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:编辑一条"代填来的"答卷时,被代填人必须仍然真实存在且属本校——
// 提交时验过一次不代表现在还在(用户可能被删/转校)。身份列本接口不改,但脏引用不能靠编辑继续留档。
@@ -72,19 +72,21 @@ public class AnswerReadService {
/**
* 会话身份 + 表单 + 请求 → 读路径查询规格。
*
* <p>范围在这里定:{@link DataScopeService} 按契约 §8 矩阵 + 管理员两表解析,解析不出来
* (未登录等)即拒,不存在"默认全部"的分支。筛选条件按<b>当前发布版本</b>的
* {@code filterableFields} 收敛(V3 未声明 searchable 不进候选、P28 附件无算子),
* 传一个不在候选里的 key 直接拒——不能默默忽略,那会把"筛选没生效"伪装成"没有匹配数据"。</p>
* <p>范围在这里定:{@link DataScopeService} 按契约 v2 §2.3 两档(ALL/SELF)解析,未登录即拒,
* 不存在"默认全部"的分支。筛选条件按<b>当前发布版本</b>的 {@code filterableFields} 收敛
* (V3 未声明 searchable 不进候选、P28 附件无算子),传一个不在候选里的 key 直接拒——
* 不能默默忽略,那会把"筛选没生效"伪装成"没有匹配数据"。</p>
*/
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())) {
@@ -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;
/**
* 数据范围<b>实现器</b>(方案 §6.4"枚举 + 实现器(策略接口)"):一个枚举值一个实现器,
* 后续加枚举=加实现器;实现器内部要去调 SDK(如 UserClass 的任教关系)也只在自己这一格里长,
* 接口隔离保证读路径不跟着改。
* 数据范围<b>实现器</b>(契约 v2 §2.3:两档=两个实现器):一个枚举值一个实现器,
* 谓词只回答"这条答卷在不在这一档里"。
*
* <p><b>唯一出口约束</b>:实现器只产出"这条答卷在不在该范围内"的 SQL 谓词,
* 由 {@link AnswerScopeQueryBuilder} 在四条读出路径(列表/详情/统计/导出)与管理动作行级把关上
* 复用同一次构造(方案 §6.4 红线,V27/V37/V42)。实现器里不允许出现"顺手把 values 取出来判"这类分支。</p>
* <p><b>唯一出口约束</b>(V55):实现器只产出 SQL 谓词,由 {@link AnswerScopeQueryBuilder}
* 在四条读出路径(列表/详情/统计/导出)与管理动作行级把关上复用同一次构造。
* 绕开构造器另写查询=越权后门;实现器里也不允许出现"顺手把 values 取出来判"这类分支。</p>
*
* <p>原"加枚举=加实现器"的开放项(任教班级)随 §19 作废-1 出清:业务语义范围需要时任课数据源
* 落实后单独开批次,不在两档模型里留占位。</p>
*/
public interface AnswerScopeImplementor {
/** 本实现器负责的范围值 */
/** 本实现器负责的档 */
DataScope scope();
/**
* 一期是否可选。契约 §8 的四值闭集里 {@code 任教班级} 属 1.5 开放项(依赖 UserClass 任教关系/班级 SDK)
* ——枚举里有、<b>一期不可选</b>(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<Answer> root, Integer userId, Long viewerSchId);
Predicate rowPredicate(CriteriaBuilder cb, Root<Answer> root, ReadModels.Query q);
}
@@ -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<Answer> root, ReadModels.Query q) {
Set<DataScope> 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<Predicate> 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]));
}
@@ -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:手工填报、代录、批量导入三者同走这条流水线)。
* 一次提交依次过关:
*
* <pre>
* 钉版解析 → <b>第 0 关·填报入口层(P95)</b> → 截止 → 幂等命中 → subject 授权+存在性同校(P33)
* 钉版解析 → <b>audience 门</b> → 截止 → 幂等命中 → subject 授权(=ALL 档)+存在性同校(P33)
* → 值校验(按钉住版本) → 附件归属与 key 一致(P34) → 限次占位(fill_quota 撞键即拒)
* → 落 answer(写 form_uuid) → 写 answer_index(只写 searchable)
* </pre>
*
* <p><b>为什么顺序是这样</b>:</p>
* <ul>
* <li><b>先鉴权后业务</b>(P95):入口层放在钉版之后(要 {@code form.audience} 才能判人群)、
* 截止之前(过期表单对无资格的人不该输出"已过期"这种业务信息)。
* 幂等重落在闸门<b>之后</b>,所以被闸门拒的人重放同样是 403——这是任务书 W2 明示可接受的取舍;
* <li><b>先鉴权后业务</b>:audience 门放在钉版之后(要 {@code form.audience} 才能判人群)、
* 截止之前(过期表单对无资格的人不该输出"已过期"这种业务信息)。它取代 P95 的第 0 关
* 三层入口层(《契约v2》§19 作废-2)——资格从此只有一个问题:"这个人的身份类型在不在面向人群里";
* 幂等重落在闸门<b>之后</b>,所以被闸门拒的人重放同样是 403(沿 W2 明示可接受的取舍);
* 外层 {@code DataIntegrityViolationException} 的并发重放路径不再重复过闸(命中
* {@code belongsToCaller} 即返回原答卷);</li>
* <li>幂等检查必须在限次<b>之前</b>——重放的那一条本来就占着名额,先查限次会把双击重试误判成"超过次数";</li>
@@ -52,11 +55,10 @@ import java.util.UUID;
* "软删条目仍放行"的口径也就无法表达(契约 §2.1)。</li>
* </ul>
*
* <p><b>第 0 关与后面两关的分工</b>:{@link FillAccessService} 判"这个身份在这张表单上算哪一层"
* (入口层,P95 起取代 {@code /f/{code}} 模块行授权);{@link #requireSubject} 判"subject 能不能指向
* 别人"(ADMIN 沿 {@link FormAdminGuard#canManage} 现路径、MANAGE 走 P33 存在性);
* {@link DataScopeService} 判"写进去之后谁能看见这行"(读矩阵,与本类无关)。
* 三条线各自独立,改一条不动另一条。</p>
* <p><b>三条线的分工(v2 后只剩两条半)</b>:audience 门(本类)判"能不能填这张表";
* {@link #requireSubject} 判"subject 能不能指向别人"=该表单 ALL 档({@link DataScopeService});
* 同一次解析的结果同时决定"写进去之后谁能看见这行"。<b>代录与导入也走这里</b>(§3.4/V60),
* 所以"代录的权限"与"手工填的权限"不可能有两套口径。</p>
*
* <p><b>事务形态</b>:公开方法不带 {@code @Transactional},写库段用 {@link TransactionTemplate} 包。
* 目的是让"幂等键撞 uk"这种并发结局能在<b>事务外</b>重新读取原答卷并按契约返回 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<FillAccessService.Tier> tiers = fillAccess.resolve(user, form);
requireFillEntry(user, tiers, form, req.getSubjectId(), submitterId);
// 2) audience 门(契约 v2 §3.1):钉版之后才有 audience 可判,截止之前先鉴权。
// 只对<b>自填</b>生效(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):<b>自填</b>资格=会话身份类型 ∈ 表单面向人群(∩ 状态门在后面几关)。
*
* <p>两条拒绝各有独立文案,因为它们是两种不同的用户动作失败了:
* <b>NONE</b>=压根不在面向人群里(家长/工作号/局领导、学生填教师问卷),直链进来也是 403;
* <b>仅 MANAGE 的自填</b>=人在人群口径里、但档位只给代填不给自填(教师×学生面向),
* 文案要告诉他"改成代填学生就能提交",否则这条 403 无从下手。</p>
* <p><b>代录不吃这道门</b>(问题单 <b>P115</b>,用户 2026-10-20 裁决):§3.4 写"audience 门按代录
* 操作者会话判定",而 §5.1/V73 又要求管理员视图能"添加(代录)"、批量导入走同一管线——
* 校管理员(roleId 2/7)本身不在学生或教师人群里,按字面判会让代录对管理员整体失效。
* 两条承诺只能留一条,留的是<b>功能可用</b>那条:{@code subject} 指向他人时,操作者的身份
* 不代表填报人,资格由"是不是该表单 ALL 档持有者"决定(在 {@link #requireSubject} 判),
* 人群门留给自填。</p>
*
* <p>拒绝文案必须<b>可诊断</b>(承接原第 0 关的文案纪律):带上表单 code、配置的 audience
* 集合、当前会话的身份要素——"人群配错了"与"这个人确实不在人群里"要能在日志里一眼分开。</p>
*/
private void requireFillEntry(BoaUser user, Set<FillAccessService.Tier> 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<FormEnums.Audience> identities = FormAccessPolicy.identityTypes(user);
Set<FormEnums.Audience> 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<FillAccessService.Tier> tiers, Integer subjectId,
/**
* subject 指定权(契约 v2 §3.1/G1):只有该表单 <b>ALL 档</b>持有者(创建人 ∪ form_admin ∪
* 校管理员 ∪ 超管)能把答卷挂到别人名下;其余会话 subject 只能=本人。
*
* <p>原 {@code FormAdminGuard#canManage}(授权模块行判"有没有代填权")随模块行退役收敛到这里
* ——数据职权与"能替谁填"从此是同一个事实源(V55 的写侧对应面)。P33 的存在性+同校复核不变。</p>
*/
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());
@@ -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):把"会话用户 + 这张表单"解析成<b>两档</b>可见集,
* 交给 {@link AnswerScopeQueryBuilder} 落成 SQL 谓词。
*
* <p><b>解析规则(优先级)</b>:{@code 范围 = 管理员两表授权 ∪ 身份默认矩阵},
* 命中"全部"即收敛为全部;<b>全不命中回落仅本人提交</b>(宁紧勿松)。</p>
*
* <p><b>管理员两表来源</b>(T10 起,取代已退役的 {@code customId} 权限锚点行):
* {@code school_admin} 命中会话校、或 {@code form_admin} 命中这张表单时,按表单归属域升级——
* 本校表单=<b>全部</b>(含本校快应用表单,{@code app_code} 不限),基础表单({@code sch_id=0})=
* <b>本校提交</b>(D2 裁决:跨校学生数据不因授权而可见)。他校表单不给任何升级。</p>
*
* <p>"多来源取最宽"在这里的实现是<b>并集</b>:一个用户可能同时有多个身份({@code roleIds} 里既有
* 教师又有学生),逐格取默认值后并起来,读路径按 OR 判行——这比"只取一个最宽枚举值"更贴语义:
* 学生视角(关于我)与教师视角(仅本人提交)不是包含关系,取单值会丢掉其中一格。</p>
*
* <p><b>身份默认矩阵</b>(契约 §8,行=roleId、列=表单面向人群):</p>
* <pre>
* 面向学生(1) 面向教师(2)
* 7/2 全部 全部
* 1 任教班级 → 一期实现器缺席=仅本人提交 仅本人提交
* 3 关于我 仅本人提交(该格契约写"不授填报模块行,进不来",
* 数据面按最保守的兜底值处理)
* 4/6/5 仅本人提交 仅本人提交
* ALL =该表单全部行 持有者:创建人(C16)∪ form_admin ∪ 校管理员(∪ 超管)
* SELF =我提交的 ∪ 关于我的 一切 CAS 会话用户天然持有
* </pre>
*
* <p>与 {@link FormAdminGuard} 是<b>两条正交的口径</b>:守卫答"能不能进这个管理面"(UserBase 授权模块行,
* 外加 T10 的基础表单读开口),本类答"进来后能看哪些行"。<b>两条线的事实源不同</b>——模块行来自 UserBase
* ({@link ModuleTreeSource}),管理员身份来自本库两表({@link AdminScopeSource}),管理员表不充当页面
* 入口凭证(方案 §3 三层模型不变)。</p>
* <p>三条与旧模型不同的地方,逐条对应《契约v2》§19 作废-1/5:</p>
* <ul>
* <li><b>身份默认矩阵整体下线</b>——原"roleId × 面向人群"逐格取值、多来源取最宽、
* "全不命中回落仅本人提交"三段全部删除。回落分支之所以不需要:登录用户天然持有 SELF,
* 空集只可能出现在无会话场景(匿名通道不经本解析);</li>
* <li><b>SCHOOL 不再是独立范围值</b>——它变成 ALL 档内的租户限定变体({@link Resolved#platformScopedAll()}),
* 只对平台级表单({@code sch_id=0})生效;</li>
* <li><b>创建人自动 ALL</b>(D6/G1)——运行时并集判定,不落 {@code form_admin} 行,两张表保持"纯授权"语义。</li>
* </ul>
*
* <p><b>fail-closed 三处不回退</b>:未登录即拒;他校表单({@code sch_id} 既非会话校也非 0)不给 ALL;
* 租户限定变体给不出会话校即拒。存量 {@code submitter_sch_id=NULL} 不匹配等值谓词(P84)。</p>
*/
@Slf4j
@Service
@@ -53,57 +43,69 @@ public class DataScopeService {
private final List<AnswerScopeImplementor> 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<DataScope> 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<DataScope> 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 形态:本校表单=全量,平台级表单=租户限定变体,他校表单=不给。
*
* <p>平台级表单上不给全量是<b>刻意的</b>:§2.3 的变体只点名校管理员,但 D9/V57 的不变式是
* "平台表单全量归创建人(超管)、跨校学生数据不因授权而可见"——form_admin 授权同样落在一张跨校
* 资产上,给全量就等于开一个跨校读口子(T10 D2=B 加 {@code submitter_sch_id} 列的动因原样成立)。
* 该差额登记为问题单,等裁决而不是静默放宽。</p>
*/
public Set<DataScope> selectableScopes() {
Set<DataScope> 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 <em>或</em> {@code create_by}==会话账号名。
*
* <p>兜底那半不是历史包袱而是必需项:{@code create_by} 存的是账号名(String),与两表的 user_id
* 标识体系不同,存量行 {@code creator_id} 未回填时只有账号名可比。存量回填由一次性对照 UserBase
* 的脚本/人工完成(160 现存 form 行个位数),回填前后判定结果一致。</p>
*/
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,交给矩阵)。
*
* <p>两张表的映射口径<em>逐字相同</em>(方案 §5.1 伪码里两段 if 是同一段逻辑),所以合成一次判定;
* 若将来两表要分档(比如单表单管理员只给到本校提交),在这里分叉而不是在调用侧。</p>
* 解析输出=二档布尔集(§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<DataScope> scopes() {
if (all) {
return EnumSet.of(DataScope.ALL);
}
// 面向学生:矩阵值=任教班级,一期实现器缺席 → 生效值=仅本人提交(方案 §6.4 表格明示,防被当 bug 报)
log.debug("[数据范围] 教师×面向学生:任教班级一期未开放,按矩阵注记生效为仅本人提交");
return DataScope.SELF_SUBMITTED;
return self ? Collections.singleton(DataScope.SELF) : Collections.<DataScope>emptySet();
}
if (roleId == FormAccessPolicy.ROLE_STUDENT && studentFacing) {
return DataScope.ABOUT_ME;
}
// 学生×面向教师、家长(4)/工作号(6)/局领导(5)、以及未知角色:兜底
return DataScope.SELF_SUBMITTED;
}
}
@@ -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;
/**
* 填报<b>入口层</b>解析(P95 任务书 §0.1 三层角色模型):把"会话用户 + 这张表单"解析成一个<b>层集合</b>,
* 回答"填报入口给不给这个人开、开到哪一档"。纯解析、无写路径、不碰任何一张表。
*
* <p><b>三条线的分工(互不越界,P95 红线 1/2)</b>:</p>
* <ul>
* <li><b>UserBase 模块行</b>管"能不能进页面"——{@code /admin/*} 四行是管理面入口凭证
* ({@link FormAdminGuard},P80 口径"入口只认模块行,两表只升范围"一字不动)。
* {@code /f/{code}} 填报模块行自 P95 起<b>不再承担填报授权</b>;</li>
* <li><b>本类</b>管"填报入口给谁开"(P95 新增)——人群由表单的 {@code audience} × 会话身份 ×
* 管理员两表在中心侧自解析,不需要超管逐个表单配模块行;</li>
* <li>{@link DataScopeService} 管"进页面后能看哪些行"(读矩阵,一字不动)。</li>
* </ul>
* 三者判定因子有重叠(都看 roleIds、都看两表),但<b>结论互不供给</b>:本类不给数据面放行,
* 数据面也不给填报入口放行。
*
* <p><b>层与谓词(任务书 §0.1 表原样)</b>:</p>
* <pre>
* 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 全不命中)= 空集
* </pre>
*
* <p><b>多身份=并集</b>:{@code roleIds}(全部正常态角色)∪ 会话 {@code currentRole.roleId}
* 逐层判定后合并(沿 {@link DataScopeService} 的 {@code identityRoleIds} 口径,防 UserBase 两侧不一致漏格)。
* 一个层集合可以同时含多层(如教师×学生面向 = {@code {MANAGE}},校领导 = {@code {ADMIN}})。</p>
*
* <p><b>与任教班级 stub 的关系</b>:层②的<b>数据面</b>语义就是现状(矩阵该格生效=仅本人提交),
* 本类只是把"教师可以往本校学生名下写数据"这件事从模块行授权收回到中心解析,
* {@code DataScopeService} 的注释与取值一概不动。</p>
*/
@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(没有任何入口)。<b>永不为 null</b>
*/
public Set<Tier> resolve(BoaUser user, Form form) {
if (user == null || user.getId() == null) {
throw new BizException(ErrorCode.UNAUTHORIZED, "未登录");
}
Set<Tier> tiers = EnumSet.noneOf(Tier.class);
List<Integer> 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;
}
/** 层①∪层③:能否以<b>本人</b>身份填报(层②只有代填,自填不在这里——由调用侧按行为表拒绝并给文案) */
public boolean canFill(Set<Tier> tiers) {
return tiers != null && (tiers.contains(Tier.FILL) || tiers.contains(Tier.ADMIN));
}
/** 层②∪层③:能否<b>代填</b>他人(真正能不能落到某个人身上还要过 P33 的 existsInSchool) */
public boolean canProxy(Set<Tier> tiers) {
return tiers != null && (tiers.contains(Tier.MANAGE) || tiers.contains(Tier.ADMIN));
}
/** 层③:管理员层命中(超管 / 两表 / 校领导(2) / 管理员(7)) */
public boolean isAdmin(Set<Tier> tiers) {
return tiers != null && tiers.contains(Tier.ADMIN);
}
/** 并集里的最高层(序 ADMIN &gt; MANAGE &gt; FILL;空集返回 null=NONE),供列表的 {@code myTier} 用 */
public Tier topTier(Set<Tier> tiers) {
if (tiers == null || tiers.isEmpty()) {
return null;
}
for (Tier candidate : Tier.values()) {
if (tiers.contains(candidate)) {
return candidate;
}
}
return null;
}
/**
* 管理员两表是否把这个人抬进层③:{@code school_admin} 命中<b>会话校</b>、
* 或 {@code form_admin} 命中<b>这张表单</b>(口径与 {@link DataScopeService} 读侧同源,
* 但结论只用在入口层,不改数据范围)。
*
* <p>与读侧的差别只有一处,且是有意的:两表在这里<b>不看表单归属域</b>。
* 读侧要区分"本校表单→全部 / 基础表单→本校提交",而入口层问的是"这个人是不是管理员",
* 基础表单的管理员同样是管理员;校域不变式({@code answer.sch_id}=会话校)也不因代填而变。</p>
*/
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<Integer> roleIds) {
for (Integer roleId : roleIds) {
if (roleId != null && FormAccessPolicy.SCHOOL_LEADER_ROLES.contains(roleId)) {
return true;
}
}
return false;
}
}
@@ -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 那个列表的数据源,从此<b>不再吃 UserBase 模块树</b>。
* 「我可填」清单解析({@code GET /api/form/my/fillable},契约 v2 C2 行形状):
* 把"这个会话用户能进哪些表单的填报页"算成一张列表——数据源不再是 UserBase 模块树。
*
* <p><b>四步,一步不多</b>:</p>
* <ol>
@@ -29,17 +30,18 @@ import java.util.Set;
* <li><b>遮蔽去重</b>:同 {@code (app_code, code)} 本校与平台级都在时本校胜出且不回落——
* 直接复用 {@link FormDefService#pickByShadowing},与 resolve 同一条 U5 实现(P20 的取舍:
* 两处口径必须同源,否则列表里出现的表单点进去可能解析成另一张);</li>
* <li><b>逐行过入口层</b>:{@link FillAccessService#resolve} 为空集(NONE)的行不入集;非空即入集,
* {@code canFill} 与 {@code myTier} 一起下发——层②(教师×面向学生)的行 {@code canFill=false},
* 前端「可填写」列表把它筛掉,但"数据管理"tab 的候选要靠它,所以不能只下发一个布尔;</li>
* <li><b>逐行过 audience 门</b>:登录身份类型 ∩ {@code form.audience}=∅ 的行不入集。
* 这一条与 {@link AnswerService} 提交时的 audience 门<b>同一个公式</b>——
* "列表里有"与"提交能过"不可能不一致(原 P95 的 {@code canFill}/{@code myTier} 随之消失:
* 入集即等价于可填,不再有"进了列表但不能填"的中间态,§19 作废-3);</li>
* <li><b>排序</b>:deadline 升序、null(不限)在后、同档按 code。</li>
* </ol>
*
* <p><b>不分页</b>:一所学校 + 平台级里可填的表单是个位数到几十张(P95 W3 的量级假设,报告已记),
* <p><b>不分页</b>:一所学校 + 平台级里可填的表单是个位数到几十张(量级假设,报告已记),
* 一张轻列表不值得引入游标;返回值就是裸数组,不带 total——将来真长大了加分页时不破坏"行形状"这条契约。</p>
*
* <p><b>与数据面的关系</b>:本类只回答"入口给谁开",不看 {@link DataScopeService}。
* 列表里出现某张表单,不代表进去能看到别人的行(层②教师看到的仍是仅本人提交)。</p>
* <p><b>{@code ANONYMOUS} 不参与入集</b>:匿名填报人不看列表,持 URL 直达(C2)。
* 本类也不看数据范围({@link DataScopeService}):列表里出现某张表单,不代表进去能看到别人的行。</p>
*/
@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<MyModels.FillableRow> rows = new ArrayList<>();
Set<FormEnums.Audience> identities = FormAccessPolicy.identityTypes(user);
for (Form form : visible) {
Set<FillAccessService.Tier> 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;
}
}
@@ -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} <b>同一个事实源</b>
* ({@code FormCenterAuditListener} 落列时取的就是它),所以"创建人兜底判定"(C16)
* 比的是同一条字符串,不是两个系统里恰好相等的两个字段。
*
* <p>{@code BoaUser} 上另有一个 {@code account},但它由 UserBase 组织接口下发、与 token 侧
* 账号名不是同一处写入;拿它比 {@code create_by} 会引入"两边口径不同就静默判不出创建人"的隐患。</p>
*/
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;
@@ -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)。
*
* <p>契约 v2 新增两条配置面纪律:① {@code audience} 改<b>字符串数组</b>入参(C1,闭集三值),
* ② 含 {@code ANONYMOUS} 的表单<b>不许配限次</b>(§3.2/作废-11:匿名无 limit_key,cookie 防重是唯一节流)——
* 这条跨字段约束必须在配置通道挡下来,不能等提交时才发现"限次对匿名不生效"。</p>
*
* <p><b>先算合并态、再全部验完、最后才写实体</b>:跨字段约束看的是"这次改完长什么样"
* (入参 + 现值合并),但如果在校验前就改实体,被拒的那一次调用会在同一持久化上下文里留下
* 脏值(回滚型测试事务尤其明显),等于把"配置被拒"伪装成"配置已生效"。</p>
*/
@Transactional
public FormModels.FormView updateConfig(Long schId, FormModels.FormConfigRequest req) {
Form form = requireForm(schId, req.getUuid());
// ---- 1) 合并态解析(不碰实体) ----
Set<FormEnums.Audience> 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<FormEnums.Audience> 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()));
@@ -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;
}
@@ -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;
/**
* 范围实现器:<b>本校提交</b>({@code submitter_sch_id}=会话学校,T10 方案 §5.1 / 契约 §8 v1.5)。
*
* <p>它是管理员两表在<b>基础表单(sch_id=0)</b>上的落地形态:校级/单表单管理员都不因授权而看见
* 他校学生数据(D2=B 裁决加列的动因)。给不出会话学校时明确拒绝,不静默降成"无学校条件"——
* 那等于把本校范围读成全平台。</p>
*
* <p>{@link #selectable()} 为 false 的理由与任教班级同族但相反:任教班级是"能力未开放",
* 本值是"能力已开放但不是一个筛选选项"——它是授权结果,出现在筛选器里等于让调用方自己声明
* 一个未经授权的范围(V28 断言的就是这个"不入 selectableScopes")。</p>
*/
@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<Answer> 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);
}
}
@@ -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;
/**
* 范围实现器:<b>SELF</b>=我提交的 ∪ 关于我的(契约 v2 §2.3)。
*
* <p>原 {@code SELF_SUBMITTED}(按 {@code submitter_id})与 {@code ABOUT_ME}(按 {@code subject_id})
* 两档合并成一档:§2.3 把 SELF 定义成这两个视角的<b>并集</b>,读路径本就按 OR 判行,
* 拆两档只是原身份矩阵的产物(多身份并集里有"只有学生身份才拿到关于我"这种绕路)。
* 合并后"我填的"与"别人替我填的"一次命中(V18 语义不回退——{@code /my/about} 那两条视图
* 仍按单列查,见 {@code AnswerReadService#listMine},那是归属判定、不是范围矩阵)。</p>
*
* <p>走 {@code idx_answer_submitter(sch_id, submitter_id)} 与 {@code idx_answer_subject(subject_id)}。</p>
*/
@Component
public class SelfScopeImplementor implements AnswerScopeImplementor {
@Override
public DataScope scope() {
return DataScope.SELF;
}
@Override
public Predicate rowPredicate(CriteriaBuilder cb, Root<Answer> root, ReadModels.Query q) {
return cb.or(
cb.equal(root.get("submitterId"), q.getUserId()),
cb.equal(root.get("subjectId"), q.getUserId()));
}
}
@@ -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;
/**
* 范围实现器:<b>仅本人提交</b>({@code submitter_id}=会话用户)。
*
* <p>这是契约 §8 的兜底值:"全不命中=仅本人提交(宁紧勿松)",也是教师×面向学生表单在 1.5
* 之前的实际生效值(任教班级实现器缺席,方案 §6.4 表格里写明了要防"教师怎么只看到自己"被当 bug 报)。
* 走 {@code idx_answer_submitter(sch_id, submitter_id)}。</p>
*/
@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<Answer> root, Integer userId, Long viewerSchId) {
return cb.equal(root.get("submitterId"), userId);
}
}
@@ -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;
/**
* 范围实现器:<b>任教班级</b>——1.5 占位(契约 §8 / 方案 §6.4)。
*
* <p>存在意义就是把 V28 的两半钉在代码里:枚举值<em>在</em>(一期契约即含四值,定了就不变),
* 但 {@link #selectable()} 为 false,{@code DataScopeService} 因此永远不会把它放进解析结果,
* 教师×面向学生表单在一期按矩阵注明的"实际生效=仅本人提交"落地。</p>
*
* <p>1.5 开放时的改动面就在本类:接 UserClass(原 BschClass)的任教关系/班级 SDK,把
* {@code rowPredicate} 换成"submitter 的任教班级 ∩ 答卷班级列"或按 subject 的班级归属判定,
* {@code selectable()} 翻成 true——读路径与管理动作不用改(它们只认 {@link AnswerScopeQueryBuilder})。</p>
*/
@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<Answer> root, Integer userId, Long viewerSchId) {
// 防御性:一期解析不会产生该范围(selectable=false),走到这里说明有人绕过了矩阵解析
throw new BizException(ErrorCode.CUSTOM, "数据范围 任教班级 一期未开放(1.5 依赖 UserClass 任教关系)");
}
}
@@ -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\":[]}]"));
@@ -26,19 +26,19 @@ import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
/**
* 管理员两表的数据范围集成测试(真 MySQL;自验项 <b>V43 / V44 / V47</b>)。
* 管理员两表的数据范围集成测试(真 MySQL;契约 v2 §2.3 下承继 <b>V43/V44/V47</b> 的行集断言)。
*
* <p>取证重点与 {@code DataScopeServiceTest} 互补:单测证"解析出哪个集合",这里证
* <b>集合落成的 SQL 谓词真的少读了行</b>——包括 SCHOOL 谓词把 {@code submitter_sch_id} 为 NULL 的
* 存量行挡在外面(方案 §4 的 fail-closed 半边),以及导出与列表同集(V27 红线的 T10 延伸)。</p>
* <p>取证重点与 {@code DataScopeServiceTest} 互补:单测证"解析出哪一档",这里证
* <b>档落成的 SQL 谓词真的少读了行</b>——包括 ALL 档租户限定变体把 {@code submitter_sch_id} 为 NULL 的
* 存量行挡在外面(P84 的 fail-closed 半边,不回退),以及导出与列表同集(V27 红线的延伸)。</p>
*
* <p>数据集刻意含"基础表单(sch_id=0)+ 两校各提交一条":这是 {@code SCHOOL} 唯一能被单独分辨出来的
* 形状(本校表单上升级出的是 ALL,与租户域同向;只有平台域表单的读开口才需要"本校提交"这一档)。</p>
* <p>数据集刻意含"基础表单(sch_id=0)+ 两校各提交一条":这是租户限定变体唯一能被单独分辨出来的
* 形状(本校表单上 ALL 就是全量;只有平台域表单的读开口需要收窄成本校提交行)。</p>
*/
@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<String> 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 关之后,教师×面向学生表单<b>只能代填本校学生</b>(W2 行为表层②那一行),
* 以本人身份自填会被拒。本类要的是"教师提交过的行"这个事实(submitter=教师),
* 所以造它就走代填路径——读侧矩阵断言(仅本人提交=submitter 谓词)吃的还是同一个谓词。
* 教师以<b>本人</b>身份提交的那条({@code submitter=subject=教师})。
*
* <p>被代填人刻意用 {@code PROXY_SUBJECT} 这个新 id:它不出现在任何"关于我"断言里,
* 换 subject 不会把学生视角的可见集改大(V43"未命中=矩阵默认一格不变"依赖这点)。</p>
* <p>P95 时代这条要靠"教师×面向学生表单只能代填"(层②)造;三层模型出清后,代录只对 ALL 档开放,
* 而本数据集刻意不给教师 ALL 档——否则"教师只见自己那行"的范围断言就没有对照物了。
* 于是把表单人群扩成含 TEACHER,教师自填,读侧 SELF 档吃的仍是 {@code submitter} 谓词。</p>
*/
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);
@@ -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 <b>代填</b>提交
* 建表单并发布(人群=学生+教师共集),写 {@code total} 条答卷,其中前 3 条由<b>教师1 本人</b>提交
* (范围用例的"他可见集"=submitter=教师1 的那三条)。
*
* <p>P95 第 0 关之后,教师×面向学生表单以本人身份自填会被拒(行为表层②),所以这三条走代填路径:
* 谓词列还是 {@code submitter_id},"仅本人提交"的可见集与导出行数断言一字未改。</p>
* <p>P95 时代这三条走"教师代填学生"(层②);三层模型出清后教师不再是任何代填档,
* 于是把人群扩成含 TEACHER、由教师自填——谓词列还是 {@code submitter_id},
* "SELF 档可见集=submitter ∪ subject"与导出行数断言一字未改。</p>
*
* @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;
}
@@ -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 关与教师代填(自验项 <b>V49</b>):按任务书 W2 行为表<b>逐行</b>打真库
* ({@code @Transactional} 回滚,沿 {@code AnswerSubmitIT} 先例)。
* audience 门与 subject 指定权(契约 v2 §3.1,验收 <b>V66</b>;取代 P95 的 V49 第 0 关 IT——
* 三层入口层随《契约v2》§19 作废-2 出清,本类按 v2 语义重写,并逐格钉住新旧<b>行为差异</b>)。
*
* <p>行为表三档 × 自填/代填 = 六个格子,加上"代填仍受 P33 管"和"闸门排在截止与幂等之前"两条顺序断言。
* 桩只顶掉 {@link SubjectDirectory}(UserBase 的人)与 {@link ModuleTreeSource}(模块授权树),
* 管理员两表用真行({@link #grantSchoolAdmin}/{@link #grantFormAdmin})——入口层与读侧共用同一个事实源,
* 用桩顶掉就等于没验接线。</p>
* <p>打真库({@code @Transactional} 回滚,沿 {@code AnswerSubmitIT} 先例)。两条断言主线:</p>
* <ul>
* <li><b>资格只有一个问题</b>:会话身份类型 ∈ {@code form.audience}。原层①/②/③/NONE 的四格放行表
* 不再存在,"人在人群里但只给代填不给自填"这种中间态也一并消失;</li>
* <li><b>subject 指定权=该表单 ALL 档</b>(§3.1/G1):原 {@code canManage}(授权模块行)判据退役,
* "进了 form_admin 能不能代填"因此翻了个向——这是本批最大的一处行为变化,单独一格钉住。</li>
* </ul>
*
* <p>闸门在<b>截止与幂等之前</b>的次序、限次/值校验/钉版等既有环节,都在本类一并复验(不回退)。</p>
*/
@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();
}
// 同一个人以<b>本人</b>身份提交(无 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<String> codes) {
FormModels.FormConfigRequest req = new FormModels.FormConfigRequest();
req.setAudience(codes);
return req;
}
}
@@ -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,验收 <b>V61/V68</b>):{@code form.frozen=1} 时
* 提交人编辑与管理员编辑<b>双写通道同关</b>,唯一修正路径=软删(撤回)+ 重新提交。
*
* <p>为什么值得单独一个 IT:两条编辑通道分别落在 {@code AnswerReadService#editBySubmitter}
* 与 {@code AnswerAdminService#doEdit} 两个类里,只在一处加判据就等于"藏一半开一半"——
* 原件是档案这条承诺只在两边都关的时候成立(V61 断言的就是"两条通道"而不是"编辑")。</p>
*
* <p>同时钉住<b>未冻结</b>那条腿:默认关(V68),不开时两条通道照常可编辑,
* 免得把"冻结"实现成"一律禁编辑"。</p>
*/
@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;
}
}
@@ -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);
@@ -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;
/**
* 数据范围解析单测(自验项 <b>V28</b> 全部 + <b>V42</b> 的"身份默认矩阵/来源升级/回落"三半边 +
* <b>V43/V44</b> 的解析半边)。
* 数据范围两档解析单测(契约 v2 §2.3,验收 <b>V55/V57</b> 的解析半边)。
*
* <p>矩阵(契约 §8)在这里逐格断言;"受限身份的统计/导出 ⊆ 列表可见集"那半边是接口级事实,
* 在 {@code AnswerReadIT}/{@code AdminScopeIT} 打真库断言。桩只顶掉两表来源(本库事实源),
* 解析规则本身全部走生产代码。</p>
* <p>原"身份默认矩阵逐格断言"(V42/V28 的载体)随 §19 作废-1 出清,本类改按两档规则断言:
* {@code ALL=创建人 ∪ form_admin ∪ 校管理员(∪ 超管)}、{@code SELF=登录用户天然持有},
* 外加平台级表单上的<b>租户限定变体</b>(D9)与三处 fail-closed。</p>
*
* <p>T10 改版:原"customId 锚点升级"两例(V42 扩展位 / P47 只认 path 空行)随锚点协议退役删除,
* 同一位置改由管理员两表用例承担(V43/V44)。</p>
* <p>桩只顶掉管理员两表来源({@link InMemoryAdminScopeSource}),解析规则本身全部走生产代码。</p>
*/
@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<DataScope> 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<AnswerScopeImplementor> 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;
}
@@ -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;
/**
* 填报入口层解析单测(自验项 <b>V48</b>:任务书 P95 §0.1 三层模型矩阵逐格 + 多身份并集 + 超管 +
* 两表命中 + NONE 兜底)。
*
* <p>桩只顶掉管理员两表来源({@link InMemoryAdminScopeSource},同 {@code DataScopeServiceTest} 先例),
* 判定规则本身全走生产代码。第 0 关的<b>行为</b>(放行/拒绝/文案)不在这里,是
* {@code AnswerSubmitGateIT} 按 W2 行为表逐行打的。</p>
*/
@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(可代填、<b>不可</b>自填)——FILL 谓词不含这一层,正是 W2 行为表'教师自填学生面向=拒'的解析根据")
void manageTierIsProxyOnly() {
Set<Tier> 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<Tier> 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 命中<b>会话校</b>才升 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 命中<b>该表单 uuid</b>才升 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<Tier> 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<Tier> 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;
}
}
@@ -22,16 +22,20 @@ import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
/**
* 「我可填」列表接口集成测试(自验项 <b>V50</b>):域遮蔽去重、仅已发布、appCode 过滤、
* canFill/myTier、排序——四条都打真库({@code @Transactional} 回滚)。
* 「我可填」列表接口集成测试(自验项 <b>V50</b>,行形状按契约 v2 <b>C2</b> 复验):
* 域遮蔽去重、仅已发布、appCode 过滤、audience 入集、排序——都打真库({@code @Transactional} 回滚)。
*
* <p>服务方法直调(同 {@code MyAnswerIT} 先例,不起 MockMvc):这一把要证的是取域/去重/过闸/排序四件事,
* <p>服务方法直调(同 {@code MyAnswerIT} 先例,不起 MockMvc):这一把要证的是取域/去重/过人群门/排序四件事,
* 全在服务内;信封与参数绑定是 {@code FormBaseControllerTest} 那类已在别处验过的壳。</p>
*
* <p><b>与 P95 的两处行为差(§19 作废-3,逐格钉住)</b>:{@code canFill}/{@code myTier} 两个字段消失
* (入集即等价于可填,不再有"进了列表但不能自填"的中间态);管理员身份不再让行入集
* (填报资格只看 audience,两表只升数据范围——原"进 form_admin 就在列表里升到 ADMIN"随三层模型出清)。</p>
*/
@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<MyModels.FillableRow> 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<MyModels.FillableRow> 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<MyModels.FillableRow> 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<MyModels.FillableRow> 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<MyModels.FillableRow> 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<String> 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);
@@ -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) {
@@ -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:草稿不暴露;发布后可解析;<b>本校遮蔽平台级且不回落</b>;未发布/停用给业务级错误")
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.<String>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) {
@@ -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);
@@ -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);
@@ -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);
@@ -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);
@@ -15,16 +15,16 @@ import com.yunhai.platform.utils.model.BoaUser;
* V9(升版声明 searchable 后旧答卷可按新字段筛,靠发布时补建索引行)用的就是它;</li>
* <li>{@code cert} 是 upload 且被勾了 searchable——P28(无检索算子:不写索引行、不进筛选候选);</li>
* <li>学生自填的行 {@code subject_id}=本人(Bug#3 起落库即归一,不再为 {@code NULL})、
* 管理员代填的行 {@code subject_id}=学生——契约 §8"学生=关于我"这一格含自填与代填两类
* ("仅本人提交"那一格看的是 {@code submitter_id});</li>
* 管理员代录的行 {@code subject_id}=学生——两档模型里这些都落在同一档 {@code SELF}
* ({@code submitter}=我 ∪ {@code subject}=我,契约 v2 §2.3);</li>
* <li>一条被软删的行——四条读出路径都必须看不见(V30 的读侧半边)。</li>
* </ul>
*/
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 起教师×面向学生表单<b>只能代填</b>,自填被第 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 有数据管理权且有学校归属的操作人(代填与软删用它)
* <p>表单 A 的人群配成<b>学生+教师共集</b>(C1 多值):v2 出清三层模型后,教师不能自填纯学生面向的表单,
* 也没有代填权(代录要 ALL 档,而本数据集刻意让教师<b>不是</b> ALL 档,否则"只见自己"这条断言无从谈起)。
* 教师那两条因此是<b>自填行</b>({@code submitter=subject=教师})——读侧 SELF 档的谓词形状与原来一致。</p>
*
* @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,