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 s.matches("(?s).*\\.id\\s*<\\s*(\\d+|\\?).*")); + log("铁律② 抓到的语句里存在 keyset 谓词(id 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); + } +}