Browse Source

test: isolate fairness monitoring authentication fixtures

master
Zhang Shun 1 month ago
parent
commit
84fe9f5feb
  1. 2
      docs/backend-development-roadmap.md
  2. 128
      docs/r01-a6-fairness-fixture-isolation-fix-20260917.md
  3. 76
      mydreamjob-server/src/test/java/com/mydreamjob/scoringmonitoring/FairnessMonitoringControllerTest.java
  4. 301
      mydreamjob-server/src/test/java/com/mydreamjob/scoringmonitoring/FairnessMonitoringFixtureIsolationTest.java

2
docs/backend-development-roadmap.md

@ -15,7 +15,7 @@
| S02 Access/Refresh Token 隔离 | 已实现并完成自动化验证与干净构建,待独立代码复验及真实 MySQL/Docker 黑盒验收 | 类型隔离、独立密钥、轮换、重放处置、会话吊销已锁定;OpenAPI 175/213 | | S02 Access/Refresh Token 隔离 | 已实现并完成自动化验证与干净构建,待独立代码复验及真实 MySQL/Docker 黑盒验收 | 类型隔离、独立密钥、轮换、重放处置、会话吊销已锁定;OpenAPI 175/213 |
| S03 生产配置与简历隐私 | 已实现并完成自动化验证与干净构建,待独立代码复验及真实 MySQL/Docker 黑盒验收 | V43 禁用原始指纹种子账号;管理员初始化默认关闭且幂等;企业简历下载按隐私决策表授权;Actuator 隔离到管理端口;OpenAPI 176/214 | | S03 生产配置与简历隐私 | 已实现并完成自动化验证与干净构建,待独立代码复验及真实 MySQL/Docker 黑盒验收 | V43 禁用原始指纹种子账号;管理员初始化默认关闭且幂等;企业简历下载按隐私决策表授权;Actuator 隔离到管理端口;OpenAPI 176/214 |
| Q01 岗位搜索真实查询覆盖(C02 证据补充) | 已完成实现、跨库一致性整改、独立复验及 Git 检查点(`77dc624`)。新增 54 条真实库测试:H2 Repository 21、H2 Service 9、H2 副作用 4、MySQL 8.4 Specification 15、MySQL 副作用 4、MySQL EXPLAIN 1;全量 1890/1890 | OpenAPI 保持 176/214;前导通配符、filesort 与真实规模执行计划移交 R01 | | Q01 岗位搜索真实查询覆盖(C02 证据补充) | 已完成实现、跨库一致性整改、独立复验及 Git 检查点(`77dc624`)。新增 54 条真实库测试:H2 Repository 21、H2 Service 9、H2 副作用 4、MySQL 8.4 Specification 15、MySQL 副作用 4、MySQL EXPLAIN 1;全量 1890/1890 | OpenAPI 保持 176/214;前导通配符、filesort 与真实规模执行计划移交 R01 |
| R01 结构与性能收束 | R01-A `0b5b849`、R01-B `15bd98b`、R01-C `b68519e`、R01-D1 `cf0c71e`、R01-D2 `8c40fd4` 已建本地检查点;**R01-D3 已完成**会话列表未读数 N+1 收束;**R01-D4 已冻结并推送**(提交 `52c8026`,远端 `origin/refactor/r01-structure-performance`);**R01-A2 请求身份与 requestId 收束(第二批 8 个 Controller)已完成独立复验、提交并推送**(提交 `72b82a3`);**R01-A3(第三批 8 个 Controller)已完成独立复验、提交并推送**(提交 `d5c3712`);**账号注销测试夹具隔离修复已提交并推送**(提交 `35baa84`);**R01-A4(第四批 8 个 Controller)已完成实施、独立复验、提交并推送**(提交 `0226977`);**R01-A5(第五批 8 个 Controller)已完成独立复验、提交并推送**(提交 `534afe68`);**R01-A6(最终批 5 个 Controller)已完成实施与独立复验**(实施报告 `docs/r01-a6-final-request-context-migration-report.md`;独立复验报告 `docs/r01-a6-independent-verification-20260917.md`)。**请求上下文收束 A 系列到此结束,不再拆分 A7/A8**;下一阶段为 R01-B。历史 R01-D4 迁移方案见 `docs/r01-d4-get-scoring-snapshot-migration-design.md` | R01-D4:三 GET 评分接口已停写快照,改为写侧登记 + 后台 Worker + 补偿扫描物化,V44/V45 已并入真实 MySQL;claim fence 采用不可复用 claim token(V45)。**R01-A2 至 A6 共迁移 37 个 Controller**(A2–A5 各 8 个 + A6 5 个),身份与 requestId 统一到 CurrentUserAccessor / RequestIdAccessor,非法主体由「400/500」统一为 401;角色、归属与 403/404 契约未变。A2–A5 各删除 **16 个**私有解析方法,A6 删除 **6 个**。**请求上下文收束终态:业务 Controller 身份解析 0、requestId 重复解析 0**;剩余命中仅为基础设施(身份 3 文件:CurrentUserAccessor / JwtAuthenticationFilter / DeletionGuardInterceptor;requestId 5 文件:AuditContext / GlobalExceptionHandler / RequestIdAccessor / RequestLoggingFilter / DeletionGuardInterceptor),**A7/A8 不再设置** | | R01 结构与性能收束 | R01-A `0b5b849`、R01-B `15bd98b`、R01-C `b68519e`、R01-D1 `cf0c71e`、R01-D2 `8c40fd4` 已建本地检查点;**R01-D3 已完成**会话列表未读数 N+1 收束;**R01-D4 已冻结并推送**(提交 `52c8026`,远端 `origin/refactor/r01-structure-performance`);**R01-A2 请求身份与 requestId 收束(第二批 8 个 Controller)已完成独立复验、提交并推送**(提交 `72b82a3`);**R01-A3(第三批 8 个 Controller)已完成独立复验、提交并推送**(提交 `d5c3712`);**账号注销测试夹具隔离修复已提交并推送**(提交 `35baa84`);**R01-A4(第四批 8 个 Controller)已完成实施、独立复验、提交并推送**(提交 `0226977`);**R01-A5(第五批 8 个 Controller)已完成独立复验、提交并推送**(提交 `534afe68`);**R01-A6(最终批 5 个 Controller)已完成实施与独立复验**(实施报告 `docs/r01-a6-final-request-context-migration-report.md`;独立复验报告 `docs/r01-a6-independent-verification-20260917.md`)。**请求上下文收束 A 系列到此结束,不再拆分 A7/A8**;下一阶段为 R01-B。历史 R01-D4 迁移方案见 `docs/r01-d4-get-scoring-snapshot-migration-design.md` | R01-D4:三 GET 评分接口已停写快照,改为写侧登记 + 后台 Worker + 补偿扫描物化,V44/V45 已并入真实 MySQL;claim fence 采用不可复用 claim token(V45)。**R01-A2 至 A6 共迁移 37 个 Controller**(A2–A5 各 8 个 + A6 5 个),身份与 requestId 统一到 CurrentUserAccessor / RequestIdAccessor,非法主体由「400/500」统一为 401;角色、归属与 403/404 契约未变。A2–A5 各删除 **16 个**私有解析方法,A6 删除 **6 个**。**请求上下文收束终态:业务 Controller 身份解析 0、requestId 重复解析 0**;剩余命中仅为基础设施(身份 3 文件:CurrentUserAccessor / JwtAuthenticationFilter / DeletionGuardInterceptor;requestId 5 文件:AuditContext / GlobalExceptionHandler / RequestIdAccessor / RequestLoggingFilter / DeletionGuardInterceptor),**A7/A8 不再设置**。**A6 后夹具问题已关闭**:`FairnessMonitoringControllerTest` 曾用 `userId = 1/2` 与 V1_1 种子账号(被 V43 置为 inactive)冲突而在单跑时全部 401,已改为独占用户(`9_310_001/2`)并按精确 ID 清理,单跑稳定通过;新增反证测试 `FairnessMonitoringFixtureIsolationTest`(报告 `docs/r01-a6-fairness-fixture-isolation-fix-20260917.md`) |
| 其余五个功能组 | 技能词云、入职清单/公司知识、薪资计算器/参考、诚信积分/激励、简历查重 | N03–N06、N08仍属一期,细节设计后编码 | | 其余五个功能组 | 技能词云、入职清单/公司知识、薪资计算器/参考、诚信积分/激励、简历查重 | N03–N06、N08仍属一期,细节设计后编码 |
| 上线保障 | 现有资产待逐项核验 | G01–G04,一期交付门槛 | | 上线保障 | 现有资产待逐项核验 | G01–G04,一期交付门槛 |
| 后续候选池 | AI简历、AI模拟面试/预测、SHAP高级解释等 | 以清单第6节为准,移出一期 | | 后续候选池 | AI简历、AI模拟面试/预测、SHAP高级解释等 | 以清单第6节为准,移出一期 |

