diff --git a/sql/h2_userbase_app_and_modules.sql b/sql/h2_userbase_app_and_modules.sql
new file mode 100644
index 0000000..d4b4e86
--- /dev/null
+++ b/sql/h2_userbase_app_and_modules.sql
@@ -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:应用身份由超管在管理端维护,脚本不碰主数据删除)
+-- ----------------------------------------------------------------------------
diff --git a/src/test/java/com/yunhai/formcenter/service/Perf200kProbe.java b/src/test/java/com/yunhai/formcenter/service/Perf200kProbe.java
new file mode 100644
index 0000000..48fa5fe
--- /dev/null
+++ b/src/test/java/com/yunhai/formcenter/service/Perf200kProbe.java
@@ -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 压测探针(自验项 V10 的量化半边 + 问题单 P26/P35/P37/P44 的实测口径)。
+ *
+ *
命名即隔离:类名不带 Test/IT 后缀 → 默认 {@code mvn test} 门禁不收集
+ * (pom surefire includes 只收 {@code *Test}/{@code *IT}),手点才跑:
+ * {@code mvn -B -o test -Dtest=Perf200kProbe}。20 万级造数与 20s 级导出不该进每轮门禁。
+ *
+ * 独立库:造数落 {@code bsch_formcenter_perf}(URL 带 createDatabaseIfNotExist=true、
+ * ddl-auto=update),不碰开发库 bsch_formcenter;压测数据必须真提交(要的是真实表与真实索引),
+ * 跑完由 local-debug 的清理命令整库 DROP。
+ *
+ * 量级(V10 原文):全库 20 万答卷、单表单典型 2 万 → 10 张表单 × 2 万;
+ * searchable 四字段各写索引行(≈78 万),每 50 行置 1 行软删(让谓词真实参与)。
+ *
+ * 口径边界(报告要点名):测的是 service+SQL 层延迟,不含 HTTP/CAS/JSON 序列化
+ * 的用户面开销,也不含 UserBase 签名调用的网络耗时(模块树被桩顶掉;P44 单独量化每请求取树次数)。
+ * 本机 MySQL 8.0.45 与 160 容器网 mysql8 同大版本。
+ */
+@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 report = new ArrayList<>();
+ private final List sqlLog = new ArrayList<>();
+ private final AtomicInteger userSeq = new AtomicInteger(910000);
+
+ /** 模块树桩的计数版(P44):桩顶掉签名调用的同时把调用次数留下来,否则 P44 只能靠读代码猜 */
+ static class CountingModuleTreeSource extends InMemoryModuleTreeSource {
+ final AtomicInteger calls = new AtomicInteger();
+
+ @Override
+ public List 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 保守口径),
+ * 再按表单批量插入。索引行只给未删行的 searchable 字段写,与线上写入器同规则(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} 同规则——只给未删答卷的 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 statements = captureSql(manager, uuid);
+ sqlLog.addAll(statements);
+
+ Optional 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 captureSql(BoaUser manager, String uuid) {
+ Logger sql = (Logger) LoggerFactory.getLogger("org.hibernate.SQL");
+ Level old = sql.getLevel();
+ ListAppender 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> 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> futures = pool.invokeAll(jobs);
+ pool.shutdown();
+ int accepted = 0;
+ for (Future 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 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 keys = new LinkedHashSet<>();
+ collect(json(explainJson), keys);
+ return String.join(" ", keys);
+ } catch (Exception e) {
+ return "解析失败:" + e.getMessage();
+ }
+ }
+
+ private static void collect(JsonNode node, Set 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());
+ }
+}
diff --git a/src/test/java/com/yunhai/formcenter/service/PerfTreeCallsProbe.java b/src/test/java/com/yunhai/formcenter/service/PerfTreeCallsProbe.java
new file mode 100644
index 0000000..70fc74e
--- /dev/null
+++ b/src/test/java/com/yunhai/formcenter/service/PerfTreeCallsProbe.java
@@ -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 压测补探针(控制器形状):{@code Perf200kProbe} 直调服务层,量到的"每请求取树次数"少了一次
+ * 控制器侧 {@code FormAdminGuard.requireAdmin} 的取树——那正是 P44 问的东西("每请求 1~2 次"里的 2 从哪来)。
+ * 本探针打 {@link FormAnswerReadController} 的端点方法(会话用户 + 请求体,与线上调用形状一致),
+ * 把 P44 的数字钉成请求级,并顺带给出 V10 三项在含守卫形状下的延迟。
+ *
+ * 数据复用 {@code bsch_formcenter_perf}(由 {@code Perf200kProbe} 造好,20 万答卷 / 78 万索引行)。
+ * 命名不带 Test/IT 后缀 → 默认门禁不收集;手点:{@code mvn -B -o test -Dtest=PerfTreeCallsProbe}。
+ */
+@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 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 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 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);
+ }
+}