Browse Source

feat: unify industry catalog and chat identity display

fix/mysql57-module-migrations-20261009
Zhang Shun 1 day ago
parent
commit
164b7b37c3
  1. 49
      docs/chat-display-name-frontend-handover-20261028.md
  2. 290
      docs/openapi-rc.json
  3. 94
      docs/unified-industry-frontend-handover-20261028.md
  4. 26
      docs/verify_openapi.py
  5. 3
      mydreamjob-server/src/main/java/com/mydreamjob/auth/company/CompanyRegistrationRequest.java
  6. 3
      mydreamjob-server/src/main/java/com/mydreamjob/auth/company/CompanyRegistrationService.java
  7. 2
      mydreamjob-server/src/main/java/com/mydreamjob/auth/identity/IdentityAccessFilter.java
  8. 5
      mydreamjob-server/src/main/java/com/mydreamjob/auth/identity/IdentitySelectionService.java
  9. 11
      mydreamjob-server/src/main/java/com/mydreamjob/company/Company.java
  10. 2
      mydreamjob-server/src/main/java/com/mydreamjob/company/CompanyController.java
  11. 3
      mydreamjob-server/src/main/java/com/mydreamjob/company/CompanyOnboardingResponse.java
  12. 11
      mydreamjob-server/src/main/java/com/mydreamjob/company/CompanyService.java
  13. 4
      mydreamjob-server/src/main/java/com/mydreamjob/company/dto/CompanyCreateRequest.java
  14. 7
      mydreamjob-server/src/main/java/com/mydreamjob/company/dto/CompanyPublicView.java
  15. 3
      mydreamjob-server/src/main/java/com/mydreamjob/company/dto/CompanyUpdateRequest.java
  16. 1
      mydreamjob-server/src/main/java/com/mydreamjob/config/SecurityConfig.java
  17. 1
      mydreamjob-server/src/main/java/com/mydreamjob/dataexport/PersonalDataCollector.java
  18. 95
      mydreamjob-server/src/main/java/com/mydreamjob/industry/IndustryCatalog.java
  19. 20
      mydreamjob-server/src/main/java/com/mydreamjob/industry/IndustryDirectoryController.java
  20. 30
      mydreamjob-server/src/main/java/com/mydreamjob/industry/IndustryDirectoryService.java
  21. 4
      mydreamjob-server/src/main/java/com/mydreamjob/job/JobController.java
  22. 16
      mydreamjob-server/src/main/java/com/mydreamjob/job/JobSearchSpecification.java
  23. 12
      mydreamjob-server/src/main/java/com/mydreamjob/job/JobService.java
  24. 7
      mydreamjob-server/src/main/java/com/mydreamjob/job/dto/JobDocCompanySummary.java
  25. 2
      mydreamjob-server/src/main/java/com/mydreamjob/job/dto/JobSearchItem.java
  26. 28
      mydreamjob-server/src/main/java/com/mydreamjob/match/MatchService.java
  27. 14
      mydreamjob-server/src/main/java/com/mydreamjob/messaging/ConversationSummaryService.java
  28. 10
      mydreamjob-server/src/main/java/com/mydreamjob/messaging/MessagingService.java
  29. 1
      mydreamjob-server/src/main/java/com/mydreamjob/onboarding/CompanyKnowledgeService.java
  30. 1
      mydreamjob-server/src/main/java/com/mydreamjob/onboarding/dto/CompanyKnowledgeOverviewResponse.java
  31. 1
      mydreamjob-server/src/main/java/com/mydreamjob/onlineresume/OnlineResumeContent.java
  32. 10
      mydreamjob-server/src/main/java/com/mydreamjob/onlineresume/OnlineResumeService.java
  33. 19
      mydreamjob-server/src/main/java/com/mydreamjob/preference/PreferenceService.java
  34. 3
      mydreamjob-server/src/main/java/com/mydreamjob/preference/StudentPreference.java
  35. 3
      mydreamjob-server/src/main/java/com/mydreamjob/preference/dto/StudentPreferenceRequest.java
  36. 2
      mydreamjob-server/src/main/java/com/mydreamjob/profileimage/ProfileImageController.java
  37. 8
      mydreamjob-server/src/main/java/com/mydreamjob/profileimage/ProfileImageService.java
  38. 29
      mydreamjob-server/src/main/java/com/mydreamjob/recommendation/JobRecommendationService.java
  39. 2
      mydreamjob-server/src/main/java/com/mydreamjob/skill/SkillCloudQueryService.java
  40. 4
      mydreamjob-server/src/main/java/com/mydreamjob/talentdiscovery/TalentDiscoveryService.java
  41. 629
      mydreamjob-server/src/main/resources/catalog/industries-v1.json
  42. 97
      mydreamjob-server/src/main/resources/db/migration/V65__unified_recruitment_industries.sql
  43. 22
      mydreamjob-server/src/test/java/com/mydreamjob/application/CompanyResumeDownloadIntegrationTest.java
  44. 2
      mydreamjob-server/src/test/java/com/mydreamjob/auth/identity/IdentityPolicyTest.java
  45. 4
      mydreamjob-server/src/test/java/com/mydreamjob/auth/identity/UnifiedRegistrationIntegrationTest.java
  46. 39
      mydreamjob-server/src/test/java/com/mydreamjob/industry/IndustryCatalogTest.java
  47. 60
      mydreamjob-server/src/test/java/com/mydreamjob/industry/IndustryMySqlMigrationTest.java
  48. 145
      mydreamjob-server/src/test/java/com/mydreamjob/industry/UnifiedIndustryIntegrationTest.java
  49. 4
      mydreamjob-server/src/test/java/com/mydreamjob/job/JobDisplayFieldsConsistencyIntegrationTest.java
  50. 14
      mydreamjob-server/src/test/java/com/mydreamjob/match/MatchServiceTest.java
  51. 52
      mydreamjob-server/src/test/java/com/mydreamjob/messaging/ChatWorkflowIntegrationTest.java
  52. 19
      mydreamjob-server/src/test/java/com/mydreamjob/onlineresume/OnlineResumeIntegrationTest.java
  53. 28
      mydreamjob-server/src/test/java/com/mydreamjob/profileimage/ProfileImageIntegrationTest.java
  54. 4
      mydreamjob-server/src/test/java/com/mydreamjob/resume/optimization/WorkExperienceOptimizationSecurityIntegrationTest.java
  55. 2
      mydreamjob-server/src/test/java/com/mydreamjob/skill/SkillCloudQueryServiceTest.java

49
docs/chat-display-name-frontend-handover-20261028.md

@ -0,0 +1,49 @@
# 沟通姓名与头像同步:前端增量对接
日期:2026-10-28。对象:求职者端/企业端前端联调人员。**本次为本地后端改动,尚未提交、推送或发布;线上 Jenkins #107 不包含本次修复。** 本文沿用已确认的头像/Logo 对接结构,作为原文档的增量,不改写历史验收状态。
## 1. 展示规则
- 对方为求职者:`contactName` 优先使用当前个人资料 `realName`;缺失、空串或纯空白时回退账号 `nickname`,再回退“求职者”。姓名/昵称首尾空白去除。
- 对方为企业:`contactName` 继续使用公司名称,`avatarUrl` 继续使用公司 Logo,不替换为 HR 的个人头像或姓名。
- DIRECT 和 TALENT_INVITATION 的会话摘要均按上述非匿名规则展示姓名和头像。
- 修改资料姓名不强行改写账号 nickname;修改头像不影响公司 Logo。
- 没有新增手机号、邮箱、简历正文、用户 ID 等展示字段。原消息 senderScope、已有内部 ID 隐藏和简历权限规则保持不变。
## 2. 接口与权限
API 路径、认证和响应字段不变,无新增迁移或依赖:
| 接口 | 用途 |
|---|---|
| `PUT /api/v1/profile/me` | 保存资料姓名 realName;已有在线简历的账号继续遵循 revision 规则,不能为了改名绕过并发保护 |
| 现有在线简历保存接口 | 保存 basicInfo.realName,并同步到资料;沿用既有完整保存及 revision 契约 |
| `GET /api/v1/conversations` | 返回最新会话摘要,前端使用 contactName、avatarUrl |
| `GET /api/v1/conversations/{id}` | 返回最新会话详情摘要,字段来源与列表一致 |
| `POST /api/v1/auth/me/avatar` | 上传本人头像,沿用原上传协议,返回新的头像版本 URL |
| `GET /api/v1/profile-images/{imageId}` | 读取头像,仍携带正常 access token;公司 Logo 的原公开规则不变 |
不新增让前端传 userId 查他人姓名的能力。非成员不能读取会话;邀约头像读取仍检查成员、拉黑、撤回、企业认证及已有企业接受/匹配沟通授权。移除展示匿名不等于关闭这些门禁。
图片/文件附件继续只允许 DIRECT 会话。不能通过本次头像调整扩大 TALENT_INVITATION 的附件权限。
## 3. 缓存更新与前端修改点
1. 修改姓名后,等待保存接口真正成功;409 冲突、网络错误或服务端失败不能假装已保存。保存成功后刷新个人资料、在线简历的相应缓存。
2. 会话列表和详情统一使用后端 contactName,不再用历史路由参数、第一次登录的 nickname、硬编码名字或旧消息快照长期覆盖。
3. 企业端进入/回到沟通页、打开会话详情时重新读取摘要,更新标题与头像。若已有统一轮询/刷新机制,应让它同步合并这两个字段。
4. 本次未新增“对方改名”WebSocket 事件。用户 A 改名后,不能只刷新 A 本机就声称企业 B 已刷新;B 应在上述刷新点重新拉取。要实现当前打开页面的即时更新,需前端接入既有刷新机制或另行设计事件。
5. 头像更换后使用后端最新版本 URL,并携带认证头;旧 URL 失效不能一直沿用。姓名变化不需要拼接虚假的头像缓存版本。
6. 切账号清理会话缓存;异步请求按账号/会话 ID 或请求代次保护,旧响应不能覆盖新账号、新会话的标题与头像。
7. 名称/头像加载失败保留明确错误或合理占位,不显示假名字,不从手机号推断姓名。
## 4. 联调验收
- 求职者修改 realName 后,企业重新 GET 列表、详情,contactName 均返回新值;再次修改也更新,原 nickname 不被强行覆盖。
- 无资料记录、null/空白 realName 时回退 nickname;两者均空白时为“求职者”。
- DIRECT/TALENT_INVITATION 摘要遵循同一显示规则;企业一侧仍显示公司名称和 Logo。
- 邀约会话的已授权成员可加载个人头像;匿名、非成员、拉黑后读取仍拒绝;企业未获沟通授权或投递撤回不因展示规则变化而获权。
- 点击同一个会话、返回列表、重新进入、切账号等场景,标题/列表名字/头像不互相覆盖。
- 邀约附件仍不能通过 DIRECT 附件接口读取或发送;不新增个人敏感字段。
请前端回传实际请求/响应(去除 Token 和个人信息)、姓名保存后的两端录屏/截图、缓存刷新方式、类型检查和测试结果。后端定向测试、真实 MySQL、同次 JAR 黑盒、前端实机与线上发布应分别登记,不能把之前 #107 的通知发布验收当作本次姓名修复也已上线。

290
docs/openapi-rc.json

