Browse Source

feat: deliver V41 enterprise talent discovery

master
Zhang Shun 2 months ago
parent
commit
5cc2c866bc
  1. 245
      backend-infra/v41_blackbox.ps1
  2. 12
      docs/backend-development-roadmap.md
  3. 53
      docs/enterprise-talent-discovery-api.md
  4. 8
      docs/frontend-integration.md
  5. 1516
      docs/openapi-rc.json
  6. 366
      docs/v41-enterprise-talent-recommendation-design.md
  7. 27
      docs/verify_openapi.py
  8. 3
      mydreamjob-server/src/main/java/com/mydreamjob/config/SecurityConfig.java
  9. 35
      mydreamjob-server/src/main/java/com/mydreamjob/talentdiscovery/CandidateKeyGenerator.java
  10. 57
      mydreamjob-server/src/main/java/com/mydreamjob/talentdiscovery/StudentTalentDiscoveryPreference.java
  11. 17
      mydreamjob-server/src/main/java/com/mydreamjob/talentdiscovery/StudentTalentDiscoveryPreferenceRepository.java
  12. 215
      mydreamjob-server/src/main/java/com/mydreamjob/talentdiscovery/TalentDiscoveryCandidateSource.java
  13. 71
      mydreamjob-server/src/main/java/com/mydreamjob/talentdiscovery/TalentDiscoveryController.java
  14. 46
      mydreamjob-server/src/main/java/com/mydreamjob/talentdiscovery/TalentDiscoveryScoreCalculator.java
  15. 298
      mydreamjob-server/src/main/java/com/mydreamjob/talentdiscovery/TalentDiscoveryService.java
  16. 26
      mydreamjob-server/src/main/java/com/mydreamjob/talentdiscovery/dto/TalentDiscoveryCandidateResponse.java
  17. 24
      mydreamjob-server/src/main/java/com/mydreamjob/talentdiscovery/dto/TalentDiscoveryPageResponse.java
  18. 24
      mydreamjob-server/src/main/java/com/mydreamjob/talentdiscovery/dto/TalentDiscoveryPreferenceResponse.java
  19. 17
      mydreamjob-server/src/main/java/com/mydreamjob/talentdiscovery/dto/TalentDiscoveryPreferenceUpdateRequest.java
  20. 16
      mydreamjob-server/src/main/resources/db/migration/V39__enterprise_talent_discovery.sql
  21. 20
      mydreamjob-server/src/test/java/com/mydreamjob/talentdiscovery/CandidateKeyGeneratorTest.java
  22. 138
      mydreamjob-server/src/test/java/com/mydreamjob/talentdiscovery/TalentDiscoveryControllerTest.java
  23. 41
      mydreamjob-server/src/test/java/com/mydreamjob/talentdiscovery/TalentDiscoveryPersistenceIntegrationTest.java
  24. 47
      mydreamjob-server/src/test/java/com/mydreamjob/talentdiscovery/TalentDiscoveryScoreCalculatorTest.java
  25. 163
      mydreamjob-server/src/test/java/com/mydreamjob/talentdiscovery/TalentDiscoveryServiceTest.java

245
backend-infra/v41_blackbox.ps1

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

12
docs/backend-development-roadmap.md

