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:
@@ -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 > MANAGE > 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 > MANAGE > 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,
|
||||
|
||||
Reference in New Issue
Block a user