Browse Source

fix: gate salary policies behind verified publication allowlist

integration/lxq-n03-n05-20261008
Zhang Shun 3 weeks ago
parent
commit
0609b9422a
  1. 3
      docs/openapi-rc.json
  2. 44
      docs/salary-calculator-api.md
  3. 2
      mydreamjob-server/src/main/java/com/mydreamjob/salarycalculator/SalaryCalculationController.java
  4. 13
      mydreamjob-server/src/main/java/com/mydreamjob/salarycalculator/SalaryCalculationService.java
  5. 4
      mydreamjob-server/src/main/java/com/mydreamjob/salarycalculator/SalaryCityPolicyController.java
  6. 5
      mydreamjob-server/src/main/java/com/mydreamjob/salarycalculator/SalaryCityPolicyService.java
  7. 33
      mydreamjob-server/src/main/java/com/mydreamjob/salarycalculator/SalaryPolicyPublicationProperties.java
  8. 3
      mydreamjob-server/src/test/java/com/mydreamjob/integration/LxqIndependentMySqlFunctionalVerificationTest.java
  9. 124
      mydreamjob-server/src/test/java/com/mydreamjob/integration/SalaryPublicationDefaultMySqlTest.java
  10. 169
      mydreamjob-server/src/test/java/com/mydreamjob/integration/SalaryPublicationIndependentMySqlVerificationTest.java
  11. 6
      mydreamjob-server/src/test/java/com/mydreamjob/salarycalculator/SalaryCalculationEndToEndIntegrationTest.java
  12. 14
      mydreamjob-server/src/test/java/com/mydreamjob/salarycalculator/SalaryCityPolicyControllerTest.java
  13. 16
      mydreamjob-server/src/test/java/com/mydreamjob/salarycalculator/SalaryCityPolicyServiceTest.java
  14. 54
      mydreamjob-server/src/test/java/com/mydreamjob/salarycalculator/SalaryPolicyPublicationPropertiesTest.java

3
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": [
{

44
docs/salary-calculator-api.md

@ -4,6 +4,8 @@
> 认证:`Authorization: Bearer <access-token>`
> 角色:当前登录用户必须具备 `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 不受此门禁影响。合并、部署及部署后三中间件黑盒验收另行执行。

2
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<ApiResponse<SalaryCalculationResponse>> calculate(
@Valid @RequestBody SalaryCalculationRequest body, HttpServletRequest request) {

13
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(

4
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<ApiResponse<List<SalaryCitySummaryResponse>>> 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<ApiResponse<SalaryCityDetailResponse>> getCity(
@PathVariable String cityCode, HttpServletRequest request) {

5
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<SalaryCitySummaryResponse> 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())

33
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<String, String> 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));
}
}

3
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")

124
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<String, Object> 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<String, List<String>> snapshot() {
Map<String, List<String>> result = new TreeMap<>();
for (String table : jdbc.queryForList("show tables", String.class)) {
List<String> rows = jdbc.query("select * from `" + table + "`", (rs, n) -> {
List<String> 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());
}
}

169
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<String, Object> 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<String, List<String>> snapshot() {
Map<String, List<String>> result = new TreeMap<>();
for (String table : jdbc.queryForList("show tables", String.class)) {
List<String> rows = jdbc.query("select * from `" + table + "`", (rs, n) -> {
List<String> 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);
}
}

6
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;

14
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<String, Object> 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

16
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)));

54
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");
}
}
Loading…
Cancel
Save