@ -26,11 +26,11 @@
| 项目 | 当前状态 |
|---|---|
| OpenAPI | **159 paths、196 operations**(V40 静态与运行时校验通过) |
| Flyway | **V1–V38 已成功执行**;V38 为企业评价官方回复迁移(功能版本 V40,真实 MySQL/Docker 已验收) |
| 全量测试 | V40 专项与全量测试、`mvn clean verify` 已通过 **1445/1445**;真实 MySQL/Docker 黑盒 `PASS=62/FAIL=0/SKIP=0` |
| OpenAPI | **161 paths、199 operations**(V41 静态、运行时与校验脚本对账通过;学生 GET/PUT 共用一个 path) |
| Flyway | **V1–V39 已成功执行**;V39 为企业人才发现授权迁移(功能版本 V41,真实 MySQL/Docker 已验收) |
| 全量测试 | V41 全量自动化测试 **1461/1461** 通过;真实 MySQL/Docker 黑盒 `PASS=37/FAIL=0`(V40 黑盒结论 `PASS=62/FAIL=0/SKIP=0` 保持有效) |
| 已完成范围 | V24 Candidate Scoring、V25 Governance、V26 Offer Advance、V27 Privacy、V28/V29 Account Deletion、V30 Data Export、V31 Interview Score、V32 Competition Ranking、V33 Report/Block、V34 统一求职进度聚合、**V35 RIASEC 职业兴趣测评(Flyway V34,已真实验收)**、**V36 四维能力测评(Flyway V35)**、**V37 防刷风险概览与运营规则配置(Flyway V36)**、**V38 四维能力测评有限接入候选评分(无新 Flyway、无新公网接口)**,及认证、资料、岗位、投递、面试、Offer、看板等核心闭环 |
| 当前结论 | V35–V39 的既有验收结论保持不变。**V40 企业评价管理与官方回复已完成首版实现、专项/全量自动化测试、`mvn clean verify`、静态/运行时 OpenAPI 与真实 MySQL/Docker 黑盒验收(PASS=62/FAIL=0/SKIP=0);待建立 Git 检查点。** OpenAPI 为 159/196。 |
| 当前结论 | V35–V40 的既有验收结论保持不变。**V41 企业人才发现推荐已完成实现、自动化测试、静态/运行时 OpenAPI 对账与真实 MySQL/Docker 黑盒验收(PASS=37/FAIL=0),可建立 Git 检查点。** 当前 OpenAPI 为 161/199。 |
| 本规划范围 | 代码修复、新功能开发、接口契约、数据权限和治理能力 |
### 2.0 模块状态分类
@ -39,7 +39,7 @@
|---|---|
| 已完成并已验收 | V24 Candidate Scoring、V25 Governance、V26 Offer Advance、V27 Privacy、V28/V29 Account Deletion、V30 Data Export、V31 Interview Score、V32 Competition Ranking、V33 Report/Block、V34 统一求职进度聚合、**V35 RIASEC 职业兴趣测评(Flyway V34,PASS=39/FAIL=0/SKIP=1)**、**V36 四维能力测评(Flyway V35,PASS=93/FAIL=0/SKIP=0)**、**V37 防刷风险概览与运营规则配置(Flyway V36,PASS=76/FAIL=0/SKIP=0)**、**V38 四维能力测评有限接入候选评分(无新 Flyway/公网接口,真实 MySQL/Docker 关键黑盒通过,待 Git 检查点)**、**V39 测评驱动岗位推荐(Flyway V37,真实 MySQL/Docker 关键黑盒通过,待 Git 检查点)** |
| 已完成、待真实 MySQL/Docker 复验 | — |
| 真实黑盒已验收、待 Git 检查点 | **V40 企业评价管理与官方回复(Flyway V38,5 paths / 6 operations,PASS=62/FAIL=0/SKIP=0)** |
| 真实黑盒已验收、待 Git 检查点 | **V40 企业评价管理与官方回复(Flyway V38,5 paths / 6 operations,PASS=62/FAIL=0/SKIP=0)**;**V41 企业人才发现推荐(Flyway V39,2 paths / 3 operations,PASS=37/FAIL=0)** |
| 已设计未编码 | 公平性检测、SHAP/规则解释、企业人才推荐 |
| 未开始 | AI 简历诊断/优化、AI 模拟面试、行业技能词云、入职准备清单、薪资计算器、诚信积分、消息撤回 |
@ -461,7 +461,7 @@ scoring_risk_scan_checkpoints
| P1 | 测评结果进入评分引擎、测评岗位推荐(V38/V39 已完成) |
| P1 | 统一求职进度(V34 已完成) |
| P1 | ~~企业评价管理~~(V40 自动化、`clean verify` 与真实 MySQL/Docker 黑盒已通过,待 Git 检查点) |
| P1 | 企业人才推荐 |
| P1 | ~~企业人才推荐~~(V41 已实现;新增 V39 授权迁移,自动化 1461/1461、真实 MySQL/Docker 黑盒 PASS=37/FAIL=0,待 Git 检查点) |
| P1 | 公平性检测 |
| P1 | 公平性检测 |
| P2 | SHAP 高级解释 |