128
docs/r01-a6-fairness-fixture-isolation-fix-20260917.md

@ -0,0 +1,128 @@
# Fairness 认证夹具隔离修复(A6 后独立纯测试提交)
日期:2026-09-17
分支:`refactor/r01-structure-performance`
A6 提交:`c6db74a1a8300e1aab8a452fb67909f86861fa43`
本轮性质:**纯测试修复** —— 生产代码 diff 为 0,Flyway 为 0,OpenAPI 为 0
---
## 0. 结论
`FairnessMonitoringControllerTest` 已不再依赖种子数据、测试执行顺序或共享表清空,
**单独运行稳定通过**;并新增反证测试锁定该隔离性。
---
## 1. 缺陷(A6 独立复验定位)
| 环节 | 事实 |
|---|---|
| 冲突 | 该测试用 `token(1L, "ADMIN")` / `token(2L, "STUDENT")`,而 `V1_1__seed_data.sql` 的种子账号占用 `users.id = 1/2/3` |
| 被禁用 | `V43__disable_legacy_seed_accounts.sql` 把这三行置为 `is_active = 0` |
| 夹具缺陷 | `TestAuthTokens.writeSession()` 仅在 `SELECT COUNT(*) FROM users WHERE id = ?` 为 0 时 INSERT,**不会激活已存在的禁用行** |
| 后果 | `AuthSessionService.isSessionActive()` 的用户检查短路 → 全部 Token 请求 401 |
| 被掩盖 | 全量顺序下 `com.mydreamjob.controller.AuthIntegrationTest` 的 `@BeforeEach` 执行 `userRepository.deleteAll()`,偶然清掉种子行,夹具随后才补建 active 用户 |
单跑实测(修复前):`Tests run: 7, Failures: 6`,全部 `Status expected:<200|403|400> but was:<401>`。
详见 `docs/r01-a6-independent-verification-20260917.md` §9。
---
## 2. 修复方案
**选择:让 `FairnessMonitoringControllerTest` 使用独占用户,而不是修改公共 `TestAuthTokens`。**
理由:公共 helper 若"自动激活已存在的 inactive 用户",会静默复活本应被禁用的账号
(包括生产语义上被 V43 禁用的种子账号),风险远大于收益。
### 2.1 改动内容
| 项 | 修复前 | 修复后 |
|---|---|---|
| 夹具身份 | `userId = 1` / `2`(与种子账号冲突) | **独占** `ADMIN_USER_ID = 9_310_001L`、`STUDENT_USER_ID = 9_310_002L` |
| 手机号 | 由 `TestAuthTokens` 隐式生成 `"t"+id` | 独占 `a6fair-admin` / `a6fair-student` |
| 用户来源 | 依赖 `TestAuthTokens` 的"不存在才插入" | `@BeforeEach` **显式插入 `is_active = 1`** 的独占用户 |
| 清理 | 无 | `@AfterEach` 按**精确 ID**、按外键顺序删除:`auth_tokens` → `user_roles` → `users` |
`@BeforeEach` 先执行与 `@AfterEach` 相同的精确清理再插入,因此对"上轮异常中断残留"具备幂等性,
且**从不把任何已存在的 inactive 用户直接激活**(是删除后重建自有行)。
### 2.2 未使用的手段(明确排除)
未改 `V43`;未改生产认证逻辑;未使用 `deleteAll()` 清空共享表;未使用 `@DirtiesContext` 掩盖污染;
未捕获并忽略约束异常;未依赖测试执行顺序;未放宽任何既有 Fairness 断言;未修改公共 `TestAuthTokens`。
---
## 3. 反证测试 `FairnessMonitoringFixtureIsolationTest`(7 例)
| # | 用例 | 覆盖点 |
|---|---|---|
| 1 | `fixtureIdsAreOutsideSeedAccountRange` | 夹具 ID 不在种子号段 1/2/3;夹具用户确为 active;未占用种子手机号 |
| 2 | `seedRowsInactiveOrAbsent_stillAuthenticates` | 种子行 **inactive(V43 状态)** 与 **完全缺失** 两种前置下端点均返回 200 |
| 3 | `repeatedProvisioningAndRequests_doNotConflict` | 连续 3 轮"装配 + 请求"均成功,且不产生重复用户行 |
| 4 | `fixtureCycleLeavesSeedRowsUntouched` | 完整周期前后种子行逐字段不变(顺序无关的根本保证) |
| 5 | `cleanupRemovesOnlyOwnFixtureRows` | 清理只删本类行;探针用户及其会话不受波及 |
| 6 | `unrelatedUsersAndSessionsUnchanged` | 非夹具用户与会话逐字段不变 |
| 7 | `publicTokenFixtureDoesNotSilentlyReviveInactiveUser` | `TestAuthTokens` 对已存在 inactive 用户**不激活**,该 Token 请求为 401 且 Service 零调用 |
用例 2 会临时快照并按主键**原样恢复**种子行(`try` 语义由 `@AfterEach` 兜底),不新增、不批量重建。
---
## 4. 验证矩阵
命令均在 `mydreamjob-server/` 下执行;日志位于 `docs/acceptance/r01-d4-2-20260916/ff-*.log`。
| # | 命令 | 结果 | 日志 |
|---|---|---|---|
| 1 | `mvn -Dtest="FairnessMonitoringControllerTest" test` | **7 / 7**,F=0 E=0 S=0 ✔ | `ff-1-standalone.log` |
| 2 | `mvn -Dtest="FairnessMonitoringFixtureIsolationTest,FairnessMonitoringControllerTest" test` | **14 / 14**,F=0 E=0 S=0 ✔ | `ff-2-isolation.log` |
| 3 | Fairness **先**跑 ×3(`-Dsurefire.runOrder=reversealphabetical`) | **13 / 13** ×3 ✔ | `ff-3-fairnessfirst-{1,2,3}.log` |
| 4 | Auth **先**跑 ×3(`-Dsurefire.runOrder=alphabetical`) | **13 / 13** ×3 ✔ | `ff-4-authfirst-{1,2,3}.log` |
| 5 | `mvn clean verify` | 见 §4.1 | `ff-5-clean-verify.log` |
| 6 | `python ..\docs\verify_openapi.py` | **176 paths / 214 operations**,All checks passed ✔ | — |
> **顺序说明**:surefire 默认 `runOrder=filesystem` 会忽略 `-Dtest` 的书写顺序,
> 因此额外用 `-Dsurefire.runOrder=reversealphabetical`(Fairness 先)与 `=alphabetical`(Auth 先)
> 强制两个方向,并在日志中逐次核对实际执行顺序(`reversealphabetical` 下
> `scoringmonitoring.FairnessMonitoringControllerTest` 确在 `controller.AuthIntegrationTest` 之前)。
> `runOrder=testorder` 在本仓库使用的 surefire 3.5.6 中不可用(已实测报错)。
### 4.1 全量结果
| 项 | 数值 |
|---|---|
| 全量测试总数 | 2278 → **2285**(+7:新增反证测试类) |
| Failures / Errors / Skipped | **0 / 0 / 0** ✔ |
| 生产代码 diff | **0** ✔ |
| V1–V45 | **未改动** ✔ |
| `docs/openapi-rc.json` | **未修改** ✔ |
---
## 5. 修改文件
```
mydreamjob-server/src/test/java/com/mydreamjob/scoringmonitoring/FairnessMonitoringControllerTest.java (修改:夹具独占化)
mydreamjob-server/src/test/java/com/mydreamjob/scoringmonitoring/FairnessMonitoringFixtureIsolationTest.java(新增:反证测试)
docs/r01-a6-fairness-fixture-isolation-fix-20260917.md (新增:本报告)
docs/backend-development-roadmap.md (夹具问题关闭记录)
```
**无生产代码、无 Flyway、无 ErrorCode、无 DTO、无接口、无 OpenAPI、无 Filter/Accessor/SecurityConfig 改动。**
---
## 6. 明确声明
- 未修改任何生产代码,未修改 `V43`,未修改公共 `TestAuthTokens` 夹具。
- 未删除、未放宽任何既有断言;`FairnessMonitoringControllerTest` 的业务断言逐字保留。
- 未使用 `deleteAll()`、`@DirtiesContext`、顺序依赖或异常吞没等掩盖手段。
- 未与 R01-B 混合。
---
> **FairnessMonitoringControllerTest 单跑夹具缺陷已通过独立纯测试提交修复;
> 该测试不再依赖种子数据状态与测试执行顺序。**

