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. 30
      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", "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": [], "security": [],
"parameters": [ "parameters": [
{ {
@ -2091,7 +2091,7 @@
"企业" "企业"
], ],
"summary": "查询企业公开详情", "summary": "查询企业公开详情",
"description": "匿名可访问,仅返回 6 个公开字段", "description": "匿名可访问,仅返回8个公开字段,含industryCode和companySize,不含ownerUserId及联系方式",
"operationId": "getPublicCompany", "operationId": "getPublicCompany",
"parameters": [ "parameters": [
{ {
@ -3723,6 +3723,15 @@
"format": "int32", "format": "int32",
"default": 20 "default": 20
} }
},
{
"name": "industryCode",
"in": "query",
"description": "稳定行业编码,逗号多选最多20项;大类包含细分类,取公司行业,与旧industry参数同时提供时AND。完整目录见/jobs/industry-catalog。",
"required": false,
"schema": {
"type": "string"
}
} }
], ],
"responses": { "responses": {
@ -13103,7 +13112,11 @@
"429": { "429": {
"description": "调用额度或并发已满;code429,Retry-After=60;不自动重试", "description": "调用额度或并发已满;code429,Retry-After=60;不自动重试",
"headers": { "headers": {
"Retry-After": { "schema": { "type": "integer" } } "Retry-After": {
"schema": {
"type": "integer"
}
}
} }
}, },
"503": { "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": { "components": {
@ -13426,37 +13472,105 @@
"NotificationListResponseDoc": { "NotificationListResponseDoc": {
"type": "object", "type": "object",
"properties": { "properties": {
"code": {"type": "integer", "format": "int32"}, "code": {
"message": {"type": "string"}, "type": "integer",
"data": {"$ref": "#/components/schemas/NotificationPageDoc"}, "format": "int32"
"requestId": {"type": "string"}, },
"timestamp": {"type": "string", "format": "date-time"} "message": {
"type": "string"
},
"data": {
"$ref": "#/components/schemas/NotificationPageDoc"
},
"requestId": {
"type": "string"
},
"timestamp": {
"type": "string",
"format": "date-time"
}
} }
}, },
"NotificationPageDoc": { "NotificationPageDoc": {
"type": "object", "type": "object",
"properties": { "properties": {
"items": {"type": "array", "items": {"$ref": "#/components/schemas/NotificationItemDoc"}}, "items": {
"total": {"type": "integer", "format": "int64"}, "type": "array",
"page": {"type": "integer", "format": "int32"}, "items": {
"size": {"type": "integer", "format": "int32"}, "$ref": "#/components/schemas/NotificationItemDoc"
"totalPages": {"type": "integer", "format": "int32"} }
},
"total": {
"type": "integer",
"format": "int64"
},
"page": {
"type": "integer",
"format": "int32"
},
"size": {
"type": "integer",
"format": "int32"
},
"totalPages": {
"type": "integer",
"format": "int32"
}
} }
}, },
"NotificationItemDoc": { "NotificationItemDoc": {
"type": "object", "type": "object",
"description": "通知展示与业务关联;未知类型/空关联不推断目标,详情仍受各业务接口权限校验", "description": "通知展示与业务关联;未知类型/空关联不推断目标,详情仍受各业务接口权限校验",
"required": ["businessType", "businessId"], "required": [
"businessType",
"businessId"
],
"properties": { "properties": {
"id": {"type": "integer", "format": "int64"}, "id": {
"type": {"type": "string"}, "type": "integer",
"title": {"type": "string"}, "format": "int64"
"content": {"type": ["string", "null"]}, },
"read": {"type": "boolean"}, "type": {
"readAt": {"type": ["string", "null"], "format": "date-time"}, "type": "string"
"createdAt": {"type": "string", "format": "date-time"}, },
"businessType": {"type": ["string", "null"], "description": "持久化业务类型,非封闭枚举;历史或系统通知可为 null。已核查 APPLICATION/INTERVIEW/OFFER/MATCH/VERIFICATION/REPORT"}, "title": {
"businessId": {"type": ["integer", "null"], "format": "int64", "description": "持久化关联业务记录ID,不是通知ID;APPLICATION 和 MATCH 均为投递ID,INTERVIEW 为面试ID,OFFER 为Offer ID;无关联时 null"} "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": { "ProfileImageView": {
@ -15972,7 +16086,8 @@
"type": "string" "type": "string"
}, },
"industry": { "industry": {
"type": "string" "type": "string",
"maxLength": 80
}, },
"website": { "website": {
"type": "string" "type": "string"
@ -15988,6 +16103,11 @@
"description": "企业规模,≤40 字符。缺省/null:创建存 NULL;空串或纯空白清空为 NULL;非空去两端空白后保存;超过 40 字符拒绝且不截断。", "description": "企业规模,≤40 字符。缺省/null:创建存 NULL;空串或纯空白清空为 NULL;非空去两端空白后保存;超过 40 字符拒绝且不截断。",
"maxLength": 40, "maxLength": 40,
"minLength": 0 "minLength": 0
},
"industryCode": {
"type": "string",
"maxLength": 64,
"minLength": 0
} }
}, },
"required": [ "required": [
@ -16051,8 +16171,14 @@
], ],
"description": "企业规模,≤40 字符", "description": "企业规模,≤40 字符",
"maxLength": 40 "maxLength": 40
},
"industryCode": {
"type": "string",
"description": "稳定招聘行业编码;无法确定映射的旧名称为空",
"maxLength": 64
} }
} },
"description": "企业公开摘要;严格 8 字段,含 industryCode,不含 ownerUserId、联系方式、登记地址与审核信息"
}, },
"CompanyScoreItem": { "CompanyScoreItem": {
"type": "object", "type": "object",
@ -16112,6 +16238,11 @@
"description": "企业规模,≤40 字符。缺省/null:更新保持原值;空串或纯空白清空为 NULL;非空去两端空白后保存;超过 40 字符拒绝且不截断。", "description": "企业规模,≤40 字符。缺省/null:更新保持原值;空串或纯空白清空为 NULL;非空去两端空白后保存;超过 40 字符拒绝且不截断。",
"maxLength": 40, "maxLength": 40,
"minLength": 0 "minLength": 0
},
"industryCode": {
"type": "string",
"maxLength": 64,
"minLength": 0
} }
}, },
"required": [ "required": [
@ -17385,6 +17516,16 @@
}, },
"salaryRangeValid": { "salaryRangeValid": {
"type": "boolean" "type": "boolean"
},
"preferredIndustryCodes": {
"type": "array",
"items": {
"type": "string",
"maxLength": 64,
"minLength": 0
},
"maxItems": 20,
"minItems": 0
} }
} }
}, },
@ -20653,6 +20794,11 @@
"type": "string", "type": "string",
"maxLength": 255, "maxLength": 255,
"minLength": 0 "minLength": 0
},
"industryCode": {
"type": "string",
"maxLength": 64,
"minLength": 0
} }
}, },
"required": [ "required": [
@ -20814,6 +20960,9 @@
}, },
"verified": { "verified": {
"type": "boolean" "type": "boolean"
},
"industryCode": {
"type": "string"
} }
} }
}, },
@ -22188,7 +22337,7 @@
}, },
"JobDocCompanySummary": { "JobDocCompanySummary": {
"type": "object", "type": "object",
"description": "公开企业摘要;严格 7 字段,不含 ownerUserId、联系方式、地址与审核信息", "description": "公开企业摘要;严格 8 字段,含 industryCode,不含 ownerUserId、联系方式、地址与审核信息",
"properties": { "properties": {
"id": { "id": {
"type": "integer", "type": "integer",
@ -22219,6 +22368,11 @@
"type": "string", "type": "string",
"description": "企业规模,≤40 字符;缺省时为空", "description": "企业规模,≤40 字符;缺省时为空",
"maxLength": 40 "maxLength": 40
},
"industryCode": {
"type": "string",
"description": "企业行业稳定编码;无法映射的历史值为空",
"maxLength": 64
} }
} }
}, },
@ -22774,6 +22928,16 @@
"availableFrom": { "availableFrom": {
"type": "string", "type": "string",
"format": "date" "format": "date"
},
"preferredIndustryCodes": {
"type": "array",
"items": {
"type": "string",
"maxLength": 64,
"minLength": 0
},
"maxItems": 20,
"minItems": 0
} }
}, },
"required": [ "required": [
@ -24717,6 +24881,9 @@
}, },
"verified": { "verified": {
"type": "boolean" "type": "boolean"
},
"industryCode": {
"type": "string"
} }
} }
}, },
@ -25954,6 +26121,77 @@
"type": "string" "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": { "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')) d = json.load(open(p, encoding='utf-8'))
print('paths:', len(d['paths'])) 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'] paths = d['paths']
@ -743,7 +743,7 @@ print(' S03 company resume download contract validated; candidate DTOs remain r
# 统计 operation 总数并校验当前接口契约基线。 # 统计 operation 总数并校验当前接口契约基线。
operations = sum(len(methods) for methods in d['paths'].values()) operations = sum(len(methods) for methods in d['paths'].values())
print('operations:', operations) print('operations:', operations)
assert operations == 271, f"Expected 271 operations, got {operations}" assert operations == 272, f"Expected 272 operations, got {operations}"
# 个人头像/企业 Logo 独立写入;文件上传不接受客户端 userId 或 URL。 # 个人头像/企业 Logo 独立写入;文件上传不接受客户端 userId 或 URL。
for _route, _upload, _delete in ( for _route, _upload, _delete in (
@ -903,12 +903,12 @@ print(' CompanyManagedJobItem 展示字段已验证')
_public_company_props = d['components']['schemas']['CompanyPublicView']['properties'] _public_company_props = d['components']['schemas']['CompanyPublicView']['properties']
assert set(_public_company_props) == { assert set(_public_company_props) == {
'id', 'name', 'logoUrl', 'description', 'industry', 'verified', 'companySize'}, \ 'id', 'name', 'logoUrl', 'description', 'industry', 'industryCode', 'verified', 'companySize'}, \
f'CompanyPublicView 必须严格为 7 字段白名单,实际={sorted(_public_company_props)}' f'CompanyPublicView 必须严格为 8 字段白名单,实际={sorted(_public_company_props)}'
for _forbidden in ('ownerUserId', 'address', 'website', 'phone', 'email', 'createdAt', for _forbidden in ('ownerUserId', 'address', 'website', 'phone', 'email', 'createdAt',
'materialsJson', 'decisionReason', 'reviewerId'): 'materialsJson', 'decisionReason', 'reviewerId'):
assert _forbidden not in _public_company_props, f'CompanyPublicView 不得包含 {_forbidden}' 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'), for _pt, _method in [('/jobs', 'get'), ('/jobs', 'post'), ('/jobs/search', 'get'),
('/jobs/public/{jobId}', 'get'), ('/jobs/{jobId}', '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 _trash_item['properties']['deletedAt']['format'] == 'date-time'
assert 'deletedAt' in _trash_item['required'] assert 'deletedAt' in _trash_item['required']
print(' Notification recycle bin: five authenticated operations, bounded batch IDs, deletion time; no permanent deletion') 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("Valid JSON: True")
print("All checks passed!") 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; private String description;
@Size(max = 80) @Size(max = 80)
private String industry; private String industry;
@jakarta.validation.constraints.Size(max = 64)
private String industryCode;
@Size(max = 255) @Size(max = 255)
private String website; private String website;
@Size(max = 255) @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); user = userRepository.saveAndFlush(user);
CompanyRegistrationRequest.CompanyDetails d = request.getCompany(); CompanyRegistrationRequest.CompanyDetails d = request.getCompany();
var industry = com.mydreamjob.industry.IndustryCatalog.selection(d.getIndustryCode(), d.getIndustry());
Company company = Company.builder() Company company = Company.builder()
.ownerUserId(user.getId()) .ownerUserId(user.getId())
.name(d.getName()) .name(d.getName())
.logoUrl(d.getLogoUrl()) .logoUrl(d.getLogoUrl())
.description(d.getDescription()) .description(d.getDescription())
.industry(d.getIndustry()) .industry(industry.name()).industryCode(industry.code())
.website(d.getWebsite()) .website(d.getWebsite())
.address(d.getAddress()) .address(d.getAddress())
.verified(false) .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/set", "/auth/password/setup", "/auth/password/change", "/auth/login", "/auth/password/login",
"/auth/password/reset", "/auth/refresh", "/auth/sms/send", "/auth/company/register"); "/auth/password/reset", "/auth/refresh", "/auth/sms/send", "/auth/company/register");
private static final Set<String> PUBLIC_GET = Set.of("/", "/health", "/health/**", "/jobs", 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", "/jobs/*/reviews/*", "/jobs/*/reviews/summary", "/companies/*", "/companies/*/jobs",
"/job-recommendation-taxonomies", "/profile-images/*", "/swagger-ui/**", "/v3/api-docs/**", "/api-docs/**"); "/job-recommendation-taxonomies", "/profile-images/*", "/swagger-ui/**", "/v3/api-docs/**", "/api-docs/**");
private final UserRepository users; 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()) { if (target == User.Role.COMPANY && !properties.isEnabled()) {
throw new BusinessException(ErrorCode.COMPANY_REGISTRATION_DISABLED); 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.setRole(target);
user.setRoles(new HashSet<>(Set.of(target))); user.setRoles(new HashSet<>(Set.of(target)));
users.saveAndFlush(user); users.saveAndFlush(user);
@ -77,7 +80,7 @@ public class IdentitySelectionService {
if (target == User.Role.COMPANY) { if (target == User.Role.COMPANY) {
CompanyDetails d = request.getCompany(); CompanyDetails d = request.getCompany();
company = companies.saveAndFlush(Company.builder().ownerUserId(userId).name(d.getName()) 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()); .website(d.getWebsite()).address(d.getAddress()).verified(false).build());
} }
// modifying 会清理 persistence context;上面的角色与资料已 flush,锁仍持有。 // 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) @Column(name = "industry", length = 80)
private String industry; 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) @Column(name = "company_size", length = 40)
private String companySize; 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}") @GetMapping("/{companyId}")
public ResponseEntity<ApiResponse<CompanyPublicView>> getPublicCompany( public ResponseEntity<ApiResponse<CompanyPublicView>> getPublicCompany(
@PathVariable Long companyId, @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, public record CompanySummary(Long id, String name, String logoUrl, String description,
String industry, String website, String address, Boolean verified) { 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, 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()) { if (companyRepository.findByOwnerUserId(userId).isPresent()) {
throw new BusinessException(ErrorCode.COMPANY_ALREADY_EXISTS); throw new BusinessException(ErrorCode.COMPANY_ALREADY_EXISTS);
} }
var industry = com.mydreamjob.industry.IndustryCatalog.selection(req.getIndustryCode(), req.getIndustry());
Company company = Company.builder() Company company = Company.builder()
.ownerUserId(userId) .ownerUserId(userId)
.name(req.getName()) .name(req.getName())
.logoUrl(req.getLogoUrl()) .logoUrl(req.getLogoUrl())
.description(req.getDescription()) .description(req.getDescription())
.industry(req.getIndustry()) .industry(industry.name()).industryCode(industry.code())
.companySize(normalizeCompanySize(req.getCompanySize())) .companySize(normalizeCompanySize(req.getCompanySize()))
.website(req.getWebsite()) .website(req.getWebsite())
.address(req.getAddress()) .address(req.getAddress())
@ -92,10 +93,14 @@ public class CompanyService {
Company company = companyRepository.findByOwnerUserIdForUpdate(userId) Company company = companyRepository.findByOwnerUserIdForUpdate(userId)
.orElseThrow(() -> new BusinessException(ErrorCode.COMPANY_NOT_FOUND)); .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.getName() != null) company.setName(req.getName());
if (req.getLogoUrl() != null) company.setLogoUrl(req.getLogoUrl()); if (req.getLogoUrl() != null) company.setLogoUrl(req.getLogoUrl());
if (req.getDescription() != null) company.setDescription(req.getDescription()); 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/缺省保持原值;其余交给与创建共用的同一套归一化规则。 // companySize:null/缺省保持原值;其余交给与创建共用的同一套归一化规则。
if (req.getCompanySize() != null) { if (req.getCompanySize() != null) {
company.setCompanySize(normalizeCompanySize(req.getCompanySize())); company.setCompanySize(normalizeCompanySize(req.getCompanySize()));
@ -145,6 +150,7 @@ public class CompanyService {
view.put("logoUrl", company.getLogoUrl()); view.put("logoUrl", company.getLogoUrl());
view.put("description", company.getDescription()); view.put("description", company.getDescription());
view.put("industry", company.getIndustry()); view.put("industry", company.getIndustry());
view.put("industryCode", company.getIndustryCode());
view.put("companySize", company.getCompanySize()); view.put("companySize", company.getCompanySize());
view.put("website", company.getWebsite()); view.put("website", company.getWebsite());
view.put("address", company.getAddress()); view.put("address", company.getAddress());
@ -176,6 +182,7 @@ public class CompanyService {
.logoUrl(company.getLogoUrl()) .logoUrl(company.getLogoUrl())
.description(company.getDescription()) .description(company.getDescription())
.industry(company.getIndustry()) .industry(company.getIndustry())
.industryCode(company.getIndustryCode())
.companySize(company.getCompanySize()) .companySize(company.getCompanySize())
.verified(company.getVerified()) .verified(company.getVerified())
.build(); .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 logoUrl;
private String description; private String description;
@jakarta.validation.constraints.Size(max = 80)
private String industry; private String industry;
@jakarta.validation.constraints.Size(max = 64)
private String industryCode;
@Size(max = 40, message = "company size must be at most 40 characters") @Size(max = 40, message = "company size must be at most 40 characters")
@Schema(types = {"string", "null"}, maxLength = 40, @Schema(types = {"string", "null"}, maxLength = 40,
description = "企业规模,≤40 字符。缺省/null:创建存 NULL;空串或纯空白清空为 NULL;" 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。 * 企业公开信息 DTO。
* 仅暴露 7 个公开字段,不泄露 ownerUserId、联系方式、审核信息等。 * 仅暴露 8 个公开字段,不泄露 ownerUserId、联系方式、审核信息等。
*/ */
@Data @Data
@Builder @Builder
@ -17,7 +17,7 @@ import lombok.NoArgsConstructor;
@AllArgsConstructor @AllArgsConstructor
@JsonInclude(JsonInclude.Include.NON_NULL) @JsonInclude(JsonInclude.Include.NON_NULL)
@Schema(name = "CompanyPublicView", @Schema(name = "CompanyPublicView",
description = "企业公开摘要;严格 7 字段,不含 ownerUserId、联系方式、登记地址与审核信息") description = "企业公开摘要;严格 8 字段,含 industryCode,不含 ownerUserId、联系方式、登记地址与审核信息")
public class CompanyPublicView { public class CompanyPublicView {
@Schema(description = "企业 ID") @Schema(description = "企业 ID")
@ -35,6 +35,9 @@ public class CompanyPublicView {
@Schema(description = "所属行业") @Schema(description = "所属行业")
private String industry; private String industry;
@Schema(description = "稳定招聘行业编码;无法确定映射的旧名称为空", maxLength = 64)
private String industryCode;
@Schema(types = {"string", "null"}, maxLength = 40, description = "企业规模,≤40 字符") @Schema(types = {"string", "null"}, maxLength = 40, description = "企业规模,≤40 字符")
private String companySize; 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") @Size(max = 80, message = "industry must be at most 80 characters")
private String industry; private String industry;
@jakarta.validation.constraints.Size(max = 64)
private String industryCode;
@Size(max = 40, message = "company size must be at most 40 characters") @Size(max = 40, message = "company size must be at most 40 characters")
@Schema(types = {"string", "null"}, maxLength = 40, @Schema(types = {"string", "null"}, maxLength = 40,
description = "企业规模,≤40 字符。缺省/null:更新保持原值;空串或纯空白清空为 NULL;" 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/search").permitAll()
.requestMatchers(HttpMethod.GET, "/jobs/filters").permitAll() .requestMatchers(HttpMethod.GET, "/jobs/filters").permitAll()
.requestMatchers(HttpMethod.GET, "/jobs/industry-catalog").permitAll()
// 岗位管理(需登录) // 岗位管理(需登录)
.requestMatchers("/jobs/**").authenticated() .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("preferredCities", safeJson(sp.getPreferredCities()));
m.put("employmentTypes", safeJson(sp.getEmploymentTypes())); m.put("employmentTypes", safeJson(sp.getEmploymentTypes()));
m.put("preferredIndustries", safeJson(sp.getPreferredIndustries())); m.put("preferredIndustries", safeJson(sp.getPreferredIndustries()));
m.put("preferredIndustryCodes", safeJson(sp.getPreferredIndustryCodes()));
m.put("preferredJobTypes", safeJson(sp.getPreferredJobTypes())); m.put("preferredJobTypes", safeJson(sp.getPreferredJobTypes()));
m.put("remoteAllowed", sp.getRemoteAllowed()); m.put("remoteAllowed", sp.getRemoteAllowed());
m.put("createdAt", sp.getCreatedAt()); 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, @RequestParam(required = false) Long companyId,
@Parameter(description = "行业标签精确匹配,取公司 industry;逗号分隔多选,同维度 OR、跨维度 AND。选项见 /jobs/filters 的 industries;最多20个去重值、每项80字符,空值不筛选,非法格式400,未知值无匹配。") @Parameter(description = "行业标签精确匹配,取公司 industry;逗号分隔多选,同维度 OR、跨维度 AND。选项见 /jobs/filters 的 industries;最多20个去重值、每项80字符,空值不筛选,非法格式400,未知值无匹配。")
@RequestParam(required = false) String industry, @RequestParam(required = false) String industry,
@Parameter(description = "稳定行业编码,逗号多选最多20项;大类包含细分类,取公司行业,与旧industry参数同时提供时AND。完整目录见/jobs/industry-catalog。")
@RequestParam(required = false) String industryCode,
@Parameter(description = "经验标签精确匹配,不做年限推断;逗号分隔多选,选项见 experiences;最多20个去重值、每项40字符,空值不筛选,非法格式400。") @Parameter(description = "经验标签精确匹配,不做年限推断;逗号分隔多选,选项见 experiences;最多20个去重值、每项40字符,空值不筛选,非法格式400。")
@RequestParam(required = false) String experience, @RequestParam(required = false) String experience,
@Parameter(description = "学历标签精确匹配,不做学历高低推断;逗号分隔多选,选项见 educations;最多20个去重值、每项40字符,空值不筛选,非法格式400。") @Parameter(description = "学历标签精确匹配,不做学历高低推断;逗号分隔多选,选项见 educations;最多20个去重值、每项40字符,空值不筛选,非法格式400。")
@ -172,7 +174,7 @@ public class JobController {
HttpServletRequest request) { HttpServletRequest request) {
Page<JobSearchItem> result = jobService.searchJobs( Page<JobSearchItem> result = jobService.searchJobs(
keyword, city, employmentType, salaryMin, salaryMax, companyId, 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<>(); Map<String, Object> data = new HashMap<>();
data.put("items", result.getContent()); data.put("items", result.getContent());
data.put("total", result.getTotalElements()); 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 // 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, Integer salaryMin, Integer salaryMax, Long companyId,
String sort, int page, int size, String sort, int page, int size,
String industry, String experience, String education) { 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) { if (salaryMin != null && salaryMax != null && salaryMin > salaryMax) {
throw new BusinessException(ErrorCode.BAD_REQUEST); throw new BusinessException(ErrorCode.BAD_REQUEST);
@ -426,7 +434,8 @@ public class JobService {
} }
var spec = JobSearchSpecification.searchJobs(keyword, city, employmentType, 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; Page<Job> jobPage;
if ("salary".equals(sort)) { if ("salary".equals(sort)) {
@ -524,6 +533,7 @@ public class JobService {
.logoUrl(company.getLogoUrl()) .logoUrl(company.getLogoUrl())
.description(company.getDescription()) .description(company.getDescription())
.industry(company.getIndustry()) .industry(company.getIndustry())
.industryCode(company.getIndustryCode())
.verified(company.getVerified()) .verified(company.getVerified())
.companySize(company.getCompanySize()) .companySize(company.getCompanySize())
.build(); .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; import lombok.Data;
/** /**
* 仅供 OpenAPI 文档使用的公开企业摘要模型(严格 7 字段白名单)。 * 仅供 OpenAPI 文档使用的公开企业摘要模型(严格 8 字段白名单)。
* *
* <p>不参与任何序列化/反序列化:仅通过 {@code @Schema(implementation = ...)} 绑定到 * <p>不参与任何序列化/反序列化:仅通过 {@code @Schema(implementation = ...)} 绑定到
* 岗位相关 Map 响应的文档结构,实际返回类型不变。不得暴露 ownerUserId、联系方式、 * 岗位相关 Map 响应的文档结构,实际返回类型不变。不得暴露 ownerUserId、联系方式、
@ -12,7 +12,7 @@ import lombok.Data;
*/ */
@Data @Data
@Schema(name = "JobDocCompanySummary", @Schema(name = "JobDocCompanySummary",
description = "公开企业摘要;严格 7 字段,不含 ownerUserId、联系方式、地址与审核信息") description = "公开企业摘要;严格 8 字段,含 industryCode,不含 ownerUserId、联系方式、地址与审核信息")
public class JobDocCompanySummary { public class JobDocCompanySummary {
@Schema(description = "企业 ID") @Schema(description = "企业 ID")
@ -30,6 +30,9 @@ public class JobDocCompanySummary {
@Schema(description = "所属行业") @Schema(description = "所属行业")
private String industry; private String industry;
@Schema(description = "企业行业稳定编码;无法映射的历史值为空", maxLength = 64)
private String industryCode;
@Schema(description = "是否已认证") @Schema(description = "是否已认证")
private Boolean verified; 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 logoUrl;
private String description; private String description;
private String industry; private String industry;
private String industryCode;
private Boolean verified; private Boolean verified;
private String companySize; 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 StudentProfileRepository profileRepository;
private final AuditService auditService; private final AuditService auditService;
private final UserRepository userRepository; private final UserRepository userRepository;
private final com.mydreamjob.company.CompanyRepository companyRepository;
private static final ObjectMapper mapper = new ObjectMapper(); private static final ObjectMapper mapper = new ObjectMapper();
private static final String RULES_VERSION = "RULES_V0_1"; private static final String RULES_VERSION = "RULES_V0_1";
@ -95,14 +96,15 @@ public class MatchService {
} }
// 规则4:行业偏好匹配(若有设置) // 规则4:行业偏好匹配(若有设置)
if (preference != null && preference.getPreferredIndustries() != null if (preference != null) {
&& !preference.getPreferredIndustries().isBlank()) { var industries = com.mydreamjob.industry.IndustryCatalog.scoringCriteria(
// 行业字段:job 没有 industry,用 description 关键词近似 preference.getPreferredIndustryCodes() == null ? null : industryList(preference.getPreferredIndustryCodes()),
String desc = job.getDescription() != null ? job.getDescription().toLowerCase() : ""; industryList(preference.getPreferredIndustries()));
boolean industryMatch = preference.getPreferredIndustries().toLowerCase() if (!industries.isEmpty() && job.getCompanyId() != null) {
.contains(desc.substring(0, Math.min(30, desc.length()))); String industryCode = companyRepository.findById(job.getCompanyId())
// 简化:行业偏好为参考项,不参与硬性 .map(com.mydreamjob.company.Company::getIndustryCode).orElse(null);
if (industryMatch) { score += 10; matched.add("行业偏好匹配"); } if (industryCode != null && industries.contains(industryCode)) { score += 10; matched.add("行业偏好匹配"); }
}
} }
// 规则5:岗位类型偏好(参考项) // 规则5:岗位类型偏好(参考项)
@ -163,4 +165,14 @@ public class MatchService {
return jsonArray.toLowerCase().contains(target.toLowerCase()); 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 lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
/** 对已完成成员鉴权的会话批量补展示摘要;不查询手机号、实名、简历或虚构在线状态。 */ /** 对已完成成员鉴权的会话批量补展示摘要;非匿名沟通使用资料姓名,不查询联系方式或简历正文。 */
@Service @RequiredArgsConstructor @Service @RequiredArgsConstructor
public class ConversationSummaryService { public class ConversationSummaryService {
@PersistenceContext private EntityManager em; @PersistenceContext private EntityManager em;
@ -15,9 +15,10 @@ public class ConversationSummaryService {
List<Long> ids = views.stream().map(v -> (Long)v.get("id")).toList(); List<Long> ids = views.stream().map(v -> (Long)v.get("id")).toList();
Map<Long,List<Object[]>> contacts = new HashMap<>(); Map<Long,List<Object[]>> contacts = new HashMap<>();
em.createQuery(""" 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 from ConversationMember m join User u on u.id = m.userId
left join Company c on c.ownerUserId = u.id 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 where m.conversationId in :ids and m.userId <> :userId
order by m.conversationId, m.id order by m.conversationId, m.id
""",Object[].class).setParameter("ids",ids).setParameter("userId",userId) """,Object[].class).setParameter("ids",ids).setParameter("userId",userId)
@ -31,15 +32,14 @@ public class ConversationSummaryService {
""",Object[].class).setParameter("ids",ids).getResultList() """,Object[].class).setParameter("ids",ids).getResultList()
.forEach(r -> last.put((Long)r[0],r)); .forEach(r -> last.put((Long)r[0],r));
for (Map<String,Object> view : views) { for (Map<String,Object> view : views) {
boolean invitation = "TALENT_INVITATION".equals(view.get("type"));
var peers = contacts.getOrDefault(view.get("id"),List.of()); var peers = contacts.getOrDefault(view.get("id"),List.of());
String name = "会话成员"; String name = "会话成员";
if (peers.size() == 1) { if (peers.size() == 1) {
Object[] peer = peers.get(0); 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]); 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); if (avatar != null) view.put("avatarUrl",avatar);
} }
view.put("contactName",name); view.put("contactName",name);
@ -55,6 +55,6 @@ public class ConversationSummaryService {
} }
} }
private String text(Object value,String fallback) { 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); getConversation(conversationId, userId);
if (!"DIRECT".equals(requireConversation(conversationId).getType())) if (!"DIRECT".equals(requireConversation(conversationId).getType()))
throw new BusinessException(ErrorCode.FORBIDDEN, "attachments require a DIRECT conversation"); 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)) { for (ConversationMember member : memberRepository.findByConversationId(conversationId)) {
if (!member.getUserId().equals(userId)) { if (!member.getUserId().equals(userId)) {
assertNotBlockedBetween(userId, member.getUserId()); 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()) .logoUrl(company.getLogoUrl())
.description(company.getDescription()) .description(company.getDescription())
.industry(company.getIndustry()) .industry(company.getIndustry())
.industryCode(company.getIndustryCode())
.website(company.getWebsite()) .website(company.getWebsite())
.address(company.getAddress()) .address(company.getAddress())
.verified(company.getVerified()) .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 logoUrl;
private String description; private String description;
private String industry; private String industry;
private String industryCode;
private String website; private String website;
private String address; private String address;
private Boolean verified; 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> 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> employmentTypes = new ArrayList<>();
@NotNull @Size(max = 20) private List<@NotBlank @Size(max = 100) String> preferredIndustries = 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<>(); @NotNull @Size(max = 20) private List<@NotBlank @Size(max = 100) String> preferredJobTypes = new ArrayList<>();
private boolean remoteAllowed; private boolean remoteAllowed;
private JobSeekingStatus jobSeekingStatus; 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) { private OnlineResumeView persist(User user, OnlineResume row, OnlineResumeContent content, OnlineResumeContent before) {
content = copy(content); content = copy(content);
validate(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<>(); Set<String> skillNames = new HashSet<>();
for (var skill : content.getSkills()) { for (var skill : content.getSkills()) {
skill.setName(skill.getName().trim()); skill.setName(skill.getName().trim());
@ -126,6 +132,7 @@ public class OnlineResumeService {
pref.setExpectedSalaryMin(intention.getExpectedSalaryMin()); pref.setExpectedSalaryMax(intention.getExpectedSalaryMax()); pref.setExpectedSalaryMin(intention.getExpectedSalaryMin()); pref.setExpectedSalaryMax(intention.getExpectedSalaryMax());
pref.setPreferredCities(encode(intention.getPreferredCities())); pref.setEmploymentTypes(encode(intention.getEmploymentTypes())); pref.setPreferredCities(encode(intention.getPreferredCities())); pref.setEmploymentTypes(encode(intention.getEmploymentTypes()));
pref.setPreferredIndustries(encode(intention.getPreferredIndustries())); pref.setPreferredJobTypes(encode(intention.getPreferredJobTypes())); pref.setPreferredIndustries(encode(intention.getPreferredIndustries())); pref.setPreferredJobTypes(encode(intention.getPreferredJobTypes()));
pref.setPreferredIndustryCodes(encode(intention.getPreferredIndustryCodes()));
pref.setRemoteAllowed(intention.isRemoteAllowed()); preferences.save(pref); pref.setRemoteAllowed(intention.isRemoteAllowed()); preferences.save(pref);
if (row == null) { row = new OnlineResume(); row.setUserId(uid); } if (row == null) { row = new OnlineResume(); row.setUserId(uid); }
row.setContentJson(json); row.setRevision(Math.addExact(row.getRevision(), 1)); 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.setExpectedSalaryMin(pref.getExpectedSalaryMin()); intention.setExpectedSalaryMax(pref.getExpectedSalaryMax());
intention.setPreferredCities(legacySkills(pref.getPreferredCities())); intention.setEmploymentTypes(legacySkills(pref.getEmploymentTypes())); intention.setPreferredCities(legacySkills(pref.getPreferredCities())); intention.setEmploymentTypes(legacySkills(pref.getEmploymentTypes()));
intention.setPreferredIndustries(legacySkills(pref.getPreferredIndustries())); intention.setPreferredJobTypes(legacySkills(pref.getPreferredJobTypes())); 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())); intention.setRemoteAllowed(Boolean.TRUE.equals(pref.getRemoteAllowed()));
}); });
OnlineResumeView.Attachment attachment = p == null || p.getResumeUrl() == null ? null : 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"); 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.getExpectedSalaryMin() != null) pref.setExpectedSalaryMin(req.getExpectedSalaryMin());
if (req.getExpectedSalaryMax() != null) pref.setExpectedSalaryMax(req.getExpectedSalaryMax()); if (req.getExpectedSalaryMax() != null) pref.setExpectedSalaryMax(req.getExpectedSalaryMax());
if (req.getPreferredCities() != null) pref.setPreferredCities(toJson(req.getPreferredCities())); if (req.getPreferredCities() != null) pref.setPreferredCities(toJson(req.getPreferredCities()));
if (req.getEmploymentTypes() != null) pref.setEmploymentTypes(toJson(req.getEmploymentTypes())); if (req.getEmploymentTypes() != null) pref.setEmploymentTypes(toJson(req.getEmploymentTypes()));
if (req.getPreferredIndustries() != null) pref.setPreferredIndustries(toJson(req.getPreferredIndustries())); 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.getPreferredJobTypes() != null) pref.setPreferredJobTypes(toJson(req.getPreferredJobTypes()));
if (req.getRemoteAllowed() != null) pref.setRemoteAllowed(req.getRemoteAllowed()); if (req.getRemoteAllowed() != null) pref.setRemoteAllowed(req.getRemoteAllowed());
@ -97,6 +103,11 @@ public class PreferenceService {
view.put("preferredCities", fromJson(pref.getPreferredCities())); view.put("preferredCities", fromJson(pref.getPreferredCities()));
view.put("employmentTypes", fromJson(pref.getEmploymentTypes())); view.put("employmentTypes", fromJson(pref.getEmploymentTypes()));
view.put("preferredIndustries", fromJson(pref.getPreferredIndustries())); 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("preferredJobTypes", fromJson(pref.getPreferredJobTypes()));
view.put("remoteAllowed", pref.getRemoteAllowed()); view.put("remoteAllowed", pref.getRemoteAllowed());
view.put("createdAt", pref.getCreatedAt()); view.put("createdAt", pref.getCreatedAt());
@ -111,6 +122,8 @@ public class PreferenceService {
view.put("preferredCities", List.of()); view.put("preferredCities", List.of());
view.put("employmentTypes", List.of()); view.put("employmentTypes", List.of());
view.put("preferredIndustries", List.of()); view.put("preferredIndustries", List.of());
view.put("preferredIndustryCodes", List.of());
view.put("unmappedPreferredIndustries", List.of());
view.put("preferredJobTypes", List.of()); view.put("preferredJobTypes", List.of());
view.put("remoteAllowed", false); view.put("remoteAllowed", false);
return view; return view;
@ -127,7 +140,11 @@ public class PreferenceService {
private List<String> fromJson(String json) { private List<String> fromJson(String json) {
if (json == null || json.isBlank()) return List.of(); if (json == null || json.isBlank()) return List.of();
try { 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) { } catch (JsonProcessingException e) {
return List.of(); 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") @Column(name = "preferred_industries", columnDefinition = "json")
private String preferredIndustries; private String preferredIndustries;
@Column(name = "preferred_industry_codes", columnDefinition = "json")
private String preferredIndustryCodes;
@Column(name = "preferred_job_types", columnDefinition = "json") @Column(name = "preferred_job_types", columnDefinition = "json")
private String preferredJobTypes; 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") @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; 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") @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; 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)); 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 @io.swagger.v3.oas.annotations.security.SecurityRequirements
@GetMapping("/profile-images/{imageId}") @GetMapping("/profile-images/{imageId}")
public ResponseEntity<byte[]> read(@PathVariable String 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)) { for (ConversationMember member : members.findByUserId(actor)) {
Long id = member.getConversationId(); Long id = member.getConversationId();
var conversation = conversations.findById(id).orElse(null); 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; || !members.existsByConversationIdAndUserId(id, owner)) continue;
try { try {
// 复用成员、拉黑、撤回与企业接收门禁;邀约会话不释放候选人的个人头像。 // 非匿名邀约同样展示头像,但不能绕过成员、拉黑、撤回或企业接收门禁。
messaging.assertAttachmentAccess(id, actor); messaging.assertProfileImageAccess(id, actor);
return true; return true;
} catch (BusinessException denied) { } catch (BusinessException denied) {
// 另一条 DIRECT 会话可能仍有合法授权;数据库/系统异常不会在此吞掉。 // 另一条会话可能仍有合法授权;数据库/系统异常不会在此吞掉。
} }
} }
return false; return false;

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

@ -58,7 +58,9 @@ public class JobRecommendationService {
@Transactional(readOnly = true) @Transactional(readOnly = true)
public List<TaxonomyResponse> taxonomies() { public List<TaxonomyResponse> taxonomies() {
return taxonomyRepository.findByEnabledTrueOrderBySortOrderAscCodeAsc().stream().map(t -> TaxonomyResponse.builder() 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) @Transactional(readOnly = true)
@ -189,11 +191,11 @@ public class JobRecommendationService {
list(preference.getPreferredCities()), list(preference.getPreferredCities()),
list(preference.getEmploymentTypes()), list(preference.getEmploymentTypes()),
preference.getExpectedSalaryMin(), preference.getExpectedSalaryMin(),
list(preference.getPreferredIndustries()), normalizedIndustries(preference),
list(preference.getPreferredJobTypes())); list(preference.getPreferredJobTypes()));
JobPreferenceScoreCalculator.Target target = new JobPreferenceScoreCalculator.Target( JobPreferenceScoreCalculator.Target target = new JobPreferenceScoreCalculator.Target(
job.getCity(), job.getEmploymentType(), job.getSalaryMax(), job.getCity(), job.getEmploymentType(), job.getSalaryMax(),
profile.getIndustryCode(), profile.getJobFamilyCode()); normalizedIndustryTarget(profile.getIndustryCode()), profile.getJobFamilyCode());
return JobPreferenceScoreCalculator.calculate(criteria, target); 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);} 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 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 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 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 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) { private void assertCompanyOwns(Long user, Long job) {
Company company = companyService.findCompanyByOwnerUserId(user); Company company = companyService.findCompanyByOwnerUserId(user);

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

@ -91,7 +91,7 @@ public class SkillCloudQueryService {
.toList(); .toList();
return new SkillCloudResponse( 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()), toNode(occupation.getId(), occupation.getCode(), occupation.getNameZh()),
category == null ? null : toNode(category.getId(), category.getCode(), category.getNameZh()), category == null ? null : toNode(category.getId(), category.getCode(), category.getNameZh()),
items.size(), items.size(),

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

@ -195,13 +195,13 @@ public class TalentDiscoveryService {
preference.cities(), preference.cities(),
preference.employmentTypes(), preference.employmentTypes(),
preference.expectedSalaryMin(), preference.expectedSalaryMin(),
preference.industries(), com.mydreamjob.industry.IndustryCatalog.scoringCriteria(null, preference.industries()),
preference.jobTypes()), preference.jobTypes()),
new JobPreferenceScoreCalculator.Target( new JobPreferenceScoreCalculator.Target(
job.getCity(), job.getCity(),
job.getEmploymentType(), job.getEmploymentType(),
job.getSalaryMax(), job.getSalaryMax(),
profile.getIndustryCode(), com.mydreamjob.industry.IndustryCatalog.scoringTarget(profile.getIndustryCode()),
profile.getJobFamilyCode())); 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(modulePaths).as("N03/N04/N05 新模块路径").isEqualTo(13);
assertThat(moduleOperations).as("N03/N04/N05 新模块操作").isEqualTo(18); assertThat(moduleOperations).as("N03/N04/N05 新模块操作").isEqualTo(18);
assertThat(pathCount - modulePaths).as("非模块路径包含头像/Logo及通知回收站").isEqualTo(208); var staticPaths = new com.fasterxml.jackson.databind.ObjectMapper().readTree(java.nio.file.Files.readString(
assertThat(opCount - moduleOperations).as("非模块操作包含头像/Logo及通知回收站").isEqualTo(253); 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"); var download = paths.path("/company/candidates/{applicationId}/resume").path("get");
assertThat(download.isMissingNode()).as("必须存在企业简历下载 GET").isFalse(); 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) { void businessGetIsDenied(String path) {
assertThat(new IdentityAccessFilter(null,null,null,null).allowed("GET",path)).isFalse(); 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"}) "/companies/42/jobs","/auth/me","/v3/api-docs","/health"})
void publicGetRemainsAccessible(String path) { void publicGetRemainsAccessible(String path) {
assertThat(new IdentityAccessFilter(null,null,null,null).allowed("GET",path)).isTrue(); 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)); 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(); String token=login("19900009410").path("accessToken").asText();
// 独立列出已批准的公开契约,不调用生产 gate.allowed 生成期望值。 // 独立列出已批准的公开契约,不调用生产 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/compare","/jobs/public/{jobId}","/jobs/{jobId}/reviews","/jobs/{jobId}/reviews/{reviewId}",
"/jobs/{jobId}/reviews/summary","/companies/{companyId}","/companies/{companyId}/jobs", "/jobs/{jobId}/reviews/summary","/companies/{companyId}","/companies/{companyId}/jobs",
"/job-recommendation-taxonomies","/auth/me","/profile-images/{imageId}"); "/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/password/change","/auth/login","/auth/password/login","/auth/password/reset",
"/auth/refresh","/auth/sms/send","/auth/company/register"); "/auth/refresh","/auth/sms/send","/auth/company/register");
int denied=0; 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; int moduleDenied=0;
var paths=doc.path("paths").fields(); var paths=doc.path("paths").fields();
while(paths.hasNext()) { 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 PHONE_PREFIX = "1990078";
private static final String CITY = "CONSISTENCYCITY"; private static final String CITY = "CONSISTENCYCITY";
private static final Set<String> PUBLIC_COMPANY_KEYS = 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; @MockBean private RedisTemplate<String, Object> redis;
@Autowired private MockMvc mvc; @Autowired private MockMvc mvc;
@ -201,7 +201,7 @@ class JobDisplayFieldsConsistencyIntegrationTest {
// ── 4. 公开企业摘要白名单 ─────────────────────────────────── // ── 4. 公开企业摘要白名单 ───────────────────────────────────
@Test @Test
void publicCompanySummaryIsExactlySevenFieldWhitelist() throws Exception { void publicCompanySummaryIsExactlyEightFieldWhitelist() throws Exception {
long jobId = createFullJob("白名单岗位"); long jobId = createFullJob("白名单岗位");
publish(jobId); 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 StudentProfileRepository profileRepository;
@Mock private AuditService auditService; @Mock private AuditService auditService;
@Mock private UserRepository userRepository; @Mock private UserRepository userRepository;
@Mock private com.mydreamjob.company.CompanyRepository companyRepository;
/** R01-B/B1:角色授权已委托给公共 guard;此处以委托桩保持既有断言语义。 */ /** R01-B/B1:角色授权已委托给公共 guard;此处以委托桩保持既有断言语义。 */
@Mock @Mock
private UserRoleGuard userRoleGuard; private UserRoleGuard userRoleGuard;
@ -70,12 +71,25 @@ class MatchServiceTest {
.expectedSalaryMin(10000).preferredIndustries("[\"互联网\"]") .expectedSalaryMin(10000).preferredIndustries("[\"互联网\"]")
.preferredJobTypes("[\"Java\"]").remoteAllowed(false).build(); .preferredJobTypes("[\"Java\"]").remoteAllowed(false).build();
when(preferenceRepository.findByStudentUserId(STUDENT_ID)).thenReturn(Optional.of(p)); 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() { private void mockProfile() {
when(profileRepository.findByUserId(STUDENT_ID)).thenReturn(Optional.of( when(profileRepository.findByUserId(STUDENT_ID)).thenReturn(Optional.of(
StudentProfile.builder().userId(STUDENT_ID).build())); 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); denied(()->messaging.sendMessage(student.getId(),text),1001);
assertNotNull(sent.getId()); assertNotNull(sent.getId());
} }
@Test void summaryHasRealPeerAndRedactedInvitationView() { @Test void summaryHasRealPeerAndNonAnonymousInvitationDisplay() {
send("summary-1"); send("summary-1");
var forStudent=messaging.getConversation(conversation.getId(),student.getId()); var forStudent=messaging.getConversation(conversation.getId(),student.getId());
assertEquals("测试企业",forStudent.get("contactName"));assertEquals("[简历]",forStudent.get("lastMessage")); assertEquals("测试企业",forStudent.get("contactName"));assertEquals("[简历]",forStudent.get("lastMessage"));
@ -164,11 +164,59 @@ class ChatWorkflowIntegrationTest {
assertEquals("候选人昵称",forHr.get("contactName"));assertFalse(forHr.containsKey("online")); assertEquals("候选人昵称",forHr.get("contactName"));assertFalse(forHr.containsKey("online"));
conversation.setType("TALENT_INVITATION");em.flush(); conversation.setType("TALENT_INVITATION");em.flush();
var anonymous=messaging.getConversation(conversation.getId(),employer.getId()); 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); var message=messaging.listMessages(conversation.getId(),employer.getId(),0,20).getItems().get(0);
assertNull(message.getSenderUserId());assertEquals("OTHER_PARTICIPANT",message.getSenderScope()); assertNull(message.getSenderUserId());assertEquals("OTHER_PARTICIPANT",message.getSenderScope());
assertEquals("resume.pdf",message.getResume().fileName()); 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 { @Test void downloadIsBinaryAndNeverPublicOrCached() throws Exception {
var sent=send("http-1"); var sent=send("http-1");
var token=sessions.createSession(employer,clock.instant(),com.mydreamjob.audit.AuditContext.builder().build()).accessToken(); 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"); 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))); } 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 content() {
OnlineResumeContent c = new OnlineResumeContent(); c.getBasicInfo().setRealName("合成测试"); OnlineResumeContent c = new OnlineResumeContent(); c.getBasicInfo().setRealName("合成测试");
c.getBasicInfo().setPhone("13800000000"); c.getBasicInfo().setEmail("resume@example.test"); c.getBasicInfo().setPhone("13800000000"); c.getBasicInfo().setEmail("resume@example.test");

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

@ -101,10 +101,32 @@ class ProfileImageIntegrationTest {
mvc.perform(get(path(view)).header("Authorization", "Bearer " + ot)).andExpect(status().isForbidden()); 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()); 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"); direct(student, peer, "TALENT_INVITATION");
var view = upload("/auth/me/avatar", st); var view = upload("/auth/me/avatar", st);
mvc.perform(get(path(view)).header("Authorization", "Bearer " + pt)).andExpect(status().isForbidden()); 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 { @Test void blockRevokesExistingAvatarUrl() throws Exception {
direct(student, peer, "DIRECT"); direct(student, peer, "DIRECT");
@ -236,11 +258,11 @@ class ProfileImageIntegrationTest {
var response = mvc.perform(get("/v3/api-docs")).andExpect(status().isOk()).andReturn().getResponse(); var response = mvc.perform(get("/v3/api-docs")).andExpect(status().isOk()).andReturn().getResponse();
var runtime = json.readTree(response.getContentAsByteArray()).path("paths"); 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"); 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<>(); Set<String> expected = new TreeSet<>(), actual = new TreeSet<>();
contract.fields().forEachRemaining(p -> p.getValue().fieldNames().forEachRemaining(m -> expected.add(m + " " + p.getKey()))); 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()))); 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()); 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}")) { for (String route : List.of("/auth/me/avatar", "/companies/me/logo", "/profile-images/{imageId}")) {
contract.path(route).fields().forEachRemaining(method -> { 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()); 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()); assertEquals(200,response.statusCode());
var runtime=mapper.readTree(response.body());var contract=mapper.readTree(Files.readString(Path.of("../docs/openapi-rc.json"))); 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(222,runtime.path("paths").size());
assertEquals(operations(contract),operations(runtime));assertEquals(271,operations(runtime).size()); assertEquals(operations(contract),operations(runtime));assertEquals(272,operations(runtime).size());
var path=runtime.path("paths").path("/profile/me/work-experience-optimization").path("post"); var path=runtime.path("paths").path("/profile/me/work-experience-optimization").path("post");
assertTrue(path.path("requestBody").path("required").asBoolean()); assertTrue(path.path("requestBody").path("required").asBoolean());
assertTrue(path.path("security").toString().contains("bearerAuth")); 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().id()).isEqualTo(1L);
assertThat(response.industry().code()).isEqualTo("INTERNET"); 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().id()).isEqualTo(1L);
assertThat(response.occupation().code()).isEqualTo("FRONTEND_DEVELOPER"); assertThat(response.occupation().code()).isEqualTo("FRONTEND_DEVELOPER");
assertThat(response.occupation().name()).isEqualTo("前端开发工程师"); assertThat(response.occupation().name()).isEqualTo("前端开发工程师");

Loading…
Cancel
Save