forked from baskbuddy/zhangshun
25 changed files with 3438 additions and 47 deletions
@ -0,0 +1,245 @@ |
|||||
|
$ErrorActionPreference = 'Stop' |
||||
|
|
||||
|
Push-Location $PSScriptRoot |
||||
|
$pass = 0 |
||||
|
$fail = 0 |
||||
|
$fixtureUsers = @() |
||||
|
$fixturePhones = @() |
||||
|
$fixtureAbilityUser = $null |
||||
|
Add-Type -AssemblyName System.Net.Http |
||||
|
|
||||
|
function Pass([string]$name) { |
||||
|
$script:pass++ |
||||
|
Write-Output "PASS $name" |
||||
|
} |
||||
|
|
||||
|
function Fail([string]$name, [string]$detail = '') { |
||||
|
$script:fail++ |
||||
|
Write-Output ("FAIL {0}{1}" -f $name, $(if ($detail) { " :: $detail" } else { '' })) |
||||
|
} |
||||
|
|
||||
|
function Assert([bool]$condition, [string]$name, [string]$detail = '') { |
||||
|
if ($condition) { Pass $name } else { Fail $name $detail } |
||||
|
} |
||||
|
|
||||
|
function Sql([string]$query) { |
||||
|
$output = $query | docker compose --env-file .env exec -T mysql sh -lc 'MYSQL_PWD="$MYSQL_ROOT_PASSWORD" mysql -uroot -N -B mydreamjob' 2>$null |
||||
|
if ($LASTEXITCODE -ne 0) { throw 'mysql query failed' } |
||||
|
$rows = @($output | ForEach-Object { "$($_)".Trim() } | Where-Object { $_ -ne '' }) |
||||
|
return ,$rows |
||||
|
} |
||||
|
|
||||
|
function Rabbit { |
||||
|
$output = docker compose --env-file .env exec -T rabbitmq rabbitmqctl list_queues name messages_ready messages_unacknowledged 2>$null |
||||
|
if ($LASTEXITCODE -ne 0) { throw 'rabbitmq query failed' } |
||||
|
$rows = @($output | ForEach-Object { "$($_)".Trim() } | Where-Object { $_ -ne '' }) |
||||
|
return ,$rows |
||||
|
} |
||||
|
|
||||
|
function Api([string]$method, [string]$path, [string]$token = $null, $body = $null) { |
||||
|
$client = [System.Net.Http.HttpClient]::new() |
||||
|
try { |
||||
|
$request = [System.Net.Http.HttpRequestMessage]::new( |
||||
|
[System.Net.Http.HttpMethod]::new($method), |
||||
|
('http://127.0.0.1:8080/api/v1' + $path)) |
||||
|
$request.Headers.Add('X-Request-Id', ('v41-blackbox-' + [guid]::NewGuid().ToString('N'))) |
||||
|
if ($token) { |
||||
|
$request.Headers.Authorization = [System.Net.Http.Headers.AuthenticationHeaderValue]::new('Bearer', $token) |
||||
|
} |
||||
|
if ($null -ne $body) { |
||||
|
$json = $body | ConvertTo-Json -Depth 30 -Compress |
||||
|
$request.Content = [System.Net.Http.StringContent]::new($json, [Text.Encoding]::UTF8, 'application/json') |
||||
|
} |
||||
|
$response = $client.SendAsync($request).GetAwaiter().GetResult() |
||||
|
$raw = $response.Content.ReadAsStringAsync().GetAwaiter().GetResult() |
||||
|
$parsed = $null |
||||
|
if ($raw) { try { $parsed = $raw | ConvertFrom-Json } catch {} } |
||||
|
return [pscustomobject]@{ Status = [int]$response.StatusCode; Json = $parsed; Raw = $raw } |
||||
|
} finally { $client.Dispose() } |
||||
|
} |
||||
|
|
||||
|
function Login([long]$userId) { |
||||
|
$phone = (Sql "SELECT phone FROM users WHERE id=$userId LIMIT 1")[0] |
||||
|
if (-not $phone) { throw "login user not found" } |
||||
|
docker compose --env-file .env exec -T redis redis-cli DEL ("sms:rate:" + $phone) 2>$null | Out-Null |
||||
|
$sent = Api 'POST' ("/auth/sms/send?phone=$phone&purpose=login") $null $null |
||||
|
if ($sent.Status -ne 200) { throw 'sms send failed' } |
||||
|
$code = (Sql "SELECT code FROM sms_verification_codes WHERE phone='$phone' AND purpose='login' ORDER BY id DESC LIMIT 1")[0] |
||||
|
$login = Api 'POST' '/auth/login' $null @{ phone = $phone; code = $code; purpose = 'login' } |
||||
|
if ($login.Status -ne 200 -or -not $login.Json.data.accessToken) { throw 'login failed' } |
||||
|
return [string]$login.Json.data.accessToken |
||||
|
} |
||||
|
|
||||
|
function NewFixtureStudent([string]$label, [bool]$withPreference) { |
||||
|
$phone = '19' + (([DateTimeOffset]::Now.ToUnixTimeMilliseconds() % 1000000000) + $script:fixtureUsers.Count + 1).ToString('D9') |
||||
|
$script:fixturePhones += $phone |
||||
|
$sql = @" |
||||
|
INSERT INTO users(phone,nickname,role,is_active,is_verified) VALUES ('$phone','$label','STUDENT',1,1); |
||||
|
SELECT LAST_INSERT_ID(); |
||||
|
"@ |
||||
|
$id = [long]((Sql $sql)[0]) |
||||
|
$script:fixtureUsers += $id |
||||
|
Sql @" |
||||
|
INSERT INTO user_roles(user_id,role) VALUES ($id,'STUDENT'); |
||||
|
INSERT INTO student_profiles(user_id,location,education,graduation_year,skills_json) |
||||
|
SELECT $id,j.city,'Bachelor',2026,'["$label","Java","Spring Boot","SQL"]' FROM jobs j WHERE j.id=1; |
||||
|
INSERT INTO student_talent_discovery_preferences(student_user_id,enabled,version) VALUES ($id,1,0); |
||||
|
"@ | Out-Null |
||||
|
if ($withPreference) { |
||||
|
Sql @" |
||||
|
INSERT INTO student_preferences(student_user_id,expected_salary_min,expected_salary_max,preferred_cities,employment_types,preferred_industries,preferred_job_types,remote_allowed) |
||||
|
SELECT $id,NULL,NULL,JSON_ARRAY(j.city),JSON_ARRAY(j.employment_type),JSON_ARRAY(p.industry_code),JSON_ARRAY(p.job_family_code),0 |
||||
|
FROM jobs j JOIN job_recommendation_profiles p ON p.job_id=j.id WHERE j.id=1; |
||||
|
"@ | Out-Null |
||||
|
} |
||||
|
return $id |
||||
|
} |
||||
|
|
||||
|
function Cleanup { |
||||
|
if (-not $script:fixtureUsers.Count) { return } |
||||
|
$ids = $script:fixtureUsers -join ',' |
||||
|
$phones = ($script:fixturePhones | ForEach-Object { "'$_'" }) -join ',' |
||||
|
try { Sql "DELETE FROM audit_logs WHERE user_id IN ($ids) OR (action='SMS_SEND' AND request_id LIKE 'v41-blackbox-%')" | Out-Null } catch {} |
||||
|
try { Sql "DELETE FROM ability_results WHERE attempt_id IN (SELECT id FROM ability_attempts WHERE student_user_id IN ($ids))" | Out-Null } catch {} |
||||
|
try { Sql "DELETE FROM ability_attempts WHERE student_user_id IN ($ids)" | Out-Null } catch {} |
||||
|
try { Sql "DELETE FROM applications WHERE student_user_id IN ($ids)" | Out-Null } catch {} |
||||
|
try { Sql "DELETE FROM student_preferences WHERE student_user_id IN ($ids)" | Out-Null } catch {} |
||||
|
try { Sql "DELETE FROM student_talent_discovery_preferences WHERE student_user_id IN ($ids)" | Out-Null } catch {} |
||||
|
try { Sql "DELETE FROM student_profiles WHERE user_id IN ($ids)" | Out-Null } catch {} |
||||
|
try { Sql "DELETE FROM account_deletions WHERE user_id IN ($ids)" | Out-Null } catch {} |
||||
|
try { Sql "DELETE FROM user_roles WHERE user_id IN ($ids)" | Out-Null } catch {} |
||||
|
try { Sql "DELETE FROM sms_verification_codes WHERE phone IN ($phones)" | Out-Null } catch {} |
||||
|
try { Sql "DELETE FROM users WHERE id IN ($ids)" | Out-Null } catch {} |
||||
|
} |
||||
|
|
||||
|
try { |
||||
|
$flywayRow = (Sql "SELECT version,success FROM flyway_schema_history ORDER BY installed_rank DESC LIMIT 1")[0] |
||||
|
$flyway = @($flywayRow -split '\s+') |
||||
|
Assert ($flyway.Count -ge 2 -and $flyway[0] -eq '39' -and $flyway[1] -eq '1') 'Flyway V39 success' |
||||
|
$table = Sql "SELECT COUNT(*) FROM information_schema.tables WHERE table_schema='mydreamjob' AND table_name='student_talent_discovery_preferences'" |
||||
|
Assert ([int]$table[0] -eq 1) 'V39 table exists' |
||||
|
$constraints = Sql "SELECT constraint_type FROM information_schema.table_constraints WHERE table_schema='mydreamjob' AND table_name='student_talent_discovery_preferences'" |
||||
|
Assert (@($constraints | Where-Object { $_ -eq 'UNIQUE' }).Count -ge 1) 'UNIQUE student_user_id' |
||||
|
$check = Sql "SELECT COUNT(*) FROM information_schema.table_constraints WHERE constraint_schema='mydreamjob' AND table_name='student_talent_discovery_preferences' AND constraint_type='CHECK'" |
||||
|
Assert ([int]$check[0] -ge 1) 'CHECK enabled constraint' |
||||
|
|
||||
|
$health = Api 'GET' '/health' |
||||
|
Assert ($health.Status -eq 200 -and $health.Json.data.status -eq 'UP') 'health 200' |
||||
|
$docs = Api 'GET' '/v3/api-docs' |
||||
|
$pathNames = @($docs.Json.paths.PSObject.Properties.Name) |
||||
|
$opCount = 0 |
||||
|
foreach ($property in $docs.Json.paths.PSObject.Properties) { |
||||
|
$opCount += @($property.Value.PSObject.Properties.Name | Where-Object { $_ -in @('get','post','put','patch','delete','options','head','trace') }).Count |
||||
|
} |
||||
|
Assert ($pathNames.Count -eq 161 -and $opCount -eq 199) 'runtime OpenAPI 161/199' |
||||
|
Assert ($pathNames -contains '/profile/me/talent-discovery' -and $pathNames -contains '/companies/me/jobs/{jobId}/talent-recommendations') 'V41 paths present' |
||||
|
|
||||
|
$baseScores = [int](Sql 'SELECT COUNT(*) FROM candidate_dimension_scores')[0] |
||||
|
$a = NewFixtureStudent 'V41BB-A' $true |
||||
|
$b = NewFixtureStudent 'V41BB-B' $true |
||||
|
$c = NewFixtureStudent 'V41BB-C' $true |
||||
|
$d = NewFixtureStudent 'V41BB-D' $false |
||||
|
$e = NewFixtureStudent 'V41BB-E' $true |
||||
|
$script:fixtureAbilityUser = $d |
||||
|
Sql "INSERT INTO applications(job_id,student_user_id,status) VALUES (1,$b,'REJECTED'),(13,$c,'WITHDRAWN');" | Out-Null |
||||
|
Sql "INSERT INTO account_deletions(user_id,status,requested_at,version) VALUES ($e,'PENDING',NOW(),0);" | Out-Null |
||||
|
Sql @" |
||||
|
INSERT INTO ability_attempts(student_user_id,definition_id,status,question_set_version,answered_count,active_key,idempotency_key,completed_at,submitted_at,started_at) |
||||
|
VALUES ($d,1,'COMPLETED','ability-v0.1',24,NULL,NULL,DATE_SUB(NOW(),INTERVAL 2 DAY),DATE_SUB(NOW(),INTERVAL 2 DAY),DATE_SUB(NOW(),INTERVAL 2 DAY)); |
||||
|
SET @high_attempt=LAST_INSERT_ID(); |
||||
|
INSERT INTO ability_results(attempt_id,dimension_scores_json,overall_score,valid_until,question_set_version,rules_version,generated_at) |
||||
|
VALUES (@high_attempt,'[{"code":"LOGIC","score":100,"answeredCount":6},{"code":"COMMUNICATION","score":100,"answeredCount":6},{"code":"EXECUTION","score":100,"answeredCount":6},{"code":"LEARNING","score":100,"answeredCount":6}]',100,DATE_ADD(NOW(),INTERVAL 7 DAY),'ability-v0.1','ability-v0.1',NOW()); |
||||
|
INSERT INTO ability_attempts(student_user_id,definition_id,status,question_set_version,answered_count,active_key,idempotency_key,completed_at,submitted_at,started_at) |
||||
|
VALUES ($d,1,'COMPLETED','ability-v0.1',24,NULL,NULL,DATE_SUB(NOW(),INTERVAL 1 HOUR),DATE_SUB(NOW(),INTERVAL 1 HOUR),DATE_SUB(NOW(),INTERVAL 1 HOUR)); |
||||
|
SET @low_attempt=LAST_INSERT_ID(); |
||||
|
INSERT INTO ability_results(attempt_id,dimension_scores_json,overall_score,valid_until,question_set_version,rules_version,generated_at) |
||||
|
VALUES (@low_attempt,'[{"code":"LOGIC","score":0,"answeredCount":6},{"code":"COMMUNICATION","score":0,"answeredCount":6},{"code":"EXECUTION","score":0,"answeredCount":6},{"code":"LEARNING","score":0,"answeredCount":6}]',0,DATE_ADD(NOW(),INTERVAL 7 DAY),'ability-v0.1','ability-v0.1',NOW()); |
||||
|
"@ | Out-Null |
||||
|
|
||||
|
$tokenA = Login $a |
||||
|
$tokenB = Login $b |
||||
|
$tokenC = Login $c |
||||
|
$tokenD = Login $d |
||||
|
$tokenCompany = Login 3 |
||||
|
$tokenAdmin = Login 1 |
||||
|
$tokenUnverified = Login 84 |
||||
|
|
||||
|
$pref = Api 'GET' '/profile/me/talent-discovery' $tokenA |
||||
|
Assert ($pref.Status -eq 200 -and $pref.Json.data.enabled -eq $true -and [int]$pref.Json.data.version -eq 0) 'student preference GET' |
||||
|
$updated = Api 'PUT' '/profile/me/talent-discovery' $tokenA @{ enabled = $false; expectedVersion = 0 } |
||||
|
Assert ($updated.Status -eq 200 -and $updated.Json.data.enabled -eq $false -and [int]$updated.Json.data.version -eq 1) 'student preference update/version' |
||||
|
$stale = Api 'PUT' '/profile/me/talent-discovery' $tokenA @{ enabled = $true; expectedVersion = 0 } |
||||
|
Assert ($stale.Status -eq 409 -and [int]$stale.Json.code -eq 9201) 'stale preference conflict 9201' |
||||
|
$restored = Api 'PUT' '/profile/me/talent-discovery' $tokenA @{ enabled = $true; expectedVersion = 1 } |
||||
|
Assert ($restored.Status -eq 200 -and $restored.Json.data.enabled -eq $true -and [int]$restored.Json.data.version -eq 2) 'preference restored' |
||||
|
$studentForbidden = Api 'GET' '/profile/me/talent-discovery' $tokenCompany |
||||
|
Assert ($studentForbidden.Status -eq 403 -and [int]$studentForbidden.Json.code -eq 2002) 'company denied student preference' |
||||
|
$anon = Api 'GET' '/profile/me/talent-discovery' |
||||
|
Assert ($anon.Status -eq 401) 'anonymous denied preference' |
||||
|
|
||||
|
$unavailable = Api 'GET' '/companies/me/jobs/7/talent-recommendations?page=0&size=20' $tokenCompany |
||||
|
Assert ($unavailable.Status -eq 200 -and $unavailable.Json.data.mode -eq 'UNAVAILABLE' -and [int]$unavailable.Json.data.total -eq 0) 'unavailable job profile' |
||||
|
$page = Api 'GET' '/companies/me/jobs/1/talent-recommendations?page=0&size=50' $tokenCompany |
||||
|
Assert ($page.Status -eq 200 -and $page.Json.data.mode -eq 'PERSONALIZED') 'personalized recommendations' |
||||
|
$items = @($page.Json.data.items) |
||||
|
$idsInRaw = $page.Raw |
||||
|
Assert ($items.Count -ge 3 -and [int]$page.Json.data.total -ge 3) 'eligible candidates returned' |
||||
|
$keys = @($items | ForEach-Object { $_.candidateKey }) |
||||
|
Assert ($keys -contains $null -eq $false -and ($keys | Select-Object -Unique).Count -eq $keys.Count) 'opaque candidate keys' |
||||
|
Assert (-not ($idsInRaw -match 'studentUserId|candidateId|fitScore|dimensionScores|overallScore|attemptId|resultId|answerKey|scoringRule|questionCode|competencyTag|resumeUrl|phone|email|gender|birthDate|password|token')) 'candidate JSON redacted' |
||||
|
$bands = @($items | ForEach-Object { $_.fitBand }) |
||||
|
Assert ($bands -contains 'BASIC') 'latest low ability beats historical high (BASIC)' |
||||
|
$signals = @($items | ForEach-Object { $_.signalsAvailable -join ',' }) |
||||
|
Assert (($signals -contains 'PREFERENCE') -and ($signals -contains 'ABILITY')) 'signals available' |
||||
|
$currentRaw = ($items | ConvertTo-Json -Depth 20 -Compress) |
||||
|
$skillText = ($items | ForEach-Object { $_.skills -join ',' }) -join ',' |
||||
|
Assert ($skillText -match 'V41BB-A' -and $skillText -match 'V41BB-C' -and $skillText -match 'V41BB-D' -and $skillText -notmatch 'V41BB-B') 'same-company application excluded; other-company retained' |
||||
|
Assert ($skillText -notmatch 'V41BB-E') 'pending account deletion excluded' |
||||
|
$dCard = @($items | Where-Object { ($_.skills -join ',') -match 'V41BB-D' })[0] |
||||
|
Assert ($null -ne $dCard -and ($dCard.signalsAvailable -join ',') -eq 'ABILITY' -and $dCard.fitBand -eq 'BASIC') 'latest low ability selected over historical high' |
||||
|
$p0 = Api 'GET' '/companies/me/jobs/1/talent-recommendations?page=0&size=1' $tokenCompany |
||||
|
$pLast = Api 'GET' ("/companies/me/jobs/1/talent-recommendations?page=" + ([int]$page.Json.data.total - 1) + '&size=1') $tokenCompany |
||||
|
Assert ($p0.Status -eq 200 -and $pLast.Status -eq 200 -and $p0.Json.data.items[0].fitBand -ne 'BASIC' -and $pLast.Json.data.items[0].fitBand -eq 'BASIC') 'global sort before pagination' |
||||
|
|
||||
|
$afterScores = [int](Sql 'SELECT COUNT(*) FROM candidate_dimension_scores')[0] |
||||
|
Assert ($afterScores -eq $baseScores) 'V41 does not write candidate_dimension_scores' |
||||
|
$countB = [int](Sql "SELECT COUNT(*) FROM applications WHERE student_user_id=$b AND job_id=1")[0] |
||||
|
$countC = [int](Sql "SELECT COUNT(*) FROM applications WHERE student_user_id=$c AND job_id=13")[0] |
||||
|
Assert ($countB -eq 1 -and $countC -eq 1) 'fixture historical applications recorded' |
||||
|
|
||||
|
$studentCompany = Api 'GET' '/companies/me/jobs/1/talent-recommendations' $tokenA |
||||
|
Assert ($studentCompany.Status -eq 403 -and [int]$studentCompany.Json.code -eq 2002) 'student denied company recommendations' |
||||
|
$adminCompany = Api 'GET' '/companies/me/jobs/1/talent-recommendations' $tokenAdmin |
||||
|
Assert ($adminCompany.Status -eq 403 -and [int]$adminCompany.Json.code -eq 2002) 'admin denied company recommendations' |
||||
|
$cross = Api 'GET' '/companies/me/jobs/13/talent-recommendations' $tokenCompany |
||||
|
Assert ($cross.Status -eq 403 -and [int]$cross.Json.code -eq 2002) 'cross-company job denied' |
||||
|
$unverified = Api 'GET' '/companies/me/jobs/13/talent-recommendations' $tokenUnverified |
||||
|
Assert ($unverified.Status -eq 403 -and [int]$unverified.Json.code -eq 2002) 'unverified company denied' |
||||
|
$anonCompany = Api 'GET' '/companies/me/jobs/1/talent-recommendations' |
||||
|
Assert ($anonCompany.Status -eq 401) 'anonymous denied company recommendations' |
||||
|
$badPage = Api 'GET' '/companies/me/jobs/1/talent-recommendations?page=-1&size=51' $tokenCompany |
||||
|
Assert ($badPage.Status -eq 400 -and [int]$badPage.Json.code -in @(400,1001)) 'page/size validation' |
||||
|
|
||||
|
$legacy = Api 'GET' '/profile/me/job-recommendations?page=0&size=1' $tokenA |
||||
|
Assert ($legacy.Status -eq 200) 'V39 job recommendation regression' |
||||
|
$queues = Rabbit |
||||
|
$known = @('queue.notification','queue.audit','queue.notification.dlq') |
||||
|
foreach ($q in $known) { |
||||
|
$line = @($queues | Where-Object { $_ -match "^$q\s" }) |
||||
|
if ($line.Count -eq 0) { Fail "Rabbit queue exists $q"; continue } |
||||
|
$fields = ($line[0] -split '\s+') |
||||
|
Assert ([int]$fields[-2] -eq 0 -and [int]$fields[-1] -eq 0) "Rabbit queue drained $q" |
||||
|
} |
||||
|
|
||||
|
$constraintRow = Sql "SELECT COUNT(*) FROM student_talent_discovery_preferences WHERE student_user_id=$a AND enabled=1" |
||||
|
Assert ([int]$constraintRow[0] -eq 1) 'preference persisted in MySQL' |
||||
|
Write-Output ("V41_BLACKBOX_PASS={0} FAIL={1}" -f $pass, $fail) |
||||
|
if ($fail -gt 0) { exit 1 } |
||||
|
} catch { |
||||
|
Fail 'blackbox execution' $_.Exception.Message |
||||
|
Write-Output ("V41_BLACKBOX_PASS={0} FAIL={1}" -f $pass, $fail) |
||||
|
exit 1 |
||||
|
} finally { |
||||
|
Cleanup |
||||
|
Pop-Location |
||||
|
} |
||||
@ -0,0 +1,53 @@ |
|||||
|
# V41 企业人才发现 API |
||||
|
|
||||
|
V41 提供学生显式授权和企业岗位候选人发现。学生授权默认关闭;企业必须已认证,岗位必须属于当前企业、处于 `PUBLISHED`,并且具有 V39 `enabled=true`、`APPROVED` 的岗位画像。 |
||||
|
|
||||
|
## 接口 |
||||
|
|
||||
|
| 方法 | 路径 | 权限 | |
||||
|
|---|---|---| |
||||
|
| GET | `/profile/me/talent-discovery` | STUDENT,仅本人 | |
||||
|
| PUT | `/profile/me/talent-discovery` | STUDENT,仅本人 | |
||||
|
| GET | `/companies/me/jobs/{jobId}/talent-recommendations` | 已认证 COMPANY,且岗位属于本人 | |
||||
|
|
||||
|
统一前缀为 `/api/v1`。身份从 JWT subject 获取,客户端不得传 `studentUserId`、`companyId` 或 `candidateId`。 |
||||
|
|
||||
|
## 学生开关 |
||||
|
|
||||
|
无记录的 GET 返回 `enabled=false、version=0`,不会自动写库。PUT 请求体: |
||||
|
|
||||
|
```json |
||||
|
{"enabled":true,"expectedVersion":0} |
||||
|
``` |
||||
|
|
||||
|
`expectedVersion` 可省略;已有记录传入后必须匹配当前版本。并发冲突复用 `409/9201`。成功只记录 `TALENT_DISCOVERY_PREFERENCE_UPDATED`,审计 details 仅包含 `enabled/version/requestId`。 |
||||
|
|
||||
|
## 企业候选列表 |
||||
|
|
||||
|
`page` 从 0 开始,`size` 为 1–50。岗位画像不可用时返回 `mode=UNAVAILABLE`;没有合格候选时返回 `mode=NO_ELIGIBLE_CANDIDATE`。正常返回 `mode=PERSONALIZED`。 |
||||
|
|
||||
|
候选池排除:未授权学生、注销限制账号、无基础档案、曾向当前企业任一岗位投递过的学生(包括 WITHDRAWN/REJECTED)。其他企业的投递不影响当前企业发现。 |
||||
|
|
||||
|
PREFERENCE/RIASEC/ABILITY 三个信号按 40/35/25 计算,缺失信号不按 0 处罚而重新归一化;至少一个有效信号才进入列表。RIASEC 和能力只读取最新完成结果,能力结果还必须未过期;不读取答案、题目、答案键、评分规则或标签。服务端先计算全部候选并全局排序,再分页,保证分页正确。 |
||||
|
|
||||
|
企业候选卡仅返回: |
||||
|
|
||||
|
```json |
||||
|
{ |
||||
|
"candidateKey":"…", |
||||
|
"candidateLabel":"开放人才候选人", |
||||
|
"city":"合肥", |
||||
|
"education":"本科", |
||||
|
"graduationYear":2027, |
||||
|
"skills":["Java","Spring Boot"], |
||||
|
"fitBand":"HIGH", |
||||
|
"summary":"岗位画像与候选人公开资料存在较高匹配度", |
||||
|
"signalsAvailable":["PREFERENCE","RIASEC","ABILITY"] |
||||
|
} |
||||
|
``` |
||||
|
|
||||
|
不返回学生 ID、姓名、联系方式、简历、测评分数、候选评分、结果/答卷 ID 或岗位画像原始权重。`candidateKey` 为 HMAC-SHA256、按公司/岗位/学生/日期桶隔离的不透明展示键,本版没有用它换取详情或联系方式的接口。 |
||||
|
|
||||
|
## 版本与边界 |
||||
|
|
||||
|
V41 新增 Flyway `V39__enterprise_talent_discovery.sql`,不修改 V1–V38,不写 `candidate_dimension_scores`,不新增 feature flag、ErrorCode、消息或推荐历史。OpenAPI 由 159/196 增至实际 **161/199**(两个唯一 path、三个 operation)。自动化测试 1461/1461 通过;真实 MySQL/Docker 黑盒已通过(`PASS=37 / FAIL=0`)。 |
||||
File diff suppressed because it is too large
@ -0,0 +1,366 @@ |
|||||
|
# V41 企业人才发现推荐——详细设计(编码前评审) |
||||
|
|
||||
|
> 状态:**已实现并通过自动化与真实 MySQL/Docker 黑盒验收**。自动化测试 1461/1461 通过,真实黑盒 `PASS=37 / FAIL=0`。本文是 V41 的唯一设计基线。 |
||||
|
|
||||
|
## 1. 目标与边界 |
||||
|
|
||||
|
### 1.1 目标 |
||||
|
|
||||
|
让已认证企业针对自己已发布的岗位,发现**主动开启人才发现**的学生候选人。系统用已批准的岗位画像和学生自愿提供的聚合信号做规则排序,返回数据最小化的候选人卡片。 |
||||
|
|
||||
|
这是一项“人才发现”能力,不替代投递、面试、Offer 或现有企业候选人看板: |
||||
|
|
||||
|
- 现有 `/company/candidates/**` 只展示已经投递的候选人; |
||||
|
- V41 只展示尚未向当前企业投递、且显式同意被发现的候选人; |
||||
|
- V41 不创建投递、不发送消息、不创建邀约,也不让企业获得联系方式或简历文件。 |
||||
|
|
||||
|
### 1.2 确认范围 |
||||
|
|
||||
|
1. 学生人才发现默认关闭,学生可随时自行开启或关闭; |
||||
|
2. 只有 `verified=true` 的 COMPANY,才可对其自有、`PUBLISHED` 的岗位查询; |
||||
|
3. 仅已启用且 ADMIN 审核通过的 V39 岗位画像可以计算推荐; |
||||
|
4. 复用 V39 的规则信号权重:学生偏好 40%、RIASEC 聚合兴趣 35%、四维能力聚合 25%; |
||||
|
5. 已向**当前企业任一岗位**投递过的学生均从候选池排除,包含已撤回或已拒绝的历史投递,避免重复触达; |
||||
|
6. 公司只得到匹配档位与安全概述,不得到 RIASEC、四维能力、候选评分、答卷、答案、题目或原始权重; |
||||
|
7. 本版没有企业私信、邀约、收藏/淘汰反馈、企业端联系方式、学生被动通知、AI 解释或推荐历史快照。 |
||||
|
|
||||
|
### 1.3 明确不做 |
||||
|
|
||||
|
- 不改变 V35/V36 的测评题库、提交、重测、有效期或审计语义; |
||||
|
- 不改变 V38 的候选评分写入、排名和企业评分卡; |
||||
|
- 不改变 V39 的“学生找岗位”推荐及其岗位画像审核流; |
||||
|
- 不改变现有 `resumeVisibility` / `contactVisible` 的含义; |
||||
|
- 不按性别、年龄、籍贯、手机号、邮箱、学校排名、姓名或头像作为排序信号; |
||||
|
- 不生成“录用概率”“胜任结论”“适合/不适合”之类确定性结论。 |
||||
|
|
||||
|
## 2. 现状核查与设计前提 |
||||
|
|
||||
|
| 项目 | 现状 | V41 处理 | |
||||
|
|---|---|---| |
||||
|
| 最新迁移 | V38 为企业评价官方回复 | V41 建议下一编号 `V39__enterprise_talent_discovery.sql` | |
||||
|
| V39 岗位画像 | `job_recommendation_profiles`,仅 `enabled=true` 且 `APPROVED` 可用 | 作为岗位侧唯一的结构化推荐输入 | |
||||
|
| 学生偏好 | `student_preferences` 含城市、行业、岗位类型、用工类型、远程与薪资期望 | 只用于内部偏好匹配,不原样返回企业 | |
||||
|
| 兴趣测评 | V35 RIASEC 聚合结果 | 仅可作为内部聚合信号,绝不下发维度分 | |
||||
|
| 能力测评 | V36 完整且未过期的聚合结果 | 仅可作为内部聚合信号,绝不下发维度分 | |
||||
|
| 简历隐私 | `resumeVisibility` 只控制简历可见性,默认 `PRIVATE` | 不复用为人才发现授权;V41 另设明确授权 | |
||||
|
| 企业候选人看板 | 仅已投递的 `application` | V41 只发现未向本企业投递过的候选人 | |
||||
|
|
||||
|
`resumeVisibility` 与“是否允许被人才发现”是不同目的的授权:前者控制简历,后者控制是否进入企业发现候选池。两者不可混用或隐式推导。 |
||||
|
|
||||
|
## 3. 角色、权限与隐私 |
||||
|
|
||||
|
### 3.1 权限矩阵 |
||||
|
|
||||
|
| 能力 | STUDENT | COMPANY(未认证) | COMPANY(认证且岗位所属) | ADMIN | 未登录 | |
||||
|
|---|---:|---:|---:|---:|---:| |
||||
|
| 查询/更新本人人才发现开关 | ✓ | 403/2002 | 403/2002 | 403/2002 | 401 | |
||||
|
| 查询岗位人才推荐 | 403/2002 | 403/2002 | ✓ | 403/2002 | 401 | |
||||
|
|
||||
|
所有身份从 `SecurityContext.getAuthentication().getName()` 的 JWT subject 推导。请求中不得接受 `companyId`、`studentUserId`、`candidateId` 或任何越权身份参数。 |
||||
|
|
||||
|
### 3.2 候选人可见性规则 |
||||
|
|
||||
|
一个学生进入某企业某岗位的候选池,必须同时满足: |
||||
|
|
||||
|
1. 用户角色为 `STUDENT`,账号未进入注销终态; |
||||
|
2. 存在 `student_talent_discovery_preferences` 且 `enabled=true`;无记录一律视为关闭; |
||||
|
3. 存在可用于候选卡的基础学生档案; |
||||
|
4. 与当前公司不存在任何历史 `applications` 记录; |
||||
|
5. 当前 COMPANY 已认证,目标岗位归属该 COMPANY 且为 `PUBLISHED`; |
||||
|
6. 目标岗位有 `APPROVED + enabled=true` 的 V39 岗位画像。 |
||||
|
|
||||
|
关闭开关后,后续查询立即不再返回该学生;不删除其档案、投递、测评、评分或审计历史。V41 首版不保存企业查询历史与候选人曝光历史,避免把“被浏览”固化为可追踪个人行为数据。 |
||||
|
|
||||
|
### 3.3 响应白名单 |
||||
|
|
||||
|
企业候选卡仅允许: |
||||
|
|
||||
|
```json |
||||
|
{ |
||||
|
"candidateKey": "tdc_opaque_token", |
||||
|
"candidateLabel": "开放人才候选人", |
||||
|
"city": "合肥", |
||||
|
"education": "本科", |
||||
|
"graduationYear": 2027, |
||||
|
"skills": ["Java", "Spring Boot"], |
||||
|
"fitBand": "HIGH", |
||||
|
"summary": "岗位画像与候选人公开资料存在较高匹配度", |
||||
|
"signalsAvailable": ["PREFERENCE", "RIASEC", "ABILITY"] |
||||
|
} |
||||
|
``` |
||||
|
|
||||
|
`candidateKey` 是服务端签名的、与公司和岗位绑定的短期不透明标识,只用于前端列表稳定 key;它不等于 `studentUserId`,也不能在本版换取详情、联系方式或简历。 |
||||
|
|
||||
|
企业响应禁止出现: |
||||
|
|
||||
|
- `studentUserId`、用户 ID、简历 ID、手机号、邮箱、住址、简历 URL; |
||||
|
- 姓名、头像、出生日期、性别、身份证明、学校排名; |
||||
|
- 测评 attempt/result ID、维度分、原始分、题目、答案、答案键、解析、评分规则; |
||||
|
- `candidate_dimension_scores` 明细、候选综合分、排名、百分位; |
||||
|
- 画像原始权重、能力期望、内部规则版本、SQL/审计数据。 |
||||
|
|
||||
|
学生端开关响应只返回 `enabled`、`version`、`createdAt`、`updatedAt`;不返回企业查询过该学生的记录。 |
||||
|
|
||||
|
## 4. 接口契约(提案) |
||||
|
|
||||
|
### 4.1 学生人才发现偏好 |
||||
|
|
||||
|
```http |
||||
|
GET /profile/me/talent-discovery |
||||
|
PUT /profile/me/talent-discovery |
||||
|
``` |
||||
|
|
||||
|
`GET` 无记录时返回默认值,不写库: |
||||
|
|
||||
|
```json |
||||
|
{ "enabled": false, "version": 0 } |
||||
|
``` |
||||
|
|
||||
|
`PUT` 请求: |
||||
|
|
||||
|
```json |
||||
|
{ "enabled": true, "expectedVersion": 0 } |
||||
|
``` |
||||
|
|
||||
|
- `enabled` 必填; |
||||
|
- `expectedVersion` 可选,传入时必须与当前版本一致; |
||||
|
- 首次创建仅接受 `expectedVersion` 缺失或 `0`; |
||||
|
- 并发冲突复用现有乐观锁错误 `9201`,不新增无必要错误码; |
||||
|
- 成功只写 `TALENT_DISCOVERY_PREFERENCE_UPDATED` 审计,details 仅有开关状态、版本、requestId 与时间。 |
||||
|
|
||||
|
### 4.2 企业岗位人才推荐 |
||||
|
|
||||
|
```http |
||||
|
GET /companies/me/jobs/{jobId}/talent-recommendations?page=0&size=20 |
||||
|
``` |
||||
|
|
||||
|
参数规范:`page` 从 0 起;`size` 为 1–50;`jobId > 0`。无候选人返回 HTTP 200: |
||||
|
|
||||
|
```json |
||||
|
{ |
||||
|
"mode": "NO_ELIGIBLE_CANDIDATE", |
||||
|
"items": [], |
||||
|
"total": 0, |
||||
|
"page": 0, |
||||
|
"size": 20, |
||||
|
"totalPages": 0, |
||||
|
"message": "暂未发现已授权且满足基础条件的候选人" |
||||
|
} |
||||
|
``` |
||||
|
|
||||
|
目标岗位缺少可用画像时也返回 HTTP 200,但明确: |
||||
|
|
||||
|
```json |
||||
|
{ |
||||
|
"mode": "UNAVAILABLE", |
||||
|
"items": [], |
||||
|
"message": "该岗位尚无审核通过的推荐画像" |
||||
|
} |
||||
|
``` |
||||
|
|
||||
|
不得把空候选池或不可用画像伪装成评分为 0 的推荐列表。 |
||||
|
|
||||
|
### 4.3 预计 OpenAPI |
||||
|
|
||||
|
新增 2 个唯一 path / 3 个 operation(学生 GET/PUT 共用同一 path): |
||||
|
|
||||
|
| 方法 | 路径 | 角色 | |
||||
|
|---|---|---| |
||||
|
| GET | `/profile/me/talent-discovery` | STUDENT | |
||||
|
| PUT | `/profile/me/talent-discovery` | STUDENT | |
||||
|
| GET | `/companies/me/jobs/{jobId}/talent-recommendations` | 已认证 COMPANY,且岗位归属本人 | |
||||
|
|
||||
|
以当前 159 paths / 196 operations 为基线,预计更新到 **161 paths / 199 operations**。三个 operation 只形成两个唯一 URL path;实际以运行时 `/v3/api-docs` 和静态 `openapi-rc.json` 的对账结果为准。 |
||||
|
|
||||
|
## 5. 数据模型与迁移 |
||||
|
|
||||
|
### 5.1 新迁移 |
||||
|
|
||||
|
建议新增、且只新增: |
||||
|
|
||||
|
```text |
||||
|
V39__enterprise_talent_discovery.sql |
||||
|
``` |
||||
|
|
||||
|
不修改 V1–V38 已执行迁移;不向 `candidate_dimension_scores` 写入任何数据。 |
||||
|
|
||||
|
### 5.2 `student_talent_discovery_preferences` |
||||
|
|
||||
|
| 字段 | 类型/约束 | 含义 | |
||||
|
|---|---|---| |
||||
|
| `id` | BIGINT PK | 主键 | |
||||
|
| `student_user_id` | BIGINT NOT NULL UNIQUE,FK `users(id)` | 仅本人一条授权记录 | |
||||
|
| `enabled` | TINYINT NOT NULL DEFAULT 0 | 人才发现显式授权 | |
||||
|
| `version` | BIGINT NOT NULL DEFAULT 0 | 乐观锁 | |
||||
|
| `created_at` | DATETIME NOT NULL | 创建时间 | |
||||
|
| `updated_at` | DATETIME NOT NULL | 最后更新 | |
||||
|
|
||||
|
索引:`(enabled, student_user_id)`。关闭的记录不参与候选查询;不存在记录等效关闭。 |
||||
|
|
||||
|
不存:公司 ID、岗位 ID、企业浏览记录、联系方式、简历路径、测评细节、候选评分或算法分。 |
||||
|
|
||||
|
## 6. 规则计算与数据一致性 |
||||
|
|
||||
|
### 6.1 capturedNow |
||||
|
|
||||
|
企业一次查询在服务起点生成唯一 `capturedNow`。同一次计算的能力有效期、候选过滤与输出必须复用该值,避免边界时刻同页结果不一致。 |
||||
|
|
||||
|
### 6.2 信号选择 |
||||
|
|
||||
|
对每位候选人仅读取以下最小聚合数据: |
||||
|
|
||||
|
| 信号 | 有效条件 | 不可用处理 | |
||||
|
|---|---|---| |
||||
|
| 偏好 P | 可解析的城市、行业、岗位类型、用工类型、远程或薪资偏好 | 该信号不计入分母 | |
||||
|
| 兴趣 I | 最新 `HOLLAND_RIASEC`、`COMPLETED`,按 `completed_at DESC, attempt_id DESC` 取一条,六维完整且 0–100 | 不计入分母 | |
||||
|
| 能力 A | 最新 `WORKPLACE_ABILITY`、`COMPLETED`、`valid_until > capturedNow`,四维完整、每维 6 题、总计 24 题且 0–100 | 不计入分母 | |
||||
|
|
||||
|
禁止取历史最高分。JSON 损坏、缺维、越界、空值、重复维或无效时间都只能令该信号不可用,不能造成 HTTP 500。 |
||||
|
|
||||
|
### 6.3 分数与排序 |
||||
|
|
||||
|
仅当至少一项可用信号存在时,计算: |
||||
|
|
||||
|
```text |
||||
|
fitScore = |
||||
|
(P × 40 + I × 35 + A × 25) / 可用信号权重之和 |
||||
|
``` |
||||
|
|
||||
|
中间与最终结果使用 `BigDecimal`,最终保留两位、`HALF_UP`。`fitScore` 仅用于后端排序与映射档位,不对企业返回数值。 |
||||
|
|
||||
|
| 匹配档位 | 内部区间 | 企业展示 | |
||||
|
|---|---:|---| |
||||
|
| HIGH | ≥ 75 | 较高匹配度 | |
||||
|
| MEDIUM | 60–74.99 | 中等匹配度 | |
||||
|
| BASIC | < 60 | 基础匹配度 | |
||||
|
|
||||
|
排序:`fitScore DESC` → 候选档案最后更新时间 DESC → `student_user_id DESC`。最后一项只用于稳定排序,不对外暴露。 |
||||
|
|
||||
|
若没有任何有效信号,候选人不进入 V41 首版列表;不使用学校、姓名、性别等字段做“兜底排序”。 |
||||
|
|
||||
|
### 6.4 安全概述 |
||||
|
|
||||
|
`summary` 固定来自模板,只允许说明总体匹配等级。`signalsAvailable` 仅披露存在的类别 `PREFERENCE` / `RIASEC` / `ABILITY`,不披露任何单项高低、维度或权重。详细理由与可解释反馈明确留给后续“推荐理由和解释”模块评审。 |
||||
|
|
||||
|
## 7. 查询、性能与并发 |
||||
|
|
||||
|
### 7.1 查询策略 |
||||
|
|
||||
|
V41 第一版采用**实时计算、全局排序后分页**:不能先在数据库分页、只对当前页计算分数再排序,否则第 2 页候选人可能比第 1 页分数更高,分页结果会错误。 |
||||
|
|
||||
|
1. 先用一次批量资格查询读取全部候选 ID 与必要的基础档案字段:授权开启、`STUDENT`、档案存在、账号未处于注销限制状态、与当前公司不存在任何历史投递;历史投递通过 `NOT EXISTS` 或批量集合排除,不能只排除当前岗位,也不能按候选人逐条查询。 |
||||
|
2. 按固定批次(建议每批 500 个 ID)批量读取学生偏好、最新可用 RIASEC、最新有效能力结果和展示档案;候选 ID 必须使用参数绑定,禁止拼接 SQL。 |
||||
|
3. 服务层以内存 Map 汇总每个候选人的信号,计算所有候选人的 `fitScore`,再按 `fitScore DESC`、档案 `updatedAt DESC`、`studentUserId DESC` 做全局排序,最后执行 `page/size` 切片。 |
||||
|
|
||||
|
这是第一版实时方案;当候选池达到规模阈值,应升级为异步推荐索引/快照,但不能以页内排序牺牲当前版本的全局排序正确性。查询需预留真实 MySQL `EXPLAIN` 验收:`student_user_id`、`definition_id`、`completed_at`、`valid_until` 与授权开关索引均应参与过滤。 |
||||
|
|
||||
|
### 7.2 并发 |
||||
|
|
||||
|
人才推荐列表是只读、实时计算,无持久化推荐记录,故无创建推荐的并发写入问题。学生开关通过 `@Version + expectedVersion` 控制并发,两个竞争更新只能有一个成功;另一个返回 `409/9201`。 |
||||
|
|
||||
|
查询过程中学生关闭开关的竞态允许下一次请求生效;同一已开始的只读事务可在当前页继续返回当时快照,不延迟或阻塞用户操作。 |
||||
|
|
||||
|
## 8. 错误语义与审计 |
||||
|
|
||||
|
### 8.1 错误 |
||||
|
|
||||
|
| 场景 | HTTP / 业务码 | |
||||
|
|---|---| |
||||
|
| 非认证 | 401 | |
||||
|
| 角色不符 | 403 / 2002 | |
||||
|
| 企业不存在 | 404 / 5001 | |
||||
|
| 岗位不存在/非发布 | 复用既有岗位错误码 | |
||||
|
| 岗位不属于当前企业 | 403 / 2002(不泄露额外信息) | |
||||
|
| 企业未认证 | 403 / 2002 | |
||||
|
| page/size/jobId 非法 | 400 / 1001 | |
||||
|
| 学生开关版本冲突 | 409 / 9201 | |
||||
|
|
||||
|
本版不新增 V41 专属错误码。`UNAVAILABLE` 和 `NO_ELIGIBLE_CANDIDATE` 是成功响应模式,不是错误。 |
||||
|
|
||||
|
### 8.2 审计 |
||||
|
|
||||
|
仅学生开关写审计: |
||||
|
|
||||
|
```json |
||||
|
{ |
||||
|
"enabled": true, |
||||
|
"version": 2, |
||||
|
"requestId": "..." |
||||
|
} |
||||
|
``` |
||||
|
|
||||
|
`GET` 推荐列表不写逐候选人或逐企业浏览审计,避免形成候选人追踪链路。不得审计候选人清单、matchScore、信号细节、学生 ID、联系方式、简历地址、答案、IP、User-Agent、token 或密码。 |
||||
|
|
||||
|
## 9. 安全与公平性门禁 |
||||
|
|
||||
|
1. 学生显式同意是进入企业发现池的前置条件;默认关闭; |
||||
|
2. 企业必须认证、岗位必须属于自己且公开发布; |
||||
|
3. 推荐不是自动淘汰或录用依据,企业仍需在后续合法招聘流程中自行评估; |
||||
|
4. 不使用受保护特征或联系方式排序; |
||||
|
5. 任何测评聚合仅在学生自愿进入发现池后作为内部排序信号;企业侧不见测评详情; |
||||
|
6. 若后续加入解释、邀约、反馈或模型训练,必须单列模块并重新做隐私、风控和产品评审。 |
||||
|
|
||||
|
## 10. 建议代码结构 |
||||
|
|
||||
|
```text |
||||
|
com.mydreamjob.talentdiscovery |
||||
|
StudentTalentDiscoveryPreference |
||||
|
StudentTalentDiscoveryPreferenceRepository |
||||
|
TalentDiscoveryService |
||||
|
TalentDiscoveryCandidateSource # 批量只读数据来源 |
||||
|
TalentDiscoveryScoreCalculator # 纯函数,无数据库访问 |
||||
|
TalentDiscoveryController |
||||
|
dto/ |
||||
|
``` |
||||
|
|
||||
|
允许复用 V39 的安全 JSON 解析与岗位画像校验,但不得复制 Controller 中的权限逻辑或把 V41 放入 V39 Controller 而扩大其职责。 |
||||
|
|
||||
|
## 11. 测试与验收方案 |
||||
|
|
||||
|
### 11.1 自动化测试 |
||||
|
|
||||
|
1. `TalentDiscoveryScoreCalculatorTest` |
||||
|
- 三信号权重与缺项重归一化; |
||||
|
- 最新低分优先于历史高分; |
||||
|
- 能力恰在 `capturedNow` 过期; |
||||
|
- 缺维、非法 JSON、越界均不 500; |
||||
|
- 不使用姓名、性别、学校或联系方式作为输入。 |
||||
|
2. `TalentDiscoveryServiceTest` |
||||
|
- 默认关闭不入池;开启后入池;关闭后立即排除; |
||||
|
- 当前企业任一历史投递均排除;其他企业投递不排除; |
||||
|
- 未认证企业、跨企业岗位、无审核画像、未发布岗位; |
||||
|
- 候选卡白名单;无数据模式正确;N+1 查询次数固定。 |
||||
|
3. `TalentDiscoveryControllerTest` |
||||
|
- STUDENT/COMPANY/ADMIN/未登录矩阵; |
||||
|
- 分页校验;不泄露 `studentUserId`、联系方式、测评字段或分数; |
||||
|
- 学生开关 PUT 乐观锁冲突。 |
||||
|
4. `TalentDiscoveryPersistenceIntegrationTest` |
||||
|
- V39 迁移、唯一约束、FK、默认关闭、`@Version` 冲突; |
||||
|
- 批量查询与已投递排除在 H2 MySQL 模式工作。 |
||||
|
|
||||
|
### 11.2 真实 MySQL/Docker 黑盒 |
||||
|
|
||||
|
1. V39 迁移成功且无影响 V1–V38; |
||||
|
2. 默认关闭、开启、关闭后的立即可见性变化; |
||||
|
3. 已认证/未认证企业、岗位所有权、PUBLISHED 与已审核画像门禁; |
||||
|
4. 同企业历史投递排除、其他企业投递不误排; |
||||
|
5. 最新低分优先、能力过期、无有效信号和无候选人模式; |
||||
|
6. 响应脱敏、审计不写候选清单; |
||||
|
7. `EXPLAIN` 与常数批量查询验证; |
||||
|
8. RabbitMQ 队列终态与 V39/V40 关键接口回归。 |
||||
|
|
||||
|
## 12. 文档、OpenAPI 与发布门禁 |
||||
|
|
||||
|
编码后更新: |
||||
|
|
||||
|
- `docs/enterprise-talent-discovery-api.md`; |
||||
|
- `docs/frontend-integration.md`; |
||||
|
- `docs/backend-development-roadmap.md`; |
||||
|
- `docs/openapi-rc.json`(V41 实际 161 paths / 199 operations,两个唯一 path 承载三个 operation); |
||||
|
- `docs/verify_openapi.py`。 |
||||
|
|
||||
|
发布门禁已完成:专项与全量自动化测试通过(1461/1461),OpenAPI 静态/运行时均为 161 paths / 199 operations,真实 MySQL/Docker 黑盒 `PASS=37 / FAIL=0`。黑盒覆盖 V39 迁移、授权状态机、最新版测评选择、同企业历史投递排除、全局排序后分页、权限与脱敏、队列终态及 V39 回归。 |
||||
|
|
||||
|
## 13. 编码前确认结论 |
||||
|
|
||||
|
负责人已确认本设计的业务范围。进入编码前还必须按本设计完成只读核查:最新 Flyway 为 V38、V39 岗位画像审核字段存在、认证企业判断可用、学生档案字段可满足候选卡白名单;如任一项与当前代码冲突,停止并报告,不自行改变接口、迁移号、隐私边界或排序口径。 |
||||
@ -0,0 +1,35 @@ |
|||||
|
package com.mydreamjob.talentdiscovery; |
||||
|
|
||||
|
import org.springframework.beans.factory.annotation.Value; |
||||
|
import org.springframework.stereotype.Component; |
||||
|
|
||||
|
import javax.crypto.Mac; |
||||
|
import javax.crypto.spec.SecretKeySpec; |
||||
|
import java.nio.charset.StandardCharsets; |
||||
|
import java.time.LocalDate; |
||||
|
import java.util.Base64; |
||||
|
|
||||
|
/** 生成与公司、岗位和日期桶绑定的不可逆候选人展示键。 */ |
||||
|
@Component |
||||
|
public class CandidateKeyGenerator { |
||||
|
private static final String HMAC = "HmacSHA256"; |
||||
|
private final byte[] secret; |
||||
|
|
||||
|
public CandidateKeyGenerator(@Value("${app.jwt.secret:}") String configuredSecret) { |
||||
|
this.secret = (configuredSecret == null ? "" : configuredSecret).getBytes(StandardCharsets.UTF_8); |
||||
|
} |
||||
|
|
||||
|
public String generate(Long companyId, Long jobId, Long studentUserId, LocalDate capturedDate) { |
||||
|
if (companyId == null || jobId == null || studentUserId == null || capturedDate == null || secret.length == 0) { |
||||
|
throw new IllegalArgumentException("candidate key parameters are invalid"); |
||||
|
} |
||||
|
String message = "talent-discovery:v1|" + companyId + "|" + jobId + "|" + studentUserId + "|" + capturedDate; |
||||
|
try { |
||||
|
Mac mac = Mac.getInstance(HMAC); |
||||
|
mac.init(new SecretKeySpec(secret, HMAC)); |
||||
|
return Base64.getUrlEncoder().withoutPadding().encodeToString(mac.doFinal(message.getBytes(StandardCharsets.UTF_8))); |
||||
|
} catch (Exception e) { |
||||
|
throw new IllegalStateException("unable to generate candidate key", e); |
||||
|
} |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,57 @@ |
|||||
|
package com.mydreamjob.talentdiscovery; |
||||
|
|
||||
|
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; |
||||
|
|
||||
|
/** 学生对“被企业发现”的显式授权。无记录等价于关闭。 */ |
||||
|
@Data |
||||
|
@Builder |
||||
|
@NoArgsConstructor |
||||
|
@AllArgsConstructor |
||||
|
@Entity |
||||
|
@Table(name = "student_talent_discovery_preferences") |
||||
|
@EntityListeners(AuditingEntityListener.class) |
||||
|
public class StudentTalentDiscoveryPreference { |
||||
|
|
||||
|
@Id |
||||
|
@GeneratedValue(strategy = GenerationType.IDENTITY) |
||||
|
private Long id; |
||||
|
|
||||
|
@Column(name = "student_user_id", nullable = false, unique = true) |
||||
|
private Long studentUserId; |
||||
|
|
||||
|
@JdbcTypeCode(SqlTypes.TINYINT) |
||||
|
@Column(nullable = false) |
||||
|
@Builder.Default |
||||
|
private Boolean enabled = false; |
||||
|
|
||||
|
@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,17 @@ |
|||||
|
package com.mydreamjob.talentdiscovery; |
||||
|
|
||||
|
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 StudentTalentDiscoveryPreferenceRepository |
||||
|
extends JpaRepository<StudentTalentDiscoveryPreference, Long> { |
||||
|
|
||||
|
Optional<StudentTalentDiscoveryPreference> findByStudentUserId(Long studentUserId); |
||||
|
|
||||
|
List<StudentTalentDiscoveryPreference> findByStudentUserIdInAndEnabledTrue(Collection<Long> studentUserIds); |
||||
|
} |
||||
@ -0,0 +1,215 @@ |
|||||
|
package com.mydreamjob.talentdiscovery; |
||||
|
|
||||
|
import com.fasterxml.jackson.core.type.TypeReference; |
||||
|
import com.fasterxml.jackson.databind.ObjectMapper; |
||||
|
import lombok.RequiredArgsConstructor; |
||||
|
import org.springframework.jdbc.core.JdbcTemplate; |
||||
|
import org.springframework.stereotype.Component; |
||||
|
|
||||
|
import java.math.BigDecimal; |
||||
|
import java.sql.Timestamp; |
||||
|
import java.time.LocalDateTime; |
||||
|
import java.util.ArrayList; |
||||
|
import java.util.Collection; |
||||
|
import java.util.Collections; |
||||
|
import java.util.HashMap; |
||||
|
import java.util.HashSet; |
||||
|
import java.util.LinkedHashMap; |
||||
|
import java.util.List; |
||||
|
import java.util.Map; |
||||
|
import java.util.Set; |
||||
|
|
||||
|
/** |
||||
|
* V41 的批量只读数据源。这里集中保存资格 SQL 与聚合结果读取,Controller/Service 不执行 SQL。 |
||||
|
* 所有 ID 都通过 JDBC 参数绑定;仅动态生成与参数数量相同的 ? 占位符。 |
||||
|
*/ |
||||
|
@Component |
||||
|
@RequiredArgsConstructor |
||||
|
public class TalentDiscoveryCandidateSource { |
||||
|
|
||||
|
private static final Set<String> RIASEC_DIMENSIONS = Set.of("R", "I", "A", "S", "E", "C"); |
||||
|
private static final Set<String> ABILITY_DIMENSIONS = Set.of("LOGIC", "COMMUNICATION", "EXECUTION", "LEARNING"); |
||||
|
private static final TypeReference<List<Map<String, Object>>> LIST = new TypeReference<>() { }; |
||||
|
private static final TypeReference<List<String>> STRINGS = new TypeReference<>() { }; |
||||
|
|
||||
|
private final JdbcTemplate jdbcTemplate; |
||||
|
private final ObjectMapper objectMapper; |
||||
|
|
||||
|
/** |
||||
|
* 读取全部合格候选,不做数据库分页。账号注销限制和当前企业任意历史投递均在 SQL 中排除。 |
||||
|
*/ |
||||
|
public List<CandidateSeed> findEligibleCandidates(Long companyId) { |
||||
|
if (companyId == null) return List.of(); |
||||
|
String sql = """ |
||||
|
SELECT u.id AS student_user_id, p.location, p.education, p.graduation_year, |
||||
|
p.skills_json, p.updated_at |
||||
|
FROM users u |
||||
|
JOIN student_talent_discovery_preferences td ON td.student_user_id = u.id AND td.enabled = 1 |
||||
|
JOIN student_profiles p ON p.user_id = u.id |
||||
|
WHERE u.role = 'STUDENT' |
||||
|
AND u.is_active = 1 |
||||
|
AND NOT EXISTS ( |
||||
|
SELECT 1 FROM account_deletions ad |
||||
|
WHERE ad.user_id = u.id AND ad.status IN ('PENDING', 'COMPLETED') |
||||
|
) |
||||
|
AND NOT EXISTS ( |
||||
|
SELECT 1 FROM applications a |
||||
|
JOIN jobs j ON j.id = a.job_id |
||||
|
WHERE a.student_user_id = u.id AND j.company_id = ? |
||||
|
) |
||||
|
ORDER BY p.updated_at DESC, u.id DESC |
||||
|
"""; |
||||
|
return jdbcTemplate.query(sql, (rs, rowNum) -> new CandidateSeed( |
||||
|
rs.getLong("student_user_id"), |
||||
|
rs.getString("location"), |
||||
|
rs.getString("education"), |
||||
|
rs.getObject("graduation_year", Short.class) == null ? null : rs.getObject("graduation_year", Short.class).intValue(), |
||||
|
rs.getString("skills_json"), |
||||
|
timestamp(rs.getTimestamp("updated_at"))), companyId); |
||||
|
} |
||||
|
|
||||
|
/** 批量读取偏好,一次调用对应一条带 IN 参数的 SQL。 */ |
||||
|
public Map<Long, PreferenceSignal> findPreferences(Collection<Long> studentIds) { |
||||
|
if (studentIds == null || studentIds.isEmpty()) return Map.of(); |
||||
|
List<Long> ids = distinctIds(studentIds); |
||||
|
String sql = "SELECT student_user_id, expected_salary_min, expected_salary_max, preferred_cities, employment_types, " |
||||
|
+ "preferred_industries, preferred_job_types, remote_allowed FROM student_preferences WHERE student_user_id IN (" |
||||
|
+ placeholders(ids.size()) + ")"; |
||||
|
Object[] args = ids.toArray(); |
||||
|
Map<Long, PreferenceSignal> out = new HashMap<>(); |
||||
|
jdbcTemplate.query(sql, rs -> { |
||||
|
Long id = rs.getLong("student_user_id"); |
||||
|
out.put(id, new PreferenceSignal( |
||||
|
rs.getObject("expected_salary_min", Integer.class), |
||||
|
rs.getObject("expected_salary_max", Integer.class), |
||||
|
readStrings(rs.getString("preferred_cities")), |
||||
|
readStrings(rs.getString("employment_types")), |
||||
|
readStrings(rs.getString("preferred_industries")), |
||||
|
readStrings(rs.getString("preferred_job_types")), |
||||
|
bool(rs.getObject("remote_allowed")))); |
||||
|
}, args); |
||||
|
return out; |
||||
|
} |
||||
|
|
||||
|
/** 批量读取每位学生最新一份 RIASEC 结果;先按时间选一条,再解析,禁止取历史最高。 */ |
||||
|
public Map<Long, ScoreSignal> findLatestRiasec(Collection<Long> studentIds) { |
||||
|
if (studentIds == null || studentIds.isEmpty()) return Map.of(); |
||||
|
List<Long> ids = distinctIds(studentIds); |
||||
|
String sql = "SELECT a.student_user_id, a.id AS attempt_id, a.completed_at, r.answered_count, r.dimension_scores_text " |
||||
|
+ "FROM assessment_results r JOIN assessment_attempts a ON a.id=r.attempt_id " |
||||
|
+ "JOIN assessment_definitions d ON d.id=a.definition_id " |
||||
|
+ "WHERE a.student_user_id IN (" + placeholders(ids.size()) + ") " |
||||
|
+ "AND a.status='COMPLETED' AND d.type_code='HOLLAND_RIASEC' " |
||||
|
+ "ORDER BY a.student_user_id, a.completed_at DESC, a.id DESC"; |
||||
|
Map<Long, ScoreSignal> out = new LinkedHashMap<>(); |
||||
|
jdbcTemplate.query(sql, rs -> { |
||||
|
Long id = rs.getLong("student_user_id"); |
||||
|
if (!out.containsKey(id)) { |
||||
|
out.put(id, new ScoreSignal(parseScores(rs.getString("dimension_scores_text"), RIASEC_DIMENSIONS, |
||||
|
rs.getObject("answered_count", Integer.class), 30), |
||||
|
timestamp(rs.getTimestamp("completed_at")), null)); |
||||
|
} |
||||
|
}, ids.toArray()); |
||||
|
return out; |
||||
|
} |
||||
|
|
||||
|
/** 批量读取每位学生最新、尚未过期的能力结果;valid_until 等于 capturedNow 不可用。 */ |
||||
|
public Map<Long, ScoreSignal> findLatestAbility(Collection<Long> studentIds, LocalDateTime capturedNow) { |
||||
|
if (studentIds == null || studentIds.isEmpty() || capturedNow == null) return Map.of(); |
||||
|
List<Long> ids = distinctIds(studentIds); |
||||
|
String sql = "SELECT a.student_user_id, a.id AS attempt_id, a.completed_at, a.answered_count, r.valid_until, r.dimension_scores_json " |
||||
|
+ "FROM ability_results r JOIN ability_attempts a ON a.id=r.attempt_id " |
||||
|
+ "JOIN ability_definitions d ON d.id=a.definition_id " |
||||
|
+ "WHERE a.student_user_id IN (" + placeholders(ids.size()) + ") " |
||||
|
+ "AND a.status='COMPLETED' AND d.type_code='WORKPLACE_ABILITY' AND r.valid_until > ? " |
||||
|
+ "ORDER BY a.student_user_id, a.completed_at DESC, a.id DESC"; |
||||
|
List<Object> args = new ArrayList<>(ids); |
||||
|
args.add(capturedNow); |
||||
|
Map<Long, ScoreSignal> out = new LinkedHashMap<>(); |
||||
|
jdbcTemplate.query(sql, rs -> { |
||||
|
Long id = rs.getLong("student_user_id"); |
||||
|
if (!out.containsKey(id)) { |
||||
|
out.put(id, new ScoreSignal(parseScores(rs.getString("dimension_scores_json"), ABILITY_DIMENSIONS, |
||||
|
rs.getObject("answered_count", Integer.class), 24), |
||||
|
timestamp(rs.getTimestamp("completed_at")), timestamp(rs.getTimestamp("valid_until")))); |
||||
|
} |
||||
|
}, args.toArray()); |
||||
|
return out; |
||||
|
} |
||||
|
|
||||
|
private Map<String, BigDecimal> parseScores(String raw, Set<String> expected, Integer totalAnswered, int requiredTotal) { |
||||
|
if (raw == null || raw.isBlank() || totalAnswered == null || totalAnswered != requiredTotal) return Map.of(); |
||||
|
try { |
||||
|
List<Map<String, Object>> rows = objectMapper.readValue(raw, LIST); |
||||
|
if (rows == null || rows.size() != expected.size()) return Map.of(); |
||||
|
Map<String, BigDecimal> out = new LinkedHashMap<>(); |
||||
|
for (Map<String, Object> row : rows) { |
||||
|
// V35 RIASEC 持久化字段名是 dimension;V36 能力结果字段名是 code。
|
||||
|
// 按测评实际 JSON 结构选择字段,禁止把任意字段静默当成维度。
|
||||
|
Object codeValue = expected.equals(ABILITY_DIMENSIONS) |
||||
|
? row.get("code") |
||||
|
: (row.containsKey("dimension") ? row.get("dimension") : row.get("code")); |
||||
|
if (!(codeValue instanceof String code) || !expected.contains(code) || out.containsKey(code)) return Map.of(); |
||||
|
BigDecimal score = decimal(row.get("score")); |
||||
|
if (score == null || score.compareTo(BigDecimal.ZERO) < 0 || score.compareTo(BigDecimal.valueOf(100)) > 0) return Map.of(); |
||||
|
if (expected.equals(ABILITY_DIMENSIONS)) { |
||||
|
Integer perDimension = exactInteger(row.get("answeredCount")); |
||||
|
if (perDimension == null || perDimension != 6) return Map.of(); |
||||
|
} |
||||
|
out.put(code, score); |
||||
|
} |
||||
|
return out.keySet().equals(expected) ? out : Map.of(); |
||||
|
} catch (Exception ignored) { |
||||
|
return Map.of(); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
private List<String> readStrings(String raw) { |
||||
|
if (raw == null || raw.isBlank()) return List.of(); |
||||
|
try { |
||||
|
List<String> values = objectMapper.readValue(raw, STRINGS); |
||||
|
if (values == null) return List.of(); |
||||
|
return values.stream().filter(v -> v != null && !v.trim().isEmpty()).map(String::trim).toList(); |
||||
|
} catch (Exception ignored) { |
||||
|
return List.of(); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
private BigDecimal decimal(Object value) { |
||||
|
if (value == null) return null; |
||||
|
try { return new BigDecimal(String.valueOf(value)); } catch (Exception e) { return null; } |
||||
|
} |
||||
|
|
||||
|
private Integer exactInteger(Object value) { |
||||
|
BigDecimal number = decimal(value); |
||||
|
if (number == null) return null; |
||||
|
try { return number.stripTrailingZeros().intValueExact(); } catch (ArithmeticException e) { return null; } |
||||
|
} |
||||
|
|
||||
|
private boolean bool(Object value) { |
||||
|
if (value instanceof Boolean b) return b; |
||||
|
if (value instanceof Number n) return n.intValue() != 0; |
||||
|
return "true".equalsIgnoreCase(String.valueOf(value)) || "1".equals(String.valueOf(value)); |
||||
|
} |
||||
|
|
||||
|
private static LocalDateTime timestamp(Timestamp timestamp) { |
||||
|
return timestamp == null ? null : timestamp.toLocalDateTime(); |
||||
|
} |
||||
|
|
||||
|
private static List<Long> distinctIds(Collection<Long> ids) { |
||||
|
return ids.stream().filter(java.util.Objects::nonNull).distinct().toList(); |
||||
|
} |
||||
|
|
||||
|
private static String placeholders(int count) { |
||||
|
return String.join(",", Collections.nCopies(count, "?")); |
||||
|
} |
||||
|
|
||||
|
public record CandidateSeed(Long studentUserId, String city, String education, Integer graduationYear, |
||||
|
String skillsJson, LocalDateTime profileUpdatedAt) { } |
||||
|
|
||||
|
public record PreferenceSignal(Integer expectedSalaryMin, Integer expectedSalaryMax, List<String> cities, |
||||
|
List<String> employmentTypes, List<String> industries, List<String> jobTypes, |
||||
|
boolean remoteAllowed) { } |
||||
|
|
||||
|
public record ScoreSignal(Map<String, BigDecimal> scores, LocalDateTime completedAt, LocalDateTime validUntil) { } |
||||
|
} |
||||
@ -0,0 +1,71 @@ |
|||||
|
package com.mydreamjob.talentdiscovery; |
||||
|
|
||||
|
import com.mydreamjob.audit.AuditContext; |
||||
|
import com.mydreamjob.common.api.ApiResponse; |
||||
|
import com.mydreamjob.common.exception.BusinessException; |
||||
|
import com.mydreamjob.common.exception.ErrorCode; |
||||
|
import com.mydreamjob.talentdiscovery.dto.TalentDiscoveryPageResponse; |
||||
|
import com.mydreamjob.talentdiscovery.dto.TalentDiscoveryPreferenceResponse; |
||||
|
import com.mydreamjob.talentdiscovery.dto.TalentDiscoveryPreferenceUpdateRequest; |
||||
|
import io.swagger.v3.oas.annotations.Operation; |
||||
|
import io.swagger.v3.oas.annotations.media.Content; |
||||
|
import io.swagger.v3.oas.annotations.media.Schema; |
||||
|
import io.swagger.v3.oas.annotations.responses.ApiResponses; |
||||
|
import io.swagger.v3.oas.annotations.security.SecurityRequirement; |
||||
|
import io.swagger.v3.oas.annotations.tags.Tag; |
||||
|
import jakarta.servlet.http.HttpServletRequest; |
||||
|
import jakarta.validation.Valid; |
||||
|
import lombok.RequiredArgsConstructor; |
||||
|
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.PutMapping; |
||||
|
import org.springframework.web.bind.annotation.RequestBody; |
||||
|
import org.springframework.web.bind.annotation.RequestParam; |
||||
|
import org.springframework.web.bind.annotation.PathVariable; |
||||
|
import org.springframework.web.bind.annotation.RestController; |
||||
|
|
||||
|
/** V41 学生授权与企业人才发现接口;身份只从 SecurityContext 获取。 */ |
||||
|
@RestController |
||||
|
@RequiredArgsConstructor |
||||
|
@Tag(name = "企业人才发现", description = "学生显式授权后,认证企业按自有岗位发现匿名候选人") |
||||
|
public class TalentDiscoveryController { |
||||
|
private final TalentDiscoveryService service; |
||||
|
|
||||
|
@GetMapping("/profile/me/talent-discovery") |
||||
|
@Operation(summary = "查询人才发现授权", security = @SecurityRequirement(name = "bearerAuth")) |
||||
|
@ApiResponses(@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", content = @Content(schema = @Schema(ref = "#/components/schemas/ApiResponseTalentDiscoveryPreferenceResponse")))) |
||||
|
public ApiResponse<TalentDiscoveryPreferenceResponse> getPreference(HttpServletRequest request) { |
||||
|
return ApiResponse.ok(service.getPreference(userId()), requestId(request)); |
||||
|
} |
||||
|
|
||||
|
@PutMapping("/profile/me/talent-discovery") |
||||
|
@Operation(summary = "更新人才发现授权", description = "仅当前学生本人可更新;expectedVersion 用于乐观锁并发控制。", security = @SecurityRequirement(name = "bearerAuth")) |
||||
|
@ApiResponses(@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", content = @Content(schema = @Schema(ref = "#/components/schemas/ApiResponseTalentDiscoveryPreferenceResponse")))) |
||||
|
public ApiResponse<TalentDiscoveryPreferenceResponse> updatePreference( |
||||
|
@Valid @RequestBody TalentDiscoveryPreferenceUpdateRequest body, HttpServletRequest request) { |
||||
|
return ApiResponse.ok(service.updatePreference(userId(), body, AuditContext.from(request)), requestId(request)); |
||||
|
} |
||||
|
|
||||
|
@GetMapping("/companies/me/jobs/{jobId}/talent-recommendations") |
||||
|
@Operation(summary = "查询岗位人才发现推荐", description = "仅已认证企业可查询本人已发布且具备审核通过画像的岗位;候选卡不包含学生身份或测评详情。", security = @SecurityRequirement(name = "bearerAuth")) |
||||
|
@ApiResponses(@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", content = @Content(schema = @Schema(ref = "#/components/schemas/ApiResponseTalentDiscoveryPageResponse")))) |
||||
|
public ApiResponse<TalentDiscoveryPageResponse> recommendations( |
||||
|
@PathVariable Long jobId, |
||||
|
@RequestParam(defaultValue = "0") int page, |
||||
|
@RequestParam(defaultValue = "20") int size, |
||||
|
HttpServletRequest request) { |
||||
|
return ApiResponse.ok(service.recommendations(userId(), jobId, page, size), requestId(request)); |
||||
|
} |
||||
|
|
||||
|
private Long userId() { |
||||
|
Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); |
||||
|
if (authentication == null || !authentication.isAuthenticated() || authentication.getName() == null) { |
||||
|
throw new BusinessException(ErrorCode.UNAUTHORIZED); |
||||
|
} |
||||
|
try { return Long.valueOf(authentication.getName()); } |
||||
|
catch (NumberFormatException e) { throw new BusinessException(ErrorCode.UNAUTHORIZED); } |
||||
|
} |
||||
|
|
||||
|
private String requestId(HttpServletRequest request) { return request.getHeader("X-Request-Id"); } |
||||
|
} |
||||
@ -0,0 +1,46 @@ |
|||||
|
package com.mydreamjob.talentdiscovery; |
||||
|
|
||||
|
import org.springframework.stereotype.Component; |
||||
|
|
||||
|
import java.math.BigDecimal; |
||||
|
import java.math.RoundingMode; |
||||
|
import java.util.ArrayList; |
||||
|
import java.util.List; |
||||
|
import java.util.Optional; |
||||
|
|
||||
|
/** V41 人才发现评分纯函数;不访问数据库,也不生成对外的数值分数。 */ |
||||
|
@Component |
||||
|
public class TalentDiscoveryScoreCalculator { |
||||
|
public static final BigDecimal PREFERENCE_WEIGHT = new BigDecimal("40"); |
||||
|
public static final BigDecimal RIASEC_WEIGHT = new BigDecimal("35"); |
||||
|
public static final BigDecimal ABILITY_WEIGHT = new BigDecimal("25"); |
||||
|
private static final BigDecimal MIN = BigDecimal.ZERO; |
||||
|
private static final BigDecimal MAX = new BigDecimal("100"); |
||||
|
|
||||
|
public record Calculation(BigDecimal fitScore, String fitBand, List<String> signalsAvailable) { } |
||||
|
|
||||
|
/** 缺失信号不按 0 处罚,仅对有效信号权重重新归一化。 */ |
||||
|
public Optional<Calculation> calculate(BigDecimal preference, BigDecimal riasec, BigDecimal ability) { |
||||
|
BigDecimal numerator = BigDecimal.ZERO; |
||||
|
BigDecimal denominator = BigDecimal.ZERO; |
||||
|
List<String> available = new ArrayList<>(); |
||||
|
if (valid(preference)) { numerator = numerator.add(preference.multiply(PREFERENCE_WEIGHT)); denominator = denominator.add(PREFERENCE_WEIGHT); available.add("PREFERENCE"); } |
||||
|
if (valid(riasec)) { numerator = numerator.add(riasec.multiply(RIASEC_WEIGHT)); denominator = denominator.add(RIASEC_WEIGHT); available.add("RIASEC"); } |
||||
|
if (valid(ability)) { numerator = numerator.add(ability.multiply(ABILITY_WEIGHT)); denominator = denominator.add(ABILITY_WEIGHT); available.add("ABILITY"); } |
||||
|
if (denominator.signum() == 0) return Optional.empty(); |
||||
|
BigDecimal score = numerator.divide(denominator, 2, RoundingMode.HALF_UP); |
||||
|
return Optional.of(new Calculation(score, band(score), List.copyOf(available))); |
||||
|
} |
||||
|
|
||||
|
public String band(BigDecimal score) { |
||||
|
if (score == null) return null; |
||||
|
if (score.compareTo(new BigDecimal("75")) >= 0) return "HIGH"; |
||||
|
if (score.compareTo(new BigDecimal("60")) >= 0) return "MEDIUM"; |
||||
|
return "BASIC"; |
||||
|
} |
||||
|
|
||||
|
private boolean valid(BigDecimal value) { |
||||
|
return value != null && value.compareTo(MIN) >= 0 && value.compareTo(MAX) <= 0 |
||||
|
&& value.scale() < 1000; |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,298 @@ |
|||||
|
package com.mydreamjob.talentdiscovery; |
||||
|
|
||||
|
import com.fasterxml.jackson.core.type.TypeReference; |
||||
|
import com.fasterxml.jackson.databind.ObjectMapper; |
||||
|
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 com.mydreamjob.company.Company; |
||||
|
import com.mydreamjob.company.CompanyRepository; |
||||
|
import com.mydreamjob.job.Job; |
||||
|
import com.mydreamjob.job.JobRepository; |
||||
|
import com.mydreamjob.recommendation.JobRecommendationProfile; |
||||
|
import com.mydreamjob.recommendation.JobRecommendationProfileRepository; |
||||
|
import com.mydreamjob.talentdiscovery.dto.TalentDiscoveryCandidateResponse; |
||||
|
import com.mydreamjob.talentdiscovery.dto.TalentDiscoveryPageResponse; |
||||
|
import com.mydreamjob.talentdiscovery.dto.TalentDiscoveryPreferenceResponse; |
||||
|
import com.mydreamjob.talentdiscovery.dto.TalentDiscoveryPreferenceUpdateRequest; |
||||
|
import lombok.RequiredArgsConstructor; |
||||
|
import org.springframework.dao.DataIntegrityViolationException; |
||||
|
import org.springframework.orm.ObjectOptimisticLockingFailureException; |
||||
|
import org.springframework.stereotype.Service; |
||||
|
import org.springframework.transaction.annotation.Transactional; |
||||
|
|
||||
|
import java.math.BigDecimal; |
||||
|
import java.math.RoundingMode; |
||||
|
import java.time.LocalDate; |
||||
|
import java.time.LocalDateTime; |
||||
|
import java.util.ArrayList; |
||||
|
import java.util.Comparator; |
||||
|
import java.util.HashMap; |
||||
|
import java.util.List; |
||||
|
import java.util.Map; |
||||
|
import java.util.Set; |
||||
|
import java.util.regex.Pattern; |
||||
|
|
||||
|
/** V41 企业人才发现服务。只读候选池,不写推荐历史、曝光或候选评分快照。 */ |
||||
|
@Service |
||||
|
@RequiredArgsConstructor |
||||
|
public class TalentDiscoveryService { |
||||
|
private static final ObjectMapper JSON = new ObjectMapper(); |
||||
|
private static final TypeReference<Map<String, Object>> MAP = new TypeReference<>() { }; |
||||
|
private static final TypeReference<List<String>> LIST = new TypeReference<>() { }; |
||||
|
private static final int BATCH_SIZE = 500; |
||||
|
private static final Pattern SAFE_SKILL = Pattern.compile("[\\p{L}\\p{N}+#./()()·\\-_ ]+"); |
||||
|
|
||||
|
private final StudentTalentDiscoveryPreferenceRepository preferenceRepository; |
||||
|
private final UserRepository userRepository; |
||||
|
private final CompanyRepository companyRepository; |
||||
|
private final JobRepository jobRepository; |
||||
|
private final JobRecommendationProfileRepository profileRepository; |
||||
|
private final TalentDiscoveryCandidateSource candidateSource; |
||||
|
private final TalentDiscoveryScoreCalculator scoreCalculator; |
||||
|
private final CandidateKeyGenerator candidateKeyGenerator; |
||||
|
private final AuditService auditService; |
||||
|
|
||||
|
@Transactional(readOnly = true) |
||||
|
public TalentDiscoveryPreferenceResponse getPreference(Long userId) { |
||||
|
requireStudent(userId); |
||||
|
StudentTalentDiscoveryPreference value = preferenceRepository.findByStudentUserId(userId).orElse(null); |
||||
|
return toPreference(value); |
||||
|
} |
||||
|
|
||||
|
@Transactional |
||||
|
public TalentDiscoveryPreferenceResponse updatePreference(Long userId, |
||||
|
TalentDiscoveryPreferenceUpdateRequest request, |
||||
|
AuditContext context) { |
||||
|
requireStudent(userId); |
||||
|
if (request == null || request.getEnabled() == null) { |
||||
|
throw new BusinessException(ErrorCode.BAD_REQUEST); |
||||
|
} |
||||
|
StudentTalentDiscoveryPreference existing = preferenceRepository.findByStudentUserId(userId).orElse(null); |
||||
|
Long expected = request.getExpectedVersion(); |
||||
|
if (existing == null && expected != null && expected != 0L) { |
||||
|
throw new BusinessException(ErrorCode.PRIVACY_SETTINGS_CONCURRENT_UPDATE); |
||||
|
} |
||||
|
if (existing != null && expected != null && !java.util.Objects.equals(existing.getVersion(), expected)) { |
||||
|
throw new BusinessException(ErrorCode.PRIVACY_SETTINGS_CONCURRENT_UPDATE); |
||||
|
} |
||||
|
try { |
||||
|
StudentTalentDiscoveryPreference saved; |
||||
|
if (existing == null) { |
||||
|
saved = preferenceRepository.saveAndFlush(StudentTalentDiscoveryPreference.builder() |
||||
|
.studentUserId(userId).enabled(request.getEnabled()).version(0L).build()); |
||||
|
} else { |
||||
|
existing.setEnabled(request.getEnabled()); |
||||
|
saved = preferenceRepository.saveAndFlush(existing); |
||||
|
} |
||||
|
String requestId = context == null ? null : context.getRequestId(); |
||||
|
String details = "{\"enabled\":" + saved.getEnabled() |
||||
|
+ ",\"version\":" + saved.getVersion() |
||||
|
+ ",\"requestId\":" + (requestId == null ? "null" : quote(requestId)) + "}"; |
||||
|
auditService.log(userId, "TALENT_DISCOVERY_PREFERENCE_UPDATED", |
||||
|
"STUDENT_TALENT_DISCOVERY_PREFERENCE", saved.getId(), details, context); |
||||
|
return toPreference(saved); |
||||
|
} catch (ObjectOptimisticLockingFailureException | DataIntegrityViolationException ex) { |
||||
|
throw new BusinessException(ErrorCode.PRIVACY_SETTINGS_CONCURRENT_UPDATE); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
@Transactional(readOnly = true) |
||||
|
public TalentDiscoveryPageResponse recommendations(Long userId, Long jobId, int page, int size) { |
||||
|
validatePage(jobId, page, size); |
||||
|
Company company = requireVerifiedCompany(userId); |
||||
|
Job job = jobRepository.findById(jobId).orElseThrow(() -> new BusinessException(ErrorCode.JOB_NOT_FOUND)); |
||||
|
if (!company.getId().equals(job.getCompanyId())) { |
||||
|
throw new BusinessException(ErrorCode.FORBIDDEN); |
||||
|
} |
||||
|
if (!"PUBLISHED".equals(job.getStatus())) { |
||||
|
throw new BusinessException(ErrorCode.INVALID_JOB_STATUS_TRANSITION); |
||||
|
} |
||||
|
JobRecommendationProfile profile = profileRepository.findByJobId(jobId).orElse(null); |
||||
|
if (profile == null || !Boolean.TRUE.equals(profile.getEnabled()) |
||||
|
|| !JobRecommendationProfile.APPROVED.equals(profile.getReviewStatus())) { |
||||
|
return page("UNAVAILABLE", List.of(), 0, page, size, "该岗位尚无审核通过的推荐画像"); |
||||
|
} |
||||
|
|
||||
|
LocalDateTime capturedNow = LocalDateTime.now(); |
||||
|
List<TalentDiscoveryCandidateSource.CandidateSeed> seeds = candidateSource.findEligibleCandidates(company.getId()); |
||||
|
if (seeds.isEmpty()) { |
||||
|
return page("NO_ELIGIBLE_CANDIDATE", List.of(), 0, page, size, "暂未发现已授权且满足基础条件的候选人"); |
||||
|
} |
||||
|
Map<Long, TalentDiscoveryCandidateSource.PreferenceSignal> preferences = new HashMap<>(); |
||||
|
Map<Long, TalentDiscoveryCandidateSource.ScoreSignal> riasec = new HashMap<>(); |
||||
|
Map<Long, TalentDiscoveryCandidateSource.ScoreSignal> ability = new HashMap<>(); |
||||
|
for (int from = 0; from < seeds.size(); from += BATCH_SIZE) { |
||||
|
int to = Math.min(from + BATCH_SIZE, seeds.size()); |
||||
|
List<Long> ids = seeds.subList(from, to).stream() |
||||
|
.map(TalentDiscoveryCandidateSource.CandidateSeed::studentUserId).toList(); |
||||
|
preferences.putAll(candidateSource.findPreferences(ids)); |
||||
|
riasec.putAll(candidateSource.findLatestRiasec(ids)); |
||||
|
ability.putAll(candidateSource.findLatestAbility(ids, capturedNow)); |
||||
|
} |
||||
|
|
||||
|
List<ScoredCandidate> scored = new ArrayList<>(); |
||||
|
for (TalentDiscoveryCandidateSource.CandidateSeed seed : seeds) { |
||||
|
BigDecimal p = preferenceScore(preferences.get(seed.studentUserId()), profile, job); |
||||
|
TalentDiscoveryCandidateSource.ScoreSignal iSignal = riasec.get(seed.studentUserId()); |
||||
|
BigDecimal i = weighted(iSignal == null ? Map.of() : iSignal.scores(), parseMap(profile.getRiasecWeightsJson()), Set.of("R", "I", "A", "S", "E", "C")); |
||||
|
TalentDiscoveryCandidateSource.ScoreSignal aSignal = ability.get(seed.studentUserId()); |
||||
|
BigDecimal a = weighted(aSignal == null ? Map.of() : aSignal.scores(), parseMap(profile.getAbilityExpectationsJson()), Set.of("LOGIC", "COMMUNICATION", "EXECUTION", "LEARNING")); |
||||
|
var calculation = scoreCalculator.calculate(p, i, a); |
||||
|
if (calculation.isEmpty()) continue; |
||||
|
TalentDiscoveryScoreCalculator.Calculation value = calculation.get(); |
||||
|
scored.add(new ScoredCandidate(seed, value)); |
||||
|
} |
||||
|
if (scored.isEmpty()) { |
||||
|
return page("NO_ELIGIBLE_CANDIDATE", List.of(), 0, page, size, |
||||
|
"暂未发现已授权且满足基础条件的候选人"); |
||||
|
} |
||||
|
scored.sort(Comparator.comparing((ScoredCandidate c) -> c.calculation.fitScore()).reversed() |
||||
|
.thenComparing(c -> c.seed.profileUpdatedAt(), Comparator.nullsLast(Comparator.reverseOrder())) |
||||
|
.thenComparing(c -> c.seed.studentUserId(), Comparator.reverseOrder())); |
||||
|
int total = scored.size(); |
||||
|
long offset = (long) page * size; |
||||
|
int from = offset >= total ? total : (int) offset; |
||||
|
int to = Math.min(from + size, total); |
||||
|
List<TalentDiscoveryCandidateResponse> items = scored.subList(from, to).stream() |
||||
|
.map(c -> toCandidate(c, company.getId(), job.getId(), capturedNow.toLocalDate())).toList(); |
||||
|
return page("PERSONALIZED", items, total, page, size, null); |
||||
|
} |
||||
|
|
||||
|
private TalentDiscoveryCandidateResponse toCandidate(ScoredCandidate scored, Long companyId, Long jobId, LocalDate date) { |
||||
|
TalentDiscoveryCandidateSource.CandidateSeed seed = scored.seed; |
||||
|
return TalentDiscoveryCandidateResponse.builder() |
||||
|
.candidateKey(candidateKeyGenerator.generate(companyId, jobId, seed.studentUserId(), date)) |
||||
|
.candidateLabel("开放人才候选人") |
||||
|
.city(safeText(seed.city(), 100)) |
||||
|
.education(safeText(seed.education(), 100)) |
||||
|
.graduationYear(seed.graduationYear()) |
||||
|
.skills(safeSkills(seed.skillsJson())) |
||||
|
.fitBand(scored.calculation.fitBand()) |
||||
|
.summary(summary(scored.calculation.fitBand())) |
||||
|
.signalsAvailable(scored.calculation.signalsAvailable()) |
||||
|
.build(); |
||||
|
} |
||||
|
|
||||
|
private BigDecimal preferenceScore(TalentDiscoveryCandidateSource.PreferenceSignal preference, |
||||
|
JobRecommendationProfile profile, Job job) { |
||||
|
if (preference == null) return null; |
||||
|
int got = 0; |
||||
|
int total = 0; |
||||
|
if (!preference.cities().isEmpty()) { total += 30; if (preference.cities().contains(job.getCity())) got += 30; } |
||||
|
if (!preference.employmentTypes().isEmpty()) { total += 20; if (preference.employmentTypes().contains(job.getEmploymentType())) got += 20; } |
||||
|
if (preference.expectedSalaryMin() != null) { total += 20; if (job.getSalaryMax() != null && job.getSalaryMax() >= preference.expectedSalaryMin()) got += 20; } |
||||
|
if (!preference.industries().isEmpty()) { total += 15; if (preference.industries().contains(profile.getIndustryCode())) got += 15; } |
||||
|
if (!preference.jobTypes().isEmpty()) { total += 15; if (preference.jobTypes().contains(profile.getJobFamilyCode())) got += 15; } |
||||
|
return total == 0 ? null : BigDecimal.valueOf(got * 100L).divide(BigDecimal.valueOf(total), 2, RoundingMode.HALF_UP); |
||||
|
} |
||||
|
|
||||
|
private BigDecimal weighted(Map<String, BigDecimal> scores, Map<String, BigDecimal> expected, Set<String> allowed) { |
||||
|
if (scores.isEmpty() || expected.isEmpty()) return null; |
||||
|
BigDecimal numerator = BigDecimal.ZERO; |
||||
|
BigDecimal denominator = BigDecimal.ZERO; |
||||
|
for (Map.Entry<String, BigDecimal> entry : expected.entrySet()) { |
||||
|
if (!allowed.contains(entry.getKey()) || !scores.containsKey(entry.getKey()) || entry.getValue() == null || entry.getValue().signum() <= 0) continue; |
||||
|
numerator = numerator.add(scores.get(entry.getKey()).multiply(entry.getValue())); |
||||
|
denominator = denominator.add(entry.getValue()); |
||||
|
} |
||||
|
return denominator.signum() == 0 ? null : numerator.divide(denominator, 2, RoundingMode.HALF_UP); |
||||
|
} |
||||
|
|
||||
|
private Map<String, BigDecimal> parseMap(String raw) { |
||||
|
if (raw == null || raw.isBlank()) return Map.of(); |
||||
|
try { |
||||
|
Map<String, Object> input = JSON.readValue(raw, MAP); |
||||
|
Map<String, BigDecimal> result = new HashMap<>(); |
||||
|
for (Map.Entry<String, Object> entry : input.entrySet()) { |
||||
|
if (entry.getKey() == null || entry.getValue() == null) return Map.of(); |
||||
|
BigDecimal value = new BigDecimal(String.valueOf(entry.getValue())); |
||||
|
if (value.compareTo(BigDecimal.ZERO) < 0 || value.compareTo(BigDecimal.valueOf(100)) > 0) return Map.of(); |
||||
|
result.put(entry.getKey(), value); |
||||
|
} |
||||
|
return result; |
||||
|
} catch (Exception ignored) { return Map.of(); } |
||||
|
} |
||||
|
|
||||
|
private List<String> safeSkills(String raw) { |
||||
|
if (raw == null || raw.isBlank()) return List.of(); |
||||
|
try { |
||||
|
List<String> values = JSON.readValue(raw, LIST); |
||||
|
if (values == null) return List.of(); |
||||
|
return values.stream().map(v -> safeSkill(v)).filter(v -> v != null && !v.isBlank()) |
||||
|
.distinct().limit(10).toList(); |
||||
|
} catch (Exception ignored) { return List.of(); } |
||||
|
} |
||||
|
|
||||
|
private String safeSkill(String value) { |
||||
|
String text = safeText(value, 40); |
||||
|
return text != null && SAFE_SKILL.matcher(text).matches() ? text : null; |
||||
|
} |
||||
|
|
||||
|
private String safeText(String value, int max) { |
||||
|
if (value == null) return null; |
||||
|
String text = value.trim(); |
||||
|
if (text.isEmpty()) return null; |
||||
|
return text.length() <= max ? text : text.substring(0, max); |
||||
|
} |
||||
|
|
||||
|
private String summary(String band) { |
||||
|
return switch (band) { |
||||
|
case "HIGH" -> "岗位画像与候选人公开资料存在较高匹配度"; |
||||
|
case "MEDIUM" -> "岗位画像与候选人公开资料存在一定匹配度"; |
||||
|
default -> "候选人满足部分岗位画像条件"; |
||||
|
}; |
||||
|
} |
||||
|
|
||||
|
private Company requireVerifiedCompany(Long userId) { |
||||
|
User user = requireUser(userId); |
||||
|
if (!hasRole(user, User.Role.COMPANY)) throw new BusinessException(ErrorCode.FORBIDDEN); |
||||
|
Company company = companyRepository.findByOwnerUserId(userId) |
||||
|
.orElseThrow(() -> new BusinessException(ErrorCode.COMPANY_NOT_FOUND)); |
||||
|
if (!Boolean.TRUE.equals(company.getVerified())) throw new BusinessException(ErrorCode.FORBIDDEN); |
||||
|
return company; |
||||
|
} |
||||
|
|
||||
|
private void requireStudent(Long userId) { |
||||
|
if (!hasRole(requireUser(userId), User.Role.STUDENT)) throw new BusinessException(ErrorCode.FORBIDDEN); |
||||
|
} |
||||
|
|
||||
|
private User requireUser(Long userId) { |
||||
|
if (userId == null) throw new BusinessException(ErrorCode.UNAUTHORIZED); |
||||
|
return userRepository.findById(userId).orElseThrow(() -> new BusinessException(ErrorCode.USER_NOT_FOUND)); |
||||
|
} |
||||
|
|
||||
|
private boolean hasRole(User user, User.Role role) { |
||||
|
return user != null && ((user.getRoles() != null && user.getRoles().contains(role)) || user.getRole() == role); |
||||
|
} |
||||
|
|
||||
|
private void validatePage(Long jobId, int page, int size) { |
||||
|
if (jobId == null || jobId <= 0 || page < 0 || size < 1 || size > 50) { |
||||
|
throw new BusinessException(ErrorCode.BAD_REQUEST); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
private TalentDiscoveryPreferenceResponse toPreference(StudentTalentDiscoveryPreference value) { |
||||
|
if (value == null) return TalentDiscoveryPreferenceResponse.builder().enabled(false).version(0L).build(); |
||||
|
return TalentDiscoveryPreferenceResponse.builder().enabled(Boolean.TRUE.equals(value.getEnabled())) |
||||
|
.version(value.getVersion() == null ? 0L : value.getVersion()) |
||||
|
.createdAt(value.getCreatedAt()).updatedAt(value.getUpdatedAt()).build(); |
||||
|
} |
||||
|
|
||||
|
private TalentDiscoveryPageResponse page(String mode, List<TalentDiscoveryCandidateResponse> items, long total, |
||||
|
int page, int size, String message) { |
||||
|
int totalPages = total == 0 ? 0 : (int) Math.ceil((double) total / size); |
||||
|
return TalentDiscoveryPageResponse.builder().mode(mode).items(items).total(total) |
||||
|
.page(page).size(size).totalPages(totalPages).message(message).build(); |
||||
|
} |
||||
|
|
||||
|
private String quote(String value) { |
||||
|
return "\"" + value.replace("\\", "\\\\").replace("\"", "\\\"") + "\""; |
||||
|
} |
||||
|
|
||||
|
private record ScoredCandidate(TalentDiscoveryCandidateSource.CandidateSeed seed, |
||||
|
TalentDiscoveryScoreCalculator.Calculation calculation) { } |
||||
|
} |
||||
@ -0,0 +1,26 @@ |
|||||
|
package com.mydreamjob.talentdiscovery.dto; |
||||
|
|
||||
|
import io.swagger.v3.oas.annotations.media.Schema; |
||||
|
import lombok.AllArgsConstructor; |
||||
|
import lombok.Builder; |
||||
|
import lombok.Data; |
||||
|
import lombok.NoArgsConstructor; |
||||
|
|
||||
|
import java.util.List; |
||||
|
|
||||
|
@Data |
||||
|
@Builder |
||||
|
@NoArgsConstructor |
||||
|
@AllArgsConstructor |
||||
|
@Schema(description = "企业人才发现候选人卡片;不含候选人身份、联系方式或测评分数") |
||||
|
public class TalentDiscoveryCandidateResponse { |
||||
|
private String candidateKey; |
||||
|
private String candidateLabel; |
||||
|
private String city; |
||||
|
private String education; |
||||
|
private Integer graduationYear; |
||||
|
private List<String> skills; |
||||
|
private String fitBand; |
||||
|
private String summary; |
||||
|
private List<String> signalsAvailable; |
||||
|
} |
||||
@ -0,0 +1,24 @@ |
|||||
|
package com.mydreamjob.talentdiscovery.dto; |
||||
|
|
||||
|
import io.swagger.v3.oas.annotations.media.Schema; |
||||
|
import lombok.AllArgsConstructor; |
||||
|
import lombok.Builder; |
||||
|
import lombok.Data; |
||||
|
import lombok.NoArgsConstructor; |
||||
|
|
||||
|
import java.util.List; |
||||
|
|
||||
|
@Data |
||||
|
@Builder |
||||
|
@NoArgsConstructor |
||||
|
@AllArgsConstructor |
||||
|
public class TalentDiscoveryPageResponse { |
||||
|
@Schema(description = "PERSONALIZED、UNAVAILABLE 或 NO_ELIGIBLE_CANDIDATE") |
||||
|
private String mode; |
||||
|
private List<TalentDiscoveryCandidateResponse> items; |
||||
|
private long total; |
||||
|
private int page; |
||||
|
private int size; |
||||
|
private int totalPages; |
||||
|
private String message; |
||||
|
} |
||||
@ -0,0 +1,24 @@ |
|||||
|
package com.mydreamjob.talentdiscovery.dto; |
||||
|
|
||||
|
import com.fasterxml.jackson.annotation.JsonInclude; |
||||
|
import io.swagger.v3.oas.annotations.media.Schema; |
||||
|
import lombok.AllArgsConstructor; |
||||
|
import lombok.Builder; |
||||
|
import lombok.Data; |
||||
|
import lombok.NoArgsConstructor; |
||||
|
|
||||
|
import java.time.LocalDateTime; |
||||
|
|
||||
|
@Data |
||||
|
@Builder |
||||
|
@NoArgsConstructor |
||||
|
@AllArgsConstructor |
||||
|
@JsonInclude(JsonInclude.Include.NON_NULL) |
||||
|
public class TalentDiscoveryPreferenceResponse { |
||||
|
@Schema(description = "是否允许进入企业人才发现候选池") |
||||
|
private Boolean enabled; |
||||
|
@Schema(description = "乐观锁版本号") |
||||
|
private Long version; |
||||
|
private LocalDateTime createdAt; |
||||
|
private LocalDateTime updatedAt; |
||||
|
} |
||||
@ -0,0 +1,17 @@ |
|||||
|
package com.mydreamjob.talentdiscovery.dto; |
||||
|
|
||||
|
import io.swagger.v3.oas.annotations.media.Schema; |
||||
|
import jakarta.validation.constraints.NotNull; |
||||
|
import jakarta.validation.constraints.PositiveOrZero; |
||||
|
import lombok.Data; |
||||
|
|
||||
|
@Data |
||||
|
public class TalentDiscoveryPreferenceUpdateRequest { |
||||
|
@NotNull |
||||
|
@Schema(description = "是否允许进入企业人才发现候选池", requiredMode = Schema.RequiredMode.REQUIRED) |
||||
|
private Boolean enabled; |
||||
|
|
||||
|
@PositiveOrZero |
||||
|
@Schema(description = "期望的当前版本;首次创建可省略或传 0") |
||||
|
private Long expectedVersion; |
||||
|
} |
||||
@ -0,0 +1,16 @@ |
|||||
|
-- Flyway V39:企业人才发现授权(功能版本 V41)。 |
||||
|
-- 仅保存学生对“被企业发现”的显式授权,不保存企业浏览、岗位、测评或联系方式数据。 |
||||
|
|
||||
|
CREATE TABLE student_talent_discovery_preferences ( |
||||
|
id BIGINT AUTO_INCREMENT PRIMARY KEY, |
||||
|
student_user_id BIGINT NOT NULL, |
||||
|
enabled TINYINT NOT NULL DEFAULT 0, |
||||
|
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_student_talent_discovery_preferences_student UNIQUE (student_user_id), |
||||
|
CONSTRAINT fk_student_talent_discovery_preferences_student FOREIGN KEY (student_user_id) |
||||
|
REFERENCES users(id), |
||||
|
CONSTRAINT ck_student_talent_discovery_preferences_enabled CHECK (enabled IN (0, 1)), |
||||
|
INDEX idx_student_talent_discovery_preferences_enabled_student (enabled, student_user_id) |
||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; |
||||
@ -0,0 +1,20 @@ |
|||||
|
package com.mydreamjob.talentdiscovery; |
||||
|
|
||||
|
import org.junit.jupiter.api.Test; |
||||
|
|
||||
|
import java.time.LocalDate; |
||||
|
|
||||
|
import static org.junit.jupiter.api.Assertions.*; |
||||
|
|
||||
|
class CandidateKeyGeneratorTest { |
||||
|
@Test |
||||
|
void keyIsOpaqueAndBoundToScopeAndDate() { |
||||
|
CandidateKeyGenerator generator = new CandidateKeyGenerator("test-secret"); |
||||
|
String first = generator.generate(1L, 2L, 3L, LocalDate.of(2026, 9, 4)); |
||||
|
assertNotNull(first); |
||||
|
assertTrue(first.length() >= 40); |
||||
|
assertNotEquals("1|2|3|2026-09-04", first); |
||||
|
assertNotEquals(first, generator.generate(1L, 2L, 4L, LocalDate.of(2026, 9, 4))); |
||||
|
assertNotEquals(first, generator.generate(1L, 2L, 3L, LocalDate.of(2026, 9, 5))); |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,138 @@ |
|||||
|
package com.mydreamjob.talentdiscovery; |
||||
|
|
||||
|
import com.fasterxml.jackson.databind.ObjectMapper; |
||||
|
import com.mydreamjob.auth.JwtTokenProvider; |
||||
|
import com.mydreamjob.common.exception.BusinessException; |
||||
|
import com.mydreamjob.common.exception.ErrorCode; |
||||
|
import com.mydreamjob.talentdiscovery.dto.TalentDiscoveryCandidateResponse; |
||||
|
import com.mydreamjob.talentdiscovery.dto.TalentDiscoveryPageResponse; |
||||
|
import com.mydreamjob.talentdiscovery.dto.TalentDiscoveryPreferenceResponse; |
||||
|
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.context.TestPropertySource; |
||||
|
import org.springframework.test.web.servlet.MockMvc; |
||||
|
|
||||
|
import java.util.List; |
||||
|
|
||||
|
import static org.hamcrest.Matchers.containsString; |
||||
|
import static org.hamcrest.Matchers.not; |
||||
|
import static org.mockito.ArgumentMatchers.any; |
||||
|
import static org.mockito.ArgumentMatchers.anyInt; |
||||
|
import static org.mockito.ArgumentMatchers.anyLong; |
||||
|
import static org.mockito.Mockito.doThrow; |
||||
|
import static org.mockito.Mockito.when; |
||||
|
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; |
||||
|
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put; |
||||
|
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; |
||||
|
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") |
||||
|
@TestPropertySource(properties = "spring.jpa.open-in-view=false") |
||||
|
class TalentDiscoveryControllerTest { |
||||
|
|
||||
|
@Autowired private MockMvc mockMvc; |
||||
|
@Autowired private JwtTokenProvider jwtTokenProvider; |
||||
|
@MockBean private TalentDiscoveryService service; |
||||
|
@MockBean private RedisTemplate<String, Object> redisTemplate; |
||||
|
|
||||
|
private final ObjectMapper mapper = new ObjectMapper(); |
||||
|
|
||||
|
@BeforeEach |
||||
|
@SuppressWarnings("unchecked") |
||||
|
void setUp() { |
||||
|
ValueOperations<String, Object> ops = org.mockito.Mockito.mock(ValueOperations.class); |
||||
|
when(redisTemplate.opsForValue()).thenReturn(ops); |
||||
|
when(redisTemplate.hasKey(any())).thenReturn(false); |
||||
|
} |
||||
|
|
||||
|
private String token(long userId, String role) { |
||||
|
return "Bearer " + jwtTokenProvider.generateAccessToken(userId, "1380013" + userId, |
||||
|
java.util.Map.of("roles", role)); |
||||
|
} |
||||
|
|
||||
|
@Test |
||||
|
void getPreference_usesJwtIdentityAndReturnsPreference() throws Exception { |
||||
|
when(service.getPreference(100L)).thenReturn(TalentDiscoveryPreferenceResponse.builder() |
||||
|
.enabled(false).version(0L).build()); |
||||
|
|
||||
|
mockMvc.perform(get("/profile/me/talent-discovery") |
||||
|
.header("Authorization", token(100L, "STUDENT"))) |
||||
|
.andExpect(status().isOk()) |
||||
|
.andExpect(jsonPath("$.code").value(200)) |
||||
|
.andExpect(jsonPath("$.data.enabled").value(false)) |
||||
|
.andExpect(jsonPath("$.data.version").value(0)); |
||||
|
} |
||||
|
|
||||
|
@Test |
||||
|
void getPreference_withoutTokenReturns401() throws Exception { |
||||
|
mockMvc.perform(get("/profile/me/talent-discovery")) |
||||
|
.andExpect(status().isUnauthorized()) |
||||
|
.andExpect(jsonPath("$.code").value(401)); |
||||
|
} |
||||
|
|
||||
|
@Test |
||||
|
void updatePreference_studentCannotSupplyIdentityAndReturns200() throws Exception { |
||||
|
when(service.updatePreference(anyLong(), any(), any())).thenReturn( |
||||
|
TalentDiscoveryPreferenceResponse.builder().enabled(true).version(1L).build()); |
||||
|
|
||||
|
String body = mapper.writeValueAsString(java.util.Map.of( |
||||
|
"enabled", true, "expectedVersion", 0, "studentUserId", 999999)); |
||||
|
mockMvc.perform(put("/profile/me/talent-discovery") |
||||
|
.header("Authorization", token(100L, "STUDENT")) |
||||
|
.contentType(MediaType.APPLICATION_JSON) |
||||
|
.content(body)) |
||||
|
.andExpect(status().isOk()) |
||||
|
.andExpect(jsonPath("$.code").value(200)) |
||||
|
.andExpect(jsonPath("$.data.enabled").value(true)); |
||||
|
} |
||||
|
|
||||
|
@Test |
||||
|
void companyRecommendationResponseContainsOnlyWhitelistedCardFields() throws Exception { |
||||
|
TalentDiscoveryCandidateResponse candidate = TalentDiscoveryCandidateResponse.builder() |
||||
|
.candidateKey("opaque-key") |
||||
|
.candidateLabel("开放人才候选人") |
||||
|
.city("杭州") |
||||
|
.education("本科") |
||||
|
.graduationYear(2026) |
||||
|
.skills(List.of("Java", "Spring Boot")) |
||||
|
.fitBand("HIGH") |
||||
|
.summary("岗位画像与候选人公开资料存在较高匹配度") |
||||
|
.signalsAvailable(List.of("PREFERENCE", "RIASEC", "ABILITY")) |
||||
|
.build(); |
||||
|
when(service.recommendations(anyLong(), anyLong(), anyInt(), anyInt())).thenReturn( |
||||
|
TalentDiscoveryPageResponse.builder().mode("PERSONALIZED") |
||||
|
.items(List.of(candidate)).total(1).page(0).size(20).totalPages(1).build()); |
||||
|
|
||||
|
mockMvc.perform(get("/companies/me/jobs/9/talent-recommendations") |
||||
|
.header("Authorization", token(200L, "COMPANY"))) |
||||
|
.andExpect(status().isOk()) |
||||
|
.andExpect(jsonPath("$.data.items[0].candidateKey").value("opaque-key")) |
||||
|
.andExpect(jsonPath("$.data.items[0].fitScore").doesNotExist()) |
||||
|
.andExpect(content().string(not(containsString("studentUserId")))) |
||||
|
.andExpect(content().string(not(containsString("dimensionScores")))) |
||||
|
.andExpect(content().string(not(containsString("overallScore")))) |
||||
|
.andExpect(content().string(not(containsString("fitScore")))); |
||||
|
} |
||||
|
|
||||
|
@Test |
||||
|
void companyRecommendationServiceForbiddenIsMappedTo403() throws Exception { |
||||
|
doThrow(new BusinessException(ErrorCode.FORBIDDEN)) |
||||
|
.when(service).recommendations(anyLong(), anyLong(), anyInt(), anyInt()); |
||||
|
|
||||
|
mockMvc.perform(get("/companies/me/jobs/9/talent-recommendations") |
||||
|
.header("Authorization", token(201L, "COMPANY"))) |
||||
|
.andExpect(status().isForbidden()) |
||||
|
.andExpect(jsonPath("$.code").value(2002)); |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,41 @@ |
|||||
|
package com.mydreamjob.talentdiscovery; |
||||
|
|
||||
|
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 static org.assertj.core.api.Assertions.assertThat; |
||||
|
|
||||
|
/** 只读确认 V41 的 V39 迁移在集成测试 H2(MODE=MySQL) 中已执行。 */ |
||||
|
@SpringBootTest |
||||
|
@ActiveProfiles("integration-test") |
||||
|
class TalentDiscoveryPersistenceIntegrationTest { |
||||
|
@Autowired JdbcTemplate jdbcTemplate; |
||||
|
@MockBean RedisTemplate<String, Object> redisTemplate; |
||||
|
|
||||
|
@Test |
||||
|
void v39Migration_createsPreferenceTableWithSafeDefaultsAndConstraints() { |
||||
|
Integer tableCount = jdbcTemplate.queryForObject( |
||||
|
"SELECT COUNT(*) FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_NAME='STUDENT_TALENT_DISCOVERY_PREFERENCES'", |
||||
|
Integer.class); |
||||
|
assertThat(tableCount).isEqualTo(1); |
||||
|
|
||||
|
String enabledDefault = jdbcTemplate.queryForObject( |
||||
|
"SELECT COLUMN_DEFAULT FROM INFORMATION_SCHEMA.COLUMNS " |
||||
|
+ "WHERE TABLE_NAME='STUDENT_TALENT_DISCOVERY_PREFERENCES' AND COLUMN_NAME='ENABLED'", |
||||
|
String.class); |
||||
|
assertThat(enabledDefault).isEqualTo("0"); |
||||
|
|
||||
|
Integer uniqueCount = jdbcTemplate.queryForObject( |
||||
|
"SELECT COUNT(*) FROM INFORMATION_SCHEMA.TABLE_CONSTRAINTS " |
||||
|
+ "WHERE TABLE_NAME='STUDENT_TALENT_DISCOVERY_PREFERENCES' " |
||||
|
+ "AND CONSTRAINT_NAME='UK_STUDENT_TALENT_DISCOVERY_PREFERENCES_STUDENT' " |
||||
|
+ "AND CONSTRAINT_TYPE='UNIQUE'", |
||||
|
Integer.class); |
||||
|
assertThat(uniqueCount).isEqualTo(1); |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,47 @@ |
|||||
|
package com.mydreamjob.talentdiscovery; |
||||
|
|
||||
|
import org.junit.jupiter.api.Test; |
||||
|
|
||||
|
import java.math.BigDecimal; |
||||
|
|
||||
|
import static org.junit.jupiter.api.Assertions.*; |
||||
|
|
||||
|
class TalentDiscoveryScoreCalculatorTest { |
||||
|
private final TalentDiscoveryScoreCalculator calculator = new TalentDiscoveryScoreCalculator(); |
||||
|
|
||||
|
@Test |
||||
|
void allSignals_useFixedWeightsAndHalfUp() { |
||||
|
var result = calculator.calculate(new BigDecimal("80"), new BigDecimal("70"), new BigDecimal("60")); |
||||
|
assertTrue(result.isPresent()); |
||||
|
assertEquals(new BigDecimal("71.50"), result.get().fitScore()); |
||||
|
assertEquals("MEDIUM", result.get().fitBand()); |
||||
|
assertEquals(java.util.List.of("PREFERENCE", "RIASEC", "ABILITY"), result.get().signalsAvailable()); |
||||
|
} |
||||
|
|
||||
|
@Test |
||||
|
void missingSignals_areRenormalized_notZero() { |
||||
|
var onlyAbility = calculator.calculate(null, null, new BigDecimal("80")); |
||||
|
assertTrue(onlyAbility.isPresent()); |
||||
|
assertEquals(new BigDecimal("80.00"), onlyAbility.get().fitScore()); |
||||
|
var preferenceAndRiasec = calculator.calculate(new BigDecimal("100"), new BigDecimal("0"), null); |
||||
|
assertTrue(preferenceAndRiasec.isPresent()); |
||||
|
assertEquals(new BigDecimal("53.33"), preferenceAndRiasec.get().fitScore()); |
||||
|
} |
||||
|
|
||||
|
@Test |
||||
|
void noSignal_orOutOfRange_isUnavailable() { |
||||
|
assertTrue(calculator.calculate(null, null, null).isEmpty()); |
||||
|
assertTrue(calculator.calculate(new BigDecimal("-1"), null, null).isEmpty()); |
||||
|
assertTrue(calculator.calculate(null, new BigDecimal("101"), null).isEmpty()); |
||||
|
} |
||||
|
|
||||
|
@Test |
||||
|
void bandBoundaries_areDeterministic() { |
||||
|
assertEquals("HIGH", calculator.calculate(new BigDecimal("74.995"), null, null).orElseThrow().fitBand()); |
||||
|
assertEquals("MEDIUM", calculator.calculate(new BigDecimal("59.995"), null, null).orElseThrow().fitBand()); |
||||
|
assertEquals("HIGH", calculator.band(new BigDecimal("75.00"))); |
||||
|
assertEquals("MEDIUM", calculator.band(new BigDecimal("74.99"))); |
||||
|
assertEquals("MEDIUM", calculator.band(new BigDecimal("60.00"))); |
||||
|
assertEquals("BASIC", calculator.band(new BigDecimal("59.99"))); |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,163 @@ |
|||||
|
package com.mydreamjob.talentdiscovery; |
||||
|
|
||||
|
import com.fasterxml.jackson.databind.ObjectMapper; |
||||
|
import com.mydreamjob.audit.AuditService; |
||||
|
import com.mydreamjob.auth.User; |
||||
|
import com.mydreamjob.auth.UserRepository; |
||||
|
import com.mydreamjob.company.Company; |
||||
|
import com.mydreamjob.company.CompanyRepository; |
||||
|
import com.mydreamjob.job.Job; |
||||
|
import com.mydreamjob.job.JobRepository; |
||||
|
import com.mydreamjob.recommendation.JobRecommendationProfile; |
||||
|
import com.mydreamjob.recommendation.JobRecommendationProfileRepository; |
||||
|
import com.mydreamjob.talentdiscovery.dto.TalentDiscoveryPageResponse; |
||||
|
import com.mydreamjob.talentdiscovery.dto.TalentDiscoveryPreferenceUpdateRequest; |
||||
|
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.time.LocalDateTime; |
||||
|
import java.util.List; |
||||
|
import java.util.Map; |
||||
|
import java.util.Optional; |
||||
|
import java.util.Set; |
||||
|
import java.util.stream.IntStream; |
||||
|
|
||||
|
import static org.assertj.core.api.Assertions.assertThat; |
||||
|
import static org.mockito.ArgumentMatchers.any; |
||||
|
import static org.mockito.ArgumentMatchers.anyCollection; |
||||
|
import static org.mockito.Mockito.*; |
||||
|
|
||||
|
@ExtendWith(MockitoExtension.class) |
||||
|
class TalentDiscoveryServiceTest { |
||||
|
@Mock StudentTalentDiscoveryPreferenceRepository preferenceRepository; |
||||
|
@Mock UserRepository userRepository; |
||||
|
@Mock CompanyRepository companyRepository; |
||||
|
@Mock JobRepository jobRepository; |
||||
|
@Mock JobRecommendationProfileRepository profileRepository; |
||||
|
@Mock TalentDiscoveryCandidateSource candidateSource; |
||||
|
@Mock TalentDiscoveryScoreCalculator scoreCalculator; |
||||
|
@Mock AuditService auditService; |
||||
|
@Mock CandidateKeyGenerator candidateKeyGenerator; |
||||
|
@InjectMocks TalentDiscoveryService service; |
||||
|
|
||||
|
@Test |
||||
|
void missingApprovedProfile_returnsUnavailableWithoutReadingCandidates() { |
||||
|
User companyUser = User.builder().id(1L).role(User.Role.COMPANY).roles(Set.of(User.Role.COMPANY)).build(); |
||||
|
Company company = Company.builder().id(10L).ownerUserId(1L).verified(true).build(); |
||||
|
Job job = Job.builder().id(20L).companyId(10L).status("PUBLISHED").build(); |
||||
|
when(userRepository.findById(1L)).thenReturn(Optional.of(companyUser)); |
||||
|
when(companyRepository.findByOwnerUserId(1L)).thenReturn(Optional.of(company)); |
||||
|
when(jobRepository.findById(20L)).thenReturn(Optional.of(job)); |
||||
|
when(profileRepository.findByJobId(20L)).thenReturn(Optional.empty()); |
||||
|
|
||||
|
TalentDiscoveryPageResponse response = service.recommendations(1L, 20L, 0, 20); |
||||
|
|
||||
|
assertThat(response.getMode()).isEqualTo("UNAVAILABLE"); |
||||
|
assertThat(response.getItems()).isEmpty(); |
||||
|
verifyNoInteractions(candidateSource, candidateKeyGenerator); |
||||
|
} |
||||
|
|
||||
|
@Test |
||||
|
void calculatesAllCandidatesBeforePagingAndNeverSerializesFitScore() throws Exception { |
||||
|
User companyUser = User.builder().id(1L).role(User.Role.COMPANY).roles(Set.of(User.Role.COMPANY)).build(); |
||||
|
Company company = Company.builder().id(10L).ownerUserId(1L).verified(true).build(); |
||||
|
Job job = Job.builder().id(20L).companyId(10L).status("PUBLISHED") |
||||
|
.city("北京").employmentType("FULL_TIME").salaryMin(10000).salaryMax(20000).build(); |
||||
|
JobRecommendationProfile profile = JobRecommendationProfile.builder().jobId(20L).enabled(true) |
||||
|
.reviewStatus(JobRecommendationProfile.APPROVED).industryCode("TECH").jobFamilyCode("BACKEND") |
||||
|
.build(); |
||||
|
LocalDateTime now = LocalDateTime.now(); |
||||
|
var first = new TalentDiscoveryCandidateSource.CandidateSeed(101L, "北京", "本科", 2026, |
||||
|
"[\"Java\",\"SQL\"]", now); |
||||
|
var second = new TalentDiscoveryCandidateSource.CandidateSeed(102L, "上海", "本科", 2026, |
||||
|
"[\"Go\"]", now.minusMinutes(1)); |
||||
|
when(userRepository.findById(1L)).thenReturn(Optional.of(companyUser)); |
||||
|
when(companyRepository.findByOwnerUserId(1L)).thenReturn(Optional.of(company)); |
||||
|
when(jobRepository.findById(20L)).thenReturn(Optional.of(job)); |
||||
|
when(profileRepository.findByJobId(20L)).thenReturn(Optional.of(profile)); |
||||
|
when(candidateSource.findEligibleCandidates(10L)).thenReturn(List.of(first, second)); |
||||
|
when(candidateSource.findPreferences(anyCollection())).thenReturn(Map.of( |
||||
|
101L, new TalentDiscoveryCandidateSource.PreferenceSignal(null, null, |
||||
|
List.of("北京"), List.of(), List.of(), List.of(), false), |
||||
|
102L, new TalentDiscoveryCandidateSource.PreferenceSignal(null, null, |
||||
|
List.of("上海"), List.of(), List.of(), List.of(), false))); |
||||
|
when(candidateSource.findLatestRiasec(anyCollection())).thenReturn(Map.of()); |
||||
|
when(candidateSource.findLatestAbility(anyCollection(), any())).thenReturn(Map.of()); |
||||
|
when(candidateKeyGenerator.generate(any(), any(), any(), any())).thenAnswer(invocation -> |
||||
|
"candidate-" + invocation.getArgument(2)); |
||||
|
when(scoreCalculator.calculate(any(), any(), any())).thenAnswer(invocation -> |
||||
|
new TalentDiscoveryScoreCalculator().calculate(invocation.getArgument(0), invocation.getArgument(1), invocation.getArgument(2))); |
||||
|
|
||||
|
TalentDiscoveryPageResponse page0 = service.recommendations(1L, 20L, 0, 1); |
||||
|
TalentDiscoveryPageResponse page1 = service.recommendations(1L, 20L, 1, 1); |
||||
|
|
||||
|
assertThat(page0.getMode()).isEqualTo("PERSONALIZED"); |
||||
|
assertThat(page0.getTotal()).isEqualTo(2); |
||||
|
assertThat(page0.getItems()).singleElement().satisfies(item -> { |
||||
|
assertThat(item.getCandidateKey()).isEqualTo("candidate-101"); |
||||
|
assertThat(item.getFitBand()).isEqualTo("HIGH"); |
||||
|
assertThat(item.getSignalsAvailable()).containsExactly("PREFERENCE"); |
||||
|
}); |
||||
|
assertThat(page1.getItems()).singleElement().satisfies(item -> |
||||
|
assertThat(item.getCandidateKey()).isEqualTo("candidate-102")); |
||||
|
String json = new ObjectMapper().writeValueAsString(page0); |
||||
|
assertThat(json).doesNotContain("fitScore", "studentUserId", "candidateId", "dimensionScores"); |
||||
|
verify(candidateSource, times(2)).findEligibleCandidates(10L); |
||||
|
} |
||||
|
|
||||
|
@Test |
||||
|
void missingPreference_isReadOnlyAndDefaultsToDisabled() { |
||||
|
User student = User.builder().id(7L).role(User.Role.STUDENT).roles(Set.of(User.Role.STUDENT)).build(); |
||||
|
when(userRepository.findById(7L)).thenReturn(Optional.of(student)); |
||||
|
when(preferenceRepository.findByStudentUserId(7L)).thenReturn(Optional.empty()); |
||||
|
|
||||
|
var response = service.getPreference(7L); |
||||
|
|
||||
|
assertThat(response.getEnabled()).isFalse(); |
||||
|
assertThat(response.getVersion()).isZero(); |
||||
|
verify(preferenceRepository, never()).save(any()); |
||||
|
verify(preferenceRepository, never()).saveAndFlush(any()); |
||||
|
} |
||||
|
|
||||
|
@Test |
||||
|
void invalidPageParameters_areRejectedBeforeDatabaseQueries() { |
||||
|
org.junit.jupiter.api.Assertions.assertThrows( |
||||
|
com.mydreamjob.common.exception.BusinessException.class, |
||||
|
() -> service.recommendations(1L, 20L, -1, 50)); |
||||
|
verifyNoInteractions(userRepository, companyRepository, jobRepository); |
||||
|
} |
||||
|
|
||||
|
@Test |
||||
|
void batchSignalReads_areBoundedByBatchCountNotCandidateCount() { |
||||
|
User companyUser = User.builder().id(1L).role(User.Role.COMPANY).roles(Set.of(User.Role.COMPANY)).build(); |
||||
|
Company company = Company.builder().id(10L).ownerUserId(1L).verified(true).build(); |
||||
|
Job job = Job.builder().id(20L).companyId(10L).status("PUBLISHED") |
||||
|
.city("北京").employmentType("FULL_TIME").salaryMin(10000).salaryMax(20000).build(); |
||||
|
JobRecommendationProfile profile = JobRecommendationProfile.builder().jobId(20L).enabled(true) |
||||
|
.reviewStatus(JobRecommendationProfile.APPROVED).industryCode("TECH").jobFamilyCode("BACKEND") |
||||
|
.build(); |
||||
|
List<TalentDiscoveryCandidateSource.CandidateSeed> seeds = IntStream.range(0, 501) |
||||
|
.mapToObj(i -> new TalentDiscoveryCandidateSource.CandidateSeed( |
||||
|
1000L + i, "北京", "本科", 2026, "[]", LocalDateTime.now())) |
||||
|
.toList(); |
||||
|
when(userRepository.findById(1L)).thenReturn(Optional.of(companyUser)); |
||||
|
when(companyRepository.findByOwnerUserId(1L)).thenReturn(Optional.of(company)); |
||||
|
when(jobRepository.findById(20L)).thenReturn(Optional.of(job)); |
||||
|
when(profileRepository.findByJobId(20L)).thenReturn(Optional.of(profile)); |
||||
|
when(candidateSource.findEligibleCandidates(10L)).thenReturn(seeds); |
||||
|
when(candidateSource.findPreferences(anyCollection())).thenReturn(Map.of()); |
||||
|
when(candidateSource.findLatestRiasec(anyCollection())).thenReturn(Map.of()); |
||||
|
when(candidateSource.findLatestAbility(anyCollection(), any())).thenReturn(Map.of()); |
||||
|
when(scoreCalculator.calculate(any(), any(), any())).thenReturn(java.util.Optional.empty()); |
||||
|
|
||||
|
TalentDiscoveryPageResponse response = service.recommendations(1L, 20L, 0, 20); |
||||
|
|
||||
|
assertThat(response.getMode()).isEqualTo("NO_ELIGIBLE_CANDIDATE"); |
||||
|
verify(candidateSource, times(2)).findPreferences(anyCollection()); |
||||
|
verify(candidateSource, times(2)).findLatestRiasec(anyCollection()); |
||||
|
verify(candidateSource, times(2)).findLatestAbility(anyCollection(), any()); |
||||
|
} |
||||
|
} |
||||
Loading…
Reference in new issue