From 5cc2c866bc111800bb8585a1fa9f27ccce8c6fae Mon Sep 17 00:00:00 2001 From: Zhang Shun Date: Fri, 4 Sep 2026 17:51:32 +0800 Subject: [PATCH] feat: deliver V41 enterprise talent discovery --- backend-infra/v41_blackbox.ps1 | 245 +++ docs/backend-development-roadmap.md | 12 +- docs/enterprise-talent-discovery-api.md | 53 + docs/frontend-integration.md | 8 + docs/openapi-rc.json | 1516 ++++++++++++++++- ...enterprise-talent-recommendation-design.md | 366 ++++ docs/verify_openapi.py | 27 +- .../com/mydreamjob/config/SecurityConfig.java | 3 + .../CandidateKeyGenerator.java | 35 + .../StudentTalentDiscoveryPreference.java | 57 + ...ntTalentDiscoveryPreferenceRepository.java | 17 + .../TalentDiscoveryCandidateSource.java | 215 +++ .../TalentDiscoveryController.java | 71 + .../TalentDiscoveryScoreCalculator.java | 46 + .../TalentDiscoveryService.java | 298 ++++ .../dto/TalentDiscoveryCandidateResponse.java | 26 + .../dto/TalentDiscoveryPageResponse.java | 24 + .../TalentDiscoveryPreferenceResponse.java | 24 + ...alentDiscoveryPreferenceUpdateRequest.java | 17 + .../V39__enterprise_talent_discovery.sql | 16 + .../CandidateKeyGeneratorTest.java | 20 + .../TalentDiscoveryControllerTest.java | 138 ++ ...ntDiscoveryPersistenceIntegrationTest.java | 41 + .../TalentDiscoveryScoreCalculatorTest.java | 47 + .../TalentDiscoveryServiceTest.java | 163 ++ 25 files changed, 3438 insertions(+), 47 deletions(-) create mode 100644 backend-infra/v41_blackbox.ps1 create mode 100644 docs/enterprise-talent-discovery-api.md create mode 100644 docs/v41-enterprise-talent-recommendation-design.md create mode 100644 mydreamjob-server/src/main/java/com/mydreamjob/talentdiscovery/CandidateKeyGenerator.java create mode 100644 mydreamjob-server/src/main/java/com/mydreamjob/talentdiscovery/StudentTalentDiscoveryPreference.java create mode 100644 mydreamjob-server/src/main/java/com/mydreamjob/talentdiscovery/StudentTalentDiscoveryPreferenceRepository.java create mode 100644 mydreamjob-server/src/main/java/com/mydreamjob/talentdiscovery/TalentDiscoveryCandidateSource.java create mode 100644 mydreamjob-server/src/main/java/com/mydreamjob/talentdiscovery/TalentDiscoveryController.java create mode 100644 mydreamjob-server/src/main/java/com/mydreamjob/talentdiscovery/TalentDiscoveryScoreCalculator.java create mode 100644 mydreamjob-server/src/main/java/com/mydreamjob/talentdiscovery/TalentDiscoveryService.java create mode 100644 mydreamjob-server/src/main/java/com/mydreamjob/talentdiscovery/dto/TalentDiscoveryCandidateResponse.java create mode 100644 mydreamjob-server/src/main/java/com/mydreamjob/talentdiscovery/dto/TalentDiscoveryPageResponse.java create mode 100644 mydreamjob-server/src/main/java/com/mydreamjob/talentdiscovery/dto/TalentDiscoveryPreferenceResponse.java create mode 100644 mydreamjob-server/src/main/java/com/mydreamjob/talentdiscovery/dto/TalentDiscoveryPreferenceUpdateRequest.java create mode 100644 mydreamjob-server/src/main/resources/db/migration/V39__enterprise_talent_discovery.sql create mode 100644 mydreamjob-server/src/test/java/com/mydreamjob/talentdiscovery/CandidateKeyGeneratorTest.java create mode 100644 mydreamjob-server/src/test/java/com/mydreamjob/talentdiscovery/TalentDiscoveryControllerTest.java create mode 100644 mydreamjob-server/src/test/java/com/mydreamjob/talentdiscovery/TalentDiscoveryPersistenceIntegrationTest.java create mode 100644 mydreamjob-server/src/test/java/com/mydreamjob/talentdiscovery/TalentDiscoveryScoreCalculatorTest.java create mode 100644 mydreamjob-server/src/test/java/com/mydreamjob/talentdiscovery/TalentDiscoveryServiceTest.java diff --git a/backend-infra/v41_blackbox.ps1 b/backend-infra/v41_blackbox.ps1 new file mode 100644 index 0000000..5681ae1 --- /dev/null +++ b/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 +} diff --git a/docs/backend-development-roadmap.md b/docs/backend-development-roadmap.md index 95c96fa..d85578f 100644 --- a/docs/backend-development-roadmap.md +++ b/docs/backend-development-roadmap.md @@ -26,11 +26,11 @@ | 项目 | 当前状态 | |---|---| -| OpenAPI | **159 paths、196 operations**(V40 静态与运行时校验通过) | -| Flyway | **V1–V38 已成功执行**;V38 为企业评价官方回复迁移(功能版本 V40,真实 MySQL/Docker 已验收) | -| 全量测试 | V40 专项与全量测试、`mvn clean verify` 已通过 **1445/1445**;真实 MySQL/Docker 黑盒 `PASS=62/FAIL=0/SKIP=0` | +| OpenAPI | **161 paths、199 operations**(V41 静态、运行时与校验脚本对账通过;学生 GET/PUT 共用一个 path) | +| Flyway | **V1–V39 已成功执行**;V39 为企业人才发现授权迁移(功能版本 V41,真实 MySQL/Docker 已验收) | +| 全量测试 | V41 全量自动化测试 **1461/1461** 通过;真实 MySQL/Docker 黑盒 `PASS=37/FAIL=0`(V40 黑盒结论 `PASS=62/FAIL=0/SKIP=0` 保持有效) | | 已完成范围 | V24 Candidate Scoring、V25 Governance、V26 Offer Advance、V27 Privacy、V28/V29 Account Deletion、V30 Data Export、V31 Interview Score、V32 Competition Ranking、V33 Report/Block、V34 统一求职进度聚合、**V35 RIASEC 职业兴趣测评(Flyway V34,已真实验收)**、**V36 四维能力测评(Flyway V35)**、**V37 防刷风险概览与运营规则配置(Flyway V36)**、**V38 四维能力测评有限接入候选评分(无新 Flyway、无新公网接口)**,及认证、资料、岗位、投递、面试、Offer、看板等核心闭环 | -| 当前结论 | V35–V39 的既有验收结论保持不变。**V40 企业评价管理与官方回复已完成首版实现、专项/全量自动化测试、`mvn clean verify`、静态/运行时 OpenAPI 与真实 MySQL/Docker 黑盒验收(PASS=62/FAIL=0/SKIP=0);待建立 Git 检查点。** OpenAPI 为 159/196。 | +| 当前结论 | V35–V40 的既有验收结论保持不变。**V41 企业人才发现推荐已完成实现、自动化测试、静态/运行时 OpenAPI 对账与真实 MySQL/Docker 黑盒验收(PASS=37/FAIL=0),可建立 Git 检查点。** 当前 OpenAPI 为 161/199。 | | 本规划范围 | 代码修复、新功能开发、接口契约、数据权限和治理能力 | ### 2.0 模块状态分类 @@ -39,7 +39,7 @@ |---|---| | 已完成并已验收 | V24 Candidate Scoring、V25 Governance、V26 Offer Advance、V27 Privacy、V28/V29 Account Deletion、V30 Data Export、V31 Interview Score、V32 Competition Ranking、V33 Report/Block、V34 统一求职进度聚合、**V35 RIASEC 职业兴趣测评(Flyway V34,PASS=39/FAIL=0/SKIP=1)**、**V36 四维能力测评(Flyway V35,PASS=93/FAIL=0/SKIP=0)**、**V37 防刷风险概览与运营规则配置(Flyway V36,PASS=76/FAIL=0/SKIP=0)**、**V38 四维能力测评有限接入候选评分(无新 Flyway/公网接口,真实 MySQL/Docker 关键黑盒通过,待 Git 检查点)**、**V39 测评驱动岗位推荐(Flyway V37,真实 MySQL/Docker 关键黑盒通过,待 Git 检查点)** | | 已完成、待真实 MySQL/Docker 复验 | — | -| 真实黑盒已验收、待 Git 检查点 | **V40 企业评价管理与官方回复(Flyway V38,5 paths / 6 operations,PASS=62/FAIL=0/SKIP=0)** | +| 真实黑盒已验收、待 Git 检查点 | **V40 企业评价管理与官方回复(Flyway V38,5 paths / 6 operations,PASS=62/FAIL=0/SKIP=0)**;**V41 企业人才发现推荐(Flyway V39,2 paths / 3 operations,PASS=37/FAIL=0)** | | 已设计未编码 | 公平性检测、SHAP/规则解释、企业人才推荐 | | 未开始 | AI 简历诊断/优化、AI 模拟面试、行业技能词云、入职准备清单、薪资计算器、诚信积分、消息撤回 | @@ -461,7 +461,7 @@ scoring_risk_scan_checkpoints | P1 | 测评结果进入评分引擎、测评岗位推荐(V38/V39 已完成) | | P1 | 统一求职进度(V34 已完成) | | P1 | ~~企业评价管理~~(V40 自动化、`clean verify` 与真实 MySQL/Docker 黑盒已通过,待 Git 检查点) | -| P1 | 企业人才推荐 | +| P1 | ~~企业人才推荐~~(V41 已实现;新增 V39 授权迁移,自动化 1461/1461、真实 MySQL/Docker 黑盒 PASS=37/FAIL=0,待 Git 检查点) | | P1 | 公平性检测 | | P1 | 公平性检测 | | P2 | SHAP 高级解释 | diff --git a/docs/enterprise-talent-discovery-api.md b/docs/enterprise-talent-discovery-api.md new file mode 100644 index 0000000..f0e598b --- /dev/null +++ b/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`)。 diff --git a/docs/frontend-integration.md b/docs/frontend-integration.md index d3b2614..dfc0bae 100644 --- a/docs/frontend-integration.md +++ b/docs/frontend-integration.md @@ -866,6 +866,14 @@ POST /admin/scoring/risk/events/{eventId}/actions 人工标记(ACKNOWLEDGE/DI > 验收状态:真实 MySQL/Docker 黑盒已通过(`PASS=62 FAIL=0 SKIP=0`);OpenAPI 159/196,待 Git 检查点。 +## V41 企业人才发现 + +新增接口详见 [enterprise-talent-discovery-api.md](enterprise-talent-discovery-api.md)。学生先通过 `/profile/me/talent-discovery` 显式开启授权;企业仅能查询已认证企业自己的已发布岗位,岗位需具备 V39 审核通过且启用的画像。候选卡只返回不透明 `candidateKey`、匿名标签、城市、学历、毕业年份、白名单技能、匹配档位、固定概述和可用信号类别,不返回学生身份、联系方式、简历或任何测评分数。 + +V41 服务端先对全部合格候选计算并全局排序,再切分页;`fitScore` 不下发。无可用岗位画像返回 `UNAVAILABLE`,无候选返回 `NO_ELIGIBLE_CANDIDATE`。OpenAPI 实际为 161 paths / 199 operations(学生 GET/PUT 共用一个 path)。 + +> 验收状态:自动化测试 1461/1461 通过;真实 MySQL/Docker 黑盒已通过(`PASS=37 / FAIL=0`)。黑盒确认最新低分测评优先于历史高分、同企业任一历史投递均排除、其他企业投递不误排,并验证全局排序在分页之前完成。 + ### 24.1 接口 ```text diff --git a/docs/openapi-rc.json b/docs/openapi-rc.json index 4e20402..af96c5f 100644 --- a/docs/openapi-rc.json +++ b/docs/openapi-rc.json @@ -8049,47 +8049,1320 @@ } } }, - "/job-recommendation-taxonomies": {"get":{"tags":["岗位推荐"],"operationId":"recommendationTaxonomies","responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponseListTaxonomyResponse"}}}}}}}, - "/profile/me/job-recommendations": {"get":{"tags":["岗位推荐"],"operationId":"myJobRecommendations","parameters":[{"name":"page","in":"query","schema":{"type":"integer"}},{"name":"size","in":"query","schema":{"type":"integer"}}],"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponsePageJobRecommendationItem"}}}}}}}, - "/profile/me/job-recommendations/{jobId}/feedback": {"post":{"tags":["岗位推荐"],"operationId":"createRecommendationFeedback","requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/RecommendationFeedbackRequest"}}}},"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponseRecommendationFeedbackResponse"}}}}}},"delete":{"tags":["岗位推荐"],"operationId":"revokeNotInterestedFeedback","parameters":[{"name":"expectedVersion","in":"query","required":true,"schema":{"type":"integer","format":"int64"}}],"responses":{"200":{"description":"OK"}}}}, - "/companies/me/jobs/{jobId}/recommendation-profile": {"get":{"tags":["岗位推荐"],"operationId":"companyRecommendationProfile","responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponseRecommendationProfileResponse"}}}}}},"put":{"tags":["岗位推荐"],"operationId":"updateCompanyRecommendationProfile","requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/RecommendationProfileRequest"}}}},"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponseRecommendationProfileResponse"}}}}}}}, - "/admin/job-recommendation-profiles": {"get":{"tags":["岗位推荐"],"operationId":"adminRecommendationProfiles","responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponsePageRecommendationProfileResponse"}}}}}}}, - "/admin/job-recommendation-profiles/{jobId}/decision": {"post":{"tags":["岗位推荐"],"operationId":"decideRecommendationProfile","requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/RecommendationDecisionRequest"}}}},"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponseRecommendationProfileResponse"}}}}}}}, - "/companies/me/job-reviews": {"get":{"tags":["企业评价管理"],"operationId":"listCompanyJobReviews","parameters":[{"name":"jobId","in":"query","schema":{"type":"integer","format":"int64"}},{"name":"rating","in":"query","schema":{"type":"integer"}},{"name":"reviewModerationStatus","in":"query","schema":{"type":"string"}},{"name":"replyStatus","in":"query","schema":{"type":"string"}},{"name":"createdFrom","in":"query","schema":{"type":"string","format":"date-time"}},{"name":"createdTo","in":"query","schema":{"type":"string","format":"date-time"}},{"name":"page","in":"query","schema":{"type":"integer","default":0}},{"name":"size","in":"query","schema":{"type":"integer","default":20}}],"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponseReviewReplyPageResponse"}}}}}}}, - "/companies/me/job-reviews/{reviewId}": {"get":{"tags":["企业评价管理"],"operationId":"getCompanyJobReview","parameters":[{"name":"reviewId","in":"path","required":true,"schema":{"type":"integer","format":"int64"}}],"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponseCompanyReviewDetailResponse"}}}}}}}, - "/companies/me/job-reviews/{reviewId}/reply": {"post":{"tags":["企业评价管理"],"operationId":"createCompanyReviewReply","parameters":[{"name":"reviewId","in":"path","required":true,"schema":{"type":"integer","format":"int64"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateCompanyReviewReplyRequest"}}}},"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponseCompanyReviewReplyResponse"}}}}}},"put":{"tags":["企业评价管理"],"operationId":"reviseCompanyReviewReply","parameters":[{"name":"reviewId","in":"path","required":true,"schema":{"type":"integer","format":"int64"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateCompanyReviewReplyRequest"}}}},"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponseCompanyReviewReplyResponse"}}}}}}}, - "/admin/review-replies/pending": {"get":{"tags":["官方回复审核"],"operationId":"listPendingReviewReplies","parameters":[{"name":"companyId","in":"query","schema":{"type":"integer","format":"int64"}},{"name":"jobId","in":"query","schema":{"type":"integer","format":"int64"}},{"name":"riskLevel","in":"query","schema":{"type":"string"}},{"name":"createdFrom","in":"query","schema":{"type":"string","format":"date-time"}},{"name":"createdTo","in":"query","schema":{"type":"string","format":"date-time"}},{"name":"page","in":"query","schema":{"type":"integer","default":0}},{"name":"size","in":"query","schema":{"type":"integer","default":20}}],"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponseAdminReviewReplyPageResponse"}}}}}}}, - "/admin/review-replies/{replyId}/decision": {"post":{"tags":["官方回复审核"],"operationId":"decideReviewReply","parameters":[{"name":"replyId","in":"path","required":true,"schema":{"type":"integer","format":"int64"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ReviewReplyDecisionRequest"}}}},"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponseAdminReviewReplyResponse"}}}}}}} + "/job-recommendation-taxonomies": { + "get": { + "tags": [ + "岗位推荐" + ], + "operationId": "recommendationTaxonomies", + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResponseListTaxonomyResponse" + } + } + } + } + } + } + }, + "/profile/me/job-recommendations": { + "get": { + "tags": [ + "岗位推荐" + ], + "operationId": "myJobRecommendations", + "parameters": [ + { + "name": "page", + "in": "query", + "schema": { + "type": "integer" + } + }, + { + "name": "size", + "in": "query", + "schema": { + "type": "integer" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResponsePageJobRecommendationItem" + } + } + } + } + } + } + }, + "/profile/me/job-recommendations/{jobId}/feedback": { + "post": { + "tags": [ + "岗位推荐" + ], + "operationId": "createRecommendationFeedback", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RecommendationFeedbackRequest" + } + } + } + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResponseRecommendationFeedbackResponse" + } + } + } + } + } + }, + "delete": { + "tags": [ + "岗位推荐" + ], + "operationId": "revokeNotInterestedFeedback", + "parameters": [ + { + "name": "expectedVersion", + "in": "query", + "required": true, + "schema": { + "type": "integer", + "format": "int64" + } + } + ], + "responses": { + "200": { + "description": "OK" + } + } + } + }, + "/companies/me/jobs/{jobId}/recommendation-profile": { + "get": { + "tags": [ + "岗位推荐" + ], + "operationId": "companyRecommendationProfile", + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResponseRecommendationProfileResponse" + } + } + } + } + } + }, + "put": { + "tags": [ + "岗位推荐" + ], + "operationId": "updateCompanyRecommendationProfile", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RecommendationProfileRequest" + } + } + } + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResponseRecommendationProfileResponse" + } + } + } + } + } + } + }, + "/admin/job-recommendation-profiles": { + "get": { + "tags": [ + "岗位推荐" + ], + "operationId": "adminRecommendationProfiles", + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResponsePageRecommendationProfileResponse" + } + } + } + } + } + } + }, + "/admin/job-recommendation-profiles/{jobId}/decision": { + "post": { + "tags": [ + "岗位推荐" + ], + "operationId": "decideRecommendationProfile", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RecommendationDecisionRequest" + } + } + } + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResponseRecommendationProfileResponse" + } + } + } + } + } + } + }, + "/companies/me/job-reviews": { + "get": { + "tags": [ + "企业评价管理" + ], + "operationId": "listCompanyJobReviews", + "parameters": [ + { + "name": "jobId", + "in": "query", + "schema": { + "type": "integer", + "format": "int64" + } + }, + { + "name": "rating", + "in": "query", + "schema": { + "type": "integer" + } + }, + { + "name": "reviewModerationStatus", + "in": "query", + "schema": { + "type": "string" + } + }, + { + "name": "replyStatus", + "in": "query", + "schema": { + "type": "string" + } + }, + { + "name": "createdFrom", + "in": "query", + "schema": { + "type": "string", + "format": "date-time" + } + }, + { + "name": "createdTo", + "in": "query", + "schema": { + "type": "string", + "format": "date-time" + } + }, + { + "name": "page", + "in": "query", + "schema": { + "type": "integer", + "default": 0 + } + }, + { + "name": "size", + "in": "query", + "schema": { + "type": "integer", + "default": 20 + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResponseReviewReplyPageResponse" + } + } + } + } + } + } + }, + "/companies/me/job-reviews/{reviewId}": { + "get": { + "tags": [ + "企业评价管理" + ], + "operationId": "getCompanyJobReview", + "parameters": [ + { + "name": "reviewId", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "format": "int64" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResponseCompanyReviewDetailResponse" + } + } + } + } + } + } + }, + "/companies/me/job-reviews/{reviewId}/reply": { + "post": { + "tags": [ + "企业评价管理" + ], + "operationId": "createCompanyReviewReply", + "parameters": [ + { + "name": "reviewId", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "format": "int64" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateCompanyReviewReplyRequest" + } + } + } + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResponseCompanyReviewReplyResponse" + } + } + } + } + } + }, + "put": { + "tags": [ + "企业评价管理" + ], + "operationId": "reviseCompanyReviewReply", + "parameters": [ + { + "name": "reviewId", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "format": "int64" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdateCompanyReviewReplyRequest" + } + } + } + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResponseCompanyReviewReplyResponse" + } + } + } + } + } + } + }, + "/admin/review-replies/pending": { + "get": { + "tags": [ + "官方回复审核" + ], + "operationId": "listPendingReviewReplies", + "parameters": [ + { + "name": "companyId", + "in": "query", + "schema": { + "type": "integer", + "format": "int64" + } + }, + { + "name": "jobId", + "in": "query", + "schema": { + "type": "integer", + "format": "int64" + } + }, + { + "name": "riskLevel", + "in": "query", + "schema": { + "type": "string" + } + }, + { + "name": "createdFrom", + "in": "query", + "schema": { + "type": "string", + "format": "date-time" + } + }, + { + "name": "createdTo", + "in": "query", + "schema": { + "type": "string", + "format": "date-time" + } + }, + { + "name": "page", + "in": "query", + "schema": { + "type": "integer", + "default": 0 + } + }, + { + "name": "size", + "in": "query", + "schema": { + "type": "integer", + "default": 20 + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResponseAdminReviewReplyPageResponse" + } + } + } + } + } + } + }, + "/admin/review-replies/{replyId}/decision": { + "post": { + "tags": [ + "官方回复审核" + ], + "operationId": "decideReviewReply", + "parameters": [ + { + "name": "replyId", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "format": "int64" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ReviewReplyDecisionRequest" + } + } + } + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResponseAdminReviewReplyResponse" + } + } + } + } + } + } + }, + "/profile/me/talent-discovery": { + "get": { + "tags": [ + "企业人才发现" + ], + "summary": "查询人才发现授权", + "responses": { + "200": { + "description": "成功", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResponseTalentDiscoveryPreferenceResponse" + } + } + } + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + }, + "put": { + "tags": [ + "企业人才发现" + ], + "summary": "更新人才发现授权", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TalentDiscoveryPreferenceUpdateRequest" + } + } + } + }, + "responses": { + "200": { + "description": "成功", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResponseTalentDiscoveryPreferenceResponse" + } + } + } + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + } + }, + "/companies/me/jobs/{jobId}/talent-recommendations": { + "get": { + "tags": [ + "企业人才发现" + ], + "summary": "查询岗位人才发现推荐", + "parameters": [ + { + "name": "jobId", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "format": "int64" + } + }, + { + "name": "page", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "format": "int32", + "default": 0, + "minimum": 0 + } + }, + { + "name": "size", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "format": "int32", + "default": 20, + "minimum": 1, + "maximum": 50 + } + } + ], + "responses": { + "200": { + "description": "成功", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResponseTalentDiscoveryPageResponse" + } + } + } + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + } + } }, "components": { "schemas": { - "TaxonomyResponse":{"type":"object","properties":{"code":{"type":"string"},"parentCode":{"type":"string"},"type":{"type":"string"},"displayName":{"type":"string"}}}, - "RecommendationFeedbackRequest":{"type":"object","required":["feedbackType","idempotencyKey","expectedVersion"],"properties":{"feedbackType":{"type":"string"},"reasonType":{"type":"string"},"idempotencyKey":{"type":"string"},"expectedVersion":{"type":"integer","format":"int64"}}}, - "RecommendationProfileRequest":{"type":"object","required":["industryCode","jobFamilyCode","enabled","expectedVersion"],"properties":{"industryCode":{"type":"string"},"jobFamilyCode":{"type":"string"},"riasecWeights":{"type":"object","additionalProperties":{"type":"integer"}},"abilityExpectations":{"type":"object","additionalProperties":{"type":"integer"}},"enabled":{"type":"boolean"},"expectedVersion":{"type":"integer","format":"int64"}}}, - "RecommendationDecisionRequest":{"type":"object","required":["decision","expectedVersion"],"properties":{"decision":{"type":"string","enum":["APPROVE","REJECT"]},"reasonCode":{"type":"string"},"expectedVersion":{"type":"integer","format":"int64"}}}, - "RecommendationProfileResponse":{"type":"object","properties":{"jobId":{"type":"integer","format":"int64"},"industryCode":{"type":"string"},"jobFamilyCode":{"type":"string"},"riasecWeights":{"type":"object","additionalProperties":{"type":"integer"}},"abilityExpectations":{"type":"object","additionalProperties":{"type":"integer"}},"enabled":{"type":"boolean"},"reviewStatus":{"type":"string"},"reviewReason":{"type":"string"},"version":{"type":"integer","format":"int64"},"updatedAt":{"type":"string","format":"date-time"}}}, - "RecommendationFeedbackResponse":{"type":"object","properties":{"jobId":{"type":"integer","format":"int64"},"feedbackType":{"type":"string"},"reasonType":{"type":"string"},"status":{"type":"string"},"version":{"type":"integer","format":"int64"}}}, - "JobRecommendationItem":{"type":"object","properties":{"jobId":{"type":"integer","format":"int64"},"title":{"type":"string"},"city":{"type":"string"},"employmentType":{"type":"string"},"salaryMin":{"type":"integer"},"salaryMax":{"type":"integer"},"mode":{"type":"string","enum":["PERSONALIZED","FALLBACK"]},"score":{"type":"number"},"reasons":{"type":"array","items":{"type":"string"}}}}, - "ApiResponseListTaxonomyResponse":{"type":"object","properties":{"code":{"type":"integer"},"message":{"type":"string"},"data":{"type":"array","items":{"$ref":"#/components/schemas/TaxonomyResponse"}}}}, - "ApiResponseRecommendationFeedbackResponse":{"type":"object","properties":{"code":{"type":"integer"},"message":{"type":"string"},"data":{"$ref":"#/components/schemas/RecommendationFeedbackResponse"}}}, - "ApiResponseRecommendationProfileResponse":{"type":"object","properties":{"code":{"type":"integer"},"message":{"type":"string"},"data":{"$ref":"#/components/schemas/RecommendationProfileResponse"}}}, - "ApiResponsePageJobRecommendationItem":{"type":"object","properties":{"code":{"type":"integer"},"message":{"type":"string"},"data":{"type":"object","properties":{"items":{"type":"array","items":{"$ref":"#/components/schemas/JobRecommendationItem"}},"total":{"type":"integer"},"page":{"type":"integer"},"size":{"type":"integer"},"totalPages":{"type":"integer"}}}}}, - "ApiResponsePageRecommendationProfileResponse":{"type":"object","properties":{"code":{"type":"integer"},"message":{"type":"string"},"data":{"type":"object","properties":{"items":{"type":"array","items":{"$ref":"#/components/schemas/RecommendationProfileResponse"}},"total":{"type":"integer"},"page":{"type":"integer"},"size":{"type":"integer"},"totalPages":{"type":"integer"}}}}}, - "CompanyReviewJobResponse": {"type":"object","properties":{"id":{"type":"integer","format":"int64"},"title":{"type":"string"}}}, - "CompanyReviewReplyResponse": {"type":"object","properties":{"id":{"type":"integer","format":"int64"},"content":{"type":"string"},"status":{"type":"string"},"version":{"type":"integer","format":"int64"},"updatedAt":{"type":"string","format":"date-time"},"rejectionReason":{"type":"string","nullable":true}}}, - "CompanyReviewItemResponse": {"type":"object","properties":{"reviewId":{"type":"integer","format":"int64"},"job":{"$ref":"#/components/schemas/CompanyReviewJobResponse"},"rating":{"type":"integer"},"content":{"type":"string"},"tags":{"type":"array","items":{"type":"string"}},"dimensionScores":{"type":"object","additionalProperties":{"type":"integer"}},"reviewStatus":{"type":"string"},"reviewCreatedAt":{"type":"string","format":"date-time"},"reply":{"$ref":"#/components/schemas/CompanyReviewReplyResponse"}}}, - "CompanyReviewDetailResponse":{"allOf":[{"$ref":"#/components/schemas/CompanyReviewItemResponse"}]}, - "ReviewReplyPageResponse":{"type":"object","properties":{"items":{"type":"array","items":{"$ref":"#/components/schemas/CompanyReviewItemResponse"}},"total":{"type":"integer","format":"int64"},"page":{"type":"integer"},"size":{"type":"integer"},"totalPages":{"type":"integer"}}}, - "CreateCompanyReviewReplyRequest":{"type":"object","required":["content","idempotencyKey"],"properties":{"content":{"type":"string","minLength":1,"maxLength":500},"idempotencyKey":{"type":"string","format":"uuid","maxLength":64}}}, - "UpdateCompanyReviewReplyRequest":{"type":"object","required":["content","expectedVersion"],"properties":{"content":{"type":"string","minLength":1,"maxLength":500},"expectedVersion":{"type":"integer","format":"int64","minimum":0}}}, - "ReviewReplyDecisionRequest":{"type":"object","required":["decision"],"properties":{"decision":{"type":"string","enum":["APPROVE","REJECT"]},"reason":{"type":"string","maxLength":500}}}, - "AdminReviewReplyResponse":{"type":"object","properties":{"replyId":{"type":"integer","format":"int64"},"reviewId":{"type":"integer","format":"int64"},"jobId":{"type":"integer","format":"int64"},"jobTitle":{"type":"string"},"companyId":{"type":"integer","format":"int64"},"companyName":{"type":"string"},"rating":{"type":"integer"},"reviewContent":{"type":"string"},"reviewModerationStatus":{"type":"string"},"replyContent":{"type":"string"},"replyStatus":{"type":"string"},"version":{"type":"integer","format":"int64"},"riskScore":{"type":"number"},"riskLevel":{"type":"string"},"reasonCodes":{"type":"array","items":{"type":"string"}},"createdAt":{"type":"string","format":"date-time"}}}, - "AdminReviewReplyPageResponse":{"type":"object","properties":{"items":{"type":"array","items":{"$ref":"#/components/schemas/AdminReviewReplyResponse"}},"total":{"type":"integer","format":"int64"},"page":{"type":"integer"},"size":{"type":"integer"},"totalPages":{"type":"integer"}}}, - "ApiResponseReviewReplyPageResponse":{"type":"object","properties":{"code":{"type":"integer"},"message":{"type":"string"},"data":{"$ref":"#/components/schemas/ReviewReplyPageResponse"},"requestId":{"type":"string"},"timestamp":{"type":"string","format":"date-time"}}}, - "ApiResponseCompanyReviewDetailResponse":{"type":"object","properties":{"code":{"type":"integer"},"message":{"type":"string"},"data":{"$ref":"#/components/schemas/CompanyReviewDetailResponse"},"requestId":{"type":"string"},"timestamp":{"type":"string","format":"date-time"}}}, - "ApiResponseCompanyReviewReplyResponse":{"type":"object","properties":{"code":{"type":"integer"},"message":{"type":"string"},"data":{"$ref":"#/components/schemas/CompanyReviewReplyResponse"},"requestId":{"type":"string"},"timestamp":{"type":"string","format":"date-time"}}}, - "ApiResponseAdminReviewReplyPageResponse":{"type":"object","properties":{"code":{"type":"integer"},"message":{"type":"string"},"data":{"$ref":"#/components/schemas/AdminReviewReplyPageResponse"},"requestId":{"type":"string"},"timestamp":{"type":"string","format":"date-time"}}}, - "ApiResponseAdminReviewReplyResponse":{"type":"object","properties":{"code":{"type":"integer"},"message":{"type":"string"},"data":{"$ref":"#/components/schemas/AdminReviewReplyResponse"},"requestId":{"type":"string"},"timestamp":{"type":"string","format":"date-time"}}}, + "TaxonomyResponse": { + "type": "object", + "properties": { + "code": { + "type": "string" + }, + "parentCode": { + "type": "string" + }, + "type": { + "type": "string" + }, + "displayName": { + "type": "string" + } + } + }, + "RecommendationFeedbackRequest": { + "type": "object", + "required": [ + "feedbackType", + "idempotencyKey", + "expectedVersion" + ], + "properties": { + "feedbackType": { + "type": "string" + }, + "reasonType": { + "type": "string" + }, + "idempotencyKey": { + "type": "string" + }, + "expectedVersion": { + "type": "integer", + "format": "int64" + } + } + }, + "RecommendationProfileRequest": { + "type": "object", + "required": [ + "industryCode", + "jobFamilyCode", + "enabled", + "expectedVersion" + ], + "properties": { + "industryCode": { + "type": "string" + }, + "jobFamilyCode": { + "type": "string" + }, + "riasecWeights": { + "type": "object", + "additionalProperties": { + "type": "integer" + } + }, + "abilityExpectations": { + "type": "object", + "additionalProperties": { + "type": "integer" + } + }, + "enabled": { + "type": "boolean" + }, + "expectedVersion": { + "type": "integer", + "format": "int64" + } + } + }, + "RecommendationDecisionRequest": { + "type": "object", + "required": [ + "decision", + "expectedVersion" + ], + "properties": { + "decision": { + "type": "string", + "enum": [ + "APPROVE", + "REJECT" + ] + }, + "reasonCode": { + "type": "string" + }, + "expectedVersion": { + "type": "integer", + "format": "int64" + } + } + }, + "RecommendationProfileResponse": { + "type": "object", + "properties": { + "jobId": { + "type": "integer", + "format": "int64" + }, + "industryCode": { + "type": "string" + }, + "jobFamilyCode": { + "type": "string" + }, + "riasecWeights": { + "type": "object", + "additionalProperties": { + "type": "integer" + } + }, + "abilityExpectations": { + "type": "object", + "additionalProperties": { + "type": "integer" + } + }, + "enabled": { + "type": "boolean" + }, + "reviewStatus": { + "type": "string" + }, + "reviewReason": { + "type": "string" + }, + "version": { + "type": "integer", + "format": "int64" + }, + "updatedAt": { + "type": "string", + "format": "date-time" + } + } + }, + "RecommendationFeedbackResponse": { + "type": "object", + "properties": { + "jobId": { + "type": "integer", + "format": "int64" + }, + "feedbackType": { + "type": "string" + }, + "reasonType": { + "type": "string" + }, + "status": { + "type": "string" + }, + "version": { + "type": "integer", + "format": "int64" + } + } + }, + "JobRecommendationItem": { + "type": "object", + "properties": { + "jobId": { + "type": "integer", + "format": "int64" + }, + "title": { + "type": "string" + }, + "city": { + "type": "string" + }, + "employmentType": { + "type": "string" + }, + "salaryMin": { + "type": "integer" + }, + "salaryMax": { + "type": "integer" + }, + "mode": { + "type": "string", + "enum": [ + "PERSONALIZED", + "FALLBACK" + ] + }, + "score": { + "type": "number" + }, + "reasons": { + "type": "array", + "items": { + "type": "string" + } + } + } + }, + "ApiResponseListTaxonomyResponse": { + "type": "object", + "properties": { + "code": { + "type": "integer" + }, + "message": { + "type": "string" + }, + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/TaxonomyResponse" + } + } + } + }, + "ApiResponseRecommendationFeedbackResponse": { + "type": "object", + "properties": { + "code": { + "type": "integer" + }, + "message": { + "type": "string" + }, + "data": { + "$ref": "#/components/schemas/RecommendationFeedbackResponse" + } + } + }, + "ApiResponseRecommendationProfileResponse": { + "type": "object", + "properties": { + "code": { + "type": "integer" + }, + "message": { + "type": "string" + }, + "data": { + "$ref": "#/components/schemas/RecommendationProfileResponse" + } + } + }, + "ApiResponsePageJobRecommendationItem": { + "type": "object", + "properties": { + "code": { + "type": "integer" + }, + "message": { + "type": "string" + }, + "data": { + "type": "object", + "properties": { + "items": { + "type": "array", + "items": { + "$ref": "#/components/schemas/JobRecommendationItem" + } + }, + "total": { + "type": "integer" + }, + "page": { + "type": "integer" + }, + "size": { + "type": "integer" + }, + "totalPages": { + "type": "integer" + } + } + } + } + }, + "ApiResponsePageRecommendationProfileResponse": { + "type": "object", + "properties": { + "code": { + "type": "integer" + }, + "message": { + "type": "string" + }, + "data": { + "type": "object", + "properties": { + "items": { + "type": "array", + "items": { + "$ref": "#/components/schemas/RecommendationProfileResponse" + } + }, + "total": { + "type": "integer" + }, + "page": { + "type": "integer" + }, + "size": { + "type": "integer" + }, + "totalPages": { + "type": "integer" + } + } + } + } + }, + "CompanyReviewJobResponse": { + "type": "object", + "properties": { + "id": { + "type": "integer", + "format": "int64" + }, + "title": { + "type": "string" + } + } + }, + "CompanyReviewReplyResponse": { + "type": "object", + "properties": { + "id": { + "type": "integer", + "format": "int64" + }, + "content": { + "type": "string" + }, + "status": { + "type": "string" + }, + "version": { + "type": "integer", + "format": "int64" + }, + "updatedAt": { + "type": "string", + "format": "date-time" + }, + "rejectionReason": { + "type": "string", + "nullable": true + } + } + }, + "CompanyReviewItemResponse": { + "type": "object", + "properties": { + "reviewId": { + "type": "integer", + "format": "int64" + }, + "job": { + "$ref": "#/components/schemas/CompanyReviewJobResponse" + }, + "rating": { + "type": "integer" + }, + "content": { + "type": "string" + }, + "tags": { + "type": "array", + "items": { + "type": "string" + } + }, + "dimensionScores": { + "type": "object", + "additionalProperties": { + "type": "integer" + } + }, + "reviewStatus": { + "type": "string" + }, + "reviewCreatedAt": { + "type": "string", + "format": "date-time" + }, + "reply": { + "$ref": "#/components/schemas/CompanyReviewReplyResponse" + } + } + }, + "CompanyReviewDetailResponse": { + "allOf": [ + { + "$ref": "#/components/schemas/CompanyReviewItemResponse" + } + ] + }, + "ReviewReplyPageResponse": { + "type": "object", + "properties": { + "items": { + "type": "array", + "items": { + "$ref": "#/components/schemas/CompanyReviewItemResponse" + } + }, + "total": { + "type": "integer", + "format": "int64" + }, + "page": { + "type": "integer" + }, + "size": { + "type": "integer" + }, + "totalPages": { + "type": "integer" + } + } + }, + "CreateCompanyReviewReplyRequest": { + "type": "object", + "required": [ + "content", + "idempotencyKey" + ], + "properties": { + "content": { + "type": "string", + "minLength": 1, + "maxLength": 500 + }, + "idempotencyKey": { + "type": "string", + "format": "uuid", + "maxLength": 64 + } + } + }, + "UpdateCompanyReviewReplyRequest": { + "type": "object", + "required": [ + "content", + "expectedVersion" + ], + "properties": { + "content": { + "type": "string", + "minLength": 1, + "maxLength": 500 + }, + "expectedVersion": { + "type": "integer", + "format": "int64", + "minimum": 0 + } + } + }, + "ReviewReplyDecisionRequest": { + "type": "object", + "required": [ + "decision" + ], + "properties": { + "decision": { + "type": "string", + "enum": [ + "APPROVE", + "REJECT" + ] + }, + "reason": { + "type": "string", + "maxLength": 500 + } + } + }, + "AdminReviewReplyResponse": { + "type": "object", + "properties": { + "replyId": { + "type": "integer", + "format": "int64" + }, + "reviewId": { + "type": "integer", + "format": "int64" + }, + "jobId": { + "type": "integer", + "format": "int64" + }, + "jobTitle": { + "type": "string" + }, + "companyId": { + "type": "integer", + "format": "int64" + }, + "companyName": { + "type": "string" + }, + "rating": { + "type": "integer" + }, + "reviewContent": { + "type": "string" + }, + "reviewModerationStatus": { + "type": "string" + }, + "replyContent": { + "type": "string" + }, + "replyStatus": { + "type": "string" + }, + "version": { + "type": "integer", + "format": "int64" + }, + "riskScore": { + "type": "number" + }, + "riskLevel": { + "type": "string" + }, + "reasonCodes": { + "type": "array", + "items": { + "type": "string" + } + }, + "createdAt": { + "type": "string", + "format": "date-time" + } + } + }, + "AdminReviewReplyPageResponse": { + "type": "object", + "properties": { + "items": { + "type": "array", + "items": { + "$ref": "#/components/schemas/AdminReviewReplyResponse" + } + }, + "total": { + "type": "integer", + "format": "int64" + }, + "page": { + "type": "integer" + }, + "size": { + "type": "integer" + }, + "totalPages": { + "type": "integer" + } + } + }, + "ApiResponseReviewReplyPageResponse": { + "type": "object", + "properties": { + "code": { + "type": "integer" + }, + "message": { + "type": "string" + }, + "data": { + "$ref": "#/components/schemas/ReviewReplyPageResponse" + }, + "requestId": { + "type": "string" + }, + "timestamp": { + "type": "string", + "format": "date-time" + } + } + }, + "ApiResponseCompanyReviewDetailResponse": { + "type": "object", + "properties": { + "code": { + "type": "integer" + }, + "message": { + "type": "string" + }, + "data": { + "$ref": "#/components/schemas/CompanyReviewDetailResponse" + }, + "requestId": { + "type": "string" + }, + "timestamp": { + "type": "string", + "format": "date-time" + } + } + }, + "ApiResponseCompanyReviewReplyResponse": { + "type": "object", + "properties": { + "code": { + "type": "integer" + }, + "message": { + "type": "string" + }, + "data": { + "$ref": "#/components/schemas/CompanyReviewReplyResponse" + }, + "requestId": { + "type": "string" + }, + "timestamp": { + "type": "string", + "format": "date-time" + } + } + }, + "ApiResponseAdminReviewReplyPageResponse": { + "type": "object", + "properties": { + "code": { + "type": "integer" + }, + "message": { + "type": "string" + }, + "data": { + "$ref": "#/components/schemas/AdminReviewReplyPageResponse" + }, + "requestId": { + "type": "string" + }, + "timestamp": { + "type": "string", + "format": "date-time" + } + } + }, + "ApiResponseAdminReviewReplyResponse": { + "type": "object", + "properties": { + "code": { + "type": "integer" + }, + "message": { + "type": "string" + }, + "data": { + "$ref": "#/components/schemas/AdminReviewReplyResponse" + }, + "requestId": { + "type": "string" + }, + "timestamp": { + "type": "string", + "format": "date-time" + } + } + }, "AccountDeletionResponse": { "type": "object", "properties": { @@ -12564,6 +13837,173 @@ "format": "date-time" } } + }, + "TalentDiscoveryPreferenceResponse": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean" + }, + "version": { + "type": "integer", + "format": "int64" + }, + "createdAt": { + "type": "string", + "format": "date-time" + }, + "updatedAt": { + "type": "string", + "format": "date-time" + } + } + }, + "TalentDiscoveryPreferenceUpdateRequest": { + "type": "object", + "required": [ + "enabled" + ], + "properties": { + "enabled": { + "type": "boolean" + }, + "expectedVersion": { + "type": "integer", + "format": "int64", + "minimum": 0 + } + } + }, + "TalentDiscoveryCandidateResponse": { + "type": "object", + "properties": { + "candidateKey": { + "type": "string" + }, + "candidateLabel": { + "type": "string" + }, + "city": { + "type": "string" + }, + "education": { + "type": "string" + }, + "graduationYear": { + "type": "integer", + "format": "int32" + }, + "skills": { + "type": "array", + "items": { + "type": "string" + } + }, + "fitBand": { + "type": "string", + "enum": [ + "HIGH", + "MEDIUM", + "BASIC" + ] + }, + "summary": { + "type": "string" + }, + "signalsAvailable": { + "type": "array", + "items": { + "type": "string", + "enum": [ + "PREFERENCE", + "RIASEC", + "ABILITY" + ] + } + } + } + }, + "TalentDiscoveryPageResponse": { + "type": "object", + "properties": { + "mode": { + "type": "string", + "enum": [ + "PERSONALIZED", + "UNAVAILABLE", + "NO_ELIGIBLE_CANDIDATE" + ] + }, + "items": { + "type": "array", + "items": { + "$ref": "#/components/schemas/TalentDiscoveryCandidateResponse" + } + }, + "total": { + "type": "integer", + "format": "int64" + }, + "page": { + "type": "integer", + "format": "int32" + }, + "size": { + "type": "integer", + "format": "int32" + }, + "totalPages": { + "type": "integer", + "format": "int32" + }, + "message": { + "type": "string" + } + } + }, + "ApiResponseTalentDiscoveryPreferenceResponse": { + "type": "object", + "properties": { + "code": { + "type": "integer", + "format": "int32" + }, + "message": { + "type": "string" + }, + "data": { + "$ref": "#/components/schemas/TalentDiscoveryPreferenceResponse" + }, + "requestId": { + "type": "string" + }, + "timestamp": { + "type": "string", + "format": "date-time" + } + } + }, + "ApiResponseTalentDiscoveryPageResponse": { + "type": "object", + "properties": { + "code": { + "type": "integer", + "format": "int32" + }, + "message": { + "type": "string" + }, + "data": { + "$ref": "#/components/schemas/TalentDiscoveryPageResponse" + }, + "requestId": { + "type": "string" + }, + "timestamp": { + "type": "string", + "format": "date-time" + } + } } }, "securitySchemes": { diff --git a/docs/v41-enterprise-talent-recommendation-design.md b/docs/v41-enterprise-talent-recommendation-design.md new file mode 100644 index 0000000..254a54c --- /dev/null +++ b/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 岗位画像审核字段存在、认证企业判断可用、学生档案字段可满足候选卡白名单;如任一项与当前代码冲突,停止并报告,不自行改变接口、迁移号、隐私边界或排序口径。 diff --git a/docs/verify_openapi.py b/docs/verify_openapi.py index 65eb6fc..72664d5 100644 --- a/docs/verify_openapi.py +++ b/docs/verify_openapi.py @@ -6,7 +6,7 @@ p = PROJECT_ROOT / 'docs' / 'openapi-rc.json' d = json.load(open(p, encoding='utf-8')) print('paths:', len(d['paths'])) -assert len(d['paths']) == 159, f"Expected 159 paths, got {len(d['paths'])}" +assert len(d['paths']) == 161, f"Expected 161 paths, got {len(d['paths'])}" paths = d['paths'] required = [ @@ -351,10 +351,31 @@ for name in ('CompanyReviewItemResponse', 'CompanyReviewDetailResponse', 'Compan assert 'officialReply' not in json.dumps(d['components']['schemas']['CompanyReviewReplyResponse']) print(" V40 exactly 5 paths / 6 operations; request/response schemas are present and redacted") -# 统计 operation 总数并校验 V40 基线 196 +# V41 企业人才发现:2 个唯一 path / 3 个 operation(学生 GET/PUT 共享一个 path)。 +v41 = [ + '/profile/me/talent-discovery', + '/companies/me/jobs/{jobId}/talent-recommendations', +] +assert all(k in paths for k in v41), "One or more V41 paths are missing" +assert len(v41) == 2, f"Expected exactly 2 V41 paths, got {len(v41)}" +assert sum(len(paths[k]) for k in v41) == 3, "Expected exactly 3 V41 operations" +assert 'get' in paths[v41[0]] and 'put' in paths[v41[0]], "V41 preference GET/PUT missing" +assert 'get' in paths[v41[1]], "V41 talent recommendation GET missing" +assert paths[v41[0]]['put'].get('requestBody', {}).get('content', {}).get('application/json', {}).get('schema', {}).get('$ref'), "V41 PUT requestBody schema missing" +for pt, method in [(v41[0], 'get'), (v41[0], 'put'), (v41[1], 'get')]: + assert response_ref(paths[pt][method]), f"{method.upper()} {pt} 200 schema missing" +candidate_schema = json.dumps(d['components']['schemas']['TalentDiscoveryCandidateResponse']) +for f in ['studentUserId', 'candidateId', 'phone', 'email', 'resumeUrl', 'attemptId', 'resultId', + 'answerKey', 'scoringRule', 'questionCode', 'competencyTag', 'dimensionScores', + 'overallScore', 'percentile', 'fitScore', 'gender', 'birthDate', 'password', 'token']: + assert f not in candidate_schema, f"V41 candidate schema exposes {f}" +assert 'candidateKey' in candidate_schema and 'studentUserId' not in candidate_schema +print(" V41 exactly 2 paths / 3 operations; candidate card schema is redacted") + +# 统计 operation 总数并校验 V41 基线 199 operations = sum(len(methods) for methods in d['paths'].values()) print('operations:', operations) -assert operations == 196, f"Expected 196 operations, got {operations}" +assert operations == 199, f"Expected 199 operations, got {operations}" print(" Offer advance endpoints all present") print(" 26 operations all present") print(" Account deletion endpoints present (3 operations)") diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/config/SecurityConfig.java b/mydreamjob-server/src/main/java/com/mydreamjob/config/SecurityConfig.java index 1c2c5e5..e57d380 100644 --- a/mydreamjob-server/src/main/java/com/mydreamjob/config/SecurityConfig.java +++ b/mydreamjob-server/src/main/java/com/mydreamjob/config/SecurityConfig.java @@ -78,6 +78,9 @@ public class SecurityConfig { .requestMatchers("/auth/me").authenticated() // 企业资料(需登录) .requestMatchers("/companies/me/**").authenticated() + // V41 学生人才发现授权与企业人才发现推荐(角色和归属由 Service 校验) + .requestMatchers("/profile/me/talent-discovery").authenticated() + .requestMatchers("/companies/me/jobs/*/talent-recommendations").authenticated() // 企业公开岗位列表(匿名 GET) .requestMatchers(HttpMethod.GET, "/companies/*/jobs").permitAll() // 企业公开主页(匿名 GET) diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/talentdiscovery/CandidateKeyGenerator.java b/mydreamjob-server/src/main/java/com/mydreamjob/talentdiscovery/CandidateKeyGenerator.java new file mode 100644 index 0000000..3044645 --- /dev/null +++ b/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); + } + } +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/talentdiscovery/StudentTalentDiscoveryPreference.java b/mydreamjob-server/src/main/java/com/mydreamjob/talentdiscovery/StudentTalentDiscoveryPreference.java new file mode 100644 index 0000000..6f821bd --- /dev/null +++ b/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; +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/talentdiscovery/StudentTalentDiscoveryPreferenceRepository.java b/mydreamjob-server/src/main/java/com/mydreamjob/talentdiscovery/StudentTalentDiscoveryPreferenceRepository.java new file mode 100644 index 0000000..626f818 --- /dev/null +++ b/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 { + + Optional findByStudentUserId(Long studentUserId); + + List findByStudentUserIdInAndEnabledTrue(Collection studentUserIds); +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/talentdiscovery/TalentDiscoveryCandidateSource.java b/mydreamjob-server/src/main/java/com/mydreamjob/talentdiscovery/TalentDiscoveryCandidateSource.java new file mode 100644 index 0000000..16d7954 --- /dev/null +++ b/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 RIASEC_DIMENSIONS = Set.of("R", "I", "A", "S", "E", "C"); + private static final Set ABILITY_DIMENSIONS = Set.of("LOGIC", "COMMUNICATION", "EXECUTION", "LEARNING"); + private static final TypeReference>> LIST = new TypeReference<>() { }; + private static final TypeReference> STRINGS = new TypeReference<>() { }; + + private final JdbcTemplate jdbcTemplate; + private final ObjectMapper objectMapper; + + /** + * 读取全部合格候选,不做数据库分页。账号注销限制和当前企业任意历史投递均在 SQL 中排除。 + */ + public List 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 findPreferences(Collection studentIds) { + if (studentIds == null || studentIds.isEmpty()) return Map.of(); + List 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 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 findLatestRiasec(Collection studentIds) { + if (studentIds == null || studentIds.isEmpty()) return Map.of(); + List 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 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 findLatestAbility(Collection studentIds, LocalDateTime capturedNow) { + if (studentIds == null || studentIds.isEmpty() || capturedNow == null) return Map.of(); + List 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 args = new ArrayList<>(ids); + args.add(capturedNow); + Map 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 parseScores(String raw, Set expected, Integer totalAnswered, int requiredTotal) { + if (raw == null || raw.isBlank() || totalAnswered == null || totalAnswered != requiredTotal) return Map.of(); + try { + List> rows = objectMapper.readValue(raw, LIST); + if (rows == null || rows.size() != expected.size()) return Map.of(); + Map out = new LinkedHashMap<>(); + for (Map 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 readStrings(String raw) { + if (raw == null || raw.isBlank()) return List.of(); + try { + List 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 distinctIds(Collection 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 cities, + List employmentTypes, List industries, List jobTypes, + boolean remoteAllowed) { } + + public record ScoreSignal(Map scores, LocalDateTime completedAt, LocalDateTime validUntil) { } +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/talentdiscovery/TalentDiscoveryController.java b/mydreamjob-server/src/main/java/com/mydreamjob/talentdiscovery/TalentDiscoveryController.java new file mode 100644 index 0000000..f12069c --- /dev/null +++ b/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 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 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 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"); } +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/talentdiscovery/TalentDiscoveryScoreCalculator.java b/mydreamjob-server/src/main/java/com/mydreamjob/talentdiscovery/TalentDiscoveryScoreCalculator.java new file mode 100644 index 0000000..8b18bb8 --- /dev/null +++ b/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 signalsAvailable) { } + + /** 缺失信号不按 0 处罚,仅对有效信号权重重新归一化。 */ + public Optional calculate(BigDecimal preference, BigDecimal riasec, BigDecimal ability) { + BigDecimal numerator = BigDecimal.ZERO; + BigDecimal denominator = BigDecimal.ZERO; + List 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; + } +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/talentdiscovery/TalentDiscoveryService.java b/mydreamjob-server/src/main/java/com/mydreamjob/talentdiscovery/TalentDiscoveryService.java new file mode 100644 index 0000000..6b37525 --- /dev/null +++ b/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 = new TypeReference<>() { }; + private static final TypeReference> 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 seeds = candidateSource.findEligibleCandidates(company.getId()); + if (seeds.isEmpty()) { + return page("NO_ELIGIBLE_CANDIDATE", List.of(), 0, page, size, "暂未发现已授权且满足基础条件的候选人"); + } + Map preferences = new HashMap<>(); + Map riasec = new HashMap<>(); + Map ability = new HashMap<>(); + for (int from = 0; from < seeds.size(); from += BATCH_SIZE) { + int to = Math.min(from + BATCH_SIZE, seeds.size()); + List 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 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 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 scores, Map expected, Set allowed) { + if (scores.isEmpty() || expected.isEmpty()) return null; + BigDecimal numerator = BigDecimal.ZERO; + BigDecimal denominator = BigDecimal.ZERO; + for (Map.Entry 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 parseMap(String raw) { + if (raw == null || raw.isBlank()) return Map.of(); + try { + Map input = JSON.readValue(raw, MAP); + Map result = new HashMap<>(); + for (Map.Entry 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 safeSkills(String raw) { + if (raw == null || raw.isBlank()) return List.of(); + try { + List 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 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) { } +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/talentdiscovery/dto/TalentDiscoveryCandidateResponse.java b/mydreamjob-server/src/main/java/com/mydreamjob/talentdiscovery/dto/TalentDiscoveryCandidateResponse.java new file mode 100644 index 0000000..1db9f93 --- /dev/null +++ b/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 skills; + private String fitBand; + private String summary; + private List signalsAvailable; +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/talentdiscovery/dto/TalentDiscoveryPageResponse.java b/mydreamjob-server/src/main/java/com/mydreamjob/talentdiscovery/dto/TalentDiscoveryPageResponse.java new file mode 100644 index 0000000..5bd23af --- /dev/null +++ b/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 items; + private long total; + private int page; + private int size; + private int totalPages; + private String message; +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/talentdiscovery/dto/TalentDiscoveryPreferenceResponse.java b/mydreamjob-server/src/main/java/com/mydreamjob/talentdiscovery/dto/TalentDiscoveryPreferenceResponse.java new file mode 100644 index 0000000..0019b82 --- /dev/null +++ b/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; +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/talentdiscovery/dto/TalentDiscoveryPreferenceUpdateRequest.java b/mydreamjob-server/src/main/java/com/mydreamjob/talentdiscovery/dto/TalentDiscoveryPreferenceUpdateRequest.java new file mode 100644 index 0000000..1ff67cd --- /dev/null +++ b/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; +} diff --git a/mydreamjob-server/src/main/resources/db/migration/V39__enterprise_talent_discovery.sql b/mydreamjob-server/src/main/resources/db/migration/V39__enterprise_talent_discovery.sql new file mode 100644 index 0000000..ba7c705 --- /dev/null +++ b/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; diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/talentdiscovery/CandidateKeyGeneratorTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/talentdiscovery/CandidateKeyGeneratorTest.java new file mode 100644 index 0000000..ec812af --- /dev/null +++ b/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))); + } +} diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/talentdiscovery/TalentDiscoveryControllerTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/talentdiscovery/TalentDiscoveryControllerTest.java new file mode 100644 index 0000000..bfef8e9 --- /dev/null +++ b/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 redisTemplate; + + private final ObjectMapper mapper = new ObjectMapper(); + + @BeforeEach + @SuppressWarnings("unchecked") + void setUp() { + ValueOperations 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)); + } +} diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/talentdiscovery/TalentDiscoveryPersistenceIntegrationTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/talentdiscovery/TalentDiscoveryPersistenceIntegrationTest.java new file mode 100644 index 0000000..79ab354 --- /dev/null +++ b/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 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); + } +} diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/talentdiscovery/TalentDiscoveryScoreCalculatorTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/talentdiscovery/TalentDiscoveryScoreCalculatorTest.java new file mode 100644 index 0000000..db0be1e --- /dev/null +++ b/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"))); + } +} diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/talentdiscovery/TalentDiscoveryServiceTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/talentdiscovery/TalentDiscoveryServiceTest.java new file mode 100644 index 0000000..8b434eb --- /dev/null +++ b/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 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()); + } +}