53
docs/enterprise-talent-discovery-api.md

@ -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`)。

8
docs/frontend-integration.md

@ -866,6 +866,14 @@ POST /admin/scoring/risk/events/{eventId}/actions 人工标记(ACKNOWLEDGE/DI
> 验收状态:真实 MySQL/Docker 黑盒已通过(`PASS=62 FAIL=0 SKIP=0`);OpenAPI 159/196,待 Git 检查点。
## V41 企业人才发现
新增接口详见 [enterprise-talent-discovery-api.md](enterprise-talent-discovery-api.md)。学生先通过 `/profile/me/talent-discovery` 显式开启授权;企业仅能查询已认证企业自己的已发布岗位,岗位需具备 V39 审核通过且启用的画像。候选卡只返回不透明 `candidateKey`、匿名标签、城市、学历、毕业年份、白名单技能、匹配档位、固定概述和可用信号类别,不返回学生身份、联系方式、简历或任何测评分数。
V41 服务端先对全部合格候选计算并全局排序,再切分页;`fitScore` 不下发。无可用岗位画像返回 `UNAVAILABLE`,无候选返回 `NO_ELIGIBLE_CANDIDATE`。OpenAPI 实际为 161 paths / 199 operations(学生 GET/PUT 共用一个 path)。
> 验收状态:自动化测试 1461/1461 通过;真实 MySQL/Docker 黑盒已通过(`PASS=37 / FAIL=0`)。黑盒确认最新低分测评优先于历史高分、同企业任一历史投递均排除、其他企业投递不误排,并验证全局排序在分页之前完成。
### 24.1 接口
```text

1516
docs/openapi-rc.json

File diff suppressed because it is too large

366
docs/v41-enterprise-talent-recommendation-design.md

@ -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 岗位画像审核字段存在、认证企业判断可用、学生档案字段可满足候选卡白名单;如任一项与当前代码冲突,停止并报告,不自行改变接口、迁移号、隐私边界或排序口径。

27
docs/verify_openapi.py

