feat(p95-fill): 填报入口收回中心——三层角色模型 + 提交第 0 关 + 我可填清单
build / build-push (push) Successful in 58s

任务书 P95 W1/W2/W3/W7。发一张表单原本要 UserBase 超管加一条 /f/{code} 模块行并组授权,
实测成本不可接受(10 张表=10 次跨系统操作),人群判定收回表单中心自解析。

- FillAccessService:audience × 会话身份(roleIds ∪ currentRole)× 管理员两表 → 层集合
  ADMIN/MANAGE/FILL/空=NONE,谓词 canFill/canProxy/isAdmin + topTier;与 DataScopeService
  读矩阵、FormAdminGuard 管理端入口三条线正交,结论互不供给
- AnswerService.doSubmit:钉版解析之后插第 0 关(先鉴权后业务,幂等重落在闸门后);
  requireSubject 吸收行为表——教师×面向学生只能代填本校学生(P33 existsInSchool 保留),
  层③代填仍沿 canManage 现路径,层①普通成员代填照旧拒
- GET /api/form/my/fillable:会话校 ∪ 平台级取域、复用 FormDefService.pickByShadowing 做
  (app_code,code) 遮蔽去重、仅已发布未软删、NONE 不入集,行带 canFill + myTier 两件事,
  deadline 升序 null 在后;新增 MyModels.FillableRow
- h2 登记脚本快应用段 5→4 行:删 /f/ 填报行与学生组授权,保留应用行/管理页面行/
  path 空导入导出锚点/学校开通;H2 冻结延续,本轮未在任何环境执行
- 测试:FillAccessServiceTest 16(V48)+ AnswerFillGateIT 14(V49)+ FillableListIT 10(V50),
  mvn -B -o clean test 全量 259 绿(教师×面向学生的造数据通道按新口径改代填,
  读矩阵与限次等断言判据一字未改)

