diff --git a/docs/chat-display-name-frontend-handover-20261028.md b/docs/chat-display-name-frontend-handover-20261028.md new file mode 100644 index 0000000..9a9698d --- /dev/null +++ b/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 的通知发布验收当作本次姓名修复也已上线。 diff --git a/docs/openapi-rc.json b/docs/openapi-rc.json index 0ff4f93..cdf70c4 100644 --- a/docs/openapi-rc.json +++ b/docs/openapi-rc.json @@ -401,7 +401,7 @@ "个人资料" ], "operationId": "readProfileImage", - "description": "Logo anonymous; personal avatar requires authenticated self or authorized DIRECT peer. Replaced/deleted URLs return 404.", + "description": "企业Logo匿名可读;个人头像须携带Bearer且仅本人或满足当前沟通授权的DIRECT或TALENT_INVITATION成员可读,仍校验拉黑、撤回和企业接收门禁,非公开用户头像目录;不扩大邀约附件权限。旧地址替换/删除后404;no-store。", "security": [], "parameters": [ { @@ -2091,7 +2091,7 @@ "企业" ], "summary": "查询企业公开详情", - "description": "匿名可访问,仅返回 6 个公开字段", + "description": "匿名可访问,仅返回8个公开字段,含industryCode和companySize,不含ownerUserId及联系方式", "operationId": "getPublicCompany", "parameters": [ { @@ -3723,6 +3723,15 @@ "format": "int32", "default": 20 } + }, + { + "name": "industryCode", + "in": "query", + "description": "稳定行业编码,逗号多选最多20项;大类包含细分类,取公司行业,与旧industry参数同时提供时AND。完整目录见/jobs/industry-catalog。", + "required": false, + "schema": { + "type": "string" + } } ], "responses": { @@ -13103,7 +13112,11 @@ "429": { "description": "调用额度或并发已满;code429,Retry-After=60;不自动重试", "headers": { - "Retry-After": { "schema": { "type": "integer" } } + "Retry-After": { + "schema": { + "type": "integer" + } + } } }, "503": { @@ -13222,6 +13235,39 @@ } } } + }, + "/jobs/industry-catalog": { + "get": { + "tags": [ + "岗位" + ], + "summary": "统一招聘行业目录", + "description": "ALL为完整两级目录,WITH_JOBS仅含当前有PUBLISHED岗位的行业;计数为全站发布岗位,非当前城市筛选结果。编码稳定;未确定映射的旧名称不归其他。skillIndustryCode非空才表示已有技能数据集,不代表职位类别。", + "operationId": "getIndustryCatalog", + "parameters": [ + { + "name": "scope", + "in": "query", + "required": false, + "schema": { + "type": "string", + "default": "ALL" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ApiResponseIndustryDirectory" + } + } + } + } + } + } } }, "components": { @@ -13426,37 +13472,105 @@ "NotificationListResponseDoc": { "type": "object", "properties": { - "code": {"type": "integer", "format": "int32"}, - "message": {"type": "string"}, - "data": {"$ref": "#/components/schemas/NotificationPageDoc"}, - "requestId": {"type": "string"}, - "timestamp": {"type": "string", "format": "date-time"} + "code": { + "type": "integer", + "format": "int32" + }, + "message": { + "type": "string" + }, + "data": { + "$ref": "#/components/schemas/NotificationPageDoc" + }, + "requestId": { + "type": "string" + }, + "timestamp": { + "type": "string", + "format": "date-time" + } } }, "NotificationPageDoc": { "type": "object", "properties": { - "items": {"type": "array", "items": {"$ref": "#/components/schemas/NotificationItemDoc"}}, - "total": {"type": "integer", "format": "int64"}, - "page": {"type": "integer", "format": "int32"}, - "size": {"type": "integer", "format": "int32"}, - "totalPages": {"type": "integer", "format": "int32"} + "items": { + "type": "array", + "items": { + "$ref": "#/components/schemas/NotificationItemDoc" + } + }, + "total": { + "type": "integer", + "format": "int64" + }, + "page": { + "type": "integer", + "format": "int32" + }, + "size": { + "type": "integer", + "format": "int32" + }, + "totalPages": { + "type": "integer", + "format": "int32" + } } }, "NotificationItemDoc": { "type": "object", "description": "通知展示与业务关联;未知类型/空关联不推断目标,详情仍受各业务接口权限校验", - "required": ["businessType", "businessId"], + "required": [ + "businessType", + "businessId" + ], "properties": { - "id": {"type": "integer", "format": "int64"}, - "type": {"type": "string"}, - "title": {"type": "string"}, - "content": {"type": ["string", "null"]}, - "read": {"type": "boolean"}, - "readAt": {"type": ["string", "null"], "format": "date-time"}, - "createdAt": {"type": "string", "format": "date-time"}, - "businessType": {"type": ["string", "null"], "description": "持久化业务类型,非封闭枚举;历史或系统通知可为 null。已核查 APPLICATION/INTERVIEW/OFFER/MATCH/VERIFICATION/REPORT"}, - "businessId": {"type": ["integer", "null"], "format": "int64", "description": "持久化关联业务记录ID,不是通知ID;APPLICATION 和 MATCH 均为投递ID,INTERVIEW 为面试ID,OFFER 为Offer ID;无关联时 null"} + "id": { + "type": "integer", + "format": "int64" + }, + "type": { + "type": "string" + }, + "title": { + "type": "string" + }, + "content": { + "type": [ + "string", + "null" + ] + }, + "read": { + "type": "boolean" + }, + "readAt": { + "type": [ + "string", + "null" + ], + "format": "date-time" + }, + "createdAt": { + "type": "string", + "format": "date-time" + }, + "businessType": { + "type": [ + "string", + "null" + ], + "description": "持久化业务类型,非封闭枚举;历史或系统通知可为 null。已核查 APPLICATION/INTERVIEW/OFFER/MATCH/VERIFICATION/REPORT" + }, + "businessId": { + "type": [ + "integer", + "null" + ], + "format": "int64", + "description": "持久化关联业务记录ID,不是通知ID;APPLICATION 和 MATCH 均为投递ID,INTERVIEW 为面试ID,OFFER 为Offer ID;无关联时 null" + } } }, "ProfileImageView": { @@ -15972,7 +16086,8 @@ "type": "string" }, "industry": { - "type": "string" + "type": "string", + "maxLength": 80 }, "website": { "type": "string" @@ -15988,6 +16103,11 @@ "description": "企业规模,≤40 字符。缺省/null:创建存 NULL;空串或纯空白清空为 NULL;非空去两端空白后保存;超过 40 字符拒绝且不截断。", "maxLength": 40, "minLength": 0 + }, + "industryCode": { + "type": "string", + "maxLength": 64, + "minLength": 0 } }, "required": [ @@ -16051,8 +16171,14 @@ ], "description": "企业规模,≤40 字符", "maxLength": 40 + }, + "industryCode": { + "type": "string", + "description": "稳定招聘行业编码;无法确定映射的旧名称为空", + "maxLength": 64 } - } + }, + "description": "企业公开摘要;严格 8 字段,含 industryCode,不含 ownerUserId、联系方式、登记地址与审核信息" }, "CompanyScoreItem": { "type": "object", @@ -16112,6 +16238,11 @@ "description": "企业规模,≤40 字符。缺省/null:更新保持原值;空串或纯空白清空为 NULL;非空去两端空白后保存;超过 40 字符拒绝且不截断。", "maxLength": 40, "minLength": 0 + }, + "industryCode": { + "type": "string", + "maxLength": 64, + "minLength": 0 } }, "required": [ @@ -17385,6 +17516,16 @@ }, "salaryRangeValid": { "type": "boolean" + }, + "preferredIndustryCodes": { + "type": "array", + "items": { + "type": "string", + "maxLength": 64, + "minLength": 0 + }, + "maxItems": 20, + "minItems": 0 } } }, @@ -20653,6 +20794,11 @@ "type": "string", "maxLength": 255, "minLength": 0 + }, + "industryCode": { + "type": "string", + "maxLength": 64, + "minLength": 0 } }, "required": [ @@ -20814,6 +20960,9 @@ }, "verified": { "type": "boolean" + }, + "industryCode": { + "type": "string" } } }, @@ -22188,7 +22337,7 @@ }, "JobDocCompanySummary": { "type": "object", - "description": "公开企业摘要;严格 7 字段,不含 ownerUserId、联系方式、地址与审核信息", + "description": "公开企业摘要;严格 8 字段,含 industryCode,不含 ownerUserId、联系方式、地址与审核信息", "properties": { "id": { "type": "integer", @@ -22219,6 +22368,11 @@ "type": "string", "description": "企业规模,≤40 字符;缺省时为空", "maxLength": 40 + }, + "industryCode": { + "type": "string", + "description": "企业行业稳定编码;无法映射的历史值为空", + "maxLength": 64 } } }, @@ -22774,6 +22928,16 @@ "availableFrom": { "type": "string", "format": "date" + }, + "preferredIndustryCodes": { + "type": "array", + "items": { + "type": "string", + "maxLength": 64, + "minLength": 0 + }, + "maxItems": 20, + "minItems": 0 } }, "required": [ @@ -24717,6 +24881,9 @@ }, "verified": { "type": "boolean" + }, + "industryCode": { + "type": "string" } } }, @@ -25954,6 +26121,77 @@ "type": "string" } } + }, + "IndustryDirectory": { + "type": "object", + "properties": { + "version": { + "type": "string" + }, + "scope": { + "type": "string" + }, + "countScope": { + "type": "string" + }, + "items": { + "type": "array", + "items": { + "$ref": "#/components/schemas/IndustryDirectoryItem" + } + }, + "unmappedPublishedJobCount": { + "type": "integer", + "format": "int64" + } + } + }, + "IndustryDirectoryItem": { + "type": "object", + "properties": { + "code": { + "type": "string" + }, + "parentCode": { + "type": "string" + }, + "name": { + "type": "string" + }, + "level": { + "type": "integer", + "format": "int32" + }, + "publishedJobCount": { + "type": "integer", + "format": "int64" + }, + "skillIndustryCode": { + "type": "string" + } + } + }, + "ApiResponseIndustryDirectory": { + "type": "object", + "properties": { + "code": { + "type": "integer", + "format": "int32" + }, + "message": { + "type": "string" + }, + "data": { + "$ref": "#/components/schemas/IndustryDirectory" + }, + "requestId": { + "type": "string" + }, + "timestamp": { + "type": "string", + "format": "date-time" + } + } } }, "securitySchemes": { diff --git a/docs/unified-industry-frontend-handover-20261028.md b/docs/unified-industry-frontend-handover-20261028.md new file mode 100644 index 0000000..1f34d6b --- /dev/null +++ b/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验证分别记账,不互相冒充。 diff --git a/docs/verify_openapi.py b/docs/verify_openapi.py index f297c10..05d4e8e 100644 --- a/docs/verify_openapi.py +++ b/docs/verify_openapi.py @@ -6,7 +6,7 @@ p = PROJECT_ROOT / 'docs' / 'openapi-rc.json' d = json.load(open(p, encoding='utf-8')) print('paths:', len(d['paths'])) -assert len(d['paths']) == 221, f"Expected 221 paths, got {len(d['paths'])}" +assert len(d['paths']) == 222, f"Expected 222 paths, got {len(d['paths'])}" paths = d['paths'] @@ -743,7 +743,7 @@ print(' S03 company resume download contract validated; candidate DTOs remain r # 统计 operation 总数并校验当前接口契约基线。 operations = sum(len(methods) for methods in d['paths'].values()) print('operations:', operations) -assert operations == 271, f"Expected 271 operations, got {operations}" +assert operations == 272, f"Expected 272 operations, got {operations}" # 个人头像/企业 Logo 独立写入;文件上传不接受客户端 userId 或 URL。 for _route, _upload, _delete in ( @@ -903,12 +903,12 @@ print(' CompanyManagedJobItem 展示字段已验证') _public_company_props = d['components']['schemas']['CompanyPublicView']['properties'] assert set(_public_company_props) == { - 'id', 'name', 'logoUrl', 'description', 'industry', 'verified', 'companySize'}, \ - f'CompanyPublicView 必须严格为 7 字段白名单,实际={sorted(_public_company_props)}' + 'id', 'name', 'logoUrl', 'description', 'industry', 'industryCode', 'verified', 'companySize'}, \ + f'CompanyPublicView 必须严格为 8 字段白名单,实际={sorted(_public_company_props)}' for _forbidden in ('ownerUserId', 'address', 'website', 'phone', 'email', 'createdAt', 'materialsJson', 'decisionReason', 'reviewerId'): assert _forbidden not in _public_company_props, f'CompanyPublicView 不得包含 {_forbidden}' -print(' 公开企业摘要严格 7 字段白名单已验证(不含 ownerUserId/联系方式/地址/认证材料/审核信息)') +print(' 公开企业摘要严格 8 字段白名单已验证(不含 ownerUserId/联系方式/地址/认证材料/审核信息)') for _pt, _method in [('/jobs', 'get'), ('/jobs', 'post'), ('/jobs/search', 'get'), ('/jobs/public/{jobId}', 'get'), ('/jobs/{jobId}', 'get'), @@ -1094,5 +1094,21 @@ assert set(_trash_item['properties']) == set(_notification_item['properties']) | assert _trash_item['properties']['deletedAt']['format'] == 'date-time' assert 'deletedAt' in _trash_item['required'] print(' Notification recycle bin: five authenticated operations, bounded batch IDs, deletion time; no permanent deletion') +_industry_path = paths['/jobs/industry-catalog']['get'] +assert _industry_path['operationId'] == 'getIndustryCatalog' +assert any(p['name'] == 'scope' for p in _industry_path['parameters']) +_industry_schemas = d['components']['schemas'] +_industry_item = _industry_schemas['IndustryDirectoryItem']['properties'] +assert set(_industry_item) == {'code', 'parentCode', 'name', 'level', 'publishedJobCount', 'skillIndustryCode'} +assert _industry_item['publishedJobCount']['type'] == 'integer' +assert {'version', 'scope', 'countScope', 'items', 'unmappedPublishedJobCount'} == set(_industry_schemas['IndustryDirectory']['properties']) +for _schema in ('CompanyCreateRequest', 'CompanyUpdateRequest', 'CompanyDetails', 'CompanyPublicView', 'JobDocCompanySummary', 'CompanyView'): + assert _industry_schemas[_schema]['properties']['industryCode']['type'] == 'string' +for _schema in ('StudentPreferenceRequest', 'OnlineResumeJobIntention'): + _codes = _industry_schemas[_schema]['properties']['preferredIndustryCodes'] + assert _codes['type'] == 'array' and _codes['maxItems'] == 20 + assert _codes['items']['type'] == 'string' +assert any(p['name'] == 'industryCode' for p in paths['/jobs/search']['get']['parameters']) +print(' Unified industry directory, stable company codes, preference codes and search parameter verified') print("Valid JSON: True") print("All checks passed!") diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/auth/company/CompanyRegistrationRequest.java b/mydreamjob-server/src/main/java/com/mydreamjob/auth/company/CompanyRegistrationRequest.java index 3b62335..48fbcc0 100644 --- a/mydreamjob-server/src/main/java/com/mydreamjob/auth/company/CompanyRegistrationRequest.java +++ b/mydreamjob-server/src/main/java/com/mydreamjob/auth/company/CompanyRegistrationRequest.java @@ -39,6 +39,9 @@ public class CompanyRegistrationRequest { private String description; @Size(max = 80) private String industry; + + @jakarta.validation.constraints.Size(max = 64) + private String industryCode; @Size(max = 255) private String website; @Size(max = 255) diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/auth/company/CompanyRegistrationService.java b/mydreamjob-server/src/main/java/com/mydreamjob/auth/company/CompanyRegistrationService.java index 245b6f4..9222088 100644 --- a/mydreamjob-server/src/main/java/com/mydreamjob/auth/company/CompanyRegistrationService.java +++ b/mydreamjob-server/src/main/java/com/mydreamjob/auth/company/CompanyRegistrationService.java @@ -75,12 +75,13 @@ public class CompanyRegistrationService { user = userRepository.saveAndFlush(user); CompanyRegistrationRequest.CompanyDetails d = request.getCompany(); + var industry = com.mydreamjob.industry.IndustryCatalog.selection(d.getIndustryCode(), d.getIndustry()); Company company = Company.builder() .ownerUserId(user.getId()) .name(d.getName()) .logoUrl(d.getLogoUrl()) .description(d.getDescription()) - .industry(d.getIndustry()) + .industry(industry.name()).industryCode(industry.code()) .website(d.getWebsite()) .address(d.getAddress()) .verified(false) diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/auth/identity/IdentityAccessFilter.java b/mydreamjob-server/src/main/java/com/mydreamjob/auth/identity/IdentityAccessFilter.java index e1f1e35..9c4a7b9 100644 --- a/mydreamjob-server/src/main/java/com/mydreamjob/auth/identity/IdentityAccessFilter.java +++ b/mydreamjob-server/src/main/java/com/mydreamjob/auth/identity/IdentityAccessFilter.java @@ -27,7 +27,7 @@ public class IdentityAccessFilter extends OncePerRequestFilter { "/auth/password/set", "/auth/password/setup", "/auth/password/change", "/auth/login", "/auth/password/login", "/auth/password/reset", "/auth/refresh", "/auth/sms/send", "/auth/company/register"); private static final Set PUBLIC_GET = Set.of("/", "/health", "/health/**", "/jobs", - "/jobs/search", "/jobs/filters", "/jobs/compare", "/jobs/public/**", "/jobs/*/reviews", + "/jobs/search", "/jobs/filters", "/jobs/industry-catalog", "/jobs/compare", "/jobs/public/**", "/jobs/*/reviews", "/jobs/*/reviews/*", "/jobs/*/reviews/summary", "/companies/*", "/companies/*/jobs", "/job-recommendation-taxonomies", "/profile-images/*", "/swagger-ui/**", "/v3/api-docs/**", "/api-docs/**"); private final UserRepository users; diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/auth/identity/IdentitySelectionService.java b/mydreamjob-server/src/main/java/com/mydreamjob/auth/identity/IdentitySelectionService.java index 4fec286..cdaf168 100644 --- a/mydreamjob-server/src/main/java/com/mydreamjob/auth/identity/IdentitySelectionService.java +++ b/mydreamjob-server/src/main/java/com/mydreamjob/auth/identity/IdentitySelectionService.java @@ -70,6 +70,9 @@ public class IdentitySelectionService { if (target == User.Role.COMPANY && !properties.isEnabled()) { throw new BusinessException(ErrorCode.COMPANY_REGISTRATION_DISABLED); } + var industry = target == User.Role.COMPANY + ? com.mydreamjob.industry.IndustryCatalog.selection(request.getCompany().getIndustryCode(), request.getCompany().getIndustry()) + : null; user.setRole(target); user.setRoles(new HashSet<>(Set.of(target))); users.saveAndFlush(user); @@ -77,7 +80,7 @@ public class IdentitySelectionService { if (target == User.Role.COMPANY) { CompanyDetails d = request.getCompany(); company = companies.saveAndFlush(Company.builder().ownerUserId(userId).name(d.getName()) - .logoUrl(d.getLogoUrl()).industry(d.getIndustry()).description(d.getDescription()) + .logoUrl(d.getLogoUrl()).industry(industry.name()).industryCode(industry.code()).description(d.getDescription()) .website(d.getWebsite()).address(d.getAddress()).verified(false).build()); } // modifying 会清理 persistence context;上面的角色与资料已 flush,锁仍持有。 diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/company/Company.java b/mydreamjob-server/src/main/java/com/mydreamjob/company/Company.java index 0316a57..ff8a884 100644 --- a/mydreamjob-server/src/main/java/com/mydreamjob/company/Company.java +++ b/mydreamjob-server/src/main/java/com/mydreamjob/company/Company.java @@ -42,6 +42,17 @@ public class Company { @Column(name = "industry", length = 80) private String industry; + @Column(name = "industry_code", length = 64) + private String industryCode; + + public String getIndustryCode() { + return industryCode != null ? industryCode : com.mydreamjob.industry.IndustryCatalog.resolveLegacy(industry); + } + + public String getIndustry() { + return com.mydreamjob.industry.IndustryCatalog.displayName(industryCode, industry); + } + @Column(name = "company_size", length = 40) private String companySize; diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/company/CompanyController.java b/mydreamjob-server/src/main/java/com/mydreamjob/company/CompanyController.java index 1113158..9dbd68f 100644 --- a/mydreamjob-server/src/main/java/com/mydreamjob/company/CompanyController.java +++ b/mydreamjob-server/src/main/java/com/mydreamjob/company/CompanyController.java @@ -83,7 +83,7 @@ public class CompanyController { // ── 公开端:企业详情 ──────────────────────────────────────── - @Operation(summary = "查询企业公开详情", description = "匿名可访问,仅返回 6 个公开字段") + @Operation(summary = "查询企业公开详情", description = "匿名可访问,仅返回8个公开字段,含industryCode和companySize,不含ownerUserId及联系方式") @GetMapping("/{companyId}") public ResponseEntity> getPublicCompany( @PathVariable Long companyId, diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/company/CompanyOnboardingResponse.java b/mydreamjob-server/src/main/java/com/mydreamjob/company/CompanyOnboardingResponse.java index ea9ff2f..2564d00 100644 --- a/mydreamjob-server/src/main/java/com/mydreamjob/company/CompanyOnboardingResponse.java +++ b/mydreamjob-server/src/main/java/com/mydreamjob/company/CompanyOnboardingResponse.java @@ -12,6 +12,9 @@ public record CompanyOnboardingResponse( public record CompanySummary(Long id, String name, String logoUrl, String description, String industry, String website, String address, Boolean verified) { + public String getIndustryCode() { + return com.mydreamjob.industry.IndustryCatalog.resolveLegacy(industry); + } } public record VerificationSummary(Long id, String status, LocalDateTime submittedAt, diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/company/CompanyService.java b/mydreamjob-server/src/main/java/com/mydreamjob/company/CompanyService.java index 65f08c0..ac6a11c 100644 --- a/mydreamjob-server/src/main/java/com/mydreamjob/company/CompanyService.java +++ b/mydreamjob-server/src/main/java/com/mydreamjob/company/CompanyService.java @@ -69,12 +69,13 @@ public class CompanyService { if (companyRepository.findByOwnerUserId(userId).isPresent()) { throw new BusinessException(ErrorCode.COMPANY_ALREADY_EXISTS); } + var industry = com.mydreamjob.industry.IndustryCatalog.selection(req.getIndustryCode(), req.getIndustry()); Company company = Company.builder() .ownerUserId(userId) .name(req.getName()) .logoUrl(req.getLogoUrl()) .description(req.getDescription()) - .industry(req.getIndustry()) + .industry(industry.name()).industryCode(industry.code()) .companySize(normalizeCompanySize(req.getCompanySize())) .website(req.getWebsite()) .address(req.getAddress()) @@ -92,10 +93,14 @@ public class CompanyService { Company company = companyRepository.findByOwnerUserIdForUpdate(userId) .orElseThrow(() -> new BusinessException(ErrorCode.COMPANY_NOT_FOUND)); + var industry = com.mydreamjob.industry.IndustryCatalog.selection(req.getIndustryCode(), req.getIndustry()); if (req.getName() != null) company.setName(req.getName()); if (req.getLogoUrl() != null) company.setLogoUrl(req.getLogoUrl()); if (req.getDescription() != null) company.setDescription(req.getDescription()); - if (req.getIndustry() != null) company.setIndustry(req.getIndustry()); + if (req.getIndustry() != null || req.getIndustryCode() != null) { + company.setIndustry(industry.name()); + company.setIndustryCode(industry.code()); + } // companySize:null/缺省保持原值;其余交给与创建共用的同一套归一化规则。 if (req.getCompanySize() != null) { company.setCompanySize(normalizeCompanySize(req.getCompanySize())); @@ -145,6 +150,7 @@ public class CompanyService { view.put("logoUrl", company.getLogoUrl()); view.put("description", company.getDescription()); view.put("industry", company.getIndustry()); + view.put("industryCode", company.getIndustryCode()); view.put("companySize", company.getCompanySize()); view.put("website", company.getWebsite()); view.put("address", company.getAddress()); @@ -176,6 +182,7 @@ public class CompanyService { .logoUrl(company.getLogoUrl()) .description(company.getDescription()) .industry(company.getIndustry()) + .industryCode(company.getIndustryCode()) .companySize(company.getCompanySize()) .verified(company.getVerified()) .build(); diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/company/dto/CompanyCreateRequest.java b/mydreamjob-server/src/main/java/com/mydreamjob/company/dto/CompanyCreateRequest.java index 513f5bd..5d2cb8d 100644 --- a/mydreamjob-server/src/main/java/com/mydreamjob/company/dto/CompanyCreateRequest.java +++ b/mydreamjob-server/src/main/java/com/mydreamjob/company/dto/CompanyCreateRequest.java @@ -13,8 +13,12 @@ public class CompanyCreateRequest { private String logoUrl; private String description; + @jakarta.validation.constraints.Size(max = 80) private String industry; + @jakarta.validation.constraints.Size(max = 64) + private String industryCode; + @Size(max = 40, message = "company size must be at most 40 characters") @Schema(types = {"string", "null"}, maxLength = 40, description = "企业规模,≤40 字符。缺省/null:创建存 NULL;空串或纯空白清空为 NULL;" diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/company/dto/CompanyPublicView.java b/mydreamjob-server/src/main/java/com/mydreamjob/company/dto/CompanyPublicView.java index 4a0b0d6..fd45fdd 100644 --- a/mydreamjob-server/src/main/java/com/mydreamjob/company/dto/CompanyPublicView.java +++ b/mydreamjob-server/src/main/java/com/mydreamjob/company/dto/CompanyPublicView.java @@ -9,7 +9,7 @@ import lombok.NoArgsConstructor; /** * 企业公开信息 DTO。 - * 仅暴露 7 个公开字段,不泄露 ownerUserId、联系方式、审核信息等。 + * 仅暴露 8 个公开字段,不泄露 ownerUserId、联系方式、审核信息等。 */ @Data @Builder @@ -17,7 +17,7 @@ import lombok.NoArgsConstructor; @AllArgsConstructor @JsonInclude(JsonInclude.Include.NON_NULL) @Schema(name = "CompanyPublicView", - description = "企业公开摘要;严格 7 字段,不含 ownerUserId、联系方式、登记地址与审核信息") + description = "企业公开摘要;严格 8 字段,含 industryCode,不含 ownerUserId、联系方式、登记地址与审核信息") public class CompanyPublicView { @Schema(description = "企业 ID") @@ -35,9 +35,12 @@ public class CompanyPublicView { @Schema(description = "所属行业") private String industry; + @Schema(description = "稳定招聘行业编码;无法确定映射的旧名称为空", maxLength = 64) + private String industryCode; + @Schema(types = {"string", "null"}, maxLength = 40, description = "企业规模,≤40 字符") private String companySize; @Schema(description = "是否已认证") private Boolean verified; -} \ No newline at end of file +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/company/dto/CompanyUpdateRequest.java b/mydreamjob-server/src/main/java/com/mydreamjob/company/dto/CompanyUpdateRequest.java index 8b75bd7..8f33d10 100644 --- a/mydreamjob-server/src/main/java/com/mydreamjob/company/dto/CompanyUpdateRequest.java +++ b/mydreamjob-server/src/main/java/com/mydreamjob/company/dto/CompanyUpdateRequest.java @@ -23,6 +23,9 @@ public class CompanyUpdateRequest { @Size(max = 80, message = "industry must be at most 80 characters") private String industry; + @jakarta.validation.constraints.Size(max = 64) + private String industryCode; + @Size(max = 40, message = "company size must be at most 40 characters") @Schema(types = {"string", "null"}, maxLength = 40, description = "企业规模,≤40 字符。缺省/null:更新保持原值;空串或纯空白清空为 NULL;" diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/config/SecurityConfig.java b/mydreamjob-server/src/main/java/com/mydreamjob/config/SecurityConfig.java index 6d6bf2f..16db555 100644 --- a/mydreamjob-server/src/main/java/com/mydreamjob/config/SecurityConfig.java +++ b/mydreamjob-server/src/main/java/com/mydreamjob/config/SecurityConfig.java @@ -150,6 +150,7 @@ public class SecurityConfig { // 岗位搜索与筛选(公开) .requestMatchers(HttpMethod.GET, "/jobs/search").permitAll() .requestMatchers(HttpMethod.GET, "/jobs/filters").permitAll() + .requestMatchers(HttpMethod.GET, "/jobs/industry-catalog").permitAll() // 岗位管理(需登录) .requestMatchers("/jobs/**").authenticated() // 投递(需登录) diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/dataexport/PersonalDataCollector.java b/mydreamjob-server/src/main/java/com/mydreamjob/dataexport/PersonalDataCollector.java index d6a2f27..ede3925 100644 --- a/mydreamjob-server/src/main/java/com/mydreamjob/dataexport/PersonalDataCollector.java +++ b/mydreamjob-server/src/main/java/com/mydreamjob/dataexport/PersonalDataCollector.java @@ -159,6 +159,7 @@ public class PersonalDataCollector { m.put("preferredCities", safeJson(sp.getPreferredCities())); m.put("employmentTypes", safeJson(sp.getEmploymentTypes())); m.put("preferredIndustries", safeJson(sp.getPreferredIndustries())); + m.put("preferredIndustryCodes", safeJson(sp.getPreferredIndustryCodes())); m.put("preferredJobTypes", safeJson(sp.getPreferredJobTypes())); m.put("remoteAllowed", sp.getRemoteAllowed()); m.put("createdAt", sp.getCreatedAt()); diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/industry/IndustryCatalog.java b/mydreamjob-server/src/main/java/com/mydreamjob/industry/IndustryCatalog.java new file mode 100644 index 0000000..3f0887e --- /dev/null +++ b/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 aliases) {} + public record Definition(String version,String description,List nodes) {} + public record Selection(String code,String name) {} + private static final Definition DEFINITION; + private static final Map CODES; + private static final Map 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 codes=new LinkedHashMap<>();Map 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 nodes(){return List.copyOf(CODES.values());} + public static Node node(String code){return CODES.get(code);} + public static List 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 preferenceCodes(List explicit,List 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 preferenceNames(List codes, List 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 expand(Collection codes) { + Set 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 legacyLabels(Collection codes) { + return codes.stream().map(CODES::get).filter(Objects::nonNull).flatMap(n->labels(n).stream()).map(IndustryCatalog::key).distinct().toList(); + } + /** 共享评分输入:展开已知编码,未知旧名称仍保留为未匹配条件,不能静默移除权重。 */ + public static List scoringCriteria(List explicit, List 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);} +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/industry/IndustryDirectoryController.java b/mydreamjob-server/src/main/java/com/mydreamjob/industry/IndustryDirectoryController.java new file mode 100644 index 0000000..20427ee --- /dev/null +++ b/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 get(@RequestParam(defaultValue="ALL") String scope,HttpServletRequest request) { + return ApiResponse.ok(directory.directory(scope),requestIds.get(request)); + } +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/industry/IndustryDirectoryService.java b/mydreamjob-server/src/main/java/com/mydreamjob/industry/IndustryDirectoryService.java new file mode 100644 index 0000000..9afb4b3 --- /dev/null +++ b/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 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 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 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]); + } +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/job/JobController.java b/mydreamjob-server/src/main/java/com/mydreamjob/job/JobController.java index 12b45b2..9249456 100644 --- a/mydreamjob-server/src/main/java/com/mydreamjob/job/JobController.java +++ b/mydreamjob-server/src/main/java/com/mydreamjob/job/JobController.java @@ -162,6 +162,8 @@ public class JobController { @RequestParam(required = false) Long companyId, @Parameter(description = "行业标签精确匹配,取公司 industry;逗号分隔多选,同维度 OR、跨维度 AND。选项见 /jobs/filters 的 industries;最多20个去重值、每项80字符,空值不筛选,非法格式400,未知值无匹配。") @RequestParam(required = false) String industry, + @Parameter(description = "稳定行业编码,逗号多选最多20项;大类包含细分类,取公司行业,与旧industry参数同时提供时AND。完整目录见/jobs/industry-catalog。") + @RequestParam(required = false) String industryCode, @Parameter(description = "经验标签精确匹配,不做年限推断;逗号分隔多选,选项见 experiences;最多20个去重值、每项40字符,空值不筛选,非法格式400。") @RequestParam(required = false) String experience, @Parameter(description = "学历标签精确匹配,不做学历高低推断;逗号分隔多选,选项见 educations;最多20个去重值、每项40字符,空值不筛选,非法格式400。") @@ -172,7 +174,7 @@ public class JobController { HttpServletRequest request) { Page result = jobService.searchJobs( keyword, city, employmentType, salaryMin, salaryMax, companyId, - sort, page, size, industry, experience, education); + sort, page, size, industry, experience, education, industryCode); Map data = new HashMap<>(); data.put("items", result.getContent()); data.put("total", result.getTotalElements()); diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/job/JobSearchSpecification.java b/mydreamjob-server/src/main/java/com/mydreamjob/job/JobSearchSpecification.java index b97bf26..5600bef 100644 --- a/mydreamjob-server/src/main/java/com/mydreamjob/job/JobSearchSpecification.java +++ b/mydreamjob-server/src/main/java/com/mydreamjob/job/JobSearchSpecification.java @@ -28,6 +28,22 @@ public class JobSearchSpecification { // utility class } + /** 新编码按目录父子关系筛选;旧数据只使用精确别名,不猜测未知名称。 */ + public static Specification 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.get("industry"))).in(labels)))); + return cb.exists(subquery); + }; + } + /** * 构建动态查询条件。 * diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/job/JobService.java b/mydreamjob-server/src/main/java/com/mydreamjob/job/JobService.java index 3d2e437..757bce6 100644 --- a/mydreamjob-server/src/main/java/com/mydreamjob/job/JobService.java +++ b/mydreamjob-server/src/main/java/com/mydreamjob/job/JobService.java @@ -411,6 +411,14 @@ public class JobService { Integer salaryMin, Integer salaryMax, Long companyId, String sort, int page, int size, String industry, String experience, String education) { + return searchJobs(keyword, city, employmentType, salaryMin, salaryMax, companyId, + sort, page, size, industry, experience, education, null); + } + + public Page searchJobs(String keyword, String city, String employmentType, + Integer salaryMin, Integer salaryMax, Long companyId, + String sort, int page, int size, + String industry, String experience, String education, String industryCode) { // 参数校验 if (salaryMin != null && salaryMax != null && salaryMin > salaryMax) { throw new BusinessException(ErrorCode.BAD_REQUEST); @@ -426,7 +434,8 @@ public class JobService { } var spec = JobSearchSpecification.searchJobs(keyword, city, employmentType, - salaryMin, salaryMax, companyId, industry, experience, education); + salaryMin, salaryMax, companyId, industry, experience, education) + .and(JobSearchSpecification.industryCodes(industryCode)); Page jobPage; if ("salary".equals(sort)) { @@ -524,6 +533,7 @@ public class JobService { .logoUrl(company.getLogoUrl()) .description(company.getDescription()) .industry(company.getIndustry()) + .industryCode(company.getIndustryCode()) .verified(company.getVerified()) .companySize(company.getCompanySize()) .build(); diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/job/dto/JobDocCompanySummary.java b/mydreamjob-server/src/main/java/com/mydreamjob/job/dto/JobDocCompanySummary.java index d978616..2ca99ff 100644 --- a/mydreamjob-server/src/main/java/com/mydreamjob/job/dto/JobDocCompanySummary.java +++ b/mydreamjob-server/src/main/java/com/mydreamjob/job/dto/JobDocCompanySummary.java @@ -4,7 +4,7 @@ import io.swagger.v3.oas.annotations.media.Schema; import lombok.Data; /** - * 仅供 OpenAPI 文档使用的公开企业摘要模型(严格 7 字段白名单)。 + * 仅供 OpenAPI 文档使用的公开企业摘要模型(严格 8 字段白名单)。 * *

不参与任何序列化/反序列化:仅通过 {@code @Schema(implementation = ...)} 绑定到 * 岗位相关 Map 响应的文档结构,实际返回类型不变。不得暴露 ownerUserId、联系方式、 @@ -12,7 +12,7 @@ import lombok.Data; */ @Data @Schema(name = "JobDocCompanySummary", - description = "公开企业摘要;严格 7 字段,不含 ownerUserId、联系方式、地址与审核信息") + description = "公开企业摘要;严格 8 字段,含 industryCode,不含 ownerUserId、联系方式、地址与审核信息") public class JobDocCompanySummary { @Schema(description = "企业 ID") @@ -30,6 +30,9 @@ public class JobDocCompanySummary { @Schema(description = "所属行业") private String industry; + @Schema(description = "企业行业稳定编码;无法映射的历史值为空", maxLength = 64) + private String industryCode; + @Schema(description = "是否已认证") private Boolean verified; diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/job/dto/JobSearchItem.java b/mydreamjob-server/src/main/java/com/mydreamjob/job/dto/JobSearchItem.java index 80beec9..b826ea2 100644 --- a/mydreamjob-server/src/main/java/com/mydreamjob/job/dto/JobSearchItem.java +++ b/mydreamjob-server/src/main/java/com/mydreamjob/job/dto/JobSearchItem.java @@ -50,7 +50,9 @@ public class JobSearchItem { private String logoUrl; private String description; private String industry; + + private String industryCode; private Boolean verified; private String companySize; } -} \ No newline at end of file +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/match/MatchService.java b/mydreamjob-server/src/main/java/com/mydreamjob/match/MatchService.java index 93ee9a9..edc0049 100644 --- a/mydreamjob-server/src/main/java/com/mydreamjob/match/MatchService.java +++ b/mydreamjob-server/src/main/java/com/mydreamjob/match/MatchService.java @@ -35,6 +35,7 @@ public class MatchService { private final StudentProfileRepository profileRepository; private final AuditService auditService; private final UserRepository userRepository; + private final com.mydreamjob.company.CompanyRepository companyRepository; private static final ObjectMapper mapper = new ObjectMapper(); private static final String RULES_VERSION = "RULES_V0_1"; @@ -95,14 +96,15 @@ public class MatchService { } // 规则4:行业偏好匹配(若有设置) - if (preference != null && preference.getPreferredIndustries() != null - && !preference.getPreferredIndustries().isBlank()) { - // 行业字段:job 没有 industry,用 description 关键词近似 - String desc = job.getDescription() != null ? job.getDescription().toLowerCase() : ""; - boolean industryMatch = preference.getPreferredIndustries().toLowerCase() - .contains(desc.substring(0, Math.min(30, desc.length()))); - // 简化:行业偏好为参考项,不参与硬性 - if (industryMatch) { score += 10; matched.add("行业偏好匹配"); } + if (preference != null) { + var industries = com.mydreamjob.industry.IndustryCatalog.scoringCriteria( + preference.getPreferredIndustryCodes() == null ? null : industryList(preference.getPreferredIndustryCodes()), + industryList(preference.getPreferredIndustries())); + if (!industries.isEmpty() && job.getCompanyId() != null) { + String industryCode = companyRepository.findById(job.getCompanyId()) + .map(com.mydreamjob.company.Company::getIndustryCode).orElse(null); + if (industryCode != null && industries.contains(industryCode)) { score += 10; matched.add("行业偏好匹配"); } + } } // 规则5:岗位类型偏好(参考项) @@ -163,4 +165,14 @@ public class MatchService { return jsonArray.toLowerCase().contains(target.toLowerCase()); } } + private List 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>() {}); + } catch (Exception ignored) { return List.of(); } + } } diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/messaging/ConversationSummaryService.java b/mydreamjob-server/src/main/java/com/mydreamjob/messaging/ConversationSummaryService.java index dcc70c6..b72eb12 100644 --- a/mydreamjob-server/src/main/java/com/mydreamjob/messaging/ConversationSummaryService.java +++ b/mydreamjob-server/src/main/java/com/mydreamjob/messaging/ConversationSummaryService.java @@ -5,7 +5,7 @@ import java.util.*; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; -/** 对已完成成员鉴权的会话批量补展示摘要;不查询手机号、实名、简历或虚构在线状态。 */ +/** 对已完成成员鉴权的会话批量补展示摘要;非匿名沟通使用资料姓名,不查询联系方式或简历正文。 */ @Service @RequiredArgsConstructor public class ConversationSummaryService { @PersistenceContext private EntityManager em; @@ -15,9 +15,10 @@ public class ConversationSummaryService { List ids = views.stream().map(v -> (Long)v.get("id")).toList(); Map> contacts = new HashMap<>(); em.createQuery(""" - select m.conversationId, u.nickname, u.avatarUrl, c.name, c.logoUrl + select m.conversationId, u.nickname, u.avatarUrl, c.name, c.logoUrl, sp.realName from ConversationMember m join User u on u.id = m.userId left join Company c on c.ownerUserId = u.id + left join StudentProfile sp on sp.userId = u.id where m.conversationId in :ids and m.userId <> :userId order by m.conversationId, m.id """,Object[].class).setParameter("ids",ids).setParameter("userId",userId) @@ -31,15 +32,14 @@ public class ConversationSummaryService { """,Object[].class).setParameter("ids",ids).getResultList() .forEach(r -> last.put((Long)r[0],r)); for (Map view : views) { - boolean invitation = "TALENT_INVITATION".equals(view.get("type")); var peers = contacts.getOrDefault(view.get("id"),List.of()); String name = "会话成员"; if (peers.size() == 1) { Object[] peer = peers.get(0); - // 邀约候选人隐藏昵称/头像;公司公开展示名可见,绝不倒查求职者身份。 - name = peer[3] != null ? peer[3].toString() : invitation ? "求职者" : text(peer[1],"求职者"); + // 普通与邀约沟通统一展示:公司优先,其余使用资料姓名→昵称→兜底。 + name = peer[3] != null ? peer[3].toString() : text(peer[5],text(peer[1],"求职者")); if (peer[3] != null) view.put("companyName",peer[3]); - Object avatar = peer[3] != null ? peer[4] : invitation ? null : peer[2]; + Object avatar = peer[3] != null ? peer[4] : peer[2]; if (avatar != null) view.put("avatarUrl",avatar); } view.put("contactName",name); @@ -55,6 +55,6 @@ public class ConversationSummaryService { } } private String text(Object value,String fallback) { - return value == null || value.toString().isBlank() ? fallback : value.toString(); + return value == null || value.toString().isBlank() ? fallback : value.toString().strip(); } } diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/messaging/MessagingService.java b/mydreamjob-server/src/main/java/com/mydreamjob/messaging/MessagingService.java index 9b8bc8c..33b350f 100644 --- a/mydreamjob-server/src/main/java/com/mydreamjob/messaging/MessagingService.java +++ b/mydreamjob-server/src/main/java/com/mydreamjob/messaging/MessagingService.java @@ -289,6 +289,16 @@ public class MessagingService { getConversation(conversationId, userId); if (!"DIRECT".equals(requireConversation(conversationId).getType())) throw new BusinessException(ErrorCode.FORBIDDEN, "attachments require a DIRECT conversation"); + assertPeerAccess(conversationId, userId); + } + + /** 非匿名会话头像读取仍复用沟通门禁,不扩大附件发送或下载范围。 */ + public void assertProfileImageAccess(Long conversationId, Long userId) { + getConversation(conversationId, userId); + assertPeerAccess(conversationId, userId); + } + + private void assertPeerAccess(Long conversationId, Long userId) { for (ConversationMember member : memberRepository.findByConversationId(conversationId)) { if (!member.getUserId().equals(userId)) { assertNotBlockedBetween(userId, member.getUserId()); diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/onboarding/CompanyKnowledgeService.java b/mydreamjob-server/src/main/java/com/mydreamjob/onboarding/CompanyKnowledgeService.java index 78f28de..b7db28a 100644 --- a/mydreamjob-server/src/main/java/com/mydreamjob/onboarding/CompanyKnowledgeService.java +++ b/mydreamjob-server/src/main/java/com/mydreamjob/onboarding/CompanyKnowledgeService.java @@ -373,6 +373,7 @@ public class CompanyKnowledgeService { .logoUrl(company.getLogoUrl()) .description(company.getDescription()) .industry(company.getIndustry()) + .industryCode(company.getIndustryCode()) .website(company.getWebsite()) .address(company.getAddress()) .verified(company.getVerified()) diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/onboarding/dto/CompanyKnowledgeOverviewResponse.java b/mydreamjob-server/src/main/java/com/mydreamjob/onboarding/dto/CompanyKnowledgeOverviewResponse.java index ba63c91..dbbce4b 100644 --- a/mydreamjob-server/src/main/java/com/mydreamjob/onboarding/dto/CompanyKnowledgeOverviewResponse.java +++ b/mydreamjob-server/src/main/java/com/mydreamjob/onboarding/dto/CompanyKnowledgeOverviewResponse.java @@ -32,6 +32,7 @@ public class CompanyKnowledgeOverviewResponse { private String logoUrl; private String description; private String industry; + private String industryCode; private String website; private String address; private Boolean verified; diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/onlineresume/OnlineResumeContent.java b/mydreamjob-server/src/main/java/com/mydreamjob/onlineresume/OnlineResumeContent.java index efde76f..46a1b4e 100644 --- a/mydreamjob-server/src/main/java/com/mydreamjob/onlineresume/OnlineResumeContent.java +++ b/mydreamjob-server/src/main/java/com/mydreamjob/onlineresume/OnlineResumeContent.java @@ -41,6 +41,7 @@ public class OnlineResumeContent { @NotNull @Size(max = 20) private List<@NotBlank @Size(max = 100) String> preferredCities = new ArrayList<>(); @NotNull @Size(max = 20) private List<@NotBlank @Size(max = 100) String> employmentTypes = new ArrayList<>(); @NotNull @Size(max = 20) private List<@NotBlank @Size(max = 100) String> preferredIndustries = new ArrayList<>(); + @Size(max = 20) private List<@NotBlank @Size(max = 64) String> preferredIndustryCodes; @NotNull @Size(max = 20) private List<@NotBlank @Size(max = 100) String> preferredJobTypes = new ArrayList<>(); private boolean remoteAllowed; private JobSeekingStatus jobSeekingStatus; diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/onlineresume/OnlineResumeService.java b/mydreamjob-server/src/main/java/com/mydreamjob/onlineresume/OnlineResumeService.java index 37c51fa..8df7304 100644 --- a/mydreamjob-server/src/main/java/com/mydreamjob/onlineresume/OnlineResumeService.java +++ b/mydreamjob-server/src/main/java/com/mydreamjob/onlineresume/OnlineResumeService.java @@ -96,6 +96,12 @@ public class OnlineResumeService { private OnlineResumeView persist(User user, OnlineResume row, OnlineResumeContent content, OnlineResumeContent before) { content = copy(content); validate(content); + var industryIntention = content.getJobIntention(); + var industryCodes = com.mydreamjob.industry.IndustryCatalog.preferenceCodes( + industryIntention.getPreferredIndustryCodes(), industryIntention.getPreferredIndustries()); + if (industryIntention.getPreferredIndustryCodes() != null) industryIntention.setPreferredIndustries( + com.mydreamjob.industry.IndustryCatalog.preferenceNames(industryCodes, industryIntention.getPreferredIndustries())); + industryIntention.setPreferredIndustryCodes(industryCodes); Set skillNames = new HashSet<>(); for (var skill : content.getSkills()) { skill.setName(skill.getName().trim()); @@ -126,6 +132,7 @@ public class OnlineResumeService { pref.setExpectedSalaryMin(intention.getExpectedSalaryMin()); pref.setExpectedSalaryMax(intention.getExpectedSalaryMax()); pref.setPreferredCities(encode(intention.getPreferredCities())); pref.setEmploymentTypes(encode(intention.getEmploymentTypes())); pref.setPreferredIndustries(encode(intention.getPreferredIndustries())); pref.setPreferredJobTypes(encode(intention.getPreferredJobTypes())); + pref.setPreferredIndustryCodes(encode(intention.getPreferredIndustryCodes())); pref.setRemoteAllowed(intention.isRemoteAllowed()); preferences.save(pref); if (row == null) { row = new OnlineResume(); row.setUserId(uid); } row.setContentJson(json); row.setRevision(Math.addExact(row.getRevision(), 1)); @@ -174,6 +181,9 @@ public class OnlineResumeService { intention.setExpectedSalaryMin(pref.getExpectedSalaryMin()); intention.setExpectedSalaryMax(pref.getExpectedSalaryMax()); intention.setPreferredCities(legacySkills(pref.getPreferredCities())); intention.setEmploymentTypes(legacySkills(pref.getEmploymentTypes())); intention.setPreferredIndustries(legacySkills(pref.getPreferredIndustries())); intention.setPreferredJobTypes(legacySkills(pref.getPreferredJobTypes())); + intention.setPreferredIndustryCodes(pref.getPreferredIndustryCodes() == null + ? com.mydreamjob.industry.IndustryCatalog.preferenceCodes(null, intention.getPreferredIndustries()) + : legacySkills(pref.getPreferredIndustryCodes())); intention.setRemoteAllowed(Boolean.TRUE.equals(pref.getRemoteAllowed())); }); OnlineResumeView.Attachment attachment = p == null || p.getResumeUrl() == null ? null : diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/preference/PreferenceService.java b/mydreamjob-server/src/main/java/com/mydreamjob/preference/PreferenceService.java index 855f7f4..2278e65 100644 --- a/mydreamjob-server/src/main/java/com/mydreamjob/preference/PreferenceService.java +++ b/mydreamjob-server/src/main/java/com/mydreamjob/preference/PreferenceService.java @@ -71,11 +71,17 @@ public class PreferenceService { throw new BusinessException(ErrorCode.BAD_REQUEST, "invalid merged salary range"); } + var industryCodes = com.mydreamjob.industry.IndustryCatalog.preferenceCodes(req.getPreferredIndustryCodes(), req.getPreferredIndustries()); if (req.getExpectedSalaryMin() != null) pref.setExpectedSalaryMin(req.getExpectedSalaryMin()); if (req.getExpectedSalaryMax() != null) pref.setExpectedSalaryMax(req.getExpectedSalaryMax()); if (req.getPreferredCities() != null) pref.setPreferredCities(toJson(req.getPreferredCities())); if (req.getEmploymentTypes() != null) pref.setEmploymentTypes(toJson(req.getEmploymentTypes())); if (req.getPreferredIndustries() != null) pref.setPreferredIndustries(toJson(req.getPreferredIndustries())); + if (req.getPreferredIndustries() != null || req.getPreferredIndustryCodes() != null) { + pref.setPreferredIndustryCodes(toJson(industryCodes)); + if (req.getPreferredIndustryCodes() != null) pref.setPreferredIndustries(toJson( + com.mydreamjob.industry.IndustryCatalog.preferenceNames(industryCodes, req.getPreferredIndustries()))); + } if (req.getPreferredJobTypes() != null) pref.setPreferredJobTypes(toJson(req.getPreferredJobTypes())); if (req.getRemoteAllowed() != null) pref.setRemoteAllowed(req.getRemoteAllowed()); @@ -97,6 +103,11 @@ public class PreferenceService { view.put("preferredCities", fromJson(pref.getPreferredCities())); view.put("employmentTypes", fromJson(pref.getEmploymentTypes())); view.put("preferredIndustries", fromJson(pref.getPreferredIndustries())); + view.put("preferredIndustryCodes", pref.getPreferredIndustryCodes() == null + ? com.mydreamjob.industry.IndustryCatalog.preferenceCodes(null, fromJson(pref.getPreferredIndustries())) + : fromJson(pref.getPreferredIndustryCodes())); + view.put("unmappedPreferredIndustries", fromJson(pref.getPreferredIndustries()).stream() + .filter(name -> com.mydreamjob.industry.IndustryCatalog.resolveLegacy(name) == null).toList()); view.put("preferredJobTypes", fromJson(pref.getPreferredJobTypes())); view.put("remoteAllowed", pref.getRemoteAllowed()); view.put("createdAt", pref.getCreatedAt()); @@ -111,6 +122,8 @@ public class PreferenceService { view.put("preferredCities", List.of()); view.put("employmentTypes", List.of()); view.put("preferredIndustries", List.of()); + view.put("preferredIndustryCodes", List.of()); + view.put("unmappedPreferredIndustries", List.of()); view.put("preferredJobTypes", List.of()); view.put("remoteAllowed", false); return view; @@ -127,7 +140,11 @@ public class PreferenceService { private List fromJson(String json) { if (json == null || json.isBlank()) return List.of(); try { - return mapper.readValue(json, new TypeReference>() {}); + 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>() {}); } catch (JsonProcessingException e) { return List.of(); } diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/preference/StudentPreference.java b/mydreamjob-server/src/main/java/com/mydreamjob/preference/StudentPreference.java index 5610f13..e3d0123 100644 --- a/mydreamjob-server/src/main/java/com/mydreamjob/preference/StudentPreference.java +++ b/mydreamjob-server/src/main/java/com/mydreamjob/preference/StudentPreference.java @@ -42,6 +42,9 @@ public class StudentPreference { @Column(name = "preferred_industries", columnDefinition = "json") private String preferredIndustries; + @Column(name = "preferred_industry_codes", columnDefinition = "json") + private String preferredIndustryCodes; + @Column(name = "preferred_job_types", columnDefinition = "json") private String preferredJobTypes; diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/preference/dto/StudentPreferenceRequest.java b/mydreamjob-server/src/main/java/com/mydreamjob/preference/dto/StudentPreferenceRequest.java index 08748a8..30369e5 100644 --- a/mydreamjob-server/src/main/java/com/mydreamjob/preference/dto/StudentPreferenceRequest.java +++ b/mydreamjob-server/src/main/java/com/mydreamjob/preference/dto/StudentPreferenceRequest.java @@ -31,6 +31,9 @@ public class StudentPreferenceRequest { @Size(max = 20, message = "preferredIndustries must have at most 20 items") private List<@Size(min = 1, max = 100, message = "each industry must be 1-100 chars") String> preferredIndustries; + @Size(max = 20) + private List<@jakarta.validation.constraints.NotBlank @Size(max = 64) String> preferredIndustryCodes; + @Size(max = 20, message = "preferredJobTypes must have at most 20 items") private List<@Size(min = 1, max = 100, message = "each job type must be 1-100 chars") String> preferredJobTypes; diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/profileimage/ProfileImageController.java b/mydreamjob-server/src/main/java/com/mydreamjob/profileimage/ProfileImageController.java index 0f443f0..4dfefc5 100644 --- a/mydreamjob-server/src/main/java/com/mydreamjob/profileimage/ProfileImageController.java +++ b/mydreamjob-server/src/main/java/com/mydreamjob/profileimage/ProfileImageController.java @@ -47,7 +47,7 @@ public class ProfileImageController { return ApiResponse.ok(Map.of("deleted", true), requestIds.get(request)); } - @Operation(operationId = "readProfileImage", summary = "读取头像或企业Logo", description = "企业Logo匿名可读;个人头像须携带Bearer且仅本人或满足当前沟通授权的DIRECT成员可读,非公开用户头像目录。旧地址替换/删除后404;no-store。") + @Operation(operationId = "readProfileImage", summary = "读取头像或企业Logo", description = "企业Logo匿名可读;个人头像须携带Bearer且仅本人或满足当前沟通授权的DIRECT或TALENT_INVITATION成员可读,仍校验拉黑、撤回和企业接收门禁,非公开用户头像目录;不扩大邀约附件权限。旧地址替换/删除后404;no-store。") @io.swagger.v3.oas.annotations.security.SecurityRequirements @GetMapping("/profile-images/{imageId}") public ResponseEntity read(@PathVariable String imageId) { diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/profileimage/ProfileImageService.java b/mydreamjob-server/src/main/java/com/mydreamjob/profileimage/ProfileImageService.java index 261166e..3447efc 100644 --- a/mydreamjob-server/src/main/java/com/mydreamjob/profileimage/ProfileImageService.java +++ b/mydreamjob-server/src/main/java/com/mydreamjob/profileimage/ProfileImageService.java @@ -84,14 +84,14 @@ public class ProfileImageService { for (ConversationMember member : members.findByUserId(actor)) { Long id = member.getConversationId(); var conversation = conversations.findById(id).orElse(null); - if (conversation == null || !"DIRECT".equals(conversation.getType()) + if (conversation == null || !("DIRECT".equals(conversation.getType()) || "TALENT_INVITATION".equals(conversation.getType())) || !members.existsByConversationIdAndUserId(id, owner)) continue; try { - // 复用成员、拉黑、撤回与企业接收门禁;邀约会话不释放候选人的个人头像。 - messaging.assertAttachmentAccess(id, actor); + // 非匿名邀约同样展示头像,但不能绕过成员、拉黑、撤回或企业接收门禁。 + messaging.assertProfileImageAccess(id, actor); return true; } catch (BusinessException denied) { - // 另一条 DIRECT 会话可能仍有合法授权;数据库/系统异常不会在此吞掉。 + // 另一条会话可能仍有合法授权;数据库/系统异常不会在此吞掉。 } } return false; diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/recommendation/JobRecommendationService.java b/mydreamjob-server/src/main/java/com/mydreamjob/recommendation/JobRecommendationService.java index 252b6c3..8874bfe 100644 --- a/mydreamjob-server/src/main/java/com/mydreamjob/recommendation/JobRecommendationService.java +++ b/mydreamjob-server/src/main/java/com/mydreamjob/recommendation/JobRecommendationService.java @@ -58,7 +58,9 @@ public class JobRecommendationService { @Transactional(readOnly = true) public List taxonomies() { return taxonomyRepository.findByEnabledTrueOrderBySortOrderAscCodeAsc().stream().map(t -> TaxonomyResponse.builder() - .code(t.getCode()).parentCode(t.getParentCode()).type(t.getTaxonomyType()).displayName(t.getDisplayName()).build()).toList(); + .code(t.getCode()).parentCode("JOB_FAMILY".equals(t.getTaxonomyType()) ? null : t.getParentCode()) + .type(t.getTaxonomyType()).displayName("INDUSTRY".equals(t.getTaxonomyType()) + ? com.mydreamjob.industry.IndustryCatalog.displayName(t.getCode(), t.getDisplayName()) : t.getDisplayName()).build()).toList(); } @Transactional(readOnly = true) @@ -189,11 +191,11 @@ public class JobRecommendationService { list(preference.getPreferredCities()), list(preference.getEmploymentTypes()), preference.getExpectedSalaryMin(), - list(preference.getPreferredIndustries()), + normalizedIndustries(preference), list(preference.getPreferredJobTypes())); JobPreferenceScoreCalculator.Target target = new JobPreferenceScoreCalculator.Target( job.getCity(), job.getEmploymentType(), job.getSalaryMax(), - profile.getIndustryCode(), profile.getJobFamilyCode()); + normalizedIndustryTarget(profile.getIndustryCode()), profile.getJobFamilyCode()); return JobPreferenceScoreCalculator.calculate(criteria, target); } private BigDecimal weighted(Map scores,Map expected,Set 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 mapDecimal(String raw){try{if(raw==null)return Map.of();Mapm=JSON.readValue(raw,new TypeReference<>(){});Mapo=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 list(String raw){try{return raw==null?List.of():JSON.readValue(raw,new TypeReference>(){});}catch(Exception e){return List.of();}} private String write(Object o){try{return o==null?null:JSON.writeValueAsString(o);}catch(Exception e){throw new BusinessException(ErrorCode.RECOMMENDATION_PROFILE_INVALID);}} - private void validateTaxonomy(String industry,String family){JobRecommendationTaxonomy i=taxonomyRepository.findById(industry).orElseThrow(()->new BusinessException(ErrorCode.RECOMMENDATION_TAXONOMY_INVALID));JobRecommendationTaxonomy f=taxonomyRepository.findById(family).orElseThrow(()->new BusinessException(ErrorCode.RECOMMENDATION_TAXONOMY_INVALID));if(!Boolean.TRUE.equals(i.getEnabled())||!Boolean.TRUE.equals(f.getEnabled())||!"INDUSTRY".equals(i.getTaxonomyType())||!"JOB_FAMILY".equals(f.getTaxonomyType())||!industry.equals(f.getParentCode()))throw new BusinessException(ErrorCode.RECOMMENDATION_TAXONOMY_INVALID);} + private List 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 r,Mapa){if((r==null||r.isEmpty())&&(a==null||a.isEmpty()))throw new BusinessException(ErrorCode.RECOMMENDATION_PROFILE_INVALID);if(r!=null&&(!RIASEC.containsAll(r.keySet())||r.values().stream().anyMatch(v->v==null||v<0||v>100)))throw new BusinessException(ErrorCode.RECOMMENDATION_PROFILE_INVALID);if(a!=null&&(!ABILITY.containsAll(a.keySet())||a.values().stream().anyMatch(v->v==null||v<1||v>100)))throw new BusinessException(ErrorCode.RECOMMENDATION_PROFILE_INVALID);} private void assertCompanyOwns(Long user, Long job) { Company company = companyService.findCompanyByOwnerUserId(user); diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/skill/SkillCloudQueryService.java b/mydreamjob-server/src/main/java/com/mydreamjob/skill/SkillCloudQueryService.java index f2ae2e5..c0d185f 100644 --- a/mydreamjob-server/src/main/java/com/mydreamjob/skill/SkillCloudQueryService.java +++ b/mydreamjob-server/src/main/java/com/mydreamjob/skill/SkillCloudQueryService.java @@ -91,7 +91,7 @@ public class SkillCloudQueryService { .toList(); return new SkillCloudResponse( - toNode(industry.getId(), industry.getCode(), industry.getNameZh()), + toNode(industry.getId(), industry.getCode(), com.mydreamjob.industry.IndustryCatalog.displayName(industry.getCode(), industry.getNameZh())), toNode(occupation.getId(), occupation.getCode(), occupation.getNameZh()), category == null ? null : toNode(category.getId(), category.getCode(), category.getNameZh()), items.size(), diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/talentdiscovery/TalentDiscoveryService.java b/mydreamjob-server/src/main/java/com/mydreamjob/talentdiscovery/TalentDiscoveryService.java index 3c28458..0217c39 100644 --- a/mydreamjob-server/src/main/java/com/mydreamjob/talentdiscovery/TalentDiscoveryService.java +++ b/mydreamjob-server/src/main/java/com/mydreamjob/talentdiscovery/TalentDiscoveryService.java @@ -195,13 +195,13 @@ public class TalentDiscoveryService { preference.cities(), preference.employmentTypes(), preference.expectedSalaryMin(), - preference.industries(), + com.mydreamjob.industry.IndustryCatalog.scoringCriteria(null, preference.industries()), preference.jobTypes()), new JobPreferenceScoreCalculator.Target( job.getCity(), job.getEmploymentType(), job.getSalaryMax(), - profile.getIndustryCode(), + com.mydreamjob.industry.IndustryCatalog.scoringTarget(profile.getIndustryCode()), profile.getJobFamilyCode())); } diff --git a/mydreamjob-server/src/main/resources/catalog/industries-v1.json b/mydreamjob-server/src/main/resources/catalog/industries-v1.json new file mode 100644 index 0000000..4e3899d --- /dev/null +++ b/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": [] + } + ] +} diff --git a/mydreamjob-server/src/main/resources/db/migration/V65__unified_recruitment_industries.sql b/mydreamjob-server/src/main/resources/db/migration/V65__unified_recruitment_industries.sql new file mode 100644 index 0000000..32f0cae --- /dev/null +++ b/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); diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/application/CompanyResumeDownloadIntegrationTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/application/CompanyResumeDownloadIntegrationTest.java index 13274e0..93cbb38 100644 --- a/mydreamjob-server/src/test/java/com/mydreamjob/application/CompanyResumeDownloadIntegrationTest.java +++ b/mydreamjob-server/src/test/java/com/mydreamjob/application/CompanyResumeDownloadIntegrationTest.java @@ -503,8 +503,26 @@ class CompanyResumeDownloadIntegrationTest { } assertThat(modulePaths).as("N03/N04/N05 新模块路径").isEqualTo(13); assertThat(moduleOperations).as("N03/N04/N05 新模块操作").isEqualTo(18); - assertThat(pathCount - modulePaths).as("非模块路径包含头像/Logo及通知回收站").isEqualTo(208); - assertThat(opCount - moduleOperations).as("非模块操作包含头像/Logo及通知回收站").isEqualTo(253); + var staticPaths = new com.fasterxml.jackson.databind.ObjectMapper().readTree(java.nio.file.Files.readString( + java.nio.file.Path.of("../docs/openapi-rc.json"))).path("paths"); + var expectedPaths = new java.util.HashSet(); + var actualPaths = new java.util.HashSet(); + var expectedOperations = new java.util.HashSet(); + var actualOperations = new java.util.HashSet(); + staticPaths.fields().forEachRemaining(entry -> { + expectedPaths.add(entry.getKey()); + entry.getValue().fieldNames().forEachRemaining(method -> { + if (java.util.Set.of("get", "post", "put", "delete", "patch").contains(method)) expectedOperations.add(method + " " + entry.getKey()); + }); + }); + paths.fields().forEachRemaining(entry -> { + actualPaths.add(entry.getKey()); + entry.getValue().fieldNames().forEachRemaining(method -> { + if (java.util.Set.of("get", "post", "put", "delete", "patch").contains(method)) actualOperations.add(method + " " + entry.getKey()); + }); + }); + assertThat(actualPaths).as("运行时完整路径集与静态契约一致,含统一行业目录").isEqualTo(expectedPaths); + assertThat(actualOperations).as("运行时完整操作集与静态契约一致,不靠旧全局计数").isEqualTo(expectedOperations); var download = paths.path("/company/candidates/{applicationId}/resume").path("get"); assertThat(download.isMissingNode()).as("必须存在企业简历下载 GET").isFalse(); diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/auth/identity/IdentityPolicyTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/auth/identity/IdentityPolicyTest.java index 51d891e..3568957 100644 --- a/mydreamjob-server/src/test/java/com/mydreamjob/auth/identity/IdentityPolicyTest.java +++ b/mydreamjob-server/src/test/java/com/mydreamjob/auth/identity/IdentityPolicyTest.java @@ -20,7 +20,7 @@ class IdentityPolicyTest { void businessGetIsDenied(String path) { assertThat(new IdentityAccessFilter(null,null,null,null).allowed("GET",path)).isFalse(); } - @ParameterizedTest @ValueSource(strings={"/jobs","/jobs/search","/jobs/filters","/companies/42", + @ParameterizedTest @ValueSource(strings={"/jobs","/jobs/search","/jobs/filters","/jobs/industry-catalog","/companies/42", "/companies/42/jobs","/auth/me","/v3/api-docs","/health"}) void publicGetRemainsAccessible(String path) { assertThat(new IdentityAccessFilter(null,null,null,null).allowed("GET",path)).isTrue(); diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/auth/identity/UnifiedRegistrationIntegrationTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/auth/identity/UnifiedRegistrationIntegrationTest.java index 8f6d0d3..14f268f 100644 --- a/mydreamjob-server/src/test/java/com/mydreamjob/auth/identity/UnifiedRegistrationIntegrationTest.java +++ b/mydreamjob-server/src/test/java/com/mydreamjob/auth/identity/UnifiedRegistrationIntegrationTest.java @@ -183,7 +183,7 @@ class UnifiedRegistrationIntegrationTest { java.nio.file.Files.writeString(java.nio.file.Path.of("target/unified-openapi-runtime.json"),mapper.writeValueAsString(doc)); String token=login("19900009410").path("accessToken").asText(); // 独立列出已批准的公开契约,不调用生产 gate.allowed 生成期望值。 - Set publicGet=Set.of("/","/health","/health/details","/jobs","/jobs/search","/jobs/filters", + Set publicGet=Set.of("/","/health","/health/details","/jobs","/jobs/search","/jobs/filters","/jobs/industry-catalog", "/jobs/compare","/jobs/public/{jobId}","/jobs/{jobId}/reviews","/jobs/{jobId}/reviews/{reviewId}", "/jobs/{jobId}/reviews/summary","/companies/{companyId}","/companies/{companyId}/jobs", "/job-recommendation-taxonomies","/auth/me","/profile-images/{imageId}"); @@ -191,6 +191,8 @@ class UnifiedRegistrationIntegrationTest { "/auth/password/change","/auth/login","/auth/password/login","/auth/password/reset", "/auth/refresh","/auth/sms/send","/auth/company/register"); int denied=0; + mvc.perform(get("/jobs/industry-catalog").header("Authorization", "Bearer " + token)) + .andExpect(status().isOk()).andExpect(jsonPath("$.data.version").value("recruitment-industry-v1")); int moduleDenied=0; var paths=doc.path("paths").fields(); while(paths.hasNext()) { diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/industry/IndustryCatalogTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/industry/IndustryCatalogTest.java new file mode 100644 index 0000000..9a4a7ea --- /dev/null +++ b/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())); + } +} diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/industry/IndustryMySqlMigrationTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/industry/IndustryMySqlMigrationTest.java new file mode 100644 index 0000000..4a88cf0 --- /dev/null +++ b/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 history(Statement sql) throws SQLException { + var result = new ArrayList(); + 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)); + } + } + } +} diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/industry/UnifiedIndustryIntegrationTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/industry/UnifiedIndustryIntegrationTest.java new file mode 100644 index 0000000..29180bc --- /dev/null +++ b/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 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 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()); + } +} diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/job/JobDisplayFieldsConsistencyIntegrationTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/job/JobDisplayFieldsConsistencyIntegrationTest.java index c8b7405..5497c6f 100644 --- a/mydreamjob-server/src/test/java/com/mydreamjob/job/JobDisplayFieldsConsistencyIntegrationTest.java +++ b/mydreamjob-server/src/test/java/com/mydreamjob/job/JobDisplayFieldsConsistencyIntegrationTest.java @@ -55,7 +55,7 @@ class JobDisplayFieldsConsistencyIntegrationTest { private static final String PHONE_PREFIX = "1990078"; private static final String CITY = "CONSISTENCYCITY"; private static final Set 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 redis; @Autowired private MockMvc mvc; @@ -201,7 +201,7 @@ class JobDisplayFieldsConsistencyIntegrationTest { // ── 4. 公开企业摘要白名单 ─────────────────────────────────── @Test - void publicCompanySummaryIsExactlySevenFieldWhitelist() throws Exception { + void publicCompanySummaryIsExactlyEightFieldWhitelist() throws Exception { long jobId = createFullJob("白名单岗位"); publish(jobId); diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/match/MatchServiceTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/match/MatchServiceTest.java index 4b957be..21839cf 100644 --- a/mydreamjob-server/src/test/java/com/mydreamjob/match/MatchServiceTest.java +++ b/mydreamjob-server/src/test/java/com/mydreamjob/match/MatchServiceTest.java @@ -36,6 +36,7 @@ class MatchServiceTest { @Mock private StudentProfileRepository profileRepository; @Mock private AuditService auditService; @Mock private UserRepository userRepository; + @Mock private com.mydreamjob.company.CompanyRepository companyRepository; /** R01-B/B1:角色授权已委托给公共 guard;此处以委托桩保持既有断言语义。 */ @Mock private UserRoleGuard userRoleGuard; @@ -70,12 +71,25 @@ class MatchServiceTest { .expectedSalaryMin(10000).preferredIndustries("[\"互联网\"]") .preferredJobTypes("[\"Java\"]").remoteAllowed(false).build(); when(preferenceRepository.findByStudentUserId(STUDENT_ID)).thenReturn(Optional.of(p)); + when(companyRepository.findById(1L)).thenReturn(Optional.of(com.mydreamjob.company.Company.builder().industryCode("INTERNET").build())); } private void mockProfile() { when(profileRepository.findByUserId(STUDENT_ID)).thenReturn(Optional.of( StudentProfile.builder().userId(STUDENT_ID).build())); } + @Test void industryMatchesCompanyCodeNotSoftwareJobDescription() { + mockRole(STUDENT_ID, User.Role.STUDENT); + when(jobService.getJob(JOB_ID)).thenReturn(publishedJob); + when(preferenceRepository.findByStudentUserId(STUDENT_ID)).thenReturn(Optional.of(StudentPreference.builder() + .preferredIndustryCodes("[\"FINANCE\"]").preferredIndustries("[]").build())); + publishedJob.setDescription("制造业的软件工程师职位描述"); + when(companyRepository.findById(1L)).thenReturn(Optional.of(com.mydreamjob.company.Company.builder().industryCode("FINANCE_BANKING").build())); + assertTrue(((java.util.List) matchService.diagnose(STUDENT_ID, JOB_ID).get("matchedConditions")).contains("行业偏好匹配")); + when(companyRepository.findById(1L)).thenReturn(Optional.of(com.mydreamjob.company.Company.builder().industryCode("MANUFACTURING").build())); + publishedJob.setDescription("金融行业职位描述"); + assertFalse(((java.util.List) matchService.diagnose(STUDENT_ID, JOB_ID).get("matchedConditions")).contains("行业偏好匹配")); + } // ── 角色校验 ───────────────────────────────────────────── diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/messaging/ChatWorkflowIntegrationTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/messaging/ChatWorkflowIntegrationTest.java index 6f75f31..7ac255d 100644 --- a/mydreamjob-server/src/test/java/com/mydreamjob/messaging/ChatWorkflowIntegrationTest.java +++ b/mydreamjob-server/src/test/java/com/mydreamjob/messaging/ChatWorkflowIntegrationTest.java @@ -156,7 +156,7 @@ class ChatWorkflowIntegrationTest { denied(()->messaging.sendMessage(student.getId(),text),1001); assertNotNull(sent.getId()); } - @Test void summaryHasRealPeerAndRedactedInvitationView() { + @Test void summaryHasRealPeerAndNonAnonymousInvitationDisplay() { send("summary-1"); var forStudent=messaging.getConversation(conversation.getId(),student.getId()); assertEquals("测试企业",forStudent.get("contactName"));assertEquals("[简历]",forStudent.get("lastMessage")); @@ -164,11 +164,59 @@ class ChatWorkflowIntegrationTest { assertEquals("候选人昵称",forHr.get("contactName"));assertFalse(forHr.containsKey("online")); conversation.setType("TALENT_INVITATION");em.flush(); var anonymous=messaging.getConversation(conversation.getId(),employer.getId()); - assertEquals("求职者",anonymous.get("contactName"));assertFalse(anonymous.containsKey("createdBy")); + assertEquals("候选人昵称",anonymous.get("contactName"));assertFalse(anonymous.containsKey("createdBy")); var message=messaging.listMessages(conversation.getId(),employer.getId(),0,20).getItems().get(0); assertNull(message.getSenderUserId());assertEquals("OTHER_PARTICIPANT",message.getSenderScope()); assertEquals("resume.pdf",message.getResume().fileName()); } + @Test void displayUsesCurrentProfileNameForDirectAndInvitationWithoutChangingNickname() { + student.setAvatarUrl("/api/v1/profile-images/synthetic-avatar"); + for (String type : List.of("DIRECT", "TALENT_INVITATION")) { + conversation.setType(type); + profile.setRealName(" 新姓名 "); em.flush(); + var detail=messaging.getConversation(conversation.getId(),employer.getId()); + assertEquals("新姓名",detail.get("contactName")); + assertEquals(student.getAvatarUrl(),detail.get("avatarUrl")); + assertEquals("新姓名",messaging.listConversations(employer.getId()).get(0).get("contactName")); + assertEquals("候选人昵称",student.getNickname()); + assertFalse(detail.containsKey("realName")); assertFalse(detail.containsKey("phone")); + profile.setRealName("再次修改");em.flush(); + assertEquals("再次修改",messaging.getConversation(conversation.getId(),employer.getId()).get("contactName")); + } + } + @Test void missingAndBlankProfileNamesFallBackToNicknameThenGenericName() { + for(String name:Arrays.asList(null,""," ")) { + profile.setRealName(name);em.flush(); + assertEquals("候选人昵称",messaging.getConversation(conversation.getId(),employer.getId()).get("contactName")); + } + em.remove(profile);em.flush(); + assertEquals("候选人昵称",messaging.getConversation(conversation.getId(),employer.getId()).get("contactName")); + student.setNickname(" ");em.flush(); + assertEquals("求职者",messaging.getConversation(conversation.getId(),employer.getId()).get("contactName")); + } + @Test void companyDisplayAlwaysUsesCompanyNameAndLogo() { + employer.setAvatarUrl("/hr-avatar");company.setLogoUrl("/company-logo"); + em.persist(StudentProfile.builder().userId(employer.getId()).realName("历史学生资料名").build());em.flush(); + for(String type:List.of("DIRECT","TALENT_INVITATION")) { + conversation.setType(type);em.flush(); + var view=messaging.getConversation(conversation.getId(),student.getId()); + assertEquals("测试企业",view.get("contactName"));assertEquals("/company-logo",view.get("avatarUrl")); + } + } + @Test void nonMemberCannotReadNamesAndInvitationStillCannotReadAttachments() { + profile.setRealName("仅成员可见");conversation.setType("TALENT_INVITATION");em.flush(); + denied(()->messaging.getConversation(conversation.getId(),stranger.getId()),2002); + denied(()->messaging.assertAttachmentAccess(conversation.getId(),employer.getId()),2002); + } + @Test void invitationAvatarStillRequiresCommunicationAuthorizationAndRejectsWithdrawal() { + conversation.setType("TALENT_INVITATION"); + matches.delete(matches.findByApplicationId(application.getId()).orElseThrow());em.flush(); + assertThrows(BusinessException.class,()->messaging.assertProfileImageAccess(conversation.getId(),employer.getId())); + application.setCompanyAcceptedAt(java.time.LocalDateTime.now(clock));em.flush(); + assertDoesNotThrow(()->messaging.assertProfileImageAccess(conversation.getId(),employer.getId())); + application.setStatus("WITHDRAWN");em.flush(); + assertThrows(BusinessException.class,()->messaging.assertProfileImageAccess(conversation.getId(),employer.getId())); + } @Test void downloadIsBinaryAndNeverPublicOrCached() throws Exception { var sent=send("http-1"); var token=sessions.createSession(employer,clock.instant(),com.mydreamjob.audit.AuditContext.builder().build()).accessToken(); diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/onlineresume/OnlineResumeIntegrationTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/onlineresume/OnlineResumeIntegrationTest.java index fa52a1a..0179a8c 100644 --- a/mydreamjob-server/src/test/java/com/mydreamjob/onlineresume/OnlineResumeIntegrationTest.java +++ b/mydreamjob-server/src/test/java/com/mydreamjob/onlineresume/OnlineResumeIntegrationTest.java @@ -60,6 +60,25 @@ class OnlineResumeIntegrationTest { return mapper.readTree(action.andExpect(status().isOk()).andReturn().getResponse().getContentAsString(StandardCharsets.UTF_8)).path("data"); } JsonNode read() throws Exception { return data(mvc.perform(get(PATH).header("Authorization", token))); } + @Test void unknownLegacyIndustrySurvivesReadEditSaveRoundTrip() throws Exception { + var c = content(); c.getJobIntention().setPreferredIndustries(List.of("金融", "待确认旧行业")); + data(save(0, c)); + var roundTrip = mapper.treeToValue(read().path("content"), OnlineResumeContent.class); + roundTrip.setSelfEvaluation("只改自我评价"); + var updated = data(save(1, roundTrip)); + assertTrue(updated.path("content").path("jobIntention").path("preferredIndustries").toString().contains("待确认旧行业")); + } + @Test void industryCodesShareRevisionAcrossResumeAndLegacyPreferences() throws Exception { + var c = content(); c.getJobIntention().setPreferredIndustryCodes(List.of("FINANCE_BANKING")); + var saved = data(save(0, c)); + assertEquals("FINANCE_BANKING", saved.path("content").path("jobIntention").path("preferredIndustryCodes").get(0).asText()); + mvc.perform(put("/profile/me/preferences").header("Authorization", token).contentType(MediaType.APPLICATION_JSON) + .content("{\"preferredIndustryCodes\":[\"EDUCATION\"],\"revision\":0}")) + .andExpect(status().isConflict()); + var clear = content(); clear.getJobIntention().setPreferredIndustryCodes(List.of()); + assertEquals(0, data(save(1, clear)).path("content").path("jobIntention").path("preferredIndustryCodes").size()); + assertEquals(List.of(), service.get(user.getId()).content().getJobIntention().getPreferredIndustryCodes()); + } OnlineResumeContent content() { OnlineResumeContent c = new OnlineResumeContent(); c.getBasicInfo().setRealName("合成测试"); c.getBasicInfo().setPhone("13800000000"); c.getBasicInfo().setEmail("resume@example.test"); diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/profileimage/ProfileImageIntegrationTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/profileimage/ProfileImageIntegrationTest.java index 84080c9..45e9fb4 100644 --- a/mydreamjob-server/src/test/java/com/mydreamjob/profileimage/ProfileImageIntegrationTest.java +++ b/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(multipart("/auth/me/avatar").file(image(2, 2))).andExpect(status().isUnauthorized()); } - @Test void invitationCannotRevealCandidateAvatar() throws Exception { + @Test void invitationMembersCanReadAvatarWithoutMakingItPublic() throws Exception { direct(student, peer, "TALENT_INVITATION"); var view = upload("/auth/me/avatar", st); - mvc.perform(get(path(view)).header("Authorization", "Bearer " + pt)).andExpect(status().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 { direct(student, peer, "DIRECT"); @@ -236,11 +258,11 @@ class ProfileImageIntegrationTest { var response = mvc.perform(get("/v3/api-docs")).andExpect(status().isOk()).andReturn().getResponse(); var runtime = json.readTree(response.getContentAsByteArray()).path("paths"); var contract = json.readTree(java.nio.file.Files.readString(java.nio.file.Path.of("../docs/openapi-rc.json"))).path("paths"); - assertEquals(221, runtime.size()); + assertEquals(222, runtime.size()); Set expected = new TreeSet<>(), actual = new TreeSet<>(); contract.fields().forEachRemaining(p -> p.getValue().fieldNames().forEachRemaining(m -> expected.add(m + " " + p.getKey()))); runtime.fields().forEachRemaining(p -> p.getValue().fieldNames().forEachRemaining(m -> actual.add(m + " " + p.getKey()))); - assertEquals(271, actual.size()); assertEquals(expected, actual); + assertEquals(272, actual.size()); assertEquals(expected, actual); assertEquals("uploadAvatar", runtime.path("/auth/me/avatar").path("post").path("operationId").asText()); for (String route : List.of("/auth/me/avatar", "/companies/me/logo", "/profile-images/{imageId}")) { contract.path(route).fields().forEachRemaining(method -> { diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/resume/optimization/WorkExperienceOptimizationSecurityIntegrationTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/resume/optimization/WorkExperienceOptimizationSecurityIntegrationTest.java index 5d048b3..2a4a2c0 100644 --- a/mydreamjob-server/src/test/java/com/mydreamjob/resume/optimization/WorkExperienceOptimizationSecurityIntegrationTest.java +++ b/mydreamjob-server/src/test/java/com/mydreamjob/resume/optimization/WorkExperienceOptimizationSecurityIntegrationTest.java @@ -140,8 +140,8 @@ class WorkExperienceOptimizationSecurityIntegrationTest { var response=http.send(HttpRequest.newBuilder(URI.create("http://127.0.0.1:"+port+"/api/v1/v3/api-docs")).GET().build(),HttpResponse.BodyHandlers.ofString()); assertEquals(200,response.statusCode()); var runtime=mapper.readTree(response.body());var contract=mapper.readTree(Files.readString(Path.of("../docs/openapi-rc.json"))); - assertEquals(221,runtime.path("paths").size()); - assertEquals(operations(contract),operations(runtime));assertEquals(271,operations(runtime).size()); + assertEquals(222,runtime.path("paths").size()); + assertEquals(operations(contract),operations(runtime));assertEquals(272,operations(runtime).size()); var path=runtime.path("paths").path("/profile/me/work-experience-optimization").path("post"); assertTrue(path.path("requestBody").path("required").asBoolean()); assertTrue(path.path("security").toString().contains("bearerAuth")); diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/skill/SkillCloudQueryServiceTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/skill/SkillCloudQueryServiceTest.java index c207304..01dca8a 100644 --- a/mydreamjob-server/src/test/java/com/mydreamjob/skill/SkillCloudQueryServiceTest.java +++ b/mydreamjob-server/src/test/java/com/mydreamjob/skill/SkillCloudQueryServiceTest.java @@ -65,7 +65,7 @@ class SkillCloudQueryServiceTest { assertThat(response.industry().id()).isEqualTo(1L); assertThat(response.industry().code()).isEqualTo("INTERNET"); - assertThat(response.industry().name()).isEqualTo("互联网"); + assertThat(response.industry().name()).isEqualTo("互联网/信息技术"); assertThat(response.occupation().id()).isEqualTo(1L); assertThat(response.occupation().code()).isEqualTo("FRONTEND_DEVELOPER"); assertThat(response.occupation().name()).isEqualTo("前端开发工程师");