Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bf39c220f2 | ||
|
|
12a8df4fef |
@@ -0,0 +1,202 @@
|
||||
-- ============================================================================
|
||||
-- 表单中心 H2 + 第一个快应用:UserBase 侧应用行/模块行/授权登记脚本
|
||||
-- 编制:Qoder(T9 联调轮)|日期:2026-10-15
|
||||
-- 依据:《表单中心执行手册》§2.2(中心应用六条模块行表 + 验证口径)、
|
||||
-- 《表单中心建设方案 v1.4》§6.2(快应用登记 checklist R8)、§6.4(customId=fc:scope:all)、
|
||||
-- §6.5(中心=BUSINESS 应用,模块行与快应用模块行两棵树不混)
|
||||
-- 先例:D:\SoftProject\yunhai\UserBase\flow-center接入UserBase菜单.sql(同一套形态与幂等写法)
|
||||
--
|
||||
-- ⚠ 纪律:本脚本**不由 AI 执行**(沿 flow-center 先例"AI 不代跑,交由你在 dev / prod 各自执行")。
|
||||
-- H2 的既定路径是 UserBase 管理端(超管)逐项录入;本脚本是同一份数据的批量/复核形态,
|
||||
-- 两者取其一即可,重复执行安全(全部按唯一键做"不存在才插",不覆盖人工改过的值)。
|
||||
-- 目标库=**UserBase 的库**(本地 bsch_userbase / 160 容器网 userbase 库),不是 bsch_formcenter。
|
||||
-- MySQL 5.7 兼容(无 CTE / 无窗口函数)。
|
||||
-- ============================================================================
|
||||
|
||||
-- ----------------------------------------------------------------------------
|
||||
-- 步骤 0:定位 form-center 的应用主键(关键,排第一大坑)
|
||||
-- 开放接口按「调用方 app_key → application_info 主键 id」反查,而 app_module.app_id 存的是【主键 id】,
|
||||
-- 不是 app_key(100xxx)。中心后端签名用的 app_key 由 projman 注入(P7:jar 内占位 100021 上线前替换)。
|
||||
-- 期望下面这条命中一行;命中 0 行=应用行还没建(走 0b 建,或去管理端建)。
|
||||
-- ----------------------------------------------------------------------------
|
||||
SELECT id AS app_id_pk, app_key, name, app_code, app_nature, pc_url
|
||||
FROM application_info
|
||||
WHERE app_code = 'form-center' OR app_key = 100021;
|
||||
|
||||
SET @center_app_id = 0; -- ← 按上一步结果填:form-center 在 application_info 的主键 id
|
||||
SET @center_app_key = 100021; -- ← 按 UserBase 注册所得 app_key 填(占位值仅本地)
|
||||
SET @operator = 0; -- create_user 占位(0=系统)
|
||||
|
||||
-- 0b(可选):应用行缺失时才需要。app_secret 请改成 UserBase 注册实际下发的值,
|
||||
-- 不要把这里的占位串带到任何环境(D7:一期无写 oapi,应用行由超管建=本行就是那个"手工建")。
|
||||
-- INSERT INTO application_info (app_key, app_secret, name, app_code, app_nature, pc_url, pc_icon,
|
||||
-- clients, create_time, create_user)
|
||||
-- SELECT @center_app_key, 'REPLACE-WITH-REAL-SECRET', '表单中心', 'form-center', 'BUSINESS',
|
||||
-- 'http://base.yunhaixingtong.com/form-center/front/', 'category', JSON_ARRAY('pc'),
|
||||
-- NOW(), @operator
|
||||
-- WHERE @center_app_id = 0;
|
||||
|
||||
-- ----------------------------------------------------------------------------
|
||||
-- 步骤 1:中心应用六条模块行(执行手册 §2.2 表;path 必须逐字等于 front/src/router/routes.ts)
|
||||
-- ①表单设计 /admin/forms ②数据管理 /admin/data ③统计 /admin/stats
|
||||
-- ④字典管理 /admin/dicts ⑤我的填写 /my(全员) ⑥数据全量【path 留空 + customId=fc:scope:all】
|
||||
-- 第⑥行=纯权限锚点:导航不渲染、后端 DataScopeService 只扫 path 空节点的 customId(P47 收紧后口径),
|
||||
-- 授权给哪个组,该组的数据范围即升级为"全部"(方案 §6.4)。
|
||||
-- 子页面路由(/admin/forms/designer、/admin/data/detail、/app/:appCode/data/detail)
|
||||
-- **不建模块行**(手册 §2.2 注记 + P63/P77):不占 modId、不进导航,前端按 meta.parentPath 归位。
|
||||
-- custom_id 用 200~205 段(避开 flow-center 的 100~105;后端 resolveModId 只认数字 customId)
|
||||
-- ----------------------------------------------------------------------------
|
||||
INSERT INTO app_module
|
||||
(app_id, parent_id, name, custom_id, path, icon, level, sort_no, create_user, create_time)
|
||||
SELECT * FROM (
|
||||
SELECT @center_app_id AS app_id, NULL AS parent_id, '表单设计' AS name, '200' AS custom_id,
|
||||
'/admin/forms' AS path, 'word' AS icon, 1 AS level, 200 AS sort_no, @operator AS create_user, NOW() AS create_time
|
||||
UNION ALL SELECT @center_app_id, NULL, '数据管理', '201', '/admin/data', 'category', 1, 201, @operator, NOW()
|
||||
UNION ALL SELECT @center_app_id, NULL, '统计', '202', '/admin/stats','tongji', 1, 202, @operator, NOW()
|
||||
UNION ALL SELECT @center_app_id, NULL, '字典管理', '203', '/admin/dicts','zidingyi', 1, 203, @operator, NOW()
|
||||
UNION ALL SELECT @center_app_id, NULL, '我的填写', '204', '/my', '', 1, 204, @operator, NOW()
|
||||
-- 锚点行:path 空 + customId 协议值(非数字 customId 是刻意的,前端 modId 会回退主键 id,不影响消费)
|
||||
UNION ALL SELECT @center_app_id, NULL, '数据全量', 'fc:scope:all', '', '', 1, 205, @operator, NOW()
|
||||
) t
|
||||
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 四步的前两步)
|
||||
-- 快应用=真实应用行 + 无自有后端 + pc_url 指中心分发路由。app_code 一经确定不可改
|
||||
-- (表单归属列 app_code 与模块树查询都按它走,P25/D8)。
|
||||
-- ----------------------------------------------------------------------------
|
||||
SET @quick_code = 'fc-drill'; -- ← 演练用快应用 app_code(前端 mock 桩也叫这个名字)
|
||||
SET @quick_app_key = 0; -- ← UserBase 注册所得 app_key(无后端应用也要有身份行)
|
||||
|
||||
SELECT id AS quick_app_id_pk, app_key, name, app_code, app_nature
|
||||
FROM application_info WHERE app_code = @quick_code;
|
||||
|
||||
SET @quick_app_id = 0; -- ← 按上一步结果填;0=还没建,走 2b
|
||||
|
||||
-- 2b(可选):快应用应用行(BUSINESS 之外按平台既有 nature 填;无自有后端=不建 projman 项目)
|
||||
-- INSERT INTO application_info (app_key, app_secret, name, app_code, app_nature, pc_url, pc_icon,
|
||||
-- clients, create_time, create_user)
|
||||
-- SELECT @quick_app_key, 'REPLACE-WITH-REAL-SECRET', '表单演练应用', @quick_code, 'BUSINESS',
|
||||
-- CONCAT('http://base.yunhaixingtong.com/form-center/app/', @quick_code, '/'), 'edit',
|
||||
-- JSON_ARRAY('pc'), NOW(), @operator
|
||||
-- WHERE @quick_app_id = 0;
|
||||
-- SET @quick_app_id = LAST_INSERT_ID(); -- ← 紧接着上面那条 INSERT 生效;否则手工填
|
||||
|
||||
-- 模块行五条(方案 §6.2 表的典型配法)。path 形态:
|
||||
-- 填报 = /f/{code}(该快应用名下的表单 code,经 resolve 换 uuid——快应用不授权定义管理行,§3.4)
|
||||
-- 数据查看/统计 = /app/{app_code}/data|stats(AppContext.vue 的分派段)
|
||||
-- 导入/导出 = path 留空的按钮级锚点(导出按钮显隐按它判,P76 修的就是"判哪棵树")
|
||||
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,
|
||||
@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()
|
||||
) 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);
|
||||
|
||||
-- ----------------------------------------------------------------------------
|
||||
-- 步骤 3:授权 + 开通(方案 §6.1 三层:①应用开通(校级)②模块授权(组/角色)③数据范围)
|
||||
-- 判定口径(UserBase 侧现成实现):最终模块权限 =(组授权模块 ∩ 已开通应用)∪(启用角色授权模块)。
|
||||
-- 组授权路径**必须先开通应用**(开通不继承,子组要自己开通或挂在已开通的祖先链上)。
|
||||
-- ----------------------------------------------------------------------------
|
||||
-- 3.1 中心管理员组(示例:把 @grp_admin 换成目标学校的管理员组 id;sys_group 里查)
|
||||
SET @grp_admin = 0; -- ← 中心管理员组
|
||||
SET @grp_all = 0; -- ← 全员组(学校根组/基础组,"我的填写"给它)
|
||||
SET @grp_teacher = 0; -- ← 快应用教师组(数据查看 + 导出锚点)
|
||||
SET @grp_student = 0; -- ← 快应用学生组(只给填报行)
|
||||
|
||||
-- 3.2 应用开通(中心 + 快应用各一条;@school_id 是要开通的学校 id)
|
||||
SET @school_id = 0; -- ← 目标学校 id
|
||||
INSERT INTO sys_group_app_rel (group_id, app_id, create_user, create_time)
|
||||
SELECT g.group_id, a.app_id, @operator, NOW()
|
||||
FROM (SELECT @grp_admin AS group_id, @center_app_id AS app_id UNION ALL
|
||||
SELECT @grp_all, @center_app_id UNION ALL
|
||||
SELECT @grp_teacher, @quick_app_id UNION ALL
|
||||
SELECT @grp_student, @quick_app_id) a
|
||||
JOIN (SELECT 1 AS dummy) g ON a.group_id > 0
|
||||
WHERE NOT EXISTS (SELECT 1 FROM sys_group_app_rel r
|
||||
WHERE r.group_id = a.group_id AND r.app_id = a.app_id);
|
||||
|
||||
-- 3.3 中心模块授权:管理员组=前五行(除锚点),全员组=只"我的填写",
|
||||
-- 要看全校数据的组=额外勾"数据全量"锚点行(customId=fc:scope:all)
|
||||
INSERT INTO sys_group_module_perm (group_id, module_id, inherit_flag, create_user, create_time)
|
||||
SELECT @grp_admin, m.id, 0, @operator, NOW()
|
||||
FROM app_module m
|
||||
WHERE m.app_id = @center_app_id AND @grp_admin > 0 AND COALESCE(m.custom_id,'') <> 'fc:scope:all'
|
||||
AND NOT EXISTS (SELECT 1 FROM sys_group_module_perm p WHERE p.group_id = @grp_admin AND p.module_id = m.id);
|
||||
|
||||
INSERT INTO sys_group_module_perm (group_id, module_id, inherit_flag, create_user, create_time)
|
||||
SELECT @grp_all, m.id, 0, @operator, NOW()
|
||||
FROM app_module m
|
||||
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);
|
||||
|
||||
-- 数据全量锚点(V42 的授权动作;只给确实要看全校数据的组)
|
||||
INSERT INTO sys_group_module_perm (group_id, module_id, inherit_flag, create_user, create_time)
|
||||
SELECT @grp_admin, m.id, 0, @operator, NOW()
|
||||
FROM app_module m
|
||||
WHERE m.app_id = @center_app_id AND @grp_admin > 0 AND m.custom_id = 'fc:scope:all'
|
||||
AND NOT EXISTS (SELECT 1 FROM sys_group_module_perm p WHERE p.group_id = @grp_admin AND p.module_id = m.id);
|
||||
|
||||
-- 3.4 快应用模块授权:教师组=数据查看+统计+导出锚点(不给填报也算得出数据);学生组=只给填报行
|
||||
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/**,而后端守卫扫的是【中心会话树】(签名调用方=表单中心自己),
|
||||
-- 不消费快应用自己的"数据查看"行。只勾快应用行的教师组进应用数据视图会被后端拒。
|
||||
-- 演练要端到端跑通,就必须给同一批组**再勾中心的 /admin/data 行**(下面这条就是干这个的)。
|
||||
-- 这不是"权限放宽"的偷懒——它把两棵树的关系显式写在登记脚本里,避免演练时把它当 bug 报。
|
||||
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 = @center_app_id AND @grp_teacher > 0 AND m.path = '/admin/data'
|
||||
AND NOT EXISTS (SELECT 1 FROM sys_group_module_perm p WHERE p.group_id = @grp_teacher AND p.module_id = m.id);
|
||||
|
||||
-- ----------------------------------------------------------------------------
|
||||
-- 步骤 4:验收(对应任务书 T9 演练清单与手册 §2.2"验证"行)
|
||||
-- ①超管登录中心:/form-center/api/base/GetUserMod 返回步骤 1 的树(前端导航恰 5 项,锚点行不出现)
|
||||
-- ②无授权用户:只见"我的填写"
|
||||
-- ③快应用上下文:/form-center/api/base/GetUserMod?appCode=fc-drill 返回步骤 2 那棵树
|
||||
-- ⚠ 这条要 **H4 上线**(UserBase /oapi/user/modules 的可选 appCode 参数)+ 中心消费侧接线才通;
|
||||
-- 中心侧当前按 P3 返回空树+warn,见 T9 报告问题单 P78。
|
||||
-- ④教师组账号进 /form-center/app/fc-drill/data:导出按钮出现(锚点判定生效,P76)
|
||||
-- ⑤学生组账号:V16 门户可见性由开通闸门决定;进应用只见填报
|
||||
-- ----------------------------------------------------------------------------
|
||||
SELECT '中心模块行数(期望 6)' 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
|
||||
UNION ALL
|
||||
SELECT '中心 path 空锚点数(期望 1)', COUNT(*) FROM app_module
|
||||
WHERE app_id = @center_app_id AND (path IS NULL OR path = '')
|
||||
UNION ALL
|
||||
SELECT '已开通中心应用的组数', COUNT(*) FROM sys_group_app_rel WHERE app_id = @center_app_id
|
||||
UNION ALL
|
||||
SELECT '已授权中心模块的组-行对数', COUNT(*) FROM sys_group_module_perm p
|
||||
JOIN app_module m ON m.id = p.module_id WHERE m.app_id = @center_app_id;
|
||||
|
||||
-- ----------------------------------------------------------------------------
|
||||
-- 回滚(演练完要撤就跑这段;顺序与建立相反)
|
||||
-- DELETE p FROM sys_group_module_perm p JOIN app_module m ON m.id=p.module_id
|
||||
-- WHERE m.app_id IN (@center_app_id, @quick_app_id);
|
||||
-- DELETE FROM sys_group_app_rel WHERE app_id IN (@center_app_id, @quick_app_id);
|
||||
-- DELETE FROM app_module WHERE app_id IN (@center_app_id, @quick_app_id);
|
||||
-- 应用行不在此删(D7:应用身份由超管在管理端维护,脚本不碰主数据删除)
|
||||
-- ----------------------------------------------------------------------------
|
||||
@@ -0,0 +1,118 @@
|
||||
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.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.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 填报面的"我的"数据读改端点:T7 的填报页内嵌"我的提交"段(按单表单)+
|
||||
* T8 的全局两视图 {@code /my/submit}(我提交的)与 {@code /my/about}(关于我的,V18)。
|
||||
*
|
||||
* <p>纪律:</p>
|
||||
* <ul>
|
||||
* <li><b>身份一律会话推导</b>:不接受外部传 submitterId/schId(V17);</li>
|
||||
* <li><b>数据面身份只用 uuid</b>:answerId 是答卷 uuid,库内 id 不出现在任何外层(契约 §1/V5);</li>
|
||||
* <li><b>权限闸门</b>:普通用户只能看/改/删<b>自己提交</b>的答卷(submitter_id=会话用户),
|
||||
* "关于我的"只读(我是被代填人时可看不可改——改是填表人或管理员的动作);
|
||||
* 有管理权者另有 /api/form/admin/data/**(T4 已落地,scope 矩阵生效),两套口径不混用;</li>
|
||||
* <li><b>本控制器没有状态端点</b>:填表人改状态在接口层即不存在(V14 的实现形态)。</li>
|
||||
* </ul>
|
||||
*/
|
||||
@Slf4j
|
||||
@RestController
|
||||
@RequestMapping("/api/form/my")
|
||||
@RequiredArgsConstructor
|
||||
public class FormMyAnswerController {
|
||||
|
||||
private final AnswerReadService readService;
|
||||
|
||||
/** 我的答卷列表(按 submitter 过滤,"我的提交"段) */
|
||||
@GetMapping("/list")
|
||||
public List<ReadModels.RowView> list(BoaUser user,
|
||||
@RequestParam("formCode") String formCode,
|
||||
@RequestParam(value = "appCode", required = false) String appCode) {
|
||||
requireSession(user);
|
||||
return readService.listBySubmitter(user, formCode, appCode);
|
||||
}
|
||||
|
||||
/**
|
||||
* "我提交的"视图({@code /my/submit},T8):跨表单、按 {@code submitter_id}=会话用户。
|
||||
*
|
||||
* <p>与 {@code /my/about} 是<b>两个端点、两个谓词</b>,不是一个端点加 view 参数
|
||||
* (方案 §6.3 原文:"做成两个视图不做一个接口加参数(鉴权分支容易写漏)")。
|
||||
* 这里<b>不看模块行、不看契约 §8 范围矩阵</b>——数据管理面那套范围管的是"管理员能看谁的行",
|
||||
* 本面管的是"我自己的行",两套口径混用会让填表人不授权模块行就看不见自己的数据。</p>
|
||||
*/
|
||||
@GetMapping("/submit-list")
|
||||
public List<MyModels.ItemView> submitList(BoaUser user) {
|
||||
requireSession(user);
|
||||
return readService.listMine(user, false);
|
||||
}
|
||||
|
||||
/** "关于我的"视图({@code /my/about},T8、V18):跨表单、按 {@code subject_id}=会话用户(含他人代填) */
|
||||
@GetMapping("/about-list")
|
||||
public List<MyModels.ItemView> aboutList(BoaUser user) {
|
||||
requireSession(user);
|
||||
return readService.listMine(user, true);
|
||||
}
|
||||
|
||||
/**
|
||||
* 我的答卷详情(uuid 寻址):<b>钉住版本</b>的内联展开 schema + 值文档(形状与
|
||||
* {@code /api/form/admin/data/detail} 同一个 {@link ReadModels.DetailView},渲染与校验同源)。
|
||||
*
|
||||
* <p>归属判定=我是提交人或我是被代填人,别人的答卷按"不存在"回答(不给探测面);
|
||||
* 不在这里的动作:改状态(V14——本控制器没有任何状态端点,结构即证据)。</p>
|
||||
*/
|
||||
@GetMapping("/detail")
|
||||
public ReadModels.DetailView detail(BoaUser user, @RequestParam("answerId") String answerId) {
|
||||
requireSession(user);
|
||||
return readService.myDetail(user, answerId);
|
||||
}
|
||||
|
||||
/** 软删我的答卷(确认对话框后调用;软删释放限次名额,契约 §5) */
|
||||
@PostMapping("/delete")
|
||||
public void delete(BoaUser user, @RequestBody DeleteRequest body) {
|
||||
requireSession(user);
|
||||
readService.softDeleteBySubmitter(user, body.getAnswerId());
|
||||
}
|
||||
|
||||
/** 编辑我的答卷(按钉住 version 校验;不改状态、不改身份) */
|
||||
@PostMapping("/edit")
|
||||
public ReadModels.RowView edit(BoaUser user, @RequestBody EditRequest body) {
|
||||
requireSession(user);
|
||||
return readService.editBySubmitter(user, body.getAnswerId(), body.getValues(), body.getSummary());
|
||||
}
|
||||
|
||||
private static void requireSession(BoaUser user) {
|
||||
if (user == null || user.getId() == null) {
|
||||
throw new BizException(ErrorCode.UNAUTHORIZED, "未登录");
|
||||
}
|
||||
}
|
||||
|
||||
@lombok.Getter
|
||||
@lombok.Setter
|
||||
public static class DeleteRequest {
|
||||
private String answerId;
|
||||
}
|
||||
|
||||
@lombok.Getter
|
||||
@lombok.Setter
|
||||
public static class EditRequest {
|
||||
private String answerId;
|
||||
private com.fasterxml.jackson.databind.JsonNode values;
|
||||
private String summary;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
package com.yunhai.formcenter.model;
|
||||
|
||||
import lombok.Getter;
|
||||
import lombok.Setter;
|
||||
|
||||
/**
|
||||
* "我的填写"两视图({@code /my/submit} 与 {@code /my/about})的列表出参(方案 §6.3、任务书 T8)。
|
||||
*
|
||||
* <p><b>为什么不是 {@link ReadModels.RowView}</b>:那两个视图是<b>跨表单</b>的——
|
||||
* 一个学生/老师的名下可能挂着好几张表单的答卷,行上必须带表单身份(名称/code/归属应用)才认得出是哪张表。
|
||||
* {@code RowView} 是"单表单数据列表"的行形状(列由前端按该表单 schema 展),没有这两列。</p>
|
||||
*
|
||||
* <p><b>为什么不含 {@code values}</b>:跨表单列表没有统一列可展,值文档在这里没有展示位;
|
||||
* 要看值走 {@code /api/form/my/detail}(钉住版本 + 值文档一起给,渲染与校验同源)。
|
||||
* 少带一个 MEDIUMTEXT 列也与方案 §5.5 铁律①同向。</p>
|
||||
*
|
||||
* <p>对外身份仍然只有 uuid:{@code answerId}/{@code formUuid};库内 {@code id} 不外露(契约 §1、V5)。
|
||||
* {@code submitterId}/{@code subjectId} 是<b>用户</b> id(平台全局身份,不是本库自增主键),
|
||||
* 与数据管理面 {@code RowView} 同口径。</p>
|
||||
*/
|
||||
public final class MyModels {
|
||||
|
||||
private MyModels() {
|
||||
}
|
||||
|
||||
/** 一行"我的答卷":答卷身份 + 归属表单身份 + 状态/报名号/提交时间 */
|
||||
@Getter
|
||||
@Setter
|
||||
public static class ItemView {
|
||||
private String answerId;
|
||||
private String formUuid;
|
||||
/** 归属应用(中心=form-center,快应用=该应用 app_code) */
|
||||
private String appCode;
|
||||
private String code;
|
||||
/** 表单名称;归属表单已被彻底取不到时为 null,前端降级显示 code */
|
||||
private String formName;
|
||||
/** 该答卷钉住的版本号(详情/编辑按它取 schema) */
|
||||
private Integer version;
|
||||
private String status;
|
||||
/** 状态 label;已废弃 code 为 null,前端标"已废弃:code"(方案 §5.3①) */
|
||||
private String statusLabel;
|
||||
private Integer submitterId;
|
||||
private Integer subjectId;
|
||||
private String answerNo;
|
||||
/** yyyy-MM-dd HH:mm:ss */
|
||||
private String submitTime;
|
||||
}
|
||||
}
|
||||
@@ -57,4 +57,14 @@ public interface AnswerRepository extends JpaRepository<Answer, Long> {
|
||||
+ "order by a.id asc")
|
||||
List<Answer> findLiveByFormUuidAfterId(@Param("formUuid") String formUuid, @Param("cursor") Long cursor,
|
||||
Pageable pageable);
|
||||
|
||||
/** 我的提交段(T7)/ "我提交的"视图(T8):按 submitter 过滤本人答卷,按 id 降序(最新在前) */
|
||||
List<Answer> findBySchIdAndSubmitterIdOrderByIdDesc(Long schId, Integer submitterId);
|
||||
|
||||
/**
|
||||
* "关于我的"视图(T8,V18):按 subject 过滤——别人<b>代我填</b>的答卷也在这里
|
||||
* (方案 §6.3:谁提交与关于谁是两个角色,学生看到的是"关于我"的那几条,不限于自己填的)。
|
||||
* 与上一条是两个方法而非一个方法加 flag(同视图侧"两个入口不做参数分支"的口径)。
|
||||
*/
|
||||
List<Answer> findBySchIdAndSubjectIdOrderByIdDesc(Long schId, Integer subjectId);
|
||||
}
|
||||
|
||||
@@ -2,18 +2,27 @@ package com.yunhai.formcenter.service;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.yunhai.formcenter.common.FormFilterOperators;
|
||||
import com.yunhai.formcenter.common.FormRuntimeRules;
|
||||
import com.yunhai.formcenter.common.FormSchemaValidator;
|
||||
import com.yunhai.formcenter.common.FormStatusDefs;
|
||||
import com.yunhai.formcenter.common.FormValueValidator;
|
||||
import com.yunhai.formcenter.entity.Answer;
|
||||
import com.yunhai.formcenter.entity.AnswerOpLog;
|
||||
import com.yunhai.formcenter.entity.Form;
|
||||
import com.yunhai.formcenter.entity.FormFile;
|
||||
import com.yunhai.formcenter.model.MyModels;
|
||||
import com.yunhai.formcenter.model.ReadModels;
|
||||
import com.yunhai.formcenter.repository.AnswerOpLogRepository;
|
||||
import com.yunhai.formcenter.repository.AnswerRepository;
|
||||
import com.yunhai.formcenter.repository.FillQuotaRepository;
|
||||
import com.yunhai.formcenter.repository.FormFileRepository;
|
||||
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 org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
@@ -35,6 +44,11 @@ import java.util.Map;
|
||||
* <p>三处纪律:列表走<b>两段式</b>(先取当页 id、再按 id 批量取值文档,铁律①);
|
||||
* 翻页走 <b>keyset</b> 且游标对外是 uuid(铁律② + 契约 §1 id 不外露);
|
||||
* 软删答卷在四条路径一律不可见(V30,谓词在构造器里)。</p>
|
||||
*
|
||||
* <p><b>末尾的"我的填写"段是另一条线,不要与上面四条混读</b>:那里的可见集来自
|
||||
* {@code submitter_id}/{@code subject_id}=会话用户的<b>归属判定</b>,不是契约 §8 的范围矩阵
|
||||
* (填表人不授权任何模块行也能看自己的数据,方案 §6.3"我的填写两层落点");
|
||||
* 它也不接受 {@code schId} 外部传入,租户域同样只从会话推导(V17)。</p>
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
@@ -48,6 +62,10 @@ public class AnswerReadService {
|
||||
private final DataScopeService scopeService;
|
||||
private final AnswerScopeQueryBuilder queryBuilder;
|
||||
private final AnswerRepository answerRepository;
|
||||
private final AnswerIndexService indexService;
|
||||
private final FillQuotaRepository quotaRepository;
|
||||
private final FormFileRepository fileRepository;
|
||||
private final AnswerOpLogRepository opLogRepository;
|
||||
|
||||
// ==================== 规格构造(四路径共用) ====================
|
||||
|
||||
@@ -255,4 +273,230 @@ public class AnswerReadService {
|
||||
}
|
||||
return labels;
|
||||
}
|
||||
|
||||
// ==================== 我的提交(T7 填报页内嵌段) ====================
|
||||
|
||||
/**
|
||||
* 我的答卷列表(按 submitter 过滤,"我的提交"段)。
|
||||
* 普通用户只能看自己的答卷(submitter_id=会话用户),无数据管理权。
|
||||
*/
|
||||
public List<ReadModels.RowView> listBySubmitter(BoaUser user, String formCode, String appCode) {
|
||||
if (user == null || user.getId() == null) {
|
||||
throw new BizException(ErrorCode.UNAUTHORIZED, "未登录");
|
||||
}
|
||||
Long schId = sessionSchId(user);
|
||||
if (schId == null) {
|
||||
throw new BizException(ErrorCode.FORBIDDEN, "当前身份无学校归属");
|
||||
}
|
||||
String app = (appCode == null || appCode.trim().isEmpty()) ? "form-center" : appCode.trim();
|
||||
// 与填报页加载同一条解析(契约 §1 resolve 是读接口唯一入口):不存在=PARAM_ERROR、未发布/停用=CUSTOM
|
||||
Form form = defService.resolvePublished(schId, app, formCode).form();
|
||||
List<Answer> answers = answerRepository.findBySchIdAndSubmitterIdOrderByIdDesc(schId, user.getId());
|
||||
List<ReadModels.RowView> rows = new ArrayList<>();
|
||||
for (Answer answer : answers) {
|
||||
if (!answer.getAppCode().equals(app) || !answer.getCode().equals(formCode)) {
|
||||
continue;
|
||||
}
|
||||
if (Integer.valueOf(1).equals(answer.getIsDeleted())) {
|
||||
continue;
|
||||
}
|
||||
rows.add(toRow(answer, form));
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
/**
|
||||
* 软删我的答卷(确认对话框后调用;软删释放限次名额,契约 §5)。
|
||||
* 普通用户只能删自己提交的答卷(submitter_id=会话用户;被代填的答卷删不动——语义上是"我填的")。
|
||||
*/
|
||||
@Transactional
|
||||
public void softDeleteBySubmitter(BoaUser user, String answerId) {
|
||||
if (user == null || user.getId() == null) {
|
||||
throw new BizException(ErrorCode.UNAUTHORIZED, "未登录");
|
||||
}
|
||||
Long schId = sessionSchId(user);
|
||||
if (schId == null) {
|
||||
throw new BizException(ErrorCode.FORBIDDEN, "当前身份无学校归属");
|
||||
}
|
||||
Answer answer = requireLiveAnswer(schId, answerId);
|
||||
if (!answer.getSubmitterId().equals(user.getId())) {
|
||||
throw new BizException(ErrorCode.FORBIDDEN, "只能删除自己提交的答卷");
|
||||
}
|
||||
answer.setIsDeleted(1);
|
||||
answerRepository.save(answer);
|
||||
// 与管理员软删同口径:索引行同步删、限次名额释放(V30/V32),否则删了行仍挡着本人重填
|
||||
indexService.clear(answer.getId());
|
||||
quotaRepository.releaseQuota(
|
||||
defService.pinnedForData(answer.getFormUuid(), answer.getVersion()).form().getId(),
|
||||
FormRuntimeRules.quotaKey(answer.getSubjectId(), answer.getSubmitterId()));
|
||||
opLog(answer.getId(), AnswerOpLog.OP_SOFT_DELETE, user.getId(), "本人删除自己的提交(软删)");
|
||||
log.info("[我的提交] 软删答卷 answerId={} submitterId={}", answerId, user.getId());
|
||||
}
|
||||
|
||||
/**
|
||||
* 编辑我的答卷(按钉住 version 校验;不改状态、不改身份)。
|
||||
* 普通用户只能编辑自己提交的答卷(submitter_id=会话用户)。
|
||||
*/
|
||||
@Transactional
|
||||
public ReadModels.RowView editBySubmitter(BoaUser user, String answerId, JsonNode values, String summary) {
|
||||
if (user == null || user.getId() == null) {
|
||||
throw new BizException(ErrorCode.UNAUTHORIZED, "未登录");
|
||||
}
|
||||
Long schId = sessionSchId(user);
|
||||
if (schId == null) {
|
||||
throw new BizException(ErrorCode.FORBIDDEN, "当前身份无学校归属");
|
||||
}
|
||||
Answer answer = requireLiveAnswer(schId, answerId);
|
||||
if (!answer.getSubmitterId().equals(user.getId())) {
|
||||
throw new BizException(ErrorCode.FORBIDDEN, "只能编辑自己提交的答卷");
|
||||
}
|
||||
FormDefService.Published pinned = defService.pinnedForData(answer.getFormUuid(), answer.getVersion());
|
||||
Form form = pinned.form();
|
||||
String valuesJson = values == null || values.isNull() ? "{}" : values.toString();
|
||||
// 与管理员编辑同一条值契约(按钉住版本、字典放行集);P34:附件三件套必须对得上本校上传记录
|
||||
FormValueValidator.validate(valuesJson, pinned.versionRow().getSchemaJson(),
|
||||
dictCode -> dictService.allowedValues(answer.getSchId(), dictCode));
|
||||
assertAttachmentsOwned(answer.getSchId(), valuesJson, pinned.versionRow().getSchemaJson());
|
||||
answer.setValuesJson(valuesJson);
|
||||
answerRepository.save(answer);
|
||||
// 索引按钉住版本重建(方案 §5.4),否则改了可筛字段的值而筛选结果不变
|
||||
indexService.rebuild(answer, pinned.versionRow().getSchemaJson());
|
||||
opLog(answer.getId(), AnswerOpLog.OP_EDIT, user.getId(), summary == null || summary.trim().isEmpty()
|
||||
? "本人编辑自己的提交(钉住版本 v" + answer.getVersion() + ")"
|
||||
: "本人编辑:钉住版本 v" + answer.getVersion() + ";" + summary.trim());
|
||||
log.info("[我的提交] 编辑答卷 answerId={} submitterId={} v={}", answerId, user.getId(),
|
||||
answer.getVersion());
|
||||
return toRow(answer, form);
|
||||
}
|
||||
|
||||
// ==================== 我的填写两视图(T8:/my/submit 按 submitter、/my/about 按 subject) ====================
|
||||
|
||||
/**
|
||||
* 全局"我的填写"两视图的列表读取。<b>两个视图 = 两个谓词、两个入口方法</b>(方案 §6.3:
|
||||
* "做成两个视图不做一个接口加参数"),鉴权分支不写在同一个方法体里靠 flag 分岔:
|
||||
* {@code bySubject=false} 按 {@code submitter_id}=会话用户(我提交的),
|
||||
* {@code bySubject=true} 按 {@code subject_id}=会话用户(关于我的,含管理员代填的那几条,V18)。
|
||||
*
|
||||
* <p>与数据管理面的区别:那条路径的范围来自契约 §8 矩阵({@link DataScopeService})且要先有
|
||||
* {@code /admin/data} 模块行;这里<b>不看模块行、不看范围矩阵</b>,只看"这条数据是不是我的",
|
||||
* 所以填表人(学生)不授权任何管理模块行也能用自己的两视图。</p>
|
||||
*/
|
||||
public List<MyModels.ItemView> listMine(BoaUser user, boolean bySubject) {
|
||||
if (user == null || user.getId() == null) {
|
||||
throw new BizException(ErrorCode.UNAUTHORIZED, "未登录");
|
||||
}
|
||||
Long schId = sessionSchId(user);
|
||||
if (schId == null) {
|
||||
throw new BizException(ErrorCode.FORBIDDEN, "当前身份无学校归属");
|
||||
}
|
||||
List<Answer> answers = bySubject
|
||||
? answerRepository.findBySchIdAndSubjectIdOrderByIdDesc(schId, user.getId())
|
||||
: answerRepository.findBySchIdAndSubmitterIdOrderByIdDesc(schId, user.getId());
|
||||
// 表头信息按 form_uuid 去重取(跨表单列表,一页内同表单多行时不重复查头表)
|
||||
Map<String, Form> forms = new HashMap<>();
|
||||
List<MyModels.ItemView> items = new ArrayList<>();
|
||||
for (Answer answer : answers) {
|
||||
if (Integer.valueOf(1).equals(answer.getIsDeleted())) {
|
||||
continue;
|
||||
}
|
||||
Form form = forms.computeIfAbsent(answer.getFormUuid(), uuid -> findFormQuietly(schId, uuid));
|
||||
items.add(toMyItem(answer, form));
|
||||
}
|
||||
return items;
|
||||
}
|
||||
|
||||
/**
|
||||
* 我的答卷详情(uuid 寻址):<b>钉住版本</b>的内联展开 schema + 值文档,
|
||||
* 与 {@link #detail} 同一份形状(渲染与校验同源——两视图的编辑弹层直接按它出控件)。
|
||||
* 归属判定=我是提交人 <em>或</em> 我是被代填人;后者只读——读路径上<b>没有</b> editable 之类的
|
||||
* 服务端判定字段(P75 修正:前端按列表行的 {@code submitterId} 是否等于会话用户决定编辑入口显隐,
|
||||
* 真正的拒绝在 {@link #editBySubmitter}/{@link #softDeleteBySubmitter} 的提交人判定里);
|
||||
* 改状态在这一面压根没有入口——V14/V29。
|
||||
*/
|
||||
public ReadModels.DetailView myDetail(BoaUser user, String answerId) {
|
||||
if (user == null || user.getId() == null) {
|
||||
throw new BizException(ErrorCode.UNAUTHORIZED, "未登录");
|
||||
}
|
||||
Long schId = sessionSchId(user);
|
||||
if (schId == null) {
|
||||
throw new BizException(ErrorCode.FORBIDDEN, "当前身份无学校归属");
|
||||
}
|
||||
Answer answer = requireLiveAnswer(schId, answerId);
|
||||
boolean mine = user.getId().equals(answer.getSubmitterId()) || user.getId().equals(answer.getSubjectId());
|
||||
if (!mine) {
|
||||
// 别人的答卷与不存在的答卷同一口径回答,差别信息就是探测面(契约 §1:uuid 不可枚举)
|
||||
throw new BizException(ErrorCode.PARAM_ERROR, "答卷不存在或已删除:" + answerId);
|
||||
}
|
||||
FormDefService.Published pinned = defService.pinnedForData(answer.getFormUuid(), answer.getVersion());
|
||||
Form form = pinned.form();
|
||||
ReadModels.DetailView view = new ReadModels.DetailView();
|
||||
copyInto(answer, form, view);
|
||||
view.setAppCode(form.getAppCode());
|
||||
view.setCode(form.getCode());
|
||||
view.setFormName(form.getName());
|
||||
view.setGroups(groupsNode(dictService.expandSchema(form.getSchId(), pinned.versionRow().getSchemaJson())));
|
||||
return view;
|
||||
}
|
||||
|
||||
private MyModels.ItemView toMyItem(Answer answer, Form form) {
|
||||
MyModels.ItemView item = new MyModels.ItemView();
|
||||
item.setAnswerId(answer.getUuid());
|
||||
item.setFormUuid(answer.getFormUuid());
|
||||
item.setAppCode(answer.getAppCode());
|
||||
item.setCode(answer.getCode());
|
||||
item.setFormName(form == null ? null : form.getName());
|
||||
item.setVersion(answer.getVersion());
|
||||
item.setStatus(answer.getStatus());
|
||||
item.setStatusLabel(form == null ? null : FormStatusDefs.labelOf(form.getStatusDefs(), answer.getStatus()));
|
||||
item.setSubmitterId(answer.getSubmitterId());
|
||||
item.setSubjectId(answer.getSubjectId());
|
||||
item.setAnswerNo(answer.getAnswerNo());
|
||||
item.setSubmitTime(format(answer.getCreateTime()));
|
||||
return item;
|
||||
}
|
||||
|
||||
/** 会话校(Integer→Long 换算走已有字段,契约 §7 身份行:外部不传) */
|
||||
private static Long sessionSchId(BoaUser user) {
|
||||
Integer schId = user.getCurrentRole() == null ? null : user.getCurrentRole().getSchId();
|
||||
return schId == null ? null : Long.valueOf(schId);
|
||||
}
|
||||
|
||||
/** 表单头表静默取数:答卷归属的表单理论上一定在(P32 连软删的都还在),取不到就让表名列降级为空 */
|
||||
private Form findFormQuietly(Long schId, String formUuid) {
|
||||
try {
|
||||
return defService.readDomainForm(schId, formUuid);
|
||||
} catch (BizException e) {
|
||||
log.warn("[我的填写] 答卷归属表单取不到,表名列降级显示 code:formUuid={} 原因={}", formUuid, e.getMessage());
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** 附件归属与一致性(P24 同校 + P34 key/fileName 与上传记录一致),判据与管理员编辑侧同源 */
|
||||
private void assertAttachmentsOwned(Long schId, String valuesJson, String schemaJson) {
|
||||
for (JsonNode item : FormValueValidator.attachments(valuesJson, schemaJson)) {
|
||||
long id = item.get("id").longValue();
|
||||
FormFile file = fileRepository.findByIdAndSchId(id, schId).orElse(null);
|
||||
if (file == null) {
|
||||
throw new BizException(ErrorCode.PARAM_ERROR, "附件不存在或不属于本校:id=" + id);
|
||||
}
|
||||
String key = item.path("key").asText(null);
|
||||
if (key == null || !key.equals(file.getStoreKey())) {
|
||||
throw new BizException(ErrorCode.PARAM_ERROR, "附件 key 与上传记录不一致:id=" + id);
|
||||
}
|
||||
String fileName = item.path("fileName").asText(null);
|
||||
if (fileName == null || !fileName.equals(file.getFileName())) {
|
||||
throw new BizException(ErrorCode.PARAM_ERROR, "附件文件名与上传记录不一致:id=" + id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** 留痕形态与管理员侧同源(方案 §5.4:编辑/删除都进 answer_op_log),只是操作人=本人 */
|
||||
private void opLog(Long answerId, String op, Integer operatorId, String summary) {
|
||||
AnswerOpLog entry = new AnswerOpLog();
|
||||
entry.setAnswerId(answerId);
|
||||
entry.setOp(op);
|
||||
entry.setOperatorId(operatorId);
|
||||
entry.setSummary(AnswerOpLog.summarize(summary));
|
||||
opLogRepository.save(entry);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,500 @@
|
||||
package com.yunhai.formcenter.service;
|
||||
|
||||
import com.yunhai.formcenter.controller.api.FormMyAnswerController;
|
||||
import com.yunhai.formcenter.entity.Answer;
|
||||
import com.yunhai.formcenter.entity.AnswerOpLog;
|
||||
import com.yunhai.formcenter.enums.FormEnums;
|
||||
import com.yunhai.formcenter.model.AnswerModels;
|
||||
import com.yunhai.formcenter.model.FormModels;
|
||||
import com.yunhai.formcenter.model.MyModels;
|
||||
import com.yunhai.formcenter.model.ReadModels;
|
||||
import com.yunhai.formcenter.repository.AnswerIndexRepository;
|
||||
import com.yunhai.formcenter.repository.AnswerOpLogRepository;
|
||||
import com.yunhai.formcenter.repository.FillQuotaRepository;
|
||||
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.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.test.context.SpringBootTest;
|
||||
import org.springframework.context.annotation.Import;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
|
||||
import java.lang.reflect.Field;
|
||||
import java.lang.reflect.Method;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
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;
|
||||
import static org.assertj.core.api.Assertions.catchThrowableOfType;
|
||||
|
||||
/**
|
||||
* "我的填写"面六端点集成测试(真实 MySQL;问题单 <b>P70/P75</b> 的单测缺口在此闭环)。
|
||||
*
|
||||
* <p><b>打控制器方法而不是只打服务</b>:六端点的身份闸门 {@code requireSession} 与"入参里没有身份位"
|
||||
* 这两条纪律都落在控制器层,只测服务会把它们留在断言之外(P75 的教训正是"扩了面却没测面")。
|
||||
* 这里不走 MockMvc——CAS 会话由 {@link BoaUser} 形参注入,直调方法就是线上的调用形状。</p>
|
||||
*
|
||||
* <p>五组断言按 P75 裁决逐条落地:①身份全会话推导(外部传 id 结构性不可能,V17)②越权无探测面
|
||||
* ③软删释放名额(删后可重填)④两视图谓词分离(submitter/subject 各归各、互不串,V18)
|
||||
* ⑤myDetail 归属(提交人或被代填人可读,他人按"不存在"答)。</p>
|
||||
*/
|
||||
@SpringBootTest
|
||||
@Import(AnswerTestSupport.TestGatewayConfiguration.class)
|
||||
@Transactional
|
||||
@DisplayName("FormMyAnswerController - 我的填写六端点(P70/P75:V17/V18/V29/V30/V32/V14/V5)")
|
||||
class MyAnswerIT extends AnswerTestSupport {
|
||||
|
||||
/** name/grade 声明 searchable(编辑后索引重建的断言靠它),remark 不声明(V3 半边) */
|
||||
private static final String SCHEMA = "{\"groups\":[{\"key\":\"base\",\"label\":\"基本信息\",\"fields\":["
|
||||
+ "{\"key\":\"name\",\"label\":\"姓名\",\"type\":\"text\",\"required\":true,\"searchable\":true},"
|
||||
+ "{\"key\":\"grade\",\"label\":\"年级\",\"type\":\"select\",\"required\":true,\"searchable\":true,"
|
||||
+ "\"options\":[{\"label\":\"高二\",\"value\":\"g2\"},{\"label\":\"高三\",\"value\":\"g3\"}]},"
|
||||
+ "{\"key\":\"remark\",\"label\":\"备注\",\"type\":\"textarea\"}"
|
||||
+ "]}]}";
|
||||
|
||||
private static final String VALUES = "{\"name\":\"张三\",\"grade\":\"g2\",\"remark\":\"原文\"}";
|
||||
|
||||
/** 升版后新增必填字段——老答卷按钉住版本编辑时不该被它挡住(P20/方案 §5.4) */
|
||||
private static final String SCHEMA_V2 = SCHEMA.replace(
|
||||
"{\"key\":\"remark\"", "{\"key\":\"teacher\",\"label\":\"指导教师\",\"type\":\"text\",\"required\":true},"
|
||||
+ "{\"key\":\"remark\"");
|
||||
|
||||
/** 演练用的快应用归属码(同一 code 在中心与快应用各有一张表单,V6 的我的面形态) */
|
||||
private static final String QUICK_APP = "honour-app";
|
||||
|
||||
@Autowired
|
||||
private FormMyAnswerController controller;
|
||||
|
||||
@Autowired
|
||||
private AnswerIndexRepository indexRepository;
|
||||
|
||||
@Autowired
|
||||
private AnswerOpLogRepository opLogRepository;
|
||||
|
||||
@Autowired
|
||||
private FillQuotaRepository quotaRepository;
|
||||
|
||||
@Autowired
|
||||
private FormAdminGuard adminGuard;
|
||||
|
||||
// ==================== ① 身份全会话推导(V17) ====================
|
||||
|
||||
@Test
|
||||
@DisplayName("V17 结构半边:六端点首参恒为会话用户、无 id 型入参,请求体只有 uuid/值/说明(外部传 id 不可能)")
|
||||
void endpointsCarryNoIdentityParameter() {
|
||||
List<String> mapped = new ArrayList<>();
|
||||
for (Method m : FormMyAnswerController.class.getDeclaredMethods()) {
|
||||
GetMapping get = m.getAnnotation(GetMapping.class);
|
||||
PostMapping post = m.getAnnotation(PostMapping.class);
|
||||
if (get == null && post == null) {
|
||||
continue;
|
||||
}
|
||||
mapped.add((get != null ? "GET " : "POST ")
|
||||
+ (get != null ? get.value()[0] : post.value()[0]));
|
||||
assertThat(m.getParameterTypes()[0])
|
||||
.as("端点 %s 的第一个入参必须是会话用户", m.getName())
|
||||
.isEqualTo(BoaUser.class);
|
||||
assertThat(Arrays.stream(m.getParameterTypes()).filter(t -> t == Long.class || t == Integer.class).count())
|
||||
.as("端点 %s 不接受外部传入的 id 型身份参数", m.getName())
|
||||
.isEqualTo(0L);
|
||||
}
|
||||
assertThat(mapped).containsExactlyInAnyOrder(
|
||||
"GET /list", "GET /submit-list", "GET /about-list", "GET /detail",
|
||||
"POST /delete", "POST /edit");
|
||||
// V14 的结构半边:本面没有任何改状态端点
|
||||
assertThat(mapped).noneMatch(p -> p.contains("status"));
|
||||
assertThat(fieldNames(FormMyAnswerController.DeleteRequest.class)).containsExactly("answerId");
|
||||
assertThat(fieldNames(FormMyAnswerController.EditRequest.class))
|
||||
.containsExactlyInAnyOrder("answerId", "values", "summary");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("V17 行为半边:租户域取自会话——他校用户拿同一条 uuid 读不到,两视图里也没有它")
|
||||
void tenantDomainComesFromSessionNotFromParameter() {
|
||||
String code = "my_v17_tenant";
|
||||
publishForm(code, SCHEMA);
|
||||
BoaUser student = plainUser(3);
|
||||
String answerId = answerService.submit(student, submit(code, VALUES)).getAnswerId();
|
||||
|
||||
BoaUser otherSchoolAdmin = userAtOtherSchool();
|
||||
assertNotFoundShape(() -> controller.detail(otherSchoolAdmin, answerId), answerId);
|
||||
assertThat(idsOf(controller.submitList(otherSchoolAdmin))).doesNotContain(answerId);
|
||||
assertThat(idsOf(controller.aboutList(otherSchoolAdmin))).doesNotContain(answerId);
|
||||
assertThat(idsOf(controller.submitList(user(student.getId(), OTHER_SCH, 3)))).doesNotContain(answerId);
|
||||
// 他校管理员连"删除"都指不到这行(uuid 在同校域外不可定位)
|
||||
assertNotFoundShape(() -> controller.delete(otherSchoolAdmin, deleteBody(answerId)), answerId);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("未登录(会话为空或无 id):六端点一律 UNAUTHORIZED,不查库不落库")
|
||||
void everyEndpointRequiresSession() {
|
||||
String code = "my_v17_session";
|
||||
publishForm(code, SCHEMA);
|
||||
String answerId = answerService.submit(plainUser(3), submit(code, VALUES)).getAnswerId();
|
||||
BoaUser nobody = new BoaUser();
|
||||
|
||||
List<Runnable> calls = Arrays.asList(
|
||||
() -> controller.list(null, code, null),
|
||||
() -> controller.submitList(null),
|
||||
() -> controller.aboutList(null),
|
||||
() -> controller.detail(null, answerId),
|
||||
() -> controller.delete(null, deleteBody(answerId)),
|
||||
() -> controller.edit(null, editBody(answerId, VALUES)),
|
||||
() -> controller.list(nobody, code, null),
|
||||
() -> controller.submitList(nobody),
|
||||
() -> controller.aboutList(nobody),
|
||||
() -> controller.detail(nobody, answerId),
|
||||
() -> controller.delete(nobody, deleteBody(answerId)),
|
||||
() -> controller.edit(nobody, editBody(answerId, VALUES)));
|
||||
for (Runnable call : calls) {
|
||||
BizException e = catchThrowableOfType(call::run, BizException.class);
|
||||
assertThat(e.getErrorCode()).isEqualTo(ErrorCode.UNAUTHORIZED);
|
||||
assertThat(e).hasMessageContaining("未登录");
|
||||
}
|
||||
assertThat(answerRepository.findBySchIdAndAppCodeAndCodeOrderByIdAsc(SCH, "form-center", code)).hasSize(1);
|
||||
}
|
||||
|
||||
// ==================== ② 越权:无探测面 ====================
|
||||
|
||||
@Test
|
||||
@DisplayName("越权读他人答卷=与\"不存在\"同码同形(仅 uuid 不同)——不给\"存在但不属于你\"这个比特")
|
||||
void foreignAnswerIsAnsweredExactlyLikeMissingOne() {
|
||||
String code = "my_probe";
|
||||
publishForm(code, SCHEMA);
|
||||
String answerId = answerService.submit(plainUser(3), submit(code, VALUES)).getAnswerId();
|
||||
BoaUser stranger = plainUser(3);
|
||||
String bogus = "0000000000000000000000000000dead";
|
||||
|
||||
BizException foreign = catchThrowableOfType(() -> controller.detail(stranger, answerId), BizException.class);
|
||||
BizException missing = catchThrowableOfType(() -> controller.detail(stranger, bogus), BizException.class);
|
||||
|
||||
assertThat(foreign.getErrorCode()).isEqualTo(ErrorCode.PARAM_ERROR);
|
||||
assertThat(missing.getErrorCode()).isEqualTo(ErrorCode.PARAM_ERROR);
|
||||
assertThat(foreign.getMessage().replace(answerId, "<id>"))
|
||||
.isEqualTo(missing.getMessage().replace(bogus, "<id>"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("被代填人可读不可改:改/删是提交人动作,非提交人 FORBIDDEN(V29 的我的面形态)")
|
||||
void onlySubmitterCanMutate() {
|
||||
String code = "my_mutate";
|
||||
publishForm(code, SCHEMA);
|
||||
BoaUser teacher = user(10001, SCH, 7);
|
||||
Integer studentId = 20270001;
|
||||
AnswerModels.SubmitRequest req = submit(code, VALUES);
|
||||
req.setSubjectId(studentId);
|
||||
String answerId = answerService.submit(teacher, req).getAnswerId();
|
||||
|
||||
BoaUser student = user(studentId, SCH, 3);
|
||||
assertThat(controller.detail(student, answerId).getAnswerId()).isEqualTo(answerId);
|
||||
|
||||
BizException del = catchThrowableOfType(() -> controller.delete(student, deleteBody(answerId)),
|
||||
BizException.class);
|
||||
assertThat(del.getErrorCode()).isEqualTo(ErrorCode.FORBIDDEN);
|
||||
assertThat(del).hasMessageContaining("只能删除自己提交的答卷");
|
||||
BizException edit = catchThrowableOfType(
|
||||
() -> controller.edit(student, editBody(answerId, "{\"name\":\"改\",\"grade\":\"g3\"}")),
|
||||
BizException.class);
|
||||
assertThat(edit.getErrorCode()).isEqualTo(ErrorCode.FORBIDDEN);
|
||||
assertThat(edit).hasMessageContaining("只能编辑自己提交的答卷");
|
||||
|
||||
// 同校第三人同样改不动,且数据未被改动
|
||||
BoaUser stranger = plainUser(3);
|
||||
assertThatThrownBy(() -> controller.delete(stranger, deleteBody(answerId)))
|
||||
.isInstanceOf(BizException.class).hasMessageContaining("只能删除自己提交的答卷");
|
||||
assertThat(answerRepository.findByUuidAndSchId(answerId, SCH).get().getIsDeleted()).isZero();
|
||||
}
|
||||
|
||||
// ==================== ③ 软删释放名额(删后可重填) ====================
|
||||
|
||||
@Test
|
||||
@DisplayName("V32 我的面半边:本人软删释放限次名额+同步删索引行+留痕,删后可重填;已删行四视图皆不可见(V30)")
|
||||
void selfDeleteReleasesQuotaAndIndexRows() {
|
||||
String code = "my_release";
|
||||
String uuid = publishForm(code, SCHEMA);
|
||||
FormModels.FormConfigRequest once = new FormModels.FormConfigRequest();
|
||||
once.setFillLimit(FormEnums.FillLimit.ONCE.getCode());
|
||||
config(SCH, uuid, once);
|
||||
|
||||
BoaUser student = plainUser(3);
|
||||
String answerId = answerService.submit(student, submit(code, VALUES)).getAnswerId();
|
||||
Long answerDbId = answerRepository.findByUuidAndSchId(answerId, SCH).get().getId();
|
||||
assertThat(indexRepository.findByAnswerIdOrderByIdAsc(answerDbId)).isNotEmpty();
|
||||
assertThat(idsOf(controller.list(student, code, null))).containsExactly(answerId);
|
||||
|
||||
controller.delete(student, deleteBody(answerId));
|
||||
|
||||
Answer row = answerRepository.findByUuidAndSchId(answerId, SCH).get();
|
||||
assertThat(row.getIsDeleted()).isEqualTo(1);
|
||||
assertThat(indexRepository.findByAnswerIdOrderByIdAsc(answerDbId)).isEmpty();
|
||||
assertThat(ops(answerDbId)).contains(AnswerOpLog.OP_SOFT_DELETE);
|
||||
assertThat(quotaRepository.findByFormId(
|
||||
defService.pinnedForData(row.getFormUuid(), row.getVersion()).form().getId())).isEmpty();
|
||||
|
||||
String refillId = answerService.submit(student, submit(code, "{\"name\":\"重填\",\"grade\":\"g3\"}"))
|
||||
.getAnswerId();
|
||||
assertThat(refillId).isNotEqualTo(answerId);
|
||||
assertThat(idsOf(controller.list(student, code, null))).containsExactly(refillId);
|
||||
assertThat(idsOf(controller.submitList(student))).containsExactly(refillId);
|
||||
// 自填未指定被代填人(subject 为空)→"关于我的"按 subject 谓词查,这条不在那里(两谓词各归各,V18)
|
||||
assertThat(idsOf(controller.aboutList(student))).isEmpty();
|
||||
assertNotFoundShape(() -> controller.detail(student, answerId), answerId);
|
||||
}
|
||||
|
||||
// ==================== ④ 两视图谓词分离(V18) ====================
|
||||
|
||||
@Test
|
||||
@DisplayName("V18:代填行只进被代填人\"关于我的\"+操作人\"我提交的\",自填行两视图都在——两谓词互不串")
|
||||
void twoViewsUseSeparatePredicates() {
|
||||
String code = "my_twoview";
|
||||
publishForm(code, SCHEMA);
|
||||
BoaUser teacher = user(10001, SCH, 7);
|
||||
Integer teacherId = teacher.getId();
|
||||
Integer studentId = 20271001;
|
||||
|
||||
AnswerModels.SubmitRequest proxy = submit(code, VALUES);
|
||||
proxy.setSubjectId(studentId);
|
||||
String proxyAnswerId = answerService.submit(teacher, proxy).getAnswerId();
|
||||
|
||||
AnswerModels.SubmitRequest self = submit(code, "{\"name\":\"李四\",\"grade\":\"g3\"}");
|
||||
self.setSubjectId(studentId);
|
||||
String selfAnswerId = answerService.submit(user(studentId, SCH, 3), self).getAnswerId();
|
||||
|
||||
assertThat(idsOf(controller.submitList(teacher))).containsExactly(proxyAnswerId);
|
||||
assertThat(idsOf(controller.aboutList(teacher))).isEmpty();
|
||||
|
||||
BoaUser student = user(studentId, SCH, 3);
|
||||
assertThat(idsOf(controller.submitList(student))).containsExactly(selfAnswerId);
|
||||
assertThat(idsOf(controller.aboutList(student))).containsExactlyInAnyOrder(proxyAnswerId, selfAnswerId);
|
||||
|
||||
MyModels.ItemView proxyRow = controller.aboutList(student).stream()
|
||||
.filter(i -> i.getAnswerId().equals(proxyAnswerId)).findFirst().orElseThrow(AssertionError::new);
|
||||
assertThat(proxyRow.getSubmitterId()).isEqualTo(teacherId);
|
||||
assertThat(proxyRow.getSubjectId()).isEqualTo(studentId);
|
||||
// 前端编辑/删除入口按 submitterId 判 isMine(P75 修正:DetailView 无 editable 字段)
|
||||
assertThat(proxyRow.getSubmitterId()).isNotEqualTo(studentId);
|
||||
assertThat(proxyRow.getFormName()).isEqualTo("测试 " + code);
|
||||
assertThat(proxyRow.getVersion()).isEqualTo(1);
|
||||
assertThat(proxyRow.getStatusLabel()).isEqualTo("待处理");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("两视图不看模块行、不看 §8 范围矩阵:零授权学生仍看得见自己的行;管理面同一人照样被拒")
|
||||
void myViewsIgnoreModuleGrants() {
|
||||
String code = "my_no_grant";
|
||||
publishForm(code, SCHEMA);
|
||||
BoaUser student = plainUser(3);
|
||||
Integer studentId = student.getId();
|
||||
// 自填并把被代填人写成自己:两视图都该有这条(subject=submitter=本人)
|
||||
AnswerModels.SubmitRequest selfFill = submit(code, VALUES);
|
||||
selfFill.setSubjectId(studentId);
|
||||
String answerId = answerService.submit(student, selfFill).getAnswerId();
|
||||
|
||||
moduleTree.clear(studentId);
|
||||
assertThat(idsOf(controller.submitList(student))).containsExactly(answerId);
|
||||
assertThat(idsOf(controller.aboutList(student))).containsExactly(answerId);
|
||||
assertThat(controller.detail(student, answerId).getAnswerId()).isEqualTo(answerId);
|
||||
// 对照:数据管理面要求 /admin/data 模块行,同一个人过去就是 FORBIDDEN(两套口径不混用)
|
||||
assertThatThrownBy(() -> adminGuard.requireAdmin(student, SCH, FormAdminGuard.MODULE_DATA_MANAGE))
|
||||
.isInstanceOf(BizException.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("填报页内嵌\"我的提交\"段:只回本表单+本人行;同 code 的中心表单与快应用表单按归属应用分流不串")
|
||||
void listSegmentIsPerFormAndPerApp() {
|
||||
String code = "my_segment";
|
||||
publishForm(code, SCHEMA);
|
||||
publishFormUnder(QUICK_APP, code, SCHEMA);
|
||||
|
||||
BoaUser me = plainUser(3);
|
||||
BoaUser other = plainUser(3);
|
||||
String mine = answerService.submit(me, submit(code, VALUES)).getAnswerId();
|
||||
String otherRow = answerService.submit(other, submit(code, VALUES)).getAnswerId();
|
||||
AnswerModels.SubmitRequest quick = submit(code, "{\"name\":\"快应用里的我\",\"grade\":\"g2\"}");
|
||||
quick.setAppCode(QUICK_APP);
|
||||
String mineInQuickApp = answerService.submit(me, quick).getAnswerId();
|
||||
|
||||
List<ReadModels.RowView> centerRows = controller.list(me, code, null);
|
||||
assertThat(idsOf(centerRows)).containsExactly(mine);
|
||||
assertThat(centerRows.get(0).getValues().path("name").asText()).isEqualTo("张三");
|
||||
assertThat(idsOf(controller.list(me, code, QUICK_APP))).containsExactly(mineInQuickApp);
|
||||
assertThat(idsOf(centerRows)).doesNotContain(otherRow, mineInQuickApp);
|
||||
// 未发布/不存在的 code 走与填报页同一条 resolve 口径
|
||||
assertThatThrownBy(() -> controller.list(me, "no_such_code", null)).isInstanceOf(BizException.class);
|
||||
}
|
||||
|
||||
// ==================== ⑤ myDetail 归属与钉版 ====================
|
||||
|
||||
@Test
|
||||
@DisplayName("myDetail:提交人与被代填人都可读,给钉住版本的展开 schema+值文档;外层无库内自增 id(V5)")
|
||||
void detailIsSelfAddressedAndSelfContained() {
|
||||
String code = "my_detail";
|
||||
String uuid = publishForm(code, SCHEMA);
|
||||
BoaUser teacher = user(10001, SCH, 7);
|
||||
Integer studentId = 20272001;
|
||||
AnswerModels.SubmitRequest req = submit(code, VALUES);
|
||||
req.setSubjectId(studentId);
|
||||
String answerId = answerService.submit(teacher, req).getAnswerId();
|
||||
|
||||
ReadModels.DetailView asSubmitter = controller.detail(teacher, answerId);
|
||||
ReadModels.DetailView asSubject = controller.detail(user(studentId, SCH, 3), answerId);
|
||||
assertThat(asSubmitter.getAnswerId()).isEqualTo(answerId);
|
||||
assertThat(asSubmitter.getFormUuid()).isEqualTo(uuid);
|
||||
assertThat(asSubmitter.getVersion()).isEqualTo(1);
|
||||
assertThat(asSubmitter.getValues().path("name").asText()).isEqualTo("张三");
|
||||
assertThat(asSubmitter.getGroups().get(0).get("fields").get(0).get("key").asText()).isEqualTo("name");
|
||||
assertThat(asSubject.getGroups().toString()).isEqualTo(asSubmitter.getGroups().toString());
|
||||
// 两个身份位是"用户 id",不是本库自增主键;出参对象里不存在名为 id 的外层字段
|
||||
assertThat(asSubmitter.getSubmitterId()).isEqualTo(teacher.getId());
|
||||
assertThat(asSubmitter.getSubjectId()).isEqualTo(studentId);
|
||||
List<String> outerFields = new ArrayList<>(fieldNames(ReadModels.RowView.class));
|
||||
outerFields.addAll(fieldNames(ReadModels.DetailView.class));
|
||||
assertThat(outerFields).doesNotContain("id");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("P20 钉版半边:升版后老答卷详情/编辑仍按提交时版本,v2 新增必填字段不挡历史行")
|
||||
void detailPinsVersionAfterUpgrade() {
|
||||
String code = "my_pin";
|
||||
String uuid = publishForm(code, SCHEMA);
|
||||
BoaUser student = plainUser(3);
|
||||
String answerId = answerService.submit(student, submit(code, VALUES)).getAnswerId();
|
||||
|
||||
FormModels.FormSaveRequest save = new FormModels.FormSaveRequest();
|
||||
save.setUuid(uuid);
|
||||
save.setSchemaJson(SCHEMA_V2);
|
||||
defService.save(SCH, save);
|
||||
defService.publish(SCH, uuid, null);
|
||||
|
||||
ReadModels.DetailView view = controller.detail(student, answerId);
|
||||
assertThat(view.getVersion()).isEqualTo(1);
|
||||
assertThat(view.getGroups().toString()).doesNotContain("teacher");
|
||||
|
||||
ReadModels.RowView edited = controller.edit(student,
|
||||
editBody(answerId, "{\"name\":\"张三改\",\"grade\":\"g3\",\"remark\":\"按 v1 改\"}"));
|
||||
assertThat(edited.getVersion()).isEqualTo(1);
|
||||
assertThat(edited.getValues().path("name").asText()).isEqualTo("张三改");
|
||||
}
|
||||
|
||||
// ==================== ⑥ 本人编辑(值契约 / 索引 / 留痕 / 身份不动) ====================
|
||||
|
||||
@Test
|
||||
@DisplayName("本人编辑:按钉住版本过值校验、searchable 索引随新值重建、写 answer_op_log、身份与状态列不动")
|
||||
void editKeepsIdentityAndRebuildsIndex() {
|
||||
String code = "my_edit";
|
||||
publishForm(code, SCHEMA);
|
||||
BoaUser student = plainUser(3);
|
||||
Integer studentId = student.getId();
|
||||
String answerId = answerService.submit(student, submit(code, VALUES)).getAnswerId();
|
||||
Long answerDbId = answerRepository.findByUuidAndSchId(answerId, SCH).get().getId();
|
||||
|
||||
ReadModels.RowView row = controller.edit(student,
|
||||
editBody(answerId, "{\"name\":\"张三改名\",\"grade\":\"g3\"}", "改成高三"));
|
||||
assertThat(row.getAnswerId()).isEqualTo(answerId);
|
||||
assertThat(row.getSubmitterId()).isEqualTo(studentId);
|
||||
assertThat(row.getStatus()).isEqualTo("pending");
|
||||
|
||||
Answer stored = answerRepository.findByUuidAndSchId(answerId, SCH).get();
|
||||
assertThat(stored.getValuesJson()).contains("张三改名");
|
||||
assertThat(stored.getSubmitterId()).isEqualTo(studentId);
|
||||
assertThat(stored.getStatus()).isEqualTo("pending");
|
||||
assertThat(indexValue(stored.getId(), "name")).isEqualTo("张三改名");
|
||||
assertThat(indexValue(stored.getId(), "grade")).isEqualTo("g3");
|
||||
List<String> ops = ops(answerDbId);
|
||||
assertThat(ops.get(ops.size() - 1)).isEqualTo(AnswerOpLog.OP_EDIT);
|
||||
assertThat(opLogRepository.findByAnswerIdOrderByIdAsc(answerDbId).get(ops.size() - 1).getSummary())
|
||||
.contains("改成高三");
|
||||
|
||||
// 同一份 schema 也挡住非法值:required 缺失、伪造选项
|
||||
assertThatThrownBy(() -> controller.edit(student, editBody(answerId, "{\"grade\":\"g2\"}")))
|
||||
.isInstanceOf(BizException.class).hasMessageContaining("必填");
|
||||
assertThatThrownBy(() -> controller.edit(student, editBody(answerId, "{\"name\":\"x\",\"grade\":\"g9\"}")))
|
||||
.isInstanceOf(BizException.class).hasMessageContaining("不在候选列表内");
|
||||
// 空 answerId 与缺 uuid 按参数段拒(不是 500)
|
||||
assertThatThrownBy(() -> controller.detail(student, " "))
|
||||
.isInstanceOf(BizException.class).hasMessageContaining("answerId 不能为空");
|
||||
}
|
||||
|
||||
// ==================== 小工具 ====================
|
||||
|
||||
/** 在指定归属应用下建并发布一张同 code 表单(快应用表单与中心表单同 code 共存) */
|
||||
private void publishFormUnder(String appCode, String code, String schemaJson) {
|
||||
FormModels.FormCreateRequest create = new FormModels.FormCreateRequest();
|
||||
create.setAppCode(appCode);
|
||||
create.setCode(code);
|
||||
create.setName("快应用 " + code);
|
||||
String uuid = defService.create(SCH, create).getFormUuid();
|
||||
FormModels.FormSaveRequest save = new FormModels.FormSaveRequest();
|
||||
save.setUuid(uuid);
|
||||
save.setSchemaJson(schemaJson);
|
||||
defService.save(SCH, save);
|
||||
defService.publish(SCH, uuid, null);
|
||||
}
|
||||
|
||||
private FormMyAnswerController.DeleteRequest deleteBody(String answerId) {
|
||||
FormMyAnswerController.DeleteRequest body = new FormMyAnswerController.DeleteRequest();
|
||||
body.setAnswerId(answerId);
|
||||
return body;
|
||||
}
|
||||
|
||||
private FormMyAnswerController.EditRequest editBody(String answerId, String valuesJson) {
|
||||
return editBody(answerId, valuesJson, null);
|
||||
}
|
||||
|
||||
private FormMyAnswerController.EditRequest editBody(String answerId, String valuesJson, String summary) {
|
||||
FormMyAnswerController.EditRequest body = new FormMyAnswerController.EditRequest();
|
||||
body.setAnswerId(answerId);
|
||||
body.setValues(json(valuesJson));
|
||||
body.setSummary(summary);
|
||||
return body;
|
||||
}
|
||||
|
||||
private static com.fasterxml.jackson.databind.JsonNode json(String body) {
|
||||
try {
|
||||
return com.yunhai.formcenter.common.FormSchemaValidator.mapper().readTree(body);
|
||||
} catch (Exception e) {
|
||||
throw new IllegalStateException("测试 JSON 写错了:" + body, e);
|
||||
}
|
||||
}
|
||||
|
||||
private List<String> ops(Long answerDbId) {
|
||||
return opLogRepository.findByAnswerIdOrderByIdAsc(answerDbId).stream()
|
||||
.map(AnswerOpLog::getOp).collect(Collectors.toList());
|
||||
}
|
||||
|
||||
private String indexValue(Long answerDbId, String fieldKey) {
|
||||
return indexRepository.findByAnswerIdOrderByIdAsc(answerDbId).stream()
|
||||
.filter(x -> x.getFieldKey().equals(fieldKey))
|
||||
.map(x -> x.getVStr())
|
||||
.findFirst().orElse(null);
|
||||
}
|
||||
|
||||
private static List<String> idsOf(List<?> rows) {
|
||||
return rows.stream().map(row -> {
|
||||
try {
|
||||
return (String) row.getClass().getMethod("getAnswerId").invoke(row);
|
||||
} catch (Exception e) {
|
||||
throw new IllegalStateException("行形状没有 answerId:" + row.getClass(), e);
|
||||
}
|
||||
}).collect(Collectors.toList());
|
||||
}
|
||||
|
||||
private static List<String> fieldNames(Class<?> type) {
|
||||
return Arrays.stream(type.getDeclaredFields()).map(Field::getName).collect(Collectors.toList());
|
||||
}
|
||||
|
||||
/** 越权与不存在的共同回答口径:PARAM_ERROR + "答卷不存在或已删除" */
|
||||
private void assertNotFoundShape(Runnable call, String answerId) {
|
||||
BizException e = catchThrowableOfType(call::run, BizException.class);
|
||||
assertThat(e.getErrorCode()).isEqualTo(ErrorCode.PARAM_ERROR);
|
||||
assertThat(e).hasMessageContaining("答卷不存在或已删除");
|
||||
assertThat(e).hasMessageContaining(answerId);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,662 @@
|
||||
package com.yunhai.formcenter.service;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.yunhai.formcenter.enums.FormEnums;
|
||||
import com.yunhai.formcenter.model.AnswerModels;
|
||||
import com.yunhai.formcenter.model.FormModels;
|
||||
import com.yunhai.formcenter.model.ReadModels;
|
||||
import com.yunhai.platform.utils.model.BoaModule;
|
||||
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.slf4j.LoggerFactory;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.test.context.SpringBootTest;
|
||||
import org.springframework.boot.test.context.TestConfiguration;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Primary;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
|
||||
import ch.qos.logback.classic.Level;
|
||||
import ch.qos.logback.classic.Logger;
|
||||
import ch.qos.logback.classic.spi.ILoggingEvent;
|
||||
import ch.qos.logback.core.read.ListAppender;
|
||||
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.Paths;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.Collections;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
import java.util.concurrent.Callable;
|
||||
import java.util.concurrent.ExecutorService;
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.concurrent.Future;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
* T9 压测探针(自验项 <b>V10</b> 的量化半边 + 问题单 <b>P26/P35/P37/P44</b> 的实测口径)。
|
||||
*
|
||||
* <p><b>命名即隔离</b>:类名不带 Test/IT 后缀 → 默认 {@code mvn test} 门禁不收集
|
||||
* (pom surefire includes 只收 {@code *Test}/{@code *IT}),手点才跑:
|
||||
* {@code mvn -B -o test -Dtest=Perf200kProbe}。20 万级造数与 20s 级导出不该进每轮门禁。</p>
|
||||
*
|
||||
* <p><b>独立库</b>:造数落 {@code bsch_formcenter_perf}(URL 带 createDatabaseIfNotExist=true、
|
||||
* ddl-auto=update),不碰开发库 bsch_formcenter;压测数据<b>必须真提交</b>(要的是真实表与真实索引),
|
||||
* 跑完由 local-debug 的清理命令整库 DROP。</p>
|
||||
*
|
||||
* <p><b>量级(V10 原文)</b>:全库 20 万答卷、单表单典型 2 万 → 10 张表单 × 2 万;
|
||||
* searchable 四字段各写索引行(≈78 万),每 50 行置 1 行软删(让谓词真实参与)。</p>
|
||||
*
|
||||
* <p><b>口径边界(报告要点名)</b>:测的是 <b>service+SQL 层</b>延迟,不含 HTTP/CAS/JSON 序列化
|
||||
* 的用户面开销,也不含 UserBase 签名调用的网络耗时(模块树被桩顶掉;P44 单独量化<b>每请求取树次数</b>)。
|
||||
* 本机 MySQL 8.0.45 与 160 容器网 mysql8 同大版本。</p>
|
||||
*/
|
||||
@SpringBootTest(properties = {
|
||||
"spring.datasource.url=jdbc:mysql://localhost:3306/bsch_formcenter_perf?createDatabaseIfNotExist=true"
|
||||
+ "&useUnicode=true&characterEncoding=utf8&useSSL=false&serverTimezone=Asia/Shanghai"
|
||||
+ "&allowPublicKeyRetrieval=true",
|
||||
"spring.jpa.hibernate.ddl-auto=update"
|
||||
})
|
||||
@DisplayName("T9 压测探针(V10 阈值 + P26 重号率 + P35 索引选择性 + P37 缓存语义 + P44 取树次数)")
|
||||
class Perf200kProbe {
|
||||
|
||||
private static final long SCH = 990600L;
|
||||
private static final String APP = "form-center";
|
||||
private static final int FORMS = 10;
|
||||
private static final int PER_FORM = 20000;
|
||||
private static final String SCHEMA = "{\"groups\":[{\"key\":\"base\",\"label\":\"基本信息\",\"fields\":["
|
||||
+ "{\"key\":\"name\",\"label\":\"姓名\",\"type\":\"text\",\"required\":true,\"searchable\":true},"
|
||||
+ "{\"key\":\"score\",\"label\":\"分数\",\"type\":\"number\",\"precision\":1,\"searchable\":true},"
|
||||
+ "{\"key\":\"grade\",\"label\":\"年级\",\"type\":\"select\",\"required\":true,\"searchable\":true,"
|
||||
+ "\"options\":[{\"label\":\"高一\",\"value\":\"g1\"},{\"label\":\"高二\",\"value\":\"g2\"},"
|
||||
+ "{\"label\":\"高三\",\"value\":\"g3\"},{\"label\":\"初三\",\"value\":\"g4\"}]},"
|
||||
+ "{\"key\":\"birthday\",\"label\":\"出生日期\",\"type\":\"date\",\"searchable\":true},"
|
||||
+ "{\"key\":\"remark\",\"label\":\"备注\",\"type\":\"textarea\"}"
|
||||
+ "]}]}";
|
||||
|
||||
/** 与 Criteria 生成同形的手写语句(EXPLAIN 用,绑定值换成字面量;对照日志里取到的真实 SQL) */
|
||||
private static final String SQL_LIST_FIRST_PAGE =
|
||||
"select a.id from answer a where a.sch_id=990600 and a.app_code='form-center' and a.code='perf_f01' "
|
||||
+ "and a.is_deleted=0 order by a.id desc limit 20";
|
||||
private static final String SQL_LIST_KEYSET_DEEP =
|
||||
"select a.id from answer a where a.sch_id=990600 and a.app_code='form-center' and a.code='perf_f01' "
|
||||
+ "and a.is_deleted=0 and a.id<18000 order by a.id desc limit 20";
|
||||
private static final String SQL_COUNT =
|
||||
"select count(a.id) from answer a where a.sch_id=990600 and a.app_code='form-center'"
|
||||
+ " and a.code='perf_f01' and a.is_deleted=0";
|
||||
private static final String SQL_GROUP_BY_INDEX =
|
||||
"select x.v_str, count(distinct x.answer_id) from answer_index x where x.sch_id=990600"
|
||||
+ " and x.code='perf_f01' and x.field_key='grade' group by x.v_str";
|
||||
|
||||
@Autowired
|
||||
private JdbcTemplate jdbc;
|
||||
@Autowired
|
||||
private FormDefService defService;
|
||||
@Autowired
|
||||
private AnswerService answerService;
|
||||
@Autowired
|
||||
private AnswerReadService readService;
|
||||
@Autowired
|
||||
private AnswerStatsService statsService;
|
||||
@Autowired
|
||||
private AnswerExportService exportService;
|
||||
@Autowired
|
||||
private CountingModuleTreeSource moduleTree;
|
||||
|
||||
private final List<String> report = new ArrayList<>();
|
||||
private final List<String> sqlLog = new ArrayList<>();
|
||||
private final AtomicInteger userSeq = new AtomicInteger(910000);
|
||||
|
||||
/** 模块树桩的计数版(P44):桩顶掉签名调用的同时把<b>调用次数</b>留下来,否则 P44 只能靠读代码猜 */
|
||||
static class CountingModuleTreeSource extends InMemoryModuleTreeSource {
|
||||
final AtomicInteger calls = new AtomicInteger();
|
||||
|
||||
@Override
|
||||
public List<BoaModule> authorizedModules(BoaUser user) {
|
||||
calls.incrementAndGet();
|
||||
return super.authorizedModules(user);
|
||||
}
|
||||
}
|
||||
|
||||
@TestConfiguration
|
||||
static class PerfGatewayConfiguration {
|
||||
@Bean
|
||||
@Primary
|
||||
CountingModuleTreeSource countingModuleTreeSource() {
|
||||
return new CountingModuleTreeSource();
|
||||
}
|
||||
|
||||
@Bean
|
||||
@Primary
|
||||
InMemorySubjectDirectory perfSubjectDirectory() {
|
||||
return new InMemorySubjectDirectory();
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("V10:20 万答卷下列表/筛选/统计/导出量化阈值 + 计划断言(不含 values 列、深分页走 keyset)")
|
||||
void runAll() throws Exception {
|
||||
BoaUser manager = manager();
|
||||
long setupStart = System.nanoTime();
|
||||
seedDefinitions();
|
||||
seedAnswers();
|
||||
log("SETUP 造数耗时(ms)=" + ms(System.nanoTime() - setupStart) + " 答卷行=" + count("answer")
|
||||
+ " 索引行=" + count("answer_index") + " 库=bsch_formcenter_perf 租户域=" + SCH);
|
||||
|
||||
String uuid = formUuidOf("perf_f01");
|
||||
v10ListAndFilter(manager, uuid);
|
||||
v10DeepKeyset(manager, uuid);
|
||||
v10Stats(manager, uuid);
|
||||
v10Export(manager, uuid);
|
||||
plans(manager, uuid);
|
||||
p37CacheSemantics(manager, uuid);
|
||||
p44TreeCalls(manager, uuid);
|
||||
p26AnswerNoDuplicates();
|
||||
write();
|
||||
}
|
||||
|
||||
// ==================== 造数 ====================
|
||||
|
||||
private void seedDefinitions() {
|
||||
Integer forms = jdbc.queryForObject("select count(*) from form where sch_id=" + SCH, Integer.class);
|
||||
if (forms != null && forms >= FORMS + 1) {
|
||||
log("SEED 定义行已存在(" + forms + "),跳过重建");
|
||||
return;
|
||||
}
|
||||
for (int i = 1; i <= FORMS; i++) {
|
||||
String code = String.format("perf_f%02d", i);
|
||||
String uuid = createForm(code, "压测表单 " + code, SCHEMA);
|
||||
defService.publish(SCH, uuid, "压测种子");
|
||||
}
|
||||
// P26 专用:开报名号 + 不限次(问题单点名的长尾组合)
|
||||
String noUuid = createForm("perf_answerno", "压测表单 报名号",
|
||||
"{\"groups\":[{\"key\":\"g\",\"label\":\"组\",\"fields\":["
|
||||
+ "{\"key\":\"name\",\"label\":\"姓名\",\"type\":\"text\",\"required\":true}]}]}");
|
||||
defService.publish(SCH, noUuid, null);
|
||||
FormModels.FormConfigRequest config = new FormModels.FormConfigRequest();
|
||||
config.setUuid(noUuid);
|
||||
config.setAnswerNoEnabled(1);
|
||||
config.setFillLimit(FormEnums.FillLimit.UNLIMITED.getCode());
|
||||
defService.updateConfig(SCH, config);
|
||||
}
|
||||
|
||||
private String createForm(String code, String name, String schemaJson) {
|
||||
FormModels.FormCreateRequest create = new FormModels.FormCreateRequest();
|
||||
create.setCode(code);
|
||||
create.setName(name);
|
||||
String uuid = defService.create(SCH, create).getFormUuid();
|
||||
FormModels.FormSaveRequest save = new FormModels.FormSaveRequest();
|
||||
save.setUuid(uuid);
|
||||
save.setSchemaJson(schemaJson);
|
||||
defService.save(SCH, save);
|
||||
return uuid;
|
||||
}
|
||||
|
||||
/**
|
||||
* 答卷造数:先建 2 万行的数字辅助表(自乘倍增,无递归 CTE——沿方案 §7.7 的 5.7 保守口径),
|
||||
* 再按表单批量插入。<b>索引行只给未删行的 searchable 字段写</b>,与线上写入器同规则(V3/P28)。
|
||||
*/
|
||||
private void seedAnswers() {
|
||||
int answers = count("answer");
|
||||
if (answers >= FORMS * PER_FORM) {
|
||||
log("SEED 答卷已就位(" + answers + "),跳过造数");
|
||||
if (count("answer_index") == 0) {
|
||||
seedIndexRows();
|
||||
}
|
||||
return;
|
||||
}
|
||||
jdbc.execute("DROP TABLE IF EXISTS perf_num");
|
||||
jdbc.execute("CREATE TABLE perf_num (n INT NOT NULL, PRIMARY KEY (n)) ENGINE=InnoDB");
|
||||
jdbc.update("INSERT INTO perf_num (n) VALUES (1)");
|
||||
while (jdbc.queryForObject("select count(*) from perf_num", Integer.class) < PER_FORM) {
|
||||
Integer max = jdbc.queryForObject("select max(n) from perf_num", Integer.class);
|
||||
jdbc.update("insert into perf_num (n) select n + ? from perf_num", max);
|
||||
}
|
||||
for (int i = 1; i <= FORMS; i++) {
|
||||
String code = String.format("perf_f%02d", i);
|
||||
jdbc.update("insert into answer (uuid, form_uuid, sch_id, app_code, code, version, submitter_id,"
|
||||
+ " subject_id, status, values_json, answer_no, idempotency_key, is_deleted,"
|
||||
+ " create_time, update_time, create_by, update_by)"
|
||||
+ " select replace(uuid(),'-',''), ?, ?, ?, ?, 1, 300000 + mod(n.n, 9000), null,"
|
||||
+ " case when mod(n.n,3)=0 then 'done' else 'pending' end, n.val, null,"
|
||||
+ " replace(uuid(),'-',''), case when mod(n.n,50)=0 then 1 else 0 end,"
|
||||
+ " date_sub(now(), interval n.n minute), date_sub(now(), interval n.n minute),"
|
||||
+ " 'perf', 'perf'"
|
||||
+ " from (select n.n as n, json_object("
|
||||
+ " 'name', concat('stu', lpad(mod(n.n,4000),4,'0')),"
|
||||
+ " 'score', mod(n.n,101),"
|
||||
+ " 'grade', elt(1 + mod(n.n,4),'g1','g2','g3','g4'),"
|
||||
+ " 'birthday', date_format(date_sub(curdate(), interval mod(n.n,400) day),'%Y-%m-%d'),"
|
||||
+ " 'remark', repeat('若',30)) as val"
|
||||
+ " from perf_num n where n.n <= ?) n",
|
||||
formUuidOf(code), SCH, APP, code, PER_FORM);
|
||||
}
|
||||
seedIndexRows();
|
||||
}
|
||||
|
||||
/**
|
||||
* 索引行造数:与线上 {@code AnswerIndexService} 同规则——只给<b>未删</b>答卷的 searchable 字段写行,
|
||||
* 文本/选项进 v_str、数值进 v_num、日期进 v_date(四条语句各写一列,位置对齐表列,不玩动态拼接)。
|
||||
*/
|
||||
private void seedIndexRows() {
|
||||
String head = "insert into answer_index (answer_id, sch_id, code, field_key, v_str, v_num, v_date,"
|
||||
+ " create_time, update_time, create_by, update_by) select a.id, a.sch_id, a.code, ";
|
||||
String tail = ", now(), now(), 'perf', 'perf' from answer a where a.sch_id=" + SCH + " and a.is_deleted=0";
|
||||
jdbc.update(head + "'name', json_unquote(json_extract(a.values_json,'$.name')), null, null" + tail);
|
||||
jdbc.update(head + "'grade', json_unquote(json_extract(a.values_json,'$.grade')), null, null" + tail);
|
||||
jdbc.update(head + "'score', null, cast(json_unquote(json_extract(a.values_json,'$.score'))"
|
||||
+ " as decimal(20,6)), null" + tail);
|
||||
jdbc.update(head + "'birthday', null, null,"
|
||||
+ " cast(json_unquote(json_extract(a.values_json,'$.birthday')) as date)" + tail);
|
||||
jdbc.execute("analyze table answer");
|
||||
jdbc.execute("analyze table answer_index");
|
||||
}
|
||||
|
||||
// ==================== V10 四段 ====================
|
||||
|
||||
private void v10ListAndFilter(BoaUser manager, String uuid) {
|
||||
ReadModels.ListRequest first = new ReadModels.ListRequest();
|
||||
first.setFormUuid(uuid);
|
||||
first.setSize(20);
|
||||
sample("V10 列表 首屏(无筛选,20 行)", 60, () -> readService.list(manager, SCH, first), "阈值 p95≤1500");
|
||||
|
||||
ReadModels.ListRequest filtered = new ReadModels.ListRequest();
|
||||
filtered.setFormUuid(uuid);
|
||||
filtered.setSize(20);
|
||||
filtered.setFilters(Arrays.asList(eq("grade", "g2"), range("score", "30", "80"), prefix("name", "stu0")));
|
||||
sample("V10 列表 三条件筛选(select 等值 + number 区间 + text 前缀,均在 SQL 层)", 60,
|
||||
() -> readService.list(manager, SCH, filtered), "阈值 p95≤1500");
|
||||
|
||||
ReadModels.ListRequest statusOnly = new ReadModels.ListRequest();
|
||||
statusOnly.setFormUuid(uuid);
|
||||
statusOnly.setSize(20);
|
||||
statusOnly.setStatus("done");
|
||||
sample("V10 列表 状态过滤", 30, () -> readService.list(manager, SCH, statusOnly), "阈值 p95≤1500");
|
||||
}
|
||||
|
||||
private void v10DeepKeyset(BoaUser manager, String uuid) {
|
||||
String deepCursor = jdbc.queryForObject("select uuid from answer where code='perf_f01' and sch_id=" + SCH
|
||||
+ " and is_deleted=0 order by id desc limit 1 offset 15000", String.class);
|
||||
ReadModels.ListRequest deep = new ReadModels.ListRequest();
|
||||
deep.setFormUuid(uuid);
|
||||
deep.setSize(20);
|
||||
deep.setCursor(deepCursor);
|
||||
sample("V10 列表 深分页 keyset(游标在第 15000 行之后)", 40, () -> readService.list(manager, SCH, deep),
|
||||
"阈值 p95≤1500;与下面裸 OFFSET 对照");
|
||||
|
||||
long offsetMs = timed(5, () -> jdbc.queryForList(
|
||||
"select id from answer where sch_id=" + SCH + " and app_code='" + APP + "' and code='perf_f01'"
|
||||
+ " and is_deleted=0 order by id desc limit 15000,20"));
|
||||
log("V10 对照 裸 LIMIT 15000,20 平均(ms)=" + offsetMs + " —— keyset 不吃这个扫描成本(铁律②)");
|
||||
}
|
||||
|
||||
private void v10Stats(BoaUser manager, String uuid) {
|
||||
ReadModels.QueryRequest total = new ReadModels.QueryRequest();
|
||||
total.setFormUuid(uuid);
|
||||
sample("V10 统计 总数+按状态", 20, () -> statsService.stats(manager, SCH, total), "阈值 p95≤3000");
|
||||
|
||||
ReadModels.QueryRequest grouped = new ReadModels.QueryRequest();
|
||||
grouped.setFormUuid(uuid);
|
||||
grouped.setGroupByField("grade");
|
||||
sample("V10 统计 按字段分组(grade)", 20, () -> statsService.stats(manager, SCH, grouped), "阈值 p95≤3000");
|
||||
|
||||
ReadModels.QueryRequest filteredStats = new ReadModels.QueryRequest();
|
||||
filteredStats.setFormUuid(uuid);
|
||||
filteredStats.setFilters(Collections.singletonList(eq("grade", "g2")));
|
||||
sample("V10 统计 带筛选分组", 20, () -> statsService.stats(manager, SCH, filteredStats), "阈值 p95≤3000");
|
||||
}
|
||||
|
||||
private void v10Export(BoaUser manager, String uuid) {
|
||||
ReadModels.QueryRequest req = new ReadModels.QueryRequest();
|
||||
req.setFormUuid(uuid);
|
||||
for (int i = 0; i < 3; i++) {
|
||||
long start = System.nanoTime();
|
||||
long rows = exportService.export(manager, SCH, req, new ByteArrayOutputStream());
|
||||
log("V10 导出 单表单全量(2 万级)第 " + (i + 1) + " 轮:行数=" + rows + " 耗时(ms)="
|
||||
+ ms(System.nanoTime() - start) + " 阈值=20000");
|
||||
}
|
||||
ReadModels.QueryRequest filtered = new ReadModels.QueryRequest();
|
||||
filtered.setFormUuid(uuid);
|
||||
filtered.setFilters(Collections.singletonList(eq("grade", "g1")));
|
||||
long start = System.nanoTime();
|
||||
long rows = exportService.export(manager, SCH, filtered, new ByteArrayOutputStream());
|
||||
log("V10 导出 带筛选(grade=g1):行数=" + rows + " 耗时(ms)=" + ms(System.nanoTime() - start));
|
||||
}
|
||||
|
||||
// ==================== 铁律与计划取证 ====================
|
||||
|
||||
private void plans(BoaUser manager, String uuid) {
|
||||
List<String> statements = captureSql(manager, uuid);
|
||||
sqlLog.addAll(statements);
|
||||
|
||||
Optional<String> phase1 = statements.stream()
|
||||
.filter(s -> s.startsWith("select") && s.contains("from answer ") && !s.contains("answer_index")
|
||||
&& !s.toLowerCase().contains("count("))
|
||||
.findFirst();
|
||||
if (!phase1.isPresent()) {
|
||||
log("铁律① 取证失败:日志里没抓到 answer 主表查询(见 perf-sql-and-plans.txt)");
|
||||
} else {
|
||||
String sql = phase1.get();
|
||||
String selectList = sql.substring("select".length(), sql.indexOf(" from answer ")).trim();
|
||||
log("铁律① 第一段 select 列=[" + selectList + "] 含 values_json=" + sql.contains("values_json")
|
||||
+ "(期望 false)");
|
||||
// 游标在本机日志里是内联字面量(id<19980)或占位符(id<?)两种形态都算 keyset 证据
|
||||
boolean keyset = statements.stream().anyMatch(s -> s.matches("(?s).*\\.id\\s*<\\s*(\\d+|\\?).*"));
|
||||
log("铁律② 抓到的语句里存在 keyset 谓词(id<?)=" + keyset + "(本次带游标调用,见下条 EXPLAIN)");
|
||||
log("铁律② 带游标的第一段 SQL=" + trim(statements.stream()
|
||||
.filter(s -> s.contains("<") && s.contains("from answer ") && !s.contains("answer_index"))
|
||||
.reduce((a, b) -> b).orElse("-")));
|
||||
}
|
||||
|
||||
for (String sqlText : Arrays.asList(SQL_LIST_FIRST_PAGE, SQL_LIST_KEYSET_DEEP, SQL_COUNT, SQL_GROUP_BY_INDEX)) {
|
||||
String json = jdbc.queryForObject("explain format=json " + sqlText, String.class);
|
||||
sqlLog.add("EXPLAIN " + sqlText + "\n" + json);
|
||||
log("EXPLAIN " + trim(sqlText) + " → " + pickKeys(json));
|
||||
}
|
||||
log("P35 idx_answer_form_time 选择性(information_schema.statistics):" + jdbc.queryForList(
|
||||
"select index_name, seq_in_index, column_name, cardinality from information_schema.statistics"
|
||||
+ " where table_schema='bsch_formcenter_perf' and table_name='answer'"
|
||||
+ " and index_name in ('idx_answer_form','idx_answer_form_time') order by index_name,"
|
||||
+ " seq_in_index").stream().map(m -> m.toString()).collect(Collectors.joining(" ; ")));
|
||||
log("表规模与行格式(P10/P31 同项在 160 的核对手法):" + jdbc.queryForList(
|
||||
"select table_name, row_format, table_rows, data_length from information_schema.tables"
|
||||
+ " where table_schema='bsch_formcenter_perf' and table_name in ('answer','answer_index')"
|
||||
+ " order by table_name").stream().map(m -> m.toString()).collect(Collectors.joining(" ; ")));
|
||||
}
|
||||
|
||||
/** 打开 org.hibernate.SQL 日志抓真实生成语句(含一次带游标的翻页,让 keyset 谓词出现在样本里) */
|
||||
private List<String> captureSql(BoaUser manager, String uuid) {
|
||||
Logger sql = (Logger) LoggerFactory.getLogger("org.hibernate.SQL");
|
||||
Level old = sql.getLevel();
|
||||
ListAppender<ILoggingEvent> appender = new ListAppender<>();
|
||||
appender.start();
|
||||
sql.addAppender(appender);
|
||||
sql.setLevel(Level.DEBUG);
|
||||
try {
|
||||
ReadModels.ListRequest page = new ReadModels.ListRequest();
|
||||
page.setFormUuid(uuid);
|
||||
page.setSize(20);
|
||||
ReadModels.ListView firstPage = readService.list(manager, SCH, page);
|
||||
page.setCursor(firstPage.getNextCursor());
|
||||
readService.list(manager, SCH, page);
|
||||
|
||||
ReadModels.ListRequest filtered = new ReadModels.ListRequest();
|
||||
filtered.setFormUuid(uuid);
|
||||
filtered.setSize(20);
|
||||
filtered.setFilters(Collections.singletonList(eq("grade", "g2")));
|
||||
readService.list(manager, SCH, filtered);
|
||||
|
||||
ReadModels.QueryRequest stats = new ReadModels.QueryRequest();
|
||||
stats.setFormUuid(uuid);
|
||||
stats.setGroupByField("grade");
|
||||
statsService.stats(manager, SCH, stats);
|
||||
} finally {
|
||||
sql.detachAppender(appender);
|
||||
sql.setLevel(old);
|
||||
}
|
||||
return appender.list.stream().map(ILoggingEvent::getFormattedMessage).map(String::trim)
|
||||
.filter(s -> s.toLowerCase().startsWith("select")).collect(Collectors.toList());
|
||||
}
|
||||
|
||||
// ==================== P26 / P37 / P44 ====================
|
||||
|
||||
private void p37CacheSemantics(BoaUser manager, String uuid) {
|
||||
ReadModels.QueryRequest req = new ReadModels.QueryRequest();
|
||||
req.setFormUuid(uuid);
|
||||
req.setGroupByField("grade");
|
||||
statsService.stats(manager, SCH, req);
|
||||
long baseline = statsService.stats(manager, SCH, req).getTotal();
|
||||
|
||||
// ① 新答卷不失效(方案 §5.5 v1.2:提交是高频事件,每次清缓存等于没有缓存,容忍 N 分钟延迟)
|
||||
// 自填身份:普通用户 subject 只能=自己(V17),这里干脆不传 subject,走"仅本人提交"的常态路径
|
||||
answerService.submit(plainUser(userSeq.incrementAndGet()), submitFor("perf_f01",
|
||||
"{\"name\":\"缓存语义\",\"score\":7,\"grade\":\"g2\",\"birthday\":\"2008-01-01\",\"remark\":\"P37\"}"));
|
||||
ReadModels.StatsView afterSubmit = statsService.stats(manager, SCH, req);
|
||||
log("P37 新答卷后:total=" + afterSubmit.getTotal() + "(基线 " + baseline + ")cached="
|
||||
+ afterSubmit.getCached() + " → 期望 cached=true 且 total 未即时反映新行");
|
||||
|
||||
// ② 升版才失效
|
||||
FormModels.FormSaveRequest save = new FormModels.FormSaveRequest();
|
||||
save.setUuid(uuid);
|
||||
save.setSchemaJson(SCHEMA.replace("{\"key\":\"remark\"",
|
||||
"{\"key\":\"extra\",\"label\":\"附加\",\"type\":\"text\"},{\"key\":\"remark\""));
|
||||
defService.save(SCH, save);
|
||||
defService.publish(SCH, uuid, "P37 升版失效验证");
|
||||
ReadModels.StatsView afterPublish = statsService.stats(manager, SCH, req);
|
||||
log("P37 升版后:total=" + afterPublish.getTotal() + " cached=" + afterPublish.getCached()
|
||||
+ " → 期望 cached=false(键里带当前发布 version,换版即换键)");
|
||||
}
|
||||
|
||||
private void p44TreeCalls(BoaUser manager, String uuid) {
|
||||
ReadModels.ListRequest list = new ReadModels.ListRequest();
|
||||
list.setFormUuid(uuid);
|
||||
list.setSize(20);
|
||||
ReadModels.QueryRequest stats = new ReadModels.QueryRequest();
|
||||
stats.setFormUuid(uuid);
|
||||
|
||||
// 详情用的 uuid 先取好——否则取首屏那一次 list 会被算进详情的计数里
|
||||
String detailUuid = cursorOfFirstRow(manager, list);
|
||||
log("P44 每请求取树次数:列表=" + probe(() -> readService.list(manager, SCH, list))
|
||||
+ " 统计=" + probe(() -> statsService.stats(manager, SCH, stats))
|
||||
+ " 导出=" + probe(() -> exportService.export(manager, SCH, stats, new ByteArrayOutputStream()))
|
||||
+ " 详情=" + probe(() -> readService.detail(manager, SCH, detailUuid)));
|
||||
log("P44 口径:阈值内(V10 p95 达标)按一期现状保持,不改成控制器内一次取树两处复用;"
|
||||
+ "生产侧每次取树=一次 UserBase 签名调用,网络耗时不在本探针口径内(类注释已记档)");
|
||||
}
|
||||
|
||||
private String cursorOfFirstRow(BoaUser manager, ReadModels.ListRequest list) {
|
||||
return readService.list(manager, SCH, list).getRows().get(0).getAnswerId();
|
||||
}
|
||||
|
||||
/** 跑一次并返回这段时间里模块树被取了几次 */
|
||||
private int probe(Runnable body) {
|
||||
int before = moduleTree.calls.get();
|
||||
body.run();
|
||||
return moduleTree.calls.get() - before;
|
||||
}
|
||||
|
||||
/** P26:answer_no 当日序号 count+1 的重号率——并发真实提交才量得出来(不限次 + 报名号的长尾组合) */
|
||||
private void p26AnswerNoDuplicates() throws Exception {
|
||||
int threads = 8;
|
||||
int perThread = 250;
|
||||
ExecutorService pool = Executors.newFixedThreadPool(threads);
|
||||
List<Callable<Integer>> jobs = new ArrayList<>();
|
||||
for (int t = 0; t < threads; t++) {
|
||||
final int seed = t * perThread;
|
||||
jobs.add(() -> {
|
||||
Integer uid = userSeq.incrementAndGet();
|
||||
BoaUser user = plainUser(uid);
|
||||
int ok = 0;
|
||||
for (int i = 0; i < perThread; i++) {
|
||||
try {
|
||||
answerService.submit(user, submitFor("perf_answerno",
|
||||
"{\"name\":\"并发" + (seed + i) + "\"}"));
|
||||
ok++;
|
||||
} catch (Exception ignored) {
|
||||
// 撞 uk/规则被拒是预期分支;重号率只看落库行
|
||||
}
|
||||
}
|
||||
return ok;
|
||||
});
|
||||
}
|
||||
List<Future<Integer>> futures = pool.invokeAll(jobs);
|
||||
pool.shutdown();
|
||||
int accepted = 0;
|
||||
for (Future<Integer> f : futures) {
|
||||
accepted += f.get();
|
||||
}
|
||||
Integer rows = jdbc.queryForObject("select count(*) from answer where code='perf_answerno'", Integer.class);
|
||||
Integer distinct = jdbc.queryForObject("select count(distinct answer_no) from answer"
|
||||
+ " where code='perf_answerno' and answer_no is not null", Integer.class);
|
||||
Integer maxSeq = jdbc.queryForObject("select ifnull(max(cast(substring_index(answer_no,'-',-1) as unsigned)),"
|
||||
+ " 0) from answer where code='perf_answerno'", Integer.class);
|
||||
int duplicated = rows == null || distinct == null ? 0 : rows - distinct;
|
||||
log("P26 answer_no 重号率:并发提交成功=" + accepted + " 落库行=" + rows + " 不同报名号=" + distinct
|
||||
+ " 重号行=" + duplicated + " 最大当日序号=" + maxSeq
|
||||
+ "(一期无 uk,重号=真重复;裁决触发条件=暴露真实需求,届时另立问题单加 uk+撞号重试)");
|
||||
}
|
||||
|
||||
// ==================== 小工具 ====================
|
||||
|
||||
private BoaUser manager() {
|
||||
BoaUser user = new BoaUser();
|
||||
user.setId(900001);
|
||||
user.setType(2);
|
||||
user.setRoleIds(Collections.singletonList(7));
|
||||
user.setCurrentRole(role(7));
|
||||
moduleTree.grantManageDefaults(900001);
|
||||
return user;
|
||||
}
|
||||
|
||||
private BoaUser plainUser(Integer id) {
|
||||
BoaUser user = new BoaUser();
|
||||
user.setId(id);
|
||||
user.setType(0);
|
||||
user.setRoleIds(Collections.singletonList(3));
|
||||
user.setCurrentRole(role(3));
|
||||
return user;
|
||||
}
|
||||
|
||||
private static BoaRoleInfo role(Integer roleId) {
|
||||
BoaRoleInfo info = new BoaRoleInfo();
|
||||
info.setRoleId(roleId);
|
||||
info.setSchId((int) SCH);
|
||||
return info;
|
||||
}
|
||||
|
||||
private AnswerModels.SubmitRequest submitFor(String code, String valuesJson) {
|
||||
AnswerModels.SubmitRequest req = new AnswerModels.SubmitRequest();
|
||||
req.setFormCode(code);
|
||||
req.setIdempotencyKey(java.util.UUID.randomUUID().toString().replace("-", ""));
|
||||
req.setValues(json(valuesJson));
|
||||
return req;
|
||||
}
|
||||
|
||||
private static JsonNode json(String body) {
|
||||
try {
|
||||
return com.yunhai.formcenter.common.FormSchemaValidator.mapper().readTree(body);
|
||||
} catch (Exception e) {
|
||||
throw new IllegalStateException("压测 JSON 写错了:" + body, e);
|
||||
}
|
||||
}
|
||||
|
||||
private static ReadModels.FilterItem eq(String key, String value) {
|
||||
ReadModels.FilterItem item = new ReadModels.FilterItem();
|
||||
item.setKey(key);
|
||||
item.setValue(value);
|
||||
return item;
|
||||
}
|
||||
|
||||
private static ReadModels.FilterItem range(String key, String from, String to) {
|
||||
ReadModels.FilterItem item = new ReadModels.FilterItem();
|
||||
item.setKey(key);
|
||||
item.setFrom(from);
|
||||
item.setTo(to);
|
||||
return item;
|
||||
}
|
||||
|
||||
private static ReadModels.FilterItem prefix(String key, String value) {
|
||||
return eq(key, value);
|
||||
}
|
||||
|
||||
private String formUuidOf(String code) {
|
||||
return jdbc.queryForObject("select uuid from form where sch_id=" + SCH + " and code='" + code + "'",
|
||||
String.class);
|
||||
}
|
||||
|
||||
private int count(String table) {
|
||||
Integer n = jdbc.queryForObject("select count(*) from " + table + " where sch_id=" + SCH, Integer.class);
|
||||
return n == null ? 0 : n;
|
||||
}
|
||||
|
||||
/** 预热一次(首查含缓存冷启/计划编译),单独报冷值;随后 times 次取 p50/p95/p99 */
|
||||
private void sample(String label, int times, Runnable body, String thresholdNote) {
|
||||
long coldStart = System.nanoTime();
|
||||
body.run();
|
||||
long cold = ms(System.nanoTime() - coldStart);
|
||||
List<Long> samples = new ArrayList<>();
|
||||
for (int i = 0; i < times; i++) {
|
||||
long start = System.nanoTime();
|
||||
body.run();
|
||||
samples.add(ms(System.nanoTime() - start));
|
||||
}
|
||||
Collections.sort(samples);
|
||||
long p95 = samples.get(Math.min(samples.size() - 1, (int) Math.ceil(samples.size() * 0.95) - 1));
|
||||
log(label + " | n=" + samples.size() + " 冷启=" + cold + "ms p50=" + samples.get(samples.size() / 2)
|
||||
+ "ms p95=" + p95 + "ms p99=" + samples.get(Math.min(samples.size() - 1,
|
||||
(int) Math.ceil(samples.size() * 0.99) - 1)) + "ms max=" + samples.get(samples.size() - 1)
|
||||
+ "ms | " + thresholdNote);
|
||||
}
|
||||
|
||||
private long timed(int times, Runnable body) {
|
||||
long total = 0;
|
||||
for (int i = 0; i < times; i++) {
|
||||
long start = System.nanoTime();
|
||||
body.run();
|
||||
total += ms(System.nanoTime() - start);
|
||||
}
|
||||
return total / times;
|
||||
}
|
||||
|
||||
private static long ms(long nanos) {
|
||||
return nanos / 1_000_000L;
|
||||
}
|
||||
|
||||
private static String trim(String s) {
|
||||
String one = s.replaceAll("\\s+", " ").trim();
|
||||
return one.length() > 300 ? one.substring(0, 300) + "…" : one;
|
||||
}
|
||||
|
||||
/** EXPLAIN JSON 里递归收 key/rows/access_type/using_index,只留判据所需 */
|
||||
private static String pickKeys(String explainJson) {
|
||||
try {
|
||||
Set<String> keys = new LinkedHashSet<>();
|
||||
collect(json(explainJson), keys);
|
||||
return String.join(" ", keys);
|
||||
} catch (Exception e) {
|
||||
return "解析失败:" + e.getMessage();
|
||||
}
|
||||
}
|
||||
|
||||
private static void collect(JsonNode node, Set<String> out) {
|
||||
if (node == null || node.isNull()) {
|
||||
return;
|
||||
}
|
||||
if (node.isObject()) {
|
||||
node.fields().forEachRemaining(entry -> {
|
||||
String name = entry.getKey();
|
||||
JsonNode value = entry.getValue();
|
||||
if ((name.equals("key") || name.equals("rows") || name.equals("rows_examined_per_scan")
|
||||
|| name.equals("access_type") || name.equals("using_index")
|
||||
|| name.equals("query_cost") || name.equals("key_length")) && value.isValueNode()) {
|
||||
out.add(name + "=" + value.asText());
|
||||
}
|
||||
collect(value, out);
|
||||
});
|
||||
} else if (node.isArray()) {
|
||||
node.forEach(child -> collect(child, out));
|
||||
}
|
||||
}
|
||||
|
||||
private void log(String line) {
|
||||
report.add(line);
|
||||
System.out.println("[PERF] " + line);
|
||||
}
|
||||
|
||||
private void write() throws Exception {
|
||||
Path dir = Paths.get(System.getProperty("user.dir"), "..", "local-debug", "perf-20261015");
|
||||
Files.createDirectories(dir);
|
||||
Files.write(dir.resolve("perf-summary.txt"), report, StandardCharsets.UTF_8);
|
||||
Files.write(dir.resolve("perf-sql-and-plans.txt"), sqlLog, StandardCharsets.UTF_8);
|
||||
System.out.println("[PERF] 证据落盘:" + dir.toAbsolutePath().normalize());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,222 @@
|
||||
package com.yunhai.formcenter.service;
|
||||
|
||||
import com.yunhai.formcenter.controller.api.FormAnswerReadController;
|
||||
import com.yunhai.formcenter.model.ReadModels;
|
||||
import com.yunhai.platform.utils.model.BoaModule;
|
||||
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.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.test.context.SpringBootTest;
|
||||
import org.springframework.boot.test.context.TestConfiguration;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Primary;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.mock.web.MockHttpServletResponse;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Paths;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
|
||||
/**
|
||||
* T9 压测补探针(<b>控制器形状</b>):{@code Perf200kProbe} 直调服务层,量到的"每请求取树次数"少了一次
|
||||
* 控制器侧 {@code FormAdminGuard.requireAdmin} 的取树——那正是 P44 问的东西("每请求 1~2 次"里的 2 从哪来)。
|
||||
* 本探针打 {@link FormAnswerReadController} 的端点方法(会话用户 + 请求体,与线上调用形状一致),
|
||||
* 把 P44 的数字钉成<b>请求级</b>,并顺带给出 V10 三项在含守卫形状下的延迟。
|
||||
*
|
||||
* <p>数据复用 {@code bsch_formcenter_perf}(由 {@code Perf200kProbe} 造好,20 万答卷 / 78 万索引行)。
|
||||
* 命名不带 Test/IT 后缀 → 默认门禁不收集;手点:{@code mvn -B -o test -Dtest=PerfTreeCallsProbe}。</p>
|
||||
*/
|
||||
@SpringBootTest(properties = {
|
||||
"spring.datasource.url=jdbc:mysql://localhost:3306/bsch_formcenter_perf?createDatabaseIfNotExist=true"
|
||||
+ "&useUnicode=true&characterEncoding=utf8&useSSL=false&serverTimezone=Asia/Shanghai"
|
||||
+ "&allowPublicKeyRetrieval=true",
|
||||
"spring.jpa.hibernate.ddl-auto=none"
|
||||
})
|
||||
@DisplayName("T9 压测补探针:请求级取树次数(P44)+ 含守卫形状下的 V10 延迟")
|
||||
class PerfTreeCallsProbe {
|
||||
|
||||
private static final long SCH = 990600L;
|
||||
private static final int MANAGER_ID = 900001;
|
||||
|
||||
@Autowired
|
||||
private JdbcTemplate jdbc;
|
||||
@Autowired
|
||||
private FormDefService defService;
|
||||
@Autowired
|
||||
private FormAnswerReadController controller;
|
||||
@Autowired
|
||||
private CountingModuleTreeSource moduleTree;
|
||||
|
||||
static class CountingModuleTreeSource extends InMemoryModuleTreeSource {
|
||||
final AtomicInteger calls = new AtomicInteger();
|
||||
|
||||
@Override
|
||||
public List<BoaModule> authorizedModules(BoaUser user) {
|
||||
calls.incrementAndGet();
|
||||
return super.authorizedModules(user);
|
||||
}
|
||||
}
|
||||
|
||||
@TestConfiguration
|
||||
static class ProbeGatewayConfiguration {
|
||||
@Bean
|
||||
@Primary
|
||||
CountingModuleTreeSource countingModuleTreeSource() {
|
||||
return new CountingModuleTreeSource();
|
||||
}
|
||||
|
||||
@Bean
|
||||
@Primary
|
||||
InMemorySubjectDirectory probeSubjectDirectory() {
|
||||
return new InMemorySubjectDirectory();
|
||||
}
|
||||
}
|
||||
|
||||
private final List<String> report = new ArrayList<>();
|
||||
|
||||
@Test
|
||||
@DisplayName("P44:列表/详情/统计/筛选候选/导出 各自一次请求取几棵树;同时给含守卫的请求级延迟")
|
||||
void requestShape() throws Exception {
|
||||
BoaUser manager = manager();
|
||||
String uuid = jdbc.queryForObject("select uuid from form where sch_id=" + SCH + " and code='perf_f01'",
|
||||
String.class);
|
||||
String sampleAnswer = jdbc.queryForObject("select uuid from answer where sch_id=" + SCH
|
||||
+ " and code='perf_f01' and is_deleted=0 order by id desc limit 1", String.class);
|
||||
|
||||
ReadModels.ListRequest list = new ReadModels.ListRequest();
|
||||
list.setFormUuid(uuid);
|
||||
list.setSize(20);
|
||||
ReadModels.ListRequest filtered = new ReadModels.ListRequest();
|
||||
filtered.setFormUuid(uuid);
|
||||
filtered.setSize(20);
|
||||
filtered.setFilters(Arrays.asList(eq("grade", "g2"), range("score", "30", "80")));
|
||||
ReadModels.QueryRequest stats = new ReadModels.QueryRequest();
|
||||
stats.setFormUuid(uuid);
|
||||
stats.setGroupByField("grade");
|
||||
|
||||
log("P44 请求级取树次数(控制器形状,含 requireAdmin):列表="
|
||||
+ probe(() -> controller.list(manager, null, list))
|
||||
+ " 详情=" + probe(() -> controller.detail(manager, null, sampleAnswer))
|
||||
+ " 统计=" + probe(() -> controller.stats(manager, null, stats))
|
||||
+ " 筛选候选=" + probe(() -> controller.filterFields(manager, null, uuid))
|
||||
+ " 导出=" + probe(() -> exportThroughController(manager, stats)));
|
||||
|
||||
// 请求级延迟(比服务层多一次守卫取树;桩里取树是内存操作,真 UserBase 是签名 HTTP 调用)
|
||||
sample("V10 请求级 列表 首屏", 40, () -> controller.list(manager, null, list));
|
||||
sample("V10 请求级 三条件筛选", 40, () -> controller.list(manager, null, filtered));
|
||||
sample("V10 请求级 统计(缓存命中态)", 20, () -> controller.stats(manager, null, stats));
|
||||
long start = System.nanoTime();
|
||||
int bytes = exportThroughController(manager, stats);
|
||||
log("V10 请求级 导出(2 万级,写满 MockHttpServletResponse 缓冲区):字节=" + bytes
|
||||
+ " 耗时(ms)=" + (System.nanoTime() - start) / 1_000_000L + " 阈值=20000");
|
||||
|
||||
p36PublishTiming();
|
||||
Files.write(Paths.get(System.getProperty("user.dir"), "..", "local-debug", "perf-20261015",
|
||||
"perf-request-shape.txt"), report, StandardCharsets.UTF_8);
|
||||
}
|
||||
|
||||
private int exportThroughController(BoaUser manager, ReadModels.QueryRequest body) {
|
||||
try {
|
||||
MockHttpServletResponse response = new MockHttpServletResponse();
|
||||
controller.export(manager, null, body, response);
|
||||
return response.getContentAsByteArray().length;
|
||||
} catch (Exception e) {
|
||||
throw new IllegalStateException("导出端点调用失败:" + e.getMessage(), e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* P36 的实测项:升版发布时同步补建历史索引行({@code backfillAfterPublish})在 2 万行表单上到底多久。
|
||||
* P36 记档的估计是"2 万级秒级",而 T9 的处置线写的是"若实测不可接受,改点在 FormDefService.publish 一处"——
|
||||
* 所以要量的是 publish 这一步本身,不是整段流程。
|
||||
*/
|
||||
private void p36PublishTiming() {
|
||||
String uuid = jdbc.queryForObject("select uuid from form where sch_id=" + SCH + " and code='perf_f02'",
|
||||
String.class);
|
||||
Integer current = jdbc.queryForObject("select current_version from form where uuid='" + uuid + "'",
|
||||
Integer.class);
|
||||
String schema = jdbc.queryForObject("select schema_json from form_version where form_id="
|
||||
+ "(select id from form where uuid='" + uuid + "') and version=" + current, String.class);
|
||||
com.yunhai.formcenter.model.FormModels.FormSaveRequest draft =
|
||||
new com.yunhai.formcenter.model.FormModels.FormSaveRequest();
|
||||
draft.setUuid(uuid);
|
||||
draft.setSchemaJson(schema.replace("{\"key\":\"remark\"",
|
||||
"{\"key\":\"p36_extra\",\"label\":\"P36 计时字段\",\"type\":\"text\",\"searchable\":true},"
|
||||
+ "{\"key\":\"remark\""));
|
||||
long saveStart = System.nanoTime();
|
||||
defService.save(SCH, draft);
|
||||
long saveMs = (System.nanoTime() - saveStart) / 1_000_000L;
|
||||
long publishStart = System.nanoTime();
|
||||
defService.publish(SCH, uuid, "P36 升版补建索引计时(新增 searchable 字段)");
|
||||
long publishMs = (System.nanoTime() - publishStart) / 1_000_000L;
|
||||
Integer indexed = jdbc.queryForObject("select count(*) from answer_index where sch_id=" + SCH
|
||||
+ " and code='perf_f02' and field_key='p36_extra'", Integer.class);
|
||||
long live = jdbc.queryForObject("select count(*) from answer where sch_id=" + SCH
|
||||
+ " and code='perf_f02' and is_deleted=0", Long.class);
|
||||
log("P36 升版补建实测:草稿保存=" + saveMs + "ms;**发布=" + publishMs + "ms**(含同步补建索引);"
|
||||
+ "本表未删答卷=" + live + " 行,新字段索引行补出=" + indexed + " 行"
|
||||
+ "(P36 记档的\"2 万级秒级\"与实测的差值见 T9 报告 §4.5)");
|
||||
}
|
||||
|
||||
private int probe(Runnable body) {
|
||||
int before = moduleTree.calls.get();
|
||||
body.run();
|
||||
return moduleTree.calls.get() - before;
|
||||
}
|
||||
|
||||
private void sample(String label, int times, Runnable body) {
|
||||
long coldStart = System.nanoTime();
|
||||
body.run();
|
||||
long cold = (System.nanoTime() - coldStart) / 1_000_000L;
|
||||
List<Long> ms = new ArrayList<>();
|
||||
for (int i = 0; i < times; i++) {
|
||||
long start = System.nanoTime();
|
||||
body.run();
|
||||
ms.add((System.nanoTime() - start) / 1_000_000L);
|
||||
}
|
||||
Collections.sort(ms);
|
||||
log(label + " | n=" + ms.size() + " 冷启=" + cold + "ms p50=" + ms.get(ms.size() / 2) + "ms p95="
|
||||
+ ms.get(Math.min(ms.size() - 1, (int) Math.ceil(ms.size() * 0.95) - 1)) + "ms max="
|
||||
+ ms.get(ms.size() - 1) + "ms");
|
||||
}
|
||||
|
||||
private BoaUser manager() {
|
||||
BoaUser user = new BoaUser();
|
||||
user.setId(MANAGER_ID);
|
||||
user.setType(2);
|
||||
user.setRoleIds(Collections.singletonList(7));
|
||||
BoaRoleInfo current = new BoaRoleInfo();
|
||||
current.setRoleId(7);
|
||||
current.setSchId((int) SCH);
|
||||
user.setCurrentRole(current);
|
||||
moduleTree.grantManageDefaults(MANAGER_ID);
|
||||
return user;
|
||||
}
|
||||
|
||||
private static ReadModels.FilterItem eq(String key, String value) {
|
||||
ReadModels.FilterItem item = new ReadModels.FilterItem();
|
||||
item.setKey(key);
|
||||
item.setValue(value);
|
||||
return item;
|
||||
}
|
||||
|
||||
private static ReadModels.FilterItem range(String key, String from, String to) {
|
||||
ReadModels.FilterItem item = new ReadModels.FilterItem();
|
||||
item.setKey(key);
|
||||
item.setFrom(from);
|
||||
item.setTo(to);
|
||||
return item;
|
||||
}
|
||||
|
||||
private void log(String line) {
|
||||
report.add(line);
|
||||
System.out.println("[PERF] " + line);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user