@ -401,7 +401,7 @@
"个人资料"
],
"operationId": "readProfileImage",
"description": "Logo anonymous; personal avatar requires authenticated self or authorized DIRECT peer. Replaced/deleted URLs return 404.",
"description": "企业Logo匿名可读;个人头像须携带Bearer且仅本人或满足当前沟通授权的DIRECT或TALENT_INVITATION成员可读,仍校验拉黑、撤回和企业接收门禁,非公开用户头像目录;不扩大邀约附件权限。旧地址替换/删除后404;no-store。",
"security": [],
"parameters": [
{
@ -2091,7 +2091,7 @@
"企业"
],
"summary": "查询企业公开详情",
"description": "匿名可访问,仅返回 6 个公开字段",
"description": "匿名可访问,仅返回8个公开字段,含industryCode和companySize,不含ownerUserId及联系方式",
"operationId": "getPublicCompany",
"parameters": [
{
@ -3723,6 +3723,15 @@
"format": "int32",
"default": 20
}
},
{
"name": "industryCode",
"in": "query",
"description": "稳定行业编码,逗号多选最多20项;大类包含细分类,取公司行业,与旧industry参数同时提供时AND。完整目录见/jobs/industry-catalog。",
"required": false,
"schema": {
"type": "string"
}
}
],
"responses": {
@ -13103,7 +13112,11 @@
"429": {
"description": "调用额度或并发已满;code429,Retry-After=60;不自动重试",
"headers": {
"Retry-After": { "schema": { "type": "integer" } }
"Retry-After": {
"schema": {
"type": "integer"
}
}
}
},
"503": {
@ -13222,6 +13235,39 @@
}
}
}
},
"/jobs/industry-catalog": {
"get": {
"tags": [
"岗位"
],
"summary": "统一招聘行业目录",
"description": "ALL为完整两级目录,WITH_JOBS仅含当前有PUBLISHED岗位的行业;计数为全站发布岗位,非当前城市筛选结果。编码稳定;未确定映射的旧名称不归其他。skillIndustryCode非空才表示已有技能数据集,不代表职位类别。",
"operationId": "getIndustryCatalog",
"parameters": [
{
"name": "scope",
"in": "query",
"required": false,
"schema": {
"type": "string",
"default": "ALL"
}
}
],
"responses": {
"200": {
"description": "OK",
"content": {
"*/*": {
"schema": {
"$ref": "#/components/schemas/ApiResponseIndustryDirectory"
}
}
}
}
}
}
}
},
"components": {
@ -13426,37 +13472,105 @@
"NotificationListResponseDoc": {
"type": "object",
"properties": {
"code": {"type": "integer", "format": "int32"},
"message": {"type": "string"},
"data": {"$ref": "#/components/schemas/NotificationPageDoc"},
"requestId": {"type": "string"},
"timestamp": {"type": "string", "format": "date-time"}
"code": {
"type": "integer",
"format": "int32"
},
"message": {
"type": "string"
},
"data": {
"$ref": "#/components/schemas/NotificationPageDoc"
},
"requestId": {
"type": "string"
},
"timestamp": {
"type": "string",
"format": "date-time"
}
}
},
"NotificationPageDoc": {
"type": "object",
"properties": {
"items": {"type": "array", "items": {"$ref": "#/components/schemas/NotificationItemDoc"}},
"total": {"type": "integer", "format": "int64"},
"page": {"type": "integer", "format": "int32"},
"size": {"type": "integer", "format": "int32"},
"totalPages": {"type": "integer", "format": "int32"}
"items": {
"type": "array",
"items": {
"$ref": "#/components/schemas/NotificationItemDoc"
}
},
"total": {
"type": "integer",
"format": "int64"
},
"page": {
"type": "integer",
"format": "int32"
},
"size": {
"type": "integer",
"format": "int32"
},
"totalPages": {
"type": "integer",
"format": "int32"
}
}
},
"NotificationItemDoc": {
"type": "object",
"description": "通知展示与业务关联;未知类型/空关联不推断目标,详情仍受各业务接口权限校验",
"required": ["businessType", "businessId"],
"required": [
"businessType",
"businessId"
],
"properties": {
"id": {"type": "integer", "format": "int64"},
"type": {"type": "string"},
"title": {"type": "string"},
"content": {"type": ["string", "null"]},
"read": {"type": "boolean"},
"readAt": {"type": ["string", "null"], "format": "date-time"},
"createdAt": {"type": "string", "format": "date-time"},
"businessType": {"type": ["string", "null"], "description": "持久化业务类型,非封闭枚举;历史或系统通知可为 null。已核查 APPLICATION/INTERVIEW/OFFER/MATCH/VERIFICATION/REPORT"},
"businessId": {"type": ["integer", "null"], "format": "int64", "description": "持久化关联业务记录ID,不是通知ID;APPLICATION 和 MATCH 均为投递ID,INTERVIEW 为面试ID,OFFER 为Offer ID;无关联时 null"}
"id": {
"type": "integer",
"format": "int64"
},
"type": {
"type": "string"
},
"title": {
"type": "string"
},
"content": {
"type": [
"string",
"null"
]
},
"read": {
"type": "boolean"
},
"readAt": {
"type": [
"string",
"null"
],
"format": "date-time"
},
"createdAt": {
"type": "string",
"format": "date-time"
},
"businessType": {
"type": [
"string",
"null"
],
"description": "持久化业务类型,非封闭枚举;历史或系统通知可为 null。已核查 APPLICATION/INTERVIEW/OFFER/MATCH/VERIFICATION/REPORT"
},
"businessId": {
"type": [
"integer",
"null"
],
"format": "int64",
"description": "持久化关联业务记录ID,不是通知ID;APPLICATION 和 MATCH 均为投递ID,INTERVIEW 为面试ID,OFFER 为Offer ID;无关联时 null"
}
}
},
"ProfileImageView": {
@ -15972,7 +16086,8 @@
"type": "string"
},
"industry": {
"type": "string"
"type": "string",
"maxLength": 80
},
"website": {
"type": "string"
@ -15988,6 +16103,11 @@
"description": "企业规模,≤40 字符。缺省/null:创建存 NULL;空串或纯空白清空为 NULL;非空去两端空白后保存;超过 40 字符拒绝且不截断。",
"maxLength": 40,
"minLength": 0
},
"industryCode": {
"type": "string",
"maxLength": 64,
"minLength": 0
}
},
"required": [
@ -16051,9 +16171,15 @@
],
"description": "企业规模,≤40 字符",
"maxLength": 40
}
},
"industryCode": {
"type": "string",
"description": "稳定招聘行业编码;无法确定映射的旧名称为空",
"maxLength": 64
}
},
"description": "企业公开摘要;严格 8 字段,含 industryCode,不含 ownerUserId、联系方式、登记地址与审核信息"
},
"CompanyScoreItem": {
"type": "object",
"properties": {
@ -16112,6 +16238,11 @@
"description": "企业规模,≤40 字符。缺省/null:更新保持原值;空串或纯空白清空为 NULL;非空去两端空白后保存;超过 40 字符拒绝且不截断。",
"maxLength": 40,
"minLength": 0
},
"industryCode": {
"type": "string",
"maxLength": 64,
"minLength": 0
}
},
"required": [
@ -17385,6 +17516,16 @@
},
"salaryRangeValid": {
"type": "boolean"
},
"preferredIndustryCodes": {
"type": "array",
"items": {
"type": "string",
"maxLength": 64,
"minLength": 0
},
"maxItems": 20,
"minItems": 0
}
}
},
@ -20653,6 +20794,11 @@
"type": "string",
"maxLength": 255,
"minLength": 0
},
"industryCode": {
"type": "string",
"maxLength": 64,
"minLength": 0
}
},
"required": [
@ -20814,6 +20960,9 @@
},
"verified": {
"type": "boolean"
},
"industryCode": {
"type": "string"
}
}
},
@ -22188,7 +22337,7 @@
},
"JobDocCompanySummary": {
"type": "object",
"description": "公开企业摘要;严格 7 字段,不含 ownerUserId、联系方式、地址与审核信息",
"description": "公开企业摘要;严格 8 字段,含 industryCode,不含 ownerUserId、联系方式、地址与审核信息",
"properties": {
"id": {
"type": "integer",
@ -22219,6 +22368,11 @@
"type": "string",
"description": "企业规模,≤40 字符;缺省时为空",
"maxLength": 40
},
"industryCode": {
"type": "string",
"description": "企业行业稳定编码;无法映射的历史值为空",
"maxLength": 64
}
}
},
@ -22774,6 +22928,16 @@
"availableFrom": {
"type": "string",
"format": "date"
},
"preferredIndustryCodes": {
"type": "array",
"items": {
"type": "string",
"maxLength": 64,
"minLength": 0
},
"maxItems": 20,
"minItems": 0
}
},
"required": [
@ -24717,6 +24881,9 @@
},
"verified": {
"type": "boolean"
},
"industryCode": {
"type": "string"
}
}
},
@ -25954,6 +26121,77 @@
"type": "string"
}
}
},
"IndustryDirectory": {
"type": "object",
"properties": {
"version": {
"type": "string"
},
"scope": {
"type": "string"
},
"countScope": {
"type": "string"
},
"items": {
"type": "array",
"items": {
"$ref": "#/components/schemas/IndustryDirectoryItem"
}
},
"unmappedPublishedJobCount": {
"type": "integer",
"format": "int64"
}
}
},
"IndustryDirectoryItem": {
"type": "object",
"properties": {
"code": {
"type": "string"
},
"parentCode": {
"type": "string"
},
"name": {
"type": "string"
},
"level": {
"type": "integer",
"format": "int32"
},
"publishedJobCount": {
"type": "integer",
"format": "int64"
},
"skillIndustryCode": {
"type": "string"
}
}
},
"ApiResponseIndustryDirectory": {
"type": "object",
"properties": {
"code": {
"type": "integer",
"format": "int32"
},
"message": {
"type": "string"
},
"data": {
"$ref": "#/components/schemas/IndustryDirectory"
},
"requestId": {
"type": "string"
},
"timestamp": {
"type": "string",
"format": "date-time"
}
}
}
},
"securitySchemes": {

94
docs/unified-industry-frontend-handover-20261028.md

@ -0,0 +1,94 @@
# 统一招聘行业目录对接说明
适用对象:前后端联调工程师。范围:目录、企业资料、岗位筛选、求职意向、推荐及技能映射;不包含前端实现、线上数据回填或发布授权。
本批为本地后端改动,不能据此认为当前线上已支持。最终验证状态以本批验收报告为准。
## 目录及字段参考
唯一主目录为 `catalog/industries-v1.json`,版本 `recruitment-industry-v1`。这是项目招聘场景目录,不宣称是国家行业标准。共18个大类、78个细分类;目录版本和编码稳定,调整展示名称不改变编码。新增、停用或拆分类别需单独审核兼容策略,不复用已发出的编码。
大类覆盖互联网/信息技术、金融、制造、汽车、消费品与零售、医疗健康、教育、建筑与房地产、物流与交通、能源、专业服务、生活服务、文化传媒、农林牧渔、公共与社会服务、通信、旅游与住宿、环保。
`GET /api/v1/jobs/industry-catalog?scope=ALL` 无需登录,返回标准 `ApiResponse`。`data`:
```json
{
"version": "recruitment-industry-v1",
"scope": "ALL",
"countScope": "GLOBAL_PUBLISHED_NOT_FILTERED_BY_CITY",
"items": [
{"code":"FINANCE","parentCode":null,"name":"金融","level":1,"publishedJobCount":2,"skillIndustryCode":null},
{"code":"FINANCE_BANKING","parentCode":"FINANCE","name":"银行","level":2,"publishedJobCount":2,"skillIndustryCode":null}
],
"unmappedPublishedJobCount": 1
}
```
示例仅演示部分条目和计数,不是实际环境数据。全局序列化可能省略空值字段,前端需兼容 `null` 和字段缺省。例如已有金融技能集时 FINANCE 的 `skillIndustryCode` 是 FINANCE。
- `ALL`:完整两级目录,包括零岗位类别。企业资料和求职意向必须用此目录。
- `WITH_JOBS`:只返回当前存在已发布岗位的类别及其大类,不能作为可填写的完整行业范围。
- `publishedJobCount` 为全站 PUBLISHED 岗位数,不叠加当前城市等筛选。大类包含自身和细分类,父子数不可相加当作岗位总数。
- `unmappedPublishedJobCount`:无法可靠映射行业的已发布岗位数;不强塞进“其他”。
- 未知 scope 返回400;不发送中文“不限”作为编码。
## 企业、岗位及求职意向接入
### 企业
企业创建、更新、企业注册内的 `company` 对象可传:
```json
{"industryCode":"FINANCE_BANKING"}
```
旧 `industry` 名称参数保留。若同时传编码和名称,名称必须精确映射到该编码,否则400;不做模糊猜测。更新两字段都缺省表示保留;`industryCode:""` 且名称缺省/空白表示清空。未知非空编码400。
企业自查、公开企业摘要、岗位列表/详情内企业摘要增加 `industryCode`。已存编码时展示目录现行名称;旧数据能精确映射时可在读取时返回编码,但不写回数据库;未知旧名称原样展示,编码为空或缺省。
岗位行业继承所属企业,不新增岗位独立行业字段。企业行业修改会影响其岗位的行业筛选与计数。不要从岗位标题推导行业。
### 岗位筛选
`GET /api/v1/jobs/search?industryCode=FINANCE,MANUFACTURING`。
最多20个去重编码、每项64字符、整个参数最多4096字符。大类包含全部细分类,同维度OR,与城市/学历等维度AND。未知编码或空分隔项400。编码匹配优先用企业已存编码;旧数据只使用精确别名匹配,未知数据仍可在不限定行业时看到。
旧 `industry` 名称精确筛选语义不变;新旧参数同时提供时AND,建议新前端只用 `industryCode`。旧 `/jobs/filters.industries` 仍是有岗名称选项,不是完整目录。
### 求职意向
`PUT /api/v1/profile/me/preferences` 和在线简历 `content.jobIntention` 增加 `preferredIndustryCodes` 字符串数组,最多20项。例如:
```json
{"preferredIndustryCodes":["FINANCE_BANKING","INTERNET"],"revision":7}
```
偏好局部更新中编码和旧名称都缺省则保留,只传编码 `[]` 明确清空。只传旧名称会保留原文,并生成可确定的编码;同时传两组选择时,可确定的编码集合必须一致,否则400且不部分写入。两组同时带有待确认旧名称时保留未知名称,使 GET→编辑其他字段→保存不会丢失历史内容;要连同这些未知名称一起删除,请把两组数组都置为 `[]`。
偏好读取返回 `preferredIndustryCodes`,并以 `unmappedPreferredIndustries` 列出未知旧名称;旧名称列表仍保留。在线简历也返回编码数组;不能静默删掉旧名称。旧客户端整份保存没有编码字段时根据名称做确定映射,保持兼容。
原有 revision 并发保护不变:已有在线简历的旧偏好更新必须携带最新 revision,冲突仍409/6002。此功能不绕开版本校验。
## 推荐与技能映射
推荐行业引用同一主目录,数据库 `job_recommendation_taxonomies` 的 INDUSTRY 行是兼容投影,非第二套自主维护的字典。旧六个编码保留;新增编码由 V65 提供。推荐偏好按编码和大类展开参与原有偏好评分,不改变城市/薪资硬筛选规则。
岗位推荐和人才发现使用同一评分映射,同时归一化偏好与目标的旧别名;未知旧偏好保留为未匹配条件,不能静默移除其评分权重。岗位匹配诊断的行业参考项读取岗位所属企业的编码,不再用岗位描述猜行业;原有10分参考权重与城市/薪资门槛不变。个人数据导出同时携带已存的行业编码。
行业与职位类别独立。FINANCE_BANKING(银行)可以搭配 IT_SOFTWARE(软件职位类别);JOB_FAMILY 不再返回行业父级以限制选择。职位类别本身仍需已启用、类型正确。推荐配置里的行业需与业务实际保持一致,不能根据职位类别推断。
技能仍使用现有 `industryId + occupationId` 数据集查询方式,不把行业编码当数字ID。目录条目的 `skillIndustryCode` 仅在数据库确有对应技能集时提供;当前已有互联网、金融、教育数据,其余行业不虚构词云或学习路径。
技能的 occupation 与 industry 绑定表示现有数据集范围,不是“该行业只能招聘这些职业”的业务限制。前端没有可用技能映射时应明确展示“该行业技能数据暂未覆盖”,不能把它隐藏出行业选择器,也不能随意套用互联网技能。
## 旧数据待确认与验收限制
V65 仅新增企业编码/偏好编码列、索引和新增推荐行业条目,不批量回填或改写旧名称,不修改历史迁移。旧名称仅按中央目录精确别名表解析,未知或混合业务名称保留待确认。名称别名新增必须检查冲突;不得为消除待确认而统归“其他”。
发布前在隔离副本汇总现有企业行业、求职意向名称的映射结果供业务确认;生产回填须单独授权,不在本批自动执行。
联调需覆盖:完整目录与有岗目录差异、零岗位类别、编码保存重读、金融大类筛选银行、城市条件联用、未知旧名称保留、清空、revision冲突、银行招聘软件工程师、技能未覆盖提示。岗位数量无结果应展示真实空态,不能回退到全行业结果。
本批不修改前端,不发起真实AI/短信/地图调用;定向测试、真实MySQL、全量构建和同次JAR验证分别记账,不互相冒充。

26
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']) == 221, f"Expected 221 paths, got {len(d['paths'])}"
assert len(d['paths']) == 222, f"Expected 222 paths, got {len(d['paths'])}"
paths = d['paths']
@ -743,7 +743,7 @@ print(' S03 company resume download contract validated; candidate DTOs remain r
# 统计 operation 总数并校验当前接口契约基线。
operations = sum(len(methods) for methods in d['paths'].values())
print('operations:', operations)
assert operations == 271, f"Expected 271 operations, got {operations}"
assert operations == 272, f"Expected 272 operations, got {operations}"
# 个人头像/企业 Logo 独立写入;文件上传不接受客户端 userId 或 URL。
for _route, _upload, _delete in (
@ -903,12 +903,12 @@ print(' CompanyManagedJobItem 展示字段已验证')
_public_company_props = d['components']['schemas']['CompanyPublicView']['properties']
assert set(_public_company_props) == {
'id', 'name', 'logoUrl', 'description', 'industry', 'verified', 'companySize'}, \
f'CompanyPublicView 必须严格为 7 字段白名单,实际={sorted(_public_company_props)}'
'id', 'name', 'logoUrl', 'description', 'industry', 'industryCode', 'verified', 'companySize'}, \
f'CompanyPublicView 必须严格为 8 字段白名单,实际={sorted(_public_company_props)}'
for _forbidden in ('ownerUserId', 'address', 'website', 'phone', 'email', 'createdAt',
'materialsJson', 'decisionReason', 'reviewerId'):
assert _forbidden not in _public_company_props, f'CompanyPublicView 不得包含 {_forbidden}'
print(' 公开企业摘要严格 7 字段白名单已验证(不含 ownerUserId/联系方式/地址/认证材料/审核信息)')
print(' 公开企业摘要严格 8 字段白名单已验证(不含 ownerUserId/联系方式/地址/认证材料/审核信息)')
for _pt, _method in [('/jobs', 'get'), ('/jobs', 'post'), ('/jobs/search', 'get'),
('/jobs/public/{jobId}', 'get'), ('/jobs/{jobId}', 'get'),
@ -1094,5 +1094,21 @@ assert set(_trash_item['properties']) == set(_notification_item['properties']) |
assert _trash_item['properties']['deletedAt']['format'] == 'date-time'
assert 'deletedAt' in _trash_item['required']
print(' Notification recycle bin: five authenticated operations, bounded batch IDs, deletion time; no permanent deletion')
_industry_path = paths['/jobs/industry-catalog']['get']
assert _industry_path['operationId'] == 'getIndustryCatalog'
assert any(p['name'] == 'scope' for p in _industry_path['parameters'])
_industry_schemas = d['components']['schemas']
_industry_item = _industry_schemas['IndustryDirectoryItem']['properties']
assert set(_industry_item) == {'code', 'parentCode', 'name', 'level', 'publishedJobCount', 'skillIndustryCode'}
assert _industry_item['publishedJobCount']['type'] == 'integer'
assert {'version', 'scope', 'countScope', 'items', 'unmappedPublishedJobCount'} == set(_industry_schemas['IndustryDirectory']['properties'])
for _schema in ('CompanyCreateRequest', 'CompanyUpdateRequest', 'CompanyDetails', 'CompanyPublicView', 'JobDocCompanySummary', 'CompanyView'):
assert _industry_schemas[_schema]['properties']['industryCode']['type'] == 'string'
for _schema in ('StudentPreferenceRequest', 'OnlineResumeJobIntention'):
_codes = _industry_schemas[_schema]['properties']['preferredIndustryCodes']
assert _codes['type'] == 'array' and _codes['maxItems'] == 20
assert _codes['items']['type'] == 'string'
assert any(p['name'] == 'industryCode' for p in paths['/jobs/search']['get']['parameters'])
print(' Unified industry directory, stable company codes, preference codes and search parameter verified')
print("Valid JSON: True")
print("All checks passed!")

3
mydreamjob-server/src/main/java/com/mydreamjob/auth/company/CompanyRegistrationRequest.java

@ -39,6 +39,9 @@ public class CompanyRegistrationRequest {
private String description;
@Size(max = 80)
private String industry;
@jakarta.validation.constraints.Size(max = 64)
private String industryCode;
@Size(max = 255)
private String website;
@Size(max = 255)

3
mydreamjob-server/src/main/java/com/mydreamjob/auth/company/CompanyRegistrationService.java

@ -75,12 +75,13 @@ public class CompanyRegistrationService {
user = userRepository.saveAndFlush(user);
CompanyRegistrationRequest.CompanyDetails d = request.getCompany();
var industry = com.mydreamjob.industry.IndustryCatalog.selection(d.getIndustryCode(), d.getIndustry());
Company company = Company.builder()
.ownerUserId(user.getId())
.name(d.getName())
.logoUrl(d.getLogoUrl())
.description(d.getDescription())
.industry(d.getIndustry())
.industry(industry.name()).industryCode(industry.code())
.website(d.getWebsite())
.address(d.getAddress())
.verified(false)

2
mydreamjob-server/src/main/java/com/mydreamjob/auth/identity/IdentityAccessFilter.java

@ -27,7 +27,7 @@ public class IdentityAccessFilter extends OncePerRequestFilter {
"/auth/password/set", "/auth/password/setup", "/auth/password/change", "/auth/login", "/auth/password/login",
"/auth/password/reset", "/auth/refresh", "/auth/sms/send", "/auth/company/register");
private static final Set<String> PUBLIC_GET = Set.of("/", "/health", "/health/**", "/jobs",
"/jobs/search", "/jobs/filters", "/jobs/compare", "/jobs/public/**", "/jobs/*/reviews",
"/jobs/search", "/jobs/filters", "/jobs/industry-catalog", "/jobs/compare", "/jobs/public/**", "/jobs/*/reviews",
"/jobs/*/reviews/*", "/jobs/*/reviews/summary", "/companies/*", "/companies/*/jobs",
"/job-recommendation-taxonomies", "/profile-images/*", "/swagger-ui/**", "/v3/api-docs/**", "/api-docs/**");
private final UserRepository users;

5
mydreamjob-server/src/main/java/com/mydreamjob/auth/identity/IdentitySelectionService.java

@ -70,6 +70,9 @@ public class IdentitySelectionService {
if (target == User.Role.COMPANY && !properties.isEnabled()) {
throw new BusinessException(ErrorCode.COMPANY_REGISTRATION_DISABLED);
}
var industry = target == User.Role.COMPANY
? com.mydreamjob.industry.IndustryCatalog.selection(request.getCompany().getIndustryCode(), request.getCompany().getIndustry())
: null;
user.setRole(target);
user.setRoles(new HashSet<>(Set.of(target)));
users.saveAndFlush(user);
@ -77,7 +80,7 @@ public class IdentitySelectionService {
if (target == User.Role.COMPANY) {
CompanyDetails d = request.getCompany();
company = companies.saveAndFlush(Company.builder().ownerUserId(userId).name(d.getName())
.logoUrl(d.getLogoUrl()).industry(d.getIndustry()).description(d.getDescription())
.logoUrl(d.getLogoUrl()).industry(industry.name()).industryCode(industry.code()).description(d.getDescription())
.website(d.getWebsite()).address(d.getAddress()).verified(false).build());
}
// modifying 会清理 persistence context;上面的角色与资料已 flush,锁仍持有。

11
mydreamjob-server/src/main/java/com/mydreamjob/company/Company.java

@ -42,6 +42,17 @@ public class Company {
@Column(name = "industry", length = 80)
private String industry;
@Column(name = "industry_code", length = 64)
private String industryCode;
public String getIndustryCode() {
return industryCode != null ? industryCode : com.mydreamjob.industry.IndustryCatalog.resolveLegacy(industry);
}
public String getIndustry() {
return com.mydreamjob.industry.IndustryCatalog.displayName(industryCode, industry);
}
@Column(name = "company_size", length = 40)
private String companySize;

2
mydreamjob-server/src/main/java/com/mydreamjob/company/CompanyController.java

@ -83,7 +83,7 @@ public class CompanyController {
// ── 公开端:企业详情 ────────────────────────────────────────
@Operation(summary = "查询企业公开详情", description = "匿名可访问,仅返回 6 个公开字段")
@Operation(summary = "查询企业公开详情", description = "匿名可访问,仅返回8个公开字段,含industryCode和companySize,不含ownerUserId及联系方式")
@GetMapping("/{companyId}")
public ResponseEntity<ApiResponse<CompanyPublicView>> getPublicCompany(
@PathVariable Long companyId,

3
mydreamjob-server/src/main/java/com/mydreamjob/company/CompanyOnboardingResponse.java

@ -12,6 +12,9 @@ public record CompanyOnboardingResponse(
public record CompanySummary(Long id, String name, String logoUrl, String description,
String industry, String website, String address, Boolean verified) {
public String getIndustryCode() {
return com.mydreamjob.industry.IndustryCatalog.resolveLegacy(industry);
}
}
public record VerificationSummary(Long id, String status, LocalDateTime submittedAt,

11
mydreamjob-server/src/main/java/com/mydreamjob/company/CompanyService.java

@ -69,12 +69,13 @@ public class CompanyService {
if (companyRepository.findByOwnerUserId(userId).isPresent()) {
throw new BusinessException(ErrorCode.COMPANY_ALREADY_EXISTS);
}
var industry = com.mydreamjob.industry.IndustryCatalog.selection(req.getIndustryCode(), req.getIndustry());
Company company = Company.builder()
.ownerUserId(userId)
.name(req.getName())
.logoUrl(req.getLogoUrl())
.description(req.getDescription())
.industry(req.getIndustry())
.industry(industry.name()).industryCode(industry.code())
.companySize(normalizeCompanySize(req.getCompanySize()))
.website(req.getWebsite())
.address(req.getAddress())
@ -92,10 +93,14 @@ public class CompanyService {
Company company = companyRepository.findByOwnerUserIdForUpdate(userId)
.orElseThrow(() -> new BusinessException(ErrorCode.COMPANY_NOT_FOUND));
var industry = com.mydreamjob.industry.IndustryCatalog.selection(req.getIndustryCode(), req.getIndustry());
if (req.getName() != null) company.setName(req.getName());
if (req.getLogoUrl() != null) company.setLogoUrl(req.getLogoUrl());
if (req.getDescription() != null) company.setDescription(req.getDescription());
if (req.getIndustry() != null) company.setIndustry(req.getIndustry());
if (req.getIndustry() != null || req.getIndustryCode() != null) {
company.setIndustry(industry.name());
company.setIndustryCode(industry.code());
}
// companySize:null/缺省保持原值;其余交给与创建共用的同一套归一化规则。
if (req.getCompanySize() != null) {
company.setCompanySize(normalizeCompanySize(req.getCompanySize()));
@ -145,6 +150,7 @@ public class CompanyService {
view.put("logoUrl", company.getLogoUrl());
view.put("description", company.getDescription());
view.put("industry", company.getIndustry());
view.put("industryCode", company.getIndustryCode());
view.put("companySize", company.getCompanySize());
view.put("website", company.getWebsite());
view.put("address", company.getAddress());
@ -176,6 +182,7 @@ public class CompanyService {
.logoUrl(company.getLogoUrl())
.description(company.getDescription())
.industry(company.getIndustry())
.industryCode(company.getIndustryCode())
.companySize(company.getCompanySize())
.verified(company.getVerified())
.build();

4
mydreamjob-server/src/main/java/com/mydreamjob/company/dto/CompanyCreateRequest.java

@ -13,8 +13,12 @@ public class CompanyCreateRequest {
private String logoUrl;
private String description;
@jakarta.validation.constraints.Size(max = 80)
private String industry;
@jakarta.validation.constraints.Size(max = 64)
private String industryCode;
@Size(max = 40, message = "company size must be at most 40 characters")
@Schema(types = {"string", "null"}, maxLength = 40,
description = "企业规模,≤40 字符。缺省/null:创建存 NULL;空串或纯空白清空为 NULL;"

7
mydreamjob-server/src/main/java/com/mydreamjob/company/dto/CompanyPublicView.java

@ -9,7 +9,7 @@ import lombok.NoArgsConstructor;
/**
* 企业公开信息 DTO。
* 仅暴露 7 个公开字段,不泄露 ownerUserId、联系方式、审核信息等。
* 仅暴露 8 个公开字段,不泄露 ownerUserId、联系方式、审核信息等。
*/
@Data
@Builder
@ -17,7 +17,7 @@ import lombok.NoArgsConstructor;
@AllArgsConstructor
@JsonInclude(JsonInclude.Include.NON_NULL)
@Schema(name = "CompanyPublicView",
description = "企业公开摘要;严格 7 字段,不含 ownerUserId、联系方式、登记地址与审核信息")
description = "企业公开摘要;严格 8 字段,含 industryCode,不含 ownerUserId、联系方式、登记地址与审核信息")
public class CompanyPublicView {
@Schema(description = "企业 ID")
@ -35,6 +35,9 @@ public class CompanyPublicView {
@Schema(description = "所属行业")
private String industry;
@Schema(description = "稳定招聘行业编码;无法确定映射的旧名称为空", maxLength = 64)
private String industryCode;
@Schema(types = {"string", "null"}, maxLength = 40, description = "企业规模,≤40 字符")
private String companySize;

3
mydreamjob-server/src/main/java/com/mydreamjob/company/dto/CompanyUpdateRequest.java

@ -23,6 +23,9 @@ public class CompanyUpdateRequest {
@Size(max = 80, message = "industry must be at most 80 characters")
private String industry;
@jakarta.validation.constraints.Size(max = 64)
private String industryCode;
@Size(max = 40, message = "company size must be at most 40 characters")
@Schema(types = {"string", "null"}, maxLength = 40,
description = "企业规模,≤40 字符。缺省/null:更新保持原值;空串或纯空白清空为 NULL;"

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

@ -150,6 +150,7 @@ public class SecurityConfig {
// 岗位搜索与筛选(公开)
.requestMatchers(HttpMethod.GET, "/jobs/search").permitAll()
.requestMatchers(HttpMethod.GET, "/jobs/filters").permitAll()
.requestMatchers(HttpMethod.GET, "/jobs/industry-catalog").permitAll()
// 岗位管理(需登录)
.requestMatchers("/jobs/**").authenticated()
// 投递(需登录)

1
mydreamjob-server/src/main/java/com/mydreamjob/dataexport/PersonalDataCollector.java

@ -159,6 +159,7 @@ public class PersonalDataCollector {
m.put("preferredCities", safeJson(sp.getPreferredCities()));
m.put("employmentTypes", safeJson(sp.getEmploymentTypes()));
m.put("preferredIndustries", safeJson(sp.getPreferredIndustries()));
m.put("preferredIndustryCodes", safeJson(sp.getPreferredIndustryCodes()));
m.put("preferredJobTypes", safeJson(sp.getPreferredJobTypes()));
m.put("remoteAllowed", sp.getRemoteAllowed());
m.put("createdAt", sp.getCreatedAt());

95
mydreamjob-server/src/main/java/com/mydreamjob/industry/IndustryCatalog.java

@ -0,0 +1,95 @@
package com.mydreamjob.industry;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.mydreamjob.common.exception.BusinessException;
import com.mydreamjob.common.exception.ErrorCode;
import java.util.*;
/** 所有招聘行业使用同一版本化目录;旧名称仅精确别名映射,不做模糊推断。 */
public final class IndustryCatalog {
public record Node(String code,String parentCode,String name,List<String> aliases) {}
public record Definition(String version,String description,List<Node> nodes) {}
public record Selection(String code,String name) {}
private static final Definition DEFINITION;
private static final Map<String,Node> CODES;
private static final Map<String,String> ALIASES;
static {
try(var input=IndustryCatalog.class.getResourceAsStream("/catalog/industries-v1.json")) {
if(input==null)throw new IllegalStateException("industry catalog missing");
Definition definition=new ObjectMapper().readValue(input,Definition.class);
Map<String,Node> codes=new LinkedHashMap<>();Map<String,String> aliases=new HashMap<>();
for(Node node:definition.nodes()) {
if(!node.code().matches("[A-Z][A-Z0-9_]{0,63}") || codes.putIfAbsent(node.code(),node)!=null)
throw new IllegalStateException("invalid or duplicate industry code");
for(String label:labels(node)) {
String previous=aliases.putIfAbsent(key(label),node.code());
if(previous!=null&&!previous.equals(node.code()))throw new IllegalStateException("ambiguous industry alias");
}
}
for(Node node:codes.values())if(node.parentCode()!=null &&
(!codes.containsKey(node.parentCode()) || codes.get(node.parentCode()).parentCode()!=null))
throw new IllegalStateException("industry catalog must have two levels");
DEFINITION=definition;CODES=Collections.unmodifiableMap(codes);ALIASES=Map.copyOf(aliases);
}catch(Exception error){throw new ExceptionInInitializerError(error);}
}
private IndustryCatalog() {}
private static String key(String text){return text.strip().toLowerCase(Locale.ROOT);}
public static String version(){return DEFINITION.version();}
public static List<Node> nodes(){return List.copyOf(CODES.values());}
public static Node node(String code){return CODES.get(code);}
public static List<String> labels(Node node){var values=new ArrayList<>(node.aliases());values.add(node.name());values.add(node.code());return List.copyOf(values);}
public static String resolveLegacy(String value){return value==null||value.isBlank()?null:ALIASES.get(key(value));}
public static String requireCode(String value){
if(value==null || !CODES.containsKey(value.strip()))throw bad("unknown industryCode");
return value.strip();
}
public static String displayName(String code,String legacy){Node node=CODES.get(code);return node==null?legacy:node.name();}
public static Selection selection(String code,String legacy) {
if (legacy != null && legacy.length() > 80) throw bad("industry name exceeds 80 characters");
if(code!=null) {
if(code.isBlank()) {
if(legacy!=null&&!legacy.isBlank())throw bad("industry and industryCode conflict");
return new Selection(null,null);
}
String canonical=requireCode(code);
if(legacy!=null&&!legacy.isBlank()&&!canonical.equals(resolveLegacy(legacy)))throw bad("industry and industryCode conflict");
return new Selection(canonical,node(canonical).name());
}
return new Selection(resolveLegacy(legacy),legacy);
}
public static List<String> preferenceCodes(List<String> explicit,List<String> legacy) {
if(explicit==null)return legacy==null?List.of():legacy.stream().map(IndustryCatalog::resolveLegacy).filter(Objects::nonNull).distinct().toList();
if(explicit.size()>20)throw bad("at most 20 industry codes");
var result=explicit.stream().map(IndustryCatalog::requireCode).distinct().toList();
if(legacy!=null&&!legacy.isEmpty()) {
if(!new HashSet<>(result).equals(new HashSet<>(preferenceCodes(null,legacy))))
throw bad("preferred industries and codes conflict");
}
return result;
}
public static List<String> preferenceNames(List<String> codes, List<String> legacy) {
var result = new ArrayList<>(codes.stream().map(code -> node(code).name()).toList());
if (legacy != null) legacy.stream().filter(Objects::nonNull).filter(name -> resolveLegacy(name) == null).forEach(result::add);
return List.copyOf(result);
}
public static Set<String> expand(Collection<String> codes) {
Set<String> result=new LinkedHashSet<>();
for(String code:codes){String canonical=requireCode(code);result.add(canonical);for(Node n:CODES.values())if(canonical.equals(n.parentCode()))result.add(n.code());}
return result;
}
public static List<String> legacyLabels(Collection<String> codes) {
return codes.stream().map(CODES::get).filter(Objects::nonNull).flatMap(n->labels(n).stream()).map(IndustryCatalog::key).distinct().toList();
}
/** 共享评分输入:展开已知编码,未知旧名称仍保留为未匹配条件,不能静默移除权重。 */
public static List<String> scoringCriteria(List<String> explicit, List<String> legacy) {
var codes = explicit == null ? preferenceCodes(null, legacy) : explicit;
var result = new LinkedHashSet<>(expand(codes));
if (legacy != null) legacy.stream().filter(Objects::nonNull).filter(value -> resolveLegacy(value) == null).forEach(result::add);
return new ArrayList<>(result);
}
public static String scoringTarget(String value) {
String code = resolveLegacy(value);
return code == null ? value : code;
}
private static BusinessException bad(String message){return new BusinessException(ErrorCode.BAD_REQUEST,message);}
}

20
mydreamjob-server/src/main/java/com/mydreamjob/industry/IndustryDirectoryController.java

@ -0,0 +1,20 @@
package com.mydreamjob.industry;
import com.mydreamjob.common.api.ApiResponse;
import com.mydreamjob.common.logging.RequestIdAccessor;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.servlet.http.HttpServletRequest;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;
@RestController @RequiredArgsConstructor @Tag(name="岗位")
public class IndustryDirectoryController {
private final IndustryDirectoryService directory;
private final RequestIdAccessor requestIds;
@GetMapping("/jobs/industry-catalog")
@Operation(operationId="getIndustryCatalog",summary="统一招聘行业目录",description="ALL为完整两级目录,WITH_JOBS仅含当前有PUBLISHED岗位的行业;计数为全站发布岗位,非当前城市筛选结果。编码稳定;未确定映射的旧名称不归其他。skillIndustryCode非空才表示已有技能数据集,不代表职位类别。")
public ApiResponse<IndustryDirectoryService.Directory> get(@RequestParam(defaultValue="ALL") String scope,HttpServletRequest request) {
return ApiResponse.ok(directory.directory(scope),requestIds.get(request));
}
}

30
mydreamjob-server/src/main/java/com/mydreamjob/industry/IndustryDirectoryService.java

@ -0,0 +1,30 @@
package com.mydreamjob.industry;
import com.mydreamjob.common.exception.*;
import lombok.RequiredArgsConstructor;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Service;
import java.util.*;
@Service @RequiredArgsConstructor
public class IndustryDirectoryService {
private final JdbcTemplate jdbc;
@io.swagger.v3.oas.annotations.media.Schema(name="IndustryDirectoryItem")
public record Item(String code,String parentCode,String name,int level,long publishedJobCount,String skillIndustryCode) {}
@io.swagger.v3.oas.annotations.media.Schema(name="IndustryDirectory")
public record Directory(String version,String scope,String countScope,List<Item> items,long unmappedPublishedJobCount) {}
public Directory directory(String scope) {
if(!Set.of("ALL","WITH_JOBS").contains(scope))throw new BusinessException(ErrorCode.BAD_REQUEST,"invalid industry directory scope");
Map<String,Long> counts=new HashMap<>();long[] unmapped={0};
jdbc.query("SELECT c.industry_code,c.industry,COUNT(j.id) AS n FROM jobs j LEFT JOIN companies c ON c.id=j.company_id WHERE j.status='PUBLISHED' GROUP BY c.industry_code,c.industry",rs->{
String code=rs.getString(1);if(code==null)code=IndustryCatalog.resolveLegacy(rs.getString(2));
long count=rs.getLong(3);
if(IndustryCatalog.node(code)==null)unmapped[0]+=count;
else {counts.merge(code,count,Long::sum);String parent=IndustryCatalog.node(code).parentCode();if(parent!=null)counts.merge(parent,count,Long::sum);}
});
Set<String> skillCodes=new HashSet<>(jdbc.queryForList("SELECT code FROM skill_industries",String.class));
var items=IndustryCatalog.nodes().stream().filter(n->"ALL".equals(scope)||counts.getOrDefault(n.code(),0L)>0)
.map(n->new Item(n.code(),n.parentCode(),n.name(),n.parentCode()==null?1:2,counts.getOrDefault(n.code(),0L),skillCodes.contains(n.code())?n.code():null)).toList();
return new Directory(IndustryCatalog.version(),scope,"GLOBAL_PUBLISHED_NOT_FILTERED_BY_CITY",items,unmapped[0]);
}
}

4
mydreamjob-server/src/main/java/com/mydreamjob/job/JobController.java

@ -162,6 +162,8 @@ public class JobController {
@RequestParam(required = false) Long companyId,
@Parameter(description = "行业标签精确匹配,取公司 industry;逗号分隔多选,同维度 OR、跨维度 AND。选项见 /jobs/filters 的 industries;最多20个去重值、每项80字符,空值不筛选,非法格式400,未知值无匹配。")
@RequestParam(required = false) String industry,
@Parameter(description = "稳定行业编码,逗号多选最多20项;大类包含细分类,取公司行业,与旧industry参数同时提供时AND。完整目录见/jobs/industry-catalog。")
@RequestParam(required = false) String industryCode,
@Parameter(description = "经验标签精确匹配,不做年限推断;逗号分隔多选,选项见 experiences;最多20个去重值、每项40字符,空值不筛选,非法格式400。")
@RequestParam(required = false) String experience,
@Parameter(description = "学历标签精确匹配,不做学历高低推断;逗号分隔多选,选项见 educations;最多20个去重值、每项40字符,空值不筛选,非法格式400。")
@ -172,7 +174,7 @@ public class JobController {
HttpServletRequest request) {
Page<JobSearchItem> result = jobService.searchJobs(
keyword, city, employmentType, salaryMin, salaryMax, companyId,
sort, page, size, industry, experience, education);
sort, page, size, industry, experience, education, industryCode);
Map<String, Object> data = new HashMap<>();
data.put("items", result.getContent());
data.put("total", result.getTotalElements());

16
mydreamjob-server/src/main/java/com/mydreamjob/job/JobSearchSpecification.java

@ -28,6 +28,22 @@ public class JobSearchSpecification {
// utility class
}
/** 新编码按目录父子关系筛选;旧数据只使用精确别名,不猜测未知名称。 */
public static Specification<Job> industryCodes(String raw) {
if (raw == null || raw.isBlank()) return (root, query, cb) -> cb.conjunction();
var values = JobFilterValues.parse(raw, 64).stream().map(value -> value.toUpperCase(Locale.ROOT)).toList();
var codes = com.mydreamjob.industry.IndustryCatalog.expand(values);
var labels = com.mydreamjob.industry.IndustryCatalog.legacyLabels(codes);
return (root, query, cb) -> {
var subquery = query.subquery(Long.class);
var company = subquery.from(Company.class);
subquery.select(company.get("id")).where(cb.equal(company.get("id"), root.get(COMPANY_ID)),
cb.or(company.get("industryCode").in(codes), cb.and(cb.isNull(company.get("industryCode")),
cb.lower(cb.trim(company.<String>get("industry"))).in(labels))));
return cb.exists(subquery);
};
}
/**
* 构建动态查询条件。
*

12
mydreamjob-server/src/main/java/com/mydreamjob/job/JobService.java

@ -411,6 +411,14 @@ public class JobService {
Integer salaryMin, Integer salaryMax, Long companyId,
String sort, int page, int size,
String industry, String experience, String education) {
return searchJobs(keyword, city, employmentType, salaryMin, salaryMax, companyId,
sort, page, size, industry, experience, education, null);
}
public Page<JobSearchItem> searchJobs(String keyword, String city, String employmentType,
Integer salaryMin, Integer salaryMax, Long companyId,
String sort, int page, int size,
String industry, String experience, String education, String industryCode) {
// 参数校验
if (salaryMin != null && salaryMax != null && salaryMin > salaryMax) {
throw new BusinessException(ErrorCode.BAD_REQUEST);
@ -426,7 +434,8 @@ public class JobService {
}
var spec = JobSearchSpecification.searchJobs(keyword, city, employmentType,
salaryMin, salaryMax, companyId, industry, experience, education);
salaryMin, salaryMax, companyId, industry, experience, education)
.and(JobSearchSpecification.industryCodes(industryCode));
Page<Job> jobPage;
if ("salary".equals(sort)) {
@ -524,6 +533,7 @@ public class JobService {
.logoUrl(company.getLogoUrl())
.description(company.getDescription())
.industry(company.getIndustry())
.industryCode(company.getIndustryCode())
.verified(company.getVerified())
.companySize(company.getCompanySize())
.build();

7
mydreamjob-server/src/main/java/com/mydreamjob/job/dto/JobDocCompanySummary.java

@ -4,7 +4,7 @@ import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
/**
* 仅供 OpenAPI 文档使用的公开企业摘要模型(严格 7 字段白名单)。
* 仅供 OpenAPI 文档使用的公开企业摘要模型(严格 8 字段白名单)。
*
* <p>不参与任何序列化/反序列化:仅通过 {@code @Schema(implementation = ...)} 绑定到
* 岗位相关 Map 响应的文档结构,实际返回类型不变。不得暴露 ownerUserId、联系方式、
@ -12,7 +12,7 @@ import lombok.Data;
*/
@Data
@Schema(name = "JobDocCompanySummary",
description = "公开企业摘要;严格 7 字段,不含 ownerUserId、联系方式、地址与审核信息")
description = "公开企业摘要;严格 8 字段,含 industryCode,不含 ownerUserId、联系方式、地址与审核信息")
public class JobDocCompanySummary {
@Schema(description = "企业 ID")
@ -30,6 +30,9 @@ public class JobDocCompanySummary {
@Schema(description = "所属行业")
private String industry;
@Schema(description = "企业行业稳定编码;无法映射的历史值为空", maxLength = 64)
private String industryCode;
@Schema(description = "是否已认证")
private Boolean verified;

2
mydreamjob-server/src/main/java/com/mydreamjob/job/dto/JobSearchItem.java

@ -50,6 +50,8 @@ public class JobSearchItem {
private String logoUrl;
private String description;
private String industry;
private String industryCode;
private Boolean verified;
private String companySize;
}

28
mydreamjob-server/src/main/java/com/mydreamjob/match/MatchService.java

@ -35,6 +35,7 @@ public class MatchService {
private final StudentProfileRepository profileRepository;
private final AuditService auditService;
private final UserRepository userRepository;
private final com.mydreamjob.company.CompanyRepository companyRepository;
private static final ObjectMapper mapper = new ObjectMapper();
private static final String RULES_VERSION = "RULES_V0_1";
@ -95,14 +96,15 @@ public class MatchService {
}
// 规则4:行业偏好匹配(若有设置)
if (preference != null && preference.getPreferredIndustries() != null
&& !preference.getPreferredIndustries().isBlank()) {
// 行业字段:job 没有 industry,用 description 关键词近似
String desc = job.getDescription() != null ? job.getDescription().toLowerCase() : "";
boolean industryMatch = preference.getPreferredIndustries().toLowerCase()
.contains(desc.substring(0, Math.min(30, desc.length())));
// 简化:行业偏好为参考项,不参与硬性
if (industryMatch) { score += 10; matched.add("行业偏好匹配"); }
if (preference != null) {
var industries = com.mydreamjob.industry.IndustryCatalog.scoringCriteria(
preference.getPreferredIndustryCodes() == null ? null : industryList(preference.getPreferredIndustryCodes()),
industryList(preference.getPreferredIndustries()));
if (!industries.isEmpty() && job.getCompanyId() != null) {
String industryCode = companyRepository.findById(job.getCompanyId())
.map(com.mydreamjob.company.Company::getIndustryCode).orElse(null);
if (industryCode != null && industries.contains(industryCode)) { score += 10; matched.add("行业偏好匹配"); }
}
}
// 规则5:岗位类型偏好(参考项)
@ -163,4 +165,14 @@ public class MatchService {
return jsonArray.toLowerCase().contains(target.toLowerCase());
}
}
private List<String> industryList(String json) {
if (json == null || json.isBlank()) return List.of();
try {
var node = mapper.readTree(json);
for (int depth = 0; depth < 3 && node.isTextual(); depth++) node = mapper.readTree(node.asText());
if (!node.isArray()) return List.of();
for (var item : node) if (!item.isTextual()) return List.of();
return mapper.convertValue(node, new TypeReference<List<String>>() {});
} catch (Exception ignored) { return List.of(); }
}
}

14
mydreamjob-server/src/main/java/com/mydreamjob/messaging/ConversationSummaryService.java

@ -5,7 +5,7 @@ import java.util.*;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
/** 对已完成成员鉴权的会话批量补展示摘要;不查询手机号、实名、简历或虚构在线状态。 */
/** 对已完成成员鉴权的会话批量补展示摘要;非匿名沟通使用资料姓名,不查询联系方式或简历正文。 */
@Service @RequiredArgsConstructor
public class ConversationSummaryService {
@PersistenceContext private EntityManager em;
@ -15,9 +15,10 @@ public class ConversationSummaryService {
List<Long> ids = views.stream().map(v -> (Long)v.get("id")).toList();
Map<Long,List<Object[]>> contacts = new HashMap<>();
em.createQuery("""
select m.conversationId, u.nickname, u.avatarUrl, c.name, c.logoUrl
select m.conversationId, u.nickname, u.avatarUrl, c.name, c.logoUrl, sp.realName
from ConversationMember m join User u on u.id = m.userId
left join Company c on c.ownerUserId = u.id
left join StudentProfile sp on sp.userId = u.id
where m.conversationId in :ids and m.userId <> :userId
order by m.conversationId, m.id
""",Object[].class).setParameter("ids",ids).setParameter("userId",userId)
@ -31,15 +32,14 @@ public class ConversationSummaryService {
""",Object[].class).setParameter("ids",ids).getResultList()
.forEach(r -> last.put((Long)r[0],r));
for (Map<String,Object> view : views) {
boolean invitation = "TALENT_INVITATION".equals(view.get("type"));
var peers = contacts.getOrDefault(view.get("id"),List.of());
String name = "会话成员";
if (peers.size() == 1) {
Object[] peer = peers.get(0);
// 邀约候选人隐藏昵称/头像;公司公开展示名可见,绝不倒查求职者身份。
name = peer[3] != null ? peer[3].toString() : invitation ? "求职者" : text(peer[1],"求职者");
// 普通与邀约沟通统一展示:公司优先,其余使用资料姓名→昵称→兜底。
name = peer[3] != null ? peer[3].toString() : text(peer[5],text(peer[1],"求职者"));
if (peer[3] != null) view.put("companyName",peer[3]);
Object avatar = peer[3] != null ? peer[4] : invitation ? null : peer[2];
Object avatar = peer[3] != null ? peer[4] : peer[2];
if (avatar != null) view.put("avatarUrl",avatar);
}
view.put("contactName",name);
@ -55,6 +55,6 @@ public class ConversationSummaryService {
}
}
private String text(Object value,String fallback) {
return value == null || value.toString().isBlank() ? fallback : value.toString();
return value == null || value.toString().isBlank() ? fallback : value.toString().strip();
}
}

10
mydreamjob-server/src/main/java/com/mydreamjob/messaging/MessagingService.java

@ -289,6 +289,16 @@ public class MessagingService {
getConversation(conversationId, userId);
if (!"DIRECT".equals(requireConversation(conversationId).getType()))
throw new BusinessException(ErrorCode.FORBIDDEN, "attachments require a DIRECT conversation");
assertPeerAccess(conversationId, userId);
}
/** 非匿名会话头像读取仍复用沟通门禁,不扩大附件发送或下载范围。 */
public void assertProfileImageAccess(Long conversationId, Long userId) {
getConversation(conversationId, userId);
assertPeerAccess(conversationId, userId);
}
private void assertPeerAccess(Long conversationId, Long userId) {
for (ConversationMember member : memberRepository.findByConversationId(conversationId)) {
if (!member.getUserId().equals(userId)) {
assertNotBlockedBetween(userId, member.getUserId());

1
mydreamjob-server/src/main/java/com/mydreamjob/onboarding/CompanyKnowledgeService.java

@ -373,6 +373,7 @@ public class CompanyKnowledgeService {
.logoUrl(company.getLogoUrl())
.description(company.getDescription())
.industry(company.getIndustry())
.industryCode(company.getIndustryCode())
.website(company.getWebsite())
.address(company.getAddress())
.verified(company.getVerified())

1
mydreamjob-server/src/main/java/com/mydreamjob/onboarding/dto/CompanyKnowledgeOverviewResponse.java

@ -32,6 +32,7 @@ public class CompanyKnowledgeOverviewResponse {
private String logoUrl;
private String description;
private String industry;
private String industryCode;
private String website;
private String address;
private Boolean verified;

1
mydreamjob-server/src/main/java/com/mydreamjob/onlineresume/OnlineResumeContent.java

@ -41,6 +41,7 @@ public class OnlineResumeContent {
@NotNull @Size(max = 20) private List<@NotBlank @Size(max = 100) String> preferredCities = new ArrayList<>();
@NotNull @Size(max = 20) private List<@NotBlank @Size(max = 100) String> employmentTypes = new ArrayList<>();
@NotNull @Size(max = 20) private List<@NotBlank @Size(max = 100) String> preferredIndustries = new ArrayList<>();
@Size(max = 20) private List<@NotBlank @Size(max = 64) String> preferredIndustryCodes;
@NotNull @Size(max = 20) private List<@NotBlank @Size(max = 100) String> preferredJobTypes = new ArrayList<>();
private boolean remoteAllowed;
private JobSeekingStatus jobSeekingStatus;

10
mydreamjob-server/src/main/java/com/mydreamjob/onlineresume/OnlineResumeService.java

@ -96,6 +96,12 @@ public class OnlineResumeService {
private OnlineResumeView persist(User user, OnlineResume row, OnlineResumeContent content, OnlineResumeContent before) {
content = copy(content);
validate(content);
var industryIntention = content.getJobIntention();
var industryCodes = com.mydreamjob.industry.IndustryCatalog.preferenceCodes(
industryIntention.getPreferredIndustryCodes(), industryIntention.getPreferredIndustries());
if (industryIntention.getPreferredIndustryCodes() != null) industryIntention.setPreferredIndustries(
com.mydreamjob.industry.IndustryCatalog.preferenceNames(industryCodes, industryIntention.getPreferredIndustries()));
industryIntention.setPreferredIndustryCodes(industryCodes);
Set<String> skillNames = new HashSet<>();
for (var skill : content.getSkills()) {
skill.setName(skill.getName().trim());
@ -126,6 +132,7 @@ public class OnlineResumeService {
pref.setExpectedSalaryMin(intention.getExpectedSalaryMin()); pref.setExpectedSalaryMax(intention.getExpectedSalaryMax());
pref.setPreferredCities(encode(intention.getPreferredCities())); pref.setEmploymentTypes(encode(intention.getEmploymentTypes()));
pref.setPreferredIndustries(encode(intention.getPreferredIndustries())); pref.setPreferredJobTypes(encode(intention.getPreferredJobTypes()));
pref.setPreferredIndustryCodes(encode(intention.getPreferredIndustryCodes()));
pref.setRemoteAllowed(intention.isRemoteAllowed()); preferences.save(pref);
if (row == null) { row = new OnlineResume(); row.setUserId(uid); }
row.setContentJson(json); row.setRevision(Math.addExact(row.getRevision(), 1));
@ -174,6 +181,9 @@ public class OnlineResumeService {
intention.setExpectedSalaryMin(pref.getExpectedSalaryMin()); intention.setExpectedSalaryMax(pref.getExpectedSalaryMax());
intention.setPreferredCities(legacySkills(pref.getPreferredCities())); intention.setEmploymentTypes(legacySkills(pref.getEmploymentTypes()));
intention.setPreferredIndustries(legacySkills(pref.getPreferredIndustries())); intention.setPreferredJobTypes(legacySkills(pref.getPreferredJobTypes()));
intention.setPreferredIndustryCodes(pref.getPreferredIndustryCodes() == null
? com.mydreamjob.industry.IndustryCatalog.preferenceCodes(null, intention.getPreferredIndustries())
: legacySkills(pref.getPreferredIndustryCodes()));
intention.setRemoteAllowed(Boolean.TRUE.equals(pref.getRemoteAllowed()));
});
OnlineResumeView.Attachment attachment = p == null || p.getResumeUrl() == null ? null :

19
mydreamjob-server/src/main/java/com/mydreamjob/preference/PreferenceService.java

@ -71,11 +71,17 @@ public class PreferenceService {
throw new BusinessException(ErrorCode.BAD_REQUEST, "invalid merged salary range");
}
var industryCodes = com.mydreamjob.industry.IndustryCatalog.preferenceCodes(req.getPreferredIndustryCodes(), req.getPreferredIndustries());
if (req.getExpectedSalaryMin() != null) pref.setExpectedSalaryMin(req.getExpectedSalaryMin());
if (req.getExpectedSalaryMax() != null) pref.setExpectedSalaryMax(req.getExpectedSalaryMax());
if (req.getPreferredCities() != null) pref.setPreferredCities(toJson(req.getPreferredCities()));
if (req.getEmploymentTypes() != null) pref.setEmploymentTypes(toJson(req.getEmploymentTypes()));
if (req.getPreferredIndustries() != null) pref.setPreferredIndustries(toJson(req.getPreferredIndustries()));
if (req.getPreferredIndustries() != null || req.getPreferredIndustryCodes() != null) {
pref.setPreferredIndustryCodes(toJson(industryCodes));
if (req.getPreferredIndustryCodes() != null) pref.setPreferredIndustries(toJson(
com.mydreamjob.industry.IndustryCatalog.preferenceNames(industryCodes, req.getPreferredIndustries())));
}
if (req.getPreferredJobTypes() != null) pref.setPreferredJobTypes(toJson(req.getPreferredJobTypes()));
if (req.getRemoteAllowed() != null) pref.setRemoteAllowed(req.getRemoteAllowed());
@ -97,6 +103,11 @@ public class PreferenceService {
view.put("preferredCities", fromJson(pref.getPreferredCities()));
view.put("employmentTypes", fromJson(pref.getEmploymentTypes()));
view.put("preferredIndustries", fromJson(pref.getPreferredIndustries()));
view.put("preferredIndustryCodes", pref.getPreferredIndustryCodes() == null
? com.mydreamjob.industry.IndustryCatalog.preferenceCodes(null, fromJson(pref.getPreferredIndustries()))
: fromJson(pref.getPreferredIndustryCodes()));
view.put("unmappedPreferredIndustries", fromJson(pref.getPreferredIndustries()).stream()
.filter(name -> com.mydreamjob.industry.IndustryCatalog.resolveLegacy(name) == null).toList());
view.put("preferredJobTypes", fromJson(pref.getPreferredJobTypes()));
view.put("remoteAllowed", pref.getRemoteAllowed());
view.put("createdAt", pref.getCreatedAt());
@ -111,6 +122,8 @@ public class PreferenceService {
view.put("preferredCities", List.of());
view.put("employmentTypes", List.of());
view.put("preferredIndustries", List.of());
view.put("preferredIndustryCodes", List.of());
view.put("unmappedPreferredIndustries", List.of());
view.put("preferredJobTypes", List.of());
view.put("remoteAllowed", false);
return view;
@ -127,7 +140,11 @@ public class PreferenceService {
private List<String> fromJson(String json) {
if (json == null || json.isBlank()) return List.of();
try {
return mapper.readValue(json, new TypeReference<List<String>>() {});
var node = mapper.readTree(json);
for (int depth = 0; depth < 3 && node.isTextual(); depth++) node = mapper.readTree(node.asText());
if (!node.isArray()) return List.of();
for (var item : node) if (!item.isTextual()) return List.of();
return mapper.convertValue(node, new TypeReference<List<String>>() {});
} catch (JsonProcessingException e) {
return List.of();
}

3
mydreamjob-server/src/main/java/com/mydreamjob/preference/StudentPreference.java

@ -42,6 +42,9 @@ public class StudentPreference {
@Column(name = "preferred_industries", columnDefinition = "json")
private String preferredIndustries;
@Column(name = "preferred_industry_codes", columnDefinition = "json")
private String preferredIndustryCodes;
@Column(name = "preferred_job_types", columnDefinition = "json")
private String preferredJobTypes;

3
mydreamjob-server/src/main/java/com/mydreamjob/preference/dto/StudentPreferenceRequest.java

@ -31,6 +31,9 @@ public class StudentPreferenceRequest {
@Size(max = 20, message = "preferredIndustries must have at most 20 items")
private List<@Size(min = 1, max = 100, message = "each industry must be 1-100 chars") String> preferredIndustries;
@Size(max = 20)
private List<@jakarta.validation.constraints.NotBlank @Size(max = 64) String> preferredIndustryCodes;
@Size(max = 20, message = "preferredJobTypes must have at most 20 items")
private List<@Size(min = 1, max = 100, message = "each job type must be 1-100 chars") String> preferredJobTypes;

2
mydreamjob-server/src/main/java/com/mydreamjob/profileimage/ProfileImageController.java

@ -47,7 +47,7 @@ public class ProfileImageController {
return ApiResponse.ok(Map.of("deleted", true), requestIds.get(request));
}
@Operation(operationId = "readProfileImage", summary = "读取头像或企业Logo", description = "企业Logo匿名可读;个人头像须携带Bearer且仅本人或满足当前沟通授权的DIRECT成员可读,非公开用户头像目录。旧地址替换/删除后404;no-store。")
@Operation(operationId = "readProfileImage", summary = "读取头像或企业Logo", description = "企业Logo匿名可读;个人头像须携带Bearer且仅本人或满足当前沟通授权的DIRECT或TALENT_INVITATION成员可读,仍校验拉黑、撤回和企业接收门禁,非公开用户头像目录;不扩大邀约附件权限。旧地址替换/删除后404;no-store。")
@io.swagger.v3.oas.annotations.security.SecurityRequirements
@GetMapping("/profile-images/{imageId}")
public ResponseEntity<byte[]> read(@PathVariable String imageId) {

8
mydreamjob-server/src/main/java/com/mydreamjob/profileimage/ProfileImageService.java

@ -84,14 +84,14 @@ public class ProfileImageService {
for (ConversationMember member : members.findByUserId(actor)) {
Long id = member.getConversationId();
var conversation = conversations.findById(id).orElse(null);
if (conversation == null || !"DIRECT".equals(conversation.getType())
if (conversation == null || !("DIRECT".equals(conversation.getType()) || "TALENT_INVITATION".equals(conversation.getType()))
|| !members.existsByConversationIdAndUserId(id, owner)) continue;
try {
// 复用成员、拉黑、撤回与企业接收门禁;邀约会话不释放候选人的个人头像。
messaging.assertAttachmentAccess(id, actor);
// 非匿名邀约同样展示头像,但不能绕过成员、拉黑、撤回或企业接收门禁。
messaging.assertProfileImageAccess(id, actor);
return true;
} catch (BusinessException denied) {
// 另一条 DIRECT 会话可能仍有合法授权;数据库/系统异常不会在此吞掉。
// 另一条会话可能仍有合法授权;数据库/系统异常不会在此吞掉。
}
}
return false;

29
mydreamjob-server/src/main/java/com/mydreamjob/recommendation/JobRecommendationService.java

@ -58,7 +58,9 @@ public class JobRecommendationService {
@Transactional(readOnly = true)
public List<TaxonomyResponse> taxonomies() {
return taxonomyRepository.findByEnabledTrueOrderBySortOrderAscCodeAsc().stream().map(t -> TaxonomyResponse.builder()
.code(t.getCode()).parentCode(t.getParentCode()).type(t.getTaxonomyType()).displayName(t.getDisplayName()).build()).toList();
.code(t.getCode()).parentCode("JOB_FAMILY".equals(t.getTaxonomyType()) ? null : t.getParentCode())
.type(t.getTaxonomyType()).displayName("INDUSTRY".equals(t.getTaxonomyType())
? com.mydreamjob.industry.IndustryCatalog.displayName(t.getCode(), t.getDisplayName()) : t.getDisplayName()).build()).toList();
}
@Transactional(readOnly = true)
@ -189,11 +191,11 @@ public class JobRecommendationService {
list(preference.getPreferredCities()),
list(preference.getEmploymentTypes()),
preference.getExpectedSalaryMin(),
list(preference.getPreferredIndustries()),
normalizedIndustries(preference),
list(preference.getPreferredJobTypes()));
JobPreferenceScoreCalculator.Target target = new JobPreferenceScoreCalculator.Target(
job.getCity(), job.getEmploymentType(), job.getSalaryMax(),
profile.getIndustryCode(), profile.getJobFamilyCode());
normalizedIndustryTarget(profile.getIndustryCode()), profile.getJobFamilyCode());
return JobPreferenceScoreCalculator.calculate(criteria, target);
}
private BigDecimal weighted(Map<String,BigDecimal> scores,Map<String,BigDecimal> expected,Set<String> allowed){if(scores.isEmpty()||expected.isEmpty())return null; BigDecimal n=BigDecimal.ZERO,d=BigDecimal.ZERO;for(var e:expected.entrySet())if(allowed.contains(e.getKey())&&scores.containsKey(e.getKey())&&e.getValue().signum()>0){n=n.add(scores.get(e.getKey()).multiply(e.getValue()));d=d.add(e.getValue());}return d.signum()==0?null:n.divide(d,2,RoundingMode.HALF_UP);}
@ -205,7 +207,26 @@ public class JobRecommendationService {
private Map<String,BigDecimal> mapDecimal(String raw){try{if(raw==null)return Map.of();Map<String,Object>m=JSON.readValue(raw,new TypeReference<>(){});Map<String,BigDecimal>o=new HashMap<>();for(var e:m.entrySet())o.put(e.getKey(),new BigDecimal(String.valueOf(e.getValue())));return o;}catch(Exception e){return Map.of();}}
private List<String> list(String raw){try{return raw==null?List.of():JSON.readValue(raw,new TypeReference<List<String>>(){});}catch(Exception e){return List.of();}}
private String write(Object o){try{return o==null?null:JSON.writeValueAsString(o);}catch(Exception e){throw new BusinessException(ErrorCode.RECOMMENDATION_PROFILE_INVALID);}}
private void validateTaxonomy(String industry,String family){JobRecommendationTaxonomy i=taxonomyRepository.findById(industry).orElseThrow(()->new BusinessException(ErrorCode.RECOMMENDATION_TAXONOMY_INVALID));JobRecommendationTaxonomy f=taxonomyRepository.findById(family).orElseThrow(()->new BusinessException(ErrorCode.RECOMMENDATION_TAXONOMY_INVALID));if(!Boolean.TRUE.equals(i.getEnabled())||!Boolean.TRUE.equals(f.getEnabled())||!"INDUSTRY".equals(i.getTaxonomyType())||!"JOB_FAMILY".equals(f.getTaxonomyType())||!industry.equals(f.getParentCode()))throw new BusinessException(ErrorCode.RECOMMENDATION_TAXONOMY_INVALID);}
private List<String> normalizedIndustries(StudentPreference preference) {
return com.mydreamjob.industry.IndustryCatalog.scoringCriteria(preference.getPreferredIndustryCodes() == null
? null : list(preference.getPreferredIndustryCodes()), list(preference.getPreferredIndustries()));
}
private String normalizedIndustryTarget(String code) {
return com.mydreamjob.industry.IndustryCatalog.scoringTarget(code);
}
private void validateTaxonomy(String industry, String family) {
JobRecommendationTaxonomy i = taxonomyRepository.findById(industry)
.orElseThrow(() -> new BusinessException(ErrorCode.RECOMMENDATION_TAXONOMY_INVALID));
JobRecommendationTaxonomy f = taxonomyRepository.findById(family)
.orElseThrow(() -> new BusinessException(ErrorCode.RECOMMENDATION_TAXONOMY_INVALID));
// 行业和职位类别独立:金融企业同样可以招聘软件工程师。
if (!Boolean.TRUE.equals(i.getEnabled()) || !Boolean.TRUE.equals(f.getEnabled())
|| !"INDUSTRY".equals(i.getTaxonomyType()) || !"JOB_FAMILY".equals(f.getTaxonomyType())
|| com.mydreamjob.industry.IndustryCatalog.node(industry) == null)
throw new BusinessException(ErrorCode.RECOMMENDATION_TAXONOMY_INVALID);
}
private void validateMaps(Map<String,Integer> r,Map<String,Integer>a){if((r==null||r.isEmpty())&&(a==null||a.isEmpty()))throw new BusinessException(ErrorCode.RECOMMENDATION_PROFILE_INVALID);if(r!=null&&(!RIASEC.containsAll(r.keySet())||r.values().stream().anyMatch(v->v==null||v<0||v>100)))throw new BusinessException(ErrorCode.RECOMMENDATION_PROFILE_INVALID);if(a!=null&&(!ABILITY.containsAll(a.keySet())||a.values().stream().anyMatch(v->v==null||v<1||v>100)))throw new BusinessException(ErrorCode.RECOMMENDATION_PROFILE_INVALID);}
private void assertCompanyOwns(Long user, Long job) {
Company company = companyService.findCompanyByOwnerUserId(user);

2
mydreamjob-server/src/main/java/com/mydreamjob/skill/SkillCloudQueryService.java

@ -91,7 +91,7 @@ public class SkillCloudQueryService {
.toList();
return new SkillCloudResponse(
toNode(industry.getId(), industry.getCode(), industry.getNameZh()),
toNode(industry.getId(), industry.getCode(), com.mydreamjob.industry.IndustryCatalog.displayName(industry.getCode(), industry.getNameZh())),
toNode(occupation.getId(), occupation.getCode(), occupation.getNameZh()),
category == null ? null : toNode(category.getId(), category.getCode(), category.getNameZh()),
items.size(),

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

@ -195,13 +195,13 @@ public class TalentDiscoveryService {
preference.cities(),
preference.employmentTypes(),
preference.expectedSalaryMin(),
preference.industries(),
com.mydreamjob.industry.IndustryCatalog.scoringCriteria(null, preference.industries()),
preference.jobTypes()),
new JobPreferenceScoreCalculator.Target(
job.getCity(),
job.getEmploymentType(),
job.getSalaryMax(),
profile.getIndustryCode(),
com.mydreamjob.industry.IndustryCatalog.scoringTarget(profile.getIndustryCode()),
profile.getJobFamilyCode()));
}

629
mydreamjob-server/src/main/resources/catalog/industries-v1.json

@ -0,0 +1,629 @@
{
"version": "recruitment-industry-v1",
"description": "项目招聘行业目录;不宣称等同国家统计行业分类。行业与职位类别独立。",
"nodes": [
{
"code": "INTERNET",
"parentCode": null,
"name": "互联网/信息技术",
"aliases": [
"互联网",
"互联网/IT",
"IT",
"信息技术"
]
},
{
"code": "INTERNET_SOFTWARE",
"parentCode": "INTERNET",
"name": "软件与信息服务",
"aliases": []
},
{
"code": "INTERNET_NETWORK",
"parentCode": "INTERNET",
"name": "互联网平台",
"aliases": []
},
{
"code": "INTERNET_CLOUD",
"parentCode": "INTERNET",
"name": "云计算与数据服务",
"aliases": []
},
{
"code": "INTERNET_SECURITY",
"parentCode": "INTERNET",
"name": "网络安全",
"aliases": []
},
{
"code": "INTERNET_GAMES",
"parentCode": "INTERNET",
"name": "游戏与数字内容",
"aliases": []
},
{
"code": "FINANCE",
"parentCode": null,
"name": "金融",
"aliases": [
"金融业"
]
},
{
"code": "FINANCE_BANKING",
"parentCode": "FINANCE",
"name": "银行",
"aliases": []
},
{
"code": "FINANCE_SECURITIES",
"parentCode": "FINANCE",
"name": "证券与基金",
"aliases": []
},
{
"code": "FINANCE_INSURANCE",
"parentCode": "FINANCE",
"name": "保险",
"aliases": []
},
{
"code": "FINANCE_PAYMENT",
"parentCode": "FINANCE",
"name": "支付与金融科技",
"aliases": []
},
{
"code": "FINANCE_ASSET",
"parentCode": "FINANCE",
"name": "资产管理",
"aliases": []
},
{
"code": "MANUFACTURING",
"parentCode": null,
"name": "制造业",
"aliases": [
"制造",
"工业制造"
]
},
{
"code": "MANUFACTURING_MACHINERY",
"parentCode": "MANUFACTURING",
"name": "机械设备",
"aliases": []
},
{
"code": "MANUFACTURING_ELECTRONICS",
"parentCode": "MANUFACTURING",
"name": "电子与半导体",
"aliases": []
},
{
"code": "MANUFACTURING_ELECTRICAL",
"parentCode": "MANUFACTURING",
"name": "电气设备",
"aliases": []
},
{
"code": "MANUFACTURING_CHEMICAL",
"parentCode": "MANUFACTURING",
"name": "化工与材料",
"aliases": []
},
{
"code": "MANUFACTURING_METAL",
"parentCode": "MANUFACTURING",
"name": "金属与加工",
"aliases": []
},
{
"code": "MANUFACTURING_INSTRUMENT",
"parentCode": "MANUFACTURING",
"name": "仪器仪表",
"aliases": []
},
{
"code": "AUTOMOTIVE",
"parentCode": null,
"name": "汽车",
"aliases": [
"汽车行业"
]
},
{
"code": "AUTOMOTIVE_VEHICLE",
"parentCode": "AUTOMOTIVE",
"name": "整车制造",
"aliases": []
},
{
"code": "AUTOMOTIVE_PARTS",
"parentCode": "AUTOMOTIVE",
"name": "汽车零部件",
"aliases": []
},
{
"code": "AUTOMOTIVE_EV",
"parentCode": "AUTOMOTIVE",
"name": "新能源汽车",
"aliases": []
},
{
"code": "AUTOMOTIVE_SERVICE",
"parentCode": "AUTOMOTIVE",
"name": "汽车销售与服务",
"aliases": []
},
{
"code": "RETAIL",
"parentCode": null,
"name": "消费品与零售",
"aliases": [
"零售/消费",
"零售",
"消费品"
]
},
{
"code": "RETAIL_FMCG",
"parentCode": "RETAIL",
"name": "日用消费品",
"aliases": []
},
{
"code": "RETAIL_FOOD",
"parentCode": "RETAIL",
"name": "食品饮料",
"aliases": []
},
{
"code": "RETAIL_APPAREL",
"parentCode": "RETAIL",
"name": "服装纺织",
"aliases": []
},
{
"code": "RETAIL_HOME",
"parentCode": "RETAIL",
"name": "家居家电",
"aliases": []
},
{
"code": "RETAIL_ECOMMERCE",
"parentCode": "RETAIL",
"name": "电子商务",
"aliases": []
},
{
"code": "RETAIL_STORE",
"parentCode": "RETAIL",
"name": "零售与批发",
"aliases": []
},
{
"code": "HEALTHCARE",
"parentCode": null,
"name": "医疗健康",
"aliases": [
"医疗",
"医疗/健康"
]
},
{
"code": "HEALTHCARE_HOSPITAL",
"parentCode": "HEALTHCARE",
"name": "医疗服务",
"aliases": []
},
{
"code": "HEALTHCARE_PHARMA",
"parentCode": "HEALTHCARE",
"name": "医药",
"aliases": []
},
{
"code": "HEALTHCARE_DEVICE",
"parentCode": "HEALTHCARE",
"name": "医疗器械",
"aliases": []
},
{
"code": "HEALTHCARE_BIOTECH",
"parentCode": "HEALTHCARE",
"name": "生物技术",
"aliases": []
},
{
"code": "HEALTHCARE_CARE",
"parentCode": "HEALTHCARE",
"name": "养老与健康服务",
"aliases": []
},
{
"code": "EDUCATION",
"parentCode": null,
"name": "教育",
"aliases": [
"教育培训"
]
},
{
"code": "EDUCATION_SCHOOL",
"parentCode": "EDUCATION",
"name": "学校教育",
"aliases": []
},
{
"code": "EDUCATION_VOCATIONAL",
"parentCode": "EDUCATION",
"name": "职业教育",
"aliases": []
},
{
"code": "EDUCATION_TRAINING",
"parentCode": "EDUCATION",
"name": "培训服务",
"aliases": []
},
{
"code": "EDUCATION_EDTECH",
"parentCode": "EDUCATION",
"name": "教育科技",
"aliases": []
},
{
"code": "CONSTRUCTION",
"parentCode": null,
"name": "建筑与房地产",
"aliases": [
"建筑",
"房地产"
]
},
{
"code": "CONSTRUCTION_ENGINEERING",
"parentCode": "CONSTRUCTION",
"name": "建筑工程",
"aliases": []
},
{
"code": "CONSTRUCTION_DESIGN",
"parentCode": "CONSTRUCTION",
"name": "建筑设计",
"aliases": []
},
{
"code": "CONSTRUCTION_PROPERTY",
"parentCode": "CONSTRUCTION",
"name": "房地产开发",
"aliases": []
},
{
"code": "CONSTRUCTION_MANAGEMENT",
"parentCode": "CONSTRUCTION",
"name": "物业管理",
"aliases": []
},
{
"code": "CONSTRUCTION_DECORATION",
"parentCode": "CONSTRUCTION",
"name": "装饰装修",
"aliases": []
},
{
"code": "LOGISTICS",
"parentCode": null,
"name": "物流与交通",
"aliases": [
"物流",
"交通运输"
]
},
{
"code": "LOGISTICS_EXPRESS",
"parentCode": "LOGISTICS",
"name": "快递物流",
"aliases": []
},
{
"code": "LOGISTICS_SUPPLY_CHAIN",
"parentCode": "LOGISTICS",
"name": "仓储与供应链",
"aliases": []
},
{
"code": "LOGISTICS_SHIPPING",
"parentCode": "LOGISTICS",
"name": "航运港口",
"aliases": []
},
{
"code": "LOGISTICS_AVIATION",
"parentCode": "LOGISTICS",
"name": "航空",
"aliases": []
},
{
"code": "LOGISTICS_ROAD_RAIL",
"parentCode": "LOGISTICS",
"name": "公路与铁路运输",
"aliases": []
},
{
"code": "ENERGY",
"parentCode": null,
"name": "能源",
"aliases": [
"能源行业"
]
},
{
"code": "ENERGY_POWER",
"parentCode": "ENERGY",
"name": "电力",
"aliases": []
},
{
"code": "ENERGY_RENEWABLE",
"parentCode": "ENERGY",
"name": "新能源",
"aliases": []
},
{
"code": "ENERGY_OIL_GAS",
"parentCode": "ENERGY",
"name": "石油与天然气",
"aliases": []
},
{
"code": "ENERGY_MINING",
"parentCode": "ENERGY",
"name": "矿产资源",
"aliases": []
},
{
"code": "PROFESSIONAL",
"parentCode": null,
"name": "专业服务",
"aliases": [
"专业服务业"
]
},
{
"code": "PROFESSIONAL_CONSULTING",
"parentCode": "PROFESSIONAL",
"name": "管理咨询",
"aliases": []
},
{
"code": "PROFESSIONAL_LEGAL",
"parentCode": "PROFESSIONAL",
"name": "法律服务",
"aliases": []
},
{
"code": "PROFESSIONAL_ACCOUNTING",
"parentCode": "PROFESSIONAL",
"name": "会计审计与税务",
"aliases": []
},
{
"code": "PROFESSIONAL_HR",
"parentCode": "PROFESSIONAL",
"name": "人力资源服务",
"aliases": []
},
{
"code": "PROFESSIONAL_TESTING",
"parentCode": "PROFESSIONAL",
"name": "检验检测与认证",
"aliases": []
},
{
"code": "LIFE_SERVICES",
"parentCode": null,
"name": "生活服务",
"aliases": [
"生活服务业"
]
},
{
"code": "LIFE_SERVICES_CATERING",
"parentCode": "LIFE_SERVICES",
"name": "餐饮",
"aliases": []
},
{
"code": "LIFE_SERVICES_DOMESTIC",
"parentCode": "LIFE_SERVICES",
"name": "家政与社区服务",
"aliases": []
},
{
"code": "LIFE_SERVICES_BEAUTY",
"parentCode": "LIFE_SERVICES",
"name": "美容美发",
"aliases": []
},
{
"code": "LIFE_SERVICES_REPAIR",
"parentCode": "LIFE_SERVICES",
"name": "维修与居民服务",
"aliases": []
},
{
"code": "LIFE_SERVICES_SPORTS",
"parentCode": "LIFE_SERVICES",
"name": "体育健身",
"aliases": []
},
{
"code": "CULTURE_MEDIA",
"parentCode": null,
"name": "文化传媒",
"aliases": [
"文化",
"传媒"
]
},
{
"code": "CULTURE_MEDIA_ADVERTISING",
"parentCode": "CULTURE_MEDIA",
"name": "广告营销",
"aliases": []
},
{
"code": "CULTURE_MEDIA_PUBLISHING",
"parentCode": "CULTURE_MEDIA",
"name": "出版与新闻",
"aliases": []
},
{
"code": "CULTURE_MEDIA_FILM",
"parentCode": "CULTURE_MEDIA",
"name": "影视演艺",
"aliases": []
},
{
"code": "CULTURE_MEDIA_DESIGN",
"parentCode": "CULTURE_MEDIA",
"name": "创意设计",
"aliases": []
},
{
"code": "AGRICULTURE",
"parentCode": null,
"name": "农林牧渔",
"aliases": [
"农业"
]
},
{
"code": "AGRICULTURE_FARMING",
"parentCode": "AGRICULTURE",
"name": "种植与林业",
"aliases": []
},
{
"code": "AGRICULTURE_LIVESTOCK",
"parentCode": "AGRICULTURE",
"name": "畜牧与渔业",
"aliases": []
},
{
"code": "AGRICULTURE_AGTECH",
"parentCode": "AGRICULTURE",
"name": "农业科技",
"aliases": []
},
{
"code": "PUBLIC_SERVICES",
"parentCode": null,
"name": "公共与社会服务",
"aliases": [
"公共服务"
]
},
{
"code": "PUBLIC_SERVICES_PUBLIC",
"parentCode": "PUBLIC_SERVICES",
"name": "公共事业",
"aliases": []
},
{
"code": "PUBLIC_SERVICES_NONPROFIT",
"parentCode": "PUBLIC_SERVICES",
"name": "社会组织与公益",
"aliases": []
},
{
"code": "PUBLIC_SERVICES_RESEARCH",
"parentCode": "PUBLIC_SERVICES",
"name": "科研机构",
"aliases": []
},
{
"code": "TELECOM",
"parentCode": null,
"name": "通信",
"aliases": [
"通信行业"
]
},
{
"code": "TELECOM_OPERATOR",
"parentCode": "TELECOM",
"name": "通信运营",
"aliases": []
},
{
"code": "TELECOM_EQUIPMENT",
"parentCode": "TELECOM",
"name": "通信设备",
"aliases": []
},
{
"code": "TELECOM_NETWORK",
"parentCode": "TELECOM",
"name": "通信工程",
"aliases": []
},
{
"code": "TOURISM",
"parentCode": null,
"name": "旅游与住宿",
"aliases": [
"旅游",
"酒店"
]
},
{
"code": "TOURISM_HOTEL",
"parentCode": "TOURISM",
"name": "酒店与住宿",
"aliases": []
},
{
"code": "TOURISM_TRAVEL",
"parentCode": "TOURISM",
"name": "旅行服务",
"aliases": []
},
{
"code": "TOURISM_SCENIC",
"parentCode": "TOURISM",
"name": "景区与文旅",
"aliases": []
},
{
"code": "ENVIRONMENT",
"parentCode": null,
"name": "环保",
"aliases": [
"环境保护"
]
},
{
"code": "ENVIRONMENT_WASTE",
"parentCode": "ENVIRONMENT",
"name": "废弃物处理",
"aliases": []
},
{
"code": "ENVIRONMENT_WATER",
"parentCode": "ENVIRONMENT",
"name": "水务",
"aliases": []
},
{
"code": "ENVIRONMENT_ENVTECH",
"parentCode": "ENVIRONMENT",
"name": "环保技术与工程",
"aliases": []
}
]
}

97
mydreamjob-server/src/main/resources/db/migration/V65__unified_recruitment_industries.sql

@ -0,0 +1,97 @@
-- 新增编码字段;不回填、不覆盖任何旧企业或求职意向名称。
ALTER TABLE companies ADD COLUMN industry_code VARCHAR(64) NULL;
CREATE INDEX idx_companies_industry_code ON companies(industry_code);
ALTER TABLE student_preferences ADD COLUMN preferred_industry_codes JSON NULL;
-- 旧推荐编码保留;新增目录编码供既有 FK 引用,名称展示以中央目录为准。
INSERT INTO job_recommendation_taxonomies(code,parent_code,taxonomy_type,display_name,sort_order) VALUES
('INTERNET_SOFTWARE','INTERNET','INDUSTRY','软件与信息服务',1000),
('INTERNET_NETWORK','INTERNET','INDUSTRY','互联网平台',1001),
('INTERNET_CLOUD','INTERNET','INDUSTRY','云计算与数据服务',1002),
('INTERNET_SECURITY','INTERNET','INDUSTRY','网络安全',1003),
('INTERNET_GAMES','INTERNET','INDUSTRY','游戏与数字内容',1004),
('FINANCE_BANKING','FINANCE','INDUSTRY','银行',1005),
('FINANCE_SECURITIES','FINANCE','INDUSTRY','证券与基金',1006),
('FINANCE_INSURANCE','FINANCE','INDUSTRY','保险',1007),
('FINANCE_PAYMENT','FINANCE','INDUSTRY','支付与金融科技',1008),
('FINANCE_ASSET','FINANCE','INDUSTRY','资产管理',1009),
('MANUFACTURING_MACHINERY','MANUFACTURING','INDUSTRY','机械设备',1010),
('MANUFACTURING_ELECTRONICS','MANUFACTURING','INDUSTRY','电子与半导体',1011),
('MANUFACTURING_ELECTRICAL','MANUFACTURING','INDUSTRY','电气设备',1012),
('MANUFACTURING_CHEMICAL','MANUFACTURING','INDUSTRY','化工与材料',1013),
('MANUFACTURING_METAL','MANUFACTURING','INDUSTRY','金属与加工',1014),
('MANUFACTURING_INSTRUMENT','MANUFACTURING','INDUSTRY','仪器仪表',1015),
('AUTOMOTIVE',NULL,'INDUSTRY','汽车',1016),
('AUTOMOTIVE_VEHICLE','AUTOMOTIVE','INDUSTRY','整车制造',1017),
('AUTOMOTIVE_PARTS','AUTOMOTIVE','INDUSTRY','汽车零部件',1018),
('AUTOMOTIVE_EV','AUTOMOTIVE','INDUSTRY','新能源汽车',1019),
('AUTOMOTIVE_SERVICE','AUTOMOTIVE','INDUSTRY','汽车销售与服务',1020),
('RETAIL_FMCG','RETAIL','INDUSTRY','日用消费品',1021),
('RETAIL_FOOD','RETAIL','INDUSTRY','食品饮料',1022),
('RETAIL_APPAREL','RETAIL','INDUSTRY','服装纺织',1023),
('RETAIL_HOME','RETAIL','INDUSTRY','家居家电',1024),
('RETAIL_ECOMMERCE','RETAIL','INDUSTRY','电子商务',1025),
('RETAIL_STORE','RETAIL','INDUSTRY','零售与批发',1026),
('HEALTHCARE_HOSPITAL','HEALTHCARE','INDUSTRY','医疗服务',1027),
('HEALTHCARE_PHARMA','HEALTHCARE','INDUSTRY','医药',1028),
('HEALTHCARE_DEVICE','HEALTHCARE','INDUSTRY','医疗器械',1029),
('HEALTHCARE_BIOTECH','HEALTHCARE','INDUSTRY','生物技术',1030),
('HEALTHCARE_CARE','HEALTHCARE','INDUSTRY','养老与健康服务',1031),
('EDUCATION_SCHOOL','EDUCATION','INDUSTRY','学校教育',1032),
('EDUCATION_VOCATIONAL','EDUCATION','INDUSTRY','职业教育',1033),
('EDUCATION_TRAINING','EDUCATION','INDUSTRY','培训服务',1034),
('EDUCATION_EDTECH','EDUCATION','INDUSTRY','教育科技',1035),
('CONSTRUCTION',NULL,'INDUSTRY','建筑与房地产',1036),
('CONSTRUCTION_ENGINEERING','CONSTRUCTION','INDUSTRY','建筑工程',1037),
('CONSTRUCTION_DESIGN','CONSTRUCTION','INDUSTRY','建筑设计',1038),
('CONSTRUCTION_PROPERTY','CONSTRUCTION','INDUSTRY','房地产开发',1039),
('CONSTRUCTION_MANAGEMENT','CONSTRUCTION','INDUSTRY','物业管理',1040),
('CONSTRUCTION_DECORATION','CONSTRUCTION','INDUSTRY','装饰装修',1041),
('LOGISTICS',NULL,'INDUSTRY','物流与交通',1042),
('LOGISTICS_EXPRESS','LOGISTICS','INDUSTRY','快递物流',1043),
('LOGISTICS_SUPPLY_CHAIN','LOGISTICS','INDUSTRY','仓储与供应链',1044),
('LOGISTICS_SHIPPING','LOGISTICS','INDUSTRY','航运港口',1045),
('LOGISTICS_AVIATION','LOGISTICS','INDUSTRY','航空',1046),
('LOGISTICS_ROAD_RAIL','LOGISTICS','INDUSTRY','公路与铁路运输',1047),
('ENERGY',NULL,'INDUSTRY','能源',1048),
('ENERGY_POWER','ENERGY','INDUSTRY','电力',1049),
('ENERGY_RENEWABLE','ENERGY','INDUSTRY','新能源',1050),
('ENERGY_OIL_GAS','ENERGY','INDUSTRY','石油与天然气',1051),
('ENERGY_MINING','ENERGY','INDUSTRY','矿产资源',1052),
('PROFESSIONAL',NULL,'INDUSTRY','专业服务',1053),
('PROFESSIONAL_CONSULTING','PROFESSIONAL','INDUSTRY','管理咨询',1054),
('PROFESSIONAL_LEGAL','PROFESSIONAL','INDUSTRY','法律服务',1055),
('PROFESSIONAL_ACCOUNTING','PROFESSIONAL','INDUSTRY','会计审计与税务',1056),
('PROFESSIONAL_HR','PROFESSIONAL','INDUSTRY','人力资源服务',1057),
('PROFESSIONAL_TESTING','PROFESSIONAL','INDUSTRY','检验检测与认证',1058),
('LIFE_SERVICES',NULL,'INDUSTRY','生活服务',1059),
('LIFE_SERVICES_CATERING','LIFE_SERVICES','INDUSTRY','餐饮',1060),
('LIFE_SERVICES_DOMESTIC','LIFE_SERVICES','INDUSTRY','家政与社区服务',1061),
('LIFE_SERVICES_BEAUTY','LIFE_SERVICES','INDUSTRY','美容美发',1062),
('LIFE_SERVICES_REPAIR','LIFE_SERVICES','INDUSTRY','维修与居民服务',1063),
('LIFE_SERVICES_SPORTS','LIFE_SERVICES','INDUSTRY','体育健身',1064),
('CULTURE_MEDIA',NULL,'INDUSTRY','文化传媒',1065),
('CULTURE_MEDIA_ADVERTISING','CULTURE_MEDIA','INDUSTRY','广告营销',1066),
('CULTURE_MEDIA_PUBLISHING','CULTURE_MEDIA','INDUSTRY','出版与新闻',1067),
('CULTURE_MEDIA_FILM','CULTURE_MEDIA','INDUSTRY','影视演艺',1068),
('CULTURE_MEDIA_DESIGN','CULTURE_MEDIA','INDUSTRY','创意设计',1069),
('AGRICULTURE',NULL,'INDUSTRY','农林牧渔',1070),
('AGRICULTURE_FARMING','AGRICULTURE','INDUSTRY','种植与林业',1071),
('AGRICULTURE_LIVESTOCK','AGRICULTURE','INDUSTRY','畜牧与渔业',1072),
('AGRICULTURE_AGTECH','AGRICULTURE','INDUSTRY','农业科技',1073),
('PUBLIC_SERVICES',NULL,'INDUSTRY','公共与社会服务',1074),
('PUBLIC_SERVICES_PUBLIC','PUBLIC_SERVICES','INDUSTRY','公共事业',1075),
('PUBLIC_SERVICES_NONPROFIT','PUBLIC_SERVICES','INDUSTRY','社会组织与公益',1076),
('PUBLIC_SERVICES_RESEARCH','PUBLIC_SERVICES','INDUSTRY','科研机构',1077),
('TELECOM',NULL,'INDUSTRY','通信',1078),
('TELECOM_OPERATOR','TELECOM','INDUSTRY','通信运营',1079),
('TELECOM_EQUIPMENT','TELECOM','INDUSTRY','通信设备',1080),
('TELECOM_NETWORK','TELECOM','INDUSTRY','通信工程',1081),
('TOURISM',NULL,'INDUSTRY','旅游与住宿',1082),
('TOURISM_HOTEL','TOURISM','INDUSTRY','酒店与住宿',1083),
('TOURISM_TRAVEL','TOURISM','INDUSTRY','旅行服务',1084),
('TOURISM_SCENIC','TOURISM','INDUSTRY','景区与文旅',1085),
('ENVIRONMENT',NULL,'INDUSTRY','环保',1086),
('ENVIRONMENT_WASTE','ENVIRONMENT','INDUSTRY','废弃物处理',1087),
('ENVIRONMENT_WATER','ENVIRONMENT','INDUSTRY','水务',1088),
('ENVIRONMENT_ENVTECH','ENVIRONMENT','INDUSTRY','环保技术与工程',1089);

22
mydreamjob-server/src/test/java/com/mydreamjob/application/CompanyResumeDownloadIntegrationTest.java

@ -503,8 +503,26 @@ class CompanyResumeDownloadIntegrationTest {
}
assertThat(modulePaths).as("N03/N04/N05 新模块路径").isEqualTo(13);
assertThat(moduleOperations).as("N03/N04/N05 新模块操作").isEqualTo(18);
assertThat(pathCount - modulePaths).as("非模块路径包含头像/Logo及通知回收站").isEqualTo(208);
assertThat(opCount - moduleOperations).as("非模块操作包含头像/Logo及通知回收站").isEqualTo(253);
var staticPaths = new com.fasterxml.jackson.databind.ObjectMapper().readTree(java.nio.file.Files.readString(
java.nio.file.Path.of("../docs/openapi-rc.json"))).path("paths");
var expectedPaths = new java.util.HashSet<String>();
var actualPaths = new java.util.HashSet<String>();
var expectedOperations = new java.util.HashSet<String>();
var actualOperations = new java.util.HashSet<String>();
staticPaths.fields().forEachRemaining(entry -> {
expectedPaths.add(entry.getKey());
entry.getValue().fieldNames().forEachRemaining(method -> {
if (java.util.Set.of("get", "post", "put", "delete", "patch").contains(method)) expectedOperations.add(method + " " + entry.getKey());
});
});
paths.fields().forEachRemaining(entry -> {
actualPaths.add(entry.getKey());
entry.getValue().fieldNames().forEachRemaining(method -> {
if (java.util.Set.of("get", "post", "put", "delete", "patch").contains(method)) actualOperations.add(method + " " + entry.getKey());
});
});
assertThat(actualPaths).as("运行时完整路径集与静态契约一致,含统一行业目录").isEqualTo(expectedPaths);
assertThat(actualOperations).as("运行时完整操作集与静态契约一致,不靠旧全局计数").isEqualTo(expectedOperations);
var download = paths.path("/company/candidates/{applicationId}/resume").path("get");
assertThat(download.isMissingNode()).as("必须存在企业简历下载 GET").isFalse();

2
mydreamjob-server/src/test/java/com/mydreamjob/auth/identity/IdentityPolicyTest.java

@ -20,7 +20,7 @@ class IdentityPolicyTest {
void businessGetIsDenied(String path) {
assertThat(new IdentityAccessFilter(null,null,null,null).allowed("GET",path)).isFalse();
}
@ParameterizedTest @ValueSource(strings={"/jobs","/jobs/search","/jobs/filters","/companies/42",
@ParameterizedTest @ValueSource(strings={"/jobs","/jobs/search","/jobs/filters","/jobs/industry-catalog","/companies/42",
"/companies/42/jobs","/auth/me","/v3/api-docs","/health"})
void publicGetRemainsAccessible(String path) {
assertThat(new IdentityAccessFilter(null,null,null,null).allowed("GET",path)).isTrue();

4
mydreamjob-server/src/test/java/com/mydreamjob/auth/identity/UnifiedRegistrationIntegrationTest.java

@ -183,7 +183,7 @@ class UnifiedRegistrationIntegrationTest {
java.nio.file.Files.writeString(java.nio.file.Path.of("target/unified-openapi-runtime.json"),mapper.writeValueAsString(doc));
String token=login("19900009410").path("accessToken").asText();
// 独立列出已批准的公开契约,不调用生产 gate.allowed 生成期望值。
Set<String> publicGet=Set.of("/","/health","/health/details","/jobs","/jobs/search","/jobs/filters",
Set<String> publicGet=Set.of("/","/health","/health/details","/jobs","/jobs/search","/jobs/filters","/jobs/industry-catalog",
"/jobs/compare","/jobs/public/{jobId}","/jobs/{jobId}/reviews","/jobs/{jobId}/reviews/{reviewId}",
"/jobs/{jobId}/reviews/summary","/companies/{companyId}","/companies/{companyId}/jobs",
"/job-recommendation-taxonomies","/auth/me","/profile-images/{imageId}");
@ -191,6 +191,8 @@ class UnifiedRegistrationIntegrationTest {
"/auth/password/change","/auth/login","/auth/password/login","/auth/password/reset",
"/auth/refresh","/auth/sms/send","/auth/company/register");
int denied=0;
mvc.perform(get("/jobs/industry-catalog").header("Authorization", "Bearer " + token))
.andExpect(status().isOk()).andExpect(jsonPath("$.data.version").value("recruitment-industry-v1"));
int moduleDenied=0;
var paths=doc.path("paths").fields();
while(paths.hasNext()) {

39
mydreamjob-server/src/test/java/com/mydreamjob/industry/IndustryCatalogTest.java

@ -0,0 +1,39 @@
package com.mydreamjob.industry;
import com.mydreamjob.common.exception.BusinessException;
import org.junit.jupiter.api.Test;
import java.util.*;
import static org.junit.jupiter.api.Assertions.*;
class IndustryCatalogTest {
@Test void catalogHasBroadTwoLevelCoverageAndStableLegacyCodes() {
assertEquals(18, IndustryCatalog.nodes().stream().filter(n -> n.parentCode() == null).count());
assertTrue(IndustryCatalog.nodes().size() > 90);
for (String code : List.of("INTERNET", "FINANCE", "MANUFACTURING", "HEALTHCARE", "EDUCATION", "RETAIL",
"AUTOMOTIVE", "CONSTRUCTION", "LOGISTICS", "ENERGY", "PROFESSIONAL", "LIFE_SERVICES")) {
assertNotNull(IndustryCatalog.node(code));
assertTrue(IndustryCatalog.nodes().stream().anyMatch(n -> code.equals(n.parentCode())));
}
}
@Test void aliasesAreExactAndUnknownsStayUnmapped() {
assertEquals("INTERNET", IndustryCatalog.resolveLegacy(" 互联网/IT "));
assertEquals("FINANCE_BANKING", IndustryCatalog.resolveLegacy("银行"));
assertNull(IndustryCatalog.resolveLegacy("软件工程师"));
assertNull(IndustryCatalog.resolveLegacy("未核实的综合业务"));
}
@Test void codeAndLabelConflictsAreRejected() {
assertThrows(BusinessException.class, () -> IndustryCatalog.selection("FINANCE_BANKING", "教育"));
assertThrows(BusinessException.class, () -> IndustryCatalog.selection("UNKNOWN", null));
assertEquals("银行", IndustryCatalog.selection("FINANCE_BANKING", null).name());
}
@Test void parentExpansionDoesNotConflateOccupation() {
assertTrue(IndustryCatalog.expand(List.of("FINANCE")).contains("FINANCE_BANKING"));
assertEquals(Set.of("FINANCE_BANKING"), IndustryCatalog.expand(List.of("FINANCE_BANKING")));
assertThrows(BusinessException.class, () -> IndustryCatalog.expand(List.of("IT_SOFTWARE")));
}
@Test void legacyPreferenceKeepsUnknownOutsideKnownCodeSet() {
assertEquals(List.of("FINANCE"), IndustryCatalog.preferenceCodes(null, List.of("金融", "未知旧名称")));
assertThrows(BusinessException.class, () -> IndustryCatalog.preferenceCodes(List.of("FINANCE"), List.of("教育")));
assertEquals(List.of(), IndustryCatalog.preferenceCodes(List.of(), List.of()));
}
}

60
mydreamjob-server/src/test/java/com/mydreamjob/industry/IndustryMySqlMigrationTest.java

@ -0,0 +1,60 @@
package com.mydreamjob.industry;
import com.mydreamjob.config.MySql57MigrationConfiguration;
import org.flywaydb.core.Flyway;
import org.junit.jupiter.api.Test;
import org.testcontainers.containers.MySQLContainer;
import java.sql.*;
import java.util.*;
import static org.junit.jupiter.api.Assertions.*;
/** 独立验证 V64→V65,不改写旧名称、历史 checksum 或既有技能数据。 */
class IndustryMySqlMigrationTest {
@Test void mysql84() throws Exception { verify("mysql:8.4"); }
@Test void mysql57() throws Exception { verify("mysql:5.7.36"); }
private Flyway flyway(MySQLContainer<?> db, String version) {
var config = Flyway.configure().dataSource(db.getJdbcUrl(), db.getUsername(), db.getPassword())
.locations("classpath:db/migration").target(version);
MySql57MigrationConfiguration.configure(config); return config.load();
}
private List<String> history(Statement sql) throws SQLException {
var result = new ArrayList<String>();
try (var rs = sql.executeQuery("SELECT version,checksum,success FROM flyway_schema_history WHERE version<>'65' ORDER BY installed_rank")) {
while (rs.next()) result.add(rs.getString(1) + "|" + rs.getString(2) + "|" + rs.getBoolean(3));
}
return result;
}
private void verify(String image) throws Exception {
try (var db = new MySQLContainer<>(image).withDatabaseName("industry_migration").withUsername("industry_test")
.withPassword("synthetic-only").withCommand("--max-allowed-packet=33554432", "--character-set-server=utf8mb4", "--collation-server=utf8mb4_unicode_ci")
.withUrlParam("characterEncoding", "UTF-8")
.withCreateContainerCmdModifier(c -> c.getHostConfig().withMemory(768L * 1024 * 1024))) {
db.start(); flyway(db, "64").migrate();
try (var connection = DriverManager.getConnection(db.getJdbcUrl(), db.getUsername(), db.getPassword()); var sql = connection.createStatement()) {
sql.execute("INSERT INTO users(id,phone,role,is_active,is_verified) VALUES(965001,'19800965001','COMPANY',1,1),(965002,'19800965002','STUDENT',1,0)");
sql.execute("INSERT INTO companies(id,owner_user_id,name,industry,verified) VALUES(965001,965001,'legacy company','unconfirmed legacy label',1)");
sql.execute("INSERT INTO student_preferences(student_user_id,preferred_industries) VALUES(965002,JSON_ARRAY('old unknown industry','finance'))");
var before = history(sql);
assertEquals(1, flyway(db, "65").migrate().migrationsExecuted);
assertEquals(before, history(sql));
try (var rs = sql.executeQuery("SELECT industry,industry_code FROM companies WHERE id=965001")) {
assertTrue(rs.next()); assertEquals("unconfirmed legacy label", rs.getString(1)); assertNull(rs.getString(2));
}
try (var rs = sql.executeQuery("SELECT preferred_industries,preferred_industry_codes FROM student_preferences WHERE student_user_id=965002")) {
assertTrue(rs.next()); assertTrue(rs.getString(1).contains("old unknown industry")); assertNull(rs.getString(2));
}
try (var rs = sql.executeQuery("SELECT count(*) FROM job_recommendation_taxonomies WHERE taxonomy_type='INDUSTRY'")) {
assertTrue(rs.next()); assertEquals(IndustryCatalog.nodes().size(), rs.getInt(1));
}
try (var rs = sql.executeQuery("SELECT code,display_name,parent_code FROM job_recommendation_taxonomies WHERE taxonomy_type='INDUSTRY'")) {
while (rs.next()) { assertNotNull(IndustryCatalog.node(rs.getString(1))); assertEquals(IndustryCatalog.node(rs.getString(1)).parentCode(), rs.getString(3)); }
}
try (var rs = sql.executeQuery("SELECT code FROM skill_industries")) {
while (rs.next()) assertNotNull(IndustryCatalog.node(rs.getString(1)));
}
assertEquals(0, flyway(db, "65").migrate().migrationsExecuted);
flyway(db, "65").validate(); assertEquals(before, history(sql));
}
}
}
}

145
mydreamjob-server/src/test/java/com/mydreamjob/industry/UnifiedIndustryIntegrationTest.java

@ -0,0 +1,145 @@
package com.mydreamjob.industry;
import com.fasterxml.jackson.databind.*;
import com.mydreamjob.auth.*;
import com.mydreamjob.company.*;
import com.mydreamjob.company.dto.*;
import com.mydreamjob.job.*;
import com.mydreamjob.preference.*;
import com.mydreamjob.preference.dto.StudentPreferenceRequest;
import com.mydreamjob.onlineresume.*;
import com.mydreamjob.recommendation.JobRecommendationService;
import jakarta.persistence.EntityManager;
import org.junit.jupiter.api.*;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.test.util.ReflectionTestUtils;
import java.util.*;
import java.util.concurrent.atomic.AtomicLong;
import static org.junit.jupiter.api.Assertions.*;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
@SpringBootTest @AutoConfigureMockMvc @ActiveProfiles("integration-test") @Transactional
class UnifiedIndustryIntegrationTest {
private static final AtomicLong PHONE = new AtomicLong(19865000000L);
@Autowired EntityManager em;
@Autowired MockMvc mvc;
@Autowired ObjectMapper mapper;
@Autowired IndustryDirectoryService directory;
@Autowired CompanyService companies;
@Autowired JobService jobs;
@Autowired PreferenceService preferences;
@Autowired OnlineResumeService resumes;
@Autowired JobRecommendationService recommendations;
@MockBean RedisTemplate<String, Object> redis;
User user(User.Role role) {
var u = User.builder().phone(Long.toString(PHONE.incrementAndGet())).role(role).roles(Set.of(role)).active(true).build();
em.persist(u); em.flush(); return u;
}
Company company(String name, String code, String legacy) {
var owner = user(User.Role.COMPANY);
var c = Company.builder().ownerUserId(owner.getId()).name(name).industryCode(code).industry(legacy).verified(true).build();
em.persist(c); em.flush(); return c;
}
Job job(Company company, String status) {
var j = Job.builder().companyId(company.getId()).title("行业独立测试软件工程师").description("合成岗位")
.city("行业测试城").employmentType("FULL_TIME").status(status).build();
em.persist(j); em.flush(); return j;
}
JsonNode data(String path) throws Exception {
return mapper.readTree(mvc.perform(get(path)).andExpect(status().isOk()).andReturn().getResponse().getContentAsString(java.nio.charset.StandardCharsets.UTF_8)).path("data");
}
@Test void completeDirectoryIsPublicAndContainsZeroJobIndustries() throws Exception {
assertEquals(IndustryCatalog.nodes().size(), data("/jobs/industry-catalog").path("items").size());
mvc.perform(get("/jobs/industry-catalog?scope=OTHER")).andExpect(status().isBadRequest());
assertTrue(directory.directory("ALL").items().stream().anyMatch(i -> i.publishedJobCount() == 0));
}
@Test void runtimeContractDocumentsDirectoryAndStableCodes() throws Exception {
var response = mvc.perform(get("/v3/api-docs")).andExpect(status().isOk()).andReturn().getResponse().getContentAsString(java.nio.charset.StandardCharsets.UTF_8);
java.nio.file.Files.writeString(java.nio.file.Path.of("target/industry-runtime-openapi.json"), response);
var spec = mapper.readTree(response);
assertEquals("getIndustryCatalog", spec.path("paths").path("/jobs/industry-catalog").path("get").path("operationId").asText());
assertTrue(spec.path("components").path("schemas").path("CompanyUpdateRequest").path("properties").has("industryCode"));
assertTrue(spec.path("components").path("schemas").path("StudentPreferenceRequest").path("properties").has("preferredIndustryCodes"));
}
@Test void countsIncludeLegacyAliasesAndChildrenButExcludeDraftAndUnknown() {
var bank = company("银行企业", "FINANCE_BANKING", "银行");
job(bank, "PUBLISHED"); job(bank, "DRAFT");
job(company("旧银行", null, "银行"), "PUBLISHED");
var before = directory.directory("ALL").unmappedPublishedJobCount();
job(company("未知", null, "不可确认旧行业"), "PUBLISHED");
var view = directory.directory("WITH_JOBS");
assertEquals(before + 1, view.unmappedPublishedJobCount());
assertEquals(2, view.items().stream().filter(i -> i.code().equals("FINANCE_BANKING")).findFirst().orElseThrow().publishedJobCount());
assertTrue(view.items().stream().anyMatch(i -> i.code().equals("FINANCE") && i.publishedJobCount() >= 2));
assertTrue(view.items().stream().allMatch(i -> i.publishedJobCount() > 0));
}
@Test void codeFilteringUsesCompanyIndustryIncludesDescendantsAndLegacy() throws Exception {
job(company("编码银行", "FINANCE_BANKING", "名称以后可变"), "PUBLISHED");
job(company("历史银行", null, "银行"), "PUBLISHED");
job(company("制造业", "MANUFACTURING", "制造业"), "PUBLISHED");
var result = data("/jobs/search?city=行业测试城&industryCode=FINANCE");
assertEquals(2, result.path("total").asInt());
assertEquals(2, data("/jobs/search?city=行业测试城&industryCode=FINANCE_BANKING").path("total").asInt());
mvc.perform(get("/jobs/search?industryCode=UNKNOWN")).andExpect(status().isBadRequest());
mvc.perform(get("/jobs/search?industryCode=FINANCE,,EDUCATION")).andExpect(status().isBadRequest());
}
@Test void companyWritesStoreCodeAndRejectConflictingNamesAtomically() {
var owner = user(User.Role.COMPANY);
var req = new CompanyCreateRequest(); req.setName("编码创建"); req.setIndustryCode("AUTOMOTIVE");
var c = companies.createCompany(owner.getId(), req);
assertEquals("AUTOMOTIVE", c.getIndustryCode()); assertEquals("汽车", c.getIndustry());
var update = new CompanyUpdateRequest(); update.setName("不可写入"); update.setIndustryCode("FINANCE"); update.setIndustry("教育");
assertThrows(com.mydreamjob.common.exception.BusinessException.class, () -> companies.updateCompany(owner.getId(), update));
assertEquals("编码创建", c.getName());
}
@Test void unknownLegacyCompanyNameIsPreservedAndNotBackfilled() {
var c = company("旧资料", null, "待业务确认的行业");
assertNull(c.getIndustryCode()); assertEquals("待业务确认的行业", c.getIndustry());
em.clear(); assertNull(em.find(Company.class, c.getId()).getIndustryCode());
}
@Test void preferenceCodesRoundTripToOnlineResumeAndClear() {
var student = user(User.Role.STUDENT);
var req = new StudentPreferenceRequest(); req.setPreferredIndustryCodes(List.of("FINANCE_BANKING"));
assertEquals(List.of("FINANCE_BANKING"), preferences.savePreferences(student.getId(), req).get("preferredIndustryCodes"));
assertEquals(List.of("FINANCE_BANKING"), resumes.get(student.getId()).content().getJobIntention().getPreferredIndustryCodes());
req.setPreferredIndustryCodes(List.of()); preferences.savePreferences(student.getId(), req);
assertEquals(List.of(), resumes.get(student.getId()).content().getJobIntention().getPreferredIndustryCodes());
}
@Test void unknownLegacyPreferencesRemainPendingAndConflictsRejectBeforeMutation() {
var student = user(User.Role.STUDENT);
var req = new StudentPreferenceRequest(); req.setPreferredIndustries(List.of("金融", "未知旧行业"));
var view = preferences.savePreferences(student.getId(), req);
assertEquals(List.of("FINANCE"), view.get("preferredIndustryCodes"));
assertEquals(List.of("未知旧行业"), view.get("unmappedPreferredIndustries"));
req.setPreferredIndustryCodes(List.of("EDUCATION")); req.setExpectedSalaryMin(90000);
assertThrows(com.mydreamjob.common.exception.BusinessException.class, () -> preferences.savePreferences(student.getId(), req));
assertNull(preferences.getPreferences(student.getId()).get("expectedSalaryMin"));
}
@Test void bankCanRecruitSoftwareEngineerWithoutChangingOccupationCodes() {
assertDoesNotThrow(() -> ReflectionTestUtils.invokeMethod(recommendations, "validateTaxonomy", "FINANCE_BANKING", "IT_SOFTWARE"));
assertThrows(RuntimeException.class, () -> ReflectionTestUtils.invokeMethod(recommendations, "validateTaxonomy", "IT_SOFTWARE", "FINANCE"));
assertTrue(recommendations.taxonomies().stream().filter(t -> "JOB_FAMILY".equals(t.getType())).allMatch(t -> t.getParentCode() == null));
}
@Test void recommendationNormalizesBothSidesAndKeepsUnknownLegacyCriterion() {
var preference = StudentPreference.builder().preferredIndustries("[\"IT\",\"待确认旧行业\"]").build();
List<String> normalized = ReflectionTestUtils.invokeMethod(recommendations, "normalizedIndustries", preference);
assertTrue(normalized.containsAll(List.of("INTERNET", "INTERNET_SOFTWARE", "待确认旧行业")));
var profile = com.mydreamjob.recommendation.JobRecommendationProfile.builder().industryCode("IT").jobFamilyCode("IT_SOFTWARE").build();
java.math.BigDecimal score = ReflectionTestUtils.invokeMethod(recommendations, "preferenceScore", preference, profile, Job.builder().build());
assertEquals(0, new java.math.BigDecimal("100.00").compareTo(score));
}
@Test void skillsExposeOnlyExistingDatasetMappingNotInventedCoverage() {
var items = directory.directory("ALL").items();
assertTrue(items.stream().anyMatch(i -> "INTERNET".equals(i.skillIndustryCode())));
assertNull(items.stream().filter(i -> i.code().equals("AUTOMOTIVE")).findFirst().orElseThrow().skillIndustryCode());
}
}

4
mydreamjob-server/src/test/java/com/mydreamjob/job/JobDisplayFieldsConsistencyIntegrationTest.java

@ -55,7 +55,7 @@ class JobDisplayFieldsConsistencyIntegrationTest {
private static final String PHONE_PREFIX = "1990078";
private static final String CITY = "CONSISTENCYCITY";
private static final Set<String> PUBLIC_COMPANY_KEYS =
Set.of("id", "name", "logoUrl", "description", "industry", "verified", "companySize");
Set.of("id", "name", "logoUrl", "description", "industry", "industryCode", "verified", "companySize");
@MockBean private RedisTemplate<String, Object> redis;
@Autowired private MockMvc mvc;
@ -201,7 +201,7 @@ class JobDisplayFieldsConsistencyIntegrationTest {
// ── 4. 公开企业摘要白名单 ───────────────────────────────────
@Test
void publicCompanySummaryIsExactlySevenFieldWhitelist() throws Exception {
void publicCompanySummaryIsExactlyEightFieldWhitelist() throws Exception {
long jobId = createFullJob("白名单岗位");
publish(jobId);

14
mydreamjob-server/src/test/java/com/mydreamjob/match/MatchServiceTest.java

@ -36,6 +36,7 @@ class MatchServiceTest {
@Mock private StudentProfileRepository profileRepository;
@Mock private AuditService auditService;
@Mock private UserRepository userRepository;
@Mock private com.mydreamjob.company.CompanyRepository companyRepository;
/** R01-B/B1:角色授权已委托给公共 guard;此处以委托桩保持既有断言语义。 */
@Mock
private UserRoleGuard userRoleGuard;
@ -70,12 +71,25 @@ class MatchServiceTest {
.expectedSalaryMin(10000).preferredIndustries("[\"互联网\"]")
.preferredJobTypes("[\"Java\"]").remoteAllowed(false).build();
when(preferenceRepository.findByStudentUserId(STUDENT_ID)).thenReturn(Optional.of(p));
when(companyRepository.findById(1L)).thenReturn(Optional.of(com.mydreamjob.company.Company.builder().industryCode("INTERNET").build()));
}
private void mockProfile() {
when(profileRepository.findByUserId(STUDENT_ID)).thenReturn(Optional.of(
StudentProfile.builder().userId(STUDENT_ID).build()));
}
@Test void industryMatchesCompanyCodeNotSoftwareJobDescription() {
mockRole(STUDENT_ID, User.Role.STUDENT);
when(jobService.getJob(JOB_ID)).thenReturn(publishedJob);
when(preferenceRepository.findByStudentUserId(STUDENT_ID)).thenReturn(Optional.of(StudentPreference.builder()
.preferredIndustryCodes("[\"FINANCE\"]").preferredIndustries("[]").build()));
publishedJob.setDescription("制造业的软件工程师职位描述");
when(companyRepository.findById(1L)).thenReturn(Optional.of(com.mydreamjob.company.Company.builder().industryCode("FINANCE_BANKING").build()));
assertTrue(((java.util.List<?>) matchService.diagnose(STUDENT_ID, JOB_ID).get("matchedConditions")).contains("行业偏好匹配"));
when(companyRepository.findById(1L)).thenReturn(Optional.of(com.mydreamjob.company.Company.builder().industryCode("MANUFACTURING").build()));
publishedJob.setDescription("金融行业职位描述");
assertFalse(((java.util.List<?>) matchService.diagnose(STUDENT_ID, JOB_ID).get("matchedConditions")).contains("行业偏好匹配"));
}
// ── 角色校验 ─────────────────────────────────────────────

52
mydreamjob-server/src/test/java/com/mydreamjob/messaging/ChatWorkflowIntegrationTest.java

@ -156,7 +156,7 @@ class ChatWorkflowIntegrationTest {
denied(()->messaging.sendMessage(student.getId(),text),1001);
assertNotNull(sent.getId());
}
@Test void summaryHasRealPeerAndRedactedInvitationView() {
@Test void summaryHasRealPeerAndNonAnonymousInvitationDisplay() {
send("summary-1");
var forStudent=messaging.getConversation(conversation.getId(),student.getId());
assertEquals("测试企业",forStudent.get("contactName"));assertEquals("[简历]",forStudent.get("lastMessage"));
@ -164,11 +164,59 @@ class ChatWorkflowIntegrationTest {
assertEquals("候选人昵称",forHr.get("contactName"));assertFalse(forHr.containsKey("online"));
conversation.setType("TALENT_INVITATION");em.flush();
var anonymous=messaging.getConversation(conversation.getId(),employer.getId());
assertEquals("求职者",anonymous.get("contactName"));assertFalse(anonymous.containsKey("createdBy"));
assertEquals("候选人昵称",anonymous.get("contactName"));assertFalse(anonymous.containsKey("createdBy"));
var message=messaging.listMessages(conversation.getId(),employer.getId(),0,20).getItems().get(0);
assertNull(message.getSenderUserId());assertEquals("OTHER_PARTICIPANT",message.getSenderScope());
assertEquals("resume.pdf",message.getResume().fileName());
}
@Test void displayUsesCurrentProfileNameForDirectAndInvitationWithoutChangingNickname() {
student.setAvatarUrl("/api/v1/profile-images/synthetic-avatar");
for (String type : List.of("DIRECT", "TALENT_INVITATION")) {
conversation.setType(type);
profile.setRealName(" 新姓名 "); em.flush();
var detail=messaging.getConversation(conversation.getId(),employer.getId());
assertEquals("新姓名",detail.get("contactName"));
assertEquals(student.getAvatarUrl(),detail.get("avatarUrl"));
assertEquals("新姓名",messaging.listConversations(employer.getId()).get(0).get("contactName"));
assertEquals("候选人昵称",student.getNickname());
assertFalse(detail.containsKey("realName")); assertFalse(detail.containsKey("phone"));
profile.setRealName("再次修改");em.flush();
assertEquals("再次修改",messaging.getConversation(conversation.getId(),employer.getId()).get("contactName"));
}
}
@Test void missingAndBlankProfileNamesFallBackToNicknameThenGenericName() {
for(String name:Arrays.asList(null,""," ")) {
profile.setRealName(name);em.flush();
assertEquals("候选人昵称",messaging.getConversation(conversation.getId(),employer.getId()).get("contactName"));
}
em.remove(profile);em.flush();
assertEquals("候选人昵称",messaging.getConversation(conversation.getId(),employer.getId()).get("contactName"));
student.setNickname(" ");em.flush();
assertEquals("求职者",messaging.getConversation(conversation.getId(),employer.getId()).get("contactName"));
}
@Test void companyDisplayAlwaysUsesCompanyNameAndLogo() {
employer.setAvatarUrl("/hr-avatar");company.setLogoUrl("/company-logo");
em.persist(StudentProfile.builder().userId(employer.getId()).realName("历史学生资料名").build());em.flush();
for(String type:List.of("DIRECT","TALENT_INVITATION")) {
conversation.setType(type);em.flush();
var view=messaging.getConversation(conversation.getId(),student.getId());
assertEquals("测试企业",view.get("contactName"));assertEquals("/company-logo",view.get("avatarUrl"));
}
}
@Test void nonMemberCannotReadNamesAndInvitationStillCannotReadAttachments() {
profile.setRealName("仅成员可见");conversation.setType("TALENT_INVITATION");em.flush();
denied(()->messaging.getConversation(conversation.getId(),stranger.getId()),2002);
denied(()->messaging.assertAttachmentAccess(conversation.getId(),employer.getId()),2002);
}
@Test void invitationAvatarStillRequiresCommunicationAuthorizationAndRejectsWithdrawal() {
conversation.setType("TALENT_INVITATION");
matches.delete(matches.findByApplicationId(application.getId()).orElseThrow());em.flush();
assertThrows(BusinessException.class,()->messaging.assertProfileImageAccess(conversation.getId(),employer.getId()));
application.setCompanyAcceptedAt(java.time.LocalDateTime.now(clock));em.flush();
assertDoesNotThrow(()->messaging.assertProfileImageAccess(conversation.getId(),employer.getId()));
application.setStatus("WITHDRAWN");em.flush();
assertThrows(BusinessException.class,()->messaging.assertProfileImageAccess(conversation.getId(),employer.getId()));
}
@Test void downloadIsBinaryAndNeverPublicOrCached() throws Exception {
var sent=send("http-1");
var token=sessions.createSession(employer,clock.instant(),com.mydreamjob.audit.AuditContext.builder().build()).accessToken();

19
mydreamjob-server/src/test/java/com/mydreamjob/onlineresume/OnlineResumeIntegrationTest.java

@ -60,6 +60,25 @@ class OnlineResumeIntegrationTest {
return mapper.readTree(action.andExpect(status().isOk()).andReturn().getResponse().getContentAsString(StandardCharsets.UTF_8)).path("data");
}
JsonNode read() throws Exception { return data(mvc.perform(get(PATH).header("Authorization", token))); }
@Test void unknownLegacyIndustrySurvivesReadEditSaveRoundTrip() throws Exception {
var c = content(); c.getJobIntention().setPreferredIndustries(List.of("金融", "待确认旧行业"));
data(save(0, c));
var roundTrip = mapper.treeToValue(read().path("content"), OnlineResumeContent.class);
roundTrip.setSelfEvaluation("只改自我评价");
var updated = data(save(1, roundTrip));
assertTrue(updated.path("content").path("jobIntention").path("preferredIndustries").toString().contains("待确认旧行业"));
}
@Test void industryCodesShareRevisionAcrossResumeAndLegacyPreferences() throws Exception {
var c = content(); c.getJobIntention().setPreferredIndustryCodes(List.of("FINANCE_BANKING"));
var saved = data(save(0, c));
assertEquals("FINANCE_BANKING", saved.path("content").path("jobIntention").path("preferredIndustryCodes").get(0).asText());
mvc.perform(put("/profile/me/preferences").header("Authorization", token).contentType(MediaType.APPLICATION_JSON)
.content("{\"preferredIndustryCodes\":[\"EDUCATION\"],\"revision\":0}"))
.andExpect(status().isConflict());
var clear = content(); clear.getJobIntention().setPreferredIndustryCodes(List.of());
assertEquals(0, data(save(1, clear)).path("content").path("jobIntention").path("preferredIndustryCodes").size());
assertEquals(List.of(), service.get(user.getId()).content().getJobIntention().getPreferredIndustryCodes());
}
OnlineResumeContent content() {
OnlineResumeContent c = new OnlineResumeContent(); c.getBasicInfo().setRealName("合成测试");
c.getBasicInfo().setPhone("13800000000"); c.getBasicInfo().setEmail("resume@example.test");

28
mydreamjob-server/src/test/java/com/mydreamjob/profileimage/ProfileImageIntegrationTest.java

@ -101,9 +101,31 @@ class ProfileImageIntegrationTest {
mvc.perform(get(path(view)).header("Authorization", "Bearer " + ot)).andExpect(status().isForbidden());
mvc.perform(multipart("/auth/me/avatar").file(image(2, 2))).andExpect(status().isUnauthorized());
}
@Test void invitationCannotRevealCandidateAvatar() throws Exception {
@Test void invitationMembersCanReadAvatarWithoutMakingItPublic() throws Exception {
direct(student, peer, "TALENT_INVITATION");
var view = upload("/auth/me/avatar", st);
mvc.perform(get(path(view)).header("Authorization", "Bearer " + pt)).andExpect(status().isOk());
mvc.perform(get(path(view))).andExpect(status().isUnauthorized());
mvc.perform(get(path(view)).header("Authorization", "Bearer " + ot)).andExpect(status().isForbidden());
}
@Test void profileNameSaveImmediatelyChangesBothConversationResponses() throws Exception {
long cid=direct(student,employer,"DIRECT");
for(String type:List.of("DIRECT","TALENT_INVITATION")) {
var conversation=conversations.findById(cid).orElseThrow();conversation.setType(type);conversations.saveAndFlush(conversation);
for(String name:List.of("修改姓名一","修改姓名二")) {
mvc.perform(put("/profile/me").header("Authorization","Bearer "+st).contentType("application/json")
.content(json.writeValueAsString(Map.of("realName",name)))).andExpect(status().isOk());
mvc.perform(get("/conversations/"+cid).header("Authorization","Bearer "+et))
.andExpect(status().isOk()).andExpect(jsonPath("$.data.contactName").value(name));
mvc.perform(get("/conversations").header("Authorization","Bearer "+et))
.andExpect(status().isOk()).andExpect(jsonPath("$.data[0].contactName").value(name));
}
}
assertEquals("头像测试",users.findById(student.getId()).orElseThrow().getNickname());
}
@Test void invitationAvatarStillHonorsBlocking() throws Exception {
direct(student,peer,"TALENT_INVITATION"); var view=upload("/auth/me/avatar",st);
mvc.perform(put("/blocks/"+student.getId()).header("Authorization","Bearer "+pt)).andExpect(status().isOk());
mvc.perform(get(path(view)).header("Authorization","Bearer "+pt)).andExpect(status().isForbidden());
}
@Test void blockRevokesExistingAvatarUrl() throws Exception {
@ -236,11 +258,11 @@ class ProfileImageIntegrationTest {
var response = mvc.perform(get("/v3/api-docs")).andExpect(status().isOk()).andReturn().getResponse();
var runtime = json.readTree(response.getContentAsByteArray()).path("paths");
var contract = json.readTree(java.nio.file.Files.readString(java.nio.file.Path.of("../docs/openapi-rc.json"))).path("paths");
assertEquals(221, runtime.size());
assertEquals(222, runtime.size());
Set<String> expected = new TreeSet<>(), actual = new TreeSet<>();
contract.fields().forEachRemaining(p -> p.getValue().fieldNames().forEachRemaining(m -> expected.add(m + " " + p.getKey())));
runtime.fields().forEachRemaining(p -> p.getValue().fieldNames().forEachRemaining(m -> actual.add(m + " " + p.getKey())));
assertEquals(271, actual.size()); assertEquals(expected, actual);
assertEquals(272, actual.size()); assertEquals(expected, actual);
assertEquals("uploadAvatar", runtime.path("/auth/me/avatar").path("post").path("operationId").asText());
for (String route : List.of("/auth/me/avatar", "/companies/me/logo", "/profile-images/{imageId}")) {
contract.path(route).fields().forEachRemaining(method -> {

4
mydreamjob-server/src/test/java/com/mydreamjob/resume/optimization/WorkExperienceOptimizationSecurityIntegrationTest.java

@ -140,8 +140,8 @@ class WorkExperienceOptimizationSecurityIntegrationTest {
var response=http.send(HttpRequest.newBuilder(URI.create("http://127.0.0.1:"+port+"/api/v1/v3/api-docs")).GET().build(),HttpResponse.BodyHandlers.ofString());
assertEquals(200,response.statusCode());
var runtime=mapper.readTree(response.body());var contract=mapper.readTree(Files.readString(Path.of("../docs/openapi-rc.json")));
assertEquals(221,runtime.path("paths").size());
assertEquals(operations(contract),operations(runtime));assertEquals(271,operations(runtime).size());
assertEquals(222,runtime.path("paths").size());
assertEquals(operations(contract),operations(runtime));assertEquals(272,operations(runtime).size());
var path=runtime.path("paths").path("/profile/me/work-experience-optimization").path("post");
assertTrue(path.path("requestBody").path("required").asBoolean());
assertTrue(path.path("security").toString().contains("bearerAuth"));

2
mydreamjob-server/src/test/java/com/mydreamjob/skill/SkillCloudQueryServiceTest.java

@ -65,7 +65,7 @@ class SkillCloudQueryServiceTest {
assertThat(response.industry().id()).isEqualTo(1L);
assertThat(response.industry().code()).isEqualTo("INTERNET");
assertThat(response.industry().name()).isEqualTo("互联网");
assertThat(response.industry().name()).isEqualTo("互联网/信息技术");
assertThat(response.occupation().id()).isEqualTo(1L);
assertThat(response.occupation().code()).isEqualTo("FRONTEND_DEVELOPER");
assertThat(response.occupation().name()).isEqualTo("前端开发工程师");

Loading…
Cancel
Save