DataScopeService 与 FormAdminGuard 两个文件本轮未被改动(红线 1/2)。
This commit is contained in:
chenxing
2026-10-16 12:43:31 +08:00
parent dc76574118
commit a02f47ad5c
18 changed files with 1367 additions and 69 deletions
+30 -19
View File
@@ -1,8 +1,16 @@
-- ============================================================================ -- ============================================================================
-- 表单中心 H2 + 第一个快应用:UserBase 侧应用行/模块行/授权登记脚本 -- 表单中心 H2 + 第一个快应用:UserBase 侧应用行/模块行/授权登记脚本
-- 编制:Qoder(T9 联调轮)|日期:2026-10-15|改版:T10 中心段 6→5 行(手册 §2.2 v1.5,锚点行退役) -- 编制:Qoder(T9 联调轮)|日期:2026-10-15|改版:T10 中心段 6→5 行(手册 §2.2 v1.5,锚点行退役)
-- 改版:**P95(2026-10-16)快应用段 5→4 行——`/f/{code}` 填报行及其组授权整条撤销**
-- 终态口径(任务书 P95 §0.1 三层角色模型,用户 2026-10-16 裁决):填报人群由表单中心按
-- `audience × 会话身份 × 管理员两表` 自解析(后端 FillAccessService + GET /api/form/my/fillable),
-- UserBase 侧**不再逐表单配填报模块行**——发一张表单求一次超管的成本就此归零。
-- 快应用树只剩四种行:数据查看 / 统计 两个页面行 + 导入 / 导出 两个 path 空按钮锚点。
-- **中心应用五行(200~204)与 /admin/* 四行原样不动**(P95 红线 2:管理端入口仍只认模块行)。
-- ⚠ 存量环境里已录入的 `/f/` 行不在本脚本删除范围内(脚本只做"不存在才插",从不删行)——
-- 清理清单见 docs/P95-report.md 的「存量清理」段,由超管在管理端手点删。
-- 依据:《表单中心执行手册》§2.2(中心应用 v1.5 五条模块行表 + 验证口径)、 -- 依据:《表单中心执行手册》§2.2(中心应用 v1.5 五条模块行表 + 验证口径)、
-- 《表单中心建设方案 v1.4》§6.2(快应用登记 checklist R8)、§6.5(中心=BUSINESS 应用, -- 《表单中心建设方案 v1.4》§6.2(快应用登记 checklist R8;P95 注记后收敛为两步)、§6.5(中心=BUSINESS 应用,
-- 模块行与快应用模块行两棵树不混) -- 模块行与快应用模块行两棵树不混)
-- 《表单中心管理员体系方案-20261015》§3/§7(T10 改版:中心 6→5 行,customId 数据范围锚点退役) -- 《表单中心管理员体系方案-20261015》§3/§7(T10 改版:中心 6→5 行,customId 数据范围锚点退役)
-- 先例:D:\SoftProject\yunhai\UserBase\flow-center接入UserBase菜单.sql(同一套形态与幂等写法) -- 先例:D:\SoftProject\yunhai\UserBase\flow-center接入UserBase菜单.sql(同一套形态与幂等写法)
@@ -64,7 +72,7 @@ WHERE @center_app_id > 0
AND NOT EXISTS (SELECT 1 FROM app_module m WHERE m.app_id = t.app_id AND m.custom_id = t.custom_id); AND NOT EXISTS (SELECT 1 FROM app_module m WHERE m.app_id = t.app_id AND m.custom_id = t.custom_id);
-- ---------------------------------------------------------------------------- -- ----------------------------------------------------------------------------
-- 步骤 2:第一个快应用(演练对象,方案 §6.2 checklist R8 四步的前两步) -- 步骤 2:第一个快应用(演练对象,方案 §6.2 checklist R8;**P95 注记后 R8 只剩①建应用行 + ④学校开通**)
-- 快应用=真实应用行 + 无自有后端 + pc_url 指中心分发路由。app_code 一经确定不可改 -- 快应用=真实应用行 + 无自有后端 + pc_url 指中心分发路由。app_code 一经确定不可改
-- (表单归属列 app_code 与模块树查询都按它走,P25/D8)。 -- (表单归属列 app_code 与模块树查询都按它走,P25/D8)。
-- ---------------------------------------------------------------------------- -- ----------------------------------------------------------------------------
@@ -85,17 +93,17 @@ SET @quick_app_id = 0; -- ← 按上一步结果填;0=还没建
-- WHERE @quick_app_id = 0; -- WHERE @quick_app_id = 0;
-- SET @quick_app_id = LAST_INSERT_ID(); -- ← 紧接着上面那条 INSERT 生效;否则手工填 -- SET @quick_app_id = LAST_INSERT_ID(); -- ← 紧接着上面那条 INSERT 生效;否则手工填
-- 模块行五条(方案 §6.2 表的典型配法)。path 形态: -- 模块行四条(P95 终态;方案 §6.2 表在 P95 注记后只剩这四行是 UserBase 侧的事)。path 形态:
-- 填报 = /f/{code}(该快应用名下的表单 code,经 resolve 换 uuid——快应用不授权定义管理行,§3.4)
-- 数据查看/统计 = /app/{app_code}/data|stats(AppContext.vue 的分派段) -- 数据查看/统计 = /app/{app_code}/data|stats(AppContext.vue 的分派段)
-- 导入/导出 = path 留空的按钮级锚点(导出按钮显隐按它判,P76 修的就是"判哪棵树") -- 导入/导出 = path 留空的按钮级锚点(导出按钮显隐按它判,P76 修的就是"判哪棵树")
-- ⚠ 原「填报 /f/{code}」行**自 P95 起不再登记**:填报入口由中心按三层模型解析
-- (GET /api/form/my/fillable),模块行不参与;custom_id 300 段就此空置,不要再复用成别的行。
INSERT INTO app_module INSERT INTO app_module
(app_id, parent_id, name, custom_id, path, icon, level, sort_no, create_user, create_time) (app_id, parent_id, name, custom_id, path, icon, level, sort_no, create_user, create_time)
SELECT * FROM ( SELECT * FROM (
SELECT @quick_app_id AS app_id, NULL AS parent_id, '填报' AS name, '300' AS custom_id, SELECT @quick_app_id AS app_id, NULL AS parent_id, '数据查看' AS name, '301' AS custom_id,
CONCAT('/f/', @quick_code, '_form') AS path, 'edit' AS icon, 1 AS level, 300 AS sort_no, CONCAT('/app/', @quick_code, '/data') AS path, 'category' AS icon, 1 AS level, 301 AS sort_no,
@operator AS create_user, NOW() AS create_time @operator AS create_user, NOW() AS create_time
UNION ALL SELECT @quick_app_id, NULL, '数据查看', '301', CONCAT('/app/', @quick_code, '/data'), 'category', 1, 301, @operator, NOW()
UNION ALL SELECT @quick_app_id, NULL, '统计', '302', CONCAT('/app/', @quick_code, '/stats'), 'tongji', 1, 302, @operator, NOW() UNION ALL SELECT @quick_app_id, NULL, '统计', '302', CONCAT('/app/', @quick_code, '/stats'), 'tongji', 1, 302, @operator, NOW()
UNION ALL SELECT @quick_app_id, NULL, '导入', '303', '', '', 1, 303, @operator, NOW() UNION ALL SELECT @quick_app_id, NULL, '导入', '303', '', '', 1, 303, @operator, NOW()
UNION ALL SELECT @quick_app_id, NULL, '导出', '304', '', '', 1, 304, @operator, NOW() UNION ALL SELECT @quick_app_id, NULL, '导出', '304', '', '', 1, 304, @operator, NOW()
@@ -111,8 +119,9 @@ WHERE @quick_app_id > 0
-- 3.1 中心管理员组(示例:把 @grp_admin 换成目标学校的管理员组 id;sys_group 里查) -- 3.1 中心管理员组(示例:把 @grp_admin 换成目标学校的管理员组 id;sys_group 里查)
SET @grp_admin = 0; -- ← 中心管理员组 SET @grp_admin = 0; -- ← 中心管理员组
SET @grp_all = 0; -- ← 全员组(学校根组/基础组,"我的填写"给它) SET @grp_all = 0; -- ← 全员组(学校根组/基础组,"我的填写"给它)
SET @grp_teacher = 0; -- ← 快应用教师组(数据查看 + 导出锚点) SET @grp_teacher = 0; -- ← 快应用教师组(数据查看 + 统计 + 导出锚点)
SET @grp_student = 0; -- ← 快应用学生组(只给填报行) SET @grp_student = 0; -- ← 快应用学生组:**P95 起只需 3.2 的应用开通(门户可见性),不再勾任何模块行**
-- 填报入口由中心按三层模型给(面向学生的表单自动出现在学生账号的「可填写」里)
-- 3.2 应用开通(中心 + 快应用各一条;@school_id 是要开通的学校 id) -- 3.2 应用开通(中心 + 快应用各一条;@school_id 是要开通的学校 id)
SET @school_id = 0; -- ← 目标学校 id SET @school_id = 0; -- ← 目标学校 id
@@ -141,20 +150,16 @@ SELECT @grp_all, m.id, 0, @operator, NOW()
WHERE m.app_id = @center_app_id AND @grp_all > 0 AND m.path = '/my' WHERE m.app_id = @center_app_id AND @grp_all > 0 AND m.path = '/my'
AND NOT EXISTS (SELECT 1 FROM sys_group_module_perm p WHERE p.group_id = @grp_all AND p.module_id = m.id); AND NOT EXISTS (SELECT 1 FROM sys_group_module_perm p WHERE p.group_id = @grp_all AND p.module_id = m.id);
-- 3.4 快应用模块授权:教师组=数据查看+统计+导出锚点(不给填报也算得出数据);学生组=只给填报行 -- 3.4 快应用模块授权:教师组=数据查看+统计+导出锚点(导入 303 是 1.5 预留位不授)。
-- P95 起**学生组这一段整条撤销**:填报入口不再由模块行给(原"学生组=只给 /f/{code} 行"),
-- 学生所在组只需要 3.2 的**应用开通**(门户可见性与闸门照旧,V16 语义不变)——
-- 能不能填哪张表由中心按三层模型判(后端 FillAccessService)。
INSERT INTO sys_group_module_perm (group_id, module_id, inherit_flag, create_user, create_time) INSERT INTO sys_group_module_perm (group_id, module_id, inherit_flag, create_user, create_time)
SELECT @grp_teacher, m.id, 0, @operator, NOW() SELECT @grp_teacher, m.id, 0, @operator, NOW()
FROM app_module m FROM app_module m
WHERE m.app_id = @quick_app_id AND @grp_teacher > 0 AND COALESCE(m.custom_id,'') <> '303' -- 303=导入(1.5 预留位不授) WHERE m.app_id = @quick_app_id AND @grp_teacher > 0 AND COALESCE(m.custom_id,'') <> '303' -- 303=导入(1.5 预留位不授)
AND NOT EXISTS (SELECT 1 FROM sys_group_module_perm p WHERE p.group_id = @grp_teacher AND p.module_id = m.id); AND NOT EXISTS (SELECT 1 FROM sys_group_module_perm p WHERE p.group_id = @grp_teacher AND p.module_id = m.id);
INSERT INTO sys_group_module_perm (group_id, module_id, inherit_flag, create_user, create_time)
SELECT @grp_student, m.id, 0, @operator, NOW()
FROM app_module m
WHERE m.app_id = @quick_app_id AND @grp_student > 0 AND m.level = 1 AND m.path LIKE '/f/%'
AND NOT EXISTS (SELECT 1 FROM sys_group_module_perm p WHERE p.group_id = @grp_student AND p.module_id = m.id);
-- 3.5 ⚠ 过渡口径(T9 报告 §5.4 末段 / 问题单 P78 附注,需裁决后才可能取消这一步): -- 3.5 ⚠ 过渡口径(T9 报告 §5.4 末段 / 问题单 P78 附注,需裁决后才可能取消这一步):
-- 快应用的数据视图复用 /api/form/admin/data/**,而后端守卫扫的是【中心会话树】(签名调用方=表单中心自己), -- 快应用的数据视图复用 /api/form/admin/data/**,而后端守卫扫的是【中心会话树】(签名调用方=表单中心自己),
-- 不消费快应用自己的"数据查看"行。只勾快应用行的教师组进应用数据视图会被后端拒。 -- 不消费快应用自己的"数据查看"行。只勾快应用行的教师组进应用数据视图会被后端拒。
@@ -174,13 +179,19 @@ SELECT @grp_teacher, m.id, 0, @operator, NOW()
-- ⚠ 这条要 **H4 上线**(UserBase /oapi/user/modules 的可选 appCode 参数)+ 中心消费侧接线才通; -- ⚠ 这条要 **H4 上线**(UserBase /oapi/user/modules 的可选 appCode 参数)+ 中心消费侧接线才通;
-- 中心侧当前按 P3 返回空树+warn,见 T9 报告问题单 P78。 -- 中心侧当前按 P3 返回空树+warn,见 T9 报告问题单 P78。
-- ④教师组账号进 /form-center/app/fc-drill/data:导出按钮出现(快应用按钮锚点判定生效,P76) -- ④教师组账号进 /form-center/app/fc-drill/data:导出按钮出现(快应用按钮锚点判定生效,P76)
-- ⑤学生组账号:V16 门户可见性由开通闸门决定;进应用只见填报 -- ⑤学生组账号:V16 门户可见性由开通闸门决定;进应用的「填报」档看到该应用下**面向学生**的表单
-- (P95:这张清单来自 GET /api/form/my/fillable,与快应用树里有没有 `/f/` 行无关——
-- 删掉存量 `/f/` 行后门户导航、「可填写」列表、分享直链三路都不受影响,见 P95 报告收尾清单)
-- ⑥中心应用管理端进 /form-center/#/admin/app:应用设置页出两个 tab(角色设置/字典管理), -- ⑥中心应用管理端进 /form-center/#/admin/app:应用设置页出两个 tab(角色设置/字典管理),
-- 角色设置里加的人落 school_admin(数据范围升级,不是菜单行)——T10 V43/V46 -- 角色设置里加的人落 school_admin(数据范围升级,不是菜单行)——T10 V43/V46
-- ⑦P95 新判据(V52):快应用树里 `/f/` 行数=0(下面倒数第二条 chk),且中心段仍是 200~204 五行
-- ---------------------------------------------------------------------------- -- ----------------------------------------------------------------------------
SELECT '中心模块行数(期望 5)' AS chk, COUNT(*) AS n FROM app_module WHERE app_id = @center_app_id SELECT '中心模块行数(期望 5)' AS chk, COUNT(*) AS n FROM app_module WHERE app_id = @center_app_id
UNION ALL UNION ALL
SELECT '快应用模块行数(期望 5)', COUNT(*) FROM app_module WHERE app_id = @quick_app_id SELECT '快应用模块行数(P95 期望 4)', COUNT(*) FROM app_module WHERE app_id = @quick_app_id
UNION ALL
SELECT '快应用 /f/ 填报行数(P95 期望 0)', COUNT(*) FROM app_module
WHERE app_id = @quick_app_id AND path LIKE '/f/%'
UNION ALL UNION ALL
SELECT '中心 path 空行数(期望 0,锚点已退役)', COUNT(*) FROM app_module SELECT '中心 path 空行数(期望 0,锚点已退役)', COUNT(*) FROM app_module
WHERE app_id = @center_app_id AND (path IS NULL OR path = '') WHERE app_id = @center_app_id AND (path IS NULL OR path = '')
@@ -3,6 +3,7 @@ package com.yunhai.formcenter.controller.api;
import com.yunhai.formcenter.model.MyModels; import com.yunhai.formcenter.model.MyModels;
import com.yunhai.formcenter.model.ReadModels; import com.yunhai.formcenter.model.ReadModels;
import com.yunhai.formcenter.service.AnswerReadService; import com.yunhai.formcenter.service.AnswerReadService;
import com.yunhai.formcenter.service.FillableFormService;
import com.yunhai.platform.apistd.BizException; import com.yunhai.platform.apistd.BizException;
import com.yunhai.platform.utils.exception.ErrorCode; import com.yunhai.platform.utils.exception.ErrorCode;
import com.yunhai.platform.utils.model.BoaUser; import com.yunhai.platform.utils.model.BoaUser;
@@ -38,6 +39,7 @@ import java.util.List;
public class FormMyAnswerController { public class FormMyAnswerController {
private final AnswerReadService readService; private final AnswerReadService readService;
private final FillableFormService fillableService;
/** 我的答卷列表(按 submitter 过滤,"我的提交"段) */ /** 我的答卷列表(按 submitter 过滤,"我的提交"段) */
@GetMapping("/list") @GetMapping("/list")
@@ -96,6 +98,24 @@ public class FormMyAnswerController {
return readService.editBySubmitter(user, body.getAnswerId(), body.getValues(), body.getSummary()); return readService.editBySubmitter(user, body.getAnswerId(), body.getValues(), body.getSummary());
} }
/**
* 「可填写」列表(P95 W3,V50):<b>填报入口</b>清单,不是答卷清单——
* 域=会话校 ∪ 平台级且本校遮蔽平台、仅已发布未软删,逐行按 P95 三层模型过入口层,NONE 不入集。
*
* <p>这一把查询把 P91 那个"从会话菜单树挑 {@code /f/} 行"的前端推导整条换掉:
* 谁能填什么从此由中心按 {@code audience × 身份} 自解析,UserBase 侧不再需要逐表单配模块行
* (任务书 P95 §0)。<b>它不看模块行、也不看数据范围矩阵</b>:入口层与那两条线正交
* ({@code FillAccessService} 类注释记了三条线的分工)。</p>
*
* @param appCode 可选,缺省=全部应用;快应用上下文({@code /app/{appCode}/})传它自身以收窄清单
*/
@GetMapping("/fillable")
public List<MyModels.FillableRow> fillable(BoaUser user,
@RequestParam(value = "appCode", required = false) String appCode) {
requireSession(user);
return fillableService.listFillable(user, appCode);
}
private static void requireSession(BoaUser user) { private static void requireSession(BoaUser user) {
if (user == null || user.getId() == null) { if (user == null || user.getId() == null) {
throw new BizException(ErrorCode.UNAUTHORIZED, "未登录"); throw new BizException(ErrorCode.UNAUTHORIZED, "未登录");
@@ -45,4 +45,36 @@ public final class MyModels {
/** yyyy-MM-dd HH:mm:ss */ /** yyyy-MM-dd HH:mm:ss */
private String submitTime; private String submitTime;
} }
/**
* 一行"我可填的表单"(P95 W3,{@code GET /api/form/my/fillable}):填报<b>入口</b>清单的行形状。
*
* <p><b>它不是答卷行</b>:这一把查询还没写任何数据,只回答"这个人能进哪张表的填报页、
* 进完之后按 P95 三层模型他算哪一层"。所以字段是<b>表单</b>身份 + 两个入口层结论,
* 与 {@link ItemView} 没有共同祖先。</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>
*/
@Getter
@Setter
public static class FillableRow {
private String code;
private String name;
private String appCode;
/** 面向人群:1 学生 / 2 教师(契约 §10 列值) */
private Integer 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;
}
} }
@@ -43,4 +43,14 @@ public interface FormRepository extends JpaRepository<Form, Long> {
List<Form> findResolveCandidates(@Param("code") String code, List<Form> findResolveCandidates(@Param("code") String code,
@Param("appCode") String appCode, @Param("appCode") String appCode,
@Param("schIds") Collection<Long> schIds); @Param("schIds") Collection<Long> schIds);
/**
* 「我可填」列表取域(P95 W3):会话校 ∪ 平台级里<b>已发布且未软删</b>的表单头表。
*
* <p>{@code status} 写死在 SQL 而 app_code 过滤留在应用层,同 {@link #findLiveBySch} 的口径:
* 生命周期是这一把查询的语义(草稿/停用压根不是候选),而 app_code 是可选筛项,
* 可空参数在 MySQL 上的类型推断有坑(P10)。遮蔽去重与入口层过滤都在服务侧。</p>
*/
@Query("select f from Form f where f.schId in (:schIds) and f.isDeleted = 0 and f.status = 1")
List<Form> findPublishedInDomain(@Param("schIds") Collection<Long> schIds);
} }
@@ -25,19 +25,25 @@ import org.springframework.transaction.support.TransactionTemplate;
import java.time.LocalDate; import java.time.LocalDate;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter; import java.time.format.DateTimeFormatter;
import java.util.Set;
import java.util.UUID; import java.util.UUID;
/** /**
* 提交链路(契约 §5 / 方案 §5.4)。一次提交在这条流水线上依次过关: * 提交链路(契约 §5 / 方案 §5.4)。一次提交在这条流水线上依次过关:
* *
* <pre> * <pre>
* 钉版解析 → 截止 → 幂等命中 → subject 授权+存在性同校(P33) → 值校验(按钉住版本) * 钉版解析 → <b>第 0 关·填报入口层(P95)</b> → 截止 → 幂等命中 → subject 授权+存在性同校(P33)
* → 附件归属与 key 一致(P34) → 限次占位(fill_quota 撞键即拒) → 落 answer(写 form_uuid) * → 值校验(按钉住版本) → 附件归属与 key 一致(P34) → 限次占位(fill_quota 撞键即拒)
* → 写 answer_index(只写 searchable) * → 落 answer(写 form_uuid) → 写 answer_index(只写 searchable)
* </pre> * </pre>
* *
* <p><b>为什么顺序是这样</b>:</p> * <p><b>为什么顺序是这样</b>:</p>
* <ul> * <ul>
* <li><b>先鉴权后业务</b>(P95):入口层放在钉版之后(要 {@code form.audience} 才能判人群)、
* 截止之前(过期表单对无资格的人不该输出"已过期"这种业务信息)。
* 幂等重落在闸门<b>之后</b>,所以被闸门拒的人重放同样是 403——这是任务书 W2 明示可接受的取舍;
* 外层 {@code DataIntegrityViolationException} 的并发重放路径不再重复过闸(命中
* {@code belongsToCaller} 即返回原答卷);</li>
* <li>幂等检查必须在限次<b>之前</b>——重放的那一条本来就占着名额,先查限次会把双击重试误判成"超过次数";</li> * <li>幂等检查必须在限次<b>之前</b>——重放的那一条本来就占着名额,先查限次会把双击重试误判成"超过次数";</li>
* <li>限次用"事务内向 {@code fill_quota} 插入、撞唯一键即拒"而不是先 count 再插 * <li>限次用"事务内向 {@code fill_quota} 插入、撞唯一键即拒"而不是先 count 再插
* (方案 §5.4 v1.4:两个并发提交不可能都通过 count 检查);</li> * (方案 §5.4 v1.4:两个并发提交不可能都通过 count 检查);</li>
@@ -46,6 +52,12 @@ import java.util.UUID;
* "软删条目仍放行"的口径也就无法表达(契约 §2.1)。</li> * "软删条目仍放行"的口径也就无法表达(契约 §2.1)。</li>
* </ul> * </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>事务形态</b>:公开方法不带 {@code @Transactional},写库段用 {@link TransactionTemplate} 包。 * <p><b>事务形态</b>:公开方法不带 {@code @Transactional},写库段用 {@link TransactionTemplate} 包。
* 目的是让"幂等键撞 uk"这种并发结局能在<b>事务外</b>重新读取原答卷并按契约返回 200+原 answer_id * 目的是让"幂等键撞 uk"这种并发结局能在<b>事务外</b>重新读取原答卷并按契约返回 200+原 answer_id
* (撞键后当前事务已被标记回滚,事务内再读是不可靠的)。</p> * (撞键后当前事务已被标记回滚,事务内再读是不可靠的)。</p>
@@ -62,6 +74,7 @@ public class AnswerService {
private final FormDefService defService; private final FormDefService defService;
private final FormDictService dictService; private final FormDictService dictService;
private final FormAdminGuard adminGuard; private final FormAdminGuard adminGuard;
private final FillAccessService fillAccess;
private final SubjectDirectory subjectDirectory; private final SubjectDirectory subjectDirectory;
private final AnswerRepository answerRepository; private final AnswerRepository answerRepository;
private final FillQuotaRepository quotaRepository; private final FillQuotaRepository quotaRepository;
@@ -99,12 +112,16 @@ public class AnswerService {
Form form = published.form(); Form form = published.form();
String schemaJson = published.versionRow().getSchemaJson(); String schemaJson = published.versionRow().getSchemaJson();
// 2) 截止(datetime 粒度,过期接口拒绝,契约 §5) // 2) 第 0 关(P95 三层模型):填报入口层——钉版之后才有 audience 可判,截止之前先鉴权
Set<FillAccessService.Tier> tiers = fillAccess.resolve(user, form);
requireFillEntry(user, tiers, form, req.getSubjectId(), submitterId);
// 3) 截止(datetime 粒度,过期接口拒绝,契约 §5)
if (FormRuntimeRules.isDeadlineReached(form, LocalDateTime.now())) { if (FormRuntimeRules.isDeadlineReached(form, LocalDateTime.now())) {
throw new BizException(ErrorCode.CUSTOM, "已过收集截止时间,不再接收提交:" + form.getCode()); throw new BizException(ErrorCode.CUSTOM, "已过收集截止时间,不再接收提交:" + form.getCode());
} }
// 3) 幂等命中→200+原 answer_id(在限次之前,见类注释) // 4) 幂等命中→200+原 answer_id(在限次之前,见类注释)
Answer existing = answerRepository.findByIdempotencyKey(idempotencyKey).orElse(null); Answer existing = answerRepository.findByIdempotencyKey(idempotencyKey).orElse(null);
if (existing != null) { if (existing != null) {
if (!belongsToCaller(existing, user)) { if (!belongsToCaller(existing, user)) {
@@ -114,18 +131,18 @@ public class AnswerService {
return toView(existing, true); return toView(existing, true);
} }
// 4) subject 授权(方案 §6.3:普通用户只能=自己,有管理权者可代填他人)+ 存在性/同校(P33) // 5) subject 授权(P95 行为表:谁能代填由第 0 关的层决定)+ 存在性/同校(P33)
Integer subjectId = requireSubject(user, req.getSubjectId(), submitterId, schId); Integer subjectId = requireSubject(user, tiers, req.getSubjectId(), submitterId, schId);
// 5) 值校验(按钉住版本;字典取值域含软删条目)+ 附件归属 // 6) 值校验(按钉住版本;字典取值域含软删条目)+ 附件归属
String valuesJson = valuesToJson(req.getValues()); String valuesJson = valuesToJson(req.getValues());
FormValueValidator.validate(valuesJson, schemaJson, dictCode -> dictService.allowedValues(schId, dictCode)); FormValueValidator.validate(valuesJson, schemaJson, dictCode -> dictService.allowedValues(schId, dictCode));
assertAttachmentsOwned(schId, valuesJson, schemaJson); assertAttachmentsOwned(schId, valuesJson, schemaJson);
// 6) 限次占位(uk 是并发兜底) // 7) 限次占位(uk 是并发兜底)
String quotaKey = occupyQuota(form, subjectId, submitterId); String quotaKey = occupyQuota(form, subjectId, submitterId);
// 7) 落答卷 // 8) 落答卷
Answer answer = new Answer(); Answer answer = new Answer();
answer.setUuid(newUuid()); answer.setUuid(newUuid());
// P25:钉版溯源的精确坐标=resolvePublished 命中的那张表单(本校遮蔽平台时的胜出行) // P25:钉版溯源的精确坐标=resolvePublished 命中的那张表单(本校遮蔽平台时的胜出行)
@@ -147,7 +164,7 @@ public class AnswerService {
// 若这一步失败,同事务内的 fill_quota 占位一起回滚,不残留"名额被占但无答卷"的脏状态 // 若这一步失败,同事务内的 fill_quota 占位一起回滚,不残留"名额被占但无答卷"的脏状态
answer = answerRepository.saveAndFlush(answer); answer = answerRepository.saveAndFlush(answer);
// 8) 索引:只写 searchable 字段(V3/V9) // 9) 索引:只写 searchable 字段(V3/V9)
indexService.write(answer, schemaJson); indexService.write(answer, schemaJson);
log.info("[提交] answer={} form={}/{} v{} submitter={} subject={} quotaKey={}", log.info("[提交] answer={} form={}/{} v{} submitter={} subject={} quotaKey={}",
@@ -158,16 +175,45 @@ public class AnswerService {
// ==================== 各环节 ==================== // ==================== 各环节 ====================
private Integer requireSubject(BoaUser user, Integer subjectId, Integer submitterId, Long schId) { /**
* 第 0 关(P95 W2 行为表):入口层判"这个身份在这张表单上到底能不能写"。
*
* <p>两条拒绝各有独立文案,因为它们是两种不同的用户动作失败了:
* <b>NONE</b>=压根不在面向人群里(家长/工作号/局领导、学生填教师问卷),直链进来也是 403;
* <b>仅 MANAGE 的自填</b>=人在人群口径里、但档位只给代填不给自填(教师×学生面向),
* 文案要告诉他"改成代填学生就能提交",否则这条 403 无从下手。</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) + " 不在可填报人群内"
+ "(可填=面向人群本人;教师可代填本校学生;管理员不受限)");
}
boolean proxy = subjectId != null && !subjectId.equals(submitterId);
if (!proxy && !fillAccess.canFill(tiers)) {
throw new BizException(ErrorCode.FORBIDDEN, "教师不能以本人身份填报学生面向表单,可代填本校学生"
+ "(subjectId 传被代填学生 id):表单「" + form.getCode() + "」面向" + audienceLabel(form));
}
}
private Integer requireSubject(BoaUser user, Set<FillAccessService.Tier> tiers, Integer subjectId,
Integer submitterId, Long schId) {
if (subjectId == null || subjectId.equals(submitterId)) { if (subjectId == null || subjectId.equals(submitterId)) {
return subjectId; return subjectId;
} }
if (fillAccess.isAdmin(tiers)) {
// 层③的代填仍走 canManage 现路径(P95 红线 3"canManage 代填路径原样"):
// 入口层把 ADMIN 判出来不等于给一条比现状更宽的写通道——没被授权管理模块行的人照旧拒
if (!adminGuard.canManage(user)) { if (!adminGuard.canManage(user)) {
throw new BizException(ErrorCode.FORBIDDEN, throw new BizException(ErrorCode.FORBIDDEN, forbiddenProxy(submitterId, subjectId));
"无权为他人填报:subjectId 只能等于本人(当前 " + submitterId + ",请求 " + subjectId + ")"); }
} else if (!fillAccess.canProxy(tiers)) {
// 层①普通成员(学生×学生面向、教师×教师面向)代填=拒,与 P95 之前的现行为逐字同文案
throw new BizException(ErrorCode.FORBIDDEN, forbiddenProxy(submitterId, subjectId));
} }
// P33:代填=把数据挂到别人名下,"关于我的"视图(V18)与按 subject 限次都建立在"id 真实且同校"上。 // P33:代填=把数据挂到别人名下,"关于我的"视图(V18)与按 subject 限次都建立在"id 真实且同校"上。
// 本人自填不查(会话里的人本来就存在),只验代填那一个 id。 // 本人自填不查(会话里的人本来就存在),只验代填那一个 id。层②与层③共用这一条(红线 3)。
if (!subjectDirectory.existsInSchool(subjectId, schId)) { if (!subjectDirectory.existsInSchool(subjectId, schId)) {
throw new BizException(ErrorCode.PARAM_ERROR, throw new BizException(ErrorCode.PARAM_ERROR,
"被代填人不存在或不属于本校:subjectId=" + subjectId + ",schId=" + schId); "被代填人不存在或不属于本校:subjectId=" + subjectId + ",schId=" + schId);
@@ -175,6 +221,24 @@ public class AnswerService {
return subjectId; return subjectId;
} }
private static String forbiddenProxy(Integer submitterId, Integer subjectId) {
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());
if (user.getCurrentRole() != null && user.getCurrentRole().getRoleId() != null) {
sb.append("(当前=").append(user.getCurrentRole().getRoleId()).append(")");
}
return sb.toString();
}
/** 返回占住的 limit_key(null=该表单不限次,未占位) */ /** 返回占住的 limit_key(null=该表单不限次,未占位) */
private String occupyQuota(Form form, Integer subjectId, Integer submitterId) { private String occupyQuota(Form form, Integer subjectId, Integer submitterId) {
if (!FormEnums.FillLimit.once(form.getFillLimit())) { if (!FormEnums.FillLimit.once(form.getFillLimit())) {
@@ -0,0 +1,173 @@
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.ArrayList;
import java.util.Collections;
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 {
/** 超管用户类型(同 {@link FormAdminGuard}/{@link DataScopeService}:不是 roleId,是 UserBase 的用户类型) */
private static final int SUPER_ADMIN_TYPE = 1;
private static final int ROLE_TEACHER = 1;
private static final int ROLE_SCHOOL_LEADER = 2;
private static final int ROLE_STUDENT = 3;
private static final int ROLE_ADMIN = 7;
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 = identityRoleIds(user);
if (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(ROLE_TEACHER)) {
tiers.add(Tier.MANAGE);
}
if (studentFacing ? roleIds.contains(ROLE_STUDENT) : roleIds.contains(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 = sessionSchId(user);
if (ownSch != null && adminScopeSource.isSchoolAdmin(ownSch, user.getId())) {
return true;
}
return form != null && adminScopeSource.isFormAdmin(form.getUuid(), user.getId());
}
/** 校领导(2)/管理员(7):平台标准角色里的管理身份(契约 §8 矩阵那两行同一个因子) */
private static boolean holdsAdminRole(List<Integer> roleIds) {
return roleIds.contains(ROLE_ADMIN) || roleIds.contains(ROLE_SCHOOL_LEADER);
}
/** 参与解析的身份:{@code roleIds}(全部正常态角色)并上会话 {@code currentRole.roleId}(同 DataScopeService) */
private static List<Integer> identityRoleIds(BoaUser user) {
List<Integer> ids = new ArrayList<>();
if (user.getRoleIds() != null) {
ids.addAll(user.getRoleIds());
}
Integer current = user.getCurrentRole() == null ? null : user.getCurrentRole().getRoleId();
if (current != null && !ids.contains(current)) {
ids.add(current);
}
return ids.isEmpty() ? Collections.<Integer>emptyList() : ids;
}
private static boolean isSuperAdmin(BoaUser user) {
return user.getType() != null && user.getType() == SUPER_ADMIN_TYPE;
}
private static Long sessionSchId(BoaUser user) {
Integer schId = user.getCurrentRole() == null ? null : user.getCurrentRole().getSchId();
return schId == null ? null : Long.valueOf(schId);
}
}
@@ -0,0 +1,135 @@
package com.yunhai.formcenter.service;
import com.yunhai.formcenter.entity.Form;
import com.yunhai.formcenter.model.MyModels;
import com.yunhai.formcenter.repository.FormRepository;
import com.yunhai.platform.apistd.BizException;
import com.yunhai.platform.utils.exception.ErrorCode;
import com.yunhai.platform.utils.model.BoaUser;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.Comparator;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
/**
* 「我可填」清单解析(P95 W3,{@code GET /api/form/my/fillable}):把"这个会话用户能进哪些表单的填报页"
* 算成一张列表——P91 那个列表的数据源,从此<b>不再吃 UserBase 模块树</b>。
*
* <p><b>四步,一步不多</b>:</p>
* <ol>
* <li><b>取域</b>:会话校 ∪ 平台级({@code sch_id=0}),只认 {@code status=1} 已发布且未软删的头表;</li>
* <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>排序</b>:deadline 升序、null(不限)在后、同档按 code。</li>
* </ol>
*
* <p><b>不分页</b>:一所学校 + 平台级里可填的表单是个位数到几十张(P95 W3 的量级假设,报告已记),
* 一张轻列表不值得引入游标;返回值就是裸数组,不带 total——将来真长大了加分页时不破坏"行形状"这条契约。</p>
*
* <p><b>与数据面的关系</b>:本类只回答"入口给谁开",不看 {@link DataScopeService}。
* 列表里出现某张表单,不代表进去能看到别人的行(层②教师看到的仍是仅本人提交)。</p>
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class FillableFormService {
private static final DateTimeFormatter TS = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
private final FormRepository formRepository;
private final FillAccessService fillAccess;
/**
* 我可填清单。
*
* @param appCode 可选;空/缺省=全部应用(中心 + 所有快应用),非空=只要该 app_code 下的表单
* @return 已按 deadline/code 排好序的行集;无权限的人拿到的是空集而不是异常
*/
public List<MyModels.FillableRow> listFillable(BoaUser user, String appCode) {
Long schId = requireSessionSchId(user);
String cleanApp = appCode == null ? "" : appCode.trim();
List<Long> domain = Form.PLATFORM_SCH_ID == schId
? Collections.singletonList(Form.PLATFORM_SCH_ID)
: Arrays.asList(schId, Form.PLATFORM_SCH_ID);
// 先按 (app_code, code) 分组,再逐组走遮蔽规则——resolve 是"一个 code 一次判定",
// 列表要把同一条判定摊到多个 code 上,分组就是这条摊法(不是另写一份遮蔽逻辑)
Map<String, List<Form>> groups = new LinkedHashMap<>();
for (Form form : formRepository.findPublishedInDomain(domain)) {
if (!cleanApp.isEmpty() && !cleanApp.equals(form.getAppCode())) {
continue;
}
String key = form.getAppCode() + "#" + form.getCode();
List<Form> bucket = groups.get(key);
if (bucket == null) {
bucket = new ArrayList<>();
groups.put(key, bucket);
}
bucket.add(form);
}
List<Form> visible = new ArrayList<>();
for (List<Form> bucket : groups.values()) {
Form winner = FormDefService.pickByShadowing(bucket, schId);
if (winner == null) {
continue;
}
if (winner.getCurrentVersion() == null) {
// status=1 却没有版本指针=定义坏状态,resolve 会以"当前发布版本行缺失"拒绝,
// 这里同样不给入口(宁缺勿滥:列表里出现点了报 500 的表单比少一张更糟)
log.warn("[我可填] 表单 {}/{} 已发布但当前版本指针为空,跳过", winner.getAppCode(), winner.getCode());
continue;
}
visible.add(winner);
}
Collections.sort(visible, Comparator
.comparing((Form form) -> form.getDeadline() == null ? LocalDateTime.MAX : form.getDeadline())
.thenComparing(form -> form.getCode() == null ? "" : form.getCode()));
List<MyModels.FillableRow> rows = new ArrayList<>();
for (Form form : visible) {
Set<FillAccessService.Tier> tiers = fillAccess.resolve(user, form);
if (tiers.isEmpty()) {
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);
}
return rows;
}
/** 会话校(同 {@code AnswerService} 的口径:未登录与无学校归属两段分开拒,文案与 my 面其余端点一致) */
private static Long requireSessionSchId(BoaUser user) {
if (user == null || user.getId() == null) {
throw new BizException(ErrorCode.UNAUTHORIZED, "未登录");
}
if (user.getCurrentRole() == null || user.getCurrentRole().getSchId() == null) {
throw new BizException(ErrorCode.FORBIDDEN, "当前身份无学校归属");
}
return Long.valueOf(user.getCurrentRole().getSchId());
}
}
@@ -656,8 +656,11 @@ public class FormDefService {
/** 遮蔽规则(U5):同 (app_code, code) 本校与平台级都在时,本校胜出且不回落—— /** 遮蔽规则(U5):同 (app_code, code) 本校与平台级都在时,本校胜出且不回落——
* 本校行哪怕处于草稿/停用,也不让请求穿透到平台级(否则"本校遮蔽"会变成可绕过的建议)。 * 本校行哪怕处于草稿/停用,也不让请求穿透到平台级(否则"本校遮蔽"会变成可绕过的建议)。
* 兜底只认 sch_id=0:取域已限定在本校 ∪ 平台级,多加一层判断是为了让"他校行"永远不可能被当兜底。 */ * 兜底只认 sch_id=0:取域已限定在本校 ∪ 平台级,多加一层判断是为了让"他校行"永远不可能被当兜底。
private Form pickByShadowing(List<Form> candidates, Long schId) { *
* <p>P95 起 {@code FillableFormService} 也要这条规则(「我可填」列表按 (app_code, code) 分组后逐组判),
* 所以把它收成<b>包内静态方法</b>而不是复制一份——两条链路共用一条遮蔽实现(同 P20 的取舍)。</p> */
static Form pickByShadowing(List<Form> candidates, Long schId) {
Form platformFallback = null; Form platformFallback = null;
for (Form form : candidates) { for (Form form : candidates) {
if (form.getSchId() == null) { if (form.getSchId() == null) {
@@ -58,6 +58,8 @@ class AdminScopeIT extends AnswerTestSupport {
private static final String QUICK_CODE = "t10_quick_drill"; private static final String QUICK_CODE = "t10_quick_drill";
/** 快应用面(方案 §6.2):中心之外的 app_code,用来证"本校快应用表单也被校级管理员覆盖" */ /** 快应用面(方案 §6.2):中心之外的 app_code,用来证"本校快应用表单也被校级管理员覆盖" */
private static final String QUICK_APP = "fc-drill"; 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\":[" private static final String SCHEMA = "{\"groups\":[{\"key\":\"base\",\"label\":\"基本信息\",\"fields\":["
+ "{\"key\":\"name\",\"label\":\"姓名\",\"type\":\"text\",\"required\":true,\"searchable\":true}]}]}"; + "{\"key\":\"name\",\"label\":\"姓名\",\"type\":\"text\",\"required\":true,\"searchable\":true}]}]}";
@@ -202,17 +204,31 @@ class AdminScopeIT extends AnswerTestSupport {
seed.quickUuid = publishQuickForm(QUICK_APP, QUICK_CODE); seed.quickUuid = publishQuickForm(QUICK_APP, QUICK_CODE);
seed.baseByStudent = submit(user(ReadFixture.STUDENT_1, SCH, 3), BASE_CODE, "本校学生甲"); seed.baseByStudent = submit(user(ReadFixture.STUDENT_1, SCH, 3), BASE_CODE, "本校学生甲");
seed.baseByTeacher = submit(user(ReadFixture.TEACHER_1, SCH, 1), BASE_CODE, "本校教师乙"); seed.baseByTeacher = submitByTeacherProxy(user(ReadFixture.TEACHER_1, SCH, 1), BASE_CODE, "本校教师乙");
seed.baseAtOtherSchool = submit(user(ReadFixture.STUDENT_2, OTHER_SCH, 3), BASE_CODE, "他校学生丙"); seed.baseAtOtherSchool = submit(user(ReadFixture.STUDENT_2, OTHER_SCH, 3), BASE_CODE, "他校学生丙");
seed.baseLegacyNull = insertLegacyAnswer(seed.baseUuid, "存量没有学校列的行"); seed.baseLegacyNull = insertLegacyAnswer(seed.baseUuid, "存量没有学校列的行");
seed.ownByStudent = submit(user(ReadFixture.STUDENT_1, SCH, 3), OWN_CODE, "自己学校的行"); seed.ownByStudent = submit(user(ReadFixture.STUDENT_1, SCH, 3), OWN_CODE, "自己学校的行");
seed.ownByTeacher = submit(user(ReadFixture.TEACHER_1, SCH, 1), OWN_CODE, "老师填的行"); seed.ownByTeacher = submitByTeacherProxy(user(ReadFixture.TEACHER_1, SCH, 1), OWN_CODE, "老师填的行");
seed.quickByStudent = submitQuick(user(ReadFixture.STUDENT_1, SCH, 3), "快应用的行"); seed.quickByStudent = submitQuick(user(ReadFixture.STUDENT_1, SCH, 3), "快应用的行");
return seed; return seed;
} }
/**
* P95 第 0 关之后,教师×面向学生表单<b>只能代填本校学生</b>(W2 行为表层②那一行),
* 以本人身份自填会被拒。本类要的是"教师提交过的行"这个事实(submitter=教师),
* 所以造它就走代填路径——读侧矩阵断言(仅本人提交=submitter 谓词)吃的还是同一个谓词。
*
* <p>被代填人刻意用 {@code PROXY_SUBJECT} 这个新 id:它不出现在任何"关于我"断言里,
* 换 subject 不会把学生视角的可见集改大(V43"未命中=矩阵默认一格不变"依赖这点)。</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();
}
/** 造一条 T10 之前的存量行(submitter_sch_id=NULL):新链路的提交一定会落值,只有直接入库才出得来 */ /** 造一条 T10 之前的存量行(submitter_sch_id=NULL):新链路的提交一定会落值,只有直接入库才出得来 */
private String insertLegacyAnswer(String formUuid, String name) { private String insertLegacyAnswer(String formUuid, String name) {
Answer answer = new Answer(); Answer answer = new Answer();
@@ -100,7 +100,7 @@ class AnswerConcurrencyIT extends AnswerTestSupport {
Outcome[] outcomes = runTwo(() -> { Outcome[] outcomes = runTwo(() -> {
try { try {
String answerId = answerService.submit(user(70001, SCH_C, 1), submit(code, VALUES)) String answerId = answerService.submit(user(70001, SCH_C, 3), submit(code, VALUES))
.getAnswerId(); .getAnswerId();
return new Outcome(answerId, null); return new Outcome(answerId, null);
} catch (BizException e) { } catch (BizException e) {
@@ -130,7 +130,7 @@ class AnswerConcurrencyIT extends AnswerTestSupport {
Outcome[] outcomes = runTwo(() -> { Outcome[] outcomes = runTwo(() -> {
try { try {
return new Outcome(answerService.submit(user(70002, SCH_C, 1), return new Outcome(answerService.submit(user(70002, SCH_C, 3),
submitWithKey(code, key, VALUES)).getAnswerId(), null); submitWithKey(code, key, VALUES)).getAnswerId(), null);
} catch (BizException e) { } catch (BizException e) {
return new Outcome(null, e.getMessage()); return new Outcome(null, e.getMessage());
@@ -1,6 +1,7 @@
package com.yunhai.formcenter.service; package com.yunhai.formcenter.service;
import com.yunhai.formcenter.entity.FormFile; import com.yunhai.formcenter.entity.FormFile;
import com.yunhai.formcenter.model.AnswerModels;
import com.yunhai.formcenter.model.ReadModels; import com.yunhai.formcenter.model.ReadModels;
import com.yunhai.formcenter.repository.FormFileRepository; import com.yunhai.formcenter.repository.FormFileRepository;
import com.yunhai.platform.apistd.BizException; import com.yunhai.platform.apistd.BizException;
@@ -153,7 +154,11 @@ class AnswerExportIT extends AnswerTestSupport {
// ==================== 数据准备 ==================== // ==================== 数据准备 ====================
/** /**
* 建表单并发布,管理员身份写 {@code total} 条答卷,其中前 3 条由教师1 提交(范围用例的"他可见集")。 * 建表单并发布,管理员身份写 {@code total} 条答卷,其中前 3 条由教师1 <b>代填</b>提交
* (范围用例的"他可见集"=submitter=教师1 的那三条)。
*
* <p>P95 第 0 关之后,教师×面向学生表单以本人身份自填会被拒(行为表层②),所以这三条走代填路径:
* 谓词列还是 {@code submitter_id},"仅本人提交"的可见集与导出行数断言一字未改。</p>
* *
* @return 表单 uuid * @return 表单 uuid
*/ */
@@ -176,7 +181,12 @@ class AnswerExportIT extends AnswerTestSupport {
+ "\"subjects\":[\"phy\",\"math\"]," + "\"subjects\":[\"phy\",\"math\"],"
+ "\"cert\":[{\"id\":" + file.getId() + ",\"fileName\":\"" + file.getFileName() + "\"cert\":[{\"id\":" + file.getId() + ",\"fileName\":\"" + file.getFileName()
+ "\",\"key\":\"" + file.getStoreKey() + "\"}]}"; + "\",\"key\":\"" + file.getStoreKey() + "\"}]}";
answerService.submit(i < 3 ? teacher : admin, submit(FORM, values)); AnswerModels.SubmitRequest request = submit(FORM, values);
if (i < 3) {
// P95:教师在这一列人群上只有代填档,代填一个不参与任何"关于我"断言的 id
request.setSubjectId(ReadFixture.TEACHER_PROXY_1);
}
answerService.submit(i < 3 ? teacher : admin, request);
} }
return formUuid; return formUuid;
} }
@@ -0,0 +1,289 @@
package com.yunhai.formcenter.service;
import com.yunhai.formcenter.entity.Answer;
import com.yunhai.formcenter.model.AnswerModels;
import com.yunhai.formcenter.model.FormModels;
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 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} 先例)。
*
* <p>行为表三档 × 自填/代填 = 六个格子,加上"代填仍受 P33 管"和"闸门排在截止与幂等之前"两条顺序断言。
* 桩只顶掉 {@link SubjectDirectory}(UserBase 的人)与 {@link ModuleTreeSource}(模块授权树),
* 管理员两表用真行({@link #grantSchoolAdmin}/{@link #grantFormAdmin})——入口层与读侧共用同一个事实源,
* 用桩顶掉就等于没验接线。</p>
*/
@SpringBootTest
@Import(AnswerTestSupport.TestGatewayConfiguration.class)
@Transactional
@DisplayName("AnswerService - P95 第 0 关与教师代填(V49)")
class AnswerFillGateIT extends AnswerTestSupport {
private static final String SCHEMA = "{\"groups\":[{\"key\":\"base\",\"label\":\"基本信息\",\"fields\":["
+ "{\"key\":\"name\",\"label\":\"姓名\",\"type\":\"text\",\"required\":true},"
+ "{\"key\":\"grade\",\"label\":\"年级\",\"type\":\"select\",\"required\":true,"
+ "\"options\":[{\"label\":\"高二\",\"value\":\"g2\"},{\"label\":\"高三\",\"value\":\"g3\"}]}]}]}";
private static final String VALUES = "{\"name\":\"张三\",\"grade\":\"g2\"}";
private static final int STUDENT = 20265001;
private static final int TEACHER = 20265002;
private static final int CLASSMATE = 20265003;
// ==================== 层① FILL:本人可填、代填仍拒 ====================
@Test
@DisplayName("V49 层①:学生×面向学生——自填放行;代填他人=拒(现行为文案逐字保留)")
void studentSelfFillPassesProxyRejected() {
String code = "p95_fill_self";
publishForm(code, SCHEMA);
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))
.isInstanceOf(BizException.class)
.hasMessageContaining("无权为他人填报:subjectId 只能等于本人");
}
@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";
publishForm(code, SCHEMA);
assertThatThrownBy(() -> answerService.submit(user(TEACHER, SCH, 1), submit(code, VALUES)))
.isInstanceOfSatisfying(BizException.class, e -> {
assertThat(e.getErrorCode()).isEqualTo(ErrorCode.FORBIDDEN);
assertThat(e.getMessage())
.contains("教师不能以本人身份填报学生面向表单,可代填本校学生")
.contains(code);
});
}
@Test
@DisplayName("V49 层②:教师×面向学生代填本校学生=放行(P95 净新增能力),且落值满足校域不变式")
void teacherProxyForSchoolStudentPasses() {
String code = "p95_manage_proxy";
String uuid = publishForm(code, SCHEMA);
subjects.markSchool(CLASSMATE, SCH);
AnswerModels.SubmitRequest req = submit(code, VALUES);
req.setSubjectId(CLASSMATE);
String answerId = answerService.submit(user(TEACHER, SCH, 1), req).getAnswerId();
Answer stored = answerRepository.findByUuidAndSchId(answerId, SCH).orElse(null);
assertThat(stored).isNotNull();
// 红线 4:answer.sch_id 恒=会话校,代填不改域(P33/P80 口径不回退)
assertThat(stored.getSchId()).isEqualTo(SCH);
assertThat(stored.getSubmitterId()).isEqualTo(TEACHER);
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);
Integer missing = 20265999;
subjects.markMissing(missing);
AnswerModels.SubmitRequest toMissing = submit(code, VALUES);
toMissing.setSubjectId(missing);
assertThatThrownBy(() -> answerService.submit(user(TEACHER, SCH, 1), toMissing))
.isInstanceOfSatisfying(BizException.class, e -> {
assertThat(e.getErrorCode()).isEqualTo(ErrorCode.PARAM_ERROR);
assertThat(e.getMessage()).contains("被代填人不存在或不属于本校");
});
Integer foreign = 20265888;
subjects.markSchool(foreign, OTHER_SCH);
AnswerModels.SubmitRequest toForeign = submit(code, VALUES);
toForeign.setSubjectId(foreign);
assertThatThrownBy(() -> answerService.submit(user(TEACHER, SCH, 1), toForeign))
.isInstanceOf(BizException.class).hasMessageContaining("被代填人不存在或不属于本校");
}
// ==================== 层③ ADMIN:可填 + 代填沿 canManage 现路径 ====================
@Test
@DisplayName("V49 层③:管理员(7)×面向学生自填与代填都放行(P95 之前就能填能代填,一格不减)")
void adminFillsAndProxies() {
String code = "p95_admin";
publishForm(code, SCHEMA);
Integer adminId = 10001;
assertThat(answerService.submit(user(adminId, SCH, 7), submit(code, VALUES)).getAnswerId()).isNotBlank();
AnswerModels.SubmitRequest proxy = submit(code, "{\"name\":\"李四\",\"grade\":\"g3\"}");
proxy.setSubjectId(CLASSMATE);
assertThat(answerService.submit(user(adminId, SCH, 7), proxy).getAnswerId()).isNotBlank();
}
@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))
.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,不是'已过收集截止时间'(先鉴权后业务)")
void gateRunsBeforeDeadline() {
String code = "p95_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("不在可填报人群内");
}
@Test
@DisplayName("V49 顺序:被闸门拒的人重放同一幂等键仍是 403(闸门在幂等之前,任务书 W2 明示的取舍)")
void rejectedReplayIsStillForbidden() {
String code = "p95_gate_replay";
publishForm(code, SCHEMA);
String key = newIdemKey();
assertThatThrownBy(() -> answerService.submit(user(20265202, SCH, 4), submitWithKey(code, key, VALUES)))
.isInstanceOf(BizException.class).hasMessageContaining("不在可填报人群内");
assertThatThrownBy(() -> answerService.submit(user(20265202, SCH, 4), submitWithKey(code, key, VALUES)))
.isInstanceOf(BizException.class).hasMessageContaining("不在可填报人群内");
assertThat(answerRepository.findBySchIdAndAppCodeAndCodeOrderByIdAsc(SCH, "form-center", code)).isEmpty();
}
// ==================== 既有环节不回退(代表性用例) ====================
@Test
@DisplayName("V49 后半:闸门不吃既有环节——限一次、值校验、钉版顺序与语义原样")
void existingStagesAreUntouched() {
String code = "p95_stages";
String uuid = publishForm(code, SCHEMA);
FormModels.FormConfigRequest once = new FormModels.FormConfigRequest();
once.setFillLimit(com.yunhai.formcenter.enums.FormEnums.FillLimit.ONCE.getCode());
config(SCH, uuid, once);
BoaUser student = user(STUDENT, SCH, 3);
assertThat(answerService.submit(student, 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 未登录/无学校归属两段的顺序不回退:先要得到会话,再谈入口层")
void sessionChecksStillFirst() {
String code = "p95_session";
publishForm(code, SCHEMA);
assertThatThrownBy(() -> answerService.submit(null, submit(code, VALUES)))
.isInstanceOf(BizException.class).hasMessageContaining("未登录");
BoaUser noSchool = new BoaUser();
noSchool.setId(20265301);
noSchool.setRoleIds(java.util.Collections.singletonList(3));
assertThatThrownBy(() -> answerService.submit(noSchool, submit(code, VALUES)))
.isInstanceOf(BizException.class).hasMessageContaining("无学校归属");
}
}
@@ -106,10 +106,10 @@ class AnswerReadIT extends AnswerTestSupport {
} }
assertThat(pages).as("五行未删数据按 size=2 应翻 3 页").isEqualTo(3); assertThat(pages).as("五行未删数据按 size=2 应翻 3 页").isEqualTo(3);
assertThat(paged).containsExactlyInAnyOrder(rows.student1Self, rows.student2Self, assertThat(paged).containsExactlyInAnyOrder(rows.student1Self, rows.student2Self,
rows.proxyForStudent1, rows.teacher1Self, rows.teacher2Self); rows.proxyForStudent1, rows.teacher1Row, rows.teacher2Row);
// 游标用别人范围内的 uuid:不可见即拒(PARAM_ERROR,而不是静默从头开始) // 游标用别人范围内的 uuid:不可见即拒(PARAM_ERROR,而不是静默从头开始)
String foreign = rows.teacher1Self; String foreign = rows.teacher1Row;
ReadModels.ListRequest probe = listRequest(rows.formAUuid); ReadModels.ListRequest probe = listRequest(rows.formAUuid);
probe.setCursor(foreign); probe.setCursor(foreign);
probe.setSize(2); probe.setSize(2);
@@ -128,10 +128,10 @@ class AnswerReadIT extends AnswerTestSupport {
assertThat(visibleIds(fixture, manager(), rows.formAUuid)) assertThat(visibleIds(fixture, manager(), rows.formAUuid))
.containsExactlyInAnyOrder(rows.student1Self, rows.student2Self, rows.proxyForStudent1, .containsExactlyInAnyOrder(rows.student1Self, rows.student2Self, rows.proxyForStudent1,
rows.teacher1Self, rows.teacher2Self); rows.teacher1Row, rows.teacher2Row);
// 教师×面向学生:一期实际生效=仅本人提交(方案 §6.4 表格明示,防被当 bug 报) // 教师×面向学生:一期实际生效=仅本人提交(方案 §6.4 表格明示,防被当 bug 报)
assertThat(visibleIds(fixture, fixture.teacher1(), rows.formAUuid)) assertThat(visibleIds(fixture, fixture.teacher1(), rows.formAUuid))
.containsExactly(rows.teacher1Self); .containsExactly(rows.teacher1Row);
// 学生×面向学生=关于我:自填那条 subject 为空看不见,老师代填的看得见(契约 §6.3 两键分立的直接体现) // 学生×面向学生=关于我:自填那条 subject 为空看不见,老师代填的看得见(契约 §6.3 两键分立的直接体现)
assertThat(visibleIds(fixture, fixture.student1(), rows.formAUuid)) assertThat(visibleIds(fixture, fixture.student1(), rows.formAUuid))
.containsExactly(rows.proxyForStudent1); .containsExactly(rows.proxyForStudent1);
@@ -145,7 +145,7 @@ class AnswerReadIT extends AnswerTestSupport {
ReadFixture fixture = new ReadFixture(this); ReadFixture fixture = new ReadFixture(this);
ReadFixture.Rows rows = fixture.seed(manager()); ReadFixture.Rows rows = fixture.seed(manager());
BoaUser teacher = fixture.teacher1(); BoaUser teacher = fixture.teacher1();
assertThat(visibleIds(fixture, teacher, rows.formAUuid)).containsExactly(rows.teacher1Self); assertThat(visibleIds(fixture, teacher, rows.formAUuid)).containsExactly(rows.teacher1Row);
grantSchoolAdmin(ReadFixture.TEACHER_1, SCH); grantSchoolAdmin(ReadFixture.TEACHER_1, SCH);
ReadModels.ListView upgraded = readService.list(teacher, SCH, listRequest(rows.formAUuid)); ReadModels.ListView upgraded = readService.list(teacher, SCH, listRequest(rows.formAUuid));
@@ -237,7 +237,7 @@ class AnswerReadIT extends AnswerTestSupport {
filter("advisor", "李", null, null)); filter("advisor", "李", null, null));
assertThat(hit).containsExactlyInAnyOrder(rows.student1Self, rows.proxyForStudent1); assertThat(hit).containsExactlyInAnyOrder(rows.student1Self, rows.proxyForStudent1);
assertThat(visibleIdsWithFilter(fixture, admin, rows.formAUuid, filter("advisor", "张老师", null, null))) assertThat(visibleIdsWithFilter(fixture, admin, rows.formAUuid, filter("advisor", "张老师", null, null)))
.containsExactlyInAnyOrder(rows.teacher1Self, rows.teacher2Self); .containsExactlyInAnyOrder(rows.teacher1Row, rows.teacher2Row);
} }
@Test @Test
@@ -276,7 +276,7 @@ class AnswerReadIT extends AnswerTestSupport {
// 等值:选项存 value,展示 label 变了也不影响筛选(V2 同族) // 等值:选项存 value,展示 label 变了也不影响筛选(V2 同族)
assertThat(visibleIdsWithFilter(fixture, manager(), rows.formAUuid, filter("grade", "g2", null, null))) assertThat(visibleIdsWithFilter(fixture, manager(), rows.formAUuid, filter("grade", "g2", null, null)))
.containsExactlyInAnyOrder(rows.student1Self, rows.proxyForStudent1, rows.teacher2Self); .containsExactlyInAnyOrder(rows.student1Self, rows.proxyForStudent1, rows.teacher2Row);
// 前缀模糊(且不是 LIKE 注入:传 "%" 不会命中全部) // 前缀模糊(且不是 LIKE 注入:传 "%" 不会命中全部)
assertThat(visibleIdsWithFilter(fixture, manager(), rows.formAUuid, filter("name", "张", null, null))) assertThat(visibleIdsWithFilter(fixture, manager(), rows.formAUuid, filter("name", "张", null, null)))
.containsExactlyInAnyOrder(rows.student1Self, rows.proxyForStudent1); .containsExactlyInAnyOrder(rows.student1Self, rows.proxyForStudent1);
@@ -288,7 +288,7 @@ class AnswerReadIT extends AnswerTestSupport {
// 日期区间按日期比,不是字典序(V4:2010-03-09 落在 2010-01-01~2010-06-01 内) // 日期区间按日期比,不是字典序(V4:2010-03-09 落在 2010-01-01~2010-06-01 内)
assertThat(visibleIdsWithFilter(fixture, manager(), rows.formAUuid, assertThat(visibleIdsWithFilter(fixture, manager(), rows.formAUuid,
range("birthday", "2010-01-01", "2010-06-01"))) range("birthday", "2010-01-01", "2010-06-01")))
.containsExactlyInAnyOrder(rows.student1Self, rows.proxyForStudent1, rows.teacher2Self); .containsExactlyInAnyOrder(rows.student1Self, rows.proxyForStudent1, rows.teacher2Row);
ReadModels.ListRequest unknown = listRequest(rows.formAUuid); ReadModels.ListRequest unknown = listRequest(rows.formAUuid);
unknown.setFilters(Arrays.asList(filter("remark_not_exists", "x", null, null))); unknown.setFilters(Arrays.asList(filter("remark_not_exists", "x", null, null)));
@@ -395,16 +395,16 @@ class AnswerReadIT extends AnswerTestSupport {
ReadFixture fixture = new ReadFixture(this); ReadFixture fixture = new ReadFixture(this);
ReadFixture.Rows rows = fixture.seed(manager()); ReadFixture.Rows rows = fixture.seed(manager());
assertThatThrownBy(() -> readService.detail(fixture.teacher2(), SCH, rows.teacher1Self)) assertThatThrownBy(() -> readService.detail(fixture.teacher2(), SCH, rows.teacher1Row))
.isInstanceOf(BizException.class).hasMessageContaining("不在你的数据范围内"); .isInstanceOf(BizException.class).hasMessageContaining("不在你的数据范围内");
assertThatThrownBy(() -> readService.detail(manager(), OTHER_SCH, rows.teacher1Self)) assertThatThrownBy(() -> readService.detail(manager(), OTHER_SCH, rows.teacher1Row))
.isInstanceOf(BizException.class).hasMessageContaining("答卷不存在"); .isInstanceOf(BizException.class).hasMessageContaining("答卷不存在");
// 教师被授权数据管理模块行也改不动别人的行(V39 范围半边:批量逐条判) // 教师被授权数据管理模块行也改不动别人的行(V39 范围半边:批量逐条判)
BoaUser teacher = fixture.teacher1(); BoaUser teacher = fixture.teacher1();
moduleTree.grantPaths(ReadFixture.TEACHER_1, FormAdminGuard.MODULE_DATA_MANAGE); moduleTree.grantPaths(ReadFixture.TEACHER_1, FormAdminGuard.MODULE_DATA_MANAGE);
adminGuard.requireAdmin(teacher, null, FormAdminGuard.MODULE_DATA_MANAGE); adminGuard.requireAdmin(teacher, null, FormAdminGuard.MODULE_DATA_MANAGE);
AnswerModels.BatchRequest batch = new AnswerModels.BatchRequest(); AnswerModels.BatchRequest batch = new AnswerModels.BatchRequest();
batch.setAnswerIds(Arrays.asList(rows.teacher1Self, rows.teacher2Self)); batch.setAnswerIds(Arrays.asList(rows.teacher1Row, rows.teacher2Row));
batch.setStatus("pending"); batch.setStatus("pending");
List<AnswerModels.BatchItemView> results = adminService.batchChangeStatus(teacher, SCH, batch); List<AnswerModels.BatchItemView> results = adminService.batchChangeStatus(teacher, SCH, batch);
assertThat(results.get(0).getSuccess()).isTrue(); assertThat(results.get(0).getSuccess()).isTrue();
@@ -240,14 +240,16 @@ class AnswerSubmitIT extends AnswerTestSupport {
String uuid = publishForm(limited, SCHEMA); String uuid = publishForm(limited, SCHEMA);
config(SCH, uuid, limitOnce()); config(SCH, uuid, limitOnce());
Integer sameUser = 10001; Integer sameUser = 10001;
answerService.submit(user(sameUser, SCH, 1), submit(limited, VALUES)); // P95 起教师×面向学生只能代填(第 0 关表层②),这条要的是"subject 为空的自填行",
assertThatThrownBy(() -> answerService.submit(user(sameUser, SCH, 1), submit(limited, VALUES))) // 所以提交人换成学生身份——限次谓词(coalesce(subject,submitter))与 roleId 无关,断言一字未改
answerService.submit(user(sameUser, SCH, 3), submit(limited, VALUES));
assertThatThrownBy(() -> answerService.submit(user(sameUser, SCH, 3), submit(limited, VALUES)))
.hasMessageContaining("限填一次"); .hasMessageContaining("限填一次");
String unlimited = "v32_unlimited"; String unlimited = "v32_unlimited";
publishForm(unlimited, SCHEMA); publishForm(unlimited, SCHEMA);
answerService.submit(user(sameUser, SCH, 1), submit(unlimited, VALUES)); answerService.submit(user(sameUser, SCH, 3), submit(unlimited, VALUES));
answerService.submit(user(sameUser, SCH, 1), submit(unlimited, VALUES)); answerService.submit(user(sameUser, SCH, 3), submit(unlimited, VALUES));
assertThat(answerRepository.findBySchIdAndAppCodeAndCodeOrderByIdAsc(SCH, "form-center", unlimited)) assertThat(answerRepository.findBySchIdAndAppCodeAndCodeOrderByIdAsc(SCH, "form-center", unlimited))
.hasSize(2); .hasSize(2);
} }
@@ -0,0 +1,263 @@
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;
}
}
@@ -0,0 +1,262 @@
package com.yunhai.formcenter.service;
import com.yunhai.formcenter.entity.Form;
import com.yunhai.formcenter.model.FormModels;
import com.yunhai.formcenter.model.MyModels;
import com.yunhai.platform.apistd.BizException;
import com.yunhai.platform.utils.exception.ErrorCode;
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 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 java.util.List;
import java.util.stream.Collectors;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
/**
* 「我可填」列表接口集成测试(自验项 <b>V50</b>):域遮蔽去重、仅已发布、appCode 过滤、
* canFill/myTier、排序——四条都打真库({@code @Transactional} 回滚)。
*
* <p>服务方法直调(同 {@code MyAnswerIT} 先例,不起 MockMvc):这一把要证的是取域/去重/过闸/排序四件事,
* 全在服务内;信封与参数绑定是 {@code FormBaseControllerTest} 那类已在别处验过的壳。</p>
*/
@SpringBootTest
@Import(AnswerTestSupport.TestGatewayConfiguration.class)
@Transactional
@DisplayName("FillableFormService - 我可填列表(V50)")
class FillableListIT extends AnswerTestSupport {
@org.springframework.beans.factory.annotation.Autowired
private FillableFormService fillableService;
private static final String SCHEMA = "{\"groups\":[{\"key\":\"base\",\"label\":\"基本信息\",\"fields\":["
+ "{\"key\":\"name\",\"label\":\"姓名\",\"type\":\"text\",\"required\":true}]}]}";
/** 快应用面(方案 §6.2):中心之外的 app_code,用来证 appCode 过滤与"列表跨应用"两件事 */
private static final String QUICK_APP = "fc-drill";
// ==================== 入口层过滤 ====================
@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);
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");
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");
// 层③:管理员(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()));
}
@Test
@DisplayName("V50 NONE 不入集:家长(4)/局领导(5)/工作号(6) 拿到空列表(不是报错、也不是全给)")
void peripheralRolesGetEmptyList() {
publishAt(SCH, Form.CENTER_APP_CODE, "v50_none", "任意表单", 1, null);
for (int roleId : Arrays.asList(4, 5, 6)) {
assertThat(fillableService.listFillable(user(30100 + roleId, SCH, roleId), null))
.as("roleId=" + roleId)
.isEmpty();
}
}
@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);
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");
grantSchoolAdmin(student.getId(), SCH);
assertThat(fillableService.listFillable(student, null)).allMatch(r -> "ADMIN".equals(r.getMyTier()));
}
// ==================== 域与遮蔽去重 ====================
@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);
List<MyModels.FillableRow> rows = fillableService.listFillable(user(30005, SCH, 3), null);
assertThat(rows).extracting(MyModels.FillableRow::getCode)
.containsExactlyInAnyOrder("v50_shadow", "v50_base_only");
assertThat(row(rows, "v50_shadow").getName())
.as("遮蔽规则与 resolve 同源:本校行胜出且不回落到平台行")
.isEqualTo("本校的那张");
}
@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();
}
// ==================== 生命周期 ====================
@Test
@DisplayName("V50 仅已发布:草稿、停用、软删三种状态都不入集(与 resolve 的填报入口口径一致)")
void onlyPublishedAndLiveFormsAreListed() {
FormModels.FormCreateRequest draft = new FormModels.FormCreateRequest();
draft.setAppCode(Form.CENTER_APP_CODE);
draft.setCode("v50_draft");
draft.setName("草稿");
String draftUuid = defService.create(SCH, draft).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);
defService.disable(SCH, disabledUuid);
String deletedUuid = publishAt(SCH, Form.CENTER_APP_CODE, "v50_deleted", "软删的", 1, null);
defService.softDelete(SCH, deletedUuid);
publishAt(SCH, Form.CENTER_APP_CODE, "v50_live", "正常发布", 1, null);
assertThat(fillableService.listFillable(user(30007, SCH, 3), null))
.extracting(MyModels.FillableRow::getCode).containsExactly("v50_live");
}
// ==================== appCode 过滤 ====================
@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);
BoaUser student = user(30008, SCH, 3);
assertThat(fillableService.listFillable(student, null))
.as("缺省=全部应用")
.extracting(MyModels.FillableRow::getCode).containsExactly("v50_center", "v50_quick");
assertThat(fillableService.listFillable(student, QUICK_APP))
.extracting(MyModels.FillableRow::getCode).containsExactly("v50_quick");
assertThat(fillableService.listFillable(student, Form.CENTER_APP_CODE))
.extracting(MyModels.FillableRow::getCode).containsExactly("v50_center");
assertThat(fillableService.listFillable(student, " "))
.as("空白串按缺省处理(=全部应用),不按 app_code=' ' 去查")
.extracting(MyModels.FillableRow::getCode).containsExactly("v50_center", "v50_quick");
}
// ==================== 排序与行形状 ====================
@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");
assertThat(fillableService.listFillable(user(30009, SCH, 3), null))
.extracting(MyModels.FillableRow::getCode)
.containsExactly("a_early", "b_same_2", "c_same_1", "b_late", "z_none");
}
@Test
@DisplayName("V50 行形状:audience/fillLimit 下发列值、deadline 格式化、groupTag 透出,且不带任何库内 id")
void rowCarriesDefinitionFieldsInContractShape() {
String uuid = publishAt(SCH, Form.CENTER_APP_CODE, "v50_shape", "形状用例", 1, "2026-11-20");
FormModels.FormConfigRequest config = new FormModels.FormConfigRequest();
config.setFillLimit(1);
config.setGroupTag("五年级");
config(SCH, uuid, config);
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.getFillLimit()).isEqualTo(1);
assertThat(row.getGroupTag()).isEqualTo("五年级");
assertThat(row.getDeadline()).isEqualTo("2026-11-20 23:59:59");
}
// ==================== 会话两段 ====================
@Test
@DisplayName("V50 会话:未登录=UNAUTHORIZED、无学校归属=FORBIDDEN(沿 my 面现口径,不返回空集糊弄)")
void sessionGuardsFollowMyFaceConvention() {
assertThatThrownBy(() -> fillableService.listFillable(null, null))
.isInstanceOfSatisfying(BizException.class,
e -> assertThat(e.getErrorCode()).isEqualTo(ErrorCode.UNAUTHORIZED));
BoaUser noSchool = new BoaUser();
noSchool.setId(30011);
noSchool.setRoleIds(Collections.singletonList(3));
noSchool.setCurrentRole(role(3, null));
assertThatThrownBy(() -> fillableService.listFillable(noSchool, null))
.isInstanceOfSatisfying(BizException.class,
e -> assertThat(e.getErrorCode()).isEqualTo(ErrorCode.FORBIDDEN));
}
// ==================== 小工具 ====================
/** 建表单→存 schema→发布,可选人群与截止(deadline 只给日期时后端补当日 23:59:59,方案 §5.4) */
private String publishAt(Long schId, String appCode, String code, String name, Integer 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();
FormModels.FormSaveRequest save = new FormModels.FormSaveRequest();
save.setUuid(uuid);
save.setSchemaJson(SCHEMA);
defService.save(schId, save);
defService.publish(schId, uuid, null);
FormModels.FormConfigRequest config = new FormModels.FormConfigRequest();
config.setAudience(audience);
config.setDeadline(deadline);
config(schId, uuid, config);
return uuid;
}
private static MyModels.FillableRow row(List<MyModels.FillableRow> rows, String code) {
List<MyModels.FillableRow> hit = rows.stream()
.filter(r -> code.equals(r.getCode())).collect(Collectors.toList());
assertThat(hit).as("列表里没有 code=" + code).hasSize(1);
return hit.get(0);
}
private static BoaRoleInfo role(Integer roleId, Integer schId) {
BoaRoleInfo info = new BoaRoleInfo();
info.setRoleId(roleId);
info.setSchId(schId);
return info;
}
}
@@ -87,7 +87,7 @@ class MyAnswerIT extends AnswerTestSupport {
// ==================== ① 身份全会话推导(V17) ==================== // ==================== ① 身份全会话推导(V17) ====================
@Test @Test
@DisplayName("V17 结构半边:六端点首参恒为会话用户、无 id 型入参,请求体只有 uuid/值/说明(外部传 id 不可能)") @DisplayName("V17 结构半边:七端点首参恒为会话用户、无 id 型入参,请求体只有 uuid/值/说明(外部传 id 不可能)")
void endpointsCarryNoIdentityParameter() { void endpointsCarryNoIdentityParameter() {
List<String> mapped = new ArrayList<>(); List<String> mapped = new ArrayList<>();
for (Method m : FormMyAnswerController.class.getDeclaredMethods()) { for (Method m : FormMyAnswerController.class.getDeclaredMethods()) {
@@ -107,7 +107,9 @@ class MyAnswerIT extends AnswerTestSupport {
} }
assertThat(mapped).containsExactlyInAnyOrder( assertThat(mapped).containsExactlyInAnyOrder(
"GET /list", "GET /submit-list", "GET /about-list", "GET /detail", "GET /list", "GET /submit-list", "GET /about-list", "GET /detail",
"POST /delete", "POST /edit"); "POST /delete", "POST /edit",
// P95 W3 新增的填报入口清单:同样只吃会话身份(appCode 是应用维度筛项,不是身份)
"GET /fillable");
// V14 的结构半边:本面没有任何改状态端点 // V14 的结构半边:本面没有任何改状态端点
assertThat(mapped).noneMatch(p -> p.contains("status")); assertThat(mapped).noneMatch(p -> p.contains("status"));
assertThat(fieldNames(FormMyAnswerController.DeleteRequest.class)).containsExactly("answerId"); assertThat(fieldNames(FormMyAnswerController.DeleteRequest.class)).containsExactly("answerId");
@@ -54,6 +54,12 @@ class ReadFixture {
static final int TEACHER_1 = 20264101; static final int TEACHER_1 = 20264101;
static final int TEACHER_2 = 20264102; static final int TEACHER_2 = 20264102;
static final int BUREAU_LEADER = 20264201; static final int BUREAU_LEADER = 20264201;
/**
* 教师代填的被代填人(P95 起教师×面向学生表单<b>只能代填</b>,自填被第 0 关拒)。
* 刻意用两个新 id:它们不落在任何"关于我"断言的主体上,学生视角的可见集因此不因造数据而变大。
*/
static final int TEACHER_PROXY_1 = 20264003;
static final int TEACHER_PROXY_2 = 20264004;
private final AnswerTestSupport support; private final AnswerTestSupport support;
@@ -69,10 +75,10 @@ class ReadFixture {
String student2Self; String student2Self;
/** 管理员代填学生1:grade g2 / score 70 / advisor 李老师(subject=学生1、submitter=管理员) */ /** 管理员代填学生1:grade g2 / score 70 / advisor 李老师(subject=学生1、submitter=管理员) */
String proxyForStudent1; String proxyForStudent1;
/** 教师1 自填:grade g3 / score 95 / advisor 张老师 */ /** 教师1 提交的那条(P95 起=他代填 TEACHER_PROXY_1,submitter=教师1):grade g3 / score 95 / advisor 张老师 */
String teacher1Self; String teacher1Row;
/** 教师2 自填:grade g2 / score 60 / advisor 张老师 */ /** 教师2 提交的那条(同上,代填 TEACHER_PROXY_2):grade g2 / score 60 / advisor 张老师 */
String teacher2Self; String teacher2Row;
/** 学生2 自填后被软删(四条读路径都看不见) */ /** 学生2 自填后被软删(四条读路径都看不见) */
String softDeleted; String softDeleted;
/** 表单 B(面向教师)上教师1 自填的一条 */ /** 表单 B(面向教师)上教师1 自填的一条 */
@@ -105,10 +111,10 @@ class ReadFixture {
rows.proxyForStudent1 = submitProxy(admin, FORM_A, STUDENT_1, rows.proxyForStudent1 = submitProxy(admin, FORM_A, STUDENT_1,
"{\"name\":\"张三(老师代填)\",\"grade\":\"g2\",\"score\":70,\"birthday\":\"2010-03-09\"," "{\"name\":\"张三(老师代填)\",\"grade\":\"g2\",\"score\":70,\"birthday\":\"2010-03-09\","
+ "\"advisor\":\"李老师\"}"); + "\"advisor\":\"李老师\"}");
rows.teacher1Self = submit(teacher1(), FORM_A, rows.teacher1Row = submitProxy(teacher1(), FORM_A, TEACHER_PROXY_1,
"{\"name\":\"王五\",\"grade\":\"g3\",\"score\":95,\"birthday\":\"2009-05-20\"," "{\"name\":\"王五\",\"grade\":\"g3\",\"score\":95,\"birthday\":\"2009-05-20\","
+ "\"advisor\":\"张老师\"}"); + "\"advisor\":\"张老师\"}");
rows.teacher2Self = submit(teacher2(), FORM_A, rows.teacher2Row = submitProxy(teacher2(), FORM_A, TEACHER_PROXY_2,
"{\"name\":\"赵六\",\"grade\":\"g2\",\"score\":60,\"birthday\":\"2010-01-15\"," "{\"name\":\"赵六\",\"grade\":\"g2\",\"score\":60,\"birthday\":\"2010-01-15\","
+ "\"advisor\":\"张老师\"}"); + "\"advisor\":\"张老师\"}");
rows.softDeleted = submit(student2(), FORM_A, rows.softDeleted = submit(student2(), FORM_A,