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

- DDL sql/t12_access_simplification.sql(非幂等,先查列):form.audience TINYINT→VARCHAR(64)
  + 存量迁移、form 加 creator_id/frozen/anon_cookie_minutes、answer 加 anon_ip/anon_ua
  + idx_answer_form_utime;实体与 @Index 同步(本地库已备份并手工执行)
- audience 多值(C1/C2/C5):FormEnums.Audience 扩 ANONYMOUS + parse/join/namesOf 单点工具;
  配置通道收数组并加跨字段约束(含 ANONYMOUS 时 fill_limit 必 0,CUSTOM(5));
  FormView/ResolvedFormView/FillableRow/oapi SchemaView 出参一律数组(flow-center 需通告)
- 两档范围(§2.3/D4):DataScopeService 重写为 {all,self} + 平台级表单的租户限定变体(D9),
  身份默认矩阵与回落分支删除;5 实现器收敛为 ALL/SELF 两个,SCHOOL 谓词并入 ALL 变体、
  TEACHING_CLASS 摘除;AnswerScopeQueryBuilder 改吃查询规格(V55 唯一出口不变)
- audience 门替第 0 关(§3.1):AnswerService 的 fillAccess.resolve+requireFillEntry 换成
  人群门,requireSubject 指定权收敛为"该表单 ALL 档";FillAccessService 全类删除;
  代录/导入不过人群门(P116 用户裁决:§3.4 与 §5.1/V73 冲突取"功能可用"一侧)
- fillable 行形状(C2):删 canFill/myTier,入集=身份类型 ∈ audience(与提交同一公式)
- 冻结开关(§4.2/V61):config 通道 + 两条编辑通道同关,文案指路"撤回后重新提交";
  新增 AnswerFrozenIT 钉双通道与修正路径
- 创建人识别(C16):creator_id 建列 + create_by 账号名兜底(兜底取会话线程账号,
  与 create_by 同一事实源,不拿 BoaUser.account 跨系统比串)
- 测试:FillAccessServiceTest 整类删除(V48 作废)、AnswerFillGateIT 按 v2 语义重写(V66)、
  DataScopeServiceTest 按两档重写、AdminScopeIT/AnswerExportIT/ReadFixture 造数改自填路径