76
mydreamjob-server/src/test/java/com/mydreamjob/scoringmonitoring/FairnessMonitoringControllerTest.java

@ -3,6 +3,8 @@ package com.mydreamjob.scoringmonitoring;
import com.mydreamjob.auth.JwtTokenProvider; import com.mydreamjob.auth.JwtTokenProvider;
import com.mydreamjob.common.exception.BusinessException; import com.mydreamjob.common.exception.BusinessException;
import com.mydreamjob.common.exception.ErrorCode; import com.mydreamjob.common.exception.ErrorCode;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
@ -11,6 +13,7 @@ import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.data.redis.core.RedisTemplate; import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.core.ValueOperations; import org.springframework.data.redis.core.ValueOperations;
import org.springframework.http.MediaType; import org.springframework.http.MediaType;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.test.context.ActiveProfiles; import org.springframework.test.context.ActiveProfiles;
import org.springframework.test.web.servlet.MockMvc; import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.MvcResult; import org.springframework.test.web.servlet.MvcResult;
@ -34,9 +37,52 @@ class FairnessMonitoringControllerTest {
@Autowired private MockMvc mockMvc; @Autowired private MockMvc mockMvc;
@Autowired private JwtTokenProvider jwtTokenProvider; @Autowired private JwtTokenProvider jwtTokenProvider;
@Autowired private JdbcTemplate jdbcTemplate;
@MockBean private FairnessMonitoringService service; @MockBean private FairnessMonitoringService service;
@MockBean private RedisTemplate<String, Object> redisTemplate; @MockBean private RedisTemplate<String, Object> redisTemplate;
/**
* 本类**独占**的夹具用户 ID。
*
* <p>刻意避开 {@code V1_1__seed_data.sql} 种子账号占用的 {@code users.id = 1/2/3}
* —— 那三行已被 {@code V43__disable_legacy_seed_accounts.sql} 置为 {@code is_active = 0},
* 而 {@code TestAuthTokens} 只在用户行**不存在**时才插入,不会激活已存在的禁用行。
* 曾因此在本类**单独运行**时全部 Token 请求被判 401(详见
* {@code docs/r01-a6-independent-verification-20260917.md} §9)。
* 改用独占号段后,本类的结果不再依赖 V1_1/V43 种子数据的存在性与状态,
* 也不再依赖其他测试类(如 {@code AuthIntegrationTest} 的 {@code deleteAll()})的执行顺序。</p>
*/
static final long ADMIN_USER_ID = 9_310_001L;
static final long STUDENT_USER_ID = 9_310_002L;
/** 独占手机号:不使用种子号的 138… 号段,也不与 {@code TestAuthTokens} 的 {@code "t"+id} 冲突。 */
private static final String ADMIN_PHONE = "a6fair-admin";
private static final String STUDENT_PHONE = "a6fair-student";
@BeforeEach
void provisionFixtureUsers() {
// 先清理可能的上轮残留(异常中断),再插入本类自有的 active 用户。
// 注意:这里是"删除后重建",**不会**把任何已存在的 inactive 用户直接激活。
removeFixtureUsers();
insertActiveUser(ADMIN_USER_ID, ADMIN_PHONE, "ADMIN");
insertActiveUser(STUDENT_USER_ID, STUDENT_PHONE, "STUDENT");
}
@AfterEach
void removeFixtureUsers() {
for (long id : new long[]{ADMIN_USER_ID, STUDENT_USER_ID}) {
// 外键顺序:会话 → 角色 → 用户;只删本类自建的确切 ID,不做任何整表删除。
jdbcTemplate.update("DELETE FROM auth_tokens WHERE user_id = ?", id);
jdbcTemplate.update("DELETE FROM user_roles WHERE user_id = ?", id);
jdbcTemplate.update("DELETE FROM users WHERE id = ?", id);
}
}
private void insertActiveUser(long id, String phone, String role) {
jdbcTemplate.update("INSERT INTO users (id, phone, nickname, role, is_active, is_verified) "
+ "VALUES (?, ?, ?, ?, 1, 1)", id, phone, "a6-fairness-fixture-" + role, role);
}
@Test @Test
@SuppressWarnings("unchecked") @SuppressWarnings("unchecked")
void adminOverview_returnsAggregateWhitelistOnly() throws Exception { void adminOverview_returnsAggregateWhitelistOnly() throws Exception {
@ -53,7 +99,7 @@ class FairnessMonitoringControllerTest {
"40-49", 5, "50-59", 5, "60-69", 4, "70-79", 3, "80-89", 2, "90-100", 1)) "40-49", 5, "50-59", 5, "60-69", 4, "70-79", 3, "80-89", 2, "90-100", 1))
.concentrationRate(new BigDecimal("16.67")).riskLevel("NORMAL") .concentrationRate(new BigDecimal("16.67")).riskLevel("NORMAL")
.availability("AVAILABLE").build(); .availability("AVAILABLE").build();
when(service.overview(eq(1L), eq("2026-09"))).thenReturn(FairnessResponse.Overview.builder() when(service.overview(eq(ADMIN_USER_ID), eq("2026-09"))).thenReturn(FairnessResponse.Overview.builder()
.basis("CURRENT_STORED_SNAPSHOTS") .basis("CURRENT_STORED_SNAPSHOTS")
.scope("SCORED_SNAPSHOT_POPULATION") .scope("SCORED_SNAPSHOT_POPULATION")
.period("2026-09") .period("2026-09")
@ -70,7 +116,7 @@ class FairnessMonitoringControllerTest {
String json = mockMvc.perform(get("/admin/scoring/fairness/overview") String json = mockMvc.perform(get("/admin/scoring/fairness/overview")
.param("period", "2026-09") .param("period", "2026-09")
.header("Authorization", token(1L, "ADMIN")) .header("Authorization", token(ADMIN_USER_ID, "ADMIN"))
.accept(MediaType.APPLICATION_JSON)) .accept(MediaType.APPLICATION_JSON))
.andExpect(status().isOk()) .andExpect(status().isOk())
.andExpect(jsonPath("$.data.basis").value("CURRENT_STORED_SNAPSHOTS")) .andExpect(jsonPath("$.data.basis").value("CURRENT_STORED_SNAPSHOTS"))
@ -90,11 +136,11 @@ class FairnessMonitoringControllerTest {
@Test @Test
void nonAdmin_isForbidden() throws Exception { void nonAdmin_isForbidden() throws Exception {
doThrow(new BusinessException(ErrorCode.FORBIDDEN)) doThrow(new BusinessException(ErrorCode.FORBIDDEN))
.when(service).overview(eq(2L), eq("2026-09")); .when(service).overview(eq(STUDENT_USER_ID), eq("2026-09"));
mockMvc.perform(get("/admin/scoring/fairness/overview") mockMvc.perform(get("/admin/scoring/fairness/overview")
.param("period", "2026-09") .param("period", "2026-09")
.header("Authorization", token(2L, "STUDENT"))) .header("Authorization", token(STUDENT_USER_ID, "STUDENT")))
.andExpect(status().isForbidden()) .andExpect(status().isForbidden())
.andExpect(jsonPath("$.code").value(ErrorCode.FORBIDDEN.getCode())); .andExpect(jsonPath("$.code").value(ErrorCode.FORBIDDEN.getCode()));
} }
@ -104,29 +150,29 @@ class FairnessMonitoringControllerTest {
mockMvc.perform(get("/admin/scoring/fairness/overview") mockMvc.perform(get("/admin/scoring/fairness/overview")
.param("period", "2026-09") .param("period", "2026-09")
.param("jobId", "9") .param("jobId", "9")
.header("Authorization", token(1L, "ADMIN"))) .header("Authorization", token(ADMIN_USER_ID, "ADMIN")))
.andExpect(status().isBadRequest()) .andExpect(status().isBadRequest())
.andExpect(jsonPath("$.code").value(ErrorCode.BAD_REQUEST.getCode())); .andExpect(jsonPath("$.code").value(ErrorCode.BAD_REQUEST.getCode()));
} }
@Test @Test
void rankingStability_returnsExplicitUnavailable() throws Exception { void rankingStability_returnsExplicitUnavailable() throws Exception {
when(service.rankingStability(eq(1L))).thenReturn(FairnessResponse.Unavailable.builder() when(service.rankingStability(eq(ADMIN_USER_ID))).thenReturn(FairnessResponse.Unavailable.builder()
.availability("UNAVAILABLE") .availability("UNAVAILABLE")
.reason("RANKING_SNAPSHOT_HISTORY_NOT_AVAILABLE") .reason("RANKING_SNAPSHOT_HISTORY_NOT_AVAILABLE")
.build()); .build());
when(service.drift(eq(1L))).thenReturn(FairnessResponse.Unavailable.builder() when(service.drift(eq(ADMIN_USER_ID))).thenReturn(FairnessResponse.Unavailable.builder()
.availability("UNAVAILABLE") .availability("UNAVAILABLE")
.reason("IMMUTABLE_SCORE_HISTORY_NOT_AVAILABLE") .reason("IMMUTABLE_SCORE_HISTORY_NOT_AVAILABLE")
.build()); .build());
mockMvc.perform(get("/admin/scoring/fairness/ranking-stability") mockMvc.perform(get("/admin/scoring/fairness/ranking-stability")
.header("Authorization", token(1L, "ADMIN"))) .header("Authorization", token(ADMIN_USER_ID, "ADMIN")))
.andExpect(status().isOk()) .andExpect(status().isOk())
.andExpect(jsonPath("$.data.availability").value("UNAVAILABLE")) .andExpect(jsonPath("$.data.availability").value("UNAVAILABLE"))
.andExpect(jsonPath("$.data.reason").value("RANKING_SNAPSHOT_HISTORY_NOT_AVAILABLE")); .andExpect(jsonPath("$.data.reason").value("RANKING_SNAPSHOT_HISTORY_NOT_AVAILABLE"));
mockMvc.perform(get("/admin/scoring/fairness/drift") mockMvc.perform(get("/admin/scoring/fairness/drift")
.header("Authorization", token(1L, "ADMIN"))) .header("Authorization", token(ADMIN_USER_ID, "ADMIN")))
.andExpect(status().isOk()) .andExpect(status().isOk())
.andExpect(jsonPath("$.data.availability").value("UNAVAILABLE")) .andExpect(jsonPath("$.data.availability").value("UNAVAILABLE"))
.andExpect(jsonPath("$.data.reason").value("IMMUTABLE_SCORE_HISTORY_NOT_AVAILABLE")); .andExpect(jsonPath("$.data.reason").value("IMMUTABLE_SCORE_HISTORY_NOT_AVAILABLE"));
@ -150,10 +196,10 @@ class FairnessMonitoringControllerTest {
.unknownStatusCount(0).outOfRangeScoreCount(0).unknownRulesVersionCount(0) .unknownStatusCount(0).outOfRangeScoreCount(0).unknownRulesVersionCount(0)
.distribution(distribution).distributionsByRulesVersion(Map.of("candidate-scoring-v0.2", distribution)) .distribution(distribution).distributionsByRulesVersion(Map.of("candidate-scoring-v0.2", distribution))
.availability("AVAILABLE").build(); .availability("AVAILABLE").build();
when(service.dimensions(eq(1L), eq("2026-09"), eq("ABILITY"))).thenReturn(FairnessResponse.Dimensions.builder() when(service.dimensions(eq(ADMIN_USER_ID), eq("2026-09"), eq("ABILITY"))).thenReturn(FairnessResponse.Dimensions.builder()
.basis("CURRENT_STORED_SNAPSHOTS").scope("SCORED_SNAPSHOT_POPULATION").period("2026-09") .basis("CURRENT_STORED_SNAPSHOTS").scope("SCORED_SNAPSHOT_POPULATION").period("2026-09")
.availability("AVAILABLE").dimensions(java.util.List.of(metric)).build()); .availability("AVAILABLE").dimensions(java.util.List.of(metric)).build());
when(service.appeals(eq(1L), eq(java.time.LocalDate.of(2026, 8, 1)), eq(java.time.LocalDate.of(2026, 9, 1)))) when(service.appeals(eq(ADMIN_USER_ID), eq(java.time.LocalDate.of(2026, 8, 1)), eq(java.time.LocalDate.of(2026, 9, 1))))
.thenReturn(FairnessResponse.Appeals.builder().basis("APPEAL_CREATION_QUEUE_CURRENT_STATE") .thenReturn(FairnessResponse.Appeals.builder().basis("APPEAL_CREATION_QUEUE_CURRENT_STATE")
.from("2026-08-01").to("2026-09-01").appealCount(31).sampleSize(30) .from("2026-08-01").to("2026-09-01").appealCount(31).sampleSize(30)
.statusCounts(Map.of("APPROVED", 20, "REJECTED", 10)).statusEventCounts(Map.of("APPROVED", 21, "REJECTED", 10)) .statusCounts(Map.of("APPROVED", 20, "REJECTED", 10)).statusEventCounts(Map.of("APPROVED", 21, "REJECTED", 10))
@ -167,14 +213,14 @@ class FairnessMonitoringControllerTest {
.invalidScoreRollbackCount(1).ruleVersionChangedRollbackCount(1) .invalidScoreRollbackCount(1).ruleVersionChangedRollbackCount(1)
.reasonCounts(Map.of("INVALID_SCORE", 1)).availability("AVAILABLE").build()); .reasonCounts(Map.of("INVALID_SCORE", 1)).availability("AVAILABLE").build());
String dimensionJson = mockMvc.perform(get("/admin/scoring/fairness/dimensions").param("period", "2026-09").param("dimension", "ABILITY") String dimensionJson = mockMvc.perform(get("/admin/scoring/fairness/dimensions").param("period", "2026-09").param("dimension", "ABILITY")
.header("Authorization", token(1L, "ADMIN")).accept(MediaType.APPLICATION_JSON)) .header("Authorization", token(ADMIN_USER_ID, "ADMIN")).accept(MediaType.APPLICATION_JSON))
.andExpect(status().isOk()).andExpect(jsonPath("$.data.availability").value("AVAILABLE")) .andExpect(status().isOk()).andExpect(jsonPath("$.data.availability").value("AVAILABLE"))
.andExpect(jsonPath("$.data.dimensions[0].distribution.mean").value(55.55)) .andExpect(jsonPath("$.data.dimensions[0].distribution.mean").value(55.55))
.andExpect(jsonPath("$.data.dimensions[0].unknownStatusCount").value(0)) .andExpect(jsonPath("$.data.dimensions[0].unknownStatusCount").value(0))
.andReturn().getResponse().getContentAsString(); .andReturn().getResponse().getContentAsString();
assertNoSensitiveFields(dimensionJson); assertNoSensitiveFields(dimensionJson);
String appealJson = mockMvc.perform(get("/admin/scoring/fairness/appeals").param("from", "2026-08-01").param("to", "2026-09-01") String appealJson = mockMvc.perform(get("/admin/scoring/fairness/appeals").param("from", "2026-08-01").param("to", "2026-09-01")
.header("Authorization", token(1L, "ADMIN")).accept(MediaType.APPLICATION_JSON)) .header("Authorization", token(ADMIN_USER_ID, "ADMIN")).accept(MediaType.APPLICATION_JSON))
.andExpect(status().isOk()).andExpect(jsonPath("$.data.sampleSize").value(30)) .andExpect(status().isOk()).andExpect(jsonPath("$.data.sampleSize").value(30))
.andExpect(jsonPath("$.data.statusCandidateCounts.APPROVED").value(20)) .andExpect(jsonPath("$.data.statusCandidateCounts.APPROVED").value(20))
.andExpect(jsonPath("$.data.actualScoreChangeRate").value(37.50)) .andExpect(jsonPath("$.data.actualScoreChangeRate").value(37.50))
@ -184,7 +230,7 @@ class FairnessMonitoringControllerTest {
@Test @Test
void overviewSuppressionOmitsNestedDerivedValuesInSerializedJson() throws Exception { void overviewSuppressionOmitsNestedDerivedValuesInSerializedJson() throws Exception {
when(service.overview(eq(1L), eq("2026-09"))).thenReturn(FairnessResponse.Overview.builder() when(service.overview(eq(ADMIN_USER_ID), eq("2026-09"))).thenReturn(FairnessResponse.Overview.builder()
.basis("CURRENT_STORED_SNAPSHOTS").scope("SCORED_SNAPSHOT_POPULATION") .basis("CURRENT_STORED_SNAPSHOTS").scope("SCORED_SNAPSHOT_POPULATION")
.period("2026-09").populationSize(100) .period("2026-09").populationSize(100)
.availability("SUPPRESSED").reason("SMALL_CELL_OR_COMPLEMENT_RISK") .availability("SUPPRESSED").reason("SMALL_CELL_OR_COMPLEMENT_RISK")
@ -192,7 +238,7 @@ class FairnessMonitoringControllerTest {
String json = mockMvc.perform(get("/admin/scoring/fairness/overview") String json = mockMvc.perform(get("/admin/scoring/fairness/overview")
.param("period", "2026-09") .param("period", "2026-09")
.header("Authorization", token(1L, "ADMIN")) .header("Authorization", token(ADMIN_USER_ID, "ADMIN"))
.accept(MediaType.APPLICATION_JSON)) .accept(MediaType.APPLICATION_JSON))
.andExpect(status().isOk()) .andExpect(status().isOk())
.andExpect(jsonPath("$.data.populationSize").value(100)) .andExpect(jsonPath("$.data.populationSize").value(100))

301
mydreamjob-server/src/test/java/com/mydreamjob/scoringmonitoring/FairnessMonitoringFixtureIsolationTest.java

@ -0,0 +1,301 @@
package com.mydreamjob.scoringmonitoring;
import com.mydreamjob.auth.support.TestAuthTokens;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.test.web.servlet.MockMvc;
import java.sql.Timestamp;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
/**
* Fairness 认证夹具隔离反证测试。
*
* <p>背景:{@code FairnessMonitoringControllerTest} 曾用 {@code userId = 1/2} 申请 Token,
* 而 {@code V1_1__seed_data.sql} 的种子账号正好占用 {@code users.id = 1/2/3},
* 且 {@code V43__disable_legacy_seed_accounts.sql} 已把它们置为 {@code is_active = 0};
* {@code TestAuthTokens} 对"已存在但 inactive"的行不插入也不激活,于是该测试类
* **单独运行时**全部 Token 请求被 401 拒绝。全量顺序下
* {@code com.mydreamjob.controller.AuthIntegrationTest} 的 {@code userRepository.deleteAll()}
* 偶然清掉了种子行,问题才被掩盖。</p>
*
* <p>本类是该缺陷的**反证**:证明修复后的夹具既不依赖种子行的存在性/状态,
* 也不依赖测试执行顺序,并且在清理时只删除自己创建的数据。</p>
*
* <p>本类自身不使用种子 ID,全部操作按精确 ID 限定,不做任何整表删除。</p>
*/
@SpringBootTest
@AutoConfigureMockMvc
@ActiveProfiles("integration-test")
class FairnessMonitoringFixtureIsolationTest {
/** 与 {@code FairnessMonitoringControllerTest} 相同的独占夹具 ID(同包可见)。 */
private static final long ADMIN_USER_ID = FairnessMonitoringControllerTest.ADMIN_USER_ID;
/** V1_1 种子账号占用的 ID;本类只读、只做快照与恢复,绝不把它们当作夹具。 */
private static final List<Long> SEED_IDS = List.of(1L, 2L, 3L);
/** 本类自有的探针 ID,不与其他测试共用。 */
private static final long SENTINEL_USER_ID = 9_310_009L;
private static final long INACTIVE_USER_ID = 9_310_008L;
private static final String DRIFT = "/admin/scoring/fairness/drift";
@Autowired private MockMvc mockMvc;
@Autowired private JdbcTemplate jdbcTemplate;
@MockBean private FairnessMonitoringService service;
@MockBean private RedisTemplate<String, Object> redisTemplate;
/** 种子行快照;非 null 时 {@link #restoreSeedRows()} 负责原样恢复。 */
private List<Map<String, Object>> seedSnapshot;
@BeforeEach
void provision() {
cleanupOwnRows();
insertActiveUser(ADMIN_USER_ID, "a6fair-admin", "ADMIN");
when(service.drift(ADMIN_USER_ID)).thenReturn(FairnessResponse.Unavailable.builder()
.availability("UNAVAILABLE")
.reason("IMMUTABLE_SCORE_HISTORY_NOT_AVAILABLE")
.build());
}
@AfterEach
void cleanup() {
restoreSeedRows();
cleanupOwnRows();
jdbcTemplate.update("DELETE FROM auth_tokens WHERE user_id = ?", SENTINEL_USER_ID);
jdbcTemplate.update("DELETE FROM user_roles WHERE user_id = ?", SENTINEL_USER_ID);
jdbcTemplate.update("DELETE FROM users WHERE id = ?", SENTINEL_USER_ID);
}
private void cleanupOwnRows() {
for (long id : new long[]{ADMIN_USER_ID, INACTIVE_USER_ID}) {
jdbcTemplate.update("DELETE FROM auth_tokens WHERE user_id = ?", id);
jdbcTemplate.update("DELETE FROM user_roles WHERE user_id = ?", id);
jdbcTemplate.update("DELETE FROM users WHERE id = ?", id);
}
}
private void insertActiveUser(long id, String phone, String role) {
jdbcTemplate.update("INSERT INTO users (id, phone, nickname, role, is_active, is_verified) "
+ "VALUES (?, ?, ?, ?, 1, 1)", id, phone, "a6-fairness-isolation-" + role, role);
}
private int isActive(long id) {
Integer v = jdbcTemplate.queryForObject("SELECT is_active FROM users WHERE id = ?", Integer.class, id);
return v == null ? -1 : v;
}
private String bearer() {
return TestAuthTokens.bearer(ADMIN_USER_ID, "ADMIN");
}
// ── 1. 夹具 ID 不与被禁用的种子账号冲突 ────────────────────
@Test
@DisplayName("反证:夹具 ID 位于种子账号号段之外,且夹具用户确为 active")
void fixtureIdsAreOutsideSeedAccountRange() {
assertThat(ADMIN_USER_ID).as("夹具 ID 不得落在 V1_1 种子账号的 1/2/3 上")
.isNotIn(SEED_IDS);
assertThat(FairnessMonitoringControllerTest.STUDENT_USER_ID).isNotIn(SEED_IDS);
assertThat(isActive(ADMIN_USER_ID)).as("夹具用户必须由本类显式插入为 active").isEqualTo(1);
// 夹具手机号不得占用种子账号的手机号(唯一约束 + 语义隔离)
Integer squat = jdbcTemplate.queryForObject(
"SELECT COUNT(*) FROM users WHERE phone IN ('13800000001','13800000002','13800000003') "
+ "AND id IN (?, ?)", Integer.class, ADMIN_USER_ID,
FairnessMonitoringControllerTest.STUDENT_USER_ID);
assertThat(squat).as("夹具不得占用种子账号手机号").isZero();
}
// ── 2. 种子行为 inactive(V43 状态)或缺失时,夹具仍可认证 ──
@Test
@DisplayName("反证:种子行 inactive(V43 状态)或完全缺失时,Fairness 端点仍返回 200")
void seedRowsInactiveOrAbsent_stillAuthenticates() throws Exception {
snapshotAndTakeSeedRows();
// 阶段 A:种子行存在但 inactive(即 V43 执行后的真实状态,也是单跑时的状态)
jdbcTemplate.update("UPDATE users SET is_active = 0 WHERE id IN (1, 2, 3)");
mockMvc.perform(get(DRIFT).header("Authorization", bearer()))
.andExpect(status().isOk())
.andExpect(jsonPath("$.data.availability").value("UNAVAILABLE"));
// 阶段 B:种子行完全缺失(全量顺序下 AuthIntegrationTest.deleteAll() 之后的真实状态)
jdbcTemplate.update("DELETE FROM user_roles WHERE user_id IN (1, 2, 3)");
jdbcTemplate.update("DELETE FROM auth_tokens WHERE user_id IN (1, 2, 3)");
jdbcTemplate.update("DELETE FROM users WHERE id IN (1, 2, 3)");
mockMvc.perform(get(DRIFT).header("Authorization", bearer()))
.andExpect(status().isOk())
.andExpect(jsonPath("$.data.availability").value("UNAVAILABLE"));
}
// ── 3. 连续多次夹具装配与请求不冲突 ────────────────────────
@Test
@DisplayName("反证:连续三次装配 + 三次请求均成功,无唯一约束或残留冲突")
void repeatedProvisioningAndRequests_doNotConflict() throws Exception {
for (int round = 1; round <= 3; round++) {
cleanupOwnRows();
insertActiveUser(ADMIN_USER_ID, "a6fair-admin", "ADMIN");
when(service.drift(ADMIN_USER_ID)).thenReturn(FairnessResponse.Unavailable.builder()
.availability("UNAVAILABLE")
.reason("IMMUTABLE_SCORE_HISTORY_NOT_AVAILABLE")
.build());
mockMvc.perform(get(DRIFT).header("Authorization", bearer()))
.andExpect(status().isOk());
}
Integer users = jdbcTemplate.queryForObject(
"SELECT COUNT(*) FROM users WHERE id = ?", Integer.class, ADMIN_USER_ID);
assertThat(users).as("重复装配不得产生重复用户行").isEqualTo(1);
}
// ── 4. 夹具不触碰种子数据(顺序无关的根本保证)────────────
@Test
@DisplayName("反证:一次完整装配→请求→清理周期后,种子行逐字段不变")
void fixtureCycleLeavesSeedRowsUntouched() throws Exception {
List<Map<String, Object>> before = seedRows();
mockMvc.perform(get(DRIFT).header("Authorization", bearer())).andExpect(status().isOk());
cleanupOwnRows();
insertActiveUser(ADMIN_USER_ID, "a6fair-admin", "ADMIN");
assertThat(seedRows()).as("夹具周期不得改写任何种子行").isEqualTo(before);
}
// ── 5. 清理只删除本类自有数据 ──────────────────────────────
@Test
@DisplayName("反证:清理只删除本类夹具行,探针用户与会话保持不变")
void cleanupRemovesOnlyOwnFixtureRows() {
jdbcTemplate.update("INSERT INTO users (id, phone, nickname, role, is_active, is_verified) "
+ "VALUES (?, ?, ?, ?, 1, 1)", SENTINEL_USER_ID, "a6fair-sentinel",
"a6-fairness-isolation-sentinel", "STUDENT");
jdbcTemplate.update("INSERT INTO auth_tokens (user_id, token_hash, token_type, expired_at, revoked) "
+ "VALUES (?, ?, 'REFRESH', ?, 0)", SENTINEL_USER_ID, "a6fair-sentinel-hash",
Timestamp.valueOf(java.time.LocalDateTime.now().plusDays(1)));
cleanupOwnRows();
assertThat(jdbcTemplate.queryForObject(
"SELECT COUNT(*) FROM users WHERE id = ?", Integer.class, ADMIN_USER_ID))
.as("本类夹具行应已被清理").isZero();
assertThat(jdbcTemplate.queryForObject(
"SELECT COUNT(*) FROM users WHERE id = ?", Integer.class, SENTINEL_USER_ID))
.as("探针用户不得被清理波及").isEqualTo(1);
assertThat(jdbcTemplate.queryForObject(
"SELECT COUNT(*) FROM auth_tokens WHERE user_id = ?", Integer.class, SENTINEL_USER_ID))
.as("探针会话不得被清理波及").isEqualTo(1);
}
// ── 6. 其他用户与会话不受影响 ──────────────────────────────
@Test
@DisplayName("反证:装配→请求→清理周期前后,非夹具用户与会话逐字段不变")
void unrelatedUsersAndSessionsUnchanged() throws Exception {
List<Map<String, Object>> usersBefore = nonFixtureUsers();
List<Map<String, Object>> tokensBefore = nonFixtureTokens();
mockMvc.perform(get(DRIFT).header("Authorization", bearer())).andExpect(status().isOk());
cleanupOwnRows();
assertThat(nonFixtureUsers()).as("非夹具用户必须逐字段不变").isEqualTo(usersBefore);
assertThat(nonFixtureTokens()).as("非夹具会话必须逐字段不变").isEqualTo(tokensBefore);
}
// ── 7. 公共夹具不会静默复活 inactive 用户 ─────────────────
@Test
@DisplayName("反证:TestAuthTokens 不得把已存在的 inactive 用户静默激活")
void publicTokenFixtureDoesNotSilentlyReviveInactiveUser() throws Exception {
jdbcTemplate.update("INSERT INTO users (id, phone, nickname, role, is_active, is_verified) "
+ "VALUES (?, ?, ?, ?, 0, 1)", INACTIVE_USER_ID, "a6fair-inactive",
"a6-fairness-isolation-inactive", "ADMIN");
String token = TestAuthTokens.bearer(INACTIVE_USER_ID, "ADMIN");
assertThat(isActive(INACTIVE_USER_ID))
.as("公共夹具必须保留该用户被禁用的状态,不得静默复活")
.isEqualTo(0);
mockMvc.perform(get(DRIFT).header("Authorization", token))
.andExpect(status().isUnauthorized());
verify(service, never()).drift(any());
}
// ── 工具 ───────────────────────────────────────────────────
private List<Map<String, Object>> seedRows() {
return jdbcTemplate.queryForList(
"SELECT id, phone, email, nickname, password_hash, role, is_active, is_verified "
+ "FROM users WHERE id IN (1, 2, 3) ORDER BY id");
}
private List<Map<String, Object>> nonFixtureUsers() {
return jdbcTemplate.queryForList(
"SELECT id, phone, nickname, role, is_active FROM users WHERE id NOT IN (?, ?) ORDER BY id",
ADMIN_USER_ID, INACTIVE_USER_ID);
}
private List<Map<String, Object>> nonFixtureTokens() {
return jdbcTemplate.queryForList(
"SELECT id, user_id, token_type, revoked FROM auth_tokens "
+ "WHERE user_id NOT IN (?, ?) ORDER BY id",
ADMIN_USER_ID, INACTIVE_USER_ID);
}
/** 快照种子行(含主键与状态),供缺失场景后原样恢复。 */
private void snapshotAndTakeSeedRows() {
seedSnapshot = jdbcTemplate.queryForList(
"SELECT id, phone, email, nickname, password_hash, role, is_active, is_verified, "
+ "created_at, updated_at FROM users WHERE id IN (1, 2, 3) ORDER BY id");
}
/**
* 按精确主键恢复种子行。
*
* <p>只在测试**主动删除**过种子行时才执行({@code seedSnapshot} 非 null),
* 且严格还原全部字段,不新增、不批量重建、不影响其他行。</p>
*/
private void restoreSeedRows() {
if (seedSnapshot == null) {
return;
}
List<Map<String, Object>> snapshot = new ArrayList<>(seedSnapshot);
seedSnapshot = null;
for (Map<String, Object> row : snapshot) {
Long id = ((Number) row.get("id")).longValue();
if (jdbcTemplate.queryForObject("SELECT COUNT(*) FROM users WHERE id = ?", Integer.class, id) > 0) {
continue;
}
jdbcTemplate.update("INSERT INTO users (id, phone, email, nickname, password_hash, role, "
+ "is_active, is_verified, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
id, row.get("phone"), row.get("email"), row.get("nickname"), row.get("password_hash"),
row.get("role"), row.get("is_active"), row.get("is_verified"),
row.get("created_at"), row.get("updated_at"));
}
}
}
Loading…
Cancel
Save