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