Browse Source

feat: deliver V35-V37 assessment and risk governance

feat/message-recall
Zhang Shun 2 months ago
parent
commit
662fad8e58
  1. 262
      backend-infra/v35_acceptance.ps1
  2. 188
      docs/ability-assessment-api.md
  3. 1064
      docs/ability-question-bank-v0.1.json
  4. 53
      docs/backend-development-roadmap.md
  5. 151
      docs/frontend-integration.md
  6. 7887
      docs/openapi-rc.json
  7. 119
      docs/riasec-assessment-api.md
  8. 253
      docs/risk-governance-api.md
  9. 224
      docs/validate_ability_bank.py
  10. 141
      docs/verify_openapi.py
  11. 46
      mydreamjob-server/src/main/java/com/mydreamjob/ability/AbilityAnswer.java
  12. 14
      mydreamjob-server/src/main/java/com/mydreamjob/ability/AbilityAnswerRepository.java
  13. 79
      mydreamjob-server/src/main/java/com/mydreamjob/ability/AbilityAttempt.java
  14. 44
      mydreamjob-server/src/main/java/com/mydreamjob/ability/AbilityAttemptQuestion.java
  15. 15
      mydreamjob-server/src/main/java/com/mydreamjob/ability/AbilityAttemptQuestionRepository.java
  16. 56
      mydreamjob-server/src/main/java/com/mydreamjob/ability/AbilityAttemptRepository.java
  17. 580
      mydreamjob-server/src/main/java/com/mydreamjob/ability/AbilityAttemptService.java
  18. 51
      mydreamjob-server/src/main/java/com/mydreamjob/ability/AbilityAttemptWriter.java
  19. 32
      mydreamjob-server/src/main/java/com/mydreamjob/ability/AbilityCatalogService.java
  20. 164
      mydreamjob-server/src/main/java/com/mydreamjob/ability/AbilityController.java
  21. 79
      mydreamjob-server/src/main/java/com/mydreamjob/ability/AbilityDefinition.java
  22. 13
      mydreamjob-server/src/main/java/com/mydreamjob/ability/AbilityDefinitionRepository.java
  23. 98
      mydreamjob-server/src/main/java/com/mydreamjob/ability/AbilityImprovementCatalog.java
  24. 88
      mydreamjob-server/src/main/java/com/mydreamjob/ability/AbilityQuestion.java
  25. 12
      mydreamjob-server/src/main/java/com/mydreamjob/ability/AbilityQuestionRepository.java
  26. 62
      mydreamjob-server/src/main/java/com/mydreamjob/ability/AbilityResult.java
  27. 150
      mydreamjob-server/src/main/java/com/mydreamjob/ability/AbilityResultComputer.java
  28. 14
      mydreamjob-server/src/main/java/com/mydreamjob/ability/AbilityResultRepository.java
  29. 109
      mydreamjob-server/src/main/java/com/mydreamjob/ability/AbilityResultService.java
  30. 88
      mydreamjob-server/src/main/java/com/mydreamjob/ability/AbilitySnapshotCodec.java
  31. 44
      mydreamjob-server/src/main/java/com/mydreamjob/ability/dto/AbilityAttemptResponse.java
  32. 23
      mydreamjob-server/src/main/java/com/mydreamjob/ability/dto/AbilityHistoryItem.java
  33. 76
      mydreamjob-server/src/main/java/com/mydreamjob/ability/dto/AbilityResultResponse.java
  34. 17
      mydreamjob-server/src/main/java/com/mydreamjob/ability/dto/AbilityTypeResponse.java
  35. 11
      mydreamjob-server/src/main/java/com/mydreamjob/ability/dto/CreateAbilityAttemptRequest.java
  36. 21
      mydreamjob-server/src/main/java/com/mydreamjob/ability/dto/SaveAbilityAnswersRequest.java
  37. 43
      mydreamjob-server/src/main/java/com/mydreamjob/assessment/AssessmentAnswer.java
  38. 15
      mydreamjob-server/src/main/java/com/mydreamjob/assessment/AssessmentAnswerRepository.java
  39. 89
      mydreamjob-server/src/main/java/com/mydreamjob/assessment/AssessmentAttempt.java
  40. 43
      mydreamjob-server/src/main/java/com/mydreamjob/assessment/AssessmentAttemptQuestion.java
  41. 12
      mydreamjob-server/src/main/java/com/mydreamjob/assessment/AssessmentAttemptQuestionRepository.java
  42. 32
      mydreamjob-server/src/main/java/com/mydreamjob/assessment/AssessmentAttemptRepository.java
  43. 484
      mydreamjob-server/src/main/java/com/mydreamjob/assessment/AssessmentAttemptService.java
  44. 60
      mydreamjob-server/src/main/java/com/mydreamjob/assessment/AssessmentAttemptWriter.java
  45. 67
      mydreamjob-server/src/main/java/com/mydreamjob/assessment/AssessmentCatalogService.java
  46. 178
      mydreamjob-server/src/main/java/com/mydreamjob/assessment/AssessmentController.java
  47. 76
      mydreamjob-server/src/main/java/com/mydreamjob/assessment/AssessmentDefinition.java
  48. 11
      mydreamjob-server/src/main/java/com/mydreamjob/assessment/AssessmentDefinitionRepository.java
  49. 80
      mydreamjob-server/src/main/java/com/mydreamjob/assessment/AssessmentQuestion.java
  50. 11
      mydreamjob-server/src/main/java/com/mydreamjob/assessment/AssessmentQuestionRepository.java
  51. 64
      mydreamjob-server/src/main/java/com/mydreamjob/assessment/AssessmentResult.java
  52. 85
      mydreamjob-server/src/main/java/com/mydreamjob/assessment/AssessmentResultComputer.java
  53. 15
      mydreamjob-server/src/main/java/com/mydreamjob/assessment/AssessmentResultRepository.java
  54. 143
      mydreamjob-server/src/main/java/com/mydreamjob/assessment/AssessmentResultService.java
  55. 59
      mydreamjob-server/src/main/java/com/mydreamjob/assessment/CareerDirectionCatalog.java
  56. 26
      mydreamjob-server/src/main/java/com/mydreamjob/assessment/dto/AssessmentAttemptResponse.java
  57. 26
      mydreamjob-server/src/main/java/com/mydreamjob/assessment/dto/AssessmentHistoryItem.java
  58. 22
      mydreamjob-server/src/main/java/com/mydreamjob/assessment/dto/AssessmentQuestionResponse.java
  59. 40
      mydreamjob-server/src/main/java/com/mydreamjob/assessment/dto/AssessmentResultResponse.java
  60. 24
      mydreamjob-server/src/main/java/com/mydreamjob/assessment/dto/AssessmentTypeResponse.java
  61. 21
      mydreamjob-server/src/main/java/com/mydreamjob/assessment/dto/CreateAssessmentAttemptRequest.java
  62. 41
      mydreamjob-server/src/main/java/com/mydreamjob/assessment/dto/SaveAssessmentAnswersRequest.java
  63. 7
      mydreamjob-server/src/main/java/com/mydreamjob/audit/AuditLogRepository.java
  64. 33
      mydreamjob-server/src/main/java/com/mydreamjob/common/exception/ErrorCode.java
  65. 45
      mydreamjob-server/src/main/java/com/mydreamjob/common/exception/GlobalExceptionHandler.java
  66. 18
      mydreamjob-server/src/main/java/com/mydreamjob/common/time/TimeConfig.java
  67. 4
      mydreamjob-server/src/main/java/com/mydreamjob/config/SecurityConfig.java
  68. 219
      mydreamjob-server/src/main/java/com/mydreamjob/risk/AdminRiskController.java
  69. 138
      mydreamjob-server/src/main/java/com/mydreamjob/risk/RiskEventDetector.java
  70. 32
      mydreamjob-server/src/main/java/com/mydreamjob/risk/RiskEventScanScheduler.java
  71. 273
      mydreamjob-server/src/main/java/com/mydreamjob/risk/RiskGovernanceService.java
  72. 61
      mydreamjob-server/src/main/java/com/mydreamjob/risk/ScoringRiskAction.java
  73. 6
      mydreamjob-server/src/main/java/com/mydreamjob/risk/ScoringRiskActionRepository.java
  74. 106
      mydreamjob-server/src/main/java/com/mydreamjob/risk/ScoringRiskEvent.java
  75. 73
      mydreamjob-server/src/main/java/com/mydreamjob/risk/ScoringRiskEventRepository.java
  76. 54
      mydreamjob-server/src/main/java/com/mydreamjob/risk/ScoringRiskScanCheckpoint.java
  77. 19
      mydreamjob-server/src/main/java/com/mydreamjob/risk/ScoringRiskScanCheckpointRepository.java
  78. 78
      mydreamjob-server/src/main/java/com/mydreamjob/risk/ScoringRuleConfig.java
  79. 15
      mydreamjob-server/src/main/java/com/mydreamjob/risk/ScoringRuleConfigRepository.java
  80. 23
      mydreamjob-server/src/main/java/com/mydreamjob/risk/dto/RiskDistributionResponse.java
  81. 22
      mydreamjob-server/src/main/java/com/mydreamjob/risk/dto/RiskEventActionRequest.java
  82. 24
      mydreamjob-server/src/main/java/com/mydreamjob/risk/dto/RiskEventResponse.java
  83. 33
      mydreamjob-server/src/main/java/com/mydreamjob/risk/dto/RiskOverviewResponse.java
  84. 19
      mydreamjob-server/src/main/java/com/mydreamjob/risk/dto/RiskRuleResponse.java
  85. 32
      mydreamjob-server/src/main/java/com/mydreamjob/risk/dto/RiskRuleUpdateRequest.java
  86. 24
      mydreamjob-server/src/main/java/com/mydreamjob/risk/dto/RiskTrendResponse.java
  87. 271
      mydreamjob-server/src/main/resources/db/migration/V34__riasec_assessment.sql
  88. 225
      mydreamjob-server/src/main/resources/db/migration/V35__ability_assessment.sql
  89. 95
      mydreamjob-server/src/main/resources/db/migration/V36__risk_governance.sql
  90. 210
      mydreamjob-server/src/test/java/com/mydreamjob/ability/AbilityAttemptServiceTest.java
  91. 39
      mydreamjob-server/src/test/java/com/mydreamjob/ability/AbilityCatalogServiceTest.java
  92. 163
      mydreamjob-server/src/test/java/com/mydreamjob/ability/AbilityControllerTest.java
  93. 391
      mydreamjob-server/src/test/java/com/mydreamjob/ability/AbilityPersistenceIntegrationTest.java
  94. 128
      mydreamjob-server/src/test/java/com/mydreamjob/ability/AbilityQuestionBankTest.java
  95. 111
      mydreamjob-server/src/test/java/com/mydreamjob/ability/AbilityResultComputerTest.java
  96. 62
      mydreamjob-server/src/test/java/com/mydreamjob/ability/AbilitySchemaValidationSmokeTest.java
  97. 288
      mydreamjob-server/src/test/java/com/mydreamjob/assessment/AssessmentAttemptServiceTest.java
  98. 99
      mydreamjob-server/src/test/java/com/mydreamjob/assessment/AssessmentCatalogServiceTest.java
  99. 280
      mydreamjob-server/src/test/java/com/mydreamjob/assessment/AssessmentControllerTest.java
  100. 430
      mydreamjob-server/src/test/java/com/mydreamjob/assessment/AssessmentPersistenceIntegrationTest.java

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' }

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

53
docs/backend-development-roadmap.md

@ -26,20 +26,20 @@
| 项目 | 当前状态 |
|---|---|
| OpenAPI | **126 paths、160 operations** |
| Flyway | **V1–V33** 已存在(V34 为纯接口扩展,不含 Flyway 迁移) |
| 全量测试 | **1275/1275 通过**(本次 `mvn clean verify` 实际数量) |
| 已完成范围 | 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 统一求职进度聚合,及认证、资料、岗位、投递、面试、Offer、看板等核心闭环 |
| 当前结论 | 已达到已实现范围的 Release Candidate;V33 Report/Block 已完成真实 MySQL/Docker 黑盒验收并正式冻结;V34 统一求职进度聚合已完成实现并通过真实 MySQL/Docker 黑盒验收(PASS=99 / FAIL=0 / SKIP=0),待建立正式 Git 检查点 |
| OpenAPI | **148 paths、182 operations** |
| Flyway | **V1–V36 已应用**;**V34 为 RIASEC 测评迁移(功能版本 V35)**;**V35 为四维能力测评迁移(功能版本 V36)**;**V36 为防刷风险概览与规则配置迁移(功能版本 V37)** |
| 全量测试 | **1389/1389 通过**(`mvn clean verify`,0 Failures / 0 Errors / 0 Skipped) |
| 已完成范围 | 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)**,及认证、资料、岗位、投递、面试、Offer、看板等核心闭环 |
| 当前结论 | V35 已通过真实 MySQL/Docker 验收(PASS=39/FAIL=0/SKIP=1;唯一 SKIP 为验收库无 INACTIVE 定义)。**V36 四维能力测评已通过真实 MySQL/Docker 验收(PASS=93/FAIL=0/SKIP=0);V37 防刷风险治理已通过真实 MySQL/Docker 验收(PASS=76/FAIL=0/SKIP=0)。** V37 修复前 4 条 `RISK_EVENT_DETECTED.entity_id=NULL` 审计只作历史观察项,不回填、不影响修复后验收。运行时/静态 OpenAPI 均为 148/182,风险扫描不写候选评分引擎。 |
| 本规划范围 | 代码修复、新功能开发、接口契约、数据权限和治理能力 |
### 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 统一求职进度聚合 |
| 已完成、待真实 MySQL/Docker 验收 | — |
| 已设计未编码 | 测评系统(Holland/能力测评)、公平性检测、SHAP/规则解释、防刷审计、企业评价管理、企业人才推荐 |
| 已完成并已验收 | 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)** |
| 已完成、待真实 MySQL/Docker 复验 | — |
| 已设计未编码 | 公平性检测、SHAP/规则解释、企业评价管理、企业人才推荐 |
| 未开始 | AI 简历诊断/优化、AI 模拟面试、行业技能词云、入职准备清单、薪资计算器、诚信积分、消息撤回 |
### 2.1 已确认的产品决策
@ -125,14 +125,14 @@
| HR 评价详情 | `candidate_feedback` 已实现 | 不是结构化面试评分 |
| 企业评价管理 | 有评价审核和管理员决策 | 缺企业端评价管理、回复和运营动作 |
| 企业人才推荐 | 有候选人列表和评分 | 缺推荐排序、推荐理由和可解释结果 |
| 运营规则配置 | Figma 有页面 | 后端暂无规则配置接口 |
| 防刷审计报告 | 已有评价审核基础能力 | 不是风险等级、风险趋势、风险分布和事件审计报告 |
| 运营规则配置 | V37 `GET/PUT /admin/scoring/risk/rules` 已实现 | 仅 ADMIN;当前为确定性拒绝型安全审计规则,不做自动处置 |
| 防刷审计报告 | V37 风险概览、趋势、分布、事件与人工状态操作已实现 | 仅 ADMIN;不返回主体身份、原始审计内容、IP/UA 或凭据 |
### 4.3 完全缺失
- 霍兰德职业兴趣测评;
- 四维能力测评;
- 测评结果和测评岗位推荐;
- ~~霍兰德职业兴趣测评~~(已由 V35 实现:测评 + 结果 + 探索方向,Flyway V34);
- ~~四维能力测评~~(已由 V36 实现:限时答题、进度保存、结果、短板和历史,Flyway V35);
- 测评岗位推荐(V35/V36 已提供结果,但不根据结果推荐岗位,仍为缺失);
- AI 简历诊断和优化;
- AI 模拟面试和面试题预测;
- 行业技能词云;
@ -140,9 +140,9 @@
- 薪资计算器和薪资参考;
- 公平性检测;
- SHAP/规则解释;
- ADMIN 防刷审计和规则配置;
- ~~ADMIN 防刷审计和规则配置~~(已由 V37 实现:仅 ADMIN 的确定性风险概览、规则配置和人工处置,Flyway V36);
- 诚信积分和激励。
- 已非缺失(已实现):个人数据导出(V30)、隐私设置(V27)、账号注销(V28/V29)、举报与屏蔽(V33)、结构化面试评分(V31)、同岗位竞争力排名(V32)、统一求职进度聚合(V34)。
- 已非缺失(已实现):个人数据导出(V30)、隐私设置(V27)、账号注销(V28/V29)、举报与屏蔽(V33)、结构化面试评分(V31)、同岗位竞争力排名(V32)、统一求职进度聚合(V34)、RIASEC 职业兴趣测评(V35)、四维能力测评(V36)、ADMIN 防刷审计与规则配置(V37)。
## 5. 已有代码的优先修复项
@ -338,12 +338,11 @@ jobs
开发顺序:
1. 霍兰德职业兴趣测评;
2. 四维能力测评;
3. 测评答题和进度保存;
4. 测评结果;
5. 测评结果进入 ABILITY/GROWTH;
6. 根据测评结果推荐岗位。
1. ~~霍兰德职业兴趣测评~~(V35 已完成);
2. ~~四维能力测评、答题和进度保存~~(V36 已完成);
3. ~~测评结果~~(V35/V36 已完成);
4. 测评结果进入 ABILITY/GROWTH(需独立评审,当前未接入评分引擎);
5. 根据测评结果推荐岗位。
建议接口:
@ -374,7 +373,7 @@ V30__assessment.sql
### 阶段 5:公平性、SHAP、反刷审计
#### 防刷审计
#### 防刷审计(V37 已完成)
仅 ADMIN 可见:
@ -383,8 +382,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 等敏感信息。
@ -395,6 +395,7 @@ PUT /admin/scoring/rules/{id}
scoring_risk_events
scoring_rule_configs
scoring_risk_actions
scoring_risk_scan_checkpoints
```
#### 公平性检测
@ -456,12 +457,12 @@ scoring_risk_actions
| P0 | 个人数据导出 |
| P0 | 结构化面试评分 |
| P0/P1 | 同岗位竞争力排名 |
| P1 | 霍兰德和四维能力测评 |
| P1 | 测评结果进入评分引擎、测评岗位推荐 |
| P1 | 统一求职进度(V34 已完成) |
| P1 | 企业评价管理 |
| P1 | 企业人才推荐 |
| P1 | 公平性检测 |
| P1 | ADMIN 防刷审计 |
| P1 | 公平性检测 |
| P2 | SHAP 高级解释 |
| P2 | AI 简历、AI 模拟面试 |
| P2 | 技能词云、入职清单、诚信积分 |

151
docs/frontend-integration.md

@ -708,3 +708,154 @@ GET /profile/me/career-progress?page=0&size=20&status=INTERVIEW # 仅 STUDENT
- **脱敏**:不返回 `studentUserId/candidateId/companyUserId/evaluatorUserId/phone/email/resumeUrl/passwordHash/JWT`、通知标题/正文、评分 `comment`、Offer 薪资/条款、他人任何身份字段。**本接口只返回通知计数,通知详情仍用 `/notifications`**。
- **只读**:`GET`,不写任何业务表;`size` 上限 50,聚合查询次数与单页条数无关(无 N+1)。
- API 详见 [docs/career-progress-api.md](career-progress-api.md)。
## 21. RIASEC 职业兴趣测评(Assessment)联调
基于 RIASEC 理论的自研题库(72 道),**仅供职业兴趣探索参考,不构成心理或职业诊断**。结果不写入求职者评分引擎。
**接口(身份一律取 JWT,不接受 `userId` 参数)**
```
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}` 返回。
### 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 聚合返回,前端无需额外拉取明细。

7887
docs/openapi-rc.json

File diff suppressed because it is too large

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 错误码。

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

141
docs/verify_openapi.py

@ -6,7 +6,7 @@ p = PROJECT_ROOT / 'docs' / 'openapi-rc.json'
d = json.load(open(p, encoding='utf-8'))
print('paths:', len(d['paths']))
assert len(d['paths']) == 126, f"Expected 126 paths, got {len(d['paths'])}"
assert len(d['paths']) == 148, f"Expected 148 paths, got {len(d['paths'])}"
paths = d['paths']
required = [
@ -66,6 +66,31 @@ required = [
'/admin/reports',
'/admin/reports/{id}',
'/admin/reports/{id}/decision',
# 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}"
@ -135,6 +160,120 @@ 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")
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 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")
# 统计 operation 总数并校验基线 182
operations = sum(len(methods) for methods in d['paths'].values())
print('operations:', operations)
assert operations == 182, f"Expected 182 operations, got {operations}"
print(" Offer advance endpoints all present")
print(" 26 operations all present")
print(" Account deletion endpoints present (3 operations)")

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

14
mydreamjob-server/src/main/java/com/mydreamjob/ability/AbilityAnswerRepository.java

@ -0,0 +1,14 @@
package com.mydreamjob.ability;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.List;
public interface AbilityAnswerRepository extends JpaRepository<AbilityAnswer, Long> {
List<AbilityAnswer> findByAttemptId(Long attemptId);
List<AbilityAnswer> findByAttemptIdAndAttemptQuestionIdIn(Long attemptId, List<Long> attemptQuestionIds);
long countByAttemptId(Long attemptId);
}

79
mydreamjob-server/src/main/java/com/mydreamjob/ability/AbilityAttempt.java

@ -0,0 +1,79 @@
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_attempts")
@EntityListeners(AuditingEntityListener.class)
public class AbilityAttempt {
public static final String STATUS_DRAFT = "DRAFT";
public static final String STATUS_IN_PROGRESS = "IN_PROGRESS";
public static final String STATUS_COMPLETED = "COMPLETED";
public static final String STATUS_EXPIRED = "EXPIRED";
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "student_user_id", nullable = false)
private Long studentUserId;
@Column(name = "definition_id", nullable = false)
private Long definitionId;
@Column(name = "status", nullable = false, length = 20)
@Builder.Default
private String status = STATUS_DRAFT;
@Column(name = "active_key", length = 255)
private String activeKey;
@Column(name = "idempotency_key", length = 64)
private String idempotencyKey;
@Column(name = "question_set_version", nullable = false, length = 40)
private String questionSetVersion;
@Column(name = "answered_count", nullable = false)
@Builder.Default
private Integer answeredCount = 0;
@Version
@Column(name = "version", nullable = false)
@Builder.Default
private Long version = 0L;
@Column(name = "started_at")
private LocalDateTime startedAt;
@Column(name = "expires_at")
private LocalDateTime expiresAt;
@Column(name = "submitted_at")
private LocalDateTime submittedAt;
@Column(name = "completed_at")
private LocalDateTime completedAt;
@CreatedDate
@Column(name = "created_at", nullable = false, updatable = false)
private LocalDateTime createdAt;
@LastModifiedDate
@Column(name = "updated_at", nullable = false)
private LocalDateTime updatedAt;
}

44
mydreamjob-server/src/main/java/com/mydreamjob/ability/AbilityAttemptQuestion.java

@ -0,0 +1,44 @@
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.jpa.domain.support.AuditingEntityListener;
import java.time.LocalDateTime;
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
@Entity
@Table(name = "ability_attempt_questions")
@EntityListeners(AuditingEntityListener.class)
public class AbilityAttemptQuestion {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "attempt_id", nullable = false)
private Long attemptId;
@Column(name = "question_id", nullable = false)
private Long questionId;
@Column(name = "position", nullable = false)
private Integer position;
@Column(name = "question_version", nullable = false, length = 40)
private String questionVersion;
@Column(name = "question_snapshot_json", nullable = false, columnDefinition = "text")
private String questionSnapshotJson;
@CreatedDate
@Column(name = "created_at", nullable = false, updatable = false)
private LocalDateTime createdAt;
}

15
mydreamjob-server/src/main/java/com/mydreamjob/ability/AbilityAttemptQuestionRepository.java

@ -0,0 +1,15 @@
package com.mydreamjob.ability;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.List;
import java.util.Optional;
public interface AbilityAttemptQuestionRepository extends JpaRepository<AbilityAttemptQuestion, Long> {
List<AbilityAttemptQuestion> findByAttemptIdOrderByPositionAsc(Long attemptId);
boolean existsByAttemptIdAndQuestionId(Long attemptId, Long questionId);
long countByAttemptId(Long attemptId);
}

56
mydreamjob-server/src/main/java/com/mydreamjob/ability/AbilityAttemptRepository.java

@ -0,0 +1,56 @@
package com.mydreamjob.ability;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Optional;
public interface AbilityAttemptRepository extends JpaRepository<AbilityAttempt, Long> {
Optional<AbilityAttempt> findByStudentUserIdAndIdempotencyKey(Long studentUserId, String idempotencyKey);
boolean existsByStudentUserIdAndDefinitionIdAndStatusIn(Long studentUserId, Long definitionId, List<String> statuses);
@org.springframework.data.jpa.repository.Modifying
@org.springframework.transaction.annotation.Transactional
@Query("UPDATE AbilityAttempt a SET a.status='COMPLETED', a.submittedAt=:submittedAt, "
+ "a.completedAt=:submittedAt, a.answeredCount=:answeredCount, a.activeKey=null "
+ "WHERE a.id=:attemptId AND a.status IN ('DRAFT','IN_PROGRESS')")
int markCompleted(@Param("attemptId") Long attemptId,
@Param("submittedAt") LocalDateTime submittedAt,
@Param("answeredCount") int answeredCount);
@org.springframework.data.jpa.repository.Modifying
@org.springframework.transaction.annotation.Transactional
@Query("UPDATE AbilityAttempt a SET a.status='EXPIRED', a.activeKey=null "
+ "WHERE a.id=:attemptId AND a.status IN ('DRAFT','IN_PROGRESS')")
int markExpiredAndReleaseActive(@Param("attemptId") Long attemptId);
// 滚动窗口内 COMPLETED 次数(重测限制)
int countByStudentUserIdAndDefinitionIdAndStatusAndCompletedAtGreaterThanEqual(
Long studentUserId, Long definitionId, String status, LocalDateTime since);
List<AbilityAttempt> findByStudentUserIdAndDefinitionIdAndStatusOrderByCompletedAtDesc(
Long studentUserId, Long definitionId, String status);
List<AbilityAttempt> findByStudentUserIdAndDefinitionIdOrderByCompletedAtDesc(
Long studentUserId, Long definitionId);
org.springframework.data.domain.Page<AbilityAttempt> findByStudentUserIdOrderByCreatedAtDesc(
Long studentUserId, org.springframework.data.domain.Pageable pageable);
// 遗留超时的活跃答卷(startedAt 已启动但已到 expiresAt 的 DRAFT/IN_PROGRESS)
@Query("SELECT a FROM AbilityAttempt a WHERE a.status IN ('DRAFT','IN_PROGRESS') "
+ "AND a.expiresAt IS NOT NULL AND a.expiresAt <= :now")
List<AbilityAttempt> findTimedOutActive(@Param("now") LocalDateTime now);
@Query("SELECT a FROM AbilityAttempt a WHERE a.studentUserId=:userId AND a.definitionId=:defId "
+ "AND a.status IN ('DRAFT','IN_PROGRESS') "
+ "AND a.expiresAt IS NOT NULL AND a.expiresAt <= :now")
List<AbilityAttempt> findTimedOutActiveByUserAndDef(@Param("userId") Long userId,
@Param("defId") Long defId,
@Param("now") LocalDateTime now);
}

580
mydreamjob-server/src/main/java/com/mydreamjob/ability/AbilityAttemptService.java

@ -0,0 +1,580 @@
package com.mydreamjob.ability;
import com.fasterxml.jackson.databind.JsonNode;
import com.mydreamjob.ability.dto.AbilityAttemptResponse;
import com.mydreamjob.ability.dto.AbilityHistoryItem;
import com.mydreamjob.ability.dto.AbilityResultResponse;
import com.mydreamjob.ability.dto.CreateAbilityAttemptRequest;
import com.mydreamjob.ability.dto.SaveAbilityAnswersRequest;
import com.mydreamjob.audit.AuditContext;
import com.mydreamjob.audit.AuditService;
import com.mydreamjob.auth.User;
import com.mydreamjob.auth.UserRepository;
import com.mydreamjob.common.exception.BusinessException;
import com.mydreamjob.common.exception.ErrorCode;
import lombok.RequiredArgsConstructor;
import org.springframework.dao.DataIntegrityViolationException;
import org.springframework.dao.OptimisticLockingFailureException;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Isolation;
import org.springframework.transaction.annotation.Transactional;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.time.Clock;
import java.time.Duration;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Random;
import java.util.Set;
import java.util.function.Function;
import java.util.stream.Collectors;
@Service
@RequiredArgsConstructor
public class AbilityAttemptService {
private static final int MAX_PAGE_SIZE = 50;
private static final String[] DIMENSIONS = {"LOGIC", "COMMUNICATION", "EXECUTION", "LEARNING"};
private final AbilityDefinitionRepository definitionRepository;
private final AbilityQuestionRepository questionRepository;
private final AbilityAttemptRepository attemptRepository;
private final AbilityAttemptQuestionRepository attemptQuestionRepository;
private final AbilityAnswerRepository answerRepository;
private final AbilityResultRepository resultRepository;
private final AbilityResultService resultService;
private final AbilityResultComputer resultComputer;
private final AbilityAttemptWriter attemptWriter;
private final AbilitySnapshotCodec snapshotCodec;
private final UserRepository userRepository;
private final AuditService auditService;
private final Clock clock;
// ─────────────────────────── 创建答卷 ───────────────────────────
@Transactional(isolation = Isolation.READ_COMMITTED)
public AbilityAttemptResponse create(Long userId, CreateAbilityAttemptRequest req, AuditContext ctx) {
requireStudent(userId);
AbilityDefinition def = definitionRepository.findByTypeCode(req.getTypeCode())
.orElseThrow(() -> new BusinessException(ErrorCode.ABILITY_TYPE_NOT_FOUND));
if (!AbilityDefinition.STATUS_ACTIVE.equals(def.getStatus())) {
throw new BusinessException(ErrorCode.ABILITY_TYPE_NOT_FOUND);
}
LocalDateTime now = now();
String idem = normalizeIdempotencyKey(req.getIdempotencyKey());
if (idem != null) {
AbilityAttempt existing = findByIdem(userId, idem);
if (existing != null) {
if (!def.getId().equals(existing.getDefinitionId())) {
throw new BusinessException(ErrorCode.ABILITY_IDEMPOTENCY_CONFLICT);
}
return toAttemptResponse(existing);
}
}
// 原子释放本学生本类型已超时的遗留活跃答卷
releaseTimedOut(userId, def.getId(), now);
if (attemptRepository.existsByStudentUserIdAndDefinitionIdAndStatusIn(
userId, def.getId(), List.of(AbilityAttempt.STATUS_DRAFT, AbilityAttempt.STATUS_IN_PROGRESS))) {
throw new BusinessException(ErrorCode.ABILITY_DUPLICATE_ACTIVE);
}
// 有效结果冷却期:存在未过期结果时不可重测
AbilityAttempt recentCompleted = attemptRepository
.findByStudentUserIdAndDefinitionIdAndStatusOrderByCompletedAtDesc(
userId, def.getId(), AbilityAttempt.STATUS_COMPLETED)
.stream().findFirst().orElse(null);
if (recentCompleted != null) {
AbilityResult r = resultRepository.findByAttemptId(recentCompleted.getId()).orElse(null);
if (r != null && r.getValidUntil() != null && now.isBefore(r.getValidUntil())) {
throw new BusinessException(ErrorCode.ABILITY_RETAKE_COOLDOWN);
}
}
// 滚动窗口内完成次数限制
int windowDays = def.getRetakeWindowDays() == null ? 90 : def.getRetakeWindowDays();
int maxCompleted = def.getRetakeMaxCompleted() == null ? 3 : def.getRetakeMaxCompleted();
long completedInWindow = attemptRepository
.countByStudentUserIdAndDefinitionIdAndStatusAndCompletedAtGreaterThanEqual(
userId, def.getId(), AbilityAttempt.STATUS_COMPLETED, now.minusDays(windowDays));
if (completedInWindow >= maxCompleted) {
throw new BusinessException(ErrorCode.ABILITY_RETAKE_LIMIT);
}
List<AbilityQuestion> all = questionRepository
.findByDefinitionIdAndActiveTrueOrderByPositionAsc(def.getId());
List<AbilityQuestion> sampled = sample(all, def);
int perDim = def.getQuestionsPerDimension() == null ? 6 : def.getQuestionsPerDimension();
AbilityAttempt attempt;
try {
attempt = attemptWriter.persistNew(userId, def, sampled, idem, activeKeyOf(userId, def.getId()));
} catch (DataIntegrityViolationException e) {
if (idem != null) {
AbilityAttempt existing = findByIdem(userId, idem);
if (existing != null) {
if (!def.getId().equals(existing.getDefinitionId())) {
throw new BusinessException(ErrorCode.ABILITY_IDEMPOTENCY_CONFLICT);
}
return toAttemptResponse(existing);
}
}
if (attemptRepository.existsByStudentUserIdAndDefinitionIdAndStatusIn(
userId, def.getId(), List.of(AbilityAttempt.STATUS_DRAFT, AbilityAttempt.STATUS_IN_PROGRESS))) {
throw new BusinessException(ErrorCode.ABILITY_DUPLICATE_ACTIVE);
}
throw e;
}
auditService.log(userId, "ABILITY_ATTEMPT_CREATED", "ABILITY_ATTEMPT", attempt.getId(),
"attemptId=" + attempt.getId() + ", typeCode=" + def.getTypeCode()
+ ", status=" + attempt.getStatus() + ", answeredCount=0"
+ ", questionSetVersion=" + attempt.getQuestionSetVersion()
+ ", rulesVersion=" + def.getRulesVersion(),
ctx);
return toAttemptResponse(attempt, def);
}
// ─────────────────────────── 答卷详情 ───────────────────────────
@Transactional
public AbilityAttemptResponse get(Long userId, Long attemptId) {
requireStudent(userId);
AbilityAttempt attempt = loadOwned(userId, attemptId);
expireIfTimedOut(attempt);
return toAttemptResponse(attempt);
}
// ─────────────────────────── 查看结果 ───────────────────────────
@Transactional(readOnly = true)
public AbilityResultResponse loadResult(Long userId, Long attemptId) {
requireStudent(userId);
AbilityAttempt attempt = loadOwned(userId, attemptId);
AbilityResult result = resultRepository.findByAttemptId(attemptId)
.orElseThrow(() -> new BusinessException(ErrorCode.ABILITY_RESULT_NOT_FOUND));
AbilityDefinition def = definitionRepository.findById(attempt.getDefinitionId()).orElse(null);
return resultService.toResponse(attempt, result, def, now());
}
// ─────────────────────────── 保存答案 ───────────────────────────
@Transactional
public AbilityAttemptResponse saveAnswers(Long userId, Long attemptId,
SaveAbilityAnswersRequest req, AuditContext ctx) {
requireStudent(userId);
AbilityAttempt attempt = loadOwned(userId, attemptId);
if (expireIfTimedOut(attempt)) {
throw new BusinessException(ErrorCode.ABILITY_ATTEMPT_INVALID_STATE);
}
if (!isMutable(attempt.getStatus())) {
throw new BusinessException(ErrorCode.ABILITY_ATTEMPT_INVALID_STATE);
}
AbilityDefinition def = definitionRepository.findById(attempt.getDefinitionId()).orElse(null);
// 同一 PATCH 内不得重复 attemptQuestionId
if (req.getAnswers().size() != req.getAnswers().stream()
.map(a -> a.getAttemptQuestionId()).distinct().count()) {
throw new BusinessException(ErrorCode.ABILITY_ANSWER_INVALID, "duplicate attemptQuestionId in answers");
}
List<AbilityAttemptQuestion> aqs = attemptQuestionRepository
.findByAttemptIdOrderByPositionAsc(attemptId);
Map<Long, AbilityAttemptQuestion> aqById = aqs.stream()
.collect(Collectors.toMap(AbilityAttemptQuestion::getId, Function.identity()));
// 先整体校验:attemptQuestionId 属于本答卷、选项存在于快照;任一无效 → 整体拒绝,不启动计时
for (SaveAbilityAnswersRequest.AnswerItem item : req.getAnswers()) {
AbilityAttemptQuestion aq = aqById.get(item.getAttemptQuestionId());
if (aq == null || item.getSelectedOptionId() == null) {
throw new BusinessException(ErrorCode.ABILITY_ANSWER_INVALID, "attemptQuestionId not in this attempt");
}
if (!snapshotCodec.optionExists(aq.getQuestionSnapshotJson(), item.getSelectedOptionId())) {
throw new BusinessException(ErrorCode.ABILITY_ANSWER_INVALID, "optionId does not exist");
}
}
LocalDateTime now = now();
boolean startTimer = attempt.getStartedAt() == null;
// 批量加载本请求涉及题目的既有答案,避免对每题逐条查询(消除 N+1)
List<Long> questionIds = req.getAnswers().stream()
.map(SaveAbilityAnswersRequest.AnswerItem::getAttemptQuestionId).toList();
Map<Long, AbilityAnswer> existingByAqId = answerRepository
.findByAttemptIdAndAttemptQuestionIdIn(attemptId, questionIds).stream()
.collect(Collectors.toMap(AbilityAnswer::getAttemptQuestionId, Function.identity()));
List<AbilityAnswer> toSave = new ArrayList<>(questionIds.size());
for (SaveAbilityAnswersRequest.AnswerItem item : req.getAnswers()) {
AbilityAttemptQuestion aq = aqById.get(item.getAttemptQuestionId());
Integer awarded = snapshotCodec.awardedScore(aq.getQuestionSnapshotJson(), item.getSelectedOptionId());
AbilityAnswer answer = existingByAqId.get(aq.getId());
if (answer == null) {
answer = AbilityAnswer.builder().attemptId(attemptId).attemptQuestionId(aq.getId()).build();
}
answer.setSelectedOptionId(item.getSelectedOptionId());
answer.setAwardedScore(awarded == null ? 0 : awarded);
toSave.add(answer);
}
answerRepository.saveAll(toSave);
answerRepository.flush();
int answered = (int) answerRepository.countByAttemptId(attemptId);
attempt.setAnsweredCount(answered);
if (startTimer && answered > 0) {
attempt.setStartedAt(now);
int limit = def == null || def.getTimeLimitMinutes() == null ? 30 : def.getTimeLimitMinutes();
attempt.setExpiresAt(now.plusMinutes(limit));
attempt.setStatus(AbilityAttempt.STATUS_IN_PROGRESS);
}
try {
attemptRepository.saveAndFlush(attempt);
} catch (OptimisticLockingFailureException e) {
throw new BusinessException(ErrorCode.ABILITY_CONCURRENT_UPDATE);
}
return toAttemptResponse(attempt, def);
}
// ─────────────────────────── 提交答卷 ───────────────────────────
@Transactional
public AbilityResultResponse submit(Long userId, Long attemptId, AuditContext ctx) {
requireStudent(userId);
AbilityAttempt attempt = loadOwned(userId, attemptId);
if (AbilityAttempt.STATUS_COMPLETED.equals(attempt.getStatus())) {
return idempotentResult(attempt);
}
if (expireIfTimedOut(attempt)) {
throw new BusinessException(ErrorCode.ABILITY_ATTEMPT_INVALID_STATE);
}
if (!isMutable(attempt.getStatus())) {
throw new BusinessException(ErrorCode.ABILITY_ATTEMPT_INVALID_STATE);
}
int answered = (int) answerRepository.countByAttemptId(attemptId);
int total = totalOf(attempt);
if (answered < total || !perDimensionAnswered(attemptId)) {
throw new BusinessException(ErrorCode.ABILITY_INCOMPLETE);
}
int transitioned = attemptRepository.markCompleted(attemptId, now(), answered);
if (transitioned == 0) {
return idempotentResult(attempt);
}
AbilityDefinition def = definitionRepository.findById(attempt.getDefinitionId()).orElse(null);
AbilityResultComputer.ComputedResult computed = compute(attempt);
AbilityResult result;
try {
result = resultRepository.save(resultService.persist(attempt, def, computed, now()));
} catch (DataIntegrityViolationException e) {
return idempotentResult(attempt);
}
auditService.log(userId, "ABILITY_ATTEMPT_SUBMITTED", "ABILITY_ATTEMPT", attemptId,
"attemptId=" + attemptId + ", typeCode=" + (def == null ? null : def.getTypeCode())
+ ", status=COMPLETED, answeredCount=" + answered
+ ", questionSetVersion=" + attempt.getQuestionSetVersion()
+ ", rulesVersion=" + (def == null ? null : def.getRulesVersion()),
ctx);
return resultService.toResponse(attempt, result, def, now());
}
// ─────────────────────────── 我的历史 ───────────────────────────
@Transactional(readOnly = true)
public Page<AbilityHistoryItem> listMine(Long userId, int page, int size) {
requireStudent(userId);
if (page < 0 || size < 1 || size > MAX_PAGE_SIZE) {
throw new BusinessException(ErrorCode.BAD_REQUEST);
}
LocalDateTime now = now();
Page<AbilityAttempt> attempts = attemptRepository
.findByStudentUserIdOrderByCreatedAtDesc(userId, PageRequest.of(page, size));
List<AbilityAttempt> content = attempts.getContent();
Map<Long, AbilityResult> resultByAttempt = resultRepository
.findByAttemptIdIn(content.stream().map(AbilityAttempt::getId).toList()).stream()
.collect(Collectors.toMap(AbilityResult::getAttemptId, Function.identity()));
// 对当前页 definitionId 去重后批量加载,避免对每条历史逐个 findById(消除 N+1)
List<Long> defIds = content.stream().map(AbilityAttempt::getDefinitionId).distinct().toList();
Map<Long, AbilityDefinition> defById = defIds.isEmpty() ? Map.of()
: definitionRepository.findAllById(defIds).stream()
.collect(Collectors.toMap(AbilityDefinition::getId, Function.identity()));
return attempts.map(a -> {
AbilityResult r = resultByAttempt.get(a.getId());
AbilityDefinition d = defById.get(a.getDefinitionId());
String validity = null;
if (r != null && r.getValidUntil() != null) {
validity = now.isBefore(r.getValidUntil()) ? AbilityResultComputer.VALIDITY_VALID
: AbilityResultComputer.VALIDITY_EXPIRED;
}
int total = d == null || d.getAttemptQuestionCount() == null ? 24 : d.getAttemptQuestionCount();
return AbilityHistoryItem.builder()
.attemptId(a.getId())
.typeCode(d == null ? null : d.getTypeCode())
.status(a.getStatus())
.validityStatus(validity)
.overallScore(r == null ? null : r.getOverallScore())
.answeredCount(a.getAnsweredCount())
.completionRate(completionRate(a.getAnsweredCount(), total))
.startedAt(a.getStartedAt())
.expiresAt(a.getExpiresAt())
.completedAt(a.getCompletedAt())
.validUntil(r == null ? null : r.getValidUntil())
.build();
});
}
// ─────────────────────────── 内部工具 ───────────────────────────
private AbilityResultResponse idempotentResult(AbilityAttempt attempt) {
AbilityResult result = resultRepository.findByAttemptId(attempt.getId())
.orElseThrow(() -> new BusinessException(ErrorCode.ABILITY_ATTEMPT_INVALID_STATE));
AbilityDefinition def = definitionRepository.findById(attempt.getDefinitionId()).orElse(null);
return resultService.toResponse(attempt, result, def, now());
}
private AbilityResultComputer.ComputedResult compute(AbilityAttempt attempt) {
List<AbilityResultComputer.Answered> items = new ArrayList<>();
List<AbilityAttemptQuestion> aqs = attemptQuestionRepository
.findByAttemptIdOrderByPositionAsc(attempt.getId());
Map<Long, AbilityAnswer> answerByAq = answerRepository.findByAttemptId(attempt.getId()).stream()
.collect(Collectors.toMap(AbilityAnswer::getAttemptQuestionId, Function.identity()));
for (AbilityAttemptQuestion aq : aqs) {
AbilityAnswer ans = answerByAq.get(aq.getId());
if (ans == null) {
continue;
}
JsonNode snap = snapshotCodec.parse(aq.getQuestionSnapshotJson());
items.add(new AbilityResultComputer.Answered(
text(snap, AbilitySnapshotCodec.KEY_DIMENSION),
text(snap, AbilitySnapshotCodec.KEY_COMPETENCY_TAG),
text(snap, AbilitySnapshotCodec.KEY_STEM),
text(snap, AbilitySnapshotCodec.KEY_EXPLANATION),
ans.getAwardedScore(), aq.getId(), aq.getPosition(), ans.getSelectedOptionId()));
}
return resultComputer.compute(items, perDimOf(attempt));
}
private int totalOf(AbilityAttempt attempt) {
AbilityDefinition def = definitionRepository.findById(attempt.getDefinitionId()).orElse(null);
return def == null || def.getAttemptQuestionCount() == null ? 24 : def.getAttemptQuestionCount();
}
private int perDimOf(AbilityAttempt attempt) {
AbilityDefinition def = definitionRepository.findById(attempt.getDefinitionId()).orElse(null);
return def == null || def.getQuestionsPerDimension() == null ? 6 : def.getQuestionsPerDimension();
}
private String text(JsonNode snap, String key) {
JsonNode n = snap.path(key);
return n.isMissingNode() || n.isNull() ? null : n.asText();
}
private boolean perDimensionAnswered(Long attemptId) {
// 一次查询该答题的题目,一次批量查询该答题的全部答案,避免对每题逐条查询(消除 N+1)
List<AbilityAttemptQuestion> aqs =
attemptQuestionRepository.findByAttemptIdOrderByPositionAsc(attemptId);
if (aqs.isEmpty()) {
return false;
}
Set<Long> answeredAqIds = answerRepository.findByAttemptId(attemptId).stream()
.map(AbilityAnswer::getAttemptQuestionId).collect(Collectors.toSet());
Map<String, Integer> counts = new LinkedHashMap<>();
for (AbilityAttemptQuestion aq : aqs) {
if (answeredAqIds.contains(aq.getId())) {
String dim = snapshotCodec.text(aq.getQuestionSnapshotJson(), AbilitySnapshotCodec.KEY_DIMENSION);
counts.merge(dim, 1, Integer::sum);
}
}
int required = 6;
for (String d : DIMENSIONS) {
if (counts.getOrDefault(d, 0) != required) {
return false;
}
}
return true;
}
private boolean expireIfTimedOut(AbilityAttempt attempt) {
if (isMutable(attempt.getStatus()) && attempt.getExpiresAt() != null
&& !now().isBefore(attempt.getExpiresAt())) {
attemptRepository.markExpiredAndReleaseActive(attempt.getId());
attempt.setStatus(AbilityAttempt.STATUS_EXPIRED);
attempt.setActiveKey(null);
return true;
}
return false;
}
private void releaseTimedOut(Long userId, Long definitionId, LocalDateTime now) {
for (AbilityAttempt a : attemptRepository.findTimedOutActiveByUserAndDef(userId, definitionId, now)) {
attemptRepository.markExpiredAndReleaseActive(a.getId());
}
// 立即刷新旧状态以免 DELETE/REREAD 不一致:不做,仅在此事务内后续判断用 DB 最新
}
private AbilityAttemptResponse toAttemptResponse(AbilityAttempt attempt) {
return toAttemptResponse(attempt, definitionRepository.findById(attempt.getDefinitionId()).orElse(null));
}
private AbilityAttemptResponse toAttemptResponse(AbilityAttempt attempt, AbilityDefinition def) {
List<AbilityAttemptQuestion> aqs = attemptQuestionRepository
.findByAttemptIdOrderByPositionAsc(attempt.getId());
Map<Long, String> selectedByAq = answerRepository.findByAttemptId(attempt.getId()).stream()
.collect(Collectors.toMap(AbilityAnswer::getAttemptQuestionId, AbilityAnswer::getSelectedOptionId));
int total = totalOf(attempt);
int answered = attempt.getAnsweredCount() == null ? (int) selectedByAq.size() : attempt.getAnsweredCount();
List<AbilityAttemptResponse.AbilityAttemptQuestionView> questions = new ArrayList<>(aqs.size());
for (AbilityAttemptQuestion aq : aqs) {
JsonNode snap = snapshotCodec.parse(aq.getQuestionSnapshotJson());
String itemType = text(snap, AbilitySnapshotCodec.KEY_ITEM_TYPE);
String stem = text(snap, AbilitySnapshotCodec.KEY_STEM);
List<AbilityAttemptResponse.AbilityOptionView> options = new ArrayList<>();
JsonNode opts = snap.path(AbilitySnapshotCodec.KEY_OPTIONS);
if (opts.isArray()) {
for (JsonNode o : opts) {
options.add(AbilityAttemptResponse.AbilityOptionView.builder()
.optionId(o.path("optionId").asText()).text(o.path("text").asText()).build());
}
}
questions.add(AbilityAttemptResponse.AbilityAttemptQuestionView.builder()
.attemptQuestionId(aq.getId())
.position(aq.getPosition())
.itemType(itemType)
.stem(stem)
.options(options)
.selectedOptionId(selectedByAq.get(aq.getId()))
.build());
}
Long remaining = null;
if (attempt.getExpiresAt() != null) {
long secs = Duration.between(now(), attempt.getExpiresAt()).getSeconds();
remaining = Math.max(0, secs);
}
return AbilityAttemptResponse.builder()
.attemptId(attempt.getId())
.status(attempt.getStatus())
.answeredCount(answered)
.totalQuestions(total)
.completionRate(completionRate(answered, total))
.startedAt(attempt.getStartedAt())
.expiresAt(attempt.getExpiresAt())
.remainingSeconds(remaining)
.questions(questions)
.build();
}
/**
* 抽题:四维各若干道,且每维三个 competencyTag 各抽 2 道,共 def.attemptQuestionCount 道。
* 任一维度/标签可用题不足时直接拒绝创建(不静默减少)。
*/
private List<AbilityQuestion> sample(List<AbilityQuestion> all, AbilityDefinition def) {
int perDim = def.getQuestionsPerDimension() == null ? 6 : def.getQuestionsPerDimension();
int attemptCount = def.getAttemptQuestionCount() == null ? 24 : def.getAttemptQuestionCount();
int perTag = 2; // perDim = 3 tags × 2
long seed = new Random().nextLong();
Random random = new Random(seed);
Map<String, Map<String, List<AbilityQuestion>>> byDimTag = new LinkedHashMap<>();
for (AbilityQuestion q : all) {
byDimTag.computeIfAbsent(q.getDimension(), k -> new LinkedHashMap<>())
.computeIfAbsent(q.getCompetencyTag(), k -> new ArrayList<>()).add(q);
}
int collected = 0;
List<AbilityQuestion> picked = new ArrayList<>();
for (String dim : DIMENSIONS) {
Map<String, List<AbilityQuestion>> tags = byDimTag.get(dim);
if (tags == null || tags.size() < 3) {
throw new BusinessException(ErrorCode.BAD_REQUEST,
"insufficient competency tags in dimension " + dim);
}
List<AbilityQuestion> dimPicked = new ArrayList<>();
for (Map.Entry<String, List<AbilityQuestion>> e : tags.entrySet()) {
List<AbilityQuestion> group = new ArrayList<>(e.getValue());
if (group.size() < perTag) {
throw new BusinessException(ErrorCode.BAD_REQUEST,
"insufficient active questions for tag " + e.getKey());
}
Collections.shuffle(group, random);
dimPicked.addAll(group.subList(0, perTag));
}
if (dimPicked.size() != perDim) {
throw new BusinessException(ErrorCode.BAD_REQUEST,
"dimension " + dim + " sampled != " + perDim);
}
picked.addAll(dimPicked);
collected += dimPicked.size();
}
Collections.shuffle(picked, random);
if (collected != attemptCount || picked.size() != attemptCount) {
throw new BusinessException(ErrorCode.BAD_REQUEST, "sampled question count mismatch");
}
return picked;
}
private boolean isMutable(String status) {
return AbilityAttempt.STATUS_DRAFT.equals(status)
|| AbilityAttempt.STATUS_IN_PROGRESS.equals(status);
}
private AbilityAttempt loadOwned(Long userId, Long attemptId) {
AbilityAttempt a = attemptRepository.findById(attemptId)
.orElseThrow(() -> new BusinessException(ErrorCode.ABILITY_ATTEMPT_NOT_FOUND));
if (!a.getStudentUserId().equals(userId)) {
throw new BusinessException(ErrorCode.FORBIDDEN);
}
return a;
}
private void requireStudent(Long userId) {
User u = userRepository.findById(userId)
.orElseThrow(() -> new BusinessException(ErrorCode.USER_NOT_FOUND));
boolean isStudent = u.getRoles() != null && u.getRoles().contains(User.Role.STUDENT);
if (!isStudent) {
throw new BusinessException(ErrorCode.FORBIDDEN);
}
}
private AbilityAttempt findByIdem(Long userId, String idem) {
if (idem == null) {
return null;
}
return attemptRepository.findByStudentUserIdAndIdempotencyKey(userId, idem).orElse(null);
}
private String normalizeIdempotencyKey(String key) {
return key == null || key.isBlank() ? null : key.trim();
}
private String activeKeyOf(Long studentUserId, Long definitionId) {
return studentUserId + ":" + definitionId + ":ACTIVE";
}
private LocalDateTime now() {
return LocalDateTime.now(clock);
}
private BigDecimal completionRate(int answered, int total) {
if (total <= 0) {
return BigDecimal.ZERO.setScale(2);
}
return BigDecimal.valueOf(answered)
.multiply(BigDecimal.valueOf(100))
.divide(BigDecimal.valueOf(total), 2, RoundingMode.HALF_UP);
}
}

51
mydreamjob-server/src/main/java/com/mydreamjob/ability/AbilityAttemptWriter.java

@ -0,0 +1,51 @@
package com.mydreamjob.ability;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Propagation;
import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDateTime;
import java.util.List;
/**
* 答卷持久化写入 Bean(V36)。将「新建 attempt + 24 条 attempt_questions(含完整快照)」放入
* 独立的 REQUIRES_NEW 事务:并发创建触发唯一键冲突(active_key / idempotency_key)时在独立事务内回滚,
* 外层 Session 不被污染,避免 Hibernate AssertionFailure 或 HTTP 500。
*/
@Component
@RequiredArgsConstructor
public class AbilityAttemptWriter {
private final AbilityAttemptRepository attemptRepository;
private final AbilityAttemptQuestionRepository attemptQuestionRepository;
private final AbilitySnapshotCodec snapshotCodec;
@Transactional(propagation = Propagation.REQUIRES_NEW)
public AbilityAttempt persistNew(Long userId, AbilityDefinition def, List<AbilityQuestion> sampled,
String idempotencyKey, String activeKey) {
AbilityAttempt attempt = AbilityAttempt.builder()
.studentUserId(userId)
.definitionId(def.getId())
.status(AbilityAttempt.STATUS_DRAFT)
.activeKey(activeKey)
.idempotencyKey(idempotencyKey)
.questionSetVersion(def.getQuestionSetVersion())
.answeredCount(0)
.build();
attempt = attemptRepository.saveAndFlush(attempt);
int position = 1;
for (AbilityQuestion q : sampled) {
attemptQuestionRepository.save(AbilityAttemptQuestion.builder()
.attemptId(attempt.getId())
.questionId(q.getId())
.position(position++)
.questionVersion(q.getQuestionVersion())
.questionSnapshotJson(snapshotCodec.build(q))
.build());
}
attemptQuestionRepository.flush();
return attempt;
}
}

32
mydreamjob-server/src/main/java/com/mydreamjob/ability/AbilityCatalogService.java

@ -0,0 +1,32 @@
package com.mydreamjob.ability;
import com.mydreamjob.ability.dto.AbilityTypeResponse;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
@Service
@RequiredArgsConstructor
public class AbilityCatalogService {
private final AbilityDefinitionRepository definitionRepository;
@Transactional(readOnly = true)
public List<AbilityTypeResponse> listTypes() {
return definitionRepository.findAllByStatusOrderByCreatedAtAsc(AbilityDefinition.STATUS_ACTIVE)
.stream()
.map(d -> AbilityTypeResponse.builder()
.typeCode(d.getTypeCode())
.displayName(d.getDisplayName())
.description(d.getDescription())
.questionCount(d.getQuestionCount())
.attemptQuestionCount(d.getAttemptQuestionCount())
.timeLimitMinutes(d.getTimeLimitMinutes())
.resultValidDays(d.getResultValidDays())
.status(d.getStatus())
.build())
.toList();
}
}

164
mydreamjob-server/src/main/java/com/mydreamjob/ability/AbilityController.java

@ -0,0 +1,164 @@
package com.mydreamjob.ability;
import com.mydreamjob.ability.dto.AbilityAttemptResponse;
import com.mydreamjob.ability.dto.AbilityHistoryItem;
import com.mydreamjob.ability.dto.AbilityResultResponse;
import com.mydreamjob.ability.dto.AbilityTypeResponse;
import com.mydreamjob.ability.dto.CreateAbilityAttemptRequest;
import com.mydreamjob.ability.dto.SaveAbilityAnswersRequest;
import com.mydreamjob.audit.AuditContext;
import com.mydreamjob.common.api.ApiResponse;
import com.mydreamjob.common.api.PageResponse;
import com.mydreamjob.common.exception.BusinessException;
import com.mydreamjob.common.exception.ErrorCode;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.responses.ApiResponses;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.data.domain.Page;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.web.bind.annotation.*;
import java.util.List;
/**
* 四维能力测评接口(V36)。
*
* <p>身份仅取自 JWT。测评类型对任意已登录角色开放;其余接口仅 STUDENT 且仅限本人。
* 不回读完整题库:前端仅通过创建/恢复本人答卷获得本次 24 道题。</p>
*/
@Tag(name = "四维能力测评", description = "能力测评类型、答卷、保存答案、提交、结果与历史")
@RestController
@RequiredArgsConstructor
public class AbilityController {
private final AbilityCatalogService catalogService;
private final AbilityAttemptService attemptService;
@Operation(summary = "能力测评类型列表",
description = "返回全部 ACTIVE 能力测评定义,不返回题库/答案/评分规则。任意已登录用户。")
@ApiResponses({
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "OK"),
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "401", description = "未登录")
})
@GetMapping("/abilities/types")
public ApiResponse<List<AbilityTypeResponse>> listTypes(HttpServletRequest request) {
return ApiResponse.ok(catalogService.listTypes(), requestId(request));
}
@Operation(summary = "创建能力答卷",
description = "按四维各抽 6 题(每维三个 competencyTag 各 2)共 24 道并持久化,刷新不重抽。"
+ "同幂等键重放返回原答卷;有效结果冷却期内 409/9810;90 天完成次数达上限 429/9811。仅 STUDENT。")
@ApiResponses({
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "OK"),
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "401", description = "未登录"),
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "403", description = "非 STUDENT(2002)"),
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "404", description = "类型不存在(9801)"),
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "409", description = "活跃/幂等/冷却(9808/9809/9810)"),
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "429", description = "重测次数上限(9811)")
})
@PostMapping("/abilities/attempts")
public ApiResponse<AbilityAttemptResponse> createAttempt(
@Valid @RequestBody CreateAbilityAttemptRequest req, HttpServletRequest request) {
return ApiResponse.ok(attemptService.create(resolveUserId(request), req, AuditContext.from(request)),
requestId(request));
}
@Operation(summary = "能力答卷详情",
description = "返回本次 24 道题(固定顺序)、已选答案、进度与倒计时。仅本人;超时自动标 EXPIRED。")
@ApiResponses({
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "OK"),
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "401", description = "未登录"),
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "403", description = "非本人或非 STUDENT(2002)"),
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "404", description = "答卷不存在(9802)")
})
@GetMapping("/abilities/attempts/{attemptId}")
public ApiResponse<AbilityAttemptResponse> getAttempt(
@PathVariable Long attemptId, HttpServletRequest request) {
return ApiResponse.ok(attemptService.get(resolveUserId(request), attemptId), requestId(request));
}
@Operation(summary = "保存能力测评答案",
description = "upsert;首次有效保存启动 30 分钟倒计时且并发安全;不延长;到期禁止保存;"
+ "并发保存冲突 409/9807。仅本人。")
@ApiResponses({
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "OK"),
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "401", description = "未登录"),
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "403", description = "非本人或非 STUDENT(2002)"),
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "404", description = "答卷不存在(9802)"),
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "400", description = "答案非法/未答完整(9805/9806)"),
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "409", description = "非法状态/并发/到期(9803/9807)")
})
@PatchMapping("/abilities/attempts/{attemptId}/answers")
public ApiResponse<AbilityAttemptResponse> saveAnswers(
@PathVariable Long attemptId,
@Valid @RequestBody SaveAbilityAnswersRequest req, HttpServletRequest request) {
return ApiResponse.ok(attemptService.saveAnswers(resolveUserId(request), attemptId, req,
AuditContext.from(request)), requestId(request));
}
@Operation(summary = "提交能力测评答卷",
description = "24/24 且四维各 6 方可提交(否则 400/9806);超时标 EXPIRED 不生成结果;"
+ "并发提交最多一条结果与一条审计;重复提交幂等返回原结果。仅本人。")
@ApiResponses({
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "OK"),
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "401", description = "未登录"),
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "403", description = "非本人或非 STUDENT(2002)"),
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "404", description = "答卷不存在(9802)"),
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "400", description = "未完成(9806)"),
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "409", description = "非法状态(9803)")
})
@PostMapping("/abilities/attempts/{attemptId}/submit")
public ApiResponse<AbilityResultResponse> submitAttempt(
@PathVariable Long attemptId, HttpServletRequest request) {
return ApiResponse.ok(attemptService.submit(resolveUserId(request), attemptId,
AuditContext.from(request)), requestId(request));
}
@Operation(summary = "查看能力测评结果",
description = "返回四维分、综合分、短板、提升建议、雷达与每题解析。仅本人;未生成结果 404/9804。")
@ApiResponses({
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "OK"),
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "401", description = "未登录"),
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "403", description = "非本人或非 STUDENT(2002)"),
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "404", description = "结果不存在(9804)")
})
@GetMapping("/abilities/attempts/{attemptId}/result")
public ApiResponse<AbilityResultResponse> getResult(
@PathVariable Long attemptId, HttpServletRequest request) {
return ApiResponse.ok(attemptService.loadResult(resolveUserId(request), attemptId), requestId(request));
}
@Operation(summary = "我的能力测评历史",
description = "分页返回当前学生全部答卷与结果概要。仅 STUDENT 本人。")
@ApiResponses({
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "OK"),
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "401", description = "未登录"),
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "403", description = "非 STUDENT(2002)")
})
@GetMapping("/abilities/me")
public ApiResponse<PageResponse<AbilityHistoryItem>> listMine(
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "20") int size,
HttpServletRequest request) {
Page<AbilityHistoryItem> result = attemptService.listMine(resolveUserId(request), page, size);
return ApiResponse.ok(PageResponse.of(
result.getContent(), result.getTotalElements(), result.getNumber(), result.getSize()),
requestId(request));
}
private Long resolveUserId(HttpServletRequest request) {
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
if (auth == null || !auth.isAuthenticated()) {
throw new BusinessException(ErrorCode.UNAUTHORIZED);
}
return Long.valueOf(auth.getName());
}
private String requestId(HttpServletRequest request) {
return request.getHeader("X-Request-Id");
}
}

79
mydreamjob-server/src/main/java/com/mydreamjob/ability/AbilityDefinition.java

@ -0,0 +1,79 @@
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_definitions")
@EntityListeners(AuditingEntityListener.class)
public class AbilityDefinition {
public static final String STATUS_ACTIVE = "ACTIVE";
public static final String STATUS_INACTIVE = "INACTIVE";
public static final String TYPE_WORKPLACE_ABILITY = "WORKPLACE_ABILITY";
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "type_code", nullable = false, length = 40)
private String typeCode;
@Column(name = "display_name", nullable = false, length = 100)
private String displayName;
@Column(length = 1000)
private String description;
@Column(name = "status", nullable = false, length = 20)
@Builder.Default
private String status = STATUS_ACTIVE;
@Column(name = "question_count", nullable = false)
private Integer questionCount;
@Column(name = "attempt_question_count", nullable = false)
private Integer attemptQuestionCount;
@Column(name = "questions_per_dimension", nullable = false)
private Integer questionsPerDimension;
@Column(name = "time_limit_minutes", nullable = false)
private Integer timeLimitMinutes;
@Column(name = "result_valid_days", nullable = false)
private Integer resultValidDays;
@Column(name = "retake_window_days", nullable = false)
private Integer retakeWindowDays;
@Column(name = "retake_max_completed", nullable = false)
private Integer retakeMaxCompleted;
@Column(name = "question_set_version", nullable = false, length = 40)
private String questionSetVersion;
@Column(name = "rules_version", nullable = false, length = 40)
private String rulesVersion;
@CreatedDate
@Column(name = "created_at", nullable = false, updatable = false)
private LocalDateTime createdAt;
@LastModifiedDate
@Column(name = "updated_at", nullable = false)
private LocalDateTime updatedAt;
}

13
mydreamjob-server/src/main/java/com/mydreamjob/ability/AbilityDefinitionRepository.java

@ -0,0 +1,13 @@
package com.mydreamjob.ability;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.List;
import java.util.Optional;
public interface AbilityDefinitionRepository extends JpaRepository<AbilityDefinition, Long> {
Optional<AbilityDefinition> findByTypeCode(String typeCode);
List<AbilityDefinition> findAllByStatusOrderByCreatedAtAsc(String status);
}

98
mydreamjob-server/src/main/java/com/mydreamjob/ability/AbilityImprovementCatalog.java

@ -0,0 +1,98 @@
package com.mydreamjob.ability;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
/**
* 固定白名单建议目录(V36,非 AI)。每个 competencyTag 提供短板提示摘要与可执行动作。
*/
public final class AbilityImprovementCatalog {
private AbilityImprovementCatalog() {
}
public static final Map<String, String[]> ACTIONS = new LinkedHashMap<>();
static {
ACTIONS.put("INFORMATION_ANALYSIS", new String[]{
"每次对数据进行判断前,先统一指标定义、时间范围与数据来源。",
"把“结论”与“证据来源”拆开记录,遇到不一致时先定位原始数据。",
"养成用“假设-验证-记录”清单核对结果的习惯。"});
ACTIONS.put("CAUSAL_REASONING", new String[]{
"区分“相关”与“因果”,只有对照与趋势分析才能支持因果判断。",
"排查同时变化的混杂因素,避免把时间先后当作因果。",
"用最小可验证实验(单变量对照)检验关键假设。"});
ACTIONS.put("PROBLEM_SOLVING", new String[]{
"把模糊任务先转化为用户、场景、问题和验收标准。",
"复杂问题先拆子问题、明确输入输出,再逐项验证。",
"方案失败时对照预期与实际,检查假设、执行偏差与外部因素。"});
ACTIONS.put("LISTENING_UNDERSTANDING", new String[]{
"用“复述理解-确认目标/边界”的方式回应模糊需求。",
"先听完事实与影响,再提建议,避免打断或提前下结论。",
"把情绪表达转化为具体场景、期望与影响后再行动。"});
ACTIONS.put("STRUCTURED_EXPRESSION", new String[]{
"重要消息按“背景-问题-行动-负责人-截止时间”组织。",
"面向不同对象选择信息颗粒度,先讲影响再讲原理。",
"反馈聚焦具体的可观察行为、影响与修改标准,不上升为人格评价。"});
ACTIONS.put("COLLABORATION_CONFLICT", new String[]{
"分歧发生时先确认共同目标,再用统一标准比较方案。",
"跨团队问题先确认事实与阻塞,再共同制定新节点与升级条件。",
"边界沟通说明已有承诺与优先级,主动提出替代时间或资源方案。"});
ACTIONS.put("PLANNING_PRIORITIZATION", new String[]{
"计划从交付结果出发,明确步骤、依赖、负责人与检查节点。",
"资源冲突时按影响、时效与依赖排序,并同步取舍方案。",
"范围变化时量化影响,推动范围/资源/时间的明确取舍。"});
ACTIONS.put("RISK_FOLLOWUP", new String[]{
"记录风险触发条件、概率、影响、责任人、应对方案并持续检查。",
"阻塞时透明说明影响,提出备选路径并约定升级时间。",
"用可验证的小节点产出衡量真实进度,而非主观百分比。"});
ACTIONS.put("DELIVERY_REVIEW", new String[]{
"交付前用验收标准确认结果,并补齐测试、文档、监控与交接。",
"临近截止时间守住关键质量门槛,用范围取舍与分阶段交付管理风险。",
"复盘比较目标与实际,形成有负责人和期限的改进动作。"});
ACTIONS.put("INFORMATION_ACQUISITION", new String[]{
"学习新技能先明确使用目标,读权威资料并做小练习再应用。",
"比较信息源、发布时间、证据与适用条件,并做小范围验证。",
"围绕真实问题建立框架,再用实践和反馈补齐知识。"});
ACTIONS.put("FEEDBACK_REFLECTION", new String[]{
"把模糊反馈转化为具体例子,修改后再次验证是否解决。",
"失败后还原目标、过程与关键决策,区分假设、执行与外部因素。",
"用“目标-练习-反馈-调整-再验证”的闭环持续提升。"});
ACTIONS.put("TRANSFER_ADAPTATION", new String[]{
"迁移方法时识别共同原理与条件差异,用结果验证适配。",
"求助时说明目标、现象、已尝试方法与当前判断。",
"面对变化结合需求、成本与验证结果,规划分阶段迁移与学习。"});
}
public static Advice get(String tag) {
String[] actions = ACTIONS.get(tag);
if (actions == null) {
return new Advice("结合工作情境,分析本题对应能力并制定练习计划。",
List.of("复盘本题更合理的处理方式并复述关键判断。", "在真实任务中主动实践对应能力并记录结果。", "阶段性回顾并调整改进动作。"));
}
return new Advice(summary(tag), List.of(actions));
}
public record Advice(String summary, List<String> actions) {
}
private static String summary(String tag) {
return switch (tag) {
case "INFORMATION_ANALYSIS" -> "提升对信息口径与来源的核查颗粒度,先统一再做判断。";
case "CAUSAL_REASONING" -> "加强因果判断意识,用对照与单变量验证支持结论。";
case "PROBLEM_SOLVING" -> "先定义问题与验收标准,再拆解并逐项验证解决方案。";
case "LISTENING_UNDERSTANDING" -> "先确认理解与目标,再给出回应,避免基于错误假设的建议。";
case "STRUCTURED_EXPRESSION" -> "面向受众组织信息,先结论后证据,明确行动与负责人。";
case "COLLABORATION_CONFLICT" -> "用共同目标与一致标准处理分歧,前置确认事实与优先级。";
case "PLANNING_PRIORITIZATION" -> "以结果与节点驱动计划,资源冲突时显性取舍并同步相关方。";
case "RISK_FOLLOWUP" -> "隐患转化为可观察的风险条目并持续跟进,而非一次性提醒。";
case "DELIVERY_REVIEW" -> "交付以验收标准为闭环,复盘以有责任人和期限的动作落地。";
case "INFORMATION_ACQUISITION" -> "围绕目标选权威信息并动手验证,用实践补齐知识。";
case "FEEDBACK_REFLECTION" -> "把反馈变成具体行动并通过再次验证确认改进。";
case "TRANSFER_ADAPTATION" -> "识别原理与条件差异做迁移,变化时规划分阶段落地。";
default -> "结合情境制定对应能力维度的练习与复盘计划。";
};
}
}

88
mydreamjob-server/src/main/java/com/mydreamjob/ability/AbilityQuestion.java

@ -0,0 +1,88 @@
package com.mydreamjob.ability;
import jakarta.persistence.*;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import org.hibernate.annotations.JdbcTypeCode;
import org.hibernate.type.SqlTypes;
import org.springframework.data.annotation.CreatedDate;
import org.springframework.data.annotation.LastModifiedDate;
import org.springframework.data.jpa.domain.support.AuditingEntityListener;
import java.math.BigDecimal;
import java.time.LocalDateTime;
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
@Entity
@Table(name = "ability_questions")
@EntityListeners(AuditingEntityListener.class)
public class AbilityQuestion {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "definition_id", nullable = false)
private Long definitionId;
@Column(name = "question_code", nullable = false, length = 20)
private String questionCode;
@Column(name = "dimension", nullable = false, length = 20)
private String dimension;
@Column(name = "competency_tag", nullable = false, length = 40)
private String competencyTag;
@Column(name = "item_type", nullable = false, length = 40)
private String itemType;
@Column(nullable = false, length = 2000)
private String stem;
@Column(name = "options_json", nullable = false, columnDefinition = "text")
private String optionsJson;
@Column(name = "answer_key_json", nullable = false, columnDefinition = "text")
private String answerKeyJson;
@Column(name = "scoring_rule_json", nullable = false, columnDefinition = "text")
private String scoringRuleJson;
@Column(columnDefinition = "text")
private String explanation;
@Column(name = "difficulty", nullable = false, length = 10)
private String difficulty;
@Column(name = "discrimination", precision = 6, scale = 3)
private BigDecimal discrimination;
@Column(name = "question_version", nullable = false, length = 40)
private String questionVersion;
@Column(name = "source", nullable = false, length = 40)
@Builder.Default
private String source = "SELF_AUTHORED";
@Column(nullable = false)
@JdbcTypeCode(SqlTypes.TINYINT)
@Builder.Default
private Boolean active = true;
@Column(nullable = false)
private Integer position;
@CreatedDate
@Column(name = "created_at", nullable = false, updatable = false)
private LocalDateTime createdAt;
@LastModifiedDate
@Column(name = "updated_at", nullable = false)
private LocalDateTime updatedAt;
}

12
mydreamjob-server/src/main/java/com/mydreamjob/ability/AbilityQuestionRepository.java

@ -0,0 +1,12 @@
package com.mydreamjob.ability;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.List;
public interface AbilityQuestionRepository extends JpaRepository<AbilityQuestion, Long> {
List<AbilityQuestion> findByDefinitionIdAndActiveTrueOrderByPositionAsc(Long definitionId);
List<AbilityQuestion> findAllByDefinitionIdOrderByPositionAsc(Long definitionId);
}

62
mydreamjob-server/src/main/java/com/mydreamjob/ability/AbilityResult.java

@ -0,0 +1,62 @@
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.jpa.domain.support.AuditingEntityListener;
import java.math.BigDecimal;
import java.time.LocalDateTime;
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
@Entity
@Table(name = "ability_results")
@EntityListeners(AuditingEntityListener.class)
public class AbilityResult {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "attempt_id", nullable = false)
private Long attemptId;
@Column(name = "dimension_scores_json", columnDefinition = "text")
private String dimensionScoresJson;
@Column(name = "overall_score", nullable = false, precision = 5, scale = 2)
private BigDecimal overallScore;
@Column(name = "weaknesses_json", columnDefinition = "text")
private String weaknessesJson;
@Column(name = "improvement_areas_json", columnDefinition = "text")
private String improvementAreasJson;
@Column(name = "question_feedback_json", columnDefinition = "text")
private String questionFeedbackJson;
@Column(name = "valid_until")
private LocalDateTime validUntil;
@Column(name = "question_set_version", nullable = false, length = 40)
private String questionSetVersion;
@Column(name = "rules_version", nullable = false, length = 40)
private String rulesVersion;
@CreatedDate
@Column(name = "generated_at", nullable = false, updatable = false)
private LocalDateTime generatedAt;
@Version
@Column(name = "version", nullable = false)
@Builder.Default
private Long version = 0L;
}

150
mydreamjob-server/src/main/java/com/mydreamjob/ability/AbilityResultComputer.java

@ -0,0 +1,150 @@
package com.mydreamjob.ability;
import com.mydreamjob.ability.dto.AbilityResultResponse;
import org.springframework.stereotype.Component;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
/**
* 四维能力计分与结果计算机(V36)。
*
* <p>每题最优 4 / 次优 2 / 一般 1 / 高风险 0;每维 6 题满分 24,
* dimensionScore = rawScore / 24 × 100;overallScore = 四维平均(须四维齐全)。
* 满分与单维题数由定义给出,但默认 4 / 6 保持一致。</p>
*/
@Component
public class AbilityResultComputer {
public static final int MAX_SCORE_PER_QUESTION = 4;
public static final List<String> DIMENSIONS =
List.of("LOGIC", "COMMUNICATION", "EXECUTION", "LEARNING");
public static final String DISCLAIMER =
"本测评反映当前工作情境答题表现,用于自我了解和能力提升参考;不构成职业资格、正式招聘结论或永久能力标签。";
public static final String VALIDITY_VALID = "VALID";
public static final String VALIDITY_EXPIRED = "EXPIRED";
public record Answered(String dimension, String competencyTag, String stem, String explanation,
int awardedScore, long attemptQuestionId, int position,
String selectedOptionId) {
}
public record ComputedResult(List<AbilityResultResponse.DimensionScore> dimensionScores,
BigDecimal overallScore,
List<AbilityResultResponse.Weakness> weaknesses,
List<AbilityResultResponse.ImprovementArea> improvementAreas,
List<AbilityResultResponse.RadarEntry> radar,
List<AbilityResultResponse.QuestionFeedback> questionFeedback) {
}
public ComputedResult compute(List<Answered> items, int perDimQuestions) {
List<AbilityResultResponse.DimensionScore> dims = new ArrayList<>();
Map<String, List<Answered>> byDim = new LinkedHashMap<>();
for (String d : DIMENSIONS) {
byDim.put(d, items.stream().filter(it -> d.equals(it.dimension())).toList());
}
Map<String, Integer> rawByDim = new LinkedHashMap<>();
for (Map.Entry<String, List<Answered>> e : byDim.entrySet()) {
int raw = e.getValue().stream().mapToInt(Answered::awardedScore).sum();
rawByDim.put(e.getKey(), raw);
int maxScoreTotal = perDimQuestions * MAX_SCORE_PER_QUESTION;
BigDecimal score = maxScoreTotal <= 0 ? BigDecimal.ZERO.setScale(2)
: BigDecimal.valueOf(raw).multiply(BigDecimal.valueOf(100))
.divide(BigDecimal.valueOf(maxScoreTotal), 2, RoundingMode.HALF_UP);
dims.add(AbilityResultResponse.DimensionScore.builder()
.code(e.getKey()).score(score).answeredCount(e.getValue().size()).build());
}
// 综合分:四维都必须有 6 道有效答案
BigDecimal overall = null;
boolean allAnswered = DIMENSIONS.stream().allMatch(d -> byDim.get(d).size() >= perDimQuestions);
if (allAnswered && !dims.isEmpty()) {
BigDecimal sum = dims.stream().map(AbilityResultResponse.DimensionScore::getScore)
.reduce(BigDecimal.ZERO, BigDecimal::add);
overall = sum.divide(BigDecimal.valueOf(dims.size()), 2, RoundingMode.HALF_UP);
}
// 短板:四维最低分,并列全部返回
BigDecimal min = dims.stream().map(AbilityResultResponse.DimensionScore::getScore)
.min(Comparator.naturalOrder()).orElse(null);
List<AbilityResultResponse.Weakness> weaknesses = new ArrayList<>();
if (min != null) {
for (AbilityResultResponse.DimensionScore ds : dims) {
if (ds.getScore().compareTo(min) == 0) {
weaknesses.add(AbilityResultResponse.Weakness.builder()
.code(ds.getCode()).score(ds.getScore()).build());
}
}
}
// 提升建议:最低维度的最低 competencyTag,固定白名单
List<AbilityResultResponse.ImprovementArea> areas = new ArrayList<>();
if (min != null) {
String weakestDim = dims.stream()
.filter(ds -> ds.getScore().compareTo(min) == 0)
.min(Comparator.comparing(AbilityResultResponse.DimensionScore::getCode))
.map(AbilityResultResponse.DimensionScore::getCode).orElse(null);
if (weakestDim != null) {
String tag = lowestCompetencyTag(byDim.getOrDefault(weakestDim, List.of()));
BigDecimal tagScore = competencyScore(byDim.getOrDefault(weakestDim, List.of()), tag);
AbilityImprovementCatalog.Advice advice =
AbilityImprovementCatalog.get(tag == null ? weakestDim : tag);
// competencyTag 仅内部用于生成摘要与动作,不写入对外 DTO
areas.add(AbilityResultResponse.ImprovementArea.builder()
.dimension(weakestDim)
.currentScore(tagScore).summary(advice.summary()).actions(advice.actions())
.build());
}
}
// 雷达
List<AbilityResultResponse.RadarEntry> radar = new ArrayList<>();
for (AbilityResultResponse.DimensionScore ds : dims) {
radar.add(AbilityResultResponse.RadarEntry.builder()
.code(ds.getCode()).value(ds.getScore()).build());
}
// 每题解析(仅暴露本题解析文本,不返回答案键与完整评分规则)
List<AbilityResultResponse.QuestionFeedback> feedback = new ArrayList<>();
for (Answered a : items) {
feedback.add(AbilityResultResponse.QuestionFeedback.builder()
.attemptQuestionId(a.attemptQuestionId()).position(a.position())
.stem(a.stem()).selectedOptionId(a.selectedOptionId())
.awardedScore(a.awardedScore()).maxScore(MAX_SCORE_PER_QUESTION)
.explanation(a.explanation()).build());
}
return new ComputedResult(dims, overall, weaknesses, areas, radar, feedback);
}
private String lowestCompetencyTag(List<Answered> items) {
Map<String, Integer> raw = new LinkedHashMap<>();
for (Answered a : items) {
raw.merge(a.competencyTag(), a.awardedScore(), Integer::sum);
}
return raw.entrySet().stream()
.min(Comparator.<Map.Entry<String, Integer>>comparingInt(Map.Entry::getValue)
.thenComparing(Map.Entry::getKey))
.map(Map.Entry::getKey).orElse(null);
}
private BigDecimal competencyScore(List<Answered> items, String tag) {
List<Answered> tagged = items.stream()
.filter(a -> java.util.Objects.equals(tag, a.competencyTag())).toList();
if (tagged.isEmpty()) {
return BigDecimal.ZERO.setScale(2);
}
int raw = tagged.stream().mapToInt(Answered::awardedScore).sum();
int maxCol = tagged.size() * MAX_SCORE_PER_QUESTION;
return maxCol <= 0 ? BigDecimal.ZERO.setScale(2)
: BigDecimal.valueOf(raw).multiply(BigDecimal.valueOf(100))
.divide(BigDecimal.valueOf(maxCol), 2, RoundingMode.HALF_UP);
}
}

14
mydreamjob-server/src/main/java/com/mydreamjob/ability/AbilityResultRepository.java

@ -0,0 +1,14 @@
package com.mydreamjob.ability;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.Collection;
import java.util.List;
import java.util.Optional;
public interface AbilityResultRepository extends JpaRepository<AbilityResult, Long> {
Optional<AbilityResult> findByAttemptId(Long attemptId);
List<AbilityResult> findByAttemptIdIn(Collection<Long> attemptIds);
}

109
mydreamjob-server/src/main/java/com/mydreamjob/ability/AbilityResultService.java

@ -0,0 +1,109 @@
package com.mydreamjob.ability;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.mydreamjob.ability.dto.AbilityResultResponse;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.List;
@Service
@RequiredArgsConstructor
public class AbilityResultService {
private final ObjectMapper objectMapper;
private static final TypeReference<List<AbilityResultResponse.DimensionScore>> DIM_LIST =
new TypeReference<>() {
};
private static final TypeReference<List<AbilityResultResponse.Weakness>> WEAK_LIST =
new TypeReference<>() {
};
private static final TypeReference<List<AbilityResultResponse.ImprovementArea>> AREA_LIST =
new TypeReference<>() {
};
private static final TypeReference<List<AbilityResultResponse.QuestionFeedback>> FEEDBACK_LIST =
new TypeReference<>() {
};
public AbilityResult persist(AbilityAttempt attempt, AbilityDefinition def,
AbilityResultComputer.ComputedResult computed, LocalDateTime now) {
AbilityResult r = AbilityResult.builder()
.attemptId(attempt.getId())
.dimensionScoresJson(write(computed.dimensionScores()))
.overallScore(computed.overallScore())
.weaknessesJson(write(computed.weaknesses()))
.improvementAreasJson(write(computed.improvementAreas()))
.questionFeedbackJson(write(computed.questionFeedback()))
.validUntil(now.plusDays(def.getResultValidDays() == null ? 7 : def.getResultValidDays()))
.questionSetVersion(def.getQuestionSetVersion())
.rulesVersion(def.getRulesVersion())
.generatedAt(now)
.build();
return r;
}
public AbilityResultResponse toResponse(AbilityAttempt attempt, AbilityResult result, AbilityDefinition def,
LocalDateTime now) {
List<AbilityResultResponse.DimensionScore> dims = read(result.getDimensionScoresJson(), DIM_LIST);
List<AbilityResultResponse.RadarEntry> radar = new ArrayList<>();
for (AbilityResultResponse.DimensionScore ds : dims) {
radar.add(AbilityResultResponse.RadarEntry.builder()
.code(ds.getCode()).value(ds.getScore()).build());
}
boolean valid = result.getValidUntil() != null && now.isBefore(result.getValidUntil());
return AbilityResultResponse.builder()
.attemptId(result.getAttemptId())
.typeCode(def == null ? null : def.getTypeCode())
.status(AbilityAttempt.STATUS_COMPLETED)
.validityStatus(valid ? AbilityResultComputer.VALIDITY_VALID
: AbilityResultComputer.VALIDITY_EXPIRED)
.validUntil(result.getValidUntil())
.dimensionScores(dims)
.overallScore(result.getOverallScore())
.weaknesses(read(result.getWeaknessesJson(), WEAK_LIST))
.improvementAreas(read(result.getImprovementAreasJson(), AREA_LIST))
.radar(radar)
.answeredCount(attempt.getAnsweredCount())
.completionRate(attempt.getAnsweredCount() == null ? null
: completionRate(attempt.getAnsweredCount(),
def == null || def.getAttemptQuestionCount() == null ? 24 : def.getAttemptQuestionCount()))
.questionFeedback(read(result.getQuestionFeedbackJson(), FEEDBACK_LIST))
.questionSetVersion(result.getQuestionSetVersion())
.rulesVersion(result.getRulesVersion())
.generatedAt(result.getGeneratedAt())
.disclaimer(AbilityResultComputer.DISCLAIMER)
.build();
}
private java.math.BigDecimal completionRate(int answered, int total) {
if (total <= 0) {
return java.math.BigDecimal.ZERO.setScale(2);
}
return java.math.BigDecimal.valueOf(answered)
.multiply(java.math.BigDecimal.valueOf(100))
.divide(java.math.BigDecimal.valueOf(total), 2, java.math.RoundingMode.HALF_UP);
}
private String write(Object obj) {
try {
return objectMapper.writeValueAsString(obj);
} catch (Exception e) {
throw new IllegalStateException("unable to serialize ability result", e);
}
}
private <T> List<T> read(String json, TypeReference<List<T>> ref) {
if (json == null || json.isBlank()) {
return List.of();
}
try {
return objectMapper.readValue(json, ref);
} catch (Exception e) {
throw new IllegalStateException("unable to deserialize ability result", e);
}
}
}

88
mydreamjob-server/src/main/java/com/mydreamjob/ability/AbilitySnapshotCodec.java

@ -0,0 +1,88 @@
package com.mydreamjob.ability;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Component;
import java.util.LinkedHashMap;
import java.util.Map;
/**
* 题目快照编解码:把 {@link AbilityQuestion} 关键内容固化为快照 JSON,
* 并据此判定所选选项所得分值。快照保证后续题库更新不改历史答卷评分。
*/
@Component
@RequiredArgsConstructor
public class AbilitySnapshotCodec {
public static final String KEY_QUESTION_ID = "questionId";
public static final String KEY_QUESTION_CODE = "questionCode";
public static final String KEY_DIMENSION = "dimension";
public static final String KEY_COMPETENCY_TAG = "competencyTag";
public static final String KEY_ITEM_TYPE = "itemType";
public static final String KEY_STEM = "stem";
public static final String KEY_OPTIONS = "options";
public static final String KEY_ANSWER_KEY = "answerKey";
public static final String KEY_SCORING_RULE = "scoringRule";
public static final String KEY_EXPLANATION = "explanation";
private final ObjectMapper objectMapper;
public String build(AbilityQuestion q) {
try {
Map<String, Object> snap = new LinkedHashMap<>();
snap.put(KEY_QUESTION_ID, q.getId());
snap.put(KEY_QUESTION_CODE, q.getQuestionCode());
snap.put(KEY_DIMENSION, q.getDimension());
snap.put(KEY_COMPETENCY_TAG, q.getCompetencyTag());
snap.put(KEY_ITEM_TYPE, q.getItemType());
snap.put(KEY_STEM, q.getStem());
snap.put(KEY_OPTIONS, objectMapper.readTree(q.getOptionsJson()));
snap.put(KEY_ANSWER_KEY, objectMapper.readTree(q.getAnswerKeyJson()).path("answerKey").asText());
snap.put(KEY_SCORING_RULE, objectMapper.readTree(q.getScoringRuleJson()));
snap.put(KEY_EXPLANATION, q.getExplanation());
return objectMapper.writeValueAsString(snap);
} catch (Exception e) {
throw new IllegalStateException("unable to build ability question snapshot", e);
}
}
public JsonNode parse(String snapshotJson) {
try {
return objectMapper.readTree(snapshotJson);
} catch (Exception e) {
throw new IllegalStateException("unable to parse ability question snapshot", e);
}
}
public String text(String snapshotJson, String key) {
JsonNode n = parse(snapshotJson).path(key);
return n.isMissingNode() || n.isNull() ? null : n.asText();
}
/**
* 按所选选项返回分值(0/1/2/4);选项不存在返回 null。
*/
public Integer awardedScore(String snapshotJson, String selectedOptionId) {
JsonNode rule = parse(snapshotJson).path(KEY_SCORING_RULE);
if (rule == null || rule.isMissingNode() || selectedOptionId == null) {
return null;
}
JsonNode v = rule.get(selectedOptionId);
return (v == null || v.isNull()) ? null : v.asInt();
}
public boolean optionExists(String snapshotJson, String optionId) {
JsonNode options = parse(snapshotJson).path(KEY_OPTIONS);
if (options.isMissingNode() || !options.isArray()) {
return false;
}
for (JsonNode o : options) {
if (optionId.equals(o.path("optionId").asText())) {
return true;
}
}
return false;
}
}

44
mydreamjob-server/src/main/java/com/mydreamjob/ability/dto/AbilityAttemptResponse.java

@ -0,0 +1,44 @@
package com.mydreamjob.ability.dto;
import lombok.Builder;
import lombok.Data;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.util.List;
/**
* 答卷响应(创建/详情)。题目仅暴露 attemptQuestionId / position / itemType / stem / options / selectedOptionId,
* 绝不返回 questionCode / dimension / competencyTag / answerKey / scoringRule / explanation / difficulty 等内部字段。
*/
@Data
@Builder
public class AbilityAttemptResponse {
private Long attemptId;
private String status;
private Integer answeredCount;
private Integer totalQuestions;
private BigDecimal completionRate;
private LocalDateTime startedAt;
private LocalDateTime expiresAt;
private Long remainingSeconds;
private List<AbilityAttemptQuestionView> questions;
@Data
@Builder
public static class AbilityAttemptQuestionView {
private Long attemptQuestionId;
private Integer position;
private String itemType;
private String stem;
private List<AbilityOptionView> options;
private String selectedOptionId;
}
@Data
@Builder
public static class AbilityOptionView {
private String optionId;
private String text;
}
}

23
mydreamjob-server/src/main/java/com/mydreamjob/ability/dto/AbilityHistoryItem.java

@ -0,0 +1,23 @@
package com.mydreamjob.ability.dto;
import lombok.Builder;
import lombok.Data;
import java.math.BigDecimal;
import java.time.LocalDateTime;
@Data
@Builder
public class AbilityHistoryItem {
private Long attemptId;
private String typeCode;
private String status;
private String validityStatus;
private BigDecimal overallScore;
private Integer answeredCount;
private BigDecimal completionRate;
private LocalDateTime startedAt;
private LocalDateTime expiresAt;
private LocalDateTime completedAt;
private LocalDateTime validUntil;
}

76
mydreamjob-server/src/main/java/com/mydreamjob/ability/dto/AbilityResultResponse.java

@ -0,0 +1,76 @@
package com.mydreamjob.ability.dto;
import lombok.Builder;
import lombok.Data;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.util.List;
/**
* 结果响应白名单。不含原始答案分值键/完整评分规则/学生身份等敏感字段。
*/
@Data
@Builder
public class AbilityResultResponse {
private Long attemptId;
private String typeCode;
private String status;
private String validityStatus; // VALID / EXPIRED(动态计算)
private LocalDateTime validUntil;
private List<DimensionScore> dimensionScores;
private BigDecimal overallScore;
private List<Weakness> weaknesses;
private List<ImprovementArea> improvementAreas;
private List<RadarEntry> radar;
private Integer answeredCount;
private BigDecimal completionRate;
private List<QuestionFeedback> questionFeedback;
private String questionSetVersion;
private String rulesVersion;
private LocalDateTime generatedAt;
private String disclaimer;
@Data
@Builder
public static class DimensionScore {
private String code; // LOGIC/COMMUNICATION/EXECUTION/LEARNING
private BigDecimal score;
private Integer answeredCount;
}
@Data
@Builder
public static class Weakness {
private String code;
private BigDecimal score;
}
@Data
@Builder
public static class ImprovementArea {
private String dimension;
private BigDecimal currentScore;
private String summary;
private List<String> actions;
}
@Data
@Builder
public static class RadarEntry {
private String code;
private BigDecimal value;
}
@Data
@Builder
public static class QuestionFeedback {
private Long attemptQuestionId;
private Integer position;
private String stem;
private String selectedOptionId;
private Integer awardedScore;
private Integer maxScore;
private String explanation;
}
}

17
mydreamjob-server/src/main/java/com/mydreamjob/ability/dto/AbilityTypeResponse.java

@ -0,0 +1,17 @@
package com.mydreamjob.ability.dto;
import lombok.Builder;
import lombok.Data;
@Data
@Builder
public class AbilityTypeResponse {
private String typeCode;
private String displayName;
private String description;
private Integer questionCount;
private Integer attemptQuestionCount;
private Integer timeLimitMinutes;
private Integer resultValidDays;
private String status;
}

11
mydreamjob-server/src/main/java/com/mydreamjob/ability/dto/CreateAbilityAttemptRequest.java

@ -0,0 +1,11 @@
package com.mydreamjob.ability.dto;
import jakarta.validation.constraints.NotBlank;
import lombok.Data;
@Data
public class CreateAbilityAttemptRequest {
@NotBlank
private String typeCode;
private String idempotencyKey;
}

21
mydreamjob-server/src/main/java/com/mydreamjob/ability/dto/SaveAbilityAnswersRequest.java

@ -0,0 +1,21 @@
package com.mydreamjob.ability.dto;
import jakarta.validation.Valid;
import jakarta.validation.constraints.NotEmpty;
import lombok.Data;
import java.util.List;
@Data
public class SaveAbilityAnswersRequest {
@Valid
@NotEmpty(message = "answers must not be empty")
private List<AnswerItem> answers;
@Data
public static class AnswerItem {
private Long attemptQuestionId;
private String selectedOptionId;
}
}

43
mydreamjob-server/src/main/java/com/mydreamjob/assessment/AssessmentAnswer.java

@ -0,0 +1,43 @@
package com.mydreamjob.assessment;
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.jpa.domain.support.AuditingEntityListener;
import java.time.LocalDateTime;
/**
* 单题答案(V34/V35)。
*
* <p>value 使用 1-5 Likert 量表(1=完全不感兴趣 … 5=非常感兴趣)。同 (attempt, question) 唯一,重复保存走 upsert。</p>
*/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
@Entity
@Table(name = "assessment_answers")
@EntityListeners(AuditingEntityListener.class)
public class AssessmentAnswer {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "attempt_id", nullable = false)
private Long attemptId;
@Column(name = "question_id", nullable = false)
private Long questionId;
@Column(name = "answer_value", nullable = false)
private Integer value;
@CreatedDate
@Column(name = "answered_at", nullable = false, updatable = false)
private LocalDateTime answeredAt;
}

15
mydreamjob-server/src/main/java/com/mydreamjob/assessment/AssessmentAnswerRepository.java

@ -0,0 +1,15 @@
package com.mydreamjob.assessment;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
import java.util.List;
import java.util.Optional;
@Repository
public interface AssessmentAnswerRepository extends JpaRepository<AssessmentAnswer, Long> {
Optional<AssessmentAnswer> findByAttemptIdAndQuestionId(Long attemptId, Long questionId);
List<AssessmentAnswer> findByAttemptId(Long attemptId);
long countByAttemptId(Long attemptId);
void deleteByAttemptId(Long attemptId);
}

89
mydreamjob-server/src/main/java/com/mydreamjob/assessment/AssessmentAttempt.java

@ -0,0 +1,89 @@
package com.mydreamjob.assessment;
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;
/**
* 答卷主记录(V34/V35)。
*
* <p>状态流转:DRAFT → IN_PROGRESS → COMPLETED。{@code samplingSeed} 用于抽题随机种子,
* 持久化但不下发前端。{@code idempotencyKey} 与 {@code studentUserId} 联合唯一实现创建幂等。</p>
*/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
@Entity
@Table(name = "assessment_attempts")
@EntityListeners(AuditingEntityListener.class)
public class AssessmentAttempt {
public static final String STATUS_DRAFT = "DRAFT";
public static final String STATUS_IN_PROGRESS = "IN_PROGRESS";
public static final String STATUS_COMPLETED = "COMPLETED";
public static final String STATUS_EXPIRED = "EXPIRED";
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "student_user_id", nullable = false)
private Long studentUserId;
@Column(name = "definition_id", nullable = false)
private Long definitionId;
@Column(nullable = false, length = 20)
@Builder.Default
private String status = STATUS_DRAFT;
@Column(name = "sampling_seed")
private Long samplingSeed;
@Column(name = "question_set_version", nullable = false, length = 40)
private String questionSetVersion;
@Column(name = "selected_question_count", nullable = false)
private Integer selectedQuestionCount;
@Column(name = "answered_count", nullable = false)
@Builder.Default
private Integer answeredCount = 0;
@Column(name = "idempotency_key", length = 64)
private String idempotencyKey;
/** 活跃答卷唯一键:DRAFT/IN_PROGRESS 非空,终态后置 NULL(数据库唯一约束保证同学生同类型仅一份活跃) */
@Column(name = "active_key", length = 255)
private String activeKey;
@Version
@Column(nullable = false)
@Builder.Default
private Long version = 0L;
@Column(name = "started_at")
private LocalDateTime startedAt;
@Column(name = "submitted_at")
private LocalDateTime submittedAt;
@Column(name = "completed_at")
private LocalDateTime completedAt;
@CreatedDate
@Column(name = "created_at", nullable = false, updatable = false)
private LocalDateTime createdAt;
@LastModifiedDate
@Column(name = "updated_at", nullable = false)
private LocalDateTime updatedAt;
}

43
mydreamjob-server/src/main/java/com/mydreamjob/assessment/AssessmentAttemptQuestion.java

@ -0,0 +1,43 @@
package com.mydreamjob.assessment;
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.jpa.domain.support.AuditingEntityListener;
import java.time.LocalDateTime;
/**
* 本次答卷随机抽中的题目(V34/V35)。
*
* <p>答卷创建时一次性写入 30 道,顺序(position)不可再变,刷新页面不重新抽题。</p>
*/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
@Entity
@Table(name = "assessment_attempt_questions")
@EntityListeners(AuditingEntityListener.class)
public class AssessmentAttemptQuestion {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "attempt_id", nullable = false)
private Long attemptId;
@Column(name = "question_id", nullable = false)
private Long questionId;
@Column(nullable = false)
private Integer position;
@CreatedDate
@Column(name = "selected_at", nullable = false, updatable = false)
private LocalDateTime selectedAt;
}

12
mydreamjob-server/src/main/java/com/mydreamjob/assessment/AssessmentAttemptQuestionRepository.java

@ -0,0 +1,12 @@
package com.mydreamjob.assessment;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
import java.util.List;
@Repository
public interface AssessmentAttemptQuestionRepository extends JpaRepository<AssessmentAttemptQuestion, Long> {
List<AssessmentAttemptQuestion> findByAttemptIdOrderByPositionAsc(Long attemptId);
boolean existsByAttemptIdAndQuestionId(Long attemptId, Long questionId);
}

32
mydreamjob-server/src/main/java/com/mydreamjob/assessment/AssessmentAttemptRepository.java

@ -0,0 +1,32 @@
package com.mydreamjob.assessment;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Modifying;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import org.springframework.stereotype.Repository;
import java.time.LocalDateTime;
import java.util.Collection;
import java.util.Optional;
@Repository
public interface AssessmentAttemptRepository extends JpaRepository<AssessmentAttempt, Long> {
Optional<AssessmentAttempt> findByStudentUserIdAndIdempotencyKey(Long studentUserId, String idempotencyKey);
boolean existsByStudentUserIdAndDefinitionIdAndStatusIn(
Long studentUserId, Long definitionId, Collection<String> statuses);
Page<AssessmentAttempt> findByStudentUserIdOrderByCreatedAtDesc(Long studentUserId, Pageable pageable);
/** 幂等置为已完成:仅 DRAFT/IN_PROGRESS → COMPLETED,并释放 active_key;已终态返回 0,供并发提交防护。 */
@Modifying
@Query("update AssessmentAttempt a set a.status = :status, a.submittedAt = :now, "
+ "a.completedAt = :now, a.answeredCount = :answered, a.activeKey = null "
+ "where a.id = :id and a.status in ('DRAFT','IN_PROGRESS')")
int markCompleted(@Param("id") Long id, @Param("status") String status,
@Param("now") LocalDateTime now, @Param("answered") int answered);
}

484
mydreamjob-server/src/main/java/com/mydreamjob/assessment/AssessmentAttemptService.java

@ -0,0 +1,484 @@
package com.mydreamjob.assessment;
import com.mydreamjob.assessment.dto.AssessmentAttemptResponse;
import com.mydreamjob.assessment.dto.AssessmentHistoryItem;
import com.mydreamjob.assessment.dto.AssessmentQuestionResponse;
import com.mydreamjob.assessment.dto.AssessmentResultResponse;
import com.mydreamjob.assessment.dto.CreateAssessmentAttemptRequest;
import com.mydreamjob.assessment.dto.SaveAssessmentAnswersRequest;
import com.mydreamjob.audit.AuditContext;
import com.mydreamjob.audit.AuditService;
import com.mydreamjob.auth.User;
import com.mydreamjob.auth.UserRepository;
import com.mydreamjob.common.exception.BusinessException;
import com.mydreamjob.common.exception.ErrorCode;
import lombok.RequiredArgsConstructor;
import org.springframework.dao.DataIntegrityViolationException;
import org.springframework.dao.OptimisticLockingFailureException;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Isolation;
import org.springframework.transaction.annotation.Transactional;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.Collections;
import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Random;
import java.util.function.Function;
import java.util.stream.Collectors;
/**
* 答卷业务服务(V34/V35)。
*
* <p>创建答卷时按六维各随机抽 5 题、整体打乱后持久化,刷新页面不重抽;保存答案走 upsert 且受
* 乐观锁保护(9707);提交仅允许 30 题全答(9706)、仅 DRAFT/IN_PROGRESS(9703)、
* 重复提交幂等返回原结果、不重复生成结果与审计。身份仅取自 JWT,仅 STUDENT 可操作。</p>
*/
@Service
@RequiredArgsConstructor
public class AssessmentAttemptService {
private static final int MAX_PAGE_SIZE = 50;
private final AssessmentDefinitionRepository definitionRepository;
private final AssessmentQuestionRepository questionRepository;
private final AssessmentAttemptRepository attemptRepository;
private final AssessmentAttemptQuestionRepository attemptQuestionRepository;
private final AssessmentAnswerRepository answerRepository;
private final AssessmentResultRepository resultRepository;
private final AssessmentResultService resultService;
private final AssessmentResultComputer resultComputer;
private final AssessmentAttemptWriter attemptWriter;
private final UserRepository userRepository;
private final AuditService auditService;
// ─────────────────────────── 创建答卷 ───────────────────────────
/**
* 创建答卷。写实现(attempt+attempt_questions)委托给 {@link AssessmentAttemptWriter}(REQUIRES_NEW 独立事务),
* 并发唯一键冲突在该独立事务内回滚,外层 Session 不被污染。
*
* <p>此处显式使用 {@code READ_COMMITTED} 隔离级别:MySQL/H2 默认行为下,事务内固定的
* REPEATABLE READ 快照会让「外层事务」读不到刚由 {@code REQUIRES_NEW} 提交的 attempt_questions,
* 也读不到并发胜者已提交的活跃答卷,从而返回空题列表或误 500;READ_COMMITTED 使每次读都取最新已提交数据。
*/
@Transactional(isolation = Isolation.READ_COMMITTED)
public AssessmentAttemptResponse create(Long userId, CreateAssessmentAttemptRequest req, AuditContext ctx) {
requireStudent(userId);
AssessmentDefinition def = definitionRepository.findByTypeCode(req.getTypeCode())
.orElseThrow(() -> new BusinessException(ErrorCode.ASSESSMENT_TYPE_NOT_FOUND));
// 仅 ACTIVE 类型可创建;INACTIVE 统一 404/9701
if (!AssessmentDefinition.STATUS_ACTIVE.equals(def.getStatus())) {
throw new BusinessException(ErrorCode.ASSESSMENT_TYPE_NOT_FOUND);
}
String idem = normalizeIdempotencyKey(req.getIdempotencyKey());
// 幂等重放:同学生+同键返回原答卷;同键但定义不同 → 9709
if (idem != null) {
AssessmentAttempt existing = findByIdem(userId, idem);
if (existing != null) {
if (!def.getId().equals(existing.getDefinitionId())) {
throw new BusinessException(ErrorCode.ASSESSMENT_IDEMPOTENCY_CONFLICT);
}
return toAttemptResponse(existing);
}
}
// 快速路径:已有活跃答卷(数据库 active_key 唯一约束才是并发下的最终兜底)
if (attemptRepository.existsByStudentUserIdAndDefinitionIdAndStatusIn(
userId, def.getId(), List.of(AssessmentAttempt.STATUS_DRAFT,
AssessmentAttempt.STATUS_IN_PROGRESS))) {
throw new BusinessException(ErrorCode.ASSESSMENT_DUPLICATE_ACTIVE);
}
long seed = new Random().nextLong();
List<AssessmentQuestion> active = questionRepository
.findByDefinitionIdAndActiveTrueOrderByPositionAsc(def.getId());
List<AssessmentQuestion> sampled = sample(active, seed, def.getAttemptQuestionCount(), def.getMinPerDimension());
// 写入委托给依赖 Bean:attempt + attempt_questions 在独立 REQUIRES_NEW 事务中落库。
// 若并发触发唯一键冲突,失败发生在该独立事务内并自行回滚,外层 Session 不受污染。
AssessmentAttempt attempt;
try {
attempt = attemptWriter.persistNew(userId, def, seed, sampled, idem, activeKeyOf(userId, def.getId()));
} catch (DataIntegrityViolationException e) {
// 幂等键唯一冲突:同学生+同键已有原 attempt → 幂等返回
if (idem != null) {
AssessmentAttempt existing = findByIdem(userId, idem);
if (existing != null) {
if (!def.getId().equals(existing.getDefinitionId())) {
throw new BusinessException(ErrorCode.ASSESSMENT_IDEMPOTENCY_CONFLICT);
}
return toAttemptResponse(existing);
}
}
// active_key 唯一冲突:同学生同类型已有活跃答卷 → 409/9708
if (attemptRepository.existsByStudentUserIdAndDefinitionIdAndStatusIn(
userId, def.getId(), List.of(AssessmentAttempt.STATUS_DRAFT,
AssessmentAttempt.STATUS_IN_PROGRESS))) {
throw new BusinessException(ErrorCode.ASSESSMENT_DUPLICATE_ACTIVE);
}
// 其他数据库约束异常:保留真实错误,不得伪装成重复答卷
throw e;
}
auditService.log(userId, "ASSESSMENT_ATTEMPT_CREATED", "ASSESSMENT_ATTEMPT", attempt.getId(),
"attemptId=" + attempt.getId() + ", typeCode=" + def.getTypeCode()
+ ", status=" + attempt.getStatus()
+ ", questionSetVersion=" + attempt.getQuestionSetVersion()
+ ", rulesVersion=" + def.getRulesVersion(),
ctx);
return toAttemptResponse(attempt);
}
// ─────────────────────────── 答卷详情 ───────────────────────────
@Transactional(readOnly = true)
public AssessmentAttemptResponse get(Long userId, Long attemptId) {
requireStudent(userId);
AssessmentAttempt attempt = loadOwned(userId, attemptId);
return toAttemptResponse(attempt);
}
// ─────────────────────────── 保存答案 ───────────────────────────
@Transactional
public AssessmentAttemptResponse saveAnswers(Long userId, Long attemptId,
SaveAssessmentAnswersRequest req, AuditContext ctx) {
requireStudent(userId);
AssessmentAttempt attempt = loadOwned(userId, attemptId);
if (!isMutable(attempt.getStatus())) {
throw new BusinessException(ErrorCode.ASSESSMENT_ATTEMPT_INVALID_STATE);
}
if (!attempt.getVersion().equals(req.getVersion())) {
throw new BusinessException(ErrorCode.ASSESSMENT_CONCURRENT_UPDATE);
}
// 同一 PATCH 内同题不得重复出现(防止同题不同值静默取最后一条)
if (req.getAnswers().size() != req.getAnswers().stream()
.map(SaveAssessmentAnswersRequest.AnswerItem::getQuestionId).distinct().count()) {
throw new BusinessException(ErrorCode.ASSESSMENT_ANSWER_INVALID,
"duplicate questionId in answers");
}
for (SaveAssessmentAnswersRequest.AnswerItem item : req.getAnswers()) {
if (item.getValue() == null || item.getValue() < 1 || item.getValue() > 5) {
throw new BusinessException(ErrorCode.ASSESSMENT_ANSWER_INVALID, "value must be 1-5");
}
if (!attemptQuestionRepository.existsByAttemptIdAndQuestionId(attemptId, item.getQuestionId())) {
throw new BusinessException(ErrorCode.ASSESSMENT_ANSWER_INVALID,
"question does not belong to this attempt");
}
AssessmentAnswer answer = answerRepository
.findByAttemptIdAndQuestionId(attemptId, item.getQuestionId())
.orElseGet(() -> AssessmentAnswer.builder()
.attemptId(attemptId)
.questionId(item.getQuestionId())
.build());
answer.setValue(item.getValue());
answerRepository.save(answer);
}
answerRepository.flush();
int answered = (int) answerRepository.countByAttemptId(attemptId);
attempt.setAnsweredCount(answered);
if (answered > 0 && AssessmentAttempt.STATUS_DRAFT.equals(attempt.getStatus())) {
attempt.setStatus(AssessmentAttempt.STATUS_IN_PROGRESS);
}
try {
attemptRepository.saveAndFlush(attempt);
} catch (OptimisticLockingFailureException e) {
throw new BusinessException(ErrorCode.ASSESSMENT_CONCURRENT_UPDATE);
}
return toAttemptResponse(attempt);
}
// ─────────────────────────── 提交答卷 ───────────────────────────
@Transactional
public AssessmentResultResponse submit(Long userId, Long attemptId, AuditContext ctx) {
requireStudent(userId);
AssessmentAttempt attempt = loadOwned(userId, attemptId);
// 重复提交幂等:返回原结果,不重新生成、不重复审计
if (AssessmentAttempt.STATUS_COMPLETED.equals(attempt.getStatus())) {
AssessmentResult existing = resultRepository.findByAttemptId(attemptId)
.orElseThrow(() -> new BusinessException(ErrorCode.ASSESSMENT_ATTEMPT_INVALID_STATE));
return resultService.toResponse(attempt, existing);
}
if (!isMutable(attempt.getStatus())) {
throw new BusinessException(ErrorCode.ASSESSMENT_ATTEMPT_INVALID_STATE);
}
int answered = (int) answerRepository.countByAttemptId(attemptId);
if (answered < attempt.getSelectedQuestionCount()) {
throw new BusinessException(ErrorCode.ASSESSMENT_INCOMPLETE);
}
// 每个维度各答 5 题
if (!allSelectedAnswered(attemptId, attempt)) {
throw new BusinessException(ErrorCode.ASSESSMENT_INCOMPLETE);
}
// 原子状态流转:仅 DRAFT/IN_PROGRESS → COMPLETED,并返回影响行数。
// 赢得流转的线程负责生成结果+审计;失败方(行数=0)说明已由并发请求完成 → 幂等返回。
int transitioned = attemptRepository.markCompleted(
attemptId, AssessmentAttempt.STATUS_COMPLETED, LocalDateTime.now(), answered);
if (transitioned == 0) {
AssessmentResult existing = resultRepository.findByAttemptId(attemptId)
.orElseThrow(() -> new BusinessException(ErrorCode.ASSESSMENT_ATTEMPT_INVALID_STATE));
return resultService.toResponse(attempt, existing);
}
AssessmentResultComputer.ComputedResult computed = compute(attempt);
AssessmentResult result;
try {
result = resultService.persist(attempt, computed, answered);
} catch (DataIntegrityViolationException e) {
// 极端并发:result attempt_id 唯一约束兜底,复用已有结果实现幂等
AssessmentResult existing = resultRepository.findByAttemptId(attemptId)
.orElseThrow(() -> new BusinessException(ErrorCode.ASSESSMENT_ATTEMPT_INVALID_STATE));
return resultService.toResponse(attempt, existing);
}
AssessmentDefinition def = definitionRepository.findById(attempt.getDefinitionId()).orElse(null);
auditService.log(userId, "ASSESSMENT_ATTEMPT_SUBMITTED", "ASSESSMENT_ATTEMPT", attemptId,
"attemptId=" + attemptId + ", typeCode=" + (def == null ? null : def.getTypeCode())
+ ", status=COMPLETED, answeredCount=" + answered
+ ", questionSetVersion=" + attempt.getQuestionSetVersion()
+ ", rulesVersion=" + (def == null ? null : def.getRulesVersion()),
ctx);
return resultService.toResponse(attempt, result);
}
// ─────────────────────────── 我的历史 ───────────────────────────
@Transactional(readOnly = true)
public Page<AssessmentHistoryItem> listMine(Long userId, int page, int size) {
requireStudent(userId);
if (page < 0 || size < 1 || size > MAX_PAGE_SIZE) {
throw new BusinessException(ErrorCode.BAD_REQUEST);
}
Page<AssessmentAttempt> attempts = attemptRepository
.findByStudentUserIdOrderByCreatedAtDesc(userId, PageRequest.of(page, size));
List<AssessmentAttempt> content = attempts.getContent();
Map<Long, AssessmentAttempt> attemptById = content.stream()
.collect(Collectors.toMap(AssessmentAttempt::getId, Function.identity()));
Map<Long, AssessmentResult> resultByAttempt = resultRepository
.findByAttemptIdIn(attemptById.keySet()).stream()
.collect(Collectors.toMap(AssessmentResult::getAttemptId, Function.identity()));
Map<Long, AssessmentDefinition> defById = new HashMap<>();
for (AssessmentAttempt a : content) {
if (!defById.containsKey(a.getDefinitionId())) {
defById.put(a.getDefinitionId(),
definitionRepository.findById(a.getDefinitionId()).orElse(null));
}
}
return attempts.map(a -> {
AssessmentResult r = resultByAttempt.get(a.getId());
AssessmentDefinition d = defById.get(a.getDefinitionId());
return AssessmentHistoryItem.builder()
.attemptId(a.getId())
.typeCode(d == null ? null : d.getTypeCode())
.status(a.getStatus())
.answeredCount(a.getAnsweredCount())
.completionRate(completionRate(a))
.questionSetVersion(a.getQuestionSetVersion())
.primaryCode(r == null ? null : r.getPrimaryCode())
.primaryType(r == null ? null : r.getPrimaryType())
.createdAt(a.getCreatedAt())
.submittedAt(a.getSubmittedAt())
.build();
});
}
// ─────────────────────────── 内部工具 ───────────────────────────
private boolean isMutable(String status) {
return AssessmentAttempt.STATUS_DRAFT.equals(status)
|| AssessmentAttempt.STATUS_IN_PROGRESS.equals(status);
}
private AssessmentAttempt loadOwned(Long userId, Long attemptId) {
AssessmentAttempt a = attemptRepository.findById(attemptId)
.orElseThrow(() -> new BusinessException(ErrorCode.ASSESSMENT_ATTEMPT_NOT_FOUND));
if (!a.getStudentUserId().equals(userId)) {
throw new BusinessException(ErrorCode.FORBIDDEN);
}
return a;
}
private void requireStudent(Long userId) {
User u = userRepository.findById(userId)
.orElseThrow(() -> new BusinessException(ErrorCode.USER_NOT_FOUND));
boolean isStudent = u.getRoles() != null && u.getRoles().contains(User.Role.STUDENT);
if (!isStudent) {
throw new BusinessException(ErrorCode.FORBIDDEN);
}
}
private AssessmentAttempt findByIdem(Long userId, String idem) {
if (idem == null) {
return null;
}
return attemptRepository.findByStudentUserIdAndIdempotencyKey(userId, idem).orElse(null);
}
private String normalizeIdempotencyKey(String key) {
return key == null || key.isBlank() ? null : key.trim();
}
private String activeKeyOf(Long studentUserId, Long definitionId) {
return studentUserId + ":" + definitionId + ":ACTIVE";
}
/**
* 抽题:R/I/A/S/E/C 六维各恰好抽 perDim 道,合并后整体打乱,总数为 attemptCount。
*
* <p>严格约束:任一维度 ACTIVE 题不足 perDim 时直接拒绝创建(不静默减少题目数);
* 校验 attemptCount == 6 × perDim 且六维齐全,保证总数严格为 30。</p>
*/
private List<AssessmentQuestion> sample(List<AssessmentQuestion> all, long seed,
int attemptCount, int perDim) {
if (attemptCount <= 0 || perDim <= 0) {
throw new BusinessException(ErrorCode.BAD_REQUEST,
"invalid attempt question count or per-dimension count");
}
if (attemptCount != 6 * perDim) {
throw new BusinessException(ErrorCode.BAD_REQUEST,
"attemptQuestionCount must equal 6 * minPerDimension");
}
Map<String, List<AssessmentQuestion>> byDim = new LinkedHashMap<>();
for (AssessmentQuestion q : all) {
byDim.computeIfAbsent(q.getDimension(), k -> new ArrayList<>()).add(q);
}
if (byDim.size() != 6) {
throw new BusinessException(ErrorCode.BAD_REQUEST,
"all six RIASEC dimensions must be present");
}
for (String dim : List.of("R", "I", "A", "S", "E", "C")) {
int available = byDim.getOrDefault(dim, List.of()).size();
if (available < perDim) {
throw new BusinessException(ErrorCode.BAD_REQUEST,
"insufficient active questions in dimension " + dim
+ " (need " + perDim + ", have " + available + ")");
}
}
Random random = new Random(seed);
List<AssessmentQuestion> picked = new ArrayList<>(attemptCount);
for (String dim : List.of("R", "I", "A", "S", "E", "C")) {
List<AssessmentQuestion> group = new ArrayList<>(byDim.get(dim));
Collections.shuffle(group, random);
picked.addAll(group.subList(0, perDim)); // 显式每维恰 perDim,不再依赖 Math.min()
}
Collections.shuffle(picked, random);
if (picked.size() != attemptCount) {
throw new BusinessException(ErrorCode.BAD_REQUEST,
"sampled question count mismatch");
}
return picked;
}
private boolean allSelectedAnswered(Long attemptId, AssessmentAttempt attempt) {
List<Long> selected = attemptQuestionRepository.findByAttemptIdOrderByPositionAsc(attemptId)
.stream().map(AssessmentAttemptQuestion::getQuestionId).toList();
if (selected.size() < attempt.getSelectedQuestionCount()) {
return false;
}
for (Long qid : selected) {
if (answerRepository.findByAttemptIdAndQuestionId(attemptId, qid).isEmpty()) {
return false;
}
}
return true;
}
private AssessmentResultComputer.ComputedResult compute(AssessmentAttempt attempt) {
List<AssessmentAttemptQuestion> aqs = attemptQuestionRepository
.findByAttemptIdOrderByPositionAsc(attempt.getId());
Map<Long, AssessmentQuestion> questionById = new HashMap<>();
if (!aqs.isEmpty()) {
questionById.putAll(questionRepository.findAllById(
aqs.stream().map(AssessmentAttemptQuestion::getQuestionId).toList())
.stream().collect(Collectors.toMap(AssessmentQuestion::getId, Function.identity())));
}
Map<Long, AssessmentAnswer> answerByQuestion = answerRepository.findByAttemptId(attempt.getId())
.stream().collect(Collectors.toMap(AssessmentAnswer::getQuestionId, Function.identity()));
List<AssessmentResultComputer.DimensionValue> values = new ArrayList<>();
for (AssessmentAttemptQuestion aq : aqs) {
AssessmentQuestion q = questionById.get(aq.getQuestionId());
AssessmentAnswer ans = answerByQuestion.get(aq.getQuestionId());
if (q == null || ans == null) {
continue;
}
values.add(new AssessmentResultComputer.DimensionValue(q.getDimension(), ans.getValue()));
}
AssessmentDefinition def = definitionRepository.findById(attempt.getDefinitionId()).orElse(null);
int perDim = def == null ? 1 : def.getMinPerDimension();
return resultComputer.compute(values, perDim, 5);
}
private AssessmentAttemptResponse toAttemptResponse(AssessmentAttempt attempt) {
List<AssessmentAttemptQuestion> aqs = attemptQuestionRepository
.findByAttemptIdOrderByPositionAsc(attempt.getId());
Map<Long, Integer> values = answerRepository.findByAttemptId(attempt.getId())
.stream().collect(Collectors.toMap(AssessmentAnswer::getQuestionId, AssessmentAnswer::getValue));
Map<Long, AssessmentQuestion> questionById = new HashMap<>();
if (!aqs.isEmpty()) {
questionById.putAll(questionRepository.findAllById(
aqs.stream().map(AssessmentAttemptQuestion::getQuestionId).toList())
.stream().collect(Collectors.toMap(AssessmentQuestion::getId, Function.identity())));
}
int total = attempt.getSelectedQuestionCount() == null ? 30 : attempt.getSelectedQuestionCount();
int answered = values.size();
List<AssessmentQuestionResponse> items = aqs.stream().map(aq -> {
AssessmentQuestion q = questionById.get(aq.getQuestionId());
if (q == null) {
return null;
}
return AssessmentQuestionResponse.builder()
.id(q.getId())
.position(aq.getPosition())
.stem(q.getStem())
.scaleType(q.getScaleType())
.value(values.get(q.getId()))
.build();
}).filter(java.util.Objects::nonNull).toList();
return AssessmentAttemptResponse.builder()
.attemptId(attempt.getId())
.status(attempt.getStatus())
.totalQuestions(total)
.answeredCount(answered)
.completionRate(completionRate(total, answered))
.questionSetVersion(attempt.getQuestionSetVersion())
.version(attempt.getVersion())
.questions(items)
.build();
}
private BigDecimal completionRate(int total, int answered) {
if (total <= 0) {
return BigDecimal.ZERO.setScale(2);
}
return BigDecimal.valueOf(answered)
.multiply(BigDecimal.valueOf(100))
.divide(BigDecimal.valueOf(total), 2, RoundingMode.HALF_UP);
}
private BigDecimal completionRate(AssessmentAttempt a) {
int total = a.getSelectedQuestionCount() == null ? 30 : a.getSelectedQuestionCount();
return completionRate(total, a.getAnsweredCount() == null ? 0 : a.getAnsweredCount());
}
}

60
mydreamjob-server/src/main/java/com/mydreamjob/assessment/AssessmentAttemptWriter.java

@ -0,0 +1,60 @@
package com.mydreamjob.assessment;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Propagation;
import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDateTime;
import java.util.List;
/**
* 答卷持久化写入 Bean(V34/V35)。
*
* <p>将「新建 attempt + 30 条 attempt_questions」放到独立的 {@code REQUIRES_NEW} 事务中执行:
* 当并发创建某一份 attempt 触发唯一键冲突(active_key / idempotency_key)时,冲突发生在
* 新开的事务内、由其自身回滚,外层事务的 Hibernate Session 不会被污染的 flush 破坏,
* 从而避免 {@code Hibernate AssertionFailure} 与 HTTP 500。失败后外层可安全使用新事务查询幂等记录。</p>
*/
@Component
@RequiredArgsConstructor
public class AssessmentAttemptWriter {
private final AssessmentAttemptRepository attemptRepository;
private final AssessmentAttemptQuestionRepository attemptQuestionRepository;
/**
* 在独立的新事务中创建答卷主记录并写入 30 道抽中题目。
*
* @return 已持久化的答卷(含数据库生成的 id)
*/
@Transactional(propagation = Propagation.REQUIRES_NEW)
public AssessmentAttempt persistNew(Long userId, AssessmentDefinition def, long seed,
List<AssessmentQuestion> sampled, String idempotencyKey,
String activeKey) {
AssessmentAttempt attempt = AssessmentAttempt.builder()
.studentUserId(userId)
.definitionId(def.getId())
.status(AssessmentAttempt.STATUS_DRAFT)
.samplingSeed(seed)
.questionSetVersion(def.getQuestionSetVersion())
.selectedQuestionCount(sampled.size())
.answeredCount(0)
.idempotencyKey(idempotencyKey)
.activeKey(activeKey)
.startedAt(LocalDateTime.now())
.build();
attempt = attemptRepository.saveAndFlush(attempt);
int position = 1;
for (AssessmentQuestion q : sampled) {
attemptQuestionRepository.save(AssessmentAttemptQuestion.builder()
.attemptId(attempt.getId())
.questionId(q.getId())
.position(position++)
.build());
}
attemptQuestionRepository.flush();
return attempt;
}
}

67
mydreamjob-server/src/main/java/com/mydreamjob/assessment/AssessmentCatalogService.java

@ -0,0 +1,67 @@
package com.mydreamjob.assessment;
import com.mydreamjob.common.exception.BusinessException;
import com.mydreamjob.common.exception.ErrorCode;
import com.mydreamjob.assessment.dto.AssessmentQuestionResponse;
import com.mydreamjob.assessment.dto.AssessmentTypeResponse;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
/**
* 测评目录服务(V34/V35)。
*
* <p>仅提供测评类型与题目查询,任何已登录用户可访问。题目响应隐藏
* dimension、reverseScored、source、questionVersion。</p>
*/
@Service
@RequiredArgsConstructor
public class AssessmentCatalogService {
private final AssessmentDefinitionRepository definitionRepository;
private final AssessmentQuestionRepository questionRepository;
@Transactional(readOnly = true)
public List<AssessmentTypeResponse> listTypes() {
return definitionRepository.findAll().stream()
.filter(d -> AssessmentDefinition.STATUS_ACTIVE.equals(d.getStatus()))
.map(this::toTypeResponse)
.toList();
}
@Transactional(readOnly = true)
public List<AssessmentQuestionResponse> listQuestions(String typeCode) {
AssessmentDefinition def = definitionRepository.findByTypeCode(typeCode)
.orElseThrow(() -> new BusinessException(ErrorCode.ASSESSMENT_TYPE_NOT_FOUND));
if (!AssessmentDefinition.STATUS_ACTIVE.equals(def.getStatus())) {
throw new BusinessException(ErrorCode.ASSESSMENT_TYPE_NOT_FOUND);
}
return questionRepository.findByDefinitionIdAndActiveTrueOrderByPositionAsc(def.getId())
.stream()
.map(q -> AssessmentQuestionResponse.builder()
.id(q.getId())
.position(q.getPosition())
.stem(q.getStem())
.scaleType(q.getScaleType())
.build())
.toList();
}
private AssessmentTypeResponse toTypeResponse(AssessmentDefinition d) {
return AssessmentTypeResponse.builder()
.typeCode(d.getTypeCode())
.displayName(d.getDisplayName())
.description(d.getDescription())
.instructions(d.getInstructions())
.status(d.getStatus())
.scaleType(d.getScaleType())
.questionCount(d.getQuestionCount())
.attemptQuestionCount(d.getAttemptQuestionCount())
.minPerDimension(d.getMinPerDimension())
.questionSetVersion(d.getQuestionSetVersion())
.rulesVersion(d.getRulesVersion())
.build();
}
}

178
mydreamjob-server/src/main/java/com/mydreamjob/assessment/AssessmentController.java

@ -0,0 +1,178 @@
package com.mydreamjob.assessment;
import com.mydreamjob.assessment.dto.AssessmentAttemptResponse;
import com.mydreamjob.assessment.dto.AssessmentHistoryItem;
import com.mydreamjob.assessment.dto.AssessmentQuestionResponse;
import com.mydreamjob.assessment.dto.AssessmentResultResponse;
import com.mydreamjob.assessment.dto.AssessmentTypeResponse;
import com.mydreamjob.assessment.dto.CreateAssessmentAttemptRequest;
import com.mydreamjob.assessment.dto.SaveAssessmentAnswersRequest;
import com.mydreamjob.audit.AuditContext;
import com.mydreamjob.common.api.ApiResponse;
import com.mydreamjob.common.api.PageResponse;
import com.mydreamjob.common.exception.BusinessException;
import com.mydreamjob.common.exception.ErrorCode;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.responses.ApiResponses;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.data.domain.Page;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.web.bind.annotation.*;
import java.util.List;
/**
* RIASEC 职业兴趣测评接口(V34/V35)。
*
* <p>身份仅取自 JWT,不接受 userId 参数。类型与题目对任意已登录用户开放;
* 答卷创建/详情/保存/提交/结果/历史仅 STUDENT 且仅限本人。COMPANY/ADMIN 操作答卷返回 403。</p>
*/
@Tag(name = "RIASEC 职业兴趣测评", description = "测评类型、题目、答卷、保存答案、提交与结果查询")
@RestController
@RequiredArgsConstructor
public class AssessmentController {
private final AssessmentCatalogService catalogService;
private final AssessmentAttemptService attemptService;
private final AssessmentResultService resultService;
@Operation(summary = "测评类型列表",
description = "返回全部 ACTIVE 测评定义。任意已登录用户可访问;未登录 401。")
@ApiResponses({
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "OK"),
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "401", description = "未登录")
})
@GetMapping("/assessments/types")
public ApiResponse<List<AssessmentTypeResponse>> listTypes(HttpServletRequest request) {
return ApiResponse.ok(catalogService.listTypes(), requestId(request));
}
@Operation(summary = "题目列表",
description = "返回指定测评的 ACTIVE 题目,隐藏维度/reverseScored/source/版本等内部字段。")
@ApiResponses({
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "OK"),
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "401", description = "未登录"),
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "404", description = "测评类型不存在(9701)")
})
@GetMapping("/assessments/{type}/questions")
public ApiResponse<List<AssessmentQuestionResponse>> listQuestions(
@PathVariable String type, HttpServletRequest request) {
return ApiResponse.ok(catalogService.listQuestions(type), requestId(request));
}
@Operation(summary = "创建答卷",
description = "照六维各抽 5 题共 30 道并持久化,刷新不重抽。幂等键重放返回原答卷;"
+ "同键不同定义 409/9709;已有活跃答卷 409/9708。仅 STUDENT。")
@ApiResponses({
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "OK"),
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "401", description = "未登录"),
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "403", description = "非 STUDENT(2002)"),
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "404", description = "测评类型不存在(9701)"),
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "409", description = "活跃答卷/幂等冲突(9708/9709)")
})
@PostMapping("/assessments/attempts")
public ApiResponse<AssessmentAttemptResponse> createAttempt(
@Valid @RequestBody CreateAssessmentAttemptRequest req, HttpServletRequest request) {
return ApiResponse.ok(attemptService.create(resolveUserId(request), req, AuditContext.from(request)),
requestId(request));
}
@Operation(summary = "答卷详情",
description = "返回本次抽中的 30 道题(固定顺序)、已保存答案、进度与当前状态。仅本人可访问;非 STUDENT 403。")
@ApiResponses({
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "OK"),
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "401", description = "未登录"),
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "403", description = "非本人或非 STUDENT(2002)"),
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "404", description = "答卷不存在(9702)")
})
@GetMapping("/assessments/attempts/{attemptId}")
public ApiResponse<AssessmentAttemptResponse> getAttempt(
@PathVariable Long attemptId, HttpServletRequest request) {
return ApiResponse.ok(attemptService.get(resolveUserId(request), attemptId), requestId(request));
}
@Operation(summary = "保存答案",
description = "单题或批量保存,重复保存 upsert;value 1-5;题目必须属于本答卷;"
+ "版本冲突 409/9707;首次保存后状态置为 IN_PROGRESS。仅本人。")
@ApiResponses({
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "OK"),
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "401", description = "未登录"),
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "403", description = "非本人或非 STUDENT(2002)"),
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "404", description = "答卷不存在(9702)"),
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "409", description = "非法状态/并发(9703/9707)")
})
@PatchMapping("/assessments/attempts/{attemptId}/answers")
public ApiResponse<AssessmentAttemptResponse> saveAnswers(
@PathVariable Long attemptId,
@Valid @RequestBody SaveAssessmentAnswersRequest req, HttpServletRequest request) {
return ApiResponse.ok(attemptService.saveAnswers(resolveUserId(request), attemptId, req,
AuditContext.from(request)), requestId(request));
}
@Operation(summary = "提交答卷",
description = "30 题全答且每维各 5 题后方可提交(否则 400/9706);提交后生成结果并写审计;"
+ "重复提交返回原结果,不重复生成与审计。仅本人。")
@ApiResponses({
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "OK"),
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "401", description = "未登录"),
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "403", description = "非本人或非 STUDENT(2002)"),
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "404", description = "答卷不存在(9702)"),
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "409", description = "非法状态(9703)"),
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "400", description = "未完成(9706)")
})
@PostMapping("/assessments/attempts/{attemptId}/submit")
public ApiResponse<AssessmentResultResponse> submitAttempt(
@PathVariable Long attemptId, HttpServletRequest request) {
return ApiResponse.ok(attemptService.submit(resolveUserId(request), attemptId,
AuditContext.from(request)), requestId(request));
}
@Operation(summary = "查看结果",
description = "答卷 COMPLETED 后返回聚合分、类型排名、探索方向。仅本人可访问。")
@ApiResponses({
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "OK"),
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "401", description = "未登录"),
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "403", description = "非本人或非 STUDENT(2002)"),
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "404", description = "答卷不存在(9702)"),
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "409", description = "答卷未完成(9703)")
})
@GetMapping("/assessments/attempts/{attemptId}/result")
public ApiResponse<AssessmentResultResponse> getResult(
@PathVariable Long attemptId, HttpServletRequest request) {
return ApiResponse.ok(resultService.get(resolveUserId(request), attemptId), requestId(request));
}
@Operation(summary = "我的测评历史",
description = "分页返回当前学生的全部答卷与结果概要。仅 STUDENT 本人可访问。")
@ApiResponses({
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "OK"),
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "401", description = "未登录"),
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "403", description = "非 STUDENT(2002)")
})
@GetMapping("/assessments/me")
public ApiResponse<PageResponse<AssessmentHistoryItem>> listMine(
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "20") int size,
HttpServletRequest request) {
Page<AssessmentHistoryItem> result = attemptService.listMine(resolveUserId(request), page, size);
return ApiResponse.ok(PageResponse.of(
result.getContent(),
result.getTotalElements(), result.getNumber(), result.getSize()), requestId(request));
}
private Long resolveUserId(HttpServletRequest request) {
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
if (auth == null || !auth.isAuthenticated()) {
throw new BusinessException(ErrorCode.UNAUTHORIZED);
}
return Long.valueOf(auth.getName());
}
private String requestId(HttpServletRequest request) {
return request.getHeader("X-Request-Id");
}
}

76
mydreamjob-server/src/main/java/com/mydreamjob/assessment/AssessmentDefinition.java

@ -0,0 +1,76 @@
package com.mydreamjob.assessment;
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;
/**
* 测评定义(V34/V35)。
*
* <p>{@code questionCount} 为题库总题数(72),{@code attemptQuestionCount} 为每次答卷抽题数(30),
* {@code minPerDimension} 为每个维度最少抽题数(5)。</p>
*/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
@Entity
@Table(name = "assessment_definitions")
@EntityListeners(AuditingEntityListener.class)
public class AssessmentDefinition {
public static final String STATUS_ACTIVE = "ACTIVE";
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "type_code", nullable = false, length = 40)
private String typeCode;
@Column(name = "display_name", nullable = false, length = 100)
private String displayName;
@Column(length = 1000)
private String description;
@Column(columnDefinition = "TEXT")
private String instructions;
@Column(nullable = false, length = 20)
@Builder.Default
private String status = STATUS_ACTIVE;
@Column(name = "scale_type", nullable = false, length = 20)
private String scaleType;
@Column(name = "question_count", nullable = false)
private Integer questionCount;
@Column(name = "attempt_question_count", nullable = false)
private Integer attemptQuestionCount;
@Column(name = "min_per_dimension", nullable = false)
private Integer minPerDimension;
@Column(name = "question_set_version", nullable = false, length = 40)
private String questionSetVersion;
@Column(name = "rules_version", nullable = false, length = 40)
private String rulesVersion;
@CreatedDate
@Column(name = "created_at", nullable = false, updatable = false)
private LocalDateTime createdAt;
@LastModifiedDate
@Column(name = "updated_at", nullable = false)
private LocalDateTime updatedAt;
}

11
mydreamjob-server/src/main/java/com/mydreamjob/assessment/AssessmentDefinitionRepository.java

@ -0,0 +1,11 @@
package com.mydreamjob.assessment;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
import java.util.Optional;
@Repository
public interface AssessmentDefinitionRepository extends JpaRepository<AssessmentDefinition, Long> {
Optional<AssessmentDefinition> findByTypeCode(String typeCode);
}

80
mydreamjob-server/src/main/java/com/mydreamjob/assessment/AssessmentQuestion.java

@ -0,0 +1,80 @@
package com.mydreamjob.assessment;
import jakarta.persistence.*;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import org.hibernate.annotations.JdbcTypeCode;
import org.hibernate.type.SqlTypes;
import org.springframework.data.annotation.CreatedDate;
import org.springframework.data.annotation.LastModifiedDate;
import org.springframework.data.jpa.domain.support.AuditingEntityListener;
import java.time.LocalDateTime;
/**
* 测评题目(V34/V35)。
*
* <p>所有题目仅测量职业兴趣,不测量能力,无正确答案,{@code reverseScored} 恒为 false,
* 来源 {@code SELF_AUTHORED}。</p>
*/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
@Entity
@Table(name = "assessment_questions")
@EntityListeners(AuditingEntityListener.class)
public class AssessmentQuestion {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "question_code", nullable = false, length = 20)
private String questionCode;
@Column(name = "definition_id", nullable = false)
private Long definitionId;
@Column(nullable = false)
private Integer position;
@Column(nullable = false, length = 1)
private String dimension;
@Column(nullable = false, length = 2000)
private String stem;
@Column(name = "scale_type", nullable = false, length = 20)
private String scaleType;
@Column(name = "question_version", nullable = false, length = 40)
private String questionVersion;
@Column(nullable = false, length = 40)
private String source;
/**
* MySQL/H2 MODE=MySQL 均以 TINYINT 存储 0/1;用 {@code @JdbcTypeCode(TINYINT)} 使
* Hibernate 在 schema 校验时按 tinyint 比对,避免默认 Boolean→BIT 与 MySQL TINYINT 的类型冲突。
*/
@Column(name = "reverse_scored", nullable = false)
@JdbcTypeCode(SqlTypes.TINYINT)
@Builder.Default
private Boolean reverseScored = false;
@Column(nullable = false)
@JdbcTypeCode(SqlTypes.TINYINT)
@Builder.Default
private Boolean active = true;
@CreatedDate
@Column(name = "created_at", nullable = false, updatable = false)
private LocalDateTime createdAt;
@LastModifiedDate
@Column(name = "updated_at", nullable = false)
private LocalDateTime updatedAt;
}

11
mydreamjob-server/src/main/java/com/mydreamjob/assessment/AssessmentQuestionRepository.java

@ -0,0 +1,11 @@
package com.mydreamjob.assessment;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
import java.util.List;
@Repository
public interface AssessmentQuestionRepository extends JpaRepository<AssessmentQuestion, Long> {
List<AssessmentQuestion> findByDefinitionIdAndActiveTrueOrderByPositionAsc(Long definitionId);
}

64
mydreamjob-server/src/main/java/com/mydreamjob/assessment/AssessmentResult.java

@ -0,0 +1,64 @@
package com.mydreamjob.assessment;
import jakarta.persistence.*;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.math.BigDecimal;
import java.time.LocalDateTime;
/**
* 测评结果(V34/V35)。
*
* <p>仅 RIASEC 六维聚合分,不存储原始答案。{@code dimensionScoresText} / {@code typeRankingText}
* 以 TEXT 保存 JSON 字符串,保持 MySQL 8 与 H2 MODE=MySQL 兼容。</p>
*/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
@Entity
@Table(name = "assessment_results")
public class AssessmentResult {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "attempt_id", nullable = false)
private Long attemptId;
@Column(name = "primary_code", nullable = false, length = 3)
private String primaryCode;
@Column(name = "primary_type", nullable = false, length = 1)
private String primaryType;
@Column(name = "dimension_scores_text", columnDefinition = "TEXT")
private String dimensionScoresText;
@Column(name = "type_ranking_text", columnDefinition = "TEXT")
private String typeRankingText;
@Column(name = "answered_count", nullable = false)
private Integer answeredCount;
@Column(name = "completion_rate", nullable = false, precision = 5, scale = 2)
private BigDecimal completionRate;
@Column(name = "question_set_version", nullable = false, length = 40)
private String questionSetVersion;
@Column(name = "rules_version", nullable = false, length = 40)
private String rulesVersion;
@Column(name = "generated_at", nullable = false)
private LocalDateTime generatedAt;
@Version
@Column(nullable = false)
@Builder.Default
private Long version = 0L;
}

85
mydreamjob-server/src/main/java/com/mydreamjob/assessment/AssessmentResultComputer.java

@ -0,0 +1,85 @@
package com.mydreamjob.assessment;
import org.springframework.stereotype.Component;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
/**
* RIASEC 计分引擎(V34/V35,rules-version riasec-scoring-v0.1)。
*
* <p>每个维度 5 道题:rawScore = 5 道题之和,maxScore = 25,
* percentage = rawScore / 25 × 100(BigDecimal, HALF_UP, scale=2)。
* 六维按 percentage 降序排序,同分按维度字母升序;前三位组成 primaryCode。</p>
*/
@Component
public class AssessmentResultComputer {
/** 六个维度的规范顺序,用于分数结果的有序输出 */
public static final List<String> DIMENSION_ORDER = List.of("R", "I", "A", "S", "E", "C");
/** 单条已答维度值 */
public record DimensionValue(String dimension, Integer value) {}
/** 单个维度计算结果 */
public record DimensionScoreResult(String dimension, BigDecimal score, int answeredCount) {}
/** 计算结果 */
public record ComputedResult(
List<DimensionScoreResult> dimensionScores,
List<String> typeRanking,
String primaryCode,
String primaryType) {}
/**
* 计算六维聚合分、排序与 primary code。
*
* @param answers 已答的 (维度, 值) 列表
* @param questionsPerDimension 每个维度抽题数(5)
* @param maxScorePerQuestion maxScorePerQuestion 每题最高分(5)
*/
public ComputedResult compute(List<DimensionValue> answers,
int questionsPerDimension, int maxScorePerQuestion) {
int maxScore = questionsPerDimension * maxScorePerQuestion;
// 分维度求和
Map<String, Integer> rawScores = new HashMap<>();
Map<String, Integer> answeredCount = new HashMap<>();
for (DimensionValue a : answers) {
if (a.dimension() == null || a.value() == null) continue;
rawScores.merge(a.dimension(), a.value(), Integer::sum);
answeredCount.merge(a.dimension(), 1, Integer::sum);
}
// 规范顺序输出六维分数
Map<String, BigDecimal> percentages = new LinkedHashMap<>();
List<DimensionScoreResult> scores = new ArrayList<>(6);
for (String dim : DIMENSION_ORDER) {
int raw = rawScores.getOrDefault(dim, 0);
int count = answeredCount.getOrDefault(dim, 0);
BigDecimal pct = BigDecimal.valueOf(raw)
.multiply(BigDecimal.valueOf(100))
.divide(BigDecimal.valueOf(maxScore), 2, RoundingMode.HALF_UP);
percentages.put(dim, pct);
scores.add(new DimensionScoreResult(dim, pct, count));
}
// 降序排序,同分按维度字母升序
List<String> ranking = new ArrayList<>(DIMENSION_ORDER);
ranking.sort((d1, d2) -> {
int cmp = percentages.get(d2).compareTo(percentages.get(d1));
return cmp != 0 ? cmp : d1.compareTo(d2);
});
String primaryCode = ranking.size() >= 3
? ranking.get(0) + ranking.get(1) + ranking.get(2) : null;
String primaryType = ranking.isEmpty() ? null : ranking.get(0);
return new ComputedResult(scores, ranking, primaryCode, primaryType);
}
}

15
mydreamjob-server/src/main/java/com/mydreamjob/assessment/AssessmentResultRepository.java

@ -0,0 +1,15 @@
package com.mydreamjob.assessment;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
import java.util.Collection;
import java.util.List;
import java.util.Optional;
@Repository
public interface AssessmentResultRepository extends JpaRepository<AssessmentResult, Long> {
Optional<AssessmentResult> findByAttemptId(Long attemptId);
boolean existsByAttemptId(Long attemptId);
List<AssessmentResult> findByAttemptIdIn(Collection<Long> attemptIds);
}

143
mydreamjob-server/src/main/java/com/mydreamjob/assessment/AssessmentResultService.java

@ -0,0 +1,143 @@
package com.mydreamjob.assessment;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.mydreamjob.assessment.dto.AssessmentResultResponse;
import com.mydreamjob.common.exception.BusinessException;
import com.mydreamjob.common.exception.ErrorCode;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.time.LocalDateTime;
import java.util.List;
/**
* 测评结果服务(V34/V35)。
*
* <p>负责将计分结果持久化(dimension/ranking 以 TEXT JSON 保存)与构建对外结果响应。
* 结果响应不返回 studentUserId、phone、email、password、原始答案及他人信息。</p>
*/
@Service
@RequiredArgsConstructor
public class AssessmentResultService {
public static final String DISCLAIMER = "本结果仅供职业兴趣探索参考,不构成心理或职业诊断。";
private static final TypeReference<List<AssessmentResultResponse.DimensionScore>> DIM_SCORES_TYPE = new TypeReference<>() {};
private static final TypeReference<List<String>> STRING_LIST_TYPE = new TypeReference<>() {};
private final AssessmentResultRepository resultRepository;
private final AssessmentDefinitionRepository definitionRepository;
private final AssessmentAttemptRepository attemptRepository;
private final CareerDirectionCatalog directionCatalog;
private final ObjectMapper objectMapper;
/**
* 持久化结果。attempt_id 在表上唯一,并发重复生成会在数据库层被拦截。
*/
@Transactional
public AssessmentResult persist(AssessmentAttempt attempt,
AssessmentResultComputer.ComputedResult computed,
int answeredCount) {
int total = attempt.getSelectedQuestionCount() == null ? 0 : attempt.getSelectedQuestionCount();
BigDecimal rate = total == 0 ? BigDecimal.ZERO.setScale(2)
: BigDecimal.valueOf(answeredCount)
.multiply(BigDecimal.valueOf(100))
.divide(BigDecimal.valueOf(total), 2, RoundingMode.HALF_UP);
AssessmentResult result = AssessmentResult.builder()
.attemptId(attempt.getId())
.primaryCode(computed.primaryCode())
.primaryType(computed.primaryType())
.dimensionScoresText(toJson(computed.dimensionScores()))
.typeRankingText(toJson(computed.typeRanking()))
.answeredCount(answeredCount)
.completionRate(rate)
.questionSetVersion(attempt.getQuestionSetVersion())
.rulesVersion(attemptRulesVersion(attempt))
.generatedAt(LocalDateTime.now())
.build();
return resultRepository.saveAndFlush(result);
}
/**
* 读取本人答卷结果。非 COMPLETED 返回 9703。
*/
@Transactional(readOnly = true)
public AssessmentResultResponse get(Long userId, Long attemptId) {
AssessmentAttempt attempt = requireCompleted(userId, attemptId);
AssessmentResult result = resultRepository.findByAttemptId(attemptId)
.orElseThrow(() -> new BusinessException(ErrorCode.ASSESSMENT_ATTEMPT_INVALID_STATE));
return toResponse(attempt, result);
}
/**
* 仅本人且答卷已 COMPLETED 才允许读取结果。
*/
public AssessmentAttempt requireCompleted(Long userId, Long attemptId) {
AssessmentAttempt attempt = attemptFor(userId, attemptId);
if (!AssessmentAttempt.STATUS_COMPLETED.equals(attempt.getStatus())) {
throw new BusinessException(ErrorCode.ASSESSMENT_ATTEMPT_INVALID_STATE);
}
return attempt;
}
public AssessmentAttempt attemptFor(Long userId, Long attemptId) {
AssessmentAttempt attempt = attemptRepository.findById(attemptId)
.orElseThrow(() -> new BusinessException(ErrorCode.ASSESSMENT_ATTEMPT_NOT_FOUND));
if (!attempt.getStudentUserId().equals(userId)) {
throw new BusinessException(ErrorCode.FORBIDDEN);
}
return attempt;
}
public AssessmentResultResponse toResponse(AssessmentAttempt attempt, AssessmentResult result) {
AssessmentDefinition def = definitionRepository.findById(attempt.getDefinitionId()).orElse(null);
String typeCode = def == null ? null : def.getTypeCode();
return AssessmentResultResponse.builder()
.attemptId(attempt.getId())
.typeCode(typeCode)
.status(AssessmentAttempt.STATUS_COMPLETED)
.primaryCode(result.getPrimaryCode())
.primaryType(result.getPrimaryType())
.dimensionScores(fromJson(result.getDimensionScoresText(), DIM_SCORES_TYPE))
.typeRanking(fromJson(result.getTypeRankingText(), STRING_LIST_TYPE))
.answeredCount(result.getAnsweredCount())
.completionRate(result.getCompletionRate())
.questionSetVersion(result.getQuestionSetVersion())
.rulesVersion(result.getRulesVersion())
.generatedAt(result.getGeneratedAt() == null ? null : result.getGeneratedAt().toString())
.disclaimer(DISCLAIMER)
.explorationDirections(directionCatalog.resolveDirections(result.getPrimaryCode()))
.build();
}
private String attemptRulesVersion(AssessmentAttempt attempt) {
return definitionRepository.findById(attempt.getDefinitionId())
.map(AssessmentDefinition::getRulesVersion)
.orElse(null);
}
private String toJson(Object value) {
try {
return objectMapper.writeValueAsString(value);
} catch (JsonProcessingException e) {
throw new BusinessException(ErrorCode.INTERNAL_ERROR, "failed to serialize score");
}
}
private <T> T fromJson(String json, TypeReference<T> type) {
if (json == null || json.isBlank()) {
return null;
}
try {
return objectMapper.readValue(json, type);
} catch (JsonProcessingException e) {
return null;
}
}
}

59
mydreamjob-server/src/main/java/com/mydreamjob/assessment/CareerDirectionCatalog.java

@ -0,0 +1,59 @@
package com.mydreamjob.assessment;
import org.springframework.stereotype.Component;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
/**
* 职业方向探索建议(V34/V35, career-direction-v0.1)。
*
* <p>仅供职业兴趣探索参考,不使用"适合职业/精准匹配/最适合职业/职业诊断结果"等表述。
* 返回文案统一为"可能感兴趣的职业探索方向"。</p>
*/
@Component
public class CareerDirectionCatalog {
/** 已配置的三字母组合 → 探索方向(精确优先) */
private final Map<String, List<String>> curated = new LinkedHashMap<>();
/** 单维度兜底方向(未配置的三字母组合按前三个维度分别返回一条兜底) */
private final Map<String, String> dimensionDefault = new LinkedHashMap<>();
public CareerDirectionCatalog() {
curated.put("SIC", List.of("教育辅导", "用户运营", "客户成功", "职业咨询"));
curated.put("SEC", List.of("市场营销", "客户成功", "项目运营", "职业咨询"));
curated.put("SIE", List.of("职业咨询", "教育培训", "产品运营", "客户成功"));
curated.put("RIE", List.of("技术研发", "产品方案", "项目管理", "质量控制"));
dimensionDefault.put("R", "工程设计/现场实施");
dimensionDefault.put("I", "数据分析/科研支持");
dimensionDefault.put("A", "创意设计/内容创作");
dimensionDefault.put("S", "教育辅导/社区服务");
dimensionDefault.put("E", "市场营销/项目管理");
dimensionDefault.put("C", "财务行政/流程运营");
}
/**
* 由 primaryCode 解析探索方向。
*/
public List<String> resolveDirections(String primaryCode) {
if (primaryCode == null || primaryCode.isBlank()) {
return List.of();
}
String key = primaryCode.toUpperCase();
if (curated.containsKey(key)) {
return curated.get(key);
}
List<String> fallback = new ArrayList<>();
for (char c : key.toCharArray()) {
String dim = String.valueOf(c);
if (dimensionDefault.containsKey(dim)) {
fallback.add(dimensionDefault.get(dim));
}
}
return fallback;
}
}

26
mydreamjob-server/src/main/java/com/mydreamjob/assessment/dto/AssessmentAttemptResponse.java

@ -0,0 +1,26 @@
package com.mydreamjob.assessment.dto;
import lombok.Builder;
import lombok.Data;
import java.math.BigDecimal;
import java.util.List;
/**
* 答卷响应(V34/V35)。
*
* <p>包含本次抽中的 30 道题(固定顺序)及已保存答案。不含 samplingSeed、studentUserId 等内部字段。</p>
*/
@Data
@Builder
public class AssessmentAttemptResponse {
private Long attemptId;
private String status;
private Integer totalQuestions;
private Integer answeredCount;
private BigDecimal completionRate;
private String questionSetVersion;
private Long version;
private List<AssessmentQuestionResponse> questions;
}

26
mydreamjob-server/src/main/java/com/mydreamjob/assessment/dto/AssessmentHistoryItem.java

@ -0,0 +1,26 @@
package com.mydreamjob.assessment.dto;
import lombok.Builder;
import lombok.Data;
import java.math.BigDecimal;
import java.time.LocalDateTime;
/**
* 我的测评历史项(V34/V35)。
*/
@Data
@Builder
public class AssessmentHistoryItem {
private Long attemptId;
private String typeCode;
private String status;
private Integer answeredCount;
private BigDecimal completionRate;
private String questionSetVersion;
private String primaryCode;
private String primaryType;
private LocalDateTime createdAt;
private LocalDateTime submittedAt;
}

22
mydreamjob-server/src/main/java/com/mydreamjob/assessment/dto/AssessmentQuestionResponse.java

@ -0,0 +1,22 @@
package com.mydreamjob.assessment.dto;
import lombok.Builder;
import lombok.Data;
/**
* 题目响应(V34/V35)。
*
* <p>对外隐藏 dimension、reverseScored、source、questionVersion、questionCode ——
* 不暴露任何可推导 R/I/A/S/E/C 维度的内部编码。{@code value} 仅在答卷上下文中
* 填充当前已保存答案(1-5),题目目录列表场景为 null。</p>
*/
@Data
@Builder
public class AssessmentQuestionResponse {
private Long id;
private Integer position;
private String stem;
private String scaleType;
private Integer value;
}

40
mydreamjob-server/src/main/java/com/mydreamjob/assessment/dto/AssessmentResultResponse.java

@ -0,0 +1,40 @@
package com.mydreamjob.assessment.dto;
import lombok.Builder;
import lombok.Data;
import java.math.BigDecimal;
import java.util.List;
/**
* 测评结果响应(V34/V35)。
*
* <p>仅返回聚合分与探索建议,不暴露 studentUserId、phone、email、password、原始答案及他人信息。</p>
*/
@Data
@Builder
public class AssessmentResultResponse {
private Long attemptId;
private String typeCode;
private String status;
private String primaryCode;
private String primaryType;
private List<DimensionScore> dimensionScores;
private List<String> typeRanking;
private Integer answeredCount;
private BigDecimal completionRate;
private String questionSetVersion;
private String rulesVersion;
private String generatedAt;
private String disclaimer;
private List<String> explorationDirections;
@Data
@Builder
public static class DimensionScore {
private String dimension;
private BigDecimal score;
private Integer answeredCount;
}
}

24
mydreamjob-server/src/main/java/com/mydreamjob/assessment/dto/AssessmentTypeResponse.java

@ -0,0 +1,24 @@
package com.mydreamjob.assessment.dto;
import lombok.Builder;
import lombok.Data;
/**
* 测评类型响应(V34/V35)。
*/
@Data
@Builder
public class AssessmentTypeResponse {
private String typeCode;
private String displayName;
private String description;
private String instructions;
private String status;
private String scaleType;
private Integer questionCount;
private Integer attemptQuestionCount;
private Integer minPerDimension;
private String questionSetVersion;
private String rulesVersion;
}

21
mydreamjob-server/src/main/java/com/mydreamjob/assessment/dto/CreateAssessmentAttemptRequest.java

@ -0,0 +1,21 @@
package com.mydreamjob.assessment.dto;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;
import lombok.Builder;
import lombok.Data;
/**
* 创建答卷请求(V34/V35)。
*/
@Data
@Builder
public class CreateAssessmentAttemptRequest {
@NotBlank(message = "typeCode is required")
@Size(max = 40, message = "typeCode must be at most 40 chars")
private String typeCode;
@Size(max = 64, message = "idempotencyKey must be at most 64 chars")
private String idempotencyKey;
}

41
mydreamjob-server/src/main/java/com/mydreamjob/assessment/dto/SaveAssessmentAnswersRequest.java

@ -0,0 +1,41 @@
package com.mydreamjob.assessment.dto;
import jakarta.validation.Valid;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotEmpty;
import jakarta.validation.constraints.NotNull;
import lombok.Builder;
import lombok.Data;
import java.util.List;
/**
* 保存答案请求(V34/V35)。
*
* <p>支持单题或批量;同一题重复保存执行 upsert。{@code version} 为乐观锁版本,用于并发冲突检测(9707)。</p>
*/
@Data
@Builder
public class SaveAssessmentAnswersRequest {
@NotNull(message = "version is required")
@Min(value = 0, message = "version must be >= 0")
private Long version;
@NotEmpty(message = "answers is required")
@Valid
private List<AnswerItem> answers;
@Data
@Builder
public static class AnswerItem {
@NotNull(message = "questionId is required")
private Long questionId;
@NotNull(message = "value is required")
@Min(value = 1, message = "value must be 1-5")
@jakarta.validation.constraints.Max(value = 5, message = "value must be 1-5")
private Integer value;
}
}

7
mydreamjob-server/src/main/java/com/mydreamjob/audit/AuditLogRepository.java

@ -19,4 +19,11 @@ public interface AuditLogRepository extends JpaRepository<AuditLog, Long> {
List<AuditLog> findByCreatedAtAfter(@Param("since") LocalDateTime since);
void deleteByCreatedAtBefore(LocalDateTime before);
// ---- V37 风险扫描:读取增量审计 + 窗口命中数(仅读取,不复制 details/ip/user_agent) ----
@Query("SELECT a FROM AuditLog a WHERE a.id > :id ORDER BY a.id ASC")
List<AuditLog> findByIdAfterOrderByIdAsc(@Param("id") Long id, Pageable pageable);
long countByUserIdAndActionAndCreatedAtBetween(Long userId, String action,
LocalDateTime from, LocalDateTime to);
}

33
mydreamjob-server/src/main/java/com/mydreamjob/common/exception/ErrorCode.java

@ -165,7 +165,38 @@ public enum ErrorCode {
REPORT_REASON_REQUIRED(9607, "report reason is required"),
CANNOT_BLOCK_SELF(9608, "cannot block yourself"),
REPORT_RATE_LIMITED(9609, "report rate limited"),
BLOCKED_SEND_PROHIBITED(9610, "message cannot be sent");
BLOCKED_SEND_PROHIBITED(9610, "message cannot be sent"),
// RIASEC 职业兴趣测评 97xx(9704 预留,未使用)
ASSESSMENT_TYPE_NOT_FOUND(9701, "assessment type not found"),
ASSESSMENT_ATTEMPT_NOT_FOUND(9702, "assessment attempt not found"),
ASSESSMENT_ATTEMPT_INVALID_STATE(9703, "assessment attempt is not in a valid state"),
ASSESSMENT_ANSWER_INVALID(9705, "invalid assessment answer"),
ASSESSMENT_INCOMPLETE(9706, "assessment is incomplete, answer all questions before submitting"),
ASSESSMENT_CONCURRENT_UPDATE(9707, "assessment attempt was modified concurrently"),
ASSESSMENT_DUPLICATE_ACTIVE(9708, "an active assessment attempt already exists"),
ASSESSMENT_IDEMPOTENCY_CONFLICT(9709, "idempotency key conflict: request body differs"),
// 四维能力测评 98xx
ABILITY_TYPE_NOT_FOUND(9801, "ability assessment type not found"),
ABILITY_ATTEMPT_NOT_FOUND(9802, "ability attempt not found"),
ABILITY_ATTEMPT_INVALID_STATE(9803, "ability attempt is not in a valid state"),
ABILITY_RESULT_NOT_FOUND(9804, "ability result not found"),
ABILITY_ANSWER_INVALID(9805, "invalid ability answer"),
ABILITY_INCOMPLETE(9806, "ability assessment is incomplete, answer all questions before submitting"),
ABILITY_CONCURRENT_UPDATE(9807, "ability attempt was modified concurrently"),
ABILITY_DUPLICATE_ACTIVE(9808, "an active ability attempt already exists"),
ABILITY_IDEMPOTENCY_CONFLICT(9809, "idempotency key conflict: request body differs"),
ABILITY_RETAKE_COOLDOWN(9810, "a valid result still exists within cooldown period"),
ABILITY_RETAKE_LIMIT(9811, "retake limit reached within the rolling window"),
// 风险概览与运营规则配置 99xx
RISK_RULE_NOT_FOUND(9901, "risk rule not found"),
RISK_EVENT_NOT_FOUND(9902, "risk event not found"),
RISK_RULE_CONCURRENT_UPDATE(9903, "risk rule was modified concurrently"),
RISK_EVENT_CONCURRENT_UPDATE(9904, "risk event was modified concurrently"),
RISK_INVALID_ACTION(9905, "invalid risk action or reason code"),
RISK_INVALID_FILTER(9906, "invalid risk filter");
private final int code;
private final String message;

45
mydreamjob-server/src/main/java/com/mydreamjob/common/exception/GlobalExceptionHandler.java

@ -166,11 +166,56 @@ public class GlobalExceptionHandler {
// 举报限流 → 429
if (errorCode == ErrorCode.REPORT_RATE_LIMITED.getCode())
return HttpStatus.TOO_MANY_REQUESTS;
// RIASEC 测评:不存在 → 404
if (errorCode == ErrorCode.ASSESSMENT_TYPE_NOT_FOUND.getCode()
|| errorCode == ErrorCode.ASSESSMENT_ATTEMPT_NOT_FOUND.getCode())
return HttpStatus.NOT_FOUND;
// RIASEC 测评:状态/并发/幂等冲突 → 409
if (errorCode == ErrorCode.ASSESSMENT_ATTEMPT_INVALID_STATE.getCode()
|| errorCode == ErrorCode.ASSESSMENT_CONCURRENT_UPDATE.getCode()
|| errorCode == ErrorCode.ASSESSMENT_DUPLICATE_ACTIVE.getCode()
|| errorCode == ErrorCode.ASSESSMENT_IDEMPOTENCY_CONFLICT.getCode())
return HttpStatus.CONFLICT;
// RIASEC 测评:答案/未完成 → 400
if (errorCode == ErrorCode.ASSESSMENT_ANSWER_INVALID.getCode()
|| errorCode == ErrorCode.ASSESSMENT_INCOMPLETE.getCode())
return HttpStatus.BAD_REQUEST;
// 四维能力测评:不存在 → 404
if (errorCode == ErrorCode.ABILITY_TYPE_NOT_FOUND.getCode()
|| errorCode == ErrorCode.ABILITY_ATTEMPT_NOT_FOUND.getCode()
|| errorCode == ErrorCode.ABILITY_RESULT_NOT_FOUND.getCode())
return HttpStatus.NOT_FOUND;
// 四维能力测评:状态/并发/幂等/冷却冲突 → 409
if (errorCode == ErrorCode.ABILITY_ATTEMPT_INVALID_STATE.getCode()
|| errorCode == ErrorCode.ABILITY_CONCURRENT_UPDATE.getCode()
|| errorCode == ErrorCode.ABILITY_DUPLICATE_ACTIVE.getCode()
|| errorCode == ErrorCode.ABILITY_IDEMPOTENCY_CONFLICT.getCode()
|| errorCode == ErrorCode.ABILITY_RETAKE_COOLDOWN.getCode())
return HttpStatus.CONFLICT;
// 四维能力测评:答案/未完成 → 400
if (errorCode == ErrorCode.ABILITY_ANSWER_INVALID.getCode()
|| errorCode == ErrorCode.ABILITY_INCOMPLETE.getCode())
return HttpStatus.BAD_REQUEST;
// 四维能力测评:重测次数限制 → 429
if (errorCode == ErrorCode.ABILITY_RETAKE_LIMIT.getCode())
return HttpStatus.TOO_MANY_REQUESTS;
// Offer 进阶契约 → 400(不可撤销、不可还价、比较数量超限)
if (errorCode == ErrorCode.OFFER_NOT_WITHDRAWABLE.getCode()
|| errorCode == ErrorCode.OFFER_NOT_NEGOTIABLE.getCode()
|| errorCode == ErrorCode.OFFER_COMPARE_TOO_MANY.getCode())
return HttpStatus.BAD_REQUEST;
// 风险概览与运营规则配置:不存在 → 404
if (errorCode == ErrorCode.RISK_RULE_NOT_FOUND.getCode()
|| errorCode == ErrorCode.RISK_EVENT_NOT_FOUND.getCode())
return HttpStatus.NOT_FOUND;
// 风险概览与运营规则配置:并发/非法流转 → 409
if (errorCode == ErrorCode.RISK_RULE_CONCURRENT_UPDATE.getCode()
|| errorCode == ErrorCode.RISK_EVENT_CONCURRENT_UPDATE.getCode()
|| errorCode == ErrorCode.RISK_INVALID_ACTION.getCode())
return HttpStatus.CONFLICT;
// 风险概览与运营规则配置:非法筛选 → 400(默认即 400,显式声明保证 9906)
if (errorCode == ErrorCode.RISK_INVALID_FILTER.getCode())
return HttpStatus.BAD_REQUEST;
return HttpStatus.BAD_REQUEST;
}

18
mydreamjob-server/src/main/java/com/mydreamjob/common/time/TimeConfig.java

@ -0,0 +1,18 @@
package com.mydreamjob.common.time;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import java.time.Clock;
/**
* 可注入的 {@link Clock},便于在计时与超时边界测试中固定时间(V36)。
*/
@Configuration
public class TimeConfig {
@Bean
public Clock clock() {
return Clock.systemDefaultZone();
}
}

4
mydreamjob-server/src/main/java/com/mydreamjob/config/SecurityConfig.java

@ -121,6 +121,10 @@ public class SecurityConfig {
.requestMatchers("/company/scoring/dashboard/**").authenticated()
// 面试(需登录)
.requestMatchers("/interviews/**").authenticated()
// RIASEC 职业兴趣测评(需登录;类型/题目任意登录用户,答卷仅 STUDENT 由 Service 层校验)
.requestMatchers("/assessments/**").authenticated()
// 四维能力测评(需登录;类型任意登录用户,答卷仅 STUDENT 本人由 Service 层校验)
.requestMatchers("/abilities/**").authenticated()
// API 信息入口
.requestMatchers("/").permitAll()
.anyRequest().authenticated()

219
mydreamjob-server/src/main/java/com/mydreamjob/risk/AdminRiskController.java

@ -0,0 +1,219 @@
package com.mydreamjob.risk;
import com.mydreamjob.audit.AuditContext;
import com.mydreamjob.auth.User;
import com.mydreamjob.auth.UserRepository;
import com.mydreamjob.common.api.ApiResponse;
import com.mydreamjob.common.api.PageResponse;
import com.mydreamjob.common.exception.BusinessException;
import com.mydreamjob.common.exception.ErrorCode;
import com.mydreamjob.risk.dto.RiskDistributionResponse;
import com.mydreamjob.risk.dto.RiskEventActionRequest;
import com.mydreamjob.risk.dto.RiskEventResponse;
import com.mydreamjob.risk.dto.RiskOverviewResponse;
import com.mydreamjob.risk.dto.RiskRuleResponse;
import com.mydreamjob.risk.dto.RiskRuleUpdateRequest;
import com.mydreamjob.risk.dto.RiskTrendResponse;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.responses.ApiResponses;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.data.domain.Page;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import java.time.LocalDateTime;
import java.time.format.DateTimeParseException;
import java.util.List;
/**
* 风险概览与运营规则配置(V37)。仅 ADMIN;COMPANY/STUDENT/未登录分别 403/403/401。
*
* <p>身份一律 JWT/SecurityContext,不接受 adminUserId/operatorUserId/userId 参数。
* 风险事件响应为字段白名单,严禁返回 subjectUserId/phone/email/IP/userAgent/details。</p>
*/
@Tag(name = "风险治理", description = "ADMIN 防刷风险概览与运营规则配置")
@RestController
@RequestMapping("/admin/scoring/risk")
@RequiredArgsConstructor
public class AdminRiskController {
private final RiskGovernanceService riskGovernanceService;
private final UserRepository userRepository;
@Operation(summary = "风险概览(ADMIN)")
@ApiResponses({
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "OK"),
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "401", description = "未登录"),
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "403", description = "非 ADMIN(2002/4001)"),
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "400", description = "非法筛选(9906)")
})
@GetMapping("/overview")
public ResponseEntity<ApiResponse<RiskOverviewResponse>> overview(
@RequestParam(required = false) String from,
@RequestParam(required = false) String to,
HttpServletRequest request) {
requireAdmin(request);
return ResponseEntity.ok(ApiResponse.ok(
riskGovernanceService.overview(parse(from), parse(to)), requestId(request)));
}
@Operation(summary = "风险趋势(ADMIN)")
@ApiResponses({
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "OK"),
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "401", description = "未登录"),
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "403", description = "非 ADMIN(2002/4001)"),
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "400", description = "非法 bucket/筛选(9906)")
})
@GetMapping("/trend")
public ResponseEntity<ApiResponse<RiskTrendResponse>> trend(
@RequestParam String bucket,
@RequestParam(required = false) String from,
@RequestParam(required = false) String to,
HttpServletRequest request) {
requireAdmin(request);
return ResponseEntity.ok(ApiResponse.ok(
riskGovernanceService.trend(bucket, parse(from), parse(to)), requestId(request)));
}
@Operation(summary = "风险分布(ADMIN)")
@ApiResponses({
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "OK"),
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "401", description = "未登录"),
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "403", description = "非 ADMIN(2002/4001)"),
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "400", description = "非法筛选(9906)")
})
@GetMapping("/distribution")
public ResponseEntity<ApiResponse<RiskDistributionResponse>> distribution(
@RequestParam(required = false) String from,
@RequestParam(required = false) String to,
HttpServletRequest request) {
requireAdmin(request);
return ResponseEntity.ok(ApiResponse.ok(
riskGovernanceService.distribution(parse(from), parse(to)), requestId(request)));
}
@Operation(summary = "风险事件分页(ADMIN)")
@ApiResponses({
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "OK"),
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "401", description = "未登录"),
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "403", description = "非 ADMIN(2002/4001)"),
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "400", description = "非法筛选/分页(9906/1001)")
})
@GetMapping("/events")
public ResponseEntity<ApiResponse<PageResponse<RiskEventResponse>>> events(
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "20") int size,
@RequestParam(required = false) String status,
@RequestParam(required = false) String severity,
@RequestParam(required = false) String ruleCode,
@RequestParam(required = false) String from,
@RequestParam(required = false) String to,
HttpServletRequest request) {
requireAdmin(request);
Page<RiskEventResponse> result = riskGovernanceService.events(
page, size, status, severity, ruleCode, parse(from), parse(to));
return ResponseEntity.ok(ApiResponse.ok(PageResponse.of(
result.getContent(), result.getTotalElements(), result.getNumber(), result.getSize()),
requestId(request)));
}
@Operation(summary = "风险规则列表(ADMIN)")
@ApiResponses({
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "OK"),
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "401", description = "未登录"),
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "403", description = "非 ADMIN(2002/4001)")
})
@GetMapping("/rules")
public ResponseEntity<ApiResponse<List<RiskRuleResponse>>> rules(HttpServletRequest request) {
requireAdmin(request);
return ResponseEntity.ok(ApiResponse.ok(riskGovernanceService.listRules(), requestId(request)));
}
@Operation(summary = "更新风险规则(ADMIN)")
@ApiResponses({
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "OK"),
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "400", description = "参数非法(1001)"),
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "401", description = "未登录"),
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "403", description = "非 ADMIN(2002/4001)"),
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "404", description = "规则不存在(9901)"),
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "409", description = "并发更新(9903)")
})
@PutMapping("/rules/{ruleCode}")
public ResponseEntity<ApiResponse<RiskRuleResponse>> updateRule(
@PathVariable String ruleCode, @Valid @RequestBody RiskRuleUpdateRequest req,
HttpServletRequest request) {
Long adminUserId = requireAdmin(request);
return ResponseEntity.ok(ApiResponse.ok(riskGovernanceService.updateRule(
ruleCode, req, adminUserId, riskAuditContext(request)), requestId(request)));
}
@Operation(summary = "风险事件人工操作(ADMIN)")
@ApiResponses({
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "OK"),
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "400", description = "参数非法(1001)"),
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "401", description = "未登录"),
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "403", description = "非 ADMIN(2002/4001)"),
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "404", description = "事件不存在(9902)"),
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "409", description = "非法流转/并发(9904/9905)")
})
@PostMapping("/events/{eventId}/actions")
public ResponseEntity<ApiResponse<RiskEventResponse>> actionEvent(
@PathVariable Long eventId, @Valid @RequestBody RiskEventActionRequest req,
HttpServletRequest request) {
Long adminUserId = requireAdmin(request);
return ResponseEntity.ok(ApiResponse.ok(riskGovernanceService.actionEvent(
eventId, req, adminUserId, riskAuditContext(request)), requestId(request)));
}
private Long requireAdmin(HttpServletRequest request) {
Long userId = resolveUserId(request);
User admin = userRepository.findById(userId)
.orElseThrow(() -> new BusinessException(ErrorCode.USER_NOT_FOUND));
boolean isAdmin = admin.getRoles() != null
&& admin.getRoles().stream().anyMatch(r -> r == User.Role.ADMIN);
if (!isAdmin) {
throw new BusinessException(ErrorCode.FORBIDDEN);
}
return userId;
}
private Long resolveUserId(HttpServletRequest request) {
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
if (auth == null || !auth.isAuthenticated()) {
throw new BusinessException(ErrorCode.UNAUTHORIZED);
}
return Long.valueOf(auth.getName());
}
private String requestId(HttpServletRequest request) {
return request.getHeader("X-Request-Id");
}
/** 风险审计上下文:仅保留 requestId,禁止写入 IP/UA/请求体等。 */
private AuditContext riskAuditContext(HttpServletRequest request) {
return AuditContext.builder().requestId(request.getHeader("X-Request-Id")).build();
}
private LocalDateTime parse(String s) {
if (s == null || s.isBlank()) {
return null;
}
try {
return LocalDateTime.parse(s);
} catch (DateTimeParseException e) {
throw new BusinessException(ErrorCode.RISK_INVALID_FILTER);
}
}
}

138
mydreamjob-server/src/main/java/com/mydreamjob/risk/RiskEventDetector.java

@ -0,0 +1,138 @@
package com.mydreamjob.risk;
import com.mydreamjob.audit.AuditContext;
import com.mydreamjob.audit.AuditLog;
import com.mydreamjob.audit.AuditLogRepository;
import com.mydreamjob.audit.AuditService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.data.domain.PageRequest;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Transactional;
import java.time.Clock;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.function.Function;
import java.util.stream.Collectors;
/**
* 风险事件扫描器(V37)。
*
* <p>{@code scanOnce()} 为可直接单测的方法;{@code @Scheduled} 只调用它。扫描基于既有
* {@code audit_logs},仅消费 {@code REPORT_RATE_LIMITED}/{@code BLOCKED_SEND_PROHIBITED} 两个
* 白名单 action,且 user_id 非空。整批成功后才推进 checkpoint;任一处理失败整体回滚。</p>
*
* <p>去刷屏:仅当窗口内命中数恰等于 thresholdCount 时创建事件;阈值之后同窗口的更多命中不再
* 创建新事件。事件插入受唯一键 {@code (rule_config_id, source_audit_log_id)} 与 existsBy 防重保护。</p>
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class RiskEventDetector {
public static final int BATCH_SIZE = 500;
public static final Set<String> ALLOWED_ACTIONS =
Set.of("REPORT_RATE_LIMITED", "BLOCKED_SEND_PROHIBITED");
private static final String AUDIT_DETECTED = "RISK_EVENT_DETECTED";
private final ScoringRuleConfigRepository ruleRepository;
private final ScoringRiskEventRepository eventRepository;
private final ScoringRiskScanCheckpointRepository checkpointRepository;
private final AuditLogRepository auditLogRepository;
private final AuditService auditService;
private final Clock clock;
/**
* 执行一次风险扫描。返回本轮新建的风险事件数。
*/
@Transactional
public int scanOnce() {
ScoringRiskScanCheckpoint checkpoint = checkpointRepository
.findByCheckpointKeyForUpdate(ScoringRiskScanCheckpoint.KEY_RISK_EVENT_DETECTOR)
.orElseGet(this::createCheckpoint);
long lastId = checkpoint.getLastAuditLogId();
List<AuditLog> batch = auditLogRepository
.findByIdAfterOrderByIdAsc(lastId, PageRequest.of(0, BATCH_SIZE));
if (batch.isEmpty()) {
return 0;
}
Map<String, ScoringRuleConfig> ruleByAction = ruleRepository.findAllByEnabledTrue().stream()
.collect(Collectors.toMap(ScoringRuleConfig::getSourceAction, Function.identity()));
LocalDateTime now = LocalDateTime.now(clock);
long maxId = lastId;
int created = 0;
for (AuditLog a : batch) {
if (a.getId() > maxId) {
maxId = a.getId();
}
// 仅处理带用户主体且 action 在规则白名单内的审计
if (a.getUserId() == null || a.getCreatedAt() == null) {
continue;
}
ScoringRuleConfig rule = ruleByAction.get(a.getAction());
if (rule == null) {
// 规则已禁用或非白名单 action:审计照常越过 checkpoint;重新启用不回放历史
continue;
}
LocalDateTime winStart = a.getCreatedAt().minusMinutes(rule.getWindowMinutes());
long occurrence = auditLogRepository.countByUserIdAndActionAndCreatedAtBetween(
a.getUserId(), a.getAction(), winStart, a.getCreatedAt());
// 仅恰好等于阈值才生成事件(防止阈值后每次操作刷屏)
if (occurrence == rule.getThresholdCount().longValue()
&& !eventRepository.existsByRuleIdAndSourceAuditLogId(rule.getId(), a.getId())) {
ScoringRiskEvent ev = ScoringRiskEvent.builder()
.rule(rule)
.subjectUserId(a.getUserId())
.sourceAuditLogId(a.getId())
.sourceAction(rule.getSourceAction())
.severity(rule.getSeverity())
.windowStartedAt(winStart)
.windowEndedAt(a.getCreatedAt())
.occurrenceCount((int) occurrence)
.status(ScoringRiskEvent.STATUS_OPEN)
.detectedAt(now)
.build();
// 使用 saveAndFlush 的返回对象,确保事件 ID 已生成(IDENTITY 生成策略下必须用返回值,
// 否则 ev.getId() 可能仍为 null,导致检测审计 entity_id/details.riskEventId 无法定位)。
ScoringRiskEvent savedEvent = eventRepository.saveAndFlush(ev);
created++;
auditService.log(null, AUDIT_DETECTED, "SCORING_RISK_EVENT", savedEvent.getId(),
detectedAuditDetails(savedEvent), AuditContext.builder().build());
}
}
checkpoint.setLastAuditLogId(maxId);
checkpointRepository.save(checkpoint);
return created;
}
private ScoringRiskScanCheckpoint createCheckpoint() {
ScoringRiskScanCheckpoint cp = ScoringRiskScanCheckpoint.builder()
.checkpointKey(ScoringRiskScanCheckpoint.KEY_RISK_EVENT_DETECTOR)
.lastAuditLogId(0L)
.build();
return checkpointRepository.saveAndFlush(cp);
}
/**
* RISK_EVENT_DETECTED 审计 details(JSON 形式规避 AuditService 对 {@code code=} 的脱敏误伤,
* 只记录非敏感追踪字段,不含用户身份/原始审计 details/IP/UA/凭据)。
*/
private static String detectedAuditDetails(ScoringRiskEvent ev) {
return "{\"riskEventId\":" + ev.getId()
+ ",\"ruleCode\":\"" + ev.getRule().getRuleCode() + "\""
+ ",\"severity\":\"" + ev.getSeverity() + "\""
+ ",\"occurrenceCount\":" + ev.getOccurrenceCount() + "}";
}
}

32
mydreamjob-server/src/main/java/com/mydreamjob/risk/RiskEventScanScheduler.java

@ -0,0 +1,32 @@
package com.mydreamjob.risk;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
/**
* 风险扫描定时调度(V37)。{@code @Scheduled} 仅调用 {@link RiskEventDetector#scanOnce()},
* 默认 60 秒固定延迟,可配置 {@code app.risk.scan.fixed-delay-ms};测试环境关闭自动扫描。
*/
@Slf4j
@Component
@RequiredArgsConstructor
@ConditionalOnProperty(name = "app.risk.scan.enabled", havingValue = "true", matchIfMissing = true)
public class RiskEventScanScheduler {
private final RiskEventDetector riskEventDetector;
@Scheduled(fixedDelayString = "${app.risk.scan.fixed-delay-ms:60000}")
public void scan() {
try {
int count = riskEventDetector.scanOnce();
if (count > 0) {
log.info("Risk scan produced {} event(s)", count);
}
} catch (Exception e) {
log.error("Risk scan failed: {}", e.getMessage(), e);
}
}
}

273
mydreamjob-server/src/main/java/com/mydreamjob/risk/RiskGovernanceService.java

@ -0,0 +1,273 @@
package com.mydreamjob.risk;
import com.mydreamjob.audit.AuditContext;
import com.mydreamjob.audit.AuditService;
import com.mydreamjob.common.exception.BusinessException;
import com.mydreamjob.common.exception.ErrorCode;
import com.mydreamjob.risk.dto.RiskDistributionResponse;
import com.mydreamjob.risk.dto.RiskEventActionRequest;
import com.mydreamjob.risk.dto.RiskEventResponse;
import com.mydreamjob.risk.dto.RiskOverviewResponse;
import com.mydreamjob.risk.dto.RiskRuleResponse;
import com.mydreamjob.risk.dto.RiskRuleUpdateRequest;
import com.mydreamjob.risk.dto.RiskTrendResponse;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.dao.OptimisticLockingFailureException;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.Clock;
import java.time.Duration;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.List;
import java.util.Set;
import java.util.stream.Collectors;
/**
* 风险治理服务(V37)。仅 ADMIN 使用;身份一律 JWT/SecurityContext。
*
* <p>概览/趋势/分布/事件全部使用 SQL GROUP BY 或 JOIN FETCH 投影,消除 N+1;事件与规则响应
* 均为字段白名单,绝不返回 subjectUserId/phone/email/IP/userAgent/details 等敏感字段。</p>
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class RiskGovernanceService {
private static final int MAX_PAGE_SIZE = 50;
private static final int MAX_RANGE_DAYS = 90;
private static final Set<String> SEVERITIES = Set.of("LOW", "MEDIUM", "HIGH");
private static final Set<String> STATUSES = Set.of("OPEN", "ACKNOWLEDGED", "DISMISSED");
private static final Set<String> BUCKETS = Set.of("HOUR", "DAY");
private static final Set<String> REASONS =
Set.of("MONITORING", "CONFIRMED_FALSE_POSITIVE", "OUT_OF_BAND_ESCALATED");
private static final String AUDIT_RULE_UPDATED = "RISK_RULE_UPDATED";
private static final String AUDIT_EVENT_ACTIONED = "RISK_EVENT_ACTIONED";
private final ScoringRuleConfigRepository ruleRepository;
private final ScoringRiskEventRepository eventRepository;
private final ScoringRiskActionRepository actionRepository;
private final AuditService auditService;
private final Clock clock;
// ─────────────────────── 概览 / 趋势 / 分布 ───────────────────────
@Transactional(readOnly = true)
public RiskOverviewResponse overview(LocalDateTime from, LocalDateTime to) {
LocalDateTime[] range = resolveRange(from, to);
from = range[0];
to = range[1];
List<RiskOverviewResponse.RuleCount> byRule = eventRepository
.countGroupByRuleCode(ScoringRiskEvent.STATUS_OPEN, from, to).stream()
.map(r -> RiskOverviewResponse.RuleCount.builder()
.ruleCode((String) r[0]).count(((Number) r[1]).longValue()).build())
.sorted(java.util.Comparator.comparing(RiskOverviewResponse.RuleCount::getRuleCode))
.collect(Collectors.toList());
return RiskOverviewResponse.builder()
.openCount(eventRepository.countByStatusAndDetectedAtBetween(
ScoringRiskEvent.STATUS_OPEN, from, to))
.bySeverity(eventRepository.countGroupBySeverity(
ScoringRiskEvent.STATUS_OPEN, from, to).stream()
.map(r -> RiskOverviewResponse.SeverityCount.builder()
.severity((String) r[0]).count(((Number) r[1]).longValue()).build())
.sorted(java.util.Comparator.comparing(RiskOverviewResponse.SeverityCount::getSeverity))
.collect(Collectors.toList()))
.byRule(byRule)
.lastDetectedAt(eventRepository.findMaxDetectedAtByStatusBetween(
ScoringRiskEvent.STATUS_OPEN, from, to))
.build();
}
@Transactional(readOnly = true)
public RiskTrendResponse trend(String bucket, LocalDateTime from, LocalDateTime to) {
LocalDateTime[] range = resolveRange(from, to);
from = range[0];
to = range[1];
if (!BUCKETS.contains(bucket)) {
throw new BusinessException(ErrorCode.RISK_INVALID_FILTER);
}
List<Object[]> rows = "HOUR".equals(bucket)
? eventRepository.trendByHour(from, to)
: eventRepository.trendByDay(from, to);
List<RiskTrendResponse.TrendPoint> items = new ArrayList<>(rows.size());
for (Object[] r : rows) {
LocalDateTime time;
if ("HOUR".equals(bucket)) {
time = LocalDateTime.of((int) r[0], (int) r[1], (int) r[2], (int) r[3], 0);
} else {
time = LocalDateTime.of((int) r[0], (int) r[1], (int) r[2], 0, 0);
}
items.add(RiskTrendResponse.TrendPoint.builder()
.time(time).count(((Number) r[r.length - 1]).longValue()).build());
}
return RiskTrendResponse.builder().bucket(bucket).items(items).build();
}
@Transactional(readOnly = true)
public RiskDistributionResponse distribution(LocalDateTime from, LocalDateTime to) {
LocalDateTime[] range = resolveRange(from, to);
from = range[0];
to = range[1];
List<RiskDistributionResponse.DistributionRow> items = eventRepository
.distribution(from, to).stream()
.map(r -> RiskDistributionResponse.DistributionRow.builder()
.ruleCode((String) r[0]).severity((String) r[1])
.count(((Number) r[2]).longValue()).build())
.collect(Collectors.toList());
return RiskDistributionResponse.builder().items(items).build();
}
// ─────────────────────── 事件列表 ───────────────────────
@Transactional(readOnly = true)
public Page<RiskEventResponse> events(int page, int size, String status, String severity,
String ruleCode, LocalDateTime from, LocalDateTime to) {
LocalDateTime[] range = resolveRange(from, to);
from = range[0];
to = range[1];
if (page < 0 || size < 1 || size > MAX_PAGE_SIZE) {
throw new BusinessException(ErrorCode.BAD_REQUEST);
}
if (status != null && !STATUSES.contains(status)) {
throw new BusinessException(ErrorCode.RISK_INVALID_FILTER);
}
if (severity != null && !SEVERITIES.contains(severity)) {
throw new BusinessException(ErrorCode.RISK_INVALID_FILTER);
}
return eventRepository
.search(status, severity, ruleCode, from, to, PageRequest.of(page, size))
.map(this::toEventResponse);
}
// ─────────────────────── 规则查询 / 更新 ───────────────────────
@Transactional(readOnly = true)
public List<RiskRuleResponse> listRules() {
return ruleRepository.findAllByOrderByRuleCodeAsc().stream().map(this::toRuleResponse)
.collect(Collectors.toList());
}
@Transactional
public RiskRuleResponse updateRule(String ruleCode, RiskRuleUpdateRequest req, Long operatorUserId,
AuditContext ctx) {
ScoringRuleConfig rule = ruleRepository.findByRuleCode(ruleCode)
.orElseThrow(() -> new BusinessException(ErrorCode.RISK_RULE_NOT_FOUND));
if (!rule.getVersion().equals(req.getExpectedVersion())) {
throw new BusinessException(ErrorCode.RISK_RULE_CONCURRENT_UPDATE);
}
rule.setEnabled(req.getEnabled());
rule.setThresholdCount(req.getThresholdCount());
rule.setWindowMinutes(req.getWindowMinutes());
rule.setSeverity(req.getSeverity());
rule.setVersion(req.getExpectedVersion());
try {
rule = ruleRepository.saveAndFlush(rule);
} catch (OptimisticLockingFailureException e) {
throw new BusinessException(ErrorCode.RISK_RULE_CONCURRENT_UPDATE);
}
auditService.log(operatorUserId, AUDIT_RULE_UPDATED, "SCORING_RULE_CONFIG", rule.getId(),
ruleUpdateAuditDetails(rule), ctx);
return toRuleResponse(rule);
}
// ─────────────────────── 事件人工操作 ───────────────────────
@Transactional
public RiskEventResponse actionEvent(Long eventId, RiskEventActionRequest req, Long operatorUserId,
AuditContext ctx) {
if (!isValidAction(req.getAction())) {
throw new BusinessException(ErrorCode.RISK_INVALID_ACTION);
}
if (!REASONS.contains(req.getReasonCode())) {
throw new BusinessException(ErrorCode.RISK_INVALID_ACTION);
}
ScoringRiskEvent ev = eventRepository.findById(eventId)
.orElseThrow(() -> new BusinessException(ErrorCode.RISK_EVENT_NOT_FOUND));
if (!ev.getVersion().equals(req.getExpectedVersion())) {
throw new BusinessException(ErrorCode.RISK_EVENT_CONCURRENT_UPDATE);
}
String to = ScoringRiskEvent.transition(ev.getStatus(), req.getAction());
if (to == null) {
throw new BusinessException(ErrorCode.RISK_INVALID_ACTION);
}
ev.setStatus(to);
ev.setVersion(req.getExpectedVersion());
ScoringRiskAction actionRow = ScoringRiskAction.builder()
.riskEventId(ev.getId()).operatorUserId(operatorUserId)
.action(req.getAction()).reasonCode(req.getReasonCode()).build();
try {
ev = eventRepository.saveAndFlush(ev);
actionRepository.save(actionRow);
} catch (OptimisticLockingFailureException e) {
throw new BusinessException(ErrorCode.RISK_EVENT_CONCURRENT_UPDATE);
}
auditService.log(operatorUserId, AUDIT_EVENT_ACTIONED, "SCORING_RISK_EVENT", ev.getId(),
eventActionAuditDetails(ev, req), ctx);
return toEventResponse(ev);
}
private boolean isValidAction(String action) {
return "ACKNOWLEDGE".equals(action) || "DISMISS".equals(action);
}
// ─────────────────────── 工具 ───────────────────────
/** 解析时间范围:默认近 7 天;任一缺失则用默认边界;校验 from<=to 与最大 90 天。 */
private LocalDateTime[] resolveRange(LocalDateTime from, LocalDateTime to) {
LocalDateTime now = LocalDateTime.now(clock);
LocalDateTime f = from != null ? from : now.minusDays(7);
LocalDateTime t = to != null ? to : now;
if (from != null && to == null) {
t = now;
}
if (from == null && to != null) {
f = now.minusDays(7);
}
if (f.isAfter(t)) {
throw new BusinessException(ErrorCode.RISK_INVALID_FILTER);
}
if (Duration.between(f, t).toDays() > MAX_RANGE_DAYS) {
throw new BusinessException(ErrorCode.RISK_INVALID_FILTER);
}
return new LocalDateTime[]{f, t};
}
private RiskRuleResponse toRuleResponse(ScoringRuleConfig rule) {
return RiskRuleResponse.builder()
.ruleCode(rule.getRuleCode()).sourceAction(rule.getSourceAction())
.thresholdCount(rule.getThresholdCount()).windowMinutes(rule.getWindowMinutes())
.severity(rule.getSeverity()).enabled(rule.getEnabled()).version(rule.getVersion())
.build();
}
private RiskEventResponse toEventResponse(ScoringRiskEvent ev) {
return RiskEventResponse.builder()
.eventId(ev.getId()).ruleCode(ev.getRule().getRuleCode())
.sourceAction(ev.getSourceAction()).severity(ev.getSeverity())
.status(ev.getStatus()).occurrenceCount(ev.getOccurrenceCount())
.windowStartedAt(ev.getWindowStartedAt()).windowEndedAt(ev.getWindowEndedAt())
.detectedAt(ev.getDetectedAt()).version(ev.getVersion())
.build();
}
private static String ruleUpdateAuditDetails(ScoringRuleConfig rule) {
return "{\"ruleCode\":\"" + rule.getRuleCode() + "\""
+ ",\"enabled\":" + rule.getEnabled()
+ ",\"thresholdCount\":" + rule.getThresholdCount()
+ ",\"windowMinutes\":" + rule.getWindowMinutes()
+ ",\"severity\":\"" + rule.getSeverity() + "\""
+ ",\"version\":" + rule.getVersion() + "}";
}
private static String eventActionAuditDetails(ScoringRiskEvent ev, RiskEventActionRequest req) {
return "{\"riskEventId\":" + ev.getId()
+ ",\"action\":\"" + req.getAction() + "\""
+ ",\"reasonCode\":\"" + req.getReasonCode() + "\""
+ ",\"version\":" + ev.getVersion() + "}";
}
}

61
mydreamjob-server/src/main/java/com/mydreamjob/risk/ScoringRiskAction.java

@ -0,0 +1,61 @@
package com.mydreamjob.risk;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.EntityListeners;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import org.springframework.data.annotation.CreatedDate;
import org.springframework.data.jpa.domain.support.AuditingEntityListener;
import java.time.LocalDateTime;
/**
* 风险事件人工操作记录(V37)。
*
* <p>{@code operatorUserId} 仅内部存储,响应不返回。仅支持受控 {code reasonCode}:
* MONITORING / CONFIRMED_FALSE_POSITIVE / OUT_OF_BAND_ESCALATED;不提供自由文本备注,
* 避免运营备注成为新的个人信息载体。</p>
*/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
@Entity
@Table(name = "scoring_risk_actions")
@EntityListeners(AuditingEntityListener.class)
public class ScoringRiskAction {
public static final String ACTION_ACKNOWLEDGE = "ACKNOWLEDGE";
public static final String ACTION_DISMISS = "DISMISS";
public static final String REASON_MONITORING = "MONITORING";
public static final String REASON_CONFIRMED_FALSE_POSITIVE = "CONFIRMED_FALSE_POSITIVE";
public static final String REASON_OUT_OF_BAND_ESCALATED = "OUT_OF_BAND_ESCALATED";
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "risk_event_id", nullable = false)
private Long riskEventId;
@Column(name = "operator_user_id", nullable = false)
private Long operatorUserId;
@Column(nullable = false, length = 20)
private String action;
@Column(name = "reason_code", nullable = false, length = 40)
private String reasonCode;
@CreatedDate
@Column(name = "created_at", nullable = false, updatable = false)
private LocalDateTime createdAt;
}

6
mydreamjob-server/src/main/java/com/mydreamjob/risk/ScoringRiskActionRepository.java

@ -0,0 +1,6 @@
package com.mydreamjob.risk;
import org.springframework.data.jpa.repository.JpaRepository;
public interface ScoringRiskActionRepository extends JpaRepository<ScoringRiskAction, Long> {
}

106
mydreamjob-server/src/main/java/com/mydreamjob/risk/ScoringRiskEvent.java

@ -0,0 +1,106 @@
package com.mydreamjob.risk;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.EntityListeners;
import jakarta.persistence.FetchType;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.Table;
import jakarta.persistence.Version;
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;
/**
* 风险事件(V37)。
*
* <p>{@code subjectUserId} 仅内部关联用户,任何 API 响应均不得返回;对外只暴露事件 ID 与
* 规则快照字段(ruleCode/sourceAction/severity)。状态机:OPEN→ACKNOWLEDGED、OPEN→DISMISSED、
* ACKNOWLEDGED→DISMISSED,DISMISSED 为终态。</p>
*/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
@Entity
@Table(name = "scoring_risk_events")
@EntityListeners(AuditingEntityListener.class)
public class ScoringRiskEvent {
public static final String STATUS_OPEN = "OPEN";
public static final String STATUS_ACKNOWLEDGED = "ACKNOWLEDGED";
public static final String STATUS_DISMISSED = "DISMISSED";
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "rule_config_id", nullable = false)
private ScoringRuleConfig rule;
@Column(name = "subject_user_id", nullable = false)
private Long subjectUserId;
@Column(name = "source_audit_log_id", nullable = false)
private Long sourceAuditLogId;
@Column(name = "source_action", nullable = false, length = 60)
private String sourceAction;
@Column(nullable = false, length = 10)
private String severity;
@Column(name = "window_started_at", nullable = false)
private LocalDateTime windowStartedAt;
@Column(name = "window_ended_at", nullable = false)
private LocalDateTime windowEndedAt;
@Column(name = "occurrence_count", nullable = false)
private Integer occurrenceCount;
@Column(nullable = false, length = 20)
@Builder.Default
private String status = STATUS_OPEN;
@Column(name = "detected_at", nullable = false)
private LocalDateTime detectedAt;
@Version
@Column(nullable = false)
@Builder.Default
private Long version = 0L;
@CreatedDate
@Column(name = "created_at", nullable = false, updatable = false)
private LocalDateTime createdAt;
@LastModifiedDate
@Column(name = "updated_at", nullable = false)
private LocalDateTime updatedAt;
/** 依据当前状态与目标 action 计算目标状态;非法返回 null。 */
public static String transition(String from, String action) {
if (STATUS_DISMISSED.equals(from)) {
return null;
}
if ("ACKNOWLEDGE".equals(action)) {
return STATUS_OPEN.equals(from) ? STATUS_ACKNOWLEDGED : null;
}
if ("DISMISS".equals(action)) {
return STATUS_OPEN.equals(from) || STATUS_ACKNOWLEDGED.equals(from) ? STATUS_DISMISSED : null;
}
return null;
}
}

73
mydreamjob-server/src/main/java/com/mydreamjob/risk/ScoringRiskEventRepository.java

@ -0,0 +1,73 @@
package com.mydreamjob.risk;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import java.time.LocalDateTime;
import java.util.List;
public interface ScoringRiskEventRepository extends JpaRepository<ScoringRiskEvent, Long> {
// 扫描幂等:同一(rule, source_audit_log_id) 已存在视为已处理
boolean existsByRuleIdAndSourceAuditLogId(Long ruleId, Long sourceAuditLogId);
long countByStatusAndDetectedAtBetween(String status, LocalDateTime from, LocalDateTime to);
@Query("SELECT e.severity, COUNT(e) FROM ScoringRiskEvent e "
+ "WHERE e.status = :status AND e.detectedAt >= :from AND e.detectedAt <= :to "
+ "GROUP BY e.severity ORDER BY e.severity")
List<Object[]> countGroupBySeverity(@Param("status") String status,
@Param("from") LocalDateTime from,
@Param("to") LocalDateTime to);
@Query("SELECT e.rule.ruleCode, COUNT(e) FROM ScoringRiskEvent e "
+ "WHERE e.status = :status AND e.detectedAt >= :from AND e.detectedAt <= :to "
+ "GROUP BY e.rule.ruleCode ORDER BY e.rule.ruleCode")
List<Object[]> countGroupByRuleCode(@Param("status") String status,
@Param("from") LocalDateTime from,
@Param("to") LocalDateTime to);
@Query("SELECT MAX(e.detectedAt) FROM ScoringRiskEvent e "
+ "WHERE e.status = :status AND e.detectedAt >= :from AND e.detectedAt <= :to")
LocalDateTime findMaxDetectedAtByStatusBetween(@Param("status") String status,
@Param("from") LocalDateTime from,
@Param("to") LocalDateTime to);
@Query("SELECT e.rule.ruleCode, e.severity, COUNT(e) FROM ScoringRiskEvent e "
+ "WHERE e.detectedAt >= :from AND e.detectedAt <= :to "
+ "GROUP BY e.rule.ruleCode, e.severity ORDER BY e.rule.ruleCode, e.severity")
List<Object[]> distribution(@Param("from") LocalDateTime from, @Param("to") LocalDateTime to);
// 按天/小时聚合趋势(SQL GROUP BY projection,避免逐事件取数)
@Query("SELECT FUNCTION('YEAR', e.detectedAt), FUNCTION('MONTH', e.detectedAt), "
+ "FUNCTION('DAY', e.detectedAt), COUNT(e) FROM ScoringRiskEvent e "
+ "WHERE e.detectedAt >= :from AND e.detectedAt <= :to "
+ "GROUP BY FUNCTION('YEAR', e.detectedAt), FUNCTION('MONTH', e.detectedAt), FUNCTION('DAY', e.detectedAt) "
+ "ORDER BY FUNCTION('YEAR', e.detectedAt), FUNCTION('MONTH', e.detectedAt), FUNCTION('DAY', e.detectedAt)")
List<Object[]> trendByDay(@Param("from") LocalDateTime from, @Param("to") LocalDateTime to);
@Query("SELECT FUNCTION('YEAR', e.detectedAt), FUNCTION('MONTH', e.detectedAt), "
+ "FUNCTION('DAY', e.detectedAt), FUNCTION('HOUR', e.detectedAt), COUNT(e) FROM ScoringRiskEvent e "
+ "WHERE e.detectedAt >= :from AND e.detectedAt <= :to "
+ "GROUP BY FUNCTION('YEAR', e.detectedAt), FUNCTION('MONTH', e.detectedAt), "
+ "FUNCTION('DAY', e.detectedAt), FUNCTION('HOUR', e.detectedAt) "
+ "ORDER BY FUNCTION('YEAR', e.detectedAt), FUNCTION('MONTH', e.detectedAt), "
+ "FUNCTION('DAY', e.detectedAt), FUNCTION('HOUR', e.detectedAt)")
List<Object[]> trendByHour(@Param("from") LocalDateTime from, @Param("to") LocalDateTime to);
@Query("SELECT e FROM ScoringRiskEvent e JOIN FETCH e.rule r "
+ "WHERE (:status IS NULL OR e.status = :status) "
+ "AND (:severity IS NULL OR e.severity = :severity) "
+ "AND (:ruleCode IS NULL OR r.ruleCode = :ruleCode) "
+ "AND e.detectedAt >= :from AND e.detectedAt <= :to "
+ "ORDER BY e.detectedAt DESC, e.id DESC")
Page<ScoringRiskEvent> search(@Param("status") String status,
@Param("severity") String severity,
@Param("ruleCode") String ruleCode,
@Param("from") LocalDateTime from,
@Param("to") LocalDateTime to,
Pageable pageable);
}

54
mydreamjob-server/src/main/java/com/mydreamjob/risk/ScoringRiskScanCheckpoint.java

@ -0,0 +1,54 @@
package com.mydreamjob.risk;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.EntityListeners;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import jakarta.persistence.Version;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import org.springframework.data.annotation.LastModifiedDate;
import org.springframework.data.jpa.domain.support.AuditingEntityListener;
import java.time.LocalDateTime;
/**
* 风险扫描游标(V37)。只维护一条 {@code checkpoint_key=RISK_EVENT_DETECTOR},持久化已成功消费的
* 最大 {@code audit_logs.id},解决应用重启或扫描耗时导致的事件遗漏。
*/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
@Entity
@Table(name = "scoring_risk_scan_checkpoints")
@EntityListeners(AuditingEntityListener.class)
public class ScoringRiskScanCheckpoint {
public static final String KEY_RISK_EVENT_DETECTOR = "RISK_EVENT_DETECTOR";
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "checkpoint_key", nullable = false, length = 40)
private String checkpointKey;
@Column(name = "last_audit_log_id", nullable = false)
@Builder.Default
private Long lastAuditLogId = 0L;
@Version
@Column(nullable = false)
@Builder.Default
private Long version = 0L;
@LastModifiedDate
@Column(name = "updated_at", nullable = false)
private LocalDateTime updatedAt;
}

19
mydreamjob-server/src/main/java/com/mydreamjob/risk/ScoringRiskScanCheckpointRepository.java

@ -0,0 +1,19 @@
package com.mydreamjob.risk;
import jakarta.persistence.LockModeType;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Lock;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import java.util.Optional;
public interface ScoringRiskScanCheckpointRepository extends JpaRepository<ScoringRiskScanCheckpoint, Long> {
Optional<ScoringRiskScanCheckpoint> findByCheckpointKey(String checkpointKey);
/** 悲观写锁:同一时间只允许一个扫描事务消费审计增量,避免并发扫描重复建事件。 */
@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("SELECT c FROM ScoringRiskScanCheckpoint c WHERE c.checkpointKey = :key")
Optional<ScoringRiskScanCheckpoint> findByCheckpointKeyForUpdate(@Param("key") String key);
}

78
mydreamjob-server/src/main/java/com/mydreamjob/risk/ScoringRuleConfig.java

@ -0,0 +1,78 @@
package com.mydreamjob.risk;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.EntityListeners;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import jakarta.persistence.Version;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import org.hibernate.annotations.JdbcTypeCode;
import org.hibernate.type.SqlTypes;
import org.springframework.data.annotation.CreatedDate;
import org.springframework.data.annotation.LastModifiedDate;
import org.springframework.data.jpa.domain.support.AuditingEntityListener;
import java.time.LocalDateTime;
/**
* 风险规则配置(V37)。
*
* <p>{@code source_action} 仅允许 {@code REPORT_RATE_LIMITED}/{@code BLOCKED_SEND_PROHIBITED};
* 规则更新只影响后续扫描,不回溯、不删除既有事件。</p>
*/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
@Entity
@Table(name = "scoring_rule_configs")
@EntityListeners(AuditingEntityListener.class)
public class ScoringRuleConfig {
public static final String SEVERITY_LOW = "LOW";
public static final String SEVERITY_MEDIUM = "MEDIUM";
public static final String SEVERITY_HIGH = "HIGH";
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "rule_code", nullable = false, length = 60)
private String ruleCode;
@Column(name = "source_action", nullable = false, length = 60)
private String sourceAction;
@Column(name = "threshold_count", nullable = false)
private Integer thresholdCount;
@Column(name = "window_minutes", nullable = false)
private Integer windowMinutes;
@Column(nullable = false, length = 10)
private String severity;
@Column(nullable = false)
@JdbcTypeCode(SqlTypes.TINYINT)
@Builder.Default
private Boolean enabled = true;
@Version
@Column(nullable = false)
@Builder.Default
private Long version = 0L;
@CreatedDate
@Column(name = "created_at", nullable = false, updatable = false)
private LocalDateTime createdAt;
@LastModifiedDate
@Column(name = "updated_at", nullable = false)
private LocalDateTime updatedAt;
}

15
mydreamjob-server/src/main/java/com/mydreamjob/risk/ScoringRuleConfigRepository.java

@ -0,0 +1,15 @@
package com.mydreamjob.risk;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.List;
import java.util.Optional;
public interface ScoringRuleConfigRepository extends JpaRepository<ScoringRuleConfig, Long> {
Optional<ScoringRuleConfig> findByRuleCode(String ruleCode);
List<ScoringRuleConfig> findAllByEnabledTrue();
List<ScoringRuleConfig> findAllByOrderByRuleCodeAsc();
}

23
mydreamjob-server/src/main/java/com/mydreamjob/risk/dto/RiskDistributionResponse.java

@ -0,0 +1,23 @@
package com.mydreamjob.risk.dto;
import lombok.Builder;
import lombok.Data;
import java.util.List;
/**
* 风险分布(V37):按规则与风险等级聚合。
*/
@Data
@Builder
public class RiskDistributionResponse {
private List<DistributionRow> items;
@Data
@Builder
public static class DistributionRow {
private String ruleCode;
private String severity;
private Long count;
}
}

22
mydreamjob-server/src/main/java/com/mydreamjob/risk/dto/RiskEventActionRequest.java

@ -0,0 +1,22 @@
package com.mydreamjob.risk.dto;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import lombok.Data;
/**
* 风险事件人工操作请求(V37)。{@code action} 仅 ACKNOWLEDGE/DISMISS,
* {@code reasonCode} 仅 MONITORING/CONFIRMED_FALSE_POSITIVE/OUT_OF_BAND_ESCALATED。
*/
@Data
public class RiskEventActionRequest {
@NotBlank
private String action;
@NotBlank
private String reasonCode;
@NotNull
private Long expectedVersion;
}

24
mydreamjob-server/src/main/java/com/mydreamjob/risk/dto/RiskEventResponse.java

@ -0,0 +1,24 @@
package com.mydreamjob.risk.dto;
import lombok.Builder;
import lombok.Data;
import java.time.LocalDateTime;
/**
* 风险事件响应(V37)白名单。严禁返回 subjectUserId/phone/email/ipAddress/userAgent/details/requestId/token。
*/
@Data
@Builder
public class RiskEventResponse {
private Long eventId;
private String ruleCode;
private String sourceAction;
private String severity;
private String status;
private Integer occurrenceCount;
private LocalDateTime windowStartedAt;
private LocalDateTime windowEndedAt;
private LocalDateTime detectedAt;
private Long version;
}

33
mydreamjob-server/src/main/java/com/mydreamjob/risk/dto/RiskOverviewResponse.java

@ -0,0 +1,33 @@
package com.mydreamjob.risk.dto;
import lombok.Builder;
import lombok.Data;
import java.time.LocalDateTime;
import java.util.List;
/**
* 风险概览(V37):时间范围内 OPEN 总数、等级分布、规则分布、最近检测时间。
*/
@Data
@Builder
public class RiskOverviewResponse {
private Long openCount;
private List<SeverityCount> bySeverity;
private List<RuleCount> byRule;
private LocalDateTime lastDetectedAt;
@Data
@Builder
public static class SeverityCount {
private String severity;
private Long count;
}
@Data
@Builder
public static class RuleCount {
private String ruleCode;
private Long count;
}
}

19
mydreamjob-server/src/main/java/com/mydreamjob/risk/dto/RiskRuleResponse.java

@ -0,0 +1,19 @@
package com.mydreamjob.risk.dto;
import lombok.Builder;
import lombok.Data;
/**
* 规则响应(V37)。
*/
@Data
@Builder
public class RiskRuleResponse {
private String ruleCode;
private String sourceAction;
private Integer thresholdCount;
private Integer windowMinutes;
private String severity;
private Boolean enabled;
private Long version;
}

32
mydreamjob-server/src/main/java/com/mydreamjob/risk/dto/RiskRuleUpdateRequest.java

@ -0,0 +1,32 @@
package com.mydreamjob.risk.dto;
import jakarta.validation.constraints.Max;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotNull;
import lombok.Data;
/**
* 规则更新请求(V37)。不允许修改 {@code ruleCode}/{@code sourceAction}。
*/
@Data
public class RiskRuleUpdateRequest {
@NotNull
private Boolean enabled;
@NotNull
@Min(1)
@Max(100)
private Integer thresholdCount;
@NotNull
@Min(1)
@Max(1440)
private Integer windowMinutes;
@NotNull
private String severity;
@NotNull
private Long expectedVersion;
}

24
mydreamjob-server/src/main/java/com/mydreamjob/risk/dto/RiskTrendResponse.java

@ -0,0 +1,24 @@
package com.mydreamjob.risk.dto;
import lombok.Builder;
import lombok.Data;
import java.time.LocalDateTime;
import java.util.List;
/**
* 风险趋势(V37):bucket 仅 HOUR/DAY,SQL GROUP BY 聚合。
*/
@Data
@Builder
public class RiskTrendResponse {
private String bucket;
private List<TrendPoint> items;
@Data
@Builder
public static class TrendPoint {
private LocalDateTime time;
private Long count;
}
}

271
mydreamjob-server/src/main/resources/db/migration/V34__riasec_assessment.sql

@ -0,0 +1,271 @@
-- Flyway V34: RIASEC 职业兴趣测评模块(功能版本 V35)
--
-- 基于 RIASEC 理论的自研题库,仅供职业兴趣探索参考,不构成心理或职业诊断。
-- 新增 6 张表:测评定义、题目、答卷主记录、答卷抽中题目、答案、结果。
-- 每次答卷由后端从六个维度中各随机抽取 5 道,共 30 道。
-- dimension_scores_text / type_ranking_text 使用 TEXT 保存 JSON 字符串,不使用数据库 JSON 列,
-- 以保持 MySQL 8 与 H2 MODE=MySQL 兼容。
-- 迁移由 Flyway 精确执行一次,不会重复插入。
-- 1) 测评定义
CREATE TABLE assessment_definitions (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
type_code VARCHAR(40) NOT NULL,
display_name VARCHAR(100) NOT NULL,
description VARCHAR(1000) NULL,
instructions TEXT NULL,
status VARCHAR(20) NOT NULL DEFAULT 'ACTIVE',
scale_type VARCHAR(20) NOT NULL,
question_count INT NOT NULL,
attempt_question_count INT NOT NULL,
min_per_dimension INT NOT NULL,
question_set_version VARCHAR(40) NOT NULL,
rules_version VARCHAR(40) NOT NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
ON UPDATE CURRENT_TIMESTAMP,
CONSTRAINT uk_assessment_definitions_type UNIQUE (type_code)
) ENGINE=InnoDB
DEFAULT CHARSET=utf8mb4
COLLATE=utf8mb4_unicode_ci;
-- 2) 题目(72 道,六维各 12)
CREATE TABLE assessment_questions (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
question_code VARCHAR(20) NOT NULL,
definition_id BIGINT NOT NULL,
position INT NOT NULL,
dimension VARCHAR(1) NOT NULL,
stem VARCHAR(2000) NOT NULL,
scale_type VARCHAR(20) NOT NULL,
question_version VARCHAR(40) NOT NULL,
source VARCHAR(40) NOT NULL,
reverse_scored TINYINT NOT NULL DEFAULT 0,
active TINYINT NOT NULL DEFAULT 1,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
ON UPDATE CURRENT_TIMESTAMP,
CONSTRAINT uk_assessment_questions_code UNIQUE (definition_id, question_code),
CONSTRAINT uk_assessment_questions_position UNIQUE (definition_id, position),
CONSTRAINT fk_assessment_questions_definition
FOREIGN KEY (definition_id)
REFERENCES assessment_definitions(id),
CONSTRAINT chk_assessment_questions_dimension
CHECK (dimension IN ('R', 'I', 'A', 'S', 'E', 'C')),
CONSTRAINT chk_assessment_questions_reverse
CHECK (reverse_scored IN (0, 1))
) ENGINE=InnoDB
DEFAULT CHARSET=utf8mb4
COLLATE=utf8mb4_unicode_ci;
-- 3) 答卷主记录
CREATE TABLE assessment_attempts (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
student_user_id BIGINT NOT NULL,
definition_id BIGINT NOT NULL,
status VARCHAR(20) NOT NULL DEFAULT 'DRAFT',
sampling_seed BIGINT NULL,
question_set_version VARCHAR(40) NOT NULL,
selected_question_count INT NOT NULL,
answered_count INT NOT NULL DEFAULT 0,
idempotency_key VARCHAR(64) NULL,
active_key VARCHAR(255) NULL,
version BIGINT NOT NULL DEFAULT 0,
started_at DATETIME NULL,
submitted_at DATETIME NULL,
completed_at DATETIME NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
ON UPDATE CURRENT_TIMESTAMP,
CONSTRAINT uk_assessment_attempts_idem UNIQUE (student_user_id, idempotency_key),
-- active_key 全局唯一:DRAFT/IN_PROGRESS 时非空(studentId:definitionId:ACTIVE),
-- COMPLETED/EXPIRED 后置 NULL。多 NULL 不冲突,从而在数据库层原子保证
-- “同一学生、同一测评类型最多一份活跃答卷”,并发创建只会成功一份。
CONSTRAINT uk_assessment_attempts_active UNIQUE (active_key),
CONSTRAINT chk_assessment_attempts_status
CHECK (status IN ('DRAFT', 'IN_PROGRESS', 'COMPLETED', 'EXPIRED')),
CONSTRAINT fk_assessment_attempts_user
FOREIGN KEY (student_user_id)
REFERENCES users(id)
ON DELETE CASCADE,
CONSTRAINT fk_assessment_attempts_definition
FOREIGN KEY (definition_id)
REFERENCES assessment_definitions(id),
INDEX idx_assessment_attempts_student_created (student_user_id, created_at)
) ENGINE=InnoDB
DEFAULT CHARSET=utf8mb4
COLLATE=utf8mb4_unicode_ci;
-- 4) 本次随机抽中的题目(30 道)
CREATE TABLE assessment_attempt_questions (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
attempt_id BIGINT NOT NULL,
question_id BIGINT NOT NULL,
position INT NOT NULL,
selected_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT uk_assessment_aq_attempt_question UNIQUE (attempt_id, question_id),
CONSTRAINT uk_assessment_aq_position UNIQUE (attempt_id, position),
CONSTRAINT fk_assessment_aq_attempt
FOREIGN KEY (attempt_id)
REFERENCES assessment_attempts(id)
ON DELETE CASCADE,
CONSTRAINT fk_assessment_aq_question
FOREIGN KEY (question_id)
REFERENCES assessment_questions(id),
INDEX idx_assessment_aq_attempt (attempt_id)
) ENGINE=InnoDB
DEFAULT CHARSET=utf8mb4
COLLATE=utf8mb4_unicode_ci;
-- 5) 答案
CREATE TABLE assessment_answers (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
attempt_id BIGINT NOT NULL,
question_id BIGINT NOT NULL,
answer_value INT NOT NULL,
answered_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT uk_assessment_answers_attempt_question UNIQUE (attempt_id, question_id),
CONSTRAINT chk_assessment_answers_value CHECK (answer_value BETWEEN 1 AND 5),
CONSTRAINT fk_assessment_answers_attempt
FOREIGN KEY (attempt_id)
REFERENCES assessment_attempts(id)
ON DELETE CASCADE,
CONSTRAINT fk_assessment_answers_question
FOREIGN KEY (question_id)
REFERENCES assessment_questions(id)
) ENGINE=InnoDB
DEFAULT CHARSET=utf8mb4
COLLATE=utf8mb4_unicode_ci;
-- 6) 结果(一份答卷至多一条结果,attempt_id 唯一,用于幂等防重复生成)
CREATE TABLE assessment_results (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
attempt_id BIGINT NOT NULL,
primary_code VARCHAR(3) NOT NULL,
primary_type VARCHAR(1) NOT NULL,
dimension_scores_text TEXT NULL,
type_ranking_text TEXT NULL,
answered_count INT NOT NULL,
completion_rate DECIMAL(5,2) NOT NULL,
question_set_version VARCHAR(40) NOT NULL,
rules_version VARCHAR(40) NOT NULL,
generated_at DATETIME NOT NULL,
version BIGINT NOT NULL DEFAULT 0,
CONSTRAINT uk_assessment_results_attempt UNIQUE (attempt_id),
CONSTRAINT fk_assessment_results_attempt
FOREIGN KEY (attempt_id)
REFERENCES assessment_attempts(id)
ON DELETE CASCADE
) ENGINE=InnoDB
DEFAULT CHARSET=utf8mb4
COLLATE=utf8mb4_unicode_ci;
-- =====================================================================
-- 种子数据:一条测评定义 + 72 道题(R/I/A/S/E/C 各 12 道,position 1-72)
-- =====================================================================
INSERT INTO assessment_definitions
(type_code, display_name, description, instructions, status, scale_type,
question_count, attempt_question_count, min_per_dimension,
question_set_version, rules_version)
VALUES (
'HOLLAND_RIASEC',
'RIASEC 职业兴趣测评',
'基于 RIASEC 理论的自研题库,仅供职业兴趣探索参考,不构成心理或职业诊断。',
'请根据你对每项职业活动的兴趣程度作答:1=完全不感兴趣,2=不太感兴趣,3=一般,4=比较感兴趣,5=非常感兴趣。',
'ACTIVE',
'LIKERT_5',
72,
30,
5,
'riasec-self-v0.2',
'riasec-scoring-v0.1'
);
INSERT INTO assessment_questions
(question_code, definition_id, position, dimension, stem,
scale_type, question_version, source, reverse_scored, active)
SELECT q.question_code, d.id, q.position, q.dimension, q.stem,
'LIKERT_5', 'v0.2', 'SELF_AUTHORED', 0, 1
FROM (
SELECT 'R01' AS question_code, 1 AS position, 'R' AS dimension, '我对使用手工工具制作物品感兴趣。' AS stem
UNION ALL SELECT 'R02', 2, 'R', '我喜欢按照图纸组装模型。'
UNION ALL SELECT 'R03', 3, 'R', '我对学习操作 3D 打印机感兴趣。'
UNION ALL SELECT 'R04', 4, 'R', '我对在户外进行现场操作类工作感兴趣。'
UNION ALL SELECT 'R05', 5, 'R', '我喜欢参与植物种植和养护。'
UNION ALL SELECT 'R06', 6, 'R', '我对拆装和更换电脑硬件部件感兴趣。'
UNION ALL SELECT 'R07', 7, 'R', '我对制作木制品感兴趣。'
UNION ALL SELECT 'R08', 8, 'R', '我对学习操作无人机感兴趣。'
UNION ALL SELECT 'R09', 9, 'R', '我对通过动手操作获得实体成果感兴趣。'
UNION ALL SELECT 'R10', 10, 'R', '我对了解建筑结构的实际施工过程感兴趣。'
UNION ALL SELECT 'R11', 11, 'R', '我喜欢观察机械装置的运动原理。'
UNION ALL SELECT 'R12', 12, 'R', '我对设备安装与调试工作感兴趣。'
UNION ALL SELECT 'I01', 13, 'I', '我喜欢探究复杂问题背后的原因和运作机制。'
UNION ALL SELECT 'I02', 14, 'I', '我对数独、逻辑推理题或解谜游戏感兴趣。'
UNION ALL SELECT 'I03', 15, 'I', '我喜欢阅读科学研究成果。'
UNION ALL SELECT 'I04', 16, 'I', '我对通过收集数据分析未知问题感兴趣。'
UNION ALL SELECT 'I05', 17, 'I', '我对阅读技术白皮书感兴趣。'
UNION ALL SELECT 'I06', 18, 'I', '我对设计测试方案验证某个假设感兴趣。'
UNION ALL SELECT 'I07', 19, 'I', '我喜欢研究数学模型。'
UNION ALL SELECT 'I08', 20, 'I', '我对核查原始事实和数据来源感兴趣。'
UNION ALL SELECT 'I09', 21, 'I', '我喜欢独立钻研专业领域的课题。'
UNION ALL SELECT 'I10', 22, 'I', '我对研究新技术产品的底层架构感兴趣。'
UNION ALL SELECT 'I11', 23, 'I', '我喜欢探究自然现象的运作机制。'
UNION ALL SELECT 'I12', 24, 'I', '我对通过实验或模拟比较不同方案感兴趣。'
UNION ALL SELECT 'A01', 25, 'A', '我对创作故事感兴趣。'
UNION ALL SELECT 'A02', 26, 'A', '我喜欢研究摄影构图。'
UNION ALL SELECT 'A03', 27, 'A', '我对创作音乐作品感兴趣。'
UNION ALL SELECT 'A04', 28, 'A', '我喜欢尝试不同的创作方法。'
UNION ALL SELECT 'A05', 29, 'A', '我喜欢参观艺术展览。'
UNION ALL SELECT 'A06', 30, 'A', '我对构思新项目的创意方案感兴趣。'
UNION ALL SELECT 'A07', 31, 'A', '我对设计短视频分镜感兴趣。'
UNION ALL SELECT 'A08', 32, 'A', '我喜欢设计具有个人特色的视觉风格。'
UNION ALL SELECT 'A09', 33, 'A', '我对从零开始构思原创作品感兴趣。'
UNION ALL SELECT 'A10', 34, 'A', '我对进行三维视觉建模感兴趣。'
UNION ALL SELECT 'A11', 35, 'A', '我对将情感转化为艺术表达感兴趣。'
UNION ALL SELECT 'A12', 36, 'A', '我对舞台视觉设计感兴趣。'
UNION ALL SELECT 'S01', 37, 'S', '我乐于倾听他人的烦恼。'
UNION ALL SELECT 'S02', 38, 'S', '我对向他人讲解新知识感兴趣。'
UNION ALL SELECT 'S03', 39, 'S', '我愿意参与社区互助活动。'
UNION ALL SELECT 'S04', 40, 'S', '我对了解他人的情绪和感受感兴趣。'
UNION ALL SELECT 'S05', 41, 'S', '我喜欢与不同背景的人交流。'
UNION ALL SELECT 'S06', 42, 'S', '我对职业规划咨询相关工作感兴趣。'
UNION ALL SELECT 'S07', 43, 'S', '我对协助团队成员增进理解感兴趣。'
UNION ALL SELECT 'S08', 44, 'S', '我对参与健康知识普及活动感兴趣。'
UNION ALL SELECT 'S09', 45, 'S', '我对帮助他人改善生活状态感兴趣。'
UNION ALL SELECT 'S10', 46, 'S', '我喜欢组织团队交流活动。'
UNION ALL SELECT 'S11', 47, 'S', '我对了解不同人的实际需求感兴趣。'
UNION ALL SELECT 'S12', 48, 'S', '我对参与青少年成长指导感兴趣。'
UNION ALL SELECT 'E01', 49, 'E', '我对负责推进团队项目感兴趣。'
UNION ALL SELECT 'E02', 50, 'E', '我对向客户介绍产品方案感兴趣。'
UNION ALL SELECT 'E03', 51, 'E', '我对参与商业项目策划感兴趣。'
UNION ALL SELECT 'E04', 52, 'E', '我喜欢研究企业的商业模式。'
UNION ALL SELECT 'E05', 53, 'E', '我对设定挑战性目标并争取达成感兴趣。'
UNION ALL SELECT 'E06', 54, 'E', '我喜欢协调不同人员共同完成目标。'
UNION ALL SELECT 'E07', 55, 'E', '我对进行公开方案演讲感兴趣。'
UNION ALL SELECT 'E08', 56, 'E', '我对评估商业机会感兴趣。'
UNION ALL SELECT 'E09', 57, 'E', '我对统筹项目整体规划感兴趣。'
UNION ALL SELECT 'E10', 58, 'E', '我对拓展职业社交圈感兴趣。'
UNION ALL SELECT 'E11', 59, 'E', '我对推动团队达成核心目标感兴趣。'
UNION ALL SELECT 'E12', 60, 'E', '我对参与突发情况下的团队决策感兴趣。'
UNION ALL SELECT 'C01', 61, 'C', '我喜欢把文件、表格或数据整理得分类清晰。'
UNION ALL SELECT 'C02', 62, 'C', '我对核对数据并找出细微差异感兴趣。'
UNION ALL SELECT 'C03', 63, 'C', '我喜欢按照明确的标准操作流程开展工作。'
UNION ALL SELECT 'C04', 64, 'C', '我对财务记账工作感兴趣。'
UNION ALL SELECT 'C05', 65, 'C', '我喜欢仔细校对文档中的文字和格式。'
UNION ALL SELECT 'C06', 66, 'C', '我偏好流程清晰、职责明确的工作环境。'
UNION ALL SELECT 'C07', 67, 'C', '我喜欢制定时间表或任务清单。'
UNION ALL SELECT 'C08', 68, 'C', '我对审阅合同条款感兴趣。'
UNION ALL SELECT 'C09', 69, 'C', '我对使用电子表格处理数据感兴趣。'
UNION ALL SELECT 'C10', 70, 'C', '我喜欢维护资料的分类和归档秩序。'
UNION ALL SELECT 'C11', 71, 'C', '我对标准明确、需要细致核验的事务感兴趣。'
UNION ALL SELECT 'C12', 72, 'C', '我对按照信息安全流程管理资料感兴趣。'
) q
JOIN assessment_definitions d ON d.type_code = 'HOLLAND_RIASEC';

225
mydreamjob-server/src/main/resources/db/migration/V35__ability_assessment.sql

@ -0,0 +1,225 @@
-- Flyway V35: 四维能力测评模块(功能版本 V36)
--
-- 学生端四维能力测评:LOGIC / COMMUNICATION / EXECUTION / LEARNING。
-- 48 道自研工作情境题,每题四选一(SITUATIONAL_JUDGMENT / SEQUENCE_JUDGMENT)。
-- 每次按四维各抽 6 题(每维三个 competencyTag 各 2 题)共 24 题。
-- options_json / answer_key_json / scoring_rule_json / explanation 使用 TEXT 保存,
-- 不使用 MySQL JSON 列,保持 MySQL 8 与 H2 MODE=MySQL 兼容。
-- 迁移由 Flyway 精确执行一次,不会重复插入。
-- 1) 测评定义
CREATE TABLE ability_definitions (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
type_code VARCHAR(40) NOT NULL,
display_name VARCHAR(100) NOT NULL,
description VARCHAR(1000) NULL,
status VARCHAR(20) NOT NULL DEFAULT 'ACTIVE',
question_count INT NOT NULL,
attempt_question_count INT NOT NULL,
questions_per_dimension INT NOT NULL,
time_limit_minutes INT NOT NULL,
result_valid_days INT NOT NULL,
retake_window_days INT NOT NULL,
retake_max_completed INT NOT NULL,
question_set_version VARCHAR(40) NOT NULL,
rules_version VARCHAR(40) NOT NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
CONSTRAINT uk_ability_definitions_type UNIQUE (type_code),
CONSTRAINT chk_ability_definitions_status CHECK (status IN ('ACTIVE', 'INACTIVE'))
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- 2) 题目(48 道,四维各 12)
CREATE TABLE ability_questions (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
definition_id BIGINT NOT NULL,
question_code VARCHAR(20) NOT NULL,
dimension VARCHAR(20) NOT NULL,
competency_tag VARCHAR(40) NOT NULL,
item_type VARCHAR(40) NOT NULL,
stem VARCHAR(2000) NOT NULL,
options_json TEXT NOT NULL,
answer_key_json TEXT NOT NULL,
scoring_rule_json TEXT NOT NULL,
explanation TEXT NOT NULL,
difficulty VARCHAR(10) NOT NULL,
discrimination DECIMAL(6,3) NULL,
question_version VARCHAR(40) NOT NULL,
source VARCHAR(40) NOT NULL,
active TINYINT NOT NULL DEFAULT 1,
position INT NOT NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
CONSTRAINT uk_ability_questions_code UNIQUE (definition_id, question_code),
CONSTRAINT uk_ability_questions_position UNIQUE (definition_id, position),
CONSTRAINT fk_ability_questions_definition
FOREIGN KEY (definition_id) REFERENCES ability_definitions(id),
CONSTRAINT chk_ability_questions_dimension
CHECK (dimension IN ('LOGIC', 'COMMUNICATION', 'EXECUTION', 'LEARNING')),
CONSTRAINT chk_ability_questions_item_type
CHECK (item_type IN ('SITUATIONAL_JUDGMENT', 'SEQUENCE_JUDGMENT')),
CONSTRAINT chk_ability_questions_difficulty
CHECK (difficulty IN ('EASY', 'MEDIUM', 'HARD')),
CONSTRAINT chk_ability_questions_active CHECK (active IN (0, 1)),
INDEX idx_ability_questions_def_dim (definition_id, dimension, competency_tag, active)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- 3) 答卷主记录
CREATE TABLE ability_attempts (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
student_user_id BIGINT NOT NULL,
definition_id BIGINT NOT NULL,
status VARCHAR(20) NOT NULL DEFAULT 'DRAFT',
active_key VARCHAR(255) NULL,
idempotency_key VARCHAR(64) NULL,
question_set_version VARCHAR(40) NOT NULL,
answered_count INT NOT NULL DEFAULT 0,
version BIGINT NOT NULL DEFAULT 0,
started_at DATETIME NULL,
expires_at DATETIME NULL,
submitted_at DATETIME NULL,
completed_at DATETIME NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
CONSTRAINT uk_ability_attempts_idem UNIQUE (student_user_id, idempotency_key),
CONSTRAINT uk_ability_attempts_active UNIQUE (active_key),
CONSTRAINT chk_ability_attempts_status
CHECK (status IN ('DRAFT', 'IN_PROGRESS', 'COMPLETED', 'EXPIRED')),
CONSTRAINT fk_ability_attempts_user
FOREIGN KEY (student_user_id) REFERENCES users(id) ON DELETE CASCADE,
CONSTRAINT fk_ability_attempts_definition
FOREIGN KEY (definition_id) REFERENCES ability_definitions(id),
INDEX idx_ability_attempts_student_status_created (student_user_id, status, created_at),
INDEX idx_ability_attempts_student_def_completed (student_user_id, definition_id, completed_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- 4) 本次抽中的题目(24 道,含完整题目快照,保证后续题库更新不改历史答卷评分)
CREATE TABLE ability_attempt_questions (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
attempt_id BIGINT NOT NULL,
question_id BIGINT NOT NULL,
position INT NOT NULL,
question_version VARCHAR(40) NOT NULL,
question_snapshot_json TEXT NOT NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT uk_ability_aq_attempt_question UNIQUE (attempt_id, question_id),
CONSTRAINT uk_ability_aq_position UNIQUE (attempt_id, position),
CONSTRAINT fk_ability_aq_attempt
FOREIGN KEY (attempt_id) REFERENCES ability_attempts(id) ON DELETE CASCADE,
CONSTRAINT fk_ability_aq_question
FOREIGN KEY (question_id) REFERENCES ability_questions(id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- 5) 答案
CREATE TABLE ability_answers (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
attempt_id BIGINT NOT NULL,
attempt_question_id BIGINT NOT NULL,
selected_option_id VARCHAR(4) NOT NULL,
awarded_score INT NOT NULL,
answered_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
CONSTRAINT uk_ability_answers_attempt_aq UNIQUE (attempt_id, attempt_question_id),
CONSTRAINT chk_ability_answers_score CHECK (awarded_score IN (0, 1, 2, 4)),
CONSTRAINT fk_ability_answers_attempt
FOREIGN KEY (attempt_id) REFERENCES ability_attempts(id) ON DELETE CASCADE,
CONSTRAINT fk_ability_answers_aq
FOREIGN KEY (attempt_question_id) REFERENCES ability_attempt_questions(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- 6) 结果(一份答卷至多一条结果,attempt_id 唯一,用于幂等防重复生成)
CREATE TABLE ability_results (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
attempt_id BIGINT NOT NULL,
dimension_scores_json TEXT NULL,
overall_score DECIMAL(5,2) NOT NULL,
weaknesses_json TEXT NULL,
improvement_areas_json TEXT NULL,
question_feedback_json TEXT NULL,
valid_until DATETIME NULL,
question_set_version VARCHAR(40) NOT NULL,
rules_version VARCHAR(40) NOT NULL,
generated_at DATETIME NOT NULL,
version BIGINT NOT NULL DEFAULT 0,
CONSTRAINT uk_ability_results_attempt UNIQUE (attempt_id),
CONSTRAINT fk_ability_results_attempt
FOREIGN KEY (attempt_id) REFERENCES ability_attempts(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- =====================================================================
-- 种子数据:一条测评定义 + 48 道题(四维各 12,position 1-48)
-- =====================================================================
INSERT INTO ability_definitions
(type_code, display_name, description, status,
question_count, attempt_question_count, questions_per_dimension,
time_limit_minutes, result_valid_days, retake_window_days, retake_max_completed,
question_set_version, rules_version)
VALUES (
'WORKPLACE_ABILITY',
'四维能力测评',
'基于工作情境的自研四维能力测评,用于自我了解和能力提升参考,不构成职业资格、正式招聘结论或永久能力标签。',
'ACTIVE',
48, 24, 6, 30, 7, 90, 3,
'ability-v0.1',
'ability-scoring-v0.1'
);
INSERT INTO ability_questions
(question_code, definition_id, dimension, competency_tag, item_type, stem, options_json, answer_key_json, scoring_rule_json, explanation, difficulty, discrimination, question_version, source, active, position)
VALUES
('LO01',(SELECT id FROM ability_definitions WHERE type_code='WORKPLACE_ABILITY'),'LOGIC','INFORMATION_ANALYSIS','SITUATIONAL_JUDGMENT','销售报表显示本月销售额增长20%,财务报表只增长8%。你首先会怎么处理?','[{"optionId":"A","text":"核对两份报表的统计口径、时间范围和原始数据来源"},{"optionId":"B","text":"先取两个增长率的平均值作为汇报结果"},{"optionId":"C","text":"先使用财务报表,并注明另一份报表存在差异"},{"optionId":"D","text":"采用增长率较高的报表,因为结果更积极"}]','{"answerKey":"A"}','{"A":4,"B":1,"C":2,"D":0}','冲突数据应先统一口径并追溯原始来源。直接平均、择一或选择有利结果都会掩盖真正的数据问题。','EASY',NULL,'ability-v0.1','SELF_AUTHORED',1,1),
('LO02',(SELECT id FROM ability_definitions WHERE type_code='WORKPLACE_ABILITY'),'LOGIC','INFORMATION_ANALYSIS','SITUATIONAL_JUDGMENT','一项新页面的转化率从10%升到18%,但目前只有20名访问者。你会如何判断效果?','[{"optionId":"A","text":"立即宣布新页面显著优于旧页面"},{"optionId":"B","text":"扩大样本并与同口径对照组比较后再判断"},{"optionId":"C","text":"继续观察,同时先把18%作为确定结论汇报"},{"optionId":"D","text":"说明样本较小,只能给出暂定趋势而非确定结论"}]','{"answerKey":"B"}','{"A":0,"B":4,"C":1,"D":2}','小样本波动较大,应扩大样本并设置可比基线。仅提示不确定性有价值,但仍不足以验证变化是否来自页面本身。','MEDIUM',NULL,'ability-v0.1','SELF_AUTHORED',1,2),
('LO03',(SELECT id FROM ability_definitions WHERE type_code='WORKPLACE_ABILITY'),'LOGIC','INFORMATION_ANALYSIS','SEQUENCE_JUDGMENT','两份分析报告得出相反结论。以下哪组核查顺序最合理?','[{"optionId":"A","text":"比较结论→询问作者→补充少量数据→决定采用哪份"},{"optionId":"B","text":"选择更新的报告→删除旧报告→通知相关人员→结束核查"},{"optionId":"C","text":"统一指标定义→核对原始数据→比较分析方法→复算并形成结论"},{"optionId":"D","text":"先复算结果→再确认口径→挑选更可信作者→形成结论"}]','{"answerKey":"C"}','{"A":1,"B":0,"C":4,"D":2}','先统一定义,再核查数据和方法,最后复算,才能避免在不同口径上比较结论。','HARD',NULL,'ability-v0.1','SELF_AUTHORED',1,3),
('LO04',(SELECT id FROM ability_definitions WHERE type_code='WORKPLACE_ABILITY'),'LOGIC','INFORMATION_ANALYSIS','SITUATIONAL_JUDGMENT','必须当天决定是否上线,但关键用户数据尚未收齐。你会怎么做?','[{"optionId":"A","text":"直接按个人经验上线,后续再看结果"},{"optionId":"B","text":"延期所有工作,直到全部信息完整"},{"optionId":"C","text":"让职位最高的人直接给出判断"},{"optionId":"D","text":"列明已知、未知和关键假设,选择可回滚的小范围方案并设置观察指标"}]','{"answerKey":"D"}','{"A":2,"B":1,"C":0,"D":4}','信息不完整时应显式管理不确定性,并优先选择可回滚、可观察的决策,控制错误成本。','MEDIUM',NULL,'ability-v0.1','SELF_AUTHORED',1,4),
('LO05',(SELECT id FROM ability_definitions WHERE type_code='WORKPLACE_ABILITY'),'LOGIC','CAUSAL_REASONING','SITUATIONAL_JUDGMENT','促销活动上线后订单增长了25%。如何判断增长是否主要由活动带来?','[{"optionId":"A","text":"比较同期趋势、对照群体并排查价格和流量等同时变化因素"},{"optionId":"B","text":"活动和增长同时发生,因此可以判断活动有效"},{"optionId":"C","text":"查看几条参与活动用户的正面反馈"},{"optionId":"D","text":"只比较活动前一天和活动后一天订单量"}]','{"answerKey":"A"}','{"A":4,"B":2,"C":1,"D":0}','时间上的同时发生不等于因果关系。对照、趋势和混杂因素核查能更可靠地识别活动贡献。','EASY',NULL,'ability-v0.1','SELF_AUTHORED',1,5),
('LO06',(SELECT id FROM ability_definitions WHERE type_code='WORKPLACE_ABILITY'),'LOGIC','CAUSAL_REASONING','SITUATIONAL_JUDGMENT','系统发布新版本后出现大量错误。最合适的第一步是什么?','[{"optionId":"A","text":"同时修改多个可疑模块,争取尽快碰到正确修复"},{"optionId":"B","text":"先控制影响并保留日志,再对比发布前后变化定位原因"},{"optionId":"C","text":"立即回滚,但不保留现场信息"},{"optionId":"D","text":"等待更多用户反馈后再开始处理"}]','{"answerKey":"B"}','{"A":0,"B":4,"C":2,"D":1}','先止损并保存证据,再围绕版本变化定位原因,既控制业务影响,也避免破坏根因分析条件。','MEDIUM',NULL,'ability-v0.1','SELF_AUTHORED',1,6),
('LO07',(SELECT id FROM ability_definitions WHERE type_code='WORKPLACE_ABILITY'),'LOGIC','CAUSAL_REASONING','SITUATIONAL_JUDGMENT','数据显示加班时间越长的团队交付量越高。你会怎样解释?','[{"optionId":"A","text":"说明加班一定能提升团队效率"},{"optionId":"B","text":"建议所有团队立即增加加班时间"},{"optionId":"C","text":"检查项目规模、人员数量和紧急程度等共同影响因素"},{"optionId":"D","text":"先承认存在相关性,但暂不据此做因果决策"}]','{"answerKey":"C"}','{"A":1,"B":0,"C":4,"D":2}','交付量和加班可能同时受到项目规模等因素影响。相关性需要结合混杂因素和对照数据解释。','HARD',NULL,'ability-v0.1','SELF_AUTHORED',1,7),
('LO08',(SELECT id FROM ability_definitions WHERE type_code='WORKPLACE_ABILITY'),'LOGIC','CAUSAL_REASONING','SEQUENCE_JUDGMENT','你要验证“缩短注册表单能提高完成率”。哪组步骤最合理?','[{"optionId":"A","text":"修改表单→全量上线→观察总注册量→询问用户感受"},{"optionId":"B","text":"提出假设→直接上线→发现变化→补写指标定义"},{"optionId":"C","text":"观察历史数据→修改多个流程→比较前后总量→形成结论"},{"optionId":"D","text":"定义指标→设置随机对照→只改变表单长度→达到样本量后分析"}]','{"answerKey":"D"}','{"A":2,"B":1,"C":0,"D":4}','明确指标、设置对照并控制唯一变量,是验证页面变化与转化率之间因果关系的基本路径。','HARD',NULL,'ability-v0.1','SELF_AUTHORED',1,8),
('LO09',(SELECT id FROM ability_definitions WHERE type_code='WORKPLACE_ABILITY'),'LOGIC','PROBLEM_SOLVING','SITUATIONAL_JUDGMENT','你收到“把这个页面做得更好用”的任务。首先应该做什么?','[{"optionId":"A","text":"明确目标用户、具体场景、当前问题和验收标准"},{"optionId":"B","text":"先按常见设计规范修改一版"},{"optionId":"C","text":"只询问对方喜欢什么颜色和布局"},{"optionId":"D","text":"等待对方给出完整解决方案"}]','{"answerKey":"A"}','{"A":4,"B":2,"C":1,"D":0}','模糊需求应先转化为用户、场景、问题和验收条件,之后才能选择合适的实现方案。','EASY',NULL,'ability-v0.1','SELF_AUTHORED',1,9),
('LO10',(SELECT id FROM ability_definitions WHERE type_code='WORKPLACE_ABILITY'),'LOGIC','PROBLEM_SOLVING','SEQUENCE_JUDGMENT','面对一个复杂而陌生的问题,哪组处理顺序最合理?','[{"optionId":"A","text":"寻找现成答案→直接实施→发现问题→再定义目标"},{"optionId":"B","text":"明确目标→拆分子问题→确定输入输出→逐项验证并整合"},{"optionId":"C","text":"先处理最熟悉部分→逐步扩大范围→最后确认是否解决目标"},{"optionId":"D","text":"先写完整长方案→交给他人执行→根据反馈决定是否验证"}]','{"answerKey":"B"}','{"A":0,"B":4,"C":2,"D":1}','复杂问题需要先明确目标,再拆解和定义验证条件,避免局部工作很多却没有解决核心问题。','MEDIUM',NULL,'ability-v0.1','SELF_AUTHORED',1,10),
('LO11',(SELECT id FROM ability_definitions WHERE type_code='WORKPLACE_ABILITY'),'LOGIC','PROBLEM_SOLVING','SITUATIONAL_JUDGMENT','你提出的方案试运行后没有达到目标。下一步最合理的是?','[{"optionId":"A","text":"继续扩大使用范围,以获得更多结果"},{"optionId":"B","text":"认为执行人员没有严格遵循方案"},{"optionId":"C","text":"对照预期与实际,检查假设、执行偏差和外部因素后再调整"},{"optionId":"D","text":"先暂停扩大,并收集失败环节的数据"}]','{"answerKey":"C"}','{"A":1,"B":0,"C":4,"D":2}','方案无效时需要同时检查假设、执行和环境,而不是只扩大范围或归责于某一方。','HARD',NULL,'ability-v0.1','SELF_AUTHORED',1,11),
('LO12',(SELECT id FROM ability_definitions WHERE type_code='WORKPLACE_ABILITY'),'LOGIC','PROBLEM_SOLVING','SITUATIONAL_JUDGMENT','负责人要求估算一个尚未完全明确的任务工期。你会如何回答?','[{"optionId":"A","text":"给出一个大致天数,并说明只是个人感觉"},{"optionId":"B","text":"拒绝估算,等所有需求完全确定"},{"optionId":"C","text":"参考类似任务,直接承诺相同工期"},{"optionId":"D","text":"拆分已知范围,说明假设、区间、风险以及需要补充的信息"}]','{"answerKey":"D"}','{"A":2,"B":1,"C":0,"D":4}','在不确定条件下,区间估算加上假设和风险说明,比伪精确承诺或完全拒绝估算更可执行。','EASY',NULL,'ability-v0.1','SELF_AUTHORED',1,12),
('CO01',(SELECT id FROM ability_definitions WHERE type_code='WORKPLACE_ABILITY'),'COMMUNICATION','LISTENING_UNDERSTANDING','SITUATIONAL_JUDGMENT','对方提出一个比较模糊的合作需求。你首先会怎么回应?','[{"optionId":"A","text":"用自己的话复述理解,并确认目标、对象、边界和优先级"},{"optionId":"B","text":"根据过往经验直接开始准备方案"},{"optionId":"C","text":"发送一份通用需求模板,请对方有空时补充"},{"optionId":"D","text":"要求对方想清楚全部细节后再来沟通"}]','{"answerKey":"A"}','{"A":4,"B":2,"C":1,"D":0}','复述与确认能够尽早暴露理解偏差,同时帮助双方把模糊表达转化为共同目标和边界。','EASY',NULL,'ability-v0.1','SELF_AUTHORED',1,13),
('CO02',(SELECT id FROM ability_definitions WHERE type_code='WORKPLACE_ABILITY'),'COMMUNICATION','LISTENING_UNDERSTANDING','SITUATIONAL_JUDGMENT','同事正在说明一次工作失误及其困难。你最合适的做法是?','[{"optionId":"A","text":"马上告诉对方正确做法,避免继续浪费时间"},{"optionId":"B","text":"先听完并复述关键事实、影响和对方需要的支持"},{"optionId":"C","text":"先听取主要内容,再提出自己的解决建议"},{"optionId":"D","text":"保持沉默,让对方自己整理清楚"}]','{"answerKey":"B"}','{"A":0,"B":4,"C":2,"D":1}','先完整理解事实和需求,再讨论方案,能避免把建议建立在错误假设上,也能降低对方的防御感。','MEDIUM',NULL,'ability-v0.1','SELF_AUTHORED',1,14),
('CO03',(SELECT id FROM ability_definitions WHERE type_code='WORKPLACE_ABILITY'),'COMMUNICATION','LISTENING_UNDERSTANDING','SEQUENCE_JUDGMENT','三方对同一需求理解不同。哪组沟通顺序最合理?','[{"optionId":"A","text":"各自发言→多数表决→记录结果→开始执行"},{"optionId":"B","text":"负责人定调→其他人补充→形成文档→会后再澄清"},{"optionId":"C","text":"分别复述理解→识别分歧点→对齐目标和证据→确认决定与责任"},{"optionId":"D","text":"收集意见→整理共同点→由负责人决定→通知执行人员"}]','{"answerKey":"C"}','{"A":1,"B":0,"C":4,"D":2}','先让各方显式表达理解,再定位分歧并对齐依据,才能形成真正一致且可执行的结论。','HARD',NULL,'ability-v0.1','SELF_AUTHORED',1,15),
('CO04',(SELECT id FROM ability_definitions WHERE type_code='WORKPLACE_ABILITY'),'COMMUNICATION','LISTENING_UNDERSTANDING','SITUATIONAL_JUDGMENT','客户表达不满,但没有明确说明具体问题。你会怎么做?','[{"optionId":"A","text":"先说明团队已经严格按流程完成工作"},{"optionId":"B","text":"请客户提交正式投诉材料后再处理"},{"optionId":"C","text":"直接提供补偿,尽快结束沟通"},{"optionId":"D","text":"确认客户感受,再询问具体场景、期望和造成的影响"}]','{"answerKey":"D"}','{"A":2,"B":1,"C":0,"D":4}','先确认感受并提取具体事实,有助于把情绪表达转化为可以处理的问题和期望。','MEDIUM',NULL,'ability-v0.1','SELF_AUTHORED',1,16),
('CO05',(SELECT id FROM ability_definitions WHERE type_code='WORKPLACE_ABILITY'),'COMMUNICATION','STRUCTURED_EXPRESSION','SITUATIONAL_JUDGMENT','你需要向非技术同事解释一次系统故障。最合适的方式是?','[{"optionId":"A","text":"先讲业务影响和处理进展,再用通俗类比说明原因并确认理解"},{"optionId":"B","text":"完整说明技术栈、日志和异常调用链"},{"optionId":"C","text":"只说系统有问题,修好后再通知"},{"optionId":"D","text":"省略不确定性,给出一个肯定恢复时间"}]','{"answerKey":"A"}','{"A":4,"B":2,"C":1,"D":0}','面向非技术对象应从其关心的影响和行动出发,用适当语言说明原因,同时保留必要的不确定性。','EASY',NULL,'ability-v0.1','SELF_AUTHORED',1,17),
('CO06',(SELECT id FROM ability_definitions WHERE type_code='WORKPLACE_ABILITY'),'COMMUNICATION','STRUCTURED_EXPRESSION','SITUATIONAL_JUDGMENT','发送一条需要对方行动的重要工作消息,应该如何组织?','[{"optionId":"A","text":"只写“请尽快处理”,减少阅读时间"},{"optionId":"B","text":"说明背景、当前问题、所需行动、负责人和截止时间"},{"optionId":"C","text":"先给结论和行动要求,再附上必要背景"},{"optionId":"D","text":"把全部聊天记录转发过去,让对方自行理解"}]','{"answerKey":"B"}','{"A":0,"B":4,"C":2,"D":1}','清晰的工作消息应让接收者迅速知道为什么、做什么、谁负责以及何时完成。','MEDIUM',NULL,'ability-v0.1','SELF_AUTHORED',1,18),
('CO07',(SELECT id FROM ability_definitions WHERE type_code='WORKPLACE_ABILITY'),'COMMUNICATION','STRUCTURED_EXPRESSION','SITUATIONAL_JUDGMENT','你需要指出同事提交材料中的严重问题。怎样表达最有效?','[{"optionId":"A","text":"直接说“你做事不够认真”"},{"optionId":"B","text":"只修改材料,不再提及问题"},{"optionId":"C","text":"描述具体问题、造成的影响、修改标准和可获得的支持"},{"optionId":"D","text":"私下指出具体错误,并提醒以后注意"}]','{"answerKey":"C"}','{"A":1,"B":0,"C":4,"D":2}','高质量反馈针对可观察行为和影响,并给出明确标准与支持,不把问题上升为人格评价。','HARD',NULL,'ability-v0.1','SELF_AUTHORED',1,19),
('CO08',(SELECT id FROM ability_definitions WHERE type_code='WORKPLACE_ABILITY'),'COMMUNICATION','STRUCTURED_EXPRESSION','SEQUENCE_JUDGMENT','汇报一个延期项目时,哪组顺序最合理?','[{"optionId":"A","text":"说明困难→解释原因→请求理解→承诺尽快完成"},{"optionId":"B","text":"汇报已完成工作→省略延期风险→等待追问→再解释"},{"optionId":"C","text":"说明延期→列出责任方→描述过程→等待负责人决定"},{"optionId":"D","text":"先报结论和影响→说明原因与证据→给出方案→明确需要的决定"}]','{"answerKey":"D"}','{"A":2,"B":1,"C":0,"D":4}','延期汇报应优先呈现事实和影响,再提供可验证原因、解决方案和明确决策请求。','HARD',NULL,'ability-v0.1','SELF_AUTHORED',1,20),
('CO09',(SELECT id FROM ability_definitions WHERE type_code='WORKPLACE_ABILITY'),'COMMUNICATION','COLLABORATION_CONFLICT','SITUATIONAL_JUDGMENT','你与同事对方案选择意见相反。你会如何推进?','[{"optionId":"A","text":"先确认共同目标,再区分事实与偏好,用统一标准比较方案"},{"optionId":"B","text":"请更多同事投票,按人数决定"},{"optionId":"C","text":"暂时接受对方方案,避免影响关系"},{"optionId":"D","text":"重点证明自己的方案更专业"}]','{"answerKey":"A"}','{"A":4,"B":2,"C":1,"D":0}','以共同目标和一致评价标准处理分歧,能够把立场冲突转化为可比较的方案决策。','EASY',NULL,'ability-v0.1','SELF_AUTHORED',1,21),
('CO10',(SELECT id FROM ability_definitions WHERE type_code='WORKPLACE_ABILITY'),'COMMUNICATION','COLLABORATION_CONFLICT','SEQUENCE_JUDGMENT','跨团队依赖已经延期。哪种沟通方式最合理?','[{"optionId":"A","text":"在公共群指出对方延期→要求承诺→抄送负责人→等待结果"},{"optionId":"B","text":"确认事实和影响→了解阻塞→共同制定新节点→记录责任和升级条件"},{"optionId":"C","text":"先私下询问进度→协商新时间→定期跟进"},{"optionId":"D","text":"自行绕过依赖→完成后再告知对方"}]','{"answerKey":"B"}','{"A":0,"B":4,"C":2,"D":1}','跨团队协作需要共同确认事实、阻塞和新承诺,并提前约定何时升级,避免指责或信息缺失。','MEDIUM',NULL,'ability-v0.1','SELF_AUTHORED',1,22),
('CO11',(SELECT id FROM ability_definitions WHERE type_code='WORKPLACE_ABILITY'),'COMMUNICATION','COLLABORATION_CONFLICT','SITUATIONAL_JUDGMENT','同事临时要求你承担一项会影响原计划的工作。你会怎么回应?','[{"optionId":"A","text":"直接答应,之后再想办法补原任务"},{"optionId":"B","text":"直接拒绝,因为这不属于原计划"},{"optionId":"C","text":"说明当前承诺和影响,确认优先级并提出替代时间或资源方案"},{"optionId":"D","text":"先了解紧急程度,再决定是否帮助"}]','{"answerKey":"C"}','{"A":1,"B":0,"C":4,"D":2}','边界沟通既要说明已有承诺,也要讨论优先级和替代方案,避免无条件接受或简单拒绝。','HARD',NULL,'ability-v0.1','SELF_AUTHORED',1,23),
('CO12',(SELECT id FROM ability_definitions WHERE type_code='WORKPLACE_ABILITY'),'COMMUNICATION','COLLABORATION_CONFLICT','SITUATIONAL_JUDGMENT','你发现自己的表达让对方产生了误解。最合适的处理方式是?','[{"optionId":"A","text":"详细解释当时为什么会那样表达"},{"optionId":"B","text":"不再提及,避免让情况更尴尬"},{"optionId":"C","text":"先做出对方希望的承诺,之后再调整"},{"optionId":"D","text":"承认造成的影响,澄清真实意图并共同确认修正行动"}]','{"answerKey":"D"}','{"A":2,"B":1,"C":0,"D":4}','修复误解应同时处理影响、意图和后续行动,而不是只解释动机或给出无法兑现的承诺。','EASY',NULL,'ability-v0.1','SELF_AUTHORED',1,24),
('EX01',(SELECT id FROM ability_definitions WHERE type_code='WORKPLACE_ABILITY'),'EXECUTION','PLANNING_PRIORITIZATION','SITUATIONAL_JUDGMENT','三个任务都临近截止时间,但现有资源只能优先完成一个。你会怎么做?','[{"optionId":"A","text":"按业务影响、紧急程度和依赖关系排序,并同步取舍方案"},{"optionId":"B","text":"三个任务同时开始,尽量都推进一些"},{"optionId":"C","text":"先完成最容易交付的任务"},{"optionId":"D","text":"等待负责人主动指定优先级"}]','{"answerKey":"A"}','{"A":4,"B":2,"C":1,"D":0}','资源冲突时应基于影响、时效和依赖做明确取舍,并让相关方及时知道后果和安排。','EASY',NULL,'ability-v0.1','SELF_AUTHORED',1,25),
('EX02',(SELECT id FROM ability_definitions WHERE type_code='WORKPLACE_ABILITY'),'EXECUTION','PLANNING_PRIORITIZATION','SITUATIONAL_JUDGMENT','开始一个跨两周的新任务时,你首先会建立什么?','[{"optionId":"A","text":"一份尽可能详细、覆盖所有可能情况的计划"},{"optionId":"B","text":"明确交付结果、关键步骤、依赖、负责人和检查节点"},{"optionId":"C","text":"先确定最终期限和几个主要步骤"},{"optionId":"D","text":"先从最熟悉的部分开始,再逐步补计划"}]','{"answerKey":"B"}','{"A":0,"B":4,"C":2,"D":1}','可执行计划要连接目标、步骤、依赖、责任与检查点,既能推进,也能尽早发现偏差。','MEDIUM',NULL,'ability-v0.1','SELF_AUTHORED',1,26),
('EX03',(SELECT id FROM ability_definitions WHERE type_code='WORKPLACE_ABILITY'),'EXECUTION','PLANNING_PRIORITIZATION','SITUATIONAL_JUDGMENT','需求范围比预计大,但截止日期没有变化。你会怎样处理?','[{"optionId":"A","text":"保持原计划,靠加班完成新增范围"},{"optionId":"B","text":"先完成原范围,新增部分到期后再解释"},{"optionId":"C","text":"评估新增工作量和影响,提出范围、资源或时间的取舍方案"},{"optionId":"D","text":"记录需求变化,并提醒负责人可能延期"}]','{"answerKey":"C"}','{"A":1,"B":0,"C":4,"D":2}','范围、资源和时间相互制约。范围变化后应量化影响并推动明确取舍,而不是默默承担风险。','HARD',NULL,'ability-v0.1','SELF_AUTHORED',1,27),
('EX04',(SELECT id FROM ability_definitions WHERE type_code='WORKPLACE_ABILITY'),'EXECUTION','PLANNING_PRIORITIZATION','SEQUENCE_JUDGMENT','把一个目标转成可执行计划,哪组顺序最合理?','[{"optionId":"A","text":"列任务→安排人员→确定目标→检查结果"},{"optionId":"B","text":"确定期限→开始执行→补充资源→最后验收"},{"optionId":"C","text":"选择工具→分配任务→同步进度→根据结果调整目标"},{"optionId":"D","text":"明确结果→拆分任务→识别依赖和风险→设置节点与验收标准"}]','{"answerKey":"D"}','{"A":2,"B":1,"C":0,"D":4}','计划应从结果出发,再拆解任务、依赖、风险和验收节点,保证每一步都服务于最终目标。','MEDIUM',NULL,'ability-v0.1','SELF_AUTHORED',1,28),
('EX05',(SELECT id FROM ability_definitions WHERE type_code='WORKPLACE_ABILITY'),'EXECUTION','RISK_FOLLOWUP','SITUATIONAL_JUDGMENT','你发现一个依赖项可能导致项目延期。你会怎么管理这项风险?','[{"optionId":"A","text":"记录触发条件、概率、影响、负责人和应对方案,并持续检查"},{"optionId":"B","text":"在周会上提醒一次,让大家注意"},{"optionId":"C","text":"先准备一个备用方案,但暂不告诉其他人"},{"optionId":"D","text":"等风险真正发生后再处理,避免过度反应"}]','{"answerKey":"A"}','{"A":4,"B":2,"C":1,"D":0}','风险管理需要可观察的触发条件、责任人和预案,并持续跟踪,而不只是一次提醒或被动等待。','EASY',NULL,'ability-v0.1','SELF_AUTHORED',1,29),
('EX06',(SELECT id FROM ability_definitions WHERE type_code='WORKPLACE_ABILITY'),'EXECUTION','RISK_FOLLOWUP','SITUATIONAL_JUDGMENT','你的任务被另一个团队阻塞,且已经影响当前节点。你会怎么做?','[{"optionId":"A","text":"绕开对方直接修改其负责内容"},{"optionId":"B","text":"说明阻塞事实和影响,提出备选方案并约定升级时间"},{"optionId":"C","text":"持续跟进对方进度,同时调整自己可先做的工作"},{"optionId":"D","text":"记录阻塞,等负责人下次检查时再汇报"}]','{"answerKey":"B"}','{"A":0,"B":4,"C":2,"D":1}','处理阻塞不仅要跟进,还要透明说明影响、准备替代路径并约定升级条件,防止风险继续扩大。','MEDIUM',NULL,'ability-v0.1','SELF_AUTHORED',1,30),
('EX07',(SELECT id FROM ability_definitions WHERE type_code='WORKPLACE_ABILITY'),'EXECUTION','RISK_FOLLOWUP','SITUATIONAL_JUDGMENT','项目进度看似正常,但关键任务一直没有可验证产出。你会怎么处理?','[{"optionId":"A","text":"继续相信负责人汇报,避免打断执行"},{"optionId":"B","text":"增加每日进度汇报次数"},{"optionId":"C","text":"把关键任务拆成可验收的小节点,用产出和风险重新评估进度"},{"optionId":"D","text":"要求负责人尽快提供一个演示版本"}]','{"answerKey":"C"}','{"A":1,"B":0,"C":4,"D":2}','进度应由可验证产出而非主观百分比衡量。拆成小节点可以提前暴露真实偏差和风险。','HARD',NULL,'ability-v0.1','SELF_AUTHORED',1,31),
('EX08',(SELECT id FROM ability_definitions WHERE type_code='WORKPLACE_ABILITY'),'EXECUTION','RISK_FOLLOWUP','SEQUENCE_JUDGMENT','线上发生严重故障时,哪组执行顺序最合理?','[{"optionId":"A","text":"查找根因→修改代码→通知用户→恢复服务"},{"optionId":"B","text":"重启服务→观察结果→继续修改→故障后补记录"},{"optionId":"C","text":"通知负责人→等待决定→组织排查→发布修复"},{"optionId":"D","text":"控制影响→保留证据→同步状态→恢复服务→复盘根因"}]','{"answerKey":"D"}','{"A":2,"B":1,"C":0,"D":4}','故障处置首先控制影响,同时保留分析证据并保持信息同步;恢复后再完成系统性根因复盘。','HARD',NULL,'ability-v0.1','SELF_AUTHORED',1,32),
('EX09',(SELECT id FROM ability_definitions WHERE type_code='WORKPLACE_ABILITY'),'EXECUTION','DELIVERY_REVIEW','SITUATIONAL_JUDGMENT','截止时间临近,但交付物仍存在质量风险。你会怎么处理?','[{"optionId":"A","text":"识别关键质量门槛,缩小非核心范围,分阶段交付并同步风险"},{"optionId":"B","text":"优先保证所有功能完成,测试可以之后补"},{"optionId":"C","text":"继续优化细节,必要时到期后再解释"},{"optionId":"D","text":"直接推迟交付,直到所有问题完全解决"}]','{"answerKey":"A"}','{"A":4,"B":0,"C":1,"D":2}','临近截止时间应守住关键质量底线,并通过范围取舍和分阶段交付透明管理风险。','EASY',NULL,'ability-v0.1','SELF_AUTHORED',1,33),
('EX10',(SELECT id FROM ability_definitions WHERE type_code='WORKPLACE_ABILITY'),'EXECUTION','DELIVERY_REVIEW','SEQUENCE_JUDGMENT','向他人交接一项进行中的任务,哪组顺序最合理?','[{"optionId":"A","text":"发送文件→说明截止日期→让对方有问题再问→退出任务"},{"optionId":"B","text":"说明目标和现状→移交材料与风险→明确责任和节点→确认对方理解"},{"optionId":"C","text":"口头说明主要事项→发送材料→约定一次后续检查"},{"optionId":"D","text":"把任务重新做一遍→整理文件→通知对方结果"}]','{"answerKey":"B"}','{"A":0,"B":4,"C":2,"D":1}','有效交接需要共同理解目标、现状、材料、风险、责任和检查节点,而不仅是发送文件。','MEDIUM',NULL,'ability-v0.1','SELF_AUTHORED',1,34),
('EX11',(SELECT id FROM ability_definitions WHERE type_code='WORKPLACE_ABILITY'),'EXECUTION','DELIVERY_REVIEW','SITUATIONAL_JUDGMENT','任务完成后结果没有达到预期。最有价值的复盘方式是?','[{"optionId":"A","text":"确定主要责任人并避免再次由其负责"},{"optionId":"B","text":"记录最终结果,提醒团队以后更认真"},{"optionId":"C","text":"比较目标与实际,分析过程证据,形成有负责人和期限的改进动作"},{"optionId":"D","text":"讨论主要问题并总结经验,供下次参考"}]','{"answerKey":"C"}','{"A":1,"B":0,"C":4,"D":2}','复盘的价值在于从证据中找到可改变因素,并把经验转成有负责人和期限的行动。','HARD',NULL,'ability-v0.1','SELF_AUTHORED',1,35),
('EX12',(SELECT id FROM ability_definitions WHERE type_code='WORKPLACE_ABILITY'),'EXECUTION','DELIVERY_REVIEW','SITUATIONAL_JUDGMENT','开发工作已经完成,怎样才算真正完成交付闭环?','[{"optionId":"A","text":"代码已经合并,可以把任务标记完成"},{"optionId":"B","text":"部署完成且没有立即出现错误"},{"optionId":"C","text":"把剩余事项记录在自己的待办中"},{"optionId":"D","text":"按验收标准确认结果,补齐文档、监控、交接和明确遗留项"}]','{"answerKey":"D"}','{"A":1,"B":2,"C":0,"D":4}','完成实现不等于完成交付;验收、文档、运行保障、交接和遗留事项都应形成闭环。','EASY',NULL,'ability-v0.1','SELF_AUTHORED',1,36),
('LE01',(SELECT id FROM ability_definitions WHERE type_code='WORKPLACE_ABILITY'),'LEARNING','INFORMATION_ACQUISITION','SITUATIONAL_JUDGMENT','第一次接触一项工作需要的新工具时,你会怎么开始?','[{"optionId":"A","text":"明确使用目标,阅读权威入门资料,完成小练习后应用到任务"},{"optionId":"B","text":"先看几个热门视频,再边做边查"},{"optionId":"C","text":"直接在正式任务中尝试,遇到问题再处理"},{"optionId":"D","text":"请熟悉工具的同事替自己完成关键部分"}]','{"answerKey":"A"}','{"A":4,"B":2,"C":1,"D":0}','目标、可靠资料、小范围练习和真实应用形成完整学习闭环,能兼顾效率和风险。','EASY',NULL,'ability-v0.1','SELF_AUTHORED',1,37),
('LE02',(SELECT id FROM ability_definitions WHERE type_code='WORKPLACE_ABILITY'),'LEARNING','INFORMATION_ACQUISITION','SITUATIONAL_JUDGMENT','网上对同一技术方案存在互相矛盾的观点。你会怎样判断?','[{"optionId":"A","text":"选择点赞和转发最多的观点"},{"optionId":"B","text":"比较来源、发布时间、证据、适用条件,并做小范围验证"},{"optionId":"C","text":"优先参考官方文档,再结合一项实践案例"},{"optionId":"D","text":"选择与自己原有经验一致的观点"}]','{"answerKey":"B"}','{"A":0,"B":4,"C":2,"D":1}','可靠判断既需要评价信息来源和适用条件,也需要在自己的场景中验证,而不能只看热度或偏好。','MEDIUM',NULL,'ability-v0.1','SELF_AUTHORED',1,38),
('LE03',(SELECT id FROM ability_definitions WHERE type_code='WORKPLACE_ABILITY'),'LEARNING','INFORMATION_ACQUISITION','SEQUENCE_JUDGMENT','快速进入一个陌生业务领域,哪组学习顺序最合理?','[{"optionId":"A","text":"收集大量资料→全部阅读→整理术语→开始参与工作"},{"optionId":"B","text":"跟随一名同事→模仿做法→遇到问题再查资料→形成经验"},{"optionId":"C","text":"明确任务问题→建立基础框架→结合案例实践→用反馈补齐知识"},{"optionId":"D","text":"阅读概览→询问关键术语→尝试一项小任务→继续自学"}]','{"answerKey":"C"}','{"A":1,"B":0,"C":4,"D":2}','围绕真实问题建立知识框架,再通过实践和反馈迭代,比无目标囤积资料更有效。','HARD',NULL,'ability-v0.1','SELF_AUTHORED',1,39),
('LE04',(SELECT id FROM ability_definitions WHERE type_code='WORKPLACE_ABILITY'),'LEARNING','INFORMATION_ACQUISITION','SITUATIONAL_JUDGMENT','你只有两小时了解一个新主题并给出初步建议。你会怎么安排?','[{"optionId":"A","text":"尽量阅读更多文章,以覆盖所有观点"},{"optionId":"B","text":"询问熟悉主题的人并直接采用其建议"},{"optionId":"C","text":"先写出自己的看法,再搜索资料验证"},{"optionId":"D","text":"明确输出问题,优先权威概览和关键证据,并标明未知与后续验证项"}]','{"answerKey":"D"}','{"A":2,"B":1,"C":0,"D":4}','时间有限时,应围绕输出目标选择高价值信息,并明确区分结论、假设和待验证内容。','MEDIUM',NULL,'ability-v0.1','SELF_AUTHORED',1,40),
('LE05',(SELECT id FROM ability_definitions WHERE type_code='WORKPLACE_ABILITY'),'LEARNING','FEEDBACK_REFLECTION','SITUATIONAL_JUDGMENT','你收到“方案不够清晰”的反馈。最有效的回应是?','[{"optionId":"A","text":"请对方指出具体难以理解之处,修改后再次确认是否解决"},{"optionId":"B","text":"详细解释自己当时的设计思路"},{"optionId":"C","text":"接受反馈,并在下一次方案中注意"},{"optionId":"D","text":"找其他同事判断这条反馈是否合理"}]','{"answerKey":"A"}','{"A":4,"B":2,"C":1,"D":0}','将模糊反馈转化为具体例子,并通过修改后的再次验证,才能真正形成可观察的学习改进。','EASY',NULL,'ability-v0.1','SELF_AUTHORED',1,41),
('LE06',(SELECT id FROM ability_definitions WHERE type_code='WORKPLACE_ABILITY'),'LEARNING','FEEDBACK_REFLECTION','SITUATIONAL_JUDGMENT','一次重要尝试失败后,你最应该优先做什么?','[{"optionId":"A","text":"立即换一种完全不同的方法重新尝试"},{"optionId":"B","text":"还原目标、过程和关键决策,区分假设错误、执行偏差与外部因素"},{"optionId":"C","text":"先记录失败现象和可能原因,再选择一个方向验证"},{"optionId":"D","text":"等待更多类似失败出现后一起复盘"}]','{"answerKey":"B"}','{"A":0,"B":4,"C":2,"D":1}','有效复盘需要还原过程并区分不同原因类型,避免在没有证据时只更换方法或延后学习。','MEDIUM',NULL,'ability-v0.1','SELF_AUTHORED',1,42),
('LE07',(SELECT id FROM ability_definitions WHERE type_code='WORKPLACE_ABILITY'),'LEARNING','FEEDBACK_REFLECTION','SITUATIONAL_JUDGMENT','怎样最能验证自己真正掌握了一项新知识?','[{"optionId":"A","text":"能够完整复述资料中的定义"},{"optionId":"B","text":"完成课程并通过课程自带的小测"},{"optionId":"C","text":"能用自己的话解释、应用到新问题,并根据结果修正理解"},{"optionId":"D","text":"尝试向别人讲解,并接受对方提问"}]','{"answerKey":"C"}','{"A":1,"B":0,"C":4,"D":2}','真正掌握不仅是记忆,还包括解释、迁移、实践和基于结果修正认知。','HARD',NULL,'ability-v0.1','SELF_AUTHORED',1,43),
('LE08',(SELECT id FROM ability_definitions WHERE type_code='WORKPLACE_ABILITY'),'LEARNING','FEEDBACK_REFLECTION','SEQUENCE_JUDGMENT','根据反馈持续改进一项技能,哪组顺序最合理?','[{"optionId":"A","text":"收集反馈→一次修改全部问题→长期练习→最终检查"},{"optionId":"B","text":"确定目标→大量练习→请人评价→记录结果"},{"optionId":"C","text":"模仿优秀示例→反复练习→比较差距→继续模仿"},{"optionId":"D","text":"设定具体目标→刻意练习→获得反馈→调整方法并再次验证"}]','{"answerKey":"D"}','{"A":2,"B":1,"C":0,"D":4}','学习闭环应围绕具体目标,通过练习、反馈、调整和再验证不断缩小差距。','HARD',NULL,'ability-v0.1','SELF_AUTHORED',1,44),
('LE09',(SELECT id FROM ability_definitions WHERE type_code='WORKPLACE_ABILITY'),'LEARNING','TRANSFER_ADAPTATION','SITUATIONAL_JUDGMENT','学会一种分析方法后,你遇到一个相似但条件不同的新问题。你会怎么做?','[{"optionId":"A","text":"识别共同原理和条件差异,调整方法并用结果验证"},{"optionId":"B","text":"先按原步骤完整执行,再根据结果修改"},{"optionId":"C","text":"重新寻找专门针对新问题的现成方法"},{"optionId":"D","text":"认为条件不同,原方法没有参考价值"}]','{"answerKey":"A"}','{"A":4,"B":2,"C":1,"D":0}','知识迁移的关键是识别稳定原理与变化条件,并通过调整和验证把旧知识应用到新场景。','EASY',NULL,'ability-v0.1','SELF_AUTHORED',1,45),
('LE10',(SELECT id FROM ability_definitions WHERE type_code='WORKPLACE_ABILITY'),'LEARNING','TRANSFER_ADAPTATION','SITUATIONAL_JUDGMENT','你遇到一个自己长时间无法解决的问题,需要向同事求助。怎样提问最有效?','[{"optionId":"A","text":"把全部材料发给对方,请对方帮忙看看"},{"optionId":"B","text":"说明目标、现象、已尝试方法、当前判断和希望获得的帮助"},{"optionId":"C","text":"整理出最小问题、错误信息和已排除项后提问"},{"optionId":"D","text":"直接请对方演示正确做法,自己跟着学习"}]','{"answerKey":"B"}','{"A":0,"B":4,"C":2,"D":1}','高质量求助能让对方快速理解上下文,也暴露自己的思考过程,从而获得可迁移的指导而非代做。','MEDIUM',NULL,'ability-v0.1','SELF_AUTHORED',1,46),
('LE11',(SELECT id FROM ability_definitions WHERE type_code='WORKPLACE_ABILITY'),'LEARNING','TRANSFER_ADAPTATION','SITUATIONAL_JUDGMENT','你熟悉的工具即将停止维护,团队需要迁移到新工具。你会怎么应对?','[{"optionId":"A","text":"继续使用旧工具,直到它真正无法运行"},{"optionId":"B","text":"立即把所有工作迁移到最热门的新工具"},{"optionId":"C","text":"比较需求和迁移成本,做小范围验证,形成学习与分阶段迁移计划"},{"optionId":"D","text":"先试用几个候选工具并记录主要差异"}]','{"answerKey":"C"}','{"A":1,"B":0,"C":4,"D":2}','面对变化应结合实际需求、成本和验证结果规划迁移,同时安排学习,避免被动拖延或盲目追新。','HARD',NULL,'ability-v0.1','SELF_AUTHORED',1,47),
('LE12',(SELECT id FROM ability_definitions WHERE type_code='WORKPLACE_ABILITY'),'LEARNING','TRANSFER_ADAPTATION','SEQUENCE_JUDGMENT','制定一项与目标岗位相关的学习计划,哪组顺序最合理?','[{"optionId":"A","text":"购买课程→安排时间→完成课程→更新简历"},{"optionId":"B","text":"收集热门技能→选择感兴趣内容→集中学习→寻找应用机会"},{"optionId":"C","text":"参考他人路线→复制学习清单→定期检查完成量→继续补课"},{"optionId":"D","text":"分析岗位要求和当前差距→设定可验证目标→练习应用→根据反馈调整"}]','{"answerKey":"D"}','{"A":2,"B":1,"C":0,"D":4}','有效学习计划从真实目标和差距出发,用可验证练习和反馈调整方向,而不是以课程数量衡量成长。','EASY',NULL,'ability-v0.1','SELF_AUTHORED',1,48);

95
mydreamjob-server/src/main/resources/db/migration/V36__risk_governance.sql

@ -0,0 +1,95 @@
-- Flyway V36: 风险概览与运营规则配置(功能版本 V37)
--
-- 仅供 ADMIN 的风险概览/趋势/分布/事件/规则/人工标记,基于既有拒绝型安全审计(audit_logs)
-- 做确定性、规则化检测。规则输入 action 仅限 REPORT_RATE_LIMITED 与 BLOCKED_SEND_PROHIBITED。
-- 全部表均为增量新增;仅在本迁移中为 audit_logs 增加风险扫描复合索引,不改任何既有语义。
-- 1) 规则配置表(scoring_rule_configs)
CREATE TABLE scoring_rule_configs (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
rule_code VARCHAR(60) NOT NULL,
source_action VARCHAR(60) NOT NULL,
threshold_count INT NOT NULL,
window_minutes INT NOT NULL,
severity VARCHAR(10) NOT NULL,
enabled TINYINT NOT NULL DEFAULT 1,
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,
CONSTRAINT uk_scoring_rule_configs_code UNIQUE (rule_code),
CONSTRAINT chk_scoring_rule_threshold CHECK (threshold_count BETWEEN 1 AND 100),
CONSTRAINT chk_scoring_rule_window CHECK (window_minutes BETWEEN 1 AND 1440),
CONSTRAINT chk_scoring_rule_severity CHECK (severity IN ('LOW', 'MEDIUM', 'HIGH')),
CONSTRAINT chk_scoring_rule_enabled CHECK (enabled IN (0, 1))
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- 2) 风险事件表(scoring_risk_events)
CREATE TABLE scoring_risk_events (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
rule_config_id BIGINT NOT NULL,
subject_user_id BIGINT NOT NULL,
source_audit_log_id BIGINT NOT NULL,
source_action VARCHAR(60) NOT NULL,
severity VARCHAR(10) NOT NULL,
window_started_at DATETIME NOT NULL,
window_ended_at DATETIME NOT NULL,
occurrence_count INT NOT NULL,
status VARCHAR(20) NOT NULL DEFAULT 'OPEN',
detected_at DATETIME NOT 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,
CONSTRAINT uk_scoring_risk_events_rule_audit UNIQUE (rule_config_id, source_audit_log_id),
CONSTRAINT fk_scoring_risk_events_rule
FOREIGN KEY (rule_config_id) REFERENCES scoring_rule_configs(id),
CONSTRAINT chk_scoring_risk_status CHECK (status IN ('OPEN', 'ACKNOWLEDGED', 'DISMISSED')),
CONSTRAINT chk_scoring_risk_severity CHECK (severity IN ('LOW', 'MEDIUM', 'HIGH')),
INDEX idx_scoring_risk_detected_status (detected_at, status),
INDEX idx_scoring_risk_severity_detected (severity, detected_at),
INDEX idx_scoring_risk_rule_detected (rule_config_id, detected_at),
INDEX idx_scoring_risk_subject_detected (subject_user_id, detected_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- 3) 风险事件人工操作(scoring_risk_actions,受控 reasonCode,无自由文本备注)
CREATE TABLE scoring_risk_actions (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
risk_event_id BIGINT NOT NULL,
operator_user_id BIGINT NOT NULL,
action VARCHAR(20) NOT NULL,
reason_code VARCHAR(40) NOT NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT fk_scoring_risk_actions_event
FOREIGN KEY (risk_event_id) REFERENCES scoring_risk_events(id) ON DELETE CASCADE,
CONSTRAINT chk_scoring_risk_action_type CHECK (action IN ('ACKNOWLEDGE', 'DISMISS')),
CONSTRAINT chk_scoring_risk_reason
CHECK (reason_code IN ('MONITORING', 'CONFIRMED_FALSE_POSITIVE', 'OUT_OF_BAND_ESCALATED')),
INDEX idx_scoring_risk_actions_event (risk_event_id, created_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- 4) 扫描游标(仅一条 checkpoint_key=RISK_EVENT_DETECTOR)
CREATE TABLE scoring_risk_scan_checkpoints (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
checkpoint_key VARCHAR(40) NOT NULL,
last_audit_log_id BIGINT NOT NULL DEFAULT 0,
version BIGINT NOT NULL DEFAULT 0,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
CONSTRAINT uk_scoring_risk_checkpoint_key UNIQUE (checkpoint_key)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
INSERT INTO scoring_risk_scan_checkpoints (checkpoint_key, last_audit_log_id) VALUES ('RISK_EVENT_DETECTOR', 0);
-- 5) 种子规则(两档初始阈值):REPORT_RATE_LIMITED 一次即 HIGH;BLOCKED_SEND_PROHIBITED 窗口 3 次即 MEDIUM
INSERT INTO scoring_rule_configs
(rule_code, source_action, threshold_count, window_minutes, severity, enabled, version)
VALUES
('REPORT_RATE_LIMIT_BURST', 'REPORT_RATE_LIMITED', 1, 60, 'HIGH', 1, 0),
('BLOCKED_MESSAGE_BURST', 'BLOCKED_SEND_PROHIBITED', 3, 15, 'MEDIUM', 1, 0);
-- 6) 仅优化风险扫描的审计复合索引:不改任何既有审计语义
CREATE INDEX idx_audit_risk_scan ON audit_logs (action, user_id, created_at, id);

210
mydreamjob-server/src/test/java/com/mydreamjob/ability/AbilityAttemptServiceTest.java

@ -0,0 +1,210 @@
package com.mydreamjob.ability;
import com.fasterxml.jackson.databind.node.JsonNodeFactory;
import com.mydreamjob.ability.dto.AbilityHistoryItem;
import com.mydreamjob.ability.dto.CreateAbilityAttemptRequest;
import com.mydreamjob.ability.dto.SaveAbilityAnswersRequest;
import com.mydreamjob.audit.AuditContext;
import com.mydreamjob.audit.AuditService;
import com.mydreamjob.auth.User;
import com.mydreamjob.auth.UserRepository;
import com.mydreamjob.common.exception.BusinessException;
import com.mydreamjob.common.exception.ErrorCode;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageImpl;
import java.time.Clock;
import java.time.Instant;
import java.time.LocalDateTime;
import java.time.ZoneId;
import java.time.ZoneOffset;
import java.util.ArrayList;
import java.util.List;
import java.util.Optional;
import java.util.Set;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyList;
import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
class AbilityAttemptServiceTest {
@Mock private AbilityDefinitionRepository definitionRepository;
@Mock private AbilityQuestionRepository questionRepository;
@Mock private AbilityAttemptRepository attemptRepository;
@Mock private AbilityAttemptQuestionRepository attemptQuestionRepository;
@Mock private AbilityAnswerRepository answerRepository;
@Mock private AbilityResultRepository resultRepository;
@Mock private AbilityResultService resultService;
@Mock private AbilityResultComputer resultComputer;
@Mock private AbilityAttemptWriter attemptWriter;
@Mock private AbilitySnapshotCodec snapshotCodec;
@Mock private UserRepository userRepository;
@Mock private AuditService auditService;
@Mock private Clock clock;
@InjectMocks private AbilityAttemptService service;
private final AuditContext ctx = AuditContext.builder().requestId("r").build();
@BeforeEach
void time() {
org.mockito.Mockito.lenient().when(clock.getZone()).thenReturn(ZoneId.systemDefault());
org.mockito.Mockito.lenient().when(clock.instant()).thenReturn(Instant.parse("2026-09-03T08:00:00Z"));
}
private User student() {
return User.builder().id(77L).phone("p").role(User.Role.STUDENT)
.roles(Set.of(User.Role.STUDENT)).build();
}
private AbilityDefinition activeDef(int maxCompleted) {
return AbilityDefinition.builder().id(1L).typeCode("WORKPLACE_ABILITY").status("ACTIVE")
.attemptQuestionCount(24).questionsPerDimension(6).timeLimitMinutes(30)
.resultValidDays(7).retakeWindowDays(90).retakeMaxCompleted(maxCompleted)
.questionSetVersion("ability-v0.1").rulesVersion("ability-scoring-v0.1").build();
}
@Test
void create_typeNotFound_whenDefinitionMissing() {
when(userRepository.findById(77L)).thenReturn(Optional.of(student()));
when(definitionRepository.findByTypeCode(anyString())).thenReturn(Optional.empty());
BusinessException ex = assertThrows(BusinessException.class,
() -> service.create(77L, newReq(), ctx));
assertEquals(ErrorCode.ABILITY_TYPE_NOT_FOUND.getCode(), ex.getErrorCodeInt());
}
@Test
void create_activeConflict_returns9808() {
when(userRepository.findById(77L)).thenReturn(Optional.of(student()));
when(definitionRepository.findByTypeCode(anyString()))
.thenReturn(Optional.of(activeDef(3)));
when(attemptRepository.findTimedOutActiveByUserAndDef(any(), any(), any())).thenReturn(List.of());
when(attemptRepository.existsByStudentUserIdAndDefinitionIdAndStatusIn(any(), any(), any()))
.thenReturn(true);
BusinessException ex = assertThrows(BusinessException.class,
() -> service.create(77L, newReq(), ctx));
assertEquals(ErrorCode.ABILITY_DUPLICATE_ACTIVE.getCode(), ex.getErrorCodeInt());
}
@Test
void create_retakeLimit_returns9811() {
when(userRepository.findById(77L)).thenReturn(Optional.of(student()));
when(definitionRepository.findByTypeCode(anyString()))
.thenReturn(Optional.of(activeDef(1)));
when(attemptRepository.findTimedOutActiveByUserAndDef(any(), any(), any())).thenReturn(List.of());
when(attemptRepository.existsByStudentUserIdAndDefinitionIdAndStatusIn(any(), any(), any()))
.thenReturn(false);
when(attemptRepository.findByStudentUserIdAndDefinitionIdAndStatusOrderByCompletedAtDesc(any(), any(), anyString()))
.thenReturn(List.of());
when(attemptRepository.countByStudentUserIdAndDefinitionIdAndStatusAndCompletedAtGreaterThanEqual(
any(), any(), anyString(), any())).thenReturn(1);
BusinessException ex = assertThrows(BusinessException.class,
() -> service.create(77L, newReq(), ctx));
assertEquals(ErrorCode.ABILITY_RETAKE_LIMIT.getCode(), ex.getErrorCodeInt());
}
@Test
void submit_perDimensionCheck_queriesAnswersOnce_notPerQuestion() {
when(userRepository.findById(77L)).thenReturn(Optional.of(student()));
when(definitionRepository.findById(1L)).thenReturn(Optional.of(activeDef(3)));
AbilityAttempt attempt = AbilityAttempt.builder().id(9L).studentUserId(77L).definitionId(1L)
.status(AbilityAttempt.STATUS_IN_PROGRESS).questionSetVersion("ability-v0.1")
.expiresAt(LocalDateTime.now(clock).plusMinutes(30)).build();
when(attemptRepository.findById(9L)).thenReturn(Optional.of(attempt));
when(answerRepository.countByAttemptId(9L)).thenReturn(24L);
List<AbilityAttemptQuestion> aqs = new ArrayList<>();
for (int i = 0; i < 24; i++) {
aqs.add(AbilityAttemptQuestion.builder().id(1000L + i).attemptId(9L).position(i + 1).build());
}
when(attemptQuestionRepository.findByAttemptIdOrderByPositionAsc(9L)).thenReturn(aqs);
// 空答案 → 四维未答满 → 9806;借此验证 perDimensionAnswered 只批量取一次答案、不逐题查询
when(answerRepository.findByAttemptId(9L)).thenReturn(List.of());
BusinessException ex = assertThrows(BusinessException.class,
() -> service.submit(77L, 9L, ctx));
assertEquals(ErrorCode.ABILITY_INCOMPLETE.getCode(), ex.getErrorCodeInt());
verify(answerRepository, times(1)).findByAttemptId(anyLong());
}
@Test
void saveAnswers_24Questions_batchLoadOnce_saveAllOnce() {
when(userRepository.findById(77L)).thenReturn(Optional.of(student()));
AbilityAttempt attempt = AbilityAttempt.builder().id(9L).studentUserId(77L).definitionId(1L)
.status(AbilityAttempt.STATUS_IN_PROGRESS).questionSetVersion("ability-v0.1")
.expiresAt(LocalDateTime.now(clock).plusMinutes(30)).build();
when(attemptRepository.findById(9L)).thenReturn(Optional.of(attempt));
when(definitionRepository.findById(1L)).thenReturn(Optional.of(activeDef(3)));
List<AbilityAttemptQuestion> aqs = new ArrayList<>();
for (int i = 0; i < 24; i++) {
aqs.add(AbilityAttemptQuestion.builder().id(1000L + i).attemptId(9L).position(i + 1).build());
}
when(attemptQuestionRepository.findByAttemptIdOrderByPositionAsc(9L)).thenReturn(aqs);
when(snapshotCodec.optionExists(any(), any())).thenReturn(true);
when(snapshotCodec.awardedScore(any(), any())).thenReturn(4);
when(snapshotCodec.parse(any())).thenReturn(JsonNodeFactory.instance.objectNode());
// 部分题目已存在答案(模拟混合 upsert 的既有数据路径)
when(answerRepository.findByAttemptIdAndAttemptQuestionIdIn(anyLong(), anyList()))
.thenReturn(List.of(AbilityAnswer.builder().id(9L).attemptId(9L)
.attemptQuestionId(1000L).selectedOptionId("A").awardedScore(0).build()));
when(answerRepository.countByAttemptId(9L)).thenReturn(24L);
when(answerRepository.findByAttemptId(9L)).thenReturn(List.of());
when(attemptRepository.saveAndFlush(any())).thenReturn(attempt);
SaveAbilityAnswersRequest req = new SaveAbilityAnswersRequest();
List<SaveAbilityAnswersRequest.AnswerItem> items = new ArrayList<>();
for (int i = 0; i < 24; i++) {
SaveAbilityAnswersRequest.AnswerItem it = new SaveAbilityAnswersRequest.AnswerItem();
it.setAttemptQuestionId(1000L + i);
it.setSelectedOptionId("A");
items.add(it);
}
req.setAnswers(items);
service.saveAnswers(77L, 9L, req, ctx);
// 24 题整体保存只触发一次批量查询与一次批量持久化;单题查询方法已删除,结构上不可能逐题查询
verify(answerRepository, times(1)).findByAttemptIdAndAttemptQuestionIdIn(anyLong(), anyList());
verify(answerRepository, times(1)).saveAll(any());
}
@Test
void listMine_loadsDefinitionsInBatch_notPerItem() {
when(userRepository.findById(77L)).thenReturn(Optional.of(student()));
AbilityAttempt a1 = AbilityAttempt.builder().id(1L).studentUserId(77L).definitionId(1L)
.status(AbilityAttempt.STATUS_COMPLETED).questionSetVersion("ability-v0.1").build();
AbilityAttempt a2 = AbilityAttempt.builder().id(2L).studentUserId(77L).definitionId(2L)
.status(AbilityAttempt.STATUS_COMPLETED).questionSetVersion("ability-v0.1").build();
when(attemptRepository.findByStudentUserIdOrderByCreatedAtDesc(any(), any()))
.thenReturn(new PageImpl<>(List.of(a1, a2)));
when(resultRepository.findByAttemptIdIn(any())).thenReturn(List.of());
when(definitionRepository.findAllById(any())).thenReturn(List.of());
Page<AbilityHistoryItem> page = service.listMine(77L, 0, 20);
assertEquals(2, page.getContent().size());
verify(definitionRepository, never()).findById(anyLong());
verify(definitionRepository, times(1)).findAllById(any());
}
private CreateAbilityAttemptRequest newReq() {
CreateAbilityAttemptRequest r = new CreateAbilityAttemptRequest();
r.setTypeCode("WORKPLACE_ABILITY");
return r;
}
}

39
mydreamjob-server/src/test/java/com/mydreamjob/ability/AbilityCatalogServiceTest.java

@ -0,0 +1,39 @@
package com.mydreamjob.ability;
import com.mydreamjob.ability.dto.AbilityTypeResponse;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import java.util.List;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
class AbilityCatalogServiceTest {
@Mock private AbilityDefinitionRepository definitionRepository;
@InjectMocks private AbilityCatalogService catalogService;
@Test
void listTypes_returnsActiveDefinitionsMapped() {
AbilityDefinition def = AbilityDefinition.builder()
.id(1L).typeCode("WORKPLACE_ABILITY").displayName("四维能力测评")
.description("desc").status("ACTIVE").questionCount(48).attemptQuestionCount(24)
.questionsPerDimension(6).timeLimitMinutes(30).resultValidDays(7).build();
when(definitionRepository.findAllByStatusOrderByCreatedAtAsc("ACTIVE"))
.thenReturn(List.of(def));
List<AbilityTypeResponse> types = catalogService.listTypes();
assertEquals(1, types.size());
AbilityTypeResponse t = types.get(0);
assertEquals("WORKPLACE_ABILITY", t.getTypeCode());
assertEquals(48, t.getQuestionCount());
assertEquals(24, t.getAttemptQuestionCount());
assertEquals(30, t.getTimeLimitMinutes());
assertEquals(7, t.getResultValidDays());
}
}

163
mydreamjob-server/src/test/java/com/mydreamjob/ability/AbilityControllerTest.java

@ -0,0 +1,163 @@
package com.mydreamjob.ability;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.mydreamjob.auth.JwtTokenProvider;
import com.mydreamjob.auth.User;
import com.mydreamjob.auth.UserRepository;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.core.ValueOperations;
import org.springframework.http.MediaType;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.test.web.servlet.MockMvc;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.atomic.AtomicLong;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
@SpringBootTest
@AutoConfigureMockMvc
@ActiveProfiles("integration-test")
class AbilityControllerTest {
private static final AtomicLong PHONE = new AtomicLong(6000);
@Autowired private MockMvc mockMvc;
@Autowired private ObjectMapper objectMapper;
@Autowired private JwtTokenProvider jwtTokenProvider;
@Autowired private UserRepository userRepository;
@MockBean private RedisTemplate<String, Object> redisTemplate;
private Long studentUserId;
private Long companyUserId;
private Long adminUserId;
@BeforeEach
@SuppressWarnings("unchecked")
void setUp() {
ValueOperations<String, Object> ops = mock(ValueOperations.class);
when(redisTemplate.opsForValue()).thenReturn(ops);
when(redisTemplate.hasKey(any())).thenReturn(false);
studentUserId = createUser(User.Role.STUDENT);
companyUserId = createUser(User.Role.COMPANY);
adminUserId = createUser(User.Role.ADMIN);
}
@AfterEach
void tearDown() {
userRepository.deleteById(adminUserId);
userRepository.deleteById(companyUserId);
userRepository.deleteById(studentUserId);
}
private Long createUser(User.Role role) {
User u = userRepository.save(User.builder()
.phone("17" + String.format("%08d", PHONE.incrementAndGet())).nickname("u")
.role(role).roles(Set.of(role)).active(true).verified(true).build());
return u.getId();
}
private String token(Long userId, User.Role role) {
return "Bearer " + jwtTokenProvider.generateAccessToken(userId,
String.valueOf(userId), Map.of("roles", role.name()));
}
@Test
void anonymous_create_returns401() throws Exception {
mockMvc.perform(post("/abilities/attempts").contentType(MediaType.APPLICATION_JSON)
.content("{\"typeCode\":\"WORKPLACE_ABILITY\"}")).andExpect(status().isUnauthorized());
}
@Test
void studentListTypes_returns200() throws Exception {
mockMvc.perform(get("/abilities/types").header("Authorization", token(studentUserId, User.Role.STUDENT)))
.andExpect(status().isOk())
.andExpect(jsonPath("$.data.[0].typeCode").value("WORKPLACE_ABILITY"));
}
@Test
void companyAndAdmin_create_returns403() throws Exception {
mockMvc.perform(post("/abilities/attempts").header("Authorization", token(companyUserId, User.Role.COMPANY))
.contentType(MediaType.APPLICATION_JSON).content("{\"typeCode\":\"WORKPLACE_ABILITY\"}"))
.andExpect(status().isForbidden());
mockMvc.perform(post("/abilities/attempts").header("Authorization", token(adminUserId, User.Role.ADMIN))
.contentType(MediaType.APPLICATION_JSON).content("{\"typeCode\":\"WORKPLACE_ABILITY\"}"))
.andExpect(status().isForbidden());
}
@Test
void fullStudentFlow_createSaveSubmitResult_repeatIdempotent_noSensitiveLeak() throws Exception {
String tk = token(studentUserId, User.Role.STUDENT);
JsonNode create = objectMapper.readTree(mockMvc.perform(post("/abilities/attempts")
.header("Authorization", tk).contentType(MediaType.APPLICATION_JSON)
.content("{\"typeCode\":\"WORKPLACE_ABILITY\",\"idempotencyKey\":\"http-key-1\"}"))
.andExpect(status().isOk())
.andReturn().getResponse().getContentAsString());
long attemptId = create.path("data").path("attemptId").asLong();
assertEquals(24, create.path("data").path("questions").size());
List<Map<String, Object>> answers = new ArrayList<>();
for (JsonNode q : create.path("data").path("questions")) {
answers.add(Map.of("attemptQuestionId", q.path("attemptQuestionId").asLong(),
"selectedOptionId", "A"));
}
String body = objectMapper.writeValueAsString(Map.of("answers", answers));
mockMvc.perform(patch("/abilities/attempts/{id}/answers", attemptId)
.header("Authorization", tk).contentType(MediaType.APPLICATION_JSON).content(body))
.andExpect(status().isOk())
.andExpect(jsonPath("$.data.answeredCount").value(24));
JsonNode submitted = objectMapper.readTree(mockMvc.perform(post("/abilities/attempts/{id}/submit", attemptId)
.header("Authorization", tk))
.andExpect(status().isOk())
.andExpect(jsonPath("$.data.status").value("COMPLETED"))
.andReturn().getResponse().getContentAsString());
// 结果脱敏:完整 result JSON 不得包含题库内部字段
String submitedRaw = submitted.toString();
assertFalse(submitedRaw.contains("studentUserId"));
assertFalse(submitedRaw.contains("phone"));
assertFalse(submitedRaw.contains("email"));
assertFalse(submitedRaw.contains("answerKey"), "result must not contain answerKey");
assertFalse(submitedRaw.contains("scoringRule"), "result must not contain scoringRule");
assertFalse(submitedRaw.contains("questionCode"), "result must not contain questionCode");
assertFalse(submitedRaw.contains("competencyTag"), "result must not contain competencyTag");
assertFalse(submitedRaw.contains("source"), "result must not contain source");
assertFalse(submitedRaw.contains("difficulty"), "result must not contain difficulty");
assertFalse(submitedRaw.contains("discrimination"), "result must not contain discrimination");
assertTrue(submitedRaw.contains("disclaimer"));
assertEquals(4, submitted.path("data").path("dimensionScores").size());
// 重复提交幂等:同一综合分
JsonNode resub = objectMapper.readTree(mockMvc.perform(post("/abilities/attempts/{id}/submit", attemptId)
.header("Authorization", tk)).andExpect(status().isOk())
.andReturn().getResponse().getContentAsString());
assertEquals(submitted.path("data").path("overallScore").asText(),
resub.path("data").path("overallScore").asText());
// GET result 200
mockMvc.perform(get("/abilities/attempts/{id}/result", attemptId).header("Authorization", tk))
.andExpect(status().isOk())
.andExpect(jsonPath("$.data.validityStatus").value("VALID"));
}
}

391
mydreamjob-server/src/test/java/com/mydreamjob/ability/AbilityPersistenceIntegrationTest.java

@ -0,0 +1,391 @@
package com.mydreamjob.ability;
import com.mydreamjob.ability.dto.AbilityAttemptResponse;
import com.mydreamjob.ability.dto.CreateAbilityAttemptRequest;
import com.mydreamjob.ability.dto.SaveAbilityAnswersRequest;
import com.mydreamjob.audit.AuditContext;
import com.mydreamjob.audit.AuditLogRepository;
import com.mydreamjob.auth.User;
import com.mydreamjob.auth.UserRepository;
import com.mydreamjob.candidatescore.CandidateDimensionScoreRepository;
import com.mydreamjob.common.exception.BusinessException;
import com.mydreamjob.common.exception.ErrorCode;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.test.context.ActiveProfiles;
import java.time.Clock;
import java.time.Instant;
import java.time.LocalDateTime;
import java.time.ZoneId;
import java.util.ArrayList;
import java.util.List;
import java.util.Set;
import java.util.concurrent.ConcurrentLinkedQueue;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicLong;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
@SpringBootTest
@ActiveProfiles("integration-test")
class AbilityPersistenceIntegrationTest {
private static final AtomicLong PHONE = new AtomicLong(5000);
private LocalDateTime base = LocalDateTime.of(2026, 9, 3, 8, 0, 0);
@Autowired private AbilityAttemptService attemptService;
@Autowired private AbilityDefinitionRepository definitionRepository;
@Autowired private AbilityAttemptRepository attemptRepository;
@Autowired private AbilityAttemptQuestionRepository attemptQuestionRepository;
@Autowired private AbilityAnswerRepository answerRepository;
@Autowired private AbilityResultRepository resultRepository;
@Autowired private UserRepository userRepository;
@Autowired private CandidateDimensionScoreRepository dimensionScoreRepository;
@Autowired private AuditLogRepository auditLogRepository;
@MockBean private RedisTemplate<String, Object> redisTemplate;
@MockBean private Clock clock;
private final AuditContext ctx = AuditContext.builder().requestId("req").build();
private final List<Long> createdUsers = new ArrayList<>();
@BeforeEach
void setUp() {
when(redisTemplate.opsForValue()).thenReturn(mock(org.springframework.data.redis.core.ValueOperations.class));
when(redisTemplate.hasKey(any())).thenReturn(false);
time(base);
}
@AfterEach
void tearDown() {
for (Long id : createdUsers) {
userRepository.deleteById(id);
}
createdUsers.clear();
}
private void time(LocalDateTime t) {
when(clock.getZone()).thenReturn(ZoneId.of("Asia/Shanghai"));
when(clock.instant()).thenReturn(t.atZone(ZoneId.of("Asia/Shanghai")).toInstant());
}
private Long createUser() {
User u = userRepository.save(User.builder()
.phone("18" + String.format("%08d", PHONE.incrementAndGet())).nickname("u")
.role(User.Role.STUDENT).roles(Set.of(User.Role.STUDENT)).active(true).verified(true).build());
createdUsers.add(u.getId());
return u.getId();
}
private CreateAbilityAttemptRequest createReq(String key) {
CreateAbilityAttemptRequest r = new CreateAbilityAttemptRequest();
r.setTypeCode("WORKPLACE_ABILITY");
r.setIdempotencyKey(key);
return r;
}
private SaveAbilityAnswersRequest answers(AbilityAttemptResponse resp, int start, int end, String option) {
List<SaveAbilityAnswersRequest.AnswerItem> items = new ArrayList<>();
for (int i = start; i < end && i < resp.getQuestions().size(); i++) {
var q = resp.getQuestions().get(i);
SaveAbilityAnswersRequest.AnswerItem it = new SaveAbilityAnswersRequest.AnswerItem();
it.setAttemptQuestionId(q.getAttemptQuestionId());
it.setSelectedOptionId(option);
items.add(it);
}
SaveAbilityAnswersRequest r = new SaveAbilityAnswersRequest();
r.setAnswers(items);
return r;
}
@Test
void create_returns24QuestionsFourDimsSixEach_andNoLeakyFields() {
Long u = createUser();
AbilityAttemptResponse resp = attemptService.create(u, createReq("k1"), ctx);
assertEquals(24, resp.getQuestions().size());
assertEquals(24, resp.getTotalQuestions());
assertEquals("DRAFT", resp.getStatus());
assertNull(resp.getStartedAt());
assertEquals(24, attemptQuestionRepository.countByAttemptId(resp.getAttemptId()));
resp.getQuestions().forEach(q -> {
assertNotNull(q.getAttemptQuestionId());
assertNotNull(q.getStem());
assertEquals(4, q.getOptions().size());
});
}
@Test
void attemptResponse_hasNoAnswerOrScoringExposed() {
Long u = createUser();
AbilityAttemptResponse resp = attemptService.create(u, createReq("k2"), ctx);
String json = resp.getQuestions().toString();
assertFalse(json.contains("answerKey"));
assertFalse(json.contains("scoringRule"));
assertFalse(json.contains("awardedScore"));
}
@Test
void idempotentSameKey_returnsSameAttempt() {
Long u = createUser();
AbilityAttemptResponse a = attemptService.create(u, createReq("idem1"), ctx);
AbilityAttemptResponse b = attemptService.create(u, createReq("idem1"), ctx);
assertEquals(a.getAttemptId(), b.getAttemptId());
}
@Test
void activeDuplicate_returns9808() {
Long u = createUser();
attemptService.create(u, createReq("act1"), ctx);
BusinessException ex = assertThrows(BusinessException.class,
() -> attemptService.create(u, createReq("act2"), ctx));
assertEquals(ErrorCode.ABILITY_DUPLICATE_ACTIVE.getCode(), ex.getErrorCodeInt());
}
@Test
void firstSave_startsTimer_subsequentSaveDoesNotExtend() {
Long u = createUser();
AbilityAttemptResponse resp = attemptService.create(u, createReq("t1"), ctx);
attemptService.saveAnswers(u, resp.getAttemptId(), answers(resp, 0, 1, "B"), ctx);
var a = attemptRepository.findById(resp.getAttemptId()).get();
assertEquals("IN_PROGRESS", a.getStatus());
assertEquals(base, a.getStartedAt().withNano(0).withSecond(0));
assertEquals(base.plusMinutes(30), a.getExpiresAt().withNano(0).withSecond(0));
time(base.plusMinutes(5));
attemptService.saveAnswers(u, resp.getAttemptId(), answers(resp, 1, 2, "C"), ctx);
var a2 = attemptRepository.findById(resp.getAttemptId()).get();
assertEquals(base.plusMinutes(30), a2.getExpiresAt().withNano(0).withSecond(0), "must not extend");
}
@Test
void saveAnswers_mixedExistingAndNew_shouldUpsert() {
Long u = createUser();
AbilityAttemptResponse resp = attemptService.create(u, createReq("upsert"), ctx);
// 先答第 0、1 题(option A)
attemptService.saveAnswers(u, resp.getAttemptId(), answers(resp, 0, 2, "A"), ctx);
Long aq0 = resp.getQuestions().get(0).getAttemptQuestionId();
Long aq2 = resp.getQuestions().get(2).getAttemptQuestionId();
Long aq3 = resp.getQuestions().get(3).getAttemptQuestionId();
// 混合保存:覆盖第 0 题(改 D) + 新增第 2、3 题(B)
SaveAbilityAnswersRequest req = new SaveAbilityAnswersRequest();
SaveAbilityAnswersRequest.AnswerItem i0 = new SaveAbilityAnswersRequest.AnswerItem();
i0.setAttemptQuestionId(aq0);
i0.setSelectedOptionId("D");
SaveAbilityAnswersRequest.AnswerItem i2 = new SaveAbilityAnswersRequest.AnswerItem();
i2.setAttemptQuestionId(aq2);
i2.setSelectedOptionId("B");
SaveAbilityAnswersRequest.AnswerItem i3 = new SaveAbilityAnswersRequest.AnswerItem();
i3.setAttemptQuestionId(aq3);
i3.setSelectedOptionId("B");
req.setAnswers(List.of(i0, i2, i3));
attemptService.saveAnswers(u, resp.getAttemptId(), req, ctx);
// 持久化断言:1 条覆盖 + 2 条新增,加上最初的第 1 题,共 4 条;覆盖题的选项已更新
List<AbilityAnswer> saved = answerRepository.findByAttemptId(resp.getAttemptId());
assertEquals(4, saved.size(), "1 overwrite + 2 insert + 1 original");
AbilityAnswer ans0 = saved.stream().filter(a -> a.getAttemptQuestionId().equals(aq0))
.findFirst().orElseThrow();
assertEquals("D", ans0.getSelectedOptionId(), "existing answer must be overwritten");
assertTrue(saved.stream().anyMatch(a -> a.getAttemptQuestionId().equals(aq2)));
assertTrue(saved.stream().anyMatch(a -> a.getAttemptQuestionId().equals(aq3)));
}
@Test
void timeout_expiresAttempt_andReleasesActiveKey() {
Long u = createUser();
AbilityAttemptResponse resp = attemptService.create(u, createReq("t2"), ctx);
attemptService.saveAnswers(u, resp.getAttemptId(), answers(resp, 0, 1, "B"), ctx);
time(base.plusMinutes(31));
AbilityAttemptResponse g = attemptService.get(u, resp.getAttemptId());
assertEquals("EXPIRED", g.getStatus());
var a = attemptRepository.findById(resp.getAttemptId()).get();
assertNull(a.getActiveKey());
}
@Test
void injection_fromAnotherAttempt_isRejected9805() {
Long u = createUser();
AbilityAttemptResponse a = attemptService.create(u, createReq("inj1"), ctx);
attemptService.saveAnswers(u, a.getAttemptId(), answers(a, 0, 24, "B"), ctx);
attemptService.submit(u, a.getAttemptId(), ctx); // 完成 a,释放 active
try {
time(base.plusDays(8)); // 让结果过期,允许重测创建 b
} catch (Exception ignored) {
}
AbilityAttemptResponse b = attemptService.create(u, createReq("inj2"), ctx);
var req = new SaveAbilityAnswersRequest();
SaveAbilityAnswersRequest.AnswerItem item = new SaveAbilityAnswersRequest.AnswerItem();
item.setAttemptQuestionId(a.getQuestions().get(0).getAttemptQuestionId()); // a 的第一题
item.setSelectedOptionId("A");
req.setAnswers(List.of(item));
BusinessException ex = assertThrows(BusinessException.class,
() -> attemptService.saveAnswers(u, b.getAttemptId(), req, ctx));
assertEquals(ErrorCode.ABILITY_ANSWER_INVALID.getCode(), ex.getErrorCodeInt());
}
@Test
void invalidOption_isRejected9805_noTimerStart() {
Long u = createUser();
AbilityAttemptResponse resp = attemptService.create(u, createReq("inv1"), ctx);
var req = new SaveAbilityAnswersRequest();
SaveAbilityAnswersRequest.AnswerItem item = new SaveAbilityAnswersRequest.AnswerItem();
item.setAttemptQuestionId(resp.getQuestions().get(0).getAttemptQuestionId());
item.setSelectedOptionId("Z");
req.setAnswers(List.of(item));
BusinessException ex = assertThrows(BusinessException.class,
() -> attemptService.saveAnswers(u, resp.getAttemptId(), req, ctx));
assertEquals(ErrorCode.ABILITY_ANSWER_INVALID.getCode(), ex.getErrorCodeInt());
var a = attemptRepository.findById(resp.getAttemptId()).get();
assertNull(a.getStartedAt(), "invalid save must not start timer");
}
@Test
void submit_23of24_returns9806() {
Long u = createUser();
AbilityAttemptResponse resp = attemptService.create(u, createReq("s1"), ctx);
attemptService.saveAnswers(u, resp.getAttemptId(), answers(resp, 0, 23, "B"), ctx);
BusinessException ex = assertThrows(BusinessException.class,
() -> attemptService.submit(u, resp.getAttemptId(), ctx));
assertEquals(ErrorCode.ABILITY_INCOMPLETE.getCode(), ex.getErrorCodeInt());
}
// 24/24 提交 → 结果;重复提交幂等;result 唯一;审计一条
@Test
void submit24_thenRepeatIdempotent_singleResultAndAudit() {
Long u = createUser();
AbilityAttemptResponse resp = attemptService.create(u, createReq("s2"), ctx);
attemptService.saveAnswers(u, resp.getAttemptId(), answers(resp, 0, 24, "B"), ctx);
var r1 = attemptService.submit(u, resp.getAttemptId(), ctx);
assertEquals("COMPLETED", r1.getStatus());
assertNotNull(r1.getOverallScore());
assertEquals(4, r1.getDimensionScores().size());
assertEquals(24, r1.getQuestionFeedback().size());
var r2 = attemptService.submit(u, resp.getAttemptId(), ctx);
assertEquals(r1.getOverallScore(), r2.getOverallScore());
assertEquals(1, countResults(resp.getAttemptId()));
assertEquals(1, countSubmitAudits(resp.getAttemptId()));
assertNotNull(r1.getImprovementAreas());
assertFalse(r1.getImprovementAreas().isEmpty());
}
@Test
void completedResult_triggersRetakeCooldown_9810() {
Long u = createUser();
AbilityAttemptResponse resp = attemptService.create(u, createReq("c1"), ctx);
attemptService.saveAnswers(u, resp.getAttemptId(), answers(resp, 0, 24, "A"), ctx);
attemptService.submit(u, resp.getAttemptId(), ctx);
BusinessException ex = assertThrows(BusinessException.class,
() -> attemptService.create(u, createReq("c2"), ctx));
assertEquals(ErrorCode.ABILITY_RETAKE_COOLDOWN.getCode(), ex.getErrorCodeInt());
}
@Test
void concurrentCreate_onlyOneSuccess_others9808() throws Exception {
Long u = createUser();
int n = 4;
ExecutorService pool = Executors.newFixedThreadPool(n);
CountDownLatch start = new CountDownLatch(1);
CountDownLatch done = new CountDownLatch(n);
ConcurrentLinkedQueue<Integer> results = new ConcurrentLinkedQueue<>();
for (int i = 0; i < n; i++) {
final String key = "cc-" + u + "-" + i;
pool.submit(() -> {
await(start);
try {
attemptService.create(u, createReq(key), ctx);
results.add(0);
} catch (BusinessException be) {
results.add(be.getErrorCodeInt());
} catch (Exception e) {
results.add(-1);
} finally {
done.countDown();
}
});
}
start.countDown();
assertTrue(done.await(30, TimeUnit.SECONDS));
pool.shutdown();
assertEquals(1, results.stream().filter(c -> c == 0).count(), "exactly one success");
assertEquals(n - 1, results.stream().filter(c -> c == ErrorCode.ABILITY_DUPLICATE_ACTIVE.getCode()).count());
assertEquals(0, results.stream().filter(c -> c == -1).count(), "no 500");
assertTrue(attemptRepository.findByStudentUserIdOrderByCreatedAtDesc(u,
org.springframework.data.domain.PageRequest.of(0, 50)).getContent().stream()
.filter(a -> a.getActiveKey() != null).count() == 1, "one active_key");
}
@Test
void concurrentSubmit_singleResultAndAudit_no500() throws Exception {
Long u = createUser();
AbilityAttemptResponse resp = attemptService.create(u, createReq("cs"), ctx);
attemptService.saveAnswers(u, resp.getAttemptId(), answers(resp, 0, 24, "B"), ctx);
int n = 2;
ExecutorService pool = Executors.newFixedThreadPool(n);
CountDownLatch start = new CountDownLatch(1);
CountDownLatch done = new CountDownLatch(n);
ConcurrentLinkedQueue<Integer> codes = new ConcurrentLinkedQueue<>();
for (int i = 0; i < n; i++) {
pool.submit(() -> {
await(start);
try {
attemptService.submit(u, resp.getAttemptId(), ctx);
codes.add(0);
} catch (Exception e) {
codes.add(-1);
} finally {
done.countDown();
}
});
}
start.countDown();
assertTrue(done.await(30, TimeUnit.SECONDS));
pool.shutdown();
assertEquals(0, codes.stream().filter(c -> c < 0).count(), "no 500");
assertEquals(1, countResults(resp.getAttemptId()));
assertEquals(1, countSubmitAudits(resp.getAttemptId()));
}
@Test
void candidateDimensionScores_unchanged() {
long before = dimensionScoreRepository.count();
Long u = createUser();
AbilityAttemptResponse resp = attemptService.create(u, createReq("cds"), ctx);
attemptService.saveAnswers(u, resp.getAttemptId(), answers(resp, 0, 24, "B"), ctx);
attemptService.submit(u, resp.getAttemptId(), ctx);
assertEquals(before, dimensionScoreRepository.count(), "must not write candidate_dimension_scores");
}
private void await(CountDownLatch latch) {
try {
latch.await();
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
private long countResults(Long attemptId) {
return resultRepository.findByAttemptId(attemptId).isPresent() ? 1 : 0;
}
private long countSubmitAudits(Long attemptId) {
return auditLogRepository.findAll().stream()
.filter(l -> "ABILITY_ATTEMPT_SUBMITTED".equals(l.getAction())
&& Long.toString(attemptId).equals(String.valueOf(l.getEntityId())))
.count();
}
}

128
mydreamjob-server/src/test/java/com/mydreamjob/ability/AbilityQuestionBankTest.java

@ -0,0 +1,128 @@
package com.mydreamjob.ability;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.core.io.ClassPathResource;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.test.context.ActiveProfiles;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import static org.junit.jupiter.api.Assertions.*;
/**
* 题库对账测试(V36):把数据库迁移后的 ability_questions 与权威 JSON 逐题对账,
* 并校验总数 48 / 四维各 12 / 12 个 competencyTag 各 4(通过各维 3 标签×4)/
* 最优答案 A/B/C/D 各 12(四维各 12)/ position 1-48 唯一 / 每题 {0,1,2,4} / answerKey=4 分 / explanation 非空 /
* questionVersion / source / active。
*/
@SpringBootTest
@ActiveProfiles("integration-test")
class AbilityQuestionBankTest {
@Autowired private AbilityQuestionRepository questionRepository;
@Autowired private AbilityDefinitionRepository definitionRepository;
@Autowired private ObjectMapper objectMapper;
@MockBean private RedisTemplate<String, Object> redisTemplate;
private JsonNode bank;
@BeforeEach
void loadBank() throws Exception {
bank = objectMapper.readTree(new ClassPathResource("ability-question-bank-v0.1.json").getInputStream());
}
@Test
void bankReconciledWithDatabase_andDistributionsHold() throws Exception {
Long defId = definitionRepository.findByTypeCode("WORKPLACE_ABILITY").orElseThrow().getId();
List<AbilityQuestion> rows = questionRepository.findAllByDefinitionIdOrderByPositionAsc(defId);
assertEquals(48, rows.size(), "total questions");
// 权威 JSON
JsonNode qs = bank.path("questions");
assertEquals(48, qs.size());
// DB 侧分布
Map<String, Integer> dimCount = new HashMap<>();
Map<String, Integer> tagCount = new HashMap<>();
Map<String, Integer> bestCount = new HashMap<>();
Set<Integer> positions = new HashSet<>();
Map<Integer, AbilityQuestion> byPos = new HashMap<>();
for (AbilityQuestion q : rows) {
dimCount.merge(q.getDimension(), 1, Integer::sum);
tagCount.merge(q.getCompetencyTag(), 1, Integer::sum);
positions.add(q.getPosition());
byPos.put(q.getPosition(), q);
JsonNode rule = objectMapper.readTree(q.getScoringRuleJson());
for (String opt : List.of("A", "B", "C", "D")) {
rule.get(opt);
}
bestCount.merge(objectMapper.readTree(q.getAnswerKeyJson()).path("answerKey").asText(), 1, Integer::sum);
}
for (String d : List.of("LOGIC", "COMMUNICATION", "EXECUTION", "LEARNING")) {
assertEquals(12, dimCount.getOrDefault(d, 0), "dim " + d);
}
assertEquals(12, tagCount.size(), "competencyTag count");
tagCount.values().forEach(c -> assertEquals(4, c));
for (String l : List.of("A", "B", "C", "D")) {
assertEquals(12, bestCount.getOrDefault(l, 0), "best " + l);
}
assertEquals(48, positions.size(), "positions unique");
assertEquals(48, positions.stream().mapToInt(Integer::intValue).max().orElse(0), "max position");
// 逐题对账
for (JsonNode aqJson : qs) {
int pos = aqJson.path("position").asInt();
AbilityQuestion db = byPos.get(pos);
assertNotNull(db, "missing position " + pos);
assertEquals(aqJson.path("questionCode").asText(), db.getQuestionCode());
assertEquals(aqJson.path("dimension").asText(), db.getDimension());
assertEquals(aqJson.path("competencyTag").asText(), db.getCompetencyTag());
assertEquals(aqJson.path("itemType").asText(), db.getItemType());
assertEquals(aqJson.path("stem").asText(), db.getStem());
assertEquals(aqJson.path("difficulty").asText(), db.getDifficulty());
assertEquals("ability-v0.1", db.getQuestionVersion());
assertEquals("SELF_AUTHORED", db.getSource());
assertTrue(db.getActive());
assertTrue(db.getExplanation() != null && !db.getExplanation().isBlank());
// options 逐项一致
JsonNode optJson = aqJson.path("options");
JsonNode optDb = objectMapper.readTree(db.getOptionsJson());
assertEquals(optJson.size(), optDb.size(), pos + " options size");
for (int i = 0; i < optJson.size(); i++) {
assertEquals(optJson.get(i).path("optionId").asText(), optDb.get(i).path("optionId").asText());
assertEquals(optJson.get(i).path("text").asText(), optDb.get(i).path("text").asText());
}
// scoringRule 与 answerKey 一致
JsonNode ruleJson = aqJson.path("scoringRule");
JsonNode ruleDb = objectMapper.readTree(db.getScoringRuleJson());
Set<Integer> scoreSet = new HashSet<>();
for (String opt : List.of("A", "B", "C", "D")) {
assertEquals(ruleJson.get(opt).asInt(), ruleDb.get(opt).asInt(), pos + " score " + opt);
scoreSet.add(ruleDb.get(opt).asInt());
}
assertEquals(Set.of(0, 1, 2, 4), scoreSet, pos + " scoring set");
assertEquals(aqJson.path("answerKey").asText(),
objectMapper.readTree(db.getAnswerKeyJson()).path("answerKey").asText());
// answerKey 对应的分值必须为 4
assertEquals(4, ruleDb.get(aqJson.path("answerKey").asText()).asInt(), pos + " answerKey not 4");
assertEquals(aqJson.path("explanation").asText(), db.getExplanation());
}
}
@AfterEach
void reset() {
}
}

111
mydreamjob-server/src/test/java/com/mydreamjob/ability/AbilityResultComputerTest.java

@ -0,0 +1,111 @@
package com.mydreamjob.ability;
import com.mydreamjob.ability.dto.AbilityResultResponse;
import org.junit.jupiter.api.Test;
import java.math.BigDecimal;
import java.util.ArrayList;
import java.util.List;
import static org.junit.jupiter.api.Assertions.*;
class AbilityResultComputerTest {
private final AbilityResultComputer computer = new AbilityResultComputer();
private List<AbilityResultComputer.Answered> items(int[] perDimScore, String[] dims,
String[][] tags) {
List<AbilityResultComputer.Answered> out = new ArrayList<>();
long aid = 0;
for (int d = 0; d < dims.length; d++) {
int[] scores = java.util.Arrays.copyOf(perDimScore, 6);
for (int i = 0; i < 6; i++) {
out.add(new AbilityResultComputer.Answered(dims[d], tags[d][i % tags[d].length],
"stem-" + dims[d] + i, "exp-" + dims[d] + i, scores[i], ++aid, d * 6 + i + 1, "B"));
}
}
return out;
}
@Test
void computesDimensionsOverallWeaknessImprovementRadarFeedback() {
int[] log = {4, 4, 4, 4, 4, 4}; // 100
int[] com = {2, 2, 2, 2, 2, 2}; // 50
int[] exe = {1, 1, 1, 1, 1, 1}; // 25
int[] lea = {0, 0, 0, 0, 0, 0}; // 0
String[] dims = {"LOGIC", "COMMUNICATION", "EXECUTION", "LEARNING"};
String[][] tags = {
{"INFORMATION_ANALYSIS", "CAUSAL_REASONING", "PROBLEM_SOLVING"},
{"LISTENING_UNDERSTANDING", "STRUCTURED_EXPRESSION", "COLLABORATION_CONFLICT"},
{"PLANNING_PRIORITIZATION", "RISK_FOLLOWUP", "DELIVERY_REVIEW"},
{"INFORMATION_ACQUISITION", "FEEDBACK_REFLECTION", "TRANSFER_ADAPTATION"}};
List<AbilityResultComputer.Answered> list = new ArrayList<>();
list.addAll(items(log, dims, tags)); // default perDimScore arrays reused? recompute below
list = new ArrayList<>();
int row = 0;
List<AbilityResultComputer.Answered> built = new ArrayList<>();
for (int d = 0; d < 4; d++) {
int base = switch (d) {
case 0 -> 4;
case 1 -> 2;
case 2 -> 1;
default -> 0;
};
for (int i = 0; i < 6; i++) {
built.add(new AbilityResultComputer.Answered(dims[d], tags[d][i % 3],
"s" + row, "e" + row, base, ++row, row, "A"));
}
}
var computed = computer.compute(built, 6);
assertEquals(4, computed.dimensionScores().size());
assertEquals(new BigDecimal("100.00"), score(computed, "LOGIC"));
assertEquals(new BigDecimal("50.00"), score(computed, "COMMUNICATION"));
assertEquals(new BigDecimal("25.00"), score(computed, "EXECUTION"));
assertEquals(new BigDecimal("0.00"), score(computed, "LEARNING"));
assertEquals(new BigDecimal("43.75"), computed.overallScore());
assertEquals(1, computed.weaknesses().size());
assertEquals("LEARNING", computed.weaknesses().get(0).getCode());
assertEquals(new BigDecimal("0.00"), computed.weaknesses().get(0).getScore());
assertEquals(1, computed.improvementAreas().size());
assertEquals("LEARNING", computed.improvementAreas().get(0).getDimension());
// competencyTag 字段已从 DTO 删除;仅保留 dimension/currentScore/summary/actions
assertNotNull(computed.improvementAreas().get(0).getSummary());
assertFalse(computed.improvementAreas().get(0).getActions().isEmpty());
assertEquals(4, computed.radar().size());
assertEquals(24, computed.questionFeedback().size());
assertEquals(4, computed.questionFeedback().get(0).getAwardedScore());
assertNotNull(computed.questionFeedback().get(0).getExplanation());
}
@Test
void weaknessReturnsAllTiedDimensions() {
List<AbilityResultComputer.Answered> built = new ArrayList<>();
int row = 0;
String[] dims = {"LOGIC", "COMMUNICATION", "EXECUTION", "LEARNING"};
String[][] tags = {
{"INFORMATION_ANALYSIS"}, {"LISTENING_UNDERSTANDING"},
{"PLANNING_PRIORITIZATION"}, {"INFORMATION_ACQUISITION"}};
// LOGIC 与 COMMUNICATION 并列最低(全 0)
for (String d : dims) {
int base = (d.equals("EXECUTION") || d.equals("LEARNING")) ? 4 : 0;
for (int i = 0; i < 6; i++) {
built.add(new AbilityResultComputer.Answered(d, tags[0][0] + (d.equals("LOGIC") ? 0 : 1),
"s" + row, "e" + row, base, ++row, row, "A"));
}
}
var c = computer.compute(built, 6);
assertEquals(2, c.weaknesses().size());
assertEquals(new BigDecimal("0.00"), c.weaknesses().get(0).getScore());
assertTrue(c.weaknesses().stream().anyMatch(w -> w.getCode().equals("LOGIC")));
assertTrue(c.weaknesses().stream().anyMatch(w -> w.getCode().equals("COMMUNICATION")));
}
private BigDecimal score(AbilityResultComputer.ComputedResult c, String code) {
return c.dimensionScores().stream().filter(d -> d.getCode().equals(code))
.findFirst().map(AbilityResultResponse.DimensionScore::getScore).orElseThrow();
}
}

62
mydreamjob-server/src/test/java/com/mydreamjob/ability/AbilitySchemaValidationSmokeTest.java

@ -0,0 +1,62 @@
package com.mydreamjob.ability;
import org.hibernate.annotations.JdbcTypeCode;
import org.hibernate.type.SqlTypes;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.test.context.ActiveProfiles;
import java.lang.reflect.Field;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import static org.junit.jupiter.api.Assertions.*;
/**
* V36 启动/schema 校验冒烟测试:ability_questions.active 为 TINYINT(非 BIT),
* 实体 Boolean 以 @JdbcTypeCode(TINYINT) 映射,避免与迁移类型冲突;六张表 + 迁移(48 题)可加载。
*/
@SpringBootTest
@ActiveProfiles("integration-test")
class AbilitySchemaValidationSmokeTest {
@Autowired private JdbcTemplate jdbcTemplate;
@MockBean private RedisTemplate<String, Object> redisTemplate;
@Test
void contextBoots_andAbilityColumnsAreTinyInt() {
List<Map<String, Object>> cols = jdbcTemplate.queryForList(
"SELECT column_name, data_type, is_nullable FROM information_schema.columns "
+ "WHERE LOWER(table_name) = 'ability_questions' AND LOWER(column_name) = 'active'");
assertEquals(1, cols.size());
String type = String.valueOf(cols.get(0).get("DATA_TYPE")).toUpperCase();
assertTrue(type.contains("INT"), "ability_questions.active must be TINYINT, got " + type);
assertEquals("NO", String.valueOf(cols.get(0).get("IS_NULLABLE")).toUpperCase());
// 实体映射为 TINYINT
assertEquals(SqlTypes.TINYINT, fieldJdbcType("active"));
assertTrue(questionCount() == 48, "ability_questions must seed 48");
}
private Long questionCount() {
return jdbcTemplate.queryForObject("SELECT COUNT(*) FROM ability_questions", Long.class);
}
private int fieldJdbcType(String name) {
try {
Field f = AbilityQuestion.class.getDeclaredField(name);
JdbcTypeCode code = f.getAnnotation(JdbcTypeCode.class);
assertNotNull(code, name + " must carry @JdbcTypeCode");
return code.value();
} catch (NoSuchFieldException e) {
throw new AssertionError(e);
}
}
}

288
mydreamjob-server/src/test/java/com/mydreamjob/assessment/AssessmentAttemptServiceTest.java

@ -0,0 +1,288 @@
package com.mydreamjob.assessment;
import com.mydreamjob.assessment.dto.AssessmentResultResponse;
import com.mydreamjob.assessment.dto.CreateAssessmentAttemptRequest;
import com.mydreamjob.assessment.dto.SaveAssessmentAnswersRequest;
import com.mydreamjob.audit.AuditContext;
import com.mydreamjob.audit.AuditService;
import com.mydreamjob.auth.User;
import com.mydreamjob.auth.UserRepository;
import com.mydreamjob.common.exception.BusinessException;
import com.mydreamjob.common.exception.ErrorCode;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.mockito.junit.jupiter.MockitoSettings;
import org.mockito.quality.Strictness;
import org.springframework.dao.OptimisticLockingFailureException;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.*;
/**
* 答卷服务单测(V34/V35)。
*
* <p>覆盖身份校验、活跃答卷重复 9708、幂等冲突 9709、答案不属于本答卷 9705、
* 版本并发 9707、29 题提交失败 9706、重复提交幂等返回原结果。</p>
*/
@ExtendWith(MockitoExtension.class)
@MockitoSettings(strictness = Strictness.LENIENT)
class AssessmentAttemptServiceTest {
private static final Long STUDENT = 100L;
private static final Long COMPANY = 200L;
private static final Long ATTEMPT_ID = 1L;
@Mock private AssessmentDefinitionRepository definitionRepository;
@Mock private AssessmentQuestionRepository questionRepository;
@Mock private AssessmentAttemptRepository attemptRepository;
@Mock private AssessmentAttemptQuestionRepository attemptQuestionRepository;
@Mock private AssessmentAnswerRepository answerRepository;
@Mock private AssessmentResultRepository resultRepository;
@Mock private AssessmentResultService resultService;
@Mock private AssessmentResultComputer resultComputer;
@Mock private UserRepository userRepository;
@Mock private AuditService auditService;
@InjectMocks private AssessmentAttemptService service;
private final AuditContext ctx = AuditContext.builder().requestId("req").build();
@BeforeEach
void setUp() {
when(attemptQuestionRepository.findByAttemptIdOrderByPositionAsc(anyLong())).thenReturn(List.of());
when(answerRepository.findByAttemptId(anyLong())).thenReturn(List.of());
}
private User student() {
User u = User.builder().id(STUDENT).phone("1").role(User.Role.STUDENT).build();
u.setRoles(Set.of(User.Role.STUDENT));
return u;
}
private User company() {
User u = User.builder().id(COMPANY).phone("2").role(User.Role.COMPANY).build();
u.setRoles(Set.of(User.Role.COMPANY));
return u;
}
private AssessmentDefinition definition() {
return AssessmentDefinition.builder().id(10L).typeCode("HOLLAND_RIASEC").status("ACTIVE")
.questionCount(72).attemptQuestionCount(30).minPerDimension(5)
.questionSetVersion("riasec-self-v0.2").rulesVersion("riasec-scoring-v0.1").build();
}
private AssessmentAttempt attempt(String status) {
return AssessmentAttempt.builder().id(ATTEMPT_ID).studentUserId(STUDENT).definitionId(10L)
.status(status).selectedQuestionCount(30).questionSetVersion("riasec-self-v0.2")
.version(0L).answeredCount(0).build();
}
@Test
void companyUser_create_returnsForbidden() {
when(userRepository.findById(COMPANY)).thenReturn(Optional.of(company()));
BusinessException ex = assertThrows(BusinessException.class,
() -> service.create(COMPANY, CreateAssessmentAttemptRequest.builder()
.typeCode("HOLLAND_RIASEC").build(), ctx));
assertEquals(ErrorCode.FORBIDDEN.getCode(), ex.getErrorCodeInt());
}
@Test
void create_duplicateActive_returns9708() {
when(userRepository.findById(STUDENT)).thenReturn(Optional.of(student()));
when(definitionRepository.findByTypeCode("HOLLAND_RIASEC")).thenReturn(Optional.of(definition()));
when(attemptRepository.existsByStudentUserIdAndDefinitionIdAndStatusIn(anyLong(), anyLong(), anyList()))
.thenReturn(true);
BusinessException ex = assertThrows(BusinessException.class,
() -> service.create(STUDENT, CreateAssessmentAttemptRequest.builder()
.typeCode("HOLLAND_RIASEC").build(), ctx));
assertEquals(ErrorCode.ASSESSMENT_DUPLICATE_ACTIVE.getCode(), ex.getErrorCodeInt());
}
@Test
void create_sameKeyDifferentDefinition_returns9709() {
when(userRepository.findById(STUDENT)).thenReturn(Optional.of(student()));
when(definitionRepository.findByTypeCode("HOLLAND_RIASEC")).thenReturn(Optional.of(definition()));
AssessmentAttempt existing = attempt("DRAFT");
existing.setId(99L);
existing.setDefinitionId(999L); // 定义不同 → 冲突
when(attemptRepository.findByStudentUserIdAndIdempotencyKey(STUDENT, "k")).thenReturn(Optional.of(existing));
BusinessException ex = assertThrows(BusinessException.class,
() -> service.create(STUDENT, CreateAssessmentAttemptRequest.builder()
.typeCode("HOLLAND_RIASEC").idempotencyKey("k").build(), ctx));
assertEquals(ErrorCode.ASSESSMENT_IDEMPOTENCY_CONFLICT.getCode(), ex.getErrorCodeInt());
}
@Test
void create_sameKeySameDefinition_returnsOriginalAttempt() {
when(userRepository.findById(STUDENT)).thenReturn(Optional.of(student()));
when(definitionRepository.findByTypeCode("HOLLAND_RIASEC")).thenReturn(Optional.of(definition()));
AssessmentAttempt existing = attempt("DRAFT");
existing.setId(99L);
when(attemptRepository.findByStudentUserIdAndIdempotencyKey(STUDENT, "k")).thenReturn(Optional.of(existing));
var resp = service.create(STUDENT, CreateAssessmentAttemptRequest.builder()
.typeCode("HOLLAND_RIASEC").idempotencyKey("k").build(), ctx);
assertEquals(99L, resp.getAttemptId());
verify(attemptRepository, never()).saveAndFlush(any());
}
@Test
void saveAnswers_questionNotOnAttempt_returns9705() {
when(userRepository.findById(STUDENT)).thenReturn(Optional.of(student()));
when(attemptRepository.findById(ATTEMPT_ID)).thenReturn(Optional.of(attempt("DRAFT")));
when(attemptQuestionRepository.existsByAttemptIdAndQuestionId(ATTEMPT_ID, 999L)).thenReturn(false);
SaveAssessmentAnswersRequest req = SaveAssessmentAnswersRequest.builder()
.version(0L)
.answers(List.of(SaveAssessmentAnswersRequest.AnswerItem.builder()
.questionId(999L).value(4).build()))
.build();
BusinessException ex = assertThrows(BusinessException.class,
() -> service.saveAnswers(STUDENT, ATTEMPT_ID, req, ctx));
assertEquals(ErrorCode.ASSESSMENT_ANSWER_INVALID.getCode(), ex.getErrorCodeInt());
}
@Test
void saveAnswers_versionMismatch_returns9707() {
when(userRepository.findById(STUDENT)).thenReturn(Optional.of(student()));
AssessmentAttempt a = attempt("DRAFT");
a.setVersion(5L);
when(attemptRepository.findById(ATTEMPT_ID)).thenReturn(Optional.of(a));
SaveAssessmentAnswersRequest req = SaveAssessmentAnswersRequest.builder()
.version(4L)
.answers(List.of(SaveAssessmentAnswersRequest.AnswerItem.builder()
.questionId(301L).value(4).build()))
.build();
BusinessException ex = assertThrows(BusinessException.class,
() -> service.saveAnswers(STUDENT, ATTEMPT_ID, req, ctx));
assertEquals(ErrorCode.ASSESSMENT_CONCURRENT_UPDATE.getCode(), ex.getErrorCodeInt());
}
@Test
void saveAnswers_optimisticLockConflict_returns9707() {
when(userRepository.findById(STUDENT)).thenReturn(Optional.of(student()));
when(attemptRepository.findById(ATTEMPT_ID)).thenReturn(Optional.of(attempt("DRAFT")));
when(attemptQuestionRepository.existsByAttemptIdAndQuestionId(ATTEMPT_ID, 301L)).thenReturn(true);
when(answerRepository.findByAttemptIdAndQuestionId(ATTEMPT_ID, 301L)).thenReturn(Optional.empty());
when(answerRepository.countByAttemptId(ATTEMPT_ID)).thenReturn(1L);
doThrow(new OptimisticLockingFailureException("stale"))
.when(attemptRepository).saveAndFlush(any());
SaveAssessmentAnswersRequest req = SaveAssessmentAnswersRequest.builder()
.version(0L)
.answers(List.of(SaveAssessmentAnswersRequest.AnswerItem.builder()
.questionId(301L).value(4).build()))
.build();
BusinessException ex = assertThrows(BusinessException.class,
() -> service.saveAnswers(STUDENT, ATTEMPT_ID, req, ctx));
assertEquals(ErrorCode.ASSESSMENT_CONCURRENT_UPDATE.getCode(), ex.getErrorCodeInt());
}
@Test
void submit_incomplete_returns9706() {
when(userRepository.findById(STUDENT)).thenReturn(Optional.of(student()));
when(attemptRepository.findById(ATTEMPT_ID)).thenReturn(Optional.of(attempt("IN_PROGRESS")));
when(answerRepository.countByAttemptId(ATTEMPT_ID)).thenReturn(29L);
BusinessException ex = assertThrows(BusinessException.class,
() -> service.submit(STUDENT, ATTEMPT_ID, ctx));
assertEquals(ErrorCode.ASSESSMENT_INCOMPLETE.getCode(), ex.getErrorCodeInt());
}
@Test
void submit_completed_idempotentReturnsExistingResult() {
when(userRepository.findById(STUDENT)).thenReturn(Optional.of(student()));
AssessmentAttempt a = attempt("COMPLETED");
a.setSubmittedAt(java.time.LocalDateTime.now());
when(attemptRepository.findById(ATTEMPT_ID)).thenReturn(Optional.of(a));
AssessmentResult r = AssessmentResult.builder().attemptId(ATTEMPT_ID).primaryCode("SIC")
.primaryType("S").answeredCount(30).build();
when(resultRepository.findByAttemptId(ATTEMPT_ID)).thenReturn(Optional.of(r));
AssessmentResultResponse resp = AssessmentResultResponse.builder().attemptId(ATTEMPT_ID)
.primaryCode("SIC").build();
when(resultService.toResponse(a, r)).thenReturn(resp);
var out = service.submit(STUDENT, ATTEMPT_ID, ctx);
assertEquals("SIC", out.getPrimaryCode());
verify(resultService).toResponse(a, r);
verify(auditService, never()).log(anyLong(), eq("ASSESSMENT_ATTEMPT_SUBMITTED"),
anyString(), anyLong(), anyString(), any());
}
@Test
void adminUser_create_returnsForbidden() {
User u = User.builder().id(999L).phone("3").role(User.Role.ADMIN).build();
u.setRoles(Set.of(User.Role.ADMIN));
when(userRepository.findById(999L)).thenReturn(Optional.of(u));
BusinessException ex = assertThrows(BusinessException.class,
() -> service.create(999L, CreateAssessmentAttemptRequest.builder()
.typeCode("HOLLAND_RIASEC").build(), ctx));
assertEquals(ErrorCode.FORBIDDEN.getCode(), ex.getErrorCodeInt());
}
@Test
void create_inactiveType_returns9701() {
when(userRepository.findById(STUDENT)).thenReturn(Optional.of(student()));
when(definitionRepository.findByTypeCode("HOLLAND_RIASEC")).thenReturn(Optional.of(
AssessmentDefinition.builder().id(10L).typeCode("HOLLAND_RIASEC").status("INACTIVE").build()));
BusinessException ex = assertThrows(BusinessException.class,
() -> service.create(STUDENT, CreateAssessmentAttemptRequest.builder()
.typeCode("HOLLAND_RIASEC").build(), ctx));
assertEquals(ErrorCode.ASSESSMENT_TYPE_NOT_FOUND.getCode(), ex.getErrorCodeInt());
}
@Test
void create_insufficientBankOnADimension_rejects() {
when(userRepository.findById(STUDENT)).thenReturn(Optional.of(student()));
when(definitionRepository.findByTypeCode("HOLLAND_RIASEC")).thenReturn(Optional.of(definition()));
when(attemptRepository.existsByStudentUserIdAndDefinitionIdAndStatusIn(anyLong(), anyLong(), anyList()))
.thenReturn(false);
// 六维齐全但 R 维度仅 3 道(<5),不能静默减题
List<AssessmentQuestion> bank = new ArrayList<>();
Map<String, Integer> perDim = new LinkedHashMap<>(
Map.of("R", 3, "I", 5, "A", 5, "S", 5, "E", 5, "C", 5));
long seq = 1;
for (Map.Entry<String, Integer> e : perDim.entrySet()) {
for (int i = 0; i < e.getValue(); i++) {
bank.add(AssessmentQuestion.builder().id(seq++).dimension(e.getKey()).build());
}
}
when(questionRepository.findByDefinitionIdAndActiveTrueOrderByPositionAsc(10L)).thenReturn(bank);
BusinessException ex = assertThrows(BusinessException.class,
() -> service.create(STUDENT, CreateAssessmentAttemptRequest.builder()
.typeCode("HOLLAND_RIASEC").build(), ctx));
assertEquals(ErrorCode.BAD_REQUEST.getCode(), ex.getErrorCodeInt());
}
@Test
void saveAnswers_duplicateQuestionIdInSameRequest_returns9705() {
when(userRepository.findById(STUDENT)).thenReturn(Optional.of(student()));
when(attemptRepository.findById(ATTEMPT_ID)).thenReturn(Optional.of(attempt("DRAFT")));
SaveAssessmentAnswersRequest req = SaveAssessmentAnswersRequest.builder()
.version(0L)
.answers(List.of(
SaveAssessmentAnswersRequest.AnswerItem.builder().questionId(301L).value(4).build(),
SaveAssessmentAnswersRequest.AnswerItem.builder().questionId(301L).value(5).build()))
.build();
BusinessException ex = assertThrows(BusinessException.class,
() -> service.saveAnswers(STUDENT, ATTEMPT_ID, req, ctx));
assertEquals(ErrorCode.ASSESSMENT_ANSWER_INVALID.getCode(), ex.getErrorCodeInt());
}
}

99
mydreamjob-server/src/test/java/com/mydreamjob/assessment/AssessmentCatalogServiceTest.java

@ -0,0 +1,99 @@
package com.mydreamjob.assessment;
import com.mydreamjob.assessment.dto.AssessmentQuestionResponse;
import com.mydreamjob.assessment.dto.AssessmentTypeResponse;
import com.mydreamjob.common.exception.BusinessException;
import com.mydreamjob.common.exception.ErrorCode;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.mockito.junit.jupiter.MockitoSettings;
import org.mockito.quality.Strictness;
import java.util.List;
import java.util.Optional;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.when;
/**
* 测评目录服务单测(V34/V35)。
*
* <p>覆盖类型列表(仅 ACTIVE)、题目列表隐藏 dimension/reverseScored/source/questionVersion、
* 未知类型 404/9701。</p>
*/
@ExtendWith(MockitoExtension.class)
@MockitoSettings(strictness = Strictness.LENIENT)
class AssessmentCatalogServiceTest {
@Mock private AssessmentDefinitionRepository definitionRepository;
@Mock private AssessmentQuestionRepository questionRepository;
@InjectMocks private AssessmentCatalogService service;
@Test
void listTypes_returnsOnlyActive() {
AssessmentDefinition active = AssessmentDefinition.builder().id(1L).typeCode("HOLLAND_RIASEC")
.displayName("RIASEC").status("ACTIVE").questionCount(72).attemptQuestionCount(30)
.minPerDimension(5).scaleType("LIKERT_5")
.questionSetVersion("riasec-self-v0.2").rulesVersion("riasec-scoring-v0.1").build();
AssessmentDefinition inactive = AssessmentDefinition.builder().id(2L).typeCode("OTHER")
.status("DISABLED").build();
when(definitionRepository.findAll()).thenReturn(List.of(active, inactive));
List<AssessmentTypeResponse> types = service.listTypes();
assertEquals(1, types.size());
assertEquals("HOLLAND_RIASEC", types.get(0).getTypeCode());
assertEquals(30, types.get(0).getAttemptQuestionCount());
}
@Test
void listQuestions_hidesInternalFields_andMapsPublicOnes() {
AssessmentDefinition def = AssessmentDefinition.builder().id(1L).typeCode("HOLLAND_RIASEC")
.status("ACTIVE").build();
AssessmentQuestion q = AssessmentQuestion.builder().id(301L).questionCode("R01")
.position(1).dimension("R").stem("我对使用手工工具制作物品感兴趣。")
.scaleType("LIKERT_5").build();
when(definitionRepository.findByTypeCode("HOLLAND_RIASEC")).thenReturn(Optional.of(def));
when(questionRepository.findByDefinitionIdAndActiveTrueOrderByPositionAsc(1L)).thenReturn(List.of(q));
List<AssessmentQuestionResponse> items = service.listQuestions("HOLLAND_RIASEC");
assertEquals(1, items.size());
AssessmentQuestionResponse r = items.get(0);
assertEquals(301L, r.getId());
assertEquals(1, r.getPosition());
assertEquals("我对使用手工工具制作物品感兴趣。", r.getStem());
assertEquals("LIKERT_5", r.getScaleType());
// 内部字段一律不暴露:序列化后 JSON 不含这些字段,value 默认 null
assertNull(r.getValue());
String json = assertDoesNotThrow(() ->
new com.fasterxml.jackson.databind.ObjectMapper().writeValueAsString(r));
assertFalse(json.contains("dimension"), "must not serialize dimension");
assertFalse(json.contains("reverseScored"), "must not serialize reverseScored");
assertFalse(json.contains("questionVersion"), "must not serialize questionVersion");
assertFalse(json.contains("questionCode"), "must not serialize dimension-carrying questionCode");
assertFalse(json.contains("source"), "must not serialize source");
}
@Test
void listQuestions_inactiveType_returns9701() {
AssessmentDefinition def = AssessmentDefinition.builder().id(9L).typeCode("HOLLAND_RIASEC")
.status("INACTIVE").build();
when(definitionRepository.findByTypeCode("HOLLAND_RIASEC")).thenReturn(Optional.of(def));
BusinessException ex = assertThrows(BusinessException.class,
() -> service.listQuestions("HOLLAND_RIASEC"));
assertEquals(ErrorCode.ASSESSMENT_TYPE_NOT_FOUND.getCode(), ex.getErrorCodeInt());
}
@Test
void listQuestions_unknownType_returns9701() {
when(definitionRepository.findByTypeCode("NOPE")).thenReturn(Optional.empty());
BusinessException ex = assertThrows(BusinessException.class,
() -> service.listQuestions("NOPE"));
assertEquals(ErrorCode.ASSESSMENT_TYPE_NOT_FOUND.getCode(), ex.getErrorCodeInt());
}
}

280
mydreamjob-server/src/test/java/com/mydreamjob/assessment/AssessmentControllerTest.java

@ -0,0 +1,280 @@
package com.mydreamjob.assessment;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.mydreamjob.auth.JwtTokenProvider;
import com.mydreamjob.auth.User;
import com.mydreamjob.auth.UserRepository;
import com.mydreamjob.candidatescore.CandidateDimensionScoreRepository;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.core.ValueOperations;
import org.springframework.http.MediaType;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.MvcResult;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.ConcurrentLinkedQueue;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicLong;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
/**
* RIASEC 测评控制器集成测试(真实 Service + H2)。
*
* <p>覆盖 401、题目响应不返回 dimension、创建 200、全流程保存/提交/结果、
* COMPANY 创建 403、结果不含隐私字段。</p>
*/
@SpringBootTest
@AutoConfigureMockMvc
@ActiveProfiles("integration-test")
class AssessmentControllerTest {
private static final AtomicLong PHONE = new AtomicLong(700);
@Autowired private MockMvc mockMvc;
@Autowired private ObjectMapper objectMapper;
@Autowired private JwtTokenProvider jwtTokenProvider;
@Autowired private UserRepository userRepository;
@Autowired private CandidateDimensionScoreRepository dimensionScoreRepository;
@MockBean private RedisTemplate<String, Object> redisTemplate;
private Long studentUserId;
private Long companyUserId;
@BeforeEach
@SuppressWarnings("unchecked")
void setUp() {
ValueOperations<String, Object> ops = mock(ValueOperations.class);
when(redisTemplate.opsForValue()).thenReturn(ops);
when(redisTemplate.hasKey(any())).thenReturn(false);
studentUserId = createUser(User.Role.STUDENT);
companyUserId = createUser(User.Role.COMPANY);
}
@AfterEach
void tearDown() {
// 仅删除自建用户(级联删除其答卷/答案/结果),保留迁移种子(定义+72题)
userRepository.deleteById(studentUserId);
userRepository.deleteById(companyUserId);
}
private Long createUser(User.Role role) {
User u = userRepository.save(User.builder()
.phone("17" + String.format("%08d", PHONE.incrementAndGet()))
.nickname("u").role(role).roles(Set.of(role)).active(true).verified(true).build());
return u.getId();
}
private String token(Long userId, User.Role role) {
return "Bearer " + jwtTokenProvider.generateAccessToken(userId,
String.valueOf(userId), Map.of("roles", role.name()));
}
@Test
void types_requiresAuth_returns401() throws Exception {
mockMvc.perform(get("/assessments/types"))
.andExpect(status().isUnauthorized());
}
@Test
void questions_hidesDimensionFields() throws Exception {
String tk = token(studentUserId, User.Role.STUDENT);
mockMvc.perform(get("/assessments/HOLLAND_RIASEC/questions").header("Authorization", tk))
.andExpect(status().isOk())
.andExpect(jsonPath("$.data[0].id").exists())
.andExpect(jsonPath("$.data[0].stem").exists())
.andExpect(jsonPath("$.data[0].dimension").doesNotExist())
.andExpect(jsonPath("$.data[0].reverseScored").doesNotExist())
.andExpect(jsonPath("$.data[0].source").doesNotExist())
.andExpect(jsonPath("$.data[0].questionVersion").doesNotExist())
.andExpect(jsonPath("$.data[0].questionCode").doesNotExist());
}
@Test
void company_cannotCreateAttempt_returns403() throws Exception {
String tk = token(companyUserId, User.Role.COMPANY);
mockMvc.perform(post("/assessments/attempts")
.header("Authorization", tk)
.contentType(MediaType.APPLICATION_JSON)
.content("{\"typeCode\":\"HOLLAND_RIASEC\"}"))
.andExpect(status().isForbidden());
}
@Test
void fullFlow_createAnswerSubmitResult() throws Exception {
long dimBefore = dimensionScoreRepository.count();
String tk = token(studentUserId, User.Role.STUDENT);
MvcResult create = mockMvc.perform(post("/assessments/attempts")
.header("Authorization", tk)
.contentType(MediaType.APPLICATION_JSON)
.content("{\"typeCode\":\"HOLLAND_RIASEC\"}"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.data.attemptId").exists())
.andExpect(jsonPath("$.data.totalQuestions").value(30))
.andExpect(jsonPath("$.data.questions.length()").value(30))
.andReturn();
String body = create.getResponse().getContentAsString();
JsonNode data = objectMapper.readTree(body).path("data");
long attemptId = data.path("attemptId").asLong();
int version = data.path("version").asInt();
JsonNode questions = data.path("questions");
assertEquals(30, questions.size());
StringBuilder answers = new StringBuilder("{\"version\":").append(version).append(",\"answers\":[");
for (int i = 0; i < questions.size(); i++) {
if (i > 0) answers.append(',');
answers.append("{\"questionId\":").append(questions.get(i).path("id").asLong())
.append(",\"value\":4}");
}
answers.append("]}");
mockMvc.perform(patch("/assessments/attempts/{id}/answers", attemptId)
.header("Authorization", tk)
.contentType(MediaType.APPLICATION_JSON)
.content(answers.toString()))
.andExpect(status().isOk())
.andExpect(jsonPath("$.data.answeredCount").value(30))
.andExpect(jsonPath("$.data.completionRate").value(100.0));
MvcResult submit = mockMvc.perform(post("/assessments/attempts/{id}/submit", attemptId)
.header("Authorization", tk))
.andExpect(status().isOk())
.andExpect(jsonPath("$.data.primaryCode").exists())
.andExpect(jsonPath("$.data.disclaimer").value("本结果仅供职业兴趣探索参考,不构成心理或职业诊断。"))
.andReturn();
JsonNode resultData = objectMapper.readTree(submit.getResponse().getContentAsString()).path("data");
assertEquals(3, resultData.path("primaryCode").asText().length());
mockMvc.perform(get("/assessments/attempts/{id}/result", attemptId)
.header("Authorization", tk))
.andExpect(status().isOk())
// 脱敏:结果不含隐私字段与国家内部标识
.andExpect(jsonPath("$.data.studentUserId").doesNotExist())
.andExpect(jsonPath("$.data.phone").doesNotExist())
.andExpect(jsonPath("$.data.email").doesNotExist())
.andExpect(jsonPath("$.data.password").doesNotExist())
// 探索方向使用"可能感兴趣"文案
.andExpect(jsonPath("$.data.explorationDirections").isArray());
// 不写入候选评分引擎
assertEquals(dimBefore, dimensionScoreRepository.count());
}
@Test
void concurrentCreate_differentKeys_onlyOne200_others409_9708() throws Exception {
String tk = token(studentUserId, User.Role.STUDENT);
int n = 4;
ExecutorService pool = Executors.newFixedThreadPool(n);
CountDownLatch start = new CountDownLatch(1);
CountDownLatch done = new CountDownLatch(n);
ConcurrentLinkedQueue<int[]> httpResults = new ConcurrentLinkedQueue<>(); // {status, code}
for (int i = 0; i < n; i++) {
final String key = "http-cc-" + studentUserId + "-" + i;
pool.submit(() -> {
await(start);
try {
MvcResult r = mockMvc.perform(post("/assessments/attempts")
.header("Authorization", tk)
.contentType(MediaType.APPLICATION_JSON)
.content("{\"typeCode\":\"HOLLAND_RIASEC\",\"idempotencyKey\":\"" + key + "\"}"))
.andReturn();
int status = r.getResponse().getStatus();
int code = -1;
try {
code = objectMapper.readTree(r.getResponse().getContentAsString()).path("errorCode").isMissingNode()
? objectMapper.readTree(r.getResponse().getContentAsString()).path("code").asInt(-1)
: objectMapper.readTree(r.getResponse().getContentAsString()).path("errorCode").asInt(-1);
} catch (Exception ignore) { }
httpResults.add(new int[]{status, code});
} catch (Exception ex) {
httpResults.add(new int[]{-1, -1});
} finally {
done.countDown();
}
});
}
start.countDown();
assertTrue(done.await(20, TimeUnit.SECONDS));
pool.shutdown();
int[] statuses = httpResults.stream().mapToInt(r -> r[0]).toArray();
assertEquals(1, countOf(statuses, 200), "exactly one HTTP 200");
assertEquals(n - 1, countOf(statuses, 409), "losers must be HTTP 409: " + java.util.Arrays.toString(statuses));
assertEquals(0, countOf(statuses, 500), "no HTTP 500");
assertEquals(0, countOf(statuses, -1), "no empty/unreachable responses");
// 所有失败请求的 JSON code 都必须是 9708
assertEquals(n - 1, httpResults.stream().filter(r -> r[0] == 409 && r[1] == 9708).count(),
"all 409 must carry code 9708");
}
@Test
void concurrentCreate_sameKey_all200_sameAttemptId() throws Exception {
String tk = token(studentUserId, User.Role.STUDENT);
String key = "http-cidem-" + studentUserId;
int n = 4;
ExecutorService pool = Executors.newFixedThreadPool(n);
CountDownLatch start = new CountDownLatch(1);
CountDownLatch done = new CountDownLatch(n);
ConcurrentLinkedQueue<Long> ids = new ConcurrentLinkedQueue<>();
ConcurrentLinkedQueue<Integer> badStatus = new ConcurrentLinkedQueue<>();
for (int i = 0; i < n; i++) {
pool.submit(() -> {
await(start);
try {
MvcResult r = mockMvc.perform(post("/assessments/attempts")
.header("Authorization", tk)
.contentType(MediaType.APPLICATION_JSON)
.content("{\"typeCode\":\"HOLLAND_RIASEC\",\"idempotencyKey\":\"" + key + "\"}"))
.andReturn();
if (r.getResponse().getStatus() != 200) {
badStatus.add(r.getResponse().getStatus());
} else {
ids.add(objectMapper.readTree(r.getResponse().getContentAsString())
.path("data").path("attemptId").asLong());
}
} catch (Exception ex) {
badStatus.add(-1);
} finally {
done.countDown();
}
});
}
start.countDown();
assertTrue(done.await(20, TimeUnit.SECONDS));
pool.shutdown();
assertEquals(0, badStatus.size(), "same idempotencyKey concurrent create must all be 200: " + badStatus);
assertEquals(n, ids.size());
assertEquals(1, ids.stream().distinct().count(), "all requests must return the same attemptId");
}
private int countOf(int[] arr, int v) {
int c = 0;
for (int x : arr) if (x == v) c++;
return c;
}
private void await(CountDownLatch latch) {
try { latch.await(); } catch (InterruptedException e) { Thread.currentThread().interrupt(); }
}
}

430
mydreamjob-server/src/test/java/com/mydreamjob/assessment/AssessmentPersistenceIntegrationTest.java

@ -0,0 +1,430 @@
package com.mydreamjob.assessment;
import com.mydreamjob.assessment.dto.AssessmentAttemptResponse;
import com.mydreamjob.assessment.dto.AssessmentQuestionResponse;
import com.mydreamjob.assessment.dto.CreateAssessmentAttemptRequest;
import com.mydreamjob.assessment.dto.SaveAssessmentAnswersRequest;
import com.mydreamjob.audit.AuditContext;
import com.mydreamjob.audit.AuditLogRepository;
import com.mydreamjob.auth.User;
import com.mydreamjob.auth.UserRepository;
import com.mydreamjob.candidatescore.CandidateDimensionScoreRepository;
import com.mydreamjob.common.exception.BusinessException;
import com.mydreamjob.common.exception.ErrorCode;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.dao.DataIntegrityViolationException;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.core.ValueOperations;
import org.springframework.test.context.ActiveProfiles;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.atomic.AtomicLong;
import java.util.stream.Collectors;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
/**
* RIASEC 测评持久化集成测试(H2 + Flyway V34 真实表结构)。
*
* <p>覆盖 V34 迁移成功、抽题 30 且每维 5、无重复、固定顺序、答案归属校验、
* 29 题提交失败、30 题提交成功、重复提交幂等、幂等键、活跃答卷重复、候选评分不写入、
* 唯一/CHECK 约束。</p>
*/
@SpringBootTest
@ActiveProfiles("integration-test")
class AssessmentPersistenceIntegrationTest {
private static final AtomicLong PHONE = new AtomicLong(800);
@Autowired private AssessmentAttemptService attemptService;
@Autowired private AssessmentCatalogService catalogService;
@Autowired private AssessmentDefinitionRepository definitionRepository;
@Autowired private AssessmentQuestionRepository questionRepository;
@Autowired private AssessmentAttemptRepository attemptRepository;
@Autowired private AssessmentAttemptQuestionRepository attemptQuestionRepository;
@Autowired private AssessmentAnswerRepository answerRepository;
@Autowired private AssessmentResultRepository resultRepository;
@Autowired private UserRepository userRepository;
@Autowired private CandidateDimensionScoreRepository dimensionScoreRepository;
@Autowired private AuditLogRepository auditLogRepository;
@MockBean private RedisTemplate<String, Object> redisTemplate;
private final AuditContext ctx = AuditContext.builder().requestId("req").build();
private final List<Long> createdUsers = new ArrayList<>();
private Long studentUserId;
@BeforeEach
@SuppressWarnings("unchecked")
void setUp() {
ValueOperations<String, Object> ops = mock(ValueOperations.class);
when(redisTemplate.opsForValue()).thenReturn(ops);
when(redisTemplate.hasKey(any())).thenReturn(false);
studentUserId = createUser();
}
@AfterEach
void tearDown() {
// 仅删除自建用户(级联删除其答卷/答案/结果),保留迁移种子
for (Long id : createdUsers) {
userRepository.deleteById(id);
}
createdUsers.clear();
}
private Long createUser() {
User u = userRepository.save(User.builder()
.phone("18" + String.format("%08d", PHONE.incrementAndGet()))
.nickname("u").role(User.Role.STUDENT).roles(Set.of(User.Role.STUDENT))
.active(true).verified(true).build());
createdUsers.add(u.getId());
return u.getId();
}
private CreateAssessmentAttemptRequest createReq(String key) {
return CreateAssessmentAttemptRequest.builder()
.typeCode("HOLLAND_RIASEC").idempotencyKey(key).build();
}
private SaveAssessmentAnswersRequest answerReq(AssessmentAttemptResponse resp, int value) {
List<SaveAssessmentAnswersRequest.AnswerItem> items = resp.getQuestions().stream()
.map(q -> SaveAssessmentAnswersRequest.AnswerItem.builder()
.questionId(q.getId()).value(value).build())
.collect(Collectors.toList());
return SaveAssessmentAnswersRequest.builder().version(resp.getVersion()).answers(items).build();
}
private SaveAssessmentAnswersRequest partialReq(AssessmentAttemptResponse resp, int count) {
List<SaveAssessmentAnswersRequest.AnswerItem> items = resp.getQuestions().subList(0, count).stream()
.map(q -> SaveAssessmentAnswersRequest.AnswerItem.builder()
.questionId(q.getId()).value(4).build())
.collect(Collectors.toList());
return SaveAssessmentAnswersRequest.builder().version(resp.getVersion()).answers(items).build();
}
// ── 迁移 & 抽题 ─────────────────────────────────────────
@Test
void v34Migration_seedsDefinitionAnd72Questions() {
var def = definitionRepository.findByTypeCode("HOLLAND_RIASEC").orElseThrow();
assertEquals(72, def.getQuestionCount());
assertEquals(30, def.getAttemptQuestionCount());
assertEquals(5, def.getMinPerDimension());
assertEquals(72, questionRepository.findByDefinitionIdAndActiveTrueOrderByPositionAsc(def.getId()).size());
assertEquals(1, catalogService.listTypes().stream().filter(t -> "ACTIVE".equals(t.getStatus())).count());
}
@Test
void create_sampled30_fivePerDimension_noDuplicatesFixedOrder() {
AssessmentAttemptResponse resp = attemptService.create(studentUserId, createReq("k-create"), ctx);
// 恰好 30 题
assertEquals(30, resp.getTotalQuestions());
assertEquals(30, resp.getQuestions().size());
// 无重复
Set<Long> ids = new HashSet<>();
for (AssessmentQuestionResponse q : resp.getQuestions()) {
assertTrue(ids.add(q.getId()), "no duplicate question expected");
}
// 每维恰好 5 题
List<Long> qids = resp.getQuestions().stream().map(AssessmentQuestionResponse::getId).toList();
Map<String, Long> byDim = questionRepository.findAllById(qids).stream()
.collect(Collectors.groupingBy(AssessmentQuestion::getDimension, Collectors.counting()));
for (String dim : List.of("R", "I", "A", "S", "E", "C")) {
assertEquals(5L, byDim.getOrDefault(dim, 0L), "dimension " + dim);
}
// 刷新(重查)顺序不变
AssessmentAttemptResponse again = attemptService.get(studentUserId, resp.getAttemptId());
assertEquals(resp.getQuestions().stream().map(AssessmentQuestionResponse::getId).toList(),
again.getQuestions().stream().map(AssessmentQuestionResponse::getId).toList());
}
@Test
void create_duplicateActive_returns9708() {
attemptService.create(studentUserId, createReq("k-active-1"), ctx);
BusinessException ex = assertThrows(BusinessException.class,
() -> attemptService.create(studentUserId, createReq("k-active-2"), ctx));
assertEquals(ErrorCode.ASSESSMENT_DUPLICATE_ACTIVE.getCode(), ex.getErrorCodeInt());
}
@Test
void create_sameKeySameBody_returnsOriginalAttempt() {
AssessmentAttemptResponse first = attemptService.create(studentUserId, createReq("k-idem"), ctx);
AssessmentAttemptResponse second = attemptService.create(studentUserId, createReq("k-idem"), ctx);
assertEquals(first.getAttemptId(), second.getAttemptId());
long attempts = attemptRepository.findByStudentUserIdOrderByCreatedAtDesc(studentUserId,
org.springframework.data.domain.PageRequest.of(0, 50)).getTotalElements();
assertEquals(1L, attempts);
}
// ── 保存答案 ────────────────────────────────────────────
@Test
void saveAnswers_questionNotOnAttempt_returns9705() {
AssessmentAttemptResponse resp = attemptService.create(studentUserId, createReq("k-save-1"), ctx);
Long foreignQuestionId = questionRepository.findAll().stream()
.map(AssessmentQuestion::getId)
.filter(id -> resp.getQuestions().stream().noneMatch(q -> q.getId().equals(id)))
.findFirst().orElseThrow();
SaveAssessmentAnswersRequest req = SaveAssessmentAnswersRequest.builder()
.version(resp.getVersion())
.answers(List.of(SaveAssessmentAnswersRequest.AnswerItem.builder()
.questionId(foreignQuestionId).value(4).build()))
.build();
BusinessException ex = assertThrows(BusinessException.class,
() -> attemptService.saveAnswers(studentUserId, resp.getAttemptId(), req, ctx));
assertEquals(ErrorCode.ASSESSMENT_ANSWER_INVALID.getCode(), ex.getErrorCodeInt());
}
@Test
void saveAnswers_invalidValue_returns9705() {
AssessmentAttemptResponse resp = attemptService.create(studentUserId, createReq("k-save-2"), ctx);
Long qid = resp.getQuestions().get(0).getId();
SaveAssessmentAnswersRequest req = SaveAssessmentAnswersRequest.builder()
.version(resp.getVersion())
.answers(List.of(SaveAssessmentAnswersRequest.AnswerItem.builder()
.questionId(qid).value(6).build()))
.build();
BusinessException ex = assertThrows(BusinessException.class,
() -> attemptService.saveAnswers(studentUserId, resp.getAttemptId(), req, ctx));
assertEquals(ErrorCode.ASSESSMENT_ANSWER_INVALID.getCode(), ex.getErrorCodeInt());
}
// ── 提交 ────────────────────────────────────────────────
@Test
void submit_29Answered_returns9706() {
AssessmentAttemptResponse resp = attemptService.create(studentUserId, createReq("k-sub-29"), ctx);
attemptService.saveAnswers(studentUserId, resp.getAttemptId(),
partialReq(resp, 29), ctx);
BusinessException ex = assertThrows(BusinessException.class,
() -> attemptService.submit(studentUserId, resp.getAttemptId(), ctx));
assertEquals(ErrorCode.ASSESSMENT_INCOMPLETE.getCode(), ex.getErrorCodeInt());
}
@Test
void submit_30Answered_succeeds_andRepeatedIdempotent() {
long dimBefore = dimensionScoreRepository.count();
AssessmentAttemptResponse resp = attemptService.create(studentUserId, createReq("k-sub-30"), ctx);
attemptService.saveAnswers(studentUserId, resp.getAttemptId(), answerReq(resp, 4), ctx);
assertEquals(30, answerRepository.countByAttemptId(resp.getAttemptId()));
var result = attemptService.submit(studentUserId, resp.getAttemptId(), ctx);
assertEquals("COMPLETED", result.getStatus());
assertEquals(30, result.getAnsweredCount());
assertEquals(100.0, result.getCompletionRate().doubleValue());
assertNotNull(result.getPrimaryCode());
assertEquals(3, result.getPrimaryCode().length());
assertEquals(30, attemptQuestionRepository.findByAttemptIdOrderByPositionAsc(resp.getAttemptId()).size());
// 重复提交幂等:不再新增结果
var again = attemptService.submit(studentUserId, resp.getAttemptId(), ctx);
assertEquals(result.getPrimaryCode(), again.getPrimaryCode());
assertEquals(1, resultRepository.findByAttemptId(resp.getAttemptId()).isPresent() ? 1 : 0);
// 不写入候选评分引擎
assertEquals(dimBefore, dimensionScoreRepository.count());
}
@Test
void fullFlow_statusAndResultPersisted() {
AssessmentAttemptResponse resp = attemptService.create(studentUserId, createReq("k-flow"), ctx);
attemptService.saveAnswers(studentUserId, resp.getAttemptId(), answerReq(resp, 3), ctx);
var detail = attemptService.get(studentUserId, resp.getAttemptId());
assertEquals("IN_PROGRESS", detail.getStatus());
assertEquals(30, detail.getAnsweredCount());
var result = attemptService.submit(studentUserId, resp.getAttemptId(), ctx);
var attemptRow = attemptRepository.findById(resp.getAttemptId()).orElseThrow();
assertEquals(AssessmentAttempt.STATUS_COMPLETED, attemptRow.getStatus());
assertNotNull(attemptRow.getSubmittedAt());
assertNotNull(resultRepository.findByAttemptId(resp.getAttemptId()).orElseThrow().getGeneratedAt());
}
// ── 约束校验 ────────────────────────────────────────────
@Test
void inactiveDefinition_createAndList_returns9701() {
AssessmentDefinition inactive = definitionRepository.save(AssessmentDefinition.builder()
.typeCode("INACTIVE_TEST").displayName("x").description("d").instructions("i")
.status("INACTIVE").scaleType("LIKERT_5")
.questionCount(30).attemptQuestionCount(30).minPerDimension(5)
.questionSetVersion("v0.2").rulesVersion("v0.1").build());
try {
BusinessException createEx = assertThrows(BusinessException.class,
() -> attemptService.create(studentUserId, CreateAssessmentAttemptRequest.builder()
.typeCode("INACTIVE_TEST").build(), ctx));
assertEquals(ErrorCode.ASSESSMENT_TYPE_NOT_FOUND.getCode(), createEx.getErrorCodeInt());
BusinessException listEx = assertThrows(BusinessException.class,
() -> catalogService.listQuestions("INACTIVE_TEST"));
assertEquals(ErrorCode.ASSESSMENT_TYPE_NOT_FOUND.getCode(), listEx.getErrorCodeInt());
} finally {
definitionRepository.delete(inactive);
}
}
@Test
void concurrentCreate_sameStudentSameType_onlyOneSucceedsRestAre9708() throws Exception {
Long u = createUser();
int n = 4;
ExecutorService pool = Executors.newFixedThreadPool(n);
CountDownLatch start = new CountDownLatch(1);
CountDownLatch done = new CountDownLatch(n);
java.util.concurrent.ConcurrentLinkedQueue<Integer> results = new java.util.concurrent.ConcurrentLinkedQueue<>();
for (int i = 0; i < n; i++) {
final String key = "ccp-" + u + "-" + i;
pool.submit(() -> {
await(start);
try {
attemptService.create(u, createReq(key), ctx);
results.add(0); // 成功
} catch (BusinessException be) {
results.add(be.getErrorCodeInt()); // 9708
} catch (Exception other) {
results.add(-1); // 出现 AssertionFailure/500 等即失败
} finally {
done.countDown();
}
});
}
start.countDown();
assertTrue(done.await(20, TimeUnit.SECONDS), "concurrent create should finish");
pool.shutdown();
assertEquals(1, results.stream().filter(c -> c == 0).count(), "exactly one concurrent create must win (200)");
assertEquals(n - 1, results.stream().filter(c -> c == ErrorCode.ASSESSMENT_DUPLICATE_ACTIVE.getCode()).count(),
"all losers must be BusinessException 9708");
assertEquals(0, results.stream().filter(c -> c < 0).count(), "no AssertionFailure/500/other exceptions allowed");
long attemptRows = attemptRepository.findByStudentUserIdOrderByCreatedAtDesc(u,
PageRequest.of(0, 50)).getTotalElements();
assertEquals(1, attemptRows, "DB must contain exactly 1 attempt row");
long activeKeys = attemptRepository.findByStudentUserIdOrderByCreatedAtDesc(u,
PageRequest.of(0, 50)).getContent().stream()
.filter(a -> a.getActiveKey() != null).count();
assertEquals(1, activeKeys, "DB must contain exactly 1 non-null active_key");
Long winnerId = attemptRepository.findByStudentUserIdOrderByCreatedAtDesc(u, PageRequest.of(0, 50))
.getContent().get(0).getId();
assertEquals(30, attemptQuestionRepository.findByAttemptIdOrderByPositionAsc(winnerId).size(),
"winning attempt must have 30 attempt_questions");
}
@Test
void concurrentCreate_sameIdempotencyKey_returnsSameAttempt() throws Exception {
Long u = createUser();
String idem = "cidem-" + u;
int n = 4;
ExecutorService pool = Executors.newFixedThreadPool(n);
CountDownLatch start = new CountDownLatch(1);
CountDownLatch done = new CountDownLatch(n);
java.util.concurrent.ConcurrentLinkedQueue<Long> attemptIds = new java.util.concurrent.ConcurrentLinkedQueue<>();
java.util.concurrent.ConcurrentLinkedQueue<Integer> errors = new java.util.concurrent.ConcurrentLinkedQueue<>();
for (int i = 0; i < n; i++) {
pool.submit(() -> {
await(start);
try {
AssessmentAttemptResponse r = attemptService.create(u,
CreateAssessmentAttemptRequest.builder()
.typeCode("HOLLAND_RIASEC").idempotencyKey(idem).build(), ctx);
attemptIds.add(r.getAttemptId());
} catch (BusinessException be) {
errors.add(be.getErrorCodeInt());
} finally {
done.countDown();
}
});
}
start.countDown();
assertTrue(done.await(20, TimeUnit.SECONDS), "concurrent idem create should finish");
pool.shutdown();
assertEquals(0, errors.size(), "same idempotencyKey concurrent create must not error: " + errors);
assertEquals(n, attemptIds.size(), "every request must return an attemptId (idempotent 200)");
assertEquals(1, attemptIds.stream().distinct().count(), "all requests must share the same attemptId");
assertEquals(1, attemptRepository.findByStudentUserIdOrderByCreatedAtDesc(u,
PageRequest.of(0, 50)).getTotalElements(), "only one attempt row persisted");
}
@Test
void concurrentSubmit_onlyOneResultAndOneSubmitAudit() throws Exception {
Long u = createUser();
AssessmentAttemptResponse resp = attemptService.create(u, createReq("cs-" + u), ctx);
attemptService.saveAnswers(u, resp.getAttemptId(), answerReq(resp, 4), ctx);
int n = 2;
ExecutorService pool = Executors.newFixedThreadPool(n);
CountDownLatch start = new CountDownLatch(1);
CountDownLatch done = new CountDownLatch(n);
for (int i = 0; i < n; i++) {
pool.submit(() -> {
await(start);
try {
attemptService.submit(u, resp.getAttemptId(), ctx);
} catch (Exception ignored) {
// 并发幂等,允许 9703/原有结果
} finally {
done.countDown();
}
});
}
start.countDown();
assertTrue(done.await(20, TimeUnit.SECONDS), "concurrent submit should finish");
pool.shutdown();
assertEquals(1, resultRepository.findByAttemptId(resp.getAttemptId()).isPresent() ? 1 : 0,
"concurrent submit must produce exactly one result");
long submitAudits = auditLogRepository.findAll().stream()
.filter(a -> "ASSESSMENT_ATTEMPT_SUBMITTED".equals(a.getAction())
&& resp.getAttemptId().equals(a.getEntityId()))
.count();
assertEquals(1, submitAudits, "concurrent submit must write exactly one submit audit");
var attemptRow = attemptRepository.findById(resp.getAttemptId()).orElseThrow();
assertEquals(AssessmentAttempt.STATUS_COMPLETED, attemptRow.getStatus());
assertNull(attemptRow.getActiveKey(), "active_key must be released after completion");
}
@Test
void dbConstraints_uniqueAndCheckEnforced() {
AssessmentAttemptResponse resp = attemptService.create(studentUserId, createReq("k-cons"), ctx);
Long attemptId = resp.getAttemptId();
Long q1 = resp.getQuestions().get(0).getId();
Long q2 = resp.getQuestions().get(1).getId();
answerRepository.saveAndFlush(AssessmentAnswer.builder()
.attemptId(attemptId).questionId(q1).value(4).build());
// 唯一(attempt,question) → 重复插入违反唯一约束
assertThrows(DataIntegrityViolationException.class,
() -> answerRepository.saveAndFlush(AssessmentAnswer.builder()
.attemptId(attemptId).questionId(q1).value(5).build()));
// CHECK value BETWEEN 1 AND 5 → 越界
assertThrows(DataIntegrityViolationException.class,
() -> answerRepository.saveAndFlush(AssessmentAnswer.builder()
.attemptId(attemptId).questionId(q2).value(6).build()));
}
private void await(CountDownLatch latch) {
try {
latch.await();
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
}

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

Loading…
Cancel
Save