109 changed files with 18084 additions and 2548 deletions
@ -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' } |
|||
@ -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 编码;试测统计口径为后续运营与题库质量治理工作,不阻塞本版实现。 |
|||
File diff suppressed because it is too large
File diff suppressed because it is too large
@ -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 下均兼容)。 |
|||
@ -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 错误码。 |
|||
@ -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() |
|||
@ -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; |
|||
} |
|||
@ -0,0 +1,14 @@ |
|||
package com.mydreamjob.ability; |
|||
|
|||
import org.springframework.data.jpa.repository.JpaRepository; |
|||
|
|||
import java.util.List; |
|||
|
|||
public interface AbilityAnswerRepository extends JpaRepository<AbilityAnswer, Long> { |
|||
|
|||
List<AbilityAnswer> findByAttemptId(Long attemptId); |
|||
|
|||
List<AbilityAnswer> findByAttemptIdAndAttemptQuestionIdIn(Long attemptId, List<Long> attemptQuestionIds); |
|||
|
|||
long countByAttemptId(Long attemptId); |
|||
} |
|||
@ -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; |
|||
} |
|||
@ -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; |
|||
} |
|||
@ -0,0 +1,15 @@ |
|||
package com.mydreamjob.ability; |
|||
|
|||
import org.springframework.data.jpa.repository.JpaRepository; |
|||
|
|||
import java.util.List; |
|||
import java.util.Optional; |
|||
|
|||
public interface AbilityAttemptQuestionRepository extends JpaRepository<AbilityAttemptQuestion, Long> { |
|||
|
|||
List<AbilityAttemptQuestion> findByAttemptIdOrderByPositionAsc(Long attemptId); |
|||
|
|||
boolean existsByAttemptIdAndQuestionId(Long attemptId, Long questionId); |
|||
|
|||
long countByAttemptId(Long attemptId); |
|||
} |
|||
@ -0,0 +1,56 @@ |
|||
package com.mydreamjob.ability; |
|||
|
|||
import org.springframework.data.jpa.repository.JpaRepository; |
|||
import org.springframework.data.jpa.repository.Query; |
|||
import org.springframework.data.repository.query.Param; |
|||
|
|||
import java.time.LocalDateTime; |
|||
import java.util.List; |
|||
import java.util.Optional; |
|||
|
|||
public interface AbilityAttemptRepository extends JpaRepository<AbilityAttempt, Long> { |
|||
|
|||
Optional<AbilityAttempt> findByStudentUserIdAndIdempotencyKey(Long studentUserId, String idempotencyKey); |
|||
|
|||
boolean existsByStudentUserIdAndDefinitionIdAndStatusIn(Long studentUserId, Long definitionId, List<String> statuses); |
|||
|
|||
@org.springframework.data.jpa.repository.Modifying |
|||
@org.springframework.transaction.annotation.Transactional |
|||
@Query("UPDATE AbilityAttempt a SET a.status='COMPLETED', a.submittedAt=:submittedAt, " |
|||
+ "a.completedAt=:submittedAt, a.answeredCount=:answeredCount, a.activeKey=null " |
|||
+ "WHERE a.id=:attemptId AND a.status IN ('DRAFT','IN_PROGRESS')") |
|||
int markCompleted(@Param("attemptId") Long attemptId, |
|||
@Param("submittedAt") LocalDateTime submittedAt, |
|||
@Param("answeredCount") int answeredCount); |
|||
|
|||
@org.springframework.data.jpa.repository.Modifying |
|||
@org.springframework.transaction.annotation.Transactional |
|||
@Query("UPDATE AbilityAttempt a SET a.status='EXPIRED', a.activeKey=null " |
|||
+ "WHERE a.id=:attemptId AND a.status IN ('DRAFT','IN_PROGRESS')") |
|||
int markExpiredAndReleaseActive(@Param("attemptId") Long attemptId); |
|||
|
|||
// 滚动窗口内 COMPLETED 次数(重测限制)
|
|||
int countByStudentUserIdAndDefinitionIdAndStatusAndCompletedAtGreaterThanEqual( |
|||
Long studentUserId, Long definitionId, String status, LocalDateTime since); |
|||
|
|||
List<AbilityAttempt> findByStudentUserIdAndDefinitionIdAndStatusOrderByCompletedAtDesc( |
|||
Long studentUserId, Long definitionId, String status); |
|||
|
|||
List<AbilityAttempt> findByStudentUserIdAndDefinitionIdOrderByCompletedAtDesc( |
|||
Long studentUserId, Long definitionId); |
|||
|
|||
org.springframework.data.domain.Page<AbilityAttempt> findByStudentUserIdOrderByCreatedAtDesc( |
|||
Long studentUserId, org.springframework.data.domain.Pageable pageable); |
|||
|
|||
// 遗留超时的活跃答卷(startedAt 已启动但已到 expiresAt 的 DRAFT/IN_PROGRESS)
|
|||
@Query("SELECT a FROM AbilityAttempt a WHERE a.status IN ('DRAFT','IN_PROGRESS') " |
|||
+ "AND a.expiresAt IS NOT NULL AND a.expiresAt <= :now") |
|||
List<AbilityAttempt> findTimedOutActive(@Param("now") LocalDateTime now); |
|||
|
|||
@Query("SELECT a FROM AbilityAttempt a WHERE a.studentUserId=:userId AND a.definitionId=:defId " |
|||
+ "AND a.status IN ('DRAFT','IN_PROGRESS') " |
|||
+ "AND a.expiresAt IS NOT NULL AND a.expiresAt <= :now") |
|||
List<AbilityAttempt> findTimedOutActiveByUserAndDef(@Param("userId") Long userId, |
|||
@Param("defId") Long defId, |
|||
@Param("now") LocalDateTime now); |
|||
} |
|||
@ -0,0 +1,580 @@ |
|||
package com.mydreamjob.ability; |
|||
|
|||
import com.fasterxml.jackson.databind.JsonNode; |
|||
import com.mydreamjob.ability.dto.AbilityAttemptResponse; |
|||
import com.mydreamjob.ability.dto.AbilityHistoryItem; |
|||
import com.mydreamjob.ability.dto.AbilityResultResponse; |
|||
import com.mydreamjob.ability.dto.CreateAbilityAttemptRequest; |
|||
import com.mydreamjob.ability.dto.SaveAbilityAnswersRequest; |
|||
import com.mydreamjob.audit.AuditContext; |
|||
import com.mydreamjob.audit.AuditService; |
|||
import com.mydreamjob.auth.User; |
|||
import com.mydreamjob.auth.UserRepository; |
|||
import com.mydreamjob.common.exception.BusinessException; |
|||
import com.mydreamjob.common.exception.ErrorCode; |
|||
import lombok.RequiredArgsConstructor; |
|||
import org.springframework.dao.DataIntegrityViolationException; |
|||
import org.springframework.dao.OptimisticLockingFailureException; |
|||
import org.springframework.data.domain.Page; |
|||
import org.springframework.data.domain.PageRequest; |
|||
import org.springframework.stereotype.Service; |
|||
import org.springframework.transaction.annotation.Isolation; |
|||
import org.springframework.transaction.annotation.Transactional; |
|||
|
|||
import java.math.BigDecimal; |
|||
import java.math.RoundingMode; |
|||
import java.time.Clock; |
|||
import java.time.Duration; |
|||
import java.time.LocalDateTime; |
|||
import java.util.ArrayList; |
|||
import java.util.Collections; |
|||
import java.util.LinkedHashMap; |
|||
import java.util.List; |
|||
import java.util.Map; |
|||
import java.util.Random; |
|||
import java.util.Set; |
|||
import java.util.function.Function; |
|||
import java.util.stream.Collectors; |
|||
|
|||
@Service |
|||
@RequiredArgsConstructor |
|||
public class AbilityAttemptService { |
|||
|
|||
private static final int MAX_PAGE_SIZE = 50; |
|||
private static final String[] DIMENSIONS = {"LOGIC", "COMMUNICATION", "EXECUTION", "LEARNING"}; |
|||
|
|||
private final AbilityDefinitionRepository definitionRepository; |
|||
private final AbilityQuestionRepository questionRepository; |
|||
private final AbilityAttemptRepository attemptRepository; |
|||
private final AbilityAttemptQuestionRepository attemptQuestionRepository; |
|||
private final AbilityAnswerRepository answerRepository; |
|||
private final AbilityResultRepository resultRepository; |
|||
private final AbilityResultService resultService; |
|||
private final AbilityResultComputer resultComputer; |
|||
private final AbilityAttemptWriter attemptWriter; |
|||
private final AbilitySnapshotCodec snapshotCodec; |
|||
private final UserRepository userRepository; |
|||
private final AuditService auditService; |
|||
private final Clock clock; |
|||
|
|||
// ─────────────────────────── 创建答卷 ───────────────────────────
|
|||
|
|||
@Transactional(isolation = Isolation.READ_COMMITTED) |
|||
public AbilityAttemptResponse create(Long userId, CreateAbilityAttemptRequest req, AuditContext ctx) { |
|||
requireStudent(userId); |
|||
AbilityDefinition def = definitionRepository.findByTypeCode(req.getTypeCode()) |
|||
.orElseThrow(() -> new BusinessException(ErrorCode.ABILITY_TYPE_NOT_FOUND)); |
|||
if (!AbilityDefinition.STATUS_ACTIVE.equals(def.getStatus())) { |
|||
throw new BusinessException(ErrorCode.ABILITY_TYPE_NOT_FOUND); |
|||
} |
|||
LocalDateTime now = now(); |
|||
String idem = normalizeIdempotencyKey(req.getIdempotencyKey()); |
|||
|
|||
if (idem != null) { |
|||
AbilityAttempt existing = findByIdem(userId, idem); |
|||
if (existing != null) { |
|||
if (!def.getId().equals(existing.getDefinitionId())) { |
|||
throw new BusinessException(ErrorCode.ABILITY_IDEMPOTENCY_CONFLICT); |
|||
} |
|||
return toAttemptResponse(existing); |
|||
} |
|||
} |
|||
|
|||
// 原子释放本学生本类型已超时的遗留活跃答卷
|
|||
releaseTimedOut(userId, def.getId(), now); |
|||
|
|||
if (attemptRepository.existsByStudentUserIdAndDefinitionIdAndStatusIn( |
|||
userId, def.getId(), List.of(AbilityAttempt.STATUS_DRAFT, AbilityAttempt.STATUS_IN_PROGRESS))) { |
|||
throw new BusinessException(ErrorCode.ABILITY_DUPLICATE_ACTIVE); |
|||
} |
|||
|
|||
// 有效结果冷却期:存在未过期结果时不可重测
|
|||
AbilityAttempt recentCompleted = attemptRepository |
|||
.findByStudentUserIdAndDefinitionIdAndStatusOrderByCompletedAtDesc( |
|||
userId, def.getId(), AbilityAttempt.STATUS_COMPLETED) |
|||
.stream().findFirst().orElse(null); |
|||
if (recentCompleted != null) { |
|||
AbilityResult r = resultRepository.findByAttemptId(recentCompleted.getId()).orElse(null); |
|||
if (r != null && r.getValidUntil() != null && now.isBefore(r.getValidUntil())) { |
|||
throw new BusinessException(ErrorCode.ABILITY_RETAKE_COOLDOWN); |
|||
} |
|||
} |
|||
|
|||
// 滚动窗口内完成次数限制
|
|||
int windowDays = def.getRetakeWindowDays() == null ? 90 : def.getRetakeWindowDays(); |
|||
int maxCompleted = def.getRetakeMaxCompleted() == null ? 3 : def.getRetakeMaxCompleted(); |
|||
long completedInWindow = attemptRepository |
|||
.countByStudentUserIdAndDefinitionIdAndStatusAndCompletedAtGreaterThanEqual( |
|||
userId, def.getId(), AbilityAttempt.STATUS_COMPLETED, now.minusDays(windowDays)); |
|||
if (completedInWindow >= maxCompleted) { |
|||
throw new BusinessException(ErrorCode.ABILITY_RETAKE_LIMIT); |
|||
} |
|||
|
|||
List<AbilityQuestion> all = questionRepository |
|||
.findByDefinitionIdAndActiveTrueOrderByPositionAsc(def.getId()); |
|||
List<AbilityQuestion> sampled = sample(all, def); |
|||
int perDim = def.getQuestionsPerDimension() == null ? 6 : def.getQuestionsPerDimension(); |
|||
|
|||
AbilityAttempt attempt; |
|||
try { |
|||
attempt = attemptWriter.persistNew(userId, def, sampled, idem, activeKeyOf(userId, def.getId())); |
|||
} catch (DataIntegrityViolationException e) { |
|||
if (idem != null) { |
|||
AbilityAttempt existing = findByIdem(userId, idem); |
|||
if (existing != null) { |
|||
if (!def.getId().equals(existing.getDefinitionId())) { |
|||
throw new BusinessException(ErrorCode.ABILITY_IDEMPOTENCY_CONFLICT); |
|||
} |
|||
return toAttemptResponse(existing); |
|||
} |
|||
} |
|||
if (attemptRepository.existsByStudentUserIdAndDefinitionIdAndStatusIn( |
|||
userId, def.getId(), List.of(AbilityAttempt.STATUS_DRAFT, AbilityAttempt.STATUS_IN_PROGRESS))) { |
|||
throw new BusinessException(ErrorCode.ABILITY_DUPLICATE_ACTIVE); |
|||
} |
|||
throw e; |
|||
} |
|||
|
|||
auditService.log(userId, "ABILITY_ATTEMPT_CREATED", "ABILITY_ATTEMPT", attempt.getId(), |
|||
"attemptId=" + attempt.getId() + ", typeCode=" + def.getTypeCode() |
|||
+ ", status=" + attempt.getStatus() + ", answeredCount=0" |
|||
+ ", questionSetVersion=" + attempt.getQuestionSetVersion() |
|||
+ ", rulesVersion=" + def.getRulesVersion(), |
|||
ctx); |
|||
return toAttemptResponse(attempt, def); |
|||
} |
|||
|
|||
// ─────────────────────────── 答卷详情 ───────────────────────────
|
|||
|
|||
@Transactional |
|||
public AbilityAttemptResponse get(Long userId, Long attemptId) { |
|||
requireStudent(userId); |
|||
AbilityAttempt attempt = loadOwned(userId, attemptId); |
|||
expireIfTimedOut(attempt); |
|||
return toAttemptResponse(attempt); |
|||
} |
|||
|
|||
// ─────────────────────────── 查看结果 ───────────────────────────
|
|||
|
|||
@Transactional(readOnly = true) |
|||
public AbilityResultResponse loadResult(Long userId, Long attemptId) { |
|||
requireStudent(userId); |
|||
AbilityAttempt attempt = loadOwned(userId, attemptId); |
|||
AbilityResult result = resultRepository.findByAttemptId(attemptId) |
|||
.orElseThrow(() -> new BusinessException(ErrorCode.ABILITY_RESULT_NOT_FOUND)); |
|||
AbilityDefinition def = definitionRepository.findById(attempt.getDefinitionId()).orElse(null); |
|||
return resultService.toResponse(attempt, result, def, now()); |
|||
} |
|||
|
|||
// ─────────────────────────── 保存答案 ───────────────────────────
|
|||
|
|||
@Transactional |
|||
public AbilityAttemptResponse saveAnswers(Long userId, Long attemptId, |
|||
SaveAbilityAnswersRequest req, AuditContext ctx) { |
|||
requireStudent(userId); |
|||
AbilityAttempt attempt = loadOwned(userId, attemptId); |
|||
if (expireIfTimedOut(attempt)) { |
|||
throw new BusinessException(ErrorCode.ABILITY_ATTEMPT_INVALID_STATE); |
|||
} |
|||
if (!isMutable(attempt.getStatus())) { |
|||
throw new BusinessException(ErrorCode.ABILITY_ATTEMPT_INVALID_STATE); |
|||
} |
|||
AbilityDefinition def = definitionRepository.findById(attempt.getDefinitionId()).orElse(null); |
|||
|
|||
// 同一 PATCH 内不得重复 attemptQuestionId
|
|||
if (req.getAnswers().size() != req.getAnswers().stream() |
|||
.map(a -> a.getAttemptQuestionId()).distinct().count()) { |
|||
throw new BusinessException(ErrorCode.ABILITY_ANSWER_INVALID, "duplicate attemptQuestionId in answers"); |
|||
} |
|||
|
|||
List<AbilityAttemptQuestion> aqs = attemptQuestionRepository |
|||
.findByAttemptIdOrderByPositionAsc(attemptId); |
|||
Map<Long, AbilityAttemptQuestion> aqById = aqs.stream() |
|||
.collect(Collectors.toMap(AbilityAttemptQuestion::getId, Function.identity())); |
|||
|
|||
// 先整体校验:attemptQuestionId 属于本答卷、选项存在于快照;任一无效 → 整体拒绝,不启动计时
|
|||
for (SaveAbilityAnswersRequest.AnswerItem item : req.getAnswers()) { |
|||
AbilityAttemptQuestion aq = aqById.get(item.getAttemptQuestionId()); |
|||
if (aq == null || item.getSelectedOptionId() == null) { |
|||
throw new BusinessException(ErrorCode.ABILITY_ANSWER_INVALID, "attemptQuestionId not in this attempt"); |
|||
} |
|||
if (!snapshotCodec.optionExists(aq.getQuestionSnapshotJson(), item.getSelectedOptionId())) { |
|||
throw new BusinessException(ErrorCode.ABILITY_ANSWER_INVALID, "optionId does not exist"); |
|||
} |
|||
} |
|||
|
|||
LocalDateTime now = now(); |
|||
boolean startTimer = attempt.getStartedAt() == null; |
|||
|
|||
// 批量加载本请求涉及题目的既有答案,避免对每题逐条查询(消除 N+1)
|
|||
List<Long> questionIds = req.getAnswers().stream() |
|||
.map(SaveAbilityAnswersRequest.AnswerItem::getAttemptQuestionId).toList(); |
|||
Map<Long, AbilityAnswer> existingByAqId = answerRepository |
|||
.findByAttemptIdAndAttemptQuestionIdIn(attemptId, questionIds).stream() |
|||
.collect(Collectors.toMap(AbilityAnswer::getAttemptQuestionId, Function.identity())); |
|||
|
|||
List<AbilityAnswer> toSave = new ArrayList<>(questionIds.size()); |
|||
for (SaveAbilityAnswersRequest.AnswerItem item : req.getAnswers()) { |
|||
AbilityAttemptQuestion aq = aqById.get(item.getAttemptQuestionId()); |
|||
Integer awarded = snapshotCodec.awardedScore(aq.getQuestionSnapshotJson(), item.getSelectedOptionId()); |
|||
AbilityAnswer answer = existingByAqId.get(aq.getId()); |
|||
if (answer == null) { |
|||
answer = AbilityAnswer.builder().attemptId(attemptId).attemptQuestionId(aq.getId()).build(); |
|||
} |
|||
answer.setSelectedOptionId(item.getSelectedOptionId()); |
|||
answer.setAwardedScore(awarded == null ? 0 : awarded); |
|||
toSave.add(answer); |
|||
} |
|||
answerRepository.saveAll(toSave); |
|||
answerRepository.flush(); |
|||
|
|||
int answered = (int) answerRepository.countByAttemptId(attemptId); |
|||
attempt.setAnsweredCount(answered); |
|||
if (startTimer && answered > 0) { |
|||
attempt.setStartedAt(now); |
|||
int limit = def == null || def.getTimeLimitMinutes() == null ? 30 : def.getTimeLimitMinutes(); |
|||
attempt.setExpiresAt(now.plusMinutes(limit)); |
|||
attempt.setStatus(AbilityAttempt.STATUS_IN_PROGRESS); |
|||
} |
|||
try { |
|||
attemptRepository.saveAndFlush(attempt); |
|||
} catch (OptimisticLockingFailureException e) { |
|||
throw new BusinessException(ErrorCode.ABILITY_CONCURRENT_UPDATE); |
|||
} |
|||
return toAttemptResponse(attempt, def); |
|||
} |
|||
|
|||
// ─────────────────────────── 提交答卷 ───────────────────────────
|
|||
|
|||
@Transactional |
|||
public AbilityResultResponse submit(Long userId, Long attemptId, AuditContext ctx) { |
|||
requireStudent(userId); |
|||
AbilityAttempt attempt = loadOwned(userId, attemptId); |
|||
|
|||
if (AbilityAttempt.STATUS_COMPLETED.equals(attempt.getStatus())) { |
|||
return idempotentResult(attempt); |
|||
} |
|||
if (expireIfTimedOut(attempt)) { |
|||
throw new BusinessException(ErrorCode.ABILITY_ATTEMPT_INVALID_STATE); |
|||
} |
|||
if (!isMutable(attempt.getStatus())) { |
|||
throw new BusinessException(ErrorCode.ABILITY_ATTEMPT_INVALID_STATE); |
|||
} |
|||
|
|||
int answered = (int) answerRepository.countByAttemptId(attemptId); |
|||
int total = totalOf(attempt); |
|||
if (answered < total || !perDimensionAnswered(attemptId)) { |
|||
throw new BusinessException(ErrorCode.ABILITY_INCOMPLETE); |
|||
} |
|||
|
|||
int transitioned = attemptRepository.markCompleted(attemptId, now(), answered); |
|||
if (transitioned == 0) { |
|||
return idempotentResult(attempt); |
|||
} |
|||
|
|||
AbilityDefinition def = definitionRepository.findById(attempt.getDefinitionId()).orElse(null); |
|||
AbilityResultComputer.ComputedResult computed = compute(attempt); |
|||
AbilityResult result; |
|||
try { |
|||
result = resultRepository.save(resultService.persist(attempt, def, computed, now())); |
|||
} catch (DataIntegrityViolationException e) { |
|||
return idempotentResult(attempt); |
|||
} |
|||
|
|||
auditService.log(userId, "ABILITY_ATTEMPT_SUBMITTED", "ABILITY_ATTEMPT", attemptId, |
|||
"attemptId=" + attemptId + ", typeCode=" + (def == null ? null : def.getTypeCode()) |
|||
+ ", status=COMPLETED, answeredCount=" + answered |
|||
+ ", questionSetVersion=" + attempt.getQuestionSetVersion() |
|||
+ ", rulesVersion=" + (def == null ? null : def.getRulesVersion()), |
|||
ctx); |
|||
return resultService.toResponse(attempt, result, def, now()); |
|||
} |
|||
|
|||
// ─────────────────────────── 我的历史 ───────────────────────────
|
|||
|
|||
@Transactional(readOnly = true) |
|||
public Page<AbilityHistoryItem> listMine(Long userId, int page, int size) { |
|||
requireStudent(userId); |
|||
if (page < 0 || size < 1 || size > MAX_PAGE_SIZE) { |
|||
throw new BusinessException(ErrorCode.BAD_REQUEST); |
|||
} |
|||
LocalDateTime now = now(); |
|||
Page<AbilityAttempt> attempts = attemptRepository |
|||
.findByStudentUserIdOrderByCreatedAtDesc(userId, PageRequest.of(page, size)); |
|||
List<AbilityAttempt> content = attempts.getContent(); |
|||
Map<Long, AbilityResult> resultByAttempt = resultRepository |
|||
.findByAttemptIdIn(content.stream().map(AbilityAttempt::getId).toList()).stream() |
|||
.collect(Collectors.toMap(AbilityResult::getAttemptId, Function.identity())); |
|||
// 对当前页 definitionId 去重后批量加载,避免对每条历史逐个 findById(消除 N+1)
|
|||
List<Long> defIds = content.stream().map(AbilityAttempt::getDefinitionId).distinct().toList(); |
|||
Map<Long, AbilityDefinition> defById = defIds.isEmpty() ? Map.of() |
|||
: definitionRepository.findAllById(defIds).stream() |
|||
.collect(Collectors.toMap(AbilityDefinition::getId, Function.identity())); |
|||
return attempts.map(a -> { |
|||
AbilityResult r = resultByAttempt.get(a.getId()); |
|||
AbilityDefinition d = defById.get(a.getDefinitionId()); |
|||
String validity = null; |
|||
if (r != null && r.getValidUntil() != null) { |
|||
validity = now.isBefore(r.getValidUntil()) ? AbilityResultComputer.VALIDITY_VALID |
|||
: AbilityResultComputer.VALIDITY_EXPIRED; |
|||
} |
|||
int total = d == null || d.getAttemptQuestionCount() == null ? 24 : d.getAttemptQuestionCount(); |
|||
return AbilityHistoryItem.builder() |
|||
.attemptId(a.getId()) |
|||
.typeCode(d == null ? null : d.getTypeCode()) |
|||
.status(a.getStatus()) |
|||
.validityStatus(validity) |
|||
.overallScore(r == null ? null : r.getOverallScore()) |
|||
.answeredCount(a.getAnsweredCount()) |
|||
.completionRate(completionRate(a.getAnsweredCount(), total)) |
|||
.startedAt(a.getStartedAt()) |
|||
.expiresAt(a.getExpiresAt()) |
|||
.completedAt(a.getCompletedAt()) |
|||
.validUntil(r == null ? null : r.getValidUntil()) |
|||
.build(); |
|||
}); |
|||
} |
|||
|
|||
// ─────────────────────────── 内部工具 ───────────────────────────
|
|||
|
|||
private AbilityResultResponse idempotentResult(AbilityAttempt attempt) { |
|||
AbilityResult result = resultRepository.findByAttemptId(attempt.getId()) |
|||
.orElseThrow(() -> new BusinessException(ErrorCode.ABILITY_ATTEMPT_INVALID_STATE)); |
|||
AbilityDefinition def = definitionRepository.findById(attempt.getDefinitionId()).orElse(null); |
|||
return resultService.toResponse(attempt, result, def, now()); |
|||
} |
|||
|
|||
private AbilityResultComputer.ComputedResult compute(AbilityAttempt attempt) { |
|||
List<AbilityResultComputer.Answered> items = new ArrayList<>(); |
|||
List<AbilityAttemptQuestion> aqs = attemptQuestionRepository |
|||
.findByAttemptIdOrderByPositionAsc(attempt.getId()); |
|||
Map<Long, AbilityAnswer> answerByAq = answerRepository.findByAttemptId(attempt.getId()).stream() |
|||
.collect(Collectors.toMap(AbilityAnswer::getAttemptQuestionId, Function.identity())); |
|||
for (AbilityAttemptQuestion aq : aqs) { |
|||
AbilityAnswer ans = answerByAq.get(aq.getId()); |
|||
if (ans == null) { |
|||
continue; |
|||
} |
|||
JsonNode snap = snapshotCodec.parse(aq.getQuestionSnapshotJson()); |
|||
items.add(new AbilityResultComputer.Answered( |
|||
text(snap, AbilitySnapshotCodec.KEY_DIMENSION), |
|||
text(snap, AbilitySnapshotCodec.KEY_COMPETENCY_TAG), |
|||
text(snap, AbilitySnapshotCodec.KEY_STEM), |
|||
text(snap, AbilitySnapshotCodec.KEY_EXPLANATION), |
|||
ans.getAwardedScore(), aq.getId(), aq.getPosition(), ans.getSelectedOptionId())); |
|||
} |
|||
return resultComputer.compute(items, perDimOf(attempt)); |
|||
} |
|||
|
|||
private int totalOf(AbilityAttempt attempt) { |
|||
AbilityDefinition def = definitionRepository.findById(attempt.getDefinitionId()).orElse(null); |
|||
return def == null || def.getAttemptQuestionCount() == null ? 24 : def.getAttemptQuestionCount(); |
|||
} |
|||
|
|||
private int perDimOf(AbilityAttempt attempt) { |
|||
AbilityDefinition def = definitionRepository.findById(attempt.getDefinitionId()).orElse(null); |
|||
return def == null || def.getQuestionsPerDimension() == null ? 6 : def.getQuestionsPerDimension(); |
|||
} |
|||
|
|||
private String text(JsonNode snap, String key) { |
|||
JsonNode n = snap.path(key); |
|||
return n.isMissingNode() || n.isNull() ? null : n.asText(); |
|||
} |
|||
|
|||
private boolean perDimensionAnswered(Long attemptId) { |
|||
// 一次查询该答题的题目,一次批量查询该答题的全部答案,避免对每题逐条查询(消除 N+1)
|
|||
List<AbilityAttemptQuestion> aqs = |
|||
attemptQuestionRepository.findByAttemptIdOrderByPositionAsc(attemptId); |
|||
if (aqs.isEmpty()) { |
|||
return false; |
|||
} |
|||
Set<Long> answeredAqIds = answerRepository.findByAttemptId(attemptId).stream() |
|||
.map(AbilityAnswer::getAttemptQuestionId).collect(Collectors.toSet()); |
|||
Map<String, Integer> counts = new LinkedHashMap<>(); |
|||
for (AbilityAttemptQuestion aq : aqs) { |
|||
if (answeredAqIds.contains(aq.getId())) { |
|||
String dim = snapshotCodec.text(aq.getQuestionSnapshotJson(), AbilitySnapshotCodec.KEY_DIMENSION); |
|||
counts.merge(dim, 1, Integer::sum); |
|||
} |
|||
} |
|||
int required = 6; |
|||
for (String d : DIMENSIONS) { |
|||
if (counts.getOrDefault(d, 0) != required) { |
|||
return false; |
|||
} |
|||
} |
|||
return true; |
|||
} |
|||
|
|||
private boolean expireIfTimedOut(AbilityAttempt attempt) { |
|||
if (isMutable(attempt.getStatus()) && attempt.getExpiresAt() != null |
|||
&& !now().isBefore(attempt.getExpiresAt())) { |
|||
attemptRepository.markExpiredAndReleaseActive(attempt.getId()); |
|||
attempt.setStatus(AbilityAttempt.STATUS_EXPIRED); |
|||
attempt.setActiveKey(null); |
|||
return true; |
|||
} |
|||
return false; |
|||
} |
|||
|
|||
private void releaseTimedOut(Long userId, Long definitionId, LocalDateTime now) { |
|||
for (AbilityAttempt a : attemptRepository.findTimedOutActiveByUserAndDef(userId, definitionId, now)) { |
|||
attemptRepository.markExpiredAndReleaseActive(a.getId()); |
|||
} |
|||
// 立即刷新旧状态以免 DELETE/REREAD 不一致:不做,仅在此事务内后续判断用 DB 最新
|
|||
} |
|||
|
|||
private AbilityAttemptResponse toAttemptResponse(AbilityAttempt attempt) { |
|||
return toAttemptResponse(attempt, definitionRepository.findById(attempt.getDefinitionId()).orElse(null)); |
|||
} |
|||
|
|||
private AbilityAttemptResponse toAttemptResponse(AbilityAttempt attempt, AbilityDefinition def) { |
|||
List<AbilityAttemptQuestion> aqs = attemptQuestionRepository |
|||
.findByAttemptIdOrderByPositionAsc(attempt.getId()); |
|||
Map<Long, String> selectedByAq = answerRepository.findByAttemptId(attempt.getId()).stream() |
|||
.collect(Collectors.toMap(AbilityAnswer::getAttemptQuestionId, AbilityAnswer::getSelectedOptionId)); |
|||
|
|||
int total = totalOf(attempt); |
|||
int answered = attempt.getAnsweredCount() == null ? (int) selectedByAq.size() : attempt.getAnsweredCount(); |
|||
|
|||
List<AbilityAttemptResponse.AbilityAttemptQuestionView> questions = new ArrayList<>(aqs.size()); |
|||
for (AbilityAttemptQuestion aq : aqs) { |
|||
JsonNode snap = snapshotCodec.parse(aq.getQuestionSnapshotJson()); |
|||
String itemType = text(snap, AbilitySnapshotCodec.KEY_ITEM_TYPE); |
|||
String stem = text(snap, AbilitySnapshotCodec.KEY_STEM); |
|||
List<AbilityAttemptResponse.AbilityOptionView> options = new ArrayList<>(); |
|||
JsonNode opts = snap.path(AbilitySnapshotCodec.KEY_OPTIONS); |
|||
if (opts.isArray()) { |
|||
for (JsonNode o : opts) { |
|||
options.add(AbilityAttemptResponse.AbilityOptionView.builder() |
|||
.optionId(o.path("optionId").asText()).text(o.path("text").asText()).build()); |
|||
} |
|||
} |
|||
questions.add(AbilityAttemptResponse.AbilityAttemptQuestionView.builder() |
|||
.attemptQuestionId(aq.getId()) |
|||
.position(aq.getPosition()) |
|||
.itemType(itemType) |
|||
.stem(stem) |
|||
.options(options) |
|||
.selectedOptionId(selectedByAq.get(aq.getId())) |
|||
.build()); |
|||
} |
|||
|
|||
Long remaining = null; |
|||
if (attempt.getExpiresAt() != null) { |
|||
long secs = Duration.between(now(), attempt.getExpiresAt()).getSeconds(); |
|||
remaining = Math.max(0, secs); |
|||
} |
|||
|
|||
return AbilityAttemptResponse.builder() |
|||
.attemptId(attempt.getId()) |
|||
.status(attempt.getStatus()) |
|||
.answeredCount(answered) |
|||
.totalQuestions(total) |
|||
.completionRate(completionRate(answered, total)) |
|||
.startedAt(attempt.getStartedAt()) |
|||
.expiresAt(attempt.getExpiresAt()) |
|||
.remainingSeconds(remaining) |
|||
.questions(questions) |
|||
.build(); |
|||
} |
|||
|
|||
/** |
|||
* 抽题:四维各若干道,且每维三个 competencyTag 各抽 2 道,共 def.attemptQuestionCount 道。 |
|||
* 任一维度/标签可用题不足时直接拒绝创建(不静默减少)。 |
|||
*/ |
|||
private List<AbilityQuestion> sample(List<AbilityQuestion> all, AbilityDefinition def) { |
|||
int perDim = def.getQuestionsPerDimension() == null ? 6 : def.getQuestionsPerDimension(); |
|||
int attemptCount = def.getAttemptQuestionCount() == null ? 24 : def.getAttemptQuestionCount(); |
|||
int perTag = 2; // perDim = 3 tags × 2
|
|||
long seed = new Random().nextLong(); |
|||
Random random = new Random(seed); |
|||
|
|||
Map<String, Map<String, List<AbilityQuestion>>> byDimTag = new LinkedHashMap<>(); |
|||
for (AbilityQuestion q : all) { |
|||
byDimTag.computeIfAbsent(q.getDimension(), k -> new LinkedHashMap<>()) |
|||
.computeIfAbsent(q.getCompetencyTag(), k -> new ArrayList<>()).add(q); |
|||
} |
|||
int collected = 0; |
|||
List<AbilityQuestion> picked = new ArrayList<>(); |
|||
for (String dim : DIMENSIONS) { |
|||
Map<String, List<AbilityQuestion>> tags = byDimTag.get(dim); |
|||
if (tags == null || tags.size() < 3) { |
|||
throw new BusinessException(ErrorCode.BAD_REQUEST, |
|||
"insufficient competency tags in dimension " + dim); |
|||
} |
|||
List<AbilityQuestion> dimPicked = new ArrayList<>(); |
|||
for (Map.Entry<String, List<AbilityQuestion>> e : tags.entrySet()) { |
|||
List<AbilityQuestion> group = new ArrayList<>(e.getValue()); |
|||
if (group.size() < perTag) { |
|||
throw new BusinessException(ErrorCode.BAD_REQUEST, |
|||
"insufficient active questions for tag " + e.getKey()); |
|||
} |
|||
Collections.shuffle(group, random); |
|||
dimPicked.addAll(group.subList(0, perTag)); |
|||
} |
|||
if (dimPicked.size() != perDim) { |
|||
throw new BusinessException(ErrorCode.BAD_REQUEST, |
|||
"dimension " + dim + " sampled != " + perDim); |
|||
} |
|||
picked.addAll(dimPicked); |
|||
collected += dimPicked.size(); |
|||
} |
|||
Collections.shuffle(picked, random); |
|||
if (collected != attemptCount || picked.size() != attemptCount) { |
|||
throw new BusinessException(ErrorCode.BAD_REQUEST, "sampled question count mismatch"); |
|||
} |
|||
return picked; |
|||
} |
|||
|
|||
private boolean isMutable(String status) { |
|||
return AbilityAttempt.STATUS_DRAFT.equals(status) |
|||
|| AbilityAttempt.STATUS_IN_PROGRESS.equals(status); |
|||
} |
|||
|
|||
private AbilityAttempt loadOwned(Long userId, Long attemptId) { |
|||
AbilityAttempt a = attemptRepository.findById(attemptId) |
|||
.orElseThrow(() -> new BusinessException(ErrorCode.ABILITY_ATTEMPT_NOT_FOUND)); |
|||
if (!a.getStudentUserId().equals(userId)) { |
|||
throw new BusinessException(ErrorCode.FORBIDDEN); |
|||
} |
|||
return a; |
|||
} |
|||
|
|||
private void requireStudent(Long userId) { |
|||
User u = userRepository.findById(userId) |
|||
.orElseThrow(() -> new BusinessException(ErrorCode.USER_NOT_FOUND)); |
|||
boolean isStudent = u.getRoles() != null && u.getRoles().contains(User.Role.STUDENT); |
|||
if (!isStudent) { |
|||
throw new BusinessException(ErrorCode.FORBIDDEN); |
|||
} |
|||
} |
|||
|
|||
private AbilityAttempt findByIdem(Long userId, String idem) { |
|||
if (idem == null) { |
|||
return null; |
|||
} |
|||
return attemptRepository.findByStudentUserIdAndIdempotencyKey(userId, idem).orElse(null); |
|||
} |
|||
|
|||
private String normalizeIdempotencyKey(String key) { |
|||
return key == null || key.isBlank() ? null : key.trim(); |
|||
} |
|||
|
|||
private String activeKeyOf(Long studentUserId, Long definitionId) { |
|||
return studentUserId + ":" + definitionId + ":ACTIVE"; |
|||
} |
|||
|
|||
private LocalDateTime now() { |
|||
return LocalDateTime.now(clock); |
|||
} |
|||
|
|||
private BigDecimal completionRate(int answered, int total) { |
|||
if (total <= 0) { |
|||
return BigDecimal.ZERO.setScale(2); |
|||
} |
|||
return BigDecimal.valueOf(answered) |
|||
.multiply(BigDecimal.valueOf(100)) |
|||
.divide(BigDecimal.valueOf(total), 2, RoundingMode.HALF_UP); |
|||
} |
|||
} |
|||
@ -0,0 +1,51 @@ |
|||
package com.mydreamjob.ability; |
|||
|
|||
import lombok.RequiredArgsConstructor; |
|||
import org.springframework.stereotype.Component; |
|||
import org.springframework.transaction.annotation.Propagation; |
|||
import org.springframework.transaction.annotation.Transactional; |
|||
|
|||
import java.time.LocalDateTime; |
|||
import java.util.List; |
|||
|
|||
/** |
|||
* 答卷持久化写入 Bean(V36)。将「新建 attempt + 24 条 attempt_questions(含完整快照)」放入 |
|||
* 独立的 REQUIRES_NEW 事务:并发创建触发唯一键冲突(active_key / idempotency_key)时在独立事务内回滚, |
|||
* 外层 Session 不被污染,避免 Hibernate AssertionFailure 或 HTTP 500。 |
|||
*/ |
|||
@Component |
|||
@RequiredArgsConstructor |
|||
public class AbilityAttemptWriter { |
|||
|
|||
private final AbilityAttemptRepository attemptRepository; |
|||
private final AbilityAttemptQuestionRepository attemptQuestionRepository; |
|||
private final AbilitySnapshotCodec snapshotCodec; |
|||
|
|||
@Transactional(propagation = Propagation.REQUIRES_NEW) |
|||
public AbilityAttempt persistNew(Long userId, AbilityDefinition def, List<AbilityQuestion> sampled, |
|||
String idempotencyKey, String activeKey) { |
|||
AbilityAttempt attempt = AbilityAttempt.builder() |
|||
.studentUserId(userId) |
|||
.definitionId(def.getId()) |
|||
.status(AbilityAttempt.STATUS_DRAFT) |
|||
.activeKey(activeKey) |
|||
.idempotencyKey(idempotencyKey) |
|||
.questionSetVersion(def.getQuestionSetVersion()) |
|||
.answeredCount(0) |
|||
.build(); |
|||
attempt = attemptRepository.saveAndFlush(attempt); |
|||
|
|||
int position = 1; |
|||
for (AbilityQuestion q : sampled) { |
|||
attemptQuestionRepository.save(AbilityAttemptQuestion.builder() |
|||
.attemptId(attempt.getId()) |
|||
.questionId(q.getId()) |
|||
.position(position++) |
|||
.questionVersion(q.getQuestionVersion()) |
|||
.questionSnapshotJson(snapshotCodec.build(q)) |
|||
.build()); |
|||
} |
|||
attemptQuestionRepository.flush(); |
|||
return attempt; |
|||
} |
|||
} |
|||
@ -0,0 +1,32 @@ |
|||
package com.mydreamjob.ability; |
|||
|
|||
import com.mydreamjob.ability.dto.AbilityTypeResponse; |
|||
import lombok.RequiredArgsConstructor; |
|||
import org.springframework.stereotype.Service; |
|||
import org.springframework.transaction.annotation.Transactional; |
|||
|
|||
import java.util.List; |
|||
|
|||
@Service |
|||
@RequiredArgsConstructor |
|||
public class AbilityCatalogService { |
|||
|
|||
private final AbilityDefinitionRepository definitionRepository; |
|||
|
|||
@Transactional(readOnly = true) |
|||
public List<AbilityTypeResponse> listTypes() { |
|||
return definitionRepository.findAllByStatusOrderByCreatedAtAsc(AbilityDefinition.STATUS_ACTIVE) |
|||
.stream() |
|||
.map(d -> AbilityTypeResponse.builder() |
|||
.typeCode(d.getTypeCode()) |
|||
.displayName(d.getDisplayName()) |
|||
.description(d.getDescription()) |
|||
.questionCount(d.getQuestionCount()) |
|||
.attemptQuestionCount(d.getAttemptQuestionCount()) |
|||
.timeLimitMinutes(d.getTimeLimitMinutes()) |
|||
.resultValidDays(d.getResultValidDays()) |
|||
.status(d.getStatus()) |
|||
.build()) |
|||
.toList(); |
|||
} |
|||
} |
|||
@ -0,0 +1,164 @@ |
|||
package com.mydreamjob.ability; |
|||
|
|||
import com.mydreamjob.ability.dto.AbilityAttemptResponse; |
|||
import com.mydreamjob.ability.dto.AbilityHistoryItem; |
|||
import com.mydreamjob.ability.dto.AbilityResultResponse; |
|||
import com.mydreamjob.ability.dto.AbilityTypeResponse; |
|||
import com.mydreamjob.ability.dto.CreateAbilityAttemptRequest; |
|||
import com.mydreamjob.ability.dto.SaveAbilityAnswersRequest; |
|||
import com.mydreamjob.audit.AuditContext; |
|||
import com.mydreamjob.common.api.ApiResponse; |
|||
import com.mydreamjob.common.api.PageResponse; |
|||
import com.mydreamjob.common.exception.BusinessException; |
|||
import com.mydreamjob.common.exception.ErrorCode; |
|||
import io.swagger.v3.oas.annotations.Operation; |
|||
import io.swagger.v3.oas.annotations.responses.ApiResponses; |
|||
import io.swagger.v3.oas.annotations.tags.Tag; |
|||
import jakarta.servlet.http.HttpServletRequest; |
|||
import jakarta.validation.Valid; |
|||
import lombok.RequiredArgsConstructor; |
|||
import org.springframework.data.domain.Page; |
|||
import org.springframework.security.core.Authentication; |
|||
import org.springframework.security.core.context.SecurityContextHolder; |
|||
import org.springframework.web.bind.annotation.*; |
|||
|
|||
import java.util.List; |
|||
|
|||
/** |
|||
* 四维能力测评接口(V36)。 |
|||
* |
|||
* <p>身份仅取自 JWT。测评类型对任意已登录角色开放;其余接口仅 STUDENT 且仅限本人。 |
|||
* 不回读完整题库:前端仅通过创建/恢复本人答卷获得本次 24 道题。</p> |
|||
*/ |
|||
@Tag(name = "四维能力测评", description = "能力测评类型、答卷、保存答案、提交、结果与历史") |
|||
@RestController |
|||
@RequiredArgsConstructor |
|||
public class AbilityController { |
|||
|
|||
private final AbilityCatalogService catalogService; |
|||
private final AbilityAttemptService attemptService; |
|||
|
|||
@Operation(summary = "能力测评类型列表", |
|||
description = "返回全部 ACTIVE 能力测评定义,不返回题库/答案/评分规则。任意已登录用户。") |
|||
@ApiResponses({ |
|||
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "OK"), |
|||
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "401", description = "未登录") |
|||
}) |
|||
@GetMapping("/abilities/types") |
|||
public ApiResponse<List<AbilityTypeResponse>> listTypes(HttpServletRequest request) { |
|||
return ApiResponse.ok(catalogService.listTypes(), requestId(request)); |
|||
} |
|||
|
|||
@Operation(summary = "创建能力答卷", |
|||
description = "按四维各抽 6 题(每维三个 competencyTag 各 2)共 24 道并持久化,刷新不重抽。" |
|||
+ "同幂等键重放返回原答卷;有效结果冷却期内 409/9810;90 天完成次数达上限 429/9811。仅 STUDENT。") |
|||
@ApiResponses({ |
|||
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "OK"), |
|||
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "401", description = "未登录"), |
|||
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "403", description = "非 STUDENT(2002)"), |
|||
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "404", description = "类型不存在(9801)"), |
|||
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "409", description = "活跃/幂等/冷却(9808/9809/9810)"), |
|||
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "429", description = "重测次数上限(9811)") |
|||
}) |
|||
@PostMapping("/abilities/attempts") |
|||
public ApiResponse<AbilityAttemptResponse> createAttempt( |
|||
@Valid @RequestBody CreateAbilityAttemptRequest req, HttpServletRequest request) { |
|||
return ApiResponse.ok(attemptService.create(resolveUserId(request), req, AuditContext.from(request)), |
|||
requestId(request)); |
|||
} |
|||
|
|||
@Operation(summary = "能力答卷详情", |
|||
description = "返回本次 24 道题(固定顺序)、已选答案、进度与倒计时。仅本人;超时自动标 EXPIRED。") |
|||
@ApiResponses({ |
|||
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "OK"), |
|||
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "401", description = "未登录"), |
|||
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "403", description = "非本人或非 STUDENT(2002)"), |
|||
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "404", description = "答卷不存在(9802)") |
|||
}) |
|||
@GetMapping("/abilities/attempts/{attemptId}") |
|||
public ApiResponse<AbilityAttemptResponse> getAttempt( |
|||
@PathVariable Long attemptId, HttpServletRequest request) { |
|||
return ApiResponse.ok(attemptService.get(resolveUserId(request), attemptId), requestId(request)); |
|||
} |
|||
|
|||
@Operation(summary = "保存能力测评答案", |
|||
description = "upsert;首次有效保存启动 30 分钟倒计时且并发安全;不延长;到期禁止保存;" |
|||
+ "并发保存冲突 409/9807。仅本人。") |
|||
@ApiResponses({ |
|||
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "OK"), |
|||
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "401", description = "未登录"), |
|||
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "403", description = "非本人或非 STUDENT(2002)"), |
|||
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "404", description = "答卷不存在(9802)"), |
|||
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "400", description = "答案非法/未答完整(9805/9806)"), |
|||
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "409", description = "非法状态/并发/到期(9803/9807)") |
|||
}) |
|||
@PatchMapping("/abilities/attempts/{attemptId}/answers") |
|||
public ApiResponse<AbilityAttemptResponse> saveAnswers( |
|||
@PathVariable Long attemptId, |
|||
@Valid @RequestBody SaveAbilityAnswersRequest req, HttpServletRequest request) { |
|||
return ApiResponse.ok(attemptService.saveAnswers(resolveUserId(request), attemptId, req, |
|||
AuditContext.from(request)), requestId(request)); |
|||
} |
|||
|
|||
@Operation(summary = "提交能力测评答卷", |
|||
description = "24/24 且四维各 6 方可提交(否则 400/9806);超时标 EXPIRED 不生成结果;" |
|||
+ "并发提交最多一条结果与一条审计;重复提交幂等返回原结果。仅本人。") |
|||
@ApiResponses({ |
|||
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "OK"), |
|||
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "401", description = "未登录"), |
|||
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "403", description = "非本人或非 STUDENT(2002)"), |
|||
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "404", description = "答卷不存在(9802)"), |
|||
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "400", description = "未完成(9806)"), |
|||
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "409", description = "非法状态(9803)") |
|||
}) |
|||
@PostMapping("/abilities/attempts/{attemptId}/submit") |
|||
public ApiResponse<AbilityResultResponse> submitAttempt( |
|||
@PathVariable Long attemptId, HttpServletRequest request) { |
|||
return ApiResponse.ok(attemptService.submit(resolveUserId(request), attemptId, |
|||
AuditContext.from(request)), requestId(request)); |
|||
} |
|||
|
|||
@Operation(summary = "查看能力测评结果", |
|||
description = "返回四维分、综合分、短板、提升建议、雷达与每题解析。仅本人;未生成结果 404/9804。") |
|||
@ApiResponses({ |
|||
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "OK"), |
|||
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "401", description = "未登录"), |
|||
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "403", description = "非本人或非 STUDENT(2002)"), |
|||
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "404", description = "结果不存在(9804)") |
|||
}) |
|||
@GetMapping("/abilities/attempts/{attemptId}/result") |
|||
public ApiResponse<AbilityResultResponse> getResult( |
|||
@PathVariable Long attemptId, HttpServletRequest request) { |
|||
return ApiResponse.ok(attemptService.loadResult(resolveUserId(request), attemptId), requestId(request)); |
|||
} |
|||
|
|||
@Operation(summary = "我的能力测评历史", |
|||
description = "分页返回当前学生全部答卷与结果概要。仅 STUDENT 本人。") |
|||
@ApiResponses({ |
|||
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "OK"), |
|||
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "401", description = "未登录"), |
|||
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "403", description = "非 STUDENT(2002)") |
|||
}) |
|||
@GetMapping("/abilities/me") |
|||
public ApiResponse<PageResponse<AbilityHistoryItem>> listMine( |
|||
@RequestParam(defaultValue = "0") int page, |
|||
@RequestParam(defaultValue = "20") int size, |
|||
HttpServletRequest request) { |
|||
Page<AbilityHistoryItem> result = attemptService.listMine(resolveUserId(request), page, size); |
|||
return ApiResponse.ok(PageResponse.of( |
|||
result.getContent(), result.getTotalElements(), result.getNumber(), result.getSize()), |
|||
requestId(request)); |
|||
} |
|||
|
|||
private Long resolveUserId(HttpServletRequest request) { |
|||
Authentication auth = SecurityContextHolder.getContext().getAuthentication(); |
|||
if (auth == null || !auth.isAuthenticated()) { |
|||
throw new BusinessException(ErrorCode.UNAUTHORIZED); |
|||
} |
|||
return Long.valueOf(auth.getName()); |
|||
} |
|||
|
|||
private String requestId(HttpServletRequest request) { |
|||
return request.getHeader("X-Request-Id"); |
|||
} |
|||
} |
|||
@ -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; |
|||
} |
|||
@ -0,0 +1,13 @@ |
|||
package com.mydreamjob.ability; |
|||
|
|||
import org.springframework.data.jpa.repository.JpaRepository; |
|||
|
|||
import java.util.List; |
|||
import java.util.Optional; |
|||
|
|||
public interface AbilityDefinitionRepository extends JpaRepository<AbilityDefinition, Long> { |
|||
|
|||
Optional<AbilityDefinition> findByTypeCode(String typeCode); |
|||
|
|||
List<AbilityDefinition> findAllByStatusOrderByCreatedAtAsc(String status); |
|||
} |
|||
@ -0,0 +1,98 @@ |
|||
package com.mydreamjob.ability; |
|||
|
|||
import java.util.ArrayList; |
|||
import java.util.LinkedHashMap; |
|||
import java.util.List; |
|||
import java.util.Map; |
|||
|
|||
/** |
|||
* 固定白名单建议目录(V36,非 AI)。每个 competencyTag 提供短板提示摘要与可执行动作。 |
|||
*/ |
|||
public final class AbilityImprovementCatalog { |
|||
|
|||
private AbilityImprovementCatalog() { |
|||
} |
|||
|
|||
public static final Map<String, String[]> ACTIONS = new LinkedHashMap<>(); |
|||
|
|||
static { |
|||
ACTIONS.put("INFORMATION_ANALYSIS", new String[]{ |
|||
"每次对数据进行判断前,先统一指标定义、时间范围与数据来源。", |
|||
"把“结论”与“证据来源”拆开记录,遇到不一致时先定位原始数据。", |
|||
"养成用“假设-验证-记录”清单核对结果的习惯。"}); |
|||
ACTIONS.put("CAUSAL_REASONING", new String[]{ |
|||
"区分“相关”与“因果”,只有对照与趋势分析才能支持因果判断。", |
|||
"排查同时变化的混杂因素,避免把时间先后当作因果。", |
|||
"用最小可验证实验(单变量对照)检验关键假设。"}); |
|||
ACTIONS.put("PROBLEM_SOLVING", new String[]{ |
|||
"把模糊任务先转化为用户、场景、问题和验收标准。", |
|||
"复杂问题先拆子问题、明确输入输出,再逐项验证。", |
|||
"方案失败时对照预期与实际,检查假设、执行偏差与外部因素。"}); |
|||
ACTIONS.put("LISTENING_UNDERSTANDING", new String[]{ |
|||
"用“复述理解-确认目标/边界”的方式回应模糊需求。", |
|||
"先听完事实与影响,再提建议,避免打断或提前下结论。", |
|||
"把情绪表达转化为具体场景、期望与影响后再行动。"}); |
|||
ACTIONS.put("STRUCTURED_EXPRESSION", new String[]{ |
|||
"重要消息按“背景-问题-行动-负责人-截止时间”组织。", |
|||
"面向不同对象选择信息颗粒度,先讲影响再讲原理。", |
|||
"反馈聚焦具体的可观察行为、影响与修改标准,不上升为人格评价。"}); |
|||
ACTIONS.put("COLLABORATION_CONFLICT", new String[]{ |
|||
"分歧发生时先确认共同目标,再用统一标准比较方案。", |
|||
"跨团队问题先确认事实与阻塞,再共同制定新节点与升级条件。", |
|||
"边界沟通说明已有承诺与优先级,主动提出替代时间或资源方案。"}); |
|||
ACTIONS.put("PLANNING_PRIORITIZATION", new String[]{ |
|||
"计划从交付结果出发,明确步骤、依赖、负责人与检查节点。", |
|||
"资源冲突时按影响、时效与依赖排序,并同步取舍方案。", |
|||
"范围变化时量化影响,推动范围/资源/时间的明确取舍。"}); |
|||
ACTIONS.put("RISK_FOLLOWUP", new String[]{ |
|||
"记录风险触发条件、概率、影响、责任人、应对方案并持续检查。", |
|||
"阻塞时透明说明影响,提出备选路径并约定升级时间。", |
|||
"用可验证的小节点产出衡量真实进度,而非主观百分比。"}); |
|||
ACTIONS.put("DELIVERY_REVIEW", new String[]{ |
|||
"交付前用验收标准确认结果,并补齐测试、文档、监控与交接。", |
|||
"临近截止时间守住关键质量门槛,用范围取舍与分阶段交付管理风险。", |
|||
"复盘比较目标与实际,形成有负责人和期限的改进动作。"}); |
|||
ACTIONS.put("INFORMATION_ACQUISITION", new String[]{ |
|||
"学习新技能先明确使用目标,读权威资料并做小练习再应用。", |
|||
"比较信息源、发布时间、证据与适用条件,并做小范围验证。", |
|||
"围绕真实问题建立框架,再用实践和反馈补齐知识。"}); |
|||
ACTIONS.put("FEEDBACK_REFLECTION", new String[]{ |
|||
"把模糊反馈转化为具体例子,修改后再次验证是否解决。", |
|||
"失败后还原目标、过程与关键决策,区分假设、执行与外部因素。", |
|||
"用“目标-练习-反馈-调整-再验证”的闭环持续提升。"}); |
|||
ACTIONS.put("TRANSFER_ADAPTATION", new String[]{ |
|||
"迁移方法时识别共同原理与条件差异,用结果验证适配。", |
|||
"求助时说明目标、现象、已尝试方法与当前判断。", |
|||
"面对变化结合需求、成本与验证结果,规划分阶段迁移与学习。"}); |
|||
} |
|||
|
|||
public static Advice get(String tag) { |
|||
String[] actions = ACTIONS.get(tag); |
|||
if (actions == null) { |
|||
return new Advice("结合工作情境,分析本题对应能力并制定练习计划。", |
|||
List.of("复盘本题更合理的处理方式并复述关键判断。", "在真实任务中主动实践对应能力并记录结果。", "阶段性回顾并调整改进动作。")); |
|||
} |
|||
return new Advice(summary(tag), List.of(actions)); |
|||
} |
|||
|
|||
public record Advice(String summary, List<String> actions) { |
|||
} |
|||
|
|||
private static String summary(String tag) { |
|||
return switch (tag) { |
|||
case "INFORMATION_ANALYSIS" -> "提升对信息口径与来源的核查颗粒度,先统一再做判断。"; |
|||
case "CAUSAL_REASONING" -> "加强因果判断意识,用对照与单变量验证支持结论。"; |
|||
case "PROBLEM_SOLVING" -> "先定义问题与验收标准,再拆解并逐项验证解决方案。"; |
|||
case "LISTENING_UNDERSTANDING" -> "先确认理解与目标,再给出回应,避免基于错误假设的建议。"; |
|||
case "STRUCTURED_EXPRESSION" -> "面向受众组织信息,先结论后证据,明确行动与负责人。"; |
|||
case "COLLABORATION_CONFLICT" -> "用共同目标与一致标准处理分歧,前置确认事实与优先级。"; |
|||
case "PLANNING_PRIORITIZATION" -> "以结果与节点驱动计划,资源冲突时显性取舍并同步相关方。"; |
|||
case "RISK_FOLLOWUP" -> "隐患转化为可观察的风险条目并持续跟进,而非一次性提醒。"; |
|||
case "DELIVERY_REVIEW" -> "交付以验收标准为闭环,复盘以有责任人和期限的动作落地。"; |
|||
case "INFORMATION_ACQUISITION" -> "围绕目标选权威信息并动手验证,用实践补齐知识。"; |
|||
case "FEEDBACK_REFLECTION" -> "把反馈变成具体行动并通过再次验证确认改进。"; |
|||
case "TRANSFER_ADAPTATION" -> "识别原理与条件差异做迁移,变化时规划分阶段落地。"; |
|||
default -> "结合情境制定对应能力维度的练习与复盘计划。"; |
|||
}; |
|||
} |
|||
} |
|||
@ -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; |
|||
} |
|||
@ -0,0 +1,12 @@ |
|||
package com.mydreamjob.ability; |
|||
|
|||
import org.springframework.data.jpa.repository.JpaRepository; |
|||
|
|||
import java.util.List; |
|||
|
|||
public interface AbilityQuestionRepository extends JpaRepository<AbilityQuestion, Long> { |
|||
|
|||
List<AbilityQuestion> findByDefinitionIdAndActiveTrueOrderByPositionAsc(Long definitionId); |
|||
|
|||
List<AbilityQuestion> findAllByDefinitionIdOrderByPositionAsc(Long definitionId); |
|||
} |
|||
@ -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; |
|||
} |
|||
@ -0,0 +1,150 @@ |
|||
package com.mydreamjob.ability; |
|||
|
|||
import com.mydreamjob.ability.dto.AbilityResultResponse; |
|||
import org.springframework.stereotype.Component; |
|||
|
|||
import java.math.BigDecimal; |
|||
import java.math.RoundingMode; |
|||
import java.util.ArrayList; |
|||
import java.util.Comparator; |
|||
import java.util.LinkedHashMap; |
|||
import java.util.List; |
|||
import java.util.Map; |
|||
|
|||
/** |
|||
* 四维能力计分与结果计算机(V36)。 |
|||
* |
|||
* <p>每题最优 4 / 次优 2 / 一般 1 / 高风险 0;每维 6 题满分 24, |
|||
* dimensionScore = rawScore / 24 × 100;overallScore = 四维平均(须四维齐全)。 |
|||
* 满分与单维题数由定义给出,但默认 4 / 6 保持一致。</p> |
|||
*/ |
|||
@Component |
|||
public class AbilityResultComputer { |
|||
|
|||
public static final int MAX_SCORE_PER_QUESTION = 4; |
|||
public static final List<String> DIMENSIONS = |
|||
List.of("LOGIC", "COMMUNICATION", "EXECUTION", "LEARNING"); |
|||
|
|||
public static final String DISCLAIMER = |
|||
"本测评反映当前工作情境答题表现,用于自我了解和能力提升参考;不构成职业资格、正式招聘结论或永久能力标签。"; |
|||
|
|||
public static final String VALIDITY_VALID = "VALID"; |
|||
public static final String VALIDITY_EXPIRED = "EXPIRED"; |
|||
|
|||
public record Answered(String dimension, String competencyTag, String stem, String explanation, |
|||
int awardedScore, long attemptQuestionId, int position, |
|||
String selectedOptionId) { |
|||
} |
|||
|
|||
public record ComputedResult(List<AbilityResultResponse.DimensionScore> dimensionScores, |
|||
BigDecimal overallScore, |
|||
List<AbilityResultResponse.Weakness> weaknesses, |
|||
List<AbilityResultResponse.ImprovementArea> improvementAreas, |
|||
List<AbilityResultResponse.RadarEntry> radar, |
|||
List<AbilityResultResponse.QuestionFeedback> questionFeedback) { |
|||
} |
|||
|
|||
public ComputedResult compute(List<Answered> items, int perDimQuestions) { |
|||
List<AbilityResultResponse.DimensionScore> dims = new ArrayList<>(); |
|||
Map<String, List<Answered>> byDim = new LinkedHashMap<>(); |
|||
for (String d : DIMENSIONS) { |
|||
byDim.put(d, items.stream().filter(it -> d.equals(it.dimension())).toList()); |
|||
} |
|||
Map<String, Integer> rawByDim = new LinkedHashMap<>(); |
|||
for (Map.Entry<String, List<Answered>> e : byDim.entrySet()) { |
|||
int raw = e.getValue().stream().mapToInt(Answered::awardedScore).sum(); |
|||
rawByDim.put(e.getKey(), raw); |
|||
int maxScoreTotal = perDimQuestions * MAX_SCORE_PER_QUESTION; |
|||
BigDecimal score = maxScoreTotal <= 0 ? BigDecimal.ZERO.setScale(2) |
|||
: BigDecimal.valueOf(raw).multiply(BigDecimal.valueOf(100)) |
|||
.divide(BigDecimal.valueOf(maxScoreTotal), 2, RoundingMode.HALF_UP); |
|||
dims.add(AbilityResultResponse.DimensionScore.builder() |
|||
.code(e.getKey()).score(score).answeredCount(e.getValue().size()).build()); |
|||
} |
|||
|
|||
// 综合分:四维都必须有 6 道有效答案
|
|||
BigDecimal overall = null; |
|||
boolean allAnswered = DIMENSIONS.stream().allMatch(d -> byDim.get(d).size() >= perDimQuestions); |
|||
if (allAnswered && !dims.isEmpty()) { |
|||
BigDecimal sum = dims.stream().map(AbilityResultResponse.DimensionScore::getScore) |
|||
.reduce(BigDecimal.ZERO, BigDecimal::add); |
|||
overall = sum.divide(BigDecimal.valueOf(dims.size()), 2, RoundingMode.HALF_UP); |
|||
} |
|||
|
|||
// 短板:四维最低分,并列全部返回
|
|||
BigDecimal min = dims.stream().map(AbilityResultResponse.DimensionScore::getScore) |
|||
.min(Comparator.naturalOrder()).orElse(null); |
|||
List<AbilityResultResponse.Weakness> weaknesses = new ArrayList<>(); |
|||
if (min != null) { |
|||
for (AbilityResultResponse.DimensionScore ds : dims) { |
|||
if (ds.getScore().compareTo(min) == 0) { |
|||
weaknesses.add(AbilityResultResponse.Weakness.builder() |
|||
.code(ds.getCode()).score(ds.getScore()).build()); |
|||
} |
|||
} |
|||
} |
|||
|
|||
// 提升建议:最低维度的最低 competencyTag,固定白名单
|
|||
List<AbilityResultResponse.ImprovementArea> areas = new ArrayList<>(); |
|||
if (min != null) { |
|||
String weakestDim = dims.stream() |
|||
.filter(ds -> ds.getScore().compareTo(min) == 0) |
|||
.min(Comparator.comparing(AbilityResultResponse.DimensionScore::getCode)) |
|||
.map(AbilityResultResponse.DimensionScore::getCode).orElse(null); |
|||
if (weakestDim != null) { |
|||
String tag = lowestCompetencyTag(byDim.getOrDefault(weakestDim, List.of())); |
|||
BigDecimal tagScore = competencyScore(byDim.getOrDefault(weakestDim, List.of()), tag); |
|||
AbilityImprovementCatalog.Advice advice = |
|||
AbilityImprovementCatalog.get(tag == null ? weakestDim : tag); |
|||
// competencyTag 仅内部用于生成摘要与动作,不写入对外 DTO
|
|||
areas.add(AbilityResultResponse.ImprovementArea.builder() |
|||
.dimension(weakestDim) |
|||
.currentScore(tagScore).summary(advice.summary()).actions(advice.actions()) |
|||
.build()); |
|||
} |
|||
} |
|||
|
|||
// 雷达
|
|||
List<AbilityResultResponse.RadarEntry> radar = new ArrayList<>(); |
|||
for (AbilityResultResponse.DimensionScore ds : dims) { |
|||
radar.add(AbilityResultResponse.RadarEntry.builder() |
|||
.code(ds.getCode()).value(ds.getScore()).build()); |
|||
} |
|||
|
|||
// 每题解析(仅暴露本题解析文本,不返回答案键与完整评分规则)
|
|||
List<AbilityResultResponse.QuestionFeedback> feedback = new ArrayList<>(); |
|||
for (Answered a : items) { |
|||
feedback.add(AbilityResultResponse.QuestionFeedback.builder() |
|||
.attemptQuestionId(a.attemptQuestionId()).position(a.position()) |
|||
.stem(a.stem()).selectedOptionId(a.selectedOptionId()) |
|||
.awardedScore(a.awardedScore()).maxScore(MAX_SCORE_PER_QUESTION) |
|||
.explanation(a.explanation()).build()); |
|||
} |
|||
|
|||
return new ComputedResult(dims, overall, weaknesses, areas, radar, feedback); |
|||
} |
|||
|
|||
private String lowestCompetencyTag(List<Answered> items) { |
|||
Map<String, Integer> raw = new LinkedHashMap<>(); |
|||
for (Answered a : items) { |
|||
raw.merge(a.competencyTag(), a.awardedScore(), Integer::sum); |
|||
} |
|||
return raw.entrySet().stream() |
|||
.min(Comparator.<Map.Entry<String, Integer>>comparingInt(Map.Entry::getValue) |
|||
.thenComparing(Map.Entry::getKey)) |
|||
.map(Map.Entry::getKey).orElse(null); |
|||
} |
|||
|
|||
private BigDecimal competencyScore(List<Answered> items, String tag) { |
|||
List<Answered> tagged = items.stream() |
|||
.filter(a -> java.util.Objects.equals(tag, a.competencyTag())).toList(); |
|||
if (tagged.isEmpty()) { |
|||
return BigDecimal.ZERO.setScale(2); |
|||
} |
|||
int raw = tagged.stream().mapToInt(Answered::awardedScore).sum(); |
|||
int maxCol = tagged.size() * MAX_SCORE_PER_QUESTION; |
|||
return maxCol <= 0 ? BigDecimal.ZERO.setScale(2) |
|||
: BigDecimal.valueOf(raw).multiply(BigDecimal.valueOf(100)) |
|||
.divide(BigDecimal.valueOf(maxCol), 2, RoundingMode.HALF_UP); |
|||
} |
|||
} |
|||
@ -0,0 +1,14 @@ |
|||
package com.mydreamjob.ability; |
|||
|
|||
import org.springframework.data.jpa.repository.JpaRepository; |
|||
|
|||
import java.util.Collection; |
|||
import java.util.List; |
|||
import java.util.Optional; |
|||
|
|||
public interface AbilityResultRepository extends JpaRepository<AbilityResult, Long> { |
|||
|
|||
Optional<AbilityResult> findByAttemptId(Long attemptId); |
|||
|
|||
List<AbilityResult> findByAttemptIdIn(Collection<Long> attemptIds); |
|||
} |
|||
@ -0,0 +1,109 @@ |
|||
package com.mydreamjob.ability; |
|||
|
|||
import com.fasterxml.jackson.core.type.TypeReference; |
|||
import com.fasterxml.jackson.databind.ObjectMapper; |
|||
import com.mydreamjob.ability.dto.AbilityResultResponse; |
|||
import lombok.RequiredArgsConstructor; |
|||
import org.springframework.stereotype.Service; |
|||
|
|||
import java.time.LocalDateTime; |
|||
import java.util.ArrayList; |
|||
import java.util.List; |
|||
|
|||
@Service |
|||
@RequiredArgsConstructor |
|||
public class AbilityResultService { |
|||
|
|||
private final ObjectMapper objectMapper; |
|||
|
|||
private static final TypeReference<List<AbilityResultResponse.DimensionScore>> DIM_LIST = |
|||
new TypeReference<>() { |
|||
}; |
|||
private static final TypeReference<List<AbilityResultResponse.Weakness>> WEAK_LIST = |
|||
new TypeReference<>() { |
|||
}; |
|||
private static final TypeReference<List<AbilityResultResponse.ImprovementArea>> AREA_LIST = |
|||
new TypeReference<>() { |
|||
}; |
|||
private static final TypeReference<List<AbilityResultResponse.QuestionFeedback>> FEEDBACK_LIST = |
|||
new TypeReference<>() { |
|||
}; |
|||
|
|||
public AbilityResult persist(AbilityAttempt attempt, AbilityDefinition def, |
|||
AbilityResultComputer.ComputedResult computed, LocalDateTime now) { |
|||
AbilityResult r = AbilityResult.builder() |
|||
.attemptId(attempt.getId()) |
|||
.dimensionScoresJson(write(computed.dimensionScores())) |
|||
.overallScore(computed.overallScore()) |
|||
.weaknessesJson(write(computed.weaknesses())) |
|||
.improvementAreasJson(write(computed.improvementAreas())) |
|||
.questionFeedbackJson(write(computed.questionFeedback())) |
|||
.validUntil(now.plusDays(def.getResultValidDays() == null ? 7 : def.getResultValidDays())) |
|||
.questionSetVersion(def.getQuestionSetVersion()) |
|||
.rulesVersion(def.getRulesVersion()) |
|||
.generatedAt(now) |
|||
.build(); |
|||
return r; |
|||
} |
|||
|
|||
public AbilityResultResponse toResponse(AbilityAttempt attempt, AbilityResult result, AbilityDefinition def, |
|||
LocalDateTime now) { |
|||
List<AbilityResultResponse.DimensionScore> dims = read(result.getDimensionScoresJson(), DIM_LIST); |
|||
List<AbilityResultResponse.RadarEntry> radar = new ArrayList<>(); |
|||
for (AbilityResultResponse.DimensionScore ds : dims) { |
|||
radar.add(AbilityResultResponse.RadarEntry.builder() |
|||
.code(ds.getCode()).value(ds.getScore()).build()); |
|||
} |
|||
boolean valid = result.getValidUntil() != null && now.isBefore(result.getValidUntil()); |
|||
return AbilityResultResponse.builder() |
|||
.attemptId(result.getAttemptId()) |
|||
.typeCode(def == null ? null : def.getTypeCode()) |
|||
.status(AbilityAttempt.STATUS_COMPLETED) |
|||
.validityStatus(valid ? AbilityResultComputer.VALIDITY_VALID |
|||
: AbilityResultComputer.VALIDITY_EXPIRED) |
|||
.validUntil(result.getValidUntil()) |
|||
.dimensionScores(dims) |
|||
.overallScore(result.getOverallScore()) |
|||
.weaknesses(read(result.getWeaknessesJson(), WEAK_LIST)) |
|||
.improvementAreas(read(result.getImprovementAreasJson(), AREA_LIST)) |
|||
.radar(radar) |
|||
.answeredCount(attempt.getAnsweredCount()) |
|||
.completionRate(attempt.getAnsweredCount() == null ? null |
|||
: completionRate(attempt.getAnsweredCount(), |
|||
def == null || def.getAttemptQuestionCount() == null ? 24 : def.getAttemptQuestionCount())) |
|||
.questionFeedback(read(result.getQuestionFeedbackJson(), FEEDBACK_LIST)) |
|||
.questionSetVersion(result.getQuestionSetVersion()) |
|||
.rulesVersion(result.getRulesVersion()) |
|||
.generatedAt(result.getGeneratedAt()) |
|||
.disclaimer(AbilityResultComputer.DISCLAIMER) |
|||
.build(); |
|||
} |
|||
|
|||
private java.math.BigDecimal completionRate(int answered, int total) { |
|||
if (total <= 0) { |
|||
return java.math.BigDecimal.ZERO.setScale(2); |
|||
} |
|||
return java.math.BigDecimal.valueOf(answered) |
|||
.multiply(java.math.BigDecimal.valueOf(100)) |
|||
.divide(java.math.BigDecimal.valueOf(total), 2, java.math.RoundingMode.HALF_UP); |
|||
} |
|||
|
|||
private String write(Object obj) { |
|||
try { |
|||
return objectMapper.writeValueAsString(obj); |
|||
} catch (Exception e) { |
|||
throw new IllegalStateException("unable to serialize ability result", e); |
|||
} |
|||
} |
|||
|
|||
private <T> List<T> read(String json, TypeReference<List<T>> ref) { |
|||
if (json == null || json.isBlank()) { |
|||
return List.of(); |
|||
} |
|||
try { |
|||
return objectMapper.readValue(json, ref); |
|||
} catch (Exception e) { |
|||
throw new IllegalStateException("unable to deserialize ability result", e); |
|||
} |
|||
} |
|||
} |
|||
@ -0,0 +1,88 @@ |
|||
package com.mydreamjob.ability; |
|||
|
|||
import com.fasterxml.jackson.databind.JsonNode; |
|||
import com.fasterxml.jackson.databind.ObjectMapper; |
|||
import lombok.RequiredArgsConstructor; |
|||
import org.springframework.stereotype.Component; |
|||
|
|||
import java.util.LinkedHashMap; |
|||
import java.util.Map; |
|||
|
|||
/** |
|||
* 题目快照编解码:把 {@link AbilityQuestion} 关键内容固化为快照 JSON, |
|||
* 并据此判定所选选项所得分值。快照保证后续题库更新不改历史答卷评分。 |
|||
*/ |
|||
@Component |
|||
@RequiredArgsConstructor |
|||
public class AbilitySnapshotCodec { |
|||
|
|||
public static final String KEY_QUESTION_ID = "questionId"; |
|||
public static final String KEY_QUESTION_CODE = "questionCode"; |
|||
public static final String KEY_DIMENSION = "dimension"; |
|||
public static final String KEY_COMPETENCY_TAG = "competencyTag"; |
|||
public static final String KEY_ITEM_TYPE = "itemType"; |
|||
public static final String KEY_STEM = "stem"; |
|||
public static final String KEY_OPTIONS = "options"; |
|||
public static final String KEY_ANSWER_KEY = "answerKey"; |
|||
public static final String KEY_SCORING_RULE = "scoringRule"; |
|||
public static final String KEY_EXPLANATION = "explanation"; |
|||
|
|||
private final ObjectMapper objectMapper; |
|||
|
|||
public String build(AbilityQuestion q) { |
|||
try { |
|||
Map<String, Object> snap = new LinkedHashMap<>(); |
|||
snap.put(KEY_QUESTION_ID, q.getId()); |
|||
snap.put(KEY_QUESTION_CODE, q.getQuestionCode()); |
|||
snap.put(KEY_DIMENSION, q.getDimension()); |
|||
snap.put(KEY_COMPETENCY_TAG, q.getCompetencyTag()); |
|||
snap.put(KEY_ITEM_TYPE, q.getItemType()); |
|||
snap.put(KEY_STEM, q.getStem()); |
|||
snap.put(KEY_OPTIONS, objectMapper.readTree(q.getOptionsJson())); |
|||
snap.put(KEY_ANSWER_KEY, objectMapper.readTree(q.getAnswerKeyJson()).path("answerKey").asText()); |
|||
snap.put(KEY_SCORING_RULE, objectMapper.readTree(q.getScoringRuleJson())); |
|||
snap.put(KEY_EXPLANATION, q.getExplanation()); |
|||
return objectMapper.writeValueAsString(snap); |
|||
} catch (Exception e) { |
|||
throw new IllegalStateException("unable to build ability question snapshot", e); |
|||
} |
|||
} |
|||
|
|||
public JsonNode parse(String snapshotJson) { |
|||
try { |
|||
return objectMapper.readTree(snapshotJson); |
|||
} catch (Exception e) { |
|||
throw new IllegalStateException("unable to parse ability question snapshot", e); |
|||
} |
|||
} |
|||
|
|||
public String text(String snapshotJson, String key) { |
|||
JsonNode n = parse(snapshotJson).path(key); |
|||
return n.isMissingNode() || n.isNull() ? null : n.asText(); |
|||
} |
|||
|
|||
/** |
|||
* 按所选选项返回分值(0/1/2/4);选项不存在返回 null。 |
|||
*/ |
|||
public Integer awardedScore(String snapshotJson, String selectedOptionId) { |
|||
JsonNode rule = parse(snapshotJson).path(KEY_SCORING_RULE); |
|||
if (rule == null || rule.isMissingNode() || selectedOptionId == null) { |
|||
return null; |
|||
} |
|||
JsonNode v = rule.get(selectedOptionId); |
|||
return (v == null || v.isNull()) ? null : v.asInt(); |
|||
} |
|||
|
|||
public boolean optionExists(String snapshotJson, String optionId) { |
|||
JsonNode options = parse(snapshotJson).path(KEY_OPTIONS); |
|||
if (options.isMissingNode() || !options.isArray()) { |
|||
return false; |
|||
} |
|||
for (JsonNode o : options) { |
|||
if (optionId.equals(o.path("optionId").asText())) { |
|||
return true; |
|||
} |
|||
} |
|||
return false; |
|||
} |
|||
} |
|||
@ -0,0 +1,44 @@ |
|||
package com.mydreamjob.ability.dto; |
|||
|
|||
import lombok.Builder; |
|||
import lombok.Data; |
|||
|
|||
import java.math.BigDecimal; |
|||
import java.time.LocalDateTime; |
|||
import java.util.List; |
|||
|
|||
/** |
|||
* 答卷响应(创建/详情)。题目仅暴露 attemptQuestionId / position / itemType / stem / options / selectedOptionId, |
|||
* 绝不返回 questionCode / dimension / competencyTag / answerKey / scoringRule / explanation / difficulty 等内部字段。 |
|||
*/ |
|||
@Data |
|||
@Builder |
|||
public class AbilityAttemptResponse { |
|||
private Long attemptId; |
|||
private String status; |
|||
private Integer answeredCount; |
|||
private Integer totalQuestions; |
|||
private BigDecimal completionRate; |
|||
private LocalDateTime startedAt; |
|||
private LocalDateTime expiresAt; |
|||
private Long remainingSeconds; |
|||
private List<AbilityAttemptQuestionView> questions; |
|||
|
|||
@Data |
|||
@Builder |
|||
public static class AbilityAttemptQuestionView { |
|||
private Long attemptQuestionId; |
|||
private Integer position; |
|||
private String itemType; |
|||
private String stem; |
|||
private List<AbilityOptionView> options; |
|||
private String selectedOptionId; |
|||
} |
|||
|
|||
@Data |
|||
@Builder |
|||
public static class AbilityOptionView { |
|||
private String optionId; |
|||
private String text; |
|||
} |
|||
} |
|||
@ -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; |
|||
} |
|||
@ -0,0 +1,76 @@ |
|||
package com.mydreamjob.ability.dto; |
|||
|
|||
import lombok.Builder; |
|||
import lombok.Data; |
|||
|
|||
import java.math.BigDecimal; |
|||
import java.time.LocalDateTime; |
|||
import java.util.List; |
|||
|
|||
/** |
|||
* 结果响应白名单。不含原始答案分值键/完整评分规则/学生身份等敏感字段。 |
|||
*/ |
|||
@Data |
|||
@Builder |
|||
public class AbilityResultResponse { |
|||
private Long attemptId; |
|||
private String typeCode; |
|||
private String status; |
|||
private String validityStatus; // VALID / EXPIRED(动态计算)
|
|||
private LocalDateTime validUntil; |
|||
private List<DimensionScore> dimensionScores; |
|||
private BigDecimal overallScore; |
|||
private List<Weakness> weaknesses; |
|||
private List<ImprovementArea> improvementAreas; |
|||
private List<RadarEntry> radar; |
|||
private Integer answeredCount; |
|||
private BigDecimal completionRate; |
|||
private List<QuestionFeedback> questionFeedback; |
|||
private String questionSetVersion; |
|||
private String rulesVersion; |
|||
private LocalDateTime generatedAt; |
|||
private String disclaimer; |
|||
|
|||
@Data |
|||
@Builder |
|||
public static class DimensionScore { |
|||
private String code; // LOGIC/COMMUNICATION/EXECUTION/LEARNING
|
|||
private BigDecimal score; |
|||
private Integer answeredCount; |
|||
} |
|||
|
|||
@Data |
|||
@Builder |
|||
public static class Weakness { |
|||
private String code; |
|||
private BigDecimal score; |
|||
} |
|||
|
|||
@Data |
|||
@Builder |
|||
public static class ImprovementArea { |
|||
private String dimension; |
|||
private BigDecimal currentScore; |
|||
private String summary; |
|||
private List<String> actions; |
|||
} |
|||
|
|||
@Data |
|||
@Builder |
|||
public static class RadarEntry { |
|||
private String code; |
|||
private BigDecimal value; |
|||
} |
|||
|
|||
@Data |
|||
@Builder |
|||
public static class QuestionFeedback { |
|||
private Long attemptQuestionId; |
|||
private Integer position; |
|||
private String stem; |
|||
private String selectedOptionId; |
|||
private Integer awardedScore; |
|||
private Integer maxScore; |
|||
private String explanation; |
|||
} |
|||
} |
|||
@ -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; |
|||
} |
|||
@ -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; |
|||
} |
|||
@ -0,0 +1,21 @@ |
|||
package com.mydreamjob.ability.dto; |
|||
|
|||
import jakarta.validation.Valid; |
|||
import jakarta.validation.constraints.NotEmpty; |
|||
import lombok.Data; |
|||
|
|||
import java.util.List; |
|||
|
|||
@Data |
|||
public class SaveAbilityAnswersRequest { |
|||
|
|||
@Valid |
|||
@NotEmpty(message = "answers must not be empty") |
|||
private List<AnswerItem> answers; |
|||
|
|||
@Data |
|||
public static class AnswerItem { |
|||
private Long attemptQuestionId; |
|||
private String selectedOptionId; |
|||
} |
|||
} |
|||
@ -0,0 +1,43 @@ |
|||
package com.mydreamjob.assessment; |
|||
|
|||
import jakarta.persistence.*; |
|||
import lombok.AllArgsConstructor; |
|||
import lombok.Builder; |
|||
import lombok.Data; |
|||
import lombok.NoArgsConstructor; |
|||
import org.springframework.data.annotation.CreatedDate; |
|||
import org.springframework.data.jpa.domain.support.AuditingEntityListener; |
|||
|
|||
import java.time.LocalDateTime; |
|||
|
|||
/** |
|||
* 单题答案(V34/V35)。 |
|||
* |
|||
* <p>value 使用 1-5 Likert 量表(1=完全不感兴趣 … 5=非常感兴趣)。同 (attempt, question) 唯一,重复保存走 upsert。</p> |
|||
*/ |
|||
@Data |
|||
@Builder |
|||
@NoArgsConstructor |
|||
@AllArgsConstructor |
|||
@Entity |
|||
@Table(name = "assessment_answers") |
|||
@EntityListeners(AuditingEntityListener.class) |
|||
public class AssessmentAnswer { |
|||
|
|||
@Id |
|||
@GeneratedValue(strategy = GenerationType.IDENTITY) |
|||
private Long id; |
|||
|
|||
@Column(name = "attempt_id", nullable = false) |
|||
private Long attemptId; |
|||
|
|||
@Column(name = "question_id", nullable = false) |
|||
private Long questionId; |
|||
|
|||
@Column(name = "answer_value", nullable = false) |
|||
private Integer value; |
|||
|
|||
@CreatedDate |
|||
@Column(name = "answered_at", nullable = false, updatable = false) |
|||
private LocalDateTime answeredAt; |
|||
} |
|||
@ -0,0 +1,15 @@ |
|||
package com.mydreamjob.assessment; |
|||
|
|||
import org.springframework.data.jpa.repository.JpaRepository; |
|||
import org.springframework.stereotype.Repository; |
|||
|
|||
import java.util.List; |
|||
import java.util.Optional; |
|||
|
|||
@Repository |
|||
public interface AssessmentAnswerRepository extends JpaRepository<AssessmentAnswer, Long> { |
|||
Optional<AssessmentAnswer> findByAttemptIdAndQuestionId(Long attemptId, Long questionId); |
|||
List<AssessmentAnswer> findByAttemptId(Long attemptId); |
|||
long countByAttemptId(Long attemptId); |
|||
void deleteByAttemptId(Long attemptId); |
|||
} |
|||
@ -0,0 +1,89 @@ |
|||
package com.mydreamjob.assessment; |
|||
|
|||
import jakarta.persistence.*; |
|||
import lombok.AllArgsConstructor; |
|||
import lombok.Builder; |
|||
import lombok.Data; |
|||
import lombok.NoArgsConstructor; |
|||
import org.springframework.data.annotation.CreatedDate; |
|||
import org.springframework.data.annotation.LastModifiedDate; |
|||
import org.springframework.data.jpa.domain.support.AuditingEntityListener; |
|||
|
|||
import java.time.LocalDateTime; |
|||
|
|||
/** |
|||
* 答卷主记录(V34/V35)。 |
|||
* |
|||
* <p>状态流转:DRAFT → IN_PROGRESS → COMPLETED。{@code samplingSeed} 用于抽题随机种子, |
|||
* 持久化但不下发前端。{@code idempotencyKey} 与 {@code studentUserId} 联合唯一实现创建幂等。</p> |
|||
*/ |
|||
@Data |
|||
@Builder |
|||
@NoArgsConstructor |
|||
@AllArgsConstructor |
|||
@Entity |
|||
@Table(name = "assessment_attempts") |
|||
@EntityListeners(AuditingEntityListener.class) |
|||
public class AssessmentAttempt { |
|||
|
|||
public static final String STATUS_DRAFT = "DRAFT"; |
|||
public static final String STATUS_IN_PROGRESS = "IN_PROGRESS"; |
|||
public static final String STATUS_COMPLETED = "COMPLETED"; |
|||
public static final String STATUS_EXPIRED = "EXPIRED"; |
|||
|
|||
@Id |
|||
@GeneratedValue(strategy = GenerationType.IDENTITY) |
|||
private Long id; |
|||
|
|||
@Column(name = "student_user_id", nullable = false) |
|||
private Long studentUserId; |
|||
|
|||
@Column(name = "definition_id", nullable = false) |
|||
private Long definitionId; |
|||
|
|||
@Column(nullable = false, length = 20) |
|||
@Builder.Default |
|||
private String status = STATUS_DRAFT; |
|||
|
|||
@Column(name = "sampling_seed") |
|||
private Long samplingSeed; |
|||
|
|||
@Column(name = "question_set_version", nullable = false, length = 40) |
|||
private String questionSetVersion; |
|||
|
|||
@Column(name = "selected_question_count", nullable = false) |
|||
private Integer selectedQuestionCount; |
|||
|
|||
@Column(name = "answered_count", nullable = false) |
|||
@Builder.Default |
|||
private Integer answeredCount = 0; |
|||
|
|||
@Column(name = "idempotency_key", length = 64) |
|||
private String idempotencyKey; |
|||
|
|||
/** 活跃答卷唯一键:DRAFT/IN_PROGRESS 非空,终态后置 NULL(数据库唯一约束保证同学生同类型仅一份活跃) */ |
|||
@Column(name = "active_key", length = 255) |
|||
private String activeKey; |
|||
|
|||
@Version |
|||
@Column(nullable = false) |
|||
@Builder.Default |
|||
private Long version = 0L; |
|||
|
|||
@Column(name = "started_at") |
|||
private LocalDateTime startedAt; |
|||
|
|||
@Column(name = "submitted_at") |
|||
private LocalDateTime submittedAt; |
|||
|
|||
@Column(name = "completed_at") |
|||
private LocalDateTime completedAt; |
|||
|
|||
@CreatedDate |
|||
@Column(name = "created_at", nullable = false, updatable = false) |
|||
private LocalDateTime createdAt; |
|||
|
|||
@LastModifiedDate |
|||
@Column(name = "updated_at", nullable = false) |
|||
private LocalDateTime updatedAt; |
|||
} |
|||
@ -0,0 +1,43 @@ |
|||
package com.mydreamjob.assessment; |
|||
|
|||
import jakarta.persistence.*; |
|||
import lombok.AllArgsConstructor; |
|||
import lombok.Builder; |
|||
import lombok.Data; |
|||
import lombok.NoArgsConstructor; |
|||
import org.springframework.data.annotation.CreatedDate; |
|||
import org.springframework.data.jpa.domain.support.AuditingEntityListener; |
|||
|
|||
import java.time.LocalDateTime; |
|||
|
|||
/** |
|||
* 本次答卷随机抽中的题目(V34/V35)。 |
|||
* |
|||
* <p>答卷创建时一次性写入 30 道,顺序(position)不可再变,刷新页面不重新抽题。</p> |
|||
*/ |
|||
@Data |
|||
@Builder |
|||
@NoArgsConstructor |
|||
@AllArgsConstructor |
|||
@Entity |
|||
@Table(name = "assessment_attempt_questions") |
|||
@EntityListeners(AuditingEntityListener.class) |
|||
public class AssessmentAttemptQuestion { |
|||
|
|||
@Id |
|||
@GeneratedValue(strategy = GenerationType.IDENTITY) |
|||
private Long id; |
|||
|
|||
@Column(name = "attempt_id", nullable = false) |
|||
private Long attemptId; |
|||
|
|||
@Column(name = "question_id", nullable = false) |
|||
private Long questionId; |
|||
|
|||
@Column(nullable = false) |
|||
private Integer position; |
|||
|
|||
@CreatedDate |
|||
@Column(name = "selected_at", nullable = false, updatable = false) |
|||
private LocalDateTime selectedAt; |
|||
} |
|||
@ -0,0 +1,12 @@ |
|||
package com.mydreamjob.assessment; |
|||
|
|||
import org.springframework.data.jpa.repository.JpaRepository; |
|||
import org.springframework.stereotype.Repository; |
|||
|
|||
import java.util.List; |
|||
|
|||
@Repository |
|||
public interface AssessmentAttemptQuestionRepository extends JpaRepository<AssessmentAttemptQuestion, Long> { |
|||
List<AssessmentAttemptQuestion> findByAttemptIdOrderByPositionAsc(Long attemptId); |
|||
boolean existsByAttemptIdAndQuestionId(Long attemptId, Long questionId); |
|||
} |
|||
@ -0,0 +1,32 @@ |
|||
package com.mydreamjob.assessment; |
|||
|
|||
import org.springframework.data.domain.Page; |
|||
import org.springframework.data.domain.Pageable; |
|||
import org.springframework.data.jpa.repository.JpaRepository; |
|||
import org.springframework.data.jpa.repository.Modifying; |
|||
import org.springframework.data.jpa.repository.Query; |
|||
import org.springframework.data.repository.query.Param; |
|||
import org.springframework.stereotype.Repository; |
|||
|
|||
import java.time.LocalDateTime; |
|||
import java.util.Collection; |
|||
import java.util.Optional; |
|||
|
|||
@Repository |
|||
public interface AssessmentAttemptRepository extends JpaRepository<AssessmentAttempt, Long> { |
|||
|
|||
Optional<AssessmentAttempt> findByStudentUserIdAndIdempotencyKey(Long studentUserId, String idempotencyKey); |
|||
|
|||
boolean existsByStudentUserIdAndDefinitionIdAndStatusIn( |
|||
Long studentUserId, Long definitionId, Collection<String> statuses); |
|||
|
|||
Page<AssessmentAttempt> findByStudentUserIdOrderByCreatedAtDesc(Long studentUserId, Pageable pageable); |
|||
|
|||
/** 幂等置为已完成:仅 DRAFT/IN_PROGRESS → COMPLETED,并释放 active_key;已终态返回 0,供并发提交防护。 */ |
|||
@Modifying |
|||
@Query("update AssessmentAttempt a set a.status = :status, a.submittedAt = :now, " |
|||
+ "a.completedAt = :now, a.answeredCount = :answered, a.activeKey = null " |
|||
+ "where a.id = :id and a.status in ('DRAFT','IN_PROGRESS')") |
|||
int markCompleted(@Param("id") Long id, @Param("status") String status, |
|||
@Param("now") LocalDateTime now, @Param("answered") int answered); |
|||
} |
|||
@ -0,0 +1,484 @@ |
|||
package com.mydreamjob.assessment; |
|||
|
|||
import com.mydreamjob.assessment.dto.AssessmentAttemptResponse; |
|||
import com.mydreamjob.assessment.dto.AssessmentHistoryItem; |
|||
import com.mydreamjob.assessment.dto.AssessmentQuestionResponse; |
|||
import com.mydreamjob.assessment.dto.AssessmentResultResponse; |
|||
import com.mydreamjob.assessment.dto.CreateAssessmentAttemptRequest; |
|||
import com.mydreamjob.assessment.dto.SaveAssessmentAnswersRequest; |
|||
import com.mydreamjob.audit.AuditContext; |
|||
import com.mydreamjob.audit.AuditService; |
|||
import com.mydreamjob.auth.User; |
|||
import com.mydreamjob.auth.UserRepository; |
|||
import com.mydreamjob.common.exception.BusinessException; |
|||
import com.mydreamjob.common.exception.ErrorCode; |
|||
import lombok.RequiredArgsConstructor; |
|||
import org.springframework.dao.DataIntegrityViolationException; |
|||
import org.springframework.dao.OptimisticLockingFailureException; |
|||
import org.springframework.data.domain.Page; |
|||
import org.springframework.data.domain.PageRequest; |
|||
import org.springframework.stereotype.Service; |
|||
import org.springframework.transaction.annotation.Isolation; |
|||
import org.springframework.transaction.annotation.Transactional; |
|||
|
|||
import java.math.BigDecimal; |
|||
import java.math.RoundingMode; |
|||
import java.time.LocalDateTime; |
|||
import java.util.ArrayList; |
|||
import java.util.Collections; |
|||
import java.util.HashMap; |
|||
import java.util.LinkedHashMap; |
|||
import java.util.List; |
|||
import java.util.Map; |
|||
import java.util.Random; |
|||
import java.util.function.Function; |
|||
import java.util.stream.Collectors; |
|||
|
|||
/** |
|||
* 答卷业务服务(V34/V35)。 |
|||
* |
|||
* <p>创建答卷时按六维各随机抽 5 题、整体打乱后持久化,刷新页面不重抽;保存答案走 upsert 且受 |
|||
* 乐观锁保护(9707);提交仅允许 30 题全答(9706)、仅 DRAFT/IN_PROGRESS(9703)、 |
|||
* 重复提交幂等返回原结果、不重复生成结果与审计。身份仅取自 JWT,仅 STUDENT 可操作。</p> |
|||
*/ |
|||
@Service |
|||
@RequiredArgsConstructor |
|||
public class AssessmentAttemptService { |
|||
|
|||
private static final int MAX_PAGE_SIZE = 50; |
|||
|
|||
private final AssessmentDefinitionRepository definitionRepository; |
|||
private final AssessmentQuestionRepository questionRepository; |
|||
private final AssessmentAttemptRepository attemptRepository; |
|||
private final AssessmentAttemptQuestionRepository attemptQuestionRepository; |
|||
private final AssessmentAnswerRepository answerRepository; |
|||
private final AssessmentResultRepository resultRepository; |
|||
private final AssessmentResultService resultService; |
|||
private final AssessmentResultComputer resultComputer; |
|||
private final AssessmentAttemptWriter attemptWriter; |
|||
private final UserRepository userRepository; |
|||
private final AuditService auditService; |
|||
|
|||
// ─────────────────────────── 创建答卷 ───────────────────────────
|
|||
|
|||
/** |
|||
* 创建答卷。写实现(attempt+attempt_questions)委托给 {@link AssessmentAttemptWriter}(REQUIRES_NEW 独立事务), |
|||
* 并发唯一键冲突在该独立事务内回滚,外层 Session 不被污染。 |
|||
* |
|||
* <p>此处显式使用 {@code READ_COMMITTED} 隔离级别:MySQL/H2 默认行为下,事务内固定的 |
|||
* REPEATABLE READ 快照会让「外层事务」读不到刚由 {@code REQUIRES_NEW} 提交的 attempt_questions, |
|||
* 也读不到并发胜者已提交的活跃答卷,从而返回空题列表或误 500;READ_COMMITTED 使每次读都取最新已提交数据。 |
|||
*/ |
|||
@Transactional(isolation = Isolation.READ_COMMITTED) |
|||
public AssessmentAttemptResponse create(Long userId, CreateAssessmentAttemptRequest req, AuditContext ctx) { |
|||
requireStudent(userId); |
|||
AssessmentDefinition def = definitionRepository.findByTypeCode(req.getTypeCode()) |
|||
.orElseThrow(() -> new BusinessException(ErrorCode.ASSESSMENT_TYPE_NOT_FOUND)); |
|||
// 仅 ACTIVE 类型可创建;INACTIVE 统一 404/9701
|
|||
if (!AssessmentDefinition.STATUS_ACTIVE.equals(def.getStatus())) { |
|||
throw new BusinessException(ErrorCode.ASSESSMENT_TYPE_NOT_FOUND); |
|||
} |
|||
String idem = normalizeIdempotencyKey(req.getIdempotencyKey()); |
|||
|
|||
// 幂等重放:同学生+同键返回原答卷;同键但定义不同 → 9709
|
|||
if (idem != null) { |
|||
AssessmentAttempt existing = findByIdem(userId, idem); |
|||
if (existing != null) { |
|||
if (!def.getId().equals(existing.getDefinitionId())) { |
|||
throw new BusinessException(ErrorCode.ASSESSMENT_IDEMPOTENCY_CONFLICT); |
|||
} |
|||
return toAttemptResponse(existing); |
|||
} |
|||
} |
|||
|
|||
// 快速路径:已有活跃答卷(数据库 active_key 唯一约束才是并发下的最终兜底)
|
|||
if (attemptRepository.existsByStudentUserIdAndDefinitionIdAndStatusIn( |
|||
userId, def.getId(), List.of(AssessmentAttempt.STATUS_DRAFT, |
|||
AssessmentAttempt.STATUS_IN_PROGRESS))) { |
|||
throw new BusinessException(ErrorCode.ASSESSMENT_DUPLICATE_ACTIVE); |
|||
} |
|||
|
|||
long seed = new Random().nextLong(); |
|||
List<AssessmentQuestion> active = questionRepository |
|||
.findByDefinitionIdAndActiveTrueOrderByPositionAsc(def.getId()); |
|||
List<AssessmentQuestion> sampled = sample(active, seed, def.getAttemptQuestionCount(), def.getMinPerDimension()); |
|||
|
|||
// 写入委托给依赖 Bean:attempt + attempt_questions 在独立 REQUIRES_NEW 事务中落库。
|
|||
// 若并发触发唯一键冲突,失败发生在该独立事务内并自行回滚,外层 Session 不受污染。
|
|||
AssessmentAttempt attempt; |
|||
try { |
|||
attempt = attemptWriter.persistNew(userId, def, seed, sampled, idem, activeKeyOf(userId, def.getId())); |
|||
} catch (DataIntegrityViolationException e) { |
|||
// 幂等键唯一冲突:同学生+同键已有原 attempt → 幂等返回
|
|||
if (idem != null) { |
|||
AssessmentAttempt existing = findByIdem(userId, idem); |
|||
if (existing != null) { |
|||
if (!def.getId().equals(existing.getDefinitionId())) { |
|||
throw new BusinessException(ErrorCode.ASSESSMENT_IDEMPOTENCY_CONFLICT); |
|||
} |
|||
return toAttemptResponse(existing); |
|||
} |
|||
} |
|||
// active_key 唯一冲突:同学生同类型已有活跃答卷 → 409/9708
|
|||
if (attemptRepository.existsByStudentUserIdAndDefinitionIdAndStatusIn( |
|||
userId, def.getId(), List.of(AssessmentAttempt.STATUS_DRAFT, |
|||
AssessmentAttempt.STATUS_IN_PROGRESS))) { |
|||
throw new BusinessException(ErrorCode.ASSESSMENT_DUPLICATE_ACTIVE); |
|||
} |
|||
// 其他数据库约束异常:保留真实错误,不得伪装成重复答卷
|
|||
throw e; |
|||
} |
|||
|
|||
auditService.log(userId, "ASSESSMENT_ATTEMPT_CREATED", "ASSESSMENT_ATTEMPT", attempt.getId(), |
|||
"attemptId=" + attempt.getId() + ", typeCode=" + def.getTypeCode() |
|||
+ ", status=" + attempt.getStatus() |
|||
+ ", questionSetVersion=" + attempt.getQuestionSetVersion() |
|||
+ ", rulesVersion=" + def.getRulesVersion(), |
|||
ctx); |
|||
return toAttemptResponse(attempt); |
|||
} |
|||
|
|||
// ─────────────────────────── 答卷详情 ───────────────────────────
|
|||
|
|||
@Transactional(readOnly = true) |
|||
public AssessmentAttemptResponse get(Long userId, Long attemptId) { |
|||
requireStudent(userId); |
|||
AssessmentAttempt attempt = loadOwned(userId, attemptId); |
|||
return toAttemptResponse(attempt); |
|||
} |
|||
|
|||
// ─────────────────────────── 保存答案 ───────────────────────────
|
|||
|
|||
@Transactional |
|||
public AssessmentAttemptResponse saveAnswers(Long userId, Long attemptId, |
|||
SaveAssessmentAnswersRequest req, AuditContext ctx) { |
|||
requireStudent(userId); |
|||
AssessmentAttempt attempt = loadOwned(userId, attemptId); |
|||
if (!isMutable(attempt.getStatus())) { |
|||
throw new BusinessException(ErrorCode.ASSESSMENT_ATTEMPT_INVALID_STATE); |
|||
} |
|||
if (!attempt.getVersion().equals(req.getVersion())) { |
|||
throw new BusinessException(ErrorCode.ASSESSMENT_CONCURRENT_UPDATE); |
|||
} |
|||
|
|||
// 同一 PATCH 内同题不得重复出现(防止同题不同值静默取最后一条)
|
|||
if (req.getAnswers().size() != req.getAnswers().stream() |
|||
.map(SaveAssessmentAnswersRequest.AnswerItem::getQuestionId).distinct().count()) { |
|||
throw new BusinessException(ErrorCode.ASSESSMENT_ANSWER_INVALID, |
|||
"duplicate questionId in answers"); |
|||
} |
|||
|
|||
for (SaveAssessmentAnswersRequest.AnswerItem item : req.getAnswers()) { |
|||
if (item.getValue() == null || item.getValue() < 1 || item.getValue() > 5) { |
|||
throw new BusinessException(ErrorCode.ASSESSMENT_ANSWER_INVALID, "value must be 1-5"); |
|||
} |
|||
if (!attemptQuestionRepository.existsByAttemptIdAndQuestionId(attemptId, item.getQuestionId())) { |
|||
throw new BusinessException(ErrorCode.ASSESSMENT_ANSWER_INVALID, |
|||
"question does not belong to this attempt"); |
|||
} |
|||
AssessmentAnswer answer = answerRepository |
|||
.findByAttemptIdAndQuestionId(attemptId, item.getQuestionId()) |
|||
.orElseGet(() -> AssessmentAnswer.builder() |
|||
.attemptId(attemptId) |
|||
.questionId(item.getQuestionId()) |
|||
.build()); |
|||
answer.setValue(item.getValue()); |
|||
answerRepository.save(answer); |
|||
} |
|||
answerRepository.flush(); |
|||
|
|||
int answered = (int) answerRepository.countByAttemptId(attemptId); |
|||
attempt.setAnsweredCount(answered); |
|||
if (answered > 0 && AssessmentAttempt.STATUS_DRAFT.equals(attempt.getStatus())) { |
|||
attempt.setStatus(AssessmentAttempt.STATUS_IN_PROGRESS); |
|||
} |
|||
try { |
|||
attemptRepository.saveAndFlush(attempt); |
|||
} catch (OptimisticLockingFailureException e) { |
|||
throw new BusinessException(ErrorCode.ASSESSMENT_CONCURRENT_UPDATE); |
|||
} |
|||
return toAttemptResponse(attempt); |
|||
} |
|||
|
|||
// ─────────────────────────── 提交答卷 ───────────────────────────
|
|||
|
|||
@Transactional |
|||
public AssessmentResultResponse submit(Long userId, Long attemptId, AuditContext ctx) { |
|||
requireStudent(userId); |
|||
AssessmentAttempt attempt = loadOwned(userId, attemptId); |
|||
|
|||
// 重复提交幂等:返回原结果,不重新生成、不重复审计
|
|||
if (AssessmentAttempt.STATUS_COMPLETED.equals(attempt.getStatus())) { |
|||
AssessmentResult existing = resultRepository.findByAttemptId(attemptId) |
|||
.orElseThrow(() -> new BusinessException(ErrorCode.ASSESSMENT_ATTEMPT_INVALID_STATE)); |
|||
return resultService.toResponse(attempt, existing); |
|||
} |
|||
if (!isMutable(attempt.getStatus())) { |
|||
throw new BusinessException(ErrorCode.ASSESSMENT_ATTEMPT_INVALID_STATE); |
|||
} |
|||
|
|||
int answered = (int) answerRepository.countByAttemptId(attemptId); |
|||
if (answered < attempt.getSelectedQuestionCount()) { |
|||
throw new BusinessException(ErrorCode.ASSESSMENT_INCOMPLETE); |
|||
} |
|||
// 每个维度各答 5 题
|
|||
if (!allSelectedAnswered(attemptId, attempt)) { |
|||
throw new BusinessException(ErrorCode.ASSESSMENT_INCOMPLETE); |
|||
} |
|||
|
|||
// 原子状态流转:仅 DRAFT/IN_PROGRESS → COMPLETED,并返回影响行数。
|
|||
// 赢得流转的线程负责生成结果+审计;失败方(行数=0)说明已由并发请求完成 → 幂等返回。
|
|||
int transitioned = attemptRepository.markCompleted( |
|||
attemptId, AssessmentAttempt.STATUS_COMPLETED, LocalDateTime.now(), answered); |
|||
if (transitioned == 0) { |
|||
AssessmentResult existing = resultRepository.findByAttemptId(attemptId) |
|||
.orElseThrow(() -> new BusinessException(ErrorCode.ASSESSMENT_ATTEMPT_INVALID_STATE)); |
|||
return resultService.toResponse(attempt, existing); |
|||
} |
|||
|
|||
AssessmentResultComputer.ComputedResult computed = compute(attempt); |
|||
AssessmentResult result; |
|||
try { |
|||
result = resultService.persist(attempt, computed, answered); |
|||
} catch (DataIntegrityViolationException e) { |
|||
// 极端并发:result attempt_id 唯一约束兜底,复用已有结果实现幂等
|
|||
AssessmentResult existing = resultRepository.findByAttemptId(attemptId) |
|||
.orElseThrow(() -> new BusinessException(ErrorCode.ASSESSMENT_ATTEMPT_INVALID_STATE)); |
|||
return resultService.toResponse(attempt, existing); |
|||
} |
|||
|
|||
AssessmentDefinition def = definitionRepository.findById(attempt.getDefinitionId()).orElse(null); |
|||
auditService.log(userId, "ASSESSMENT_ATTEMPT_SUBMITTED", "ASSESSMENT_ATTEMPT", attemptId, |
|||
"attemptId=" + attemptId + ", typeCode=" + (def == null ? null : def.getTypeCode()) |
|||
+ ", status=COMPLETED, answeredCount=" + answered |
|||
+ ", questionSetVersion=" + attempt.getQuestionSetVersion() |
|||
+ ", rulesVersion=" + (def == null ? null : def.getRulesVersion()), |
|||
ctx); |
|||
return resultService.toResponse(attempt, result); |
|||
} |
|||
|
|||
// ─────────────────────────── 我的历史 ───────────────────────────
|
|||
|
|||
@Transactional(readOnly = true) |
|||
public Page<AssessmentHistoryItem> listMine(Long userId, int page, int size) { |
|||
requireStudent(userId); |
|||
if (page < 0 || size < 1 || size > MAX_PAGE_SIZE) { |
|||
throw new BusinessException(ErrorCode.BAD_REQUEST); |
|||
} |
|||
Page<AssessmentAttempt> attempts = attemptRepository |
|||
.findByStudentUserIdOrderByCreatedAtDesc(userId, PageRequest.of(page, size)); |
|||
|
|||
List<AssessmentAttempt> content = attempts.getContent(); |
|||
Map<Long, AssessmentAttempt> attemptById = content.stream() |
|||
.collect(Collectors.toMap(AssessmentAttempt::getId, Function.identity())); |
|||
Map<Long, AssessmentResult> resultByAttempt = resultRepository |
|||
.findByAttemptIdIn(attemptById.keySet()).stream() |
|||
.collect(Collectors.toMap(AssessmentResult::getAttemptId, Function.identity())); |
|||
Map<Long, AssessmentDefinition> defById = new HashMap<>(); |
|||
for (AssessmentAttempt a : content) { |
|||
if (!defById.containsKey(a.getDefinitionId())) { |
|||
defById.put(a.getDefinitionId(), |
|||
definitionRepository.findById(a.getDefinitionId()).orElse(null)); |
|||
} |
|||
} |
|||
|
|||
return attempts.map(a -> { |
|||
AssessmentResult r = resultByAttempt.get(a.getId()); |
|||
AssessmentDefinition d = defById.get(a.getDefinitionId()); |
|||
return AssessmentHistoryItem.builder() |
|||
.attemptId(a.getId()) |
|||
.typeCode(d == null ? null : d.getTypeCode()) |
|||
.status(a.getStatus()) |
|||
.answeredCount(a.getAnsweredCount()) |
|||
.completionRate(completionRate(a)) |
|||
.questionSetVersion(a.getQuestionSetVersion()) |
|||
.primaryCode(r == null ? null : r.getPrimaryCode()) |
|||
.primaryType(r == null ? null : r.getPrimaryType()) |
|||
.createdAt(a.getCreatedAt()) |
|||
.submittedAt(a.getSubmittedAt()) |
|||
.build(); |
|||
}); |
|||
} |
|||
|
|||
// ─────────────────────────── 内部工具 ───────────────────────────
|
|||
|
|||
private boolean isMutable(String status) { |
|||
return AssessmentAttempt.STATUS_DRAFT.equals(status) |
|||
|| AssessmentAttempt.STATUS_IN_PROGRESS.equals(status); |
|||
} |
|||
|
|||
private AssessmentAttempt loadOwned(Long userId, Long attemptId) { |
|||
AssessmentAttempt a = attemptRepository.findById(attemptId) |
|||
.orElseThrow(() -> new BusinessException(ErrorCode.ASSESSMENT_ATTEMPT_NOT_FOUND)); |
|||
if (!a.getStudentUserId().equals(userId)) { |
|||
throw new BusinessException(ErrorCode.FORBIDDEN); |
|||
} |
|||
return a; |
|||
} |
|||
|
|||
private void requireStudent(Long userId) { |
|||
User u = userRepository.findById(userId) |
|||
.orElseThrow(() -> new BusinessException(ErrorCode.USER_NOT_FOUND)); |
|||
boolean isStudent = u.getRoles() != null && u.getRoles().contains(User.Role.STUDENT); |
|||
if (!isStudent) { |
|||
throw new BusinessException(ErrorCode.FORBIDDEN); |
|||
} |
|||
} |
|||
|
|||
private AssessmentAttempt findByIdem(Long userId, String idem) { |
|||
if (idem == null) { |
|||
return null; |
|||
} |
|||
return attemptRepository.findByStudentUserIdAndIdempotencyKey(userId, idem).orElse(null); |
|||
} |
|||
|
|||
private String normalizeIdempotencyKey(String key) { |
|||
return key == null || key.isBlank() ? null : key.trim(); |
|||
} |
|||
|
|||
private String activeKeyOf(Long studentUserId, Long definitionId) { |
|||
return studentUserId + ":" + definitionId + ":ACTIVE"; |
|||
} |
|||
|
|||
/** |
|||
* 抽题:R/I/A/S/E/C 六维各恰好抽 perDim 道,合并后整体打乱,总数为 attemptCount。 |
|||
* |
|||
* <p>严格约束:任一维度 ACTIVE 题不足 perDim 时直接拒绝创建(不静默减少题目数); |
|||
* 校验 attemptCount == 6 × perDim 且六维齐全,保证总数严格为 30。</p> |
|||
*/ |
|||
private List<AssessmentQuestion> sample(List<AssessmentQuestion> all, long seed, |
|||
int attemptCount, int perDim) { |
|||
if (attemptCount <= 0 || perDim <= 0) { |
|||
throw new BusinessException(ErrorCode.BAD_REQUEST, |
|||
"invalid attempt question count or per-dimension count"); |
|||
} |
|||
if (attemptCount != 6 * perDim) { |
|||
throw new BusinessException(ErrorCode.BAD_REQUEST, |
|||
"attemptQuestionCount must equal 6 * minPerDimension"); |
|||
} |
|||
Map<String, List<AssessmentQuestion>> byDim = new LinkedHashMap<>(); |
|||
for (AssessmentQuestion q : all) { |
|||
byDim.computeIfAbsent(q.getDimension(), k -> new ArrayList<>()).add(q); |
|||
} |
|||
if (byDim.size() != 6) { |
|||
throw new BusinessException(ErrorCode.BAD_REQUEST, |
|||
"all six RIASEC dimensions must be present"); |
|||
} |
|||
for (String dim : List.of("R", "I", "A", "S", "E", "C")) { |
|||
int available = byDim.getOrDefault(dim, List.of()).size(); |
|||
if (available < perDim) { |
|||
throw new BusinessException(ErrorCode.BAD_REQUEST, |
|||
"insufficient active questions in dimension " + dim |
|||
+ " (need " + perDim + ", have " + available + ")"); |
|||
} |
|||
} |
|||
Random random = new Random(seed); |
|||
List<AssessmentQuestion> picked = new ArrayList<>(attemptCount); |
|||
for (String dim : List.of("R", "I", "A", "S", "E", "C")) { |
|||
List<AssessmentQuestion> group = new ArrayList<>(byDim.get(dim)); |
|||
Collections.shuffle(group, random); |
|||
picked.addAll(group.subList(0, perDim)); // 显式每维恰 perDim,不再依赖 Math.min()
|
|||
} |
|||
Collections.shuffle(picked, random); |
|||
if (picked.size() != attemptCount) { |
|||
throw new BusinessException(ErrorCode.BAD_REQUEST, |
|||
"sampled question count mismatch"); |
|||
} |
|||
return picked; |
|||
} |
|||
|
|||
private boolean allSelectedAnswered(Long attemptId, AssessmentAttempt attempt) { |
|||
List<Long> selected = attemptQuestionRepository.findByAttemptIdOrderByPositionAsc(attemptId) |
|||
.stream().map(AssessmentAttemptQuestion::getQuestionId).toList(); |
|||
if (selected.size() < attempt.getSelectedQuestionCount()) { |
|||
return false; |
|||
} |
|||
for (Long qid : selected) { |
|||
if (answerRepository.findByAttemptIdAndQuestionId(attemptId, qid).isEmpty()) { |
|||
return false; |
|||
} |
|||
} |
|||
return true; |
|||
} |
|||
|
|||
private AssessmentResultComputer.ComputedResult compute(AssessmentAttempt attempt) { |
|||
List<AssessmentAttemptQuestion> aqs = attemptQuestionRepository |
|||
.findByAttemptIdOrderByPositionAsc(attempt.getId()); |
|||
Map<Long, AssessmentQuestion> questionById = new HashMap<>(); |
|||
if (!aqs.isEmpty()) { |
|||
questionById.putAll(questionRepository.findAllById( |
|||
aqs.stream().map(AssessmentAttemptQuestion::getQuestionId).toList()) |
|||
.stream().collect(Collectors.toMap(AssessmentQuestion::getId, Function.identity()))); |
|||
} |
|||
Map<Long, AssessmentAnswer> answerByQuestion = answerRepository.findByAttemptId(attempt.getId()) |
|||
.stream().collect(Collectors.toMap(AssessmentAnswer::getQuestionId, Function.identity())); |
|||
|
|||
List<AssessmentResultComputer.DimensionValue> values = new ArrayList<>(); |
|||
for (AssessmentAttemptQuestion aq : aqs) { |
|||
AssessmentQuestion q = questionById.get(aq.getQuestionId()); |
|||
AssessmentAnswer ans = answerByQuestion.get(aq.getQuestionId()); |
|||
if (q == null || ans == null) { |
|||
continue; |
|||
} |
|||
values.add(new AssessmentResultComputer.DimensionValue(q.getDimension(), ans.getValue())); |
|||
} |
|||
AssessmentDefinition def = definitionRepository.findById(attempt.getDefinitionId()).orElse(null); |
|||
int perDim = def == null ? 1 : def.getMinPerDimension(); |
|||
return resultComputer.compute(values, perDim, 5); |
|||
} |
|||
|
|||
private AssessmentAttemptResponse toAttemptResponse(AssessmentAttempt attempt) { |
|||
List<AssessmentAttemptQuestion> aqs = attemptQuestionRepository |
|||
.findByAttemptIdOrderByPositionAsc(attempt.getId()); |
|||
Map<Long, Integer> values = answerRepository.findByAttemptId(attempt.getId()) |
|||
.stream().collect(Collectors.toMap(AssessmentAnswer::getQuestionId, AssessmentAnswer::getValue)); |
|||
|
|||
Map<Long, AssessmentQuestion> questionById = new HashMap<>(); |
|||
if (!aqs.isEmpty()) { |
|||
questionById.putAll(questionRepository.findAllById( |
|||
aqs.stream().map(AssessmentAttemptQuestion::getQuestionId).toList()) |
|||
.stream().collect(Collectors.toMap(AssessmentQuestion::getId, Function.identity()))); |
|||
} |
|||
|
|||
int total = attempt.getSelectedQuestionCount() == null ? 30 : attempt.getSelectedQuestionCount(); |
|||
int answered = values.size(); |
|||
List<AssessmentQuestionResponse> items = aqs.stream().map(aq -> { |
|||
AssessmentQuestion q = questionById.get(aq.getQuestionId()); |
|||
if (q == null) { |
|||
return null; |
|||
} |
|||
return AssessmentQuestionResponse.builder() |
|||
.id(q.getId()) |
|||
.position(aq.getPosition()) |
|||
.stem(q.getStem()) |
|||
.scaleType(q.getScaleType()) |
|||
.value(values.get(q.getId())) |
|||
.build(); |
|||
}).filter(java.util.Objects::nonNull).toList(); |
|||
|
|||
return AssessmentAttemptResponse.builder() |
|||
.attemptId(attempt.getId()) |
|||
.status(attempt.getStatus()) |
|||
.totalQuestions(total) |
|||
.answeredCount(answered) |
|||
.completionRate(completionRate(total, answered)) |
|||
.questionSetVersion(attempt.getQuestionSetVersion()) |
|||
.version(attempt.getVersion()) |
|||
.questions(items) |
|||
.build(); |
|||
} |
|||
|
|||
private BigDecimal completionRate(int total, int answered) { |
|||
if (total <= 0) { |
|||
return BigDecimal.ZERO.setScale(2); |
|||
} |
|||
return BigDecimal.valueOf(answered) |
|||
.multiply(BigDecimal.valueOf(100)) |
|||
.divide(BigDecimal.valueOf(total), 2, RoundingMode.HALF_UP); |
|||
} |
|||
|
|||
private BigDecimal completionRate(AssessmentAttempt a) { |
|||
int total = a.getSelectedQuestionCount() == null ? 30 : a.getSelectedQuestionCount(); |
|||
return completionRate(total, a.getAnsweredCount() == null ? 0 : a.getAnsweredCount()); |
|||
} |
|||
} |
|||
@ -0,0 +1,60 @@ |
|||
package com.mydreamjob.assessment; |
|||
|
|||
import lombok.RequiredArgsConstructor; |
|||
import org.springframework.stereotype.Component; |
|||
import org.springframework.transaction.annotation.Propagation; |
|||
import org.springframework.transaction.annotation.Transactional; |
|||
|
|||
import java.time.LocalDateTime; |
|||
import java.util.List; |
|||
|
|||
/** |
|||
* 答卷持久化写入 Bean(V34/V35)。 |
|||
* |
|||
* <p>将「新建 attempt + 30 条 attempt_questions」放到独立的 {@code REQUIRES_NEW} 事务中执行: |
|||
* 当并发创建某一份 attempt 触发唯一键冲突(active_key / idempotency_key)时,冲突发生在 |
|||
* 新开的事务内、由其自身回滚,外层事务的 Hibernate Session 不会被污染的 flush 破坏, |
|||
* 从而避免 {@code Hibernate AssertionFailure} 与 HTTP 500。失败后外层可安全使用新事务查询幂等记录。</p> |
|||
*/ |
|||
@Component |
|||
@RequiredArgsConstructor |
|||
public class AssessmentAttemptWriter { |
|||
|
|||
private final AssessmentAttemptRepository attemptRepository; |
|||
private final AssessmentAttemptQuestionRepository attemptQuestionRepository; |
|||
|
|||
/** |
|||
* 在独立的新事务中创建答卷主记录并写入 30 道抽中题目。 |
|||
* |
|||
* @return 已持久化的答卷(含数据库生成的 id) |
|||
*/ |
|||
@Transactional(propagation = Propagation.REQUIRES_NEW) |
|||
public AssessmentAttempt persistNew(Long userId, AssessmentDefinition def, long seed, |
|||
List<AssessmentQuestion> sampled, String idempotencyKey, |
|||
String activeKey) { |
|||
AssessmentAttempt attempt = AssessmentAttempt.builder() |
|||
.studentUserId(userId) |
|||
.definitionId(def.getId()) |
|||
.status(AssessmentAttempt.STATUS_DRAFT) |
|||
.samplingSeed(seed) |
|||
.questionSetVersion(def.getQuestionSetVersion()) |
|||
.selectedQuestionCount(sampled.size()) |
|||
.answeredCount(0) |
|||
.idempotencyKey(idempotencyKey) |
|||
.activeKey(activeKey) |
|||
.startedAt(LocalDateTime.now()) |
|||
.build(); |
|||
attempt = attemptRepository.saveAndFlush(attempt); |
|||
|
|||
int position = 1; |
|||
for (AssessmentQuestion q : sampled) { |
|||
attemptQuestionRepository.save(AssessmentAttemptQuestion.builder() |
|||
.attemptId(attempt.getId()) |
|||
.questionId(q.getId()) |
|||
.position(position++) |
|||
.build()); |
|||
} |
|||
attemptQuestionRepository.flush(); |
|||
return attempt; |
|||
} |
|||
} |
|||
@ -0,0 +1,67 @@ |
|||
package com.mydreamjob.assessment; |
|||
|
|||
import com.mydreamjob.common.exception.BusinessException; |
|||
import com.mydreamjob.common.exception.ErrorCode; |
|||
import com.mydreamjob.assessment.dto.AssessmentQuestionResponse; |
|||
import com.mydreamjob.assessment.dto.AssessmentTypeResponse; |
|||
import lombok.RequiredArgsConstructor; |
|||
import org.springframework.stereotype.Service; |
|||
import org.springframework.transaction.annotation.Transactional; |
|||
|
|||
import java.util.List; |
|||
|
|||
/** |
|||
* 测评目录服务(V34/V35)。 |
|||
* |
|||
* <p>仅提供测评类型与题目查询,任何已登录用户可访问。题目响应隐藏 |
|||
* dimension、reverseScored、source、questionVersion。</p> |
|||
*/ |
|||
@Service |
|||
@RequiredArgsConstructor |
|||
public class AssessmentCatalogService { |
|||
|
|||
private final AssessmentDefinitionRepository definitionRepository; |
|||
private final AssessmentQuestionRepository questionRepository; |
|||
|
|||
@Transactional(readOnly = true) |
|||
public List<AssessmentTypeResponse> listTypes() { |
|||
return definitionRepository.findAll().stream() |
|||
.filter(d -> AssessmentDefinition.STATUS_ACTIVE.equals(d.getStatus())) |
|||
.map(this::toTypeResponse) |
|||
.toList(); |
|||
} |
|||
|
|||
@Transactional(readOnly = true) |
|||
public List<AssessmentQuestionResponse> listQuestions(String typeCode) { |
|||
AssessmentDefinition def = definitionRepository.findByTypeCode(typeCode) |
|||
.orElseThrow(() -> new BusinessException(ErrorCode.ASSESSMENT_TYPE_NOT_FOUND)); |
|||
if (!AssessmentDefinition.STATUS_ACTIVE.equals(def.getStatus())) { |
|||
throw new BusinessException(ErrorCode.ASSESSMENT_TYPE_NOT_FOUND); |
|||
} |
|||
return questionRepository.findByDefinitionIdAndActiveTrueOrderByPositionAsc(def.getId()) |
|||
.stream() |
|||
.map(q -> AssessmentQuestionResponse.builder() |
|||
.id(q.getId()) |
|||
.position(q.getPosition()) |
|||
.stem(q.getStem()) |
|||
.scaleType(q.getScaleType()) |
|||
.build()) |
|||
.toList(); |
|||
} |
|||
|
|||
private AssessmentTypeResponse toTypeResponse(AssessmentDefinition d) { |
|||
return AssessmentTypeResponse.builder() |
|||
.typeCode(d.getTypeCode()) |
|||
.displayName(d.getDisplayName()) |
|||
.description(d.getDescription()) |
|||
.instructions(d.getInstructions()) |
|||
.status(d.getStatus()) |
|||
.scaleType(d.getScaleType()) |
|||
.questionCount(d.getQuestionCount()) |
|||
.attemptQuestionCount(d.getAttemptQuestionCount()) |
|||
.minPerDimension(d.getMinPerDimension()) |
|||
.questionSetVersion(d.getQuestionSetVersion()) |
|||
.rulesVersion(d.getRulesVersion()) |
|||
.build(); |
|||
} |
|||
} |
|||
@ -0,0 +1,178 @@ |
|||
package com.mydreamjob.assessment; |
|||
|
|||
import com.mydreamjob.assessment.dto.AssessmentAttemptResponse; |
|||
import com.mydreamjob.assessment.dto.AssessmentHistoryItem; |
|||
import com.mydreamjob.assessment.dto.AssessmentQuestionResponse; |
|||
import com.mydreamjob.assessment.dto.AssessmentResultResponse; |
|||
import com.mydreamjob.assessment.dto.AssessmentTypeResponse; |
|||
import com.mydreamjob.assessment.dto.CreateAssessmentAttemptRequest; |
|||
import com.mydreamjob.assessment.dto.SaveAssessmentAnswersRequest; |
|||
import com.mydreamjob.audit.AuditContext; |
|||
import com.mydreamjob.common.api.ApiResponse; |
|||
import com.mydreamjob.common.api.PageResponse; |
|||
import com.mydreamjob.common.exception.BusinessException; |
|||
import com.mydreamjob.common.exception.ErrorCode; |
|||
import io.swagger.v3.oas.annotations.Operation; |
|||
import io.swagger.v3.oas.annotations.responses.ApiResponses; |
|||
import io.swagger.v3.oas.annotations.tags.Tag; |
|||
import jakarta.servlet.http.HttpServletRequest; |
|||
import jakarta.validation.Valid; |
|||
import lombok.RequiredArgsConstructor; |
|||
import org.springframework.data.domain.Page; |
|||
import org.springframework.security.core.Authentication; |
|||
import org.springframework.security.core.context.SecurityContextHolder; |
|||
import org.springframework.web.bind.annotation.*; |
|||
|
|||
import java.util.List; |
|||
|
|||
/** |
|||
* RIASEC 职业兴趣测评接口(V34/V35)。 |
|||
* |
|||
* <p>身份仅取自 JWT,不接受 userId 参数。类型与题目对任意已登录用户开放; |
|||
* 答卷创建/详情/保存/提交/结果/历史仅 STUDENT 且仅限本人。COMPANY/ADMIN 操作答卷返回 403。</p> |
|||
*/ |
|||
@Tag(name = "RIASEC 职业兴趣测评", description = "测评类型、题目、答卷、保存答案、提交与结果查询") |
|||
@RestController |
|||
@RequiredArgsConstructor |
|||
public class AssessmentController { |
|||
|
|||
private final AssessmentCatalogService catalogService; |
|||
private final AssessmentAttemptService attemptService; |
|||
private final AssessmentResultService resultService; |
|||
|
|||
@Operation(summary = "测评类型列表", |
|||
description = "返回全部 ACTIVE 测评定义。任意已登录用户可访问;未登录 401。") |
|||
@ApiResponses({ |
|||
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "OK"), |
|||
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "401", description = "未登录") |
|||
}) |
|||
@GetMapping("/assessments/types") |
|||
public ApiResponse<List<AssessmentTypeResponse>> listTypes(HttpServletRequest request) { |
|||
return ApiResponse.ok(catalogService.listTypes(), requestId(request)); |
|||
} |
|||
|
|||
@Operation(summary = "题目列表", |
|||
description = "返回指定测评的 ACTIVE 题目,隐藏维度/reverseScored/source/版本等内部字段。") |
|||
@ApiResponses({ |
|||
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "OK"), |
|||
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "401", description = "未登录"), |
|||
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "404", description = "测评类型不存在(9701)") |
|||
}) |
|||
@GetMapping("/assessments/{type}/questions") |
|||
public ApiResponse<List<AssessmentQuestionResponse>> listQuestions( |
|||
@PathVariable String type, HttpServletRequest request) { |
|||
return ApiResponse.ok(catalogService.listQuestions(type), requestId(request)); |
|||
} |
|||
|
|||
@Operation(summary = "创建答卷", |
|||
description = "照六维各抽 5 题共 30 道并持久化,刷新不重抽。幂等键重放返回原答卷;" |
|||
+ "同键不同定义 409/9709;已有活跃答卷 409/9708。仅 STUDENT。") |
|||
@ApiResponses({ |
|||
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "OK"), |
|||
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "401", description = "未登录"), |
|||
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "403", description = "非 STUDENT(2002)"), |
|||
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "404", description = "测评类型不存在(9701)"), |
|||
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "409", description = "活跃答卷/幂等冲突(9708/9709)") |
|||
}) |
|||
@PostMapping("/assessments/attempts") |
|||
public ApiResponse<AssessmentAttemptResponse> createAttempt( |
|||
@Valid @RequestBody CreateAssessmentAttemptRequest req, HttpServletRequest request) { |
|||
return ApiResponse.ok(attemptService.create(resolveUserId(request), req, AuditContext.from(request)), |
|||
requestId(request)); |
|||
} |
|||
|
|||
@Operation(summary = "答卷详情", |
|||
description = "返回本次抽中的 30 道题(固定顺序)、已保存答案、进度与当前状态。仅本人可访问;非 STUDENT 403。") |
|||
@ApiResponses({ |
|||
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "OK"), |
|||
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "401", description = "未登录"), |
|||
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "403", description = "非本人或非 STUDENT(2002)"), |
|||
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "404", description = "答卷不存在(9702)") |
|||
}) |
|||
@GetMapping("/assessments/attempts/{attemptId}") |
|||
public ApiResponse<AssessmentAttemptResponse> getAttempt( |
|||
@PathVariable Long attemptId, HttpServletRequest request) { |
|||
return ApiResponse.ok(attemptService.get(resolveUserId(request), attemptId), requestId(request)); |
|||
} |
|||
|
|||
@Operation(summary = "保存答案", |
|||
description = "单题或批量保存,重复保存 upsert;value 1-5;题目必须属于本答卷;" |
|||
+ "版本冲突 409/9707;首次保存后状态置为 IN_PROGRESS。仅本人。") |
|||
@ApiResponses({ |
|||
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "OK"), |
|||
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "401", description = "未登录"), |
|||
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "403", description = "非本人或非 STUDENT(2002)"), |
|||
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "404", description = "答卷不存在(9702)"), |
|||
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "409", description = "非法状态/并发(9703/9707)") |
|||
}) |
|||
@PatchMapping("/assessments/attempts/{attemptId}/answers") |
|||
public ApiResponse<AssessmentAttemptResponse> saveAnswers( |
|||
@PathVariable Long attemptId, |
|||
@Valid @RequestBody SaveAssessmentAnswersRequest req, HttpServletRequest request) { |
|||
return ApiResponse.ok(attemptService.saveAnswers(resolveUserId(request), attemptId, req, |
|||
AuditContext.from(request)), requestId(request)); |
|||
} |
|||
|
|||
@Operation(summary = "提交答卷", |
|||
description = "30 题全答且每维各 5 题后方可提交(否则 400/9706);提交后生成结果并写审计;" |
|||
+ "重复提交返回原结果,不重复生成与审计。仅本人。") |
|||
@ApiResponses({ |
|||
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "OK"), |
|||
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "401", description = "未登录"), |
|||
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "403", description = "非本人或非 STUDENT(2002)"), |
|||
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "404", description = "答卷不存在(9702)"), |
|||
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "409", description = "非法状态(9703)"), |
|||
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "400", description = "未完成(9706)") |
|||
}) |
|||
@PostMapping("/assessments/attempts/{attemptId}/submit") |
|||
public ApiResponse<AssessmentResultResponse> submitAttempt( |
|||
@PathVariable Long attemptId, HttpServletRequest request) { |
|||
return ApiResponse.ok(attemptService.submit(resolveUserId(request), attemptId, |
|||
AuditContext.from(request)), requestId(request)); |
|||
} |
|||
|
|||
@Operation(summary = "查看结果", |
|||
description = "答卷 COMPLETED 后返回聚合分、类型排名、探索方向。仅本人可访问。") |
|||
@ApiResponses({ |
|||
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "OK"), |
|||
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "401", description = "未登录"), |
|||
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "403", description = "非本人或非 STUDENT(2002)"), |
|||
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "404", description = "答卷不存在(9702)"), |
|||
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "409", description = "答卷未完成(9703)") |
|||
}) |
|||
@GetMapping("/assessments/attempts/{attemptId}/result") |
|||
public ApiResponse<AssessmentResultResponse> getResult( |
|||
@PathVariable Long attemptId, HttpServletRequest request) { |
|||
return ApiResponse.ok(resultService.get(resolveUserId(request), attemptId), requestId(request)); |
|||
} |
|||
|
|||
@Operation(summary = "我的测评历史", |
|||
description = "分页返回当前学生的全部答卷与结果概要。仅 STUDENT 本人可访问。") |
|||
@ApiResponses({ |
|||
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "OK"), |
|||
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "401", description = "未登录"), |
|||
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "403", description = "非 STUDENT(2002)") |
|||
}) |
|||
@GetMapping("/assessments/me") |
|||
public ApiResponse<PageResponse<AssessmentHistoryItem>> listMine( |
|||
@RequestParam(defaultValue = "0") int page, |
|||
@RequestParam(defaultValue = "20") int size, |
|||
HttpServletRequest request) { |
|||
Page<AssessmentHistoryItem> result = attemptService.listMine(resolveUserId(request), page, size); |
|||
return ApiResponse.ok(PageResponse.of( |
|||
result.getContent(), |
|||
result.getTotalElements(), result.getNumber(), result.getSize()), requestId(request)); |
|||
} |
|||
|
|||
private Long resolveUserId(HttpServletRequest request) { |
|||
Authentication auth = SecurityContextHolder.getContext().getAuthentication(); |
|||
if (auth == null || !auth.isAuthenticated()) { |
|||
throw new BusinessException(ErrorCode.UNAUTHORIZED); |
|||
} |
|||
return Long.valueOf(auth.getName()); |
|||
} |
|||
|
|||
private String requestId(HttpServletRequest request) { |
|||
return request.getHeader("X-Request-Id"); |
|||
} |
|||
} |
|||
@ -0,0 +1,76 @@ |
|||
package com.mydreamjob.assessment; |
|||
|
|||
import jakarta.persistence.*; |
|||
import lombok.AllArgsConstructor; |
|||
import lombok.Builder; |
|||
import lombok.Data; |
|||
import lombok.NoArgsConstructor; |
|||
import org.springframework.data.annotation.CreatedDate; |
|||
import org.springframework.data.annotation.LastModifiedDate; |
|||
import org.springframework.data.jpa.domain.support.AuditingEntityListener; |
|||
|
|||
import java.time.LocalDateTime; |
|||
|
|||
/** |
|||
* 测评定义(V34/V35)。 |
|||
* |
|||
* <p>{@code questionCount} 为题库总题数(72),{@code attemptQuestionCount} 为每次答卷抽题数(30), |
|||
* {@code minPerDimension} 为每个维度最少抽题数(5)。</p> |
|||
*/ |
|||
@Data |
|||
@Builder |
|||
@NoArgsConstructor |
|||
@AllArgsConstructor |
|||
@Entity |
|||
@Table(name = "assessment_definitions") |
|||
@EntityListeners(AuditingEntityListener.class) |
|||
public class AssessmentDefinition { |
|||
|
|||
public static final String STATUS_ACTIVE = "ACTIVE"; |
|||
|
|||
@Id |
|||
@GeneratedValue(strategy = GenerationType.IDENTITY) |
|||
private Long id; |
|||
|
|||
@Column(name = "type_code", nullable = false, length = 40) |
|||
private String typeCode; |
|||
|
|||
@Column(name = "display_name", nullable = false, length = 100) |
|||
private String displayName; |
|||
|
|||
@Column(length = 1000) |
|||
private String description; |
|||
|
|||
@Column(columnDefinition = "TEXT") |
|||
private String instructions; |
|||
|
|||
@Column(nullable = false, length = 20) |
|||
@Builder.Default |
|||
private String status = STATUS_ACTIVE; |
|||
|
|||
@Column(name = "scale_type", nullable = false, length = 20) |
|||
private String scaleType; |
|||
|
|||
@Column(name = "question_count", nullable = false) |
|||
private Integer questionCount; |
|||
|
|||
@Column(name = "attempt_question_count", nullable = false) |
|||
private Integer attemptQuestionCount; |
|||
|
|||
@Column(name = "min_per_dimension", nullable = false) |
|||
private Integer minPerDimension; |
|||
|
|||
@Column(name = "question_set_version", nullable = false, length = 40) |
|||
private String questionSetVersion; |
|||
|
|||
@Column(name = "rules_version", nullable = false, length = 40) |
|||
private String rulesVersion; |
|||
|
|||
@CreatedDate |
|||
@Column(name = "created_at", nullable = false, updatable = false) |
|||
private LocalDateTime createdAt; |
|||
|
|||
@LastModifiedDate |
|||
@Column(name = "updated_at", nullable = false) |
|||
private LocalDateTime updatedAt; |
|||
} |
|||
@ -0,0 +1,11 @@ |
|||
package com.mydreamjob.assessment; |
|||
|
|||
import org.springframework.data.jpa.repository.JpaRepository; |
|||
import org.springframework.stereotype.Repository; |
|||
|
|||
import java.util.Optional; |
|||
|
|||
@Repository |
|||
public interface AssessmentDefinitionRepository extends JpaRepository<AssessmentDefinition, Long> { |
|||
Optional<AssessmentDefinition> findByTypeCode(String typeCode); |
|||
} |
|||
@ -0,0 +1,80 @@ |
|||
package com.mydreamjob.assessment; |
|||
|
|||
import jakarta.persistence.*; |
|||
import lombok.AllArgsConstructor; |
|||
import lombok.Builder; |
|||
import lombok.Data; |
|||
import lombok.NoArgsConstructor; |
|||
import org.hibernate.annotations.JdbcTypeCode; |
|||
import org.hibernate.type.SqlTypes; |
|||
import org.springframework.data.annotation.CreatedDate; |
|||
import org.springframework.data.annotation.LastModifiedDate; |
|||
import org.springframework.data.jpa.domain.support.AuditingEntityListener; |
|||
|
|||
import java.time.LocalDateTime; |
|||
|
|||
/** |
|||
* 测评题目(V34/V35)。 |
|||
* |
|||
* <p>所有题目仅测量职业兴趣,不测量能力,无正确答案,{@code reverseScored} 恒为 false, |
|||
* 来源 {@code SELF_AUTHORED}。</p> |
|||
*/ |
|||
@Data |
|||
@Builder |
|||
@NoArgsConstructor |
|||
@AllArgsConstructor |
|||
@Entity |
|||
@Table(name = "assessment_questions") |
|||
@EntityListeners(AuditingEntityListener.class) |
|||
public class AssessmentQuestion { |
|||
|
|||
@Id |
|||
@GeneratedValue(strategy = GenerationType.IDENTITY) |
|||
private Long id; |
|||
|
|||
@Column(name = "question_code", nullable = false, length = 20) |
|||
private String questionCode; |
|||
|
|||
@Column(name = "definition_id", nullable = false) |
|||
private Long definitionId; |
|||
|
|||
@Column(nullable = false) |
|||
private Integer position; |
|||
|
|||
@Column(nullable = false, length = 1) |
|||
private String dimension; |
|||
|
|||
@Column(nullable = false, length = 2000) |
|||
private String stem; |
|||
|
|||
@Column(name = "scale_type", nullable = false, length = 20) |
|||
private String scaleType; |
|||
|
|||
@Column(name = "question_version", nullable = false, length = 40) |
|||
private String questionVersion; |
|||
|
|||
@Column(nullable = false, length = 40) |
|||
private String source; |
|||
|
|||
/** |
|||
* MySQL/H2 MODE=MySQL 均以 TINYINT 存储 0/1;用 {@code @JdbcTypeCode(TINYINT)} 使 |
|||
* Hibernate 在 schema 校验时按 tinyint 比对,避免默认 Boolean→BIT 与 MySQL TINYINT 的类型冲突。 |
|||
*/ |
|||
@Column(name = "reverse_scored", nullable = false) |
|||
@JdbcTypeCode(SqlTypes.TINYINT) |
|||
@Builder.Default |
|||
private Boolean reverseScored = false; |
|||
|
|||
@Column(nullable = false) |
|||
@JdbcTypeCode(SqlTypes.TINYINT) |
|||
@Builder.Default |
|||
private Boolean active = true; |
|||
|
|||
@CreatedDate |
|||
@Column(name = "created_at", nullable = false, updatable = false) |
|||
private LocalDateTime createdAt; |
|||
|
|||
@LastModifiedDate |
|||
@Column(name = "updated_at", nullable = false) |
|||
private LocalDateTime updatedAt; |
|||
} |
|||
@ -0,0 +1,11 @@ |
|||
package com.mydreamjob.assessment; |
|||
|
|||
import org.springframework.data.jpa.repository.JpaRepository; |
|||
import org.springframework.stereotype.Repository; |
|||
|
|||
import java.util.List; |
|||
|
|||
@Repository |
|||
public interface AssessmentQuestionRepository extends JpaRepository<AssessmentQuestion, Long> { |
|||
List<AssessmentQuestion> findByDefinitionIdAndActiveTrueOrderByPositionAsc(Long definitionId); |
|||
} |
|||
@ -0,0 +1,64 @@ |
|||
package com.mydreamjob.assessment; |
|||
|
|||
import jakarta.persistence.*; |
|||
import lombok.AllArgsConstructor; |
|||
import lombok.Builder; |
|||
import lombok.Data; |
|||
import lombok.NoArgsConstructor; |
|||
|
|||
import java.math.BigDecimal; |
|||
import java.time.LocalDateTime; |
|||
|
|||
/** |
|||
* 测评结果(V34/V35)。 |
|||
* |
|||
* <p>仅 RIASEC 六维聚合分,不存储原始答案。{@code dimensionScoresText} / {@code typeRankingText} |
|||
* 以 TEXT 保存 JSON 字符串,保持 MySQL 8 与 H2 MODE=MySQL 兼容。</p> |
|||
*/ |
|||
@Data |
|||
@Builder |
|||
@NoArgsConstructor |
|||
@AllArgsConstructor |
|||
@Entity |
|||
@Table(name = "assessment_results") |
|||
public class AssessmentResult { |
|||
|
|||
@Id |
|||
@GeneratedValue(strategy = GenerationType.IDENTITY) |
|||
private Long id; |
|||
|
|||
@Column(name = "attempt_id", nullable = false) |
|||
private Long attemptId; |
|||
|
|||
@Column(name = "primary_code", nullable = false, length = 3) |
|||
private String primaryCode; |
|||
|
|||
@Column(name = "primary_type", nullable = false, length = 1) |
|||
private String primaryType; |
|||
|
|||
@Column(name = "dimension_scores_text", columnDefinition = "TEXT") |
|||
private String dimensionScoresText; |
|||
|
|||
@Column(name = "type_ranking_text", columnDefinition = "TEXT") |
|||
private String typeRankingText; |
|||
|
|||
@Column(name = "answered_count", nullable = false) |
|||
private Integer answeredCount; |
|||
|
|||
@Column(name = "completion_rate", nullable = false, precision = 5, scale = 2) |
|||
private BigDecimal completionRate; |
|||
|
|||
@Column(name = "question_set_version", nullable = false, length = 40) |
|||
private String questionSetVersion; |
|||
|
|||
@Column(name = "rules_version", nullable = false, length = 40) |
|||
private String rulesVersion; |
|||
|
|||
@Column(name = "generated_at", nullable = false) |
|||
private LocalDateTime generatedAt; |
|||
|
|||
@Version |
|||
@Column(nullable = false) |
|||
@Builder.Default |
|||
private Long version = 0L; |
|||
} |
|||
@ -0,0 +1,85 @@ |
|||
package com.mydreamjob.assessment; |
|||
|
|||
import org.springframework.stereotype.Component; |
|||
|
|||
import java.math.BigDecimal; |
|||
import java.math.RoundingMode; |
|||
import java.util.ArrayList; |
|||
import java.util.HashMap; |
|||
import java.util.LinkedHashMap; |
|||
import java.util.List; |
|||
import java.util.Map; |
|||
|
|||
/** |
|||
* RIASEC 计分引擎(V34/V35,rules-version riasec-scoring-v0.1)。 |
|||
* |
|||
* <p>每个维度 5 道题:rawScore = 5 道题之和,maxScore = 25, |
|||
* percentage = rawScore / 25 × 100(BigDecimal, HALF_UP, scale=2)。 |
|||
* 六维按 percentage 降序排序,同分按维度字母升序;前三位组成 primaryCode。</p> |
|||
*/ |
|||
@Component |
|||
public class AssessmentResultComputer { |
|||
|
|||
/** 六个维度的规范顺序,用于分数结果的有序输出 */ |
|||
public static final List<String> DIMENSION_ORDER = List.of("R", "I", "A", "S", "E", "C"); |
|||
|
|||
/** 单条已答维度值 */ |
|||
public record DimensionValue(String dimension, Integer value) {} |
|||
|
|||
/** 单个维度计算结果 */ |
|||
public record DimensionScoreResult(String dimension, BigDecimal score, int answeredCount) {} |
|||
|
|||
/** 计算结果 */ |
|||
public record ComputedResult( |
|||
List<DimensionScoreResult> dimensionScores, |
|||
List<String> typeRanking, |
|||
String primaryCode, |
|||
String primaryType) {} |
|||
|
|||
/** |
|||
* 计算六维聚合分、排序与 primary code。 |
|||
* |
|||
* @param answers 已答的 (维度, 值) 列表 |
|||
* @param questionsPerDimension 每个维度抽题数(5) |
|||
* @param maxScorePerQuestion maxScorePerQuestion 每题最高分(5) |
|||
*/ |
|||
public ComputedResult compute(List<DimensionValue> answers, |
|||
int questionsPerDimension, int maxScorePerQuestion) { |
|||
int maxScore = questionsPerDimension * maxScorePerQuestion; |
|||
|
|||
// 分维度求和
|
|||
Map<String, Integer> rawScores = new HashMap<>(); |
|||
Map<String, Integer> answeredCount = new HashMap<>(); |
|||
for (DimensionValue a : answers) { |
|||
if (a.dimension() == null || a.value() == null) continue; |
|||
rawScores.merge(a.dimension(), a.value(), Integer::sum); |
|||
answeredCount.merge(a.dimension(), 1, Integer::sum); |
|||
} |
|||
|
|||
// 规范顺序输出六维分数
|
|||
Map<String, BigDecimal> percentages = new LinkedHashMap<>(); |
|||
List<DimensionScoreResult> scores = new ArrayList<>(6); |
|||
for (String dim : DIMENSION_ORDER) { |
|||
int raw = rawScores.getOrDefault(dim, 0); |
|||
int count = answeredCount.getOrDefault(dim, 0); |
|||
BigDecimal pct = BigDecimal.valueOf(raw) |
|||
.multiply(BigDecimal.valueOf(100)) |
|||
.divide(BigDecimal.valueOf(maxScore), 2, RoundingMode.HALF_UP); |
|||
percentages.put(dim, pct); |
|||
scores.add(new DimensionScoreResult(dim, pct, count)); |
|||
} |
|||
|
|||
// 降序排序,同分按维度字母升序
|
|||
List<String> ranking = new ArrayList<>(DIMENSION_ORDER); |
|||
ranking.sort((d1, d2) -> { |
|||
int cmp = percentages.get(d2).compareTo(percentages.get(d1)); |
|||
return cmp != 0 ? cmp : d1.compareTo(d2); |
|||
}); |
|||
|
|||
String primaryCode = ranking.size() >= 3 |
|||
? ranking.get(0) + ranking.get(1) + ranking.get(2) : null; |
|||
String primaryType = ranking.isEmpty() ? null : ranking.get(0); |
|||
|
|||
return new ComputedResult(scores, ranking, primaryCode, primaryType); |
|||
} |
|||
} |
|||
@ -0,0 +1,15 @@ |
|||
package com.mydreamjob.assessment; |
|||
|
|||
import org.springframework.data.jpa.repository.JpaRepository; |
|||
import org.springframework.stereotype.Repository; |
|||
|
|||
import java.util.Collection; |
|||
import java.util.List; |
|||
import java.util.Optional; |
|||
|
|||
@Repository |
|||
public interface AssessmentResultRepository extends JpaRepository<AssessmentResult, Long> { |
|||
Optional<AssessmentResult> findByAttemptId(Long attemptId); |
|||
boolean existsByAttemptId(Long attemptId); |
|||
List<AssessmentResult> findByAttemptIdIn(Collection<Long> attemptIds); |
|||
} |
|||
@ -0,0 +1,143 @@ |
|||
package com.mydreamjob.assessment; |
|||
|
|||
import com.fasterxml.jackson.core.JsonProcessingException; |
|||
import com.fasterxml.jackson.core.type.TypeReference; |
|||
import com.fasterxml.jackson.databind.ObjectMapper; |
|||
import com.mydreamjob.assessment.dto.AssessmentResultResponse; |
|||
import com.mydreamjob.common.exception.BusinessException; |
|||
import com.mydreamjob.common.exception.ErrorCode; |
|||
import lombok.RequiredArgsConstructor; |
|||
import org.springframework.stereotype.Service; |
|||
import org.springframework.transaction.annotation.Transactional; |
|||
|
|||
import java.math.BigDecimal; |
|||
import java.math.RoundingMode; |
|||
import java.time.LocalDateTime; |
|||
import java.util.List; |
|||
|
|||
/** |
|||
* 测评结果服务(V34/V35)。 |
|||
* |
|||
* <p>负责将计分结果持久化(dimension/ranking 以 TEXT JSON 保存)与构建对外结果响应。 |
|||
* 结果响应不返回 studentUserId、phone、email、password、原始答案及他人信息。</p> |
|||
*/ |
|||
@Service |
|||
@RequiredArgsConstructor |
|||
public class AssessmentResultService { |
|||
|
|||
public static final String DISCLAIMER = "本结果仅供职业兴趣探索参考,不构成心理或职业诊断。"; |
|||
|
|||
private static final TypeReference<List<AssessmentResultResponse.DimensionScore>> DIM_SCORES_TYPE = new TypeReference<>() {}; |
|||
private static final TypeReference<List<String>> STRING_LIST_TYPE = new TypeReference<>() {}; |
|||
|
|||
private final AssessmentResultRepository resultRepository; |
|||
private final AssessmentDefinitionRepository definitionRepository; |
|||
private final AssessmentAttemptRepository attemptRepository; |
|||
private final CareerDirectionCatalog directionCatalog; |
|||
private final ObjectMapper objectMapper; |
|||
|
|||
/** |
|||
* 持久化结果。attempt_id 在表上唯一,并发重复生成会在数据库层被拦截。 |
|||
*/ |
|||
@Transactional |
|||
public AssessmentResult persist(AssessmentAttempt attempt, |
|||
AssessmentResultComputer.ComputedResult computed, |
|||
int answeredCount) { |
|||
int total = attempt.getSelectedQuestionCount() == null ? 0 : attempt.getSelectedQuestionCount(); |
|||
BigDecimal rate = total == 0 ? BigDecimal.ZERO.setScale(2) |
|||
: BigDecimal.valueOf(answeredCount) |
|||
.multiply(BigDecimal.valueOf(100)) |
|||
.divide(BigDecimal.valueOf(total), 2, RoundingMode.HALF_UP); |
|||
|
|||
AssessmentResult result = AssessmentResult.builder() |
|||
.attemptId(attempt.getId()) |
|||
.primaryCode(computed.primaryCode()) |
|||
.primaryType(computed.primaryType()) |
|||
.dimensionScoresText(toJson(computed.dimensionScores())) |
|||
.typeRankingText(toJson(computed.typeRanking())) |
|||
.answeredCount(answeredCount) |
|||
.completionRate(rate) |
|||
.questionSetVersion(attempt.getQuestionSetVersion()) |
|||
.rulesVersion(attemptRulesVersion(attempt)) |
|||
.generatedAt(LocalDateTime.now()) |
|||
.build(); |
|||
return resultRepository.saveAndFlush(result); |
|||
} |
|||
|
|||
/** |
|||
* 读取本人答卷结果。非 COMPLETED 返回 9703。 |
|||
*/ |
|||
@Transactional(readOnly = true) |
|||
public AssessmentResultResponse get(Long userId, Long attemptId) { |
|||
AssessmentAttempt attempt = requireCompleted(userId, attemptId); |
|||
AssessmentResult result = resultRepository.findByAttemptId(attemptId) |
|||
.orElseThrow(() -> new BusinessException(ErrorCode.ASSESSMENT_ATTEMPT_INVALID_STATE)); |
|||
return toResponse(attempt, result); |
|||
} |
|||
|
|||
/** |
|||
* 仅本人且答卷已 COMPLETED 才允许读取结果。 |
|||
*/ |
|||
public AssessmentAttempt requireCompleted(Long userId, Long attemptId) { |
|||
AssessmentAttempt attempt = attemptFor(userId, attemptId); |
|||
if (!AssessmentAttempt.STATUS_COMPLETED.equals(attempt.getStatus())) { |
|||
throw new BusinessException(ErrorCode.ASSESSMENT_ATTEMPT_INVALID_STATE); |
|||
} |
|||
return attempt; |
|||
} |
|||
|
|||
public AssessmentAttempt attemptFor(Long userId, Long attemptId) { |
|||
AssessmentAttempt attempt = attemptRepository.findById(attemptId) |
|||
.orElseThrow(() -> new BusinessException(ErrorCode.ASSESSMENT_ATTEMPT_NOT_FOUND)); |
|||
if (!attempt.getStudentUserId().equals(userId)) { |
|||
throw new BusinessException(ErrorCode.FORBIDDEN); |
|||
} |
|||
return attempt; |
|||
} |
|||
|
|||
public AssessmentResultResponse toResponse(AssessmentAttempt attempt, AssessmentResult result) { |
|||
AssessmentDefinition def = definitionRepository.findById(attempt.getDefinitionId()).orElse(null); |
|||
String typeCode = def == null ? null : def.getTypeCode(); |
|||
return AssessmentResultResponse.builder() |
|||
.attemptId(attempt.getId()) |
|||
.typeCode(typeCode) |
|||
.status(AssessmentAttempt.STATUS_COMPLETED) |
|||
.primaryCode(result.getPrimaryCode()) |
|||
.primaryType(result.getPrimaryType()) |
|||
.dimensionScores(fromJson(result.getDimensionScoresText(), DIM_SCORES_TYPE)) |
|||
.typeRanking(fromJson(result.getTypeRankingText(), STRING_LIST_TYPE)) |
|||
.answeredCount(result.getAnsweredCount()) |
|||
.completionRate(result.getCompletionRate()) |
|||
.questionSetVersion(result.getQuestionSetVersion()) |
|||
.rulesVersion(result.getRulesVersion()) |
|||
.generatedAt(result.getGeneratedAt() == null ? null : result.getGeneratedAt().toString()) |
|||
.disclaimer(DISCLAIMER) |
|||
.explorationDirections(directionCatalog.resolveDirections(result.getPrimaryCode())) |
|||
.build(); |
|||
} |
|||
|
|||
private String attemptRulesVersion(AssessmentAttempt attempt) { |
|||
return definitionRepository.findById(attempt.getDefinitionId()) |
|||
.map(AssessmentDefinition::getRulesVersion) |
|||
.orElse(null); |
|||
} |
|||
|
|||
private String toJson(Object value) { |
|||
try { |
|||
return objectMapper.writeValueAsString(value); |
|||
} catch (JsonProcessingException e) { |
|||
throw new BusinessException(ErrorCode.INTERNAL_ERROR, "failed to serialize score"); |
|||
} |
|||
} |
|||
|
|||
private <T> T fromJson(String json, TypeReference<T> type) { |
|||
if (json == null || json.isBlank()) { |
|||
return null; |
|||
} |
|||
try { |
|||
return objectMapper.readValue(json, type); |
|||
} catch (JsonProcessingException e) { |
|||
return null; |
|||
} |
|||
} |
|||
} |
|||
@ -0,0 +1,59 @@ |
|||
package com.mydreamjob.assessment; |
|||
|
|||
import org.springframework.stereotype.Component; |
|||
|
|||
import java.util.ArrayList; |
|||
import java.util.LinkedHashMap; |
|||
import java.util.List; |
|||
import java.util.Map; |
|||
|
|||
/** |
|||
* 职业方向探索建议(V34/V35, career-direction-v0.1)。 |
|||
* |
|||
* <p>仅供职业兴趣探索参考,不使用"适合职业/精准匹配/最适合职业/职业诊断结果"等表述。 |
|||
* 返回文案统一为"可能感兴趣的职业探索方向"。</p> |
|||
*/ |
|||
@Component |
|||
public class CareerDirectionCatalog { |
|||
|
|||
/** 已配置的三字母组合 → 探索方向(精确优先) */ |
|||
private final Map<String, List<String>> curated = new LinkedHashMap<>(); |
|||
|
|||
/** 单维度兜底方向(未配置的三字母组合按前三个维度分别返回一条兜底) */ |
|||
private final Map<String, String> dimensionDefault = new LinkedHashMap<>(); |
|||
|
|||
public CareerDirectionCatalog() { |
|||
curated.put("SIC", List.of("教育辅导", "用户运营", "客户成功", "职业咨询")); |
|||
curated.put("SEC", List.of("市场营销", "客户成功", "项目运营", "职业咨询")); |
|||
curated.put("SIE", List.of("职业咨询", "教育培训", "产品运营", "客户成功")); |
|||
curated.put("RIE", List.of("技术研发", "产品方案", "项目管理", "质量控制")); |
|||
|
|||
dimensionDefault.put("R", "工程设计/现场实施"); |
|||
dimensionDefault.put("I", "数据分析/科研支持"); |
|||
dimensionDefault.put("A", "创意设计/内容创作"); |
|||
dimensionDefault.put("S", "教育辅导/社区服务"); |
|||
dimensionDefault.put("E", "市场营销/项目管理"); |
|||
dimensionDefault.put("C", "财务行政/流程运营"); |
|||
} |
|||
|
|||
/** |
|||
* 由 primaryCode 解析探索方向。 |
|||
*/ |
|||
public List<String> resolveDirections(String primaryCode) { |
|||
if (primaryCode == null || primaryCode.isBlank()) { |
|||
return List.of(); |
|||
} |
|||
String key = primaryCode.toUpperCase(); |
|||
if (curated.containsKey(key)) { |
|||
return curated.get(key); |
|||
} |
|||
List<String> fallback = new ArrayList<>(); |
|||
for (char c : key.toCharArray()) { |
|||
String dim = String.valueOf(c); |
|||
if (dimensionDefault.containsKey(dim)) { |
|||
fallback.add(dimensionDefault.get(dim)); |
|||
} |
|||
} |
|||
return fallback; |
|||
} |
|||
} |
|||
@ -0,0 +1,26 @@ |
|||
package com.mydreamjob.assessment.dto; |
|||
|
|||
import lombok.Builder; |
|||
import lombok.Data; |
|||
|
|||
import java.math.BigDecimal; |
|||
import java.util.List; |
|||
|
|||
/** |
|||
* 答卷响应(V34/V35)。 |
|||
* |
|||
* <p>包含本次抽中的 30 道题(固定顺序)及已保存答案。不含 samplingSeed、studentUserId 等内部字段。</p> |
|||
*/ |
|||
@Data |
|||
@Builder |
|||
public class AssessmentAttemptResponse { |
|||
|
|||
private Long attemptId; |
|||
private String status; |
|||
private Integer totalQuestions; |
|||
private Integer answeredCount; |
|||
private BigDecimal completionRate; |
|||
private String questionSetVersion; |
|||
private Long version; |
|||
private List<AssessmentQuestionResponse> questions; |
|||
} |
|||
@ -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; |
|||
} |
|||
@ -0,0 +1,22 @@ |
|||
package com.mydreamjob.assessment.dto; |
|||
|
|||
import lombok.Builder; |
|||
import lombok.Data; |
|||
|
|||
/** |
|||
* 题目响应(V34/V35)。 |
|||
* |
|||
* <p>对外隐藏 dimension、reverseScored、source、questionVersion、questionCode —— |
|||
* 不暴露任何可推导 R/I/A/S/E/C 维度的内部编码。{@code value} 仅在答卷上下文中 |
|||
* 填充当前已保存答案(1-5),题目目录列表场景为 null。</p> |
|||
*/ |
|||
@Data |
|||
@Builder |
|||
public class AssessmentQuestionResponse { |
|||
|
|||
private Long id; |
|||
private Integer position; |
|||
private String stem; |
|||
private String scaleType; |
|||
private Integer value; |
|||
} |
|||
@ -0,0 +1,40 @@ |
|||
package com.mydreamjob.assessment.dto; |
|||
|
|||
import lombok.Builder; |
|||
import lombok.Data; |
|||
|
|||
import java.math.BigDecimal; |
|||
import java.util.List; |
|||
|
|||
/** |
|||
* 测评结果响应(V34/V35)。 |
|||
* |
|||
* <p>仅返回聚合分与探索建议,不暴露 studentUserId、phone、email、password、原始答案及他人信息。</p> |
|||
*/ |
|||
@Data |
|||
@Builder |
|||
public class AssessmentResultResponse { |
|||
|
|||
private Long attemptId; |
|||
private String typeCode; |
|||
private String status; |
|||
private String primaryCode; |
|||
private String primaryType; |
|||
private List<DimensionScore> dimensionScores; |
|||
private List<String> typeRanking; |
|||
private Integer answeredCount; |
|||
private BigDecimal completionRate; |
|||
private String questionSetVersion; |
|||
private String rulesVersion; |
|||
private String generatedAt; |
|||
private String disclaimer; |
|||
private List<String> explorationDirections; |
|||
|
|||
@Data |
|||
@Builder |
|||
public static class DimensionScore { |
|||
private String dimension; |
|||
private BigDecimal score; |
|||
private Integer answeredCount; |
|||
} |
|||
} |
|||
@ -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; |
|||
} |
|||
@ -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; |
|||
} |
|||
@ -0,0 +1,41 @@ |
|||
package com.mydreamjob.assessment.dto; |
|||
|
|||
import jakarta.validation.Valid; |
|||
import jakarta.validation.constraints.Min; |
|||
import jakarta.validation.constraints.NotEmpty; |
|||
import jakarta.validation.constraints.NotNull; |
|||
import lombok.Builder; |
|||
import lombok.Data; |
|||
|
|||
import java.util.List; |
|||
|
|||
/** |
|||
* 保存答案请求(V34/V35)。 |
|||
* |
|||
* <p>支持单题或批量;同一题重复保存执行 upsert。{@code version} 为乐观锁版本,用于并发冲突检测(9707)。</p> |
|||
*/ |
|||
@Data |
|||
@Builder |
|||
public class SaveAssessmentAnswersRequest { |
|||
|
|||
@NotNull(message = "version is required") |
|||
@Min(value = 0, message = "version must be >= 0") |
|||
private Long version; |
|||
|
|||
@NotEmpty(message = "answers is required") |
|||
@Valid |
|||
private List<AnswerItem> answers; |
|||
|
|||
@Data |
|||
@Builder |
|||
public static class AnswerItem { |
|||
|
|||
@NotNull(message = "questionId is required") |
|||
private Long questionId; |
|||
|
|||
@NotNull(message = "value is required") |
|||
@Min(value = 1, message = "value must be 1-5") |
|||
@jakarta.validation.constraints.Max(value = 5, message = "value must be 1-5") |
|||
private Integer value; |
|||
} |
|||
} |
|||
@ -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(); |
|||
} |
|||
} |
|||
@ -0,0 +1,219 @@ |
|||
package com.mydreamjob.risk; |
|||
|
|||
import com.mydreamjob.audit.AuditContext; |
|||
import com.mydreamjob.auth.User; |
|||
import com.mydreamjob.auth.UserRepository; |
|||
import com.mydreamjob.common.api.ApiResponse; |
|||
import com.mydreamjob.common.api.PageResponse; |
|||
import com.mydreamjob.common.exception.BusinessException; |
|||
import com.mydreamjob.common.exception.ErrorCode; |
|||
import com.mydreamjob.risk.dto.RiskDistributionResponse; |
|||
import com.mydreamjob.risk.dto.RiskEventActionRequest; |
|||
import com.mydreamjob.risk.dto.RiskEventResponse; |
|||
import com.mydreamjob.risk.dto.RiskOverviewResponse; |
|||
import com.mydreamjob.risk.dto.RiskRuleResponse; |
|||
import com.mydreamjob.risk.dto.RiskRuleUpdateRequest; |
|||
import com.mydreamjob.risk.dto.RiskTrendResponse; |
|||
import io.swagger.v3.oas.annotations.Operation; |
|||
import io.swagger.v3.oas.annotations.responses.ApiResponses; |
|||
import io.swagger.v3.oas.annotations.tags.Tag; |
|||
import jakarta.servlet.http.HttpServletRequest; |
|||
import jakarta.validation.Valid; |
|||
import lombok.RequiredArgsConstructor; |
|||
import org.springframework.data.domain.Page; |
|||
import org.springframework.http.ResponseEntity; |
|||
import org.springframework.security.core.Authentication; |
|||
import org.springframework.security.core.context.SecurityContextHolder; |
|||
import org.springframework.web.bind.annotation.GetMapping; |
|||
import org.springframework.web.bind.annotation.PathVariable; |
|||
import org.springframework.web.bind.annotation.PostMapping; |
|||
import org.springframework.web.bind.annotation.PutMapping; |
|||
import org.springframework.web.bind.annotation.RequestBody; |
|||
import org.springframework.web.bind.annotation.RequestMapping; |
|||
import org.springframework.web.bind.annotation.RequestParam; |
|||
import org.springframework.web.bind.annotation.RestController; |
|||
|
|||
import java.time.LocalDateTime; |
|||
import java.time.format.DateTimeParseException; |
|||
import java.util.List; |
|||
|
|||
/** |
|||
* 风险概览与运营规则配置(V37)。仅 ADMIN;COMPANY/STUDENT/未登录分别 403/403/401。 |
|||
* |
|||
* <p>身份一律 JWT/SecurityContext,不接受 adminUserId/operatorUserId/userId 参数。 |
|||
* 风险事件响应为字段白名单,严禁返回 subjectUserId/phone/email/IP/userAgent/details。</p> |
|||
*/ |
|||
@Tag(name = "风险治理", description = "ADMIN 防刷风险概览与运营规则配置") |
|||
@RestController |
|||
@RequestMapping("/admin/scoring/risk") |
|||
@RequiredArgsConstructor |
|||
public class AdminRiskController { |
|||
|
|||
private final RiskGovernanceService riskGovernanceService; |
|||
private final UserRepository userRepository; |
|||
|
|||
@Operation(summary = "风险概览(ADMIN)") |
|||
@ApiResponses({ |
|||
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "OK"), |
|||
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "401", description = "未登录"), |
|||
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "403", description = "非 ADMIN(2002/4001)"), |
|||
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "400", description = "非法筛选(9906)") |
|||
}) |
|||
@GetMapping("/overview") |
|||
public ResponseEntity<ApiResponse<RiskOverviewResponse>> overview( |
|||
@RequestParam(required = false) String from, |
|||
@RequestParam(required = false) String to, |
|||
HttpServletRequest request) { |
|||
requireAdmin(request); |
|||
return ResponseEntity.ok(ApiResponse.ok( |
|||
riskGovernanceService.overview(parse(from), parse(to)), requestId(request))); |
|||
} |
|||
|
|||
@Operation(summary = "风险趋势(ADMIN)") |
|||
@ApiResponses({ |
|||
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "OK"), |
|||
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "401", description = "未登录"), |
|||
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "403", description = "非 ADMIN(2002/4001)"), |
|||
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "400", description = "非法 bucket/筛选(9906)") |
|||
}) |
|||
@GetMapping("/trend") |
|||
public ResponseEntity<ApiResponse<RiskTrendResponse>> trend( |
|||
@RequestParam String bucket, |
|||
@RequestParam(required = false) String from, |
|||
@RequestParam(required = false) String to, |
|||
HttpServletRequest request) { |
|||
requireAdmin(request); |
|||
return ResponseEntity.ok(ApiResponse.ok( |
|||
riskGovernanceService.trend(bucket, parse(from), parse(to)), requestId(request))); |
|||
} |
|||
|
|||
@Operation(summary = "风险分布(ADMIN)") |
|||
@ApiResponses({ |
|||
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "OK"), |
|||
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "401", description = "未登录"), |
|||
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "403", description = "非 ADMIN(2002/4001)"), |
|||
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "400", description = "非法筛选(9906)") |
|||
}) |
|||
@GetMapping("/distribution") |
|||
public ResponseEntity<ApiResponse<RiskDistributionResponse>> distribution( |
|||
@RequestParam(required = false) String from, |
|||
@RequestParam(required = false) String to, |
|||
HttpServletRequest request) { |
|||
requireAdmin(request); |
|||
return ResponseEntity.ok(ApiResponse.ok( |
|||
riskGovernanceService.distribution(parse(from), parse(to)), requestId(request))); |
|||
} |
|||
|
|||
@Operation(summary = "风险事件分页(ADMIN)") |
|||
@ApiResponses({ |
|||
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "OK"), |
|||
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "401", description = "未登录"), |
|||
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "403", description = "非 ADMIN(2002/4001)"), |
|||
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "400", description = "非法筛选/分页(9906/1001)") |
|||
}) |
|||
@GetMapping("/events") |
|||
public ResponseEntity<ApiResponse<PageResponse<RiskEventResponse>>> events( |
|||
@RequestParam(defaultValue = "0") int page, |
|||
@RequestParam(defaultValue = "20") int size, |
|||
@RequestParam(required = false) String status, |
|||
@RequestParam(required = false) String severity, |
|||
@RequestParam(required = false) String ruleCode, |
|||
@RequestParam(required = false) String from, |
|||
@RequestParam(required = false) String to, |
|||
HttpServletRequest request) { |
|||
requireAdmin(request); |
|||
Page<RiskEventResponse> result = riskGovernanceService.events( |
|||
page, size, status, severity, ruleCode, parse(from), parse(to)); |
|||
return ResponseEntity.ok(ApiResponse.ok(PageResponse.of( |
|||
result.getContent(), result.getTotalElements(), result.getNumber(), result.getSize()), |
|||
requestId(request))); |
|||
} |
|||
|
|||
@Operation(summary = "风险规则列表(ADMIN)") |
|||
@ApiResponses({ |
|||
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "OK"), |
|||
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "401", description = "未登录"), |
|||
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "403", description = "非 ADMIN(2002/4001)") |
|||
}) |
|||
@GetMapping("/rules") |
|||
public ResponseEntity<ApiResponse<List<RiskRuleResponse>>> rules(HttpServletRequest request) { |
|||
requireAdmin(request); |
|||
return ResponseEntity.ok(ApiResponse.ok(riskGovernanceService.listRules(), requestId(request))); |
|||
} |
|||
|
|||
@Operation(summary = "更新风险规则(ADMIN)") |
|||
@ApiResponses({ |
|||
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "OK"), |
|||
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "400", description = "参数非法(1001)"), |
|||
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "401", description = "未登录"), |
|||
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "403", description = "非 ADMIN(2002/4001)"), |
|||
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "404", description = "规则不存在(9901)"), |
|||
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "409", description = "并发更新(9903)") |
|||
}) |
|||
@PutMapping("/rules/{ruleCode}") |
|||
public ResponseEntity<ApiResponse<RiskRuleResponse>> updateRule( |
|||
@PathVariable String ruleCode, @Valid @RequestBody RiskRuleUpdateRequest req, |
|||
HttpServletRequest request) { |
|||
Long adminUserId = requireAdmin(request); |
|||
return ResponseEntity.ok(ApiResponse.ok(riskGovernanceService.updateRule( |
|||
ruleCode, req, adminUserId, riskAuditContext(request)), requestId(request))); |
|||
} |
|||
|
|||
@Operation(summary = "风险事件人工操作(ADMIN)") |
|||
@ApiResponses({ |
|||
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "OK"), |
|||
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "400", description = "参数非法(1001)"), |
|||
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "401", description = "未登录"), |
|||
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "403", description = "非 ADMIN(2002/4001)"), |
|||
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "404", description = "事件不存在(9902)"), |
|||
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "409", description = "非法流转/并发(9904/9905)") |
|||
}) |
|||
@PostMapping("/events/{eventId}/actions") |
|||
public ResponseEntity<ApiResponse<RiskEventResponse>> actionEvent( |
|||
@PathVariable Long eventId, @Valid @RequestBody RiskEventActionRequest req, |
|||
HttpServletRequest request) { |
|||
Long adminUserId = requireAdmin(request); |
|||
return ResponseEntity.ok(ApiResponse.ok(riskGovernanceService.actionEvent( |
|||
eventId, req, adminUserId, riskAuditContext(request)), requestId(request))); |
|||
} |
|||
|
|||
private Long requireAdmin(HttpServletRequest request) { |
|||
Long userId = resolveUserId(request); |
|||
User admin = userRepository.findById(userId) |
|||
.orElseThrow(() -> new BusinessException(ErrorCode.USER_NOT_FOUND)); |
|||
boolean isAdmin = admin.getRoles() != null |
|||
&& admin.getRoles().stream().anyMatch(r -> r == User.Role.ADMIN); |
|||
if (!isAdmin) { |
|||
throw new BusinessException(ErrorCode.FORBIDDEN); |
|||
} |
|||
return userId; |
|||
} |
|||
|
|||
private Long resolveUserId(HttpServletRequest request) { |
|||
Authentication auth = SecurityContextHolder.getContext().getAuthentication(); |
|||
if (auth == null || !auth.isAuthenticated()) { |
|||
throw new BusinessException(ErrorCode.UNAUTHORIZED); |
|||
} |
|||
return Long.valueOf(auth.getName()); |
|||
} |
|||
|
|||
private String requestId(HttpServletRequest request) { |
|||
return request.getHeader("X-Request-Id"); |
|||
} |
|||
|
|||
/** 风险审计上下文:仅保留 requestId,禁止写入 IP/UA/请求体等。 */ |
|||
private AuditContext riskAuditContext(HttpServletRequest request) { |
|||
return AuditContext.builder().requestId(request.getHeader("X-Request-Id")).build(); |
|||
} |
|||
|
|||
private LocalDateTime parse(String s) { |
|||
if (s == null || s.isBlank()) { |
|||
return null; |
|||
} |
|||
try { |
|||
return LocalDateTime.parse(s); |
|||
} catch (DateTimeParseException e) { |
|||
throw new BusinessException(ErrorCode.RISK_INVALID_FILTER); |
|||
} |
|||
} |
|||
} |
|||
@ -0,0 +1,138 @@ |
|||
package com.mydreamjob.risk; |
|||
|
|||
import com.mydreamjob.audit.AuditContext; |
|||
import com.mydreamjob.audit.AuditLog; |
|||
import com.mydreamjob.audit.AuditLogRepository; |
|||
import com.mydreamjob.audit.AuditService; |
|||
import lombok.RequiredArgsConstructor; |
|||
import lombok.extern.slf4j.Slf4j; |
|||
import org.springframework.data.domain.PageRequest; |
|||
import org.springframework.stereotype.Component; |
|||
import org.springframework.transaction.annotation.Transactional; |
|||
|
|||
import java.time.Clock; |
|||
import java.time.LocalDateTime; |
|||
import java.util.List; |
|||
import java.util.Map; |
|||
import java.util.Set; |
|||
import java.util.function.Function; |
|||
import java.util.stream.Collectors; |
|||
|
|||
/** |
|||
* 风险事件扫描器(V37)。 |
|||
* |
|||
* <p>{@code scanOnce()} 为可直接单测的方法;{@code @Scheduled} 只调用它。扫描基于既有 |
|||
* {@code audit_logs},仅消费 {@code REPORT_RATE_LIMITED}/{@code BLOCKED_SEND_PROHIBITED} 两个 |
|||
* 白名单 action,且 user_id 非空。整批成功后才推进 checkpoint;任一处理失败整体回滚。</p> |
|||
* |
|||
* <p>去刷屏:仅当窗口内命中数恰等于 thresholdCount 时创建事件;阈值之后同窗口的更多命中不再 |
|||
* 创建新事件。事件插入受唯一键 {@code (rule_config_id, source_audit_log_id)} 与 existsBy 防重保护。</p> |
|||
*/ |
|||
@Slf4j |
|||
@Component |
|||
@RequiredArgsConstructor |
|||
public class RiskEventDetector { |
|||
|
|||
public static final int BATCH_SIZE = 500; |
|||
public static final Set<String> ALLOWED_ACTIONS = |
|||
Set.of("REPORT_RATE_LIMITED", "BLOCKED_SEND_PROHIBITED"); |
|||
|
|||
private static final String AUDIT_DETECTED = "RISK_EVENT_DETECTED"; |
|||
|
|||
private final ScoringRuleConfigRepository ruleRepository; |
|||
private final ScoringRiskEventRepository eventRepository; |
|||
private final ScoringRiskScanCheckpointRepository checkpointRepository; |
|||
private final AuditLogRepository auditLogRepository; |
|||
private final AuditService auditService; |
|||
private final Clock clock; |
|||
|
|||
/** |
|||
* 执行一次风险扫描。返回本轮新建的风险事件数。 |
|||
*/ |
|||
@Transactional |
|||
public int scanOnce() { |
|||
ScoringRiskScanCheckpoint checkpoint = checkpointRepository |
|||
.findByCheckpointKeyForUpdate(ScoringRiskScanCheckpoint.KEY_RISK_EVENT_DETECTOR) |
|||
.orElseGet(this::createCheckpoint); |
|||
|
|||
long lastId = checkpoint.getLastAuditLogId(); |
|||
List<AuditLog> batch = auditLogRepository |
|||
.findByIdAfterOrderByIdAsc(lastId, PageRequest.of(0, BATCH_SIZE)); |
|||
if (batch.isEmpty()) { |
|||
return 0; |
|||
} |
|||
|
|||
Map<String, ScoringRuleConfig> ruleByAction = ruleRepository.findAllByEnabledTrue().stream() |
|||
.collect(Collectors.toMap(ScoringRuleConfig::getSourceAction, Function.identity())); |
|||
|
|||
LocalDateTime now = LocalDateTime.now(clock); |
|||
long maxId = lastId; |
|||
int created = 0; |
|||
|
|||
for (AuditLog a : batch) { |
|||
if (a.getId() > maxId) { |
|||
maxId = a.getId(); |
|||
} |
|||
// 仅处理带用户主体且 action 在规则白名单内的审计
|
|||
if (a.getUserId() == null || a.getCreatedAt() == null) { |
|||
continue; |
|||
} |
|||
ScoringRuleConfig rule = ruleByAction.get(a.getAction()); |
|||
if (rule == null) { |
|||
// 规则已禁用或非白名单 action:审计照常越过 checkpoint;重新启用不回放历史
|
|||
continue; |
|||
} |
|||
|
|||
LocalDateTime winStart = a.getCreatedAt().minusMinutes(rule.getWindowMinutes()); |
|||
long occurrence = auditLogRepository.countByUserIdAndActionAndCreatedAtBetween( |
|||
a.getUserId(), a.getAction(), winStart, a.getCreatedAt()); |
|||
|
|||
// 仅恰好等于阈值才生成事件(防止阈值后每次操作刷屏)
|
|||
if (occurrence == rule.getThresholdCount().longValue() |
|||
&& !eventRepository.existsByRuleIdAndSourceAuditLogId(rule.getId(), a.getId())) { |
|||
ScoringRiskEvent ev = ScoringRiskEvent.builder() |
|||
.rule(rule) |
|||
.subjectUserId(a.getUserId()) |
|||
.sourceAuditLogId(a.getId()) |
|||
.sourceAction(rule.getSourceAction()) |
|||
.severity(rule.getSeverity()) |
|||
.windowStartedAt(winStart) |
|||
.windowEndedAt(a.getCreatedAt()) |
|||
.occurrenceCount((int) occurrence) |
|||
.status(ScoringRiskEvent.STATUS_OPEN) |
|||
.detectedAt(now) |
|||
.build(); |
|||
// 使用 saveAndFlush 的返回对象,确保事件 ID 已生成(IDENTITY 生成策略下必须用返回值,
|
|||
// 否则 ev.getId() 可能仍为 null,导致检测审计 entity_id/details.riskEventId 无法定位)。
|
|||
ScoringRiskEvent savedEvent = eventRepository.saveAndFlush(ev); |
|||
created++; |
|||
|
|||
auditService.log(null, AUDIT_DETECTED, "SCORING_RISK_EVENT", savedEvent.getId(), |
|||
detectedAuditDetails(savedEvent), AuditContext.builder().build()); |
|||
} |
|||
} |
|||
|
|||
checkpoint.setLastAuditLogId(maxId); |
|||
checkpointRepository.save(checkpoint); |
|||
return created; |
|||
} |
|||
|
|||
private ScoringRiskScanCheckpoint createCheckpoint() { |
|||
ScoringRiskScanCheckpoint cp = ScoringRiskScanCheckpoint.builder() |
|||
.checkpointKey(ScoringRiskScanCheckpoint.KEY_RISK_EVENT_DETECTOR) |
|||
.lastAuditLogId(0L) |
|||
.build(); |
|||
return checkpointRepository.saveAndFlush(cp); |
|||
} |
|||
|
|||
/** |
|||
* RISK_EVENT_DETECTED 审计 details(JSON 形式规避 AuditService 对 {@code code=} 的脱敏误伤, |
|||
* 只记录非敏感追踪字段,不含用户身份/原始审计 details/IP/UA/凭据)。 |
|||
*/ |
|||
private static String detectedAuditDetails(ScoringRiskEvent ev) { |
|||
return "{\"riskEventId\":" + ev.getId() |
|||
+ ",\"ruleCode\":\"" + ev.getRule().getRuleCode() + "\"" |
|||
+ ",\"severity\":\"" + ev.getSeverity() + "\"" |
|||
+ ",\"occurrenceCount\":" + ev.getOccurrenceCount() + "}"; |
|||
} |
|||
} |
|||
@ -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); |
|||
} |
|||
} |
|||
} |
|||
@ -0,0 +1,273 @@ |
|||
package com.mydreamjob.risk; |
|||
|
|||
import com.mydreamjob.audit.AuditContext; |
|||
import com.mydreamjob.audit.AuditService; |
|||
import com.mydreamjob.common.exception.BusinessException; |
|||
import com.mydreamjob.common.exception.ErrorCode; |
|||
import com.mydreamjob.risk.dto.RiskDistributionResponse; |
|||
import com.mydreamjob.risk.dto.RiskEventActionRequest; |
|||
import com.mydreamjob.risk.dto.RiskEventResponse; |
|||
import com.mydreamjob.risk.dto.RiskOverviewResponse; |
|||
import com.mydreamjob.risk.dto.RiskRuleResponse; |
|||
import com.mydreamjob.risk.dto.RiskRuleUpdateRequest; |
|||
import com.mydreamjob.risk.dto.RiskTrendResponse; |
|||
import lombok.RequiredArgsConstructor; |
|||
import lombok.extern.slf4j.Slf4j; |
|||
import org.springframework.dao.OptimisticLockingFailureException; |
|||
import org.springframework.data.domain.Page; |
|||
import org.springframework.data.domain.PageRequest; |
|||
import org.springframework.stereotype.Service; |
|||
import org.springframework.transaction.annotation.Transactional; |
|||
|
|||
import java.time.Clock; |
|||
import java.time.Duration; |
|||
import java.time.LocalDateTime; |
|||
import java.util.ArrayList; |
|||
import java.util.List; |
|||
import java.util.Set; |
|||
import java.util.stream.Collectors; |
|||
|
|||
/** |
|||
* 风险治理服务(V37)。仅 ADMIN 使用;身份一律 JWT/SecurityContext。 |
|||
* |
|||
* <p>概览/趋势/分布/事件全部使用 SQL GROUP BY 或 JOIN FETCH 投影,消除 N+1;事件与规则响应 |
|||
* 均为字段白名单,绝不返回 subjectUserId/phone/email/IP/userAgent/details 等敏感字段。</p> |
|||
*/ |
|||
@Slf4j |
|||
@Service |
|||
@RequiredArgsConstructor |
|||
public class RiskGovernanceService { |
|||
|
|||
private static final int MAX_PAGE_SIZE = 50; |
|||
private static final int MAX_RANGE_DAYS = 90; |
|||
private static final Set<String> SEVERITIES = Set.of("LOW", "MEDIUM", "HIGH"); |
|||
private static final Set<String> STATUSES = Set.of("OPEN", "ACKNOWLEDGED", "DISMISSED"); |
|||
private static final Set<String> BUCKETS = Set.of("HOUR", "DAY"); |
|||
private static final Set<String> REASONS = |
|||
Set.of("MONITORING", "CONFIRMED_FALSE_POSITIVE", "OUT_OF_BAND_ESCALATED"); |
|||
|
|||
private static final String AUDIT_RULE_UPDATED = "RISK_RULE_UPDATED"; |
|||
private static final String AUDIT_EVENT_ACTIONED = "RISK_EVENT_ACTIONED"; |
|||
|
|||
private final ScoringRuleConfigRepository ruleRepository; |
|||
private final ScoringRiskEventRepository eventRepository; |
|||
private final ScoringRiskActionRepository actionRepository; |
|||
private final AuditService auditService; |
|||
private final Clock clock; |
|||
|
|||
// ─────────────────────── 概览 / 趋势 / 分布 ───────────────────────
|
|||
|
|||
@Transactional(readOnly = true) |
|||
public RiskOverviewResponse overview(LocalDateTime from, LocalDateTime to) { |
|||
LocalDateTime[] range = resolveRange(from, to); |
|||
from = range[0]; |
|||
to = range[1]; |
|||
List<RiskOverviewResponse.RuleCount> byRule = eventRepository |
|||
.countGroupByRuleCode(ScoringRiskEvent.STATUS_OPEN, from, to).stream() |
|||
.map(r -> RiskOverviewResponse.RuleCount.builder() |
|||
.ruleCode((String) r[0]).count(((Number) r[1]).longValue()).build()) |
|||
.sorted(java.util.Comparator.comparing(RiskOverviewResponse.RuleCount::getRuleCode)) |
|||
.collect(Collectors.toList()); |
|||
return RiskOverviewResponse.builder() |
|||
.openCount(eventRepository.countByStatusAndDetectedAtBetween( |
|||
ScoringRiskEvent.STATUS_OPEN, from, to)) |
|||
.bySeverity(eventRepository.countGroupBySeverity( |
|||
ScoringRiskEvent.STATUS_OPEN, from, to).stream() |
|||
.map(r -> RiskOverviewResponse.SeverityCount.builder() |
|||
.severity((String) r[0]).count(((Number) r[1]).longValue()).build()) |
|||
.sorted(java.util.Comparator.comparing(RiskOverviewResponse.SeverityCount::getSeverity)) |
|||
.collect(Collectors.toList())) |
|||
.byRule(byRule) |
|||
.lastDetectedAt(eventRepository.findMaxDetectedAtByStatusBetween( |
|||
ScoringRiskEvent.STATUS_OPEN, from, to)) |
|||
.build(); |
|||
} |
|||
|
|||
@Transactional(readOnly = true) |
|||
public RiskTrendResponse trend(String bucket, LocalDateTime from, LocalDateTime to) { |
|||
LocalDateTime[] range = resolveRange(from, to); |
|||
from = range[0]; |
|||
to = range[1]; |
|||
if (!BUCKETS.contains(bucket)) { |
|||
throw new BusinessException(ErrorCode.RISK_INVALID_FILTER); |
|||
} |
|||
List<Object[]> rows = "HOUR".equals(bucket) |
|||
? eventRepository.trendByHour(from, to) |
|||
: eventRepository.trendByDay(from, to); |
|||
List<RiskTrendResponse.TrendPoint> items = new ArrayList<>(rows.size()); |
|||
for (Object[] r : rows) { |
|||
LocalDateTime time; |
|||
if ("HOUR".equals(bucket)) { |
|||
time = LocalDateTime.of((int) r[0], (int) r[1], (int) r[2], (int) r[3], 0); |
|||
} else { |
|||
time = LocalDateTime.of((int) r[0], (int) r[1], (int) r[2], 0, 0); |
|||
} |
|||
items.add(RiskTrendResponse.TrendPoint.builder() |
|||
.time(time).count(((Number) r[r.length - 1]).longValue()).build()); |
|||
} |
|||
return RiskTrendResponse.builder().bucket(bucket).items(items).build(); |
|||
} |
|||
|
|||
@Transactional(readOnly = true) |
|||
public RiskDistributionResponse distribution(LocalDateTime from, LocalDateTime to) { |
|||
LocalDateTime[] range = resolveRange(from, to); |
|||
from = range[0]; |
|||
to = range[1]; |
|||
List<RiskDistributionResponse.DistributionRow> items = eventRepository |
|||
.distribution(from, to).stream() |
|||
.map(r -> RiskDistributionResponse.DistributionRow.builder() |
|||
.ruleCode((String) r[0]).severity((String) r[1]) |
|||
.count(((Number) r[2]).longValue()).build()) |
|||
.collect(Collectors.toList()); |
|||
return RiskDistributionResponse.builder().items(items).build(); |
|||
} |
|||
|
|||
// ─────────────────────── 事件列表 ───────────────────────
|
|||
|
|||
@Transactional(readOnly = true) |
|||
public Page<RiskEventResponse> events(int page, int size, String status, String severity, |
|||
String ruleCode, LocalDateTime from, LocalDateTime to) { |
|||
LocalDateTime[] range = resolveRange(from, to); |
|||
from = range[0]; |
|||
to = range[1]; |
|||
if (page < 0 || size < 1 || size > MAX_PAGE_SIZE) { |
|||
throw new BusinessException(ErrorCode.BAD_REQUEST); |
|||
} |
|||
if (status != null && !STATUSES.contains(status)) { |
|||
throw new BusinessException(ErrorCode.RISK_INVALID_FILTER); |
|||
} |
|||
if (severity != null && !SEVERITIES.contains(severity)) { |
|||
throw new BusinessException(ErrorCode.RISK_INVALID_FILTER); |
|||
} |
|||
return eventRepository |
|||
.search(status, severity, ruleCode, from, to, PageRequest.of(page, size)) |
|||
.map(this::toEventResponse); |
|||
} |
|||
|
|||
// ─────────────────────── 规则查询 / 更新 ───────────────────────
|
|||
|
|||
@Transactional(readOnly = true) |
|||
public List<RiskRuleResponse> listRules() { |
|||
return ruleRepository.findAllByOrderByRuleCodeAsc().stream().map(this::toRuleResponse) |
|||
.collect(Collectors.toList()); |
|||
} |
|||
|
|||
@Transactional |
|||
public RiskRuleResponse updateRule(String ruleCode, RiskRuleUpdateRequest req, Long operatorUserId, |
|||
AuditContext ctx) { |
|||
ScoringRuleConfig rule = ruleRepository.findByRuleCode(ruleCode) |
|||
.orElseThrow(() -> new BusinessException(ErrorCode.RISK_RULE_NOT_FOUND)); |
|||
if (!rule.getVersion().equals(req.getExpectedVersion())) { |
|||
throw new BusinessException(ErrorCode.RISK_RULE_CONCURRENT_UPDATE); |
|||
} |
|||
rule.setEnabled(req.getEnabled()); |
|||
rule.setThresholdCount(req.getThresholdCount()); |
|||
rule.setWindowMinutes(req.getWindowMinutes()); |
|||
rule.setSeverity(req.getSeverity()); |
|||
rule.setVersion(req.getExpectedVersion()); |
|||
try { |
|||
rule = ruleRepository.saveAndFlush(rule); |
|||
} catch (OptimisticLockingFailureException e) { |
|||
throw new BusinessException(ErrorCode.RISK_RULE_CONCURRENT_UPDATE); |
|||
} |
|||
auditService.log(operatorUserId, AUDIT_RULE_UPDATED, "SCORING_RULE_CONFIG", rule.getId(), |
|||
ruleUpdateAuditDetails(rule), ctx); |
|||
return toRuleResponse(rule); |
|||
} |
|||
|
|||
// ─────────────────────── 事件人工操作 ───────────────────────
|
|||
|
|||
@Transactional |
|||
public RiskEventResponse actionEvent(Long eventId, RiskEventActionRequest req, Long operatorUserId, |
|||
AuditContext ctx) { |
|||
if (!isValidAction(req.getAction())) { |
|||
throw new BusinessException(ErrorCode.RISK_INVALID_ACTION); |
|||
} |
|||
if (!REASONS.contains(req.getReasonCode())) { |
|||
throw new BusinessException(ErrorCode.RISK_INVALID_ACTION); |
|||
} |
|||
ScoringRiskEvent ev = eventRepository.findById(eventId) |
|||
.orElseThrow(() -> new BusinessException(ErrorCode.RISK_EVENT_NOT_FOUND)); |
|||
if (!ev.getVersion().equals(req.getExpectedVersion())) { |
|||
throw new BusinessException(ErrorCode.RISK_EVENT_CONCURRENT_UPDATE); |
|||
} |
|||
String to = ScoringRiskEvent.transition(ev.getStatus(), req.getAction()); |
|||
if (to == null) { |
|||
throw new BusinessException(ErrorCode.RISK_INVALID_ACTION); |
|||
} |
|||
ev.setStatus(to); |
|||
ev.setVersion(req.getExpectedVersion()); |
|||
ScoringRiskAction actionRow = ScoringRiskAction.builder() |
|||
.riskEventId(ev.getId()).operatorUserId(operatorUserId) |
|||
.action(req.getAction()).reasonCode(req.getReasonCode()).build(); |
|||
try { |
|||
ev = eventRepository.saveAndFlush(ev); |
|||
actionRepository.save(actionRow); |
|||
} catch (OptimisticLockingFailureException e) { |
|||
throw new BusinessException(ErrorCode.RISK_EVENT_CONCURRENT_UPDATE); |
|||
} |
|||
auditService.log(operatorUserId, AUDIT_EVENT_ACTIONED, "SCORING_RISK_EVENT", ev.getId(), |
|||
eventActionAuditDetails(ev, req), ctx); |
|||
return toEventResponse(ev); |
|||
} |
|||
|
|||
private boolean isValidAction(String action) { |
|||
return "ACKNOWLEDGE".equals(action) || "DISMISS".equals(action); |
|||
} |
|||
|
|||
// ─────────────────────── 工具 ───────────────────────
|
|||
|
|||
/** 解析时间范围:默认近 7 天;任一缺失则用默认边界;校验 from<=to 与最大 90 天。 */ |
|||
private LocalDateTime[] resolveRange(LocalDateTime from, LocalDateTime to) { |
|||
LocalDateTime now = LocalDateTime.now(clock); |
|||
LocalDateTime f = from != null ? from : now.minusDays(7); |
|||
LocalDateTime t = to != null ? to : now; |
|||
if (from != null && to == null) { |
|||
t = now; |
|||
} |
|||
if (from == null && to != null) { |
|||
f = now.minusDays(7); |
|||
} |
|||
if (f.isAfter(t)) { |
|||
throw new BusinessException(ErrorCode.RISK_INVALID_FILTER); |
|||
} |
|||
if (Duration.between(f, t).toDays() > MAX_RANGE_DAYS) { |
|||
throw new BusinessException(ErrorCode.RISK_INVALID_FILTER); |
|||
} |
|||
return new LocalDateTime[]{f, t}; |
|||
} |
|||
|
|||
private RiskRuleResponse toRuleResponse(ScoringRuleConfig rule) { |
|||
return RiskRuleResponse.builder() |
|||
.ruleCode(rule.getRuleCode()).sourceAction(rule.getSourceAction()) |
|||
.thresholdCount(rule.getThresholdCount()).windowMinutes(rule.getWindowMinutes()) |
|||
.severity(rule.getSeverity()).enabled(rule.getEnabled()).version(rule.getVersion()) |
|||
.build(); |
|||
} |
|||
|
|||
private RiskEventResponse toEventResponse(ScoringRiskEvent ev) { |
|||
return RiskEventResponse.builder() |
|||
.eventId(ev.getId()).ruleCode(ev.getRule().getRuleCode()) |
|||
.sourceAction(ev.getSourceAction()).severity(ev.getSeverity()) |
|||
.status(ev.getStatus()).occurrenceCount(ev.getOccurrenceCount()) |
|||
.windowStartedAt(ev.getWindowStartedAt()).windowEndedAt(ev.getWindowEndedAt()) |
|||
.detectedAt(ev.getDetectedAt()).version(ev.getVersion()) |
|||
.build(); |
|||
} |
|||
|
|||
private static String ruleUpdateAuditDetails(ScoringRuleConfig rule) { |
|||
return "{\"ruleCode\":\"" + rule.getRuleCode() + "\"" |
|||
+ ",\"enabled\":" + rule.getEnabled() |
|||
+ ",\"thresholdCount\":" + rule.getThresholdCount() |
|||
+ ",\"windowMinutes\":" + rule.getWindowMinutes() |
|||
+ ",\"severity\":\"" + rule.getSeverity() + "\"" |
|||
+ ",\"version\":" + rule.getVersion() + "}"; |
|||
} |
|||
|
|||
private static String eventActionAuditDetails(ScoringRiskEvent ev, RiskEventActionRequest req) { |
|||
return "{\"riskEventId\":" + ev.getId() |
|||
+ ",\"action\":\"" + req.getAction() + "\"" |
|||
+ ",\"reasonCode\":\"" + req.getReasonCode() + "\"" |
|||
+ ",\"version\":" + ev.getVersion() + "}"; |
|||
} |
|||
} |
|||
@ -0,0 +1,61 @@ |
|||
package com.mydreamjob.risk; |
|||
|
|||
import jakarta.persistence.Column; |
|||
import jakarta.persistence.Entity; |
|||
import jakarta.persistence.EntityListeners; |
|||
import jakarta.persistence.GeneratedValue; |
|||
import jakarta.persistence.GenerationType; |
|||
import jakarta.persistence.Id; |
|||
import jakarta.persistence.Table; |
|||
import lombok.AllArgsConstructor; |
|||
import lombok.Builder; |
|||
import lombok.Data; |
|||
import lombok.NoArgsConstructor; |
|||
import org.springframework.data.annotation.CreatedDate; |
|||
import org.springframework.data.jpa.domain.support.AuditingEntityListener; |
|||
|
|||
import java.time.LocalDateTime; |
|||
|
|||
/** |
|||
* 风险事件人工操作记录(V37)。 |
|||
* |
|||
* <p>{@code operatorUserId} 仅内部存储,响应不返回。仅支持受控 {code reasonCode}: |
|||
* MONITORING / CONFIRMED_FALSE_POSITIVE / OUT_OF_BAND_ESCALATED;不提供自由文本备注, |
|||
* 避免运营备注成为新的个人信息载体。</p> |
|||
*/ |
|||
@Data |
|||
@Builder |
|||
@NoArgsConstructor |
|||
@AllArgsConstructor |
|||
@Entity |
|||
@Table(name = "scoring_risk_actions") |
|||
@EntityListeners(AuditingEntityListener.class) |
|||
public class ScoringRiskAction { |
|||
|
|||
public static final String ACTION_ACKNOWLEDGE = "ACKNOWLEDGE"; |
|||
public static final String ACTION_DISMISS = "DISMISS"; |
|||
|
|||
public static final String REASON_MONITORING = "MONITORING"; |
|||
public static final String REASON_CONFIRMED_FALSE_POSITIVE = "CONFIRMED_FALSE_POSITIVE"; |
|||
public static final String REASON_OUT_OF_BAND_ESCALATED = "OUT_OF_BAND_ESCALATED"; |
|||
|
|||
@Id |
|||
@GeneratedValue(strategy = GenerationType.IDENTITY) |
|||
private Long id; |
|||
|
|||
@Column(name = "risk_event_id", nullable = false) |
|||
private Long riskEventId; |
|||
|
|||
@Column(name = "operator_user_id", nullable = false) |
|||
private Long operatorUserId; |
|||
|
|||
@Column(nullable = false, length = 20) |
|||
private String action; |
|||
|
|||
@Column(name = "reason_code", nullable = false, length = 40) |
|||
private String reasonCode; |
|||
|
|||
@CreatedDate |
|||
@Column(name = "created_at", nullable = false, updatable = false) |
|||
private LocalDateTime createdAt; |
|||
} |
|||
@ -0,0 +1,6 @@ |
|||
package com.mydreamjob.risk; |
|||
|
|||
import org.springframework.data.jpa.repository.JpaRepository; |
|||
|
|||
public interface ScoringRiskActionRepository extends JpaRepository<ScoringRiskAction, Long> { |
|||
} |
|||
@ -0,0 +1,106 @@ |
|||
package com.mydreamjob.risk; |
|||
|
|||
import jakarta.persistence.Column; |
|||
import jakarta.persistence.Entity; |
|||
import jakarta.persistence.EntityListeners; |
|||
import jakarta.persistence.FetchType; |
|||
import jakarta.persistence.GeneratedValue; |
|||
import jakarta.persistence.GenerationType; |
|||
import jakarta.persistence.Id; |
|||
import jakarta.persistence.JoinColumn; |
|||
import jakarta.persistence.ManyToOne; |
|||
import jakarta.persistence.Table; |
|||
import jakarta.persistence.Version; |
|||
import lombok.AllArgsConstructor; |
|||
import lombok.Builder; |
|||
import lombok.Data; |
|||
import lombok.NoArgsConstructor; |
|||
import org.springframework.data.annotation.CreatedDate; |
|||
import org.springframework.data.annotation.LastModifiedDate; |
|||
import org.springframework.data.jpa.domain.support.AuditingEntityListener; |
|||
|
|||
import java.time.LocalDateTime; |
|||
|
|||
/** |
|||
* 风险事件(V37)。 |
|||
* |
|||
* <p>{@code subjectUserId} 仅内部关联用户,任何 API 响应均不得返回;对外只暴露事件 ID 与 |
|||
* 规则快照字段(ruleCode/sourceAction/severity)。状态机:OPEN→ACKNOWLEDGED、OPEN→DISMISSED、 |
|||
* ACKNOWLEDGED→DISMISSED,DISMISSED 为终态。</p> |
|||
*/ |
|||
@Data |
|||
@Builder |
|||
@NoArgsConstructor |
|||
@AllArgsConstructor |
|||
@Entity |
|||
@Table(name = "scoring_risk_events") |
|||
@EntityListeners(AuditingEntityListener.class) |
|||
public class ScoringRiskEvent { |
|||
|
|||
public static final String STATUS_OPEN = "OPEN"; |
|||
public static final String STATUS_ACKNOWLEDGED = "ACKNOWLEDGED"; |
|||
public static final String STATUS_DISMISSED = "DISMISSED"; |
|||
|
|||
@Id |
|||
@GeneratedValue(strategy = GenerationType.IDENTITY) |
|||
private Long id; |
|||
|
|||
@ManyToOne(fetch = FetchType.LAZY) |
|||
@JoinColumn(name = "rule_config_id", nullable = false) |
|||
private ScoringRuleConfig rule; |
|||
|
|||
@Column(name = "subject_user_id", nullable = false) |
|||
private Long subjectUserId; |
|||
|
|||
@Column(name = "source_audit_log_id", nullable = false) |
|||
private Long sourceAuditLogId; |
|||
|
|||
@Column(name = "source_action", nullable = false, length = 60) |
|||
private String sourceAction; |
|||
|
|||
@Column(nullable = false, length = 10) |
|||
private String severity; |
|||
|
|||
@Column(name = "window_started_at", nullable = false) |
|||
private LocalDateTime windowStartedAt; |
|||
|
|||
@Column(name = "window_ended_at", nullable = false) |
|||
private LocalDateTime windowEndedAt; |
|||
|
|||
@Column(name = "occurrence_count", nullable = false) |
|||
private Integer occurrenceCount; |
|||
|
|||
@Column(nullable = false, length = 20) |
|||
@Builder.Default |
|||
private String status = STATUS_OPEN; |
|||
|
|||
@Column(name = "detected_at", nullable = false) |
|||
private LocalDateTime detectedAt; |
|||
|
|||
@Version |
|||
@Column(nullable = false) |
|||
@Builder.Default |
|||
private Long version = 0L; |
|||
|
|||
@CreatedDate |
|||
@Column(name = "created_at", nullable = false, updatable = false) |
|||
private LocalDateTime createdAt; |
|||
|
|||
@LastModifiedDate |
|||
@Column(name = "updated_at", nullable = false) |
|||
private LocalDateTime updatedAt; |
|||
|
|||
/** 依据当前状态与目标 action 计算目标状态;非法返回 null。 */ |
|||
public static String transition(String from, String action) { |
|||
if (STATUS_DISMISSED.equals(from)) { |
|||
return null; |
|||
} |
|||
if ("ACKNOWLEDGE".equals(action)) { |
|||
return STATUS_OPEN.equals(from) ? STATUS_ACKNOWLEDGED : null; |
|||
} |
|||
if ("DISMISS".equals(action)) { |
|||
return STATUS_OPEN.equals(from) || STATUS_ACKNOWLEDGED.equals(from) ? STATUS_DISMISSED : null; |
|||
} |
|||
return null; |
|||
} |
|||
} |
|||
@ -0,0 +1,73 @@ |
|||
package com.mydreamjob.risk; |
|||
|
|||
import org.springframework.data.domain.Page; |
|||
import org.springframework.data.domain.Pageable; |
|||
import org.springframework.data.jpa.repository.JpaRepository; |
|||
import org.springframework.data.jpa.repository.Query; |
|||
import org.springframework.data.repository.query.Param; |
|||
|
|||
import java.time.LocalDateTime; |
|||
import java.util.List; |
|||
|
|||
public interface ScoringRiskEventRepository extends JpaRepository<ScoringRiskEvent, Long> { |
|||
|
|||
// 扫描幂等:同一(rule, source_audit_log_id) 已存在视为已处理
|
|||
boolean existsByRuleIdAndSourceAuditLogId(Long ruleId, Long sourceAuditLogId); |
|||
|
|||
long countByStatusAndDetectedAtBetween(String status, LocalDateTime from, LocalDateTime to); |
|||
|
|||
@Query("SELECT e.severity, COUNT(e) FROM ScoringRiskEvent e " |
|||
+ "WHERE e.status = :status AND e.detectedAt >= :from AND e.detectedAt <= :to " |
|||
+ "GROUP BY e.severity ORDER BY e.severity") |
|||
List<Object[]> countGroupBySeverity(@Param("status") String status, |
|||
@Param("from") LocalDateTime from, |
|||
@Param("to") LocalDateTime to); |
|||
|
|||
@Query("SELECT e.rule.ruleCode, COUNT(e) FROM ScoringRiskEvent e " |
|||
+ "WHERE e.status = :status AND e.detectedAt >= :from AND e.detectedAt <= :to " |
|||
+ "GROUP BY e.rule.ruleCode ORDER BY e.rule.ruleCode") |
|||
List<Object[]> countGroupByRuleCode(@Param("status") String status, |
|||
@Param("from") LocalDateTime from, |
|||
@Param("to") LocalDateTime to); |
|||
|
|||
@Query("SELECT MAX(e.detectedAt) FROM ScoringRiskEvent e " |
|||
+ "WHERE e.status = :status AND e.detectedAt >= :from AND e.detectedAt <= :to") |
|||
LocalDateTime findMaxDetectedAtByStatusBetween(@Param("status") String status, |
|||
@Param("from") LocalDateTime from, |
|||
@Param("to") LocalDateTime to); |
|||
|
|||
@Query("SELECT e.rule.ruleCode, e.severity, COUNT(e) FROM ScoringRiskEvent e " |
|||
+ "WHERE e.detectedAt >= :from AND e.detectedAt <= :to " |
|||
+ "GROUP BY e.rule.ruleCode, e.severity ORDER BY e.rule.ruleCode, e.severity") |
|||
List<Object[]> distribution(@Param("from") LocalDateTime from, @Param("to") LocalDateTime to); |
|||
|
|||
// 按天/小时聚合趋势(SQL GROUP BY projection,避免逐事件取数)
|
|||
@Query("SELECT FUNCTION('YEAR', e.detectedAt), FUNCTION('MONTH', e.detectedAt), " |
|||
+ "FUNCTION('DAY', e.detectedAt), COUNT(e) FROM ScoringRiskEvent e " |
|||
+ "WHERE e.detectedAt >= :from AND e.detectedAt <= :to " |
|||
+ "GROUP BY FUNCTION('YEAR', e.detectedAt), FUNCTION('MONTH', e.detectedAt), FUNCTION('DAY', e.detectedAt) " |
|||
+ "ORDER BY FUNCTION('YEAR', e.detectedAt), FUNCTION('MONTH', e.detectedAt), FUNCTION('DAY', e.detectedAt)") |
|||
List<Object[]> trendByDay(@Param("from") LocalDateTime from, @Param("to") LocalDateTime to); |
|||
|
|||
@Query("SELECT FUNCTION('YEAR', e.detectedAt), FUNCTION('MONTH', e.detectedAt), " |
|||
+ "FUNCTION('DAY', e.detectedAt), FUNCTION('HOUR', e.detectedAt), COUNT(e) FROM ScoringRiskEvent e " |
|||
+ "WHERE e.detectedAt >= :from AND e.detectedAt <= :to " |
|||
+ "GROUP BY FUNCTION('YEAR', e.detectedAt), FUNCTION('MONTH', e.detectedAt), " |
|||
+ "FUNCTION('DAY', e.detectedAt), FUNCTION('HOUR', e.detectedAt) " |
|||
+ "ORDER BY FUNCTION('YEAR', e.detectedAt), FUNCTION('MONTH', e.detectedAt), " |
|||
+ "FUNCTION('DAY', e.detectedAt), FUNCTION('HOUR', e.detectedAt)") |
|||
List<Object[]> trendByHour(@Param("from") LocalDateTime from, @Param("to") LocalDateTime to); |
|||
|
|||
@Query("SELECT e FROM ScoringRiskEvent e JOIN FETCH e.rule r " |
|||
+ "WHERE (:status IS NULL OR e.status = :status) " |
|||
+ "AND (:severity IS NULL OR e.severity = :severity) " |
|||
+ "AND (:ruleCode IS NULL OR r.ruleCode = :ruleCode) " |
|||
+ "AND e.detectedAt >= :from AND e.detectedAt <= :to " |
|||
+ "ORDER BY e.detectedAt DESC, e.id DESC") |
|||
Page<ScoringRiskEvent> search(@Param("status") String status, |
|||
@Param("severity") String severity, |
|||
@Param("ruleCode") String ruleCode, |
|||
@Param("from") LocalDateTime from, |
|||
@Param("to") LocalDateTime to, |
|||
Pageable pageable); |
|||
} |
|||
@ -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; |
|||
} |
|||
@ -0,0 +1,19 @@ |
|||
package com.mydreamjob.risk; |
|||
|
|||
import jakarta.persistence.LockModeType; |
|||
import org.springframework.data.jpa.repository.JpaRepository; |
|||
import org.springframework.data.jpa.repository.Lock; |
|||
import org.springframework.data.jpa.repository.Query; |
|||
import org.springframework.data.repository.query.Param; |
|||
|
|||
import java.util.Optional; |
|||
|
|||
public interface ScoringRiskScanCheckpointRepository extends JpaRepository<ScoringRiskScanCheckpoint, Long> { |
|||
|
|||
Optional<ScoringRiskScanCheckpoint> findByCheckpointKey(String checkpointKey); |
|||
|
|||
/** 悲观写锁:同一时间只允许一个扫描事务消费审计增量,避免并发扫描重复建事件。 */ |
|||
@Lock(LockModeType.PESSIMISTIC_WRITE) |
|||
@Query("SELECT c FROM ScoringRiskScanCheckpoint c WHERE c.checkpointKey = :key") |
|||
Optional<ScoringRiskScanCheckpoint> findByCheckpointKeyForUpdate(@Param("key") String key); |
|||
} |
|||
@ -0,0 +1,78 @@ |
|||
package com.mydreamjob.risk; |
|||
|
|||
import jakarta.persistence.Column; |
|||
import jakarta.persistence.Entity; |
|||
import jakarta.persistence.EntityListeners; |
|||
import jakarta.persistence.GeneratedValue; |
|||
import jakarta.persistence.GenerationType; |
|||
import jakarta.persistence.Id; |
|||
import jakarta.persistence.Table; |
|||
import jakarta.persistence.Version; |
|||
import lombok.AllArgsConstructor; |
|||
import lombok.Builder; |
|||
import lombok.Data; |
|||
import lombok.NoArgsConstructor; |
|||
import org.hibernate.annotations.JdbcTypeCode; |
|||
import org.hibernate.type.SqlTypes; |
|||
import org.springframework.data.annotation.CreatedDate; |
|||
import org.springframework.data.annotation.LastModifiedDate; |
|||
import org.springframework.data.jpa.domain.support.AuditingEntityListener; |
|||
|
|||
import java.time.LocalDateTime; |
|||
|
|||
/** |
|||
* 风险规则配置(V37)。 |
|||
* |
|||
* <p>{@code source_action} 仅允许 {@code REPORT_RATE_LIMITED}/{@code BLOCKED_SEND_PROHIBITED}; |
|||
* 规则更新只影响后续扫描,不回溯、不删除既有事件。</p> |
|||
*/ |
|||
@Data |
|||
@Builder |
|||
@NoArgsConstructor |
|||
@AllArgsConstructor |
|||
@Entity |
|||
@Table(name = "scoring_rule_configs") |
|||
@EntityListeners(AuditingEntityListener.class) |
|||
public class ScoringRuleConfig { |
|||
|
|||
public static final String SEVERITY_LOW = "LOW"; |
|||
public static final String SEVERITY_MEDIUM = "MEDIUM"; |
|||
public static final String SEVERITY_HIGH = "HIGH"; |
|||
|
|||
@Id |
|||
@GeneratedValue(strategy = GenerationType.IDENTITY) |
|||
private Long id; |
|||
|
|||
@Column(name = "rule_code", nullable = false, length = 60) |
|||
private String ruleCode; |
|||
|
|||
@Column(name = "source_action", nullable = false, length = 60) |
|||
private String sourceAction; |
|||
|
|||
@Column(name = "threshold_count", nullable = false) |
|||
private Integer thresholdCount; |
|||
|
|||
@Column(name = "window_minutes", nullable = false) |
|||
private Integer windowMinutes; |
|||
|
|||
@Column(nullable = false, length = 10) |
|||
private String severity; |
|||
|
|||
@Column(nullable = false) |
|||
@JdbcTypeCode(SqlTypes.TINYINT) |
|||
@Builder.Default |
|||
private Boolean enabled = true; |
|||
|
|||
@Version |
|||
@Column(nullable = false) |
|||
@Builder.Default |
|||
private Long version = 0L; |
|||
|
|||
@CreatedDate |
|||
@Column(name = "created_at", nullable = false, updatable = false) |
|||
private LocalDateTime createdAt; |
|||
|
|||
@LastModifiedDate |
|||
@Column(name = "updated_at", nullable = false) |
|||
private LocalDateTime updatedAt; |
|||
} |
|||
@ -0,0 +1,15 @@ |
|||
package com.mydreamjob.risk; |
|||
|
|||
import org.springframework.data.jpa.repository.JpaRepository; |
|||
|
|||
import java.util.List; |
|||
import java.util.Optional; |
|||
|
|||
public interface ScoringRuleConfigRepository extends JpaRepository<ScoringRuleConfig, Long> { |
|||
|
|||
Optional<ScoringRuleConfig> findByRuleCode(String ruleCode); |
|||
|
|||
List<ScoringRuleConfig> findAllByEnabledTrue(); |
|||
|
|||
List<ScoringRuleConfig> findAllByOrderByRuleCodeAsc(); |
|||
} |
|||
@ -0,0 +1,23 @@ |
|||
package com.mydreamjob.risk.dto; |
|||
|
|||
import lombok.Builder; |
|||
import lombok.Data; |
|||
|
|||
import java.util.List; |
|||
|
|||
/** |
|||
* 风险分布(V37):按规则与风险等级聚合。 |
|||
*/ |
|||
@Data |
|||
@Builder |
|||
public class RiskDistributionResponse { |
|||
private List<DistributionRow> items; |
|||
|
|||
@Data |
|||
@Builder |
|||
public static class DistributionRow { |
|||
private String ruleCode; |
|||
private String severity; |
|||
private Long count; |
|||
} |
|||
} |
|||
@ -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; |
|||
} |
|||
@ -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; |
|||
} |
|||
@ -0,0 +1,33 @@ |
|||
package com.mydreamjob.risk.dto; |
|||
|
|||
import lombok.Builder; |
|||
import lombok.Data; |
|||
|
|||
import java.time.LocalDateTime; |
|||
import java.util.List; |
|||
|
|||
/** |
|||
* 风险概览(V37):时间范围内 OPEN 总数、等级分布、规则分布、最近检测时间。 |
|||
*/ |
|||
@Data |
|||
@Builder |
|||
public class RiskOverviewResponse { |
|||
private Long openCount; |
|||
private List<SeverityCount> bySeverity; |
|||
private List<RuleCount> byRule; |
|||
private LocalDateTime lastDetectedAt; |
|||
|
|||
@Data |
|||
@Builder |
|||
public static class SeverityCount { |
|||
private String severity; |
|||
private Long count; |
|||
} |
|||
|
|||
@Data |
|||
@Builder |
|||
public static class RuleCount { |
|||
private String ruleCode; |
|||
private Long count; |
|||
} |
|||
} |
|||
@ -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; |
|||
} |
|||
@ -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; |
|||
} |
|||
@ -0,0 +1,24 @@ |
|||
package com.mydreamjob.risk.dto; |
|||
|
|||
import lombok.Builder; |
|||
import lombok.Data; |
|||
|
|||
import java.time.LocalDateTime; |
|||
import java.util.List; |
|||
|
|||
/** |
|||
* 风险趋势(V37):bucket 仅 HOUR/DAY,SQL GROUP BY 聚合。 |
|||
*/ |
|||
@Data |
|||
@Builder |
|||
public class RiskTrendResponse { |
|||
private String bucket; |
|||
private List<TrendPoint> items; |
|||
|
|||
@Data |
|||
@Builder |
|||
public static class TrendPoint { |
|||
private LocalDateTime time; |
|||
private Long count; |
|||
} |
|||
} |
|||
@ -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'; |
|||
@ -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); |
|||
@ -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); |
|||
@ -0,0 +1,210 @@ |
|||
package com.mydreamjob.ability; |
|||
|
|||
import com.fasterxml.jackson.databind.node.JsonNodeFactory; |
|||
import com.mydreamjob.ability.dto.AbilityHistoryItem; |
|||
import com.mydreamjob.ability.dto.CreateAbilityAttemptRequest; |
|||
import com.mydreamjob.ability.dto.SaveAbilityAnswersRequest; |
|||
import com.mydreamjob.audit.AuditContext; |
|||
import com.mydreamjob.audit.AuditService; |
|||
import com.mydreamjob.auth.User; |
|||
import com.mydreamjob.auth.UserRepository; |
|||
import com.mydreamjob.common.exception.BusinessException; |
|||
import com.mydreamjob.common.exception.ErrorCode; |
|||
import org.junit.jupiter.api.BeforeEach; |
|||
import org.junit.jupiter.api.Test; |
|||
import org.junit.jupiter.api.extension.ExtendWith; |
|||
import org.mockito.InjectMocks; |
|||
import org.mockito.Mock; |
|||
import org.mockito.junit.jupiter.MockitoExtension; |
|||
import org.springframework.data.domain.Page; |
|||
import org.springframework.data.domain.PageImpl; |
|||
|
|||
import java.time.Clock; |
|||
import java.time.Instant; |
|||
import java.time.LocalDateTime; |
|||
import java.time.ZoneId; |
|||
import java.time.ZoneOffset; |
|||
import java.util.ArrayList; |
|||
import java.util.List; |
|||
import java.util.Optional; |
|||
import java.util.Set; |
|||
|
|||
import static org.junit.jupiter.api.Assertions.*; |
|||
import static org.mockito.ArgumentMatchers.any; |
|||
import static org.mockito.ArgumentMatchers.anyList; |
|||
import static org.mockito.ArgumentMatchers.anyLong; |
|||
import static org.mockito.ArgumentMatchers.anyString; |
|||
import static org.mockito.Mockito.never; |
|||
import static org.mockito.Mockito.times; |
|||
import static org.mockito.Mockito.verify; |
|||
import static org.mockito.Mockito.when; |
|||
|
|||
@ExtendWith(MockitoExtension.class) |
|||
class AbilityAttemptServiceTest { |
|||
|
|||
@Mock private AbilityDefinitionRepository definitionRepository; |
|||
@Mock private AbilityQuestionRepository questionRepository; |
|||
@Mock private AbilityAttemptRepository attemptRepository; |
|||
@Mock private AbilityAttemptQuestionRepository attemptQuestionRepository; |
|||
@Mock private AbilityAnswerRepository answerRepository; |
|||
@Mock private AbilityResultRepository resultRepository; |
|||
@Mock private AbilityResultService resultService; |
|||
@Mock private AbilityResultComputer resultComputer; |
|||
@Mock private AbilityAttemptWriter attemptWriter; |
|||
@Mock private AbilitySnapshotCodec snapshotCodec; |
|||
@Mock private UserRepository userRepository; |
|||
@Mock private AuditService auditService; |
|||
@Mock private Clock clock; |
|||
|
|||
@InjectMocks private AbilityAttemptService service; |
|||
|
|||
private final AuditContext ctx = AuditContext.builder().requestId("r").build(); |
|||
|
|||
@BeforeEach |
|||
void time() { |
|||
org.mockito.Mockito.lenient().when(clock.getZone()).thenReturn(ZoneId.systemDefault()); |
|||
org.mockito.Mockito.lenient().when(clock.instant()).thenReturn(Instant.parse("2026-09-03T08:00:00Z")); |
|||
} |
|||
|
|||
private User student() { |
|||
return User.builder().id(77L).phone("p").role(User.Role.STUDENT) |
|||
.roles(Set.of(User.Role.STUDENT)).build(); |
|||
} |
|||
|
|||
private AbilityDefinition activeDef(int maxCompleted) { |
|||
return AbilityDefinition.builder().id(1L).typeCode("WORKPLACE_ABILITY").status("ACTIVE") |
|||
.attemptQuestionCount(24).questionsPerDimension(6).timeLimitMinutes(30) |
|||
.resultValidDays(7).retakeWindowDays(90).retakeMaxCompleted(maxCompleted) |
|||
.questionSetVersion("ability-v0.1").rulesVersion("ability-scoring-v0.1").build(); |
|||
} |
|||
|
|||
@Test |
|||
void create_typeNotFound_whenDefinitionMissing() { |
|||
when(userRepository.findById(77L)).thenReturn(Optional.of(student())); |
|||
when(definitionRepository.findByTypeCode(anyString())).thenReturn(Optional.empty()); |
|||
BusinessException ex = assertThrows(BusinessException.class, |
|||
() -> service.create(77L, newReq(), ctx)); |
|||
assertEquals(ErrorCode.ABILITY_TYPE_NOT_FOUND.getCode(), ex.getErrorCodeInt()); |
|||
} |
|||
|
|||
@Test |
|||
void create_activeConflict_returns9808() { |
|||
when(userRepository.findById(77L)).thenReturn(Optional.of(student())); |
|||
when(definitionRepository.findByTypeCode(anyString())) |
|||
.thenReturn(Optional.of(activeDef(3))); |
|||
when(attemptRepository.findTimedOutActiveByUserAndDef(any(), any(), any())).thenReturn(List.of()); |
|||
when(attemptRepository.existsByStudentUserIdAndDefinitionIdAndStatusIn(any(), any(), any())) |
|||
.thenReturn(true); |
|||
BusinessException ex = assertThrows(BusinessException.class, |
|||
() -> service.create(77L, newReq(), ctx)); |
|||
assertEquals(ErrorCode.ABILITY_DUPLICATE_ACTIVE.getCode(), ex.getErrorCodeInt()); |
|||
} |
|||
|
|||
@Test |
|||
void create_retakeLimit_returns9811() { |
|||
when(userRepository.findById(77L)).thenReturn(Optional.of(student())); |
|||
when(definitionRepository.findByTypeCode(anyString())) |
|||
.thenReturn(Optional.of(activeDef(1))); |
|||
when(attemptRepository.findTimedOutActiveByUserAndDef(any(), any(), any())).thenReturn(List.of()); |
|||
when(attemptRepository.existsByStudentUserIdAndDefinitionIdAndStatusIn(any(), any(), any())) |
|||
.thenReturn(false); |
|||
when(attemptRepository.findByStudentUserIdAndDefinitionIdAndStatusOrderByCompletedAtDesc(any(), any(), anyString())) |
|||
.thenReturn(List.of()); |
|||
when(attemptRepository.countByStudentUserIdAndDefinitionIdAndStatusAndCompletedAtGreaterThanEqual( |
|||
any(), any(), anyString(), any())).thenReturn(1); |
|||
BusinessException ex = assertThrows(BusinessException.class, |
|||
() -> service.create(77L, newReq(), ctx)); |
|||
assertEquals(ErrorCode.ABILITY_RETAKE_LIMIT.getCode(), ex.getErrorCodeInt()); |
|||
} |
|||
|
|||
@Test |
|||
void submit_perDimensionCheck_queriesAnswersOnce_notPerQuestion() { |
|||
when(userRepository.findById(77L)).thenReturn(Optional.of(student())); |
|||
when(definitionRepository.findById(1L)).thenReturn(Optional.of(activeDef(3))); |
|||
AbilityAttempt attempt = AbilityAttempt.builder().id(9L).studentUserId(77L).definitionId(1L) |
|||
.status(AbilityAttempt.STATUS_IN_PROGRESS).questionSetVersion("ability-v0.1") |
|||
.expiresAt(LocalDateTime.now(clock).plusMinutes(30)).build(); |
|||
when(attemptRepository.findById(9L)).thenReturn(Optional.of(attempt)); |
|||
when(answerRepository.countByAttemptId(9L)).thenReturn(24L); |
|||
List<AbilityAttemptQuestion> aqs = new ArrayList<>(); |
|||
for (int i = 0; i < 24; i++) { |
|||
aqs.add(AbilityAttemptQuestion.builder().id(1000L + i).attemptId(9L).position(i + 1).build()); |
|||
} |
|||
when(attemptQuestionRepository.findByAttemptIdOrderByPositionAsc(9L)).thenReturn(aqs); |
|||
// 空答案 → 四维未答满 → 9806;借此验证 perDimensionAnswered 只批量取一次答案、不逐题查询
|
|||
when(answerRepository.findByAttemptId(9L)).thenReturn(List.of()); |
|||
|
|||
BusinessException ex = assertThrows(BusinessException.class, |
|||
() -> service.submit(77L, 9L, ctx)); |
|||
assertEquals(ErrorCode.ABILITY_INCOMPLETE.getCode(), ex.getErrorCodeInt()); |
|||
|
|||
verify(answerRepository, times(1)).findByAttemptId(anyLong()); |
|||
} |
|||
|
|||
@Test |
|||
void saveAnswers_24Questions_batchLoadOnce_saveAllOnce() { |
|||
when(userRepository.findById(77L)).thenReturn(Optional.of(student())); |
|||
AbilityAttempt attempt = AbilityAttempt.builder().id(9L).studentUserId(77L).definitionId(1L) |
|||
.status(AbilityAttempt.STATUS_IN_PROGRESS).questionSetVersion("ability-v0.1") |
|||
.expiresAt(LocalDateTime.now(clock).plusMinutes(30)).build(); |
|||
when(attemptRepository.findById(9L)).thenReturn(Optional.of(attempt)); |
|||
when(definitionRepository.findById(1L)).thenReturn(Optional.of(activeDef(3))); |
|||
|
|||
List<AbilityAttemptQuestion> aqs = new ArrayList<>(); |
|||
for (int i = 0; i < 24; i++) { |
|||
aqs.add(AbilityAttemptQuestion.builder().id(1000L + i).attemptId(9L).position(i + 1).build()); |
|||
} |
|||
when(attemptQuestionRepository.findByAttemptIdOrderByPositionAsc(9L)).thenReturn(aqs); |
|||
when(snapshotCodec.optionExists(any(), any())).thenReturn(true); |
|||
when(snapshotCodec.awardedScore(any(), any())).thenReturn(4); |
|||
when(snapshotCodec.parse(any())).thenReturn(JsonNodeFactory.instance.objectNode()); |
|||
// 部分题目已存在答案(模拟混合 upsert 的既有数据路径)
|
|||
when(answerRepository.findByAttemptIdAndAttemptQuestionIdIn(anyLong(), anyList())) |
|||
.thenReturn(List.of(AbilityAnswer.builder().id(9L).attemptId(9L) |
|||
.attemptQuestionId(1000L).selectedOptionId("A").awardedScore(0).build())); |
|||
when(answerRepository.countByAttemptId(9L)).thenReturn(24L); |
|||
when(answerRepository.findByAttemptId(9L)).thenReturn(List.of()); |
|||
when(attemptRepository.saveAndFlush(any())).thenReturn(attempt); |
|||
|
|||
SaveAbilityAnswersRequest req = new SaveAbilityAnswersRequest(); |
|||
List<SaveAbilityAnswersRequest.AnswerItem> items = new ArrayList<>(); |
|||
for (int i = 0; i < 24; i++) { |
|||
SaveAbilityAnswersRequest.AnswerItem it = new SaveAbilityAnswersRequest.AnswerItem(); |
|||
it.setAttemptQuestionId(1000L + i); |
|||
it.setSelectedOptionId("A"); |
|||
items.add(it); |
|||
} |
|||
req.setAnswers(items); |
|||
|
|||
service.saveAnswers(77L, 9L, req, ctx); |
|||
|
|||
// 24 题整体保存只触发一次批量查询与一次批量持久化;单题查询方法已删除,结构上不可能逐题查询
|
|||
verify(answerRepository, times(1)).findByAttemptIdAndAttemptQuestionIdIn(anyLong(), anyList()); |
|||
verify(answerRepository, times(1)).saveAll(any()); |
|||
} |
|||
|
|||
@Test |
|||
void listMine_loadsDefinitionsInBatch_notPerItem() { |
|||
when(userRepository.findById(77L)).thenReturn(Optional.of(student())); |
|||
AbilityAttempt a1 = AbilityAttempt.builder().id(1L).studentUserId(77L).definitionId(1L) |
|||
.status(AbilityAttempt.STATUS_COMPLETED).questionSetVersion("ability-v0.1").build(); |
|||
AbilityAttempt a2 = AbilityAttempt.builder().id(2L).studentUserId(77L).definitionId(2L) |
|||
.status(AbilityAttempt.STATUS_COMPLETED).questionSetVersion("ability-v0.1").build(); |
|||
when(attemptRepository.findByStudentUserIdOrderByCreatedAtDesc(any(), any())) |
|||
.thenReturn(new PageImpl<>(List.of(a1, a2))); |
|||
when(resultRepository.findByAttemptIdIn(any())).thenReturn(List.of()); |
|||
when(definitionRepository.findAllById(any())).thenReturn(List.of()); |
|||
|
|||
Page<AbilityHistoryItem> page = service.listMine(77L, 0, 20); |
|||
assertEquals(2, page.getContent().size()); |
|||
|
|||
verify(definitionRepository, never()).findById(anyLong()); |
|||
verify(definitionRepository, times(1)).findAllById(any()); |
|||
} |
|||
|
|||
private CreateAbilityAttemptRequest newReq() { |
|||
CreateAbilityAttemptRequest r = new CreateAbilityAttemptRequest(); |
|||
r.setTypeCode("WORKPLACE_ABILITY"); |
|||
return r; |
|||
} |
|||
} |
|||
@ -0,0 +1,39 @@ |
|||
package com.mydreamjob.ability; |
|||
|
|||
import com.mydreamjob.ability.dto.AbilityTypeResponse; |
|||
import org.junit.jupiter.api.Test; |
|||
import org.junit.jupiter.api.extension.ExtendWith; |
|||
import org.mockito.InjectMocks; |
|||
import org.mockito.Mock; |
|||
import org.mockito.junit.jupiter.MockitoExtension; |
|||
|
|||
import java.util.List; |
|||
|
|||
import static org.junit.jupiter.api.Assertions.*; |
|||
import static org.mockito.Mockito.when; |
|||
|
|||
@ExtendWith(MockitoExtension.class) |
|||
class AbilityCatalogServiceTest { |
|||
|
|||
@Mock private AbilityDefinitionRepository definitionRepository; |
|||
@InjectMocks private AbilityCatalogService catalogService; |
|||
|
|||
@Test |
|||
void listTypes_returnsActiveDefinitionsMapped() { |
|||
AbilityDefinition def = AbilityDefinition.builder() |
|||
.id(1L).typeCode("WORKPLACE_ABILITY").displayName("四维能力测评") |
|||
.description("desc").status("ACTIVE").questionCount(48).attemptQuestionCount(24) |
|||
.questionsPerDimension(6).timeLimitMinutes(30).resultValidDays(7).build(); |
|||
when(definitionRepository.findAllByStatusOrderByCreatedAtAsc("ACTIVE")) |
|||
.thenReturn(List.of(def)); |
|||
|
|||
List<AbilityTypeResponse> types = catalogService.listTypes(); |
|||
assertEquals(1, types.size()); |
|||
AbilityTypeResponse t = types.get(0); |
|||
assertEquals("WORKPLACE_ABILITY", t.getTypeCode()); |
|||
assertEquals(48, t.getQuestionCount()); |
|||
assertEquals(24, t.getAttemptQuestionCount()); |
|||
assertEquals(30, t.getTimeLimitMinutes()); |
|||
assertEquals(7, t.getResultValidDays()); |
|||
} |
|||
} |
|||
@ -0,0 +1,163 @@ |
|||
package com.mydreamjob.ability; |
|||
|
|||
import com.fasterxml.jackson.databind.JsonNode; |
|||
import com.fasterxml.jackson.databind.ObjectMapper; |
|||
import com.mydreamjob.auth.JwtTokenProvider; |
|||
import com.mydreamjob.auth.User; |
|||
import com.mydreamjob.auth.UserRepository; |
|||
import org.junit.jupiter.api.AfterEach; |
|||
import org.junit.jupiter.api.BeforeEach; |
|||
import org.junit.jupiter.api.Test; |
|||
import org.springframework.beans.factory.annotation.Autowired; |
|||
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; |
|||
import org.springframework.boot.test.context.SpringBootTest; |
|||
import org.springframework.boot.test.mock.mockito.MockBean; |
|||
import org.springframework.data.redis.core.RedisTemplate; |
|||
import org.springframework.data.redis.core.ValueOperations; |
|||
import org.springframework.http.MediaType; |
|||
import org.springframework.test.context.ActiveProfiles; |
|||
import org.springframework.test.web.servlet.MockMvc; |
|||
|
|||
import java.util.ArrayList; |
|||
import java.util.List; |
|||
import java.util.Map; |
|||
import java.util.Set; |
|||
import java.util.concurrent.atomic.AtomicLong; |
|||
|
|||
import static org.junit.jupiter.api.Assertions.*; |
|||
import static org.mockito.ArgumentMatchers.any; |
|||
import static org.mockito.Mockito.mock; |
|||
import static org.mockito.Mockito.when; |
|||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; |
|||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; |
|||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; |
|||
|
|||
@SpringBootTest |
|||
@AutoConfigureMockMvc |
|||
@ActiveProfiles("integration-test") |
|||
class AbilityControllerTest { |
|||
|
|||
private static final AtomicLong PHONE = new AtomicLong(6000); |
|||
|
|||
@Autowired private MockMvc mockMvc; |
|||
@Autowired private ObjectMapper objectMapper; |
|||
@Autowired private JwtTokenProvider jwtTokenProvider; |
|||
@Autowired private UserRepository userRepository; |
|||
|
|||
@MockBean private RedisTemplate<String, Object> redisTemplate; |
|||
|
|||
private Long studentUserId; |
|||
private Long companyUserId; |
|||
private Long adminUserId; |
|||
|
|||
@BeforeEach |
|||
@SuppressWarnings("unchecked") |
|||
void setUp() { |
|||
ValueOperations<String, Object> ops = mock(ValueOperations.class); |
|||
when(redisTemplate.opsForValue()).thenReturn(ops); |
|||
when(redisTemplate.hasKey(any())).thenReturn(false); |
|||
studentUserId = createUser(User.Role.STUDENT); |
|||
companyUserId = createUser(User.Role.COMPANY); |
|||
adminUserId = createUser(User.Role.ADMIN); |
|||
} |
|||
|
|||
@AfterEach |
|||
void tearDown() { |
|||
userRepository.deleteById(adminUserId); |
|||
userRepository.deleteById(companyUserId); |
|||
userRepository.deleteById(studentUserId); |
|||
} |
|||
|
|||
private Long createUser(User.Role role) { |
|||
User u = userRepository.save(User.builder() |
|||
.phone("17" + String.format("%08d", PHONE.incrementAndGet())).nickname("u") |
|||
.role(role).roles(Set.of(role)).active(true).verified(true).build()); |
|||
return u.getId(); |
|||
} |
|||
|
|||
private String token(Long userId, User.Role role) { |
|||
return "Bearer " + jwtTokenProvider.generateAccessToken(userId, |
|||
String.valueOf(userId), Map.of("roles", role.name())); |
|||
} |
|||
|
|||
@Test |
|||
void anonymous_create_returns401() throws Exception { |
|||
mockMvc.perform(post("/abilities/attempts").contentType(MediaType.APPLICATION_JSON) |
|||
.content("{\"typeCode\":\"WORKPLACE_ABILITY\"}")).andExpect(status().isUnauthorized()); |
|||
} |
|||
|
|||
@Test |
|||
void studentListTypes_returns200() throws Exception { |
|||
mockMvc.perform(get("/abilities/types").header("Authorization", token(studentUserId, User.Role.STUDENT))) |
|||
.andExpect(status().isOk()) |
|||
.andExpect(jsonPath("$.data.[0].typeCode").value("WORKPLACE_ABILITY")); |
|||
} |
|||
|
|||
@Test |
|||
void companyAndAdmin_create_returns403() throws Exception { |
|||
mockMvc.perform(post("/abilities/attempts").header("Authorization", token(companyUserId, User.Role.COMPANY)) |
|||
.contentType(MediaType.APPLICATION_JSON).content("{\"typeCode\":\"WORKPLACE_ABILITY\"}")) |
|||
.andExpect(status().isForbidden()); |
|||
mockMvc.perform(post("/abilities/attempts").header("Authorization", token(adminUserId, User.Role.ADMIN)) |
|||
.contentType(MediaType.APPLICATION_JSON).content("{\"typeCode\":\"WORKPLACE_ABILITY\"}")) |
|||
.andExpect(status().isForbidden()); |
|||
} |
|||
|
|||
@Test |
|||
void fullStudentFlow_createSaveSubmitResult_repeatIdempotent_noSensitiveLeak() throws Exception { |
|||
String tk = token(studentUserId, User.Role.STUDENT); |
|||
|
|||
JsonNode create = objectMapper.readTree(mockMvc.perform(post("/abilities/attempts") |
|||
.header("Authorization", tk).contentType(MediaType.APPLICATION_JSON) |
|||
.content("{\"typeCode\":\"WORKPLACE_ABILITY\",\"idempotencyKey\":\"http-key-1\"}")) |
|||
.andExpect(status().isOk()) |
|||
.andReturn().getResponse().getContentAsString()); |
|||
long attemptId = create.path("data").path("attemptId").asLong(); |
|||
assertEquals(24, create.path("data").path("questions").size()); |
|||
|
|||
List<Map<String, Object>> answers = new ArrayList<>(); |
|||
for (JsonNode q : create.path("data").path("questions")) { |
|||
answers.add(Map.of("attemptQuestionId", q.path("attemptQuestionId").asLong(), |
|||
"selectedOptionId", "A")); |
|||
} |
|||
String body = objectMapper.writeValueAsString(Map.of("answers", answers)); |
|||
|
|||
mockMvc.perform(patch("/abilities/attempts/{id}/answers", attemptId) |
|||
.header("Authorization", tk).contentType(MediaType.APPLICATION_JSON).content(body)) |
|||
.andExpect(status().isOk()) |
|||
.andExpect(jsonPath("$.data.answeredCount").value(24)); |
|||
|
|||
JsonNode submitted = objectMapper.readTree(mockMvc.perform(post("/abilities/attempts/{id}/submit", attemptId) |
|||
.header("Authorization", tk)) |
|||
.andExpect(status().isOk()) |
|||
.andExpect(jsonPath("$.data.status").value("COMPLETED")) |
|||
.andReturn().getResponse().getContentAsString()); |
|||
|
|||
// 结果脱敏:完整 result JSON 不得包含题库内部字段
|
|||
String submitedRaw = submitted.toString(); |
|||
assertFalse(submitedRaw.contains("studentUserId")); |
|||
assertFalse(submitedRaw.contains("phone")); |
|||
assertFalse(submitedRaw.contains("email")); |
|||
assertFalse(submitedRaw.contains("answerKey"), "result must not contain answerKey"); |
|||
assertFalse(submitedRaw.contains("scoringRule"), "result must not contain scoringRule"); |
|||
assertFalse(submitedRaw.contains("questionCode"), "result must not contain questionCode"); |
|||
assertFalse(submitedRaw.contains("competencyTag"), "result must not contain competencyTag"); |
|||
assertFalse(submitedRaw.contains("source"), "result must not contain source"); |
|||
assertFalse(submitedRaw.contains("difficulty"), "result must not contain difficulty"); |
|||
assertFalse(submitedRaw.contains("discrimination"), "result must not contain discrimination"); |
|||
assertTrue(submitedRaw.contains("disclaimer")); |
|||
assertEquals(4, submitted.path("data").path("dimensionScores").size()); |
|||
|
|||
// 重复提交幂等:同一综合分
|
|||
JsonNode resub = objectMapper.readTree(mockMvc.perform(post("/abilities/attempts/{id}/submit", attemptId) |
|||
.header("Authorization", tk)).andExpect(status().isOk()) |
|||
.andReturn().getResponse().getContentAsString()); |
|||
assertEquals(submitted.path("data").path("overallScore").asText(), |
|||
resub.path("data").path("overallScore").asText()); |
|||
|
|||
// GET result 200
|
|||
mockMvc.perform(get("/abilities/attempts/{id}/result", attemptId).header("Authorization", tk)) |
|||
.andExpect(status().isOk()) |
|||
.andExpect(jsonPath("$.data.validityStatus").value("VALID")); |
|||
} |
|||
} |
|||
@ -0,0 +1,391 @@ |
|||
package com.mydreamjob.ability; |
|||
|
|||
import com.mydreamjob.ability.dto.AbilityAttemptResponse; |
|||
import com.mydreamjob.ability.dto.CreateAbilityAttemptRequest; |
|||
import com.mydreamjob.ability.dto.SaveAbilityAnswersRequest; |
|||
import com.mydreamjob.audit.AuditContext; |
|||
import com.mydreamjob.audit.AuditLogRepository; |
|||
import com.mydreamjob.auth.User; |
|||
import com.mydreamjob.auth.UserRepository; |
|||
import com.mydreamjob.candidatescore.CandidateDimensionScoreRepository; |
|||
import com.mydreamjob.common.exception.BusinessException; |
|||
import com.mydreamjob.common.exception.ErrorCode; |
|||
import org.junit.jupiter.api.AfterEach; |
|||
import org.junit.jupiter.api.BeforeEach; |
|||
import org.junit.jupiter.api.Test; |
|||
import org.springframework.beans.factory.annotation.Autowired; |
|||
import org.springframework.boot.test.context.SpringBootTest; |
|||
import org.springframework.boot.test.mock.mockito.MockBean; |
|||
import org.springframework.data.redis.core.RedisTemplate; |
|||
import org.springframework.test.context.ActiveProfiles; |
|||
|
|||
import java.time.Clock; |
|||
import java.time.Instant; |
|||
import java.time.LocalDateTime; |
|||
import java.time.ZoneId; |
|||
import java.util.ArrayList; |
|||
import java.util.List; |
|||
import java.util.Set; |
|||
import java.util.concurrent.ConcurrentLinkedQueue; |
|||
import java.util.concurrent.CountDownLatch; |
|||
import java.util.concurrent.ExecutorService; |
|||
import java.util.concurrent.Executors; |
|||
import java.util.concurrent.TimeUnit; |
|||
import java.util.concurrent.atomic.AtomicLong; |
|||
|
|||
import static org.junit.jupiter.api.Assertions.*; |
|||
import static org.mockito.ArgumentMatchers.any; |
|||
import static org.mockito.Mockito.mock; |
|||
import static org.mockito.Mockito.when; |
|||
|
|||
@SpringBootTest |
|||
@ActiveProfiles("integration-test") |
|||
class AbilityPersistenceIntegrationTest { |
|||
|
|||
private static final AtomicLong PHONE = new AtomicLong(5000); |
|||
private LocalDateTime base = LocalDateTime.of(2026, 9, 3, 8, 0, 0); |
|||
|
|||
@Autowired private AbilityAttemptService attemptService; |
|||
@Autowired private AbilityDefinitionRepository definitionRepository; |
|||
@Autowired private AbilityAttemptRepository attemptRepository; |
|||
@Autowired private AbilityAttemptQuestionRepository attemptQuestionRepository; |
|||
@Autowired private AbilityAnswerRepository answerRepository; |
|||
@Autowired private AbilityResultRepository resultRepository; |
|||
@Autowired private UserRepository userRepository; |
|||
@Autowired private CandidateDimensionScoreRepository dimensionScoreRepository; |
|||
@Autowired private AuditLogRepository auditLogRepository; |
|||
|
|||
@MockBean private RedisTemplate<String, Object> redisTemplate; |
|||
@MockBean private Clock clock; |
|||
|
|||
private final AuditContext ctx = AuditContext.builder().requestId("req").build(); |
|||
private final List<Long> createdUsers = new ArrayList<>(); |
|||
|
|||
@BeforeEach |
|||
void setUp() { |
|||
when(redisTemplate.opsForValue()).thenReturn(mock(org.springframework.data.redis.core.ValueOperations.class)); |
|||
when(redisTemplate.hasKey(any())).thenReturn(false); |
|||
time(base); |
|||
} |
|||
|
|||
@AfterEach |
|||
void tearDown() { |
|||
for (Long id : createdUsers) { |
|||
userRepository.deleteById(id); |
|||
} |
|||
createdUsers.clear(); |
|||
} |
|||
|
|||
private void time(LocalDateTime t) { |
|||
when(clock.getZone()).thenReturn(ZoneId.of("Asia/Shanghai")); |
|||
when(clock.instant()).thenReturn(t.atZone(ZoneId.of("Asia/Shanghai")).toInstant()); |
|||
} |
|||
|
|||
private Long createUser() { |
|||
User u = userRepository.save(User.builder() |
|||
.phone("18" + String.format("%08d", PHONE.incrementAndGet())).nickname("u") |
|||
.role(User.Role.STUDENT).roles(Set.of(User.Role.STUDENT)).active(true).verified(true).build()); |
|||
createdUsers.add(u.getId()); |
|||
return u.getId(); |
|||
} |
|||
|
|||
private CreateAbilityAttemptRequest createReq(String key) { |
|||
CreateAbilityAttemptRequest r = new CreateAbilityAttemptRequest(); |
|||
r.setTypeCode("WORKPLACE_ABILITY"); |
|||
r.setIdempotencyKey(key); |
|||
return r; |
|||
} |
|||
|
|||
private SaveAbilityAnswersRequest answers(AbilityAttemptResponse resp, int start, int end, String option) { |
|||
List<SaveAbilityAnswersRequest.AnswerItem> items = new ArrayList<>(); |
|||
for (int i = start; i < end && i < resp.getQuestions().size(); i++) { |
|||
var q = resp.getQuestions().get(i); |
|||
SaveAbilityAnswersRequest.AnswerItem it = new SaveAbilityAnswersRequest.AnswerItem(); |
|||
it.setAttemptQuestionId(q.getAttemptQuestionId()); |
|||
it.setSelectedOptionId(option); |
|||
items.add(it); |
|||
} |
|||
SaveAbilityAnswersRequest r = new SaveAbilityAnswersRequest(); |
|||
r.setAnswers(items); |
|||
return r; |
|||
} |
|||
|
|||
@Test |
|||
void create_returns24QuestionsFourDimsSixEach_andNoLeakyFields() { |
|||
Long u = createUser(); |
|||
AbilityAttemptResponse resp = attemptService.create(u, createReq("k1"), ctx); |
|||
assertEquals(24, resp.getQuestions().size()); |
|||
assertEquals(24, resp.getTotalQuestions()); |
|||
assertEquals("DRAFT", resp.getStatus()); |
|||
assertNull(resp.getStartedAt()); |
|||
assertEquals(24, attemptQuestionRepository.countByAttemptId(resp.getAttemptId())); |
|||
resp.getQuestions().forEach(q -> { |
|||
assertNotNull(q.getAttemptQuestionId()); |
|||
assertNotNull(q.getStem()); |
|||
assertEquals(4, q.getOptions().size()); |
|||
}); |
|||
} |
|||
|
|||
@Test |
|||
void attemptResponse_hasNoAnswerOrScoringExposed() { |
|||
Long u = createUser(); |
|||
AbilityAttemptResponse resp = attemptService.create(u, createReq("k2"), ctx); |
|||
String json = resp.getQuestions().toString(); |
|||
assertFalse(json.contains("answerKey")); |
|||
assertFalse(json.contains("scoringRule")); |
|||
assertFalse(json.contains("awardedScore")); |
|||
} |
|||
|
|||
@Test |
|||
void idempotentSameKey_returnsSameAttempt() { |
|||
Long u = createUser(); |
|||
AbilityAttemptResponse a = attemptService.create(u, createReq("idem1"), ctx); |
|||
AbilityAttemptResponse b = attemptService.create(u, createReq("idem1"), ctx); |
|||
assertEquals(a.getAttemptId(), b.getAttemptId()); |
|||
} |
|||
|
|||
@Test |
|||
void activeDuplicate_returns9808() { |
|||
Long u = createUser(); |
|||
attemptService.create(u, createReq("act1"), ctx); |
|||
BusinessException ex = assertThrows(BusinessException.class, |
|||
() -> attemptService.create(u, createReq("act2"), ctx)); |
|||
assertEquals(ErrorCode.ABILITY_DUPLICATE_ACTIVE.getCode(), ex.getErrorCodeInt()); |
|||
} |
|||
|
|||
@Test |
|||
void firstSave_startsTimer_subsequentSaveDoesNotExtend() { |
|||
Long u = createUser(); |
|||
AbilityAttemptResponse resp = attemptService.create(u, createReq("t1"), ctx); |
|||
attemptService.saveAnswers(u, resp.getAttemptId(), answers(resp, 0, 1, "B"), ctx); |
|||
var a = attemptRepository.findById(resp.getAttemptId()).get(); |
|||
assertEquals("IN_PROGRESS", a.getStatus()); |
|||
assertEquals(base, a.getStartedAt().withNano(0).withSecond(0)); |
|||
assertEquals(base.plusMinutes(30), a.getExpiresAt().withNano(0).withSecond(0)); |
|||
|
|||
time(base.plusMinutes(5)); |
|||
attemptService.saveAnswers(u, resp.getAttemptId(), answers(resp, 1, 2, "C"), ctx); |
|||
var a2 = attemptRepository.findById(resp.getAttemptId()).get(); |
|||
assertEquals(base.plusMinutes(30), a2.getExpiresAt().withNano(0).withSecond(0), "must not extend"); |
|||
} |
|||
|
|||
@Test |
|||
void saveAnswers_mixedExistingAndNew_shouldUpsert() { |
|||
Long u = createUser(); |
|||
AbilityAttemptResponse resp = attemptService.create(u, createReq("upsert"), ctx); |
|||
// 先答第 0、1 题(option A)
|
|||
attemptService.saveAnswers(u, resp.getAttemptId(), answers(resp, 0, 2, "A"), ctx); |
|||
|
|||
Long aq0 = resp.getQuestions().get(0).getAttemptQuestionId(); |
|||
Long aq2 = resp.getQuestions().get(2).getAttemptQuestionId(); |
|||
Long aq3 = resp.getQuestions().get(3).getAttemptQuestionId(); |
|||
// 混合保存:覆盖第 0 题(改 D) + 新增第 2、3 题(B)
|
|||
SaveAbilityAnswersRequest req = new SaveAbilityAnswersRequest(); |
|||
SaveAbilityAnswersRequest.AnswerItem i0 = new SaveAbilityAnswersRequest.AnswerItem(); |
|||
i0.setAttemptQuestionId(aq0); |
|||
i0.setSelectedOptionId("D"); |
|||
SaveAbilityAnswersRequest.AnswerItem i2 = new SaveAbilityAnswersRequest.AnswerItem(); |
|||
i2.setAttemptQuestionId(aq2); |
|||
i2.setSelectedOptionId("B"); |
|||
SaveAbilityAnswersRequest.AnswerItem i3 = new SaveAbilityAnswersRequest.AnswerItem(); |
|||
i3.setAttemptQuestionId(aq3); |
|||
i3.setSelectedOptionId("B"); |
|||
req.setAnswers(List.of(i0, i2, i3)); |
|||
attemptService.saveAnswers(u, resp.getAttemptId(), req, ctx); |
|||
|
|||
// 持久化断言:1 条覆盖 + 2 条新增,加上最初的第 1 题,共 4 条;覆盖题的选项已更新
|
|||
List<AbilityAnswer> saved = answerRepository.findByAttemptId(resp.getAttemptId()); |
|||
assertEquals(4, saved.size(), "1 overwrite + 2 insert + 1 original"); |
|||
AbilityAnswer ans0 = saved.stream().filter(a -> a.getAttemptQuestionId().equals(aq0)) |
|||
.findFirst().orElseThrow(); |
|||
assertEquals("D", ans0.getSelectedOptionId(), "existing answer must be overwritten"); |
|||
assertTrue(saved.stream().anyMatch(a -> a.getAttemptQuestionId().equals(aq2))); |
|||
assertTrue(saved.stream().anyMatch(a -> a.getAttemptQuestionId().equals(aq3))); |
|||
} |
|||
|
|||
@Test |
|||
void timeout_expiresAttempt_andReleasesActiveKey() { |
|||
Long u = createUser(); |
|||
AbilityAttemptResponse resp = attemptService.create(u, createReq("t2"), ctx); |
|||
attemptService.saveAnswers(u, resp.getAttemptId(), answers(resp, 0, 1, "B"), ctx); |
|||
time(base.plusMinutes(31)); |
|||
AbilityAttemptResponse g = attemptService.get(u, resp.getAttemptId()); |
|||
assertEquals("EXPIRED", g.getStatus()); |
|||
var a = attemptRepository.findById(resp.getAttemptId()).get(); |
|||
assertNull(a.getActiveKey()); |
|||
} |
|||
|
|||
@Test |
|||
void injection_fromAnotherAttempt_isRejected9805() { |
|||
Long u = createUser(); |
|||
AbilityAttemptResponse a = attemptService.create(u, createReq("inj1"), ctx); |
|||
attemptService.saveAnswers(u, a.getAttemptId(), answers(a, 0, 24, "B"), ctx); |
|||
attemptService.submit(u, a.getAttemptId(), ctx); // 完成 a,释放 active
|
|||
try { |
|||
time(base.plusDays(8)); // 让结果过期,允许重测创建 b
|
|||
} catch (Exception ignored) { |
|||
} |
|||
AbilityAttemptResponse b = attemptService.create(u, createReq("inj2"), ctx); |
|||
var req = new SaveAbilityAnswersRequest(); |
|||
SaveAbilityAnswersRequest.AnswerItem item = new SaveAbilityAnswersRequest.AnswerItem(); |
|||
item.setAttemptQuestionId(a.getQuestions().get(0).getAttemptQuestionId()); // a 的第一题
|
|||
item.setSelectedOptionId("A"); |
|||
req.setAnswers(List.of(item)); |
|||
BusinessException ex = assertThrows(BusinessException.class, |
|||
() -> attemptService.saveAnswers(u, b.getAttemptId(), req, ctx)); |
|||
assertEquals(ErrorCode.ABILITY_ANSWER_INVALID.getCode(), ex.getErrorCodeInt()); |
|||
} |
|||
|
|||
@Test |
|||
void invalidOption_isRejected9805_noTimerStart() { |
|||
Long u = createUser(); |
|||
AbilityAttemptResponse resp = attemptService.create(u, createReq("inv1"), ctx); |
|||
var req = new SaveAbilityAnswersRequest(); |
|||
SaveAbilityAnswersRequest.AnswerItem item = new SaveAbilityAnswersRequest.AnswerItem(); |
|||
item.setAttemptQuestionId(resp.getQuestions().get(0).getAttemptQuestionId()); |
|||
item.setSelectedOptionId("Z"); |
|||
req.setAnswers(List.of(item)); |
|||
BusinessException ex = assertThrows(BusinessException.class, |
|||
() -> attemptService.saveAnswers(u, resp.getAttemptId(), req, ctx)); |
|||
assertEquals(ErrorCode.ABILITY_ANSWER_INVALID.getCode(), ex.getErrorCodeInt()); |
|||
var a = attemptRepository.findById(resp.getAttemptId()).get(); |
|||
assertNull(a.getStartedAt(), "invalid save must not start timer"); |
|||
} |
|||
|
|||
@Test |
|||
void submit_23of24_returns9806() { |
|||
Long u = createUser(); |
|||
AbilityAttemptResponse resp = attemptService.create(u, createReq("s1"), ctx); |
|||
attemptService.saveAnswers(u, resp.getAttemptId(), answers(resp, 0, 23, "B"), ctx); |
|||
BusinessException ex = assertThrows(BusinessException.class, |
|||
() -> attemptService.submit(u, resp.getAttemptId(), ctx)); |
|||
assertEquals(ErrorCode.ABILITY_INCOMPLETE.getCode(), ex.getErrorCodeInt()); |
|||
} |
|||
|
|||
// 24/24 提交 → 结果;重复提交幂等;result 唯一;审计一条
|
|||
@Test |
|||
void submit24_thenRepeatIdempotent_singleResultAndAudit() { |
|||
Long u = createUser(); |
|||
AbilityAttemptResponse resp = attemptService.create(u, createReq("s2"), ctx); |
|||
attemptService.saveAnswers(u, resp.getAttemptId(), answers(resp, 0, 24, "B"), ctx); |
|||
var r1 = attemptService.submit(u, resp.getAttemptId(), ctx); |
|||
assertEquals("COMPLETED", r1.getStatus()); |
|||
assertNotNull(r1.getOverallScore()); |
|||
assertEquals(4, r1.getDimensionScores().size()); |
|||
assertEquals(24, r1.getQuestionFeedback().size()); |
|||
|
|||
var r2 = attemptService.submit(u, resp.getAttemptId(), ctx); |
|||
assertEquals(r1.getOverallScore(), r2.getOverallScore()); |
|||
|
|||
assertEquals(1, countResults(resp.getAttemptId())); |
|||
assertEquals(1, countSubmitAudits(resp.getAttemptId())); |
|||
assertNotNull(r1.getImprovementAreas()); |
|||
assertFalse(r1.getImprovementAreas().isEmpty()); |
|||
} |
|||
|
|||
@Test |
|||
void completedResult_triggersRetakeCooldown_9810() { |
|||
Long u = createUser(); |
|||
AbilityAttemptResponse resp = attemptService.create(u, createReq("c1"), ctx); |
|||
attemptService.saveAnswers(u, resp.getAttemptId(), answers(resp, 0, 24, "A"), ctx); |
|||
attemptService.submit(u, resp.getAttemptId(), ctx); |
|||
BusinessException ex = assertThrows(BusinessException.class, |
|||
() -> attemptService.create(u, createReq("c2"), ctx)); |
|||
assertEquals(ErrorCode.ABILITY_RETAKE_COOLDOWN.getCode(), ex.getErrorCodeInt()); |
|||
} |
|||
|
|||
@Test |
|||
void concurrentCreate_onlyOneSuccess_others9808() throws Exception { |
|||
Long u = createUser(); |
|||
int n = 4; |
|||
ExecutorService pool = Executors.newFixedThreadPool(n); |
|||
CountDownLatch start = new CountDownLatch(1); |
|||
CountDownLatch done = new CountDownLatch(n); |
|||
ConcurrentLinkedQueue<Integer> results = new ConcurrentLinkedQueue<>(); |
|||
for (int i = 0; i < n; i++) { |
|||
final String key = "cc-" + u + "-" + i; |
|||
pool.submit(() -> { |
|||
await(start); |
|||
try { |
|||
attemptService.create(u, createReq(key), ctx); |
|||
results.add(0); |
|||
} catch (BusinessException be) { |
|||
results.add(be.getErrorCodeInt()); |
|||
} catch (Exception e) { |
|||
results.add(-1); |
|||
} finally { |
|||
done.countDown(); |
|||
} |
|||
}); |
|||
} |
|||
start.countDown(); |
|||
assertTrue(done.await(30, TimeUnit.SECONDS)); |
|||
pool.shutdown(); |
|||
assertEquals(1, results.stream().filter(c -> c == 0).count(), "exactly one success"); |
|||
assertEquals(n - 1, results.stream().filter(c -> c == ErrorCode.ABILITY_DUPLICATE_ACTIVE.getCode()).count()); |
|||
assertEquals(0, results.stream().filter(c -> c == -1).count(), "no 500"); |
|||
assertTrue(attemptRepository.findByStudentUserIdOrderByCreatedAtDesc(u, |
|||
org.springframework.data.domain.PageRequest.of(0, 50)).getContent().stream() |
|||
.filter(a -> a.getActiveKey() != null).count() == 1, "one active_key"); |
|||
} |
|||
|
|||
@Test |
|||
void concurrentSubmit_singleResultAndAudit_no500() throws Exception { |
|||
Long u = createUser(); |
|||
AbilityAttemptResponse resp = attemptService.create(u, createReq("cs"), ctx); |
|||
attemptService.saveAnswers(u, resp.getAttemptId(), answers(resp, 0, 24, "B"), ctx); |
|||
int n = 2; |
|||
ExecutorService pool = Executors.newFixedThreadPool(n); |
|||
CountDownLatch start = new CountDownLatch(1); |
|||
CountDownLatch done = new CountDownLatch(n); |
|||
ConcurrentLinkedQueue<Integer> codes = new ConcurrentLinkedQueue<>(); |
|||
for (int i = 0; i < n; i++) { |
|||
pool.submit(() -> { |
|||
await(start); |
|||
try { |
|||
attemptService.submit(u, resp.getAttemptId(), ctx); |
|||
codes.add(0); |
|||
} catch (Exception e) { |
|||
codes.add(-1); |
|||
} finally { |
|||
done.countDown(); |
|||
} |
|||
}); |
|||
} |
|||
start.countDown(); |
|||
assertTrue(done.await(30, TimeUnit.SECONDS)); |
|||
pool.shutdown(); |
|||
assertEquals(0, codes.stream().filter(c -> c < 0).count(), "no 500"); |
|||
assertEquals(1, countResults(resp.getAttemptId())); |
|||
assertEquals(1, countSubmitAudits(resp.getAttemptId())); |
|||
} |
|||
|
|||
@Test |
|||
void candidateDimensionScores_unchanged() { |
|||
long before = dimensionScoreRepository.count(); |
|||
Long u = createUser(); |
|||
AbilityAttemptResponse resp = attemptService.create(u, createReq("cds"), ctx); |
|||
attemptService.saveAnswers(u, resp.getAttemptId(), answers(resp, 0, 24, "B"), ctx); |
|||
attemptService.submit(u, resp.getAttemptId(), ctx); |
|||
assertEquals(before, dimensionScoreRepository.count(), "must not write candidate_dimension_scores"); |
|||
} |
|||
|
|||
private void await(CountDownLatch latch) { |
|||
try { |
|||
latch.await(); |
|||
} catch (InterruptedException e) { |
|||
Thread.currentThread().interrupt(); |
|||
} |
|||
} |
|||
|
|||
private long countResults(Long attemptId) { |
|||
return resultRepository.findByAttemptId(attemptId).isPresent() ? 1 : 0; |
|||
} |
|||
|
|||
private long countSubmitAudits(Long attemptId) { |
|||
return auditLogRepository.findAll().stream() |
|||
.filter(l -> "ABILITY_ATTEMPT_SUBMITTED".equals(l.getAction()) |
|||
&& Long.toString(attemptId).equals(String.valueOf(l.getEntityId()))) |
|||
.count(); |
|||
} |
|||
} |
|||
@ -0,0 +1,128 @@ |
|||
package com.mydreamjob.ability; |
|||
|
|||
import com.fasterxml.jackson.databind.JsonNode; |
|||
import com.fasterxml.jackson.databind.ObjectMapper; |
|||
import org.junit.jupiter.api.AfterEach; |
|||
import org.junit.jupiter.api.BeforeEach; |
|||
import org.junit.jupiter.api.Test; |
|||
import org.springframework.beans.factory.annotation.Autowired; |
|||
import org.springframework.boot.test.context.SpringBootTest; |
|||
import org.springframework.boot.test.mock.mockito.MockBean; |
|||
import org.springframework.core.io.ClassPathResource; |
|||
import org.springframework.data.redis.core.RedisTemplate; |
|||
import org.springframework.test.context.ActiveProfiles; |
|||
|
|||
import java.util.HashMap; |
|||
import java.util.HashSet; |
|||
import java.util.List; |
|||
import java.util.Map; |
|||
import java.util.Set; |
|||
|
|||
import static org.junit.jupiter.api.Assertions.*; |
|||
|
|||
/** |
|||
* 题库对账测试(V36):把数据库迁移后的 ability_questions 与权威 JSON 逐题对账, |
|||
* 并校验总数 48 / 四维各 12 / 12 个 competencyTag 各 4(通过各维 3 标签×4)/ |
|||
* 最优答案 A/B/C/D 各 12(四维各 12)/ position 1-48 唯一 / 每题 {0,1,2,4} / answerKey=4 分 / explanation 非空 / |
|||
* questionVersion / source / active。 |
|||
*/ |
|||
@SpringBootTest |
|||
@ActiveProfiles("integration-test") |
|||
class AbilityQuestionBankTest { |
|||
|
|||
@Autowired private AbilityQuestionRepository questionRepository; |
|||
@Autowired private AbilityDefinitionRepository definitionRepository; |
|||
@Autowired private ObjectMapper objectMapper; |
|||
|
|||
@MockBean private RedisTemplate<String, Object> redisTemplate; |
|||
|
|||
private JsonNode bank; |
|||
|
|||
@BeforeEach |
|||
void loadBank() throws Exception { |
|||
bank = objectMapper.readTree(new ClassPathResource("ability-question-bank-v0.1.json").getInputStream()); |
|||
} |
|||
|
|||
@Test |
|||
void bankReconciledWithDatabase_andDistributionsHold() throws Exception { |
|||
Long defId = definitionRepository.findByTypeCode("WORKPLACE_ABILITY").orElseThrow().getId(); |
|||
List<AbilityQuestion> rows = questionRepository.findAllByDefinitionIdOrderByPositionAsc(defId); |
|||
assertEquals(48, rows.size(), "total questions"); |
|||
|
|||
// 权威 JSON
|
|||
JsonNode qs = bank.path("questions"); |
|||
assertEquals(48, qs.size()); |
|||
|
|||
// DB 侧分布
|
|||
Map<String, Integer> dimCount = new HashMap<>(); |
|||
Map<String, Integer> tagCount = new HashMap<>(); |
|||
Map<String, Integer> bestCount = new HashMap<>(); |
|||
Set<Integer> positions = new HashSet<>(); |
|||
Map<Integer, AbilityQuestion> byPos = new HashMap<>(); |
|||
for (AbilityQuestion q : rows) { |
|||
dimCount.merge(q.getDimension(), 1, Integer::sum); |
|||
tagCount.merge(q.getCompetencyTag(), 1, Integer::sum); |
|||
positions.add(q.getPosition()); |
|||
byPos.put(q.getPosition(), q); |
|||
JsonNode rule = objectMapper.readTree(q.getScoringRuleJson()); |
|||
for (String opt : List.of("A", "B", "C", "D")) { |
|||
rule.get(opt); |
|||
} |
|||
bestCount.merge(objectMapper.readTree(q.getAnswerKeyJson()).path("answerKey").asText(), 1, Integer::sum); |
|||
} |
|||
for (String d : List.of("LOGIC", "COMMUNICATION", "EXECUTION", "LEARNING")) { |
|||
assertEquals(12, dimCount.getOrDefault(d, 0), "dim " + d); |
|||
} |
|||
assertEquals(12, tagCount.size(), "competencyTag count"); |
|||
tagCount.values().forEach(c -> assertEquals(4, c)); |
|||
for (String l : List.of("A", "B", "C", "D")) { |
|||
assertEquals(12, bestCount.getOrDefault(l, 0), "best " + l); |
|||
} |
|||
assertEquals(48, positions.size(), "positions unique"); |
|||
assertEquals(48, positions.stream().mapToInt(Integer::intValue).max().orElse(0), "max position"); |
|||
|
|||
// 逐题对账
|
|||
for (JsonNode aqJson : qs) { |
|||
int pos = aqJson.path("position").asInt(); |
|||
AbilityQuestion db = byPos.get(pos); |
|||
assertNotNull(db, "missing position " + pos); |
|||
assertEquals(aqJson.path("questionCode").asText(), db.getQuestionCode()); |
|||
assertEquals(aqJson.path("dimension").asText(), db.getDimension()); |
|||
assertEquals(aqJson.path("competencyTag").asText(), db.getCompetencyTag()); |
|||
assertEquals(aqJson.path("itemType").asText(), db.getItemType()); |
|||
assertEquals(aqJson.path("stem").asText(), db.getStem()); |
|||
assertEquals(aqJson.path("difficulty").asText(), db.getDifficulty()); |
|||
assertEquals("ability-v0.1", db.getQuestionVersion()); |
|||
assertEquals("SELF_AUTHORED", db.getSource()); |
|||
assertTrue(db.getActive()); |
|||
assertTrue(db.getExplanation() != null && !db.getExplanation().isBlank()); |
|||
|
|||
// options 逐项一致
|
|||
JsonNode optJson = aqJson.path("options"); |
|||
JsonNode optDb = objectMapper.readTree(db.getOptionsJson()); |
|||
assertEquals(optJson.size(), optDb.size(), pos + " options size"); |
|||
for (int i = 0; i < optJson.size(); i++) { |
|||
assertEquals(optJson.get(i).path("optionId").asText(), optDb.get(i).path("optionId").asText()); |
|||
assertEquals(optJson.get(i).path("text").asText(), optDb.get(i).path("text").asText()); |
|||
} |
|||
// scoringRule 与 answerKey 一致
|
|||
JsonNode ruleJson = aqJson.path("scoringRule"); |
|||
JsonNode ruleDb = objectMapper.readTree(db.getScoringRuleJson()); |
|||
Set<Integer> scoreSet = new HashSet<>(); |
|||
for (String opt : List.of("A", "B", "C", "D")) { |
|||
assertEquals(ruleJson.get(opt).asInt(), ruleDb.get(opt).asInt(), pos + " score " + opt); |
|||
scoreSet.add(ruleDb.get(opt).asInt()); |
|||
} |
|||
assertEquals(Set.of(0, 1, 2, 4), scoreSet, pos + " scoring set"); |
|||
assertEquals(aqJson.path("answerKey").asText(), |
|||
objectMapper.readTree(db.getAnswerKeyJson()).path("answerKey").asText()); |
|||
// answerKey 对应的分值必须为 4
|
|||
assertEquals(4, ruleDb.get(aqJson.path("answerKey").asText()).asInt(), pos + " answerKey not 4"); |
|||
assertEquals(aqJson.path("explanation").asText(), db.getExplanation()); |
|||
} |
|||
} |
|||
|
|||
@AfterEach |
|||
void reset() { |
|||
} |
|||
} |
|||
@ -0,0 +1,111 @@ |
|||
package com.mydreamjob.ability; |
|||
|
|||
import com.mydreamjob.ability.dto.AbilityResultResponse; |
|||
import org.junit.jupiter.api.Test; |
|||
|
|||
import java.math.BigDecimal; |
|||
import java.util.ArrayList; |
|||
import java.util.List; |
|||
|
|||
import static org.junit.jupiter.api.Assertions.*; |
|||
|
|||
class AbilityResultComputerTest { |
|||
|
|||
private final AbilityResultComputer computer = new AbilityResultComputer(); |
|||
|
|||
private List<AbilityResultComputer.Answered> items(int[] perDimScore, String[] dims, |
|||
String[][] tags) { |
|||
List<AbilityResultComputer.Answered> out = new ArrayList<>(); |
|||
long aid = 0; |
|||
for (int d = 0; d < dims.length; d++) { |
|||
int[] scores = java.util.Arrays.copyOf(perDimScore, 6); |
|||
for (int i = 0; i < 6; i++) { |
|||
out.add(new AbilityResultComputer.Answered(dims[d], tags[d][i % tags[d].length], |
|||
"stem-" + dims[d] + i, "exp-" + dims[d] + i, scores[i], ++aid, d * 6 + i + 1, "B")); |
|||
} |
|||
} |
|||
return out; |
|||
} |
|||
|
|||
@Test |
|||
void computesDimensionsOverallWeaknessImprovementRadarFeedback() { |
|||
int[] log = {4, 4, 4, 4, 4, 4}; // 100
|
|||
int[] com = {2, 2, 2, 2, 2, 2}; // 50
|
|||
int[] exe = {1, 1, 1, 1, 1, 1}; // 25
|
|||
int[] lea = {0, 0, 0, 0, 0, 0}; // 0
|
|||
String[] dims = {"LOGIC", "COMMUNICATION", "EXECUTION", "LEARNING"}; |
|||
String[][] tags = { |
|||
{"INFORMATION_ANALYSIS", "CAUSAL_REASONING", "PROBLEM_SOLVING"}, |
|||
{"LISTENING_UNDERSTANDING", "STRUCTURED_EXPRESSION", "COLLABORATION_CONFLICT"}, |
|||
{"PLANNING_PRIORITIZATION", "RISK_FOLLOWUP", "DELIVERY_REVIEW"}, |
|||
{"INFORMATION_ACQUISITION", "FEEDBACK_REFLECTION", "TRANSFER_ADAPTATION"}}; |
|||
List<AbilityResultComputer.Answered> list = new ArrayList<>(); |
|||
list.addAll(items(log, dims, tags)); // default perDimScore arrays reused? recompute below
|
|||
list = new ArrayList<>(); |
|||
int row = 0; |
|||
List<AbilityResultComputer.Answered> built = new ArrayList<>(); |
|||
for (int d = 0; d < 4; d++) { |
|||
int base = switch (d) { |
|||
case 0 -> 4; |
|||
case 1 -> 2; |
|||
case 2 -> 1; |
|||
default -> 0; |
|||
}; |
|||
for (int i = 0; i < 6; i++) { |
|||
built.add(new AbilityResultComputer.Answered(dims[d], tags[d][i % 3], |
|||
"s" + row, "e" + row, base, ++row, row, "A")); |
|||
} |
|||
} |
|||
var computed = computer.compute(built, 6); |
|||
|
|||
assertEquals(4, computed.dimensionScores().size()); |
|||
assertEquals(new BigDecimal("100.00"), score(computed, "LOGIC")); |
|||
assertEquals(new BigDecimal("50.00"), score(computed, "COMMUNICATION")); |
|||
assertEquals(new BigDecimal("25.00"), score(computed, "EXECUTION")); |
|||
assertEquals(new BigDecimal("0.00"), score(computed, "LEARNING")); |
|||
assertEquals(new BigDecimal("43.75"), computed.overallScore()); |
|||
|
|||
assertEquals(1, computed.weaknesses().size()); |
|||
assertEquals("LEARNING", computed.weaknesses().get(0).getCode()); |
|||
assertEquals(new BigDecimal("0.00"), computed.weaknesses().get(0).getScore()); |
|||
|
|||
assertEquals(1, computed.improvementAreas().size()); |
|||
assertEquals("LEARNING", computed.improvementAreas().get(0).getDimension()); |
|||
// competencyTag 字段已从 DTO 删除;仅保留 dimension/currentScore/summary/actions
|
|||
assertNotNull(computed.improvementAreas().get(0).getSummary()); |
|||
assertFalse(computed.improvementAreas().get(0).getActions().isEmpty()); |
|||
|
|||
assertEquals(4, computed.radar().size()); |
|||
assertEquals(24, computed.questionFeedback().size()); |
|||
assertEquals(4, computed.questionFeedback().get(0).getAwardedScore()); |
|||
assertNotNull(computed.questionFeedback().get(0).getExplanation()); |
|||
} |
|||
|
|||
@Test |
|||
void weaknessReturnsAllTiedDimensions() { |
|||
List<AbilityResultComputer.Answered> built = new ArrayList<>(); |
|||
int row = 0; |
|||
String[] dims = {"LOGIC", "COMMUNICATION", "EXECUTION", "LEARNING"}; |
|||
String[][] tags = { |
|||
{"INFORMATION_ANALYSIS"}, {"LISTENING_UNDERSTANDING"}, |
|||
{"PLANNING_PRIORITIZATION"}, {"INFORMATION_ACQUISITION"}}; |
|||
// LOGIC 与 COMMUNICATION 并列最低(全 0)
|
|||
for (String d : dims) { |
|||
int base = (d.equals("EXECUTION") || d.equals("LEARNING")) ? 4 : 0; |
|||
for (int i = 0; i < 6; i++) { |
|||
built.add(new AbilityResultComputer.Answered(d, tags[0][0] + (d.equals("LOGIC") ? 0 : 1), |
|||
"s" + row, "e" + row, base, ++row, row, "A")); |
|||
} |
|||
} |
|||
var c = computer.compute(built, 6); |
|||
assertEquals(2, c.weaknesses().size()); |
|||
assertEquals(new BigDecimal("0.00"), c.weaknesses().get(0).getScore()); |
|||
assertTrue(c.weaknesses().stream().anyMatch(w -> w.getCode().equals("LOGIC"))); |
|||
assertTrue(c.weaknesses().stream().anyMatch(w -> w.getCode().equals("COMMUNICATION"))); |
|||
} |
|||
|
|||
private BigDecimal score(AbilityResultComputer.ComputedResult c, String code) { |
|||
return c.dimensionScores().stream().filter(d -> d.getCode().equals(code)) |
|||
.findFirst().map(AbilityResultResponse.DimensionScore::getScore).orElseThrow(); |
|||
} |
|||
} |
|||
@ -0,0 +1,62 @@ |
|||
package com.mydreamjob.ability; |
|||
|
|||
import org.hibernate.annotations.JdbcTypeCode; |
|||
import org.hibernate.type.SqlTypes; |
|||
import org.junit.jupiter.api.Test; |
|||
import org.springframework.beans.factory.annotation.Autowired; |
|||
import org.springframework.boot.test.context.SpringBootTest; |
|||
import org.springframework.boot.test.mock.mockito.MockBean; |
|||
import org.springframework.data.redis.core.RedisTemplate; |
|||
import org.springframework.jdbc.core.JdbcTemplate; |
|||
import org.springframework.test.context.ActiveProfiles; |
|||
|
|||
import java.lang.reflect.Field; |
|||
import java.util.HashSet; |
|||
import java.util.List; |
|||
import java.util.Map; |
|||
import java.util.Set; |
|||
|
|||
import static org.junit.jupiter.api.Assertions.*; |
|||
|
|||
/** |
|||
* V36 启动/schema 校验冒烟测试:ability_questions.active 为 TINYINT(非 BIT), |
|||
* 实体 Boolean 以 @JdbcTypeCode(TINYINT) 映射,避免与迁移类型冲突;六张表 + 迁移(48 题)可加载。 |
|||
*/ |
|||
@SpringBootTest |
|||
@ActiveProfiles("integration-test") |
|||
class AbilitySchemaValidationSmokeTest { |
|||
|
|||
@Autowired private JdbcTemplate jdbcTemplate; |
|||
|
|||
@MockBean private RedisTemplate<String, Object> redisTemplate; |
|||
|
|||
@Test |
|||
void contextBoots_andAbilityColumnsAreTinyInt() { |
|||
List<Map<String, Object>> cols = jdbcTemplate.queryForList( |
|||
"SELECT column_name, data_type, is_nullable FROM information_schema.columns " |
|||
+ "WHERE LOWER(table_name) = 'ability_questions' AND LOWER(column_name) = 'active'"); |
|||
assertEquals(1, cols.size()); |
|||
String type = String.valueOf(cols.get(0).get("DATA_TYPE")).toUpperCase(); |
|||
assertTrue(type.contains("INT"), "ability_questions.active must be TINYINT, got " + type); |
|||
assertEquals("NO", String.valueOf(cols.get(0).get("IS_NULLABLE")).toUpperCase()); |
|||
|
|||
// 实体映射为 TINYINT
|
|||
assertEquals(SqlTypes.TINYINT, fieldJdbcType("active")); |
|||
assertTrue(questionCount() == 48, "ability_questions must seed 48"); |
|||
} |
|||
|
|||
private Long questionCount() { |
|||
return jdbcTemplate.queryForObject("SELECT COUNT(*) FROM ability_questions", Long.class); |
|||
} |
|||
|
|||
private int fieldJdbcType(String name) { |
|||
try { |
|||
Field f = AbilityQuestion.class.getDeclaredField(name); |
|||
JdbcTypeCode code = f.getAnnotation(JdbcTypeCode.class); |
|||
assertNotNull(code, name + " must carry @JdbcTypeCode"); |
|||
return code.value(); |
|||
} catch (NoSuchFieldException e) { |
|||
throw new AssertionError(e); |
|||
} |
|||
} |
|||
} |
|||
@ -0,0 +1,288 @@ |
|||
package com.mydreamjob.assessment; |
|||
|
|||
import com.mydreamjob.assessment.dto.AssessmentResultResponse; |
|||
import com.mydreamjob.assessment.dto.CreateAssessmentAttemptRequest; |
|||
import com.mydreamjob.assessment.dto.SaveAssessmentAnswersRequest; |
|||
import com.mydreamjob.audit.AuditContext; |
|||
import com.mydreamjob.audit.AuditService; |
|||
import com.mydreamjob.auth.User; |
|||
import com.mydreamjob.auth.UserRepository; |
|||
import com.mydreamjob.common.exception.BusinessException; |
|||
import com.mydreamjob.common.exception.ErrorCode; |
|||
import org.junit.jupiter.api.BeforeEach; |
|||
import org.junit.jupiter.api.Test; |
|||
import org.junit.jupiter.api.extension.ExtendWith; |
|||
import org.mockito.InjectMocks; |
|||
import org.mockito.Mock; |
|||
import org.mockito.junit.jupiter.MockitoExtension; |
|||
import org.mockito.junit.jupiter.MockitoSettings; |
|||
import org.mockito.quality.Strictness; |
|||
import org.springframework.dao.OptimisticLockingFailureException; |
|||
|
|||
import java.util.ArrayList; |
|||
import java.util.LinkedHashMap; |
|||
import java.util.List; |
|||
import java.util.Map; |
|||
import java.util.Optional; |
|||
import java.util.Set; |
|||
|
|||
import static org.junit.jupiter.api.Assertions.*; |
|||
import static org.mockito.ArgumentMatchers.*; |
|||
import static org.mockito.Mockito.*; |
|||
|
|||
/** |
|||
* 答卷服务单测(V34/V35)。 |
|||
* |
|||
* <p>覆盖身份校验、活跃答卷重复 9708、幂等冲突 9709、答案不属于本答卷 9705、 |
|||
* 版本并发 9707、29 题提交失败 9706、重复提交幂等返回原结果。</p> |
|||
*/ |
|||
@ExtendWith(MockitoExtension.class) |
|||
@MockitoSettings(strictness = Strictness.LENIENT) |
|||
class AssessmentAttemptServiceTest { |
|||
|
|||
private static final Long STUDENT = 100L; |
|||
private static final Long COMPANY = 200L; |
|||
private static final Long ATTEMPT_ID = 1L; |
|||
|
|||
@Mock private AssessmentDefinitionRepository definitionRepository; |
|||
@Mock private AssessmentQuestionRepository questionRepository; |
|||
@Mock private AssessmentAttemptRepository attemptRepository; |
|||
@Mock private AssessmentAttemptQuestionRepository attemptQuestionRepository; |
|||
@Mock private AssessmentAnswerRepository answerRepository; |
|||
@Mock private AssessmentResultRepository resultRepository; |
|||
@Mock private AssessmentResultService resultService; |
|||
@Mock private AssessmentResultComputer resultComputer; |
|||
@Mock private UserRepository userRepository; |
|||
@Mock private AuditService auditService; |
|||
|
|||
@InjectMocks private AssessmentAttemptService service; |
|||
|
|||
private final AuditContext ctx = AuditContext.builder().requestId("req").build(); |
|||
|
|||
@BeforeEach |
|||
void setUp() { |
|||
when(attemptQuestionRepository.findByAttemptIdOrderByPositionAsc(anyLong())).thenReturn(List.of()); |
|||
when(answerRepository.findByAttemptId(anyLong())).thenReturn(List.of()); |
|||
} |
|||
|
|||
private User student() { |
|||
User u = User.builder().id(STUDENT).phone("1").role(User.Role.STUDENT).build(); |
|||
u.setRoles(Set.of(User.Role.STUDENT)); |
|||
return u; |
|||
} |
|||
|
|||
private User company() { |
|||
User u = User.builder().id(COMPANY).phone("2").role(User.Role.COMPANY).build(); |
|||
u.setRoles(Set.of(User.Role.COMPANY)); |
|||
return u; |
|||
} |
|||
|
|||
private AssessmentDefinition definition() { |
|||
return AssessmentDefinition.builder().id(10L).typeCode("HOLLAND_RIASEC").status("ACTIVE") |
|||
.questionCount(72).attemptQuestionCount(30).minPerDimension(5) |
|||
.questionSetVersion("riasec-self-v0.2").rulesVersion("riasec-scoring-v0.1").build(); |
|||
} |
|||
|
|||
private AssessmentAttempt attempt(String status) { |
|||
return AssessmentAttempt.builder().id(ATTEMPT_ID).studentUserId(STUDENT).definitionId(10L) |
|||
.status(status).selectedQuestionCount(30).questionSetVersion("riasec-self-v0.2") |
|||
.version(0L).answeredCount(0).build(); |
|||
} |
|||
|
|||
@Test |
|||
void companyUser_create_returnsForbidden() { |
|||
when(userRepository.findById(COMPANY)).thenReturn(Optional.of(company())); |
|||
BusinessException ex = assertThrows(BusinessException.class, |
|||
() -> service.create(COMPANY, CreateAssessmentAttemptRequest.builder() |
|||
.typeCode("HOLLAND_RIASEC").build(), ctx)); |
|||
assertEquals(ErrorCode.FORBIDDEN.getCode(), ex.getErrorCodeInt()); |
|||
} |
|||
|
|||
@Test |
|||
void create_duplicateActive_returns9708() { |
|||
when(userRepository.findById(STUDENT)).thenReturn(Optional.of(student())); |
|||
when(definitionRepository.findByTypeCode("HOLLAND_RIASEC")).thenReturn(Optional.of(definition())); |
|||
when(attemptRepository.existsByStudentUserIdAndDefinitionIdAndStatusIn(anyLong(), anyLong(), anyList())) |
|||
.thenReturn(true); |
|||
|
|||
BusinessException ex = assertThrows(BusinessException.class, |
|||
() -> service.create(STUDENT, CreateAssessmentAttemptRequest.builder() |
|||
.typeCode("HOLLAND_RIASEC").build(), ctx)); |
|||
assertEquals(ErrorCode.ASSESSMENT_DUPLICATE_ACTIVE.getCode(), ex.getErrorCodeInt()); |
|||
} |
|||
|
|||
@Test |
|||
void create_sameKeyDifferentDefinition_returns9709() { |
|||
when(userRepository.findById(STUDENT)).thenReturn(Optional.of(student())); |
|||
when(definitionRepository.findByTypeCode("HOLLAND_RIASEC")).thenReturn(Optional.of(definition())); |
|||
AssessmentAttempt existing = attempt("DRAFT"); |
|||
existing.setId(99L); |
|||
existing.setDefinitionId(999L); // 定义不同 → 冲突
|
|||
when(attemptRepository.findByStudentUserIdAndIdempotencyKey(STUDENT, "k")).thenReturn(Optional.of(existing)); |
|||
|
|||
BusinessException ex = assertThrows(BusinessException.class, |
|||
() -> service.create(STUDENT, CreateAssessmentAttemptRequest.builder() |
|||
.typeCode("HOLLAND_RIASEC").idempotencyKey("k").build(), ctx)); |
|||
assertEquals(ErrorCode.ASSESSMENT_IDEMPOTENCY_CONFLICT.getCode(), ex.getErrorCodeInt()); |
|||
} |
|||
|
|||
@Test |
|||
void create_sameKeySameDefinition_returnsOriginalAttempt() { |
|||
when(userRepository.findById(STUDENT)).thenReturn(Optional.of(student())); |
|||
when(definitionRepository.findByTypeCode("HOLLAND_RIASEC")).thenReturn(Optional.of(definition())); |
|||
AssessmentAttempt existing = attempt("DRAFT"); |
|||
existing.setId(99L); |
|||
when(attemptRepository.findByStudentUserIdAndIdempotencyKey(STUDENT, "k")).thenReturn(Optional.of(existing)); |
|||
|
|||
var resp = service.create(STUDENT, CreateAssessmentAttemptRequest.builder() |
|||
.typeCode("HOLLAND_RIASEC").idempotencyKey("k").build(), ctx); |
|||
assertEquals(99L, resp.getAttemptId()); |
|||
verify(attemptRepository, never()).saveAndFlush(any()); |
|||
} |
|||
|
|||
@Test |
|||
void saveAnswers_questionNotOnAttempt_returns9705() { |
|||
when(userRepository.findById(STUDENT)).thenReturn(Optional.of(student())); |
|||
when(attemptRepository.findById(ATTEMPT_ID)).thenReturn(Optional.of(attempt("DRAFT"))); |
|||
when(attemptQuestionRepository.existsByAttemptIdAndQuestionId(ATTEMPT_ID, 999L)).thenReturn(false); |
|||
|
|||
SaveAssessmentAnswersRequest req = SaveAssessmentAnswersRequest.builder() |
|||
.version(0L) |
|||
.answers(List.of(SaveAssessmentAnswersRequest.AnswerItem.builder() |
|||
.questionId(999L).value(4).build())) |
|||
.build(); |
|||
BusinessException ex = assertThrows(BusinessException.class, |
|||
() -> service.saveAnswers(STUDENT, ATTEMPT_ID, req, ctx)); |
|||
assertEquals(ErrorCode.ASSESSMENT_ANSWER_INVALID.getCode(), ex.getErrorCodeInt()); |
|||
} |
|||
|
|||
@Test |
|||
void saveAnswers_versionMismatch_returns9707() { |
|||
when(userRepository.findById(STUDENT)).thenReturn(Optional.of(student())); |
|||
AssessmentAttempt a = attempt("DRAFT"); |
|||
a.setVersion(5L); |
|||
when(attemptRepository.findById(ATTEMPT_ID)).thenReturn(Optional.of(a)); |
|||
|
|||
SaveAssessmentAnswersRequest req = SaveAssessmentAnswersRequest.builder() |
|||
.version(4L) |
|||
.answers(List.of(SaveAssessmentAnswersRequest.AnswerItem.builder() |
|||
.questionId(301L).value(4).build())) |
|||
.build(); |
|||
BusinessException ex = assertThrows(BusinessException.class, |
|||
() -> service.saveAnswers(STUDENT, ATTEMPT_ID, req, ctx)); |
|||
assertEquals(ErrorCode.ASSESSMENT_CONCURRENT_UPDATE.getCode(), ex.getErrorCodeInt()); |
|||
} |
|||
|
|||
@Test |
|||
void saveAnswers_optimisticLockConflict_returns9707() { |
|||
when(userRepository.findById(STUDENT)).thenReturn(Optional.of(student())); |
|||
when(attemptRepository.findById(ATTEMPT_ID)).thenReturn(Optional.of(attempt("DRAFT"))); |
|||
when(attemptQuestionRepository.existsByAttemptIdAndQuestionId(ATTEMPT_ID, 301L)).thenReturn(true); |
|||
when(answerRepository.findByAttemptIdAndQuestionId(ATTEMPT_ID, 301L)).thenReturn(Optional.empty()); |
|||
when(answerRepository.countByAttemptId(ATTEMPT_ID)).thenReturn(1L); |
|||
doThrow(new OptimisticLockingFailureException("stale")) |
|||
.when(attemptRepository).saveAndFlush(any()); |
|||
|
|||
SaveAssessmentAnswersRequest req = SaveAssessmentAnswersRequest.builder() |
|||
.version(0L) |
|||
.answers(List.of(SaveAssessmentAnswersRequest.AnswerItem.builder() |
|||
.questionId(301L).value(4).build())) |
|||
.build(); |
|||
BusinessException ex = assertThrows(BusinessException.class, |
|||
() -> service.saveAnswers(STUDENT, ATTEMPT_ID, req, ctx)); |
|||
assertEquals(ErrorCode.ASSESSMENT_CONCURRENT_UPDATE.getCode(), ex.getErrorCodeInt()); |
|||
} |
|||
|
|||
@Test |
|||
void submit_incomplete_returns9706() { |
|||
when(userRepository.findById(STUDENT)).thenReturn(Optional.of(student())); |
|||
when(attemptRepository.findById(ATTEMPT_ID)).thenReturn(Optional.of(attempt("IN_PROGRESS"))); |
|||
when(answerRepository.countByAttemptId(ATTEMPT_ID)).thenReturn(29L); |
|||
|
|||
BusinessException ex = assertThrows(BusinessException.class, |
|||
() -> service.submit(STUDENT, ATTEMPT_ID, ctx)); |
|||
assertEquals(ErrorCode.ASSESSMENT_INCOMPLETE.getCode(), ex.getErrorCodeInt()); |
|||
} |
|||
|
|||
@Test |
|||
void submit_completed_idempotentReturnsExistingResult() { |
|||
when(userRepository.findById(STUDENT)).thenReturn(Optional.of(student())); |
|||
AssessmentAttempt a = attempt("COMPLETED"); |
|||
a.setSubmittedAt(java.time.LocalDateTime.now()); |
|||
when(attemptRepository.findById(ATTEMPT_ID)).thenReturn(Optional.of(a)); |
|||
AssessmentResult r = AssessmentResult.builder().attemptId(ATTEMPT_ID).primaryCode("SIC") |
|||
.primaryType("S").answeredCount(30).build(); |
|||
when(resultRepository.findByAttemptId(ATTEMPT_ID)).thenReturn(Optional.of(r)); |
|||
AssessmentResultResponse resp = AssessmentResultResponse.builder().attemptId(ATTEMPT_ID) |
|||
.primaryCode("SIC").build(); |
|||
when(resultService.toResponse(a, r)).thenReturn(resp); |
|||
|
|||
var out = service.submit(STUDENT, ATTEMPT_ID, ctx); |
|||
|
|||
assertEquals("SIC", out.getPrimaryCode()); |
|||
verify(resultService).toResponse(a, r); |
|||
verify(auditService, never()).log(anyLong(), eq("ASSESSMENT_ATTEMPT_SUBMITTED"), |
|||
anyString(), anyLong(), anyString(), any()); |
|||
} |
|||
|
|||
@Test |
|||
void adminUser_create_returnsForbidden() { |
|||
User u = User.builder().id(999L).phone("3").role(User.Role.ADMIN).build(); |
|||
u.setRoles(Set.of(User.Role.ADMIN)); |
|||
when(userRepository.findById(999L)).thenReturn(Optional.of(u)); |
|||
BusinessException ex = assertThrows(BusinessException.class, |
|||
() -> service.create(999L, CreateAssessmentAttemptRequest.builder() |
|||
.typeCode("HOLLAND_RIASEC").build(), ctx)); |
|||
assertEquals(ErrorCode.FORBIDDEN.getCode(), ex.getErrorCodeInt()); |
|||
} |
|||
|
|||
@Test |
|||
void create_inactiveType_returns9701() { |
|||
when(userRepository.findById(STUDENT)).thenReturn(Optional.of(student())); |
|||
when(definitionRepository.findByTypeCode("HOLLAND_RIASEC")).thenReturn(Optional.of( |
|||
AssessmentDefinition.builder().id(10L).typeCode("HOLLAND_RIASEC").status("INACTIVE").build())); |
|||
BusinessException ex = assertThrows(BusinessException.class, |
|||
() -> service.create(STUDENT, CreateAssessmentAttemptRequest.builder() |
|||
.typeCode("HOLLAND_RIASEC").build(), ctx)); |
|||
assertEquals(ErrorCode.ASSESSMENT_TYPE_NOT_FOUND.getCode(), ex.getErrorCodeInt()); |
|||
} |
|||
|
|||
@Test |
|||
void create_insufficientBankOnADimension_rejects() { |
|||
when(userRepository.findById(STUDENT)).thenReturn(Optional.of(student())); |
|||
when(definitionRepository.findByTypeCode("HOLLAND_RIASEC")).thenReturn(Optional.of(definition())); |
|||
when(attemptRepository.existsByStudentUserIdAndDefinitionIdAndStatusIn(anyLong(), anyLong(), anyList())) |
|||
.thenReturn(false); |
|||
// 六维齐全但 R 维度仅 3 道(<5),不能静默减题
|
|||
List<AssessmentQuestion> bank = new ArrayList<>(); |
|||
Map<String, Integer> perDim = new LinkedHashMap<>( |
|||
Map.of("R", 3, "I", 5, "A", 5, "S", 5, "E", 5, "C", 5)); |
|||
long seq = 1; |
|||
for (Map.Entry<String, Integer> e : perDim.entrySet()) { |
|||
for (int i = 0; i < e.getValue(); i++) { |
|||
bank.add(AssessmentQuestion.builder().id(seq++).dimension(e.getKey()).build()); |
|||
} |
|||
} |
|||
when(questionRepository.findByDefinitionIdAndActiveTrueOrderByPositionAsc(10L)).thenReturn(bank); |
|||
|
|||
BusinessException ex = assertThrows(BusinessException.class, |
|||
() -> service.create(STUDENT, CreateAssessmentAttemptRequest.builder() |
|||
.typeCode("HOLLAND_RIASEC").build(), ctx)); |
|||
assertEquals(ErrorCode.BAD_REQUEST.getCode(), ex.getErrorCodeInt()); |
|||
} |
|||
|
|||
@Test |
|||
void saveAnswers_duplicateQuestionIdInSameRequest_returns9705() { |
|||
when(userRepository.findById(STUDENT)).thenReturn(Optional.of(student())); |
|||
when(attemptRepository.findById(ATTEMPT_ID)).thenReturn(Optional.of(attempt("DRAFT"))); |
|||
SaveAssessmentAnswersRequest req = SaveAssessmentAnswersRequest.builder() |
|||
.version(0L) |
|||
.answers(List.of( |
|||
SaveAssessmentAnswersRequest.AnswerItem.builder().questionId(301L).value(4).build(), |
|||
SaveAssessmentAnswersRequest.AnswerItem.builder().questionId(301L).value(5).build())) |
|||
.build(); |
|||
BusinessException ex = assertThrows(BusinessException.class, |
|||
() -> service.saveAnswers(STUDENT, ATTEMPT_ID, req, ctx)); |
|||
assertEquals(ErrorCode.ASSESSMENT_ANSWER_INVALID.getCode(), ex.getErrorCodeInt()); |
|||
} |
|||
} |
|||
@ -0,0 +1,99 @@ |
|||
package com.mydreamjob.assessment; |
|||
|
|||
import com.mydreamjob.assessment.dto.AssessmentQuestionResponse; |
|||
import com.mydreamjob.assessment.dto.AssessmentTypeResponse; |
|||
import com.mydreamjob.common.exception.BusinessException; |
|||
import com.mydreamjob.common.exception.ErrorCode; |
|||
import org.junit.jupiter.api.Test; |
|||
import org.junit.jupiter.api.extension.ExtendWith; |
|||
import org.mockito.InjectMocks; |
|||
import org.mockito.Mock; |
|||
import org.mockito.junit.jupiter.MockitoExtension; |
|||
import org.mockito.junit.jupiter.MockitoSettings; |
|||
import org.mockito.quality.Strictness; |
|||
|
|||
import java.util.List; |
|||
import java.util.Optional; |
|||
|
|||
import static org.junit.jupiter.api.Assertions.*; |
|||
import static org.mockito.Mockito.when; |
|||
|
|||
/** |
|||
* 测评目录服务单测(V34/V35)。 |
|||
* |
|||
* <p>覆盖类型列表(仅 ACTIVE)、题目列表隐藏 dimension/reverseScored/source/questionVersion、 |
|||
* 未知类型 404/9701。</p> |
|||
*/ |
|||
@ExtendWith(MockitoExtension.class) |
|||
@MockitoSettings(strictness = Strictness.LENIENT) |
|||
class AssessmentCatalogServiceTest { |
|||
|
|||
@Mock private AssessmentDefinitionRepository definitionRepository; |
|||
@Mock private AssessmentQuestionRepository questionRepository; |
|||
|
|||
@InjectMocks private AssessmentCatalogService service; |
|||
|
|||
@Test |
|||
void listTypes_returnsOnlyActive() { |
|||
AssessmentDefinition active = AssessmentDefinition.builder().id(1L).typeCode("HOLLAND_RIASEC") |
|||
.displayName("RIASEC").status("ACTIVE").questionCount(72).attemptQuestionCount(30) |
|||
.minPerDimension(5).scaleType("LIKERT_5") |
|||
.questionSetVersion("riasec-self-v0.2").rulesVersion("riasec-scoring-v0.1").build(); |
|||
AssessmentDefinition inactive = AssessmentDefinition.builder().id(2L).typeCode("OTHER") |
|||
.status("DISABLED").build(); |
|||
when(definitionRepository.findAll()).thenReturn(List.of(active, inactive)); |
|||
|
|||
List<AssessmentTypeResponse> types = service.listTypes(); |
|||
|
|||
assertEquals(1, types.size()); |
|||
assertEquals("HOLLAND_RIASEC", types.get(0).getTypeCode()); |
|||
assertEquals(30, types.get(0).getAttemptQuestionCount()); |
|||
} |
|||
|
|||
@Test |
|||
void listQuestions_hidesInternalFields_andMapsPublicOnes() { |
|||
AssessmentDefinition def = AssessmentDefinition.builder().id(1L).typeCode("HOLLAND_RIASEC") |
|||
.status("ACTIVE").build(); |
|||
AssessmentQuestion q = AssessmentQuestion.builder().id(301L).questionCode("R01") |
|||
.position(1).dimension("R").stem("我对使用手工工具制作物品感兴趣。") |
|||
.scaleType("LIKERT_5").build(); |
|||
when(definitionRepository.findByTypeCode("HOLLAND_RIASEC")).thenReturn(Optional.of(def)); |
|||
when(questionRepository.findByDefinitionIdAndActiveTrueOrderByPositionAsc(1L)).thenReturn(List.of(q)); |
|||
|
|||
List<AssessmentQuestionResponse> items = service.listQuestions("HOLLAND_RIASEC"); |
|||
|
|||
assertEquals(1, items.size()); |
|||
AssessmentQuestionResponse r = items.get(0); |
|||
assertEquals(301L, r.getId()); |
|||
assertEquals(1, r.getPosition()); |
|||
assertEquals("我对使用手工工具制作物品感兴趣。", r.getStem()); |
|||
assertEquals("LIKERT_5", r.getScaleType()); |
|||
// 内部字段一律不暴露:序列化后 JSON 不含这些字段,value 默认 null
|
|||
assertNull(r.getValue()); |
|||
String json = assertDoesNotThrow(() -> |
|||
new com.fasterxml.jackson.databind.ObjectMapper().writeValueAsString(r)); |
|||
assertFalse(json.contains("dimension"), "must not serialize dimension"); |
|||
assertFalse(json.contains("reverseScored"), "must not serialize reverseScored"); |
|||
assertFalse(json.contains("questionVersion"), "must not serialize questionVersion"); |
|||
assertFalse(json.contains("questionCode"), "must not serialize dimension-carrying questionCode"); |
|||
assertFalse(json.contains("source"), "must not serialize source"); |
|||
} |
|||
|
|||
@Test |
|||
void listQuestions_inactiveType_returns9701() { |
|||
AssessmentDefinition def = AssessmentDefinition.builder().id(9L).typeCode("HOLLAND_RIASEC") |
|||
.status("INACTIVE").build(); |
|||
when(definitionRepository.findByTypeCode("HOLLAND_RIASEC")).thenReturn(Optional.of(def)); |
|||
BusinessException ex = assertThrows(BusinessException.class, |
|||
() -> service.listQuestions("HOLLAND_RIASEC")); |
|||
assertEquals(ErrorCode.ASSESSMENT_TYPE_NOT_FOUND.getCode(), ex.getErrorCodeInt()); |
|||
} |
|||
|
|||
@Test |
|||
void listQuestions_unknownType_returns9701() { |
|||
when(definitionRepository.findByTypeCode("NOPE")).thenReturn(Optional.empty()); |
|||
BusinessException ex = assertThrows(BusinessException.class, |
|||
() -> service.listQuestions("NOPE")); |
|||
assertEquals(ErrorCode.ASSESSMENT_TYPE_NOT_FOUND.getCode(), ex.getErrorCodeInt()); |
|||
} |
|||
} |
|||
@ -0,0 +1,280 @@ |
|||
package com.mydreamjob.assessment; |
|||
|
|||
import com.fasterxml.jackson.databind.JsonNode; |
|||
import com.fasterxml.jackson.databind.ObjectMapper; |
|||
import com.mydreamjob.auth.JwtTokenProvider; |
|||
import com.mydreamjob.auth.User; |
|||
import com.mydreamjob.auth.UserRepository; |
|||
import com.mydreamjob.candidatescore.CandidateDimensionScoreRepository; |
|||
import org.junit.jupiter.api.AfterEach; |
|||
import org.junit.jupiter.api.BeforeEach; |
|||
import org.junit.jupiter.api.Test; |
|||
import org.springframework.beans.factory.annotation.Autowired; |
|||
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; |
|||
import org.springframework.boot.test.context.SpringBootTest; |
|||
import org.springframework.boot.test.mock.mockito.MockBean; |
|||
import org.springframework.data.redis.core.RedisTemplate; |
|||
import org.springframework.data.redis.core.ValueOperations; |
|||
import org.springframework.http.MediaType; |
|||
import org.springframework.test.context.ActiveProfiles; |
|||
import org.springframework.test.web.servlet.MockMvc; |
|||
import org.springframework.test.web.servlet.MvcResult; |
|||
|
|||
import java.util.Map; |
|||
import java.util.Set; |
|||
import java.util.concurrent.ConcurrentLinkedQueue; |
|||
import java.util.concurrent.CountDownLatch; |
|||
import java.util.concurrent.ExecutorService; |
|||
import java.util.concurrent.Executors; |
|||
import java.util.concurrent.TimeUnit; |
|||
import java.util.concurrent.atomic.AtomicLong; |
|||
|
|||
import static org.junit.jupiter.api.Assertions.*; |
|||
import static org.mockito.ArgumentMatchers.any; |
|||
import static org.mockito.Mockito.mock; |
|||
import static org.mockito.Mockito.when; |
|||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; |
|||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; |
|||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; |
|||
|
|||
/** |
|||
* RIASEC 测评控制器集成测试(真实 Service + H2)。 |
|||
* |
|||
* <p>覆盖 401、题目响应不返回 dimension、创建 200、全流程保存/提交/结果、 |
|||
* COMPANY 创建 403、结果不含隐私字段。</p> |
|||
*/ |
|||
@SpringBootTest |
|||
@AutoConfigureMockMvc |
|||
@ActiveProfiles("integration-test") |
|||
class AssessmentControllerTest { |
|||
|
|||
private static final AtomicLong PHONE = new AtomicLong(700); |
|||
|
|||
@Autowired private MockMvc mockMvc; |
|||
@Autowired private ObjectMapper objectMapper; |
|||
@Autowired private JwtTokenProvider jwtTokenProvider; |
|||
@Autowired private UserRepository userRepository; |
|||
@Autowired private CandidateDimensionScoreRepository dimensionScoreRepository; |
|||
|
|||
@MockBean private RedisTemplate<String, Object> redisTemplate; |
|||
|
|||
private Long studentUserId; |
|||
private Long companyUserId; |
|||
|
|||
@BeforeEach |
|||
@SuppressWarnings("unchecked") |
|||
void setUp() { |
|||
ValueOperations<String, Object> ops = mock(ValueOperations.class); |
|||
when(redisTemplate.opsForValue()).thenReturn(ops); |
|||
when(redisTemplate.hasKey(any())).thenReturn(false); |
|||
studentUserId = createUser(User.Role.STUDENT); |
|||
companyUserId = createUser(User.Role.COMPANY); |
|||
} |
|||
|
|||
@AfterEach |
|||
void tearDown() { |
|||
// 仅删除自建用户(级联删除其答卷/答案/结果),保留迁移种子(定义+72题)
|
|||
userRepository.deleteById(studentUserId); |
|||
userRepository.deleteById(companyUserId); |
|||
} |
|||
|
|||
private Long createUser(User.Role role) { |
|||
User u = userRepository.save(User.builder() |
|||
.phone("17" + String.format("%08d", PHONE.incrementAndGet())) |
|||
.nickname("u").role(role).roles(Set.of(role)).active(true).verified(true).build()); |
|||
return u.getId(); |
|||
} |
|||
|
|||
private String token(Long userId, User.Role role) { |
|||
return "Bearer " + jwtTokenProvider.generateAccessToken(userId, |
|||
String.valueOf(userId), Map.of("roles", role.name())); |
|||
} |
|||
|
|||
@Test |
|||
void types_requiresAuth_returns401() throws Exception { |
|||
mockMvc.perform(get("/assessments/types")) |
|||
.andExpect(status().isUnauthorized()); |
|||
} |
|||
|
|||
@Test |
|||
void questions_hidesDimensionFields() throws Exception { |
|||
String tk = token(studentUserId, User.Role.STUDENT); |
|||
mockMvc.perform(get("/assessments/HOLLAND_RIASEC/questions").header("Authorization", tk)) |
|||
.andExpect(status().isOk()) |
|||
.andExpect(jsonPath("$.data[0].id").exists()) |
|||
.andExpect(jsonPath("$.data[0].stem").exists()) |
|||
.andExpect(jsonPath("$.data[0].dimension").doesNotExist()) |
|||
.andExpect(jsonPath("$.data[0].reverseScored").doesNotExist()) |
|||
.andExpect(jsonPath("$.data[0].source").doesNotExist()) |
|||
.andExpect(jsonPath("$.data[0].questionVersion").doesNotExist()) |
|||
.andExpect(jsonPath("$.data[0].questionCode").doesNotExist()); |
|||
} |
|||
|
|||
@Test |
|||
void company_cannotCreateAttempt_returns403() throws Exception { |
|||
String tk = token(companyUserId, User.Role.COMPANY); |
|||
mockMvc.perform(post("/assessments/attempts") |
|||
.header("Authorization", tk) |
|||
.contentType(MediaType.APPLICATION_JSON) |
|||
.content("{\"typeCode\":\"HOLLAND_RIASEC\"}")) |
|||
.andExpect(status().isForbidden()); |
|||
} |
|||
|
|||
@Test |
|||
void fullFlow_createAnswerSubmitResult() throws Exception { |
|||
long dimBefore = dimensionScoreRepository.count(); |
|||
String tk = token(studentUserId, User.Role.STUDENT); |
|||
|
|||
MvcResult create = mockMvc.perform(post("/assessments/attempts") |
|||
.header("Authorization", tk) |
|||
.contentType(MediaType.APPLICATION_JSON) |
|||
.content("{\"typeCode\":\"HOLLAND_RIASEC\"}")) |
|||
.andExpect(status().isOk()) |
|||
.andExpect(jsonPath("$.data.attemptId").exists()) |
|||
.andExpect(jsonPath("$.data.totalQuestions").value(30)) |
|||
.andExpect(jsonPath("$.data.questions.length()").value(30)) |
|||
.andReturn(); |
|||
|
|||
String body = create.getResponse().getContentAsString(); |
|||
JsonNode data = objectMapper.readTree(body).path("data"); |
|||
long attemptId = data.path("attemptId").asLong(); |
|||
int version = data.path("version").asInt(); |
|||
JsonNode questions = data.path("questions"); |
|||
assertEquals(30, questions.size()); |
|||
|
|||
StringBuilder answers = new StringBuilder("{\"version\":").append(version).append(",\"answers\":["); |
|||
for (int i = 0; i < questions.size(); i++) { |
|||
if (i > 0) answers.append(','); |
|||
answers.append("{\"questionId\":").append(questions.get(i).path("id").asLong()) |
|||
.append(",\"value\":4}"); |
|||
} |
|||
answers.append("]}"); |
|||
|
|||
mockMvc.perform(patch("/assessments/attempts/{id}/answers", attemptId) |
|||
.header("Authorization", tk) |
|||
.contentType(MediaType.APPLICATION_JSON) |
|||
.content(answers.toString())) |
|||
.andExpect(status().isOk()) |
|||
.andExpect(jsonPath("$.data.answeredCount").value(30)) |
|||
.andExpect(jsonPath("$.data.completionRate").value(100.0)); |
|||
|
|||
MvcResult submit = mockMvc.perform(post("/assessments/attempts/{id}/submit", attemptId) |
|||
.header("Authorization", tk)) |
|||
.andExpect(status().isOk()) |
|||
.andExpect(jsonPath("$.data.primaryCode").exists()) |
|||
.andExpect(jsonPath("$.data.disclaimer").value("本结果仅供职业兴趣探索参考,不构成心理或职业诊断。")) |
|||
.andReturn(); |
|||
JsonNode resultData = objectMapper.readTree(submit.getResponse().getContentAsString()).path("data"); |
|||
assertEquals(3, resultData.path("primaryCode").asText().length()); |
|||
|
|||
mockMvc.perform(get("/assessments/attempts/{id}/result", attemptId) |
|||
.header("Authorization", tk)) |
|||
.andExpect(status().isOk()) |
|||
// 脱敏:结果不含隐私字段与国家内部标识
|
|||
.andExpect(jsonPath("$.data.studentUserId").doesNotExist()) |
|||
.andExpect(jsonPath("$.data.phone").doesNotExist()) |
|||
.andExpect(jsonPath("$.data.email").doesNotExist()) |
|||
.andExpect(jsonPath("$.data.password").doesNotExist()) |
|||
// 探索方向使用"可能感兴趣"文案
|
|||
.andExpect(jsonPath("$.data.explorationDirections").isArray()); |
|||
|
|||
// 不写入候选评分引擎
|
|||
assertEquals(dimBefore, dimensionScoreRepository.count()); |
|||
} |
|||
|
|||
@Test |
|||
void concurrentCreate_differentKeys_onlyOne200_others409_9708() throws Exception { |
|||
String tk = token(studentUserId, User.Role.STUDENT); |
|||
int n = 4; |
|||
ExecutorService pool = Executors.newFixedThreadPool(n); |
|||
CountDownLatch start = new CountDownLatch(1); |
|||
CountDownLatch done = new CountDownLatch(n); |
|||
ConcurrentLinkedQueue<int[]> httpResults = new ConcurrentLinkedQueue<>(); // {status, code}
|
|||
for (int i = 0; i < n; i++) { |
|||
final String key = "http-cc-" + studentUserId + "-" + i; |
|||
pool.submit(() -> { |
|||
await(start); |
|||
try { |
|||
MvcResult r = mockMvc.perform(post("/assessments/attempts") |
|||
.header("Authorization", tk) |
|||
.contentType(MediaType.APPLICATION_JSON) |
|||
.content("{\"typeCode\":\"HOLLAND_RIASEC\",\"idempotencyKey\":\"" + key + "\"}")) |
|||
.andReturn(); |
|||
int status = r.getResponse().getStatus(); |
|||
int code = -1; |
|||
try { |
|||
code = objectMapper.readTree(r.getResponse().getContentAsString()).path("errorCode").isMissingNode() |
|||
? objectMapper.readTree(r.getResponse().getContentAsString()).path("code").asInt(-1) |
|||
: objectMapper.readTree(r.getResponse().getContentAsString()).path("errorCode").asInt(-1); |
|||
} catch (Exception ignore) { } |
|||
httpResults.add(new int[]{status, code}); |
|||
} catch (Exception ex) { |
|||
httpResults.add(new int[]{-1, -1}); |
|||
} finally { |
|||
done.countDown(); |
|||
} |
|||
}); |
|||
} |
|||
start.countDown(); |
|||
assertTrue(done.await(20, TimeUnit.SECONDS)); |
|||
pool.shutdown(); |
|||
int[] statuses = httpResults.stream().mapToInt(r -> r[0]).toArray(); |
|||
assertEquals(1, countOf(statuses, 200), "exactly one HTTP 200"); |
|||
assertEquals(n - 1, countOf(statuses, 409), "losers must be HTTP 409: " + java.util.Arrays.toString(statuses)); |
|||
assertEquals(0, countOf(statuses, 500), "no HTTP 500"); |
|||
assertEquals(0, countOf(statuses, -1), "no empty/unreachable responses"); |
|||
// 所有失败请求的 JSON code 都必须是 9708
|
|||
assertEquals(n - 1, httpResults.stream().filter(r -> r[0] == 409 && r[1] == 9708).count(), |
|||
"all 409 must carry code 9708"); |
|||
} |
|||
|
|||
@Test |
|||
void concurrentCreate_sameKey_all200_sameAttemptId() throws Exception { |
|||
String tk = token(studentUserId, User.Role.STUDENT); |
|||
String key = "http-cidem-" + studentUserId; |
|||
int n = 4; |
|||
ExecutorService pool = Executors.newFixedThreadPool(n); |
|||
CountDownLatch start = new CountDownLatch(1); |
|||
CountDownLatch done = new CountDownLatch(n); |
|||
ConcurrentLinkedQueue<Long> ids = new ConcurrentLinkedQueue<>(); |
|||
ConcurrentLinkedQueue<Integer> badStatus = new ConcurrentLinkedQueue<>(); |
|||
for (int i = 0; i < n; i++) { |
|||
pool.submit(() -> { |
|||
await(start); |
|||
try { |
|||
MvcResult r = mockMvc.perform(post("/assessments/attempts") |
|||
.header("Authorization", tk) |
|||
.contentType(MediaType.APPLICATION_JSON) |
|||
.content("{\"typeCode\":\"HOLLAND_RIASEC\",\"idempotencyKey\":\"" + key + "\"}")) |
|||
.andReturn(); |
|||
if (r.getResponse().getStatus() != 200) { |
|||
badStatus.add(r.getResponse().getStatus()); |
|||
} else { |
|||
ids.add(objectMapper.readTree(r.getResponse().getContentAsString()) |
|||
.path("data").path("attemptId").asLong()); |
|||
} |
|||
} catch (Exception ex) { |
|||
badStatus.add(-1); |
|||
} finally { |
|||
done.countDown(); |
|||
} |
|||
}); |
|||
} |
|||
start.countDown(); |
|||
assertTrue(done.await(20, TimeUnit.SECONDS)); |
|||
pool.shutdown(); |
|||
assertEquals(0, badStatus.size(), "same idempotencyKey concurrent create must all be 200: " + badStatus); |
|||
assertEquals(n, ids.size()); |
|||
assertEquals(1, ids.stream().distinct().count(), "all requests must return the same attemptId"); |
|||
} |
|||
|
|||
private int countOf(int[] arr, int v) { |
|||
int c = 0; |
|||
for (int x : arr) if (x == v) c++; |
|||
return c; |
|||
} |
|||
|
|||
private void await(CountDownLatch latch) { |
|||
try { latch.await(); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } |
|||
} |
|||
} |
|||
@ -0,0 +1,430 @@ |
|||
package com.mydreamjob.assessment; |
|||
|
|||
import com.mydreamjob.assessment.dto.AssessmentAttemptResponse; |
|||
import com.mydreamjob.assessment.dto.AssessmentQuestionResponse; |
|||
import com.mydreamjob.assessment.dto.CreateAssessmentAttemptRequest; |
|||
import com.mydreamjob.assessment.dto.SaveAssessmentAnswersRequest; |
|||
import com.mydreamjob.audit.AuditContext; |
|||
import com.mydreamjob.audit.AuditLogRepository; |
|||
import com.mydreamjob.auth.User; |
|||
import com.mydreamjob.auth.UserRepository; |
|||
import com.mydreamjob.candidatescore.CandidateDimensionScoreRepository; |
|||
import com.mydreamjob.common.exception.BusinessException; |
|||
import com.mydreamjob.common.exception.ErrorCode; |
|||
import org.junit.jupiter.api.AfterEach; |
|||
import org.junit.jupiter.api.BeforeEach; |
|||
import org.junit.jupiter.api.Test; |
|||
import org.springframework.beans.factory.annotation.Autowired; |
|||
import org.springframework.boot.test.context.SpringBootTest; |
|||
import org.springframework.boot.test.mock.mockito.MockBean; |
|||
import org.springframework.dao.DataIntegrityViolationException; |
|||
import org.springframework.data.domain.PageRequest; |
|||
import org.springframework.data.redis.core.RedisTemplate; |
|||
import org.springframework.data.redis.core.ValueOperations; |
|||
import org.springframework.test.context.ActiveProfiles; |
|||
|
|||
import java.util.ArrayList; |
|||
import java.util.HashSet; |
|||
import java.util.List; |
|||
import java.util.Map; |
|||
import java.util.Set; |
|||
import java.util.concurrent.CountDownLatch; |
|||
import java.util.concurrent.ExecutorService; |
|||
import java.util.concurrent.Executors; |
|||
import java.util.concurrent.TimeUnit; |
|||
import java.util.concurrent.atomic.AtomicInteger; |
|||
import java.util.concurrent.atomic.AtomicLong; |
|||
import java.util.stream.Collectors; |
|||
|
|||
import static org.junit.jupiter.api.Assertions.*; |
|||
import static org.mockito.ArgumentMatchers.any; |
|||
import static org.mockito.Mockito.mock; |
|||
import static org.mockito.Mockito.when; |
|||
|
|||
/** |
|||
* RIASEC 测评持久化集成测试(H2 + Flyway V34 真实表结构)。 |
|||
* |
|||
* <p>覆盖 V34 迁移成功、抽题 30 且每维 5、无重复、固定顺序、答案归属校验、 |
|||
* 29 题提交失败、30 题提交成功、重复提交幂等、幂等键、活跃答卷重复、候选评分不写入、 |
|||
* 唯一/CHECK 约束。</p> |
|||
*/ |
|||
@SpringBootTest |
|||
@ActiveProfiles("integration-test") |
|||
class AssessmentPersistenceIntegrationTest { |
|||
|
|||
private static final AtomicLong PHONE = new AtomicLong(800); |
|||
|
|||
@Autowired private AssessmentAttemptService attemptService; |
|||
@Autowired private AssessmentCatalogService catalogService; |
|||
@Autowired private AssessmentDefinitionRepository definitionRepository; |
|||
@Autowired private AssessmentQuestionRepository questionRepository; |
|||
@Autowired private AssessmentAttemptRepository attemptRepository; |
|||
@Autowired private AssessmentAttemptQuestionRepository attemptQuestionRepository; |
|||
@Autowired private AssessmentAnswerRepository answerRepository; |
|||
@Autowired private AssessmentResultRepository resultRepository; |
|||
@Autowired private UserRepository userRepository; |
|||
@Autowired private CandidateDimensionScoreRepository dimensionScoreRepository; |
|||
@Autowired private AuditLogRepository auditLogRepository; |
|||
|
|||
@MockBean private RedisTemplate<String, Object> redisTemplate; |
|||
|
|||
private final AuditContext ctx = AuditContext.builder().requestId("req").build(); |
|||
private final List<Long> createdUsers = new ArrayList<>(); |
|||
private Long studentUserId; |
|||
|
|||
@BeforeEach |
|||
@SuppressWarnings("unchecked") |
|||
void setUp() { |
|||
ValueOperations<String, Object> ops = mock(ValueOperations.class); |
|||
when(redisTemplate.opsForValue()).thenReturn(ops); |
|||
when(redisTemplate.hasKey(any())).thenReturn(false); |
|||
studentUserId = createUser(); |
|||
} |
|||
|
|||
@AfterEach |
|||
void tearDown() { |
|||
// 仅删除自建用户(级联删除其答卷/答案/结果),保留迁移种子
|
|||
for (Long id : createdUsers) { |
|||
userRepository.deleteById(id); |
|||
} |
|||
createdUsers.clear(); |
|||
} |
|||
|
|||
private Long createUser() { |
|||
User u = userRepository.save(User.builder() |
|||
.phone("18" + String.format("%08d", PHONE.incrementAndGet())) |
|||
.nickname("u").role(User.Role.STUDENT).roles(Set.of(User.Role.STUDENT)) |
|||
.active(true).verified(true).build()); |
|||
createdUsers.add(u.getId()); |
|||
return u.getId(); |
|||
} |
|||
|
|||
private CreateAssessmentAttemptRequest createReq(String key) { |
|||
return CreateAssessmentAttemptRequest.builder() |
|||
.typeCode("HOLLAND_RIASEC").idempotencyKey(key).build(); |
|||
} |
|||
|
|||
private SaveAssessmentAnswersRequest answerReq(AssessmentAttemptResponse resp, int value) { |
|||
List<SaveAssessmentAnswersRequest.AnswerItem> items = resp.getQuestions().stream() |
|||
.map(q -> SaveAssessmentAnswersRequest.AnswerItem.builder() |
|||
.questionId(q.getId()).value(value).build()) |
|||
.collect(Collectors.toList()); |
|||
return SaveAssessmentAnswersRequest.builder().version(resp.getVersion()).answers(items).build(); |
|||
} |
|||
|
|||
private SaveAssessmentAnswersRequest partialReq(AssessmentAttemptResponse resp, int count) { |
|||
List<SaveAssessmentAnswersRequest.AnswerItem> items = resp.getQuestions().subList(0, count).stream() |
|||
.map(q -> SaveAssessmentAnswersRequest.AnswerItem.builder() |
|||
.questionId(q.getId()).value(4).build()) |
|||
.collect(Collectors.toList()); |
|||
return SaveAssessmentAnswersRequest.builder().version(resp.getVersion()).answers(items).build(); |
|||
} |
|||
|
|||
// ── 迁移 & 抽题 ─────────────────────────────────────────
|
|||
|
|||
@Test |
|||
void v34Migration_seedsDefinitionAnd72Questions() { |
|||
var def = definitionRepository.findByTypeCode("HOLLAND_RIASEC").orElseThrow(); |
|||
assertEquals(72, def.getQuestionCount()); |
|||
assertEquals(30, def.getAttemptQuestionCount()); |
|||
assertEquals(5, def.getMinPerDimension()); |
|||
assertEquals(72, questionRepository.findByDefinitionIdAndActiveTrueOrderByPositionAsc(def.getId()).size()); |
|||
assertEquals(1, catalogService.listTypes().stream().filter(t -> "ACTIVE".equals(t.getStatus())).count()); |
|||
} |
|||
|
|||
@Test |
|||
void create_sampled30_fivePerDimension_noDuplicatesFixedOrder() { |
|||
AssessmentAttemptResponse resp = attemptService.create(studentUserId, createReq("k-create"), ctx); |
|||
// 恰好 30 题
|
|||
assertEquals(30, resp.getTotalQuestions()); |
|||
assertEquals(30, resp.getQuestions().size()); |
|||
// 无重复
|
|||
Set<Long> ids = new HashSet<>(); |
|||
for (AssessmentQuestionResponse q : resp.getQuestions()) { |
|||
assertTrue(ids.add(q.getId()), "no duplicate question expected"); |
|||
} |
|||
// 每维恰好 5 题
|
|||
List<Long> qids = resp.getQuestions().stream().map(AssessmentQuestionResponse::getId).toList(); |
|||
Map<String, Long> byDim = questionRepository.findAllById(qids).stream() |
|||
.collect(Collectors.groupingBy(AssessmentQuestion::getDimension, Collectors.counting())); |
|||
for (String dim : List.of("R", "I", "A", "S", "E", "C")) { |
|||
assertEquals(5L, byDim.getOrDefault(dim, 0L), "dimension " + dim); |
|||
} |
|||
// 刷新(重查)顺序不变
|
|||
AssessmentAttemptResponse again = attemptService.get(studentUserId, resp.getAttemptId()); |
|||
assertEquals(resp.getQuestions().stream().map(AssessmentQuestionResponse::getId).toList(), |
|||
again.getQuestions().stream().map(AssessmentQuestionResponse::getId).toList()); |
|||
} |
|||
|
|||
@Test |
|||
void create_duplicateActive_returns9708() { |
|||
attemptService.create(studentUserId, createReq("k-active-1"), ctx); |
|||
BusinessException ex = assertThrows(BusinessException.class, |
|||
() -> attemptService.create(studentUserId, createReq("k-active-2"), ctx)); |
|||
assertEquals(ErrorCode.ASSESSMENT_DUPLICATE_ACTIVE.getCode(), ex.getErrorCodeInt()); |
|||
} |
|||
|
|||
@Test |
|||
void create_sameKeySameBody_returnsOriginalAttempt() { |
|||
AssessmentAttemptResponse first = attemptService.create(studentUserId, createReq("k-idem"), ctx); |
|||
AssessmentAttemptResponse second = attemptService.create(studentUserId, createReq("k-idem"), ctx); |
|||
assertEquals(first.getAttemptId(), second.getAttemptId()); |
|||
long attempts = attemptRepository.findByStudentUserIdOrderByCreatedAtDesc(studentUserId, |
|||
org.springframework.data.domain.PageRequest.of(0, 50)).getTotalElements(); |
|||
assertEquals(1L, attempts); |
|||
} |
|||
|
|||
// ── 保存答案 ────────────────────────────────────────────
|
|||
|
|||
@Test |
|||
void saveAnswers_questionNotOnAttempt_returns9705() { |
|||
AssessmentAttemptResponse resp = attemptService.create(studentUserId, createReq("k-save-1"), ctx); |
|||
Long foreignQuestionId = questionRepository.findAll().stream() |
|||
.map(AssessmentQuestion::getId) |
|||
.filter(id -> resp.getQuestions().stream().noneMatch(q -> q.getId().equals(id))) |
|||
.findFirst().orElseThrow(); |
|||
SaveAssessmentAnswersRequest req = SaveAssessmentAnswersRequest.builder() |
|||
.version(resp.getVersion()) |
|||
.answers(List.of(SaveAssessmentAnswersRequest.AnswerItem.builder() |
|||
.questionId(foreignQuestionId).value(4).build())) |
|||
.build(); |
|||
BusinessException ex = assertThrows(BusinessException.class, |
|||
() -> attemptService.saveAnswers(studentUserId, resp.getAttemptId(), req, ctx)); |
|||
assertEquals(ErrorCode.ASSESSMENT_ANSWER_INVALID.getCode(), ex.getErrorCodeInt()); |
|||
} |
|||
|
|||
@Test |
|||
void saveAnswers_invalidValue_returns9705() { |
|||
AssessmentAttemptResponse resp = attemptService.create(studentUserId, createReq("k-save-2"), ctx); |
|||
Long qid = resp.getQuestions().get(0).getId(); |
|||
SaveAssessmentAnswersRequest req = SaveAssessmentAnswersRequest.builder() |
|||
.version(resp.getVersion()) |
|||
.answers(List.of(SaveAssessmentAnswersRequest.AnswerItem.builder() |
|||
.questionId(qid).value(6).build())) |
|||
.build(); |
|||
BusinessException ex = assertThrows(BusinessException.class, |
|||
() -> attemptService.saveAnswers(studentUserId, resp.getAttemptId(), req, ctx)); |
|||
assertEquals(ErrorCode.ASSESSMENT_ANSWER_INVALID.getCode(), ex.getErrorCodeInt()); |
|||
} |
|||
|
|||
// ── 提交 ────────────────────────────────────────────────
|
|||
|
|||
@Test |
|||
void submit_29Answered_returns9706() { |
|||
AssessmentAttemptResponse resp = attemptService.create(studentUserId, createReq("k-sub-29"), ctx); |
|||
attemptService.saveAnswers(studentUserId, resp.getAttemptId(), |
|||
partialReq(resp, 29), ctx); |
|||
BusinessException ex = assertThrows(BusinessException.class, |
|||
() -> attemptService.submit(studentUserId, resp.getAttemptId(), ctx)); |
|||
assertEquals(ErrorCode.ASSESSMENT_INCOMPLETE.getCode(), ex.getErrorCodeInt()); |
|||
} |
|||
|
|||
@Test |
|||
void submit_30Answered_succeeds_andRepeatedIdempotent() { |
|||
long dimBefore = dimensionScoreRepository.count(); |
|||
AssessmentAttemptResponse resp = attemptService.create(studentUserId, createReq("k-sub-30"), ctx); |
|||
attemptService.saveAnswers(studentUserId, resp.getAttemptId(), answerReq(resp, 4), ctx); |
|||
assertEquals(30, answerRepository.countByAttemptId(resp.getAttemptId())); |
|||
|
|||
var result = attemptService.submit(studentUserId, resp.getAttemptId(), ctx); |
|||
assertEquals("COMPLETED", result.getStatus()); |
|||
assertEquals(30, result.getAnsweredCount()); |
|||
assertEquals(100.0, result.getCompletionRate().doubleValue()); |
|||
assertNotNull(result.getPrimaryCode()); |
|||
assertEquals(3, result.getPrimaryCode().length()); |
|||
assertEquals(30, attemptQuestionRepository.findByAttemptIdOrderByPositionAsc(resp.getAttemptId()).size()); |
|||
|
|||
// 重复提交幂等:不再新增结果
|
|||
var again = attemptService.submit(studentUserId, resp.getAttemptId(), ctx); |
|||
assertEquals(result.getPrimaryCode(), again.getPrimaryCode()); |
|||
assertEquals(1, resultRepository.findByAttemptId(resp.getAttemptId()).isPresent() ? 1 : 0); |
|||
|
|||
// 不写入候选评分引擎
|
|||
assertEquals(dimBefore, dimensionScoreRepository.count()); |
|||
} |
|||
|
|||
@Test |
|||
void fullFlow_statusAndResultPersisted() { |
|||
AssessmentAttemptResponse resp = attemptService.create(studentUserId, createReq("k-flow"), ctx); |
|||
attemptService.saveAnswers(studentUserId, resp.getAttemptId(), answerReq(resp, 3), ctx); |
|||
|
|||
var detail = attemptService.get(studentUserId, resp.getAttemptId()); |
|||
assertEquals("IN_PROGRESS", detail.getStatus()); |
|||
assertEquals(30, detail.getAnsweredCount()); |
|||
|
|||
var result = attemptService.submit(studentUserId, resp.getAttemptId(), ctx); |
|||
var attemptRow = attemptRepository.findById(resp.getAttemptId()).orElseThrow(); |
|||
assertEquals(AssessmentAttempt.STATUS_COMPLETED, attemptRow.getStatus()); |
|||
assertNotNull(attemptRow.getSubmittedAt()); |
|||
assertNotNull(resultRepository.findByAttemptId(resp.getAttemptId()).orElseThrow().getGeneratedAt()); |
|||
} |
|||
|
|||
// ── 约束校验 ────────────────────────────────────────────
|
|||
|
|||
@Test |
|||
void inactiveDefinition_createAndList_returns9701() { |
|||
AssessmentDefinition inactive = definitionRepository.save(AssessmentDefinition.builder() |
|||
.typeCode("INACTIVE_TEST").displayName("x").description("d").instructions("i") |
|||
.status("INACTIVE").scaleType("LIKERT_5") |
|||
.questionCount(30).attemptQuestionCount(30).minPerDimension(5) |
|||
.questionSetVersion("v0.2").rulesVersion("v0.1").build()); |
|||
try { |
|||
BusinessException createEx = assertThrows(BusinessException.class, |
|||
() -> attemptService.create(studentUserId, CreateAssessmentAttemptRequest.builder() |
|||
.typeCode("INACTIVE_TEST").build(), ctx)); |
|||
assertEquals(ErrorCode.ASSESSMENT_TYPE_NOT_FOUND.getCode(), createEx.getErrorCodeInt()); |
|||
|
|||
BusinessException listEx = assertThrows(BusinessException.class, |
|||
() -> catalogService.listQuestions("INACTIVE_TEST")); |
|||
assertEquals(ErrorCode.ASSESSMENT_TYPE_NOT_FOUND.getCode(), listEx.getErrorCodeInt()); |
|||
} finally { |
|||
definitionRepository.delete(inactive); |
|||
} |
|||
} |
|||
|
|||
@Test |
|||
void concurrentCreate_sameStudentSameType_onlyOneSucceedsRestAre9708() throws Exception { |
|||
Long u = createUser(); |
|||
int n = 4; |
|||
ExecutorService pool = Executors.newFixedThreadPool(n); |
|||
CountDownLatch start = new CountDownLatch(1); |
|||
CountDownLatch done = new CountDownLatch(n); |
|||
java.util.concurrent.ConcurrentLinkedQueue<Integer> results = new java.util.concurrent.ConcurrentLinkedQueue<>(); |
|||
for (int i = 0; i < n; i++) { |
|||
final String key = "ccp-" + u + "-" + i; |
|||
pool.submit(() -> { |
|||
await(start); |
|||
try { |
|||
attemptService.create(u, createReq(key), ctx); |
|||
results.add(0); // 成功
|
|||
} catch (BusinessException be) { |
|||
results.add(be.getErrorCodeInt()); // 9708
|
|||
} catch (Exception other) { |
|||
results.add(-1); // 出现 AssertionFailure/500 等即失败
|
|||
} finally { |
|||
done.countDown(); |
|||
} |
|||
}); |
|||
} |
|||
start.countDown(); |
|||
assertTrue(done.await(20, TimeUnit.SECONDS), "concurrent create should finish"); |
|||
pool.shutdown(); |
|||
assertEquals(1, results.stream().filter(c -> c == 0).count(), "exactly one concurrent create must win (200)"); |
|||
assertEquals(n - 1, results.stream().filter(c -> c == ErrorCode.ASSESSMENT_DUPLICATE_ACTIVE.getCode()).count(), |
|||
"all losers must be BusinessException 9708"); |
|||
assertEquals(0, results.stream().filter(c -> c < 0).count(), "no AssertionFailure/500/other exceptions allowed"); |
|||
|
|||
long attemptRows = attemptRepository.findByStudentUserIdOrderByCreatedAtDesc(u, |
|||
PageRequest.of(0, 50)).getTotalElements(); |
|||
assertEquals(1, attemptRows, "DB must contain exactly 1 attempt row"); |
|||
long activeKeys = attemptRepository.findByStudentUserIdOrderByCreatedAtDesc(u, |
|||
PageRequest.of(0, 50)).getContent().stream() |
|||
.filter(a -> a.getActiveKey() != null).count(); |
|||
assertEquals(1, activeKeys, "DB must contain exactly 1 non-null active_key"); |
|||
Long winnerId = attemptRepository.findByStudentUserIdOrderByCreatedAtDesc(u, PageRequest.of(0, 50)) |
|||
.getContent().get(0).getId(); |
|||
assertEquals(30, attemptQuestionRepository.findByAttemptIdOrderByPositionAsc(winnerId).size(), |
|||
"winning attempt must have 30 attempt_questions"); |
|||
} |
|||
|
|||
@Test |
|||
void concurrentCreate_sameIdempotencyKey_returnsSameAttempt() throws Exception { |
|||
Long u = createUser(); |
|||
String idem = "cidem-" + u; |
|||
int n = 4; |
|||
ExecutorService pool = Executors.newFixedThreadPool(n); |
|||
CountDownLatch start = new CountDownLatch(1); |
|||
CountDownLatch done = new CountDownLatch(n); |
|||
java.util.concurrent.ConcurrentLinkedQueue<Long> attemptIds = new java.util.concurrent.ConcurrentLinkedQueue<>(); |
|||
java.util.concurrent.ConcurrentLinkedQueue<Integer> errors = new java.util.concurrent.ConcurrentLinkedQueue<>(); |
|||
for (int i = 0; i < n; i++) { |
|||
pool.submit(() -> { |
|||
await(start); |
|||
try { |
|||
AssessmentAttemptResponse r = attemptService.create(u, |
|||
CreateAssessmentAttemptRequest.builder() |
|||
.typeCode("HOLLAND_RIASEC").idempotencyKey(idem).build(), ctx); |
|||
attemptIds.add(r.getAttemptId()); |
|||
} catch (BusinessException be) { |
|||
errors.add(be.getErrorCodeInt()); |
|||
} finally { |
|||
done.countDown(); |
|||
} |
|||
}); |
|||
} |
|||
start.countDown(); |
|||
assertTrue(done.await(20, TimeUnit.SECONDS), "concurrent idem create should finish"); |
|||
pool.shutdown(); |
|||
assertEquals(0, errors.size(), "same idempotencyKey concurrent create must not error: " + errors); |
|||
assertEquals(n, attemptIds.size(), "every request must return an attemptId (idempotent 200)"); |
|||
assertEquals(1, attemptIds.stream().distinct().count(), "all requests must share the same attemptId"); |
|||
assertEquals(1, attemptRepository.findByStudentUserIdOrderByCreatedAtDesc(u, |
|||
PageRequest.of(0, 50)).getTotalElements(), "only one attempt row persisted"); |
|||
} |
|||
|
|||
@Test |
|||
void concurrentSubmit_onlyOneResultAndOneSubmitAudit() throws Exception { |
|||
Long u = createUser(); |
|||
AssessmentAttemptResponse resp = attemptService.create(u, createReq("cs-" + u), ctx); |
|||
attemptService.saveAnswers(u, resp.getAttemptId(), answerReq(resp, 4), ctx); |
|||
|
|||
int n = 2; |
|||
ExecutorService pool = Executors.newFixedThreadPool(n); |
|||
CountDownLatch start = new CountDownLatch(1); |
|||
CountDownLatch done = new CountDownLatch(n); |
|||
for (int i = 0; i < n; i++) { |
|||
pool.submit(() -> { |
|||
await(start); |
|||
try { |
|||
attemptService.submit(u, resp.getAttemptId(), ctx); |
|||
} catch (Exception ignored) { |
|||
// 并发幂等,允许 9703/原有结果
|
|||
} finally { |
|||
done.countDown(); |
|||
} |
|||
}); |
|||
} |
|||
start.countDown(); |
|||
assertTrue(done.await(20, TimeUnit.SECONDS), "concurrent submit should finish"); |
|||
pool.shutdown(); |
|||
|
|||
assertEquals(1, resultRepository.findByAttemptId(resp.getAttemptId()).isPresent() ? 1 : 0, |
|||
"concurrent submit must produce exactly one result"); |
|||
long submitAudits = auditLogRepository.findAll().stream() |
|||
.filter(a -> "ASSESSMENT_ATTEMPT_SUBMITTED".equals(a.getAction()) |
|||
&& resp.getAttemptId().equals(a.getEntityId())) |
|||
.count(); |
|||
assertEquals(1, submitAudits, "concurrent submit must write exactly one submit audit"); |
|||
|
|||
var attemptRow = attemptRepository.findById(resp.getAttemptId()).orElseThrow(); |
|||
assertEquals(AssessmentAttempt.STATUS_COMPLETED, attemptRow.getStatus()); |
|||
assertNull(attemptRow.getActiveKey(), "active_key must be released after completion"); |
|||
} |
|||
|
|||
@Test |
|||
void dbConstraints_uniqueAndCheckEnforced() { |
|||
AssessmentAttemptResponse resp = attemptService.create(studentUserId, createReq("k-cons"), ctx); |
|||
Long attemptId = resp.getAttemptId(); |
|||
Long q1 = resp.getQuestions().get(0).getId(); |
|||
Long q2 = resp.getQuestions().get(1).getId(); |
|||
|
|||
answerRepository.saveAndFlush(AssessmentAnswer.builder() |
|||
.attemptId(attemptId).questionId(q1).value(4).build()); |
|||
// 唯一(attempt,question) → 重复插入违反唯一约束
|
|||
assertThrows(DataIntegrityViolationException.class, |
|||
() -> answerRepository.saveAndFlush(AssessmentAnswer.builder() |
|||
.attemptId(attemptId).questionId(q1).value(5).build())); |
|||
// CHECK value BETWEEN 1 AND 5 → 越界
|
|||
assertThrows(DataIntegrityViolationException.class, |
|||
() -> answerRepository.saveAndFlush(AssessmentAnswer.builder() |
|||
.attemptId(attemptId).questionId(q2).value(6).build())); |
|||
} |
|||
|
|||
private void await(CountDownLatch latch) { |
|||
try { |
|||
latch.await(); |
|||
} catch (InterruptedException e) { |
|||
Thread.currentThread().interrupt(); |
|||
} |
|||
} |
|||
} |
|||
Some files were not shown because too many files changed in this diff
Loading…
Reference in new issue