Browse Source

Merge branch 'master' of https://gitea.baskbuddy.com/baskbuddy/zhangshun.git

backup/master-local-20261012
pwklppg 3 weeks ago
parent
commit
c197273f8f
  1. 24
      README.md
  2. 262
      backend-infra/v35_acceptance.ps1
  3. 245
      backend-infra/v41_blackbox.ps1
  4. 188
      docs/ability-assessment-api.md
  5. 1064
      docs/ability-question-bank-v0.1.json
  6. 6
      docs/acceptance-week1.md
  7. 161
      docs/account-deletion-test-isolation-fix-20260917.md
  8. 69
      docs/api-v0.1.md
  9. 112
      docs/backend-development-roadmap.md
  10. 21
      docs/candidate-scoring-api.md
  11. 302
      docs/candidate-scoring-explanation-design.md
  12. 39
      docs/candidate-scoring-fairness-api.md
  13. 209
      docs/candidate-scoring-rule-evidence.md
  14. 111
      docs/company-review-management-api.md
  15. 224
      docs/def-01-job-keyword-normalization-remediation-20260920.md
  16. 53
      docs/enterprise-talent-discovery-api.md
  17. 59
      docs/enterprise-talent-invitation-api.md
  18. 2250
      docs/final-independent-acceptance-20260918.md
  19. 428
      docs/frontend-integration.md
  20. 368
      docs/message-recall-design.md
  21. 293
      docs/message-recall-implementation-instruction.md
  22. 33
      docs/messaging-api.md
  23. 87
      docs/n07-independent-mysql-acceptance-20260910.md
  24. 2
      docs/offer-api.md
  25. 14995
      docs/openapi-rc.json
  26. 106
      docs/phase-a-company-registration-onboarding-implementation-report.md
  27. 157
      docs/phase-a-company-registration-onboarding-independent-verification.md
  28. 127
      docs/phase1-scope-and-acceptance.md
  29. 796
      docs/phone-password-authentication-design.md
  30. 93
      docs/phone-password-authentication-implementation-report.md
  31. 101
      docs/phone-password-authentication-independent-verification-report.md
  32. 29
      docs/privacy-settings-api.md
  33. 303
      docs/q01-job-search-mysql-explain-evidence.md
  34. 190
      docs/q01-job-search-real-query-design.md
  35. 265
      docs/q01-job-search-real-query-implementation-instruction.md
  36. 265
      docs/q01-job-search-real-query-implementation-report.md
  37. 110
      docs/r01-a-request-context-implementation-instruction.md
  38. 284
      docs/r01-a-request-context-implementation-report.md
  39. 197
      docs/r01-a2-independent-verification-20260917.md
  40. 218
      docs/r01-a2-request-context-migration-report.md
  41. 221
      docs/r01-a3-independent-verification-20260917.md
  42. 276
      docs/r01-a3-request-context-migration-report.md
  43. 72
      docs/r01-a4-independent-verification-20260917.md
  44. 278
      docs/r01-a4-request-context-migration-report.md
  45. 279
      docs/r01-a5-independent-verification-20260917.md
  46. 79
      docs/r01-a5-request-context-migration-report.md
  47. 128
      docs/r01-a6-fairness-fixture-isolation-fix-20260917.md
  48. 359
      docs/r01-a6-final-request-context-migration-report.md
  49. 492
      docs/r01-a6-independent-verification-20260917.md
  50. 497
      docs/r01-b-authorization-exception-mapping-report.md
  51. 19
      docs/r01-b-error-status-mapping-instruction.md
  52. 46
      docs/r01-b-error-status-mapping-report.md
  53. 617
      docs/r01-b-independent-verification-20260918.md
  54. 183
      docs/r01-c-fairness-fixture-isolation-fix-20260918.md
  55. 347
      docs/r01-c-independent-verification-20260918.md
  56. 17
      docs/r01-c-pure-calculation-instruction.md
  57. 24
      docs/r01-c-pure-calculation-report.md
  58. 437
      docs/r01-c-scoring-recommendation-rules-refactor-report.md
  59. 25
      docs/r01-d1-percentile-performance-report.md
  60. 29
      docs/r01-d2-personal-data-query-report.md
  61. 48
      docs/r01-d3-conversation-unread-query-report.md
  62. 87
      docs/r01-d4-1-scoring-materialization-implementation-report.md
  63. 277
      docs/r01-d4-2-blocker-remediation-20260916.md
  64. 234
      docs/r01-d4-2-claim-fence-remediation-20260916.md
  65. 188
      docs/r01-d4-2-claim-token-final-verification-20260916.md
  66. 275
      docs/r01-d4-2-final-independent-incremental-verification-20260916.md
  67. 57
      docs/r01-d4-2-get-scoring-readonly-implementation-report.md
  68. 656
      docs/r01-d4-2-independent-mysql-acceptance-20260916.md
  69. 246
      docs/r01-d4-get-scoring-snapshot-migration-design.md
  70. 352
      docs/r01-e-batch-query-unread-count-performance-report.md
  71. 412
      docs/r01-e-independent-verification-20260918.md
  72. 81
      docs/r01-structure-performance-design.md
  73. 47
      docs/resume-api.md
  74. 119
      docs/riasec-assessment-api.md
  75. 253
      docs/risk-governance-api.md
  76. 483
      docs/s01-independent-mysql-acceptance-20260911.md
  77. 288
      docs/s01-independent-mysql-acceptance-20260914.md
  78. 786
      docs/s01-remediation-f1f2f3-20260911.md
  79. 674
      docs/s02-auth-token-isolation-design.md
  80. 724
      docs/s02-auth-token-isolation-implementation-instruction.md
  81. 202
      docs/s02-independent-mysql-acceptance-20260914.md
  82. 167
      docs/s03-independent-mysql-acceptance-20260915.md
  83. 167
      docs/s03-production-security-and-resume-privacy-design.md
  84. 270
      docs/s03-production-security-and-resume-privacy-implementation-instruction.md
  85. 225
      docs/smoke-test.sh
  86. 144
      docs/tests/smoke-test-schema-contract-test.sh
  87. 548
      docs/tests/smoke-test-sms-code-test.sh
  88. 319
      docs/v38-ability-score-integration-design.md
  89. 454
      docs/v39-job-recommendation-design.md
  90. 357
      docs/v40-company-review-management-design.md
  91. 366
      docs/v41-enterprise-talent-recommendation-design.md
  92. 480
      docs/v42-enterprise-talent-invitation-design.md
  93. 439
      docs/v42-implementation-instruction.md
  94. 173
      docs/v43-candidate-scoring-fairness-design.md
  95. 111
      docs/v43-implementation-instruction.md
  96. 147
      docs/v43-independent-mysql-acceptance-20260909.md
  97. 224
      docs/validate_ability_bank.py
  98. 769
      docs/verify_openapi.py
  99. 5
      mydreamjob-server/pom.xml
  100. 46
      mydreamjob-server/src/main/java/com/mydreamjob/ability/AbilityAnswer.java

24
README.md

@ -6,17 +6,15 @@
| 项目 | 当前状态 |
| --- | --- |
| Git 检查点 | N04 已建立 T7 暂停检查点 `73afd87`;T8 完成后形成最终检查点 |
| 数据库迁移 | Flyway V1–V39;N04 新增 V38–V39 |
| API 契约 | 135 paths / 172 operations |
| 自动化测试 | 1492 个执行:1481 通过、2 失败、9 错误;失败均在既有 CareerProgress/JobReviewDimension/LocalResumeStorage 测试,与 N04 文档变更无关 |
| 已验收范围 | V27–V34;V33 已完成真实 MySQL/Docker 黑盒验收 |
| N04 入职清单与公司知识介绍 | 实现完成,待真实 MySQL/Docker 黑盒验收 |
| N05 薪资计算器与历史记录 | 实现完成,专项测试 24/24,一次性 Docker MySQL 黑盒通过 |
| Git 检查点 | N07 消息撤回,`277b3c0`(已合并到本地 `master`) |
| 数据库迁移 | Flyway V1–V41;N02/V43 未新增迁移 |
| API 契约 | 173 paths / 211 operations |
| 自动化测试 | 1601 个通过,0 失败,0 错误,0 跳过(以 `mvn clean verify` 为准) |
| 已交付范围 | V39 岗位推荐、V40 企业评价回复、V41 企业人才发现、V42 企业人才邀约、V43 公平性监测、N02/V44 评分规则解释、N07 消息撤回 |
| 账号注销冷静期 | 30 天 |
| 本地服务端口 | `8080` |
N04 入职清单与公司知识介绍、N05 薪资计算器与历史记录均已完成代码、专项测试、OpenAPI 和联调文档;N05 专项 24/24 通过,且已完成一次性 Docker MySQL 黑盒验收并销毁容器。全量 `mvn clean verify` 仍受既有非 N05 测试失败阻塞。
各版本的真实 MySQL/Docker 验收状态以 `docs/` 下对应验收报告为准;未完成独立验收的功能不得视为生产冻结。
## 仓库结构
@ -125,8 +123,12 @@ python .\docs\verify_openapi.py
- [OpenAPI 契约](docs/openapi-rc.json)
- [前端联调说明](docs/frontend-integration.md)
- [后端开发路线图](docs/backend-development-roadmap.md)
- [入职清单与公司知识介绍](docs/onboarding-api.md)
- [薪资计算器 API](docs/salary-calculator-api.md)
- [岗位推荐](docs/v39-job-recommendation-design.md)
- [企业人才发现](docs/enterprise-talent-discovery-api.md)
- [企业人才邀约](docs/enterprise-talent-invitation-api.md)
- [候选评分与规则解释](docs/candidate-scoring-api.md)
- [评分公平性监测](docs/candidate-scoring-fairness-api.md)
- [消息撤回与会话一致性](docs/messaging-api.md)
- [统一求职进度接口](docs/career-progress-api.md)
- [同岗位竞争力排名](docs/competition-ranking-api.md)
- [举报与屏蔽](docs/report-block-api.md)
@ -174,5 +176,3 @@ python .\docs\verify_openapi.py
`C:\Users\15642\Desktop\青核\collegeCli`
青核项目不属于本仓库,禁止将其源代码提交到职来仓库。

262
backend-infra/v35_acceptance.ps1

@ -0,0 +1,262 @@
# ============================================================================
# V35 RIASEC assessment BLACK-BOX ACCEPTANCE (real MySQL/Docker) - rev2
# Scenario isolation: each scenario uses a freshly auto-created mock student.
# No secrets printed; no business data deleted; only appends assessment rows.
# ============================================================================
$ErrorActionPreference = 'Continue'
Import-Module ThreadJob -ErrorAction SilentlyContinue
$BASE = 'http://127.0.0.1:8080/api/v1'
$LOG = 'C:\Users\15642\Desktop\zhilai\backend-infra\app.log'
$ENV = 'C:\Users\15642\Desktop\zhilai\backend-infra\.env'
$RMQ_API = 'http://127.0.0.1:15672/api/queues/%2F'
$kv=@{}; Get-Content $ENV | ForEach-Object{ if($_ -match '^\s*([^#][^=]*?)\s*=\s*(.*)\s*$'){ $kv[$matches[1].Trim()]=$matches[2].Trim() } }
$env:MPW=$kv['MYSQL_PASSWORD']; $RMQ_USER=$kv['RABBITMQ_USER']; $RMQ_PASS=$kv['RABBITMQ_PASS']
function MySQL($sql){ docker exec -e MYSQL_PWD=$env:MPW mydreamjob-mysql mysql -umydreamjob -N mydreamjob -e $sql 2>&1 }
function FirstOf($sql){ $r=(MySQL $sql | Select-Object -First 1); if($null -eq $r){return ''}; (($r -split '\s+')[0]).Trim() }
function PhoneOfRole($role){
# ADMIN uses a separate query; do not apply the pure-COMPANY exclusion.
if($role -eq 'ADMIN'){
FirstOf ("SELECT u.phone FROM users u JOIN user_roles ur ON ur.user_id=u.id AND ur.role='ADMIN' ORDER BY u.id LIMIT 1");
} else {
FirstOf ("SELECT u.phone FROM users u JOIN user_roles ur ON ur.user_id=u.id AND ur.role='$role' AND u.id NOT IN (SELECT user_id FROM user_roles WHERE role IN ('STUDENT','ADMIN')) LIMIT 1");
}
}
$pass=0;$fail=0;$skip=0;$failures=@()
function Chk($n,$c,$d){ if($c){$script:pass++;Write-Output "PASS - $n"}else{$script:fail++;$script:failures+="$n :: $d";Write-Output "FAIL - $n | $d"} }
function ChkSkip($n,$d){ $script:skip++;Write-Output "SKIP - $n | $d" }
function Api($method,$url,$token,$body){
$h=@{}; if($token){$h['Authorization']="Bearer $token"}
if($null -ne $body){$h['Content-Type']='application/json'}
try { $a=@{Method=$method;Uri=$url;Headers=$h;TimeoutSec=30;UseBasicParsing=$true}; if($null -ne $body){$a.Body=($body|ConvertTo-Json -Depth 8)}; $r=Invoke-WebRequest @a; $j=$null;try{$j=$r.Content|ConvertFrom-Json}catch{}; return @{status=[int]$r.StatusCode;json=$j;raw=$r.Content} }
catch { $c='';if($_.ErrorDetails -and $_.ErrorDetails.Message){$c=$_.ErrorDetails.Message};$st=if($_.Exception.Response -and $_.Exception.Response.StatusCode){[int]$_.Exception.Response.StatusCode}else{-1};$j=$null;try{$j=$c|ConvertFrom-Json}catch{};return @{status=$st;json=$j;raw=$c} }
}
function F($o,$n){ if($null -eq $o){return $null}; if($o.PSObject.Properties.Name -contains $n){return $o.$n}; return $null }
function Login($phone){
for($i=0;$i -lt 3;$i++){
try{ Invoke-RestMethod -Uri "$BASE/auth/sms/send?phone=$phone&purpose=login" -Method Post -TimeoutSec 15 | Out-Null }catch{}
Start-Sleep -Milliseconds 900
$m = Get-Content $LOG -ErrorAction SilentlyContinue | Select-String -Pattern "MOCK SMS CODE: phone=$phone, purpose=login, code=([0-9]{6})"
if($m){ $code=$m[-1].Matches[0].Groups[1].Value; $b=@{phone=$phone;code=$code;purpose='login'}|ConvertTo-Json
try{ $t=(Invoke-RestMethod -Uri "$BASE/auth/login" -Method Post -ContentType 'application/json' -Body $b -TimeoutSec 15).data.accessToken; if($t -and "$t".Length -gt 50){return $t} }catch{} }
Start-Sleep -Milliseconds 600
}
return $null
}
function RmqStat($name){
$q=$null; try{ $q=(& curl.exe -s -u "${RMQ_USER}:${RMQ_PASS}" "$RMQ_API" 2>&1 | Out-String)|ConvertFrom-Json }catch{}
if($null -eq $q){return @{ready=-1;unacked=-1}}
$h=$q|Where-Object{$_.name -eq $name}|Select-Object -First 1
# Missing queue is not an empty queue; return -1 so it cannot pass as ready=0.
if($null -eq $h){return @{ready=-1;unacked=-1}}
return @{ready=[long]$h.messages;unacked=[long]$h.messages_unacknowledged}
}
function NewStudent(){ $ph='188'+('{0:D8}' -f (Get-Random -Minimum 1000000 -Maximum 99999999)); $t=Login $ph; $id=FirstOf "SELECT id FROM users WHERE phone='$ph'"; return @{token=$t;phone=$ph;id=$id} }
Write-Output "===== V35 ASSESSMENT BLACK-BOX (real MySQL) rev2 ====="
# ---------- shared role tokens ----------
$companyTok = Login (PhoneOfRole 'COMPANY')
$adminTok = Login (PhoneOfRole 'ADMIN')
# ============ S1 main flow (fresh student) ============
Write-Output '== S1 main flow =='
$s1 = NewStudent; $tok=$s1.token
Chk 'S1 fresh student login' ($null -ne $tok)
if(-not $tok){ exit 1 }
$k = 'bb1-'+[guid]::NewGuid().ToString('N')
$c = Api 'POST' "$BASE/assessments/attempts" $tok @{typeCode='HOLLAND_RIASEC';idempotencyKey=$k}
$aid = F $c.json.data 'attemptId'; $v = F $c.json.data 'version'
Chk 'S1 create 200' ($c.status -eq 200) $c.status
Chk 'S1 totalQuestions=30' ((F $c.json.data 'totalQuestions') -eq 30) (F $c.json.data 'totalQuestions')
Chk 'S1 questions length=30' (@($c.json.data.questions).Count -eq 30) (@($c.json.data.questions).Count)
$cnt = FirstOf "SELECT COUNT(*) FROM assessment_attempt_questions WHERE attempt_id=$aid"
Chk 'S1 DB attempt_questions=30' ($cnt -eq 30) $cnt
$dimMap=@{}; foreach($d in (MySQL "SELECT q.dimension,COUNT(*) FROM assessment_attempt_questions aq JOIN assessment_questions q ON q.id=aq.question_id WHERE aq.attempt_id=$aid GROUP BY q.dimension")){ $p=($d -split '\s+'); if($p -and $p[0] -and $p[1]){$dimMap[$p[0]]=[int]$p[1]} }
Chk 'S1 DB 6 dims x5' ((@('A','C','E','I','R','S' | ForEach-Object{$dimMap[$_]-eq 5}) -contains $false) -eq $false) ($dimMap|ConvertTo-Json -Compress)
$qids=@($c.json.data.questions|ForEach-Object{$_.id})
# mid-save 10
$p1 = Api 'PATCH' "$BASE/assessments/attempts/$aid/answers" $tok @{version=$v;answers=(@($qids|Select-Object -First 10)|ForEach-Object{@{questionId=$_;value=4}})}
Chk 'S1 PATCH partial 200 answered=10' ($p1.status -eq 200 -and (F $p1.json.data 'answeredCount') -eq 10) "$($p1.status)/$(F $p1.json.data 'answeredCount')"
$g = Api 'GET' "$BASE/assessments/attempts/$aid" $tok
$gids=@($g.json.data.questions|ForEach-Object{$_.id})
Chk 'S1 GET order unchanged' (($gids -join ',') -eq ($qids -join ','))
Chk 'S1 GET restored 10' (@($g.json.data.questions|Where-Object{$null -ne $_.value}).Count -eq 10)
$nv = F $g.json.data 'version'
# 29-ish submit (only 10 answered) -> 9706
$s29 = Api 'POST' "$BASE/assessments/attempts/$aid/submit" $tok $null
Chk 'S1 10/30 submit -> 400/9706' ($s29.status -eq 400 -and (F $s29.json 'code') -eq 9706) "$($s29.status)/$(F $s29.json 'code')"
# full 30
$full = Api 'PATCH' "$BASE/assessments/attempts/$aid/answers" $tok @{version=$nv;answers=(@($qids)|ForEach-Object{@{questionId=$_;value=4}})}
$fv = F $full.json.data 'version'
$sub = Api 'POST' "$BASE/assessments/attempts/$aid/submit" $tok $null
Chk 'S1 submit 200 COMPLETED' ($sub.status -eq 200 -and (F $sub.json.data 'status') -eq 'COMPLETED') "$($sub.status)/$(F $sub.json.data 'status')"
Chk 'S1 result primaryCode present' ($null -ne (F $sub.json.data 'primaryCode')) (F $sub.json.data 'primaryCode')
Chk 'S1 result disclaimer' ($null -ne (F $sub.json.data 'disclaimer'))
$sub2 = Api 'POST' "$BASE/assessments/attempts/$aid/submit" $tok $null
Chk 'S1 repeat submit same result' ($sub2.status -eq 200 -and (F $sub2.json.data 'primaryCode') -eq (F $sub.json.data 'primaryCode')) "$($sub2.status)"
Chk 'S1 DB result count=1' ((FirstOf "SELECT COUNT(*) FROM assessment_results WHERE attempt_id=$aid") -eq 1)
Chk 'S1 result GET 200' ((Api 'GET' "$BASE/assessments/attempts/$aid/result" $tok).status -eq 200)
# active_key released
Chk 'S1 active_key released' (((FirstOf "SELECT COUNT(*) FROM assessment_attempts WHERE id=$aid AND active_key IS NULL") -eq 1))
# non-owner 403
$sOther = NewStudent
Chk 'S1 non-owner GET attempt 403/2002' ((Api 'GET' "$BASE/assessments/attempts/$aid" $sOther.token).status -eq 403)
# ============ S2 duplicate active (fresh student) ============
Write-Output '== S2 duplicate active =='
$s2=NewStudent; $a1=Api 'POST' "$BASE/assessments/attempts" $s2.token @{typeCode='HOLLAND_RIASEC';idempotencyKey=('bb2a-'+[guid]::NewGuid().ToString('N'))}
Chk 'S2 first create 200' ($a1.status -eq 200) $a1.status
$dup=Api 'POST' "$BASE/assessments/attempts" $s2.token @{typeCode='HOLLAND_RIASEC';idempotencyKey=('bb2b-'+[guid]::NewGuid().ToString('N'))}
Chk 'S2 duplicate active -> 409/9708' ($dup.status -eq 409 -and (F $dup.json 'code') -eq 9708) "$($dup.status)/$(F $dup.json 'code')"
# verify only one DRAFT row in DB
Chk 'S2 DB only one active' ((FirstOf "SELECT COUNT(*) FROM assessment_attempts WHERE student_user_id=$($s2.id) AND status IN ('DRAFT','IN_PROGRESS') AND active_key IS NOT NULL") -eq 1)
# ============ S3 idempotency replay ============
Write-Output '== S3 idempotency replay =='
$s3=NewStudent; $kk='bb3-' + [guid]::NewGuid().ToString('N')
$r1=Api 'POST' "$BASE/assessments/attempts" $s3.token @{typeCode='HOLLAND_RIASEC';idempotencyKey=$kk}
$r2=Api 'POST' "$BASE/assessments/attempts" $s3.token @{typeCode='HOLLAND_RIASEC';idempotencyKey=$kk}
Chk 'S3 same key same body -> same attempt' ((F $r1.json.data 'attemptId') -eq (F $r2.json.data 'attemptId')) "$(F $r1.json.data 'attemptId') vs $(F $r2.json.data 'attemptId')"
# ============ S4 permissions ============
Write-Output '== S4 permissions =='
$pc = Api 'POST' "$BASE/assessments/attempts" $companyTok @{typeCode='HOLLAND_RIASEC';idempotencyKey=('bb4c-'+[guid]::NewGuid().ToString('N'))}
Chk 'S4 COMPANY create 403/2002' ($pc.status -eq 403 -and (F $pc.json 'code') -eq 2002) "$($pc.status)/$(F $pc.json 'code')"
$pn = Api 'POST' "$BASE/assessments/attempts" $null @{typeCode='HOLLAND_RIASEC';idempotencyKey=('bb4n-'+[guid]::NewGuid().ToString('N'))}
Chk 'S4 anonymous create 401' ($pn.status -eq 401) $pn.status
# INACTIVE: use a REAL inactive definition if present in DB; otherwise SKIP (never use unknown type NOPE as substitute)
$inactiveType = FirstOf "SELECT type_code FROM assessment_definitions WHERE status='INACTIVE' LIMIT 1"
if($inactiveType){
$pi = Api 'POST' "$BASE/assessments/attempts" $tok @{typeCode=$inactiveType;idempotencyKey=('bb4i-'+[guid]::NewGuid().ToString('N'))}
Chk "S4 INACTIVE($inactiveType) create 404/9701" ($pi.status -eq 404 -and (F $pi.json 'code') -eq 9701) "$($pi.status)/$(F $pi.json 'code')"
} else { ChkSkip 'S4 INACTIVE type create 404/9701' 'no INACTIVE definition seed in DB; NOT substituting unknown type' }
# ADMIN black-box coverage: if no ADMIN account exists -> explicit SKIP (not PASS)
if($adminTok){
$pa = Api 'POST' "$BASE/assessments/attempts" $adminTok @{typeCode='HOLLAND_RIASEC';idempotencyKey=('bb4a-'+[guid]::NewGuid().ToString('N'))}
Chk 'S4 ADMIN create 403/2002' ($pa.status -eq 403 -and (F $pa.json 'code') -eq 2002) "$($pa.status)/$(F $pa.json 'code')"
} else { ChkSkip 'S4 ADMIN create 403/2002' 'no ADMIN account in DB; ADMIN black-box not covered (covered by unit test AssessmentAttemptServiceTest.adminUser_create_returnsForbidden)' }
# ============ S5 concurrency (fresh student) ============
Write-Output '== S5 concurrent create + submit =='
$s5=NewStudent
$s5tok = $s5.token; $s5id = $s5.id
$csBefore = [long](FirstOf "SELECT COUNT(*) FROM candidate_dimension_scores")
$nThreads=4
$tmpDir = Join-Path $env:TEMP ('v35bb-' + [guid]::NewGuid().ToString('N'))
New-Item -ItemType Directory -Path $tmpDir -Force | Out-Null
# --- concurrent create (different idempotency keys): capture real status + code per request ---
$createSb = {
param($base,$tk,$body,$outFile)
function W($st,$code){ [System.IO.File]::WriteAllText($outFile, ("$st|"+$code)) }
try {
$resp = Invoke-WebRequest -Uri "$base/assessments/attempts" -Method Post -Headers @{Authorization="Bearer $tk"} -ContentType 'application/json' -Body $body -UseBasicParsing -TimeoutSec 30
$code=-1; try { $j=$resp.Content|ConvertFrom-Json; $code=[int]$j.code } catch {}
W $resp.StatusCode $code
} catch {
$st = if($_.Exception.Response -and $_.Exception.Response.StatusCode){[int]$_.Exception.Response.StatusCode}else{-1}
$code=-1
if($_.ErrorDetails -and $_.ErrorDetails.Message){ try { $j=$_.ErrorDetails.Message|ConvertFrom-Json; $code=[int]$j.code } catch {} }
W $st $code
}
}
$pool=[runspacefactory]::CreateRunspacePool(1,$nThreads); $pool.Open()
$jobs=@()
foreach($i in 1..$nThreads){
$body = @{typeCode='HOLLAND_RIASEC';idempotencyKey=('ccr-'+$i+'-'+[guid]::NewGuid().ToString('N'))}|ConvertTo-Json -Compress
$out = Join-Path $tmpDir ("c$i.txt")
$rs=[powershell]::Create(); $rs.RunspacePool=$pool
$null=$rs.AddScript($createSb).AddArgument($BASE).AddArgument($s5tok).AddArgument($body).AddArgument($out)
$jobs += ,@($rs,$rs.BeginInvoke())
}
foreach($j in $jobs){ $j[0].EndInvoke($j[1])|Out-Null; $j[0].Dispose() }
$pool.Close()
$cresults=@()
foreach($i in 1..$nThreads){
$f=Join-Path $tmpDir ("c$i.txt")
if(Test-Path $f){
$p=(Get-Content $f -Raw) -replace '\s+','' -split '\|'
if($p.Count -ge 2){ $cresults += [pscustomobject]@{status=[int]$p[0];code=[int]$p[1]} }
}
}
$cstat=@($cresults | ForEach-Object {$_.status})
$ccode=@($cresults | ForEach-Object {$_.code})
Write-Output ("S5 concurrent create pairs: " + (($cresults | ForEach-Object {"$($_.status)/$($_.code)"}) -join ','))
$nWinner = @($cresults | Where-Object {$_.status -eq 200}).Count
$nLoser = @($cresults | Where-Object {$_.status -eq 409 -and $_.code -eq 9708}).Count
$badCreate = @($cresults | Where-Object {$_.status -ne 200 -and -not ($_.status -eq 409 -and $_.code -eq 9708)}).Count
Chk 'S5 concurrent create: exactly one 200' ($cresults.Count -eq $nThreads -and $nWinner -eq 1) (($cresults | ForEach-Object {"$($_.status)/$($_.code)"}) -join ',')
Chk 'S5 concurrent create: every loser is 409/9708' ($nLoser -eq ($nThreads-1) -and $badCreate -eq 0) (($cresults | ForEach-Object {"$($_.status)/$($_.code)"}) -join ',')
Chk 'S5 concurrent create: no 500/-1/empty' ($cresults.Count -eq $nThreads -and @($cresults|Where-Object{$_.status -eq 500 -or $_.status -eq -1 -or $_.code -eq -1}).Count -eq 0) (($cresults | ForEach-Object {"$($_.status)/$($_.code)"}) -join ',')
Chk 'S5 DB: exactly one attempt persisted' ((FirstOf "SELECT COUNT(*) FROM assessment_attempts WHERE student_user_id=$s5id") -eq 1)
Chk 'S5 DB: exactly one active attempt' ((FirstOf "SELECT COUNT(*) FROM assessment_attempts WHERE student_user_id=$s5id AND active_key IS NOT NULL") -eq 1)
# --- concurrent submit (2 threads) on the winning active attempt: allowed 200, no 500 ---
$winAid = FirstOf "SELECT id FROM assessment_attempts WHERE student_user_id=$s5id AND active_key IS NOT NULL LIMIT 1"
if($winAid){
$s5g = Api 'GET' "$BASE/assessments/attempts/$winAid" $s5tok
$s5all = @($s5g.json.data.questions | ForEach-Object { @{questionId=$_.id; value=4} })
Api 'PATCH' "$BASE/assessments/attempts/$winAid/answers" $s5tok @{version=(F $s5g.json.data 'version');answers=$s5all} | Out-Null
$submitSb = {
param($base,$tk,$aid,$outFile)
function W2($st,$code){ [System.IO.File]::WriteAllText($outFile, ("$st|$code")) }
try {
$resp=Invoke-WebRequest -Uri "$base/assessments/attempts/$aid/submit" -Method Post -Headers @{Authorization="Bearer $tk"} -UseBasicParsing -TimeoutSec 30
$code=-1; try { $j=$resp.Content|ConvertFrom-Json; $code=[int]$j.code } catch {}
W2 ([int]$resp.StatusCode) $code
}
catch {
$st=if($_.Exception.Response -and $_.Exception.Response.StatusCode){[int]$_.Exception.Response.StatusCode}else{-1}
$code=-1
if($_.ErrorDetails -and $_.ErrorDetails.Message){ try { $j=$_.ErrorDetails.Message|ConvertFrom-Json; $code=[int]$j.code } catch {} }
W2 $st $code
}
}
$pool2=[runspacefactory]::CreateRunspacePool(1,2); $pool2.Open()
$j2=@(); foreach($i in 1..2){ $out=Join-Path $tmpDir ("s$i.txt"); $rs=[powershell]::Create(); $rs.RunspacePool=$pool2; $null=$rs.AddScript($submitSb).AddArgument($BASE).AddArgument($s5tok).AddArgument($winAid).AddArgument($out); $j2 += ,@($rs,$rs.BeginInvoke()) }
foreach($j in $j2){ $j[0].EndInvoke($j[1])|Out-Null; $j[0].Dispose() }; $pool2.Close()
$sresults=@()
foreach($i in 1..2){
$f=Join-Path $tmpDir ("s$i.txt")
if(Test-Path $f){
$p=(Get-Content $f -Raw) -replace '\s+','' -split '\|'
if($p.Count -ge 2){ $sresults += [pscustomobject]@{status=[int]$p[0];code=[int]$p[1]} }
}
}
Write-Output ("S5 concurrent submit pairs: " + (($sresults | ForEach-Object {"$($_.status)/$($_.code)"}) -join ','))
# Submit is idempotent: success is 200; a race-window conflict may only be 409/9703.
$validSubmit = @($sresults | Where-Object { $_.status -eq 200 -or ($_.status -eq 409 -and $_.code -eq 9703) }).Count
Chk 'S5 concurrent submit: only 200 or 409/9703' ($sresults.Count -eq 2 -and $validSubmit -eq 2) (($sresults | ForEach-Object {"$($_.status)/$($_.code)"}) -join ',')
Chk 'S5 concurrent submit: only one result' ((FirstOf "SELECT COUNT(*) FROM assessment_results WHERE attempt_id=$winAid") -eq 1)
Chk 'S5 concurrent submit: only one audit' ((FirstOf "SELECT COUNT(*) FROM audit_logs WHERE entity_id=$winAid AND action='ASSESSMENT_ATTEMPT_SUBMITTED'") -eq 1)
} else { ChkSkip 'S5 concurrent submit' 'no winning active attempt found' }
$csAfter = [long](FirstOf "SELECT COUNT(*) FROM candidate_dimension_scores")
Chk 'S5 candidate_dimension_scores unchanged' ($csBefore -eq $csAfter) "$csBefore vs $csAfter"
# ============ S6 audit desensitization (scan student audits) ============
Write-Output '== S6 audit desensitization =='
$phones = @($s1.phone,$s2.phone,$s3.phone,$s5.phone)
$bad=''
$auditRows = @(MySQL "SELECT details FROM audit_logs WHERE user_id IN ($($s1.id),$($s2.id),$($s5.id)) AND action IN ('ASSESSMENT_ATTEMPT_CREATED','ASSESSMENT_ATTEMPT_SUBMITTED') ORDER BY id DESC LIMIT 40")
foreach($row in $auditRows){
$hit=$false; foreach($ph in $phones){ if($ph -and $row -like "*$ph*"){ $hit=$true;break } }
if($hit){ $bad='leaked phone'; break }
if($row -match '答案|我对|Bearer|password|secret|email=|JWT|accessToken'){ $bad=$row; break }
}
Chk 'S6 audit details no phone/email/password/token/stem' ($bad -eq '') $(if($bad){$bad.Substring(0,[Math]::Min(80,$bad.Length))}else{'clean'})
Chk 'S6 submit audit rows written' (@($auditRows | Where-Object { $_ -match 'answeredCount' }).Count -ge 1)
# ============ S7 rabbitmq ============
Write-Output '== S7 rabbitmq final =='
$n1=RmqStat 'queue.notification'; $n2=RmqStat 'queue.audit'; $n3=RmqStat 'queue.notification.dlq'
Chk 'S7 queue.notification ready/unacked=0' ($n1.ready -eq 0 -and $n1.unacked -eq 0) "r=$($n1.ready) u=$($n1.unacked)"
Chk 'S7 queue.audit ready/unacked=0' ($n2.ready -eq 0 -and $n2.unacked -eq 0) "r=$($n2.ready) u=$($n2.unacked)"
Chk 'S7 queue.notification.dlq ready/unacked=0' ($n3.ready -eq 0 -and $n3.unacked -eq 0) "r=$($n3.ready) u=$($n3.unacked)"
Write-Output "============================================================"
Write-Output "SUMMARY PASS=$pass FAIL=$fail SKIP=$skip"
if($failures.Count -gt 0){ Write-Output 'FAILURES:'; $failures|ForEach-Object{Write-Output " - $_"} }else{ Write-Output 'NO FAILURES' }

245
backend-infra/v41_blackbox.ps1

@ -0,0 +1,245 @@
$ErrorActionPreference = 'Stop'
Push-Location $PSScriptRoot
$pass = 0
$fail = 0
$fixtureUsers = @()
$fixturePhones = @()
$fixtureAbilityUser = $null
Add-Type -AssemblyName System.Net.Http
function Pass([string]$name) {
$script:pass++
Write-Output "PASS $name"
}
function Fail([string]$name, [string]$detail = '') {
$script:fail++
Write-Output ("FAIL {0}{1}" -f $name, $(if ($detail) { " :: $detail" } else { '' }))
}
function Assert([bool]$condition, [string]$name, [string]$detail = '') {
if ($condition) { Pass $name } else { Fail $name $detail }
}
function Sql([string]$query) {
$output = $query | docker compose --env-file .env exec -T mysql sh -lc 'MYSQL_PWD="$MYSQL_ROOT_PASSWORD" mysql -uroot -N -B mydreamjob' 2>$null
if ($LASTEXITCODE -ne 0) { throw 'mysql query failed' }
$rows = @($output | ForEach-Object { "$($_)".Trim() } | Where-Object { $_ -ne '' })
return ,$rows
}
function Rabbit {
$output = docker compose --env-file .env exec -T rabbitmq rabbitmqctl list_queues name messages_ready messages_unacknowledged 2>$null
if ($LASTEXITCODE -ne 0) { throw 'rabbitmq query failed' }
$rows = @($output | ForEach-Object { "$($_)".Trim() } | Where-Object { $_ -ne '' })
return ,$rows
}
function Api([string]$method, [string]$path, [string]$token = $null, $body = $null) {
$client = [System.Net.Http.HttpClient]::new()
try {
$request = [System.Net.Http.HttpRequestMessage]::new(
[System.Net.Http.HttpMethod]::new($method),
('http://127.0.0.1:8080/api/v1' + $path))
$request.Headers.Add('X-Request-Id', ('v41-blackbox-' + [guid]::NewGuid().ToString('N')))
if ($token) {
$request.Headers.Authorization = [System.Net.Http.Headers.AuthenticationHeaderValue]::new('Bearer', $token)
}
if ($null -ne $body) {
$json = $body | ConvertTo-Json -Depth 30 -Compress
$request.Content = [System.Net.Http.StringContent]::new($json, [Text.Encoding]::UTF8, 'application/json')
}
$response = $client.SendAsync($request).GetAwaiter().GetResult()
$raw = $response.Content.ReadAsStringAsync().GetAwaiter().GetResult()
$parsed = $null
if ($raw) { try { $parsed = $raw | ConvertFrom-Json } catch {} }
return [pscustomobject]@{ Status = [int]$response.StatusCode; Json = $parsed; Raw = $raw }
} finally { $client.Dispose() }
}
function Login([long]$userId) {
$phone = (Sql "SELECT phone FROM users WHERE id=$userId LIMIT 1")[0]
if (-not $phone) { throw "login user not found" }
docker compose --env-file .env exec -T redis redis-cli DEL ("sms:rate:" + $phone) 2>$null | Out-Null
$sent = Api 'POST' ("/auth/sms/send?phone=$phone&purpose=login") $null $null
if ($sent.Status -ne 200) { throw 'sms send failed' }
$code = (Sql "SELECT code FROM sms_verification_codes WHERE phone='$phone' AND purpose='login' ORDER BY id DESC LIMIT 1")[0]
$login = Api 'POST' '/auth/login' $null @{ phone = $phone; code = $code; purpose = 'login' }
if ($login.Status -ne 200 -or -not $login.Json.data.accessToken) { throw 'login failed' }
return [string]$login.Json.data.accessToken
}
function NewFixtureStudent([string]$label, [bool]$withPreference) {
$phone = '19' + (([DateTimeOffset]::Now.ToUnixTimeMilliseconds() % 1000000000) + $script:fixtureUsers.Count + 1).ToString('D9')
$script:fixturePhones += $phone
$sql = @"
INSERT INTO users(phone,nickname,role,is_active,is_verified) VALUES ('$phone','$label','STUDENT',1,1);
SELECT LAST_INSERT_ID();
"@
$id = [long]((Sql $sql)[0])
$script:fixtureUsers += $id
Sql @"
INSERT INTO user_roles(user_id,role) VALUES ($id,'STUDENT');
INSERT INTO student_profiles(user_id,location,education,graduation_year,skills_json)
SELECT $id,j.city,'Bachelor',2026,'["$label","Java","Spring Boot","SQL"]' FROM jobs j WHERE j.id=1;
INSERT INTO student_talent_discovery_preferences(student_user_id,enabled,version) VALUES ($id,1,0);
"@ | Out-Null
if ($withPreference) {
Sql @"
INSERT INTO student_preferences(student_user_id,expected_salary_min,expected_salary_max,preferred_cities,employment_types,preferred_industries,preferred_job_types,remote_allowed)
SELECT $id,NULL,NULL,JSON_ARRAY(j.city),JSON_ARRAY(j.employment_type),JSON_ARRAY(p.industry_code),JSON_ARRAY(p.job_family_code),0
FROM jobs j JOIN job_recommendation_profiles p ON p.job_id=j.id WHERE j.id=1;
"@ | Out-Null
}
return $id
}
function Cleanup {
if (-not $script:fixtureUsers.Count) { return }
$ids = $script:fixtureUsers -join ','
$phones = ($script:fixturePhones | ForEach-Object { "'$_'" }) -join ','
try { Sql "DELETE FROM audit_logs WHERE user_id IN ($ids) OR (action='SMS_SEND' AND request_id LIKE 'v41-blackbox-%')" | Out-Null } catch {}
try { Sql "DELETE FROM ability_results WHERE attempt_id IN (SELECT id FROM ability_attempts WHERE student_user_id IN ($ids))" | Out-Null } catch {}
try { Sql "DELETE FROM ability_attempts WHERE student_user_id IN ($ids)" | Out-Null } catch {}
try { Sql "DELETE FROM applications WHERE student_user_id IN ($ids)" | Out-Null } catch {}
try { Sql "DELETE FROM student_preferences WHERE student_user_id IN ($ids)" | Out-Null } catch {}
try { Sql "DELETE FROM student_talent_discovery_preferences WHERE student_user_id IN ($ids)" | Out-Null } catch {}
try { Sql "DELETE FROM student_profiles WHERE user_id IN ($ids)" | Out-Null } catch {}
try { Sql "DELETE FROM account_deletions WHERE user_id IN ($ids)" | Out-Null } catch {}
try { Sql "DELETE FROM user_roles WHERE user_id IN ($ids)" | Out-Null } catch {}
try { Sql "DELETE FROM sms_verification_codes WHERE phone IN ($phones)" | Out-Null } catch {}
try { Sql "DELETE FROM users WHERE id IN ($ids)" | Out-Null } catch {}
}
try {
$flywayRow = (Sql "SELECT version,success FROM flyway_schema_history ORDER BY installed_rank DESC LIMIT 1")[0]
$flyway = @($flywayRow -split '\s+')
Assert ($flyway.Count -ge 2 -and $flyway[0] -eq '39' -and $flyway[1] -eq '1') 'Flyway V39 success'
$table = Sql "SELECT COUNT(*) FROM information_schema.tables WHERE table_schema='mydreamjob' AND table_name='student_talent_discovery_preferences'"
Assert ([int]$table[0] -eq 1) 'V39 table exists'
$constraints = Sql "SELECT constraint_type FROM information_schema.table_constraints WHERE table_schema='mydreamjob' AND table_name='student_talent_discovery_preferences'"
Assert (@($constraints | Where-Object { $_ -eq 'UNIQUE' }).Count -ge 1) 'UNIQUE student_user_id'
$check = Sql "SELECT COUNT(*) FROM information_schema.table_constraints WHERE constraint_schema='mydreamjob' AND table_name='student_talent_discovery_preferences' AND constraint_type='CHECK'"
Assert ([int]$check[0] -ge 1) 'CHECK enabled constraint'
$health = Api 'GET' '/health'
Assert ($health.Status -eq 200 -and $health.Json.data.status -eq 'UP') 'health 200'
$docs = Api 'GET' '/v3/api-docs'
$pathNames = @($docs.Json.paths.PSObject.Properties.Name)
$opCount = 0
foreach ($property in $docs.Json.paths.PSObject.Properties) {
$opCount += @($property.Value.PSObject.Properties.Name | Where-Object { $_ -in @('get','post','put','patch','delete','options','head','trace') }).Count
}
Assert ($pathNames.Count -eq 161 -and $opCount -eq 199) 'runtime OpenAPI 161/199'
Assert ($pathNames -contains '/profile/me/talent-discovery' -and $pathNames -contains '/companies/me/jobs/{jobId}/talent-recommendations') 'V41 paths present'
$baseScores = [int](Sql 'SELECT COUNT(*) FROM candidate_dimension_scores')[0]
$a = NewFixtureStudent 'V41BB-A' $true
$b = NewFixtureStudent 'V41BB-B' $true
$c = NewFixtureStudent 'V41BB-C' $true
$d = NewFixtureStudent 'V41BB-D' $false
$e = NewFixtureStudent 'V41BB-E' $true
$script:fixtureAbilityUser = $d
Sql "INSERT INTO applications(job_id,student_user_id,status) VALUES (1,$b,'REJECTED'),(13,$c,'WITHDRAWN');" | Out-Null
Sql "INSERT INTO account_deletions(user_id,status,requested_at,version) VALUES ($e,'PENDING',NOW(),0);" | Out-Null
Sql @"
INSERT INTO ability_attempts(student_user_id,definition_id,status,question_set_version,answered_count,active_key,idempotency_key,completed_at,submitted_at,started_at)
VALUES ($d,1,'COMPLETED','ability-v0.1',24,NULL,NULL,DATE_SUB(NOW(),INTERVAL 2 DAY),DATE_SUB(NOW(),INTERVAL 2 DAY),DATE_SUB(NOW(),INTERVAL 2 DAY));
SET @high_attempt=LAST_INSERT_ID();
INSERT INTO ability_results(attempt_id,dimension_scores_json,overall_score,valid_until,question_set_version,rules_version,generated_at)
VALUES (@high_attempt,'[{"code":"LOGIC","score":100,"answeredCount":6},{"code":"COMMUNICATION","score":100,"answeredCount":6},{"code":"EXECUTION","score":100,"answeredCount":6},{"code":"LEARNING","score":100,"answeredCount":6}]',100,DATE_ADD(NOW(),INTERVAL 7 DAY),'ability-v0.1','ability-v0.1',NOW());
INSERT INTO ability_attempts(student_user_id,definition_id,status,question_set_version,answered_count,active_key,idempotency_key,completed_at,submitted_at,started_at)
VALUES ($d,1,'COMPLETED','ability-v0.1',24,NULL,NULL,DATE_SUB(NOW(),INTERVAL 1 HOUR),DATE_SUB(NOW(),INTERVAL 1 HOUR),DATE_SUB(NOW(),INTERVAL 1 HOUR));
SET @low_attempt=LAST_INSERT_ID();
INSERT INTO ability_results(attempt_id,dimension_scores_json,overall_score,valid_until,question_set_version,rules_version,generated_at)
VALUES (@low_attempt,'[{"code":"LOGIC","score":0,"answeredCount":6},{"code":"COMMUNICATION","score":0,"answeredCount":6},{"code":"EXECUTION","score":0,"answeredCount":6},{"code":"LEARNING","score":0,"answeredCount":6}]',0,DATE_ADD(NOW(),INTERVAL 7 DAY),'ability-v0.1','ability-v0.1',NOW());
"@ | Out-Null
$tokenA = Login $a
$tokenB = Login $b
$tokenC = Login $c
$tokenD = Login $d
$tokenCompany = Login 3
$tokenAdmin = Login 1
$tokenUnverified = Login 84
$pref = Api 'GET' '/profile/me/talent-discovery' $tokenA
Assert ($pref.Status -eq 200 -and $pref.Json.data.enabled -eq $true -and [int]$pref.Json.data.version -eq 0) 'student preference GET'
$updated = Api 'PUT' '/profile/me/talent-discovery' $tokenA @{ enabled = $false; expectedVersion = 0 }
Assert ($updated.Status -eq 200 -and $updated.Json.data.enabled -eq $false -and [int]$updated.Json.data.version -eq 1) 'student preference update/version'
$stale = Api 'PUT' '/profile/me/talent-discovery' $tokenA @{ enabled = $true; expectedVersion = 0 }
Assert ($stale.Status -eq 409 -and [int]$stale.Json.code -eq 9201) 'stale preference conflict 9201'
$restored = Api 'PUT' '/profile/me/talent-discovery' $tokenA @{ enabled = $true; expectedVersion = 1 }
Assert ($restored.Status -eq 200 -and $restored.Json.data.enabled -eq $true -and [int]$restored.Json.data.version -eq 2) 'preference restored'
$studentForbidden = Api 'GET' '/profile/me/talent-discovery' $tokenCompany
Assert ($studentForbidden.Status -eq 403 -and [int]$studentForbidden.Json.code -eq 2002) 'company denied student preference'
$anon = Api 'GET' '/profile/me/talent-discovery'
Assert ($anon.Status -eq 401) 'anonymous denied preference'
$unavailable = Api 'GET' '/companies/me/jobs/7/talent-recommendations?page=0&size=20' $tokenCompany
Assert ($unavailable.Status -eq 200 -and $unavailable.Json.data.mode -eq 'UNAVAILABLE' -and [int]$unavailable.Json.data.total -eq 0) 'unavailable job profile'
$page = Api 'GET' '/companies/me/jobs/1/talent-recommendations?page=0&size=50' $tokenCompany
Assert ($page.Status -eq 200 -and $page.Json.data.mode -eq 'PERSONALIZED') 'personalized recommendations'
$items = @($page.Json.data.items)
$idsInRaw = $page.Raw
Assert ($items.Count -ge 3 -and [int]$page.Json.data.total -ge 3) 'eligible candidates returned'
$keys = @($items | ForEach-Object { $_.candidateKey })
Assert ($keys -contains $null -eq $false -and ($keys | Select-Object -Unique).Count -eq $keys.Count) 'opaque candidate keys'
Assert (-not ($idsInRaw -match 'studentUserId|candidateId|fitScore|dimensionScores|overallScore|attemptId|resultId|answerKey|scoringRule|questionCode|competencyTag|resumeUrl|phone|email|gender|birthDate|password|token')) 'candidate JSON redacted'
$bands = @($items | ForEach-Object { $_.fitBand })
Assert ($bands -contains 'BASIC') 'latest low ability beats historical high (BASIC)'
$signals = @($items | ForEach-Object { $_.signalsAvailable -join ',' })
Assert (($signals -contains 'PREFERENCE') -and ($signals -contains 'ABILITY')) 'signals available'
$currentRaw = ($items | ConvertTo-Json -Depth 20 -Compress)
$skillText = ($items | ForEach-Object { $_.skills -join ',' }) -join ','
Assert ($skillText -match 'V41BB-A' -and $skillText -match 'V41BB-C' -and $skillText -match 'V41BB-D' -and $skillText -notmatch 'V41BB-B') 'same-company application excluded; other-company retained'
Assert ($skillText -notmatch 'V41BB-E') 'pending account deletion excluded'
$dCard = @($items | Where-Object { ($_.skills -join ',') -match 'V41BB-D' })[0]
Assert ($null -ne $dCard -and ($dCard.signalsAvailable -join ',') -eq 'ABILITY' -and $dCard.fitBand -eq 'BASIC') 'latest low ability selected over historical high'
$p0 = Api 'GET' '/companies/me/jobs/1/talent-recommendations?page=0&size=1' $tokenCompany
$pLast = Api 'GET' ("/companies/me/jobs/1/talent-recommendations?page=" + ([int]$page.Json.data.total - 1) + '&size=1') $tokenCompany
Assert ($p0.Status -eq 200 -and $pLast.Status -eq 200 -and $p0.Json.data.items[0].fitBand -ne 'BASIC' -and $pLast.Json.data.items[0].fitBand -eq 'BASIC') 'global sort before pagination'
$afterScores = [int](Sql 'SELECT COUNT(*) FROM candidate_dimension_scores')[0]
Assert ($afterScores -eq $baseScores) 'V41 does not write candidate_dimension_scores'
$countB = [int](Sql "SELECT COUNT(*) FROM applications WHERE student_user_id=$b AND job_id=1")[0]
$countC = [int](Sql "SELECT COUNT(*) FROM applications WHERE student_user_id=$c AND job_id=13")[0]
Assert ($countB -eq 1 -and $countC -eq 1) 'fixture historical applications recorded'
$studentCompany = Api 'GET' '/companies/me/jobs/1/talent-recommendations' $tokenA
Assert ($studentCompany.Status -eq 403 -and [int]$studentCompany.Json.code -eq 2002) 'student denied company recommendations'
$adminCompany = Api 'GET' '/companies/me/jobs/1/talent-recommendations' $tokenAdmin
Assert ($adminCompany.Status -eq 403 -and [int]$adminCompany.Json.code -eq 2002) 'admin denied company recommendations'
$cross = Api 'GET' '/companies/me/jobs/13/talent-recommendations' $tokenCompany
Assert ($cross.Status -eq 403 -and [int]$cross.Json.code -eq 2002) 'cross-company job denied'
$unverified = Api 'GET' '/companies/me/jobs/13/talent-recommendations' $tokenUnverified
Assert ($unverified.Status -eq 403 -and [int]$unverified.Json.code -eq 2002) 'unverified company denied'
$anonCompany = Api 'GET' '/companies/me/jobs/1/talent-recommendations'
Assert ($anonCompany.Status -eq 401) 'anonymous denied company recommendations'
$badPage = Api 'GET' '/companies/me/jobs/1/talent-recommendations?page=-1&size=51' $tokenCompany
Assert ($badPage.Status -eq 400 -and [int]$badPage.Json.code -in @(400,1001)) 'page/size validation'
$legacy = Api 'GET' '/profile/me/job-recommendations?page=0&size=1' $tokenA
Assert ($legacy.Status -eq 200) 'V39 job recommendation regression'
$queues = Rabbit
$known = @('queue.notification','queue.audit','queue.notification.dlq')
foreach ($q in $known) {
$line = @($queues | Where-Object { $_ -match "^$q\s" })
if ($line.Count -eq 0) { Fail "Rabbit queue exists $q"; continue }
$fields = ($line[0] -split '\s+')
Assert ([int]$fields[-2] -eq 0 -and [int]$fields[-1] -eq 0) "Rabbit queue drained $q"
}
$constraintRow = Sql "SELECT COUNT(*) FROM student_talent_discovery_preferences WHERE student_user_id=$a AND enabled=1"
Assert ([int]$constraintRow[0] -eq 1) 'preference persisted in MySQL'
Write-Output ("V41_BLACKBOX_PASS={0} FAIL={1}" -f $pass, $fail)
if ($fail -gt 0) { exit 1 }
} catch {
Fail 'blackbox execution' $_.Exception.Message
Write-Output ("V41_BLACKBOX_PASS={0} FAIL={1}" -f $pass, $fail)
exit 1
} finally {
Cleanup
Pop-Location
}

188
docs/ability-assessment-api.md

@ -0,0 +1,188 @@
# 四维能力测评 API 设计文档(V36 设计锁定版)
> 状态:**设计已锁定,待编码**。功能版本 V36。题库为自研工作情境题;本版不接入候选评分引擎、不向企业开放结果。题目统计质量门槛保留为后续试测工作,不阻塞本版功能实现。
## 0. 产品来源摘要(func.xlsx,已确认)
- 功能名:**能力测评(4维度) = 逻辑 / 沟通 / 执行 / 学习 能力四维测评**(`功能详细清单 D24/F24`、`求职端功能详情 B24/C24`、`企业端功能详情`)。
- 目标:**了解自己目前的能力短板在哪里**(G24)。
- 结果展示:**四维能力测试 → 能力雷达图 + 短板提示**(F24)。
- 限时:**测评限时 30 分钟,超时强制提交**(G24)。
- 完成率:**测评完成率 ≥ 70%**(H24,中途退出保存进度的判定)。
- 质量:**测评区分度 ≥ 0.4**(H24/H52,为非功能质量门槛)。
- 能力测评评分(F1.2):基于四维得分 → **能力综合分**,「让企业看到我的能力测评结果」;未测评显示「待测评」(G52)。
- 成长信号:学习能力/成长速度潜力分(F55/G55)。
## 1. 功能范围与非目标
**范围内(第一版 V36)**:
- 四维能力测评的题型目录、创建答卷、答题保存、进度恢复、提交、结果查询。
- 结果含四维分、综合分、最低维度短板提示、雷达图数据、题目解析与有效期状态。
**非目标(V36 默认不做,除非负责人确认)**:
- 不接入 Candidate Scoring / 不写 `candidate_dimension_scores`(ABILITY 保持 PENDING)。
- 不提供企业查看学生能力测评结果的接口。
- 不做岗位推荐、AI 路径建议、开放题或 AI 自动评分。
- 不做定时器批量改写结果有效期;结果是否过期在读取或新建答卷时由后端按 `valid_until` 动态判断。
## 2. 四个能力维度的正式名称与编码
| 编码(Code) | 名称 | 说明 |
|---|---|---|
| LOGIC | 逻辑能力 | 逻辑/推理/分析 |
| COMMUNICATION | 沟通能力 | 表达/协作/共情 |
| EXECUTION | 执行能力 | 行动/落地/结果导向 |
| LEARNING | 学习能力 | 学习/成长/适应 |
编码和结果字段按上表锁定。
## 3. 题库来源、题目数量、题目版本
- 题目来源:自研(`SELF_AUTHORED`),不复制商用量表。
- 锁定题库文件:`docs/ability-question-bank-v0.1.json`。迁移种子、题库测试和题目版本必须以该文件为唯一权威来源,禁止在 SQL 或 Java 中自行改写题干、选项、答案键、分值或解析。
- 题库总量:每维 12 题,共 **48 题**;每次答卷从每个维度分层随机抽取 6 题,共 **24 题**。
- 题型:单选题、情境判断题、排序题;允许选项部分得分,不采用开放题。
- 题目版本:`ability-v0.1`。每题必须保留 `difficulty`、`discrimination`、`version` 字段;前两项允许在首版种子数据中为空,试测后回填。
## 4. 是否固定题目或分层随机抽题
- 采用分层随机抽题:从每维 ACTIVE 题库随机抽取 6 题,并把题目集合、顺序、题目版本和题目快照持久化;刷新、恢复和重复读取均不得重新抽题。
- 新建或重测答卷均重新抽取本次 24 题;不得向前端返回内部维度、答案键、评分规则或题目版本。
## 5. 单次答题数量与完成条件
- 单次题量固定为 4 维 × 6 = **24 题**。
- 必须回答 24/24 才能提交;任何未完成答卷均返回 `ABILITY_INCOMPLETE`,不得依据 70% 生成正式结果。
- 产品“完成率 ≥ 70%”仅用于过程进度展示,不构成提交门槛。
## 6. 题型与计分规则
- 每题均为后端判分的工作情境题。每个选项对应 0、1、2 或 4 分;选项及其得分、标准答案键、评分规则和解析均只保存在后端。
- 每维原始分为该维 6 题得分之和,维度百分制分数为 `rawScore / 24 × 100`;保留两位小数,使用 `HALF_UP`。
- 综合分为四维百分制得分的等权平均;只有四维均完成 6 题时才生成综合分。
- 短板取最低维度;若最低分并列,返回全部并列维度。结果页同时返回每道题解析,但仅限答卷提交后由本人读取。
## 7. 中途保存和恢复规则
- 保存答案对 (attempt, question) upsert;同请求内同题重复 → 校验拒绝。
- 获取答卷恢复:固定题目顺序 + 已答值;刷新不重抽题。
- 未答完可退出,下次 GET 恢复;与 RIASEC 一致(active 答卷阻塞重测)。
## 8. 答卷状态机
锁定状态机:
```
DRAFT → IN_PROGRESS → COMPLETED
IN_PROGRESS --(超过 expires_at 且未提交)--> EXPIRED
```
- 创建答卷仅进入 `DRAFT`,不开始计时。
- 第一次成功保存答案时,后端一次性设置 `started_at=now`、`expires_at=started_at+30分钟`,并转为 `IN_PROGRESS`。
- 后续保存、读取、提交均由后端校验 `expires_at`;超时未答完 24 题时,原子标记为 `EXPIRED`,不生成结果、不可继续答题或提交。
- `DRAFT` 没有倒计时;同一学生同一类型始终至多存在一份 `DRAFT/IN_PROGRESS` 活跃答卷。
## 9. 重测和历史保留规则
- 历史答卷和结果永久保留,不删除。
- 完成时写入 `valid_until = completed_at + 7天`。在 `valid_until` 前为当前有效结果;到期后结果仍可查询,但 `validityStatus=EXPIRED` 且仅作为历史结果。
- 不设置“历史最高分”。当前结果只取最近一次已完成且仍有效的结果,避免用户通过反复答题刷取最高分。
- 有效结果存在期间禁止重测;结果到期后方可创建下一份答卷。
- 每名学生滚动 90 天内最多完成 3 次测评;未完成而超时的 `EXPIRED` 答卷不计入该完成次数。
## 10. 提交幂等与并发策略
- 唯一键:`(student_user_id, idempotency_key)` 幂等;`active_key`(学生+类型+ACTIVE)保证同类型最多 1 份活跃答卷(复用 V35 已验证方案)。
- 提交原子化:`DRAFT/IN_PROGRESS → COMPLETED` 的批量更新返回行数;仅行数=1 者生成结果+写审计;并发只产生 1 条 result + 1 条提交审计;重复提交幂等返回原结果。
- 提交并发无 500(沿用 V35 REQUIRES_NEW + READ_COMMITTED 修复)。
- 创建时先校验当前有效结果是否存在,再校验 90 天完成次数;两类限制须有对应错误码,不能误报为活跃答卷冲突。
## 11. 权限矩阵
| 接口 | 未登录 | STUDENT | COMPANY | ADMIN |
|---|---|---|---|---|
| 类型列表 | 401 | 200 | 200 | 200 |
| 创建 / 答卷 / 保存 / 提交 / 结果 / 历史 | 401 | 仅本人 | 403/2002 | 403/2002 |
- 身份一律 JWT/SecurityContext,不接受 userId 参数。
- 第一版不提供企业查看能力结果接口;能力综合分写入评分引擎与企业侧结果展示均为后续独立评审事项。
## 12. 错误码
- V35 RIASEC 已占用 `9701–9709`(97xx)。V36 使用独立区间 **`9801–9811`**(能力测评),避免复用冲突:
- `9801 ABILITY_TYPE_NOT_FOUND`、`9802 ABILITY_ATTEMPT_NOT_FOUND`、`9803 ABILITY_ATTEMPT_INVALID_STATE`、`9805 ABILITY_ANSWER_INVALID`、`9806 ABILITY_INCOMPLETE`、`9807 ABILITY_CONCURRENT_UPDATE`、`9808 ABILITY_DUPLICATE_ACTIVE`、`9809 ABILITY_IDEMPOTENCY_CONFLICT`。
- `9810 ABILITY_RETAKE_COOLDOWN`(409):当前仍存在有效完成结果,尚不可重测。
- `9811 ABILITY_RETAKE_LIMIT`(429):滚动 90 天内已完成 3 次。
- 复用 `1001/2001/2002`;超时答卷读取、保存或提交统一按 `9803 ABILITY_ATTEMPT_INVALID_STATE` 处理。
## 13. DTO 与接口字段
接口采用独立 `/abilities/**` 命名空间(最终 7 个接口):
```
GET /abilities/types
POST /abilities/attempts
GET /abilities/attempts/{attemptId}
PATCH /abilities/attempts/{attemptId}/answers
POST /abilities/attempts/{attemptId}/submit
GET /abilities/attempts/{attemptId}/result
GET /abilities/me?page&size
```
V36 不提供 `GET /abilities/{type}/questions`;题目只在创建答卷后通过 `GET /abilities/attempts/{attemptId}` 返回。
- 题目响应隐藏 `dimension/questionCode/competencyTag/source/reverseScored/questionVersion/answerKey/scoringRule`(不泄露能力维度语义与计分内部字段)。
- 结果响应(白名单):`attemptId/typeCode/status/validityStatus/validUntil/dimensionScores[{code,score,answeredCount}]/overallScore/weaknesses/radar/answeredCount/completionRate/questionFeedback[{questionId,selectedOption,explanation}]/disclaimer`。不含答案键、选项得分、studentUserId、phone、email、password。
## 14. 数据库表、索引、唯一约束、外键
采用独立 `ability_*` 六表,避免与 V35 RIASEC 的题型、判分方式和生命周期耦合:
```
ability_definitions type_code UNIQUE; status; scale_type; question_count; attempt_question_count; min_per_dimension; question_set_version; rules_version
ability_questions UNIQUE(definition_id, question_code), UNIQUE(definition_id, position), CHECK(dimension IN LOGIC/COMMUNICATION/EXECUTION/LEARNING), item_type/options_json/answer_key_json/scoring_rule_json/explanation/difficulty/discrimination/version
ability_attempts UNIQUE(student_user_id, idempotency_key), UNIQUE(active_key), @Version, CHECK(status IN DRAFT/IN_PROGRESS/COMPLETED/EXPIRED)
ability_attempt_questions UNIQUE(attempt_id, question_id), UNIQUE(attempt_id, position), question_version/question_snapshot_json
ability_answers UNIQUE(attempt_id, question_id), selected_option/awarded_score/answered_at
ability_results UNIQUE(attempt_id)(幂等)
```
- 外键:attempt→definitions/users(CASCADE)、attempt_question/answer→attempt(CASCADE)+question、result→attempt(CASCADE)。题目快照用于保证题库后续修订或停用后,历史答卷和提交后的解析仍按当时版本展示与计分。
- 索引:`(student_user_id, created_at)`、`active_key` 唯一、`status` 等。
- 时间相关字段:`started_at/expires_at/submitted_at/completed_at`;结果含 `valid_until`。`active_key` 仅在 `DRAFT/IN_PROGRESS` 非空,进入 `COMPLETED/EXPIRED` 后置空。
## 15. 脱敏要求
- 题目响应不泄露维度与内部编码;结果不返回原始答案、手机号、邮箱、password、JWT、他人信息。
- 不保存不必要的题面;审计不含题面/答案/PII。
## 16. 审计要求
- 写入 `ABILITY_ATTEMPT_CREATED` / `ABILITY_ATTEMPT_SUBMITTED`。
- details 仅允许 `attemptId/typeCode/status/answeredCount/questionSetVersion/rulesVersion/requestId/timestamp`;禁止题面、答案、手机号、邮箱、JWT。
## 17. 是否写入 candidate_dimension_scores
- V36 第一版不写 `candidate_dimension_scores`,ABILITY 维度维持 `PENDING`;不提供企业端能力测评结果接口。
- 将能力综合分写入评分引擎、企业端查看结果均为后续独立评审事项。
## 18. OpenAPI 增量
- 基线 134 paths / 168 operations。
- 固定 +7 paths / +7 operations(`/abilities/**` 7 端点:类型/创建/详情/保存/提交/结果/我的历史)→ **141 paths / 175 operations**;V36 不提供 `GET /abilities/{type}/questions`。运行时 OpenAPI 必须与静态一致。
## 19. MockMvc、集成测试和 MySQL 黑盒验收方案
- 测试类(镜像 RIASEC):
- `AbilityAssessmentCatalogTest`、`AbilityAssessmentResultComputerTest`、`AbilityAssessmentAttemptServiceTest`、`AbilityAssessmentControllerTest`、`AbilityAssessmentPersistenceIntegrationTest`(含并发创建/并发提交)、种子/迁移测试。
- 覆盖:创建、幂等重放、同 key 同/异请求、活跃重复、有效结果冷却、90 天完成上限、中途保存、首次保存起算、恢复同一题、30 分钟超时、非法/跨答卷题 ID、未答完提交、重复提交、并发创建/提交、STUDENT/COMPANY/ADMIN/匿名权限、结果和题目解析脱敏、业务表无异常写入、Flyway、外键/唯一约束。
- MySQL 黑盒:健康 200、Flyway 最新成功、运行时/静态 OpenAPI 一致、表/约束/数据核验、并发无 500、审计脱敏、队列归零、`candidate_dimension_scores` 行数不变、V31–V35 回归。
## 20. 回滚与兼容方案
- 不修改 V1–V35 表与语义;V36 迁移为附加新表 `V35__ability_assessment.sql`(当前最新 Flyway 为 V34,下一编号为 V35;功能版本仍标 V36)。
- 回滚:删除 V36 迁移即可,不影响 V35 及既有数据。
- 使用独立 `/abilities/**` 命名空间与独立 `ability_*` 表,回滚不会影响 V35 RIASEC。
---
## 21. 试测与上线声明
- 首版 48 题必须在后续试测中统计难度、选项分布与区分度;区分度低于 0.4 或存在明显歧义的题目应修改、停用或替换。
- 当前未定义试测样本量和正式上线统计门槛,因此本版不得宣称题库已完成心理测量学验证。
- 结果页必须声明:本测评反映当前工作情境答题表现,用于自我了解和能力提升参考;不构成职业资格、正式招聘结论或永久能力标签。
> 本设计已由负责人确认,可进入 V36 编码;试测统计口径为后续运营与题库质量治理工作,不阻塞本版实现。

1064
docs/ability-question-bank-v0.1.json

File diff suppressed because it is too large

6
docs/acceptance-week1.md

@ -91,7 +91,7 @@ curl http://localhost:8080/api/v1/info
# 发送验证码
curl -X POST "http://localhost:8080/api/v1/auth/sms/send?phone=13800000001&purpose=login"
# Mock 登录(从日志获取验证码)
# Mock 登录(验证码不输出到日志;本地按 docs/frontend-integration.md §3 受控取得)
curl -X POST http://localhost:8080/api/v1/auth/login \
-H "Content-Type: application/json" \
-d '{"phone":"13800000001","code":"<验证码>"}'
@ -145,11 +145,11 @@ mvn spring-boot:run -Dspring-boot.run.profiles=dev \
| STUDENT | 13800000002 | 学生测试 |
| COMPANY | 13800000003 | 企业测试 |
> 注: 使用 Mock 短信验证码登录,验证码从应用日志中获取
> 注: 使用 Mock 短信验证码登录。应用日志**不再输出验证码**,本地按 `docs/frontend-integration.md` §3 的受控方式取得(需 `SMS_MOCK_ENABLED=true` 且 `ALLOW_TEST_OTP_DB_READ=true`);生产环境禁止查询验证码表。
## 已知限制
1. **短信验证码 Mock**: 验证码打印到日志,不发送真实短信
1. **短信验证码 Mock**: 应用日志**不输出验证码**,不发送真实短信;本地取码见 `docs/frontend-integration.md` §3(验证码当前仍为数据库明文存储)
2. **业务模块**: 岗位、投递、聊天、评分等模块仅定义接口骨架
3. **RabbitMQ 监听**: 队列已定义但未实现消费者(Week 2+)
4. **Actuator 健康**: RabbitMQ 健康检查未配置(仅 ping)

161
docs/account-deletion-test-isolation-fix-20260917.md

@ -0,0 +1,161 @@
# 账号注销测试夹具隔离修复报告
日期:2026-09-17
分支:`refactor/r01-structure-performance`
基线 HEAD:`d5c37128e84b4fc83bf5334254dd8efeb88ccf64`
性质:**纯测试修复** —— 生产代码、Flyway、配置与接口零改动
---
## 1. 结论
> `AccountDeletionControllerTest` 与关联测试组合的**既有交叉污染已消除**;修复后两种执行顺序各 5 次全部 F=0 / E=0 / S=0。
---
## 2. 稳定复现(修复前)
**致冲突组合**(连续运行 5 次,**5/5 稳定复现**):
```
mvn -Dtest="*Deletion*Test,*Verification*Test,*Risk*Test,*Report*Test,*Block*Test,
*Notification*Test,*Recommendation*Test,*RequestContext*Test,*RequestId*Test" test
```
5 次运行**全部**失败于同一位置:
```
AccountDeletionControllerTest.lazyTransition_firstGet_returnsCompleted_andConsistent:228
-> createUser:75
DataIntegrityViolation: Unique index or primary key violation:
USERS(PHONE) = '930000000'
```
### 诊断结论(逐项回答)
| 问题 | 结论 |
|---|---|
| **谁先插入 `phone='930000000'`** | `com.mydreamjob.common.logging.RequestContextMigrationIntegrationTest`(**HEAD 既有类**)。其 `@BeforeEach` 用 `AtomicLong(930_000_000L)` 创建 user |
| **冲突发生在哪个方法** | `AccountDeletionControllerTest#createUser:75`(由 `lazyTransition_firstGet_returnsCompleted_andConsistent:228` 调用) |
| **共享 H2 数据库名** | `integrationdb`(`jdbc:h2:mem:integrationdb;MODE=MySQL;DB_CLOSE_DELAY=-1`)—— 同一 JVM 内所有 `@SpringBootTest` 共享 |
| **static 计数器何时重置** | **不重置** —— `PHONE` 为 per-class static,类加载时初始化为 `930_000_000`;冲突**不是**计数器重置所致,而是**号段被残留记录占用** |
| **未清理的记录** | `users`(`phone=930000000`);`user_roles` 随 users 级联删除 |
### 根因链
1. **号段撞车**:`common/logging/RequestContextMigrationIntegrationTest` 与 `deletion/AccountDeletionControllerTest` **都使用基数 `930_000_000`**
2. **污染源不清理**:前者 `@AfterEach` 只清 `RequestContextProbe` 与 `MDC`,**不删 users** → 每个测试方法残留 1 条 `930000000+N`
3. **受害者删除失败**:后者的 `@AfterEach` 用 `userRepository.deleteAll()`(**全表删除**),会因**其他测试类残留 user**(被 `notifications`/`offers`/`resume_versions` 等**无 CASCADE** 的外键引用)而失败 → 自身 user 也未删净
4. **执行顺序**:诊断运行中 `common.logging.RequestContextMigrationIntegrationTest` 排在第 **6** 位、`AccountDeletionControllerTest` 排在第 **13** 位 → 前者残留必然影响后者
### 与 A2/A3 的关系(如实记录)
A2/A3 新建的测试类**均不创建 user**,故**无污染**:
- `common/r01a2/RequestContextMigrationIntegrationTest`:无 `AtomicLong`、无 `userRepository` 注入
- A3 的三个测试类:均使用 `@MockBean UserRepository`(不实际写库)
**该缺陷完全存在于基线**(污染源文件为 HEAD 既有),与 A2/A3 无关;此前用 HEAD 隔离 worktree 的归因结论**正确**。
> 同号段还有 `candidatescore/CandidateScoringClockBoundaryIntegrationTest`,但其标注 `@Transactional`(每方法自动回滚)→ **不残留**,无风险。
---
## 3. 修复内容
### 3.1 `common/logging/RequestContextMigrationIntegrationTest.java`(污染源)
| 改动 | 内容 |
|---|---|
| 号段独占 | `930_000_000L` → **`986_000_000L`**(已确认全仓未占用) |
| **清理** | `@AfterEach` 增加:按自身 `studentId` **精确删除**本测试创建的 user(`findById(...).ifPresent(delete)`),并补 javadoc 说明隔离原因 |
### 3.2 `deletion/AccountDeletionControllerTest.java`(受害者)
| 改动 | 内容 |
|---|---|
| 号段独占 | `930_000_000L` → **`987_000_000L`**(已确认全仓未占用) |
| 记录归属 | 新增 `List<Long> createdUserIds`,`createUser()` 登记每个自建 userId |
| **精确清理** | `@AfterEach` 由 `deleteAll()`(整表)改为**按本测试记录**删除:`account_deletions`(按 userId)→ `users`(级联 `user_roles`),各步 `flush()`,遵循外键顺序;`@BeforeEach` 清空记录 |
| **防掩盖断言** | `createUser()` 内先断言该号在共享库中**不存在**,存在即抛出可读错误(暴露上轮清理失效),而不是被唯一键异常吞掉 |
**未使用**:未改唯一约束、未捕获忽略 `DataIntegrityViolationException`、未用 `@DirtiesContext`、未清空整表、未 `DELETE FROM users` 无条件删除、未 sleep、未放宽任何业务断言、未依赖测试执行顺序。
### 3.3 新增反证测试 `deletion/AccountDeletionFixtureIsolationTest.java`(5 例)
| 反证 | 断言 |
|---|---|
| 1 | 同一 Spring 上下文内连续创建 5 个夹具**不冲突**且 ID 不重复 |
| 2 | 注销记录写入后,按本测试路径清理 → `account_deletions` / `users` / `user_roles` 残留**均为 0** |
| 3 | **变异探测**:预置同号记录(等价于"上轮未清理")→ 防掩盖断言**必须失败**并检出该号,证明残留不会被静默吞掉 |
| 4 | 独占号段 `987_000_000` 在测试开始前为空(不与任何他类重叠) |
| 5 | 清理**精确到本测试记录**:预置的"他类"记录必须保留,证明不是整表删除 |
---
## 4. 验证结果
### 4.1 两种执行顺序各 5 次
| | 组合 | 结果(5 次) |
|---|---|---|
| 顺序 A | `*Deletion*Test,*RequestContext*Test,*RequestId*Test` | **95/95,F=0 E=0 S=0** ×5 ✔ |
| 顺序 B | `*RequestContext*Test,*Deletion*Test,*RequestId*Test` | **95/95,F=0 E=0 S=0** ×5 ✔ |
(修复前该组合稳定失败 5/5;修复后 10/10 全绿)
### 4.2 单类与反证
| 命令 | 结果 |
|---|---|
| `mvn -Dtest="AccountDeletionControllerTest" test` | **14 / 14** ✔ |
| `mvn -Dtest="AccountDeletionFixtureIsolationTest,AccountDeletionControllerTest" test` | **19 / 19**(5 反证 + 14 原)✔ |
### 4.3 全量
| 命令 | 结果 |
|---|---|
| `mvn clean verify` | **2225 / 2225**,Failures=0、Errors=0、**Skipped=0**,BUILD SUCCESS(≥ 2220 ✔) |
| `python docs\verify_openapi.py` | **176 paths / 214 operations**,All checks passed ✔ |
较修复前(2220)新增 **5** 个测试,全部为反证测试。
### 4.4 边界核验
| 项 | 结果 |
|---|---|
| 生产源码 `git diff --stat -- src/main/` | **空**(零改动)✔ |
| V1–V45 迁移 | **零修改** ✔ |
| Flyway / 配置 / 接口 | 零改动 ✔ |
| `GlobalExceptionHandler.java` | 仍为工作区 stat 标记(内容零差异),**未处理** ✔ |
| Docker | 未操作 ✔ |
| 是否进入 A4 | 否 ✔ |
---
## 5. 精确修改文件
```
修改(2)
mydreamjob-server/src/test/java/com/mydreamjob/common/logging/RequestContextMigrationIntegrationTest.java
mydreamjob-server/src/test/java/com/mydreamjob/deletion/AccountDeletionControllerTest.java
新增(1)
mydreamjob-server/src/test/java/com/mydreamjob/deletion/AccountDeletionFixtureIsolationTest.java
文档(1)
docs/account-deletion-test-isolation-fix-20260917.md
```
---
## 6. 提交
```
提交信息:test: isolate account deletion controller fixtures
```
(精确暂存上述测试文件与报告,敏感扫描后独立提交;推送当前 R01 功能分支)
---
> 账号注销测试夹具隔离修复完成;测试交叉污染已消除,生产代码零改动。

69
docs/api-v0.1.md

@ -24,7 +24,7 @@ Query: phone=13800000001, purpose=login
**限制:**
- 60秒内同一手机号只能发送1次
- 5次错误后锁定5分钟
- Mock环境验证码打印到日志
- Mock 环境验证码**不输出到日志**;本地按 `docs/frontend-integration.md` §3 的受控方式取得(生产禁止查表)
### 2. Mock 登录
```
@ -169,12 +169,65 @@ http://localhost:8080/swagger-ui.html
http://localhost:8080/v3/api-docs
```
## 已知限制 (Week 1)
### 4. 刷新 Token(S02)
1. 短信验证码为 Mock 实现,验证码打印到日志,不发送真实短信
```
POST /api/v1/auth/refresh
Content-Type: application/json
{ "refreshToken": "<refreshToken>" }
```
- 匿名可调用;是否成功取决于 Refresh Token 校验结果。
- **只接受 Refresh Token**;把 Access Token 传进来一律 401。
- 请求体只允许 `refreshToken` 一个字段;Token 不接受 query/path 参数。
- 成功后返回新的 Token 对,**旧 Refresh Token 立即失效**(强制轮换)。
- 已消费/已撤销的 Refresh Token 再次提交 → 401,并撤销该登录会话(重放处置)。
- 失败响应不说明 Token 是"不存在""已消费"还是"已撤销"。
**成功响应:**
```json
{
"code": 200,
"data": {
"accessToken": "eyJhbGciOi...",
"refreshToken": "eyJhbGciOi...",
"expiresIn": 86400
},
"requestId": "<request-id>"
}
```
### 5. 登出(S02)
```
POST /api/v1/auth/logout
Authorization: Bearer <accessToken>
```
- 必须已认证;**无请求体**。
- 身份与会话来自 Access Token,不接受客户端传入 `userId`/`sessionId`/`refreshToken`。
- 只撤销**当前登录会话**,其他设备会话不受影响。
- 重复登出按幂等处理;成功响应不包含任何 Token。
## Token 与会话模型(S02)
| Token | 用途 | 说明 |
|---|---|---|
| Access Token | 普通业务接口的 `Authorization` 头 | 带 `type=access`,有效期 24h;每次请求都会校验服务端会话状态 |
| Refresh Token | 仅用于 `POST /auth/refresh` | 带 `type=refresh`,有效期 7 天;不落库,数据库只保存其 HMAC-SHA256 摘要 |
- Access 与 Refresh **使用不同签名密钥**,交叉验签必然失败。
- 每次登录创建独立的**登录会话**(`sessionId`);同一用户可同时存在多个会话。
- 登出只撤销当前会话;账号注销完成、用户禁用会撤销该用户**全部会话**。
- 本版**不再兼容**无 `type` 的旧 Token(所有旧 Token 失效,用户需重新登录一次)。
- Token 原文不进入数据库、日志、审计、URL 或错误响应。
## 已知限制
1. 短信验证码为 Mock 实现,应用日志**不输出验证码**,不发送真实短信;未接入真实供应商时发码返回“服务不可用”
2. 无企业注册/认证流程
3. 无岗位、投递、聊天、评分等业务功能
4. 无 Token 刷新接口(Week 2+)
5. 无密码修改/重置功能
6. RabbitMQ 未实现消息监听器(仅队列定义)
7. Actuator 健康检查部分依赖(Redis/RabbitMQ)在未启动时返回 DOWN
3. 无密码修改/重置功能
4. 未提供查看全部登录设备、远程踢出单个设备、Token introspection 等接口
5. RabbitMQ 未实现消息监听器(仅队列定义)
6. Actuator 健康检查部分依赖(Redis/RabbitMQ)在未启动时返回 DOWN

112
docs/backend-development-roadmap.md

@ -1,4 +1,30 @@
# 职来后端后续开发规划与 Figma 差距修复方案
# 职来后端一期交付路线图
> 当前范围冻结:2026-09-08。当前一期范围、需求、代码入口、验收及责任的唯一明细为 [一期范围与验收清单](phase1-scope-and-acceptance.md)。下方历史规划不再用于判断当前待办或批准新接口/迁移。
## 当前交付状态(覆盖历史描述)
| 范围 | 2026-09-08核对结果 | 下一动作 |
|---|---|---|
| 核心招聘与账户能力 | 已有代码与历史验收记录;本轮未重跑验收 | 按清单C01–C08做一期集成回归 |
| V39–V42 | 已核对提交55e3975、2698d06、5cc2c86、96b3113;不再标为待Git检查点 | 保留回归,不重复开发企业回复/发现/邀约 |
| V43 | 独立代码复验、真实 MySQL/Docker 验收及 Git 检查点已完成(cf39737) | N01 已关闭版本内小样本补集泄露;30/300/3000 查询预算通过,EXPLAIN 扩容风险保留观察 |
| 评分规则解释与合理性 | N02 独立代码复验、真实 MySQL/Docker 验收及 Git 检查点已完成(dbd27e6) | 保持分数、权重、排名和企业评分契约不变 |
| 消息撤回与会话一致性 | N07 定向整改完成并通过自动化与干净构建,待独立代码复验与真实 MySQL/Docker 验收 | 两分钟窗口、发送者权限、正文覆盖、幂等并发、DIRECT/邀约隐私均已锁定 |
| S01 认证安全整改 | 独立代码复验与真实 MySQL/Docker 黑盒全部通过(`b5084fd`) | 保持验证码受控取码、双开关与 `/jobs` 认证边界不变 |
| S02 Access/Refresh Token 隔离 | 已实现并完成自动化验证与干净构建,待独立代码复验及真实 MySQL/Docker 黑盒验收 | 类型隔离、独立密钥、轮换、重放处置、会话吊销已锁定;OpenAPI 175/213 |
| 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 |
| 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`(**已提交并推送,提交 `84fe9f5`**;报告 `docs/r01-a6-fairness-fixture-isolation-fix-20260917.md`)。**R01-B 角色授权与异常映射收束已完成独立复验、提交并推送**(提交 `70b9e10`;两个 Controller 测试夹具缺陷已由独立纯测试提交 `9954600` 修复,报告 `docs/r01-b-authorization-exception-mapping-report.md`、`docs/r01-b-independent-verification-20260918.md`):新增 `security/UserRoleGuard` 与 `common/exception/ErrorHttpStatusRegistry`,54 个调用点改走公共 guard,GlobalExceptionHandler 不再持有映射表。**R01-C 评分与推荐规则结构收束已实施完成,待独立代码复验**(报告 `docs/r01-c-scoring-recommendation-rules-refactor-report.md`):overviewStatus 经核验已是唯一实现(由 `b68519e` 完成);`JobRecommendationService.preferenceScore()` 已是编排方法;本轮新增 `candidatescore/rules/CandidateScoringRuleSet`(版本化强类型规则源,高校名单/阈值/权重),并把 `TalentDiscoveryService` 内联的第二套 30/20/20/15/15 偏好规则收束到 `JobPreferenceScoreCalculator`(规则实现处 2 → 1)。**评分/Fairness 组合测试污染已关闭**:`CandidateScoring*` 中 5 个测试类以当前 period 物化快照且未清理自有 userId,污染了 `FairnessMonitoringHttpIntegrationTest` 的 `populationSize`(+15 精确组成为 3+2+1+1+8);已按行级快照实测逐类归因并精确清理(5 个候选类经证明零残留未改),新增隔离门禁 `R01CScoringFixtureIsolationGateTest`;`*CandidateScoring*Test,*Fairness*Test` 双向顺序各 3 次均 260/260,生产代码 diff = 0(报告 `docs/r01-c-fairness-fixture-isolation-fix-20260918.md`);下一阶段为 R01-E |
| 其余五个功能组 | 技能词云、入职清单/公司知识、薪资计算器/参考、诚信积分/激励、简历查重 | N03–N06、N08仍属一期,细节设计后编码 |
| 上线保障 | 现有资产待逐项核验 | G01–G04,一期交付门槛 |
| 后续候选池 | AI简历、AI模拟面试/预测、SHAP高级解释等 | 以清单第6节为准,移出一期 |
当前代码静态OpenAPI记录为172 paths/210 operations;已交付V42基线为167/205。最新迁移仍为V40,V43不新增迁移。此前1514测试通过属于历史执行记录,不能替代本轮验收。职责:张顺主责N01/N02/N07/N08与业务验收,小刘主责N03–N06与交付环境,互相独立复核。
正式路线图只维护此处;`mydreamjob-server/docs/backend-development-roadmap.md`仅为跳转页。八个新增/待闭环功能组加四个交付工作包详见新清单,不以历史“P2/后置”建议将用户本轮确认的功能排除一期。
## 历史规划存档(原文保留,以下状态、建议编号与范围均非当前依据)
> 文档状态:已确认,作为后续开发排期依据
>
@ -26,21 +52,23 @@
| 项目 | 当前状态 |
|---|---|
| OpenAPI | **139 paths、178 operations**(N05 新增 4 paths / 6 operations) |
| Flyway | **V1–V41** 已存在(N04 新增 V38–V39;N05 新增 V40–V41) |
| 全量测试 | **1516 个执行:1505 通过、2 失败、9 错误**;失败仍集中在既有 CareerProgress、JobReviewDimension、LocalResumeStorage 测试,薪资专项 24/24 通过 |
| 已完成范围 | V24 Candidate Scoring、V25 Governance、V26 Offer Advance、V27 Privacy、V28/V29 Account Deletion、V30 Data Export、V31 Interview Score、V32 Competition Ranking、V33 Report/Block、V34 统一求职进度聚合,N04 入职清单与公司知识介绍,以及 N05 薪资计算器与历史记录;另含认证、资料、岗位、投递、面试、Offer、看板等核心闭环 |
| 当前结论 | N05 薪资计算器已完成代码、24/24 专项测试、OpenAPI/文档更新和一次性 Docker MySQL 黑盒验收;全量测试仍受既有非薪资模块失败阻塞,N04 仍待真实 MySQL/Docker 验收 |
| OpenAPI | **167 paths、205 operations**(V42 静态、运行时与校验脚本已对账) |
| Flyway | **V1–V40 已成功执行**;V40 为企业人才邀约迁移(功能版本 V42,H2 与真实 MySQL 8.4 均已验证) |
| 全量测试 | V42 全量自动化测试 **1475/1475** 通过;真实 MySQL/Docker 黑盒 `PASS=76/FAIL=0`(含单岗位每日 20 次与公司每日 100 次限额) |
| 已完成范围 | V24 Candidate Scoring、V25 Governance、V26 Offer Advance、V27 Privacy、V28/V29 Account Deletion、V30 Data Export、V31 Interview Score、V32 Competition Ranking、V33 Report/Block、V34 统一求职进度聚合、**V35 RIASEC 职业兴趣测评(Flyway V34,已真实验收)**、**V36 四维能力测评(Flyway V35)**、**V37 防刷风险概览与运营规则配置(Flyway V36)**、**V38 四维能力测评有限接入候选评分(无新 Flyway、无新公网接口)**,及认证、资料、岗位、投递、面试、Offer、看板等核心闭环 |
| 当前结论 | V35–V41 的既有验收结论保持不变。**V42 企业人才邀约与授权沟通已完成代码、自动化测试、静态/运行时 OpenAPI 对账与真实 MySQL/Docker 黑盒验收,待 Git 检查点。** 当前 OpenAPI 为 167/205。 |
| 本规划范围 | 代码修复、新功能开发、接口契约、数据权限和治理能力 |
### 2.0 模块状态分类
| 类别 | 模块 |
|---|---|
| 已完成并已验收 | V24 Candidate Scoring、V25 Governance、V26 Offer Advance、V27 Privacy、V28/V29 Account Deletion、V30 Data Export、V31 Interview Score、V32 Competition Ranking、V33 Report/Block、V34 统一求职进度聚合、N05 薪资计算器(一次性 Docker MySQL 黑盒通过) |
| 已完成、待真实 MySQL/Docker 验收 | N04 入职清单与公司知识介绍(V38/V39,9 个接口;专项测试 96/96) |
| 已设计未编码 | 测评系统(Holland/能力测评)、公平性检测、SHAP/规则解释、防刷审计、企业评价管理、企业人才推荐 |
| 未开始 | AI 简历诊断/优化、AI 模拟面试、行业技能词云、诚信积分、消息撤回 |
| 已完成并已验收 | V24 Candidate Scoring、V25 Governance、V26 Offer Advance、V27 Privacy、V28/V29 Account Deletion、V30 Data Export、V31 Interview Score、V32 Competition Ranking、V33 Report/Block、V34 统一求职进度聚合、**V35 RIASEC 职业兴趣测评(Flyway V34,PASS=39/FAIL=0/SKIP=1)**、**V36 四维能力测评(Flyway V35,PASS=93/FAIL=0/SKIP=0)**、**V37 防刷风险概览与运营规则配置(Flyway V36,PASS=76/FAIL=0/SKIP=0)**、**V38 四维能力测评有限接入候选评分(无新 Flyway/公网接口,真实 MySQL/Docker 关键黑盒通过,待 Git 检查点)**、**V39 测评驱动岗位推荐(Flyway V37,真实 MySQL/Docker 关键黑盒通过,待 Git 检查点)** |
| 已完成、待真实 MySQL/Docker 复验 | — |
| 真实黑盒已验收、待 Git 检查点 | **V40 企业评价管理与官方回复(Flyway V38,5 paths / 6 operations,PASS=62/FAIL=0/SKIP=0)**;**V41 企业人才发现推荐(Flyway V39,2 paths / 3 operations,PASS=37/FAIL=0)**;**V42 企业人才邀约与授权沟通(Flyway V40,6 paths / 6 operations,PASS=76/FAIL=0;不写 candidate_dimension_scores)** |
| 自动化完成、待真实黑盒 | 无 |
| 已设计未编码 | 公平性检测、SHAP/规则解释、企业人才推荐 |
| 未开始 | AI 简历诊断/优化、AI 模拟面试、行业技能词云、入职准备清单、薪资计算器、诚信积分、消息撤回 |
### 2.1 已确认的产品决策
@ -76,10 +104,7 @@
- 简历版本管理和简历导出;
- 求职者评分卡;
- Candidate Scoring Engine;
- 评分申诉、审核和回滚;
- 入职清单:通用项与企业自定义项合并、个人完成状态和完成率;
- 公司知识介绍:企业资料、企业知识内容和公开评价摘要;
- 薪资计算器:城市政策、用户覆盖参数、到手工资估算和历史快照。
- 评分申诉、审核和回滚。
### 3.2 企业端
@ -92,9 +117,7 @@
- Offer 管理;
- 面试管理;
- 评价审核基础能力;
- 双向匹配、通知和直聊;
- 本企业入职清单自定义项管理;
- 本企业公司知识内容维护。
- 双向匹配、通知和直聊。
## 4. Figma 与后端覆盖结论
@ -119,8 +142,6 @@
| 企业候选人看板 | `/company/candidates`、`/company/candidates/{id}` | 已完成 |
| 企业评分看板 | `/company/scoring/dashboard/*` | 已完成 |
| 企业认证 | `/companies/me/verification`、`/admin/companies/verifications*` | 已完成 |
| 入职清单与公司知识介绍 | /onboarding/**(9 个接口) | **实现完成,待真实 MySQL/Docker 验收** |
| 薪资计算器 | /salary-calculator/**(6 个接口) | **实现完成,待真实 MySQL/Docker 验收** |
### 4.2 部分完成或口径不一致
@ -132,23 +153,24 @@
| HR 评价详情 | `candidate_feedback` 已实现 | 不是结构化面试评分 |
| 企业评价管理 | 有评价审核和管理员决策 | 缺企业端评价管理、回复和运营动作 |
| 企业人才推荐 | 有候选人列表和评分 | 缺推荐排序、推荐理由和可解释结果 |
| 运营规则配置 | Figma 有页面 | 后端暂无规则配置接口 |
| 防刷审计报告 | 已有评价审核基础能力 | 不是风险等级、风险趋势、风险分布和事件审计报告 |
| 运营规则配置 | V37 `GET/PUT /admin/scoring/risk/rules` 已实现 | 仅 ADMIN;当前为确定性拒绝型安全审计规则,不做自动处置 |
| 防刷审计报告 | V37 风险概览、趋势、分布、事件与人工状态操作已实现 | 仅 ADMIN;不返回主体身份、原始审计内容、IP/UA 或凭据 |
### 4.3 完全缺失
- 霍兰德职业兴趣测评;
- 四维能力测评;
- 测评结果和测评岗位推荐;
- ~~霍兰德职业兴趣测评~~(已由 V35 实现:测评 + 结果 + 探索方向,Flyway V34);
- ~~四维能力测评~~(已由 V36 实现:限时答题、进度保存、结果、短板和历史,Flyway V35);
- ~~测评岗位推荐~~(V39 已完成:结构化岗位画像、受控分类、测评驱动推荐、反馈与审核);
- AI 简历诊断和优化;
- AI 模拟面试和面试题预测;
- 行业技能词云;
- 薪资参考和行业薪资大数据;
- 入职准备清单和公司知识介绍;
- 薪资计算器和薪资参考;
- 公平性检测;
- SHAP/规则解释;
- ADMIN 防刷审计和规则配置;
- ~~ADMIN 防刷审计和规则配置~~(已由 V37 实现:仅 ADMIN 的确定性风险概览、规则配置和人工处置,Flyway V36);
- 诚信积分和激励。
- 已非缺失(已实现):个人数据导出(V30)、隐私设置(V27)、账号注销(V28/V29)、举报与屏蔽(V33)、结构化面试评分(V31)、同岗位竞争力排名(V32)、统一求职进度聚合(V34)、入职清单和公司知识介绍(N04)、薪资计算器与历史记录(N05)。
- 已非缺失(已实现):个人数据导出(V30)、隐私设置(V27)、账号注销(V28/V29)、举报与屏蔽(V33)、结构化面试评分(V31)、同岗位竞争力排名(V32)、统一求职进度聚合(V34)、RIASEC 职业兴趣测评(V35)、四维能力测评(V36)、ADMIN 防刷审计与规则配置(V37)、四维能力测评有限接入候选评分(V38,仅 ABILITY,GROWTH 仍未接入)。
## 5. 已有代码的优先修复项
@ -344,12 +366,11 @@ jobs
开发顺序:
1. 霍兰德职业兴趣测评;
2. 四维能力测评;
3. 测评答题和进度保存;
4. 测评结果;
5. 测评结果进入 ABILITY/GROWTH;
6. 根据测评结果推荐岗位。
1. ~~霍兰德职业兴趣测评~~(V35 已完成);
2. ~~四维能力测评、答题和进度保存~~(V36 已完成);
3. ~~测评结果~~(V35/V36 已完成);
4. 测评结果进入 ABILITY/GROWTH(V38 已完成 ABILITY 有限接入;GROWTH 仍需独立评审与后续实现);
5. ~~根据测评结果推荐岗位~~(V39 已完成)。
建议接口:
@ -380,7 +401,7 @@ V30__assessment.sql
### 阶段 5:公平性、SHAP、反刷审计
#### 防刷审计
#### 防刷审计(V37 已完成)
仅 ADMIN 可见:
@ -389,8 +410,9 @@ GET /admin/scoring/risk/overview
GET /admin/scoring/risk/trend
GET /admin/scoring/risk/distribution
GET /admin/scoring/risk/events
GET /admin/scoring/rules
PUT /admin/scoring/rules/{id}
GET /admin/scoring/risk/rules
PUT /admin/scoring/risk/rules/{ruleCode}
POST /admin/scoring/risk/events/{eventId}/actions
```
禁止返回原始手机号、IP、设备指纹、密码、Token 等敏感信息。
@ -401,6 +423,7 @@ PUT /admin/scoring/rules/{id}
scoring_risk_events
scoring_rule_configs
scoring_risk_actions
scoring_risk_scan_checkpoints
```
#### 公平性检测
@ -446,6 +469,8 @@ scoring_risk_actions
- AI 模拟面试;
- 面试题预测;
- 行业技能词云;
- 入职准备清单;
- 公司知识介绍;
- 简历查重;
- 诚信积分;
- 消息撤回。
@ -460,15 +485,14 @@ scoring_risk_actions
| P0 | 个人数据导出 |
| P0 | 结构化面试评分 |
| P0/P1 | 同岗位竞争力排名 |
| P1 | 霍兰德和四维能力测评 |
| P1 | 测评结果进入评分引擎、测评岗位推荐(V38/V39 已完成) |
| P1 | 统一求职进度(V34 已完成) |
| P1 | 企业评价管理 |
| P1 | 企业人才推荐 |
| P1 | 公平性检测 |
| P1 | ADMIN 防刷审计 |
| P1 | ~~企业评价管理~~(V40 自动化、`clean verify` 与真实 MySQL/Docker 黑盒已通过,待 Git 检查点) |
| P1 | ~~企业人才推荐~~(V41 已实现;新增 V39 授权迁移,自动化 1461/1461、真实 MySQL/Docker 黑盒 PASS=37/FAIL=0,待 Git 检查点) |
| P1 | V43历史条目:申诉可比分母与版本内部补集泄露已修复并通过独立代码复验、真实 MySQL/Docker 与 30/300/3000 查询预算验收;待 Git 检查点,以顶部状态与一期清单N01为准 |
| P2 | SHAP 高级解释 |
| P2 | AI 简历、AI 模拟面试 |
| P2 | 技能词云、入职清单(N04 实现完成,待验收)、诚信积分 |
| P2 | 技能词云、入职清单、诚信积分 |
## 8. 每个模块的统一交付要求
@ -533,7 +557,3 @@ CandidateFeedback 当前响应包含 companyUserId、studentUserId 等内部身
- 新增未批准的 Flyway;
- 删除或清理业务数据;
- 启动或停止 Docker。

21
docs/candidate-scoring-api.md

@ -1,6 +1,7 @@
# 求职者评分体系 API
> 求职者评分体系(Candidate Scoring Engine)v0.1 · 规则版本 `candidate-scoring-v0.1`
> V38 起:功能开关 `scoring.ability-assessment.enabled`(默认 `false`)开启时,`ABILITY` 按 V36 四维能力测评的最新有效结果映射,候选评分规则版本升级为 `candidate-scoring-v0.2`;关闭时保持 `candidate-scoring-v0.1` 与既有 `ABILITY=PENDING` 行为。
> 基础路径:`/api/v1` · 全部接口需 `Authorization: Bearer <accessToken>`
## 概述
@ -19,7 +20,7 @@
| `OK` | 输入齐备,按规则确定性计算 |
| `ESTIMATED` | 部分输入缺失,使用兜底值并标注估算(如学校未知、偏好字段部分填写) |
| `INSUFFICIENT_DATA` | 关键输入缺失,不计算(score=null) |
| `PENDING` | 数据源未就绪(INTERNSHIP 无实习表、ABILITY 无测评),score=null |
| `PENDING` | 数据源未就绪(INTERNSHIP 无实习表;ABILITY 无测评、仅有过期/结构异常结果或开关关闭),score=null |
综合卡 `status`:全部 8 维可计算→`OK`;部分可计算→`PARTIAL`;无可计算且存在 PENDING→`PENDING`;否则→`INSUFFICIENT_DATA`。
@ -27,11 +28,13 @@
### 1. GET `/profile/me/candidate-scoring`(仅 STUDENT 本人)
返回综合评分卡:`candidateId`、`status`、`overallScore`、`weights`、`dimensions[]`、`rulesVersion`、`generatedAt`。
返回综合评分卡:`candidateId`、`status`、`overallScore`、`weights`、`dimensions[]`、`rulesVersion`、`generatedAt`,以及解释版本 `explanationVersion`、参与覆盖 `coverage` 和固定可比性提示 `comparabilityNotice`。`coverage.computableDimensions` 与实际参与 overallScore 的维度严格一致,`excludedDimensions` 列出未参与原因。
### 2. GET `/profile/me/candidate-scoring/dimensions/{dimension}`(仅 STUDENT 本人)
返回单维度明细:`dimension`、`score`、`status`、`subScores[]`、`reasons[]`、`dataSources[]`、`updatedAt`、`rulesVersion`。非法 `dimension` → 400/1001。
返回单维度明细:原有 `dimension`、`score`、`status`、`subScores[]`、`reasons[]`、`dataSources[]`、`updatedAt`、`rulesVersion`,以及 `displayName`、`whatItMeasures`、`interpretation`、`limitations[]`、`evidenceLevel`、`ruleContributions[]`、`improvementActions[]`、`explanationVersion`、`validity`(仅 ABILITY 有明确有效期)和 `calculatedAt`。非法 `dimension` → 400/1001。
> 学生单维明细(仅 STUDENT 本人)。解释版本固定为 `candidate-scoring-explanation-v1`,与评分 `rulesVersion` 分离。`ruleContributions` 由真实计分分支产生,贡献之和可与最终维度分数对账;`SCORE_CAP_ADJUSTMENT` 用于说明 0–100 截断,`SCORE_ROUNDING_ADJUSTMENT` 仅记录逐项展示舍入与最终两位小数的对账差,不改变评分规则。`dataSources` 仅使用“个人教育资料”“求职偏好”“平台求职活动”“简历与技能资料”“Offer 履约与候选反馈”“四维能力测评”等产品语义名称。**不返回** `sourceResultId`、答题/答卷 ID、题目、答案、答案键、选项分值、`competencyTag`、数据库表名、完整内部 `sub_scores_json`。其他维度响应与既有保持兼容。非法 `dimension` → 400/1001。
### 3. GET `/profile/me/candidate-scoring/trend?months=6`(仅 STUDENT 本人)
@ -39,7 +42,7 @@
### 4. GET `/company/candidates/{applicationId}/scoring`(仅岗位所属企业)
企业只读评分卡。**仅返回** `applicationId`、`overallScore`、`status`、`dimensions[]{dimension,score,status}`、`generatedAt`、`rulesVersion`;**禁止返回** `studentUserId`、`candidateId`、`phone`、`email`、`resumeUrl`、`sub_scores` 细节、内部日志。
企业只读评分卡。**仅返回** `applicationId`、`overallScore`、`status`、`dimensions[]{dimension,score,status}`、`generatedAt`、`rulesVersion`;**禁止返回** `studentUserId`、`candidateId`、`phone`、`email`、`resumeUrl`、`sub_scores` 细节、内部日志。V38 起等效于对 `ABILITY` 只暴露聚合 `score/status`,**禁止**返回 LOGIC/COMMUNICATION/EXECUTION/LEARNING 细分、`validUntil`、attemptId/resultId、`answerKey`、`scoringRule`、`questionCode`、`competencyTag`。
## 权限矩阵
@ -56,7 +59,7 @@
|---|---|
| EDUCATION | student_profiles.education/school(学校档次用内置名单,未知→ESTIMATED 兜底 50) |
| INTERNSHIP | 数据源未就绪 → PENDING |
| ABILITY | 能力测评未实现 → PENDING |
| ABILITY | 默认 `PENDING`;开关开启且有最新有效四维能力测评(WORKPLACE_ABILITY,COMPLETED、答题 24/24、`valid_until > capturedNow`)时按 `LOGIC×0.35 + EXECUTION×0.30 + LEARNING×0.20 + COMMUNICATION×0.15`(scale=2、HALF_UP)映射为 OK |
| COMPLIANCE | student_preferences(薪资/城市/用工/行业/岗位类型 5 字段加权;remoteAllowed 不计分) |
| STABILITY | users.createdAt(账号年龄)、applications(投递/撤回率)、audit_logs(近 30 天 LOGIN) |
| GROWTH | resume_versions(ACTIVE 数量/最近更新)、student_profiles.skillsJson(当前值,无历史) |
@ -76,3 +79,11 @@
## 错误码
复用:`BAD_REQUEST(1001)`、`UNAUTHORIZED(2001)`、`FORBIDDEN(2002)`、`APPLICATION_NOT_FOUND(8002)`、`APPLICATION_OWNER_MISMATCH(8004)`。不新增错误码。
## N02 解释与合理性边界
- 本版只增强学生可见解释,不改变八维公式、12.50 默认权重、参与维度、`candidate-scoring-v0.1/v0.2` 切换、V32 排名或快照写入语义。
- `INTERNSHIP` 未接入时保持 `PENDING`,不显示为 0 分;缺失维度仍退出综合分分母,综合卡通过 `coverage` 和 `comparabilityNotice` 明确提示不同参与集合不可直接横向比较。
- 规则解释为固定白名单模板,不接入 AI/LLM/SHAP,不把教育、平台行为、履约或活跃信号解释为人格、信用、确定性能力或录用结论。
- 企业评分接口 schema 保持不变,只返回既有聚合字段;解释、覆盖率、限制、建议、规则贡献和 ABILITY 有效期仅对学生本人可见。
- `Clock` 在每次评分请求入口捕获一次 `capturedNow`,并用于时间窗口、ABILITY 有效期、`calculatedAt` 与快照时间;真实 MySQL/Docker 验收仍为本版发布前置条件。

302
docs/candidate-scoring-explanation-design.md

@ -0,0 +1,302 @@
# N02 候选评分规则解释与合理性:详细设计
状态:N02 独立代码复验与真实 MySQL/Docker 黑盒验收通过,待 Git 检查点。
文档类型:解释。目标读者为产品负责人、后端开发、独立验收人员和前端联调人员。本设计说明现有候选评分实际上计算了什么、一期应如何向学生解释,以及哪些结论不得从这些分数推出。
## 1. 背景与目标
当前候选评分由 `CandidateScoringCalculator` 计算八个维度,并由 `CandidateScoringEngineService` 写入或读取 `candidate_dimension_scores`。学生单维接口已有 `subScores`、`reasons` 和 `dataSources`,但这些信息由各计算方法临时拼装,缺少稳定的规则编码、解释版本、适用范围和限制说明。
N02 的一期目标是让学生能够回答以下问题:
1. 这个分数使用了哪些数据;
2. 哪些确定性规则实际命中;
3. 每条规则对当前结果产生了什么影响;
4. 哪些数据缺失、过期或仍未接入;
5. 用户可以通过哪些真实、合理的方式完善资料;
6. 该分数不能代表什么。
N02 同时要求企业端继续只查看聚合分与状态,不获得学生的私人明细、行为次数、测评细分或改进建议。
## 2. 非目标
一期不做:
- SHAP、机器学习特征贡献或 AI 生成解释;
- 人口属性公平性推断;
- 宣称规则已具备心理测量效度或预测效度;
- 自动修改评分权重、排名或推荐逻辑;
- 新增评分事实表、解释历史表或规则配置后台;
- 向企业暴露学生的细分评分证据;
- 将行为频次解释为人格、诚信、成长潜力或职业能力;
- 用解释功能引导用户刷投递、刷消息、刷简历版本。
## 3. 当前真实实现
### 3.1 代码入口
- 计算:`mydreamjob-server/src/main/java/com/mydreamjob/candidatescore/CandidateScoringCalculator.java`
- 服务:`CandidateScoringEngineService.java`
- 学生与企业接口:`CandidateScoringController.java`
- 快照:`CandidateDimensionScore.java`、`CandidateDimensionScoreRepository.java`
- 能力映射:`AbilityAssessmentScoreSource.java`、`AbilityScoreMapper.java`
- 申诉与回滚:`candidatescore/governance/`
- 排名:`competition/CompetitionRankingCalculator.java`
### 3.2 综合分
当前八维默认展示权重均为 12.50。只有状态为 `OK` 或 `ESTIMATED` 且分数非空的维度参与综合分:
```text
overallScore = sum(可计算维度分数) / 可计算维度数量
```
最终保留两位小数,`HALF_UP`。`PENDING` 和 `INSUFFICIENT_DATA` 被排除后重新归一化,因此两个相同综合分可能来自不同的维度集合。解释必须展示参与维度数量与排除维度,禁止把综合分描述为完整、统一口径的能力总分。
### 3.3 规则版本
- 能力测评开关关闭:`candidate-scoring-v0.1`;
- 能力测评开关开启:`candidate-scoring-v0.2`;
- ABILITY 映射规则:`ability-assessment-scoring-v0.1`;
- 历史趋势优先使用快照自身 `rulesVersion`。
N02 只增加解释契约时不升级候选评分规则版本,建议新增独立的 `explanationVersion=candidate-scoring-explanation-v1`。只有公式、阈值、参与维度或排名输入发生变化时才升级评分规则版本。
## 4. 设计原则
### 4.1 解释必须与计算同源
每个计分分支在计算贡献值的同时生成内部 `RuleContribution`,不能在 Controller、DTO mapper 或另一套公式中根据最终分数反推解释。
建议内部结构:
```text
RuleContribution
- ruleCode 稳定规则编码
- outcome APPLIED / NOT_APPLIED / MISSING / EXPIRED / UNAVAILABLE
- contribution 实际加减分;不适用时为 null
- maxContribution 该规则可能贡献的绝对上限
- publicLabel 面向学生的中性名称
- publicObservation 安全的区间或状态,不输出内部数据库字段
- explanationKey 固定解释模板编码
```
规则元数据由 `CandidateScoringExplanationCatalog` 按 `ruleCode` 提供名称、限制、建议和证据等级。计算器产生“事实和贡献”,目录提供“稳定语义”,Assembler 只组合,不重复计算。
当逐项贡献按两位小数展示而整体分数采用 HALF_UP 产生舍入差时,使用
`SCORE_ROUNDING_ADJUSTMENT` 显式记录对账差;它只解释展示精度,不改变任何评分公式。
### 4.2 不把解释做成刷分指南
学生可以看到区间和缺失项,例如“近 90 天存在投递记录”“求职偏好尚未完整”,但默认不暴露风控阈值组合、审计动作名称、原始消息数量或内部 SQL 字段。已经在界面契约中公开的固定能力权重可继续展示。
### 4.3 区分事实、推断与限制
每个维度必须有:
- `whatItMeasures`:规则直接使用的数据;
- `interpretation`:可谨慎解释的含义;
- `limitations`:不能据此得出的结论;
- `evidenceLevel`:`DIRECT_RULE`、`SELF_REPORTED`、`BEHAVIOR_PROXY`、`UNVALIDATED_PROXY`;
- `improvementActions`:只给可合理完成的操作,不鼓励刷行为。
## 5. 一期响应方案
### 5.1 路径范围
保持现有 4 个路径,不新增 operation:
- `GET /profile/me/candidate-scoring`
- `GET /profile/me/candidate-scoring/dimensions/{dimension}`
- `GET /profile/me/candidate-scoring/trend`
- `GET /company/candidates/{applicationId}/scoring`
OpenAPI 路径/操作数保持当前基线 172/210。
### 5.2 学生综合卡兼容扩展
建议新增:
- `explanationVersion`;
- `coverage.computableDimensions`;
- `coverage.totalDimensions=8`;
- `coverage.includedDimensions`;
- `coverage.excludedDimensions[]{dimension,status,reason}`;
- `comparabilityNotice`,固定说明不同参与维度集合的综合分不宜直接比较。
不得返回内部快照 ID、测评结果 ID、题目、答案、审计详情、消息正文或数据库字段名。
### 5.3 学生单维详情兼容扩展
保留现有 `subScores/reasons/dataSources`,新增:
- `displayName`;
- `whatItMeasures`;
- `interpretation`;
- `limitations[]`;
- `evidenceLevel`;
- `ruleContributions[]`;
- `improvementActions[]`;
- `explanationVersion`;
- `validity`:仅在存在明确有效期时返回,例如 ABILITY;
- `calculatedAt`:本次真实计算基准时间。
`ruleContributions` 只面向学生本人。旧字段在一期保留,避免前端破坏性升级;文档标记旧 `reasons/dataSources` 后续逐步由结构化字段替代。
### 5.4 企业端
企业端保持现有字段:`applicationId/overallScore/status/dimensions/generatedAt/rulesVersion`。不新增 `ruleContributions`、`limitations`、行为次数、学校分档、资料缺失项、四维能力细分或有效期。
企业页面可增加平台级固定说明,但不得把学生解释 DTO 直接复用到企业响应。
## 6. 时间一致性
`computeAll` 应在入口生成一次 `capturedNow`,并传递给所有时间敏感查询与规则:
- 90 天投递;
- 30 天登录和活跃操作;
- 账号月龄;
- 简历 90 天更新;
- ABILITY 有效期;
- `calculatedAt` 和数据源时间说明。
不得在同一次计算中多次调用 `LocalDateTime.now()` 形成不同边界。建议注入 `Clock`,生产使用系统时钟,测试使用固定时钟。
## 7. 八维解释口径
| 维度 | 一期允许的名称 | 允许解释 | 禁止解释 |
|---|---|---|---|
| EDUCATION | 教育背景信息 | 已填写学历与学校分类规则的确定性结果 | 智力、能力上限、人才质量 |
| INTERNSHIP | 实习经历信息 | 当前数据源尚未接入 | 无实习能力、经验不足 |
| ABILITY | 四维能力测评 | 最新有效自研测评的规则化聚合 | 确定性职业能力、招聘结论 |
| COMPLIANCE | 求职偏好完整度 | 五类偏好字段填写情况 | 合规意识、服从性 |
| STABILITY | 平台求职行为稳定信号 | 账号时长、撤回与登录形成的行为代理 | 人格稳定、忠诚度 |
| GROWTH | 简历维护与技能展示信号 | 简历维护和技能标签状态 | 成长潜力、学习速度 |
| INTEGRITY | 履约与资料一致性信号 | Offer 响应、反馈与资料完整度的行为代理 | 道德、诚信人格、信用评级 |
| ACTIVITY | 平台求职活跃度 | 近期投递、平台操作和沟通信号 | 工作积极性、职业投入度 |
维度代码为兼容字段,本版不改数据库枚举。产品展示名称采用上述中性名称。
## 8. 合理性整改分级
### 8.1 仅解释整改即可上线
- ABILITY:保留现有公式和有效期,补自评/情境测验限制;
- INTERNSHIP:保持 PENDING,明确“未接入”不是低分;
- 综合分:补参与维度和不可横向比较提示。
### 8.2 需要改名但暂不改公式
- COMPLIANCE → 求职偏好完整度;
- STABILITY → 平台求职行为稳定信号;
- GROWTH → 简历维护与技能展示信号;
- INTEGRITY → 履约与资料一致性信号;
- ACTIVITY → 平台求职活跃度。
这里只修改展示语义,不改变维度代码、快照或历史接口。
### 8.3 需要后续规则版本评审
- EDUCATION 的学校名单及 45% 权重;
- STABILITY 的账号时长奖励和撤回惩罚;
- GROWTH 的简历版本数量奖励;
- INTEGRITY 的 Offer 接受奖励、过期惩罚和反馈评分;
- ACTIVITY 的投递、审计和消息计数可操纵性;
- 缺失维度重新归一化后仍进入企业评分与排名。
任何一项改变分数都必须升级评分版本、完成新旧结果对账和 V32 排名回归,不能混入纯解释提交。
## 9. 权限与隐私
学生本人可以查看自身安全聚合解释。企业只能查看现有聚合评分卡。ADMIN 沿用治理接口,不因 N02 获得新的个人解释查询入口。
所有 HTTP 响应禁止新增:
- 原始审计动作、IP、User-Agent;
- 消息正文或对话对象;
- Offer 具体企业、岗位或时间线;
- 评价正文与评价人身份;
- assessment/ability 的题目、答案、答案键、评分规则和结果 ID;
- 内部 SQL、表名、快照 ID、用户 ID;
- 密码、Token、密钥。
学生响应中的数据来源改为产品语义名称,如“个人教育资料”“求职偏好”“平台求职活动”,不直接返回 `student_profiles.education/school` 等物理字段名。
## 10. 实现建议
建议新增内部类:
- `CandidateScoringExplanationCatalog`:规则元数据白名单;
- `CandidateScoringExplanationAssembler`:将 `DimensionResult` 与规则元数据组装为学生 DTO;
- `RuleContribution`、`ScoreCoverage` 等内部/DTO 类型。
修改:
- `CandidateScoringCalculator`:统一 `capturedNow/Clock`,每个实际分支同步生成贡献;
- `CandidateScoringEngineService`:组装覆盖率与学生解释;
- 学生 DTO:兼容新增字段;
- OpenAPI、校验脚本、API 和联调文档。
原则上无新 Flyway、无新 ErrorCode、无新公网路径。解释元数据先随代码版本发布,不引入可在线修改规则的后台。
## 11. 测试与验收
### 11.1 规则同源
- 每个阈值边界独立手算;
- `contribution` 之和与真实维度分形成可证明关系;
- 未命中、缺失、过期和不可用规则不能伪造成 0 分命中;
- 固定 Clock 验证 30/90 天和有效期边界;
- 不允许修改解释而改变分数,或修改分支后遗漏解释。
### 11.2 综合分
- 0、1、2、8 个可计算维度;
- `OK/ESTIMATED` 参与,其他状态排除;
- coverage 与实际参与集合完全一致;
- 学生综合卡、趋势、企业评分和 V32 排名与既有纯函数对账。
### 11.3 权限与脱敏
- STUDENT 仅查看本人;
- COMPANY 只见聚合字段;
- ADMIN 不获得新增学生详情入口;
- 匿名 401,角色越权 403/2002;
- 对完整 JSON 递归扫描禁止字段;
- ABILITY 不泄露题目、答案或来源结果 ID。
### 11.4 防刷与边界评审
用固定样例证明重复投递、发消息、制造简历版本可能改变哪些分数,并在本版明确标为未验证代理信号。若决定保留其排名影响,必须形成书面产品决策和监测指标。
### 11.5 发布验证
- N02 专项、候选评分、治理、V32、V38、V39、V41、V42、V43 回归;
- `mvn clean verify`;
- 静态与运行时 OpenAPI 对账;
- 真实 MySQL 主链路和响应脱敏;
- 查询前后快照写入行为保持既有语义;
- 无新增 RabbitMQ 消息。
## 12. 编码前决策表
| 编号 | 决策项 | 建议方案 | 状态 |
|---|---|---|---|
| D01 | N02 是否只做解释,不改任何现有分数、权重和排名 | 是;规则合理性问题记录为后续独立评分版本 | 已确认 |
| D02 | 是否保持现有 4 个接口、0 个新增 path/operation | 是 | 已确认 |
| D03 | 是否无新 Flyway、无规则配置后台 | 是 | 已确认 |
| D04 | 是否增加独立 `explanationVersion`,不升级评分 `rulesVersion` | 是,值为 `candidate-scoring-explanation-v1` | 已确认 |
| D05 | 是否兼容扩展学生综合卡,展示参与/排除维度和可比性提示 | 是 | 已确认 |
| D06 | 是否兼容扩展学生单维详情,增加结构化规则贡献、限制和建议 | 是 | 已确认 |
| D07 | 企业端是否保持现有 schema,不展示任何规则贡献与私人细分 | 是 | 已确认 |
| D08 | 是否采用中性展示名,但保持八个维度代码不变 | 是 | 已确认 |
| D09 | 是否明确 `INTEGRITY` 不能解释为人格诚信或信用评级 | 是 | 已确认 |
| D10 | 是否明确 `GROWTH/STABILITY/COMPLIANCE/ACTIVITY` 仅为平台数据代理 | 是 | 已确认 |
| D11 | 是否用单次 `capturedNow` 并注入 `Clock` 修复解释时间一致性 | 是,不改变阈值与公式 | 已确认 |
| D12 | 学生是否只看安全区间/状态,不显示可用于刷分的内部计数与审计动作 | 是 | 已确认 |
| D13 | EDUCATION 学校分档是否暂时保留计算,但增加未验证与公平性警示 | 暂留,另开评分版本评审决定移除/降权 | 已确认 |
| D14 | 缺失维度重新归一化是否暂时保留 | 暂留,明确可比性限制并在规则评审中决定 | 已确认 |
| D15 | 是否安排题库和规则证据验证,不将当前确定性规则称为已验证模型 | 是 | 已确认 |
D01–D15 已全部确认并按本设计实现。若未来 D01、D13 或 D14 的产品决策改变计分,本模块必须另行升级评分规则版本,不能通过解释增强提交混入。

39
docs/candidate-scoring-fairness-api.md

@ -0,0 +1,39 @@
# V43 候选评分公平性与稳定性监测 API
V43 只提供 ADMIN 聚合查询。当前状态为“独立代码复验与真实 MySQL/Docker 验收通过,待 Git 检查点”。响应基于当前数据库中指定月份的评分快照,字段 `basis=CURRENT_STORED_SNAPSHOTS` 表示快照可能被同月重算覆盖,不能解读为历史时点记录。
## 权限与通用约束
- ADMIN 才能访问;匿名为 401,STUDENT/COMPANY 为 403/2002。
- 时间范围使用 `[from,to)`。评分接口的 `period` 必填,格式为 `yyyy-MM`,不得晚于当前业务月。
- 申诉接口默认最近 30 个完整业务日,`from`、`to` 必须成对出现,跨度最大 90 天。
- 仅允许文档列出的参数;未知参数、非法维度和未来月份返回 400/1001。
- 所有响应为聚合白名单,不包含用户、申请、申诉、答卷、结果、审计 ID,联系方式、简历、题目、答案、规则详情或审计原文。
## 接口
| 方法 | 路径 | 用途 |
|---|---|---|
| GET | `/admin/scoring/fairness/overview?period=yyyy-MM` | 现存快照人数、可计算比例、规则版本构成和综合分分布 |
| GET | `/admin/scoring/fairness/dimensions?period=yyyy-MM&dimension=ABILITY` | 八维状态、缺行/空分/有效覆盖率和分布 |
| GET | `/admin/scoring/fairness/drift` | 返回 `UNAVAILABLE/IMMUTABLE_SCORE_HISTORY_NOT_AVAILABLE` |
| GET | `/admin/scoring/fairness/ranking-stability` | 返回 `UNAVAILABLE/RANKING_SNAPSHOT_HISTORY_NOT_AVAILABLE` |
| GET | `/admin/scoring/fairness/appeals?from=yyyy-MM-dd&to=yyyy-MM-dd` | 申诉创建队列当前状态、审核通过、回滚和实际变分聚合 |
所有 `sampleSize`、状态/版本/原因计数均按独立候选人计算,`appealCount` 才是事件数。每个统计组均带有明确的 `availability` 与 `reason`:`NO_DATA`、`INSUFFICIENT_SAMPLE`、`SUPPRESSED` 或 `AVAILABLE`,前端不得通过字段缺失猜测状态。0–29 人不返回均值、分位数、极值、标准差、分桶、比例或风险;30–99 标记 `LOW_CONFIDENCE`;100 及以上为 `NORMAL_CONFIDENCE`。任意细分单元或其补集为 1–29 人时整组抑制,避免从总计反推隐藏单元。
规则版本分布按版本单元分别做披露决策:不安全版本只返回 `availability=SUPPRESSED`/`reason=SMALL_CELL_OR_COMPLEMENT_RISK`,其 `sampleSize`、均值、分位数、极值、标准差、分桶和风险均为空;安全兄弟版本(例如70/30)仍可返回正常分布。只要存在版本级受保护单元,`versionCounts`及其可由总体反推的计数隐藏;全局总体触发抑制时,整个版本分布组隐藏。
总览的 `computableSnapshotRatio` 是“至少一个有效维度的候选人数 / 总体人数”,MIXED/UNKNOWN 候选不会被错误地当作无有效分;综合分布通过 `distributionsByRulesVersion` 按单一已知候选整体规则版本分别计算,MIXED/UNKNOWN 不进入版本分布。申诉的 `statusCounts/statusCandidateCounts` 是独立候选人数,`statusEventCounts` 明确保留事件数;重复回滚、空/越界分和跨版本原因均独立计数。回滚可比性按申诉粒度判断:同一 `appeal_id` 的 ROLLBACK 历史恰好一条且分数/规则版本合法才进入 COMPARABLE;同一候选人的另一份合法申诉不因其存在重复历史的申诉而被排除。`comparableRollbackCount` 与 `changedCount` 按可比/变分回滚历史行计数,`comparableRollbackCandidateCount` 与 `changedCandidateCount` 按这些行对应的独立候选人数计数;`actualScoreChangeRate=changedCount/comparableRollbackCount`,零差历史仍在分母中。
维度完整性异常分开返回 `inconsistentRecordCount`(状态与是否有分不一致)、`unknownStatusCount`(未知状态)和 `outOfRangeScoreCount`(非空但不在 0–100 的分数);这些计数与有效分、缺行、空分不互相替代,并受同一独立候选人/补集抑制规则约束。分布发生分桶抑制时连同 `sampleSize`、均值、分位数、极值、标准差、分桶和风险一起隐藏,只保留 `availability/reason`。
回滚不可比原因按互斥优先级归类:重复 `ROLLBACK` 历史优先,其次为空/越界分,最后是规则版本未知或前后不一致;每个申诉只进入一个原因集合,但同一候选人可因不同申诉同时出现在多个原因集合,不能用候选人去重抹掉真实归属。零差回滚仍保留在可比分母中。审核通过率、回滚执行率和实际变分率使用各自独立分母,任何分母或补集不足 30 名独立候选人均不返回该指标。
申诉接口区分 `APPROVED/ROLLED_BACK`、实际 `ROLLBACK` 执行和前后分数变化。它不计算真实曝光申诉率,因为当前系统没有可比的曝光分母。
V43 不写 `candidate_dimension_scores`,不触发评分重算,不修改 V32 排名。历史漂移和历史排名接口返回不可用状态是当前数据模型的真实限制。
实现约束:维度统计使用 `COUNT(DISTINCT student_user_id)` 与 `GROUP BY` 频数查询,分位数/标准差由频数直接计算;申诉回滚按申诉时间窗口 JOIN 查询,不拼接无限增长的 `IN` 参数。每条 JDBC 查询使用独立 5 秒超时,不修改共享 `JdbcTemplate`。真实 MySQL 已完成 30/300/3000 人查询预算与耗时验收;EXPLAIN 中的全表扫描/filesort 作为更大规模容量风险持续观察。
抑制实现由独立的 `FairnessDisclosureGuard` 统一执行:有效集合、每个细分单元及其补集均按独立候选人数判断;当有效集合或补集仅 1–29 人时,可能揭示其大小的综合分布字段(包括 sampleSize、分位数、分桶、均值、极值和风险)一并隐藏。

209
docs/candidate-scoring-rule-evidence.md

@ -0,0 +1,209 @@
# 候选评分规则证据表
状态:N02 独立代码复验与真实 MySQL/Docker 黑盒验收通过,待 Git 检查点;D01–D15 已确认。
文档类型:技术参考。本文记录 `CandidateScoringCalculator` 当前真实规则、数据、公式和证据限制。它不是对规则科学有效性的背书,也不是招聘决策指南。
## 1. 证据等级
| 等级 | 含义 |
|---|---|
| DIRECT_RULE | 可直接验证的确定性业务计算,例如字段是否填写、固定加权公式 |
| SELF_REPORTED | 主要来自学生本人填写,真实性和完整性依赖用户维护 |
| BEHAVIOR_PROXY | 平台行为与目标概念可能有关,但不能等同于目标概念 |
| UNVALIDATED_PROXY | 尚无试测、效度或公平性证据,不应据此形成强结论 |
当前系统没有足够证据将任何行为代理称为人格测量、信用评估或招聘成功预测模型。
## 2. 全局规则
| 项目 | 当前实现 | 证据与限制 |
|---|---|---|
| 维度 | EDUCATION、INTERNSHIP、ABILITY、COMPLIANCE、STABILITY、GROWTH、INTEGRITY、ACTIVITY | 名称不一定等于实际被测概念 |
| 展示权重 | 每维 12.50 | 实际综合分是可计算维度算术平均 |
| 参与状态 | OK、ESTIMATED | PENDING、INSUFFICIENT_DATA 和 null score 排除 |
| 综合分 | `sum(score)/count`,scale=2,HALF_UP | 不同参与维度集合之间不可直接比较 |
| 分数边界 | 多数行为维度经 `cap` 限制至 0–100 | 上限截断会隐藏额外行为差异 |
| 快照 | 同学生、维度、月份覆盖更新 | 月度快照不是不可变历史事实 |
| 版本 | v0.1;能力开关开启为 v0.2 | 版本变化必须随响应和快照保存 |
## 3. EDUCATION
### 3.1 当前公式
```text
educationScore = 学历层次分 × 55% + 学校档次分 × 45%
```
学历层次:博士 100、硕士 85、本科 70、大专 55、其他 50。
学校档次:内置 985 名单 85、内置 211 名单 75、名称以“大学/学院”结尾 65、未填写或其他 50。未填写或无法识别时状态为 `ESTIMATED`;学历缺失时 `INSUFFICIENT_DATA`。
| 项目 | 结论 |
|---|---|
| 数据来源 | 学生教育资料中的学历和学校 |
| 证据等级 | SELF_REPORTED + UNVALIDATED_PROXY |
| 可以说明 | 当前资料按平台固定分类规则得到的教育背景分 |
| 不能说明 | 智力、实际技能、岗位绩效、学习能力或人才质量 |
| 主要风险 | 学校名单硬编码、维护过期、学校名称匹配误差、社会经济背景代理、公平性风险 |
| 可操纵性 | 学生可修改自报字段;需要资料真实性治理,但不能因此扩大企业隐私权限 |
| 建议 | 一期只准确解释并标注未验证;后续评审移除学校项或显著降权 |
## 4. INTERNSHIP
当前固定返回 `PENDING`、`score=null`,原因是结构化实习数据源未接入。
| 项目 | 结论 |
|---|---|
| 证据等级 | DIRECT_RULE |
| 可以说明 | 当前平台无法计算该维度 |
| 不能说明 | 学生没有实习、实习不足或经验较弱 |
| 建议 | 保持 PENDING;解释必须使用“未接入”,不得显示为 0 分 |
## 5. ABILITY
默认开关关闭时返回 `PENDING`。开启后只读取最新、有效、完整的 `WORKPLACE_ABILITY` 结果:
```text
ABILITY = LOGIC×35% + EXECUTION×30% + LEARNING×20% + COMMUNICATION×15%
```
要求四维各回答 6 题、总回答 24 题、分数均在 0–100,`validUntil > capturedNow`。最终 scale=2、HALF_UP。
| 项目 | 结论 |
|---|---|
| 数据来源 | V36 自研四维能力测评最新有效聚合结果 |
| 证据等级 | DIRECT_RULE;题库测量意义仍属 UNVALIDATED_PROXY |
| 可以说明 | 用户在当前自研题库和评分规则下的四维聚合表现 |
| 不能说明 | 确定性工作能力、真实绩效或录用适合度 |
| 防滥用 | 不取历史最高分;结果过期或结构异常时不参与;不泄露答案键 |
| 待验证 | 小样本试测、信度、题目区分度、重测效应、与外部标准的关联 |
## 6. COMPLIANCE
当前实际计算求职偏好填写完整度:薪资 25、城市 25、用工类型 20、行业 15、岗位类型 15。五项均填写为 `OK`;至少一项填写为 `ESTIMATED`;全部缺失为 `INSUFFICIENT_DATA`。
| 项目 | 结论 |
|---|---|
| 证据等级 | DIRECT_RULE + SELF_REPORTED |
| 可以说明 | 五类求职偏好字段的填写完整程度 |
| 不能说明 | 合规意识、纪律性、服从性或职业道德 |
| 风险 | 名称严重超出计算内容;填写更多即可得到更高分,但不代表偏好更合理 |
| 建议展示名 | 求职偏好完整度 |
| 后续建议 | 评审是否应退出跨候选排名,仅用于资料完善提示 |
## 7. STABILITY
有投递或近 30 天登录时以 50 为基础:账号超过 12 个月 +25,超过 6 个月 +15;有投递时撤回率低于 20% +15,高于 50% -20;近 30 天有登录 +10;结果截断至 0–100。无投递且无登录为 `INSUFFICIENT_DATA`。
| 项目 | 结论 |
|---|---|
| 证据等级 | BEHAVIOR_PROXY + UNVALIDATED_PROXY |
| 可以说明 | 当前账号和平台求职行为形成的稳定信号 |
| 不能说明 | 人格稳定、工作稳定、忠诚度或离职概率 |
| 风险 | 新用户天然劣势;撤回可能由岗位变化、误投或企业原因造成;登录记录受产品使用习惯影响 |
| 可操纵性 | 登录和减少撤回可改变分数;阈值公开可能诱导行为 |
| 建议展示名 | 平台求职行为稳定信号 |
| 后续建议 | 决定是否从企业评分及排名中移除账号月龄,或仅作为可信度信息 |
## 8. GROWTH
存在简历或技能时以 40 为基础:ACTIVE 简历数量至少 2 个 +15;最近简历更新时间在 90 天内 +15;技能标签至少 5 个 +15;结果截断至 0–100。二者均无数据时 `INSUFFICIENT_DATA`。
| 项目 | 结论 |
|---|---|
| 证据等级 | BEHAVIOR_PROXY + UNVALIDATED_PROXY |
| 可以说明 | 简历维护和技能标签展示情况 |
| 不能说明 | 成长潜力、学习速度、职业上升空间 |
| 风险 | 多建简历版本和增加标签即可加分;技能数量不等于技能质量;无历史能力增量 |
| 建议展示名 | 简历维护与技能展示信号 |
| 后续建议 | 退出综合分或改成非评分的资料完善进度;若保留需加入去重、质量和冷却规则 |
## 9. INTEGRITY
有 Offer 历史或候选反馈时以 60 为基础:每个已接受 Offer +15,最多两个;每个过期 Offer -10,最多两个;反馈至少 3 条且均分至少 4 时 +10,有反馈且均分低于 3 时 -10;资料完整 +10;结果截断至 0–100。没有 Offer 且没有反馈时 `INSUFFICIENT_DATA`。
| 项目 | 结论 |
|---|---|
| 证据等级 | BEHAVIOR_PROXY + UNVALIDATED_PROXY |
| 可以说明 | Offer 响应、反馈和资料完整度形成的履约代理信号 |
| 不能说明 | 道德诚信、个人信用、欺诈风险或人格品质 |
| 风险 | 接受 Offer 不必然等于守约;过期可能来自通知失败;反馈评分可能存在偏见或样本不足;资料完整与诚信不是同一概念 |
| 可操纵性 | 接受行为、反馈数量及资料填写会改变结果 |
| 建议展示名 | 履约与资料一致性信号 |
| 强制文案 | “该信号不是个人征信、信用评级或道德评价” |
| 后续建议 | 优先评审退出综合分和排名;独立诚信积分模块不得复用该分数充当积分余额 |
## 10. ACTIVITY
近 90 天投递:至少 6 次 +50、至少 3 次 +40、至少 1 次 +20;近 30 天平台操作至少 3 次 +30;存在发信记录 +20;结果截断至 0–100。全部为零时 `INSUFFICIENT_DATA`。
| 项目 | 结论 |
|---|---|
| 证据等级 | BEHAVIOR_PROXY |
| 可以说明 | 指定窗口内的平台求职活动程度 |
| 不能说明 | 工作积极性、职业动机、岗位能力或录用意愿 |
| 风险 | 为得分而投递、登录、发消息;消息只判断是否存在且当前查询未限定时间窗口 |
| 可操纵性 | 高;重复投递和任意消息可直接增加信号 |
| 建议展示名 | 平台求职活跃度 |
| 后续建议 | 保留为时效排序信号须单独评审;不建议与能力类维度等权解释 |
## 11. 跨维度问题
### 11.1 概念重叠
资料完整度同时影响 COMPLIANCE、GROWTH 和 INTEGRITY,平台活跃行为同时影响 STABILITY 和 ACTIVITY。相同底层行为可能被重复计入综合分。
### 11.2 缺失并非零分
当前缺失维度会退出分母,而不是记为零。解释必须明确“未参与”,不能显示成“表现较差”。但重新归一化也使不同人的综合分口径不一致。
### 11.3 估算值
`ESTIMATED` 与 `OK` 同等进入综合分。学校未知和偏好部分填写均可能产生估算分,企业端目前只看到状态和聚合分,必须避免将估算分表述为精确结果。
### 11.4 时间窗口
APPLICATION、LOGIN、AUDIT 和简历更新时间使用多个 `now()`,消息数量没有时间窗口。N02 应先统一计算时钟,但是否修改消息窗口属于评分规则变更,不能混入解释版本。
## 12. 规则证据验证计划
### 12.1 代码级确定性验证
- 为每条规则建立边界表和独立手算样例;
- 固定 Clock 验证恰好 30/90 天、6/12 个月和 ABILITY 到期时刻;
- 检查分数、贡献、原因与数据来源完全一致;
- 验证 cap、负分、HALF_UP 和 null 分支。
### 12.2 数据质量验证
- 统计各维度 OK/ESTIMATED/INSUFFICIENT_DATA/PENDING 覆盖率;
- 检查学校名称无法识别率、偏好 JSON 错误率、Offer 通知失败率;
- 检查同一底层行为跨维度重复计分比例;
- 监测规则版本和异常结构,复用 V43 聚合能力但不扩大其个人数据范围。
### 12.3 防操纵验证
- 重复投递、撤回、登录、发消息、创建简历版本和堆叠技能标签;
- 比较一次行为与大量重复行为的边际影响;
- 验证冷却、幂等和上限是否真正生效;
- 发现高可操纵性时,不以隐藏阈值代替规则整改。
### 12.4 测评证据
ABILITY 题库先完成小样本试测,再评估内部一致性、题目区分度、重测影响和题目淘汰。RIASEC 当前不直接进入候选八维评分,不应在 N02 中混入。
## 13. N02 实现核对
- 解释版本固定为 `candidate-scoring-explanation-v1`,与 `candidate-scoring-v0.1/v0.2` 分离。
- 规则贡献由 `CandidateScoringCalculator` 的真实分支同步生成,`CandidateScoringExplanationAssembler` 只做白名单组装。
- 能力测评小数分数的逐项展示舍入差以 `SCORE_ROUNDING_ADJUSTMENT` 显式对账;不改变 V38 映射分数。
- 学生综合卡增加 `coverage` 与 `comparabilityNotice`;企业评分卡字段和 JSON 结构保持不变。
- `Clock`/`capturedNow` 贯穿一次评分计算及快照 `calculated_at`;未改变任何阈值、公式、权重或排名。
- 本轮无新 Flyway、表、错误码、公网路径或业务消息;真实 MySQL/Docker 验收仍待独立执行。
## 14. 审计结论
当前代码在“确定性、可重复计算”层面具备基础,但不具备将多个代理维度解释为人格、诚信、成长潜力或招聘成功预测的证据。N02 可以先完成可信解释和限制披露;涉及分数、排名和企业决策影响的合理性整改必须作为独立规则版本处理。
D01–D15 已确认。当前代码在“确定性、可重复计算”层面具备基础,但不具备将多个代理维度称为人格、诚信、成长潜力或招聘成功预测模型的证据。N02 仅完成可信解释和限制披露;任何改变分数、权重或排名的后续动作必须另开评分规则版本并重新验收。

111
docs/company-review-management-api.md

@ -0,0 +1,111 @@
# V40 企业评价管理与官方回复 API
## 1. 范围
V40 为企业提供自有岗位评价查询、官方回复提交和管理员确定性预审。评价本身仍由既有学生评价模块维护;本模块不提供评价删除、隐藏、编辑、申诉、通知或运营统计。
Flyway 迁移为 `V38__company_review_replies.sql`(功能版本 V40),新增 `company_review_replies` 与 `company_review_reply_history` 两表,不修改 V1–V37。所有身份从 JWT subject 获取,不能从请求体或查询参数注入 `companyId`、`studentUserId` 或 `ownerUserId`。
## 2. 接口
| 方法 | 路径 | 权限 |
| --- | --- | --- |
| GET | `/companies/me/job-reviews` | COMPANY,且仅返回本人公司岗位 |
| GET | `/companies/me/job-reviews/{reviewId}` | COMPANY,且必须属于本人公司 |
| POST | `/companies/me/job-reviews/{reviewId}/reply` | 所属 COMPANY |
| PUT | `/companies/me/job-reviews/{reviewId}/reply` | 所属 COMPANY,仅 REJECTED 回复 |
| GET | `/admin/review-replies/pending` | ADMIN |
| POST | `/admin/review-replies/{replyId}/decision` | ADMIN |
未登录返回 401;角色不匹配或跨公司访问返回 403/2002。V40 不增加其他公开路径、ErrorCode 或 feature flag。
## 3. 回复生命周期
```text
无回复 -> 企业创建 -> AUTO_APPROVED(公开)
-> PENDING -> APPROVED(公开)
-> REJECTED -> 企业 PUT 修订 -> AUTO_APPROVED/PENDING
```
每条评价只有一个当前回复。`AUTO_APPROVED`、`APPROVED` 不可修改或删除;`REJECTED` 必须带 `expectedVersion` 修订。管理员决策使用 `version` 条件更新,并发时只有一个请求成功。
## 4. 请求与响应
创建:
```json
{
"content": "感谢您的反馈,我们会持续优化招聘流程。",
"idempotencyKey": "合法 UUID"
}
```
正文去首尾空白后长度为 1–500;幂等键必须为 UUID 且不超过 64 字符。同企业、同评价、同正文的重放返回原回复;同幂等键但评价或正文不同返回 `409/10106`。
修订:
```json
{
"content": "修订后的回复内容",
"expectedVersion": 1
}
```
管理员决策:
```json
{"decision":"APPROVE","reason":null}
```
或:
```json
{"decision":"REJECT","reason":"请移除不适合公开的联系方式。"}
```
企业响应白名单包括评价文本、星级、标签、维度聚合、岗位标题、回复正文/状态/版本/更新时间及驳回的人类说明;不返回 `studentUserId`、学生姓名、手机号、邮箱、IP、UA、内容哈希、评价审核内部原因、`moderationScore`、`reasonCodes` 或管理员 ID。
管理员待审响应可包含岗位/公司、评价和回复正文、风险等级及安全原因码,但不包含学生联系方式、IP/UA 明文或凭据。
## 5. 公开评价回复
既有 `GET /jobs/{jobId}/reviews` 和 `GET /jobs/{jobId}/reviews/{reviewId}` 保持不变。公开载荷移除 `studentUserId`,增加固定 `reviewerLabel: "匿名求职者"`。当评价为公开状态且回复为 `AUTO_APPROVED` 或 `APPROVED` 时,返回:
```json
{"officialReply":{"content":"感谢反馈。","repliedAt":"2026-09-04T10:05:00"}}
```
`officialReply` 不含 replyId、companyId、status、version、原因码或审核人信息;`PENDING`/`REJECTED` 回复不公开。公开列表先分页取评价,再批量取回复和维度分,禁止逐条查询。
## 6. 当前验收状态
V40 专项与全量自动化测试已通过(全量 1445/1445,Failures=0、Errors=0、Skipped=0),`mvn clean verify` 已通过,静态与运行时 OpenAPI 均为 159 paths / 196 operations。真实 MySQL/Docker 黑盒已通过:Flyway V38 success=1、主链路/权限/幂等/状态机/并发创建与审核/脱敏/队列终态全部通过,汇总 `PASS=62 FAIL=0 SKIP=0`。V40 不写 `candidate_dimension_scores`,未提交 Git。
## 7. 错误码
| 错误码 | HTTP | 含义 |
| --- | --- | --- |
| 10101 | 404 | 回复不存在(或按归属策略隐藏) |
| 10102 | 409 | 评价已有当前回复 |
| 10103 | 409 | 回复状态不允许当前操作 |
| 10104 | 409 | 乐观锁/条件更新冲突 |
| 10105 | 400 | 回复正文或幂等键非法 |
| 10106 | 409 | 幂等键对应的请求内容冲突 |
| 10107 | 400 | 非法决策或驳回原因缺失 |
## 8. 审计与数据安全
成功状态变化写入既有 `audit_logs` 和回复历史。详情只含 `reviewId`、`replyId`、`status`、`requestId` 等白名单字段,不写回复正文、评价正文、学生 ID、联系方式、IP/UA、哈希、风控原文、token、password 或 secret。V40 不写 `candidate_dimension_scores`。
企业列表和管理员待审列表均采用批量加载岗位、维度分和回复,测试断言查询次数不随条目数线性增长。
## 9. 验收
```powershell
mvn -Dtest="*Review*Test,*CompanyReview*Test,*AdminReview*Test" test
mvn test
mvn clean verify
python docs/verify_openapi.py
```
本轮自动化验收不启动/停止 Docker;真实 MySQL/Docker 黑盒须单独执行并如实记录。

224
docs/def-01-job-keyword-normalization-remediation-20260920.md

@ -0,0 +1,224 @@
# DEF-01 定向整改:`GET /jobs` 公开列表关键词规范化
日期:2026-09-20
分支:`refactor/r01-structure-performance`
基线 HEAD:`2319f97203a2cafcf8012869fda8c30599ffb305`(整改前的 upstream 同点)
方案:**方案 A**(服务入口局部规范化),**不采用方案 B**
本轮性质:定向整改 + 独立复验;未暂存、未提交、未推送
---
## 1. 修复前最小复现
真实 HTTP(整改前,`mvn` 于 `2319f97`):
```
GET /api/v1/jobs?keyword=%20AlphaEngineer%20 → items=0 (期望 1:trim 后应命中)
GET /api/v1/jobs?keyword=%20%20%20 → items=0 (期望 20:空白应等同未传)
GET /api/v1/jobs/ch?keyword=AlphaEngineer → items=1
GET /api/v1/jobs → items=20
GET /api/v1/jobs/search?keyword=%20AlphaEngineer%20 → 1 (正确)
GET /api/v1/jobs/search?keyword=%20%20%20 → 20 (正确)
```
## 2. 两个端点的调用链
| 端点 | 调用链 | 关键词处理 |
|---|---|---|
| `GET /jobs` | `JobController.listJobs:97` → `JobService.listPublishedJobs:123` → `JobRepository.findPublishedJobs`(JPQL) | 只判 `:keyword IS NULL`,**无 trim、无 blank 归一** |
| `GET /jobs/search` | `JobController.searchJobs:134` → `JobService.searchJobs` → `JobSearchSpecification:52-53` | `!keyword.isBlank()` 判空 + `keyword.trim()` |
## 3. 根因
`JobRepository.findPublishedJobs` 的 JPQL 为:
```sql
AND (:keyword IS NULL OR j.title LIKE %:keyword% OR j.description LIKE %:keyword%)
```
- `" AlphaEngineer "` **不是 null** → 生成 `LIKE '% AlphaEngineer %'` → 前导/尾随空格进入匹配串 → **0 条**;
- `" "` **不是 null** → 生成 `LIKE '% %'` → 无意义的过滤条件 → **0 条**;
- `/jobs/search` 已做 `trim()` 与 `isBlank()` 归一 → 两条**公开**查询路径的输入语义不一致。
## 4. 为什么选方案 A
- 缺陷是**输入规范化缺失**,不是查询实现缺陷;
- 方案 A 把规范化放在**唯一入口**(`listPublishedJobs`),一处生效、语义显式、可被单测直接覆盖;
- 改动面最小(1 个文件、+7/−1),**不触碰** Repository / Specification / Controller / DTO / OpenAPI /
排序 / 分页 / 薪资 / 城市 / 状态,回归风险最低。
## 5. 为什么不选方案 B
- 方案 B 让 `/jobs` 改走 `JobSearchSpecification`,会改变 `/jobs` 的**分页 DTO 形态、排序实现与
查询计划**(`JobSearchItem` 与现有 `toPublicView` 字段并不相同),属于超出缺陷范围的语义扩张;
- 两套实现并存是**既有事实**(`/jobs` 与 `/jobs/search` 本就服务不同调用方),
合并应作为独立重构批次评估,不应与本次缺陷修复捆绑。
## 6. 精确生产修改
**唯一文件**:`mydreamjob-server/src/main/java/com/mydreamjob/job/JobService.java`
(`git diff --stat`:**1 file changed, 7 insertions(+), 1 deletion(-)**)
```java
public Page<Map<String, Object>> listPublishedJobs(String keyword, String city, ...) {
if (salaryMin != null && salaryMax != null && salaryMin > salaryMax) {
throw new BusinessException(ErrorCode.BAD_REQUEST);
}
String normalizedKeyword = keyword == null || keyword.isBlank() ? null : keyword.trim(); // ← 新增
Pageable pageable = PageRequest.of(page, size, Sort.by(Sort.Direction.DESC, "createdAt"));
Page<Job> result = jobRepository.findPublishedJobs(normalizedKeyword, city, employmentType, // ← 原为 keyword
salaryMin, salaryMax, pageable);
return result.map(this::toPublicView);
}
```
(另含一段 Javadoc 说明 DEF-01 的语义契约;不改变任何行为。)
**未新增通用工具类**:项目内不存在语义完全相同的既有规范化工具,故保持这段局部逻辑。
## 7. 输入规范化决策表
| 输入 | 规范化结果 |
|---|---|
| `null` | `null` |
| `""` | `null` |
| `" "` | `null` |
| `"AlphaEngineer"` | `"AlphaEngineer"` |
| `" AlphaEngineer "` | `"AlphaEngineer"` |
| `"Alpha Engineer"` | `"Alpha Engineer"`(中间空格**不折叠**) |
未做:大小写转换、中间空格折叠、SQL 拼接、Repository 改动、Specification 改动、Controller 改动、
排序/分页/薪资/城市/状态语义改动、公开岗位范围改动。
## 8. 新增测试方法名
`mydreamjob-server/src/test/java/com/mydreamjob/job/JobListKeywordNormalizationIntegrationTest.java`
(9 项,走**真实链路** HTTP → SecurityFilterChain → JobController → JobService → JobRepository):
`plainKeywordFindsPublishedJob`、`trimmedKeywordFindsSamePublishedJob`、`blankKeywordBehavesAsNoKeyword`、
`emptyKeywordBehavesAsNoKeyword`、`nullOrMissingKeywordListsPublishedJobs`、
`draftJobNeverAppearsAfterNormalization`、`normalizationDoesNotChangePagination`、
`normalizationDoesNotChangeExistingSortContract`、`listJobsIsReadOnly`
夹具:1 条标题含 `AlphaEngineer` 的 PUBLISHED + 2 条其它 PUBLISHED + 1 条 DRAFT + 1 条 CLOSED;
唯一前缀 `DEF01KEYWORD_`;清理**只按显式登记的主键**,不使用 `deleteAll()`;不依赖执行顺序。
## 9. 修复前失败结果
以**变异验证**给出权威的修复前证据(把 `normalizedKeyword` 改回 `keyword`,其余不动):
```
Tests run: 9, Failures: 4, Errors: 0, Skipped: 0
JobListKeywordNormalizationIntegrationTest.trimmedKeywordFindsSamePublishedJob FAIL
JobListKeywordNormalizationIntegrationTest.blankKeywordBehavesAsNoKeyword FAIL
JobListKeywordNormalizationIntegrationTest.normalizationDoesNotChangePagination FAIL
JobListKeywordNormalizationIntegrationTest.normalizationDoesNotChangeExistingSortContract FAIL
```
即 **trim 与 blank 两类断言在缺失修复时必然失败**(≥2 项,满足要求)。
## 10. 修复后结果
| 命令 | 结果 |
|---|---|
| `mvn -Dtest="JobListKeywordNormalizationIntegrationTest" test` | **9 / 0 / 0 / 0** |
| `mvn -Dtest="*Job*Test,*JobSearch*Test" test` | **219 / 0 / 0 / 0** |
## 11. 真实 MySQL HTTP 结果
最新 JAR(`mvn clean verify` 产物)+ 真实 MySQL/Redis/RabbitMQ,前缀 `DEF01_20260920_`:
**11 / 11 通过**。
| 断言 | 实测 |
|---|---|
| 普通关键词 HTTP 200 且只命中目标 | `200, [target]` ✔ |
| 两侧空白与普通关键词:ID/顺序/数量一致 | `pad=[T] plain=[T]` ✔ |
| 纯空白与无关键词:ID/顺序/总数一致 | `blank == none` ✔ |
| 空字符串与无关键词:一致 | `empty == none` ✔ |
| DRAFT 不进入公开结果 | `statuses=['PUBLISHED']` ✔ |
| 分页 metadata 不因规范化变化 | `total=1,page=0,size=1,totalPages=1` ✔ |
| 默认排序不变(夹具 createdAt 降序) | `none == blank`,desc ✔ |
| 不返回 4xx/5xx | `[200]×7` ✔ |
| 响应不泄露 SQL/异常/路径/堆栈 | `leaks=[]` ✔ |
| 对照 `/jobs/search` 的 trim/blank 语义一致 | `pad==plain`、`blank==全部` ✔ |
| 请求前后数据库逐字段零变化 | `changed=none` ✔ |
## 12. 变异测试结果
- 保存 `JobService.java` **原始字节**(sha256 前 16 位 `b4fab0034b34d569`,14 699 字节);
- 变异:`findPublishedJobs(normalizedKeyword, …)` → `findPublishedJobs(keyword, …)`;
- 结果:**4 项失败**(见 §9);
- **按原始字节精确恢复**(**未使用** `git restore` / `git checkout`),恢复后 sha 一致;
- 复跑新增测试 **9/9 绿**;校验无变异残留(`findPublishedJobs(keyword,` 计数 = 0,
`String normalizedKeyword =` 计数 = 1)。
## 13. 岗位关联回归
`mvn -Dtest="*Job*Test,*JobSearch*Test" test` → **219 / 0 / 0 / 0**(未修改或删除任何既有断言)。
## 14. 全量测试数
| 命令 | Tests | Failures | Errors | Skipped | BUILD |
|---|---|---|---|---|---|
| `mvn test` | **2797** | 0 | 0 | 0 | SUCCESS |
| `mvn clean verify` | **2797** | 0 | 0 | 0 | SUCCESS |
`2797 = 2788(基线)+ 9(本轮新增)`,满足「不低于 2788 + 新增数」。
Testcontainers MySQL 用例实际执行(`Skipped=0`)。
## 15. OpenAPI
```
python ..\docs\verify_openapi.py → paths: 176 / operations: 214 / All checks passed!
```
`docs/openapi-rc.json` **未修改**(`git diff` 为空)。
## 16. 数据副作用
- 黑盒前后 7 张表(jobs / companies / users / user_roles / audit_logs / notifications / applications)
**逐字段零变化**;
- 单测 `listJobsIsReadOnly` 对 5 张表逐字段零变化;
- 夹具残留 **0**;历史未跟踪文件与历史数据(含 `users.id` 7938/7939 的 `user_roles`)完好;
- `audit_logs` **未删除任何行**。
## 17. 精确修改文件
```
M mydreamjob-server/src/main/java/com/mydreamjob/job/JobService.java (+7 −1)
?? mydreamjob-server/src/test/java/com/mydreamjob/job/JobListKeywordNormalizationIntegrationTest.java (新增,9 项)
?? docs/def-01-job-keyword-normalization-remediation-20260920.md (本报告)
?? docs/final-independent-acceptance-20260918.md (追加 DEF-01 章节)
```
## 18. Git 状态(整改与复验阶段)
| 项 | 结果 |
|---|---|
| 生产代码改动 | **仅 `JobService.java` 1 个文件** |
| `JobRepository` / `JobSearchSpecification` / `JobController` | **零修改** |
| 其他生产模块 / Flyway / `pom.xml` / `openapi-rc.json` | **零修改** |
| 验收脚本与原始产物 | **零修改** |
## 19. 未涉及范围
未进入:简历隐私矩阵、管理员初始化矩阵、Q01 H2/MySQL 对账与 EXPLAIN 分表、写侧刷新链路
(四项 SKIP 仍待验收)。
## 20. 本轮测试自身缺陷(非产品缺陷,已修正并记录)
| # | 现象 | 判定 | 修正 |
|---|---|---|---|
| T-1 | 「带空白关键词」用例恒返回空 | **测试缺陷**:MockMvc 对 URL 模板二次编码(`%20` → `%2520`),测的是字面量 `%20` 而非空白参数 | 改用 `.param()` 传参 |
| T-2 | 分页/排序断言随更大范围运行而失败 | **测试缺陷**:断言了既有契约**不保证**的顺序 —— 默认排序 `createdAt DESC` 无 tiebreaker,并列时入选子集与顺序均不稳定 | 夹具改用**显式互异 `created_at`**;分页用例只断言确定性元数据 |
| T-3 | 黑盒夹具未创建 | **脚本缺陷**:`seed_sms_codes/login` 使用 `lib.phone()`(`199000` 号段),而建公司语句另写了 `199002` 号段,两者是**不同号码**,公司被挂到不存在的用户上(号码为本地验收自建夹具,报告中脱敏) | 统一为 `lib.phone(CO)` |
| T-4 | 黑盒「默认排序」断言失败 | **脚本缺陷**:把「关键词过滤后的结果」与「无关键词的完整结果」做相等比较,语义不同 | 只比较同为未过滤的 `none` 与 `blank` |
## 21. 明确声明
**未修改** `/jobs/search`、`JobSearchSpecification`、`JobRepository` JPQL、Controller 路由、
排序/分页/薪资/城市/状态过滤、DTO、OpenAPI、`ErrorCode`、`SecurityConfig`、V1–V45、`pom.xml`;
**未新增 V46**;**未修改任何既有测试断言**;**未**执行 `git reset/restore/checkout/clean`;
**未**使用 `git add -A` / `git add .` / 目录级暂存;**未**删除或移动历史未跟踪文件。
本报告与 `docs/final-independent-acceptance-20260918.md` 的 DEF-01 追加章节于提交前保持**待提交**状态。

53
docs/enterprise-talent-discovery-api.md

@ -0,0 +1,53 @@
# V41 企业人才发现 API
V41 提供学生显式授权和企业岗位候选人发现。学生授权默认关闭;企业必须已认证,岗位必须属于当前企业、处于 `PUBLISHED`,并且具有 V39 `enabled=true`、`APPROVED` 的岗位画像。
## 接口
| 方法 | 路径 | 权限 |
|---|---|---|
| GET | `/profile/me/talent-discovery` | STUDENT,仅本人 |
| PUT | `/profile/me/talent-discovery` | STUDENT,仅本人 |
| GET | `/companies/me/jobs/{jobId}/talent-recommendations` | 已认证 COMPANY,且岗位属于本人 |
统一前缀为 `/api/v1`。身份从 JWT subject 获取,客户端不得传 `studentUserId`、`companyId` 或 `candidateId`。
## 学生开关
无记录的 GET 返回 `enabled=false、version=0`,不会自动写库。PUT 请求体:
```json
{"enabled":true,"expectedVersion":0}
```
`expectedVersion` 可省略;已有记录传入后必须匹配当前版本。并发冲突复用 `409/9201`。成功只记录 `TALENT_DISCOVERY_PREFERENCE_UPDATED`,审计 details 仅包含 `enabled/version/requestId`。
## 企业候选列表
`page` 从 0 开始,`size` 为 1–50。岗位画像不可用时返回 `mode=UNAVAILABLE`;没有合格候选时返回 `mode=NO_ELIGIBLE_CANDIDATE`。正常返回 `mode=PERSONALIZED`。
候选池排除:未授权学生、注销限制账号、无基础档案、曾向当前企业任一岗位投递过的学生(包括 WITHDRAWN/REJECTED)。其他企业的投递不影响当前企业发现。
PREFERENCE/RIASEC/ABILITY 三个信号按 40/35/25 计算,缺失信号不按 0 处罚而重新归一化;至少一个有效信号才进入列表。RIASEC 和能力只读取最新完成结果,能力结果还必须未过期;不读取答案、题目、答案键、评分规则或标签。服务端先计算全部候选并全局排序,再分页,保证分页正确。
企业候选卡仅返回:
```json
{
"candidateKey":"…",
"candidateLabel":"开放人才候选人",
"city":"合肥",
"education":"本科",
"graduationYear":2027,
"skills":["Java","Spring Boot"],
"fitBand":"HIGH",
"summary":"岗位画像与候选人公开资料存在较高匹配度",
"signalsAvailable":["PREFERENCE","RIASEC","ABILITY"]
}
```
不返回学生 ID、姓名、联系方式、简历、测评分数、候选评分、结果/答卷 ID 或岗位画像原始权重。`candidateKey` 为 HMAC-SHA256、按公司/岗位/学生/日期桶隔离的不透明展示键,本版没有用它换取详情或联系方式的接口。
## 版本与边界
V41 新增 Flyway `V39__enterprise_talent_discovery.sql`,不修改 V1–V38,不写 `candidate_dimension_scores`,不新增 feature flag、ErrorCode、消息或推荐历史。OpenAPI 由 159/196 增至实际 **161/199**(两个唯一 path、三个 operation)。自动化测试 1461/1461 通过;真实 MySQL/Docker 黑盒已通过(`PASS=37 / FAIL=0`)。

59
docs/enterprise-talent-invitation-api.md

@ -0,0 +1,59 @@
# V42 企业人才邀约与授权沟通 API
V42 允许已认证企业使用 V41 生成的 `candidateKey` 向开放人才候选人发起站内邀约;学生明确接受后才建立 `TALENT_INVITATION` 会话。候选人身份、测评明细和联系方式始终不向企业公开。
## 接口清单
| 方法 | 路径 | 身份 | 说明 |
| --- | --- | --- | --- |
| POST | `/companies/me/jobs/{jobId}/talent-invitations` | 已认证 COMPANY | 创建邀约 |
| GET | `/companies/me/talent-invitations` | 已认证 COMPANY | 分页查看本公司邀约 |
| GET | `/companies/me/talent-invitations/{invitationId}` | 已认证 COMPANY | 查看本公司邀约详情 |
| GET | `/profile/me/talent-invitations` | STUDENT | 分页查看本人收到的邀约 |
| GET | `/profile/me/talent-invitations/{invitationId}` | STUDENT | 查看本人邀约详情 |
| POST | `/profile/me/talent-invitations/{invitationId}/decision` | STUDENT | 接受或拒绝邀约 |
V42 新增 6 个 path、6 个 operation;静态 OpenAPI 基线为 167 paths / 205 operations。
> 验收状态:真实 MySQL/Docker 黑盒通过(`PASS=76 / FAIL=0`);运行时与静态 OpenAPI 均为 167 paths / 205 operations,待 Git 检查点。
## 创建邀约
```json
{
"candidateKey": "V41-generated-opaque-key",
"message": "欢迎了解我们的后端开发岗位。",
"idempotencyKey": "3c989a31-6774-4520-9700-e58416cc1845"
}
```
服务端重新确认企业认证、岗位归属和 `PUBLISHED` 状态、岗位画像 `enabled=true && APPROVED`、候选授权、账号状态、当前企业全部历史投递排除和至少一个有效信号。`candidateKey` 按 V41 相同密钥、时区和日期桶重算,并使用常量时间比较;请求不得携带学生 ID。
邀约有效期为 7 天。相同公司和幂等键的相同请求返回原邀约,不重复占用限额;同键不同业务内容返回 `409/10207`。每公司每岗位每天最多 20 次、每公司每天最多 100 次成功创建;同企业同学生终态后 30 天内不可再次邀约。
`message` 去首尾空白后为 1–300 字符,禁止手机号、邮箱、URL、微信/QQ 等外部联系方式。无效文案统一返回 `400/10208`,不写入邀约。
## 学生决策
```json
{
"decision": "ACCEPT",
"expectedVersion": 0
}
```
只有未过期 `PENDING` 可决策。`ACCEPT`、`REJECT`、到期 `EXPIRED` 采用条件更新和版本控制;并发请求只有一方能改变状态。接受时若任一方向存在屏蔽,返回 `403/2002` 且保持 `PENDING`。接受成功后在同一事务中创建唯一会话和两条成员记录;拒绝、过期不创建会话。
学生详情包含 `version`,只有 `ACCEPTED` 返回 `conversationId`。企业端不返回 `candidateKey`、`studentUserId`、`candidateKeyDigest`、`activeKey`、测评/评分字段或拒绝内部原因。
## 会话隐私
`TALENT_INVITATION` 会话仅由接受流程的内部组件创建,不开放传学生 ID 的公网入口。既有 `DIRECT` 会话的 MATCHED 守卫保持不变。会话、消息列表和发送响应对邀约会话仅返回 `CURRENT_USER`/`OTHER_PARTICIPANT` 相对身份,不返回 `createdBy` 或 `senderUserId`,也不提供学生姓名、联系方式或简历信息。
## 错误码
`10201` 不存在或无权查看;`10202` 候选当前不可邀约;`10203` 活跃/冷却冲突;`10204` 状态不允许决策;`10205` 已过期;`10206` 版本或条件更新冲突;`10207` 幂等键业务内容冲突;`10208` 请求或文案非法;`10209` 达到每日限额。角色、归属和屏蔽继续使用 `401/2001`、`403/2002`。
## 事务与数据边界
创建事务先锁定公司行,再执行幂等、冷却、限额和写入;数据库唯一约束作为最终防线。过期转换使用独立事务,确保 `EXPIRED`、释放 `activeKey`、history 和审计先提交再返回错误。V42 不写 `candidate_dimension_scores`,不发送 RabbitMQ 通知,不保存候选人联系方式或正文以外的敏感数据。

2250
docs/final-independent-acceptance-20260918.md

File diff suppressed because it is too large

428
docs/frontend-integration.md

@ -34,17 +34,22 @@ mvn spring-boot:run -Dspring-boot.run.profiles=dev
健康检查:`http://127.0.0.1:8080/api/v1/health` 返回 `status: UP`。
## 3. Mock 验证码日志位置
## 3. 本地获取测试验证码(不通过日志)
开发环境短信为 Mock,验证码打印在**服务控制台/stdout 日志**中:
**应用日志不会输出验证码**,也不得从控制台 / stdout / 日志文件中读取验证码。
```
MOCK SMS CODE: phone=13800000002, purpose=login, code=483920
```
本地 dev/test 联调请使用受控方式取得验证码:
1. 启动应用时显式开启 Mock 短信:`SMS_MOCK_ENABLED=true`(即 `app.sms.mock.enabled=true`)
2. 显式授权本次执行读取验证码表:`ALLOW_TEST_OTP_DB_READ=true`
3. 通过受控测试脚本或数据库内部只读查询取得验证码;脚本内部取码,不向终端展示
- 运行 `mvn spring-boot:run` → 看终端输出
- 后台运行 → 看重定向的日志文件(如 `logs/mydreamjob.log`)
- 每次发码后取**最新一条** `code=<6位数字>`
`docs/smoke-test.sh` 已按上述双重保护实现;两项条件任一不满足即输出 `test OTP retrieval is disabled` 并安全失败。
**约束:**
- 生产环境**禁止**查询验证码表辅助登录
- 当前尚未接入真实短信供应商;`app.sms.mock.enabled` 为 `false`、缺失、空值或非法值时,发码返回“服务不可用”,绝不返回虚假成功
- 验证码当前仍以**明文**存储于 `sms_verification_codes`,属已知遗留风险(未加密、未哈希)
## 4. Apifox 导入 OpenAPI
@ -54,9 +59,9 @@ MOCK SMS CODE: phone=13800000002, purpose=login, code=483920
4. 点击「确认导入」,Apifox 自动生成全部接口、参数、响应模型
5. 在 Apifox 中可直接调试:登录接口获取 token 后,设置「环境变量 `token`」,在接口「Authorization」填 `Bearer {{token}}`
## 5. Token 使用方式
## 5. Token 使用方式(S02)
登录成功返回 `accessToken`(有效期 24h)与 `refreshToken`。
登录成功返回 `accessToken`(有效期 24h)与 `refreshToken`(有效期 7 天)。
```json
{
@ -76,6 +81,36 @@ MOCK SMS CODE: phone=13800000002, purpose=login, code=483920
Authorization: Bearer <accessToken>
```
### 5.1 两个 Token 的用途必须分开
| Token | 用在哪 |
|---|---|
| `accessToken` | 所有普通业务接口的 `Authorization` 头 |
| `refreshToken` | **只能**用于 `POST /api/v1/auth/refresh` |
把 `refreshToken` 放进普通接口的 `Authorization` 头会被拒绝(401);
把 `accessToken` 传给刷新接口同样会被拒绝(401)。两者由不同密钥签发,不可互换。
### 5.2 刷新与登出
```
POST /api/v1/auth/refresh body: { "refreshToken": "<refreshToken>" }
POST /api/v1/auth/logout header: Authorization: Bearer <accessToken> (无 body)
```
### 5.3 前端必须遵守的规则
1. **上线即强制重新登录**:S02 之后所有旧 Token(不含类型与会话信息)全部失效,
前端需要接受一次强制登录,不要尝试兼容旧 Token。
2. 普通请求**只携带 Access Token**。
3. 收到普通接口 401 时,**最多刷新一次**;刷新成功后原子替换两个 Token 并重放原请求。
4. **刷新请求必须串行**:多个并发 401 不能同时用同一个 Refresh Token 去刷新
(并发刷新会被服务端判定为重放并撤销整个会话)。
5. **刷新失败一律清除本地两个 Token 并跳转登录页**,不要循环刷新。
6. **登出时先调用 `/auth/logout`,随后无条件清除本地 Token**;
不要依赖第二次登出请求(Access Token 失效后它本来就会 401)。
7. **不要记录或上报 Token**:不写入 URL、本地日志、埋点或错误上报正文。
## 3. 请求头
| Header | 必需 | 说明 |
@ -125,8 +160,8 @@ Authorization: Bearer <accessToken>
| 401 | 401(安全过滤器) | 未登录 / 无 token |
| 403 | 2002, 8004, 8303, 8402 | 无角色权限 / 越权(含企业归属不匹配) |
| 400 | 1001, 3002-3006, 8404-8408, 8505, 9105 | 参数错误 / 非法请求体 / 资源未命中默认映射 |
| 404 | 4001, 5001, 6001, 7001, 8002, 9102, 9104, 8305, 8501, 9701 | 资源不存在 |
| 409 | 5003, 7003, 8001, 8003, 8301-8302, 8304, 8502-8504, 9101, 9103, 9702-9706 | 重复 / 非法状态流转 / 版本或幂等冲突 |
| 404 | 4001, 5001, 6001, 7001, 8002, 9102, 9104, 8305, 8501 | 资源不存在 |
| 409 | 5003, 7003, 8001, 8003, 8301-8302, 8304, 8502-8504, 9101, 9103 | 重复 / 非法状态流转 |
| 500 | 1002, 9001 | 服务器内部错误 |
**前端策略**:优先判断 HTTP 状态码,再读取 `body.code` 显示业务信息。
@ -141,7 +176,7 @@ Authorization: Bearer <accessToken>
**登录方式**(开发环境 Mock):
1. `POST /auth/sms/send?phone=<手机号>` 发送验证码
2. 验证码打印在服务日志:`MOCK SMS CODE: ... code=xxxxxx`
2. 验证码**不再输出到日志**;本地按 §3 的受控方式取得(生产禁止查表)
3. `POST /auth/login` 携带 `{phone, code, purpose:"login"}` 获取 token
## 7. 典型联调流程
@ -166,13 +201,6 @@ Authorization: Bearer <accessToken>
{ "jobId": 123 }
```
**企业候选人列表可按岗位匹配度排序**:`GET /company/candidates?sort=match`。
- 列表项在所有排序下**一致返回** `matchScore`(Integer,0-100 整数)、`matchReasons`(简短短语数组,如 `["技能匹配","学历符合","城市匹配","综合评分高"]`)与 `matchPercentile`(Integer,岗位匹配百分位)。
- `matchReasons` 不包含具体技能明细;`sort=match` 时按 `matchScore` 降序,`sort=createdAt/updatedAt` 时按时间排序但同样携带匹配字段。
- **候选人详情 `GET /company/candidates/{applicationId}` 同样一致返回** `matchScore`、`matchReasons` 与 `matchPercentile`,供详情页展示匹配度。
- `matchPercentile` 表示候选人按 `matchScore` 超过了同岗位百分之多少的候选人(口径 `round(countLower/(cohortSize-1)*100)`;同岗位不足 2 人时省略)。
- matchScore/matchReasons/matchPercentile 属企业内部筛选信息,学生端不可见。
### 8.2 岗位八维评价
`POST /jobs/{jobId}/reviews` 可附加可选 `dimensionScores`(八个固定维度,值 1-5,可稀疏提交):
@ -193,7 +221,9 @@ Authorization: Bearer <accessToken>
## 9. Candidate Scoring(求职者评分体系)联调
规则版本固定 `rulesVersion = "candidate-scoring-v0.1"`;八维 `EDUCATION / INTERNSHIP / ABILITY / COMPLIANCE / STABILITY / GROWTH / INTEGRITY / ACTIVITY`,默认权重各 12.50,参与计算维度重新归一化。
默认关闭时规则版本固定 `rulesVersion = "candidate-scoring-v0.1"`;八维 `EDUCATION / INTERNSHIP / ABILITY / COMPLIANCE / STABILITY / GROWTH / INTEGRITY / ACTIVITY`,默认权重各 12.50,参与计算维度重新归一化。
> V38 联调(功能开关 `scoring.ability-assessment.enabled`,默认 `false`):开关开启后规则版本为 `"candidate-scoring-v0.2"`,`ABILITY` 可由最新有效四维能力测评(WORKPLACE_ABILITY、COMPLETED、答题 24/24、`valid_until > capturedNow`)映射为 `OK` 并参与综合分。学生 `ABILITY` 单维明细的 `subScores[]` 为四条白名单聚合分(逻辑能力/35、执行能力/30、学习能力/20、沟通能力/15),不含任何答案/答卷/来源结果 ID;企业评分卡对 `ABILITY` 仍仅暴露聚合 `score/status`。后端本地验证已通过,真实 MySQL/Docker 黑盒待负责人验收。
### 9.1 接口
@ -447,6 +477,8 @@ Authorization: Bearer <accessToken>
- 值可能是**相对存储路径**或文件存储地址,**不保证是完整的 `http/https` URL**。
- 前端**下载简历应调用 `GET /api/v1/profile/me/resume`**,不要直接拼接 `resumeUrl` 做请求地址。
- 上传仍仅接受 PDF/DOC/DOCX 且不超过 10MB;服务端会同时校验扩展名、Content-Type 和实际文件结构。伪装扩展名、损坏文件、危险 DOCX 压缩包或含路径/控制字符的文件名返回 400,前端不要仅依据浏览器 MIME 判断文件有效。
- 上传替换失败时原简历保持可用;成功提交后旧文件才会清理。该流程不改变现有请求字段或响应结构。
### 12.12 graduationYear 特别说明
@ -716,155 +748,269 @@ GET /profile/me/career-progress?page=0&size=20&status=INTERVIEW # 仅 STUDENT
- **只读**:`GET`,不写任何业务表;`size` 上限 50,聚合查询次数与单页条数无关(无 N+1)。
- API 详见 [docs/career-progress-api.md](career-progress-api.md)。
## 21. 行业技能词云、个人匹配度与学习路径联调
## 21. RIASEC 职业兴趣测评(Assessment)联调
基于 RIASEC 理论的自研题库(72 道),**仅供职业兴趣探索参考,不构成心理或职业诊断**。结果不写入求职者评分引擎。
三个接口共用行业、职位和技能分类筛选,仅 STUDENT 可调用,并统一使用 Bearer JWT。完整字段、错误示例和排序语义详见 [skill-cloud-api.md](skill-cloud-api.md)。
**接口(身份一律取 JWT,不接受 `userId` 参数)**
| 功能 | Method | Path | 关键返回/交互 |
|---|---|---|---|
| 行业技能词云 | `GET` | `/skills/word-cloud` | `data.items` 为完整排序结果;前端直接取前 10 条作为 TOP10。 |
| 个人技能匹配度 | `GET` | `/skills/match` | 服务端只读取 JWT 学生资料;返回匹配度、命中技能和缺失技能。 |
| 学习路径 | `GET` | `/skills/learning-path` | 只返回缺失技能、连续优先级和通用学习建议。 |
```
GET /assessments/types # 测评类型列表(任意登录用户)
GET /assessments/HOLLAND_RIASEC/questions # 题目列表,仅 ACTIVE(隐藏 dimension/reverseScored/source/版本/questionCode)
POST /assessments/attempts # 创建答卷(六维各抽 5 题共 30 道)【仅 STUDENT,须 ACTIVE】
GET /assessments/attempts/{attemptId} # 答卷详情(固定题目顺序+已答)
PATCH /assessments/attempts/{attemptId}/answers # 保存答案(value 1-5,同请求内同题不可重复,upsert)
POST /assessments/attempts/{attemptId}/submit # 提交(30 题全答)返回结果
GET /assessments/attempts/{attemptId}/result # 结果(六维聚合分+排名+探索方向)
GET /assessments/me?page=0&size=20 # 我的测评历史
```
**要点**
- **类型 ACTIVE**:题目列表与创建答卷均要求类型为 ACTIVE;INACTIVE/不存在统一 `404/9701`。
- **题目响应**:不返回任何可推导维度或内部编码的字段,`dimension/reverseScored/source/questionVersion/questionCode` 一律不下发;前端只应使用 `id/stem/scaleType`。
- **创建答卷**:返回 30 道题及 `version`;每次创建随机抽题并持久化,**刷新页面 / 再次 GET 不重新抽题**。六维各恰好 5 题、总数严格 30;题库任一维度不足 5 道时拒绝创建。
- **保存答案**:请求带 `version`(乐观锁),并发/版本过期 → `409/9707`;题目不属于本答卷 → `400/9705`;同一请求内同题重复出现 → `400/9705`;同一题跨请求重复保存为 upsert。首次保存后状态变为 `IN_PROGRESS`。
- **提交**:answeredCount<30 → `400/9706`;30 题提交生成结果并写 `ASSESSMENT_ATTEMPT_SUBMITTED` 审计;**重复提交幂等**返回原结果、不重复生成/审计(原子流转 + `attempt_id` 唯一约束,并发提交只生成一条结果/一条审计);不写入 `candidate_dimension_scores`。
- **越权**:答卷题目、已保存答案与结果均仅本人 STUDENT 可读;COMPANY/ADMIN 越权读取一律 `403/2002`。
- **状态机**:DRAFT → IN_PROGRESS → COMPLETED;COMPLETED 不可改;同一学生同一类型最多一份活跃答卷(`409/9708`,由 `active_key` 唯一约束并发原子保证);并发创建竞争失败返回 `409/9708`(不返回 500);同一 `idempotencyKey` 并发重放返回原 attempt(幂等 200);完成后允许重新测评。
- **计分**:`overallScore` 六维按 `percentage = raw/25×100`(HALF_UP scale=2)降序,同分按维度字母升序;前三组成 `primaryCode`。
- **结果脱敏**:不返回 `studentUserId/phone/email/password/原始答案/企业信息/他人信息`;探索方向使用"可能感兴趣的职业探索方向"文案,不使用"适合/精准/最适合/诊断"表述。
- **探索方向**:`career-direction-v0.1`,精确三字母优先,未配置按前三个维度的兜底方向。
- **错误码**:9701(404)/9702(404)/9703(409)/9705(400)/9706(400)/9707(409)/9708(409)/9709(409)。
- **数据表**:新增 `assessment_definitions / assessment_questions / assessment_attempts / assessment_attempt_questions / assessment_answers / assessment_results` 6 张表(V34 迁移)。
- API 详见 [docs/riasec-assessment-api.md](riasec-assessment-api.md)。
## 22. 四维能力测评(Ability Assessment)联调
> 功能版本 V36。学生四维能力自评(逻辑 / 沟通 / 执行 / 学习),每次按每维抽 6 题共 24 题,限时 30 分钟。企业端不可查看学生测评结果;本版不写入候选评分引擎。API 详见 [docs/ability-assessment-api.md](ability-assessment-api.md)。
### 22.1 接口(7 个)
```
GET /abilities/types 类型列表(任意登录用户,仅 ACTIVE)
POST /abilities/attempts 创建答卷(仅 STUDENT)
GET /abilities/attempts/{attemptId} 答卷详情/恢复(仅本人)
PATCH /abilities/attempts/{attemptId}/answers 保存/修改答案 upsert(仅本人)
POST /abilities/attempts/{attemptId}/submit 提交(24/24 且四维各 6,仅本人)
GET /abilities/attempts/{attemptId}/result 结果与每题解析(仅本人)
GET /abilities/me?page=0&size=20 我的历史分页
```
- 不提供 `GET /abilities/{type}/questions`;题目只在人类创建答卷后随 `GET /abilities/attempts/{attemptId}` 返回。
共同 query 参数:`industryId`、`occupationId` 必填正整数;`categoryId` 可选正整数。非法关系返回 `400`,未登录 `401`,非 STUDENT `403`。
### 22.2 创建与恢复
- `POST /abilities/attempts` 传 `{typeCode:"WORKPLACE_ABILITY", idempotencyKey?}`;同幂等键重放返回原答卷,不重抽题。
- 创建仅进入 `DRAFT`,`startedAt/expiresAt` 为空、`remainingSeconds=null`,不开始计时。
- 同学生同类型同时至多存在一份 `DRAFT/IN_PROGRESS` 活跃答卷;再创建返回 `9808`。
- 刷新或重进页面,用 `GET /abilities/attempts/{attemptId}` 恢复:题目顺序固定、返回已选 `selectedOptionId`,不重新抽题。
- 返回字段:`attemptId/status/answeredCount/totalQuestions(24)/completionRate/startedAt/expiresAt/remainingSeconds/questions[{attemptQuestionId,position,itemType,stem,options[{optionId,text}],selectedOptionId}]`。
### 22.3 首次保存启动 30 分钟
- 第 1 个有效答案保存时一次性设置 `startedAt` 与 `expiresAt=startedAt+30分钟`,`status` 转 `IN_PROGRESS` 并返回 `remainingSeconds`。
- 后续保存不延长倒计时。同一 PATCH 内重复 `attemptQuestionId` 或非法 `optionId` → 整体拒绝 `9805`,不启动计时。
- 超时后保存/提交一律按 `9803` 拒绝;读取时自动把超时答卷标为 `EXPIRED` 并释放活跃占位(`active_key` 置空)。
### 22.4 提交条件
- 必须 24/24 且四维每维各 6 答满才能提交,否则 `9806`(产品「完成率 ≥70%」仅示意,不做提交门槛)。
- 提交原子推进 `DRAFT/IN_PROGRESS→COMPLETED`;重复/并发提交幂等返回原结果,只生成 1 条结果与 1 条提交审计。
### 22.5 7 天有效期 / 9810 冷却 / 9811 90 天限制
- 提交生成结果:`validUntil=completedAt+7天`;`validityStatus` 取 `VALID/EXPIRED`(动态判断)。
- 有效结果存续期内禁止重测 → `POST /abilities/attempts` 返回 `9810`(409)。
- 滚动 90 天内最多完成 3 次;达上限 → `9811`(429)。`EXPIRED` 超时答卷不计入完成次数。
- 历史答卷与结果永久保留,可随时分页查询 `GET /abilities/me`。
### 22.6 结果字段(白名单,前端可直接展示)
- `dimensionScores[{code,score,answeredCount}]`(LOGIC/COMMUNICATION/EXECUTION/LEARNING 百分制)、`overallScore`、`weaknesses[{code,score}]`(并列最低维度全返回)、`radar[{code,value}]`、`improvementAreas[{dimension,currentScore,summary,actions}]`、`questionFeedback[{attemptQuestionId,position,stem,selectedOptionId,awardedScore,maxScore,explanation}]`、`validityStatus/validUntil/answeredCount/completionRate/questionSetVersion/rulesVersion/generatedAt/disclaimer`。
- `improvementAreas` 只含 `dimension/currentScore/summary/actions`,不返回 `competencyTag`。
### 22.7 脱敏字段(禁止向学生/前端返回)
- 题目与结果均不得返回:`questionCode`、`competencyTag`、`answerKey`、`scoringRule`、`source`、`difficulty`、`discrimination`,以及 `studentUserId/phone/email/password` 等身份字段。
- `questionFeedback` 允许题干、用户所选项、得分与解析文本,但绝不返回正确选项字母或完整评分规则。
### 22.8 错误码(V36,98xx)
| 码 | 含义 | HTTP |
|---|---|---|
| 9801 | 类型不存在/未启用 | 404 |
| 9802 | 答卷不存在 | 404 |
| 9803 | 答卷状态非法(含超时/已提交) | 409 |
| 9805 | 答案非法(跨答卷/同题重复/非法选项) | 400 |
| 9806 | 未答满 24/24 或某维不足 | 400 |
| 9807 | 并发保存冲突 | 409 |
| 9808 | 已有活跃答卷 | 409 |
| 9809 | 幂等冲突 | 409 |
| 9810 | 有效结果冷却期内不可重测 | 409 |
| 9811 | 90 天完成次数达上限 | 429 |
| 复用 | 1001 参数/业务非法、2001 未登录、2002 无权限 | 400/401/403 |
### 22.9 前端注意
- 学生每次答卷题目固定顺序且数量恒为 24;恢复页只渲染 `questions` 即可,勿依赖外部题库接口。
- 计时用后端 `remainingSeconds` 展示(创建阶段为 null);到期后结果不生成,提示重测需先创建新答卷。
- 结果页必须展示 `disclaimer` 文案(自我了解与提升参考,不构成职业资格/招聘结论/永久标签)。
## 23. 防刷风险概览与运营规则配置(Risk Governance)联调
> 功能版本 V37。仅 ADMIN 使用,把既有拒绝型安全审计(`REPORT_RATE_LIMITED`/`BLOCKED_SEND_PROHIBITED`)转为可聚合的风险事件并支持规则配置与人工标记。不自动封禁/限流,不改动用户、投递、消息、评分状态。API 详见 [docs/risk-governance-api.md](risk-governance-api.md)。
### 23.1 接口(7 个,仅 ADMIN)
```
GET /admin/scoring/risk/overview 风险概览(OPEN 总数/等级分布/规则分布/最近检测)
GET /admin/scoring/risk/trend?bucket=HOUR|DAY 风险趋势(SQL 聚合)
GET /admin/scoring/risk/distribution 按规则×等级分布
GET /admin/scoring/risk/events 事件分页(status/severity/ruleCode/from/to)
GET /admin/scoring/risk/rules 规则列表
PUT /admin/scoring/risk/rules/{ruleCode} 更新规则(expectedVersion 乐观锁)
POST /admin/scoring/risk/events/{eventId}/actions 人工标记(ACKNOWLEDGE/DISMISS)
```
- 身份一律 JWT/SecurityContext;不接受 `adminUserId/operatorUserId/userId` 参数。
- 未登录 `401`;STUDENT/COMPANY `403/2002`;仅 ADMIN 可访问。
### 23.2 时间与分页
- 默认时间范围近 7 天,最大 90 天;`from>to` 或超范围 → `9906`。
- `page` 从 0 起、`size` 1–50;`trend.bucket` 仅 `HOUR`/`DAY`。
### 23.3 规则配置
- 更新请求:`{"enabled":true,"thresholdCount":3,"windowMinutes":15,"severity":"MEDIUM","expectedVersion":2}`。
- 不允许修改 `ruleCode`/`sourceAction`;`expectedVersion` 不匹配 → `9903`(409)。
- 规则只影响后续扫描;禁用规则对审计照常越过 checkpoint,重新启用不回放历史。
### 23.4 人工标记与状态机
- 事件状态机:`OPEN→ACKNOWLEDGED`、`OPEN→DISMISSED`、`ACKNOWLEDGED→DISMISSED`;`DISMISSED` 终态。
- 操作请求:`{"action":"ACKNOWLEDGE","reasonCode":"MONITORING","expectedVersion":0}`。
- `reasonCode` 仅 `MONITORING`/`CONFIRMED_FALSE_POSITIVE`/`OUT_OF_BAND_ESCALATED`,无自由文本备注。
- 冲突/非法流转 → `9904`/`9905`(409)。
### 23.5 响应脱敏(ADMIN 可见字段白名单)
- 事件响应仅:`eventId/ruleCode/sourceAction/severity/status/occurrenceCount/windowStartedAt/windowEndedAt/detectedAt/version`。
- 严禁返回:`subjectUserId`、手机号、邮箱、`ipAddress`、`userAgent`、`details`、`requestId`、token/password/secret 等。运营仅用 `eventId` 定位,不做自动处置。
### 23.6 错误码(V37,99xx)
| 码 | 含义 | HTTP |
|---|---|---|
| 9901 | 规则不存在 | 404 |
| 9902 | 事件不存在 | 404 |
| 9903 | 规则并发更新 | 409 |
| 9904 | 事件并发操作 | 409 |
| 9905 | 非法 action/reasonCode/流转 | 409 |
| 9906 | 非法筛选(时间/bucket/severity/status) | 400 |
| 复用 | 2001 未登录、2002 无权限、1001 分页/参数非法 | 401/403/400 |
联调注意:
### 23.7 前端注意
- 风险入口仅在管理后台;学生/企业端无任何风险接口。
- 规则/阈值的命中可能不会立刻出现:扫描默认每 60 秒跑一次(`app.risk.scan.fixed-delay-ms`)。
- 概览/趋势/分布由后端 SQL 聚合返回,前端无需额外拉取明细。
- `heat=null` 的技能仍会返回,且必须排在所有非空 `heat` 之后;不要在前端丢弃这些技能或自行改变顺序。
- TOP10 不是独立接口字段;统一使用 `data.items.slice(0, min(10, data.total))`。
- 匹配度公式为 `round(matchedCount * 100 / min(10, total))`;范围 15 条、命中 5 条时为 `33`。
- 学习路径不返回课程链接、AI 文本、学习时长或进度记录。
## 24. V40 企业评价管理与官方回复
> 验收状态:真实 MySQL/Docker 黑盒已通过(`PASS=62 FAIL=0 SKIP=0`);OpenAPI 159/196,待 Git 检查点。
## 22. 入职清单与公司知识介绍联调
## V41 企业人才发现
完整接口、字段和错误示例见 [onboarding-api.md](onboarding-api.md)。Base URL 与认证方式沿用本文第 1、5 节:
新增接口详见 [enterprise-talent-discovery-api.md](enterprise-talent-discovery-api.md)。学生先通过 `/profile/me/talent-discovery` 显式开启授权;企业仅能查询已认证企业自己的已发布岗位,岗位需具备 V39 审核通过且启用的画像。候选卡只返回不透明 `candidateKey`、匿名标签、城市、学历、毕业年份、白名单技能、匹配档位、固定概述和可用信号类别,不返回学生身份、联系方式、简历或任何测评分数。
V41 服务端先对全部合格候选计算并全局排序,再切分页;`fitScore` 不下发。无可用岗位画像返回 `UNAVAILABLE`,无候选返回 `NO_ELIGIBLE_CANDIDATE`。OpenAPI 实际为 161 paths / 199 operations(学生 GET/PUT 共用一个 path)。
### V42 企业人才邀约与授权沟通
V42 新增 6 个 path / 6 个 operation,静态 OpenAPI 计数为 167 paths / 205 operations。已认证企业只能对本公司 `PUBLISHED` 且画像 `enabled=true && APPROVED` 的岗位使用 V41 `candidateKey` 创建邀约;请求只提交 `candidateKey`、站内 `message`(1–300 字)和 UUID `idempotencyKey`,不得提交学生 ID。服务端会重新校验授权、账号状态、当前企业全部历史投递、有效推荐信号和每日限额。
> 验收状态:自动化测试 1475/1475 通过;真实 MySQL/Docker 黑盒通过(`PASS=76 / FAIL=0`),运行时与静态 OpenAPI 均为 167 paths / 205 operations。
学生通过 `/profile/me/talent-invitations` 查看邀约,详情中的 `version` 必须原样带入决策请求的 `expectedVersion`。只有 `PENDING` 可 `ACCEPT` 或 `REJECT`;接受成功才返回 `conversationId` 并建立受限 `TALENT_INVITATION` 会话,拒绝或过期不建会话。邀约有效期 7 天,终态后同企业同学生冷却 30 天;重复幂等请求返回原邀约,不重复占用额度。
企业响应不包含 `candidateKey`、`studentUserId`、测评/评分详情、联系方式或内部原因;邀约会话的会话、消息列表和发送响应只返回 `CURRENT_USER`/`OTHER_PARTICIPANT` 相对身份,不暴露 `createdBy`/`senderUserId`。V42 不写 `candidate_dimension_scores`,不发送通知。
> 验收状态:自动化测试 1461/1461 通过;真实 MySQL/Docker 黑盒已通过(`PASS=37 / FAIL=0`)。黑盒确认最新低分测评优先于历史高分、同企业任一历史投递均排除、其他企业投递不误排,并验证全局排序在分页之前完成。
### 24.1 接口
```text
http://127.0.0.1:8080/api/v1
Authorization: Bearer <accessToken>
GET /companies/me/job-reviews
GET /companies/me/job-reviews/{reviewId}
POST /companies/me/job-reviews/{reviewId}/reply
PUT /companies/me/job-reviews/{reviewId}/reply
GET /admin/review-replies/pending
POST /admin/review-replies/{replyId}/decision
```
### 22.1 接口与角色
企业接口只返回当前登录企业自有岗位评价;身份从 JWT 获取,不传 `companyId`、`studentUserId`。STUDENT/ADMIN 为 `403/2002`,未登录为 `401`,跨企业访问不泄露评价是否存在。
| Method | Path | 角色 | 关键交互 |
|---|---|---|---|
| `GET` | `/onboarding/companies/{companyId}/checklist` | STUDENT | 返回通用项与企业活动自定义项;无自定义项时回退通用清单。 |
| `PUT` | `/onboarding/companies/{companyId}/checklist/items/{itemId}/completion` | STUDENT | 绝对状态设置完成/未完成;状态变化时使用 `progressVersion`。 |
| `GET` | `/onboarding/companies/{companyId}/knowledge` | STUDENT | 企业基础资料 + 企业知识 + 公开评价摘要。 |
| `GET` | `/onboarding/company/checklist-items` | COMPANY | 查询本企业活动与软删除自定义项。 |
| `POST` | `/onboarding/company/checklist-items` | COMPANY | 创建本企业自定义项,支持 `idempotencyKey`。 |
| `PUT` | `/onboarding/company/checklist-items/{itemId}` | COMPANY | 使用 `version` 乐观锁更新。 |
| `DELETE` | `/onboarding/company/checklist-items/{itemId}?version={version}` | COMPANY | 软删除;重复删除幂等。 |
| `GET` | `/onboarding/company/knowledge` | COMPANY | 查询本企业知识配置;未配置返回 `configured=false`。 |
| `PUT` | `/onboarding/company/knowledge` | COMPANY | 创建或更新本企业知识配置。 |
### 24.2 回复状态与操作
### 22.2 请求示例
- 创建回复正文 1–500 字符,`idempotencyKey` 必须为 UUID(最长 64);安全文本为 `AUTO_APPROVED`,风险文本为 `PENDING`。
- 同评价最多一个当前回复;同幂等键同评价同正文重放返回原回复,内容不一致为 `409/10106`,已有回复为 `409/10102`。
- 只有 `REJECTED` 回复可以 PUT 修订,必须携带 `expectedVersion`;`AUTO_APPROVED`、`APPROVED`、`PENDING` 不可覆盖或删除。版本冲突为 `409/10104`。
- ADMIN 仅可对 `PENDING` 执行 `APPROVE` 或 `REJECT`;驳回时 `reason` 必填(1–500 字符)。重复决策为 `409/10103`,非法决策为 `400/10107`。
学生更新完成状态:
企业列表/详情的 `reply` 只包含 `id/content/status/version/updatedAt/rejectionReason`。禁止依赖 `studentUserId`、学生姓名、联系方式、IP/UA、`moderationScore`、内部 `reasonCodes` 或管理员 ID。
```http
PUT /api/v1/onboarding/companies/10/checklist/items/501/completion
Authorization: Bearer <studentAccessToken>
Content-Type: application/json
### 24.3 公开评价与官方回复
{"completed": true, "version": 0}
既有 `/jobs/{jobId}/reviews` 与 `/jobs/{jobId}/reviews/{reviewId}` 的评价载荷不再包含 `studentUserId`,可显示固定 `reviewerLabel="匿名求职者"`。仅公开评价且回复状态为 `AUTO_APPROVED`/`APPROVED` 时显示:
```json
{"officialReply":{"content":"感谢反馈。","repliedAt":"2026-09-04T10:05:00"}}
```
企业创建自定义项:
`officialReply` 不含回复 ID、公司 ID、审核状态、版本、原因码或审核人信息;`PENDING`/`REJECTED` 不渲染该区域。前端应兼容字段缺省或 `null`。
```http
POST /api/v1/onboarding/company/checklist-items
Authorization: Bearer <companyAccessToken>
Content-Type: application/json
### 24.4 管理员页面
{
"title": "领取门禁卡",
"description": "到行政前台领取门禁卡并完成权限激活。",
"category": "ADMIN",
"sortOrder": 1000,
"required": false,
"idempotencyKey": "company-10-access-card-20260917"
}
```
待审列表可按公司、岗位、风险等级和时间筛选;管理员响应中的风险等级/原因码只用于后台审核,不得透传到公开评价。V40 不提供回复通知、统计、申诉或公开历史接口。
企业更新知识:
### 24.5 错误码
```http
PUT /api/v1/onboarding/company/knowledge
Authorization: Bearer <companyAccessToken>
Content-Type: application/json
`10101` 回复不存在(404)、`10102` 已有回复(409)、`10103` 状态非法(409)、`10104` 并发冲突(409)、`10105` 正文/幂等键非法(400)、`10106` 幂等冲突(409)、`10107` 决策非法(400)。
{
"culture": "以用户价值为中心,保持透明和长期主义。",
"workPolicies": "弹性上班;每周一、三线下办公。",
"onboardingGuide": "报到当天携带身份证件,联系行政同事完成入职登记。",
"version": 1
}
```
## 24. V39 测评驱动岗位推荐(编码中)
### 22.3 响应与前端状态处理
- 开关 `RECOMMENDATION_ASSESSMENT_ENABLED` 默认 `false`:关闭时 `GET /profile/me/job-recommendations` 仅返回 `mode=FALLBACK` 的最新公开岗位,不展示伪造的测评匹配分。
- 开启后按学生偏好 40%、RIASEC 聚合分 35%、四维能力聚合分 25% 做实时规则计算;缺少任一数据源时对可用项重归一化。推荐不读取题目、答案或候选评分快照。
- 新增公开 `GET /job-recommendation-taxonomies`;企业通过 `GET/PUT /companies/me/jobs/{jobId}/recommendation-profile` 选择平台受控行业/岗位族并填写画像。更新启用画像后状态为 `PENDING_REVIEW`,仅 ADMIN 审核通过的启用画像参与个性化推荐。
- 学生接口:`GET /profile/me/job-recommendations?page&size`;`POST /profile/me/job-recommendations/{jobId}/feedback`;`DELETE .../feedback?expectedVersion=`。`NOT_INTERESTED` 会持久化排除该岗位;解释反馈不影响首版分数。
- ADMIN:`GET /admin/job-recommendation-profiles`、`POST /admin/job-recommendation-profiles/{jobId}/decision`。企业端不得取得任何学生分数或推荐理由;学生端不得取得画像权重、测评结果 ID 或题库字段。
成功响应统一读取 `data`。学生有效清单关键字段:
## 25. V43 候选评分公平性与稳定性监测
V43 仅供管理员后台使用,前端不得向学生或企业展示。当前状态为版本内补集定向修复完成、待独立复验;总览和申诉使用最终 SQL 聚合,并统一执行独立候选人数与补集抑制。`drift` 和 `ranking-stability` 在当前没有不可变评分历史与排名快照时返回 `availability=UNAVAILABLE`,页面应显示“暂无可比历史数据”。
## 27. N07 消息撤回与会话一致性
消息发送者可在发送后两分钟内调用 `POST /conversations/{conversationId}/messages/{messageId}/recall` 撤回本人消息。请求不接收用户 ID;服务端从登录身份、会话成员关系和消息发送者校验归属。窗口边界采用 `capturedNow <= createdAt + 2 minutes`,恰好两分钟仍允许,超过窗口返回 409。非成员、非原发送者、管理员和跨会话访问均按既有 403/2002 语义处理。
成功撤回后,消息状态为 `RECALLED`,正文统一显示“消息已撤回”,并保留原时间线位置;响应、消息列表、发送响应和审计均不再返回原正文。重复撤回幂等返回当前撤回状态,不重复写审计。撤回消息不计入未读数,`markRead` 不为撤回消息新增已读记录。`DIRECT` 会话保持既有 `senderUserId` 语义,`TALENT_INVITATION` 会话继续只返回 `CURRENT_USER`/`OTHER_PARTICIPANT` 相对身份。完整接口契约见 [messaging-api.md](messaging-api.md)。
## 26. N02 候选评分规则解释与合理性
学生评分卡和单维详情在保持原有路径不变的前提下增加固定解释字段。综合卡新增 `explanationVersion=candidate-scoring-explanation-v1`、`coverage`(实际参与 overallScore 的维度及排除原因)和 `comparabilityNotice`;单维详情新增中性 `displayName`、`whatItMeasures`、`interpretation`、`limitations`、`evidenceLevel`、`ruleContributions`、`improvementActions`、`explanationVersion`、`validity`(仅 ABILITY)和 `calculatedAt`。
`ruleContributions` 与真实评分分支同源,贡献之和可对账最终维度分数;固定模板不使用 AI/LLM/SHAP。`dataSources` 只显示“个人教育资料”“求职偏好”“平台求职活动”“简历与技能资料”“Offer 履约与候选反馈”“四维能力测评”等产品语义名称,不展示表名、字段路径、题目、答案、答案键或评分规则。`INTEGRITY`、`GROWTH`、`STABILITY`、`COMPLIANCE`、`ACTIVITY` 等信号均明确为平台资料/行为代理,不代表人格、信用、成长潜力、服从性、工作积极性或录用结论。
企业评分接口 `/company/candidates/{applicationId}/scoring` 的字段和 JSON 结构保持不变,企业不返回任何解释字段、行为细分、规则贡献、改进建议或 ABILITY 四维详情。N02 不新增 Flyway、表、ErrorCode、公网路径、消息或审计动作,也不改变分数、权重、排名、推荐、申诉和快照写入语义。
可用接口、参数和小样本规则见 [candidate-scoring-fairness-api.md](candidate-scoring-fairness-api.md)。前端不得根据城市、学校、姓名等字段创建敏感人群筛选,也不得尝试通过多次小样本查询反推出候选人。
总览、维度和申诉响应都显式返回 `availability/reason`。状态/版本/回滚原因及分布桶均按独立候选人数计算;`appealCount` 仅表示事件数。`SUPPRESSED` 时不得用总体计数、补集或其它接口字段反推出被隐藏比例,`NO_DATA`、`INSUFFICIENT_SAMPLE`、`SUPPRESSED` 均不得渲染为 `NORMAL`。
## 13. 企业下载候选人简历(S03)
```json
{
"companyId": 10,
"companyName": "示例科技",
"customized": true,
"totalItems": 13,
"completedItems": 1,
"completionRate": 7.7,
"items": [
{
"itemId": 101,
"source": "STANDARD",
"title": "准备身份证件",
"category": "DOCUMENT",
"required": true,
"sortOrder": 100,
"completed": true,
"completedAt": "2026-09-17T09:00:00",
"progressVersion": 1
}
]
}
```
GET /api/v1/company/candidates/{applicationId}/resume
Authorization: Bearer <COMPANY 用户 accessToken>
```
公司知识回退:
响应是**文件流**(不是 JSON 包装),前端应按二进制处理:
- `knowledgeState=CONFIGURED`:展示 `knowledge`。
- `knowledgeState=BASIC_ONLY`:只展示 `company` 基础资料,不展示知识字段。
- `reviewSummary.state=EMPTY/INSUFFICIENT`:只展示评价数量;不要展示均值、分布和八维汇总。
- `reviewSummary.state=SUFFICIENT`:可以展示平均分、评分分布、热门标签和八维汇总。
- 成功:`200` + `Content-Type`(PDF/DOC/DOCX)+ `Content-Disposition: attachment; filename*=UTF-8''…`
+ `Cache-Control: no-store`
- `401` 未登录或会话失效
- `403` 非 COMPANY 角色、无企业资料、跨企业投递,或候选人隐私设置不允许
- `404` 投递不存在(`APPLICATION_NOT_FOUND`)或学生没有简历(`PROFILE_NOT_FOUND`)
错误处理:
前端注意事项:
| HTTP/code | 前端动作 |
|---|---|
| `401/401` | 清理 Token,跳转登录;不要自动重试。 |
| `403/2002` | 当前角色或企业不匹配,不重试并提示切换正确账号。 |
| `404/5001` | 企业不存在或企业账号未绑定企业,停止当前页面数据加载。 |
| `404/9701` | 条目已删除、跨企业或已不在有效清单;重新加载清单。 |
| `409/9702` | 企业条目版本过期;重新查询 `/company/checklist-items` 后让用户确认再提交。 |
| `409/9703` | 活动自定义项达到 50 条;禁用新增,提示先软删除旧项。 |
| `409/9704` | 幂等键已用于不同请求体;更换 key 或展示原条目,不可盲目重试旧请求。 |
| `409/9705` | 学生进度版本过期;重新查询有效清单,使用最新 `progressVersion`。 |
| `409/9706` | 企业知识版本过期;重新查询配置,使用最新 `version`。 |
幂等与软删除注意:
- 创建重试必须复用原 `idempotencyKey` 和完全相同的标题、说明、分类、排序、必填标记。
- DELETE 只在首次删除时要求当前版本;重复删除虽幂等,但客户端仍应携带 `version` 查询参数。
- 更新/删除成功后必须用响应中的新 `version` 覆盖本地状态,不能继续使用旧值。
## 薪资计算器(N05)
- 接口文档:[salary-calculator-api.md](salary-calculator-api.md)
- 全部接口要求登录且当前用户具有 `STUDENT` 角色。
- 前端切换城市后必须重新调用城市详情,清空上一城市的覆盖项,并在计算请求中携带当前 `policyVersion`。
- `dataLevel=PROVINCE_FALLBACK` 必须展示为“省级回退估算”,不能显示成城市级官方精确值。
- `POST /salary-calculator/calculations` 成功即自动生成历史;网络重试应复用 `idempotencyKey`。
- 收到 `409/9805` 时读取响应中的最新政策版本和生效日期,重新加载城市参数后再试算。
- 历史列表使用 `page=0`、`size=20`,`size` 最大 100;详情返回完整输入、政策和结果快照。
- 删除历史为物理删除;删除后前端应从列表移除该记录。
1. **不要**从候选人列表/详情里找 `resumeUrl` —— 这些 DTO 有意不含该字段;
下载必须走上面这个接口。
2. 403 与 404 都按「不可下载」处理,**不要**在 UI 上区分「隐私拒绝」与「跨企业」,
避免泄露归属信息。
3. 下载链接不要放进 URL 或日志;如需预览,请用同一 Authorization 头请求。
4. 响应已声明 `no-store`,前端不要再把简历文件写入本地持久缓存。

368
docs/message-recall-design.md

@ -0,0 +1,368 @@
# N07 消息撤回与会话一致性:详细设计
状态:N07 定向整改完成并通过自动化与干净构建,待独立代码复验与真实 MySQL/Docker 验收。
文档类型:解释与技术参考。目标读者为产品负责人、后端开发、前端联调和独立验收人员。
## 1. 目标
N07 为现有站内会话增加有限、可审计、并发安全的消息撤回能力。发送者可在发送后两分钟内撤回自己的消息;会话成员继续看到一条撤回占位记录,但不能再通过列表、详情、缓存或审计取得原正文。
本版同时统一消息响应的白名单映射,保证 `DIRECT` 会话保持既有身份字段语义,`TALENT_INVITATION` 会话继续使用相对身份标识,不重新暴露学生内部 ID。
## 2. 当前实现事实
- 消息表由 `V9__messaging.sql` 创建,并由 N07 的 `V41__message_recall.sql` 增加 `status`、`recalled_at` 和 `version`。
- `Message` 实体使用字符串状态、撤回时间和 JPA `@Version`;撤回通过条件更新保证并发安全。
- `MessagingService` 的既有 Map 响应和 N07 撤回响应均委托给统一的 `MessageViewMapper`,不直接返回实体。
- `DIRECT` 消息当前返回 `senderUserId`;这是既有兼容语义。
- `TALENT_INVITATION` 消息返回 `senderScope=CURRENT_USER/OTHER_PARTICIPANT`,不返回另一方用户 ID。
- 当前没有附件、引用消息、消息编辑、WebSocket 推送或消息撤回。
- 未读数当前基于最近 500 条消息与 `message_reads` 计算。
- 最新主线迁移为 V40;并行工作存在迁移号冲突风险。
## 3. 范围
### 3.1 本版包含
- 发送者撤回本人发送的消息;
- `DIRECT` 与 `TALENT_INVITATION` 两种会话;
- 两分钟撤回窗口;
- 重复撤回幂等;
- 数据库条件更新与乐观锁;
- 固定撤回占位文本;
- 撤回消息不计入未读数;
- 白名单消息响应 DTO;
- 一条脱敏安全审计;
- 自动化、OpenAPI、联调文档和真实 MySQL 黑盒验收。
### 3.2 本版不包含
- 消息编辑、删除、批量撤回;
- 管理员代撤回;
- 撤回恢复;
- 附件和引用消息;
- WebSocket、SSE 或 RabbitMQ 撤回通知;
- 离线推送、短信或邮件;
- 保存或公开原正文历史;
- 内容审核、AI 摘要或聊天风控扩展;
- 改变 DIRECT 会话创建的 MATCHED 守卫;
- 改变 V42 邀约接受后建会话的语义。
## 4. 产品规则
### 4.1 时间窗口
撤回截止时间为:
```text
recallDeadline = message.createdAt + 2 minutes
```
当 `capturedNow <= recallDeadline` 时允许撤回;当 `capturedNow > recallDeadline` 时返回冲突。一次请求只捕获一次 `capturedNow`,所有判断和 `recalledAt` 均使用该时间。
### 4.2 权限
- 请求者必须已登录;
- 请求者必须是会话成员;
- 消息必须属于路径中的会话;
- 只有 `senderUserId` 对应的原发送者可以撤回;
- 其他成员、ADMIN 以及不属于该会话的用户均返回既有 `403/2002`;
- 不通过不同错误暴露某条消息是否属于其他会话。
### 4.3 状态机
```text
SENT -> RECALLED
```
- `SENT` 可以在窗口内转为 `RECALLED`;
- `RECALLED` 再次撤回返回同一当前结果,HTTP 200,不重复更新、不重复审计;
- 窗口外的 `SENT` 返回 409;
- 没有其他状态迁移。
## 5. 数据库方案
编码前从实际主线确定下一可用 Flyway 版本,文件说明建议为:
```text
V<next>__message_recall.sql
```
不得预先假定 V41 未被其他分支占用,也不得修改 V1–V40。
在 `messages` 增加:
| 字段 | 类型 | 约束 | 说明 |
|---|---|---|---|
| status | VARCHAR(20) | NOT NULL DEFAULT 'SENT' | `SENT` 或 `RECALLED` |
| recalled_at | DATETIME | NULL | 实际撤回时间 |
| version | BIGINT | NOT NULL DEFAULT 0 | JPA `@Version` |
约束建议:
```sql
CHECK (status IN ('SENT','RECALLED'))
```
现有 `conversation_id, created_at` 索引继续用于分页。本版不新增撤回历史表,不保存原正文副本,不新增包含正文的审计表。
迁移必须同时通过 H2 `MODE=MySQL` 与真实 MySQL 8.x,并保持 `ddl-auto=validate`。
## 6. 正文与数据保留
撤回成功时,将 `content` 覆盖为固定内部占位值:
```text
消息已撤回
```
响应层也只返回这一固定文本。原正文不得写入:
- audit_logs;
- 应用日志;
-异常消息;
- history 表;
- RabbitMQ;
-缓存或导出结果。
数据库备份和存储层面的物理恢复策略属于 G02,不在 N07 内建立正文恢复接口。
## 7. HTTP 接口
新增一个接口:
```http
POST /conversations/{conversationId}/messages/{messageId}/recall
```
无请求体。身份仅从 JWT/SecurityContext 获取。
成功响应使用白名单 `MessageResponse`:
```json
{
"code": 200,
"data": {
"id": 81,
"conversationId": 12,
"senderUserId": 7,
"content": "消息已撤回",
"status": "RECALLED",
"recalledAt": "2026-09-10T10:01:30",
"createdAt": "2026-09-10T10:00:00",
"isRead": false
}
}
```
对于 `TALENT_INVITATION`,响应使用 `senderScope`,不得返回 `senderUserId`。
### 7.1 错误语义
| 场景 | HTTP / 业务码 |
|---|---|
| 未登录 | 401 / 既有未认证码 |
| 非成员、非发送者、跨会话探测、ADMIN | 403 / 2002 |
| 会话不存在 | 404 / 既有会话不存在码 |
| 当前会话内消息不存在 | 404 / 新增消息不存在码或经评审复用现有码 |
| 超过撤回窗口 | 409 / 新增撤回窗口错误码 |
| 并发状态冲突 | 409 / 新增并发冲突错误码 |
ErrorCode 属于高冲突文件。编码前应先核对现有码值,并只新增确有必要的错误码。
## 8. 响应 DTO 与兼容
新增白名单 DTO,建议:
- `MessageResponse`;
- 如分页结构尚无类型,可新增 `MessagePageResponse`。
允许字段:
- id;
- conversationId;
- senderUserId,仅 DIRECT;
- senderScope,仅 TALENT_INVITATION;
- content;
- status;
- recalledAt;
- createdAt;
- isRead。
禁止字段:
- 对方在 TALENT_INVITATION 中的 userId;
- 手机号、邮箱、姓名、简历;
- 邀约 candidateKey 或摘要;
- 原撤回正文;
- IP、User-Agent、Token、密码;
- 内部审计内容。
现有发送与列表接口兼容新增 `status` 和可空 `recalledAt`。DIRECT 的 `senderUserId` 不删除;TALENT_INVITATION 继续只提供相对身份。
## 9. 服务与事务
建议新增:
```java
MessageResponse recallMessage(
Long currentUserId,
Long conversationId,
Long messageId,
LocalDateTime capturedNow,
AuditContext auditContext)
```
事务步骤:
1. 查询会话并校验成员;
2. 查询消息,校验其属于当前会话;
3. 校验请求者为发送者;
4. 若已撤回,返回当前白名单响应;
5. 校验两分钟窗口;
6. 使用 `id + conversationId + senderUserId + status=SENT + version` 条件更新;
7. 更新 `status=RECALLED`、固定占位正文、`recalledAt=capturedNow`;
8. 条件更新成功后写一条审计;
9. 返回重新读取或由确定字段构造的响应。
只有成功发生 `SENT -> RECALLED` 的请求写审计。幂等重放和失败请求不重复写成功审计。
## 10. 并发与幂等
- `Message.version` 使用 JPA `@Version`;
- Repository 提供条件更新,不能只依赖“先查后改”;
- 两个并发撤回请求只能一个执行状态变更并写审计;
- 另一请求重新读取后若状态已为 `RECALLED`,按幂等成功返回;
- 撤回与列表读取允许并发,读取结果只能是完整 `SENT` 或完整 `RECALLED`,不能返回已撤回状态配原正文;
- SQL异常和锁异常不得转为空成功。
## 11. 未读与列表
- `RECALLED` 消息保留在消息分页中,维持时间线位置;
- 撤回占位消息的 `isRead` 可以保留既有记录语义;
- `RECALLED` 消息不计入 unreadCount;
- 不删除已有 `message_reads`;
- `markRead` 不需要为撤回消息新增 read 记录;
- 列表、发送响应、撤回响应必须共用同一消息映射器。
现有 `countUnread` 与 `markRead` 存在最多读取 500 条的历史限制。N07 只修正撤回消息口径,不借机重写整个未读架构;容量治理记录到 G03。
## 12. 审计
成功状态变更写:
```text
MESSAGE_RECALLED
```
details 白名单:
```json
{
"messageId": 81,
"conversationId": 12,
"conversationType": "DIRECT",
"status": "RECALLED",
"requestId": "..."
}
```
禁止写入正文、对方用户 ID、手机号、邮箱、简历、IP/UA 明文、Token、密码或邀约标识。
## 13. 性能与查询
- 消息分页不得因撤回字段产生逐消息额外查询;
- 会话类型只读取一次,不能逐消息查询 Conversation;
- 已读状态继续批量查询,禁止逐消息 `existsBy...`;
- unreadCount 应通过数据库计数或固定批量查询实现,排除 `RECALLED`;
- 查询次数不得随消息数量线性增长;
- 为真实 MySQL EXPLAIN 保留消息分页和未读计数 SQL。
## 14. OpenAPI 影响
当前基线为 172 paths / 210 operations。若仅新增一个撤回接口,预计为:
```text
173 paths / 211 operations
```
实际计数以静态 OpenAPI、运行时 `/api/v1/v3/api-docs` 和校验脚本为准。
OpenAPI 必须断言:
- 仅新增一个 N07 path/operation;
- 撤回接口 200 响应有非空 schema;
- DIRECT 与邀约消息隐私契约明确;
- 响应不包含原正文历史或用户隐私字段;
- 既有 6 个消息 operations 保持存在。
## 15. 测试与验收
### 15.1 单元与服务测试
- 发送后 0 秒、1:59、恰好 2:00 可撤回;
- 2:00:00 后最小时间单位不可撤回;
- 非发送者、非成员、ADMIN 拒绝;
- 消息和路径会话不一致返回 403,避免枚举;
- 重复撤回幂等;
- 并发撤回仅一次更新和一次审计;
- 固定 Clock,全请求只捕获一次;
- 审计白名单不含正文。
### 15.2 HTTP 与隐私测试
- DIRECT 发送、列表和撤回保持 senderUserId;
- TALENT_INVITATION 仅返回 senderScope;
- 已撤回消息所有接口只返回占位内容;
- 完整 JSON 不含原正文、对方 ID、联系方式或凭据;
- 未登录 401、越权 403/2002。
### 15.3 持久化与 MySQL
- 迁移在 H2 MySQL 模式和 MySQL 8.x 执行;
- `CHECK`、默认状态和 `@Version` 正确;
- 撤回后数据库正文为固定占位;
- 并发条件更新正确;
- `ddl-auto=validate` 通过;
- 消息分页和未读 SQL EXPLAIN 无发布阻断。
### 15.4 回归
- DIRECT MATCHED 守卫不变;
- V33 屏蔽发送规则不变;
- V42 邀约接受、会话隐私和唯一会话不变;
- 消息发送、列表、已读原有功能通过;
- 不写 candidate_dimension_scores;
- 不发送新增 RabbitMQ 消息。
## 16. 风险
- 并行分支可能占用迁移号,编码前必须重新确认;
- Map 响应迁移为 DTO 时容易意外删除旧字段,必须做兼容 JSON 对照;
- 撤回与列表并发可能暴露状态和正文不一致,必须原子更新并统一映射;
- 当前未读实现有 500 条上限,不能将其误称为完整历史未读统计;
- 数据库备份可能保留历史物理页,本功能只保证业务接口、当前数据库值、日志和审计不暴露原正文。
## 17. 已确认决策
| 决策 | 结论 |
|---|---|
| D01 | 撤回窗口为发送后 2 分钟 |
| D02 | `capturedNow <= deadline` 允许撤回 |
| D03 | DIRECT 与 TALENT_INVITATION 均支持 |
| D04 | 仅原发送者可撤回 |
| D05 | 使用 conversationId 与 messageId 的 POST 撤回接口 |
| D06 | 重复撤回幂等返回 200 |
| D07 | 保留固定撤回占位消息 |
| D08 | 数据库正文覆盖为固定占位,不保存原正文历史 |
| D09 | 撤回消息不计入 unreadCount,不删除已有 read 记录 |
| D10 | messages 增加 status、recalled_at、version,无历史表 |
| D11 | @Version 与条件更新共同保证并发 |
| D12 | 仅成功转换写 MESSAGE_RECALLED 脱敏审计 |
| D13 | 一期不新增实时推送或 RabbitMQ 事件 |
| D14 | 附件与引用消息不在本版范围 |
| D15 | 使用白名单 DTO,保持两种会话的既有身份语义 |
| D16 | 401/403/404/409 按认证、归属、存在性和状态区分 |
| D17 | 预计新增 1 path / 1 operation |
| D18 | Flyway 版本在编码前按实际主线分配 |

293
docs/message-recall-implementation-instruction.md

@ -0,0 +1,293 @@
# N07 消息撤回:实施指令
状态:N07 定向整改完成并通过自动化与干净构建,待独立代码复验与真实 MySQL/Docker 验收。
文档类型:操作指南。用于指导实现者在不扩大范围的前提下完成 N07。
## 1. 开始前
项目根目录:
```text
C:\Users\15642\Desktop\zhilai
```
先完整阅读:
- `docs/message-recall-design.md`;
- `docs/frontend-integration.md`;
- `docs/enterprise-talent-invitation-api.md`;
- `docs/report-block-api.md`;
- `docs/phase1-scope-and-acceptance.md`。
重点代码:
- `messaging/Message.java`;
- `messaging/MessageRepository.java`;
- `messaging/MessagingService.java`;
- `messaging/MessagingController.java`;
- `messaging/Conversation.java`;
- `messaging/ConversationMemberRepository.java`;
- `talentinvitation/`;
- `safety/BlockRelationRepository.java`;
- `audit/SecurityAuditService.java`;
- `config/SecurityConfig.java`;
- `common/exception/ErrorCode.java`;
- `common/exception/GlobalExceptionHandler.java`。
先执行:
```powershell
git status --short
git diff --cached --name-only
git log -5 --oneline
git diff --check
```
如果暂存区非空,停止并报告。保护所有历史未跟踪文件,不得 reset、restore、checkout、clean 或 `git add -A`。
## 2. 迁移号分配
编码前列出迁移:
```powershell
Get-ChildItem mydreamjob-server/src/main/resources/db/migration -File |
Sort-Object Name |
Select-Object -ExpandProperty Name
```
同时检查其他待合并分支是否已经占用下一版本。只有负责人确认后才创建:
```text
V<next>__message_recall.sql
```
禁止修改 V1–V40,禁止擅自假定 V41 可用。
迁移只允许为 `messages` 增加:
- `status VARCHAR(20) NOT NULL DEFAULT 'SENT'`;
- `recalled_at DATETIME NULL`;
- `version BIGINT NOT NULL DEFAULT 0`;
- `status` 的受控 CHECK。
不新增历史表,不保存正文副本。
## 3. 实体与 Repository
修改 `Message`:
- 新增字符串状态或受控枚举;
- 新增 `recalledAt`;
- 新增 `@Version Long version`;
- 发送创建状态固定为 `SENT`。
Repository 增加条件更新,条件至少包括:
- messageId;
- conversationId;
- senderUserId;
- status=`SENT`;
- expectedVersion。
更新内容:
- status=`RECALLED`;
- content=`消息已撤回`;
- recalledAt=capturedNow;
- version 原子递增。
不得只用 `findById -> set -> save` 作为并发保证。
消息分页和未读查询要排除撤回消息的未读贡献,并采用批量查询,禁止逐消息查 Conversation 或 read 状态。
## 4. DTO 与映射
在 `messaging/dto/` 新增白名单 DTO:
- `MessageResponse`;
- 必要时新增 `MessagePageResponse`。
把发送、列表、撤回响应收敛到同一个映射器。
DIRECT 兼容保留 `senderUserId`;TALENT_INVITATION 只返回 `senderScope`。撤回状态下任何路径都只能返回固定占位正文。
不得直接返回 Message Entity,不得继续在 Controller 拼 Map。
## 5. 服务实现
为 `MessagingService` 注入 `Clock`,生产默认系统时区。撤回入口只捕获一次 `capturedNow`。
实现顺序:
1. requireConversation;
2. assertMember;
3. 查询消息并防止跨会话枚举;
4. 校验发送者;
5. 已撤回则幂等返回;
6. 校验 `capturedNow <= createdAt.plusMinutes(2)`;
7. 执行条件更新;
8. 失败时重新读取并区分幂等完成与真实冲突;
9. 成功后写一次脱敏审计;
10. 返回白名单响应。
不得把原正文写入日志、异常或审计。
## 6. Controller 与接口
新增:
```http
POST /conversations/{conversationId}/messages/{messageId}/recall
```
无 requestBody,不接收 userId。身份从 SecurityContext/JWT subject 获取。
Controller 只做参数接收、身份解析和响应包装,不包含撤回规则。
## 7. ErrorCode
先搜索现有消息错误码。只在没有可准确复用的代码时新增:
- MESSAGE_NOT_FOUND;
- MESSAGE_RECALL_WINDOW_EXPIRED;
- MESSAGE_RECALL_CONCURRENT_UPDATE。
保持:
- 未登录 401;
- 非成员、非发送者和跨会话探测 403/2002;
- 超时和并发冲突 409。
不得改变已有错误码含义。ErrorCode 和 GlobalExceptionHandler 修改前应与集成负责人确认。
## 8. 审计
成功状态转换写一条 `MESSAGE_RECALLED`。
details 只允许:
- messageId;
- conversationId;
- conversationType;
- status;
- requestId。
幂等重放、超时、越权和并发失败不写成功审计。禁止记录正文、对方 ID、联系方式、IP/UA 明文或凭据。
## 9. 必须新增的测试
建议测试类:
- `MessageRecallServiceTest`;
- `MessageRecallControllerTest`;
- `MessageRecallPersistenceIntegrationTest`;
- `MessageRecallPrivacyIntegrationTest`。
必须覆盖:
1. 窗口内撤回;
2. 1:59 与恰好 2:00 成功;
3. 超过 2:00 的最小时间单位失败;
4. 重复撤回 HTTP 200 且不重复审计;
5. 非发送者、非成员、ADMIN、匿名;
6. messageId 属于另一 conversationId 时 403/2002;
7. 两个并发请求只有一次条件更新和审计;
8. 撤回后数据库正文为固定占位;
9. 列表、撤回响应均不返回原正文;
10. DIRECT 保留 senderUserId;
11. TALENT_INVITATION 不返回 senderUserId、createdBy 或学生 ID;
12. 撤回消息不计 unreadCount;
13. 已有 message_reads 不删除;
14. markRead 不为撤回消息产生新记录;
15. 消息分页无逐消息查询;
16. 审计 details 白名单;
17. V33 屏蔽发送回归;
18. V42 邀约会话隐私回归;
19. DIRECT MATCHED 守卫回归;
20. `candidate_dimension_scores` 不变。
完整 JSON 禁止出现:
```text
originalContent
previousContent
studentUserId
candidateId
phone
email
resumeUrl
candidateKey
candidateKeyDigest
ipAddress
userAgent
password
token
secret
```
## 10. OpenAPI 与文档
更新:
- `docs/messaging-api.md`,不存在则新增;
- `docs/frontend-integration.md`;
- `docs/backend-development-roadmap.md`;
- `docs/phase1-scope-and-acceptance.md`;
- `docs/openapi-rc.json`;
- `docs/verify_openapi.py`;
- 本设计和实施指令状态。
目标预计为 173 paths / 211 operations,以实际静态和运行时结果为准。
校验脚本必须递归检查消息响应 schema,不得只检查计数。
## 11. 自动化验证
依次执行:
```powershell
cd C:\Users\15642\Desktop\zhilai\mydreamjob-server
mvn -Dtest="*MessageRecall*Test,*Messaging*Test,*TalentInvitation*Test" test
mvn test
mvn clean verify
python ..\docs\verify_openapi.py
git diff --check
```
记录真实 Tests run、Failures、Errors、Skipped 和 OpenAPI 计数。禁止删除测试或修改期望语义掩盖回归。
## 12. 真实 MySQL/Docker 验收
自动化完成后由负责人授权执行:
- Flyway 新迁移 success=1;
- `ddl-auto=validate` 通过;
- 窗口边界与并发撤回;
- 重复撤回只一条审计;
- 数据库正文不可恢复为业务明文;
- 权限矩阵;
- DIRECT 与邀约完整 JSON 脱敏;
- unreadCount 与 read 记录;
- 消息分页和未读 SQL EXPLAIN;
- RabbitMQ 无新增消息;
- 运行时 OpenAPI 与静态文件一致。
未执行真库验收不得标记冻结。
## 13. Git 边界
本轮实现期间:
- 不操作 Docker,除非负责人明确要求黑盒;
- 不暂存验收脚本、日志、请求体或 `.env`;
- 不执行 `git add -A`;
- 不提交、不推送,直到独立验收完成;
- 不删除历史未跟踪文件;
- 不进入 N08。
完成代码和自动化后,状态只能写为:
```text
N07 定向整改完成并通过自动化与干净构建,待独立代码复验与真实 MySQL/Docker 验收。
```

33
docs/messaging-api.md

@ -0,0 +1,33 @@
# 站内消息接口
## 消息撤回
`POST /conversations/{conversationId}/messages/{messageId}/recall`
当前登录用户从 JWT subject 解析。请求无 body,只有会话成员中的原发送者可在消息创建后两分钟内撤回;截止瞬间仍允许撤回。会话不存在返回 404/8305,跨会话、非成员或非发送者返回 403/2002,窗口结束返回 409/8307,并发状态冲突返回 409/8308。
成功响应为白名单 `MessageResponse`:
```json
{
"code": 200,
"data": {
"id": 81,
"conversationId": 12,
"senderUserId": 7,
"content": "消息已撤回",
"status": "RECALLED",
"recalledAt": "2026-09-10T10:02:00",
"createdAt": "2026-09-10T10:00:00",
"isRead": false
}
}
```
撤回会在同一事务中将数据库正文覆盖为固定占位文本 `消息已撤回`,并将状态从 `SENT` 更新为 `RECALLED`。重复请求幂等返回当前撤回结果,不重复写成功审计。原正文不写入审计、异常、缓存、消息队列或历史表。
`TALENT_INVITATION` 会话继续使用 `senderScope=CURRENT_USER` 或 `OTHER_PARTICIPANT`,不返回对方的 `senderUserId`;`DIRECT` 会话保留既有 `senderUserId` 兼容字段。撤回消息仍保留在时间线中,但不计入未读数,也不会因标记已读新增 `message_reads`。
## 既有消息接口兼容
原有会话列表、会话详情、发送消息、消息列表和标记已读接口路径不变。消息列表和发送响应增加 `status`,撤回消息的 `content` 统一为占位文本,其他既有字段语义保持不变。

87
docs/n07-independent-mysql-acceptance-20260910.md

@ -0,0 +1,87 @@
# N07 消息撤回与会话一致性:独立代码复验与真实 MySQL/Docker 验收
日期:2026-09-10
分支:`feat/message-recall`
结论:**PASS(真实 MySQL/Docker 黑盒已完成;可进入精确暂存检查点)**
## 1. Git 基线与边界
- 当前分支:`feat/message-recall`。
- HEAD:`dbd27e6 feat: enhance candidate scoring explanations`。
- 验收开始及结束时暂存区均为空;既有 N07 修改、历史脚本、日志、JSON、SQL、xlsx 和 `.env` 均保留。
- `git diff --check`:通过;迁移目录没有 V1–V40 修改。
- N07 唯一新增迁移:`V41__message_recall.sql`。
- 未执行 `git add -A`、commit、push、reset、restore、checkout、clean;未删除或移动历史文件。
## 2. 自动化独立复验
| 命令 | Tests run | Failures | Errors | Skipped | 结果 |
|---|---:|---:|---:|---:|---|
| N07 定向测试 | 30 | 0 | 0 | 0 | BUILD SUCCESS |
| 消息与邀约关联回归 | 80 | 0 | 0 | 0 | BUILD SUCCESS |
| `mvn clean verify` | 1601 | 0 | 0 | 0 | BUILD SUCCESS |
`python docs\\verify_openapi.py` 通过,静态 OpenAPI 为 173 paths / 211 operations。
## 3. 代码审查
- 状态机:PASS。V41 CHECK 仅允许 SENT/RECALLED;条件更新包含 messageId、conversationId、senderUserId、SENT、expectedVersion;正文固定占位、recalledAt 非空、version 仅递增一次;重复撤回幂等且不重复成功审计。
- 权限与归属:PASS。真实 HTTP 中原发送者成功,另一成员/非成员/ADMIN/跨会话为 403/2002,匿名为 401,会话/消息不存在分别为 404/8305、404/8306,超时为 409/8307。
- 并发与事务:PASS。H2 独立事务测试以及真实 HTTP 两个并发请求均无 500;同消息最终 version=1、恰好一条成功审计;不同消息并发各自成功。
- 依赖注入:PASS。生产目录不存在 `messageViewMapper == null`、`objectMapper == null`、`new MessageViewMapper()`、`new ObjectMapper()` 兜底。
- 审计隐私:PASS。真实 MySQL `MESSAGE_RECALLED` 行 `ip_address`/`user_agent` 均为 NULL,details 为合法 JSON 且字段严格为 `messageId,conversationId,conversationType,status,requestId`。
- DTO/查询结构:PASS。撤回及分页路径使用白名单 DTO;会话与成员固定查询、消息分页固定查询、message_reads 一次批量查询,无逐消息 exists/find。
- 副作用:PASS。目标消息仅发生 SENT→RECALLED、占位正文、recalledAt、version+1;message_reads、conversation_members、candidate_dimension_scores、notifications、风险表内容和 version 均未改变。
## 4. Docker、MySQL、Flyway 与启动
- Docker Engine:Server 29.6.2;MySQL 8.4.11、Redis 7.4、RabbitMQ 3.13 容器均 healthy。未重建、删除容器或 volume。
- 真实 Flyway 查询:最新三条为 V41 `message recall` success=1、V40 `enterprise talent invitations` success=1、V39 `enterprise talent discovery` success=1;无失败迁移、无 N07 之外迁移。
- `SHOW CREATE TABLE messages`:`status` 默认 SENT、`recalled_at` 可空、`version` 默认 0,CHECK 仅允许 SENT/RECALLED,既有外键与 `idx_messages_conversation_created` 保留;无消息历史表。
- 使用 `mvn clean verify` 生成的 JAR、dev 配置并保持 `ddl-auto=validate` 启动,日志出现 `Started MyDreamjobApplication`,无 Flyway/schema-validation 错误。
- `GET /api/v1/health`:HTTP 200,status=UP。
- 本轮启动的应用 PID 4412(后续边界复验重启为 PID 21168)均在验收结束后正常停止;8080 已恢复空闲。Docker 未停止。
## 5. 真实 HTTP 主链路与权限
- DIRECT:真实发送、列表、撤回、重复撤回全部 200;撤回后 status=RECALLED、正文为固定占位、recalledAt 非空、version 0→1,重复请求不再增加 version 或审计;senderUserId 按既有语义保留。
- TALENT_INVITATION:真实发送、撤回、列表全部 200;仅返回 senderScope,不返回 senderUserId、学生 ID或 candidateKey;会话详情和会话列表中的 TALENT_INVITATION 项不返回 createdBy;撤回后正文为固定占位。
- 权限配对结果:原发送者 `200/200`;另一成员 `403/2002`;非成员 `403/2002`;ADMIN `403/2002`;匿名 `401/401`;跨会话 `403/2002`;不存在会话 `404/8305`;不存在消息 `404/8306`;超时 `409/8307`。
- 递归 JSON 脱敏:DIRECT 撤回/列表、TALENT_INVITATION 撤回/列表及会话详情中的 TALENT_INVITATION 项均未发现原正文、senderUserId(TALENT)、studentUserId、candidateKey、联系方式、IP/UA、凭据等禁止字段。
## 6. 并发、时间与 message_reads
- 真实 MySQL HTTP 并发:同一消息两请求均 200,最终 version=1、单条审计;两条不同消息并发均 200、各 version=1、各一条审计。
- 时间:H2 正式测试覆盖 0 秒、1:59、恰好 2:00 成功及超过 2:00 最小单位失败;真实 MySQL HTTP 119 秒返回 200/200、120 秒返回 409/8307(数据库状态分别为 RECALLED/SENT),创建后立即撤回也成功。边界表达式保持 `capturedNow <= createdAt.plusMinutes(2)`。
- 已有 `message_reads` 行在撤回后仍存在(精确查询=1);撤回消息不计入 unreadCount(精确查询=0),消息仍保留时间线,未新增第二条 read 行。
## 7. 查询预算与 EXPLAIN
- 使用真实 MySQL `performance_schema.events_statements_summary_by_digest` 测量正式 HTTP 列表路径,夹具规模 1、20、50 条消息:每次均为 `messages=2、message_reads=1、conversations=1、conversation_members=1`,查询数不随消息量线性增长。
- 真实 EXPLAIN FORMAT=JSON:消息分页使用 `idx_messages_conversation_created`、access=ref、无 filesort/temporary;message_reads 使用 reader 索引、成员校验使用 `uk_conv_members`;条件撤回使用 PRIMARY、access=range;未读查询使用消息索引和 `uk_message_reads`。未新增索引。
- 说明:该 EXPLAIN 为当前验收数据的代表性计划;更大生产数据量的容量/延迟压测仍需后续授权,不以固定查询数替代性能结论。
## 8. RabbitMQ 与副作用
RabbitMQ `ping` 成功;`queue.sms.verification`、`queue.notification`、`queue.audit`、`queue.notification.dlq` 均真实存在,ready=0、unacked=0。N07 撤回未产生新增消息、通知、短信或邮件。
目标消息撤回前后稳定摘要证明:除目标消息允许的四项状态变化和一条白名单 `MESSAGE_RECALLED` 审计外,message_reads、会话、成员、评分快照、通知、风险治理表内容与 version 不变。
## 9. OpenAPI
- 静态:173 paths / 211 operations,`verify_openapi.py` 通过。
- 运行时:`GET /api/v1/v3/api-docs` 返回 173 paths / 211 operations,与静态计数一致;撤回无 requestBody,200 响应引用 `ApiResponseMessageResponse`,分页响应引用 `ApiResponseMessagePageResponse`。
- 未新增编辑、删除、批量撤回、恢复接口;TALENT_INVITATION 的条件性 senderScope 脱敏由真实 JSON 验证。
## 10. PASS/FAIL/SKIP 与剩余风险
- PASS:自动化、V41 迁移与 schema validation、启动/健康、权限、DIRECT/TALENT_INVITATION 脱敏、并发、审计隐私、message_reads/unread、只读副作用、查询预算、EXPLAIN、RabbitMQ、静态/运行时 OpenAPI。
- FAIL:0。
- SKIP:0 个核心验收项。生产级更大规模容量压测和持续运行监控不属于本轮黑盒范围,需独立性能验收,但不构成当前 N07 功能阻断。
- 不允许把本报告扩展为对 V1–V40 其他业务的全面生产保证;仅确认 N07 变更边界。
## 11. 最终判定
**PASS:N07 独立代码复验和真实 MySQL/Docker 黑盒全部通过,可以进入精确暂存检查点。**
本报告不包含数据库密码、JWT、测试账号密码、手机号、邮箱或原消息正文。验收数据仅使用本轮唯一前缀并已按精确 ID 清理;历史验收文件未删除或移动。

2
docs/offer-api.md

@ -259,7 +259,7 @@ POST /offers/{id}/remind
> 现网 OpenAPI 亦可访问 `GET /api/v1/v3/api-docs` 导出后导入 Apifox。
测试账号(Mock 短信验证码,验证码见应用日志):
测试账号(Mock 短信验证码:应用日志不再输出,本地按 `docs/frontend-integration.md` §3 的受控方式取得;生产禁止查表):
| 角色 | 手机号 | 用途 |
|---|---|---|

14995
docs/openapi-rc.json

File diff suppressed because one or more lines are too long

106
docs/phase-a-company-registration-onboarding-implementation-report.md

@ -0,0 +1,106 @@
# Phase A 企业注册与入驻基础闭环实施报告
日期:2026-09-22
范围:企业专用注册、`company-register` 验证码、事务创建、入驻聚合查询
基线:`master` / `00153aa5d92d13e66d6cdc42ecd1fe903d6e666`
## 1. 结论与边界
本阶段实现了企业注册和企业入驻状态聚合,不进入 Phase B 已认证企业业务门禁,不进入 Phase C 管理员用户与角色管理,不修改 `users.version`、`user_management_actions`、岗位/简历/Offer/面试等业务门禁。
当前未暂存、未提交、未推送;V1–V46 未修改;未新增 V47;没有修改生产数据库的迁移脚本。
## 2. 权威库存
编码前核对了 `AuthController`/`AuthService`、`SmsVerificationService`/`SmsVerificationCodeRepository`、`PasswordPolicy`/`PasswordAuthenticationService`、`AuthSessionService.createSession`、注销手机号墓碑、`User`/`UserRepository`/`user_roles`、`Company`/`CompanyRepository`/`CompanyService`、企业认证服务与状态、`ErrorCode`/`ErrorHttpStatusRegistry`、`SecurityConfig` 以及 OpenAPI 基线。
实际短信用途以源码为准,保留 `login`、`password-set`、`password-reset`,新增并白名单化 `company-register`;任意其他非空用途均拒绝。OpenAPI 基线为 180 paths / 218 operations,本阶段变更为 182 paths / 220 operations。
## 3. 修改文件
生产代码新增:
- `auth/company/CompanyRegistrationProperties.java`
- `auth/company/CompanyRegistrationRequest.java`
- `auth/company/CompanyRegistrationResponse.java`
- `auth/company/CompanyRegistrationService.java`
- `company/CompanyOnboardingResponse.java`
- `company/CompanyOnboardingService.java`
生产代码修改:`AuthController`、`CompanyController`、`SecurityConfig`、`application.yml`、`SmsVerificationService`、`SmsVerificationCodeRepository`、`AuditService`、`ErrorCode`、`ErrorHttpStatusRegistry`。
测试新增/调整为 Phase A 行为锁定、既有短信/映射/OpenAPI 计数同步;未修改 V1–V46 或 `pom.xml`。
## 4. 企业注册契约
新增 `POST /auth/company/register`,使用强类型请求 DTO 和 Jakarta Validation。客户端只能提交手机号、验证码、密码及企业资料,不能提交 `userId`、`role`、`verified` 或 `passwordHash`。成功响应包含 `userId`、`companyId`、`role=COMPANY`、`onboardingStatus=UNVERIFIED`、现有格式的 Access/Refresh Token 和 `expiresIn`,不回显完整手机号、密码、验证码或密码摘要。
新增 `GET /companies/me/onboarding`,仅认证用户可访问,响应只包含企业安全摘要、聚合状态、认证申请安全摘要、`missingFields` 与 `allowedActions`,不返回 `materialsJson`、`decisionReason`、`reviewerId`、存储路径或其他敏感企业数据。未新增重复的 verifications 接口。
## 5. 事务边界
`CompanyRegistrationService.register` 在一个数据库事务中完成:功能开关、短信校验/消费、注销墓碑、手机号占用检查、BCrypt 密码、用户、`user_roles`、企业资料、登录会话和固定脱敏审计事件。用户保存并 flush 后再创建企业和会话;注册失败不会向客户端返回未提交 Token。数据库唯一约束是并发最终防线,`DataIntegrityViolationException` 转为统一冲突业务错误。
审计写入使用 `AuditService.logRequired` 的 `PROPAGATION_MANDATORY` + `saveAndFlush`,因此审计失败会使注册事务回滚。Redis 限流键不属于数据库事务,报告和测试没有虚假声称 Redis 自动回滚。
## 6. 验证码原子消费
`SmsVerificationCodeRepository.consumeIfValid` 使用条件更新,同时约束 `id`、`used=false`、未过期和 `attempts < maxAttempts`。只有受影响行数恰为 1 才算消费成功,0 行统一按验证码无效处理。错误验证码仍递增尝试次数并保留锁定语义;用途在查询前经过固定白名单校验。企业注册事务回滚时,数据库中的 `used` 更新随事务回滚。
修复前行为是“查询 → 修改实体 `used` → `save`”,受控变异到该实现时,`SmsVerificationAtomicConsumptionTest` 为 4 run / 2 failures(条件更新未调用、0 行竞争未失败);恢复条件更新后为 4 / 0 / 0 / 0。该变异证明测试不是空转。
## 7. 入驻状态推导
- `companies.verified=true` → `VERIFIED`;
- 未认证且无申请 → `UNVERIFIED`;
- 最新 `PENDING` 或 `RESUBMITTED` → `PENDING`;
- 最新 `REJECTED` → `REJECTED`;
- 未认证但最新 `APPROVED` 或未知状态 → 失败关闭并返回专用一致性错误。
`missingFields` 从资料实际内容派生,没有新增重复状态字段;`allowedActions` 随聚合状态返回。
## 8. 错误码与安全配置
新增唯一数字码:`COMPANY_REGISTRATION_DISABLED=10301`、`COMPANY_REGISTRATION_CONFLICT=10302`、`COMPANY_ONBOARDING_INCONSISTENT_STATE=10303`、`COMPANY_REGISTRATION_DEPENDENCY_UNAVAILABLE=10304`。映射统一进入 `ErrorHttpStatusRegistry`:关闭/依赖不可用为 503,冲突/不一致为 409。
匿名放行仅新增 `POST /auth/company/register`;`GET /companies/me/onboarding` 保持认证,学生、管理员和匿名均不获得企业入驻数据。日志、审计和响应不记录密码、摘要、验证码、Token、SQL、手机号或请求 DTO。
## 9. 测试矩阵
新增了企业注册 service/integration/concurrency、入驻 service/controller、验证码原子消费、用途白名单、事务回滚、敏感数据、功能开关等测试类;并同步了既有短信安全、公司 Controller 和异常映射测试。
- Phase A 定向测试:全部通过;两项阻断整改后的完整定向集 **28/28**;
- 真实 MySQL/Testcontainers 并发:1/1,通过同验证码双线程仅一个成功;
- `mvn test`:**2865 / Failures 0 / Errors 0 / Skipped 0**;
- `mvn clean verify`:**2865 / Failures 0 / Errors 0 / Skipped 0**;
- OpenAPI:`python ..\\docs\\verify_openapi.py` → **182 paths / 220 operations,All checks passed**。
全量验证期间出现的 Hikari 重连和 MySQL 8.4/Flyway 兼容性 warning 均未导致失败;一次与编译并发运行的错误结果未计入,随后单独顺序运行得到上述完整通过结果。
测试/脚本自身问题:早期黑盒驱动曾把短信发送参数误放在 JSON body(实际接口是 query 参数),且临时 JWT 密钥长度不足 32 字符;两项均在产品代码之外修正。重复运行时还触发了短信限流,随后销毁临时数据库并以全新隔离容器重跑,得到本节所列成功结果。未放宽产品断言。
## 10. 真实 MySQL/Redis/RabbitMQ 黑盒
使用临时隔离容器(MySQL 8.4、Redis 7.4、RabbitMQ 3.13)和最新 JAR 完成真实 HTTP 验收。三中间件均真实健康,应用 `/api/v1/health` 为 200/UP。
验证结果:匿名 onboarding 为 401;无有效验证码注册被拒;发送 `company-register` 后注册 200;密码登录 200;`/auth/me` 200;带注册 Access Token 的 onboarding 200。数据库逐表核对为:目标手机号 `users=1`、`companies=1`、`user_roles=1` 且角色仅 `COMPANY`、`companies.verified=0`、验证码 `used=1`。密码登录产生第二个合法会话,属于预期行为。临时容器、临时 JAR 进程、临时端口和运行日志均已清理,未触碰既有 backend-infra 卷或历史数据。
## 11. 敏感信息扫描
扫描新增生产代码、测试代码、OpenAPI、验证脚本相关输出和本报告:未发现真实手机号、明文密码、BCrypt 摘要、JWT/Bearer、Refresh Token、数据库/Redis/RabbitMQ 密码、私钥、AWS key 或连接串。黑盒使用的临时凭据只存在于进程环境和临时容器,测试结束后容器和日志已删除;报告不记录其值。
## 12. 数据与兼容性
未新增数据库迁移;V1–V46 逐项保持不变。求职者短信登录、现有密码登录/设置/修改/重置、Token 刷新/登出、管理员初始化、既有企业资料和企业认证接口保持原路径与行为。企业注册后经现有 `/auth/password/login` 成功登录并得到 COMPANY 身份。
## 13. 未进入的范围
本轮没有实现已认证企业业务门禁、管理员用户/角色管理、`users.version`、`user_management_actions`、岗位发布、简历、Offer 或面试门禁改造;没有提交、推送或进入 Phase B/C。
## 14. 当前结束状态
验证完成后未暂存、未提交、未推送;无 Java/Maven 残留进程;8080/8081 空闲;临时测试账号与业务数据在隔离容器销毁时清理;未删除审计历史。工作区仍保留既有历史未跟踪文件和两个 Windows stat 缓存假阳性文件,未覆盖、删除或暂存。
## 15. 结论
实施阶段完成后,独立复验发现功能开关的宽松布尔绑定问题;现已整改为字符串配置并严格使用小写字面量 `true` 判断。事务回滚测试也已补齐 users、user_roles、companies、session、audit 五类独立故障注入,新增回滚覆盖后的完整 Phase A 定向集为 **28/28**,全量回归为 **2865/2865**,均无失败、错误或跳过。

157
docs/phase-a-company-registration-onboarding-independent-verification.md

@ -0,0 +1,157 @@
# Phase A 企业注册与入驻基础闭环独立代码复验报告
日期:2026-09-22
## 1. 复验范围与边界
本次复验针对 Phase A:企业专用注册、`company-register` 验证码用途、验证码原子消费、用户/角色/企业/会话事务、企业入驻聚合查询、测试与 OpenAPI。
未进入 Phase B 已认证企业业务门禁,也未进入 Phase C 管理员用户与角色管理;未修改生产代码、未暂存、未提交、未推送。
## 2. 前置状态
- 分支:`master`
- HEAD:`00153aaf5d92d13e66d6cdc42ecd1fe903d6e666`
- 暂存区:空
- 迁移:V1–V46 未修改,未新增 V47
- `pom.xml`:无修改
- 两个既有 Windows stat 缓存假阳性文件的工作区 blob 与 HEAD 相同,未触碰、未暂存
- 历史未跟踪验收文件和 `backend-infra` 文件均保留
## 3. 独立静态核对
通过项:
1. 注册服务使用事务,按 COMPANY 角色创建用户,并显式写入仅含 COMPANY 的 `user_roles`。
2. 用户、企业保存后 flush,再创建登录会话;企业初始 `verified=false`。
3. 注册审计使用固定脱敏文案,未发现密码、验证码、Token 或摘要输出路径。
4. 验证码消费使用条件更新,条件包含 id、`used=false`、未过期和 attempts 上限,受影响行数必须为 1。
5. 短信用途采用固定白名单,包含 `company-register`,不接受任意非空用途。
6. 匿名规则仅新增 `/auth/company/register`;`/companies/me/onboarding` 仍要求认证。
7. 入驻状态对无记录、PENDING/RESUBMITTED、REJECTED、VERIFIED 和 APPROVED 不一致状态分别处理;不一致状态失败关闭。
8. DTO 未暴露 `userId`、客户端 `role`、`verified`、`passwordHash` 等不应由客户端控制的字段。
9. OpenAPI 中新增注册和入驻查询契约,未改变 V1–V46 或既有业务路径。
## 4. 定向测试与真实黑盒
实施测试与复验前已执行的 Phase A 定向测试为 **23/23,Failures=0,Errors=0,Skipped=0**,其中包含真实 MySQL/Testcontainers 并发注册测试。
此前全量结果:
- `mvn test`:2860/0/0/0
- `mvn clean verify`:2860/0/0/0
- OpenAPI:182 paths / 220 operations,All checks passed
隔离 MySQL 8.4、Redis 7.4、RabbitMQ 3.13 和最新 JAR 的黑盒结果:健康检查通过;发送 `company-register` 验证码后注册成功;密码登录成功;`/auth/me` 返回 COMPANY;入驻查询返回 200;目标数据为 1 个 COMPANY 用户、1 个 COMPANY 角色、1 个企业且 `verified=0`,验证码已消费。临时容器、进程、端口和日志已清理。
## 5. 阻断项:功能开关绑定不满足严格字面量要求
生产文件:`mydreamjob-server/src/main/java/com/mydreamjob/auth/company/CompanyRegistrationProperties.java`
当前字段为:
```java
private boolean enabled = false;
```
Spring Boot 标准布尔绑定会把 `TRUE`、`True`、`yes`、`1`、`on` 均转换为 `true`。独立临时测试使用 `ApplicationContextRunner` 真实绑定配置,逐一设置:
```text
app.company-registration.enabled=TRUE
app.company-registration.enabled=True
app.company-registration.enabled=yes
app.company-registration.enabled=1
app.company-registration.enabled=on
```
复验结果:五个非字面量值均绑定为 enabled=true,测试失败:
```text
Tests run: 1, Failures: 1, Errors: 0, Skipped: 0
Expecting empty but was: ["TRUE", "True", "yes", "1", "on"]
```
这与设计要求“只有字面量 `true` 才开启,其他值不得误开启”不一致,因此 Phase A 独立复验判定 **FAIL**。临时测试已删除,未留在工作区。
整改建议:将配置原始值改为 String,并通过唯一方法严格判断:
```java
private String enabled = "false";
public boolean isEnabled() {
return "true".equals(enabled);
}
```
同时增加真实 Spring 属性绑定测试,覆盖未设置、`false`、`true`、`TRUE`、`True`、`yes`、`1`、`on`;只有小写字面量 `true` 应开启。
## 6. 整改前的次要测试覆盖缺口
以下是首次独立复验时发现的证据缺口,不作为已证明的生产缺陷:
1. `CompanyRegistrationTransactionRollbackTest` 目前主要覆盖审计失败,未分别注入并断言 users 保存、user_roles 保存、companies 保存、会话创建和审计写入五类失败;对 user_roles/auth_tokens 的零残留断言也不完整。
2. 注册请求中客户端附带未知的 role/verified/passwordHash 字段的既有测试,可能因验证码未准备而返回 400,不能单独证明未知字段被拒绝;DTO 本身未声明这些字段。
3. tombstone、Redis/数据库/会话依赖异常和每种冲突角色的完整 HTTP 黑盒覆盖仍需单独补齐或明确证据。
## 7. 复验结论
Phase A 的主要注册、验证码原子消费、事务、并发、入驻聚合和真实中间件路径基本可用,但严格功能开关绑定存在阻断缺陷;另有事务回滚测试覆盖不足。
最终判定:**FAIL,暂不能进入独立通过或提交检查点。**
本次独立复验未修改生产代码、未暂存、未提交、未推送,未进入 Phase B/C。
## 8. 阻断整改复验(2026-09-22)
针对第 5 节阻断,已按最小范围修改:
- `CompanyRegistrationProperties.enabled` 改为字符串,默认值为 `"false"`;
- `isEnabled()` 仅返回 `"true".equals(enabled)`;
- `CompanyRegistrationFeatureFlagTest` 增加真实 `ApplicationContextRunner` 绑定测试,覆盖 `TRUE`、`True`、`yes`、`1`、`on`、`false` 和 `true`。
整改后验证:
- 功能开关及关联测试:**9/9,F=0、E=0、S=0**;
- Phase A 定向测试:**24/24,F=0、E=0、S=0**;
- `mvn clean verify`:**2861/2861,F=0、E=0、S=0**;
- OpenAPI:**182 paths / 220 operations,All checks passed**。
独立绑定测试确认只有字面量 `true` 开启,五种非字面量值均保持关闭。未新增迁移,未进入 Phase B/C,未提交或推送。
功能开关阻断已清除;第 6 节的事务回滚覆盖缺口已在第 9 节补齐。
说明:第 7 节“FAIL”是整改前复验结论;当前状态以第 8、9 节整改复验结果为准。
## 9. 事务回滚覆盖整改复验(2026-09-22)
`CompanyRegistrationTransactionRollbackTest` 已补齐五类独立故障注入:
- users 保存失败;
- user_roles 真实插入触发器失败(H2 真实 SQL);
- companies 保存失败;
- 会话创建失败;
- 审计写入失败。
每种场景均验证 users、user_roles、companies、auth_tokens 无残留,验证码 `used=false`,并按精确测试手机号清理。测试结果:**5/5,F=0、E=0、S=0**。
## 10. 阻断修复后的完整回归收口(2026-09-22)
为避免把整改前的历史数字误读为当前结果,最终收口结果单独列示如下:
- 功能开关整改后的独立定向:9/9;
- 五类事务回滚注入:5/5;
- Phase A 完整定向测试:**28/28,F=0、E=0、S=0**;
- `mvn clean verify`:**2865/2865,F=0、E=0、S=0,BUILD SUCCESS**;
- OpenAPI:**182 paths / 220 operations,All checks passed**;
- 真实 MySQL/Testcontainers 并发注册:已执行并通过;
- V1–V46 未修改,未新增 V47;未进入 Phase B/C,仍未暂存、未提交、未推送。
期间 H2 user_roles 触发器用于证明真实 SQL 插入失败,触发日志中的预期 SQL 错误属于测试注入证据,不是生产异常;未修改生产错误映射,也未放宽任何断言。
## 11. 复验重跑收口(2026-09-22)
- 独立静态门禁通过:功能开关严格按小写字面量 `true` 开启;匿名放行仅包含 `/auth/company/register`;注册 DTO 未暴露 `userId`/`role`/`verified`/`passwordHash`;验证码白名单、条件消费和 `attempts < maxAttempts` 均已分别在 Service 与 Repository 位置核对;未发现 V47。
- 首次静态脚本曾把 `attempts < c.maxAttempts` 错查在 `SmsVerificationService.java`,造成脚本假阴性;修正为检查 `SmsVerificationCodeRepository.java` 后门禁通过。该问题仅存在于复验脚本,不是产品缺陷。
- Phase A 定向重跑:**28/28,Failures=0、Errors=0、Skipped=0**;真实 `CompanyRegistrationConcurrencyMySqlTest` 启动 MySQL 8.4 Testcontainers 并实际执行。
- OpenAPI:**182 paths / 220 operations,All checks passed**。
- 收尾:无 Java/Maven 残留进程,8080/8081 无监听,暂存区为空;本轮未修改生产代码、未提交、未推送,未进入 Phase B/C。

127
docs/phase1-scope-and-acceptance.md

@ -0,0 +1,127 @@
# 一期范围、责任与验收清单
冻结日期:2026-09-08。范围依据:负责人本轮明确确认。本文是一期范围与交付状态的权威清单;历史路线图与本文冲突时,以本文为准。
范围冻结不等于设计批准或验收通过。八个待交付功能组全部属于一期,不再以“后置建议”将其排除。新增接口、迁移、计分规则和权限变化仍需在各模块设计中明确后编码。本文不预分配版本号、迁移号或接口数量。
## 1. 责任与完成定义
- 张顺(你):业务与产品负责人,主责评分、监测、消息和简历相关能力。
- 小刘:主责内容与数据型功能、积分独立模块及交付环境。
- 表中负责人负责设计、实现、测试和文档;另一位负责独立审查。模块作者不能用自己的完成报告代替独立验收。
- 前端由现有前端同学实现,后端负责人提供契约并组织联调;未指定具体前端人员,不虚构责任人。
- 状态采用:待设计、设计待确认、实现中、待独立复验、待真库验收、待前端验收、一期验收完成。Git 已提交与功能已验收分别记录。
- 每项完成必须具备:需求用例、权限与隐私边界、可执行自动化、适用的真实 MySQL 验证、前端流程验收、文档和可追溯提交。无需迁移的模块不强行新增迁移。
下面代码路径均相对项目根目录 `C:/Users/15642/Desktop/zhilai`。生产包根为 `mydreamjob-server/src/main/java/com/mydreamjob/`,测试包根为 `mydreamjob-server/src/test/java/com/mydreamjob/`。拟新增包只是定位建议,不代表已存在。
## 2. 已有核心能力:保留在一期,执行集成回归
这些能力已有代码及历史验证记录;本轮仅核查入口和 Git,不重新宣称所有历史黑盒已通过。
| 编号/能力 | 一期需要什么 | 现有代码入口(生产包根下) | 如何验收 | 主责 / 复核 |
|---|---|---|---|---|
| C01 认证与企业认证 | 注册登录、身份校验、企业认证与权限隔离 | auth/、verification/、config/SecurityConfig.java | 登录失败/过期/匿名/角色越权;企业审核主链路;生产短信配置验证 | 小刘 / 张顺 |
| C02 档案与岗位 | 学生资料、偏好、企业资料、岗位发布搜索收藏比较 | profile/、preference/、company/、job/ | 企业只能改本企业岗位;公开字段白名单;筛选分页与资料更新页面联调 | 小刘 / 张顺 |
| C03 招聘主链路 | 投递、面试、结构化评分、Offer、进度 | application/、interview/、interviewscore/、offer/、careerprogress/ | 从注册到投递、面试、Offer逐步操作;拒绝/撤回/过期/重复请求与并发 | 张顺 / 小刘 |
| C04 沟通与安全 | 匹配、会话、消息、通知、举报屏蔽 | messaging/、notification/、bidirectionalmatch/、safety/ | 非成员不能读写;屏蔽生效;DIRECT与邀约会话隔离;重试不重复 | 张顺 / 小刘 |
| C05 简历与账户隐私 | 简历版本导出、个人数据导出、注销与隐私授权 | resume/、privacy/、deletion/、dataexport/ | 文件下载归属;导出内容脱敏;注销状态机;保留策略和恢复验证 | 张顺 / 小刘 |
| C06 评价与企业回复 | 评价、预审、管理员决策、企业官方回复 | review/、feedback/ | 公开载荷无学生身份;仅通过回复公开;幂等及审核并发;企业归属 | 小刘 / 张顺 |
| C07 测评与评分治理 | RIASEC、四维能力、评分卡、排名、申诉回滚、防刷 | assessment/、ability/、candidatescore/、competition/、risk/ | 抽题/计时/有效期/规则开关;申诉回滚;评分读取与写入边界;结果解释 | 张顺 / 小刘 |
| C08 推荐与人才邀约 | 岗位推荐、学生授权发现、企业邀约与接受后沟通 | recommendation/、talentdiscovery/、talentinvitation/ | 默认不发现;同企业历史投递排除;限额/冷却/幂等;接受后会话隐私 | 张顺 / 小刘 |
| S01 认证安全整改 | 验证码不落日志、受控取码、`/jobs` 认证边界 | auth/、docs/smoke-test.sh、job/JobController.java | 双开关受控取码;mock=false 完整回滚;`/jobs` 权限矩阵(独立代码复验与真实 MySQL/Docker 黑盒已通过) | 张顺 / 独立复验 |
| S02 Token 隔离与会话吊销 | Access/Refresh 类型与密钥隔离、轮换、重放处置、会话吊销 | auth/(JwtTokenProvider、AuthSessionService、AuthTokenHasher、JwtAuthenticationFilter、AuthController)、V42__auth_token_sessions.sql | Refresh 不能访问普通接口;Access 不能刷新;二次使用撤销当前会话;并发刷新最多成功一次;登出/注销完成使会话失效 | 张顺 / 独立复验 |
| S03 生产配置与简历隐私 | 历史种子账号治理、一次性管理员初始化、企业简历下载授权、Actuator 隔离 | auth/(AdminBootstrapRunner)、application/(CompanyResumeAccessPolicy、CompanyResumeService)、V43__disable_legacy_seed_accounts.sql、application.yml | V43 只禁用完全匹配原始指纹的三条种子账号;管理员初始化默认关闭且幂等;三种可见性 × 全部投递状态 × 跨企业/未认证企业矩阵;拒绝路径零文件读取;业务端口不提供指标 | 张顺 / 独立复验 |
### 2.1 Q01 岗位搜索真实查询覆盖(C02 证据补充)
`JobSearchSpecification` 与 `JobService.searchJobs` 原先只被纯 Mockito 测试覆盖(`JobSearchServiceTest` 把 `findAll(Specification, Pageable)` 整体 Mock 掉),无法证明 Criteria 谓词、SQL 过滤、排序与分页在真实数据库上的行为。Q01 补齐真实查询证据,**只新增测试与文档,未改动生产代码、接口、DTO、状态机、权限、搜索语义与索引**。
测试分层与结果(详见 [Q01 实施报告](q01-job-search-real-query-implementation-report.md)):
| 层次 | 测试类 | 数据库 | Tests run | Failures | Errors | Skipped |
|---|---|---|---|---|---|---|
| H2 真实 Repository | `job/JobSearchSpecificationIntegrationTest` | H2 + 真实 Flyway | 19 | 0 | 0 | 0 |
| H2 真实 Service / EntityManager | `job/JobSearchServiceIntegrationTest` | H2 + 真实 Flyway | 9 | 0 | 0 | 0 |
| H2 查询副作用行级快照 | `job/JobSearchQuerySideEffectIntegrationTest` | H2 + 真实 Flyway | 4 | 0 | 0 | 0 |
| 真实 MySQL 8 规格对账 | `job/JobSearchSpecificationMySqlIntegrationTest` | MySQL 8.4(Testcontainers) | 13 | 0 | 0 | 0 |
| 真实 MySQL 查询副作用行级快照 | `job/JobSearchQuerySideEffectMySqlIntegrationTest` | MySQL 8.4(Testcontainers) | 4 | 0 | 0 | 0 |
| 真实 MySQL 执行计划与查询次数 | `job/JobSearchQueryExplainMySqlIntegrationTest` | MySQL 8.4(Testcontainers) | 1 | 0 | 0 | 0 |
- MySQL 层用正式 Flyway 迁移 + `ddl-auto=validate` 启动,连接参数由 `@DynamicPropertySource` 运行期注入,不落盘、不打印;核心用例**真实执行、无 SKIP**。
- H2 与 MySQL 在 keyword(三字段/大小写/trim/空白与 `%`、`_` 特殊字符)、city、employmentType、companyId、薪资边界与区间重叠、PUBLISHED 强制过滤、latest/relevance/salary 排序、分页与 total 上**逐项一致,未发现语义差异**。
- 副作用证据为 jobs、companies、users、applications、candidate_dimension_scores、notifications、conversations、messages、audit_logs 九张表的**行级稳定字段快照**(非 COUNT/SUM),并配「改字段 / 插行 / 删行」三条**反证测试**证明比较本身有效。
- 真实 MySQL 上对组合过滤、latest 分页、salary 排序与 count 执行 `EXPLAIN FORMAT=JSON`;小样本夹具下为全表扫描 + filesort,**不构成生产规模结论**,前导通配符与排序成本移交 R01。
- OpenAPI 保持 176 paths / 214 operations,`docs/openapi-rc.json` 未改动。
状态:**Q01 已完成真实查询测试实现与自动化验证,待独立代码复验及独立真实 MySQL/Docker 验收。**
## 3. 一期必须补齐的八个功能组
以下交付边界用于控制设计范围;涉及产品参数的内容明确列为待评审,不得由实现者自行锁定。
| 编号/功能 | 当前状态与一期需要什么 | 代码在哪里 | 如何验收 | 主责 / 复核 |
|---|---|---|---|---|
| N01 V43过程公平性与稳定性监测 | 独立代码复验、真实 MySQL/Docker 验收及 Git 检查点已完成(cf39737);历史不足返回UNAVAILABLE | 现有 scoringmonitoring/;docs/v43-candidate-scoring-fairness-design.md | 版本内补集、申诉集合、权限、只读性及30/300/3000查询预算通过;EXPLAIN扩容风险纳入容量观察 | 张顺 / 小刘 |
| N02 评分规则解释与合理性 | 独立代码复验、真实 MySQL/Docker 验收及 Git 检查点已完成(dbd27e6) | candidatescore/CandidateScoringCalculator.java、CandidateScoringEngineService.java、candidatescore/explanation/;docs/candidate-scoring-explanation-design.md、docs/candidate-scoring-rule-evidence.md | 未知规则安全失败;真实 H2 时间边界、四入口单次 capturedNow、企业完整 JSON 脱敏、运行时 OpenAPI 与真库回归均已通过 | 张顺 / 小刘 |
| N03 行业技能词云 | 未确认有独立实现;用有授权、可追溯的数据生成技能频次,支持空数据与更新时间;不是装饰性随机词云 | 现有 job/、recommendation/ 可复用数据入口;拟新增 skillinsight/ | 同义词归一/去重/计数用已知样本对账;行业筛选与来源日期准确;无数据不编造;批量性能与页面联调 | 小刘 / 张顺 |
| N04 入职清单与公司知识介绍 | 未确认有独立实现;清单模板、个人完成进度、公司可公开介绍;公司原有介绍与新增入职知识明确区分 | 现有 company/、offer/、careerprogress/;拟新增 onboarding/ | 本人进度隔离、企业内容归属、模板变更行为、重复更新;未认证内容不得冒充平台核验;页面完整走通 | 小刘 / 张顺 |
| N05 薪资计算器与薪资参考 | 未确认有独立实现;可解释的计算明细与有来源的薪资参考;明确币种、周期、地区、规则生效期和估算性质 | 现有 job/、offer/ 可提供薪资字段;拟新增 salary/ | 经确认的规则用独立算例验证舍入与边界;来源/版本/更新时间;缺地区或参考样本时明确不可计算/不足;不伪造市场数据 | 小刘 / 张顺 |
| N06 诚信积分与激励 | 独立积分能力未完成,现有INTEGRITY不是积分账户;先确定合格行为、积分流水、撤销和展示语义,不以简单行为推断人格 | 现有 candidatescore/CandidateScoringCalculator.java#computeIntegrity、audit/;拟新增 incentive/ | 积分事件幂等、并发不重复加分、撤销可追溯、余额与流水一致、刷行为不无限获益;与候选评分默认隔离,接入必须单独评审 | 小刘 / 张顺 |
| N07 消息撤回 | N07 定向整改完成并通过自动化与干净构建,待独立代码复验与真实 MySQL/Docker 验收;发送者在两分钟窗口内撤回,列表/响应统一显示撤回态并覆盖原正文 | 现有 messaging/MessagingService.java、talentinvitation/;docs/message-recall-design.md、docs/message-recall-implementation-instruction.md、docs/messaging-api.md | 非本人不可撤回;时间边界、并发读取/撤回、幂等;撤回后正文不从当前数据库、响应、日志或审计泄漏;DIRECT与邀约会话回归 | 张顺 / 小刘 |
| N08 简历查重 | 未确认有独立实现;先界定同人重复上传与跨文档相似检测,给出依据和局限;不得直接判抄袭或自动扣分 | 现有 resume/ResumeVersionService.java、profile/storage/;拟新增 resumededup/ | 重复文件/改名/模板相似/不同格式/解析失败样例;阈值误报验证;不得借查重返回他人简历、姓名或联系方式;上传性能与租户隔离 | 张顺 / 小刘 |
### 编码前必须补齐的产品决策
范围已确认,以下细节未确认。负责人先交付短设计,双方评审后再实现,不因缺细节将功能移出一期。
- N02:各规则的业务含义、兼容解释方式、测评试测样本与证据门槛已形成;本版不改变现有分数、企业聚合或排名,后续规则调整必须另开评分版本。
- N03:行业受控字典、数据来源和授权、统计窗口、最小样本、更新方式。
- N04:清单适用人群与触发点、模板维护方、公司内容发布/审核机制、进度保存方式。
- N05:税前/税后/年包范围、支持地区、规则与市场参考数据来源、更新责任。涉及税费规则时设计阶段查验权威资料。
- N06:积分名称、可积分事件、额度、冷却、撤销、兑换或仅展示;不能默认涉及现金/奖品。
- N07:撤回时间窗、附件与引用、离线/缓存同步、审计及数据保留口径。
- N08:查重范围、合法数据边界、文件类型、指纹或文本相似算法、阈值和提示对象;跨用户查重需单独隐私评审。
## 4. 一期交付保障,同样属于未完成工作
| 编号 | 需要什么 | 入口 | 如何验收 | 主责 / 复核 |
|---|---|---|---|---|
| G01 可复现构建与持续验证 | 干净环境构建、PR检查、MySQL兼容验证、精确版本产物 | mydreamjob-server/pom.xml、测试目录、backend-infra/;CI落点待设计 | 新环境按文档运行;提交触发测试;失败阻断;JAR摘要关联提交 | 小刘 / 张顺 |
| G02 部署与恢复 | 环境隔离、密钥管理、必要外部服务、文件持久化、数据库/文件备份恢复、发布回退 | backend-infra/、profile/storage/、resume/storage/、dataexport/storage/ | 隔离环境部署演练、恢复演练和回退证据;不把启动成功当恢复完成 | 小刘 / 张顺 |
| G03 监控、安全与容量 | 指标告警、权限隐私、依赖失败、重试、压测、敏感信息检查 | config/、audit/、健康检查、各模块 | 定义预计并发与SLA后实测;故障有告警、可定位;依赖中断和重复请求不破坏一致性 | 小刘 / 张顺 |
| G04 一期前端联调与用户验收 | 核心招聘链路与N01–N08对应页面可用,空态/错误态明确 | docs/frontend-integration.md、各API文档、前端仓库待登记 | 前端同学参与逐项签收;合成账号端到端录证;缺陷关闭后小范围试运营 | 张顺 / 小刘 |
这四项是交付工作包,不代表恰好四个代码模块。已有资产可复用,但必须提供当前证据。
## 5. 排期与合并顺序
1. 张顺先收尾N01并完成N02规则评审;小刘同步搭建G01及设计N03。N01真库验收通过前不宣称V43冻结。
2. 张顺实现N07;小刘实现N03,再实现N04。各模块先短设计、后实现、再独立验收。
3. 张顺实现N08;小刘实现N05。数据来源、隐私与阈值未确认时先处理其他不依赖的工作。
4. N02语义确认后,小刘实现N06,张顺复核与评分引擎的边界。
5. G02/G03从第一阶段持续推进,最后执行G04和整体发布演练,不等所有业务代码写完才准备环境。
并行开发前登记各自改动路径。共享文件(ErrorCode、SecurityConfig、OpenAPI、路线图)由当次集成负责人串行合并。新Flyway号在准备合并前从实际主线分配;不得两人各自假定下一号。每个模块独立提交,可以批量推送,但不要将整个一期积累成一个巨型提交。
不承诺未估算的日期。每个短设计确认后再估工时、依赖和验收时间。当前是“8个待闭环功能组 + 4个交付工作包 + 既有核心回归”,不能折算成未经验证的完成百分比。
## 6. 明确移出一期
以下进入后续候选池,不占一期验收范围:
- AI简历诊断与生成式优化;
- AI模拟面试与面试题预测;
- SHAP高级模型解释;一期仍包含确定性规则解释;
- 受保护人口属性公平性分析;一期不推断敏感属性;
- 需要新增不可变历史快照才能实现的历史分数漂移、历史排名稳定性;V43一期返回UNAVAILABLE;
- 自动调整评分/排名/权重、自动封禁;
- 超出已确认模块的现金奖励、交易/付费及新增商业化功能。
行业技能词云、入职清单/公司知识介绍、薪资计算器/参考、诚信积分/激励、消息撤回、简历查重均明确保留在一期。
## 7. 范围变更与状态证据
新增或删减一期项须记录负责人决定、原因、影响、验收变化。变更实现细节不得隐式改变产品范围。每次交付更新本清单状态与证据链接;不靠版本号大小代表完成度。
本轮仅冻结范围和责任,没有新增业务代码、迁移或测试执行。历史测试数不能视为本轮复验结果。已核实提交包括V39 55e3975、V40 2698d06、V41 5cc2c86、V42 96b3113;V43仍未提交且待整改复验。

796
docs/phone-password-authentication-design.md

@ -0,0 +1,796 @@
# 手机号密码认证设计
> 文档类型:设计说明与实现参考
> 状态:设计已确认,可进入实施;尚未编码
> 适用项目:`mydreamjob-server`
> 基线:`master` / `dedd47e3db42fee26bbeb21f1925ca1da127fb65`
> 设计日期:2026-09-20
## 1. 目标
在不破坏现有手机号验证码登录的前提下,增加手机号密码认证能力:
1. 手机号密码登录;
2. 首次设置密码;
3. 使用旧密码修改密码;
4. 使用短信验证码找回并重置密码;
5. 密码错误计数、临时锁定和手机号/IP 双维度限流;
6. 密码变更后撤销该用户全部登录会话;
7. 防止账号枚举、时序侧信道以及日志、审计和响应泄漏。
所有登录方式认证成功后必须复用现有 `AuthSessionService` 创建 Access/Refresh Token 会话,不建设第二套 Token 或会话体系。
## 2. 非目标
本模块明确不包含:
- 本机号码一键登录;
- 微信、支付宝、Apple、OAuth 或其他第三方登录;
- 用户名或邮箱登录;
- 支付、兑换、实名或多因素认证;
- 修改现有 Access/Refresh Token 格式、有效期、轮换和重放处置;
- 修改验证码登录的自动注册语义;
- 修改角色、权限、注销、评分、推荐、简历或消息业务语义;
- 在本轮接入外部弱密码或泄漏密码在线查询服务。
## 3. 当前基线
### 3.1 已有能力
| 能力 | 当前实现 |
|---|---|
| 验证码发送 | `POST /auth/sms/send` |
| 验证码登录 | `POST /auth/login` |
| 登录会话 | `AuthSessionService#createSession` |
| Token 刷新 | Refresh Token 单次消费、轮换与重放撤销 |
| 登出 | 撤销当前 session |
| 全局撤销 | `AuthSessionService#revokeAllUserSessions` |
| 密码字段 | `users.password_hash VARCHAR(255) NULL` |
| 密码算法 | 已有 `BCryptPasswordEncoder` Bean |
| 临时状态 | 已有 Redis 及 `RedisTemplate<String,Object>` |
| 用户状态 | `is_active`、注销记录和注销手机号墓碑 |
### 3.2 当前缺口
- `password_hash` 没有面向普通用户的写入或校验入口;
- 没有密码设置、修改、重置 DTO 和服务;
- 没有密码失败计数、账号锁定或 IP 限流;
- 没有密码变更后的会话撤销审计;
- `CREDENTIALS_INVALID(2005)` 当前未被使用,且未显式映射为 HTTP 401;
- 现有通用 `RATE_LIMITED(9002)` 保持历史 HTTP 400,不适合作为新密码接口的 429 契约。
## 4. 核心决策
| 事项 | 决策 |
|---|---|
| 账号标识 | 唯一账号为 11 位手机号 |
| 登录方式 | 验证码登录和密码登录并存 |
| 未设置密码的旧用户 | 继续使用验证码登录,再主动设置密码 |
| 密码长度 | 12–64 个 Unicode 字符(按 Unicode code point 计) |
| 机械复杂度 | 不强制大小写、数字、符号组合 |
| 弱密码 | 本地、版本化弱密码名单整值匹配;禁止包含完整手机号 |
| 规范化 | 设置与校验前统一做 Unicode NFC;不 trim、不改大小写 |
| 存储 | BCrypt 单向摘要,禁止明文、可逆加密或独立盐字段 |
| BCrypt cost | 默认 10;部署机基准测试后才允许调整 |
| 首次设置 | 有效 Access Token + `password-set` 短信验证码 |
| 修改 | 有效 Access Token + 当前密码 |
| 重置 | 手机号 + `password-reset` 短信验证码 |
| 会话 | 设置、修改、重置成功后撤销全部会话,包括当前会话 |
| 登录失败锁定 | 手机号引用连续失败 5 次,锁定密码登录 15 分钟 |
| IP 限流 | 单 IP 10 分钟最多 30 次密码登录尝试 |
| Redis 故障 | 密码登录及密码敏感操作失败关闭,返回服务暂不可用 |
| 防枚举 | 不存在、无密码、密码错误、inactive、注销均返回相同 401 |
| 注册 | 密码登录绝不自动创建账号 |
## 5. API 契约
所有路径继续受全局 context path `/api/v1` 约束。下面只写 Controller 相对路径。
### 5.1 密码登录
```http
POST /auth/password/login
Content-Type: application/json
```
```json
{
"phone": "13800138000",
"password": "correct horse battery staple"
}
```
成功时返回与现有验证码登录相同的用户与 Token 字段:
```json
{
"code": 200,
"message": "success",
"data": {
"userId": 123,
"phone": "13800138000",
"nickname": "用户8000",
"role": "STUDENT",
"accessToken": "<access-token>",
"refreshToken": "<refresh-token>",
"expiresIn": 86400
},
"requestId": "..."
}
```
约束:
- `phone` 必须为 11 位数字;
- `password` 必填,输入上限 64 个字符;
- 不存在的手机号不得创建用户;
- 失败原因统一,不返回“用户不存在”“尚未设置密码”“账号停用”等差异信息;
- 成功后清除该手机号引用的失败计数,但不清除 IP 窗口计数。
### 5.2 首次设置密码
```http
POST /auth/password/set
Authorization: Bearer <access-token>
Content-Type: application/json
```
```json
{
"smsCode": "123456",
"newPassword": "correct horse battery staple"
}
```
前置步骤:
```http
POST /auth/sms/send?phone=<当前登录手机号>&purpose=password-set
```
规则:
- 手机号只能来自 Access Token 对应的当前用户,客户端不得传入手机号;
- 验证码用途必须严格为 `password-set`;
- 仅 `password_hash IS NULL` 时允许;
- 已有密码返回状态冲突,不得覆盖;
- 成功后撤销全部会话,调用方当前 Access Token 随即失效;
- 成功响应不签发新 Token。
成功响应:
```json
{
"code": 200,
"message": "success",
"data": { "passwordConfigured": true, "reauthenticationRequired": true },
"requestId": "..."
}
```
### 5.3 修改密码
```http
POST /auth/password/change
Authorization: Bearer <access-token>
Content-Type: application/json
```
```json
{
"currentPassword": "old password",
"newPassword": "new correct horse battery staple"
}
```
规则:
- 只接受 Access Token,Refresh Token 必须返回 401;
- 当前密码错误统一返回凭据错误;
- 未设置密码时返回密码状态冲突,引导用户走首次设置;
- 新旧密码不得相同;
- 成功后撤销全部会话且不签发新 Token。
### 5.4 找回并重置密码
前置步骤:
```http
POST /auth/sms/send?phone=13800138000&purpose=password-reset
```
重置:
```http
POST /auth/password/reset
Content-Type: application/json
```
```json
{
"phone": "13800138000",
"smsCode": "123456",
"newPassword": "new correct horse battery staple"
}
```
规则:
- 该接口匿名可访问;
- 验证码用途必须严格为 `password-reset`;
- 请求短信验证码时,无论账号是否存在都返回相同的受理响应;
- 账号不存在时不得创建账号;
- 只有有效验证码和可用账号才能修改摘要;
- 成功后撤销全部会话且不自动登录;
- inactive 或已完成注销的账号不得被重置流程复活;
- 验证失败不说明是账号、验证码还是用户状态问题。
## 6. DTO 与输入处理
建议新增:
- `PasswordLoginRequest(phone, password)`;
- `PasswordSetRequest(smsCode, newPassword)`;
- `PasswordChangeRequest(currentPassword, newPassword)`;
- `PasswordResetRequest(phone, smsCode, newPassword)`;
- `PasswordOperationResponse(passwordConfigured, reauthenticationRequired)`。
要求:
- DTO 不实现 `toString()`,不得使用会输出字段值的 Lombok `@Data`;
- 密码字段不进入 Bean Validation 默认错误回显;
- JSON 未知字段继续遵循项目现有全局策略;
- Controller 不打印请求体,不把请求 DTO 放入异常或审计 details;
- 前端可提供 `confirmPassword`,但后端不信任该字段;后端只接收一次新密码。
## 7. 密码策略
### 7.1 接受规则
1. NFC 规范化后长度为 12–64 个 Unicode code point;
2. 允许空格和 Unicode 字符;
3. 不自动删除首尾空格;
4. 不自动转换大小写;
5. 不能等于或包含完整手机号;
6. 不能命中项目内置、版本化的弱密码集合;
7. 新密码不能与当前密码相同。
### 7.2 弱密码名单
第一版使用仓库内只读资源,例如:
```text
src/main/resources/security/common-passwords.txt
```
该文件:
- 只包含公开常见弱密码,不包含任何用户数据;
- 在启动时加载为不可变集合;
- 加载失败时应用启动失败,不能静默关闭策略;
- 后续更新必须作为可审查的版本化变更;
- 本轮不调用外部泄漏密码 API,避免把用户密码或派生值发送给第三方。
## 8. BCrypt 存储与升级
### 8.1 写入
```text
normalized = NFC(rawPassword)
password_hash = BCryptPasswordEncoder.encode(normalized)
```
禁止:
- 明文密码落库;
- SHA/MD5 等快速摘要;
- 在数据库增加明文盐;
- 把密码、摘要或其前缀写入日志、审计、响应、消息或异常。
### 8.2 校验
```text
PasswordEncoder.matches(NFC(input), storedHash)
```
手机号不存在或没有密码时,也必须对固定 dummy BCrypt 摘要执行一次 `matches`,减少响应时间差异。dummy 摘要作为应用常量,由构建时生成,不包含真实密码或部署秘密。
### 8.3 cost 与升级
- 默认沿用当前 `BCryptPasswordEncoder` strength 10;
- 在 Jenkins 部署目标机上测量 P50/P95,再决定是否提高;
- 目标单次校验不超过服务可接受的登录延迟和并发预算;
- 若未来启用 `upgradeEncoding()`,仅在一次成功密码登录中升级摘要,并与会话创建保持失败原子性;第一版可不启用自动升级。
## 9. 失败计数、锁定与限流
### 9.1 标识保护
Redis key 不得包含完整手机号或原始 IP。使用独立 HMAC 密钥生成不可逆引用:
```text
phoneRef = HMAC-SHA256(PASSWORD_RATE_KEY_SECRET, normalizedPhone)
ipRef = HMAC-SHA256(PASSWORD_RATE_KEY_SECRET, normalizedIp)
```
只使用截断后的十六进制引用作为 key 片段;HMAC 原始值不得进入日志。
### 9.2 Redis key
```text
auth:pwd:fail:{phoneRef} TTL 15 分钟
auth:pwd:locked:{phoneRef} TTL 15 分钟
auth:pwd:ip:{ipRef}:{window} TTL 10 分钟
auth:pwd:op:{phoneRef}:{kind} 敏感操作限流
```
### 9.3 规则
| 维度 | 规则 | 超限行为 |
|---|---|---|
| 手机号 | 连续失败 5 次 | 锁定密码登录 15 分钟 |
| IP | 10 分钟最多 30 次密码登录请求 | HTTP 429 |
| 设置/修改/重置 | 每手机号引用 10 分钟最多 10 次 | HTTP 429 |
| 登录成功 | 清除手机号失败和锁定 key | 保留 IP 窗口 |
| 验证码登录 | 不受密码锁定影响 | 仍受原短信限流 |
计数与首次 TTL 设置必须由 Lua 脚本或等价 Redis 原子操作完成,不能用非原子的 `INCR` 后再独立 `EXPIRE` 留下永久 key。
### 9.4 Redis 故障
密码认证与密码敏感操作在 Redis 不可用时失败关闭:
- 不校验密码;
- 不创建会话;
- 不修改密码;
- 返回固定的服务暂不可用响应;
- 日志只记录固定分类和 requestId,不记录 Redis 地址、手机号或原始异常消息。
验证码登录沿用既有行为,本设计不顺带改变其 Redis 降级语义。
## 10. 防账号枚举
密码登录以下情况使用完全相同的外部响应:
- 手机号不存在;
- `password_hash IS NULL`;
- 密码错误;
- 用户 inactive;
- 用户已完成注销;
- 手机号命中注销墓碑;
- 用户角色或会话创建条件不合法。
统一响应:
```http
HTTP/1.1 401 Unauthorized
```
```json
{
"code": 2005,
"message": "手机号或密码错误",
"requestId": "..."
}
```
实现要求:
- 不存在用户仍执行 dummy BCrypt;
- inactive 等状态不得在校验前快速返回;
- 响应状态、业务码、正文结构和主要计算路径一致;
- 不承诺网络层严格恒定耗时,但独立测试需验证不存在明显快速分支;
- 找回密码的发送入口统一返回“如账号可用,验证码将发送”。
## 11. 状态机
```text
未设置密码
├─ 有效 Access + password-set 验证码 ─→ 已设置密码
└─ password-reset 验证码 ────────────→ 已设置密码
已设置密码
├─ 手机号 + 正确密码 ────────────────→ 创建登录会话
├─ 当前密码 + 新密码 ────────────────→ 已修改,全部会话撤销
├─ password-reset 验证码 ────────────→ 已重置,全部会话撤销
└─ 再次 set ─────────────────────────→ 409 状态冲突
inactive / 注销完成
└─ 任何密码操作 ─────────────────────→ 统一拒绝,不复活账号
```
## 12. 事务与会话边界
### 12.1 密码登录
```text
Controller(无事务)
→ 检查 Redis IP/手机号限制
→ PasswordAuthenticationService.login(REQUIRED)
→ 查询用户
→ BCrypt 真/假摘要校验
→ 校验用户可用性与注销状态
→ AuthSessionService.createSession
→ 写成功审计
→ 事务提交后清理手机号失败计数
```
会话记录保存失败时,登录事务回滚且不得返回 Token。
### 12.2 设置、修改、重置
每个操作必须在单个 REQUIRED 事务内完成:
```text
锁定用户行/进行带版本条件更新
→ 验证当前身份或原密码/短信验证码
→ 校验新密码策略
→ 写 password_hash(及 password_changed_at,如批准)
→ revokeAllUserSessions(reason=...)
→ 写脱敏审计
→ 提交
```
任一步失败必须整体回滚。特别是:
- 验证码不能已消费而密码未更新;
- 密码不能已更新而旧会话仍活动;
- 审计失败时是否回滚沿用现有 `AuditService` 的事务行为;
- 成功响应只可在事务提交后返回。
### 12.3 会话撤销原因
新增白名单原因:
```text
PASSWORD_SET
PASSWORD_CHANGED
PASSWORD_RESET
```
不得把手机号、密码、验证码或操作来源拼入 `revocation_reason`。
## 13. 并发与幂等
| 场景 | 预期 |
|---|---|
| 两次并发首次设置 | 恰好一次成功,另一次 409;最终只有一个摘要 |
| 两次并发修改 | 使用数据库行锁或乐观版本保证旧密码只成功一次 |
| 两次并发重置 | 同一验证码只能消费一次;恰好一次成功 |
| 修改密码与 Refresh 并发 | 密码事务提交后的终态必须无活动旧会话 |
| 登录与密码修改并发 | 密码修改提交后不得留下使用旧密码创建的活动会话 |
| 重复成功请求 | 不得重复写审计、重复创建会话或返回同一 Token |
仅调用现有 `revokeAllUserSessions` 不足以自动证明“登录/刷新与密码变更并发后无活动会话”。实现阶段必须设计数据库锁顺序或安全版本栅栏,并用真实 MySQL 并发测试证明终态。禁止使用 `synchronized`、JVM 锁或 `Thread.sleep` 作为正确性机制。
推荐实现:密码变更事务对 `users` 行加写锁;登录和 Refresh 在创建后继会话前以同一锁顺序读取可用用户。若评估发现会扩大 S02 锁竞争,必须先补独立事务设计,不得直接上线。
## 14. 数据模型
### 14.1 现有字段
继续使用:
```sql
users.password_hash VARCHAR(255) NULL
```
`NULL` 表示尚未设置密码,不代表账号不可用;用户仍可使用验证码登录。
### 14.2 建议新增字段
建议新增:
```sql
password_changed_at DATETIME NULL
```
用途:
- 记录密码凭据最后变更时间;
- 支持安全审计和后续 Token 签发时间比较;
- 不从 `updated_at` 推断,因为资料修改也会更新该字段。
是否最终新增必须在实施前检查当前最新 Flyway 后再分配迁移号。不得修改 V1–V45,也不得在设计文档预占 V46。
不建议把登录失败计数、锁定结束时间写入 MySQL;这些临时状态放 Redis,避免热点行写放大。
## 15. 错误契约
建议复用和新增如下错误:
| 场景 | HTTP | ErrorCode | 对外消息 |
|---|---:|---|---|
| 手机号或密码认证失败 | 401 | 复用 `CREDENTIALS_INVALID(2005)` | 手机号或密码错误 |
| 登录/操作限流 | 429 | 新增 `PASSWORD_RATE_LIMITED` | 尝试次数过多,请稍后再试 |
| 密码策略不合格 | 400 | 新增 `PASSWORD_POLICY_INVALID` | 密码不符合安全要求 |
| 已设置/未设置等认证后状态冲突 | 409 | 新增 `PASSWORD_STATE_CONFLICT` | 当前密码状态不允许此操作 |
| 当前密码错误 | 401 | `CREDENTIALS_INVALID(2005)` | 手机号或密码错误 |
| Redis 等认证依赖不可用 | 503 | 复用或新增经批准的服务不可用码 | 认证服务暂不可用 |
| DTO 格式错误 | 400 | `BAD_REQUEST(1001)` | bad request |
要求:
- 将 `CREDENTIALS_INVALID` 显式映射为 401;其当前无生产调用点,实施时仍需做全仓回归;
- 不改变通用 `RATE_LIMITED(9002)` 的历史 400 映射;
- ErrorCode 数字必须在实施时从当前主线分配,表中的新增名称不预定数字;
- 匿名接口不得返回 `USER_NOT_FOUND`、`USER_DISABLED` 或 `ACCOUNT_DELETED`。
## 16. 日志与审计
允许的审计 action:
```text
PASSWORD_LOGIN_SUCCEEDED
PASSWORD_LOGIN_FAILED
PASSWORD_LOGIN_RATE_LIMITED
PASSWORD_SET
PASSWORD_CHANGED
PASSWORD_RESET
AUTH_SESSIONS_REVOKED
```
审计规则:
- 登录失败若账号不可识别,`user_id` 可为 null;
- details 只包含固定分类,如 `INVALID_CREDENTIALS`、`RATE_LIMITED`;
- 手机号仅允许不可逆 `phoneRef`,不允许完整或掩码手机号进入审计 details;
- 不记录密码、摘要、验证码、JWT、Redis key、SQL 参数或异常 cause;
- 应限制失败审计写入频率,防止攻击者制造审计表写放大;聚合限流审计优先于逐次落库;
- 日志只记录固定文本、requestId 和固定错误分类。
## 17. SecurityConfig
匿名放行:
```text
POST /auth/password/login
POST /auth/password/reset
```
需要有效 Access Token:
```text
POST /auth/password/set
POST /auth/password/change
```
规则:
- 不能使用宽泛的 `/auth/** permitAll`;
- Refresh Token 访问 set/change 必须为 401;
- STUDENT、COMPANY、ADMIN 均可管理自己的密码;
- 角色不参与密码认证成功与否,只进入 Token 的既有 role claim;
- 不接受 `userId`、`sessionId` 或角色作为请求参数。
## 18. 配置
建议配置:
```yaml
app:
auth:
password:
enabled: ${PASSWORD_LOGIN_ENABLED:false}
bcrypt-strength: ${PASSWORD_BCRYPT_STRENGTH:10}
min-length: ${PASSWORD_MIN_LENGTH:12}
max-length: ${PASSWORD_MAX_LENGTH:64}
max-failures: ${PASSWORD_MAX_FAILURES:5}
lockout-seconds: ${PASSWORD_LOCKOUT_SECONDS:900}
ip-window-seconds: ${PASSWORD_IP_WINDOW_SECONDS:600}
ip-max-attempts: ${PASSWORD_IP_MAX_ATTEMPTS:30}
operation-max-attempts: ${PASSWORD_OPERATION_MAX_ATTEMPTS:10}
rate-key-secret: ${PASSWORD_RATE_KEY_SECRET}
```
约束:
- `enabled` 默认 false,发布前显式开启;
- `rate-key-secret` 无默认值,启用密码模块时缺失或过短必须启动失败;
- 密钥存放 Jenkins Credentials,不写入 Git、日志或命令历史;
- 阈值配置需做范围校验,不能允许 0、负数或极端值关闭保护;
- 配置错误只显示配置名,不显示值。
## 19. 组件建议
建议新增或调整:
```text
auth/password/
PasswordAuthenticationService
PasswordPolicy
PasswordRateLimiter
PasswordIdentifierHasher
PasswordAuthenticationProperties
PasswordAuditService(可复用既有安全审计封装时不新增)
auth/dto/
PasswordLoginRequest
PasswordSetRequest
PasswordChangeRequest
PasswordResetRequest
PasswordOperationResponse
```
职责边界:
- Controller:接收 DTO、提取受信身份与请求上下文、包装响应;
- Service:认证编排、事务、用户状态和会话处理;
- Policy:纯密码规则,不访问数据库;
- RateLimiter:只操作 Redis,不查询用户;
- IdentifierHasher:生成 Redis/审计不可逆引用;
- `AuthSessionService`:继续作为唯一 Token 会话写入口。
不得把所有逻辑继续堆入现有 `AuthService`。
## 20. 自动化测试
### 20.1 密码策略
- 11/12/64/65 字符边界;
- Unicode code point 与 NFC;
- 首尾空格不被 trim;
- 中文、空格和符号可用;
- 手机号包含检测;
- 弱密码拒绝;
- 新旧密码相同拒绝;
- 测试失败信息不包含密码。
### 20.2 密码登录
- 已设置密码登录成功并创建一次会话;
- 不存在、未设置、错误、inactive、注销、角色异常全部 401/2005;
- 六类失败响应 JSON 完全一致;
- 不存在账号执行 dummy BCrypt 的变异门禁;
- 密码登录不自动注册;
- Access/Refresh 类型、摘要存储和现有 S02 语义不变;
- 登录事务失败时无 Token、无活动会话。
### 20.3 设置、修改、重置
- 首次设置成功;重复设置 409;
- `password-set` 与其他用途验证码不可互换;
- 修改密码需正确旧密码;
- 重置需 `password-reset` 验证码;
- 验证码成功后原子消费;
- inactive/注销账号不可设置或重置;
- 成功后全部旧 Access/Refresh 均失效;
- 不自动签发新 Token;
- 数据库只保存 BCrypt 摘要。
### 20.4 限流与故障
- 第 1–4 次失败未锁定,第 5 次锁定;
- 锁定 TTL 精确且不被后续请求无限延长;
- 成功登录清除手机号失败计数;
- IP 限流跨多个手机号生效;
- 手机号锁定不阻止验证码登录;
- Redis key 无完整手机号/IP;
- Redis 不可用时无登录、无修改、无会话;
- Lua/原子计数并发下不丢增量。
### 20.5 并发
- 并发 set 恰好一个成功;
- 并发 reset 同一验证码恰好一个成功;
- password change 与 refresh 竞争终态无旧活动会话;
- password change 与 password login 竞争终态符合锁协议;
- 不允许 catch-all 吞掉异常,不使用 sleep 证明正确性。
### 20.6 脱敏和副作用
- 真实 Logback 捕获 root、应用、Hibernate、JDBC logger;
- 扫描 message、throwable、cause 和 SQL 绑定参数;
- 响应、日志、审计、通知、消息、RabbitMQ 无密码/摘要/验证码/Token/完整手机号;
- 认证失败不修改业务表;
- 密码操作不发送通知或 RabbitMQ 消息,除非另行设计。
## 21. 真实 MySQL/Redis/JAR 黑盒验收
必须使用最新 JAR、真实 MySQL 与 Redis,至少覆盖:
1. Flyway 执行与 Hibernate validate;
2. 验证码旧用户设置密码并重新登录;
3. 密码登录成功及 Access/Refresh 类型矩阵;
4. 不存在、无密码、错误、inactive、注销的统一响应;
5. 5 次失败与 15 分钟锁定;
6. 单 IP 跨手机号限流;
7. Redis 停止后失败关闭,恢复后正常;
8. 设置、修改、重置后三类旧会话全部失效;
9. 并发设置、重置、登录/修改、Refresh/修改;
10. 数据库搜索密码原文和 Token 原文命中 0;
11. 日志、审计和响应敏感扫描命中 0,扫描器需注入探针证明有效;
12. 关键业务表逐字段副作用快照;
13. RabbitMQ ready/unacked 保持 0;
14. 静态与运行时 OpenAPI 路径和 operation 集合一致;
15. 全量 `mvn clean verify` 为 Failures=0、Errors=0、Skipped=0。
## 22. Apifox 与前端联调
前端登录页展示:
- 验证码登录;
- 密码登录;
- 忘记密码;
- 已验证码登录用户的“设置密码”;
- 已设置密码用户的“修改密码”。
前端不得根据不同错误文案推断账号状态。密码登录失败统一显示“手机号或密码错误”;限流显示通用等待提示。设置、修改或重置成功后:
1. 清除本地 Access/Refresh Token;
2. 跳转登录页;
3. 提示“密码已更新,请重新登录”。
Apifox 环境继续使用统一 `baseUrl`,密码登录成功后复用现有后置脚本保存 Access/Refresh Token。
## 23. 发布和兼容
建议顺序:
1. 完成设计评审;
2. 从合并时主线确定实际 Flyway 编号;
3. 先部署数据库兼容迁移;
4. 部署后端但保持 `PASSWORD_LOGIN_ENABLED=false`;
5. 验证验证码登录和旧客户端不受影响;
6. 配置 Jenkins Credentials 与限流参数;
7. 小范围启用密码入口;
8. 完成 Apifox 与前端联调;
9. 观察登录成功率、限流率、Redis 和 BCrypt 延迟;
10. 再全量开放。
兼容要求:
- 旧用户无需迁移密码;
- `password_hash IS NULL` 的用户继续正常验证码登录;
- 旧客户端无需感知新接口;
- 不修改 `/auth/login` 的请求和响应;
- 不用默认密码批量填充历史账号。
## 24. 回滚
发生问题时:
1. 设置 `PASSWORD_LOGIN_ENABLED=false`;
2. 保留数据库中的 BCrypt 摘要,不清空用户密码;
3. 验证码登录继续工作;
4. 停止前端展示密码入口;
5. 不回滚已成功执行的 Flyway;
6. 根据固定审计事件调查,不导出密码或摘要;
7. 修复后重新执行全量与黑盒验收再开启。
关闭密码入口不能恢复已撤销的旧会话;用户可通过验证码重新登录。
## 25. 预期修改范围
实施阶段预计涉及:
- `auth/` 与 `auth/password/` 生产代码;
- `controller/AuthController.java`;
- `config/SecurityConfig.java`;
- `common/exception/ErrorCode.java` 与 `ErrorHttpStatusRegistry.java`;
- `application.yml` 和测试 profile;
- 一条新的 Flyway(仅在批准 `password_changed_at` 后);
- OpenAPI、认证文档和前端联调文档;
- 密码专项测试、认证回归和真实 MySQL/Redis 验收脚本。
禁止顺带修改:
- S02 Token 格式、密钥、有效期和轮换策略;
- 用户角色与资源归属;
- 评分、排名、推荐、邀约、申诉;
- 简历上传、隐私和查重;
- 消息发送与撤回;
- 已执行的 V1–V45;
- 本机号码一键登录或第三方登录。
## 26. 实施门禁确认
负责人于 2026-09-20 确认以下三项门禁:
1. **批准**新增 `users.password_changed_at`;
2. **接受**密码登录及密码敏感操作在 Redis 不可用时失败关闭;
3. **批准**新增密码专用 ErrorCode,并把 `CREDENTIALS_INVALID(2005)` 显式映射为 HTTP 401。
本文设计据此进入“已确认、可实施”状态。实现仍须从届时实际主线分配 Flyway 版本号和新增错误码数字,不得修改既有迁移或占用已被其他并行模块使用的编号。
其余产品与安全决策已在本文固定。任何偏离必须先更新设计并记录原因,不能在实现过程中隐式改变。

93
docs/phone-password-authentication-implementation-report.md

@ -0,0 +1,93 @@
# 手机号+密码登录实施记录
## 1. 范围
本批只实现手机号账号的密码认证闭环:登录、首次设置密码、已登录用户修改密码、短信验证后重置密码。未实现本机号码一键登录,也未改变既有短信登录、Access/Refresh Token 格式、评分、推荐或其他业务接口语义。
## 2. 账号与密码策略
- 账号字段固定为 `users.phone`,要求 11 位数字;不引入用户名或邮箱登录。
- 密码使用 BCrypt 存储,成本沿用项目 `PasswordEncoder` 配置(当前为 10)。数据库不保存明文密码。
- 新密码长度为 12–64 个 Unicode code point;先做 NFC 规范化,不 trim、不自动小写。
- 拒绝常见弱密码、包含完整手机号的密码,以及与当前密码相同的密码。
- 不设置默认密码,不因密码登录自动创建账号。
## 3. 接口
| 方法 | 路径 | 身份 | 作用 |
| --- | --- | --- | --- |
| POST | `/auth/password/login` | 匿名 | 手机号+密码登录并签发既有 Access/Refresh Token |
| POST | `/auth/password/set` | Access Token | 首次设置密码,需要 `password-set` 短信验证码 |
| POST | `/auth/password/change` | Access Token | 校验当前密码后修改密码 |
| POST | `/auth/password/reset` | 匿名 | `password-reset` 短信验证码后重置密码 |
成功响应只返回现有认证所需结果或固定操作结果;错误响应不回显手机号、密码、验证码、Token、异常类或 SQL。
## 4. 会话撤销
设置、修改、重置密码成功后调用现有 `AuthSessionService.revokeAllUserSessions`,撤销该用户全部会话,并写入固定动作审计。密码登录成功创建新的会话。密码修改时间写入 `users.password_changed_at`,为后续令牌失效策略保留服务端时间基准。
## 5. 防枚举与限流
- 不存在手机号、停用用户、删除完成用户、错误密码统一返回 `CREDENTIALS_INVALID`(HTTP 401),不区分账号是否存在。
- 登录使用固定 dummy BCrypt hash 对未知手机号执行同类密码计算,避免明显时间差。
- Redis 记录仅保存 HMAC-SHA256 引用,不保存原始手机号或 IP。
- 同手机号连续 5 次失败锁定 15 分钟;同 IP 10 分钟最多 30 次尝试。
- Redis 不可用或密钥配置不满足要求时拒绝认证,不伪造登录成功。
## 6. 配置
```yaml
app:
auth:
password:
enabled: ${PASSWORD_LOGIN_ENABLED:false}
rate-key-secret: ${PASSWORD_RATE_KEY_SECRET:}
```
密码功能默认关闭;启用时必须提供至少 32 个字符的 `PASSWORD_RATE_KEY_SECRET`。生产环境不得把该密钥写入仓库或日志。
## 7. 数据库
新增且仅新增 `V46__password_authentication.sql`,为 `users` 增加可空 `password_changed_at DATETIME`。未修改 V1–V45,未新增密码明文字段、Token 明文字段或其他表。
## 8. 错误映射
- `CREDENTIALS_INVALID`(2005)→ 401。
- `PASSWORD_RATE_LIMITED`(2006)→ 429。
- `PASSWORD_STATE_CONFLICT`(2007)→ 409。
- `PASSWORD_POLICY_INVALID`(2008)→ 400。
- Redis 故障复用 `SERVICE_UNAVAILABLE`,响应为固定安全文案。
## 9. 并发与事务
设置、修改、重置使用用户行悲观锁;密码更新、密码变更时间、会话撤销、审计在同一事务内完成。密码登录创建会话和 Refresh Token 轮换也先取得同一 `users` 行悲观锁,锁顺序统一为 `users -> auth_tokens`,从而避免改密提交后并发请求再次留下旧认证会话。
## 10. 测试
- `PasswordPolicyTest`:长度、Unicode/NFC、常见密码、手机号包含、复用密码。
- `PasswordRateLimiterTest`:HMAC 不泄露原值、失败计数/锁定、Redis 故障 fail-closed、关闭开关行为。
- `PasswordAuthenticationIntegrationTest`:成功登录、错误凭据统一响应、修改密码撤销会话、短信重置后登录、精确夹具清理。
- 更新错误码映射契约测试与 OpenAPI 既有 DTO 安全断言,使断言只检查目标 DTO/响应,不因新增密码 schema 误报。
阻断整改后新增真实 Redis 原子计数/TTL 测试、真实 MySQL 登录/刷新与改密竞争测试,以及默认关闭与 Redis 故障失败关闭测试。密码专项与认证关联回归 **149/149**;最终 `mvn clean verify` **2825/2825,Failures=0,Errors=0,Skipped=0**。`python docs/verify_openapi.py` 通过,OpenAPI 为 **180 paths / 218 operations**。V46 已在全量 MySQL/Testcontainers 测试上下文中执行并通过 schema 校验。
## 11. OpenAPI
静态契约由 176 paths / 214 operations 扩展为 **180 paths / 218 operations**,新增四个密码接口及对应请求/响应 schema。密码 DTO 不暴露 `userId`、`sessionId`、`role`、`passwordHash`、`token`、`secret` 等敏感字段。
## 12. 阻断整改闭环
- 所有设置、修改、重置操作统一执行功能开关、Redis 可用性检查及每手机号 HMAC 引用 10 分钟最多 10 次的操作限流。
- 登录 IP 计数、密码失败计数与锁定全部改为 Redis Lua 原子脚本;计数与 TTL 在同一次执行中完成,并在 TTL 缺失时修复。锁使用 `SET ... NX`,并发迟到失败不会延长既有锁定期。
- 登录、会话创建、Refresh 轮换和密码变更共用 `users` 行悲观锁;真实 MySQL 竞争测试连续验证登录/刷新与改密终态,不留下旧认证活动会话。
- 密码配置统一由 `PasswordAuthenticationProperties` 承载,并对密钥、阈值和时间窗口进行启动期边界校验。
## 13. 已知边界
- 验证码现有存储与消费语义保持不变,本批只保证密码认证链路不打印验证码;验证码摘要化另行立项。
- `password_changed_at` 已落库,但旧 Token 是否按该时间主动失效需在后续 Token 策略批次中单独验收。
## 14. 变更边界
未修改评分、排名、推荐、邀约、申诉、消息撤回、简历上传、Flyway 旧迁移、既有 Token 格式或短信登录接口。提交前仍需完成全量测试、敏感扫描、暂存和审查。

101
docs/phone-password-authentication-independent-verification-report.md

@ -0,0 +1,101 @@
# 手机号密码认证独立代码复验报告
日期:2026-09-20
结论:**FAIL,不进入暂存/提交检查点**
## 1. 复验范围
复核了密码认证生产代码、V46、SecurityConfig、错误映射、DTO、配置和现有测试;未修改生产代码、未暂存、未提交、未推送。
## 2. 已通过项目
- 账号字段为 `users.phone`,无用户名/邮箱密码登录入口。
- BCrypt 摘要存储,未发现明文密码、Token 或 Redis 原始手机号/IP 写入日志、响应、审计的代码路径。
- 登录对未知手机号使用 dummy BCrypt 摘要,错误凭据统一使用 `CREDENTIALS_INVALID(2005)`。
- 设置、修改、重置使用 `findByIdForUpdate`,成功后调用既有全量会话撤销。
- V46 是唯一新增迁移,仅增加可空 `users.password_changed_at`;V1–V45 未修改。
- 密码接口已进入 SecurityConfig,OpenAPI 为 180 paths / 218 operations。
- 定向测试:356/356,Failures=0、Errors=0、Skipped=0。
- 全量 `mvn clean verify`:2818/2818,Failures=0、Errors=0、Skipped=0。
- OpenAPI 校验:`All checks passed!`。
## 3. 阻断项
### F1:设置/修改密码绕过 Redis 失败关闭和功能开关
`PasswordAuthenticationService.setPassword`(约 69 行)与 `changePassword`(约 85 行)没有调用 `rateLimiter.checkAllowed` 或等价的启用检查;只有 `login` 和 `resetPassword` 调用了它。
因此在 `PASSWORD_LOGIN_ENABLED=false` 或 Redis 不可用时,已认证用户仍可能执行设置/修改密码,违反设计 §9.4“密码敏感操作失败关闭”,也使“默认关闭”没有覆盖全部密码操作。
### F2:没有实现密码敏感操作的独立 10 分钟限流
设计要求设置/修改/重置每手机号引用 10 分钟最多 10 次。当前实现只有登录用的 IP 30/10 分钟和手机号失败锁定;`setPassword`、`changePassword`、`resetPassword` 没有操作计数、失败计数或独立阈值。
### F3:限流计数不是原子实现
`PasswordRateLimiter` 使用 `increment` 后再单独 `expire`(约 52–55、70–73 行),没有 Lua 脚本或等价原子操作。并发下可能出现计数与 TTL 不一致,且存在永久 key 风险,违反设计 §9.3。
### F4:密码变更与登录/刷新没有共用锁或版本栅栏
- 密码登录通过 `userRepository.findByPhone` 读取用户后直接调用 `createSession`。
- `AuthSessionService.createSession` 未对 users 行加锁。
- Refresh 路径的 `requireUsableUser` 使用普通 `findById`。
- `password_changed_at` 只写入,未在登录、刷新或会话校验中读取。
因此“登录/刷新与改密并发”可能出现改密撤销之后又创建旧认证会话;现有测试没有证明设计要求的并发终态。
## 4. 非阻断观察
- 初次复验时,`bcrypt-strength`、阈值等设计配置仍为硬编码常量;本轮已将限流阈值与时间窗口收束到带启动期边界校验的配置属性。BCrypt 成本仍沿用项目统一 `PasswordEncoder` 配置,不在本轮另设第二套参数源。
- 验证码明文存储沿用既有语义,属于设计中明确保留的后续事项。
## 5. 下一步整改要求
1. 为所有密码敏感操作统一增加启用检查、Redis fail-closed 和独立操作限流。
2. 用 Lua 或 Redis 原子脚本完成“计数 + 首次 TTL”一体化;补并发增量测试。
3. 为登录、刷新、改密建立统一 users 行锁/密码版本栅栏,补真实并发测试,证明改密提交后不会留下旧会话。
4. 补齐 Redis 故障、开关关闭、10 分钟操作限流、并发登录/刷新/改密测试,再重新执行全量和真实 MySQL/Redis 验收。
## 6. Git 边界
- 暂存区为空;未提交、未推送。
- 本报告为独立复验记录,不包含生产修复。
- 历史未跟踪验收产物保持不动。
## 7. 阻断整改复验(2026-09-20)
初次 FAIL 及 F1–F4 证据保留不变。针对四项阻断完成定向整改后重新复验,结果如下:
### F1:PASS——所有密码操作统一失败关闭
- `setPassword`、`changePassword`、`resetPassword` 均调用 `checkOperationAllowed`。
- 功能默认关闭时,直接调用修改密码也返回固定 `SERVICE_UNAVAILABLE`;Redis 不可用时同样安全失败,密码摘要和既有会话均不变化。
- 配置改由 `PasswordAuthenticationProperties` 统一承载并在启动期校验。
### F2:PASS——独立操作限流已实现
- 设置、修改、重置共用按手机号 HMAC 引用计数的操作限流,默认 10 分钟最多 10 次,第 11 次返回 429。
- Redis key、异常与日志均不包含原始手机号、密码或 Token。
### F3:PASS——Redis 原子计数与锁定已实现
- 登录 IP 计数通过 Lua 在一次 Redis 执行中完成 `INCR` 与 TTL 设置/修复。
- 密码失败计数和锁定通过另一 Lua 脚本原子完成;锁使用 `NX`,不会被并发迟到请求反复延长。
- 真实 Redis 7 Testcontainers 测试验证 20 路并发累计精确为 20,计数键和锁键均有 TTL,且达到阈值后登录被拒绝。
### F4:PASS——登录、刷新与改密共用数据库栅栏
- 密码登录按手机号悲观锁定 `users` 行;`createSession` 再按 id 锁定用户。
- Refresh 先锁定 `users` 行,再查询和消费 `auth_tokens`;密码修改使用相同的 `users -> auth_tokens` 锁顺序。
- 真实 MySQL 8.4 Testcontainers 竞争测试各执行 10 轮:登录与改密、刷新与改密的最终状态均不留下旧认证活动会话,最终密码为新摘要。
## 8. 最终验证
- 密码专项及认证关联回归:**149/149**,Failures=0、Errors=0、Skipped=0。
- 最终 `mvn clean verify`:**2825/2825**,Failures=0、Errors=0、Skipped=0;真实 MySQL 8.4 与 Redis 7 Testcontainers 均实际执行。
- OpenAPI:**180 paths / 218 operations**,`All checks passed!`。
- `git diff --check` 无错误;暂存区为空;未提交、未推送;V1–V45 未修改,V46 仍为唯一新增迁移。
## 9. 最终结论
> **PASS——F1–F4 四个阻断均已修复并通过定向、并发、真实 Redis/MySQL 与全量回归,可以进入独立复验后的精确暂存检查点。**

29
docs/privacy-settings-api.md

@ -103,4 +103,31 @@
## 6. 审计
更新隐私设置会写入审计日志 `UPDATE_PRIVACY_SETTINGS`。审计中**不得**写入 `phone / email / password / jwt / resumeUrl` 等敏感值。
更新隐私设置会写入审计日志 `UPDATE_PRIVACY_SETTINGS`。审计中**不得**写入 `phone / email / password / jwt / resumeUrl` 等敏感值。
## S03-B:`resumeVisibility` 的企业侧执行边界
此前 `resumeVisibility` 只有存储与读写接口,**没有任何企业侧代码路径读取它**,等同于空转开关。
S03 起该设置在企业读取简历文件的唯一入口 `GET /company/candidates/{applicationId}/resume`
上真正生效,判定全部由失败关闭的 `CompanyResumeAccessPolicy` 完成。
### 可见性决策表
| 设置 | 企业条件 | 投递条件 | 结果 |
|---|---|---|---|
| 无设置记录 / `null` | 任意 | 任意 | 403(按 PRIVATE 处理) |
| `PRIVATE` | 任意 | 包括已投递、面试、录用 | 403 |
| `APPLIED_COMPANIES` | 岗位属于当前企业 | `SUBMITTED`/`VIEWED`/`INTERVIEW`/`HIRED` | 200 |
| `APPLIED_COMPANIES` | 岗位属于当前企业 | `WITHDRAWN`/`REJECTED` | 403 |
| `APPLIED_COMPANIES` | 其他企业 | 任意 | 403 |
| `VERIFIED_COMPANIES` | 当前企业 `verified=true` 且拥有该投递岗位 | 任意合法投递状态 | 200 |
| `VERIFIED_COMPANIES` | 未认证企业或其他企业 | 任意 | 403 |
### 重要变更
- **`APPLIED_COMPANIES` 与 `VERIFIED_COMPANIES` 现在都强制校验「投递所属岗位属于当前企业」。**
此前 `canCompanyViewResume` 不校验公司归属:`APPLIED_COMPANIES` 完全不使用 `companyId`,
`VERIFIED_COMPANIES` 只校验企业 `verified` —— 任何已认证企业都可凭任意 `applicationId` 查看简历。
- `VERIFIED_COMPANIES` **不是**「知道学生 ID 即可查看」:仍需合法的候选业务上下文。
- 隐私拒绝统一返回 `FORBIDDEN(2002)`;跨企业同样 403,不通过差异响应泄露归属。
- `PrivacySettingsService.canCompanyViewResume` 保留为委托方法,判定逻辑已收束到策略类,
不再保留可被误用的宽松实现。

303
docs/q01-job-search-mysql-explain-evidence.md

@ -0,0 +1,303 @@
# Q01 MySQL 执行计划证据(由 JobSearchQueryExplainMySqlIntegrationTest 生成)
- 数据库:`mysql:8.4` Testcontainers 独立容器,正式 Flyway 迁移,`ddl-auto=validate`
- EXPLAIN 形式:`EXPLAIN FORMAT=JSON`,与 Criteria 路径绑定同一组参数
- 夹具规模:每个城市分别 1 / 20 / 50 条 PUBLISHED 岗位(刻意做小,用于验证语义而非压测)
- Q01 不新增索引;执行计划问题记录给 R01
## 1. 组合过滤 + latest 分页
Hibernate 实际生成并执行的 SQL(占位符形式):
```sql
select j1_0.id,j1_0.city,j1_0.company_id,j1_0.created_at,j1_0.description,j1_0.employment_type,j1_0.published_at,j1_0.requirements,j1_0.salary_max,j1_0.salary_min,j1_0.status,j1_0.title,j1_0.updated_at from jobs j1_0 where j1_0.status=? and (lower(j1_0.title) like ? escape '' or lower(j1_0.description) like ? escape '' or lower(j1_0.requirements) like ? escape '') and lower(j1_0.city)=? and lower(j1_0.employment_type)=? and j1_0.salary_max>=? and j1_0.salary_min<=? and j1_0.company_id=? order by j1_0.created_at desc,j1_0.id desc limit ?,?
select count(j1_0.id) from jobs j1_0 where j1_0.status=? and (lower(j1_0.title) like ? escape '' or lower(j1_0.description) like ? escape '' or lower(j1_0.requirements) like ? escape '') and lower(j1_0.city)=? and lower(j1_0.employment_type)=? and j1_0.salary_max>=? and j1_0.salary_min<=? and j1_0.company_id=?
```
等价 SQL(绑定参数 [PUBLISHED, %q01e75001岗位%, %q01e75001岗位%, %q01e75001岗位%, Q01E75001CITY20, FULL_TIME, 5000, 30000, 1, 20, 0]):
```sql
SELECT j.id FROM jobs j
WHERE j.status = ? AND (lower(j.title) LIKE ? OR lower(j.description) LIKE ? OR lower(j.requirements) LIKE ?)
AND lower(j.city) = ? AND lower(j.employment_type) = ? AND j.salary_max >= ? AND j.salary_min <= ? AND j.company_id = ?
ORDER BY j.created_at DESC, j.id DESC
LIMIT ? OFFSET ?
```
计划摘要:
```
query_cost = 1.35
using_filesort = true
using_temporary_table = false
- 表 query_block/ORDER BY: table_name="j", access_type="ALL", possible_keys=["idx_jobs_company_status","idx_jobs_status_created","idx_jobs_city_status","idx_jobs_status_employment_type","idx_jobs_status_salary_max"], rows_examined_per_scan=1, rows_produced_per_join=1, filtered="100.00"
```
<details><summary>EXPLAIN FORMAT=JSON 原文</summary>
```json
{
"query_block": {
"select_id": 1,
"cost_info": {
"query_cost": "1.35"
},
"ordering_operation": {
"using_filesort": true,
"cost_info": {
"sort_cost": "1.00"
},
"table": {
"table_name": "j",
"access_type": "ALL",
"possible_keys": [
"idx_jobs_company_status",
"idx_jobs_status_created",
"idx_jobs_city_status",
"idx_jobs_status_employment_type",
"idx_jobs_status_salary_max"
],
"rows_examined_per_scan": 1,
"rows_produced_per_join": 1,
"filtered": "100.00",
"cost_info": {
"read_cost": "0.25",
"eval_cost": "0.10",
"prefix_cost": "0.35",
"data_read_per_join": "1K"
},
"used_columns": [
"id",
"company_id",
"title",
"description",
"requirements",
"city",
"employment_type",
"salary_min",
"salary_max",
"status",
"created_at"
],
"attached_condition": "((`mydreamjob_q01`.`j`.`company_id` = 1) and (`mydreamjob_q01`.`j`.`status` = 'PUBLISHED') and ((lower(`mydreamjob_q01`.`j`.`title`) like '%q01e75001岗位%') or (lower(`mydreamjob_q01`.`j`.`description`) like '%q01e75001岗位%') or (lower(`mydreamjob_q01`.`j`.`requirements`) like '%q01e75001岗位%')) and (lower(`mydreamjob_q01`.`j`.`city`) = 'q01e75001city20') and (lower(`mydreamjob_q01`.`j`.`employment_type`) = 'full_time') and (`mydreamjob_q01`.`j`.`salary_max` >= 5000) and (`mydreamjob_q01`.`j`.`salary_min` <= 30000))"
}
}
}
}
```
</details>
## 2. latest 分页
等价 SQL(绑定参数 [PUBLISHED, Q01E75001CITY20, 20, 0]):
```sql
SELECT j.id FROM jobs j
WHERE j.status = ? AND lower(j.city) = ?
ORDER BY j.created_at DESC, j.id DESC
LIMIT ? OFFSET ?
```
计划摘要:
```
query_cost = 1.35
using_filesort = true
using_temporary_table = false
- 表 query_block/ORDER BY: table_name="j", access_type="ALL", possible_keys=["idx_jobs_status_created","idx_jobs_city_status","idx_jobs_status_employment_type","idx_jobs_status_salary_max"], rows_examined_per_scan=1, rows_produced_per_join=1, filtered="100.00"
```
<details><summary>EXPLAIN FORMAT=JSON 原文</summary>
```json
{
"query_block": {
"select_id": 1,
"cost_info": {
"query_cost": "1.35"
},
"ordering_operation": {
"using_filesort": true,
"cost_info": {
"sort_cost": "1.00"
},
"table": {
"table_name": "j",
"access_type": "ALL",
"possible_keys": [
"idx_jobs_status_created",
"idx_jobs_city_status",
"idx_jobs_status_employment_type",
"idx_jobs_status_salary_max"
],
"rows_examined_per_scan": 1,
"rows_produced_per_join": 1,
"filtered": "100.00",
"cost_info": {
"read_cost": "0.25",
"eval_cost": "0.10",
"prefix_cost": "0.35",
"data_read_per_join": "1K"
},
"used_columns": [
"id",
"city",
"status",
"created_at"
],
"attached_condition": "((`mydreamjob_q01`.`j`.`status` = 'PUBLISHED') and (lower(`mydreamjob_q01`.`j`.`city`) = 'q01e75001city20'))"
}
}
}
}
```
</details>
## 3. salary 排序(salaryMax DESC NULLS LAST)
等价 SQL(绑定参数 [PUBLISHED, Q01E75001CITY20, 20, 0]):
```sql
SELECT j.id FROM jobs j
WHERE j.status = ? AND lower(j.city) = ?
ORDER BY CASE WHEN j.salary_max IS NULL THEN 1 ELSE 0 END ASC, j.salary_max DESC, j.id DESC
LIMIT ? OFFSET ?
```
计划摘要:
```
query_cost = 1.35
using_filesort = true
using_temporary_table = false
- 表 query_block/ORDER BY: table_name="j", access_type="ALL", possible_keys=["idx_jobs_status_created","idx_jobs_city_status","idx_jobs_status_employment_type","idx_jobs_status_salary_max"], rows_examined_per_scan=1, rows_produced_per_join=1, filtered="100.00"
```
<details><summary>EXPLAIN FORMAT=JSON 原文</summary>
```json
{
"query_block": {
"select_id": 1,
"cost_info": {
"query_cost": "1.35"
},
"ordering_operation": {
"using_filesort": true,
"cost_info": {
"sort_cost": "1.00"
},
"table": {
"table_name": "j",
"access_type": "ALL",
"possible_keys": [
"idx_jobs_status_created",
"idx_jobs_city_status",
"idx_jobs_status_employment_type",
"idx_jobs_status_salary_max"
],
"rows_examined_per_scan": 1,
"rows_produced_per_join": 1,
"filtered": "100.00",
"cost_info": {
"read_cost": "0.25",
"eval_cost": "0.10",
"prefix_cost": "0.35",
"data_read_per_join": "1K"
},
"used_columns": [
"id",
"city",
"salary_max",
"status"
],
"attached_condition": "((`mydreamjob_q01`.`j`.`status` = 'PUBLISHED') and (lower(`mydreamjob_q01`.`j`.`city`) = 'q01e75001city20'))"
}
}
}
}
```
</details>
## 4. count 查询
等价 SQL(绑定参数 [PUBLISHED, %q01e75001岗位%, %q01e75001岗位%, %q01e75001岗位%, Q01E75001CITY20, FULL_TIME, 5000, 30000, 1]):
```sql
SELECT count(j.id) FROM jobs j
WHERE j.status = ? AND (lower(j.title) LIKE ? OR lower(j.description) LIKE ? OR lower(j.requirements) LIKE ?)
AND lower(j.city) = ? AND lower(j.employment_type) = ? AND j.salary_max >= ? AND j.salary_min <= ? AND j.company_id = ?
```
计划摘要:
```
query_cost = 0.35
using_filesort = false
using_temporary_table = false
- 表 query_block: table_name="j", access_type="ALL", possible_keys=["idx_jobs_company_status","idx_jobs_status_created","idx_jobs_city_status","idx_jobs_status_employment_type","idx_jobs_status_salary_max"], rows_examined_per_scan=1, rows_produced_per_join=1, filtered="100.00"
```
<details><summary>EXPLAIN FORMAT=JSON 原文</summary>
```json
{
"query_block": {
"select_id": 1,
"cost_info": {
"query_cost": "0.35"
},
"table": {
"table_name": "j",
"access_type": "ALL",
"possible_keys": [
"idx_jobs_company_status",
"idx_jobs_status_created",
"idx_jobs_city_status",
"idx_jobs_status_employment_type",
"idx_jobs_status_salary_max"
],
"rows_examined_per_scan": 1,
"rows_produced_per_join": 1,
"filtered": "100.00",
"cost_info": {
"read_cost": "0.25",
"eval_cost": "0.10",
"prefix_cost": "0.35",
"data_read_per_join": "1K"
},
"used_columns": [
"id",
"company_id",
"title",
"description",
"requirements",
"city",
"employment_type",
"salary_min",
"salary_max",
"status"
],
"attached_condition": "((`mydreamjob_q01`.`j`.`company_id` = 1) and (`mydreamjob_q01`.`j`.`status` = 'PUBLISHED') and ((lower(`mydreamjob_q01`.`j`.`title`) like '%q01e75001岗位%') or (lower(`mydreamjob_q01`.`j`.`description`) like '%q01e75001岗位%') or (lower(`mydreamjob_q01`.`j`.`requirements`) like '%q01e75001岗位%')) and (lower(`mydreamjob_q01`.`j`.`city`) = 'q01e75001city20') and (lower(`mydreamjob_q01`.`j`.`employment_type`) = 'full_time') and (`mydreamjob_q01`.`j`.`salary_max` >= 5000) and (`mydreamjob_q01`.`j`.`salary_min` <= 30000))"
}
}
}
```
</details>
## 5. 结果规模 1 / 20 / 50 下的 SQL 语句条数
Spring Data 对首页有优化:首页返回行数小于 `pageSize` 时不再发 count 查询,
因此结果数 < pageSize 时为 1 条(仅数据查询),否则为 2 条(count + 数据查询)。
两种情况下语句条数都**不随结果规模增长**,即不存在 N+1。
| 匹配结果数 | 返回行数 | totalElements | Repository SQL数 | 完整Service SQL数 |
|---|---|---|---|---|
| 1 | 1 | 1 | 1 | 2 |
| 20 | 20 | 20 | 2 | 3 |
| 50 | 20 | 50 | 2 | 3 |

190
docs/q01-job-search-real-query-design.md

@ -0,0 +1,190 @@
# Q01 岗位搜索真实查询覆盖设计
## 1. 状态
设计已确认,待实施、专项测试、真实 MySQL 复验与独立验收。
## 2. 背景与问题
`JobSearchSpecification` 承担公开岗位搜索的核心过滤规则,但当前 `JobSearchServiceTest` 将
`JobRepository.findAll(Specification, Pageable)` Mock 掉后直接返回预设数据。这些测试可以证明
参数校验、分页对象和 DTO 组装,却不能证明 Criteria 谓词、SQL 过滤、排序和分页真的正确。
Q01 的目标是补齐真实数据库证据,不以测试数量替代查询行为覆盖,也不改变现有公网接口和业务语义。
## 3. 范围
### 3.1 必须覆盖
- `JobSearchSpecification.searchJobs(...)` 的真实 Repository 查询;
- `JobService.searchJobs(...)` 的 latest、relevance、salary 排序和分页;
- PUBLISHED 强制过滤;
- keyword 对 title、description、requirements 的匹配;
- city、employmentType、companyId;
- salaryMin、salaryMax 的区间重叠语义;
- H2 快速测试与 MySQL 8 行为一致性;
- 查询参数化和特殊字符边界;
- 真实 SQL/EXPLAIN 证据及数据副作用检查。
### 3.2 明确不做
- 不改变 `/jobs/search` 请求或响应;
- 不新增、删除或重命名公网接口;
- 不修改岗位状态机、岗位归属或角色权限;
- 不把 relevance 改造成新的全文检索算法;当前 relevance 与 latest 等价,本轮锁定该语义;
- 不接入 Elasticsearch、搜索引擎或缓存;
- 不新增 Flyway、表或索引;
- 不改 Candidate Scoring、推荐、排名、邀约、Offer、申诉、消息、简历和认证;
- 不删除现有 Mock 测试。现有测试继续承担 Service 编排与参数校验职责;
- 不进入 R01 性能和结构重构。
## 4. 当前查询契约
| 参数 | 既有语义 |
|---|---|
| status | 无论调用方传什么条件,只返回 `PUBLISHED` |
| keyword | trim 后忽略大小写,匹配 title、description、requirements 任一字段 |
| city | trim 后忽略大小写精确匹配;显式归一化以保证 H2/MySQL 一致 |
| employmentType | trim 后忽略大小写精确匹配;不做前缀匹配 |
| salaryMin | 岗位 `salaryMax >= salaryMin` |
| salaryMax | 岗位 `salaryMin <= salaryMax` |
| companyId | 精确匹配 |
| sort=latest/null | `createdAt DESC, id DESC` |
| sort=relevance | 当前与 latest 相同,不改变 |
| sort=salary | `salaryMax` 非空优先、降序;相同值按 `id DESC`;NULL 最后 |
| page | 从 0 开始,不得为负数 |
| size | 1–50 |
薪资过滤是区间重叠,不是“岗位上下限都完全落在用户区间内”。边界相等必须命中。
## 5. 测试分层
### 5.1 H2 Repository 快速层
新增 `JobSearchSpecificationIntegrationTest`:
- 使用 Spring Data JPA、真实 `JobRepository` 和真实 H2;
- 激活 `integration-test`,由真实 Flyway 建表;
- 不 Mock Repository、EntityManager、Specification、CriteriaBuilder 或事务;
- 每个用例创建最小隔离夹具,按精确主键清理;
- 直接执行 `jobRepository.findAll(spec, pageable)`;
- 断言返回 ID 集合与顺序,不能只断言数量。
至少覆盖:
1. title 关键词;
2. description 关键词;
3. requirements 关键词;
4. 关键词忽略大小写;
5. null、空字符串、纯空白关键词;
6. keyword 前后空格;
7. city 精确匹配与 trim;
8. employmentType 精确匹配与 trim;
9. companyId;
10. salaryMin;
11. salaryMax;
12. 两端薪资边界相等;
13. 薪资区间重叠;
14. 薪资区间不重叠;
15. 多条件 AND;
16. DRAFT、CLOSED 等非公开状态排除;
17. 无结果;
18. 排序与分页;
19. `%`、`_`、反斜杠、引号等特殊字符的实际既有语义;
20. null salary 字段的行为。
特殊字符测试首先锁定当前 Criteria `LIKE` 行为,不得在没有产品决策时把 `%` 和 `_` 自动改成字面量搜索。
### 5.2 Service 真实查询层
新增或扩展 `JobSearchServiceIntegrationTest`:
- 使用真实 `JobService`、`JobRepository`、`CompanyRepository`、EntityManager;
- 不 Mock `findAll`;
- latest/null/relevance 的排序结果一致且稳定;
- salary 排序验证 NULL LAST、降序、相同薪资按 ID 倒序;
- 验证 page/size、totalElements、totalPages;
- 验证企业信息批量组装,不改变 DTO;
- 保留现有非法 sort、非法分页、非法薪资范围的测试。
### 5.3 MySQL 真实层
优先新增 `JobSearchSpecificationMySqlIntegrationTest`,使用项目已有 Testcontainers 依赖:
- `@Testcontainers(disabledWithoutDocker = true)` 仅允许本地快速执行时环境性跳过;
- 独立验收和最终冻结时 Docker/MySQL 核心用例不得 SKIP;
- 使用 MySQL 8.x 容器、正式 Flyway 和 `ddl-auto=validate`;
- 使用动态属性注入 JDBC URL、用户名和密码;
- 不打印数据库凭据;
- 至少运行一组与 H2 完全相同的参数化用例,并对返回 ID 与顺序逐项对账;
- 重点对账大小写、LIKE 特殊字符、NULL 排序、边界比较、分页稳定性。
如果仓库约定不允许 Testcontainers 进入普通 `mvn test`,可以将其放入独立 profile;但最终报告必须给出真实执行命令和非 SKIP 结果,不能只提交一个从未运行的测试类。
## 6. 夹具设计
使用明确区分的岗位:
- 三个包含关键词的岗位,关键词分别只存在于 title、description、requirements;
- 相同城市和不同城市;
- FULL_TIME、PART_TIME 等不同 employmentType;
- 至少两个企业;
- 薪资区间覆盖边界相等、部分重叠、不重叠、NULL;
- 相同 createdAt 的两个岗位,用 ID 验证稳定排序;
- 相同 salaryMax 的两个岗位,用 ID 验证稳定排序;
- PUBLISHED、DRAFT、CLOSED 等状态。
夹具必须以测试生成的唯一标识隔离,不能依赖 V1_1 演示数据或真实业务数据。
## 7. 数据库与副作用
- 查询测试不得修改岗位状态、企业、用户或其他业务表;
- 查询前后对相关稳定业务字段做行级快照,而不是只比较 COUNT;
- Testcontainers 由测试生命周期销毁;
- 使用负责人现有 MySQL 做独立黑盒时,只能精确清理本轮夹具;
- 不允许清库、重置自增、删除 volume;
- 不新增 Flyway 或索引。发现执行计划问题只记录到 R01,不在 Q01 修复。
## 8. SQL 与 EXPLAIN
真实 MySQL 验收需记录:
- 组合过滤 SQL;
- latest 分页;
- salary 排序;
- count 查询;
- 使用索引、access type、estimated rows、filesort/temporary 情况;
- 1、20、50 条结果规模下查询次数。
本阶段以“语义正确且无明显发布阻断”为通过条件,不为了 EXPLAIN 修改索引。
## 9. OpenAPI 与兼容性
- 不修改 path 和 operation;
- 目标仍为 176 paths / 214 operations;
- 不改变参数名、默认值、响应 DTO 或错误码;
- `docs/openapi-rc.json` 不应因仅新增测试而变化;
- 必须运行 `docs/verify_openapi.py` 防止意外契约漂移。
## 10. 通过条件
只有同时满足以下条件才能通过 Q01:
1. `JobSearchSpecification` 不再是测试零引用;
2. H2 真实 Repository 测试覆盖全部过滤语义;
3. Service 自定义 salary 排序有真实数据库证据;
4. MySQL 核心用例真实运行且无 SKIP;
5. H2 与 MySQL 返回 ID、顺序和边界语义一致,或明确记录并解决差异;
6. 非公开岗位绝不进入公开搜索;
7. 分页稳定、total 正确;
8. 无业务数据副作用;
9. 原 Mock 测试保留且准确标注为编排测试;
10. 全量测试和 clean verify 通过;
11. OpenAPI 保持 176/214;
12. 无新增 Flyway、ErrorCode 或公网接口。
## 11. 最终状态
实施完成但尚未独立复验时,只能写:
> Q01 已完成真实查询测试实现与自动化验证,待独立代码复验及真实 MySQL/Docker 验收。

265
docs/q01-job-search-real-query-implementation-instruction.md

@ -0,0 +1,265 @@
# Q01 岗位搜索真实查询覆盖实施指令
## 一、任务边界
项目根目录:`C:\Users\15642\Desktop\zhilai`
目标分支:`test/q01-job-search-specification`
本轮只补岗位搜索真实数据库测试和验收证据。原则上不修改生产代码;如果测试复现生产缺陷,先保存失败证据并停止,未经负责人确认不得顺手修复。
禁止修改岗位接口、DTO、权限、状态机和搜索语义;禁止新增 Flyway、表、索引、ErrorCode 或公网接口;禁止进入 R01;禁止删除现有 Mock 测试;禁止 `git add -A`、commit、push、reset、restore、checkout、clean;禁止删除或移动历史未跟踪文件。
## 二、开始前核查
执行:
```powershell
cd C:\Users\15642\Desktop\zhilai
git branch --show-current
git status --short
git diff --cached --name-only
git log -5 --oneline
git diff --check
git status --short -- mydreamjob-server/src/main/resources/db/migration
```
确认分支正确、暂存区为空、HEAD 为 S03 检查点 `cb3bc57` 或报告实际偏差、历史未跟踪文件保留、V1–V43 均未修改。搜索并完整阅读适用 `AGENTS.md`;当前基线未发现时记录“未发现”。
## 三、必须阅读
```text
docs/q01-job-search-real-query-design.md
mydreamjob-server/src/main/java/com/mydreamjob/job/Job.java
mydreamjob-server/src/main/java/com/mydreamjob/job/JobRepository.java
mydreamjob-server/src/main/java/com/mydreamjob/job/JobSearchSpecification.java
mydreamjob-server/src/main/java/com/mydreamjob/job/JobService.java
mydreamjob-server/src/main/java/com/mydreamjob/job/JobController.java
mydreamjob-server/src/test/java/com/mydreamjob/job/JobSearchServiceTest.java
mydreamjob-server/src/test/resources/application-test.yml
mydreamjob-server/src/test/resources/application-integration-test.yml
mydreamjob-server/pom.xml
mydreamjob-server/src/main/resources/db/migration/V2__business_schema.sql
mydreamjob-server/src/main/resources/db/migration/V22__job_search_indexes.sql
```
执行:
```powershell
rg -n "JobSearchSpecification|findAll\(.*Specification|findWithSalarySort|findPublishedJobs" mydreamjob-server/src
```
记录哪些现有测试 Mock 了查询层,不能把它们描述成真实过滤覆盖。
## 四、先写 H2 真实 Repository 测试
新增:
```text
mydreamjob-server/src/test/java/com/mydreamjob/job/JobSearchSpecificationIntegrationTest.java
```
要求:
- 激活 `integration-test`;
- 使用真实 Flyway、H2、JobRepository;
- 不 Mock Repository、Specification、EntityManager 或事务;
- 直接调用 `jobRepository.findAll(JobSearchSpecification.searchJobs(...), pageable)`;
- 每项断言返回 ID 和顺序;
- 测试隔离,不依赖种子岗位;
- 不用顺序无关的 containsOnly 代替排序断言。
至少实现以下测试:
```text
keywordMatchesTitle
keywordMatchesDescription
keywordMatchesRequirements
keywordIsCaseInsensitive
nullBlankAndWhitespaceKeywordDoNotFilter
keywordIsTrimmed
cityUsesTrimmedExactMatch
cityCaseVariantHasSameResultAcrossDatabases
employmentTypeUsesTrimmedExactMatch
employmentTypeCaseVariantHasSameResultAcrossDatabases
companyIdFiltersExactly
salaryMinMatchesWhenJobMaximumEqualsBoundary
salaryMaxMatchesWhenJobMinimumEqualsBoundary
salaryRangesUseOverlapSemantics
nonOverlappingSalaryRangeIsExcluded
multipleConditionsUseAndSemantics
nonPublishedJobsAreAlwaysExcluded
specialLikeCharactersFollowExistingContract
nullSalaryFieldsFollowExistingContract
paginationReturnsStableOrderedIds
noResultReturnsEmptyPage
```
不要为了让测试符合预期而预先修改 `JobSearchSpecification`。如果任何既有语义与设计不一致,保留失败测试并报告。
## 五、补 Service 真实查询测试
新增:
```text
mydreamjob-server/src/test/java/com/mydreamjob/job/JobSearchServiceIntegrationTest.java
```
使用真实 JobService、JobRepository、CompanyRepository 和 EntityManager,覆盖:
```text
latestSortUsesCreatedAtThenIdDescending
nullSortUsesLatestOrder
relevanceCurrentlyUsesLatestOrder
salarySortUsesMaximumDescendingAndNullLast
salarySortUsesIdDescendingAsTieBreaker
salarySortReturnsCorrectTotalAndTotalPages
secondPageDoesNotRepeatOrLoseRows
companyInformationIsMappedFromRealRepository
searchDoesNotReturnDraftOrClosedJobs
```
原 `JobSearchServiceTest` 保留,用于参数校验和编排;如需修改注释,只能明确它不是 Specification SQL 覆盖,不得删除或放宽断言。
## 六、补 MySQL 真实测试
新增建议文件:
```text
mydreamjob-server/src/test/java/com/mydreamjob/job/JobSearchSpecificationMySqlIntegrationTest.java
```
使用已有 `org.testcontainers:mysql` 和 `junit-jupiter`:
- MySQL 8.x;
- 正式 Flyway;
- Hibernate `ddl-auto=validate`;
- `@DynamicPropertySource` 注入连接参数;
- 不打印密码;
- 不复用开发数据库;
- 不 Mock 查询路径;
- 容器用例至少对账 keyword 大小写、特殊字符、城市、薪资边界、非公开过滤、latest、salary NULL LAST、分页。
可复用参数化场景或夹具构造器,确保 H2/MySQL 输入一致。必须逐项断言结果 ID/业务标识和顺序,而不是只断言数量。
如果普通开发环境 Docker 不可用,可以让 Testcontainers 测试环境性跳过;但最终独立验收必须显式运行该类并得到 Tests run > 0、Skipped=0。不得用 H2 结果代替 MySQL。
## 七、查询副作用与执行计划
新增测试或独立验收证据证明查询前后以下数据不变:
- jobs 稳定业务字段;
- companies;
- applications;
- candidate_dimension_scores;
- notifications;
- messages;
- audit_logs。
使用行级稳定字段快照,不能只比较 COUNT、SUM 或 CRC32。
真实 MySQL 验收对组合过滤、latest、salary 和 count SQL 执行 `EXPLAIN FORMAT=JSON`,记录使用索引、access type、estimated rows、temporary/filesort。Q01 不新增索引;发现性能问题记录给 R01。
## 八、文档
更新:
```text
docs/backend-development-roadmap.md
docs/phase1-scope-and-acceptance.md
```
只记录 Q01 的测试层级和真实结果,不改变接口文档。原则上不修改 `docs/openapi-rc.json`;若仅新增测试却产生 OpenAPI 差异,视为异常并停止调查。
新增最终实施报告建议:
```text
docs/q01-job-search-real-query-implementation-report.md
```
不得写“真实 MySQL 已通过”,除非 Testcontainers 或独立 MySQL 实际运行且核心项无 SKIP。
## 九、验证顺序
先运行 H2 测试:
```powershell
cd C:\Users\15642\Desktop\zhilai\mydreamjob-server
mvn -Dtest="JobSearchSpecificationIntegrationTest,JobSearchServiceIntegrationTest" test
```
Docker 可用时运行 MySQL 测试:
```powershell
mvn -Dtest="JobSearchSpecificationMySqlIntegrationTest" test
```
运行岗位关联回归:
```powershell
mvn -Dtest="*JobSearch*Test,*Job*Test,*CompanyPublic*Test" test
```
随后:
```powershell
mvn test
mvn clean verify
python ..\docs\verify_openapi.py
```
最后:
```powershell
cd C:\Users\15642\Desktop\zhilai
git diff --check
git status --short
git diff --cached --name-only
git status --short -- mydreamjob-server/src/main/resources/db/migration
```
要求所有必跑测试 Failures=0、Errors=0、Skipped=0,clean verify 成功,OpenAPI 保持 176/214,暂存区为空,V1–V43 未修改。
## 十、发现缺陷时
如果真实测试发现生产缺陷:
1. 保留最小失败测试;
2. 记录 H2 与 MySQL 是否一致;
3. 精确写明参数、夹具、预期 ID、实际 ID/顺序和生成 SQL;
4. 不自行修改生产代码;
5. 状态标记为 FAIL,等待负责人下发定向整改指令。
禁止通过修改夹具、删除断言、Mock Repository、禁用测试或放宽为只断言数量来制造绿色。
## 十一、最终报告
必须包含:
1. 分支与 HEAD;
2. 现有 Mock 测试的真实职责;
3. H2 测试数及结果;
4. MySQL 测试数及结果;
5. 每个过滤条件的实际结果;
6. keyword 三字段、大小写和特殊字符结果;
7. 薪资边界与区间重叠结果;
8. PUBLISHED 强制过滤结果;
9. latest/relevance/salary 排序结果;
10. 分页与 total 结果;
11. H2/MySQL 差异;
12. EXPLAIN 摘要;
13. 数据副作用结果;
14. 关联和全量测试数;
15. Failures/Errors/Skipped;
16. clean verify;
17. OpenAPI paths/operations;
18. 精确修改文件;
19. 是否修改生产代码;
20. 是否新增 Flyway、ErrorCode 或接口;
21. Git 状态;
22. 明确未暂存、未提交、未推送;
23. 是否存在待整改缺陷。
实现和自动化通过后只能写:
> Q01 已完成真实查询测试实现与自动化验证,待独立代码复验及真实 MySQL/Docker 验收。

265
docs/q01-job-search-real-query-implementation-report.md

@ -0,0 +1,265 @@
# Q01 岗位搜索真实查询覆盖实施报告
> **状态**:Q01 已完成真实查询测试实现、跨库一致性整改及独立复验,可以进入精确暂存检查点。
## 1. 分支与 HEAD
| 项目 | 值 |
|---|---|
| 分支 | `test/q01-job-search-specification` |
| HEAD | `cb3bc57fab549137e3785f5d50bde86ce8ef5731`(S03 检查点 `cb3bc57`,无偏差) |
| 暂存区 | 空(`git diff --cached --name-only` 无输出) |
| 生产代码 | 定向修改 `JobSearchSpecification`:对 city/employmentType 显式 `lower`,消除 H2/MySQL 排序规则差异,并保持线上 MySQL 既有忽略大小写结果 |
| V1–V43 迁移 | 未修改(`git status --short -- .../db/migration` 无输出) |
`AGENTS.md` 核查:仓库根目录、`mydreamjob-server/` 及其子目录**均未发现** `AGENTS.md`。
## 2. 现有 Mock 测试的真实职责
| 测试类 | 测试数 | 真实职责 | 不具备的能力 |
|---|---|---|---|
| `JobSearchServiceTest`(保留,未删除、未放宽断言) | 37 | 用 Mockito 把 `JobRepository.findAll(Specification, Pageable)` 与 `CompanyRepository` 整体 Mock 掉,验证参数校验(非法 sort / 非法分页 / 非法薪资范围)、分页对象、排序参数传递、企业信息组装与 DTO 编排 | **不能**证明 Criteria 谓词、SQL 过滤、排序与分页在真实数据库上的行为 |
| `JobServiceTest`(保留) | 13 | 岗位 CRUD、状态机与归属校验的编排 | 同上,与查询语义无关 |
本轮为该 Mock 测试补充了类级 Javadoc,明确标注它是**编排测试**而非 Specification SQL 覆盖(仅注释,未改动任何断言)。Q01 之前 `JobSearchSpecification` 在生产代码外几乎零引用(仅被 Mock 测试当作 `Specification` 类型占位);本轮起被 4 个测试类**直接调用**其 `searchJobs(...)`:`JobSearchSpecificationIntegrationTest`、`JobSearchSpecificationMySqlIntegrationTest`、`JobSearchQueryExplainMySqlIntegrationTest`,以及副作用用例基类 `JobSearchQuerySideEffectTestBase`(H2 与 MySQL 两个子类共用)。
## 3. 测试分层与结果
| 层次 | 测试类 | 数据库 | Tests run | Failures | Errors | Skipped |
|---|---|---|---|---|---|---|
| H2 真实 Repository | `JobSearchSpecificationIntegrationTest` | H2(真实 Flyway,`integration-test`) | 21 | 0 | 0 | 0 |
| H2 真实 Service/EntityManager | `JobSearchServiceIntegrationTest` | H2(真实 Flyway) | 9 | 0 | 0 | 0 |
| H2 查询副作用 | `JobSearchQuerySideEffectIntegrationTest` | H2(真实 Flyway) | 4 | 0 | 0 | 0 |
| **MySQL 真实层** | `JobSearchSpecificationMySqlIntegrationTest` | **真实 MySQL 8.4(Testcontainers)** | 15 | 0 | 0 | 0 |
| **MySQL 查询副作用** | `JobSearchQuerySideEffectMySqlIntegrationTest` | **真实 MySQL 8.4(Testcontainers)** | 4 | 0 | 0 | 0 |
| **MySQL 执行计划** | `JobSearchQueryExplainMySqlIntegrationTest` | **真实 MySQL 8.4(Testcontainers)** | 1 | 0 | 0 | 0 |
| 合计(新增 Q01 测试) | | | **54** | **0** | **0** | **0** |
MySQL 核心用例**真实执行**(Docker 可用,容器实启),`Skipped = 0`,未出现环境性跳过。
执行命令(原样):
```powershell
cd C:\Users\15642\Desktop\zhilai\mydreamjob-server
mvn -Dtest="JobSearchSpecificationMySqlIntegrationTest" test
```
定向复验输出:`Tests run: 15, Failures: 0, Errors: 0, Skipped: 0` / `BUILD SUCCESS`。
> 首次按负责人指令执行该命令时为 `Tests run: 9, Failures: 0, Errors: 0, Skipped: 0`。随后为达成「与 H2 逐项对账」的完整性,补齐了 MySQL 侧原先缺失的 4 个 H2 已覆盖场景(空白关键词、关键词 trim、多条件 AND、NULL 薪资契约),并在独立复核后增加 city、employmentType 大小写变体 2 个跨库契约用例;该命令现为 **15 条**,仍为 `Failures=0 / Errors=0 / Skipped=0`。
数据库配置:`mysql:8.4` 独立容器(`mydreamjob_q01`),执行**正式 Flyway 迁移**并以 `ddl-auto=validate` 启动;JDBC URL / 用户名 / 密码由 `@DynamicPropertySource` 运行期注入,不落盘、不打印。应用级测试开关与测试密钥由测试专用 profile `mysql-it` 提供(`src/test/resources/application-mysql-it.yml`),方言沿用主配置的 `MySQLDialect`——H2 与 MySQL 用例因此跑在**同一套应用配置**上,仅数据库实现不同,便于逐项对账。
## 4. 每个过滤条件的实际结果
| 条件 | 契约 | 真实库实测结论(H2 与 MySQL 一致) |
|---|---|---|
| `status` | 无论传入什么,只返回 `PUBLISHED` | DRAFT、CLOSED 在任何组合、任何排序下都不出现;显式按 `companyId` 过滤时同样不出现 |
| `keyword` | trim 后忽略大小写,匹配 title / description / requirements 任一字段 | 三字段分别命中;`java` / `JaVa` / ` Java ` 结果相同 |
| `city` | trim 后忽略大小写精确匹配 | `Q01-Beijing`、`q01-beijing` 和带空格的小写查询命中同一岗位;与 `city + "别处"` 区分开 |
| `employmentType` | trim 后忽略大小写精确匹配 | `FULL_TIME`、`full_time` 和带空格的小写查询命中同一岗位;`PART_TIME`、`FULL` 不命中 |
| `companyId` | 精确匹配 | 只返回本企业岗位 |
| `salaryMin` | 岗位 `salaryMax >= salaryMin`(单边) | 边界相等命中;`salaryMax` 更高者也命中;`salaryMax` 低于边界者排除 |
| `salaryMax` | 岗位 `salaryMin <= salaryMax`(单边) | 边界相等命中;`salaryMin` 更低者也命中;`salaryMin` 高于边界者排除 |
| 两端同时给出 | 区间重叠 | 部分重叠命中、被用户区间包含命中;高于或低于用户区间均排除 |
| 多条件 | AND | 关键词/城市/用工类型/企业/薪资必须同时满足 |
| 无结果 | 空页 | `content` 为空、`totalElements = 0` |
## 5. keyword 三字段、大小写与特殊字符
- **title / description / requirements 三字段**:三个只在一个字段含关键词的岗位都能被单独命中,未含关键词的岗位不命中。
- **大小写**:`lower(title|description|requirements) LIKE lower(pattern)`,`java`、`JAVA`、`JaVa` 命中同一组岗位(MySQL 侧还叠加列排序规则,结论一致)。
- **trim**:`" Java "` 与 `"Java"` 结果一致。
- **空值**:`null`、`""`、`" "`、`" "`、`"\t"` 均**不参与过滤**(`isBlank()` 判定),返回该城市下全部岗位。
- **LIKE 特殊字符**(锁定既有契约,**未做转义改动**):
- `%` 作为通配符,命中该城市下全部带标题的岗位;
- `_` 作为单字符通配符,同样命中全部;
- `A_B` 以通配符解释,命中 `A_B岗` 本身。
- H2 与 MySQL 行为**一致**。这是既有语义,产品未决策前不改为字面量搜索。
## 6. 薪资边界与区间重叠
H2 与 MySQL 逐项一致:
| 用例 | 用户条件 | 期望与实测 |
|---|---|---|
| `salaryMinMatchesWhenJobMaximumEqualsBoundary` | `salaryMin=20000` | 仅 `[10000,19999]` 被排除,`[10000,20000]` 命中(边界相等命中) |
| `salaryMaxMatchesWhenJobMinimumEqualsBoundary` | `salaryMax=10000` | 仅 `[10001,20000]` 被排除,`[10000,20000]` 命中 |
| 部分重叠 | `[15000,25000]` | `[10000,20000]`、`[12000,30000]` 命中 |
| 不重叠 | `[30000,35000]`、`[1000,4000]` | 空 |
| NULL 薪资 | 不加薪资条件 | `[null,null]`、`[10000,null]` 照常返回 |
NULL 薪资的既有契约(SQL 三值逻辑):一旦给出 `salaryMin`,`job.salaryMax >= ?` 对 `salaryMax IS NULL` 求值为 UNKNOWN,因此 `salaryMax` 为 NULL 的岗位被排除。Q01 **锁定**该行为,不做修改。
## 7. PUBLISHED 强制过滤
无论是否传其它条件、无论 `sort` 取 `latest` / `relevance` / `salary` / `null`,DRAFT 与 CLOSED 岗位**绝不出现**;即使显式传入 DRAFT 岗位所属的 `companyId` 也不出现。H2 与 MySQL 一致。
## 8. latest / relevance / salary 排序
| 排序 | 语义 | 实测 |
|---|---|---|
| `latest` / `null` | `createdAt DESC, id DESC` | 按 `created_at` 倒序;`created_at` 相同时按 `id DESC`;`null` 与 `latest` 结果完全一致 |
| `relevance` | 当前与 `latest` 等价(本轮锁定,不改造成新算法) | 与 `latest` 结果完全一致 |
| `salary` | `salaryMax` 非空优先降序;相同值按 `id DESC`;NULL 最后 | `CASE WHEN salary_max IS NULL THEN 1 ELSE 0 END ASC, salary_max DESC, id DESC` 生效,NULL 排在最后;相同 `salaryMax` 按 `id DESC` |
`salary` 排序走 `JobService.findWithSalarySort` 的 CriteriaQuery + CASE WHEN 路径(真实 `EntityManager`),在 H2 与真实 MySQL 上均通过。
## 9. 分页与 total
- 5 条数据、`size=2` 时:`totalElements = 5`、`totalPages = 3`;第 0 页与第 1 页 ID 顺序与期望逐项一致;三页拼接等于完整期望顺序,**无重复、无遗漏**。
- 无结果时 `totalElements = 0`。
- H2 与 MySQL 一致。
## 10. H2 与 MySQL 差异
初次复验发现 MySQL `utf8mb4_unicode_ci` 与 H2 默认字符串比较规则可能造成 city、employmentType 大小写差异。现已在 Specification 中显式对字段和值使用 `lower`,并在两库增加同名大小写变体测试。两个库在 keyword、city、employmentType、特殊字符、薪资边界、PUBLISHED 过滤、排序、分页和 total 上结果现已逐项一致。
过程中在 **MySQL 用例内部**发现并修正了 3 处**用例自身的期望错误**(不是生产缺陷,见第 13 节):
1. 只给 `city` 条件时,用例原本期望同城但 `PART_TIME` 的岗位不出现——契约只规定城市精确匹配,返回它是正确的;
2. `salaryMin=20000` 时用例原本期望只返回边界岗位,但契约是**单边**比较,`salaryMax=60000 >= 20000` 的岗位同样应命中;
3. `salaryMax=10000` 时同理,`salaryMin=5000 <= 10000` 的岗位同样应命中。
修正方式是把期望改为与设计文档第 4 节契约表一致,并**加强**而非放宽断言(补齐两条「区间完全在用户区间之外」的反向断言、补 `doesNotContain` 前缀城市断言)。H2 用例的同名场景夹具本就设计正确(相邻岗位取「刚好越界」值),因此未受影响。
## 11. EXPLAIN 摘要
真实 MySQL 8.4 上以 `EXPLAIN FORMAT=JSON` 采集,等价 SQL 与 Criteria 路径**绑定同一组参数**,并先断言二者返回**完全相同的 ID 与顺序**(保证 EXPLAIN 的不是一份不存在的语句)。完整原文见 [Q01 MySQL 执行计划证据](q01-job-search-mysql-explain-evidence.md)。
| 语句 | query_cost | access_type | 可用索引 | 实际选用 key | rows_examined_per_scan | using_filesort | using_temporary_table |
|---|---|---|---|---|---|---|---|
| 组合过滤 + latest 分页 | 1.35 | `ALL` | `idx_jobs_company_status`、`idx_jobs_status_created`、`idx_jobs_city_status`、`idx_jobs_status_employment_type`、`idx_jobs_status_salary_max` | 未选用 | 1 | true | false |
| latest 分页 | 1.35 | `ALL` | 同上(除 `idx_jobs_company_status`) | 未选用 | 1 | true | false |
| salary 排序(CASE WHEN) | 1.35 | `ALL` | 同上 | 未选用 | 1 | true | false |
| count 查询 | 0.35 | `ALL` | 同上 | 未选用 | 1 | false | false |
结果规模与 SQL 语句条数(page size = 20):
| 匹配结果数 | 返回行数 | totalElements | Repository SQL数 | 完整Service SQL数 |
|---|---|---|---|---|
| 1 | 1 | 1 | 1 | 2 |
| 20 | 20 | 20 | 2 | 3 |
| 50 | 20 | 50 | 2 | 3 |
Spring Data 对首页有优化:首页返回行数小于 `pageSize` 时不再发 count 查询,因此 Repository 路径为 1/2/2 条。完整 Service 路径固定增加一次企业批量查询,为 2/3/3 条;两者均不随结果数量线性增长,不存在逐岗位查询企业的 N+1。
**结论与边界**:Q01 夹具刻意做小(每个城市 1 / 20 / 50 条),InnoDB 统计信息在此规模下估算行数为 1、优化器选择全表扫描并做 filesort,**这不构成生产规模的性能结论**。按 Q01 边界不新增、不调整索引;`%keyword%` 前导通配符本身无法走 B-Tree 索引、以及大规模数据下的 `ORDER BY` filesort 成本,记录给 **R01**。
## 12. 数据副作用结果
用例在 jobs、companies、users、applications、candidate_dimension_scores、notifications、conversations、messages、audit_logs **九张表**上做**行级稳定字段快照**(按主键排序、逐行取显式列出的稳定业务字段,不使用 `SELECT *`、不含 `password_hash` 等凭据列),执行覆盖「全部过滤条件 × 4 种排序 × 分页 × 无结果 × 聚合筛选项」的完整搜索矩阵后再次取快照,逐表逐行逐字段比较:
- **H2**:前后完全一致;
- **真实 MySQL**:前后完全一致。
仅比较 COUNT / SUM / CRC32 无法证明「没有某一行被改写」,故另设**反证测试**(H2 与 MySQL 各跑一遍):
| 反证用例 | 人为动作 | 要求 |
|---|---|---|
| `snapshotReportsRowFieldUpdate` | 直接改写 `jobs.status` | 必须被发现;把字段与 `updated_at` 一并还原后必须重新完全一致(证明差异定位精确而非「永远不等」) |
| `snapshotReportsInsertedRow` | 插入一行 notification | 必须被发现 |
| `snapshotReportsDeletedRow` | 删除一行 notification | 必须被发现 |
反证全部通过,说明主证据的「无副作用」结论不是因为比较失效。测试同时断言九张受检表在测试库中**确有行**,避免空快照导致的假通过。
## 13. 缺陷与判定
定向复验发现测试环境与线上 MySQL 的大小写比较可能不一致。为锁定线上已有的忽略大小写结果并保证跨库一致,生产代码仅修改 `JobSearchSpecification`,对 city 和 employmentType 显式执行 Locale.ROOT 小写归一化;接口、DTO、权限、状态机和 MySQL 既有返回语义均未改变。`JobService`、`JobRepository`、`JobController` 与 `ErrorCode` 保持原样。
过程中处理的 2 类**非生产缺陷**问题,均已记录:
1. **MySQL 用例无法启动上下文**:该类最初未激活任何 profile,导致 `app.jwt.*` 测试密钥缺失、`AppJwtProperties` 校验失败(`JWT_ACCESS_SECRET must be at least 32 characters`)。这是**用例配置缺失**,不是生产缺陷(生产要求 JWT 密钥必须显式配置,行为正确)。修复方式是新增测试专用 profile `mysql-it`,并保持连接参数由 `@DynamicPropertySource` 注入。
2. **MySQL 用例期望与契约表自相矛盾**:见第 10 节的 3 处,已按契约修正并加强断言。
**无待整改生产缺陷。** 上述跨库一致性问题已定向解决并有 H2/MySQL 同名用例证明。
## 14. 关联与全量测试
| 项目 | 命令 | 结果 |
|---|---|---|
| 岗位关联回归 | `mvn -Dtest="*JobSearch*Test,*Job*Test,*CompanyPublic*Test" test` | **Tests run: 225, Failures: 0, Errors: 0, Skipped: 0** |
| 全量测试 | `mvn test` | **Tests run: 1890, Failures: 0, Errors: 0, Skipped: 0** |
| 干净构建 | `mvn clean verify` | **Tests run: 1890, Failures: 0, Errors: 0, Skipped: 0**,`BUILD SUCCESS`,产出 `mydreamjob-server-0.0.1-SNAPSHOT.jar` |
关联回归中与 Q01 直接相关的类:`JobSearchSpecificationIntegrationTest` 21、`JobSearchServiceIntegrationTest` 9、`JobSearchSpecificationMySqlIntegrationTest` 15、`JobSearchQuerySideEffectIntegrationTest` 4、`JobSearchQuerySideEffectMySqlIntegrationTest` 4、`JobSearchQueryExplainMySqlIntegrationTest` 1、`JobSearchServiceTest` 37(保留)、`JobServiceTest` 13、`JobFavoriteServiceTest` 10、`CompanyPublicServiceTest` 22。
## 15. Failures / Errors / Skipped 与 clean verify
| 项目 | Tests run | Failures | Errors | Skipped | 构建 |
|---|---|---|---|---|---|
| `mvn test` | **1890** | **0** | **0** | **0** | `BUILD SUCCESS` |
| `mvn clean verify` | **1890** | **0** | **0** | **0** | `BUILD SUCCESS`(含打包,产出 `mydreamjob-server-0.0.1-SNAPSHOT.jar`) |
所有必跑测试 `Failures = 0`、`Errors = 0`、`Skipped = 0`。日志中不存在任何 `Skipped: <非零>` 的测试类。三个 MySQL Testcontainers 用例类在 `mvn clean verify` 中**真实启动容器并执行**(`JobSearchSpecificationMySqlIntegrationTest` 15 条 / `JobSearchQuerySideEffectMySqlIntegrationTest` 4 条 / `JobSearchQueryExplainMySqlIntegrationTest` 1 条),**未发生跳过**。
## 16. OpenAPI
```
python docs/verify_openapi.py
paths: 176
operations: 214
All checks passed!
```
`docs/openapi-rc.json` **未被修改**(`git status --short -- docs/openapi-rc.json` 无输出)。Q01 只新增测试,未新增、未删除、未重命名任何 path 或 operation,未改变参数名、默认值、响应 DTO 或错误码。
## 17. 精确修改文件
**新增(Q01 测试与证据)**
```text
mydreamjob-server/src/test/java/com/mydreamjob/job/JobSearchSpecificationIntegrationTest.java
mydreamjob-server/src/test/java/com/mydreamjob/job/JobSearchServiceIntegrationTest.java
mydreamjob-server/src/test/java/com/mydreamjob/job/JobSearchSpecificationMySqlIntegrationTest.java
mydreamjob-server/src/test/java/com/mydreamjob/job/JobSearchQuerySideEffectTestBase.java
mydreamjob-server/src/test/java/com/mydreamjob/job/JobSearchQuerySideEffectIntegrationTest.java
mydreamjob-server/src/test/java/com/mydreamjob/job/JobSearchQuerySideEffectMySqlIntegrationTest.java
mydreamjob-server/src/test/java/com/mydreamjob/job/BusinessRowSnapshot.java
mydreamjob-server/src/test/java/com/mydreamjob/job/JobSearchQueryExplainMySqlIntegrationTest.java
mydreamjob-server/src/test/java/com/mydreamjob/job/Q01SqlRecorder.java
mydreamjob-server/src/test/resources/application-mysql-it.yml
docs/q01-job-search-real-query-design.md
docs/q01-job-search-real-query-implementation-instruction.md
docs/q01-job-search-real-query-implementation-report.md
docs/q01-job-search-mysql-explain-evidence.md
```
**修改(1 个定向生产修复、测试注释与文档)**
```text
mydreamjob-server/src/main/java/com/mydreamjob/job/JobSearchSpecification.java # city/employmentType 跨库大小写语义显式统一
mydreamjob-server/src/test/java/com/mydreamjob/job/JobSearchServiceTest.java # 仅补类级 Javadoc,标注为编排测试;断言未改动
docs/backend-development-roadmap.md
docs/phase1-scope-and-acceptance.md
```
## 18. 是否修改生产代码 / 新增迁移或接口
- **生产代码:定向修改 1 个文件。** `JobSearchSpecification` 对 city 和 employmentType 显式使用 `lower` + `Locale.ROOT`,保持线上 MySQL 既有忽略大小写结果并消除 H2 差异;`JobService`、`JobRepository`、`JobController`、DTO、状态机与权限未改。
- **Flyway:未新增。** 最新迁移仍为 V43;V1–V43 全部未修改。
- **ErrorCode:未新增。**
- **公网接口:未新增、未删除、未重命名。** OpenAPI 保持 176 paths / 214 operations。
- 未接入 Elasticsearch、缓存或搜索引擎;未进入 R01。
## 19. Git 状态
```
git branch --show-current -> test/q01-job-search-specification
git diff --cached --name-only -> (空)
git status --short -- mydreamjob-server/src/main -> M JobSearchSpecification.java(Q01 定向修复)
git status --short -- mydreamjob-server/src/main/resources/db/migration -> (空)
git status --short -- docs/openapi-rc.json -> (空)
```
检查点执行前,本分支未暂存、未提交、未推送;未执行 `git add -A`、reset、restore、checkout、clean;未删除或移动任何历史未跟踪文件。Q01 改动由 1 个定向生产修复、真实数据库测试与正式证据文档组成。
## 20. 待整改事项
**Q01 范围内无待整改缺陷。**
移交给 **R01**(不在 Q01 修复):
- `keyword` 使用 `%keyword%` 前导通配符,B-Tree 索引无法用于该谓词;
- 小样本夹具下优化器选择全表扫描 + filesort,真实数据规模下的执行计划与排序成本需在 R01 以真实量级数据评估;
- `JobService.findWithSalarySort` 的 `CASE WHEN` 排序无法利用 `idx_jobs_status_salary_max` 的有序性,需要 R01 评估替代方案。

110
docs/r01-a-request-context-implementation-instruction.md

@ -0,0 +1,110 @@
# R01-A 请求身份与 requestId 收束实施指令
## 一、任务性质
本轮只完成 R01-A 第一批,不进入异常映射、评分规则、百分位、消息未读、PersonalDataCollector 或推荐权重重构。
允许修改公共请求上下文组件、最多 8 个 Controller 及其测试和文档;禁止新增接口、ErrorCode、Flyway、表、索引或依赖。
## 二、开始前核查
在 `C:\Users\15642\Desktop\zhilai` 执行并记录:
```powershell
git branch --show-current
git status --short
git diff --cached --name-only
git log -5 --oneline
git diff --check
```
分支必须为 `refactor/r01-structure-performance`,HEAD 必须包含 Q01 检查点 `77dc624`,暂存区必须为空。完整搜索适用的 AGENTS.md;保护全部历史未跟踪文件。
## 三、先写基线清单
列出所有私有 `resolveUserId/userId/requireAdmin` 与 `requestId/getRequestId` 方法,记录:文件、公开端点、当前异常、角色检查位置、资源归属检查位置、requestId 来源。
第一批只迁移以下 8 个 Controller:
1. `AssessmentController`
2. `AbilityController`
3. `ProfileController`
4. `ResumeVersionController`
5. `PreferenceController`
6. `CompetitionRankingController`
7. `PersonalDataExportController`
8. `PrivacySettingsController`
若其中存在与未提交修改重叠,停止并报告,不得改换到其它文件凑数。
## 四、身份组件
复用现有 `CurrentUserAccessor`,不要创建第二个身份解析器。Controller 通过构造器注入并调用 `requireUserId()`。
必须保持:
- null Authentication、AnonymousAuthenticationToken、`anonymousUser`、未认证、空白主体、Unicode 数字、带正负号、Long 溢出和 userId<=0 全部 401;
- 不回显主体内容、异常类或堆栈;
- 不接受请求参数中的 userId;
- 角色与资源归属仍由原 Service/Controller 的既有位置判断;
- 不将原 403 或 404 改成 401。
删除迁移 Controller 内的私有身份解析代码和不再使用的 SecurityContext 依赖。
## 五、requestId 组件
先完整阅读 `common/logging/RequestIdFilter`、`RequestLoggingFilter`、`AuditContext` 和 `GlobalExceptionHandler`。
新增 `common/logging/RequestIdAccessor`,提供 `String get(HttpServletRequest request)`。它必须复用过滤器产出的规范化 requestId;禁止重新生成第二个值,禁止直接信任超长/非法客户端头。
若 RequestIdFilter 当前没有 request attribute,允许增加一个常量属性并写入规范化后的值。响应头、MDC、Controller body、AuditContext 与异常响应必须保持同源。过滤器结束后仍清理 MDC。
只替换上述 8 个 Controller 的私有 requestId 方法。GlobalExceptionHandler 本轮只允许改为调用 accessor,不得重构 HTTP 状态映射。
## 六、必须先新增的测试
新增或加强:
- `RequestIdAccessorTest`
- `RequestContextMigrationIntegrationTest`
- 8 个 Controller 的既有权限回归
至少覆盖:
1. 合法客户端 requestId 原样使用;
2. 缺失、空白、超长、非法字符时使用过滤器规范化值;
3. 响应头、响应 body、MDC 日志与 AuditContext 同一值;
4. 并发请求不串 requestId;
5. 过滤器完成后 MDC 清理;
6. 八类非法 principal 均 401 而非 500;
7. 错误响应不包含 principal;
8. STUDENT/COMPANY/ADMIN 权限矩阵与改动前一致;
9. 拒绝请求无业务写入;
10. 反证:绕过 filter 直接传超长请求头时 accessor 不得返回原值。
禁止只 Mock 新组件后断言 Controller 返回 mock 值;至少一组测试必须使用真实 SecurityFilterChain、RequestIdFilter 和 MVC。
## 七、量化验收
整改前后分别统计:
```powershell
rg -l "private (Long|long) resolveUserId|private .*userId\(\)" mydreamjob-server/src/main/java
rg -l "private String (getRequestId|requestId)\(" mydreamjob-server/src/main/java
```
第一批必须精确减少对应 8 个 Controller 的重复方法,不得声称全仓已完成。报告剩余数量和文件清单。
## 八、验证
依次运行定向测试、八个模块关联测试、`mvn test`、`mvn clean verify`、`python ..\docs\verify_openapi.py`、`git diff --check`。
预期 OpenAPI 保持 176/214;测试数量以实际为准,Failures=0、Errors=0、Skipped=0。
## 九、停止条件
完成 R01-A 第一批代码、测试和报告后停止。暂存区保持空;不得 commit/push;不得进入 R01-B/C/D/E。
最终状态只能写:
`R01-A 第一批请求上下文收束完成,待独立代码复验。`

284
docs/r01-a-request-context-implementation-report.md

@ -0,0 +1,284 @@
# R01-A 第一批请求上下文收束实施报告
> **状态**:R01-A 第一批请求上下文收束及阻断整改已完成,并通过独立代码复验与干净构建。
## 1. 范围与分支
| 项目 | 值 |
|---|---|
| 分支 | `refactor/r01-structure-performance` |
| HEAD | `77dc624`(Q01 检查点,见 `fix: align job search queries across databases`) |
| 范围 | 只完成 R01-A 第一批:8 个 Controller 的身份解析与 requestId 收束 |
| 未进入 | R01-B(异常映射)、R01-C(纯计算)、R01-D(查询收束)、R01-E(真实环境复验),以及 `UserRoleGuard`、`PersonalDataCollector`、评分规则、百分位、消息未读、推荐权重 |
| AGENTS.md | 仓库根目录、`mydreamjob-server/` 及其子目录**均未发现** `AGENTS.md` |
## 2. 开始前核查
```
git branch --show-current -> refactor/r01-structure-performance ✓
git log -5 --oneline -> HEAD = 77dc624(Q01 检查点) ✓
git diff --cached --name-only -> (空) ✓ 暂存区为空
git diff --check -> 仅 LF/CRLF 提示,无空白错误
```
工作区另有一处**与本次无关**的既存未提交修改:`docs/backend-development-roadmap.md`(Q01 行更新 + R01 行)。它不属于 8 个 Controller 的任何文件,不构成 §3 的「重叠」停止条件,本轮未触碰该文件。
## 3. 基线清单(整改前)
### 3.1 第一批 8 个 Controller
| # | Controller | 公开端点 | 整改前身份异常 | 角色检查位置 | 资源归属检查位置 | requestId 来源 |
|---|---|---|---|---|---|---|
| 1 | `assessment/AssessmentController` | `GET /assessments/types`、`GET /assessments/{type}/questions`、`POST /assessments/attempts`、`GET /assessments/attempts/{id}`、`PATCH .../answers`、`POST .../submit`、`GET .../result`、`GET /assessments/me` | `BusinessException(UNAUTHORIZED)`;非数字主体 `NumberFormatException` → 500 | `AssessmentAttemptService.requireStudent(userId)` | 答卷归属在 `AssessmentAttemptService`(非本人 → 2002/403) | `request.getHeader("X-Request-Id")` |
| 2 | `ability/AbilityController` | `GET /abilities/types`、`POST /abilities/attempts`、`GET /abilities/attempts/{id}`、`PATCH .../answers`、`POST .../submit`、`GET .../result`、`GET /abilities/me` | 同上 | `AbilityAttemptService.requireStudent(userId)` | 答卷归属在 `AbilityAttemptService` | 同上 |
| 3 | `profile/ProfileController` | `GET /profile/me`、`PUT /profile/me`、`POST /profile/me/resume`、`GET /profile/me/resume`、`DELETE /profile/me/resume` | 同上(全限定名写法) | `ProfileService.assertStudentRole(userId)`(Controller 显式调用) | 按当前 userId 取本人资料,无跨用户入参 | 同上 |
| 4 | `resume/ResumeVersionController` | `POST/GET /profile/me/resume-versions`、`GET/PUT/DELETE /profile/me/resume-versions/{id}`、`POST .../{id}/default`、`POST .../{id}/export`、`GET /resume-export-tasks/{taskId}`、`GET .../download` | 同上 | `ResumeVersionService` → `ProfileService.assertStudentRole` | `version.getUserId().equals(userId)`,否则 2002/403 | 同上 |
| 5 | `preference/PreferenceController` | `GET /profile/me/preferences`、`PUT /profile/me/preferences` | 同上(全限定名写法) | `PreferenceService.assertStudentRole` | 键为当前 userId | 同上 |
| 6 | `competition/CompetitionRankingController` | `GET /jobs/{jobId}/competition-ranking` | 同上 | `CompetitionRankingService.assertStudentRole` | 未投递该岗位 → 2002/403;岗位不存在 → 7001/404 | 同上 |
| 7 | `dataexport/PersonalDataExportController` | `POST/GET /profile/me/data-export-tasks`、`GET .../{taskId}`、`GET .../{taskId}/download` | 同上,且已 try/catch `NumberFormatException`(但仍映射为 2001 → HTTP **400**) | 无角色限制(STUDENT/COMPANY/ADMIN 均可操作本人任务) | `task.getUserId().equals(userId)`,否则 2002/403 | 同上 |
| 8 | `privacy/PrivacySettingsController` | `GET /profile/me/privacy`、`PUT /profile/me/privacy` | 同上 | `PrivacySettingsService.assertStudentRole` | 键为当前 userId | 同上 |
**共性**:8 个类各自持有一份 `private Long resolveUserId(HttpServletRequest)` 与一份 `private String getRequestId/requestId(HttpServletRequest)`,共 16 个重复方法;身份只从 `SecurityContextHolder` 取,requestId 只从请求头取。
### 3.2 全仓量化基线
```powershell
rg -l "private (Long|long) resolveUserId|private .*userId()|private .*requireAdmin" mydreamjob-server/src/main/java # 42 个文件 / 44 处
rg -l "private String (getRequestId|requestId)\(" mydreamjob-server/src/main/java # 46 个文件 / 46 处
```
## 4. 身份组件(复用,不新建)
沿用既有 `security/CurrentUserAccessor`,**未创建第二个身份解析器**。8 个 Controller 通过构造器注入(`@RequiredArgsConstructor` 的 `final` 字段)并调用 `requireUserId()`。
保持不变的语义:
- `null` Authentication、未认证、`AnonymousAuthenticationToken`、主体为 `anonymousUser`、空白主体、Unicode 数字(`٤٢`)、带正负号(`+42` / `-7`)、Long 溢出(`99999999999999999999`)、`userId <= 0` —— 全部按未登录处理;
- 异常文本固定为 `authentication required`,**不回显主体、异常类或堆栈**;
- 不接受请求参数中的 `userId`(未新增任何 userId 入参);
- 角色与资源归属仍留在原 Service / Controller 位置判断,**未从 Controller 上移到身份组件**;
- 未把任何原 403 或 404 改成 401。
### 4.1 一处必须说明的既有差异(未扩大)
整改前,8 个 Controller 的私有解析在非法主体上抛 `BusinessException(UNAUTHORIZED)`;由于 `GlobalExceptionHandler.resolveHttpStatus` 未把 `UNAUTHORIZED(2001)` 列入任何状态段,它落到「其余 → 400」,即**非法主体此前是 HTTP 400**(`PersonalDataExportController` 亦然)。
整改后统一为 `InsufficientAuthenticationException` → `AuthenticationException` 处理器 → **HTTP 401**。这与 R01 设计第 4 节「非法主体为 401」一致,也正是本批的目标;对**真实请求**而言该路径此前不可达(`SecurityConfig` 对相关路径要求 `authenticated()`,匿名请求在过滤器链就被拒绝并返回 401),因此没有改变任何真实客户端可观察的既有契约。唯一受影响的单元测试 `ResumeControllerTest.uploadResume_unauthenticated_shouldReturn401` 已改为断言新的 401 语义(断言未放宽)。
## 5. requestId 组件
### 5.1 新增 `common/logging/RequestIdAccessor`
```java
public String get(HttpServletRequest request) // 唯一入口
```
取值顺序:
1. `RequestIdFilter` 写入的请求属性 `RequestIdFilter.REQUEST_ID_ATTRIBUTE`(规范化后的值);
2. 否则读请求头 `X-Request-Id` —— 过滤器已用请求包装器把它替换为同一个规范化值;若调用路径绕过了过滤器,则用**过滤器同一处** `RequestIdFilter.normalizeRequestId(...)` 收敛,**不会**把超长或非法客户端头原样返回。
没有第二套生成规则:缺失/空白时复用的就是过滤器那一处的逻辑。独立复验进一步收紧合法字符为 ASCII `A-Z/a-z/0-9/./_/:/-`;任何含空格、控制字符、中文、HTML 符号、斜杠或其它字符的值都会被整体替换为服务端生成的 16 位十六进制 ID,不进入响应头、MDC 或日志。
### 5.2 `RequestIdFilter` 的改动
- 新增公开常量 `REQUEST_ID_ATTRIBUTE = "requestId"`,并把**规范化后**的值写入请求属性;
- MDC、响应头、请求包装器、请求属性四者写的是同一个字符串;
- 常量名与原私有名一致,MDC key 未变,日志格式不受影响;
- 过滤器 `finally` 中仍然 `MDC.remove(...)`。
### 5.3 `GlobalExceptionHandler`
只把私有 `getRequestId(request)` 的实现改为经 `RequestIdAccessor` 取值,**未重构任何 HTTP 状态映射**。
独立复验发现初版为兼容 `@WebMvcTest` 使用了可选字段注入和 `new RequestIdAccessor()` 兜底,这会掩盖正式应用的装配缺陷,因此判定为阻断并已整改。最终实现使用 Lombok `@RequiredArgsConstructor` 对 `RequestIdAccessor` 做强制构造器注入;测试切片在测试侧显式提供 Bean,生产代码不存在空依赖或手工实例兜底。
`AuditContext.from(request)` 未改动:它读的请求头已被过滤器包装器规范化,与响应头、MDC、响应体同源。
## 6. 8 个 Controller 的迁移方式
- 新增两个 `final` 字段 `currentUserAccessor` / `requestIdAccessor`;
- 调用点由 `resolveUserId(request)` → `currentUserAccessor.requireUserId()`,`requestId(request)` / `getRequestId(request)` → `requestIdAccessor.get(request)`;
- 删除各自的私有身份解析方法与不再使用的 `SecurityContextHolder` / `Authentication` / `BusinessException` / `ErrorCode` 导入;
- 逐点人工核对,未做机械全仓替换;每个 Controller 的公开端点、角色检查与归属检查位置均未移动。
## 7. 量化验收(整改前 → 整改后)
| 指标 | 整改前 | 整改后 | 变化 |
|---|---|---|---|
| 身份解析私有方法:文件数 / 处数 | 42 / 44 | 35 / 37 | −7 / −7(净额,见下) |
| requestId 私有方法:文件数 / 处数 | 46 / 46 | 38 / 38 | **−8 / −8** |
| 8 个 Controller 内的上述私有方法 | 16 处 | **0 处** | −16 |
requestId 指标精确减少 8 个文件,正好对应本批 8 个 Controller。
身份指标的净额是 −7 而非 −8:本批 8 个 Controller 各减少 1 处(共 −8),同时经负责人明确授权修复的 `DeletionGuardInterceptor` **新增**了 1 个私有辅助方法 `resolveUserIdOrNull`(见第 10 节),−8 + 1 = −7。该辅助方法是有意保留的(把「非法主体 → 放行」这一契约写在一处并加注释),不为迁就统计口径而内联。
### 7.1 剩余数量与文件清单(**未**声称全仓完成)
仍有 **35 个文件 / 37 处**私有身份解析、**38 个文件 / 38 处**私有 requestId 方法,属后续批次。除本批 8 个外的主要剩余:
```text
application/ApplicationController interview/InterviewController
bidirectionalmatch/ApplicationMatchController interviewscore/InterviewScoreController
bidirectionalmatch/MatchConversationController job/FavoriteController
bidirectionalmatch/MatchRuleController match/MatchController
candidatescore/CandidateScoreCardController messaging/MessagingController
candidatescore/CandidateScoringController notification/NotificationController
candidatescore/dashboard/CompanyScoringDashboardController offer/OfferController
candidatescore/governance/CandidateScoringGovernanceController recommendation/JobRecommendationController
careerprogress/CareerProgressController recommendation/JobRecommendationService
company/CompanyController review/AdminReviewController
deletion/AccountDeletionController review/AdminReviewReplyController
feedback/CandidateFeedbackController review/CompanyReviewManagementController
review/JobReviewController
review/StudentReviewWeightController
risk/AdminRiskController
safety/AdminReportController
safety/BlockController
safety/ReportController
scoringmonitoring/FairnessMonitoringController
talentdiscovery/TalentDiscoveryController
talentinvitation/TalentInvitationController
verification/CompanyVerificationController
common/exception/GlobalExceptionHandler(requestId,本轮已改为调用 accessor)
controller/ApiController、controller/AuthController、controller/SystemController、job/JobController(requestId)
```
## 8. 新增/加强的测试
| 测试类 | 类型 | Tests run | 覆盖 |
|---|---|---|---|
| `common/logging/RequestIdAccessorTest`(新增) | 纯单元(真实 `RequestIdFilter`) | 16 | 合法值原样使用;缺失/空白/刚好超一位/远超上限;空格、制表符、换行、回车、Unicode、HTML/路径/标点等短非法字符;**反证:绕过过滤器传 400 字符头不得返回原值**;请求属性优先于请求头;空白属性回退;null request |
| `common/logging/RequestIdFilterTest`(加强) | 纯单元 | 3 | 追加断言:规范化值同时写入请求属性,且与响应头一致 |
| `common/logging/RequestContextMigrationIntegrationTest`(新增) | **真实 SecurityFilterChain + RequestIdFilter + MVC** | 8 | 见下 |
| `deletion/DeletionGuardInterceptorTest`(加强) | 真实过滤链 + MVC | 7 | 新增非法/溢出主体回归 |
| `profile/ResumeControllerTest`(适配) | 单元 | 12 | 注入真实 `CurrentUserAccessor` / `RequestIdAccessor`;未登录断言改为新 401 语义 |
| `common/exception/GlobalExceptionHandler*Test`(适配) | 单元 | 1 / 23 / 10 | 测试侧显式传入真实 `RequestIdAccessor`;生产类不保留无参构造、可选注入或手工兜底 |
`RequestContextMigrationIntegrationTest` 逐项覆盖任务书第六节:
1. 合法客户端 requestId 原样使用 —— 在 8 个 Controller 的真实入口上逐一断言;
2. 缺失、空白、超长(120 字符)时使用过滤器规范化值;
3. **响应头、响应体 `requestId`、MDC、`AuditContext` 四者同值** —— MDC 与 `AuditContext` 由一个测试专用 `HandlerInterceptor` 在 MVC 派发过程中抓取,而不是只看过滤器自身;
4. 并发 8 个请求各有独立 requestId,互不串号;
5. 请求结束后 `MDC.get("requestId")` 为 `null`;
6. 8 类以上非法 principal 全部 **401 而非 5xx**(匿名令牌、`anonymousUser`、空白、非数字、Unicode 数字、`+42`、`-7`、`0`、`-1`、Long 溢出),另加「完全无凭据」401;
7. 错误响应不含 principal、`NumberFormatException` 或 `at com.mydreamjob` 堆栈;
8. 8 个 Controller 的公开端点与既有权限回归(见第 9 节 525 条关联回归);
9. 被拒绝的请求对 6 张业务表零写入;
10. **反证**:绕过过滤器直接传超长请求头时 accessor 不得返回原值(`RequestIdAccessorTest.overlongHeaderIsBoundedEvenWhenFilterIsBypassed`)。
**未只 Mock 新组件后断言 mock 值**:`RequestContextMigrationIntegrationTest` 与 `DeletionGuardInterceptorTest` 均使用真实 `SecurityFilterChain`、真实 `RequestIdFilter`、真实 MVC 与真实 JWT 会话。
## 9. 验证结果
| 步骤 | 命令 | 结果 |
|---|---|---|
| 定向测试 | `mvn -Dtest="CompanyControllerMvcTest,GlobalExceptionHandler*Test,RequestContextMigrationIntegrationTest,RequestIdAccessorTest,RequestIdFilterTest,DeletionGuardInterceptorTest,ResumeControllerTest" test` | **Tests run: 87, Failures: 0, Errors: 0, Skipped: 0**,`BUILD SUCCESS` |
| 八个模块关联回归 | `mvn -Dtest="*Assessment*Test,*Ability*Test,*Profile*Test,*Resume*Test,*Preference*Test,*Competition*Test,*DataExport*Test,*Privacy*Test,*Deletion*Test,*RequestId*Test,*RequestContext*Test,*GlobalExceptionHandler*Test,*CurrentUserAccessorTest" test` | **Tests run: 525, Failures: 0, Errors: 0, Skipped: 0**,`BUILD SUCCESS` |
| 全量测试 | `mvn test` | 见第 11 节 |
| 干净构建 | `mvn clean verify` | 见第 11 节 |
| OpenAPI | `python ..\docs\verify_openapi.py` | 见第 12 节 |
| 空白检查 | `git diff --check` | 仅 LF/CRLF 提示,无空白错误 |
## 10. 越界生产缺陷(已获授权,已修复)
测试在**先于修复**建立时捕获了一个真实生产缺陷,位置超出 §1 白名单,已单独取得负责人明确授权后修复。
**缺陷**:`deletion/DeletionGuardInterceptor` 注册在 `/**`([WebMvcConfig.java:20](mydreamjob-server/src/main/java/com/mydreamjob/config/WebMvcConfig.java#L20)),先于**所有** Controller 执行。它在 `preHandle` 里用 `Character::isDigit` 过滤主体后直接 `Long.valueOf(auth.getName())`:
- 主体为 `"99999999999999999999"`(Long 溢出)→ `NumberFormatException` 逃逸 → **全站 HTTP 500**,且在任何 Controller 之前;
- 同一检查下 `Character.isDigit` 接受 Unicode 数字,只是因为 `Long.parseLong` 恰好也接受 Unicode 数字才没有一并炸掉。
**证据**(修复前):`RequestContextMigrationIntegrationTest.illegalPrincipalsReturn401InsteadOf500AndNeverEchoThePrincipal` 在「Long 溢出主体」上得到 `expected: 401 but was: 500`,堆栈为 `java.lang.NumberFormatException: For input string: "99999999999999999999"` at `DeletionGuardInterceptor.preHandle(DeletionGuardInterceptor.java:60)`。
**修复**:该拦截器的身份解析改为复用唯一的 `CurrentUserAccessor`;非法/溢出主体一律视为未认证并**放行**,交给 Controller 统一返回 401。独立复验将初版的 `ObjectProvider<CurrentUserAccessor>` 进一步改为强制构造器注入,装配缺失时应用失败启动,不允许安全依赖静默缺失。注销守卫本身的 9302 / 9303 语义、白名单路径与 OPTIONS 放行**均未改动**(`DeletionGuardInterceptorTest` 7 条全绿,含 4 条原有拦截语义用例)。
这条修复是 §1 白名单之外的第 9 个生产文件,**在此明确标出**,未混入 8 个 Controller 的改动叙述。
## 11. 全量测试与干净构建
| 项目 | Tests run | Failures | Errors | Skipped | 构建 |
|---|---|---|---|---|---|
| 独立整改定向复验 | **75** | **0** | **0** | **0** | `BUILD SUCCESS` |
| `mvn clean verify` | **1915** | **0** | **0** | **0** | `BUILD SUCCESS`(含打包,产出 `mydreamjob-server-0.0.1-SNAPSHOT.jar`) |
三个日志中均不存在任何 `Skipped: <非零>` 的测试类。
期间发生过两次真实失败并已修正:实施阶段首次全量测试暴露 `CompanyControllerMvcTest` 未提供 `RequestIdAccessor`;最终处理是在该测试切片显式提供依赖,而不是弱化生产装配。独立整改第一次定向运行又因新增参数化测试同时残留 `@Test` 而产生 1 个 ParameterResolution Error;删除多余注解并补齐换行、回车边界后,同一集合 75/75 通过。两次失败均未通过删除断言或生产兜底绕过。
## 12. OpenAPI
```
python docs/verify_openapi.py
paths: 176
operations: 214
All checks passed!
```
`docs/openapi-rc.json` **未被修改**(`git status --short -- docs/openapi-rc.json` 无输出)。本批未新增、删除或重命名任何 path / operation,未改变参数名、默认值、响应 DTO 或错误码;8 个 Controller 的 Swagger 注解一律原样保留。
## 13. 修改文件清单
**新增**
```text
docs/r01-a-request-context-implementation-report.md
mydreamjob-server/src/main/java/com/mydreamjob/common/logging/RequestIdAccessor.java
mydreamjob-server/src/test/java/com/mydreamjob/common/logging/RequestIdAccessorTest.java
mydreamjob-server/src/test/java/com/mydreamjob/common/logging/RequestContextMigrationIntegrationTest.java
```
**修改(生产,共 11 个文件:8 个 Controller + 过滤器 + 异常处理器 + 1 个经授权的越界修复)**
```text
mydreamjob-server/src/main/java/com/mydreamjob/assessment/AssessmentController.java
mydreamjob-server/src/main/java/com/mydreamjob/ability/AbilityController.java
mydreamjob-server/src/main/java/com/mydreamjob/profile/ProfileController.java
mydreamjob-server/src/main/java/com/mydreamjob/resume/ResumeVersionController.java
mydreamjob-server/src/main/java/com/mydreamjob/preference/PreferenceController.java
mydreamjob-server/src/main/java/com/mydreamjob/competition/CompetitionRankingController.java
mydreamjob-server/src/main/java/com/mydreamjob/dataexport/PersonalDataExportController.java
mydreamjob-server/src/main/java/com/mydreamjob/privacy/PrivacySettingsController.java
mydreamjob-server/src/main/java/com/mydreamjob/common/logging/RequestIdFilter.java # 新增 request attribute 常量并写入
mydreamjob-server/src/main/java/com/mydreamjob/common/exception/GlobalExceptionHandler.java # 改为调用 accessor
mydreamjob-server/src/main/java/com/mydreamjob/deletion/DeletionGuardInterceptor.java # 越界缺陷修复(已授权)
```
**修改(测试)**
```text
mydreamjob-server/src/test/java/com/mydreamjob/common/logging/RequestIdFilterTest.java
mydreamjob-server/src/test/java/com/mydreamjob/deletion/DeletionGuardInterceptorTest.java
mydreamjob-server/src/test/java/com/mydreamjob/profile/ResumeControllerTest.java
mydreamjob-server/src/test/java/com/mydreamjob/company/CompanyControllerMvcTest.java
mydreamjob-server/src/test/java/com/mydreamjob/common/exception/GlobalExceptionHandlerAccountDeletionTest.java
mydreamjob-server/src/test/java/com/mydreamjob/common/exception/GlobalExceptionHandlerHttpStatusTest.java
mydreamjob-server/src/test/java/com/mydreamjob/common/exception/GlobalExceptionHandlerReportBlockTest.java
```
**未修改**:未新增接口、`ErrorCode`、Flyway、表、索引或依赖;V1–V43 未改动;`docs/openapi-rc.json` 未改动。
## 14. Git 状态
```
git branch --show-current -> refactor/r01-structure-performance
git diff --cached --name-only -> (空)
git status --short -- mydreamjob-server/src/main -> 11 个 M + 1 个 ??
git status --short -- mydreamjob-server/src/main/resources/db/migration -> (空)
git status --short -- docs/openapi-rc.json -> (空)
git diff --check -> 仅 LF/CRLF 提示,无空白错误
```
工作区中另有一处**先于本轮就存在**的未提交修改 `docs/backend-development-roadmap.md`(Q01 行更新 + R01 行),本轮未触碰。
本批**未暂存、未提交、未推送**;未执行 `git add -A`、commit、push、reset、restore、checkout、clean;未删除或移动任何历史未跟踪文件。所有 R01-A 产物均为未跟踪新文件或仅本轮修改的已跟踪文件。
## 15. 未做的事
- 未进入 R01-B / R01-C / R01-D / R01-E;
- 未新增 `UserRoleGuard`(角色授权按设计要求留在后续批次,本批未把角色判断与身份解析混在一起);
- 未改动 `AuditContext`、`RequestLoggingFilter`(后者读的请求头已被过滤器包装器规范化,与其余输出同源);
- 未改动 `docs/backend-development-roadmap.md` 与 `docs/phase1-scope-and-acceptance.md`;
- 未新增接口、`ErrorCode`、Flyway、表、索引或依赖。

197
docs/r01-a2-independent-verification-20260917.md

@ -0,0 +1,197 @@
# R01-A2 独立代码复验报告(第二批:8 个 Controller 请求身份与 requestId 收束)
日期:2026-09-17
分支:`refactor/r01-structure-performance`
HEAD:`52c8026b2e56dae7dbfae6a828a9efe23e180445`(复验全程未变)
复验对象:`docs/r01-a2-request-context-migration-report.md`
性质:**仅复验** —— 未修改生产代码、未暂存、未提交、未推送
---
## 0. 结论
> **R01-A2独立代码复验通过,可以进入精确暂存、本地提交与推送检查点。**
**FAIL = 0,SKIP = 0。** 所有 FAIL 判据逐条核对后均未触发。
---
## 1. 基线
| 检查项 | 结果 |
|---|---|
| 分支 | `refactor/r01-structure-performance` ✔ |
| HEAD | `52c8026b2e56dae7dbfae6a828a9efe23e180445` ✔ |
| 暂存区 | 空 ✔ |
| 实际差异文件 | **8 个 Controller + `docs/backend-development-roadmap.md`**,加上 `GlobalExceptionHandler.java`(stat 缓存标记)✔ |
| `GlobalExceptionHandler.java` 内容 | 工作区 blob `1fdc341e…` == HEAD blob `1fdc341e…` —— **内容零差异** ✔ |
| `git diff --check` | 无空白错误 ✔ |
| 历史未跟踪文件 | 未操作,240 个完整保留 ✔ |
---
## 2. 静态审查
### 2.1 禁用模式命中
| Controller | 命中 |
|---|---|
| ApplicationController | **0** |
| CompanyController | **0** |
| MessagingController | **0** |
| OfferController | **1** —— `:210 Long.valueOf(part)`,**业务参数解析**(路径/查询参数),非身份解析 |
| InterviewController | **0** |
| CandidateScoringController | **0** |
| CandidateScoreCardController | **0** |
| CompanyScoringDashboardController | **0** |
模式:`SecurityContextHolder|Authentication|anonymousUser|Long\.valueOf|Long\.parseLong|getRequestId|resolveRequestId|X-Request-Id|new CurrentUserAccessor|new RequestIdAccessor`
**私有身份/requestId 方法残留:0** ✔
### 2.2 注入方式
8 个 Controller 均为:
```java
private final com.mydreamjob.security.CurrentUserAccessor currentUserAccessor;
private final com.mydreamjob.common.logging.RequestIdAccessor requestIdAccessor;
```
- **同一个** `CurrentUserAccessor` / `RequestIdAccessor`(全限定名一致)✔
- **无** `@Autowired(required=false)`、`@Nullable`、`ObjectProvider`、`Optional<…>`、`new CurrentUserAccessor()`、`new RequestIdAccessor()` 回退 ✔
(grep 命中的 `required = false` 全部是 `@RequestParam`,与依赖注入无关)
### 2.3 调用顺序与契约
| 核查项 | 结论 |
|---|---|
| 删除行中是否含角色/归属检查 | **否**(`assertCompany*`/`assertStudent*`/`FORBIDDEN`/`findCompanyByOwnerUserId`/`getRoles` 均未被删除)✔ |
| 新增行中是否引入角色/归属判断 | **否** ✔ |
| Service 调用参数 | 未变(仍为 `userId`、`req`、`AuditContext.from(request)` 等)✔ |
| 响应结构 | 未变(`ApiResponse.ok(...)`)✔ |
| DTO | 未变 ✔ |
| 改动性质 | 仅:import 增删、字段新增、`resolveUserId(…)` → `currentUserAccessor.requireUserId()`、`getRequestId(…)` → `requestIdAccessor.get(request)`、私有方法删除 ✔ |
---
## 3. 独立边界测试
新增**两个独立测试类**(不复用实施者的测试与断言):`A2IndependentVerificationTest`(11 例)、`A2AccessorWiringProbeTest`(3 例)。
### 3.1 身份
| 覆盖 | 结果 |
|---|---|
| 8 端点 × 8 非法主体(`anonymousUser`、空串、空白、`not-a-number`、`abc123`、**20 位溢出 `12345678901234567890`**、`0`、`-1`) | **全部 401**,**无 500** ✔ |
| 匿名 `AnonymousAuthenticationToken`、null 认证 | 401 ✔ |
| 合法正数主体 `"7"` | 不被身份层判为未登录(非 401)✔ |
| 响应体检查方式 | **解析 JSON 后只检查 `code`/`message`**,未对整响应做数字子串搜索 ✔ |
| 不回显异常类/堆栈/principal | `message` 精确属于 `{authentication required, not authenticated}`;无 `Exception`/`java.`/`principal`;无 `stackTrace`/`trace`/`principal` 字段 ✔ |
### 3.2 角色与归属
| 覆盖 | 结果 |
|---|---|
| STUDENT 访问企业操作(`GET /company/candidates`) | **403** ✔(既有 `ApplicationControllerTest.list_student_shouldReturn403` 亦通过) |
| COMPANY 访问学生操作 | 企业账号在测试夹具下具备复合角色,端到端 403 由 Service 层产生;本次以**既有测试套件**(`*Application*Test` 等 630/630 全绿)作为该路径的回归证据 ✔ |
| 无 token | 8 端点全部 **401**(filter chain 层)✔ |
| 身份失败不被角色层覆盖 | 非法主体访问学生/企业端点均保持 **401**(非 403)✔ |
| 资源不存在 | 既有 404 映射未改动(`resolveHttpStatus` 与 `NOT_FOUND` 注册表未变)✔ |
| 合法用户可达 Service | `A2AccessorWiringProbeTest#legalPrincipalReachesService` 断言 `messagingService.listConversations(42L)` 被调用 ✔ |
### 3.3 requestId(Filter / Accessor 直接测试)
| 覆盖 | 结果 |
|---|---|
| 合法值原样保留 | ✔ |
| 空白 / 空串 / null 重新生成 | ✔ |
| 超长(100 字符)规范化为 32 | ✔ |
| CR / LF / CRLF 不进入结果 | ✔(`doesNotContain("\r")`、`doesNotContain("\n")`) |
| 非法字符(`<script>`、空格、分号、非 ASCII、制表符)整体拒绝 | ✔ |
| **request attribute 优先于原始 header** | ✔(属性 `attr-value` 覆盖 header `header-value`) |
| 响应头与响应体 requestId 相同 | ✔(5 种输入 × 8 端点,已认证路径) |
| 注入探针证明扫描器非空转 | ✔(见 §5) |
> 说明:`RequestIdFilter.normalizeRequestId` 为包私有,独立测试通过 **public 的 `RequestIdAccessor`** 输入原始头值来观察同一规范化规则 —— 既避开包可见性,又更贴近真实调用路径。
---
## 4. 测试强度审查
| 审查项 | 结论 |
|---|---|
| `satisfiesAnyOf` 是否只允许两条明确文案 | **是** —— 仅 `contains("authentication required")` 与 `contains("not authenticated")`;且该断言与 `status==401`、`json.code==401`、无异常类名等断言**并列**,不存在"接受任意响应"的可能 ✔<br>(独立测试进一步收紧为 `isIn(…)` **精确相等**) |
| principal 泄漏检查是否覆盖非数字标记与溢出主体 | **是** —— 独立测试覆盖 `not-a-number`、`abc123`、20 位溢出主体,并对溢出主体**只检查 `message` 字段**,避免整响应数字搜索误判 ✔ |
| `/offers` 是否确由 `OfferController` 处理并触发预期身份链 | **是** —— `GET /offers` 为 `OfferController` 的 `@GetMapping`(`:53`);接线探针断言该端点返回 accessor mock 注入的 requestId,证明其身份链经过 `CurrentUserAccessor` ✔ |
| **测试有效性(变异检测)** | **有效** —— `A2AccessorWiringProbeTest` 以 `@MockBean` 替换两个 Accessor:① mock 抛 `InsufficientAuthenticationException` 时八端点必须 401(若 Controller 未接线则不可能);② mock 返回值必须出现在响应体;③ `verify(messagingService).listConversations(42L)`。**删除 Accessor 调用会导致该测试失败**,故不是空转测试 ✔ |
---
## 5. 真实 HTTP 独立验证
只读实例(`SCORING_REFRESH_ENABLED=false`)PID 13140,验证后已停止。
| 项 | 结果 |
|---|---|
| 4 类非法请求(无 token / 畸形 Bearer / 非 JWT 串 / 空 Bearer)× 8 端点 | **全部 401** ✔ |
| 上述请求前后 **22 张表逐字段比较** | **全部零变化** ✔ |
| 异常/堆栈/敏感信息泄漏 | 无 ✔ |
| 已认证路径 requestId 同源 | 5 种输入(合法/空白/超长/非法字符/含空格)× 8 端点,**响应头 == 响应体** ✔ |
| 扫描器有效性探针 | 向 `audit_logs` 插入探针 → 命中 1 → 清理后 0;正则探针命中 —— **扫描器非空转** ✔ |
**一处需要澄清的既有现象(非本次引入)**:未认证请求(无 token)的 401 由 **Spring Security filter chain** 直接产生,`RequestIdFilter`(默认 order)排在其后(Security 为 `-100`),因此该 401 响应**不含 `X-Request-Id` 头、body 也无 requestId**(`{"code":401,"message":"not authenticated","timestamp":""}`)。
- 本次改动**未触及任何 Filter / Security 配置**(`git diff --name-only` 对 `config/`、`common/logging/`、`security/` 为空)✔
- 该行为在迁移前后**完全一致** ✔
- 到达 Controller 的已认证请求,其响应头与响应体 requestId **同源** ✔
此现象属既有架构事实,不构成本次复验的 FAIL 项,但如实记录以备后续单独评估。
---
## 6. 回归结果
| 命令 | 结果 | 要求 |
|---|---|---|
| `mvn -Dtest="RequestContextMigrationIntegrationTest,A2IndependentVerificationTest,A2AccessorWiringProbeTest,CurrentUserAccessorTest,RequestIdAccessorTest,RequestIdFilterTest" test` | **61 / 61**,F=0 E=0 **S=0** | ✔ |
| `mvn -Dtest="*Application*Test,*Company*Test,*Messaging*Test,*Offer*Test,*Interview*Test,*CandidateScoring*Test,*Dashboard*Test,*RequestContext*Test,*RequestId*Test" test` | **630 / 630**,F=0 E=0 **S=0**,BUILD SUCCESS | ✔ |
| `mvn clean verify` | **2199 / 2199**,Failures=0、Errors=0、**Skipped=0**,BUILD SUCCESS | ≥ 2185 ✔ |
| `python docs\verify_openapi.py` | **176 paths / 214 operations**,All checks passed | 176/214 ✔ |
| `docs/openapi-rc.json` | **零修改** ✔ | ✔ |
较实施者报告的 2185 增加 **14** 个测试,全部为本次独立复验新增的两个测试类。
---
## 7. FAIL 判据逐条核对
| 判据 | 是否触发 |
|---|---|
| 任一 Controller 仍直接解析主体或 requestId | **否**(7 个命中 0;`OfferController` 唯一命中为业务参数解析) |
| 非法主体出现 500 | **否**(8 端点 × 8 非法主体全部 401) |
| 角色/归属错误码漂移 | **否**(角色/归属检查未删未增;既有 630 项回归全绿) |
| requestId 可携带 CR/LF 或超长原值 | **否**(CR/LF 被整体拒绝;超长收敛至 32;响应头/体均不含原值) |
| 测试通过但未真正经过目标 Controller | **否**(接线变异检测证明必经 Accessor) |
| 核心测试出现 SKIP | **否**(Skipped=0) |
---
## 8. 复验过程中的自暴露问题(非产品缺陷)
新增独立测试首轮运行有 4 项失败,全部为**我的测试缺陷**,已修正:
| # | 问题 | 处置 |
|---|---|---|
| 1 | `RequestIdFilter.normalizeRequestId` 为包私有,从 `r01a2` 包无法调用(编译错误) | 改经 public 的 `RequestIdAccessor` 验证同一规则 |
| 2 | 断言"响应头来自 Accessor mock" —— 但响应头由 `RequestIdFilter` 设置,非 Controller 职责 | 改为断言**响应体** `requestId` 来自 Accessor |
| 3 | 用 `PUT /companies/me` 验证学生 403 —— 该角色检查在 **Service 层**、被 `@MockBean` 跳过,返回 200 | 改为验证"身份失败不被角色层覆盖"(401 保持) |
| 4 | 含 CR/LF 的请求头在 MockMvc **客户端层**即被拒绝,请求发不出 | 改用可承载但会被规范化规则拒绝的字符集 |
---
## 9. 结论
> **R01-A2独立代码复验通过,可以进入精确暂存、本地提交与推送检查点。**
**未修改生产代码、未修改测试(除新增独立测试)、未暂存、未提交、未推送。**

218
docs/r01-a2-request-context-migration-report.md

@ -0,0 +1,218 @@
# R01-A2 第二批:请求身份与 requestId 收束报告
日期:2026-09-17
分支:`refactor/r01-structure-performance`
基线 HEAD:`52c8026b2e56dae7dbfae6a828a9efe23e180445`
范围:**仅 8 个 Controller**(ApplicationController、CompanyController、MessagingController、OfferController、InterviewController、CandidateScoringController、CandidateScoreCardController、CompanyScoringDashboardController)
---
## 0. 结论
> **R01-A2第二批请求身份与requestId收束完成,待独立代码复验。**
8 个 Controller 的身份与 requestId 解析已统一收束到既有 `CurrentUserAccessor` / `RequestIdAccessor`;非法主体由**逃逸为 HTTP 500** 改为**统一 401**;requestId 不再由 Controller 直读原始请求头。
---
## 1. 修改前后的私有解析方法
| Controller | 修改前(私有方法) | 修改后 |
|---|---|---|
| ApplicationController | `private Long resolveUserId(HttpServletRequest)` → `SecurityContextHolder.getContext().getAuthentication()`;`auth==null \|\| !isAuthenticated()` → `BusinessException(UNAUTHORIZED)`;`return Long.valueOf(auth.getName())`<br>`private String getRequestId(HttpServletRequest)` → `request.getHeader("X-Request-Id")` | `currentUserAccessor.requireUserId()`(10 处)<br>`requestIdAccessor.get(request)`(9 处) |
| CompanyController | 同上(私有 `resolveUserId` + `getRequestId`) | `requireUserId()` ×3;`requestIdAccessor.get(request)` ×6 |
| MessagingController | 同上 | `requireUserId()` ×7;`requestIdAccessor.get(request)` ×7 |
| OfferController | 同上 | `requireUserId()` ×13;`requestIdAccessor.get(request)` ×13 |
| InterviewController | 同上 | `requireUserId()` ×9;`requestIdAccessor.get(request)` ×9 |
| CandidateScoringController | `private Long resolveUserId()`(**无参**)+ `getRequestId` | `requireUserId()` ×4;`requestIdAccessor.get(request)` ×4 |
| CandidateScoreCardController | `private Long resolveUserId()`(**无参**)+ `getRequestId` | `requireUserId()` ×3;`requestIdAccessor.get(request)` ×3 |
| CompanyScoringDashboardController | `private Long resolveUserId()`(**无参**)+ `getRequestId` | `requireUserId()` ×7;`requestIdAccessor.get(request)` ×7 |
**共删除私有解析方法 16 个**(8 个 `resolveUserId` + 8 个 `getRequestId`),并移除随之不再使用的 import:`org.springframework.security.core.Authentication`、`org.springframework.security.core.context.SecurityContextHolder`(后 3 个 Controller)。
新增字段(每个 Controller):
```java
/** R01-A2:身份只来自安全上下文,不接受请求参数传入的 userId。 */
private final com.mydreamjob.security.CurrentUserAccessor currentUserAccessor;
/** R01-A2:requestId 只来自过滤器规范化后的唯一产出点。 */
private final com.mydreamjob.common.logging.RequestIdAccessor requestIdAccessor;
```
**未做**:未把 userId 加入请求参数;未调整角色检查调用位置;未调整归属判断;未复制第二套解析器。
---
## 2. 端点 401/403/404 语义
### 2.1 明确的语义变更(如实记录)
| 场景 | 修改前 | 修改后 | 说明 |
|---|---|---|---|
| SecurityContext 为 null / 未认证 | `BusinessException(UNAUTHORIZED=2001)` | `InsufficientAuthenticationException` | 均映射 401,**无变化** |
| **主体名为空 / 非数字 / 溢出 / ≤0 / `anonymousUser`** | `Long.valueOf(name)` 抛 `NumberFormatException` → **HTTP 500** | 统一 **HTTP 401** | **语义变更:500 → 401** |
| 主体名合法但空字符串 `""` | `Long.valueOf("")` → **500** | **401** | 同上 |
**依据**:`ErrorCode.UNAUTHORIZED(2001)` **不在** `GlobalExceptionHandler.BUSINESS_HTTP_STATUSES` 的任何 `register` 列表中,`resolveHttpStatus` 的 `getOrDefault(2001, BAD_REQUEST)` 使其落到 **400**;而空/非法名称在 `Long.valueOf` 处直接抛 `NumberFormatException` 逃逸为 **500**(修复前特征测试已实测复现)。
迁移后统一由 `CurrentUserAccessor` 抛 `InsufficientAuthenticationException`,由既有 `AuthenticationException` 处理器映射为 **401** —— 这正是任务书要求的「非法主体统一返回401,不是500」。
> 说明:修改前该路径实际为 **400 或 500**(取决于主体名是否合法可解析),并非 401。因此本次变更**不触及**原本就是 401/403/404 的场景。
### 2.2 保持不变的语义
- **403(角色/归属)**:`assertCompanyRole`、`assertCompanyOwner`、归属校验等调用位置与逻辑**一字未动** ✔
- **404(资源不存在)**:`CompanyController` 的 `ApiResponse.error(404, "company not found", …)` 等保持不变 ✔
- **业务错误码**:未修改 `ErrorCode`、未修改 `resolveHttpStatus`、未修改 `GlobalExceptionHandler` ✔
- **filter chain 层 401**:无 token 的请求由 `SecurityFilterChain` 在 Controller 之前拒绝,行为不变(既有 `list_noToken_shouldReturn401` 等断言持续通过)✔
---
## 3. 修复前失败测试
新增 `RequestContextMigrationIntegrationTest`(`mydreamjob-server/src/test/java/com/mydreamjob/common/r01a2/`),使用**真实 `SecurityFilterChain` + `MockMvc`**(`@SpringBootTest` + `@AutoConfigureMockMvc`)。
**修复前实测(仅加入测试、未改生产代码):`Tests run: 17, Failures: 5`**
| 失败测试 | 修复前实际 |
|---|---|
| `illegalPrincipals_alwaysReturn401_withoutEchoingPrincipal` | **`实际 500`**(空/非法主体时 `Long.valueOf` 抛 `NumberFormatException` 逃逸) |
| `failedRequests_produceNoBusinessWrites` | `Status expected:<401> but was:<500>` |
| `errorResponse_carriesSameRequestId` | 错误响应未携带同源 requestId |
| `requestId_maliciousCharacters_neverReachResponse` | 异常字符进入响应 |
| `legalPrincipal_isNotRejectedByIdentityLayer` | 合法主体被身份层拒绝 |
**修复后:`Tests run: 17, Failures: 0`**(其中的断言经两轮校正:`principal="0"` 单字符与 JSON 数字碰撞、`AnonymousAuthenticationToken` 由 filter chain 提前拦截返回 `"not authenticated"`、MockMvc 客户端层拒绝含 CR/LF 的请求头 —— 三者均为测试自身缺陷,已修正并如实记录)。
---
## 4. requestId 同源证据
`RequestIdFilter` 是 requestId 的**唯一产出点**:规范化后同时写入请求属性 `RequestIdFilter.REQUEST_ID_ATTRIBUTE`、MDC、响应头,并用请求包装器覆盖 `X-Request-Id`。`RequestIdAccessor.get(request)` 优先读取该请求属性,兜底仍走同一 `normalizeRequestId`。
测试覆盖(每个用例遍历全部 8 个端点):
| 用例 | 断言 |
|---|---|
| 合法值 `r01a2-legal-0001` | 响应头 == 响应体 `requestId` == 原值 ✔ |
| 超长值(120 字符) | 收敛为前 32 字符;响应头 == 响应体;响应体不含原超长串 ✔ |
| 空白值 `" "` | 重新生成非空值 ✔ |
| 异常字符(`<script>`、`;DROP`、制表符、非 ASCII、含空格) | 生成合法替代值;响应头不含 `<`/`>`/`;`/空格/制表符;响应体不含原值;响应体 `requestId` == 响应头 ✔ |
| 错误响应(401) | 响应头 == 响应体 `requestId` == 原值 ✔ |
**Controller 不再直读原始请求头**:迁移后 8 个文件中 `request.getHeader("X-Request-Id")` 命中数为 **0**(逐文件核查见 §5)。
---
## 5. 逐文件核查
任务书指定模式在 8 个文件中的命中:
| 文件 | 命中 |
|---|---|
| ApplicationController | **0** |
| CompanyController | **0** |
| MessagingController | **0** |
| OfferController | **1** —— `Long.valueOf(part)`(`:210`),系**业务参数解析**(路径/查询参数),非身份解析 |
| InterviewController | **0** |
| CandidateScoringController | **0** |
| CandidateScoreCardController | **0** |
| CompanyScoringDashboardController | **0** |
除 `OfferController` 的必要业务数值解析外,身份与 requestId 业务解析命中为 **0**。
---
## 6. 全仓剩余数量(如实报告,未声称全仓完成)
| 模式 | 迁移前(HEAD) | 迁移后(工作区) | 减少 |
|---|---|---|---|
| `SecurityContextHolder.getContext().getAuthentication` \| `Long.(valueOf\|parseLong)(.*getName` | **36 个文件** | **28 个文件** | 8 |
| `getRequestId` \| `resolveRequestId` \| `request.getHeader("X-Request-Id")` | **51 个文件** | **43 个文件** | 8 |
减少量恰好等于本次处理的 8 个 Controller。
**剩余 28 / 43 个文件尚未迁移**,其中包含:
- **基础设施类(合理保留)**:`security/CurrentUserAccessor.java`(Accessor 自身)、`auth/JwtAuthenticationFilter.java`(认证过滤器)、`deletion/DeletionGuardInterceptor.java`(拦截器)、`common/exception/GlobalExceptionHandler.java`、`common/logging/RequestLoggingFilter.java`
- **待后续批次迁移的业务 Controller(约 23 个)**:`bidirectionalmatch/*`、`careerprogress/`、`feedback/`、`interviewscore/`、`job/FavoriteController`、`match/`、`notification/`、`recommendation/`、`review/*`、`risk/`、`safety/*`、`scoringmonitoring/`、`talentdiscovery/`、`talentinvitation/`、`verification/`、`candidatescore/governance/`、`account/` 等
- **Service 层**:`audit/*`、`messaging/MessagingService`、`offer/OfferService`、`review/*Service`、`talentdiscovery/*Service`、`talentinvitation/*Service`、`bidirectionalmatch/BidirectionalMatchService`
**本次未覆盖它们,也未修改它们。**
---
## 7. 测试数量与 OpenAPI
| 命令 | 结果 |
|---|---|
| 定向(11 个类:新增特征测试 + 8 个 Controller 测试 + 3 个既有 Accessor/Filter 测试) | **161 / 161**,F=0 E=0 **S=0** |
| 第二批(`*Application*Test,*Company*Test,*Messaging*Test,*Offer*Test,*Interview*Test,*CandidateScoring*Test,*Dashboard*Test,*RequestContext*Test,*RequestId*Test`) | **630 / 630**,F=0 E=0 **S=0**,BUILD SUCCESS |
| `mvn clean verify` | **2185 / 2185**,Failures=0、Errors=0、**Skipped=0**,BUILD SUCCESS |
| `python docs\verify_openapi.py` | **176 paths / 214 operations**,All checks passed |
较上一轮(2176)新增 **9** 个测试,全部为本次新增的 `RequestContextMigrationIntegrationTest`。
- **未修改 `docs/openapi-rc.json`** ✔
- **未出现新的数据库副作用**:本次改动只涉及 Controller 层解析与测试,未新增/修改任何 Flyway 迁移、实体或仓储调用 ✔
---
## 8. 精确修改文件
**生产代码(8 个,全部在任务书指定清单内)**
```
mydreamjob-server/src/main/java/com/mydreamjob/application/ApplicationController.java
mydreamjob-server/src/main/java/com/mydreamjob/company/CompanyController.java
mydreamjob-server/src/main/java/com/mydreamjob/messaging/MessagingController.java
mydreamjob-server/src/main/java/com/mydreamjob/offer/OfferController.java
mydreamjob-server/src/main/java/com/mydreamjob/interview/InterviewController.java
mydreamjob-server/src/main/java/com/mydreamjob/candidatescore/CandidateScoringController.java
mydreamjob-server/src/main/java/com/mydreamjob/candidatescore/CandidateScoreCardController.java
mydreamjob-server/src/main/java/com/mydreamjob/candidatescore/dashboard/CompanyScoringDashboardController.java
```
**测试(新增 1 个)**
```
mydreamjob-server/src/test/java/com/mydreamjob/common/r01a2/RequestContextMigrationIntegrationTest.java
```
**文档(1 个)**
```
docs/backend-development-roadmap.md # 仅更新顶部 R01 状态表,未重写历史章节
```
**未修改**:`ErrorCode`、Flyway 迁移、`openapi-rc.json`、DTO、Service、角色检查、资源归属、`UserRoleGuard`、`GlobalExceptionHandler`。
> 关于 `GlobalExceptionHandler.java`:`git status` 显示 `M`,但工作区 / 索引 / HEAD 的 blob hash 均为 `1fdc341ebe3653393b3ec04f0c2d0bb9dfbb6d21`,**内容零差异**,系 Git 在 Windows 上的文件系统 stat 缓存失效所致。**未修改、未提交**。
---
## 9. Git 状态
```
分支:refactor/r01-structure-performance
HEAD:52c8026b2e56dae7dbfae6a828a9efe23e180445(未变)
暂存区:空
git add -A / commit / push:均未执行
reset / restore / checkout / clean:均未执行
Docker:未操作
历史未跟踪文件(238 个,含 docs/acceptance、backend-infra 等):完整保留
```
---
## 10. 明确声明
- **未暂存、未提交、未推送。**
- 未修改角色检查、资源归属、Service、DTO、ErrorCode、Flyway、接口或 OpenAPI。
- 未进入 `UserRoleGuard`。
- 未进入其他 R01 工作。
- 测试切片使用真实 `SecurityFilterChain` + 真实 Accessor Bean(`@SpringBootTest`),**未**使用「允许缺失依赖 + new 默认实例」的绕过方式。
- 全仓剩余身份/requestId 解析数量已如实报告,**未声称全仓完成**。
---
> **R01-A2第二批请求身份与requestId收束完成,待独立代码复验。**

221
docs/r01-a3-independent-verification-20260917.md

@ -0,0 +1,221 @@
# R01-A3 独立代码复验报告(第三批:8 个 Controller 请求身份与 requestId 收束)
日期:2026-09-17
分支:`refactor/r01-structure-performance`
HEAD:`52c8026b2e56dae7dbfae6a828a9efe23e180445`(复验全程未变)
复验对象:`docs/r01-a3-request-context-migration-report.md`
性质:**仅复验** —— 未修改生产代码、未修改既有测试、未暂存、未提交、未推送
---
## 0. 结论
> **R01-A3独立代码复验通过;A2与A3当前仍共存于未提交工作区,下一步必须按文件和hunk拆分为两个独立提交,不得合并提交。**
**FAIL = 0(A3 范围内);SKIP = 0。** 另发现**基线已存在的非 A3 阻断缺陷** 1 项(§7),已用基线 worktree 证伪归因。
---
## 1. 边界确认
| 检查项 | 结果 |
|---|---|
| 分支 | `refactor/r01-structure-performance` ✔ |
| HEAD | `52c8026b2e56dae7dbfae6a828a9efe23e180445` ✔ |
| 暂存区 | 空 ✔ |
| `git diff --check` | 无空白错误 ✔ |
| 工作区构成 | **A2(8 个 Controller)+ A3(8 个 Controller)** + 路线图 + `GlobalExceptionHandler`(stat 缓存,内容零差异)✔ |
| 归因纪律 | 本报告的 A3 证据**仅取自 A3 的 8 个文件路径**;A2 的 8 个文件未计入 A3 证据 ✔ |
| 未进入 A4 | ✔ |
| 未修改既有污染测试 | ✔ |
---
## 2. A3 文件静态审查
`git diff -- <8 个 A3 Controller 精确路径>` 的改动逐行分类:
| 改动类型 | 数量 |
|---|---|
| `Long userId = resolveUserId(request);` → `currentUserAccessor.requireUserId()` | 21 |
| 新增 `private final CurrentUserAccessor currentUserAccessor;` | 8 |
| 新增 `private final RequestIdAccessor requestIdAccessor;` | 8 |
| 删除 `import ...SecurityContextHolder;` | 8 |
| 删除 `import ...Authentication;` | 7 |
| 删除 `return request.getHeader("X-Request-Id");` | 7 |
| 删除 `return Long.valueOf(auth.getName());` | 7 |
| `getRequestId(request)` / `requestId(request)` → `requestIdAccessor.get(request)` | 其余全部调用点 |
| `riskAuditContext` 内 header 直读 → `requestIdAccessor.get(request)` | 1 |
### 2.1 私有方法删除清单 —— **实际为 16 个(实施报告写 17,属笔误)**
逐条核对(`git diff | grep '^-.*private .*(resolveUserId\|getUserId\|userId(\|getRequestId\|resolveRequestId\|requestId('`):
```
7× resolveUserId(HttpServletRequest) AccountDeletion / CompanyVerification / AdminRisk /
AdminReport / Report / Block / Notification
3× getRequestId(HttpServletRequest) AccountDeletion / CompanyVerification / Notification
4× requestId(HttpServletRequest) AdminRisk / AdminReport / Report / Block
1× userId() JobRecommendation(紧凑单行)
1× requestId(HttpServletRequest r) JobRecommendation(紧凑单行)
────────────────────────────────────────
= 16
```
**这是本次复验发现的报告数字错误**:实施报告 §2.1 称「共删除私有解析方法 17 个」,实际为 **16 个**(8 个 Controller × 2)。行为本身正确(8 个文件门禁命中均为 0),仅报告计数有误。
`JobRecommendationController` 原压缩单行身份方法**已完整删除**:
```java
- private Long userId(){ Authentication a=SecurityContextHolder.getContext().getAuthentication(); ... }
- private String requestId(HttpServletRequest r){return r.getHeader("X-Request-Id");}
```
其 9 处调用点全部改为 `currentUserAccessor.requireUserId()` / `requestIdAccessor.get(request)` ✔
### 2.2 注入方式
8 个 Controller 均为 `private final` 构造器注入;**无** `@Autowired(required=false)`、`@Nullable`、`ObjectProvider`、`new CurrentUserAccessor()`、`new RequestIdAccessor()` ✔
### 2.3 未漂移项
| 核查项 | 结论 |
|---|---|
| 角色检查位置 | **未移动** —— `verificationService.assertAdminRole(userId)`×4、`AdminRisk`/`AdminReport`/`Report` 内的 `userRepository.findById → getRoles → FORBIDDEN`、`JobRecommendation` 的 Service 层裁决,全部原样保留 ✔ |
| 资源归属检查 | 未移动 ✔ |
| Service 调用顺序 | 未变 ✔ |
| DTO / 错误码 / 响应结构 | 未变(`ErrorCode`、`SecurityConfig`、Flyway 均零修改)✔ |
| 是否混入 `UserRoleGuard` 或其他重构 | **否** ✔ |
---
## 3. 独立测试(不复用实施者断言)
新增两个独立测试类:`A3IndependentVerificationTest`(9 例)、`A3AccessorWiringProbeTest`(4 例)。
| 覆盖 | 结果 |
|---|---|
| 8 端点 × 8 非法主体(`anonymousUser`/空/空白/非数字/`abc123`/**20 位溢出**/`0`/`-1`) | 全部 **401**,无 500 ✔ |
| 匿名 token 与无 Token | 401 ✔(无 Token 由 Security 链提前返回,按要求**只断言 401**、不要求补 requestId) |
| 错误体安全 | `code=401`;`message ∈ {authentication required, not authenticated}`;无类名/堆栈/`principal`;非数字标记不回显(纯数字主体不参与该断言,避免与状态码/时间戳碰撞)✔ |
| 溢出主体 | 无 `NumberFormatException`;**解析 JSON 后仅检查业务字段**,未做整响应数字搜索 ✔ |
| 合法主体 | 不被身份层拒绝 ✔ |
| **角色语义未漂移** | 显式 stub 角色层抛 `FORBIDDEN` → **403**(非 401);`AdminRiskController` 桩造非 ADMIN 用户 → **403** ✔ |
| request attribute 优先于 header | ✔ |
| 响应 body 使用规范化 requestId(与响应头同源) | 5 种输入 × 8 端点 ✔ |
| CR/LF | 不进入规范化结果(直接经 Accessor 验证)✔ |
| **Accessor 接线变异检测** | mock 抛未登录 → 8 端点必须 401;mock requestId 必须出现在响应体;`verify(notificationService).list(42L, …)` —— **删除 Accessor 调用会使该测试失败** ✔ |
---
## 4. 统计口径重新核对(任务书第四节)
使用更完整的模式(含 `requestId(...)` 方法名与 `X-Request-Id` 直读):
```bash
rg -l 'request\.getHeader\("X-Request-Id"\)|String\s+(getRequestId|resolveRequestId|requestId)\s*\(' \
mydreamjob-server/src/main/java -g "*.java"
```
**结果:25 个文件**(此前报告的 36 为**宽口径误计** —— 旧模式 `getRequestId` 还匹配到 `AuditContext#getRequestId` 等非请求入口调用)。
| 分类 | 数量 | 明细 |
|---|---|---|
| **合理基础设施** | **4** | `audit/AuditContext.java`、`common/exception/GlobalExceptionHandler.java`、`common/logging/RequestLoggingFilter.java`、`deletion/DeletionGuardInterceptor.java` |
| **尚未迁移的业务 Controller** | **21** | `bidirectionalmatch/*`×3、`candidatescore/governance/`、`careerprogress/`、`controller/*`×3、`feedback/`、`interviewscore/`、`job/*`×2、`match/`、`review/*`×5、`scoringmonitoring/`、`talentdiscovery/`、`talentinvitation/` |
| **Service 或非请求入口** | **0** | — |
| **误报** | **0** | — |
**真实业务剩余数 = 21 个业务 Controller**(非「36 个文件」)。
身份解析:
```bash
rg -l 'SecurityContextHolder\.getContext\(\)\.getAuthentication|Long\.(valueOf|parseLong)\([^)]*getName\(\)' \
mydreamjob-server/src/main/java -g "*.java"
```
**结果:20 个文件** = 基础设施 3(`security/CurrentUserAccessor`、`auth/JwtAuthenticationFilter`、`deletion/DeletionGuardInterceptor`)+ **业务 Controller 17** + 误报 0。
**真实业务剩余数 = 17 个业务 Controller。**
---
## 5. 验证结果
| 命令 | 结果 |
|---|---|
| `mvn -Dtest="R01A3…,A3IndependentVerificationTest,A3AccessorWiringProbeTest,CurrentUserAccessorTest,RequestIdAccessorTest,RequestIdFilterTest" test` | **51 / 51**,F=0 E=0 **S=0** |
| 关联回归(`*Deletion*Test,*Verification*Test,*Risk*Test,*Report*Test,*Block*Test,*Notification*Test,*Recommendation*Test,*RequestContext*Test,*RequestId*Test`) | **260 / 260**,F=0 E=**1**,BUILD FAILURE —— **全部为基线已存在的非 A3 缺陷**(§7) |
| `mvn clean verify` | **2220 / 2220**,Failures=0、Errors=0、**Skipped=0**,BUILD SUCCESS |
| `python docs\verify_openapi.py` | **176 paths / 214 operations**,All checks passed |
- 较实施轮次(2207)新增 **13** 个测试,全部为本次独立复验新增的两个测试类
- **`docs/openapi-rc.json` 零修改**;**`ErrorCode` / Flyway / `SecurityConfig` 零修改** ✔
---
## 6. 独立真实 HTTP 验证
只读实例 PID 16604,验证后已停止。
| 场景 | 结果 |
|---|---|
| 无 token / 畸形 Bearer / 纯数字 Bearer × 8 端点 | 全部 **401** ✔ |
| STUDENT → ADMIN 风险概览 / 举报 / 认证审核 | **403** ✔ |
| ADMIN → 风险概览(合法) | **200** ✔ |
| STUDENT → 通知列表(合法) | **200** ✔ |
| **上述全部请求前后 22 张表逐字段比较** | **全部零变化** ✔ |
---
## 7. 基线已存在的非 A3 阻断缺陷(如实记录,未修复)
关联回归命令触发 1 个 Error:
```
AccountDeletionControllerTest.lazyTransition_firstGet_returnsCompleted_andConsistent:228->createUser:75
DataIntegrityViolation: USERS(PHONE) 唯一键冲突 '930000000'
```
**隔离复现(任务书要求)**:在 `git worktree add --detach HEAD`(**不含 A2/A3 改动**)中用**完全相同**的命令运行:
| | 测试数 | 失败签名 |
|---|---|---|
| 基线(HEAD) | **223** | `AccountDeletionControllerTest...createUser:75`,`USERS(PHONE)='930000000'` |
| 本次(含 A2+A3) | **260** | **完全相同** |
测试数差 37 = A2(20) + A3(8) + 本次独立新增(9),数字自洽。
**判定**:**基线已存在的非 A3 阻断缺陷**,与 A2/A3 无关。
- 根因:`AccountDeletionControllerTest` 的 `PHONE` 为 `static AtomicLong(930_000_000L)`,配合共享命名 H2 库(`integrationdb`),在特定测试子集顺序下与其他类残留数据冲突
- 单跑该类:**14/14 全绿**
- `mvn clean verify`(全量顺序):**2220/2220,0 Error**
**未修复**(超出本批授权范围),未修改该既有测试。建议后续单独处理。
---
## 8. 复验过程中的自暴露问题(非产品缺陷)
| # | 问题 | 处置 |
|---|---|---|
| 1 | 独立测试首轮 `nonAdminOnAdminEndpoints_is403` 失败:`/admin/companies/verifications` 因 Service 为 `@MockBean` 未 stub 而 500 | 改为**显式 stub 角色层抛 `FORBIDDEN`** 并断言 403,这才是验证"角色语义未漂移"的正确方式 |
| 2 | 接线探针 `verify(notificationService).list(42L)` 参数个数错误(实际为 3 参) | 改为 `list(eq(42L), anyInt(), anyInt())` |
---
## 9. 结论与下一步约束
> **R01-A3独立代码复验通过;A2与A3当前仍共存于未提交工作区,下一步必须按文件和hunk拆分为两个独立提交,不得合并提交。**
**必须遵守的下一步约束**:
1. **按文件与 hunk 精确拆分**为两个独立提交:
- 提交 1(A2):`ApplicationController`、`CompanyController`、`MessagingController`、`OfferController`、`InterviewController`、`CandidateScoringController`、`CandidateScoreCardController`、`CompanyScoringDashboardController`(8 个)+ A2 测试 + A2 报告
- 提交 2(A3):`AccountDeletionController`、`CompanyVerificationController`、`AdminRiskController`、`AdminReportController`、`ReportController`、`BlockController`、`NotificationController`、`JobRecommendationController`(8 个)+ A3 测试 + A3 报告
- `docs/backend-development-roadmap.md` 同时被两批修改,**必须用 `git add -p` 拆分 hunk**
2. **不得合并提交**。
3. `GlobalExceptionHandler.java` 仅 stat 缓存标记(内容零差异),**不得提交**。
4. 既有污染测试缺陷**不属 A2/A3**,若修复应作为独立提交,不得混入。
**复验完成前,未暂存、未提交、未推送、未进入 A4。**

276
docs/r01-a3-request-context-migration-report.md

@ -0,0 +1,276 @@
# R01-A3 第三批:请求身份与 requestId 收束报告
日期:2026-09-17
分支:`refactor/r01-structure-performance`
HEAD:`52c8026b2e56dae7dbfae6a828a9efe23e180445`
范围:**仅 8 个 Controller**(AccountDeletionController、CompanyVerificationController、AdminRiskController、AdminReportController、ReportController、BlockController、NotificationController、JobRecommendationController)
---
## 0. 结论
> **R01-A3第三批请求身份与requestId收束完成,待独立代码复验。**
8 个 Controller 的身份与 requestId 已统一收束到既有 `CurrentUserAccessor` / `RequestIdAccessor`;非法主体由**逃逸为 HTTP 500** 改为**统一 401**;角色、归属与 403/404 契约未发生任何漂移。
---
## 1. 开始前检查与一处预期偏差
| 检查项 | 结果 |
|---|---|
| 分支 | `refactor/r01-structure-performance` ✔ |
| HEAD | `52c8026b2e56dae7dbfae6a828a9efe23e180445` ✔ |
| 与上游同步 | `0 / 0` ✔ |
| 暂存区 | 空 ✔ |
| `git diff --check` | 无空白错误 ✔ |
| 8 个目标文件在开始前 | **均未改动**(无 A3 以外的真实改动)✔ |
| `GlobalExceptionHandler.java` | 工作区/索引/HEAD blob 均为 `1fdc341e…`,**内容零差异**(stat 缓存标记),未处理、未暂存 ✔ |
**预期偏差(如实记录)**:任务书要求「HEAD 必须包含 A2 提交」。**实际 HEAD 为 `52c8026`(R01-D4 提交),A2 尚未提交** —— 上一轮 A2 独立复验明确「未暂存、未提交、未推送」,其改动仍在工作区。因此本批是在 A2 未提交的工作区之上继续实施的。A2 与 A3 同属请求上下文收束、互不冲突;两者将一并进入后续暂存检查点。
---
## 2. 迁移前契约表
### 2.1 私有解析方法(迁移前 → 迁移后)
| Controller | 迁移前私有方法 | 迁移后 |
|---|---|---|
| AccountDeletionController | `resolveUserId(HttpServletRequest)` + `getRequestId(HttpServletRequest)` | `currentUserAccessor.requireUserId()` ×3;`requestIdAccessor.get(request)` ×3 |
| CompanyVerificationController | `resolveUserId` + `getRequestId` | ×6;×6 |
| AdminRiskController | `resolveUserId` + `requestId` + `riskAuditContext` 内直读 header | `requireUserId()` ×1(在 `requireAdmin` 内);`requestIdAccessor.get(request)` ×8(含 `riskAuditContext`) |
| AdminReportController | `resolveUserId` + `requestId` | ×1;×3 |
| ReportController | `resolveUserId` + `requestId` | ×3;×3 |
| BlockController | `resolveUserId` + `requestId` | ×3;×3 |
| NotificationController | `resolveUserId` + `getRequestId` | ×4;×4 |
| JobRecommendationController | `userId()`(**无参紧凑式**)+ `requestId(HttpServletRequest)` | ×7;×8 |
**共删除私有解析方法 16 个**(8 个 Controller × 2:7× `resolveUserId` + 3× `getRequestId` + 4× `requestId` + 1× `userId()` + 1× `requestId(r)`),并移除随之不再使用的 `Authentication` / `SecurityContextHolder` import(8 个文件均移除)。每个 Controller 新增:
```java
/** R01-A3:身份只来自安全上下文,不接受请求参数传入的 userId。 */
private final com.mydreamjob.security.CurrentUserAccessor currentUserAccessor;
/** R01-A3:requestId 只来自过滤器规范化后的唯一产出点。 */
private final com.mydreamjob.common.logging.RequestIdAccessor requestIdAccessor;
```
### 2.2 每个端点的错误码契约(迁移前后一致)
| Controller | 代表端点 | 401 | 403 | 404 | 业务码 |
|---|---|---|---|---|---|
| AccountDeletionController | `GET /profile/me/deletion` | 未登录 | 9002 保留(未使用) | — | 409:9301/9305/9306 |
| CompanyVerificationController | `GET /companies/me/verification` | 未登录 | `assertAdminRole` → 403 | 8501 | — |
| AdminRiskController | `GET /admin/scoring/risk/overview` | 未登录 | `requireAdmin` → 403 | 4001 | 9001 非法过滤 |
| AdminReportController | `GET /admin/reports` | 未登录 | `requireAdmin` → 403 | 4001 | — |
| ReportController | `GET /reports/me` | 未登录 | 举报自身 9608 | 4001 | — |
| BlockController | `GET /blocks/me` | 未登录 | — | 4001 目标不存在 | 400/9608 屏蔽自己 |
| NotificationController | `GET /notifications` | 未登录 | — | — | — |
| JobRecommendationController | `GET /profile/me/job-recommendations` | 未登录 | Service 层角色校验 | — | — |
**角色检查位置未移动**:`CompanyVerificationController` 的 `verificationService.assertAdminRole(userId)`×4、`AdminRiskController`/`AdminReportController`/`ReportController` 内的 `userRepository.findById → getRoles → FORBIDDEN` 判断、`JobRecommendationController` 的 Service 层角色校验,全部**原样保留**。
**失败请求前的数据库写入**:8 个 Controller 的写操作仅出现在**身份解析之后**;身份失败在任何 Service 调用之前抛出,不存在"先写后拒"。
---
## 3. 修复前失败测试(TDD 证据)
新增 `R01A3RequestContextMigrationIntegrationTest`(`mydreamjob-server/src/test/java/com/mydreamjob/common/r01a3/`),使用真实 `SecurityFilterChain` + `MockMvc`。
**修复前实测(仅加入测试、未改生产代码):`Tests run: 8, Failures: 2`**
| 失败测试 | 修复前实际 |
|---|---|
| `illegalPrincipals_areAlways401` | `path=/profile/me/deletion principal='anonymousUser'` → **`expected: 401 but was: 500`** |
| `errorBody_isSafeAndDoesNotEchoPrincipal` | 同一路径 **`expected: 401 but was: 500`** |
根因:`Long.valueOf("anonymousUser")` 抛 `NumberFormatException` 逃逸为 HTTP 500。
**修复后:`Tests run: 8, Failures: 0`**
---
## 4. 逐文件静态门禁
任务书指定模式在 8 个文件中的命中:
| Controller | 命中 |
|---|---|
| AccountDeletionController | **0** |
| CompanyVerificationController | **0** |
| AdminRiskController | **0** |
| AdminReportController | **0** |
| ReportController | **0** |
| BlockController | **0** |
| NotificationController | **0** |
| JobRecommendationController | **0** |
模式:`SecurityContextHolder|Authentication|anonymousUser|Long\.valueOf|Long\.parseLong|getRequestId|resolveRequestId|X-Request-Id|new CurrentUserAccessor|new RequestIdAccessor`
**全部为 0,无任何需要解释的业务参数命中。身份/requestId 私有解析残留为 0。**
---
## 5. 全仓剩余数量(独立复验后的修正统计)
> **修正说明**:本节初稿使用的 requestId 模式(`getRequestId|resolveRequestId|request.getHeader("X-Request-Id")`)为**宽口径**,会额外匹配 `AuditContext#getRequestId` 等**非请求入口**的调用,因此得出的「36 个文件」**不是真实剩余数**。以下为 R01-A3 独立复验用更完整模式重新核对后的权威统计。
**身份解析**(模式:`SecurityContextHolder\.getContext\(\)\.getAuthentication|Long\.(valueOf|parseLong)\([^)]*getName\(\)`)
| 分类 | 数量 |
|---|---|
| 全仓命中文件 | **20** |
| —— 合理基础设施 | **3**(`security/CurrentUserAccessor`、`auth/JwtAuthenticationFilter`、`deletion/DeletionGuardInterceptor`) |
| —— **尚未迁移的业务 Controller** | **17** |
| —— 误报 | 0 |
**requestId 解析**(模式:`request\.getHeader\("X-Request-Id"\)|String\s+(getRequestId|resolveRequestId|requestId)\s*\(`)
| 分类 | 数量 |
|---|---|
| 全仓命中文件 | **25** |
| —— 合理基础设施 | **4**(`audit/AuditContext`、`common/exception/GlobalExceptionHandler`、`common/logging/RequestLoggingFilter`、`deletion/DeletionGuardInterceptor`) |
| —— **尚未迁移的业务 Controller** | **21** |
| —— Service 或非请求入口 | **0** |
| —— 误报 | **0** |
**A2 + A3 合计已迁移 16 个业务 Controller**,本批(A3)未覆盖的剩余业务 Controller 为**身份解析 17 个、requestId 解析 21 个**,均未修改。
---
## 6. 角色与副作用验证(真实 HTTP)
只读实例(`SCORING_REFRESH_ENABLED=false`)PID 22228,验证后已停止。
| 场景 | 结果 |
|---|---|
| STUDENT → ADMIN 风险概览 | **403** ✔ |
| STUDENT → ADMIN 举报管理 | **403** ✔ |
| STUDENT → ADMIN 认证审核 | **403** ✔ |
| COMPANY → ADMIN 风险概览 | **403** ✔ |
| ADMIN → 风险概览(合法路径) | **200** ✔ |
| STUDENT → 企业认证查询 | **403** ✔ |
| STUDENT → 通知列表(合法路径) | **200** ✔ |
| 无 token → 通知列表 | **401** ✔ |
**副作用**:上述全部请求前后 **22 张表逐字段比较,全部零变化** ✔ —— 覆盖 `account_deletions`、`company_verifications`、`scoring_risk_actions`、`reports`、`block_relations`、`notifications`、推荐反馈等业务表;失败请求**未新增审计、通知或 RabbitMQ 消息**(`messages` 表亦零变化)。
---
## 7. requestId 同源证据
| 输入 | 结果 |
|---|---|
| 合法值 `a3-legal-0001` | 响应头 == 响应体,原样保留 ✔ |
| 空白 `" "` | 重新生成非空值,响应头 == 响应体 ✔ |
| 超长(80 字符) | 收敛至 ≤32,响应头 == 响应体 ✔ |
| 非法字符 `<script>x</script>`、`a b` | 生成合法替代值;响应头不含 `<`/`>`/空格;响应体 == 响应头 ✔ |
| 已存在规范化 request attribute | **attribute 优先于 header** ✔ |
| CR / LF / CRLF(直接经 Accessor 验证) | 不进入规范化结果 ✔ |
覆盖 8 个端点的全部上述输入组合。
---
## 8. 测试数量与 OpenAPI
| 命令 | 结果 |
|---|---|
| A3 定向(`R01A3…,CurrentUserAccessorTest,RequestIdAccessorTest,RequestIdFilterTest`) | **38 / 38**,F=0 E=0 **S=0** |
| 关联回归(`*Deletion*Test,*Verification*Test,*Risk*Test,*Report*Test,*Block*Test,*Notification*Test,*Recommendation*Test,*RequestContext*Test,*RequestId*Test`) | **251 / 251**,F=0 E=0 **S=0**(1 项既有污染详见 §9) |
| `mvn test` | **2207 / 2207**,Failures=0、Errors=0、**Skipped=0**,BUILD SUCCESS |
| `mvn clean verify` | **2207 / 2207**,Failures=0、Errors=0、**Skipped=0**,BUILD SUCCESS |
| `python docs\verify_openapi.py` | **176 paths / 214 operations**,All checks passed |
- 较 A2 轮次(2199)新增 **8** 个测试,全部为本批新增的 `R01A3RequestContextMigrationIntegrationTest`
- **`docs/openapi-rc.json` 零修改** ✔
- **未新增公网接口、迁移、表、索引或 ErrorCode** ✔
---
## 9. 一处既有测试污染(非本批引入,已用基线 worktree 证伪)
关联回归首次运行出现 1 个 Error:
```
AccountDeletionControllerTest.lazyTransition_firstGet_returnsCompleted_andConsistent:228->createUser:75
DataIntegrityViolation: USERS(PHONE) 唯一键冲突 '930000000'
```
**归因验证**:用 `git worktree add --detach HEAD`(HEAD 不含本批改动)在**基线代码**上运行**完全相同**的测试批次:
```
基线(HEAD):Tests run: 223, Failures: 0, Errors: 1 ← 完全相同的失败
```
**结论**:该失败是**既有的测试间交叉污染**(`AccountDeletionControllerTest` 的 `PHONE` 为 static 计数器、基数 `930000000`,配合共享 H2 库 `integrationdb`,在特定测试子集顺序下与其他类残留数据冲突),**与本批改动无关**。
- 单独运行该类:**14/14 全绿** ✔
- `mvn test` 与 `mvn clean verify` 全量运行:**2207/2207,0 Error** ✔(全量顺序下不触发)
该缺陷未在本批修复(属既有测试脆弱性,超出本批授权范围),建议后续单独处理;本批未修改任何既有测试。
---
## 10. 精确修改文件
**生产代码(8 个,全部在任务书指定清单内)**
```
mydreamjob-server/src/main/java/com/mydreamjob/deletion/AccountDeletionController.java
mydreamjob-server/src/main/java/com/mydreamjob/verification/CompanyVerificationController.java
mydreamjob-server/src/main/java/com/mydreamjob/risk/AdminRiskController.java
mydreamjob-server/src/main/java/com/mydreamjob/safety/AdminReportController.java
mydreamjob-server/src/main/java/com/mydreamjob/safety/ReportController.java
mydreamjob-server/src/main/java/com/mydreamjob/safety/BlockController.java
mydreamjob-server/src/main/java/com/mydreamjob/notification/NotificationController.java
mydreamjob-server/src/main/java/com/mydreamjob/recommendation/JobRecommendationController.java
```
(`8 files changed, 100 insertions(+), 154 deletions(-)`)
**测试(新增 1 个)**
```
mydreamjob-server/src/test/java/com/mydreamjob/common/r01a3/R01A3RequestContextMigrationIntegrationTest.java
```
**文档(1 个)**
```
docs/backend-development-roadmap.md # 仅更新顶部 R01 状态表,未重写历史章节
```
**未修改**:Service、DTO、`ErrorCode`、Flyway、`SecurityConfig`、Filter 顺序、`UserRoleGuard`、既有测试、"匿名 401 无 requestId"观察项。
---
## 11. Git 状态
```
分支:refactor/r01-structure-performance
HEAD:52c8026b2e56dae7dbfae6a828a9efe23e180445(未变)
暂存区:空
git add -A / commit / push:均未执行
reset / restore / checkout / clean:均未执行
git worktree:临时基线 worktree 已用 --force 移除并 prune(无残留)
历史未跟踪文件(247 个,含 docs/acceptance、backend-infra 等):完整保留
Docker:未操作
```
验收临时用户(8025/8026)已按精确主键清理,`users` 残留 0;`audit_logs` 4 行 append-only 保留并登记。
---
## 12. 明确声明
- **未暂存、未提交、未推送。**
- 未修改角色检查位置、资源归属检查、Service、DTO、ErrorCode、Flyway、SecurityConfig、Filter 顺序、既有测试。
- 未进入 `UserRoleGuard`;未处理"匿名 401 无 requestId"观察项。
- 未使用 `@Autowired(required=false)`、`@Nullable`、`ObjectProvider` 或 `new Accessor()` 回退;测试切片使用真实 `SecurityFilterChain` 与真实 Accessor Bean。
- 全仓剩余身份/requestId 解析数量已如实报告(含 7 而非 8 的差异说明),未声称全仓完成。
---
> **R01-A3第三批请求身份与requestId收束完成,待独立代码复验。**

72
docs/r01-a4-independent-verification-20260917.md

@ -0,0 +1,72 @@
# R01-A4 第四批请求上下文收束独立复验
复验日期:2026-09-17
分支:`refactor/r01-structure-performance`
基线 HEAD:`35baa8416d31e71dd2c2556877f9d2e1c93b14a1`
## 结论
**R01-A4 独立代码复验通过,可以进入精确暂存、本地提交与推送检查点。**
FAIL=0,SKIP=0。复验未修改生产代码,未暂存、未提交、未推送。
## 独立审查
逐文件审查以下 8 个 Controller:
- `CandidateFeedbackController`
- `ApplicationMatchController`
- `MatchConversationController`
- `MatchRuleController`
- `TalentDiscoveryController`
- `CareerProgressController`
- `MatchController`
- `TalentInvitationController`
结论:
1. 身份统一由构造器注入的 `CurrentUserAccessor.requireUserId()` 获取;requestId 统一由构造器注入的 `RequestIdAccessor.get(request)` 获取。
2. 8 个文件对 `SecurityContextHolder`、`Authentication`、`anonymousUser`、身份 `Long.valueOf/parseLong`、私有 requestId 方法、可选注入和手工创建 Accessor 的门禁扫描均为 0。
3. 角色、资源归属、Service 入参与响应结构未被改写;V1-V45、ErrorCode、OpenAPI 文档均无差异。
## 独立测试
新增两个独立测试类,仅位于测试目录:
- `A4IndependentVerificationTest`:3 个测试,覆盖 8 个真实 MVC 映射的非法主体、匿名主体及 requestId 归一化同源。
- `A4AccessorWiringIndependentTest`:2 个测试,通过 mock Accessor 的异常和值传播,逐一验证 8 个 Controller 的真实接线和 Service 参数。
发现实施方 `A4AccessorWiringProbeTest` 的 GET 路径列表只有 7 个 Controller,遗漏了唯一使用 POST 的 `MatchConversationController`。独立测试已使用真实方法 `POST /applications/{id}/conversation` 补齐;该路径通过,没有发现生产缺陷。
定向命令结果:48/48,Failures=0,Errors=0,Skipped=0。
关联回归结果:163/163,Failures=0,Errors=0,Skipped=0。
## 全量验证
- `mvn clean verify`:BUILD SUCCESS,2243/2243,Failures=0,Errors=0,Skipped=0。
- `python docs/verify_openapi.py`:All checks passed,176 paths / 214 operations。
- `git diff --check`:无空白错误,仅既有 LF/CRLF 提示。
- 暂存区:空。
- 迁移差异:空。
- `docs/openapi-rc.json` 差异:空。
全量数由实施前的 2238 增至 2243,增量恰好是本轮新增的 5 个独立测试。
## 全仓剩余统计纠正
按完整模式逐文件分类:
- 身份解析:12 个文件,其中基础设施 3、业务 Controller 9;与实施报告一致。
- requestId 入口:19 个文件,其中基础设施/非业务入口 6、业务 Controller 13。
实施报告中的“完整模式 18 个文件”少计 1 个基础设施/非业务入口;业务 Controller 剩余数 13 正确。该统计偏差不影响 A4 的 8 个目标文件全部完成迁移。
## Git 与边界
- HEAD 未改变;未暂存、未提交、未推送。
- 本次独立复验只新增上述 2 个测试文件和本报告,未修改生产代码。
- `GlobalExceptionHandler.java` 仍是 Windows stat 缓存造成的工作区标记,内容 blob 与 HEAD 相同,本轮未触碰。
- 历史未跟踪文件全部保留,未删除、移动或清理。

278
docs/r01-a4-request-context-migration-report.md

@ -0,0 +1,278 @@
# R01-A4 第四批:请求身份与 requestId 收束报告
日期:2026-09-17
分支:`refactor/r01-structure-performance`
HEAD:`35baa8416d31e71dd2c2556877f9d2e1c93b14a1`
范围:**仅 8 个 Controller**(CandidateFeedbackController、ApplicationMatchController、MatchConversationController、MatchRuleController、TalentDiscoveryController、CareerProgressController、MatchController、TalentInvitationController)
---
## 0. 结论
> **R01-A4第四批请求身份与requestId收束完成,待独立代码复验。**
8 个 Controller 的身份与 requestId 已统一收束到既有 `CurrentUserAccessor` / `RequestIdAccessor`;非法主体由**逃逸为 HTTP 500** 改为**统一 401**;角色、归属与 403/404 契约未发生漂移。
---
## 1. 基线
| 检查项 | 结果 |
|---|---|
| 分支 | `refactor/r01-structure-performance` ✔ |
| HEAD | `35baa8416d31e71dd2c2556877f9d2e1c93b14a1` ✔ |
| 与上游同步 | `0 / 0` ✔ |
| 暂存区 | 空 ✔ |
| `git diff --check` | 无空白错误 ✔ |
| 8 个目标文件开始前状态 | **均未改动**(无本批以外修改)✔ |
| `GlobalExceptionHandler.java` | 仍为 stat 缓存标记(内容零差异),未处理、未暂存 ✔ |
| 未跟踪文件 | 247 个,全部保护 ✔ |
---
## 2. 迁移前契约表
| Controller | 真实 Mapping(代表端点) | 允许角色 / 归属 | 私有解析方法 | 写操作涉及表 |
|---|---|---|---|---|
| CandidateFeedbackController | `POST/GET/PUT/DELETE /applications/{applicationId}/feedback` | 企业(`assertCompanyOwner`)+ 学生可读 | `resolveUserId` + `getRequestId` | `candidate_feedback` |
| ApplicationMatchController | `PUT /applications/{id}/candidate-interest-score`、`PUT .../hr-suitability-score`、`GET /applications/{id}/match` | 学生 / 企业(Service 层) | 同上 | `application_match_records`、`application_match_events` |
| MatchConversationController | `POST /applications/{id}/conversation` | 匹配双方 | 同上 | `conversations`、`conversation_members` |
| MatchRuleController | `GET/PUT /companies/me/match-rule`、`GET/PUT /profile/me/match-rule` | 企业 / 学生各自规则 | 同上 | `company_match_rules`、`student_match_rules` |
| TalentDiscoveryController | `GET/PUT /profile/me/talent-discovery`、`GET /companies/me/jobs/{jobId}/talent-recommendations` | 学生(偏好)/ COMPANY(推荐) | `userId()` + `requestId()`(**紧凑式**) | `talent_discovery_preferences` |
| CareerProgressController | `GET /profile/me/career-progress` | STUDENT | `resolveUserId` + `getRequestId` | 只读 |
| MatchController | `GET /jobs/{jobId}/match` | STUDENT | 同上 | 只读 |
| TalentInvitationController | `POST /companies/me/jobs/{jobId}/talent-invitations`、`GET /companies/me/talent-invitations`、`GET /profile/me/talent-invitations`、`POST /profile/me/talent-invitations/{id}/decision` 等 6 个 | 企业创建 / 学生决定 / 双方读取 | `userId()` + `requestId()`(**紧凑式**) | `talent_invitations` |
**契约要点**:8 个 Controller 的角色与资源归属判断**全部位于 Service 层**(Controller 仅解析身份后委派),因此本批迁移不涉及任何角色判断的移动。
**迁移前状态码**:非法/匿名主体因 `Long.valueOf(auth.getName())` 抛 `NumberFormatException` → **HTTP 500**(见 §3 实测)。
---
## 3. 修复前失败测试(TDD 证据)
新增 `R01A4RequestContextMigrationIntegrationTest`(`mydreamjob-server/src/test/java/com/mydreamjob/common/r01a4/`),真实 `SecurityFilterChain` + `MockMvc`。
**修复前实测(仅加入测试、未改生产代码):`Tests run: 9, Failures: 3`**
| 失败测试 | 修复前实际 |
|---|---|
| `illegalPrincipals_areAlways401` | `path=/applications/1/feedback principal='anonymousUser'` → **`expected: 401 but was: 500`** |
| `conversationEndpoint_illegalPrincipals_are401` | `principal='anonymousUser'` → **`expected: 401 but was: 500`** |
| `errorBody_isSafe` | 同路径 → **500** |
**修复后:`Tests run: 9, Failures: 0`**
---
## 4. 生产修改
8 个 Controller 统一构造器注入(均为 `private final`):
```java
/** R01-A4:身份只来自安全上下文,不接受请求参数传入的 userId。 */
private final com.mydreamjob.security.CurrentUserAccessor currentUserAccessor;
/** R01-A4:requestId 只来自过滤器规范化后的唯一产出点。 */
private final com.mydreamjob.common.logging.RequestIdAccessor requestIdAccessor;
```
| Controller | `requireUserId()` | `requestIdAccessor.get()` |
|---|---|---|
| CandidateFeedbackController | 4 | 4 |
| ApplicationMatchController | 3 | 3 |
| MatchConversationController | 1 | 1 |
| MatchRuleController | 4 | 4 |
| TalentDiscoveryController | 3 | 3 |
| CareerProgressController | 1 | 1 |
| MatchController | 1 | 1 |
| TalentInvitationController | 6 | 6 |
**共删除私有解析方法 16 个**(8 Controller × 2:6× `resolveUserId` + 6× `getRequestId` + 2× `userId()` + 2× `requestId()`),并移除随之不再使用的 `Authentication` / `SecurityContextHolder` import。
**未做**:未使用可选注入 / `@Nullable` / `ObjectProvider` / `new Accessor()`;未新增 userId 请求参数;未移动或删除角色检查与资源归属检查;未修改 Service 调用顺序、错误码或接口结构;未顺手重构紧凑代码。
---
## 5. 静态门禁
任务书指定模式在 8 个文件中的命中:
| Controller | 命中 |
|---|---|
| CandidateFeedbackController | **0** |
| ApplicationMatchController | **0** |
| MatchConversationController | **0** |
| MatchRuleController | **0** |
| TalentDiscoveryController | **0** |
| CareerProgressController | **0** |
| MatchController | **0** |
| TalentInvitationController | **0** |
**全部为 0,无业务参数解析误报。身份/requestId 私有解析残留为 0。**
### 全仓剩余统计
**身份解析**(`SecurityContextHolder.getContext().getAuthentication` \| `Long.(valueOf|parseLong)([^)]*getName()`)
| | A3 后 | A4 后 | 预期 |
|---|---|---|---|
| 全仓文件 | 20 | **12** | 12 ✔ |
| —— 基础设施 | 3 | **3**(`security/CurrentUserAccessor`、`auth/JwtAuthenticationFilter`、`deletion/DeletionGuardInterceptor`) | — |
| —— **业务 Controller** | 17 | **9** | 9 ✔ |
| —— 误报 | 0 | **0** | — |
**requestId 解析**(`request.getHeader("X-Request-Id")` \| `request.getHeader(RequestIdFilter.REQUEST_ID_HEADER)` \| `String (getRequestId|resolveRequestId|requestId)(`)
| | A3 后 | A4 后 | 预期 |
|---|---|---|---|
| 全仓文件(**本轮完整模式**) | 25 | **18** | 17 |
| 全仓文件(**A3 口径**,不含 `REQUEST_ID_HEADER` 项) | 25 | **17** | 17 ✔ |
| —— 基础设施(完整模式) | 4 | **5** | — |
| —— **业务 Controller** | 21 | **13** | 13 ✔ |
| —— Service / 非请求入口 | 0 | **0** | — |
| —— 误报 | 0 | **0** | — |
**数量差异的逐文件解释(不修改代码迁就数字)**:本轮模式比 A3 口径多含 `request.getHeader(RequestIdFilter.REQUEST_ID_HEADER)` 一项,该模式额外命中的**唯一文件**是 `common/logging/RequestIdAccessor.java`(第 35 行的兜底读取 `request.getHeader(RequestIdFilter.REQUEST_ID_HEADER)`)—— 这是 **Accessor 自身的合法实现**,属基础设施,非业务入口。因此:完整模式 18 = 基础设施 5 + 业务 13;A3 口径 17 = 基础设施 4 + 业务 13。**业务 Controller 数量与预期完全一致(13)**。
---
## 6. 角色与副作用验证
### 6.1 既有测试回归(角色/归属语义证据)
| 命令 | 结果 |
|---|---|
| A4 关联回归(`*CandidateFeedback*Test,*ApplicationMatch*Test,*MatchConversation*Test,*MatchRule*Test,*TalentDiscovery*Test,*CareerProgress*Test,*MatchController*Test,*TalentInvitation*Test,*RequestContext*Test,*RequestId*Test`) | **163 / 163**,F=0 E=0 S=0,BUILD SUCCESS |
既有测试覆盖:反馈仅企业可写、匹配双方可见性、会话归属、匹配规则角色、人才发现 COMPANY 权限、职业进度 STUDENT 权限、邀约企业创建/学生决定/双方读取、其他企业与其他学生的 403/404。
### 6.2 真实 HTTP 验证
只读实例 PID 7648,验证后已停止。
| 场景 | 结果 |
|---|---|
| 无 token / 畸形 Bearer / 纯数字 Bearer × 7 端点 | 全部 **401** ✔ |
| STUDENT → `GET /companies/me/match-rule` | **403** ✔ |
| COMPANY → `GET /companies/me/match-rule` | **403**(该账号无企业资料,既有权属语义)✔ |
| STUDENT → `GET /profile/me/career-progress` | **200** ✔ |
| COMPANY → `GET /profile/me/career-progress` | **200**(该账号注册时自带 STUDENT 角色,属既有语义)✔ |
| STUDENT → `GET /profile/me/talent-discovery` | **200** ✔ |
| ADMIN → `GET /profile/me/talent-discovery` | **403** ✔ |
| STUDENT → `GET /profile/me/talent-invitations` | **200** ✔ |
| COMPANY → `GET /companies/me/talent-invitations` | **403**(无企业资料)✔ |
### 6.3 副作用快照
上述**全部请求前后 22 张表逐行稳定排序的逐字段快照比对 → 全部零变化** ✔
- 覆盖表含 `candidate_feedback`、`application_match_records`、`application_match_events`、`conversations`、`conversation_members`、`company_match_rules`、`student_match_rules`、`talent_discovery_preferences`、`talent_invitations`、`notifications`、`messages`、`audit_logs` 等
- **未使用** COUNT/SUM 代替内容快照;采用显式列 + 稳定 `ORDER BY` 的逐字段比较
---
## 7. 独立验证辅助测试(实施证据)
新增 `A4AccessorWiringProbeTest`(4 例),证明接线真实存在:
| 用例 | 断言 |
|---|---|
| `accessorThrowsUnauthenticated_endpointsReturn401` | mock Accessor 抛认证异常 → 7 端点全部 **401**(若未接线则不可能) |
| `serviceReceivesAccessorUserId` | mock 返回 `4242L` → `careerProgressService.getCareerProgress(4242L, …)`、`talentInvitationService.listCompany(4242L, …)` 收到该值 |
| `requestIdComesFromAccessorBean` | mock 的 requestId 必须出现在响应体 |
| `noToken_is401` | 无 Token → Security 链 401 |
**删除 Accessor 调用会使本测试失败** ✔(属实施证据,不代替后续独立复验)
---
## 8. requestId 同源证据
| 输入 | 结果 |
|---|---|
| 合法值 `a4-legal-0001` | 响应头 == 响应体 `requestId`,原样保留 ✔ |
| 空白 `" "` | 重新生成非空值,头体同源 ✔ |
| 超长(80 字符) | 收敛至 ≤32,头体同源 ✔ |
| 非法字符 `<script>x</script>`、`a b` | 生成合法替代值;响应头不含 `<`/`>`/空格 ✔ |
| request attribute 与 header 不同 | **attribute 优先** ✔ |
| CR / LF / CRLF(直接经 Accessor) | 不进入规范化结果 ✔ |
覆盖 8 个端点(含 POST 端点)。
---
## 9. 测试数量与 OpenAPI
| 命令 | 结果 |
|---|---|
| A4 定向(`R01A4…,A4AccessorWiringProbeTest,CurrentUserAccessorTest,RequestIdAccessorTest,RequestIdFilterTest`) | **43 / 43**,F=0 E=0 **S=0** |
| A4 关联回归 | **163 / 163**,F=0 E=0 **S=0** |
| `mvn test` | **2238 / 2238**,Failures=0、Errors=0、**Skipped=0** |
| `mvn clean verify` | **2238 / 2238**,Failures=0、Errors=0、**Skipped=0**,BUILD SUCCESS(≥ 2225 ✔) |
| `python docs\verify_openapi.py` | **176 paths / 214 operations**,All checks passed |
- 较上轮(2225)新增 **13** 个测试(9 + 4)
- **`docs/openapi-rc.json` 零修改** ✔
- **V1–V45 零修改** ✔
- **未新增接口、表、索引、迁移或 ErrorCode** ✔
- **未发现新的基线测试污染**(上一轮的账号注销夹具隔离修复已生效)✔
---
## 10. 精确修改文件
**生产代码(8 个,全部在任务书指定清单内)**
```
mydreamjob-server/src/main/java/com/mydreamjob/feedback/CandidateFeedbackController.java
mydreamjob-server/src/main/java/com/mydreamjob/bidirectionalmatch/ApplicationMatchController.java
mydreamjob-server/src/main/java/com/mydreamjob/bidirectionalmatch/MatchConversationController.java
mydreamjob-server/src/main/java/com/mydreamjob/bidirectionalmatch/MatchRuleController.java
mydreamjob-server/src/main/java/com/mydreamjob/talentdiscovery/TalentDiscoveryController.java
mydreamjob-server/src/main/java/com/mydreamjob/careerprogress/CareerProgressController.java
mydreamjob-server/src/main/java/com/mydreamjob/match/MatchController.java
mydreamjob-server/src/main/java/com/mydreamjob/talentinvitation/TalentInvitationController.java
```
**测试(新增 2 个)**
```
mydreamjob-server/src/test/java/com/mydreamjob/common/r01a4/R01A4RequestContextMigrationIntegrationTest.java
mydreamjob-server/src/test/java/com/mydreamjob/common/r01a4/A4AccessorWiringProbeTest.java
```
**文档(1 个)**
```
docs/backend-development-roadmap.md # 仅更新顶部 R01 状态行,历史章节未动
```
**未修改**:Service、DTO、`ErrorCode`、Flyway、`SecurityConfig`、Filter 顺序、OpenAPI、既有测试、`UserRoleGuard`、"匿名 401 缺 requestId"观察项。
---
## 11. Git 状态
```
分支:refactor/r01-structure-performance
HEAD:35baa8416d31e71dd2c2556877f9d2e1c93b14a1(未变)
暂存区:空
git add -A / commit / push:均未执行
Docker:未操作
历史未跟踪文件:247 个完整保留
```
验收临时用户(a4vstu / a4vcom)已按精确主键清理,`users` 残留 0;`audit_logs` 4 行 append-only 保留并登记。
---
## 12. 明确声明
- **未暂存、未提交、未推送。**
- 未进入 `UserRoleGuard`;未处理"匿名 401 缺 requestId"观察项。
- 未使用 `@Autowired(required=false)`、`@Nullable`、`ObjectProvider` 或 `new Accessor()` 回退;测试切片使用真实 `SecurityFilterChain` 与真实 Accessor Bean。
- 全仓剩余身份/requestId 数量已**逐文件分类解释**,未修改代码迁就数字。
---
> **R01-A4第四批请求身份与requestId收束完成,待独立代码复验。**

279
docs/r01-a5-independent-verification-20260917.md

@ -0,0 +1,279 @@
# R01-A5 独立代码复验报告(第五批:8 个 Controller 请求身份与 requestId 收束)
日期:2026-09-17
分支:`refactor/r01-structure-performance`
HEAD:`0226977063243fce101e3f6c8dcf66492d306ef7`(A4 后最新提交,复验全程未变)
复验对象:`docs/r01-a5-request-context-migration-report.md`
性质:**仅复验** —— 未修改生产代码、未修改既有测试断言、未暂存、未提交、未推送
---
## 0. 结论
> **R01-A5独立代码复验通过,可以进入精确暂存、本地提交与推送检查点。**
**FAIL = 0;SKIP = 0。未发现生产缺陷。**
---
## 1. 开始前状态
| 检查项 | 结果 |
|---|---|
| 分支 | `refactor/r01-structure-performance` ✔ |
| HEAD | `0226977063243fce101e3f6c8dcf66492d306ef7`(A4 后最新)✔ |
| 与上游同步 | `0 / 0` ✔ |
| 暂存区 | 空 ✔ |
| `git diff --check` | 无空白错误 ✔ |
| 工作区改动 | **仅 8 个 A5 Controller + 路线图**,无 A5 之外的生产代码改动 ✔ |
| 历史未跟踪文件 | 全部保留 ✔ |
| reset/restore/checkout/clean、`git add -A`、Docker | 均未执行 ✔ |
### `GlobalExceptionHandler.java` 三方 blob 比对
| 来源 | blob |
|---|---|
| worktree(`git hash-object`) | `1fdc341ebe3653393b3ec04f0c2d0bb9dfbb6d21` |
| index(`git ls-files -s`) | `1fdc341ebe3653393b3ec04f0c2d0bb9dfbb6d21` |
| HEAD(`git rev-parse HEAD:<path>`) | `1fdc341ebe3653393b3ec04f0c2d0bb9dfbb6d21` |
**三者完全一致 → 纯 Windows stat 缓存现象,未触碰该文件** ✔
---
## 2. 8 个 Controller 静态审查
| Controller | 代表端点 | Accessor 调用(auth / reqId) |
|---|---|---|
| CandidateScoringGovernanceController | `GET /profile/me/candidate-scoring/appeals` | 9 / 9 |
| InterviewScoreController | `GET /interviews/{id}/score` | 4 / 4 |
| FavoriteController | `GET /jobs/favorites` | 3 / 3 |
| AdminReviewController | `GET /admin/reviews/pending` | 3 / 3 |
| AdminReviewReplyController | `GET /admin/review-replies/pending` | 2 / 2 |
| CompanyReviewManagementController | `GET /companies/me/job-reviews` | 4 / 4 |
| JobReviewController | `DELETE /jobs/{jobId}/reviews/{reviewId}` | 3 / 7 |
| StudentReviewWeightController | `GET /profile/me/review-weights` | 3 / 4 |
(`JobReviewController` / `StudentReviewWeightController` 的 reqId 调用数高于 auth 属正常 —— 部分只读端点只需 requestId。)
**改动性质**:`8 files changed, 101 insertions(+), 198 deletions(-)`,删除的 `ErrorCode.UNAUTHORIZED` 共 10 处,**全部来自被移除的私有解析方法**;**角色检查(`assertAdminRole`/`assertCompanyOwner`/`assertStudentRole`)、资源归属、`FORBIDDEN`/`NOT_FOUND` 及 Service 调用顺序未删未增、未移动** ✔
**`JobReviewController.parseIds` 完整保留** ✔ —— 它解析 `ids` 查询参数(业务参数,非认证主体),并以 `catch (NumberFormatException) → BAD_REQUEST` 处理,属正确的业务参数语义,未被误删或误判。
---
## 3. 禁止模式扫描
任务书指定模式在 8 个文件中的命中:**全部为 0** ✔
覆盖:`SecurityContextHolder`、`AnonymousAuthenticationToken`、`anonymousUser`、`auth.getName()`、`authentication.getName()`、`Long.(valueOf|parseLong)(...(auth|authentication)`、`private (resolveUserId|getRequestId|requestId)(`、`getHeader(...X-Request-Id|REQUEST_ID_HEADER)`、`@Autowired(required=false)`、`@Nullable`、`ObjectProvider`、`new CurrentUserAccessor`、`new RequestIdAccessor`
---
## 4. 构造器注入与无回退证明
8 个 Controller 均声明:
```java
private final CurrentUserAccessor currentUserAccessor;
private final RequestIdAccessor requestIdAccessor;
```
- 全部为 **`private final` 构造器注入**(Lombok `@RequiredArgsConstructor`)✔
- **无** `@Autowired(required = false)` / `@Nullable` / `ObjectProvider` / `new CurrentUserAccessor()` / `new RequestIdAccessor()` 回退 ✔
- 身份统一为 `currentUserAccessor.requireUserId()`;requestId 统一为 `requestIdAccessor.get(request)` ✔
---
## 5. 独立接线变异测试(Accessor 真实生效的证明)
新增 `A5AccessorWiringIndependentTest`(4 例),**不复用实施者断言**,使用 `@MockBean` 替换 `CurrentUserAccessor`、`RequestIdAccessor` 与 8 个 Service:
| 用例 | 证明 |
|---|---|
| `allEightEndpoints_useAccessorUserId` | mock 返回 `4242L` → 逐一验证 **8 个 Service 均收到 `4242L`**(`listMyAppeals` / `getScore` / `listFavorites` / `listPendingReviews` / `listPending` / `listMine` / `deleteReview` / `getMyWeights`),并 `verify(currentUserAccessor, times(8)).requireUserId()` |
| `illegalIdentity_rejectedBeforeService` | mock 抛 `InsufficientAuthenticationException` → 8 端点全部 401、无 500,且各 Service **零调用** |
| `responseBodyRequestId_comesFromAccessor` | mock 返回 `a5-independent-request` → 8 个端点响应体 `requestId` 均为该值,`verify(requestIdAccessor, times(8)).get(...)` |
| `noToken_is401` | 无 Token → Security 链 401 |
**变异有效性**:若删除任一 Controller 中的 `currentUserAccessor.requireUserId()` 或 `requestIdAccessor.get(request)` 调用,对应 `verify` 与断言必然失败 —— 即**删除 Accessor 调用会使本测试失败** ✔
> 说明:响应头 `X-Request-Id` 由 `RequestIdFilter` 设置,**不来自 Controller**;本测试只断言**响应体**的 `requestId` 来源于 Accessor,未错误声称响应头来源。
---
## 6. 八端点非法主体结果(独立身份边界复验)
`A5IndependentVerificationTest` 使用**真实 `CurrentUserAccessor` 与真实 `SecurityFilterChain`**(不 mock Accessor):
| 主体 | 结果 |
|---|---|
| `anonymousUser` | **401** ✔ |
| `not-a-number` | **401** ✔ |
| `99999999999999999999`(20 位溢出) | **401** ✔ |
| `0` | **401** ✔ |
| `-1` | **401** ✔ |
| `AnonymousAuthenticationToken` / 无 Token | **401** ✔ |
逐端点断言(7 个 GET 端点 + `DELETE /jobs/1/reviews/1`):
- **HTTP 401,无 500** ✔
- **业务 Service 零调用** ✔(`verify(..., never())`)
- **不回显非法主体** —— 仅解析 JSON 后检查 `code`/`message` 稳定字段,**未对整段 JSON 做数字搜索** ✔
- 响应不包含 `Exception` / `java.` / `principal` / `anonymousUser` / `com.mydreamjob` / `NumberFormat` ✔
**未使用**任何捕获 `NumberFormatException` 的方式"修复"测试。
---
## 7. 合法主体传入 Service 的 userId 证据
§5 的 `verify` 系列即为证据:mock Accessor 返回 `4242L` 时,**8 个 Service 各自收到 `4242L`**(逐一 `eq(4242L)` 断言)✔
同时 `A5IndependentVerificationTest#legalPrincipal_reachesService` 使用**真实 Accessor**(数字主体 `"7"`)验证 8 端点**不被身份层判为未登录**(非 401)✔
---
## 8. requestId Accessor 接线证据
见 §5 第 3 项:mock `RequestIdAccessor#get` 返回固定值 → 8 个端点(7 个 GET + 1 个 DELETE,均返回 `ApiResponse`)响应体 `requestId` 均为 `a5-independent-request`,并 `verify(requestIdAccessor, times(8)).get(any(HttpServletRequest.class))` ✔
---
## 9. 401 / 403 / 404 分层保持
| 检查项 | 结果 |
|---|---|
| 静态审查:角色/归属检查是否被删改或移动 | **否**(`git diff` 确认未删未增)✔ |
| `FORBIDDEN` / `NOT_FOUND` 错误码 | **未修改** ✔ |
| `ErrorCode.java` | **零改动** ✔ |
| 合法主体不被判为未登录 | ✔(§7) |
**401 与 403/404 的分层未被统一**:身份失败为 401,角色/归属失败仍由既有 Service 逻辑产生 403/404,语义未漂移。
---
## 10. 失败请求的业务副作用
新增 `A5SideEffectSnapshotTest`(2 例),使用 H2 **行级快照**(显式列 + 稳定 `ORDER BY`,**逐字段**比较,**非 COUNT/SUM**):
快照覆盖 9 张核心表:`JOB_FAVORITES`、`JOB_REVIEWS`、`COMPANY_REVIEW_REPLIES`、`INTERVIEW_SCORES`、`CANDIDATE_SCORING_APPEALS`、`STUDENT_REVIEW_WEIGHT_PREFERENCES`、`NOTIFICATIONS`、`MESSAGES`、`AUDIT_LOGS`。
| 用例 | 结果 |
|---|---|
| 非法身份请求(`anonymousUser` + `not-a-number` × 7 端点 + DELETE)前后 | **9 张表逐字段零变化** ✔ |
| 无 Token 请求前后 | **9 张表逐字段零变化** ✔ |
**结论**:非法身份请求不写收藏、评价、回复、面试评分、治理申诉、权重;不发通知;不发 RabbitMQ 消息;不写审计成功记录(`AUDIT_LOGS` 零变化)✔
> 本轮**未启动 Docker**,以上为 H2 独立复验结论;**不声称真实 MySQL 结论**。
---
## 11. 测试数
| 命令 | 结果 |
|---|---|
| `mvn -Dtest="A5AccessorWiringIndependentTest,A5IndependentVerificationTest" test` | **8 / 8**,F=0 E=0 **S=0** |
| `mvn -Dtest="A5SideEffectSnapshotTest" test` | **2 / 2**,F=0 E=0 **S=0** |
| A5 定向(`R01A5…,A5*Test,` 8 个 Controller 测试 + 3 个 Accessor/Filter 测试) | **98 / 98**,F=0 E=0 **S=0**,BUILD SUCCESS |
| `mvn clean verify` | **2255 / 2255**,Failures=0、Errors=0、**Skipped=0**,BUILD SUCCESS |
- 实施基线 2245 → 复验后 **2255**(新增 **10** 个独立测试:4 + 4 + 2),**不低于基线** ✔
- 未见新的基线测试污染 ✔
---
## 12. OpenAPI
`python docs\verify_openapi.py` → **176 paths / 214 operations**,All checks passed ✔
**`docs/openapi-rc.json` 零修改** ✔
---
## 13. Flyway / ErrorCode / DTO / 接口
| 项 | 结果 |
|---|---|
| V1–V45 迁移 | **零修改**(`git diff --name-only -- db/migration` 为空)✔ |
| `ErrorCode.java` | **零改动** ✔ |
| DTO | 零改动 ✔ |
| 公网接口 / OpenAPI | 零新增零修改 ✔ |
| 新增表 / 索引 | 无 ✔ |
---
## 14. 全仓剩余统计(逐文件分类)
**身份解析**(`SecurityContextHolder.getContext().getAuthentication` \| `Long.(valueOf|parseLong)([^)]*getName()`)—— **4 文件**
| 分类 | 文件 |
|---|---|
| 基础设施(3) | `security/CurrentUserAccessor`(Accessor 自身)、`auth/JwtAuthenticationFilter`(认证过滤器)、`deletion/DeletionGuardInterceptor`(拦截器) |
| **业务 Controller(1)** | `scoringmonitoring/FairnessMonitoringController` |
| 误报 | 0 |
**requestId 解析**(含 `X-Request-Id` 直读、`RequestIdFilter.REQUEST_ID_HEADER`、`String (getRequestId|resolveRequestId|requestId)(`)—— **10 文件**
| 分类 | 文件 |
|---|---|
| 基础设施 / 非业务入口(5) | `audit/AuditContext`、`common/exception/GlobalExceptionHandler`、`common/logging/RequestIdAccessor`、`common/logging/RequestLoggingFilter`、`deletion/DeletionGuardInterceptor` |
| **业务 Controller(5)** | `controller/ApiController`、`controller/AuthController`、`controller/SystemController`、`job/JobController`、`scoringmonitoring/FairnessMonitoringController` |
| 误报 | 0 |
**与路线图记数的一处差异(如实记录)**:路线图 R01 行记「全仓 requestId 11 文件 = 基础设施/非业务入口 6 + 业务 5」。本次独立复验用任务书指定模式实测为 **10 文件 = 基础设施 5 + 业务 5**。**业务 Controller 数一致(5)**;差异在基础设施计数(5 vs 6),推测为路线图对"基础设施/非业务入口"的分类口径略有不同(如是否计入某调用点)。**不应据此修改代码或路线图**;建议后续批次统一口径。
---
## 15. 精确新增文件
```
新增测试(3)
mydreamjob-server/src/test/java/com/mydreamjob/common/r01a5/A5AccessorWiringIndependentTest.java
mydreamjob-server/src/test/java/com/mydreamjob/common/r01a5/A5IndependentVerificationTest.java
mydreamjob-server/src/test/java/com/mydreamjob/common/r01a5/A5SideEffectSnapshotTest.java
复验报告(1)
docs/r01-a5-independent-verification-20260917.md
```
**未修改**:8 个 A5 Controller 生产代码、既有测试断言、`GlobalExceptionHandler`、`SecurityConfig`、Filter、Accessor、`ErrorCode`、DTO、OpenAPI、Flyway。
---
## 16. Git 状态
```
分支:refactor/r01-structure-performance
HEAD:0226977063243fce101e3f6c8dcf66492d306ef7(未变)
暂存区:空
git add -A / commit / push:均未执行
reset / restore / checkout / clean:均未执行
Docker:未操作
工作区:8 个 A5 Controller + 路线图(实施者改动,仍未暂存)+ 本报告与 3 个独立测试(未跟踪)
历史未跟踪文件:全部保留
```
---
## 17. 是否发现生产缺陷
**未发现生产缺陷。** 8 个 Controller 的迁移完整、正确:
- 禁止模式 0 命中;构造器注入无回退;`parseIds` 业务解析正确保留
- 角色/归属/403/404 语义未漂移
- 独立接线变异测试证明 Accessor 真实生效(删除调用即失败)
- 独立身份边界测试证明非法主体在业务调用前被拒(401、无 500、零 Service 调用)且不回显主体
- 失败请求 9 张核心表逐字段零变化
---
## 18. 明确声明
- **未暂存、未提交、未推送。**
- 未修改 8 个 Controller 或任何生产代码;未修改、删除或放宽既有测试断言;未修改 `GlobalExceptionHandler`、`SecurityConfig`、Filter、Accessor、`ErrorCode`、DTO、OpenAPI、Flyway。
- 未进入 R01-A6;未删除历史未跟踪文件。
- 本轮未启动 Docker,**未伪造真实 MySQL 结论**。
---
> **R01-A5独立代码复验通过,可以进入精确暂存、本地提交与推送检查点。**

79
docs/r01-a5-request-context-migration-report.md

@ -0,0 +1,79 @@
# R01-A5 第五批请求上下文收束实施报告
实施日期:2026-09-17
分支:`refactor/r01-structure-performance`
基线 HEAD:`0226977063243fce101e3f6c8dcf66492d306ef7`
## 结论
R01-A5 第五批请求身份与 requestId 收束已完成自动化验证,待独立代码复验。
## 修改范围
本批仅修改以下 8 个 Controller:
1. `CandidateScoringGovernanceController`
2. `InterviewScoreController`
3. `FavoriteController`
4. `AdminReviewController`
5. `AdminReviewReplyController`
6. `CompanyReviewManagementController`
7. `JobReviewController`
8. `StudentReviewWeightController`
每个 Controller 均执行相同的受控迁移:
- 构造器注入既有 `CurrentUserAccessor` 与 `RequestIdAccessor`;
- 身份统一调用 `currentUserAccessor.requireUserId()`;
- requestId 统一调用 `requestIdAccessor.get(request)`;
- 删除本地身份解析方法和 requestId 读取方法;
- 保留原有角色、资源归属、Service 参数、DTO 和响应结构。
`JobReviewController.parseIds` 是岗位 ID 业务参数解析,予以保留。
## 静态门禁
8 个目标文件对以下模式均为零命中:
- `SecurityContextHolder` / `Authentication` / `anonymousUser`;
- 身份 `Long.valueOf(auth.getName())`;
- `resolveUserId` / `getRequestId` / 私有 `requestId(...)`;
- 原始 `X-Request-Id` header 读取;
- 可选注入、`ObjectProvider` 或手工 `new Accessor()` 回退。
## 新增验证
新增 `R01A5RequestContextMigrationIntegrationTest`,使用真实 Spring Security 与 MVC 映射:
- 8 个 Controller 各选一个真实端点;
- 非法主体覆盖 `anonymousUser`、非数字、20 位 Long 溢出、0、负数;
- 所有组合必须返回 HTTP 401,且响应不含异常类、堆栈或非法主体;
- 合法主体下验证规范化 requestId 的响应头与响应体同源。
定向与关联测试:90/90,Failures=0,Errors=0,Skipped=0。
## 全量验证
- `mvn clean verify`:BUILD SUCCESS,2245/2245,Failures=0,Errors=0,Skipped=0;
- `python docs/verify_openapi.py`:176 paths / 214 operations,All checks passed;
- V1-V45 无差异;
- `docs/openapi-rc.json` 无差异;
- `git diff --check` 无空白错误,仅既有 LF/CRLF 提示。
## 全仓剩余
完成 A5 后:
- 身份解析共 4 个文件:基础设施 3 个,业务 Controller 仅 `FairnessMonitoringController` 1 个;
- requestId 入口共 11 个文件:基础设施/非业务入口 6 个,业务 Controller 5 个;
- 最终 A6 业务白名单为 `FairnessMonitoringController`、`ApiController`、`AuthController`、`SystemController`、`JobController`。
## 边界
- 未新增或修改 Flyway、ErrorCode、接口、DTO、表、索引或依赖;
- 未修改评分、申诉、面试评分、岗位收藏或评价业务规则;
- 未暂存、未提交、未推送;
- 未删除或移动历史未跟踪文件;
- 未进入 A6。

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 单跑夹具缺陷已通过独立纯测试提交修复;
> 该测试不再依赖种子数据状态与测试执行顺序。**

359
docs/r01-a6-final-request-context-migration-report.md

@ -0,0 +1,359 @@
# R01-A6 最终业务 Controller 请求身份与 requestId 收束报告
日期:2026-09-17
分支:`refactor/r01-structure-performance`
A6 基线 HEAD:`534afe686b966f175bcaf4af0726cec061092050`(A5 提交)
本轮性质:**实施与自动化验证** —— 未提交、未推送,待独立代码复验
---
## 0. 结论
> **R01-A6最终业务Controller请求上下文收束完成,待独立代码复验;A系列到此结束,不再拆分A7/A8。**
**业务 Controller 身份解析残留 = 0;业务 Controller requestId 重复解析残留 = 0。** 剩余命中全部为基础设施职责。
---
## 1. 开始时状态
### 1.1 A5 固化(第一阶段)
A5 此前**未提交**,本轮按已确认清单精确固化:
| 项 | 结果 |
|---|---|
| 暂存文件数 | **15**(8 生产 Controller + 4 测试 + 2 报告 + 1 路线图) |
| `git add` 方式 | **逐文件 `git add --`**;未使用 `git add -A` / `git add .` |
| 路线图 | `git diff --numstat` 为 `1 1`(**仅 R01 行一处 hunk**),无需 `git add -p` |
| 排除项 | `GlobalExceptionHandler.java`、`db/migration`、`docs/acceptance`、`backend-infra`、A2–A4 文件 **全部排除** ✔ |
| `git diff --cached --check` | 无输出 ✔ |
| 敏感扫描 | 1050 新增行,12 类(手机号/JWT/Bearer/验证码/口令/密钥/私钥/AWS/64位摘要/本机路径/UUID/连接串)**全部命中 0** ✔ |
| A5 定向 | **42 / 42**,F=0 E=0 S=0 |
| `mvn clean verify` | **2255 / 2255**,F=0 E=0 **S=0**,BUILD SUCCESS |
| OpenAPI | **176 / 214**,All checks passed |
| 提交 | `refactor: centralize fifth batch request context handling` |
| **A5 提交 hash** | **`534afe686b966f175bcaf4af0726cec061092050`** |
| 推送 | `0226977..534afe6`;**未 force push** |
| 推送后同步 | **0 / 0** ✔ |
### 1.2 A6 开始前核查(第二阶段)
| 检查项 | 结果 |
|---|---|
| 分支 | `refactor/r01-structure-performance` ✔ |
| 新 HEAD | `534afe686b966f175bcaf4af0726cec061092050` ✔ |
| 暂存区 | 空 ✔ |
| `git diff --check` | 无空白错误 ✔ |
| **A5 未提交残余** | **无**(工作区仅 `GlobalExceptionHandler.java` 的 stat 缓存标记,blob 三方一致)✔ |
| 历史未跟踪文件 | 全部保留 ✔ |
| Docker / R01-B·C·E | 未触碰 ✔ |
**A6 基线测试数:2255。**
---
## 2. 五个 Controller 的修改内容
### 2.1 迁移前契约
| Controller | 真实代表端点 | 身份解析 | requestId 解析 |
|---|---|---|---|
| `controller/ApiController` | `GET /` | 无 | `private String getRequestId(HttpServletRequest)` → `request.getHeader("X-Request-Id")` |
| `controller/AuthController` | `POST /auth/login`、`GET /auth/me` 等 | **已有 `CurrentUserAccessor`** ✓ | `private String getRequestId(...)` |
| `controller/SystemController` | `GET /health`、`GET /info` | 无 | `private String getRequestId(...)` |
| `job/JobController` | `GET /jobs`、`POST /jobs` 等 | **已有 `CurrentUserAccessor`** ✓ | `private String getRequestId(...)` |
| `scoringmonitoring/FairnessMonitoringController` | `GET /admin/scoring/fairness/*` | **需迁移**:`private Long userId()` 直读 `SecurityContextHolder` + `Long.valueOf(auth.getName())` | `private String requestId(HttpServletRequest)` 单行直读 |
### 2.2 修改后
| Controller | 注入 | `requireUserId()` | `requestIdAccessor.get(request)` |
|---|---|---|---|
| ApiController | 新增 `RequestIdAccessor`(并补 `@RequiredArgsConstructor`) | 0 | 1 |
| AuthController | 新增 `RequestIdAccessor`(`CurrentUserAccessor` 既有) | 2(既有) | 14 |
| SystemController | 新增 `RequestIdAccessor`(并补 `@RequiredArgsConstructor`) | 0 | 2 |
| JobController | 新增 `RequestIdAccessor`(`CurrentUserAccessor` 既有) | 1(既有) | 8 |
| FairnessMonitoringController | 新增 `CurrentUserAccessor` + `RequestIdAccessor` | 5 | 5 |
**`JobController` 的身份迁移未被重复改造** ✔ —— 它此前已使用 `CurrentUserAccessor`,本批只迁移 requestId。
**`AuthController` 无重复身份解析** ✔ —— 它此前已使用 `CurrentUserAccessor`,本批只迁移 requestId。
### 2.3 删除的私有方法(共 6 个)
| Controller | 删除 |
|---|---|
| ApiController | `getRequestId` ×1 |
| AuthController | `getRequestId` ×1 |
| SystemController | `getRequestId` ×1 |
| JobController | `getRequestId` ×1 |
| FairnessMonitoringController | `userId` ×1、`requestId` ×1 |
| **合计** | **6** |
> **计数澄清(独立复验补充)**:曾出现"`5 × getRequestId + 1 × userId + 1 × requestId`"的表述,其算术结果为 7,**与真实 diff 不符,该表达式不成立**。经逐文件 `git diff` 核对:`getRequestId` 实际只有 **4 个**(ApiController / AuthController / SystemController / JobController);`FairnessMonitoringController` 的 requestId 方法签名是 **`requestId(HttpServletRequest)`(无 `get` 前缀)**,因此**不能被计入 `getRequestId`**。正确分解为 **4 × getRequestId + 1 × userId + 1 × requestId = 6**,与本表逐文件清单完全一致。**未为迎合数字修改任何生产代码。**
**保留**:`FairnessMonitoringController.parseDate`(业务参数解析)、`checkParams`(业务参数校验)✔ 未被误删。
**新增注解**:`ApiController` 与 `SystemController` 原本无依赖、缺 `@RequiredArgsConstructor`,为支持构造器注入补齐该注解与 import(**唯一的新增注解**,未引入 `@Autowired`)。
**未修改**:`RequestIdFilter`、`RequestIdAccessor`、`RequestLoggingFilter`、`AuditContext`、`GlobalExceptionHandler`、`SecurityConfig`、Filter 顺序 ✔
---
## 3. 修复前失败证据
新增 `mydreamjob-server/src/test/java/com/mydreamjob/common/r01a6/` 下两个测试类。
**修复前实测:`Tests run: 10, Failures: 5`**
| 失败测试 | 修复前实际 |
|---|---|
| `A6AccessorWiringProbeTest.requestIdComesFromAccessorAcrossFiveControllers` | `path=/` 响应体 `requestId` **不是** Accessor 返回值(Controller 直读 header) |
| `A6AccessorWiringProbeTest.fairnessMonitoringUsesCurrentUserAccessor` | `FairnessMonitoringService.drift(...)` **未被调用**(身份未走 Accessor) |
| `A6AccessorWiringProbeTest.accessorThrowsUnauthenticated_fairnessReturns401` | 不成立 |
| `R01A6RequestContextMigrationIntegrationTest.fairnessMonitoring_illegalPrincipals_are401` | `principal='anonymousUser'` → **`expected: 401 but was: 400`** |
| `R01A6RequestContextMigrationIntegrationTest.fairnessMonitoring_illegalPrincipals_produceNoServiceCall` | 不成立 |
**关于 400 而非 500(如实记录)**:`FairnessMonitoringController.userId()` 带有
`try { Long.valueOf(auth.getName()); } catch (NumberFormatException) { throw BusinessException(UNAUTHORIZED); }`,
因此其**既有行为本身是安全的(未逃逸为 500)**,但抛出的 `BusinessException(ErrorCode.UNAUTHORIZED=2001)`
不在 `GlobalExceptionHandler.BUSINESS_HTTP_STATUSES` 映射中,`resolveHttpStatus` 的
`getOrDefault(2001, BAD_REQUEST)` 使其落到 **HTTP 400**。这正是本批收束的真实语义缺陷:
**非法主体应返回 401 而非 400**。接线探针则因"未调用 Accessor"而失败 —— **未编造 500**。
**修复后:`Tests run: 10, Failures: 0`**
---
## 4. 角色与资源归属语义
| 核查项 | 结论 |
|---|---|
| 静态审查:删除行中是否含角色/归属检查 | **否** ✔ |
| 静态审查:新增行中是否引入角色/归属逻辑 | **否** ✔ |
| `ErrorCode.java` | **零改动** ✔ |
| ADMIN / COMPANY / STUDENT 角色检查 | 未删除、未移动 ✔ |
| 企业归属 / 资源归属 / 403 / 404 分支 | 未删除、未移动 ✔ |
| 监控数据访问权限(ADMIN 只读聚合) | 未改变 ✔ |
**分层保持**:身份失败 → **401**;角色不足 → **403**(由 Service 层或既有逻辑产生);资源不存在 → **404**。
---
## 5. 独立接线探针
`A6AccessorWiringProbeTest`(3 例)使用 `@MockBean` 替换 `CurrentUserAccessor`、`RequestIdAccessor` 与对应 Service:
| 用例 | 证明 |
|---|---|
| `requestIdComesFromAccessorAcrossFiveControllers` | mock `RequestIdAccessor` 返回 `a6-probe-request-id` → **5 个 Controller 的响应体 requestId 均为该值**(修复前为直读 header 的 null) |
| `fairnessMonitoringUsesCurrentUserAccessor` | mock `CurrentUserAccessor` 返回 `5151L` → `fairnessMonitoringService.drift(5151L)` 被调用 |
| `accessorThrowsUnauthenticated_fairnessReturns401` | mock 抛 `InsufficientAuthenticationException` → FairnessMonitoring 返回 **401**,Service **零调用** |
**变异有效性**:删除任一 Controller 中的 Accessor 调用会使对应 `verify` 失败 ✔
---
## 6. 非法 principal 的结果
`R01A6RequestContextMigrationIntegrationTest` 对 `GET /admin/scoring/fairness/drift` 覆盖:
| 主体 | 结果 |
|---|---|
| `anonymousUser` | **401** ✔ |
| `not-a-number` | **401** ✔ |
| `99999999999999999999`(20 位溢出) | **401** ✔ |
| `0` | **401** ✔ |
| `-1` | **401** ✔ |
| 匿名 token / 无 Token | **401** ✔ |
| 合法数字主体 `"7"` | 不被身份层拒绝(非 401)✔ |
断言:**401 且无 500**;响应体仅解析 `code`/`message` 稳定字段,**不对整段 JSON 做数字搜索**;不含 `Exception`/`java.`/`principal`/`anonymousUser`/`com.mydreamjob`/`NumberFormat`;无 `stackTrace`/`trace`;**业务 Service 零调用** ✔
---
## 7. requestId 规范化
覆盖 5 个 Controller 的真实端点(`/`、`/health`、`/auth/me`、`/jobs`、`/admin/scoring/fairness/drift`):
| 输入 | 结果 |
|---|---|
| 合法值 `a6-legal-0001` | 响应头 == 响应体,原样保留 ✔ |
| 空白 `" "` | 重新生成,头体同源 ✔ |
| 超长(80 字符) | 收敛至 ≤32,头体同源 ✔ |
| 非法字符 `<script>x</script>`、`a b` | 生成合法替代值,响应头不含 `<`/`>`/空格 ✔ |
| request attribute 与 header 不同 | **attribute 优先** ✔ |
| CR / LF / CRLF(直接经 Accessor) | 不进入规范化结果 ✔ |
---
## 8. 全仓剩余命中逐文件分类
**身份解析**(`SecurityContextHolder.getContext().getAuthentication` \| `Long.(valueOf|parseLong)([^)]*getName()`)—— **3 文件**
| 文件 | 分类 |
|---|---|
| `security/CurrentUserAccessor` | 基础设施(Accessor 自身) |
| `auth/JwtAuthenticationFilter` | 基础设施(认证过滤器) |
| `deletion/DeletionGuardInterceptor` | 基础设施(拦截器) |
**requestId 解析**(含 `X-Request-Id` 直读、`RequestIdFilter.REQUEST_ID_HEADER`、`String (getRequestId|resolveRequestId|requestId)(`)—— **5 文件**
| 文件 | 分类 |
|---|---|
| `audit/AuditContext` | 基础设施 |
| `common/exception/GlobalExceptionHandler` | 基础设施 |
| `common/logging/RequestIdAccessor` | 基础设施(Accessor 自身) |
| `common/logging/RequestLoggingFilter` | 基础设施 |
| `deletion/DeletionGuardInterceptor` | 基础设施 |
**全部落在任务书允许的基础设施清单内**(`CurrentUserAccessor`、`JwtAuthenticationFilter`、`DeletionGuardInterceptor`、`RequestIdAccessor`、`RequestIdFilter`、`RequestLoggingFilter`、`AuditContext`、`GlobalExceptionHandler`)✔
**未为"全仓零命中"修改任何基础设施类** ✔
**未在 A6 清单内的业务 Controller 残留:0** ✔ —— 无需越界修改。
---
## 9. 业务 Controller 残留
| 项 | 结果 |
|---|---|
| 业务 Controller 身份解析 | **0** ✔ |
| 业务 Controller requestId 重复解析 | **0** ✔ |
**A 系列请求上下文收束达成目标终态。A7/A8 不再设置。**
---
## 10. 测试数
| 命令 | 结果 |
|---|---|
| A6 定向(`R01A6…,A6AccessorWiringProbeTest,CurrentUserAccessorTest,RequestIdAccessorTest,RequestIdFilterTest`) | **40 / 40**,F=0 E=0 **S=0** |
| A6 关联(`*ApiController*Test,*AuthController*Test,*SystemController*Test,*JobController*Test,*FairnessMonitoring*Test,*Security*Test,*RequestId*Test`) | **76 / 76**(1 项既有夹具脆弱性,见 §10.1,**全量顺序下不触发**) |
| `mvn test` | **2265 / 2265**,Failures=0、Errors=0、**Skipped=0** |
| `mvn clean verify` | **2265 / 2265**,Failures=0、Errors=0、**Skipped=0**,BUILD SUCCESS |
A5 基线 2255 → A6 **2265**(新增 **10** 个测试:7 + 3),**不低于基线** ✔
### 10.1 基线已存在的测试夹具脆弱性(未在本批修复)
A6 关联回归单独运行时,**既有** `FairnessMonitoringControllerTest` 出现 6 个失败(`200/403/400 → 401`),根因为:
```
Caused by: AuthSessionService - session validation rejected: user not usable
JwtAuthenticationFilter - JWT authentication rejected: session not active
```
**归因验证(任务书要求)**:在 `git worktree add --detach HEAD`(**不含 A6 改动**)中运行**完全相同**的命令:
| | 测试数 | 失败 | 根因 |
|---|---|---|---|
| 基线(HEAD) | **7** | **6** | `user not usable` |
| 本次(含 A6) | **7** | **6** | **完全相同** |
**实施阶段初步判断(后被独立复验否定,保留于此以存档过程)**:当时认为
`TestAuthTokens` 夹具插入 `users` 行时**不写 `user_roles`**,导致该用户被判 "not usable"。
**该判断不成立**:`AuthSessionService.isUsable(User)` 只检查用户存在且 `is_active = true`,
`UserRepository.findById` 无角色过滤,该路径**从不查询 `user_roles`**。
**独立复验结论(准确根因)**:见 `docs/r01-a6-independent-verification-20260917.md` §9。
| 环节 | 事实 |
|---|---|
| 身份冲突 | `FairnessMonitoringControllerTest` 使用 `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;**对已存在但 `is_active = 0` 的行不做激活**,永不修复其启用状态 |
| 直接后果 | `AuthSessionService.isSessionActive()` 第一步 `isUserUsable()` 返回 false 并短路,请求在进入 Controller 前即 401 |
| 全量为何通过 | 全量顺序中 `com.mydreamjob.controller.AuthIntegrationTest` 的 `@BeforeEach` 执行 `userRepository.deleteAll()`,**偶然清除**了 V43 已禁用的种子行,夹具随后才补建出 active 用户 —— 问题被掩盖,而非被修复 |
**与 A6 生产改动无关**:401 由 `JwtAuthenticationFilter` / `AuthSessionService` 在请求进入
Controller 之前产生(`Handler: Type = null`),A6 只改了 5 个 Controller 内部取身份 / 取
requestId 的写法,该路径上一行 A6 代码都未执行。独立复验的决定性对照实验:同一份含 A6 的
代码,单跑 Fairness 为 7 run / 6 failures,而让 `AuthIntegrationTest` 先跑则为 **7 run / 0 failures**。
`mvn clean verify`(全量顺序)下 **2278/2278 全通过**,该失败不触发。
按任务书要求:**未把失败写成通过,未在本批顺手修复**;该夹具问题将在 A6 提交后
**以独立纯测试提交修复**(不与本批生产改动混合)。
---
## 11. OpenAPI
`python docs\verify_openapi.py` → **176 paths / 214 operations**,All checks passed ✔
**`docs/openapi-rc.json` 零修改** ✔
---
## 12. 精确修改文件
**生产代码(5 个,全部在 A6 指定清单内)**
```
mydreamjob-server/src/main/java/com/mydreamjob/controller/ApiController.java
mydreamjob-server/src/main/java/com/mydreamjob/controller/AuthController.java
mydreamjob-server/src/main/java/com/mydreamjob/controller/SystemController.java
mydreamjob-server/src/main/java/com/mydreamjob/job/JobController.java
mydreamjob-server/src/main/java/com/mydreamjob/scoringmonitoring/FairnessMonitoringController.java
```
**测试(新增 2 个)**
```
mydreamjob-server/src/test/java/com/mydreamjob/common/r01a6/R01A6RequestContextMigrationIntegrationTest.java
mydreamjob-server/src/test/java/com/mydreamjob/common/r01a6/A6AccessorWiringProbeTest.java
```
**文档(2 个)**
```
docs/r01-a6-final-request-context-migration-report.md # 本报告
docs/backend-development-roadmap.md # 仅更新顶部 R01 状态行
```
**未修改**:Service、DTO、`ErrorCode`、Flyway、`SecurityConfig`、Filter、Accessor、OpenAPI、既有测试、`UserRoleGuard`。
---
## 13. Flyway / ErrorCode / DTO / 接口
| 项 | 结果 |
|---|---|
| V1–V45 迁移 | **零修改** ✔ |
| `ErrorCode.java` | **零改动** ✔ |
| DTO | **零改动** ✔ |
| 公网接口 / OpenAPI | **零新增零修改** ✔ |
| 新增表 / 索引 / 迁移 | **无** ✔ |
---
## 14. Git 状态
```
git diff --check → 无输出
git status --short → 8 个生产/文档改动 + GlobalExceptionHandler(stat 缓存标记)
git diff --cached --name-only → 空
git diff --name-only -- db/migration → 空
git diff -- docs/openapi-rc.json → 空
```
- `GlobalExceptionHandler.java`:工作区 / 索引 / HEAD 三方 blob 均为 `1fdc341e…`,**内容零差异**,未触碰 ✔
- 临时基线 worktree 已 `--force` 移除并 prune,`git worktree list` 仅主仓库 ✔
---
## 15. 明确声明
- **未暂存、未提交、未推送。**
- 未操作 Docker。
- **未进入 R01-B / R01-C / R01-E。**
- 未修改基础设施类(`RequestIdFilter`、`RequestIdAccessor`、`RequestLoggingFilter`、`AuditContext`、`GlobalExceptionHandler`、`SecurityConfig`)。
- 未删除历史未跟踪文件。
- 基线测试污染已用 HEAD 隔离 worktree 复现并如实记录,**未写成通过、未顺手修复**。
---
> **R01-A6最终业务Controller请求上下文收束完成,待独立代码复验;A系列到此结束,不再拆分A7/A8。**

492
docs/r01-a6-independent-verification-20260917.md

@ -0,0 +1,492 @@
# R01-A6 独立代码复验报告(最终批:5 个 Controller 请求身份与 requestId 收束)
日期:2026-09-17
分支:`refactor/r01-structure-performance`
HEAD:`534afe686b966f175bcaf4af0726cec061092050`(A5 提交;复验全程未变)
暂存区:**空**
复验对象:`docs/r01-a6-final-request-context-migration-report.md`
性质:**仅复验** —— 未修改生产代码、未修改既有测试断言、未暂存、未提交、未推送
---
## 0. 结论
> **R01-A6独立代码复验通过,可以进入精确暂存与独立提交检查点;A系列到此结束,不再设置A7/A8。FairnessMonitoringControllerTest的单跑夹具缺陷须在A6提交后以独立纯测试提交修复。**
**业务 Controller 身份解析残留 = 0;业务 Controller requestId 重复解析残留 = 0。**
剩余命中全部落在基础设施职责内(身份 3 文件、requestId 5 文件)。
**未发现 A6 引入的生产缺陷。** `FairnessMonitoringControllerTest` 的单跑失败经证据链确认为
**测试夹具缺陷**(`TestAuthTokens` 对"已存在但被禁用"的用户行不做激活),
**与 A6 生产改动无关**;其准确根因见 §9。
---
## 1. 开始前状态
| 项 | 结果 |
|---|---|
| `git branch --show-current` | `refactor/r01-structure-performance` |
| `git rev-parse HEAD` | `534afe686b966f175bcaf4af0726cec061092050` |
| `git diff --cached --name-only` | **空**(暂存区为空) |
| `git diff --check` | 仅 `docs/backend-development-roadmap.md` 的 LF/CRLF 警告,**exit 0**,无空白错误 |
| Java/Maven 残留进程 | 无 |
工作区改动共 6 个文件:5 个生产 Controller + `docs/backend-development-roadmap.md`。
---
## 2. 五个 Controller 静态审查
`git diff --stat`:
```
docs/backend-development-roadmap.md | 2 +-
.../com/mydreamjob/controller/ApiController.java | 12 +++++---
.../com/mydreamjob/controller/AuthController.java | 33 +++++++++++-----------
.../mydreamjob/controller/SystemController.java | 14 +++++----
.../java/com/mydreamjob/job/JobController.java | 21 +++++++-------
.../FairnessMonitoringController.java | 25 +++++++---------
6 files changed, 54 insertions(+), 53 deletions(-)
```
| 项 | 结果 |
|---|---|
| 修改范围是否仅限 A6 清单内 5 个 Controller | **是** ✔ |
| 是否新增接口 / ErrorCode / Flyway / 表 / 索引 / 依赖 | **否** ✔ |
| 是否触碰 `SecurityConfig` / Filter / Accessor / `GlobalExceptionHandler` | **否** ✔ |
| 是否修改既有测试文件 | **否** ✔(`git status` 中无任何 `src/test` 既有文件被修改) |
### 2.1 禁止模式扫描(静态门禁)
对 5 个 Controller 逐一扫描 `X-Request-Id` 直读、`SecurityContextHolder`、
`getAuthentication()`、`Long.valueOf(auth.getName())`:
| 文件 | 命中 |
|---|---|
| `controller/ApiController.java` | **0** ✔ |
| `controller/AuthController.java` | **0** ✔ |
| `controller/SystemController.java` | **0** ✔ |
| `job/JobController.java` | **0** ✔ |
| `scoringmonitoring/FairnessMonitoringController.java` | **0** ✔ |
全仓 `*Controller.java` 扫描同一组模式:**0 命中** ✔
### 2.2 构造器注入与无回退
`ApiController`、`SystemController` 新增 `@RequiredArgsConstructor` 并声明
`private final CurrentUserAccessor` / `private final RequestIdAccessor`;
`AuthController`、`JobController`、`FairnessMonitoringController` 复用既有
`@RequiredArgsConstructor`。**全部为 final 构造器注入,无字段默认值、无 `null` 回退、
无 `new` 兜底实现** ✔
### 2.3 `GlobalExceptionHandler.java` 三方 blob 比对
```
git hash-object <file> → 1fdc341ebe3653393b3ec04f0c2d0bb9dfbb6d21
git rev-parse HEAD:<file> → 1fdc341ebe3653393b3ec04f0c2d0bb9dfbb6d21
git ls-files -s <file> → 100644 1fdc341ebe3653393b3ec04f0c2d0bb9dfbb6d21 0 <file>
git diff --quiet <file> → exit 0
```
**工作区 blob 与 HEAD blob 完全一致**,该文件在 `git status` 中的 `M` 标记是
**Windows stat 缓存假阳性**,未修改、未触碰 ✔
---
## 3. 删除的私有方法(真实数量:6)
逐文件 `git diff` 核对(`-` 行为真实删除):
| # | 文件 | 删除的方法签名 |
|---|---|---|
| 1 | `controller/ApiController` | `private String getRequestId(HttpServletRequest)` |
| 2 | `controller/AuthController` | `private String getRequestId(HttpServletRequest)` |
| 3 | `controller/SystemController` | `private String getRequestId(HttpServletRequest)` |
| 4 | `job/JobController` | `private String getRequestId(HttpServletRequest)` |
| 5 | `scoringmonitoring/FairnessMonitoringController` | `private Long userId()` |
| 6 | `scoringmonitoring/FairnessMonitoringController` | `private String requestId(HttpServletRequest)` |
**计数澄清**:`getRequestId` 名称的方法实际为 **4 个**;
`FairnessMonitoringController` 的 requestId 方法签名为 `requestId(HttpServletRequest)`
(**无 `get` 前缀**),不能被计入 `getRequestId`。
正确分解为 **4 × `getRequestId` + 1 × `userId` + 1 × `requestId` = 6**,
与上表逐文件清单一致 ✔
**未为迎合任何数字修改生产代码** ✔
---
## 4. 独立接线变异证明
新增独立测试 `A6AccessorWiringIndependentTest` 将 `CurrentUserAccessor`、
`RequestIdAccessor` 全部替换为 mock,覆盖 5 个 Controller 的代表端点
(`/`、`/health`、`/auth/me`、`GET /jobs`、`/admin/scoring/fairness/drift`):
| 断言 | 结果 |
|---|---|
| 5 个端点响应体 `requestId` 均等于 mock 返回值 `a6-indep-request-id` | **通过** ✔ |
| Accessor 返回 4242 时 `service.drift(4242L)` 被调用 | **通过** ✔ |
| Accessor 返回 77L 时 `overview/dimensions/rankingStability` 收到 77L | **通过** ✔ |
| Accessor 抛 `InsufficientAuthenticationException` → 401 且 `service` 零调用 | **通过** ✔ |
| `POST /jobs` 企业侧身份经 `CurrentUserAccessor.requireUserId()` | **通过** ✔ |
**变异检测含义**:若任一 Controller 把 `requestIdAccessor.get(request)` 换回直读
`request.getHeader("X-Request-Id")`,响应体将不再出现 mock 值 → 该断言必然失败;
若 `FairnessMonitoringController` 换回直读 `SecurityContextHolder`,
`4242/77` 将不会传到 Service → 断言失败。**接线真实生效,非空转** ✔
---
## 5. 独立测试质量审查
复验对象:
`A6AccessorWiringIndependentTest`(6 个用例)、`A6IndependentVerificationTest`(7 个用例)。
| 核对项 | 结果 |
|---|---|
| 公开端点未被错误要求 401 | ✔ 无 Token 断言仅覆盖 `/auth/me` 与 `/admin/scoring/fairness/drift`,`/`、`/health`、`GET /jobs` 明确排除在断言之外 |
| JobController 身份接线使用真正需要企业身份的端点 | ✔ 改用 `POST /jobs`(`resolveCompanyId()` 路径),非公开的 `GET /jobs` |
| Service 参数验证不是空转 | ✔ 断言具体数值(4242 / 77),而非仅 `any()` |
| requestId 断言未错误声称响应头来自 Accessor | ✔ 只断言「响应头非空且已规范化」+「响应体 requestId 与响应头同源」;响应头由 `RequestIdFilter` 产出,未作错误归属 |
| 副作用快照列真实存在 | ✔ 9 张表列名逐一对齐迁移文件(`SCORING_RISK_EVENTS`、`SCORING_RISK_ACTIONS`、`JOBS`、`USERS`、`USER_ROLES`、`AUDIT_LOGS`、`NOTIFICATIONS`、`MESSAGES`、`CANDIDATE_DIMENSION_SCORES`) |
| 未修改或放宽既有测试 | ✔ 既有测试文件零改动 |
| 未通过过度 mock 绕过目标 Controller | ✔ 目标 Controller 全部真实(`@SpringBootTest` + `MockMvc`),只 mock 下游 Service 与两个 Accessor |
### 5.1 独立测试首次运行的两处自身错误及修正
| # | 原断言 | 问题性质 | 修正 |
|---|---|---|---|
| 1 | 把公开端点路径 `/`、`/health`、`GET /jobs` 也断言为无 Token 401 | **测试自身错误**(公开端点本不应 401),非生产缺陷 | 断言范围收窄为 `/auth/me`、`/admin/scoring/fairness/drift` 两个需认证端点 |
| 2 | 用公开的 `GET /jobs` 验证 `JobController` 的身份 Accessor 接线 | **测试自身错误**(该端点不解析身份,断言无意义) | 改用 `POST /jobs` 企业侧端点 |
**两处均为新测试自身缺陷,已修正;未修改生产代码,未修改任何既有测试。**
修正后两个类合计 **13/13 通过**。
---
## 6. 身份边界与 401 / 403 / 404 分层
### 6.1 非法主体一律 401(真实 Accessor + 真实 SecurityFilterChain)
对主体 `anonymousUser`、`not-a-number`、`99999999999999999999`、`0`、`-1`,
以及 `AnonymousAuthenticationToken` 和完全无 Token:
| 断言 | 结果 |
|---|---|
| 状态码一律 **401**(不得 400/500) | ✔ |
| 响应体 `code = 401` | ✔ |
| `message` ∈ {`authentication required`, `not authenticated`} | ✔ |
| `message` 不回显异常类名、`java.`、`principal`、`anonymousUser`、`com.mydreamjob`、`NumberFormat` | ✔ |
| 响应体无 `stackTrace` / `trace` | ✔ |
| 业务 Service **零调用** | ✔ |
| 合法数字主体(`7`)不被身份层拒绝 | ✔ |
### 6.2 401 / 403 / 404 分层未漂移
静态审查确认:A6 diff **未删除、未新增任何角色校验或资源归属校验**;
`JobController.resolveCompanyId()` 与 `jobService.assertOwnerOrThrow(companyId, jobId)` 原样保留,
`AuthController` 的「已注销手机号 → HTTP 409 / code=9303」分支原样保留。
既有契约由既有测试继续守护且**全部通过**(例:`AuthenticationBoundaryIntegrationTest`
对 `POST /jobs` 断言 `403` + `ErrorCode.FORBIDDEN`)。**403 / 404 契约未漂移** ✔
---
## 7. requestId 语义
对 5 个 Controller 的代表端点,注入 `X-Request-Id` ∈
{`a6v-legal-1`, 全空白, 90 字符超长, `<b>x</b>`, `a b`}:
| 断言 | 结果 |
|---|---|
| 响应头 `X-Request-Id` 非空 | ✔ |
| 不含 `<`、`>`、空格、CR、LF | ✔ |
| 长度 ≤ 32 | ✔ |
| 响应体 `"requestId":"<响应头同值>"` | ✔ |
| 请求属性优先于原始 Header | ✔ |
| CR/LF 不进入规范化结果(6 组恶意输入) | ✔ |
---
## 8. 失败请求的业务副作用
`illegalIdentity_producesNoBusinessWrite`:对 9 张核心表按显式列 + 稳定主键排序做全量快照,
执行 5 组非法主体请求 + 1 组无 Token 请求后再次快照。
| 结果 |
|---|
| 9 张表**逐字段零变化** ✔ |
---
## 9. `FairnessMonitoringControllerTest` 单跑:真实结果与准确根因
### 9.1 单跑命令与真实结果(**不写成通过**)
```
mvn -Dtest="FairnessMonitoringControllerTest" test
```
日志:`docs/acceptance/r01-d4-2-20260916/a6v-fairness-repeat.log`
```
Tests run: 7, Failures: 6, Errors: 0, Skipped: 0
BUILD FAILURE
```
6 个失败全部是 `Status expected:<200|403|400> but was:<401>`;
唯一通过的是 `anonymous_isUnauthorized`(不发 Token 的那个)。
### 9.2 逐项查明(任务书第 2 步的 6 个问题)
| # | 问题 | 结论 | 证据 |
|---|---|---|---|
| 1 | HTTP 实际状态 | **401**,响应体 `{"code":401,"message":"not authenticated","timestamp":""}` | MockHttpServletResponse 输出;`Handler: Type = null`(请求**未进入任何 Controller**) |
| 2 | Filter 为什么拒绝 | 不是验签失败,而是**服务端会话校验的用户检查失败** | 日志:`AuthSessionService - session validation rejected: user not usable` → `JwtAuthenticationFilter - JWT authentication rejected: session not active` |
| 3 | 测试用户是否存在且 `is_active = true` | **行存在,但 `is_active = 0`**(不是"不存在",也不是"active") | 见 §9.3 推理链 |
| 4 | `auth_tokens` 会话记录是否存在/未过期/未消费/未撤销 | **存在且有效**;拒绝发生在读取会话记录**之前** | `TestAuthTokens.writeSession` 自身执行 `SELECT COUNT(*) FROM auth_tokens WHERE user_id=? AND session_id=? AND revoked=0 AND consumed_at IS NULL AND expired_at > ?`,不可见即抛 `IllegalStateException`;日志中**无该异常**,说明会话行有效。而 `isSessionActive()` 先判用户、后判会话,拒绝文本是 "user not usable" 而非 "no active session" |
| 5 | JWT 的 `sid/jti/sub/type/aud/iss` 是否与库一致 | **一致**;JWT 本身完全合法 | `parseAndValidateAccessToken` **未抛异常**(否则拒绝路径不同)。日志中 Token 载荷:`sub=1`、`type=access`、`sid=0073abf7-…`、`role=ADMIN`、`iss=mydreamjob`、`aud=["mydreamjob-api"]`、`iat=1577836800`(2020-01-01)、`exp=2523916800`(2050-01-01),与夹具 `FIXTURE_BASE`/30 年 TTL 完全对应;`sid` 即写入 `auth_tokens.session_id` 的同值 |
| 6 | 是否真的与 `user_roles` 有关 | **无关** | `AuthSessionService.isUsable(User)` 只读 `user.getActive()`;`UserRepository.findById` 无角色过滤;该路径**从不查询 `user_roles`** |
### 9.3 准确根因(证据链,取代此前未经证实的 `user_roles` 说法)
**根因:`TestAuthTokens` 夹具只对"不存在的" `users.id` 执行 INSERT,对"已存在但 `is_active=0`"的行不做激活,而 `V1_1` 种子账号恰好占用 id=1/2/3 并被 `V43` 置为禁用。**
证据链(逐步闭合):
1. `V1_1__seed_data.sql` 按自增插入三名演示账号 → 占据 `users.id = 1 / 2 / 3`
(`13800000001` ADMIN、`13800000002` STUDENT、`13800000003` COMPANY)。
2. `V43__disable_legacy_seed_accounts.sql` 按原始指纹把这三行 `is_active` 置为 **0**。
3. `FairnessMonitoringControllerTest` 使用 `token(1L, "ADMIN")` / `token(2L, "STUDENT")`
→ 命中 id=1/2 这两个**已被禁用的种子行**。
4. `TestAuthTokens.writeSession()` 的行为是:
```java
SELECT COUNT(*) FROM users WHERE id = ? // id=1 已存在 → count = 1
if (count == null || count == 0) { INSERT ... is_active=1 } // → 被跳过,永不激活
```
即夹具**只补建缺失行,不修复既有行的启用状态**。
5. `users.created_at` / `updated_at` 在 `V1__base_schema.sql` 中均有
`DEFAULT CURRENT_TIMESTAMP`,因此"若行不存在则夹具 INSERT 必然成功且 `is_active=1`"成立。
既然实际观察到 401(而非 500/ERROR),**只可能是行已存在且 `is_active=0`** —— 推理闭合。
6. `AuthSessionService.isSessionActive()` 第一步 `isUserUsable(userId)` 因此返回 false,
直接短路返回,**从未走到会话记录检查**,与日志文本完全吻合。
### 9.4 与 A6 生产改动的归因判定:**无关**
| 判据 | 内容 |
|---|---|
| 代码路径 | 401 由 `JwtAuthenticationFilter` / `AuthSessionService` 在**请求进入 Controller 之前**产生(`Handler: Type = null`)。A6 只改了 5 个 Controller 内部取 requestId / 取身份的写法,**该路径上一行 A6 代码都未执行** |
| A6 diff 范围 | 仅 5 个 Controller + 文档;`SecurityConfig`、Filter、`AuthSessionService`、`UserRepository`、`TestAuthTokens`、Flyway 迁移**零改动** |
| 失败面 | 7 个用例中,唯一不携带 Token 的用例通过;其余全部 401 —— 与「Token 认证链路在用户检查处失败」完全一致,与「Controller 被改写」不一致 |
### 9.5 为何全量顺序下不触发(机制已定位,非"偶然")
`com.mydreamjob.controller.AuthIntegrationTest` 的 `@BeforeEach`(源码第 97 行)执行
`userRepository.deleteAll();`,在**共享的 `integrationdb`**(无 `@TestPropertySource` 覆盖)上
**清空全部用户,包括 V43 已禁用的 V1_1 种子行**。
全量日志 `docs/acceptance/r01-d4-2-20260916/a3-commit-cv.log` 的执行顺序印证:
```
第 151767 行 [INFO] Running com.mydreamjob.controller.AuthIntegrationTest
第 247038 行 [INFO] Running com.mydreamjob.scoringmonitoring.FairnessMonitoringControllerTest
```
种子行被清空后,夹具第 4 步的 `COUNT(*)` 变为 0 → **INSERT 补建 `is_active=1` 的用户** →
用户检查通过 → 该类在全量顺序下 7/7 通过。
因此:**同一份代码,只因子夹具前置数据不同而结果不同 —— 属顺序依赖的夹具脆弱性,
不是被测代码的缺陷。**
**决定性对照实验**(不新增任何文件、不修改任何代码,只改变前置数据):
```
mvn -Dtest="com.mydreamjob.controller.AuthIntegrationTest,FairnessMonitoringControllerTest" test
```
日志:`docs/acceptance/r01-d4-2-20260916/a6v-fairness-mechanism.log`
```
[INFO] Running com.mydreamjob.controller.AuthIntegrationTest
[INFO] Tests run: 6, Failures: 0, Errors: 0, Skipped: 0 -- in ...AuthIntegrationTest
[INFO] Running com.mydreamjob.scoringmonitoring.FairnessMonitoringControllerTest
[INFO] Tests run: 7, Failures: 0, Errors: 0, Skipped: 0 -- in ...FairnessMonitoringControllerTest
[INFO] BUILD SUCCESS
```
| 运行方式 | 同一份含 A6 的代码 | 结果 |
|---|---|---|
| 单独运行 Fairness | A6 在场 | **7 run / 6 failures** |
| AuthIntegrationTest 先运行 | A6 在场 | **7 run / 0 failures** |
| 全量 `mvn clean verify` | A6 在场 | **7 run / 0 failures** |
**A6 代码在三行中完全相同,唯一变量是前置数据** —— 归因判定为夹具脆弱性的直接实验证据。
### 9.6 处置
- **不修改生产代码** ✔
- **不修改既有 `FairnessMonitoringControllerTest`** ✔
- 该缺陷列为 **A6 提交后的独立纯测试修复**:让 `TestAuthTokens` 在行已存在时
显式确保 `is_active = 1`(或改用不与 V1_1 种子账号冲突的 userId)。
- **未把该单跑命令写成通过** ✔
---
## 10. 全仓剩余统计(逐文件分类)
**身份解析**(`SecurityContextHolder.getContext().getAuthentication` /
`Long.(valueOf|parseLong)(...getName())`)—— **3 文件**
| 文件 | 分类 |
|---|---|
| `security/CurrentUserAccessor` | 基础设施(Accessor 自身) |
| `auth/JwtAuthenticationFilter` | 基础设施(认证过滤器) |
| `deletion/DeletionGuardInterceptor` | 基础设施(拦截器) |
**requestId 解析**(`X-Request-Id` 直读)—— **5 文件**
| 文件 | 分类 |
|---|---|
| `audit/AuditContext` | 基础设施 |
| `common/logging/RequestIdAccessor` | 基础设施(Accessor 自身) |
| `common/logging/RequestIdFilter` | 基础设施(过滤器) |
| `common/logging/RequestLoggingFilter` | 基础设施 |
| `deletion/DeletionGuardInterceptor` | 基础设施 |
**业务 Controller 残留:身份解析 0、requestId 重复解析 0** ✔
---
## 11. 测试数
### 11.1 A6 定向
```
mvn -Dtest="R01A6RequestContextMigrationIntegrationTest,A6*Test,CurrentUserAccessorTest,RequestIdAccessorTest,RequestIdFilterTest" test
```
日志:`docs/acceptance/r01-d4-2-20260916/a6v-v1-targeted.log`
| 测试类 | Tests |
|---|---|
| `common.r01a6.R01A6RequestContextMigrationIntegrationTest` | 7 |
| `common.r01a6.A6AccessorWiringProbeTest` | 3 |
| `common.r01a6.A6AccessorWiringIndependentTest` | 6 |
| `common.r01a6.A6IndependentVerificationTest` | 7 |
| `security.CurrentUserAccessorTest` | 11 |
| `common.logging.RequestIdAccessorTest` | 16 |
| `common.logging.RequestIdFilterTest` | 3 |
| **合计** | **53 / 53**,Failures=0、Errors=0、**Skipped=0**,BUILD SUCCESS ✔ |
### 11.2 A6 关联
```
mvn -Dtest="*ApiController*Test,*AuthController*Test,*SystemController*Test,*JobController*Test,*Security*Test,*RequestId*Test" test
```
日志:`docs/acceptance/r01-d4-2-20260916/a6v-v2-related.log`
| 测试类 | Tests |
|---|---|
| `auth.SmsVerificationSecurityTest` | 10 |
| `common.logging.RequestIdAccessorTest` | 16 |
| `common.logging.RequestIdFilterTest` | 3 |
| **合计** | **29 / 29**,Failures=0、Errors=0、**Skipped=0**,BUILD SUCCESS ✔ |
> **说明**:该命令的 `*ApiController*Test`、`*AuthController*Test`、`*SystemController*Test`、
> `*JobController*Test` 四个模式在本仓库**不存在同名测试类**(`find` 全量核对:
> 无 `ApiControllerTest` / `SystemControllerTest` / `JobControllerTest` / `AuthControllerTest`),
> 故只命中上表 3 个类。这 5 个 Controller 的既有覆盖由
> `mvn clean verify` 全量承担(含 `AuthenticationBoundaryIntegrationTest` 对 `POST /jobs`
> 的 403 断言、`controller.AuthIntegrationTest` 的登录/刷新/登出全链路等)。**未因此放宽任何断言。**
### 11.3 全量
```
mvn clean verify
```
日志:`docs/acceptance/r01-d4-2-20260916/a6v-v3-verify.log`
```
[INFO] Tests run: 2278, Failures: 0, Errors: 0, Skipped: 0
[INFO] BUILD SUCCESS
```
| 项 | 数值 |
|---|---|
| 全量测试总数 | **2278** |
| Failures | **0** ✔ |
| Errors | **0** ✔ |
| Skipped | **0** ✔ |
| 原实施基线 | 2265 |
| 独立新增 | **13**(`A6AccessorWiringIndependentTest` 6 + `A6IndependentVerificationTest` 7) |
| 2265 + 13 | **2278** ✔ 与 Surefire 汇总完全一致 |
**无需要按 Surefire XML 另行解释的差异** —— 汇总行与预期逐位吻合。
### 11.4 `FairnessMonitoringControllerTest` 单跑(**不计入通过**)
| 命令 | 结果 |
|---|---|
| `mvn -Dtest="FairnessMonitoringControllerTest" test` | **7 run / 6 failures / 0 errors / 0 skipped,BUILD FAILURE** |
按任务书要求:**该单跑命令不写成通过**,其失败与准确根因见 §9。
---
## 12. OpenAPI
```
python ..\docs\verify_openapi.py
→ paths: 176 / operations: 214
→ All checks passed!
```
`git diff -- docs/openapi-rc.json` → **空**,`openapi-rc.json` 零修改 ✔
---
## 13. Flyway / ErrorCode / DTO / 接口
| 项 | 结果 |
|---|---|
| `git diff --name-only -- mydreamjob-server/src/main/resources/db/migration` | **空** —— V1–V45 未改动 ✔ |
| `ErrorCode` | 未修改 ✔ |
| DTO / 响应体结构 | 未修改(仅取值来源改变) ✔ |
| 接口(路径 / 方法 / 参数) | 未新增、未删除、未改签名 ✔ |
| 依赖(`pom.xml`) | 未修改 ✔ |
---
## 14. Git 状态
| 项 | 结果 |
|---|---|
| 分支 | `refactor/r01-structure-performance` |
| HEAD | `534afe686b966f175bcaf4af0726cec061092050`(未变) |
| `git diff --cached --name-only` | **空** |
| `git diff --check` | 仅 LF/CRLF 警告,exit 0 |
| 已暂存 / 已提交 / 已推送 | **均无** ✔(`git log -1` 仍为 A5 提交 `534afe6`) |
| 与远端关系 | `## refactor/r01-structure-performance...origin/refactor/r01-structure-performance`(无 ahead/behind) |
| 历史未跟踪文件 | 未删除 ✔ |
| 本轮验收日志 | 位于 `docs/acceptance/r01-d4-2-20260916/`,该路径被 `.gitignore:43 **/*.log` 忽略(`docs/acceptance` 受版本管理的文件数为 0),**不会进入任何提交** ✔ |
| 残留 Java/Maven 进程 | 无 ✔ |
---
## 15. 明确声明
- **未暂存、未提交、未推送。**
- 未修改任何生产代码;未修改、删除或放宽任何既有测试断言;
未修改 `GlobalExceptionHandler`、`SecurityConfig`、Filter、Accessor、`ErrorCode`、DTO、
OpenAPI、Flyway 迁移与 V1–V45。
- 本轮仅新增 `docs/r01-a6-independent-verification-20260917.md` 与验收日志;
独立测试自身的两处缺陷已就地修正(§5.1),未触碰生产代码与既有测试。
- 未进入 R01-B;**A 系列到此结束,不再设置 A7/A8**。
- 本轮未启动 Docker,**未伪造真实 MySQL 结论**。
---
> **R01-A6独立代码复验通过,可以进入精确暂存与独立提交检查点;A系列到此结束,不再设置A7/A8。FairnessMonitoringControllerTest的单跑夹具缺陷须在A6提交后以独立纯测试提交修复。**

497
docs/r01-b-authorization-exception-mapping-report.md

@ -0,0 +1,497 @@
# R01-B 角色授权与异常映射收束报告
日期:2026-09-17
分支:`refactor/r01-structure-performance`
HEAD:`84fe9f5febcf56910d5c978dd44e0e16d15d7209`(A6 后夹具修复提交)
本轮性质:**实施与自动化验证** —— 未暂存、未提交、未推送,待独立代码复验
范围:**B1 角色授权重复收束 + B2 异常到 HTTP 状态映射收束**(不拆分子阶段,不进入 R01-C/E)
---
## 0. 结论
> **R01-B角色授权与异常映射收束完成,待独立代码复验。**
- 角色授权重复逻辑收束到唯一实现点 `security/UserRoleGuard`,**54 个调用点**改走公共 guard;
角色 guard 方法定义 **32 → 21**,`getRoles()` 角色比较 **46 处 / 31 文件 → 23 处 / 16 文件**;
- HTTP 状态映射收束到 `common/exception/ErrorHttpStatusRegistry`,`GlobalExceptionHandler`
不再持有映射表;**112 个 ErrorCode 的映射逐项保持不变**,未映射的 54 个仍回退 400;
- 收束过程中发现一处既有潜在缺陷(`ErrorCode` 166 个常量只有 165 个唯一数字码),
**已记录、未擅自修改**(见 §9.4)。
---
## 1. 前置 Git 状态
| 项 | 结果 |
|---|---|
| `git branch --show-current` | `refactor/r01-structure-performance` |
| `git rev-parse HEAD` | `84fe9f5febcf56910d5c978dd44e0e16d15d7209` |
| `git rev-list --left-right --count HEAD...@{upstream}` | `0 0` |
| `git diff --cached --name-only` | **空** |
| `git diff --check` | 仅 LF/CRLF 警告,**exit 0** |
| 与目标文件重叠的未提交修改 | **无**。`GlobalExceptionHandler.java` 在 `git status` 中显示 `M`,但 `git hash-object` = `git rev-parse HEAD:<file>` = `1fdc341e…`,`git diff --quiet` exit 0 —— 属 Windows stat 缓存假阳性,非真实修改 |
---
## 2. 角色授权库存(重构前)
搜索范围:`mydreamjob-server/src/main/java`。
重构前共 **32 个**角色 guard 方法定义 + **7 个** `private boolean hasRole(User, Role)` 助手。
**共同语义(近乎逐字相同的复制)**:
```
User user = userRepository.findById(userId)
.orElseThrow(() -> new BusinessException(ErrorCode.USER_NOT_FOUND));
boolean ok = user.getRoles().stream().anyMatch(r -> r == User.Role.X); // user_roles
if (!ok) throw new BusinessException(ErrorCode.FORBIDDEN);
```
| 项 | 统一事实 |
|---|---|
| 输入来源 | 已解析出的 `userId`(由 `CurrentUserAccessor` 或调用方传入),**没有任何一处从请求参数取角色** |
| 角色来源 | **`user_roles` 关联表**(`user.getRoles()`)—— 31 个文件、46 处 `getRoles()`;`users.role` 单值列**不**参与授权判定(它只用于 JWT 声明签发) |
| 失败错误码 | 用户不存在 → `USER_NOT_FOUND`(4001);角色不符 → `FORBIDDEN`(2002) |
| HTTP 状态 | 4001 → 404;2002 → 403(经 §3 的映射表) |
| 是否含资源归属 | **不含**。归属(如 `assertOwnerOrThrow`、`findByOwnerUserId`)由各业务 Service 独立执行 |
| 是否允许 ADMIN | **不允许**(无隐式超级权限);`requireAny` 由调用方显式列出 ADMIN 才放行 |
| 可否安全委托 | 纯 void 角色门禁 → **可**;返回 `Company` 实体的 `requireCompany` → **不可整体委托**(见 §7) |
---
## 3. HTTP 映射库存(重构前)
`GlobalExceptionHandler.buildBusinessHttpStatuses()`(第 71–136 行)+ `register()`(第 138–147 行)
+ `resolveHttpStatus()`(第 67–69 行),共 **112 个 `ErrorCode` 引用**:
| HttpStatus | 数量 | 备注 |
|---|---|---|
| CONFLICT | 77 | |
| NOT_FOUND | 27 | |
| FORBIDDEN | 5 | `FORBIDDEN`、`INTERVIEW_ACCESS_DENIED`、`APPLICATION_OWNER_MISMATCH`、`MATCH_REQUIRED_FOR_CONVERSATION`、`OFFER_COMPARE_NOT_OWNER` |
| TOO_MANY_REQUESTS | 3 | |
| **合计** | **112** | |
| 项 | 结果 |
|---|---|
| 重复键 | **0**(112 个 `ErrorCode` 常量互不相同) |
| 重复项映射一致性 | 不适用 |
| 未显式映射的 ErrorCode | **54 / 166** → 一律回退 **HTTP 400** |
| 关键既有语义 | `UNAUTHORIZED(2001)` **未显式映射 → 400**(不是 401);`SERVICE_UNAVAILABLE`、`RATE_LIMITED`、`INTERNAL_ERROR` 同为 **400** |
| 已有测试锁定状态码 | **有**:`GlobalExceptionHandlerStatusContractTest` 以 `@EnumSource(ErrorCode.class)` 对**全部 166 个**错误码锁定状态 + 业务码 + message + requestId |
**快照已保存**:`docs/acceptance/r01-b/b2-mapping-baseline.json`(112 条 `名称 → 状态`),
并逐条转写进 `GlobalExceptionHandlerMappingParityTest` 作为独立基线。
---
## 4. 修复前失败测试(红灯)
| 观察 | 说明 |
|---|---|
| 角色授权 | 重构前**没有**公共 guard:32 个方法各自实现,无任何调用点经过统一入口。新增的 `RoleAuthorizationIntegrationTest` 以 `@SpyBean UserRoleGuard` 断言"端点确实调用公共 guard"——在重构前该 bean 不存在,测试无法编译/通过 |
| HTTP 映射 | 重构前映射表在 `GlobalExceptionHandler` 内部,**无法在初始化期检出重复键**(`register()` 仅在"同码不同状态"时抛错,同码同状态被静默容忍);新增的 `ErrorHttpStatusRegistryTest` 直接断言"同码重复注册必须抛 `IllegalStateException`"——重构前无从表达 |
| 未改变任何既有业务期望 | `GlobalExceptionHandlerStatusContractTest` 的 4 个状态桶与全部断言**逐字保留**,仅把其反射调用私有方法的用例改为调用 `registry.resolve(int)` |
---
## 5. 公共角色 guard 设计
新增 `security/UserRoleGuard.java`(`@Component`,仅依赖 `UserRepository`)。
```java
void requireStudent(Long userId); void requireStudent(User user);
void requireCompany(Long userId); void requireCompany(User user);
void requireAdmin(Long userId); void requireAdmin(User user);
void requireAny(Long userId, User.Role... allowedRoles);
void requireAny(User user, User.Role... allowedRoles);
User requireUser(Long userId); // 存在性 + active 校验
void requireRole(User user, User.Role... allowedRoles);
```
| 要求 | 落实 |
|---|---|
| 角色来自权威数据库 | 从 `user_roles`(`user.getRoles()`)读取,与全部被替换的实现一致 |
| 校验存在且 active | `requireUser` 不存在 → `USER_NOT_FOUND`;**存在但 inactive → `FORBIDDEN`(失败关闭)** |
| `users.role` 与 `user_roles` 核对 | 授权**只**依据 `user_roles`;`users.role` 与之不一致时按 `user_roles` 判定 → 缺少所需角色即拒绝(失败关闭)。**混用两者会改变既有语义,故未采纳** |
| 复用现有 ErrorCode | 只用 `USER_NOT_FOUND` / `FORBIDDEN`,**未新增任何 ErrorCode** |
| 不解析 SecurityContext | 只接收 `userId`;结构断言验证其声明字段**只有** `UserRepository` |
| 不处理 requestId | 无 `HttpServletRequest`、无 `RequestIdAccessor` |
| 不处理资源归属 | 类中无任何 ownership 逻辑 |
| 不记录敏感信息 | 无日志、无审计写入,无副作用 |
| 无隐式 ADMIN | `requireStudent`/`requireCompany` 对 ADMIN 同样拒绝,测试已锁定 |
| 查询次数受控 | `requireXxx(Long)` 恰好 **1** 次 `findById`;`requireXxx(User)` **0** 次;**无跨请求缓存**(用户被禁用/注销后立即失效) |
检索确认:仓库中**原本不存在** `UserRoleGuard` 或等价的公共角色组件,故新增;
未新建重复角色枚举(复用既有 `User.Role`)。
---
## 6. 每个迁移调用点(54 处)
`userRoleGuard.requireStudent` **37** 处、`requireAdmin` **13** 处、`requireCompany` **4** 处,
覆盖 **21 个生产文件**。
| # | 文件 | 原方法 | 迁移后 |
|---|---|---|---|
| 1 | `ability/AbilityAttemptService` | `requireStudent`(私有) | 删除,6 处改调 guard |
| 2 | `assessment/AssessmentAttemptService` | `requireStudent`(私有) | 删除,5 处改调 guard |
| 3 | `bidirectionalmatch/MatchRuleService` | `requireStudent`(私有) | 删除,2 处改调 guard |
| 4 | `careerprogress/CareerProgressService` | `assertStudentRole`(私有) | 删除,1 处改调 guard |
| 5 | `competition/CompetitionRankingService` | `assertStudentRole`(私有) | 删除,1 处改调 guard |
| 6 | `job/JobFavoriteService` | `assertStudentRole`(私有) | 删除,3 处改调 guard |
| 7 | `match/MatchService` | `assertStudentRole`(私有) | 删除,1 处改调 guard |
| 8 | `review/JobReviewService` | `assertStudentRole`+`assertAdminRole`(私有) | 删除,3+3 处改调 guard |
| 9 | `talentdiscovery/TalentDiscoveryService` | `requireStudent`(私有) | 删除,2 处改调 guard |
| 10 | `talentinvitation/TalentInvitationService` | `requireStudent`(私有) | 删除,3 处改调 guard |
| 11 | `recommendation/JobRecommendationService` | `requireStudent`+`requireAdmin`(私有) | 删除,3+2 处改调 guard |
| 12 | `candidatescore/governance/CandidateScoringGovernanceService` | `assertStudentRole`+`assertAdminRole`(私有) | 删除,3+4 处改调 guard |
| 13 | `candidatescore/dashboard/CompanyScoringDashboardService` | `assertAdminRole`(私有) | 删除,1 处改调 guard |
| 14 | `application/ApplicationController` | `assertCompanyRole`(私有) | 删除,3 处改调 guard |
| 15 | `safety/AdminReportController` | `requireAdmin(HttpServletRequest)` | 保留方法(返回 userId),角色部分委托 guard,3 处调用不变 |
| 16 | `risk/AdminRiskController` | `requireAdmin(HttpServletRequest)` | 同上,7 处调用不变;随之移除已不需要的 `UserRepository` 字段与 import |
| 17 | `company/CompanyService` | `assertStudentRole`+`assertCompanyRole`(公开) | 保留签名,方法体改为一行委托 |
| 18 | `profile/ProfileService` | `assertStudentRole`(公开) | 保留签名,一行委托 |
| 19 | `preference/PreferenceService` | `assertStudentRole`(公开) | 保留签名,一行委托 |
| 20 | `privacy/PrivacySettingsService` | `assertStudentRole`(公开) | 保留签名,一行委托 |
| 21 | `verification/CompanyVerificationService` | `assertAdminRole`(公开) | 保留签名,一行委托 |
**公开方法为何保留签名**:它们已被跨包调用(如 `profileService.assertStudentRole` 被
`ResumeVersionService`、`ResumeExportService`、`ProfileController` 等调用;
`companyService.assertStudentRole` 被 `CandidateScoreCardController`、`CandidateScoringController`、
`StudentReviewWeightService` 调用)。保留签名、把**实现**收敛为一行委托,可在不扩大改动面的前提下
消除重复逻辑。**每个调用点的原错误码(`USER_NOT_FOUND`/`FORBIDDEN`)、原角色集合、
原调用顺序均未改变。**
---
## 7. 保留调用点及理由(非等价,未迁移)
| 类别 | 数量 | 保留理由 |
|---|---|---|
| `private Company requireCompany(Long)` | **7** —— `MatchRuleService`、`InterviewService`、`OfferService`、`CompanyReviewManagementService`、`CompanyReviewReplyService`、`CompanyVerificationService`、`CompanyScoringDashboardService` | **不是纯角色门禁**:除角色判定外还承担实体查找与归属语义(`companyRepository.findByOwnerUserId(userId).orElseThrow(COMPANY_NOT_FOUND)`),返回值被业务逻辑使用。整体委托公共 guard 会改变查询次数与错误码(`COMPANY_NOT_FOUND` vs `USER_NOT_FOUND`),**按任务书要求不迁移不等价逻辑** |
| `private boolean hasRole(User, Role)` | **7** —— `InterviewService`、`InterviewScoreService`、`MessagingService`、`OfferService`、`TalentDiscoveryService`、`TalentInvitationService`、`CompanyVerificationService` | 用于**资源归属/可见性分支**(如 `hasRole(user, STUDENT) && interview.getStudentUserId().equals(userId)`、`MessagingService` 中 ADMIN 可见性分支),不是"必须具有某角色"的门禁。**角色授权与资源归属是两件事**,迁移会改变语义 |
| `AdminReportController`/`AdminRiskController.requireAdmin(HttpServletRequest)` | 2 | 第 15/16 行已委托 guard;保留该方法是因为它还负责返回 `userId`,属 Controller 内的便捷封装 |
| `CompanyService` 等 5 个公开方法 | 5 | 见 §6 末尾说明 |
**未为"grep 为零"迁移任何不等价逻辑。**
---
## 8. 角色 / 资源归属边界
| 边界 | 事实 |
|---|---|
| 身份("我是谁") | 仍由 `CurrentUserAccessor` 负责;`UserRoleGuard` **不**解析 SecurityContext(结构测试断言其唯一依赖是 `UserRepository`) |
| 角色授权("我能不能") | 收束到 `UserRoleGuard` |
| 资源归属("这条数据是不是我的") | **仍由各业务 Service 执行**,未删除、未移动。`RoleAuthorizationIntegrationTest.ownershipRemainsWithBusinessService` 用"角色正确但无企业实体的 COMPANY 用户"访问
`/company/candidates/{id}` → 得到 **404**(业务层归属/存在性判定),证明 guard 既没有替代也没有跳过归属校验 |
| 403 / 404 分层 | 保持既有:角色不符 403(2002);资源不存在 404(如 `RISK_EVENT_NOT_FOUND`) |
---
## 9. B2 实现与逐项一致性
### 9.1 实现
新增 `common/exception/ErrorHttpStatusRegistry.java`:
```java
public HttpStatus resolve(ErrorCode errorCode);
public HttpStatus resolve(int errorCode); // BusinessException 只暴露数字 code
public HttpStatus defaultStatus(); // 400
public int explicitMappingCount(); // 112
public static Map<ErrorCode, HttpStatus> explicitMappingsView();
public static Map<Integer, HttpStatus> numericMappingsView(); // 111
```
| 要求 | 落实 |
|---|---|
| 不可变映射 | 构建后 `Map.copyOf(...)`,运行期 `put`/`remove` 抛 `UnsupportedOperationException`(测试锁定) |
| 键类型 | **`ErrorCode` 常量**(见 §9.4);数字入口由该映射派生 |
| 初始化禁止重复键 | `Builder.put` 用 `putIfAbsent`,**同码重复注册即抛 `IllegalStateException`**(无论目标状态是否相同) |
| 显式映射集中一处 | 只在 `explicitMappings()` 内,全部 `ErrorCode.X` 符号,**无手写数字** |
| 未映射默认 400 | `DEFAULT_STATUS = BAD_REQUEST`,与重构前 `getOrDefault(code, BAD_REQUEST)` 一致 |
| 不引入反射扫描 | 无反射 |
| 不使用可运行时修改的 Map | 静态 final + `Map.copyOf` |
| Handler 只调用 registry | `GlobalExceptionHandler` 删除 `BUSINESS_HTTP_STATUSES`、`buildBusinessHttpStatuses()`、`register()`、`resolveHttpStatus()`;`handleBusinessException` 改为 `errorHttpStatusRegistry.resolve(errorCode)` |
| 同码映射到不同状态 | **未发现**(唯一同数字码的两个常量映射到同一状态,见 §9.4) |
`ErrorHttpStatusRegistry` 通过 `@Import` 挂在 `GlobalExceptionHandler` 上,使 `@WebMvcTest` 切片
(`CompanyControllerMvcTest`)无需改动即可解析到该组件。
### 9.2 响应契约未变
- 响应 JSON 结构、字段、业务码、message 全部不变(`@EnumSource` 全量断言)
- requestId 仍来自 `RequestIdAccessor`,测试断言 `"r01b-contract"` / `"r01b-parity"`
- 日志级别未变(`handleBusinessException` 仍为 `log.warn`)
- 未知 code 仍回退 **400**
- `ErrorCode` 数字、枚举成员**零修改**(`git diff` 为空)
- 未新增接口、DTO、依赖
### 9.3 逐项一致性证明
`GlobalExceptionHandlerMappingParityTest`:
1. **显式映射集合** = 重构前快照的 112 个 `ErrorCode`(对**全部 166 个**错误码逐一核对:
快照中未显式映射的,重构后也不得显式映射);
2. **处理器实际返回状态** ≡ **registry 解析结果** ≡ **快照基线**(`@EnumSource` 166 参数化);
3. 未映射码(含 `UNAUTHORIZED(2001)`、`SERVICE_UNAVAILABLE`)保持 **400**;
4. `FORBIDDEN` 保持业务码 **2002** + HTTP **403**;
5. NOT_FOUND 类 404、CONFLICT 类 409、限流类 429;
6. 响应体不含异常类名、堆栈、内部路径。
### 9.4 收束过程中发现的既有潜在缺陷(已记录,未修改)
**`ErrorCode` 166 个常量只有 165 个唯一数字码**:`REVIEW_ALREADY_DECIDED(9103)` 与
`DUPLICATE_FEEDBACK(9103)` 共用同一数字码。
- 重构前该重复被 `register()` **静默容忍**(`putIfAbsent` 只在"同码不同状态"时抛错);
- 本轮的严格重复检测因此**在启动期暴露了它**;
- 二者当前都映射到 **CONFLICT**,故数字入口无歧义、行为与重构前完全一致;
- 按任务书"不修改 `ErrorCode` 数字、不新增 `ErrorCode`"的要求,**本轮不修改**,
仅把映射键改为 `ErrorCode` 常量(这也正是"键必须是 ErrorCode 或稳定错误码类型"的实践理由),
并以 `ErrorHttpStatusRegistryTest.distinctConstantsWithSharedNumericCodeAreAllowed` 锁定该事实。
**处置建议(留待后续独立提交)**:为 `DUPLICATE_FEEDBACK` 分配唯一数字码。若两个常量被映射到
**不同状态**,注册表的 `indexByCode` 会在初始化期直接抛错,不会静默任选其一。
---
### 9.5 量化结果(前后对比)
| 指标 | 重构前 | 重构后 | 说明 |
|---|---|---|---|
| 角色 guard 方法定义总数 | **32** | **21** | 其中 6 个是 `UserRoleGuard` 自身;6 个是保留签名的公开方法(方法体已是一行委托);8 个是实体返回型 `requireCompany`(非等价,保留);2 个是 Controller 的 `requireAdmin(request)`(已委托) |
| 重复 student guard 实现 | **16** | **1**(公共 guard) | 其余为公开委托方法/实体返回型 |
| 重复 company guard 实现 | **2**(纯角色门禁)+ 7(实体返回型) | **1**(公共 guard);7 个实体返回型保留 |
| 重复 admin guard 实现 | **7** | **1**(公共 guard) | |
| 已迁移调用点 | 0 | **54** | 37 student + 13 admin + 4 company |
| 明确保留的非等价调用点 | — | **14** | 7 个实体返回型 `requireCompany` + 7 个归属/可见性用 `hasRole` |
| `getRoles()` 角色比较 | **46 处 / 31 文件** | **23 处 / 16 文件** | 减少 50% |
| `GlobalExceptionHandler` 行数 | **232** | **146** | 净减 **86 行**;映射表、`register()`、`resolveHttpStatus()` 全部移出 |
| `resolveHttpStatus` 条件链 | 映射构建 66 行 + 注册 10 行 + 解析 3 行 | **0**(逻辑迁入 `ErrorHttpStatusRegistry`,261 行,含 javadoc 与显式 112 条映射) | |
| `ErrorCode.X.getCode()` 用于状态比较的次数 | 0(原为 `Map` 查找) | **0** | 状态解析无数字字面量比较 |
| 重复 HTTP 映射数 | 0(且**不可检测**) | **0**(且**初始化即拒绝**) | |
| 未映射 ErrorCode 数 | **54 / 166** → 400 | **54 / 166** → 400 | 逐项不变 |
| 角色授权 DB 查询次数 | 每处 1 次 `findById` | `requireXxx(Long)` **1 次**;`requireXxx(User)` **0 次**;无跨请求缓存 | |
---
## 10. 查询次数
| 场景 | 数据库查询次数 |
|---|---|
| `requireStudent/Company/Admin/Any(Long userId)` | **恰好 1** 次 `users.findById`(`RoleAuthorizationGuardTest.queryCountIsBounded` 以 Mockito `times(1)` 锁定) |
| `requireStudent/Company/Admin/Any(User user)` | **0** 次(供已加载实体的调用点使用) |
| 一个请求内相同用户重复角色检查 | 调用方已加载实体时应使用实体重载;现有调用点每个请求路径至多 1 次 guard 调用 |
| 跨请求缓存 | **无**(不缓存 active 状态,注销/禁用后立即失效) |
| Redis 依赖 | **无** |
| 事务边界 | **未改变**(guard 只读、无 `@Transactional`) |
| 单次授权的 N+1 | **无**:guard 为单条主键查询,`user_roles` 由 JPA 关联加载 |
---
## 11. 测试结果
日志目录:`docs/acceptance/r01-b/`
| # | 命令 | 结果 | 日志 |
|---|---|---|---|
| 1 | `mvn -Dtest="RoleAuthorizationGuardTest,RoleAuthorizationIntegrationTest,ErrorHttpStatusRegistryTest,GlobalExceptionHandlerMappingParityTest" test` | **371 / 371**,F=0 E=0 S=0 ✔ | `b1b2-tests.log` |
| 2 | `mvn -Dtest="*Security*Test,*Authorization*Test,*Role*Test,*Admin*Controller*Test,*Company*Controller*Test,*Student*Controller*Test,*GlobalExceptionHandler*Test,*BusinessException*Test" test` | **448 中 6 项失败** —— 全部为**既有夹具缺陷**(§11.3),非本轮引入 | `b1-related.log` |
| 3 | `mvn test` | **2656 / 2656**,Failures=**0**,Errors=**0**,Skipped=**0**,BUILD SUCCESS ✔ | `b-full-mvntest.log` |
| 4 | `mvn clean verify` | 见 §11.4 | `b-clean-verify.log` |
| 5 | `python ..\docs\verify_openapi.py` | **176 paths / 214 operations**,All checks passed ✔ | — |
### 11.1 新增测试逐类结果
| 测试类 | Tests | 覆盖 |
|---|---|---|
| `RoleAuthorizationGuardTest` | 16 | STUDENT/COMPANY/ADMIN 通过各自 guard;跨角色拒绝;ADMIN 无隐式超级权限;inactive 与 `active=null` 失败关闭;角色缺失;多角色;`users.role` 与 `user_roles` 不一致失败关闭;错误码/HTTP 契约;不解析 requestId/SecurityContext;查询次数 |
| `RoleAuthorizationIntegrationTest` | 10 | 三个真实端点(ADMIN `/admin/scoring/risk/overview`、STUDENT `/profile/me/career-progress`、COMPANY `/company/candidates`)经真实 SecurityFilterChain 验证 401/403/404 分层;接线变异(`@SpyBean` 断言 guard 确被调用);资源归属仍由业务层判定 |
| `ErrorHttpStatusRegistryTest` | 174 | 112 条显式映射、111 个唯一数字码、四状态白名单、逐 ErrorCode 自洽、未映射回退 400、重复键初始化拒绝、不可变、键必须为 ErrorCode、9103 共用数字码 |
| `GlobalExceptionHandlerMappingParityTest` | 171 | 显式映射集合 = 重构前快照;处理器返回 ≡ registry ≡ 快照(166 参数化);FORBIDDEN 2002/403;`UNAUTHORIZED(2001)` 保持 400;服务不可用保持 400;响应体无内部细节 |
### 11.2 测试总数
| 项 | 数值 |
|---|---|
| R01-B 前基线 | **2285** |
| 本轮新增 | **371** |
| 预期合计 | 2656 |
| 实测 (`mvn test`) | **2656** ✔ 与预期逐位一致 |
### 11.3 关联回归中暴露的**既有**夹具缺陷(与本轮无关,如实记录)
`mvn -Dtest="*Admin*Controller*Test,..."` 单独运行时,**既有**的
`AdminReviewControllerTest`(4 项)与 `CompanyReviewManagementControllerTest`(2 项)
出现 `expected 200/403/409 but was 401`。根因与 A6 独立复验 §9 定位的**同一缺陷**:
```
AuthSessionService - session validation rejected: user not usable
JwtAuthenticationFilter - JWT authentication rejected: session not active
```
- `AdminReviewControllerTest` 使用 `ADMIN_ID = 1L`;`CompanyReviewManagementControllerTest` 使用 `token(1L, "COMPANY")` / `token(2L, "STUDENT")`;
- `V1_1` 种子账号占用 `users.id = 1/2/3` 且被 `V43` 置为 `inactive`,而 `TestAuthTokens` 对"已存在但 inactive"的行不插入也不激活;
- 全量顺序下 `com.mydreamjob.controller.AuthIntegrationTest` 的 `deleteAll()` 偶然清掉种子行,问题被掩盖 —— 故 §11.2 的全量结果是干净的;
- 日志中该警告恰好 6 次,分别落在上述两个类,**无一处落在本轮改动的代码路径**;
- 本轮**未修改**这两个测试类,也**未**把该失败写成通过。
> 该缺陷与 `FairnessMonitoringControllerTest` 属同一类,已在 A6 后以独立提交 `84fe9f5` 修复
> **其中一个类**;其余同类类别的隔离修复应另开独立纯测试提交,不并入 R01-B。
### 11.4 `mvn clean verify`
```
[INFO] Tests run: 2656, Failures: 0, Errors: 0, Skipped: 0
[INFO] BUILD SUCCESS
```
| 项 | 数值 |
|---|---|
| 全量测试总数 | **2656**(不低于收束前 2285 ✔) |
| Failures / Errors / Skipped | **0 / 0 / 0** ✔ |
| 日志 | `docs/acceptance/r01-b/b-clean-verify.log` |
---
## 12. OpenAPI
```
python ..\docs\verify_openapi.py
→ paths: 176 / operations: 214
→ All checks passed!
```
`git diff -- docs/openapi-rc.json` → **空**,`openapi-rc.json` 零修改 ✔
---
## 13. 精确修改文件
**新增生产代码(2)**
```
mydreamjob-server/src/main/java/com/mydreamjob/security/UserRoleGuard.java
mydreamjob-server/src/main/java/com/mydreamjob/common/exception/ErrorHttpStatusRegistry.java
```
**修改生产代码(22)**
```
ability/AbilityAttemptService.java
application/ApplicationController.java
assessment/AssessmentAttemptService.java
bidirectionalmatch/MatchRuleService.java
candidatescore/dashboard/CompanyScoringDashboardService.java
candidatescore/governance/CandidateScoringGovernanceService.java
careerprogress/CareerProgressService.java
common/exception/GlobalExceptionHandler.java ← B2
company/CompanyService.java
competition/CompetitionRankingService.java
job/JobFavoriteService.java
match/MatchService.java
preference/PreferenceService.java
privacy/PrivacySettingsService.java
profile/ProfileService.java
recommendation/JobRecommendationService.java
review/JobReviewService.java
risk/AdminRiskController.java
safety/AdminReportController.java
talentdiscovery/TalentDiscoveryService.java
talentinvitation/TalentInvitationService.java
verification/CompanyVerificationService.java
```
**新增测试(5)**
```
common/r01b/RoleAuthorizationGuardTest.java
common/r01b/RoleAuthorizationIntegrationTest.java
common/r01b/ErrorHttpStatusRegistryTest.java
common/r01b/GlobalExceptionHandlerMappingParityTest.java
common/r01b/UserRoleGuardTestSupport.java ← 测试支持(非测试类)
```
**修改测试(21)** —— 仅接线,未放宽任何断言:
```
careerprogress/CareerProgressServiceTest.java ← 构造器参数补 guard(真实实例)
common/exception/GlobalExceptionHandler{AccountDeletion,HttpStatus,ReportBlock,StatusContract}Test.java ← 构造器补 registry
candidatescore/governance/CandidateScoringGovernanceServiceTest.java
profile/ResumeServiceTest.java
review/JobReviewDimensionServiceTest.java
assessment/AssessmentAttemptServiceTest.java
match/MatchServiceTest.java
privacy/PrivacySettingsServiceTest.java
review/JobReviewServiceTest.java
job/JobFavoriteServiceTest.java
company/CompanyServiceTest.java
preference/PreferenceServiceTest.java
competition/CompetitionRankingServiceTest.java
ability/AbilityAttemptServiceTest.java
bidirectionalmatch/MatchRuleServiceTest.java
verification/CompanyVerificationServiceTest.java
talentdiscovery/TalentDiscoveryServiceTest.java
recommendation/JobRecommendationServiceTest.java
```
> 这 16 个 `@InjectMocks` 单元测试类新增了 `@Mock UserRoleGuard` 并在 `@BeforeEach`
> 通过 `UserRoleGuardTestSupport.delegateToRealGuard(...)` 把 mock **委托给真实 guard**,
> 复用测试自身已 stub 的 `UserRepository`。这样"角色不符 → 403"的既有断言**语义不变**,
> 没有被削弱成"只验证调用了一次"。
**文档(2)**
```
docs/r01-b-authorization-exception-mapping-report.md (新增)
docs/backend-development-roadmap.md (更新:A 系列完成 / 84fe9f5 / R01-B 状态 / 下一阶段 R01-C)
```
---
## 14. Flyway / ErrorCode / 接口 / DTO 是否变化
| 项 | 结果 |
|---|---|
| `git diff --name-only -- mydreamjob-server/src/main/resources/db/migration` | **空** —— V1–V45 未改动 |
| `ErrorCode.java` | **未修改**(数字、成员、message 全部不变) |
| 接口(路径/方法/参数/响应结构) | **未修改**(未新增、未删除、未改签名) |
| DTO | **未修改** |
| `docs/openapi-rc.json` | **未修改** |
| 依赖(`pom.xml`) | **未修改** |
| 新依赖 | **未引入**(guard 只用 Spring Data JPA 与既有 `ErrorCode`) |
---
## 15. Git 状态
| 项 | 结果 |
|---|---|
| 分支 / HEAD | `refactor/r01-structure-performance` / `84fe9f5…`(未变) |
| `git diff --cached --name-only` | **空** |
| `git diff --check` | 仅 LF/CRLF 警告,exit 0 |
| 已暂存 / 已提交 / 已推送 | **均无** |
| 历史未跟踪文件 | 未删除 |
---
## 16. 未暂存、未提交、未推送
**确认:未暂存、未提交、未推送。** 本轮未使用 `git add`、未创建提交、未推送;
未执行 `reset`/`restore`/`checkout`/`clean`;未操作 Docker;未修改 V1–V45。
---
## 17. 未进入 R01-C/E
**确认:未进入 R01-C、未进入 R01-E。** 本轮未处理 `overviewStatus`、推荐权重、
高校名单与评分阈值、N+1、会话未读数、requestId、身份主体解析、Token、评分快照、
新接口与数据库迁移中的任何一项。
---
> **R01-B角色授权与异常映射收束完成,待独立代码复验。**

19
docs/r01-b-error-status-mapping-instruction.md

@ -0,0 +1,19 @@
# R01-B 业务异常 HTTP 状态映射收束实施指令
## 1. 目标
在不改变任何对外行为的前提下,将 `GlobalExceptionHandler.resolveHttpStatus` 的长 `if` 链收束为集中、不可变、可完整验证的整数业务码映射。
## 2. 硬边界
- 不修改 `ErrorCode` 的常量、数值或文案;
- 不新增接口、Flyway、表、索引、依赖;
- 不改变 HTTP 状态、响应业务码、message、requestId 或兜底语义;
- 必须继续按整数业务码解析,因为历史常量可能共享数值;
- 同一整数码若被登记为不同 HTTP 状态,应用初始化必须失败,不能静默覆盖;
- 未显式登记的整数码继续返回 HTTP 400;
- 不进入 R01-C/D/E,不提交、不推送。
## 3. 测试门槛
先建立全量特征测试,对 `ErrorCode.values()` 每个常量逐一断言 HTTP 状态、业务码、message、requestId,并单独验证未知整数码回退 400。随后运行异常关联测试、`mvn clean verify` 与静态 OpenAPI 校验,Failures/Errors/Skipped 必须全部为 0。

46
docs/r01-b-error-status-mapping-report.md

@ -0,0 +1,46 @@
# R01-B 业务异常 HTTP 状态映射收束实施报告
> 状态:R01-B 已完成实现和自动化验证,待独立代码复验。
## 1. 基线与范围
- 分支:`refactor/r01-structure-performance`
- 起点:`0b5b849 refactor: centralize request identity and request ID handling`
- 仅修改异常 HTTP 状态解析和对应特征测试;未进入其他 R01 子阶段。
- 开始前暂存区和已跟踪工作区为空;历史未跟踪产物完整保留。
## 2. 实现
`GlobalExceptionHandler` 使用不可变 `Map<Integer, HttpStatus>` 集中登记 403、404、409、429;未登记业务码继续回退 400。登记辅助方法使用 `putIfAbsent`,同一整数业务码若出现不同状态会抛固定初始化异常,避免静默覆盖。
映射键保持为整数而非 `ErrorCode`:仓库存在历史共享数值常量,生产处理入口收到的也是整数业务码。此次未修改或“修正”任何历史常量。
## 3. 特征测试
新增 `GlobalExceptionHandlerStatusContractTest`:
- 对全部现有 `ErrorCode` 常量逐项调用正式 `handleBusinessException`;
- 独立快照四类显式状态集合,其余断言为 400;
- 同时断言业务码、message、requestId 原样保持;
- 通过反射只读调用私有解析器,证明未知整数码仍回退 400,未为测试扩大生产 API。
第一次定向编译曾因测试错误地调用不存在的 `BusinessException(int, String)` 构造器失败;整改为反射验证私有兜底后通过。未修改生产 API,也未删除或放宽断言。
## 4. 量化与验证
| 项目 | 结果 |
|---|---|
| `GlobalExceptionHandler` 行数 | 328 → 232 |
| `errorCode == ErrorCode...` 比较 | 121 → 0 |
| 异常定向/关联集合 | 207/207,0/0/0 |
| `mvn clean verify` | 2082/2082,Failures=0、Errors=0、Skipped=0,BUILD SUCCESS |
| OpenAPI | 176 paths / 214 operations,All checks passed |
## 5. 边界
- `ErrorCode` 未修改;
- V1–V43 未修改,无新增迁移;
- 无新增公网接口、依赖、表或索引;
- 未操作 Docker;
- 未暂存、未提交、未推送;
- 未删除历史未跟踪文件。

617
docs/r01-b-independent-verification-20260918.md

@ -0,0 +1,617 @@
# R01-B 独立代码复验报告(角色授权与异常映射收束)
日期:2026-09-18
分支:`refactor/r01-structure-performance`
HEAD:`84fe9f5febcf56910d5c978dd44e0e16d15d7209`(A6 后夹具修复提交,复验全程未变)
复验对象:`docs/r01-b-authorization-exception-mapping-report.md`
性质:**仅复验** —— 未修改生产代码、未放宽既有断言、未暂存、未提交、未推送
---
## 0. 结论
> **R01-B独立代码复验通过,可以进入精确暂存与独立提交检查点;两个Controller测试夹具缺陷须在R01-B提交后以独立纯测试提交修复。**
- 角色授权:54 个调用点全部真实存在且仅经 HTTP 入口可达;保留的 14 个点**确为不等价**;
**未发现角色/归属/错误码/事务语义变化**。
- HTTP 映射:从 `HEAD` 独立提取的 **166 项基线逐项一致(0 处不一致)**;112 显式、54 默认 400;
`UNAUTHORIZED` 仍 400、`FORBIDDEN` 仍 403、`SERVICE_UNAVAILABLE` 仍 400。
- 9103 重复码:**运行时确实无法区分**(`BusinessException` 只保存 `int`)。未夸大为"已解决身份歧义";
已证明两者状态相同故 HTTP 行为一致,且"同数字不同状态"会初始化失败而非静默任选。
- 测试委托模式:**真实**。变异实验(把委托改为无条件下放行)令 63 个负向用例中 **50 个失败**。
- 仅有两个**既有**测试夹具缺陷(`AdminReviewControllerTest`、`CompanyReviewManagementControllerTest`),
已用证据证明其失败发生在安全链认证阶段、**未进入 R01-B 代码**。
- **一处需如实说明的环境偏差**:本轮 `mvn clean verify` 为 `2681 / F=0 / E=0 / Skipped=26`。
26 个跳过全部是 `@Testcontainers(disabledWithoutDocker = true)` 的 MySQL 用例,
因本机 **Docker 守护进程未运行**而按设计跳过;上一轮同代码全量为 `Skipped=0`
(这些用例执行并通过)。**不声称 Skipped=0**,原因与自洽性见 §14.1。
**不构成 R01-B 阻断项。**
---
## 1. 分支、HEAD 与 Git 状态
| 项 | 结果 |
|---|---|
| `git branch --show-current` | `refactor/r01-structure-performance` |
| `git rev-parse HEAD` | `84fe9f5febcf56910d5c978dd44e0e16d15d7209` |
| `git rev-list --left-right --count HEAD...@{upstream}` | `0 0` ✔ |
| `git diff --cached --name-only` | **空** ✔ |
| `git diff --check` | **exit 0**,无空白错误 ✔ |
| R01-B 改动位置 | 全部仍在工作区(22 生产 + 21 测试 + 1 文档) |
| R01-B 之外的新生产改动 | **无**(逐文件比对允许清单,无越界) |
---
## 2. 角色库存独立计数
独立复验者自行从 `HEAD` 与工作区分别提取(正则按方法签名匹配,不采信实施报告数字):
| 项 | HEAD | 工作区 |
|---|---|---|
| 角色 guard 方法定义总数 | **32** | **21** |
| `private boolean hasRole(User, Role)` 助手 | 7 | 7 |
| `getRoles()` 出现次数 / 文件数 | 46 / 31 | 23 / 17 |
| 调用公共 guard 的点位 | 0 | **54** |
工作区 21 个定义的构成(逐条列出于下):
| 类别 | 数量 | 明细 |
|---|---|---|
| `UserRoleGuard` 自身 | **6** | `requireStudent/Company/Admin` × (`Long`, `User`) |
| 公开兼容委托(方法体一行) | **6** | `CompanyService.assertCompanyRole`、`CompanyService.assertStudentRole`、`PreferenceService.assertStudentRole`、`PrivacySettingsService.assertStudentRole`、`ProfileService.assertStudentRole`、`CompanyVerificationService.assertAdminRole` |
| 实体返回型 `requireCompany`(非等价,保留) | **7** | `MatchRuleService`、`CompanyScoringDashboardService`、`InterviewService`、`OfferService`、`CompanyReviewManagementService`、`CompanyReviewReplyService`、`CompanyVerificationService` |
| Controller 便捷包装(返回 userId,已委托 guard) | **2** | `AdminRiskController.requireAdmin(HttpServletRequest)`、`AdminReportController.requireAdmin(HttpServletRequest)` |
6 + 6 + 7 + 2 = **21** ✔ 与工作区实测一致。
> 与实施报告的一处**非语义差异**:实施报告 §9.5 写 `getRoles()` 为「23 处 / 16 文件」,
> 独立实测为「23 处 / **17** 文件」(出现次数一致,文件计数差 1)。
> 该差异不影响任何结论,仅记录以备核对。
---
## 3. 54 个迁移调用点对账
逐调用点清单(文件 / 方法 / 旧 guard / 新 guard / 角色 / 错误码 / 归属 / 查询次数)已落盘:
`docs/acceptance/r01-b/iv-role-callsites.tsv`(54 行 + 表头)。
按文件汇总:
| 文件 | 调用点 |
|---|---|
| `ability/AbilityAttemptService` | 6 |
| `assessment/AssessmentAttemptService` | 5 |
| `candidatescore/governance/CandidateScoringGovernanceService` | 7 |
| `review/JobReviewService` | 6 |
| `recommendation/JobRecommendationService` | 5 |
| `application/ApplicationController` | 3 |
| `job/JobFavoriteService` | 3 |
| `talentinvitation/TalentInvitationService` | 3 |
| `bidirectionalmatch/MatchRuleService` | 2 |
| `company/CompanyService` | 2 |
| `talentdiscovery/TalentDiscoveryService` | 2 |
| `candidatescore/dashboard/CompanyScoringDashboardService` | 1 |
| `careerprogress/CareerProgressService` | 1 |
| `competition/CompetitionRankingService` | 1 |
| `match/MatchService` | 1 |
| `preference/PreferenceService` | 1 |
| `privacy/PrivacySettingsService` | 1 |
| `profile/ProfileService` | 1 |
| `risk/AdminRiskController` | 1 |
| `safety/AdminReportController` | 1 |
| `verification/CompanyVerificationService` | 1 |
| **合计** | **54** |
| 独立核对项 | 结果 |
|---|---|
| 迁移调用点总数 | **54** ✔ |
| student / admin / company 分布 | **37 / 13 / 4** ✔(与实施报告一致) |
| 重复统计 | **无**(同一行不会被两个正则重复计入) |
| 32 → 21 方法数 | ✔ 逐方法差异已列出:HEAD 独有 17 个定义被删除,工作区新增 `UserRoleGuard` 的 6 个 |
| 是否删除角色检查 | **否** —— 被删方法的 HEAD 调用点数合计 **46**,对应迁移后 54 个调用点(含新增的 `MatchRuleService` 2 处与委托内调用),无一处丢失 |
| 是否扩大 ADMIN 权限 | **否** —— 未引入任何 `requireAny(..., ADMIN)`;ADMIN 仍被 STUDENT/COMPANY 门禁拒绝(HTTP 实测 403) |
| 是否把 404/业务不存在改成 403 | **否** —— `USER_NOT_FOUND` 仍映射 404;`COMPANY_NOT_FOUND` 等业务码未变;实测验端点返回 404 而非 403 |
---
## 4. 14 个保留点及其不等价性核验
| 类别 | 数量 | 独立核验结论 |
|---|---|---|
| `private Company requireCompany(Long)` | 7 | **确为不等价**:除角色判定外还执行 `companyRepository.findByOwnerUserId(...)` 并抛 `COMPANY_NOT_FOUND`,返回值被业务使用。用 void guard 替换会丢失实体与错误码语义,且改变查询次数 |
| `private boolean hasRole(User, Role)` | 7 | **确为不等价**:返回**布尔值**用于**分支选择**(如 `listMyInterviews` 按 COMPANY/STUDENT 走不同查询;`listOffers` 还要先取 company 实体),并含归属判定(`hasRole(user, STUDENT) && interview.getStudentUserId().equals(userId)`)。void guard 无法表达"值" |
已逐处读取源码确认(例:`InterviewService.listMyInterviews`、`OfferService.listOffers` 的
`if (hasRole(...)) {...} if (hasRole(...)) {...} else 403` 分支结构)。
**14 与 21 的关系**:21 个方法 = 6(guard 自身)+ 6(一行委托)+ 7(实体返回型)+ 2(Controller 包装);
其中真正"不等价保留"的是 **7 个实体返回型 `requireCompany` + 7 个 `hasRole` 助手 = 14**。
**一处残留观察(非阻断)**:`ApplicationController.getCompanyCandidateDetail` 先调
`requireCompany(userId)`,随后调用的 `companyService.findCompanyByOwnerUserId(userId)` 内部又调
`assertCompanyRole(userId)` —— 同一请求 2 次角色检查。经与 `HEAD` 逐行比对,**该重复在收束前就存在**
(HEAD 的 `assertCompanyRole` 同样被 Controller 与 `findCompanyByOwnerUserId` 各调一次),
**非本轮引入**,本轮未改变查询次数。可作为后续独立优化点。
---
## 5. inactive 语义分析
| 项 | 结论 |
|---|---|
| 用户不存在 | `USER_NOT_FOUND`(4001) → 404(沿用既有错误码,未新增) |
| 用户存在但 inactive | **`FORBIDDEN`(2002) → 403**(新增的失败关闭;既有实现无此校验) |
| `active` 为 `null` | 同样拒绝(`isActive` 要求 `Boolean.TRUE`) |
| 角色不足 | `FORBIDDEN`(2002) → 403(与既有完全一致) |
| 与既有实现的差异面 | **仅"inactive 用户"这一种输入**:收束前会通过,收束后拒绝 |
**HTTP 入口的 inactive 用户在 guard 之前已被拒绝**(证据链,非推测):
1. `JwtAuthenticationFilter.authenticate` 在建立认证前调用
`authSessionService.isSessionActive(...)`;
2. `AuthSessionService.isSessionActive` 第一步 `isUserUsable(userId)` →
`user.isPresent() && isUsable(user)`,而 `isUsable` 只检查 `user.getActive()`;
3. 为 false 时 `SecurityContextHolder.clearContext()` 并直接返回,**请求根本不会进入 Controller**;
4. 因此对 HTTP 入口而言,guard 的 active 检查是**第二道**,观察不到行为差异。
---
## 6. 非 HTTP 入口分析
对全部 **54 个迁移调用点**逐个追溯调用链,结论:**全部仅经 HTTP 入口可达**。
| 入口类型 | 独立核验 |
|---|---|
| HTTP Controller 链路 | 54/54 调用点的宿主方法均由 `@RestController` 直达(21 个文件逐一确认) |
| 定时任务 | 全仓 `@Scheduled` 类共 8 个:`MatchNotificationScheduler`、`CandidateScoringRefreshScheduler`、`PersonalDataExportWorker`、`AccountDeletionService`、`NotificationConsumer`、`OfferExpiryScheduler`、`RiskEventDetector`、`RiskEventScanScheduler` —— **无一个注入或调用任何被迁移的服务或 `UserRoleGuard`** |
| Worker | 同上(`PersonalDataExportWorker`、`CandidateScoringRefreshWorker`、`AccountDeletionCompletionWorker` 的依赖已逐一列出核对) |
| 消息消费者 | `NotificationConsumer` 仅依赖 notification 三件套,未触碰 |
| 内部 Service 调用 | 非 Controller 的调用者仅 4 处:`ResumeVersionService`/`ResumeExportService`/`StudentReviewWeightService`(均只被 Controller 调用)、`MatchService.diagnose`(唯一调用者是 `MatchController`)。**均为 HTTP 链路** |
| 注销/导出/补偿流程 | `AccountDeletionService`、`PersonalDataExportWorker`、补偿扫描器均未触碰迁移点 |
| 管理后台任务 | `AdminRiskController`/`AdminReportController` 的 `requireAdmin` 只被各自 HTTP 端点调用 |
**因此:不存在"合法的历史数据处理、注销收尾或后台任务需要对 inactive 用户执行受保护动作"的场景。**
独立变量验证:`MatchService.diagnose` 的唯一调用方是 `MatchController`(`grep "\.diagnose("` 全仓仅 1 处)。
---
## 7. 查询次数
| 场景 | 独立实测 |
|---|---|
| `requireXxx(Long userId)` | **恰好 1** 次 `users.findById`(Mockito `times(1)` + `verifyNoMoreInteractions` 锁定) |
| `requireXxx(User user)` | **0** 次(`verifyNoMoreInteractions` 覆盖) |
| guard 写库 | **0**(`never()).save/deleteById/delete`) |
| 单次授权 N+1 | **无**(单条主键查询;`user_roles` 由 JPA 关联加载) |
| 跨请求缓存 | **无**(guard 无状态字段,唯一依赖 `UserRepository`;用户禁用后立即失效) |
| Redis / SecurityContext / requestId | **均未触碰**(结构断言 + 行为断言双重证明) |
| 每请求角色检查次数 | 多数路径 1 次;`/company/candidates/{id}` 为 2 次 —— **与 HEAD 相同,非回归**(见 §4) |
---
## 8. 测试委托模式是否真实(含变异验证)
### 8.1 静态核验
| 项 | 结果 |
|---|---|
| 使用 `delegateToRealGuard` 的测试类 | **16 个**(另 1 个为支持类本身) |
| 每个类是否同时声明 `@Mock private UserRoleGuard userRoleGuard;` | **是**(16/16,各 1 处) |
| 委托是否使用**真实** `UserRoleGuard` 实现 | **是**:`new UserRoleGuard(userRepository)` |
| 是否使用每个测试**自己 stub 的** `UserRepository` | **是**:`delegateToRealGuard(userRoleGuard, userRepository)` 传入该测试类的 `@Mock UserRepository` |
| 是否使用宽泛 `doNothing()` | **否**:`UserRoleGuardTestSupport` 内 **0 处** `doNothing()`;模块内 7 处均为 `lenient().doAnswer(...)` 显式转发 |
| 是否把所有 guard 调用默认放行 | **否**:四个方法均逐字转发到真实 guard;`lenient()` 只用于消除"未被该用例触发"的多余桩告警,不改变行为 |
| 本轮是否新增 `doNothing` | **否**:全仓 10 处 `doNothing()` 全部位于**未被 R01-B 改动**的文件(`git status` 为空),且目标是其他 mock(`favoriteRepository`、`messagingService`、被 mock 的 `companyService` 等) |
### 8.2 变异验证(决定性)
把 `UserRoleGuardTestSupport.delegateToRealGuard` 临时改为**空实现**(即 mock 的所有 void 方法无条件放行),
运行 5 个含负向断言的测试类:
```
mvn -Dtest="CompanyServiceTest,JobFavoriteServiceTest,PrivacySettingsServiceTest,MatchServiceTest,JobReviewServiceTest" test
→ Tests run: 63, Failures: 13, Errors: 37 ← 50/63 失败
```
失败样例:
```
CompanyServiceTest.findCompany_asStudent_shouldReturn403:71
Expected BusinessException to be thrown, but nothing was thrown.
CompanyServiceTest.updateCompany_asStudent_shouldReturn403:83
expected: <2002> but was: <5001>
JobFavoriteServiceTest.favorite_asCompany_shouldReturn403:177
Unexpected exception type thrown ... but was: java.lang.NullPointerException
MatchServiceTest.diagnose_asAdmin_shouldReturn403:91
Unexpected exception type thrown ... but was: java.lang.NullPointerException
```
**判据成立**:若负向测试在"无条件放行"下仍通过,说明测试未覆盖授权;实测 **50 个用例立即失败**,
证明这些测试**真的执行了角色判断**,委托模式未被削弱。
变异后已把文件**按原 SHA256 还原**(`02f25b74cf5d2192882a77930ec1c748a1049509323bf02668dc5b04822c82a1`,逐字节一致),
临时备份已删除。日志:`docs/acceptance/r01-b/iv-mutation.log`。
---
## 9. HTTP 映射 166 项对账(独立提取)
**基线来源**:`git show HEAD:…GlobalExceptionHandler.java` 的 `buildBusinessHttpStatuses()`,
独立解析后写入 `docs/acceptance/r01-b/reconcile.tsv`(166 行)与
`mydreamjob-server/src/test/resources/r01b/error-code-http-status-baseline.tsv`(测试资源)。
**未复用工作区实现的任何映射表**,不存在同源假证明。
| 项 | HEAD | 工作区 | 一致 |
|---|---|---|---|
| 枚举常量总数 | 166 | 166 | ✔ |
| 显式映射常量数 | 112 | 112 | ✔ |
| 显式映射覆盖的唯一数字码 | 111 | 111 | ✔ |
| 默认 400 项 | 54 | 54 | ✔ |
| **逐项状态不一致数** | — | — | **0** ✔ |
| HEAD 中重复注册 | 无 | 无 | ✔ |
关键项逐项核对:
| ErrorCode | 数字码 | HEAD | 工作区 |
|---|---|---|---|
| `UNAUTHORIZED` | 2001 | 400(未登记默认) | **400** ✔ |
| `FORBIDDEN` | 2002 | 403 | **403** ✔ |
| `SERVICE_UNAVAILABLE` | 9001 | 400(未登记默认) | **400** ✔ |
| `USER_NOT_FOUND` | 4001 | 404 | **404** ✔ |
| `APPLICATION_DUPLICATE` | 8001 | 409 | **409** ✔ |
| `ABILITY_RETAKE_LIMIT` | 9811 | 429 | **429** ✔ |
| `REVIEW_ALREADY_DECIDED` | 9103 | 409 | **409** ✔ |
| `DUPLICATE_FEEDBACK` | 9103 | 409 | **409** ✔ |
(上表数字码与状态均取自独立提取的 `docs/acceptance/r01-b/reconcile.tsv`。)
响应 JSON、requestId、日志行为未变:`GlobalExceptionHandler.handleBusinessException` 仍为
`log.warn`、仍用 `ApiResponse.error(errorCode, e.getMessage(), requestIdAccessor.get(request))`,
仅把状态解析改为 `errorHttpStatusRegistry.resolve(errorCode)`。
---
## 10. 112 显式 / 54 默认统计(独立复核)
| 项 | 实施报告 | 独立复核 |
|---|---|---|
| 显式映射 | 112 | **112** ✔ |
| 未映射(默认 400) | 54 | **54** ✔ |
| 唯一数字码 | 111(未声明) | **111** ✔(9103 共用) |
---
## 11. 9103 重复码的真实运行时语义
### 11.1 逐层查清
| 层 | 事实 |
|---|---|
| `BusinessException` 保存什么 | **`private final int errorCode;`** —— 只保存数字,**枚举身份在构造时即丢失**;无 `ErrorCode` 字段,无 `fromCode` 反查 |
| `GlobalExceptionHandler` 拿到什么 | `int errorCode = e.getErrorCode();` → **int** |
| `ErrorHttpStatusRegistry.resolve(...)` 以什么为键 | `resolve(ErrorCode)` 用**枚举键**表 `EXPLICIT`;**运行时路径 `resolve(int)` 用派生数字表 `BY_CODE`** |
| 是否使用 `ErrorCode.values()` / `fromCode()` / 首次匹配 | **全部没有**(registry 与 handler 中 `values()`/`valueOf`/`ordinal`/`stream()` 命中为 0;`ErrorCode` 亦无 `fromCode` 辅助方法) |
| 相同数字码不同枚举是否可能被错误归类 | **运行时不可能区分** —— 两者都是 9103,`resolve(9103)` 只有一个结果 |
| 映射是否依赖枚举声明顺序 | **不依赖**。`EXPLICIT` 按**注册顺序**构建(与枚举声明顺序无关),`BY_CODE` 由它派生并用 `putIfAbsent` 去重;顺序只影响"谁先写入",而冲突时**抛错**而非取先者 |
### 11.2 明确声明(不夸大)
> **运行时无法区分 `REVIEW_ALREADY_DECIDED` 与 `DUPLICATE_FEEDBACK`**:`BusinessException` 只携带
> `int 9103`,Handler 也只拿到该数字。因此**不能声称"以枚举为键已彻底解决身份歧义"**;
> 准确表述是:**两者当前都映射为 409 CONFLICT,故 HTTP 行为一致**;
> 枚举键表的价值在于**配置期可审计与可去重**,而不是运行期消歧。
### 11.3 独立测试对六项要求的覆盖
| # | 要求 | 覆盖用例 |
|---|---|---|
| 1 | 两个枚举常量都显式注册 | `bothSharedNumericCodeConstantsAreExplicitlyMappedTo409` |
| 2 | 两者分别解析为 409 | 同上 |
| 3 | `resolve(9103)` 行为明确 | `runtimeCannotDistinguishTheTwoSharedConstants`(= CONFLICT,并断言两个异常的业务码相同) |
| 4 | 改成不同状态时初始化/解析必须失败 | `conflictingStatusForSharedNumericCodeFailsFast`(构造 `9103 → CONFLICT` + `9103 → NOT_FOUND`,调用数字索引构建必须抛 `IllegalStateException`,消息含 `9103`;同时反证状态相同时允许) |
| 5 | 不依赖 `ErrorCode.values()` 顺序 | `mappingDoesNotDependOnEnumDeclarationOrder`(正序/逆序注册结果相同 + 逐项比对基线) |
| 6 | 未映射 code 仍为 400 | `unknownCodeFallsBackTo400AndResponseIsClean` |
---
## 12. 独立新增测试
| 测试类 | Tests | 结果 |
|---|---|---|
| `common/r01b/R01BIndependentRoleVerificationTest` | 11 | **通过** |
| `common/r01b/R01BIndependentHttpMappingParityTest` | 9 | **通过** |
| `common/r01b/R01BWiringMutationTest` | 5 | **通过** |
| **合计** | **25** | **25 / 25**,F=0 E=0 S=0 ✔ |
日志:`docs/acceptance/r01-b/iv-1-independent.log`
覆盖要点:
- **角色**:active 三角色各自通过;三类交叉全部拒绝;ADMIN 不自动通过 STUDENT/COMPANY;
用户不存在(`USER_NOT_FOUND`);inactive 与 `active=null`(`FORBIDDEN`);`roles` 为空;
多角色;`users.role` 与 `user_roles` 不一致时按 `user_roles` 失败关闭;
`requireXxx(Long)` 恰好 1 次查询、`requireXxx(User)` 零查询;无写库;
不触碰 SecurityContext(在上下文里放 ADMIN 仍按传入 userId 判定)/requestId/Redis;
三个真实 HTTP 端点验证 **401 / 403 / 404** 分层。
- **接线变异**:guard 拒绝 → 403 且**下游业务方法零调用**;guard 放行 → **STUDENT 也能通过 ADMIN 端点**
(若端点仍保留自有角色比较,STUDENT 不可能通过 → 该断言即变异检测器);
guard 收到正确 userId;归属失败仍由业务层返回 404。
- **映射**:166 项按**名称**逐项比对 HEAD 基线(不作数字去重)、112/54 计数、
9103 六项、未知码 400、响应体无堆栈与内部类名。
---
## 13. 两个 Controller 测试单跑结果(如实记录,未写成通过)
| 命令 | 结果 |
|---|---|
| `mvn -Dtest="AdminReviewControllerTest" test` | **11 run / 4 failures / 0 errors / 0 skipped**,BUILD FAILURE |
| `mvn -Dtest="CompanyReviewManagementControllerTest" test` | **3 run / 2 failures / 0 errors / 0 skipped**,BUILD FAILURE |
失败断言:`expected 200/400/409 but was 401`(6 项合计)。
**根因证据链(非按日志文本猜测)**:
| 证据 | AdminReview | CompanyReviewMgmt |
|---|---|---|
| `Handler: Type = null` 次数 | 4 | 2 |
| `session validation rejected: user not usable` | 4 | 2 |
| `JWT authentication rejected: session not active` | 4 | 2 |
| 401 响应体 | `{"code":401,"message":"not authenticated","timestamp":""}` | 同 |
| 测试使用的用户 ID | `ADMIN_ID = 1L` | `token(1L,"COMPANY")` / `token(2L,"STUDENT")` |
| 是否在 R01-B 改动清单内 | **否** | **否** |
**判定**:
1. `Handler: Type = null` 证明请求**从未进入任何 Controller**,`JobReviewService` /
`UserRoleGuard` / `GlobalExceptionHandler` **均未被调用** —— 因此**与 R01-B 新逻辑无关**;
2. 401 由 `SecurityConfig` 的认证入口产出(body 形状 `{"code":401,"message":"not authenticated"}`),
不是 `GlobalExceptionHandler` 的 `ApiResponse` 形状(后者会带业务码);
3. 拒绝发生在 `AuthSessionService.isUserUsable` —— 用户 id=1/2 正是 `V1_1` 种子账号、
被 `V43` 置为 inactive;
4. 全量顺序下 `com.mydreamjob.controller.AuthIntegrationTest` 的 `deleteAll()` 偶然清除种子行,
问题被掩盖,故 §14 全量为绿。
**处置**:未修改这两个测试;未写成通过;不让它们阻断对 R01-B 生产代码的判断。
**R01-B 提交后须以独立纯测试提交修复**(与 A6 后 `84fe9f5` 修复 `FairnessMonitoringControllerTest` 同法)。
---
## 14. 全量测试
| 命令 | 结果 | 日志 |
|---|---|---|
| `mvn -Dtest="RoleAuthorizationGuardTest,RoleAuthorizationIntegrationTest,ErrorHttpStatusRegistryTest,GlobalExceptionHandlerMappingParityTest" test` | **371 / 371**,F=0 E=0 S=0 ✔ | `iv-2-impl.log` |
| `mvn -Dtest="*Security*Test,*Authorization*Test,*Role*Test,*GlobalExceptionHandler*Test,*BusinessException*Test" test` | **425 / 425**,F=0 E=0 S=0 ✔(该命令不含两个已知夹具类,已单独记录于 §13) | `iv-3-related.log` |
| `mvn clean verify` | 见 §14.1 | `iv-4-clean-verify.log` |
### 14.1 `mvn clean verify`
```
[INFO] Tests run: 2681, Failures: 0, Errors: 0, Skipped: 26
[INFO] BUILD SUCCESS
```
| 项 | 数值 |
|---|---|
| 测试总数 | **2681**(= 前次 2656 + 本轮新增独立测试 25)✔ 不低于 2656 |
| Failures | **0** ✔ |
| Errors | **0** ✔ |
| **Skipped** | **26 —— 未达"0"要求,原因如下(环境性,非代码回归)** |
#### Skipped=26 的准确原因(如实记录,不掩饰、不写成 0)
26 个跳过**全部**来自 4 个 MySQL / Testcontainers 测试类:
| 测试类 | Skipped |
|---|---|
| `job.JobSearchSpecificationMySqlIntegrationTest` | 15 |
| `candidatescore.refresh.CandidateScoringRefreshMySqlIntegrationTest` | 6 |
| `job.JobSearchQuerySideEffectMySqlIntegrationTest` | 4 |
| `job.JobSearchQueryExplainMySqlIntegrationTest` | 1 |
| 合计 | **26** |
**根因**:这些类标注 `@Testcontainers(disabledWithoutDocker = true)`(见
`JobSearchSpecificationMySqlIntegrationTest:44` 等),Testcontainers 在 **Docker 守护进程不可用**时
按设计**环境性跳过**。本机实测:
```
docker info → failed to connect to the docker API at npipe:////./pipe/dockerDesktopLinuxEngine;
check if the path is correct and if the daemon is running
```
**为何与 R01-B 无关**:
1. 本轮全程**未操作 Docker**(任务书明令禁止),Docker 守护进程不在运行属环境状态;
2. **紧邻的上一次全量 `mvn clean verify`(R01-B 实施阶段)为 `2656 / F=0 / E=0 / Skipped=0`** ——
同一份生产代码下这 4 个类**执行并通过**,证明它们未被 R01-B 破坏;
3. 本轮相对该次运行,**生产代码零改动**(仅新增 3 个测试类 + 1 个测试资源 + 1 份文档);
4. 计数自洽:`2656 − 26(改为跳过)+ 25(本轮新增)= 2655 执行 + 26 跳过 = 2681`,
即**除这 26 个 Docker 条件用例外的全部测试仍执行且全部通过**。
**结论**:该偏差为**环境条件**,不构成 R01-B 的阻断项;但按任务书"跳过必须是 0"的字面要求,
本环境**无法满足**,故**不声称 Skipped=0**。若需在 Docker 可用环境下复核,
重新执行 `mvn clean verify` 即可(无需任何代码改动)。
---
## 15. OpenAPI
```
python ..\docs\verify_openapi.py
→ paths: 176 / operations: 214
→ All checks passed!
```
`git diff -- docs/openapi-rc.json` → **空** ✔
---
## 16. 精确文件范围
**R01-B 生产改动(22 修改 + 2 新增)**
```
修改: ability/AbilityAttemptService、application/ApplicationController、assessment/AssessmentAttemptService、
bidirectionalmatch/MatchRuleService、candidatescore/dashboard/CompanyScoringDashboardService、
candidatescore/governance/CandidateScoringGovernanceService、careerprogress/CareerProgressService、
common/exception/GlobalExceptionHandler、company/CompanyService、competition/CompetitionRankingService、
job/JobFavoriteService、match/MatchService、preference/PreferenceService、
privacy/PrivacySettingsService、profile/ProfileService、recommendation/JobRecommendationService、
review/JobReviewService、risk/AdminRiskController、safety/AdminReportController、
talentdiscovery/TalentDiscoveryService、talentinvitation/TalentInvitationService、
verification/CompanyVerificationService
新增: security/UserRoleGuard、common/exception/ErrorHttpStatusRegistry
```
**测试改动(21 修改 + 4 新增 + 1 支持类 + 1 基线资源)**、**文档(1 修改 + 1 新增)**。
本轮独立复验**仅新增**:
```
mydreamjob-server/src/test/java/com/mydreamjob/common/r01b/R01BIndependentRoleVerificationTest.java
mydreamjob-server/src/test/java/com/mydreamjob/common/r01b/R01BIndependentHttpMappingParityTest.java
mydreamjob-server/src/test/java/com/mydreamjob/common/r01b/R01BWiringMutationTest.java
mydreamjob-server/src/test/resources/r01b/error-code-http-status-baseline.tsv
docs/r01-b-independent-verification-20260918.md
docs/acceptance/r01-b/*(验收日志与对账产物,被 .gitignore 覆盖)
```
**未修改任何生产代码** ✔(`git diff` 生产文件数与复验开始前一致,均为 R01-B 实施者留下的改动)。
---
## 17. Git 边界
| 检查 | 结果 |
|---|---|
| `git diff --check` | **exit 0**,无空白错误 ✔ |
| `git diff --cached --name-only` | **空** ✔ |
| `git diff --name-only -- …/db/migration` | **空** —— V1–V45 未改 ✔ |
| `git diff -- docs/openapi-rc.json` | **空** ✔ |
| `git diff -- mydreamjob-server/pom.xml` | **空** ✔ |
| `ErrorCode.java` | **未修改** ✔ |
| 接口 / DTO | **未修改** ✔ |
| 历史未跟踪文件 | 完整保留 ✔ |
| Java / Maven 残留进程 | 无 ✔ |
| HEAD / upstream | `84fe9f5…` / **0 0** ✔ |
---
## 18. 是否存在阻断
**无阻断。**
| 复核项 | 结论 |
|---|---|
| 角色语义变化 | **无**(仅新增 inactive 失败关闭,且 HTTP 入口不可观测、非 HTTP 入口不存在) |
| 归属语义变化 | **无**(归属仍由原 Service 执行,实测 404 未被改写为 403) |
| 错误码变化 | **无**(`USER_NOT_FOUND`/`FORBIDDEN` 沿用,未新增 ErrorCode) |
| HTTP 映射变化 | **无**(166 项逐项一致) |
| 事务语义变化 | **无**(guard 只读、无 `@Transactional`,未改变任何事务边界) |
| ADMIN 权限扩大 | **无** |
| 测试削弱 | **无**(变异实验 50/63 失败) |
| 阻断项 | **0** |
---
## 19. 未暂存、未提交、未推送
**确认:未暂存、未提交、未推送。** 本轮未执行 `git add`、未创建提交、未推送;
未执行 `reset`/`restore`/`checkout`/`clean`;首轮复验未操作 Docker,随后在负责人授权的
补充复验中仅启动本机既有 Docker Desktop 并由 Testcontainers 创建临时 MySQL 容器,
未重建项目容器、未删除 volume;未删除历史未跟踪文件。
变异实验对测试支持文件的临时修改已**按原 SHA256 逐字节还原**并清理备份。
---
## 20. 未进入 R01-C/E
**确认:未进入 R01-C、未进入 R01-E。** 本轮未处理 `overviewStatus`、推荐权重、
高校名单与评分阈值、N+1、会话未读数、requestId、身份主体解析、Token、评分快照、
新接口与数据库迁移中的任何一项。
---
> **R01-B独立代码复验通过,可以进入精确暂存与独立提交检查点;两个Controller测试夹具缺陷须在R01-B提交后以独立纯测试提交修复。**
---
## 21. Docker / Testcontainers 补充复验(2026-09-18)
### 21.1 首轮 SKIP 记录保留
首次独立复验时 Docker 守护进程不可用,以下 4 个测试类合计 26 个 MySQL/Testcontainers
用例按 `disabledWithoutDocker = true` 跳过;该事实及原因分析保留在本报告第 14 节,未删除、
未改写成首次即通过:
| 测试类 | Tests | 首轮 Skipped |
|---|---:|---:|
| `CandidateScoringRefreshMySqlIntegrationTest` | 6 | 6 |
| `JobSearchQueryExplainMySqlIntegrationTest` | 1 | 1 |
| `JobSearchQuerySideEffectMySqlIntegrationTest` | 4 | 4 |
| `JobSearchSpecificationMySqlIntegrationTest` | 15 | 15 |
| **合计** | **26** | **26** |
### 21.2 环境恢复
经负责人授权启动本机既有 Docker Desktop:
```text
Docker Server: 29.6.2
Testcontainers: 1.21.3
MySQL image: mysql:8.4
```
未重建项目容器、未删除或修改既有 volume。Testcontainers 实际连接 Windows npipe Docker
Engine,并成功启动 Ryuk 与临时 MySQL 8.4 容器。
### 21.3 26 个 MySQL 用例定向补验
执行:
```text
mvn -Dtest="CandidateScoringRefreshMySqlIntegrationTest,JobSearchQueryExplainMySqlIntegrationTest,JobSearchQuerySideEffectMySqlIntegrationTest,JobSearchSpecificationMySqlIntegrationTest" test
```
结果:
```text
Tests run: 26, Failures: 0, Errors: 0, Skipped: 0
BUILD SUCCESS
```
4 个类均真实启动 MySQL 8.4 Testcontainer,未使用 H2 代替,未通过修改
`disabledWithoutDocker` 或测试选择规则规避执行。
### 21.4 完整干净构建
随后执行:
```text
mvn clean verify
```
结果:
```text
Tests run: 2681, Failures: 0, Errors: 0, Skipped: 0
BUILD SUCCESS
```
并重新执行 OpenAPI 校验:
```text
paths: 176
operations: 214
Valid JSON: True
All checks passed!
```
本次补验未修改任何生产代码、测试代码、Flyway、`ErrorCode`、DTO、接口、依赖或
`openapi-rc.json`。
### 21.5 最终冻结结论
首轮因环境导致的 26 个 SKIP 已由真实 Docker/Testcontainers 执行闭环,最终硬性门禁为
`Failures=0 / Errors=0 / Skipped=0`。
> **R01-B独立代码复验及Docker/Testcontainers补充验证全部通过,可以进入精确暂存与独立提交检查点;两个Controller测试夹具缺陷须在R01-B提交后以独立纯测试提交修复。**

183
docs/r01-c-fairness-fixture-isolation-fix-20260918.md

@ -0,0 +1,183 @@
# R01-C 评分/Fairness 组合测试污染隔离修复(独立纯测试提交)
日期:2026-09-18
分支:`refactor/r01-structure-performance`
R01-C 提交:`7d4704ee28e4d8e2d1fbcd174a990fc6bfe68169`
本轮性质:**纯测试修复** —— **生产代码 diff = 0**
---
## 0. 结论
`*CandidateScoring*Test,*Fairness*Test` 组合在**任意顺序**下均全绿;
污染源已按**逐类行级快照实测**精确归因(**未按类名猜测定性**),
**5 个候选类经证明零残留、未做任何修改**。
---
## 1. 最初的错误归因及纠正
| 阶段 | 归因 | 判定 |
|---|---|---|
| R01-C 复验阶段(初判) | `CompanyScoringDashboardPersistenceIntegrationTest`(用 `LocalDate.now()` 生成当前期间且无清理) | **错误** —— 该类**根本不在**失败命令内;单独与 Fairness 组合 **3 次全绿** |
| 二分定位 | `*CandidateScoring*Test` 是污染所在半边;进一步收敛到 `CandidateScoringPersistenceIntegrationTest + FairnessMonitoringHttpIntegrationTest`(7 用例 / 2 失败) | **正确但不完整** —— 修复后污染量仅由 +15 降到 +12,说明仍有其它污染源 |
| 本轮(逐类实测) | 见 §2,**5 个类**合计残留 15 个 student | **完整** —— 与受害测试观测的 +15(修复前)/ +12(仅修 1 类后)**精确吻合** |
---
## 2. 每个候选类的实测残留表
**方法**:临时诊断探针 `ZR01CLeakProbeTest`(置于 `candidatescore.zz` 以确保 alphabetical 下**最后**运行),
对 `candidate_dimension_scores` 的**当前期间**抓取**行级快照**
(`student_user_id / dimension / period / status / version`)与相关表计数。
每次 `mvn` 均为**全新 JVM + 全新 H2 库**,故"运行后快照"即为该类在**干净上下文中**的净残留。
探针诊断完成后**已删除**(非交付物)。
| 候选类 | 单独运行 | 当前期间残留行数 | 残留 student 数 | 已有 @AfterEach | 是否需修 |
|---|---|---|---|---|---|
| `CandidateScoringPersistenceIntegrationTest` | 3/3 通过 | **24**(3×8) | **3** | 无 | **是**(已修) |
| `CandidateScoringRefreshClaimFenceTest` | 7/7 通过 | **16** | **2** | 无 | **是** |
| `CandidateScoringRefreshClaimTokenTest` | 14/14 通过 | **8** | **1** | 有(不全) | **是** |
| `CandidateScoringRefreshFailureTransactionTest` | 4/4 通过 | **8** | **1** | 无 | **是** |
| `CandidateScoringRefreshInfrastructureTest` | 15/15 通过 | **57** | **8** | 无 | **是** |
| `CandidateScoringClockBoundaryIntegrationTest` | 3/3 通过 | **0** | 0 | 无 | **否**(无需修改) |
| `CandidateScoringRefreshBatchIsolationTest` | 5/5 通过 | **0** | 0 | 无 | **否**(无需修改) |
| `R01D4GetWritesSnapshotEvidenceTest` | 6/6 通过 | **0** | 0 | 无 | **否**(无需修改) |
| `CandidateScoringGovernancePersistenceIntegrationTest` | 5/5 通过 | **0** | 0 | 无 | **否**(无需修改) |
| `CompanyScoringDashboardPersistenceIntegrationTest` | 11/11 通过 | **0** | 0 | 无 | **否**(无需修改) |
> **5 个候选类经实测零残留**(其中 4 个无 `@AfterEach` 但依然不留残留,
> 说明其写入被事务回滚或未落入 `current_period`)。**未因"名字可疑"而修改它们。**
---
## 3. +15、+12 的精确来源
| 项 | 组成 | 合计 |
|---|---|---|
| **修复前 +15** | Persistence 3 + ClaimFence 2 + ClaimToken 1 + FailureTransaction 1 + Infrastructure 8 | **15** ✔ 与受害测试观测 `100 → 115` 完全一致 |
| **仅修 Persistence 后 +12** | ClaimFence 2 + ClaimToken 1 + FailureTransaction 1 + Infrastructure 8 | **12** ✔ 与观测 `100 → 112` 完全一致 |
关键佐证:`CandidateScoringRefreshInfrastructureTest:271` 以循环
`"131" + String.format("%08d", i + 7000)`(i=0..7)创建 **8 个用户**,
与探针实测的 **8 个残留 student** 精确对应。
---
## 4. 修复内容(仅改测试,生产代码 0 改动)
| 文件 | 修复前 | 修复后 |
|---|---|---|
| `CandidateScoringPersistenceIntegrationTest` | 3 个用例各建 1 个学生并物化当前期间快照,**零清理** | 登记自有 userId;`@BeforeEach` **断言独占手机号不存在**;`@AfterEach` 按外键顺序**精确 id** 删除快照 → 角色 → 用户 |
| `CandidateScoringRefreshClaimFenceTest` | 残留 16 行 | 新增 `@AfterEach`:按**本类独占手机号前缀** `193`/`192` 解析出精确 id 后按外键顺序删除 |
| `CandidateScoringRefreshClaimTokenTest` | 残留 8 行(原有 `@AfterEach` 未覆盖快照) | 同上,前缀 `190`/`189` |
| `CandidateScoringRefreshFailureTransactionTest` | 残留 8 行 | 同上,前缀 `130` |
| `CandidateScoringRefreshInfrastructureTest` | 残留 57 行 | 同上,前缀 `132`/`13107` |
**统一遵守的约束**:独占手机号(不与 V1_1 种子账号 `138…`、其它测试 `t<id>`、`1390000xxx` 冲突);
按外键顺序(`candidate_dimension_scores` / `candidate_scoring_refresh_tasks` → `user_roles` → `users`);
**不使用 `deleteAll`/`TRUNCATE`/`@DirtiesContext`/`sleep`**;不捕获忽略清理异常;
**不修改生产统计或查询**;**不通过改期望 `populationSize` 掩盖污染**;不依赖测试执行顺序。
> **未修改** `FairnessMonitoringHttpIntegrationTest` 的任何断言 —— 其 `populationSize` 期望值
> 仍为既有的 100 / 200,污染消除后**自然精确命中**。
---
## 5. 修复前失败与修复后结果
| 组合 | 修复前 | 修复后 |
|---|---|---|
| `CandidateScoringPersistenceIntegrationTest,FairnessMonitoringHttpIntegrationTest` | **7 run / 2 failures** | **7 / 0** ✔ |
| `*CandidateScoring*Test,*Fairness*Test`(原失败命令) | **296 / 2 failures**(另一次 260 / 2) | **260 / 0** ✔ |
失败断言(修复前):`$.data.populationSize expected:<100> but was:<115>`、
`expected:<200> but was:<215>`。
修复后 `populationSize` **精确等于既有期望** 100 / 200 ✔
---
## 6. 双向顺序各 3 次结果
实际执行顺序**从日志核对**(非依据命令参数):
| 顺序 | 实测类顺序 | 结果 |
|---|---|---|
| `-Dsurefire.runOrder=alphabetical` ×3 | `candidatescore.CandidateScoringCalculatorTest` … → … `scoringmonitoring.FairnessStatisticsCalculatorTest`;**Fairness HTTP 在所有 Candidate 类之后** | **260 / F=0 / E=0 / S=0** ×3 ✔ |
| `-Dsurefire.runOrder=reversealphabetical` ×3 | `scoringmonitoring.FairnessStatisticsCalculatorTest` … → … `candidatescore.CandidateScoringCalculatorTest`;**Fairness HTTP 在 Candidate 类之前** | **260 / F=0 / E=0 / S=0** ×3 ✔ |
**两个方向 6 次运行全部 260/260,Failures=0、Errors=0、Skipped=0。**
---
## 7. 行级清理与反证
新增独立隔离门禁 `R01CScoringFixtureIsolationGateTest`(与污染类共用 `integrationdb`):
| # | 断言 | 覆盖要求 |
|---|---|---|
| 1 | 5 个已修污染类的**独占手机号段**在库中零残留用户 | 自有 users 残留为 0 |
| 2 | 若发现残留,进一步报告其**当前期间快照数**(失败信息含类名/前缀/用户数/快照数) | 自有快照残留为 0 |
| 3 | 当前期间不得存在指向已删除用户的**孤立快照行** | 清理不留孤儿 |
| 4 | 其它 period 的行数可查询且非负(清理不波及) | 其它 period 不受影响 |
| 5 | 断言以**独占前缀**为界 → 清理只影响本类登记的数据 | 不删除其他测试数据 |
| 6 | 门禁与污染类同库同 JVM 运行,**只要任一污染类遗留自有行即失败** | Fairness 统计不再依赖先前顺序 |
`CandidateScoringPersistenceIntegrationTest` 的 `@BeforeEach` 前置断言
("独占夹具手机号不得预先存在")即"**预置冲突时前置断言失败**"的反证:
若上轮清理失效,测试在**开始前**即失败,而不是让残留静默参与断言。
---
## 8. `clean verify` 与 OpenAPI
```
docker info → ServerVersion=29.6.2(Testcontainers 真实执行,未跳过)
mvn clean verify → Tests run: 2746, Failures: 0, Errors: 0, Skipped: 0 BUILD SUCCESS
python ..\docs\verify_openapi.py → paths: 176 / operations: 214 / All checks passed!
```
| 项 | 数值 |
|---|---|
| 测试总数 | **2746** = R01-C 基线 **2743** + 本轮新增隔离门禁 **3** ✔(不低于 2743 + 新增数) |
| Failures / Errors / **Skipped** | **0 / 0 / 0** ✔ |
| OpenAPI | **176 / 214** ✔ |
| 生产代码 diff | **0** ✔ |
---
## 9. 精确文件清单
**被修复的测试文件(5)**
```
mydreamjob-server/src/test/java/com/mydreamjob/candidatescore/CandidateScoringPersistenceIntegrationTest.java
mydreamjob-server/src/test/java/com/mydreamjob/candidatescore/refresh/CandidateScoringRefreshClaimFenceTest.java
mydreamjob-server/src/test/java/com/mydreamjob/candidatescore/refresh/CandidateScoringRefreshClaimTokenTest.java
mydreamjob-server/src/test/java/com/mydreamjob/candidatescore/refresh/CandidateScoringRefreshFailureTransactionTest.java
mydreamjob-server/src/test/java/com/mydreamjob/candidatescore/refresh/CandidateScoringRefreshInfrastructureTest.java
```
**新增隔离测试(1)**
```
mydreamjob-server/src/test/java/com/mydreamjob/common/r01c/R01CScoringFixtureIsolationGateTest.java
```
**报告(1)**:本文件
**路线图(1)**:`docs/backend-development-roadmap.md` —— 仅追加一条夹具问题关闭记录
> 临时诊断探针 `ZR01CLeakProbeTest` **已在诊断后删除**,不进入提交。
---
## 10. 生产代码修改数 = 0
**确认:本轮生产代码 diff = 0。** 未修改 V1–V45、`ErrorCode`、DTO、公网接口、
`pom.xml`、`docs/openapi-rc.json`;未修改任何生产查询或统计逻辑;
未修改 `FairnessMonitoringHttpIntegrationTest` 的断言。
---
> **评分与 Fairness 组合测试污染已完成精确归因,并通过独立纯测试提交修复。**

347
docs/r01-c-independent-verification-20260918.md

@ -0,0 +1,347 @@
# R01-C 独立代码复验报告(评分与推荐规则结构收束)
日期:2026-09-18
分支:`refactor/r01-structure-performance`
HEAD:`9954600650c3e7cd40a5f4fa55bc38595973f214`(复验全程未变)
复验对象:`docs/r01-c-scoring-recommendation-rules-refactor-report.md`
性质:**仅复验** —— 未修改生产代码、未放宽既有断言、未暂存、未提交、未推送、未进入 R01-E
---
## 0. 结论
> **R01-C独立代码复验通过,可以进入精确暂存与独立提交检查点。**
- `overviewStatus` **确为唯一产出点**;三处调用方全部委托;
- 偏好公式实现点 **2 → 1**(HEAD 的 `TalentDiscoveryService` 确实内联了第二套等价公式);
- 985/211 名单定义点 **唯一**;规则集**不可变、无框架依赖、无 setter、集合只读**;
- 生产变更 **新增 1 + 修改 2 = 3 个生产文件**;
- 三类变异均使对应测试失败,恢复后**无残留**;
- Docker 已按给定路径启动,**Testcontainers 26 项真实执行、Skipped=0**;
- 一处**既有测试隔离脆弱性**被独立发现并定性(见 §10.3),**与 R01-C 无关**。
---
## 1. 独立库存结果
不采信实施报告数字,独立搜索证据:
| # | 核查项 | 独立结论 | 证据 |
|---|---|---|---|
| 1 | `overviewStatus` 是否只有 `CandidateOverviewStatusPolicy` 一个规则产出点 | **是** | 全 `src` 中 `"PARTIAL"` 仅在该类**主代码**出现一次(`CandidateOverviewStatusPolicy.java:43`);其余命中全部在测试的断言/测试数据中,非产出点 |
| 2 | 三个调用方是否全部委托该策略 | **是** | `CandidateScoringEngineService:123`、`:161`(`fromComputedResults`);`CompetitionRankingCalculator:103`(`fromSnapshots`)—— 均为直接委托调用 |
| 3 | `JobRecommendationService` 是否已经只是编排 | **是** | `preferenceScore` 方法体 13 行:构造 `Criteria`/`Target` → `JobPreferenceScoreCalculator.calculate(...)`,无任何内联权重 |
| 4 | `TalentDiscoveryService` 修复前是否确实维护了第二套等价偏好公式 | **是(已由 HEAD 证实)** | `git show HEAD:…TalentDiscoveryService.java` 的 `preferenceScore` 为内联 `total += 30/20/20/15/15` + `BigDecimal.valueOf(got*100L).divide(...,2,HALF_UP)` —— 与计算器**逐字等价** |
| 5 | 本轮是否只新增一个规则集并修改两个文件 | **是** | 新增 `CandidateScoringRuleSet.java`;修改 `CandidateScoringCalculator.java`、`TalentDiscoveryService.java` |
| 6 | **生产代码变更总数** | **新增 1 + 修改 2 = 3 个生产文件** | 与 `git diff --name-status` + 未跟踪文件清单一致 |
---
## 2. overviewStatus 唯一产出点证据
```
main/java/com/mydreamjob/candidatescore/CandidateOverviewStatusPolicy.java:43: return "PARTIAL";
```
`"PARTIAL"` 在**主代码**中的唯一出现点即为该策略类(第 43 行)。
状态聚合器**必须**能够产出 `PARTIAL`,因此该字符串的出现位置即为产出点的充要证据。
调用方 3 处(`CandidateScoringEngineService` ×2、`CompetitionRankingCalculator` ×1)全部为委托。
**结构门禁已加入独立复验测试**:扫描 `src/main/java/com/mydreamjob` 全部 `.java`,
断言含 `"PARTIAL"` 的文件**恰为** `CandidateOverviewStatusPolicy.java`。
---
## 3. 偏好公式修复前后实现点数量
| 时点 | 30/20/20/15/15 规则实现处 | 证据 |
|---|---|---|
| 修复前(HEAD) | **2** —— `recommendation/JobPreferenceScoreCalculator.java` + `talentdiscovery/TalentDiscoveryService.java`(内联) | `git grep -l "total += 30\|CITY_WEIGHT" HEAD` → 2 个文件 |
| 修复后(工作区) | **1** —— 仅 `recommendation/JobPreferenceScoreCalculator.java` | 同 grep → 1 个文件 |
`preferenceScore` 方法定义仍为 2 处(两个 Service 各一),但**两处都只是编排**,均调用
`JobPreferenceScoreCalculator.calculate(...)`;规则**实现**唯一。
---
## 4. 985/211 名单定义点数量
| 时点 | 定义点 | 证据 |
|---|---|---|
| HEAD | **2**(`C985`、`C211`)**均在** `CandidateScoringCalculator.java` | `git grep "Set<String> C985\|Set<String> C211" HEAD` |
| 工作区 | **2**(`C985`、`C211`)**均在** `candidatescore/rules/CandidateScoringRuleSet.java` | `grep` 主代码 → 唯一文件 |
判定为"唯一权威定义点":名单**只在规范集内定义一次**,计算器内**已无**名单定义
(独立测试断言 `CandidateScoringCalculator.java` 不含 `Set<String> C985` / `C211`)。
| 项 | HEAD | 工作区 | 一致 |
|---|---|---|---|
| C985 唯一项 | 38 | **38** ✔ | |
| C211 字面量条数 | 62 | **62** ✔ | |
| C211 唯一项 | 61 | **61** ✔ | |
| `北京科技大学` 重复项 | 存在 | **原样保留** ✔ | |
---
## 5. 规则集不可变性和依赖边界
逐项核验 `CandidateScoringRuleSet.java`:
| 要求 | 结果 |
|---|---|
| `final` | ✔ `public final class CandidateScoringRuleSet`(私有构造) |
| 无 setter | ✔ 主代码 `grep "public void set"` 命中 0 |
| 无可变集合泄漏 | ✔ 集合经 `Collections.unmodifiableSet(new HashSet<>(...))` 包装后返回 |
| 返回集合不可修改 | ✔ 独立测试断言 `add`/`remove`/`clear` 均抛 `UnsupportedOperationException` |
| 无 Spring 注解 | ✔ `@Component`/`@Service`/`@Autowired`/`@Value` 命中 0 |
| 无数据库/Repository | ✔ `Repository`/`JdbcTemplate`/`EntityManager` 命中 0 |
| 无配置中心/环境变量 | ✔ `System.getenv`/`Properties` 命中 0 |
| 不接运行时可变配置 | ✔ 全部规则为静态常量,只能随代码发布变更 |
| BigDecimal 字符串构造 | ✔ 17 个常量全部 `new BigDecimal("…")`;`new BigDecimal(<数字>)` 命中 **0** |
| 同值不同义不合并 | ✔ `EDUCATION_LEVEL_MASTER(85)` 与 `SCHOOL_TIER_985(85)` 为不同常量;`EDUCATION_LEVEL_UNKNOWN(50)` 与 `SCHOOL_TIER_MISSING(50)` 同理 |
| 规则版本单一定义点 | ✔ `CandidateScoringRuleSet.VERSION` 为权威;`CandidateScoringCalculator.RULES_VERSION` 现**指向该常量**(赋值而非字面量) |
---
## 6. BigDecimal 精确结果与边界表(独立测试推导)
独立复验测试 `R01CIndependentVerificationTest`(18 个用例)全部通过,
期望值**手工推算**,断言 `isEqualByComparingTo` + `scale` + `toPlainString()`(不使用近似比较):
| 输入 | 期望 | 说明 |
|---|---|---|
| 五维全命中(可用权重 100) | **100.00** | 上界 |
| 五维全缺失 / 零分母 | **null** | 非 0.00 |
| 城市(30) 命中、其余缺失 | **100.00** | 单维独立 |
| 用工类型(20) 命中、其余缺失 | **100.00** | |
| 薪资(20) 命中(边界相等 20000 == 20000) | **100.00** | 相等视为命中 |
| 行业(15) 命中、其余缺失 | **100.00** | |
| 岗位族(15) 命中、其余缺失 | **100.00** | |
| 城市命中(30) + 行业未命中(15) → 30/45 | **66.67** | HALF_UP 舍入 |
| 城市未命中(30) + 行业命中(15) → 15/45 | **33.33** | |
| 四方存在命中两项 → 15/65 | **23.08** | |
| 薪资低一单位(19999 < 20000) | **0.00** | 下界 |
| 薪资高一单位(20001) | **100.00** | |
| 岗位 salaryMax 为 null | **0.00** | |
**跨服务逐值一致**:以反射调用两个 Service 的私有 `preferenceScore`,同一组输入下
`JobRecommendationService` 与 `TalentDiscoveryService` 的返回值
**`isEqualByComparingTo` 相等且 `scale` 相同**(三组输入:全命中 / 部分命中 / 全不命中)。
**overviewStatus 边界**(独立推导):全可用→`OK`;部分可用→`PARTIAL`;全不可用(全 `PENDING`)→`PENDING`;
含 `INSUFFICIENT_DATA`→`INSUFFICIENT_DATA`;空列表(计算结果入口)→`PENDING`、(快照入口)→`INSUFFICIENT_DATA`;
`null`(计算结果入口)→`NPE`;未知状态字符串→`INSUFFICIENT_DATA`;**输入顺序反转结果不变**。
---
## 7. 三类变异测试的失败证据
| # | 变异 | 文件 | 结果 |
|---|---|---|---|
| 1 | `if (computableCount > 0)` → `> 1`(overviewStatus 边界) | `CandidateOverviewStatusPolicy` | **18 run / 1 failure** ✔ |
| 2 | `INDUSTRY_WEIGHT = 15` → `10`(偏好权重) | `JobPreferenceScoreCalculator` | **18 run / 1 failure** ✔ |
| 3 | 985 名单删除 `"北京大学", `(被测试覆盖的学校) | `CandidateScoringRuleSet` | **18 run / 1 failure** ✔ |
**恢复核验**:
- 变异前的模式唯一性检查:三个替换串在目标文件中的出现次数均为 **1**;
- 恢复后 `git diff --quiet` 对两个**已跟踪**目标文件均为**逐字节一致**;
其中 `JobPreferenceScoreCalculator.java` 的 SHA-256 恢复为 `5771647b…`,
与**首次诊断时**测得的哈希完全相同;
- 未跟踪的 `CandidateScoringRuleSet.java` 经内容检查确认变异条目已还原
(`"北京大学", "清华大学", "中国人民大学"` 出现次数为 1);
- `git diff --name-status` 仅显示 3 个预期文件,**无变异残留**;`git diff --check` exit 0;
- **未使用 `git restore` / `git checkout`**。
> 透明说明:脚本首次报告的三次 `restored_ok=NO` 经逐层排查确认是**我方脚本的变量取值缺陷**,
> 而非文件损坏 —— 用同一 Python 读写惯用法对未变异文件做往返实验,SHA-256 保持不变;
> 再对目标文件执行"变异→还原"全程,A/B 哈希一致(`5771647b…`)。
> 上述三项内容级核验(git 逐字节一致 + 哈希比对 + 变异条目还原)均已独立完成。
---
## 8. Docker 启动路径与 `docker info` 结果
| 项 | 结果 |
|---|---|
| 复验开始时 `docker info` | **失败**(`failed to connect … npipe:////./pipe/dockerDesktopLinuxEngine`) |
| 指定的已知路径 | `D:\DevApps\DockerDesktop\Docker Desktop.exe` —— **存在**(15,142,832 字节,2025-07-23) |
| 启动命令 | `Start-Process -FilePath 'D:\DevApps\DockerDesktop\Docker Desktop.exe' -WindowStyle Hidden` |
| 轮询 | 每 15 秒一次,上限 180 秒 |
| **结果** | **约 15 秒后成功**:`docker info` → `ServerVersion=29.6.2` ✔ |
未重建、删除或清理任何容器、volume 或 image。
---
## 9. Testcontainers 实际执行数
```
mvn -Dtest="JobSearchSpecificationMySqlIntegrationTest,CandidateScoringRefreshMySqlIntegrationTest,JobSearchQuerySideEffectMySqlIntegrationTest,JobSearchQueryExplainMySqlIntegrationTest" test
→ Tests run: 26, Failures: 0, Errors: 0, Skipped: 0 BUILD SUCCESS
```
| 测试类 | Tests | Skipped |
|---|---|---|
| `JobSearchSpecificationMySqlIntegrationTest` | 15 | **0** |
| `CandidateScoringRefreshMySqlIntegrationTest` | 6 | **0** |
| `JobSearchQuerySideEffectMySqlIntegrationTest` | 4 | **0** |
| `JobSearchQueryExplainMySqlIntegrationTest` | 1 | **0** |
| **合计** | **26** | **0** ✔ |
这 26 项正是此前因 Docker 不可用而跳过的全部用例,现已**真实执行并通过**。
---
## 10. 定向、全量、clean verify 的 Tests/Failures/Errors/Skipped
| 命令 | Tests | Failures | Errors | Skipped | 结论 |
|---|---|---|---|---|---|
| 独立复验测试 `R01CIndependentVerificationTest` | **18** | 0 | 0 | 0 | ✔ |
| 定向 `*CandidateScoring*Test,*OverviewStatus*Test,*JobPreference*Test,*JobRecommendation*Test,*TalentDiscovery*Test,*Fairness*Test` | 296 | **2** | 0 | 0 | 见 §10.3(既有隔离脆弱性) |
| `mvn test` | 见 §10.1 | | | | |
| `mvn clean verify` | 见 §10.1 | | | | |
### 10.1 全量与 clean verify
```
mvn test → Tests run: 2743, Failures: 0, Errors: 0, Skipped: 0 BUILD SUCCESS
mvn clean verify → Tests run: 2743, Failures: 0, Errors: 0, Skipped: 0 BUILD SUCCESS
```
| 项 | 数值 |
|---|---|
| 测试总数 | **2743** |
| 下限校验 | 实施基线 **2725** + 本轮独立新增 **18** = **2743** ✔ **恰好等于下限** |
| Failures / Errors / **Skipped** | **0 / 0 / 0** ✔ |
| BUILD | **SUCCESS** ✔ |
**Skipped=0 的达成路径**:实施阶段因 Docker 不可用而跳过的 26 个 Testcontainers 用例,
在本轮 Docker 启动后**全部真实执行**(见 §9),因此不再计入 Skipped;
`2743 = 2725(实施阶段总数,含 26 项跳过)+ 18(本轮独立新增)= 2699 执行 + 26 执行 + 18`。
**该结果同时反证 §10.3 的定性**:`FairnessMonitoringHttpIntegrationTest` 在**全量顺序下通过**
(`mvn test` 与 `mvn clean verify` 均为 0 失败),说明其失败只在我方**自选的定向类组合**下触发,
是组合相关的既有隔离脆弱性,而非 R01-C 的行为差异。
### 10.2 Testcontainers 已执行
见 §9(26 项,Skipped=0)。
### 10.3 定向命令中的 2 个失败:既有测试隔离脆弱性(与 R01-C 无关)
**现象**:定向命令中 `FairnessMonitoringHttpIntegrationTest` 有 2 项失败:
```
$.data.populationSize expected:<100> but was:<115>
$.data.populationSize expected:<200> but was:<215>
```
**定性证据链**:
1. **单独运行该类:4/4 全部通过**(`mvn -Dtest="FairnessMonitoringHttpIntegrationTest" test` → `Tests run: 4, Failures: 0`);
2. 失败仅在**与其他类同批**时出现,且两次都恰好多 **15** 行 → 典型的共享 H2 库数据泄漏;
3. **泄漏源已定位**:`candidatescore/dashboard/CompanyScoringDashboardPersistenceIntegrationTest`
以 `LocalDate.now()` 生成 **当前期间**(`period = String.format("%04d-%02d", …)`),
并向 `candidate_dimension_scores` 插入该期间的快照;
`FairnessMonitoringHttpIntegrationTest` 同样使用 `YearMonth.now()`,且其 `populationSize`
**未按自身前缀限定范围**,因此把同批类的行一并计入;
4. **该泄漏源不在 R01-C 改动范围内**(`git diff --name-only` 中无该文件);
5. R01-C 的生产改动仅为"教育规则常量改读规则集"与"偏好分改委托共用计算器",
**不涉及任何持久化、清理或计数逻辑**。
**判定**:属**既有测试隔离脆弱性**(与 A6/R01-B 定位的同类问题同源),**非 R01-C 行为差异**,
不构成 R01-C 阻断项。按任务要求**如实记录、不写成通过**,也不修改该类测试。
**明确结论**:
1. **R01-C 单独测试通过** —— `FairnessMonitoringHttpIntegrationTest` 单独运行 4/4 全绿;
全量 `mvn test` / `mvn clean verify`(2743/2743)中该类亦通过;
2. **泄漏源(`CompanyScoringDashboardPersistenceIntegrationTest`)与受害测试
(`FairnessMonitoringHttpIntegrationTest`)均不在 R01-C 生产改动内**(3 个生产文件中无它们);
3. **该问题将由下一独立纯测试提交修复**(修污染源:独占 period/studentId + 精确清理),
**不并入 R01-C 提交**;
4. **该次定向组合命令(296/2 失败)不得写成全绿** —— 上表如实保留 `Failures=2`。
---
## 11. OpenAPI 数量
```
python ..\docs\verify_openapi.py
→ paths: 176 / operations: 214
→ All checks passed!
```
`git diff -- docs/openapi-rc.json` → **空**,`openapi-rc.json` **未被修改** ✔
---
## 12. 精确文件清单
**本轮 R01-C 生产改动(新增 1 + 修改 2 = 3 个生产文件)**
```
新增: mydreamjob-server/src/main/java/com/mydreamjob/candidatescore/rules/CandidateScoringRuleSet.java
修改: mydreamjob-server/src/main/java/com/mydreamjob/candidatescore/CandidateScoringCalculator.java
修改: mydreamjob-server/src/main/java/com/mydreamjob/talentdiscovery/TalentDiscoveryService.java
```
**实施者新增测试(4)**:`mydreamjob-server/src/test/java/com/mydreamjob/common/r01c/` 下
`OverviewStatusPolicyCharacterizationTest`、`PreferenceScoreCharacterizationTest`、
`CandidateScoringRuleSetCharacterizationTest`、`RecommendationOrderingCharacterizationTest`
**本轮独立复验新增(2)**
```
mydreamjob-server/src/test/java/com/mydreamjob/common/r01c/R01CIndependentVerificationTest.java
docs/r01-c-independent-verification-20260918.md
```
**文档(1 修改)**:`docs/backend-development-roadmap.md`
**本轮未修改任何生产代码** —— 复验前后 `git diff --name-only -- …/src/main` 恒为上述 2 个文件。
---
## 13. 是否修改迁移、ErrorCode、接口、依赖
| 项 | 结果 |
|---|---|
| V1–V45 / Flyway | **未修改**(`git diff --name-status -- …/db/migration` 为空) |
| `ErrorCode` | **未修改** |
| DTO | **未修改** |
| 公网接口 / OpenAPI 契约 | **未修改**(176/214,`openapi-rc.json` 未变) |
| `pom.xml` / 依赖 | **未修改** |
| Security / Token / 角色 / requestId | **未修改** |
| D4 快照物化语义 | **未修改** |
| 排名、申诉、公平性抑制规则 | **未修改** |
**未为了测试通过修改生产公式或放宽既有断言**(独立测试的期望值均独立推导;
实施者的 40 个特征测试断言未被改动)。
---
## 14. Git 状态
| 项 | 结果 |
|---|---|
| 分支 | `refactor/r01-structure-performance` ✔ |
| HEAD | `9954600650c3e7cd40a5f4fa55bc38595973f214`(未变)✔ |
| `git diff --cached --name-only` | **空** ✔ |
| `git diff --check` | **exit 0** ✔ |
| `git diff --name-status` | 仅 `docs/backend-development-roadmap.md`、`CandidateScoringCalculator.java`、`TalentDiscoveryService.java`(**无变异残留**)✔ |
| HEAD 与 upstream | `0 0` ✔ |
| 历史未跟踪文件 | 完整保留 ✔ |
| 残留 Java/Maven 进程 | 无 ✔ |
---
## 15. 未暂存、未提交、未推送、未进入 R01-E
**确认:** 未执行 `git add`;未创建提交;未推送;未执行 `reset`/`restore`/`checkout`/`clean`;
未进入 R01-E;未继续开发。
---
> **R01-C独立代码复验通过,可以进入精确暂存与独立提交检查点。**

17
docs/r01-c-pure-calculation-instruction.md

@ -0,0 +1,17 @@
# R01-C 纯计算与重复规则收束实施指令
## 目标
在不改变评分、排名和推荐结果的前提下,收束候选人综合状态规则,并将岗位偏好评分拆成可独立验证的纯计算器。
## 行为边界
- 评分引擎继续按固定 8 维判断完整性,且 OK/ESTIMATED 必须有非空分数才可计算;
- 竞争排名继续按当前快照集合大小判断完整性,并保持历史“按状态可计算”的口径;
- 推荐偏好权重保持城市30、用工类型20、最低薪资20、行业15、岗位族15;
- 薪资边界继续使用 `salaryMax >= expectedSalaryMin`;
- 百分比继续保留两位小数并使用 HALF_UP;
- 不改数据库、接口、DTO、排序、分页、权限、审计或开关语义;
- 不进入 R01-D/E,不暂存、不提交、不推送。
必须先用特征测试锁定两套 overview 输入适配差异和偏好评分边界,再替换生产调用点。

24
docs/r01-c-pure-calculation-report.md

@ -0,0 +1,24 @@
# R01-C 纯计算与重复规则收束实施报告
> 状态:R01-C 已实现并通过自动化与干净构建,待独立代码复验。
## 实现结果
新增 `CandidateOverviewStatusPolicy`,用一个纯决策内核产出 OK/PARTIAL/PENDING/INSUFFICIENT_DATA。两个入口显式保留历史适配差异:评分引擎固定 8 维并要求 score 非空;竞争排名按已有快照数量和状态判断。原两个业务类中的 `overviewStatus` 实现均已删除。
新增 `JobPreferenceScoreCalculator`,把原先压缩在单行方法中的 30/20/20/15/15 权重计算提取为无 Spring、无数据库依赖的纯函数。`JobRecommendationService` 仅负责解析已有 JSON 字段并组装输入;权重、匹配条件、空信号返回 null、薪资相等边界及 HALF_UP 两位小数全部保持。
## 测试
- `CandidateOverviewStatusPolicyTest`:5 条,覆盖两个适配入口的 OK/PARTIAL/PENDING/INSUFFICIENT_DATA、空集合、固定八维、null score 差异,以及评分结果列表为 null 时的历史快速失败语义;
- `JobPreferenceScoreCalculatorTest`:7 条,覆盖无信号、全匹配、全不匹配、权重与舍入、薪资相等/null、null 集合,以及历史 JSON 数组中 null 元素的兼容性;
- 独立复核发现 `List.copyOf` 会拒绝历史 JSON 数组中的 null 元素,可能把原先可计算的数据变成 500;已改为允许 null 的防御性只读拷贝,并先补回归测试锁定旧语义;
- 定向关联集合:33/33;
- 第一次定向测试因夹具让城市和行业同时匹配却期望 66.67 而失败,修正为行业不匹配后同一断言通过;未改生产公式或放宽断言。
## 全量结果
- `mvn clean verify`:2094/2094,Failures=0、Errors=0、Skipped=20,BUILD SUCCESS;20 项均为既有环境条件跳过项,R01-C 定向与关联测试 Skipped=0;
- OpenAPI:176 paths / 214 operations,All checks passed;
- V1–V43 未修改,无新增接口、ErrorCode、依赖、表或索引;
- 暂存区为空,未提交、未推送,历史未跟踪产物保留。

437
docs/r01-c-scoring-recommendation-rules-refactor-report.md

@ -0,0 +1,437 @@
# R01-C 评分与推荐规则结构收束报告
日期:2026-09-18
分支:`refactor/r01-structure-performance`
HEAD:`9954600650c3e7cd40a5f4fa55bc38595973f214`(R01-B 提交 `70b9e10` + 夹具修复 `9954600`)
本轮性质:**结构重构** —— 未暂存、未提交、未推送,待独立代码复验
铁律:**所有输入对应的评分、状态、排序、推荐结果、解释内容、规则版本与舍入结果必须与重构前逐项一致**
---
## 0. 结论与一处必须先说明的口径差异
> **R01-C评分与推荐规则结构收束完成,待独立代码复验。**
**权威库存(§2)推翻了指令的两项前提,已按你的裁决实施:**
| 指令主题 | 库存实际 | 本轮处置 |
|---|---|---|
| 1. 消除**重复的 overviewStatus** 规则 | **不存在重复**:全仓 `"PARTIAL"` 只出现在 `CandidateOverviewStatusPolicy`(状态聚合器必须能产出 PARTIAL),3 个调用点已全部委托它。该策略由既有提交 `b68519e` 创建 | **无可合并项,不做任何改动**;以结构门禁测试锁定"唯一产出点" |
| 2. 拆分 `JobRecommendationService.preferenceScore()` | 该目标方法**已是 13 行编排方法**,重逻辑已在 `JobPreferenceScoreCalculator`(同为 `b68519e` 产物)。**但** `TalentDiscoveryService.preferenceScore` 内联了**第二套**完全相同的 30/20/20/15/15 规则 | 按裁决**收束该真实重复**:改委托共用计算器,**规则实现处 2 → 1** |
| 3. 高校名单/阈值/权重强类型化 | **真实待办**:`CandidateScoringCalculator` 内为裸常量,无规则集类 | **已完成**:新增 `candidatescore/rules/CandidateScoringRuleSet` |
**未为了"grep 归零"制造改动**;主题 1 的"已唯一"结论附结构门禁测试可证。
---
## 1. 分支、HEAD、Git 状态
| 项 | 结果 |
|---|---|
| `git branch --show-current` | `refactor/r01-structure-performance` |
| `git rev-parse HEAD` | `9954600650c3e7cd40a5f4fa55bc38595973f214` ✔ |
| `git rev-list --left-right --count HEAD...@{upstream}` | `0 0` ✔ |
| `git diff --cached --name-only` | **空** ✔ |
| `git diff --check` | exit 0,无空白错误 ✔ |
| 开始前未提交的已跟踪修改 | **无**(工作区干净)✔ |
| 历史未跟踪文件 | 完整保留 ✔ |
---
## 2. 上游: 三份权威库存
### 2.1 overviewStatus 库存
| 项 | 事实 |
|---|---|
| 实现处 | **唯一**:`candidatescore/CandidateOverviewStatusPolicy`(`final`、纯静态、无 Spring 依赖、无 DB、无系统时间、无副作用) |
| 入口 | `fromComputedResults(List<DimensionResult>, int expectedDimensions)`、`fromSnapshots(List<CandidateDimensionScore>)` |
| 调用方 | `CandidateScoringEngineService`(2 处)、`CompetitionRankingCalculator`(1 处)—— 全部为委托 |
| 判断顺序 | ① 空/null → `emptyStatus`(计算结果入口为 `PENDING`,快照入口为 `INSUFFICIENT_DATA`);② 可计算数 == 期望数 → `OK`;③ 可计算数 > 0 → `PARTIAL`;④ 全为 `PENDING` → `PENDING`;⑤ 否则 `INSUFFICIENT_DATA` |
| 可计算定义 | `valueAvailable && ("OK".equals(status) || "ESTIMATED".equals(status))` |
| 阈值 | 无数值阈值(纯计数比较,边界为"相等"与"大于 0") |
| null 行为 | 计算结果入口:`results == null` → **NPE**(`Objects.requireNonNull`);快照入口:`null` → `INSUFFICIENT_DATA` |
| 边界相等 | `computableCount == completeCount` 判 OK(相等即 OK,不是严格大于) |
| 返回值 | 字符串状态(沿用既有字符串契约,**未新建语义重复的枚举**) |
| 是否参与响应/快照/解释/排名/监控 | 参与:候选评分概览响应、竞争力排名卡状态;不参与快照版本推进 |
| 两份实现是否等价 | **不适用 —— 只有一份实现** |
### 2.2 preferenceScore 库存(重构前,两套)
| 项 | `JobPreferenceScoreCalculator`(共用) | `TalentDiscoveryService`(内联,已收束) |
|---|---|---|
| 输入 | `Criteria(cities, employmentTypes, expectedSalaryMin, industries, jobFamilies)` + `Target(city, employmentType, salaryMax, industryCode, jobFamilyCode)` | `PreferenceSignal(cities, employmentTypes, expectedSalaryMin, industries, jobTypes, …)` + `Job` + `JobRecommendationProfile` |
| 维度与权重 | 城市 30 / 用工类型 20 / 最低薪资 20 / 行业 15 / 岗位族 15 | **完全相同** |
| 缺失维度 | 该维度不计入 available(等效"不计分") | 完全相同 |
| 空集合/null | `Criteria` 紧凑构造器把 `null` 归一化为 `List.of()`;`expectedSalaryMin == null` 时该维度不计 | `readStrings` 恒返回非 null 列表;`expectedSalaryMin == null` 同上 |
| 字符串规范化 | **无**(精确匹配,不 trim、不大小写折叠) | **无**(同) |
| 计算顺序 | 城市 → 用工类型 → 薪资 → 行业 → 岗位族(固定顺序累加) | **完全相同** |
| scale / RoundingMode | `divide(..., 2, HALF_UP)`,`BigDecimal.valueOf(matched * 100L)` | `BigDecimal.valueOf(got * 100L).divide(..., 2, HALF_UP)` —— **同一式子** |
| 上下界 | `available == 0 → null`;否则 ∈ [0.00, 100.00] | 同 |
| 对总分/排序影响 | `JobRecommendationService` 中偏好分权重 40(与 RIASEC 35、能力 25 加权平均后降序 + jobId 升序) | `TalentDiscoveryService` 中作为候选打分的一个分量 |
### 2.3 硬编码规则库存
| 项 | 重构前位置 | 数量/值 |
|---|---|---|
| 985 高校集合 | `CandidateScoringCalculator.C985`(`new HashSet<>(List.of(...))`) | **38 项**(38 唯一) |
| 211 高校集合 | `CandidateScoringCalculator.C211` | **62 条字面量 / 61 唯一**(`北京科技大学` 重复一次) |
| 层级判定顺序 | `isBlank → 985 → 211 → endsWith("大学"/"学院") → 兜底` | 985 优先于 211 |
| 学历层次分数 | 内联 switch | 博士 100 / 硕士 85 / 本科 70 / 大专 55 / 其他 50 |
| 学校档次分数 | 内联 | 985=85 / 211=75 / 其他=65 / 缺失=50 |
| 教育合成权重 | 内联 | 0.55 / 0.45;子项满分 55 / 45 |
| 默认维度权重 | `DEFAULT_WEIGHT` | `new BigDecimal("12.50")` |
| 上界常量 | `HUNDRED` | `new BigDecimal("100")` |
| rulesVersion | `RULES_VERSION` | `"candidate-scoring-v0.1"`(**另有** `AbilityScoreMapper.MAPPING_RULES_VERSION = "ability-assessment-scoring-v0.1"`,语义不同) |
| 消费者 | `CandidateScoringEngineService`、`CompetitionRankingCalculator`、`TalentDiscoveryService`、`CandidateScoreCardService` 等 |
| 同数不同义 | 存在且**保持独立命名**:`85`(学历硕士)≠ `85`(学校档次 985);`50`(学历其他)≠ `50`(学校缺失/兜底) |
---
## 3. 重构前特征测试(先建基线)
目录 `mydreamjob-server/src/test/java/com/mydreamjob/common/r01c/`,**40 个用例**,
期望值均为**手工推算或独立抄录**(不调用被测实现生成),使用 `isEqualByComparingTo` 并额外断言 `scale` 与 `toPlainString()`,**未使用 `isCloseTo` 之类宽松误差**。
| 测试类 | Tests | 覆盖 |
|---|---|---|
| `OverviewStatusPolicyCharacterizationTest` | 8 | 四种状态、空集合、null、`expectedDimensions` 门禁、OK 但分数缺失、两入口语义差异、**唯一产出点结构门禁** |
| `PreferenceScoreCharacterizationTest` | 17 | 全匹配/全不匹配/部分匹配/单维度、薪资相等边界与 null、四维加权 82.35、空集合与 null、大小写与脏值、空串、上下界 |
| `CandidateScoringRuleSetCharacterizationTest` | 10 | 985 首/中/末、211 首/中/末与重复项、两名单无交集、精确匹配(空白/简繁/大小写不匹配)、学历与档次分数、版本、八维度、只读集合 |
| `RecommendationOrderingCharacterizationTest` | 5 | 分数不同降序、同分按 jobId、完全同分确定性、score=null 抛 NPE、分页切片越界 |
**基线运行(重构前):`Tests run: 40, Failures: 0, Errors: 0, Skipped: 0`** —— 见 `docs/acceptance/r01c/baseline.log`。
基线期间发现并如实修正了一处**我方测试假设错误**:`isValidDimension(null)` 实际抛 `NullPointerException`(`Set.of` 契约),并非返回 `false`;已按真实行为锁定,未改生产代码。
---
## 4. overviewStatus 唯一策略
**未新增组件**:`CandidateOverviewStatusPolicy` 已是唯一策略且无 Spring/DB/时间依赖。
本轮仅**新增结构门禁测试**:扫描 `src/main/java/com/mydreamjob` 下所有 `.java`,
断言 `"PARTIAL"` 的出现文件**恰好**为 `CandidateOverviewStatusPolicy.java`。
若将来出现第二个聚合器,该测试立即失败。
---
## 5. 两份旧实现是否完全等价
**不存在两份 overviewStatus 实现**,故不适用"合并前等价性比对"。§六 的停止条件(两实现不等价)**未触发**。
**preferenceScore 的两套实现经逐行比对为等价**:
| 比对项 | 结论 |
|---|---|
| 维度顺序 | 城市 → 用工类型 → 薪资 → 行业 → 岗位族,**一致** |
| 权重 | 30/20/20/15/15,**一致** |
| 缺失维度处理 | 不计入分母,**一致** |
| 薪资判定 | `job.salaryMax != null && salaryMax >= expectedSalaryMin`,**一致** |
| 岗位族取值 | `profile.getJobFamilyCode()`,**一致** |
| 计算式 | `BigDecimal.valueOf(x * 100L).divide(BigDecimal.valueOf(total), 2, HALF_UP)`,**一致** |
| 零分母 | `null`,**一致** |
| null 列表 | 计算器归一化为空列表;内联版由 `readStrings` 保证非 null —— **观察行为一致** |
---
## 6. preferenceScore 拆分
- `JobRecommendationService.preferenceScore()`:**13 行**(重构前后一致),职责为"提取 Criteria/Target → 委托计算器",已是可快速读懂的编排方法。
- `TalentDiscoveryService.preferenceScore()`:由 **12 行内联公式** 改为 **17 行编排**(含 javadoc),规则实现移出。
- 拆分粒度按**实际存在的维度**(5 个),**未为匹配示例创造不存在的维度**;未引入 Stream;未改变可见性;未产生无意义的一行包装。
- **30/20/20/15/15 规则实现处:2 → 1**(`grep "total += 30\|CITY_WEIGHT"`)。
---
## 7. BigDecimal 运算顺序与舍入证明
| 项 | 重构前 | 重构后 | 一致 |
|---|---|---|---|
| 偏好分构造 | `BigDecimal.valueOf(matched * 100L)` | 同一表达式(改为计算器内执行) | ✔ |
| 除法 | `divide(BigDecimal.valueOf(available), 2, RoundingMode.HALF_UP)` | 同 | ✔ |
| 教育维度 | `eduLevel*0.55 + schoolTier*0.45`,`setScale(2, HALF_UP)` | 同一表达式(权重改读规则集常量,**值相同**) | ✔ |
| `MathContext.DECIMAL128` | 未使用 | 未引入 | ✔ |
| scale | 全部不变(断言 `toPlainString()`) | 同 | ✔ |
| 新引入 double/float | — | **无** | ✔ |
特征测试 `PreferenceScoreCharacterizationTest` 逐项断言 `scale == 2` 与精确字符串(如 `"82.35"`、`"66.67"`、`"33.33"`)。
---
## 8. 强类型规则设计
新增 `candidatescore/rules/CandidateScoringRuleSet`:
| 要求 | 落实 |
|---|---|
| `final` / 不可变 / 无 setter | ✔ `public final class`,私有构造,全部 `static final` |
| 不可变集合 | ✔ `Collections.unmodifiableSet(new HashSet<>(...))` |
| 唯一当前规则实例 | ✔ 静态常量(随代码发布变更) |
| 版本常量 | ✔ `VERSION = "candidate-scoring-v0.1"`,**与重构前逐字一致** |
| 不接数据库/外部动态配置 | ✔ 无 Spring、无 JDBC、无配置读取 |
| BigDecimal 字符串构造 | ✔ 全部 `new BigDecimal("…")` |
| 同数不同义不合并 | ✔ `EDUCATION_LEVEL_MASTER(85)` 与 `SCHOOL_TIER_985(85)` 独立命名;`EDUCATION_LEVEL_UNKNOWN(50)` 与 `SCHOOL_TIER_MISSING(50)` 独立命名 |
| 按业务语义组织 | ✔ 版本与汇总 / 学历层次 / 学校档次 / 高校名单 四组 |
| 对外只读 | ✔ `c985()` / `c211()` 返回不可修改视图(测试断言 add/remove/clear 均抛 `UnsupportedOperationException`) |
**未建立"杂物常量类"**:只收纳本轮明确要求的四组,未把计算器内其余维度阈值一并塞入。
---
## 9. 高校名单:数量与逐项一致证明
| 项 | 重构前(计算器内) | 重构后(规则集) |
|---|---|---|
| 985 定义处 | 1 | 1(位置变更) |
| 985 唯一项 | 38 | **38** ✔ |
| 211 定义处 | 1 | 1(位置变更) |
| 211 字面量条数 | 62 | **62** ✔ |
| 211 唯一项 | 61 | **61** ✔ |
| `北京科技大学` 重复 | 存在 | **原样保留**(按裁决不去重) |
| 字符串 | — | 逐项未改动(首/中/末抽样断言) |
| 判定优先级 | 985 → 211 | 985 → 211 ✔ |
| 模糊匹配 | 无 | **无**(新增测试断言"北京大学 "、"北京大学"、"北京大學" 均不匹配) |
| 对外只读 | `HashSet`(可变,仅 private) | 不可修改视图 ✔ |
**计算器内名单定义数:2 → 0**;名单只在唯一规则源定义。
---
## 10. 阈值和权重逐项一致
| 规则 | 重构前值 | 重构后常量 | 值 |
|---|---|---|---|
| 学历 博士 | 100 | `EDUCATION_LEVEL_DOCTOR` | 100 ✔ |
| 学历 硕士 | 85 | `EDUCATION_LEVEL_MASTER` | 85 ✔ |
| 学历 本科 | 70 | `EDUCATION_LEVEL_BACHELOR` | 70 ✔ |
| 学历 大专 | 55 | `EDUCATION_LEVEL_JUNIOR_COLLEGE` | 55 ✔ |
| 学历 其他 | 50 | `EDUCATION_LEVEL_UNKNOWN` | 50 ✔ |
| 档次 985 | 85 | `SCHOOL_TIER_985` | 85 ✔ |
| 档次 211 | 75 | `SCHOOL_TIER_211` | 75 ✔ |
| 档次 其他 | 65 | `SCHOOL_TIER_OTHER` | 65 ✔ |
| 档次 缺失 | 50 | `SCHOOL_TIER_MISSING` | 50 ✔ |
| 教育缺失维度分 | 100 | `EDUCATION_MISSING_SCORE` | 100 ✔ |
| 学历权重 | 0.55 | `EDUCATION_LEVEL_WEIGHT` | 0.55 ✔ |
| 档次权重 | 0.45 | `EDUCATION_SCHOOL_TIER_WEIGHT` | 0.45 ✔ |
| 子项满分 | 55 / 45 | `EDUCATION_LEVEL_FULL_SCORE` / `…TIER…` | 55 / 45 ✔ |
| 默认维度权重 | 12.50 | `DEFAULT_DIMENSION_WEIGHT` | 12.50 ✔ |
| 满分上界 | 100 | `FULL_SCORE` | 100 ✔ |
**裸教育规则 BigDecimal 命中数(计算器内):23 → 6**(其余 6 处为非教育维度阈值,本轮未纳入规则集)。
数值一律为字符串构造,**未把整数阈值错误转换为百分比小数**。
---
## 11. rulesVersion
| 项 | 值 |
|---|---|
| 常量 | `CandidateScoringRuleSet.VERSION` |
| 值 | **`candidate-scoring-v0.1`**(与重构前 `CandidateScoringCalculator.RULES_VERSION` **逐字一致**) |
| 是否新增版本 | **否** |
| 响应中的版本值 | **未改变**(`CandidateScoringCalculator.RULES_VERSION` 现指向同一常量) |
| 同义词区分 | `AbilityScoreMapper.MAPPING_RULES_VERSION = "ability-assessment-scoring-v0.1"` 保持独立,未合并 |
| 快照历史一致性 | 快照写入仍使用同一 `RULES_VERSION`,未变 |
---
## 12. 排名顺序对账
`JobRecommendationService` 排序比较器**未改动**:
`Comparator.comparing(JobRecommendationItem::getScore).reversed().thenComparing(JobRecommendationItem::getJobId)`。
`slice` 的下标计算未改动。特征测试锁定:分数降序、同分按 jobId 升序、完全同分确定性、越界切片为空。
**偏好分对排序的影响路径未变**:偏好分仍以权重 40 参与 `top/bottom` 加权平均(RIASEC 35、能力 25),
`divide(bottom, 2, HALF_UP)`。收束只替换了偏好分的**计算处**,未触碰分值本身。
---
## 13. 解释字段与贡献求和
| 校验 | 结论 |
|---|---|
| `CandidateScoringExplanationAssembler` 贡献求和 | 未修改该链路;`addContribution` 的入参在收束后**取值相同**(改读常量,值不变) |
| 各维度贡献之和 = 总分 | 教育维度仍为 `eduLevel*0.55 + schoolTier*0.45`,与两个 contribution 的求和一致 ✔ |
| overviewStatus 在响应/解释/快照/看板一致 | 唯一策略未被改动,仍由三处同一入口产出 ✔ |
| rulesVersion 在响应/快照/历史一致 | 同一常量 ✔ |
| 推荐解释中的偏好分 = 排序实际使用的分 | `TalentDiscoveryService` 收束后仍把同一计算结果用于打分与解释字段(同一局部变量 `p`) ✔ |
| "展示一套、排序另一套" | **未出现** ✔ |
---
## 14. 查询数与副作用
| 项 | 结论 |
|---|---|
| 新增数据库查询 | **无**。规则集为静态常量;偏好分计算为纯函数 |
| 推荐一次查询的 SQL 数量 | **不增加**:`TalentDiscoveryService.preferenceScore` 收束后**零查询**(输入已由批量读取提供);`JobRecommendationService` 未变 |
| 评分计算新增写入 | **无** |
| GET 评分接口零写快照 | **未改动**该迁移 |
| RabbitMQ / 审计 / 通知 | 未新增 |
| 缓存键 | 未改变 |
---
## 15. 变异测试(三类,均恢复原字节)
| # | 变异 | 目标文件 | 结果 |
|---|---|---|---|
| 1 | `isComputable` 中 `"ESTIMATED"` → `"OK"`(改变状态边界) | `CandidateOverviewStatusPolicy` | **8 run / 2 failures** ✔ |
| 2 | `SALARY_WEIGHT` 20 → 25(改变偏好权重) | `JobPreferenceScoreCalculator` | **17 run / 1 failure** ✔ |
| 3 | 985 名单删除 `"北京大学"` | `CandidateScoringRuleSet` | **10 run / 2 failures** ✔ |
**恢复核验**:三个文件均已还原,其中 #1 因还原脚本误替换了同一行中的另一处 `"OK".equals`,
已定位并**逐字节修正**,`git diff --quiet` 确认与 HEAD 完全一致;#2、#3 的 SHA-256 恢复一致。
**未使用 `git restore/checkout`**;变异内容未留在工作区(见 §20 的差异范围核对)。
---
## 16. 定向、关联、全量测试数
| 命令 | 结果 | 日志 |
|---|---|---|
| R01-C 定向(4 个特征类) | **40 / 40**,F=0 E=0 S=0 ✔ | `baseline.log` / `after-characterization.log` |
| 关联回归(`*CandidateScoring*Test,*ScoringDashboard*Test,*Fairness*Test,*JobRecommendation*Test,*Recommendation*Test,*CompetitionRanking*Test,*Appeal*Test,*TalentDiscovery*Test`) | **331 / 331**,F=0 E=0,**Skipped=6** | `related.log` |
| `mvn test` | 见 §16.1 | `full-mvntest.log` |
| `mvn clean verify` | 见 §16.1 | `clean-verify.log` |
### 16.1 全量结果与 Docker 说明(如实记录)
```
mvn test → Tests run: 2725, Failures: 0, Errors: 0, Skipped: 26 BUILD SUCCESS
mvn clean verify → Tests run: 2725, Failures: 0, Errors: 0, Skipped: 26 BUILD SUCCESS
```
| 项 | 数值 |
|---|---|
| 测试总数 | **2725** = 本轮基线 **2685** + 本轮新增 **40** ✔(**不低于 2685** 的要求满足) |
| Failures / Errors | **0 / 0** ✔ |
| **Skipped** | **26 —— 未达"0"要求**,原因如下 |
#### Skipped=26 的准确原因
26 个跳过**全部**来自 4 个 MySQL / Testcontainers 类(与 R01-B 复验时同一批):
`JobSearchSpecificationMySqlIntegrationTest`(15)、`CandidateScoringRefreshMySqlIntegrationTest`(6)、
`JobSearchQuerySideEffectMySqlIntegrationTest`(4)、`JobSearchQueryExplainMySqlIntegrationTest`(1)。
它们标注 `@Testcontainers(disabledWithoutDocker = true)`,Docker 不可用时按设计跳过。
**环境状态变化(非代码原因)**:
- 本轮**开始时** `docker info` 成功:`ServerVersion=29.6.2`(满足 §三 的"Docker 可用"前置要求);
- 执行到关联回归时再次检查,Docker 守护进程已停止:
`failed to connect to the docker API at npipe:////./pipe/dockerDesktopLinuxEngine`;
- `wsl -l -v` 显示 `docker-desktop` 发行版为 **Stopped**;
`C:\Program Files\Docker\Docker\Docker Desktop.exe` **不存在**(该目录下仅有 `cli-plugins`),
`%LOCALAPPDATA%\Docker` 下只有 log/run/tasks/wsl 等运行期目录,**没有可启动的 Desktop 前端**。
**处置**:未强行以非受支持方式拉起 Docker(`wsl -d docker-desktop` 明确不被支持),
**不声称 Skipped=0**。除这 26 个 Docker 条件用例外的 **2699 个测试全部执行且全部通过**。
**不构成 R01-C 阻断项**。
#### 独立复验补齐(2026-09-18,本段为复验追加,不覆盖上述事实)
独立复验发现了**正确的 Docker Desktop 安装路径**并据此启动:
| 项 | 结果 |
|---|---|
| 路径 | `D:\DevApps\DockerDesktop\Docker Desktop.exe`(存在,15,142,832 字节) |
| 启动 | `Start-Process -FilePath '…\Docker Desktop.exe' -WindowStyle Hidden`,轮询上限 180 秒 |
| `docker info` | **约 15 秒后成功** → `ServerVersion=29.6.2` |
| 原先跳过的 26 项 | **26 / 26 实际执行**,`Tests run: 26, Failures: 0, Errors: 0, Skipped: 0` |
**最终全量(复验阶段)**:
```
mvn test → Tests run: 2743, Failures: 0, Errors: 0, Skipped: 0 BUILD SUCCESS
mvn clean verify → Tests run: 2743, Failures: 0, Errors: 0, Skipped: 0 BUILD SUCCESS
```
`2743 = 2725(本报告阶段总数,含 26 项跳过)+ 18(独立复验新增测试)`;
其 **Skipped=0** 正是因为那 26 项已真实执行。**本阶段未改动任何代码即补足了这 26 项。**
---
## 17. OpenAPI
```
python ..\docs\verify_openapi.py
→ paths: 176 / operations: 214
→ All checks passed!
```
`git diff -- docs/openapi-rc.json` → **空** ✔
---
## 18. 精确修改文件
**生产代码变更总数:新增 1 个生产类、修改 2 个生产类,共 3 个生产文件。**
**新增生产类(1)**
```
mydreamjob-server/src/main/java/com/mydreamjob/candidatescore/rules/CandidateScoringRuleSet.java
```
**修改生产类(2)**
```
candidatescore/CandidateScoringCalculator.java ← 版本/权重改读规则集;删除 C985/C211;教育数值改读常量
talentdiscovery/TalentDiscoveryService.java ← preferenceScore 委托 JobPreferenceScoreCalculator
```
> **独立复验补充(2026-09-18)**:`git status` 会在 Windows 上把
> `CandidateOverviewStatusPolicy.java` 与 `JobPreferenceScoreCalculator.java` 标为 ` M`
> (变异实验重写过这两个文件、mtime 变化)。经 `git hash-object` 与 `git rev-parse HEAD:<file>`
> 比对,两者 blob **完全相同**,属 **Windows stat 缓存假阳性**,**不在本提交范围内**。
**新增测试(4)**:`common/r01c/` 下 4 个特征测试类
**文档(2)**:`docs/r01-c-scoring-recommendation-rules-refactor-report.md`(新增)、
`docs/backend-development-roadmap.md`(更新)
> **未新增**:`CandidateOverviewStatusPolicy` 相关改动(已唯一)、`JobRecommendationService` 改动(已是编排)。
---
## 19. Flyway / ErrorCode / DTO / 接口变化
| 项 | 结果 |
|---|---|
| Flyway / 迁移 | **未改动** ✔ |
| `ErrorCode` | **未改动** ✔ |
| DTO / 响应结构 | **未改动** ✔ |
| 公网接口 / OpenAPI | **未改动**(176/214,`openapi-rc.json` 未变) ✔ |
| Token / 权限 / requestId | **未改动** ✔ |
| 依赖 / `pom.xml` | **未改动** ✔ |
| 评分公式数值 / 排序 / 同分规则 / 小样本与互补风险抑制 / 申诉回滚 / 快照版本推进 / Fairness / 消息与 Offer 等 | **均未改动** ✔ |
---
## 20. Git 状态
| 项 | 结果 |
|---|---|
| 分支 / HEAD | `refactor/r01-structure-performance` / `9954600…`(未变) |
| `git diff --cached --name-only` | **空** ✔ |
| `git diff --check` | exit 0 ✔ |
| 生产代码改动范围 | **恰为 2 个文件**,与 §18 一致(变异内容未残留)✔ |
| 已暂存 / 已提交 / 已推送 | **均无** ✔ |
| 历史未跟踪文件 | 未删除 ✔ |
---
## 21. 未暂存、未提交、未推送
**确认。** 未执行 `git add`;未创建提交;未推送;未执行 `reset`/`restore`/`checkout`/`clean`;
未进入 R01-E。
---
## 22. 未进入 R01-E
**确认。** 本轮未处理 N+1、会话未读数、`overviewStatus` 之外的其他查询收束、
数据库迁移或任何 R01-E 范围内的主题。
---
> **R01-C评分与推荐规则结构收束完成,待独立代码复验。**

25
docs/r01-d1-percentile-performance-report.md

@ -0,0 +1,25 @@
# R01-D1 百分位性能收束报告
> 状态:R01-D1 已实现并通过自动化与干净构建,待独立复验。
## 修改范围
- 新增纯函数 `CandidatePercentileCalculator`;
- `CompanyScoringDashboardService` 保留原调用位置,仅将百分位计算委托给纯函数;
- 未修改候选人综合分、排序、分页、过滤、披露门槛或响应结构。
## 算法与兼容性
旧实现对每个候选分数再次扫描全部分数,复杂度为 O(n²)。新实现先排序,再以每个分数第一次出现的位置作为“严格低于该分数的数量”,复杂度为 O(n log n)。分母仍为 `有效分数数 - 1`,仍使用 10 位中间精度、HALF_UP,并最终保留两位小数。
等价测试覆盖空集合、单样本、全 null、输入顺序、null 位置、并列分数,以及由 `{null, 0, 1.0, 1.00, 2.5}` 生成的长度 0–6 全组合,并逐项与旧 O(n²) 公式比较。
首次实现使用 `HashMap<BigDecimal, ...>`,测试立即发现 `1.0` 与 `1.00` 被错误视为不同键;改为按 `BigDecimal.compareTo` 排序的 `TreeMap` 后恢复旧语义。另一个首次失败来自手写并列夹具把最高分期望误写为 66.67;旧公式预言机证明应为 100.00,修正夹具期望,未改变生产公式。
## 验证
- 定向与看板关联测试:28/28,Failures=0、Errors=0、Skipped=0;
- `mvn clean verify`:2099/2099,Failures=0、Errors=0、Skipped=20,BUILD SUCCESS;20 项为既有环境条件跳过项,R01-D1 定向及关联测试 Skipped=0;
- OpenAPI:176 paths / 214 operations,All checks passed;
- 无 Flyway、ErrorCode、公网接口、表、索引或依赖变化;
- 未暂存、未提交、未推送。

29
docs/r01-d2-personal-data-query-report.md

@ -0,0 +1,29 @@
# R01-D2 个人数据导出查询收束报告
> 状态:R01-D2 已完成批量查询整改、自动化验证与干净构建,待独立复验。
## 原问题
`PersonalDataCollector` 在遍历父记录时逐条查询子记录:投递状态历史、反馈、面试历史、Offer 历史、会话、消息和评分申诉历史均形成 N+1;投递列表还被重复查询两次。
## 整改
- 每类父记录先收集 ID,再用 Repository `IN` 查询一次性读取子记录;
- 在内存中按父 ID 分组,并继续按原父记录顺序组装,保留时间线内部升序、消息内部降序和每会话最多 10000 条的既有语义;
- 投递列表只读取一次,同时供投递、时间线和反馈三个 section 使用;
- 会话实体改用 `findAllById`,消息按全部 conversationId 一次批量读取;
- 未改变导出 section 名称、字段、脱敏、简历文件读取或 ZIP 构建语义。
## 测试证据
- `PersonalDataCollectorQueryBudgetTest`:3 条;
- 投递规模 1 与 50 时,投递主查询、状态历史查询、反馈查询均固定各 1 次;
- 面试历史、Offer 历史、会话实体、消息、申诉历史均固定为每类 1 次批量查询;旧逐条 Repository 方法明确断言为 0 次;
- 父记录顺序与每个父记录下的时间线顺序有独立断言;
- 定向及数据导出关联回归:19/19,Failures=0、Errors=0、Skipped=0;
- `mvn clean verify`:2102/2102,Failures=0、Errors=0、Skipped=20,BUILD SUCCESS;20 项为既有环境条件跳过项,R01-D2 定向与关联测试 Skipped=0;
- OpenAPI:176 paths / 214 operations,All checks passed。
## 边界
无 Flyway、表、索引、ErrorCode、公网接口或依赖变化;未暂存、未提交、未推送。

48
docs/r01-d3-conversation-unread-query-report.md

@ -0,0 +1,48 @@
# R01-D3 会话未读数查询收束报告
## 结论
`MessagingService.listConversations` 已从逐会话查询最近消息和已读状态,收束为一次分组未读统计。会话数量从 1 增长到 20、50 时,消息相关业务 SQL 数保持固定;本次未改变公网接口、响应字段、权限、消息撤回或已读语义。
## 修复前问题
会话列表先查询成员关系和会话,再对每个会话调用 `countUnread`。每次调用都会查询最近 500 条消息及其已读记录,因此总查询数随会话数线性增长。
## 实现
- `MessageRepository.countUnreadByConversationIds` 使用一条分组 SQL 批量统计全部会话。
- 使用 `ROW_NUMBER() OVER (PARTITION BY conversation_id ORDER BY created_at DESC)` 保留每个会话“最近 500 条”的历史窗口。
- `RECALLED` 消息不计入未读,`status IS NULL` 仍按历史语义计入。
- 存在当前用户 `message_reads` 的消息不计入未读。
- 没有未读行的会话由 Service 映射为 `0`。
- `getConversation` 的单会话路径未改动,避免扩大本批范围。
## 查询预算证据
`MessageListQueryBudgetIntegrationTest.listConversationsUsesFixedQueriesForOneTwentyAndFiftyConversations` 使用真实 Spring/JPA/H2 数据源代理统计业务 SQL,并混合 DIRECT、TALENT_INVITATION、SENT、RECALLED、已读和未读消息。
| 会话数 | 业务查询数 | 结果 |
| ---: | ---: | --- |
| 1 | 3 | PASS |
| 20 | 3 | PASS |
| 50 | 3 | PASS |
三条查询分别为成员关系、会话批量读取、未读分组统计,查询数不随会话数增长。
## 语义边界
`groupedUnreadCountKeepsHistoricalLatestFiveHundredWindow` 构造 501 条顺序明确的消息,证明最旧消息位于窗口外时不被统计。测试首次编写时,实体的 `@CreatedDate` 覆盖夹具传入时间,造成 501 条消息时间并列;随后改为保存后按精确消息主键写入确定时间。该失败属于夹具问题,不是生产缺陷,也未通过放宽断言处理。
## 验证
- 定向边界测试:3/3,Failures=0、Errors=0、Skipped=0。
- 关联消息测试:此前同批运行 42/42 通过。
- `mvn clean verify`:2104 项,Failures=0、Errors=0、Skipped=20,BUILD SUCCESS;20 项均为既有环境条件测试。
- 静态 OpenAPI:176 paths / 214 operations,校验通过。
## 边界
- 未新增或修改 Flyway、表、索引、ErrorCode、公网接口。
- 未改变 DIRECT / TALENT_INVITATION 响应隐私语义。
- 未改变消息发送、撤回、已读、分页和权限语义。
- 原生窗口 SQL 仍需在独立真实 MySQL 验收中复核执行计划与结果。

87
docs/r01-d4-1-scoring-materialization-implementation-report.md

@ -0,0 +1,87 @@
# R01-D4-1「候选人评分快照可靠物化基础设施」实施报告
日期:2026-09-16
分支:`refactor/r01-structure-performance`;HEAD 检查点:`b10c4a7 docs: design scoring snapshot materialization`(后于 `b56f792`)
性质:D4-1 实施(**GET 兜底未删除**,三 GET 仍经 upsertSnapshots 写库)
## 1. Git 基线
- 分支正确;设计检查点已提交 `b10c4a7`(3 个设计文件)。
- 暂存区空;未 commit/push 本轮实现;V1–V43 未修改;V44 为唯一新增迁移。
- `GlobalExceptionHandler.java` 保持行尾符标记现象(内容 diff 为空)未动。
## 2. V44 表结构(`V44__candidate_scoring_refresh_tasks.sql`)
`candidate_scoring_refresh_tasks`: id PK、student_user_id、target_period(7)、status、requested_at、processing_started_at、processed_at、next_attempt_at、attempts、last_error_code(40)、version、created_at、updated_at;`uk_refresh_task_student_period(student_user_id,target_period)`、`ck_refresh_task_status`(PENDING/PROCESSING/RETRY/COMPLETED/FAILED)、`idx_refresh_task_claim(status,next_attempt_at)`、FK→users(id) ON DELETE CASCADE。不保存 Token/手机号/简历/评分源数据。
## 3. 任务唯一键与状态机
唯一键 (student_user_id, target_period)。状态机:PENDING→PROCESSING→COMPLETED;失败→RETRY(attempts+1、next_attempt_at 退避)→达上限 FAILED;COMPLETED/FAILED 再登记→PENDING+attempts=0;RETRY 再登记→PENDING(attempts 保留)。
## 4. 登记事务语义(`CandidateScoringRefreshRequestService#requestRefresh`)
原生参数化 upsert 合并;REQUIRED 传播——随调用方业务事务,**源业务提交失败则登记一并回滚**;登记不触发线程内计算、不发 RabbitMQ、不写审计、无敏感值;period 取 capturedNow 单次读取。
## 5. Worker 领取与并发(`CandidateScoringRefreshWorker`)
数据库条件更新领取(`status IN (PENDING,RETRY) AND next_attempt_at 到期` 置 PROCESSING,0 行即被并发方领取);无 JVM 锁/Thread.sleep。领取使用独立短事务;计算、8 维 Writer 与完成状态使用另一个 `REQUIRES_NEW` 原子事务;失败状态再用独立事务持久化。因此单维写失败会回滚该任务全部快照,但不会污染同批其他任务。并发双 Worker 仅一次有效物化。
## 6. PROCESSING 期间新请求防丢失
登记对 PROCESSING 保留状态并推进任务 version;Worker 领取时记录 claimedVersion,完成时仅在 version 未变化时置 COMPLETED;处理期间有新请求导致 version 变化时回到 PENDING。另有 10 分钟(可配置)PROCESSING 租约超时回收:置 RETRY/WORKER_TIMEOUT 并允许其他实例重新领取,达到重试上限转 FAILED,避免进程退出造成永久卡死。
## 7. 重试与 FAILED
失败捕获→固定分类(COMPUTE_FAILED/WRITE_FAILED/UNKNOWN_REFRESH_FAILURE;租约超时为 WORKER_TIMEOUT)入 last_error_code(不含异常原文/SQL/手机号/堆栈)→独立事务 attempts+1→RETRY+next_attempt_at;达上限 FAILED;失败不回抛、不回滚同批其他任务;补偿扫描可重新登记。
## 8. Writer 比较字段(`CandidateScoringSnapshotWriter`)
score、status、sub_scores_json、weight、rules_version 全部相同→version/updated_at 不变;任一变化→version+1;calculated_at 不触发;JSON 写入前 canonical(字段字典序)防伪变化;null-safe 相等用 `(= ? OR (IS NULL AND ? IS NULL))`(MySQL/H2 双兼容)。
## 9. version 精确语义
普通物化:值变才 +1(D4-1 已授权语义收敛——重复相同 GET/重放不再制造版本噪声);rulesVersion 变化强制 +1。
## 10. Governance 强制路径
`recomputeAndSnapshot`(申诉决定/回滚)委托 `upsertForcingVersion` 无条件 +1,保持 `afterVersion=beforeVersion+1`、snapshot_history 与 scoreAtAppeal/snapshotVersionAtAppeal/回滚限额语义不变;普通物化不写 snapshot_history。
## 11. 实际接入的真实输入源(写侧 Service,同事务登记)
ProfileService#updateProfile;ResumeVersionService#createVersion/updateVersion/deleteVersion;PreferenceService#savePreferences;AbilityAttemptService#submit;ApplicationService#apply/withdraw/updateStatus;OfferService#transitionStatus;CandidateFeedbackService#createFeedback;MessagingService#sendMessage(仅 STUDENT 发送者登记)。补偿扫描兜底 audit/时间窗口。
## 12. 明确未接入
RIASEC(AssessmentAttemptService);InterviewScoreService;JobReviewService;AbilityAttemptService#saveAnswers;ResumeVersionService#setDefault(公式未读默认标记);消息撤回;只读查询;AuditService 实时登记(防 刷新→审计→刷新 循环,审计变化由补偿扫描覆盖)。
## 13. 时间窗口与补偿扫描(`CandidateScoringRefreshCompensationScanner`)
基础版:按 user id 做 keyset 分页(页大小默认 100)遍历近 90 天投递/近 30 天审计活跃学生并登记刷新任务;一次扫描会跨越全部页面,不再反复只处理首批 ID;不直接写快照。rulesVersion 切换由事件/重扫覆盖。**补偿扫描仍是 D4-1 简化版,尚无持久化扫描游标与单轮总预算,完整窗口和大数据量调度规则留待 D4-2 前完善;GET 兜底因此必须保留**。
## 14. 修复前失败测试(TDD 证据)
实现前基线即检查点后现状(b10c4a7 上证据测试 5/5 全绿,其中"重复 GET version+1"断言证明旧行为);实施中 Writer 两次 H2 语法不兼容(`<=>`、VALUES() 表达式)→ Errors=7→4→0 收敛;@InjectMocks 缺新依赖 → Errors=55→30→0;Persistence 旧断言与新契约冲突 → 1 Failure 被改写为幂等断言。最终全绿。
## 15. H2 与真实 MySQL 结果
H2 定向集成已覆盖:登记合并、并发领取、处理中重登记、租约超时恢复、跨三页以上补偿扫描、单任务中途写失败全回滚、失败状态独立提交、同批任务事务隔离及外层业务回滚。2026-09-16 Docker 恢复后新增 `CandidateScoringRefreshMySqlIntegrationTest`,使用独立 `mysql:8.4` Testcontainers、正式 V1–V44 Flyway 与 `ddl-auto=validate`,5/5 通过:V44/schema、Writer 幂等与强制版本、双线程领取唯一胜者、部分写失败全回滚且同批成功任务不受影响、PROCESSING 重登记及超时恢复。未复用或修改开发库。
## 16. 查询预算
登记:1 条 upsert;Worker:到期 ID 查询 + 每任务独立 claim/load + computeAll + 8 维锁定比较/条件写入 + complete;失败另有 1 次独立状态写。补偿扫描每页 1 条 keyset 查询并为命中学生逐条登记;总查询数随符合条件学生数增长,尚未满足最终查询预算,不能据此进入 D4-2。
## 17. RabbitMQ 与数据库副作用
Worker/登记不发布 RabbitMQ、不写 notification、不写 snapshot_history、不写 audit;测试 20/22 验证。快照仅 candidate_dimension_scores 与任务表。
## 18. 全量测试
Docker 恢复后的最终实测 `mvn clean verify`:**2130/2130,Failures=0、Errors=0、Skipped=0,BUILD SUCCESS**。其中既有 Q01 MySQL 20 项与新增 D4-1 MySQL 5 项均真实执行。OpenAPI **176 paths / 214 operations**,校验通过。
## 19. OpenAPI
176 paths / 214 operations,Actuator 不进业务契约;无新增公网刷新接口。
## 20. 精确修改文件
新增:V44 迁移;`candidatescore/refresh/`:CandidateScoringRefreshTask、CandidateScoringRefreshRequestService、CandidateScoringSnapshotWriter、CandidateScoringRefreshWorker、CandidateScoringRefreshTaskExecutor、CandidateScoringRefreshCompensationScanner、CandidateScoringRefreshScheduler;测试 `CandidateScoringRefreshInfrastructureTest`、`CandidateScoringRefreshFailureTransactionTest`。
修改:CandidateScoringCalculator(upsert 委托 Writer + recompute 强制路径);写侧 8 Service(登记接入);application.yml(app.scoring.refresh.* 默认关闭);测试适配:R01D4GetWritesSnapshotEvidenceTest/CandidateScoringPersistenceIntegrationTest(version 幂等契约演进)、CandidateScoringCalculatorTest/ClockConsistencyTest/FairnessMonitoringPersistenceIntegrationTest(构造/委托验证)、6 个 Service 测试补 Mock 依赖(未放宽断言)。
## 21. Git 状态
未 commit/push;暂存区空;V44 唯一新增迁移;历史未跟踪文件未删未移;GlobalExceptionHandler 行尾现象保持。
## 22. 进入 D4-2 的网关(当前:**未达到**)
- [x] 唯一 Writer + 幂等 version 语义(H2 验证)
- [x] 任务登记/防丢失/并发领取/重试/failure 分类(H2 验证)
- [x] 写侧 8 类事件登记 + 不接入项确认
- [x] 真实 MySQL 8.4 核心基础设施验收(V44/validate、Writer、并发领取、事务隔离、重登记与超时恢复)
- [ ] D4-2 前消费者黑盒(GET 零写切换、趋势/申诉/排名/看板/监控、副作用与 RabbitMQ)
- [ ] 补偿扫描完整窗口规则 + 查询预算真实库验证
- [ ] 写侧覆盖证明(滚动运行期对账)
→ 未满足 → **不得移除 GET 写入**
## 23. 剩余风险
补偿扫描仍缺持久化游标与单轮总预算;POST/PUT 申诉决定/回滚在真实库对 version 语义的回归未验(H2 已验);普通 Writer 的 MySQL 幂等、差异更新、强制版本、`SELECT FOR UPDATE` 与 no-op upsert 已有专用真实库证据。
## 最终状态
> **R01-D4-1 核心基础设施及真实 MySQL 8.4 验证已完成;GET 兜底仍保留。** 进入 D4-2 前仍需把补偿扫描收束为可持续的持久游标/单轮预算,并在 D4-2 同批完成消费者、副作用及 RabbitMQ 黑盒,不得直接删除 GET upsert。

277
docs/r01-d4-2-blocker-remediation-20260916.md

@ -0,0 +1,277 @@
# R01-D4-2 阻断缺陷定向整改报告(F1–F4 及两项次生缺陷)
日期:2026-09-16
分支:`refactor/r01-structure-performance`
HEAD:`b10c4a732740e42353ca826f63141c3dd2a49915`(整改全程未变)
来源:`docs/r01-d4-2-independent-mysql-acceptance-20260916.md` 判定的 4 项 FAIL + 2 项次生缺陷
整改性质:**仅修复已确认缺陷**,未进入其他 R01 工作
---
## 1. 结论
> **R01-D4-2 阻断缺陷定向整改完成,待最终独立增量复验;GET 评分快照写入迁移尚未冻结。**
| 缺陷 | 状态 | 修复前失败证据 | 修复后 |
|---|---|---|---|
| F1 Offer 自动过期不登记 | 已修复 | 4 个断言失败 | 全通过 + 真实 MySQL 复验 |
| F2 反馈 update/delete 不登记 | 已修复 | 4 个断言失败 | 全通过 + 真实 MySQL 复验 |
| F3 简历版本与任务提交边界不对称 | 已修复 | 1 个边界断言失败 | 全通过 + 真实 MySQL 复验 |
| F4 ABILITY 敏感字段断言缺失 | 已恢复(并增强) | 原断言已删(本次新增更强版本) | 全通过 |
| 次生:claim fence 缺失 | 已修复 | — | 全通过(含双 Worker 并发) |
| 次生:单任务失败阻断批次 | 已修复 | — | 全通过(含循环隔离单测) |
---
## 2. 修复前失败测试结果(TDD 证据)
命令:
```
mvn -Dtest="OfferExpiryRefreshRegistrationTest,CandidateFeedbackRefreshRegistrationTest,ResumeVersionRefreshAtomicityTest,CandidateScoringRefreshClaimFenceTest,CandidateScoringRefreshBatchIsolationTest,CandidateScoringRefreshInfrastructureTest" test
```
日志:`docs/acceptance/r01-d4-2-20260916/fix-before.log`
| 测试方法 | 失败信息(节选) |
|---|---|
| `CandidateFeedbackRefreshRegistrationTest.updateFeedback_registersRefreshTask:125` | `expected: <1> but was: <0>` —— updateFeedback 未登记 |
| `CandidateFeedbackRefreshRegistrationTest.deleteFeedback_registersRefreshTask_afterRowRemoved:137` | `expected: <1> but was: <0>` —— deleteFeedback 未登记 |
| `CandidateFeedbackRefreshRegistrationTest.updateFeedback_ratingUnchanged_...:149` | `expected: <1> but was: <0>` |
| `CandidateFeedbackRefreshRegistrationTest.feedbackLifecycle_...:182` | `update 登记 ==> expected: <1> but was: <0>` |
| `ResumeVersionRefreshAtomicityTest.createVersion_outerTransactionRollsBack_...:102` | 外层事务回滚后版本行存在(1)而刷新任务为 0 —— 提交边界不对称 |
| `OfferExpiryRefreshRegistrationTest`(4 个) | 首轮先因夹具 FK 报错,修正夹具后 `expireOne` 未登记 → 断言失败 |
> 说明:F1/claim-fence 的首轮运行还包含**测试自身夹具缺陷**(H2 不支持 `DATE_FORMAT`、FK 删除顺序、简历文件缺 PDF 签名、offer 夹具缺列)。这些已先行修正,产品缺陷证据以上表为准。
---
## 3. 各写侧的事务边界
| 写侧入口 | 登记位置 | 事务边界 | 原子性依据 |
|---|---|---|---|
| `OfferExpiryWorker#expireOne` | `OfferExpiryWorker.java`(`saveAndFlush` 之后、`notifyExpired` 之前) | 方法自身的 `REQUIRES_NEW` | 状态变更、还价过期标记、刷新登记、历史行同属一个事务;任一步失败全部回滚 |
| `CandidateFeedbackService#createFeedback` | 既有(`:82` 之后) | 方法 `@Transactional` | 与 feedback 插入同事务 |
| `CandidateFeedbackService#updateFeedback` | 新增(`save` 之后) | 方法 `@Transactional` | 与 rating 更新同事务 |
| `CandidateFeedbackService#deleteFeedback` | 新增(`delete` 之后;先固定 `studentUserId`) | 方法 `@Transactional` | 与删除同事务 |
| `ResumeVersionWriter#saveNewVersion` | 新增(`save` 之后) | 组件自身的 `REQUIRES_NEW` | **与版本行同一提交边界**(F3 核心) |
| `ResumeVersionService#createVersion` | 已移除(原 `:71`) | 外层 `@Transactional` | 不再重复登记,避免外层事务耦合 |
| `ResumeVersionService#updateVersion / #deleteVersion` | 既有 | 方法 `@Transactional` | 与版本更新/删除同事务 |
**F3 设计选择**:保留 `ResumeVersionWriter` 的 `REQUIRES_NEW`(既有并发冲突重试防护,移除会破坏"单次冲突只回滚自身、外层可安全重试"的设计),按任务书推荐把 `requestRefresh` 放入该写事务组件,`CandidateScoringRefreshRequestService` 以 `REQUIRED` 加入。外层不再重复登记。
---
## 4. 简历版本与任务的原子性证据
测试:`ResumeVersionRefreshAtomicityTest#createVersion_outerTransactionRollsBack_versionRowAndTaskCommitTogether`
场景:用 `TransactionTemplate` 包裹 `createVersion`,返回后抛异常使外层回滚。
断言:
- `resume_versions` 行数 = 1(既有设计:`REQUIRES_NEW` 独立提交,外层回滚不使其消失)
- `candidate_scoring_refresh_tasks` 行数 = 1 —— **版本行存在则刷新任务必须同样存在**
修复前:`expected: <1> but was: <0>`(任务丢失)。
修复后:通过。
真实 MySQL 复验:`F3 createVersion 成功 st=200` → `版本行与任务同时存在(无一半提交)versions=3 tasks=1`(三次调用收敛为一条任务)。
**文件系统操作未被纳入数据库原子性承诺**:测试 mock 了 `ResumeStorageService`;生产代码中"存量文件清理"仍由 `createVersion` 既有的 `catch(RuntimeException)` 负责,与数据库事务边界解耦,未做改动。
---
## 5. claim fence 的字段与 SQL 条件
**所有权标识**:`processing_started_at`(每次 claim 写入的领取时刻)。
不采用"仅校验 `version`":同一 claim 期间到达的新请求会合法推进 `version`,此时仍需回到 `PENDING`,所以 `version` 不能单独充当所有权闸门。
| 操作 | SQL 条件 |
|---|---|
| `claim` | `WHERE id=? AND status IN ('PENDING','RETRY') AND (next_attempt_at IS NULL OR next_attempt_at <= ?)`;随后 `SELECT ... processing_started_at` 记入 `ClaimedTask.claimedAt` |
| `materialize` → COMPLETED | `WHERE id=? AND status='PROCESSING' AND processing_started_at=? AND version=?` |
| `materialize` → PENDING 回退 | `WHERE id=? AND status='PROCESSING' AND processing_started_at=?`(不带 version:新请求推进 version 正是回退的触发条件) |
| `recordFailure` | `WHERE id=? AND status='PROCESSING' AND processing_started_at=?` |
API 变更:`ClaimedTask` 新增 `LocalDateTime claimedAt` 字段;`recordFailure(...)` 新增 `claimedAt` 形参。
---
## 6. stale worker 的处置结果
三种情形与处置:
| 情形 | 条件命中 | 处置 |
|---|---|---|
| 同一 claim,无新请求 | COMPLETED 更新 1 行 | 置 `COMPLETED` |
| 同一 claim,期间有新请求 | COMPLETED 0 行 → PENDING 更新 1 行 | **回到 `PENDING`**,下一轮重算 |
| 已被其他 Worker 重新领取 | COMPLETED 0 行 → PENDING 0 行 | **安静返回**,不覆盖新持有者(不抛异常) |
| 失败登记遇 stale claim | `recordFailure` 更新 0 行 | **安静返回**,不抛异常(原先抛 `IllegalStateException`) |
`processOne` 的失败登记另加一层保护:`recordFailure` 自身异常仅告警,不向外抛。
测试证据(`CandidateScoringRefreshClaimFenceTest`):
- `staleWorkerCompletion_cannotOverwriteNewWorkerClaim` —— 旧 claim 完成路径 `assertDoesNotThrow`,且任务保持新持有者的 `PROCESSING` 与 `processing_started_at`
- `staleWorkerFailure_cannotOverwriteNewWorkerClaim` —— 旧 claim 失败登记同样安静退出
- `requestDuringSameClaim_completionFallsBackToPending` —— 同 claim 期间新请求 → 回到 `PENDING`
- `claim_exposesProcessingStartedAtAsOwnershipFence` —— `claimedAt` 已暴露
不使用 `sleep`,不使用 JVM 锁。
---
## 7. 单任务失败是否影响同批后续任务
**修复前**:`recordFailure` 在 `updated != 1` 时抛 `IllegalStateException`,异常点位于 Worker 的 catch 块内,而 `processDue` 的 `for` 循环无保护 → 同批剩余任务本轮全部不处理。
**修复后**:
- `recordFailure` 的 stale claim 改为安静返回(不再抛)
- `processOne` 捕获失败登记自身异常
- `processDue` 的循环体增加 `try/catch`(兜底,日志只记录 taskId 与异常类名)
测试证据:
- `CandidateScoringRefreshWorkerLoopIsolationTest`(纯单元,精确构造"第一项失败且失败登记也抛异常"):
- `recordFailureThrowing_doesNotAbortRemainingTasksInBatch` —— 后续第 2、3 项仍被 `materialize`
- `recordFailureThrowingInMiddle_doesNotAbortRemainingTasks` —— 中间项失败不阻断前后
- `claimLosing_skipsTaskWithoutRecordingFailure` —— claim 失败(被并发抢走)不记失败、继续下一项
- `CandidateScoringRefreshBatchIsolationTest`(真实 H2 + mock 计算器):
- `firstTaskFails_secondTaskStillProcessed`、`failingTaskInMiddle_doesNotBlockRemainingTasks`
- `lastErrorCode_neverLeaksExceptionMessage` —— 分类为固定值 `COMPUTE_FAILED`,不含手机号/Token/异常原文
- `batchTerminalState_matchesRealOutcome` —— 批次结束无残留 `PROCESSING`
---
## 8. 恢复后的 ABILITY 敏感字段断言清单
位置:`CandidateScoringRefreshInfrastructureTest#abilitySnapshotPayload_hasNoSensitiveFields_andKeepsWhitelistedKeys`
方式:**经真实 `CandidateScoringSnapshotWriter` 写入 → 从真实数据库读回 `sub_scores_json` → 递归断言**(不是检查 DTO 定义,也不复用旧测试文本)。
**正向断言(允许字段必须存在且取值正确)**:
`source`(= `V36_ABILITY_ASSESSMENT`)、`sourceResultId`(= 123)、`validUntil`、`questionSetVersion`、`assessmentRulesVersion`、`mappingRulesVersion`、`weights`、`dimensionScores`
**负向断言(递归、大小写不敏感、覆盖嵌套对象与数组)** —— 以下 key 一律禁止:
```
answer, answers, rawanswer, rawanswers,
question, questions, questionid, questioncode, questionstem,
option, options, optionid, selectedoption, selectedoptionid,
answerkey, competencytag, stem,
scoringrule, awardedscore, explanation,
phone, email, mobile, token, password, accesstoken
```
(在任务书要求的 10 项之上,保留了原验收报告中的 `answerKey/questionCode/competencyTag/stem/scoringRule/awardedScore/explanation/phone/email`,并补充 `mobile/token/password/accessToken`)
**大小写策略**:key 小写化后**精确匹配**(避免白名单字段 `questionSetVersion` 因含子串 `question` 而误报);另有一层原始 JSON 文本层断言,禁止出现 `"<banned>"` 形式的 key 字面量。
---
## 9. 自动化测试数量
| 命令 | 结果 |
|---|---|
| 定向(12 个类,含 6 个新增) | **104 / 104**,F=0 E=0 S=0 |
| 关联回归(`*CandidateScoring*Test,*Scoring*Test,*Offer*Test,*Feedback*Test,*ResumeVersion*Test,*Application*Test,*Appeal*Test,*Ranking*Test,*Fairness*Test`) | **558 / 558**,F=0 E=0 S=0,BUILD SUCCESS |
| `mvn clean verify` | **2163 / 2163**,Failures=0、Errors=0、**Skipped=0**,BUILD SUCCESS(3:47) |
| `python docs\verify_openapi.py` | **176 paths / 214 operations**,All checks passed |
较整改前(2131)新增 **32** 个测试(6 个新测试类:Offer 过期登记 7、反馈登记 8、简历原子性 4、claim fence 6、批次隔离 4、循环隔离 3)。
> 过程中出现并已修复的**测试自身缺陷**(非产品):新测试类未清理 H2 共享内存库残留,导致 `InterviewScoreControllerTest` / `MessageRecallPersistenceIntegrationTest` / `MessagingBlockIntegrationTest` 的 tearDown 遇 FK 冲突(23 errors)。已为全部新测试类补 `@AfterEach` 精确清理,复跑全绿。
---
## 10. 真实 MySQL / Docker 结果
实例:最新 JAR(`mvn clean verify` 产出,82,441,449 bytes),`SCORING_REFRESH_ENABLED=true`、`OFFER_EXPIRY_INTERVAL_MS=3000`、compensation-interval 5s、limit 2。PID 2856,验收后已停止。
`docs/acceptance/r01-d4-2-20260916/v2-out.txt`:**PASS=18 / FAIL=0 / SKIP=0**
| 项 | 真实 MySQL 结果 |
|---|---|
| F1 Offer 自动过期 | 夹具 offer(精确 INSERT,id=15)被定时任务置为 `EXPIRED` → **生成刷新任务 1 条** → `COMPLETED`(0.1s) |
| F2 create | `POST /applications/88/feedback` 200 → 任务 `COMPLETED`(0.7s) |
| F2 update | `PUT /applications/88/feedback`(rating 5→2)200 → 任务 `COMPLETED`(1.3s),无失败分类、attempts=0 |
| F2 delete | `DELETE /applications/88/feedback` 200 → 任务 `COMPLETED`(1.3s) |
| F3 简历版本 | `POST /profile/me/resume-versions` 200 → 版本行与任务**同时存在**,三次调用收敛为 1 条任务 |
| 三个 GET 零写 | 审计/通知/消息/申诉/用户/业务表**逐字段零变化**(豁免表仅限后台 Worker 与补偿扫描合法改动的 3 张) |
| 趋势/排名/看板 | 4 个端点全部 200 |
| 快照完整性 | 学生 A 当前周期 **8 维**快照齐备 |
| RabbitMQ 终态 | 4 个队列(`queue.sms.verification` / `queue.notification` / `queue.notification.dlq` / `queue.audit`)**ready=0、unacked=0** |
| 日志与审计 | 未出现手机号、Token、评分原始输入或 SQL 绑定参数;错误分类仅固定枚举值 |
---
## 11. OpenAPI 数量
静态 `docs/openapi-rc.json` 与 `docs/verify_openapi.py`:**176 paths / 214 operations**,`All checks passed!` —— 与本轮整改前一致,**未新增公网刷新、任务、补偿或管理接口**。
---
## 12. 精确修改文件
**生产代码(6 个,全部在任务书允许清单内)**
| 文件 | 改动 |
|---|---|
| `offer/OfferExpiryWorker.java` | 注入 `CandidateScoringRefreshRequestService`;`expireOne` 在状态提交后同事务登记 |
| `feedback/CandidateFeedbackService.java` | `updateFeedback` / `deleteFeedback` 同事务登记(删除前固定 `studentUserId`) |
| `resume/ResumeVersionWriter.java` | 注入登记服务;`saveNewVersion` 在与版本行同一 `REQUIRES_NEW` 事务内登记 |
| `resume/ResumeVersionService.java` | 移除 `createVersion` 中的重复登记(登记已移入 Writer) |
| `candidatescore/refresh/CandidateScoringRefreshTaskExecutor.java` | `ClaimedTask` 增 `claimedAt`;`claim` 读回 `processing_started_at`;`materialize` 的 COMPLETED/PENDING 两分支与 `recordFailure` 全部加所有权闸门;stale claim 改为安静返回 |
| `candidatescore/refresh/CandidateScoringRefreshWorker.java` | `processOne` 失败登记加保护;`processDue` 循环体加 `try/catch` |
**测试(新增 6 个类;修改 2 个既有类仅补依赖/调用签名)**
新增:`OfferExpiryRefreshRegistrationTest`、`CandidateFeedbackRefreshRegistrationTest`、`ResumeVersionRefreshAtomicityTest`、`CandidateScoringRefreshClaimFenceTest`、`CandidateScoringRefreshBatchIsolationTest`、`CandidateScoringRefreshWorkerLoopIsolationTest`
修改:`CandidateScoringRefreshInfrastructureTest`(新增 F4 测试 + 增补 `Map` import + `recordFailure` 新签名)、`OfferExpiryWorkerTest`(补 `@Mock` 依赖)
**未超出允许范围**:无需修改清单外的生产文件(清单中的 `CandidateScoringRefreshTaskRepository.java` 在项目内**不存在** —— D4-1 采用 JdbcTemplate 而非 JPA Repository,属清单笔误,无需报告偏离)。
---
## 13. 是否修改迁移 / ErrorCode / 接口
| 项 | 结论 |
|---|---|
| V1–V44 迁移 | **未修改**(`git diff --name-only -- db/migration` 为空);V44 仍为唯一新增迁移 |
| 新增迁移 | **无** |
| 表 / 索引 | **未新增** |
| `ErrorCode` 枚举 | **未修改**(0 个文件) |
| 公网接口 | **未新增**(OpenAPI 保持 176/214) |
| 评分公式 / 权重 / 排名规则 / 消费者行为 | **未修改** |
| RabbitMQ 消息 / 通知 | 未引入 |
---
## 14. Git 状态
```
分支:refactor/r01-structure-performance
HEAD:b10c4a732740e42353ca826f63141c3dd2a49915(未变)
暂存区:空
git add -A / commit / push:均未执行
删除文件:无
git diff --check:无空白错误
```
验收数据已按精确主键清理(`users` 残留 0;`offers`/`resume_versions`/`applications`/`jobs`/`companies`/`dimension_scores`/`refresh_tasks` 全部归零)。`audit_logs` 中 actor 为本轮 4 个隔离用户的 **14 行**属 append-only 合规记录,保留并登记(关联 ID:`user_id ∈ {8006, 8007, 8008, 8009}`)。
唯一测试夹具(精确 INSERT,已登记并已清理):`user_roles` 为 8008/8009 各插入 1 行 `COMPANY`(项目无公网接口可授予该角色);`offers` 插入 1 条已过期 SENT 记录用于触发 F1 定时路径(id=15)。
---
## 15. 明确声明
- **未暂存、未提交、未推送。**
- 未进入其他 R01 工作。
- 未修改评分公式、权重、状态、规则版本、排名、申诉或公平性语义。
- 已按要求「先写失败测试 → 保存修复前失败结果 → 再改生产代码」,失败证据见 `fix-before.log`。
---
## 16. 待最终独立增量复验项
1. F1–F4 的修复在**独立复验**中重新验证(本轮为整改方自测)。
2. `claim fence` 的双 Worker 真实并发复验(本轮已有真实 MySQL 层的 stale claim 单线程构造 + 纯单测并发场景;**真实双 JAR 竞争仍未做**)。
3. PROCESSING 防丢失的真实 HTTP 窗口复验(沿用 D4-2 验收的方式引用真实 MySQL 并发集成测试)。
4. 申诉审核/回滚的真实黑盒(本轮未改动申诉路径,仍属 D4-2 遗留 SKIP)。
> R01-D4-2 阻断缺陷定向整改完成,待最终独立增量复验;GET 评分快照写入迁移尚未冻结。

234
docs/r01-d4-2-claim-fence-remediation-20260916.md

@ -0,0 +1,234 @@
# R01-D4-2 claim fence 定向整改报告(不可复用 claim token)
日期:2026-09-16
分支:`refactor/r01-structure-performance`
HEAD:`b10c4a732740e42353ca826f63141c3dd2a49915`(整改全程未变)
来源:`docs/r01-d4-2-final-independent-incremental-verification-20260916.md` 的 FAIL 项(R-1)
整改范围:**仅修复 fence 唯一性**,未进入其他 R01 项目
---
## 1. 结论
> **R01-D4-2 claim fence 定向整改完成,待最终独立增量复验;GET 评分快照写入迁移尚未冻结。**
采用任务书指定的**不可复用 claim token** 方案。`processing_started_at` 秒级精度导致的 fence 可重复问题已消除 —— 所有权判定不再依赖任何时间值、时间精度或配置下界。
---
## 2. 修复前失败测试(TDD 证据)
新增 `CandidateScoringRefreshClaimTokenTest`(13 个测试),在**只加入 V45 列、未改 Java**时运行:
命令:`mvn -Dtest="CandidateScoringRefreshClaimTokenTest" test`
日志:`docs/acceptance/r01-d4-2-20260916/fence-before.log`
**结果:Tests run: 10, Failures: 6**
任务书要求复现的三条,全部复现成功:
| 要求 | 测试方法 | 失败信息 |
|---|---|---|
| ① 两次领取拥有完全相同的 `processing_started_at` | `sameTimestamp_staleWorkerPendingRevert_mustNotTouchNewClaim` | 断言 `assertEquals(SAME_INSTANT, processingStartedAt(taskId))` **通过**,证明两次领取时间值相同 |
| ② Worker B 已重新领取 | 同上 | 断言 `assertEquals("PROCESSING", status(taskId))` **通过** |
| ③ **Worker A 的 PENDING 回退可以错误修改 B 的任务** | 同上 | **`expected: <PROCESSING> but was: <PENDING>`** ← 核心缺陷复现 |
同时复现了失败登记的同类覆盖:
`sameTimestamp_staleWorkerFailure_mustNotTouchNewClaim` → **`expected: <PROCESSING> but was: <RETRY>`**
其余 4 条失败为 `claim_token` 尚未写入(值为 `null`)。
**关键点**:测试使用常量 `SAME_INSTANT = LocalDateTime.of(2026, 9, 16, 12, 0, 0)`,两次领取传入**完全相同**的时间值,**未使用任何 sleep 或跨秒等待**。
---
## 3. 实现
### 3.1 迁移 V45
`mydreamjob-server/src/main/resources/db/migration/V45__add_scoring_refresh_claim_token.sql`
```sql
ALTER TABLE candidate_scoring_refresh_tasks
ADD COLUMN claim_token VARCHAR(36) NULL
COMMENT '本次领取的不可复用令牌(UUID),终态/待处理时清空;仅用于并发所有权判定';
```
- **仅新增列**,未增加索引 —— 查询依据:`claim_token` 只出现在既有的**按主键 `id` 定位**的 UPDATE 中(`WHERE id=? AND ...`),不参与扫描或排序,因此无需新索引。
- 未修改 V1–V44,未修改业务表数据,未新增公网接口或 ErrorCode。
### 3.2 `CandidateScoringRefreshTaskExecutor`
| 操作 | 关键改动 |
|---|---|
| `claim` | 生成 `java.util.UUID.randomUUID().toString()`,在领取 SQL 中**原子写入** `claim_token`;读回并放入 `ClaimedTask` |
| `materialize` → COMPLETED | `WHERE id=? AND status='PROCESSING' AND claim_token=? AND version=?`;`SET claim_token=NULL` |
| `materialize` → PENDING 回退 | `WHERE id=? AND status='PROCESSING' AND claim_token=?`;`SET claim_token=NULL`;0 行 → 安静返回 |
| `recordFailure` | 参数由 `LocalDateTime claimedAt` 改为 `String claimToken`;`WHERE id=? AND status='PROCESSING' AND claim_token=?`;`SET claim_token=NULL`;0 行 → 安静返回 |
| `recoverStale` | `SET claim_token=NULL`(回收即释放所有权) |
| `ClaimedTask` | 新增 `String claimToken`;保留 `claimedAt` 但**仅用于超时扫描与观测**,不再参与任何 WHERE 条件 |
`processing_started_at` 的用途收敛为:超时扫描(`WHERE status='PROCESSING' AND processing_started_at < ?`)与观测。所有权证明完全由 `claim_token` 承担。
### 3.3 语义保持
- 同一 claim 期间出现新请求 → `version` 前进 → COMPLETED 更新 0 行 → **PENDING 回退**(`claim_token` 未变,闸门放行)✔
- 新 Worker 已重新领取 → `claim_token` 已变 → 完成、失败、回退**全部更新 0 行并安静退出** ✔
- 进入 PENDING / COMPLETED / RETRY / FAILED 均清空 `claim_token` ✔
- 不依赖 `Thread.sleep`、时间精度或配置下界 ✔
---
## 4. 测试覆盖(任务书清单逐项)
`CandidateScoringRefreshClaimTokenTest`(13 个测试,全部通过):
| 任务书要求 | 测试方法 |
|---|---|
| 同一时间戳下两次领取的 claim token 不同 | `twoClaimsAtSameInstant_getDifferentTokens` |
| 旧 Worker 不能完成新 claim | `sameTimestamp_staleWorkerPendingRevert_mustNotTouchNewClaim`(含完成尝试) |
| 旧 Worker 不能将新 claim 回退 PENDING | 同上(核心断言) |
| 旧 Worker 不能记录新 claim 失败 | `sameTimestamp_staleWorkerFailure_mustNotTouchNewClaim` |
| 当前 Worker 可以正常完成 | `currentHolder_canCompleteNormally` |
| 同一 claim 期间 version 前进会正确回到 PENDING | `requestDuringSameClaim_versionAdvances_completionFallsBackToPending` |
| stale recovery 后生成新 token | `staleRecovery_generatesNewTokenOnNextClaim` |
| terminal/PENDING 状态 token 被清空 | `terminalAndPendingStates_clearClaimToken`、`failedTerminalState_clearsClaimToken` |
| 双 Worker 循环至少 100 次无覆盖、无永久 PROCESSING | `doubleWorkerLoop_100Rounds_noOverwrite_noStuckProcessing_noLostRequest` |
| token 不进入日志、审计、响应或错误信息 | `claimToken_doesNotLeakIntoAuditOrNotificationTables`、`claimToken_rowNeverExposedInTaskStatusQueriesUsedByObservability` |
补充:`claim_repeatedOnDifferentTasks_producesDistinctTokens`、`tokenIsUuidShaped_andNotDerivedFromIds`(UUID 形态、不可复用、不由 ID 派生)。
**全部使用固定时间值**,无 sleep、无跨秒等待。
---
## 5. 回归
| 命令 | 结果 |
|---|---|
| `mvn -Dtest="*CandidateScoringRefresh*Test" test` 等 7 个 fence 相关类 | **49 / 49**,F=0 E=0 S=0 |
| 关联回归(9 组通配符) | **571 / 571**,F=0 E=0 S=0 |
| `mvn clean verify` | **2176 / 2176**,Failures=0、Errors=0、**Skipped=0**,BUILD SUCCESS |
| `python docs\verify_openapi.py` | **176 paths / 214 operations**,All checks passed |
较整改前(2163)新增 **13** 个测试,全部为 claim token 相关。
**过程中修正的测试侧问题(非产品缺陷)**:
1. `CandidateScoringRefreshMySqlIntegrationTest#v44MigrationAndSchemaValidationAreApplied` 原断言任务表 13 列 —— V45 后为 14 列,同步更新并**新增** V45 成功性、唯一性、`claim_token` 存在性断言(属本次授权迁移的计划内演进,非放宽)。
2. `OfferExpiryRefreshRegistrationTest#expireOne_refreshTaskCarriesNoSensitiveFields` 原禁止列名含 `TOKEN` —— `claim_token` 是随机 UUID 而非用户凭据,禁止列表改为 `ACCESS_TOKEN/REFRESH_TOKEN/PASSWORD/SECRET` 等真实凭据列,并**新增**断言要求 `claim_token` 必须存在。
3. 本人新增测试中的两处断言缺陷(UUID 随机字符集几乎必然含任务 ID 数字;`newTask()` 复用同学生导致任务合并),已改为有意义的断言。
---
## 6. 真实 MySQL 最小增量复验
使用本轮 `clean verify` 产出的最新 JAR。物化实例 PID 11104(`SCORING_REFRESH_ENABLED=true`),只读实例 PID 18372(`false`),均已停止,8080/8081 空闲。
### 6.1 V45 迁移与 schema validation
```
version | description | success
44 | candidate scoring refresh tasks | 1
45 | add scoring refresh claim token | 1
v45_rows = 1 failed_migrations = 0
claim_token | varchar(36) | YES
task_cols = 14
```
Hibernate `ddl-auto=validate` 通过,日志中 `schema-validation`/`SchemaManagementException` 命中 **0** ✔
### 6.2 claim fence 100 轮竞争(固定相同 processing_started_at)
脚本 `iv-fence-loop2.py`:使用与生产 `claim`/`recoverStale`/`materialize`/`recordFailure` **逐字一致**的 SQL,两次领取的 `processing_started_at` **固定为 `2026-09-16 12:00:00`**。
```
轮次 = 100(两次领取的 processing_started_at 固定为 2026-09-16 12:00:00)
[PASS] 100 轮:A 的完成/失败/回退全部 0 行、未覆盖 B、B 均完成、无永久 PROCESSING
```
每轮断言:
- A 用旧 token 的完成 / 失败 / PENDING 回退 → **受影响行数均为 0**
- A 未改动 B 的 `status`、`claim_token`、`attempts`、`last_error_code`
- B 用新 token 完成 → 1 行
- 终态无残留 `PROCESSING`
(脚本首轮运行暴露了自身缺陷:`ROW_COUNT()` 跨 `docker exec` 连接失效、回收步骤的 `WORKER_TIMEOUT` 被误判为污染;已修正后重跑。)
### 6.3 三个 GET 零写
**PASS=24 / FAIL=0 / SKIP=0** —— 三个目标 GET ×3 + 趋势 GET ×3 全部 HTTP 200,**22 张表逐字段零变化** ✔
### 6.4 无快照学生
新学生 8019:`before=0` → 3 次 GET(均 200)→ `after=0` ✔
### 6.5 五条写侧链路
**PASS=44 / FAIL=0 / SKIP=0** —— Offer 自动过期、Feedback create/update/delete、简历版本创建;每条均验证「业务变化 → 唯一任务 → 周期正确 → Worker COMPLETED → 无失败分类 → 八维齐备 → 不写 snapshot_history」;相同输入重放不推进 version ✔
### 6.6 消费者最小回归
**PASS=33 / FAIL=0 / SKIP=0** —— 趋势(当前周期 2026-09)、排名(rank/percentile/cohortSize)、企业看板 4 端点 + 越权 403、公平性 5 端点(抑制语义保留)、申诉取证非空;消费者 GET 逐字段零写 ✔
### 6.7 RabbitMQ
4 个队列(`queue.sms.verification` / `queue.notification` / `queue.notification.dlq` / `queue.audit`)**ready=0、unacked=0** ✔
### 6.8 token 不泄露
- 测试断言:token 不出现在 `audit_logs` / `notifications` / `messages` / `candidate_dimension_scores.sub_scores_json`;`last_error_code` 仅固定枚举 ✔
- 运行日志:未出现 token 形态内容;`claim_token` 不参与任何日志输出语句 ✔
- 数据库:token 仅存于 `candidate_scoring_refresh_tasks.claim_token`,且在终态/PENDING 时被清空 ✔
---
## 7. 精确修改文件
**生产代码(3 个)**
| 文件 | 改动 |
|---|---|
| `db/migration/V45__add_scoring_refresh_claim_token.sql` | **新增**:仅加 `claim_token VARCHAR(36) NULL` |
| `candidatescore/refresh/CandidateScoringRefreshTaskExecutor.java` | `claim` 生成并原子写入 UUID;COMPLETED / PENDING 回退 / `recordFailure` 的全部 WHERE 由 `processing_started_at` 改为 `claim_token`;四处(含 `recoverStale`)`SET claim_token=NULL`;`ClaimedTask` 增 `claimToken`;`recordFailure` 形参改 `String claimToken` |
| `candidatescore/refresh/CandidateScoringRefreshTask.java` | 实体新增 `claimToken` 映射;`processingStartedAt` 注释标明不再作为所有权证明 |
**配置/接口/数据**:未修改 `application.yml`;未新增 ErrorCode;未新增公网接口;未修改评分公式、权重、快照内容、任务合并语义;未把 `DATETIME(6)` 当作最终修复(`processing_started_at` 精度保持不变)。
**测试(新增 1 个类;适配 4 个既有类)**
新增:`CandidateScoringRefreshClaimTokenTest`(13 个测试)
适配:`CandidateScoringRefreshMySqlIntegrationTest`(列数 13→14 + V45 断言)、`OfferExpiryRefreshRegistrationTest`(禁止列名调整 + `claim_token` 存在性断言)、`CandidateScoringRefreshClaimFenceTest` 与 `CandidateScoringRefreshInfrastructureTest`(`recordFailure` 传 `claimToken()`)、`CandidateScoringRefreshWorkerLoopIsolationTest`(mock 签名 + `ClaimedTask` 构造)
---
## 8. Git 状态
```
分支:refactor/r01-structure-performance
HEAD:b10c4a732740e42353ca826f63141c3dd2a49915(未变)
暂存区:空
git add -A / commit / push:均未执行
V1–V44:未修改
新增迁移:V45(唯一本轮新增;V44 为 D4-1 产物)
删除文件:无
git diff --check:无空白错误
```
验收数据已按精确主键清理(`users` 残留 0:8015/8016/8017/8018/8019 均删除;公司 33/34、岗位 50、投递 92/93、offer、简历版本、申诉及关联行全部清除)。`audit_logs` 中 actor 为本轮 5 个隔离用户的 **15 + 2 = 17 行**属 append-only 合规记录,保留并登记(关联 ID:`user_id ∈ {8015, 8016, 8017, 8018, 8019}`)。
唯一测试夹具(精确 INSERT,已登记并已清理):`user_roles` 为 8017/8018 各插入 1 行 `COMPANY`。
Docker 保持运行(三容器 healthy);仅停止本轮记录 PID 的实例;8080/8081 空闲。
---
## 9. 明确声明
- **未暂存、未提交、未推送。**
- 未进入其他 R01 工作。
- 原始验收产物继续排除在后续提交之外。
- 已按要求「先补失败测试 → 保存修复前失败结果 → 再改生产代码」。
---
> R01-D4-2 claim fence 定向整改完成,待最终独立增量复验;GET 评分快照写入迁移尚未冻结。

188
docs/r01-d4-2-claim-token-final-verification-20260916.md

@ -0,0 +1,188 @@
# R01-D4-2 claim token 最终独立复验报告(冻结前最后一次)
日期:2026-09-16
分支:`refactor/r01-structure-performance`
HEAD:`b10c4a732740e42353ca826f63141c3dd2a49915`(复验全程未变)
复验对象:`docs/r01-d4-2-claim-fence-remediation-20260916.md` 的 claim token 整改
性质:**仅复验** —— 未修改生产代码、未修改既有测试、未暂存、未提交、未推送
---
## 0. 最终结论
> **R01-D4-2独立代码复验和真实MySQL/Docker增量黑盒全部通过,GET评分快照写入迁移可以冻结并进入精确暂存检查点。**
**FAIL = 0,SKIP = 0。** claim token 闸门在代码审查、自动化测试与真实 MySQL 100 轮黑盒三个层面均确证有效:旧 Worker 无法以任何形式修改新持有者。
---
## 1. 基线
| 检查项 | 结果 |
|---|---|
| 分支 | `refactor/r01-structure-performance` ✔ |
| HEAD | `b10c4a732740e42353ca826f63141c3dd2a49915` ✔(= b10c4a7) |
| 暂存区 | 空 ✔ |
| `git diff --check` | 无空白错误 ✔ |
| V1–V44 | 未修改 ✔ |
| 新增迁移 | **V45 为唯一本轮新增**(V44 为 D4-1 产物)✔ |
| JAR | `mydreamjob-server-0.0.1-SNAPSHOT.jar`,2026-09-16 **17:26:51**(最新 `clean verify` 产出)✔ |
---
## 2. 独立代码审查
`CandidateScoringRefreshTaskExecutor.java`(153 行,逐行核对):
| 核查项 | 结论 | 证据 |
|---|---|---|
| token 在领取 SQL 中原子生成并写入 | ✔ | `:53` `UUID.randomUUID().toString()`;`:56` `SET status='PROCESSING', processing_started_at=?, claim_token=?, ...` 同一 UPDATE |
| token 不由调用方复用 | ✔ | `claim(Long, LocalDateTime)` 无 token 形参,每次调用新生成 |
| COMPLETED 条件 = id + PROCESSING + claim_token + version | ✔ | `:92` `WHERE id=? AND status='PROCESSING' AND claim_token=? AND version=?` |
| PENDING 条件 = id + PROCESSING + claim_token | ✔ | `:104` |
| RETRY / FAILED 条件 = id + PROCESSING + claim_token | ✔ | `:135`(`recordFailure` 依 `attempts+1 >= maxAttempts` 落到 RETRY 或 FAILED) |
| 离开 PROCESSING 后 token 全部清空 | ✔ | `recoverStale` `:38`;COMPLETED `:89`;PENDING `:102`;RETRY/FAILED `:133` —— **四处全覆盖** |
| recoverStale 清空旧 token | ✔ | `:38` `claim_token=NULL` |
| 下一次领取生成全新 token | ✔ | `:53` 每次调用重新生成 |
| `processing_started_at` 不再出现在所有权判断 WHERE 中 | ✔ | 全文件唯一 WHERE 用法为 `:40` 的**超时扫描** `WHERE status='PROCESSING' AND processing_started_at < ?`;所有权判定全部使用 `claim_token` |
| 更新 0 行按 stale claim 处理 | ✔ | `claim` `:61-63` 返回 null;`materialize` PENDING 分支 `:106-110` 安静返回;`recordFailure` `:137-140` 安静返回 —— **均不抛异常、不误报成功** |
| 0 行时不覆盖现持有者 | ✔ | 三个更新语句的 WHERE 均含 `claim_token=?` |
| token 不进入日志/审计/响应/通知/消息/快照 JSON | ✔ | 全仓 grep:`claim_token`/`claimToken` 仅出现在实体映射(`CandidateScoringRefreshTask.java:73-74`)、Executor 的 SQL/注释/record、以及 `CandidateScoringRefreshWorker.java:91` 的传参;refresh 包内**无任何含 token 的日志语句** |
---
## 3. 自动化复验
| 命令 | 实际 | 预期 | 结论 |
|---|---|---|---|
| `mvn -Dtest="*CandidateScoringRefresh*Test" test` | **49 / 49**,F=0 E=0 **S=0** | ≥ 49 | ✔ |
| 关联回归(9 组通配符) | **571 / 571**,F=0 E=0 **S=0**,BUILD SUCCESS | ≥ 571 | ✔ |
| `mvn clean verify` | **2176 / 2176**,F=0 E=0 **S=0**,BUILD SUCCESS | ≥ 2176 | ✔ |
| `python docs\verify_openapi.py` | **176 paths / 214 operations**,All checks passed | 176/214 | ✔ |
日志:`fv-fence.log`、`fv-regression.log`、`fv-clean-verify.log`
---
## 4. 真实 MySQL 最小黑盒
脚本 `fv-fence.py` —— 使用与生产 `claim`/`recoverStale`/`materialize`/`recordFailure` **逐字一致**的 SQL,**所有领取的 `processing_started_at` 固定为 `2026-09-16 12:00:00`**。
**结果:PASS**
### A. 100 轮竞争
每轮:A 领取(token_A)→ 回收(token 清空)→ B 在**相同时间值**下重新领取(token_B)→ A 以 token_A 执行四种迟到写 → B 以 token_B 完成。
| 断言 | 结果 |
|---|---|
| A 的 **COMPLETED** 更新 0 行 | ✔ 100 轮均为 0 |
| A 的 **PENDING** 更新 0 行 | ✔ |
| A 的 **RETRY** 更新 0 行 | ✔ |
| A 的 **FAILED** 更新 0 行 | ✔ |
| B 的 token / status / version 未被 A 修改 | ✔ 逐轮比对 `status='PROCESSING'`、`claim_token=token_B`、`version=version_B` 均不变 |
| B 正常完成 | ✔ 每轮 B 完成后 `status='COMPLETED'` 且 token 清空 |
| 终态无永久 PROCESSING | ✔ |
| 无重复完成、无任务丢失 | ✔ 终态任务数 = 1 |
### B. token 生命周期
- `recoverStale` 后 `claim_token` 为 **NULL** ✔
- 再次领取后 token **非空且不同于此前所有 token** ✔
### C. 无效 token 不可更新
| 场景 | 行数 |
|---|---|
| 空 token(`claim_token=''`) | **0** ✔ |
| 伪造 token(随机新 UUID) | **0** ✔ |
| 旧 token(A 的 token_A) | **0** ✔ |
| NULL token | **0** ✔ |
且四种尝试后任务状态**完全未变**(前后 `st()` 全等)✔
### D. version 推进 → PENDING → 下一轮新 token 完成
- 同一 claim 期间新请求推进 `version` → COMPLETED 更新 **0 行**(version 不匹配)
- 当前 Worker 走 PENDING 回退 → **1 行**,`status='PENDING'` 且 token 清空 ✔
- 下一轮领取使用**新 token** ✔ → 完成 **1 行**,`status='COMPLETED'` ✔
---
## 5. 最小业务回归
实例:最新 JAR;物化实例 PID 11884(`SCORING_REFRESH_ENABLED=true`)、只读实例 PID 6988(`false`),均已停止。
| 项 | 结果 |
|---|---|
| **三个 GET 零写** | **PASS=24 / FAIL=0 / SKIP=0** —— 三个目标 GET ×3 + 趋势 GET ×3 全部 200,**22 张表逐字段零变化** ✔ |
| **无快照学生** | 新学生 8024:`before=0` → 3 次 GET(均 200)→ `after=0` ✔ |
| **五条写侧链路** | **PASS=44 / FAIL=0 / SKIP=0** —— Offer 自动过期、Feedback create/update/delete、简历版本创建;每条验证「业务变化 → 唯一任务 → 周期正确 → Worker COMPLETED → 无失败分类 → 八维齐备 → 不写 snapshot_history」;重放不推进 version ✔ |
| **消费者回归** | **PASS=33 / FAIL=0 / SKIP=0** —— 趋势 / 排名 / 企业看板 / 申诉 / 公平性监控;消费者 GET 逐字段零写 ✔ |
| **V45** | `success=1` 且 `v45_rows=1`(唯一);`failed_migrations=0` ✔ |
| **Hibernate validate** | 通过;日志中 `schema-validation`/`SchemaManagementException` 命中 **0** ✔ |
| **RabbitMQ** | 4 队列(`queue.sms.verification` / `queue.notification` / `queue.notification.dlq` / `queue.audit`)**ready=0、unacked=0** ✔ |
| **token 泄漏扫描** | **PASS** —— 日志非框架 UUID 命中 **0**;HTTP 响应 token 形态命中 **0**;数据库其它载体命中 **0**(当前无活跃 token,终态/PENDING 已全部清空)✔ |
| **扫描器探针** | 临时文件注入 UUID → 正则命中;向 `audit_logs` 插入探针 → DB 命中 1 → 清理后 0 —— **探针 PASS,证明扫描器非空转**;探针文件已删除 ✔ |
---
## 6. 收尾
| 项 | 结果 |
|---|---|
| 本轮 Java 实例 | 仅停止本轮记录的两个 PID(11884、6988);`tasklist` java.exe 计数 = **0** ✔ |
| 8080 / 8081 | 空闲 ✔ |
| Docker | 保持运行,三容器 healthy ✔ |
| 数据清理 | `users` 残留 **0**(8020/8021/8022/8023/8024 及公司、岗位 51、投递 94/95、offer、简历版本全部按精确主键删除)✔ |
| append-only 审计 | `audit_logs` 中 actor 为本轮 5 个隔离用户的 **15 + 2 = 17 行**保留并登记(关联 ID:`user_id ∈ {8020, 8021, 8022, 8023, 8024}`)✔ |
| 测试夹具(精确 INSERT,已登记并已清理) | `user_roles` 为 8022/8023 各插入 1 行 `COMPANY` ✔ |
| Git | 分支未变、HEAD 未变、暂存区空、V1–V44 未改、V45 唯一新增、无删除文件、无空白错误 ✔ |
| 未暂存 / 未提交 / 未推送 | 是 ✔ |
本轮新增产物(`docs/acceptance/r01-d4-2-20260916/`,未进入暂存区):`fv-fence.py` + `fv-fence-out.txt`、`fv-tokenscan.py`、`fv-fence.log`、`fv-regression.log`、`fv-clean-verify.log`、`v3-fv-out.txt`、`s1-fv-out.txt`、`s4-fv-out.txt`。
---
## 7. PASS / FAIL / SKIP 清单
**FAIL**:无。
**SKIP**:无。
**PASS**:
1. 基线(分支/HEAD/暂存区/V1–V44/V45 唯一/最新 JAR)
2. 代码审查 12 项(token 原子生成、不复用、COMPLETED 四条件、PENDING/RETRY/FAILED 三条件、四处清空、recoverStale 清空、新 token、`processing_started_at` 退出所有权判定、0 行 stale 处理、不覆盖、不泄漏)
3. fence 定向 49/49
4. 关联回归 571/571
5. 全量 clean verify 2176/2176(S=0)
6. OpenAPI 176/214
7. 真实 MySQL 100 轮竞争(四种迟到写全 0 行、B 未被修改、B 均完成、无永久 PROCESSING、无丢失)
8. token 生命周期(回收后 NULL、重领后新值)
9. 空/伪造/旧/NULL token 均无法更新
10. version 推进 → PENDING → 新 token 完成
11. 三个 GET 零写 24/24(22 表逐字段)
12. 无快照学生 0 行
13. 五条写侧 44/44
14. 消费者 33/33
15. V45 成功且唯一
16. Hibernate validate
17. RabbitMQ 四队列为 0
18. token 泄漏扫描 0 + 探针有效
---
## 8. 复验过程中的自暴露问题(非产品缺陷)
| # | 问题 | 处置 |
|---|---|---|
| 1 | `fv-fence.py` 的 `retry_sql`/`failed_sql` 存在两个 `%s` 占位符只传一个参数的 Python 格式化错误 | 改为 `% (TABLE, token)`;已修正 |
| 2 | `fv-tokenscan.py` 的 DB 探针使用 `messages` 表触发外键失败 | 改用无外键依赖的 `audit_logs`,并增加「插入前=0 / 插入后=1 / 清理后=0」三段验证;已修正 |
| 3 | 探针插入的 `audit_logs` 行 | 探针执行后立即删除,已确认清理后计数为 0 |
以上均为**验收脚本自身**问题,与产品代码无关;修正后重跑全部通过。
---
> **冻结判定:R01-D4-2独立代码复验和真实MySQL/Docker增量黑盒全部通过,GET评分快照写入迁移可以冻结并进入精确暂存检查点。**

275
docs/r01-d4-2-final-independent-incremental-verification-20260916.md

@ -0,0 +1,275 @@
# R01-D4-2 最终独立增量复验报告(整改后)
日期:2026-09-16
分支:`refactor/r01-structure-performance`
HEAD:`b10c4a732740e42353ca826f63141c3dd2a49915`(复验全程未变)
复验对象:`docs/r01-d4-2-blocker-remediation-20260916.md` 的 F1–F4 与两项次生缺陷整改
性质:**只复验**,未修改生产代码与既有测试
---
## 0. 最终结论
> **已复现具体缺陷,R01-D4-2 不可冻结;报告已包含下一轮定向整改指令。**
判定 **FAIL**。唯一原因是任务书指定的硬性判据 —— **claim fence 的精度不足**:
> 「特别核查 processing_started_at 的 Java/JDBC/MySQL 精度一致性。若新旧两次领取可能得到相同 fence,直接判 FAIL」
实测已证明**可能**(见 §3)。除此之外,**本轮全部核心项均通过**:五条写侧链路、简历事务故障、20 轮 fence 竞争、批次隔离、三个 GET 零写(22 表逐字段)、无快照学生零写、消费者全量回归、V44、RabbitMQ、日志脱敏。
---
## 1. 开始前基线
| 检查项 | 结果 |
|---|---|
| 分支 | `refactor/r01-structure-performance` ✔ |
| HEAD | `b10c4a732740e42353ca826f63141c3dd2a49915`(未变)✔ |
| 暂存区 | 空 ✔ |
| `git diff --check` | 无空白错误 ✔ |
| V1–V43 | 未修改 ✔ |
| V44 | 仍为唯一新增迁移 ✔ |
| 删除文件 | 无 ✔ |
| 生产代码改动 | 14 项(13 个 `M` + `refresh/` 目录),与整改后一致 ✔ |
| JAR | `mydreamjob-server-0.0.1-SNAPSHOT.jar`,**2026-09-16 16:43:14**(本轮 `mvn clean verify` 产出,未使用旧 JAR)✔ |
---
## 2. 独立代码复验(六项重点)
### 2.1 OfferExpiryWorker — **通过**
- `OfferExpiryWorker.java:51` `saveAndFlush` → `:56` `requestRefresh(saved.getCandidateId(), LocalDateTime.now())` → `:57` `notifyExpired` → `:58` `log.info`,全部位于方法自身的 `@Transactional(REQUIRES_NEW)` 内 ✔ 状态变更与登记同事务。
- 未变化时不登记:`:45-47` 的幂等早返回(状态非 `SENT`/`NEGOTIATING` 直接 `return`)位于 `:48` 状态改写**之前**,因此不会对已过期的 Offer 重复登记 ✔
- 真实 MySQL 复验:`offer 17 -> EXPIRED`,任务唯一且 `COMPLETED` ✔
### 2.2 CandidateFeedbackService — **通过**
- `createFeedback`(`:82` 后)、`updateFeedback`(`save` 后)、`deleteFeedback`(`delete` 后)三处均登记 ✔
- `deleteFeedback` 在 `feedbackRepository.delete(fb)` **之前**取 `Long studentUserId = app.getStudentUserId()`,删除后仍用该局部变量登记 ✔
- 失败路径不登记:越权(`assertCompanyOwner`)与 `FEEDBACK_NOT_FOUND` 均在任何登记语句之前抛出 ✔ 真实复验 `B3 失败路径不留孤立任务 tasks=[]` ✔
### 2.3 ResumeVersionWriter — **通过**
- `ResumeVersionWriter.java` 的 `saveNewVersion` 为 `@Transactional(propagation = REQUIRES_NEW)`,`:38` `save` 与 `:40` `requestRefresh(userId, LocalDateTime.now())` 处于**同一事务** ✔
- 外层不重复登记:`ResumeVersionService.createVersion` 中原 `:71` 的登记已移除,改为注释说明 ✔(`grep requestRefresh ResumeVersionService.java` 仅剩 `updateVersion`/`deleteVersion` 两处,二者与各自写操作同事务)
- 保留 `REQUIRES_NEW`:既有「单次唯一键冲突只回滚自身、外层可安全重试」设计未被破坏 ✔
### 2.4 ABILITY 脱敏测试 — **通过**
`CandidateScoringRefreshInfrastructureTest#abilitySnapshotPayload_hasNoSensitiveFields_andKeepsWhitelistedKeys`:
- 经**真实 `CandidateScoringSnapshotWriter.upsertIfChanged`** 写入 → 从**真实数据库** `SELECT sub_scores_json` 读回 → `ObjectMapper.readTree` 后**递归**扫描 ✔
- 正向:`source` / `sourceResultId` / `validUntil` / `questionSetVersion` / `assessmentRulesVersion` / `mappingRulesVersion` / `weights` / `dimensionScores` 全部存在且取值正确 ✔
- 负向:`BANNED_KEYS` 共 **26 项**,逐项核对报告清单一致 —— `answer`, `answers`, `rawanswer`, `rawanswers`, `question`, `questions`, `questionid`, `questioncode`, `questionstem`, `option`, `options`, `optionid`, `selectedoption`, `selectedoptionid`, `answerkey`, `competencytag`, `stem`, `scoringrule`, `awardedscore`, `explanation`, `phone`, `email`, `mobile`, `token`, `password`, `accesstoken` ✔
- 大小写:key 小写化后**精确匹配**(`questionSetVersion` 不会因含子串 `question` 误报);另有原始 JSON 文本层 `"<key>"` 字面量断言 ✔
- 嵌套对象与数组:`assertNoBannedKeyDeep` 递归处理 `isObject` 与 `isArray` ✔
### 2.5 claim fence — **不通过(精度不足,见 §3)**
- 语义正确性:完成(`CandidateScoringRefreshTaskExecutor.java`)校验 `id + status='PROCESSING' + processing_started_at + version`;失败与 PENDING 回退校验 `id + status='PROCESSING' + processing_started_at` ✔
- **但** fence 字段本身精度不足,构成 FAIL。
### 2.6 批次隔离 — **通过**
- `recordFailure` 在 `updated != 1` 时 `return`(安静退出),不再抛 `IllegalStateException` ✔
- `processOne` 的 `catch` 内对 `recordFailure` 再包一层 `try/catch`,避免其异常外泄 ✔
- `processDue` 的 `for` 循环体加 `try/catch` ✔
- 真实数据库异常不被伪装成功:`CandidateScoringRefreshBatchIsolationTest#batchTerminalState_matchesRealOutcome` 断言批次结束为 2 个 `COMPLETED` + 1 个 `RETRY`,且无残留 `PROCESSING` ✔
- 真实 MySQL:`CandidateScoringRefreshMySqlIntegrationTest#mysqlFailureRollsBackPartialSnapshotsAndDoesNotRollbackSibling` ✔
---
## 3. FAIL 项:claim fence 精度不足
### 3.1 事实链
| 环节 | 事实 | 证据 |
|---|---|---|
| 列定义 | `processing_started_at DATETIME NULL` —— **无小数秒** | `V44__candidate_scoring_refresh_tasks.sql:11` |
| 实际精度 | `DATETIME_PRECISION = 0` | `information_schema.columns` 查询 |
| 写入截断 | `'2026-09-16 12:00:00.123456'` 存入 `DATETIME` 后变 `'2026-09-16 12:00:00'`(与 `DATETIME(6)` 比较 `equal_after_store = 0`) | `iv-precision.sql` |
| Java 侧 | `LocalDateTime.now()`(纳秒)传入 JDBC | `CandidateScoringRefreshTaskExecutor.java` `claim(...)` |
| 读回 | `rs.getObject("processing_started_at", LocalDateTime.class)` —— 秒级 | 同上 |
**结论:fence 的比较粒度是 1 秒。**
### 3.2 最小复现(已在真实 MySQL 执行)
脚本:`docs/acceptance/r01-d4-2-20260916/iv-fence2.sql`(与生产 `claim`/`recoverStale` SQL **逐字一致**)
```
after_claim_1 | 2026-09-16 16:36:09 | PROCESSING
after_recover(sub-second-timeout) | 2026-09-16 16:36:09 | PROCESSING ← 回收后立即被重领
after_claim_2 | 2026-09-16 16:36:09 | PROCESSING
```
**两次领取的 fence 完全相同**(`16:36:09` == `16:36:09`),秒级精度无法区分新旧持有者。
同一脚本末段同时给出边界条件:
```
earliest_new_fence_when_timeout_600s = 2026-09-16 12:10:00
can_be_equal = 0
```
### 3.3 触发条件
`recoverStale` 的条件是 `processing_started_at < now - timeout`,其中 `timeout = processingTimeoutMs`(`CandidateScoringRefreshWorker.java:55`:`processingTimeoutMs <= 0 ? 600_000L : processingTimeoutMs`)。
- **timeout ≥ 1000 ms**(含生产默认 600000):新领取时刻 ≥ 旧 fence + timeout > 旧 fence 所在秒 → **fence 必然不同**,机制可靠。
- **timeout < 1000 ms**(运维显式配置 `SCORING_REFRESH_PROCESSING_TIMEOUT_MS` 为 1–999):回收与重领可落在同一秒 → **fence 相同**,闸门失效。
代码中**未对该配置做下界约束**,因此「可能得到相同 fence」成立。
### 3.4 影响范围(如实界定)
即使 fence 相同,**COMPLETED 分支仍校验 `version`**,因此旧 Worker 不会把新 Worker 的 `PROCESSING` 误置为 `COMPLETED`。实际失效路径只有 PENDING 回退分支:旧 Worker 把新持有者的 `PROCESSING` 改成 `PENDING`,导致新 Worker 完成时更新 0 行而安静退出。后果是**多算一次**(任务回到 PENDING 待下轮重算),不产生数据损坏,也不会留下永久 `PROCESSING`。
但按任务书给定的判据 —— 「若新旧两次领取**可能**得到相同 fence,直接判 FAIL;不能用测试等待规避」—— 判定为 **FAIL**。
### 3.5 下一轮定向整改指令
**R-1(阻断冻结)**:让 claim fence 具备亚秒分辨力,二选一:
- **(a) 提升列精度**:新增迁移 `V45` 将 `processing_started_at` 改为 `DATETIME(6)`(**不得修改 V44**),并把 Java 侧传入值统一到微秒;同时确认 `recoverStale` 的比较与索引仍有效。
- **(b) 改用不依赖时间的 fence**:使用既有 `version` 列的**单调快照**作为所有权标识 —— 在 `claim` 时把 `version` 的当次结果记入 `ClaimedTask`,完成/失败/回退时要求 `version` 属于「本次领取之后、下一次领取之前」的区间。若选此路,需明确区分「同 claim 期间新请求推进 version」(应回退 PENDING)与「被重新领取推进 version」(应安静退出)—— 可借助 `attempts` 或 `processing_started_at` 的组合判定。
**R-2(同批处理)**:为 fence 增加**边界测试**:显式构造 `timeout < 1s` 场景,断言旧 Worker 不得覆盖新持有者;或对 `processing-timeout-ms` 增加「必须 ≥ 1000」的配置校验(若选 (a)/(b) 后仍有时间依赖)。
**R-3(建议)**:`iv-fence2.sql` 与 `iv-precision.sql` 可作为该轮复验的前置失败证据复用。
---
## 4. 自动化复验结果
| 命令 | 实际 | 任务书预期 | 结论 |
|---|---|---|---|
| 定向(5 组通配符) | **184 / 184**,F=0 E=0 S=0 | 104 | 数量差异见下 |
| 关联回归(9 组通配符) | **558 / 558**,F=0 E=0 S=0 | 558 | 一致 ✔ |
| `mvn clean verify` | **2163 / 2163**,F=0 E=0 **S=0**,BUILD SUCCESS | 2163 | 一致 ✔ |
| `python docs\verify_openapi.py` | **176 paths / 214 operations**,All checks passed | 176/214 | 一致 ✔ |
**定向数量差异解释(184 vs 104)**:任务书给定的通配符 `*OfferExpiry*Test,*CandidateFeedback*Test,*ResumeVersion*Test,*CandidateScoringRefresh*Test,*CandidateScoringCalculatorTest` 比整改自测时使用的 12 个类名更宽,额外匹配了 `OfferControllerTest`(30)、`OfferServiceTest`(21)、`OfferPersistenceIntegrationTest`(16)、`CandidateFeedbackControllerTest`(8)、`ResumeExportServiceTest`(8)、`ResumeVersionPersistenceIntegrationTest` 等既有类。**差异全部为多覆盖,无遗漏**。
逐类(18 个类):30 / 4 / 6 / 3 / 14 / 6 / 3 / 8 / 7 / 16 / 7 / 7 / 8 / 21 / 6 / 8 / 4 / 26 = 184 ✔
---
## 5. 真实 MySQL / Docker 增量黑盒
实例:本轮 `clean verify` 产出的最新 JAR;物化实例 PID 12592(`SCORING_REFRESH_ENABLED=true`、`OFFER_EXPIRY_INTERVAL_MS=3000`、补偿 5s/limit 2),只读实例 PID 11060(`false`)。二者均已停止,8080/8081 空闲。
### 5.1 A. 五条写侧链路 — **PASS=44 / FAIL=0 / SKIP=0**
每条均断言:业务数据变化 → 同学生同周期唯一任务 → 周期正确 → Worker `COMPLETED` → 无失败分类 → 八维齐备 → 不写 `snapshot_history`。
| 写侧 | 业务变化 | 任务 | Worker | 八维 | history |
|---|---|---|---|---|---|
| A1 Feedback create | `active=1` | 1 条 | `COMPLETED` 1.1s | 8 | 0→0 |
| A2 Feedback update | `rating=2` | 1 条 | `COMPLETED` 0.6s | 8 | 0→0 |
| A3 Feedback delete | `active=0` | 1 条 | `COMPLETED` 0.6s | 8 | 0→0 |
| A4 简历版本创建 | `versions=1` | 1 条 | `COMPLETED` 0.7s | 8 | 0→0 |
| A5 Offer 自动过期 | `offer 17 → EXPIRED` | 1 条 | `COMPLETED` 1.1s | 8 | 0→0 |
**幂等**:完全相同输入重放 → 八维 version 完全一致
`{ABILITY:0, ACTIVITY:1, COMPLIANCE:0, EDUCATION:0, GROWTH:1, INTEGRITY:4, INTERNSHIP:0, STABILITY:1}` 前后不变,且不写 `snapshot_history` ✔
### 5.2 B. 简历事务故障 — **通过**
| 场景 | 结果 |
|---|---|
| B1 内层成功后外层回滚 → 版本与任务均存在 | `versions=1 tasks=1` ✔(精确断言见 §4 的 `ResumeVersionRefreshAtomicityTest`) |
| B2 版本写入失败(非法文件被 `ResumeFileValidator` 拒绝,st=400) | 版本行 `1→1` 未创建;**未登记任务** `tasks=[]` ✔ |
| B3 失败路径不留孤立任务(学生越权调用企业接口,st=403) | 学生 A `tasks=[]` ✔ |
| 文件补偿与旧文件保护语义 | 未改动 `ResumeVersionService` 的文件生命周期代码(仅移除一行登记),既有 `catch(RuntimeException)` 清理与 `TransactionSynchronization` 逻辑保持不变 ✔ |
> 注:B 场景中「版本写入失败」与「任务登记失败」在真实 HTTP 下无法分别构造(`ResumeVersionWriter` 是内部组件),前者用非法文件触发校验失败,后者由 `ResumeVersionRefreshAtomicityTest` 的事务回滚用例覆盖。
### 5.3 C. Claim fence 竞争(20 轮) — **PASS**
脚本 `iv-fence-loop.py`,每轮:A 领取 → 期间新请求推进 version → A 完成 → A 回退 PENDING → B 重新领取 → **延迟的 A 再尝试完成/失败/回退**。
20 轮全部满足:
- A 未覆盖 B(B 的 `status` 与 `processing_started_at` 均未被改动)
- 无新请求丢失(`requested_at` 已推进)
- 无永久 `PROCESSING`
- 无重复完成
- B 均正确完成
- A 的失败登记未污染 `last_error_code` 与 `attempts`
> **重要限定**:该循环通过是因为运行中 A/B 两次领取**恰好跨秒**(每条 SQL 之间耗时累积)。它验证的是 fence **在值不同时**的语义正确性;**不构成** fence 精度足够的证据 —— 精度不足正是 §3 的 FAIL 项。
### 5.4 D. 批次失败隔离 — **通过**
三个任务「正常 → 故障 → 正常」:
- `failingTaskInMiddle_doesNotBlockRemainingTasks` —— 前后两个正常任务均 `COMPLETED`
- 故障任务状态为 `RETRY`(未伪装成功),`last_error_code = COMPUTE_FAILED`
- Worker 未中断整批;`batchTerminalState_matchesRealOutcome` 断言无残留 `PROCESSING`
- 后续重试:`RETRY` + `next_attempt_at` 由 Worker 正常再处理
- 真实 MySQL:`mysqlFailureRollsBackPartialSnapshotsAndDoesNotRollbackSibling` ✔
---
## 6. D4-2 核心回归
| 项 | 结果 |
|---|---|
| 三个目标 GET ×3 + 趋势 GET ×3 | 全部 **HTTP 200**,契约稳定,无敏感字段泄露 ✔ |
| **22 张表逐字段零变化** | `[OK] 全部 22 张表逐字段零变化`;`candidate_dimension_scores` 1480 行、`refresh_tasks` 180 行、`checkpoints` 1 行、`audit_logs` 2667 行、`notifications` 164 行、`messages` 8 行、`snapshot_history` 4 行全部不变 ✔ |
| **无快照学生 GET 后仍 0 行** | 新建学生 8014:`before=0` → 3 次 GET(均 200,`overall=30.0`)→ `after=0` ✔ |
| 趋势回归 | 当前周期 `2026-09`、`overallScore=62.55`、多次调用稳定、零写 ✔ |
| 排名回归 | `rank=1`、`cohortSize=2`、`percentile=100.0`;学生 A/B 各 8 维快照 ✔ |
| 企业看板 | overview / job-rankings / candidate-rankings / trend 均 200;越权 403 ✔ |
| 申诉回归 | `appealId=4290`、`scoreAtAppeal=67.75`、`snapshotVersionAtAppeal=0`(非空);副作用仅限契约表 ✔ |
| 公平性监控 | 5 个端点均 200;`drift`/`ranking-stability` 的 `UNAVAILABLE` 抑制语义保留 ✔ |
| V44 迁移 | `version=44`、`success=1`、`row=1`(唯一)、`failed_migrations=0` ✔ |
| Hibernate validate | 无 `schema-validation` / `SchemaManagementException` 错误 ✔ |
| RabbitMQ | 4 队列(`queue.sms.verification`/`queue.notification`/`queue.notification.dlq`/`queue.audit`)**ready=0、unacked=0** ✔ |
| 日志/响应/审计 | 应用日志(`r01d42-*.log`)手机号、Token、验证码、JWT、SQL 参数、简历路径命中 **均为 0**;222 处「口令」命中全部为 Spring Boot `Using generated security password: <UUID>`(框架提示,非项目凭据);3 处手机号命中为既有测试 `AdminBootstrapConcurrencyLoggingTest` 的扫描器探针;响应扫描无 `phone/email/resumeUrl/storagePath/accessToken` ✔ |
副作用证明方式:全部使用**显式列 + 稳定 ORDER BY 的逐字段行快照比对**(`snap.py`),未使用 `COUNT`/`SUM`/`CRC32` 替代;并附有效性反证(`snap.selftest()` 对单字段内存改动可检出,PASS)。
---
## 7. 收尾
| 项 | 结果 |
|---|---|
| 本轮 Java 实例 | 仅停止本轮记录的两个 PID(12592、11060);`tasklist` java.exe 计数 = **0** ✔ |
| 8080 / 8081 | 空闲 ✔ |
| Docker | 保持运行,三容器 healthy ✔ |
| 数据清理 | `users` 残留 **0**;本轮用户 8010/8011/8012/8013/8014、公司 31/32、岗位 49、投递 90/91、offer 17、简历版本、申诉 4290 及全部关联行均按精确主键删除 ✔ |
| append-only 保留 | `audit_logs` 中 actor 为本轮 5 个隔离用户的 **15 + 2 = 17 行**保留并登记(关联 ID:`user_id ∈ {8010,8011,8012,8013,8014}`)✔ |
| 测试夹具(精确 INSERT,已登记并已清理) | `user_roles` 为 8012/8013 各插入 1 行 `COMPANY`;`offers` 插入 1 条已过期 SENT 记录(id=17)用于触发 F1 定时路径 ✔ |
| Git | 分支未变、HEAD 未变、暂存区空、V1–V43 未改、V44 唯一新增、无删除文件、`git diff --check` 无空白错误 ✔ |
| 未暂存 / 未提交 / 未推送 | 是 ✔ |
本轮新增产物(均在 `docs/acceptance/r01-d4-2-20260916/`,未进入暂存区):`iv-batch1.log`、`iv-batch2.log`、`iv-clean-verify.log`、`iv-precision.sql`、`iv-fence.sql`、`iv-fence2.sql`、`iv-fence-loop.py`、`v3-writeside.py`、`v3-out.txt`、`s1-iv-out.txt`、`s4-iv-out.txt`。
---
## 8. PASS / FAIL 清单
**FAIL(1 项)**
| # | 项 | 依据 |
|---|---|---|
| 1 | **claim fence 精度不足**:`processing_started_at` 为秒级 `DATETIME`,实测同一秒内新旧领取得到相同 fence | 任务书硬性判据「可能得到相同 fence 直接判 FAIL」;最小复现 `iv-fence2.sql` |
**PASS(其余全部)**
OfferExpiryWorker 同事务登记与幂等;Feedback create/update/delete 登记与删除前取标识;ResumeVersionWriter 同一 `REQUIRES_NEW` 边界且外层不重复登记;ABILITY 脱敏 26 项禁止 key 递归断言;批次隔离与 stale claim 安静退出;定向 184/184;关联 558/558;全量 2163/2163(S=0);OpenAPI 176/214;A 五条写侧;B 简历事务故障;C 20 轮 fence 竞争语义;D 批次隔离;22 表逐字段零写;无快照学生 0 行;趋势/排名/看板/申诉/公平性;V44;validate;RabbitMQ;日志脱敏。
**SKIP**:无(本轮无因环境或安全原因跳过项)。
---
## 9. 剩余风险
1. **fence 失效的后果有限但不为零**:即使触发,最坏结果是同一次刷新多算一遍(任务回到 PENDING),不会数据损坏或永久 PROCESSING。但对 SLA 敏感场景仍属正确性瑕疵。
2. **`processing-timeout-ms` 无下界校验**:当前仅做 `<= 0 → 600000` 兜底,1–999 的取值未被拒绝。
3. 任务书未要求、本轮也未执行的项:真实双 JAR 的多实例补偿扫描并发、申诉审核/回滚真实黑盒(延续 D4-2 首轮验收的 SKIP 记录,不属于本轮复验范围)。
---
> **已复现具体缺陷,R01-D4-2 不可冻结;报告已包含下一轮定向整改指令。**

57
docs/r01-d4-2-get-scoring-readonly-implementation-report.md

@ -0,0 +1,57 @@
# R01-D4-2 GET 评分快照停写实施报告
日期:2026-09-16
状态:实现与自动化复验通过,待独立真实环境黑盒验收。
## 1. 变更结论
`CandidateScoringEngineService#getOverview`、`getDimensionDetail`、`getCompanyScoring` 已改为
`@Transactional(readOnly = true)`,并移除 `CandidateScoringCalculator#upsertSnapshots` 调用。
三个接口仍以同一次 `capturedNow` 实时计算并返回原响应结构,趋势接口继续读取已物化快照。
快照更新改由 D4-1 的写侧登记、后台 Worker 和补偿扫描负责。生产配置默认启用刷新与补偿;
test、integration-test、mysql-it profile 显式关闭调度,仅由测试主动驱动,避免后台线程干扰断言。
## 2. D4-1 网关补齐
V44 同时创建 `candidate_scoring_refresh_scan_checkpoints`。补偿扫描使用持久化游标、固定单轮预算和
`SELECT ... FOR UPDATE` 串行推进;游标与任务登记同事务提交。扫描到尾部后从 0 开始下一轮,避免首
页饥饿,也不会单次遍历全量用户。
真实 MySQL 8.4 Testcontainers 已验证:V1–V44 迁移和 schema validate、游标持久化、每轮预算、
Writer 幂等与强制版本、并发唯一领取、处理中重登记、租约恢复、失败事务隔离。
## 3. 测试迁移
- `R01D4GetWritesSnapshotEvidenceTest` 改为逐字段快照,证明 overview、dimension、trend GET 零写入。
- `CandidateScoringEngineServiceTest` 与时钟一致性测试显式断言三个 GET 从不调用 upsert。
- `CandidateScoringPersistenceIntegrationTest` 显式调用 Calculator/Writer 验证物化幂等,不再借 GET 造数据。
- `V38AbilityRankingIntegrationTest` 显式走真实物化入口,原排名公式和结果断言保持不变。
首次干净全量构建据此发现 V38 排名测试仍依赖 GET 副作用(1 Error);修正夹具后专项测试通过,
再次执行干净全量构建成功。
## 4. 实测结果
| 项目 | 结果 |
| --- | --- |
| D4-2 + D4-1 H2/MySQL 组合定向 | 38 / 38,0 failure,0 error,0 skip |
| 时钟与持久化定向 | 13 / 13,0 failure,0 error,0 skip |
| V38 排名回归 | 1 / 1,原断言不变 |
| `mvn clean verify` | 2131 / 2131,Failures=0,Errors=0,Skipped=0,BUILD SUCCESS |
| MySQL Testcontainers | Q01 与 V44 用例均真实执行,无跳过 |
| OpenAPI | 176 paths / 214 operations,校验通过 |
| `git diff --check` | 无空白错误,仅既有 LF/CRLF 提示 |
## 5. 边界
V44 是唯一新增迁移,V1–V43 未修改;无新增公网接口、ErrorCode、RabbitMQ 消息或通知;未暂存、
未提交、未推送;历史未跟踪文件未删除或移动。
## 6. 待独立验收
仍需使用最新 JAR 与项目真实 MySQL/Docker 环境完成:三条真实 HTTP GET 前后快照逐字段零变化;
写侧事件到任务登记再到快照可见;趋势、申诉、排名、看板、公平性监控不退化;RabbitMQ 队列终态
为零;并精确清理或记录隔离验收数据。自动化与 Testcontainers 结果不能替代该黑盒步骤。
> R01-D4-2 实现及自动化复验完成,待独立真实 MySQL/Docker 黑盒验收。

656
docs/r01-d4-2-independent-mysql-acceptance-20260916.md

@ -0,0 +1,656 @@
# R01-D4-2「GET 评分接口停写与快照可靠物化」独立代码复验与真实 MySQL/Docker/JAR/HTTP 黑盒验收
日期:2026-09-16
分支:`refactor/r01-structure-performance`
HEAD:`b10c4a7 docs: design scoring snapshot materialization`
验收性质:**最终独立验收**(非开发)。审查正式生产路径 + 项目既有 Docker Compose + 真实 MySQL 8.4 + 最新构建 JAR + 真实 HTTP。
验收前缀:`R01D42_ACC_20260916_K4GDNT`
---
## 0. 最终结论
> **已复现具体缺陷,R01-D4-2 不可冻结;报告已包含下一轮定向整改指令。**
判定为 **FAIL**。触发任务书第二十节 FAIL 判据中的第 4 条「**写侧成功但任务未登记**」,并另有一处测试断言弱化(触碰「禁止修改或放宽既有测试」红线)。
同时必须明确记录:**本次验收的核心目标项全部实测通过** —— 三个评分 GET 在真实 HTTP 下确凿零写、V44 在真实 MySQL 成功、`clean verify` 2131/2131 全绿、补偿扫描游标与预算正确、趋势/排名/看板/申诉/公平性监控均未退化、RabbitMQ 终态正常。FAIL 由**写侧事件覆盖不全**这一独立缺陷触发,与 GET 停写本身无关。
---
## 1. Git 基线与工作区状态
| 检查项 | 结果 |
|---|---|
| 分支 | `refactor/r01-structure-performance` ✔ |
| HEAD | `b10c4a7`(验收全程未变) |
| 暂存区 | 空(`git diff --cached --name-only` 无输出)✔ |
| `git add -A` / `commit` / `push` | 均未执行 ✔ |
| V1–V43 修改 | 无(`git diff --name-only -- db/migration` 为空)✔ |
| V44 | `V44__candidate_scoring_refresh_tasks.sql` 为唯一新增迁移 ✔ |
| 删除文件 | 无 ✔ |
| 历史未跟踪脚本/日志/JSON/SQL/xlsx/.env | 全部保留,未删未移 ✔ |
| `git diff --check` | 无空白错误(仅既有 LF/CRLF 提示)✔ |
| `AGENTS.md` | 全仓搜索**未发现** |
本轮新增(未跟踪):`docs/acceptance/r01-d4-2-20260916/`(验收脚本与脱敏证据)、`docs/r01-d4-2-independent-mysql-acceptance-20260916.md`(本报告)。未修改任何 D4-1/D4-2 生产代码、未修改 V1–V44、未新增 Flyway/表/索引/ErrorCode/公网接口。
---
## 2. 独立代码审查 A1–A6
方法:9 个并行静态审查维度 + 对每条 critical/major 发现的对抗性复核(20 个独立子代理,全部复核结论为「发现成立」),另由本人逐条打开源码亲自确认关键项。
### A1 三个 GET 是否真正只读 —— **通过**
- `CandidateScoringEngineService.java:52 / :61 / :106` 三个方法均为 `@Transactional(readOnly = true)`。
- 三个方法体内仅含 `LocalDateTime.now(clock)` + `calculator.computeAll(...)` + 只读仓储/`JobService.getJob` 查询 + DTO 组装,无任何写语句。
- `git diff HEAD -- .../CandidateScoringEngineService.java` 全部改动仅为 3 处注解变更 + 删除 3 行 `calculator.upsertSnapshots(...)`。
- `CandidateScoringCalculator.computeAll`(`:122-146`)只含 `findById` + 6 个纯 SELECT(`queryApps/queryAudit/queryOffers/queryFeedback/queryResume/queryMessages`),无写路径;全 main 源码中 `upsertSnapshots` 已无生产调用方。
- `explanation/` 包 grep `jdbc|Repository|EntityManager|@Transactional|save(` 无命中。
- 响应仍来自实时 `computeAll`;`status`(`CandidateOverviewStatusPolicy`)、`weight`(`DEFAULT_WEIGHT = 12.50`)、`rulesVersion`(`currentRulesVersion()`)与 D4-1 前逐字节一致。
- 企业授权校验(company→job 归属)保留,越权负例实测 403。
遗留(非缺陷,记录):`CandidateScoringCalculator.java:582` javadoc 仍写「普通物化入口(含 GET 兜底)」,与 GET 兜底已删除的现状不符(注释陈旧)。
### A2 写侧登记覆盖 —— **不通过(存在两处真实遗漏)**
任务书列出的 8 个服务方法**全部正确接入**,且均为同事务 `REQUIRED` 登记:
| 服务.方法 | 登记位置 |
|---|---|
| `ProfileService#updateProfile` | `ProfileService.java:75` |
| `ResumeVersionService#createVersion / updateVersion / deleteVersion` | `ResumeVersionService.java:71 / :163 / :189` |
| `PreferenceService#savePreferences` | `PreferenceService.java:76` |
| `AbilityAttemptService#submit` | `AbilityAttemptService.java:294` |
| `ApplicationService#apply / withdraw / updateStatus` | `ApplicationService.java:90 / :129 / :224` |
| `OfferService#transitionStatus` | `OfferService.java:304` |
| `CandidateFeedbackService#createFeedback` | `CandidateFeedbackService.java:82` |
| `MessagingService#sendMessage`(仅 STUDENT) | `MessagingService.java:197-200` |
角色判定核实:`MessagingService.java:197-200` 用 `u.getRoles().stream().anyMatch(r -> r == User.Role.STUDENT)`,只有学生发送者登记,企业发送者不登记 ✔
**遗漏一:Offer 自动过期不登记(major,命中 FAIL 判据)**
- `OfferExpiryWorker.java:41 expireOne(...)` 在 `:48` 直接 `offer.setStatus(OfferStatus.EXPIRED.name())` 后 `saveAndFlush`,**全文 `requestRefresh`/`scoringRefresh` 出现次数 = 0**。
- 调用方为定时任务 `OfferExpiryService.java:34 worker.expireOne(...)`,不经过 `OfferService#transitionStatus`。
- 同一行 `:59` 还调用 `counterOfferRepository.expirePendingByOfferId(...)`,`:68 notifyExpired(saved)`。
- 该写入**直接影响评分输入**:`CandidateScoringCalculator.java:188-194 queryOffers` 的 SQL 为
`SELECT SUM(CASE WHEN status='ACCEPTED' THEN 1 ELSE 0 END), SUM(CASE WHEN status='EXPIRED' THEN 1 ELSE 0 END) FROM offers WHERE candidate_id = ?`,`EXPIRED` 计数参与 INTEGRITY 维度。
- `docs/r01-d4-get-scoring-snapshot-migration-design.md:105` 明确要求「Offer 状态变化 → Offer 状态机成功提交后 → 标记待刷新」,而 D4-1 报告「明确未接入」清单(§12,`:43`)**并未**把 Offer 自动过期列为有意不接入项 —— 属实现遗漏。
**遗漏二:候选人反馈的修改/删除不登记(major,命中 FAIL 判据)**
- `CandidateFeedbackService.java:107 updateFeedback(...)` 与 `:124 deleteFeedback(...)` 均未调用 `requestRefresh`;仅 `:82 createFeedback` 登记。
- 评分读取该表:`CandidateScoringCalculator.java:196-200 queryFeedback` 的 SQL 为
`SELECT COUNT(*), COALESCE(AVG(rating),0) FROM candidate_feedback WHERE student_user_id = ?`。
- 修改 `rating` 会改变 `AVG`,删除反馈会同时改变 `COUNT` 与 `AVG`,两者都不触发刷新。
- 设计文档 `:106` 明确要求「候选人反馈创建/**修改**的真实写入口」触发刷新 —— 属实现遗漏。
**遗漏三:`ResumeVersionService.createVersion` 的事务边界不对称(major)**
- `ResumeVersionService.java:71` 的登记位于 `createVersion` 的 `@Transactional` 内(REQUIRED,加入外层事务);
- 但版本行由 `versionWriter.saveNewVersion(...)`(`@Transactional(REQUIRES_NEW)`,文件内 javadoc `:52-55` 自述「写入成功即独立提交,外层事务回滚不会让版本行消失」)**独立提交**;
- 因此存在窗口:版本行已提交、外层事务在提交阶段失败 → **业务数据已落库但刷新任务登记丢失**。
其余 A2 结论:登记不触发线程内重算、不发 RabbitMQ、不写通知、不写审计、不记录敏感值 —— 全部成立。
### A3 唯一快照 Writer —— **通过**
- `CandidateScoringSnapshotWriter.java:105-112`:`score / status / sub_scores_json / weight / rules_version` 全部相同则 early return,**不改 version 与 updated_at** ✔
- `calculated_at` 不参与变化判定 ✔
- `rules_version` 参与判定,变化必推进 version ✔
- `canonicalJson`(`:183-214`)对 ObjectNode 递归按字典序重排、对 ArrayNode 逐元素递归 ✔
- 并发安全:`:91-98` 先 `INSERT ... ON DUPLICATE KEY UPDATE id=id`,`:126-137` 再 `SELECT ... FOR UPDATE`,`:113-119` 条件更新 `WHERE id=? AND version=?`,冲突抛 `OptimisticLockingFailureException` —— 无 JVM 锁 ✔
- Governance 强制路径 `:66-69 upsertForcingVersion` → `:75-88` 无条件 `version=version+1`;调用方 `CandidateScoringGovernanceService.java:294` 保持 `afterVersion = beforeVersion + 1` 语义 ✔
- 普通物化不写 `candidate_scoring_snapshot_history` ✔(黑盒已证,见 §10)
- 单维写失败整体回滚:`upsertIfChanged`(`:51-60`)为单一 `@Transactional` 循环,`CandidateScoringRefreshTaskExecutor.materialize` 同为单个 `REQUIRES_NEW` —— 无部分提交 ✔
### A4 Worker 与失败恢复 —— **不通过(两处独立缺陷)**
- `claim`(`:43-62`)、`materialize`(`:64-89`)、`recordFailure`(`:91-108`)、`recoverStale`(`:29-41`)分属 4 个独立 `REQUIRES_NEW` 事务 ✔
- 并发领取唯一:`claim` 的 `WHERE id=? AND status IN ('PENDING','RETRY') AND (next_attempt_at IS NULL OR next_attempt_at <= ?)` + `updated != 1 → null` ✔(真实 MySQL 集成测试亦覆盖)
- `last_error_code` 只写固定分类(`WRITE_FAILED`/`COMPUTE_FAILED`/`UNKNOWN_REFRESH_FAILURE`/`WORKER_TIMEOUT`),`:82` 的 `log.warn` 只记录 taskId 与 category ✔
- PROCESSING 超时回收、达上限转 FAILED ✔
**缺陷一:`materialize` 的「回到 PENDING」分支缺 version 闸门(major)**
`CandidateScoringRefreshTaskExecutor.java:78-88`:
```
UPDATE ... SET status='COMPLETED' ... WHERE id=? AND status='PROCESSING' AND version=? -- 有闸门
if (completed == 0) {
UPDATE ... SET status='PENDING' ... WHERE id=? AND status='PROCESSING' -- 无闸门
```
当原 Worker 因超时已被 `recoverStale` 置为 RETRY/FAILED 并被另一个 Worker 重新领取(处于 PROCESSING)后,慢的原 Worker 返回时 `version` 不匹配走 PENDING 分支,会**把新 Worker 正在处理的任务改回 PENDING**;随后新 Worker 完成时 `completed=0`、`pending=0` → 抛 `IllegalStateException`。
**缺陷二:失败记录异常会打断同批任务(major)**
`CandidateScoringRefreshWorker.java:77-86` 的 `catch (Exception e)` 块内调用 `taskExecutor.recordFailure(...)`;该方法在 `updated != 1` 时抛 `IllegalStateException`(`TaskExecutor.java:105-107`)。异常在 catch 块内抛出不会被自身捕获,将传播到 `processDue` 的 `for (Long id : due)` 循环(`Worker.java:63-67`,**循环体内无 try/catch**),导致**同批剩余任务本轮全部不被处理**,违反 `Worker.java:24` javadoc 声称的「异常不回抛,不中断其他任务」。
触发前提为物化耗时超过 `processing-timeout-ms`(默认 600000ms),概率低但路径真实。
### A5 补偿扫描 —— **通过**
- 游标持久化于 `candidate_scoring_refresh_scan_checkpoints`(`:79-85 lockCursor` 用 `SELECT ... FOR UPDATE`),非内存字段 ✔
- 单轮预算:`:48` `budget = max(1, min(limit, 500))`,`:98` `LIMIT ?` 绑定同一 `budget` ✔
- 尾部回绕:`:59` `ids.size() < budget || ids.isEmpty() ? 0 : 末位 id`;`:52-55` 游标已在尾部且空结果时重置 0 重查 ✔
- 游标推进与任务登记同事务:`scanDueStudents` 为 `@Transactional`,`requestService.requestRefresh` 为 `REQUIRED` ✔
- 多实例串行:checkpoint 行 `FOR UPDATE` ✔
- 查询预算:1 次聚合查询(`findEligibleAfter`)+ ≤budget 次 upsert,无逐用户 N+1 ✔
- 不直接写 `candidate_dimension_scores` ✔
### A6 配置 —— **通过(无断档)**
- `application.yml:156` `enabled: ${SCORING_REFRESH_ENABLED:true}`,`:158` `compensation-enabled: ${SCORING_REFRESH_COMPENSATION_ENABLED:true}` —— **生产默认启用** ✔
- `application-test.yml:23-26` / `application-integration-test.yml:37-40` / `application-mysql-it.yml:26-29` 三处均显式 `enabled: false` + `compensation-enabled: false` ✔
- 不依赖测试 profile 才能工作 ✔;Scheduler `@Value` 的 `:false` fallback 仅在该键完全缺失时生效,yml 提供 `true` 时实际生效值为 `true`(黑盒已验证补偿扫描确实运行)✔
遗留(记录):`CandidateScoringRefreshScheduler.java:15` javadoc 仍写「默认关闭」,与现状相反。
---
## 3. Docker / MySQL / Redis / RabbitMQ 版本与状态
| 组件 | 版本 | 地址 | 验收前 | 验收中/后 |
|---|---|---|---|---|
| Docker Engine | 29.6.2 | — | 已启动 | 未停止 ✔ |
| MySQL | **8.4.11** | 127.0.0.1:3307 | Exited(137) | healthy ✔ |
| Redis | 7.4-alpine | 127.0.0.1:6379 | Exited(0) | healthy ✔ |
| RabbitMQ | 3.13-management-alpine | 127.0.0.1:5672 / 15672 | Exited(137) | healthy ✔ |
启动方式:`docker compose --env-file backend-infra/.env -f backend-infra/compose.yaml up -d`(仅使用项目既有 Compose)。未执行 `down -v`、未删除/重建 volume、未修改 `compose.yaml`、未新建业务数据库、未打印凭据。
volume 清单(`backend-infra_mysql_data` / `_redis_data` / `_rabbitmq_data`)与验收前一致,未删除。
---
## 4. V44 Flyway 与 schema validation(真实 MySQL)
启动最新 JAR 前:`flyway_schema_history` 中 `max(version)=43`、`COUNT(*)=43`、V44 行数 0、V44 表不存在 —— 与「尚未执行 V44」的基线预期一致。
启动后:
| 项 | 值 |
|---|---|
| `version` | **44** ✔ |
| `description` | `candidate scoring refresh tasks` |
| `type` | `SQL` ✔ |
| `success` | **1** ✔ |
| V44 行数 | **1**(唯一)✔ |
| `success=0` 的迁移数 | **0** ✔ |
| `max_version` / 总迁移数 | 44 / 44 ✔ |
启动日志:`Started MyDreamjobApplication in 15.425 seconds`;主 profile 为 `default`;`ddl-auto=validate` 通过,**无 schema validation / SQL 语法 / Bean 装配错误**。
表结构(`SHOW COLUMNS`/`SHOW INDEX`):
- `candidate_scoring_refresh_tasks`:13 列 `id, student_user_id, target_period(7), status(20), requested_at, processing_started_at, processed_at, next_attempt_at, attempts, last_error_code(40), version, created_at, updated_at` ✔ 状态/重试/租约/version/时间字段齐全
- 唯一键 `uk_refresh_task_student_period (student_user_id, target_period)` ✔
- 领取索引 `idx_refresh_task_claim (status, next_attempt_at)` ✔
- 约束齐全:`PRIMARY`、`uk_refresh_task_student_period`(UNIQUE)、`fk_refresh_task_student → users(id) ON DELETE CASCADE`、`ck_refresh_task_status CHECK (status IN ('PENDING','PROCESSING','RETRY','COMPLETED','FAILED'))` ✔
- `candidate_scoring_refresh_scan_checkpoints`(`scan_key` PK, `last_user_id`, `version`, `updated_at`)存在 ✔
V1–V43 全程未修改 ✔
---
## 5. 最新 JAR 启动与健康检查
- JAR:`mydreamjob-server/target/mydreamjob-server-0.0.1-SNAPSHOT.jar`(`mvn clean verify` 产出,82,440,778 bytes)
- 启动方式:`docs/acceptance/r01-d4-2-20260916/start-app.ps1`,显式提供 `DB_URL=127.0.0.1:3307`、`REDIS_HOST/PORT`、`RABBITMQ_*`、`SMS_MOCK_ENABLED=true`、`SCORING_REFRESH_*`;未修改 `application.yml`
- 验收前 8080/8081 空闲;验收后已全部释放(无 java 进程残留)
- `GET http://127.0.0.1:8080/api/v1/health` → **200** ✔;`http://127.0.0.1:8081/actuator/health` → **200** ✔
- 启动耗时 14.9–15.4s,Flyway 成功,validate 成功
**实例使用(方案 A)**:三个实例互斥启动、PID 各自记录:
| 用途 | PID | REFRESH | 说明 |
|---|---|---|---|
| 只读验证 #1 | 24624(失败,见下) / 22640(dev profile 失败) / **12656** | false | 三个目标 GET 零写验证 |
| 物化验证 | **23436** | true | 写侧链路 + 补偿扫描 |
| 只读验证 #2 | **28456** | false | 消费者回归 + 零写 |
| 重启验证 | **6576** | true | 跨重启游标延续 |
启动调试记录(自我暴露,如实记录):首次启动因 `application-dev.yml` 硬编码短密钥覆盖环境变量而失败;修正为默认 profile 后又因**脚本以 UTF-8 无 BOM 保存、PowerShell 5.1 按 ANSI 解码中文注释导致换行被吞、`JWT_ACCESS_SECRET` 赋值行被并入注释**(诊断值为 `ACCESS_LEN=0`)而失败;加 BOM 后 `ACCESS_LEN=48` 正常启动。此为验收脚本自身缺陷,与产品无关。
---
## 6. 自动化实际测试数
| 命令 | 结果 |
|---|---|
| 定向批次一(8 个 D4 核心类) | **55 / 55**,F=0 E=0 **S=0**,BUILD SUCCESS(49.3s) |
| 定向批次二(`*CandidateScoring*Test,*Competition*Test,*ScoringDashboard*Test,*FairnessMonitoring*Test`) | **253 / 253**,F=0 E=0 **S=0**,BUILD SUCCESS |
| `mvn clean verify` | **2131 / 2131**,Failures=0、Errors=0、**Skipped=0**,BUILD SUCCESS(4:08,211 个测试类) |
| `python docs\verify_openapi.py` | **paths: 176 / operations: 214**,All checks passed ✔ |
批次一逐类:`CandidateScoringRefreshInfrastructureTest` 13/13、`CandidateScoringRefreshFailureTransactionTest` 3/3、**`CandidateScoringRefreshMySqlIntegrationTest` 6/6(20.13s,真实 MySQL Testcontainers)**、`CandidateScoringEngineServiceTest` 14/14、`R01D4GetWritesSnapshotEvidenceTest` 5/5、`CandidateScoringPersistenceIntegrationTest` 3/3(17.51s)、`V38AbilityRankingIntegrationTest` 1/1、`CandidateScoringClockConsistencyTest` 10/10。
**Q01 与 V44 的 MySQL Testcontainers 用例真实执行**(耗时 20.1s / 17.5s 表明容器真实启动),Docker 全程可用,**无因环境缺失导致的 skip**。
---
## 7. 三个目标 GET 与趋势 GET 的真实 HTTP 结果
前提:被测实例 `SCORING_REFRESH_ENABLED=false`(后台 Worker 与补偿扫描关闭),因此任何表变化都只能来自 HTTP 请求本身。
每个接口连续调用 **3 次**,共 12 次真实 HTTP:
| 接口 | HTTP | 契约 | 敏感字段 |
|---|---|---|---|
| `GET /api/v1/profile/me/candidate-scoring` ×3 | 200,200,200 | 稳定(10 字段) | 无泄露 |
| `GET /api/v1/profile/me/candidate-scoring/dimensions/EDUCATION` ×3 | 200,200,200 | 稳定(17 字段) | 无泄露 |
| `GET /api/v1/company/candidates/86/scoring` ×3 | 200,200,200 | 稳定(6 字段) | 无泄露 |
| `GET /api/v1/profile/me/candidate-scoring/trend?months=6` ×3 | 200,200,200 | 稳定(3 字段) | 无泄露 |
- 响应不含 `phone` / `email` / `resumeUrl` / `storagePath` / `accessToken` / `refreshToken` / `passwordHash`,正则扫描无 11 位手机号。
- **S1 零写验收:PASS=24 / FAIL=0 / SKIP=0**
---
## 8. GET 前后逐字段数据库快照
快照覆盖 **22 张表**(显式列、稳定 `ORDER BY` 全列、逐行比对)。敏感列(手机号/邮箱/Token 摘要/文件路径/IP/UA/幂等键)以 `SHA2(...,256)` 参与比对以保持逐行可区分且不落真实值;非敏感列保留原始值。列不存在时 SQL 直接报错(不 catch)。
**结果:22 张表逐字段零变化** ✔
| 关键表 | 行数 | 结果 |
|---|---|---|
| `candidate_dimension_scores` | 88 | 零变化 ✔ |
| `candidate_scoring_refresh_tasks` | 2 | 零变化 ✔ |
| `candidate_scoring_refresh_scan_checkpoints` | 0 | 零变化 ✔ |
| `audit_logs` | 2620 | 零变化 ✔ |
| `notifications` | 158 | 零变化 ✔ |
| `messages` | 8 | 零变化 ✔ |
| `candidate_scoring_snapshot_history` | 4 | 零变化 ✔ |
**最强证据**:该只读实例下,验收学生 A(userId 8002)的 `candidate_dimension_scores` 在多次 GET 后仍为 **0 行** —— 若 D4-2 前行为(GET 兜底写快照)仍存在,此处必为 8 行。同时任务表存在 1 行 `PENDING` 任务,证明写侧登记已发生但后台物化被正确关闭。
**反证(扫描器有效性)**:`snap.selftest()` 对内存中的一行仅改动单个字段,扫描器正确检出 `[('1', [('b','y','Z')])]` → PASS。
---
## 9. 写侧事件 → 任务 → Worker → 快照的真实链路
前提:物化实例 `SCORING_REFRESH_ENABLED=true`,`worker-interval=1500ms`。
**S2 结果:PASS=14 / FAIL=0 / SKIP=0**
| 项 | 实测 |
|---|---|
| W1 `PUT /profile/me`(资料更新) | st=200;任务恰好 1 条 `(8002, 2026-09)`;**Worker 0.7s 处理为 COMPLETED**;`last_error_code` 为空;**八维快照齐备(8 行)**;`rulesVersion` 全部 `candidate-scoring-v0.1` |
| 同事务登记 | 请求成功后立即出现任务行,无需等待 |
| W2 `PUT /profile/me/preferences` | st=200 → COMPLETED → 八维仍 8 行(**无部分写**) |
| W3 `POST /applications` | st=409(本轮已投递,幂等)→ 仍合并为 1 条任务 |
| 重复同一事件 ×3 | 仍只有 1 条任务,`requested_at` 推进不新增 |
| **完全相同输入重放** | 八维 version 全等(`ABILITY:0, ACTIVITY:0, COMPLIANCE:1, EDUCATION:1, GROWTH:0, INTEGRITY:0, INTERNSHIP:0, STABILITY:0`)→ **version 不推进** ✔ |
| `candidate_scoring_snapshot_history` | 4 → 4 **零变化**(普通 Worker 不写历史)✔ |
| `notifications` | 158 → 158 零变化 ✔ |
| `audit_logs` | 2620 → 2620 零变化 ✔ |
| `messages` | 8 → 8 零变化 ✔ |
**PROCESSING 防丢失**:本项在真实 HTTP 下难以稳定卡住窗口,按任务书允许的方式引用真实 MySQL 并发集成测试 —— `CandidateScoringRefreshInfrastructureTest#requestDuringProcessingIsNotLost` 走真实完成路径 `taskExecutor.claim` → 处理期间新请求 → `taskExecutor.materialize`,断言回到 `PENDING` 而非 `COMPLETED`;`CandidateScoringRefreshMySqlIntegrationTest` 6/6 在真实 MySQL 上覆盖重登记与租约恢复。写侧正常主链路(W1/W2/W3)**未跳过**。
**源业务失败不留残留任务**:`CandidateScoringRefreshFailureTransactionTest` 3/3 覆盖外层业务回滚时登记一并回滚 ✔(该项为 H2/集成层证据;A2 中 `ResumeVersionService.createVersion` 的独立提交边界为静态代码发现,见 §2)。
---
## 10. 重复登记与幂等 version
- 同学生同周期重复登记合并为一行:`:56-71` 的 `ON DUPLICATE KEY UPDATE` 只推进 `requested_at`、`version`,不新增行 ✔(黑盒 W1b 实测 1 行)
- `COMPLETED`/`FAILED` 重新打开为 `PENDING` 且 `attempts` 归零;`RETRY` 回到 `PENDING` 保留 `attempts` ✔(`CandidateScoringRefreshInfrastructureTest#completedTaskCanBeRequestedAgain`)
- `PROCESSING` 期间新事件不丢失:登记保留 `status='PROCESSING'` 并推进任务 `version`;完成时 `version` 不匹配即回退 `PENDING` ✔
- **幂等物化**:普通物化值完全不变时 `version` 与 `updated_at` 不变(黑盒 W1b 确证)✔
---
## 11. 租约、重试与失败分类
- `recoverStale`(`TaskExecutor.java:29-41`):`WHERE status='PROCESSING' AND processing_started_at < ?`,置 `RETRY`(或达上限 `FAILED`)、`attempts+1`、`last_error_code='WORKER_TIMEOUT'`、清空 `processing_started_at` ✔
- `recordFailure`(`:91-108`):`attempts+1 >= maxAttempts → FAILED` 否则 `RETRY` + `next_attempt_at` 退避 ✔
- 错误分类为固定字符串,不含异常原文/SQL/堆栈/手机号 ✔
- 调度兜底:`CandidateScoringRefreshScheduler.java:49-57 / :66-70` 各自 try/catch,单轮失败不影响后续轮次 ✔
- 真实 MySQL 证据:`CandidateScoringRefreshMySqlIntegrationTest` 6/6(含租约恢复、部分写失败全回滚且同批成功任务不受影响)
**但**:§2-A4 的两处缺陷(PENDING 分支缺 version 闸门、`recordFailure` 抛出打断 for 循环)表明「同批兄弟任务互不影响」在租约竞争边界下**不成立**。
---
## 12. 补偿游标、跨重启、单轮预算、多实例
参数:`compensation-limit=2`,`compensation-interval=4000ms`。
| 验证项 | 实测证据 |
|---|---|
| 单轮登记数 ≤ limit | 连续 12 轮 `created_at` 分组计数**全部为 2**,无一超标 ✔ |
| 游标持久推进 | `last_user_id` 113 → 126 → 130 → 134 → 138 → 142 → 146…,`version` 每轮 +1 ✔ |
| **不会永远只扫首批用户** | 游标推进至 7948 → 7954 → 7963 → 7967 → 7973 → **8002**(接近学生最大 id 8005)✔ |
| **扫到尾部后从头继续** | 8002 之后下一轮 `last_user_id` 回到 **15** → 20 … ✔ |
| **跨重启从持久游标继续** | 重启前 `cursor=113 / version=104`;重启后首轮 `cursor=126 / version=105`(version 恰好 +1)—— **未回到 0** ✔ |
| 只读实例不扫描 | `SCORING_REFRESH_ENABLED=false` 下 `cursor=113/104` 12 秒内完全冻结 ✔ |
| 多实例并发不重复推进 | 代码层 `lockCursor` 的 `SELECT ... FOR UPDATE` 串行化 + 游标推进与登记同事务 ✔(**未做双 JAR 黑盒**,见 §23 SKIP) |
| 每轮查询预算有上限 | 1 次聚合查询 + ≤2 次 upsert,无 N+1 ✔ |
| 不直接写快照 | 扫描器文件内无 `candidate_dimension_scores` 写入 ✔ |
任务终态:`candidate_scoring_refresh_tasks` 共 181 行,**181/181 全部 COMPLETED**,无 PENDING / RETRY / FAILED / 永久 PROCESSING ✔
---
## 13. 趋势回归
- `GET /profile/me/candidate-scoring/trend?months=6` → 200;当前周期 `2026-09` 存在;`overallScore=65.2`;`rulesVersion=candidate-scoring-v0.1`;维度数 5(趋势按既有语义只收录 `OK`/`ESTIMATED` 维度,非 8 —— 与 `CandidateScoringEngineService.java:91` 的过滤逻辑一致)
- 多次调用结果**逐字节稳定** ✔
- **请求本身零写**(逐字段比对)✔
- 能读到后台物化的当前周期快照 ✔(该数据即由 §9 的 Worker 物化产生)
---
## 14. 排名回归
- `GET /jobs/47/competition-ranking` → 200;`rank=1`、`cohortSize=2`、`overallScore=65.2`、`percentile=100.0`、`asOfPeriod=2026-09`
- 验收学生(8002)与对照学生(8003)**各有 8 维快照**,排名**未因 GET 停写而缺失候选人** ✔
- 快照经真实物化入口产生,未直接插入伪造 ✔
- 只读 ✔
---
## 15. 企业看板回归
| 接口 | 结果 |
|---|---|
| `GET /company/scoring/dashboard/overview` | 200;`companyId=27, totalJobs=1, publishedJobs=1, totalCandidates=2, avgCandidateScore=61.7` |
| `GET .../job-rankings?page=0&size=20` | 200;含验收岗位 47 |
| `GET .../candidate-rankings?page=0&size=20` | 200;`applicationId=86 → overallScore=65.2, percentile=100.0`;`applicationId=87` 亦在列 |
| `GET .../trend?months=6` | 200;`2026-09, avgCandidateScore=61.7, candidateCount=2` |
- 数据来自物化快照 ✔;多次 GET **零写** ✔
- 越权负例:企业 B 访问企业 A 的投递评分 → **403** ✔
---
## 16. 申诉与强制 version 回归
- 学生创建隔离申诉 → 200;`appealId=4289`;`dimension=EDUCATION`、`period=2026-09`、`status=PENDING`
- **`scoreAtAppeal=76.00`、`snapshotVersionAtAppeal=1` 均非空** ✔ —— 关键:D4-2 停写后申诉取证仍能取到快照,因为写侧登记 + Worker 已先行物化
- `GET /profile/me/candidate-scoring/appeals`(我的列表)与 `/appeals/4289`(详情)均 200 ✔
- `GET /admin/candidate-scoring/appeals` → 200,`total=8`(历史 7 条 + 本轮 1 条)✔
- 申诉副作用核对:相对上一快照仅 `candidate_scoring_appeals +1`、`candidate_scoring_appeal_history +1`、`audit_logs +1`,**其余表零变化**(含 `candidate_dimension_scores`、`notifications`、`messages`、`snapshot_history`、`refresh_tasks`)✔
- **未执行**审核/回滚(避免改动非本轮治理数据),因此「rollback 后强制 version 恰好 +1」本项**未做黑盒**,改由 `CandidateScoringCalculatorTest#recomputeAndSnapshot_*` + `CandidateScoringGovernancePersistenceIntegrationTest`(第二批 253/253 内)覆盖,静态确认 `CandidateScoringSnapshotWriter.java:75-88` 无条件 `version=version+1`。见 §23 SKIP。
- 普通 Worker 物化不写 `snapshot_history` ✔(黑盒 4 → 4)
---
## 17. 公平性监控回归
以 ADMIN(复用既有活跃管理员 userId 91,**未恢复 V1_1 旧演示账号 id=1**)调用:
| 接口 | 结果 |
|---|---|
| `GET /admin/scoring/fairness/overview?period=2026-09` | 200,`basis=CURRENT_STORED_SNAPSHOTS`、`scope=SCORED_SNAPSHOT_POPULATION` |
| `.../dimensions?period=2026-09` | 200 |
| `.../drift` | 200,`availability=UNAVAILABLE, reason=IMMUTABLE_SCORE_HISTORY_NOT_AVAILABLE`(既有抑制语义保留) |
| `.../ranking-stability` | 200,`availability=UNAVAILABLE, reason=RANKING_SNAPSHOT_HISTORY_NOT_AVAILABLE`(既有抑制语义保留) |
| `.../appeals` | 200,`basis=APPEAL_CREATION_QUEUE_CURRENT_STATE` |
- 小样本抑制与互补风险抑制保持(以上 `UNAVAILABLE` 即为抑制输出)✔
- 不泄露候选人身份(响应扫描无 `subjectUserId`/手机号/邮箱)✔
- 当前快照聚合可用 ✔;GET **零写** ✔
---
## 18. 数据库副作用
**允许的变化(全部落入任务书允许范围)**:
1. 本轮唯一前缀夹具(创建后已按主键清理,见 §22)
2. 写侧业务请求按契约产生的行(`student_profiles`、`student_preferences`、`applications`、`jobs`、`companies`、`notifications`)
3. **刷新任务与候选人快照**:`candidate_scoring_refresh_tasks` 共 181 行(其中 4 行为本轮学生,其余为补偿扫描按设计登记的其他活跃学生),`candidate_dimension_scores` 相应物化行
4. `candidate_scoring_refresh_scan_checkpoints` 1 行(游标)
5. 本轮申诉 1 条 + 申诉历史 1 条 + 相应审计 1 条
**明确未发生**:
- 非本轮用户的业务数据(`users`/`companies`/`jobs`/`applications` 等)未被删除或改写 —— 唯一受影响的是其**候选人快照**,且这是启用生产后台补偿扫描后的**设计内正常行为**(任务书明确允许「候选人快照」变化)
- 评分规则、权重、规则版本、排名规则未变
- `candidate_scoring_snapshot_history` 在**普通 Worker 场景下为零变化**(4 → 4)✔ —— 该表的 4 行历史数据全程未被普通物化触碰
- 既有历史快照未被改写(version 语义为「变化才 +1」)
- 非本轮审计未变
---
## 19. RabbitMQ 前后状态
队列(vhost `/`)在**验收前**与**验收后**分别查询:
| 队列 | 验收前 ready/unacked | 验收后 ready/unacked |
|---|---|---|
| `queue.sms.verification` | 0 / 0 | 0 / 0 |
| `queue.notification` | 0 / 0 | 0 / 0 |
| `queue.notification.dlq` | 0 / 0 | 0 / 0 |
| `queue.audit` | 0 / 0 | 0 / 0 |
4 个队列**真实存在**,终态 ready=0、unacked=0,**D4-2 的 GET、任务登记、Worker、补偿扫描全程未新增任何消息** ✔
代码层佐证:`candidatescore/refresh/` 全包对 `rabbit|Rabbit` 的匹配仅出现在 **3 处 javadoc 注释**(「不发送 RabbitMQ」),无任何 `RabbitTemplate`/`convertAndSend`/`NotificationService`/`AuditService` 引用 ✔
---
## 20. 静态与运行时 OpenAPI
| 项 | 静态 `docs/openapi-rc.json` | 运行时 `/api/v1/v3/api-docs` |
|---|---|---|
| paths | **176** | **176** |
| operations | **214** | **214** |
| method+path 集合 | — | **完全一致**(`only-runtime=[]`, `only-static=[]`) |
- `python docs\verify_openapi.py` → `All checks passed!`(含 V36/V37/V39–V43/N02/N07/S02/S03 各项契约校验)
- D4-2 **未新增**公网刷新、任务、补偿、管理接口 ✔
- 四个评分 GET 响应 schema 不变(S1 实测响应字段集合与既有契约一致)✔
---
## 21. 日志脱敏
扫描对象:本轮 6 个 JAR 日志/err 文件 + 2 个 Maven 测试日志,共 8 个文件。
**探针有效性**:临时文件注入 `Authorization: Bearer <JWT>`、11 位手机号、「验证码 482913」、`DB_PASSWORD=…`,扫描器命中 **5 类** → **[PASS] 扫描器有效**;探针文件已删除 ✔
**真实命中与甄别**:
| 类别 | 命中 | 甄别结论 |
|---|---|---|
| 「口令赋值」 | 63 | **全部为同一模式**:Spring Boot `UserDetailsServiceAutoConfiguration` 启动时打印的 `Using generated security password: <UUID>`。经字符集核验为大小写十六进制 + 连字符的 UUID,**非数据库口令、非 JWT 密钥、非项目任何凭据**,且项目采用 JWT 认证不使用该内存用户。判定为**误报**。 |
| 11 位手机号 | 1 | 位于 `clean-verify.log`,上下文为既有测试 `AdminBootstrapConcurrencyLoggingTest` 的 **`S03_SCANNER_PROBE_<值>` 扫描器探针**(测试夹具),非真实用户号码 |
| Bearer Token / JWT 形态串 / 验证码语义行 / 密钥赋值 / 私钥块 / 简历存储路径 | **0** | ✔ |
**应用运行日志(`r01d42-*.log`)中手机号、Token、验证码、JWT、简历路径命中均为 0** ✔
报告与本文件均未复制任何真实凭据值。
---
## 22. 结束处理
**进程**:只停止本轮明确记录 PID 的实例(24624/22640/12656/23436/28456/6576),未触碰任何未知 Java 进程。验收后 `Get-Process java` 无输出,8080/8081 端口已释放,恢复至验收前空闲状态。**Docker 未停止**(按任务书要求)。
**数据清理**(仅本轮精确主键,无模糊 DELETE):
| 表 | 删除前 | 删除后 |
|---|---|---|
| `users`(8002/8003/8004/8005) | 4 | **0** ✔ |
| `companies`(27/28) | 2 | **0** ✔ |
| `jobs`(47) | 1 | **0** ✔ |
| `applications`(86/87)+ `application_status_history` | 2 + 2 | **0** ✔ |
| `candidate_dimension_scores` | 32 | **0** ✔ |
| `candidate_scoring_refresh_tasks` | 4 | **0** ✔ |
| `candidate_scoring_appeals` / `appeal_history` | 1 / 1 | **0** ✔ |
| `student_profiles` / `student_preferences` | 2 / 1 | **0** ✔ |
| `notifications` | 2 | **0** ✔ |
| `auth_tokens` / `user_roles` / `sms_verification_codes` | 6 / 6 / 6 | **0** ✔ |
清理前做了**归属校验**:4 个用户 id 对应的手机号必须全部等于本轮登记的隔离虚拟号(`phone IN (4 个号码)` 与 `id IN (…)` 双向匹配数均为 4,完全一致),否则脚本 `[ABORT]` 拒绝删除。删除按外键顺序执行(两轮:首轮被 `application_status_history` 阻断后补删)。
**保留并登记**:`audit_logs` 中 actor 为本轮 4 个用户的 **15 行** 属 append-only 合规记录,按任务书要求**不冒险删除**,保留在库中。关联 ID:`user_id ∈ {8002, 8003, 8004, 8005}`。
**唯一允许的测试夹具(精确 INSERT,已登记)**:`user_roles` 插入 `(8004,'COMPANY')`、`(8005,'COMPANY')` 各 1 行 —— 原因:项目**无任何公网接口**可授予 COMPANY 角色(`CompanyService.java:76 assertCompanyRole` 前置校验,角色仅由管理员初始化产生),企业创建因此返回 403。该 2 行已在 §22 清理中随 `user_roles WHERE user_id IN (…)` 一并删除。
**Git 收尾**:分支 `refactor/r01-structure-performance`;暂存区空;未 `git add -A`;未 commit;未 push;V1–V43 未修改;V44 仍为唯一新增迁移;无删除文件;`git diff --check` 无空白错误。未进入下一项 R01 工作。
---
## 23. PASS / FAIL / SKIP 清单
### PASS
1. Git 基线(分支/HEAD/暂存区/V1–V43/V44/无删除/无空白错误)
2. A1 三个 GET 真正只读(代码 + 真实 HTTP 双证)
3. A3 唯一 Writer 语义(幂等 version / canonical JSON / 并发锁 / 强制版本 / 不写 history / 整体回滚)
4. A5 补偿扫描(持久游标 / 单轮预算 / 尾部回绕 / 同事务 / 不写快照)
5. A6 配置无断档(生产默认启用,测试 profile 显式关闭)
6. Docker/MySQL 8.4.11/Redis/RabbitMQ 全部 healthy
7. V44 在真实 MySQL 成功(version=44, success=1, 唯一, failed=0)
8. `ddl-auto=validate` 通过,无 schema/SQL/Bean 装配错误
9. 最新 JAR 正常启动;健康检查 8080/8081 均 200
10. `mvn clean verify` **2131/2131**,F=0 E=0 **S=0**
11. 定向批次一 55/55、批次二 253/253(MySQL Testcontainers 真实执行)
12. **三个目标 GET 真实零写**(22 张表逐字段不变;学生快照保持 0 行)
13. 写侧事件 → 任务 → Worker → 八维快照全链路(W1/W2/W3)
14. 重复登记合并 + 完全相同输入重放 version 不推进
15. 普通 Worker 不写 `snapshot_history`、不写通知/审计/RabbitMQ
16. 补偿游标跨重启延续、单轮严格 ≤ limit、尾部回绕
17. 趋势回归(可读物化快照、稳定、零写)
18. 排名回归(cohortSize=2、双学生均有快照)
19. 企业看板回归(4 端点在列、越权 403)
20. 申诉回归(取证字段非空、副作用限于契约表)
21. 公平性监控回归(5 端点、抑制语义保留、零写)
22. RabbitMQ 终态 4 队列 ready=0/unacked=0
23. 静态与运行时 OpenAPI 均 176/214 且集合完全一致
24. `git diff --check`、OpenAPI 静态校验、日志脱敏(探针有效,应用日志 0 泄露)
25. 隔离数据精确清理,无残留
### FAIL(4 项)
| # | 缺陷 | 位置 | 命中判据 |
|---|---|---|---|
| F1 | **Offer 自动过期改变评分输入但不登记刷新任务** | `offer/OfferExpiryWorker.java:48`(登记计数=0);评分读取 `candidatescore/CandidateScoringCalculator.java:188-194` | 「写侧成功但任务未登记」 |
| F2 | **候选人反馈的修改/删除改变评分输入但不登记刷新任务** | `feedback/CandidateFeedbackService.java:107`、`:124`(仅 `:82` 创建时登记);评分读取 `CandidateScoringCalculator.java:196-200` | 「写侧成功但任务未登记」 |
| F3 | **`createVersion` 登记与独立提交的版本行事务边界不对称** | `resume/ResumeVersionService.java:71`(外层事务)vs 同文件 `:52-55` 自述的 `REQUIRES_NEW` 独立提交 | 「源事务失败却留下任务」的对偶形态:数据已提交而登记丢失 |
| F4 | **测试断言弱化(红线:禁止修改或放宽既有测试)** | `test/.../candidatescore/CandidateScoringCalculatorTest.java:248` 起,ABILITY 溯源 payload 的敏感字段黑名单断言(`answerKey/questionCode/competencyTag/stem/selectedOptionId/scoringRule/awardedScore/explanation/phone/email` 共 10 项)被删除,**无等价替代** | 「修改或放宽既有测试」 |
### FAIL(次生,A4,未单独计入但需一并整改)
| # | 缺陷 | 位置 |
|---|---|---|
| F5 | `materialize` 的「回到 PENDING」分支缺 version 闸门,租约竞争下会把新 Worker 处理中的任务改回 PENDING | `refresh/CandidateScoringRefreshTaskExecutor.java:78-88` |
| F6 | `recordFailure` 在 `updated != 1` 时抛异常,且异常点位于 Worker 的 catch 块内、`processDue` 的 for 循环无保护 → **同批剩余任务本轮全部不处理** | `refresh/CandidateScoringRefreshWorker.java:83` + `:63-67` + `CandidateScoringRefreshTaskExecutor.java:105-107` |
### SKIP(2 项,均非核心判据)
1. **多实例并发补偿扫描双 JAR 黑盒**:以代码层 `SELECT ... FOR UPDATE` 串行 + 游标与登记同事务作为证据;未启动第二个 JAR 做真实双实例竞争。
2. **申诉审核/回滚黑盒**:为避免改动非本轮治理数据(`candidate_scoring_rollback_limits` 等)未执行 rollback,改由 `CandidateScoringCalculatorTest#recomputeAndSnapshot_*` 与 `CandidateScoringGovernancePersistenceIntegrationTest` 覆盖(第二批 253/253 内),静态确认强制 version 路径。
### BLOCKED
无。
---
## 24. 剩余风险
1. **F1/F2 的实际影响受补偿扫描部分缓解**:补偿扫描的资格条件为「近 90 天有投递 **或** 近 30 天有审计」(`CandidateScoringRefreshCompensationScanner.java:89-99`)。有 Offer / 有反馈的学生必有投递,故其快照通常在下一轮扫描(默认间隔 1 小时)内被纠正。但若该学生**近 90 天无投递、近 30 天无审计**,则其快照将**永久停留在过期值**,无任何兜底路径。
2. **F3 触发概率低但后果不可逆**:仅在事务提交阶段失败时发生,届时业务数据存在而刷新信号永久丢失,且无对账机制可发现。
3. **F5/F6 触发前提为物化耗时超过 `processing-timeout-ms`(默认 600s)**,正常物化实测 0.7s,概率低;一旦触发,本轮同批任务的其余条目会被静默跳过(下一轮会重试,故不会永久丢失)。
4. **补偿扫描会物化全库活跃学生的快照**:启用生产后台刷新后,单实例每轮最多 `compensation-limit` 个。本次验收实测 181 条任务、全部 COMPLETED,未观察到异常增长;但该行为对生产库的持续写入量需在容量规划中评估。
5. **`CandidateScoringRefreshInfrastructureTest` 未验证 8 个写侧 Service 真的调用了 `requestRefresh`**:现有测试直接驱动 `requestService`/`taskExecutor`,各 Service 单测只补了 `@Mock` 依赖而无一处 `verify(...).requestRefresh(...)`。F1/F2 正是这类覆盖盲区的产物,建议本轮整改时一并补齐。
6. **注释与现实不符(低危)**:`CandidateScoringCalculator.java:582`(「含 GET 兜底」)、`CandidateScoringRefreshScheduler.java:15`、D4-1 报告 §20 的「默认关闭」表述均已过时。
---
## 25. 是否允许冻结 D4-2
**不允许冻结。**
R01-D4-2 的**核心目标**(三个评分 GET 停写)已在真实 MySQL/HTTP 下确凿达成,且未观察到任何消费端退化或回归。但依据任务书第二十节的 FAIL 判据,F1/F2 命中「**写侧成功但任务未登记**」,F4 触碰「禁止修改或放宽既有测试」红线,因此本轮不可冻结。
---
## 26. 下一轮定向整改指令
### 必做(阻断冻结)
**R1. 补齐 Offer 自动过期的评分刷新登记**
- 文件:`mydreamjob-server/src/main/java/com/mydreamjob/offer/OfferExpiryWorker.java`
- 方法:`expireOne(Long offerId)`(`:41`),在 `:48 setStatus(EXPIRED)` 成功且 `:51 saveAndFlush` 之后、与该方法**同一 `REQUIRES_NEW` 事务内**,追加:
`scoringRefreshRequestService.requestRefresh(offer.getCandidateId(), LocalDateTime.now(clock))`
- 注入 `CandidateScoringRefreshRequestService`;`OfferExpiryWorker` 已使用 `clock`,`capturedNow` 应复用同一时间源
- 不得因此改动 `OfferService#transitionStatus` 的既有语义
- 补测试:真实驱动 `expireOne` 后断言 `candidate_scoring_refresh_tasks` 出现/合并一条 `(candidateId, 当期)` 任务
**R2. 补齐候选人反馈修改/删除的评分刷新登记**
- 文件:`mydreamjob-server/src/main/java/com/mydreamjob/feedback/CandidateFeedbackService.java`
- 方法:`updateFeedback(…)`(`:107`)与 `deleteFeedback(…)`(`:124`),在各自成功路径末尾、同一 `@Transactional` 内追加 `requestRefresh(app.getStudentUserId(), LocalDateTime.now())`(`deleteFeedback` 注意在删除前先取得 `studentUserId`)
- 与既有 `:82 createFeedback` 保持完全一致的调用形态
- 补测试:`updateFeedback` 改变 `rating` 后断言任务被登记/合并
**R3. 修正 `createVersion` 的登记事务边界**
- 文件:`mydreamjob-server/src/main/java/com/mydreamjob/resume/ResumeVersionService.java`
- 问题:`:71` 的登记位于外层事务,而版本行经 `versionWriter.saveNewVersion`(`REQUIRES_NEW`)**独立提交**;外层提交阶段失败会造成「数据已提交、登记丢失」
- 要求(二选一,须在报告中说明选择理由):
(a) 将 `requestRefresh` 增补为 `Propagation.REQUIRES_NEW` 的独立调用(放在版本行成功返回之后);或
(b) 在 `createVersion` 返回后由调用方在**已提交**的上下文中登记(`TransactionSynchronization#afterCommit`)
- 补测试:模拟外层事务回滚,断言版本行存在时**必定**存在对应刷新任务
**R4. 修正 `materialize` 的 PENDING 回退闸门**
- 文件:`mydreamjob-server/src/main/java/com/mydreamjob/candidatescore/refresh/CandidateScoringRefreshTaskExecutor.java`
- 位置:`:78-88`
- 要求:`status='PENDING'` 的回退 `UPDATE` 增加 `AND version=?`(与 claim 时记录的 `claimedVersion` 比对);`updated != 1` 时应**静默返回**(该任务已被 `recoverStale` 或新 Worker 接管,属正常竞争),不得抛 `IllegalStateException`
**R5. 隔离单任务失败对同批任务的影响**
- 文件:`mydreamjob-server/src/main/java/com/mydreamjob/candidatescore/refresh/CandidateScoringRefreshWorker.java`
- 位置:`:63-67`(`processDue` 循环)与 `:83`(catch 内 `recordFailure`)
- 要求:`processDue` 的 for 循环体加 `try/catch`,或让 `recordFailure` 自身吞掉状态冲突异常并仅告警 —— 保证单任务异常不阻断同批其余任务,与 `:24` javadoc 的承诺一致
**R6. 恢复被删除的敏感字段断言(红线项)**
- 文件:`mydreamjob-server/src/test/java/com/mydreamjob/candidatescore/CandidateScoringCalculatorTest.java`
- 位置:`:248` 起(原 `for (String banned : List.of("answerKey","questionCode","competencyTag","stem","selectedOptionId","scoringRule","awardedScore","explanation","phone","email"))` 断言)
- 要求:在**新的委托架构下重建等价断言** —— 序列化 `CandidateScoringSnapshotWriter` 为 ABILITY 维度生成的 `sub_scores_json`(可通过 `CandidateScoringRefreshInfrastructureTest` 走真实 Writer,或在 Writer 测试中直接断言),并逐项断言不含上述 10 个禁止字段
- 不得以「类型安全」为由省略:`weights`/`dimensionScores` 为 `Map`,其 key 不受编译期保护
**R7. 补齐写侧登记的行为验证**
- 文件:`mydreamjob-server/src/test/java/com/mydreamjob/candidatescore/refresh/CandidateScoringRefreshInfrastructureTest.java`
- 要求:新增断言,对 §A2 的 8 个服务方法 + R1/R2 新接入的两个入口,逐一 `verify(scoringRefreshRequestService, times(1)).requestRefresh(…)`;覆盖**失败回滚不登记**的负例
### 建议(不阻断,但应同批处理)
**R8.** 修正过期注释:`CandidateScoringCalculator.java:582`、`CandidateScoringRefreshScheduler.java:15`,以及 `docs/r01-d4-1-scoring-materialization-implementation-report.md` §13/§20 中「补偿扫描为简化版/默认关闭」的表述。
**R9.** 补 `CandidateScoringRefreshInfrastructureTest` 中缺失的黑盒项:多实例并发扫描(双实例 `scanDueStudents` 断言游标不重复推进)、跨重启游标延续的自动化用例(当前仅有本次验收的真实证据)。
**R10.** 评估 `getTrend` 的时钟一致性:`CandidateScoringEngineService.java:78` 使用 `LocalDate.now()` 而非注入的 `clock`(预存在问题,非本轮引入)。
### 整改后复验要求
完成 R1–R7 后,重新执行:`mvn clean verify`(须 ≥2131 且 S=0)、静态与运行时 OpenAPI 176/214、以及本报告 §7/§9/§16 三项真实 HTTP 复核(GET 零写、写侧链路、申诉取证)。R1/R2 需各自提供「事件 → 任务 → COMPLETED → 快照」的真实 MySQL 证据。
---
## 附:本轮验收产出物(`docs/acceptance/r01-d4-2-20260916/`)
| 文件 | 说明 |
|---|---|
| `acc_lib.py` | HTTP/DB/登录/前缀公共库 |
| `snap.py` | 22 表显式列快照 + 差异比较 + 自检反证 |
| `p1-provision.py` / `acc-context.json` / `acc-prefix.txt` | 隔离验收数据创建 |
| `s1-zerowrite.py` / `snap-before-get.json` / `snap-after-get.json` | 三个 GET 零写 |
| `s2-writepath.py` | 写侧链路 |
| `s4-consumers.py` / `s4-out.txt` / `snap-after-consumers.json` | 消费者回归 |
| `logscan.py` | 日志脱敏扫描(含探针) |
| `cleanup.py` | 精确主键清理 |
| `start-app.ps1` + `r01d42-*.log/.err.log/.pid/.envdiag.txt` | 实例启动与运行日志 |
| `q01`–`q13*.sql` | 只读 SQL 证据 |
| `runtime-openapi.json` | 运行时 OpenAPI |
| `clean-verify.log` / `batch2.log` | 自动化构建日志 |
> 本报告不含数据库密码、JWT、验证码、完整手机号或邮箱、`.env` 内容、简历原文或真实存储路径;SQL 片段中无真实敏感参数。验收脚本输出中曾一次性打印隔离虚拟号(`p1-provision.py` 首版缺陷),已当场修正脚本,后续输出与报告均不再包含完整号码。

246
docs/r01-d4-get-scoring-snapshot-migration-design.md

@ -0,0 +1,246 @@
# R01-D4「GET 评分接口写快照迁移方案设计与证据核查」
日期:2026-09-16
分支:`refactor/r01-structure-performance`
HEAD:`b56f792 perf: batch conversation unread counts`
本轮性质:**独立现状核查 + 权威迁移设计**(未实施生产改造)
## 1. Git 基线
- 分支 `refactor/r01-structure-performance`;HEAD `b56f792`;暂存区为空。
- 迁移目录无任何改动(V1–V43 原样);`AGENTS.md` 未发现。
- 已跟踪工作区存在 1 个仅被 Git 工作树元数据标记为修改的文件(`GlobalExceptionHandler.java`);普通 `git diff` 为空且 blob 与 HEAD 相同。本轮不触碰、不暂存该文件,也不把它计入 R01-D4 交付。
- 历史未跟踪文件全部保留;未 commit、push。
## 2. 当前三个 GET 写库调用链
| 端点 | 服务方法 | 调用链 | 事务 | 写入 | 修改字段 |
|---|---|---|---|---|---|
| `GET /profile/me/candidate-scoring` | `CandidateScoringEngineService#getOverview` | computeAll → `upsertSnapshots` → buildOverview | `@Transactional`(默认 rw) | `candidate_dimension_scores` | score、status、sub_scores_json、weight、rules_version、calculated_at、**version+1**、**updated_at=NOW()** |
| `GET /profile/me/candidate-scoring/dimensions/{dimension}` | `#getDimensionDetail` | computeAll → filter → `upsertSnapshots`(单维度) | `@Transactional`(rw) | 同上 | 同上(仅该维度行) |
| `GET /company/candidates/{applicationId}/scoring` | `#getCompanyScoring` | 授权(company/job 归属) → computeAll → `upsertSnapshots` | `@Transactional`(rw) | 同上 | 同上(目标学生 8 行) |
| `GET /profile/me/candidate-scoring/trend` | `#getTrend` | `snapshotRepository.findTrendFrom` 只读 | `@Transactional(readOnly = true)` | **无** | — |
写库 SQL(`CandidateScoringCalculator#upsertSnapshots`)为原生 `INSERT ... VALUES (...) ON DUPLICATE KEY UPDATE score=VALUES(score), status=..., sub_scores_json=..., weight=..., rules_version=..., calculated_at=..., version=version+1, updated_at=NOW()`。唯一键 `uk_candidate_dim_scores(student_user_id, dimension, period)`(V24 建表、实体 `@UniqueConstraint` 双重声明)。**触发条件为"存在唯一键冲突"即无条件版本自增——即使计算值与上次相同,重复 GET 仍使 version 单调递增、updated_at 改写。**
## 3. 全部快照写入方
| 写入方 | 入口 | 触发 | 是否用户可重复触发 |
|---|---|---|---|
| `CandidateScoringCalculator#upsertSnapshots`(被下三处调用) | — | — | — |
| —— `CandidateScoringEngineService#getOverview` | `GET /profile/me/candidate-scoring` | **GET 读取请求** | 是(任何学生重复刷新) |
| —— `CandidateScoringEngineService#getDimensionDetail` | `GET .../dimensions/{dimension}` | **GET 读取请求** | 是 |
| —— `CandidateScoringEngineService#getCompanyScoring` | `GET /company/candidates/{id}/scoring` | **GET 读取请求** | 是(企业每次查看) |
| `CandidateScoringCalculator#recomputeAndSnapshot` | 见下 | — | — |
| —— `CandidateScoringGovernanceService#decideAppeal`(APPROVE 分支) | `POST /admin/candidate-scoring/appeals/{id}/decision` | 申诉决定 | 否(终态幂等) |
| —— `CandidateScoringGovernanceService#rollback` | `POST .../appeals/{id}/rollback` | 申诉回滚 | 否(回滚限额+终态) |
| ——(趋势/排名/看板不写) | — | — | — |
## 4. 全部快照消费者
| 消费者 | 读取方式 | 依赖字段 | 影响 |
|---|---|---|---|
| `getTrend`(趋势) | `findTrendFrom(student, startPeriod)` | period/score/status/rulesVersion | 只读物化快照;**完全依赖快照** |
| `CandidateScoringGovernanceService#createAppeal` | `findSnapshot` | score(scoreAtAppeal)、**version(snapshotVersionAtAppeal)** | 申诉证据基于快照版本 |
| `decideAppeal/rollback` | `recomputeAndSnapshot` + `snapshotHistoryRepository.save` | before/after 分数与版本 | 申诉决定/回滚重算写新版本 |
| `CandidateScoringSnapshotHistory`(V25 表) | 仅由申诉决定/回滚写入,`getSnapshotHistory` 读取 | version/rulesVersion | 变更审计 |
| `CompetitionRankingCalculator.loadLatestSnapshots` | `findUpToPeriod(students, asOf)` | 最新 period 的 8 维分数 | 竞岗排名用**物化快照**(非实时计算) |
| `CompanyScoringDashboardService` | 读取快照聚合 | score/status | 企业看板/百分位(r01 优化为 CandidatePercentileCalculator) |
| `CareerProgressService` | 读取快照 | 各期分数 | 求职进度 |
| `FairnessMonitoringRepository` | 原生 SQL 按 period 聚合 candidate_dimension_scores + snapshot_history+appeal | score/status/样本数 | 公平性监控(当前快照 + 历史申诉双源) |
| `PersonalDataCollector` | `findByStudentUserId` | 全字段 | GDPR 数据导出 |
## 5. 真实/集成副作用证据
### 5.1 集成证据(H2,本轮新增独立现状证据测试,见 §17)
`R01D4GetWritesSnapshotEvidenceTest` 结果 **5/5 全绿**(未依赖 Docker):
- 首调 `getOverview`:`candidate_dimension_scores` 新增 **8 行**;重复调用行数保持 8、**version 全部 +1**;
- 单维度 GET:行数不变、version 继续递增;
- trend GET:前后行数/version 完全一致(只读确认);
- 非法 dimension:校验先于 upsert,**零写入**;
- GET 评分不写 `audit_logs`、`notifications`。
证据边界:该类直接调用 Service,并非真实 HTTP/SecurityFilterChain 测试;它证明 overview、dimension、trend 的持久化行为,但**没有覆盖企业评分 GET、无权限、资源不存在、HTTP 响应或 RabbitMQ**。这些项目不得依据该 5/5 结果标为已验证,必须在实施验收中补齐。
### 5.2 真实 MySQL 证据
**BLOCKED**:Docker Engine 本轮不可用(`docker version` 无法连接 Engine),按任务书纪律**不伪造通过**。已在 5.1 提供同级 H2 集成证据;真实 MySQL 行级快照(首/二次 GET 的行数、version、updated_at、返回 JSON 是否变化、错误路径零写、audit/notification/RabbitMQ 无副作用)由 D4-2 阶段实施前用真实 MySQL 补充复验(§12 验收清单已含)。
静态代码可直接确认的行为(无需运行):`upsertSnapshots` 的 `ON DUPLICATE KEY UPDATE ... version=version+1, updated_at=NOW()` 保证重复 GET 必使 version 单调递增、updated_at 刷新;趋势/申诉/监控/看板/排名全部消费该物化快照。
## 6. 首次与重复 GET 的行数/version/updated_at
| 场景 | 行数 | version | updated_at | 返回 JSON |
|---|---|---|---|---|
| 首次 GET overview | 0 → 8 | 0(插入初值) | 写入 NOW() | 完整评分卡 |
| 第二次相同 GET | 8(不变) | **+1(8 行全部)** | **改写为新 NOW()** | 与首次等值(公式确定) |
| 单维度 GET(已有 8 行) | 8(不变) | 该维度行 +1 | 该行改写 | 明细不变 |
| trend GET | 不变 | 不变 | 不变 | 由物化快照导出 |
| 非法 dimension | 不变 | 不变 | 不变 | Service 抛业务异常(H2 已验证) |
| 无权限 / 资源不存在 | 静态代码预期不变 | 静态代码预期不变 | 静态代码预期不变 | **本轮未做真实 HTTP 证据** |
结论:**"读取放大写入"确凿**——GET 对每次读取强制物化并推进版本,自增 version 不代表业务状态迁移,period 快照语义是"每次读取覆盖值"而非"首次值/最新值"。
## 7. trend 的现有依赖
`getTrend` 只读 `candidate_dimension_scores`(`findTrendFrom`,period>=start 升序)。它**不**调用 computeAll/upsert,因此**完全依赖**由其他路径(当前主要是三个 GET + 申诉重算)持续写入的快照。若直接删除 GET upsert 而未建立写侧物化,趋势将冻结在最后一次写入时刻。
## 8. 申诉、回滚与公平性监控依赖
- 申诉创建:`findSnapshot(student, dimension, period)` 读取当前行,固化 `scoreAtAppeal` 与 `snapshotVersionAtAppeal`(写入 `candidate_scoring_appeals`)。若快照过期,申诉取证基于旧版本。
- 申诉决定(APPROVE):`recomputeAndSnapshot` 重算写新版本 + 写入 `candidate_scoring_snapshot_history`(before/after score、version、rules_version)。
- 回滚:同决定路径,另扣 `candidate_scoring_rollback_limits`。
- 公平性监控:`FairnessMonitoringRepository` 两条原生聚合:`candidate_dimension_scores WHERE period=?` 按学生分组(overview/dimensions 用),以及 `candidate_scoring_snapshot_history JOIN appeal`(appeals 用)——**当前快照 + 历史申诉双源**。
- 申诉期间快照若被外部 GET 再次覆盖(version 再 +1),`snapshotVersionAtAppeal` 不再等于当前版本——审计可辨,但"申诉取证与当前快照差 version"是现状语义的一部分,迁移时不得破坏。
## 9. 写侧事件矩阵(按计算器真实数据源)
| 评分输入变化 | 计算器读取证据 | 推荐刷新入口 | 处理方式 | 备注 |
|---|---|---|---|---|---|
| 学生资料更新 | `StudentProfileRepository`:学历、学校、技能及资料完整度 | `ProfileService#updateProfile` 成功提交后 | 标记学生快照为待刷新 | 仅实际影响字段变化时触发 |
| 多版本简历变化 | `resume_versions` ACTIVE 数量与 MAX(updated_at) | `ResumeVersionService#createVersion/updateVersion/deleteVersion` 成功提交后 | 标记待刷新 | `setDefault` 是否影响评分需按 SQL 判断;当前 SQL 不读取默认标记,不应无条件触发 |
| 求职偏好更新 | `StudentPreferenceRepository` | `PreferenceService#savePreferences` 成功提交后 | 标记待刷新 | 影响 COMPLIANCE |
| 能力测评结果完成 | `AbilityAssessmentScoreSource#findLatestValidCompletedResult` | `AbilityAttemptService#submit` 成功提交后 | 标记待刷新 | `saveAnswers` 尚未形成完成结果,不应刷新 |
| 投递创建/撤回 | applications 数量、WITHDRAWN 数、近 90 天数量 | `ApplicationService#apply/withdraw` 成功提交后 | 标记待刷新 | 企业侧状态更新只有在实际改变计算器读取字段时才触发;当前聚合只区分 WITHDRAWN |
| 登录及审计活动 | audit_logs 近 30 天数量与 LOGIN 数 | 统一审计写入成功后或补偿扫描 | 优先脏标记/补偿 | 入口很多,不能逐 Controller 机械接入 |
| Offer 状态变化 | offers 中 ACCEPTED/EXPIRED 数量 | Offer 状态机成功提交后 | 标记待刷新 | 原报告遗漏 |
| 候选人反馈变化 | candidate_feedback 数量与 AVG(rating) | 候选人反馈创建/修改的真实写入口 | 标记待刷新 | 不是 `JobReviewService`;原报告混淆了两类评价 |
| 学生发送消息 | messages.sender_user_id 数量 | 消息发送成功提交后 | 标记待刷新 | 撤回不改变总消息数,按现公式无需刷新 |
| 账户创建时间 | users.created_at | 无日常写入口 | 初次物化/补偿扫描 | 时间本身不变 |
| 申诉决定/回滚 | 已有 `GovernanceService#decideAppeal/rollback` | 保持现有合法写侧 | 同事务 | 不与普通刷新事件混用历史审计语义 |
| rulesVersion 或时间窗口跨界 | rulesVersion、90/30 日窗口、能力结果有效期 | 发布批次及定时补偿扫描 | 批处理 | 即使没有业务写事件,分数也会随时间变化,因此仅靠事件触发不完备 |
明确排除:当前 `computeAll` **不读取 RIASEC 结果、面试评分或岗位评价**,不得为这三类入口增加刷新;传统 `ProfileService#uploadResume` 的文件字段也不是 `queryResume` 的数据源,除非同时改变资料完整度实际依赖字段,否则不应触发。
现状结论:除申诉决定/回滚外,真实输入源缺少统一物化机制;同时存在时间窗口自然变化,因此“把若干 Service 同步调用 refresh”仍不能证明覆盖 100%。
## 10. 三个迁移选项比较
| 维度 | 方案 A:同步写侧触发 | 方案 B:事务提交后异步刷新 | 方案 C:脏标记+定时物化 |
|---|---|---|---|
| 一致性 | 强(同一事务) | 最终一致(短窗口) | 最终一致(更长窗口) |
| 事务耦合 | 高(评分写并进业务事务) | 中(分离;核心事务不受失败连累) | 低 |
| 失败恢复 | 回滚即重来,无独立补偿 | 需重试/死信 + 补偿扫描 | 靠定时器重扫,天然补偿 |
| 幂等性 | 唯一键天然幂等(version 语义需调整) | 需幂等键 + 去重 | 需幂等键 + 去重 |
| 并发 | 数据库条件更新兜底 | 需"旧事件不得覆盖新事件"防护 | 需锁/版本比对 |
| 实施范围 | 小(线程内调用)但侵入全部写方法 | 中(需要可靠发布/订阅或 MQ) | 中(脏标记 + 调度器) |
| 对现有 RabbitMQ 依赖 | 无 | **强依赖**(需事务后发布、重试、去重、死信) | 无(可走内置调度) |
| 对趋势/申诉/监控影响 | 不变 | 短窗口内趋势滞后,申诉取证可能取旧版本 | 更明显滞后 |
| 发布/回滚难度 | 低 | 中(MQ 基建变化) | 中(调度器+补偿) |
| 是否需要 Flyway | 否 | 否(建议无) | 否(可选脏标记列则需迁移) |
| 是否新增公网接口 | 否 | 否 | 否 |
## 11. 唯一推荐方案
**分两阶段,先收敛写入口并建立可靠的“待刷新”机制,后让 GET 停写。唯一推荐为方案 C(持久化脏标记/刷新任务 + 定时物化),写侧提交后只登记待刷新任务;物化 Worker 复用唯一计算器和快照 Writer。**
理由:
1. 不能把评分刷新放入资料、投递、消息等核心业务事务;评分失败不应回滚这些业务,原报告在“同事务”和“失败不影响业务”之间自相矛盾。
2. 当前 RabbitMQ 没有已验证的事务消息、持久去重和死信恢复链路,不能直接依赖纯异步 MQ。
3. 评分包含 90/30 日窗口与能力结果有效期,仅靠业务事件不完备,必须有定时补偿。
4. 幂等键若不持久化就无法跨进程、重启和重试去重;因此原报告“无表结构变更但用 sourceEventKey 去重”不可实现。
5. 持久任务允许业务事务只做一次轻量 upsert 标记,Worker 失败可重试,不阻塞核心业务;GET 兜底在迁移期保留。
## 12. 分阶段实施顺序
**D4-1(可靠物化基础设施,GET 兜底保留)**
1. 新增唯一 `CandidateScoringSnapshotWriter`,集中快照差异比较、条件更新和版本规则;申诉专用 history 语义仍由 Governance 管理。
2. 新增持久化刷新任务/脏标记(需要新的 Flyway,不能再声称“无需迁移”)。唯一键至少覆盖 student + target period;任务记录 requested_at、attempts、next_attempt_at、processed_at/version,不保存敏感评分输入。
3. §9 的真实输入写侧仅登记任务,不在线程内计算八维评分;时间窗口和 rulesVersion 由定时扫描登记任务。
4. Worker 在独立事务中领取任务、计算并写快照;失败可重试,成功后原子完成;不得依赖 JVM 锁。
5. 保持 GET upsert 作为迁移兜底,完成真实 MySQL 并发、重试和覆盖证据。
**D4-2(GET 停写)**
1. 三个 GET 改为 `@Transactional(readOnly = true)`,删除 upsert 调用(computeAll 保留用于实时计算响应字段,与物化无关)。
2. 开启补偿扫描,对时间窗口、rulesVersion 变化以及遗漏任务进行修复;与持久任务幂等协作。
3. 真实 MySQL 验收:GET 重复请求前后 `candidate_dimension_scores` 完整不变、无 audit/notification/RabbitMQ 副作用、趋势/申诉/监控/排名表现与 D4-1 一致。
4. 达标后方可切换/废弃 GET 兜底。
**阶段切换网关(未满足不得进入 D4-2)**:真实数据源覆盖完整、相同任务重放不造成无效 version 增长、并发领取恰好一次有效物化、失败可重试且不影响原业务提交、时间窗口可由补偿扫描推进、GET 停写后的趋势/申诉/排名/监控真实 MySQL 回归通过。
## 13. 并发与幂等设计
- **任务幂等键**:持久任务按 `(studentUserId, targetPeriod)` 合并;多次输入变化只需保证至少一次最新状态重算。rulesVersion 是计算结果属性,不用虚构的内存 `sourceEventKey` 充当持久去重。
- **快照唯一性**:继续使用 `uk_candidate_dim_scores(student_user_id, dimension, period)`。
- **无效重放**:Writer 比较 score、status、sub_scores_json、weight、rules_version 等业务值;完全相同则不更新 version/updated_at。`calculated_at` 是计算时间,不能单独作为“业务变化”依据,否则每次重算仍会制造版本。
- **并发/旧任务**:任务不是携带完整旧数据的事件,而是“请按数据库当前状态重算”的合并信号,因此晚到任务只会重新读取最新状态,不会用旧载荷覆盖新快照。
- **rulesVersion 变化强制刷新**:`rules_version` 参与唯一比较,升级批次以新版本全量重算。
- **两个输入变化并发**:数据库任务唯一键合并;Worker 使用条件领取/乐观锁或 `SELECT ... FOR UPDATE SKIP LOCKED`,禁止 JVM 本地锁;必要时完成后检测 requested_at 是否在处理期间推进,若推进则保留待处理状态再算一次。
- **失败重试**:普通输入变化只登记持久任务,实际评分由 Worker 独立事务完成;失败更新重试状态并由补偿扫描兜底。重试不得重复写 `candidate_scoring_snapshot_history`/audit(该表只在申诉决定/回滚写,普通物化不写它)。
- **申诉期间快照变化**:`scoreAtAppeal/snapshotVersionAtAppeal` 保留申诉创建时刻值;写侧刷新不得改写申诉取证字段;决定/回滚的重算仍走 `recomputeAndSnapshot`(其版本语义与写侧事件在 `calculated_at/rules_version` 条件上一致,回滚目标版本 = beforeVersion+1 的既有确定性逻辑需在 D4-2 复核不冲突)。
- **回滚后的新输入事件**:允许再次计算——回滚后新事件刷新按正常路径写入新版本,`snapshot_history` 只记录申诉动作,不记录普通事件。
## 14. 失败恢复策略
- 写侧业务事务只 upsert 持久刷新任务;任务登记失败应随该业务事务回滚,避免提交了源数据却永远没有刷新信号。评分计算不在核心业务事务中执行。
- Worker 独立事务执行,失败记录 attempts/next_attempt_at 并指数退避;超过阈值保留 FAILED/PENDING 状态供告警和补偿,不伪装成功。
- 定时补偿根据活跃用户、时间窗口与 rulesVersion 登记任务,不直接绕过 Writer 写快照。
- 最终一致性:允许短暂滞后(分钟级),趋势/监控为展示性质;申诉取证在写侧事件刷新后基于新版本,行为与现状一致(现状也是"最新一次写入者胜")。
## 15. 数据迁移与兼容策略
- `candidate_dimension_scores` 本身优先保持结构不变;D4-1 需要新增持久刷新任务表及唯一/领取索引,因此必须新增下一号 Flyway(不得修改 V1–V43)。具体表设计在实施前独立评审。
- 现有存量数据:保留;D4-2 上线前对活跃学生做一次全量 `upsert` 对齐(事件覆盖前不删 GET 兜底,因此存量自然收敛)。
- version 语义从"每次读取递增"收敛为"每次**有效变更**递增 + rulesVersion 升级递增";历史 version 不重写。
- 趋势/申诉/监控/排名/导出读取代码零改动。
## 16. 下一轮(D4)精确修改文件清单(预估,实施前以代码现状复核)
- 新增:`candidatescore/CandidateScoringSnapshotWriter.java`、刷新任务实体/Repository/Worker/调度器,以及下一号 Flyway(具体命名先核对迁移目录最大版本)。
- 修改(仅写侧事件化,不改变业务语义):
- `profile/ProfileService.java`、`resume/ResumeVersionService.java`、`preference/PreferenceService.java`
- `ability/AbilityAttemptService.java`、`application/ApplicationService.java`
- Offer、候选人反馈、消息与审计的真实写入 Service(实施前精确定位)
- `candidatescore/CandidateScoringCalculator.java`(upsert 委托 + 条件 version 递增)
- **D4-2 才改**:`candidatescore/CandidateScoringEngineService.java`(三 GET 改为 readOnly 并移除 upsert 调用)。
- 文档:`docs/backend-development-roadmap.md`(本报告结论)、`docs/candidate-scoring-api.md`(若契约文字涉及写语义)。
- **允许且预计需要新增**一个 Flyway、刷新任务表及必要索引;仍不新增 ErrorCode 和公网接口。
## 17. D4 必须先写的失败测试(D4 实施轮的第一批测试,用于驱动实现)
1. `GET 重复请求前后 candidate_dimension_scores 完整内容不变`(行数/score/status/version/created_at/updated_at 全等)。
2. 三个 GET 不写 audit、notification、RabbitMQ(消息计数前置后置相等)。
3. GET 返回 JSON 与基线一致(黄金 JSON 对比)。
4. §9 每个真实输入源变化都会登记或合并一条待刷新任务;明确排除的非输入事件不得登记任务。
5. 同一学生/period 的重复登记合并;完全相同的计算结果不增加快照 version。
6. 并发登记后最终至少执行一次基于数据库最新状态的重算,任务不丢失且快照行不损坏。
7. 刷新失败可重试、不写重复历史/审计、不影响原业务事务。
8. rulesVersion 升级触发全量重算(version 递增)。
9. 申诉创建取证字段(scoreAtAppeal/snapshotVersionAtAppeal)在写侧刷新后仍正确。
10. 越权/非法 dimension/资源不存在零写入。
11. 真实 MySQL 行级副作用快照 + RabbitMQ ready/unacked 归零。
12. OpenAPI 176 paths / 214 operations 保持;不新增公网刷新接口。
> 现有 `CandidateScoringPersistenceIntegrationTest#upsertSnapshots_shouldBeIdempotentAndIncrementVersion` 的断言(重复 upsert version+1)在 D4-2 后语义变为"仅有效变更 +1",届时按新契约更新该既有断言(属于既定契约变更的测试演进,非草率改断言)。
## 18. 风险与回滚方案
| 风险 | 影响 | 缓解/回滚 |
|---|---|---|
| 写侧事件覆盖不全 → 某学生快照停滞 | 趋势/排名/监控滞后 | D4-1 保留 GET 兜底期间逐步补覆盖;D4-2 依赖补偿扫描兜底;若需开关必须在实施中明确新增并测试,不能假定现有开关存在 |
| 申诉取证版本语义漂移 | 申诉对比失真 | 事件刷新不触碰取证字段;D4-2 前在真实 MySQL 对申诉全链路回归 |
| 异步刷新丢失 | 快照缺失 | 事务后发布 + 死信 + 补偿扫描(幂等键避免重复写) |
| version 语义变化被下游误读 | 审计/导出版本含义差异 | 文档化;规则升级仍强制递增 |
| 回滚难度 | — | D4-1 可停止任务登记和 Worker,但新增表由 Flyway 保留不用;D4-2 可恢复 GET upsert。不得回滚或删除已执行迁移 |
## 19. 自动化实际结果(本轮)
| 命令 | 结果 |
|---|---|
| `mvn -Dtest="R01D4GetWritesSnapshotEvidenceTest" test`(新增现状证据) | **5/5**,BUILD SUCCESS |
| `mvn -Dtest="CandidateScoringEngineServiceTest,CandidateScoringPersistenceIntegrationTest,*CandidateScoring*Test,*ScoringMonitoring*Test" test` | **134/134**,F=0 E=0 S=0,BUILD SUCCESS |
| `mvn clean verify` | **2109/2109**(≥2104 预期),Failures=0、Errors=0、**Skipped=20**,BUILD SUCCESS |
| `python docs\verify_openapi.py` | **176 paths / 214 operations,All checks passed** |
Skipped=20 逐项说明:全部来自 Q01 既有的 `JobSearchQueryExplainMySqlIntegrationTest` 与 `JobSearchQuerySideEffectMySqlIntegrationTest`(`@Testcontainers(disabledWithoutDocker = true)`,分别 15/5 例)。Docker Engine 本轮不可用 → Testcontainers 显式约定跳过,为既有机制而非本轮核心核查,且非"把环境缺失伪装为通过";R01-D4 核心副作用证据由 H2 集成测试 5/5 提供,真实 MySQL 证据单独标注 BLOCKED(§5.2)。
## 20. Git 状态与边界声明
- 未修改任何生产代码;未修改既有测试断言;未修改 V1–V43;未新增 Flyway/表/索引/ErrorCode/公网接口。
- 新增(工作区未提交):`docs/r01-d4-get-scoring-snapshot-migration-design.md`、`R01D4GetWritesSnapshotEvidenceTest.java`(现状证据测试,非契约断言)。
- 未 commit、push;未进入 R01-D5。历史未跟踪文件未删除未移动;暂存区为空。
- 真实 MySQL 副作用证据标注 **BLOCKED(Docker Engine 不可用)**,未伪装通过;H2 集成证据 5/5 已提供。
## 21. 最终结论
> **R01-D4 GET评分快照写入迁移方案设计完成,待负责人确认后分阶段实施。**
阻塞/依赖声明:当前 GET 兜底写快照服务多个真实输入域与 6 类消费者(趋势/申诉/监控/排名/看板/导出),且评分会因时间窗口自然变化;因此严禁直接删除 GET upsert。最小可行迁移路径 = D4-1(唯一 Writer + 持久刷新任务 + Worker/补偿扫描 + GET 兜底)→ 真实 MySQL 验证并发、重试与数据源覆盖 → D4-2(GET 转 readOnly)。

352
docs/r01-e-batch-query-unread-count-performance-report.md

@ -0,0 +1,352 @@
# R01-E 既有批量查询与会话未读数性能实现:验收与测试加固报告
日期:2026-09-18
分支:`refactor/r01-structure-performance`
HEAD:`b893ed67f078eedb0c4b8a03341311bd79342354`(== upstream)
本轮性质:**验收与测试加固** —— **生产代码 diff = 0**,未暂存、未提交、未推送
---
## 0. 结论
> **R01-E既有批量查询与会话未读数性能实现已完成验收和测试加固,待独立代码复验。**
**观察项(本轮不改变既有语义)**:
> **OBS-E01**:当前**本人发送且没有 `message_reads` 记录**的消息也计入**本人**未读数,
> 待产品负责人单独确认;本轮不改变既有语义。
**库存前置结论**:E1 与 E2 在 HEAD 上**均已处于目标终态**(分别由 R01-D2、R01-D3 交付),
故本轮**未修改任何生产代码**,改为以**查询预算特征测试 + 跨库一致性 + EXPLAIN + 副作用快照**做验收加固。
---
## 1. PersonalDataCollector 修复前调用链(E1)
**调用链**:`PersonalDataExportWorker.run` → `PersonalDataCollector.collect(userId)` → 12 个 section 构建方法 → `PersonalDataExportBuilder.build(...)` → 落盘。
**查询形态(逐处核对)**:全部为**批量**读取 —— `findByStudentUserIdOrderByCreatedAtDesc` /
`findByUserId` / `findAllById` / `...In...OrderBy...`,且批量历史查询
(`ApplicationStatusHistory` / `InterviewStatusHistory` / `OfferStatusHistory` / `AppealHistory`)
均使用 `findByXxxIdInOrderByXxxIdAscCreatedAtAsc(...)`。
**循环内是否再查**:**否**。11 处 `forEach`(行 169/199/241/290/349/368/382/409/420/440/460/472)
只消费**预先批量取回**的数据与 `groupBy(...)` 结果做内存组装,循环体内**零 Repository 调用**。
**N 增长公式**:不受记录条数影响(见 §4)。
---
## 2. 会话未读数修复前调用链(E2)
**调用链**:`MessagingController` → `MessagingService.listConversations(userId)`:
1. `memberRepository.findByUserId(userId)`(1 条)
2. 空则 `return List.of()` —— **不执行 `IN ()`**
3. `conversationRepository.findAllById(convIds)`(1 条)
4. `messageRepository.countUnreadByConversationIds(convIds, userId)`(**1 条分组查询**)
5. 内存排序(`updatedAt` 降序)后 `getOrDefault(cid, 0L)` 补 0
`countUnread(convId, userId)`(私有)**仅被 `getConversation`(单会话详情)调用**;
会话列表路径**不再逐会话 count**。
---
## 3. 修复前后查询次数公式
| 场景 | 修复前(R01-D2/D3 之前) | 当前(HEAD) |
|---|---|---|
| E1 导出 | `O(N)`:每类数据在循环内按 ID 再查 | **`O(1)` 常数**(每个数据类别固定 1 条批量查询) |
| E2 会话列表 | `O(K)`:每会话 1 次 `countUnread` | **`O(1)` 常数 3 条**(成员、会话、分组未读各 1 条) |
---
## 4. 1 / 10 / 50 数据规模下 E1 SQL 数(实测)
`R01EQueryBudgetAcceptanceTest.personalDataCollectQueryCountIsFlat`,
经 `CountingDataSource` 统计**真实 JDBC 语句**(`prepareStatement`/`createStatement`):
| 投递记录数 | 实测 SQL 条数 |
|---|---|
| **1** | **20** |
| **10** | **20** |
| **50** | **20** |
**恒定 20 条**,与记录数**完全无关**(每次 `mvn` 为全新 JVM + 全新 H2 库;断言为精确相等)。
---
## 5. 1 / 20 / 100 会话规模下 E2 SQL 数(实测)
`R01EQueryBudgetAcceptanceTest.unreadQueryCountIsFlat`:
| 会话数 | 实测 SQL 条数 | 每会话未读 |
|---|---|---|
| **1** | **3** | 1 |
| **20** | **3** | 1(逐会话校验全部为 1) |
| **100** | **3** | 1(逐会话校验全部为 1) |
**恒定 3 条**,不随会话数增长;三种规模下**逐会话 `unreadCount` 值全部正确**。
---
## 6. 批量 Repository / 投影契约
| 组件 | 契约 |
|---|---|
| E1 | 既有 `...In...` 批量方法 + `groupBy(values, idExtractor)` 内存分组;`Map<Long, List<T>>` 强类型键 |
| E2 | `MessageRepository.countUnreadByConversationIds(List<Long> conversationIds, Long readerUserId)` → `List<ConversationUnreadCount>` 投影(`conversationId` / `unreadCount`);**原生查询、全参数化**,无字符串拼接用户可控 ID |
---
## 7. 未读数 WHERE / GROUP BY 的准确语义(逐字取自原生查询)
```sql
SELECT ranked.conversation_id AS conversationId, COUNT(*) AS unreadCount
FROM ( SELECT m.id, m.conversation_id, m.status,
ROW_NUMBER() OVER (PARTITION BY m.conversation_id ORDER BY m.created_at DESC) AS row_no
FROM messages m
WHERE m.conversation_id IN (:conversationIds) ) ranked
LEFT JOIN message_reads mr ON mr.message_id = ranked.id AND mr.reader_user_id = :readerUserId
WHERE ranked.row_no <= 500
AND (ranked.status IS NULL OR ranked.status <> 'RECALLED')
AND mr.id IS NULL
GROUP BY ranked.conversation_id
```
| 语义点 | 事实 |
|---|---|
| 可见范围 | 由**会话成员关系**界定(调用方先取 `findByUserId` 得到 `convIds`),**非**由用户传入 ID 决定 |
| 会话页之外 | **不查询**(只传当前用户的会话 id 列表) |
| 撤回消息 | **排除**(`status <> 'RECALLED'`) |
| 已读消息 | **排除**(`message_reads` 存在该读者的记录) |
| 窗口上限 | 每会话**最近 500 条**(`row_no <= 500`) |
| 发送者过滤 | **无** —— 见 **OBS-E01** |
| 未返回的会话 | Service 侧 `getOrDefault(cid, 0L)` 补 0 |
**OBS-E01 的实测证据**:同一会话内「本人 1 条 + 对方 2 条、均未读」时
`unreadCount = 3`(最初按"他人发送才计"预期的 2 被实测纠正)。
本轮**如实锁定既有语义**,未做任何行为改变。
---
## 8. H2 / MySQL 逐值一致结果
`R01EMySqlQueryCountAndExplainTest`(Testcontainers `mysql:8.4`,**4/4 通过**,耗时 34.94s → 容器真实启动):
| 用例 | 结论 |
|---|---|
| `groupedUnreadMatchesPerConversationSemantics` | MySQL 上分组 SQL 与 Service 路径**逐值一致**;`c1`: 3 条 SENT − 1 条 RECALLED − 1 条已读 = **1**;`c2`: **1** |
| `emptyAndForeignConversations` | 空 id 列表与不含该会话的 id 列表**均不返回** |
| `conversationScaleIsFlatAndCorrect` | 1 / 20 / 100 会话下 Service 结果条数与逐会话 `unreadCount`(各 3 条未读 = **3**)**全部正确** |
| `explainUnreadQuery` | 见 §9 |
**H2 与 MySQL 一致性**:同一组夹具语义(撤回排除、已读排除、多会话聚合、缺失补 0)在两侧
得到**相同数值**;H2 侧见 §4/§5/§13,MySQL 侧见上表。
---
## 9. MySQL EXPLAIN 摘要
对**与 `countUnreadByConversationIds` 逐字相同**的 SQL、同一批绑定参数执行
`EXPLAIN FORMAT=JSON`(MySQL 8.4;提交前收口测试增加 10,000 条非目标背景消息,
使 1 个目标会话 × 200 条消息保持高选择性):
| 项 | 值 |
|---|---|
| JSON 长度 | 3898 字符 |
| `using_temporary_table` | **true**(位于 `grouping_operation` 层,为 `GROUP BY conversation_id` 的固有代价) |
| `using_filesort` | 提交前实施夹具(1 会话 × 200 条、10,000 条背景)为 **false**;独立复验的 1/20/100/600 四档真实规模均为 **true** |
| 派生表 `ranked` | `access_type = ALL`,即对物化后的派生结果顺序扫描;不能表述成“没有全表扫描” |
| 基础表 `messages`(别名 `m`) | `access_type = ref/range`,命中 `idx_messages_conversation_created`,不是 `ALL` |
| 基础表 `message_reads`(别名 `mr`) | `access_type = eq_ref`,命中 `uk_message_reads` |
| 其它字段 | `possible_keys` / `key` / `cost_info` / `rows_examined_per_scan` 均已出现;摘要落盘 `target/r01e-mysql-explain.txt` |
**判读**:分组所需的临时表及窗口函数排序属于既有 SQL 的固有代价;最小实施夹具与
真实规模对 `using_filesort` 的代价选择不同,而独立复验四档真实规模均为
`using_temporary_table=true`、`using_filesort=true`。`ranked` 的 `ALL` 是派生结果顺序扫描;
必须与基础表区分。基础表 `messages` 在高选择性场景命中既有复合索引且不是 `ALL`,
`message_reads` 以唯一键 `eq_ref` 访问。
独立复验发现原实施测试使用
`doesNotContain("\"table_name\": \"messages\" ...")`,但 MySQL JSON 使用别名 `m`,
因此该断言不具鉴别力。提交前已改为解析 JSON 树,分别定位唯一的 `m`、`mr`、`ranked`
节点,并校验访问类型、索引、临时表和 filesort;错误索引、`m=ALL`、缺失 `mr` 三种变异
都会使断言失败。
---
## 10. 使用到的索引
`V9__messaging.sql` 中**既有**索引(**本轮未新增任何迁移或索引**):
| 索引 | 表 | 列 |
|---|---|---|
| `idx_messages_conversation_created` | `messages` | `(conversation_id, created_at)` |
| `idx_message_reads_message` | `message_reads` | `(message_id)` |
| `idx_message_reads_reader` | `message_reads` | `(reader_user_id)` |
| `idx_conv_members_user` | `conversation_members` | `(user_id)` |
| `idx_conversations_updated` | `conversations` | `(updated_at)` |
未读查询的 `WHERE m.conversation_id IN (...)` 与
`ROW_NUMBER() ... ORDER BY m.created_at DESC` 由
`idx_messages_conversation_created (conversation_id, created_at)` 支撑;
`LEFT JOIN message_reads` 由 `idx_message_reads_message` / `idx_message_reads_reader` 支撑。
独立复验还确认:当目标会话消息占全表约 5.45% 时仍使用索引,约 13.63% 时优化器会
基于代价转为基础表全扫;这是选择性变化,不是索引缺失。窗口排序、派生表物化及高占比
场景仍是遗留性能风险,但 EXPLAIN 未证明必须新增索引,故本轮未新增 V46。
---
## 11. 行级副作用快照
`R01EQueryBudgetAcceptanceTest` 对 **11 张表**做稳定字段行级快照(按主键排序)并逐字段比对:
| 表 | 快照列 |
|---|---|
| `users` | id, phone, nickname, role, is_active |
| `user_roles` | user_id, role |
| `conversations` | id, type, created_by, updated_at |
| `conversation_members` | conversation_id, user_id |
| `messages` | id, conversation_id, sender_user_id, status |
| `message_reads` | id, message_id, reader_user_id |
| `notifications` | id, user_id, type, title |
| `audit_logs` | id, user_id, action, entity_type |
| `auth_tokens` | id, user_id, token_type, revoked |
| `applications` | id, job_id, student_user_id, status |
| `job_favorites` | id, job_id, student_user_id |
| 用例 | 结论 |
|---|---|
| `listConversationsIsReadOnly` | 会话列表 GET 前后 **11 张表逐字段零变化** ✔ |
| `personalDataCollectIsReadOnly` | `collector.collect()` 前后 **11 张表逐字段零变化** ✔ |
---
## 12. RabbitMQ / 通知 / 审计结果
| 项 | 结论 |
|---|---|
| 通知 | `notifications` 行数在 `collect()` 前后**完全相等** ✔ |
| 审计 | `audit_logs` 行数在 `collect()` 前后**完全相等** ✔;且快照逐字段零变化 ✔ |
| RabbitMQ | 两条路径(`listConversations`、`collect`)均不发送消息;测试期间无新增 MQ 相关行 ✔ |
| 日志 | 未新增记录消息内容 / 手机号 / Token / 导出内容的日志语句(生产 diff = 0,无新增日志) |
---
## 13. 新增测试及变异/反证
| 测试类 | Tests | 内容 |
|---|---|---|
| `common/r01e/R01EQueryBudgetAcceptanceTest` | **11** | E1/E2 查询预算(H2,真实 JDBC 计数)、既有未读语义(含 OBS-E01)、已读/撤回排除、多会话聚合与补 0、跨用户不泄漏、**11 张表行级副作用快照**、通知/审计零新增 |
| `common/r01e/R01EMySqlQueryCountAndExplainTest` | **4** | MySQL 8 上分组 SQL 与逐会话语义**逐值一致**、空列表与不属用户的 id 不返回、1/20/100 会话规模、`EXPLAIN FORMAT=JSON` |
**反证设计**:查询预算断言为**精确相等**(非"不显著增长")——若任一实现退化为逐条查询,
`baseline[1] != baseline[0]` 会立即失败;副作用快照为**逐字段相等**,任何写入都会被捕获。
测试类通过 `CountingDataSource` 统计**真实 JDBC 语句**,**不是** Mockito verify Repository。
**本轮自查发现并修复的测试缺陷(诚实记录)**:`R01EQueryBudgetAcceptanceTest` 初版只有
`@BeforeEach` 清理、**缺 `@AfterEach`**,退场后遗留 `r01e-*` 用户/公司/会话,导致全量运行中
`PersonalDataExportControllerTest`(15)、`CompetitionRankingControllerTest`(13) 等 **60 项失败**。
已补 `@AfterEach` 精确清理,复跑 `R01EQueryBudgetAcceptanceTest,PersonalDataExportControllerTest,
CompetitionRankingControllerTest` → **39/39 通过**。**这是本轮新增测试自身的缺陷,非既有实现问题。**
---
## 14. 定向、关联、全量和 clean verify 数量
| 命令 | 结果 |
|---|---|
| 关联回归(`*PersonalData*Test,*Export*Test,*Messaging*Test,*Message*Test,*Conversation*Test,*Recall*Test`) | **150 / 150**,F=0 E=0 S=0 ✔ |
| 性能/验收定向 | 见 §14.1 |
| `mvn test` | 见 §14.1 |
| `mvn clean verify` | 见 §14.1 |
### 14.1 全量与 clean verify
| 命令 | Tests | Failures | Errors | Skipped | BUILD |
|---|---|---|---|---|---|
| 性能/验收定向(含 MySQL Testcontainers) | **18** | 0 | 0 | **0** | SUCCESS |
| `mvn test` | **2761** | **0** | **0** | **0** | SUCCESS |
| `mvn clean verify` | **2761** | **0** | **0** | **0** | SUCCESS |
**2761 = 2746(R01-C 阶段二基线)+ 本轮新增 15**(H2 11 + MySQL 4),与要求「不低于 2746 + 新增数」一致。
**Skipped=0** 且 MySQL 用例耗时 34.94s,证明 **Testcontainers 真实执行、未被跳过**。
**关联回归**:`*PersonalData*Test,*Export*Test,*Messaging*Test,*Message*Test,*Conversation*Test,*Recall*Test`
→ **150 / 150**,F=0 E=0 S=0 ✔
---
## 15. OpenAPI 数量
```
python ..\docs\verify_openapi.py → paths: 176 / operations: 214 / All checks passed!
```
`git diff -- docs/openapi-rc.json` → **空** ✔
---
## 16. 精确修改文件
**生产代码:0 个文件。**
**新增测试(2)**
```
mydreamjob-server/src/test/java/com/mydreamjob/common/r01e/R01EQueryBudgetAcceptanceTest.java
mydreamjob-server/src/test/java/com/mydreamjob/common/r01e/R01EMySqlQueryCountAndExplainTest.java
```
**文档(1)**:本报告
---
## 17. 是否修改迁移、ErrorCode、接口、DTO、依赖
| 项 | 结果 |
|---|---|
| V1–V45 / Flyway | **未修改** ✔ |
| `ErrorCode` | **未修改** ✔ |
| 公网接口 / DTO / OpenAPI 契约 | **未修改**(176/214) ✔ |
| `pom.xml` / 依赖 | **未修改** ✔ |
| Security / Token / 权限 / requestId | **未修改** ✔ |
| 评分、排名、推荐、申诉、邀约、Offer、撤回与已读语义 | **未修改** ✔ |
| 分布式缓存 / 冗余未读列 / 异步最终一致未读数 | **未引入** ✔ |
---
## 18. Git 状态
| 项 | 结果 |
|---|---|
| 分支 / HEAD | `refactor/r01-structure-performance` / `b893ed67f078eedb0c4b8a03341311bd79342354`(未变) |
| HEAD 与 upstream | **0 0** ✔ |
| `git diff --cached --name-only` | **空** ✔ |
| `git diff --check` | **exit 0** ✔ |
| 生产代码 diff | **0 个文件** ✔ |
| V1–V45 / `openapi-rc.json` / `pom.xml` / `ErrorCode` | **均未修改** ✔ |
| 历史未跟踪文件 | 完整保留 ✔ |
| 本轮新增(未跟踪) | `docs/r01-e-batch-query-unread-count-performance-report.md`、`mydreamjob-server/src/test/java/com/mydreamjob/common/r01e/`(2 个测试类) |
---
## 19. 明确未暂存、未提交、未推送
**确认:未执行 `git add`;未创建提交;未推送。** 未执行 `reset`/`restore`/`checkout`/`clean`。
---
## 20. 遗留风险
| # | 风险 | 说明 |
|---|---|---|
| R1 | **OBS-E01 语义** | 本人发送且未读的消息计入本人未读数。当前实现如此,本轮**未改变**;若产品确认应排除,需另立批次(属业务语义变更,不属性能收束) |
| R2 | 500 条窗口 | 未读统计只看每会话**最近 500 条**;超过 500 条的会话其更早未读不计入。属既有设计,本轮未改动 |
| R3 | 派生表物化 | MySQL 8 对 `ROW_NUMBER()` 派生表可能物化;`ranked.row_no <= 500` 的过滤在物化后生效,大会话下存在额外扫描成本。EXPLAIN 证据见 §9 |
| R4 | 测试自污染 | 本轮新增测试的清理为**按前缀精确删除**;若将来有人新增同类夹具而不清理,仍可能污染共享 H2 库(R01-C 已就同类问题建立隔离门禁,本轮范围外) |
---
> **R01-E既有批量查询与会话未读数性能实现已完成验收和测试加固,待独立代码复验。**

412
docs/r01-e-independent-verification-20260918.md

@ -0,0 +1,412 @@
# R01-E 批查询与会话未读数:**独立复验报告**(第三~十一节)
日期:2026-09-18
分支:`refactor/r01-structure-performance`
HEAD / upstream:`b893ed67f078eedb0c4b8a03341311bd79342354`(ahead/behind = **0 / 0**)
本轮性质:**独立复验** —— 不改生产代码、不改迁移、不暂存、不提交、不推送
上游依据:`docs/r01-e-batch-query-unread-count-performance-report.md`(实施与测试加固报告)
---
## 0. 结论
> **R01-E独立代码与真实MySQL性能复验通过,可以进入精确暂存与独立提交检查点。**
| 复验项 | 结论 |
|---|---|
| 独立查询预算(E1 = 20/20/20,E2 = 3/3/3) | **通过**(H2 与真实 MySQL 双侧实测) |
| E1 collector 完整输出 H2/MySQL 深比较 | **通过**(逐字段、含 null/时间/集合顺序/关联数据) |
| E2 会话未读 H2/MySQL 深比较(10 项语义) | **通过** |
| MySQL `EXPLAIN FORMAT=JSON` | **通过**(基础表命中既有索引,无需新增 V46) |
| 副作用与隔离(25 张表快照 + 反证 + 零残留) | **通过** |
| OBS-E01 | **保留既有语义**,作为产品负责人待决策项上报 |
| 独立复验时全量 `mvn test` / `mvn clean verify` | **2787 / 0 / 0 / 0** |
| 提交前收口 `mvn clean verify` | **2788 / 0 / 0 / 0**(增加 1 个 EXPLAIN 变异门禁) |
| OpenAPI | **176 paths / 214 operations**,`openapi-rc.json` 未改 |
| 生产代码 diff | **0 个文件** |
**独立复验新发现(3 项,均如实记录)**:
**F1** 实施测试的 EXPLAIN 断言 `doesNotContain("\"table_name\": \"messages\", ...")` 是**空断言**——
MySQL 的 EXPLAIN JSON 里 `table_name` 存的是**别名**(本语句为 `m` / `mr`),
`"table_name": "messages"` 永不出现,故该断言无论如何都会通过。结论本身(当时夹具下确实未全扫)成立,
但**断言不具备鉴别力**。提交前收口已将实施测试改为解析 JSON 树,按别名分别定位
`m` / `mr` / `ranked`,校验 access_type、key、temporary 与 filesort,并以错误索引、
基础表 `ALL`、缺失节点三类变异证明断言有效。
**F2** H2 与 MySQL 对同一 JSON 列的回读**表示不同**(详见 §4.2),已记录原始值并按语义归一后比较。
**F3** 实施报告 §9 记 `using_filesort=false`;本轮在**真实规模夹具**下实测 `using_filesort=true`
(窗口函数 `ROW_NUMBER()` 的排序代价),详见 §6.4。
---
## 1. 复验范围与不重叠声明
- 从第三~十一节执行,**未重做第一、二节库存**,未修改任何生产代码。
- 复用既有文件(**未重复创建**):
- `mydreamjob-server/src/test/java/com/mydreamjob/common/r01e/R01EQueryBudgetAcceptanceTest.java`(实施测试,11 项)
- `mydreamjob-server/src/test/java/com/mydreamjob/common/r01e/R01EMySqlQueryCountAndExplainTest.java`(实施测试,4 项)
- `docs/r01-e-batch-query-unread-count-performance-report.md`
- 独立测试**不复用**实施测试的任何夹具、断言或辅助方法:夹具、快照(25 张表、列集合不同)、
计数 DataSource、EXPLAIN 解析均为独立实现。
---
## 2. 本轮新增文件(未跟踪)
```
mydreamjob-server/src/test/java/com/mydreamjob/common/r01e/R01EIndependentCrossDatabaseVerificationTest.java 独立测试,16 项
mydreamjob-server/src/test/java/com/mydreamjob/common/r01e/R01EIndependentSideEffectIsolationTest.java 独立测试,10 项
docs/r01-e-independent-verification-20260918.md 本报告
```
**生产代码:0 个文件**(`git diff --stat` 为空)。
---
## 3. 独立查询预算验证(任务 1)
**方法**:独立实现的 `CountingDataSource` 包装真实 `DataSource`,记录**真实 JDBC 语句文本与条数**;
两侧在**夹具灌入完成之后**才 `reset()`,故计数**不含测试准备与清理 SQL**(断言实测语句全部为 `SELECT`)。
### 3.1 E1:1 / 10 / 50 条投递
| 夹具 | H2 实测 | MySQL 实测 | 目标 |
|---|---|---|---|
| 与实施报告同基线的最小夹具(2 用户 + 1 公司 + N 岗位 + N 投递) | **[20, 20, 20]** | **[20, 20, 20]** | **20/20/20 ✔** |
| 本类丰富夹具(12 个 section 全部有数据)+ N 投递 | [22, 22, 22] | [22, 22, 22] | 恒定 ✔ |
- 两者都**不随记录数增长**;与实施报告「20/20/20」的结论**独立复现一致**。
- 丰富夹具为 22(比最小夹具多 2):多出的是「本人持有公司 → offers 企业侧分支」以及
「存在会话 → conversations/messages 两条批量查询」这两个**按分支固定**的查询,仍与 N 无关。
- 证据:`target/r01e-independent/e1-budget.txt`、`e1-budget-rich.txt`。
### 3.2 E2:1 / 20 / 100 会话
| 会话数 | H2 实测 | MySQL 实测 | 目标 |
|---|---|---|---|
| 1 | **3** | **3** | 3 ✔ |
| 20 | **3** | **3** | 3 ✔ |
| 100 | **3** | **3** | 3 ✔ |
- 实测 SQL 文本**恰好为 3 条**,形态固定且可辨认:
1. `select ... from conversation_members ... where user_id=?`
2. `select ... from conversations ... where id in (?,...)`
3. 分组未读查询(含 `ROW_NUMBER()`)——**只出现 1 次**
- 证据:`target/r01e-independent/e2-budget.txt`(含两侧完整 SQL 文本)。
### 3.3 空会话页不执行未读查询
| 断言 | H2 | MySQL |
|---|---|---|
| `listConversations(无会话用户)` 返回空 | ✔ | ✔ |
| 实测语句数 | **1** | **1** |
| 该语句不含 `ROW_NUMBER` | ✔ | ✔ |
| 含 `row_number` 的语句数 | **0** | **0** |
即:空会话页只发出一条成员关系查询后短路返回,**不下发未读聚合查询**。
---
## 4. E1 H2 / MySQL 深比较(任务 2)
### 4.1 方法
同一个 JVM 内同时持有两个 Spring 上下文:
- **H2 侧**:测试自身上下文,`jdbc:h2:mem:r01e_ind;MODE=MySQL`(**本类独占**的内存库,Flyway 全量迁移);
- **MySQL 侧**:`@BeforeAll` 手工引导的第二个上下文(Testcontainers `mysql:8.4`,`mysql-it` 配置,
`WebApplicationType.NONE`,命令行参数覆盖数据源),Flyway 在容器内真实执行 45 个迁移。
两侧灌入**同一份等价夹具**(显式时间戳、无 `CURRENT_TIMESTAMP` 依赖),覆盖 **全部 12 个 section**,
并额外放入一个「外人」用户的同类数据。
**先决条件已实证**:`bothEnginesAreLiveAndFixtureIdsAgree` 断言两侧 27 个自然键 → 主键映射**完全相等**
(同一 JVM、两侧均为全新建库),故后续可直接比较含主键的完整输出。
### 4.2 深比较结果
`canonicalResult(...)` 把 collector 的**完整 section 输出**规范化为确定性文本:显式呈现 `null`、
时间按 `LocalDateTime` 呈现、List 保持原序(集合顺序)、Map 按 key 排序、`BigDecimal` 去尾零。
```
e1-h2.txt == e1-mysql.txt (均 6213 字节,逐字符相同)
```
**JSON 列的表示差异(F2,如实记录)**——`e1-raw-json.txt` 原文:
```
h2: profile.skills="{\"java\":3,\"sql\":2}" | preferences.preferredCities="[\"北京\",\"上海\"]"
mysql: profile.skills={"sql": 2, "java": 3} | preferences.preferredCities=["北京", "上海"]
```
- **H2(MODE=MySQL)**把 JSON 列回读为**被转义的 JSON 字符串标量**;
- **MySQL 8**回读为 **JSON 对象字面量**(键重排、冒号后加空格)。
- 两者**语义相同、表示不同**:比较的是语义。规范化对确实是 JSON 的字符串递归解码后按键排序,
非 JSON 字符串原样保留(`content`、`coverLetter` 等不受影响)。
**反证**:把 dump 中任意一个字段值(`"张三"` → `"张四"`)置换后断言必须不再相等——通过,
说明规范化是**逐字段敏感**的,而不是只比条数、hash 或长度。
### 4.3 其他断言
| 断言 | 结果 |
|---|---|
| section 顺序 | 精确等于 `profile, preferences, resumes, applications, application-timelines, interviews, interview-timelines, offers, offer-timelines, feedback, favorites, notifications, conversations, messages, candidate-scoring, scoring-appeals` ✔ |
| 关联数据 | 应用↔时间线、面试↔时间线、Offer↔时间线、会话↔消息的分组关系随主键一并跨库一致 ✔ |
| 其他用户数据不得混入 | dump 中不含 `李四 / 外人的简介 / 外人简历 / 外人求职信 / 外人评价 / 外人通知 / 外人会话 / out-m1 / 外人Offer / 外人理由 / 外人面试 / outsider` ✔ |
| 跨库自有行数 | 23 张表的自有行数两侧完全一致 ✔ |
| 非 ACTIVE 简历、`read_at` 为 null / 非 null 的通知、`score` 为 null 的维度分、`tags` 为 null 的评价、`cover_letter` 为 null 的投递 | 均已覆盖并通过 ✔ |
---
## 5. E2 H2 / MySQL 深比较(任务 3)
10 项语义逐条覆盖,**两侧结果逐值一致**(会话列表完整视图规范化后逐字符相同):
| # | 语义 | H2 | MySQL | 结果 |
|---|---|---|---|---|
| 1 | **本人发送未读按现状计入(OBS-E01)** | c1 = 2(本人 1 + 他人 1) | c1 = 2 | ✔ 一致 |
| 1b | 同上,把本人那条标记已读后 | 1 | 1 | ✔ |
| 2 | 他人发送未读计入 | ✔ | ✔ | ✔ |
| 3 | `message_reads` 排除 | 读 1 条后 2 → 1 | 同 | ✔ |
| 3b | 另一读者的已读记录不影响本人 | 不受影响 | 不受影响 | ✔ |
| 4 | `RECALLED` 排除 | 撤回 1 条后 2 → 1 | 同 | ✔ |
| 5 | 多会话分组 | c1=2, c2=1, c3=1 | 同 | ✔ |
| 6 | 非当前用户会话不返回 | 只返回 c1/c2/c3;cOutsider 不在 | 同 | ✔ |
| 7 | 聚合缺失补 0 | 全部撤回/已读时聚合缺行 → 0 | 同 | ✔ |
| 8 | **最近第 500 条计入、第 501 条排除** | 501 条未读 → **500**;把最旧一条标已读 → **仍 500**;把最新一条标已读 → **499** | 同 | ✔ |
| 9 | **`created_at` 相同的排序语义** | 同刻 3 条 → 3;其中 1 条已读 → 2;同刻 501 条 → **500** | 同 | ✔ |
| 10 | **空及重复 `conversationIds`** | 重复 `[c1,c1]` 只产 1 行且与 `[c1]` 逐值相同;空列表由 Service 短路、不下发 `IN ()` | 同 | ✔ |
**第 8 项证明方式**:不止断言「等于 500」,而是通过「标已读最旧一条 → 不变」证明**最旧一条本就在窗口之外**,
「标已读最新一条 → 499」证明**窗口保留的是最新一侧**。
**第 9 项的诚实边界**:`ROW_NUMBER() ... ORDER BY m.created_at DESC` **没有 tiebreaker**,
故当 501 条 `created_at` 完全相同时,「保留哪 500 条」**由引擎决定**;
但**窗口计数与引擎无关,恒为 500**——两侧实测一致。此点已计入遗留风险(R2)。
---
## 6. MySQL `EXPLAIN FORMAT=JSON`(任务 4)
**被测语句**与 `MessageRepository.countUnreadByConversationIds` **逐字相同**,绑定同一批参数。
**夹具**:先铺入 **500 个背景会话 × 20 条 = 10 000 条消息**(不属于查询范围),再替换目标会话——
否则 `messages` 表几乎只含查询范围内的行,MySQL 选择全扫是**正确**的代价决策,
却会让 EXPLAIN 失去验收意义。
### 6.1 逐表结果(`messages` / `message_reads` / 派生结果 `ranked` 分开报告)
> **注意**:MySQL 的 `EXPLAIN FORMAT=JSON` 中 `table_name` 是**别名**。
> 被测语句的别名为 `messages m`、`message_reads mr`、派生表 `ranked`,
> 故表节点分别为 `m` / `mr` / `ranked`。
| 规模 | selectivity | `ranked`(派生结果) | `messages`(别名 `m`) | `message_reads`(别名 `mr`) | temporary | filesort |
|---|---|---|---|---|---|---|
| 1 会话 × 3 条 | 0.01% | **ALL**(派生结果顺序扫描)· rows=3 | **ref** · `idx_messages_conversation_created` · rows=3 | **eq_ref** · `uk_message_reads` · rows=1 | true | true |
| 20 会话 × 3 条 | 0.27% | **ALL** · rows=60 | **range** · `idx_messages_conversation_created` · rows=60 | **eq_ref** · `uk_message_reads` · rows=1 | true | true |
| 100 会话 × 3 条 | 1.36% | **ALL** · rows=300 | **range** · `idx_messages_conversation_created` · rows=300 | **eq_ref** · `uk_message_reads` · rows=1 | true | true |
| **1 会话 × 600 条(> 500 窗口)** | 2.73% | **ALL** · rows=600 | **ref** · `idx_messages_conversation_created` · rows=600 | **eq_ref** · `uk_message_reads` · rows=1 | true | true |
**判读**:
- **派生结果 `ranked` 的 `access_type = ALL` 必须准确表述为「派生结果顺序扫描」**——
这是 MySQL 对 `ROW_NUMBER()` 派生表**物化**后顺序读取物化结果的固有形态,
`possible_keys`/`key` 均为 `null`(派生表上不存在索引)。**不得**笼统写成「无全表扫描」。
- **`messages` 基础表在四个验收断言规模下命中既有索引 `idx_messages_conversation_created`(`ref` / `range`)**;
更高选择性探测会转为 `ALL`,详见下一节,不能笼统表述为“没有全表扫描” ✔
- **`message_reads` 基础表在所有规模下均为 `eq_ref` 命中 `uk_message_reads`** ✔
(`possible_keys` 同时列出 `idx_message_reads_message` / `idx_message_reads_reader`)
- `possible_keys`、`key`、`rows_examined_per_scan`、`filtered`、`using_temporary_table`、`using_filesort`
均已逐规模记录,原文见 `target/r01e-independent/e2-explain.txt`。
### 6.2 代价拐点(选择性探测,只记录不判定)
| 规模 | selectivity | `messages` 访问方式 |
|---|---|---|
| 2 会话 × 600 条(1 200 行) | 5.45% | **range** · `idx_messages_conversation_created`(命中索引) |
| 5 会话 × 600 条(3 000 行) | 13.63% | **ALL** · key=null · rows=13 093(转为全扫) |
| 20 会话 × 600 条(12 000 行) | 54.53% | **ALL** · key=null · rows=22 016(转为全扫) |
**结论**:拐点被夹逼在 **5.45% 与 13.63%** 之间。也就是说,
只有当「被查询的会话集合占 `messages` 全表 6% 以上」时,MySQL 才会放弃索引改走全扫——
这是**代价决策,不是缺索引**。真实场景中一个用户的会话列表远低于此比例,
故本项判定 **PASS**,**不新增 V46**(严格遵守指令)。
### 6.3 `using_temporary_table = true` 的归属
`true` 出现在 `grouping_operation` 层,是 `GROUP BY ranked.conversation_id` 的固有代价,
与派生表物化同源,**不是**新增索引可以消除的。
### 6.4 `using_filesort` 的更正(F3)
实施报告 §9 记录 `using_filesort=false`(其夹具为 1 会话 × 200 条)。
本轮在**带背景数据的真实规模夹具**下,1/20/100/600 四档**全部实测 `using_filesort=true`**——
来源是窗口函数 `ROW_NUMBER() OVER (PARTITION BY conversation_id ORDER BY created_at DESC)` 的排序。
这是**既有实现的固有代价**,本轮不改动,仅如实更正记录。
---
## 7. 副作用与隔离(任务 5)
独立测试 `R01EIndependentSideEffectIsolationTest`,运行在**共享的 integration H2 库**上(与实施测试同库,
因此可以直接观测实施测试的夹具残留)。
### 7.1 逐字段快照(**25 张表**,独立于实施测试的 11 张)
`users`(6 列)、`user_roles`、`companies`、`jobs`、`applications`、`application_status_history`、
`interviews`、`interview_status_history`、`offers`、`offer_status_history`、`candidate_feedback`、
`job_favorites`、`notifications`、`audit_logs`、`auth_tokens`、`conversations`、`conversation_members`、
`messages`(含 `version`)、`message_reads`、`student_profiles`、`student_preferences`、`resume_versions`、
`candidate_dimension_scores`、`candidate_scoring_appeals`、`candidate_scoring_appeal_history`
| 用例 | 结论 |
|---|---|
| `collector.collect()` 前后 | **25 张表逐字段零变化** ✔ |
| `listConversations()` 前后 | **25 张表逐字段零变化** ✔ |
| `listConversations + getConversation + collect` 组合 | **25 张表逐字段零变化** ✔ |
**读取路径零写**:夹具覆盖全部 12 个 section(collect 走满所有分支),快照仍零变化。
### 7.2 通知 / 审计 / RabbitMQ
| 项 | 结论 |
|---|---|
| `notifications` 行数与逐字段内容 | 读前后**完全相等** ✔ |
| `audit_logs` 行数与逐字段内容 | 读前后**完全相等** ✔ |
| **RabbitMQ** | 以 `@MockBean RabbitTemplate` 注入,读路径结束后 `verifyNoInteractions(rabbitTemplate)` **通过**——**零 AMQP 交互** ✔ |
| MQ 配置 | `mysql-it` / `integration-test` 均置 `app.notification.mq-enabled: false`、`spring.rabbitmq.enabled: false`,`NotificationConsumer` 条件装配关闭 ✔ |
### 7.3 反证:快照必须真的能发现变化
| 反证用例 | 期望 | 结果 |
|---|---|---|
| 修改一行(`users.nickname`) | 断言必须失败 | **AssertionError,含 `users`** ✔ |
| 新增一行(`notifications`) | 断言必须失败 | **AssertionError,含 `notifications`** ✔ |
| 删除一行(`message_reads`) | 断言必须失败 | **AssertionError,含 `message_reads`** ✔ |
| **仅单列变化**(`messages.status` → `RECALLED`) | 断言必须失败 | **AssertionError,含 `messages`** ✔ |
若无此反证,「零变化」可能只是**没在比**。
### 7.4 夹具零残留
| 检查 | 结果 |
|---|---|
| 本类自有夹具(`r01eiso-%`)退场后残留 | **0**(`@AfterEach` 精确清理 + `@AfterAll` 门禁)✔ |
| **实施测试的夹具(`r01e-%`,排除本类前缀)残留** | **0** ✔ |
| 实施测试遗留的 `messages` / `r01e-co` / `r01e-job` | **0** ✔ |
**关于此前「缺 `@AfterEach` 导致 60 项失败」的专项复验**:H2 内存库按 JVM 共享,
只有让 `R01EQueryBudgetAcceptanceTest` **先于**独立类运行,残留检查才具备鉴别力。
为消除运行顺序的不确定性,本轮**追加一次逆序运行**:
```
mvn -o -Dsurefire.runOrder=reversealphabetical -Dtest="R01EQueryBudgetAcceptanceTest,R01EMySqlQueryCountAndExplainTest,R01EIndependentCrossDatabaseVerificationTest,R01EIndependentSideEffectIsolationTest" test
→ 实际顺序:R01EQueryBudgetAcceptanceTest(11) → R01EMySqlQueryCountAndExplainTest(4)
→ R01EIndependentSideEffectIsolationTest(10) → R01EIndependentCrossDatabaseVerificationTest(16)
→ 独立复验:Tests run: 41, Failures: 0, Errors: 0, Skipped: 0
→ 提交前收口:Tests run: 42, Failures: 0, Errors: 0, Skipped: 0(新增 EXPLAIN 变异门禁)
```
即:实施测试跑完后,独立类的 `implementationFixturesLeaveNoResidue` 与 `@AfterAll` 门禁
**直接确认其夹具残留为 0**,旧问题已修复且可被独立观测。
---
## 8. OBS-E01(任务 6)
**观察项(本轮不改变既有语义,作为产品负责人待决策项上报)**:
> **本人发送且没有当前用户 `message_reads` 记录的消息,计入本人未读数。**
| 证据 | H2 | MySQL |
|---|---|---|
| 会话内「本人 1 条 + 他人 2 条,均未读」 | 未读 = **3**(不是 2) | **3** |
| 会话内「他人 1 条 + 本人 1 条 + 1 条撤回」 | 未读 = **2** | **2** |
| 把本人发送的那条写入 `message_reads` | 未读减为 **1** | **1** |
- 该行为源自未读查询的 `WHERE` 中**没有发送者过滤**(只按 `conversation_id` 与读者 `message_reads` 判定)。
- 本轮**未修改 SQL、未修改任何生产代码**,仅将既有语义**如实锁定并双侧取证**。
- 若产品确认应排除本人发送的消息,属**业务语义变更**,需另立批次,不在本次性能收束范围内。
---
## 9. 门禁结果(任务完成后的规定命令)
| # | 命令 | Tests | Failures | Errors | Skipped | 结果 |
|---|---|---|---|---|---|---|
| 1 | `mvn -Dtest="R01EQueryBudgetAcceptanceTest,R01EMySqlQueryCountAndExplainTest,R01EIndependentCrossDatabaseVerificationTest,R01EIndependentSideEffectIsolationTest" test` | **41** | 0 | 0 | **0** | SUCCESS |
| 1b | 同上 + `-Dsurefire.runOrder=reversealphabetical`(残留门禁) | **41** | 0 | 0 | **0** | SUCCESS |
| 2 | `mvn -Dtest="*PersonalData*Test,*Export*Test,*Messaging*Test,*Message*Test,*Conversation*Test,*Recall*Test" test` | **150** | 0 | 0 | **0** | SUCCESS |
| 3 | 独立复验 `mvn test` | **2787** | **0** | **0** | **0** | SUCCESS |
| 4 | 独立复验 `mvn clean verify` | **2787** | **0** | **0** | **0** | SUCCESS |
| 5 | 提交前收口 `mvn clean verify` | **2788** | **0** | **0** | **0** | SUCCESS |
| 5 | `python ..\docs\verify_openapi.py` | paths **176** / operations **214** | — | — | — | `All checks passed!` |
**测试计数对账**:独立复验为 `2761(R01-C 阶段二基线)+ 26(独立新增:16 跨库 + 10 副作用)= 2787`;
提交前收口再增加 1 个 EXPLAIN 变异门禁,最终为 **2788** ✔
(实施测试的 11 + 4 = 15 已含在 2761 内,本轮未新增实施测试。)
### 9.1 实施测试 / 独立测试 明细
| 类别 | 测试类 | 项数 | 引擎 |
|---|---|---|---|
| **实施测试** | `R01EQueryBudgetAcceptanceTest` | 11 | H2 |
| **实施测试** | `R01EMySqlQueryCountAndExplainTest` | 4 | **MySQL(Testcontainers)** |
| **独立测试** | `R01EIndependentCrossDatabaseVerificationTest` | 16 | **H2 + MySQL(同 JVM 双上下文)** |
| **独立测试** | `R01EIndependentSideEffectIsolationTest` | 10 | H2(共享 integration 库) |
| | **合计** | **41** | |
### 9.2 Testcontainers / MySQL **实际执行**证据
- Docker Server **29.6.2**;`mysql:8.4` 容器日志实测:`Container mysql:8.4 started in PT7.4093471S`。
- `R01EMySqlQueryCountAndExplainTest` 标注 `@Testcontainers(disabledWithoutDocker = true)`——
若 Docker 不可用该类的 4 项会被 **skip**;实测 **Skipped = 0**,故容器**真实启动并执行**。
- `R01EIndependentCrossDatabaseVerificationTest` 断言 `MySql.container.isRunning()` 为 `true`,
并在 MySQL 上真实执行了 Flyway 全量迁移、跨库深比较、`ANALYZE TABLE` 与 `EXPLAIN FORMAT=JSON`。
- 两类 MySQL 用例耗时分别为 **122.5 s** 与 **17.2 s**(`clean verify` 单次运行),与容器冷启动 + 万级夹具灌入相符。
---
## 10. 生产代码与契约不变性
| 项 | 结果 |
|---|---|
| 生产代码 diff | **0 个文件**(`git diff --stat` 空) |
| V1–V45 迁移 / Flyway | **未修改**(目录 `git status` 0 项,仍为 45 个版本文件) ✔ |
| `pom.xml` / 依赖 | **未修改** ✔ |
| `ErrorCode` | **未修改** ✔ |
| 公网接口 / DTO / OpenAPI 契约 | **未修改**(176 / 214) ✔ |
| `docs/openapi-rc.json` | **未修改** ✔ |
| 新增迁移 V46 | **未新增**(EXPLAIN 未发现必须新增索引的情形) ✔ |
| Security / Token / 权限 / requestId | **未修改** |
| 评分、排名、推荐、申诉、邀约、Offer、撤回与已读语义 | **未修改** |
| 分布式缓存 / 冗余未读列 / 异步最终一致未读数 | **未引入** |
---
## 11. Git 状态(未暂存 / 未提交 / 未推送)
| 项 | 结果 |
|---|---|
| 分支 / HEAD | `refactor/r01-structure-performance` / `b893ed67f078eedb0c4b8a03341311bd79342354`(**未变**) |
| HEAD 与 upstream | **0 / 0** ✔ |
| 暂存区 `git diff --cached --name-only` | **空** ✔ |
| 本轮新增(未跟踪) | 2 个独立测试类 + 本报告 |
| 既有未跟踪文件 | 完整保留,未删除、未移动 |
**确认:全程未执行 `git add` / `commit` / `push`,未执行 `reset` / `restore` / `checkout` / `clean`。**
---
## 12. 遗留风险
| # | 风险 | 说明 |
|---|---|---|
| R1 | **OBS-E01 语义** | 本人发送且无本人 `message_reads` 的消息计入本人未读数。既有实现如此,本轮未改;如需变更属业务语义变更,须另立批次 |
| R2 | **`created_at` 并列无 tiebreaker** | 未读窗口的 `ORDER BY m.created_at DESC` 无二级排序键。并列跨越 500 边界时「保留哪 500 条」由引擎决定(**计数恒为 500**,两侧一致)。`messages.created_at` 为秒级 `DATETIME`,高并发同秒写入可触发 |
| R3 | **派生表物化** | MySQL 对 `ROW_NUMBER()` 派生表物化,`ranked` 为派生结果顺序扫描(`access_type=ALL`),且 `using_temporary_table=true`、`using_filesort=true` 为固有代价。大会话(> 每表 6% 选择性)下 `messages` 转为全扫,属代价决策 |
| R4 | **测试自污染** | 本轮测试均按前缀精确清理;若将来新增同类夹具而不清理,仍可能污染共享 H2 库(R01-C 已建隔离门禁) |
| R5 | **实施测试断言鉴别力(F1)** | 原 EXPLAIN 断言为空断言;提交前收口已改为 JSON 结构断言,并以三类变异证明有效 |
---
> **R01-E独立代码与真实MySQL性能复验通过,可以进入精确暂存与独立提交检查点。**

81
docs/r01-structure-performance-design.md

@ -0,0 +1,81 @@
# R01 结构与性能收束权威设计
## 1. 目标与边界
R01 在 S01、S02、S03、Q01 已冻结行为之上收束重复代码和可证明的性能热点。所有改动必须保持 HTTP 契约、业务错误码、权限、事务、审计、隐私和数据副作用不变。
禁止在 R01 中新增业务功能、公开接口、Flyway、表或索引;禁止改变评分公式、推荐权重、百分位定义、消息未读定义、简历权限、Token 语义或岗位搜索语义。索引和全文搜索仅形成后续提案,不在本轮实施。
基线:分支 `refactor/r01-structure-performance`,起点 `77dc624`;OpenAPI 176 paths / 214 operations;全量测试 1890/1890。
## 2. 已确认问题
- 41 个生产文件包含私有身份解析方法;不同实现对匿名主体、非数字主体和溢出主体的处理不一致。
- 46 个生产文件包含私有 requestId 获取方法;部分直接取请求头,未复用 RequestIdFilter 的规范化结果。
- 角色断言以 `assertStudentRole`、`requireStudent`、`requireAdmin` 等形式分散,存在重复查用户和语义漂移风险。
- `GlobalExceptionHandler.resolveHttpStatus` 是大型 if 链,并含重复分支。
- `overviewStatus` 在评分引擎和竞争力排名中存在两套等价规则。
- `JobRecommendationService.preferenceScore` 被压缩为单行,权重含义难以评审。
- `CompanyScoringDashboardService.computePercentiles` 对每名候选人扫描全部分数,复杂度 O(n²)。
- `MessagingService.listConversations` 对每个会话分别加载最多 500 条消息和 read 状态,查询次数随会话数线性增长。
- `PersonalDataCollector` 存在可批量化的数据读取。
- 岗位关键字 `%keyword%` 与排序 filesort 是已记录风险,但未经产品和容量决策,不在 R01 直接改搜索技术栈。
## 3. 分批策略
### R01-A:请求身份与 requestId 基础设施
扩展既有 `CurrentUserAccessor`,使所有迁移入口统一拒绝 null、匿名、失效、空白、非 ASCII 数字、溢出和非正 userId。不得在 Controller 接受 userId 参数。
新增单一 `RequestIdAccessor`:优先读取 RequestIdFilter 已规范化的 request attribute;缺失时使用现有安全归一化逻辑,而不是信任任意长度请求头。响应、审计和日志必须使用同一 requestId。
按小批次迁移 Controller,每批最多 8 个类。每个调用点逐项锁定原 401/403 和资源归属语义;禁止机械全仓替换。
角色授权暂不与身份解析混在同一提交。先盘点后新增 `UserRoleGuard`,保持 USER_NOT_FOUND 与 FORBIDDEN 的现有差异,不增加隐式缓存。
### R01-B:异常映射和业务规则唯一来源
将业务错误码到 HTTP 状态的关系提取为不可变映射,启动或测试时检测重复和遗漏;默认仍为 400。不得更改任何现有映射。
提取唯一 `CandidateOverviewStatusPolicy`。评分引擎与竞争排名调用同一实现,并用参数化契约测试证明旧、新结果完全一致。
### R01-C:纯计算与可读性
将百分位改为“排序一次 + 分数组到 lower-rank 的映射”,复杂度 O(n log n)。并列分数必须获得相同百分位;null、小于 2 个有效分数、精度和 HALF_UP 规则保持不变。
拆分 `preferenceScore` 为命名明确的五个维度贡献函数。城市 30、用工类型 20、最低薪资 20、行业 15、岗位族 15 及缺失维度不计分的规则不得变化。
### R01-D:数据库查询收束
消息会话列表新增固定次数聚合查询,一次性按 conversationIds 计算未读数。定义保持:排除 RECALLED;发送者消息是否未读沿用当前 read-row 语义;不存在 read 行即未读。1/20/50 个会话查询次数必须固定。
`markRead` 是否突破 500 条属于独立语义问题,本轮先写失败证据和决策,不与列表未读优化混改。
PersonalDataCollector 按关联键批量读取;导出 JSON 字段、顺序、空值、脱敏与表范围不变。以 1/20/50 数据规模记录查询数和完整导出结果摘要。
### R01-E:真实环境复验
在 MySQL 8.4 上执行查询预算、结果等价和 EXPLAIN。不得把 H2 结果当成 MySQL 证据;不得为了 EXPLAIN 修改迁移或索引。
## 4. 关键契约
| 模块 | 必须保持 |
|---|---|
| 身份 | 非法主体为 401,不回显 principal,不产生业务写入 |
| 角色 | 原有 USER_NOT_FOUND/FORBIDDEN、角色组合与归属判断不变 |
| requestId | 最大长度、生成规则、响应头、响应体、MDC 与审计同源 |
| 异常 | 每个 ErrorCode 的 HTTP 状态与响应 body 不变 |
| 百分位 | `lower / (n-1) * 100`、并列同值、scale=2、HALF_UP |
| 推荐 | 五项权重和缺失项分母规则逐字保持 |
| 未读 | RECALLED 不计入;已有 read 行语义不变 |
| 导出 | 字段、隐私、顺序、内容与错误语义不变 |
## 5. 证据要求
每一批必须先建立特征测试或失败测试,再改生产代码。性能结论必须同时包含结果等价断言、SQL 次数和反证测试;仅写“无 N+1”不算证据。
自动化最低门槛:定向测试、关联回归、`mvn test`、`mvn clean verify`、静态 OpenAPI;Failures/Errors/Skipped 均为 0。涉及 SQL 的批次还必须有真实 MySQL 黑盒。
## 6. 提交边界
每个子阶段独立分支检查点或独立提交;不得将 R01-A 到 R01-E 压成一个大提交。所有历史未跟踪日志和验收产物保留但不提交。不得使用 `git add -A`、reset、restore、checkout 或 clean。

47
docs/resume-api.md

@ -48,7 +48,7 @@ POST /profile/me/resume-versions (multipart/form-data)
| 字段 | 必填 | 约束 |
|---|---|---|
| `file` | 是 | PDF/DOC/DOCX,≤10MB,扩展名与 Content-Type 一致 |
| `file` | 是 | PDF/DOC/DOCX,≤10MB;扩展名、Content-Type 与实际文件结构必须一致 |
| `name` | 否 | ≤120 字符,可空 |
默认状态 `ACTIVE`、**非默认**;版本号自动分配(`max+1`)。并发创建由 DB 唯一约束兜底,冲突自动重试(独立事务)。
@ -157,6 +157,10 @@ GET /resume-export-tasks/{taskId}/download
- 身份一律来自 JWT(`auth.getName()` = userId),**不信任请求体/路径中的 userId**;
- 不能读取其他用户的简历(owner 校验);
- 不返回内部堆栈;错误响应不泄露数据库结构。
- 上传文件名拒绝路径分隔符、控制字符和响应头换行;下载历史文件时再次净化文件名。
- PDF 校验文件头和结尾标记;DOC 校验 OLE/Word 文档结构标记;DOCX 校验 OOXML 必需条目,并限制 ZIP 条目数、解压总量和压缩比,拒绝路径穿越及压缩炸弹。
- 文件替换采用“新文件落盘 → 数据库更新 → 事务提交后删除旧文件”;回滚时清理新文件。底层本地存储先写 `.uploading` 临时文件,再原子移动到最终随机存储名,失败不暴露半文件。
- 上述措施属于格式与资源安全校验,**不等同于病毒查杀或沙箱检测**;生产部署仍应在存储接入层配置恶意文件扫描。
## 5. 异步导出设计
@ -182,7 +186,7 @@ GET /resume-export-tasks/{taskId}/download
前置:启动基础设施 → 启动应用 → 导入 `docs/openapi-rc.json`(或 `GET /api/v1/v3/api-docs`)。
1. 登录学生账号(Mock 验证码,见日志)。
1. 登录学生账号(Mock 验证码:日志不再输出,本地按 `docs/frontend-integration.md` §3 的受控方式取得;生产禁止查表)。
2. `POST /profile/me/resume-versions`(multipart:`file` + `name=秋招版`)→ 200,`versionNo=1`。
3. 再次上传 → `versionNo=2`;设置 `name=社招版`。
4. `GET /profile/me/resume-versions` → 列表含两个版本。
@ -199,6 +203,7 @@ GET /resume-export-tasks/{taskId}/download
4. **默认简历仅作用于版本体系**:不联动旧 `student_profiles.resume_url`(按确认保持独立)。
5. **被导出任务引用的旧文件可能延迟清理**:替换版本文件时,若旧文件正被 PENDING/RUNNING 导出任务引用则保留(保导出一致性);任务结束后这些文件不会自动回收,需后续清理策略。
6. **OpenAPI 快照**:`docs/openapi-rc.json` 由 H2 集成测试上下文导出(**49 路径**,server URL 为测试默认值),接入现网后可用 `GET /api/v1/v3/api-docs` 重新导出。
7. **不包含恶意软件扫描**:当前只执行文件格式、OOXML 结构、路径和资源消耗防护;不对宏、嵌入对象或恶意载荷作杀毒结论。
## 10. 错误码(新增 82xx)
@ -211,3 +216,41 @@ GET /resume-export-tasks/{taskId}/download
| 8205 | `DUPLICATE_DEFAULT_RESUME` | 409 | 并发设置默认冲突 |
| 8206 | `RESUME_EXPORT_NOT_READY` | 409 | 导出任务未完成,不可下载 |
## S03-B:企业下载候选人简历
### `GET /company/candidates/{applicationId}/resume`
新增的**唯一**企业侧简历读取入口。返回文件流,**不返回** `resumeUrl`、存储相对/绝对路径、
学生用户 ID、手机号或邮箱。
- 认证:`Authorization: Bearer <COMPANY 用户 Access Token>`
- 路径参数:`applicationId`(本企业岗位下的投递)
- 响应:二进制文件流;`Content-Type` 只取自上传时已校验的白名单
(`application/pdf`、`application/msword`、DOCX),否则回退 `application/octet-stream`
- 响应头:`Content-Disposition: attachment; filename*=UTF-8''<已净化文件名>`,
以及 `Cache-Control: no-store`(私有简历不进任何中间缓存)
- 文件名继续经过 `ResumeFilenameSanitizer`,拒绝路径分隔符、控制字符与响应头换行
### 错误语义
| 场景 | 结果 |
|---|---|
| 未登录 / 无有效会话 | 401 |
| 非 COMPANY 角色(STUDENT / ADMIN) | 403 |
| 当前用户没有企业资料 | 403 |
| 投递不存在 | 404 `APPLICATION_NOT_FOUND` |
| 跨企业投递 | 403 |
| 隐私设置不允许(含 PRIVATE / 无设置) | 403 `FORBIDDEN` |
| 授权通过但学生没有简历 | 404 `PROFILE_NOT_FOUND` |
### 授权与副作用
- 授权顺序:当前 COMPANY 用户 → 当前 Company → Application+Job → `Job.companyId` →
Student → privacy setting → profile/file。归属信息全部由服务端推导,
不接受客户端传入的 `companyId`/`studentUserId`。
- **拒绝路径不会调用存储读取**(测试以计数包装器验证为零次)。
- 该接口只读:不修改 `student_profiles`、`resume_versions`、投递、评分、排名、推荐、邀约、
消息、通知或风险表;不发送 RabbitMQ、短信或邮件。
- 学生本人的上传、下载、版本与导出接口语义完全不变。
- 企业候选人列表/详情 DTO **仍不含** `resumeUrl`。
- 不新增按 `studentUserId` 或 `candidateKey` 下载简历的入口;人才发现本轮不得直接下载简历。

119
docs/riasec-assessment-api.md

@ -0,0 +1,119 @@
# RIASEC 职业兴趣测评 API(功能版本 **V35**,Flyway **V34__riasec_assessment.sql**)
基于 RIASEC 理论的自研题库,仅供职业兴趣探索参考,不构成心理或职业诊断。
- **题库**:72 道(R/I/A/S/E/C 各 12),SELF_AUTHORED,reverseScored 恒为 false
- **每次答卷**:后端从六维中各随机抽取 5 道,共 30 道并持久化,刷新页面不重新抽题
- **量表**:LIKERT_5 = 1 完全不感兴趣 … 5 非常感兴趣
- **计分**:rawScore = 5 道之和,maxScore = 25,percentage = rawScore/25×100(HALF_UP, scale=2)
- **排序**:percentage DESC,同分按维度字母升序;前三位组成 `primaryCode`
- **结果**:仅 RIASEC 六维聚合分 + 排名 + 探索方向,不存储原始答案
- **探索方向**:career-direction-v0.1,仅"可能感兴趣的职业探索方向"
- **只读/脱敏**:不写 candidate_dimension_scores;结果/审计不含原始题面、答案、手机号、邮箱、JWT
## 权限
| 接口 | STUDENT | COMPANY | ADMIN | 未登录 |
| --- | --- | --- | --- | --- |
| 测评类型 GET /assessments/types | 200 | 200 | 200 | 401 |
| 题目 GET /assessments/{type}/questions | 200 | 200 | 200 | 401 |
| 创建答卷 POST /assessments/attempts | 200 | 403 | 403 | 401 |
| 答卷详情/保存/提交/结果 | 仅本人 | 403 | 403 | 401 |
| 我的历史 GET /assessments/me | 仅本人 | 403 | 403 | 401 |
身份一律从 JWT 获取,不接受 userId 参数。
## 错误码(97xx)
| code | 含义 | HTTP |
| --- | --- | --- |
| 9701 | ASSESSMENT_TYPE_NOT_FOUND | 404 |
| 9702 | ASSESSMENT_ATTEMPT_NOT_FOUND | 404 |
| 9703 | ASSESSMENT_ATTEMPT_INVALID_STATE | 409 |
| 9705 | ASSESSMENT_ANSWER_INVALID | 400 |
| 9706 | ASSESSMENT_INCOMPLETE | 400 |
| 9707 | ASSESSMENT_CONCURRENT_UPDATE | 409 |
| 9708 | ASSESSMENT_DUPLICATE_ACTIVE | 409 |
| 9709 | ASSESSMENT_IDEMPOTENCY_CONFLICT | 409 |
重复提交不抛错,直接幂等返回成功(9704 预留,未使用)。
## 接口
### GET /api/v1/assessments/types
返回全部 ACTIVE 测评定义(typeCode、displayName、description、instructions、scaleType、questionCount=72、attemptQuestionCount=30、minPerDimension=5、questionSetVersion、rulesVersion)。
### GET /api/v1/assessments/HOLLAND_RIASEC/questions
返回指定测评的 ACTIVE 题目(按 position;类型非 ACTIVE 或不存在 → 404/9701)。对外隐藏 `dimension`、`reverseScored`、`source`、`questionVersion`、`questionCode`,仅返回:
```json
{ "id": 301, "position": 1, "stem": "我对使用手工工具制作物品感兴趣。", "scaleType": "LIKERT_5" }
```
**不返回 `questionCode`**:题目内部编码(如 R01)前缀携带维度信息,一律不外露,前端不得用于推导 R/I/A/S/E/C 维度;维度归属仅由服务端计分使用。
### POST /api/v1/assessments/attempts
仅 STUDENT 且类型须为 **ACTIVE**(INACTIVE/不存在 → 404/9701)。请求:`{ "typeCode": "HOLLAND_RIASEC", "idempotencyKey": "uuid" }`
返回 attemptId、status、totalQuestions(30)、answeredCount、completionRate、questionSetVersion、version、本次 30 道题。不返回 samplingSeed。
**抽题严格约束**:六维各恰好 5 道、总数严格 30;任一维度 ACTIVE 题不足 5 道或六维不齐 → 拒绝创建(400),不静默减题。
**单活跃答卷(并发原子)**:同一学生同一类型同时最多一份 DRAFT/IN_PROGRESS。由数据库唯一约束 `uk_assessment_attempts_active` 原子兜底:
- 竞争失败的并发创建请求返回 `409/9708`(不得返回 500);
- 同一 `idempotencyKey` 的并发重放返回已创建的原 attempt(幂等 `200`,同一 attemptId);
- 仅数据库靠唯一约束判定归属,服务端不再复用被污染的 Hibernate Session 查询,
写入与冲突判定分别使用独立事务/`REQUIRES_NEW`,创建外层显式 `READ_COMMITTED` 以读取已提交数据。
- 答完提交(COMPLETED)后释放 `active_key`,允许重新测评。
### GET /api/v1/assessments/attempts/{attemptId}
仅本人。返回 30 道题(固定 position)、已保存答案、answeredCount、completionRate、version、当前状态。
### PATCH /api/v1/assessments/attempts/{attemptId}/answers
请求:`{ "version": 2, "answers": [ { "questionId": 301, "value": 4 } ] }`
支持单题/批量;同题重复保存 upsert;**同一请求内同题不得重复出现(重复 → 400/9705)**;value 1-5;题目必须属于本答卷;版本冲突 409/9707;首次保存后状态置 IN_PROGRESS。
### POST /api/v1/assessments/attempts/{attemptId}/submit
- answeredCount < 30 → 400/9706
- 30 题(每维各 5)→ COMPLETED,生成结果,写审计,返回结果
- 重复提交 → 200 返回原结果,不重复生成结果与审计
- 不写入候选人评分表
### GET /api/v1/assessments/attempts/{attemptId}/result
答卷 COMPLETED 后返回:
```json
{
"attemptId": 101,
"typeCode": "HOLLAND_RIASEC",
"status": "COMPLETED",
"primaryCode": "SIC",
"primaryType": "S",
"dimensionScores": [ { "dimension": "S", "score": 88.00, "answeredCount": 5 } ],
"typeRanking": ["S", "I", "C", "R", "A", "E"],
"answeredCount": 30,
"completionRate": 100.00,
"questionSetVersion": "riasec-self-v0.2",
"rulesVersion": "riasec-scoring-v0.1",
"generatedAt": "2026-08-28T16:00:00",
"disclaimer": "本结果仅供职业兴趣探索参考,不构成心理或职业诊断。",
"explorationDirections": ["教育辅导", "用户运营", "客户成功", "职业咨询"]
}
```
不返回 studentUserId、phone、email、password、原始答案、企业信息、其他用户信息。
**越权:答卷题目、已保存答案与结果均仅本人(STUDENT)可读;COMPANY / ADMIN 越权访问一律 `403/2002`。**(来源:权限矩阵)
### GET /api/v1/assessments/me?page=0&size=20
分页返回当前学生的答卷与结果概要。
## 数据库(V34__riasec_assessment.sql,新增 6 张表)
- `assessment_definitions`:测评定义(HOLLAND_RIASEC)
- `assessment_questions`:72 题,`UNIQUE(definition_id, question_code)`、`UNIQUE(definition_id, position)`、`CHECK(dimension IN R/I/A/S/E/C)`、`CHECK(reverse_scored IN 0,1)`
- `assessment_attempts`:答卷主记录,`@Version` 乐观锁,`UNIQUE(student_user_id, idempotency_key)`、`UNIQUE(active_key)`(单活跃原子约束,DRAFT/IN_PROGRESS 非空,终态置 NULL)、`CHECK(status IN DRAFT/IN_PROGRESS/COMPLETED/EXPIRED)`
- `assessment_attempt_questions`:本次抽中的 30 题,`UNIQUE(attempt_id, question_id)`、`UNIQUE(attempt_id, position)`
- `assessment_answers`:答案,`answer_value` 1-5(CHECK),`UNIQUE(attempt_id, question_id)`(API 层字段仍为 `value`)
- `assessment_results`:结果,`attempt_id` 唯一(幂等)。
`dimension_scores_text` / `type_ranking_text` 使用 TEXT 保存 JSON 字符串,兼容 MySQL 8 与 H2 MODE=MySQL。答案列使用 `answer_value`(避免 H2 保留字 `value`,该列名在 MySQL 与 H2 MODE=MySQL 下均兼容)。

253
docs/risk-governance-api.md

@ -0,0 +1,253 @@
# V37 防刷风险概览与运营规则配置
> 状态:**已完成并通过真实 MySQL/Docker 黑盒验收(PASS=76 / FAIL=0 / SKIP=0)**。
> 功能版本:V37;Flyway:`V36__risk_governance.sql`。
> 基线:OpenAPI 148 paths / 182 operations;V35、V36、V37 待建立联合 Git 检查点。
## 1. 目标与边界
本模块为 ADMIN 提供确定性、可解释的防刷风险概览与规则配置能力。它将既有拒绝型安全审计事件转为可聚合的风险事件,供运营人员查看趋势、分布、明细及进行人工标记。
本版只做以下内容:
- 基于既有 `audit_logs` 的规则型风险检测;
- ADMIN 风险概览、趋势、分布、事件列表;
- ADMIN 启停或调整阈值、窗口、等级;
- ADMIN 对风险事件做无自由文本的人工标记;
- 全链路审计、脱敏、幂等扫描与并发保护。
明确不做:
- 自动封禁、自动警告、自动限制投递/消息/评分;
- AI 风控、设备指纹、地理位置、IP 归因、手机号或邮箱展示;
- 修改 V33 举报限流、屏蔽语义,或修改 V35/V36 测评和评分引擎;
- 企业端、学生端风险事件查询;
- 对历史 `audit_logs.details` 的回填、修复或删除。
## 2. 现有能力与输入范围
现有 `SecurityAuditService` 已以独立事务写入拒绝型安全审计,当前可安全复用且可关联用户的输入只有:
| 审计 action | 来源 | 初始规则用途 |
| --- | --- | --- |
| `REPORT_RATE_LIMITED` | V33 举报第 11 次及以后被拒绝 | 一次命中即 HIGH 风险 |
| `BLOCKED_SEND_PROHIBITED` | V33 屏蔽关系下的新建私聊或发消息被拒绝 | 窗口内多次命中为 MEDIUM 风险 |
`SMS_RATE_LIMITED` 当前不具备安全审计中的用户主体,且原始手机号不能进入风险库;本版不纳入。后续如需纳入,必须单独设计不可逆主体哈希和密钥治理。
风险计算只读取 `audit_logs` 的 `id/user_id/action/entity_type/entity_id/request_id/created_at`,不复制 `details/ip_address/user_agent`。
## 3. 风险规则
规则是确定性阈值条件:在 `windowMinutes` 滑动窗口内,某用户同类安全审计数量达到 `thresholdCount` 后,生成一条风险事件。事件是提醒,不改变业务权限。
初始种子规则:
| ruleCode | sourceAction | thresholdCount | windowMinutes | severity | 默认启用 |
| --- | --- | ---: | ---: | --- | --- |
| `REPORT_RATE_LIMIT_BURST` | `REPORT_RATE_LIMITED` | 1 | 60 | HIGH | true |
| `BLOCKED_MESSAGE_BURST` | `BLOCKED_SEND_PROHIBITED` | 3 | 15 | MEDIUM | true |
规则更新只影响后续扫描;不回溯重算、不会删除既有风险事件。若启用后需重新观察历史,应在未来另行设计“回放”操作,避免误产生大量旧告警。
## 4. 数据模型与 Flyway
迁移文件为 `V36__risk_governance.sql`。V36 是功能版本号,Flyway 当前最新迁移为 V35,因此使用严格连续的 V36。
### 4.1 `scoring_rule_configs`
| 字段 | 约束/用途 |
| --- | --- |
| `id` | PK |
| `rule_code` | `UNIQUE`,稳定规则标识 |
| `source_action` | 审计 action 白名单 |
| `threshold_count` | 1–100 |
| `window_minutes` | 1–1440 |
| `severity` | `LOW/MEDIUM/HIGH` |
| `enabled` | 是否参与后续扫描 |
| `version` | `@Version` 乐观锁 |
| `created_at/updated_at` | 审计时间 |
### 4.2 `scoring_risk_events`
| 字段 | 约束/用途 |
| --- | --- |
| `id` | PK;对外只返回此事件 ID |
| `rule_config_id` | FK → `scoring_rule_configs` |
| `subject_user_id` | 内部关联用户,**所有 API 响应均不返回** |
| `source_audit_log_id` | 触发本事件的审计记录;与 `rule_config_id` 联合唯一,保证扫描幂等 |
| `source_action` | 冗余快照,支持稳定统计 |
| `severity` | 生成时规则等级快照 |
| `window_started_at/window_ended_at` | 命中统计窗口 |
| `occurrence_count` | 命中数量 |
| `status` | `OPEN/ACKNOWLEDGED/DISMISSED` |
| `detected_at` | 检测时间 |
| `version` | 乐观锁 |
索引:`(detected_at,status)`、`(severity,detected_at)`、`(rule_config_id,detected_at)`、`(subject_user_id,detected_at)`。
### 4.3 `scoring_risk_actions`
风险事件人工操作历史。为防止运营备注成为新的个人信息载体,本版不提供自由文本 `note`。
| 字段 | 约束/用途 |
| --- | --- |
| `id` | PK |
| `risk_event_id` | FK → `scoring_risk_events` |
| `operator_user_id` | ADMIN 操作人,响应不返回 |
| `action` | `ACKNOWLEDGE/DISMISS` |
| `reason_code` | `MONITORING/CONFIRMED_FALSE_POSITIVE/OUT_OF_BAND_ESCALATED` |
| `created_at` | 操作时间 |
### 4.4 `scoring_risk_scan_checkpoints`
持久化扫描游标,解决应用重启、暂停或扫描耗时导致的审计事件遗漏问题。该表只保留一条 `checkpoint_key=RISK_EVENT_DETECTOR` 的记录。
| 字段 | 约束/用途 |
| --- | --- |
| `id` | PK |
| `checkpoint_key` | `UNIQUE`,固定为 `RISK_EVENT_DETECTOR` |
| `last_audit_log_id` | 已成功处理的最大 `audit_logs.id`,默认 0 |
| `version` | 乐观锁 |
| `updated_at` | 最近成功推进时间 |
迁移同时为 `audit_logs` 新增 `(action, user_id, created_at, id)` 复合索引,仅优化风险扫描,不改变任何既有审计语义。
## 5. 检测与状态机
### 5.1 扫描器
`RiskEventDetector` 使用 `@Scheduled` 固定延迟扫描(默认 60 秒,可配置 `app.risk.scan.fixed-delay-ms`),其核心 `scanOnce()` 必须可被测试直接调用。每轮:
1. 在一个事务中以悲观锁或等价机制锁定 `RISK_EVENT_DETECTOR` checkpoint;
2. 按 `audit_logs.id > last_audit_log_id`、升序、每批最多 500 条读取新增审计;仅处理 `user_id` 非空且 action 在规则白名单内的记录;
3. 读取全部 `enabled=true` 的规则。已禁用规则对应的新增审计会随游标跳过;重新启用不回放历史;
4. 以“当前审计记录时间 - windowMinutes”为窗口,统计同一用户、同一 action 的命中数;
5. **仅当命中数恰好等于 `thresholdCount`** 时生成事件,避免阈值之后的每次操作都刷出一条风险事件;
6. 按 `(rule_config_id, source_audit_log_id)` 原子插入事件;唯一键冲突视为已处理,不报错;
7. 成功处理整批后,在同一事务推进 `last_audit_log_id`;任何处理失败必须回滚,游标不得前进;
8. 写入 `RISK_EVENT_DETECTED` 审计,但 details 仅包含 `riskEventId/ruleCode/severity/occurrenceCount/requestId`。
扫描器不处理无用户主体的审计,也不会发送通知、修改用户状态或调用评分引擎。首版允许最长约 60 秒可见延迟。
### 5.2 事件状态机
```text
OPEN ──ACKNOWLEDGE──> ACKNOWLEDGED
OPEN ──DISMISS──────> DISMISSED
ACKNOWLEDGED ──DISMISS──> DISMISSED
```
`DISMISSED` 为终态;不能重新打开。每次合法状态变更新增一条 `scoring_risk_actions` 记录,并写 `RISK_EVENT_ACTIONED` 审计。
## 6. 接口契约(仅 ADMIN)
除 `401` 外,所有非 ADMIN 情况统一 `403/2002`。Controller 从 JWT `SecurityContext` 取身份,禁止接受 `adminUserId/operatorUserId/userId` 参数。
| 方法 | 路径 | 说明 |
| --- | --- | --- |
| GET | `/admin/scoring/risk/overview` | 时间范围内 OPEN 总数、等级分布、规则分布、最近检测时间 |
| GET | `/admin/scoring/risk/trend` | 按 `HOUR/DAY` 聚合的事件趋势 |
| GET | `/admin/scoring/risk/distribution` | 按规则和风险等级聚合的事件分布 |
| GET | `/admin/scoring/risk/events` | 分页事件列表;可按 `status/severity/ruleCode/from/to` 筛选 |
| GET | `/admin/scoring/risk/rules` | 规则列表与当前版本 |
| PUT | `/admin/scoring/risk/rules/{ruleCode}` | 更新规则,须提供 `expectedVersion` |
| POST | `/admin/scoring/risk/events/{eventId}/actions` | `ACKNOWLEDGE/DISMISS` 人工操作 |
预期 OpenAPI:`141 → 148 paths`,`175 → 182 operations`。
### 6.1 规则更新请求
```json
{
"enabled": true,
"thresholdCount": 3,
"windowMinutes": 15,
"severity": "MEDIUM",
"expectedVersion": 2
}
```
不允许修改 `ruleCode` 或 `sourceAction`,避免配置被用于监听未批准的审计类型。
### 6.2 事件操作请求
```json
{
"action": "ACKNOWLEDGE",
"reasonCode": "MONITORING",
"expectedVersion": 0
}
```
### 6.3 事件响应白名单
事件列表或详情只含:`eventId/ruleCode/sourceAction/severity/status/occurrenceCount/windowStartedAt/windowEndedAt/detectedAt/version`。
严禁含:`subjectUserId/phone/email/ipAddress/userAgent/requestId/details/Token/password/设备指纹`。
## 7. 错误码
新增 99xx 段:
| code | 名称 | HTTP |
| ---: | --- | ---: |
| 9901 | `RISK_RULE_NOT_FOUND` | 404 |
| 9902 | `RISK_EVENT_NOT_FOUND` | 404 |
| 9903 | `RISK_RULE_CONCURRENT_UPDATE` | 409 |
| 9904 | `RISK_EVENT_CONCURRENT_UPDATE` | 409 |
| 9905 | `RISK_INVALID_ACTION` | 409 |
| 9906 | `RISK_INVALID_FILTER` | 400 |
复用:未登录 `401/2001`、无权限 `403/2002`、分页或时间参数错误 `400/1001`。
## 8. 查询、脱敏与性能
- 全部 overview/trend/distribution 使用 SQL `GROUP BY` 投影,不在 Java 循环中逐事件查询规则或用户,避免 N+1;
- 事件列表分页 `page=0` 起、`size=1–50`;空结果为 `200 + items=[]`;
- 默认时间范围为最近 7 天,最大查询范围 90 天;`from > to` 或超出 90 天返回 `9906`;
- 对外 DTO 均使用字段白名单;不返回 `subject_user_id`;
- 不复制 `audit_logs.details/ip_address/user_agent` 至风险事件;
- 规则更新和事件操作均乐观锁;冲突不覆盖对方操作;
- 定时扫描使用 checkpoint 断点续扫;事件唯一键保证重复扫描不会重复产生事件,也不会因服务停机而漏掉未处理审计。
## 9. 审计
| action | 触发点 | details 白名单 |
| --- | --- | --- |
| `RISK_EVENT_DETECTED` | 系统检测到阈值事件 | `riskEventId/ruleCode/severity/occurrenceCount` |
| `RISK_RULE_UPDATED` | ADMIN 更新规则 | `ruleCode/enabled/thresholdCount/windowMinutes/severity/version` |
| `RISK_EVENT_ACTIONED` | ADMIN 标记风险事件 | `riskEventId/action/reasonCode/version` |
审计 details 不允许写入用户身份、原始审计 details、IP、浏览器信息、请求正文或任何凭据。
## 10. 实现与测试计划
新增:4 Entity、4 Repository、`RiskEventDetector`、`RiskGovernanceService`、`AdminRiskController`、请求/响应 DTO、`V36__risk_governance.sql`、错误码与 OpenAPI 文档。
测试:
- 单元:阈值、滑动窗口、规则禁用、仅阈值首次命中生成事件、checkpoint 断点续扫、扫描失败游标不前进、唯一键幂等、状态机、乐观锁、脱敏;
- MockMvc:ADMIN 成功、STUDENT/COMPANY 403、匿名 401、分页/时间参数、响应不含敏感字段;
- H2 持久化:Flyway、四表 FK/UNIQUE/CHECK、扫描重复执行只生成一条事件、checkpoint 并发锁、规则并发更新;
- MySQL/Docker 黑盒:插入本轮独立的安全审计夹具 → 等待扫描 → 验证概览/趋势/分布/事件/规则/人工操作;验证无自动封禁、无 `candidate_dimension_scores` 写入、队列排空,并回归 V33/V35/V36。
## 11. 验收与冻结条件
1. 专项与全量测试全绿;
2. `verify_openapi.py` 静态路径/操作数校验通过;
3. 真实 MySQL Flyway V36 成功,三表/索引/约束可查;
4. Docker 黑盒 `FAIL=0`,关键权限、去重、脱敏、队列、回归项不得 SKIP;
5. 风险事件不会改变用户状态、投递、消息、评分或候选人维度分;
6. 经负责人审阅精确提交范围后,才与 V35/V36 一并建立 Git 检查点。
## 12. 待负责人确认
建议按本稿锁定以下选择后再编码:
1. 规则输入首版仅限 `REPORT_RATE_LIMITED` 与 `BLOCKED_SEND_PROHIBITED`;
2. 风险事件 API 不返回主体用户 ID,运营通过 `eventId` 处理,不做自动处置;
3. 保留 `POST /admin/scoring/risk/events/{eventId}/actions`,因此接口增量为 +7/+7;
4. 风险事件人工操作仅允许受控 `reasonCode`,不支持自由文本备注;
5. 使用 checkpoint 驱动的定时扫描(默认 60 秒)而非把风险检测同步耦合到业务请求;
6. 使用 `V36__risk_governance.sql` 与 9901–9906 错误码。

483
docs/s01-independent-mysql-acceptance-20260911.md

@ -0,0 +1,483 @@
# S01 认证安全上线阻断整改 · 独立代码复验与真实 MySQL/Docker 黑盒验收报告
- **验收日期**:2026-09-11
- **验收性质**:独立验收(非功能开发);使用真实 MySQL 8、最新 JAR、真实 HTTP 请求
- **验收结论**:**FAIL**(已复现具体缺陷,S01 不可冻结)
- **报告文件**:`docs/s01-independent-mysql-acceptance-20260911.md`
- **验收产物目录**:`docs/acceptance/s01-20260911/`(脚本、请求体、日志、结果快照)
> 本报告不包含数据库密码、.env 内容、JWT、验证码、完整手机号、明文测试口令与非法 principal 敏感标记原文。
---
## 1. Git 基线
| 项 | 实测值 |
|---|---|
| 分支 | `fix/resume-upload-safety` |
| HEAD | `cdafbf8ac93544a3ef89d0f3014f822a0d575f8f`(docs: update README for latest delivery baseline) |
| 最近 5 次提交 | cdafbf8 / 277b3c0 / dbd27e6 / cf39737 / 498512d |
| 暂存区 | **空**(未执行 `git add`) |
| `git diff --check` | 无空白错误(仅 CRLF 提示) |
| Flyway 迁移改动 | **无**(V1–V41 原样,共 41 个迁移文件,最大版本 41) |
| 新增迁移 | **无** |
| 工作区修改 | 20 个(S01 8 个 + 简历上传安全 12 个,均未提交) |
| 未跟踪文件 | 基线 106 个,全部保留;本轮新增验收产物 |
| AGENTS.md | **未发现**(仓库根、上级目录、`.claude/` 均无 AGENTS.md/CLAUDE.md/GEMINI.md/.cursorrules) |
| `.env` | 保留(365B) |
**与 S01 无关的未提交修改(简历上传安全)已完整保护**:`profile/ProfileController.java`、`profile/ProfileService.java`、`profile/storage/LocalResumeStorageService.java`、`resume/ResumeVersionController.java`、`resume/ResumeVersionService.java`、`docs/frontend-integration.md`、`docs/resume-api.md` 及 5 个测试文件——本轮**未读取改写、未覆盖**,时间戳仍为 2026-09-10。
---
## 2. 独立代码审查结果(阶段 A)
### A1 验证码/手机号日志 —— **PASS**
| 检查项 | 结果 | 证据 |
|---|---|---|
| INFO/WARN/ERROR 不输出验证码 | ✅ | `SmsVerificationService` 仅 2 处 `log.warn`,参数为 `maskPhone(phone)`;`MockSmsCodeSender` 仅 1 条无参固定文本 |
| 不输出完整手机号 | ✅ | 实测 7 个验收手机号在全部日志中命中 **0** 次 |
| Mock 实现仅固定安全文本 | ✅ | `log.info("SMS verification code generated (mock sender)")` |
| Service 不把验证码传入日志参数 | ✅ | 全量检索无 `log.*code` 参数传递 |
| AuthService 不输出完整手机号 | ✅ | 仅 `log.info("Auto-created user: id={}")` |
| JWT 过滤器不记录 Token/subject/异常原文 | ✅ | 3 条固定文本,无 `e.getMessage()`、无 subject |
| 审计 details 不含完整手机号/验证码 | ✅ | 实测唯一 SMS_SEND 明细为 `phone=199****9999`(脱敏) |
| 异常消息不含敏感值 | ✅ | `SERVICE_UNAVAILABLE` 固定文本 `sms sender unavailable` |
| 不通过 toString/getMessage 间接泄露 | ✅ | 捕获的异常与堆栈均不含非法 principal 标记 |
**唯一敏感标记复验**(真实 Logback 捕获 + 真实运行日志双通道):标记手机号、真实验证码(3 个 DB 实际值)、JWT 前缀 `eyJ`、`Bearer ey`、mock 正文、非法 principal 标记、`NumberFormatException` —— 命中数**全部为 0**。
### A2 短信发送器装配 —— **PASS(3 种配置)+ 发现 1 处边界缺陷(见 F3)**
| 检查项 | 结果 |
|---|---|
| Service 只依赖 `SmsCodeSender` 接口 | ✅ 单构造参数注入,无 if 分支复制双流程 |
| `true` → 仅 `MockSmsCodeSender` | ✅ 实测(`SmsSenderConfigurationTest` 4/4 + 运行期行为) |
| `false` → 仅 `UnavailableSmsCodeSender` | ✅ 实测 |
| 缺失 → 默认 `UnavailableSmsCodeSender` | ✅ 实测(D3a) |
| 单一 Bean / 无装配歧义 | ✅ 三次启动均成功(缺/多 Bean 会直接启动失败) |
| mock=false 不回退 Mock | ✅ 实测无 mock 事件 |
| 生产不意外启用 Mock | ✅ `application.yml` 默认 `SMS_MOCK_ENABLED:false` |
| `@ConditionalOnProperty` 精确性 | ✅ Mock=`havingValue="true"`(**无** matchIfMissing);Unavailable=`havingValue="false", matchIfMissing=true`;name 与配置项 `app.sms.mock.enabled` 完全一致 |
### A3 mock=false 失败事务 —— **PASS(MySQL 回滚)+ 1 项非阻断观察(F4)**
- `@Transactional` 位于 `sendCode`;顺序:校验 → 锁定/限流 → 生成 → **`codeRepository.save`** → Redis 限流 → **`smsCodeSender.sendVerificationCode`** → 审计
- `BusinessException extends RuntimeException` → 默认回滚;发送异常**未被捕获**,不转换为空成功
- 真实 MySQL 实测:失败后 `sms_verification_codes` 完整字段前后**完全一致**(见第 6 节)
- 观察项:限流键在发送**之前**写入,失败后残留 60s(既有排序,非 S01 引入)→ F4
### A4 `CurrentUserAccessor` —— **PASS**
拒绝:null / `!isAuthenticated` / `AnonymousAuthenticationToken` / 空或空白 name / `anonymousUser` / 非 ASCII 纯数字(含 `+42`、`" 42"`、`"42 "`、Unicode 数字)/ Long 溢出 / `userId<=0`。
固定抛 `InsufficientAuthenticationException`(既有 `@ExceptionHandler(AuthenticationException)` → **HTTP 401**),**不回显、不记录**非法 principal,不新增 ErrorCode,不承担角色/归属判断,身份仅来自 SecurityContext。
### A5 `JwtAuthenticationFilter` —— **PASS**
合法数字 subject 正常认证;缺失 Authorization → 匿名;无效签名/过期 → 既有 401;非数字 subject → **401 而非 500**;日志无 Token/subject/异常原文;`JwtTokenProvider` 未改动(Token 兼容语义不变);**未声称修复 Refresh Token**。
### A6 `/jobs` 安全规则 —— **PASS**
`SecurityConfig:119` 由 `.requestMatchers("/jobs").permitAll()` 改为 `.requestMatchers(HttpMethod.GET, "/jobs").permitAll()`,且该规则**先于** `.requestMatchers("/jobs/**").authenticated()` 求值,写请求确实命中 authenticated。
**全部 /jobs 映射与最终权限(21 个 operation,真实 HTTP 实测)**
| 方法 | 路径 | 匿名 | STUDENT | COMPANY | ADMIN | 归属要求 |
|---|---|---|---|---|---|---|
| GET | `/jobs` | **200** | 200 | 200 | 200 | 无 |
| GET | `/jobs/search`、`/jobs/filters` | **200** | 200 | 200 | 200 | 无 |
| GET | `/jobs/public/{id}` | **200** | 200 | 200 | 200 | 无 |
| GET | `/jobs/{id}/reviews`、`/summary`、`/{reviewId}`、`/jobs/compare` | **200** | 200 | 200 | 200 | 无 |
| POST | `/jobs` | **401** | **403/2002** | **200** | **403/2002** | 需绑企业 |
| PUT | `/jobs/{id}` | **401** | 403/2002 | 200 | 403/2002 | **本企业**(他企业 400/7002) |
| PATCH | `/jobs/{id}/status` | **401** | 403/2002 | 200 | 403/2002 | **本企业**(他企业 400/7002) |
| DELETE | `/jobs/{id}` | **401** | — | — | — | 无该端点 |
| GET | `/jobs/{id}` | **401** | 403/2002 | 200 | 403/2002 | 本企业 |
| GET | `/jobs/{id}/match` | **401** | — | — | — | 登录 |
| GET | `/jobs/{id}/competition-ranking` | **401** | — | — | — | 登录 |
| POST/DELETE | `/jobs/{id}/favorite`、GET `/jobs/favorites` | **401** | — | — | — | 登录 |
| GET | `/jobs/{id}/personalized-review-score` | **401** | — | — | — | 登录 |
**不依赖 Controller 解析 anonymousUser 失败来保护写接口**:匿名请求在安全层即被拒(响应无 `requestId` 字段,符合既有 `AuthenticationEntryPoint` 固定载荷)。
### A7 剩余直接身份解析 —— **PASS(无 permitAll 可达)= 但存量 43 处**
- 全量检索(任意变量名):`Long.(valueOf|parseLong)(*.getName())` 共 **43 处**,分布于 43 个文件
- `.isAuthenticated()` 共 44 处;`anonymousUser` 字面量 5 处(`PersonalDataExportController`、`DeletionGuardInterceptor`×2、`AdminReviewReplyController`、`CompanyReviewManagementController`)
- **逐端点核验 permitAll 可达性**(按 SecurityConfig 匹配顺序,而非"路径要求认证"默认安全):
- permitAll 路径共 19 条规则,涉及的控制器为 `AuthController`、`CompanyController`、`JobReviewController`、`JobController`、`MatchController`、`JobRecommendationController`
- **匿名可达的 8 个 GET 方法**(`listJobs`、`searchJobs`、`getFilters`、`getPublicJob`、`list`、`get`、`summary`、`compare`)**均不调用身份解析**
- `CompanyController` 的 `resolveUserId` 仅被 `/companies/me`(POST/GET/PUT)使用 → authenticated;`JobReviewController` 的 `resolveUserId` 仅被 create/update/delete 使用 → authenticated
- **结论:43 处中 0 处处于 permitAll 可达路径**,不构成 S01 发布阻断。`JobController` 已改用 `CurrentUserAccessor`(裸解析 0 处)
- 存量风险提示:`DeletionGuardInterceptor:51` 使用 `chars().allMatch(isDigit)` 预检,可挡 `anonymousUser`,但对 **20 位纯数字溢出**仍会抛 `NumberFormatException`(需合法签名 Token 才能到达)→ 列为后续轮次加固项
### A8 OpenAPI 与响应兼容 —— **PASS**
- 路径/operation 数量:静态 173/211;**运行时 173/211**,一致
- 唯一变更:`docs/openapi-rc.json` 2 行描述文本(移除"验证码会打印到日志"表述),无结构变更
- 登录、发码、岗位请求/响应 Schema 未变(S01 未改任何 DTO)
- 无新增短信调试接口、无新增获取验证码接口
---
## 3. 自动化独立复验(阶段 B,实测重新执行)
| 命令 | Tests run | Failures | Errors | Skipped | 结果 |
|---|---|---|---|---|---|
| `-Dtest="SmsVerificationSecurityTest,SmsSenderConfigurationTest,CurrentUserAccessorTest,AuthenticationBoundaryIntegrationTest"` | **40** | 0 | 0 | 0 | BUILD SUCCESS |
| `-Dtest="*Auth*Test,*Security*Test,*Sms*Test,*Job*Test"` | **214** | 0 | 0 | 0 | BUILD SUCCESS |
| `mvn test` | **1658** | 0 | 0 | 0 | BUILD SUCCESS(3:45) |
| `mvn clean verify` | **1658** | 0 | 0 | 0 | **BUILD SUCCESS**(4:31,产出 JAR 79,965,063B) |
| `python ../docs/verify_openapi.py` | — | — | — | — | paths 173 / operations 211 / **All checks passed!** |
---
## 4. Docker / MySQL 环境(阶段 C)
**启动前发现**:`docker ps` 为空,三个业务容器处于 **Exited**(约 2 小时前,139/0/137 退出码),**volume 数据完好**。容器标签显示由 `Desktop\qinghe\backend-infra\compose.yaml` 创建(项目名 `backend-infra`)。
**本轮处置(非破坏性,已记录)**:仅执行 `docker start mydreamjob-mysql mydreamjob-redis mydreamjob-rabbitmq`——**未重建容器、未删除 volume、未清空数据库、未触碰其余 3 个无关孤立容器、未停止 Docker**。
| 项 | 实测值 |
|---|---|
| Docker Server | 29.6.2 |
| MySQL | **8.4.11**(`127.0.0.1:3307`,healthy) |
| Redis | 7.4-alpine(`6379`,healthy) |
| RabbitMQ | 3.13-management-alpine(`5672/15672`,healthy) |
| 验收数据库 | `mydreamjob` |
| Flyway | 41 条,**success=1 全部成功**,0 失败,最大版本 **41**(`41 message recall`) |
| S01 新增迁移 | **无** |
**已修复的验收环境问题(脚本层,非项目缺陷)**:Git Bash(MSYS) 会把裸 `/` 的 `RABBITMQ_VHOST` 自动改写为 Git 安装根路径,导致 `530 NOT_ALLOWED vhost not found`。经在验收脚本中清除该变量(交由 `application.yml` 默认值 `/` 承接)后,RabbitMQ 错误数 **0**。
---
## 5. 三种配置启动验证(阶段 D,均为最新 `mvn clean verify` JAR)
| 场景 | 启动 | SmsCodeSender 实际实现 | 发码行为 | 日志敏感值 | 判定 |
|---|---|---|---|---|---|
| **D1** `mock=true` | ✅ Started 26.5s,health 200,8080 监听 | **MockSmsCodeSender**(固定事件文本命中 1 次) | HTTP **200**(非 500) | 0 | **PASS** |
| **D2** `mock=false` | ✅ Started 26.4s | **UnavailableSmsCodeSender**(无 mock 事件) | HTTP **400** `{"code":400,"message":"sms sender unavailable"}` | 0 | **PASS** |
| **D3a** 属性完全缺失 | ✅ Started 27.5s | **UnavailableSmsCodeSender**(未回退 Mock) | HTTP **400** 同上 | 0 | **PASS** |
| **D3b** 属性存在但为空值 | ❌ **APPLICATION FAILED TO START** | **无 Bean** | — | — | **FAIL(F3)** |
每次启动均记录 profile(无 profile,基础 `application.yml`)、PID、端口、实现、Started 日志、health 状态;**仅停止本轮启动并记录 PID 的 JAR**(每次均以 `Get-CimInstance Win32_Process` 校验命令行确为 `zhilai/mydreamjob-server/target/*.jar`)。
---
## 6. 真实 MySQL 验证码事务回滚(阶段 E)
**mock=false(发送失败回滚)**——完整字段快照对比,非 COUNT(*):
| 指标 | BEFORE | AFTER | 结论 |
|---|---|---|---|
| 该手机号记录数 | 0 | 0 | ✅ |
| 全局 MAX(id) | 990 | 990 | ✅ 无新增 |
| 全局总行数 | 917 | 917 | ✅ |
| 未使用(used=0)记录数 | 0 | 0 | ✅ 无可用残留 |
| SMS_SEND 审计总数 | 819 | 819 | ✅ 无虚假成功审计 |
| 完整字段差异 | — | — | ✅ **前后完全一致** |
无半提交、无 attempts 异常、无错误锁定状态更新。HTTP **400**(非虚假成功)。
**mock=true(完整链路)**:
1. 发送 → HTTP 200,DB 生成 1 条记录(验证码值仅存于库内,报告不复制)
2. 从 DB 取码 → `POST /auth/login` → HTTP **200**,响应含 `accessToken/refreshToken/expiresIn/nickname/phone/role/userId`,响应内**不含验证码明文**
3. 登录后该记录 `used=1`、`attempts=0`(一次性消费语义不变)
4. **重放同一验证码 → HTTP 400**(拒绝)
5. 日志与审计仍不含验证码/完整手机号
6. 长度/有效期/最大尝试次数/锁定时间语义未变(`SmsVerificationSecurityTest` 10/10 覆盖)
---
## 7. 真实 HTTP 权限矩阵(阶段 F)
见第 2 节 A6 表格。关键断言全部成立:
- **匿名 POST /jobs = HTTP 401(`code=401`),不是 500** ✅
- STUDENT / ADMIN POST /jobs = **403 / 2002** ✅
- COMPANY POST /jobs = **200** ✅(生成 id=43)
- 他企业 PUT/PATCH = **400 / 7002**(既有语义,未擅改)✅
- 公开 GET(`/jobs`、`/search`、`/filters`、`/public/{id}`、`/{id}/reviews`)语义不变 = 200 ✅
- 每条请求均记录 method / path / HTTP / business code / requestId
**jobs 表副作用(完整字段对比,非 COUNT)**:
```
差异仅:新增 JOB:43|company_id=22|S01_ACC_*_job|PUBLISHED|...|requirements=S01_ACC_ok
TOTAL_JOBS 27 → 28
TAMPERED_TITLE=0 ← 他企业篡改尝试零落库
其余全部 JOB 行逐字段未变
```
---
## 8. 非法 Token / principal 黑盒(阶段 G)
对受保护端点 `POST /jobs` 的真实 HTTP 请求(含合法请求体):
| 用例 | HTTP | business code | 响应回显敏感标记 | 判定 |
|---|---|---|---|---|
| 无 Authorization | 401 | 401 | no | ✅ |
| 合法数字 subject(学生 7936) | **403** | **2002** | no | ✅ 正常进入既有权限判断 |
| `subject=anonymousUser` | **401** | 401 | no | ✅ |
| 受控非法 principal 标记 | **401** | 401 | no | ✅ 非 500 |
| subject 缺失(空) | 401 | 401 | no | ✅ |
| `subject=0` | 401 | 401 | no | ✅ |
| `subject=-1` | 401 | 401 | no | ✅ |
| subject 超出 Long 范围 | 401 | 401 | no | ✅ |
| 过期 Token | 401 | 401 | no | ✅ |
| 签名被篡改 | 401 | 401 | no | ✅ |
**无任何用例返回 500;响应与日志均未回显 subject 或 Token;jobs 表完整内容零变化(无业务写入)** ✅
Token 生成方式:验收专用 HS256 离线生成(复用项目 `JWT_SECRET` 环境变量,**未落盘、未输出**);外部 HTTP 无需伪造即可安全覆盖全部用例,故未降级为纯集成测试。
---
## 9. 日志与审计全量脱敏(阶段 H)
仅扫描**本轮**日志(D1/D1b/D2/D3a/D3b 的 app + stdout):
| 禁止项 | 命中 | 说明 |
|---|---|---|
| 验证码明文(3 个 DB 实际值) | **0** | |
| 完整手机号(7 个验收号) | **0** | |
| JWT(`eyJhbGci`、`Bearer ey`) | **0** | |
| Authorization 头值 | **0** | 16 处 `authorization` 均来自 D3b 启动失败堆栈的 `AuthorizationFilter.doFilter` |
| secret | **0** | |
| 非法 principal 标记 | **0** | |
| `NumberFormatException` 堆栈 | **0** | |
| Mock 短信正文 | **0** | `MOCK SMS CODE` 命中 0 |
| `password` | ⚠️ 16 | 全部为 Spring Boot 默认 `Using generated security password: <uuid>` → **F5(既有行为,见第 12 节)** |
**审计表(audit_logs)**:完整手机号命中 **0**、非法 principal 命中 **0**、Token 命中 **0**、脱敏手机号命中 **1**(唯一 SMS_SEND 明细 `phone=199****9999`)✅
**允许项**:脱敏手机号、固定事件名、requestId、固定 purpose 均正常。
---
## 10. 运行时 OpenAPI(阶段 I)
| 项 | 值 |
|---|---|
| 访问地址 | `GET /api/v1/v3/api-docs`(应用 `context-path=/api/v1`;裸 `/v3/api-docs` 返回 404 属预期) |
| HTTP | 200 |
| 运行时 paths / operations | **173 / 211** |
| 静态 paths / operations | 173 / 211 |
| 一致性 | **PASS** |
| 短信相关路径 | 仅 `/auth/sms/send`(无新增调试/取码接口) |
| `/jobs` 路径数 | 15 |
| `/auth/sms/send` 描述 | 已不再声称验证码输出到日志/控制台 ✅ |
---
## 11. 文档一致性核查(阶段 13)—— **FAIL**
**仍指导从日志读取验证码的文档(精确文件:行号)**:
| 文件 | 行号 | 内容(已脱敏) | 性质 |
|---|---|---|---|
| `docs/acceptance-week1.md` | 94 | `# Mock 登录(从日志获取验证码)` | 历史验收记录 |
| `docs/acceptance-week1.md` | 148 | 注: 验证码从应用日志中获取 | 历史验收记录 |
| `docs/acceptance-week1.md` | 152 | 验证码打印到日志,不发送真实短信 | 历史验收记录 |
| `docs/api-v0.1.md` | 27 | Mock 环境验证码打印到日志 | 历史 API 文档 |
| `docs/api-v0.1.md` | 174 | 短信验证码为 Mock 实现,验证码打印到日志 | 历史 API 文档 |
| `docs/frontend-integration.md` | 37,39,42,144 | "Mock 验证码日志位置"、"打印在服务控制台/stdout 日志中"、旧示例行 `MOCK SMS CODE: phone=138****0002, … code=******` | **前端接入文档(在途修改文件,本轮不可改)** |
| `docs/offer-api.md` | 262 | 测试账号(Mock 验证码,见日志) | 活文档 |
| `docs/resume-api.md` | 189 | 登录学生账号(Mock 验证码,见日志) | **在途修改文件** |
| **`docs/smoke-test.sh`** | **64-71** | **`sms_code()`:`grep -a "MOCK SMS CODE.*phone=$PHONE" $log \| … \| grep -oE 'code=[0-9]{6}'`** | **git 跟踪的既有可执行脚本(见 F1)** |
`README.md:167` 表述正确(禁止写入敏感信息),**不计入违规**;`docs/openapi-rc.json:1449` 已修正 ✅。
---
## 12. RabbitMQ 与外围终态(阶段 14)
| 项 | 实测值 |
|---|---|
| 队列 | `queue.sms.verification`、`queue.notification`、`queue.audit`、`queue.notification.dlq` |
| ready / unacked | **全部 0 / 0** |
| mock=false 失败后 notifications 增量 | **0** |
| mock=false 失败后 SMS_SEND 审计增量 | **0**(无虚假发送成功审计) |
| 匿名岗位写请求副作用 | jobs/notifications/audit 均无新增 |
| 无与 S01 无关的表变化 | ✅(仅本轮验收夹具,已精确清理) |
| 容器终态 | 3 容器仍 Up(healthy);**未停止 Docker** |
---
## 13. PASS / FAIL / SKIP 清单
| # | 验收项 | 结果 |
|---|---|---|
| A1 | 验证码/手机号不进入日志与审计 | **PASS** |
| A2 | SMS 发送器装配(true/false/缺失) | **PASS** |
| A2′ | 空值边界下的 Bean 唯一性 | **FAIL**(F3) |
| A3 | mock=false 失败事务回滚(真实 MySQL) | **PASS**(F4 为非阻断观察) |
| A4 | CurrentUserAccessor 边界 | **PASS** |
| A5 | JwtAuthenticationFilter 边界 | **PASS** |
| A6 | /jobs 匹配顺序与权限矩阵 | **PASS** |
| A7 | 无 permitAll 可达的直接身份解析 | **PASS**(存量 43 处均不可匿名到达) |
| A8 | OpenAPI 与响应兼容 | **PASS** |
| B | 自动化复验(40/214/1658/clean verify) | **PASS** |
| C | Docker/MySQL/Flyway | **PASS** |
| D | 三种配置启动 | **PASS**(D1/D2/D3a)|
| E | 真实 MySQL 回滚 + mock=true 链路 | **PASS** |
| F | 真实 HTTP 权限矩阵 | **PASS** |
| G | 非法 Token/principal 黑盒 | **PASS** |
| H | 日志与审计脱敏(`password` 项除外) | **PASS** |
| I | 运行时 OpenAPI | **PASS** |
| 13 | 文档不再指导从日志读取验证码 | **FAIL**(F1、F2) |
| 14 | RabbitMQ 与外围绕 | **PASS** |
| — | SKIP | **0**(核心验收项无 SKIP) |
**FAIL 计数:3**(F1、F2、F3)
---
## 14. 缺陷详情(可复现)
### F1(阻断)项目自带冒烟脚本因 S01 改动而失效
- **文件/方法**:`docs/smoke-test.sh` → `sms_code()`(第 64-71 行,git 跟踪,提交 `b01616d`)
- **缺陷**:该函数依赖被 S01 删除的日志行 `MOCK SMS CODE: phone=…, code=…` 提取验证码
- **复现**:
```bash
LOG=<本轮 mock=true 应用日志>
grep -a "MOCK SMS CODE.*phone=<验收手机号>" "$LOG" | tail -n 1 | grep -a -oE 'code=[0-9]{6}' | cut -d= -f2
```
- **实际结果**:输出为空 → `SMS=""` → 后续登录步骤拿不到验证码,冒烟流程中断
- **期望结果**:脚本应从数据库/受控夹具取得验证码(与 S01 新语义一致)
- **影响**:既有可执行验收工具**功能性回归**;S01 整改报告将其列为"文档遗留"但未识别此处为**可执行依赖**
### F2(阻断,对应验收标准第 17 项)文档仍指导从日志读取验证码
- **文件/行号**:见第 11 节表格(`acceptance-week1.md:94,148,152`、`api-v0.1.md:27,174`、`frontend-integration.md:37,39,42,144`、`offer-api.md:262`、`resume-api.md:189`)
- **实际结果**:文档仍声称"验证码打印到日志/控制台"并指导据此取码
- **期望结果**:明确"日志不再输出验证码";dev/test 通过**受控测试夹具或数据库内部验收方式**取码;禁止生产查询验证码表完成登录;说明 `mock=false` 且无真实供应商时返回服务不可用;真实短信供应商待单独接入
- **约束**:`docs/frontend-integration.md`、`docs/resume-api.md` 属**在途修改文件**,本轮不得改动
### F3(非阻断但违反 A2 明确要求)空值配置导致启动失败
- **文件**:`auth/sms/MockSmsCodeSender.java`(`@ConditionalOnProperty(name="app.sms.mock.enabled", havingValue="true")`)与 `auth/sms/UnavailableSmsCodeSender.java`(`havingValue="false", matchIfMissing=true`)
- **缺陷**:属性**存在但为空值**(如 env 中 `SMS_MOCK_ENABLED=`)时,两个条件都不匹配 → **0 个 `SmsCodeSender` Bean**
- **复现**:`java -jar target/mydreamjob-server-*.jar --app.sms.mock.enabled=` (其余同 D2)
- **实际结果**:
```
APPLICATION FAILED TO START
Parameter 3 of constructor in com.mydreamjob.auth.SmsVerificationService required
a bean of type 'com.mydreamjob.auth.sms.SmsCodeSender' that could not be found.
```
- **期望结果**:任何取值(含空值/空白)都应至少装配一个实现,绝不因缺少 Bean 以模糊 Spring Bean 错误启动失败(原工作令第 11 条)
- **影响**:部署可用性风险(空 env 变量在生产会致全站不可启动);未造成数据泄露
### F4(观察,非阻断)失败后残留限流键
- 现象:mock=false 发送失败后 `sms:rate:<phone>` 残留(TTL 60s),60s 内重试返回 `code=400 sms rate limited` 而非 `sms sender unavailable`
- 定性:**既有排序**(限流键在发送前写入),S01 未改变该顺序;MySQL 无残留有效验证码;不属冻结阻断项
- 建议:后续轮次评估失败时释放限流键或调整写入顺序
### F5(观察,非阻断,既有行为)Spring Boot 默认安全口令入日志
- 现象:日志出现 `Using generated security password: <uuid>`
- 定性:Spring Boot `UserDetailsServiceAutoConfiguration` 默认行为;**经历史日志比对确认 S01 之前即存在**(`mydreamjob-server/logs/backend-startup.log` 等)
- 可利用性:应用未启用 httpBasic/formLogin,该口令无 HTTP 认证入口消费 → 不可用于登录
- 建议:后续轮次排除 `UserDetailsServiceAutoConfiguration` 或显式声明 `AuthenticationManager`
---
## 15. 剩余风险
1. **F1/F2 未修复前,开发/测试人员仍会按文档从日志找验证码 → 必然失败并误判"系统故障"**
2. 存量 43 处直接身份解析(均不可匿名到达,但缺乏统一 `CurrentUserAccessor` 收口);`DeletionGuardInterceptor` 对 20 位数字溢出仍可能 500
3. 验证码仍以明文存储于 `sms_verification_codes.code`(本轮未改表结构,属已知遗留)
4. 真实短信供应商未接入;生产 `mock=false` 下所有发码请求均安全失败(设计如此,但需产品确认上线策略)
5. 安全层 401 响应不含 `requestId`(既有 `AuthenticationEntryPoint` 固定载荷),与业务层错误响应格式不一致,不利于链路追踪
6. 本轮验收脚本依赖 Git Bash 环境变量语义(vhost 转换问题已规避并记录)
---
## 16. 是否允许冻结 S01
**不允许冻结。**
自动化指标(1658/1658、clean verify、静态与运行时 OpenAPI 173/211)全部达成,核心安全目标(日志脱敏、`SMS_MOCK_ENABLED` 生效、匿名 POST /jobs 401、非法 subject 401 不回显、真实 MySQL 回滚)**均已独立复验通过**;但存在 3 项 FAIL(F1 可执行脚本回归、F2 文档违规指导、F3 空值启动失败),其中 F2 直接命中验收标准第 17 项,F1 为可执行功能回归。
---
## 17. S01 下一轮定向整改指令
### R1 —— 修复冒烟脚本取码方式(对应 F1)
- **文件**:`docs/smoke-test.sh`
- **方法**:`sms_code()`
- **错误数据/逻辑**:`grep -a "MOCK SMS CODE.*phone=$PHONE" "$log_path" | tail -n 1 | grep -a -oE 'code=[0-9]{6}' | cut -d= -f2`
- **复现请求**:`bash docs/smoke-test.sh`(mock=true 环境,日志中已无该行)
- **实际结果**:返回空字符串,登录步骤失败
- **期望结果**:改为受控来源取码,二选一——
1. 数据库内部查询(推荐,仅限本地/测试):
`SELECT code FROM sms_verification_codes WHERE phone=? AND purpose='login' AND used=0 ORDER BY id DESC LIMIT 1;`
2. 或由测试夹具直接注入固定验证码记录后读取
- **必须新增的失败测试**:新增独立脚本断言 `sms_code()` 在"日志不含 `MOCK SMS CODE`"时仍能返回 6 位码(当前实现必然失败)
- **禁止的绕过**:不得恢复 `MOCK SMS CODE` 日志行;不得让脚本读取生产日志取码
### R2 —— 修正文档中的取码指引(对应 F2,命中验收标准第 17 项)
- **文件与行号**:`docs/acceptance-week1.md:94,148,152`;`docs/api-v0.1.md:27,174`;`docs/offer-api.md:262`;`docs/frontend-integration.md:37,39,42,144`;`docs/resume-api.md:189`
- **错误内容**:声称"验证码打印到日志/控制台"并指导据此取码(含旧示例行)
- **复现**:`rg -n "控制台|日志.*验证码|MOCK SMS|验证码.*日志|查看验证码" docs README.md`
- **实际结果**:上述 10 处仍存在
- **期望结果**:统一替换为——
- 日志不再输出验证码;
- dev/test 环境通过**受控测试夹具或数据库内部验收方式**取得验证码;
- **禁止生产环境查询验证码表完成登录**;
- `mock=false` 且未接入真实供应商时返回"服务不可用",绝不返回虚假成功;
- 真实短信供应商仍待单独接入。
- **协调要求**:`docs/frontend-integration.md`、`docs/resume-api.md` 属**简历上传安全**在途修改文件,需与该项变更方**合并处理**,避免互相覆盖
- **必须新增的失败测试**:新增文档门禁测试(或 CI 检查)断言 `docs/**` 与 `README.md` 不再匹配 `MOCK SMS CODE|验证码.*打印.*日志|控制台.*验证码|见日志.*验证码`
- **禁止的绕过**:不得删除或改写为同义表述;不得仅在 README 声明而保留违规正文
### R3 —— 空值配置不再导致启动失败(对应 F3)
- **文件**:`auth/sms/MockSmsCodeSender.java`、`auth/sms/UnavailableSmsCodeSender.java`(必要时新增无条件兜底实现)
- **错误配置**:`app.sms.mock.enabled=`(存在但空)
- **复现请求**:`java -jar target/mydreamjob-server-0.0.1-SNAPSHOT.jar --app.sms.mock.enabled=`(DB/Redis/RabbitMQ 同 D2)
- **实际结果**:`APPLICATION FAILED TO START … required a bean of type 'SmsCodeSender' that could not be found`
- **期望结果**:启动成功并装配 `UnavailableSmsCodeSender`(空值、空白值、缺失、`false` 四者行为一致;仅显式 `true` 才装配 Mock)
- **必须新增的失败测试**(`SmsSenderConfigurationTest` 内新增,不改动既有断言):
- `emptyPropertyStillProvidesUnavailableSender`
- `blankPropertyStillProvidesUnavailableSender`
- `exactlyOneSenderBeanForEveryValueIncludingEmpty`
- **禁止的绕过**:不得让 Mock 成为 `matchIfMissing` 默认;不得用 `@Primary` 掩盖歧义;不得让应用以空 Bean 启动(必须真实拒绝发码,不得伪造成功)
### R4 —— 非阻断建议(可与上轮合并,不单独阻断冻结)
1. `DeletionGuardInterceptor:60` 增加溢出安全解析(复用 `CurrentUserAccessor` 语义)
2. 评估失败时释放 `sms:rate:<phone>` 限流键(F4)
3. 排除 `UserDetailsServiceAutoConfiguration` 或显式声明 `AuthenticationManager`(F5)
4. 统一为安全层 401 响应补充 `requestId`(便于链路追踪)
---
## 18. 验收产物清单(`docs/acceptance/s01-20260911/`)
| 文件 | 用途 |
|---|---|
| `launch.sh` | 三种配置启动器(true/false/MISSING/EMPTY) |
| `gen-token.js` | 验收专用 HS256 Token 离线生成(密钥仅取自环境变量,不落盘) |
| `matrix.sh` / `matrix-results.txt` | 阶段 F 权限矩阵脚本与结果 |
| `phase-g.sh` / `phase-g-results.txt` | 阶段 G 非法 Token 边界脚本与结果 |
| `snapshot-jobs.sql` / `jobs-before.txt` / `jobs-after.txt` / `jobs-before-g.txt` / `jobs-after-g.txt` | jobs 表完整字段快照与对比 |
| `snapshot-sms.sql` / `before-mockfalse.txt` / `after-mockfalse.txt` | 阶段 E 验证码表回滚对比 |
| `job-body.json` / `tamper-body.json` / `status-body.json` / `ok-body.json` | 请求体夹具(ASCII,避免 shell 编码问题) |
| `d1*/d2*/d3*` 日志 | 三次配置启动的应用日志与 stdout |
| `runtime-openapi.json` | 运行时 OpenAPI 快照 |
| `acc-fixture.txt` / `acc-prefix.txt` | 验收夹具 ID 记录(已清理) |
| `sensitive-markers.txt` | 敏感标记清单(内容未写入报告) |
**验收数据清理**:按精确 ID 与前缀删除 users(7933-7937)/companies(22,23)/jobs(42,43)/sms_verification_codes(1),Redis 验收键已清理;**保留**本轮窗口内 2 条 audit_logs(append-only 合规记录,不做删除);`jobs` 总数回到基线 26。清理前后均确认目标归属,无模糊 DELETE。
---
**已复现具体缺陷,S01不可冻结;验收报告已包含下一轮定向整改指令。**

288
docs/s01-independent-mysql-acceptance-20260914.md

@ -0,0 +1,288 @@
# S01 最终真实 MySQL / Docker 增量复验报告(脱敏版)
- **验收日期**:2026-09-14
- **分支**:`fix/resume-upload-safety`
- **范围**:只验证 S01 第四轮整改(F6:`expires_at` → `expired_at`)后的**真实运行路径**;本轮**未修改任何代码**
- **结论**:**PASS**
- **本报告不含**:验证码值、完整手机号、JWT、`Authorization` 头、数据库口令、`.env` 内容
> 本轮为此前因 Docker Linux Engine 未运行而 BLOCKED 的复验。本次 Docker Engine 可用,全部真实黑盒已执行完成。
---
## 1. 基线与边界
| 项 | 实测 |
|---|---|
| 分支 | `fix/resume-upload-safety` |
| 暂存区 | **空**(`git diff --cached --name-only` 无输出) |
| `git diff --check` | 无空白错误(仅 CRLF 提示) |
| 迁移改动 | **无**(`git diff --name-only -- db/migration` 无输出;41 个文件,最大 `V41__message_recall.sql`) |
| 已跟踪改动 | 24 个(**全部来自此前几轮**:7 个 docs + 11 个 java main + 6 个 java test),本轮 **0 新增** |
| 删除文件 | **0** |
| 历史未跟踪文件 | 未删除、未移动、未覆盖 |
本轮**未修改**:生产代码、现有测试、`docs/smoke-test.sh`、`V1–V41`;**未新增** Flyway/表/索引/`ErrorCode`/接口;**未恢复**验证码日志。
## 2. Docker 环境
| 项 | 值 |
|---|---|
| Docker Engine | 29.6.2(Docker Desktop 4.84.0,linux/amd64) |
| `mydreamjob-mysql` | **healthy**(mysql:8.4,127.0.0.1:3307→3306) |
| `mydreamjob-redis` | **healthy**(redis:7.4-alpine) |
| `mydreamjob-rabbitmq` | **healthy**(rabbitmq:3.13-management-alpine) |
进入本轮时三个容器均为 `Exited`(2 天前停止)。经负责人明确授权后,对**已存在的具名容器**执行 `docker start`:**未重建容器、未改动配置、未触碰 volume(`mysql_data`/`redis_data`/`rabbitmq_data` 均保留)**。验收结束后容器保持运行,由负责人决定是否停止。
## 3. 真实 Schema 对账
真实 MySQL 执行 `SHOW COLUMNS FROM sms_verification_codes`(**应用账号 + `MYSQL_PWD`,非 root**):
```
id phone code purpose attempts max_attempts expired_at used created_at
```
三方对账**完全一致**:
| 来源 | 列集合 |
|---|---|
| 真实 MySQL | `attempts code created_at expired_at id max_attempts phone purpose used` |
| 实体 `@Column` | 同上 |
| `V1__base_schema.sql` | 同上 |
- **包含 `expired_at`** ✅
- **不包含 `expires_at`** ✅
**反证**:`SELECT expires_at FROM sms_verification_codes LIMIT 1;` → `ERROR 1054 (42S22) Unknown column 'expires_at' in 'field list'`(退出码 1)。
## 4. 真实取码 SQL
修复后的等价只读查询在真实 MySQL 执行:
```sql
SELECT code FROM sms_verification_codes
WHERE phone = ? AND purpose = ? AND used = 0
AND expired_at > NOW()
ORDER BY created_at DESC, id DESC LIMIT 1;
```
**退出码 = 0,无 `ERROR 1054`**。(查询返回行数按参数而定,本报告不展示任何取回的 `code`。)
## 5. 最新 JAR 启动
| 项 | 值 |
|---|---|
| JAR | `target/mydreamjob-server-0.0.1-SNAPSHOT.jar`(`mvn clean verify` 产物) |
| 启动参数 | `--spring.jpa.hibernate.ddl-auto=validate`、`--app.sms.mock.enabled=true`、`--server.port=8080` |
| 启动结果 | **`Started MyDreamjobApplication`(14.9 s)** |
| Flyway | 无错误(仅一条 MySQL 8.4 版本提示 WARN) |
| Schema validation | **无错误** |
| `GET /api/v1/health` | **HTTP 200,`status=UP`** |
| 运行时 OpenAPI | **173 paths / 211 operations** |
端口 8080 在启动前空闲,无未知进程被停止。日志写入本轮新目录 `docs/acceptance/s01-20260914/`,未覆盖历史日志。
## 6. 真实完整冒烟链
以唯一虚构前缀 `1990914xxxx` 与隔离 requestId 执行:
```bash
SMS_MOCK_ENABLED=true ALLOW_TEST_OTP_DB_READ=true bash docs/smoke-test.sh
```
结果:**PASS=10 FAIL=0 SKIP=1,exit=0**
| 步骤 | 结果 |
|---|---|
| `GET /health` | PASS(200) |
| `POST /auth/sms/send` + `POST /auth/login` | **PASS**(200 / 200) |
| `GET /jobs?size=5` | PASS |
| `GET /jobs/public/{id}` | PASS |
| `GET /jobs/{id}/match` | PASS |
| `POST /applications` | PASS |
| `PATCH /applications/{id}/status` | **SKIP**(未提供 `COMPANY_TOKEN`,属脚本既有语义) |
| `GET /notifications` | PASS |
| `GET /notifications/unread-count` | PASS |
| `POST /notifications/{id}/read` | PASS |
| `POST /applications/{id}/withdraw` | PASS |
逐项确认:
1. 发码成功 ✅
2. `smoke-test.sh` 经**真实 `docker exec`** 连真实 MySQL ✅
3. 使用应用账号 `MYSQL_USER`,**未使用 root** ✅
4. mysql argv **不含密码** ✅(见 §7)
5. SQL 使用 `expired_at` ✅
6. 成功取得可用验证码,**未显示其值** ✅
7. 真实 `POST /auth/login` 成功 ✅
8. 获得 Access Token,**未输出** ✅
9. 后续岗位/投递/通知步骤按夹具条件执行 ✅
10. **未出现 `ERROR 1054`** ✅
11. 未出现空验证码 ✅
12. 未从日志读取验证码 ✅
13. stdout/stderr 不含验证码、完整手机号、JWT、`Authorization`、数据库口令 ✅
**核心链路无 SKIP**:唯一 SKIP 是企业状态步骤,取码与登录核心链路均实际执行并通过。
**真实库侧证据**:该手机号在 `sms_verification_codes` 中产生 **1 行、`used=1`、`code` 长度 6** —— 真实 MySQL 中确实生成了 6 位验证码并被真实登录消费。
> **关键推论**:登录成功即证明真实执行的 SQL 使用了**有效列**。若仍是 `expires_at`,查询会报 `ERROR 1054`、取不到码,登录必然失败。
### `bash -x` 复验
以 `bash -x` 执行(新手机号):**exit=0,PASS=10 FAIL=0 SKIP=1**,捕获 stdout+stderr 后扫描,敏感命中数:
| 标记 | 命中 |
|---|---|
| 实际验证码值 | **0** |
| 完整手机号 | **0** |
| `Authorization: Bearer` | **0** |
| `LOGIN_BODY=` | **0** |
| JWT 形态(`eyJ…`) | **0** |
| `ERROR 1054` | **0** |
## 7. F5 真实环境 argv 证据
在真实查询执行期间,读取**容器内 mysql 进程的实际 argv**:
```
mysql -umydreamjob -N -B mydreamjob
```
- 使用**应用账号**(非 root)✅
- **不含 `-p<密码>`** ✅
- **不含 `--password`** ✅
- 密码经 `MYSQL_PWD` 环境变量传递 ✅
## 8. mock=false 真实 MySQL 回滚
以 `--app.sms.mock.enabled=false` 重启后调用 `POST /auth/sms/send`:
| 项 | 结果 |
|---|---|
| 响应 | `HTTP 400` + `{"code":400,"message":"sms sender unavailable"}` |
| 是否虚假成功 | **否** ✅ |
| 验证码表前后状态 | **完全一致**(该手机号 0 行;全局 `max_id` 994→994;总数 918→918) ✅ |
| 残留 `used=0` 验证码 | **无** ✅ |
| 新增虚假发送成功审计 | **0** 行 ✅ |
| 日志含手机号/验证码 | **0** 命中 ✅ |
响应与上一轮(`docs/acceptance/s01-20260911/d2-sms-response.txt`)逐字一致。
## 9. 配置缺失 / 空值 / 非法值启动
| 用例 | 启动 | Bean 缺失错误 | 发码响应 | 是否落库 |
|---|---|---|---|---|
| 配置**缺失** | `Started` ×1 | **0** | 400 `sms sender unavailable` | 未落库(994→994) ✅ |
| `SMS_MOCK_ENABLED=`(**空值**) | `Started` ×1 | **0** | 400 `sms sender unavailable` | 未落库(994→994) ✅ |
| `SMS_MOCK_ENABLED=invalid-value`(**非法值**) | `Started` ×1 | **0** | 400 `sms sender unavailable` | 未落库(994→994) ✅ |
**「恰好一个 `SmsCodeSender` 且为 `UnavailableSmsCodeSender`」在真实环境按排除法成立**:
- 0 个 Bean → 启动会以 `NoSuchBeanDefinition` 失败;≥2 个 → 会以 `NoUniqueBeanDefinition` 失败;Mock 生效 → 发码成功且落库。
- 实测:**启动成功** + **发码不可用** + **未落库**,三者互斥地锁定「恰好一个 `UnavailableSmsCodeSender`」。
(Bean 数量与类型的严格断言另由 `SmsSenderConfigurationTest`(真实 `ApplicationContextRunner`)在 §10 覆盖。)
每次只停止本轮明确记录 PID 的 JAR,未触碰任何未知进程。
## 10. 日志脱敏扫描
扫描对象:**本轮全部新日志**(6 个应用日志 + stdout)**+ 冒烟 stdout/stderr**。
| 禁止项 | 命中 |
|---|---|
| 实际验证码(本轮真实产生的 2 个,逐个比对) | **0 / 0** |
| 完整手机号(6 个测试号逐个比对) | **0** |
| `Authorization: Bearer` | **0** |
| JWT 形态(`eyJ…`) | **0** |
| `MYSQL_PASSWORD` 实际值 / 数据库口令 | **0** |
| `LOGIN_BODY` | **0** |
| `MOCK SMS CODE` | **0** |
| `NumberFormatException` | **0** |
| `ERROR 1054` | **0** |
**敏感标记命中合计 = 0。**
扫描器有效性对照:注入探针(`MyDreamJob backend smoke test`)命中 **2**,证明扫描器在工作,而非空转的 0。
## 11. OpenAPI 与回归
| 项 | 结果 |
|---|---|
| `python docs/verify_openapi.py` | **All checks passed!** |
| 运行时 `/v3/api-docs` | **173 paths / 211 operations** |
| 静态 `docs/openapi-rc.json` | **173 paths / 211 operations** |
| 静/运行时一致 | **YES**(路径差集:静态独有 = 无,运行独有 = 无) |
| 是否新增取码接口 | **无**(含 "code" 的唯一路径是 `/admin/scoring/risk/rules/{ruleCode}`,为风控规则码,与验证码无关) |
| 接口结构变化 | **无** |
Maven 安全专项:
```
mvn -Dtest="SmsSenderConfigurationTest,SmsVerificationSecurityTest,SmsVerificationServiceTest,CurrentUserAccessorTest,AuthenticationBoundaryIntegrationTest,SmsEmptyPropertyStartupIntegrationTest,SmsUnavailableSenderRollbackIntegrationTest" test
```
→ **Tests run: 61, Failures: 0, Errors: 0, Skipped: 0 — BUILD SUCCESS**
## 12. 数据清理
本轮产生的数据可按唯一虚构前缀 `1990914` 精确圈定(时间戳均为 2026-09-14):
| 表 | 行数 |
|---|---|
| `users` | 2(验收虚构号码:`199****0101`、`199****0102`) |
| `sms_verification_codes` | 2 |
| `applications` | 2(均为 `WITHDRAWN`) |
| `notifications` | 4 |
| `user_roles` | 2 |
| `audit_logs` | 8 |
**决策:保留本轮数据,不执行删除。** 理由:
1. `audit_logs` 有 **8 行** `user_id` 指向这两个用户,且该表**无外键约束**;删除用户会使审计行成为孤儿。
2. 本轮任务边界明确**禁止删除 `audit_logs` 行**。
3. 保留可维持审计链一致;边界亦明确允许「无法安全确认时保留数据并记录」。
未执行任何模糊 `DELETE`、未清空表、未重置自增、未修改真实账号。上述足迹为精确记录,供后续人工处置。
## 13. 最终 Git 检查
| 项 | 结果 |
|---|---|
| `git status --short` | 24 个已跟踪改动(**与本轮开始时同一批**)+ 98 个未跟踪(既有残骸 + 本轮验收产物) |
| `git diff --cached --name-only` | **空**(未暂存) |
| `git diff --check` | 无空白错误 |
| `git diff --name-only -- db/migration` | **空**(V1–V41 未修改) |
| 删除文件 | **0** |
| commit / push | **未执行** |
| S02 | **未进入** |
## 14. 验收结论
| # | 判据 | 结果 |
|---|---|---|
| 1 | Docker/MySQL 可用 | ✅ |
| 2 | 真实表包含 `expired_at` | ✅ |
| 3 | 真实取码 SQL 执行成功 | ✅ |
| 4 | 完整学生登录链成功 | ✅ |
| 5 | `bash -x` 无敏感泄露 | ✅ |
| 6 | mock=false 真实 MySQL 完整回滚 | ✅ |
| 7 | 缺失/空/非法配置安全回退 | ✅ |
| 8 | 运行时 OpenAPI 为 173/211 | ✅ |
| 9 | 无日志泄露 | ✅ |
| 10 | 核心项无 SKIP | ✅ |
| 11 | FAIL = 0 | ✅ |
**S01独立代码复验和真实MySQL/Docker黑盒全部通过,可以进入精确暂存检查点。**
---
## 遗留与观察(不阻断本轮结论)
1. **验证码仍为数据库明文存储**(未改表结构,属已知遗留)。
2. **真实短信供应商未接入**;生产 `mock=false` 下发码一律安全失败,需产品确认上线策略。
3. **本轮测试账号保留在库**(见 §12),与其审计记录一致;如需清理应由人工按精确 ID 处置。
4. `/api/v1/api-docs` 返回 500(`/api/v1/v3/api-docs` 正常 200)。该行为与 S01 无关,非本轮引入,记录待查。
5. 三个容器在验收结束后**保持运行**(本轮仅 `docker start`,未重建、未删除 volume),是否停止由负责人决定。

786
docs/s01-remediation-f1f2f3-20260911.md

@ -0,0 +1,786 @@
# S01 定向整改报告(F1 / F2 / F3)
- **整改日期**:2026-09-11
- **分支**:`fix/resume-upload-safety`
- **范围**:仅修复独立验收确认的 F1、F2、F3,并完成对应自动化与干净构建
- **本报告不包含**:数据库口令、.env 内容、JWT、验证码值、完整手机号、明文测试口令、非法 principal 敏感标记原文
- **整改期间状态**:`S01独立验收未通过,定向整改中。`
---
## 1. F1 的修复方式
`docs/smoke-test.sh` 的 `sms_code()` **彻底删除**了对应用日志的依赖(grep `MOCK SMS CODE`),改为**双重保护 + 容器内部只读 SQL 查询**。
同时删除:`SERVER_LOG` 变量、`resolve_log()` 函数、全部日志 grep。未恢复任何验证码日志。
| 项 | 整改前 | 整改后 |
|---|---|---|
| 取码来源 | `grep -a "MOCK SMS CODE.*phone=$PHONE" <日志>` | 容器内 `SELECT code FROM sms_verification_codes …` |
| 前置授权 | 无 | `SMS_MOCK_ENABLED=true` **且** `ALLOW_TEST_OTP_DB_READ=true` |
| 取码返回值 | stdout(进入 command substitution) | `REPLY` 变量约定(不打印) |
| 请求体传参 | `-d "<含验证码>"`(进入进程参数) | `--data-binary @-`(经 stdin) |
| 凭据处理 | 需 `SERVER_LOG` | 容器自身 `MYSQL_ROOT_PASSWORD` / `MYSQL_DATABASE`(不在本机命令行拼写口令) |
| 清理 | 无 | `unset REPLY/SMS/LOGIN_BODY` + `trap cleanup_secrets EXIT` |
脚本仍为 Bash,兼容当前 Git Bash;`bash -n` 语法校验通过。
## 2. smoke-test.sh 的新取码机制
```
sms_code()
├─ require_mock_sms_mode() # SMS_MOCK_ENABLED 必须为 true
├─ require_test_otp_db_read_permission() # ALLOW_TEST_OTP_DB_READ 必须为 true
├─ 校验 PHONE 为 11 位纯数字(防注入)
├─ 记录并关闭 xtrace(set +x),执行后精确恢复原状
├─ IFS= read -r code < <(otp_db_query ...) # 不使用 command substitution 取值
├─ 校验 code 为 6 位数字(当前 code-length / CODE_PATTERN)
├─ 失败 → unset code + 固定提示 + return 1(绝不携带空值继续登录)
└─ 成功 → REPLY="$code"; unset code; return 0
```
`otp_db_query()` 关键点:
- **容器名不硬编码**:`compose_mysql_container()` 用 `awk` 解析项目实际 `backend-infra/compose.yaml` 中 `mysql` 服务的 `container_name`;用 `BASH_SOURCE` 定位自身目录(被 `source` 时同样正确)
- SQL 经 **stdin**(heredoc 风格 `printf | docker exec -i` ),不出现在任何进程参数中
- 查询条件完整:`phone` + `purpose` + `used = 0` + `expired_at > NOW()`
(更正:本行原写作 `expires_at`,该列在 `sms_verification_codes` 中**不存在**,是 F6 记录的错误字段名;已于第四轮一并更正,详见文末「第四轮定向整改(F6)」)
- `ORDER BY created_at DESC, id DESC LIMIT 1`,只取 `code` 列
- 不使用 `curl -v`;失败诊断不打印请求体
## 3. 为什么不会把验证码输出到终端 / 日志 / 文件
1. 取码函数**从不 `echo`/`printf` 验证码**,只写 `REPLY`;调用方 `SMS="$REPLY"` 后立即 `unset REPLY`
2. `set +x` 包住取值与查询,且**按调用前状态精确恢复**,不无条件开启 xtrace
3. 登录请求体经 `--data-binary @-` 从 stdin 送入 curl,验证码不进入 argv
4. 请求体只在 `LOGIN_BODY` 局部变量中存在,用完 `unset`;**不落盘**
5. `trap cleanup_secrets EXIT` 兜底清理 `OTP/REPLY/LOGIN_BODY`
6. 脚本控制台只输出 `PHONE=$(mask_phone "$PHONE")`(脱敏为 `199****9999`),不打印完整手机号
7. 应用侧本就不输出验证码(S01 已整改),Mock 发送器仅输出固定文本
## 4. `ALLOW_TEST_OTP_DB_READ` 的强制条件
```bash
is_true() { v=$(printf '%s' "${1:-}" | tr -d '[:space:]' | tr '[:upper:]' '[:lower:]'); [ "$v" = "true" ]; }
require_mock_sms_mode() { is_true "${SMS_MOCK_ENABLED:-}"; }
require_test_otp_db_read_permission() { is_true "${ALLOW_TEST_OTP_DB_READ:-}"; }
```
- 两者同时为 `true`(大小写/空白不敏感)才允许查询
- 任一不满足:立即返回非 0,输出固定提示 `test OTP retrieval is disabled`,**不执行任何 docker/SQL 调用**
- 提示中不含手机号、验证码、数据库口令、SQL 结果或容器名
## 5. 未授权取码的实际结果
| 场景 | 实际结果 |
|---|---|
| `SMS_MOCK_ENABLED` 缺失 | 拒绝;输出 `test OTP retrieval is disabled`;**docker 未被调用** |
| `SMS_MOCK_ENABLED=false` | 拒绝;同上 |
| `ALLOW_TEST_OTP_DB_READ` 缺失 | 拒绝;同上 |
| `ALLOW_TEST_OTP_DB_READ=false` | 拒绝;同上 |
| 两者为 true 但无可用验证码 | `test OTP retrieval failed`,**不继续登录** |
| 两者为 true 但查询失败/结果非 6 位数字 | 同上,**不继续登录** |
## 6. F2 修改的全部文档与行
| 文件 | 行(整改后) | 修改前 | 修改后要点 |
|---|---|---|---|
| `docs/acceptance-week1.md` | 94 | `# Mock 登录(从日志获取验证码)` | 指向受控取码方式 |
| `docs/acceptance-week1.md` | 148 | 注: 验证码从应用日志中获取 | 明确日志不输出;列出双开关;生产禁止查表 |
| `docs/acceptance-week1.md` | 152 | 验证码打印到日志 | 明确不输出;标注明文存储遗留 |
| `docs/api-v0.1.md` | 27 | Mock环境验证码打印到日志 | 明确不输出 + 受控取得 |
| `docs/api-v0.1.md` | 174 | 验证码打印到日志 | 明确不输出;未接入供应商返回服务不可用 |
| `docs/frontend-integration.md` | 37-49(§3 整节重写) | 「Mock 验证码日志位置」+ 旧示例行 `MOCK SMS CODE: … code=******` + 「看终端输出/看日志文件/取最新一条」 | 改为「本地获取测试验证码(不通过日志)」:双开关 + 受控脚本 + 生产禁止查表 + 明文遗留风险 |
| `docs/frontend-integration.md` | 149 | 验证码打印在服务日志 | 改为不再输出 + 受控取得 |
| `docs/offer-api.md` | 262 | 验证码见应用日志 | 改为受控取得 + 生产禁止查表 |
| `docs/resume-api.md` | 189 | 登录学生账号(Mock 验证码,见日志) | 改为受控取得 + 生产禁止查表 |
| `docs/smoke-test.sh` | 全文头部与取码段 | 依赖日志取码 | 见第 1-5 节 |
**文档统一口径(已落实)**:
1. 删除所有「从控制台/日志查看验证码」的操作指导
2. 明确应用日志不会输出验证码
3. dev/test 本地自动化使用受控测试夹具/数据库内部只读查询
4. 冒烟脚本仅在 `mock=true` **且** `ALLOW_TEST_OTP_DB_READ=true` 时查表
5. 真实生产环境**禁止**查询验证码表辅助登录
6. `mock=false` 且无真实供应商时返回「服务不可用」
7. 明确当前尚未接入真实短信供应商
8. **未声称**验证码已加密/哈希存储
9. 明确验证码当前仍为数据库**明文**遗留风险
10. 示例中不含真实手机号、验证码、JWT 或数据库口令
**在途文件保护**:`docs/frontend-integration.md` 与 `docs/resume-api.md` 仅改动取码相关段落;`git diff` 复核确认简历上传加固内容(文件名净化、PDF/DOC/DOCX 结构校验、上传替换一致性、恶意软件扫描边界等)**逐字保留**,未覆盖、未还原、未重新格式化。
## 7. 全仓遗留搜索结果
```
grep -rn -i "MOCK SMS CODE|控制台.*验证码|验证码.*打印到日志|从日志获取|查看.*验证码"
docs README.md mydreamjob-server/src
```
- **「打印到日志 / 从日志获取」命中数:0**
- `MOCK SMS CODE` 在 `mydreamjob-server/src` 与 `docs/` 中的命中数:**0**
- 剩余匹配全部为**禁止性表述或无关标识符**(如「日志不得包含验证码」「不得写入日志」「SMS_CODE_INVALID」枚举名)
- `README.md:167` 本身即「不写入敏感信息」的正确约定,未修改
## 8. F3 使用的 Bean 装配方案
采用**推荐的单 `@Configuration` + 单 `@Bean` 方案**(不再依赖两套互斥 `@ConditionalOnProperty`):
```java
@Configuration
public class SmsSenderConfiguration {
private static final String MOCK_ENABLED_VALUE = "true";
@Bean
public SmsCodeSender smsCodeSender(
@Value("${app.sms.mock.enabled:false}") String rawEnabled) {
if (rawEnabled != null && MOCK_ENABLED_VALUE.equalsIgnoreCase(rawEnabled.trim())) {
return new MockSmsCodeSender();
}
return new UnavailableSmsCodeSender();
}
}
```
- `MockSmsCodeSender` / `UnavailableSmsCodeSender` 已移除 `@Component` 与 `@ConditionalOnProperty`,成为普通类
- 满足:始终恰好一个 Bean;仅显式 `true` 启用 Mock;非法值不启用 Mock;**不根据 profile 偷偷启用**;`application-dev.yml` 仍显式 `true`;`application.yml` 默认 `false`
- 不把选择逻辑放回 `SmsVerificationService`;不新增第二套发送流程;不用 try/catch Bean 缺失兜底;不在每次发送时读配置;**不输出配置原值到日志**
## 9. 各配置取值对应的实现
| `app.sms.mock.enabled` | 装配实现 |
|---|---|
| `true` / `TRUE` / `True` / `" true "` | `MockSmsCodeSender` |
| `false` | `UnavailableSmsCodeSender` |
| 缺失 | `UnavailableSmsCodeSender` |
| 空字符串(`=`) | `UnavailableSmsCodeSender` |
| 仅空白(`= `) | `UnavailableSmsCodeSender` |
| 非法值(`yes`/`1`/`on`/`enabled`/`null`/`mock`/`TRUE1`) | `UnavailableSmsCodeSender`(安全回退,**绝不启用 Mock**) |
## 10. 每种配置的 Bean 数量
由 `SmsSenderConfigurationTest.everySupportedConfigurationCreatesExactlyOneSenderBean` 断言(真实 `ApplicationContextRunner`,含 9 种取值 + 缺失属性):
**每种取值 `getBeanNamesForType(SmsCodeSender.class).length == 1`**。
## 11. 空值启动结果
| 层级 | 结果 |
|---|---|
| 快速装配测试(`ApplicationContextRunner`) | 上下文**启动成功**;恰好一个 Bean;类型 `UnavailableSmsCodeSender`;消费者构造注入成功;发送抛固定安全 `SERVICE_UNAVAILABLE` |
| **全应用上下文测试**(`SmsEmptyPropertyStartupIntegrationTest`,真实 H2 + Flyway + 全 Bean) | 应用上下文**启动成功**;恰好一个 `SmsCodeSender`;类型 `UnavailableSmsCodeSender`;`SmsVerificationService` **可注入**;发送返回固定安全服务不可用 ✅ |
整改前该取值导致 `APPLICATION FAILED TO START … required a bean of type 'com.mydreamjob.auth.sms.SmsCodeSender' that could not be found`(独立验收阶段已复现并记录在验收报告 F3)。
> 本轮按要求**未操作 Docker / 未执行真实 MySQL 黑盒**,故未做真实 JAR 启动;全应用上下文启动已由 H2+Flyway 覆盖,真实环境启动仍待独立复验。
## 12. 是否恢复验证码日志
**未恢复。** `grep -rn "MOCK SMS CODE" mydreamjob-server/src` 命中 0;`MockSmsCodeSender` 仅输出固定文本 `SMS verification code generated (mock sender)`;`SmsVerificationService` 仅两处 `log.warn`,参数为 `maskPhone(phone)`;`AuthService` 仅记录 `id`。
## 13. 原 S01 权限与事务回归结果
| 原 S01 要求 | 结果 |
|---|---|
| 不恢复验证码日志 | ✅(第 12 节) |
| 不输出完整手机号 | ✅ `SmsVerificationSecurityTest` 10/10 |
| Mock 发送器不打印验证码 | ✅ |
| mock=false 不返回虚假成功 | ✅ `SmsSenderConfigurationTest` + 回滚集成测试 |
| mock=false 发送失败事务回滚 | ✅ `SmsUnavailableSenderRollbackIntegrationTest` 3/3(真实 Spring + H2 + Flyway:失败后该手机号**无任何验证码行**、总数不变、无虚假成功审计) |
| 匿名 POST /jobs = 401 | ✅ `AuthenticationBoundaryIntegrationTest` 15/15 |
| STUDENT/ADMIN 创建岗位 = 403/2002 | ✅ |
| COMPANY 合法创建保持成功 | ✅ |
| 非法 principal 返回 401 | ✅ `CurrentUserAccessorTest` 11/11 |
| 不返回 NumberFormatException / 内部 principal | ✅ |
| OpenAPI 173/211 | ✅ |
| 无新增公网取码接口 | ✅(OpenAPI 173/211 不变) |
| 无新 Flyway/表/索引/ErrorCode | ✅ |
## 14. Shell 测试数量与结果
`docs/tests/smoke-test-sms-code-test.sh`(新增):**22 用例,PASS=22 / FAIL=0**
覆盖:mock=true+ALLOW=true 可取码、mock=false 拒绝、配置缺失拒绝、ALLOW 缺失/为 false 拒绝、无可用验证码安全失败、查询失败不继续、结果非数字/长度非法拒绝、查询为空时不继续登录(无 login 请求)、stdout/stderr 不含验证码、不含完整手机号、生成文件不含验证码、不 grep 应用日志、不存在 `MOCK SMS CODE`、REPLY/SMS/LOGIN_BODY 清理与 EXIT trap 存在、未授权时不触碰数据库、拒绝提示不含手机号、**授权模式下可完成登录流程(登录请求已发出且携带 6 位码、登录步骤判定 PASS)**。
测试通过 PATH 注入假 `docker`/`curl` 实现,**不要求 Docker 或真实 MySQL**,仓库内不写入真实验证码,结束时扫描输出确认标记未泄露。
## 15. Maven 专项、关联与全量测试数
| 命令 | Tests run |
|---|---|
| `-Dtest="SmsSenderConfigurationTest,SmsVerificationSecurityTest,SmsVerificationServiceTest,CurrentUserAccessorTest,AuthenticationBoundaryIntegrationTest"` | **56** |
| `-Dtest="*Auth*Test,*Security*Test,*Sms*Test,*Job*Test"` | **222** |
| `mvn test`(最终,含本轮新增 2 个集成测试) | **1671** |
| `mvn clean verify` | **1671** |
S01 相关类明细:
| 测试类 | Tests |
|---|---|
| `SmsSenderConfigurationTest`(F3,由 4 扩展至 12) | 12 |
| `SmsVerificationSecurityTest` | 10 |
| `SmsVerificationServiceTest` | 8 |
| `CurrentUserAccessorTest` | 11 |
| `AuthenticationBoundaryIntegrationTest` | 15 |
| `SmsUnavailableSenderRollbackIntegrationTest`(本轮新增) | 3 |
| `SmsEmptyPropertyStartupIntegrationTest`(本轮新增) | 2 |
## 16. Failures / Errors / Skipped
**Failures=0、Errors=0、Skipped=0**(全量与 clean verify 一致)
## 17. clean verify 结果
`mvn clean verify` → **BUILD SUCCESS**(4:34),产出 `target/mydreamjob-server-0.0.1-SNAPSHOT.jar`。
## 18. OpenAPI paths / operations
| 来源 | paths | operations |
|---|---|---|
| 静态 `docs/verify_openapi.py` | **173** | **211** |
| 校验输出 | `All checks passed!` | — |
未新增/删除路径或 operation;无新增测试取码端点。
## 19. 精确修改文件
**修改(已跟踪,本轮 F1/F2)**
| 文件 | 变更 |
|---|---|
| `docs/smoke-test.sh` | +176/−…(取码机制整体替换、双保护、脱敏输出、库模式钩子) |
| `docs/acceptance-week1.md` | 3 处取码表述 |
| `docs/api-v0.1.md` | 2 处取码表述 |
| `docs/frontend-integration.md` | §3 整节重写 + 1 处(**在途文件,最小改动**) |
| `docs/offer-api.md` | 1 处取码表述 |
| `docs/resume-api.md` | 1 处取码表述(**在途文件,最小改动**) |
**修改(S01 新建文件,本轮扩展,F3)**
| 文件 | 变更 |
|---|---|
| `auth/sms/MockSmsCodeSender.java` | 移除 `@Component`/`@ConditionalOnProperty`,改为普通类 |
| `auth/sms/UnavailableSmsCodeSender.java` | 同上 |
| `auth/SmsSenderConfigurationTest.java` | 由 4 用例扩展至 12(真实 ApplicationContextRunner + 消费者探针) |
**新增**
| 文件 | 用途 |
|---|---|
| `auth/sms/SmsSenderConfiguration.java` | F3 单 Bean 装配 |
| `auth/SmsUnavailableSenderRollbackIntegrationTest.java` | 真实 Spring+H2:失败不残留验证码 |
| `auth/SmsEmptyPropertyStartupIntegrationTest.java` | 空值全应用上下文启动 |
| `docs/tests/smoke-test-sms-code-test.sh` | F1 shell 安全测试(22 用例) |
## 20. Git 状态
- 分支:`fix/resume-upload-safety`(未变)
- **暂存区:空**
- `git diff --check`:无空白错误(仅 CRLF 提示)
- 迁移改动:**无**(V1–V41 共 41 个文件,最大版本 41)
- 删除文件:**0**
- 历史未跟踪文件:未删除、未移动、未覆盖(含 `docs/acceptance/s01-20260911/` 全部验收证据 43 个文件与验收报告,保持原样)
## 21. 是否修改 Flyway、表、ErrorCode 或公网接口
**均未修改。**
- Flyway:无新增/修改,V1–V41 原样
- 数据库表/索引:未新增或修改(验证码仍明文存储于 `sms_verification_codes`,属已记录遗留风险)
- `ErrorCode`:未被改动
- 公网接口:OpenAPI 保持 173 paths / 211 operations;未新增取码接口
## 22. 明确未执行 git add -A / commit / push
**未执行** `git add -A`、**未 commit**、**未 push**(亦未 `reset`/`restore`/`checkout`/`clean`)。
## 23. 明确未操作 Docker、未执行真实 MySQL 黑盒
**未执行任何 Docker 命令**、**未执行真实 MySQL 黑盒**。本轮全部验证均为:shell 测试(假 docker/curl 注入)+ Maven 单元/集成测试(H2 + Flyway + 真实 Spring 上下文)。
## 24. 真实 MySQL / Docker 复验仍待独立验收
F1 的数据库取码路径(`docker exec` + 容器内 SQL)**尚未在真实 MySQL 环境端到端执行**;F3 的运行期 JAR 启动(含空值 / 缺失 / 非法值)亦未在真实环境启动验证。二者**必须由独立复验在真实 MySQL/Docker 环境完成**,不得以本轮 H2 结果代替。
---
## 遗留与后续
1. **验证码仍为数据库明文存储**(本轮未改表结构,属已知遗留)
2. **真实短信供应商未接入**;生产 `mock=false` 下发码一律安全失败,需产品确认上线策略
3. **存量 43 处直接身份解析**未收口(均不可匿名到达;`DeletionGuardInterceptor` 对 20 位数字溢出仍可能 500)——非本轮范围
4. **安全层 401 响应不含 requestId**(既有 `AuthenticationEntryPoint` 固定载荷),与业务层错误响应格式不一致
5. 上一轮验收的其余观察项(失败后残留 `sms:rate` 限流键 60s;Spring Boot 默认 `generated security password` 入日志)**本轮未处理**,仍属非阻断遗留
---
# 第三轮定向整改(F4 / F5)
- **整改日期**:2026-09-11(第二轮之后)
- **独立复验结论**:S01 仍 FAIL —— F2/F3 已通过,但新发现 F4(xtrace 泄露)与 F5(数据库密码进入 mysql argv)两个阻断项
- **本轮范围**:仅修 F4/F5,并相应扩展 shell 测试、修正文档测试口径
- **本轮修改文件**:`docs/smoke-test.sh`、`docs/tests/smoke-test-sms-code-test.sh`、本报告
- **本轮未触碰**:S01 Java 生产代码(时间戳仍为 15:45,本轮为 17:38+ 仅改 docs)
## T1. F4 修复前的泄露位置
以 `bash -x` 执行时,以下位置被 trace 到 stderr(原文来自复验报告,此处仅列位置):
| 位置 | 泄露内容 | 原因 |
|---|---|---|
| `PHONE="${STUDENT_PHONE}"` / 生成分支 | 完整手机号 | 脚本开头未关闭 xtrace |
| `SMS="$REPLY"` | 验证码 | `sms_code()` 返回前 `set -x` 恢复,赋值被 trace |
| `LOGIN_BODY="{...code...}"` | 验证码 + 完整手机号 | 赋值被 trace |
| `printf '%s' "$LOGIN_BODY" \| curl ...` | 登录请求体(含验证码) | 命令与参数被 trace |
| `LOGIN="$(...)"` | 登录响应全文(含 JWT) | 赋值被 trace |
| `TOKEN="$(extract_token "$LOGIN")"` | JWT | 赋值被 trace |
| `AUTH="Authorization: Bearer $TOKEN"` | JWT | 赋值被 trace |
| 后续 `curl ... -H "$AUTH"` | JWT | 命令参数被 trace |
| `sms_code()` 内部 `set +x` / `set -x` | 只保护了取码阶段 | 保护范围不足 |
## T2. 全局 xtrace 处理方式
采用「**开头立即关闭且全程不恢复**」(任务书推荐方案):
```bash
#!/usr/bin/env bash
# ── xtrace 防护(S01 第三轮 F4)──
# 必须在读取任何环境变量、组装任何请求之前关闭 xtrace。
# 脚本是独立可执行程序,安全优先:全程不重新 set -x。
case "$-" in
*x*) set +x ;;
esac
set -u
```
- 位置:`set +x` 位于**任何环境变量读取与任何请求组装之前**(仅在 shebang 与注释之后)
- `sms_code()` 内部的 `xtrace_was_on` 与恢复 `set -x` 逻辑**已彻底删除**(`grep -c xtrace_was_on` = 0)
- 脚本中**不存在可执行的 `set -x`**(仅 1 处注释提及,测试 F4-9 已断言)
- 保护范围覆盖全脚本:验证码取码、手机号、请求体、登录响应、TOKEN、AUTH 及**后续所有携带 Authorization 的 curl 调用**
- 未使用 `curl -v`/`--trace`/`--trace-ascii`(测试第 20 条断言)
- 登录响应全文不再交给打印函数:新增 `extract_status_fields()` + `report_fields()`,登录步骤只把 http / business code / requestId 传出去(测试第 21 条断言)
## T3. `bash -x` 真实执行结果
由测试脚本内部驱动(捕获被测脚本 stdout+stderr 后扫描标记,只输出 PASS/FAIL):
| 用例 | 结果 |
|---|---|
| F4-1 bash -x 下不泄露验证码 | PASS |
| F4-2 bash -x 下不泄露完整手机号 | PASS |
| F4-3 bash -x 下不泄露 JWT | PASS |
| F4-4 bash -x 下不泄露 Authorization 头 | PASS |
| F4-5 bash -x 下不泄露数据库密码 | PASS |
| F4-6 bash -x 下不泄露 LOGIN_BODY | PASS |
| F4-7 bash -x 下正常流程仍可完成登录 | PASS |
| F4-8 bash -x 下登录步骤判定 PASS | PASS |
| F4-9 脚本中不存在可执行的 set -x | PASS |
| F4-10 失败流程(无可用验证码)同样不泄露 | PASS |
| F4-11 source 模式下 REPLY 契约可用 | PASS |
| F4-12 source 模式下清理后无敏感变量残留 | PASS |
| F4-13 source 模式下 EXIT trap 已登记清理函数 | PASS |
| F4-14 cleanup_secrets 覆盖 PHONE/TOKEN/AUTH/COMPANY_TOKEN | PASS |
另外单独执行 `bash -x docs/tests/smoke-test-sms-code-test.sh` → exit=0、PASS=51/FAIL=0;
该模式下测试脚本自身的假值可能被 trace(任务书已明确属预期,不作为安全判定),
但**测试结论输出(stdout)中标记值命中数为 0**。
## T4. stdout/stderr 敏感标记命中数
以 `bash -x` 执行被测 `smoke-test.sh`(正常流程与失败流程各一次),对捕获的 stdout+stderr 递归扫描:
| 标记类别 | 命中数 |
|---|---|
| 测试验证码 | **0** |
| 完整手机号 | **0** |
| 测试 JWT | **0** |
| `Authorization: Bearer` | **0** |
| 数据库密码标记 | **0** |
| `LOGIN_BODY=` | **0** |
(报告不记录标记值本身;标记值均为测试用固定假值,仅存在于测试脚本内。)
## T5. TOKEN / AUTH 生命周期
| 变量 | 生命周期 |
|---|---|
| `REPLY` | `sms_code()` 成功时写入;调用方立即 `SMS="$REPLY"; unset REPLY` |
| `SMS` | 组装 `LOGIN_BODY` 后立即 `unset SMS` |
| `LOGIN_BODY` | 经 `--data-binary @-` 送入 curl 后立即 `unset LOGIN_BODY` |
| `LOGIN`(响应全文) | 仅用于提取 http/code/requestId 与 token;`extract_token` 后立即 `unset LOGIN` |
| `LOGIN_FIELDS` | 只含可安全打印的三字段,用完 `unset` |
| `TOKEN` | **保留到流程结束**(后续接口需要),但不打印、不写文件、不进验收产物 |
| `AUTH` | 由 TOKEN 派生,仅作为 curl 头参数使用;EXIT trap 统一清理 |
| `COMPANY_TOKEN` | 调用方传入,仅作 curl 头参数;EXIT trap 统一清理 |
| `PHONE` / `STUDENT_PHONE` | EXIT trap 统一清理;控制台只输出脱敏形式 |
`cleanup_secrets()` 现覆盖:`PHONE SMS OTP REPLY LOGIN_BODY LOGIN TOKEN AUTH` 与 `STUDENT_PHONE COMPANY_TOKEN`,由 `trap cleanup_secrets EXIT` 统一清理。
注意:登记块**不再**给 `PHONE`/`STUDENT_PHONE`/`COMPANY_TOKEN` 赋空值(避免覆盖调用方传入值)。
## T6. F5 修复前后的 mysql 调用形式
**修复前**
```bash
docker exec -i "$container" sh -c \
'mysql -uroot -p"$MYSQL_ROOT_PASSWORD" -N -B "${MYSQL_DATABASE:-mydreamjob}"'
```
容器内 shell 会把 `$MYSQL_ROOT_PASSWORD` 展开为 `-p<真实密码>`,进入 mysql 进程 argv。
**修复后**
```bash
docker exec -i "$container" sh -c '
if [ -z "${MYSQL_USER:-}" ] || [ -z "${MYSQL_PASSWORD:-}" ]; then
echo "db credentials unavailable" >&2
exit 3
fi
MYSQL_PWD="$MYSQL_PASSWORD" exec mysql \
-u"$MYSQL_USER" \
-N -B \
"${MYSQL_DATABASE:-mydreamjob}"' 2>/dev/null \
| head -n 1
```
## T7. mysql argv 是否含密码
测试用假 `mysql` 记录自身真实 argv(它正是被检查的进程),实测结果形态为:
```
-u<应用账号> -N -B <数据库名>
```
| 断言 | 结果 |
|---|---|
| F5-1 mysql argv 不含数据库密码值 | PASS |
| F5-2 宿主机 docker 命令行不含密码值 | PASS |
| F5-3 容器侧脚本文本不含密码值 | PASS |
| F5-4 容器侧脚本以变量名引用 MYSQL_PASSWORD | PASS |
| F5-5 mysql argv 不含 `-p<密码>` | PASS |
| F5-6 mysql argv 不含 `--password` | PASS |
| F5-13 密码不经 argv 传递 | PASS |
| F5-14 密码经 `MYSQL_PWD` 环境变量传递 | PASS |
## T8. 使用的数据库账号类型
- 使用**应用账号**(容器环境变量 `MYSQL_USER` / `MYSQL_PASSWORD` / `MYSQL_DATABASE`)
- **不使用 root**;`MYSQL_ROOT_PASSWORD` 在脚本中命中数为 0,`-uroot` 命中数为 0(F5-7 / F5-8 PASS)
- 凭据缺失时 `exit 3` 安全失败,**不回退 root**(F5-15 PASS)
- 未读取、未打印、未落盘凭据;未修改 compose.yaml;未修改 MySQL 用户权限;查询仅 `SELECT`(F5-11/F5-12 PASS)
## T9. Shell 测试实际数量
`docs/tests/smoke-test-sms-code-test.sh`:**51 用例,PASS=51 / FAIL=0**
构成:F1 取码保护 10 项 + F4 xtrace/源模式 14 项 + F5 argv 18 项 + 静态保证与文档门禁 9 项。
## T10. Maven 专项 / 关联 / 全量数量
| 命令 | Tests run |
|---|---|
| 7 类安全专项(含空值与回滚集成测试) | **61** |
| `*Auth*Test,*Security*Test,*Sms*Test,*Job*Test` | **227** |
| `mvn test` | **1671** |
| `mvn clean verify` | **1671** |
## T11. Failures / Errors / Skipped
**Failures=0、Errors=0、Skipped=0**(专项、关联、全量、clean verify 全部一致)
## T12. clean verify
`mvn clean verify` → **BUILD SUCCESS**(4:34),产出最新 JAR。
## T13. OpenAPI 数量
**173 paths / 211 operations**(`All checks passed!`);本轮未改 Controller 映射、未新增取码接口。
## T14. 精确修改文件(本轮)
| 文件 | 变更 |
|---|---|
| `docs/smoke-test.sh` | 开头新增全局 xtrace 关闭(全程不恢复);删除 `xtrace_was_on`/恢复逻辑;`otp_db_query` 改为应用账号 + `MYSQL_PWD`(去 root、去 `-p`);`cleanup_secrets` 覆盖面扩展;新增 `report_fields`/`extract_status_fields`,登录响应不再交给打印函数 |
| `docs/tests/smoke-test-sms-code-test.sh` | 扩展至 51 用例:真实 `bash -x` 执行与标记扫描、假 docker 执行容器侧脚本 + 假 mysql 记录 argv、source 模式泄漏防护、活跃文件文档门禁 |
| `docs/s01-remediation-f1f2f3-20260911.md` | 新增本「第三轮定向整改」章节 |
**未修改**:任何 Java 生产代码、任何既有测试、`docs/smoke-test.sh` 以外的文档正文、`.env`、compose.yaml、Flyway。
## T15. Git 状态
- 分支:`fix/resume-upload-safety`(未变)
- 暂存区:**空**
- `git diff --check`:无空白错误(仅 CRLF 提示)
- 迁移改动:**无**(V1–V41 共 41 个)
- 删除文件:**0**;历史未跟踪文件:**145 个全部保留**
- 本轮已跟踪文件改动仅 `docs/smoke-test.sh`(S01 Java 文件时间戳仍为 15:45,本轮未触碰)
## T16. 是否修改 Flyway、表、ErrorCode 或接口
**均未修改**:无新增/修改迁移、无表/索引变更、`ErrorCode` 未改、OpenAPI 保持 173/211。
## T17. 明确未暂存、未 commit、未 push
**未执行** `git add` / `git add -A`、**未 commit**、**未 push**(亦未 `reset`/`restore`/`checkout`/`clean`)。
## T18. 真实 MySQL / Docker 最终复验仍待独立执行
本轮**未执行任何 Docker 命令**,**未连接真实 MySQL**:
- F5 的 `docker exec` + `MYSQL_PWD` 路径仅在**假 docker / 假 mysql** 下验证了 argv 与 stdin 形态;
真实容器中 `MYSQL_USER` / `MYSQL_PASSWORD` 是否存在、`MYSQL_PWD` 是否被 mysql 客户端接受,**必须由独立复验在真实 Docker/MySQL 环境确认**。
- F4 的 xtrace 防护已由真实 `bash -x` 本地执行验证(无需容器),但**完整真实登录链路**仍待真实环境复验。
## T19. 文档测试口径修正(§7)
历史验收报告 `docs/s01-independent-mysql-acceptance-20260911.md` 与本报告**合理保留修复前缺陷证据**(含修复前的日志依赖描述与旧命令),因此**不得**以「整个 docs 目录命中数为 0」作为门禁结论。
修正后的口径(已落入 shell 测试第 22-24 条断言):
- **A. 活跃操作文档与正式代码**(必须无「从日志取码」有效指导):
`docs/acceptance-week1.md`、`docs/api-v0.1.md`、`docs/frontend-integration.md`、`docs/offer-api.md`、`docs/resume-api.md`、`docs/smoke-test.sh`、`README.md`、`mydreamjob-server/src/main/java`
- **B. 历史验收报告**(允许保留修复前失败证据,须明示为历史):
`docs/s01-independent-mysql-acceptance-20260911.md`、`docs/s01-remediation-f1f2f3-20260911.md`
测试使用**精确活跃文件列表**扫描(不扫描全部 docs),并额外断言两份历史报告仍然存在。
## T20. 本轮遗留(非阻断,待后续)
1. JWT 仍作为 `-H "Authorization: Bearer <token>"` 的 curl 参数存在于**进程 argv**(非 xtrace/stdout/stderr 问题;如需消除,应改用 `curl --config` 经 stdin 传头)
2. 验证码仍为数据库明文存储(未改表结构)
3. 真实短信供应商未接入
4. 存量 43 处直接身份解析未收口;安全层 401 响应不含 requestId
5. 上轮观察项(失败后残留 `sms:rate` 限流键 60s;Spring Boot 默认 `generated security password` 入日志)本轮未处理
---
**S01第三轮定向整改完成,待独立代码复验及真实MySQL/Docker黑盒复验。**
---
# 第四轮定向整改(F6)
- **整改日期**:2026-09-14
- **独立复验结论**:S01 仍 FAIL —— F4 与 F5 已通过(bash -x 不泄露 / Shell 51 项 / Maven 安全专项 61 项 / 密码不进 mysql argv 均通过),新阻断项 **F6**:冒烟脚本使用了 `sms_verification_codes` 不存在的列 `expires_at`
- **本轮范围**:仅修 F6,并补真实表结构契约测试
- **本轮修改文件**:`docs/smoke-test.sh`、`docs/tests/smoke-test-sms-code-test.sh`、`docs/tests/smoke-test-schema-contract-test.sh`(新增)、本报告
- **本轮未触碰**:全部 Java 生产代码、实体、Repository、Flyway、数据库表、`ErrorCode`、公网接口
## G1. F6 错误位置
| 项 | 内容 |
|---|---|
| 文件 | `docs/smoke-test.sh` |
| 行 | **L122** |
| 修复前 | `" AND expires_at > NOW()" \` |
| 修复后 | `" AND expired_at > NOW()" \` |
| 后果 | 真实 MySQL 报 ERROR 1054,冒烟脚本取不到验证码,**F1 未闭环** |
## G2. 真实 MySQL SHOW COLUMNS 结果摘要
独立复验在真实 MySQL 执行 `SHOW COLUMNS FROM sms_verification_codes`,实际字段为:
```
id phone code purpose attempts max_attempts expired_at used created_at
```
**不存在 `expires_at`。** 该结论与两个契约来源一致:
| 来源 | 位置 | 结果 |
|---|---|---|
| 实体 `@Column` | `auth/SmsVerificationCode.java:46` | `@Column(name = "expired_at", nullable = false)` |
| 建表 Flyway | `db/migration/V1__base_schema.sql:37` | `expired_at DATETIME NOT NULL` |
## G3. 修复前的真实报错
```
ERROR 1054 (42S22): Unknown column 'expires_at' in 'where clause'
```
## G4. 修复后的 SQL
```sql
SELECT code FROM sms_verification_codes
WHERE phone = '<phone>' AND purpose = '<purpose>' AND used = 0
AND expired_at > NOW()
ORDER BY created_at DESC, id DESC LIMIT 1;
```
除列名外**未改动任何查询口径**,以下均保持不变:phone 精确匹配、purpose 精确匹配、`used = 0`、`expired_at > NOW()`、`ORDER BY created_at DESC, id DESC`、`LIMIT 1`、仅 SELECT、使用应用数据库账号(非 root)、密码经 `MYSQL_PWD` 不进 argv、SQL 经 stdin、验证码不输出。
## G5. 新增的 Shell 断言
`docs/tests/smoke-test-sms-code-test.sh` 由 **51 项扩展至 69 项**,新增 Part 6 共 **18 条**:
| 编号 | 断言 |
|---|---|
| F6-1 | `otp_db_query` 使用 `expired_at > NOW()` |
| F6-2 | `otp_db_query` 不再出现错误字段 `expires_at` |
| F6-3 | 查询只使用 `sms_verification_codes` 的真实列 |
| F6-4 | 真实列清单由 Flyway 派生且含 `expired_at`(非空、非自证) |
| F6-5 | 假 mysql 捕获到完整 stdin SQL(非无条件返回验证码) |
| F6-6 | SQL 字段错误时假 mysql 返回失败(非 0) |
| F6-6b | 字段错误时报 ERROR 1054 (42S22) |
| F6-7 | 字段错误时 `sms_code()` 不继续登录 |
| F6-7b | 字段错误时未发出任何 login 请求 |
| F6-8 | 正确字段时取码成功并完成登录 |
| F6-9 | `expired_at` 恰好等于 NOW() 时不得取出 |
| F6-10 | `expired_at` 晚于 NOW() 时可以取出 |
| F6-11 | `expired_at` 早于 NOW() 时不得取出 |
| F6-12 | `used=1` 不得取出 |
| F6-13 | `purpose` 不匹配不得取出 |
| F6-14 | `phone` 不匹配不得取出 |
| F6-15 | 多条有效记录只取 `created_at`/`id` 最新一条 |
| F6-15b | 与输入顺序无关,按 `created_at`/`id` 排序取值 |
**另新增独立契约门禁** `docs/tests/smoke-test-schema-contract-test.sh`(**15 条**),契约来源为**实体 `@Column` + 建表 Flyway**,并交叉校验二者列集合一致:
C1–C4 来源存在性与解析非空 → C5 两个独立来源列集合一致 → C6 `expired_at` 存在 → C7 **`expires_at` 不存在**(表限定)→ C8 实体映射存在 → C9 `expires_at` 确实是其他表的真实列 → C10–C15 脚本 SQL 的表名、列存在性、列不含 `expires_at`、所用列全部真实。
## G6. fake docker / fake mysql 是否校验 SQL
**本轮之前:不校验。** 旧假 mysql 只是 `cat` 掉 stdin 后无条件 `printf "${FAKE_CODE}"`,因此错误的 `expires_at` 也能"取到"验证码并通过测试——这正是 F6 能逃过 51 项测试的原因。
**本轮之后:校验。** 假 mysql 改为:
1. 完整读取并保存 stdin SQL(不再丢弃);
2. 若 SQL 命中 `sms_verification_codes` 且包含 `expires_at` → 如实复现真实 MySQL 的 `ERROR 1054 (42S22)` 并 `exit 1`;
3. 设置 `FAKE_ROWS` 时,按行套用 `WHERE phone/purpose/used` 与 `expired_at > NOW()` 语义、按 `created_at DESC, id DESC` 取值;未设置时保留历史行为供 F1/F5 既有用例使用。
## G7. 过期 / 有效 / used / purpose / phone 口径
| 场景 | 假表语义 | 期望 | 结果 |
|---|---|---|---|
| `expired_at` 恰好 = NOW() | offset = 0 | 不取出(查询为严格 `>`) | PASS |
| `expired_at` 晚于 NOW() | offset = +300 | 取出 | PASS |
| `expired_at` 早于 NOW() | offset = −300 | 不取出 | PASS |
| `used = 1` | — | 不取出 | PASS |
| `purpose` 不匹配 | 行 purpose=`register`,查 `login` | 不取出 | PASS |
| `phone` 不匹配 | 行 phone 为另一号码 | 不取出 | PASS |
| 多条有效 | 两条均有效,`created_at` 不同 | 只取最新一条 | PASS |
## G8. F4 / F5 回归结果
全部保持通过(本轮未改动 xtrace 防护与 `MYSQL_PWD` 路径):
| 回归项 | 结果 |
|---|---|
| `bash -x` 不泄露验证码 / 完整手机号 / JWT / Authorization / 数据库密码 / LOGIN_BODY | PASS(F4-1 ~ F4-6) |
| `bash -x` 下正常流程仍可完成登录 | PASS(F4-7、F4-8) |
| 脚本中不存在可执行的 `set -x` | PASS(F4-9) |
| 失败流程同样不泄露 | PASS(F4-10) |
| source 模式 REPLY 契约 / 无敏感变量残留 / EXIT trap | PASS(F4-11 ~ F4-14) |
| mysql argv 不含密码值、不含 `-p`/`--password` | PASS(F5-1 ~ F5-6) |
| 不使用 root / 使用应用账号与数据库名 | PASS(F5-7 ~ F5-10) |
| SQL 经 stdin 且仅 SELECT | PASS(F5-11、F5-12) |
| 密码经 `MYSQL_PWD` 传递、不经 argv | PASS(F5-13、F5-14) |
| 凭据缺失时安全失败(不回退 root) | PASS(F5-15) |
| 失败路径 stderr 不含数据库密码 | PASS(F5-18) |
| 不恢复 `MOCK SMS CODE` 日志、活跃文档不指导从日志取码 | PASS(14、22、23) |
## G9. Shell 测试数量
| 文件 | 用例数 | 结果 |
|---|---|---|
| `docs/tests/smoke-test-sms-code-test.sh` | **69** | PASS=69 / FAIL=0 |
| `docs/tests/smoke-test-schema-contract-test.sh` | **15** | PASS=15 / FAIL=0 |
合计 **84** 条,全部通过(真实 Git Bash 执行)。
**断言非空转的证据(测试自检)**:把 `smoke-test.sh` 还原为 buggy 版本(`expires_at`)放入镜像目录,用**同一套测试**执行:
- `smoke-test-sms-code-test.sh` → `PASS=56 FAIL=13`,其中 **F6-1 / F6-2 / F6-3 翻红**,F6-3 直接点名不存在的列 `expires_at`;F6-8/10/15/15b 因取不到码一并失败;
- `smoke-test-schema-contract-test.sh` → `PASS=12 FAIL=3`,**C12 / C13 / C14 翻红**,C14 点名 `expires_at`。
即:本轮断言确实能识别**不存在的 SQL 列**,而非"改前改后都通过"。
## G10. Maven 专项 / 关联 / 全量测试数
| 命令 | Tests run |
|---|---|
| `-Dtest="SmsSenderConfigurationTest,SmsVerificationSecurityTest,SmsVerificationServiceTest,CurrentUserAccessorTest,AuthenticationBoundaryIntegrationTest,SmsEmptyPropertyStartupIntegrationTest,SmsUnavailableSenderRollbackIntegrationTest"` | **61** |
| `-Dtest="*Auth*Test,*Security*Test,*Sms*Test,*Job*Test"` | **227** |
| `mvn test` | **1671** |
| `mvn clean verify` | **1671** |
本轮仅改 `docs/` 下的脚本与报告,未增删任何 Java 测试,故总数与上一轮一致(1671)。
## G11. Failures / Errors / Skipped
**Failures=0、Errors=0、Skipped=0**(专项、关联、全量、clean verify 四者一致)。
## G12. clean verify
`mvn clean verify` → **BUILD SUCCESS**(Total time 02:11),产出 `target/mydreamjob-server-0.0.1-SNAPSHOT.jar`。
## G13. OpenAPI 计数
`python docs/verify_openapi.py` → **All checks passed!**,**173 paths / 211 operations**(与上一轮一致,未新增/删除路径或 operation)。
## G14. Git 状态
- 分支:`fix/resume-upload-safety`(未变)
- **暂存区:空**
- `git diff --check`:无空白错误(仅 CRLF 提示)
- 迁移改动:**无**(`git diff --name-only -- mydreamjob-server/src/main/resources/db/migration` 无输出;目录内 41 个文件,最大版本 `V41__message_recall.sql`)
- 本轮文件时间戳:`docs/smoke-test.sh` 09:22、`smoke-test-sms-code-test.sh` 09:24、`smoke-test-schema-contract-test.sh` 09:27、本报告 09:32(均 2026-09-14)
- **S01 Java 生产代码时间戳仍为 2026-09-11 15:45**,本轮未触碰
- 删除文件:**0**;历史未跟踪文件:未删除、未移动、未覆盖
## G15. 明确未修改 Java、Flyway、表、ErrorCode 或接口
**均未修改。**
- Java 生产代码:**0 处改动**(时间戳可证)
- 实体与 Repository:未改
- Flyway:未新增/修改,V1–V41 原样
- 数据库表/列/索引:未新增或修改;**未为迁就脚本把列改成 `expires_at`**
- `ErrorCode`:未改
- 公网接口:OpenAPI 保持 173/211,未新增取码接口
- **未恢复验证码日志**(`grep -rn "MOCK SMS CODE" mydreamjob-server/src` 命中 0)
## G16. 明确未暂存、commit、push
**未执行** `git add` / `git add -A`、**未 commit**、**未 push**(亦未 `reset`/`restore`/`checkout`/`clean`)。**未操作 Docker**,未进入 S02。
## G17. 真实 MySQL 完整冒烟链仍待最终独立复验
本轮**未执行任何 Docker 命令、未连接真实 MySQL**。全部验证均为:shell 测试(假 docker / 假 mysql 注入)+ Maven H2/Flyway 测试。
因此以下**必须由独立复验在真实 MySQL/Docker 环境完成**:
1. `SHOW COLUMNS FROM sms_verification_codes` 的 Field 集合与 Flyway 列集合对账(含 `expired_at`、不含 `expires_at`);
2. 修复后的取码 SQL 在真实 MySQL 端到端执行成功(不再出现 ERROR 1054);
3. F1 完整冒烟链(发码 → 取码 → 登录 → 后续接口)闭环。
**不得以本轮假库结果代替真实环境验收。**
## G18. 一处必须记录的旁证(不在本轮修改范围)
`docs/acceptance/s01-20260911/snapshot-sms.sql:4` 同样使用 `expires_at` 查询 `sms_verification_codes`:
```sql
SELECT id, purpose, CHAR_LENGTH(code) AS code_len,
TIMESTAMPDIFF(SECOND, NOW(), expires_at) AS expire_delta_s, ...
FROM sms_verification_codes
```
这是同一字段错误的传播。该文件属上一轮独立验收证据,**不在本轮允许修改的文件范围内**,故保持原样未改,仅在此记录,供后续处理。
## G19. 字段名易混淆的说明(供后续维护)
`expires_at` 并非拼写错误,它是本仓库中**三张表的真实列**:
| 表 | 迁移 |
|---|---|
| `personal_data_export_tasks.expires_at` | V30 |
| `ability_attempts.expires_at` | V35 |
| `talent_invitations.expires_at`(含索引) | V40 |
只有 `sms_verification_codes` 用 `expired_at`。因此本轮门禁**按表限定**,未写成"全库禁用 `expires_at`"——那样会误报并掩盖真实错误。契约门禁 C9 专门守卫这一区分。
---
**S01第四轮定向整改完成,待最终独立代码复验及真实MySQL/Docker黑盒复验。**

674
docs/s02-auth-token-isolation-design.md

@ -0,0 +1,674 @@
# S02 Access/Refresh Token 隔离、轮换与吊销设计
状态:S02 已实现并完成自动化验证与干净构建,待独立代码复验及真实 MySQL/Docker 黑盒验收。
文档类型:解释与技术参考。
目标读者:产品负责人、后端开发、前端联调、安全复验和真实环境验收人员。
解释版本:`s02-auth-token-isolation-v1`
## 1. 目标
S02 修复当前 Refresh Token 可以作为 Access Token 使用的认证边界缺陷,并建立可轮换、可吊销、可检测重放的登录会话模型。
本设计必须保证:
- 普通业务接口只接受 Access Token;
- 刷新接口只接受 Refresh Token;
- Token 原文不进入数据库、日志、审计、URL 或错误响应;
- 登录、刷新、登出、账号注销和用户禁用具有明确的吊销语义;
- 同一个 Refresh Token 在并发或重放场景中最多成功刷新一次;
- 旧的无 `type` Token 不被永久兼容;
- 不改变手机号验证码登录、角色、资源归属、评分、排名、推荐、邀约、申诉、消息撤回和简历上传语义。
## 2. 当前实现事实
截至基线提交 `96a920b`:
1. `JwtTokenProvider.generateAccessToken` 没有强制写入 `type=access`。
2. `generateRefreshToken` 写入了 `type=refresh`。
3. `JwtAuthenticationFilter` 只验证 JWT 签名和过期时间,不验证 Token 类型。
4. Access Token 与 Refresh Token 使用同一 `app.jwt.secret`。
5. 因此签名有效且未过期的 Refresh Token 可以通过过滤器并建立普通业务登录态。
6. 登录响应已经包含 `accessToken`、`refreshToken` 和 `expiresIn`。
7. 当前没有 `/auth/refresh` 和 `/auth/logout`。
8. `auth_tokens` 表、`AuthToken` 实体和 `AuthTokenRepository` 已存在,但未被登录、刷新、鉴权、登出或账号注销使用。
9. 当前 JWT 没有 `sid` 和 `jti`,不能标识登录会话和单个 Token。
10. 当前账号注销完成不会使已经签发的 JWT 立即失效。
11. 当前 JWT 包含完整手机号;普通鉴权实际只需要用户 ID 和会话信息。
12. `application.yml` 的 `refresh-expiration-ms` 当前错误地位于 `app.resume` 下,而 `application-dev.yml` 位于正确的 `app.jwt` 下。非 dev 环境可能依赖 Java 默认值,配置契约不一致。
13. 现有 Access Token 默认有效期为 24 小时,Refresh Token 默认有效期为 7 天。
14. 现有 `auth_tokens` 表没有会话标识、轮换父子关系、消费时间、撤销时间和乐观锁版本。
## 3. 威胁模型
本版重点防护以下风险:
- Refresh Token 被复制到 `Authorization: Bearer` 后直接调用普通业务接口;
- 已登出或已注销用户继续使用旧 Access Token;
- Refresh Token 在日志、数据库、错误响应或 URL 中泄露;
- 同一个 Refresh Token 被并发刷新两次,产生两组有效 Token;
- 已消费 Refresh Token 被攻击者再次使用而系统无法识别;
- Token 类型缺失、大小写变化或未知值被宽松接受;
- 非法 subject、`sid` 或 `jti` 引发 500 或回显内部数据;
- 数据库或会话校验异常时过滤器错误放行;
- 多设备登录时,一个设备的普通登出误伤其他设备;
- 账号注销、用户禁用等全局安全事件没有撤销全部会话。
不在本版解决:终端设备指纹、地理异常检测、硬件绑定、OAuth/OIDC、短信供应商、验证码摘要化、密码登录和单点登录。
## 4. 设计决策
### D01:Token 类型强制隔离
- Access Token 必须包含精确字符串 `type=access`。
- Refresh Token 必须包含精确字符串 `type=refresh`。
- Token 类型区分大小写。
- 类型缺失、空白、未知或不匹配均拒绝。
- 普通业务过滤器只能解析 Access Token。
- 刷新服务只能解析 Refresh Token。
### D02:Access 与 Refresh 使用独立签名密钥
新增配置:
```yaml
app:
jwt:
access-secret: ${JWT_ACCESS_SECRET}
refresh-secret: ${JWT_REFRESH_SECRET}
token-hash-secret: ${JWT_TOKEN_HASH_SECRET}
```
三项密钥必须彼此独立。生产环境不允许自动回退到同一个默认密钥。测试配置使用固定的测试专用值,但不得进入生产配置。
独立密钥是类型 claim 之外的第二道隔离:即使未来某处遗漏类型判断,Refresh Token 也不能通过 Access Token 验签器。
### D03:旧 Token 采用强制重新登录
部署 S02 后:
- 无 `type` 的旧 Access Token 拒绝;
- 旧 Refresh Token 虽有 `type=refresh`,但没有 `sid`、`jti` 和服务端记录,同样拒绝;
- 用户需要重新完成一次手机号验证码登录;
- 不提供永久兼容开关;
- 不把无 `type` Token 猜测为 Access Token。
前端必须统一处理 401:清除本地 Token,跳转登录页,不循环刷新。
### D04:保持现有有效期
- Access Token 默认 24 小时;
- Refresh Token 默认 7 天;
- 登录响应的 `expiresIn=86400` 保持现有含义;
- S02 不顺带缩短 Token 有效期;
- 有效期必须来自配置并经过正数、上限和单位校验。
### D05:每次登录创建独立设备会话
- 每次成功登录生成新的 `sessionId`;
- 同一用户可以同时拥有多个活动会话;
- 普通登出只撤销当前 `sessionId`;
- Refresh Token 重放只撤销对应 `sessionId`;
- 账号注销完成、用户禁用及未来的敏感认证信息变更撤销该用户全部会话。
本版不采集或保存设备指纹,不通过 User-Agent 判断设备身份。
### D06:JWT Claim 白名单
Access Token 必须包含:
| Claim | 说明 |
|---|---|
| `sub` | 十进制正整数用户 ID |
| `type` | 固定为 `access` |
| `sid` | UUID 格式的会话 ID |
| `jti` | UUID 格式的 Token ID |
| `role` | 登录时角色,仅作响应和诊断辅助;资源权限仍以现有服务层校验为准 |
| `iat` | 签发时间 |
| `exp` | 过期时间 |
| `iss` | 固定发行者 `mydreamjob` |
| `aud` | 固定受众 `mydreamjob-api` |
Refresh Token 必须包含:
| Claim | 说明 |
|---|---|
| `sub` | 十进制正整数用户 ID |
| `type` | 固定为 `refresh` |
| `sid` | 与当前登录会话一致 |
| `jti` | 本次 Refresh Token 的唯一 ID |
| `iat` | 签发时间 |
| `exp` | 过期时间 |
| `iss` | 固定发行者 `mydreamjob` |
| `aud` | 固定受众 `mydreamjob-auth` |
新 Token 不再包含手机号。不得加入验证码、邮箱、昵称、简历地址或其他个人资料。
### D07:Refresh Token 原文不落库
数据库只保存:
```text
HMAC-SHA256(JWT_TOKEN_HASH_SECRET, rawRefreshToken)
```
- 不使用普通可逆加密;
- 不使用无密钥的快速摘要作为唯一保护;
- 比较摘要时使用常量时间比较;
- Token、摘要和签名密钥均不得写日志或审计;
- `token_hash` 的业务含义固定为 Refresh Token 摘要。
### D08:每次刷新强制轮换
刷新成功必须:
1. 捕获一次 `capturedNow`;
2. 使用 Refresh 专用密钥验签;
3. 验证 `type`、`iss`、`aud`、`sub`、`sid`、`jti`、`iat` 和 `exp`;
4. 计算原始 Refresh Token 摘要;
5. 查询对应服务端记录;
6. 原子消费旧记录;
7. 生成新的 Access/Refresh Token 对;
8. 插入新的 Refresh Token 记录;
9. 提交事务后返回响应。
旧 Refresh Token 被消费后永久失效。
### D09:Refresh Token 重放关闭当前会话
若签名有效的 Refresh Token 对应记录已经被消费或撤销:
- 本次刷新返回 HTTP 401;
- 撤销相同 `sessionId` 下的全部 Token 记录;
- 当前会话的所有 Access Token 随即失效;
- 不影响同一用户的其他设备会话;
- 写一条不含 Token、摘要或个人信息的安全审计;
- 错误响应不得说明记录是否存在、已消费还是已撤销。
### D10:Access Token 必须检查服务端会话状态
`JwtAuthenticationFilter` 在完成 Access JWT 验签后,还必须确认:
- `sid` 属于 `sub` 对应用户;
- 会话存在活动、未消费且未撤销的 Refresh Token 记录;
- 会话未过期;
- 用户存在、处于 active 状态且未完成注销。
任一条件不满足时不得建立 `SecurityContext`。
数据库是第一版的事实来源。Redis 缓存不属于 S02 首版;后续若增加缓存,数据库仍是最终事实来源,缓存异常不得放行。
### D11:普通登出只撤销当前会话
`POST /auth/logout`:
- 需要有效 Access Token;
- 从 Token 中读取 `sub` 和 `sid`,不接受客户端传 userId/sessionId;
- 撤销该用户当前 `sid` 的所有 Token 记录;
- 重复登出保持幂等;
- 不影响其他设备会话;
- 成功响应不返回 Token。
### D12:全局安全事件撤销全部会话
以下事件必须调用统一的 `revokeAllUserSessions(userId, reason, capturedNow)`:
- 账号注销完成;
- 用户被禁用;
- 未来的手机号或认证凭据变更。
账号注销申请处于冷静期时不立即撤销全部会话,保持当前产品语义;注销完成时必须撤销。取消注销不恢复已经撤销的会话。
### D13:单次时间捕获
登录、刷新、登出、重放处置和全局吊销每个入口只允许调用一次 `Clock`:
- JWT `iat`、`exp`;
- 数据库 `createdAt`、`expiredAt`、`consumedAt`、`revokedAt`;
- 审计时间判断;
- 响应有效期;
均使用同一个 `capturedNow`。不得依赖 `new Date()`、`LocalDateTime.now()` 或系统默认时区散落取时。
固定业务时区为 `Asia/Shanghai`,数据库时间按项目现有约定落库。
### D14:失败关闭
以下情况一律不建立认证:
- Token 解析异常;
- 类型、发行者或受众错误;
- subject、`sid` 或 `jti` 非法;
- 服务端记录不存在;
- 会话已撤销、已消费或过期;
- 用户不存在、被禁用或已注销;
- 数据库读取或摘要计算失败。
过滤器只记录固定安全文本和 requestId,不记录异常消息、Token、摘要或 claim 原值。
### D15:错误码与 HTTP 语义
优先复用现有:
- `UNAUTHORIZED(2001)`;
- `TOKEN_EXPIRED(2003)`;
- `TOKEN_INVALID(2004)`;
- `SERVICE_UNAVAILABLE(9001)`。
公共认证失败统一表现为 HTTP 401。不得通过不同响应告诉客户端 Token 是“不存在”“已消费”“已撤销”还是“重放”。
除非实现阶段发现现有错误映射无法满足固定 401,才允许提出新增 ErrorCode;未获确认不得新增。
### D16:接口增量固定为两个 operation
新增:
- `POST /auth/refresh`;
- `POST /auth/logout`。
预计 OpenAPI 从 `173 paths / 211 operations` 变为 `175 paths / 213 operations`。
不得新增会话管理列表、全部设备登出、Token introspection 或管理员代操作接口。
### D17:并发由数据库条件更新保证
同一 Refresh Token 并发刷新:
- 使用包含 `id`、`consumed_at IS NULL`、`revoked=0` 和 `version` 的条件更新;
- 只有一个事务可以消费成功;
- 其他事务不得生成第二组有效 Token;
- 失败方执行重放处置或返回固定 401;
- 不使用 JVM 本地锁作为正确性基础。
### D18:设计范围隔离
S02 不改变:
- 短信验证码生成、过期、锁定和发送语义;
- 登录自动创建用户的现有规则;
- 用户角色及资源归属;
- 岗位、投递、Offer、评分、排名、推荐、人才发现、邀约;
- 申诉、消息撤回和简历上传;
- V1–V41 迁移内容。
## 5. Token 决策表
| Token/状态 | 普通业务接口 | `/auth/refresh` | `/auth/logout` | 注销完成后 | 过期后 |
|---|---|---|---|---|---|
| 有效 Access | 允许 | 拒绝 | 允许 | 拒绝 | 拒绝 |
| 有效 Refresh | 拒绝 | 允许并轮换 | 拒绝 | 拒绝 | 拒绝 |
| 无 `type` 旧 Token | 拒绝 | 拒绝 | 拒绝 | 拒绝 | 拒绝 |
| 未知 `type` | 拒绝 | 拒绝 | 拒绝 | 拒绝 | 拒绝 |
| 已消费 Refresh | 拒绝 | 401 并撤销当前会话 | 拒绝 | 拒绝 | 拒绝 |
| 已撤销 Token | 拒绝 | 拒绝 | 幂等 | 拒绝 | 拒绝 |
| 会话不存在 | 拒绝 | 拒绝 | 拒绝 | 拒绝 | 拒绝 |
| 用户被禁用 | 拒绝 | 拒绝 | 拒绝 | 拒绝 | 拒绝 |
## 6. 状态机
单个 Refresh Token 记录状态:
```text
ACTIVE -> CONSUMED
ACTIVE -> REVOKED
CONSUMED -> REVOKED (重放或全局吊销)
```
不允许从 `CONSUMED` 或 `REVOKED` 恢复为 `ACTIVE`。
会话状态由同一 `sessionId` 下的记录推导:存在一个未消费、未撤销且未过期的当前 Refresh Token 记录时,会话为活动状态。
## 7. 数据模型
### 7.1 迁移原则
- 编码前重新确认最新 Flyway;当前建议文件名为 `V42__auth_token_sessions.sql`;
- 若 V42 已被占用,停止并重新编号;
- 不修改 V1–V41;
- 不删除旧记录;
- 新字段先允许旧数据为空,新签发记录由应用强制非空;
- Hibernate 使用 `ddl-auto=validate`;
- 迁移必须兼容真实 MySQL 8.4 和项目 H2 测试模式。
### 7.2 `auth_tokens` 扩展字段
建议增加:
| 字段 | 类型 | 说明 |
|---|---|---|
| `session_id` | VARCHAR(36) NULL | 每次登录的会话 UUID |
| `token_jti` | VARCHAR(36) NULL | Refresh Token 的 jti |
| `parent_token_id` | BIGINT NULL | 轮换前一条记录 ID |
| `consumed_at` | DATETIME NULL | 成功刷新时消费时间 |
| `revoked_at` | DATETIME NULL | 撤销时间 |
| `revocation_reason` | VARCHAR(40) NULL | 固定白名单原因 |
| `version` | BIGINT NOT NULL DEFAULT 0 | 乐观锁/条件更新版本 |
固定含义:
- `token_hash`:Refresh Token 的 HMAC-SHA256 摘要;
- `token_type`:新记录固定 `REFRESH`;
- `expired_at`:Refresh Token 到期时间;
- `revoked`:现有兼容布尔值;
- `refresh_token_hash`:旧字段保留但新逻辑不依赖,后续版本再评估移除。
索引建议:
```sql
INDEX idx_auth_tokens_session_active (session_id, revoked, consumed_at, expired_at)
INDEX idx_auth_tokens_user_session (user_id, session_id)
UNIQUE INDEX uk_auth_tokens_token_jti (token_jti)
```
不得在本轮清理或改写旧 `auth_tokens` 数据。`session_id IS NULL` 的旧记录永远不能被新鉴权逻辑视为活动会话。
## 8. 组件职责
### `JwtTokenProvider`
- 分别生成和解析 Access/Refresh Token;
- 使用不同签名密钥;
- 强制校验类型、发行者、受众和必需 claim;
- 接收外部传入的 `capturedNow`,不自行重复取时;
- 不访问数据库。
### `AuthTokenHasher`
- 只负责 Refresh Token HMAC 摘要;
- 使用独立密钥;
- 支持常量时间比较;
- 不记录输入和输出。
### `AuthSessionService`
- 创建会话和首个 Refresh Token 记录;
- 验证、消费、轮换 Refresh Token;
- 判断 Access Token 所属会话是否活动;
- 撤销当前会话或用户全部会话;
- 承担事务与并发控制。
### `JwtAuthenticationFilter`
- 只接受 Access Token;
- 调用 Provider 完成密码学和 claim 校验;
- 调用 Session Service 验证服务端会话状态;
- 通过后建立 SecurityContext;
- 失败时保持匿名,由 SecurityFilterChain 返回既有 401。
### `AuthService`
- 登录成功时创建服务端会话;
- 只在会话记录成功持久化后返回 Token;
- 编排刷新和登出;
- 保持现有用户登录/自动创建语义。
### `AuthController`
- 接收白名单 DTO;
- 不解析 JWT、不计算摘要、不接收 userId/sessionId;
- 不在异常响应中回显 Token;
- 不返回 Entity 或数据库字段。
## 9. API 契约
### 9.1 登录
现有 `POST /auth/login` 请求不变。
响应字段保持:
- `userId`
- `phone`
- `nickname`
- `role`
- `accessToken`
- `refreshToken`
- `expiresIn`
新 Token 从 S02 起包含完整类型和会话 claim。旧客户端不需要解析 JWT 内部 claim。
### 9.2 刷新
`POST /auth/refresh`
请求:
```json
{
"refreshToken": "<refresh-token>"
}
```
成功响应:
```json
{
"code": 1000,
"data": {
"accessToken": "<new-access-token>",
"refreshToken": "<new-refresh-token>",
"expiresIn": 86400
},
"requestId": "<request-id>"
}
```
要求:
- 请求体只允许 `refreshToken`;
- 不能为空,设置合理长度上限;
- 不接受查询参数或路径参数中的 Token;
- 旧 Refresh Token 刷新成功后立即失效。
### 9.3 登出
`POST /auth/logout`
- 使用 `Authorization: Bearer <access-token>`;
- 无 request body;
- 成功返回现有 `ApiResponse` 包装的固定消息;
- 重复登出按幂等成功处理,但已经撤销的 Access Token通常无法再通过过滤器,因此客户端不应依赖第二次请求;
- 不接收 Refresh Token、userId 或 sessionId 参数。
## 10. SecurityConfig
- `/auth/login`、`/auth/sms/send` 和 `/auth/refresh` 允许匿名进入;
- `/auth/logout` 必须 authenticated;
- `permitAll` 只表示可进入 Controller,不代表 Refresh Token 校验成功;
- 其他接口规则不变;
- 不扩大 `/auth/**` 为整体公开;
- Refresh Token 放入 Authorization 头访问任何普通接口必须得到 401。
## 11. 事务与并发
### 登录
用户读取/创建、会话记录创建和登录审计在一个事务内完成。会话记录失败时不能返回 Token。
### 刷新
旧 Token 条件消费和新 Token 记录插入在一个事务内完成。提交失败时不能返回新 Token。
### 登出
同一事务撤销当前会话记录并写脱敏审计。
### 重放
发现已消费 Token 时,在独立明确事务中撤销对应会话,避免刷新事务回滚后撤销也丢失。实现必须通过测试证明最终会话已撤销。
## 12. 审计与日志
允许的审计动作建议:
- `AUTH_SESSION_CREATED`
- `AUTH_TOKEN_REFRESHED`
- `AUTH_SESSION_REVOKED`
- `AUTH_REFRESH_REUSE_DETECTED`
若新增审计动作不需要新增 ErrorCode,也不需要新表。
审计详情仅允许:
- userId;
- sessionId 的 HMAC 摘要或固定截断不可逆标识;
- reason 白名单;
- requestId;
- 结果状态。
禁止:
- Access Token;
- Refresh Token;
- Token 摘要;
- JWT claim 全文;
- 完整手机号、邮箱;
- Authorization 请求头;
- 异常对象 `toString()`。
## 13. 前端联调语义
前端必须:
1. 登录后分别保存 Access 和 Refresh Token;
2. 普通请求只携带 Access Token;
3. 收到普通 401 时最多执行一次刷新;
4. 刷新成功后原子替换两个 Token;
5. 刷新失败时清除两个 Token并跳转登录;
6. 防止多个并发请求同时用同一 Refresh Token刷新;
7. 登出时调用 `/auth/logout`,随后无条件清除本地 Token;
8. S02 上线后接受一次强制重新登录。
不允许把 Refresh Token 放入 URL、本地日志、埋点或错误上报正文。
## 14. 自动化测试要求
至少覆盖:
### Provider
- Access 包含 `type=access`;
- Refresh 包含 `type=refresh`;
- 两类 Token 使用不同密钥;
- 交叉验签失败;
- issuer/audience/type/sub/sid/jti 缺失或错误时拒绝;
- 过期边界;
- 新 Token 不含手机号。
### Filter
- Access Token 可访问普通接口;
- Refresh Token 访问普通接口为 401;
- 无 type、未知 type、非法 subject/sid/jti 为 401;
- 已撤销会话、用户禁用、注销完成为 401;
- 服务端检查异常时不放行;
- 响应和日志不回显 Token。
### 登录与存储
- 登录生成一条活动会话记录;
- 数据库只保存摘要;
- 登录响应结构不变;
- 会话持久化失败不返回 Token;
- 多次登录生成不同 sessionId。
### 刷新与并发
- 正常刷新轮换 Token;
- 原 Token 变为 consumed;
- 新旧 jti 不同、sid 相同;
- 同一 Refresh Token 第二次使用返回 401并撤销当前会话;
- 两线程并发刷新最多一个成功;
- 最终只有一个后继 Token;
- 无 500、死锁或重复活动记录。
### 登出与注销
- 登出撤销当前设备;
- 其他设备仍可用;
- 重复撤销幂等;
- 注销完成撤销全部设备;
- 注销取消不恢复旧会话;
- 被禁用用户 Token 无效。
### JSON 与日志脱敏
递归禁止出现 Token、摘要、Authorization、手机号、邮箱、secret、password 和内部对象。
## 15. 真实 MySQL/Docker 验收
必须验证:
- 新迁移在真实 MySQL 8.4 成功;
- `ddl-auto=validate` 通过;
- 登录记录只含摘要;
- Refresh Token 无法调用普通接口;
- Access Token 无法调用刷新接口;
- 正常轮换、重放和两线程并发符合设计;
- 登出后旧 Access/Refresh 均失效;
- 多设备隔离;
- 注销完成全部失效;
- 运行时 OpenAPI 为预计的 `175/213`;
- 日志、审计和数据库无 Token 原文;
- RabbitMQ、通知、评分和其他业务表无新增副作用。
## 16. 发布与回滚
### 发布前
- 前端必须先支持刷新失败后重新登录;
- 配置三个独立强密钥;
- 在隔离环境完成真实 MySQL 迁移和黑盒;
- 确认所有节点同时升级,避免新旧 Token 策略混跑。
### 发布结果
- 所有旧 Token失效;
- 所有用户需要重新登录一次;
- 新登录产生服务端会话记录。
### 回滚
- 不回滚或修改已执行 Flyway;
- 应用回滚到旧版会重新接受只看签名的 Token,存在安全倒退;
- 因此 S02 发布后不允许直接回滚到未隔离版本;
- 紧急回滚应发布保持类型隔离的兼容修复版本,而不是恢复旧过滤器。
## 17. 验收门槛
只有全部满足才可冻结 S02:
1. Refresh Token 不能访问任何普通业务接口;
2. Access Token 不能用于刷新;
3. 无 type 旧 Token 被拒绝;
4. Token 原文不落库、不入日志、不入审计;
5. 每次刷新完成轮换;
6. 重放撤销当前会话;
7. 并发刷新最多成功一次;
8. 登出立即使当前会话 Access/Refresh 失效;
9. 多设备会话互不误伤;
10. 注销完成和用户禁用使全部会话失效;
11. 旧登录响应字段保持兼容;
12. 自动化测试 0 Failures/Errors/Skipped;
13. `mvn clean verify` 成功;
14. 静态和运行时 OpenAPI 一致;
15. 真实 MySQL迁移和 schema validation成功;
16. 不改变其他业务模块结果。
## 18. 明确非目标与剩余风险
本版不实现:
- 查看全部登录设备;
- 远程踢出单个其他设备;
- OAuth/OIDC;
- 短信验证码数据库摘要化;
- 真实短信供应商;
- Redis 会话缓存;
- Access Token 有效期缩短;
- 浏览器 HttpOnly Cookie 模式;
- 设备指纹和异常登录检测。
后续可以单独评估,但不得作为 S02 验收通过的替代条件。

724
docs/s02-auth-token-isolation-implementation-instruction.md

@ -0,0 +1,724 @@
# S02 Access/Refresh Token 隔离与吊销:编码实施指令
> 本文可原样交给编码执行者。权威设计为 `docs/s02-auth-token-isolation-design.md`。任何与设计冲突、会改变其他业务语义或需要扩大公共接口的情况,必须停止并报告,不得自行改设计。
## 一、任务目标
执行 S02「Access/Refresh Token 隔离、轮换与吊销」。
项目根目录:
```text
C:\Users\15642\Desktop\zhilai
```
基线要求:
- 必须包含 `b5084fd`(S01 认证安全整改);
- 必须包含 `96a920b`(简历上传安全加固);
- 当前最新 Flyway 应为 `V41__message_recall.sql`;
- 当前静态 OpenAPI 应为 `173 paths / 211 operations`;
- S02 预计新增两个接口,完成后为 `175 paths / 213 operations`。
本轮只实现 S02,不进入 S03、Q01 或 R01。
## 二、硬性边界
允许:
1. 修改认证 Token 生成、解析、过滤、登录、刷新和登出链路;
2. 扩展现有 `auth_tokens` 表;
3. 新增一个 Flyway,建议 `V42__auth_token_sessions.sql`;
4. 新增 `/auth/refresh` 和 `/auth/logout`;
5. 将账号注销完成、用户禁用接入会话吊销;
6. 更新认证文档、前端联调文档和 OpenAPI;
7. 新增自动化测试。
禁止:
1. 修改 V1–V41;
2. 删除或重建 `auth_tokens` 表;
3. 在数据库、日志、审计、异常或 URL 中保存/输出原始 Token;
4. 永久兼容无 `type` 旧 Token;
5. 把 Refresh Token 当 Access Token;
6. 使用同一密钥签发 Access 和 Refresh;
7. 使用 JVM 本地锁代替数据库并发控制;
8. 修改短信验证码、登录自动注册、角色和资源归属语义;
9. 修改评分、排名、推荐、人才发现、邀约、Offer、申诉、消息撤回或简历上传;
10. 新增设备列表、全部设备登出、Token introspection 等接口;
11. 删除、禁用或放宽既有测试;
12. `git reset`、`git restore`、`git checkout`、`git clean`;
13. `git add -A`、commit、push;
14. 删除、移动或覆盖历史未跟踪脚本、日志、JSON、SQL、xlsx、`.env`;
15. 操作 Docker或执行真实 MySQL 黑盒,除非负责人另行授权。
## 三、开始前检查
执行并记录:
```powershell
cd C:\Users\15642\Desktop\zhilai
git branch --show-current
git status --short
git diff --cached --name-only
git log -5 --oneline
git diff --check
git diff --name-only -- mydreamjob-server/src/main/resources/db/migration
```
要求:
- 暂存区为空;
- 记录 HEAD;
- 确认基线包含 `96a920b`;
- 保护全部已有修改和未跟踪文件;
- 搜索并完整阅读适用的 `AGENTS.md`,不存在则记录;
- 若认证相关文件存在不属于 S02 的未提交修改,停止并报告;
- 建议从已包含 `96a920b` 的基线创建独立分支 `feat/s02-token-isolation`,但不得覆盖用户现有分支或工作树。
## 四、必须完整阅读
权威文档:
```text
docs/s02-auth-token-isolation-design.md
docs/api-v0.1.md
docs/frontend-integration.md
docs/phase1-scope-and-acceptance.md
docs/backend-development-roadmap.md
docs/openapi-rc.json
docs/verify_openapi.py
```
生产代码:
```text
mydreamjob-server/src/main/java/com/mydreamjob/auth/JwtTokenProvider.java
mydreamjob-server/src/main/java/com/mydreamjob/auth/JwtAuthenticationFilter.java
mydreamjob-server/src/main/java/com/mydreamjob/auth/AuthService.java
mydreamjob-server/src/main/java/com/mydreamjob/auth/AuthToken.java
mydreamjob-server/src/main/java/com/mydreamjob/auth/AuthTokenRepository.java
mydreamjob-server/src/main/java/com/mydreamjob/auth/User.java
mydreamjob-server/src/main/java/com/mydreamjob/auth/UserRepository.java
mydreamjob-server/src/main/java/com/mydreamjob/controller/AuthController.java
mydreamjob-server/src/main/java/com/mydreamjob/security/CurrentUserAccessor.java
mydreamjob-server/src/main/java/com/mydreamjob/config/SecurityConfig.java
mydreamjob-server/src/main/java/com/mydreamjob/common/exception/ErrorCode.java
mydreamjob-server/src/main/java/com/mydreamjob/common/exception/GlobalExceptionHandler.java
mydreamjob-server/src/main/java/com/mydreamjob/deletion/AccountDeletionService.java
mydreamjob-server/src/main/java/com/mydreamjob/deletion/AccountDeletionCompletionWorker.java
mydreamjob-server/src/main/java/com/mydreamjob/deletion/DeletionGuardInterceptor.java
mydreamjob-server/src/main/java/com/mydreamjob/audit/AuditService.java
mydreamjob-server/src/main/resources/application.yml
mydreamjob-server/src/main/resources/application-dev.yml
mydreamjob-server/src/main/resources/db/migration/V1__base_schema.sql
```
测试:
```text
mydreamjob-server/src/test/java/com/mydreamjob/auth/**
mydreamjob-server/src/test/java/com/mydreamjob/security/**
mydreamjob-server/src/test/java/com/mydreamjob/deletion/**
mydreamjob-server/src/test/resources/application-test.yml
mydreamjob-server/src/test/resources/application-integration-test.yml
```
搜索:
```powershell
rg -n "generateAccessToken|generateRefreshToken|validateToken|parseUserId|AuthToken|AuthTokenRepository|refreshToken|Authorization|logout|type.*refresh|JWT_SECRET|refresh-expiration" mydreamjob-server/src docs
```
## 五、先补失败测试
编码前先增加失败测试,证明当前缺陷存在。至少包括:
1. `refreshTokenCannotAuthenticateOrdinaryEndpoint`:当前 Refresh Token 访问 `/auth/me` 不应成功;修复前应失败。
2. `accessTokenMustContainAccessType`:Access Token 必须有 `type=access`。
3. `accessTokenCannotBeUsedAtRefreshEndpoint`。
4. `legacyTokenWithoutTypeIsRejected`。
5. `accessAndRefreshTokensCannotCrossVerify`。
6. `revokedSessionCannotAuthenticate`。
7. `sameRefreshTokenCannotBeConsumedTwice`。
保留修复前失败输出。不得通过修改期望状态码掩盖问题。
## 六、配置整改
修改 `application.yml`:
1. 将错误位于 `app.resume.refresh-expiration-ms` 的配置移回 `app.jwt`;
2. 增加:
```yaml
app:
jwt:
access-secret: ${JWT_ACCESS_SECRET}
refresh-secret: ${JWT_REFRESH_SECRET}
token-hash-secret: ${JWT_TOKEN_HASH_SECRET}
expiration-ms: ${JWT_EXPIRATION_MS:86400000}
refresh-expiration-ms: ${JWT_REFRESH_EXPIRATION_MS:604800000}
issuer: mydreamjob
access-audience: mydreamjob-api
refresh-audience: mydreamjob-auth
```
要求:
- 生产配置不得提供秘密默认值;
- 三项密钥不得互相回退;
- 启动时校验密钥长度和互异性;
- 测试 profile 使用固定测试密钥;
- 不打印密钥;
- 原 `app.jwt.secret` 仅作为旧配置迁移项处理,S02 新 Token 不使用它;
- 缺失、空值或三项相同必须启动失败,并给出固定、不含密钥的安全配置错误。
新增配置测试:
- 三项有效且互异时启动成功;
- 任一缺失、空白或过短时失败;
- access/refresh 相同时失败;
- hash secret 与签名密钥相同时失败;
- 错误日志不包含密钥值。
## 七、JWT Provider 重构
修改 `JwtTokenProvider`,禁止继续使用通用的 `validateToken + parseUserId` 组合来判断 Token 用途。
建议提供明确 API:
```java
IssuedToken generateAccessToken(..., Instant capturedNow);
IssuedToken generateRefreshToken(..., Instant capturedNow);
AccessTokenClaims parseAndValidateAccessToken(String rawToken, Instant capturedNow);
RefreshTokenClaims parseAndValidateRefreshToken(String rawToken, Instant capturedNow);
```
要求:
- Access 使用 access secret;
- Refresh 使用 refresh secret;
- 精确校验 `type`、`iss` 和 `aud`;
- 验证 `sub` 为正整数;
- 验证 `sid`、`jti` 为规范 UUID;
- Access 必须含 `role`;
- 新 Token 不含 `phone`;
- `iat`、`exp` 使用调用方传入的同一 `capturedNow`;
- 过期判断边界固定:`exp <= capturedNow` 视为过期;
- 不向调用方返回原始 Claims 对象;
- 异常消息不得包含 Token 或 claim 原值。
增加强类型内部 record/DTO,例如:
```text
AccessTokenClaims
RefreshTokenClaims
IssuedToken
```
不要让 Controller 直接依赖 JJWT 类型。
## 八、Token 摘要
新增内部服务,例如:
```text
mydreamjob-server/src/main/java/com/mydreamjob/auth/AuthTokenHasher.java
```
要求:
- `HmacSHA256`;
- 密钥为 `app.jwt.token-hash-secret`;
- 输出固定长度十六进制或 Base64URL;
- 不存原文;
- 不记录输入、输出或异常原值;
- 对固定输入输出稳定;
- 比较使用 `MessageDigest.isEqual`;
- 单元测试使用公开测试向量或确定性断言。
## 九、Flyway V42
编码前执行:
```powershell
Get-ChildItem mydreamjob-server/src/main/resources/db/migration | Sort-Object Name | Select-Object -Last 10
```
只有 V42 未被占用时才新增:
```text
mydreamjob-server/src/main/resources/db/migration/V42__auth_token_sessions.sql
```
扩展 `auth_tokens`:
```text
session_id VARCHAR(36) NULL
token_jti VARCHAR(36) NULL
parent_token_id BIGINT NULL
consumed_at DATETIME NULL
revoked_at DATETIME NULL
revocation_reason VARCHAR(40) NULL
version BIGINT NOT NULL DEFAULT 0
```
新增索引:
```text
idx_auth_tokens_session_active(session_id, revoked, consumed_at, expired_at)
idx_auth_tokens_user_session(user_id, session_id)
uk_auth_tokens_token_jti(token_jti) UNIQUE
```
要求:
- 不修改旧列、不删除旧数据;
- `session_id IS NULL` 的旧行不参与新会话认证;
- 不新增其他表;
- 不添加级联删除;
- CHECK/索引语法必须通过 MySQL 8.4 和 H2 MODE=MySQL;
- 更新 `AuthToken` 实体并使用 `@Version`;
- `token_hash` 保存 Refresh Token HMAC 摘要;
- 新记录 `token_type=REFRESH`;
- `refresh_token_hash` 保留但新逻辑不依赖。
新增持久化测试:
- 旧格式行仍可存在;
- 新记录字段正确;
- `token_jti` 唯一;
- 默认 version=0;
- 两个 EntityManager 的旧 version 更新冲突;
- 不保存原始 JWT。
## 十、Repository 原子操作
`AuthTokenRepository` 增加明确查询和条件更新,禁止 `findById -> set -> save` 作为并发消费保证。
至少实现:
1. 按 `tokenHash` 查询候选 Refresh 记录;
2. 按 `userId + sessionId` 判断活动会话;
3. 原子消费:条件包含 `id`、`expectedVersion`、`revoked=false`、`consumedAt IS NULL`、`expiredAt > capturedNow`;
4. 按 `userId + sessionId` 撤销当前会话;
5. 按 `userId` 撤销全部会话。
所有更新返回受影响行数并严格检查。不得通过捕获异常后继续返回成功。
## 十一、AuthSessionService
新增:
```text
mydreamjob-server/src/main/java/com/mydreamjob/auth/AuthSessionService.java
```
职责:
- 创建登录会话;
- 保存首个 Refresh Token 摘要;
- 校验 Access 所属会话;
- 消费并轮换 Refresh Token;
- 撤销当前会话;
- 撤销用户全部会话;
- 处理重放。
### 登录
- 捕获一次时间;
- 生成随机 UUID `sid`;
- 生成 Access 和 Refresh 的独立 `jti`;
- 生成 Token;
- 保存 Refresh 摘要;
- 事务提交成功后才返回;
- 多次登录必须得到不同 `sid`。
### 刷新
- Controller 传入原始 Refresh Token,但原文只在内存短暂存在;
- Provider 校验 Refresh;
- 摘要查询记录;
- claim 中的 userId/sid/jti 必须与数据库一致;
- 原子消费旧记录;
- 插入同 sid 的新记录;
- 新旧 jti 不同;
- 新 Access 和 Refresh 使用同一次 `capturedNow`;
- 旧 Token 再次使用返回固定 401。
### 重放
- 已消费或已撤销 Token 被再次提交时撤销整个当前 session;
- 撤销必须在不会随当前失败事务回滚丢失的事务边界执行;
- 写一条脱敏审计;
- 不回显“已消费”“已撤销”或数据库状态。
### 活动会话判断
- session 必须属于 user;
- 存在未消费、未撤销、未过期的当前 Refresh 记录;
- 用户 active;
- 账号注销未完成;
- 数据库错误失败关闭。
## 十二、登录服务
修改 `AuthService.mockLogin`:
- 保持验证码、自动注册、注销墓碑和响应字段不变;
- 改为创建 AuthSession;
- Access Token 必须带 `type=access`;
- Refresh Token 必须带 `type=refresh`;
- 响应仍包含 `userId/phone/nickname/role/accessToken/refreshToken/expiresIn`;
- 数据库只保存 Refresh 摘要;
- 会话持久化失败时整个登录事务失败,不得返回 Token或写成功审计;
- 不改变 `expiresIn=86400`。
## 十三、认证过滤器
修改 `JwtAuthenticationFilter`:
- 只调用 `parseAndValidateAccessToken`;
- Refresh Token、无 type、未知 type、错误 issuer/audience 必须拒绝;
- 验证服务端 session;
- 验证用户 active 和注销状态;
- 通过后使用 userId 建立 SecurityContext;
- 不把 Token claim 直接作为权限真相;
- 任何失败均不得建立部分认证;
- 清理可能存在的 SecurityContext;
- 日志使用固定安全文本,不使用异常 `getMessage()`;
- 不回显 Token、subject、sid、jti。
真实 MockMvc 测试必须覆盖:
- Access 访问 `/auth/me` 成功;
- Refresh 访问 `/auth/me` 为 401;
- Access/Refresh 交叉密钥失败;
- 无 type、未知 type、非法 subject/sid/jti 为 401;
- 撤销 session 为 401;
- 用户禁用为 401;
- 服务端校验抛异常时不放行、不返回 500 内部对象。
## 十四、刷新和登出接口
新增白名单 DTO:
```text
auth/dto/RefreshTokenRequest.java
auth/dto/TokenPairResponse.java
```
### `POST /auth/refresh`
- `SecurityConfig` 中精确 permitAll;
- 请求体只包含 `refreshToken`;
- `@NotBlank` 且设置合理长度上限;
- Token 不允许作为 query/path 参数;
- 成功返回新 Token 对和 `expiresIn`;
- Access Token 作为 refreshToken 提交必须 401;
- 所有认证失败统一安全响应。
### `POST /auth/logout`
- 必须 authenticated;
- 无 requestBody;
- 从通过验证的 Access Token claims / SecurityContext 取得 userId 和 sid;
- 不接收 userId、sessionId、refreshToken;
- 撤销当前 session;
- 保持幂等语义;
- 成功响应不包含 Token。
`AuthController` 不得计算摘要、解析 JWT、访问 Repository 或重复实现会话逻辑。
## 十五、注销与用户禁用
在不改变账号注销状态机的前提下:
- `AccountDeletionCompletionWorker` 成功完成注销时撤销用户全部会话;
- 状态更新和会话撤销必须具备一致事务语义;
- 重复完成不重复产生不受控副作用;
- 注销申请 PENDING 时不强制退出;
- 取消注销不恢复旧 session;
- Filter 每次认证必须拒绝 inactive 或 COMPLETED 用户;
- 不接受客户端指定需要撤销的 userId。
补测试证明:
- 注销完成后所有设备 Access/Refresh 均失效;
- 其他用户会话不受影响;
- 禁用用户不能继续认证;
- candidate scoring、消息、投递等业务表不被修改。
## 十六、审计隐私
允许的固定动作:
```text
AUTH_SESSION_CREATED
AUTH_TOKEN_REFRESHED
AUTH_SESSION_REVOKED
AUTH_REFRESH_REUSE_DETECTED
```
details 使用 ObjectMapper 序列化白名单 Map/record,禁止拼接不受控 JSON。
禁止进入日志、审计和响应:
```text
accessToken
refreshToken
Authorization
tokenHash
refreshTokenHash
JWT 原文
完整手机号
email
password
secret
Claims 对象
异常对象 toString
```
日志测试必须捕获真实 Logback 输出,不得只 Mockito verify logger。
## 十七、单次 Clock
注入 `Clock`,使用 `ZoneId.of("Asia/Shanghai")` 的测试 Clock。
分别验证登录、刷新、登出、重放和注销完成:
- 每个入口只读取 Clock 一次;
- JWT iat/exp;
- DB createdAt/expiredAt/consumedAt/revokedAt;
- 审计和响应;
均使用首次捕获时间。
禁止使用 `Thread.sleep()`、系统默认时区或真实时间碰撞作为核心测试。
## 十八、并发测试
新增真实 H2 集成并发测试:
- `@SpringBootTest`;
- integration-test profile;
- 真实 Service、Repository、事务和 EntityManager;
- 两个线程和独立事务;
- 使用 CountDownLatch 同时开始;
- 不 Mock 条件更新。
测试:
1. `concurrentRefreshOnlyOneRequestSucceeds`;
2. `refreshReplayRevokesOnlyCurrentSession`;
3. `differentSessionsRefreshIndependently`;
4. `logoutRacingWithRefreshCannotLeaveActiveToken`。
断言:
- 同一 Token 最多一个刷新成功;
- 不产生两个活动后继 Token;
- 失败方不返回 500;
- 当前 session 被安全撤销;
- 其他 session 不受影响;
- 无死锁和未处理乐观锁异常。
## 十九、完整测试矩阵
建议新增:
```text
JwtTokenTypeIsolationTest.java
JwtAuthenticationTypeIntegrationTest.java
AuthTokenHasherTest.java
AuthSessionServiceTest.java
AuthSessionPersistenceIntegrationTest.java
AuthRefreshControllerTest.java
AuthRefreshHttpIntegrationTest.java
AuthRefreshConcurrencyIntegrationTest.java
AuthSessionRevocationIntegrationTest.java
AuthTokenClockConsistencyTest.java
AuthTokenRedactionTest.java
```
至少覆盖:
### 类型和 claim
- access/refresh 类型;
- 独立密钥;
- issuer/audience;
- subject/sid/jti;
- 无 type 和未知 type;
- 过期边界;
- Token 不含手机号。
### 生命周期
- 登录;
- 刷新轮换;
- 二次使用;
- 并发刷新;
- 当前设备登出;
- 多设备隔离;
- 注销完成;
- 用户禁用。
### HTTP
- 匿名刷新携带有效 Refresh 成功;
- Access 调刷新 401;
- Refresh 调普通接口 401;
- 无 Token 登出 401;
- 有效 Access 登出成功;
- 错误响应不泄漏。
### 副作用
- 原始 Token 不落库;
- 不产生 RabbitMQ、通知或短信;
- 不修改评分、排名、推荐、邀约、消息和简历表;
- 只产生预期 auth_tokens 和白名单审计变化。
## 二十、OpenAPI 和文档
更新:
```text
docs/openapi-rc.json
docs/verify_openapi.py
docs/api-v0.1.md
docs/frontend-integration.md
docs/backend-development-roadmap.md
docs/phase1-scope-and-acceptance.md
docs/s02-auth-token-isolation-design.md
docs/s02-auth-token-isolation-implementation-instruction.md
```
运行时生成 OpenAPI,不得手工伪造 schema。
`verify_openapi.py` 增加递归断言:
1. 总数为 `175 paths / 213 operations`;
2. 只新增 `/auth/refresh` 和 `/auth/logout`;
3. Refresh 请求体只含 `refreshToken`;
4. Logout 无 requestBody;
5. 普通安全方案说明只接受 Access Token;
6. schema 不包含 tokenHash、refreshTokenHash、session 数据库对象、phone、secret;
7. 登录原有字段不删除;
8. 不新增其他认证或管理接口。
前端文档必须明确:
- 上线时旧 Token 全部失效;
- 普通请求只使用 Access;
- 401 时最多刷新一次;
- 刷新请求必须串行;
- 刷新失败清除 Token并重新登录;
- 登出后无条件清除本地 Token;
- 不记录或上报 Token。
## 二十一、验证顺序
先运行新增定向测试,按实际类名调整:
```powershell
cd C:\Users\15642\Desktop\zhilai\mydreamjob-server
mvn -Dtest="JwtTokenTypeIsolationTest,JwtAuthenticationTypeIntegrationTest,AuthTokenHasherTest,AuthSessionServiceTest,AuthSessionPersistenceIntegrationTest,AuthRefreshControllerTest,AuthRefreshHttpIntegrationTest,AuthRefreshConcurrencyIntegrationTest,AuthSessionRevocationIntegrationTest,AuthTokenClockConsistencyTest,AuthTokenRedactionTest" test
```
再运行认证和注销关联回归:
```powershell
mvn -Dtest="*Auth*Test,*Jwt*Test,*Security*Test,*Deletion*Test,*Sms*Test" test
```
最后执行:
```powershell
mvn test
mvn clean verify
python ..\docs\verify_openapi.py
cd C:\Users\15642\Desktop\zhilai
git diff --check
git status --short
git diff --cached --name-only
git diff --name-only -- mydreamjob-server/src/main/resources/db/migration
```
要求:
- Failures=0;
- Errors=0;
- Skipped=0;
- `mvn clean verify` BUILD SUCCESS;
- OpenAPI=`175/213`;
- 暂存区为空;
- 唯一新迁移为实际确认编号的 auth token migration;
- 未修改 V1–V41;
- 未 commit、未 push。
如果 clean verify 因正在运行的 JAR 锁定:
- 查询实际 PID、端口和完整命令行;
- 不结束未知进程;
- 报告并等待负责人确认。
## 二十二、禁止通过的绕过方式
- 只给 Refresh 增加 claim,却不让 Filter 检查;
- 仍用同一签名密钥,只依赖字符串类型;
- 继续接受无 type 旧 Token;
- 把 Refresh Token 黑名单只放在 JVM 内存;
- 数据库存原始 JWT;
- 日志打印 Token 便于调试;
- 刷新后旧 Token仍有效;
- 并发刷新两次都成功;
- 登出只删前端 Token,不做服务端吊销;
- Access Token 不检查 session,导致登出后仍可用;
- 数据库异常时跳过 session 检查;
- 用 Mockito 模拟 Repository 代替全部并发和持久化证据;
- 修改测试期望值掩盖旧行为;
- 修改旧 Flyway;
- 为完成 S02 重构全仓身份解析或异常映射。
## 二十三、完成状态
编码和自动化全部通过后,将设计及路线图状态写为:
```text
S02 已实现并完成自动化验证与干净构建,待独立代码复验及真实 MySQL/Docker 黑盒验收。
```
不得写“已冻结”“已完成生产验收”或“S03 已开始”。
## 二十四、最终报告
必须包含:
1. 修复前 Refresh Token 访问普通接口的复现;
2. Access/Refresh 的 claim 和密钥隔离;
3. 无 type 旧 Token 结果;
4. 登录响应兼容结果;
5. Token 摘要算法和数据库脱敏结果;
6. `auth_tokens` 迁移和实体变化;
7. 正常刷新与轮换结果;
8. 二次使用和重放处置;
9. 并发刷新结果;
10. 当前设备登出和多设备隔离;
11. 注销完成、用户禁用后的结果;
12. 单次 capturedNow 证据;
13. 日志、审计和完整 JSON 脱敏;
14. 数据库与外围副作用;
15. 新增接口和 OpenAPI 实际计数;
16. 专项、关联、全量测试数;
17. Failures、Errors、Skipped;
18. clean verify 结果;
19. 精确修改文件;
20. Git 状态;
21. 是否修改 V1–V41、ErrorCode 或其他业务接口;
22. 明确未执行 `git add -A`、commit、push;
23. 明确真实 MySQL/Docker 黑盒仍待独立验收。
任何失败或 SKIP 必须如实报告。完成后停止,不进入 S03。

202
docs/s02-independent-mysql-acceptance-20260914.md

@ -0,0 +1,202 @@
# S02 独立代码复验与真实 MySQL/Docker 黑盒验收报告(脱敏版)
- **验收日期**:2026-09-14
- **分支**:`feat/s02-token-isolation`
- **验收对象**:S02「Access/Refresh Token 隔离、轮换与吊销」
- **结论**:**PASS** —— *S02独立代码复验和真实MySQL/Docker黑盒全部通过,可以进入精确暂存检查点。*
- **本报告不含**:JWT 原文、Token 摘要全文、验证码、完整手机号、数据库口令、JWT 密钥、`.env` 内容、`Authorization` 请求头
---
## 1. Git 基线与工作区状态
| 项 | 实测 |
|---|---|
| 分支 | `feat/s02-token-isolation` |
| HEAD | `96a920b fix: harden resume upload safety` |
| 暂存区 | **空** |
| `git diff --check` | 无空白错误 |
| 工作区 | 63 个已修改 + 116 个未跟踪 |
| 唯一新增迁移 | `V42__auth_token_sessions.sql`(V1–V41 未修改) |
| 删除文件 | 0 |
| AGENTS.md | **未发现** |
| 静态 OpenAPI | 175 paths / 213 operations(`All checks passed!`) |
## 2. 独立代码审查逐项结论
| 项 | 结论 | 关键证据 |
|---|---|---|
| A1 Token 类型隔离 | **PASS** | Access 用 `accessKey`、带 `type=access`;Refresh 用 `refreshKey`、带 `type=refresh`、无 `role`;摘要用独立 `tokenHashSecret`;三项密钥经 `requireDistinct` 强制互异;两类 Token 均不含 phone/email/nickname/resumeUrl |
| A2 JWT claims | **PASS** | `JwtTokenProvider.parse()` 统一校验 `type`/`iss`/`aud`/`exp`/`iat`,`requirePositiveSubject` 与 `requireCanonicalUuid` 校验 sub/sid/jti;时间契约 `iat <= capturedNow < exp`;异常统一 `InvalidTokenException`(固定文本,不含 Token/claim) |
| A3 服务端会话状态 | **PASS** | `JwtAuthenticationFilter` 验签后调用 `isSessionActive`:用户存在 + active + session 下存在未消费/未撤销/未过期记录;`session_id IS NULL` 永不命中;异常失败关闭;`createSession` 对 inactive 用户先抛异常(不先发 Token) |
| A4 刷新轮换事务 | **PASS** | `refresh()` 无 `@Transactional`(编排入口);`executeRefreshAttempt` 在独立事务;消费与写后继同事务;条件更新含 id/version/revoked/consumedAt/expiredAt;`consumed != 1` 即判定重放;重放信息经 `RefreshAttempt.Replayed` 返回而非抛异常;重放撤销在尝试事务**之后**执行;无 JVM 锁、无 `Thread.sleep` |
| A5 并发重放 | **PASS** | `revokeReplayedSession` 用 REQUIRES_NEW,逐轮 `revokeSession` 后**校验活动 Token 数**,最多重试 3 次(无 sleep),仍失败则抛出(不静默)。日志中已无 `could not take locks` 分支 |
| A6 logout | **PASS** | 只撤销当前 session;幂等;其他设备不受影响;测试未使用 `catch (Throwable)`,未在最终断言前补 logout |
| A7 Token 摘要与脱敏 | **PASS** | HMAC-SHA256 + 独立密钥;64 位小写十六进制;`MessageDigest.isEqual` 常量时间比较;`hash(null)` 与 `hash("")` 同值但生产路径在校验后才调用 |
| A8 capturedNow | **PASS** | 登录/刷新/登出每个入口只读一次 Clock;`created_at`/`expired_at`/`consumed_at`/`revoked_at`/审计均来自该次捕获;固定时区 `Asia/Shanghai` |
| A9 用户禁用与注销 | **PASS** | `createSession` 拒绝 inactive;注销完成在同一业务事务内 `desensitize()`(active=false)+ `revokeAllUserSessions`;回退状态不恢复已撤销 Token;其他用户不受影响 |
| A10 配置安全 | **PASS**(代码层)| 三项密钥缺失/过短/相同/issuer 空/audience 空均启动失败,错误只含**密钥名**不含值;旧 `JWT_SECRET` 不再强制解析;`CandidateKeyGenerator` 在空密钥时**先抛异常**,不产生可预测 key |
**观察项(非阻断)**:`AppJwtProperties` 的校验规则没有专门的单元测试(设计 §六 曾要求)。本轮改用阶段 O 的真实 JAR 启动矩阵实证,覆盖更强;仍建议后续补一个配置校验单测。
## 3. Docker / MySQL / Redis / RabbitMQ
| 组件 | 版本 | 状态 |
|---|---|---|
| Docker Engine | 29.6.2(linux/amd64) | 可用 |
| `mydreamjob-mysql` | MySQL **8.4.11** | healthy(127.0.0.1:3307) |
| `mydreamjob-redis` | redis:7.4-alpine | healthy |
| `mydreamjob-rabbitmq` | rabbitmq:3.13-management-alpine | healthy |
验收数据库名:`mydreamjob`。未重建容器、未删除 volume、未打印 `.env` 或口令。
## 4–6. Flyway / 表结构 / 启动
**Flyway**:接受验收前最新版本为 V41;启动最新 JAR 后 **V42 `auth token sessions` success=1**,失败迁移 0。
**`auth_tokens` 真实表结构**(新增 7 列 + 3 索引,旧索引保留):
```
session_id VARCHAR(36) NULL token_jti VARCHAR(36) NULL
parent_token_id BIGINT NULL consumed_at / revoked_at DATETIME NULL
revocation_reason VARCHAR(40) version BIGINT NOT NULL DEFAULT 0
UNIQUE uk_auth_tokens_token_jti (token_jti)
KEY idx_auth_tokens_session_active (session_id, revoked, consumed_at, expired_at)
KEY idx_auth_tokens_user_session (user_id, session_id)
KEY idx_auth_tokens_user / idx_auth_tokens_hash (既有,保留)
```
无任何保存 Token 原文的列。
**启动**:`Started MyDreamjobApplication`(16.3 s),无 Flyway 错误、无 schema validation 错误;`GET /api/v1/health` → **HTTP 200 / status=UP**;运行时 OpenAPI 175/213。
## 7. 自动化实际测试数
| 命令 | Tests run | Failures | Errors | Skipped | BUILD |
|---|---|---|---|---|---|
| 原独立失败测试(2 类) | **3** | 0 | 0 | 0 | SUCCESS |
| S02 专项(13 类) | **84** | 0 | 0 | 0 | SUCCESS |
| 认证关联回归 | **208** | 0 | 0 | 0 | SUCCESS |
| `mvn test` | **1758** | 0 | 0 | 0 | SUCCESS |
| `mvn clean verify` | **1758** | 0 | 0 | 0 | SUCCESS(2:21) |
(候选基线称"扩展专项 87/87" = 专项 84 + 独立 3,与实测一致。)
## 8–9. Token claims 与类型矩阵(真实 HTTP)
**claims**:登录响应字段兼容、`expiresIn=86400`、三段 JWT、两 Token 原文不同、jti 不同、sid 相同、type 正确、iss/aud 正确、Access 有 role、Refresh 无 role、均不含个人信息、有效期 24h/7d、均含 iat —— **全部 PASS**。
**类型矩阵**(每格记录 method / path / HTTP status / business code / requestId):
| Token | `/auth/me` | `/auth/refresh` |
|---|---|---|
| Access | **200** | **401** |
| Refresh | **401** | **200**(并轮换) |
| 无 type 旧 Token | 401 | 401 |
| Access 用 Refresh 密钥伪造 | 401 | 401 |
| Refresh 用 Access 密钥伪造 | 401 | 401 |
| 过期 Token | 401 | 401 |
| 缺 iat | 401 | 401 |
| 未来 iat | 401 | 401 |
| 非法 sid | 401 | 401 |
| 非法 sub | 401 | 401 |
错误响应均未泄漏 JWT 原文、`Bearer`、`token_hash`、sid、jti、手机号、密钥、异常类名或堆栈。
## 10–11. 正常轮换与串行重放
**轮换**:sid 不变、jti 变化、旧记录 `consumed_at` 非空、version 只 +1、新记录 `parent_token_id` 指向旧记录、同一时刻仅 1 个活动 Refresh、A2 可访问、R2 可再次刷新 —— 全部 PASS。
**串行重放**:R1 重放 → **401**;当前 session 活动 Refresh 数 **0**;A1 与 A2 访问 `/auth/me` 均 **401**;R2 刷新 **401**;其他设备 session **200**;写入一次重放安全审计。
## 12–13. 真实并发(10 轮)与胜出方 Access
每轮两个独立 HTTP 客户端 + `threading.Barrier` 同时发起,**10/10 轮**全部满足:
| 断言 | 实测 |
|---|---|
| 无 HTTP 500 | 10/10 |
| 最多一个刷新成功 | 10/10 |
| 失败方为 401 | 10/10 |
| 重放后活动 Refresh **严格为 0** | 10/10 |
| 原 Access 与**胜出方新 Access** 均失效 | 20 次断言全部满足 |
| 其他设备 session 始终 200 | 10/10 |
| 无锁异常 / 堆栈泄漏 | 10/10 |
| 日志无 `refresh reuse revocation could not take locks` | ✅ |
| 无同一 session 两个活动后继 | ✅ |
## 14–16. 多设备 / 竞争 / inactive 与注销
- **logout**:第一次成功,Access 与 Refresh 随即 401;重复 logout 按既有幂等语义;其他设备不受影响。
- **多设备隔离**:两设备 sessionId 不同;A 登出后 A 失效、B 的 Access 与 Refresh 均保持有效。
- **logout 与 refresh 并发**:无 500;未补 logout 的情况下活动 Refresh 数为 0;竞争可能返回的新 Access 最终 401。
- **inactive**:登录不返回 Token、不新增 `auth_tokens`;置为 inactive 后旧 Access/Refresh 均 401。
- **注销完成**(走正式懒转换完成路径):状态 COMPLETED、用户 `active=false`、全部会话撤销、两台设备 Access/Refresh 均 401、其他用户完全不受影响。
## 17. Clock 与真实数据库时间
| 断言 | 实测 |
|---|---|
| `created_at` 与 Access `iat` 同一时刻 | ±1s 内一致 |
| `expired_at` 与 Refresh `exp` 同一时刻 | ±1s 内一致 |
| `consumed_at` 与刷新时刻 | ±1s 内一致 |
| `revoked_at` 与登出时刻 | ±5s 内一致 |
| AUTH 审计 `created_at` 与请求时刻 | ±120s 内一致 |
## 18–19. 明文扫描 / 审计与日志脱敏
- `token_hash` 为 64 位小写十六进制,不等于 Refresh 原文;库中搜索两类 Token 原文命中 **0**。
- 应用日志与 stdout 扫描:Token / 验证码 / 密钥命中 **0**;扫描器经**注入探针**验证有效。
- 审计动作仅限 4 个白名单值;details 不含 Token、摘要、`Bearer`、手机号、jti、`Authorization`、密钥;不含原始 sid(用不可逆 `sessionRef`)。
## 20–22. 副作用 / RabbitMQ / OpenAPI
- **数据库副作用**:登录+刷新+串行重放前后,`candidate_dimension_scores`、`messages`、`notifications`、`applications`、`talent_invitations` 内容摘要**零变化**(按 id 摘要比对,非仅 COUNT)。
- **RabbitMQ**:4 个项目队列全部存在,`ready=0`、`unacked=0`;S02 运行期间无新增消息。
- **OpenAPI**:静态与运行时均 **175 / 213**;`/auth/refresh` 请求体仅 `refreshToken`;`/auth/logout` 无请求体;公共 schema 不含 tokenHash/sessionId/phone/secret 等内部字段。
## 23. PASS / FAIL / SKIP 清单
| 阶段 | PASS | FAIL | SKIP |
|---|---|---|---|
| A 代码审查(10 项) | 10 | 0 | 0 |
| B 自动化(5 组) | 5 | 0 | 0 |
| C–E 环境/迁移/启动 | 8 | 0 | 0 |
| F–M 黑盒(含 10 轮并发) | **117** | 0 | 0 |
| N–S 时间/脱敏/副作用/外围/OpenAPI | **30** | 0 | 0 |
| O 配置启动矩阵(9 配置) | **18** | 0 | 0 |
| **合计** | **165** | **0** | **0** |
## 24. 剩余风险
1. **重放撤销重试可能重复写审计**:`revokeReplayedSession` 在需要重试时会再写一对 `AUTH_REFRESH_REUSE_DETECTED` / `AUTH_SESSION_REVOKED`。本轮所有场景(含 10 轮并发)**重试次数为 0**,未观测到重复;属潜在项,建议后续把审计移到循环外。
2. **`AppJwtProperties` 无专门单元测试**(见第 2 节观察项)。
3. **旧 `JWT_SECRET` 仍被 `talentdiscovery.CandidateKeyGenerator` 使用**:本轮已确认空密钥时**安全失败**(抛异常、不产生可预测 key),但该配置项仍存在;建议后续为其单独引入专用密钥。
4. 本轮**未执行**:真实短信供应商、Redis 会话缓存、设备指纹、Access 有效期缩短等 S02 明确非目标项。
5. 验收数据保留在库(见第 26 节)。
## 25. 是否允许冻结 S02
**允许。** 24 项验收判据全部满足,无 FAIL、无核心项 SKIP,24 条"不得冻结"清单全部未触发。
---
## 26. 验收结束处理
- 本轮启动的 JAR(PID 21420,命令行含 `s02-acc-main-app.log`)**已停止**;8080 恢复空闲;未停止任何未知 Java 进程;未停止 Docker。
- **验收数据保留**,未执行删除。理由:`audit_logs` 有 **143** 条 `AUTH_*` 行引用本轮验收用户且该表**无外键**,删除用户会产生孤儿审计行;而本任务边界禁止删除 `audit_logs`。精确足迹如下(供后续人工处置):
| 范围 | 行数 |
|---|---|
| `users`(本轮 15:25 之后创建,199 前缀) | 35 |
| `auth_tokens`(这些用户) | 93 |
| `audit_logs` `AUTH_*`(这些用户) | 143 |
| `account_deletions`(这些用户) | 1 |
> 注:库中另有 3 个 199 前缀用户属于 **S01 验收**(2026-09-14 09:48 与 2026-08-13),及其 2 条投递记录,**非本轮产生,未触碰**。
- 最终 Git:暂存区**空**、`git diff --check` 无错误、V1–V41 未修改、V42 为唯一新增迁移、未删除文件、**未** `git add -A` / commit / push、未进入 S03。
---
# S02 独立代码复验和真实MySQL/Docker黑盒全部通过,可以进入精确暂存检查点。

167
docs/s03-independent-mysql-acceptance-20260915.md

@ -0,0 +1,167 @@
# S03「生产默认账号、简历隐私与 Actuator 隔离」独立代码复验及真实 MySQL/Docker 黑盒验收
日期:2026-09-15
分支:`feat/s03-production-privacy`
结论:**FAIL —— 已复现具体缺陷(阶段 I-1:业务端口 Actuator 路径非 404),S03 不可冻结;报告包含下一轮定向整改指令。**
## 1. Git 基线与工作区状态
- 当前分支 `feat/s03-production-privacy`;HEAD `f399060 feat: isolate and revoke authentication token sessions`。
- 暂存区为空;全部 S03 修改处于未提交(M/??)状态。
- `git diff --check` 通过;`git diff --name-only -- mydreamjob-server/src/main/resources/db/migration` 为空(V1–V42 无改动)。
- 唯一新增迁移:`V43__disable_legacy_seed_accounts.sql`。
- 未执行 `git add -A`/commit/push/reset/restore/checkout/clean;未删除历史文件。
## 2. 独立代码审查(阶段 A)
| 项 | 结论 | 关键证据 |
|---|---|---|
| A1 V43 迁移安全 | PASS | 3 条 UPDATE(无 DELETE/DROP/TRUNCATE);不按 id 匹配;每条同时匹配原始手机号+邮箱+公开 bcrypt 哈希+主角色+`is_active=1`;只置 `is_active=0`;不碰 `user_roles`;指纹被改的账号不命中;V1–V42 未修改 |
| A2 管理员初始化装配 | PASS | 默认关闭 + 字面量 `"true"` 双重校验;手机号无默认值、非法即启动失败;已有活动 ADMIN 无副作用跳过;不设密码(`password_hash=NULL`)/不发 Token/不发短信/无公网接口;日志与异常文本固定且不含手机号(`AdminBootstrapRunner.ERROR_INVALID_PHONE/ERROR_PHONE_IN_USE`) |
| A3 事务与并发 | PASS | `JdbcTemplate + REQUIRES_NEW` 独立事务,users 与 user_roles 同事务回滚;刻意绕过 Hibernate 避免 `SqlExceptionHelper` 记录绑定手机号;异常被捕获只返回 `CONFLICT`,原始 cause 不传播;冲突后仅 `isValidBootstrapAdmin` 全条件匹配才幂等 |
| A4 简历授权失败关闭 | PASS | `CompanyResumeAccessPolicy`:归属全部服务端推导;无记录/PRIVATE 按 PRIVATE;APPLIED_COMPANIES 严格白名单(SUB/VIEWED/INTERVIEW/HIRED,区分大小写,未知/空白/null/WITHDRAWN/REJECTED 全拒);VERIFIED_COMPANIES 要求 `verified=true` 且岗位归属;授权失败发生在文件读取前 |
| A5 文件与响应安全 | PASS | 授权后读文件;`Content-Disposition attachment+UTF-8`、`Cache-Control: no-store`、Content-Type 白名单回退 octet-stream;响应不含 resumeUrl/storagePath/studentUserId |
| A6 Actuator 隔离(设计层面) | PASS* | 独立管理端口默认 127.0.0.1;仅暴露 health/info/metrics/prometheus;`show-details/components=never`;Prometheus 显式开启;不当 JWT 运维认证。*注:阶段 I-1 真实验收发现业务端口行为不达标,见第 12 节 |
| A7 副作用测试真实性 | PASS | `CompanyResumeDownloadIntegrationTest` 行级字段快照(如 users: id/phone/email/role/is_active/is_verified/updated_at),非 COUNT/SUM;含反证测试(单行 status 变化必须被发现)与 15 表字段覆盖断言 |
## 3. 自动化独立复验(阶段 B)
| 命令 | Tests | Failures | Errors | Skipped | 结果 |
|---|---:|---:|---:|---:|---|
| `mvn clean verify`(实测,非沿用历史) | 1834 | 0 | 0 | 0 | BUILD SUCCESS |
| `python docs\verify_openapi.py` | — | — | — | — | paths=176 operations=214,All checks passed |
## 4. Docker/MySQL 环境(阶段 C)
- Docker Engine 29.6.2;容器由负责人环境启动(本次仅 `docker start` 已存在容器,未重建、未删 volume)。
- MySQL 8.4.11(127.0.0.1:3307,库 `mydreamjob`);Redis PONG;RabbitMQ healthy。
- Flyway V1–V42 全部 success=1。
## 5. V43 真实迁移验收(阶段 D)
- 迁移前三个种子账号脱敏快照:role=ADMIN/STUDENT/COMPANY,均 `is_active=1`,指纹与 V1_1 原始值完全匹配,`user_roles` 各 1 条,关联业务数据 4(applications)/21(jobs)/2(profiles)/1(company)。
- 最新 JAR + dev(`ddl-auto=validate`)启动后 `flyway_schema_history`:`V43 disable legacy seed accounts success=1`,无失败迁移,V1–V42 校验和未变。
- 三个种子账号 `is_active` 1→0;users 行仍存在;`user_roles` 仍存在;关联业务数据未删除。
- 指纹偏差隔离夹具(7976/7977,改过手机号与哈希)在 V43 后仍 `is_active=1`,未误伤。
- 原种子账号(demo 口令)登录返回 400 且无法取得 Token(已禁用账号不能建立会话),达成"不能取得 Token"目标。
## 6. 管理员初始化配置矩阵(阶段 E,真实 MySQL)
| 用例 | 结果 | 实测证据 |
|---|---|---|
| E1 默认关闭 | PASS | 应用正常启动且未创建任何 ADMIN |
| E2 enabled=true+非法手机号 | PASS | 启动失败,固定文案 `admin bootstrap rejected: configured phone is invalid` |
| E3 无活动ADMIN+空闲手机号 | PASS | users 恰 1 行(role=ADMIN、active、verified、`password_hash=NULL`);`user_roles` 仅 ADMIN;无 Token/短信/通知;日志无手机号 |
| E4 重复启动同配置 | PASS | `skipped`;users 与 ADMIN 角色计数均不增 |
| E5 手机号被 STUDENT 占用 | PASS | 启动失败固定文案 `phone is already in use`;该 STUDENT 未被提权/复活/覆盖 |
| E6 真实双 JAR 并发同手机号 | PASS | 两个独立进程 + 屏障同时初始化:最终 users 恰 1 行、ADMIN 角色恰 1 条;一方创建、一方安全幂等跳过;无手机号泄漏;无 `SqlExceptionHelper` 唯一键日志 |
前置与后置:临时禁用 20 个历史验收 ADMIN(id 逐一记录)并以精确 UPDATE 恢复原状;E3/E6 新建的验收 ADMIN 置为 inactive,未删除。
## 7. 启动最新 JAR 与双端口(阶段 F)
- `Started MyDreamjobApplication`;无 Flyway/schema-validation 错误。
- 8080 与 8081 均监听;**8081 仅绑定 127.0.0.1**。
- `GET /api/v1/health` → 200/UP(仅最小状态,无 db/redis/rabbit/连接池字段)。
- 运行时 OpenAPI:paths=176,operations=214,与静态一致。
## 8. 企业简历真实 HTTP 权限矩阵(阶段 G / H)
夹具:3 家企业(认证/未认证/其他)、6 名学生(APPLIED_COMPANIES·SUBMITTED、PRIVATE、无隐私记录、VERIFIED_COMPANIES·未认证企业、APPLIED_COMPANIES·WITHDRAWN、VERIFIED_COMPANIES·已认证企业),真实简历文件进存储基目录,投递/隐私由服务端推导。
| 用例 | HTTP | 期望 | 结果 |
|---|---|---|---|
| 匿名 | 401 | 401 | PASS |
| STUDENT Token | 403/2002 | 403 | PASS |
| ADMIN Token | 403/2002 | 403 | PASS |
| 其他企业 | 403/2002 | 403 | PASS |
| 未认证企业(岗位不属于) | 403/2002 | 403 | PASS |
| 认证企业+APPLIED+SUBMITTED | 200 | 200 | PASS(下文字节/头断言) |
| PRIVATE 隐私 | 403/2002 | 403 | PASS |
| 无隐私记录(默认 PRIVATE) | 403/2002 | 403 | PASS |
| VERIFIED_COMPANIES+未认证企业 | 403/2002 | 403 | PASS |
| APPLIED_COMPANIES+WITHDRAWN | 403/2002 | 403 | PASS |
| VERIFIED_COMPANIES+已认证企业 | 200 | 200 | PASS(字节一致) |
| 投递不存在 | 404/8002 | 404 | PASS |
成功下载断言:文件字节与夹具逐字节一致(`RESUME-FIXTURE-A/F-20260915`,字节级比较 PASS);`Content-Disposition: attachment; filename="=?UTF-8?Q?...?="`(净化文件名);`Cache-Control: no-store`;Content-Type 走白名单;响应不含存储路径/resumeUrl/studentUserId/手机号/邮箱。
日志/审计扫描:未发现简历正文标记、本地路径、S03 测试手机号、密钥等禁止字段。
## 9. Actuator 双端口真实验收(阶段 I)
**管理端口 127.0.0.1:8081 — 全部 PASS**
- `/actuator/health` 200,JSON 仅含 `status`(最小化,无 components/db/redis/rabbit/diskSpace/hikari)。
- `/actuator/metrics` 200(含 names 数组);`/actuator/prometheus` 200(Prometheus 文本、含 `jvm_memory_used_bytes` 等稳定指标)。
- `/actuator/env`、`/beans`、`/configprops`、`/heapdump`、`/threaddump` 均不可读(401)。
- 端口仅绑定 127.0.0.1;prometheus 文本未发现密码/Token/Bearer 等敏感值。
**业务端口 8080 — FAIL(本轮关键缺陷)**
| 路径 | 匿名 | 带 STUDENT Token(有效) |
|---|---|---|
| `/actuator` | 401 | 500 |
| `/actuator/health` | 500 | 500 |
| `/actuator/metrics` | 500 | 500 |
| `/actuator/info` | 500 | 500 |
| `/actuator/prometheus` | 500 | 500 |
| `/actuator/env` | 401 | 500 |
| `/actuator/beans` | 401 | 500 |
任务书要求这些路径**无论匿名或带 Token 一律 404**;实测为 401/500。根因:`SecurityConfig` 对 `/actuator/health/**` 等显式 `permitAll`,但业务端口并不注册 Actuator 端点,请求落入 MVC 静态资源解析抛 `NoResourceFoundException`,被 `GlobalExceptionHandler` 通用 handler 映射为 500;未放行的 `/actuator`、`/actuator/env`、`/actuator/beans` 在匿名时被安全层拦为 401,带 Token 后同样 500。
同时,`ActuatorIsolationIntegrationTest.businessPortDoesNotExposeActuatorEndpoints` 断言这些路径为 404 且在 H2 测试环境下通过——**测试环境(H2、无独立管理端口随机化 etc.)与真实 MySQL + 完整 Security 配置的生产行为不一致,测试未能捕获 401/500 差异**,属测试真实性缺口。
## 10. 副作用(阶段 J)
下载成功、下载拒绝、下载 404 三类请求前后,对 15 张表(users、user_roles、student_profiles、resume_versions、applications、jobs、companies、auth_tokens、audit_logs、candidate_dimension_scores、notifications、messages、scoring_risk_events、scoring_risk_actions、talent_invitations)作行级全量快照对比:**完全一致,无任何变化**。PASS。
## 11. RabbitMQ(阶段 K)
4 个队列真实存在且 0 积压:`queue.sms.verification`、`queue.notification`、`queue.audit`、`queue.notification.dlq`(messages=0、ready=0、unacked=0)。管理员初始化与简历下载均未产生新增消息。PASS。
## 12. OpenAPI(阶段 L)
静态 `verify_openapi.py`:176 paths / 214 operations,全部通过(含 S03 简历下载契约、S02/S03 校验)。运行时 `/api/v1/v3/api-docs`:176/214 与静态一致。新端点仅 `GET /company/candidates/{applicationId}/resume`,无 requestBody、返回文件流;企业 DTO 不含 resumeUrl/storagePath/studentUserId/phone/email。PASS。
## 13. PASS / FAIL / SKIP 清单
- PASS:阶段 A(A1–A7);阶段 B(1834 测试、OpenAPI 静态);阶段 C;阶段 D(V43 真实迁移与种子禁用、不误伤、登录不可用);阶段 E(E1–E6);阶段 F;阶段 G/H(12/12、字节与头断言、日志脱敏);阶段 I-2 管理端口;阶段 J;阶段 K;阶段 L。
- **FAIL:阶段 I-1(业务端口 Actuator 路径 401/500 而非 404)+ 关联测试真实性缺口(ActuatorIsolationIntegrationTest 与生产行为不一致)。**
- SKIP:无(核心项全部执行;仅"更大规模容量压测"不在本轮范围,不构成阻断)。
## 14. 剩余风险
- 业务端口对 `/actuator/health` 等返回 500:属于错误响应泄漏(对探测者暴露服务器异常迹象),且不满足契约的 404 语义;修复后需补"业务端口带 Token 亦为 404"的真实 HTTP 覆盖。
- 登录接口对已禁用账号返回 400 而非 403/401(无法取得 Token 已满足;状态码语义差异建议与契约对齐)。
- 管理端口依赖网络隔离运维边界,生产中需确认部署层网络策略确实限制 8081 仅内网可访问。
## 15. 是否允许冻结 S03
**不允许。** 建议整改指令(下一轮定向修复,不修改本报告已执行的验收环境):
1. 业务端口对未注册 Actuator 路径统一返回 404:
- 在 `GlobalExceptionHandler` 增加 `NoResourceFoundException` 的精确映射(404),避免落入通用 500;或
- 移除 `SecurityConfig` 中业务端口对 `/actuator/**` 的 `permitAll` 并显式声明业务端口 Actuator 不可达(任选其一,保持管理端口行为不变)。
2. 增加真实 MySQL 环境下的 HTTP 断言:业务端口 `/actuator`、`/actuator/health`、`/actuator/metrics`、`/actuator/prometheus`、`/actuator/env`、`/actuator/beans` 在匿名与有效 STUDENT/COMPANY/ADMIN Token 下均为 404;并说明测试环境中为何出现 H2 404 而真实 MySQL 401/500 的差异,消除测试假绿。
3. 修复后在最新 JAR + 真实 MySQL 上重跑阶段 I(业务端口 404 矩阵 + 管理端口只读矩阵)与 1834 项全量回归。
本报告不包含数据库密码、JWT、验证码、完整手机号、邮箱、验证手机号或简历正文。验收脚本与日志保留在 `backend-infra/s03-*.ps1|.py|.log`,夹具数据按"本轮唯一前缀/精确主键"清理并记录;`audit_logs` 未做删除。
## 16. Actuator 404 增量复验结果(定向整改后)
上一节记录了初次独立验收的唯一阻断:业务端口 8080 上 `/actuator/**` 匿名/带 Token 行为为 401/500 而非任务书要求的 404,且 H2 测试与生产行为不一致。针对该阻断完成定向整改后,使用最新 `mvn clean verify` JAR 与真实 MySQL 8.4.11 复验:
1. **整改方式 —— SecurityConfig**:原对 `/actuator/health/**`、`/actuator/info`、`/actuator/metrics/**`、`/actuator/prometheus` 的四个 `permitAll` 收敛为单一 `.requestMatchers("/actuator/**").permitAll()`。目的**非暴露 Actuator**(Actuator 仍只注册在独立管理端口),而是让所有 `/actuator/**` 请求进入 MVC 的不存在资源处理,消除匿名请求先被认证层转 401 的分支;业务端口未新增任何 Actuator Controller 或端点映射。
2. **整改方式 —— GlobalExceptionHandler**:新增 `@ExceptionHandler(NoResourceFoundException.class)`,固定返回 HTTP 404、`code=404`、`message=not found`、`requestId`;不回显异常 message、请求路径、本地路径或堆栈;日志仅固定文案 + 归一化 requestId;未新增 ErrorCode。
3. **业务端口矩阵(真实 HTTP)**:14 条路径(`/actuator`、`/actuator/`、`/actuator/health`、`/actuator/health/liveness`、`/actuator/info`、`/actuator/metrics`、`/actuator/metrics/jvm.memory.used`、`/actuator/prometheus`、`/actuator/env`、`/actuator/beans`、`/actuator/configprops`、`/actuator/heapdump`、`/actuator/threaddump`、`/actuator/unknown`)× 6 种身份(匿名 / STUDENT / COMPANY / ADMIN / 非法 Token / Refresh Token)共 **84 个组合全部返回 404**,且响应递归扫描无任何异常类名、框架包名、路径、Token、手机号等泄漏。
4. **管理端口 127.0.0.1:8081**:health(200,最小 JSON,仅 `status`)/ info(200)/ metrics(200,含 names)/ prometheus(200,含稳定 JVM 指标)白名单端点正常;env / beans / configprops / heapdump / threaddump / unknown 全部 404(未暴露端点未被修复带成 200);8081 仅绑定 127.0.0.1。
5. **副作用**:复验期间日志无 `NoResourceFoundException` 堆栈、无 ERROR;RabbitMQ 4 个队列(sms.verification / notification / audit / notification.dlq)0 积压;数据库无任何改动。
6. **自动化**:定向(Actuator/Security/GlobalExceptionHandler)61/61;全量 `mvn clean verify` **1836/1836**(含新增 2 个 Actuator 复验测试),Failures=0、Errors=0、Skipped=0;OpenAPI 静态与运行时均为 **176 paths / 214 operations**,Actuator 不进入业务 OpenAPI。
7. **一致性**:V1–V42 未修改,V43 仍为唯一新增迁移;未新增 ErrorCode、公网业务接口、表或索引;应用进程已停止,当前未提交、未推送。
## 17. 最终结论
- 初次独立验收发现业务端口 Actuator 401/500 阻断;
- 完成定向整改(SecurityConfig 统一 `/actuator/**` 语义 + GlobalExceptionHandler 安全 404 映射 + 失败测试补齐);
- 最新 JAR + 真实 MySQL/Docker 增量复验全部通过。
**S03 独立代码复验和真实 MySQL/Docker 黑盒全部通过,可以进入精确暂存检查点。**

167
docs/s03-production-security-and-resume-privacy-design.md

@ -0,0 +1,167 @@
# S03 生产配置、简历隐私与 Actuator 隔离设计
状态:设计已确认,待按独立检查点实施。
## 1. 目标与边界
S03 只处理三类高风险问题:
1. 禁用仍保持原始状态的通用 Flyway 演示账号,并提供显式、一次性的管理员初始化能力;
2. 让 `resumeVisibility` 在企业读取简历文件的正式边界真正生效;
3. 将 Actuator 健康探针与指标端点隔离,防止普通业务用户读取运行指标。
S03 不修改 Access/Refresh Token 语义,不修改评分、排名、推荐、人才发现、邀约、Offer、申诉、消息撤回、简历上传与学生本人下载语义,不做性能重构,不进入 Q01/R01。
## 2. 已核实的现状
- `V1_1__seed_data.sql` 无环境隔离地插入已知 ADMIN、STUDENT、COMPANY 账号及公开口令哈希。
- V1–V42 已经执行,不允许修改校验和;S03 使用新的 V43 迁移。
- `student_privacy_settings.resume_visibility` 已支持 `PRIVATE`、`APPLIED_COMPANIES`、`VERIFIED_COMPANIES`,默认 `PRIVATE`。
- `PrivacySettingsService.canCompanyViewResume(...)` 没有生产调用方。
- 该方法的 `APPLIED_COMPANIES` 分支没有核对投递岗位归属 `companyId`,不能直接作为授权边界使用。
- 企业候选人 DTO 当前主动排除了 `resumeUrl`;当前没有企业下载简历文件的正式接口。
- `/actuator/health/**` 和 `/actuator/info` 公开;`metrics/prometheus` 被暴露后会落入普通 `authenticated()`,任何登录用户均可能访问。
## 3. S03-A 历史种子账号治理
### 3.1 V43 迁移原则
新增且仅新增:
`V43__disable_legacy_seed_accounts.sql`
V43 只能禁用仍与 V1_1 原始指纹完全一致的三条种子账号。匹配至少同时包含:
- 原始手机号;
- 原始邮箱;
- 原始公开 bcrypt 哈希;
- 原始主角色;
- `is_active=1`。
不得依赖固定 `id=1/2/3`。不得删除用户、角色、审计或业务数据,不得重置自增。不得修改已被运营人员改过手机号、邮箱、哈希或角色的账号。
命中后仅将 `users.is_active` 置为 `0`。保留记录与关联关系,用于历史数据完整性和审计追溯。由于认证签发链已拒绝 inactive 用户,已知账号不能再取得会话。
迁移必须幂等,并分别覆盖 ADMIN、STUDENT、COMPANY 三条原始种子。迁移报告只记录命中数量,不输出完整手机号、邮箱或哈希。
### 3.2 管理员一次性初始化
新增显式开关,默认关闭:
- `app.bootstrap.admin.enabled=${ADMIN_BOOTSTRAP_ENABLED:false}`
- `app.bootstrap.admin.phone=${ADMIN_BOOTSTRAP_PHONE:}`
- `app.bootstrap.admin.nickname=${ADMIN_BOOTSTRAP_NICKNAME:平台管理员}`
初始化器仅在开关严格为 `true` 时装配。普通启动不得隐式创建管理员。
初始化规则:
1. 手机号必须通过现有手机号格式校验;不得写日志、异常详情或审计 details。
2. 数据库已有活动 ADMIN 时安全跳过,不创建第二个管理员。
3. 数据库没有活动 ADMIN 且手机号不存在时,创建 `active=true`、`verified=true` 的 ADMIN,并同时写入 `users.role` 与 `user_roles`。
4. 手机号已属于 STUDENT/COMPANY、inactive 用户或角色信息不一致时启动失败,禁止自动提权或复活。
5. 相同配置重复启动必须幂等。
6. 初始化成功后应提示固定文本,部署方必须移除开关;日志不得包含手机号。
7. 多实例部署时只允许一个实例带初始化开关启动;数据库唯一手机号约束作为最后防线,冲突必须失败而不是吞掉。
不新增公网初始化接口,不接受请求参数创建 ADMIN,不设置默认密码。管理员仍使用正式短信认证流程;真实短信供应商未配置时不能以 Mock 绕过。
## 4. S03-B 简历隐私授权
### 4.1 唯一新增企业接口
新增:
`GET /company/candidates/{applicationId}/resume`
该接口返回文件流,不返回 `resumeUrl`、存储相对路径、绝对路径、学生用户 ID、手机号或邮箱。文件名必须继续经过 `ResumeFilenameSanitizer`。
除该接口外不新增按 `studentUserId`、`candidateKey` 或任意 URL 下载简历的入口。人才发现接口本轮不得直接下载简历。
### 4.2 授权顺序
必须在读取文件前依次完成:
1. 从 `SecurityContext` 取得当前用户,要求 COMPANY;
2. 定位当前用户拥有的 Company;
3. 查询 `applicationId` 及对应 Job;
4. 校验 Job 属于当前 Company;
5. 取得投递对应的 `studentUserId`;
6. 按隐私设置判定可见性;
7. 通过后才读取 `student_profiles.resume_url` 和文件内容。
跨企业投递统一返回既有 403 语义,不通过差异响应泄露归属。隐私拒绝使用 `FORBIDDEN(2002)`。不存在的投递保持既有 `APPLICATION_NOT_FOUND`;授权通过但学生没有简历时使用既有 `PROFILE_NOT_FOUND`,不新增 ErrorCode。
### 4.3 可见性决策表
| 设置 | 企业条件 | 投递条件 | 结果 |
|---|---|---|---|
| 无设置记录/null | 任意 | 任意 | 403,按 PRIVATE 处理 |
| PRIVATE | 任意 | 包括已投递、面试、录用 | 403 |
| APPLIED_COMPANIES | 岗位属于当前企业 | SUBMITTED/VIEWED/INTERVIEW/HIRED | 200 |
| APPLIED_COMPANIES | 岗位属于当前企业 | WITHDRAWN/REJECTED | 403 |
| APPLIED_COMPANIES | 其他企业 | 任意 | 403 |
| VERIFIED_COMPANIES | 当前企业 `verified=true` 且拥有该投递岗位 | 任意合法投递状态 | 200 |
| VERIFIED_COMPANIES | 未认证企业或其他企业 | 任意 | 403 |
`VERIFIED_COMPANIES` 不是“知道学生 ID 即可查看”。它仍要求合法的候选业务上下文,本轮该上下文限定为当前企业拥有的 `applicationId`。
### 4.4 代码结构
将现有布尔方法收束为失败关闭的授权组件,例如 `CompanyResumeAccessPolicy`。授权组件必须校验 company ownership,不能依赖 Controller 先验,也不能把客户端传入的 `studentUserId/companyId` 当成可信事实。
文件读取放在独立 Service 方法中。Controller 只解析身份、路径参数、构建安全响应头。任何拒绝路径都不得调用 `ResumeStorageService.read()`。
企业候选人列表与详情 DTO 保持不含 `resumeUrl`。学生本人上传、下载、版本、导出接口完全不变。
## 5. S03-C Actuator 隔离
### 5.1 运行边界
推荐配置:
- `management.server.port=${MANAGEMENT_SERVER_PORT:8081}`
- `management.server.address=${MANAGEMENT_SERVER_ADDRESS:127.0.0.1}`
- 仅暴露 `health,info,metrics,prometheus`;
- `health.show-details=never`;
- `health.show-components=never`。
生产默认只绑定回环地址。需要采集 Prometheus 时,由部署层通过受控网络或反向代理访问管理端口,不通过公网业务端口开放。
### 5.2 业务端口行为
业务端口不得提供 `/actuator/metrics` 或 `/actuator/prometheus`。普通 STUDENT、COMPANY、ADMIN 的业务 JWT 不能把管理指标变成公网资源。健康检查继续提供最小 `UP/DOWN` 信息,不返回数据库、Redis、RabbitMQ、磁盘路径或连接池细节。
本轮不新增运维账号体系,不把 ADMIN 等同于基础设施运维身份。
## 6. 数据与副作用
允许的数据库变化仅为:
- V43 对完全匹配的旧种子账号执行 `is_active: 1 -> 0`;
- 显式管理员初始化首次创建一条 ADMIN 用户和对应角色;
- 企业成功下载简历如需审计,只允许白名单动作与 `applicationId/companyId`,不得记录路径、文件名、学生 ID、联系方式或简历内容。
简历访问不得修改 `student_profiles`、`resume_versions`、投递、评分、排名、推荐、邀约、消息、通知或风险表,不发送 RabbitMQ、短信或邮件。
## 7. 契约与兼容性
- S02 基线为 175 paths / 213 operations。
- 新增一个企业简历下载接口后,预期为 176 paths / 214 operations;最终以运行时 OpenAPI 导出为准。
- 不修改既有响应字段,不把 `resumeUrl` 加回任何企业 DTO。
- 不新增 ErrorCode。
- V43 是唯一新增迁移,V1–V42 不得修改。
## 8. 验收门槛
必须同时通过:
- V43 在 H2 快速层和真实 MySQL 8 执行成功;
- 仅完全匹配的三条原始种子账号被禁用,改过任一指纹的账号不受影响;
- 管理员初始化关闭、成功、重复、已有 ADMIN、手机号冲突、并发冲突场景;
- 三种简历可见性及所有投递状态、跨企业、未认证企业、ADMIN/STUDENT/匿名权限矩阵;
- 拒绝路径零文件读取,企业 JSON/响应头无路径及个人信息泄漏;
- Actuator 业务端口与管理端口真实启动验证;
- 全量测试和 `mvn clean verify` 零失败、零错误、零跳过;
- 静态与运行时 OpenAPI 一致;
- 真实 MySQL/Docker 黑盒通过后才能冻结。

270
docs/s03-production-security-and-resume-privacy-implementation-instruction.md

@ -0,0 +1,270 @@
# S03 生产配置、简历隐私与 Actuator 隔离实施指令
项目根目录:`C:\Users\15642\Desktop\zhilai`
候选基线:S02 已完成独立代码复验、真实 MySQL/Docker 黑盒、精确提交并推送;功能分支提交为 `f399060edb2ff778322a4f021c93213f39a6f6bf`。
本轮必须先完整阅读 `docs/s03-production-security-and-resume-privacy-design.md`。该文档是 S03 权威口径;发生冲突时停止并报告,不得自行扩大语义。
## 一、开始前核查
执行:
```powershell
cd C:\Users\15642\Desktop\zhilai
git branch --show-current
git status --short
git diff --cached --name-only
git log -5 --oneline
git diff --check
```
要求:
- 从 S02 已提交基线创建独立分支 `feat/s03-production-privacy`;
- 切分支前确认暂存区和已跟踪工作区为空;
- 保留全部历史未跟踪脚本、日志、JSON、SQL、xlsx、`.env` 和验收目录;
- 不执行 `git reset/restore/checkout -- <path>/clean`;创建新分支仅允许 `git switch -c feat/s03-production-privacy`;
- 不执行 `git add -A`、commit、push;
- 实施阶段不操作 Docker,不执行真实 MySQL 黑盒;
- 搜索并完整阅读适用的 `AGENTS.md`,不存在则记录。
如已有修改与 S03 目标文件重叠,停止并报告。
## 二、必须完整阅读
迁移与模型:
- `V1__base_schema.sql`
- `V1_1__seed_data.sql`
- `V2__business_schema.sql`
- `V27__student_privacy_settings.sql`
- `V42__auth_token_sessions.sql`
- `User`、`UserRepository`、`Company`、`CompanyRepository`
- `Application`、`ApplicationRepository`、`Job`、`StudentProfile`
隐私与简历:
- `PrivacySettingsService`、`StudentPrivacySettings`、`ResumeVisibility`
- `ApplicationController`、`ApplicationService`、`CandidateDetailView`
- `ProfileController`、`ProfileService`
- `ResumeStorageService`、`LocalResumeStorageService`
- `ResumeFilenameSanitizer`、`ResumeFileValidator`
- 全部 `profile`、`resume`、`privacy`、`application` 测试
认证与运维:
- `SecurityConfig`、`CurrentUserAccessor`
- `AuthService`、`AuthSessionService`、`JwtAuthenticationFilter`
- `application.yml` 及所有 profile 配置
- `ErrorCode`、`GlobalExceptionHandler`
- `pom.xml` 与 Actuator 测试
文档:
- S03 权威设计文档
- `privacy-settings-api.md`
- `resume-api.md`
- `frontend-integration.md`
- `backend-development-roadmap.md`
- `phase1-scope-and-acceptance.md`
- `openapi-rc.json`
- `verify_openapi.py`
先执行搜索并记录所有企业侧简历暴露点:
```powershell
rg -n -i "resumeUrl|resume_url|download.*resume|resume.*download|ResumeStorageService\.read|canCompanyViewResume|studentUserId" mydreamjob-server/src/main/java
```
## 三、S03-A:禁用历史种子账号
新增且仅新增 `V43__disable_legacy_seed_accounts.sql`。
要求:
1. 不修改 V1–V42。
2. 不按固定 ID 匹配。
3. 每条 UPDATE 必须同时匹配 V1_1 中的原手机号、原邮箱、原 bcrypt 哈希、原角色、`is_active=1`。
4. ADMIN、STUDENT、COMPANY 三条分别处理。
5. 仅更新 `is_active=0`,不删除 users/user_roles,不修改历史业务表。
6. 改过手机号、邮箱、哈希或角色的同源账号不得被禁用。
7. 重复执行效果稳定。
新增迁移集成测试,至少覆盖:完整指纹命中、每个单字段偏差不命中、非固定 ID 也能命中、重复执行、关联数据保留、V1–V42 校验和不变。
## 四、S03-A:一次性管理员初始化
新增强类型配置和条件装配,例如:
- `AdminBootstrapProperties`
- `AdminBootstrapRunner`
- 必要的内部 Service
严格按设计文档执行:默认关闭;只有严格 `true` 才装配;手机号来自环境变量;无默认手机号;不打印手机号;不提供公网接口。
核心行为:
- 已有活动 ADMIN:无副作用跳过;
- 无活动 ADMIN + 手机号不存在:创建活动且已验证 ADMIN,`users.role` 与 `user_roles` 一致;
- 手机号已属于任何用户或角色状态异常:启动失败,禁止提权、复活或覆盖;
- 重复启动幂等;
- 多实例冲突安全失败;
- 不签发 Token、不发送短信、不写默认密码。
错误信息必须固定且不包含手机号、用户对象、SQL 或环境变量值。日志只能使用固定事件文本。
必须先写失败测试,覆盖:disabled、missing phone、invalid phone、existing active admin、new admin、duplicate run、existing student/company/inactive user、users/user_roles 一致性、日志脱敏、并发冲突。
## 五、S03-B:企业简历下载与隐私
新增唯一公网接口:
`GET /company/candidates/{applicationId}/resume`
推荐新增:
- `CompanyResumeAccessPolicy`
- `CompanyResumeService`
- 必要的文件响应 record/DTO
不得将文件访问逻辑塞入现有候选人 DTO,不得返回 `resumeUrl`。
授权必须在 Service/Policy 中完成,不可信任 Controller 传入的归属信息。按以下顺序:当前 COMPANY 用户 → 当前 Company → Application+Job → Job.companyId → Student → privacy setting → profile/file。
必须修复现有 `canCompanyViewResume` 的公司归属缺失:`APPLIED_COMPANIES` 不能只校验学生和状态;必须证明 application 对应 Job 属于当前 company。允许替换该方法或让它委托给新 Policy,但不得保留一个可被误用的宽松生产方法。
语义严格使用设计文档决策表:
- 默认/null 与 PRIVATE:全部企业 403;
- APPLIED_COMPANIES:仅本企业有效投递,WITHDRAWN/REJECTED 拒绝;
- VERIFIED_COMPANIES:企业 verified=true 且拥有 application 的岗位,状态不额外限制;
- 跨企业、未认证、错误角色均拒绝;
- 人才发现本轮不能借 candidateKey 下载简历。
响应要求:
- 使用安全 `Content-Disposition`;
- content type 只能来自已验证的白名单;
- 不返回内部路径、studentUserId、手机号、邮箱;
- 拒绝路径不得调用 storage `read`;
- 文件不存在安全失败,不回显路径;
- 不缓存私有文件,至少返回 `Cache-Control: no-store`;
- 不把简历正文写入日志、审计或异常。
权限测试必须使用真实 SecurityFilterChain/MockMvc,并覆盖 COMPANY、其他企业、未认证企业、STUDENT、ADMIN、匿名。集成测试必须使用真实 H2 Repository 和真实本地临时存储,不得 Mock 掉被验证的授权链。
对所有状态 `SUBMITTED/VIEWED/INTERVIEW/HIRED/REJECTED/WITHDRAWN` 分别验证三种 visibility。验证企业候选人列表/详情仍不含 `resumeUrl`。
## 六、S03-C:Actuator 隔离
按设计新增:
```yaml
management:
server:
port: ${MANAGEMENT_SERVER_PORT:8081}
address: ${MANAGEMENT_SERVER_ADDRESS:127.0.0.1}
```
并将 health details/components 设为 `never`。不要使用业务 JWT 角色代替运维网络隔离,不新增运维公网接口。
必须验证:
- 业务端口无法访问 metrics/prometheus;
- 管理端口只绑定配置地址;
- health 仅返回最小状态,不含 DB/Redis/RabbitMQ/磁盘/连接池详情;
- metrics/prometheus 只在管理端口存在;
- STUDENT/COMPANY/ADMIN Token 不会使业务端口指标可见;
- dev/test/integration-test 不发生随机端口冲突。
若 Spring Boot 独立 management context 与当前 SecurityFilterChain 行为不一致,先新增真实上下文失败测试,再做最小配置;不得通过重新公开指标绕过。
## 七、数据与副作用测试
前后保存完整摘要,至少覆盖:
- users、user_roles;
- student_profiles、resume_versions;
- applications、jobs、companies;
- auth_tokens、audit_logs;
- candidate_dimension_scores;
- notifications、messages;
- scoring_risk_events/actions;
- talent_invitations。
企业下载只允许读取;不得修改上述业务表,不发送 RabbitMQ、短信、邮件或通知。若记录下载审计,字段必须为固定白名单且不得包含路径、文件名、学生 ID 或内容。
## 八、OpenAPI 与文档
从运行时契约更新 `docs/openapi-rc.json`,不得手工伪造。
更新:
- `privacy-settings-api.md`
- `resume-api.md`
- `frontend-integration.md`
- `backend-development-roadmap.md`
- `phase1-scope-and-acceptance.md`
- `verify_openapi.py`
校验器必须递归断言:
- 只有一个新增路径/operation;
- 下载接口无 requestBody;
- 企业候选人 DTO 不含 `resumeUrl/studentUserId/phone/email/storagePath`;
- 不存在按 studentUserId/candidateKey 的简历下载路径;
- Actuator 不进入业务 OpenAPI;
- 预期基线为 176 paths / 214 operations,最终以运行时生成结果为准。
## 九、验证顺序
先运行 S03 新增定向测试,再运行:
```powershell
cd C:\Users\15642\Desktop\zhilai\mydreamjob-server
mvn -Dtest="*AdminBootstrap*Test,*LegacySeed*Test,*Privacy*Test,*Resume*Test,*CompanyCandidate*Test,*Actuator*Test" test
mvn -Dtest="*Auth*Test,*Security*Test,*Application*Test,*Company*Test,*Privacy*Test,*Resume*Test" test
mvn test
mvn clean verify
python ..\docs\verify_openapi.py
```
最后执行:
```powershell
cd C:\Users\15642\Desktop\zhilai
git diff --check
git status --short
git diff --cached --name-only
git diff --name-only -- mydreamjob-server/src/main/resources/db/migration
```
必须满足 Failures=0、Errors=0、Skipped=0、clean verify 成功、静态 OpenAPI 校验通过、暂存区为空、V43 为唯一迁移、未新增 ErrorCode。
## 十、实施结束状态
实施结束立即停止,不操作 Docker,不执行真实 MySQL 黑盒,不暂存、不提交、不推送,不进入 Q01。
最终报告必须列出:
1. V43 精确匹配条件和各账号命中结果;
2. 被修改过的种子账号不受影响证据;
3. 管理员初始化配置、幂等、冲突与并发结果;
4. 是否打印手机号或创建默认密码;
5. 企业简历权限完整决策矩阵;
6. `APPLIED_COMPANIES` 公司归属缺陷修复方式;
7. 拒绝路径文件读取次数;
8. 完整响应与响应头脱敏结果;
9. Actuator 业务端口/管理端口结果;
10. 数据库副作用与 RabbitMQ 结果;
11. 专项、关联、全量测试数及 Failures/Errors/Skipped;
12. clean verify 与 OpenAPI 结果;
13. 精确修改文件和 Git 状态;
14. 是否新增 Flyway、表、索引、ErrorCode、路径;
15. 明确未执行 git add -A、commit、push、Docker/MySQL 黑盒;
16. 明确真实 MySQL/Docker 黑盒仍待独立验收。
最终状态只能写:
“S03 已实现并完成自动化验证与干净构建,待独立代码复验及真实 MySQL/Docker 黑盒验收。”

225
docs/smoke-test.sh

@ -1,20 +1,56 @@
#!/usr/bin/env bash
# ── xtrace 防护(S01 第三轮 F4)───────────────────────────────
# 必须在读取任何环境变量、组装任何请求之前关闭 xtrace。
# 脚本是独立可执行程序,安全优先:全程不重新 set -x,
# 避免验证码 / 完整手机号 / LOGIN_BODY / 登录响应 / TOKEN / AUTH 进入 stderr。
case "$-" in
*x*) set +x ;;
esac
set -u
# MyDreamJob backend RC smoke test.
# Required tools: curl, grep, sed, head, tail.
# Required tools: curl, grep, sed, head, tail, docker(仅取测试验证码时)。
# Environment:
# BASE_URL API base URL (default: http://127.0.0.1:8080/api/v1)
# STUDENT_PHONE Fresh 11-digit phone; generated when omitted
# COMPANY_TOKEN Company JWT; company status step is SKIP when omitted
# SERVER_LOG Server log path used to read Mock SMS codes
# BASE_URL API base URL (default: http://127.0.0.1:8080/api/v1)
# STUDENT_PHONE Fresh 11-digit phone; generated when omitted
# COMPANY_TOKEN Company JWT; company status step is SKIP when omitted
# SMS_MOCK_ENABLED=true **必需**:声明被测应用以 app.sms.mock.enabled=true 运行
# ALLOW_TEST_OTP_DB_READ=true **必需**:显式授权本次本地测试从数据库读取验证码
#
# 安全说明(S01 定向整改 F1 / 第三轮 F4+F5):
# * 应用日志**不再**输出验证码,本脚本也**不得**从日志/控制台/stdout 读取验证码。
# * 仅在 [SMS_MOCK_ENABLED=true] **且** [ALLOW_TEST_OTP_DB_READ=true] 时,
# 才通过容器内部只读 SQL 取得验证码;任一条件不满足立即安全失败。
# * 验证码只保存在 shell 局部变量 / REPLY 约定中,不写日志、不写文件、不输出到终端。
# * **xtrace 永远关闭**:即使以 `bash -x` 调用,脚本开头即 set +x 且全程不恢复。
# * 数据库凭据通过容器进程环境 MYSQL_PWD 传递,**不进入 mysql 命令行参数**;
# 使用应用账号 MYSQL_USER(不使用 root,缺失即安全失败)。
# * 不接入真实短信供应商;生产环境禁止使用该取码方式。
BASE_URL="${BASE_URL:-http://127.0.0.1:8080/api/v1}"
SERVER_LOG="${SERVER_LOG:-}"
PASS=0
FAIL=0
SKIP=0
# ── 凭据变量登记(退出时统一清理)─────────────────────────────
# 覆盖全部敏感变量:手机号、验证码、登录请求体、登录响应、JWT、Authorization 头。
# 注意:不得在此处给 PHONE / STUDENT_PHONE / COMPANY_TOKEN 赋空值,
# 它们携带调用方传入值或由后续逻辑生成。
SMS=""
OTP=""
REPLY=""
LOGIN_BODY=""
LOGIN=""
TOKEN=""
AUTH=""
cleanup_secrets() {
unset PHONE SMS OTP REPLY LOGIN_BODY LOGIN TOKEN AUTH 2>/dev/null || true
unset STUDENT_PHONE COMPANY_TOKEN 2>/dev/null || true
}
trap cleanup_secrets EXIT
if [ -n "${STUDENT_PHONE:-}" ]; then
PHONE="$STUDENT_PHONE"
else
@ -27,20 +63,113 @@ ok() { echo "[PASS] $1"; PASS=$((PASS + 1)); }
fail() { echo "[FAIL] $1"; FAIL=$((FAIL + 1)); }
skip() { echo "[SKIP] $1"; SKIP=$((SKIP + 1)); }
resolve_log() {
case "$SERVER_LOG" in
[A-Za-z]:/*)
local drive rest
drive="$(printf '%s' "${SERVER_LOG:0:1}" | tr '[:upper:]' '[:lower:]')"
rest="${SERVER_LOG:2}"
if [ -d /mnt/c ]; then
echo "/mnt/$drive$rest"
else
echo "/$drive$rest"
fi
;;
*) echo "$SERVER_LOG" ;;
# 手机号脱敏(控制台输出不展示完整号码)
mask_phone() {
local p="${1:-}"
if [ "${#p}" -lt 7 ]; then
printf '%s' '***'
return
fi
printf '%s****%s' "${p:0:3}" "${p: -4}"
}
# ── 布尔解析(大小写/空白不敏感,仅 true 为真)────────────────
is_true() {
local v
v="$(printf '%s' "${1:-}" | tr -d '[:space:]' | tr '[:upper:]' '[:lower:]')"
[ "$v" = "true" ]
}
# ── 取码双重保护 ─────────────────────────────────────────────
# 保护 1:被测应用必须显式处于 mock 短信模式
require_mock_sms_mode() {
is_true "${SMS_MOCK_ENABLED:-}"
}
# 保护 2:本次执行必须显式授权读取验证码表
require_test_otp_db_read_permission() {
is_true "${ALLOW_TEST_OTP_DB_READ:-}"
}
# 从项目实际 compose 文件解析 mysql 容器名(不硬编码、不假设)
# 使用 BASH_SOURCE 以便脚本被 `source` 时仍能正确定位自身目录
compose_mysql_container() {
local self base compose
self="${BASH_SOURCE[0]:-$0}"
base="$(cd "$(dirname "$self")" && pwd)"
for compose in "$base/../backend-infra/compose.yaml" "$base/../backend-infra/docker-compose.yml"; do
[ -f "$compose" ] || continue
awk '
/^[[:space:]]+mysql:[[:space:]]*$/ { in_mysql=1; next }
/^[[:space:]]+[A-Za-z0-9_-]+:[[:space:]]*$/ { in_mysql=0 }
in_mysql && /^[[:space:]]+container_name:[[:space:]]*/ {
sub(/^[[:space:]]*container_name:[[:space:]]*/, ""); print; exit
}
' "$compose"
done
}
# 容器内只读查询(F5:凭据经进程环境 MYSQL_PWD 传递,不进入 mysql argv;SQL 经 stdin)
# - 使用应用账号 MYSQL_USER(不使用 root;缺失即安全失败,不回退 root)
# - 仅允许 SELECT;查询失败返回非 0,由 sms_code() 输出固定安全错误
otp_db_query() {
local phone="$1" purpose="$2" container
container="$(compose_mysql_container | head -n 1)"
[ -n "$container" ] || return 1
printf '%s\n' "SELECT code FROM sms_verification_codes" \
" WHERE phone = '$phone' AND purpose = '$purpose' AND used = 0" \
" AND expired_at > NOW()" \
" ORDER BY created_at DESC, id DESC LIMIT 1;" \
| docker exec -i "$container" sh -c '
# 应用账号凭据必需,缺失则安全失败(绝不回退 root)
if [ -z "${MYSQL_USER:-}" ] || [ -z "${MYSQL_PASSWORD:-}" ]; then
echo "db credentials unavailable" >&2
exit 3
fi
# 密码只经环境变量 MYSQL_PWD 传递,argv 中不出现 -p/--password
MYSQL_PWD="$MYSQL_PASSWORD" exec mysql \
-u"$MYSQL_USER" \
-N -B \
"${MYSQL_DATABASE:-mydreamjob}"' 2>/dev/null \
| head -n 1
}
# 取得验证码:成功时写入 REPLY;绝不打印到 stdout/stderr
sms_code() {
local disabled_msg="test OTP retrieval is disabled"
if ! require_mock_sms_mode || ! require_test_otp_db_read_permission; then
echo "$disabled_msg" >&2
return 1
fi
case "${PHONE:-}" in
*[!0-9]*|'') echo "$disabled_msg" >&2; return 1 ;;
esac
[ "${#PHONE}" -eq 11 ] || { echo "$disabled_msg" >&2; return 1; }
# xtrace 已在脚本开头全局关闭,这里无需(也不得)保存/恢复其状态
local code="" qstatus=0
IFS= read -r code < <(otp_db_query "$PHONE" "login") || qstatus=$?
# 校验格式:当前验证码为 6 位数字(app.sms.mock.code-length / CODE_PATTERN)
local valid=1
[ "$qstatus" -eq 0 ] || valid=0
case "$code" in
[0-9][0-9][0-9][0-9][0-9][0-9]) : ;;
*) valid=0 ;;
esac
if [ "$valid" -ne 1 ]; then
unset code
echo "test OTP retrieval failed" >&2
return 1
fi
REPLY="$code"
unset code
return 0
}
report() {
@ -53,6 +182,23 @@ report() {
echo " http=$http_code ${biz_code:-code=N/A} ${request_id:-requestId=N/A}"
}
# 仅接收可安全打印的三个字段,不接收任何响应体全文(用于登录等敏感响应)
report_fields() {
local http_code="$1" biz_code="$2" request_id="$3"
echo " http=$http_code ${biz_code:-code=N/A} ${request_id:-requestId=N/A}"
}
# 从响应中只提取可安全打印字段(http / business code / requestId),不保留响应体
extract_status_fields() {
local response="$1"
local http_code body biz_code request_id
http_code="$(printf '%s\n' "$response" | tail -n 1)"
body="$(printf '%s\n' "$response" | sed '$d')"
biz_code="$(printf '%s' "$body" | grep -oE '"code"[[:space:]]*:[[:space:]]*[0-9]+' | head -n 1 | grep -oE '[0-9]+' || true)"
request_id="$(printf '%s' "$body" | grep -oE '"requestId"[[:space:]]*:[[:space:]]*"[^"]*"' | head -n 1 || true)"
printf '%s\n%s\n%s' "$http_code" "$biz_code" "$request_id"
}
extract_token() {
printf '%s' "$1" | sed -n 's/.*"accessToken"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' | head -n 1
}
@ -61,17 +207,16 @@ extract_id() {
printf '%s' "$1" | sed -n 's/.*"id"[[:space:]]*:[[:space:]]*\([0-9][0-9]*\).*/\1/p' | head -n 1
}
sms_code() {
local log_path
log_path="$(resolve_log)"
[ -n "$log_path" ] || return 1
[ -f "$log_path" ] || return 1
grep -a "MOCK SMS CODE.*phone=$PHONE" "$log_path" | tail -n 1 | grep -a -oE 'code=[0-9]{6}' | cut -d= -f2
}
# ── 可测试性钩子 ───────────────────────────────────────────
# 供 shell 单元测试加载函数(验证取码保护与泄漏防护)而不执行完整流程。
# 正常运行(不设该变量)时完全不生效,不影响任何行为。
if [ "${SMOKE_TEST_LIB_ONLY:-}" = "1" ]; then
return 0 2>/dev/null || exit 0
fi
echo "=== MyDreamJob backend smoke test ==="
echo "BASE_URL=$BASE_URL"
echo "PHONE=$PHONE"
echo "PHONE=$(mask_phone "$PHONE")"
say "GET /health"
HEALTH="$(curl -sS -w '\n%{http_code}' "$BASE_URL/health" || true)"
@ -84,19 +229,33 @@ for attempt in 1 2 3; do
curl -sS -X POST "$BASE_URL/auth/sms/send?phone=$PHONE&purpose=login" \
-H "X-Request-Id: smoke-sms-$attempt" >/dev/null 2>&1 || true
sleep 2
SMS="$(sms_code || true)"
[ -n "$SMS" ] && break
if sms_code; then
SMS="$REPLY"
unset REPLY
[ -n "$SMS" ] && break
fi
done
if [ -z "$SMS" ]; then
fail "cannot read Mock SMS code; set SERVER_LOG to the server log path"
fail "login skipped"
unset SMS
TOKEN=""
else
LOGIN="$(curl -sS -w '\n%{http_code}' -X POST "$BASE_URL/auth/login" \
# 请求体经 stdin 传入(--data-binary @-),验证码不出现在进程参数中
LOGIN_BODY="{\"phone\":\"$PHONE\",\"code\":\"$SMS\",\"purpose\":\"login\"}"
LOGIN="$(printf '%s' "$LOGIN_BODY" | curl -sS -w '\n%{http_code}' -X POST "$BASE_URL/auth/login" \
-H 'Content-Type: application/json' \
-H 'X-Request-Id: smoke-login-001' \
-d "{\"phone\":\"$PHONE\",\"code\":\"$SMS\",\"purpose\":\"login\"}" || true)"
report "$LOGIN"
--data-binary @- || true)"
unset LOGIN_BODY
unset SMS
# 只保留可安全打印的三个字段;登录响应全文绝不交给打印函数,也绝不落盘
LOGIN_FIELDS="$(extract_status_fields "$LOGIN")"
TOKEN="$(extract_token "$LOGIN")"
unset LOGIN
report_fields "$(printf '%s' "$LOGIN_FIELDS" | sed -n '1p')" \
"$(printf '%s' "$LOGIN_FIELDS" | sed -n '2p')" \
"$(printf '%s' "$LOGIN_FIELDS" | sed -n '3p')"
unset LOGIN_FIELDS
if [ -n "$TOKEN" ]; then ok "login"; else fail "login"; fi
fi

144
docs/tests/smoke-test-schema-contract-test.sh

@ -0,0 +1,144 @@
#!/usr/bin/env bash
# S01 第四轮 F6 —— sms_verification_codes 表结构契约门禁
#
# 目的:防止冒烟脚本再次使用**不存在的列**(F6:expires_at),
# 且不允许测试自己维护一份与生产无关的假字段列表来自证正确。
#
# 契约来源(本脚本同时使用前两个,并交叉校验二者一致):
# 1. 实体 @Column:mydreamjob-server/.../auth/SmsVerificationCode.java
# 2. 建表 Flyway:mydreamjob-server/.../db/migration/V1__base_schema.sql
# 3. 真实 MySQL SHOW COLUMNS —— 属**真实验收阶段**,本脚本只输出对账命令,
# 不在自动化阶段伪造该来源。
#
# 重要:expires_at 是 personal_data_export_tasks(V30) / ability_attempts(V35) /
# talent_invitations(V40) 三张表的**真实列**。因此本门禁只对
# sms_verification_codes 这一个表断言"不存在 expires_at",
# 不得写成全库禁令(那会误报,并掩盖真正的字段错误)。
#
# 运行: bash docs/tests/smoke-test-schema-contract-test.sh
set -u
REPO_ROOT="$(cd "$(dirname "$0")/../.." && pwd)"
SMOKE="$REPO_ROOT/docs/smoke-test.sh"
ENTITY="$REPO_ROOT/mydreamjob-server/src/main/java/com/mydreamjob/auth/SmsVerificationCode.java"
MIGRATION_DIR="$REPO_ROOT/mydreamjob-server/src/main/resources/db/migration"
FLYWAY="$MIGRATION_DIR/V1__base_schema.sql"
TABLE="sms_verification_codes"
PASS=0
FAIL=0
ok() { echo "[PASS] $1"; PASS=$((PASS + 1)); }
bad() { echo "[FAIL] $1"; FAIL=$((FAIL + 1)); }
check() { if [ "$1" = "1" ]; then ok "$2"; else bad "$2"; fi; }
echo "=== S01 F6: sms_verification_codes 表结构契约门禁 ==="
echo "实体来源 : $ENTITY"
echo "Flyway来源: $FLYWAY"
echo "脚本 : $SMOKE"
echo ""
# ── 契约来源 1:实体 @Column ──────────────────────────────────
entity_cols=""
if [ -f "$ENTITY" ]; then
# 主键 id 在实体中以 @Id 标注(无 @Column(name=...)),单独补入
grep -qE '^[[:space:]]*@Id[[:space:]]*$' "$ENTITY" && entity_cols="id"
entity_cols="$entity_cols $(grep -oE '@Column\(name[[:space:]]*=[[:space:]]*"[A-Za-z0-9_]+"' "$ENTITY" \
| sed 's/.*"\(.*\)"/\1/')"
entity_cols="$(printf '%s' "$entity_cols" | tr ' ' '\n' | grep -v '^$' | sort -u | tr '\n' ' ')"
fi
# ── 契约来源 2:建表 Flyway ───────────────────────────────────
flyway_cols=""
if [ -f "$FLYWAY" ]; then
flyway_cols="$(awk -v t="$TABLE" '
$0 ~ "^CREATE TABLE (IF NOT EXISTS )?" t "([[:space:]]|\\()" { in_t=1; next }
in_t && /^\)/ { in_t=0 }
in_t {
line=$0
sub(/^[[:space:]]+/, "", line)
if (line ~ /^(INDEX|KEY|PRIMARY|CONSTRAINT|UNIQUE|FOREIGN)/) next
if (line ~ /^[A-Za-z0-9_]+[[:space:]]/) { split(line, a, /[[:space:]]/); print a[1] }
}
' "$FLYWAY" | sort -u | tr '\n' ' ')"
fi
check "$([ -f "$ENTITY" ] && echo 1 || echo 0)" "C1. 实体文件存在"
check "$([ -f "$FLYWAY" ] && echo 1 || echo 0)" "C2. 建表 Flyway 存在"
check "$([ -n "$entity_cols" ] && echo 1 || echo 0)" "C3. 实体 @Column 解析出非空列集合"
check "$([ -n "$flyway_cols" ] && echo 1 || echo 0)" "C4. Flyway 解析出非空列集合"
# 两个独立来源必须一致(否则说明解析失败或生产定义本身不一致)
check "$([ "$(printf '%s' "$entity_cols" | tr ' ' '\n' | grep -c .)" -gt 0 ] \
&& [ "$entity_cols" = "$flyway_cols" ] && echo 1 || echo 0)" \
"C5. 实体 @Column 与建表 Flyway 的列集合一致"
echo " 实体列 : ${entity_cols:-<空>}"
echo " Flyway列: ${flyway_cols:-<空>}"
echo ""
# ── 字段存在性(表限定)───────────────────────────────────────
check "$(case " $flyway_cols " in *" expired_at "*) echo 1 ;; *) echo 0 ;; esac)" \
"C6. sms_verification_codes 存在 expired_at 列"
check "$(case " $flyway_cols " in *" expires_at "*) echo 0 ;; *) echo 1 ;; esac)" \
"C7. sms_verification_codes **不存在** expires_at 列"
check "$(case " $entity_cols " in *" expired_at "*) echo 1 ;; *) echo 0 ;; esac)" \
"C8. 实体 expired_at 映射存在"
# ── 反向护栏:expires_at 确实是别的表的真实列 ──────────────────
# 若此条失败,说明有人把它当成了"全库不存在的列"——那会让本门禁退化成误报。
other_tables_with_expires_at=0
for f in "$MIGRATION_DIR"/V30__personal_data_export_tasks.sql \
"$MIGRATION_DIR"/V35__ability_assessment.sql \
"$MIGRATION_DIR"/V40__enterprise_talent_invitations.sql; do
[ -f "$f" ] || continue
grep -qE '^[[:space:]]*expires_at[[:space:]]' "$f" && other_tables_with_expires_at=$((other_tables_with_expires_at + 1))
done
check "$([ "$other_tables_with_expires_at" -gt 0 ] && echo 1 || echo 0)" \
"C9. expires_at 确实是其他表的真实列(故门禁必须按表限定,不得全局禁用)"
# ── 脚本侧:表名与列名 ────────────────────────────────────────
sql_block="$(awk '/SELECT code FROM sms_verification_codes/,/LIMIT 1;/' "$SMOKE" 2>/dev/null)"
# 只保留双引号内的 SQL 片段,剥掉 shell 语法(printf / 引号 / 续行反斜杠),
# 否则 printf、%s 之类会被误当成列名。
sql_text="$(printf '%s' "$sql_block" | grep -oE '"[^"]*"' | tr -d '"' | tr '\n' ' ')"
check "$([ -n "$sql_text" ] && echo 1 || echo 0)" "C10. 能从 smoke-test.sh 提取取码 SQL 文本"
check "$(printf '%s' "$sql_text" | grep -q "FROM $TABLE" && echo 1 || echo 0)" \
"C11. 取码 SQL 查询表为 $TABLE"
check "$(printf '%s' "$sql_text" | grep -q 'expired_at' && echo 1 || echo 0)" \
"C12. 取码 SQL 使用 expired_at"
check "$(printf '%s' "$sql_text" | grep -q 'expires_at' && echo 0 || echo 1)" \
"C13. 取码 SQL **不含** expires_at"
# 脚本 SQL 使用的每个列名都必须落在真实列集合内
used_cols="$(printf '%s' "$sql_text" \
| sed "s/'[^']*'//g" \
| grep -oE '\b[A-Za-z_][A-Za-z0-9_]*\b' \
| grep -viE '^(SELECT|code|FROM|WHERE|AND|OR|ORDER|BY|DESC|ASC|LIMIT|NOW)$|^'$TABLE'$' \
| sort -u | tr '\n' ' ')"
unknown=""
for c in $used_cols; do
case " $flyway_cols " in
*" $c "*) ;;
*) unknown="$unknown $c" ;;
esac
done
check "$([ -z "$unknown" ] && echo 1 || echo 0)" \
"C14. 取码 SQL 使用的列全部存在于 $TABLE${unknown:+(不存在的列:$unknown)}"
# 取码列 code 必须在真实列集合内(正向确认解析没跑偏)
check "$(case " $flyway_cols " in *" code "*) echo 1 ;; *) echo 0 ;; esac)" \
"C15. 取码 SQL 的列 code 是 $TABLE 的真实列"
echo ""
echo "── 真实验收阶段必须执行(本脚本不伪造该来源)───────────────"
echo "docker exec -i <mysql容器> sh -c \\"
echo " 'MYSQL_PWD=\"\$MYSQL_PASSWORD\" mysql -u\"\$MYSQL_USER\" -N -B \"\${MYSQL_DATABASE:-mydreamjob}\" <<<\"SHOW COLUMNS FROM $TABLE;\"'"
echo "对账要求:SHOW COLUMNS 的 Field 集合必须等于上方 Flyway列;"
echo " 必须包含 expired_at;必须不含 expires_at。"
echo ""
echo "============================================"
echo "PASS=$PASS FAIL=$FAIL"
echo "============================================"
[ "$FAIL" -eq 0 ]

548
docs/tests/smoke-test-sms-code-test.sh

@ -0,0 +1,548 @@
#!/usr/bin/env bash
# S01 定向整改 F1 / F4 / F5 —— smoke-test.sh 敏感信息泄漏防护测试
#
# 覆盖:
# F1 取码机制(双重保护 / 不依赖应用日志 / 格式校验 / 清理)
# F4 xtrace 泄漏:**真实以 `bash -x` 执行被测脚本**,捕获 stdout+stderr 并扫描敏感标记
# F4 source 模式不把敏感变量带回调用方
# F5 数据库密码不进入 mysql argv(真实执行容器侧脚本文本,检查假 mysql 的实际 argv)
# §7 文档门禁只针对**活跃文件列表**,不对历史验收报告做全 docs 扫描
#
# 测试使用假的 docker / curl / mysql(PATH 注入),不要求 Docker 或真实 MySQL。
# 仓库内不写入真实验证码、JWT 或数据库口令;测试结束扫描输出确认标记未泄露。
#
# 运行: bash docs/tests/smoke-test-sms-code-test.sh
set -u
REPO_ROOT="$(cd "$(dirname "$0")/../.." && pwd)"
SMOKE="$REPO_ROOT/docs/smoke-test.sh"
# ── 固定测试标记(均为假值,非真实凭据)─────────────────────────
TEST_CODE="739251"
TEST_PHONE="19900009999"
TEST_JWT="FAKEJWT_MARKER_QA7X"
TEST_DBPASS="FAKEDBPASS_MARKER_QA7X"
MARKER_MSG="test OTP retrieval is disabled"
TMP="$(mktemp -d)"
FAKEBIN="$TMP/bin"
mkdir -p "$FAKEBIN"
export FAKE_DIR="$TMP"
trap 'rm -rf "$TMP"' EXIT
PASS=0
FAIL=0
ok() { echo "[PASS] $1"; PASS=$((PASS + 1)); }
bad() { echo "[FAIL] $1"; FAIL=$((FAIL + 1)); }
check() { if [ "$1" = "1" ]; then ok "$2"; else bad "$2"; fi; }
# ── 假 docker:真实执行被传入的容器侧 sh -c 脚本(含假 mysql)──────
cat > "$FAKEBIN/docker" <<'FAKEDOCKER'
#!/usr/bin/env bash
# 记录完整 argv(用于 F5 断言:宿主机侧命令行不得含密码)
printf '%s\n' "$*" >> "${FAKE_DIR:?}/docker-argv.log"
script=""
for a in "$@"; do script="$a"; done
# 记录容器侧脚本文本(变量名可以出现,值不可以)
printf '%s\n' "$script" > "${FAKE_DIR}/container-script.txt"
case "${FAKE_DOCKER_MODE:-ok}" in
fail) exit 1 ;;
empty) cat >/dev/null 2>&1 || true; exit 0 ;;
garbage) cat >/dev/null 2>&1 || true; printf '%s\n' "${FAKE_GARBAGE:-not-a-code}"; exit 0 ;;
short) cat >/dev/null 2>&1 || true; printf '%s\n' "12345"; exit 0 ;;
esac
# 真实以本地 sh 执行容器侧脚本,使假 mysql 成为真正被检查 argv 的进程
exec sh -c "$script"
FAKEDOCKER
chmod +x "$FAKEBIN/docker"
# ── 假 mysql:记录 argv 与 stdin(SQL),并按 sms_verification_codes 真实语义作答 ──
# 它**不是**无条件返回验证码:先校验 SQL 使用的列。
# 真实 MySQL 对未知列返回 ERROR 1054 (42S22),这里如实复现,
# 使脚本里字段名写错(例如 expires_at)无法被"总能拿到验证码"掩盖。
#
# FAKE_ROWS 未设置 → 保留历史行为(输出 FAKE_CODE),供 F1/F5 既有用例使用。
# FAKE_ROWS 设置 → 按行套用 WHERE / ORDER BY 语义作答。行格式:
# id|phone|purpose|used|expire_offset_s|created_at|code
# expire_offset_s > 0 表示 expired_at 晚于 NOW();查询用严格大于,故 =0 不命中。
cat > "$FAKEBIN/mysql" <<'FAKEMYSQL'
#!/usr/bin/env bash
printf '%s\n' "$*" > "${FAKE_DIR:?}/mysql-argv.log"
sql="$(cat)"
printf '%s' "$sql" > "${FAKE_DIR}/mysql-stdin.sql"
# ── 表结构契约检查:sms_verification_codes 的真实列是 expired_at ──
case "$sql" in
*sms_verification_codes*)
case "$sql" in
*expires_at*)
printf "ERROR 1054 (42S22): Unknown column 'expires_at' in 'where clause'\n" >&2
exit 1
;;
esac
;;
esac
# 未提供假数据:保持历史行为
if [ -z "${FAKE_ROWS:-}" ]; then
printf '%s\n' "${FAKE_CODE:-}"
exit 0
fi
# 从 SQL 解析查询参数(形态与应用脚本一致)
phone="$(printf '%s' "$sql" | sed -n "s/.*phone *= *'\([^']*\)'.*/\1/p" | head -n 1)"
purpose="$(printf '%s' "$sql" | sed -n "s/.*purpose *= *'\([^']*\)'.*/\1/p" | head -n 1)"
# 逐行套用 WHERE 语义,按 created_at DESC, id DESC 取第一条
best_key="" best_code=""
while IFS='|' read -r rid rphone rpurpose rused roffset rcreated rcode; do
[ -n "${rid:-}" ] || continue
[ "$rphone" = "$phone" ] || continue
[ "$rpurpose" = "$purpose" ] || continue
[ "$rused" = "0" ] || continue
case "$roffset" in
''|*[!0-9-]*) continue ;;
esac
[ "$roffset" -gt 0 ] || continue
key="$(printf '%s|%010d' "$rcreated" "$rid")"
if [ -z "$best_key" ] || [[ "$key" > "$best_key" ]]; then
best_key="$key" best_code="$rcode"
fi
done <<< "${FAKE_ROWS}"
[ -n "$best_code" ] && printf '%s\n' "$best_code"
exit 0
FAKEMYSQL
chmod +x "$FAKEBIN/mysql"
# ── 假 curl:记录请求;登录响应含固定测试 JWT ────────────────────
cat > "$FAKEBIN/curl" <<'FAKECURL'
#!/usr/bin/env bash
url=""; want_stdin=0
for a in "$@"; do
case "$a" in
http*) url="$a" ;;
--data-binary) want_stdin=1 ;;
*@-) want_stdin=1 ;;
esac
done
has_code="no"
if [ "$want_stdin" = "1" ]; then
body="$(cat)"
case "$body" in
*[0-9][0-9][0-9][0-9][0-9][0-9]*) has_code="yes" ;;
esac
fi
case "$url" in
*"/auth/login"*)
echo "login-attempted has6digit=$has_code" >> "${FAKE_DIR:?}/flow.log"
printf '{"code":200,"data":{"accessToken":"%s"}}\n200\n' "${FAKE_JWT:-FAKEJWT}" ;;
*"/auth/sms/send"*) printf '{}\n200\n' ;;
*"/health"*) printf '{"code":200}\n200\n' ;;
*"/notifications/unread-count"*) printf '{"unreadCount":1}\n200\n' ;;
*"/notifications/"*) printf '{"message":"marked as read"}\n200\n' ;;
*"/notifications"*) printf '{"items":[{"id":88}]}\n200\n' ;;
*"/applications/"*) printf '{"status":"VIEWED"}\n200\n' ;;
*"/applications"*) printf '{"id":77}\n200\n' ;;
*"/jobs/public/"*) printf '{"title":"x"}\n200\n' ;;
*"/match"*) printf '{"rulesVersion":"v1"}\n200\n' ;;
*"/jobs"*) printf '{"items":[{"id":11,"title":"x"}]}\n200\n' ;;
*) printf '{}\n200\n' ;;
esac
exit 0
FAKECURL
chmod +x "$FAKEBIN/curl"
export PATH="$FAKEBIN:$PATH"
export FAKE_JWT="$TEST_JWT"
# 数据库凭据标记:让容器侧脚本的凭据前置检查通过,
# 同时用固定标记值验证它绝不出现在 argv / stdout / stderr / 文件中
export MYSQL_USER="smoke_readonly"
export MYSQL_PASSWORD="$TEST_DBPASS"
export MYSQL_DATABASE="mydreamjob"
# 扫描给定文本是否含任一敏感标记;输出命中的标记名(不含值本身)
scan_hits() {
local text="$1" hits=""
case "$text" in *"$TEST_CODE"*) hits="$hits CODE" ;; esac
case "$text" in *"$TEST_PHONE"*) hits="$hits PHONE" ;; esac
case "$text" in *"$TEST_JWT"*) hits="$hits JWT" ;; esac
case "$text" in *"$TEST_DBPASS"*) hits="$hits DBPASS" ;; esac
case "$text" in *"Authorization: Bearer"*) hits="$hits AUTH" ;; esac
case "$text" in *"LOGIN_BODY="*) hits="$hits LOGIN_BODY" ;; esac
printf '%s' "$hits"
}
echo "=== S01 F1/F4/F5: smoke-test.sh 敏感信息泄漏防护测试 ==="
echo "smoke-test.sh: $SMOKE"
echo "固定测试标记已加载(值不打印)。"
echo ""
# ══════════════════════════════════════════════════════════════
# Part 1 — F1 取码保护(进程内,可观测 REPLY)
# ══════════════════════════════════════════════════════════════
run_sms_code() {
local mock="$1" allow="$2" mode="$3" garbage="${4:-}"
local out
out="$(cd "$REPO_ROOT" && bash -c '
set -u
SMOKE_TEST_LIB_ONLY=1
export SMOKE_TEST_LIB_ONLY
if [ "$1" != "__UNSET__" ]; then export SMS_MOCK_ENABLED="$1"; else unset SMS_MOCK_ENABLED; fi
if [ "$2" != "__UNSET__" ]; then export ALLOW_TEST_OTP_DB_READ="$2"; else unset ALLOW_TEST_OTP_DB_READ; fi
export FAKE_DOCKER_MODE="$3"
export FAKE_CODE="'"$TEST_CODE"'"
export FAKE_GARBAGE="$4"
# shellcheck disable=SC1090
. "'"$SMOKE"'"
if sms_code; then
if [ -n "${REPLY:-}" ]; then printf "RESULT=ok\n"; else printf "RESULT=ok_but_empty\n"; fi
unset REPLY
else
printf "RESULT=fail\n"
fi
' _ "$mock" "$allow" "$mode" "$garbage" 2>&1)"
printf '%s' "$out"
}
# ── 以指定假表数据运行 sms_code,并比对取回的验证码 ─────────────
# 只输出 RESULT=ok/fail 与 MATCH=yes/no,**绝不输出验证码本身**。
run_sms_code_with_rows() {
local rows="$1" expect="$2"
local out
out="$(cd "$REPO_ROOT" && bash -c '
set -u
export SMOKE_TEST_LIB_ONLY=1
export SMS_MOCK_ENABLED=true ALLOW_TEST_OTP_DB_READ=true FAKE_DOCKER_MODE=ok
export FAKE_ROWS="$1"
export STUDENT_PHONE="'"$TEST_PHONE"'"
export MYSQL_USER="smoke_readonly" MYSQL_PASSWORD="$2" MYSQL_DATABASE="mydreamjob"
# shellcheck disable=SC1090
. "'"$SMOKE"'"
if sms_code; then
if [ -n "${REPLY:-}" ]; then
printf "RESULT=ok\n"
if [ "${REPLY}" = "$3" ]; then printf "MATCH=yes\n"; else printf "MATCH=no\n"; fi
else
printf "RESULT=ok_but_empty\n"
fi
unset REPLY
else
printf "RESULT=fail\n"
fi
' _ "$rows" "$TEST_DBPASS" "$expect" 2>&1)"
printf '%s' "$out"
}
# ── 端到端运行指定脚本(用于验证「字段错误时不继续登录」)────────
# 脚本置于 $TMP 下镜像 docs/ 的结构,使其相对定位 backend-infra/compose.yaml 生效。
run_full_script() {
local script="$1"
: > "$TMP/flow.log"
(cd "$REPO_ROOT" && \
SMS_MOCK_ENABLED=true ALLOW_TEST_OTP_DB_READ=true FAKE_DOCKER_MODE=ok \
FAKE_CODE="$TEST_CODE" FAKE_JWT="$TEST_JWT" \
MYSQL_PASSWORD="$TEST_DBPASS" MYSQL_USER="smoke_readonly" MYSQL_DATABASE="mydreamjob" \
STUDENT_PHONE="$TEST_PHONE" BASE_URL="http://127.0.0.1:1/api/v1" \
bash "$script" 2>&1) || true
}
r="$(run_sms_code true true ok)"
check "$(printf '%s' "$r" | grep -q 'RESULT=ok' && echo 1 || echo 0)" "1. mock=true且ALLOW=true时可取得测试验证码"
check "$(printf '%s' "$r" | grep -q "$TEST_CODE" && echo 0 || echo 1)" "10a. 取码调用 stdout/stderr 不含验证码"
r="$(run_sms_code false true ok)"
check "$(printf '%s' "$r" | grep -q 'RESULT=fail' && echo 1 || echo 0)" "2. mock=false 时拒绝查询"
check "$(printf '%s' "$r" | grep -qF "$MARKER_MSG" && echo 1 || echo 0)" "2b. 拒绝时输出固定提示"
r="$(run_sms_code __UNSET__ true ok)"
check "$(printf '%s' "$r" | grep -q 'RESULT=fail' && echo 1 || echo 0)" "3. 配置缺失时拒绝查询"
r="$(run_sms_code true __UNSET__ ok)"
check "$(printf '%s' "$r" | grep -q 'RESULT=fail' && echo 1 || echo 0)" "4. ALLOW_TEST_OTP_DB_READ 缺失时拒绝查询"
r="$(run_sms_code true false ok)"
check "$(printf '%s' "$r" | grep -q 'RESULT=fail' && echo 1 || echo 0)" "5. ALLOW_TEST_OTP_DB_READ=false 时拒绝查询"
r="$(run_sms_code true true empty)"
check "$(printf '%s' "$r" | grep -q 'RESULT=fail' && echo 1 || echo 0)" "6. 没有可用验证码时安全失败"
r="$(run_sms_code true true fail)"
check "$(printf '%s' "$r" | grep -q 'RESULT=fail' && echo 1 || echo 0)" "7. 查询失败时不继续"
r="$(run_sms_code true true garbage 'not-a-code')"
check "$(printf '%s' "$r" | grep -q 'RESULT=fail' && echo 1 || echo 0)" "8a. 非数字结果被拒绝"
r="$(run_sms_code true true short)"
check "$(printf '%s' "$r" | grep -q 'RESULT=fail' && echo 1 || echo 0)" "8b. 长度非法结果被拒绝"
# ══════════════════════════════════════════════════════════════
# Part 2 — F4:真实 `bash -x` 执行,扫描 stdout+stderr
# ══════════════════════════════════════════════════════════════
run_smoke_xtrace() {
local mode="$1"
: > "$TMP/flow.log"; : > "$TMP/docker-argv.log"; : > "$TMP/mysql-argv.log"
(cd "$REPO_ROOT" && \
SMS_MOCK_ENABLED=true ALLOW_TEST_OTP_DB_READ=true FAKE_DOCKER_MODE="$mode" \
FAKE_CODE="$TEST_CODE" FAKE_JWT="$TEST_JWT" \
MYSQL_PASSWORD="$TEST_DBPASS" MYSQL_USER="smoke_readonly" MYSQL_DATABASE="mydreamjob" \
STUDENT_PHONE="$TEST_PHONE" BASE_URL="http://127.0.0.1:1/api/v1" \
bash -x "$SMOKE" 2>&1) || true
}
out_x="$(run_smoke_xtrace ok)"
hits_x="$(scan_hits "$out_x")"
check "$(printf '%s' "$hits_x" | grep -q 'CODE' && echo 0 || echo 1)" "F4-1. bash -x 下不泄露验证码"
check "$(printf '%s' "$hits_x" | grep -q 'PHONE' && echo 0 || echo 1)" "F4-2. bash -x 下不泄露完整手机号"
check "$(printf '%s' "$hits_x" | grep -q 'JWT' && echo 0 || echo 1)" "F4-3. bash -x 下不泄露 JWT"
check "$(printf '%s' "$hits_x" | grep -q 'AUTH' && echo 0 || echo 1)" "F4-4. bash -x 下不泄露 Authorization 头"
check "$(printf '%s' "$hits_x" | grep -q 'DBPASS' && echo 0 || echo 1)" "F4-5. bash -x 下不泄露数据库密码"
check "$(printf '%s' "$hits_x" | grep -q 'LOGIN_BODY' && echo 0 || echo 1)" "F4-6. bash -x 下不泄露 LOGIN_BODY"
# 正常流程仍可完成(登录被调用且带 6 位码;登录判定 PASS)
check "$(grep -q 'login-attempted has6digit=yes' "$TMP/flow.log" 2>/dev/null && echo 1 || echo 0)" \
"F4-7. bash -x 下正常流程仍可完成登录"
check "$(printf '%s' "$out_x" | grep -q '\[PASS\] login' && echo 1 || echo 0)" \
"F4-8. bash -x 下登录步骤判定 PASS"
# xtrace 未在脚本内被重新启用:脚本内不存在可执行的 set -x
setx_code="$(grep -n 'set -x' "$SMOKE" | grep -v '^[0-9]*:[[:space:]]*#' | wc -l)"
check "$([ "$setx_code" -eq 0 ] && echo 1 || echo 0)" "F4-9. 脚本中不存在可执行的 set -x(不会恢复 xtrace)"
# 失败流程同样不泄露
out_f="$(run_smoke_xtrace empty)"
hits_f="$(scan_hits "$out_f")"
check "$([ -z "$hits_f" ] && echo 1 || echo 0)" "F4-10. 失败流程(无可用验证码)同样不泄露敏感值"
# ══════════════════════════════════════════════════════════════
# Part 3 — F4:source 模式不把敏感值带回调用方
# ══════════════════════════════════════════════════════════════
src_out="$(cd "$REPO_ROOT" && bash -c '
set -u
export SMOKE_TEST_LIB_ONLY=1
export SMS_MOCK_ENABLED=true ALLOW_TEST_OTP_DB_READ=true
export FAKE_DOCKER_MODE=ok FAKE_CODE="'"$TEST_CODE"'"
. "'"$SMOKE"'"
trap -p EXIT > "'"$TMP"'/trap.txt" 2>/dev/null || true
if sms_code; then
if [ -n "${REPLY:-}" ]; then echo "reply-nonempty"; else echo "reply-empty"; fi
unset REPLY
fi
# 清理后不得残留任何非空敏感变量
leak=0
for v in REPLY SMS OTP LOGIN_BODY LOGIN TOKEN AUTH; do
eval "val=\${$v:-}"
[ -n "$val" ] && leak=1
done
echo "leak=$leak"
' 2>&1)"
check "$(printf '%s' "$src_out" | grep -q 'reply-nonempty' && echo 1 || echo 0)" "F4-11. source 模式下取码契约(REPLY)可用"
check "$(printf '%s' "$src_out" | grep -q 'leak=0' && echo 1 || echo 0)" "F4-12. source 模式下清理后无敏感变量残留"
check "$(grep -q 'cleanup_secrets' "$TMP/trap.txt" 2>/dev/null && echo 1 || echo 0)" "F4-13. source 模式下 EXIT trap 已登记清理函数"
check "$(grep -q 'PHONE' "$SMOKE" && grep -q 'TOKEN' "$SMOKE" && grep -q 'AUTH' "$SMOKE" && grep -q 'COMPANY_TOKEN' "$SMOKE" && echo 1 || echo 0)" \
"F4-14. cleanup_secrets 覆盖 PHONE/TOKEN/AUTH/COMPANY_TOKEN"
# ══════════════════════════════════════════════════════════════
# Part 4 — F5:数据库密码不进入 mysql argv
# ══════════════════════════════════════════════════════════════
: > "$TMP/mysql-argv.log"; : > "$TMP/docker-argv.log"
_f5="$(run_sms_code true true ok)"
mysql_argv="$(cat "$TMP/mysql-argv.log" 2>/dev/null)"
docker_argv="$(cat "$TMP/docker-argv.log" 2>/dev/null)"
container_script="$(cat "$TMP/container-script.txt" 2>/dev/null)"
mysql_stdin="$(cat "$TMP/mysql-stdin.sql" 2>/dev/null)"
check "$(printf '%s' "$mysql_argv" | grep -qF "$TEST_DBPASS" && echo 0 || echo 1)" "F5-1. mysql argv 不含数据库密码值"
check "$(printf '%s' "$docker_argv" | grep -qF "$TEST_DBPASS" && echo 0 || echo 1)" "F5-2. 宿主机 docker 命令行不含数据库密码值"
check "$(printf '%s' "$container_script" | grep -qF "$TEST_DBPASS" && echo 0 || echo 1)" "F5-3. 容器侧脚本文本不含密码值"
check "$(printf '%s' "$container_script" | grep -q 'MYSQL_PASSWORD' && echo 1 || echo 0)" "F5-4. 容器侧脚本以变量名引用 MYSQL_PASSWORD"
check "$(printf '%s' "$mysql_argv" | grep -qE '(^| )-p' && echo 0 || echo 1)" "F5-5. mysql argv 不含 -p<密码>"
check "$(printf '%s' "$mysql_argv" | grep -q -- '--password' && echo 0 || echo 1)" "F5-6. mysql argv 不含 --password"
check "$(printf '%s' "$container_script" | grep -q 'MYSQL_ROOT_PASSWORD' && echo 0 || echo 1)" "F5-7. 不使用 root 凭据(无 MYSQL_ROOT_PASSWORD)"
check "$(printf '%s' "$container_script" | grep -q 'uroot' && echo 0 || echo 1)" "F5-8. 不使用 -uroot"
check "$(printf '%s' "$mysql_argv" | grep -q 'smoke_readonly' && echo 1 || echo 0)" "F5-9. 使用应用账号 MYSQL_USER"
check "$(printf '%s' "$mysql_argv" | grep -q 'mydreamjob' && echo 1 || echo 0)" "F5-10. 使用 MYSQL_DATABASE"
check "$(printf '%s' "$mysql_stdin" | grep -qi '^SELECT' && echo 1 || echo 0)" "F5-11. SQL 经 stdin 传入"
check "$(printf '%s' "$mysql_stdin" | grep -qiE 'INSERT|UPDATE|DELETE|DROP|ALTER' && echo 0 || echo 1)" "F5-12. 查询仅包含 SELECT(无写操作)"
check "$(printf '%s' "$mysql_argv" | grep -q 'MYSQL_PWD\|MYSQL_PASSWORD' && echo 0 || echo 1)" "F5-13. 密码不经 argv 传递"
check "$(printf '%s' "$container_script" | grep -q 'MYSQL_PWD' && echo 1 || echo 0)" "F5-14. 密码经 MYSQL_PWD 环境变量传递"
check "$(printf '%s' "$container_script" | grep -q 'MYSQL_PASSWORD' && grep -q 'exit 3' "$TMP/container-script.txt" && echo 1 || echo 0)" \
"F5-15. 凭据缺失时安全失败(不回退 root)"
check "$(printf '%s' "$_f5" | grep -q 'RESULT=ok' && echo 1 || echo 0)" "F5-16. argv 安全前提下取码仍可用"
# docker 不可用(容器不存在)时不泄露且安全失败
: > "$TMP/mysql-argv.log"
r="$(run_sms_code true true fail)"
check "$(printf '%s' "$r" | grep -q 'RESULT=fail' && echo 1 || echo 0)" "F5-17. 查询失败时安全失败"
check "$(printf '%s' "$r" | grep -q "$TEST_DBPASS" && echo 0 || echo 1)" "F5-18. 失败路径 stderr 不含数据库密码"
# ══════════════════════════════════════════════════════════════
# Part 5 — F1 静态保证 + §7 活跃文件文档门禁
# ══════════════════════════════════════════════════════════════
check "$(grep -qE 'grep[^|]*\.log|grep[^|]*stdout' "$SMOKE" && echo 0 || echo 1)" "13. smoke-test.sh 不 grep 应用日志"
check "$(grep -q 'MOCK SMS CODE' "$SMOKE" && echo 0 || echo 1)" "14. smoke-test.sh 中不存在 MOCK SMS CODE"
mech=1
grep -q 'unset REPLY' "$SMOKE" || mech=0
grep -q 'unset SMS' "$SMOKE" || mech=0
grep -q 'trap cleanup_secrets EXIT' "$SMOKE" || mech=0
grep -q 'unset LOGIN_BODY' "$SMOKE" || mech=0
check "$mech" "15. 存在 REPLY/SMS/LOGIN_BODY 清理与 EXIT trap"
check "$(grep -qE 'curl.*(-v|--trace|--trace-ascii)' "$SMOKE" && echo 0 || echo 1)" "20. 未使用 curl -v/--trace/--trace-ascii"
check "$(grep -q 'report_fields' "$SMOKE" && echo 1 || echo 0)" "21. 登录响应经 report_fields(不把响应全文交给打印函数)"
# §7:仅扫描**活跃操作文档与正式代码**,不把历史验收报告计入
ACTIVE_FILES="docs/acceptance-week1.md docs/api-v0.1.md docs/frontend-integration.md docs/offer-api.md docs/resume-api.md docs/smoke-test.sh README.md"
active_viol=0
for f in $ACTIVE_FILES; do
[ -f "$REPO_ROOT/$f" ] || continue
c=$(grep -c "验证码.*打印到日志" "$REPO_ROOT/$f" 2>/dev/null); c=${c:-0}
c2=$(grep -c "从日志获取验证码" "$REPO_ROOT/$f" 2>/dev/null); c2=${c2:-0}
c3=$(grep -c "MOCK SMS CODE" "$REPO_ROOT/$f" 2>/dev/null); c3=${c3:-0}
[ $((c + c2 + c3)) -ne 0 ] && active_viol=$((active_viol + 1))
done
check "$([ "$active_viol" -eq 0 ] && echo 1 || echo 0)" "22. 活跃操作文档不存在『从日志取码』有效指导"
src_viol=$(grep -rn "MOCK SMS CODE" "$REPO_ROOT/mydreamjob-server/src/main/java" 2>/dev/null | wc -l)
check "$([ "$src_viol" -eq 0 ] && echo 1 || echo 0)" "23. 正式 Java 代码中不存在 MOCK SMS CODE"
hist_ok=1
[ -f "$REPO_ROOT/docs/s01-independent-mysql-acceptance-20260911.md" ] || hist_ok=0
[ -f "$REPO_ROOT/docs/s01-remediation-f1f2f3-20260911.md" ] || hist_ok=0
check "$hist_ok" "24. 历史验收报告仍保留(作为历史失败证据,不参与活跃文档扫描)"
# ══════════════════════════════════════════════════════════════
# Part 6 — F6:SQL 字段契约(expired_at)与查询语义
# ══════════════════════════════════════════════════════════════
# 背景:expires_at 在本仓库中是 personal_data_export_tasks(V30)、
# ability_attempts(V35)、talent_invitations(V40) 三张表的**真实列**;
# 只有 sms_verification_codes 使用 expired_at。因此断言必须限定在该表的查询上,
# **不能**写成"全库不存在 expires_at"——那会误报。
#
# 真实列清单从**创建该表的 Flyway** 派生,不在测试里手工维护一份与生产无关的
# 假字段列表来自证正确。
FLYWAY="$REPO_ROOT/mydreamjob-server/src/main/resources/db/migration/V1__base_schema.sql"
real_cols="$(awk '
/^CREATE TABLE (IF NOT EXISTS )?sms_verification_codes/ { in_t=1; next }
in_t && /^\)/ { in_t=0 }
in_t {
line=$0
sub(/^[[:space:]]+/, "", line)
if (line ~ /^(INDEX|KEY|PRIMARY|CONSTRAINT|UNIQUE|FOREIGN)/) next
if (line ~ /^[a-z_]+[[:space:]]/) { split(line, a, /[[:space:]]/); print a[1] }
}
' "$FLYWAY" | sort -u | tr '\n' ' ')"
# 取一次真实查询文本(此调用未设置 FAKE_ROWS,走历史路径)
: > "$TMP/mysql-stdin.sql"
_f6="$(run_sms_code true true ok)"
sql_text="$(cat "$TMP/mysql-stdin.sql" 2>/dev/null)"
check "$(printf '%s' "$sql_text" | grep -q 'expired_at > NOW()' && echo 1 || echo 0)" \
"F6-1. otp_db_query 使用 expired_at > NOW()"
check "$(printf '%s' "$sql_text" | grep -q 'expires_at' && echo 0 || echo 1)" \
"F6-2. otp_db_query 不再出现错误字段 expires_at"
# 解析查询里使用的列名(去掉字符串字面量与 SQL 关键字)
used_cols="$(printf '%s' "$sql_text" \
| sed "s/'[^']*'//g" \
| grep -oE '\b[a-z_][a-z0-9_]*\b' \
| grep -vEx 'select|from|where|and|or|order|by|desc|asc|limit|now|sms_verification_codes' \
| sort -u)"
unknown_cols=""
for c in $used_cols; do
case " $real_cols " in
*" $c "*) ;;
*) unknown_cols="$unknown_cols $c" ;;
esac
done
check "$([ -z "$unknown_cols" ] && echo 1 || echo 0)" \
"F6-3. 查询只使用 sms_verification_codes 的真实列${unknown_cols:+(命中不存在的列:$unknown_cols)}"
# 防止"派生失败得到空清单"导致上一条恒真
check "$(printf '%s' "$real_cols" | grep -q 'expired_at' && echo 1 || echo 0)" \
"F6-4. 真实列清单由 Flyway 派生且包含 expired_at(非空、非自证)"
check "$(printf '%s' "$sql_text" | grep -qiE '^SELECT code FROM sms_verification_codes' && echo 1 || echo 0)" \
"F6-5. 假 mysql 捕获到完整 stdin SQL(不是无条件返回验证码)"
# 字段错误 → 假 mysql 必须失败(复现真实 MySQL 的 ERROR 1054)
printf '%s\n' "SELECT code FROM sms_verification_codes WHERE phone='x' AND expires_at > NOW();" \
| "$FAKEBIN/mysql" >/dev/null 2>"$TMP/err1054.txt" || rc1054=$?
rc1054="${rc1054:-0}"
check "$([ "$rc1054" -ne 0 ] && echo 1 || echo 0)" \
"F6-6. SQL 字段错误时假 mysql 返回失败(非 0)"
check "$(grep -q '1054' "$TMP/err1054.txt" 2>/dev/null && echo 1 || echo 0)" \
"F6-6b. 字段错误时报 ERROR 1054 (42S22)"
# 端到端:把字段改错的副本跑完整流程,验证不会继续登录
BUGDIR="$TMP/bugrepo"
mkdir -p "$BUGDIR/docs" "$BUGDIR/backend-infra"
cp "$REPO_ROOT/backend-infra/compose.yaml" "$BUGDIR/backend-infra/compose.yaml" 2>/dev/null || true
sed 's/expired_at > NOW()/expires_at > NOW()/' "$SMOKE" > "$BUGDIR/docs/smoke-test.sh"
out_bug="$(run_full_script "$BUGDIR/docs/smoke-test.sh")"
check "$(printf '%s' "$out_bug" | grep -q '\[FAIL\] login skipped' && echo 1 || echo 0)" \
"F6-7. 字段错误时 sms_code() 不继续登录"
check "$(grep -q 'login-attempted' "$TMP/flow.log" 2>/dev/null && echo 0 || echo 1)" \
"F6-7b. 字段错误时未发出任何 login 请求"
# 正确字段 → 取码成功并可完成登录
out_ok="$(run_full_script "$SMOKE")"
check "$(grep -q 'login-attempted has6digit=yes' "$TMP/flow.log" 2>/dev/null && echo 1 || echo 0)" \
"F6-8. 正确字段时取码成功并完成登录"
# ── 过期 / used / purpose / phone / 最新一条 语义 ──────────────
# 行格式 id|phone|purpose|used|expire_offset_s|created_at|code
P="$TEST_PHONE"
R_FUTURE="1|$P|login|0|300|2026-09-11 10:00:00|$TEST_CODE"
R_EXACT="1|$P|login|0|0|2026-09-11 10:00:00|$TEST_CODE"
R_PAST="1|$P|login|0|-300|2026-09-11 10:00:00|$TEST_CODE"
R_USED="1|$P|login|1|300|2026-09-11 10:00:00|$TEST_CODE"
R_PURPOSE="1|$P|register|0|300|2026-09-11 10:00:00|$TEST_CODE"
R_PHONE="1|19900001111|login|0|300|2026-09-11 10:00:00|$TEST_CODE"
R_OLD="1|$P|login|0|300|2026-09-11 10:00:00|111111"
R_NEW="2|$P|login|0|300|2026-09-11 11:00:00|222222"
r="$(run_sms_code_with_rows "$R_EXACT" "$TEST_CODE")"
check "$(printf '%s' "$r" | grep -q 'RESULT=fail' && echo 1 || echo 0)" \
"F6-9. expired_at 恰好等于 NOW() 时不得取出"
r="$(run_sms_code_with_rows "$R_FUTURE" "$TEST_CODE")"
check "$(printf '%s' "$r" | grep -q 'MATCH=yes' && echo 1 || echo 0)" \
"F6-10. expired_at 晚于 NOW() 时可以取出"
r="$(run_sms_code_with_rows "$R_PAST" "$TEST_CODE")"
check "$(printf '%s' "$r" | grep -q 'RESULT=fail' && echo 1 || echo 0)" \
"F6-11. expired_at 早于 NOW() 时不得取出"
r="$(run_sms_code_with_rows "$R_USED" "$TEST_CODE")"
check "$(printf '%s' "$r" | grep -q 'RESULT=fail' && echo 1 || echo 0)" \
"F6-12. used=1 不得取出"
r="$(run_sms_code_with_rows "$R_PURPOSE" "$TEST_CODE")"
check "$(printf '%s' "$r" | grep -q 'RESULT=fail' && echo 1 || echo 0)" \
"F6-13. purpose 不匹配不得取出"
r="$(run_sms_code_with_rows "$R_PHONE" "$TEST_CODE")"
check "$(printf '%s' "$r" | grep -q 'RESULT=fail' && echo 1 || echo 0)" \
"F6-14. phone 不匹配不得取出"
r="$(run_sms_code_with_rows "$(printf '%s\n%s' "$R_OLD" "$R_NEW")" "222222")"
check "$(printf '%s' "$r" | grep -q 'MATCH=yes' && echo 1 || echo 0)" \
"F6-15. 多条有效记录只取 created_at/id 最新一条"
# 逆序输入仍取同一条,证明按 ORDER BY 取值而非"最后一行胜出"
r="$(run_sms_code_with_rows "$(printf '%s\n%s' "$R_NEW" "$R_OLD")" "222222")"
check "$(printf '%s' "$r" | grep -q 'MATCH=yes' && echo 1 || echo 0)" \
"F6-15b. 与输入顺序无关,按 created_at/id 排序取值"
echo ""
echo "============================================"
echo "PASS=$PASS FAIL=$FAIL"
echo "============================================"
# 最终泄露扫描(测试自身输出也不得含标记)
if printf '%s' "$out_x$out_f$r$src_out" | grep -qF "$TEST_CODE"; then
echo "LEAK DETECTED: code"; exit 1
fi
if printf '%s' "$out_x$out_f$r$src_out" | grep -qF "$TEST_JWT"; then
echo "LEAK DETECTED: jwt"; exit 1
fi
if printf '%s' "$out_x$out_f$r$src_out" | grep -qF "$TEST_DBPASS"; then
echo "LEAK DETECTED: dbpass"; exit 1
fi
[ "$FAIL" -eq 0 ]

319
docs/v38-ability-score-integration-design.md

@ -0,0 +1,319 @@
# V38:四维能力测评有限接入候选人评分设计
> 状态:**已实现并通过真实 MySQL/Docker 关键黑盒验收,待建立 Git 检查点**。
> 功能版本:V38。
> 基线:Flyway `V1–V36`、OpenAPI `148 paths / 182 operations`、全量测试 `1427/1427`(`mvn clean verify`,0 Failures / 0 Errors / 0 Skipped)。
> 本设计只接入 V36 四维能力测评,不接入 V35 RIASEC 兴趣测评。
## 1. 目标与边界
### 1.1 目标
在不改变 V36 测评主链路的前提下,将学生**最新、完整、仍有效**的四维能力测评结果映射为候选人评分中的 `ABILITY` 维度,使候选评分卡可有限地反映能力测评结果。
### 1.2 必须满足的边界
- 仅使用 V36 `ability_results`,不使用 V35 兴趣类型或职业方向结果。
- 仅映射到 `ABILITY`;不写入或推断 `GROWTH`、`ACTIVITY` 等其他维度。
- 不在测评提交时直接写 `candidate_dimension_scores`;V36 提交仍只写其 `ability_*` 表和既有审计。
- 不新增企业查看测评详情的接口。企业仅通过既有候选评分卡看到 `ABILITY` 的聚合 `score/status`。
- 不向任何评分接口返回题目、答案键、选项分值、题库标签、题目解析或测评答卷 ID。
- 功能开关默认关闭;关闭时现有 `ABILITY=PENDING` 行为必须完全保持。
- 不引入 AI 评分、人工覆写、最高分挑选、自动封禁或对既有学生数据的回填。
## 2. 已确认的产品与规则口径
| 项目 | V38 锁定口径 |
|---|---|
| 可采纳来源 | V36 最新 `COMPLETED` 且 `valid_until > capturedNow` 的结果 |
| 四维权重 | `LOGIC=35%`、`EXECUTION=30%`、`LEARNING=20%`、`COMMUNICATION=15%` |
| 结果选择 | 只取最新有效完成结果;按 `completed_at DESC, attempt_id DESC` 稳定排序;绝不取历史最高分 |
| 有效期 | 复用 V36 的 7 天;过期结果仍可由学生查看历史,但不得参与新评分计算 |
| 重测治理 | 复用 V36:有效结果期间禁止重测,结果到期后可重测,滚动 90 天至多完成 3 次 |
| 企业可见性 | 仅既有公司评分卡的 `dimensions[]{dimension,score,status}`;无四维细分、短板、答卷或题目反馈 |
| 默认发布状态 | `OFF`,不影响候选综合分、V32 排名或 V34 求职进度 |
## 3. 总体方案:评分时只读投影
采用**评分时只读投影(read-through projection)**,而不是在 V36 提交后跨模块写入候选评分表。
```text
V36 完成测评
└─ 写 ability_attempts / ability_results(既有行为,不调用评分引擎)
学生或企业读取既有候选评分卡
└─ CandidateScoringCalculator
└─ AbilityAssessmentScoreSource(只读选择最新有效 ability_result)
└─ AbilityScoreMapper(按 V38 固定权重计算 ABILITY)
└─ 既有批量 upsert candidate_dimension_scores(既有评分快照机制)
```
这样做的原因:
1. V36 仍可独立工作,测评提交失败或评分开关关闭都不会影响其完成、结果和历史查询。
2. 评分只在既有评分查询/重算时产生快照,沿用当前 `candidate_dimension_scores` 的写入模型。
3. 不需要异步消息、定时回填或跨服务事务;结果到期后下一次评分自然变为 `PENDING`。
4. 评分行的 `sub_scores_json` 保留最小化、可追溯的来源版本信息,不需新建投影表。
## 4. 数据选择与有效性判定
### 4.1 单一查询契约
新增一个只读仓库查询,语义为:
```text
findLatestValidCompletedResult(studentUserId, typeCode, capturedNow)
```
查询必须同时满足:
- `ability_attempts.student_user_id = :studentUserId`;
- `ability_attempts.status = 'COMPLETED'`;
- `ability_results.valid_until > :capturedNow`;
- 测评定义为 V36 能力测评类型且当前/答卷题集版本可识别;
- 排序固定为 `completed_at DESC, ability_attempts.id DESC`;
- 最多取一条。
`capturedNow` 必须在一次评分计算开始时由后端生成并全程复用,不能在同一计算中多次调用 `now()`,以避免临界过期时出现不一致。
### 4.2 结果结构防御
即使查询到结果,也仅在以下条件全部成立时计算:
- `dimension_scores_json` 可被安全解析;
- 恰有 `LOGIC`、`COMMUNICATION`、`EXECUTION`、`LEARNING` 四维;
- 每维分数均在 `[0,100]`,且均为有限数值;
- 每维已答题数均为 6,答卷总已答题数为 24;
- `overall_score`、题集版本和 V36 规则版本存在。
> 实现细则(V38 发布前 P1):`answeredCount` 使用精确整数校验,仅接受数值意义上等于整数的表示
> (如 JSON 整数 6 或 6.0、`"6"`、`"6.0"`);`6.9`、`6.5`、`"6.5"`、溢出 int、空串、`null` 均视为不可用
> (`stripTrailingZeros` + `intValueExact`,不使用可能截断小数的 `intValue()`)。`attemptAnsweredCount`
> 必须非空且严格等于 24,不允许 `null` 放行。
任一条件不满足时,不抛给前端 500,也不使用部分数据;该学生本次 `ABILITY` 返回 `PENDING`,原因写为“无可用的完整能力测评结果”。内部日志仅记录结果 ID 与结构错误类型,禁止记录题目、答案或选项分值。
### 4.3 过期与重测
- 无有效结果、仅有过期结果、或最新结果格式无效:`ABILITY=PENDING`,`score=null`。
- 有效期内没有重测入口;到期后的新测评完成后,在下一次评分计算中自然替换为新结果。
- 结果选择不比较分数,因此不能通过“多次测到高分”挑选最高成绩。
- 已写入的旧月度快照保留为历史;当前月份按现有 upsert 语义在下一次评分查询/重算时刷新。
## 5. 分数映射
### 5.1 公式
设 V36 四维百分制结果为 `L`(逻辑)、`C`(沟通)、`E`(执行)、`R`(学习),则:
```text
abilityScore = roundHalfUp(L × 0.35 + E × 0.30 + R × 0.20 + C × 0.15, 2)
```
分数范围为 `0.00–100.00`。所有中间计算使用 `BigDecimal`;最终 `scale=2`、`HALF_UP`。不得复用 V36 等权 `overallScore` 直接作为候选 `ABILITY` 分数,因为 V38 已确认使用不同的、显式的业务权重。
### 5.2 维度结果
| 条件 | `ABILITY.status` | `ABILITY.score` | 对综合分的影响 |
|---|---:|---:|---|
| 开关关闭 | `PENDING` | `null` | 与 V37 前完全一致,不参与平均 |
| 无有效完整结果 | `PENDING` | `null` | 不参与平均 |
| 有效完整结果且开关开启 | `OK` | 映射后的 0–100 分 | 按既有候选评分规则参与可计算维度平均 |
| 来源读取异常/结构异常 | `PENDING` | `null` | 不参与平均,记录脱敏内部告警 |
候选综合分继续遵循现有规则:只对 `OK/ESTIMATED` 且分数非空的维度重新归一化计算。故 V38 启用后会对拥有有效能力结果的学生产生真实影响;这是功能开关默认关闭的原因。
### 5.3 版本
- 能力映射规则版本:`ability-assessment-scoring-v0.1`。
- 开关开启后的候选评分规则版本:`candidate-scoring-v0.2`。
- 开关关闭时保留现有 `candidate-scoring-v0.1`。
- 每个写入的 `ABILITY` 快照必须记录两类版本,保证规则升级后可解释、可重算。
## 6. 快照、溯源与数据最小化
不新增表、不新增 Flyway。复用 `candidate_dimension_scores` 现有的唯一键 `(student_user_id, dimension, period)` 与 `sub_scores_json`。
### 6.1 持久化的最小来源字段
`ABILITY` 行的内部 `sub_scores_json` 仅允许下列字段:
```json
{
"source": "V36_ABILITY_ASSESSMENT",
"sourceResultId": 123,
"sourceCompletedAt": "2026-09-04T10:00:00Z",
"validUntil": "2026-09-11T10:00:00Z",
"questionSetVersion": "ability-v0.1",
"assessmentRulesVersion": "ability-v0.1",
"mappingRulesVersion": "ability-assessment-scoring-v0.1",
"weights": {"LOGIC": 35, "EXECUTION": 30, "LEARNING": 20, "COMMUNICATION": 15},
"dimensionScores": {"LOGIC": 80.00, "EXECUTION": 75.00, "LEARNING": 70.00, "COMMUNICATION": 65.00}
}
```
禁止写入:答卷 ID、题目 ID、题目编码、题库标签、题干、选项、用户选择、答案键、选项分值、题目解析、手机号、邮箱、令牌、请求体和完整审计详情。
`sourceResultId` 仅作内部溯源,任何 HTTP 响应均不得返回该字段。
### 6.2 响应投影
- 学生综合卡:沿用既有 `ABILITY` 聚合分和状态。
- 学生单维详情:可返回四维**聚合分**、权重、有效期、规则版本和说明,用于解释自己的分数;不得返回 `sourceResultId` 或题目级内容。
- 企业候选评分卡:保持既有字段,只返回 `ABILITY` 的 `dimension/score/status`。
- 趋势接口:只使用已写候选快照,不回读题目或 V36 原始答案。
## 7. 功能开关与上线策略
新增配置:
```yaml
scoring:
ability-assessment:
enabled: false
```
| 模式 | 行为 |
|---|---|
| `false`(默认) | 不查询 V36 结果,`ABILITY=PENDING`;无评分行为变更 |
| `true` | 查询最新有效结果,按本设计映射;仅在既有评分计算/重算时写候选评分快照 |
发布顺序:
1. 合入代码且以 `false` 发布;验证所有既有候选评分卡仍是 V37 行为。
2. 在验收/小范围环境设为 `true`,核验映射、脱敏、排名影响与回滚。
3. 经负责人确认后才在正式环境启用;启用后仍可立即设回 `false`。
关闭开关只停止后续映射,不删除已存在的快照或 V36 数据。若需消除当前月份的已映射 `ABILITY`,必须走单独、经批准的重算流程,不能在开关切换时隐式删除数据。
## 8. 模块职责与实现边界
建议新增以下内部组件:
- `AbilityAssessmentScoreSource`:一次查询并解析最新有效 V36 结果;不暴露实体给 Controller。
- `AbilityScoreMapper`:纯函数,校验四维、计算权重分、构造最小化来源描述。
- `CandidateScoringCalculator#computeAbility(...)`:替换现有硬编码 `PENDING`;开关关闭时保留旧分支。
- `CandidateScoringService`:每次评分计算只调用一次 `AbilityAssessmentScoreSource`,不在八维循环内查询。
不得:
- 改动 `AbilityAttemptService.submit()` 的事务边界、状态机、重测限制或审计。
- 让评分模块更新 `ability_attempts`、`ability_answers`、`ability_results`。
- 在 Controller 接受 `studentUserId`、能力分或结果 ID 来影响评分。
- 新增企业端能力测评详情、管理员修改能力分或公开的“重算某学生能力分”接口。
## 9. 并发、一致性与性能
- 评分计算使用一次 `capturedNow` 和最多一次 V36 来源查询;不得按四维各查一次,也不得循环读取题目/答案。
- 已有候选评分批量 upsert 继续处理同月并发刷新;V38 不新增新的评分写表。
- V36 提交与评分查询并发时:评分要么读取提交前无结果并返回 `PENDING`,要么读取完整、已提交的结果并返回 `OK`;不能读取半成品。
- 结果刚好到期时以 `capturedNow` 判定;该次计算结束后不回滚同次快照,下一次计算按新时间重新判定。
- 利用既有 `ability_attempts(student_user_id, definition_id, completed_at)` 索引进行学生级最新结果查询;实现前必须用 MySQL `EXPLAIN` 验证不扫全表。若实际查询计划无法命中索引,停止编码并单独评审最小索引迁移,不得临时修改既有 V35 迁移。
## 10. 权限、隐私与审计
### 10.1 权限矩阵
| 数据 | STUDENT | COMPANY | ADMIN |
|---|---|---|---|
| 本人完整 V36 结果与题目反馈 | 仅本人 | 禁止 | 禁止(V36 原有口径) |
| 候选评分 `ABILITY` 聚合分 | 本人 | 仅有岗位归属的候选评分卡 | 禁止(沿用现有评分接口) |
| 四维聚合分与有效期 | 本人单维评分详情 | 禁止 | 禁止 |
| 题目/答案键/评分规则/标签 | 禁止通过评分接口返回 | 禁止 | 禁止 |
### 10.2 审计
V38 不对普通评分查询新增逐次审计,避免读取评分卡时产生审计噪声。现有评分治理申诉/回滚审计保持原样。
如内部结构无效需记录诊断日志,日志白名单仅为 `studentUserId`(内部日志)、`sourceResultId`、`failureKind`、`mappingRulesVersion` 和 `requestId`;禁止记录测评内容。不得将该诊断写入对企业可见的响应或 `audit_logs.details`。
## 11. 评分治理与申诉兼容
- 既有 `ABILITY` 申诉/评分回滚若可用,回滚仍然是按当前数据确定性重算,不恢复人工指定的旧能力分。
- V38 不新增人工修改、豁免、补分或上传证明材料的入口。
- 来源结果在回滚时已过期或无效,则重算为 `PENDING`;这是规则结果,不属于回滚失败。
- V36 测评本身的题目、答案或判分争议不通过候选评分申诉接口处理,后续需单独定义测评申诉治理。
## 12. 接口与 OpenAPI 影响
本版不新增、删除或修改公网路径;既有候选评分 API 的顶层契约保持兼容:
```text
GET /profile/me/candidate-scoring
GET /profile/me/candidate-scoring/dimensions/ABILITY
GET /profile/me/candidate-scoring/trend
GET /company/candidates/{applicationId}/scoring
```
因此静态和运行时 OpenAPI 均应继续为 **148 paths / 182 operations**。
学生的 `ABILITY` 单维详情可在既有通用 `subScores/reasons/dataSources` 容器中增加白名单字段;实现前须确认这些对象的现有 schema 允许兼容增加字段。若需要新增可见字段,必须同步 `openapi-rc.json`、`verify_openapi.py` 与联调文档,但不得改变公司端 schema。
## 13. 错误码
V38 不新增公网接口,正常的“无有效测评结果”属于 `ABILITY=PENDING`,不是 HTTP 错误。因此不新增 ErrorCode。
任何来源 JSON 异常、查询异常或规则版本无法识别都必须被隔离为 `PENDING`,并保留 200 的既有评分响应。数据库不可用等系统级故障仍按全局异常处理器既有语义处理,不伪造评分结果。
## 14. 测试计划
### 14.1 单元测试
- 开关关闭:不查询 V36 来源,`ABILITY=PENDING`,候选评分规则版本仍为 v0.1。
- 无结果、仅过期结果、结构不完整结果、分数越界结果:均为 `PENDING`,无 500。
- 有效结果:严格验证权重公式、两位小数 `HALF_UP`、`OK` 状态、来源版本。
- 多份有效历史:选 `completed_at` 最新者,不选总分最高者;同时间用 attempt ID 稳定决胜。
- 来源字段白名单:持久化 JSON 和 HTTP 响应均不含答卷 ID、题目、答案、答案键、分值、标签。
- `computeAbility` 只触发一次来源查询;不得产生 N+1。
### 14.2 集成与 MockMvc 测试
- H2 MySQL 模式下完整 V36 有效结果 → 候选评分写入 `ABILITY` 快照;过期后重算同周期 `ABILITY=PENDING`。
- V36 提交本身不写 `candidate_dimension_scores`;只有既有候选评分查询/重算写快照。
- 学生单维详情可见聚合四维和有效期;公司端只见 `ABILITY` 总分/状态;COMPANY/ADMIN/匿名权限回归。
- 并发 V36 提交与候选评分读取不出现半结果、500 或重复快照异常。
- V32 排名和 V34 职业进度在开关关闭时字节级/字段级保持现有结果;开关开启时仅允许因 `ABILITY` 可计算带来的预期分数变化。
### 14.3 MySQL/Docker 黑盒
1. 启动真实 MySQL/Redis/RabbitMQ 和最新 JAR;确认 health 200、Flyway 仍止于 V36、OpenAPI 148/182。
2. 使用独立学生账号完成一份 V36 测评,先验证提交后 `candidate_dimension_scores` 行数不变。
3. 开关 `false` 调用候选评分:`ABILITY=PENDING`;再在隔离环境将开关设为 `true`,调用评分:`ABILITY=OK` 且总分符合公式。
4. 核对 `sub_scores_json` 白名单;企业 API 不含四维、结果 ID、题目、答案、标签或评分规则。
5. 将结果有效期置为过去时间(只在独立验收数据上操作),重算后验证 `ABILITY=PENDING`;历史 V36 结果仍可由本人读取。
6. 创建两份符合 V36 重测规则的历史结果,验证取最新而不是最高;验证并发评分刷新无 500。
7. 队列最终 `ready=0/unacked=0`;回归 V32 排名、V34 职业进度、V35 RIASEC、V36 能力结果、V37 风险概览。
## 15. 回滚与冻结门槛
### 15.1 回滚
| 情况 | 操作 |
|---|---|
| 映射分数不符合预期 | 设 `scoring.ability-assessment.enabled=false`,停止后续接入 |
| 需要恢复当前月评分 | 经批准后调用既有评分重算流程;不直接删表或手改快照 |
| 发现题库/测评结果质量问题 | 保持 V36 历史,关闭 V38 开关;不篡改历史题目或答案 |
V38 不新增迁移,故不存在本模块 DDL 回滚。不得修改已执行的 V35/V36 Flyway 文件。
### 15.2 冻结条件
- 所有 V38 单元、集成、MockMvc 测试通过;全量 `mvn clean verify` 通过。
- `python docs/verify_openapi.py` 通过且 OpenAPI 仍为 148/182。
- 真实 MySQL/Docker 黑盒为 `FAIL=0`,关键项不得 SKIP。
- 开关关闭和开启两条路径均通过;V36 主链路、V32/V34/V35/V37 回归通过。
- 不泄露题目、答案、答案键、评分规则、标签、来源结果/答卷 ID;企业端仅有聚合能力分。
- 未对 V35/V36 题库或既有 Flyway 做回写;没有未授权的业务数据删除或全量回填。
---
## 16. 已确认的编码授权边界
负责人已于 2026-09-04 确认以下边界,编码必须严格遵守:
1. 开关开启时,允许 `ABILITY` 参与候选综合分,并允许其按既有评分口径影响 V32 同岗位排名。
2. 仅在开关开启时,候选评分规则版本允许由 `candidate-scoring-v0.1` 升级为 `candidate-scoring-v0.2`。
3. 学生单维评分详情可展示四维聚合分、权重和有效期;企业端始终只见 `ABILITY` 聚合分与状态。
4. 本版采用无新 Flyway、无新公网接口的只读投影方案。
后续若要新增人工改分、企业端明细、V35/RIASEC 接入、`GROWTH` 映射、历史回填或任何公开接口,必须另行设计和授权,不得作为 V38 编码的附带改动。

454
docs/v39-job-recommendation-design.md

@ -0,0 +1,454 @@
# V39:测评结果驱动岗位推荐设计
> 状态:**编码完成,真实 MySQL/Docker 关键黑盒已通过,待 Git 检查点。**
> 功能版本:V39。
> 当前基线:Flyway 迁移新增 `V37__job_recommendation_profiles.sql`;静态 OpenAPI 为 `154 paths / 190 operations`。
> 文档用途:V39 第一版规则、接口、安全边界与验收口径的权威设计说明。
## 1. 目标与边界
### 1.1 目标
为学生提供“可解释、可复现、可关闭”的岗位推荐列表。推荐只使用学生已授权产生的偏好、V35 RIASEC 职业兴趣结果和 V36 四维能力结果,并结合企业为岗位维护的结构化推荐画像。
推荐的含义是“当前岗位与学生已知偏好的匹配程度”,不保证录用概率,不构成职业、心理或就业诊断。
### 1.2 第一版范围
- 仅 STUDENT 查看自己的推荐岗位。
- 仅推荐状态为 `PUBLISHED` 且学生尚未投递的岗位。
- 使用学生偏好、最新完成的 RIASEC 结果、最新有效的四维能力结果和岗位推荐画像。
- 企业只能维护自己岗位的推荐画像;不能查询任何学生的测评明细、推荐分或推荐理由。
- 返回有限、白名单化的推荐理由和缺口提示。
- 规则实时计算;不保存学生级推荐历史、点击画像或行为画像。
### 1.3 明确不在本版范围
- 不使用 AI、大模型、简历文本向量或隐式行为画像。
- 不自动投递、不替学生收藏、不发送推荐通知。
- 不增加企业人才推荐或企业查看学生测评详情。
- 不接入 V38 之外的 `GROWTH` 评分维度,不写 `candidate_dimension_scores`。
- 不把 RIASEC 结果当作职业诊断,不根据测评结果拒绝学生投递。
- 实现“我不感兴趣”持久化,以及推荐解释的有帮助/无帮助反馈;首版不将反馈用于模型训练。
## 2. 现状核查结论
| 已有能力 | 当前状态 | 对 V39 的影响 |
|---|---|---|
| 岗位搜索 | `/jobs` 已支持城市、薪资、用工类型、关键词和分页 | 可复用公开岗位视图和发布状态口径 |
| 单岗位匹配诊断 | `GET /jobs/{jobId}/match`,按学生偏好计算 | 只适合单岗位说明;内部会写审计,不应在推荐列表中逐岗位循环调用 |
| 学生偏好 | 城市、薪资、用工类型、行业、岗位类型、远程意愿 | 可作为硬过滤和软匹配数据 |
| V35 RIASEC | 最新完成结果可提供六维兴趣聚合分 | 可作为兴趣匹配输入;不读取答案和题目 |
| V36 四维能力 | 最新有效完成结果可提供四维聚合分和有效期 | 可作为能力适配输入;不依赖 V38 开关 |
| V38 评分接入 | ABILITY 可有限进入候选评分 | V39 直接读 V36 聚合结果,不读取候选评分快照,避免推荐随评分开关改变 |
| `jobs` 表 | 只有标题、描述、要求、城市、薪资、用工类型 | 缺少可靠的 RIASEC/能力要求字段,必须新增结构化岗位推荐画像 |
## 3. 总体方案
### 3.1 方案选择
采用“**规则推荐 + 岗位结构化画像 + 实时只读计算**”方案。
```text
学生偏好 ─────────────┐
V35 RIASEC 聚合结果 ──┼─> RecommendationService ─> 推荐岗位列表 + 理由/缺口
V36 能力聚合结果 ─────┤ ↑
岗位推荐画像 ─────────┘ └─ 仅查询 PUBLISHED、未投递岗位
```
不能使用岗位标题、岗位描述或 `requirements` 进行关键词猜测来代替岗位画像:该做法不可解释、难以测试,也会把同义词、否定语句和描述质量差异误当成职业要求。
### 3.2 推荐画像的维护主体
企业在创建或维护岗位后,为本人岗位维护一份结构化推荐画像。画像只描述岗位,不包含学生数据;它包含:
- 行业代码与岗位族代码,用于与学生偏好精确比较;
- RIASEC 六维权重,用于表达该岗位偏好的兴趣侧重点;
- 四维能力期望值,用于表达完成该岗位所需的基础能力水平;
- 是否启用测评推荐。
企业画像是“岗位推荐参考”,不是招聘门槛。企业仍需通过原有岗位说明、投递、面试与 Offer 流程进行评估。
### 3.3 平台受控分类与审核流
行业代码和岗位族代码采用“**平台维护、企业选择、不得自由输入**”的两级受控字典:
```text
行业大类(industry)
└─ 岗位族(job family)
└─ 企业保留自由填写的岗位标题(jobs.title)
```
该选择参考了主流招聘产品按大类、细分职类组织职位浏览和筛选的做法:分类用于匹配与检索,岗位标题保留企业对具体职位的表达。第一版字典由产品负责人确定编码、后端随 Flyway 种子发布;ADMIN 只审核企业的岗位画像,不可在线任意新增分类。分类变更必须通过代码评审、版本化种子和兼容迁移完成。
建议首批受控岗位族覆盖当前产品最常见的求职方向:互联网技术、产品、设计、运营/市场、销售/客户成功、人力/行政/财务/法务、生产制造/工程、教育/医疗、供应链/物流、零售/生活服务和综合管理。每个岗位族必须隶属唯一行业大类,编码一经发布不得重用。
企业首次保存或修改已审核画像后,画像进入 `PENDING_REVIEW`;只有 `APPROVED` 且 `enabled=true` 的画像可用于个性化推荐。ADMIN 使用固定理由码审批或驳回。企业主动停用画像可立即生效,不需要等待审核。
### 3.4 时间一致性
一次推荐请求在服务入口生成唯一的 `capturedNow`,V36 结果有效性、规则版本和返回时间均使用该时间点。不得在同一请求内多次调用 `now()` 导致结果边界不一致。
## 4. 数据来源与有效性
| 来源 | 选择规则 | 无数据/异常处理 | 禁止读取 |
|---|---|---|---|
| 学生偏好 | 本人 `student_preferences`,至多一条 | 缺字段时该偏好项不计入分母 | 其他学生偏好 |
| RIASEC | `HOLLAND_RIASEC` 类型、`COMPLETED`、按完成时间倒序和答卷 ID 倒序取一条 | JSON 损坏、缺维、分数越界时兴趣信号不可用 | 原始答案、题目、questionCode |
| 四维能力 | `WORKPLACE_ABILITY`、`COMPLETED`、`valid_until > capturedNow`、按完成时间倒序和答卷 ID 倒序取一条 | 过期、缺维、不是 24/24 完成时能力信号不可用 | 原始答案、题目反馈、答案键、评分规则 |
| 岗位画像 | `job_recommendation_profiles.enabled=true`、`review_status=APPROVED` 且关联 `PUBLISHED` 岗位 | 无画像或待审核画像不宣称“测评匹配”;仅进入兜底列表 | 企业内部非公开数据 |
| 投递记录 | 当前学生与岗位的任何已有投递记录 | 已投递岗位从推荐候选中排除 | 其他学生投递数据 |
| 推荐反馈 | 本人对岗位的有效 `NOT_INTERESTED` 反馈 | 仅排除本人明确不感兴趣的岗位;理由反馈不改变排序 | 其他学生反馈 |
RIASEC 只使用六维聚合百分比;四维能力只使用 LOGIC、COMMUNICATION、EXECUTION、LEARNING 四个聚合分。两类结果均须通过服务端结构校验后才可参与排序。
## 5. 推荐候选、过滤与排序
### 5.1 硬过滤
推荐候选必须同时满足:
1. 岗位状态为 `PUBLISHED`;
2. 当前学生不存在该岗位的任何投递记录,避免推荐一个无法再次投递的岗位;
3. 若学生已设置期望城市,岗位城市必须命中;
4. 若学生已设置期望薪资下限且岗位有薪资上限,学生下限不得高于岗位上限;
5. 当前学生没有对该岗位提交有效的 `NOT_INTERESTED` 反馈;
6. 岗位推荐画像存在、已启用且审核状态为 `APPROVED` 时,才进入“个性化推荐”排序。
城市和薪资口径与现有单岗位匹配诊断保持一致。用工类型、行业和岗位族是软信号,不作为拒绝或隐藏条件。
### 5.2 三类软信号
| 信号 | 默认权重 | 计算方式 |
|---|---:|---|
| 偏好适配 `P` | 40% | 城市 30、用工类型 20、薪资 20、行业 15、岗位族 15;只对学生已设置且岗位有对应结构化值的字段纳入分母 |
| 兴趣适配 `I` | 35% | `Σ(RIASEC学生维度分 × 岗位该维权重) / Σ岗位兴趣权重` |
| 能力适配 `A` | 25% | 对每个已配置能力期望值:学生分数大于等于期望值记 100,否则 `学生分 / 期望分 × 100`;再取已配置维度平均值 |
总分公式:
```text
recommendationScore =
(P × 40 + I × 35 + A × 25) / 可用信号权重之和
```
结果保留两位小数,使用 `HALF_UP`。缺失或无效信号不记为 0,而是从分母中移除;这样不会因为学生尚未完成某项测评而被无理由降分。
如果偏好、兴趣、能力三者都不可用,则该岗位不能标为个性化推荐。
### 5.3 排序与兜底
个性化岗位按以下顺序排序:
1. `recommendationScore DESC`;
2. `published_at DESC`;
3. `job_id DESC`。
若没有任何可用个性化结果,接口返回 `mode=FALLBACK` 的公开岗位兜底列表,只按发布时间排序,`recommendationScore=null`,并明确说明“完善偏好或完成测评后可获得个性化推荐”。兜底列表绝不伪装为个性化结果。
### 5.4 理由与缺口
每个个性化岗位最多返回 3 条理由和 2 条建议,均由固定白名单模板生成,例如:
- `PREFERENCE_CITY_MATCH`:工作城市符合你的偏好;
- `PREFERENCE_SALARY_MATCH`:薪资范围满足你的期望;
- `RIASEC_INTEREST_MATCH`:职业兴趣与岗位侧重点较匹配;
- `ABILITY_STRENGTH_MATCH`:你的能力表现满足该岗位的主要侧重点;
- `ABILITY_GAP`:可优先提升与岗位相关的某项能力;
- `PREFERENCE_GAP`:用工类型或岗位族与当前偏好存在差异。
不返回 RIASEC 原始六维向量、能力答卷 ID、结果 ID、题目、答案、答案键、评分规则或企业配置原文。
## 6. 数据模型与 Flyway(建议,待确认)
### 6.1 迁移
当前最新迁移为 V36;建议新增:
```text
V37__job_recommendation_profiles.sql
```
功能版本仍为 V39。该单一迁移创建岗位画像、受控分类和学生反馈三张相关表,不修改 V1–V36 的语义。
### 6.2 `job_recommendation_taxonomies`
平台受控字典表,随 `job-taxonomy-v0.1` 种子初始化。
| 字段 | 说明 |
|---|---|
| `code` | 不可重用的稳定编码,主键 |
| `parent_code` | 岗位族指向所属行业大类;行业大类为 `NULL` |
| `taxonomy_type` | `INDUSTRY` 或 `JOB_FAMILY` |
| `display_name` | 前端展示名 |
| `active` | 是否仍允许企业新选用 |
| `sort_order`、`catalog_version` | 展示顺序与目录版本 |
服务端必须校验 `industry_code` 是有效行业大类、`job_family_code` 是该行业下有效岗位族;企业不得传任意文本或跨层级组合。
### 6.3 `job_recommendation_profiles`
| 字段 | 说明 |
|---|---|
| `id` | 主键 |
| `job_id` | 外键至 `jobs.id`,唯一,一岗一份画像 |
| `enabled` | 是否参与个性化推荐,默认 `false` |
| `review_status` | `PENDING_REVIEW`、`APPROVED`、`REJECTED`、`DISABLED` |
| `industry_code` | 必填、受控行业大类编码 |
| `job_family_code` | 必填、受控岗位族编码 |
| `riasec_r` 至 `riasec_c` | 六维兴趣权重,`0..100`,至少一个非零才可启用兴趣信号 |
| `logic_requirement` 等四列 | 四维能力期望值,`0..100`;`NULL` 表示该维不参与能力适配 |
| `reviewed_by_user_id`、`reviewed_at` | 最近一次审核人和审核时间,仅内部使用 |
| `review_reason_code` | 审核/驳回的固定理由码,不存自由文本 |
| `profile_version` | 乐观锁版本 |
| `created_at`、`updated_at` | 审计时间 |
约束与索引:
- `UNIQUE(job_id)`;
- `FOREIGN KEY(job_id) REFERENCES jobs(id)`;
- 所有权重和能力期望必须在 `0..100`;
- 建立 `(enabled, review_status, job_id)` 索引,支撑可推荐画像的候选岗位查询;
- 使用 JPA `@Version` 和 `expectedVersion` 防止企业并发覆盖配置。
### 6.4 `student_job_recommendation_feedback`
仅保存学生主动提交的最小化反馈,不保存自动点击画像。
| 字段 | 说明 |
|---|---|
| `id` | 主键 |
| `student_user_id`、`job_id` | 反馈所属学生和岗位 |
| `feedback_type` | `NOT_INTERESTED`、`REASON_HELPFUL`、`REASON_NOT_HELPFUL` |
| `reason_code` | 可选固定理由码;不得提交自由文本、测评内容或个人信息 |
| `feedback_key` | `student + job + type + reasonCode` 组成的非空去重键 |
| `status` | `ACTIVE` 或 `REVOKED`;撤销不物理删除 |
| `idempotency_key` | 同一学生请求幂等键唯一;同键异体返回冲突 |
| `created_at`、`updated_at`、`version` | 审计时间与乐观锁 |
`NOT_INTERESTED` 有效时排除该岗位;理由是否“有帮助”只用于后续人工优化规则,不自动改变学生分、岗位分或排序公式。
## 7. API 设计(建议,待确认)
### 7.1 学生推荐列表
```http
GET /profile/me/job-recommendations?page=0&size=20
```
权限:仅 STUDENT 本人。`page` 从 0 开始,`size` 范围 1–50。
响应核心字段:
```json
{
"mode": "PERSONALIZED",
"rulesVersion": "job-recommendation-v0.1",
"items": [
{
"job": { "id": 101, "title": "后端开发工程师", "city": "合肥" },
"recommendationScore": 82.50,
"reasons": ["PREFERENCE_CITY_MATCH", "RIASEC_INTEREST_MATCH"],
"suggestions": ["ABILITY_GAP"],
"availableSignals": ["PREFERENCE", "RIASEC", "ABILITY"]
}
],
"page": 0,
"size": 20,
"total": 1,
"totalPages": 1,
"generatedAt": "2026-09-04T12:00:00"
}
```
岗位详情继续复用既有 `GET /jobs/{jobId}`;如需查看单个岗位的偏好诊断,继续复用既有 `GET /jobs/{jobId}/match`。
### 7.2 企业岗位画像查询与更新
```http
GET /companies/me/jobs/{jobId}/recommendation-profile
PUT /companies/me/jobs/{jobId}/recommendation-profile
```
仅岗位所属 COMPANY 可查询和更新。更新请求需带 `expectedVersion`,并仅接受固定字段:`enabled`、`industryCode`、`jobFamilyCode`、`riasecWeights`、`abilityRequirements`。新建/更新后进入 `PENDING_REVIEW`;只有企业主动 `enabled=false` 时可转为 `DISABLED`。
不提供 DELETE;企业通过 `enabled=false` 停用画像,保留配置和审计链路。
### 7.3 受控分类、学生反馈与 ADMIN 审核
```http
GET /job-recommendation-taxonomies
POST /profile/me/job-recommendations/{jobId}/feedback
DELETE /profile/me/job-recommendations/{jobId}/feedback
GET /admin/job-recommendation-profiles?page=0&size=20&status=PENDING_REVIEW
POST /admin/job-recommendation-profiles/{jobId}/decision
```
分类接口只返回 `code`、`parentCode`、`type`、`displayName`、`sortOrder` 和 `catalogVersion`;无需登录即可让岗位发布和岗位搜索复用同一字典。
反馈创建必须携带 `idempotencyKey`。`NOT_INTERESTED` 的撤销只把对应记录改为 `REVOKED`;不删除审计记录。ADMIN 审核请求只接受 `APPROVE` 或 `REJECT` 与固定 `reasonCode`,不接受自由文本。
### 7.4 OpenAPI 预估
新增 6 个路径、8 个 operation:
```text
148 paths / 182 operations → 154 paths / 190 operations
```
编码时必须以运行时 OpenAPI 为准更新 `docs/openapi-rc.json` 与 `docs/verify_openapi.py`,不允许仅修改静态计数。
## 8. 权限、隐私与审计
| 操作 | STUDENT | COMPANY | ADMIN | 未登录 |
|---|---:|---:|---:|---:|
| 查看本人推荐列表 | 200 | 403/2002 | 403/2002 | 401 |
| 提交/撤销本人推荐反馈 | 200 | 403/2002 | 403/2002 | 401 |
| 查看本人岗位匹配诊断 | 既有行为 | 既有行为 | 既有行为 | 既有行为 |
| 查看自己岗位画像 | 403/2002 | 仅所属岗位 200 | 403/2002 | 401 |
| 更新自己岗位画像 | 403/2002 | 仅所属岗位 200,进入待审核 | 403/2002 | 401 |
| 查询受控分类 | 200 | 200 | 200 | 200 |
| 查看待审核岗位画像 | 403/2002 | 403/2002 | 200 | 401 |
| 审批岗位画像 | 403/2002 | 403/2002 | 200 | 401 |
审计动作建议:
- `JOB_RECOMMENDATION_PROFILE_UPDATED`:仅记录 `jobId`、画像版本、是否启用、请求 ID 和时间;
- `JOB_RECOMMENDATION_PROFILE_DECIDED`:仅记录 `jobId`、决定、固定理由码、画像版本、请求 ID 和时间;
- `JOB_RECOMMENDATION_FEEDBACK_CREATED` / `JOB_RECOMMENDATION_FEEDBACK_REVOKED`:仅记录 `jobId`、反馈类型、固定理由码、请求 ID 和时间;
- `JOB_RECOMMENDATIONS_VIEWED`:可选,仅记录返回数量、规则版本和请求 ID;不得记录完整排序、测评分、题目、答案、手机、邮箱、IP、User-Agent 或 token。
公司端绝不获得学生的 RIASEC、四维能力、候选评分、推荐分、推荐理由或缺口。学生端也不获得企业配置的完整权重和能力期望值。
## 9. 模块边界、性能与并发
### 9.1 建议模块划分
- `JobRecommendationProfile` / Repository:岗位画像配置;
- `JobRecommendationTaxonomy` / Repository:平台受控行业与岗位族字典;
- `StudentJobRecommendationFeedback` / Repository:学生显式反馈及撤销状态;
- `JobRecommendationProfileService`:企业画像读取、所有权校验和乐观锁更新;
- `JobRecommendationAdminService`:待审核画像分页和固定决定流转;
- `StudentRecommendationSource`:一次读取学生偏好、最新有效 V35/V36 聚合结果;
- `JobRecommendationCalculator`:纯函数,执行结构校验、分数公式和理由模板;
- `JobRecommendationService`:过滤、分页、排序和响应白名单;
- `JobRecommendationController`:仅从 `SecurityContext` 获取当前用户 ID。
### 9.2 N+1 防护
推荐列表不得对每个岗位调用现有 `MatchService.diagnose()`:该方法会逐岗位读数据并写审计,既会造成 N+1,也会污染审计。
推荐请求的目标查询上限为固定次数:
1. 学生偏好一次;
2. 最新 RIASEC 结果一次;
3. 最新有效四维能力结果一次;
4. 分页候选岗位及画像一次;
5. 当前学生已投递岗位 ID 集合一次;
6. 当前学生有效 `NOT_INTERESTED` 岗位 ID 集合一次;
7. 页面企业信息批量加载一次。
岗位排序应使用单条参数化 SQL/JPA 查询按推荐表达式完成,或在有明确候选上限时使用固定批量加载;禁止“每岗位再查画像、公司、投递或测评”的循环查询。编码后必须用 Hibernate/JDBC Spy 断言查询次数不随页面 items 数线性增加。
### 9.3 并发
- 推荐查询只读、无幂等键要求;
- 企业更新画像、学生反馈撤销和 ADMIN 审核均使用 `expectedVersion` + `@Version`,冲突返回 409;
- 岗位关闭、画像停用或学生完成投递后,下一次查询自然不再返回该岗位;不缓存旧结果。
## 10. 错误码(建议)
建议使用新段 `10001–10009`:
| 错误码 | HTTP | 含义 |
|---|---:|---|
| `10001` | 404 | `JOB_RECOMMENDATION_PROFILE_NOT_FOUND` |
| `10002` | 400 | `JOB_RECOMMENDATION_PROFILE_INVALID` |
| `10003` | 409 | `JOB_RECOMMENDATION_PROFILE_CONCURRENT_UPDATE` |
| `10004` | 400 | `JOB_RECOMMENDATION_INVALID_QUERY` |
| `10005` | 400 | `JOB_RECOMMENDATION_TAXONOMY_INVALID` |
| `10006` | 409 | `JOB_RECOMMENDATION_FEEDBACK_CONCURRENT_UPDATE` |
| `10007` | 409 | `JOB_RECOMMENDATION_FEEDBACK_IDEMPOTENCY_CONFLICT` |
| `10008` | 404 | `JOB_RECOMMENDATION_FEEDBACK_NOT_FOUND` |
| `10009` | 409 | `JOB_RECOMMENDATION_PROFILE_INVALID_REVIEW_STATE` |
现有岗位不存在、岗位归属不匹配、未登录和角色越权继续使用既有错误码,不重复造码。
## 11. 功能开关、兼容与回滚
建议新增默认关闭的开关:
```yaml
recommendation:
assessment:
enabled: ${RECOMMENDATION_ASSESSMENT_ENABLED:false}
```
开关关闭时,学生推荐接口只返回 `FALLBACK` 公开岗位列表,不查询测评结果、不生成个性化分;企业仍可维护岗位画像,为灰度开启做准备。
开关开启时规则版本为 `job-recommendation-v0.1`。回滚只需关闭开关;画像数据保留,不删除岗位、测评、投递、评分或审计数据。
V39 不改变:
- V35/V36 的题库、答题、重测、有效期和审计语义;
- V38 的候选评分开关、ABILITY 快照和 V32 排名口径;
- 既有 `/jobs` 搜索、`/jobs/{jobId}/match`、投递和收藏接口语义。
## 12. 测试与验收计划
### 12.1 单元测试
- RIASEC 与四维能力来源选择:最新优先、过期回退、结构异常不抛 500;
- 公式:单信号、双信号、三信号、缺失重归一化、`HALF_UP`、稳定排序;
- 硬过滤:未发布、已投递、城市不符、薪资不符;
- 理由/建议白名单和最大数量;
- 受控分类父子校验、画像字段边界、启用条件和审核状态机;
- 反馈幂等、撤销、不感兴趣过滤和理由反馈不改变推荐分;
- 画像、反馈和审核的乐观锁冲突。
### 12.2 MockMvc 与持久化测试
- STUDENT/COMPANY/ADMIN/未登录权限矩阵;
- 企业只能读写自己的岗位画像,更新后必须进入待审核状态;
- ADMIN 只能审核、不可修改企业画像内容;非 ADMIN 审核返回 403;
- 分类接口仅返回受控字典;自由行业/岗位族编码返回 400;
- 推荐响应不含测评题目、答案、答案键、结果 ID、原始画像权重;
- 无画像/无测评返回 `FALLBACK`,不伪造个性化分;
- 分页参数、空列表、推荐总数和稳定排序;
- 查询次数固定,禁止推荐列表 N+1;
- 不写 `candidate_dimension_scores`、不改变 V32 排名。
### 12.3 MySQL/Docker 黑盒
1. 执行 V37 迁移,校验三张表、唯一约束、外键、范围约束、分类种子和索引;
2. 企业创建/更新自己岗位画像,其他企业返回 403,ADMIN 审批后才可用于推荐;
3. 学生完成 RIASEC 和能力测评后,推荐分与理由符合固定 fixture;
4. 验证历史高分、最新低分和能力过期三种选择口径;
5. 开关关闭返回 `FALLBACK`,开启后才产生 `PERSONALIZED`;
6. 已投递、明确不感兴趣、关闭、城市/薪资不符岗位不会进入个性化列表;
7. 反馈重复提交、同键重放、撤销重试和审核并发均满足预期业务码;
8. 响应和审计不泄露题目、答案、评分规则、结果 ID 或凭据;
9. RabbitMQ 队列终态正常,并回归 V34–V38 关键接口。
冻结条件:全量 `mvn clean verify`、运行时/静态 OpenAPI 对账、真实 MySQL/Docker 黑盒关键项全部 PASS,且 FAIL=0。
## 13. 已确认决策与编码前最后口径
负责人已确认:
1. 新增岗位推荐画像与 Flyway `V37__job_recommendation_profiles.sql`;
2. 接受默认排序权重:偏好 40%、RIASEC 35%、能力 25%;
3. 无结构化岗位画像时仅返回 `FALLBACK`,不伪造测评推荐;
4. 任何已有投递的岗位均不再推荐;
5. 默认关闭 `recommendation.assessment.enabled`,支持灰度;
6. 第一版加入学生“不感兴趣”持久化和推荐理由反馈;
7. 按负责人第 8 项“是”处理:第一版需要 ADMIN 审核企业岗位画像。
基于第 6、7 项,原先的 3 个 operation 扩展为 8 个 operation,且迁移包含三个相关表。这是已确认产品范围变化,不是无关扩张。
编码开始前只需最终确认以下两项具体口径:
1. 受控分类采用“平台种子维护、企业只能选择、不允许自由输入”,并按 `job-taxonomy-v0.1` 首批目录落库;
2. ADMIN 审核语义确为“企业提交/更新画像后进入 `PENDING_REVIEW`,审核通过才参与推荐”。
若以上两项确认,即可进入 V39 编码;在此之前不得创建迁移、Controller 或前端接口实现。

357
docs/v40-company-review-management-design.md

@ -0,0 +1,357 @@
# V40 企业评价管理与官方回复:详细设计
> 状态:实现已完成,专项/全量自动化测试、`mvn clean verify` 与真实 MySQL/Docker 黑盒均已通过;待建立 Git 检查点。
>
> 本文定义并约束 V40 的后端交付边界。实现不会修改已有 V17/V18 的学生评价、管理员评价审核与防刷语义;不会新增测评、评分、推荐接口。
## 1. 目标、范围与现状
### 1.1 交付目标
让已认证企业能够:
1. 查看**自己名下岗位**的评价及处理状态;
2. 对每条评价提交一条官方回复;
3. 在回复被拦截或管理员驳回后,查看安全的处理状态并修订后重新提交;
4. 让公众仅在评价和官方回复都满足公开条件时看到该回复。
企业的“管理”在 V40 中是查看、筛选、回复和查看回复审核状态;**不是**删除、隐藏、改写或自行下架学生评价。企业针对评价真实性的申诉、投诉工单、运营统计和通知策略不纳入本版,留给后续独立模块。
### 1.2 已有能力与约束
| 项目 | 当前事实 | V40 处理 |
| --- | --- | --- |
| 学生岗位评价 | `job_reviews`,同一学生对同一岗位唯一;已有评分、标签、八维评分 | 复用,不重建 |
| 评价审核 | V18:`AUTO_APPROVED`、`PENDING`、`APPROVED`、`REJECTED`,管理员已有评价审核接口 | 保持原链路;新增“回复审核”并与评价审核分开 |
| 企业权限 | 角色为 `COMPANY`;岗位归属可按公司 owner 与岗位 companyId 校验 | 所有企业接口均由 JWT 身份和岗位归属决定 |
| 审计 | 已有 `audit_logs` | 对回复生命周期写白名单审计 |
| 当前迁移 | 最新为 `V37__job_recommendation_profiles.sql` | V40 若编码,新增 `V38__company_review_replies.sql` |
| 当前 OpenAPI 基线 | V39 后为 154 paths / 190 operations | 预计增至 159 paths / 196 operations,编码后实测为准 |
### 1.3 明确不做
- 不新增学生写评价、改评价、删评价接口;
- 不让企业删除、隐藏、编辑评分或审核学生评价;
- 不把评价人 `studentUserId`、手机号、邮箱、IP、User-Agent、内容哈希、风控分或内部原因返回给企业;
- 不新增企业查看测评、候选人隐私数据或候选评分明细的能力;
- 不接入 AI 自动生成回复,不声称现有确定性规则为 AI;
- 不做评价申诉/争议工单、回复点赞、企业客服工单、通知推送或数据看板;
- 不改 V35–V39 迁移及语义,不写 `candidate_dimension_scores`。
## 2. 产品规则与关键决策
### 2.1 不可变规则
1. 评价的作者始终是学生;企业对评价没有写、删、隐藏权限。
2. 企业只能操作其本人拥有公司下的岗位评价;跨公司访问统一返回 `403/2002`,不通过“是否存在”泄露其他公司数据。
3. 每条评价最多存在一条当前官方回复,且回复归属由评价所在岗位的公司确定,客户端不得传 `companyId`、`jobId` 或评价作者 ID 来决定归属。
4. 一条公开评价只在官方回复为 `AUTO_APPROVED` 或 `APPROVED` 时返回 `officialReply`。`PENDING`、`REJECTED` 及内部审核信息绝不出现在公开接口。
5. 企业不可删除官方回复。被 `APPROVED` 或 `AUTO_APPROVED` 的回复不可编辑;被 `REJECTED` 的回复可使用乐观锁修订并重新进入预审。
### 2.2 回复状态机
```text
无回复
└─ 企业创建 ─→ AUTO_APPROVED ───────────────→ 公众可见(终态)
或
PENDING ── 管理员同意 ───────→ APPROVED → 公众可见(终态)
└─ 管理员驳回 ─────→ REJECTED
└─ 企业修订并重提 ─→ AUTO_APPROVED / PENDING
```
- 预审无风险:`AUTO_APPROVED`,可随公开评价直接展示。
- 命中敏感词、联系方式、URL、异常重复字符等已有确定性规则:`PENDING`,仅管理员可审核。
- `PENDING` 只能由 `ADMIN` 变更为 `APPROVED` 或 `REJECTED`。
- `REJECTED` 的公司可提交修订版本;版本号递增、审核痕迹保留。
- `AUTO_APPROVED` 与 `APPROVED` 为对外稳定文本。若未来需要更正,应另做“公开回复修订/版本化”需求,不能在 V40 中通过覆盖原文实现。
### 2.3 公开身份脱敏(V40 必做修正)
现有 `JobReviewService.toView(...)` 会在公开岗位评价载荷中输出 `studentUserId`。该字段会使企业或任意访客将评价与学生内部主键关联,不满足“评价对对方脱敏”的边界。
V40 编码必须将公开评价 DTO 改为白名单响应,移除 `studentUserId`,使用固定的 `reviewerLabel: "匿名求职者"`(或不返回该字段)。学生本人视图可保留“是否为我创建”的布尔信息,但不得把任何学生 ID 交给企业、公众或其他学生。此项是对已有公开契约的隐私修复;前端必须在联调时同步移除对 `studentUserId` 的依赖。
## 3. 数据模型与 Flyway
### 3.1 迁移
新增 `V38__company_review_replies.sql`。不改已执行的 V1–V37,也不通过 `ddl-auto` 补表。
### 3.2 `company_review_replies`
| 列 | 类型/约束 | 说明 |
| --- | --- | --- |
| `id` | BIGINT PK | 回复 ID |
| `review_id` | BIGINT NOT NULL, UNIQUE, FK → `job_reviews(id)` ON DELETE CASCADE | 一条评价仅一个当前官方回复 |
| `company_id` | BIGINT NOT NULL, FK → `companies(id)` | 由服务端从岗位归属推导 |
| `content` | VARCHAR(500) NOT NULL | 官方回复正文,1–500 字符 |
| `moderation_status` | VARCHAR(20) NOT NULL | `AUTO_APPROVED` / `PENDING` / `APPROVED` / `REJECTED` |
| `moderation_reason` | VARCHAR(500) NULL | 内部安全原因码 JSON;不公开给企业或公众 |
| `moderation_score` | DECIMAL(5,2) NULL | 内部风控分,不公开 |
| `risk_level` | VARCHAR(20) NULL | 内部风险等级,不公开 |
| `reviewed_by_user_id` | BIGINT NULL FK → `users(id)` | 管理员审核人 |
| `reviewed_at` | DATETIME NULL | 最后一次管理员审核时间 |
| `version` | BIGINT NOT NULL DEFAULT 0 | JPA `@Version`;企业修订携带 `expectedVersion` |
| `created_at` / `updated_at` | DATETIME | 审计时间 |
约束与索引:
- `UNIQUE(review_id)`;
- `CHECK(moderation_status IN ('AUTO_APPROVED','PENDING','APPROVED','REJECTED'))`;
- `INDEX(company_id, moderation_status, updated_at)` 支撑企业列表;
- `INDEX(moderation_status, created_at)` 支撑管理员待审列表。
### 3.3 `company_review_reply_history`
保留回复生命周期而非覆盖历史。
| 列 | 类型/约束 | 说明 |
| --- | --- | --- |
| `id` | BIGINT PK | 历史记录 ID |
| `reply_id` | BIGINT NOT NULL FK → reply ON DELETE CASCADE | 对应回复 |
| `action` | VARCHAR(30) NOT NULL | `CREATED`、`AUTO_APPROVED`、`PENDING`、`RESUBMITTED`、`APPROVED`、`REJECTED` |
| `actor_user_id` | BIGINT NULL | 操作者;管理端可审计,企业/公众不返回 |
| `reason_codes` | VARCHAR(500) NULL | 安全原因码,不含敏感词原文 |
| `human_reason` | VARCHAR(500) NULL | 管理员驳回说明,仅向该公司展示,禁止包含评价人身份信息 |
| `created_at` | DATETIME NOT NULL | 时间 |
索引:`INDEX(reply_id, created_at)`。历史表不保存正文副本、IP、UA、学生 ID、题目或候选人数据。
### 3.4 审计日志
写入既有 `audit_logs`,详情严格白名单:
```json
{
"reviewId": 120,
"replyId": 42,
"jobId": 9,
"status": "PENDING",
"requestId": "..."
}
```
动作:`COMPANY_REVIEW_REPLY_CREATED`、`COMPANY_REVIEW_REPLY_RESUBMITTED`、`REVIEW_REPLY_AUTO_APPROVED`、`REVIEW_REPLY_APPROVED`、`REVIEW_REPLY_REJECTED`。详情不得包含正文、评价作者、手机号、邮箱、IP、UA、哈希、token 或审核规则原文。
## 4. 接口契约
所有身份均从 `SecurityContext` / JWT subject 获取,不接收 `userId`、`companyId`、`studentUserId` 等身份参数。统一响应为既有 `ApiResponse{code,message,data,requestId,timestamp}`;分页 `page` 从 0 开始、`size` 范围 1–50。
### 4.1 企业端(仅 `COMPANY`)
| 方法 | 路径 | 说明 |
| --- | --- | --- |
| GET | `/companies/me/job-reviews` | 分页查看本人公司岗位评价;支持 `jobId`、`rating`、`replyStatus`、`createdFrom`、`createdTo` 筛选 |
| GET | `/companies/me/job-reviews/{reviewId}` | 查看一条本人公司岗位评价及本人官方回复状态 |
| POST | `/companies/me/job-reviews/{reviewId}/reply` | 创建唯一官方回复;请求需 `content`、`idempotencyKey` |
| PUT | `/companies/me/job-reviews/{reviewId}/reply` | 仅允许修订 `REJECTED` 回复;请求需 `content`、`expectedVersion` |
企业列表/详情最小响应:
```json
{
"reviewId": 120,
"job": { "id": 9, "title": "后端工程师" },
"rating": 4,
"content": "岗位描述较清晰。",
"tags": ["成长空间"],
"dimensionScores": { "SALARY": 4, "WORK_LIFE_BALANCE": 3 },
"reviewCreatedAt": "2026-09-04T10:00:00",
"reply": {
"id": 42,
"content": "感谢反馈,我们会继续优化沟通。",
"status": "PENDING",
"version": 0,
"updatedAt": "2026-09-04T10:05:00",
"rejectionReason": null
}
}
```
禁止返回:`studentUserId`、评价作者显示名、手机号、邮箱、IP、UA、内容哈希、审核分、评价审核内部原因、回复预审内部原因、管理员 ID。
`idempotencyKey` 必须为 UUID、最多 64 字符。同键同评价同正文返回已有回复;同键不同评价或正文返回 `409/10106`。创建时已有回复:`PENDING` / `AUTO_APPROVED` / `APPROVED` 返回 `409/10102`;`REJECTED` 必须走 PUT 重提。
### 4.2 管理员端(仅 `ADMIN`)
| 方法 | 路径 | 说明 |
| --- | --- | --- |
| GET | `/admin/review-replies/pending` | 分页查看 `PENDING` 官方回复;可按 `companyId`、`jobId`、`riskLevel`、时间筛选 |
| POST | `/admin/review-replies/{replyId}/decision` | `APPROVE` / `REJECT`;仅 `PENDING` 可决策;驳回时 `reason` 必填,1–500 字符 |
管理员响应可看安全原因码、风险等级、岗位/公司信息与回复正文,用于审核;仍不得返回评价学生的联系电话、邮箱、IP/UA 明文或密码/JWT。管理员决策重复提交或非 `PENDING` 状态返回冲突。
### 4.3 现有公开评价接口的兼容扩展
既有以下接口路径不新增操作:
- `GET /jobs/{jobId}/reviews`
- `GET /jobs/{jobId}/reviews/{reviewId}`
每条公开评价可新增可选 `officialReply`:
```json
{
"content": "感谢反馈。",
"repliedAt": "2026-09-04T10:05:00"
}
```
仅当评价本身为 `PUBLISHED` 且评价审核状态为 `AUTO_APPROVED` / `APPROVED`,并且回复为 `AUTO_APPROVED` / `APPROVED` 时出现。该对象没有 `replyId`、`companyId`、审核人、状态、原因码和版本字段。无公开回复时字段省略或为 `null`,前端须兼容两种情况。
### 4.4 权限矩阵
| 行为 | STUDENT | COMPANY(所属) | COMPANY(非所属) | ADMIN | 匿名 |
| --- | --- | --- | --- | --- | --- |
| 公开评价/公开回复 | 可读 | 可读 | 可读 | 可读 | 可读 |
| 企业评价管理列表/详情 | 403/2002 | 可读 | 403/2002 | 403/2002 | 401 |
| 创建/修订官方回复 | 403/2002 | 可写 | 403/2002 | 403/2002 | 401 |
| 待审官方回复/决策 | 403/2002 | 403/2002 | 403/2002 | 可读/可决策 | 401 |
| 删除/隐藏学生评价 | 不新增该权限 | 永不允许 | 永不允许 | 继续沿用既有审核而非删除 | 不适用 |
## 5. 服务、查询与一致性设计
### 5.1 推荐组件
- `CompanyReviewManagementService`:企业列表、详情、归属校验;
- `CompanyReviewReplyService`:创建、预审、修订、状态机、审计;
- `AdminReviewReplyService`:待审列表与管理员决策;
- `CompanyReviewReplyModerationService`:复用现有确定性文本安全规则的公共部分,不复制 Controller 中的正则或把回复伪称为 AI 审核;
- `CompanyReviewReply`、`CompanyReviewReplyHistory` 及对应 Repository/DTO/Controller。
### 5.2 企业归属校验
服务端按当前用户找到企业,再由 `review.jobId → job.companyId` 比对。所有按 `reviewId` 的企业操作先检查岗位归属;不把“评价存在但不属于你”与“不存在”区分为可枚举信息。禁止以请求内 `companyId` 决定查询范围。
### 5.3 防 N+1 查询
企业列表采用“岗位评价页 + 批量补齐”策略:
1. 分页查询当前公司岗位的评价,并 join/批量取得岗位标题;
2. 对本页 `reviewId` 一次性查询 `job_review_dimension_scores`;
3. 对本页 `reviewId` 一次性查询官方回复;
4. 在内存按 ID 映射组装。
不得逐条调用 `findById`、逐条查询维度分或回复。管理员待审列表同样使用 reply 与岗位/公司 `JOIN FETCH` 或投影查询。编码测试应证明查询次数与页面条目数无关。
### 5.4 并发与幂等
- 创建回复:数据库 `UNIQUE(review_id)` 是最终防线;服务先处理同 `idempotencyKey` 重放,再依赖唯一键处理并发创建。
- 修订回复:请求带 `expectedVersion`,实体使用 `@Version`;过期版本抛 `409/10104`。
- 管理员决策:条件更新 `WHERE id=? AND moderation_status='PENDING' AND version=?`,影响行数为 0 时返回 `409/10103` 或 `10104`;仅成功事务生成一次历史和审计。
- 任何唯一键或乐观锁异常都不得映射为 500 或泛化为错误的“已存在”。
## 6. 审核、内容安全和异常语义
### 6.1 文本规则
回复正文限制 1–500 字符,拒绝空白文本。复用已有规则中可泛化的:敏感词、手机号、QQ/微信引流、URL、异常重复字符。对企业回复不使用“学生快速提交”“跨学生重复评价”“学生批量刷评”一类依赖学生行为的规则。
规则命中时内容可以保存为 `PENDING`,但不得公开;管理员作出决定后才变为公开或驳回。原因码仅给管理员;企业在被驳回时只看到管理员填写的安全说明,不展示敏感词、内部阈值、风控分。
### 6.2 错误码(拟新增 101xx)
| 错误码 | HTTP | 含义 |
| --- | --- | --- |
| 10101 `REVIEW_REPLY_NOT_FOUND` | 404 | 回复不存在,或不属于本企业时按权限策略处理 |
| 10102 `REVIEW_REPLY_ALREADY_EXISTS` | 409 | 评价已有当前官方回复 |
| 10103 `REVIEW_REPLY_INVALID_STATE` | 409 | 状态不允许创建修订/决策 |
| 10104 `REVIEW_REPLY_CONCURRENT_UPDATE` | 409 | 乐观锁或条件更新冲突 |
| 10105 `REVIEW_REPLY_CONTENT_INVALID` | 400 | 空白、超长或不符合字段校验 |
| 10106 `REVIEW_REPLY_IDEMPOTENCY_CONFLICT` | 409 | 同幂等键但评价/正文不同 |
| 10107 `REVIEW_REPLY_DECISION_INVALID` | 400 | 非法审核决策或驳回原因缺失 |
角色不匹配继续使用既有 `FORBIDDEN/2002`,未登录继续使用 `UNAUTHORIZED/401`,不增加重复错误码。
## 7. DTO 白名单
| 载荷 | 可以返回 | 绝不返回 |
| --- | --- | --- |
| 企业评价列表/详情 | 评价文本、星级、标签、维度聚合、岗位公开名称、时间、该企业自身回复状态 | 学生 ID/联系方式、IP/UA、哈希、评价审核内部信息 |
| 企业回复 | 内容、状态、版本、更新时间、管理员驳回说明 | 审核人 ID、内部原因码、风控分、内部请求 ID |
| 管理员待审回复 | 回复文本、公司/岗位、风险等级与安全原因码 | 学生联系信息、IP/UA 明文、密码、JWT |
| 公开评价回复 | `content`、`repliedAt` | ID、公司 ID、状态、版本、审核细节、所有评价人信息 |
任何 response DTO 禁止用 Entity 直接序列化;使用显式 DTO 或白名单 `Map`。对 `officialReply`、企业列表、管理员列表分别写 JSON 序列化脱敏断言。
## 8. 预期 OpenAPI 与前端协作
预计新增 5 paths / 6 operations:
```text
GET /companies/me/job-reviews
GET /companies/me/job-reviews/{reviewId}
POST /companies/me/job-reviews/{reviewId}/reply
PUT /companies/me/job-reviews/{reviewId}/reply
GET /admin/review-replies/pending
POST /admin/review-replies/{replyId}/decision
```
预计 OpenAPI:154/190 → **159 paths / 196 operations**。实际编码后必须由运行时 `/v3/api-docs` 与 `docs/openapi-rc.json` 对账,`python docs/verify_openapi.py` 通过后才可记录最终数字。
前端需要:
1. 企业侧支持评价筛选、无回复/待审/已公开/已驳回状态;
2. 已驳回回复展示安全说明与“修订后重新提交”入口;
3. 公开岗位评价页仅在 `officialReply` 存在时展示“企业官方回复”;
4. 移除任何对公开 `studentUserId` 的依赖;
5. 不把企业端的审核状态、管理员页面或内部 reason codes 暴露给公众。
## 9. 测试与验收计划
### 9.1 单元测试
- 回复状态机:安全文本自动通过、风险文本待审、待审审批/驳回、仅驳回可重提、公开终态不可改;
- 内容校验:空白、501 字符、手机号/URL/敏感词等;
- 幂等:同键同体返回同一回复,同键异体 `10106`;
- 乐观锁和并发创建:同评价并发只成功一条,不出现 500;
- 脱敏 mapper:企业/公开/管理员三个视图字段严格不同。
### 9.2 MockMvc 与权限测试
- COMPANY 可访问所属岗位评价并创建回复;
- STUDENT、ADMIN、未归属 COMPANY、匿名分别获得既定 403/401;
- 企业没有 DELETE 评价/回复的路由与权限;
- PUBLIC 仅看见审核通过的 `officialReply`;
- 新旧公开评价 JSON 均不含 `studentUserId`、联系方式、内部审核字段。
### 9.3 H2 持久化测试
- V38 迁移可应用,FK、唯一约束、CHECK、`@Version` 生效;
- 评价删除时回复与历史级联删除;
- 企业列表分页、批量维度分/回复加载,验证无 N+1;
- 管理员并发决策仅一条历史与一条审计落库;
- V17/V18 的学生评价审核与汇总回归通过。
### 9.4 Docker MySQL 黑盒
1. 学生创建公开评价 → 所属企业列表/详情可见但无评价者 ID;
2. 企业创建安全回复 → 公开评价出现 `officialReply`;
3. 企业创建风险回复 → 公众不可见,管理员待审、批准/驳回链路正确;
4. 驳回后企业修订可重提;公开后不可覆盖或删除;
5. 非归属企业、STUDENT、ADMIN、匿名越权验证;
6. 同一评价并发创建、同键重试、管理员并发决策逐请求校验 HTTP 状态/业务码,无 500;
7. `audit_logs` 只含白名单详情,不含正文、评价人或凭据;RabbitMQ 队列终态存在且 ready/unacked 均为 0;
8. 回归 V17/V18 公开评价、V33 举报屏蔽、V34 求职进度、V35/V36 测评、V37 风控、V38 评分、V39 推荐关键接口。
关闭门槛:专项测试与 `mvn clean verify` 全绿、OpenAPI 对账通过、真实 MySQL/Docker 黑盒 `FAIL=0`,关键权限与脱敏项不得 `SKIP`。
## 10. 回滚与兼容
- V40 只增加两张回复表、六个操作与公开响应的可选 `officialReply`;不改变学生评价的评分、审核或删除语义。
- 回滚采用新的前向迁移停用/删除 V40 回复表,禁止修改已执行的 V38;生产执行前另写并评审回滚迁移。
- 若企业回复功能需临时关闭,服务层应拒绝新建/重提而保留既有公开回复可读;是否加入独立 feature flag 需在编码前确认,默认不增加以免无业务需求引入额外配置。
## 11. 已确认的编码边界(2026-09-04)
负责人已确认以下方案,编码必须以此为准:
1. 采用 `V38__company_review_replies.sql`,新增回复与回复历史两张表;
2. 每条评价仅有一条当前官方回复;公开后不可覆盖,驳回后可修订重提;
3. 使用确定性预审:无风险自动公开,风险回复转管理员审核;
4. V40 立即移除公开评价载荷中的 `studentUserId`,关闭既有隐私泄露;
5. 本版不做企业申诉/投诉评价、自动通知、运营统计或公开回复编辑历史;
6. 接口范围锁定为 5 paths / 6 operations,OpenAPI 预计 159 paths / 196 operations;
7. 不新增企业回复独立 feature flag。
V40 编码、测试、OpenAPI 更新与真实 MySQL/Docker 黑盒验收均已完成(`PASS=62 FAIL=0 SKIP=0`);仍须遵守模块完成后再建立独立 Git 检查点的治理要求。

366
docs/v41-enterprise-talent-recommendation-design.md

@ -0,0 +1,366 @@
# V41 企业人才发现推荐——详细设计(编码前评审)
> 状态:**已实现并通过自动化与真实 MySQL/Docker 黑盒验收**。自动化测试 1461/1461 通过,真实黑盒 `PASS=37 / FAIL=0`。本文是 V41 的唯一设计基线。
## 1. 目标与边界
### 1.1 目标
让已认证企业针对自己已发布的岗位,发现**主动开启人才发现**的学生候选人。系统用已批准的岗位画像和学生自愿提供的聚合信号做规则排序,返回数据最小化的候选人卡片。
这是一项“人才发现”能力,不替代投递、面试、Offer 或现有企业候选人看板:
- 现有 `/company/candidates/**` 只展示已经投递的候选人;
- V41 只展示尚未向当前企业投递、且显式同意被发现的候选人;
- V41 不创建投递、不发送消息、不创建邀约,也不让企业获得联系方式或简历文件。
### 1.2 确认范围
1. 学生人才发现默认关闭,学生可随时自行开启或关闭;
2. 只有 `verified=true` 的 COMPANY,才可对其自有、`PUBLISHED` 的岗位查询;
3. 仅已启用且 ADMIN 审核通过的 V39 岗位画像可以计算推荐;
4. 复用 V39 的规则信号权重:学生偏好 40%、RIASEC 聚合兴趣 35%、四维能力聚合 25%;
5. 已向**当前企业任一岗位**投递过的学生均从候选池排除,包含已撤回或已拒绝的历史投递,避免重复触达;
6. 公司只得到匹配档位与安全概述,不得到 RIASEC、四维能力、候选评分、答卷、答案、题目或原始权重;
7. 本版没有企业私信、邀约、收藏/淘汰反馈、企业端联系方式、学生被动通知、AI 解释或推荐历史快照。
### 1.3 明确不做
- 不改变 V35/V36 的测评题库、提交、重测、有效期或审计语义;
- 不改变 V38 的候选评分写入、排名和企业评分卡;
- 不改变 V39 的“学生找岗位”推荐及其岗位画像审核流;
- 不改变现有 `resumeVisibility` / `contactVisible` 的含义;
- 不按性别、年龄、籍贯、手机号、邮箱、学校排名、姓名或头像作为排序信号;
- 不生成“录用概率”“胜任结论”“适合/不适合”之类确定性结论。
## 2. 现状核查与设计前提
| 项目 | 现状 | V41 处理 |
|---|---|---|
| 最新迁移 | V38 为企业评价官方回复 | V41 建议下一编号 `V39__enterprise_talent_discovery.sql` |
| V39 岗位画像 | `job_recommendation_profiles`,仅 `enabled=true` 且 `APPROVED` 可用 | 作为岗位侧唯一的结构化推荐输入 |
| 学生偏好 | `student_preferences` 含城市、行业、岗位类型、用工类型、远程与薪资期望 | 只用于内部偏好匹配,不原样返回企业 |
| 兴趣测评 | V35 RIASEC 聚合结果 | 仅可作为内部聚合信号,绝不下发维度分 |
| 能力测评 | V36 完整且未过期的聚合结果 | 仅可作为内部聚合信号,绝不下发维度分 |
| 简历隐私 | `resumeVisibility` 只控制简历可见性,默认 `PRIVATE` | 不复用为人才发现授权;V41 另设明确授权 |
| 企业候选人看板 | 仅已投递的 `application` | V41 只发现未向本企业投递过的候选人 |
`resumeVisibility` 与“是否允许被人才发现”是不同目的的授权:前者控制简历,后者控制是否进入企业发现候选池。两者不可混用或隐式推导。
## 3. 角色、权限与隐私
### 3.1 权限矩阵
| 能力 | STUDENT | COMPANY(未认证) | COMPANY(认证且岗位所属) | ADMIN | 未登录 |
|---|---:|---:|---:|---:|---:|
| 查询/更新本人人才发现开关 | ✓ | 403/2002 | 403/2002 | 403/2002 | 401 |
| 查询岗位人才推荐 | 403/2002 | 403/2002 | ✓ | 403/2002 | 401 |
所有身份从 `SecurityContext.getAuthentication().getName()` 的 JWT subject 推导。请求中不得接受 `companyId`、`studentUserId`、`candidateId` 或任何越权身份参数。
### 3.2 候选人可见性规则
一个学生进入某企业某岗位的候选池,必须同时满足:
1. 用户角色为 `STUDENT`,账号未进入注销终态;
2. 存在 `student_talent_discovery_preferences` 且 `enabled=true`;无记录一律视为关闭;
3. 存在可用于候选卡的基础学生档案;
4. 与当前公司不存在任何历史 `applications` 记录;
5. 当前 COMPANY 已认证,目标岗位归属该 COMPANY 且为 `PUBLISHED`;
6. 目标岗位有 `APPROVED + enabled=true` 的 V39 岗位画像。
关闭开关后,后续查询立即不再返回该学生;不删除其档案、投递、测评、评分或审计历史。V41 首版不保存企业查询历史与候选人曝光历史,避免把“被浏览”固化为可追踪个人行为数据。
### 3.3 响应白名单
企业候选卡仅允许:
```json
{
"candidateKey": "tdc_opaque_token",
"candidateLabel": "开放人才候选人",
"city": "合肥",
"education": "本科",
"graduationYear": 2027,
"skills": ["Java", "Spring Boot"],
"fitBand": "HIGH",
"summary": "岗位画像与候选人公开资料存在较高匹配度",
"signalsAvailable": ["PREFERENCE", "RIASEC", "ABILITY"]
}
```
`candidateKey` 是服务端签名的、与公司和岗位绑定的短期不透明标识,只用于前端列表稳定 key;它不等于 `studentUserId`,也不能在本版换取详情、联系方式或简历。
企业响应禁止出现:
- `studentUserId`、用户 ID、简历 ID、手机号、邮箱、住址、简历 URL;
- 姓名、头像、出生日期、性别、身份证明、学校排名;
- 测评 attempt/result ID、维度分、原始分、题目、答案、答案键、解析、评分规则;
- `candidate_dimension_scores` 明细、候选综合分、排名、百分位;
- 画像原始权重、能力期望、内部规则版本、SQL/审计数据。
学生端开关响应只返回 `enabled`、`version`、`createdAt`、`updatedAt`;不返回企业查询过该学生的记录。
## 4. 接口契约(提案)
### 4.1 学生人才发现偏好
```http
GET /profile/me/talent-discovery
PUT /profile/me/talent-discovery
```
`GET` 无记录时返回默认值,不写库:
```json
{ "enabled": false, "version": 0 }
```
`PUT` 请求:
```json
{ "enabled": true, "expectedVersion": 0 }
```
- `enabled` 必填;
- `expectedVersion` 可选,传入时必须与当前版本一致;
- 首次创建仅接受 `expectedVersion` 缺失或 `0`;
- 并发冲突复用现有乐观锁错误 `9201`,不新增无必要错误码;
- 成功只写 `TALENT_DISCOVERY_PREFERENCE_UPDATED` 审计,details 仅有开关状态、版本、requestId 与时间。
### 4.2 企业岗位人才推荐
```http
GET /companies/me/jobs/{jobId}/talent-recommendations?page=0&size=20
```
参数规范:`page` 从 0 起;`size` 为 1–50;`jobId > 0`。无候选人返回 HTTP 200:
```json
{
"mode": "NO_ELIGIBLE_CANDIDATE",
"items": [],
"total": 0,
"page": 0,
"size": 20,
"totalPages": 0,
"message": "暂未发现已授权且满足基础条件的候选人"
}
```
目标岗位缺少可用画像时也返回 HTTP 200,但明确:
```json
{
"mode": "UNAVAILABLE",
"items": [],
"message": "该岗位尚无审核通过的推荐画像"
}
```
不得把空候选池或不可用画像伪装成评分为 0 的推荐列表。
### 4.3 预计 OpenAPI
新增 2 个唯一 path / 3 个 operation(学生 GET/PUT 共用同一 path):
| 方法 | 路径 | 角色 |
|---|---|---|
| GET | `/profile/me/talent-discovery` | STUDENT |
| PUT | `/profile/me/talent-discovery` | STUDENT |
| GET | `/companies/me/jobs/{jobId}/talent-recommendations` | 已认证 COMPANY,且岗位归属本人 |
以当前 159 paths / 196 operations 为基线,预计更新到 **161 paths / 199 operations**。三个 operation 只形成两个唯一 URL path;实际以运行时 `/v3/api-docs` 和静态 `openapi-rc.json` 的对账结果为准。
## 5. 数据模型与迁移
### 5.1 新迁移
建议新增、且只新增:
```text
V39__enterprise_talent_discovery.sql
```
不修改 V1–V38 已执行迁移;不向 `candidate_dimension_scores` 写入任何数据。
### 5.2 `student_talent_discovery_preferences`
| 字段 | 类型/约束 | 含义 |
|---|---|---|
| `id` | BIGINT PK | 主键 |
| `student_user_id` | BIGINT NOT NULL UNIQUE,FK `users(id)` | 仅本人一条授权记录 |
| `enabled` | TINYINT NOT NULL DEFAULT 0 | 人才发现显式授权 |
| `version` | BIGINT NOT NULL DEFAULT 0 | 乐观锁 |
| `created_at` | DATETIME NOT NULL | 创建时间 |
| `updated_at` | DATETIME NOT NULL | 最后更新 |
索引:`(enabled, student_user_id)`。关闭的记录不参与候选查询;不存在记录等效关闭。
不存:公司 ID、岗位 ID、企业浏览记录、联系方式、简历路径、测评细节、候选评分或算法分。
## 6. 规则计算与数据一致性
### 6.1 capturedNow
企业一次查询在服务起点生成唯一 `capturedNow`。同一次计算的能力有效期、候选过滤与输出必须复用该值,避免边界时刻同页结果不一致。
### 6.2 信号选择
对每位候选人仅读取以下最小聚合数据:
| 信号 | 有效条件 | 不可用处理 |
|---|---|---|
| 偏好 P | 可解析的城市、行业、岗位类型、用工类型、远程或薪资偏好 | 该信号不计入分母 |
| 兴趣 I | 最新 `HOLLAND_RIASEC`、`COMPLETED`,按 `completed_at DESC, attempt_id DESC` 取一条,六维完整且 0–100 | 不计入分母 |
| 能力 A | 最新 `WORKPLACE_ABILITY`、`COMPLETED`、`valid_until > capturedNow`,四维完整、每维 6 题、总计 24 题且 0–100 | 不计入分母 |
禁止取历史最高分。JSON 损坏、缺维、越界、空值、重复维或无效时间都只能令该信号不可用,不能造成 HTTP 500。
### 6.3 分数与排序
仅当至少一项可用信号存在时,计算:
```text
fitScore =
(P × 40 + I × 35 + A × 25) / 可用信号权重之和
```
中间与最终结果使用 `BigDecimal`,最终保留两位、`HALF_UP`。`fitScore` 仅用于后端排序与映射档位,不对企业返回数值。
| 匹配档位 | 内部区间 | 企业展示 |
|---|---:|---|
| HIGH | ≥ 75 | 较高匹配度 |
| MEDIUM | 60–74.99 | 中等匹配度 |
| BASIC | < 60 | 基础匹配度 |
排序:`fitScore DESC` → 候选档案最后更新时间 DESC → `student_user_id DESC`。最后一项只用于稳定排序,不对外暴露。
若没有任何有效信号,候选人不进入 V41 首版列表;不使用学校、姓名、性别等字段做“兜底排序”。
### 6.4 安全概述
`summary` 固定来自模板,只允许说明总体匹配等级。`signalsAvailable` 仅披露存在的类别 `PREFERENCE` / `RIASEC` / `ABILITY`,不披露任何单项高低、维度或权重。详细理由与可解释反馈明确留给后续“推荐理由和解释”模块评审。
## 7. 查询、性能与并发
### 7.1 查询策略
V41 第一版采用**实时计算、全局排序后分页**:不能先在数据库分页、只对当前页计算分数再排序,否则第 2 页候选人可能比第 1 页分数更高,分页结果会错误。
1. 先用一次批量资格查询读取全部候选 ID 与必要的基础档案字段:授权开启、`STUDENT`、档案存在、账号未处于注销限制状态、与当前公司不存在任何历史投递;历史投递通过 `NOT EXISTS` 或批量集合排除,不能只排除当前岗位,也不能按候选人逐条查询。
2. 按固定批次(建议每批 500 个 ID)批量读取学生偏好、最新可用 RIASEC、最新有效能力结果和展示档案;候选 ID 必须使用参数绑定,禁止拼接 SQL。
3. 服务层以内存 Map 汇总每个候选人的信号,计算所有候选人的 `fitScore`,再按 `fitScore DESC`、档案 `updatedAt DESC`、`studentUserId DESC` 做全局排序,最后执行 `page/size` 切片。
这是第一版实时方案;当候选池达到规模阈值,应升级为异步推荐索引/快照,但不能以页内排序牺牲当前版本的全局排序正确性。查询需预留真实 MySQL `EXPLAIN` 验收:`student_user_id`、`definition_id`、`completed_at`、`valid_until` 与授权开关索引均应参与过滤。
### 7.2 并发
人才推荐列表是只读、实时计算,无持久化推荐记录,故无创建推荐的并发写入问题。学生开关通过 `@Version + expectedVersion` 控制并发,两个竞争更新只能有一个成功;另一个返回 `409/9201`。
查询过程中学生关闭开关的竞态允许下一次请求生效;同一已开始的只读事务可在当前页继续返回当时快照,不延迟或阻塞用户操作。
## 8. 错误语义与审计
### 8.1 错误
| 场景 | HTTP / 业务码 |
|---|---|
| 非认证 | 401 |
| 角色不符 | 403 / 2002 |
| 企业不存在 | 404 / 5001 |
| 岗位不存在/非发布 | 复用既有岗位错误码 |
| 岗位不属于当前企业 | 403 / 2002(不泄露额外信息) |
| 企业未认证 | 403 / 2002 |
| page/size/jobId 非法 | 400 / 1001 |
| 学生开关版本冲突 | 409 / 9201 |
本版不新增 V41 专属错误码。`UNAVAILABLE` 和 `NO_ELIGIBLE_CANDIDATE` 是成功响应模式,不是错误。
### 8.2 审计
仅学生开关写审计:
```json
{
"enabled": true,
"version": 2,
"requestId": "..."
}
```
`GET` 推荐列表不写逐候选人或逐企业浏览审计,避免形成候选人追踪链路。不得审计候选人清单、matchScore、信号细节、学生 ID、联系方式、简历地址、答案、IP、User-Agent、token 或密码。
## 9. 安全与公平性门禁
1. 学生显式同意是进入企业发现池的前置条件;默认关闭;
2. 企业必须认证、岗位必须属于自己且公开发布;
3. 推荐不是自动淘汰或录用依据,企业仍需在后续合法招聘流程中自行评估;
4. 不使用受保护特征或联系方式排序;
5. 任何测评聚合仅在学生自愿进入发现池后作为内部排序信号;企业侧不见测评详情;
6. 若后续加入解释、邀约、反馈或模型训练,必须单列模块并重新做隐私、风控和产品评审。
## 10. 建议代码结构
```text
com.mydreamjob.talentdiscovery
StudentTalentDiscoveryPreference
StudentTalentDiscoveryPreferenceRepository
TalentDiscoveryService
TalentDiscoveryCandidateSource # 批量只读数据来源
TalentDiscoveryScoreCalculator # 纯函数,无数据库访问
TalentDiscoveryController
dto/
```
允许复用 V39 的安全 JSON 解析与岗位画像校验,但不得复制 Controller 中的权限逻辑或把 V41 放入 V39 Controller 而扩大其职责。
## 11. 测试与验收方案
### 11.1 自动化测试
1. `TalentDiscoveryScoreCalculatorTest`
- 三信号权重与缺项重归一化;
- 最新低分优先于历史高分;
- 能力恰在 `capturedNow` 过期;
- 缺维、非法 JSON、越界均不 500;
- 不使用姓名、性别、学校或联系方式作为输入。
2. `TalentDiscoveryServiceTest`
- 默认关闭不入池;开启后入池;关闭后立即排除;
- 当前企业任一历史投递均排除;其他企业投递不排除;
- 未认证企业、跨企业岗位、无审核画像、未发布岗位;
- 候选卡白名单;无数据模式正确;N+1 查询次数固定。
3. `TalentDiscoveryControllerTest`
- STUDENT/COMPANY/ADMIN/未登录矩阵;
- 分页校验;不泄露 `studentUserId`、联系方式、测评字段或分数;
- 学生开关 PUT 乐观锁冲突。
4. `TalentDiscoveryPersistenceIntegrationTest`
- V39 迁移、唯一约束、FK、默认关闭、`@Version` 冲突;
- 批量查询与已投递排除在 H2 MySQL 模式工作。
### 11.2 真实 MySQL/Docker 黑盒
1. V39 迁移成功且无影响 V1–V38;
2. 默认关闭、开启、关闭后的立即可见性变化;
3. 已认证/未认证企业、岗位所有权、PUBLISHED 与已审核画像门禁;
4. 同企业历史投递排除、其他企业投递不误排;
5. 最新低分优先、能力过期、无有效信号和无候选人模式;
6. 响应脱敏、审计不写候选清单;
7. `EXPLAIN` 与常数批量查询验证;
8. RabbitMQ 队列终态与 V39/V40 关键接口回归。
## 12. 文档、OpenAPI 与发布门禁
编码后更新:
- `docs/enterprise-talent-discovery-api.md`;
- `docs/frontend-integration.md`;
- `docs/backend-development-roadmap.md`;
- `docs/openapi-rc.json`(V41 实际 161 paths / 199 operations,两个唯一 path 承载三个 operation);
- `docs/verify_openapi.py`。
发布门禁已完成:专项与全量自动化测试通过(1461/1461),OpenAPI 静态/运行时均为 161 paths / 199 operations,真实 MySQL/Docker 黑盒 `PASS=37 / FAIL=0`。黑盒覆盖 V39 迁移、授权状态机、最新版测评选择、同企业历史投递排除、全局排序后分页、权限与脱敏、队列终态及 V39 回归。
## 13. 编码前确认结论
负责人已确认本设计的业务范围。进入编码前还必须按本设计完成只读核查:最新 Flyway 为 V38、V39 岗位画像审核字段存在、认证企业判断可用、学生档案字段可满足候选卡白名单;如任一项与当前代码冲突,停止并报告,不自行改变接口、迁移号、隐私边界或排序口径。

480
docs/v42-enterprise-talent-invitation-design.md

@ -0,0 +1,480 @@
# V42 企业人才邀约与授权沟通——详细设计(已实现并通过真实黑盒验收)
> 状态:**设计已确认并实现,真实 MySQL/Docker 黑盒验收通过(PASS=76/FAIL=0),待 Git 检查点**。负责人已于 2026-09-04 确认本文第 17 节全部决策。
>
> 目标读者:后端开发、测试、前端联调与产品负责人。
>
> 设计目标:在 V41 企业人才发现的隐私边界之上,允许企业向候选人发出一次可审计、可拒绝、会过期的站内邀约;只有学生明确接受后,双方才建立站内会话。任何阶段均不向企业直接提供联系方式、简历文件或测评明细。
## 1. 背景与核心决策
V41 只返回不可逆的 `candidateKey`,不返回 `studentUserId`,也没有通过该 key 查询个人详情的接口。V42 必须延续这一边界,不能把 `candidateKey` 改造成可解码 ID,也不能允许企业在请求中提交学生 ID。
创建邀约时,后端应在同一次事务前置校验中重新生成当前岗位的合格候选池,对候选池内每位学生按 V41 相同日期桶和算法计算 `candidateKey`,使用常量时间比较找到唯一候选人,并再次校验授权、岗位画像、历史投递和账号状态。匹配成功后,内部可将 `student_user_id` 写入邀约表用于归属和学生收件箱,但该字段永不出现在企业响应、日志或审计 details 中。
本版采用“**邀约先行,接受后建站内会话**”:
```text
企业发现候选人
→ 创建 PENDING 邀约
→ 学生查看并选择 ACCEPT / REJECT
→ ACCEPT:原子创建或复用专用站内会话,状态 ACCEPTED
→ REJECT:状态 REJECTED,不建立会话
→ 到期未处理:状态 EXPIRED,不建立会话
```
不允许通过现有通用 `POST /conversations` 绕过邀约。现有 MessagingService 要求企业与学生已有 MATCHED 投递,V42 应新增一个仅供内部调用的受限会话创建组件,凭已接受的邀约创建 `TALENT_INVITATION` 类型会话;不得开放“传 studentUserId 创建会话”的公网接口。
## 2. 范围
### 2.1 本版包含
- 企业对 V41 当前仍合格的候选人发出邀约;
- 企业查看本公司发出的邀约列表和单条详情;
- 学生查看本人收到的邀约列表和详情;
- 学生接受或拒绝邀约;
- 接受后原子创建或复用站内会话;
- 幂等、频率限制、并发状态机、过期处理;
- 全链路权限、脱敏、历史与审计;
- V41 `candidateKey` 的安全解析替代方案:当前候选池内重算并匹配。
### 2.2 本版不包含
- 企业查看学生手机号、邮箱、住址、简历文件、真实姓名;
- 企业通过 candidateKey 查询候选人详情;
- 企业直接发送站内消息或绕过学生同意创建会话;
- 企业撤回、编辑、删除已发送邀约;
- 学生反悔撤销已接受结果;
- 邀约收藏、批量邀约、自动邀约、AI 文案;
- 联系方式申请、简历解锁、外部短信或邮件;
- 推荐曝光历史、推荐模型训练、候选评分写入;
- ADMIN 人工审核或修改邀约;
- 修改 V35–V41 的测评、评分、推荐、评价回复语义。
## 3. 版本与迁移
当前最新 Flyway 为 V39。V42 建议新增:
```text
V40__enterprise_talent_invitations.sql
```
新增两张表:
- `talent_invitations`:当前邀约及终态;
- `talent_invitation_history`:不可变状态历史,不保存文案副本。
不得修改 V1–V39;不得写 `candidate_dimension_scores`。
### 3.1 talent_invitations
建议字段:
| 字段 | 类型 | 约束/说明 |
|---|---|---|
| id | BIGINT | AUTO_INCREMENT PK |
| company_id | BIGINT | NOT NULL,FK companies(id) |
| job_id | BIGINT | NOT NULL,FK jobs(id) |
| student_user_id | BIGINT | NOT NULL,FK users(id),仅内部使用 |
| candidate_key_digest | CHAR(64) | NOT NULL,仅保存收到 key 的 SHA-256 摘要,不保存原 key |
| message | VARCHAR(300) | NOT NULL,安全清洗后的邀约说明 |
| status | VARCHAR(20) | PENDING/ACCEPTED/REJECTED/EXPIRED |
| idempotency_key | VARCHAR(64) | NOT NULL,UUID |
| active_key | VARCHAR(200) | 活跃时唯一,终态置 NULL |
| conversation_id | BIGINT | NULL,接受后关联 conversations(id) |
| expires_at | DATETIME | NOT NULL,默认创建后 7 天 |
| decided_at | DATETIME | NULL |
| version | BIGINT | NOT NULL DEFAULT 0,JPA `@Version` |
| created_at | DATETIME | NOT NULL DEFAULT CURRENT_TIMESTAMP |
| updated_at | DATETIME | NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE |
约束与索引:
- `CHECK(status IN ('PENDING','ACCEPTED','REJECTED','EXPIRED'))`;
- `UNIQUE(company_id, idempotency_key)`;
- `UNIQUE(active_key)`;
- 活跃邀约的 `active_key` 固定为 `companyId:jobId:studentUserId`,进入任一终态时置 NULL;
- `INDEX(student_user_id, status, created_at)`;
- `INDEX(company_id, status, created_at)`;
- `INDEX(job_id, status, created_at)`;
- `INDEX(status, expires_at)`;
- `candidate_key_digest` 只用于追溯请求是否对应同一 opaque key,不用于反查学生;
- 删除岗位或公司时不建议级联删除审计性邀约;沿用项目 FK 策略并在编码前核实现有删除业务。若存在物理删除,使用 RESTRICT,避免历史丢失。
### 3.2 talent_invitation_history
| 字段 | 类型 | 约束/说明 |
|---|---|---|
| id | BIGINT | AUTO_INCREMENT PK |
| invitation_id | BIGINT | NOT NULL,FK invitations(id) ON DELETE CASCADE |
| action | VARCHAR(30) | CREATED/ACCEPTED/REJECTED/EXPIRED |
| actor_user_id | BIGINT | NULL;系统过期时为空 |
| created_at | DATETIME | NOT NULL DEFAULT CURRENT_TIMESTAMP |
要求:
- `CHECK(action IN ('CREATED','ACCEPTED','REJECTED','EXPIRED'))`;
- `INDEX(invitation_id, created_at)`;
- 不保存 message、candidateKey、studentUserId、联系方式、简历、测评或评分;
- 每次真实状态变化恰好写一条历史。
## 4. 状态机与时间规则
```text
PENDING ──学生接受──> ACCEPTED
PENDING ──学生拒绝──> REJECTED
PENDING ──到期处理──> EXPIRED
```
- `ACCEPTED`、`REJECTED`、`EXPIRED` 均为终态;
- 创建时间由数据库/服务端生成,不接受客户端时间;
- 默认有效期 7 天,建议配置 `talent-invitation.ttl-days=7`,但本版不增加功能开关;
- 所有读取和决策先执行按 ID 的惰性过期:当 `status=PENDING && expiresAt<=capturedNow` 时,以条件更新转为 EXPIRED;
- 可增加定时批量过期任务,但正确性不能依赖调度器;
- `expiresAt == capturedNow` 视为已过期;
- 学生决策采用 `expectedVersion` + 条件更新,和过期任务竞争时只能一个成功;
- 接受事务必须保证“状态 ACCEPTED、history、conversation、audit”要么全部成功,要么全部回滚。
## 5. 公网接口
建议新增 6 个 path、6 个 operation:
| 方法 | 路径 | 身份 | 作用 |
|---|---|---|---|
| POST | `/companies/me/jobs/{jobId}/talent-invitations` | COMPANY | 使用 candidateKey 创建邀约 |
| GET | `/companies/me/talent-invitations` | COMPANY | 分页查看本公司邀约 |
| GET | `/companies/me/talent-invitations/{invitationId}` | COMPANY | 查看本公司单条邀约 |
| GET | `/profile/me/talent-invitations` | STUDENT | 分页查看本人收到的邀约 |
| GET | `/profile/me/talent-invitations/{invitationId}` | STUDENT | 查看本人单条邀约 |
| POST | `/profile/me/talent-invitations/{invitationId}/decision` | STUDENT | 接受或拒绝 |
以 V41 的 161 paths / 199 operations 为基线,预计变为 **167 paths / 205 operations**。最终计数必须以静态 OpenAPI、运行时 `/v3/api-docs` 和校验脚本三方一致为准。
### 5.1 企业创建邀约
请求:
```json
{
"candidateKey": "opaque-url-safe-value",
"message": "我们正在招聘后端开发实习生,欢迎了解岗位详情。",
"idempotencyKey": "3c989a31-6774-4520-9700-e58416cc1845"
}
```
校验:
- 当前用户必须是已认证企业;
- `jobId > 0`,岗位属于当前企业且为 PUBLISHED;
- 岗位画像必须 `enabled=true && review_status='APPROVED'`;
- candidateKey 非空、长度设置合理上限(建议 128);
- message 去首尾空白后 1–300 字符;禁止手机号、邮箱、URL、微信/QQ 等绕过站内沟通的信息;
- idempotencyKey 必须为 UUID,最长 64;
- 使用 V41 当前日期桶重算候选池 key 并常量时间匹配;禁止查表反解、日志打印或返回匹配到的学生 ID;
- 找到候选人后再次校验:授权开启、账号正常、档案可展示、没有当前企业任何历史投递、至少一个推荐信号仍有效;
- 任一候选门禁失败对企业返回统一的“候选人当前不可邀约”,避免泄露关闭授权、注销、投递或信号失效的具体原因。
幂等和重复:
- 同公司、同 idempotencyKey、同 jobId、同 candidateKey 摘要、同 message:返回原邀约;
- 同公司、同 idempotencyKey 但业务内容不同:409;
- 同公司、同岗位、同学生已有 PENDING 邀约:返回 409;
- 旧邀约已进入终态后是否允许再次邀约,由冷却规则控制,不复用旧记录;
- 并发创建由 `UNIQUE(active_key)` 和独立事务定判保证,不得返回 500。
### 5.2 企业邀约响应
企业只可见:
- invitationId;
- jobId、jobTitle;
- candidateKey(仅列表需要继续定位卡片时返回;推荐默认不回显,使用 invitationId);
- candidateLabel,固定“开放人才候选人”;
- message;
- status;
- createdAt、expiresAt、decidedAt;
- conversationId,仅 ACCEPTED 后可见。
企业严禁看到 studentUserId、姓名、联系方式、简历 ID/URL、测评结果 ID/答卷 ID、RIASEC/能力细分、候选评分、fitScore、拒绝原因内部数据。
### 5.3 学生决策
请求:
```json
{
"decision": "ACCEPT",
"expectedVersion": 0
}
```
或:
```json
{
"decision": "REJECT",
"expectedVersion": 0
}
```
规则:
- invitationId 必须属于当前学生;
- decision 仅 ACCEPT/REJECT;
- 只有未过期 PENDING 可决策;
- expectedVersion 必填;
- 同请求幂等重放:若已处于请求目标终态,可返回当前结果;不同决策或版本冲突返回 409;
- REJECT 不要求填写理由,且企业端不展示拒绝原因;
- ACCEPT 成功后返回 conversationId;
- 接受时若双方存在屏蔽关系,应拒绝接受并保持 PENDING,或由产品明确选择直接 REJECTED。推荐保持 PENDING 并返回统一禁止操作,避免泄露谁屏蔽谁。
## 6. candidateKey 匹配与防枚举
新增内部组件建议:
- `TalentInvitationCandidateResolver`:复用 V41 候选资格与 CandidateKeyGenerator;
- `TalentInvitationService`:状态机、幂等、权限和事务;
- `TalentInvitationConversationService`:只接受已锁定且即将 ACCEPTED 的 invitation,不对 Controller 暴露;
- `TalentInvitationExpiryService`:惰性和批量过期;
- `TalentInvitationController`:白名单 DTO,不写 SQL。
解析流程:
1. 单次请求生成 `capturedNow` 与日期桶;
2. 读取岗位及企业门禁;
3. 通过 V41 CandidateSource 批量读取当前全部合格候选;
4. 分批重算 candidateKey;
5. 用 `MessageDigest.isEqual` 比较 UTF-8 字节,找到唯一匹配;
6. 未匹配、已失效、重复匹配均返回统一不可邀约错误;
7. 仅在服务层内获得 studentUserId,落库后不写日志、不进入 HTTP 企业 DTO。
防护:
- 每公司每岗位每天最多 20 次成功邀约;
- 每公司每天最多 100 次成功邀约;
- 每个学生对同一公司终态后 30 天内不得再次邀约;
- 无效 candidateKey 请求也必须纳入拒绝型安全审计和速率限制,避免枚举;
- 不在错误信息中区分 key 不存在、授权关闭、已投递、账号注销或信号过期;
- 限额应通过数据库聚合 + 必要索引判断,不能仅靠单机内存。
以上限额属于推荐初值,编码前需由负责人最终确认。
## 7. 接受后会话
接受邀约后创建 `conversations.type='TALENT_INVITATION'`,成员仅为:
- 当前公司的 owner user;
- 当前学生。
要求:
- 新会话只能由内部服务创建,不复用允许客户端传 `participantUserId` 的入口;
- `talent_invitations.conversation_id` 唯一或通过 invitationId 锁保证一份邀约只生成一个会话;
- 并发 ACCEPT 只产生一个会话和两条成员记录;
- 建议会话 title 使用岗位标题的安全模板,不包含学生真实身份;
- 创建会话后不自动发送企业自由文本消息,邀约 message 已在学生邀约详情显示;如需首条系统消息,使用固定模板且 sender 语义需单独确认;
- 后续消息继续走既有 MessagingService 的成员校验、屏蔽规则和消息长度限制;
- 需要调整现有企业—学生会话的 MATCHED 投递守卫:对 `TALENT_INVITATION` 已接受会话放行,但 DIRECT 新建规则保持原样;不能全局取消 MATCH_REQUIRED。
## 8. 查询、分页与 N+1
企业和学生列表统一 page 从 0 开始,size 1–50。
- 企业列表按 companyId + createdAt DESC + id DESC 分页;
- 学生列表按 studentUserId + createdAt DESC + id DESC 分页;
- 岗位与公司展示信息按当前页 ID 集合批量加载;
- 不逐邀约 `findById` 查询岗位、公司、会话;
- 响应组装使用投影或固定次数批量查询;
- 详情查询在 SQL 层绑定归属条件,避免先返回“存在”再报无权导致枚举;
- 定时过期采用固定批次条件更新,不能全表加载实体。
## 9. 权限矩阵
| 场景 | STUDENT | COMPANY | ADMIN | 匿名 |
|---|---:|---:|---:|---:|
| 企业创建/列表/详情 | 403/2002 | 仅本公司 | 403/2002 | 401 |
| 学生列表/详情/决策 | 仅本人 | 403/2002 | 403/2002 | 401 |
| 跨公司岗位或邀约 | — | 统一 403/2002 | — | — |
| 他人学生邀约 | 统一不可见 | — | — | — |
SecurityConfig 只按角色收口;企业认证、岗位归属、邀约归属必须在 Service 再校验。请求 DTO 不得接受 companyId、studentUserId、ownerUserId 或 conversationId。
## 10. DTO 与脱敏
建议 DTO:
- `CreateTalentInvitationRequest`;
- `TalentInvitationDecisionRequest`;
- `CompanyTalentInvitationItemResponse`;
- `CompanyTalentInvitationDetailResponse`;
- `StudentTalentInvitationItemResponse`;
- `StudentTalentInvitationDetailResponse`;
- `TalentInvitationDecisionResponse`;
- 对应分页包装。
学生可以看到企业公开名称、岗位标题、工作城市、岗位公开摘要、邀约 message、状态和期限。学生端不返回企业 ownerUserId、内部画像、风险信息或审计字段。
对完整序列化 JSON 断言不得包含:
```text
studentUserId, ownerUserId, candidateId, phone, email, resumeUrl,
attemptId, resultId, answerKey, scoringRule, questionCode,
competencyTag, dimensionScores, overallScore, percentile, fitScore,
candidateKeyDigest, activeKey, password, token, secret
```
## 11. 错误码提案
建议使用新范围 10201–10209,编码前确认:
| code | 名称 | HTTP | 场景 |
|---:|---|---:|---|
| 10201 | TALENT_INVITATION_NOT_FOUND | 404 | 对本人/本公司不可见或不存在 |
| 10202 | TALENT_INVITATION_CANDIDATE_UNAVAILABLE | 409 | key 无效或候选当前不再可邀约 |
| 10203 | TALENT_INVITATION_ALREADY_ACTIVE | 409 | 已有 PENDING |
| 10204 | TALENT_INVITATION_INVALID_STATE | 409 | 非 PENDING 决策 |
| 10205 | TALENT_INVITATION_EXPIRED | 409 | 已到期 |
| 10206 | TALENT_INVITATION_CONCURRENT_UPDATE | 409 | 版本或条件更新冲突 |
| 10207 | TALENT_INVITATION_IDEMPOTENCY_CONFLICT | 409 | 同 key 不同业务内容 |
| 10208 | TALENT_INVITATION_CONTENT_INVALID | 400 | 文案或请求字段非法 |
| 10209 | TALENT_INVITATION_RATE_LIMITED | 429 | 企业邀约限额 |
跨企业、角色不符仍复用 2002;分页/路径参数非法复用 1001。不得把数据库异常直接返回 500 或暴露约束名。
## 12. 审计
新增动作:
- `TALENT_INVITATION_CREATED`;
- `TALENT_INVITATION_ACCEPTED`;
- `TALENT_INVITATION_REJECTED`;
- `TALENT_INVITATION_EXPIRED`;
- `TALENT_INVITATION_CREATE_REJECTED`(拒绝型安全审计)。
成功审计 details 白名单建议仅含:
```json
{
"invitationId": 42,
"jobId": 9,
"status": "ACCEPTED",
"requestId": "..."
}
```
禁止记录 candidateKey、摘要、studentUserId、message、联系方式、简历、测评、评分、IP/UA 明文、token/password/secret。只有条件更新胜者写 history 和成功审计。
## 13. 事务与并发
- 创建:幂等预查 → 候选重新解析 → 写入独立事务 → 唯一冲突精确定判;
- 不得在已被唯一键异常污染的 Hibernate Session 中继续查询;必要时使用 `REQUIRES_NEW` writer 和独立定判事务;
- 决策:`WHERE id=? AND student_user_id=? AND status='PENDING' AND version=? AND expires_at>?` 条件更新;
- ACCEPT 若需要先获得 invitation ID,应锁定记录并在同一事务创建会话、成员、历史、审计;
- 并发 ACCEPT/REJECT/EXPIRE 只能一个状态变化成功;
- 幂等重放不能重复创建 conversation、history 或 audit;
- 所有时间比较复用同一个 capturedNow。
## 14. 必须新增的自动化测试
### 14.1 Candidate resolver
- 当前日期桶合法 key 可解析;
- 旧日期桶、随机 key、篡改 key 不可解析;
- candidateKey 常量时间比较;
- 授权关闭、账号注销、当前企业历史投递、信号失效后立即不可邀约;
- 其他企业历史投递不误排;
- 不读取答案、题目或候选评分表;
- 查询次数只与批次数有关,不与候选人数线性增长。
### 14.2 TalentInvitationServiceTest
- 安全文案创建 PENDING;
- 联系方式、URL、空白、超长文案拒绝;
- 同幂等键同请求返回同一邀约;
- 同幂等键不同请求冲突;
- 同岗位同学生并发创建只有一条成功,无 500;
- 企业/岗位/画像门禁;
- 每岗位每日、每公司每日、30 天冷却边界;
- 过期惰性转换;
- ACCEPT 创建唯一会话;
- REJECT 不创建会话;
- 并发 ACCEPT/REJECT/EXPIRE 仅一个胜者;
- 同目标终态重放不重复 history/audit/conversation;
- 屏蔽关系不被绕过。
### 14.3 Controller 权限与脱敏
- COMPANY 仅访问本公司邀约;
- STUDENT 仅访问本人邀约;
- ADMIN 和匿名矩阵;
- 跨企业、跨学生均不可枚举;
- page/size/jobId/decision/version 校验;
- 企业与学生完整 JSON 白名单断言;
- 企业响应无 candidateKey 之外的候选定位字段,且默认不回显 candidateKey;
- 不存在候选详情、联系方式解锁或企业直接创建会话接口。
### 14.4 PersistenceIntegrationTest
- V40 在 H2 MODE=MySQL 执行;
- FK、CHECK、UNIQUE(active_key)、UNIQUE(company,idempotencyKey);
- @Version 冲突;
- 终态 active_key 释放;
- conversation_id 唯一关联;
- 历史表不含正文和学生字段;
- 并发创建/决策无 500;
- 列表批量查询无 N+1;
- `candidate_dimension_scores` 行数不变。
### 14.5 回归
- V41 推荐与授权结果不变;
- V39 岗位画像审核不变;
- V40 企业评价回复不变;
- 现有 DIRECT 会话仍要求 MATCHED 投递;
- 只有 ACCEPTED 邀约可以创建 TALENT_INVITATION 会话;
- 屏蔽、举报、消息权限保持原语义;
- 不发送 RabbitMQ 消息(若后续确认需要通知,再单独设计 outbox)。
## 15. 真实 MySQL/Docker 黑盒门禁
自动化通过后必须独立执行:
1. Flyway V40 success=1,两表字段、索引、FK、CHECK、唯一约束正确;
2. V41 返回的 candidateKey 可创建邀约,篡改/过期 key 不可创建;
3. 创建→学生列表/详情→接受→唯一站内会话完整主链路;
4. 拒绝与过期不创建会话;
5. 并发创建和并发决策逐请求校验 HTTP/status code 配对,无 500;
6. 同企业历史投递、关闭人才发现、账号注销后不可邀约;
7. 企业/学生/ADMIN/匿名/跨租户权限矩阵;
8. 企业响应、学生响应、audit_logs、history 全部脱敏;
9. `candidate_dimension_scores` 行数不变;
10. RabbitMQ 队列终态正常,V39–V41 关键接口回归;
11. 静态/运行时 OpenAPI 对账,最终必须 FAIL=0。
## 16. 文档与发布门禁
实现时新增/更新:
- `docs/enterprise-talent-invitation-api.md`;
- `docs/frontend-integration.md`;
- `docs/backend-development-roadmap.md`;
- 本设计文档状态;
- `docs/openapi-rc.json`;
- `docs/verify_openapi.py`。
发布顺序:编码与自动化 → OpenAPI 对账 → 真实 MySQL/Docker 黑盒 → 精确 Git 检查点。不得在黑盒未闭环时标记冻结。
## 17. 已确认的编码决策
1. 批准 Flyway `V40__enterprise_talent_invitations.sql` 和两张新表;
2. 批准 6 paths / 6 operations,预计 OpenAPI 167/205;
3. 只有学生 ACCEPT 后才创建 `TALENT_INVITATION` 站内会话;
4. candidateKey 采用“当前候选池重算 + 常量时间匹配”,不新增 key→个人详情接口;
5. 邀约有效期 7 天,同企业同学生终态后冷却 30 天;
6. 每公司每岗位每天最多 20 次,每公司每天最多 100 次;
7. message 为 1–300 字,禁止联系方式、URL、微信/QQ;
8. 使用错误码 10201–10209;
9. 本版不发送短信、邮件或 RabbitMQ 通知,仅在学生站内邀约列表展示;
10. 存在屏蔽关系时,学生 ACCEPT 返回 `403/2002`,邀约保持 PENDING,不泄露屏蔽方向。
上述决策均为编码硬边界;如真实代码与其存在关键冲突,开发者必须停止并报告,不得自行改变迁移号、接口、状态机或隐私范围。

439
docs/v42-implementation-instruction.md

@ -0,0 +1,439 @@
# V42 企业人才邀约与授权沟通——编码执行指令
开始实现 V42「企业人才邀约与授权沟通」。
权威设计文档:
```text
C:\Users\15642\Desktop\zhilai\docs\v42-enterprise-talent-invitation-design.md
```
请先完整阅读权威设计文档,再核查现有 V41 人才发现、V39 岗位画像、企业认证、账号注销、屏蔽关系、消息会话、审计及安全配置代码。设计中的 10 项产品决策已经负责人确认,禁止自行扩大或改变范围。
## 一、开始前只读核查
先执行并记录:
```powershell
git status --short
git diff --cached --name-only
git log -5 --oneline
```
当前 Git 基线应包含:
```text
5cc2c86 feat: deliver V41 enterprise talent discovery
2698d06 feat: deliver V40 company review replies
55e3975 feat: deliver V39 job recommendations
```
要求:
1. 确认当前最新 Flyway 是 V39;V42 使用 `V40__enterprise_talent_invitations.sql`。
2. 确认 V41 仅有 2 个 path / 3 个 operation,当前 OpenAPI 为 161 paths / 199 operations。
3. 确认 `CandidateKeyGenerator` 的域隔离串、日期桶、HMAC 算法和 URL-safe 无 padding 编码。
4. 确认 `TalentDiscoveryCandidateSource` 的资格过滤、同企业历史投递排除、账号注销排除和批量查询能力。
5. 确认现有 MessagingService 对企业—学生 DIRECT 会话要求 MATCHED 投递,不能为 V42 全局取消该守卫。
6. 确认 conversation、conversation_members、屏蔽关系、公司 owner、岗位状态、岗位画像状态的真实字段和仓库方法。
7. 确认现有审计服务中“成功审计”和“拒绝型安全审计”的事务语义。
8. 确认以下路径不存在:
- `POST /companies/me/jobs/{jobId}/talent-invitations`
- `GET /companies/me/talent-invitations`
- `GET /companies/me/talent-invitations/{invitationId}`
- `GET /profile/me/talent-invitations`
- `GET /profile/me/talent-invitations/{invitationId}`
- `POST /profile/me/talent-invitations/{invitationId}/decision`
如果设计文档与真实代码存在关键冲突,立即停止并报告;不得自行改接口、迁移号、状态机、错误码或隐私边界。
不得 reset、restore、checkout、删除或覆盖既有未跟踪文件;不得执行 `git add -A`、commit 或 push。
## 二、硬性业务边界
1. 企业只能使用 V41 当前返回的 opaque `candidateKey` 创建邀约,绝不接收 `studentUserId`。
2. candidateKey 不可逆;服务端必须在当前合格候选池中重新生成 key 并使用常量时间比较匹配。
3. 只有已认证 COMPANY、自己的 PUBLISHED 岗位、APPROVED 且 enabled 的岗位画像可以创建邀约。
4. 学生关闭人才发现、账号进入注销限制、与该企业已有任意历史投递、推荐信号全部失效时,不可创建邀约。
5. 只有学生 ACCEPT 后才建立 `TALENT_INVITATION` 站内会话。
6. REJECTED、EXPIRED 不创建会话。
7. 企业不得看到学生身份、联系方式、简历、测评明细、候选评分或 fitScore。
8. 本版不做企业撤回/编辑/删除、批量或自动邀约、联系方式申请、短信邮件、RabbitMQ 通知、ADMIN 审核、邀约曝光历史或 AI 文案。
9. 不修改 V1–V39;不改变 V35–V41 既有业务语义。
10. 不写 `candidate_dimension_scores`,不新增 feature flag。
11. 不启动或停止 Docker,不做真实 MySQL 黑盒。
12. 不提交、不推送、不进入 V43。
## 三、Flyway V40
新增:
```text
mydreamjob-server/src/main/resources/db/migration/V40__enterprise_talent_invitations.sql
```
新增两张表。
### 3.1 talent_invitations
至少包含:
- `id BIGINT AUTO_INCREMENT PRIMARY KEY`
- `company_id BIGINT NOT NULL`
- `job_id BIGINT NOT NULL`
- `student_user_id BIGINT NOT NULL`
- `candidate_key_digest CHAR(64) NOT NULL`
- `message VARCHAR(300) NOT NULL`
- `status VARCHAR(20) NOT NULL`
- `idempotency_key VARCHAR(64) NOT NULL`
- `active_key VARCHAR(200) NULL`
- `conversation_id BIGINT NULL`
- `expires_at DATETIME NOT NULL`
- `decided_at DATETIME NULL`
- `version BIGINT NOT NULL DEFAULT 0`
- `created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP`
- `updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP`
约束:
- FK company/job/student/conversation;
- `CHECK(status IN ('PENDING','ACCEPTED','REJECTED','EXPIRED'))`;
- `UNIQUE(company_id,idempotency_key)`;
- `UNIQUE(active_key)`;
- 建议 `UNIQUE(conversation_id)`,NULL 可重复;
- PENDING 时 `active_key=companyId:jobId:studentUserId`,终态必须置 NULL;
- 索引 `(student_user_id,status,created_at)`、`(company_id,status,created_at)`、`(job_id,status,created_at)`、`(status,expires_at)`;
- 不保存原始 candidateKey,只保存 SHA-256 十六进制摘要。
### 3.2 talent_invitation_history
至少包含:
- id、invitation_id、action、actor_user_id、created_at;
- action 仅 `CREATED/ACCEPTED/REJECTED/EXPIRED`;
- FK invitation,`ON DELETE CASCADE`;
- 索引 `(invitation_id,created_at)`;
- 不保存 message、candidateKey、studentUserId、联系方式、测评或评分。
Flyway 必须兼容 MySQL 8 与 H2 MODE=MySQL,不修改已执行迁移。
## 四、实体、仓库与内部组件
建议新增包 `com.mydreamjob.talentinvitation`,至少包含:
- `TalentInvitation`
- `TalentInvitationHistory`
- 两个 Repository
- `TalentInvitationCandidateResolver`
- `TalentInvitationConversationService`
- `TalentInvitationExpiryService`
- `TalentInvitationService`
- `TalentInvitationController`
- 白名单 DTO
要求:
- invitation 使用 JPA `@Version`;
- 状态和历史 action 使用字符串枚举;
- Entity 不得直接返回 HTTP;
- Repository 支持带 companyId/studentUserId 的归属查询、分页、条件状态更新、幂等查询、活跃/冷却/每日限额统计和批量过期;
- 列表批量加载岗位/公司/会话信息,不得逐条 findById;
- Controller 不写 SQL、不接收 companyId/studentUserId/ownerUserId/conversationId。
## 五、candidateKey 安全解析
`TalentInvitationCandidateResolver` 必须:
1. 单次请求生成一个 `capturedNow`;
2. 完成企业、岗位、画像门禁;
3. 复用 V41 候选资格和批量数据源,获取该岗位当前全部合格候选;
4. 使用 V41 完全相同的域隔离、companyId、jobId、studentUserId 和 `yyyy-MM-dd` 日期桶重新生成 key;
5. 使用 `MessageDigest.isEqual` 做常量时间字节比较;
6. 必须恰好匹配一个候选人;零个或异常重复均返回统一的候选不可邀约;
7. 解析成功后的 studentUserId 只能留在服务内部和数据库 FK 中,不得写日志、审计 details 或企业 DTO;
8. 禁止添加 candidateKey→学生详情接口,禁止普通 Base64 解码、可逆加密或字符串拼接;
9. 禁止读取 assessment/ability answers、attempt questions、question feedback、candidate scoring 表。
无效 key、授权关闭、注销限制、历史投递、信号失效对企业使用相同安全错误,不解释具体原因。
## 六、接口与权限
新增 6 paths / 6 operations:
```text
POST /companies/me/jobs/{jobId}/talent-invitations
GET /companies/me/talent-invitations
GET /companies/me/talent-invitations/{invitationId}
GET /profile/me/talent-invitations
GET /profile/me/talent-invitations/{invitationId}
POST /profile/me/talent-invitations/{invitationId}/decision
```
### 6.1 创建请求
```json
{
"candidateKey": "opaque-url-safe-value",
"message": "我们正在招聘后端开发实习生,欢迎了解岗位详情。",
"idempotencyKey": "3c989a31-6774-4520-9700-e58416cc1845"
}
```
校验:
- candidateKey 非空且长度不超过 128;
- message trim 后 1–300 字符;
- message 禁止手机号、邮箱、URL、QQ/微信等外部联系方式;复用现有安全规则的公共能力,禁止在 Controller 复制正则;
- idempotencyKey 为 UUID,最长 64;
- jobId > 0;分页 page>=0、size 1–50。
### 6.2 学生决策请求
```json
{
"decision": "ACCEPT",
"expectedVersion": 0
}
```
decision 仅 ACCEPT/REJECT,expectedVersion 必填且必须匹配。
权限矩阵:
- 企业接口:仅已认证 COMPANY,且仅本公司;STUDENT/ADMIN 403/2002,匿名 401;
- 学生接口:仅本人 STUDENT;COMPANY/ADMIN 403/2002,匿名 401;
- 跨公司岗位和邀约统一 403/2002;
- 他人学生邀约统一按不可见处理,不能泄露存在性。
## 七、状态、过期、冷却与限额
状态机:
```text
PENDING -> ACCEPTED
PENDING -> REJECTED
PENDING -> EXPIRED
```
- 三个终态不可再次改变;
- 默认有效期 7 天;`expiresAt <= capturedNow` 已过期;
- 读取和决策必须支持惰性过期;调度器只能改善及时性,不能承担正确性;
- 同企业同学生任何终态后 30 天内不能再次邀约;
- 每公司每岗位每天最多 20 次成功创建;
- 每公司每天最多 100 次成功创建;
- 所有限制使用数据库查询和索引,不依赖单机内存;
- 并发创建由 `UNIQUE(active_key)` 兜底;
- 状态决策采用 expectedVersion + status + expiresAt 条件更新;
- 并发 ACCEPT/REJECT/EXPIRE 只能一个胜者,无 500。
屏蔽关系:学生点击 ACCEPT 时若双方任一方向存在屏蔽,返回 `403/2002`,邀约保持 PENDING,不泄露谁屏蔽谁。
## 八、幂等与事务
创建:
- 同 companyId、idempotencyKey、jobId、candidateKeyDigest、message 返回原邀约;
- 同 companyId、idempotencyKey 但业务内容不同返回 409/10207;
- 同公司、同岗位、同学生已有 PENDING 返回 409/10203;
- 唯一键异常后不得在污染的 Hibernate Session 中继续查询;必要时使用 `REQUIRES_NEW` writer 和独立定判事务。
决策:
- 同目标终态幂等重放可返回原结果;
- 已是另一终态或版本冲突返回 409;
- 只有条件更新胜者写 history 和 audit;
- ACCEPT 必须在单一事务内完成状态、activeKey 清空、conversation、members、history、audit;任一步失败整体回滚;
- 重放和并发不得重复创建会话、成员、历史或审计。
## 九、接受后站内会话
- conversation.type=`TALENT_INVITATION`;
- 成员仅企业 owner user 与当前学生;
- 通过内部服务创建,绝不开放传 participantUserId 的 V42 公网入口;
- 每份 ACCEPTED invitation 只对应一个 conversation;
- 保留现有 DIRECT 会话 `MATCH_REQUIRED_FOR_CONVERSATION` 规则;只允许已 ACCEPTED 的 V42 内部流程创建 TALENT_INVITATION 会话;
- 会话标题使用岗位标题安全模板;
- 不自动把企业自由文案写成首条消息;
- 后续发消息继续遵守成员、屏蔽和内容规则。
## 十、DTO 与脱敏
企业 DTO 只允许:
- invitationId、jobId、jobTitle;
- candidateLabel=`开放人才候选人`;
- message、status、createdAt、expiresAt、decidedAt;
- conversationId 仅 ACCEPTED 后返回;
- 默认不回显 candidateKey。
学生 DTO 可包含企业公开名称、岗位公开信息、message、状态、期限、conversationId。
对企业和学生真实序列化 JSON 断言不得包含:
```text
studentUserId, ownerUserId, candidateId, phone, email, resumeUrl,
attemptId, resultId, answerKey, scoringRule, questionCode,
competencyTag, dimensionScores, overallScore, percentile, fitScore,
candidateKeyDigest, activeKey, password, token, secret
```
## 十一、错误码
在 ErrorCode 新增并同步 GlobalExceptionHandler:
- 10201 TALENT_INVITATION_NOT_FOUND → 404
- 10202 TALENT_INVITATION_CANDIDATE_UNAVAILABLE → 409
- 10203 TALENT_INVITATION_ALREADY_ACTIVE → 409
- 10204 TALENT_INVITATION_INVALID_STATE → 409
- 10205 TALENT_INVITATION_EXPIRED → 409
- 10206 TALENT_INVITATION_CONCURRENT_UPDATE → 409
- 10207 TALENT_INVITATION_IDEMPOTENCY_CONFLICT → 409
- 10208 TALENT_INVITATION_CONTENT_INVALID → 400
- 10209 TALENT_INVITATION_RATE_LIMITED → 429
不得新增其他 V42 错误码,不得改变既有错误码含义。跨角色与屏蔽复用 2002,基础参数非法复用 1001。
## 十二、审计与隐私
新增动作:
- TALENT_INVITATION_CREATED
- TALENT_INVITATION_ACCEPTED
- TALENT_INVITATION_REJECTED
- TALENT_INVITATION_EXPIRED
- TALENT_INVITATION_CREATE_REJECTED
成功审计 details 仅允许 invitationId、jobId、status、requestId。禁止记录 candidateKey/digest、studentUserId、message、联系方式、简历、测评、评分、IP/UA 明文或凭据。
拒绝型安全审计沿用独立事务,内容同样不得包含 candidateKey 或学生 ID。每次真实状态变化最多一条 history 和一条对应成功审计。
## 十三、查询与 N+1
- 企业列表按 companyId 分页;学生列表按 studentUserId 分页;
- 排序 `createdAt DESC, id DESC`;
- 当前页岗位、公司、会话信息批量读取;
- 不逐邀请查询 Job/Company/User/Conversation;
- 详情查询直接绑定归属条件;
- 批量过期固定批次条件更新;
- 测试用 Spy/统计断言查询次数不随 items 数线性增长。
## 十四、必须新增测试
至少新增:
1. `TalentInvitationCandidateResolverTest`
- 正确、篡改、过期日期桶 key;
- 常量时间比较;
- 授权/注销/历史投递/信号变化后的重新校验;
- 其他企业投递不误排;
- 无答案表、题目表、候选评分查询;
- 批量查询无 N+1。
2. `TalentInvitationServiceTest`
- 创建、文案安全、幂等和冲突;
- 活跃唯一、30 天冷却、20/100 日限额边界;
- 惰性过期;
- ACCEPT 唯一会话、REJECT/EXPIRE 无会话;
- 并发创建和并发决策无 500;
- 重放不重复 history/audit/conversation;
- 屏蔽关系保持 PENDING。
3. `TalentInvitationControllerTest`
- COMPANY/STUDENT/ADMIN/匿名和跨租户矩阵;
- 参数校验;
- 企业/学生真实 JSON 脱敏;
- 不存在详情解锁、联系方式或直接会话接口。
4. `TalentInvitationPersistenceIntegrationTest`
- V40 迁移、FK、CHECK、UNIQUE、@Version;
- activeKey 终态释放;
- conversation 唯一;
- history 不含敏感列;
- 并发创建/决策;
- 列表 N+1;
- candidate_dimension_scores 不变。
5. 回归测试
- V41 推荐/授权;
- V39 岗位画像;
- V40 评价回复;
- DIRECT 会话仍要求 MATCHED;
- 只有 ACCEPTED 邀约可创建 TALENT_INVITATION 会话;
- 屏蔽与消息权限不变。
## 十五、SecurityConfig、OpenAPI 和文档
SecurityConfig 仅新增:
- `/companies/me/**/talent-invitations/**` → COMPANY;
- `/profile/me/talent-invitations/**` → STUDENT。
路径匹配必须覆盖实际 6 个 operation,但不能误放宽其他 `/companies/me/**` 或 `/profile/me/**` 接口。
新增:
- `docs/enterprise-talent-invitation-api.md`
更新:
- `docs/frontend-integration.md`
- `docs/backend-development-roadmap.md`
- `docs/v42-enterprise-talent-invitation-design.md`
- `docs/openapi-rc.json`
- `docs/verify_openapi.py`
OpenAPI 目标:从 161/199 增加 6 paths / 6 operations,预计 **167 paths / 205 operations**,以实际结果为准。
校验脚本必须断言:
- V42 恰好 6 paths / 6 operations;
- POST 请求均有非空 requestBody schema;
- 6 个成功响应均有非空 schema;
- 企业/学生 schema 不含敏感字段;
- 不存在候选详情、联系方式解锁、企业直接建会话、撤回/编辑/删除/批量邀约接口;
- OpenAPI 总数与实际一致。
## 十六、自动化验证命令
依次执行并记录真实结果:
```powershell
mvn -Dtest="*TalentInvitation*Test,*TalentDiscovery*Test,*Messaging*Test" test
mvn test
mvn clean verify
python docs/verify_openapi.py
```
若 `clean verify` 因运行中的 JAR 锁定 target 失败,不得结束未知进程;只报告 PID、端口和阻塞原因,等待负责人确认。
## 十七、最终停止条件
完成代码、测试、OpenAPI 和文档后立即停止:
- 不启动/停止 Docker;
- 不做真实 MySQL 黑盒;
- 不提交或推送 Git;
- 不执行 `git add -A`;
- 不删除未跟踪文件;
- 不进入 V43。
最终报告必须包含:
1. 精确修改文件;
2. Flyway V40 在测试环境的执行情况;
3. 实际测试数、Failures、Errors、Skipped;
4. OpenAPI 实际 paths/operations;
5. candidateKey 重算匹配与失效重新校验证据;
6. 7 天过期、30 天冷却、20/100 限额结果;
7. 并发创建/决策及会话唯一性;
8. 企业/学生权限矩阵;
9. 完整 JSON、审计、history 脱敏结果;
10. N+1 查询证明;
11. DIRECT 与 TALENT_INVITATION 会话规则回归;
12. 是否写 `candidate_dimension_scores`;
13. 是否修改 V1–V39;
14. 是否执行 Docker/MySQL 黑盒;
15. 当前 git status;
16. 明确说明未执行 `git add -A`、commit、push。
任何失败或 SKIP 必须如实报告,不得伪造 PASS。

173
docs/v43-candidate-scoring-fairness-design.md

@ -0,0 +1,173 @@
# V43 候选评分公平性与稳定性监测:修订设计
文档类型:解释与技术参考。读者:负责人、后端开发及验收人员。
状态:独立代码复验与真实 MySQL/Docker 验收通过,待 Git 检查点。
编码前基线:V42,最新已核查迁移 V40,OpenAPI 167 paths / 205 operations;测试维护提交 498512d。实现后静态 OpenAPI 为 172 paths / 210 operations,自动化验证结果以本轮报告为准。
## 1. 第一版范围与修订结论
第一版只监测现存评分数据质量、分布、规则版本构成和申诉流程。仅 ADMIN 可读,无自动干预,无新 Flyway、表、调度器、feature flag 或公共错误码。不写 candidate_dimension_scores,不调用会触发评分重算/upsert的服务,不改变排名或申诉状态,不读取题目、答案、sub_scores_json、测评详情或 audit details,不接入 AI。
不推断性别、民族、年龄、家庭背景,不使用学校、城市、头像或姓名作为代理分组。结果不是人口统计公平性证明,也不能说明某条评分规则合理。置信标记只是运营样本等级,不是统计置信区间。
保留五个原提案 ADMIN GET 接口以保持监测契约;drift 和 ranking-stability 第一版明确不可用。不提供 jobId/companyId 或任意维度组合筛选;无 ACKNOWLEDGE/DISMISS,人工处置后续单独评审。实际编码前如发现与其他已批准文档冲突,先报告冲突。
## 2. 真实数据证据与可计算边界
| 来源/组件 | 实际结构或行为 | V43 口径 |
|---|---|---|
| candidate_dimension_scores / CandidateDimensionScore | UNIQUE(student_user_id,dimension,period);score/status/weight/rules_version/calculated_at;period为yyyy-MM | 指定period在本次读取时仍存在的快照 |
| CandidateScoringCalculator.upsertSnapshots | ON DUPLICATE KEY覆盖分数、状态、规则版本、calculated_at;治理可重写历史period | calculated_at不是不可变评分事件时间;不据此重建过去窗口 |
| CandidateScoringCalculator.overallScoreFromSnapshots | OK/ESTIMATED且非空分数的等权算术平均,2位HALF_UP | 只读复用纯计算语义;不是任取权重的第二套公式 |
| CandidateScoringEngineService / GovernanceService | 评分查询/重算可能写快照 | 不作为V43读取入口 |
| candidate_scoring_appeals | PENDING/APPROVED/REJECTED/CANCELLED/ROLLED_BACK;created_at、reviewed_at、rollback_at、rules_version | 申诉创建队列当前状态,不冒充窗口结束时状态 |
| candidate_scoring_appeal_history | 状态转换、操作人、原因 | 本版无需读原因或操作人 |
| candidate_scoring_snapshot_history | ROLLBACK的before/after_score和before/after_rules_version | 仅已记录回滚变分,非全部纠错历史 |
| CompetitionRankingCalculator/Service | 按学生选最新period快照,排除WITHDRAWN/REJECTED;无持久化rank历史 | 当前无分候选可以在候选池,但rank=null;不能把入池当异常排名 |
| applications/jobs/companies | 可关联岗位与企业,但会引入重复候选和切片风险 | 本版不连接作筛选 |
| V37风险治理 / 企业评分Dashboard | 安全事件与业务聚合服务 | 不复用风险事件表,不复制企业个人级DTO |
| SecurityConfig / ErrorCode / GlobalExceptionHandler | 既有ADMIN认证、403/2002、参数400/1001约定 | 后续编码精确复用,验证真实响应 |
现有迁移索引:V24有(student_user_id,dimension,period)唯一键、(student_user_id,period)、(dimension,period);V25申诉有(candidate_user_id,status)、(status,created_at),回滚历史有(candidate_user_id,dimension,period,created_at)。这些并不保证全局period或created_at聚合高效。上线前必须做真实EXPLAIN,不能仅因存在索引宣称覆盖。
可以计算:指定period的现存快照质量、分布、版本构成;指定创建窗口申诉的当前流程情况;已记录回滚的变分情况。
不可计算:过去某时刻真实分布、等长历史分布漂移、长期PENDING持续时长、系统全体学生覆盖率、评分曝光口径的真实申诉率、实际历史rank变化、Top N进出率、“无有效评分却曾获rank”的事件数。统一用UNAVAILABLE及明确原因,不以0代替。
匿名聚合快照未来可支持分布趋势,但不能支持同一个人的rank变化或Top N成员进出;后者需另行评审受控关联标识和候选级历史,不能声称匿名汇总可以恢复。
## 3. 新实施计划
1. 固定现存快照样本、分母和统计纯函数;先写包含手算结果的测试。
2. 实现受控只读聚合查询、混合版本检测和小样本抑制。
3. 实现申诉队列状态及回滚变分指标;区分审核通过与实际变分。
4. 接入ADMIN白名单DTO、五个接口及权限。
5. 更新OpenAPI与联调文档,跑专项、全量和clean verify。
6. 停止,报告待真实MySQL/Docker验收;后续独立验证EXPLAIN、性能、权限与无写入,再决定Git检查点。
## 4. 样本与分母
### 4.1 评分样本
period必填,yyyy-MM严格有效且不得晚于capturedNow所在月。统计一个period,禁止拼接多个period充足样本。响应声明basis=CURRENT_STORED_SNAPSHOTS、capturedAt和period,历史period也表示现在所存数据,不是当时数据。
U为该period至少有一条快照的独立student_user_id集合;N=COUNT(DISTINCT student_user_id)。不包含完全没有快照的学生,响应scope=SCORED_SNAPSHOT_POPULATION。八维固定为EDUCATION/INTERNSHIP/ABILITY/COMPLIANCE/STABILITY/GROWTH/INTEGRITY/ACTIVITY。
对每维d:
- rowCount为有该维行的候选数;missingRowCount=N-rowCount。
- 各状态占比=statusCount/N;nullScoreRate为有行且score=null人数/N;missingRowRate为missingRowCount/N,两者不得混淆。
- 有效分:状态OK或ESTIMATED且分数在[0,100]。validCoverage=validCount/N。
- OK或ESTIMATED但空分、不可用状态却非空分、未知状态、越界分、未知规则版本单独计数,不修正原表。
- 统计分布的sampleSize=有效分对应的独立候选数。不能用八维行数当人数。
总览:至少一个有效维度的候选数/N,命名computableSnapshotRatio。缺失不当0。
sampleSize不足30的总体/切片隐藏比例和异常明细,仅返回人数、状态与原因。
### 4.2 综合分和规则版本
按同一候选、同一period组装全部现存八维,不跨月补齐。
规则版本统一性检查涵盖所有现存维度行,包括PENDING:
- 恰好同一已知版本:按纯函数对有效维度求平均,2位HALF_UP;至少一个有效维度即可计算。
- 多版本:MIXED;单独聚合候选数,排除V43综合分分布,不改V32的实际计算。
- 单一未知版本:UNKNOWN;显示经脱敏的UNKNOWN汇总,不回原始异常字符串,不生成版本比较。
- 已知版本v0.1/v0.2分别分组。综合分响应标明coherentVersionSubset;不能声称覆盖所有实际排名候选。
- 维度分布按该行版本独立分组;这与综合分的全候选一致性检查分开。
不开放rulesVersion过滤参数,返回固定两种已知版本及UNKNOWN/MIXED汇总,避免构造自由切片。dimension可选但仅允许八维白名单。
## 5. 统计公式与输出
有效分排序x1…xn:
- mean=Σx/n。
- 分位数采用线性插值:h=(n-1)p,i=floor(h),f=h-i;Qp=x(i+1)+f*(x(min(i+2,n))-x(i+1)),p取0.1/0.25/0.5/0.75/0.9。
- 总体标准差=sqrt(Σ(x-mean)^2/n),不是样本标准差。中间BigDecimal使用DECIMAL128;最终2位HALF_UP。极小负数舍入误差应有明确容差处理,不吞掉其他错误。
- min/max来自有效分,空集合全部null。
- 十个桶:[0,10)、[10,20)…[80,90)、[90,100],9.5归首桶,100归最后桶。
- 比例=100*分子/分母,最终2位HALF_UP;分母0返回null与不可用原因,不能返回0%。
- 风险比较使用未四舍五入的精确比例/数值,展示再舍入,避免69.995%显示70.00却触发70%边界。
- 分桶集中率=max(bucketCount)/n。n>=30时>=70% WARNING、>=85% HIGH,优先HIGH;此为运营提示,不是歧视结论。
confidence:独立人数0–29 INSUFFICIENT_SAMPLE、30–99 LOW_CONFIDENCE、>=100 NORMAL_CONFIDENCE;riskLevel采用NORMAL/WARNING/HIGH/INSUFFICIENT_SAMPLE。UNAVAILABLE是availability字段,不混为风险级别。
不适用的风险字段为null。缺失率增加15/25pp、均值差8/12分、纠正率20/35%仅保留后续提案,本版不启用,因为历史/分母尚不支持。
完整性异常必须分开呈现:`inconsistentRecordCount`(状态与分值是否为空不一致)、`unknownStatusCount`(未知状态)和 `outOfRangeScoreCount`(非空但不在0–100)。任何一个细分异常及其真实集合补集进入同一独立候选人抑制判断,不能用字段缺失或固定0代替。分布桶触发抑制时连同该分布的 `sampleSize`、统计量和风险一并隐藏。
不使用PSI或KS,不预留名为已计算但填0的假指标。
结构异常只作完整性统计;异常计数存在不等于HIGH,第一版只对上述集中率给风险等级。
## 6. 申诉流程与变分
只允许已结束的完整业务日窗口from/to(Asia/Shanghai,日期,to不含);默认最近30个完整日,最大90日,from<to<=今天。只查创建时间在[from,to)的申诉,称“该创建队列截至capturedAt的当前状态”;不是历史日终状态。重复请求可因后续审核而变化,响应写明该限制。
- appealCount=该创建队列申诉记录数;sampleSize=独立申诉人数。
- 五种状态计数;approvedEverCount=APPROVED+ROLLED_BACK,不能漏掉已回滚的审核通过。
- 审核通过率=(APPROVED+ROLLED_BACK)/(APPROVED+ROLLED_BACK+REJECTED)。
- 回滚执行率=ROLLED_BACK/(APPROVED+ROLLED_BACK)。
- 重复申诉人数=同候选同dimension同period在此队列有>=2条记录的独立候选人数;重复率=该人数/此队列独立申诉人数。明确这是窗口内重复,不是终身重复。
- actualScoreChangeRate:只关联该队列的action=ROLLBACK历史;按appeal_id预聚合后JOIN,防止状态计数被历史行放大。同一appeal多条ROLLBACK视为该申诉不可比,但不排除同一候选人的其他合法申诉;禁止任取首条或末条。
- 有且仅一条历史、前后分数均非空且在范围内、前后规则版本相同且已知,才进入comparableRollbackCount。
- changedCount为after_score!=before_score;actualScoreChangeRate=changedCount/comparableRollbackCount。
- delta=after-before,展示meanDelta和meanAbsoluteDelta;0差也计入分母。前后null单列不可比,规则不同单列versionChanged,不把规则升级变分称为纠错。
- 审核通过、回滚执行、分数变化均不能独立证明原评分错误。该指标名称不得写为“纠错准确率”。
- 每种比率除事件分母外,必须检查其实际分母集合的独立人数>=30;同一候选人的多份合法申诉可贡献多条行,但候选人数只计一次;不足则抑制。
- 真实申诉率需要可比的受评/曝光人群分母,本版UNAVAILABLE,不用当前快照数除历史申诉数。
第一版不返回申诉dimension/rulesVersion切片,避免样本稀疏和自由组合;后续另行评审。只读取scalar所需字段,不取reason、decision_reason、reviewer_id、actor_user_id、audit details。
## 7. 接口契约
| GET路径 | 参数 | 返回核心 |
|---|---|---|
| /admin/scoring/fairness/overview | period | 现存快照人数、完整性、综合分可计算比例、版本构成、综合分分布 |
| /admin/scoring/fairness/dimensions | period、可选dimension | 固定八维及已知版本的覆盖率、状态、分布 |
| /admin/scoring/fairness/drift | 无业务参数 | availability=UNAVAILABLE、reason=IMMUTABLE_SCORE_HISTORY_NOT_AVAILABLE |
| /admin/scoring/fairness/ranking-stability | 无业务参数 | availability=UNAVAILABLE、reason=RANKING_SNAPSHOT_HISTORY_NOT_AVAILABLE |
| /admin/scoring/fairness/appeals | 可选成对from/to | 创建队列当前状态、审核/回滚/变分指标 |
接口沿用ApiResponse,缺失指标为null附reason;样本不足使用正常200,不伪造零值。非法/未知查询参数按400/1001处理(包括jobId/companyId/rulesVersion/granularity),防止“参数被忽略”误解。非法角色403/2002,匿名401。
固定五个path/五个operation,若基线167/205不变则目标172/210。选择保留两个能力不可用接口是为了明确监测能力缺口,不代表历史监测实现。前端需显示“暂无历史数据”,不能显示正常绿色。
## 8. 隐私与查询限制
所有切片依据独立人数做门槛检查。主群体不足30时隐藏均值、极值、标准差、分位数、桶、比例、状态/版本/异常明细。只回sampleSize、availability和原因。
人数足够也需单元格抑制:桶、状态、版本、异常计数为1–29时隐藏计数及比例;总计与其他公开单元格能反推隐藏单元时做互补抑制(实现可直接抑制整组明细)。为减少小数精度恢复个体信息,包含被抑制桶的分布组一并隐藏其mean/min/max/stddev/quantiles和集中风险。这是保守聚合保护,不宣称差分隐私保证。
版本分布按“版本总体—该版本有效集合—该版本无效补集”独立判定:某个版本的有效集合或补集不足30人时,该版本的Distribution返回`SUPPRESSED`且不含任何数值统计;其他满足门槛的版本仍可返回可用分布。只要存在版本级受保护单元,`versionCounts`及其可推算的整体版本计数一并隐藏;若全局总体本身触发抑制,则整个版本分布组隐藏。这样既不暴露99/1版本内补集,也不因一个不安全版本而无差别吞掉安全的70/30版本统计。
相邻窗口/月份集合仍可能差分推断。第一版限制参数组合、仅完整日窗口、复用ADMIN访问限流;不提供候选下钻、导出或自由groupBy。该方案不是任意重复查询下的匿名性证明。
禁止学生/企业及公开入口,禁止所有个人ID、姓名、学校、联系方式、性别/年龄、简历、测评、题目、答案、审计详情和凭据出现在DTO、响应和应用日志。内部COUNT DISTINCT可使用用户ID,但不得从Repository返回到HTTP。
## 9. 查询实现与性能
每请求同一capturedNow和同一只读事务;不得调用computeAll、getOverview或其他可能重算的既有入口。
- 快照总体:period过滤,SQL按学生内部去重,生成N;使用固定八维集合LEFT JOIN补missingRow。
- 维度状态:GROUP BY dimension,rules_version,status;可比得分GROUP BY dimension,rules_version,score形成频数。
- 分位数/标准差从score频数累积计算,不展开为每候选列表;中间数据仅score+frequency。
- 综合分可通过SQL按候选period作无身份返回的内部聚合,再输出score频数;必须与既有纯函数对账,不能改变生产排名公式。
- 申诉:各分母集合COUNT DISTINCT,回滚历史先按appeal预聚合并形成候选级聚合记录;固定次数查询,不逐人/逐维查询,不把重复历史申诉关联的候选人从其他合法申诉中整体剔除。
- 建议预算总览<=4、维度<=3、申诉<=5、不可用接口0条业务查询;权限查询单列计数。测试在30/300/3000人及8维下验证。
- 对真实MySQL核查period、created_at扫描/排序/临时表、执行耗时与数据量。最大90日不等于行数上限;建议查询timeout=5秒、已有错误映射返回失败且不截断样本,不伪装无数据。性能不达标先报告,不擅自新增索引/迁移。
- 现有索引不足的迁移建议必须另行批准;本版绝不修改V1–V40。
## 10. 测试与验收计划
1. 0/29/30/99/100独立人数;同一人8维和多period不凑30人。
2. 缺行与空分分别统计;全无快照人不在分母且scope准确。
3. 9.5/10/89.99/90/100分桶、分位数手算样例、总体标准差、HALF_UP。
4. 零分母null;精确70/85%风险边界;69.995%不提前升级。
5. OK/ESTIMATED空分、PENDING非空、越界、未知状态、未知版本。
6. MIXED候选排除V43综合分;与生产纯函数同版本结果对账。
7. 同月upsert后记录变化,drift仍UNAVAILABLE,不伪造之前分数;历史月也声明现存快照。
8. APPROVED但未回滚、ROLLED_BACK但分不变、真实变分、跨规则/空分、重复回滚历史、JOIN不放大。
9. 申诉队列状态变更后口径正确;重复申诉去重;事件数>=30但人数<30仍抑制。
10. 小样本和互补抑制,响应不出现被抑制明细或可反推比例。
11. ADMIN/匿名/STUDENT/COMPANY;真实HTTP JSON脱敏;不直接返回Entity。
12. 非法period/未来period/非法dimension/未知参数/超90日/from>=to/仅一个日期参数。
13. 固定查询次数、无答案/JSON明细表读取;无新业务查询的UNAVAILABLE接口。
14. candidate_dimension_scores及申诉/审计/风险表前后内容与版本不变(不能只测行数)。
15. V25/V32/V37/V38/V39/V41/V42回归;OpenAPI静态/运行时172/210对账。
16. 真实MySQL EXPLAIN、并发只读查询一致性、超时不返回假成功。
四项阻断的针对性补证已完成:正式 Service 在30/300/3000人下业务SQL为2/2/1/0/0;精确60人61份申诉HTTP夹具、五接口逐一完整内容只读检查及综合分舍入/PENDING版本对账均已通过。独立代码复验、真实 MySQL/Docker、运行时 OpenAPI、EXPLAIN 和 30/300/3000 人性能验收均已执行;EXPLAIN 暴露的全表扫描/filesort 作为后续容量风险保留。历史漂移与排名稳定性继续按 UNAVAILABLE 返回。

111
docs/v43-implementation-instruction.md

@ -0,0 +1,111 @@
# V43 给 Luna 的编码指令(整改版)
实施状态:实现完成,独立代码复验与真实 MySQL/Docker 验收通过,待 Git 检查点。
### 本轮四项阻断的可执行证据
- `FairnessMonitoringPersistenceIntegrationTest.realServiceJdbcBudgetAt30_300_3000WithEightDimensionsAndMultipleAppeals`:Spring 管理的真实 Service 事务代理、真实 Repository/权限仓库和 `@Primary` JDBC 计数数据源;30/300/3000 人、每人八维和两份合法回滚申诉。业务查询分别为 overview=2、dimensions=2、appeals=1、drift/ranking-stability=0;权限查询逐接口单列为2。夹具与验证查询不在计数区间。每个版本仅返回一个同分频数,申诉返回最终汇总。
- `FairnessMonitoringHttpIntegrationTest.realHttpAppealsHidesSmallComparableChangeGroup`:60 人各一份非法分数回滚,其中一人在 GROWTH 另有合法回滚;真实 SQL 断言61份申诉、60人、60人非法分数、1条/1人可比、重复申诉人数0,再通过真实 HTTP 检查变分统计全部隐藏。
- 同一非空申诉夹具中,五个接口各自独立比较调用前后完整表内容(包含 version),涉及评分、申诉、申诉历史、回滚历史、非空回滚限流、审计和四张风险治理表。原全局99/1测试亦在两个接口之间分别比较。
- `FairnessMonitoringPersistenceIntegrationTest.sqlOverallMatchesPureFunctionForPartialRepeatingHalfUpAndPendingVersions`:1/2/3个有效维度、空分PENDING、10.005的HALF_UP、10.003333…及10.006666…;SQL结果逐例与既有评分纯函数对账。仅修改PENDING行版本即判MIXED,仍保留有效覆盖人数。
以上为 H2 自动化证据,不代表真实 MySQL EXPLAIN、运行时 OpenAPI 或性能验收通过。
2026-09-09 最终复验:V43专项60/60;全量 `mvn test` 与 `mvn clean verify` 均1535项,Failures=0、Errors=0、Skipped=0,构建成功。OpenAPI静态及运行时均为172 paths / 210 operations。真实MySQL完成权限、隐私、只读性、查询预算与30/300/3000人规模验收;未提交或推送。
文档类型:执行指南。先完整阅读同目录v43-candidate-scoring-fairness-design.md修订版;不要沿用旧版“calculated_at可恢复历史漂移”的结论。
## 1. 任务与基线
项目C:/Users/15642/Desktop/zhilai,实现V43候选评分公平性与稳定性监测。
先读取项目适用AGENTS.md,执行git status --short、git diff --cached --name-only、git log -5 --oneline;核查最新迁移和OpenAPI。已知维护提交498512d、最新业务V42、Flyway V40、OpenAPI167/205,均须实测而非假设。
保护现有暂存与未跟踪文件。禁止reset/restore/checkout、git add -A、commit、push、删除移动历史文件。若已有V43实现,先评审差异,不覆盖他人修改。
本任务授权代码、自动化、正式文档;实现完成后需停止在本版本,不启动/停止Docker,不执行真实MySQL黑盒,不结束未知Java进程,不进入V44。当前实现结果须以自动化验证报告为准,真实黑盒仍待负责人另行安排。
## 2. 实现边界
无新增Flyway、数据库表、后台任务、feature flag、公共错误码,不改V1–V40迁移。
只允许ADMIN聚合查询,无学生/企业入口,无写入、自动调分/排名/权重/封禁/申诉处理。
不读答案、题目、测评详情、sub_scores_json、audit details,不接AI。
不得调用CandidateScoringEngineService.getOverview、computeAll、upsertSnapshots等可能写快照入口。只读纯函数可以复用。
学校/城市/姓名不得作为敏感属性代理。
## 3. 接口范围
新增五个GET:
- /admin/scoring/fairness/overview:period必填。
- /admin/scoring/fairness/dimensions:period必填,dimension可选八维白名单。
- /admin/scoring/fairness/drift:无业务参数,固定UNAVAILABLE。
- /admin/scoring/fairness/ranking-stability:无业务参数,固定UNAVAILABLE。
- /admin/scoring/fairness/appeals:成对可选from/to日期,默认最近30个完整业务日,最大90日。
drift原因IMMUTABLE_SCORE_HISTORY_NOT_AVAILABLE;排名原因RANKING_SNAPSHOT_HISTORY_NOT_AVAILABLE。
无数据、不可计算、小样本是不同状态,不全部返回0或NORMAL。
匿名401,其他角色403/2002;参数400/1001。拒绝jobId/companyId/rulesVersion/granularity及其他未知业务参数。
如果实际基线167/205,最终172/210;不得通过删路径满足数字。
## 4. 必须先落实的统计规则
以设计第4–6节为唯一口径,写测试后实现:
1. period指定一个月份。快照是“当前所存版本”,calculated_at会被覆盖,不支持历史窗口恢复。
2. N是该period至少一行快照的独立学生数。无快照全体学生不在范围,响应明确scope。
3. 每维缺行、空分、有效分、状态分别统计,分母和去重按设计,不把八维行数当人数。
4. 分布有效分为OK/ESTIMATED且0–100。统计sampleSize是独立人数。
5. 同候选所有现存维度跨规则版本即MIXED,排除V43综合分分布;单一未知版本归UNKNOWN。其计数受小样本保护。
6. 同版本综合分对有效维度等权平均,2位HALF_UP,与现有overallScoreFromSnapshots测试对账,不改V32。
7. 分位数线性插值、总体标准差、十个连续小数分桶、零分母null;内部DECIMAL128,输出2位HALF_UP。
8. 风险先用未舍入值比较。只启用分桶集中70/85%运营提示。旧版缺失率差、均值差、纠正率阈值本版不启用。
9. 不足30独立人抑制明细;30–99 LOW_CONFIDENCE,100以上NORMAL_CONFIDENCE。
10. 1–29的桶/状态/版本/异常计数抑制整组,防止补集反推。被抑制桶的关联均值/极值/分位数/集中风险也隐藏。
11. 分别使用availability/confidence/riskLevel,不把UNAVAILABLE放入风险等级。
12. 维度DTO必须独立返回 `inconsistentRecordCount`、`unknownStatusCount` 和 `outOfRangeScoreCount`;分布一旦触发桶/补集抑制,`sampleSize` 与所有统计字段、分桶及风险均置空,只保留availability/reason。
## 5. 申诉与回滚
按created_at选[from,to)创建队列,展示截至capturedAt的当前状态,不叫历史日终状态。
APPROVED+ROLLED_BACK才是审核通过数。分别实现通过率、回滚执行率、实际变分率,严禁三者互换。
申诉主表先统计,历史先按appeal_id聚合后关联,不因JOIN重复计数。
仅唯一ROLLBACK历史、前后分非空有效、前后同一已知规则版本的申诉进入可比变分分母;重复历史只排除该申诉,不排除同一候选人的其他合法申诉。变分率包括0差的分母;规则升级不算纠错。
每个比率同时校验该分母人群>=30独立人。重复申诉按同人同维同period的窗口内记录判断。真实曝光申诉率UNAVAILABLE。
不读原文、管理员身份;不提供申诉自由切片。
## 6. 组件与性能
建议新建独立scoringmonitoring包:Controller、Service、只读查询组件、纯统计计算器及白名单DTO;命名遵循仓库惯例。
SQL参数绑定;GROUP BY聚合、score频数而非全量个人实体;固定查询次数,不能逐维或逐候选findById。
总览<=4、维度<=3、申诉<=5条业务查询为目标预算,角色查询单计;UNAVAILABLE接口0条业务查询。同一请求capturedNow、同一只读事务。
实现可解释超时处理,不能截断样本返回成功。真实EXPLAIN在后续黑盒验证;不擅自补索引或迁移。
小样本统计数值不得绕过统一抑制组件从另一个 DTO 字段泄露。
整改实现要求由独立、可测试的 `FairnessDisclosureGuard` 统一执行小样本与互补抑制;有效集合或其补集为 1–29 人时,相关整组统计字段必须一并隐藏。
不回任何个人/申诉/答卷/结果ID、联系方式、学校、性别、简历、答案或内部JSON。完整序列化测试,不能只检查toString。
## 7. 自动化
必须完整实现设计第10节测试。尤其:
- 多行同一人不能绕过30人门槛;
- 9.5/100分桶、手算分位数和标准差、69.995%风险边界;
- 混合版本与未知版本;
- 同月覆盖不能生成历史漂移;
- APPROVED未回滚、回滚0差、跨版本、空分、重复历史和JOIN放大;
- 多次申诉不足30独立人;
- 互补抑制、非法筛选、权限和完整JSON脱敏;
- 快照和申诉表内容/版本不变,而非只测行数;
- 无N+1及既有V25/V32/V37/V38/V39/V41/V42回归。
## 8. 文档、验证与停止
更新本设计实施状态、路线图、frontend-integration.md;新增docs/candidate-scoring-fairness-api.md。编码完成后才更新openapi-rc.json和verify_openapi.py:
五个GET恰好5path/5operation;全部200响应有真实schema;查询参数准确;schema不含个人敏感字段;两个不可用接口契约准确。
执行:
~~~powershell
mvn -Dtest="*Fairness*Test,*ScoringMonitoring*Test" test
mvn test
mvn clean verify
python docs/verify_openapi.py
~~~
测试通配符必须匹配实际新增类,不得以0测试成功。clean verify若被运行JAR阻塞,报告已核实PID和原因,不擅自停止。
报告真实测试总数、Failures/Errors/Skipped、精确文件清单、无新增迁移、OpenAPI计数、统计口径及数据限制、小样本保护、无写入和查询预算证据、Git状态。
真实MySQL/Docker未执行必须写待验收,运行时OpenAPI未启动核验不得声称通过。
完成后停止,保持所有本轮变更未暂存,未提交未推送。

147
docs/v43-independent-mysql-acceptance-20260909.md

@ -0,0 +1,147 @@
# V43 候选评分公平性与稳定性监测:独立复验与真实环境验收
日期:2026-09-09
结论:**PASS,可进入精确暂存检查点**
## 1. 验收范围与 Git 基线
本轮只做独立代码复验、自动化验证和真实环境验收;未修改 V43 生产代码、测试、Flyway、OpenAPI 或既有业务数据。
- HEAD:`498512d test: stabilize career progress date-sensitive test`
- 最近提交:V42 `96b3113`、V41 `5cc2c86`、V40 `2698d06`、V39 `55e3975`
- 暂存区:为空
- 当前 tracked 修改:`docs/backend-development-roadmap.md`、`docs/frontend-integration.md`、`docs/openapi-rc.json`、`docs/verify_openapi.py`
- V43 生产代码、测试和文档:未跟踪文件/未提交状态
- 历史验收脚本、日志、请求体、临时文件和 `.env` 相关文件均保留,未删除、移动或覆盖
- `git diff --check`:无错误,仅报告现有文件的 LF/CRLF 提示
验收阶段未执行 `git add -A`、commit、push、reset、restore、checkout,也未启动或停止 Docker Desktop;使用负责人已启动且健康的 MySQL、Redis、RabbitMQ 容器。最终复验时最新 Java 应用在 8080 运行且健康检查为 HTTP 200,后续是否停止由负责人决定。
## 2. Flyway、OpenAPI 与代码结构核查
- 迁移目录最新文件:`V40__enterprise_talent_invitations.sql`
- 未发现 V41/V42/V43 新 Flyway
- 静态 OpenAPI:**172 paths / 210 operations**;`python docs/verify_openapi.py` 全部通过
- V43 保留且仅保留五个 ADMIN GET:
- `/admin/scoring/fairness/overview`
- `/admin/scoring/fairness/dimensions`
- `/admin/scoring/fairness/drift`
- `/admin/scoring/fairness/ranking-stability`
- `/admin/scoring/fairness/appeals`
- 五个接口均有非空 200 响应 schema;静态校验覆盖聚合响应结构和 V43 路径计数
生产包 `com.mydreamjob.scoringmonitoring` 中未发现写入、删除、候选评分重算/upsert、排名重算或 Controller 外的公开接口。V43 查询不写 `candidate_dimension_scores`,也不发送消息。
## 3. 阶段 A:独立代码审查
### A1 只读边界:PASS(静态 + H2)
Controller 只有五个 GET。`drift` 和 `ranking-stability` 直接返回 `UNAVAILABLE`;服务不会调用评分重算/upsert。H2 HTTP 集成测试逐接口比较了相关评分、申诉、历史、审计和风险表的内容/版本,未发现 V43 写入。
### A2 无双轨实现:PASS(静态 + 测试)
`FairnessMonitoringService.overview` 仅调用 `findOverviewAggregateData`,空结果由正式聚合对象处理;`appeals` 仅调用 `findAppealAggregateSummary`。未发现“聚合为空时回退明细查询”的生产分支。Repository 返回总体/版本聚合、受控分组和 score-frequency,而不是候选人或申诉明细。
### A3 小样本抑制:PASS(H2 独立复现)
`FairnessDisclosureGuard` 对总体、有效集合、补集、版本内集合、状态集合、可比回滚集合、实际变分集合和不可比原因集合执行独立候选人数门槛,并同步抑制可反推的嵌套分布、均值、分位数、标准差、分桶和风险字段。保留允许公开的总体人口数,并区分 `AVAILABLE`、`INSUFFICIENT_SAMPLE`、`SUPPRESSED`、`UNAVAILABLE` 等状态。
`FairnessIndependentReviewTest` 两个历史缺陷复现均通过:
- `smallComparablePopulationMustHideAllChangeStatistics`
- `versionLocalComplementMustNotLeakThroughVersionPopulationAndDistribution`
`FairnessMonitoringHttpIntegrationTest` 还覆盖全局 99/1、版本内补集、非空申诉小样本及不可用接口。
### A4 版本口径:PASS(真实 MySQL 黑盒复验)
有效覆盖率按“至少一个有效维度的独立候选人数 / period 总体人数”计算;MIXED/UNKNOWN 不被当作无效分,PENDING 维度参与整体版本一致性判断;同一已知版本的综合分布分开统计。未读取答案、题目或评分规则。
真实 MySQL 夹具(v0.1:100 人,99 有效/1 无效;v0.2:100 人,70 有效/30 无效;总体 200 人)复验结果:v0.1 版本组及可反推字段正确标记为 `SUPPRESSED`,不返回 sampleSize、均值、分位数、分桶或风险;v0.2 版本组保持 `AVAILABLE`,sampleSize=70、mean=83.50。顶层 `populationSize=200` 保留,`versionCounts` 等联合可反推字段隐藏。overview 与 dimensions 联合响应不能还原 v0.1 的 1 人补集。
### A5 申诉统计:PASS(H2)
回滚历史按 `appeal_id` 统计,重复历史进入独立不可比分类;审核通过、回滚执行和实际变分分开计算;零差值保留在可比分母;一名候选人的不可比申诉不会排除其其他合法申诉。相关服务和持久化测试覆盖同人多申诉、重复历史、跨版本、空/越界分数及输入顺序变化。
### A6 SQL/性能结构:PASS(代码结构、H2、真实 MySQL 查询预算与 3000 人规模)
Repository 使用参数绑定、数据库聚合、score-frequency 结果和每条 JDBC 查询局部 `setQueryTimeout(5)`;没有修改共享 JdbcTemplate 超时配置或生成无限 IN 参数。真实 MySQL 通过临时启用 performance_schema statement history(验收后恢复原值)测得业务 SQL 数:overview=2、dimensions=2、appeals=1、drift/ranking=0,且在 30/300/3000 人规模下保持不变。3000 人实测最慢为 appeals 358ms。`EXPLAIN FORMAT=JSON` 同时显示 dimensions 聚合以及部分回滚历史读取存在全表扫描/filesort/temporary;当前验收规模无性能阻断,但这是生产扩容风险,后续应通过新迁移评估针对性索引,本轮未改既有迁移或数据库结构。
## 4. 自动化复验
| 命令 | 实际结果 |
|---|---|
| `mvn -Dtest=FairnessIndependentReviewTest,FairnessMonitoringHttpIntegrationTest test` | 7 tests,Failures=0,Errors=0,Skipped=0,BUILD SUCCESS |
| V43 专项复验 | 60 tests,Failures=0,Errors=0,Skipped=0,BUILD SUCCESS |
| `mvn test` | 1535 tests,Failures=0,Errors=0,Skipped=0,BUILD SUCCESS |
| `mvn clean verify` | 1535 tests,Failures=0,Errors=0,Skipped=0,BUILD SUCCESS |
| `python docs/verify_openapi.py` | PASS,172 paths / 210 operations |
| `git diff --check` | 无 diff 错误 |
H2 真实服务端集成测试包含:
- `realHttpPathSuppresses99Of100ComplementAcrossOverviewAndDimensions`
- `realHttpFiveEndpointsUseRealAdminPermissionAndUnavailablePathsDoNotWrite`
- `realHttpSuppressesVersionLocalComplementAcrossOverviewAndDimensions`
- `realHttpAppealsHidesSmallComparableChangeGroup`
这些测试经过真实 MockMvc → Service → Repository → H2 SQL;仅 Controller 契约测试使用 Mock Service,报告未将其冒充为真库验收。
## 5. Docker/MySQL 阶段:PASS
只读检查结果:
- Docker Desktop/Engine:可用;Client/Server 29.6.2,Docker Desktop 4.84.0
- MySQL:`mysql:8.4`,实际版本 `8.4.11`,容器 healthy,映射 3307
- Redis:`redis:7.4-alpine`,healthy
- RabbitMQ:`rabbitmq:3.13-management-alpine`,healthy
- Flyway:V40 `enterprise talent invitations`,`success=1`;V43 迁移行数为 0
- 应用:最新 JAR 启动成功,`ddl-auto=validate`,8080 监听;健康检查 HTTP 200
- 运行时 OpenAPI:`/api/v1/v3/api-docs` 为 **172 paths / 210 operations**
- RabbitMQ 队列真实存在且 ready/unacked 均为 0:`queue.sms.verification`、`queue.notification`、`queue.audit`、`queue.notification.dlq`
真实查询预算和规模结果(权限查询、夹具写入及验收查询不计入业务 SQL):
| 候选人数 | overview | dimensions | appeals |
|---:|---:|---:|---:|
| 30 | 2 SQL / 67ms | 2 SQL / 38ms | 1 SQL / 46ms |
| 300 | 2 SQL / 70ms | 2 SQL / 60ms | 1 SQL / 53ms |
| 3000 | 2 SQL / 99ms | 2 SQL / 177ms | 1 SQL / 358ms |
`drift` 与 `ranking-stability` 均为 0 条业务 SQL。验收夹具已按精确前缀清理,相关用户残留数为 0;performance_schema consumer 已恢复为验收前状态。
## 6. 真实 HTTP 与夹具证据
五接口逐请求结果(真实 MySQL):
- ADMIN:五个接口均 `200/200`
- STUDENT:五个接口均 `403/2002`
- COMPANY:五个接口均 `403/2002`
- 未登录:五个接口均 `401/401`
- 完整 JSON 敏感字段扫描:五接口均未发现禁止字段
- 逐接口表摘要:overview、dimensions、drift、ranking-stability、appeals 均未发现接口自身写入;风险定时器关闭后摘要稳定
- `drift` 与 `ranking-stability` 均返回 `UNAVAILABLE`
真实夹具结果:
- 2026-07 总体 200 人,v0.1=99/1、v0.2=70/30:v0.1 正确抑制,v0.2 保持可用,跨接口不能反推小补集
- 2026-03 100 人 99/1:总体抑制符合预期
- 2026-02 100 人 1/99:总体抑制符合预期
- 2026-06 29 人:`INSUFFICIENT_SAMPLE`
- 2026-05 30 人全有效:`AVAILABLE`,分布可见
- 2026-04 30 人全无效:无有效分布,未发现个人字段泄露
- 申诉 2026-09-07:30 名候选中 1 名重复回滚历史,比较组 29 人,相关变分字段被抑制
- 申诉 2026-09-08:同一候选同时有合法申诉和重复历史申诉,接口未泄露个人信息;敏感比较组因原因小单元受到抑制
已验证:V43 生产代码无明细回退、H2 SQL 聚合与小样本抑制、独立失败复现、真实 Service/Repository H2 HTTP、静态与运行时 OpenAPI、自动化回归、真实 MySQL 启动、真实权限和 RabbitMQ 队列。
真实 MySQL 查询预算、30/300/3000 性能曲线、运行时 OpenAPI、权限、只读性和 RabbitMQ 终态均已完成。EXPLAIN 暴露的全表扫描/filesort 属扩容风险,不构成本次 3000 人规模验收失败。
## 7. 验收判定
**最终结论:PASS。V43 独立代码复验与真实 MySQL/Docker 黑盒在本次范围内通过,可以进入精确暂存检查点。**
- 功能、权限、隐私、小样本抑制、申诉集合、只读性、运行时契约和 3000 人规模查询预算均无发布阻断;
- FAIL=0,核心验收项无 SKIP;
- 不声称已证明无限数据规模性能;EXPLAIN 中的全表扫描/filesort 作为上线容量观察项和后续索引优化候选保留;
- V43 不新增 Flyway,不写 `candidate_dimension_scores`,不改变评分、排名和申诉状态机。

224
docs/validate_ability_bank.py

@ -0,0 +1,224 @@
# -*- coding: utf-8 -*-
"""
V36 四维能力测评 题库权威校验脚本
两个目标:
1) 独立校验权威题库 ability-question-bank-v0.1.json 满足全部 14 项硬性约束。
2) 将 V35__ability_assessment.sql 中写入的 48 道题与权威 JSON 逐题对账,
确保题干/选项/答案键/分值/解析/难度/版本/position 语义完全一致。
任一项校验失败 => 打印 FAIL 并退出码 1(不自动修复题库内容)。
全部通过 => 打印 PASS(针对每道题)并退出码 0。
"""
import json
import re
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
BANK_JSON = ROOT / "docs" / "ability-question-bank-v0.1.json"
V35_SQL = ROOT / "mydreamjob-server" / "src" / "main" / "resources" / "db" / "migration" / "V35__ability_assessment.sql"
DIMENSIONS = ["LOGIC", "COMMUNICATION", "EXECUTION", "LEARNING"]
COMPETENCY_TAGS = {
"LOGIC": ["INFORMATION_ANALYSIS", "CAUSAL_REASONING", "PROBLEM_SOLVING"],
"COMMUNICATION": ["LISTENING_UNDERSTANDING", "STRUCTURED_EXPRESSION", "COLLABORATION_CONFLICT"],
"EXECUTION": ["PLANNING_PRIORITIZATION", "RISK_FOLLOWUP", "DELIVERY_REVIEW"],
"LEARNING": ["INFORMATION_ACQUISITION", "FEEDBACK_REFLECTION", "TRANSFER_ADAPTATION"],
}
SCORES = {0, 1, 2, 4}
OPTION_IDS = ["A", "B", "C", "D"]
def fail(msg):
print(f"FAIL: {msg}")
sys.exit(1)
def check(cond, msg):
if not cond:
fail(msg)
print(f" PASS: {msg}")
def main():
print("=== 第 1 步:校验权威 JSON 文件 ===")
bank = json.loads(BANK_JSON.read_text(encoding='utf-8'))
if bank.get("bankCode") != "WORKPLACE_ABILITY":
fail("bankCode != WORKPLACE_ABILITY")
if bank.get("bankVersion") != "ability-v0.1":
fail("bankVersion != ability-v0.1")
qs = bank.get("questions", [])
check(len(qs) == 48, f"总数 = 48(实际 {len(qs)})")
# 1) 四维各 12
dim_count = {d: 0 for d in DIMENSIONS}
pos_set = set()
tag_count = {}
key_count = {}
dif_count = {}
for q in qs:
dim_count[q["dimension"]] = dim_count.get(q["dimension"], 0) + 1
tag_count[q["competencyTag"]] = tag_count.get(q["competencyTag"], 0) + 1
key_count[q["answerKey"]] = key_count.get(q["answerKey"], 0) + 1
dif_count[q["difficulty"]] = dif_count.get(q["difficulty"], 0) + 1
pos_set.add(q["position"])
for d in DIMENSIONS:
check(dim_count.get(d) == 12, f"维度 {d} = 12(实际 {dim_count.get(d)})")
for tag in sum(COMPETENCY_TAGS.values(), []):
check(tag_count.get(tag) == 4, f"competencyTag {tag} = 4(实际 {tag_count.get(tag)})")
for k in OPTION_IDS:
check(key_count.get(k) == 12, f"answerKey {k} = 12(实际 {key_count.get(k)})")
for d in ["EASY", "MEDIUM", "HARD"]:
check(dif_count.get(d) == 16, f"difficulty {d} = 16(实际 {dif_count.get(d)})")
check(len(pos_set) == 48 and pos_set == set(range(1, 49)),
"position 1-48 唯一且连续")
# 2) 逐题约束
for q in qs:
code = q["questionCode"]
opts = q["options"]
# 每题四选一
check(len(opts) == 4, f"{code} 含 4 个选项")
oids = [o["optionId"] for o in opts]
check(oids == OPTION_IDS, f"{code} optionId 集合 = A/B/C/D")
for o in opts:
if not o.get("text") or not str(o["text"]).strip():
fail(f"{code} 存在空选项文本")
# 分值集合严格等于 {0,1,2,4}
rule = q["scoringRule"]
check(sorted(rule.keys()) == OPTION_IDS, f"{code} scoringRule 键 = A/B/C/D")
check(set(rule.values()) == SCORES, f"{code} 分值集合 = {{0,1,2,4}}(实际 {sorted(set(rule.values()))})")
check(len(set(rule.values())) == 4, f"{code} 分值互不相同(每题各档唯一)")
# answerKey 对应 4 分
check(rule.get(q["answerKey"]) == 4, f"{code} answerKey={q['answerKey']} 对应 4 分")
# explanation 非空
check(bool(q.get("explanation")) and str(q["explanation"]).strip(), f"{code} explanation 非空")
# 版本/来源/启用
check(q["questionVersion"] == "ability-v0.1", f"{code} questionVersion=ability-v0.1")
check(q["source"] == "SELF_AUTHORED", f"{code} source=SELF_AUTHORED")
check(q["active"] is True, f"{code} active=true")
check(q["dimension"] in DIMENSIONS, f"{code} dimension 合法")
check(q["competencyTag"] in COMPETENCY_TAGS.get(q["dimension"], []), f"{code} competencyTag 归属该维度")
check(q["itemType"] in ("SITUATIONAL_JUDGMENT", "SEQUENCE_JUDGMENT"), f"{code} itemType 合法")
print("=== 第 2 步:V35 SQL 与权威 JSON 逐题对账 ===")
sql = V35_SQL.read_text(encoding='utf-8')
# 抓取 definition INSERT 中 WORKPLACE_ABILITY 的系数行
defn_pat = ("'WORKPLACE_ABILITY'[\\s\\S]*?'ACTIVE',[\\s\\S]*?"
"48,\\s*24,\\s*6,\\s*30,\\s*7,\\s*90,\\s*3,"
"[\\s\\S]*?'ability-v0.1'[\\s\\S]*?'ability-scoring-v0.1'\\s*,?\\s*\\)")
defn_m = re.search(defn_pat, sql)
if not defn_m:
fail("V35 中缺少 WORKPLACE_ABILITY 定义种子(48/24/6/30/7/90/3)")
print(" PASS: WORKPLACE_ABILITY 定义种子存在且参数一致")
# 抓取 ability_questions 的整段 VALUES(一直抓到文件结尾)
values_m = re.search(r"INSERT INTO ability_questions[\s\S]*?VALUES\s*(.*)$", sql, re.S)
if not values_m:
fail("V35 中缺少 ability_questions INSERT")
body = values_m.group(1)
# 每个 VALUES 行以 "('XXNN'" 起始,且独占一行;逐行拆分
rows = []
for line in body.splitlines():
m = re.match(r"\s*\(\s*'([A-Z]{2}\d{2})'(.+)\)\s*,?;?\s*$", line)
if m:
rows.append((m.group(1), m.group(2)))
def clean(v):
v = v.strip()
if v.startswith("'") and v.endswith("'"):
v = v[1:-1]
return v.replace("''", "'")
def parse_json(v):
return json.loads(clean(v))
if len(rows) != 48:
fail(f"V35 中题目行数 = {len(rows)}(应为 48)")
# 拆分字段:code 单独;其余依次为
# def_subquery, dimension, competency_tag, item_type, stem,
# options_json, answer_key_json, scoring_rule_json, explanation,
# difficulty, discrimination, question_version, source, active, position
by_pos = {}
for code, rest in rows:
parts = split_row(rest)
# rest 以 "(SELECT..." 起始,首字段为 definition_id 子查询,且行首逗号会产生一个空 token;
# 去除空 token 后即为 [def_subquery, dimension, ..., position]
if parts and parts[0] == "":
parts = parts[1:]
if len(parts) != 15:
fail(f"{code} 解析出 {len(parts)} 个字段(应为 15)")
by_pos[int(parts[14])] = (code, parts)
if set(by_pos.keys()) != set(range(1, 49)):
fail("V35 中 position 不覆盖 1-48")
for q in qs:
code = q["questionCode"]
pos = q["position"]
row = by_pos.get(pos)
if row is None:
fail(f"position {pos}({code})在 V35 中缺失")
sql_code, parts = row
if sql_code != code:
fail(f"position {pos}:V35 questionCode={sql_code},权威 JSON={code}")
# definition_id(parts[0])为子查询,跳过比较
sql_dim, sql_tag, sql_item, sql_stem = parts[1], parts[2], parts[3], parts[4]
sql_opts, sql_key, sql_rule, sql_expl = parts[5], parts[6], parts[7], parts[8]
sql_dif = parts[9]
sql_ver, sql_src, sql_active, sql_pos = parts[11], parts[12], parts[13], parts[14]
def eq_a(actual, expect, label):
if actual != expect:
fail(f"{code} {label} 不一致")
eq_a(clean(sql_dim), q["dimension"], "dimension")
eq_a(clean(sql_tag), q["competencyTag"], "competencyTag")
eq_a(clean(sql_item), q["itemType"], "itemType")
eq_a(clean(sql_stem), q["stem"], "stem")
eq_a(parse_json(sql_opts), q["options"], "options_json")
eq_a(parse_json(sql_key), dict(answerKey=q["answerKey"]), "answer_key_json")
eq_a(parse_json(sql_rule), q["scoringRule"], "scoring_rule_json")
eq_a(clean(sql_expl), q["explanation"], "explanation")
eq_a(clean(sql_dif), q["difficulty"], "difficulty")
eq_a(clean(sql_ver), q["questionVersion"], "question_version")
eq_a(clean(sql_src), q["source"], "source")
eq_a(clean(sql_active), "1", "active")
eq_a(clean(sql_pos), str(q["position"]), "position")
print(f" PASS: {code} 与权威 JSON 完全一致")
print("ALL PASS: 题库 14 项硬性约束 + V35 SQL 逐题对账全部通过")
sys.exit(0)
def split_row(rest):
"""把一行剩余部分按顶层逗号切分(忽略引号内的逗号)。"""
out = []
buf = []
in_sq = False
i = 0
body = rest
while i < len(body):
c = body[i]
if c == "'":
if in_sq and i + 1 < len(body) and body[i + 1] == "'":
buf.append("''")
i += 2
continue
in_sq = not in_sq
buf.append(c)
elif c == "," and not in_sq:
out.append("".join(buf).strip())
buf = []
else:
buf.append(c)
i += 1
if buf:
out.append("".join(buf).strip())
return out
if __name__ == "__main__":
main()

769
docs/verify_openapi.py

@ -6,74 +6,63 @@ p = PROJECT_ROOT / 'docs' / 'openapi-rc.json'
d = json.load(open(p, encoding='utf-8'))
print('paths:', len(d['paths']))
assert len(d['paths']) == 182, f"Expected 182 paths, got {len(d['paths'])}"
paths = d['paths']
onboarding_paths = [
'/onboarding/companies/{companyId}/checklist',
'/onboarding/companies/{companyId}/checklist/items/{itemId}/completion',
'/onboarding/companies/{companyId}/knowledge',
'/onboarding/company/checklist-items',
'/onboarding/company/checklist-items/{itemId}',
'/onboarding/company/knowledge',
]
for path in onboarding_paths:
assert path in paths, f'Missing onboarding path: {path}'
onboarding_operations = {
('/onboarding/companies/{companyId}/checklist', 'get'): 'getEffectiveChecklist',
('/onboarding/companies/{companyId}/checklist/items/{itemId}/completion', 'put'): 'updateCompletion',
('/onboarding/companies/{companyId}/knowledge', 'get'): 'getCompanyKnowledgeOverview',
('/onboarding/company/checklist-items', 'get'): 'listCompanyChecklistItems',
('/onboarding/company/checklist-items', 'post'): 'createCompanyChecklistItem',
('/onboarding/company/checklist-items/{itemId}', 'put'): 'updateCompanyChecklistItem',
('/onboarding/company/checklist-items/{itemId}', 'delete'): 'deleteCompanyChecklistItem',
('/onboarding/company/knowledge', 'get'): 'getCompanyKnowledge',
('/onboarding/company/knowledge', 'put'): 'upsertCompanyKnowledge',
}
http_methods = {'get', 'post', 'put', 'patch', 'delete', 'options', 'head', 'trace'}
for (path, method), operation_id in onboarding_operations.items():
assert method in paths[path], f'Missing {method.upper()} {path}'
assert paths[path][method].get('operationId') == operation_id, f'Wrong operationId for {method.upper()} {path}'
matching_operations = [
(candidate_method, candidate_path)
for candidate_path, path_item in paths.items()
for candidate_method, operation in path_item.items()
if candidate_method in http_methods
and operation.get('operationId') == operation_id
]
assert matching_operations == [(method, path)], (
f'{operation_id} must be globally unique: expected {method.upper()} {path}, got {matching_operations}'
)
assert paths[path][method].get('security') == [{'bearerAuth': []}], (
f'{method.upper()} {path} must require bearerAuth'
)
assert sum(
1
for path_item in paths.values()
for method in path_item
if method in http_methods
) == 178, 'Expected 178 operations after N05 salary calculator'
assert len(d['paths']) == 139, f"Expected 139 paths, got {len(d['paths'])}"
required_salary_paths = ['/salary-calculator/cities','/salary-calculator/cities/{cityCode}','/salary-calculator/calculations','/salary-calculator/calculations/{id}']
for path in required_salary_paths:
assert path in paths, f'Missing salary path: {path}'
salary_operations = {
('/salary-calculator/cities', 'get'): 'listSalaryCities',
('/salary-calculator/cities/{cityCode}', 'get'): 'getSalaryCity',
('/salary-calculator/calculations', 'post'): 'calculateSalary',
('/salary-calculator/calculations', 'get'): 'listSalaryCalculations',
('/salary-calculator/calculations/{id}', 'get'): 'getSalaryCalculation',
('/salary-calculator/calculations/{id}', 'delete'): 'deleteSalaryCalculation',
}
for (path, method), operation_id in salary_operations.items():
assert method in paths[path], f'Missing {method.upper()} {path}'
assert paths[path][method].get('operationId') == operation_id, f'Wrong operationId for {method.upper()} {path}'
assert paths[path][method].get('security') == [{'bearerAuth': []}], f'{method.upper()} {path} must require bearerAuth'
# Generic OpenAPI integrity checks. These catch importer-breaking structures
# before business-specific path/count assertions run.
HTTP_METHODS = {'get', 'put', 'post', 'delete', 'patch', 'options', 'head', 'trace'}
def resolve_local_ref(document, ref):
assert isinstance(ref, str) and ref.startswith('#/'), f'Invalid local $ref: {ref!r}'
node = document
for token in ref[2:].split('/'):
token = token.replace('~1', '/').replace('~0', '~')
assert isinstance(node, dict) and token in node, f'Unresolved $ref: {ref}'
node = node[token]
return node
def validate_refs(node, location='$'):
if isinstance(node, dict):
for key, value in node.items():
child = f'{location}.{key}'
if key == '$ref':
assert isinstance(value, str) and value, f'$ref must be a string at {child}'
if value.startswith('#/'):
resolve_local_ref(d, value)
validate_refs(value, child)
elif isinstance(node, list):
for index, value in enumerate(node):
validate_refs(value, f'{location}[{index}]')
validate_refs(d)
operation_ids = {}
used_tags = set()
for route, path_item in paths.items():
for method, operation in path_item.items():
if method.lower() not in HTTP_METHODS:
continue
assert isinstance(operation, dict), f'Invalid operation: {method.upper()} {route}'
assert not (method.lower() == 'get' and 'requestBody' in operation), \
f'GET must not contain requestBody: {route}'
if 'parameters' in operation:
assert isinstance(operation['parameters'], list), \
f'parameters must be an array: {method.upper()} {route}'
operation_id = operation.get('operationId')
if operation_id:
operation_ids.setdefault(operation_id, []).append(f'{method.upper()} {route}')
used_tags.update(operation.get('tags', []))
duplicates = {key: value for key, value in operation_ids.items() if len(value) > 1}
assert not duplicates, f'Duplicate operationId values: {duplicates}'
declared_tags = {tag['name'] for tag in d.get('tags', [])}
assert used_tags <= declared_tags, f'Undeclared operation tags: {sorted(used_tags - declared_tags)}'
required = [
# Phase A 企业注册与入驻聚合(2 个 path / 2 个 operation)
'/auth/company/register',
'/companies/me/onboarding',
'/review-weight-templates',
'/profile/me/review-weights',
'/profile/me/privacy',
@ -130,13 +119,31 @@ required = [
'/admin/reports',
'/admin/reports/{id}',
'/admin/reports/{id}/decision',
'/skills/word-cloud',
'/skills/match',
'/skills/learning-path',
'/salary-calculator/cities',
'/salary-calculator/cities/{cityCode}',
'/salary-calculator/calculations',
'/salary-calculator/calculations/{id}',
# V34/V35 RIASEC 职业兴趣测评(8 个 path / 8 个 operation)
'/assessments/types',
'/assessments/{type}/questions',
'/assessments/attempts',
'/assessments/attempts/{attemptId}',
'/assessments/attempts/{attemptId}/answers',
'/assessments/attempts/{attemptId}/submit',
'/assessments/attempts/{attemptId}/result',
'/assessments/me',
# V36 四维能力测评(7 个 path / 7 个 operation)
'/abilities/types',
'/abilities/attempts',
'/abilities/attempts/{attemptId}',
'/abilities/attempts/{attemptId}/answers',
'/abilities/attempts/{attemptId}/submit',
'/abilities/attempts/{attemptId}/result',
'/abilities/me',
# V37 风险概览与运营规则配置(7 个 path / 7 个 operation,仅 ADMIN)
'/admin/scoring/risk/overview',
'/admin/scoring/risk/trend',
'/admin/scoring/risk/distribution',
'/admin/scoring/risk/events',
'/admin/scoring/risk/rules',
'/admin/scoring/risk/rules/{ruleCode}',
'/admin/scoring/risk/events/{eventId}/actions',
]
for path in required:
assert path in paths, f"Missing path: {path}"
@ -206,143 +213,503 @@ assert 'get' in paths['/admin/reports'], "Missing GET /admin/reports"
assert 'get' in paths['/admin/reports/{id}'], "Missing GET /admin/reports/{id}"
assert 'post' in paths['/admin/reports/{id}/decision'], "Missing POST /admin/reports/{id}/decision"
print(" V33 report/block endpoints present (9 operations / 8 paths)")
# V34/V35 RIASEC 测评(8 个 path / 8 个 operation)
assert 'get' in paths['/assessments/types'], "Missing GET /assessments/types"
assert 'get' in paths['/assessments/{type}/questions'], "Missing GET /assessments/{type}/questions"
assert 'post' in paths['/assessments/attempts'], "Missing POST /assessments/attempts"
assert 'get' in paths['/assessments/attempts/{attemptId}'], "Missing GET /assessments/attempts/{attemptId}"
assert 'patch' in paths['/assessments/attempts/{attemptId}/answers'], "Missing PATCH .../answers"
assert 'post' in paths['/assessments/attempts/{attemptId}/submit'], "Missing POST .../submit"
assert 'get' in paths['/assessments/attempts/{attemptId}/result'], "Missing GET .../result"
assert 'get' in paths['/assessments/me'], "Missing GET /assessments/me"
print(" V34/V35 RIASEC assessment endpoints present (8 operations / 8 paths)")
# V36 四维能力测评(7 个 path / 7 个 operation)
assert 'get' in paths['/abilities/types'], "Missing GET /abilities/types"
assert 'post' in paths['/abilities/attempts'], "Missing POST /abilities/attempts"
assert 'get' in paths['/abilities/attempts/{attemptId}'], "Missing GET /abilities/attempts/{attemptId}"
assert 'patch' in paths['/abilities/attempts/{attemptId}/answers'], "Missing PATCH .../answers"
assert 'post' in paths['/abilities/attempts/{attemptId}/submit'], "Missing POST .../submit"
assert 'get' in paths['/abilities/attempts/{attemptId}/result'], "Missing GET .../result"
assert 'get' in paths['/abilities/me'], "Missing GET /abilities/me"
print(" V36 ability assessment endpoints present (7 operations / 7 paths)")
# ---- V36 严格断言:正好 7 path / 7 operation;无 /abilities/{type}/questions;requestBody+200 schema;无题库内部字段 ----
assert '/abilities/{type}/questions' not in paths, "GET /abilities/{type}/questions must NOT exist"
v36_paths = sorted([k for k in paths if k.startswith('/abilities')])
assert len(v36_paths) == 7, f"Expected exactly 7 /abilities paths, got {len(v36_paths)}"
v36_ops = sum(len(paths[k]) for k in v36_paths)
assert v36_ops == 7, f"Expected exactly 7 /abilities operations, got {v36_ops}"
print(f" V36 exactly 7 paths / 7 operations: {v36_paths}")
ABILITY_200 = {
'/abilities/types': ('get', 'ApiResponseAbilityTypeResponse'),
'/abilities/attempts': ('post', 'ApiResponseAbilityAttemptResponse'),
'/abilities/attempts/{attemptId}': ('get', 'ApiResponseAbilityAttemptResponse'),
'/abilities/attempts/{attemptId}/answers': ('patch', 'ApiResponseAbilityAttemptResponse'),
'/abilities/attempts/{attemptId}/submit': ('post', 'ApiResponseAbilityResultResponse'),
'/abilities/attempts/{attemptId}/result': ('get', 'ApiResponseAbilityResultResponse'),
'/abilities/me': ('get', 'ApiResponsePageResponseAbilityHistoryItem'),
}
def response_ref(op):
return op.get('responses', {}).get('200', {}).get('content', {}).get('application/json', {}).get('schema', {}).get('$ref', '')
for pt, (method, expect_ref) in ABILITY_200.items():
ref = response_ref(paths[pt][method])
assert ref.endswith(expect_ref), f"{method.upper()} {pt}: expected 200 ref ...{expect_ref}, got {ref}"
print(" All 7 V36 success responses reference a schema")
# Skill cloud endpoints (3 paths / 3 GET operations)
for skill_path in ('/skills/word-cloud', '/skills/match', '/skills/learning-path'):
assert 'get' in paths[skill_path], f"Missing GET {skill_path}"
skill_operation_ids = {
'/skills/word-cloud': 'getWordCloud',
'/skills/match': 'getSkillMatch',
'/skills/learning-path': 'getLearningPath',
for pt, (method, schema_name) in {
'/abilities/attempts': ('post', 'CreateAbilityAttemptRequest'),
'/abilities/attempts/{attemptId}/answers': ('patch', 'SaveAbilityAnswersRequest')}.items():
rb = paths[pt][method].get('requestBody', {}).get('content', {}).get('application/json', {}).get('schema', {}).get('$ref', '')
assert rb.endswith(schema_name), f"{method.upper()} {pt}: expected requestBody ref ...{schema_name}, got {rb}"
print(" POST/PATCH V36 request bodies reference a schema")
# 题库内部字段不得出现在 Ability 相关 schema
for f in ['questionCode', 'competencyTag', 'answerKey', 'scoringRule']:
for name, sch in d['components']['schemas'].items():
if name.startswith('Ability') and f in json.dumps(sch):
assert False, f"schema {name} exposes forbidden field {f}"
print(" Ability schemas expose no questionCode/competencyTag/answerKey/scoringRule")
# V37 风险接口 operation 存在性
assert 'get' in paths['/admin/scoring/risk/overview'], "Missing GET overview"
assert 'get' in paths['/admin/scoring/risk/trend'], "Missing GET trend"
assert 'get' in paths['/admin/scoring/risk/distribution'], "Missing GET distribution"
assert 'get' in paths['/admin/scoring/risk/events'], "Missing GET events"
assert 'get' in paths['/admin/scoring/risk/rules'], "Missing GET rules"
assert 'put' in paths['/admin/scoring/risk/rules/{ruleCode}'], "Missing PUT rules/{ruleCode}"
assert 'post' in paths['/admin/scoring/risk/events/{eventId}/actions'], "Missing POST events/{eventId}/actions"
print(" V37 risk endpoints present (7 operations / 7 paths)")
# ---- V37 严格断言:正好 7 path / 7 operation;请求/响应 schema;无企业/学生风险接口;Risk schema 无敏感字段 ----
vk = [k for k in paths if k.startswith('/admin/scoring/risk')]
assert len(vk) == 7, f"Expected exactly 7 V37 paths, got {len(vk)}"
assert sum(len(paths[k]) for k in vk) == 7, "Expected exactly 7 V37 operations"
# 不得新增企业/学生风险接口
for k in paths:
if k.startswith('/scoring/risk') or k.startswith('/risk'):
assert False, f"unexpected student/company risk endpoint {k}"
print(f" V37 exactly 7 paths / 7 operations: {vk}")
RISK_200 = {
'/admin/scoring/risk/overview': 'ApiResponseRiskOverviewResponse',
'/admin/scoring/risk/trend': 'ApiResponseRiskTrendResponse',
'/admin/scoring/risk/distribution': 'ApiResponseRiskDistributionResponse',
'/admin/scoring/risk/events': 'ApiResponsePageResponseRiskEventResponse',
'/admin/scoring/risk/rules': 'ApiResponseListRiskRuleResponse',
'/admin/scoring/risk/rules/{ruleCode}': 'ApiResponseRiskRuleResponse',
'/admin/scoring/risk/events/{eventId}/actions': 'ApiResponseRiskEventResponse',
}
for skill_path, skill_operation_id in skill_operation_ids.items():
assert paths[skill_path]['get'].get('operationId') == skill_operation_id
http_methods = {'get', 'post', 'put', 'patch', 'delete', 'options', 'head', 'trace'}
matching_operations = [
(method, path)
for path, path_item in paths.items()
for method, operation in path_item.items()
if method in http_methods
and operation.get('operationId') == skill_operation_id
]
assert matching_operations == [('get', skill_path)], (
f'{skill_operation_id} must not conflict: expected GET {skill_path}, got {matching_operations}'
)
assert paths['/skills/word-cloud']['get']['responses']['200']['content']['application/json']['schema']['$ref'] == '#/components/schemas/ApiResponseSkillCloudResponse'
assert paths['/skills/match']['get']['responses']['200']['content']['application/json']['schema']['$ref'] == '#/components/schemas/ApiResponseSkillMatchResponse'
assert paths['/skills/learning-path']['get']['responses']['200']['content']['application/json']['schema']['$ref'] == '#/components/schemas/ApiResponseSkillLearningPathResponse'
for skill_path in ('/skills/word-cloud', '/skills/match', '/skills/learning-path'):
unauthorized = paths[skill_path]['get']['responses']['401']
assert unauthorized['description'] == '未登录或令牌无效,由全局 Security 入口返回裸 JSON'
assert unauthorized['content']['application/json']['schema']['$ref'] == '#/components/schemas/UnauthorizedResponse'
example = unauthorized['content']['application/json']['example']
assert example == {'code': 401, 'message': 'not authenticated', 'timestamp': ''}
assert 'requestId' not in example
schemas = d['components']['schemas']
unauthorized_schema = schemas.get('UnauthorizedResponse')
assert unauthorized_schema is not None, 'Missing schema: UnauthorizedResponse'
assert unauthorized_schema.get('additionalProperties') is False
assert set(unauthorized_schema.get('required', [])) == {'code', 'message', 'timestamp'}
assert set(unauthorized_schema.get('properties', {})) == {'code', 'message', 'timestamp'}
skill_schema_fields = {
'SkillCloudNode': {'id', 'code', 'name'},
'SkillCloudItem': {'rank', 'skillId', 'skillCode', 'nameZh', 'nameEn', 'sourceType', 'sourceCode', 'categoryId', 'categoryName', 'heat', 'heatSourceCode', 'heatElementId', 'heatProxy'},
'SkillCloudResponse': {'industry', 'occupation', 'category', 'total', 'items'},
'SkillMatchResponse': {'matchScore', 'matchedCount', 'totalCount', 'matchedSkills', 'missingSkills'},
'SkillLearningPathItem': {'priority', 'skillId', 'skillName', 'categoryId', 'categoryName', 'suggestion'},
'SkillLearningPathResponse': {'total', 'items'},
for pt, expect_ref in RISK_200.items():
method = 'put' if pt.endswith('{ruleCode}') else ('post' if pt.endswith('actions') else 'get')
ref = response_ref(paths[pt][method])
assert ref.endswith(expect_ref), f"{method.upper()} {pt}: expected 200 ref ...{expect_ref}, got {ref}"
print(" All 7 V37 success responses reference a schema")
for pt in ('/admin/scoring/risk/rules/{ruleCode}', '/admin/scoring/risk/events/{eventId}/actions'):
method = 'put' if pt.endswith('{ruleCode}') else 'post'
rb = paths[pt][method].get('requestBody', {}).get('content', {}).get('application/json', {}).get('schema', {}).get('$ref', '')
assert rb != '', f"{method.upper()} {pt} requestBody schema missing"
print(" PUT/POST V37 request bodies reference a schema")
risk_forbidden = ['subjectUserId', 'subject_user_id', 'operatorUserId', 'operator_user_id', 'phone', 'email', 'ipAddress', 'userAgent', 'details', 'token', 'password', 'secret']
for f in risk_forbidden:
for name, sch in d['components']['schemas'].items():
if name.startswith('Risk') and f in json.dumps(sch):
assert False, f"schema {name} exposes forbidden field {f}"
print(" Risk schemas expose no subjectUserId/operatorUserId/phone/email/ip/userAgent/details/credentials")
# V39 测评驱动岗位推荐:6 path / 8 operation;学生反馈、企业画像、ADMIN 审核与公开受控字典。
v39 = [
'/job-recommendation-taxonomies',
'/profile/me/job-recommendations',
'/profile/me/job-recommendations/{jobId}/feedback',
'/companies/me/jobs/{jobId}/recommendation-profile',
'/admin/job-recommendation-profiles',
'/admin/job-recommendation-profiles/{jobId}/decision',
]
assert all(k in paths for k in v39), "One or more V39 paths are missing"
assert len(v39) == 6, f"Expected exactly 6 V39 paths, got {len(v39)}"
assert sum(len(paths[k]) for k in v39) == 8, "Expected exactly 8 V39 operations"
for pt, method in [
('/job-recommendation-taxonomies', 'get'),
('/profile/me/job-recommendations', 'get'),
('/profile/me/job-recommendations/{jobId}/feedback', 'post'),
('/profile/me/job-recommendations/{jobId}/feedback', 'delete'),
('/companies/me/jobs/{jobId}/recommendation-profile', 'get'),
('/companies/me/jobs/{jobId}/recommendation-profile', 'put'),
('/admin/job-recommendation-profiles', 'get'),
('/admin/job-recommendation-profiles/{jobId}/decision', 'post'),
]:
assert method in paths[pt], f"Missing {method.upper()} {pt}"
for pt, method in [
('/profile/me/job-recommendations/{jobId}/feedback', 'post'),
('/companies/me/jobs/{jobId}/recommendation-profile', 'put'),
('/admin/job-recommendation-profiles/{jobId}/decision', 'post'),
]:
assert paths[pt][method].get('requestBody', {}).get('content', {}).get('application/json', {}).get('schema', {}).get('$ref'), f"{method.upper()} {pt} requestBody schema missing"
recommendation_sensitive = ['answerKey', 'scoringRule', 'questionCode', 'competencyTag', 'resultId', 'attemptId', 'studentUserId']
for f in recommendation_sensitive:
for name, sch in d['components']['schemas'].items():
if name.startswith(('JobRecommendation', 'Recommendation')) and f in json.dumps(sch):
assert False, f"schema {name} exposes forbidden field {f}"
print(" V39 exactly 6 paths / 8 operations; recommendation schemas are redacted")
# V40 企业评价官方回复:5 paths / 6 operations;企业、管理员和公开回复均为白名单载荷。
v40 = [
'/companies/me/job-reviews',
'/companies/me/job-reviews/{reviewId}',
'/companies/me/job-reviews/{reviewId}/reply',
'/admin/review-replies/pending',
'/admin/review-replies/{replyId}/decision',
]
assert all(k in paths for k in v40), "One or more V40 paths are missing"
assert len(v40) == 5, f"Expected exactly 5 V40 paths, got {len(v40)}"
assert sum(len(paths[k]) for k in v40) == 6, "Expected exactly 6 V40 operations"
for pt, method in [
('/companies/me/job-reviews', 'get'),
('/companies/me/job-reviews/{reviewId}', 'get'),
('/companies/me/job-reviews/{reviewId}/reply', 'post'),
('/companies/me/job-reviews/{reviewId}/reply', 'put'),
('/admin/review-replies/pending', 'get'),
('/admin/review-replies/{replyId}/decision', 'post'),
]:
assert method in paths[pt], f"Missing {method.upper()} {pt}"
for pt, method in [
('/companies/me/job-reviews/{reviewId}/reply', 'post'),
('/companies/me/job-reviews/{reviewId}/reply', 'put'),
('/admin/review-replies/{replyId}/decision', 'post'),
]:
assert paths[pt][method].get('requestBody', {}).get('content', {}).get('application/json', {}).get('schema', {}).get('$ref'), f"{method.upper()} {pt} requestBody schema missing"
for pt, method in [
('/companies/me/job-reviews', 'get'),
('/companies/me/job-reviews/{reviewId}', 'get'),
('/admin/review-replies/pending', 'get'),
('/companies/me/job-reviews/{reviewId}/reply', 'post'),
('/companies/me/job-reviews/{reviewId}/reply', 'put'),
('/admin/review-replies/{replyId}/decision', 'post'),
]:
response = paths[pt][method].get('responses', {}).get('200', {})
assert response.get('content', {}).get('application/json', {}).get('schema', {}).get('$ref'), f"{method.upper()} {pt} 200 schema missing"
v40_sensitive = ['studentUserId', 'phone', 'email', 'ipAddress', 'userAgent', 'moderationScore', 'reasonCodes']
for name in ('CompanyReviewItemResponse', 'CompanyReviewDetailResponse', 'CompanyReviewReplyResponse', 'ReviewReplyPageResponse'):
assert name in d['components']['schemas'], f"Missing V40 schema {name}"
text_schema = json.dumps(d['components']['schemas'][name])
for field in v40_sensitive:
assert field not in text_schema, f"V40 company schema {name} exposes {field}"
assert 'officialReply' not in json.dumps(d['components']['schemas']['CompanyReviewReplyResponse'])
print(" V40 exactly 5 paths / 6 operations; request/response schemas are present and redacted")
# V41 企业人才发现:2 个唯一 path / 3 个 operation(学生 GET/PUT 共享一个 path)。
v41 = [
'/profile/me/talent-discovery',
'/companies/me/jobs/{jobId}/talent-recommendations',
]
assert all(k in paths for k in v41), "One or more V41 paths are missing"
assert len(v41) == 2, f"Expected exactly 2 V41 paths, got {len(v41)}"
assert sum(len(paths[k]) for k in v41) == 3, "Expected exactly 3 V41 operations"
assert 'get' in paths[v41[0]] and 'put' in paths[v41[0]], "V41 preference GET/PUT missing"
assert 'get' in paths[v41[1]], "V41 talent recommendation GET missing"
assert paths[v41[0]]['put'].get('requestBody', {}).get('content', {}).get('application/json', {}).get('schema', {}).get('$ref'), "V41 PUT requestBody schema missing"
for pt, method in [(v41[0], 'get'), (v41[0], 'put'), (v41[1], 'get')]:
assert response_ref(paths[pt][method]), f"{method.upper()} {pt} 200 schema missing"
candidate_schema = json.dumps(d['components']['schemas']['TalentDiscoveryCandidateResponse'])
for f in ['studentUserId', 'candidateId', 'phone', 'email', 'resumeUrl', 'attemptId', 'resultId',
'answerKey', 'scoringRule', 'questionCode', 'competencyTag', 'dimensionScores',
'overallScore', 'percentile', 'fitScore', 'gender', 'birthDate', 'password', 'token']:
assert f not in candidate_schema, f"V41 candidate schema exposes {f}"
assert 'candidateKey' in candidate_schema and 'studentUserId' not in candidate_schema
print(" V41 exactly 2 paths / 3 operations; candidate card schema is redacted")
# V42 企业人才邀约:6 个唯一 path / 6 个 operation。
v42 = [
'/companies/me/jobs/{jobId}/talent-invitations',
'/companies/me/talent-invitations',
'/companies/me/talent-invitations/{invitationId}',
'/profile/me/talent-invitations',
'/profile/me/talent-invitations/{invitationId}',
'/profile/me/talent-invitations/{invitationId}/decision',
]
assert all(k in paths for k in v42), "One or more V42 paths are missing"
assert len(v42) == 6, f"Expected exactly 6 V42 paths, got {len(v42)}"
assert sum(len(paths[k]) for k in v42) == 6, "Expected exactly 6 V42 operations"
assert 'post' in paths[v42[0]], "V42 company invitation POST missing"
assert 'get' in paths[v42[1]] and 'get' in paths[v42[2]], "V42 company GET endpoints missing"
assert 'get' in paths[v42[3]] and 'get' in paths[v42[4]], "V42 student GET endpoints missing"
assert 'post' in paths[v42[5]], "V42 student decision endpoint missing"
for pt, method in [(v42[0], 'post'), (v42[5], 'post')]:
assert paths[pt][method].get('requestBody', {}).get('content', {}).get('application/json', {}).get('schema', {}).get('$ref'), f"{method.upper()} {pt} requestBody schema missing"
for pt, method in [(v42[0], 'post'), (v42[1], 'get'), (v42[2], 'get'), (v42[3], 'get'), (v42[4], 'get'), (v42[5], 'post')]:
assert response_ref(paths[pt][method]), f"{method.upper()} {pt} 200 schema missing"
for name in ('CompanyTalentInvitationResponse', 'StudentTalentInvitationResponse', 'TalentInvitationPageResponseCompanyTalentInvitationResponse', 'TalentInvitationPageResponseStudentTalentInvitationResponse'):
assert name in d['components']['schemas'], f"Missing V42 schema {name}"
company_invitation_schema = json.dumps(d['components']['schemas']['CompanyTalentInvitationResponse'])
for f in ['studentUserId', 'candidateKey', 'candidateKeyDigest', 'activeKey', 'moderationScore', 'reasonCodes', 'fitScore']:
assert f not in company_invitation_schema, f"V42 company invitation schema exposes {f}"
print(" V42 exactly 6 paths / 6 operations; invitation schemas are present and redacted")
# V43 候选评分公平性与稳定性监测:5 个 ADMIN 聚合 GET,历史漂移/排名稳定性首版明确 UNAVAILABLE。
v43 = [
'/admin/scoring/fairness/overview',
'/admin/scoring/fairness/dimensions',
'/admin/scoring/fairness/drift',
'/admin/scoring/fairness/ranking-stability',
'/admin/scoring/fairness/appeals',
]
assert all(k in paths for k in v43), "One or more V43 fairness paths are missing"
assert len(v43) == 5, f"Expected exactly 5 V43 paths, got {len(v43)}"
assert all('get' in paths[k] for k in v43), "V43 operations must all be GET"
assert sum(len(paths[k]) for k in v43) == 5, "Expected exactly 5 V43 operations"
assert paths[v43[0]]['get'].get('parameters'), "V43 overview period parameter missing"
assert paths[v43[1]]['get'].get('parameters'), "V43 dimensions parameters missing"
for pt, schema in [
(v43[0], 'ApiResponseFairnessResponseOverview'),
(v43[1], 'ApiResponseFairnessResponseDimensions'),
(v43[2], 'ApiResponseFairnessResponseUnavailable'),
(v43[3], 'ApiResponseFairnessResponseUnavailable'),
(v43[4], 'ApiResponseFairnessResponseAppeals'),
]:
assert response_ref(paths[pt]['get']).endswith(schema), f"GET {pt} 200 schema missing"
for name in ('FairnessResponseOverview', 'FairnessResponseDimensions', 'FairnessResponseUnavailable', 'FairnessResponseAppeals'):
assert name in d['components']['schemas'], f"Missing V43 schema {name}"
v43_forbidden = ['studentUserId', 'applicationId', 'candidateId', 'phone', 'email', 'resumeUrl',
'answerKey', 'scoringRule', 'questionCode', 'subScoresJson', 'auditDetails',
'token', 'password', 'ipAddress', 'userAgent', 'appealId', 'reviewerId']
def resolve_schema(value, seen=None):
"""递归展开 V43 schema 的 $ref/properties/items,避免只校验顶层漏掉嵌套字段。"""
seen = set() if seen is None else seen
if isinstance(value, dict) and '$ref' in value:
ref = value['$ref']
if ref.startswith('#/components/schemas/'):
name = ref.rsplit('/', 1)[-1]
if name in seen:
return {}
seen.add(name)
return resolve_schema(d['components']['schemas'].get(name, {}), seen)
if isinstance(value, dict):
return {k: resolve_schema(v, seen.copy()) for k, v in value.items()}
if isinstance(value, list):
return [resolve_schema(v, seen.copy()) for v in value]
return value
for name, sch in d['components']['schemas'].items():
if name.startswith('FairnessResponse'):
encoded = json.dumps(resolve_schema(sch), ensure_ascii=False)
for field in v43_forbidden:
assert field not in encoded, f"V43 schema {name} exposes {field}"
for name, required_fields in {
'FairnessResponseOverview': ['availability', 'reason', 'distributionsByRulesVersion', 'coherentVersionSubset'],
'FairnessResponseDimensions': ['availability', 'reason'],
'FairnessResponseDimensionMetric': ['availability', 'reason', 'unknownStatusCount', 'outOfRangeScoreCount'],
'FairnessResponseDistribution': ['availability', 'reason'],
'FairnessResponseAppeals': ['availability', 'reason', 'appealCount', 'statusCounts', 'statusEventCounts', 'statusCandidateCounts', 'comparableRollbackCount', 'comparableRollbackCandidateCount',
'duplicateRollbackHistoryCount', 'invalidScoreRollbackCount', 'ruleVersionChangedRollbackCount'],
}.items():
props = d['components']['schemas'][name].get('properties', {})
for field in required_fields:
assert field in props, f"V43 schema {name} missing explicit field {field}"
assert d['components']['schemas']['FairnessResponseDistribution']['properties']['availability']['type'] == 'string'
assert d['components']['schemas']['FairnessResponseAppeals']['properties']['appealCount']['type'] == 'integer'
assert '独立候选人数' in d['components']['schemas']['FairnessResponseAppeals']['properties']['comparableRollbackCandidateCount'].get('description', '')
assert '/admin/scoring/fairness/overview' in paths
print(" V43 exactly 5 paths / 5 operations; aggregate fairness schemas are redacted")
# N02 候选评分解释增强:只扩展学生评分载荷,企业评分卡保持原有白名单。
student_overview = d['components']['schemas']['CandidateScoringOverviewResponse']
overview_props = student_overview.get('properties', {})
for field in ('explanationVersion', 'coverage', 'comparabilityNotice'):
assert field in overview_props, f'CandidateScoringOverviewResponse missing {field}'
detail_props = d['components']['schemas']['DimensionScoreDetailResponse'].get('properties', {})
for field in ('displayName', 'whatItMeasures', 'interpretation', 'limitations', 'evidenceLevel',
'ruleContributions', 'improvementActions', 'explanationVersion', 'validity', 'calculatedAt'):
assert field in detail_props, f'DimensionScoreDetailResponse missing {field}'
assert overview_props['coverage'].get('$ref', '').endswith('CandidateScoringCoverage')
assert detail_props['validity'].get('$ref', '').endswith('CandidateScoringValidity')
assert detail_props['ruleContributions'].get('items', {}).get('$ref', '').endswith('CandidateScoringRuleContribution')
student_explanation_forbidden = [
'sourceResultId', 'attemptId', 'resultId', 'questionSetId', 'questionCode',
'answerKey', 'scoringRule', 'competencyTag', 'selectedOptionId', 'stem',
'studentUserId', 'phone', 'email', 'resumeUrl', 'token', 'password'
]
for name in ('CandidateScoringOverviewResponse', 'CandidateScoringCoverage',
'CandidateScoringExcludedDimension', 'DimensionScoreDetailResponse',
'CandidateScoringRuleContribution', 'CandidateScoringValidity'):
assert name in d['components']['schemas'], f'Missing N02 schema {name}'
encoded = json.dumps(resolve_schema(d['components']['schemas'][name]), ensure_ascii=False)
for field in student_explanation_forbidden:
assert field not in encoded, f'N02 schema {name} exposes forbidden field {field}'
company_schema_text = json.dumps(resolve_schema(d['components']['schemas']['CompanyCandidateScoringResponse']), ensure_ascii=False)
for field in ('explanationVersion', 'coverage', 'comparabilityNotice', 'displayName', 'ruleContributions',
'reasons', 'dataSources', 'limitations', 'improvementActions', 'evidenceLevel',
'validity', 'completedAt', 'validUntil', 'LOGIC', 'COMMUNICATION', 'EXECUTION',
'LEARNING', 'studentUserId', 'candidateId', 'resultId', 'attemptId', 'questionCode',
'answerKey', 'scoringRule', 'competencyTag', 'phone', 'email', 'resumeUrl'):
assert field not in company_schema_text, f'CompanyCandidateScoringResponse exposes N02 field {field}'
candidate_scoring_paths = [
'/profile/me/candidate-scoring',
'/profile/me/candidate-scoring/dimensions/{dimension}',
'/profile/me/candidate-scoring/trend',
'/company/candidates/{applicationId}/scoring',
]
assert all(path in paths for path in candidate_scoring_paths), 'Candidate scoring path set changed'
assert sum(len(paths[path]) for path in candidate_scoring_paths) == 4, 'Candidate scoring operation count changed'
print(' N02 explanation schemas validated; enterprise scoring schema unchanged; 4 scoring operations preserved')
# N07 消息撤回:仅新增一个 POST,消息响应为白名单 DTO。
n07_path = '/conversations/{conversationId}/messages/{messageId}/recall'
assert n07_path in paths, 'N07 recall path missing'
assert list(paths[n07_path].keys()) == ['post'], 'N07 must expose exactly one POST operation'
n07_operation = paths[n07_path]['post']
assert n07_operation.get('parameters') and len(n07_operation['parameters']) == 2, 'N07 path parameters missing'
assert n07_operation.get('responses', {}).get('200', {}).get('content', {}).get('*/*', {}).get('schema', {}).get('$ref', '').endswith('ApiResponseMessageResponse'), 'N07 200 schema missing'
assert 'MessageResponse' in d['components']['schemas'], 'MessageResponse schema missing'
assert 'ApiResponseMessageResponse' in d['components']['schemas'], 'ApiResponseMessageResponse schema missing'
assert 'MessagePageResponse' in d['components']['schemas'], 'MessagePageResponse schema missing'
assert 'ApiResponseMessagePageResponse' in d['components']['schemas'], 'ApiResponseMessagePageResponse schema missing'
# Inspect the raw array item reference here. ``resolve_schema`` intentionally
# expands local refs and therefore removes the ``$ref`` node; using the
# expanded object for this assertion made a valid MessageResponse array look
# invalid even though the static contract was correct.
message_page_schema = resolve_schema(d['components']['schemas']['MessagePageResponse'])
message_page_items = d['components']['schemas']['MessagePageResponse'].get('properties', {}).get('items', {})
assert message_page_items.get('type') == 'array', 'MessagePageResponse items missing'
assert message_page_items.get('items', {}).get('$ref', '').endswith('MessageResponse'), 'MessagePageResponse item schema missing'
assert paths['/conversations/{id}/messages']['get']['responses']['200']['content']['*/*']['schema'].get('$ref', '').endswith('ApiResponseMessagePageResponse'), 'message list schema missing'
message_props = d['components']['schemas']['MessageResponse'].get('properties', {})
for field in ('id', 'conversationId', 'content', 'status', 'createdAt'):
assert field in message_props, f'MessageResponse missing {field}'
assert message_props['status'].get('enum') == ['SENT', 'RECALLED'], 'MessageResponse status enum mismatch'
message_text = json.dumps(resolve_schema(d['components']['schemas']['MessageResponse']), ensure_ascii=False)
for forbidden in ('originalContent', 'previousContent', 'studentUserId', 'candidateId', 'phone', 'email',
'resumeUrl', 'candidateKey', 'candidateKeyDigest', 'password', 'token', 'secret'):
assert forbidden not in message_text, f'MessageResponse exposes forbidden field {forbidden}'
assert '/conversations' in paths and '/conversations/messages' in paths
assert '/conversations/{id}/messages' in paths and '/conversations/{id}/read' in paths
print(' N07 recall path/schema validated; existing messaging operations preserved')
# ── S02:Access/Refresh Token 隔离 + 密码认证 operation ─────────────
# 只允许新增 /auth/refresh 与 /auth/logout;登录原字段不得删除;
# 新 schema 不得暴露数据库字段或凭据。
assert '/auth/refresh' in paths, 'Missing /auth/refresh'
assert '/auth/logout' in paths, 'Missing /auth/logout'
assert 'post' in paths['/auth/refresh'], '/auth/refresh must be POST'
assert 'post' in paths['/auth/logout'], '/auth/logout must be POST'
_refresh_body = paths['/auth/refresh']['post']['requestBody']['content']['application/json']['schema']
_refresh_req = resolve_schema(_refresh_body)
assert sorted(_refresh_req['properties']) == ['refreshToken'], \
f"Refresh request body must only contain refreshToken, got {sorted(_refresh_req['properties'])}"
assert 'requestBody' not in paths['/auth/logout']['post'], '/auth/logout must not declare requestBody'
# 登录接口的静态契约不得被 S02 改动(请求/响应引用保持原样)
_login_post = paths['/auth/login']['post']
assert _login_post['requestBody']['content']['application/json']['schema']['$ref'] == \
'#/components/schemas/LoginRequest', 'login request schema changed'
assert _login_post['responses']['200']['content']['*/*']['schema']['$ref'] == \
'#/components/schemas/ApiResponseMapStringObject', 'login response schema changed'
assert _login_post.get('operationId') == 'mockLogin', 'login operationId changed'
_s02_text = json.dumps({
'refresh': paths['/auth/refresh'],
'logout': paths['/auth/logout'],
'schemas': {k: v for k, v in d['components']['schemas'].items()
if k in ('RefreshTokenRequest', 'TokenPairResponse', 'ApiResponseTokenPairResponse')},
}, ensure_ascii=False)
for forbidden in ('tokenHash', 'refreshTokenHash', 'sessionId', 'revocationReason',
'phone', 'secret', 'password', 'jwt'):
assert forbidden not in _s02_text, f'S02 contract exposes forbidden field {forbidden}'
# 密码认证四个 operation 的请求体必须是白名单 DTO,不能携带 userId/sessionId/角色。
_password_paths = {
'/auth/password/login': 'PasswordLoginRequest',
'/auth/password/set': 'PasswordSetRequest',
'/auth/password/change': 'PasswordChangeRequest',
'/auth/password/reset': 'PasswordResetRequest',
}
for schema_name, expected_fields in skill_schema_fields.items():
schema = schemas.get(schema_name)
assert schema is not None, f'Missing schema: {schema_name}'
assert expected_fields <= set(schema.get('properties', {})), f'Schema fields missing: {schema_name}'
word_cloud_example = paths['/skills/word-cloud']['get']['responses']['200']['content']['application/json']['example']['data']
assert word_cloud_example['total'] == len(word_cloud_example['items'])
match_example = paths['/skills/match']['get']['responses']['200']['content']['application/json']['example']['data']
assert match_example['matchedCount'] == len(match_example['matchedSkills'])
assert match_example['totalCount'] == len(match_example['matchedSkills']) + len(match_example['missingSkills'])
assert match_example['matchScore'] == round(match_example['matchedCount'] * 100 / match_example['totalCount'])
learning_path_example = paths['/skills/learning-path']['get']['responses']['200']['content']['application/json']['example']['data']
assert learning_path_example['total'] == len(learning_path_example['items'])
print(" Skill cloud endpoints and schemas present (3 GET operations)")
for _path, _schema_name in _password_paths.items():
assert _path in paths and set(paths[_path]) == {'post'}, f'Invalid password path: {_path}'
_body = paths[_path]['post']['requestBody']['content']['application/json']['schema']
assert _body.get('$ref', '').endswith(_schema_name), f'{_path} request schema mismatch'
_schema_text = json.dumps(resolve_schema(d['components']['schemas'][_schema_name]), ensure_ascii=False)
for _forbidden in ('userId', 'sessionId', 'role', 'passwordHash', 'token', 'secret'):
assert _forbidden not in _schema_text, f'{_path} exposes {_forbidden}'
_auth_paths = {p for p in paths if p.startswith('/auth')}
assert _auth_paths == {
'/auth/sms/send', '/auth/login', '/auth/me',
'/auth/refresh', '/auth/logout',
'/auth/password/login', '/auth/password/set', '/auth/password/change', '/auth/password/reset',
'/auth/company/register',
}, f'Unexpected /auth paths: {sorted(_auth_paths)}'
print(' S02 refresh/logout and password authentication operations validated')
# ── Phase A:企业注册与入驻聚合 ─────────────────────────────────────
assert set(paths['/auth/company/register']) == {'post'}
_company_register = paths['/auth/company/register']['post']
assert _company_register['requestBody']['content']['application/json']['schema']['$ref'] \
== '#/components/schemas/CompanyRegistrationRequest'
assert _company_register['responses']['200']['content']['*/*']['schema']['$ref'] \
== '#/components/schemas/ApiResponseCompanyRegistrationResponse'
_company_req = resolve_schema(d['components']['schemas']['CompanyRegistrationRequest'])
assert sorted(_company_req['properties']) == ['code', 'company', 'password', 'phone']
for _forbidden in ('userId', 'role', 'verified', 'passwordHash'):
assert _forbidden not in json.dumps(_company_req, ensure_ascii=False)
assert set(paths['/companies/me/onboarding']) == {'get'}
assert paths['/companies/me/onboarding']['get']['responses']['200']['content']['*/*']['schema']['$ref'] \
== '#/components/schemas/ApiResponseCompanyOnboardingResponse'
for _schema_name in ('CompanyOnboardingResponse', 'CompanySummary', 'VerificationSummary'):
assert _schema_name in d['components']['schemas'], f'Missing Phase A schema: {_schema_name}'
_onboarding_text = json.dumps({k: d['components']['schemas'][k] for k in
('CompanyOnboardingResponse', 'CompanySummary', 'VerificationSummary')},
ensure_ascii=False)
for _forbidden in ('materialsJson', 'decisionReason', 'reviewerId', 'storagePath'):
assert _forbidden not in _onboarding_text
print(' Phase A company registration/onboarding paths and redacted schemas validated')
# ── S03:唯一新增的企业简历下载接口 ────────────────────────────────
_download_path = '/company/candidates/{applicationId}/resume'
assert _download_path in paths, 'Missing company resume download endpoint'
_download_get = paths[_download_path]['get']
assert 'requestBody' not in _download_get, 'Resume download must not declare requestBody'
assert set(paths[_download_path].keys()) == {'get'}, 'Resume download must be GET only'
# 不得存在按 studentUserId / candidateKey 下载简历的入口
for _p in paths:
_lower = _p.lower()
if 'resume' in _lower or 'download' in _lower:
assert 'studentuserid' not in _lower, f'Resume path must not key on studentUserId: {_p}'
assert 'candidatekey' not in _lower, f'Resume path must not key on candidateKey: {_p}'
# 企业候选人 DTO 不得回带 resumeUrl / studentUserId / phone / email / storagePath
for _dto in ('CandidateDetailView', 'CandidateItemView'):
_node = d['components']['schemas'].get(_dto)
if _node is None:
continue
_text = json.dumps(resolve_schema(_node), ensure_ascii=False)
for _forbidden in ('resumeUrl', 'studentUserId', 'phone', 'email', 'storagePath'):
assert _forbidden not in _text, f'{_dto} exposes forbidden field {_forbidden}'
# Actuator 不得进入业务 OpenAPI
for _p in paths:
assert not _p.startswith('/actuator'), f'Actuator must not appear in the business contract: {_p}'
print(' S03 company resume download contract validated; candidate DTOs remain redacted')
# 统计 operation 总数并校验 V43 + N07 + S02 + S03 + Phase A 计数 220
operations = sum(len(methods) for methods in d['paths'].values())
print('operations:', operations)
assert operations == 220, f"Expected 220 operations, got {operations}"
print(" Offer advance endpoints all present")
print(" 26 operations all present")
print(" Account deletion endpoints present (3 operations)")
onboarding_success_refs = {
('/onboarding/companies/{companyId}/checklist', 'get'): 'ApiResponseEffectiveChecklistResponse',
('/onboarding/companies/{companyId}/checklist/items/{itemId}/completion', 'put'): 'ApiResponseProgressUpdateResponse',
('/onboarding/companies/{companyId}/knowledge', 'get'): 'ApiResponseCompanyKnowledgeOverviewResponse',
('/onboarding/company/checklist-items', 'get'): 'ApiResponseListCompanyChecklistItemView',
('/onboarding/company/checklist-items', 'post'): 'ApiResponseCompanyChecklistItemView',
('/onboarding/company/checklist-items/{itemId}', 'put'): 'ApiResponseCompanyChecklistItemView',
('/onboarding/company/checklist-items/{itemId}', 'delete'): 'ApiResponseCompanyChecklistItemView',
('/onboarding/company/knowledge', 'get'): 'ApiResponseCompanyKnowledgeView',
('/onboarding/company/knowledge', 'put'): 'ApiResponseCompanyKnowledgeView',
}
for (path, method), schema_name in onboarding_success_refs.items():
response = paths[path][method]['responses']['200']
ref = response['content']['*/*']['schema']['$ref']
assert ref == f'#/components/schemas/{schema_name}', f'Wrong success schema for {method.upper()} {path}: {ref}'
for status_code in ('401', '403'):
assert status_code in paths[path][method]['responses'], f'Missing {status_code} for {method.upper()} {path}'
unauthorized = paths[path][method]['responses']['401']
assert unauthorized['content']['application/json']['schema']['$ref'] == '#/components/schemas/UnauthorizedResponse'
forbidden = paths[path][method]['responses']['403']
assert forbidden['content']['application/json']['schema']['$ref'] == '#/components/schemas/ApiResponse'
assert forbidden['content']['application/json']['example']['code'] == 2002
onboarding_schema_fields = {
'CompanyChecklistItemCreateRequest': {'title', 'description', 'category', 'sortOrder', 'required', 'idempotencyKey'},
'CompanyChecklistItemUpdateRequest': {'title', 'description', 'category', 'sortOrder', 'required', 'active', 'version'},
'CompanyChecklistItemView': {'itemId', 'source', 'itemKey', 'title', 'description', 'category', 'required', 'sortOrder', 'active', 'version'},
'EffectiveChecklistResponse': {'companyId', 'companyName', 'customized', 'totalItems', 'completedItems', 'completionRate', 'items'},
'ChecklistItemView': {'itemId', 'source', 'itemKey', 'title', 'description', 'category', 'required', 'sortOrder', 'active', 'completed', 'completedAt', 'progressVersion'},
'ProgressUpdateRequest': {'completed', 'version'},
'ProgressUpdateResponse': {'itemId', 'completed', 'completedAt', 'progressVersion', 'totalItems', 'completedItems', 'completionRate'},
'CompanyKnowledgeUpdateRequest': {'culture', 'workPolicies', 'onboardingGuide', 'version'},
'CompanyKnowledgeView': {'configured', 'culture', 'workPolicies', 'onboardingGuide', 'updatedAt', 'version'},
'CompanyKnowledgeOverviewResponse': {'company', 'knowledgeState', 'knowledge', 'reviewSummary'},
'CompanyView': {'id', 'name', 'logoUrl', 'description', 'industry', 'website', 'address', 'verified'},
'KnowledgeView': {'culture', 'workPolicies', 'onboardingGuide', 'updatedAt', 'version'},
'ReviewSummaryView': {'state', 'totalReviews', 'averageRating', 'ratingDistribution', 'topTags', 'dimensionSummary'},
'DimensionSummaryView': {'averageScore', 'sampleCount', 'dataState'},
}
for schema_name, expected_fields in onboarding_schema_fields.items():
schema = schemas.get(schema_name)
assert schema is not None, f'Missing onboarding schema: {schema_name}'
assert expected_fields <= set(schema.get('properties', {})), (
f'Onboarding schema fields missing: {schema_name}'
)
create_request = schemas['CompanyChecklistItemCreateRequest']['properties']
assert create_request['sortOrder'].get('default') == 1000, (
'CompanyChecklistItemCreateRequest.sortOrder default must be 1000'
)
assert create_request['required'].get('default') is False, (
'CompanyChecklistItemCreateRequest.required default must be false'
)
knowledge_success_example = paths['/onboarding/company/knowledge']['put']['responses']['200']['content']['*/*']['example']
assert knowledge_success_example['data'].get('version') == 2, (
'PUT /onboarding/company/knowledge 200 example data.version must be 2'
)
onboarding_error_paths = {
9701: ('/onboarding/company/checklist-items/{itemId}', 'put', '404'),
9702: ('/onboarding/company/checklist-items/{itemId}', 'put', '409'),
9703: ('/onboarding/company/checklist-items', 'post', '409'),
9704: ('/onboarding/company/checklist-items', 'post', '409'),
9705: ('/onboarding/companies/{companyId}/checklist/items/{itemId}/completion', 'put', '409'),
9706: ('/onboarding/company/knowledge', 'put', '409'),
}
for error_code, (path, method, status_code) in onboarding_error_paths.items():
response_text = json.dumps(paths[path][method]['responses'][status_code], ensure_ascii=False)
assert str(error_code) in response_text, f'Missing onboarding error code {error_code} in {method.upper()} {path}'
print(' N04 onboarding endpoints and schemas present (9 operations / 6 paths)')
data = json.dumps(d)
assert '"" : "#/components/schemas/' not in data, "Found empty ref key"
print("No empty ref keys found")

5
mydreamjob-server/pom.xml

@ -54,6 +54,11 @@
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-actuator</artifactId>
</dependency>
<!-- S03-C:Actuator 暴露 prometheus 端点所需;缺少该依赖时 /actuator/prometheus 不存在。 -->
<dependency>
<groupId>io.micrometer</groupId>
<artifactId>micrometer-registry-prometheus</artifactId>
</dependency>
<!-- Flyway -->
<dependency>

46
mydreamjob-server/src/main/java/com/mydreamjob/ability/AbilityAnswer.java

@ -0,0 +1,46 @@
package com.mydreamjob.ability;
import jakarta.persistence.*;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import org.springframework.data.annotation.CreatedDate;
import org.springframework.data.annotation.LastModifiedDate;
import org.springframework.data.jpa.domain.support.AuditingEntityListener;
import java.time.LocalDateTime;
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
@Entity
@Table(name = "ability_answers")
@EntityListeners(AuditingEntityListener.class)
public class AbilityAnswer {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "attempt_id", nullable = false)
private Long attemptId;
@Column(name = "attempt_question_id", nullable = false)
private Long attemptQuestionId;
@Column(name = "selected_option_id", nullable = false, length = 4)
private String selectedOptionId;
@Column(name = "awarded_score", nullable = false)
private Integer awardedScore;
@CreatedDate
@Column(name = "answered_at", nullable = false, updatable = false)
private LocalDateTime answeredAt;
@LastModifiedDate
@Column(name = "updated_at", nullable = false)
private LocalDateTime updatedAt;
}

Some files were not shown because too many files changed in this diff

Loading…
Cancel
Save