From 0609b9422a4b628d93965bbc299afb1cf5339b9f Mon Sep 17 00:00:00 2001 From: Zhang Shun Date: Fri, 9 Oct 2026 10:12:18 +0800 Subject: [PATCH] fix: gate salary policies behind verified publication allowlist --- docs/openapi-rc.json | 3 + docs/salary-calculator-api.md | 44 ++++- .../SalaryCalculationController.java | 2 +- .../SalaryCalculationService.java | 13 +- .../SalaryCityPolicyController.java | 4 +- .../SalaryCityPolicyService.java | 5 + .../SalaryPolicyPublicationProperties.java | 33 ++++ ...endentMySqlFunctionalVerificationTest.java | 3 + .../SalaryPublicationDefaultMySqlTest.java | 124 +++++++++++++ ...ationIndependentMySqlVerificationTest.java | 169 ++++++++++++++++++ ...aryCalculationEndToEndIntegrationTest.java | 6 +- .../SalaryCityPolicyControllerTest.java | 14 +- .../SalaryCityPolicyServiceTest.java | 16 ++ ...SalaryPolicyPublicationPropertiesTest.java | 54 ++++++ 14 files changed, 478 insertions(+), 12 deletions(-) create mode 100644 mydreamjob-server/src/main/java/com/mydreamjob/salarycalculator/SalaryPolicyPublicationProperties.java create mode 100644 mydreamjob-server/src/test/java/com/mydreamjob/integration/SalaryPublicationDefaultMySqlTest.java create mode 100644 mydreamjob-server/src/test/java/com/mydreamjob/integration/SalaryPublicationIndependentMySqlVerificationTest.java create mode 100644 mydreamjob-server/src/test/java/com/mydreamjob/salarycalculator/SalaryPolicyPublicationPropertiesTest.java diff --git a/docs/openapi-rc.json b/docs/openapi-rc.json index c807a3f..394e298 100644 --- a/docs/openapi-rc.json +++ b/docs/openapi-rc.json @@ -9720,6 +9720,7 @@ "薪资计算器" ], "summary": "查询城市默认参数", + "description": "仅 STUDENT;未发布或未启用政策返回 404/10601,不返回参数", "operationId": "getSalaryCity", "security": [ { @@ -9765,6 +9766,7 @@ "薪资计算器" ], "summary": "查询启用城市列表", + "description": "仅 STUDENT;仅返回已核验发布白名单中的启用城市版本;默认空列表,不分页", "operationId": "listSalaryCities", "security": [ { @@ -10869,6 +10871,7 @@ "薪资计算器" ], "summary": "计算并保存到手工资", + "description": "仅 STUDENT;未发布政策返回 404/10601,幂等重放不可绕过发布门禁;本人历史查询不受影响", "operationId": "calculateSalary", "security": [ { diff --git a/docs/salary-calculator-api.md b/docs/salary-calculator-api.md index 3ad6a3f..c4c7b20 100644 --- a/docs/salary-calculator-api.md +++ b/docs/salary-calculator-api.md @@ -4,6 +4,8 @@ > 认证:`Authorization: Bearer ` > 角色:当前登录用户必须具备 `STUDENT` 角色。 +> 发布状态(2026-10-08):政策核验白名单默认为空;种子数据已入库不代表已获准发布。默认不提供城市参数或新计算,本人历史查询/删除仍保持原有角色及归属限制。 + ## 1. 能力边界 - 输入税前月薪,估算个人五险一金、大病固定费和当月个税后的到手工资。 @@ -17,7 +19,7 @@ `GET /salary-calculator/cities` -返回全部启用城市摘要,按省份编码和城市编码排序。 +仅返回启用且“城市编码 + 当前政策版本”均命中发布白名单的摘要,按省份编码和城市编码排序。默认返回 `200`,`data=[]`。以下非空响应仅为契约示例,不代表这些政策已上线。 响应示例: @@ -134,7 +136,7 @@ | 400 | 10602 | 工资、比例、基数或分页参数非法 | | 401 | 401 | 未登录或 Token 无效 | | 403 | 2002 | 已登录但不是 `STUDENT` | -| 404 | 10601 | 城市不存在、停用或未配置 | +| 404 | 10601 | 城市不存在、停用、未配置或政策未发布 | | 404 | 10603 | 历史不存在或不属于当前用户 | | 409 | 10604 | 幂等键冲突 | | 409 | 10605 | 城市政策版本已变化 | @@ -156,3 +158,41 @@ 到手工资 = max(税前月薪 - 个人养老 - 个人医疗 - 个人失业 - 个人公积金 - 大病固定费 - 当月个税, 0) ``` + +## 9. 政策核验与发布门禁 + +### 默认关闭 + +`app.salary-publication.approved-policies` 是城市编码到已核验政策版本的精确映射,默认空映射。`enabled=true`、`dataLevel=CITY`、种子数量或功能测试通过,均不等于政策已核验。V53 保留原字节,不修改种子或已有历史快照,也不新增迁移。 + +### 白名单配置 + +仅在完成核验和发布审批后,由部署人员通过外部 Spring 配置提供映射。例如下列内容只展示配置格式,不可直接照抄到生产: + +```yaml +app: + salary-publication: + approved-policies: + "110000": "已核验记录对应的实际政策版本" +``` + +城市编码必须为六位数字,版本不能为空或包含首尾空白;非法配置启动失败。城市和数据库当前版本都必须精确匹配,版本变更后旧白名单不会自动批准新版本。不提供全量开放或按 CITY/PROVINCE_FALLBACK 批量开放的开关。 + +测试类显式配置的种子版本只用于算法、响应契约、并发与回滚验证,不可作为上线授权或政策准确性证据。生产默认配置没有任何批准项。 + +### 前端行为 + +- 城市列表默认 `HTTP 200 / code=200 / data=[]`;前端显示“薪资估算暂未开放”,不要自行回退到未经核验的本地参数。 +- 未发布城市的详情和新计算返回 `HTTP 404 / code=10601`,使用既有固定错误文案,不泄露政策参数。 +- 相同幂等键的历史重放也经过发布门禁;不能利用 POST 重放绕过关闭状态。 +- 只有已发布政策才会进入原有版本冲突检查;未发布时不返回最新政策版本信息。 + +### 历史兼容 + +本人历史列表、详情和删除接口不受发布白名单影响,继续执行 STUDENT 角色与记录归属检查。历史快照仅表示当时的估算结果,不证明其政策准确,不能将其当成当前政策使用。 + +### 核验记录要求 + +每个拟开放城市必须补齐官方来源地址、发布主体、适用地区、有效期、社保和公积金基数与比例、固定费用、核验人和核验日期,并记录与数据库 `policyVersion` 的对应关系。省级回退数据还需核实适用性并明确展示估算限制。更正已提交政策应使用后续新增迁移,不改写 V1–V53。 + +当前 359 条种子的政策准确性尚未由本次测试证明,因此本轮不添加任何生产批准项;N03/N04 不受此门禁影响。合并、部署及部署后三中间件黑盒验收另行执行。 diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/salarycalculator/SalaryCalculationController.java b/mydreamjob-server/src/main/java/com/mydreamjob/salarycalculator/SalaryCalculationController.java index f69a0d2..9a80f1d 100644 --- a/mydreamjob-server/src/main/java/com/mydreamjob/salarycalculator/SalaryCalculationController.java +++ b/mydreamjob-server/src/main/java/com/mydreamjob/salarycalculator/SalaryCalculationController.java @@ -34,7 +34,7 @@ public class SalaryCalculationController { private final SalaryCalculationService calculationService; private final SalaryHistoryService historyService; - @Operation(summary = "计算并保存到手工资", description = "仅 STUDENT;校验政策版本,支持幂等键") + @Operation(summary = "计算并保存到手工资", description = "仅 STUDENT;未发布政策返回 404/10601,幂等重放不可绕过发布门禁;本人历史查询不受影响") @PostMapping("/calculations") public ResponseEntity> calculate( @Valid @RequestBody SalaryCalculationRequest body, HttpServletRequest request) { diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/salarycalculator/SalaryCalculationService.java b/mydreamjob-server/src/main/java/com/mydreamjob/salarycalculator/SalaryCalculationService.java index 83d18f4..6166d4c 100644 --- a/mydreamjob-server/src/main/java/com/mydreamjob/salarycalculator/SalaryCalculationService.java +++ b/mydreamjob-server/src/main/java/com/mydreamjob/salarycalculator/SalaryCalculationService.java @@ -28,6 +28,7 @@ public class SalaryCalculationService { private final SalaryCalculationEngine calculationEngine; private final SalaryRequestFingerprint requestFingerprint; private final ObjectMapper objectMapper; + private final SalaryPolicyPublicationProperties publication; @Transactional(isolation = Isolation.READ_COMMITTED) public SalaryCalculationResponse calculateAndSave(Long userId, SalaryCalculationRequest request) { @@ -35,15 +36,17 @@ public class SalaryCalculationService { String requestHash = idempotencyKey == null ? null : requestFingerprint.fingerprint(request); // 在读取幂等记录之前锁定用户,跨实例串行化同一用户的写入;锁随事务结束释放。 requireStudent(userId); + SalaryCityPolicy policy = cityPolicyRepository.findByCityCodeAndEnabledTrue(request.getCityCode()) + .orElseThrow(() -> new BusinessException(ErrorCode.SALARY_CITY_NOT_FOUND)); + requirePublished(policy.getCityCode(), policy.getPolicyVersion()); if (idempotencyKey != null) { var existing = historyRepository.findByStudentUserIdAndIdempotencyKey(userId, idempotencyKey); if (existing.isPresent()) { + requirePublished(existing.get().getCityCode(), existing.get().getPolicyVersion()); return replay(existing.get(), requestHash); } } - SalaryCityPolicy policy = cityPolicyRepository.findByCityCodeAndEnabledTrue(request.getCityCode()) - .orElseThrow(() -> new BusinessException(ErrorCode.SALARY_CITY_NOT_FOUND)); if (!Objects.equals(policy.getPolicyVersion(), request.getExpectedPolicyVersion())) { throw new SalaryPolicyVersionConflictException( policy.getPolicyVersion(), policy.getPolicyEffectiveDate()); @@ -78,6 +81,12 @@ public class SalaryCalculationService { return toResponse(history); } + private void requirePublished(String cityCode, String policyVersion) { + if (!publication.isPublished(cityCode, policyVersion)) { + throw new BusinessException(ErrorCode.SALARY_CITY_NOT_FOUND); + } + } + private SalaryCalculationResponse toResponse(SalaryCalculationHistory history) { try { SalaryCalculationResult result = objectMapper.readValue( diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/salarycalculator/SalaryCityPolicyController.java b/mydreamjob-server/src/main/java/com/mydreamjob/salarycalculator/SalaryCityPolicyController.java index f59d723..8dfae97 100644 --- a/mydreamjob-server/src/main/java/com/mydreamjob/salarycalculator/SalaryCityPolicyController.java +++ b/mydreamjob-server/src/main/java/com/mydreamjob/salarycalculator/SalaryCityPolicyController.java @@ -27,7 +27,7 @@ public class SalaryCityPolicyController { private final SalaryCityPolicyService cityPolicyService; - @Operation(summary = "查询启用城市列表", description = "仅 STUDENT;返回全部启用城市摘要,不分页") + @Operation(summary = "查询启用城市列表", description = "仅 STUDENT;仅返回已核验发布白名单中的启用城市版本;默认空列表,不分页") @GetMapping("/cities") public ResponseEntity>> listCities( HttpServletRequest request) { @@ -36,7 +36,7 @@ public class SalaryCityPolicyController { cityPolicyService.listCities(userId), getRequestId(request))); } - @Operation(summary = "查询城市默认参数", description = "仅 STUDENT;返回城市元数据和当前整套默认参数") + @Operation(summary = "查询城市默认参数", description = "仅 STUDENT;未发布或未启用政策返回 404/10601,不返回参数") @GetMapping("/cities/{cityCode}") public ResponseEntity> getCity( @PathVariable String cityCode, HttpServletRequest request) { diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/salarycalculator/SalaryCityPolicyService.java b/mydreamjob-server/src/main/java/com/mydreamjob/salarycalculator/SalaryCityPolicyService.java index b87c16e..1da762a 100644 --- a/mydreamjob-server/src/main/java/com/mydreamjob/salarycalculator/SalaryCityPolicyService.java +++ b/mydreamjob-server/src/main/java/com/mydreamjob/salarycalculator/SalaryCityPolicyService.java @@ -18,12 +18,14 @@ public class SalaryCityPolicyService { private final SalaryCityPolicyRepository cityPolicyRepository; private final UserRepository userRepository; + private final SalaryPolicyPublicationProperties publication; @Transactional(readOnly = true) public List listCities(Long userId) { requireStudent(userId); return cityPolicyRepository.findByEnabledTrueOrderByProvinceCodeAscCityCodeAsc() .stream() + .filter(policy -> publication.isPublished(policy.getCityCode(), policy.getPolicyVersion())) .map(this::toSummary) .toList(); } @@ -37,6 +39,9 @@ public class SalaryCityPolicyService { SalaryCityPolicy policy = cityPolicyRepository .findByCityCodeAndEnabledTrue(cityCode) .orElseThrow(() -> new BusinessException(ErrorCode.SALARY_CITY_NOT_FOUND)); + if (!publication.isPublished(policy.getCityCode(), policy.getPolicyVersion())) { + throw new BusinessException(ErrorCode.SALARY_CITY_NOT_FOUND); + } return SalaryCityDetailResponse.builder() .cityCode(policy.getCityCode()) .provinceCode(policy.getProvinceCode()) diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/salarycalculator/SalaryPolicyPublicationProperties.java b/mydreamjob-server/src/main/java/com/mydreamjob/salarycalculator/SalaryPolicyPublicationProperties.java new file mode 100644 index 0000000..e3ddca1 --- /dev/null +++ b/mydreamjob-server/src/main/java/com/mydreamjob/salarycalculator/SalaryPolicyPublicationProperties.java @@ -0,0 +1,33 @@ +package com.mydreamjob.salarycalculator; + +import jakarta.annotation.PostConstruct; +import java.util.LinkedHashMap; +import java.util.Map; +import lombok.Getter; +import lombok.Setter; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.stereotype.Component; + +/** 发布白名单为空时失败关闭;enabled/dataLevel 不表示政策已核验。 */ +@Getter +@Setter +@Component +@ConfigurationProperties(prefix = "app.salary-publication") +public class SalaryPolicyPublicationProperties { + private Map approvedPolicies = new LinkedHashMap<>(); + + @PostConstruct + void validate() { + if (approvedPolicies == null || approvedPolicies.entrySet().stream().anyMatch(entry -> + entry.getKey() == null || !entry.getKey().matches("[0-9]{6}") + || entry.getValue() == null || entry.getValue().isBlank() + || !entry.getValue().equals(entry.getValue().trim()))) { + throw new IllegalStateException("invalid salary publication allowlist"); + } + } + + public boolean isPublished(String cityCode, String policyVersion) { + return cityCode != null && policyVersion != null && approvedPolicies != null + && policyVersion.equals(approvedPolicies.get(cityCode)); + } +} diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/integration/LxqIndependentMySqlFunctionalVerificationTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/integration/LxqIndependentMySqlFunctionalVerificationTest.java index 6f282a8..f37fb4d 100644 --- a/mydreamjob-server/src/test/java/com/mydreamjob/integration/LxqIndependentMySqlFunctionalVerificationTest.java +++ b/mydreamjob-server/src/test/java/com/mydreamjob/integration/LxqIndependentMySqlFunctionalVerificationTest.java @@ -50,6 +50,9 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers. @SpringBootTest @AutoConfigureMockMvc @ActiveProfiles("mysql-it") +// 仅用于算法、并发及回滚夹具,不作为真实政策核验证据。 +@org.springframework.test.context.TestPropertySource(properties = + "app.salary-publication.approved-policies.110000=2026-09-20-v1") class LxqIndependentMySqlFunctionalVerificationTest { @Container static final MySQLContainer MYSQL = new MySQLContainer<>("mysql:8.4") .withDatabaseName("lxq_independent").withUsername("independent_test") diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/integration/SalaryPublicationDefaultMySqlTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/integration/SalaryPublicationDefaultMySqlTest.java new file mode 100644 index 0000000..65d2af8 --- /dev/null +++ b/mydreamjob-server/src/test/java/com/mydreamjob/integration/SalaryPublicationDefaultMySqlTest.java @@ -0,0 +1,124 @@ +package com.mydreamjob.integration; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.mydreamjob.auth.AuthSessionService; +import com.mydreamjob.auth.UserRepository; +import java.util.*; +import org.junit.jupiter.api.*; +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.http.MediaType; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.test.context.*; +import org.springframework.test.web.servlet.MockMvc; +import org.testcontainers.containers.MySQLContainer; +import org.testcontainers.junit.jupiter.*; +import static org.assertj.core.api.Assertions.assertThat; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; + +/** 默认发布配置必须拦截所有未核验种子;真实 MySQL,Redis mock、MQ 关闭。 */ +@SpringBootTest +@AutoConfigureMockMvc +@ActiveProfiles("mysql-it") +@Testcontainers(disabledWithoutDocker = false) +class SalaryPublicationDefaultMySqlTest { + @Container static final MySQLContainer MYSQL = new MySQLContainer<>("mysql:8.4") + .withDatabaseName("salary_publication_test") + .withUsername("publication_test").withPassword("publication-test-only"); + @DynamicPropertySource static void database(DynamicPropertyRegistry r) { + r.add("spring.datasource.url", MYSQL::getJdbcUrl); + r.add("spring.datasource.username", MYSQL::getUsername); + r.add("spring.datasource.password", MYSQL::getPassword); + } + @MockBean RedisTemplate redis; + @Autowired JdbcTemplate jdbc; + @Autowired MockMvc mvc; + @Autowired AuthSessionService sessions; + @Autowired UserRepository users; + @Autowired ObjectMapper mapper; + @Autowired com.mydreamjob.salarycalculator.SalaryRequestFingerprint fingerprint; + private String token; + @BeforeEach void identity() { + token = "Bearer " + LxqTestSessionIssuer.issue(92001, "STUDENT", sessions, users, jdbc); + } + @AfterEach void cleanup() { + jdbc.update("delete from salary_calculation_history where student_user_id=?", 92001); + LxqTestSessionIssuer.cleanup(jdbc); + } + private String request() { + return "{\"cityCode\":\"110000\",\"grossMonthlySalary\":10000,\"monthIndex\":1," + + "\"specialAdditionalDeduction\":0,\"expectedPolicyVersion\":\"2026-09-20-v1\"," + + "\"idempotencyKey\":\"publication-test\"}"; + } + private Map> snapshot() { + Map> result = new TreeMap<>(); + for (String table : jdbc.queryForList("show tables", String.class)) { + List rows = jdbc.query("select * from `" + table + "`", (rs, n) -> { + List columns = new ArrayList<>(); + for (int i = 1; i <= rs.getMetaData().getColumnCount(); i++) { + columns.add(rs.getMetaData().getColumnName(i) + "=" + rs.getString(i)); + } + return columns.toString(); + }); + rows.sort(Comparator.naturalOrder()); + result.put(table, rows); + } + return result; + } + @Test void seedsRemainButDefaultCityListIsEmptyAndReadOnly() throws Exception { + assertThat(jdbc.queryForObject("select count(*) from salary_city_policy", Integer.class)).isEqualTo(359); + var before = snapshot(); + mvc.perform(get("/salary-calculator/cities").header("Authorization", token)) + .andExpect(status().isOk()).andExpect(jsonPath("$.data").isEmpty()); + assertThat(snapshot()).isEqualTo(before); + } + @Test void directCityLookupIsClosedWithoutSideEffects() throws Exception { + var before = snapshot(); + mvc.perform(get("/salary-calculator/cities/110000").header("Authorization", token)) + .andExpect(status().isNotFound()).andExpect(jsonPath("$.code").value(10601)); + assertThat(snapshot()).isEqualTo(before); + } + @Test void calculationIsClosedWithoutSideEffects() throws Exception { + var before = snapshot(); + mvc.perform(post("/salary-calculator/calculations").header("Authorization", token) + .contentType(MediaType.APPLICATION_JSON).content(request())) + .andExpect(status().isNotFound()).andExpect(jsonPath("$.code").value(10601)); + assertThat(snapshot()).isEqualTo(before); + } + @Test void anonymousIsStillUnauthorized() throws Exception { + mvc.perform(get("/salary-calculator/cities")).andExpect(status().isUnauthorized()); + } + @Test void historyListIsStillAvailable() throws Exception { + mvc.perform(get("/salary-calculator/calculations").header("Authorization", token)) + .andExpect(status().isOk()); + } + @Test void existingHistoryIsReadableButCannotBypassPublicationThroughReplay() throws Exception { + var body = mapper.readValue(request(), com.mydreamjob.salarycalculator.dto.SalaryCalculationRequest.class); + jdbc.update("insert into salary_calculation_history(student_user_id,city_code,city_name,data_level," + + "policy_version,tax_rules_version,month_index,gross_monthly_salary,net_salary," + + "idempotency_key,request_hash,input_snapshot_json,policy_snapshot_json,result_snapshot_json)" + + " values(?,?,?,?,?,?,?,?,?,?,?,?,?,?)", + 92001, "110000", "test-city", "CITY", "2026-09-20-v1", "test-tax-version", + 1, 10000, 8000, "publication-test", fingerprint.fingerprint(body), "{}", "{}", "{}"); + Long id = jdbc.queryForObject("select id from salary_calculation_history where student_user_id=?", + Long.class, 92001); + var before = snapshot(); + mvc.perform(get("/salary-calculator/calculations/" + id).header("Authorization", token)) + .andExpect(status().isOk()).andExpect(jsonPath("$.data.id").value(id)); + mvc.perform(post("/salary-calculator/calculations").header("Authorization", token) + .contentType(MediaType.APPLICATION_JSON).content(request())) + .andExpect(status().isNotFound()).andExpect(jsonPath("$.code").value(10601)); + assertThat(snapshot()).isEqualTo(before); + } + @Test void n03AndN04RemainAvailable() throws Exception { + mvc.perform(get("/onboarding/company/checklist-items").header("Authorization", token)) + .andExpect(status().isForbidden()).andExpect(jsonPath("$.code").value(2002)); + // 接口仍然存在,原角色门禁不被薪资发布配置影响。 + mvc.perform(get("/v3/api-docs")).andExpect(status().isOk()) + .andExpect(jsonPath("$.paths['/skills/word-cloud']").exists()); + } +} diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/integration/SalaryPublicationIndependentMySqlVerificationTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/integration/SalaryPublicationIndependentMySqlVerificationTest.java new file mode 100644 index 0000000..194f5c8 --- /dev/null +++ b/mydreamjob-server/src/test/java/com/mydreamjob/integration/SalaryPublicationIndependentMySqlVerificationTest.java @@ -0,0 +1,169 @@ +package com.mydreamjob.integration; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.mydreamjob.auth.AuthSessionService; +import com.mydreamjob.auth.UserRepository; +import com.mydreamjob.salarycalculator.SalaryPolicyPublicationProperties; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.TreeMap; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +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.http.MediaType; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.DynamicPropertyRegistry; +import org.springframework.test.context.DynamicPropertySource; +import org.springframework.test.web.servlet.MockMvc; +import org.testcontainers.containers.MySQLContainer; +import org.testcontainers.junit.jupiter.Container; +import org.testcontainers.junit.jupiter.Testcontainers; +import static org.assertj.core.api.Assertions.assertThat; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; + +/** 独立复验撤销、版本漂移、旧历史与归属边界;不证明现实政策准确性。 */ +@SpringBootTest +@AutoConfigureMockMvc +@ActiveProfiles("mysql-it") +@Testcontainers(disabledWithoutDocker = false) +class SalaryPublicationIndependentMySqlVerificationTest { + private static final long OWNER = 93001; + private static final long OTHER = 93002; + private static final String VERSION = "2026-09-20-v1"; + @Container static final MySQLContainer MYSQL = new MySQLContainer<>("mysql:8.4") + .withDatabaseName("salary_independent_test") + .withUsername("salary_test").withPassword("independent-test-only"); + @DynamicPropertySource static void database(DynamicPropertyRegistry registry) { + registry.add("spring.datasource.url", MYSQL::getJdbcUrl); + registry.add("spring.datasource.username", MYSQL::getUsername); + registry.add("spring.datasource.password", MYSQL::getPassword); + } + @MockBean RedisTemplate redis; + @Autowired JdbcTemplate jdbc; + @Autowired MockMvc mvc; + @Autowired AuthSessionService sessions; + @Autowired UserRepository users; + @Autowired SalaryPolicyPublicationProperties publication; + @Autowired ObjectMapper mapper; + private String ownerToken; + + @BeforeEach void identity() { + assertThat(publication.getApprovedPolicies()).isEmpty(); + ownerToken = token(OWNER, "STUDENT"); + } + @AfterEach void restore() { + publication.setApprovedPolicies(new LinkedHashMap<>()); + jdbc.update("update salary_city_policy set policy_version=?, enabled=true where city_code=?", VERSION, "110000"); + jdbc.update("delete from salary_calculation_history where student_user_id in (?,?)", OWNER, OTHER); + LxqTestSessionIssuer.cleanup(jdbc); + } + private String token(long id, String role) { + return "Bearer " + LxqTestSessionIssuer.issue(id, role, sessions, users, jdbc); + } + private void approve() { + publication.setApprovedPolicies(new LinkedHashMap<>(Map.of("110000", VERSION))); + } + private String request() { + return "{\"cityCode\":\"110000\",\"grossMonthlySalary\":10000,\"monthIndex\":1," + + "\"specialAdditionalDeduction\":0,\"expectedPolicyVersion\":\"" + VERSION + "\"," + + "\"idempotencyKey\":\"independent-replay\"}"; + } + private long calculate() throws Exception { + String response = mvc.perform(post("/salary-calculator/calculations") + .header("Authorization", ownerToken).contentType(MediaType.APPLICATION_JSON).content(request())) + .andExpect(status().isOk()).andReturn().getResponse().getContentAsString(); + return mapper.readTree(response).path("data").path("historyId").asLong(); + } + private Map> snapshot() { + Map> result = new TreeMap<>(); + for (String table : jdbc.queryForList("show tables", String.class)) { + List rows = jdbc.query("select * from `" + table + "`", (rs, n) -> { + List columns = new ArrayList<>(); + for (int i = 1; i <= rs.getMetaData().getColumnCount(); i++) { + columns.add(rs.getMetaData().getColumnName(i) + "=" + rs.getString(i)); + } + return columns.toString(); + }); + rows.sort(Comparator.naturalOrder()); + result.put(table, rows); + } + return result; + } + private void deniedCalculationIsReadOnly() throws Exception { + var before = snapshot(); + mvc.perform(post("/salary-calculator/calculations").header("Authorization", ownerToken) + .contentType(MediaType.APPLICATION_JSON).content(request())) + .andExpect(status().isNotFound()).andExpect(jsonPath("$.code").value(10601)) + .andExpect(jsonPath("$.data.policyVersion").doesNotExist()) + .andExpect(jsonPath("$.data.result").doesNotExist()); + assertThat(snapshot()).isEqualTo(before); + } + + @Test void allowlistDoesNotPublishOtherCitiesOrVersions() throws Exception { + approve(); + mvc.perform(get("/salary-calculator/cities").header("Authorization", ownerToken)) + .andExpect(status().isOk()).andExpect(jsonPath("$.data.length()").value(1)) + .andExpect(jsonPath("$.data[0].cityCode").value("110000")); + mvc.perform(get("/salary-calculator/cities/310000").header("Authorization", ownerToken)) + .andExpect(status().isNotFound()).andExpect(jsonPath("$.code").value(10601)); + publication.setApprovedPolicies(new LinkedHashMap<>(Map.of("110000", "wrong-test-version"))); + deniedCalculationIsReadOnly(); + } + @Test void policyVersionDriftClosesEvenExistingIdempotencyReplay() throws Exception { + approve(); + calculate(); + jdbc.update("update salary_city_policy set policy_version=? where city_code=?", "unapproved-next-test-version", "110000"); + deniedCalculationIsReadOnly(); + } + @Test void approvedCurrentPolicyCannotReplayUnapprovedStoredVersion() throws Exception { + approve(); + long id = calculate(); + jdbc.update("update salary_calculation_history set policy_version=? where id=?", "unapproved-old-test-version", id); + deniedCalculationIsReadOnly(); + } + @Test void revokedPublicationPreservesOwnHistoryAndDeleteButNotCrossOwnerAccess() throws Exception { + approve(); + long id = calculate(); + String otherToken = token(OTHER, "STUDENT"); + publication.setApprovedPolicies(new LinkedHashMap<>()); + deniedCalculationIsReadOnly(); + var before = snapshot(); + mvc.perform(get("/salary-calculator/calculations/" + id).header("Authorization", ownerToken)) + .andExpect(status().isOk()).andExpect(jsonPath("$.data.id").value(id)); + mvc.perform(get("/salary-calculator/calculations/" + id).header("Authorization", otherToken)) + .andExpect(status().isNotFound()).andExpect(jsonPath("$.code").value(10603)); + mvc.perform(delete("/salary-calculator/calculations/" + id).header("Authorization", otherToken)) + .andExpect(status().isNotFound()).andExpect(jsonPath("$.code").value(10603)); + assertThat(snapshot()).isEqualTo(before); + mvc.perform(delete("/salary-calculator/calculations/" + id).header("Authorization", ownerToken)) + .andExpect(status().isOk()); + assertThat(jdbc.queryForObject("select count(*) from salary_calculation_history where id=?", Integer.class, id)).isZero(); + } + @Test void approvedButDisabledPolicyCannotReplay() throws Exception { + approve(); + calculate(); + jdbc.update("update salary_city_policy set enabled=false where city_code=?", "110000"); + deniedCalculationIsReadOnly(); + } + @Test void approvalDoesNotOverrideCompanyRoleRestriction() throws Exception { + approve(); + String companyToken = token(OTHER, "COMPANY"); + var before = snapshot(); + mvc.perform(get("/salary-calculator/cities/110000").header("Authorization", companyToken)) + .andExpect(status().isForbidden()).andExpect(jsonPath("$.code").value(2002)); + mvc.perform(post("/salary-calculator/calculations").header("Authorization", companyToken) + .contentType(MediaType.APPLICATION_JSON).content(request())) + .andExpect(status().isForbidden()).andExpect(jsonPath("$.code").value(2002)); + assertThat(snapshot()).isEqualTo(before); + } +} diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/salarycalculator/SalaryCalculationEndToEndIntegrationTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/salarycalculator/SalaryCalculationEndToEndIntegrationTest.java index 512af33..f8c6cb6 100644 --- a/mydreamjob-server/src/test/java/com/mydreamjob/salarycalculator/SalaryCalculationEndToEndIntegrationTest.java +++ b/mydreamjob-server/src/test/java/com/mydreamjob/salarycalculator/SalaryCalculationEndToEndIntegrationTest.java @@ -29,8 +29,10 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers. @SpringBootTest @AutoConfigureMockMvc @ActiveProfiles("integration-test") -@TestPropertySource(properties = - "spring.datasource.url=jdbc:h2:mem:integrationdb_salary_e2e;MODE=MySQL;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE") +// 仅算法/事务测试夹具开放该版本,不代表种子政策已经核验。 +@TestPropertySource(properties = { + "app.salary-publication.approved-policies.110000=2026-09-20-v1", + "spring.datasource.url=jdbc:h2:mem:integrationdb_salary_e2e;MODE=MySQL;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE"}) class SalaryCalculationEndToEndIntegrationTest { @org.springframework.beans.factory.annotation.Autowired private com.mydreamjob.auth.AuthSessionService integrationSessions; @org.springframework.beans.factory.annotation.Autowired private com.mydreamjob.auth.UserRepository integrationUsers; diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/salarycalculator/SalaryCityPolicyControllerTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/salarycalculator/SalaryCityPolicyControllerTest.java index d86e3d9..49e9fd5 100644 --- a/mydreamjob-server/src/test/java/com/mydreamjob/salarycalculator/SalaryCityPolicyControllerTest.java +++ b/mydreamjob-server/src/test/java/com/mydreamjob/salarycalculator/SalaryCityPolicyControllerTest.java @@ -23,6 +23,10 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers. @SpringBootTest @AutoConfigureMockMvc @ActiveProfiles("integration-test") +// 仅开放两个响应契约夹具;不表示真实政策核验,其他 357 条保持关闭。 +@org.springframework.test.context.TestPropertySource(properties = { + "app.salary-publication.approved-policies.110000=2026-09-20-v1", + "app.salary-publication.approved-policies.130100=2026-09-20-v1"}) class SalaryCityPolicyControllerTest { @org.springframework.beans.factory.annotation.Autowired private com.mydreamjob.auth.AuthSessionService integrationSessions; @org.springframework.beans.factory.annotation.Autowired private com.mydreamjob.auth.UserRepository integrationUsers; @@ -41,16 +45,20 @@ class SalaryCityPolicyControllerTest { @MockBean private RedisTemplate redisTemplate; @Test - void cities_returnsAllSeededCitiesForStudent() throws Exception { + void cities_returnsOnlyApprovedFixtureCitiesForStudent() throws Exception { long uid = createUser(User.Role.STUDENT); mockMvc.perform(get("/salary-calculator/cities") .header("Authorization", token(uid))) .andExpect(status().isOk()) .andExpect(jsonPath("$.code").value(200)) - .andExpect(jsonPath("$.data.length()").value(359)) + .andExpect(jsonPath("$.data.length()").value(2)) .andExpect(jsonPath("$.data[0].cityCode").value("110000")) - .andExpect(jsonPath("$.data[0].dataLevel").value("CITY")); + .andExpect(jsonPath("$.data[0].dataLevel").value("CITY")) + .andExpect(jsonPath("$.data[1].cityCode").value("130100")); + mockMvc.perform(get("/salary-calculator/cities/310000") + .header("Authorization", token(uid))) + .andExpect(status().isNotFound()).andExpect(jsonPath("$.code").value(10601)); } @Test diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/salarycalculator/SalaryCityPolicyServiceTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/salarycalculator/SalaryCityPolicyServiceTest.java index bf2fe8d..1868e36 100644 --- a/mydreamjob-server/src/test/java/com/mydreamjob/salarycalculator/SalaryCityPolicyServiceTest.java +++ b/mydreamjob-server/src/test/java/com/mydreamjob/salarycalculator/SalaryCityPolicyServiceTest.java @@ -27,10 +27,13 @@ class SalaryCityPolicyServiceTest { @Mock private SalaryCityPolicyRepository cityRepository; @Mock private UserRepository userRepository; + @Mock private SalaryPolicyPublicationProperties publication; @InjectMocks private SalaryCityPolicyService service; @Test void listCities_returnsStableEnabledSummariesForStudent() { + when(publication.isPublished("110000", "2026-09-20-v1")).thenReturn(true); + when(publication.isPublished("130100", "2026-09-20-v1")).thenReturn(true); when(userRepository.findById(10L)).thenReturn(Optional.of(user(10L, User.Role.STUDENT))); when(cityRepository.findByEnabledTrueOrderByProvinceCodeAscCityCodeAsc()) .thenReturn(List.of(policy("110000", "北京市", SalaryDataLevel.CITY), @@ -46,6 +49,7 @@ class SalaryCityPolicyServiceTest { @Test void getCity_returnsDefaultsAndDataLevel() { + when(publication.isPublished("110000", "2026-09-20-v1")).thenReturn(true); when(userRepository.findById(10L)).thenReturn(Optional.of(user(10L, User.Role.STUDENT))); when(cityRepository.findByCityCodeAndEnabledTrue("110000")) .thenReturn(Optional.of(policy("110000", "北京市", SalaryDataLevel.CITY))); @@ -67,6 +71,18 @@ class SalaryCityPolicyServiceTest { .isEqualTo(ErrorCode.FORBIDDEN.getCode()); } + @Test + void unapprovedPoliciesAreNotListedOrReturned() { + when(userRepository.findById(10L)).thenReturn(Optional.of(user(10L, User.Role.STUDENT))); + var candidate = policy("110000", "test-city", SalaryDataLevel.CITY); + when(cityRepository.findByEnabledTrueOrderByProvinceCodeAscCityCodeAsc()).thenReturn(List.of(candidate)); + when(cityRepository.findByCityCodeAndEnabledTrue("110000")).thenReturn(Optional.of(candidate)); + assertThat(service.listCities(10L)).isEmpty(); + assertThatThrownBy(() -> service.getCity(10L, "110000")) + .isInstanceOf(BusinessException.class) + .extracting(ex -> ((BusinessException) ex).getErrorCodeInt()).isEqualTo(10601); + } + @Test void missingOrDisabledCity_returns9801() { when(userRepository.findById(10L)).thenReturn(Optional.of(user(10L, User.Role.STUDENT))); diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/salarycalculator/SalaryPolicyPublicationPropertiesTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/salarycalculator/SalaryPolicyPublicationPropertiesTest.java new file mode 100644 index 0000000..7277d25 --- /dev/null +++ b/mydreamjob-server/src/test/java/com/mydreamjob/salarycalculator/SalaryPolicyPublicationPropertiesTest.java @@ -0,0 +1,54 @@ +package com.mydreamjob.salarycalculator; + +import java.util.Map; +import org.junit.jupiter.api.Test; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; +import org.springframework.context.annotation.Configuration; +import static org.assertj.core.api.Assertions.assertThat; + +class SalaryPolicyPublicationPropertiesTest { + @Configuration(proxyBeanMethods = false) + @EnableConfigurationProperties(SalaryPolicyPublicationProperties.class) + static class Config {} + private final ApplicationContextRunner context = new ApplicationContextRunner() + .withUserConfiguration(Config.class); + + @Test void emptyConfigurationFailsClosed() { + context.run(ctx -> { + assertThat(ctx).hasNotFailed(); + assertThat(ctx.getBean(SalaryPolicyPublicationProperties.class) + .isPublished("110000", "2026-09-20-v1")).isFalse(); + }); + } + @Test void genericEnabledSwitchCannotPublishSeeds() { + context.withPropertyValues("app.salary-publication.enabled=true").run(ctx -> + assertThat(ctx.getBean(SalaryPolicyPublicationProperties.class) + .isPublished("110000", "2026-09-20-v1")).isFalse()); + } + @Test void bindingPublishesOnlyExactCityAndVersion() { + context.withPropertyValues("app.salary-publication.approved-policies.110000=verified-test-version") + .run(ctx -> { + var properties = ctx.getBean(SalaryPolicyPublicationProperties.class); + assertThat(properties.isPublished("110000", "verified-test-version")).isTrue(); + assertThat(properties.isPublished("310000", "verified-test-version")).isFalse(); + assertThat(properties.isPublished("110000", "old-version")).isFalse(); + assertThat(properties.isPublished("110000", null)).isFalse(); + }); + } + @Test void wildcardCityFailsStartup() { + context.withPropertyValues("app.salary-publication.approved-policies.any=test-version") + .run(ctx -> assertThat(ctx).hasFailed()); + } + @Test void blankVersionFailsStartup() { + context.withPropertyValues("app.salary-publication.approved-policies.110000= ") + .run(ctx -> assertThat(ctx).hasFailed()); + } + @Test void whitespaceVersionCannotSilentlyEnable() { + var properties = new SalaryPolicyPublicationProperties(); + properties.setApprovedPolicies(Map.of("110000", " version ")); + org.assertj.core.api.Assertions.assertThatThrownBy(properties::validate) + .isInstanceOf(IllegalStateException.class) + .hasMessage("invalid salary publication allowlist"); + } +}