任务书 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:
@@ -1,8 +1,16 @@
|
||||
-- ============================================================================
|
||||
-- 表单中心 H2 + 第一个快应用:UserBase 侧应用行/模块行/授权登记脚本
|
||||
-- 编制: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 五条模块行表 + 验证口径)、
|
||||
-- 《表单中心建设方案 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 数据范围锚点退役)
|
||||
-- 先例: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);
|
||||
|
||||
-- ----------------------------------------------------------------------------
|
||||
-- 步骤 2:第一个快应用(演练对象,方案 §6.2 checklist R8 四步的前两步)
|
||||
-- 步骤 2:第一个快应用(演练对象,方案 §6.2 checklist R8;**P95 注记后 R8 只剩①建应用行 + ④学校开通**)
|
||||
-- 快应用=真实应用行 + 无自有后端 + pc_url 指中心分发路由。app_code 一经确定不可改
|
||||
-- (表单归属列 app_code 与模块树查询都按它走,P25/D8)。
|
||||
-- ----------------------------------------------------------------------------
|
||||
@@ -85,20 +93,20 @@ SET @quick_app_id = 0; -- ← 按上一步结果填;0=还没建
|
||||
-- WHERE @quick_app_id = 0;
|
||||
-- SET @quick_app_id = LAST_INSERT_ID(); -- ← 紧接着上面那条 INSERT 生效;否则手工填
|
||||
|
||||
-- 模块行五条(方案 §6.2 表的典型配法)。path 形态:
|
||||
-- 填报 = /f/{code}(该快应用名下的表单 code,经 resolve 换 uuid——快应用不授权定义管理行,§3.4)
|
||||
-- 模块行四条(P95 终态;方案 §6.2 表在 P95 注记后只剩这四行是 UserBase 侧的事)。path 形态:
|
||||
-- 数据查看/统计 = /app/{app_code}/data|stats(AppContext.vue 的分派段)
|
||||
-- 导入/导出 = path 留空的按钮级锚点(导出按钮显隐按它判,P76 修的就是"判哪棵树")
|
||||
-- ⚠ 原「填报 /f/{code}」行**自 P95 起不再登记**:填报入口由中心按三层模型解析
|
||||
-- (GET /api/form/my/fillable),模块行不参与;custom_id 300 段就此空置,不要再复用成别的行。
|
||||
INSERT INTO app_module
|
||||
(app_id, parent_id, name, custom_id, path, icon, level, sort_no, create_user, create_time)
|
||||
SELECT * FROM (
|
||||
SELECT @quick_app_id AS app_id, NULL AS parent_id, '填报' AS name, '300' AS custom_id,
|
||||
CONCAT('/f/', @quick_code, '_form') AS path, 'edit' AS icon, 1 AS level, 300 AS sort_no,
|
||||
SELECT @quick_app_id AS app_id, NULL AS parent_id, '数据查看' AS name, '301' AS custom_id,
|
||||
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
|
||||
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, '导入', '303', '', '', 1, 303, @operator, NOW()
|
||||
UNION ALL SELECT @quick_app_id, NULL, '导出', '304', '', '', 1, 304, @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, '导出', '304', '', '', 1, 304, @operator, NOW()
|
||||
) t
|
||||
WHERE @quick_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);
|
||||
@@ -111,8 +119,9 @@ WHERE @quick_app_id > 0
|
||||
-- 3.1 中心管理员组(示例:把 @grp_admin 换成目标学校的管理员组 id;sys_group 里查)
|
||||
SET @grp_admin = 0; -- ← 中心管理员组
|
||||
SET @grp_all = 0; -- ← 全员组(学校根组/基础组,"我的填写"给它)
|
||||
SET @grp_teacher = 0; -- ← 快应用教师组(数据查看 + 导出锚点)
|
||||
SET @grp_student = 0; -- ← 快应用学生组(只给填报行)
|
||||
SET @grp_teacher = 0; -- ← 快应用教师组(数据查看 + 统计 + 导出锚点)
|
||||
SET @grp_student = 0; -- ← 快应用学生组:**P95 起只需 3.2 的应用开通(门户可见性),不再勾任何模块行**
|
||||
-- 填报入口由中心按三层模型给(面向学生的表单自动出现在学生账号的「可填写」里)
|
||||
|
||||
-- 3.2 应用开通(中心 + 快应用各一条;@school_id 是要开通的学校 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'
|
||||
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)
|
||||
SELECT @grp_teacher, m.id, 0, @operator, NOW()
|
||||
FROM app_module m
|
||||
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);
|
||||
|
||||
|
||||
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 附注,需裁决后才可能取消这一步):
|
||||
-- 快应用的数据视图复用 /api/form/admin/data/**,而后端守卫扫的是【中心会话树】(签名调用方=表单中心自己),
|
||||
-- 不消费快应用自己的"数据查看"行。只勾快应用行的教师组进应用数据视图会被后端拒。
|
||||
@@ -174,13 +179,19 @@ SELECT @grp_teacher, m.id, 0, @operator, NOW()
|
||||
-- ⚠ 这条要 **H4 上线**(UserBase /oapi/user/modules 的可选 appCode 参数)+ 中心消费侧接线才通;
|
||||
-- 中心侧当前按 P3 返回空树+warn,见 T9 报告问题单 P78。
|
||||
-- ④教师组账号进 /form-center/app/fc-drill/data:导出按钮出现(快应用按钮锚点判定生效,P76)
|
||||
-- ⑤学生组账号:V16 门户可见性由开通闸门决定;进应用只见填报
|
||||
-- ⑤学生组账号:V16 门户可见性由开通闸门决定;进应用的「填报」档看到该应用下**面向学生**的表单
|
||||
-- (P95:这张清单来自 GET /api/form/my/fillable,与快应用树里有没有 `/f/` 行无关——
|
||||
-- 删掉存量 `/f/` 行后门户导航、「可填写」列表、分享直链三路都不受影响,见 P95 报告收尾清单)
|
||||
-- ⑥中心应用管理端进 /form-center/#/admin/app:应用设置页出两个 tab(角色设置/字典管理),
|
||||
-- 角色设置里加的人落 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
|
||||
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
|
||||
SELECT '中心 path 空行数(期望 0,锚点已退役)', COUNT(*) FROM app_module
|
||||
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.ReadModels;
|
||||
import com.yunhai.formcenter.service.AnswerReadService;
|
||||
import com.yunhai.formcenter.service.FillableFormService;
|
||||
import com.yunhai.platform.apistd.BizException;
|
||||
import com.yunhai.platform.utils.exception.ErrorCode;
|
||||
import com.yunhai.platform.utils.model.BoaUser;
|
||||
@@ -38,6 +39,7 @@ import java.util.List;
|
||||
public class FormMyAnswerController {
|
||||
|
||||
private final AnswerReadService readService;
|
||||
private final FillableFormService fillableService;
|
||||
|
||||
/** 我的答卷列表(按 submitter 过滤,"我的提交"段) */
|
||||
@GetMapping("/list")
|
||||
@@ -96,6 +98,24 @@ public class FormMyAnswerController {
|
||||
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) {
|
||||
if (user == null || user.getId() == null) {
|
||||
throw new BizException(ErrorCode.UNAUTHORIZED, "未登录");
|
||||
|
||||
@@ -45,4 +45,36 @@ public final class MyModels {
|
||||
/** yyyy-MM-dd HH:mm:ss */
|
||||
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 > MANAGE > FILL(NONE 不入集,故不会为 null) */
|
||||
private String myTier;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -43,4 +43,14 @@ public interface FormRepository extends JpaRepository<Form, Long> {
|
||||
List<Form> findResolveCandidates(@Param("code") String code,
|
||||
@Param("appCode") String appCode,
|
||||
@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.LocalDateTime;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* 提交链路(契约 §5 / 方案 §5.4)。一次提交在这条流水线上依次过关:
|
||||
*
|
||||
* <pre>
|
||||
* 钉版解析 → 截止 → 幂等命中 → subject 授权+存在性同校(P33) → 值校验(按钉住版本)
|
||||
* → 附件归属与 key 一致(P34) → 限次占位(fill_quota 撞键即拒) → 落 answer(写 form_uuid)
|
||||
* → 写 answer_index(只写 searchable)
|
||||
* 钉版解析 → <b>第 0 关·填报入口层(P95)</b> → 截止 → 幂等命中 → subject 授权+存在性同校(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 明示可接受的取舍;
|
||||
* 外层 {@code DataIntegrityViolationException} 的并发重放路径不再重复过闸(命中
|
||||
* {@code belongsToCaller} 即返回原答卷);</li>
|
||||
* <li>幂等检查必须在限次<b>之前</b>——重放的那一条本来就占着名额,先查限次会把双击重试误判成"超过次数";</li>
|
||||
* <li>限次用"事务内向 {@code fill_quota} 插入、撞唯一键即拒"而不是先 count 再插
|
||||
* (方案 §5.4 v1.4:两个并发提交不可能都通过 count 检查);</li>
|
||||
@@ -46,6 +52,12 @@ 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>事务形态</b>:公开方法不带 {@code @Transactional},写库段用 {@link TransactionTemplate} 包。
|
||||
* 目的是让"幂等键撞 uk"这种并发结局能在<b>事务外</b>重新读取原答卷并按契约返回 200+原 answer_id
|
||||
* (撞键后当前事务已被标记回滚,事务内再读是不可靠的)。</p>
|
||||
@@ -62,6 +74,7 @@ public class AnswerService {
|
||||
private final FormDefService defService;
|
||||
private final FormDictService dictService;
|
||||
private final FormAdminGuard adminGuard;
|
||||
private final FillAccessService fillAccess;
|
||||
private final SubjectDirectory subjectDirectory;
|
||||
private final AnswerRepository answerRepository;
|
||||
private final FillQuotaRepository quotaRepository;
|
||||
@@ -99,12 +112,16 @@ public class AnswerService {
|
||||
Form form = published.form();
|
||||
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())) {
|
||||
throw new BizException(ErrorCode.CUSTOM, "已过收集截止时间,不再接收提交:" + form.getCode());
|
||||
}
|
||||
|
||||
// 3) 幂等命中→200+原 answer_id(在限次之前,见类注释)
|
||||
// 4) 幂等命中→200+原 answer_id(在限次之前,见类注释)
|
||||
Answer existing = answerRepository.findByIdempotencyKey(idempotencyKey).orElse(null);
|
||||
if (existing != null) {
|
||||
if (!belongsToCaller(existing, user)) {
|
||||
@@ -114,18 +131,18 @@ public class AnswerService {
|
||||
return toView(existing, true);
|
||||
}
|
||||
|
||||
// 4) subject 授权(方案 §6.3:普通用户只能=自己,有管理权者可代填他人)+ 存在性/同校(P33)
|
||||
Integer subjectId = requireSubject(user, req.getSubjectId(), submitterId, schId);
|
||||
// 5) subject 授权(P95 行为表:谁能代填由第 0 关的层决定)+ 存在性/同校(P33)
|
||||
Integer subjectId = requireSubject(user, tiers, req.getSubjectId(), submitterId, schId);
|
||||
|
||||
// 5) 值校验(按钉住版本;字典取值域含软删条目)+ 附件归属
|
||||
// 6) 值校验(按钉住版本;字典取值域含软删条目)+ 附件归属
|
||||
String valuesJson = valuesToJson(req.getValues());
|
||||
FormValueValidator.validate(valuesJson, schemaJson, dictCode -> dictService.allowedValues(schId, dictCode));
|
||||
assertAttachmentsOwned(schId, valuesJson, schemaJson);
|
||||
|
||||
// 6) 限次占位(uk 是并发兜底)
|
||||
// 7) 限次占位(uk 是并发兜底)
|
||||
String quotaKey = occupyQuota(form, subjectId, submitterId);
|
||||
|
||||
// 7) 落答卷
|
||||
// 8) 落答卷
|
||||
Answer answer = new Answer();
|
||||
answer.setUuid(newUuid());
|
||||
// P25:钉版溯源的精确坐标=resolvePublished 命中的那张表单(本校遮蔽平台时的胜出行)
|
||||
@@ -147,7 +164,7 @@ public class AnswerService {
|
||||
// 若这一步失败,同事务内的 fill_quota 占位一起回滚,不残留"名额被占但无答卷"的脏状态
|
||||
answer = answerRepository.saveAndFlush(answer);
|
||||
|
||||
// 8) 索引:只写 searchable 字段(V3/V9)
|
||||
// 9) 索引:只写 searchable 字段(V3/V9)
|
||||
indexService.write(answer, schemaJson);
|
||||
|
||||
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)) {
|
||||
return subjectId;
|
||||
}
|
||||
if (!adminGuard.canManage(user)) {
|
||||
throw new BizException(ErrorCode.FORBIDDEN,
|
||||
"无权为他人填报:subjectId 只能等于本人(当前 " + submitterId + ",请求 " + 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 之前的现行为逐字同文案
|
||||
throw new BizException(ErrorCode.FORBIDDEN, forbiddenProxy(submitterId, subjectId));
|
||||
}
|
||||
// P33:代填=把数据挂到别人名下,"关于我的"视图(V18)与按 subject 限次都建立在"id 真实且同校"上。
|
||||
// 本人自填不查(会话里的人本来就存在),只验代填那一个 id。
|
||||
// 本人自填不查(会话里的人本来就存在),只验代填那一个 id。层②与层③共用这一条(红线 3)。
|
||||
if (!subjectDirectory.existsInSchool(subjectId, schId)) {
|
||||
throw new BizException(ErrorCode.PARAM_ERROR,
|
||||
"被代填人不存在或不属于本校:subjectId=" + subjectId + ",schId=" + schId);
|
||||
@@ -175,6 +221,24 @@ public class AnswerService {
|
||||
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=该表单不限次,未占位) */
|
||||
private String occupyQuota(Form form, Integer subjectId, Integer submitterId) {
|
||||
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 > MANAGE > FILL;空集返回 null=NONE),供列表的 {@code myTier} 用 */
|
||||
public Tier topTier(Set<Tier> tiers) {
|
||||
if (tiers == null || tiers.isEmpty()) {
|
||||
return null;
|
||||
}
|
||||
for (Tier candidate : Tier.values()) {
|
||||
if (tiers.contains(candidate)) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 管理员两表是否把这个人抬进层③:{@code school_admin} 命中<b>会话校</b>、
|
||||
* 或 {@code form_admin} 命中<b>这张表单</b>(口径与 {@link DataScopeService} 读侧同源,
|
||||
* 但结论只用在入口层,不改数据范围)。
|
||||
*
|
||||
* <p>与读侧的差别只有一处,且是有意的:两表在这里<b>不看表单归属域</b>。
|
||||
* 读侧要区分"本校表单→全部 / 基础表单→本校提交",而入口层问的是"这个人是不是管理员",
|
||||
* 基础表单的管理员同样是管理员;校域不变式({@code answer.sch_id}=会话校)也不因代填而变。</p>
|
||||
*/
|
||||
private boolean grantedByAdminTables(BoaUser user, Form form) {
|
||||
Long ownSch = 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) 本校与平台级都在时,本校胜出且不回落——
|
||||
* 本校行哪怕处于草稿/停用,也不让请求穿透到平台级(否则"本校遮蔽"会变成可绕过的建议)。
|
||||
* 兜底只认 sch_id=0:取域已限定在本校 ∪ 平台级,多加一层判断是为了让"他校行"永远不可能被当兜底。 */
|
||||
private Form pickByShadowing(List<Form> candidates, Long schId) {
|
||||
* 兜底只认 sch_id=0:取域已限定在本校 ∪ 平台级,多加一层判断是为了让"他校行"永远不可能被当兜底。
|
||||
*
|
||||
* <p>P95 起 {@code FillableFormService} 也要这条规则(「我可填」列表按 (app_code, code) 分组后逐组判),
|
||||
* 所以把它收成<b>包内静态方法</b>而不是复制一份——两条链路共用一条遮蔽实现(同 P20 的取舍)。</p> */
|
||||
static Form pickByShadowing(List<Form> candidates, Long schId) {
|
||||
Form platformFallback = null;
|
||||
for (Form form : candidates) {
|
||||
if (form.getSchId() == null) {
|
||||
|
||||
@@ -58,6 +58,8 @@ class AdminScopeIT extends AnswerTestSupport {
|
||||
private static final String QUICK_CODE = "t10_quick_drill";
|
||||
/** 快应用面(方案 §6.2):中心之外的 app_code,用来证"本校快应用表单也被校级管理员覆盖" */
|
||||
private static final String QUICK_APP = "fc-drill";
|
||||
/** 教师代填的被代填人(P95 起教师造不出"自填行",只能代填;这个 id 不参与任何"关于我"断言) */
|
||||
private static final int PROXY_SUBJECT = 20264777;
|
||||
private static final String SCHEMA = "{\"groups\":[{\"key\":\"base\",\"label\":\"基本信息\",\"fields\":["
|
||||
+ "{\"key\":\"name\",\"label\":\"姓名\",\"type\":\"text\",\"required\":true,\"searchable\":true}]}]}";
|
||||
|
||||
@@ -202,17 +204,31 @@ class AdminScopeIT extends AnswerTestSupport {
|
||||
seed.quickUuid = publishQuickForm(QUICK_APP, QUICK_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.baseLegacyNull = insertLegacyAnswer(seed.baseUuid, "存量没有学校列的行");
|
||||
|
||||
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), "快应用的行");
|
||||
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):新链路的提交一定会落值,只有直接入库才出得来 */
|
||||
private String insertLegacyAnswer(String formUuid, String name) {
|
||||
Answer answer = new Answer();
|
||||
|
||||
@@ -100,7 +100,7 @@ class AnswerConcurrencyIT extends AnswerTestSupport {
|
||||
|
||||
Outcome[] outcomes = runTwo(() -> {
|
||||
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();
|
||||
return new Outcome(answerId, null);
|
||||
} catch (BizException e) {
|
||||
@@ -130,7 +130,7 @@ class AnswerConcurrencyIT extends AnswerTestSupport {
|
||||
|
||||
Outcome[] outcomes = runTwo(() -> {
|
||||
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);
|
||||
} catch (BizException e) {
|
||||
return new Outcome(null, e.getMessage());
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package com.yunhai.formcenter.service;
|
||||
|
||||
import com.yunhai.formcenter.entity.FormFile;
|
||||
import com.yunhai.formcenter.model.AnswerModels;
|
||||
import com.yunhai.formcenter.model.ReadModels;
|
||||
import com.yunhai.formcenter.repository.FormFileRepository;
|
||||
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
|
||||
*/
|
||||
@@ -176,7 +181,12 @@ class AnswerExportIT extends AnswerTestSupport {
|
||||
+ "\"subjects\":[\"phy\",\"math\"],"
|
||||
+ "\"cert\":[{\"id\":" + file.getId() + ",\"fileName\":\"" + file.getFileName()
|
||||
+ "\",\"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;
|
||||
}
|
||||
|
||||
@@ -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(paged).containsExactlyInAnyOrder(rows.student1Self, rows.student2Self,
|
||||
rows.proxyForStudent1, rows.teacher1Self, rows.teacher2Self);
|
||||
rows.proxyForStudent1, rows.teacher1Row, rows.teacher2Row);
|
||||
|
||||
// 游标用别人范围内的 uuid:不可见即拒(PARAM_ERROR,而不是静默从头开始)
|
||||
String foreign = rows.teacher1Self;
|
||||
String foreign = rows.teacher1Row;
|
||||
ReadModels.ListRequest probe = listRequest(rows.formAUuid);
|
||||
probe.setCursor(foreign);
|
||||
probe.setSize(2);
|
||||
@@ -128,10 +128,10 @@ class AnswerReadIT extends AnswerTestSupport {
|
||||
|
||||
assertThat(visibleIds(fixture, manager(), rows.formAUuid))
|
||||
.containsExactlyInAnyOrder(rows.student1Self, rows.student2Self, rows.proxyForStudent1,
|
||||
rows.teacher1Self, rows.teacher2Self);
|
||||
rows.teacher1Row, rows.teacher2Row);
|
||||
// 教师×面向学生:一期实际生效=仅本人提交(方案 §6.4 表格明示,防被当 bug 报)
|
||||
assertThat(visibleIds(fixture, fixture.teacher1(), rows.formAUuid))
|
||||
.containsExactly(rows.teacher1Self);
|
||||
.containsExactly(rows.teacher1Row);
|
||||
// 学生×面向学生=关于我:自填那条 subject 为空看不见,老师代填的看得见(契约 §6.3 两键分立的直接体现)
|
||||
assertThat(visibleIds(fixture, fixture.student1(), rows.formAUuid))
|
||||
.containsExactly(rows.proxyForStudent1);
|
||||
@@ -145,7 +145,7 @@ class AnswerReadIT extends AnswerTestSupport {
|
||||
ReadFixture fixture = new ReadFixture(this);
|
||||
ReadFixture.Rows rows = fixture.seed(manager());
|
||||
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);
|
||||
ReadModels.ListView upgraded = readService.list(teacher, SCH, listRequest(rows.formAUuid));
|
||||
@@ -237,7 +237,7 @@ class AnswerReadIT extends AnswerTestSupport {
|
||||
filter("advisor", "李", null, null));
|
||||
assertThat(hit).containsExactlyInAnyOrder(rows.student1Self, rows.proxyForStudent1);
|
||||
assertThat(visibleIdsWithFilter(fixture, admin, rows.formAUuid, filter("advisor", "张老师", null, null)))
|
||||
.containsExactlyInAnyOrder(rows.teacher1Self, rows.teacher2Self);
|
||||
.containsExactlyInAnyOrder(rows.teacher1Row, rows.teacher2Row);
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -276,7 +276,7 @@ class AnswerReadIT extends AnswerTestSupport {
|
||||
|
||||
// 等值:选项存 value,展示 label 变了也不影响筛选(V2 同族)
|
||||
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 注入:传 "%" 不会命中全部)
|
||||
assertThat(visibleIdsWithFilter(fixture, manager(), rows.formAUuid, filter("name", "张", null, null)))
|
||||
.containsExactlyInAnyOrder(rows.student1Self, rows.proxyForStudent1);
|
||||
@@ -288,7 +288,7 @@ class AnswerReadIT extends AnswerTestSupport {
|
||||
// 日期区间按日期比,不是字典序(V4:2010-03-09 落在 2010-01-01~2010-06-01 内)
|
||||
assertThat(visibleIdsWithFilter(fixture, manager(), rows.formAUuid,
|
||||
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);
|
||||
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.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("不在你的数据范围内");
|
||||
assertThatThrownBy(() -> readService.detail(manager(), OTHER_SCH, rows.teacher1Self))
|
||||
assertThatThrownBy(() -> readService.detail(manager(), OTHER_SCH, rows.teacher1Row))
|
||||
.isInstanceOf(BizException.class).hasMessageContaining("答卷不存在");
|
||||
// 教师被授权数据管理模块行也改不动别人的行(V39 范围半边:批量逐条判)
|
||||
BoaUser teacher = fixture.teacher1();
|
||||
moduleTree.grantPaths(ReadFixture.TEACHER_1, FormAdminGuard.MODULE_DATA_MANAGE);
|
||||
adminGuard.requireAdmin(teacher, null, FormAdminGuard.MODULE_DATA_MANAGE);
|
||||
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");
|
||||
List<AnswerModels.BatchItemView> results = adminService.batchChangeStatus(teacher, SCH, batch);
|
||||
assertThat(results.get(0).getSuccess()).isTrue();
|
||||
|
||||
@@ -240,14 +240,16 @@ class AnswerSubmitIT extends AnswerTestSupport {
|
||||
String uuid = publishForm(limited, SCHEMA);
|
||||
config(SCH, uuid, limitOnce());
|
||||
Integer sameUser = 10001;
|
||||
answerService.submit(user(sameUser, SCH, 1), submit(limited, VALUES));
|
||||
assertThatThrownBy(() -> answerService.submit(user(sameUser, SCH, 1), submit(limited, VALUES)))
|
||||
// P95 起教师×面向学生只能代填(第 0 关表层②),这条要的是"subject 为空的自填行",
|
||||
// 所以提交人换成学生身份——限次谓词(coalesce(subject,submitter))与 roleId 无关,断言一字未改
|
||||
answerService.submit(user(sameUser, SCH, 3), submit(limited, VALUES));
|
||||
assertThatThrownBy(() -> answerService.submit(user(sameUser, SCH, 3), submit(limited, VALUES)))
|
||||
.hasMessageContaining("限填一次");
|
||||
|
||||
String unlimited = "v32_unlimited";
|
||||
publishForm(unlimited, SCHEMA);
|
||||
answerService.submit(user(sameUser, SCH, 1), submit(unlimited, VALUES));
|
||||
answerService.submit(user(sameUser, SCH, 1), submit(unlimited, VALUES));
|
||||
answerService.submit(user(sameUser, SCH, 3), submit(unlimited, VALUES));
|
||||
answerService.submit(user(sameUser, SCH, 3), submit(unlimited, VALUES));
|
||||
assertThat(answerRepository.findBySchIdAndAppCodeAndCodeOrderByIdAsc(SCH, "form-center", unlimited))
|
||||
.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) ====================
|
||||
|
||||
@Test
|
||||
@DisplayName("V17 结构半边:六端点首参恒为会话用户、无 id 型入参,请求体只有 uuid/值/说明(外部传 id 不可能)")
|
||||
@DisplayName("V17 结构半边:七端点首参恒为会话用户、无 id 型入参,请求体只有 uuid/值/说明(外部传 id 不可能)")
|
||||
void endpointsCarryNoIdentityParameter() {
|
||||
List<String> mapped = new ArrayList<>();
|
||||
for (Method m : FormMyAnswerController.class.getDeclaredMethods()) {
|
||||
@@ -107,7 +107,9 @@ class MyAnswerIT extends AnswerTestSupport {
|
||||
}
|
||||
assertThat(mapped).containsExactlyInAnyOrder(
|
||||
"GET /list", "GET /submit-list", "GET /about-list", "GET /detail",
|
||||
"POST /delete", "POST /edit");
|
||||
"POST /delete", "POST /edit",
|
||||
// P95 W3 新增的填报入口清单:同样只吃会话身份(appCode 是应用维度筛项,不是身份)
|
||||
"GET /fillable");
|
||||
// V14 的结构半边:本面没有任何改状态端点
|
||||
assertThat(mapped).noneMatch(p -> p.contains("status"));
|
||||
assertThat(fieldNames(FormMyAnswerController.DeleteRequest.class)).containsExactly("answerId");
|
||||
|
||||
@@ -54,6 +54,12 @@ class ReadFixture {
|
||||
static final int TEACHER_1 = 20264101;
|
||||
static final int TEACHER_2 = 20264102;
|
||||
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;
|
||||
|
||||
@@ -69,10 +75,10 @@ class ReadFixture {
|
||||
String student2Self;
|
||||
/** 管理员代填学生1:grade g2 / score 70 / advisor 李老师(subject=学生1、submitter=管理员) */
|
||||
String proxyForStudent1;
|
||||
/** 教师1 自填:grade g3 / score 95 / advisor 张老师 */
|
||||
String teacher1Self;
|
||||
/** 教师2 自填:grade g2 / score 60 / advisor 张老师 */
|
||||
String teacher2Self;
|
||||
/** 教师1 提交的那条(P95 起=他代填 TEACHER_PROXY_1,submitter=教师1):grade g3 / score 95 / advisor 张老师 */
|
||||
String teacher1Row;
|
||||
/** 教师2 提交的那条(同上,代填 TEACHER_PROXY_2):grade g2 / score 60 / advisor 张老师 */
|
||||
String teacher2Row;
|
||||
/** 学生2 自填后被软删(四条读路径都看不见) */
|
||||
String softDeleted;
|
||||
/** 表单 B(面向教师)上教师1 自填的一条 */
|
||||
@@ -105,10 +111,10 @@ class ReadFixture {
|
||||
rows.proxyForStudent1 = submitProxy(admin, FORM_A, STUDENT_1,
|
||||
"{\"name\":\"张三(老师代填)\",\"grade\":\"g2\",\"score\":70,\"birthday\":\"2010-03-09\","
|
||||
+ "\"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\","
|
||||
+ "\"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\","
|
||||
+ "\"advisor\":\"张老师\"}");
|
||||
rows.softDeleted = submit(student2(), FORM_A,
|
||||
|
||||
Reference in New Issue
Block a user