Browse Source

feat: deliver V41 enterprise talent discovery

feat/message-recall
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. 1520
      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 静态与运行时校验通过) | | OpenAPI | **161 paths、199 operations**(V41 静态、运行时与校验脚本对账通过;学生 GET/PUT 共用一个 path) |
| Flyway | **V1–V38 已成功执行**;V38 为企业评价官方回复迁移(功能版本 V40,真实 MySQL/Docker 已验收) | | Flyway | **V1–V39 已成功执行**;V39 为企业人才发现授权迁移(功能版本 V41,真实 MySQL/Docker 已验收) |
| 全量测试 | V40 专项与全量测试、`mvn clean verify` 已通过 **1445/1445**;真实 MySQL/Docker 黑盒 `PASS=62/FAIL=0/SKIP=0` | | 全量测试 | 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、看板等核心闭环 | | 已完成范围 | 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 模块状态分类 ### 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 检查点)** | | 已完成并已验收 | 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 复验 | — | | 已完成、待真实 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/规则解释、企业人才推荐 | | 已设计未编码 | 公平性检测、SHAP/规则解释、企业人才推荐 |
| 未开始 | AI 简历诊断/优化、AI 模拟面试、行业技能词云、入职准备清单、薪资计算器、诚信积分、消息撤回 | | 未开始 | AI 简历诊断/优化、AI 模拟面试、行业技能词云、入职准备清单、薪资计算器、诚信积分、消息撤回 |
@ -461,7 +461,7 @@ scoring_risk_scan_checkpoints
| P1 | 测评结果进入评分引擎、测评岗位推荐(V38/V39 已完成) | | P1 | 测评结果进入评分引擎、测评岗位推荐(V38/V39 已完成) |
| P1 | 统一求职进度(V34 已完成) | | P1 | 统一求职进度(V34 已完成) |
| P1 | ~~企业评价管理~~(V40 自动化、`clean verify` 与真实 MySQL/Docker 黑盒已通过,待 Git 检查点) | | P1 | ~~企业评价管理~~(V40 自动化、`clean verify` 与真实 MySQL/Docker 黑盒已通过,待 Git 检查点) |
| P1 | 企业人才推荐 | | P1 | ~~企业人才推荐~~(V41 已实现;新增 V39 授权迁移,自动化 1461/1461、真实 MySQL/Docker 黑盒 PASS=37/FAIL=0,待 Git 检查点) |
| P1 | 公平性检测 | | P1 | 公平性检测 |
| P1 | 公平性检测 | | P1 | 公平性检测 |
| P2 | SHAP 高级解释 | | 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 检查点。 > 验收状态:真实 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 接口 ### 24.1 接口
```text ```text

1520
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')) d = json.load(open(p, encoding='utf-8'))
print('paths:', len(d['paths'])) 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'] paths = d['paths']
required = [ required = [
@ -351,10 +351,31 @@ for name in ('CompanyReviewItemResponse', 'CompanyReviewDetailResponse', 'Compan
assert 'officialReply' not in json.dumps(d['components']['schemas']['CompanyReviewReplyResponse']) assert 'officialReply' not in json.dumps(d['components']['schemas']['CompanyReviewReplyResponse'])
print(" V40 exactly 5 paths / 6 operations; request/response schemas are present and redacted") 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()) operations = sum(len(methods) for methods in d['paths'].values())
print('operations:', operations) 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(" Offer advance endpoints all present")
print(" 26 operations all present") print(" 26 operations all present")
print(" Account deletion endpoints present (3 operations)") 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("/auth/me").authenticated()
// 企业资料(需登录) // 企业资料(需登录)
.requestMatchers("/companies/me/**").authenticated() .requestMatchers("/companies/me/**").authenticated()
// V41 学生人才发现授权与企业人才发现推荐(角色和归属由 Service 校验)
.requestMatchers("/profile/me/talent-discovery").authenticated()
.requestMatchers("/companies/me/jobs/*/talent-recommendations").authenticated()
// 企业公开岗位列表(匿名 GET) // 企业公开岗位列表(匿名 GET)
.requestMatchers(HttpMethod.GET, "/companies/*/jobs").permitAll() .requestMatchers(HttpMethod.GET, "/companies/*/jobs").permitAll()
// 企业公开主页(匿名 GET) // 企业公开主页(匿名 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