diff --git a/backend-infra/v35_acceptance.ps1 b/backend-infra/v35_acceptance.ps1 new file mode 100644 index 0000000..6934875 --- /dev/null +++ b/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' } diff --git a/docs/ability-assessment-api.md b/docs/ability-assessment-api.md new file mode 100644 index 0000000..2232f98 --- /dev/null +++ b/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 编码;试测统计口径为后续运营与题库质量治理工作,不阻塞本版实现。 diff --git a/docs/ability-question-bank-v0.1.json b/docs/ability-question-bank-v0.1.json new file mode 100644 index 0000000..e70f2da --- /dev/null +++ b/docs/ability-question-bank-v0.1.json @@ -0,0 +1,1064 @@ +{ + "bankCode": "WORKPLACE_ABILITY", + "bankVersion": "ability-v0.1", + "status": "LOCKED_FOR_IMPLEMENTATION", + "disclaimer": "本题库为自研工作情境题,用于自我了解和能力提升参考,不构成职业资格、正式招聘结论或永久能力标签。", + "questions": [ + { + "questionCode": "LO01", + "dimension": "LOGIC", + "competencyTag": "INFORMATION_ANALYSIS", + "itemType": "SITUATIONAL_JUDGMENT", + "stem": "销售报表显示本月销售额增长20%,财务报表只增长8%。你首先会怎么处理?", + "options": [ + {"optionId": "A", "text": "核对两份报表的统计口径、时间范围和原始数据来源"}, + {"optionId": "B", "text": "先取两个增长率的平均值作为汇报结果"}, + {"optionId": "C", "text": "先使用财务报表,并注明另一份报表存在差异"}, + {"optionId": "D", "text": "采用增长率较高的报表,因为结果更积极"} + ], + "answerKey": "A", + "scoringRule": {"A": 4, "B": 1, "C": 2, "D": 0}, + "explanation": "冲突数据应先统一口径并追溯原始来源。直接平均、择一或选择有利结果都会掩盖真正的数据问题。", + "difficulty": "EASY", + "discrimination": null, + "questionVersion": "ability-v0.1", + "source": "SELF_AUTHORED", + "active": true, + "position": 1 + }, + { + "questionCode": "LO02", + "dimension": "LOGIC", + "competencyTag": "INFORMATION_ANALYSIS", + "itemType": "SITUATIONAL_JUDGMENT", + "stem": "一项新页面的转化率从10%升到18%,但目前只有20名访问者。你会如何判断效果?", + "options": [ + {"optionId": "A", "text": "立即宣布新页面显著优于旧页面"}, + {"optionId": "B", "text": "扩大样本并与同口径对照组比较后再判断"}, + {"optionId": "C", "text": "继续观察,同时先把18%作为确定结论汇报"}, + {"optionId": "D", "text": "说明样本较小,只能给出暂定趋势而非确定结论"} + ], + "answerKey": "B", + "scoringRule": {"A": 0, "B": 4, "C": 1, "D": 2}, + "explanation": "小样本波动较大,应扩大样本并设置可比基线。仅提示不确定性有价值,但仍不足以验证变化是否来自页面本身。", + "difficulty": "MEDIUM", + "discrimination": null, + "questionVersion": "ability-v0.1", + "source": "SELF_AUTHORED", + "active": true, + "position": 2 + }, + { + "questionCode": "LO03", + "dimension": "LOGIC", + "competencyTag": "INFORMATION_ANALYSIS", + "itemType": "SEQUENCE_JUDGMENT", + "stem": "两份分析报告得出相反结论。以下哪组核查顺序最合理?", + "options": [ + {"optionId": "A", "text": "比较结论→询问作者→补充少量数据→决定采用哪份"}, + {"optionId": "B", "text": "选择更新的报告→删除旧报告→通知相关人员→结束核查"}, + {"optionId": "C", "text": "统一指标定义→核对原始数据→比较分析方法→复算并形成结论"}, + {"optionId": "D", "text": "先复算结果→再确认口径→挑选更可信作者→形成结论"} + ], + "answerKey": "C", + "scoringRule": {"A": 1, "B": 0, "C": 4, "D": 2}, + "explanation": "先统一定义,再核查数据和方法,最后复算,才能避免在不同口径上比较结论。", + "difficulty": "HARD", + "discrimination": null, + "questionVersion": "ability-v0.1", + "source": "SELF_AUTHORED", + "active": true, + "position": 3 + }, + { + "questionCode": "LO04", + "dimension": "LOGIC", + "competencyTag": "INFORMATION_ANALYSIS", + "itemType": "SITUATIONAL_JUDGMENT", + "stem": "必须当天决定是否上线,但关键用户数据尚未收齐。你会怎么做?", + "options": [ + {"optionId": "A", "text": "直接按个人经验上线,后续再看结果"}, + {"optionId": "B", "text": "延期所有工作,直到全部信息完整"}, + {"optionId": "C", "text": "让职位最高的人直接给出判断"}, + {"optionId": "D", "text": "列明已知、未知和关键假设,选择可回滚的小范围方案并设置观察指标"} + ], + "answerKey": "D", + "scoringRule": {"A": 2, "B": 1, "C": 0, "D": 4}, + "explanation": "信息不完整时应显式管理不确定性,并优先选择可回滚、可观察的决策,控制错误成本。", + "difficulty": "MEDIUM", + "discrimination": null, + "questionVersion": "ability-v0.1", + "source": "SELF_AUTHORED", + "active": true, + "position": 4 + }, + { + "questionCode": "LO05", + "dimension": "LOGIC", + "competencyTag": "CAUSAL_REASONING", + "itemType": "SITUATIONAL_JUDGMENT", + "stem": "促销活动上线后订单增长了25%。如何判断增长是否主要由活动带来?", + "options": [ + {"optionId": "A", "text": "比较同期趋势、对照群体并排查价格和流量等同时变化因素"}, + {"optionId": "B", "text": "活动和增长同时发生,因此可以判断活动有效"}, + {"optionId": "C", "text": "查看几条参与活动用户的正面反馈"}, + {"optionId": "D", "text": "只比较活动前一天和活动后一天订单量"} + ], + "answerKey": "A", + "scoringRule": {"A": 4, "B": 2, "C": 1, "D": 0}, + "explanation": "时间上的同时发生不等于因果关系。对照、趋势和混杂因素核查能更可靠地识别活动贡献。", + "difficulty": "EASY", + "discrimination": null, + "questionVersion": "ability-v0.1", + "source": "SELF_AUTHORED", + "active": true, + "position": 5 + }, + { + "questionCode": "LO06", + "dimension": "LOGIC", + "competencyTag": "CAUSAL_REASONING", + "itemType": "SITUATIONAL_JUDGMENT", + "stem": "系统发布新版本后出现大量错误。最合适的第一步是什么?", + "options": [ + {"optionId": "A", "text": "同时修改多个可疑模块,争取尽快碰到正确修复"}, + {"optionId": "B", "text": "先控制影响并保留日志,再对比发布前后变化定位原因"}, + {"optionId": "C", "text": "立即回滚,但不保留现场信息"}, + {"optionId": "D", "text": "等待更多用户反馈后再开始处理"} + ], + "answerKey": "B", + "scoringRule": {"A": 0, "B": 4, "C": 2, "D": 1}, + "explanation": "先止损并保存证据,再围绕版本变化定位原因,既控制业务影响,也避免破坏根因分析条件。", + "difficulty": "MEDIUM", + "discrimination": null, + "questionVersion": "ability-v0.1", + "source": "SELF_AUTHORED", + "active": true, + "position": 6 + }, + { + "questionCode": "LO07", + "dimension": "LOGIC", + "competencyTag": "CAUSAL_REASONING", + "itemType": "SITUATIONAL_JUDGMENT", + "stem": "数据显示加班时间越长的团队交付量越高。你会怎样解释?", + "options": [ + {"optionId": "A", "text": "说明加班一定能提升团队效率"}, + {"optionId": "B", "text": "建议所有团队立即增加加班时间"}, + {"optionId": "C", "text": "检查项目规模、人员数量和紧急程度等共同影响因素"}, + {"optionId": "D", "text": "先承认存在相关性,但暂不据此做因果决策"} + ], + "answerKey": "C", + "scoringRule": {"A": 1, "B": 0, "C": 4, "D": 2}, + "explanation": "交付量和加班可能同时受到项目规模等因素影响。相关性需要结合混杂因素和对照数据解释。", + "difficulty": "HARD", + "discrimination": null, + "questionVersion": "ability-v0.1", + "source": "SELF_AUTHORED", + "active": true, + "position": 7 + }, + { + "questionCode": "LO08", + "dimension": "LOGIC", + "competencyTag": "CAUSAL_REASONING", + "itemType": "SEQUENCE_JUDGMENT", + "stem": "你要验证“缩短注册表单能提高完成率”。哪组步骤最合理?", + "options": [ + {"optionId": "A", "text": "修改表单→全量上线→观察总注册量→询问用户感受"}, + {"optionId": "B", "text": "提出假设→直接上线→发现变化→补写指标定义"}, + {"optionId": "C", "text": "观察历史数据→修改多个流程→比较前后总量→形成结论"}, + {"optionId": "D", "text": "定义指标→设置随机对照→只改变表单长度→达到样本量后分析"} + ], + "answerKey": "D", + "scoringRule": {"A": 2, "B": 1, "C": 0, "D": 4}, + "explanation": "明确指标、设置对照并控制唯一变量,是验证页面变化与转化率之间因果关系的基本路径。", + "difficulty": "HARD", + "discrimination": null, + "questionVersion": "ability-v0.1", + "source": "SELF_AUTHORED", + "active": true, + "position": 8 + }, + { + "questionCode": "LO09", + "dimension": "LOGIC", + "competencyTag": "PROBLEM_SOLVING", + "itemType": "SITUATIONAL_JUDGMENT", + "stem": "你收到“把这个页面做得更好用”的任务。首先应该做什么?", + "options": [ + {"optionId": "A", "text": "明确目标用户、具体场景、当前问题和验收标准"}, + {"optionId": "B", "text": "先按常见设计规范修改一版"}, + {"optionId": "C", "text": "只询问对方喜欢什么颜色和布局"}, + {"optionId": "D", "text": "等待对方给出完整解决方案"} + ], + "answerKey": "A", + "scoringRule": {"A": 4, "B": 2, "C": 1, "D": 0}, + "explanation": "模糊需求应先转化为用户、场景、问题和验收条件,之后才能选择合适的实现方案。", + "difficulty": "EASY", + "discrimination": null, + "questionVersion": "ability-v0.1", + "source": "SELF_AUTHORED", + "active": true, + "position": 9 + }, + { + "questionCode": "LO10", + "dimension": "LOGIC", + "competencyTag": "PROBLEM_SOLVING", + "itemType": "SEQUENCE_JUDGMENT", + "stem": "面对一个复杂而陌生的问题,哪组处理顺序最合理?", + "options": [ + {"optionId": "A", "text": "寻找现成答案→直接实施→发现问题→再定义目标"}, + {"optionId": "B", "text": "明确目标→拆分子问题→确定输入输出→逐项验证并整合"}, + {"optionId": "C", "text": "先处理最熟悉部分→逐步扩大范围→最后确认是否解决目标"}, + {"optionId": "D", "text": "先写完整长方案→交给他人执行→根据反馈决定是否验证"} + ], + "answerKey": "B", + "scoringRule": {"A": 0, "B": 4, "C": 2, "D": 1}, + "explanation": "复杂问题需要先明确目标,再拆解和定义验证条件,避免局部工作很多却没有解决核心问题。", + "difficulty": "MEDIUM", + "discrimination": null, + "questionVersion": "ability-v0.1", + "source": "SELF_AUTHORED", + "active": true, + "position": 10 + }, + { + "questionCode": "LO11", + "dimension": "LOGIC", + "competencyTag": "PROBLEM_SOLVING", + "itemType": "SITUATIONAL_JUDGMENT", + "stem": "你提出的方案试运行后没有达到目标。下一步最合理的是?", + "options": [ + {"optionId": "A", "text": "继续扩大使用范围,以获得更多结果"}, + {"optionId": "B", "text": "认为执行人员没有严格遵循方案"}, + {"optionId": "C", "text": "对照预期与实际,检查假设、执行偏差和外部因素后再调整"}, + {"optionId": "D", "text": "先暂停扩大,并收集失败环节的数据"} + ], + "answerKey": "C", + "scoringRule": {"A": 1, "B": 0, "C": 4, "D": 2}, + "explanation": "方案无效时需要同时检查假设、执行和环境,而不是只扩大范围或归责于某一方。", + "difficulty": "HARD", + "discrimination": null, + "questionVersion": "ability-v0.1", + "source": "SELF_AUTHORED", + "active": true, + "position": 11 + }, + { + "questionCode": "LO12", + "dimension": "LOGIC", + "competencyTag": "PROBLEM_SOLVING", + "itemType": "SITUATIONAL_JUDGMENT", + "stem": "负责人要求估算一个尚未完全明确的任务工期。你会如何回答?", + "options": [ + {"optionId": "A", "text": "给出一个大致天数,并说明只是个人感觉"}, + {"optionId": "B", "text": "拒绝估算,等所有需求完全确定"}, + {"optionId": "C", "text": "参考类似任务,直接承诺相同工期"}, + {"optionId": "D", "text": "拆分已知范围,说明假设、区间、风险以及需要补充的信息"} + ], + "answerKey": "D", + "scoringRule": {"A": 2, "B": 1, "C": 0, "D": 4}, + "explanation": "在不确定条件下,区间估算加上假设和风险说明,比伪精确承诺或完全拒绝估算更可执行。", + "difficulty": "EASY", + "discrimination": null, + "questionVersion": "ability-v0.1", + "source": "SELF_AUTHORED", + "active": true, + "position": 12 + }, + { + "questionCode": "CO01", + "dimension": "COMMUNICATION", + "competencyTag": "LISTENING_UNDERSTANDING", + "itemType": "SITUATIONAL_JUDGMENT", + "stem": "对方提出一个比较模糊的合作需求。你首先会怎么回应?", + "options": [ + {"optionId": "A", "text": "用自己的话复述理解,并确认目标、对象、边界和优先级"}, + {"optionId": "B", "text": "根据过往经验直接开始准备方案"}, + {"optionId": "C", "text": "发送一份通用需求模板,请对方有空时补充"}, + {"optionId": "D", "text": "要求对方想清楚全部细节后再来沟通"} + ], + "answerKey": "A", + "scoringRule": {"A": 4, "B": 2, "C": 1, "D": 0}, + "explanation": "复述与确认能够尽早暴露理解偏差,同时帮助双方把模糊表达转化为共同目标和边界。", + "difficulty": "EASY", + "discrimination": null, + "questionVersion": "ability-v0.1", + "source": "SELF_AUTHORED", + "active": true, + "position": 13 + }, + { + "questionCode": "CO02", + "dimension": "COMMUNICATION", + "competencyTag": "LISTENING_UNDERSTANDING", + "itemType": "SITUATIONAL_JUDGMENT", + "stem": "同事正在说明一次工作失误及其困难。你最合适的做法是?", + "options": [ + {"optionId": "A", "text": "马上告诉对方正确做法,避免继续浪费时间"}, + {"optionId": "B", "text": "先听完并复述关键事实、影响和对方需要的支持"}, + {"optionId": "C", "text": "先听取主要内容,再提出自己的解决建议"}, + {"optionId": "D", "text": "保持沉默,让对方自己整理清楚"} + ], + "answerKey": "B", + "scoringRule": {"A": 0, "B": 4, "C": 2, "D": 1}, + "explanation": "先完整理解事实和需求,再讨论方案,能避免把建议建立在错误假设上,也能降低对方的防御感。", + "difficulty": "MEDIUM", + "discrimination": null, + "questionVersion": "ability-v0.1", + "source": "SELF_AUTHORED", + "active": true, + "position": 14 + }, + { + "questionCode": "CO03", + "dimension": "COMMUNICATION", + "competencyTag": "LISTENING_UNDERSTANDING", + "itemType": "SEQUENCE_JUDGMENT", + "stem": "三方对同一需求理解不同。哪组沟通顺序最合理?", + "options": [ + {"optionId": "A", "text": "各自发言→多数表决→记录结果→开始执行"}, + {"optionId": "B", "text": "负责人定调→其他人补充→形成文档→会后再澄清"}, + {"optionId": "C", "text": "分别复述理解→识别分歧点→对齐目标和证据→确认决定与责任"}, + {"optionId": "D", "text": "收集意见→整理共同点→由负责人决定→通知执行人员"} + ], + "answerKey": "C", + "scoringRule": {"A": 1, "B": 0, "C": 4, "D": 2}, + "explanation": "先让各方显式表达理解,再定位分歧并对齐依据,才能形成真正一致且可执行的结论。", + "difficulty": "HARD", + "discrimination": null, + "questionVersion": "ability-v0.1", + "source": "SELF_AUTHORED", + "active": true, + "position": 15 + }, + { + "questionCode": "CO04", + "dimension": "COMMUNICATION", + "competencyTag": "LISTENING_UNDERSTANDING", + "itemType": "SITUATIONAL_JUDGMENT", + "stem": "客户表达不满,但没有明确说明具体问题。你会怎么做?", + "options": [ + {"optionId": "A", "text": "先说明团队已经严格按流程完成工作"}, + {"optionId": "B", "text": "请客户提交正式投诉材料后再处理"}, + {"optionId": "C", "text": "直接提供补偿,尽快结束沟通"}, + {"optionId": "D", "text": "确认客户感受,再询问具体场景、期望和造成的影响"} + ], + "answerKey": "D", + "scoringRule": {"A": 2, "B": 1, "C": 0, "D": 4}, + "explanation": "先确认感受并提取具体事实,有助于把情绪表达转化为可以处理的问题和期望。", + "difficulty": "MEDIUM", + "discrimination": null, + "questionVersion": "ability-v0.1", + "source": "SELF_AUTHORED", + "active": true, + "position": 16 + }, + { + "questionCode": "CO05", + "dimension": "COMMUNICATION", + "competencyTag": "STRUCTURED_EXPRESSION", + "itemType": "SITUATIONAL_JUDGMENT", + "stem": "你需要向非技术同事解释一次系统故障。最合适的方式是?", + "options": [ + {"optionId": "A", "text": "先讲业务影响和处理进展,再用通俗类比说明原因并确认理解"}, + {"optionId": "B", "text": "完整说明技术栈、日志和异常调用链"}, + {"optionId": "C", "text": "只说系统有问题,修好后再通知"}, + {"optionId": "D", "text": "省略不确定性,给出一个肯定恢复时间"} + ], + "answerKey": "A", + "scoringRule": {"A": 4, "B": 2, "C": 1, "D": 0}, + "explanation": "面向非技术对象应从其关心的影响和行动出发,用适当语言说明原因,同时保留必要的不确定性。", + "difficulty": "EASY", + "discrimination": null, + "questionVersion": "ability-v0.1", + "source": "SELF_AUTHORED", + "active": true, + "position": 17 + }, + { + "questionCode": "CO06", + "dimension": "COMMUNICATION", + "competencyTag": "STRUCTURED_EXPRESSION", + "itemType": "SITUATIONAL_JUDGMENT", + "stem": "发送一条需要对方行动的重要工作消息,应该如何组织?", + "options": [ + {"optionId": "A", "text": "只写“请尽快处理”,减少阅读时间"}, + {"optionId": "B", "text": "说明背景、当前问题、所需行动、负责人和截止时间"}, + {"optionId": "C", "text": "先给结论和行动要求,再附上必要背景"}, + {"optionId": "D", "text": "把全部聊天记录转发过去,让对方自行理解"} + ], + "answerKey": "B", + "scoringRule": {"A": 0, "B": 4, "C": 2, "D": 1}, + "explanation": "清晰的工作消息应让接收者迅速知道为什么、做什么、谁负责以及何时完成。", + "difficulty": "MEDIUM", + "discrimination": null, + "questionVersion": "ability-v0.1", + "source": "SELF_AUTHORED", + "active": true, + "position": 18 + }, + { + "questionCode": "CO07", + "dimension": "COMMUNICATION", + "competencyTag": "STRUCTURED_EXPRESSION", + "itemType": "SITUATIONAL_JUDGMENT", + "stem": "你需要指出同事提交材料中的严重问题。怎样表达最有效?", + "options": [ + {"optionId": "A", "text": "直接说“你做事不够认真”"}, + {"optionId": "B", "text": "只修改材料,不再提及问题"}, + {"optionId": "C", "text": "描述具体问题、造成的影响、修改标准和可获得的支持"}, + {"optionId": "D", "text": "私下指出具体错误,并提醒以后注意"} + ], + "answerKey": "C", + "scoringRule": {"A": 1, "B": 0, "C": 4, "D": 2}, + "explanation": "高质量反馈针对可观察行为和影响,并给出明确标准与支持,不把问题上升为人格评价。", + "difficulty": "HARD", + "discrimination": null, + "questionVersion": "ability-v0.1", + "source": "SELF_AUTHORED", + "active": true, + "position": 19 + }, + { + "questionCode": "CO08", + "dimension": "COMMUNICATION", + "competencyTag": "STRUCTURED_EXPRESSION", + "itemType": "SEQUENCE_JUDGMENT", + "stem": "汇报一个延期项目时,哪组顺序最合理?", + "options": [ + {"optionId": "A", "text": "说明困难→解释原因→请求理解→承诺尽快完成"}, + {"optionId": "B", "text": "汇报已完成工作→省略延期风险→等待追问→再解释"}, + {"optionId": "C", "text": "说明延期→列出责任方→描述过程→等待负责人决定"}, + {"optionId": "D", "text": "先报结论和影响→说明原因与证据→给出方案→明确需要的决定"} + ], + "answerKey": "D", + "scoringRule": {"A": 2, "B": 1, "C": 0, "D": 4}, + "explanation": "延期汇报应优先呈现事实和影响,再提供可验证原因、解决方案和明确决策请求。", + "difficulty": "HARD", + "discrimination": null, + "questionVersion": "ability-v0.1", + "source": "SELF_AUTHORED", + "active": true, + "position": 20 + }, + { + "questionCode": "CO09", + "dimension": "COMMUNICATION", + "competencyTag": "COLLABORATION_CONFLICT", + "itemType": "SITUATIONAL_JUDGMENT", + "stem": "你与同事对方案选择意见相反。你会如何推进?", + "options": [ + {"optionId": "A", "text": "先确认共同目标,再区分事实与偏好,用统一标准比较方案"}, + {"optionId": "B", "text": "请更多同事投票,按人数决定"}, + {"optionId": "C", "text": "暂时接受对方方案,避免影响关系"}, + {"optionId": "D", "text": "重点证明自己的方案更专业"} + ], + "answerKey": "A", + "scoringRule": {"A": 4, "B": 2, "C": 1, "D": 0}, + "explanation": "以共同目标和一致评价标准处理分歧,能够把立场冲突转化为可比较的方案决策。", + "difficulty": "EASY", + "discrimination": null, + "questionVersion": "ability-v0.1", + "source": "SELF_AUTHORED", + "active": true, + "position": 21 + }, + { + "questionCode": "CO10", + "dimension": "COMMUNICATION", + "competencyTag": "COLLABORATION_CONFLICT", + "itemType": "SEQUENCE_JUDGMENT", + "stem": "跨团队依赖已经延期。哪种沟通方式最合理?", + "options": [ + {"optionId": "A", "text": "在公共群指出对方延期→要求承诺→抄送负责人→等待结果"}, + {"optionId": "B", "text": "确认事实和影响→了解阻塞→共同制定新节点→记录责任和升级条件"}, + {"optionId": "C", "text": "先私下询问进度→协商新时间→定期跟进"}, + {"optionId": "D", "text": "自行绕过依赖→完成后再告知对方"} + ], + "answerKey": "B", + "scoringRule": {"A": 0, "B": 4, "C": 2, "D": 1}, + "explanation": "跨团队协作需要共同确认事实、阻塞和新承诺,并提前约定何时升级,避免指责或信息缺失。", + "difficulty": "MEDIUM", + "discrimination": null, + "questionVersion": "ability-v0.1", + "source": "SELF_AUTHORED", + "active": true, + "position": 22 + }, + { + "questionCode": "CO11", + "dimension": "COMMUNICATION", + "competencyTag": "COLLABORATION_CONFLICT", + "itemType": "SITUATIONAL_JUDGMENT", + "stem": "同事临时要求你承担一项会影响原计划的工作。你会怎么回应?", + "options": [ + {"optionId": "A", "text": "直接答应,之后再想办法补原任务"}, + {"optionId": "B", "text": "直接拒绝,因为这不属于原计划"}, + {"optionId": "C", "text": "说明当前承诺和影响,确认优先级并提出替代时间或资源方案"}, + {"optionId": "D", "text": "先了解紧急程度,再决定是否帮助"} + ], + "answerKey": "C", + "scoringRule": {"A": 1, "B": 0, "C": 4, "D": 2}, + "explanation": "边界沟通既要说明已有承诺,也要讨论优先级和替代方案,避免无条件接受或简单拒绝。", + "difficulty": "HARD", + "discrimination": null, + "questionVersion": "ability-v0.1", + "source": "SELF_AUTHORED", + "active": true, + "position": 23 + }, + { + "questionCode": "CO12", + "dimension": "COMMUNICATION", + "competencyTag": "COLLABORATION_CONFLICT", + "itemType": "SITUATIONAL_JUDGMENT", + "stem": "你发现自己的表达让对方产生了误解。最合适的处理方式是?", + "options": [ + {"optionId": "A", "text": "详细解释当时为什么会那样表达"}, + {"optionId": "B", "text": "不再提及,避免让情况更尴尬"}, + {"optionId": "C", "text": "先做出对方希望的承诺,之后再调整"}, + {"optionId": "D", "text": "承认造成的影响,澄清真实意图并共同确认修正行动"} + ], + "answerKey": "D", + "scoringRule": {"A": 2, "B": 1, "C": 0, "D": 4}, + "explanation": "修复误解应同时处理影响、意图和后续行动,而不是只解释动机或给出无法兑现的承诺。", + "difficulty": "EASY", + "discrimination": null, + "questionVersion": "ability-v0.1", + "source": "SELF_AUTHORED", + "active": true, + "position": 24 + }, + { + "questionCode": "EX01", + "dimension": "EXECUTION", + "competencyTag": "PLANNING_PRIORITIZATION", + "itemType": "SITUATIONAL_JUDGMENT", + "stem": "三个任务都临近截止时间,但现有资源只能优先完成一个。你会怎么做?", + "options": [ + {"optionId": "A", "text": "按业务影响、紧急程度和依赖关系排序,并同步取舍方案"}, + {"optionId": "B", "text": "三个任务同时开始,尽量都推进一些"}, + {"optionId": "C", "text": "先完成最容易交付的任务"}, + {"optionId": "D", "text": "等待负责人主动指定优先级"} + ], + "answerKey": "A", + "scoringRule": {"A": 4, "B": 2, "C": 1, "D": 0}, + "explanation": "资源冲突时应基于影响、时效和依赖做明确取舍,并让相关方及时知道后果和安排。", + "difficulty": "EASY", + "discrimination": null, + "questionVersion": "ability-v0.1", + "source": "SELF_AUTHORED", + "active": true, + "position": 25 + }, + { + "questionCode": "EX02", + "dimension": "EXECUTION", + "competencyTag": "PLANNING_PRIORITIZATION", + "itemType": "SITUATIONAL_JUDGMENT", + "stem": "开始一个跨两周的新任务时,你首先会建立什么?", + "options": [ + {"optionId": "A", "text": "一份尽可能详细、覆盖所有可能情况的计划"}, + {"optionId": "B", "text": "明确交付结果、关键步骤、依赖、负责人和检查节点"}, + {"optionId": "C", "text": "先确定最终期限和几个主要步骤"}, + {"optionId": "D", "text": "先从最熟悉的部分开始,再逐步补计划"} + ], + "answerKey": "B", + "scoringRule": {"A": 0, "B": 4, "C": 2, "D": 1}, + "explanation": "可执行计划要连接目标、步骤、依赖、责任与检查点,既能推进,也能尽早发现偏差。", + "difficulty": "MEDIUM", + "discrimination": null, + "questionVersion": "ability-v0.1", + "source": "SELF_AUTHORED", + "active": true, + "position": 26 + }, + { + "questionCode": "EX03", + "dimension": "EXECUTION", + "competencyTag": "PLANNING_PRIORITIZATION", + "itemType": "SITUATIONAL_JUDGMENT", + "stem": "需求范围比预计大,但截止日期没有变化。你会怎样处理?", + "options": [ + {"optionId": "A", "text": "保持原计划,靠加班完成新增范围"}, + {"optionId": "B", "text": "先完成原范围,新增部分到期后再解释"}, + {"optionId": "C", "text": "评估新增工作量和影响,提出范围、资源或时间的取舍方案"}, + {"optionId": "D", "text": "记录需求变化,并提醒负责人可能延期"} + ], + "answerKey": "C", + "scoringRule": {"A": 1, "B": 0, "C": 4, "D": 2}, + "explanation": "范围、资源和时间相互制约。范围变化后应量化影响并推动明确取舍,而不是默默承担风险。", + "difficulty": "HARD", + "discrimination": null, + "questionVersion": "ability-v0.1", + "source": "SELF_AUTHORED", + "active": true, + "position": 27 + }, + { + "questionCode": "EX04", + "dimension": "EXECUTION", + "competencyTag": "PLANNING_PRIORITIZATION", + "itemType": "SEQUENCE_JUDGMENT", + "stem": "把一个目标转成可执行计划,哪组顺序最合理?", + "options": [ + {"optionId": "A", "text": "列任务→安排人员→确定目标→检查结果"}, + {"optionId": "B", "text": "确定期限→开始执行→补充资源→最后验收"}, + {"optionId": "C", "text": "选择工具→分配任务→同步进度→根据结果调整目标"}, + {"optionId": "D", "text": "明确结果→拆分任务→识别依赖和风险→设置节点与验收标准"} + ], + "answerKey": "D", + "scoringRule": {"A": 2, "B": 1, "C": 0, "D": 4}, + "explanation": "计划应从结果出发,再拆解任务、依赖、风险和验收节点,保证每一步都服务于最终目标。", + "difficulty": "MEDIUM", + "discrimination": null, + "questionVersion": "ability-v0.1", + "source": "SELF_AUTHORED", + "active": true, + "position": 28 + }, + { + "questionCode": "EX05", + "dimension": "EXECUTION", + "competencyTag": "RISK_FOLLOWUP", + "itemType": "SITUATIONAL_JUDGMENT", + "stem": "你发现一个依赖项可能导致项目延期。你会怎么管理这项风险?", + "options": [ + {"optionId": "A", "text": "记录触发条件、概率、影响、负责人和应对方案,并持续检查"}, + {"optionId": "B", "text": "在周会上提醒一次,让大家注意"}, + {"optionId": "C", "text": "先准备一个备用方案,但暂不告诉其他人"}, + {"optionId": "D", "text": "等风险真正发生后再处理,避免过度反应"} + ], + "answerKey": "A", + "scoringRule": {"A": 4, "B": 2, "C": 1, "D": 0}, + "explanation": "风险管理需要可观察的触发条件、责任人和预案,并持续跟踪,而不只是一次提醒或被动等待。", + "difficulty": "EASY", + "discrimination": null, + "questionVersion": "ability-v0.1", + "source": "SELF_AUTHORED", + "active": true, + "position": 29 + }, + { + "questionCode": "EX06", + "dimension": "EXECUTION", + "competencyTag": "RISK_FOLLOWUP", + "itemType": "SITUATIONAL_JUDGMENT", + "stem": "你的任务被另一个团队阻塞,且已经影响当前节点。你会怎么做?", + "options": [ + {"optionId": "A", "text": "绕开对方直接修改其负责内容"}, + {"optionId": "B", "text": "说明阻塞事实和影响,提出备选方案并约定升级时间"}, + {"optionId": "C", "text": "持续跟进对方进度,同时调整自己可先做的工作"}, + {"optionId": "D", "text": "记录阻塞,等负责人下次检查时再汇报"} + ], + "answerKey": "B", + "scoringRule": {"A": 0, "B": 4, "C": 2, "D": 1}, + "explanation": "处理阻塞不仅要跟进,还要透明说明影响、准备替代路径并约定升级条件,防止风险继续扩大。", + "difficulty": "MEDIUM", + "discrimination": null, + "questionVersion": "ability-v0.1", + "source": "SELF_AUTHORED", + "active": true, + "position": 30 + }, + { + "questionCode": "EX07", + "dimension": "EXECUTION", + "competencyTag": "RISK_FOLLOWUP", + "itemType": "SITUATIONAL_JUDGMENT", + "stem": "项目进度看似正常,但关键任务一直没有可验证产出。你会怎么处理?", + "options": [ + {"optionId": "A", "text": "继续相信负责人汇报,避免打断执行"}, + {"optionId": "B", "text": "增加每日进度汇报次数"}, + {"optionId": "C", "text": "把关键任务拆成可验收的小节点,用产出和风险重新评估进度"}, + {"optionId": "D", "text": "要求负责人尽快提供一个演示版本"} + ], + "answerKey": "C", + "scoringRule": {"A": 1, "B": 0, "C": 4, "D": 2}, + "explanation": "进度应由可验证产出而非主观百分比衡量。拆成小节点可以提前暴露真实偏差和风险。", + "difficulty": "HARD", + "discrimination": null, + "questionVersion": "ability-v0.1", + "source": "SELF_AUTHORED", + "active": true, + "position": 31 + }, + { + "questionCode": "EX08", + "dimension": "EXECUTION", + "competencyTag": "RISK_FOLLOWUP", + "itemType": "SEQUENCE_JUDGMENT", + "stem": "线上发生严重故障时,哪组执行顺序最合理?", + "options": [ + {"optionId": "A", "text": "查找根因→修改代码→通知用户→恢复服务"}, + {"optionId": "B", "text": "重启服务→观察结果→继续修改→故障后补记录"}, + {"optionId": "C", "text": "通知负责人→等待决定→组织排查→发布修复"}, + {"optionId": "D", "text": "控制影响→保留证据→同步状态→恢复服务→复盘根因"} + ], + "answerKey": "D", + "scoringRule": {"A": 2, "B": 1, "C": 0, "D": 4}, + "explanation": "故障处置首先控制影响,同时保留分析证据并保持信息同步;恢复后再完成系统性根因复盘。", + "difficulty": "HARD", + "discrimination": null, + "questionVersion": "ability-v0.1", + "source": "SELF_AUTHORED", + "active": true, + "position": 32 + }, + { + "questionCode": "EX09", + "dimension": "EXECUTION", + "competencyTag": "DELIVERY_REVIEW", + "itemType": "SITUATIONAL_JUDGMENT", + "stem": "截止时间临近,但交付物仍存在质量风险。你会怎么处理?", + "options": [ + {"optionId": "A", "text": "识别关键质量门槛,缩小非核心范围,分阶段交付并同步风险"}, + {"optionId": "B", "text": "优先保证所有功能完成,测试可以之后补"}, + {"optionId": "C", "text": "继续优化细节,必要时到期后再解释"}, + {"optionId": "D", "text": "直接推迟交付,直到所有问题完全解决"} + ], + "answerKey": "A", + "scoringRule": {"A": 4, "B": 0, "C": 1, "D": 2}, + "explanation": "临近截止时间应守住关键质量底线,并通过范围取舍和分阶段交付透明管理风险。", + "difficulty": "EASY", + "discrimination": null, + "questionVersion": "ability-v0.1", + "source": "SELF_AUTHORED", + "active": true, + "position": 33 + }, + { + "questionCode": "EX10", + "dimension": "EXECUTION", + "competencyTag": "DELIVERY_REVIEW", + "itemType": "SEQUENCE_JUDGMENT", + "stem": "向他人交接一项进行中的任务,哪组顺序最合理?", + "options": [ + {"optionId": "A", "text": "发送文件→说明截止日期→让对方有问题再问→退出任务"}, + {"optionId": "B", "text": "说明目标和现状→移交材料与风险→明确责任和节点→确认对方理解"}, + {"optionId": "C", "text": "口头说明主要事项→发送材料→约定一次后续检查"}, + {"optionId": "D", "text": "把任务重新做一遍→整理文件→通知对方结果"} + ], + "answerKey": "B", + "scoringRule": {"A": 0, "B": 4, "C": 2, "D": 1}, + "explanation": "有效交接需要共同理解目标、现状、材料、风险、责任和检查节点,而不仅是发送文件。", + "difficulty": "MEDIUM", + "discrimination": null, + "questionVersion": "ability-v0.1", + "source": "SELF_AUTHORED", + "active": true, + "position": 34 + }, + { + "questionCode": "EX11", + "dimension": "EXECUTION", + "competencyTag": "DELIVERY_REVIEW", + "itemType": "SITUATIONAL_JUDGMENT", + "stem": "任务完成后结果没有达到预期。最有价值的复盘方式是?", + "options": [ + {"optionId": "A", "text": "确定主要责任人并避免再次由其负责"}, + {"optionId": "B", "text": "记录最终结果,提醒团队以后更认真"}, + {"optionId": "C", "text": "比较目标与实际,分析过程证据,形成有负责人和期限的改进动作"}, + {"optionId": "D", "text": "讨论主要问题并总结经验,供下次参考"} + ], + "answerKey": "C", + "scoringRule": {"A": 1, "B": 0, "C": 4, "D": 2}, + "explanation": "复盘的价值在于从证据中找到可改变因素,并把经验转成有负责人和期限的行动。", + "difficulty": "HARD", + "discrimination": null, + "questionVersion": "ability-v0.1", + "source": "SELF_AUTHORED", + "active": true, + "position": 35 + }, + { + "questionCode": "EX12", + "dimension": "EXECUTION", + "competencyTag": "DELIVERY_REVIEW", + "itemType": "SITUATIONAL_JUDGMENT", + "stem": "开发工作已经完成,怎样才算真正完成交付闭环?", + "options": [ + {"optionId": "A", "text": "代码已经合并,可以把任务标记完成"}, + {"optionId": "B", "text": "部署完成且没有立即出现错误"}, + {"optionId": "C", "text": "把剩余事项记录在自己的待办中"}, + {"optionId": "D", "text": "按验收标准确认结果,补齐文档、监控、交接和明确遗留项"} + ], + "answerKey": "D", + "scoringRule": {"A": 1, "B": 2, "C": 0, "D": 4}, + "explanation": "完成实现不等于完成交付;验收、文档、运行保障、交接和遗留事项都应形成闭环。", + "difficulty": "EASY", + "discrimination": null, + "questionVersion": "ability-v0.1", + "source": "SELF_AUTHORED", + "active": true, + "position": 36 + }, + { + "questionCode": "LE01", + "dimension": "LEARNING", + "competencyTag": "INFORMATION_ACQUISITION", + "itemType": "SITUATIONAL_JUDGMENT", + "stem": "第一次接触一项工作需要的新工具时,你会怎么开始?", + "options": [ + {"optionId": "A", "text": "明确使用目标,阅读权威入门资料,完成小练习后应用到任务"}, + {"optionId": "B", "text": "先看几个热门视频,再边做边查"}, + {"optionId": "C", "text": "直接在正式任务中尝试,遇到问题再处理"}, + {"optionId": "D", "text": "请熟悉工具的同事替自己完成关键部分"} + ], + "answerKey": "A", + "scoringRule": {"A": 4, "B": 2, "C": 1, "D": 0}, + "explanation": "目标、可靠资料、小范围练习和真实应用形成完整学习闭环,能兼顾效率和风险。", + "difficulty": "EASY", + "discrimination": null, + "questionVersion": "ability-v0.1", + "source": "SELF_AUTHORED", + "active": true, + "position": 37 + }, + { + "questionCode": "LE02", + "dimension": "LEARNING", + "competencyTag": "INFORMATION_ACQUISITION", + "itemType": "SITUATIONAL_JUDGMENT", + "stem": "网上对同一技术方案存在互相矛盾的观点。你会怎样判断?", + "options": [ + {"optionId": "A", "text": "选择点赞和转发最多的观点"}, + {"optionId": "B", "text": "比较来源、发布时间、证据、适用条件,并做小范围验证"}, + {"optionId": "C", "text": "优先参考官方文档,再结合一项实践案例"}, + {"optionId": "D", "text": "选择与自己原有经验一致的观点"} + ], + "answerKey": "B", + "scoringRule": {"A": 0, "B": 4, "C": 2, "D": 1}, + "explanation": "可靠判断既需要评价信息来源和适用条件,也需要在自己的场景中验证,而不能只看热度或偏好。", + "difficulty": "MEDIUM", + "discrimination": null, + "questionVersion": "ability-v0.1", + "source": "SELF_AUTHORED", + "active": true, + "position": 38 + }, + { + "questionCode": "LE03", + "dimension": "LEARNING", + "competencyTag": "INFORMATION_ACQUISITION", + "itemType": "SEQUENCE_JUDGMENT", + "stem": "快速进入一个陌生业务领域,哪组学习顺序最合理?", + "options": [ + {"optionId": "A", "text": "收集大量资料→全部阅读→整理术语→开始参与工作"}, + {"optionId": "B", "text": "跟随一名同事→模仿做法→遇到问题再查资料→形成经验"}, + {"optionId": "C", "text": "明确任务问题→建立基础框架→结合案例实践→用反馈补齐知识"}, + {"optionId": "D", "text": "阅读概览→询问关键术语→尝试一项小任务→继续自学"} + ], + "answerKey": "C", + "scoringRule": {"A": 1, "B": 0, "C": 4, "D": 2}, + "explanation": "围绕真实问题建立知识框架,再通过实践和反馈迭代,比无目标囤积资料更有效。", + "difficulty": "HARD", + "discrimination": null, + "questionVersion": "ability-v0.1", + "source": "SELF_AUTHORED", + "active": true, + "position": 39 + }, + { + "questionCode": "LE04", + "dimension": "LEARNING", + "competencyTag": "INFORMATION_ACQUISITION", + "itemType": "SITUATIONAL_JUDGMENT", + "stem": "你只有两小时了解一个新主题并给出初步建议。你会怎么安排?", + "options": [ + {"optionId": "A", "text": "尽量阅读更多文章,以覆盖所有观点"}, + {"optionId": "B", "text": "询问熟悉主题的人并直接采用其建议"}, + {"optionId": "C", "text": "先写出自己的看法,再搜索资料验证"}, + {"optionId": "D", "text": "明确输出问题,优先权威概览和关键证据,并标明未知与后续验证项"} + ], + "answerKey": "D", + "scoringRule": {"A": 2, "B": 1, "C": 0, "D": 4}, + "explanation": "时间有限时,应围绕输出目标选择高价值信息,并明确区分结论、假设和待验证内容。", + "difficulty": "MEDIUM", + "discrimination": null, + "questionVersion": "ability-v0.1", + "source": "SELF_AUTHORED", + "active": true, + "position": 40 + }, + { + "questionCode": "LE05", + "dimension": "LEARNING", + "competencyTag": "FEEDBACK_REFLECTION", + "itemType": "SITUATIONAL_JUDGMENT", + "stem": "你收到“方案不够清晰”的反馈。最有效的回应是?", + "options": [ + {"optionId": "A", "text": "请对方指出具体难以理解之处,修改后再次确认是否解决"}, + {"optionId": "B", "text": "详细解释自己当时的设计思路"}, + {"optionId": "C", "text": "接受反馈,并在下一次方案中注意"}, + {"optionId": "D", "text": "找其他同事判断这条反馈是否合理"} + ], + "answerKey": "A", + "scoringRule": {"A": 4, "B": 2, "C": 1, "D": 0}, + "explanation": "将模糊反馈转化为具体例子,并通过修改后的再次验证,才能真正形成可观察的学习改进。", + "difficulty": "EASY", + "discrimination": null, + "questionVersion": "ability-v0.1", + "source": "SELF_AUTHORED", + "active": true, + "position": 41 + }, + { + "questionCode": "LE06", + "dimension": "LEARNING", + "competencyTag": "FEEDBACK_REFLECTION", + "itemType": "SITUATIONAL_JUDGMENT", + "stem": "一次重要尝试失败后,你最应该优先做什么?", + "options": [ + {"optionId": "A", "text": "立即换一种完全不同的方法重新尝试"}, + {"optionId": "B", "text": "还原目标、过程和关键决策,区分假设错误、执行偏差与外部因素"}, + {"optionId": "C", "text": "先记录失败现象和可能原因,再选择一个方向验证"}, + {"optionId": "D", "text": "等待更多类似失败出现后一起复盘"} + ], + "answerKey": "B", + "scoringRule": {"A": 0, "B": 4, "C": 2, "D": 1}, + "explanation": "有效复盘需要还原过程并区分不同原因类型,避免在没有证据时只更换方法或延后学习。", + "difficulty": "MEDIUM", + "discrimination": null, + "questionVersion": "ability-v0.1", + "source": "SELF_AUTHORED", + "active": true, + "position": 42 + }, + { + "questionCode": "LE07", + "dimension": "LEARNING", + "competencyTag": "FEEDBACK_REFLECTION", + "itemType": "SITUATIONAL_JUDGMENT", + "stem": "怎样最能验证自己真正掌握了一项新知识?", + "options": [ + {"optionId": "A", "text": "能够完整复述资料中的定义"}, + {"optionId": "B", "text": "完成课程并通过课程自带的小测"}, + {"optionId": "C", "text": "能用自己的话解释、应用到新问题,并根据结果修正理解"}, + {"optionId": "D", "text": "尝试向别人讲解,并接受对方提问"} + ], + "answerKey": "C", + "scoringRule": {"A": 1, "B": 0, "C": 4, "D": 2}, + "explanation": "真正掌握不仅是记忆,还包括解释、迁移、实践和基于结果修正认知。", + "difficulty": "HARD", + "discrimination": null, + "questionVersion": "ability-v0.1", + "source": "SELF_AUTHORED", + "active": true, + "position": 43 + }, + { + "questionCode": "LE08", + "dimension": "LEARNING", + "competencyTag": "FEEDBACK_REFLECTION", + "itemType": "SEQUENCE_JUDGMENT", + "stem": "根据反馈持续改进一项技能,哪组顺序最合理?", + "options": [ + {"optionId": "A", "text": "收集反馈→一次修改全部问题→长期练习→最终检查"}, + {"optionId": "B", "text": "确定目标→大量练习→请人评价→记录结果"}, + {"optionId": "C", "text": "模仿优秀示例→反复练习→比较差距→继续模仿"}, + {"optionId": "D", "text": "设定具体目标→刻意练习→获得反馈→调整方法并再次验证"} + ], + "answerKey": "D", + "scoringRule": {"A": 2, "B": 1, "C": 0, "D": 4}, + "explanation": "学习闭环应围绕具体目标,通过练习、反馈、调整和再验证不断缩小差距。", + "difficulty": "HARD", + "discrimination": null, + "questionVersion": "ability-v0.1", + "source": "SELF_AUTHORED", + "active": true, + "position": 44 + }, + { + "questionCode": "LE09", + "dimension": "LEARNING", + "competencyTag": "TRANSFER_ADAPTATION", + "itemType": "SITUATIONAL_JUDGMENT", + "stem": "学会一种分析方法后,你遇到一个相似但条件不同的新问题。你会怎么做?", + "options": [ + {"optionId": "A", "text": "识别共同原理和条件差异,调整方法并用结果验证"}, + {"optionId": "B", "text": "先按原步骤完整执行,再根据结果修改"}, + {"optionId": "C", "text": "重新寻找专门针对新问题的现成方法"}, + {"optionId": "D", "text": "认为条件不同,原方法没有参考价值"} + ], + "answerKey": "A", + "scoringRule": {"A": 4, "B": 2, "C": 1, "D": 0}, + "explanation": "知识迁移的关键是识别稳定原理与变化条件,并通过调整和验证把旧知识应用到新场景。", + "difficulty": "EASY", + "discrimination": null, + "questionVersion": "ability-v0.1", + "source": "SELF_AUTHORED", + "active": true, + "position": 45 + }, + { + "questionCode": "LE10", + "dimension": "LEARNING", + "competencyTag": "TRANSFER_ADAPTATION", + "itemType": "SITUATIONAL_JUDGMENT", + "stem": "你遇到一个自己长时间无法解决的问题,需要向同事求助。怎样提问最有效?", + "options": [ + {"optionId": "A", "text": "把全部材料发给对方,请对方帮忙看看"}, + {"optionId": "B", "text": "说明目标、现象、已尝试方法、当前判断和希望获得的帮助"}, + {"optionId": "C", "text": "整理出最小问题、错误信息和已排除项后提问"}, + {"optionId": "D", "text": "直接请对方演示正确做法,自己跟着学习"} + ], + "answerKey": "B", + "scoringRule": {"A": 0, "B": 4, "C": 2, "D": 1}, + "explanation": "高质量求助能让对方快速理解上下文,也暴露自己的思考过程,从而获得可迁移的指导而非代做。", + "difficulty": "MEDIUM", + "discrimination": null, + "questionVersion": "ability-v0.1", + "source": "SELF_AUTHORED", + "active": true, + "position": 46 + }, + { + "questionCode": "LE11", + "dimension": "LEARNING", + "competencyTag": "TRANSFER_ADAPTATION", + "itemType": "SITUATIONAL_JUDGMENT", + "stem": "你熟悉的工具即将停止维护,团队需要迁移到新工具。你会怎么应对?", + "options": [ + {"optionId": "A", "text": "继续使用旧工具,直到它真正无法运行"}, + {"optionId": "B", "text": "立即把所有工作迁移到最热门的新工具"}, + {"optionId": "C", "text": "比较需求和迁移成本,做小范围验证,形成学习与分阶段迁移计划"}, + {"optionId": "D", "text": "先试用几个候选工具并记录主要差异"} + ], + "answerKey": "C", + "scoringRule": {"A": 1, "B": 0, "C": 4, "D": 2}, + "explanation": "面对变化应结合实际需求、成本和验证结果规划迁移,同时安排学习,避免被动拖延或盲目追新。", + "difficulty": "HARD", + "discrimination": null, + "questionVersion": "ability-v0.1", + "source": "SELF_AUTHORED", + "active": true, + "position": 47 + }, + { + "questionCode": "LE12", + "dimension": "LEARNING", + "competencyTag": "TRANSFER_ADAPTATION", + "itemType": "SEQUENCE_JUDGMENT", + "stem": "制定一项与目标岗位相关的学习计划,哪组顺序最合理?", + "options": [ + {"optionId": "A", "text": "购买课程→安排时间→完成课程→更新简历"}, + {"optionId": "B", "text": "收集热门技能→选择感兴趣内容→集中学习→寻找应用机会"}, + {"optionId": "C", "text": "参考他人路线→复制学习清单→定期检查完成量→继续补课"}, + {"optionId": "D", "text": "分析岗位要求和当前差距→设定可验证目标→练习应用→根据反馈调整"} + ], + "answerKey": "D", + "scoringRule": {"A": 2, "B": 1, "C": 0, "D": 4}, + "explanation": "有效学习计划从真实目标和差距出发,用可验证练习和反馈调整方向,而不是以课程数量衡量成长。", + "difficulty": "EASY", + "discrimination": null, + "questionVersion": "ability-v0.1", + "source": "SELF_AUTHORED", + "active": true, + "position": 48 + } + ] +} diff --git a/docs/backend-development-roadmap.md b/docs/backend-development-roadmap.md index 815194d..824e790 100644 --- a/docs/backend-development-roadmap.md +++ b/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 | 技能词云、入职清单、诚信积分 | diff --git a/docs/frontend-integration.md b/docs/frontend-integration.md index 632e707..fe61687 100644 --- a/docs/frontend-integration.md +++ b/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 聚合返回,前端无需额外拉取明细。 diff --git a/docs/openapi-rc.json b/docs/openapi-rc.json index 24c8fc5..bdaa9f6 100644 --- a/docs/openapi-rc.json +++ b/docs/openapi-rc.json @@ -3343,153 +3343,178 @@ ] } }, - "/jobs/{jobId}/reviews": { + "/assessments/types": { "get": { "tags": [ - "岗位评价" - ], - "summary": "查询岗位评价列表", - "description": "公开访问;仅返回 PUBLISHED 评价,分页;每条含八维评分", - "operationId": "list_2", - "parameters": [ - { - "name": "jobId", - "in": "path", - "required": true, - "schema": { - "type": "integer", - "format": "int64" - } - }, - { - "name": "page", - "in": "query", - "required": false, - "schema": { - "type": "integer", - "format": "int32", - "default": 0 - } - }, - { - "name": "size", - "in": "query", - "required": false, - "schema": { - "type": "integer", - "format": "int32", - "default": 20 - } - } + "RIASEC 职业兴趣测评" ], + "summary": "测评类型列表", + "description": "返回全部 ACTIVE 测评定义。任意已登录用户可访问;未登录 401。", + "operationId": "listAssessmentTypes", "responses": { "200": { "description": "OK", "content": { "*/*": { "schema": { - "$ref": "#/components/schemas/ApiResponseMapStringObject" + "$ref": "#/components/schemas/ApiResponse" + } + } + } + }, + "401": { + "description": "未登录", + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ApiResponse" } } } } - } - }, - "post": { + }, + "security": [ + { + "bearerAuth": [] + } + ] + } + }, + "/assessments/{type}/questions": { + "get": { "tags": [ - "岗位评价" + "RIASEC 职业兴趣测评" ], - "summary": "创建岗位评价", - "description": "仅 STUDENT;同一学生对同一岗位只能评价一次", - "operationId": "create_3", + "summary": "题目列表", + "description": "返回指定测评的 ACTIVE 题目,隐藏维度/reverseScored/source/版本等内部字段。", + "operationId": "listAssessmentQuestions", "parameters": [ { - "name": "jobId", + "name": "type", "in": "path", "required": true, "schema": { - "type": "integer", - "format": "int64" + "type": "string" } } ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/JobReviewCreateRequest" - } - } - }, - "required": true - }, "responses": { "200": { "description": "OK", "content": { "*/*": { "schema": { - "$ref": "#/components/schemas/ApiResponseMapStringObject" + "$ref": "#/components/schemas/ApiResponse" + } + } + } + }, + "401": { + "description": "未登录", + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ApiResponse" + } + } + } + }, + "404": { + "description": "测评类型不存在(9701)", + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ApiResponse" } } } } - } + }, + "security": [ + { + "bearerAuth": [] + } + ] } }, - "/jobs/{jobId}/reviews/summary": { - "get": { + "/assessments/attempts": { + "post": { "tags": [ - "岗位评价" - ], - "summary": "评分汇总", - "description": "公开访问;统计 PUBLISHED 评价的平均分、分布和热门标签", - "operationId": "summary", - "parameters": [ - { - "name": "jobId", - "in": "path", - "required": true, - "schema": { - "type": "integer", - "format": "int64" - } - } + "RIASEC 职业兴趣测评" ], + "summary": "创建答卷", + "description": "照六维各抽 5 题共 30 道并持久化,刷新不重抽。幂等键重放返回原答卷;同键不同定义 409/9709;已有活跃答卷 409/9708。仅 STUDENT。", + "operationId": "createAssessmentAttempt", "responses": { "200": { "description": "OK", "content": { "*/*": { "schema": { - "$ref": "#/components/schemas/ApiResponseMapStringObject" + "$ref": "#/components/schemas/ApiResponse" + } + } + } + }, + "401": { + "description": "未登录", + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ApiResponse" + } + } + } + }, + "403": { + "description": "非 STUDENT(2002)", + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ApiResponse" + } + } + } + }, + "404": { + "description": "测评类型不存在(9701)", + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ApiResponse" + } + } + } + }, + "409": { + "description": "活跃答卷/幂等冲突(9708/9709)", + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ApiResponse" } } } } - } + }, + "security": [ + { + "bearerAuth": [] + } + ] } }, - "/jobs/{jobId}/reviews/{reviewId}": { + "/assessments/attempts/{attemptId}": { "get": { "tags": [ - "岗位评价" + "RIASEC 职业兴趣测评" ], - "summary": "岗位评价详情", - "description": "公开访问;返回单条 PUBLISHED 评价与八维评分", - "operationId": "get_2", + "summary": "答卷详情", + "description": "返回本次抽中的 30 道题(固定顺序)、已保存答案、进度与当前状态。仅本人可访问;非 STUDENT 403。", + "operationId": "getAssessmentAttempt", "parameters": [ { - "name": "jobId", - "in": "path", - "required": true, - "schema": { - "type": "integer", - "format": "int64" - } - }, - { - "name": "reviewId", + "name": "attemptId", "in": "path", "required": true, "schema": { @@ -3504,32 +3529,60 @@ "content": { "*/*": { "schema": { - "$ref": "#/components/schemas/ApiResponseMapStringObject" + "$ref": "#/components/schemas/ApiResponse" + } + } + } + }, + "401": { + "description": "未登录", + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ApiResponse" + } + } + } + }, + "403": { + "description": "非本人或非 STUDENT(2002)", + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ApiResponse" + } + } + } + }, + "404": { + "description": "答卷不存在(9702)", + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ApiResponse" } } } } - } - }, - "put": { + }, + "security": [ + { + "bearerAuth": [] + } + ] + } + }, + "/assessments/attempts/{attemptId}/answers": { + "patch": { "tags": [ - "岗位评价" + "RIASEC 职业兴趣测评" ], - "summary": "修改评价", - "description": "仅评价本人可以修改", - "operationId": "update_2", + "summary": "保存答案", + "description": "单题或批量保存,重复保存 upsert;value 1-5;题目必须属于本答卷;版本冲突 409/9707;首次保存后状态置为 IN_PROGRESS。仅本人。", + "operationId": "saveAssessmentAnswers", "parameters": [ { - "name": "jobId", - "in": "path", - "required": true, - "schema": { - "type": "integer", - "format": "int64" - } - }, - { - "name": "reviewId", + "name": "attemptId", "in": "path", "required": true, "schema": { @@ -3538,48 +3591,76 @@ } } ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/JobReviewUpdateRequest" - } - } - }, - "required": true - }, "responses": { "200": { "description": "OK", "content": { "*/*": { "schema": { - "$ref": "#/components/schemas/ApiResponseMapStringObject" + "$ref": "#/components/schemas/ApiResponse" } } } - } - } - }, - "delete": { + }, + "401": { + "description": "未登录", + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ApiResponse" + } + } + } + }, + "403": { + "description": "非本人或非 STUDENT(2002)", + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ApiResponse" + } + } + } + }, + "404": { + "description": "答卷不存在(9702)", + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ApiResponse" + } + } + } + }, + "409": { + "description": "非法状态/并发(9703/9707)", + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ApiResponse" + } + } + } + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + } + }, + "/assessments/attempts/{attemptId}/submit": { + "post": { "tags": [ - "岗位评价" + "RIASEC 职业兴趣测评" ], - "summary": "删除评价", - "description": "仅评价本人可以删除", - "operationId": "delete_1", + "summary": "提交答卷", + "description": "30 题全答且每维各 5 题后方可提交(否则 400/9706);提交后生成结果并写审计;重复提交返回原结果,不重复生成与审计。仅本人。", + "operationId": "submitAssessmentAttempt", "parameters": [ { - "name": "jobId", - "in": "path", - "required": true, - "schema": { - "type": "integer", - "format": "int64" - } - }, - { - "name": "reviewId", + "name": "attemptId", "in": "path", "required": true, "schema": { @@ -3594,25 +3675,80 @@ "content": { "*/*": { "schema": { - "$ref": "#/components/schemas/ApiResponseMapStringObject" + "$ref": "#/components/schemas/ApiResponse" + } + } + } + }, + "400": { + "description": "未完成(9706)", + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ApiResponse" + } + } + } + }, + "401": { + "description": "未登录", + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ApiResponse" + } + } + } + }, + "403": { + "description": "非本人或非 STUDENT(2002)", + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ApiResponse" + } + } + } + }, + "404": { + "description": "答卷不存在(9702)", + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ApiResponse" + } + } + } + }, + "409": { + "description": "非法状态(9703)", + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ApiResponse" } } } } - } + }, + "security": [ + { + "bearerAuth": [] + } + ] } }, - "/jobs/{jobId}/status": { - "patch": { + "/assessments/attempts/{attemptId}/result": { + "get": { "tags": [ - "岗位" + "RIASEC 职业兴趣测评" ], - "summary": "发布/关闭岗位", - "description": "DRAFT→PUBLISHED,PUBLISHED→CLOSED;禁止非法回退", - "operationId": "updateStatus", + "summary": "查看结果", + "description": "答卷 COMPLETED 后返回聚合分、类型排名、探索方向。仅本人可访问。", + "operationId": "getAssessmentResult", "parameters": [ { - "name": "jobId", + "name": "attemptId", "in": "path", "required": true, "schema": { @@ -3621,39 +3757,130 @@ } } ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/JobStatusRequest" + "responses": { + "200": { + "description": "OK", + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ApiResponse" + } } } }, - "required": true + "401": { + "description": "未登录", + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ApiResponse" + } + } + } + }, + "403": { + "description": "非本人或非 STUDENT(2002)", + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ApiResponse" + } + } + } + }, + "404": { + "description": "答卷不存在(9702)", + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ApiResponse" + } + } + } + }, + "409": { + "description": "答卷未完成(9703)", + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ApiResponse" + } + } + } + } }, + "security": [ + { + "bearerAuth": [] + } + ] + } + }, + "/assessments/me": { + "get": { + "tags": [ + "RIASEC 职业兴趣测评" + ], + "summary": "我的测评历史", + "description": "分页返回当前学生的全部答卷与结果概要。仅 STUDENT 本人可访问。", + "operationId": "listMyAssessments", "responses": { "200": { "description": "OK", "content": { "*/*": { "schema": { - "$ref": "#/components/schemas/ApiResponseMapStringObject" + "$ref": "#/components/schemas/ApiResponse" + } + } + } + }, + "401": { + "description": "未登录", + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ApiResponse" + } + } + } + }, + "403": { + "description": "非 STUDENT(2002)", + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ApiResponse" } } } } - } + }, + "security": [ + { + "bearerAuth": [] + } + ] } }, - "/notifications": { + "/jobs/{jobId}/reviews": { "get": { "tags": [ - "通知" + "岗位评价" ], - "summary": "通知列表", - "description": "当前用户的通知,分页,按时间倒序", - "operationId": "list_3", + "summary": "查询岗位评价列表", + "description": "公开访问;仅返回 PUBLISHED 评价,分页;每条含八维评分", + "operationId": "list_2", "parameters": [ + { + "name": "jobId", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "format": "int64" + } + }, { "name": "page", "in": "query", @@ -3687,38 +3914,35 @@ } } } - } - }, - "/notifications/read-all": { + }, "post": { "tags": [ - "通知" + "岗位评价" ], - "summary": "全部已读", - "description": "标记当前用户全部通知已读(幂等)", - "operationId": "markAllRead", - "responses": { - "200": { - "description": "OK", - "content": { - "*/*": { - "schema": { - "$ref": "#/components/schemas/ApiResponseMapStringObject" - } - } - } - } - } - } - }, - "/notifications/unread-count": { - "get": { - "tags": [ - "通知" + "summary": "创建岗位评价", + "description": "仅 STUDENT;同一学生对同一岗位只能评价一次", + "operationId": "create_3", + "parameters": [ + { + "name": "jobId", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "format": "int64" + } + } ], - "summary": "未读数", - "description": "当前用户未读通知数", - "operationId": "unreadCount", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/JobReviewCreateRequest" + } + } + }, + "required": true + }, "responses": { "200": { "description": "OK", @@ -3733,17 +3957,17 @@ } } }, - "/notifications/{id}/read": { - "post": { + "/jobs/{jobId}/reviews/summary": { + "get": { "tags": [ - "通知" + "岗位评价" ], - "summary": "标记已读", - "description": "标记单条通知已读(幂等)", - "operationId": "markRead", + "summary": "评分汇总", + "description": "公开访问;统计 PUBLISHED 评价的平均分、分布和热门标签", + "operationId": "summary", "parameters": [ { - "name": "id", + "name": "jobId", "in": "path", "required": true, "schema": { @@ -3766,50 +3990,31 @@ } } }, - "/offers": { + "/jobs/{jobId}/reviews/{reviewId}": { "get": { "tags": [ - "Offer" + "岗位评价" ], - "summary": "Offer 列表", - "description": "候选人查看收到的、企业查看发出的 Offer;支持 status、applicationId 筛选与分页", - "operationId": "list_1", + "summary": "岗位评价详情", + "description": "公开访问;返回单条 PUBLISHED 评价与八维评分", + "operationId": "get_2", "parameters": [ { - "name": "status", - "in": "query", - "required": false, - "schema": { - "type": "string" - } - }, - { - "name": "applicationId", - "in": "query", - "required": false, + "name": "jobId", + "in": "path", + "required": true, "schema": { "type": "integer", "format": "int64" } }, { - "name": "page", - "in": "query", - "required": false, - "schema": { - "type": "integer", - "format": "int32", - "default": 0 - } - }, - { - "name": "size", - "in": "query", - "required": false, + "name": "reviewId", + "in": "path", + "required": true, "schema": { "type": "integer", - "format": "int32", - "default": 20 + "format": "int64" } } ], @@ -3826,18 +4031,38 @@ } } }, - "post": { + "put": { "tags": [ - "Offer" + "岗位评价" + ], + "summary": "修改评价", + "description": "仅评价本人可以修改", + "operationId": "update_2", + "parameters": [ + { + "name": "jobId", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "format": "int64" + } + }, + { + "name": "reviewId", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "format": "int64" + } + } ], - "summary": "创建 Offer", - "description": "仅企业账号;投递存在、岗位属于本企业、投递状态可发、无有效 Offer;默认 DRAFT", - "operationId": "create_2", "requestBody": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/OfferCreateRequest" + "$ref": "#/components/schemas/JobReviewUpdateRequest" } } }, @@ -3855,85 +4080,26 @@ } } } - } - }, - "/offers/compare": { - "get": { + }, + "delete": { "tags": [ - "Offer" + "岗位评价" ], - "summary": "Offer 比较", - "description": "仅候选人本人;比较 2-5 个非 DRAFT 的 Offer", - "operationId": "compare", + "summary": "删除评价", + "description": "仅评价本人可以删除", + "operationId": "delete_1", "parameters": [ { - "name": "ids", - "in": "query", + "name": "jobId", + "in": "path", "required": true, - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "OK", - "content": { - "*/*": { - "schema": { - "$ref": "#/components/schemas/ApiResponseListMapStringObject" - } - } - } - } - } - } - }, - "/offers/statistics": { - "get": { - "tags": [ - "Offer" - ], - "summary": "Offer 统计", - "description": "仅企业;统计本企业 Offer 状态分布、接受率、平均响应时间", - "operationId": "statistics", - "parameters": [ - { - "name": "months", - "in": "query", - "required": false, "schema": { "type": "integer", - "format": "int32", - "default": 6 - } - } - ], - "responses": { - "200": { - "description": "OK", - "content": { - "*/*": { - "schema": { - "$ref": "#/components/schemas/ApiResponseMapStringObject" - } - } + "format": "int64" } - } - } - } - }, - "/offers/{id}": { - "get": { - "tags": [ - "Offer" - ], - "summary": "Offer 详情", - "description": "仅目标候选人、所属企业可访问;其他账号返回无权限错误", - "operationId": "get_1", - "parameters": [ + }, { - "name": "id", + "name": "reviewId", "in": "path", "required": true, "schema": { @@ -3954,17 +4120,19 @@ } } } - }, - "put": { + } + }, + "/jobs/{jobId}/status": { + "patch": { "tags": [ - "Offer" + "岗位" ], - "summary": "修改 Offer", - "description": "仅所属企业、仅 DRAFT 状态可修改;其余状态返回业务错误", - "operationId": "update_1", + "summary": "发布/关闭岗位", + "description": "DRAFT→PUBLISHED,PUBLISHED→CLOSED;禁止非法回退", + "operationId": "updateStatus", "parameters": [ { - "name": "id", + "name": "jobId", "in": "path", "required": true, "schema": { @@ -3977,7 +4145,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/OfferUpdateRequest" + "$ref": "#/components/schemas/JobStatusRequest" } } }, @@ -3997,22 +4165,33 @@ } } }, - "/offers/{id}/counter-offers": { + "/notifications": { "get": { "tags": [ - "Offer" + "通知" ], - "summary": "还价列表", - "description": "候选人查看本人 Offer 的还价、企业查看本企业 Offer 的还价", - "operationId": "listCounterOffers", + "summary": "通知列表", + "description": "当前用户的通知,分页,按时间倒序", + "operationId": "list_3", "parameters": [ { - "name": "id", - "in": "path", - "required": true, + "name": "page", + "in": "query", + "required": false, "schema": { "type": "integer", - "format": "int64" + "format": "int32", + "default": 0 + } + }, + { + "name": "size", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "format": "int32", + "default": 20 } } ], @@ -4022,20 +4201,66 @@ "content": { "*/*": { "schema": { - "$ref": "#/components/schemas/ApiResponseListMapStringObject" + "$ref": "#/components/schemas/ApiResponseMapStringObject" } } } } } - }, + } + }, + "/notifications/read-all": { "post": { "tags": [ - "Offer" + "通知" ], - "summary": "发起还价", - "description": "仅候选人本人;仅 SENT/NEGOTIATING 可发起;SENT 时自动进入 NEGOTIATING", - "operationId": "createCounterOffer", + "summary": "全部已读", + "description": "标记当前用户全部通知已读(幂等)", + "operationId": "markAllRead", + "responses": { + "200": { + "description": "OK", + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ApiResponseMapStringObject" + } + } + } + } + } + } + }, + "/notifications/unread-count": { + "get": { + "tags": [ + "通知" + ], + "summary": "未读数", + "description": "当前用户未读通知数", + "operationId": "unreadCount", + "responses": { + "200": { + "description": "OK", + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ApiResponseMapStringObject" + } + } + } + } + } + } + }, + "/notifications/{id}/read": { + "post": { + "tags": [ + "通知" + ], + "summary": "标记已读", + "description": "标记单条通知已读(幂等)", + "operationId": "markRead", "parameters": [ { "name": "id", @@ -4047,16 +4272,6 @@ } } ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/CounterOfferCreateRequest" - } - } - }, - "required": true - }, "responses": { "200": { "description": "OK", @@ -4071,34 +4286,83 @@ } } }, - "/offers/{id}/counter-offers/{coId}/accept": { - "patch": { + "/offers": { + "get": { "tags": [ "Offer" ], - "summary": "接受还价", - "description": "仅所属企业;接受后更新 Offer 薪资/入职日期并变为 ACCEPTED", - "operationId": "acceptCounterOffer", + "summary": "Offer 列表", + "description": "候选人查看收到的、企业查看发出的 Offer;支持 status、applicationId 筛选与分页", + "operationId": "list_1", "parameters": [ { - "name": "id", - "in": "path", - "required": true, + "name": "status", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "applicationId", + "in": "query", + "required": false, "schema": { "type": "integer", "format": "int64" } }, { - "name": "coId", - "in": "path", - "required": true, + "name": "page", + "in": "query", + "required": false, "schema": { "type": "integer", - "format": "int64" + "format": "int32", + "default": 0 + } + }, + { + "name": "size", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "format": "int32", + "default": 20 + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ApiResponseMapStringObject" + } + } } } + } + }, + "post": { + "tags": [ + "Offer" ], + "summary": "创建 Offer", + "description": "仅企业账号;投递存在、岗位属于本企业、投递状态可发、无有效 Offer;默认 DRAFT", + "operationId": "create_2", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/OfferCreateRequest" + } + } + }, + "required": true + }, "responses": { "200": { "description": "OK", @@ -4113,31 +4377,55 @@ } } }, - "/offers/{id}/counter-offers/{coId}/reject": { - "patch": { + "/offers/compare": { + "get": { "tags": [ "Offer" ], - "summary": "拒绝还价", - "description": "仅所属企业;拒绝后 Offer 保持 NEGOTIATING", - "operationId": "rejectCounterOffer", + "summary": "Offer 比较", + "description": "仅候选人本人;比较 2-5 个非 DRAFT 的 Offer", + "operationId": "compare", "parameters": [ { - "name": "id", - "in": "path", + "name": "ids", + "in": "query", "required": true, "schema": { - "type": "integer", - "format": "int64" + "type": "string" } - }, + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ApiResponseListMapStringObject" + } + } + } + } + } + } + }, + "/offers/statistics": { + "get": { + "tags": [ + "Offer" + ], + "summary": "Offer 统计", + "description": "仅企业;统计本企业 Offer 状态分布、接受率、平均响应时间", + "operationId": "statistics", + "parameters": [ { - "name": "coId", - "in": "path", - "required": true, + "name": "months", + "in": "query", + "required": false, "schema": { "type": "integer", - "format": "int64" + "format": "int32", + "default": 6 } } ], @@ -4155,14 +4443,14 @@ } } }, - "/offers/{id}/remind": { - "post": { + "/offers/{id}": { + "get": { "tags": [ "Offer" ], - "summary": "手动提醒", - "description": "仅所属企业;仅 SENT/NEGOTIATING 可提醒;相同提醒不重复创建", - "operationId": "remind", + "summary": "Offer 详情", + "description": "仅目标候选人、所属企业可访问;其他账号返回无权限错误", + "operationId": "get_1", "parameters": [ { "name": "id", @@ -4186,16 +4474,14 @@ } } } - } - }, - "/offers/{id}/status": { - "patch": { + }, + "put": { "tags": [ "Offer" ], - "summary": "Offer 状态变更", - "description": "DRAFT→SENT 企业;SENT/NEGOTIATING→ACCEPTED/REJECTED/NEGOTIATING 候选人;重复提交幂等", - "operationId": "changeStatus", + "summary": "修改 Offer", + "description": "仅所属企业、仅 DRAFT 状态可修改;其余状态返回业务错误", + "operationId": "update_1", "parameters": [ { "name": "id", @@ -4211,7 +4497,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/OfferStatusRequest" + "$ref": "#/components/schemas/OfferUpdateRequest" } } }, @@ -4231,14 +4517,14 @@ } } }, - "/offers/{id}/timeline": { + "/offers/{id}/counter-offers": { "get": { "tags": [ "Offer" ], - "summary": "Offer 状态时间线", - "description": "完整状态变更历史,含操作者、原因与请求 ID", - "operationId": "timeline", + "summary": "还价列表", + "description": "候选人查看本人 Offer 的还价、企业查看本企业 Offer 的还价", + "operationId": "listCounterOffers", "parameters": [ { "name": "id", @@ -4262,23 +4548,257 @@ } } } - } - }, - "/profile/me": { - "get": { + }, + "post": { "tags": [ - "个人资料" + "Offer" ], - "summary": "查询当前学生资料", - "description": "查询当前登录学生(仅 STUDENT 角色)自己的学生资料。身份仅从 JWT/SecurityContext 获取,不接受 userId、studentUserId 等请求参数;本接口无 Path、Query、Body 参数。若尚未创建个人资料:HTTP 200,data 为空对象 {}(非 null),且不会自动创建数据库记录。当前未设置接口级限流。", - "operationId": "getMyProfile", + "summary": "发起还价", + "description": "仅候选人本人;仅 SENT/NEGOTIATING 可发起;SENT 时自动进入 NEGOTIATING", + "operationId": "createCounterOffer", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "format": "int64" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CounterOfferCreateRequest" + } + } + }, + "required": true + }, "responses": { "200": { - "description": "成功;有资料返回资料对象,无资料返回空对象 {};message=ok", + "description": "OK", "content": { - "application/json": { + "*/*": { "schema": { - "$ref": "#/components/schemas/ApiResponseStudentProfileResponse" + "$ref": "#/components/schemas/ApiResponseMapStringObject" + } + } + } + } + } + } + }, + "/offers/{id}/counter-offers/{coId}/accept": { + "patch": { + "tags": [ + "Offer" + ], + "summary": "接受还价", + "description": "仅所属企业;接受后更新 Offer 薪资/入职日期并变为 ACCEPTED", + "operationId": "acceptCounterOffer", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "format": "int64" + } + }, + { + "name": "coId", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "format": "int64" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ApiResponseMapStringObject" + } + } + } + } + } + } + }, + "/offers/{id}/counter-offers/{coId}/reject": { + "patch": { + "tags": [ + "Offer" + ], + "summary": "拒绝还价", + "description": "仅所属企业;拒绝后 Offer 保持 NEGOTIATING", + "operationId": "rejectCounterOffer", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "format": "int64" + } + }, + { + "name": "coId", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "format": "int64" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ApiResponseMapStringObject" + } + } + } + } + } + } + }, + "/offers/{id}/remind": { + "post": { + "tags": [ + "Offer" + ], + "summary": "手动提醒", + "description": "仅所属企业;仅 SENT/NEGOTIATING 可提醒;相同提醒不重复创建", + "operationId": "remind", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "format": "int64" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ApiResponseMapStringObject" + } + } + } + } + } + } + }, + "/offers/{id}/status": { + "patch": { + "tags": [ + "Offer" + ], + "summary": "Offer 状态变更", + "description": "DRAFT→SENT 企业;SENT/NEGOTIATING→ACCEPTED/REJECTED/NEGOTIATING 候选人;重复提交幂等", + "operationId": "changeStatus", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "format": "int64" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/OfferStatusRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "OK", + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ApiResponseMapStringObject" + } + } + } + } + } + } + }, + "/offers/{id}/timeline": { + "get": { + "tags": [ + "Offer" + ], + "summary": "Offer 状态时间线", + "description": "完整状态变更历史,含操作者、原因与请求 ID", + "operationId": "timeline", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "format": "int64" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ApiResponseListMapStringObject" + } + } + } + } + } + } + }, + "/profile/me": { + "get": { + "tags": [ + "个人资料" + ], + "summary": "查询当前学生资料", + "description": "查询当前登录学生(仅 STUDENT 角色)自己的学生资料。身份仅从 JWT/SecurityContext 获取,不接受 userId、studentUserId 等请求参数;本接口无 Path、Query、Body 参数。若尚未创建个人资料:HTTP 200,data 为空对象 {}(非 null),且不会自动创建数据库记录。当前未设置接口级限流。", + "operationId": "getMyProfile", + "responses": { + "200": { + "description": "成功;有资料返回资料对象,无资料返回空对象 {};message=ok", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResponseStudentProfileResponse" }, "examples": { "success-with-data": { @@ -4776,13 +5296,21 @@ "name": "page", "in": "query", "required": false, - "schema": { "type": "integer", "format": "int32", "default": 0 } + "schema": { + "type": "integer", + "format": "int32", + "default": 0 + } }, { "name": "size", "in": "query", "required": false, - "schema": { "type": "integer", "format": "int32", "default": 20 } + "schema": { + "type": "integer", + "format": "int32", + "default": 20 + } }, { "name": "status", @@ -4790,30 +5318,63 @@ "required": false, "schema": { "type": "string", - "enum": ["SUBMITTED", "VIEWED", "INTERVIEW", "HIRED", "REJECTED", "WITHDRAWN"] + "enum": [ + "SUBMITTED", + "VIEWED", + "INTERVIEW", + "HIRED", + "REJECTED", + "WITHDRAWN" + ] } } ], "responses": { "200": { "description": "OK", - "content": { "*/*": { "schema": { "$ref": "#/components/schemas/ApiResponseCareerProgressResponse" } } } + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ApiResponseCareerProgressResponse" + } + } + } }, "400": { "description": "page/size/status 参数非法", - "content": { "*/*": { "schema": { "$ref": "#/components/schemas/ApiResponse" } } } + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ApiResponse" + } + } + } }, "401": { "description": "未登录", - "content": { "*/*": { "schema": { "$ref": "#/components/schemas/ApiResponse" } } } + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ApiResponse" + } + } + } }, "403": { "description": "非 STUDENT 角色", - "content": { "*/*": { "schema": { "$ref": "#/components/schemas/ApiResponse" } } } + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ApiResponse" + } + } + } } }, "security": [ - { "bearerAuth": [] } + { + "bearerAuth": [] + } ] } }, @@ -4957,22 +5518,74 @@ "description": "仅本人;按创建时间倒序分页;status 可选:PENDING/PROCESSING/COMPLETED/FAILED/EXPIRED/CANCELLED", "operationId": "listDataExportTasks", "parameters": [ - { "name": "page", "in": "query", "required": false, "schema": { "type": "integer", "default": 0 } }, - { "name": "size", "in": "query", "required": false, "schema": { "type": "integer", "default": 20 } }, - { "name": "status", "in": "query", "required": false, "schema": { "type": "string" } } + { + "name": "page", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "default": 0 + } + }, + { + "name": "size", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "default": 20 + } + }, + { + "name": "status", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + } ], "responses": { "200": { "description": "success", "content": { "*/*": { - "schema": { "$ref": "#/components/schemas/ApiResponsePageResponseDataExportTaskResponse" } + "schema": { + "$ref": "#/components/schemas/ApiResponsePageResponseDataExportTaskResponse" + } + } + } + }, + "401": { + "description": "error", + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ApiResponse" + } + } + } + }, + "403": { + "description": "error", + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ApiResponse" + } } } }, - "401": { "description": "error", "content": { "*/*": { "schema": { "$ref": "#/components/schemas/ApiResponse" } } } }, - "403": { "description": "error", "content": { "*/*": { "schema": { "$ref": "#/components/schemas/ApiResponse" } } } }, - "409": { "description": "error", "content": { "*/*": { "schema": { "$ref": "#/components/schemas/ApiResponse" } } } } + "409": { + "description": "error", + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ApiResponse" + } + } + } + } } }, "post": { @@ -4986,7 +5599,9 @@ "requestBody": { "content": { "application/json": { - "schema": { "$ref": "#/components/schemas/CreateDataExportRequest" } + "schema": { + "$ref": "#/components/schemas/CreateDataExportRequest" + } } }, "required": false @@ -4996,13 +5611,42 @@ "description": "success", "content": { "*/*": { - "schema": { "$ref": "#/components/schemas/ApiResponseDataExportTaskResponse" } + "schema": { + "$ref": "#/components/schemas/ApiResponseDataExportTaskResponse" + } + } + } + }, + "401": { + "description": "error", + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ApiResponse" + } + } + } + }, + "403": { + "description": "error", + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ApiResponse" + } } } }, - "401": { "description": "error", "content": { "*/*": { "schema": { "$ref": "#/components/schemas/ApiResponse" } } } }, - "403": { "description": "error", "content": { "*/*": { "schema": { "$ref": "#/components/schemas/ApiResponse" } } } }, - "409": { "description": "error", "content": { "*/*": { "schema": { "$ref": "#/components/schemas/ApiResponse" } } } } + "409": { + "description": "error", + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ApiResponse" + } + } + } + } } } }, @@ -5015,20 +5659,57 @@ "description": "仅本人;不暴露 storageKey 与内部字段", "operationId": "getDataExportTask", "parameters": [ - { "name": "taskId", "in": "path", "required": true, "schema": { "type": "integer", "format": "int64" } } + { + "name": "taskId", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "format": "int64" + } + } ], "responses": { "200": { "description": "success", "content": { "*/*": { - "schema": { "$ref": "#/components/schemas/ApiResponseDataExportTaskResponse" } + "schema": { + "$ref": "#/components/schemas/ApiResponseDataExportTaskResponse" + } + } + } + }, + "401": { + "description": "error", + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ApiResponse" + } + } + } + }, + "403": { + "description": "error", + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ApiResponse" + } } } }, - "401": { "description": "error", "content": { "*/*": { "schema": { "$ref": "#/components/schemas/ApiResponse" } } } }, - "403": { "description": "error", "content": { "*/*": { "schema": { "$ref": "#/components/schemas/ApiResponse" } } } }, - "404": { "description": "error", "content": { "*/*": { "schema": { "$ref": "#/components/schemas/ApiResponse" } } } } + "404": { + "description": "error", + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ApiResponse" + } + } + } + } } } }, @@ -5041,20 +5722,58 @@ "description": "仅本人且 COMPLETED 未过期;attachment;需 JWT", "operationId": "downloadDataExportTask", "parameters": [ - { "name": "taskId", "in": "path", "required": true, "schema": { "type": "integer", "format": "int64" } } + { + "name": "taskId", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "format": "int64" + } + } ], "responses": { "200": { "description": "success", "content": { "application/zip": { - "schema": { "type": "string", "format": "binary" } + "schema": { + "type": "string", + "format": "binary" + } + } + } + }, + "401": { + "description": "error", + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ApiResponse" + } + } + } + }, + "403": { + "description": "error", + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ApiResponse" + } } } }, - "401": { "description": "error", "content": { "*/*": { "schema": { "$ref": "#/components/schemas/ApiResponse" } } } }, - "403": { "description": "error", "content": { "*/*": { "schema": { "$ref": "#/components/schemas/ApiResponse" } } } }, - "409": { "description": "error", "content": { "*/*": { "schema": { "$ref": "#/components/schemas/ApiResponse" } } } } + "409": { + "description": "error", + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ApiResponse" + } + } + } + } } } }, @@ -6123,255 +6842,1851 @@ }, "/reports": { "post": { - "tags": ["举报与屏蔽"], + "tags": [ + "举报与屏蔽" + ], "summary": "提交举报", "description": "targetType=USER 必填 targetUserId;MESSAGE 必填 messageId;reportedUserId 由服务端推导。同 reporter+idempotencyKey+相同请求体返回原举报 200;不同请求体 409/9606;同目标同分类已有 PENDING 409/9603。限流:每用户每小时 10 次,超限 429/9609。", "operationId": "createReport", - "security": [{ "bearerAuth": [] }], + "security": [ + { + "bearerAuth": [] + } + ], "requestBody": { "content": { "application/json": { - "schema": { "$ref": "#/components/schemas/CreateReportRequest" } + "schema": { + "$ref": "#/components/schemas/CreateReportRequest" + } } }, "required": true }, "responses": { - "200": { "description": "OK", "content": { "*/*": { "schema": { "$ref": "#/components/schemas/ApiResponseReportResponse" } } } }, - "400": { "description": "BAD_REQUEST(1001)/举报自己 9602/理由缺失 9607", "content": { "*/*": { "schema": { "$ref": "#/components/schemas/ApiResponse" } } } }, - "401": { "description": "未登录", "content": { "*/*": { "schema": { "$ref": "#/components/schemas/ApiResponse" } } } }, - "403": { "description": "无权访问消息(2002,不泄露消息是否存在)", "content": { "*/*": { "schema": { "$ref": "#/components/schemas/ApiResponse" } } } }, - "409": { "description": "重复举报 9603 / 幂等冲突 9606", "content": { "*/*": { "schema": { "$ref": "#/components/schemas/ApiResponse" } } } }, - "429": { "description": "频率超限 9609", "content": { "*/*": { "schema": { "$ref": "#/components/schemas/ApiResponse" } } } } + "200": { + "description": "OK", + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ApiResponseReportResponse" + } + } + } + }, + "400": { + "description": "BAD_REQUEST(1001)/举报自己 9602/理由缺失 9607", + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ApiResponse" + } + } + } + }, + "401": { + "description": "未登录", + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ApiResponse" + } + } + } + }, + "403": { + "description": "无权访问消息(2002,不泄露消息是否存在)", + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ApiResponse" + } + } + } + }, + "409": { + "description": "重复举报 9603 / 幂等冲突 9606", + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ApiResponse" + } + } + } + }, + "429": { + "description": "频率超限 9609", + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ApiResponse" + } + } + } + } } } }, "/reports/me": { "get": { - "tags": ["举报与屏蔽"], + "tags": [ + "举报与屏蔽" + ], "summary": "我的举报列表", "description": "仅返回当前用户创建的举报;身份来自 JWT;不返回举报人内部信息/phone/email/passwordHash/resumeUrl/消息全文;支持 status/page/size。", "operationId": "listMyReports", - "security": [{ "bearerAuth": [] }], - "parameters": [ - { "name": "status", "in": "query", "required": false, "schema": { "type": "string" } }, - { "name": "page", "in": "query", "required": false, "schema": { "type": "integer", "format": "int32", "default": 0 } }, - { "name": "size", "in": "query", "required": false, "schema": { "type": "integer", "format": "int32", "default": 20 } } + "security": [ + { + "bearerAuth": [] + } ], - "responses": { - "200": { "description": "OK", "content": { "*/*": { "schema": { "$ref": "#/components/schemas/ApiResponsePageResponseReportResponse" } } } }, - "401": { "description": "未登录", "content": { "*/*": { "schema": { "$ref": "#/components/schemas/ApiResponse" } } } } - } - } - }, - "/reports/{id}": { - "get": { - "tags": ["举报与屏蔽"], - "summary": "举报详情(本人/ADMIN)", - "description": "当前举报人本人或 ADMIN 可查看;其他 403/2002;非 ADMIN 不显示举报人内部 ID/手机号/邮箱;MESSAGE 仅返回脱敏 messagePreview。", - "operationId": "getReportDetail", - "security": [{ "bearerAuth": [] }], "parameters": [ - { "name": "id", "in": "path", "required": true, "schema": { "type": "integer", "format": "int64" } } - ], - "responses": { - "200": { "description": "OK", "content": { "*/*": { "schema": { "$ref": "#/components/schemas/ApiResponseReportResponse" } } } }, - "401": { "description": "未登录", "content": { "*/*": { "schema": { "$ref": "#/components/schemas/ApiResponse" } } } }, - "403": { "description": "非本人非 ADMIN(2002)", "content": { "*/*": { "schema": { "$ref": "#/components/schemas/ApiResponse" } } } }, - "404": { "description": "举报不存在(9601)", "content": { "*/*": { "schema": { "$ref": "#/components/schemas/ApiResponse" } } } } + { + "name": "status", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "page", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "format": "int32", + "default": 0 + } + }, + { + "name": "size", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "format": "int32", + "default": 20 + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ApiResponsePageResponseReportResponse" + } + } + } + }, + "401": { + "description": "未登录", + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ApiResponse" + } + } + } + } + } + } + }, + "/reports/{id}": { + "get": { + "tags": [ + "举报与屏蔽" + ], + "summary": "举报详情(本人/ADMIN)", + "description": "当前举报人本人或 ADMIN 可查看;其他 403/2002;非 ADMIN 不显示举报人内部 ID/手机号/邮箱;MESSAGE 仅返回脱敏 messagePreview。", + "operationId": "getReportDetail", + "security": [ + { + "bearerAuth": [] + } + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "format": "int64" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ApiResponseReportResponse" + } + } + } + }, + "401": { + "description": "未登录", + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ApiResponse" + } + } + } + }, + "403": { + "description": "非本人非 ADMIN(2002)", + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ApiResponse" + } + } + } + }, + "404": { + "description": "举报不存在(9601)", + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ApiResponse" + } + } + } + } } } }, "/blocks/{blockedUserId}": { "put": { - "tags": ["举报与屏蔽"], + "tags": [ + "举报与屏蔽" + ], "summary": "屏蔽用户", "description": "屏蔽自己 400/9608;目标不存在复用用户不存在错误码;已存在返回 200 不新增;不影响系统通知/投递/Offer/面试/评分/评价。", "operationId": "blockUser", - "security": [{ "bearerAuth": [] }], + "security": [ + { + "bearerAuth": [] + } + ], "parameters": [ - { "name": "blockedUserId", "in": "path", "required": true, "schema": { "type": "integer", "format": "int64" } } + { + "name": "blockedUserId", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "format": "int64" + } + } ], "responses": { - "200": { "description": "OK", "content": { "*/*": { "schema": { "$ref": "#/components/schemas/ApiResponseBlockRelationResponse" } } } }, - "400": { "description": "屏蔽自己 9608", "content": { "*/*": { "schema": { "$ref": "#/components/schemas/ApiResponse" } } } }, - "401": { "description": "未登录", "content": { "*/*": { "schema": { "$ref": "#/components/schemas/ApiResponse" } } } } + "200": { + "description": "OK", + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ApiResponseBlockRelationResponse" + } + } + } + }, + "400": { + "description": "屏蔽自己 9608", + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ApiResponse" + } + } + } + }, + "401": { + "description": "未登录", + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ApiResponse" + } + } + } + } } }, "delete": { - "tags": ["举报与屏蔽"], + "tags": [ + "举报与屏蔽" + ], "summary": "解除屏蔽", "description": "幂等:不存在时返回 200 不报错。", "operationId": "unblockUser", - "security": [{ "bearerAuth": [] }], + "security": [ + { + "bearerAuth": [] + } + ], "parameters": [ - { "name": "blockedUserId", "in": "path", "required": true, "schema": { "type": "integer", "format": "int64" } } + { + "name": "blockedUserId", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "format": "int64" + } + } ], "responses": { - "200": { "description": "OK(幂等)", "content": { "*/*": { "schema": { "$ref": "#/components/schemas/ApiResponse" } } } }, - "401": { "description": "未登录", "content": { "*/*": { "schema": { "$ref": "#/components/schemas/ApiResponse" } } } } + "200": { + "description": "OK(幂等)", + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ApiResponse" + } + } + } + }, + "401": { + "description": "未登录", + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ApiResponse" + } + } + } + } } } }, "/blocks/me": { "get": { - "tags": ["举报与屏蔽"], + "tags": [ + "举报与屏蔽" + ], "summary": "我的屏蔽列表", "description": "仅返回当前用户主动屏蔽的用户;不返回对方手机号/邮箱/密码/简历地址;支持 page/size。", "operationId": "listMyBlocks", - "security": [{ "bearerAuth": [] }], + "security": [ + { + "bearerAuth": [] + } + ], "parameters": [ - { "name": "page", "in": "query", "required": false, "schema": { "type": "integer", "format": "int32", "default": 0 } }, - { "name": "size", "in": "query", "required": false, "schema": { "type": "integer", "format": "int32", "default": 20 } } + { + "name": "page", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "format": "int32", + "default": 0 + } + }, + { + "name": "size", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "format": "int32", + "default": 20 + } + } ], "responses": { - "200": { "description": "OK", "content": { "*/*": { "schema": { "$ref": "#/components/schemas/ApiResponsePageResponseBlockRelationResponse" } } } }, - "401": { "description": "未登录", "content": { "*/*": { "schema": { "$ref": "#/components/schemas/ApiResponse" } } } } + "200": { + "description": "OK", + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ApiResponsePageResponseBlockRelationResponse" + } + } + } + }, + "401": { + "description": "未登录", + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ApiResponse" + } + } + } + } } } }, "/admin/reports": { "get": { - "tags": ["举报管理"], + "tags": [ + "举报管理" + ], "summary": "举报列表(ADMIN)", "description": "仅 ADMIN;COMPANY/STUDENT/未登录分别 403/403/401;支持 status/targetType/category/page/size;响应脱敏。", "operationId": "adminListReports", - "security": [{ "bearerAuth": [] }], - "parameters": [ - { "name": "status", "in": "query", "required": false, "schema": { "type": "string" } }, - { "name": "targetType", "in": "query", "required": false, "schema": { "type": "string" } }, - { "name": "category", "in": "query", "required": false, "schema": { "type": "string" } }, - { "name": "page", "in": "query", "required": false, "schema": { "type": "integer", "format": "int32", "default": 0 } }, - { "name": "size", "in": "query", "required": false, "schema": { "type": "integer", "format": "int32", "default": 20 } } + "security": [ + { + "bearerAuth": [] + } ], - "responses": { - "200": { "description": "OK", "content": { "*/*": { "schema": { "$ref": "#/components/schemas/ApiResponsePageResponseReportResponse" } } } }, - "401": { "description": "未登录", "content": { "*/*": { "schema": { "$ref": "#/components/schemas/ApiResponse" } } } }, - "403": { "description": "非 ADMIN", "content": { "*/*": { "schema": { "$ref": "#/components/schemas/ApiResponse" } } } } - } - } - }, - "/admin/reports/{id}": { - "get": { - "tags": ["举报管理"], - "summary": "举报详情(ADMIN)", - "description": "仅 ADMIN;可见类型/分类/理由/脱敏证据 URL/MESSAGE 脱敏内容/会话最小信息/处理历史;不返回密码/JWT/手机号/邮箱/resumeUrl。", - "operationId": "adminGetReport", - "security": [{ "bearerAuth": [] }], "parameters": [ - { "name": "id", "in": "path", "required": true, "schema": { "type": "integer", "format": "int64" } } - ], - "responses": { - "200": { "description": "OK", "content": { "*/*": { "schema": { "$ref": "#/components/schemas/ApiResponseReportResponse" } } } }, - "401": { "description": "未登录", "content": { "*/*": { "schema": { "$ref": "#/components/schemas/ApiResponse" } } } }, - "403": { "description": "非 ADMIN", "content": { "*/*": { "schema": { "$ref": "#/components/schemas/ApiResponse" } } } }, - "404": { "description": "举报不存在(9601)", "content": { "*/*": { "schema": { "$ref": "#/components/schemas/ApiResponse" } } } } - } - } + { + "name": "status", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "targetType", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "category", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "page", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "format": "int32", + "default": 0 + } + }, + { + "name": "size", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "format": "int32", + "default": 20 + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ApiResponsePageResponseReportResponse" + } + } + } + }, + "401": { + "description": "未登录", + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ApiResponse" + } + } + } + }, + "403": { + "description": "非 ADMIN", + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ApiResponse" + } + } + } + } + } + } + }, + "/admin/reports/{id}": { + "get": { + "tags": [ + "举报管理" + ], + "summary": "举报详情(ADMIN)", + "description": "仅 ADMIN;可见类型/分类/理由/脱敏证据 URL/MESSAGE 脱敏内容/会话最小信息/处理历史;不返回密码/JWT/手机号/邮箱/resumeUrl。", + "operationId": "adminGetReport", + "security": [ + { + "bearerAuth": [] + } + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "format": "int64" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ApiResponseReportResponse" + } + } + } + }, + "401": { + "description": "未登录", + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ApiResponse" + } + } + } + }, + "403": { + "description": "非 ADMIN", + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ApiResponse" + } + } + } + }, + "404": { + "description": "举报不存在(9601)", + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ApiResponse" + } + } + } + } + } + } }, "/admin/reports/{id}/decision": { "post": { - "tags": ["举报管理"], + "tags": [ + "举报管理" + ], "summary": "举报处理决定(ADMIN)", "description": "仅 ADMIN;decision 仅 RESOLVED/REJECTED;REJECTED 必填 note;仅 PENDING 可处理,重复 409/9604,非法流转 409/9605;处理后发送一条 REPORT_DECISION 通知。", "operationId": "decideReport", - "security": [{ "bearerAuth": [] }], + "security": [ + { + "bearerAuth": [] + } + ], "parameters": [ - { "name": "id", "in": "path", "required": true, "schema": { "type": "integer", "format": "int64" } } + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "format": "int64" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ReportDecisionRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "OK", + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ApiResponseReportResponse" + } + } + } + }, + "400": { + "description": "参数错误(REJECTED 缺 note)", + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ApiResponse" + } + } + } + }, + "401": { + "description": "未登录", + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ApiResponse" + } + } + } + }, + "403": { + "description": "非 ADMIN", + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ApiResponse" + } + } + } + }, + "404": { + "description": "举报不存在(9601)", + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ApiResponse" + } + } + } + }, + "409": { + "description": "重复决定 9604 / 非法流转 9605", + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ApiResponse" + } + } + } + } + } + } + }, + "/abilities/types": { + "get": { + "tags": [ + "四维能力测评" + ], + "operationId": "listAbilityTypes", + "summary": "能力测评类型列表", + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResponseAbilityTypeResponse" + } + } + } + }, + "401": { + "description": "未登录" + }, + "403": { + "description": "Forbidden" + } + } + } + }, + "/abilities/attempts": { + "post": { + "tags": [ + "四维能力测评" ], + "operationId": "createAbilityAttempt", + "summary": "创建能力测评答卷", "requestBody": { "content": { "application/json": { - "schema": { "$ref": "#/components/schemas/ReportDecisionRequest" } + "schema": { + "$ref": "#/components/schemas/CreateAbilityAttemptRequest" + } } }, - "required": true - }, - "responses": { - "200": { "description": "OK", "content": { "*/*": { "schema": { "$ref": "#/components/schemas/ApiResponseReportResponse" } } } }, - "400": { "description": "参数错误(REJECTED 缺 note)", "content": { "*/*": { "schema": { "$ref": "#/components/schemas/ApiResponse" } } } }, - "401": { "description": "未登录", "content": { "*/*": { "schema": { "$ref": "#/components/schemas/ApiResponse" } } } }, - "403": { "description": "非 ADMIN", "content": { "*/*": { "schema": { "$ref": "#/components/schemas/ApiResponse" } } } }, - "404": { "description": "举报不存在(9601)", "content": { "*/*": { "schema": { "$ref": "#/components/schemas/ApiResponse" } } } }, - "409": { "description": "重复决定 9604 / 非法流转 9605", "content": { "*/*": { "schema": { "$ref": "#/components/schemas/ApiResponse" } } } } + "required": true + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResponseAbilityAttemptResponse" + } + } + } + }, + "401": { + "description": "未登录" + }, + "403": { + "description": "Forbidden" + }, + "404": { + "description": "9801" + }, + "409": { + "description": "9808/9809/9810" + }, + "429": { + "description": "9811" + } + } + } + }, + "/abilities/attempts/{attemptId}": { + "get": { + "tags": [ + "四维能力测评" + ], + "operationId": "getAbilityAttempt", + "summary": "getAbilityAttempt", + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResponseAbilityAttemptResponse" + } + } + } + }, + "401": { + "description": "未登录" + }, + "403": { + "description": "Forbidden" + }, + "404": { + "description": "404" + }, + "409": { + "description": "409" + } + } + } + }, + "/abilities/attempts/{attemptId}/answers": { + "patch": { + "tags": [ + "四维能力测评" + ], + "operationId": "saveAbilityAnswers", + "summary": "saveAbilityAnswers", + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResponseAbilityAttemptResponse" + } + } + } + }, + "401": { + "description": "未登录" + }, + "403": { + "description": "Forbidden" + }, + "404": { + "description": "404" + }, + "409": { + "description": "409" + } + }, + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SaveAbilityAnswersRequest" + } + } + }, + "required": true + } + } + }, + "/abilities/attempts/{attemptId}/submit": { + "post": { + "tags": [ + "四维能力测评" + ], + "operationId": "submitAbilityAttempt", + "summary": "submitAbilityAttempt", + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResponseAbilityResultResponse" + } + } + } + }, + "401": { + "description": "未登录" + }, + "403": { + "description": "Forbidden" + }, + "404": { + "description": "404" + }, + "409": { + "description": "409" + } + } + } + }, + "/abilities/attempts/{attemptId}/result": { + "get": { + "tags": [ + "四维能力测评" + ], + "operationId": "getAbilityResult", + "summary": "getAbilityResult", + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResponseAbilityResultResponse" + } + } + } + }, + "401": { + "description": "未登录" + }, + "403": { + "description": "Forbidden" + }, + "404": { + "description": "404" + }, + "409": { + "description": "409" + } + } + } + }, + "/abilities/me": { + "get": { + "tags": [ + "四维能力测评" + ], + "operationId": "listMyAbilityAttempts", + "summary": "我的能力测评历史", + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResponsePageResponseAbilityHistoryItem" + } + } + } + }, + "401": { + "description": "未登录" + }, + "403": { + "description": "Forbidden" + } + } + } + }, + "/admin/scoring/risk/overview": { + "get": { + "tags": [ + "风险治理" + ], + "operationId": "riskOverview", + "summary": "风险概览(ADMIN)", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": [ + { + "name": "from", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "to", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + } + ] + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResponseRiskOverviewResponse" + } + } + } + } + } + } + }, + "/admin/scoring/risk/trend": { + "get": { + "tags": [ + "风险治理" + ], + "operationId": "riskTrend", + "summary": "风险趋势(ADMIN)", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": [ + { + "name": "bucket", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "from", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "to", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + } + ] + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResponseRiskTrendResponse" + } + } + } + } + } + } + }, + "/admin/scoring/risk/distribution": { + "get": { + "tags": [ + "风险治理" + ], + "operationId": "riskDistribution", + "summary": "风险分布(ADMIN)", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": [ + { + "name": "from", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "to", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + } + ] + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResponseRiskDistributionResponse" + } + } + } + } + } + } + }, + "/admin/scoring/risk/events": { + "get": { + "tags": [ + "风险治理" + ], + "operationId": "riskEvents", + "summary": "风险事件分页(ADMIN)", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": [ + { + "name": "page", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "size", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "status", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "severity", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "ruleCode", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "from", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "to", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + } + ] + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResponsePageResponseRiskEventResponse" + } + } + } + } + } + } + }, + "/admin/scoring/risk/rules": { + "get": { + "tags": [ + "风险治理" + ], + "operationId": "riskRules", + "summary": "风险规则列表(ADMIN)", + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResponseListRiskRuleResponse" + } + } + } + } + } + } + }, + "/admin/scoring/risk/rules/{ruleCode}": { + "put": { + "tags": [ + "风险治理" + ], + "operationId": "riskUpdateRule", + "summary": "更新风险规则(ADMIN)", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RiskRuleUpdateRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResponseRiskRuleResponse" + } + } + } + } + } + } + }, + "/admin/scoring/risk/events/{eventId}/actions": { + "post": { + "tags": [ + "风险治理" + ], + "operationId": "riskActionEvent", + "summary": "风险事件人工操作(ADMIN)", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RiskEventActionRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResponseRiskEventResponse" + } + } + } + } + } + } + } + }, + "components": { + "schemas": { + "AccountDeletionResponse": { + "type": "object", + "properties": { + "userId": { + "type": "integer", + "format": "int64", + "example": 2 + }, + "status": { + "type": "string", + "enum": [ + "NONE", + "PENDING", + "COMPLETED" + ], + "example": "PENDING", + "description": "注销状态:NONE/PENDING/COMPLETED" + }, + "requestedAt": { + "type": "string", + "format": "date-time", + "nullable": true + }, + "effectiveAt": { + "type": "string", + "format": "date-time", + "nullable": true + }, + "cancelledAt": { + "type": "string", + "format": "date-time", + "nullable": true + }, + "completedAt": { + "type": "string", + "format": "date-time", + "nullable": true + }, + "version": { + "type": "integer", + "format": "int64", + "example": 0 + } + } + }, + "AdminDashboardOverviewResponse": { + "type": "object", + "properties": { + "totalCompanies": { + "type": "integer", + "format": "int32" + }, + "totalJobs": { + "type": "integer", + "format": "int32" + }, + "totalCandidates": { + "type": "integer", + "format": "int32" + }, + "avgCandidateScore": { + "type": "number" + }, + "topCompanies": { + "type": "array", + "items": { + "$ref": "#/components/schemas/CompanyScoreItem" + } + } + } + }, + "ApiResponse": { + "type": "object", + "properties": { + "code": { + "type": "integer", + "format": "int32" + }, + "message": { + "type": "string" + }, + "data": {}, + "requestId": { + "type": "string" + }, + "timestamp": { + "type": "string", + "format": "date-time" + } + } + }, + "CareerProgressResponse": { + "type": "object", + "properties": { + "todo": { + "$ref": "#/components/schemas/ProgressTodoSummary" + }, + "applications": { + "$ref": "#/components/schemas/PaginatedApplications" + }, + "generatedAt": { + "type": "string", + "format": "date-time" + } + } + }, + "ProgressTodoSummary": { + "type": "object", + "properties": { + "pendingInterviewResponseCount": { + "type": "integer", + "format": "int64" + }, + "upcomingConfirmedInterviewCount": { + "type": "integer", + "format": "int64" + }, + "pendingOfferResponseCount": { + "type": "integer", + "format": "int64" + }, + "unreadNotificationCount": { + "type": "integer", + "format": "int64" + }, + "unreadReportDecisionCount": { + "type": "integer", + "format": "int64" + } + } + }, + "PaginatedApplications": { + "type": "object", + "properties": { + "items": { + "type": "array", + "items": { + "$ref": "#/components/schemas/CareerProgressItem" + } + }, + "total": { + "type": "integer", + "format": "int64" + }, + "page": { + "type": "integer", + "format": "int32" + }, + "size": { + "type": "integer", + "format": "int32" + }, + "totalPages": { + "type": "integer", + "format": "int32" + } + } + }, + "CareerProgressItem": { + "type": "object", + "properties": { + "applicationId": { + "type": "integer", + "format": "int64" + }, + "applicationStatus": { + "type": "string" + }, + "appliedAt": { + "type": "string", + "format": "date-time" + }, + "updatedAt": { + "type": "string", + "format": "date-time" + }, + "job": { + "$ref": "#/components/schemas/JobSummary" + }, + "nextInterview": { + "$ref": "#/components/schemas/InterviewSummary" + }, + "latestSubmittedInterviewScore": { + "$ref": "#/components/schemas/ScoreSummary" + }, + "offer": { + "$ref": "#/components/schemas/OfferSummary" + }, + "competition": { + "$ref": "#/components/schemas/CompetitionSummary" + } + } + }, + "JobSummary": { + "type": "object", + "properties": { + "jobId": { + "type": "integer", + "format": "int64" + }, + "title": { + "type": "string" + }, + "companyName": { + "type": "string" + }, + "city": { + "type": "string" + }, + "employmentType": { + "type": "string" + }, + "jobStatus": { + "type": "string" + } + } + }, + "InterviewSummary": { + "type": "object", + "properties": { + "interviewId": { + "type": "integer", + "format": "int64" + }, + "roundNo": { + "type": "integer", + "format": "int32" + }, + "title": { + "type": "string" + }, + "interviewType": { + "type": "string" + }, + "startAt": { + "type": "string", + "format": "date-time" + }, + "endAt": { + "type": "string", + "format": "date-time" + }, + "status": { + "type": "string" + } + } + }, + "ScoreSummary": { + "type": "object", + "properties": { + "interviewId": { + "type": "integer", + "format": "int64" + }, + "overallScore": { + "type": "number" + }, + "recommendation": { + "type": "string" + }, + "submittedAt": { + "type": "string", + "format": "date-time" + } + } + }, + "OfferSummary": { + "type": "object", + "properties": { + "offerId": { + "type": "integer", + "format": "int64" + }, + "status": { + "type": "string" + }, + "title": { + "type": "string" + }, + "expireAt": { + "type": "string", + "format": "date-time" + }, + "onboardingDate": { + "type": "string", + "format": "date" + } + } + }, + "CompetitionSummary": { + "type": "object", + "properties": { + "eligible": { + "type": "boolean" + }, + "rank": { + "type": "integer", + "format": "int32" + }, + "cohortSize": { + "type": "integer", + "format": "int32" + }, + "overallScore": { + "type": "number" + }, + "percentile": { + "type": "number" + }, + "status": { + "type": "string" + }, + "mySnapshotPeriod": { + "type": "string" + }, + "rulesVersion": { + "type": "string" + } + } + }, + "ApiResponseCareerProgressResponse": { + "type": "object", + "properties": { + "code": { + "type": "integer", + "format": "int32", + "example": 200 + }, + "message": { + "type": "string", + "example": "ok" + }, + "data": { + "$ref": "#/components/schemas/CareerProgressResponse" + }, + "requestId": { + "type": "string", + "example": "req-1" + }, + "timestamp": { + "type": "string", + "format": "date-time" + } + } + }, + "ApiResponseAccountDeletionResponse": { + "type": "object", + "properties": { + "code": { + "type": "integer", + "format": "int32", + "example": 200 + }, + "message": { + "type": "string", + "example": "ok" + }, + "data": { + "$ref": "#/components/schemas/AccountDeletionResponse" + }, + "requestId": { + "type": "string", + "example": "req-1" + }, + "timestamp": { + "type": "string", + "format": "date-time" + } + } + }, + "ApiResponseAdminDashboardOverviewResponse": { + "type": "object", + "properties": { + "code": { + "type": "integer", + "format": "int32" + }, + "message": { + "type": "string" + }, + "data": { + "$ref": "#/components/schemas/AdminDashboardOverviewResponse" + }, + "requestId": { + "type": "string" + }, + "timestamp": { + "type": "string", + "format": "date-time" + } + } + }, + "ApiResponseAppealResponse": { + "type": "object", + "properties": { + "code": { + "type": "integer", + "format": "int32" + }, + "message": { + "type": "string" + }, + "data": { + "$ref": "#/components/schemas/AppealResponse" + }, + "requestId": { + "type": "string" + }, + "timestamp": { + "type": "string", + "format": "date-time" + } } - } - } - }, - "components": { - "schemas": { - "AccountDeletionResponse": { + }, + "ApiResponseCandidateDetailView": { "type": "object", "properties": { - "userId": { + "code": { "type": "integer", - "format": "int64", - "example": 2 + "format": "int32" }, - "status": { + "message": { + "type": "string" + }, + "data": { + "$ref": "#/components/schemas/CandidateDetailView" + }, + "requestId": { + "type": "string" + }, + "timestamp": { "type": "string", - "enum": [ - "NONE", - "PENDING", - "COMPLETED" - ], - "example": "PENDING", - "description": "注销状态:NONE/PENDING/COMPLETED" + "format": "date-time" + } + } + }, + "ApiResponseCandidateFeedbackResponse": { + "type": "object", + "properties": { + "code": { + "type": "integer", + "format": "int32" }, - "requestedAt": { + "message": { + "type": "string" + }, + "data": { + "$ref": "#/components/schemas/CandidateFeedbackResponse" + }, + "requestId": { + "type": "string" + }, + "timestamp": { "type": "string", - "format": "date-time", - "nullable": true + "format": "date-time" + } + } + }, + "ApiResponseCandidateListResponse": { + "type": "object", + "properties": { + "code": { + "type": "integer", + "format": "int32" }, - "effectiveAt": { + "message": { + "type": "string" + }, + "data": { + "$ref": "#/components/schemas/CandidateListResponse" + }, + "requestId": { + "type": "string" + }, + "timestamp": { "type": "string", - "format": "date-time", - "nullable": true + "format": "date-time" + } + } + }, + "ApiResponseCandidatePercentileResponse": { + "type": "object", + "properties": { + "code": { + "type": "integer", + "format": "int32" }, - "cancelledAt": { + "message": { + "type": "string" + }, + "data": { + "$ref": "#/components/schemas/CandidatePercentileResponse" + }, + "requestId": { + "type": "string" + }, + "timestamp": { "type": "string", - "format": "date-time", - "nullable": true + "format": "date-time" + } + } + }, + "ApiResponseCandidateRankingDetailResponse": { + "type": "object", + "properties": { + "code": { + "type": "integer", + "format": "int32" }, - "completedAt": { + "message": { + "type": "string" + }, + "data": { + "$ref": "#/components/schemas/CandidateRankingDetailResponse" + }, + "requestId": { + "type": "string" + }, + "timestamp": { "type": "string", - "format": "date-time", - "nullable": true + "format": "date-time" + } + } + }, + "ApiResponseCandidateRankingListResponse": { + "type": "object", + "properties": { + "code": { + "type": "integer", + "format": "int32" }, - "version": { + "message": { + "type": "string" + }, + "data": { + "$ref": "#/components/schemas/CandidateRankingListResponse" + }, + "requestId": { + "type": "string" + }, + "timestamp": { + "type": "string", + "format": "date-time" + } + } + }, + "ApiResponseCandidateScoreCardResponse": { + "type": "object", + "properties": { + "code": { "type": "integer", - "format": "int64", - "example": 0 + "format": "int32" + }, + "message": { + "type": "string" + }, + "data": { + "$ref": "#/components/schemas/CandidateScoreCardResponse" + }, + "requestId": { + "type": "string" + }, + "timestamp": { + "type": "string", + "format": "date-time" } } }, - "AdminDashboardOverviewResponse": { + "ApiResponseCandidateScoreTrendResponse": { "type": "object", "properties": { - "totalCompanies": { + "code": { "type": "integer", "format": "int32" }, - "totalJobs": { + "message": { + "type": "string" + }, + "data": { + "$ref": "#/components/schemas/CandidateScoreTrendResponse" + }, + "requestId": { + "type": "string" + }, + "timestamp": { + "type": "string", + "format": "date-time" + } + } + }, + "ApiResponseCandidateScoringOverviewResponse": { + "type": "object", + "properties": { + "code": { "type": "integer", "format": "int32" }, - "totalCandidates": { + "message": { + "type": "string" + }, + "data": { + "$ref": "#/components/schemas/CandidateScoringOverviewResponse" + }, + "requestId": { + "type": "string" + }, + "timestamp": { + "type": "string", + "format": "date-time" + } + } + }, + "ApiResponseCandidateScoringTrendResponse": { + "type": "object", + "properties": { + "code": { "type": "integer", "format": "int32" }, - "avgCandidateScore": { - "type": "number" + "message": { + "type": "string" }, - "topCompanies": { - "type": "array", - "items": { - "$ref": "#/components/schemas/CompanyScoreItem" - } + "data": { + "$ref": "#/components/schemas/CandidateScoringTrendResponse" + }, + "requestId": { + "type": "string" + }, + "timestamp": { + "type": "string", + "format": "date-time" } } }, - "ApiResponse": { + "ApiResponseCompanyCandidateScoringResponse": { "type": "object", "properties": { "code": { @@ -6381,7 +8696,9 @@ "message": { "type": "string" }, - "data": {}, + "data": { + "$ref": "#/components/schemas/CompanyCandidateScoringResponse" + }, "requestId": { "type": "string" }, @@ -6391,253 +8708,233 @@ } } }, - "CareerProgressResponse": { + "ApiResponseCompanyPublicView": { "type": "object", "properties": { - "todo": { - "$ref": "#/components/schemas/ProgressTodoSummary" + "code": { + "type": "integer", + "format": "int32" + }, + "message": { + "type": "string" }, - "applications": { - "$ref": "#/components/schemas/PaginatedApplications" + "data": { + "$ref": "#/components/schemas/CompanyPublicView" }, - "generatedAt": { + "requestId": { + "type": "string" + }, + "timestamp": { "type": "string", "format": "date-time" } } }, - "ProgressTodoSummary": { + "ApiResponseDashboardOverviewResponse": { "type": "object", "properties": { - "pendingInterviewResponseCount": { + "code": { "type": "integer", - "format": "int64" + "format": "int32" }, - "upcomingConfirmedInterviewCount": { - "type": "integer", - "format": "int64" + "message": { + "type": "string" }, - "pendingOfferResponseCount": { - "type": "integer", - "format": "int64" + "data": { + "$ref": "#/components/schemas/DashboardOverviewResponse" }, - "unreadNotificationCount": { - "type": "integer", - "format": "int64" + "requestId": { + "type": "string" }, - "unreadReportDecisionCount": { - "type": "integer", - "format": "int64" + "timestamp": { + "type": "string", + "format": "date-time" } } }, - "PaginatedApplications": { + "ApiResponseDimensionScoreDetailResponse": { "type": "object", "properties": { - "items": { - "type": "array", - "items": { - "$ref": "#/components/schemas/CareerProgressItem" - } - }, - "total": { - "type": "integer", - "format": "int64" - }, - "page": { + "code": { "type": "integer", "format": "int32" }, - "size": { - "type": "integer", - "format": "int32" + "message": { + "type": "string" }, - "totalPages": { - "type": "integer", - "format": "int32" + "data": { + "$ref": "#/components/schemas/DimensionScoreDetailResponse" + }, + "requestId": { + "type": "string" + }, + "timestamp": { + "type": "string", + "format": "date-time" } } }, - "CareerProgressItem": { + "ApiResponseJobRankingItem": { "type": "object", "properties": { - "applicationId": { + "code": { "type": "integer", - "format": "int64" + "format": "int32" }, - "applicationStatus": { + "message": { "type": "string" }, - "appliedAt": { - "type": "string", - "format": "date-time" + "data": { + "$ref": "#/components/schemas/JobRankingItem" }, - "updatedAt": { + "requestId": { + "type": "string" + }, + "timestamp": { "type": "string", "format": "date-time" - }, - "job": { - "$ref": "#/components/schemas/JobSummary" - }, - "nextInterview": { - "$ref": "#/components/schemas/InterviewSummary" - }, - "latestSubmittedInterviewScore": { - "$ref": "#/components/schemas/ScoreSummary" - }, - "offer": { - "$ref": "#/components/schemas/OfferSummary" - }, - "competition": { - "$ref": "#/components/schemas/CompetitionSummary" } } }, - "JobSummary": { + "ApiResponseJobRankingListResponse": { "type": "object", "properties": { - "jobId": { + "code": { "type": "integer", - "format": "int64" - }, - "title": { - "type": "string" + "format": "int32" }, - "companyName": { + "message": { "type": "string" }, - "city": { - "type": "string" + "data": { + "$ref": "#/components/schemas/JobRankingListResponse" }, - "employmentType": { + "requestId": { "type": "string" }, - "jobStatus": { - "type": "string" + "timestamp": { + "type": "string", + "format": "date-time" } } }, - "InterviewSummary": { + "ApiResponseListAppealHistoryItem": { "type": "object", "properties": { - "interviewId": { - "type": "integer", - "format": "int64" - }, - "roundNo": { + "code": { "type": "integer", "format": "int32" }, - "title": { + "message": { "type": "string" }, - "interviewType": { - "type": "string" + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/AppealHistoryItem" + } }, - "startAt": { - "type": "string", - "format": "date-time" + "requestId": { + "type": "string" }, - "endAt": { + "timestamp": { "type": "string", "format": "date-time" - }, - "status": { - "type": "string" } } }, - "ScoreSummary": { + "ApiResponseListMapStringObject": { "type": "object", "properties": { - "interviewId": { + "code": { "type": "integer", - "format": "int64" + "format": "int32" }, - "overallScore": { - "type": "number" + "message": { + "type": "string" }, - "recommendation": { + "data": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": {} + } + }, + "requestId": { "type": "string" }, - "submittedAt": { + "timestamp": { "type": "string", "format": "date-time" } } }, - "OfferSummary": { + "ApiResponseListSnapshotHistoryItem": { "type": "object", "properties": { - "offerId": { + "code": { "type": "integer", - "format": "int64" + "format": "int32" }, - "status": { + "message": { "type": "string" }, - "title": { + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/SnapshotHistoryItem" + } + }, + "requestId": { "type": "string" }, - "expireAt": { + "timestamp": { "type": "string", "format": "date-time" - }, - "onboardingDate": { - "type": "string", - "format": "date" } } }, - "CompetitionSummary": { + "ApiResponseListWeightTemplateResponse": { "type": "object", "properties": { - "eligible": { - "type": "boolean" - }, - "rank": { - "type": "integer", - "format": "int32" - }, - "cohortSize": { + "code": { "type": "integer", "format": "int32" }, - "overallScore": { - "type": "number" - }, - "percentile": { - "type": "number" - }, - "status": { + "message": { "type": "string" }, - "mySnapshotPeriod": { - "type": "string" + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/WeightTemplateResponse" + } }, - "rulesVersion": { + "requestId": { "type": "string" + }, + "timestamp": { + "type": "string", + "format": "date-time" } } }, - "ApiResponseCareerProgressResponse": { + "ApiResponseMapStringObject": { "type": "object", "properties": { "code": { "type": "integer", - "format": "int32", - "example": 200 + "format": "int32" }, "message": { - "type": "string", - "example": "ok" + "type": "string" }, "data": { - "$ref": "#/components/schemas/CareerProgressResponse" + "type": "object", + "additionalProperties": {} }, "requestId": { - "type": "string", - "example": "req-1" + "type": "string" }, "timestamp": { "type": "string", @@ -6645,24 +8942,24 @@ } } }, - "ApiResponseAccountDeletionResponse": { + "ApiResponseMapStringString": { "type": "object", "properties": { "code": { "type": "integer", - "format": "int32", - "example": 200 + "format": "int32" }, "message": { - "type": "string", - "example": "ok" + "type": "string" }, "data": { - "$ref": "#/components/schemas/AccountDeletionResponse" + "type": "object", + "additionalProperties": { + "type": "string" + } }, "requestId": { - "type": "string", - "example": "req-1" + "type": "string" }, "timestamp": { "type": "string", @@ -6670,7 +8967,7 @@ } } }, - "ApiResponseAdminDashboardOverviewResponse": { + "ApiResponsePersonalizedScoreResponse": { "type": "object", "properties": { "code": { @@ -6681,7 +8978,7 @@ "type": "string" }, "data": { - "$ref": "#/components/schemas/AdminDashboardOverviewResponse" + "$ref": "#/components/schemas/PersonalizedScoreResponse" }, "requestId": { "type": "string" @@ -6692,21 +8989,24 @@ } } }, - "ApiResponseAppealResponse": { + "ApiResponsePrivacySettingsResponse": { "type": "object", "properties": { "code": { "type": "integer", - "format": "int32" + "format": "int32", + "example": 200 }, "message": { - "type": "string" + "type": "string", + "example": "ok" }, "data": { - "$ref": "#/components/schemas/AppealResponse" + "$ref": "#/components/schemas/PrivacySettingsResponse" }, "requestId": { - "type": "string" + "type": "string", + "example": "req-1" }, "timestamp": { "type": "string", @@ -6714,7 +9014,7 @@ } } }, - "ApiResponseCandidateDetailView": { + "ApiResponseReviewWeightResponse": { "type": "object", "properties": { "code": { @@ -6725,7 +9025,7 @@ "type": "string" }, "data": { - "$ref": "#/components/schemas/CandidateDetailView" + "$ref": "#/components/schemas/ReviewWeightResponse" }, "requestId": { "type": "string" @@ -6736,7 +9036,7 @@ } } }, - "ApiResponseCandidateFeedbackResponse": { + "ApiResponseScoreTrendResponse": { "type": "object", "properties": { "code": { @@ -6747,7 +9047,7 @@ "type": "string" }, "data": { - "$ref": "#/components/schemas/CandidateFeedbackResponse" + "$ref": "#/components/schemas/ScoreTrendResponse" }, "requestId": { "type": "string" @@ -6758,1036 +9058,1176 @@ } } }, - "ApiResponseCandidateListResponse": { + "AppealDecisionRequest": { "type": "object", "properties": { - "code": { + "decision": { + "type": "string", + "minLength": 1 + }, + "reason": { + "type": "string" + } + }, + "required": [ + "decision" + ] + }, + "AppealHistoryItem": { + "type": "object", + "properties": { + "id": { "type": "integer", - "format": "int32" + "format": "int64" }, - "message": { + "fromStatus": { "type": "string" }, - "data": { - "$ref": "#/components/schemas/CandidateListResponse" + "toStatus": { + "type": "string" }, - "requestId": { + "actorUserId": { + "type": "integer", + "format": "int64" + }, + "actorRole": { "type": "string" }, - "timestamp": { + "reason": { + "type": "string" + }, + "createdAt": { "type": "string", "format": "date-time" } } }, - "ApiResponseCandidatePercentileResponse": { + "AppealResponse": { "type": "object", "properties": { - "code": { + "appealId": { "type": "integer", - "format": "int32" + "format": "int64" }, - "message": { + "candidateId": { + "type": "integer", + "format": "int64" + }, + "dimension": { "type": "string" }, - "data": { - "$ref": "#/components/schemas/CandidatePercentileResponse" + "period": { + "type": "string" }, - "requestId": { + "status": { "type": "string" }, - "timestamp": { + "reason": { + "type": "string" + }, + "decision": { + "type": "string" + }, + "decisionReason": { + "type": "string" + }, + "reviewerId": { + "type": "integer", + "format": "int64" + }, + "reviewedAt": { + "type": "string", + "format": "date-time" + }, + "rollbackAt": { + "type": "string", + "format": "date-time" + }, + "scoreAtAppeal": { + "type": "number" + }, + "snapshotVersionAtAppeal": { + "type": "integer", + "format": "int64" + }, + "rulesVersion": { + "type": "string" + }, + "createdAt": { + "type": "string", + "format": "date-time" + }, + "updatedAt": { "type": "string", "format": "date-time" } } }, - "ApiResponseCandidateRankingDetailResponse": { + "ApplicationCreateRequest": { "type": "object", "properties": { - "code": { + "jobId": { "type": "integer", - "format": "int32" + "format": "int64" + } + }, + "required": [ + "jobId" + ] + }, + "ApplicationStatusRequest": { + "type": "object", + "properties": { + "status": { + "type": "string", + "minLength": 1, + "pattern": "^(VIEWED|INTERVIEW|REJECTED|HIRED)$" + } + }, + "required": [ + "status" + ] + }, + "CandidateDetailView": { + "type": "object", + "properties": { + "applicationId": { + "type": "integer", + "format": "int64" }, - "message": { + "jobId": { + "type": "integer", + "format": "int64" + }, + "jobTitle": { "type": "string" }, - "data": { - "$ref": "#/components/schemas/CandidateRankingDetailResponse" + "jobCity": { + "type": "string" }, - "requestId": { + "jobEmploymentType": { "type": "string" }, - "timestamp": { + "status": { + "type": "string" + }, + "coverLetter": { + "type": "string" + }, + "createdAt": { + "type": "string", + "format": "date-time" + }, + "updatedAt": { "type": "string", "format": "date-time" + }, + "candidateName": { + "type": "string" + }, + "candidateAvatarUrl": { + "type": "string" + }, + "candidateBio": { + "type": "string" + }, + "candidateLocation": { + "type": "string" + }, + "candidateSchool": { + "type": "string" + }, + "candidateEducation": { + "type": "string" + }, + "candidateGraduationYear": { + "type": "integer", + "format": "int32" + }, + "candidateSkills": { + "type": "array", + "items": { + "type": "string" + } } } }, - "ApiResponseCandidateRankingListResponse": { + "CandidateFeedbackRequest": { "type": "object", "properties": { - "code": { + "rating": { "type": "integer", - "format": "int32" + "format": "int32", + "maximum": 5, + "minimum": 1 }, - "message": { - "type": "string" + "content": { + "type": "string", + "maxLength": 1000, + "minLength": 0 + }, + "tags": { + "type": "array", + "items": { + "type": "string", + "maxLength": 50, + "minLength": 1 + }, + "maxItems": 10, + "minItems": 0 + } + }, + "required": [ + "content", + "rating" + ] + }, + "CandidateFeedbackResponse": { + "type": "object", + "description": "脱敏后的候选人反馈", + "properties": { + "id": { + "type": "integer", + "format": "int64", + "description": "反馈 ID", + "example": 1 + }, + "applicationId": { + "type": "integer", + "format": "int64", + "description": "投递 ID", + "example": 31 + }, + "rating": { + "type": "integer", + "format": "int32", + "description": "评分,范围 1-5", + "example": 5 + }, + "content": { + "type": "string", + "description": "反馈内容", + "example": "沟通良好" }, - "data": { - "$ref": "#/components/schemas/CandidateRankingListResponse" + "tags": { + "type": "array", + "description": "反馈标签", + "items": { + "type": "string" + } }, - "requestId": { - "type": "string" + "createdAt": { + "type": "string", + "format": "date-time", + "description": "创建时间", + "example": "2026-08-25T10:00:00" }, - "timestamp": { + "updatedAt": { "type": "string", - "format": "date-time" + "format": "date-time", + "description": "更新时间", + "example": "2026-08-25T10:00:00" } } }, - "ApiResponseCandidateScoreCardResponse": { + "CandidateFeedbackSummary": { "type": "object", "properties": { - "code": { + "totalCount": { "type": "integer", "format": "int32" }, - "message": { - "type": "string" - }, - "data": { - "$ref": "#/components/schemas/CandidateScoreCardResponse" + "averageRating": { + "type": "number" }, - "requestId": { - "type": "string" + "ratingDistribution": { + "type": "object", + "additionalProperties": { + "type": "integer", + "format": "int32" + } }, - "timestamp": { - "type": "string", - "format": "date-time" + "tags": { + "type": "array", + "items": { + "$ref": "#/components/schemas/TagCount" + } } } }, - "ApiResponseCandidateScoreTrendResponse": { + "CandidateItemView": { "type": "object", "properties": { - "code": { + "applicationId": { "type": "integer", - "format": "int32" - }, - "message": { - "type": "string" + "format": "int64" }, - "data": { - "$ref": "#/components/schemas/CandidateScoreTrendResponse" + "jobId": { + "type": "integer", + "format": "int64" }, - "requestId": { + "jobTitle": { "type": "string" }, - "timestamp": { - "type": "string", - "format": "date-time" - } - } - }, - "ApiResponseCandidateScoringOverviewResponse": { - "type": "object", - "properties": { - "code": { - "type": "integer", - "format": "int32" + "status": { + "type": "string" }, - "message": { + "candidateName": { "type": "string" }, - "data": { - "$ref": "#/components/schemas/CandidateScoringOverviewResponse" + "candidateSchool": { + "type": "string" }, - "requestId": { + "candidateEducation": { "type": "string" }, - "timestamp": { - "type": "string", - "format": "date-time" - } - } - }, - "ApiResponseCandidateScoringTrendResponse": { - "type": "object", - "properties": { - "code": { + "candidateGraduationYear": { "type": "integer", "format": "int32" }, - "message": { + "candidateAvatarUrl": { "type": "string" }, - "data": { - "$ref": "#/components/schemas/CandidateScoringTrendResponse" - }, - "requestId": { - "type": "string" + "createdAt": { + "type": "string", + "format": "date-time" }, - "timestamp": { + "updatedAt": { "type": "string", "format": "date-time" } } }, - "ApiResponseCompanyCandidateScoringResponse": { + "CandidateListResponse": { "type": "object", "properties": { - "code": { - "type": "integer", - "format": "int32" + "items": { + "type": "array", + "items": { + "$ref": "#/components/schemas/CandidateItemView" + } }, - "message": { - "type": "string" + "total": { + "type": "integer", + "format": "int64" }, - "data": { - "$ref": "#/components/schemas/CompanyCandidateScoringResponse" + "page": { + "type": "integer", + "format": "int32" }, - "requestId": { - "type": "string" + "size": { + "type": "integer", + "format": "int32" }, - "timestamp": { - "type": "string", - "format": "date-time" + "totalPages": { + "type": "integer", + "format": "int32" } } }, - "ApiResponseCompanyPublicView": { + "CandidateMatchSignals": { "type": "object", "properties": { - "code": { - "type": "integer", - "format": "int32" - }, - "message": { - "type": "string" - }, - "data": { - "$ref": "#/components/schemas/CompanyPublicView" - }, - "requestId": { - "type": "string" + "candidateInterestScore": { + "$ref": "#/components/schemas/MatchSignal" }, - "timestamp": { - "type": "string", - "format": "date-time" + "hrSuitabilityScore": { + "$ref": "#/components/schemas/MatchSignal" } } }, - "ApiResponseDashboardOverviewResponse": { + "CandidatePercentileResponse": { "type": "object", "properties": { - "code": { + "candidateId": { "type": "integer", - "format": "int32" + "format": "int64" }, - "message": { + "status": { "type": "string" }, - "data": { - "$ref": "#/components/schemas/DashboardOverviewResponse" - }, - "requestId": { + "scoreType": { "type": "string" }, - "timestamp": { - "type": "string", - "format": "date-time" - } - } - }, - "ApiResponseDimensionScoreDetailResponse": { - "type": "object", - "properties": { - "code": { - "type": "integer", - "format": "int32" + "score": { + "type": "number" }, - "message": { - "type": "string" + "percentile": { + "type": "number" }, - "data": { - "$ref": "#/components/schemas/DimensionScoreDetailResponse" + "cohortSize": { + "type": "integer", + "format": "int32" }, - "requestId": { - "type": "string" + "sampleCount": { + "type": "integer", + "format": "int32" }, - "timestamp": { - "type": "string", - "format": "date-time" + "minimumSampleCount": { + "type": "integer", + "format": "int32" } } }, - "ApiResponseJobRankingItem": { + "CandidateRankingDetailResponse": { "type": "object", "properties": { - "code": { + "applicationId": { "type": "integer", - "format": "int32" + "format": "int64" }, - "message": { - "type": "string" + "jobId": { + "type": "integer", + "format": "int64" }, - "data": { - "$ref": "#/components/schemas/JobRankingItem" + "candidateName": { + "type": "string" }, - "requestId": { + "candidateSchool": { "type": "string" }, - "timestamp": { - "type": "string", - "format": "date-time" + "overallScore": { + "type": "number" + }, + "percentile": { + "type": "number" + }, + "cohortSize": { + "type": "integer", + "format": "int32" + }, + "dimensionScores": { + "type": "array", + "items": { + "$ref": "#/components/schemas/DimensionScoreItem" + } } } }, - "ApiResponseJobRankingListResponse": { + "CandidateRankingItem": { "type": "object", "properties": { - "code": { + "applicationId": { "type": "integer", - "format": "int32" + "format": "int64" + }, + "jobId": { + "type": "integer", + "format": "int64" + }, + "candidateName": { + "type": "string" }, - "message": { + "candidateSchool": { "type": "string" }, - "data": { - "$ref": "#/components/schemas/JobRankingListResponse" + "overallScore": { + "type": "number" }, - "requestId": { - "type": "string" + "percentile": { + "type": "number" }, - "timestamp": { - "type": "string", - "format": "date-time" + "sampleCount": { + "type": "integer", + "format": "int32" } } }, - "ApiResponseListAppealHistoryItem": { + "CandidateRankingListResponse": { "type": "object", "properties": { - "code": { - "type": "integer", - "format": "int32" - }, - "message": { - "type": "string" - }, - "data": { + "items": { "type": "array", "items": { - "$ref": "#/components/schemas/AppealHistoryItem" + "$ref": "#/components/schemas/CandidateRankingItem" } }, - "requestId": { - "type": "string" + "total": { + "type": "integer", + "format": "int64" }, - "timestamp": { - "type": "string", - "format": "date-time" + "page": { + "type": "integer", + "format": "int32" + }, + "size": { + "type": "integer", + "format": "int32" + }, + "totalPages": { + "type": "integer", + "format": "int32" } } }, - "ApiResponseListMapStringObject": { + "CandidateScoreCardResponse": { "type": "object", "properties": { - "code": { + "candidateId": { "type": "integer", - "format": "int32" + "format": "int64" }, - "message": { + "status": { "type": "string" }, - "data": { - "type": "array", - "items": { - "type": "object", - "additionalProperties": {} - } - }, - "requestId": { - "type": "string" + "overallScore": { + "type": "number" }, - "timestamp": { - "type": "string", - "format": "date-time" - } - } - }, - "ApiResponseListSnapshotHistoryItem": { - "type": "object", - "properties": { - "code": { + "sampleCount": { "type": "integer", "format": "int32" }, - "message": { - "type": "string" + "feedbackSummary": { + "$ref": "#/components/schemas/CandidateFeedbackSummary" }, - "data": { + "matchSignals": { + "$ref": "#/components/schemas/CandidateMatchSignals" + }, + "unsupportedDimensions": { "type": "array", "items": { - "$ref": "#/components/schemas/SnapshotHistoryItem" + "type": "string" } }, - "requestId": { - "type": "string" - }, - "timestamp": { + "generatedAt": { "type": "string", "format": "date-time" } } }, - "ApiResponseListWeightTemplateResponse": { + "CandidateScoreTrendItem": { "type": "object", "properties": { - "code": { - "type": "integer", - "format": "int32" - }, - "message": { + "month": { "type": "string" }, - "data": { - "type": "array", - "items": { - "$ref": "#/components/schemas/WeightTemplateResponse" - } + "feedbackCount": { + "type": "integer", + "format": "int32" }, - "requestId": { - "type": "string" + "averageRating": { + "type": "number" }, - "timestamp": { - "type": "string", - "format": "date-time" + "ratingScore": { + "type": "number" } } }, - "ApiResponseMapStringObject": { + "CandidateScoreTrendResponse": { "type": "object", "properties": { - "code": { + "candidateId": { "type": "integer", - "format": "int32" - }, - "message": { - "type": "string" - }, - "data": { - "type": "object", - "additionalProperties": {} + "format": "int64" }, - "requestId": { - "type": "string" + "months": { + "type": "integer", + "format": "int32" }, - "timestamp": { - "type": "string", - "format": "date-time" + "items": { + "type": "array", + "items": { + "$ref": "#/components/schemas/CandidateScoreTrendItem" + } } } }, - "ApiResponseMapStringString": { + "CandidateScoringOverviewResponse": { "type": "object", "properties": { - "code": { + "candidateId": { "type": "integer", - "format": "int32" + "format": "int64" }, - "message": { + "status": { "type": "string" }, - "data": { + "overallScore": { + "type": "number" + }, + "weights": { "type": "object", "additionalProperties": { - "type": "string" + "type": "number" } }, - "requestId": { + "dimensions": { + "type": "array", + "items": { + "$ref": "#/components/schemas/DimensionItem" + } + }, + "rulesVersion": { "type": "string" }, - "timestamp": { + "generatedAt": { "type": "string", "format": "date-time" } } }, - "ApiResponsePersonalizedScoreResponse": { + "CandidateScoringTrendResponse": { "type": "object", "properties": { - "code": { + "candidateId": { "type": "integer", - "format": "int32" - }, - "message": { - "type": "string" - }, - "data": { - "$ref": "#/components/schemas/PersonalizedScoreResponse" + "format": "int64" }, - "requestId": { - "type": "string" + "months": { + "type": "integer", + "format": "int32" }, - "timestamp": { - "type": "string", - "format": "date-time" + "items": { + "type": "array", + "items": { + "$ref": "#/components/schemas/TrendItem" + } } } }, - "ApiResponsePrivacySettingsResponse": { + "CompanyCandidateScoringResponse": { "type": "object", "properties": { - "code": { + "applicationId": { "type": "integer", - "format": "int32", - "example": 200 + "format": "int64" }, - "message": { - "type": "string", - "example": "ok" + "overallScore": { + "type": "number" }, - "data": { - "$ref": "#/components/schemas/PrivacySettingsResponse" + "status": { + "type": "string" }, - "requestId": { - "type": "string", - "example": "req-1" + "dimensions": { + "type": "array", + "items": { + "$ref": "#/components/schemas/CompanyDimensionItem" + } }, - "timestamp": { + "generatedAt": { "type": "string", "format": "date-time" + }, + "rulesVersion": { + "type": "string" } } }, - "ApiResponseReviewWeightResponse": { + "CompanyCreateRequest": { "type": "object", "properties": { - "code": { - "type": "integer", - "format": "int32" + "name": { + "type": "string", + "maxLength": 100, + "minLength": 0 }, - "message": { + "logoUrl": { "type": "string" }, - "data": { - "$ref": "#/components/schemas/ReviewWeightResponse" + "description": { + "type": "string" }, - "requestId": { + "industry": { "type": "string" }, - "timestamp": { - "type": "string", - "format": "date-time" + "website": { + "type": "string" + }, + "address": { + "type": "string" } - } + }, + "required": [ + "name" + ] }, - "ApiResponseScoreTrendResponse": { + "CompanyDimensionItem": { "type": "object", "properties": { - "code": { - "type": "integer", - "format": "int32" - }, - "message": { + "dimension": { "type": "string" }, - "data": { - "$ref": "#/components/schemas/ScoreTrendResponse" + "score": { + "type": "number" }, - "requestId": { + "status": { "type": "string" - }, - "timestamp": { - "type": "string", - "format": "date-time" } } }, - "AppealDecisionRequest": { + "CompanyMatchRuleRequest": { "type": "object", "properties": { - "decision": { - "type": "string", - "minLength": 1 - }, - "reason": { - "type": "string" + "requiredCandidateScore": { + "type": "integer", + "format": "int32", + "maximum": 100, + "minimum": 0 } }, "required": [ - "decision" + "requiredCandidateScore" ] }, - "AppealHistoryItem": { + "CompanyPublicView": { "type": "object", "properties": { "id": { "type": "integer", "format": "int64" }, - "fromStatus": { + "name": { "type": "string" }, - "toStatus": { + "logoUrl": { "type": "string" }, - "actorUserId": { - "type": "integer", - "format": "int64" - }, - "actorRole": { + "description": { "type": "string" }, - "reason": { + "industry": { "type": "string" }, - "createdAt": { - "type": "string", - "format": "date-time" + "verified": { + "type": "boolean" } } }, - "AppealResponse": { + "CompanyScoreItem": { "type": "object", "properties": { - "appealId": { - "type": "integer", - "format": "int64" - }, - "candidateId": { + "companyId": { "type": "integer", "format": "int64" }, - "dimension": { - "type": "string" - }, - "period": { - "type": "string" - }, - "status": { - "type": "string" - }, - "reason": { - "type": "string" - }, - "decision": { + "companyName": { "type": "string" }, - "decisionReason": { - "type": "string" + "avgCandidateScore": { + "type": "number" }, - "reviewerId": { + "jobCount": { "type": "integer", - "format": "int64" - }, - "reviewedAt": { + "format": "int32" + } + } + }, + "CompanyUpdateRequest": { + "type": "object", + "properties": { + "name": { "type": "string", - "format": "date-time" + "maxLength": 120, + "minLength": 0 }, - "rollbackAt": { + "logoUrl": { "type": "string", - "format": "date-time" - }, - "scoreAtAppeal": { - "type": "number" - }, - "snapshotVersionAtAppeal": { - "type": "integer", - "format": "int64" + "maxLength": 500, + "minLength": 0 }, - "rulesVersion": { + "description": { "type": "string" }, - "createdAt": { + "industry": { "type": "string", - "format": "date-time" + "maxLength": 80, + "minLength": 0 }, - "updatedAt": { + "website": { "type": "string", - "format": "date-time" - } - } - }, - "ApplicationCreateRequest": { - "type": "object", - "properties": { - "jobId": { - "type": "integer", - "format": "int64" - } - }, - "required": [ - "jobId" - ] - }, - "ApplicationStatusRequest": { - "type": "object", - "properties": { - "status": { + "maxLength": 255, + "minLength": 0 + }, + "address": { "type": "string", - "minLength": 1, - "pattern": "^(VIEWED|INTERVIEW|REJECTED|HIRED)$" + "maxLength": 255, + "minLength": 0 } }, "required": [ - "status" + "name" ] }, - "CandidateDetailView": { + "CounterOfferCreateRequest": { "type": "object", "properties": { - "applicationId": { + "proposedSalaryMin": { "type": "integer", - "format": "int64" + "format": "int32", + "minimum": 0 }, - "jobId": { + "proposedSalaryMax": { "type": "integer", - "format": "int64" - }, - "jobTitle": { - "type": "string" - }, - "jobCity": { - "type": "string" - }, - "jobEmploymentType": { - "type": "string" - }, - "status": { - "type": "string" - }, - "coverLetter": { - "type": "string" + "format": "int32", + "minimum": 0 }, - "createdAt": { + "proposedOnboardingDate": { "type": "string", - "format": "date-time" + "format": "date" }, - "updatedAt": { + "reason": { "type": "string", - "format": "date-time" - }, - "candidateName": { - "type": "string" - }, - "candidateAvatarUrl": { - "type": "string" - }, - "candidateBio": { - "type": "string" - }, - "candidateLocation": { - "type": "string" + "maxLength": 2000, + "minLength": 0 + } + } + }, + "CreateAppealRequest": { + "type": "object", + "properties": { + "dimension": { + "type": "string", + "minLength": 1 }, - "candidateSchool": { - "type": "string" + "period": { + "type": "string", + "minLength": 1 }, - "candidateEducation": { + "reason": { "type": "string" }, - "candidateGraduationYear": { - "type": "integer", - "format": "int32" - }, - "candidateSkills": { - "type": "array", - "items": { - "type": "string" - } + "idempotencyKey": { + "type": "string", + "maxLength": 64, + "minLength": 0 } - } + }, + "required": [ + "dimension", + "period" + ] }, - "CandidateFeedbackRequest": { + "CreateConversationRequest": { "type": "object", "properties": { - "rating": { + "participantUserId": { "type": "integer", - "format": "int32", - "maximum": 5, - "minimum": 1 - }, - "content": { - "type": "string", - "maxLength": 1000, - "minLength": 0 - }, - "tags": { - "type": "array", - "items": { - "type": "string", - "maxLength": 50, - "minLength": 1 - }, - "maxItems": 10, - "minItems": 0 + "format": "int64" } }, "required": [ - "content", - "rating" + "participantUserId" ] }, - "CandidateFeedbackResponse": { + "DashboardOverviewResponse": { "type": "object", - "description": "脱敏后的候选人反馈", "properties": { - "id": { + "companyId": { "type": "integer", - "format": "int64", - "description": "反馈 ID", - "example": 1 + "format": "int64" }, - "applicationId": { + "totalJobs": { "type": "integer", - "format": "int64", - "description": "投递 ID", - "example": 31 + "format": "int32" }, - "rating": { + "publishedJobs": { "type": "integer", - "format": "int32", - "description": "评分,范围 1-5", - "example": 5 - }, - "content": { - "type": "string", - "description": "反馈内容", - "example": "沟通良好" - }, - "tags": { - "type": "array", - "description": "反馈标签", - "items": { - "type": "string" - } + "format": "int32" }, - "createdAt": { - "type": "string", - "format": "date-time", - "description": "创建时间", - "example": "2026-08-25T10:00:00" + "avgJobScore": { + "type": "number" }, - "updatedAt": { - "type": "string", - "format": "date-time", - "description": "更新时间", - "example": "2026-08-25T10:00:00" - } - } - }, - "CandidateFeedbackSummary": { - "type": "object", - "properties": { - "totalCount": { + "totalCandidates": { "type": "integer", "format": "int32" }, - "averageRating": { + "avgCandidateScore": { "type": "number" }, - "ratingDistribution": { + "scoreDistribution": { "type": "object", "additionalProperties": { "type": "integer", "format": "int32" } }, - "tags": { - "type": "array", - "items": { - "$ref": "#/components/schemas/TagCount" - } + "updatedAt": { + "type": "string", + "format": "date-time" } } }, - "CandidateItemView": { + "DataSourceItem": { "type": "object", "properties": { - "applicationId": { - "type": "integer", - "format": "int64" + "source": { + "type": "string" }, - "jobId": { - "type": "integer", - "format": "int64" + "time": { + "type": "string", + "format": "date-time" }, - "jobTitle": { + "note": { + "type": "string" + } + } + }, + "DimensionItem": { + "type": "object", + "properties": { + "dimension": { "type": "string" }, + "score": { + "type": "number" + }, "status": { "type": "string" }, - "candidateName": { - "type": "string" + "weight": { + "type": "number" }, - "candidateSchool": { + "summary": { "type": "string" - }, - "candidateEducation": { + } + } + }, + "DimensionScoreDetailResponse": { + "type": "object", + "properties": { + "dimension": { "type": "string" }, - "candidateGraduationYear": { - "type": "integer", - "format": "int32" + "score": { + "type": "number" }, - "candidateAvatarUrl": { + "status": { "type": "string" }, - "createdAt": { - "type": "string", - "format": "date-time" + "subScores": { + "type": "array", + "items": { + "$ref": "#/components/schemas/SubScore" + } + }, + "reasons": { + "type": "array", + "items": { + "type": "string" + } + }, + "dataSources": { + "type": "array", + "items": { + "$ref": "#/components/schemas/DataSourceItem" + } }, "updatedAt": { "type": "string", "format": "date-time" + }, + "rulesVersion": { + "type": "string" } } }, - "CandidateListResponse": { + "DimensionScoreItem": { "type": "object", "properties": { - "items": { - "type": "array", - "items": { - "$ref": "#/components/schemas/CandidateItemView" - } + "dimension": { + "type": "string" }, - "total": { - "type": "integer", - "format": "int64" + "score": { + "type": "number" }, - "page": { + "status": { + "type": "string" + } + } + }, + "InterviewCancelRequest": { + "type": "object", + "properties": { + "reason": { + "type": "string" + } + } + }, + "InterviewCreateRequest": { + "type": "object", + "properties": { + "roundNo": { "type": "integer", "format": "int32" }, - "size": { - "type": "integer", - "format": "int32" + "title": { + "type": "string", + "maxLength": 200, + "minLength": 0 }, - "totalPages": { - "type": "integer", - "format": "int32" + "interviewType": { + "type": "string", + "minLength": 1 + }, + "startAt": { + "type": "string", + "format": "date-time" + }, + "endAt": { + "type": "string", + "format": "date-time" + }, + "timezone": { + "type": "string" + }, + "meetingUrl": { + "type": "string" + }, + "address": { + "type": "string" + }, + "interviewerName": { + "type": "string", + "maxLength": 100, + "minLength": 0 + }, + "notes": { + "type": "string" } - } + }, + "required": [ + "endAt", + "interviewType", + "startAt", + "title" + ] }, - "CandidateMatchSignals": { + "InterviewRespondRequest": { "type": "object", "properties": { - "candidateInterestScore": { - "$ref": "#/components/schemas/MatchSignal" + "action": { + "type": "string", + "minLength": 1 }, - "hrSuitabilityScore": { - "$ref": "#/components/schemas/MatchSignal" + "reason": { + "type": "string" } - } + }, + "required": [ + "action" + ] }, - "CandidatePercentileResponse": { + "InterviewUpdateRequest": { "type": "object", "properties": { - "candidateId": { - "type": "integer", - "format": "int64" - }, - "status": { + "title": { "type": "string" }, - "scoreType": { + "startAt": { + "type": "string", + "format": "date-time" + }, + "endAt": { + "type": "string", + "format": "date-time" + }, + "timezone": { "type": "string" }, - "score": { - "type": "number" + "meetingUrl": { + "type": "string" }, - "percentile": { - "type": "number" + "address": { + "type": "string" }, - "cohortSize": { - "type": "integer", - "format": "int32" + "interviewerName": { + "type": "string" }, - "sampleCount": { - "type": "integer", - "format": "int32" + "notes": { + "type": "string" }, - "minimumSampleCount": { - "type": "integer", - "format": "int32" + "timeRangeValid": { + "type": "boolean" } } }, - "CandidateRankingDetailResponse": { + "JobCreateRequest": { "type": "object", "properties": { - "applicationId": { - "type": "integer", - "format": "int64" - }, - "jobId": { - "type": "integer", - "format": "int64" + "title": { + "type": "string", + "maxLength": 120, + "minLength": 0 }, - "candidateName": { - "type": "string" + "description": { + "type": "string", + "minLength": 1 }, - "candidateSchool": { + "requirements": { "type": "string" }, - "overallScore": { - "type": "number" + "city": { + "type": "string", + "maxLength": 80, + "minLength": 0 }, - "percentile": { - "type": "number" + "employmentType": { + "type": "string", + "maxLength": 30, + "minLength": 0 }, - "cohortSize": { + "salaryMin": { "type": "integer", "format": "int32" }, - "dimensionScores": { - "type": "array", - "items": { - "$ref": "#/components/schemas/DimensionScoreItem" - } + "salaryMax": { + "type": "integer", + "format": "int32" } - } + }, + "required": [ + "city", + "description", + "employmentType", + "title" + ] }, - "CandidateRankingItem": { + "JobRankingItem": { "type": "object", "properties": { - "applicationId": { - "type": "integer", - "format": "int64" - }, "jobId": { "type": "integer", "format": "int64" }, - "candidateName": { + "title": { "type": "string" }, - "candidateSchool": { + "city": { "type": "string" }, - "overallScore": { - "type": "number" + "employmentType": { + "type": "string" }, - "percentile": { + "avgRating": { "type": "number" }, - "sampleCount": { + "reviewCount": { + "type": "integer", + "format": "int32" + }, + "rank": { + "type": "integer", + "format": "int32" + }, + "cohortSize": { "type": "integer", "format": "int32" + }, + "dimensionAverages": { + "type": "object", + "additionalProperties": { + "type": "number" + } } } }, - "CandidateRankingListResponse": { + "JobRankingListResponse": { "type": "object", "properties": { "items": { "type": "array", "items": { - "$ref": "#/components/schemas/CandidateRankingItem" + "$ref": "#/components/schemas/JobRankingItem" } }, "total": { @@ -7808,1661 +10248,2107 @@ } } }, - "CandidateScoreCardResponse": { + "JobReviewCreateRequest": { "type": "object", "properties": { - "candidateId": { - "type": "integer", - "format": "int64" - }, - "status": { - "type": "string" - }, - "overallScore": { - "type": "number" - }, - "sampleCount": { + "rating": { "type": "integer", - "format": "int32" - }, - "feedbackSummary": { - "$ref": "#/components/schemas/CandidateFeedbackSummary" + "format": "int32", + "maximum": 5, + "minimum": 1 }, - "matchSignals": { - "$ref": "#/components/schemas/CandidateMatchSignals" + "content": { + "type": "string", + "maxLength": 1000, + "minLength": 0 }, - "unsupportedDimensions": { + "tags": { "type": "array", "items": { - "type": "string" - } - }, - "generatedAt": { - "type": "string", - "format": "date-time" - } - } - }, - "CandidateScoreTrendItem": { - "type": "object", - "properties": { - "month": { - "type": "string" - }, - "feedbackCount": { - "type": "integer", - "format": "int32" - }, - "averageRating": { - "type": "number" + "type": "string", + "maxLength": 50, + "minLength": 1 + }, + "maxItems": 10, + "minItems": 0 }, - "ratingScore": { - "type": "number" + "dimensionScores": { + "type": "object", + "additionalProperties": { + "type": "integer", + "format": "int32" + } } - } + }, + "required": [ + "content", + "rating" + ] }, - "CandidateScoreTrendResponse": { + "JobReviewUpdateRequest": { "type": "object", "properties": { - "candidateId": { + "rating": { "type": "integer", - "format": "int64" + "format": "int32", + "maximum": 5, + "minimum": 1 }, - "months": { - "type": "integer", - "format": "int32" + "content": { + "type": "string", + "maxLength": 1000, + "minLength": 1 }, - "items": { + "tags": { "type": "array", "items": { - "$ref": "#/components/schemas/CandidateScoreTrendItem" - } - } - } - }, - "CandidateScoringOverviewResponse": { - "type": "object", - "properties": { - "candidateId": { - "type": "integer", - "format": "int64" + "type": "string", + "maxLength": 50, + "minLength": 1 + }, + "maxItems": 10, + "minItems": 0 }, "status": { - "type": "string" - }, - "overallScore": { - "type": "number" + "type": "string", + "pattern": "^(PUBLISHED|HIDDEN)$" }, - "weights": { + "dimensionScores": { "type": "object", "additionalProperties": { - "type": "number" - } - }, - "dimensions": { - "type": "array", - "items": { - "$ref": "#/components/schemas/DimensionItem" + "type": "integer", + "format": "int32" } - }, - "rulesVersion": { - "type": "string" - }, - "generatedAt": { - "type": "string", - "format": "date-time" } } }, - "CandidateScoringTrendResponse": { + "JobStatusRequest": { "type": "object", "properties": { - "candidateId": { - "type": "integer", - "format": "int64" - }, - "months": { - "type": "integer", - "format": "int32" - }, - "items": { - "type": "array", - "items": { - "$ref": "#/components/schemas/TrendItem" - } + "status": { + "type": "string", + "minLength": 1, + "pattern": "^(PUBLISHED|CLOSED)$" } - } + }, + "required": [ + "status" + ] }, - "CompanyCandidateScoringResponse": { - "type": "object", - "properties": { - "applicationId": { - "type": "integer", - "format": "int64" + "JobUpdateRequest": { + "type": "object", + "properties": { + "title": { + "type": "string", + "maxLength": 120, + "minLength": 0 }, - "overallScore": { - "type": "number" + "description": { + "type": "string" }, - "status": { + "requirements": { "type": "string" }, - "dimensions": { - "type": "array", - "items": { - "$ref": "#/components/schemas/CompanyDimensionItem" - } + "city": { + "type": "string", + "maxLength": 80, + "minLength": 0 }, - "generatedAt": { + "employmentType": { "type": "string", - "format": "date-time" + "maxLength": 30, + "minLength": 0 }, - "rulesVersion": { - "type": "string" + "salaryMin": { + "type": "integer", + "format": "int32" + }, + "salaryMax": { + "type": "integer", + "format": "int32" } } }, - "CompanyCreateRequest": { + "LoginRequest": { "type": "object", "properties": { - "name": { + "phone": { "type": "string", - "maxLength": 100, - "minLength": 0 - }, - "logoUrl": { - "type": "string" - }, - "description": { - "type": "string" - }, - "industry": { - "type": "string" + "minLength": 1, + "pattern": "[0-9]{11}" }, - "website": { - "type": "string" + "code": { + "type": "string", + "minLength": 1, + "pattern": "[0-9]{6}" }, - "address": { + "purpose": { "type": "string" } }, "required": [ - "name" + "code", + "phone" ] }, - "CompanyDimensionItem": { + "MatchSignal": { "type": "object", "properties": { - "dimension": { - "type": "string" - }, - "score": { + "average": { "type": "number" }, - "status": { - "type": "string" + "sampleCount": { + "type": "integer", + "format": "int32" } } }, - "CompanyMatchRuleRequest": { + "OfferCreateRequest": { "type": "object", "properties": { - "requiredCandidateScore": { + "applicationId": { + "type": "integer", + "format": "int64" + }, + "title": { + "type": "string", + "maxLength": 120, + "minLength": 0 + }, + "salaryMin": { "type": "integer", "format": "int32", - "maximum": 100, "minimum": 0 - } - }, - "required": [ - "requiredCandidateScore" - ] - }, - "CompanyPublicView": { - "type": "object", - "properties": { - "id": { + }, + "salaryMax": { "type": "integer", - "format": "int64" + "format": "int32", + "minimum": 0 }, - "name": { - "type": "string" + "salaryCurrency": { + "type": "string", + "maxLength": 10, + "minLength": 0 }, - "logoUrl": { - "type": "string" + "salaryPeriod": { + "type": "string", + "pattern": "^(MONTH|YEAR|HOUR)$" + }, + "workCity": { + "type": "string", + "maxLength": 80, + "minLength": 0 + }, + "employmentType": { + "type": "string", + "pattern": "^(FULL_TIME|PART_TIME|INTERN|INTERNSHIP)$" + }, + "probationMonths": { + "type": "integer", + "format": "int32", + "minimum": 0 + }, + "benefits": { + "type": "string", + "maxLength": 2000, + "minLength": 0 }, "description": { - "type": "string" + "type": "string", + "maxLength": 5000, + "minLength": 0 }, - "industry": { - "type": "string" + "expireAt": { + "type": "string", + "format": "date-time" }, - "verified": { - "type": "boolean" + "onboardingDate": { + "type": "string", + "format": "date" + }, + "baseSalary": { + "type": "integer", + "format": "int32", + "minimum": 0 + }, + "housingAllowance": { + "type": "integer", + "format": "int32", + "minimum": 0 + }, + "annualBonusMonths": { + "type": "integer", + "format": "int32", + "minimum": 0 + }, + "otherAllowances": { + "type": "string", + "maxLength": 1000, + "minLength": 0 } - } + }, + "required": [ + "applicationId", + "description", + "expireAt", + "title", + "workCity" + ] }, - "CompanyScoreItem": { + "OfferStatusRequest": { "type": "object", "properties": { - "companyId": { - "type": "integer", - "format": "int64" - }, - "companyName": { - "type": "string" - }, - "avgCandidateScore": { - "type": "number" + "status": { + "type": "string", + "minLength": 1, + "pattern": "^(SENT|NEGOTIATING|ACCEPTED|REJECTED|EXPIRED|WITHDRAWN)$" }, - "jobCount": { - "type": "integer", - "format": "int32" + "reason": { + "type": "string", + "maxLength": 255, + "minLength": 0 } - } + }, + "required": [ + "status" + ] }, - "CompanyUpdateRequest": { + "OfferUpdateRequest": { "type": "object", "properties": { - "name": { + "title": { "type": "string", "maxLength": 120, "minLength": 0 }, - "logoUrl": { + "salaryMin": { + "type": "integer", + "format": "int32", + "minimum": 0 + }, + "salaryMax": { + "type": "integer", + "format": "int32", + "minimum": 0 + }, + "salaryCurrency": { "type": "string", - "maxLength": 500, + "maxLength": 10, "minLength": 0 }, - "description": { - "type": "string" + "salaryPeriod": { + "type": "string", + "pattern": "^(MONTH|YEAR|HOUR)$" }, - "industry": { + "workCity": { "type": "string", "maxLength": 80, "minLength": 0 }, - "website": { + "employmentType": { "type": "string", - "maxLength": 255, + "pattern": "^(FULL_TIME|PART_TIME|INTERN|INTERNSHIP)$" + }, + "probationMonths": { + "type": "integer", + "format": "int32", + "minimum": 0 + }, + "benefits": { + "type": "string", + "maxLength": 2000, "minLength": 0 }, - "address": { + "description": { "type": "string", - "maxLength": 255, + "maxLength": 5000, "minLength": 0 - } - }, - "required": [ - "name" - ] - }, - "CounterOfferCreateRequest": { - "type": "object", - "properties": { - "proposedSalaryMin": { + }, + "expireAt": { + "type": "string", + "format": "date-time" + }, + "onboardingDate": { + "type": "string", + "format": "date" + }, + "baseSalary": { "type": "integer", "format": "int32", "minimum": 0 }, - "proposedSalaryMax": { + "housingAllowance": { "type": "integer", "format": "int32", "minimum": 0 }, - "proposedOnboardingDate": { - "type": "string", - "format": "date" + "annualBonusMonths": { + "type": "integer", + "format": "int32", + "minimum": 0 }, - "reason": { + "otherAllowances": { "type": "string", - "maxLength": 2000, + "maxLength": 1000, "minLength": 0 } } }, - "CreateAppealRequest": { + "PersonalizedScoreResponse": { "type": "object", "properties": { - "dimension": { - "type": "string", - "minLength": 1 + "jobId": { + "type": "integer", + "format": "int64" }, - "period": { - "type": "string", - "minLength": 1 + "score": { + "type": "number" }, - "reason": { + "status": { "type": "string" }, - "idempotencyKey": { - "type": "string", - "maxLength": 64, - "minLength": 0 - } - }, - "required": [ - "dimension", - "period" - ] - }, - "CreateConversationRequest": { - "type": "object", - "properties": { - "participantUserId": { - "type": "integer", - "format": "int64" + "source": { + "type": "string" + }, + "templateCode": { + "type": "string" + }, + "configuredWeights": { + "type": "object", + "additionalProperties": { + "type": "number" + } + }, + "effectiveWeights": { + "type": "object", + "additionalProperties": { + "type": "number" + } + }, + "dimensionScores": { + "type": "object", + "additionalProperties": { + "type": "number" + } + }, + "coveredWeight": { + "type": "number" + }, + "missingDimensions": { + "type": "array", + "items": { + "type": "string" + } } - }, - "required": [ - "participantUserId" - ] + } }, - "DashboardOverviewResponse": { + "PrivacySettingsResponse": { "type": "object", "properties": { - "companyId": { + "id": { "type": "integer", - "format": "int64" + "format": "int64", + "example": 1, + "nullable": true }, - "totalJobs": { + "studentUserId": { "type": "integer", - "format": "int32" + "format": "int64", + "example": 2 }, - "publishedJobs": { - "type": "integer", - "format": "int32" + "resumeVisibility": { + "$ref": "#/components/schemas/ResumeVisibility", + "description": "简历可见性(PRIVATE/APPLIED_COMPANIES/VERIFIED_COMPANIES)", + "example": "APPLIED_COMPANIES" }, - "avgJobScore": { - "type": "number" + "contactVisible": { + "type": "boolean", + "example": true }, - "totalCandidates": { + "version": { "type": "integer", - "format": "int32" - }, - "avgCandidateScore": { - "type": "number" + "format": "int64", + "example": 0 }, - "scoreDistribution": { - "type": "object", - "additionalProperties": { - "type": "integer", - "format": "int32" - } + "createdAt": { + "type": "string", + "format": "date-time", + "nullable": true }, "updatedAt": { "type": "string", - "format": "date-time" + "format": "date-time", + "nullable": true } } }, - "DataSourceItem": { + "PrivacySettingsUpdateRequest": { "type": "object", + "required": [ + "resumeVisibility", + "contactVisible" + ], "properties": { - "source": { - "type": "string" + "resumeVisibility": { + "$ref": "#/components/schemas/ResumeVisibility", + "description": "简历可见性(必填)", + "example": "APPLIED_COMPANIES" }, - "time": { - "type": "string", - "format": "date-time" + "contactVisible": { + "type": "boolean", + "description": "联系方式是否对企业可见(必填)", + "example": true }, - "note": { - "type": "string" + "version": { + "type": "integer", + "format": "int64", + "description": "乐观锁版本号(可选)", + "example": 0 } } }, - "DimensionItem": { + "ResumeVisibility": { + "type": "string", + "enum": [ + "PRIVATE", + "APPLIED_COMPANIES", + "VERIFIED_COMPANIES" + ], + "description": "简历可见性:PRIVATE 私有;APPLIED_COMPANIES 仅对企业有效投递可见;VERIFIED_COMPANIES 仅对认证企业可见", + "example": "PRIVATE" + }, + "ReviewDecisionRequest": { "type": "object", "properties": { - "dimension": { - "type": "string" - }, - "score": { - "type": "number" - }, - "status": { - "type": "string" - }, - "weight": { - "type": "number" + "decision": { + "type": "string", + "minLength": 1, + "pattern": "^(APPROVED|REJECTED)$" }, - "summary": { - "type": "string" + "reason": { + "type": "string", + "maxLength": 500, + "minLength": 0 } - } + }, + "required": [ + "decision" + ] }, - "DimensionScoreDetailResponse": { + "ReviewWeightResponse": { "type": "object", "properties": { - "dimension": { + "source": { "type": "string" }, - "score": { - "type": "number" - }, - "status": { + "templateCode": { "type": "string" }, - "subScores": { - "type": "array", - "items": { - "$ref": "#/components/schemas/SubScore" - } - }, - "reasons": { - "type": "array", - "items": { - "type": "string" - } - }, - "dataSources": { - "type": "array", - "items": { - "$ref": "#/components/schemas/DataSourceItem" + "configuredWeights": { + "type": "object", + "additionalProperties": { + "type": "number" } }, "updatedAt": { "type": "string", "format": "date-time" - }, - "rulesVersion": { + } + } + }, + "SaveWeightRequest": { + "type": "object", + "properties": { + "templateCode": { "type": "string" + }, + "weights": { + "type": "object", + "additionalProperties": { + "type": "number" + } } } }, - "DimensionScoreItem": { + "ScoreRequest": { + "type": "object", + "properties": { + "score": { + "type": "integer", + "format": "int32", + "maximum": 100, + "minimum": 0 + } + }, + "required": [ + "score" + ] + }, + "ScoreTrendResponse": { "type": "object", "properties": { - "dimension": { - "type": "string" + "companyId": { + "type": "integer", + "format": "int64" }, - "score": { - "type": "number" + "months": { + "type": "integer", + "format": "int32" }, - "status": { - "type": "string" + "items": { + "type": "array", + "items": { + "$ref": "#/components/schemas/TrendItem" + } } } }, - "InterviewCancelRequest": { + "SendMessageRequest": { "type": "object", "properties": { - "reason": { - "type": "string" + "conversationId": { + "type": "integer", + "format": "int64" + }, + "content": { + "type": "string", + "maxLength": 2000, + "minLength": 0 } - } + }, + "required": [ + "content", + "conversationId" + ] }, - "InterviewCreateRequest": { + "SnapshotHistoryItem": { "type": "object", "properties": { - "roundNo": { + "id": { "type": "integer", - "format": "int32" - }, - "title": { - "type": "string", - "maxLength": 200, - "minLength": 0 + "format": "int64" }, - "interviewType": { - "type": "string", - "minLength": 1 + "appealId": { + "type": "integer", + "format": "int64" }, - "startAt": { - "type": "string", - "format": "date-time" + "action": { + "type": "string" }, - "endAt": { - "type": "string", - "format": "date-time" + "dimension": { + "type": "string" }, - "timezone": { + "period": { "type": "string" }, - "meetingUrl": { + "beforeScore": { + "type": "number" + }, + "afterScore": { + "type": "number" + }, + "beforeVersion": { + "type": "integer", + "format": "int64" + }, + "afterVersion": { + "type": "integer", + "format": "int64" + }, + "beforeRulesVersion": { "type": "string" }, - "address": { + "afterRulesVersion": { "type": "string" }, - "interviewerName": { + "createdAt": { "type": "string", - "maxLength": 100, - "minLength": 0 - }, - "notes": { - "type": "string" + "format": "date-time" } - }, - "required": [ - "endAt", - "interviewType", - "startAt", - "title" - ] + } }, - "InterviewRespondRequest": { + "StudentMatchRuleRequest": { "type": "object", "properties": { - "action": { - "type": "string", - "minLength": 1 - }, - "reason": { - "type": "string" + "requiredHrScore": { + "type": "integer", + "format": "int32", + "maximum": 100, + "minimum": 0 } }, "required": [ - "action" + "requiredHrScore" ] }, - "InterviewUpdateRequest": { + "StudentPreferenceRequest": { "type": "object", "properties": { - "title": { - "type": "string" - }, - "startAt": { - "type": "string", - "format": "date-time" + "expectedSalaryMin": { + "type": "integer", + "format": "int32", + "minimum": 0 }, - "endAt": { - "type": "string", - "format": "date-time" + "expectedSalaryMax": { + "type": "integer", + "format": "int32", + "minimum": 0 }, - "timezone": { - "type": "string" + "preferredCities": { + "type": "array", + "items": { + "type": "string", + "maxLength": 100, + "minLength": 1 + }, + "maxItems": 20, + "minItems": 0 }, - "meetingUrl": { - "type": "string" + "employmentTypes": { + "type": "array", + "items": { + "type": "string", + "maxLength": 100, + "minLength": 1 + }, + "maxItems": 20, + "minItems": 0 }, - "address": { - "type": "string" + "preferredIndustries": { + "type": "array", + "items": { + "type": "string", + "maxLength": 100, + "minLength": 1 + }, + "maxItems": 20, + "minItems": 0 }, - "interviewerName": { - "type": "string" + "preferredJobTypes": { + "type": "array", + "items": { + "type": "string", + "maxLength": 100, + "minLength": 1 + }, + "maxItems": 20, + "minItems": 0 }, - "notes": { - "type": "string" + "remoteAllowed": { + "type": "boolean" }, - "timeRangeValid": { + "salaryRangeValid": { "type": "boolean" } } }, - "JobCreateRequest": { + "StudentProfileUpdateRequest": { "type": "object", "properties": { - "title": { + "realName": { "type": "string", - "maxLength": 120, + "maxLength": 50, "minLength": 0 }, - "description": { - "type": "string", - "minLength": 1 - }, - "requirements": { + "bio": { "type": "string" }, - "city": { + "location": { "type": "string", - "maxLength": 80, + "maxLength": 100, "minLength": 0 }, - "employmentType": { + "education": { "type": "string", - "maxLength": 30, + "maxLength": 100, "minLength": 0 }, - "salaryMin": { - "type": "integer", - "format": "int32" - }, - "salaryMax": { - "type": "integer", - "format": "int32" - } - }, - "required": [ - "city", - "description", - "employmentType", - "title" - ] - }, - "JobRankingItem": { - "type": "object", - "properties": { - "jobId": { - "type": "integer", - "format": "int64" - }, - "title": { - "type": "string" - }, - "city": { - "type": "string" - }, - "employmentType": { - "type": "string" - }, - "avgRating": { - "type": "number" - }, - "reviewCount": { - "type": "integer", - "format": "int32" + "school": { + "type": "string", + "maxLength": 150, + "minLength": 0 }, - "rank": { + "graduationYear": { "type": "integer", "format": "int32" }, - "cohortSize": { - "type": "integer", - "format": "int32" + "resumeUrl": { + "type": "string", + "maxLength": 500, + "minLength": 0 }, - "dimensionAverages": { - "type": "object", - "additionalProperties": { - "type": "number" + "skills": { + "type": "array", + "items": { + "type": "string" } } } }, - "JobRankingListResponse": { + "SubScore": { "type": "object", "properties": { - "items": { - "type": "array", - "items": { - "$ref": "#/components/schemas/JobRankingItem" - } + "name": { + "type": "string" }, - "total": { - "type": "integer", - "format": "int64" + "score": { + "type": "number" }, - "page": { - "type": "integer", - "format": "int32" + "weight": { + "type": "number" }, - "size": { - "type": "integer", - "format": "int32" + "description": { + "type": "string" + } + } + }, + "TagCount": { + "type": "object", + "properties": { + "name": { + "type": "string" }, - "totalPages": { + "count": { "type": "integer", "format": "int32" } } }, - "JobReviewCreateRequest": { + "TrendItem": { "type": "object", "properties": { - "rating": { - "type": "integer", - "format": "int32", - "maximum": 5, - "minimum": 1 + "period": { + "type": "string" }, - "content": { - "type": "string", - "maxLength": 1000, - "minLength": 0 + "overallScore": { + "type": "number" }, - "tags": { - "type": "array", - "items": { - "type": "string", - "maxLength": 50, - "minLength": 1 - }, - "maxItems": 10, - "minItems": 0 + "rulesVersion": { + "type": "string" }, - "dimensionScores": { + "dimensions": { "type": "object", "additionalProperties": { - "type": "integer", - "format": "int32" + "type": "number" } } + } + }, + "VerificationDecisionRequest": { + "type": "object", + "properties": { + "decision": { + "type": "string", + "minLength": 1 + }, + "reason": { + "type": "string" + } }, "required": [ - "content", - "rating" + "decision" ] }, - "JobReviewUpdateRequest": { + "VerificationSubmitRequest": { "type": "object", "properties": { - "rating": { - "type": "integer", - "format": "int32", - "maximum": 5, - "minimum": 1 - }, - "content": { + "materialsJson": { "type": "string", - "maxLength": 1000, "minLength": 1 + } + }, + "required": [ + "materialsJson" + ] + }, + "WeightTemplateResponse": { + "type": "object", + "properties": { + "code": { + "type": "string" }, - "tags": { - "type": "array", - "items": { - "type": "string", - "maxLength": 50, - "minLength": 1 - }, - "maxItems": 10, - "minItems": 0 + "name": { + "type": "string" }, - "status": { - "type": "string", - "pattern": "^(PUBLISHED|HIDDEN)$" + "description": { + "type": "string" }, - "dimensionScores": { + "weights": { "type": "object", "additionalProperties": { - "type": "integer", - "format": "int32" + "type": "number" } } } }, - "JobStatusRequest": { + "InterviewScoreSaveRequest": { "type": "object", "properties": { - "status": { + "technicalScore": { + "type": "integer", + "minimum": 1, + "maximum": 5, + "nullable": true + }, + "communicationScore": { + "type": "integer", + "minimum": 1, + "maximum": 5, + "nullable": true + }, + "problemSolvingScore": { + "type": "integer", + "minimum": 1, + "maximum": 5, + "nullable": true + }, + "roleFitScore": { + "type": "integer", + "minimum": 1, + "maximum": 5, + "nullable": true + }, + "professionalismScore": { + "type": "integer", + "minimum": 1, + "maximum": 5, + "nullable": true + }, + "recommendation": { "type": "string", - "minLength": 1, - "pattern": "^(PUBLISHED|CLOSED)$" + "enum": [ + "STRONG_HIRE", + "HIRE", + "NEUTRAL", + "NO_HIRE" + ], + "nullable": true, + "description": "企业手动选择,不根据 overallScore 自动推导" + }, + "comment": { + "type": "string", + "nullable": true } }, - "required": [ - "status" - ] + "description": "评分草稿保存请求;overallScore 由服务端计算,客户端不得传入" }, - "JobUpdateRequest": { + "InterviewScoreResponse": { "type": "object", "properties": { - "title": { + "id": { + "type": "integer", + "format": "int64" + }, + "interviewId": { + "type": "integer", + "format": "int64" + }, + "applicationId": { + "type": "integer", + "format": "int64" + }, + "status": { "type": "string", - "maxLength": 120, - "minLength": 0 + "enum": [ + "DRAFT", + "SUBMITTED" + ] }, - "description": { - "type": "string" + "technicalScore": { + "type": "integer", + "nullable": true }, - "requirements": { - "type": "string" + "communicationScore": { + "type": "integer", + "nullable": true }, - "city": { + "problemSolvingScore": { + "type": "integer", + "nullable": true + }, + "roleFitScore": { + "type": "integer", + "nullable": true + }, + "professionalismScore": { + "type": "integer", + "nullable": true + }, + "overallScore": { + "type": "number", + "format": "decimal", + "nullable": true, + "description": "由服务端 BigDecimal 计算:(t+c+ps+rf+p)/5,保留两位小数 HALF_UP" + }, + "recommendation": { "type": "string", - "maxLength": 80, - "minLength": 0 + "enum": [ + "STRONG_HIRE", + "HIRE", + "NEUTRAL", + "NO_HIRE" + ], + "nullable": true }, - "employmentType": { + "comment": { "type": "string", - "maxLength": 30, - "minLength": 0 + "nullable": true + }, + "submittedAt": { + "type": "string", + "format": "date-time", + "nullable": true }, - "salaryMin": { + "version": { "type": "integer", - "format": "int32" + "format": "int64" }, - "salaryMax": { - "type": "integer", - "format": "int32" - } - } - }, - "LoginRequest": { - "type": "object", - "properties": { - "phone": { + "createdAt": { "type": "string", - "minLength": 1, - "pattern": "[0-9]{11}" + "format": "date-time" }, - "code": { + "updatedAt": { "type": "string", - "minLength": 1, - "pattern": "[0-9]{6}" - }, - "purpose": { - "type": "string" + "format": "date-time" } }, - "required": [ - "code", - "phone" - ] + "description": "面试评分响应(脱敏:不返回 companyUserId/studentUserId/evaluatorUserId/手机号/邮箱)" }, - "MatchSignal": { + "ApiResponseInterviewScoreResponse": { "type": "object", "properties": { - "average": { - "type": "number" - }, - "sampleCount": { + "code": { "type": "integer", "format": "int32" + }, + "message": { + "type": "string" + }, + "data": { + "$ref": "#/components/schemas/InterviewScoreResponse" + }, + "requestId": { + "type": "string" + }, + "timestamp": { + "type": "string", + "format": "date-time" } } }, - "OfferCreateRequest": { + "InterviewScoreHistoryItem": { "type": "object", "properties": { - "applicationId": { + "id": { "type": "integer", "format": "int64" }, - "title": { - "type": "string", - "maxLength": 120, - "minLength": 0 - }, - "salaryMin": { + "scoreId": { "type": "integer", - "format": "int32", - "minimum": 0 + "format": "int64" }, - "salaryMax": { + "interviewId": { "type": "integer", - "format": "int32", - "minimum": 0 + "format": "int64" }, - "salaryCurrency": { + "fromStatus": { "type": "string", - "maxLength": 10, - "minLength": 0 + "enum": [ + "DRAFT", + "SUBMITTED" + ], + "nullable": true }, - "salaryPeriod": { + "toStatus": { "type": "string", - "pattern": "^(MONTH|YEAR|HOUR)$" + "enum": [ + "DRAFT", + "SUBMITTED" + ] }, - "workCity": { + "actorRole": { "type": "string", - "maxLength": 80, - "minLength": 0 + "example": "COMPANY" }, - "employmentType": { - "type": "string", - "pattern": "^(FULL_TIME|PART_TIME|INTERN|INTERNSHIP)$" + "snapshot": { + "type": "object", + "additionalProperties": {} }, - "probationMonths": { + "createdAt": { + "type": "string", + "format": "date-time" + } + }, + "description": "评分历史条目(脱敏:不返回 actorUserId/手机号/邮箱)" + }, + "ApiResponseListInterviewScoreHistoryItem": { + "type": "object", + "properties": { + "code": { "type": "integer", - "format": "int32", - "minimum": 0 + "format": "int32" }, - "benefits": { - "type": "string", - "maxLength": 2000, - "minLength": 0 + "message": { + "type": "string" }, - "description": { - "type": "string", - "maxLength": 5000, - "minLength": 0 + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/InterviewScoreHistoryItem" + } }, - "expireAt": { + "requestId": { + "type": "string" + }, + "timestamp": { "type": "string", "format": "date-time" - }, - "onboardingDate": { + } + } + }, + "CreateReportRequest": { + "type": "object", + "properties": { + "targetType": { "type": "string", - "format": "date" + "enum": [ + "USER", + "MESSAGE" + ], + "description": "举报目标类型;USER 必填 targetUserId,MESSAGE 必填 messageId" }, - "baseSalary": { + "targetUserId": { "type": "integer", - "format": "int32", - "minimum": 0 + "format": "int64", + "description": "targetType=USER 时必填" }, - "housingAllowance": { + "messageId": { "type": "integer", - "format": "int32", - "minimum": 0 + "format": "int64", + "description": "targetType=MESSAGE 时必填" }, - "annualBonusMonths": { - "type": "integer", - "format": "int32", - "minimum": 0 + "category": { + "type": "string", + "maxLength": 40, + "description": "举报分类,如 HARASSMENT" }, - "otherAllowances": { + "reason": { "type": "string", - "maxLength": 1000, - "minLength": 0 + "minLength": 1, + "maxLength": 1000 + }, + "evidenceUrls": { + "type": "array", + "items": { + "type": "string", + "maxLength": 500 + }, + "description": "最多 5 个,仅 http/https,最多 500 字符" + }, + "idempotencyKey": { + "type": "string", + "maxLength": 64, + "description": "幂等键(可选)" } - }, - "required": [ - "applicationId", - "description", - "expireAt", - "title", - "workCity" - ] + } }, - "OfferStatusRequest": { + "ReportDecisionRequest": { "type": "object", "properties": { - "status": { + "decision": { "type": "string", - "minLength": 1, - "pattern": "^(SENT|NEGOTIATING|ACCEPTED|REJECTED|EXPIRED|WITHDRAWN)$" + "enum": [ + "RESOLVED", + "REJECTED" + ] }, - "reason": { + "note": { "type": "string", - "maxLength": 255, - "minLength": 0 + "maxLength": 1000, + "description": "REJECTED 必填" } - }, - "required": [ - "status" - ] + } }, - "OfferUpdateRequest": { + "ReportResponse": { "type": "object", "properties": { - "title": { + "id": { + "type": "integer", + "format": "int64" + }, + "targetType": { "type": "string", - "maxLength": 120, - "minLength": 0 + "enum": [ + "USER", + "MESSAGE" + ] }, - "salaryMin": { + "targetUserId": { "type": "integer", - "format": "int32", - "minimum": 0 + "format": "int64" }, - "salaryMax": { + "messageId": { "type": "integer", - "format": "int32", - "minimum": 0 + "format": "int64" }, - "salaryCurrency": { - "type": "string", - "maxLength": 10, - "minLength": 0 + "reportedUserId": { + "type": "integer", + "format": "int64" }, - "salaryPeriod": { - "type": "string", - "pattern": "^(MONTH|YEAR|HOUR)$" + "category": { + "type": "string" }, - "workCity": { + "reason": { + "type": "string" + }, + "evidenceUrls": { + "type": "array", + "items": { + "type": "string" + } + }, + "status": { "type": "string", - "maxLength": 80, - "minLength": 0 + "enum": [ + "PENDING", + "RESOLVED", + "REJECTED" + ] }, - "employmentType": { + "decision": { "type": "string", - "pattern": "^(FULL_TIME|PART_TIME|INTERN|INTERNSHIP)$" + "enum": [ + "RESOLVED", + "REJECTED" + ] }, - "probationMonths": { - "type": "integer", - "format": "int32", - "minimum": 0 + "decisionNote": { + "type": "string" }, - "benefits": { - "type": "string", - "maxLength": 2000, - "minLength": 0 + "decidedAt": { + "type": "string", + "format": "date-time" }, - "description": { + "createdAt": { "type": "string", - "maxLength": 5000, - "minLength": 0 + "format": "date-time" }, - "expireAt": { + "updatedAt": { "type": "string", "format": "date-time" }, - "onboardingDate": { + "messagePreview": { "type": "string", - "format": "date" + "description": "MESSAGE 举报的脱敏消息预览,不返回会话其他敏感内容" }, - "baseSalary": { + "reportedUser": { + "$ref": "#/components/schemas/ReportResponseReportedUser" + }, + "history": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ReportResponseDecisionHistoryItem" + } + } + } + }, + "ReportResponseReportedUser": { + "type": "object", + "properties": { + "userId": { "type": "integer", - "format": "int32", - "minimum": 0 + "format": "int64" }, - "housingAllowance": { + "nickname": { + "type": "string" + }, + "role": { + "type": "string" + } + } + }, + "ReportResponseDecisionHistoryItem": { + "type": "object", + "properties": { + "id": { "type": "integer", - "format": "int32", - "minimum": 0 + "format": "int64" }, - "annualBonusMonths": { + "fromStatus": { + "type": "string" + }, + "toStatus": { + "type": "string" + }, + "actorUserId": { "type": "integer", - "format": "int32", - "minimum": 0 + "format": "int64" }, - "otherAllowances": { + "actorRole": { + "type": "string" + }, + "note": { + "type": "string" + }, + "createdAt": { "type": "string", - "maxLength": 1000, - "minLength": 0 + "format": "date-time" } } }, - "PersonalizedScoreResponse": { + "BlockRelationResponse": { "type": "object", "properties": { - "jobId": { + "id": { "type": "integer", "format": "int64" }, - "score": { - "type": "number" + "blockedUserId": { + "type": "integer", + "format": "int64" }, - "status": { + "nickname": { "type": "string" }, - "source": { + "createdAt": { + "type": "string", + "format": "date-time" + } + } + }, + "ApiResponseReportResponse": { + "type": "object", + "properties": { + "code": { + "type": "integer", + "format": "int32" + }, + "message": { "type": "string" }, - "templateCode": { + "data": { + "$ref": "#/components/schemas/ReportResponse" + }, + "requestId": { "type": "string" }, - "configuredWeights": { - "type": "object", - "additionalProperties": { - "type": "number" - } + "timestamp": { + "type": "string", + "format": "date-time" + } + } + }, + "ApiResponsePageResponseReportResponse": { + "type": "object", + "properties": { + "code": { + "type": "integer", + "format": "int32" }, - "effectiveWeights": { - "type": "object", - "additionalProperties": { - "type": "number" - } + "message": { + "type": "string" }, - "dimensionScores": { + "data": { "type": "object", - "additionalProperties": { - "type": "number" + "properties": { + "items": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ReportResponse" + } + }, + "totalElements": { + "type": "integer", + "format": "int64" + }, + "page": { + "type": "integer", + "format": "int32" + }, + "size": { + "type": "integer", + "format": "int32" + } } }, - "coveredWeight": { - "type": "number" + "requestId": { + "type": "string" }, - "missingDimensions": { - "type": "array", - "items": { - "type": "string" - } + "timestamp": { + "type": "string", + "format": "date-time" } } }, - "PrivacySettingsResponse": { + "ApiResponseBlockRelationResponse": { "type": "object", "properties": { - "id": { + "code": { "type": "integer", - "format": "int64", - "example": 1, - "nullable": true + "format": "int32" }, - "studentUserId": { - "type": "integer", - "format": "int64", - "example": 2 + "message": { + "type": "string" }, - "resumeVisibility": { - "$ref": "#/components/schemas/ResumeVisibility", - "description": "简历可见性(PRIVATE/APPLIED_COMPANIES/VERIFIED_COMPANIES)", - "example": "APPLIED_COMPANIES" + "data": { + "$ref": "#/components/schemas/BlockRelationResponse" }, - "contactVisible": { - "type": "boolean", - "example": true + "requestId": { + "type": "string" }, - "version": { + "timestamp": { + "type": "string", + "format": "date-time" + } + } + }, + "ApiResponsePageResponseBlockRelationResponse": { + "type": "object", + "properties": { + "code": { "type": "integer", - "format": "int64", - "example": 0 + "format": "int32" }, - "createdAt": { - "type": "string", - "format": "date-time", - "nullable": true + "message": { + "type": "string" }, - "updatedAt": { + "data": { + "type": "object", + "properties": { + "items": { + "type": "array", + "items": { + "$ref": "#/components/schemas/BlockRelationResponse" + } + }, + "totalElements": { + "type": "integer", + "format": "int64" + }, + "page": { + "type": "integer", + "format": "int32" + }, + "size": { + "type": "integer", + "format": "int32" + } + } + }, + "requestId": { + "type": "string" + }, + "timestamp": { "type": "string", - "format": "date-time", - "nullable": true + "format": "date-time" } } }, - "PrivacySettingsUpdateRequest": { + "CreateAbilityAttemptRequest": { "type": "object", "required": [ - "resumeVisibility", - "contactVisible" + "typeCode" ], "properties": { - "resumeVisibility": { - "$ref": "#/components/schemas/ResumeVisibility", - "description": "简历可见性(必填)", - "example": "APPLIED_COMPANIES" - }, - "contactVisible": { - "type": "boolean", - "description": "联系方式是否对企业可见(必填)", - "example": true + "typeCode": { + "type": "string" }, - "version": { - "type": "integer", - "format": "int64", - "description": "乐观锁版本号(可选)", - "example": 0 + "idempotencyKey": { + "type": "string", + "maxLength": 64 } } }, - "ResumeVisibility": { - "type": "string", - "enum": [ - "PRIVATE", - "APPLIED_COMPANIES", - "VERIFIED_COMPANIES" + "SaveAbilityAnswersRequest": { + "type": "object", + "required": [ + "answers" ], - "description": "简历可见性:PRIVATE 私有;APPLIED_COMPANIES 仅对企业有效投递可见;VERIFIED_COMPANIES 仅对认证企业可见", - "example": "PRIVATE" + "properties": { + "answers": { + "type": "array", + "items": { + "$ref": "#/components/schemas/AbilityAnswerItem" + } + } + } }, - "ReviewDecisionRequest": { + "AbilityAnswerItem": { "type": "object", + "required": [ + "attemptQuestionId", + "selectedOptionId" + ], "properties": { - "decision": { - "type": "string", - "minLength": 1, - "pattern": "^(APPROVED|REJECTED)$" + "attemptQuestionId": { + "type": "integer", + "format": "int64" }, - "reason": { + "selectedOptionId": { "type": "string", - "maxLength": 500, - "minLength": 0 + "maxLength": 4 } - }, - "required": [ - "decision" - ] + } }, - "ReviewWeightResponse": { + "AbilityTypeResponse": { "type": "object", "properties": { - "source": { + "typeCode": { "type": "string" }, - "templateCode": { + "displayName": { + "type": "string" + }, + "description": { "type": "string" }, - "configuredWeights": { - "type": "object", - "additionalProperties": { - "type": "number" - } + "questionCount": { + "type": "integer", + "format": "int32" + }, + "attemptQuestionCount": { + "type": "integer", + "format": "int32" + }, + "timeLimitMinutes": { + "type": "integer", + "format": "int32" + }, + "resultValidDays": { + "type": "integer", + "format": "int32" }, - "updatedAt": { - "type": "string", - "format": "date-time" + "status": { + "type": "string" } } }, - "SaveWeightRequest": { + "AbilityAttemptResponse": { "type": "object", "properties": { - "templateCode": { + "attemptId": { + "type": "integer", + "format": "int64" + }, + "status": { "type": "string" }, - "weights": { - "type": "object", - "additionalProperties": { - "type": "number" + "answeredCount": { + "type": "integer", + "format": "int32" + }, + "totalQuestions": { + "type": "integer", + "format": "int32" + }, + "completionRate": { + "type": "number" + }, + "startedAt": { + "type": "string", + "format": "date-time", + "nullable": true + }, + "expiresAt": { + "type": "string", + "format": "date-time", + "nullable": true + }, + "remainingSeconds": { + "type": "integer", + "format": "int64", + "nullable": true + }, + "questions": { + "type": "array", + "items": { + "$ref": "#/components/schemas/AbilityAttemptQuestionView" } } } }, - "ScoreRequest": { - "type": "object", - "properties": { - "score": { - "type": "integer", - "format": "int32", - "maximum": 100, - "minimum": 0 - } - }, - "required": [ - "score" - ] - }, - "ScoreTrendResponse": { + "AbilityAttemptQuestionView": { "type": "object", "properties": { - "companyId": { + "attemptQuestionId": { "type": "integer", "format": "int64" }, - "months": { + "position": { "type": "integer", "format": "int32" }, - "items": { + "itemType": { + "type": "string" + }, + "stem": { + "type": "string" + }, + "options": { "type": "array", "items": { - "$ref": "#/components/schemas/TrendItem" + "$ref": "#/components/schemas/AbilityOptionView" } + }, + "selectedOptionId": { + "type": "string" } } }, - "SendMessageRequest": { + "AbilityOptionView": { "type": "object", "properties": { - "conversationId": { - "type": "integer", - "format": "int64" + "optionId": { + "type": "string" }, - "content": { - "type": "string", - "maxLength": 2000, - "minLength": 0 + "text": { + "type": "string" } - }, - "required": [ - "content", - "conversationId" - ] + } }, - "SnapshotHistoryItem": { + "AbilityResultResponse": { "type": "object", "properties": { - "id": { - "type": "integer", - "format": "int64" - }, - "appealId": { + "attemptId": { "type": "integer", "format": "int64" }, - "action": { + "typeCode": { "type": "string" }, - "dimension": { + "status": { "type": "string" }, - "period": { + "validityStatus": { "type": "string" }, - "beforeScore": { - "type": "number" + "validUntil": { + "type": "string", + "format": "date-time", + "nullable": true }, - "afterScore": { + "dimensionScores": { + "type": "array", + "items": { + "$ref": "#/components/schemas/AbilityDimensionScore" + } + }, + "overallScore": { "type": "number" }, - "beforeVersion": { - "type": "integer", - "format": "int64" + "weaknesses": { + "type": "array", + "items": { + "$ref": "#/components/schemas/AbilityWeakness" + } }, - "afterVersion": { + "improvementAreas": { + "type": "array", + "items": { + "$ref": "#/components/schemas/AbilityImprovementArea" + } + }, + "radar": { + "type": "array", + "items": { + "$ref": "#/components/schemas/AbilityRadarEntry" + } + }, + "answeredCount": { "type": "integer", - "format": "int64" + "format": "int32" }, - "beforeRulesVersion": { + "completionRate": { + "type": "number" + }, + "questionFeedback": { + "type": "array", + "items": { + "$ref": "#/components/schemas/AbilityQuestionFeedback" + } + }, + "questionSetVersion": { "type": "string" }, - "afterRulesVersion": { + "rulesVersion": { "type": "string" }, - "createdAt": { + "generatedAt": { "type": "string", "format": "date-time" + }, + "disclaimer": { + "type": "string" } } }, - "StudentMatchRuleRequest": { + "AbilityDimensionScore": { "type": "object", "properties": { - "requiredHrScore": { + "code": { + "type": "string" + }, + "score": { + "type": "number" + }, + "answeredCount": { "type": "integer", - "format": "int32", - "maximum": 100, - "minimum": 0 + "format": "int32" } - }, - "required": [ - "requiredHrScore" - ] + } }, - "StudentPreferenceRequest": { + "AbilityWeakness": { "type": "object", "properties": { - "expectedSalaryMin": { - "type": "integer", - "format": "int32", - "minimum": 0 - }, - "expectedSalaryMax": { - "type": "integer", - "format": "int32", - "minimum": 0 + "code": { + "type": "string" }, - "preferredCities": { - "type": "array", - "items": { - "type": "string", - "maxLength": 100, - "minLength": 1 - }, - "maxItems": 20, - "minItems": 0 + "score": { + "type": "number" + } + } + }, + "AbilityImprovementArea": { + "type": "object", + "properties": { + "dimension": { + "type": "string" }, - "employmentTypes": { - "type": "array", - "items": { - "type": "string", - "maxLength": 100, - "minLength": 1 - }, - "maxItems": 20, - "minItems": 0 + "currentScore": { + "type": "number" }, - "preferredIndustries": { - "type": "array", - "items": { - "type": "string", - "maxLength": 100, - "minLength": 1 - }, - "maxItems": 20, - "minItems": 0 + "summary": { + "type": "string" }, - "preferredJobTypes": { + "actions": { "type": "array", "items": { - "type": "string", - "maxLength": 100, - "minLength": 1 - }, - "maxItems": 20, - "minItems": 0 - }, - "remoteAllowed": { - "type": "boolean" - }, - "salaryRangeValid": { - "type": "boolean" + "type": "string" + } } } }, - "StudentProfileUpdateRequest": { + "AbilityRadarEntry": { "type": "object", "properties": { - "realName": { - "type": "string", - "maxLength": 50, - "minLength": 0 - }, - "bio": { + "code": { "type": "string" }, - "location": { - "type": "string", - "maxLength": 100, - "minLength": 0 + "value": { + "type": "number" + } + } + }, + "AbilityQuestionFeedback": { + "type": "object", + "properties": { + "attemptQuestionId": { + "type": "integer", + "format": "int64" }, - "education": { - "type": "string", - "maxLength": 100, - "minLength": 0 + "position": { + "type": "integer", + "format": "int32" }, - "school": { - "type": "string", - "maxLength": 150, - "minLength": 0 + "stem": { + "type": "string" }, - "graduationYear": { + "selectedOptionId": { + "type": "string" + }, + "awardedScore": { "type": "integer", "format": "int32" }, - "resumeUrl": { - "type": "string", - "maxLength": 500, - "minLength": 0 + "maxScore": { + "type": "integer", + "format": "int32" }, - "skills": { - "type": "array", - "items": { - "type": "string" - } + "explanation": { + "type": "string" } } }, - "SubScore": { + "AbilityHistoryItem": { "type": "object", "properties": { - "name": { + "attemptId": { + "type": "integer", + "format": "int64" + }, + "typeCode": { "type": "string" }, - "score": { + "status": { + "type": "string" + }, + "validityStatus": { + "type": "string" + }, + "overallScore": { "type": "number" }, - "weight": { + "answeredCount": { + "type": "integer", + "format": "int32" + }, + "completionRate": { "type": "number" }, - "description": { - "type": "string" + "startedAt": { + "type": "string", + "format": "date-time", + "nullable": true + }, + "expiresAt": { + "type": "string", + "format": "date-time", + "nullable": true + }, + "completedAt": { + "type": "string", + "format": "date-time", + "nullable": true + }, + "validUntil": { + "type": "string", + "format": "date-time", + "nullable": true } } }, - "TagCount": { + "ApiResponseAbilityTypeResponse": { "type": "object", "properties": { - "name": { - "type": "string" - }, - "count": { + "code": { "type": "integer", "format": "int32" + }, + "message": { + "type": "string" + }, + "data": { + "$ref": "#/components/schemas/AbilityTypeResponse" + }, + "requestId": { + "type": "string" + }, + "timestamp": { + "type": "string", + "format": "date-time" } } }, - "TrendItem": { + "ApiResponseAbilityAttemptResponse": { "type": "object", "properties": { - "period": { + "code": { + "type": "integer", + "format": "int32" + }, + "message": { "type": "string" }, - "overallScore": { - "type": "number" + "data": { + "$ref": "#/components/schemas/AbilityAttemptResponse" }, - "rulesVersion": { + "requestId": { "type": "string" }, - "dimensions": { - "type": "object", - "additionalProperties": { - "type": "number" - } + "timestamp": { + "type": "string", + "format": "date-time" } } }, - "VerificationDecisionRequest": { + "ApiResponseAbilityResultResponse": { "type": "object", "properties": { - "decision": { - "type": "string", - "minLength": 1 + "code": { + "type": "integer", + "format": "int32" }, - "reason": { + "message": { + "type": "string" + }, + "data": { + "$ref": "#/components/schemas/AbilityResultResponse" + }, + "requestId": { "type": "string" + }, + "timestamp": { + "type": "string", + "format": "date-time" } - }, - "required": [ - "decision" - ] + } }, - "VerificationSubmitRequest": { + "ApiResponsePageResponseAbilityHistoryItem": { "type": "object", "properties": { - "materialsJson": { + "code": { + "type": "integer", + "format": "int32" + }, + "message": { + "type": "string" + }, + "data": { + "type": "object", + "properties": { + "items": { + "type": "array", + "items": { + "$ref": "#/components/schemas/AbilityHistoryItem" + } + }, + "totalElements": { + "type": "integer", + "format": "int64" + }, + "page": { + "type": "integer", + "format": "int32" + }, + "size": { + "type": "integer", + "format": "int32" + } + } + }, + "requestId": { + "type": "string" + }, + "timestamp": { "type": "string", - "minLength": 1 + "format": "date-time" } - }, - "required": [ - "materialsJson" - ] + } }, - "WeightTemplateResponse": { + "RiskRuleResponse": { "type": "object", "properties": { - "code": { + "ruleCode": { "type": "string" }, - "name": { + "sourceAction": { "type": "string" }, - "description": { + "thresholdCount": { + "type": "integer", + "format": "int32" + }, + "windowMinutes": { + "type": "integer", + "format": "int32" + }, + "severity": { "type": "string" }, - "weights": { - "type": "object", - "additionalProperties": { - "type": "number" - } + "enabled": { + "type": "boolean" + }, + "version": { + "type": "integer", + "format": "int64" } } }, - "InterviewScoreSaveRequest": { + "RiskEventResponse": { "type": "object", "properties": { - "technicalScore": { + "eventId": { "type": "integer", - "minimum": 1, - "maximum": 5, - "nullable": true + "format": "int64" }, - "communicationScore": { + "ruleCode": { + "type": "string" + }, + "sourceAction": { + "type": "string" + }, + "severity": { + "type": "string" + }, + "status": { + "type": "string" + }, + "occurrenceCount": { "type": "integer", - "minimum": 1, - "maximum": 5, - "nullable": true + "format": "int32" }, - "problemSolvingScore": { + "windowStartedAt": { + "type": "string", + "format": "date-time" + }, + "windowEndedAt": { + "type": "string", + "format": "date-time" + }, + "detectedAt": { + "type": "string", + "format": "date-time" + }, + "version": { "type": "integer", - "minimum": 1, - "maximum": 5, - "nullable": true + "format": "int64" + } + } + }, + "RiskSeverityCount": { + "type": "object", + "properties": { + "severity": { + "type": "string" }, - "roleFitScore": { + "count": { "type": "integer", - "minimum": 1, - "maximum": 5, - "nullable": true + "format": "int64" + } + } + }, + "RiskRuleCount": { + "type": "object", + "properties": { + "ruleCode": { + "type": "string" }, - "professionalismScore": { + "count": { "type": "integer", - "minimum": 1, - "maximum": 5, - "nullable": true + "format": "int64" + } + } + }, + "RiskOverviewResponse": { + "type": "object", + "properties": { + "openCount": { + "type": "integer", + "format": "int64" }, - "recommendation": { - "type": "string", - "enum": [ - "STRONG_HIRE", - "HIRE", - "NEUTRAL", - "NO_HIRE" - ], - "nullable": true, - "description": "企业手动选择,不根据 overallScore 自动推导" + "bySeverity": { + "type": "array", + "items": { + "$ref": "#/components/schemas/RiskSeverityCount" + } }, - "comment": { + "byRule": { + "type": "array", + "items": { + "$ref": "#/components/schemas/RiskRuleCount" + } + }, + "lastDetectedAt": { "type": "string", + "format": "date-time", "nullable": true } - }, - "description": "评分草稿保存请求;overallScore 由服务端计算,客户端不得传入" + } + }, + "RiskTrendPoint": { + "type": "object", + "properties": { + "time": { + "type": "string", + "format": "date-time" + }, + "count": { + "type": "integer", + "format": "int64" + } + } + }, + "RiskTrendResponse": { + "type": "object", + "properties": { + "bucket": { + "type": "string" + }, + "items": { + "type": "array", + "items": { + "$ref": "#/components/schemas/RiskTrendPoint" + } + } + } }, - "InterviewScoreResponse": { + "RiskDistributionRow": { "type": "object", "properties": { - "id": { - "type": "integer", - "format": "int64" + "ruleCode": { + "type": "string" }, - "interviewId": { - "type": "integer", - "format": "int64" + "severity": { + "type": "string" }, - "applicationId": { + "count": { "type": "integer", "format": "int64" + } + } + }, + "RiskDistributionResponse": { + "type": "object", + "properties": { + "items": { + "type": "array", + "items": { + "$ref": "#/components/schemas/RiskDistributionRow" + } + } + } + }, + "RiskRuleUpdateRequest": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean" }, - "status": { - "type": "string", - "enum": [ - "DRAFT", - "SUBMITTED" - ] - }, - "technicalScore": { - "type": "integer", - "nullable": true - }, - "communicationScore": { + "thresholdCount": { "type": "integer", - "nullable": true + "format": "int32" }, - "problemSolvingScore": { + "windowMinutes": { "type": "integer", - "nullable": true + "format": "int32" }, - "roleFitScore": { - "type": "integer", - "nullable": true + "severity": { + "type": "string" }, - "professionalismScore": { + "expectedVersion": { "type": "integer", - "nullable": true - }, - "overallScore": { - "type": "number", - "format": "decimal", - "nullable": true, - "description": "由服务端 BigDecimal 计算:(t+c+ps+rf+p)/5,保留两位小数 HALF_UP" - }, - "recommendation": { - "type": "string", - "enum": [ - "STRONG_HIRE", - "HIRE", - "NEUTRAL", - "NO_HIRE" - ], - "nullable": true - }, - "comment": { - "type": "string", - "nullable": true + "format": "int64" + } + }, + "required": [ + "enabled", + "thresholdCount", + "windowMinutes", + "severity", + "expectedVersion" + ] + }, + "RiskEventActionRequest": { + "type": "object", + "properties": { + "action": { + "type": "string" }, - "submittedAt": { - "type": "string", - "format": "date-time", - "nullable": true + "reasonCode": { + "type": "string" }, - "version": { + "expectedVersion": { "type": "integer", "format": "int64" - }, - "createdAt": { - "type": "string", - "format": "date-time" - }, - "updatedAt": { - "type": "string", - "format": "date-time" } }, - "description": "面试评分响应(脱敏:不返回 companyUserId/studentUserId/evaluatorUserId/手机号/邮箱)" + "required": [ + "action", + "reasonCode", + "expectedVersion" + ] }, - "ApiResponseInterviewScoreResponse": { + "ApiResponseRiskOverviewResponse": { "type": "object", "properties": { "code": { @@ -9473,7 +12359,7 @@ "type": "string" }, "data": { - "$ref": "#/components/schemas/InterviewScoreResponse" + "$ref": "#/components/schemas/RiskOverviewResponse" }, "requestId": { "type": "string" @@ -9484,52 +12370,51 @@ } } }, - "InterviewScoreHistoryItem": { + "ApiResponseRiskTrendResponse": { "type": "object", "properties": { - "id": { + "code": { "type": "integer", - "format": "int64" + "format": "int32" }, - "scoreId": { - "type": "integer", - "format": "int64" + "message": { + "type": "string" }, - "interviewId": { - "type": "integer", - "format": "int64" + "data": { + "$ref": "#/components/schemas/RiskTrendResponse" }, - "fromStatus": { - "type": "string", - "enum": [ - "DRAFT", - "SUBMITTED" - ], - "nullable": true + "requestId": { + "type": "string" }, - "toStatus": { + "timestamp": { "type": "string", - "enum": [ - "DRAFT", - "SUBMITTED" - ] + "format": "date-time" + } + } + }, + "ApiResponseRiskDistributionResponse": { + "type": "object", + "properties": { + "code": { + "type": "integer", + "format": "int32" }, - "actorRole": { - "type": "string", - "example": "COMPANY" + "message": { + "type": "string" }, - "snapshot": { - "type": "object", - "additionalProperties": {} + "data": { + "$ref": "#/components/schemas/RiskDistributionResponse" }, - "createdAt": { + "requestId": { + "type": "string" + }, + "timestamp": { "type": "string", "format": "date-time" } - }, - "description": "评分历史条目(脱敏:不返回 actorUserId/手机号/邮箱)" + } }, - "ApiResponseListInterviewScoreHistoryItem": { + "ApiResponsePageResponseRiskEventResponse": { "type": "object", "properties": { "code": { @@ -9540,9 +12425,26 @@ "type": "string" }, "data": { - "type": "array", - "items": { - "$ref": "#/components/schemas/InterviewScoreHistoryItem" + "type": "object", + "properties": { + "items": { + "type": "array", + "items": { + "$ref": "#/components/schemas/RiskEventResponse" + } + }, + "totalElements": { + "type": "integer", + "format": "int64" + }, + "page": { + "type": "integer", + "format": "int32" + }, + "size": { + "type": "integer", + "format": "int32" + } } }, "requestId": { @@ -9554,130 +12456,75 @@ } } }, - "CreateReportRequest": { - "type": "object", - "properties": { - "targetType": { "type": "string", "enum": ["USER", "MESSAGE"], "description": "举报目标类型;USER 必填 targetUserId,MESSAGE 必填 messageId" }, - "targetUserId": { "type": "integer", "format": "int64", "description": "targetType=USER 时必填" }, - "messageId": { "type": "integer", "format": "int64", "description": "targetType=MESSAGE 时必填" }, - "category": { "type": "string", "maxLength": 40, "description": "举报分类,如 HARASSMENT" }, - "reason": { "type": "string", "minLength": 1, "maxLength": 1000 }, - "evidenceUrls": { "type": "array", "items": { "type": "string", "maxLength": 500 }, "description": "最多 5 个,仅 http/https,最多 500 字符" }, - "idempotencyKey": { "type": "string", "maxLength": 64, "description": "幂等键(可选)" } - } - }, - "ReportDecisionRequest": { - "type": "object", - "properties": { - "decision": { "type": "string", "enum": ["RESOLVED", "REJECTED"] }, - "note": { "type": "string", "maxLength": 1000, "description": "REJECTED 必填" } - } - }, - "ReportResponse": { - "type": "object", - "properties": { - "id": { "type": "integer", "format": "int64" }, - "targetType": { "type": "string", "enum": ["USER", "MESSAGE"] }, - "targetUserId": { "type": "integer", "format": "int64" }, - "messageId": { "type": "integer", "format": "int64" }, - "reportedUserId": { "type": "integer", "format": "int64" }, - "category": { "type": "string" }, - "reason": { "type": "string" }, - "evidenceUrls": { "type": "array", "items": { "type": "string" } }, - "status": { "type": "string", "enum": ["PENDING", "RESOLVED", "REJECTED"] }, - "decision": { "type": "string", "enum": ["RESOLVED", "REJECTED"] }, - "decisionNote": { "type": "string" }, - "decidedAt": { "type": "string", "format": "date-time" }, - "createdAt": { "type": "string", "format": "date-time" }, - "updatedAt": { "type": "string", "format": "date-time" }, - "messagePreview": { "type": "string", "description": "MESSAGE 举报的脱敏消息预览,不返回会话其他敏感内容" }, - "reportedUser": { "$ref": "#/components/schemas/ReportResponseReportedUser" }, - "history": { "type": "array", "items": { "$ref": "#/components/schemas/ReportResponseDecisionHistoryItem" } } - } - }, - "ReportResponseReportedUser": { - "type": "object", - "properties": { - "userId": { "type": "integer", "format": "int64" }, - "nickname": { "type": "string" }, - "role": { "type": "string" } - } - }, - "ReportResponseDecisionHistoryItem": { - "type": "object", - "properties": { - "id": { "type": "integer", "format": "int64" }, - "fromStatus": { "type": "string" }, - "toStatus": { "type": "string" }, - "actorUserId": { "type": "integer", "format": "int64" }, - "actorRole": { "type": "string" }, - "note": { "type": "string" }, - "createdAt": { "type": "string", "format": "date-time" } - } - }, - "BlockRelationResponse": { - "type": "object", - "properties": { - "id": { "type": "integer", "format": "int64" }, - "blockedUserId": { "type": "integer", "format": "int64" }, - "nickname": { "type": "string" }, - "createdAt": { "type": "string", "format": "date-time" } - } - }, - "ApiResponseReportResponse": { - "type": "object", - "properties": { - "code": { "type": "integer", "format": "int32" }, - "message": { "type": "string" }, - "data": { "$ref": "#/components/schemas/ReportResponse" }, - "requestId": { "type": "string" }, - "timestamp": { "type": "string", "format": "date-time" } - } - }, - "ApiResponsePageResponseReportResponse": { + "ApiResponseListRiskRuleResponse": { "type": "object", "properties": { - "code": { "type": "integer", "format": "int32" }, - "message": { "type": "string" }, + "code": { + "type": "integer", + "format": "int32" + }, + "message": { + "type": "string" + }, "data": { - "type": "object", - "properties": { - "items": { "type": "array", "items": { "$ref": "#/components/schemas/ReportResponse" } }, - "totalElements": { "type": "integer", "format": "int64" }, - "page": { "type": "integer", "format": "int32" }, - "size": { "type": "integer", "format": "int32" } + "$ref": { + "type": "array", + "items": { + "$ref": "#/components/schemas/RiskRuleResponse" + } } }, - "requestId": { "type": "string" }, - "timestamp": { "type": "string", "format": "date-time" } + "requestId": { + "type": "string" + }, + "timestamp": { + "type": "string", + "format": "date-time" + } } }, - "ApiResponseBlockRelationResponse": { + "ApiResponseRiskRuleResponse": { "type": "object", "properties": { - "code": { "type": "integer", "format": "int32" }, - "message": { "type": "string" }, - "data": { "$ref": "#/components/schemas/BlockRelationResponse" }, - "requestId": { "type": "string" }, - "timestamp": { "type": "string", "format": "date-time" } + "code": { + "type": "integer", + "format": "int32" + }, + "message": { + "type": "string" + }, + "data": { + "$ref": "#/components/schemas/RiskRuleResponse" + }, + "requestId": { + "type": "string" + }, + "timestamp": { + "type": "string", + "format": "date-time" + } } }, - "ApiResponsePageResponseBlockRelationResponse": { + "ApiResponseRiskEventResponse": { "type": "object", "properties": { - "code": { "type": "integer", "format": "int32" }, - "message": { "type": "string" }, + "code": { + "type": "integer", + "format": "int32" + }, + "message": { + "type": "string" + }, "data": { - "type": "object", - "properties": { - "items": { "type": "array", "items": { "$ref": "#/components/schemas/BlockRelationResponse" } }, - "totalElements": { "type": "integer", "format": "int64" }, - "page": { "type": "integer", "format": "int32" }, - "size": { "type": "integer", "format": "int32" } - } + "$ref": "#/components/schemas/RiskEventResponse" + }, + "requestId": { + "type": "string" }, - "requestId": { "type": "string" }, - "timestamp": { "type": "string", "format": "date-time" } + "timestamp": { + "type": "string", + "format": "date-time" + } } } }, diff --git a/docs/riasec-assessment-api.md b/docs/riasec-assessment-api.md new file mode 100644 index 0000000..c7cf79a --- /dev/null +++ b/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 下均兼容)。 \ No newline at end of file diff --git a/docs/risk-governance-api.md b/docs/risk-governance-api.md new file mode 100644 index 0000000..151038e --- /dev/null +++ b/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 错误码。 diff --git a/docs/validate_ability_bank.py b/docs/validate_ability_bank.py new file mode 100644 index 0000000..9b8ae59 --- /dev/null +++ b/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() \ No newline at end of file diff --git a/docs/verify_openapi.py b/docs/verify_openapi.py index 7470085..dd11e11 100644 --- a/docs/verify_openapi.py +++ b/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)") diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/ability/AbilityAnswer.java b/mydreamjob-server/src/main/java/com/mydreamjob/ability/AbilityAnswer.java new file mode 100644 index 0000000..631a587 --- /dev/null +++ b/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; +} \ No newline at end of file diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/ability/AbilityAnswerRepository.java b/mydreamjob-server/src/main/java/com/mydreamjob/ability/AbilityAnswerRepository.java new file mode 100644 index 0000000..e84c21a --- /dev/null +++ b/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 { + + List findByAttemptId(Long attemptId); + + List findByAttemptIdAndAttemptQuestionIdIn(Long attemptId, List attemptQuestionIds); + + long countByAttemptId(Long attemptId); +} \ No newline at end of file diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/ability/AbilityAttempt.java b/mydreamjob-server/src/main/java/com/mydreamjob/ability/AbilityAttempt.java new file mode 100644 index 0000000..2f17574 --- /dev/null +++ b/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; +} \ No newline at end of file diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/ability/AbilityAttemptQuestion.java b/mydreamjob-server/src/main/java/com/mydreamjob/ability/AbilityAttemptQuestion.java new file mode 100644 index 0000000..a65c92c --- /dev/null +++ b/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; +} \ No newline at end of file diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/ability/AbilityAttemptQuestionRepository.java b/mydreamjob-server/src/main/java/com/mydreamjob/ability/AbilityAttemptQuestionRepository.java new file mode 100644 index 0000000..bf2963a --- /dev/null +++ b/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 { + + List findByAttemptIdOrderByPositionAsc(Long attemptId); + + boolean existsByAttemptIdAndQuestionId(Long attemptId, Long questionId); + + long countByAttemptId(Long attemptId); +} \ No newline at end of file diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/ability/AbilityAttemptRepository.java b/mydreamjob-server/src/main/java/com/mydreamjob/ability/AbilityAttemptRepository.java new file mode 100644 index 0000000..8b019ed --- /dev/null +++ b/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 { + + Optional findByStudentUserIdAndIdempotencyKey(Long studentUserId, String idempotencyKey); + + boolean existsByStudentUserIdAndDefinitionIdAndStatusIn(Long studentUserId, Long definitionId, List 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 findByStudentUserIdAndDefinitionIdAndStatusOrderByCompletedAtDesc( + Long studentUserId, Long definitionId, String status); + + List findByStudentUserIdAndDefinitionIdOrderByCompletedAtDesc( + Long studentUserId, Long definitionId); + + org.springframework.data.domain.Page 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 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 findTimedOutActiveByUserAndDef(@Param("userId") Long userId, + @Param("defId") Long defId, + @Param("now") LocalDateTime now); +} \ No newline at end of file diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/ability/AbilityAttemptService.java b/mydreamjob-server/src/main/java/com/mydreamjob/ability/AbilityAttemptService.java new file mode 100644 index 0000000..695f868 --- /dev/null +++ b/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 all = questionRepository + .findByDefinitionIdAndActiveTrueOrderByPositionAsc(def.getId()); + List 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 aqs = attemptQuestionRepository + .findByAttemptIdOrderByPositionAsc(attemptId); + Map 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 questionIds = req.getAnswers().stream() + .map(SaveAbilityAnswersRequest.AnswerItem::getAttemptQuestionId).toList(); + Map existingByAqId = answerRepository + .findByAttemptIdAndAttemptQuestionIdIn(attemptId, questionIds).stream() + .collect(Collectors.toMap(AbilityAnswer::getAttemptQuestionId, Function.identity())); + + List 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 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 attempts = attemptRepository + .findByStudentUserIdOrderByCreatedAtDesc(userId, PageRequest.of(page, size)); + List content = attempts.getContent(); + Map resultByAttempt = resultRepository + .findByAttemptIdIn(content.stream().map(AbilityAttempt::getId).toList()).stream() + .collect(Collectors.toMap(AbilityResult::getAttemptId, Function.identity())); + // 对当前页 definitionId 去重后批量加载,避免对每条历史逐个 findById(消除 N+1) + List defIds = content.stream().map(AbilityAttempt::getDefinitionId).distinct().toList(); + Map 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 items = new ArrayList<>(); + List aqs = attemptQuestionRepository + .findByAttemptIdOrderByPositionAsc(attempt.getId()); + Map 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 aqs = + attemptQuestionRepository.findByAttemptIdOrderByPositionAsc(attemptId); + if (aqs.isEmpty()) { + return false; + } + Set answeredAqIds = answerRepository.findByAttemptId(attemptId).stream() + .map(AbilityAnswer::getAttemptQuestionId).collect(Collectors.toSet()); + Map 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 aqs = attemptQuestionRepository + .findByAttemptIdOrderByPositionAsc(attempt.getId()); + Map 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 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 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 sample(List 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>> 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 picked = new ArrayList<>(); + for (String dim : DIMENSIONS) { + Map> tags = byDimTag.get(dim); + if (tags == null || tags.size() < 3) { + throw new BusinessException(ErrorCode.BAD_REQUEST, + "insufficient competency tags in dimension " + dim); + } + List dimPicked = new ArrayList<>(); + for (Map.Entry> e : tags.entrySet()) { + List 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); + } +} \ No newline at end of file diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/ability/AbilityAttemptWriter.java b/mydreamjob-server/src/main/java/com/mydreamjob/ability/AbilityAttemptWriter.java new file mode 100644 index 0000000..9d3a141 --- /dev/null +++ b/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 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; + } +} \ No newline at end of file diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/ability/AbilityCatalogService.java b/mydreamjob-server/src/main/java/com/mydreamjob/ability/AbilityCatalogService.java new file mode 100644 index 0000000..c17c8e0 --- /dev/null +++ b/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 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(); + } +} \ No newline at end of file diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/ability/AbilityController.java b/mydreamjob-server/src/main/java/com/mydreamjob/ability/AbilityController.java new file mode 100644 index 0000000..d69d9e7 --- /dev/null +++ b/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)。 + * + *

身份仅取自 JWT。测评类型对任意已登录角色开放;其余接口仅 STUDENT 且仅限本人。 + * 不回读完整题库:前端仅通过创建/恢复本人答卷获得本次 24 道题。

+ */ +@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> 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 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 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 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 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 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> listMine( + @RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "20") int size, + HttpServletRequest request) { + Page 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"); + } +} \ No newline at end of file diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/ability/AbilityDefinition.java b/mydreamjob-server/src/main/java/com/mydreamjob/ability/AbilityDefinition.java new file mode 100644 index 0000000..ff49d95 --- /dev/null +++ b/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; +} \ No newline at end of file diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/ability/AbilityDefinitionRepository.java b/mydreamjob-server/src/main/java/com/mydreamjob/ability/AbilityDefinitionRepository.java new file mode 100644 index 0000000..0c06848 --- /dev/null +++ b/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 { + + Optional findByTypeCode(String typeCode); + + List findAllByStatusOrderByCreatedAtAsc(String status); +} \ No newline at end of file diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/ability/AbilityImprovementCatalog.java b/mydreamjob-server/src/main/java/com/mydreamjob/ability/AbilityImprovementCatalog.java new file mode 100644 index 0000000..2a1ffed --- /dev/null +++ b/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 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 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 -> "结合情境制定对应能力维度的练习与复盘计划。"; + }; + } +} \ No newline at end of file diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/ability/AbilityQuestion.java b/mydreamjob-server/src/main/java/com/mydreamjob/ability/AbilityQuestion.java new file mode 100644 index 0000000..2b51729 --- /dev/null +++ b/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; +} \ No newline at end of file diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/ability/AbilityQuestionRepository.java b/mydreamjob-server/src/main/java/com/mydreamjob/ability/AbilityQuestionRepository.java new file mode 100644 index 0000000..818feb4 --- /dev/null +++ b/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 { + + List findByDefinitionIdAndActiveTrueOrderByPositionAsc(Long definitionId); + + List findAllByDefinitionIdOrderByPositionAsc(Long definitionId); +} \ No newline at end of file diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/ability/AbilityResult.java b/mydreamjob-server/src/main/java/com/mydreamjob/ability/AbilityResult.java new file mode 100644 index 0000000..76daaa4 --- /dev/null +++ b/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; +} \ No newline at end of file diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/ability/AbilityResultComputer.java b/mydreamjob-server/src/main/java/com/mydreamjob/ability/AbilityResultComputer.java new file mode 100644 index 0000000..05d4e07 --- /dev/null +++ b/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)。 + * + *

每题最优 4 / 次优 2 / 一般 1 / 高风险 0;每维 6 题满分 24, + * dimensionScore = rawScore / 24 × 100;overallScore = 四维平均(须四维齐全)。 + * 满分与单维题数由定义给出,但默认 4 / 6 保持一致。

+ */ +@Component +public class AbilityResultComputer { + + public static final int MAX_SCORE_PER_QUESTION = 4; + public static final List 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 dimensionScores, + BigDecimal overallScore, + List weaknesses, + List improvementAreas, + List radar, + List questionFeedback) { + } + + public ComputedResult compute(List items, int perDimQuestions) { + List dims = new ArrayList<>(); + Map> byDim = new LinkedHashMap<>(); + for (String d : DIMENSIONS) { + byDim.put(d, items.stream().filter(it -> d.equals(it.dimension())).toList()); + } + Map rawByDim = new LinkedHashMap<>(); + for (Map.Entry> 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 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 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 radar = new ArrayList<>(); + for (AbilityResultResponse.DimensionScore ds : dims) { + radar.add(AbilityResultResponse.RadarEntry.builder() + .code(ds.getCode()).value(ds.getScore()).build()); + } + + // 每题解析(仅暴露本题解析文本,不返回答案键与完整评分规则) + List 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 items) { + Map raw = new LinkedHashMap<>(); + for (Answered a : items) { + raw.merge(a.competencyTag(), a.awardedScore(), Integer::sum); + } + return raw.entrySet().stream() + .min(Comparator.>comparingInt(Map.Entry::getValue) + .thenComparing(Map.Entry::getKey)) + .map(Map.Entry::getKey).orElse(null); + } + + private BigDecimal competencyScore(List items, String tag) { + List 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); + } +} \ No newline at end of file diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/ability/AbilityResultRepository.java b/mydreamjob-server/src/main/java/com/mydreamjob/ability/AbilityResultRepository.java new file mode 100644 index 0000000..aefffeb --- /dev/null +++ b/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 { + + Optional findByAttemptId(Long attemptId); + + List findByAttemptIdIn(Collection attemptIds); +} \ No newline at end of file diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/ability/AbilityResultService.java b/mydreamjob-server/src/main/java/com/mydreamjob/ability/AbilityResultService.java new file mode 100644 index 0000000..b23e3b7 --- /dev/null +++ b/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> DIM_LIST = + new TypeReference<>() { + }; + private static final TypeReference> WEAK_LIST = + new TypeReference<>() { + }; + private static final TypeReference> AREA_LIST = + new TypeReference<>() { + }; + private static final TypeReference> 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 dims = read(result.getDimensionScoresJson(), DIM_LIST); + List 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 List read(String json, TypeReference> 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); + } + } +} \ No newline at end of file diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/ability/AbilitySnapshotCodec.java b/mydreamjob-server/src/main/java/com/mydreamjob/ability/AbilitySnapshotCodec.java new file mode 100644 index 0000000..ea98068 --- /dev/null +++ b/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 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; + } +} \ No newline at end of file diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/ability/dto/AbilityAttemptResponse.java b/mydreamjob-server/src/main/java/com/mydreamjob/ability/dto/AbilityAttemptResponse.java new file mode 100644 index 0000000..b9c1b5f --- /dev/null +++ b/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 questions; + + @Data + @Builder + public static class AbilityAttemptQuestionView { + private Long attemptQuestionId; + private Integer position; + private String itemType; + private String stem; + private List options; + private String selectedOptionId; + } + + @Data + @Builder + public static class AbilityOptionView { + private String optionId; + private String text; + } +} \ No newline at end of file diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/ability/dto/AbilityHistoryItem.java b/mydreamjob-server/src/main/java/com/mydreamjob/ability/dto/AbilityHistoryItem.java new file mode 100644 index 0000000..f0f182b --- /dev/null +++ b/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; +} \ No newline at end of file diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/ability/dto/AbilityResultResponse.java b/mydreamjob-server/src/main/java/com/mydreamjob/ability/dto/AbilityResultResponse.java new file mode 100644 index 0000000..ddb05b5 --- /dev/null +++ b/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 dimensionScores; + private BigDecimal overallScore; + private List weaknesses; + private List improvementAreas; + private List radar; + private Integer answeredCount; + private BigDecimal completionRate; + private List 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 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; + } +} \ No newline at end of file diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/ability/dto/AbilityTypeResponse.java b/mydreamjob-server/src/main/java/com/mydreamjob/ability/dto/AbilityTypeResponse.java new file mode 100644 index 0000000..927d121 --- /dev/null +++ b/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; +} \ No newline at end of file diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/ability/dto/CreateAbilityAttemptRequest.java b/mydreamjob-server/src/main/java/com/mydreamjob/ability/dto/CreateAbilityAttemptRequest.java new file mode 100644 index 0000000..3ccc315 --- /dev/null +++ b/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; +} \ No newline at end of file diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/ability/dto/SaveAbilityAnswersRequest.java b/mydreamjob-server/src/main/java/com/mydreamjob/ability/dto/SaveAbilityAnswersRequest.java new file mode 100644 index 0000000..2a8f45a --- /dev/null +++ b/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 answers; + + @Data + public static class AnswerItem { + private Long attemptQuestionId; + private String selectedOptionId; + } +} \ No newline at end of file diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/assessment/AssessmentAnswer.java b/mydreamjob-server/src/main/java/com/mydreamjob/assessment/AssessmentAnswer.java new file mode 100644 index 0000000..7eff122 --- /dev/null +++ b/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)。 + * + *

value 使用 1-5 Likert 量表(1=完全不感兴趣 … 5=非常感兴趣)。同 (attempt, question) 唯一,重复保存走 upsert。

+ */ +@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; +} \ No newline at end of file diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/assessment/AssessmentAnswerRepository.java b/mydreamjob-server/src/main/java/com/mydreamjob/assessment/AssessmentAnswerRepository.java new file mode 100644 index 0000000..de78e4f --- /dev/null +++ b/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 { + Optional findByAttemptIdAndQuestionId(Long attemptId, Long questionId); + List findByAttemptId(Long attemptId); + long countByAttemptId(Long attemptId); + void deleteByAttemptId(Long attemptId); +} \ No newline at end of file diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/assessment/AssessmentAttempt.java b/mydreamjob-server/src/main/java/com/mydreamjob/assessment/AssessmentAttempt.java new file mode 100644 index 0000000..4ecd8c6 --- /dev/null +++ b/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)。 + * + *

状态流转:DRAFT → IN_PROGRESS → COMPLETED。{@code samplingSeed} 用于抽题随机种子, + * 持久化但不下发前端。{@code idempotencyKey} 与 {@code studentUserId} 联合唯一实现创建幂等。

+ */ +@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; +} \ No newline at end of file diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/assessment/AssessmentAttemptQuestion.java b/mydreamjob-server/src/main/java/com/mydreamjob/assessment/AssessmentAttemptQuestion.java new file mode 100644 index 0000000..d807415 --- /dev/null +++ b/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)。 + * + *

答卷创建时一次性写入 30 道,顺序(position)不可再变,刷新页面不重新抽题。

+ */ +@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; +} \ No newline at end of file diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/assessment/AssessmentAttemptQuestionRepository.java b/mydreamjob-server/src/main/java/com/mydreamjob/assessment/AssessmentAttemptQuestionRepository.java new file mode 100644 index 0000000..d5068e1 --- /dev/null +++ b/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 { + List findByAttemptIdOrderByPositionAsc(Long attemptId); + boolean existsByAttemptIdAndQuestionId(Long attemptId, Long questionId); +} \ No newline at end of file diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/assessment/AssessmentAttemptRepository.java b/mydreamjob-server/src/main/java/com/mydreamjob/assessment/AssessmentAttemptRepository.java new file mode 100644 index 0000000..1b84523 --- /dev/null +++ b/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 { + + Optional findByStudentUserIdAndIdempotencyKey(Long studentUserId, String idempotencyKey); + + boolean existsByStudentUserIdAndDefinitionIdAndStatusIn( + Long studentUserId, Long definitionId, Collection statuses); + + Page 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); +} \ No newline at end of file diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/assessment/AssessmentAttemptService.java b/mydreamjob-server/src/main/java/com/mydreamjob/assessment/AssessmentAttemptService.java new file mode 100644 index 0000000..f41e68d --- /dev/null +++ b/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)。 + * + *

创建答卷时按六维各随机抽 5 题、整体打乱后持久化,刷新页面不重抽;保存答案走 upsert 且受 + * 乐观锁保护(9707);提交仅允许 30 题全答(9706)、仅 DRAFT/IN_PROGRESS(9703)、 + * 重复提交幂等返回原结果、不重复生成结果与审计。身份仅取自 JWT,仅 STUDENT 可操作。

+ */ +@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 不被污染。 + * + *

此处显式使用 {@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 active = questionRepository + .findByDefinitionIdAndActiveTrueOrderByPositionAsc(def.getId()); + List 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 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 attempts = attemptRepository + .findByStudentUserIdOrderByCreatedAtDesc(userId, PageRequest.of(page, size)); + + List content = attempts.getContent(); + Map attemptById = content.stream() + .collect(Collectors.toMap(AssessmentAttempt::getId, Function.identity())); + Map resultByAttempt = resultRepository + .findByAttemptIdIn(attemptById.keySet()).stream() + .collect(Collectors.toMap(AssessmentResult::getAttemptId, Function.identity())); + Map 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。 + * + *

严格约束:任一维度 ACTIVE 题不足 perDim 时直接拒绝创建(不静默减少题目数); + * 校验 attemptCount == 6 × perDim 且六维齐全,保证总数严格为 30。

+ */ + private List sample(List 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> 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 picked = new ArrayList<>(attemptCount); + for (String dim : List.of("R", "I", "A", "S", "E", "C")) { + List 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 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 aqs = attemptQuestionRepository + .findByAttemptIdOrderByPositionAsc(attempt.getId()); + Map 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 answerByQuestion = answerRepository.findByAttemptId(attempt.getId()) + .stream().collect(Collectors.toMap(AssessmentAnswer::getQuestionId, Function.identity())); + + List 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 aqs = attemptQuestionRepository + .findByAttemptIdOrderByPositionAsc(attempt.getId()); + Map values = answerRepository.findByAttemptId(attempt.getId()) + .stream().collect(Collectors.toMap(AssessmentAnswer::getQuestionId, AssessmentAnswer::getValue)); + + Map 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 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()); + } +} \ No newline at end of file diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/assessment/AssessmentAttemptWriter.java b/mydreamjob-server/src/main/java/com/mydreamjob/assessment/AssessmentAttemptWriter.java new file mode 100644 index 0000000..59b1965 --- /dev/null +++ b/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)。 + * + *

将「新建 attempt + 30 条 attempt_questions」放到独立的 {@code REQUIRES_NEW} 事务中执行: + * 当并发创建某一份 attempt 触发唯一键冲突(active_key / idempotency_key)时,冲突发生在 + * 新开的事务内、由其自身回滚,外层事务的 Hibernate Session 不会被污染的 flush 破坏, + * 从而避免 {@code Hibernate AssertionFailure} 与 HTTP 500。失败后外层可安全使用新事务查询幂等记录。

+ */ +@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 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; + } +} \ No newline at end of file diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/assessment/AssessmentCatalogService.java b/mydreamjob-server/src/main/java/com/mydreamjob/assessment/AssessmentCatalogService.java new file mode 100644 index 0000000..a7f90c4 --- /dev/null +++ b/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)。 + * + *

仅提供测评类型与题目查询,任何已登录用户可访问。题目响应隐藏 + * dimension、reverseScored、source、questionVersion。

+ */ +@Service +@RequiredArgsConstructor +public class AssessmentCatalogService { + + private final AssessmentDefinitionRepository definitionRepository; + private final AssessmentQuestionRepository questionRepository; + + @Transactional(readOnly = true) + public List listTypes() { + return definitionRepository.findAll().stream() + .filter(d -> AssessmentDefinition.STATUS_ACTIVE.equals(d.getStatus())) + .map(this::toTypeResponse) + .toList(); + } + + @Transactional(readOnly = true) + public List 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(); + } +} \ No newline at end of file diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/assessment/AssessmentController.java b/mydreamjob-server/src/main/java/com/mydreamjob/assessment/AssessmentController.java new file mode 100644 index 0000000..f398e3b --- /dev/null +++ b/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)。 + * + *

身份仅取自 JWT,不接受 userId 参数。类型与题目对任意已登录用户开放; + * 答卷创建/详情/保存/提交/结果/历史仅 STUDENT 且仅限本人。COMPANY/ADMIN 操作答卷返回 403。

+ */ +@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> 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> 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 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 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 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 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 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> listMine( + @RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "20") int size, + HttpServletRequest request) { + Page 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"); + } +} \ No newline at end of file diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/assessment/AssessmentDefinition.java b/mydreamjob-server/src/main/java/com/mydreamjob/assessment/AssessmentDefinition.java new file mode 100644 index 0000000..8a1f0fd --- /dev/null +++ b/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)。 + * + *

{@code questionCount} 为题库总题数(72),{@code attemptQuestionCount} 为每次答卷抽题数(30), + * {@code minPerDimension} 为每个维度最少抽题数(5)。

+ */ +@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; +} \ No newline at end of file diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/assessment/AssessmentDefinitionRepository.java b/mydreamjob-server/src/main/java/com/mydreamjob/assessment/AssessmentDefinitionRepository.java new file mode 100644 index 0000000..bacbf42 --- /dev/null +++ b/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 { + Optional findByTypeCode(String typeCode); +} \ No newline at end of file diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/assessment/AssessmentQuestion.java b/mydreamjob-server/src/main/java/com/mydreamjob/assessment/AssessmentQuestion.java new file mode 100644 index 0000000..7aaaf11 --- /dev/null +++ b/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)。 + * + *

所有题目仅测量职业兴趣,不测量能力,无正确答案,{@code reverseScored} 恒为 false, + * 来源 {@code SELF_AUTHORED}。

+ */ +@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; +} \ No newline at end of file diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/assessment/AssessmentQuestionRepository.java b/mydreamjob-server/src/main/java/com/mydreamjob/assessment/AssessmentQuestionRepository.java new file mode 100644 index 0000000..a05aeb5 --- /dev/null +++ b/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 { + List findByDefinitionIdAndActiveTrueOrderByPositionAsc(Long definitionId); +} \ No newline at end of file diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/assessment/AssessmentResult.java b/mydreamjob-server/src/main/java/com/mydreamjob/assessment/AssessmentResult.java new file mode 100644 index 0000000..e5b9ef1 --- /dev/null +++ b/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)。 + * + *

仅 RIASEC 六维聚合分,不存储原始答案。{@code dimensionScoresText} / {@code typeRankingText} + * 以 TEXT 保存 JSON 字符串,保持 MySQL 8 与 H2 MODE=MySQL 兼容。

+ */ +@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; +} \ No newline at end of file diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/assessment/AssessmentResultComputer.java b/mydreamjob-server/src/main/java/com/mydreamjob/assessment/AssessmentResultComputer.java new file mode 100644 index 0000000..7aa0a9f --- /dev/null +++ b/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)。 + * + *

每个维度 5 道题:rawScore = 5 道题之和,maxScore = 25, + * percentage = rawScore / 25 × 100(BigDecimal, HALF_UP, scale=2)。 + * 六维按 percentage 降序排序,同分按维度字母升序;前三位组成 primaryCode。

+ */ +@Component +public class AssessmentResultComputer { + + /** 六个维度的规范顺序,用于分数结果的有序输出 */ + public static final List 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 dimensionScores, + List typeRanking, + String primaryCode, + String primaryType) {} + + /** + * 计算六维聚合分、排序与 primary code。 + * + * @param answers 已答的 (维度, 值) 列表 + * @param questionsPerDimension 每个维度抽题数(5) + * @param maxScorePerQuestion maxScorePerQuestion 每题最高分(5) + */ + public ComputedResult compute(List answers, + int questionsPerDimension, int maxScorePerQuestion) { + int maxScore = questionsPerDimension * maxScorePerQuestion; + + // 分维度求和 + Map rawScores = new HashMap<>(); + Map 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 percentages = new LinkedHashMap<>(); + List 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 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); + } +} \ No newline at end of file diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/assessment/AssessmentResultRepository.java b/mydreamjob-server/src/main/java/com/mydreamjob/assessment/AssessmentResultRepository.java new file mode 100644 index 0000000..8c9c7af --- /dev/null +++ b/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 { + Optional findByAttemptId(Long attemptId); + boolean existsByAttemptId(Long attemptId); + List findByAttemptIdIn(Collection attemptIds); +} \ No newline at end of file diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/assessment/AssessmentResultService.java b/mydreamjob-server/src/main/java/com/mydreamjob/assessment/AssessmentResultService.java new file mode 100644 index 0000000..847f24e --- /dev/null +++ b/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)。 + * + *

负责将计分结果持久化(dimension/ranking 以 TEXT JSON 保存)与构建对外结果响应。 + * 结果响应不返回 studentUserId、phone、email、password、原始答案及他人信息。

+ */ +@Service +@RequiredArgsConstructor +public class AssessmentResultService { + + public static final String DISCLAIMER = "本结果仅供职业兴趣探索参考,不构成心理或职业诊断。"; + + private static final TypeReference> DIM_SCORES_TYPE = new TypeReference<>() {}; + private static final TypeReference> 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 fromJson(String json, TypeReference type) { + if (json == null || json.isBlank()) { + return null; + } + try { + return objectMapper.readValue(json, type); + } catch (JsonProcessingException e) { + return null; + } + } +} \ No newline at end of file diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/assessment/CareerDirectionCatalog.java b/mydreamjob-server/src/main/java/com/mydreamjob/assessment/CareerDirectionCatalog.java new file mode 100644 index 0000000..cea4755 --- /dev/null +++ b/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)。 + * + *

仅供职业兴趣探索参考,不使用"适合职业/精准匹配/最适合职业/职业诊断结果"等表述。 + * 返回文案统一为"可能感兴趣的职业探索方向"。

+ */ +@Component +public class CareerDirectionCatalog { + + /** 已配置的三字母组合 → 探索方向(精确优先) */ + private final Map> curated = new LinkedHashMap<>(); + + /** 单维度兜底方向(未配置的三字母组合按前三个维度分别返回一条兜底) */ + private final Map 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 resolveDirections(String primaryCode) { + if (primaryCode == null || primaryCode.isBlank()) { + return List.of(); + } + String key = primaryCode.toUpperCase(); + if (curated.containsKey(key)) { + return curated.get(key); + } + List fallback = new ArrayList<>(); + for (char c : key.toCharArray()) { + String dim = String.valueOf(c); + if (dimensionDefault.containsKey(dim)) { + fallback.add(dimensionDefault.get(dim)); + } + } + return fallback; + } +} \ No newline at end of file diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/assessment/dto/AssessmentAttemptResponse.java b/mydreamjob-server/src/main/java/com/mydreamjob/assessment/dto/AssessmentAttemptResponse.java new file mode 100644 index 0000000..28641db --- /dev/null +++ b/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)。 + * + *

包含本次抽中的 30 道题(固定顺序)及已保存答案。不含 samplingSeed、studentUserId 等内部字段。

+ */ +@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 questions; +} \ No newline at end of file diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/assessment/dto/AssessmentHistoryItem.java b/mydreamjob-server/src/main/java/com/mydreamjob/assessment/dto/AssessmentHistoryItem.java new file mode 100644 index 0000000..155696c --- /dev/null +++ b/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; +} \ No newline at end of file diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/assessment/dto/AssessmentQuestionResponse.java b/mydreamjob-server/src/main/java/com/mydreamjob/assessment/dto/AssessmentQuestionResponse.java new file mode 100644 index 0000000..6e7b162 --- /dev/null +++ b/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)。 + * + *

对外隐藏 dimension、reverseScored、source、questionVersion、questionCode —— + * 不暴露任何可推导 R/I/A/S/E/C 维度的内部编码。{@code value} 仅在答卷上下文中 + * 填充当前已保存答案(1-5),题目目录列表场景为 null。

+ */ +@Data +@Builder +public class AssessmentQuestionResponse { + + private Long id; + private Integer position; + private String stem; + private String scaleType; + private Integer value; +} \ No newline at end of file diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/assessment/dto/AssessmentResultResponse.java b/mydreamjob-server/src/main/java/com/mydreamjob/assessment/dto/AssessmentResultResponse.java new file mode 100644 index 0000000..66da335 --- /dev/null +++ b/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)。 + * + *

仅返回聚合分与探索建议,不暴露 studentUserId、phone、email、password、原始答案及他人信息。

+ */ +@Data +@Builder +public class AssessmentResultResponse { + + private Long attemptId; + private String typeCode; + private String status; + private String primaryCode; + private String primaryType; + private List dimensionScores; + private List typeRanking; + private Integer answeredCount; + private BigDecimal completionRate; + private String questionSetVersion; + private String rulesVersion; + private String generatedAt; + private String disclaimer; + private List explorationDirections; + + @Data + @Builder + public static class DimensionScore { + private String dimension; + private BigDecimal score; + private Integer answeredCount; + } +} \ No newline at end of file diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/assessment/dto/AssessmentTypeResponse.java b/mydreamjob-server/src/main/java/com/mydreamjob/assessment/dto/AssessmentTypeResponse.java new file mode 100644 index 0000000..a527f00 --- /dev/null +++ b/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; +} \ No newline at end of file diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/assessment/dto/CreateAssessmentAttemptRequest.java b/mydreamjob-server/src/main/java/com/mydreamjob/assessment/dto/CreateAssessmentAttemptRequest.java new file mode 100644 index 0000000..c150726 --- /dev/null +++ b/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; +} \ No newline at end of file diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/assessment/dto/SaveAssessmentAnswersRequest.java b/mydreamjob-server/src/main/java/com/mydreamjob/assessment/dto/SaveAssessmentAnswersRequest.java new file mode 100644 index 0000000..1b69a40 --- /dev/null +++ b/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)。 + * + *

支持单题或批量;同一题重复保存执行 upsert。{@code version} 为乐观锁版本,用于并发冲突检测(9707)。

+ */ +@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 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; + } +} \ No newline at end of file diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/audit/AuditLogRepository.java b/mydreamjob-server/src/main/java/com/mydreamjob/audit/AuditLogRepository.java index 8ec9d74..c2a37c8 100644 --- a/mydreamjob-server/src/main/java/com/mydreamjob/audit/AuditLogRepository.java +++ b/mydreamjob-server/src/main/java/com/mydreamjob/audit/AuditLogRepository.java @@ -19,4 +19,11 @@ public interface AuditLogRepository extends JpaRepository { List 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 findByIdAfterOrderByIdAsc(@Param("id") Long id, Pageable pageable); + + long countByUserIdAndActionAndCreatedAtBetween(Long userId, String action, + LocalDateTime from, LocalDateTime to); } diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/common/exception/ErrorCode.java b/mydreamjob-server/src/main/java/com/mydreamjob/common/exception/ErrorCode.java index 726014d..b2dcf1b 100644 --- a/mydreamjob-server/src/main/java/com/mydreamjob/common/exception/ErrorCode.java +++ b/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; diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/common/exception/GlobalExceptionHandler.java b/mydreamjob-server/src/main/java/com/mydreamjob/common/exception/GlobalExceptionHandler.java index af6a7c5..2f3a862 100644 --- a/mydreamjob-server/src/main/java/com/mydreamjob/common/exception/GlobalExceptionHandler.java +++ b/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; } diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/common/time/TimeConfig.java b/mydreamjob-server/src/main/java/com/mydreamjob/common/time/TimeConfig.java new file mode 100644 index 0000000..25ad109 --- /dev/null +++ b/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(); + } +} \ No newline at end of file diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/config/SecurityConfig.java b/mydreamjob-server/src/main/java/com/mydreamjob/config/SecurityConfig.java index f183bb5..0f50257 100644 --- a/mydreamjob-server/src/main/java/com/mydreamjob/config/SecurityConfig.java +++ b/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() diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/risk/AdminRiskController.java b/mydreamjob-server/src/main/java/com/mydreamjob/risk/AdminRiskController.java new file mode 100644 index 0000000..02e7ab3 --- /dev/null +++ b/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。 + * + *

身份一律 JWT/SecurityContext,不接受 adminUserId/operatorUserId/userId 参数。 + * 风险事件响应为字段白名单,严禁返回 subjectUserId/phone/email/IP/userAgent/details。

+ */ +@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> 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> 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> 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>> 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 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>> 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> 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> 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); + } + } +} \ No newline at end of file diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/risk/RiskEventDetector.java b/mydreamjob-server/src/main/java/com/mydreamjob/risk/RiskEventDetector.java new file mode 100644 index 0000000..1211f3a --- /dev/null +++ b/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)。 + * + *

{@code scanOnce()} 为可直接单测的方法;{@code @Scheduled} 只调用它。扫描基于既有 + * {@code audit_logs},仅消费 {@code REPORT_RATE_LIMITED}/{@code BLOCKED_SEND_PROHIBITED} 两个 + * 白名单 action,且 user_id 非空。整批成功后才推进 checkpoint;任一处理失败整体回滚。

+ * + *

去刷屏:仅当窗口内命中数恰等于 thresholdCount 时创建事件;阈值之后同窗口的更多命中不再 + * 创建新事件。事件插入受唯一键 {@code (rule_config_id, source_audit_log_id)} 与 existsBy 防重保护。

+ */ +@Slf4j +@Component +@RequiredArgsConstructor +public class RiskEventDetector { + + public static final int BATCH_SIZE = 500; + public static final Set 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 batch = auditLogRepository + .findByIdAfterOrderByIdAsc(lastId, PageRequest.of(0, BATCH_SIZE)); + if (batch.isEmpty()) { + return 0; + } + + Map 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() + "}"; + } +} \ No newline at end of file diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/risk/RiskEventScanScheduler.java b/mydreamjob-server/src/main/java/com/mydreamjob/risk/RiskEventScanScheduler.java new file mode 100644 index 0000000..7eea692 --- /dev/null +++ b/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); + } + } +} \ No newline at end of file diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/risk/RiskGovernanceService.java b/mydreamjob-server/src/main/java/com/mydreamjob/risk/RiskGovernanceService.java new file mode 100644 index 0000000..7476410 --- /dev/null +++ b/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。 + * + *

概览/趋势/分布/事件全部使用 SQL GROUP BY 或 JOIN FETCH 投影,消除 N+1;事件与规则响应 + * 均为字段白名单,绝不返回 subjectUserId/phone/email/IP/userAgent/details 等敏感字段。

+ */ +@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 SEVERITIES = Set.of("LOW", "MEDIUM", "HIGH"); + private static final Set STATUSES = Set.of("OPEN", "ACKNOWLEDGED", "DISMISSED"); + private static final Set BUCKETS = Set.of("HOUR", "DAY"); + private static final Set 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 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 rows = "HOUR".equals(bucket) + ? eventRepository.trendByHour(from, to) + : eventRepository.trendByDay(from, to); + List 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 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 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 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() + "}"; + } +} \ No newline at end of file diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/risk/ScoringRiskAction.java b/mydreamjob-server/src/main/java/com/mydreamjob/risk/ScoringRiskAction.java new file mode 100644 index 0000000..1f6c16b --- /dev/null +++ b/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)。 + * + *

{@code operatorUserId} 仅内部存储,响应不返回。仅支持受控 {code reasonCode}: + * MONITORING / CONFIRMED_FALSE_POSITIVE / OUT_OF_BAND_ESCALATED;不提供自由文本备注, + * 避免运营备注成为新的个人信息载体。

+ */ +@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; +} \ No newline at end of file diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/risk/ScoringRiskActionRepository.java b/mydreamjob-server/src/main/java/com/mydreamjob/risk/ScoringRiskActionRepository.java new file mode 100644 index 0000000..11df10d --- /dev/null +++ b/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 { +} \ No newline at end of file diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/risk/ScoringRiskEvent.java b/mydreamjob-server/src/main/java/com/mydreamjob/risk/ScoringRiskEvent.java new file mode 100644 index 0000000..456a1c4 --- /dev/null +++ b/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)。 + * + *

{@code subjectUserId} 仅内部关联用户,任何 API 响应均不得返回;对外只暴露事件 ID 与 + * 规则快照字段(ruleCode/sourceAction/severity)。状态机:OPEN→ACKNOWLEDGED、OPEN→DISMISSED、 + * ACKNOWLEDGED→DISMISSED,DISMISSED 为终态。

+ */ +@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; + } +} \ No newline at end of file diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/risk/ScoringRiskEventRepository.java b/mydreamjob-server/src/main/java/com/mydreamjob/risk/ScoringRiskEventRepository.java new file mode 100644 index 0000000..dd4fe29 --- /dev/null +++ b/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 { + + // 扫描幂等:同一(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 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 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 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 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 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 search(@Param("status") String status, + @Param("severity") String severity, + @Param("ruleCode") String ruleCode, + @Param("from") LocalDateTime from, + @Param("to") LocalDateTime to, + Pageable pageable); +} \ No newline at end of file diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/risk/ScoringRiskScanCheckpoint.java b/mydreamjob-server/src/main/java/com/mydreamjob/risk/ScoringRiskScanCheckpoint.java new file mode 100644 index 0000000..80ee31a --- /dev/null +++ b/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; +} \ No newline at end of file diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/risk/ScoringRiskScanCheckpointRepository.java b/mydreamjob-server/src/main/java/com/mydreamjob/risk/ScoringRiskScanCheckpointRepository.java new file mode 100644 index 0000000..a5590fb --- /dev/null +++ b/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 { + + Optional findByCheckpointKey(String checkpointKey); + + /** 悲观写锁:同一时间只允许一个扫描事务消费审计增量,避免并发扫描重复建事件。 */ + @Lock(LockModeType.PESSIMISTIC_WRITE) + @Query("SELECT c FROM ScoringRiskScanCheckpoint c WHERE c.checkpointKey = :key") + Optional findByCheckpointKeyForUpdate(@Param("key") String key); +} \ No newline at end of file diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/risk/ScoringRuleConfig.java b/mydreamjob-server/src/main/java/com/mydreamjob/risk/ScoringRuleConfig.java new file mode 100644 index 0000000..6cad592 --- /dev/null +++ b/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)。 + * + *

{@code source_action} 仅允许 {@code REPORT_RATE_LIMITED}/{@code BLOCKED_SEND_PROHIBITED}; + * 规则更新只影响后续扫描,不回溯、不删除既有事件。

+ */ +@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; +} \ No newline at end of file diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/risk/ScoringRuleConfigRepository.java b/mydreamjob-server/src/main/java/com/mydreamjob/risk/ScoringRuleConfigRepository.java new file mode 100644 index 0000000..f3826e4 --- /dev/null +++ b/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 { + + Optional findByRuleCode(String ruleCode); + + List findAllByEnabledTrue(); + + List findAllByOrderByRuleCodeAsc(); +} \ No newline at end of file diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/risk/dto/RiskDistributionResponse.java b/mydreamjob-server/src/main/java/com/mydreamjob/risk/dto/RiskDistributionResponse.java new file mode 100644 index 0000000..5b8c209 --- /dev/null +++ b/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 items; + + @Data + @Builder + public static class DistributionRow { + private String ruleCode; + private String severity; + private Long count; + } +} \ No newline at end of file diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/risk/dto/RiskEventActionRequest.java b/mydreamjob-server/src/main/java/com/mydreamjob/risk/dto/RiskEventActionRequest.java new file mode 100644 index 0000000..5ab36f4 --- /dev/null +++ b/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; +} \ No newline at end of file diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/risk/dto/RiskEventResponse.java b/mydreamjob-server/src/main/java/com/mydreamjob/risk/dto/RiskEventResponse.java new file mode 100644 index 0000000..a6610bf --- /dev/null +++ b/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; +} \ No newline at end of file diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/risk/dto/RiskOverviewResponse.java b/mydreamjob-server/src/main/java/com/mydreamjob/risk/dto/RiskOverviewResponse.java new file mode 100644 index 0000000..5b4a22e --- /dev/null +++ b/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 bySeverity; + private List 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; + } +} \ No newline at end of file diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/risk/dto/RiskRuleResponse.java b/mydreamjob-server/src/main/java/com/mydreamjob/risk/dto/RiskRuleResponse.java new file mode 100644 index 0000000..5be5d1e --- /dev/null +++ b/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; +} \ No newline at end of file diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/risk/dto/RiskRuleUpdateRequest.java b/mydreamjob-server/src/main/java/com/mydreamjob/risk/dto/RiskRuleUpdateRequest.java new file mode 100644 index 0000000..9d11879 --- /dev/null +++ b/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; +} \ No newline at end of file diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/risk/dto/RiskTrendResponse.java b/mydreamjob-server/src/main/java/com/mydreamjob/risk/dto/RiskTrendResponse.java new file mode 100644 index 0000000..5a75bde --- /dev/null +++ b/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 items; + + @Data + @Builder + public static class TrendPoint { + private LocalDateTime time; + private Long count; + } +} \ No newline at end of file diff --git a/mydreamjob-server/src/main/resources/db/migration/V34__riasec_assessment.sql b/mydreamjob-server/src/main/resources/db/migration/V34__riasec_assessment.sql new file mode 100644 index 0000000..a09f57b --- /dev/null +++ b/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'; \ No newline at end of file diff --git a/mydreamjob-server/src/main/resources/db/migration/V35__ability_assessment.sql b/mydreamjob-server/src/main/resources/db/migration/V35__ability_assessment.sql new file mode 100644 index 0000000..0405fba --- /dev/null +++ b/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); diff --git a/mydreamjob-server/src/main/resources/db/migration/V36__risk_governance.sql b/mydreamjob-server/src/main/resources/db/migration/V36__risk_governance.sql new file mode 100644 index 0000000..c0c42cf --- /dev/null +++ b/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); \ No newline at end of file diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/ability/AbilityAttemptServiceTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/ability/AbilityAttemptServiceTest.java new file mode 100644 index 0000000..35e01b4 --- /dev/null +++ b/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 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 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 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 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; + } +} \ No newline at end of file diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/ability/AbilityCatalogServiceTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/ability/AbilityCatalogServiceTest.java new file mode 100644 index 0000000..0e70f11 --- /dev/null +++ b/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 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()); + } +} \ No newline at end of file diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/ability/AbilityControllerTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/ability/AbilityControllerTest.java new file mode 100644 index 0000000..c79f873 --- /dev/null +++ b/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 redisTemplate; + + private Long studentUserId; + private Long companyUserId; + private Long adminUserId; + + @BeforeEach + @SuppressWarnings("unchecked") + void setUp() { + ValueOperations 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> 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")); + } +} \ No newline at end of file diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/ability/AbilityPersistenceIntegrationTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/ability/AbilityPersistenceIntegrationTest.java new file mode 100644 index 0000000..64c7b18 --- /dev/null +++ b/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 redisTemplate; + @MockBean private Clock clock; + + private final AuditContext ctx = AuditContext.builder().requestId("req").build(); + private final List 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 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 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 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 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(); + } +} \ No newline at end of file diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/ability/AbilityQuestionBankTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/ability/AbilityQuestionBankTest.java new file mode 100644 index 0000000..4803cdb --- /dev/null +++ b/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 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 rows = questionRepository.findAllByDefinitionIdOrderByPositionAsc(defId); + assertEquals(48, rows.size(), "total questions"); + + // 权威 JSON + JsonNode qs = bank.path("questions"); + assertEquals(48, qs.size()); + + // DB 侧分布 + Map dimCount = new HashMap<>(); + Map tagCount = new HashMap<>(); + Map bestCount = new HashMap<>(); + Set positions = new HashSet<>(); + Map 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 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() { + } +} \ No newline at end of file diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/ability/AbilityResultComputerTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/ability/AbilityResultComputerTest.java new file mode 100644 index 0000000..a8190af --- /dev/null +++ b/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 items(int[] perDimScore, String[] dims, + String[][] tags) { + List 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 list = new ArrayList<>(); + list.addAll(items(log, dims, tags)); // default perDimScore arrays reused? recompute below + list = new ArrayList<>(); + int row = 0; + List 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 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(); + } +} \ No newline at end of file diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/ability/AbilitySchemaValidationSmokeTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/ability/AbilitySchemaValidationSmokeTest.java new file mode 100644 index 0000000..35bb78c --- /dev/null +++ b/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 redisTemplate; + + @Test + void contextBoots_andAbilityColumnsAreTinyInt() { + List> 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); + } + } +} \ No newline at end of file diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/assessment/AssessmentAttemptServiceTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/assessment/AssessmentAttemptServiceTest.java new file mode 100644 index 0000000..885a7e4 --- /dev/null +++ b/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)。 + * + *

覆盖身份校验、活跃答卷重复 9708、幂等冲突 9709、答案不属于本答卷 9705、 + * 版本并发 9707、29 题提交失败 9706、重复提交幂等返回原结果。

+ */ +@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 bank = new ArrayList<>(); + Map perDim = new LinkedHashMap<>( + Map.of("R", 3, "I", 5, "A", 5, "S", 5, "E", 5, "C", 5)); + long seq = 1; + for (Map.Entry 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()); + } +} \ No newline at end of file diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/assessment/AssessmentCatalogServiceTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/assessment/AssessmentCatalogServiceTest.java new file mode 100644 index 0000000..ead71b8 --- /dev/null +++ b/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)。 + * + *

覆盖类型列表(仅 ACTIVE)、题目列表隐藏 dimension/reverseScored/source/questionVersion、 + * 未知类型 404/9701。

+ */ +@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 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 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()); + } +} \ No newline at end of file diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/assessment/AssessmentControllerTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/assessment/AssessmentControllerTest.java new file mode 100644 index 0000000..15281ea --- /dev/null +++ b/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)。 + * + *

覆盖 401、题目响应不返回 dimension、创建 200、全流程保存/提交/结果、 + * COMPANY 创建 403、结果不含隐私字段。

+ */ +@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 redisTemplate; + + private Long studentUserId; + private Long companyUserId; + + @BeforeEach + @SuppressWarnings("unchecked") + void setUp() { + ValueOperations 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 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 ids = new ConcurrentLinkedQueue<>(); + ConcurrentLinkedQueue 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(); } + } +} \ No newline at end of file diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/assessment/AssessmentPersistenceIntegrationTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/assessment/AssessmentPersistenceIntegrationTest.java new file mode 100644 index 0000000..1a9430e --- /dev/null +++ b/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 真实表结构)。 + * + *

覆盖 V34 迁移成功、抽题 30 且每维 5、无重复、固定顺序、答案归属校验、 + * 29 题提交失败、30 题提交成功、重复提交幂等、幂等键、活跃答卷重复、候选评分不写入、 + * 唯一/CHECK 约束。

+ */ +@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 redisTemplate; + + private final AuditContext ctx = AuditContext.builder().requestId("req").build(); + private final List createdUsers = new ArrayList<>(); + private Long studentUserId; + + @BeforeEach + @SuppressWarnings("unchecked") + void setUp() { + ValueOperations 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 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 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 ids = new HashSet<>(); + for (AssessmentQuestionResponse q : resp.getQuestions()) { + assertTrue(ids.add(q.getId()), "no duplicate question expected"); + } + // 每维恰好 5 题 + List qids = resp.getQuestions().stream().map(AssessmentQuestionResponse::getId).toList(); + Map 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 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 attemptIds = new java.util.concurrent.ConcurrentLinkedQueue<>(); + java.util.concurrent.ConcurrentLinkedQueue 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(); + } + } +} \ No newline at end of file diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/assessment/AssessmentQuestionBankTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/assessment/AssessmentQuestionBankTest.java new file mode 100644 index 0000000..bece479 --- /dev/null +++ b/mydreamjob-server/src/test/java/com/mydreamjob/assessment/AssessmentQuestionBankTest.java @@ -0,0 +1,105 @@ +package com.mydreamjob.assessment; + +import org.junit.jupiter.api.Test; + +import java.io.IOException; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * 题库种子迁移校验(V34/V35)。 + * + *

解析 V34__riasec_assessment.sql 的题目插入: + * 72 道、六维各 12、position 1-72 唯一、reverse_scored=0、source=SELF_AUTHORED、active=1。

+ */ +class AssessmentQuestionBankTest { + + private static final Pattern ROW = Pattern.compile( + "SELECT '([RIAESC]\\d+)'\\s*,\\s*(\\d+)\\s*,\\s*'([RIAESC])'\\s*,\\s*'(.*?)'"); + + private record Row(String code, int position, String dimension, String stem) {} + + private List parseRows() throws IOException { + try (InputStream in = getClass().getResourceAsStream("/db/migration/V34__riasec_assessment.sql")) { + assertNotNull(in, "V34 migration must exist on classpath"); + String sql = new String(in.readAllBytes(), StandardCharsets.UTF_8); + // 仅取题目 INSERT 段 + int start = sql.indexOf("INSERT INTO assessment_questions"); + assertTrue(start >= 0, "questions insert section must exist"); + String section = sql.substring(start); + int end = section.indexOf(") q"); + section = section.substring(0, end >= 0 ? end : section.length()); + // 归一化:去除短形式/长形式的列别名,统一为 "SELECT 'C01', 1, 'R', '题干'" 形式 + section = section.replaceAll("\\s+AS\\s+[A-Za-z_]+", ""); + + List rows = new ArrayList<>(); + Matcher m = ROW.matcher(section.trim()); + while (m.find()) { + rows.add(new Row(m.group(1), Integer.parseInt(m.group(2)), m.group(3), m.group(4))); + } + return rows; + } + } + + @Test + void seeds_72Questions_sixDimensions12Each_uniquePositions() throws IOException { + List rows = parseRows(); + assertEquals(72, rows.size(), "total questions must be 72"); + + Map byDim = new HashMap<>(); + Set positions = new HashSet<>(); + Set codes = new HashSet<>(); + for (Row r : rows) { + byDim.merge(r.dimension(), 1, Integer::sum); + positions.add(r.position()); + codes.add(r.code()); + } + for (String dim : List.of("R", "I", "A", "S", "E", "C")) { + assertEquals(12, byDim.getOrDefault(dim, 0), "dimension " + dim + " must have 12 questions"); + } + assertEquals(72, positions.size(), "positions must be unique"); + assertEquals(72, codes.size(), "question codes must be unique"); + // position 覆盖 1-72 + for (int i = 1; i <= 72; i++) { + assertTrue(positions.contains(i), "position " + i + " missing"); + } + } + + @Test + void seeds_constantFields_selfAuthoredNotReversedActive() throws IOException { + String sql = new String(getClass().getResourceAsStream("/db/migration/V34__riasec_assessment.sql") + .readAllBytes(), StandardCharsets.UTF_8); + int start = sql.indexOf("INSERT INTO assessment_questions"); + String section = sql.substring(start); + int end = section.indexOf(") q"); + section = section.substring(0, end >= 0 ? end : section.length()); + // 常量校验:source / reverse / active 在 INSERT 外层 SELECT 中为常量,作用于全部 72 行 + assertTrue(section.contains("'SELF_AUTHORED'"), "source must be SELF_AUTHORED"); + assertTrue(section.contains("'SELF_AUTHORED', 0, 1"), "reverse_scored=0 and active=1"); + assertEquals(1, countOccurrences(section, "'SELF_AUTHORED'"), + "self-authored constant appears exactly once as outer SELECT literal"); + assertTrue(sql.contains("'riasec-self-v0.2'"), "question_set_version must be riasec-self-v0.2"); + assertTrue(sql.contains("'riasec-scoring-v0.1'"), "rules_version must be riasec-scoring-v0.1"); + assertTrue(sql.contains("72"), "definition question_count=72"); + } + + private int countOccurrences(String s, String needle) { + int count = 0; + int idx = 0; + while ((idx = s.indexOf(needle, idx)) >= 0) { + count++; + idx += needle.length(); + } + return count; + } +} \ No newline at end of file diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/assessment/AssessmentResultComputerTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/assessment/AssessmentResultComputerTest.java new file mode 100644 index 0000000..5e7e900 --- /dev/null +++ b/mydreamjob-server/src/test/java/com/mydreamjob/assessment/AssessmentResultComputerTest.java @@ -0,0 +1,94 @@ +package com.mydreamjob.assessment; + +import org.junit.jupiter.api.Test; + +import java.math.BigDecimal; +import java.math.RoundingMode; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * RIASEC 计分引擎单测(V34/V35)。 + * + *

验证 percentage = raw/25 × 100(HALF_UP, scale2)、六维降序、同分按维度字母升序、 + * 前三组成 primaryCode、维度分数规范顺序输出。

+ */ +class AssessmentResultComputerTest { + + private final AssessmentResultComputer computer = new AssessmentResultComputer(); + + private AssessmentResultComputer.DimensionValue v(String dim, int value) { + return new AssessmentResultComputer.DimensionValue(dim, value); + } + + @Test + void compute_fullScores_matchesExpectedPercentagesAndPrimaryCode() { + List answers = List.of( + v("S", 5), v("S", 5), v("S", 5), v("S", 5), v("S", 4), // 24 → 96.00 + v("I", 4), v("I", 5), v("I", 5), v("I", 4), v("I", 4), // 22 → 88.00 + v("C", 4), v("C", 4), v("C", 4), v("C", 4), v("C", 4), // 20 → 80.00 + v("R", 4), v("R", 3), v("R", 4), v("R", 4), v("R", 4), // 19 → 76.00 + v("A", 4), v("A", 4), v("A", 3), v("A", 4), v("A", 3), // 18 → 72.00 + v("E", 3), v("E", 4), v("E", 4), v("E", 3), v("E", 3) // 17 → 68.00 + ); + + var result = computer.compute(answers, 5, 5); + + assertEquals(new BigDecimal("96.00"), score(result, "S")); + assertEquals(new BigDecimal("88.00"), score(result, "I")); + assertEquals(new BigDecimal("80.00"), score(result, "C")); + assertEquals(new BigDecimal("76.00"), score(result, "R")); + assertEquals(new BigDecimal("72.00"), score(result, "A")); + assertEquals(new BigDecimal("68.00"), score(result, "E")); + + assertEquals(List.of("S", "I", "C", "R", "A", "E"), result.typeRanking()); + assertEquals("SIC", result.primaryCode()); + assertEquals("S", result.primaryType()); + // 规范顺序输出六维 + assertEquals(6, result.dimensionScores().size()); + assertEquals("R", result.dimensionScores().get(0).dimension()); + } + + @Test + void compute_tie_orderedAlphabetically() { + // 所有维度都 60.00,排序按字母升序:A < C < E < I < R < S + List answers = List.of( + v("S", 3), v("S", 3), v("S", 3), v("S", 3), v("S", 3), + v("I", 3), v("I", 3), v("I", 3), v("I", 3), v("I", 3), + v("A", 3), v("A", 3), v("A", 3), v("A", 3), v("A", 3), + v("C", 3), v("C", 3), v("C", 3), v("C", 3), v("C", 3), + v("E", 3), v("E", 3), v("E", 3), v("E", 3), v("E", 3), + v("R", 3), v("R", 3), v("R", 3), v("R", 3), v("R", 3) + ); + + var result = computer.compute(answers, 5, 5); + + assertEquals(List.of("A", "C", "E", "I", "R", "S"), result.typeRanking()); + assertEquals("ACE", result.primaryCode()); + assertEquals("A", result.primaryType()); + } + + @Test + void compute_halfUp_rounding() { + // 每个维度 values 总和为奇数时产生 .33 → 截断为两位 HALF_UP + List answers = List.of( + v("R", 4), v("R", 4), v("R", 4), v("R", 4), v("R", 4) // 20 → 80.00 + ); + var result = computer.compute(answers, 5, 5); + // 未答维度 raw=0 → 0.00 + assertEquals(new BigDecimal("0.00"), score(result, "I")); + // 精度校验:1 题 3 分、其余缺失(仅测 answeredCount 兜底) + List partial = List.of(v("E", 3)); + var r2 = computer.compute(partial, 5, 5); + assertEquals(new BigDecimal("12.00"), score(r2, "E")); // 3/25*100 + } + + private BigDecimal score(AssessmentResultComputer.ComputedResult result, String dim) { + return result.dimensionScores().stream() + .filter(d -> d.dimension().equals(dim)) + .findFirst() + .map(AssessmentResultComputer.DimensionScoreResult::score) + .orElseThrow(); + } +} \ No newline at end of file diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/assessment/AssessmentSchemaValidationSmokeTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/assessment/AssessmentSchemaValidationSmokeTest.java new file mode 100644 index 0000000..11c8c67 --- /dev/null +++ b/mydreamjob-server/src/test/java/com/mydreamjob/assessment/AssessmentSchemaValidationSmokeTest.java @@ -0,0 +1,80 @@ +package com.mydreamjob.assessment; + +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.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * V35 启动 / schema 校验冒烟测试。 + * + *

真实启动完整上下文(H2 MODE=MySQL + Flyway V34)。校验 + * {@code assessment_questions.active / reverse_scored}: + * ① 数据库列为 NOT NULL 整数(TINYINT 家族,绝非 BIT); + * ② 实体 Boolean 字段均标注 {@code @JdbcTypeCode(TINYINT)}, + * 使 Hibernate 在 MySQL/H2 下均按 tinyint 校验,避免默认 Boolean→BIT 与 TINYINT 冲突。

+ * + *

注:此处不做全局 {@code ddl-auto=validate}(会触发既有 V1-V33 无关实体 + * {@code auth_tokens.revoked} 在 H2 的 BOOLEAN/TINYINT 校验外观问题),仅针对 assessment 列与映射断言。

+ */ +@SpringBootTest +@ActiveProfiles("integration-test") +class AssessmentSchemaValidationSmokeTest { + + @Autowired private JdbcTemplate jdbcTemplate; + + @MockBean private RedisTemplate redisTemplate; + + @Test + void assessmentQuestion_integerColumnsAndTinyIntMapping() { + // ① 上下文能加载 → Flyway V34 正常;列类型为整数(TINYINT),NOT NULL + List> cols = jdbcTemplate.queryForList( + "SELECT column_name, data_type, is_nullable FROM information_schema.columns " + + "WHERE LOWER(table_name) = 'assessment_questions' " + + "AND LOWER(column_name) IN ('active', 'reverse_scored')"); + assertEquals(2, cols.size(), "both boolean-ish columns must exist"); + + Set types = new HashSet<>(); + Set names = new HashSet<>(); + for (Map col : cols) { + names.add(String.valueOf(col.get("COLUMN_NAME")).toLowerCase()); + types.add(String.valueOf(col.get("DATA_TYPE")).toUpperCase()); + assertEquals("NO", String.valueOf(col.get("IS_NULLABLE")).toUpperCase(), "column must be NOT NULL"); + } + assertTrue(names.containsAll(Set.of("active", "reverse_scored")), "expected columns: " + names); + // TINYINT 系列整数类型(H2/MySQL 均含 'INT'),绝不允许映射成 BIT + assertTrue(types.stream().allMatch(t -> t.contains("INT")), + "active/reverse_scored must be integer (TINYINT) columns, got: " + types); + + // ② 实体字段带 @JdbcTypeCode(TINYINT),双向一致 → 不出现 TINYINT/BIT 冲突 + assertEquals(SqlTypes.TINYINT, fieldJdbcType("active"), "active must map to TINYINT"); + assertEquals(SqlTypes.TINYINT, fieldJdbcType("reverseScored"), "reverse_scored must map to TINYINT"); + + // ③ Boolean 读取 72 行无类型异常 + List actives = jdbcTemplate.queryForList("SELECT active FROM assessment_questions", Boolean.class); + assertEquals(72, actives.size()); + } + + private int fieldJdbcType(String fieldName) { + try { + java.lang.reflect.Field f = AssessmentQuestion.class.getDeclaredField(fieldName); + JdbcTypeCode code = f.getAnnotation(JdbcTypeCode.class); + assertNotNull(code, fieldName + " must be annotated with @JdbcTypeCode"); + return code.value(); + } catch (NoSuchFieldException e) { + throw new AssertionError("missing field " + fieldName, e); + } + } +} \ No newline at end of file diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/risk/AdminRiskControllerTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/risk/AdminRiskControllerTest.java new file mode 100644 index 0000000..0add3d9 --- /dev/null +++ b/mydreamjob-server/src/test/java/com/mydreamjob/risk/AdminRiskControllerTest.java @@ -0,0 +1,261 @@ +package com.mydreamjob.risk; + +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.time.LocalDateTime; +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.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put; +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 AdminRiskControllerTest { + + private static final AtomicLong PHONE = new AtomicLong(7000); + + @Autowired private MockMvc mockMvc; + @Autowired private JwtTokenProvider jwtTokenProvider; + @Autowired private UserRepository userRepository; + @Autowired private ScoringRuleConfigRepository ruleRepository; + @Autowired private ScoringRiskEventRepository eventRepository; + @Autowired private ScoringRiskActionRepository actionRepository; + + @MockBean private RedisTemplate redisTemplate; + + private Long adminUserId; + private Long studentUserId; + private Long companyUserId; + private final List createdEvents = new ArrayList<>(); + private ScoringRuleConfig reportRule; + + @BeforeEach + @SuppressWarnings("unchecked") + void setUp() { + ValueOperations ops = mock(ValueOperations.class); + when(redisTemplate.opsForValue()).thenReturn(ops); + when(redisTemplate.hasKey(any())).thenReturn(false); + adminUserId = createUser(User.Role.ADMIN); + studentUserId = createUser(User.Role.STUDENT); + companyUserId = createUser(User.Role.COMPANY); + reportRule = ruleRepository.findByRuleCode("REPORT_RATE_LIMIT_BURST").orElseThrow(); + } + + @AfterEach + void tearDown() { + restoreSeedRules(); // 规则是共享持久化种子,回滚测试对阈值的改动,避免跨测试/跨类污染 + for (Long id : createdEvents) { + actionRepository.deleteAll(); + eventRepository.deleteById(id); + } + createdEvents.clear(); + userRepository.deleteById(companyUserId); + userRepository.deleteById(studentUserId); + userRepository.deleteById(adminUserId); + } + + private void restoreSeedRules() { + resetRule("REPORT_RATE_LIMIT_BURST", "REPORT_RATE_LIMITED", 1, 60, "HIGH"); + resetRule("BLOCKED_MESSAGE_BURST", "BLOCKED_SEND_PROHIBITED", 3, 15, "MEDIUM"); + } + + private void resetRule(String code, String action, int threshold, int window, String severity) { + ruleRepository.findByRuleCode(code).ifPresent(rule -> { + rule.setSourceAction(action); + rule.setThresholdCount(threshold); + rule.setWindowMinutes(window); + rule.setSeverity(severity); + rule.setEnabled(true); + ruleRepository.save(rule); + }); + } + + private Long createUser(User.Role role) { + User u = userRepository.save(User.builder() + .phone("19" + 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())); + } + + private ScoringRiskEvent newEvent(String status, long version) { + ScoringRiskEvent ev = ScoringRiskEvent.builder() + .rule(reportRule).subjectUserId(111L).sourceAuditLogId(999L) + .sourceAction(reportRule.getSourceAction()).severity(reportRule.getSeverity()) + .windowStartedAt(LocalDateTime.now().minusHours(1)) + .windowEndedAt(LocalDateTime.now()) + .occurrenceCount(1).status(status) + .detectedAt(LocalDateTime.now()).version(version) + .build(); + ev = eventRepository.save(ev); + createdEvents.add(ev.getId()); + return ev; + } + + // ---- 匿名 401 ---- + + @Test + void anonymousAllEndpoints_401() throws Exception { + mockMvc.perform(get("/admin/scoring/risk/overview")).andExpect(status().isUnauthorized()); + mockMvc.perform(get("/admin/scoring/risk/trend").param("bucket", "DAY")).andExpect(status().isUnauthorized()); + mockMvc.perform(get("/admin/scoring/risk/distribution")).andExpect(status().isUnauthorized()); + mockMvc.perform(get("/admin/scoring/risk/events")).andExpect(status().isUnauthorized()); + mockMvc.perform(get("/admin/scoring/risk/rules")).andExpect(status().isUnauthorized()); + mockMvc.perform(put("/admin/scoring/risk/rules/RC").contentType(MediaType.APPLICATION_JSON) + .content("{\"enabled\":true,\"thresholdCount\":3,\"windowMinutes\":15,\"severity\":\"MEDIUM\",\"expectedVersion\":0}")) + .andExpect(status().isUnauthorized()); + mockMvc.perform(post("/admin/scoring/risk/events/1/actions").contentType(MediaType.APPLICATION_JSON) + .content("{\"action\":\"ACKNOWLEDGE\",\"reasonCode\":\"MONITORING\",\"expectedVersion\":0}")) + .andExpect(status().isUnauthorized()); + } + + // ---- 非 ADMIN 403 ---- + + @Test + void studentAndCompany_403() throws Exception { + String st = token(studentUserId, User.Role.STUDENT); + String co = token(companyUserId, User.Role.COMPANY); + for (String tk : new String[]{st, co}) { + mockMvc.perform(get("/admin/scoring/risk/overview").header("Authorization", tk)) + .andExpect(status().isForbidden()); + mockMvc.perform(get("/admin/scoring/risk/events").header("Authorization", tk)) + .andExpect(status().isForbidden()); + mockMvc.perform(get("/admin/scoring/risk/rules").header("Authorization", tk)) + .andExpect(status().isForbidden()); + } + } + + // ---- ADMIN 成功 ---- + + @Test + void adminOverview200() throws Exception { + mockMvc.perform(get("/admin/scoring/risk/overview") + .header("Authorization", token(adminUserId, User.Role.ADMIN))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(200)) + .andExpect(jsonPath("$.data").exists()); + } + + @Test + void adminTrendDistributionEventsRules200() throws Exception { + String tk = token(adminUserId, User.Role.ADMIN); + mockMvc.perform(get("/admin/scoring/risk/trend").param("bucket", "DAY").header("Authorization", tk)) + .andExpect(status().isOk()); + mockMvc.perform(get("/admin/scoring/risk/trend").param("bucket", "HOUR").header("Authorization", tk)) + .andExpect(status().isOk()); + mockMvc.perform(get("/admin/scoring/risk/distribution").header("Authorization", tk)) + .andExpect(status().isOk()); + mockMvc.perform(get("/admin/scoring/risk/events").param("page", "0").param("size", "20").header("Authorization", tk)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.items").isArray()); + mockMvc.perform(get("/admin/scoring/risk/rules").header("Authorization", tk)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.length()").value(2)); + } + + @Test + void adminUpdateRule200_andStale409() throws Exception { + String tk = token(adminUserId, User.Role.ADMIN); + long v = reportRule.getVersion(); + String okBody = "{\"enabled\":true,\"thresholdCount\":2,\"windowMinutes\":30,\"severity\":\"HIGH\",\"expectedVersion\":" + v + "}"; + mockMvc.perform(put("/admin/scoring/risk/rules/REPORT_RATE_LIMIT_BURST").header("Authorization", tk) + .contentType(MediaType.APPLICATION_JSON).content(okBody)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.thresholdCount").value(2)); + // 冲突版本 → 409 + String stale = "{\"enabled\":true,\"thresholdCount\":3,\"windowMinutes\":15,\"severity\":\"MEDIUM\",\"expectedVersion\":999}"; + mockMvc.perform(put("/admin/scoring/risk/rules/REPORT_RATE_LIMIT_BURST").header("Authorization", tk) + .contentType(MediaType.APPLICATION_JSON).content(stale)) + .andExpect(status().isConflict()) + .andExpect(jsonPath("$.code").value(9903)); + } + + @Test + void adminActionEvent200_noSensitiveFields() throws Exception { + ScoringRiskEvent ev = newEvent(ScoringRiskEvent.STATUS_OPEN, 0L); + String tk = token(adminUserId, User.Role.ADMIN); + String body = "{\"action\":\"ACKNOWLEDGE\",\"reasonCode\":\"MONITORING\",\"expectedVersion\":0}"; + String raw = mockMvc.perform(post("/admin/scoring/risk/events/{id}/actions", ev.getId()) + .header("Authorization", tk).contentType(MediaType.APPLICATION_JSON).content(body)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.status").value("ACKNOWLEDGED")) + .andExpect(jsonPath("$.data.eventId").value(ev.getId())) + .andReturn().getResponse().getContentAsString(); + assertNoSensitive(raw); + } + + @Test + void adminActionEvent_reasonCodeMonitored() throws Exception { + ScoringRiskEvent ev = newEvent(ScoringRiskEvent.STATUS_OPEN, 0L); + String tk = token(adminUserId, User.Role.ADMIN); + String body = "{\"action\":\"DISMISS\",\"reasonCode\":\"CONFIRMED_FALSE_POSITIVE\",\"expectedVersion\":0}"; + mockMvc.perform(post("/admin/scoring/risk/events/{id}/actions", ev.getId()) + .header("Authorization", tk).contentType(MediaType.APPLICATION_JSON).content(body)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.status").value("DISMISSED")); + } + + @Test + void adminInvalidFilter_400_9906() throws Exception { + String tk = token(adminUserId, User.Role.ADMIN); + // from > to + mockMvc.perform(get("/admin/scoring/risk/overview") + .param("from", "2026-09-03T00:00:00").param("to", "2026-08-01T00:00:00") + .header("Authorization", tk)) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value(9906)); + // 非法 bucket + mockMvc.perform(get("/admin/scoring/risk/trend").param("bucket", "MINUTE").header("Authorization", tk)) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value(9906)); + } + + @Test + void adminActionEvent_notFound_404_9902() throws Exception { + String tk = token(adminUserId, User.Role.ADMIN); + mockMvc.perform(post("/admin/scoring/risk/events/999999/actions").header("Authorization", tk) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"action\":\"ACKNOWLEDGE\",\"reasonCode\":\"MONITORING\",\"expectedVersion\":0}")) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.code").value(9902)); + } + + private void assertNoSensitive(String raw) { + org.junit.jupiter.api.Assertions.assertFalse(raw.contains("subjectUserId"), "no subjectUserId"); + org.junit.jupiter.api.Assertions.assertFalse(raw.contains("operatorUserId"), "no operatorUserId"); + org.junit.jupiter.api.Assertions.assertFalse(raw.contains("phone"), "no phone"); + org.junit.jupiter.api.Assertions.assertFalse(raw.contains("email"), "no email"); + org.junit.jupiter.api.Assertions.assertFalse(raw.contains("ipAddress"), "no ipAddress"); + org.junit.jupiter.api.Assertions.assertFalse(raw.contains("userAgent"), "no userAgent"); + org.junit.jupiter.api.Assertions.assertFalse(raw.contains("details"), "no details"); + } +} \ No newline at end of file diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/risk/RiskEventDetectorTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/risk/RiskEventDetectorTest.java new file mode 100644 index 0000000..64c2b2c --- /dev/null +++ b/mydreamjob-server/src/test/java/com/mydreamjob/risk/RiskEventDetectorTest.java @@ -0,0 +1,205 @@ +package com.mydreamjob.risk; + +import com.mydreamjob.audit.AuditLog; +import com.mydreamjob.audit.AuditLogRepository; +import com.mydreamjob.audit.AuditService; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.time.Clock; +import java.time.Instant; +import java.time.LocalDateTime; +import java.time.ZoneId; +import java.util.List; +import java.util.Optional; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.ArgumentMatchers.isNull; +import static org.mockito.Mockito.mock; +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 RiskEventDetectorTest { + + @Mock private ScoringRuleConfigRepository ruleRepository; + @Mock private ScoringRiskEventRepository eventRepository; + @Mock private ScoringRiskScanCheckpointRepository checkpointRepository; + @Mock private AuditLogRepository auditLogRepository; + @Mock private AuditService auditService; + @Mock private Clock clock; + + @InjectMocks private RiskEventDetector detector; + + private static final LocalDateTime T = LocalDateTime.of(2026, 9, 3, 8, 0, 0); + + @BeforeEach + void time() { + org.mockito.Mockito.lenient().when(clock.getZone()).thenReturn(ZoneId.of("Asia/Shanghai")); + org.mockito.Mockito.lenient().when(clock.instant()).thenReturn(Instant.parse("2026-09-03T00:00:00Z")); + } + + private ScoringRuleConfig rule(long id, String action, int threshold, int window, boolean enabled) { + return ScoringRuleConfig.builder().id(id).ruleCode("R" + id).sourceAction(action) + .thresholdCount(threshold).windowMinutes(window).severity("HIGH").enabled(enabled).version(0L).build(); + } + + private AuditLog audit(long id, long userId, String action, LocalDateTime t) { + return AuditLog.builder().id(id).userId(userId).action(action).createdAt(t).build(); + } + + private ScoringRiskScanCheckpoint cp(long lastId) { + return ScoringRiskScanCheckpoint.builder().id(1L) + .checkpointKey(ScoringRiskScanCheckpoint.KEY_RISK_EVENT_DETECTOR).lastAuditLogId(lastId).build(); + } + + @Test + void createsEventWhenCountEqualsThreshold_reportedLimited() { + ScoringRuleConfig rule = rule(1L, "REPORT_RATE_LIMITED", 1, 60, true); + when(checkpointRepository.findByCheckpointKeyForUpdate(anyString())).thenReturn(Optional.of(cp(0L))); + when(auditLogRepository.findByIdAfterOrderByIdAsc(eq(0L), any())).thenReturn(List.of(audit(10L, 7L, "REPORT_RATE_LIMITED", T))); + when(ruleRepository.findAllByEnabledTrue()).thenReturn(List.of(rule)); + when(auditLogRepository.countByUserIdAndActionAndCreatedAtBetween(eq(7L), eq("REPORT_RATE_LIMITED"), any(), any())).thenReturn(1L); + when(eventRepository.existsByRuleIdAndSourceAuditLogId(1L, 10L)).thenReturn(false); + when(eventRepository.saveAndFlush(any())).thenAnswer(inv -> inv.getArgument(0)); + + assertEquals(1, detector.scanOnce()); + verify(eventRepository, times(1)).saveAndFlush(any()); + ArgumentCaptor cap = ArgumentCaptor.forClass(ScoringRiskScanCheckpoint.class); + verify(checkpointRepository, times(1)).save(cap.capture()); + assertEquals(10L, cap.getValue().getLastAuditLogId()); + } + + @Test + void detectedAudit_usesSavedEventId_andMatchesDetailsRiskEventId() { + ScoringRuleConfig rule = rule(1L, "REPORT_RATE_LIMITED", 1, 60, true); + when(checkpointRepository.findByCheckpointKeyForUpdate(anyString())).thenReturn(Optional.of(cp(0L))); + when(auditLogRepository.findByIdAfterOrderByIdAsc(eq(0L), any())) + .thenReturn(List.of(audit(10L, 7L, "REPORT_RATE_LIMITED", T))); + when(ruleRepository.findAllByEnabledTrue()).thenReturn(List.of(rule)); + when(auditLogRepository.countByUserIdAndActionAndCreatedAtBetween(eq(7L), eq("REPORT_RATE_LIMITED"), any(), any())) + .thenReturn(1L); + when(eventRepository.existsByRuleIdAndSourceAuditLogId(1L, 10L)).thenReturn(false); + // saveAndFlush 的返回值才携带已生成的 ID(IDENTITY 策略),模拟真实底层行为 + when(eventRepository.saveAndFlush(any())).thenAnswer(inv -> { + ScoringRiskEvent e = inv.getArgument(0); + e.setId(42L); + return e; + }); + + assertEquals(1, detector.scanOnce()); + + org.mockito.ArgumentCaptor entityId = org.mockito.ArgumentCaptor.forClass(Long.class); + org.mockito.ArgumentCaptor details = org.mockito.ArgumentCaptor.forClass(String.class); + verify(auditService).log(isNull(), eq("RISK_EVENT_DETECTED"), eq("SCORING_RISK_EVENT"), + entityId.capture(), details.capture(), any()); + assertEquals(42L, entityId.getValue()); // entity_id 非空且等于事件 ID + assertEquals(42L, Long.parseLong( + details.getValue().replaceAll(".*\\\"riskEventId\\\":(\\d+).*", "$1"))); // 与 details.riskEventId 一致 + assertTrue(details.getValue().contains("\"riskEventId\":42")); + assertFalse(details.getValue().contains("subjectUserId"), "no subjectUserId"); + assertFalse(details.getValue().contains("phone"), "no phone"); + assertFalse(details.getValue().contains("email"), "no email"); + assertFalse(details.getValue().contains("ipAddress"), "no ipAddress"); + assertFalse(details.getValue().contains("userAgent"), "no userAgent"); + assertFalse(details.getValue().contains("token"), "no token"); + assertFalse(details.getValue().contains("password"), "no password"); + assertFalse(details.getValue().contains("secret"), "no secret"); + assertFalse(details.getValue().contains("details"), "no raw audit details"); + } + + @Test + void noEventWhenBelowThreshold_blockedMessage() { + ScoringRuleConfig rule = rule(2L, "BLOCKED_SEND_PROHIBITED", 3, 15, true); + when(checkpointRepository.findByCheckpointKeyForUpdate(anyString())).thenReturn(Optional.of(cp(0L))); + when(auditLogRepository.findByIdAfterOrderByIdAsc(eq(0L), any())).thenReturn(List.of(audit(5L, 7L, "BLOCKED_SEND_PROHIBITED", T))); + when(ruleRepository.findAllByEnabledTrue()).thenReturn(List.of(rule)); + when(auditLogRepository.countByUserIdAndActionAndCreatedAtBetween(eq(7L), eq("BLOCKED_SEND_PROHIBITED"), any(), any())).thenReturn(1L); + + assertEquals(0, detector.scanOnce()); + verify(eventRepository, never()).saveAndFlush(any()); + } + + @Test + void onlyFirstThresholdHitCreatesEvent_deDuplicates() { + ScoringRuleConfig rule = rule(2L, "BLOCKED_SEND_PROHIBITED", 3, 15, true); + when(checkpointRepository.findByCheckpointKeyForUpdate(anyString())).thenReturn(Optional.of(cp(0L))); + when(auditLogRepository.findByIdAfterOrderByIdAsc(eq(0L), any())).thenReturn(List.of( + audit(1L, 7L, "BLOCKED_SEND_PROHIBITED", T.minusMinutes(2)), + audit(2L, 7L, "BLOCKED_SEND_PROHIBITED", T.minusMinutes(1)), + audit(3L, 7L, "BLOCKED_SEND_PROHIBITED", T), + audit(4L, 7L, "BLOCKED_SEND_PROHIBITED", T.plusMinutes(1)))); + when(ruleRepository.findAllByEnabledTrue()).thenReturn(List.of(rule)); + when(auditLogRepository.countByUserIdAndActionAndCreatedAtBetween(eq(7L), eq("BLOCKED_SEND_PROHIBITED"), any(), any())) + .thenReturn(1L, 2L, 3L, 4L); + when(eventRepository.existsByRuleIdAndSourceAuditLogId(2L, 3L)).thenReturn(false); + when(eventRepository.saveAndFlush(any())).thenAnswer(inv -> inv.getArgument(0)); + + assertEquals(1, detector.scanOnce()); + verify(eventRepository, times(1)).saveAndFlush(any()); + assertEquals(4L, checkpointCapture().getLastAuditLogId()); + } + + @Test + void disabledRuleProducesNoEvent_andCheckpointAdvances() { + when(checkpointRepository.findByCheckpointKeyForUpdate(anyString())).thenReturn(Optional.of(cp(0L))); + when(auditLogRepository.findByIdAfterOrderByIdAsc(eq(0L), any())) + .thenReturn(List.of(audit(9L, 7L, "REPORT_RATE_LIMITED", T))); + when(ruleRepository.findAllByEnabledTrue()).thenReturn(List.of()); // 规则被禁用 → 无规则 + + assertEquals(0, detector.scanOnce()); + verify(eventRepository, never()).saveAndFlush(any()); + assertEquals(9L, checkpointCapture().getLastAuditLogId()); + } + + @Test + void repeatsScanWithoutDuplicateEvent() { + ScoringRuleConfig rule = rule(1L, "REPORT_RATE_LIMITED", 1, 60, true); + // 第一次扫描:创建事件并推进到 10 + when(checkpointRepository.findByCheckpointKeyForUpdate(anyString())) + .thenReturn(Optional.of(cp(0L)), Optional.of(cp(10L))); + when(auditLogRepository.findByIdAfterOrderByIdAsc(eq(0L), any())) + .thenReturn(List.of(audit(10L, 7L, "REPORT_RATE_LIMITED", T))); + when(auditLogRepository.findByIdAfterOrderByIdAsc(eq(10L), any())).thenReturn(List.of()); + when(ruleRepository.findAllByEnabledTrue()).thenReturn(List.of(rule)); + when(auditLogRepository.countByUserIdAndActionAndCreatedAtBetween(eq(7L), eq("REPORT_RATE_LIMITED"), any(), any())).thenReturn(1L); + when(eventRepository.existsByRuleIdAndSourceAuditLogId(1L, 10L)).thenReturn(false); + when(eventRepository.saveAndFlush(any())).thenAnswer(inv -> inv.getArgument(0)); + + assertEquals(1, detector.scanOnce()); + assertEquals(0, detector.scanOnce()); + verify(eventRepository, times(1)).saveAndFlush(any()); + } + + @Test + void auditWithNullUserIsSkipped() { + ScoringRuleConfig rule = rule(1L, "REPORT_RATE_LIMITED", 1, 60, true); + when(checkpointRepository.findByCheckpointKeyForUpdate(anyString())).thenReturn(Optional.of(cp(0L))); + when(auditLogRepository.findByIdAfterOrderByIdAsc(eq(0L), any())).thenReturn(List.of( + AuditLog.builder().id(11L).userId(null).action("REPORT_RATE_LIMITED").createdAt(T).build())); + when(ruleRepository.findAllByEnabledTrue()).thenReturn(List.of(rule)); + + assertEquals(0, detector.scanOnce()); + verify(eventRepository, never()).saveAndFlush(any()); + assertEquals(11L, checkpointCapture().getLastAuditLogId()); + } + + private ScoringRiskScanCheckpoint checkpointCapture() { + ArgumentCaptor cap = ArgumentCaptor.forClass(ScoringRiskScanCheckpoint.class); + verify(checkpointRepository, times(1)).save(cap.capture()); + return cap.getValue(); + } +} \ No newline at end of file diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/risk/RiskGovernancePersistenceIntegrationTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/risk/RiskGovernancePersistenceIntegrationTest.java new file mode 100644 index 0000000..ea2f635 --- /dev/null +++ b/mydreamjob-server/src/test/java/com/mydreamjob/risk/RiskGovernancePersistenceIntegrationTest.java @@ -0,0 +1,334 @@ +package com.mydreamjob.risk; + +import com.mydreamjob.audit.AuditContext; +import com.mydreamjob.audit.AuditLog; +import com.mydreamjob.audit.AuditLogRepository; +import com.mydreamjob.auth.User; +import com.mydreamjob.auth.UserRepository; +import com.mydreamjob.candidatescore.CandidateDimensionScoreRepository; +import com.mydreamjob.risk.dto.RiskEventActionRequest; +import com.mydreamjob.risk.dto.RiskRuleUpdateRequest; +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 RiskGovernancePersistenceIntegrationTest { + + private static final AtomicLong PHONE = new AtomicLong(8000); + private static final LocalDateTime NOW = LocalDateTime.of(2026, 9, 3, 8, 0, 0); + + @Autowired private RiskEventDetector detector; + @Autowired private RiskGovernanceService service; + @Autowired private ScoringRuleConfigRepository ruleRepository; + @Autowired private ScoringRiskEventRepository eventRepository; + @Autowired private ScoringRiskActionRepository actionRepository; + @Autowired private ScoringRiskScanCheckpointRepository checkpointRepository; + @Autowired private AuditLogRepository auditLogRepository; + @Autowired private UserRepository userRepository; + @Autowired private CandidateDimensionScoreRepository dimensionScoreRepository; + + @MockBean private RedisTemplate redisTemplate; + @MockBean private Clock clock; + + private final AuditContext ctx = AuditContext.builder().requestId("req").build(); + private final List createdUsers = new ArrayList<>(); + private final List createdAuditIds = new ArrayList<>(); + private Long userA; + + @BeforeEach + void setUp() { + when(redisTemplate.opsForValue()).thenReturn(mock(org.springframework.data.redis.core.ValueOperations.class)); + when(redisTemplate.hasKey(any())).thenReturn(false); + when(clock.getZone()).thenReturn(ZoneId.of("Asia/Shanghai")); + when(clock.instant()).thenReturn(NOW.atZone(ZoneId.of("Asia/Shanghai")).toInstant()); + userA = createUser(); + resetSeedRules(); + resetRiskState(); + } + + /** 规则是共享持久化种子,跨测试/跨类可能被改阈值;强制回到初始值,保证扫描断言确定性。 */ + private void resetSeedRules() { + resetRule("REPORT_RATE_LIMIT_BURST", 1, 60, "HIGH"); + resetRule("BLOCKED_MESSAGE_BURST", 3, 15, "MEDIUM"); + } + + private void resetRule(String code, int threshold, int window, String severity) { + ruleRepository.findByRuleCode(code).ifPresent(rule -> { + rule.setThresholdCount(threshold); + rule.setWindowMinutes(window); + rule.setSeverity(severity); + rule.setEnabled(true); + ruleRepository.save(rule); + }); + } + + @AfterEach + void tearDown() { + for (Long id : createdUsers) { + userRepository.deleteById(id); + } + createdUsers.clear(); + resetRiskState(); + } + + /** 清空风险事件/操作,并将 checkpoint 重置为当前最大审计 id(忽略历史审计,避免串扰)。 */ + private void resetRiskState() { + actionRepository.deleteAll(); + eventRepository.deleteAll(); + long base = maxAuditId(); + ScoringRiskScanCheckpoint cp = checkpointRepository.findByCheckpointKey("RISK_EVENT_DETECTOR").orElseGet( + () -> checkpointRepository.save(ScoringRiskScanCheckpoint.builder() + .checkpointKey("RISK_EVENT_DETECTOR").lastAuditLogId(0L).build())); + cp.setLastAuditLogId(base); + checkpointRepository.save(cp); + createdAuditIds.clear(); + } + + private long maxAuditId() { + return auditLogRepository.findAll().stream().mapToLong(AuditLog::getId).max().orElse(0L); + } + + private Long createUser() { + User u = userRepository.save(User.builder() + .phone("20" + 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 AuditLog insertAudit(Long userId, String action, LocalDateTime createdAt) { + AuditLog a = auditLogRepository.save(AuditLog.builder() + .userId(userId).action(action).entityType("USER").entityId(userId) + .details("{\"fromScanTest\":true}").requestId("req").createdAt(createdAt).build()); + createdAuditIds.add(a.getId()); + return a; + } + + private ScoringRuleConfig rule(String code) { + return ruleRepository.findByRuleCode(code).orElseThrow(); + } + + @Test + void flyway_seedsRulesAndCheckpoint_andIndexApplied() { + assertEquals(2, ruleRepository.findAll().size()); + assertTrue(ruleRepository.findByRuleCode("REPORT_RATE_LIMIT_BURST").isPresent()); + assertTrue(ruleRepository.findByRuleCode("BLOCKED_MESSAGE_BURST").isPresent()); + ScoringRiskScanCheckpoint cp = checkpointRepository.findByCheckpointKey("RISK_EVENT_DETECTOR").orElseThrow(); + assertNotNull(cp.getLastAuditLogId()); + } + + @Test + void scanDetectsReportRateLimited_andWritesAudit_noDuplicate() { + insertAudit(userA, "REPORT_RATE_LIMITED", NOW.minusMinutes(1)); + int created = detector.scanOnce(); + assertEquals(1, created); + // 重复扫描不重复建事件 + assertEquals(0, detector.scanOnce()); + List events = eventRepository.findAll(); + assertEquals(1, events.size()); + ScoringRiskEvent ev = events.get(0); + assertEquals(rule("REPORT_RATE_LIMIT_BURST").getId(), ev.getRule().getId()); + assertEquals("REPORT_RATE_LIMITED", ev.getSourceAction()); + assertEquals("HIGH", ev.getSeverity()); + assertEquals(ScoringRiskEvent.STATUS_OPEN, ev.getStatus()); + // subjectUserId 入库但不暴露给响应 DTO(白名单已保证) + assertNotNull(ev.getSubjectUserId()); + // RISK_EVENT_DETECTED 审计脱敏:必存在一条(取最新),且不含用户手机/详情/IP/敏感字段 + String det = auditLogRepository.findAll().stream() + .filter(a -> "RISK_EVENT_DETECTED".equals(a.getAction())) + .max(java.util.Comparator.comparing(AuditLog::getId)) + .orElseThrow(() -> new AssertionError("missing RISK_EVENT_DETECTED audit")) + .getDetails(); + assertTrue(det.contains("\"riskEventId\"")); + assertTrue(det.contains("\"ruleCode\"")); + assertTrue(det.contains("\"severity\"")); + assertTrue(det.contains("\"occurrenceCount\"")); + assertFalse(det.contains("subjectUserId")); + assertFalse(det.contains("fromScanTest"), "不得复制原始审计 details"); + } + + @Test + void detectedAudit_isLinkedToEvent_andRedacted() { + // 单一 REPORT_RATE_LIMITED(threshold=1,确定性恰好 1 条事件)验证每条事件的检测审计可精确定位 + insertAudit(userA, "REPORT_RATE_LIMITED", NOW.minusMinutes(3)); + detector.scanOnce(); + + List events = eventRepository.findAll(); + assertFalse(events.isEmpty(), "at least one risk event created"); + + for (ScoringRiskEvent ev : events) { + List linked = auditLogRepository.findAll().stream() + .filter(a -> "RISK_EVENT_DETECTED".equals(a.getAction())) + .filter(a -> a.getEntityId() != null && a.getEntityId().equals(ev.getId())) + .toList(); + // 每个新建事件恰好一条可定位的检测审计 + assertEquals(1, linked.size(), "event " + ev.getId() + " must have exactly one linked audit"); + assertEquals(ev.getId(), linked.get(0).getEntityId(), "audit entity_id == event id"); + + String det = linked.get(0).getDetails(); + // details.riskEventId 非空且等于同一 id + assertTrue(det.contains("\"riskEventId\":" + ev.getId()), "details.riskEventId == event id"); + // 审计 details 脱敏 + assertFalse(det.contains("subjectUserId"), "no subjectUserId"); + assertFalse(det.contains("phone"), "no phone"); + assertFalse(det.contains("email"), "no email"); + assertFalse(det.contains("ipAddress"), "no ipAddress"); + assertFalse(det.contains("userAgent"), "no userAgent"); + assertFalse(det.contains("token"), "no token"); + assertFalse(det.contains("password"), "no password"); + assertFalse(det.contains("secret"), "no secret"); + } + // 不得存在 entity_id 为 NULL 且无法定位的检测审计 + assertFalse(auditLogRepository.findAll().stream().anyMatch(a -> + "RISK_EVENT_DETECTED".equals(a.getAction()) && a.getEntityId() == null), + "no RISK_EVENT_DETECTED audit with NULL entity_id"); + } + + @Test + void scanCheckpointAdvanceAndResume() { + // REPORT threshold=1:单条命中稳定生成 1 条事件;随后追加的同窗口命中不再生成(count>threshold), + // 借此确定性验证 checkpoint 断点续扫(不依赖 createdAt 排序/窗口多条目阈值)。 + insertAudit(userA, "REPORT_RATE_LIMITED", NOW.minusMinutes(2)); + assertEquals(1, detector.scanOnce(), "单条 REPORT 命中 threshold=1"); + long cpAfterFirst = checkpointRepository.findByCheckpointKey("RISK_EVENT_DETECTOR").orElseThrow().getLastAuditLogId(); + // 追加两条同用户同窗口 REPORT:count>1 != 阈值 → 不再生成事件,仅推进 checkpoint + insertAudit(userA, "REPORT_RATE_LIMITED", NOW.minusMinutes(1)); + insertAudit(userA, "REPORT_RATE_LIMITED", NOW); + assertEquals(0, detector.scanOnce(), "追加命中 count>threshold 不刷屏"); + long cpAfterSecond = checkpointRepository.findByCheckpointKey("RISK_EVENT_DETECTOR").orElseThrow().getLastAuditLogId(); + assertTrue(cpAfterSecond > cpAfterFirst, "checkpoint 应续扫前进"); + assertEquals(1, eventRepository.findAll().size(), "始终只生成一条事件"); + } + + @Test + void statusMachine_andActionAudit_andTerminal() { + insertAudit(userA, "REPORT_RATE_LIMITED", NOW.minusMinutes(1)); + detector.scanOnce(); + ScoringRiskEvent ev = eventRepository.findAll().get(0); + RiskEventActionRequest ack = action("ACKNOWLEDGE", "MONITORING", 0L); + var afterAck = service.actionEvent(ev.getId(), ack, userA, ctx); + assertEquals(ScoringRiskEvent.STATUS_ACKNOWLEDGED, afterAck.getStatus()); + assertEquals(1, actionRepository.findAll().size()); + + // ACKNOWLEDGED → DISMISS + long v = afterAck.getVersion(); + var afterDism = service.actionEvent(ev.getId(), action("DISMISS", "CONFIRMED_FALSE_POSITIVE", v), userA, ctx); + assertEquals(ScoringRiskEvent.STATUS_DISMISSED, afterDism.getStatus()); + assertEquals(2, actionRepository.findAll().size()); + + // DISMISSED 终态:再次操作被拒 + assertThrows(com.mydreamjob.common.exception.BusinessException.class, + () -> service.actionEvent(ev.getId(), action("ACKNOWLEDGE", "MONITORING", afterDism.getVersion()), userA, ctx)); + + // RISK_EVENT_ACTIONED 审计不包含敏感字段 + List acted = auditLogRepository.findAll().stream() + .filter(a -> "RISK_EVENT_ACTIONED".equals(a.getAction())).toList(); + assertFalse(acted.isEmpty()); + assertFalse(acted.get(0).getDetails().contains("subjectUserId")); + } + + @Test + void ruleUpdate_persists_andRespectsVersion() { + ScoringRuleConfig rc = rule("BLOCKED_MESSAGE_BURST"); + RiskRuleUpdateRequest req = new RiskRuleUpdateRequest(); + req.setEnabled(false); + req.setThresholdCount(5); + req.setWindowMinutes(60); + req.setSeverity("LOW"); + req.setExpectedVersion(rc.getVersion()); + var updated = service.updateRule("BLOCKED_MESSAGE_BURST", req, userA, ctx); + assertEquals(false, updated.getEnabled()); + assertEquals(5, updated.getThresholdCount()); + // 重新加载确认持久化 + ScoringRuleConfig reloaded = rule("BLOCKED_MESSAGE_BURST"); + assertFalse(reloaded.getEnabled()); + assertEquals(5, reloaded.getThresholdCount()); + // 版本已推进 + assertNotEquals(0L, reloaded.getVersion().longValue()); + // 非法版本 → 9903 + RiskRuleUpdateRequest stale = new RiskRuleUpdateRequest(); + stale.setEnabled(true); stale.setThresholdCount(3); stale.setWindowMinutes(15); + stale.setSeverity("MEDIUM"); stale.setExpectedVersion(0L); + com.mydreamjob.common.exception.BusinessException ex = assertThrows( + com.mydreamjob.common.exception.BusinessException.class, + () -> service.updateRule("BLOCKED_MESSAGE_BURST", stale, userA, ctx)); + assertEquals(com.mydreamjob.common.exception.ErrorCode.RISK_RULE_CONCURRENT_UPDATE.getCode(), ex.getErrorCodeInt()); + } + + @Test + void candidateDimensionScores_unchanged() { + long base = dimensionScoreRepository.count(); + insertAudit(userA, "REPORT_RATE_LIMITED", NOW.minusMinutes(1)); + detector.scanOnce(); + assertEquals(base, dimensionScoreRepository.count(), "风险扫描不得写 candidate_dimension_scores"); + } + + @Test + void concurrentScan_onlyOneEvent_no500() throws Exception { + insertAudit(userA, "REPORT_RATE_LIMITED", NOW.minusMinutes(1)); + int n = 2; + ExecutorService pool = Executors.newFixedThreadPool(n); + CountDownLatch start = new CountDownLatch(1); + CountDownLatch done = new CountDownLatch(n); + ConcurrentLinkedQueue created = new ConcurrentLinkedQueue<>(); + for (int i = 0; i < n; i++) { + pool.submit(() -> { + await(start); + try { + created.add(detector.scanOnce()); + } catch (Exception e) { + created.add(-1); + } finally { + done.countDown(); + } + }); + } + start.countDown(); + assertTrue(done.await(30, TimeUnit.SECONDS)); + pool.shutdown(); + assertFalse(created.contains(-1), "no 500"); + assertEquals(1, eventRepository.findAll().size(), "并发扫描只生成一条事件"); + } + + private void await(CountDownLatch latch) { + try { + latch.await(); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + } + + private RiskEventActionRequest action(String a, String reason, long version) { + RiskEventActionRequest r = new RiskEventActionRequest(); + r.setAction(a); + r.setReasonCode(reason); + r.setExpectedVersion(version); + return r; + } +} \ No newline at end of file diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/risk/RiskGovernanceServiceTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/risk/RiskGovernanceServiceTest.java new file mode 100644 index 0000000..7a8dd02 --- /dev/null +++ b/mydreamjob-server/src/test/java/com/mydreamjob/risk/RiskGovernanceServiceTest.java @@ -0,0 +1,241 @@ +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.RiskEventActionRequest; +import com.mydreamjob.risk.dto.RiskRuleUpdateRequest; +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 org.springframework.data.domain.PageRequest; +import org.springframework.dao.OptimisticLockingFailureException; + +import java.time.Clock; +import java.time.Instant; +import java.time.LocalDateTime; +import java.time.ZoneId; +import java.util.List; +import java.util.Optional; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.lenient; +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 RiskGovernanceServiceTest { + + @Mock private ScoringRuleConfigRepository ruleRepository; + @Mock private ScoringRiskEventRepository eventRepository; + @Mock private ScoringRiskActionRepository actionRepository; + @Mock private AuditService auditService; + @Mock private Clock clock; + @InjectMocks private RiskGovernanceService service; + + private final AuditContext ctx = AuditContext.builder().requestId("r").build(); + private static final LocalDateTime FROM = LocalDateTime.of(2026, 8, 27, 0, 0); + private static final LocalDateTime TO = LocalDateTime.of(2026, 9, 3, 0, 0); + + @BeforeEach + void setUp() { + lenient().when(clock.getZone()).thenReturn(ZoneId.of("Asia/Shanghai")); + lenient().when(clock.instant()).thenReturn(Instant.parse("2026-09-03T00:00:00Z")); + } + + private ScoringRuleConfig rule(long version) { + ScoringRuleConfig r = ScoringRuleConfig.builder().id(1L).ruleCode("BLOCKED_MESSAGE_BURST") + .sourceAction("BLOCKED_SEND_PROHIBITED").thresholdCount(3).windowMinutes(15) + .severity("MEDIUM").enabled(true).version(version).build(); + return r; + } + + private ScoringRiskEvent event(String status, long version) { + return ScoringRiskEvent.builder().id(1L).rule(rule(0L)).subjectUserId(7L) + .sourceAuditLogId(10L).sourceAction("BLOCKED_SEND_PROHIBITED").severity("MEDIUM") + .windowStartedAt(FROM).windowEndedAt(TO).occurrenceCount(3) + .status(status).detectedAt(TO).version(version).build(); + } + + // ---- 规则更新 ---- + + @Test + void updateRule_success() { + when(ruleRepository.findByRuleCode("BLOCKED_MESSAGE_BURST")).thenReturn(Optional.of(rule(2L))); + when(ruleRepository.saveAndFlush(any())).thenAnswer(inv -> inv.getArgument(0)); + RiskRuleUpdateRequest req = req(3, 15, "MEDIUM", true, 2L); + var resp = service.updateRule("BLOCKED_MESSAGE_BURST", req, 9L, ctx); + assertEquals("BLOCKED_MESSAGE_BURST", resp.getRuleCode()); + verify(auditService, times(1)).log(eq(9L), eq("RISK_RULE_UPDATED"), eq("SCORING_RULE_CONFIG"), eq(1L), anyString(), any()); + } + + @Test + void updateRule_staleVersion_returns9903() { + when(ruleRepository.findByRuleCode("RC")).thenReturn(Optional.of(rule(2L))); + BusinessException ex = assertThrows(BusinessException.class, + () -> service.updateRule("RC", req(3, 15, "MEDIUM", true, 1L), 9L, ctx)); + assertEquals(ErrorCode.RISK_RULE_CONCURRENT_UPDATE.getCode(), ex.getErrorCodeInt()); + } + + @Test + void updateRule_notFound_returns9901() { + when(ruleRepository.findByRuleCode("NOPE")).thenReturn(Optional.empty()); + BusinessException ex = assertThrows(BusinessException.class, + () -> service.updateRule("NOPE", req(3, 15, "MEDIUM", true, 0L), 9L, ctx)); + assertEquals(ErrorCode.RISK_RULE_NOT_FOUND.getCode(), ex.getErrorCodeInt()); + } + + @Test + void updateRule_optimisticLock_returns9903() { + when(ruleRepository.findByRuleCode("RC")).thenReturn(Optional.of(rule(0L))); + when(ruleRepository.saveAndFlush(any())).thenThrow(new OptimisticLockingFailureException("x")); + BusinessException ex = assertThrows(BusinessException.class, + () -> service.updateRule("RC", req(3, 15, "MEDIUM", true, 0L), 9L, ctx)); + assertEquals(ErrorCode.RISK_RULE_CONCURRENT_UPDATE.getCode(), ex.getErrorCodeInt()); + } + + // ---- 事件人工操作 / 状态机 ---- + + @Test + void actionEvent_openAcknowledge() { + when(eventRepository.findById(1L)).thenReturn(Optional.of(event("OPEN", 0L))); + when(eventRepository.saveAndFlush(any())).thenAnswer(inv -> inv.getArgument(0)); + when(actionRepository.save(any())).thenReturn(null); + var resp = service.actionEvent(1L, action("ACKNOWLEDGE", "MONITORING", 0L), 9L, ctx); + assertEquals("ACKNOWLEDGED", resp.getStatus()); + verify(auditService, times(1)).log(eq(9L), eq("RISK_EVENT_ACTIONED"), eq("SCORING_RISK_EVENT"), eq(1L), anyString(), any()); + } + + @Test + void actionEvent_openDismiss() { + when(eventRepository.findById(1L)).thenReturn(Optional.of(event("OPEN", 0L))); + when(eventRepository.saveAndFlush(any())).thenAnswer(inv -> inv.getArgument(0)); + when(actionRepository.save(any())).thenReturn(null); + assertEquals("DISMISSED", service.actionEvent(1L, action("DISMISS", "MONITORING", 0L), 9L, ctx).getStatus()); + } + + @Test + void actionEvent_acknowledgedDismiss() { + when(eventRepository.findById(1L)).thenReturn(Optional.of(event("ACKNOWLEDGED", 1L))); + when(eventRepository.saveAndFlush(any())).thenAnswer(inv -> inv.getArgument(0)); + when(actionRepository.save(any())).thenReturn(null); + assertEquals("DISMISSED", service.actionEvent(1L, action("DISMISS", "MONITORING", 1L), 9L, ctx).getStatus()); + } + + @Test + void actionEvent_terminalDismissed_isRejected9905() { + when(eventRepository.findById(1L)).thenReturn(Optional.of(event("DISMISSED", 5L))); + BusinessException ex = assertThrows(BusinessException.class, + () -> service.actionEvent(1L, action("ACKNOWLEDGE", "MONITORING", 5L), 9L, ctx)); + assertEquals(ErrorCode.RISK_INVALID_ACTION.getCode(), ex.getErrorCodeInt()); + } + + @Test + void actionEvent_invalidAction_returns9905() { + BusinessException ex = assertThrows(BusinessException.class, + () -> service.actionEvent(1L, action("AUTO_BAN", "MONITORING", 0L), 9L, ctx)); + assertEquals(ErrorCode.RISK_INVALID_ACTION.getCode(), ex.getErrorCodeInt()); + } + + @Test + void actionEvent_invalidReason_returns9905() { + BusinessException ex = assertThrows(BusinessException.class, + () -> service.actionEvent(1L, action("DISMISS", "FREE_TEXT_NOTE", 0L), 9L, ctx)); + assertEquals(ErrorCode.RISK_INVALID_ACTION.getCode(), ex.getErrorCodeInt()); + } + + @Test + void actionEvent_notFound_returns9902() { + when(eventRepository.findById(99L)).thenReturn(Optional.empty()); + BusinessException ex = assertThrows(BusinessException.class, + () -> service.actionEvent(99L, action("ACKNOWLEDGE", "MONITORING", 0L), 9L, ctx)); + assertEquals(ErrorCode.RISK_EVENT_NOT_FOUND.getCode(), ex.getErrorCodeInt()); + } + + @Test + void actionEvent_staleVersion_returns9904() { + when(eventRepository.findById(1L)).thenReturn(Optional.of(event("OPEN", 0L))); + BusinessException ex = assertThrows(BusinessException.class, + () -> service.actionEvent(1L, action("ACKNOWLEDGE", "MONITORING", 7L), 9L, ctx)); + assertEquals(ErrorCode.RISK_EVENT_CONCURRENT_UPDATE.getCode(), ex.getErrorCodeInt()); + } + + // ---- 查询 / 过滤 ---- + + @Test + void events_invalidRange_returns9906() { + BusinessException ex = assertThrows(BusinessException.class, + () -> service.events(0, 20, null, null, null, TO, FROM)); + assertEquals(ErrorCode.RISK_INVALID_FILTER.getCode(), ex.getErrorCodeInt()); + } + + @Test + void events_invalidSeverity_returns9906() { + BusinessException ex = assertThrows(BusinessException.class, + () -> service.events(0, 20, null, "CRITICAL", null, FROM, TO)); + assertEquals(ErrorCode.RISK_INVALID_FILTER.getCode(), ex.getErrorCodeInt()); + } + + @Test + void events_badPageSize_returns1001() { + BusinessException ex = assertThrows(BusinessException.class, + () -> service.events(0, 51, null, null, null, FROM, TO)); + assertEquals(ErrorCode.BAD_REQUEST.getCode(), ex.getErrorCodeInt()); + } + + @Test + void events_joinsRule_noN1() { + ScoringRiskEvent ev = event("OPEN", 0L); + when(eventRepository.search(any(), any(), any(), any(), any(), any(PageRequest.class))) + .thenReturn(new PageImpl<>(List.of(ev))); + Page page = service.events(0, 20, null, null, null, FROM, TO); + assertEquals(1, page.getContent().size()); + // search 使用 JOIN FETCH,不应再逐事件查询规则 + verify(ruleRepository, never()).findById(anyLong()); + } + + @Test + void overview_usesProjections() { + when(eventRepository.countByStatusAndDetectedAtBetween(anyString(), any(), any())).thenReturn(5L); + when(eventRepository.countGroupBySeverity(anyString(), any(), any())).thenReturn(List.of( + new Object[]{"HIGH", 5L})); + when(eventRepository.countGroupByRuleCode(anyString(), any(), any())).thenReturn(List.of( + new Object[]{"REPORT_RATE_LIMIT_BURST", 5L})); + when(eventRepository.findMaxDetectedAtByStatusBetween(anyString(), any(), any())).thenReturn(TO); + var resp = service.overview(FROM, TO); + assertEquals(5L, resp.getOpenCount()); + verify(ruleRepository, never()).findById(anyLong()); + } + + private RiskRuleUpdateRequest req(int threshold, int window, String sev, boolean enabled, long version) { + RiskRuleUpdateRequest r = new RiskRuleUpdateRequest(); + r.setThresholdCount(threshold); + r.setWindowMinutes(window); + r.setSeverity(sev); + r.setEnabled(enabled); + r.setExpectedVersion(version); + return r; + } + + private RiskEventActionRequest action(String a, String reason, long version) { + RiskEventActionRequest r = new RiskEventActionRequest(); + r.setAction(a); + r.setReasonCode(reason); + r.setExpectedVersion(version); + return r; + } +} \ No newline at end of file diff --git a/mydreamjob-server/src/test/resources/ability-question-bank-v0.1.json b/mydreamjob-server/src/test/resources/ability-question-bank-v0.1.json new file mode 100644 index 0000000..e70f2da --- /dev/null +++ b/mydreamjob-server/src/test/resources/ability-question-bank-v0.1.json @@ -0,0 +1,1064 @@ +{ + "bankCode": "WORKPLACE_ABILITY", + "bankVersion": "ability-v0.1", + "status": "LOCKED_FOR_IMPLEMENTATION", + "disclaimer": "本题库为自研工作情境题,用于自我了解和能力提升参考,不构成职业资格、正式招聘结论或永久能力标签。", + "questions": [ + { + "questionCode": "LO01", + "dimension": "LOGIC", + "competencyTag": "INFORMATION_ANALYSIS", + "itemType": "SITUATIONAL_JUDGMENT", + "stem": "销售报表显示本月销售额增长20%,财务报表只增长8%。你首先会怎么处理?", + "options": [ + {"optionId": "A", "text": "核对两份报表的统计口径、时间范围和原始数据来源"}, + {"optionId": "B", "text": "先取两个增长率的平均值作为汇报结果"}, + {"optionId": "C", "text": "先使用财务报表,并注明另一份报表存在差异"}, + {"optionId": "D", "text": "采用增长率较高的报表,因为结果更积极"} + ], + "answerKey": "A", + "scoringRule": {"A": 4, "B": 1, "C": 2, "D": 0}, + "explanation": "冲突数据应先统一口径并追溯原始来源。直接平均、择一或选择有利结果都会掩盖真正的数据问题。", + "difficulty": "EASY", + "discrimination": null, + "questionVersion": "ability-v0.1", + "source": "SELF_AUTHORED", + "active": true, + "position": 1 + }, + { + "questionCode": "LO02", + "dimension": "LOGIC", + "competencyTag": "INFORMATION_ANALYSIS", + "itemType": "SITUATIONAL_JUDGMENT", + "stem": "一项新页面的转化率从10%升到18%,但目前只有20名访问者。你会如何判断效果?", + "options": [ + {"optionId": "A", "text": "立即宣布新页面显著优于旧页面"}, + {"optionId": "B", "text": "扩大样本并与同口径对照组比较后再判断"}, + {"optionId": "C", "text": "继续观察,同时先把18%作为确定结论汇报"}, + {"optionId": "D", "text": "说明样本较小,只能给出暂定趋势而非确定结论"} + ], + "answerKey": "B", + "scoringRule": {"A": 0, "B": 4, "C": 1, "D": 2}, + "explanation": "小样本波动较大,应扩大样本并设置可比基线。仅提示不确定性有价值,但仍不足以验证变化是否来自页面本身。", + "difficulty": "MEDIUM", + "discrimination": null, + "questionVersion": "ability-v0.1", + "source": "SELF_AUTHORED", + "active": true, + "position": 2 + }, + { + "questionCode": "LO03", + "dimension": "LOGIC", + "competencyTag": "INFORMATION_ANALYSIS", + "itemType": "SEQUENCE_JUDGMENT", + "stem": "两份分析报告得出相反结论。以下哪组核查顺序最合理?", + "options": [ + {"optionId": "A", "text": "比较结论→询问作者→补充少量数据→决定采用哪份"}, + {"optionId": "B", "text": "选择更新的报告→删除旧报告→通知相关人员→结束核查"}, + {"optionId": "C", "text": "统一指标定义→核对原始数据→比较分析方法→复算并形成结论"}, + {"optionId": "D", "text": "先复算结果→再确认口径→挑选更可信作者→形成结论"} + ], + "answerKey": "C", + "scoringRule": {"A": 1, "B": 0, "C": 4, "D": 2}, + "explanation": "先统一定义,再核查数据和方法,最后复算,才能避免在不同口径上比较结论。", + "difficulty": "HARD", + "discrimination": null, + "questionVersion": "ability-v0.1", + "source": "SELF_AUTHORED", + "active": true, + "position": 3 + }, + { + "questionCode": "LO04", + "dimension": "LOGIC", + "competencyTag": "INFORMATION_ANALYSIS", + "itemType": "SITUATIONAL_JUDGMENT", + "stem": "必须当天决定是否上线,但关键用户数据尚未收齐。你会怎么做?", + "options": [ + {"optionId": "A", "text": "直接按个人经验上线,后续再看结果"}, + {"optionId": "B", "text": "延期所有工作,直到全部信息完整"}, + {"optionId": "C", "text": "让职位最高的人直接给出判断"}, + {"optionId": "D", "text": "列明已知、未知和关键假设,选择可回滚的小范围方案并设置观察指标"} + ], + "answerKey": "D", + "scoringRule": {"A": 2, "B": 1, "C": 0, "D": 4}, + "explanation": "信息不完整时应显式管理不确定性,并优先选择可回滚、可观察的决策,控制错误成本。", + "difficulty": "MEDIUM", + "discrimination": null, + "questionVersion": "ability-v0.1", + "source": "SELF_AUTHORED", + "active": true, + "position": 4 + }, + { + "questionCode": "LO05", + "dimension": "LOGIC", + "competencyTag": "CAUSAL_REASONING", + "itemType": "SITUATIONAL_JUDGMENT", + "stem": "促销活动上线后订单增长了25%。如何判断增长是否主要由活动带来?", + "options": [ + {"optionId": "A", "text": "比较同期趋势、对照群体并排查价格和流量等同时变化因素"}, + {"optionId": "B", "text": "活动和增长同时发生,因此可以判断活动有效"}, + {"optionId": "C", "text": "查看几条参与活动用户的正面反馈"}, + {"optionId": "D", "text": "只比较活动前一天和活动后一天订单量"} + ], + "answerKey": "A", + "scoringRule": {"A": 4, "B": 2, "C": 1, "D": 0}, + "explanation": "时间上的同时发生不等于因果关系。对照、趋势和混杂因素核查能更可靠地识别活动贡献。", + "difficulty": "EASY", + "discrimination": null, + "questionVersion": "ability-v0.1", + "source": "SELF_AUTHORED", + "active": true, + "position": 5 + }, + { + "questionCode": "LO06", + "dimension": "LOGIC", + "competencyTag": "CAUSAL_REASONING", + "itemType": "SITUATIONAL_JUDGMENT", + "stem": "系统发布新版本后出现大量错误。最合适的第一步是什么?", + "options": [ + {"optionId": "A", "text": "同时修改多个可疑模块,争取尽快碰到正确修复"}, + {"optionId": "B", "text": "先控制影响并保留日志,再对比发布前后变化定位原因"}, + {"optionId": "C", "text": "立即回滚,但不保留现场信息"}, + {"optionId": "D", "text": "等待更多用户反馈后再开始处理"} + ], + "answerKey": "B", + "scoringRule": {"A": 0, "B": 4, "C": 2, "D": 1}, + "explanation": "先止损并保存证据,再围绕版本变化定位原因,既控制业务影响,也避免破坏根因分析条件。", + "difficulty": "MEDIUM", + "discrimination": null, + "questionVersion": "ability-v0.1", + "source": "SELF_AUTHORED", + "active": true, + "position": 6 + }, + { + "questionCode": "LO07", + "dimension": "LOGIC", + "competencyTag": "CAUSAL_REASONING", + "itemType": "SITUATIONAL_JUDGMENT", + "stem": "数据显示加班时间越长的团队交付量越高。你会怎样解释?", + "options": [ + {"optionId": "A", "text": "说明加班一定能提升团队效率"}, + {"optionId": "B", "text": "建议所有团队立即增加加班时间"}, + {"optionId": "C", "text": "检查项目规模、人员数量和紧急程度等共同影响因素"}, + {"optionId": "D", "text": "先承认存在相关性,但暂不据此做因果决策"} + ], + "answerKey": "C", + "scoringRule": {"A": 1, "B": 0, "C": 4, "D": 2}, + "explanation": "交付量和加班可能同时受到项目规模等因素影响。相关性需要结合混杂因素和对照数据解释。", + "difficulty": "HARD", + "discrimination": null, + "questionVersion": "ability-v0.1", + "source": "SELF_AUTHORED", + "active": true, + "position": 7 + }, + { + "questionCode": "LO08", + "dimension": "LOGIC", + "competencyTag": "CAUSAL_REASONING", + "itemType": "SEQUENCE_JUDGMENT", + "stem": "你要验证“缩短注册表单能提高完成率”。哪组步骤最合理?", + "options": [ + {"optionId": "A", "text": "修改表单→全量上线→观察总注册量→询问用户感受"}, + {"optionId": "B", "text": "提出假设→直接上线→发现变化→补写指标定义"}, + {"optionId": "C", "text": "观察历史数据→修改多个流程→比较前后总量→形成结论"}, + {"optionId": "D", "text": "定义指标→设置随机对照→只改变表单长度→达到样本量后分析"} + ], + "answerKey": "D", + "scoringRule": {"A": 2, "B": 1, "C": 0, "D": 4}, + "explanation": "明确指标、设置对照并控制唯一变量,是验证页面变化与转化率之间因果关系的基本路径。", + "difficulty": "HARD", + "discrimination": null, + "questionVersion": "ability-v0.1", + "source": "SELF_AUTHORED", + "active": true, + "position": 8 + }, + { + "questionCode": "LO09", + "dimension": "LOGIC", + "competencyTag": "PROBLEM_SOLVING", + "itemType": "SITUATIONAL_JUDGMENT", + "stem": "你收到“把这个页面做得更好用”的任务。首先应该做什么?", + "options": [ + {"optionId": "A", "text": "明确目标用户、具体场景、当前问题和验收标准"}, + {"optionId": "B", "text": "先按常见设计规范修改一版"}, + {"optionId": "C", "text": "只询问对方喜欢什么颜色和布局"}, + {"optionId": "D", "text": "等待对方给出完整解决方案"} + ], + "answerKey": "A", + "scoringRule": {"A": 4, "B": 2, "C": 1, "D": 0}, + "explanation": "模糊需求应先转化为用户、场景、问题和验收条件,之后才能选择合适的实现方案。", + "difficulty": "EASY", + "discrimination": null, + "questionVersion": "ability-v0.1", + "source": "SELF_AUTHORED", + "active": true, + "position": 9 + }, + { + "questionCode": "LO10", + "dimension": "LOGIC", + "competencyTag": "PROBLEM_SOLVING", + "itemType": "SEQUENCE_JUDGMENT", + "stem": "面对一个复杂而陌生的问题,哪组处理顺序最合理?", + "options": [ + {"optionId": "A", "text": "寻找现成答案→直接实施→发现问题→再定义目标"}, + {"optionId": "B", "text": "明确目标→拆分子问题→确定输入输出→逐项验证并整合"}, + {"optionId": "C", "text": "先处理最熟悉部分→逐步扩大范围→最后确认是否解决目标"}, + {"optionId": "D", "text": "先写完整长方案→交给他人执行→根据反馈决定是否验证"} + ], + "answerKey": "B", + "scoringRule": {"A": 0, "B": 4, "C": 2, "D": 1}, + "explanation": "复杂问题需要先明确目标,再拆解和定义验证条件,避免局部工作很多却没有解决核心问题。", + "difficulty": "MEDIUM", + "discrimination": null, + "questionVersion": "ability-v0.1", + "source": "SELF_AUTHORED", + "active": true, + "position": 10 + }, + { + "questionCode": "LO11", + "dimension": "LOGIC", + "competencyTag": "PROBLEM_SOLVING", + "itemType": "SITUATIONAL_JUDGMENT", + "stem": "你提出的方案试运行后没有达到目标。下一步最合理的是?", + "options": [ + {"optionId": "A", "text": "继续扩大使用范围,以获得更多结果"}, + {"optionId": "B", "text": "认为执行人员没有严格遵循方案"}, + {"optionId": "C", "text": "对照预期与实际,检查假设、执行偏差和外部因素后再调整"}, + {"optionId": "D", "text": "先暂停扩大,并收集失败环节的数据"} + ], + "answerKey": "C", + "scoringRule": {"A": 1, "B": 0, "C": 4, "D": 2}, + "explanation": "方案无效时需要同时检查假设、执行和环境,而不是只扩大范围或归责于某一方。", + "difficulty": "HARD", + "discrimination": null, + "questionVersion": "ability-v0.1", + "source": "SELF_AUTHORED", + "active": true, + "position": 11 + }, + { + "questionCode": "LO12", + "dimension": "LOGIC", + "competencyTag": "PROBLEM_SOLVING", + "itemType": "SITUATIONAL_JUDGMENT", + "stem": "负责人要求估算一个尚未完全明确的任务工期。你会如何回答?", + "options": [ + {"optionId": "A", "text": "给出一个大致天数,并说明只是个人感觉"}, + {"optionId": "B", "text": "拒绝估算,等所有需求完全确定"}, + {"optionId": "C", "text": "参考类似任务,直接承诺相同工期"}, + {"optionId": "D", "text": "拆分已知范围,说明假设、区间、风险以及需要补充的信息"} + ], + "answerKey": "D", + "scoringRule": {"A": 2, "B": 1, "C": 0, "D": 4}, + "explanation": "在不确定条件下,区间估算加上假设和风险说明,比伪精确承诺或完全拒绝估算更可执行。", + "difficulty": "EASY", + "discrimination": null, + "questionVersion": "ability-v0.1", + "source": "SELF_AUTHORED", + "active": true, + "position": 12 + }, + { + "questionCode": "CO01", + "dimension": "COMMUNICATION", + "competencyTag": "LISTENING_UNDERSTANDING", + "itemType": "SITUATIONAL_JUDGMENT", + "stem": "对方提出一个比较模糊的合作需求。你首先会怎么回应?", + "options": [ + {"optionId": "A", "text": "用自己的话复述理解,并确认目标、对象、边界和优先级"}, + {"optionId": "B", "text": "根据过往经验直接开始准备方案"}, + {"optionId": "C", "text": "发送一份通用需求模板,请对方有空时补充"}, + {"optionId": "D", "text": "要求对方想清楚全部细节后再来沟通"} + ], + "answerKey": "A", + "scoringRule": {"A": 4, "B": 2, "C": 1, "D": 0}, + "explanation": "复述与确认能够尽早暴露理解偏差,同时帮助双方把模糊表达转化为共同目标和边界。", + "difficulty": "EASY", + "discrimination": null, + "questionVersion": "ability-v0.1", + "source": "SELF_AUTHORED", + "active": true, + "position": 13 + }, + { + "questionCode": "CO02", + "dimension": "COMMUNICATION", + "competencyTag": "LISTENING_UNDERSTANDING", + "itemType": "SITUATIONAL_JUDGMENT", + "stem": "同事正在说明一次工作失误及其困难。你最合适的做法是?", + "options": [ + {"optionId": "A", "text": "马上告诉对方正确做法,避免继续浪费时间"}, + {"optionId": "B", "text": "先听完并复述关键事实、影响和对方需要的支持"}, + {"optionId": "C", "text": "先听取主要内容,再提出自己的解决建议"}, + {"optionId": "D", "text": "保持沉默,让对方自己整理清楚"} + ], + "answerKey": "B", + "scoringRule": {"A": 0, "B": 4, "C": 2, "D": 1}, + "explanation": "先完整理解事实和需求,再讨论方案,能避免把建议建立在错误假设上,也能降低对方的防御感。", + "difficulty": "MEDIUM", + "discrimination": null, + "questionVersion": "ability-v0.1", + "source": "SELF_AUTHORED", + "active": true, + "position": 14 + }, + { + "questionCode": "CO03", + "dimension": "COMMUNICATION", + "competencyTag": "LISTENING_UNDERSTANDING", + "itemType": "SEQUENCE_JUDGMENT", + "stem": "三方对同一需求理解不同。哪组沟通顺序最合理?", + "options": [ + {"optionId": "A", "text": "各自发言→多数表决→记录结果→开始执行"}, + {"optionId": "B", "text": "负责人定调→其他人补充→形成文档→会后再澄清"}, + {"optionId": "C", "text": "分别复述理解→识别分歧点→对齐目标和证据→确认决定与责任"}, + {"optionId": "D", "text": "收集意见→整理共同点→由负责人决定→通知执行人员"} + ], + "answerKey": "C", + "scoringRule": {"A": 1, "B": 0, "C": 4, "D": 2}, + "explanation": "先让各方显式表达理解,再定位分歧并对齐依据,才能形成真正一致且可执行的结论。", + "difficulty": "HARD", + "discrimination": null, + "questionVersion": "ability-v0.1", + "source": "SELF_AUTHORED", + "active": true, + "position": 15 + }, + { + "questionCode": "CO04", + "dimension": "COMMUNICATION", + "competencyTag": "LISTENING_UNDERSTANDING", + "itemType": "SITUATIONAL_JUDGMENT", + "stem": "客户表达不满,但没有明确说明具体问题。你会怎么做?", + "options": [ + {"optionId": "A", "text": "先说明团队已经严格按流程完成工作"}, + {"optionId": "B", "text": "请客户提交正式投诉材料后再处理"}, + {"optionId": "C", "text": "直接提供补偿,尽快结束沟通"}, + {"optionId": "D", "text": "确认客户感受,再询问具体场景、期望和造成的影响"} + ], + "answerKey": "D", + "scoringRule": {"A": 2, "B": 1, "C": 0, "D": 4}, + "explanation": "先确认感受并提取具体事实,有助于把情绪表达转化为可以处理的问题和期望。", + "difficulty": "MEDIUM", + "discrimination": null, + "questionVersion": "ability-v0.1", + "source": "SELF_AUTHORED", + "active": true, + "position": 16 + }, + { + "questionCode": "CO05", + "dimension": "COMMUNICATION", + "competencyTag": "STRUCTURED_EXPRESSION", + "itemType": "SITUATIONAL_JUDGMENT", + "stem": "你需要向非技术同事解释一次系统故障。最合适的方式是?", + "options": [ + {"optionId": "A", "text": "先讲业务影响和处理进展,再用通俗类比说明原因并确认理解"}, + {"optionId": "B", "text": "完整说明技术栈、日志和异常调用链"}, + {"optionId": "C", "text": "只说系统有问题,修好后再通知"}, + {"optionId": "D", "text": "省略不确定性,给出一个肯定恢复时间"} + ], + "answerKey": "A", + "scoringRule": {"A": 4, "B": 2, "C": 1, "D": 0}, + "explanation": "面向非技术对象应从其关心的影响和行动出发,用适当语言说明原因,同时保留必要的不确定性。", + "difficulty": "EASY", + "discrimination": null, + "questionVersion": "ability-v0.1", + "source": "SELF_AUTHORED", + "active": true, + "position": 17 + }, + { + "questionCode": "CO06", + "dimension": "COMMUNICATION", + "competencyTag": "STRUCTURED_EXPRESSION", + "itemType": "SITUATIONAL_JUDGMENT", + "stem": "发送一条需要对方行动的重要工作消息,应该如何组织?", + "options": [ + {"optionId": "A", "text": "只写“请尽快处理”,减少阅读时间"}, + {"optionId": "B", "text": "说明背景、当前问题、所需行动、负责人和截止时间"}, + {"optionId": "C", "text": "先给结论和行动要求,再附上必要背景"}, + {"optionId": "D", "text": "把全部聊天记录转发过去,让对方自行理解"} + ], + "answerKey": "B", + "scoringRule": {"A": 0, "B": 4, "C": 2, "D": 1}, + "explanation": "清晰的工作消息应让接收者迅速知道为什么、做什么、谁负责以及何时完成。", + "difficulty": "MEDIUM", + "discrimination": null, + "questionVersion": "ability-v0.1", + "source": "SELF_AUTHORED", + "active": true, + "position": 18 + }, + { + "questionCode": "CO07", + "dimension": "COMMUNICATION", + "competencyTag": "STRUCTURED_EXPRESSION", + "itemType": "SITUATIONAL_JUDGMENT", + "stem": "你需要指出同事提交材料中的严重问题。怎样表达最有效?", + "options": [ + {"optionId": "A", "text": "直接说“你做事不够认真”"}, + {"optionId": "B", "text": "只修改材料,不再提及问题"}, + {"optionId": "C", "text": "描述具体问题、造成的影响、修改标准和可获得的支持"}, + {"optionId": "D", "text": "私下指出具体错误,并提醒以后注意"} + ], + "answerKey": "C", + "scoringRule": {"A": 1, "B": 0, "C": 4, "D": 2}, + "explanation": "高质量反馈针对可观察行为和影响,并给出明确标准与支持,不把问题上升为人格评价。", + "difficulty": "HARD", + "discrimination": null, + "questionVersion": "ability-v0.1", + "source": "SELF_AUTHORED", + "active": true, + "position": 19 + }, + { + "questionCode": "CO08", + "dimension": "COMMUNICATION", + "competencyTag": "STRUCTURED_EXPRESSION", + "itemType": "SEQUENCE_JUDGMENT", + "stem": "汇报一个延期项目时,哪组顺序最合理?", + "options": [ + {"optionId": "A", "text": "说明困难→解释原因→请求理解→承诺尽快完成"}, + {"optionId": "B", "text": "汇报已完成工作→省略延期风险→等待追问→再解释"}, + {"optionId": "C", "text": "说明延期→列出责任方→描述过程→等待负责人决定"}, + {"optionId": "D", "text": "先报结论和影响→说明原因与证据→给出方案→明确需要的决定"} + ], + "answerKey": "D", + "scoringRule": {"A": 2, "B": 1, "C": 0, "D": 4}, + "explanation": "延期汇报应优先呈现事实和影响,再提供可验证原因、解决方案和明确决策请求。", + "difficulty": "HARD", + "discrimination": null, + "questionVersion": "ability-v0.1", + "source": "SELF_AUTHORED", + "active": true, + "position": 20 + }, + { + "questionCode": "CO09", + "dimension": "COMMUNICATION", + "competencyTag": "COLLABORATION_CONFLICT", + "itemType": "SITUATIONAL_JUDGMENT", + "stem": "你与同事对方案选择意见相反。你会如何推进?", + "options": [ + {"optionId": "A", "text": "先确认共同目标,再区分事实与偏好,用统一标准比较方案"}, + {"optionId": "B", "text": "请更多同事投票,按人数决定"}, + {"optionId": "C", "text": "暂时接受对方方案,避免影响关系"}, + {"optionId": "D", "text": "重点证明自己的方案更专业"} + ], + "answerKey": "A", + "scoringRule": {"A": 4, "B": 2, "C": 1, "D": 0}, + "explanation": "以共同目标和一致评价标准处理分歧,能够把立场冲突转化为可比较的方案决策。", + "difficulty": "EASY", + "discrimination": null, + "questionVersion": "ability-v0.1", + "source": "SELF_AUTHORED", + "active": true, + "position": 21 + }, + { + "questionCode": "CO10", + "dimension": "COMMUNICATION", + "competencyTag": "COLLABORATION_CONFLICT", + "itemType": "SEQUENCE_JUDGMENT", + "stem": "跨团队依赖已经延期。哪种沟通方式最合理?", + "options": [ + {"optionId": "A", "text": "在公共群指出对方延期→要求承诺→抄送负责人→等待结果"}, + {"optionId": "B", "text": "确认事实和影响→了解阻塞→共同制定新节点→记录责任和升级条件"}, + {"optionId": "C", "text": "先私下询问进度→协商新时间→定期跟进"}, + {"optionId": "D", "text": "自行绕过依赖→完成后再告知对方"} + ], + "answerKey": "B", + "scoringRule": {"A": 0, "B": 4, "C": 2, "D": 1}, + "explanation": "跨团队协作需要共同确认事实、阻塞和新承诺,并提前约定何时升级,避免指责或信息缺失。", + "difficulty": "MEDIUM", + "discrimination": null, + "questionVersion": "ability-v0.1", + "source": "SELF_AUTHORED", + "active": true, + "position": 22 + }, + { + "questionCode": "CO11", + "dimension": "COMMUNICATION", + "competencyTag": "COLLABORATION_CONFLICT", + "itemType": "SITUATIONAL_JUDGMENT", + "stem": "同事临时要求你承担一项会影响原计划的工作。你会怎么回应?", + "options": [ + {"optionId": "A", "text": "直接答应,之后再想办法补原任务"}, + {"optionId": "B", "text": "直接拒绝,因为这不属于原计划"}, + {"optionId": "C", "text": "说明当前承诺和影响,确认优先级并提出替代时间或资源方案"}, + {"optionId": "D", "text": "先了解紧急程度,再决定是否帮助"} + ], + "answerKey": "C", + "scoringRule": {"A": 1, "B": 0, "C": 4, "D": 2}, + "explanation": "边界沟通既要说明已有承诺,也要讨论优先级和替代方案,避免无条件接受或简单拒绝。", + "difficulty": "HARD", + "discrimination": null, + "questionVersion": "ability-v0.1", + "source": "SELF_AUTHORED", + "active": true, + "position": 23 + }, + { + "questionCode": "CO12", + "dimension": "COMMUNICATION", + "competencyTag": "COLLABORATION_CONFLICT", + "itemType": "SITUATIONAL_JUDGMENT", + "stem": "你发现自己的表达让对方产生了误解。最合适的处理方式是?", + "options": [ + {"optionId": "A", "text": "详细解释当时为什么会那样表达"}, + {"optionId": "B", "text": "不再提及,避免让情况更尴尬"}, + {"optionId": "C", "text": "先做出对方希望的承诺,之后再调整"}, + {"optionId": "D", "text": "承认造成的影响,澄清真实意图并共同确认修正行动"} + ], + "answerKey": "D", + "scoringRule": {"A": 2, "B": 1, "C": 0, "D": 4}, + "explanation": "修复误解应同时处理影响、意图和后续行动,而不是只解释动机或给出无法兑现的承诺。", + "difficulty": "EASY", + "discrimination": null, + "questionVersion": "ability-v0.1", + "source": "SELF_AUTHORED", + "active": true, + "position": 24 + }, + { + "questionCode": "EX01", + "dimension": "EXECUTION", + "competencyTag": "PLANNING_PRIORITIZATION", + "itemType": "SITUATIONAL_JUDGMENT", + "stem": "三个任务都临近截止时间,但现有资源只能优先完成一个。你会怎么做?", + "options": [ + {"optionId": "A", "text": "按业务影响、紧急程度和依赖关系排序,并同步取舍方案"}, + {"optionId": "B", "text": "三个任务同时开始,尽量都推进一些"}, + {"optionId": "C", "text": "先完成最容易交付的任务"}, + {"optionId": "D", "text": "等待负责人主动指定优先级"} + ], + "answerKey": "A", + "scoringRule": {"A": 4, "B": 2, "C": 1, "D": 0}, + "explanation": "资源冲突时应基于影响、时效和依赖做明确取舍,并让相关方及时知道后果和安排。", + "difficulty": "EASY", + "discrimination": null, + "questionVersion": "ability-v0.1", + "source": "SELF_AUTHORED", + "active": true, + "position": 25 + }, + { + "questionCode": "EX02", + "dimension": "EXECUTION", + "competencyTag": "PLANNING_PRIORITIZATION", + "itemType": "SITUATIONAL_JUDGMENT", + "stem": "开始一个跨两周的新任务时,你首先会建立什么?", + "options": [ + {"optionId": "A", "text": "一份尽可能详细、覆盖所有可能情况的计划"}, + {"optionId": "B", "text": "明确交付结果、关键步骤、依赖、负责人和检查节点"}, + {"optionId": "C", "text": "先确定最终期限和几个主要步骤"}, + {"optionId": "D", "text": "先从最熟悉的部分开始,再逐步补计划"} + ], + "answerKey": "B", + "scoringRule": {"A": 0, "B": 4, "C": 2, "D": 1}, + "explanation": "可执行计划要连接目标、步骤、依赖、责任与检查点,既能推进,也能尽早发现偏差。", + "difficulty": "MEDIUM", + "discrimination": null, + "questionVersion": "ability-v0.1", + "source": "SELF_AUTHORED", + "active": true, + "position": 26 + }, + { + "questionCode": "EX03", + "dimension": "EXECUTION", + "competencyTag": "PLANNING_PRIORITIZATION", + "itemType": "SITUATIONAL_JUDGMENT", + "stem": "需求范围比预计大,但截止日期没有变化。你会怎样处理?", + "options": [ + {"optionId": "A", "text": "保持原计划,靠加班完成新增范围"}, + {"optionId": "B", "text": "先完成原范围,新增部分到期后再解释"}, + {"optionId": "C", "text": "评估新增工作量和影响,提出范围、资源或时间的取舍方案"}, + {"optionId": "D", "text": "记录需求变化,并提醒负责人可能延期"} + ], + "answerKey": "C", + "scoringRule": {"A": 1, "B": 0, "C": 4, "D": 2}, + "explanation": "范围、资源和时间相互制约。范围变化后应量化影响并推动明确取舍,而不是默默承担风险。", + "difficulty": "HARD", + "discrimination": null, + "questionVersion": "ability-v0.1", + "source": "SELF_AUTHORED", + "active": true, + "position": 27 + }, + { + "questionCode": "EX04", + "dimension": "EXECUTION", + "competencyTag": "PLANNING_PRIORITIZATION", + "itemType": "SEQUENCE_JUDGMENT", + "stem": "把一个目标转成可执行计划,哪组顺序最合理?", + "options": [ + {"optionId": "A", "text": "列任务→安排人员→确定目标→检查结果"}, + {"optionId": "B", "text": "确定期限→开始执行→补充资源→最后验收"}, + {"optionId": "C", "text": "选择工具→分配任务→同步进度→根据结果调整目标"}, + {"optionId": "D", "text": "明确结果→拆分任务→识别依赖和风险→设置节点与验收标准"} + ], + "answerKey": "D", + "scoringRule": {"A": 2, "B": 1, "C": 0, "D": 4}, + "explanation": "计划应从结果出发,再拆解任务、依赖、风险和验收节点,保证每一步都服务于最终目标。", + "difficulty": "MEDIUM", + "discrimination": null, + "questionVersion": "ability-v0.1", + "source": "SELF_AUTHORED", + "active": true, + "position": 28 + }, + { + "questionCode": "EX05", + "dimension": "EXECUTION", + "competencyTag": "RISK_FOLLOWUP", + "itemType": "SITUATIONAL_JUDGMENT", + "stem": "你发现一个依赖项可能导致项目延期。你会怎么管理这项风险?", + "options": [ + {"optionId": "A", "text": "记录触发条件、概率、影响、负责人和应对方案,并持续检查"}, + {"optionId": "B", "text": "在周会上提醒一次,让大家注意"}, + {"optionId": "C", "text": "先准备一个备用方案,但暂不告诉其他人"}, + {"optionId": "D", "text": "等风险真正发生后再处理,避免过度反应"} + ], + "answerKey": "A", + "scoringRule": {"A": 4, "B": 2, "C": 1, "D": 0}, + "explanation": "风险管理需要可观察的触发条件、责任人和预案,并持续跟踪,而不只是一次提醒或被动等待。", + "difficulty": "EASY", + "discrimination": null, + "questionVersion": "ability-v0.1", + "source": "SELF_AUTHORED", + "active": true, + "position": 29 + }, + { + "questionCode": "EX06", + "dimension": "EXECUTION", + "competencyTag": "RISK_FOLLOWUP", + "itemType": "SITUATIONAL_JUDGMENT", + "stem": "你的任务被另一个团队阻塞,且已经影响当前节点。你会怎么做?", + "options": [ + {"optionId": "A", "text": "绕开对方直接修改其负责内容"}, + {"optionId": "B", "text": "说明阻塞事实和影响,提出备选方案并约定升级时间"}, + {"optionId": "C", "text": "持续跟进对方进度,同时调整自己可先做的工作"}, + {"optionId": "D", "text": "记录阻塞,等负责人下次检查时再汇报"} + ], + "answerKey": "B", + "scoringRule": {"A": 0, "B": 4, "C": 2, "D": 1}, + "explanation": "处理阻塞不仅要跟进,还要透明说明影响、准备替代路径并约定升级条件,防止风险继续扩大。", + "difficulty": "MEDIUM", + "discrimination": null, + "questionVersion": "ability-v0.1", + "source": "SELF_AUTHORED", + "active": true, + "position": 30 + }, + { + "questionCode": "EX07", + "dimension": "EXECUTION", + "competencyTag": "RISK_FOLLOWUP", + "itemType": "SITUATIONAL_JUDGMENT", + "stem": "项目进度看似正常,但关键任务一直没有可验证产出。你会怎么处理?", + "options": [ + {"optionId": "A", "text": "继续相信负责人汇报,避免打断执行"}, + {"optionId": "B", "text": "增加每日进度汇报次数"}, + {"optionId": "C", "text": "把关键任务拆成可验收的小节点,用产出和风险重新评估进度"}, + {"optionId": "D", "text": "要求负责人尽快提供一个演示版本"} + ], + "answerKey": "C", + "scoringRule": {"A": 1, "B": 0, "C": 4, "D": 2}, + "explanation": "进度应由可验证产出而非主观百分比衡量。拆成小节点可以提前暴露真实偏差和风险。", + "difficulty": "HARD", + "discrimination": null, + "questionVersion": "ability-v0.1", + "source": "SELF_AUTHORED", + "active": true, + "position": 31 + }, + { + "questionCode": "EX08", + "dimension": "EXECUTION", + "competencyTag": "RISK_FOLLOWUP", + "itemType": "SEQUENCE_JUDGMENT", + "stem": "线上发生严重故障时,哪组执行顺序最合理?", + "options": [ + {"optionId": "A", "text": "查找根因→修改代码→通知用户→恢复服务"}, + {"optionId": "B", "text": "重启服务→观察结果→继续修改→故障后补记录"}, + {"optionId": "C", "text": "通知负责人→等待决定→组织排查→发布修复"}, + {"optionId": "D", "text": "控制影响→保留证据→同步状态→恢复服务→复盘根因"} + ], + "answerKey": "D", + "scoringRule": {"A": 2, "B": 1, "C": 0, "D": 4}, + "explanation": "故障处置首先控制影响,同时保留分析证据并保持信息同步;恢复后再完成系统性根因复盘。", + "difficulty": "HARD", + "discrimination": null, + "questionVersion": "ability-v0.1", + "source": "SELF_AUTHORED", + "active": true, + "position": 32 + }, + { + "questionCode": "EX09", + "dimension": "EXECUTION", + "competencyTag": "DELIVERY_REVIEW", + "itemType": "SITUATIONAL_JUDGMENT", + "stem": "截止时间临近,但交付物仍存在质量风险。你会怎么处理?", + "options": [ + {"optionId": "A", "text": "识别关键质量门槛,缩小非核心范围,分阶段交付并同步风险"}, + {"optionId": "B", "text": "优先保证所有功能完成,测试可以之后补"}, + {"optionId": "C", "text": "继续优化细节,必要时到期后再解释"}, + {"optionId": "D", "text": "直接推迟交付,直到所有问题完全解决"} + ], + "answerKey": "A", + "scoringRule": {"A": 4, "B": 0, "C": 1, "D": 2}, + "explanation": "临近截止时间应守住关键质量底线,并通过范围取舍和分阶段交付透明管理风险。", + "difficulty": "EASY", + "discrimination": null, + "questionVersion": "ability-v0.1", + "source": "SELF_AUTHORED", + "active": true, + "position": 33 + }, + { + "questionCode": "EX10", + "dimension": "EXECUTION", + "competencyTag": "DELIVERY_REVIEW", + "itemType": "SEQUENCE_JUDGMENT", + "stem": "向他人交接一项进行中的任务,哪组顺序最合理?", + "options": [ + {"optionId": "A", "text": "发送文件→说明截止日期→让对方有问题再问→退出任务"}, + {"optionId": "B", "text": "说明目标和现状→移交材料与风险→明确责任和节点→确认对方理解"}, + {"optionId": "C", "text": "口头说明主要事项→发送材料→约定一次后续检查"}, + {"optionId": "D", "text": "把任务重新做一遍→整理文件→通知对方结果"} + ], + "answerKey": "B", + "scoringRule": {"A": 0, "B": 4, "C": 2, "D": 1}, + "explanation": "有效交接需要共同理解目标、现状、材料、风险、责任和检查节点,而不仅是发送文件。", + "difficulty": "MEDIUM", + "discrimination": null, + "questionVersion": "ability-v0.1", + "source": "SELF_AUTHORED", + "active": true, + "position": 34 + }, + { + "questionCode": "EX11", + "dimension": "EXECUTION", + "competencyTag": "DELIVERY_REVIEW", + "itemType": "SITUATIONAL_JUDGMENT", + "stem": "任务完成后结果没有达到预期。最有价值的复盘方式是?", + "options": [ + {"optionId": "A", "text": "确定主要责任人并避免再次由其负责"}, + {"optionId": "B", "text": "记录最终结果,提醒团队以后更认真"}, + {"optionId": "C", "text": "比较目标与实际,分析过程证据,形成有负责人和期限的改进动作"}, + {"optionId": "D", "text": "讨论主要问题并总结经验,供下次参考"} + ], + "answerKey": "C", + "scoringRule": {"A": 1, "B": 0, "C": 4, "D": 2}, + "explanation": "复盘的价值在于从证据中找到可改变因素,并把经验转成有负责人和期限的行动。", + "difficulty": "HARD", + "discrimination": null, + "questionVersion": "ability-v0.1", + "source": "SELF_AUTHORED", + "active": true, + "position": 35 + }, + { + "questionCode": "EX12", + "dimension": "EXECUTION", + "competencyTag": "DELIVERY_REVIEW", + "itemType": "SITUATIONAL_JUDGMENT", + "stem": "开发工作已经完成,怎样才算真正完成交付闭环?", + "options": [ + {"optionId": "A", "text": "代码已经合并,可以把任务标记完成"}, + {"optionId": "B", "text": "部署完成且没有立即出现错误"}, + {"optionId": "C", "text": "把剩余事项记录在自己的待办中"}, + {"optionId": "D", "text": "按验收标准确认结果,补齐文档、监控、交接和明确遗留项"} + ], + "answerKey": "D", + "scoringRule": {"A": 1, "B": 2, "C": 0, "D": 4}, + "explanation": "完成实现不等于完成交付;验收、文档、运行保障、交接和遗留事项都应形成闭环。", + "difficulty": "EASY", + "discrimination": null, + "questionVersion": "ability-v0.1", + "source": "SELF_AUTHORED", + "active": true, + "position": 36 + }, + { + "questionCode": "LE01", + "dimension": "LEARNING", + "competencyTag": "INFORMATION_ACQUISITION", + "itemType": "SITUATIONAL_JUDGMENT", + "stem": "第一次接触一项工作需要的新工具时,你会怎么开始?", + "options": [ + {"optionId": "A", "text": "明确使用目标,阅读权威入门资料,完成小练习后应用到任务"}, + {"optionId": "B", "text": "先看几个热门视频,再边做边查"}, + {"optionId": "C", "text": "直接在正式任务中尝试,遇到问题再处理"}, + {"optionId": "D", "text": "请熟悉工具的同事替自己完成关键部分"} + ], + "answerKey": "A", + "scoringRule": {"A": 4, "B": 2, "C": 1, "D": 0}, + "explanation": "目标、可靠资料、小范围练习和真实应用形成完整学习闭环,能兼顾效率和风险。", + "difficulty": "EASY", + "discrimination": null, + "questionVersion": "ability-v0.1", + "source": "SELF_AUTHORED", + "active": true, + "position": 37 + }, + { + "questionCode": "LE02", + "dimension": "LEARNING", + "competencyTag": "INFORMATION_ACQUISITION", + "itemType": "SITUATIONAL_JUDGMENT", + "stem": "网上对同一技术方案存在互相矛盾的观点。你会怎样判断?", + "options": [ + {"optionId": "A", "text": "选择点赞和转发最多的观点"}, + {"optionId": "B", "text": "比较来源、发布时间、证据、适用条件,并做小范围验证"}, + {"optionId": "C", "text": "优先参考官方文档,再结合一项实践案例"}, + {"optionId": "D", "text": "选择与自己原有经验一致的观点"} + ], + "answerKey": "B", + "scoringRule": {"A": 0, "B": 4, "C": 2, "D": 1}, + "explanation": "可靠判断既需要评价信息来源和适用条件,也需要在自己的场景中验证,而不能只看热度或偏好。", + "difficulty": "MEDIUM", + "discrimination": null, + "questionVersion": "ability-v0.1", + "source": "SELF_AUTHORED", + "active": true, + "position": 38 + }, + { + "questionCode": "LE03", + "dimension": "LEARNING", + "competencyTag": "INFORMATION_ACQUISITION", + "itemType": "SEQUENCE_JUDGMENT", + "stem": "快速进入一个陌生业务领域,哪组学习顺序最合理?", + "options": [ + {"optionId": "A", "text": "收集大量资料→全部阅读→整理术语→开始参与工作"}, + {"optionId": "B", "text": "跟随一名同事→模仿做法→遇到问题再查资料→形成经验"}, + {"optionId": "C", "text": "明确任务问题→建立基础框架→结合案例实践→用反馈补齐知识"}, + {"optionId": "D", "text": "阅读概览→询问关键术语→尝试一项小任务→继续自学"} + ], + "answerKey": "C", + "scoringRule": {"A": 1, "B": 0, "C": 4, "D": 2}, + "explanation": "围绕真实问题建立知识框架,再通过实践和反馈迭代,比无目标囤积资料更有效。", + "difficulty": "HARD", + "discrimination": null, + "questionVersion": "ability-v0.1", + "source": "SELF_AUTHORED", + "active": true, + "position": 39 + }, + { + "questionCode": "LE04", + "dimension": "LEARNING", + "competencyTag": "INFORMATION_ACQUISITION", + "itemType": "SITUATIONAL_JUDGMENT", + "stem": "你只有两小时了解一个新主题并给出初步建议。你会怎么安排?", + "options": [ + {"optionId": "A", "text": "尽量阅读更多文章,以覆盖所有观点"}, + {"optionId": "B", "text": "询问熟悉主题的人并直接采用其建议"}, + {"optionId": "C", "text": "先写出自己的看法,再搜索资料验证"}, + {"optionId": "D", "text": "明确输出问题,优先权威概览和关键证据,并标明未知与后续验证项"} + ], + "answerKey": "D", + "scoringRule": {"A": 2, "B": 1, "C": 0, "D": 4}, + "explanation": "时间有限时,应围绕输出目标选择高价值信息,并明确区分结论、假设和待验证内容。", + "difficulty": "MEDIUM", + "discrimination": null, + "questionVersion": "ability-v0.1", + "source": "SELF_AUTHORED", + "active": true, + "position": 40 + }, + { + "questionCode": "LE05", + "dimension": "LEARNING", + "competencyTag": "FEEDBACK_REFLECTION", + "itemType": "SITUATIONAL_JUDGMENT", + "stem": "你收到“方案不够清晰”的反馈。最有效的回应是?", + "options": [ + {"optionId": "A", "text": "请对方指出具体难以理解之处,修改后再次确认是否解决"}, + {"optionId": "B", "text": "详细解释自己当时的设计思路"}, + {"optionId": "C", "text": "接受反馈,并在下一次方案中注意"}, + {"optionId": "D", "text": "找其他同事判断这条反馈是否合理"} + ], + "answerKey": "A", + "scoringRule": {"A": 4, "B": 2, "C": 1, "D": 0}, + "explanation": "将模糊反馈转化为具体例子,并通过修改后的再次验证,才能真正形成可观察的学习改进。", + "difficulty": "EASY", + "discrimination": null, + "questionVersion": "ability-v0.1", + "source": "SELF_AUTHORED", + "active": true, + "position": 41 + }, + { + "questionCode": "LE06", + "dimension": "LEARNING", + "competencyTag": "FEEDBACK_REFLECTION", + "itemType": "SITUATIONAL_JUDGMENT", + "stem": "一次重要尝试失败后,你最应该优先做什么?", + "options": [ + {"optionId": "A", "text": "立即换一种完全不同的方法重新尝试"}, + {"optionId": "B", "text": "还原目标、过程和关键决策,区分假设错误、执行偏差与外部因素"}, + {"optionId": "C", "text": "先记录失败现象和可能原因,再选择一个方向验证"}, + {"optionId": "D", "text": "等待更多类似失败出现后一起复盘"} + ], + "answerKey": "B", + "scoringRule": {"A": 0, "B": 4, "C": 2, "D": 1}, + "explanation": "有效复盘需要还原过程并区分不同原因类型,避免在没有证据时只更换方法或延后学习。", + "difficulty": "MEDIUM", + "discrimination": null, + "questionVersion": "ability-v0.1", + "source": "SELF_AUTHORED", + "active": true, + "position": 42 + }, + { + "questionCode": "LE07", + "dimension": "LEARNING", + "competencyTag": "FEEDBACK_REFLECTION", + "itemType": "SITUATIONAL_JUDGMENT", + "stem": "怎样最能验证自己真正掌握了一项新知识?", + "options": [ + {"optionId": "A", "text": "能够完整复述资料中的定义"}, + {"optionId": "B", "text": "完成课程并通过课程自带的小测"}, + {"optionId": "C", "text": "能用自己的话解释、应用到新问题,并根据结果修正理解"}, + {"optionId": "D", "text": "尝试向别人讲解,并接受对方提问"} + ], + "answerKey": "C", + "scoringRule": {"A": 1, "B": 0, "C": 4, "D": 2}, + "explanation": "真正掌握不仅是记忆,还包括解释、迁移、实践和基于结果修正认知。", + "difficulty": "HARD", + "discrimination": null, + "questionVersion": "ability-v0.1", + "source": "SELF_AUTHORED", + "active": true, + "position": 43 + }, + { + "questionCode": "LE08", + "dimension": "LEARNING", + "competencyTag": "FEEDBACK_REFLECTION", + "itemType": "SEQUENCE_JUDGMENT", + "stem": "根据反馈持续改进一项技能,哪组顺序最合理?", + "options": [ + {"optionId": "A", "text": "收集反馈→一次修改全部问题→长期练习→最终检查"}, + {"optionId": "B", "text": "确定目标→大量练习→请人评价→记录结果"}, + {"optionId": "C", "text": "模仿优秀示例→反复练习→比较差距→继续模仿"}, + {"optionId": "D", "text": "设定具体目标→刻意练习→获得反馈→调整方法并再次验证"} + ], + "answerKey": "D", + "scoringRule": {"A": 2, "B": 1, "C": 0, "D": 4}, + "explanation": "学习闭环应围绕具体目标,通过练习、反馈、调整和再验证不断缩小差距。", + "difficulty": "HARD", + "discrimination": null, + "questionVersion": "ability-v0.1", + "source": "SELF_AUTHORED", + "active": true, + "position": 44 + }, + { + "questionCode": "LE09", + "dimension": "LEARNING", + "competencyTag": "TRANSFER_ADAPTATION", + "itemType": "SITUATIONAL_JUDGMENT", + "stem": "学会一种分析方法后,你遇到一个相似但条件不同的新问题。你会怎么做?", + "options": [ + {"optionId": "A", "text": "识别共同原理和条件差异,调整方法并用结果验证"}, + {"optionId": "B", "text": "先按原步骤完整执行,再根据结果修改"}, + {"optionId": "C", "text": "重新寻找专门针对新问题的现成方法"}, + {"optionId": "D", "text": "认为条件不同,原方法没有参考价值"} + ], + "answerKey": "A", + "scoringRule": {"A": 4, "B": 2, "C": 1, "D": 0}, + "explanation": "知识迁移的关键是识别稳定原理与变化条件,并通过调整和验证把旧知识应用到新场景。", + "difficulty": "EASY", + "discrimination": null, + "questionVersion": "ability-v0.1", + "source": "SELF_AUTHORED", + "active": true, + "position": 45 + }, + { + "questionCode": "LE10", + "dimension": "LEARNING", + "competencyTag": "TRANSFER_ADAPTATION", + "itemType": "SITUATIONAL_JUDGMENT", + "stem": "你遇到一个自己长时间无法解决的问题,需要向同事求助。怎样提问最有效?", + "options": [ + {"optionId": "A", "text": "把全部材料发给对方,请对方帮忙看看"}, + {"optionId": "B", "text": "说明目标、现象、已尝试方法、当前判断和希望获得的帮助"}, + {"optionId": "C", "text": "整理出最小问题、错误信息和已排除项后提问"}, + {"optionId": "D", "text": "直接请对方演示正确做法,自己跟着学习"} + ], + "answerKey": "B", + "scoringRule": {"A": 0, "B": 4, "C": 2, "D": 1}, + "explanation": "高质量求助能让对方快速理解上下文,也暴露自己的思考过程,从而获得可迁移的指导而非代做。", + "difficulty": "MEDIUM", + "discrimination": null, + "questionVersion": "ability-v0.1", + "source": "SELF_AUTHORED", + "active": true, + "position": 46 + }, + { + "questionCode": "LE11", + "dimension": "LEARNING", + "competencyTag": "TRANSFER_ADAPTATION", + "itemType": "SITUATIONAL_JUDGMENT", + "stem": "你熟悉的工具即将停止维护,团队需要迁移到新工具。你会怎么应对?", + "options": [ + {"optionId": "A", "text": "继续使用旧工具,直到它真正无法运行"}, + {"optionId": "B", "text": "立即把所有工作迁移到最热门的新工具"}, + {"optionId": "C", "text": "比较需求和迁移成本,做小范围验证,形成学习与分阶段迁移计划"}, + {"optionId": "D", "text": "先试用几个候选工具并记录主要差异"} + ], + "answerKey": "C", + "scoringRule": {"A": 1, "B": 0, "C": 4, "D": 2}, + "explanation": "面对变化应结合实际需求、成本和验证结果规划迁移,同时安排学习,避免被动拖延或盲目追新。", + "difficulty": "HARD", + "discrimination": null, + "questionVersion": "ability-v0.1", + "source": "SELF_AUTHORED", + "active": true, + "position": 47 + }, + { + "questionCode": "LE12", + "dimension": "LEARNING", + "competencyTag": "TRANSFER_ADAPTATION", + "itemType": "SEQUENCE_JUDGMENT", + "stem": "制定一项与目标岗位相关的学习计划,哪组顺序最合理?", + "options": [ + {"optionId": "A", "text": "购买课程→安排时间→完成课程→更新简历"}, + {"optionId": "B", "text": "收集热门技能→选择感兴趣内容→集中学习→寻找应用机会"}, + {"optionId": "C", "text": "参考他人路线→复制学习清单→定期检查完成量→继续补课"}, + {"optionId": "D", "text": "分析岗位要求和当前差距→设定可验证目标→练习应用→根据反馈调整"} + ], + "answerKey": "D", + "scoringRule": {"A": 2, "B": 1, "C": 0, "D": 4}, + "explanation": "有效学习计划从真实目标和差距出发,用可验证练习和反馈调整方向,而不是以课程数量衡量成长。", + "difficulty": "EASY", + "discrimination": null, + "questionVersion": "ability-v0.1", + "source": "SELF_AUTHORED", + "active": true, + "position": 48 + } + ] +} diff --git a/mydreamjob-server/src/test/resources/application-integration-test.yml b/mydreamjob-server/src/test/resources/application-integration-test.yml index 2aa541b..1588536 100644 --- a/mydreamjob-server/src/test/resources/application-integration-test.yml +++ b/mydreamjob-server/src/test/resources/application-integration-test.yml @@ -44,6 +44,9 @@ app: match: notification: enabled: false + risk: + scan: + enabled: false resume: storage-dir: ./target/resumes export-dir: ./target/resume-exports