This commit is contained in:
chenxing
2026-10-20 12:54:07 +08:00
parent fa4318cfc2
commit f10b367bbc
43 changed files with 1454 additions and 1439 deletions
@@ -2,6 +2,7 @@ package com.yunhai.formcenter.controller.api;
import com.yunhai.formcenter.common.FormSchemaValidator;
import com.yunhai.formcenter.model.FormModels;
import com.yunhai.formcenter.service.FormAccessPolicy;
import com.yunhai.formcenter.service.FormAdminGuard;
import com.yunhai.formcenter.service.FormDefService;
import com.yunhai.formcenter.service.FormDictService;
@@ -88,7 +89,14 @@ public class FormAdminController {
public FormModels.FormView create(BoaUser user,
@RequestParam(required = false) Long schId,
@RequestBody FormModels.FormCreateRequest body) {
return defService.create(adminGuard.requireAdmin(user, schId, FormAdminGuard.MODULE_FORM_DESIGN), body);
return defService.create(adminGuard.requireAdmin(user, schId, FormAdminGuard.MODULE_FORM_DESIGN),
body, creatorId(user));
}
/** 创建人会话自取(C16:创建人自动持有该表单 ALL 档,G1"创建人保留定义权"的落列来源) */
private static Long creatorId(BoaUser user) {
Integer uid = FormAccessPolicy.uid(user);
return uid == null ? null : Long.valueOf(uid);
}
/** 保存:草稿直改;已发布表单=开新版本行,头表指针不动(V7) */
@@ -132,15 +140,16 @@ public class FormAdminController {
defService.softDelete(adminGuard.requireAdmin(user, schId, FormAdminGuard.MODULE_FORM_DESIGN), uuid);
}
/** 另存为副本(V41):新 code/新 uuid/version 从 1 起/schema 深拷贝、字典引用随行 */
/** 另存为副本(V41):新 code/新 uuid/version 从 1 起/schema 深拷贝、字典引用随行;创建人=点副本的人 */
@PostMapping("/copy")
public FormModels.FormView copy(BoaUser user,
@RequestParam(required = false) Long schId,
@RequestBody FormModels.FormCopyRequest body) {
return defService.copy(adminGuard.requireAdmin(user, schId, FormAdminGuard.MODULE_FORM_DESIGN), body);
return defService.copy(adminGuard.requireAdmin(user, schId, FormAdminGuard.MODULE_FORM_DESIGN),
body, creatorId(user));
}
/** 表单级配置:面向人群/限一次/截止时间(只给日期补 23:59:59)/报名号开关/分组标签 */
/** 表单级配置:面向人群(数组,C1)/限一次/截止时间(只给日期补 23:59:59)/报名号开关/分组标签/冻结开关/匿名防重窗口 */
@PostMapping("/config")
public FormModels.FormView config(BoaUser user,
@RequestParam(required = false) Long schId,
@@ -99,13 +99,13 @@ public class FormMyAnswerController {
}
/**
* 「可填写」列表(P95 W3,V50):<b>填报入口</b>清单,不是答卷清单——
* 域=会话校 ∪ 平台级且本校遮蔽平台、仅已发布未软删,逐行按 P95 三层模型过入口层,NONE 不入集。
* 「可填写」列表({@code V50},行形状按契约 v2 <b>C2</b>):<b>填报入口</b>清单,不是答卷清单——
* 域=会话校 ∪ 平台级且本校遮蔽平台、仅已发布未软删,逐行过 <b>audience 门</b>(身份类型 ∈ 面向人群),
* 不中的不入集。
*
* <p>这一把查询把 P91 那个"从会话菜单树挑 {@code /f/} 行"的前端推导整条换掉:
* 谁能填什么从此由中心按 {@code audience × 身份} 自解析,UserBase 侧不再需要逐表单配模块行
* (任务书 P95 §0)。<b>它不看模块行、也不看数据范围矩阵</b>:入口层与那两条线正交
* ({@code FillAccessService} 类注释记了三条线的分工)。</p>
* 谁能填什么由中心按 {@code audience × 会话身份} 自解析,UserBase 侧不需要逐表单配模块行。
* <b>它不看数据范围</b>({@code DataScopeService}):列表里出现某张表,不代表进去能看到别人的行。</p>
*
* @param appCode 可选,缺省=全部应用;快应用上下文({@code /app/{appCode}/})传它自身以收窄清单
*/
@@ -18,8 +18,10 @@ import javax.persistence.UniqueConstraint;
* (P25;{@code (sch_id, app_code, code, version)} 四列继续冗余留存,供按 code 的入口视角与
* 索引表同源定位用,方案 §5.2,svc-hall {@code ServiceApply} 先例);升版窗口旧版仍可填(P20),
* 在途提交天然按旧版落地,编辑/详情/统计/导出也都按这一坐标取 schema。</li>
* <li><b>身份三键</b>(方案 §6.3):{@code submitter_id}=谁提交、{@code subject_id}=关于谁(可空)、
* {@code sch_id}=会话校,<b>全部会话推导不可外部传入</b>(契约 §5、V17)。</li>
* <li><b>身份三键</b>(方案 §6.3):{@code submitter_id}=谁提交(匿名=哨兵 0)、
* {@code subject_id}=关于谁(匿名恒 NULL)、{@code sch_id}=租户域(登录答卷恒=会话校;
* <b>匿名答卷=表单归属校</b>,契约 v2 §3.2/C8 取代原"恒=会话校"单句),
* <b>全部会话或表单推导,不可外部传入</b>(契约 §5、V17/V57)。</li>
* <li><b>幂等键是库级唯一</b>:防双击/重放只有 DB 唯一索引靠得住(方案 §5.1),
* 与"限一次"({@code fill_quota})是两个机制——幂等防重复提交,限次防多次提交。</li>
* </ul>
@@ -50,7 +52,10 @@ import javax.persistence.UniqueConstraint;
// ——当页只取 id 时该索引即覆盖索引(index-only,不回表、不碰 values_json)。
// 契约 §10 草案的 idx_answer_form 以 status 结尾,只对"按状态筛/分组"是覆盖的,
// 不含 app_code 且无法在跨状态时给出 id 序 → 新增本索引,登记问题单。
@Index(name = "idx_answer_form_time", columnList = "sch_id, app_code, code, is_deleted, id")
@Index(name = "idx_answer_form_time", columnList = "sch_id, app_code, code, is_deleted, id"),
// 契约 v2 §6.1:非冻结表单的 updated_since 增量拉取按 (form_uuid, update_time, id) 推进,
// 现状 answer 表无任何 update_time 索引(t3+t10 全查过)——本索引是 T17 拉取端点的前置
@Index(name = "idx_answer_form_utime", columnList = "form_uuid, update_time, id")
})
public class Answer extends BaseEntity {
@@ -120,6 +125,17 @@ public class Answer extends BaseEntity {
@Column(name = "idempotency_key", nullable = false, length = 64, updatable = false)
private String idempotencyKey;
/**
* 匿名提交来源 IP(契约 v2 C8/C12):只由匿名面(T15)写入,登录答卷恒 NULL;
* 不进 {@code values_json}(留痕是采集事实之外的东西,混进值文档就没法比对了)。
*/
@Column(name = "anon_ip", length = 64)
private String anonIp;
/** 匿名提交 User-Agent(同上,列宽 512 够装常规 UA 串;超长由服务层截断) */
@Column(name = "anon_ua", length = 512)
private String anonUa;
/** 软删=唯一删除形态(无物理删除接口,P22/V30);同步删 answer_index 行、释放 fill_quota 名额 */
@Column(name = "is_deleted", nullable = false, columnDefinition = "tinyint")
private Integer isDeleted = 0;
@@ -65,6 +65,13 @@ public class Form extends BaseEntity {
@Column(name = "name", nullable = false, length = 128)
private String name;
/**
* 创建人 user_id(C16:{@code create_by} 存的是账号名字符串,与管理员两表的 user_id 标识体系不同,
* 建列归一)。新建时从会话落列;存量行=NULL,创建人判定按 {@code create_by} 账号名兜底。
*/
@Column(name = "creator_id", updatable = false)
private Long creatorId;
/** 当前发布版本指针,null=从未发布(resolve 只认它,方案 §5.1) */
@Column(name = "current_version")
private Integer currentVersion;
@@ -77,9 +84,13 @@ public class Form extends BaseEntity {
@Column(name = "status_defs", columnDefinition = "text")
private String statusDefs;
/** 面向人群:1 学生 / 2 教师(数据范围默认矩阵的输入,契约 §10) */
@Column(name = "audience", nullable = false, columnDefinition = "tinyint")
private Integer audience = FormEnums.Audience.STUDENT.getCode();
/**
* 面向人群(契约 v2 C1 多选):逗号分隔枚举码串 {@code STUDENT,TEACHER,ANONYMOUS},
* 列型 VARCHAR(64)(原 TINYINT 1/2 由 {@code sql/t12_access_simplification.sql} 迁移)。
* 读写一律过 {@link FormEnums.Audience} 的 parse/join,不在别处拼字符串。
*/
@Column(name = "audience", nullable = false, length = 64)
private String audience = FormEnums.Audience.STUDENT.name();
/** 限填次数:0 不限 / 1 限一次(去重维度按 subject/submitter 推导,方案 §5.4) */
@Column(name = "fill_limit", nullable = false, columnDefinition = "tinyint")
@@ -93,6 +104,17 @@ public class Form extends BaseEntity {
@Column(name = "answer_no_enabled", nullable = false, columnDefinition = "tinyint")
private Integer answerNoEnabled = 0;
/**
* 提交后禁编辑(契约 v2 §4.2/D11):1=开启,提交人编辑与管理员编辑<b>双写通道同关</b>(V61),
* 修正路径只剩"软删(撤回)+ 重新提交"。默认关——喂外部业务应用的表单由设计者显式开启。
*/
@Column(name = "frozen", nullable = false, columnDefinition = "tinyint")
private Integer frozen = 0;
/** 匿名 cookie 防重窗口分钟数(C7,默认 10);仅 audience 含 ANONYMOUS 的表单消费 */
@Column(name = "anon_cookie_minutes", nullable = false)
private Integer anonCookieMinutes = 10;
/** 多表单快应用的菜单分组标签(方案 §5.1,可选) */
@Column(name = "group_tag", length = 64)
private String groupTag;
@@ -1,5 +1,11 @@
package com.yunhai.formcenter.enums;
import java.util.ArrayList;
import java.util.Collection;
import java.util.EnumSet;
import java.util.List;
import java.util.Set;
/**
* 定义域枚举(契约 §1/§10 + 方案 §5.1/§5.4,一期冻结)。
* 列值直接用契约 §10 DDL 草案里的数字口径,避免库内值与文档两套。
@@ -35,42 +41,112 @@ public final class FormEnums {
}
}
/** 面向人群:1 面向学生 / 2 面向教师(数据范围默认矩阵的输入,方案 §6.4;只决定默认,授权仍看模块行) */
/**
* 面向人群(契约 v2 C1:多选,列存逗号分隔枚举码 {@code VARCHAR(64)},JSON 面一律字符串数组)。
*
* <p>闭集三值:学生 / 教师 / 匿名可共集于一张表单(D8:禁止布尔补丁,不新增 {@code is_anonymous} 列)。
* {@code ANONYMOUS} 只对匿名通道生效,登录会话不可能持有它({@code FormAccessPolicy#identityTypes})。</p>
*
* <p>存量迁移由 {@code sql/t12_access_simplification.sql} 完成({@code 1→STUDENT}、{@code 2→TEACHER})。</p>
*/
public enum Audience {
STUDENT(1), TEACHER(2);
STUDENT, TEACHER, ANONYMOUS;
private final int code;
Audience(int code) {
this.code = code;
}
public int getCode() {
return code;
}
public static boolean valid(Integer code) {
if (code != null) {
for (Audience a : values()) {
if (a.code == code) {
return true;
}
}
/** 枚举名解析(未知=null,调用侧按拒绝处理,不静默当成某个身份) */
public static Audience of(String code) {
String clean = code == null ? null : code.trim();
if (clean == null || clean.isEmpty()) {
return null;
}
return false;
}
/** 对外下发用枚举名(契约 §4 的 {@code "audience":"STUDENT"}),不外露数字 */
public static String nameOf(Integer code) {
if (code != null) {
for (Audience a : values()) {
if (a.code == code) {
return a.name();
}
for (Audience a : values()) {
if (a.name().equalsIgnoreCase(clean)) {
return a;
}
}
return null;
}
public static boolean validName(String code) {
return of(code) != null;
}
/**
* 列值码串 → 身份集合(按枚举定义序去重)。
*
* <p>空/NULL=按默认 {@code STUDENT} 解释(C1 注记"存量脏值不放宽":宁少给一个人群,
* 也不因脏列值把表单暴露给更多人)。非法码直接拒——不静默忽略,那会把"配错了人群"
* 伪装成"这些人本来就不能填"。</p>
*/
public static Set<Audience> parse(String columnValue) {
String clean = columnValue == null ? null : columnValue.trim();
Set<Audience> result = EnumSet.noneOf(Audience.class);
if (clean == null || clean.isEmpty()) {
result.add(STUDENT);
return result;
}
for (String token : clean.split(",")) {
String name = token.trim();
if (name.isEmpty()) {
continue;
}
Audience audience = of(name);
if (audience == null) {
throw new IllegalArgumentException("audience 只能是 STUDENT/TEACHER/ANONYMOUS:" + name);
}
result.add(audience);
}
if (result.isEmpty()) {
result.add(STUDENT);
}
return result;
}
/** 身份集合 → 列值码串(按枚举定义序,稳定可比对) */
public static String join(Collection<Audience> audiences) {
StringBuilder sb = new StringBuilder();
for (Audience a : values()) {
if (audiences != null && audiences.contains(a)) {
if (sb.length() > 0) {
sb.append(',');
}
sb.append(a.name());
}
}
return sb.length() == 0 ? STUDENT.name() : sb.toString();
}
/** 列值码串 → 枚举名数组(对外出参一律数组,C1/C5;顺序=枚举定义序) */
public static List<String> namesOf(String columnValue) {
List<String> names = new ArrayList<>();
for (Audience a : parse(columnValue)) {
names.add(a.name());
}
return names;
}
/** 列值码串 → 中文文案(audience 门拒绝文案要能诊断出配了哪些人群,§3.1) */
public static String labelOf(String columnValue) {
StringBuilder sb = new StringBuilder();
for (Audience a : parse(columnValue)) {
if (sb.length() > 0) {
sb.append('/');
}
sb.append(a.label());
}
return sb.toString();
}
/** 展示名(只用于文案,不参与存储与线格式) */
public String label() {
switch (this) {
case TEACHER:
return "教师";
case ANONYMOUS:
return "匿名";
default:
return "学生";
}
}
}
/** 限填次数:0 不限 / 1 限一次(去重键见 {@link LimitScope},与幂等键是两个机制,方案 §5.4) */
@@ -101,22 +177,21 @@ public final class FormEnums {
}
/**
* 数据范围(契约 §8 / 方案 §6.4):一期契约的四值闭集 + T10 的 {@link #SCHOOL}——
* "定了就不变"指既不删改既有值、也不改既有语义,扩展走"加枚举=加实现器"这条既定通道
* (管理员体系方案 §5.1)。
* 数据范围<b>两档</b>(契约 v2 §2.3 / D4):
* <ul>
* <li>{@link #ALL}=该表单全部行;持有者=创建人 ∪ form_admin ∪ 校管理员(∪ 超管)。
* 校管理员×平台级表单({@code sch_id=0})是 <b>ALL 档内的租户限定变体</b>(D9:只给本校提交行,
* 谓词 {@code submitter_sch_id=会话校}),不是第三个范围值;</li>
* <li>{@link #SELF}=我提交的 ∪ 关于我的({@code subject_id}=会话用户)——登录用户天然持有,
* 因此不存在"全不命中回落"分支。</li>
* </ul>
*
* <p>{@link #TEACHING_CLASS} 是 1.5 开放项(依赖 UserClass 任教关系/班级 SDK):
* <b>枚举里有、一期不可选</b>(V28),其实现器只是占位。</p>
*
* <p>{@link #SCHOOL} 与任教班级同一类"枚举里有但不入 {@code selectableScopes()}"(V28 口径):
* 它不是筛选器选项,而是管理员两表授权后的<b>解析结果</b>(谓词 {@code submitter_sch_id}=会话学校)。</p>
* <p>原四值闭集(全部/任教班级/仅本人提交/关于我)+ {@code SCHOOL} + 身份默认矩阵随
* 《契约v2》§19 作废-1 退役;任教班级/按组圈人属业务语义推断,需要时任课数据源落实后单独开批次。</p>
*/
public enum DataScope {
ALL("全部"),
TEACHING_CLASS("任教班级"),
SELF_SUBMITTED("仅本人提交"),
ABOUT_ME("关于我"),
SCHOOL("本校提交");
SELF("我相关");
private final String label;
@@ -127,15 +202,5 @@ public final class FormEnums {
public String getLabel() {
return label;
}
/** 枚举名解析(对外只暴露名字,数字序号不外露——加值时不破坏兼容) */
public static DataScope of(String name) {
for (DataScope scope : values()) {
if (scope.name().equalsIgnoreCase(name == null ? null : name.trim())) {
return scope;
}
}
return null;
}
}
}
@@ -34,11 +34,16 @@ public final class FormModels {
private Integer currentVersion;
/** 待发布的草稿版本号,null=无草稿 */
private Integer draftVersion;
private Integer audience;
/** 面向人群(C1 多选,一律字符串数组:{@code ["STUDENT","TEACHER","ANONYMOUS"]}) */
private List<String> audience;
private Integer fillLimit;
/** yyyy-MM-dd HH:mm:ss,null=不限 */
private String deadline;
private Integer answerNoEnabled;
/** 提交后禁编辑(契约 v2 §4.2):1=双写编辑通道同关 */
private Integer frozen;
/** 匿名 cookie 防重窗口分钟数(C7) */
private Integer anonCookieMinutes;
private String groupTag;
private List<StatusDefView> statusDefs;
private String createTime;
@@ -73,7 +78,8 @@ public final class FormModels {
private String appCode;
private String name;
private Integer version;
private Integer audience;
/** 面向人群(C1 数组形态;填报页据此判"我这个身份能不能填",判定权威仍在后端 audience 门) */
private List<String> audience;
private Integer fillLimit;
private Integer answerNoEnabled;
private String deadline;
@@ -145,11 +151,16 @@ public final class FormModels {
@Setter
public static class FormConfigRequest {
private String uuid;
private Integer audience;
/** 面向人群(C1 入参数组形态;null=不改这一项) */
private List<String> audience;
private Integer fillLimit;
/** yyyy-MM-dd(补 23:59:59)或 yyyy-MM-dd HH:mm:ss;空串/null=不限 */
private String deadline;
private Integer answerNoEnabled;
/** 提交后禁编辑(契约 v2 §4.2):0/1,null=不改 */
private Integer frozen;
/** 匿名 cookie 防重窗口分钟数(C7):>0,null=不改 */
private Integer anonCookieMinutes;
private String groupTag;
}
@@ -3,6 +3,8 @@ package com.yunhai.formcenter.model;
import lombok.Getter;
import lombok.Setter;
import java.util.List;
/**
* "我的填写"两视图({@code /my/submit} 与 {@code /my/about})的列表出参(方案 §6.3、任务书 T8)。
*
@@ -47,17 +49,15 @@ public final class MyModels {
}
/**
* 一行"我可填的表单"(P95 W3,{@code GET /api/form/my/fillable}):填报<b>入口</b>清单的行形状。
* 一行"我可填的表单"({@code GET /api/form/my/fillable},契约 v2 C2 行形状)。
*
* <p><b>它不是答卷行</b>:这一把查询还没写任何数据,只回答"这个人能进哪张表的填报页、
* 进完之后按 P95 三层模型他算哪一层"。所以字段是<b>表单</b>身份 + 两个入口层结论,
* 与 {@link ItemView} 没有共同祖先。</p>
* <p><b>它不是答卷行</b>:这一把查询还没写任何数据,只回答"这个人能进哪张表的填报页"。
* 字段=表单身份 + 配置项,<b>不含任何入口层结论</b>:原 P95 的 {@code canFill}/{@code myTier}
* 随三层模型出清(§19 作废-3)——入集本身就等价于"能填"(登录身份类型 ∈ audience),
* 不再有"进了列表但不能填"的中间态。</p>
*
* <p>{@code canFill} 与 {@code myTier} 是<b>两件事</b>,前端两处消费:
* {@code canFill=false} 且 {@code myTier=MANAGE} 的行(教师×面向学生)在「可填写」列表里不出现,
* 但要留在"数据管理"tab 的候选里——这正是 P95 把入口层结论下发而不是只下发一个布尔的原因。
* 数字口径与定义域一致:{@code audience}/{@code fillLimit} 下发列值(1/2、0/1),
* {@code myTier} 下发枚举名(ADMIN/MANAGE/FILL,NONE 的行根本不入集)。</p>
* <p>{@code audience} 下数组(C1);{@code ANONYMOUS} 不参与登录会话的入集判定
* (匿名填报人不看列表,持 URL 直达)。</p>
*/
@Getter
@Setter
@@ -65,16 +65,12 @@ public final class MyModels {
private String code;
private String name;
private String appCode;
/** 面向人群:1 学生 / 2 教师(契约 §10 列值) */
private Integer audience;
/** 面向人群(C1 数组形态) */
private List<String> audience;
/** yyyy-MM-dd HH:mm:ss;null=不限截止 */
private String deadline;
/** 限填次数:0 不限 / 1 限一次 */
private Integer fillLimit;
private String groupTag;
/** 能否以本人身份填报(层①∪层③) */
private Boolean canFill;
/** 并集里的最高层:ADMIN &gt; MANAGE &gt; FILL(NONE 不入集,故不会为 null) */
private String myTier;
}
}
@@ -4,6 +4,8 @@ import com.fasterxml.jackson.databind.JsonNode;
import lombok.Getter;
import lombok.Setter;
import java.util.List;
/**
* 一期对外读接口(oapi)的响应模型(契约 §9 两端点、§4 下发形状)。
*
@@ -27,8 +29,12 @@ public final class OapiModels {
private Integer version;
private String code;
private String appCode;
/** STUDENT / TEACHER(枚举名下发,不外露数字,契约 §4 示例) */
private String audience;
/**
* 面向人群(契约 v2 C5/G3:<b>breaking</b>——由单值串改字符串数组,与 T13 改库同批切换,
* 不加适配层;闭集 {@code STUDENT}/{@code TEACHER}/{@code ANONYMOUS},可共集于一张表单)。
* 消费方通告:flow-center 只在流程定义<b>发布期</b>解析本字段,部署前发通告。
*/
private List<String> audience;
/** {@code [{key,label,fields:[{… , options:[{label,value}], searchable, valueSchema:{…}}]}]} */
private JsonNode groups;
}
@@ -178,8 +178,16 @@ public final class ReadModels {
private String code;
private Set<DataScope> scopes;
private Integer userId;
/** 会话学校(T10 方案 §5.1:{@code SCHOOL} 范围的谓词基准,其余范围不消费) */
/** 会话学校(契约 v2 §2.3:ALL 档租户限定变体的谓词基准,SELF 档不消费) */
private Long viewerSchId;
/**
* ALL 档是否为<b>租户限定变体</b>(契约 v2 D9:校管理员 × 平台级表单 {@code sch_id=0} 只看
* 本校提交行,谓词 {@code submitter_sch_id=会话校})。
*
* <p>它是 ALL 档内部的收窄,不是第三个范围值——所以放在规格位上而不是枚举里;
* 超管/创建人/form_admin 命中时恒 false(全量)。</p>
*/
private boolean platformScopedAll;
private String status;
private List<FormFilterOperators.Spec> filters;
/** 游标:库内 id(对外是 uuid,服务端换);null=首屏 */
@@ -1,35 +0,0 @@
package com.yunhai.formcenter.service;
import com.yunhai.formcenter.entity.Answer;
import com.yunhai.formcenter.enums.FormEnums.DataScope;
import org.springframework.stereotype.Component;
import javax.persistence.criteria.CriteriaBuilder;
import javax.persistence.criteria.Predicate;
import javax.persistence.criteria.Root;
/**
* 范围实现器:<b>关于我</b>({@code subject_id}=会话用户)。
*
* <p>与"仅本人提交"是两个视角(方案 §6.3 拆分 submitter/subject 的动因):学生看到的是
* <em>关于自己</em>的数据,<b>包含老师代填的那一条</b>——老师代填时 {@code submitter_id} 是老师、
* {@code subject_id} 是学生,按本范围才可见(V18)。</p>
*/
@Component
public class AboutMeScopeImplementor implements AnswerScopeImplementor {
@Override
public DataScope scope() {
return DataScope.ABOUT_ME;
}
@Override
public boolean selectable() {
return true;
}
@Override
public Predicate rowPredicate(CriteriaBuilder cb, Root<Answer> root, Integer userId, Long viewerSchId) {
return cb.equal(root.get("subjectId"), userId);
}
}
@@ -2,13 +2,29 @@ package com.yunhai.formcenter.service;
import com.yunhai.formcenter.entity.Answer;
import com.yunhai.formcenter.enums.FormEnums.DataScope;
import com.yunhai.formcenter.model.ReadModels;
import com.yunhai.platform.apistd.BizException;
import com.yunhai.platform.utils.exception.ErrorCode;
import org.springframework.stereotype.Component;
import javax.persistence.criteria.CriteriaBuilder;
import javax.persistence.criteria.Predicate;
import javax.persistence.criteria.Root;
/** 范围实现器:<b>全部</b>——不加行级条件(契约 §8 管理员/校领导默认,或管理员两表授权升级)。 */
/**
* 范围实现器:<b>ALL</b>——该表单全部行(契约 v2 §2.3)。
*
* <p>持有者=创建人 ∪ form_admin ∪ 校管理员(∪ 超管)。两种落点:</p>
* <ul>
* <li><b>全量 ALL</b>:不加行级条件(超管、平台表单的创建人、本校表单的授权者);</li>
* <li><b>租户限定变体</b>(D9):校管理员/form_admin 面对<b>平台级表单</b>({@code sch_id=0})时,
* 只给本校提交行——谓词 {@code submitter_sch_id=会话校}。平台表单是跨校资产,
* 授权不跨过租户边界(T10 D2=B 加列的动因,本批原样承继);给不出会话校一律拒绝,
* 不静默降成"无学校条件"(那等于把本校范围读成全平台)。</li>
* </ul>
*
* <p>{@code submitter_sch_id} 为 NULL 的存量行天然不匹配等值谓词=fail-closed(P84 口径不回退)。</p>
*/
@Component
public class AllScopeImplementor implements AnswerScopeImplementor {
@@ -18,12 +34,13 @@ public class AllScopeImplementor implements AnswerScopeImplementor {
}
@Override
public boolean selectable() {
return true;
}
@Override
public Predicate rowPredicate(CriteriaBuilder cb, Root<Answer> root, Integer userId, Long viewerSchId) {
return cb.conjunction();
public Predicate rowPredicate(CriteriaBuilder cb, Root<Answer> root, ReadModels.Query q) {
if (!q.isPlatformScopedAll()) {
return cb.conjunction();
}
if (q.getViewerSchId() == null) {
throw new BizException(ErrorCode.FORBIDDEN, "ALL 档的租户限定变体需要会话学校身份");
}
return cb.equal(root.get("submitterSchId"), q.getViewerSchId());
}
}
@@ -120,6 +120,12 @@ public class AnswerAdminService {
Answer answer = requireManageableAnswer(operator, schId, req.getAnswerId());
FormDefService.Published pinned = defService.pinnedForData(answer.getFormUuid(), answer.getVersion());
String schemaJson = pinned.versionRow().getSchemaJson();
// 冻结开关(契约 v2 §4.2/V61):与本人编辑侧同一条判据、同一份文案族——
// "原件是档案"这件事不能只在提交人那侧生效,否则管理员改值就绕开了冻结语义
if (Integer.valueOf(1).equals(pinned.form().getFrozen())) {
throw new BizException(ErrorCode.CUSTOM,
"该表单已开启提交后禁编辑,修正请撤回后重新提交:" + pinned.form().getCode());
}
// P33:编辑一条"代填来的"答卷时,被代填人必须仍然真实存在且属本校——
// 提交时验过一次不代表现在还在(用户可能被删/转校)。身份列本接口不改,但脏引用不能靠编辑继续留档。
@@ -72,19 +72,21 @@ public class AnswerReadService {
/**
* 会话身份 + 表单 + 请求 → 读路径查询规格。
*
* <p>范围在这里定:{@link DataScopeService} 按契约 §8 矩阵 + 管理员两表解析,解析不出来
* (未登录等)即拒,不存在"默认全部"的分支。筛选条件按<b>当前发布版本</b>的
* {@code filterableFields} 收敛(V3 未声明 searchable 不进候选、P28 附件无算子),
* 传一个不在候选里的 key 直接拒——不能默默忽略,那会把"筛选没生效"伪装成"没有匹配数据"。</p>
* <p>范围在这里定:{@link DataScopeService} 按契约 v2 §2.3 两档(ALL/SELF)解析,未登录即拒,
* 不存在"默认全部"的分支。筛选条件按<b>当前发布版本</b>的 {@code filterableFields} 收敛
* (V3 未声明 searchable 不进候选、P28 附件无算子),传一个不在候选里的 key 直接拒——
* 不能默默忽略,那会把"筛选没生效"伪装成"没有匹配数据"。</p>
*/
public ReadModels.Query scopeQuery(BoaUser user, Long domainSchId, Form form, ReadModels.QueryRequest req) {
ReadModels.Query query = new ReadModels.Query();
query.setSchId(domainSchId);
query.setAppCode(form.getAppCode());
query.setCode(form.getCode());
query.setScopes(scopeService.resolve(user, form));
DataScopeService.Resolved resolved = scopeService.resolve(user, form);
query.setScopes(resolved.scopes());
// 校管理员×平台级表单的 ALL 收窄成"本校提交"(D9);谓词基准=会话学校,不是查询域
query.setPlatformScopedAll(resolved.platformScopedAll());
query.setUserId(user == null ? null : user.getId());
// T10 方案 §5.1:SCHOOL 范围的谓词基准=会话学校(不是查询域,两者同源于会话校但语义不同)
query.setViewerSchId(FormAccessPolicy.sessionSchId(user));
query.setStatus(req == null ? null : trimToNull(req.getStatus()));
query.setFilters(resolveFilters(form, req == null ? null : req.getFilters()));
@@ -336,6 +338,12 @@ public class AnswerReadService {
}
FormDefService.Published pinned = defService.pinnedForData(answer.getFormUuid(), answer.getVersion());
Form form = pinned.form();
// 冻结开关(契约 v2 §4.2/V61):表单开启"提交后禁编辑"时,本人编辑与管理员编辑两条写通道同关,
// 唯一修正路径=软删(撤回)+ 重新提交——文案直接指路,不让用户对着 403 猜下一步做什么
if (Integer.valueOf(1).equals(form.getFrozen())) {
throw new BizException(ErrorCode.CUSTOM,
"该表单已开启提交后禁编辑,修正请撤回后重新提交:" + form.getCode());
}
// Bug#2:编辑与提交同口径——过截止/表单停用(或已软删)就不再接收修改。
// 否则"先交个占位、截止后随便改"能绕过 deadline,截止语义形同虚设(停用同理:填报入口已失效)。
if (FormRuntimeRules.isDeadlineReached(form, LocalDateTime.now())) {
@@ -1,6 +1,7 @@
package com.yunhai.formcenter.service;
import com.yunhai.formcenter.enums.FormEnums.DataScope;
import com.yunhai.formcenter.model.ReadModels;
import javax.persistence.criteria.CriteriaBuilder;
import javax.persistence.criteria.Predicate;
@@ -9,32 +10,26 @@ import javax.persistence.criteria.Root;
import com.yunhai.formcenter.entity.Answer;
/**
* 数据范围<b>实现器</b>(方案 §6.4"枚举 + 实现器(策略接口)"):一个枚举值一个实现器,
* 后续加枚举=加实现器;实现器内部要去调 SDK(如 UserClass 的任教关系)也只在自己这一格里长,
* 接口隔离保证读路径不跟着改。
* 数据范围<b>实现器</b>(契约 v2 §2.3:两档=两个实现器):一个枚举值一个实现器,
* 谓词只回答"这条答卷在不在这一档里"。
*
* <p><b>唯一出口约束</b>:实现器只产出"这条答卷在不在该范围内"的 SQL 谓词,
* 由 {@link AnswerScopeQueryBuilder} 在四条读出路径(列表/详情/统计/导出)与管理动作行级把关上
* 复用同一次构造(方案 §6.4 红线,V27/V37/V42)。实现器里不允许出现"顺手把 values 取出来判"这类分支。</p>
* <p><b>唯一出口约束</b>(V55):实现器只产出 SQL 谓词,由 {@link AnswerScopeQueryBuilder}
* 在四条读出路径(列表/详情/统计/导出)与管理动作行级把关上复用同一次构造。
* 绕开构造器另写查询=越权后门;实现器里也不允许出现"顺手把 values 取出来判"这类分支。</p>
*
* <p>原"加枚举=加实现器"的开放项(任教班级)随 §19 作废-1 出清:业务语义范围需要时任课数据源
* 落实后单独开批次,不在两档模型里留占位。</p>
*/
public interface AnswerScopeImplementor {
/** 本实现器负责的范围值 */
/** 本实现器负责的档 */
DataScope scope();
/**
* 一期是否可选。契约 §8 的四值闭集里 {@code 任教班级} 属 1.5 开放项(依赖 UserClass 任教关系/班级 SDK)
* ——枚举里有、<b>一期不可选</b>(V28),故其实现器返回 false 且不参与解析结果。
*/
boolean selectable();
/**
* 产出该范围对单条答卷的行级谓词。
* 产出该档对单条答卷的行级谓词。
*
* @param userId 会话用户 id({@code submitter_id}/{@code subject_id} 的比对基准)
* @param viewerSchId 会话学校({@code submitter_sch_id} 的比对基准,T10 方案 §5.1;
* 只有 {@code SCHOOL} 消费它,其余范围按各自基准忽略即可)
* @return 恒真({@code cb.conjunction()})=该范围不额外收窄行
* @param q 查询规格(会话用户 id、会话学校、ALL 档是否租户限定变体都在里面)
* @return 恒真({@code cb.conjunction()})=该档不额外收窄行
*/
Predicate rowPredicate(CriteriaBuilder cb, Root<Answer> root, Integer userId, Long viewerSchId);
Predicate rowPredicate(CriteriaBuilder cb, Root<Answer> root, ReadModels.Query q);
}
@@ -181,6 +181,7 @@ public class AnswerScopeQueryBuilder {
copy.setScopes(q.getScopes());
copy.setUserId(q.getUserId());
copy.setViewerSchId(q.getViewerSchId());
copy.setPlatformScopedAll(q.isPlatformScopedAll());
copy.setStatus(q.getStatus());
copy.setFilters(q.getFilters());
copy.setCursorId(q.getCursorId());
@@ -220,14 +221,15 @@ public class AnswerScopeQueryBuilder {
return ps;
}
/** 范围段:多来源的并集(OR);命中"全部"即不加行级条件 */
/** 范围段:两档的并集(OR);ALL 档自身可能是租户限定变体,收窄谓词在实现器里(V55 唯一出口) */
private Predicate scopePredicate(CriteriaBuilder cb, Root<Answer> root, ReadModels.Query q) {
Set<DataScope> scopes = q.getScopes();
if (scopes == null || scopes.isEmpty()) {
// 空范围不放行——"没解析出范围"与"范围是全部"是两回事,缺省必须是仅本人提交
// 空范围不放行——"没解析出范围"与"范围是全部"是两回事,缺省必须拒绝
throw new BizException(ErrorCode.FORBIDDEN, "未解析出数据范围,拒绝按无范围读取");
}
if (scopes.contains(DataScope.ALL)) {
if (scopes.contains(DataScope.ALL) && !q.isPlatformScopedAll()) {
// 全量 ALL 已经覆盖 SELF:留着 SELF 那半只会让读路径多写几个 OR
return cb.conjunction();
}
if (q.getUserId() == null) {
@@ -235,8 +237,7 @@ public class AnswerScopeQueryBuilder {
}
List<Predicate> ors = new ArrayList<>();
for (DataScope scope : scopes) {
ors.add(scopeService.implementorOf(scope)
.rowPredicate(cb, root, q.getUserId(), q.getViewerSchId()));
ors.add(scopeService.implementorOf(scope).rowPredicate(cb, root, q));
}
return cb.or(ors.toArray(new Predicate[0]));
}
@@ -25,23 +25,26 @@ import org.springframework.transaction.support.TransactionTemplate;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.Collections;
import java.util.Set;
import java.util.UUID;
/**
* 提交链路(契约 §5 / 方案 §5.4)。一次提交在这条流水线上依次过关:
* 提交链路(契约 v2 §3.1,采集管线唯一=D16/§3.4:手工填报、代录、批量导入三者同走这条流水线)。
* 一次提交依次过关:
*
* <pre>
* 钉版解析 → <b>第 0 关·填报入口层(P95)</b> → 截止 → 幂等命中 → subject 授权+存在性同校(P33)
* 钉版解析 → <b>audience 门</b> → 截止 → 幂等命中 → subject 授权(=ALL 档)+存在性同校(P33)
* → 值校验(按钉住版本) → 附件归属与 key 一致(P34) → 限次占位(fill_quota 撞键即拒)
* → 落 answer(写 form_uuid) → 写 answer_index(只写 searchable)
* </pre>
*
* <p><b>为什么顺序是这样</b>:</p>
* <ul>
* <li><b>先鉴权后业务</b>(P95):入口层放在钉版之后(要 {@code form.audience} 才能判人群)、
* 截止之前(过期表单对无资格的人不该输出"已过期"这种业务信息)。
* 幂等重落在闸门<b>之后</b>,所以被闸门拒的人重放同样是 403——这是任务书 W2 明示可接受的取舍;
* <li><b>先鉴权后业务</b>:audience 门放在钉版之后(要 {@code form.audience} 才能判人群)、
* 截止之前(过期表单对无资格的人不该输出"已过期"这种业务信息)。它取代 P95 的第 0 关
* 三层入口层(《契约v2》§19 作废-2)——资格从此只有一个问题:"这个人的身份类型在不在面向人群里";
* 幂等重落在闸门<b>之后</b>,所以被闸门拒的人重放同样是 403(沿 W2 明示可接受的取舍);
* 外层 {@code DataIntegrityViolationException} 的并发重放路径不再重复过闸(命中
* {@code belongsToCaller} 即返回原答卷);</li>
* <li>幂等检查必须在限次<b>之前</b>——重放的那一条本来就占着名额,先查限次会把双击重试误判成"超过次数";</li>
@@ -52,11 +55,10 @@ import java.util.UUID;
* "软删条目仍放行"的口径也就无法表达(契约 §2.1)。</li>
* </ul>
*
* <p><b>第 0 关与后面两关的分工</b>:{@link FillAccessService} 判"这个身份在这张表单上算哪一层"
* (入口层,P95 起取代 {@code /f/{code}} 模块行授权);{@link #requireSubject} 判"subject 能不能指向
* 别人"(ADMIN 沿 {@link FormAdminGuard#canManage} 现路径、MANAGE 走 P33 存在性);
* {@link DataScopeService} 判"写进去之后谁能看见这行"(读矩阵,与本类无关)。
* 三条线各自独立,改一条不动另一条。</p>
* <p><b>三条线的分工(v2 后只剩两条半)</b>:audience 门(本类)判"能不能填这张表";
* {@link #requireSubject} 判"subject 能不能指向别人"=该表单 ALL 档({@link DataScopeService});
* 同一次解析的结果同时决定"写进去之后谁能看见这行"。<b>代录与导入也走这里</b>(§3.4/V60),
* 所以"代录的权限"与"手工填的权限"不可能有两套口径。</p>
*
* <p><b>事务形态</b>:公开方法不带 {@code @Transactional},写库段用 {@link TransactionTemplate} 包。
* 目的是让"幂等键撞 uk"这种并发结局能在<b>事务外</b>重新读取原答卷并按契约返回 200+原 answer_id
@@ -75,8 +77,7 @@ public class AnswerService {
private final FormDefService defService;
private final FormDictService dictService;
private final FormAdminGuard adminGuard;
private final FillAccessService fillAccess;
private final DataScopeService dataScope;
private final SubjectDirectory subjectDirectory;
private final AnswerRepository answerRepository;
private final FillQuotaRepository quotaRepository;
@@ -147,9 +148,9 @@ public class AnswerService {
Form form = published.form();
String schemaJson = published.versionRow().getSchemaJson();
// 2) 第 0 关(P95 三层模型):填报入口层——钉版之后才有 audience 可判,截止之前先鉴权
Set<FillAccessService.Tier> tiers = fillAccess.resolve(user, form);
requireFillEntry(user, tiers, form, req.getSubjectId(), submitterId);
// 2) audience 门(契约 v2 §3.1):钉版之后才有 audience 可判,截止之前先鉴权。
// 只对<b>自填</b>生效(P115 裁决:§3.4 与 §5.1/V73 冲突点按"代录看 ALL 档、不看操作者人群"收敛)
requireAudienceGate(user, form, req.getSubjectId(), submitterId);
// 3) 截止(datetime 粒度,过期接口拒绝,契约 §5)
if (FormRuntimeRules.isDeadlineReached(form, LocalDateTime.now())) {
@@ -166,8 +167,9 @@ public class AnswerService {
return toView(existing, true);
}
// 5) subject 授权(P95 行为表:谁能代填由第 0 关的层决定)+ 存在性/同校(P33)
Integer subjectId = requireSubject(user, tiers, req.getSubjectId(), submitterId, schId);
// 5) subject 指定权(契约 v2 §3.1:=该表单 ALL 档持有者,原 canManage 代填路径收敛到此)
// + 存在性/同校(P33)
Integer subjectId = requireSubject(user, form, req.getSubjectId(), submitterId, schId);
// Bug#3:自填(未传 subjectId 或传了本人)落库时把 subject 归一为本人。
// 否则 subject_id=NULL 的自填行永远进不了"关于我的"(/my/about 与 ABOUT_ME 谓词都按 subject 查),
// V18"关于我(含代填)"实际退化成"仅代填"。限次键本就是 coalesce(subject, submitter),归一不改它。
@@ -215,44 +217,50 @@ public class AnswerService {
// ==================== 各环节 ====================
/**
* 第 0 关(P95 W2 行为表):入口层判"这个身份在这张表单上到底能不能写"。
* audience 门(契约 v2 §3.1):<b>自填</b>资格=会话身份类型 ∈ 表单面向人群(∩ 状态门在后面几关)。
*
* <p>两条拒绝各有独立文案,因为它们是两种不同的用户动作失败了:
* <b>NONE</b>=压根不在面向人群里(家长/工作号/局领导、学生填教师问卷),直链进来也是 403;
* <b>仅 MANAGE 的自填</b>=人在人群口径里、但档位只给代填不给自填(教师×学生面向),
* 文案要告诉他"改成代填学生就能提交",否则这条 403 无从下手。</p>
* <p><b>代录不吃这道门</b>(问题单 <b>P115</b>,用户 2026-10-20 裁决):§3.4 写"audience 门按代录
* 操作者会话判定",而 §5.1/V73 又要求管理员视图能"添加(代录)"、批量导入走同一管线——
* 校管理员(roleId 2/7)本身不在学生或教师人群里,按字面判会让代录对管理员整体失效。
* 两条承诺只能留一条,留的是<b>功能可用</b>那条:{@code subject} 指向他人时,操作者的身份
* 不代表填报人,资格由"是不是该表单 ALL 档持有者"决定(在 {@link #requireSubject} 判),
* 人群门留给自填。</p>
*
* <p>拒绝文案必须<b>可诊断</b>(承接原第 0 关的文案纪律):带上表单 code、配置的 audience
* 集合、当前会话的身份要素——"人群配错了"与"这个人确实不在人群里"要能在日志里一眼分开。</p>
*/
private void requireFillEntry(BoaUser user, Set<FillAccessService.Tier> tiers, Form form,
Integer subjectId, Integer submitterId) {
if (tiers.isEmpty()) {
throw new BizException(ErrorCode.FORBIDDEN, "无权填报该表单:表单「" + form.getCode() + "」面向"
+ audienceLabel(form) + ",当前身份 " + sessionIdentity(user) + " 不在可填报人群内"
+ "(可填=面向人群本人;教师可代填本校学生;管理员不受限)");
private void requireAudienceGate(BoaUser user, Form form, Integer subjectId, Integer submitterId) {
if (subjectId != null && !subjectId.equals(submitterId)) {
return;
}
boolean proxy = subjectId != null && !subjectId.equals(submitterId);
if (!proxy && !fillAccess.canFill(tiers)) {
throw new BizException(ErrorCode.FORBIDDEN, "教师不能以本人身份填报学生面向表单,可代填本校学生"
+ "(subjectId 传被代填学生 id):表单「" + form.getCode() + "」面向" + audienceLabel(form));
Set<FormEnums.Audience> identities = FormAccessPolicy.identityTypes(user);
Set<FormEnums.Audience> audience = FormEnums.Audience.parse(form.getAudience());
if (!Collections.disjoint(identities, audience)) {
return;
}
throw new BizException(ErrorCode.FORBIDDEN, "无权填报该表单:表单「" + form.getCode() + "」面向人群="
+ audience + "(" + FormEnums.Audience.labelOf(form.getAudience()) + "),当前身份 "
+ sessionIdentity(user) + " 不在人群内(自填资格=身份类型 ∈ audience;代录请传 subjectId,"
+ "由该表单 ALL 档把关)");
}
private Integer requireSubject(BoaUser user, Set<FillAccessService.Tier> tiers, Integer subjectId,
/**
* subject 指定权(契约 v2 §3.1/G1):只有该表单 <b>ALL 档</b>持有者(创建人 ∪ form_admin ∪
* 校管理员 ∪ 超管)能把答卷挂到别人名下;其余会话 subject 只能=本人。
*
* <p>原 {@code FormAdminGuard#canManage}(授权模块行判"有没有代填权")随模块行退役收敛到这里
* ——数据职权与"能替谁填"从此是同一个事实源(V55 的写侧对应面)。P33 的存在性+同校复核不变。</p>
*/
private Integer requireSubject(BoaUser user, Form form, Integer subjectId,
Integer submitterId, Long schId) {
if (subjectId == null || subjectId.equals(submitterId)) {
return subjectId;
}
if (fillAccess.isAdmin(tiers)) {
// 层③的代填仍走 canManage 现路径(P95 红线 3"canManage 代填路径原样"):
// 入口层把 ADMIN 判出来不等于给一条比现状更宽的写通道——没被授权管理模块行的人照旧拒
if (!adminGuard.canManage(user)) {
throw new BizException(ErrorCode.FORBIDDEN, forbiddenProxy(submitterId, subjectId));
}
} else if (!fillAccess.canProxy(tiers)) {
// 层①普通成员(学生×学生面向、教师×教师面向)代填=拒,与 P95 之前的现行为逐字同文案
if (!dataScope.resolve(user, form).all()) {
throw new BizException(ErrorCode.FORBIDDEN, forbiddenProxy(submitterId, subjectId));
}
// P33:代填=把数据挂到别人名下,"关于我的"视图(V18)与按 subject 限次都建立在"id 真实且同校"上。
// 本人自填不查(会话里的人本来就存在),只验代填那一个 id。层②与层③共用这一条(红线 3)。
// 本人自填不查(会话里的人本来就存在),只验代填那一个 id。
if (!subjectDirectory.existsInSchool(subjectId, schId)) {
throw new BizException(ErrorCode.PARAM_ERROR,
"被代填人不存在或不属于本校:subjectId=" + subjectId + ",schId=" + schId);
@@ -264,11 +272,6 @@ public class AnswerService {
return "无权为他人填报:subjectId 只能等于本人(当前 " + submitterId + ",请求 " + subjectId + ")";
}
private static String audienceLabel(Form form) {
return FormEnums.Audience.TEACHER.getCode() == (form.getAudience() == null
? FormEnums.Audience.STUDENT.getCode() : form.getAudience()) ? "教师" : "学生";
}
/** 拒绝文案里的身份要素(会话 roleIds + 当前身份,便于运维一眼看出是人群配错还是身份不对) */
private static String sessionIdentity(BoaUser user) {
StringBuilder sb = new StringBuilder("id=").append(user.getId()).append(" roleIds=").append(user.getRoleIds());
@@ -1,7 +1,6 @@
package com.yunhai.formcenter.service;
import com.yunhai.formcenter.entity.Form;
import com.yunhai.formcenter.enums.FormEnums;
import com.yunhai.formcenter.enums.FormEnums.DataScope;
import com.yunhai.platform.apistd.BizException;
import com.yunhai.platform.utils.exception.ErrorCode;
@@ -10,41 +9,32 @@ import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import java.util.Collections;
import java.util.EnumSet;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Set;
/**
* 数据范围解析器(契约 §8 / 方案 §6.4 / 管理员体系方案 §5.1):把"会话用户 + 这张表单"解析成一组
* 可见范围,交给 {@link AnswerScopeQueryBuilder} 落成 SQL 谓词。
* 数据范围解析器(契约 v2 §2.3 / D4):把"会话用户 + 这张表单"解析成<b>两档</b>可见集,
* 交给 {@link AnswerScopeQueryBuilder} 落成 SQL 谓词。
*
* <p><b>解析规则(优先级)</b>:{@code 范围 = 管理员两表授权 ∪ 身份默认矩阵},
* 命中"全部"即收敛为全部;<b>全不命中回落仅本人提交</b>(宁紧勿松)。</p>
*
* <p><b>管理员两表来源</b>(T10 起,取代已退役的 {@code customId} 权限锚点行):
* {@code school_admin} 命中会话校、或 {@code form_admin} 命中这张表单时,按表单归属域升级——
* 本校表单=<b>全部</b>(含本校快应用表单,{@code app_code} 不限),基础表单({@code sch_id=0})=
* <b>本校提交</b>(D2 裁决:跨校学生数据不因授权而可见)。他校表单不给任何升级。</p>
*
* <p>"多来源取最宽"在这里的实现是<b>并集</b>:一个用户可能同时有多个身份({@code roleIds} 里既有
* 教师又有学生),逐格取默认值后并起来,读路径按 OR 判行——这比"只取一个最宽枚举值"更贴语义:
* 学生视角(关于我)与教师视角(仅本人提交)不是包含关系,取单值会丢掉其中一格。</p>
*
* <p><b>身份默认矩阵</b>(契约 §8,行=roleId、列=表单面向人群):</p>
* <pre>
* 面向学生(1) 面向教师(2)
* 7/2 全部 全部
* 1 任教班级 → 一期实现器缺席=仅本人提交 仅本人提交
* 3 关于我 仅本人提交(该格契约写"不授填报模块行,进不来",
* 数据面按最保守的兜底值处理)
* 4/6/5 仅本人提交 仅本人提交
* ALL =该表单全部行 持有者:创建人(C16)∪ form_admin ∪ 校管理员(∪ 超管)
* SELF =我提交的 ∪ 关于我的 一切 CAS 会话用户天然持有
* </pre>
*
* <p>与 {@link FormAdminGuard} 是<b>两条正交的口径</b>:守卫答"能不能进这个管理面"(UserBase 授权模块行,
* 外加 T10 的基础表单读开口),本类答"进来后能看哪些行"。<b>两条线的事实源不同</b>——模块行来自 UserBase
* ({@link ModuleTreeSource}),管理员身份来自本库两表({@link AdminScopeSource}),管理员表不充当页面
* 入口凭证(方案 §3 三层模型不变)。</p>
* <p>三条与旧模型不同的地方,逐条对应《契约v2》§19 作废-1/5:</p>
* <ul>
* <li><b>身份默认矩阵整体下线</b>——原"roleId × 面向人群"逐格取值、多来源取最宽、
* "全不命中回落仅本人提交"三段全部删除。回落分支之所以不需要:登录用户天然持有 SELF,
* 空集只可能出现在无会话场景(匿名通道不经本解析);</li>
* <li><b>SCHOOL 不再是独立范围值</b>——它变成 ALL 档内的租户限定变体({@link Resolved#platformScopedAll()}),
* 只对平台级表单({@code sch_id=0})生效;</li>
* <li><b>创建人自动 ALL</b>(D6/G1)——运行时并集判定,不落 {@code form_admin} 行,两张表保持"纯授权"语义。</li>
* </ul>
*
* <p><b>fail-closed 三处不回退</b>:未登录即拒;他校表单({@code sch_id} 既非会话校也非 0)不给 ALL;
* 租户限定变体给不出会话校即拒。存量 {@code submitter_sch_id=NULL} 不匹配等值谓词(P84)。</p>
*/
@Slf4j
@Service
@@ -53,57 +43,69 @@ public class DataScopeService {
private final List<AnswerScopeImplementor> implementors;
private final AdminScopeSource adminScopeSource;
private final FormAccessPolicy accessPolicy;
/**
* 该会话用户在这张表单上的可见范围集合(至少一个,见类注释的回落规则)。
* 该会话用户在这张表单上的可见档集合。
*
* @param form 目标表单({@code schId} 决定两表升级成哪一档、{@code audience} 决定用矩阵哪一列);
* null 按面向学生列解析,且不产生两表升级(无表单域可判)
* @param form 目标表单({@code schId} 决定 ALL 是否走租户限定变体,{@code uuid}/{@code creatorId}
* 决定创建人与 form_admin 命中);null=没有表单域可判,只给 SELF
* @return 永不含空集、永不为 null;至少持有 SELF
*/
public Set<DataScope> resolve(BoaUser user, Form form) {
public Resolved resolve(BoaUser user, Form form) {
if (user == null || user.getId() == null) {
throw new BizException(ErrorCode.UNAUTHORIZED, "未登录");
}
if (FormAccessPolicy.isSuperAdmin(user)) {
return EnumSet.of(DataScope.ALL);
if (form == null || form.getSchId() == null) {
return Resolved.selfOnly();
}
Set<DataScope> scopes = new LinkedHashSet<>();
// 1) 管理员两表授权(契约 §8 v1.5 优先级:管理员来源授权 > 身份默认)
DataScope granted = grantedByAdminTables(user, form);
if (granted != null) {
scopes.add(granted);
// 超管与创建人不受域约束:平台表单的全量数据本来就归创建人(D9)
if (FormAccessPolicy.isSuperAdmin(user) || isCreator(user, form)) {
return Resolved.fullAll();
}
// 2) 身份默认矩阵,roleId 逐个取默认后并起来(多来源取最宽)
int audience = form == null || form.getAudience() == null
? FormEnums.Audience.STUDENT.getCode() : form.getAudience();
for (Integer roleId : FormAccessPolicy.identityRoleIds(user)) {
scopes.add(defaultScope(roleId, audience));
if (!adminScopeSource.isFormAdmin(form.getUuid(), user.getId()) && !accessPolicy.isSchoolAdmin(user)) {
return Resolved.selfOnly();
}
if (scopes.isEmpty()) {
scopes.add(DataScope.SELF_SUBMITTED);
}
if (scopes.contains(DataScope.ALL)) {
// 命中"全部"即收敛:其余范围是它的真子集,并集里留着只会让读路径多写几个 OR
return EnumSet.of(DataScope.ALL);
}
return EnumSet.copyOf(scopes);
// 两表命中后给哪种 ALL 形态由表单归属域决定(判据单点在 FormAccessPolicy,V58)
return allForDomain(form, FormAccessPolicy.sessionSchId(user));
}
/**
* 一期可选的范围值(V28 的断言载体):{@code 任教班级} 的实现器 {@code selectable()=false},
* T10 新增的 {@code SCHOOL} 同样 false——它不是筛选器选项,是授权结果(方案 §5.1 明示)。
* 授权命中后在这张表单上的 ALL 形态:本校表单=全量,平台级表单=租户限定变体,他校表单=不给。
*
* <p>平台级表单上不给全量是<b>刻意的</b>:§2.3 的变体只点名校管理员,但 D9/V57 的不变式是
* "平台表单全量归创建人(超管)、跨校学生数据不因授权而可见"——form_admin 授权同样落在一张跨校
* 资产上,给全量就等于开一个跨校读口子(T10 D2=B 加 {@code submitter_sch_id} 列的动因原样成立)。
* 该差额登记为问题单,等裁决而不是静默放宽。</p>
*/
public Set<DataScope> selectableScopes() {
Set<DataScope> result = new LinkedHashSet<>();
for (AnswerScopeImplementor implementor : implementors) {
if (implementor.selectable()) {
result.add(implementor.scope());
}
private static Resolved allForDomain(Form form, Long ownSch) {
long formSch = form.getSchId();
if (ownSch != null && formSch == ownSch) {
return Resolved.fullAll();
}
return result;
if (formSch == Form.PLATFORM_SCH_ID) {
return ownSch == null ? Resolved.selfOnly() : Resolved.platformScopedAllVariant();
}
// 他校表单:不给 ALL(跨校授权不成立;D9 的平台级例外只针对 sch_id=0)
return Resolved.selfOnly();
}
/** 范围 → 实现器(读路径构造器按它取谓词;未注册即配置错误,直接抛出不静默放行) */
/**
* 创建人判定(C16):{@code creator_id}==会话 uid <em>或</em> {@code create_by}==会话账号名。
*
* <p>兜底那半不是历史包袱而是必需项:{@code create_by} 存的是账号名(String),与两表的 user_id
* 标识体系不同,存量行 {@code creator_id} 未回填时只有账号名可比。存量回填由一次性对照 UserBase
* 的脚本/人工完成(160 现存 form 行个位数),回填前后判定结果一致。</p>
*/
private static boolean isCreator(BoaUser user, Form form) {
if (form.getCreatorId() != null) {
return user.getId() != null && form.getCreatorId().longValue() == user.getId().longValue();
}
String account = FormAccessPolicy.sessionAccount();
return account != null && !account.trim().isEmpty() && account.trim().equals(form.getCreateBy());
}
/** 档 → 实现器(读路径构造器按它取谓词;未注册即配置错误,直接抛出不静默放行) */
public AnswerScopeImplementor implementorOf(DataScope scope) {
for (AnswerScopeImplementor implementor : implementors) {
if (implementor.scope() == scope) {
@@ -114,56 +116,55 @@ public class DataScopeService {
}
/**
* 管理员两表在这张表单上应得的范围值(不给=null,交给矩阵)。
*
* <p>两张表的映射口径<em>逐字相同</em>(方案 §5.1 伪码里两段 if 是同一段逻辑),所以合成一次判定;
* 若将来两表要分档(比如单表单管理员只给到本校提交),在这里分叉而不是在调用侧。</p>
* 解析输出=二档布尔集(§2.3 原文形态),外加 ALL 是否走租户限定变体。
* {@link #scopes()} 是喂给查询规格与出参的枚举视图,两者同一份事实。
*/
private DataScope grantedByAdminTables(BoaUser user, Form form) {
if (form == null || form.getSchId() == null) {
return null;
}
Long ownSch = FormAccessPolicy.sessionSchId(user);
if (ownSch == null) {
// 会话给不出学校:既没有"本校表单"可认,SCHOOL 也没有比对基准——不给任何升级
return null;
}
boolean schoolAdmin = adminScopeSource.isSchoolAdmin(ownSch, user.getId());
if (!schoolAdmin && !adminScopeSource.isFormAdmin(form.getUuid(), user.getId())) {
return null;
}
long formSch = form.getSchId();
if (formSch == ownSch) {
return DataScope.ALL;
}
if (formSch == Form.PLATFORM_SCH_ID) {
return DataScope.SCHOOL;
}
// 他校表单:两表都不覆盖(校级管理员的授权域=会话校,跨校不成立)
return null;
}
public static final class Resolved {
/** 矩阵取格(任教班级在一期落到"仅本人提交",见类注释表格) */
private DataScope defaultScope(Integer roleId, int audience) {
boolean studentFacing = FormEnums.Audience.STUDENT.getCode() == audience;
if (roleId == null) {
return DataScope.SELF_SUBMITTED;
private static final Resolved SELF_ONLY = new Resolved(false, true, false);
private static final Resolved FULL_ALL = new Resolved(true, true, false);
private static final Resolved PLATFORM_SCOPED_ALL = new Resolved(true, true, true);
private final boolean all;
private final boolean self;
private final boolean platformScopedAll;
private Resolved(boolean all, boolean self, boolean platformScopedAll) {
this.all = all;
this.self = self;
this.platformScopedAll = platformScopedAll;
}
if (roleId == FormAccessPolicy.ROLE_ADMIN || roleId == FormAccessPolicy.ROLE_SCHOOL_LEADER) {
return DataScope.ALL;
static Resolved selfOnly() {
return SELF_ONLY;
}
if (roleId == FormAccessPolicy.ROLE_TEACHER) {
if (!studentFacing) {
return DataScope.SELF_SUBMITTED;
static Resolved fullAll() {
return FULL_ALL;
}
static Resolved platformScopedAllVariant() {
return PLATFORM_SCOPED_ALL;
}
public boolean all() {
return all;
}
public boolean self() {
return self;
}
/** ALL 档是否收窄成"本校提交"(D9;仅 {@code sch_id=0} 的表单可能为 true) */
public boolean platformScopedAll() {
return platformScopedAll;
}
public Set<DataScope> scopes() {
if (all) {
return EnumSet.of(DataScope.ALL);
}
// 面向学生:矩阵值=任教班级,一期实现器缺席 → 生效值=仅本人提交(方案 §6.4 表格明示,防被当 bug 报)
log.debug("[数据范围] 教师×面向学生:任教班级一期未开放,按矩阵注记生效为仅本人提交");
return DataScope.SELF_SUBMITTED;
return self ? Collections.singleton(DataScope.SELF) : Collections.<DataScope>emptySet();
}
if (roleId == FormAccessPolicy.ROLE_STUDENT && studentFacing) {
return DataScope.ABOUT_ME;
}
// 学生×面向教师、家长(4)/工作号(6)/局领导(5)、以及未知角色:兜底
return DataScope.SELF_SUBMITTED;
}
}
@@ -1,147 +0,0 @@
package com.yunhai.formcenter.service;
import com.yunhai.formcenter.entity.Form;
import com.yunhai.formcenter.enums.FormEnums;
import com.yunhai.platform.apistd.BizException;
import com.yunhai.platform.utils.exception.ErrorCode;
import com.yunhai.platform.utils.model.BoaUser;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import java.util.EnumSet;
import java.util.List;
import java.util.Set;
/**
* 填报<b>入口层</b>解析(P95 任务书 §0.1 三层角色模型):把"会话用户 + 这张表单"解析成一个<b>层集合</b>,
* 回答"填报入口给不给这个人开、开到哪一档"。纯解析、无写路径、不碰任何一张表。
*
* <p><b>三条线的分工(互不越界,P95 红线 1/2)</b>:</p>
* <ul>
* <li><b>UserBase 模块行</b>管"能不能进页面"——{@code /admin/*} 四行是管理面入口凭证
* ({@link FormAdminGuard},P80 口径"入口只认模块行,两表只升范围"一字不动)。
* {@code /f/{code}} 填报模块行自 P95 起<b>不再承担填报授权</b>;</li>
* <li><b>本类</b>管"填报入口给谁开"(P95 新增)——人群由表单的 {@code audience} × 会话身份 ×
* 管理员两表在中心侧自解析,不需要超管逐个表单配模块行;</li>
* <li>{@link DataScopeService} 管"进页面后能看哪些行"(读矩阵,一字不动)。</li>
* </ul>
* 三者判定因子有重叠(都看 roleIds、都看两表),但<b>结论互不供给</b>:本类不给数据面放行,
* 数据面也不给填报入口放行。
*
* <p><b>层与谓词(任务书 §0.1 表原样)</b>:</p>
* <pre>
* ADMIN 超管(type==1) ∪ school_admin 命中会话校 ∪ form_admin 命中该表单 ∪ roleIds 含 2/7
* MANAGE audience=学生 且 roleIds 含 1(教师)——可代填本校学生,数据面仍是"仅本人提交"
* FILL (audience=学生 且 roleIds 含 3) ∪ (audience=教师 且 roleIds 含 1)
* NONE 其余一切(家长4/局领导5/工作号6/未知角色、学生×教师面向、roleIds 全不命中)= 空集
* </pre>
*
* <p><b>多身份=并集</b>:{@code roleIds}(全部正常态角色)∪ 会话 {@code currentRole.roleId}
* 逐层判定后合并(沿 {@link DataScopeService} 的 {@code identityRoleIds} 口径,防 UserBase 两侧不一致漏格)。
* 一个层集合可以同时含多层(如教师×学生面向 = {@code {MANAGE}},校领导 = {@code {ADMIN}})。</p>
*
* <p><b>与任教班级 stub 的关系</b>:层②的<b>数据面</b>语义就是现状(矩阵该格生效=仅本人提交),
* 本类只是把"教师可以往本校学生名下写数据"这件事从模块行授权收回到中心解析,
* {@code DataScopeService} 的注释与取值一概不动。</p>
*/
@Service
@RequiredArgsConstructor
public class FillAccessService {
private final AdminScopeSource adminScopeSource;
/** 填报入口层({@link #resolve} 的元素;未命中任何层=空集,语义即任务书里的 NONE) */
public enum Tier {
/** 层③:全部管理 + 可填 + 可代填 */
ADMIN,
/** 层②:教师×学生面向,可代填本校学生(数据面维持仅本人提交) */
MANAGE,
/** 层①:本人身份可填 */
FILL
}
/**
* 解析该会话用户在这张表单上的层集合。
*
* @param form 目标表单({@code audience} 决定用哪一列,{@code uuid} 决定 form_admin 命中);
* null 按面向学生列解析,且不产生 form_admin 命中(无表单可判),
* 与 {@link DataScopeService#resolve} 的 null 口径同形
* @return 层集合;空集=NONE(没有任何入口)。<b>永不为 null</b>
*/
public Set<Tier> resolve(BoaUser user, Form form) {
if (user == null || user.getId() == null) {
throw new BizException(ErrorCode.UNAUTHORIZED, "未登录");
}
Set<Tier> tiers = EnumSet.noneOf(Tier.class);
List<Integer> roleIds = FormAccessPolicy.identityRoleIds(user);
if (FormAccessPolicy.isSuperAdmin(user) || grantedByAdminTables(user, form) || holdsAdminRole(roleIds)) {
tiers.add(Tier.ADMIN);
}
boolean studentFacing = FormEnums.Audience.STUDENT.getCode()
== (form == null || form.getAudience() == null
? FormEnums.Audience.STUDENT.getCode() : form.getAudience());
if (studentFacing && roleIds.contains(FormAccessPolicy.ROLE_TEACHER)) {
tiers.add(Tier.MANAGE);
}
if (studentFacing ? roleIds.contains(FormAccessPolicy.ROLE_STUDENT)
: roleIds.contains(FormAccessPolicy.ROLE_TEACHER)) {
tiers.add(Tier.FILL);
}
return tiers;
}
/** 层①∪层③:能否以<b>本人</b>身份填报(层②只有代填,自填不在这里——由调用侧按行为表拒绝并给文案) */
public boolean canFill(Set<Tier> tiers) {
return tiers != null && (tiers.contains(Tier.FILL) || tiers.contains(Tier.ADMIN));
}
/** 层②∪层③:能否<b>代填</b>他人(真正能不能落到某个人身上还要过 P33 的 existsInSchool) */
public boolean canProxy(Set<Tier> tiers) {
return tiers != null && (tiers.contains(Tier.MANAGE) || tiers.contains(Tier.ADMIN));
}
/** 层③:管理员层命中(超管 / 两表 / 校领导(2) / 管理员(7)) */
public boolean isAdmin(Set<Tier> tiers) {
return tiers != null && tiers.contains(Tier.ADMIN);
}
/** 并集里的最高层(序 ADMIN &gt; MANAGE &gt; FILL;空集返回 null=NONE),供列表的 {@code myTier} 用 */
public Tier topTier(Set<Tier> tiers) {
if (tiers == null || tiers.isEmpty()) {
return null;
}
for (Tier candidate : Tier.values()) {
if (tiers.contains(candidate)) {
return candidate;
}
}
return null;
}
/**
* 管理员两表是否把这个人抬进层③:{@code school_admin} 命中<b>会话校</b>、
* 或 {@code form_admin} 命中<b>这张表单</b>(口径与 {@link DataScopeService} 读侧同源,
* 但结论只用在入口层,不改数据范围)。
*
* <p>与读侧的差别只有一处,且是有意的:两表在这里<b>不看表单归属域</b>。
* 读侧要区分"本校表单→全部 / 基础表单→本校提交",而入口层问的是"这个人是不是管理员",
* 基础表单的管理员同样是管理员;校域不变式({@code answer.sch_id}=会话校)也不因代填而变。</p>
*/
private boolean grantedByAdminTables(BoaUser user, Form form) {
Long ownSch = FormAccessPolicy.sessionSchId(user);
if (ownSch != null && adminScopeSource.isSchoolAdmin(ownSch, user.getId())) {
return true;
}
return form != null && adminScopeSource.isFormAdmin(form.getUuid(), user.getId());
}
/** 校领导(2)/管理员(7):平台标准角色里的管理身份(清单单点在 {@link FormAccessPolicy#SCHOOL_LEADER_ROLES}) */
private static boolean holdsAdminRole(List<Integer> roleIds) {
for (Integer roleId : roleIds) {
if (roleId != null && FormAccessPolicy.SCHOOL_LEADER_ROLES.contains(roleId)) {
return true;
}
}
return false;
}
}
@@ -1,6 +1,7 @@
package com.yunhai.formcenter.service;
import com.yunhai.formcenter.entity.Form;
import com.yunhai.formcenter.enums.FormEnums;
import com.yunhai.formcenter.model.MyModels;
import com.yunhai.formcenter.repository.FormRepository;
import com.yunhai.platform.utils.model.BoaUser;
@@ -20,8 +21,8 @@ import java.util.Map;
import java.util.Set;
/**
* 「我可填」清单解析(P95 W3,{@code GET /api/form/my/fillable}):把"这个会话用户能进哪些表单的填报页"
* 算成一张列表——P91 那个列表的数据源,从此<b>不再吃 UserBase 模块树</b>。
* 「我可填」清单解析({@code GET /api/form/my/fillable},契约 v2 C2 行形状):
* 把"这个会话用户能进哪些表单的填报页"算成一张列表——数据源不再是 UserBase 模块树。
*
* <p><b>四步,一步不多</b>:</p>
* <ol>
@@ -29,17 +30,18 @@ import java.util.Set;
* <li><b>遮蔽去重</b>:同 {@code (app_code, code)} 本校与平台级都在时本校胜出且不回落——
* 直接复用 {@link FormDefService#pickByShadowing},与 resolve 同一条 U5 实现(P20 的取舍:
* 两处口径必须同源,否则列表里出现的表单点进去可能解析成另一张);</li>
* <li><b>逐行过入口层</b>:{@link FillAccessService#resolve} 为空集(NONE)的行不入集;非空即入集,
* {@code canFill} 与 {@code myTier} 一起下发——层②(教师×面向学生)的行 {@code canFill=false},
* 前端「可填写」列表把它筛掉,但"数据管理"tab 的候选要靠它,所以不能只下发一个布尔;</li>
* <li><b>逐行过 audience 门</b>:登录身份类型 ∩ {@code form.audience}=∅ 的行不入集。
* 这一条与 {@link AnswerService} 提交时的 audience 门<b>同一个公式</b>——
* "列表里有"与"提交能过"不可能不一致(原 P95 的 {@code canFill}/{@code myTier} 随之消失:
* 入集即等价于可填,不再有"进了列表但不能填"的中间态,§19 作废-3);</li>
* <li><b>排序</b>:deadline 升序、null(不限)在后、同档按 code。</li>
* </ol>
*
* <p><b>不分页</b>:一所学校 + 平台级里可填的表单是个位数到几十张(P95 W3 的量级假设,报告已记),
* <p><b>不分页</b>:一所学校 + 平台级里可填的表单是个位数到几十张(量级假设,报告已记),
* 一张轻列表不值得引入游标;返回值就是裸数组,不带 total——将来真长大了加分页时不破坏"行形状"这条契约。</p>
*
* <p><b>与数据面的关系</b>:本类只回答"入口给谁开",不看 {@link DataScopeService}。
* 列表里出现某张表单,不代表进去能看到别人的行(层②教师看到的仍是仅本人提交)。</p>
* <p><b>{@code ANONYMOUS} 不参与入集</b>:匿名填报人不看列表,持 URL 直达(C2)。
* 本类也不看数据范围({@link DataScopeService}):列表里出现某张表单,不代表进去能看到别人的行。</p>
*/
@Slf4j
@Service
@@ -49,7 +51,6 @@ public class FillableFormService {
private static final DateTimeFormatter TS = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
private final FormRepository formRepository;
private final FillAccessService fillAccess;
/**
* 我可填清单。
@@ -100,23 +101,26 @@ public class FillableFormService {
.thenComparing(form -> form.getCode() == null ? "" : form.getCode()));
List<MyModels.FillableRow> rows = new ArrayList<>();
Set<FormEnums.Audience> identities = FormAccessPolicy.identityTypes(user);
for (Form form : visible) {
Set<FillAccessService.Tier> tiers = fillAccess.resolve(user, form);
if (tiers.isEmpty()) {
// 入集判定=audience 门的列表形态(§3.1 同一公式,不留两套口径):身份类型 ∩ 面向人群非空
if (Collections.disjoint(identities, FormEnums.Audience.parse(form.getAudience()))) {
continue;
}
MyModels.FillableRow row = new MyModels.FillableRow();
row.setCode(form.getCode());
row.setName(form.getName());
row.setAppCode(form.getAppCode());
row.setAudience(form.getAudience());
row.setDeadline(form.getDeadline() == null ? null : form.getDeadline().format(TS));
row.setFillLimit(form.getFillLimit());
row.setGroupTag(form.getGroupTag());
row.setCanFill(fillAccess.canFill(tiers));
row.setMyTier(fillAccess.topTier(tiers).name());
rows.add(row);
rows.add(toRow(form));
}
return rows;
}
private static MyModels.FillableRow toRow(Form form) {
MyModels.FillableRow row = new MyModels.FillableRow();
row.setCode(form.getCode());
row.setName(form.getName());
row.setAppCode(form.getAppCode());
row.setAudience(FormEnums.Audience.namesOf(form.getAudience()));
row.setDeadline(form.getDeadline() == null ? null : form.getDeadline().format(TS));
row.setFillLimit(form.getFillLimit());
row.setGroupTag(form.getGroupTag());
return row;
}
}
@@ -2,6 +2,8 @@ package com.yunhai.formcenter.service;
import com.yunhai.formcenter.enums.FormEnums;
import com.yunhai.platform.apistd.BizException;
import com.yunhai.platform.auth.LoginUser;
import com.yunhai.platform.auth.UserContextHolder;
import com.yunhai.platform.utils.exception.ErrorCode;
import com.yunhai.platform.utils.model.BoaUser;
import lombok.RequiredArgsConstructor;
@@ -82,6 +84,19 @@ public class FormAccessPolicy {
return user == null ? null : user.getId();
}
/**
* 会话账号名——与 {@code create_by}/{@code update_by} <b>同一个事实源</b>
* ({@code FormCenterAuditListener} 落列时取的就是它),所以"创建人兜底判定"(C16)
* 比的是同一条字符串,不是两个系统里恰好相等的两个字段。
*
* <p>{@code BoaUser} 上另有一个 {@code account},但它由 UserBase 组织接口下发、与 token 侧
* 账号名不是同一处写入;拿它比 {@code create_by} 会引入"两边口径不同就静默判不出创建人"的隐患。</p>
*/
public static String sessionAccount() {
LoginUser user = UserContextHolder.get();
return user == null ? null : user.getLoginName();
}
/** 超管(UserBase 用户类型判定;不是 roleId) */
public static boolean isSuperAdmin(BoaUser user) {
return user != null && user.getType() != null && user.getType() == SUPER_ADMIN_TYPE;
@@ -23,9 +23,11 @@ import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.EnumSet;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.UUID;
import java.util.regex.Pattern;
@@ -284,9 +286,14 @@ public class FormDefService {
// ==================== 写:草稿/发布/生命周期 ====================
/** 建草稿:uuid 当场生成、跨版本不变(契约 §1) */
/**
* 建草稿:uuid 当场生成、跨版本不变(契约 §1)。
*
* @param creatorId 会话用户 id(C16:创建人自动持有该表单 ALL 档,G1"创建人保留定义权"的载体);
* {@code create_by} 存的是账号名、与两表 user_id 标识体系不同,故另建一列归一
*/
@Transactional
public FormModels.FormView create(Long schId, FormModels.FormCreateRequest req) {
public FormModels.FormView create(Long schId, FormModels.FormCreateRequest req, Long creatorId) {
String cleanApp = normalizeAppCode(req.getAppCode());
String cleanCode = requireCode(req.getCode());
String cleanName = requireName(req.getName());
@@ -304,6 +311,7 @@ public class FormDefService {
form.setAppCode(cleanApp);
form.setCode(cleanCode);
form.setName(cleanName);
form.setCreatorId(creatorId);
form.setStatus(FormEnums.FormStatus.DRAFT.getCode());
form.setCurrentVersion(null);
try {
@@ -423,22 +431,81 @@ public class FormDefService {
formRepository.save(form);
}
/** 表单级配置(不进版本快照);deadline 只给日期时补当日 23:59:59(方案 §5.4) */
/**
* 表单级配置(不进版本快照);deadline 只给日期时补当日 23:59:59(方案 §5.4)。
*
* <p>契约 v2 新增两条配置面纪律:① {@code audience} 改<b>字符串数组</b>入参(C1,闭集三值),
* ② 含 {@code ANONYMOUS} 的表单<b>不许配限次</b>(§3.2/作废-11:匿名无 limit_key,cookie 防重是唯一节流)——
* 这条跨字段约束必须在配置通道挡下来,不能等提交时才发现"限次对匿名不生效"。</p>
*
* <p><b>先算合并态、再全部验完、最后才写实体</b>:跨字段约束看的是"这次改完长什么样"
* (入参 + 现值合并),但如果在校验前就改实体,被拒的那一次调用会在同一持久化上下文里留下
* 脏值(回滚型测试事务尤其明显),等于把"配置被拒"伪装成"配置已生效"。</p>
*/
@Transactional
public FormModels.FormView updateConfig(Long schId, FormModels.FormConfigRequest req) {
Form form = requireForm(schId, req.getUuid());
// ---- 1) 合并态解析(不碰实体) ----
Set<FormEnums.Audience> audience = FormEnums.Audience.parse(form.getAudience());
if (req.getAudience() != null) {
if (!FormEnums.Audience.valid(req.getAudience())) {
throw new BizException(ErrorCode.PARAM_ERROR, "audience 只能是 1(面向学生)或 2(面向教师)");
if (req.getAudience().isEmpty()) {
throw new BizException(ErrorCode.PARAM_ERROR,
"audience 不能是空数组(闭集 STUDENT/TEACHER/ANONYMOUS,至少一项)");
}
form.setAudience(req.getAudience());
Set<FormEnums.Audience> parsed = EnumSet.noneOf(FormEnums.Audience.class);
for (String name : req.getAudience()) {
FormEnums.Audience item = FormEnums.Audience.of(name);
if (item == null) {
throw new BizException(ErrorCode.PARAM_ERROR,
"audience 只能是 STUDENT/TEACHER/ANONYMOUS:" + name);
}
parsed.add(item);
}
audience = parsed;
}
Integer fillLimit = form.getFillLimit();
if (req.getFillLimit() != null) {
if (req.getFillLimit() != 0 && req.getFillLimit() != 1) {
throw new BizException(ErrorCode.PARAM_ERROR, "fillLimit 只能是 0(不限)或 1(限一次)");
}
form.setFillLimit(req.getFillLimit());
fillLimit = req.getFillLimit();
}
Integer frozen = form.getFrozen();
if (req.getFrozen() != null) {
if (req.getFrozen() != 0 && req.getFrozen() != 1) {
throw new BizException(ErrorCode.PARAM_ERROR, "frozen 只能是 0(可编辑)或 1(提交后禁编辑)");
}
frozen = req.getFrozen();
}
Integer anonMinutes = form.getAnonCookieMinutes();
if (req.getAnonCookieMinutes() != null) {
// 防重窗口是"分钟"粒度的节流参数,1..1440(一天)够覆盖;不给 0/负数是因为那等于关掉防重
if (req.getAnonCookieMinutes() < 1 || req.getAnonCookieMinutes() > 1440) {
throw new BizException(ErrorCode.PARAM_ERROR, "anonCookieMinutes 需在 1~1440 之间(默认 10)");
}
anonMinutes = req.getAnonCookieMinutes();
}
String groupTag = form.getGroupTag();
if (req.getGroupTag() != null) {
String tag = req.getGroupTag().trim();
if (tag.length() > 64) {
throw new BizException(ErrorCode.PARAM_ERROR, "groupTag 过长(≤64)");
}
groupTag = tag.isEmpty() ? null : tag;
}
// §3.2/作废-11:匿名表单不支持限次(无身份无 limit_key)——按合并态判,两个方向的改法都挡得住
if (audience.contains(FormEnums.Audience.ANONYMOUS) && FormEnums.FillLimit.once(fillLimit)) {
throw new BizException(ErrorCode.CUSTOM,
"面向人群含匿名(ANONYMOUS)时不支持限次:fillLimit 必须为 0(匿名无身份、cookie 防重是唯一节流)");
}
// ---- 2) 校验全过,落到实体 ----
form.setAudience(FormEnums.Audience.join(audience));
form.setFillLimit(fillLimit);
form.setFrozen(frozen);
form.setAnonCookieMinutes(anonMinutes);
form.setGroupTag(groupTag);
if (req.getDeadline() != null) {
form.setDeadline(FormRuntimeRules.parseDeadline(req.getDeadline()));
}
@@ -457,13 +524,6 @@ public class FormDefService {
}
form.setAnswerNoEnabled(req.getAnswerNoEnabled());
}
if (req.getGroupTag() != null) {
String tag = req.getGroupTag().trim();
if (tag.length() > 64) {
throw new BizException(ErrorCode.PARAM_ERROR, "groupTag 过长(≤64)");
}
form.setGroupTag(tag.isEmpty() ? null : tag);
}
formRepository.save(form);
return toView(form, draftVersion(form.getId()));
}
@@ -491,7 +551,7 @@ public class FormDefService {
* 源表单及其历史答卷零改动——本方法对源表只读。
*/
@Transactional
public FormModels.FormView copy(Long schId, FormModels.FormCopyRequest req) {
public FormModels.FormView copy(Long schId, FormModels.FormCopyRequest req, Long creatorId) {
Form source = requireForm(schId, req.getUuid());
FormVersion sourceRow = copySourceVersion(source);
String cleanApp = req.getAppCode() == null || req.getAppCode().trim().isEmpty()
@@ -506,6 +566,8 @@ public class FormDefService {
copy.setAppCode(cleanApp);
copy.setCode(cleanCode);
copy.setName(cleanName);
// 副本的创建人=点"另存为副本"的人,不是源表单的创建人(C16/G1:定义权跟着"建这张表的人")
copy.setCreatorId(creatorId);
copy.setStatus(FormEnums.FormStatus.DRAFT.getCode());
copy.setCurrentVersion(null);
// 表单级配置随副本(同校复制的目的就是"基于旧表单改一张"),但收集窗口不继承:
@@ -514,6 +576,8 @@ public class FormDefService {
copy.setFillLimit(source.getFillLimit());
copy.setStatusDefs(source.getStatusDefs());
copy.setAnswerNoEnabled(source.getAnswerNoEnabled());
copy.setFrozen(source.getFrozen());
copy.setAnonCookieMinutes(source.getAnonCookieMinutes());
copy.setGroupTag(source.getGroupTag());
copy.setDeadline(null);
try {
@@ -728,10 +792,12 @@ public class FormDefService {
view.setStatusName(FormEnums.FormStatus.nameOf(form.getStatus()));
view.setCurrentVersion(form.getCurrentVersion());
view.setDraftVersion(draftVersion);
view.setAudience(form.getAudience());
view.setAudience(FormEnums.Audience.namesOf(form.getAudience()));
view.setFillLimit(form.getFillLimit());
view.setDeadline(form.getDeadline() == null ? null : TS.format(form.getDeadline()));
view.setAnswerNoEnabled(form.getAnswerNoEnabled());
view.setFrozen(form.getFrozen());
view.setAnonCookieMinutes(form.getAnonCookieMinutes());
view.setGroupTag(form.getGroupTag());
view.setStatusDefs(toStatusDefViews(form.getStatusDefs()));
view.setCreateTime(form.getCreateTime() == null ? null : TS.format(form.getCreateTime()));
@@ -759,7 +825,7 @@ public class FormDefService {
view.setAppCode(form.getAppCode());
view.setName(form.getName());
view.setVersion(row.getVersion());
view.setAudience(form.getAudience());
view.setAudience(FormEnums.Audience.namesOf(form.getAudience()));
view.setFillLimit(form.getFillLimit());
view.setAnswerNoEnabled(form.getAnswerNoEnabled());
view.setDeadline(form.getDeadline() == null ? null : TS.format(form.getDeadline()));
@@ -83,7 +83,7 @@ public class OapiFormService {
view.setVersion(published.versionRow().getVersion());
view.setCode(form.getCode());
view.setAppCode(form.getAppCode());
view.setAudience(FormEnums.Audience.nameOf(form.getAudience()));
view.setAudience(FormEnums.Audience.namesOf(form.getAudience()));
view.setGroups(root.path("groups"));
return view;
}
@@ -144,7 +144,7 @@ public class OapiFormService {
view.setVersion(published.versionRow().getVersion());
view.setCode(form.getCode());
view.setAppCode(form.getAppCode());
view.setAudience(FormEnums.Audience.nameOf(form.getAudience()));
view.setAudience(FormEnums.Audience.namesOf(form.getAudience()));
view.setGroups(root.path("groups"));
return view;
}
@@ -1,45 +0,0 @@
package com.yunhai.formcenter.service;
import com.yunhai.formcenter.entity.Answer;
import com.yunhai.formcenter.enums.FormEnums.DataScope;
import com.yunhai.platform.apistd.BizException;
import com.yunhai.platform.utils.exception.ErrorCode;
import org.springframework.stereotype.Component;
import javax.persistence.criteria.CriteriaBuilder;
import javax.persistence.criteria.Predicate;
import javax.persistence.criteria.Root;
/**
* 范围实现器:<b>本校提交</b>({@code submitter_sch_id}=会话学校,T10 方案 §5.1 / 契约 §8 v1.5)。
*
* <p>它是管理员两表在<b>基础表单(sch_id=0)</b>上的落地形态:校级/单表单管理员都不因授权而看见
* 他校学生数据(D2=B 裁决加列的动因)。给不出会话学校时明确拒绝,不静默降成"无学校条件"——
* 那等于把本校范围读成全平台。</p>
*
* <p>{@link #selectable()} 为 false 的理由与任教班级同族但相反:任教班级是"能力未开放",
* 本值是"能力已开放但不是一个筛选选项"——它是授权结果,出现在筛选器里等于让调用方自己声明
* 一个未经授权的范围(V28 断言的就是这个"不入 selectableScopes")。</p>
*/
@Component
public class SchoolScopeImplementor implements AnswerScopeImplementor {
@Override
public DataScope scope() {
return DataScope.SCHOOL;
}
@Override
public boolean selectable() {
return false;
}
@Override
public Predicate rowPredicate(CriteriaBuilder cb, Root<Answer> root, Integer userId, Long viewerSchId) {
if (viewerSchId == null) {
throw new BizException(ErrorCode.FORBIDDEN, "数据范围 本校提交 需要会话学校身份");
}
// NULL 存量行天然不匹配等值谓词(fail-closed,方案 §4 存量条款),不需要额外的 is null 分支
return cb.equal(root.get("submitterSchId"), viewerSchId);
}
}
@@ -0,0 +1,37 @@
package com.yunhai.formcenter.service;
import com.yunhai.formcenter.entity.Answer;
import com.yunhai.formcenter.enums.FormEnums.DataScope;
import com.yunhai.formcenter.model.ReadModels;
import org.springframework.stereotype.Component;
import javax.persistence.criteria.CriteriaBuilder;
import javax.persistence.criteria.Predicate;
import javax.persistence.criteria.Root;
/**
* 范围实现器:<b>SELF</b>=我提交的 ∪ 关于我的(契约 v2 §2.3)。
*
* <p>原 {@code SELF_SUBMITTED}(按 {@code submitter_id})与 {@code ABOUT_ME}(按 {@code subject_id})
* 两档合并成一档:§2.3 把 SELF 定义成这两个视角的<b>并集</b>,读路径本就按 OR 判行,
* 拆两档只是原身份矩阵的产物(多身份并集里有"只有学生身份才拿到关于我"这种绕路)。
* 合并后"我填的"与"别人替我填的"一次命中(V18 语义不回退——{@code /my/about} 那两条视图
* 仍按单列查,见 {@code AnswerReadService#listMine},那是归属判定、不是范围矩阵)。</p>
*
* <p>走 {@code idx_answer_submitter(sch_id, submitter_id)} 与 {@code idx_answer_subject(subject_id)}。</p>
*/
@Component
public class SelfScopeImplementor implements AnswerScopeImplementor {
@Override
public DataScope scope() {
return DataScope.SELF;
}
@Override
public Predicate rowPredicate(CriteriaBuilder cb, Root<Answer> root, ReadModels.Query q) {
return cb.or(
cb.equal(root.get("submitterId"), q.getUserId()),
cb.equal(root.get("subjectId"), q.getUserId()));
}
}
@@ -1,35 +0,0 @@
package com.yunhai.formcenter.service;
import com.yunhai.formcenter.entity.Answer;
import com.yunhai.formcenter.enums.FormEnums.DataScope;
import org.springframework.stereotype.Component;
import javax.persistence.criteria.CriteriaBuilder;
import javax.persistence.criteria.Predicate;
import javax.persistence.criteria.Root;
/**
* 范围实现器:<b>仅本人提交</b>({@code submitter_id}=会话用户)。
*
* <p>这是契约 §8 的兜底值:"全不命中=仅本人提交(宁紧勿松)",也是教师×面向学生表单在 1.5
* 之前的实际生效值(任教班级实现器缺席,方案 §6.4 表格里写明了要防"教师怎么只看到自己"被当 bug 报)。
* 走 {@code idx_answer_submitter(sch_id, submitter_id)}。</p>
*/
@Component
public class SelfSubmittedScopeImplementor implements AnswerScopeImplementor {
@Override
public DataScope scope() {
return DataScope.SELF_SUBMITTED;
}
@Override
public boolean selectable() {
return true;
}
@Override
public Predicate rowPredicate(CriteriaBuilder cb, Root<Answer> root, Integer userId, Long viewerSchId) {
return cb.equal(root.get("submitterId"), userId);
}
}
@@ -1,42 +0,0 @@
package com.yunhai.formcenter.service;
import com.yunhai.formcenter.entity.Answer;
import com.yunhai.formcenter.enums.FormEnums.DataScope;
import com.yunhai.platform.apistd.BizException;
import com.yunhai.platform.utils.exception.ErrorCode;
import org.springframework.stereotype.Component;
import javax.persistence.criteria.CriteriaBuilder;
import javax.persistence.criteria.Predicate;
import javax.persistence.criteria.Root;
/**
* 范围实现器:<b>任教班级</b>——1.5 占位(契约 §8 / 方案 §6.4)。
*
* <p>存在意义就是把 V28 的两半钉在代码里:枚举值<em>在</em>(一期契约即含四值,定了就不变),
* 但 {@link #selectable()} 为 false,{@code DataScopeService} 因此永远不会把它放进解析结果,
* 教师×面向学生表单在一期按矩阵注明的"实际生效=仅本人提交"落地。</p>
*
* <p>1.5 开放时的改动面就在本类:接 UserClass(原 BschClass)的任教关系/班级 SDK,把
* {@code rowPredicate} 换成"submitter 的任教班级 ∩ 答卷班级列"或按 subject 的班级归属判定,
* {@code selectable()} 翻成 true——读路径与管理动作不用改(它们只认 {@link AnswerScopeQueryBuilder})。</p>
*/
@Component
public class TeachingClassScopeImplementor implements AnswerScopeImplementor {
@Override
public DataScope scope() {
return DataScope.TEACHING_CLASS;
}
@Override
public boolean selectable() {
return false;
}
@Override
public Predicate rowPredicate(CriteriaBuilder cb, Root<Answer> root, Integer userId, Long viewerSchId) {
// 防御性:一期解析不会产生该范围(selectable=false),走到这里说明有人绕过了矩阵解析
throw new BizException(ErrorCode.CUSTOM, "数据范围 任教班级 一期未开放(1.5 依赖 UserClass 任教关系)");
}
}