@ -6,7 +6,7 @@ p = PROJECT_ROOT / 'docs' / 'openapi-rc.json'
d = json.load(open(p, encoding='utf-8'))
print('paths:', len(d['paths']))
assert len(d['paths']) == 159, f"Expected 159 paths, got {len(d['paths'])}"
assert len(d['paths']) == 161, f"Expected 161 paths, got {len(d['paths'])}"
paths = d['paths']
required = [
@ -351,10 +351,31 @@ for name in ('CompanyReviewItemResponse', 'CompanyReviewDetailResponse', 'Compan
assert 'officialReply' not in json.dumps(d['components']['schemas']['CompanyReviewReplyResponse'])
print(" V40 exactly 5 paths / 6 operations; request/response schemas are present and redacted")
# 统计 operation 总数并校验 V40 基线 196
# V41 企业人才发现:2 个唯一 path / 3 个 operation(学生 GET/PUT 共享一个 path)。
v41 = [
'/profile/me/talent-discovery',
'/companies/me/jobs/{jobId}/talent-recommendations',
]
assert all(k in paths for k in v41), "One or more V41 paths are missing"
assert len(v41) == 2, f"Expected exactly 2 V41 paths, got {len(v41)}"
assert sum(len(paths[k]) for k in v41) == 3, "Expected exactly 3 V41 operations"
assert 'get' in paths[v41[0]] and 'put' in paths[v41[0]], "V41 preference GET/PUT missing"
assert 'get' in paths[v41[1]], "V41 talent recommendation GET missing"
assert paths[v41[0]]['put'].get('requestBody', {}).get('content', {}).get('application/json', {}).get('schema', {}).get('$ref'), "V41 PUT requestBody schema missing"
for pt, method in [(v41[0], 'get'), (v41[0], 'put'), (v41[1], 'get')]:
assert response_ref(paths[pt][method]), f"{method.upper()} {pt} 200 schema missing"
candidate_schema = json.dumps(d['components']['schemas']['TalentDiscoveryCandidateResponse'])
for f in ['studentUserId', 'candidateId', 'phone', 'email', 'resumeUrl', 'attemptId', 'resultId',
'answerKey', 'scoringRule', 'questionCode', 'competencyTag', 'dimensionScores',
'overallScore', 'percentile', 'fitScore', 'gender', 'birthDate', 'password', 'token']:
assert f not in candidate_schema, f"V41 candidate schema exposes {f}"
assert 'candidateKey' in candidate_schema and 'studentUserId' not in candidate_schema
print(" V41 exactly 2 paths / 3 operations; candidate card schema is redacted")
# 统计 operation 总数并校验 V41 基线 199
operations = sum(len(methods) for methods in d['paths'].values())
print('operations:', operations)
assert operations == 196, f"Expected 196 operations, got {operations}"
assert operations == 199, f"Expected 199 operations, got {operations}"
print(" Offer advance endpoints all present")
print(" 26 operations all present")
print(" Account deletion endpoints present (3 operations)")

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

@ -78,6 +78,9 @@ public class SecurityConfig {
.requestMatchers("/auth/me").authenticated()
// 企业资料(需登录)
.requestMatchers("/companies/me/**").authenticated()
// V41 学生人才发现授权与企业人才发现推荐(角色和归属由 Service 校验)
.requestMatchers("/profile/me/talent-discovery").authenticated()
.requestMatchers("/companies/me/jobs/*/talent-recommendations").authenticated()
// 企业公开岗位列表(匿名 GET)
.requestMatchers(HttpMethod.GET, "/companies/*/jobs").permitAll()
// 企业公开主页(匿名 GET)

35
mydreamjob-server/src/main/java/com/mydreamjob/talentdiscovery/CandidateKeyGenerator.java

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

57
mydreamjob-server/src/main/java/com/mydreamjob/talentdiscovery/StudentTalentDiscoveryPreference.java

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

17
mydreamjob-server/src/main/java/com/mydreamjob/talentdiscovery/StudentTalentDiscoveryPreferenceRepository.java

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

215
mydreamjob-server/src/main/java/com/mydreamjob/talentdiscovery/TalentDiscoveryCandidateSource.java

@ -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) { }
}

71
mydreamjob-server/src/main/java/com/mydreamjob/talentdiscovery/TalentDiscoveryController.java

@ -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"); }
}

46
mydreamjob-server/src/main/java/com/mydreamjob/talentdiscovery/TalentDiscoveryScoreCalculator.java

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

298
mydreamjob-server/src/main/java/com/mydreamjob/talentdiscovery/TalentDiscoveryService.java

@ -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) { }
}

26
mydreamjob-server/src/main/java/com/mydreamjob/talentdiscovery/dto/TalentDiscoveryCandidateResponse.java

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

24
mydreamjob-server/src/main/java/com/mydreamjob/talentdiscovery/dto/TalentDiscoveryPageResponse.java

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

24
mydreamjob-server/src/main/java/com/mydreamjob/talentdiscovery/dto/TalentDiscoveryPreferenceResponse.java

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

17
mydreamjob-server/src/main/java/com/mydreamjob/talentdiscovery/dto/TalentDiscoveryPreferenceUpdateRequest.java

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

16
mydreamjob-server/src/main/resources/db/migration/V39__enterprise_talent_discovery.sql

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

20
mydreamjob-server/src/test/java/com/mydreamjob/talentdiscovery/CandidateKeyGeneratorTest.java

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

138
mydreamjob-server/src/test/java/com/mydreamjob/talentdiscovery/TalentDiscoveryControllerTest.java

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

41
mydreamjob-server/src/test/java/com/mydreamjob/talentdiscovery/TalentDiscoveryPersistenceIntegrationTest.java

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

47
mydreamjob-server/src/test/java/com/mydreamjob/talentdiscovery/TalentDiscoveryScoreCalculatorTest.java

@ -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")));
}
}

163
mydreamjob-server/src/test/java/com/mydreamjob/talentdiscovery/TalentDiscoveryServiceTest.java

@ -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…
Cancel
Save