# Offer 管理模块接口说明 > 轻松聘 (mydreamjob) · Offer 管理基础模块 > 基础路径:`/api/v1` · 除明确标注外全部接口需 `Authorization: Bearer ` ## 1. 概述 Offer 用于管理企业向求职者发出的录用邀请,覆盖:创建草稿、发送、候选人接受/拒绝/进入洽谈、 到期自动失效、完整状态历史、到期提醒与手动提醒。本轮**不包含**双向匹配、公平性检测、SHAP、 AI 简历/面试、评分算法、数据看板等模块。 ## 2. 状态机 状态统一命名(大小写敏感): | 状态 | 含义 | 是否终态 | |---|---|---| | `DRAFT` | 草稿(企业已创建、未发送) | 否 | | `SENT` | 已发送(等待候选人响应) | 否 | | `NEGOTIATING` | 洽谈中 | 否 | | `ACCEPTED` | 已接受 | **是** | | `REJECTED` | 已拒绝 | **是** | | `EXPIRED` | 已过期(定时任务触发) | **是** | 状态流转: ```text DRAFT ──(企业 SEND)──> SENT ──(候选人)→ NEGOTIATING │──(候选人)→ ACCEPTED │──(候选人)→ REJECTED └──(系统任务)→ EXPIRED NEGOTIATING ──(候选人)→ ACCEPTED ──(候选人)→ REJECTED ──(系统任务)→ EXPIRED ``` 禁止规则: - `ACCEPTED` 后再变更为其他状态; - `REJECTED` / `EXPIRED` 后接受; - `DRAFT` 直接跳转到 `ACCEPTED/REJECTED/NEGOTIATING`(非法流转,409); - 已发送(`SENT` 及以后)修改 Offer 核心字段(非 DRAFT 一律不允许 PUT,409); - 对已过期 Offer 进行接受/拒绝/洽谈/提醒(409 `OFFER_EXPIRED`)。 - 对 `DRAFT` / `ACCEPTED` / `REJECTED` Offer 进行手动提醒(400 `OFFER_NOT_REMINDABLE`)。 到期策略:仅 `SENT` / `NEGOTIATING` 由定时任务自动置为 `EXPIRED`;`DRAFT` 不自动过期, 但到期后不可再 `SEND`(返回 409 `OFFER_EXPIRED`)。 ## 3. 接口清单 ### 3.1 创建 Offer ``` POST /offers ``` 权限:**企业账号**(当前用户必须绑定企业,且岗位属于该企业)。 请求字段: | 字段 | 必填 | 约束 | |---|---|---| | `applicationId` | 是 | 投递记录必须存在 | | `title` | 是 | ≤ 120 字符 | | `salaryMin` / `salaryMax` | 否 | ≥ 0;`salaryMin ≤ salaryMax` | | `salaryCurrency` | 否 | ≤ 10 字符,默认 `CNY` | | `salaryPeriod` | 否 | `MONTH` / `YEAR` / `HOUR`,默认 `MONTH` | | `workCity` | 是 | ≤ 80 字符 | | `employmentType` | 否 | `FULL_TIME` / `PART_TIME` / `INTERN` / `INTERNSHIP` | | `probationMonths` | 否 | ≥ 0,默认 0 | | `benefits` | 否 | ≤ 2000 字符 | | `description` | 是 | ≤ 5000 字符 | | `expireAt` | 是 | 必须晚于当前时间 | 前置条件(任一不满足则失败): - 投递记录存在(404 `APPLICATION_NOT_FOUND`); - 投递的岗位属于当前企业(400 `APPLICATION_OWNER_MISMATCH`); - 投递状态允许发 Offer:`SUBMITTED` / `VIEWED` / `INTERVIEW`;`REJECTED/HIRED/WITHDRAWN` 不允许(400 `OFFER_APPLICATION_NOT_ELIGIBLE`); - 该投递没有有效 Offer(409 `DUPLICATE_OFFER`)。 默认状态 `DRAFT`;创建**不发送**任何通知。 响应(HTTP 200,业务 `code=200`): ```json { "code": 200, "message": "ok", "data": { "id": 1, "applicationId": 5, "jobId": 10, "companyId": 1, "candidateId": 100, "title": "后端工程师", "salaryMin": 10000, "salaryMax": 20000, "salaryCurrency": "CNY", "salaryPeriod": "MONTH", "workCity": "杭州", "employmentType": "FULL_TIME", "probationMonths": 3, "benefits": "五险一金", "description": "...", "expireAt": "2026-09-01T23:59:59", "status": "DRAFT", "version": 0, "createdBy": 10, "updatedBy": 10, "createdAt": "...", "updatedAt": "..." }, "requestId": "...", "timestamp": "..." } ``` ### 3.2 查询 Offer 列表 ``` GET /offers?status=SENT&applicationId=5&page=0&size=20 ``` 权限:**学生**(查看自己收到的)或 **企业**(查看自己发出的)。 - 学生视图:`candidateId = 当前用户`; - 企业视图:`companyId = 当前用户绑定企业`; - 仅返回当前用户有权限的数据,不可通过参数越权; - `status`、`applicationId` 可选筛选; - 分页:`page`(默认 0)、`size`(默认 20,最大 100),按 `createdAt` 倒序。 响应: ```json { "code": 200, "data": { "items": [ ... ], "total": 3, "page": 0, "size": 20, "totalPages": 1 }, ... } ``` ### 3.3 查看 Offer 详情 ``` GET /offers/{id} ``` 权限:目标候选人、所属企业。其他账号返回 **403**(`FORBIDDEN`,不泄露 Offer 是否存在以外的信息)。 ### 3.4 修改 Offer ``` PUT /offers/{id} ``` 权限:所属企业;**仅 `DRAFT` 状态**可修改。`SENT` 及以后返回 **409** `OFFER_NOT_EDITABLE`。 字段均可选(`null` 表示不更新),约束与创建一致;`salaryMin/salaryMax/expireAt` 同样校验。 身份字段(`applicationId/jobId/companyId/candidateId`)不可修改。 ### 3.5 状态变更 ``` PATCH /offers/{id}/status ``` 请求体: ```json { "status": "SENT", "reason": "可选,≤255字符" } ``` - 目标状态枚举:`SENT / NEGOTIATING / ACCEPTED / REJECTED / EXPIRED`; - 角色校验:`DRAFT→SENT` 仅企业;`SENT/NEGOTIATING→ACCEPTED/REJECTED/NEGOTIATING` 仅候选人; `→EXPIRED` 仅系统任务(API 调用一律拒绝); - **幂等**:目标状态与当前一致时直接返回当前 Offer,不产生重复历史; - 并发保护:乐观锁(`version`),冲突时重读——已在目标状态视为幂等成功,否则 409; - 每次成功变更写入 `offer_status_history`(from/to/operator/operatorRole/reason/createdAt/requestId), 更新 `updated_at`、`updated_by`、`version`; - 非法流转返回 **409** `INVALID_OFFER_STATUS_TRANSITION`;已过期 Offer 返回 **409** `OFFER_EXPIRED`; - `status` 非法枚举返回 **400**(校验注解)。 ### 3.6 状态时间线 ``` GET /offers/{id}/timeline ``` 权限:同详情。返回按时间升序的完整状态变更历史: ```json { "code": 200, "data": [ { "fromStatus": null, "toStatus": "DRAFT", "operator": 10, "operatorRole": "COMPANY", "reason": "Offer created", "createdAt": "...", "requestId": "..." }, { "fromStatus": "DRAFT", "toStatus": "SENT", "operator": 10, "operatorRole": "COMPANY", ... } ] } ``` ### 3.7 手动提醒 ``` POST /offers/{id}/remind ``` 权限:所属企业。仅 `SENT` / `NEGOTIATING` Offer 可提醒;过期返回 409 `OFFER_EXPIRED`, `DRAFT` / `ACCEPTED` / `REJECTED` 返回 400 `OFFER_NOT_REMINDABLE`。 同一 Offer 的 `MANUAL` 提醒只创建一次(幂等,重复请求返回已有提醒,不重复通知)。 提醒结果写入 `offer_reminders`,并通过现有通知模块向候选人推送。 ## 4. 权限矩阵 | 操作 | 学生(候选人本人) | 企业(岗位所属公司) | 其他账号 | 系统任务 | |---|---|---|---|---| | 创建 Offer | 403 | ✅ | 403 | — | | 列表 / 详情 / 时间线 | ✅(仅自己的) | ✅(仅自己公司的) | 403 | — | | 修改草稿(PUT) | 403 | ✅ | 403 | — | | `DRAFT→SENT` | 403 | ✅ | 403 | — | | `SENT→ACCEPTED/REJECTED/NEGOTIATING` | ✅(仅本人的) | 403 | 403 | — | | `NEGOTIATING→ACCEPTED/REJECTED` | ✅(仅本人的) | 403 | 403 | — | | `→EXPIRED` | 403 | 403 | 403 | ✅ | | 手动提醒(仅 `SENT` / `NEGOTIATING`) | 403 | ✅ | 403 | — | 安全说明: - 所有身份均来自 JWT(`auth.getName()` = userId),**绝不信任请求参数中的 companyId/candidateId**; - 所有关键操作记录 `operator`(用户 id)+ `operatorRole`(COMPANY/CANDIDATE/SYSTEM)到历史表; - 不返回候选人联系方式、企业内注等敏感信息;错误响应不泄露堆栈与数据库结构。 ## 5. 通知 复用现有 `NotificationService.publishEvent(...)`(异步 RabbitMQ + 幂等键去重),未新建通知系统: | 事件 | 触发 | 接收方 | 幂等键 | |---|---|---|---| | 创建 DRAFT | 创建 | 无(不通知) | — | | `OFFER_SENT` | `DRAFT→SENT` | 候选人 | `offer-sent-{offerId}` | | `OFFER_ACCEPTED` / `OFFER_REJECTED` / `OFFER_NEGOTIATING` | 候选人决策 | 企业 | `offer-accepted-{offerId}` 等 | | `OFFER_EXPIRED` | 到期失效 | 候选人 + 企业 | `offer-expired-{offerId}` | | `OFFER_REMINDER` | 3 天 / 1 天 / 手动 | 候选人 | `offer-remind-{type}-{offerId}` | ## 6. 定时任务 复用项目已有的 Spring Scheduler(`@EnableScheduling`),未引入新中间件: | 任务 | 周期(可配) | 行为 | |---|---|---| | 到期失效 | `app.offer.scheduler.expiry-interval-ms`(默认 60s) | `SENT/NEGOTIATING` 且 `expire_at < now` → `EXPIRED` | | 到期提醒 | `app.offer.scheduler.reminder-interval-ms`(默认 60s) | 3 天 / 1 天前各发一次 | 可靠性设计: - 每个 Offer 在**独立事务**(`REQUIRES_NEW`)中处理,单个失败不破坏其他 Offer、不回滚已完成变更; - 幂等:只扫描 `SENT/NEGOTIATING`;`(offer_id, reminder_type)` 唯一索引 + exists 检查保证提醒只发一次; - 乐观锁(`version`)防止并发重复处理产生重复历史; - 通知发送失败不阻断状态变更。 ## 7. 数据库迁移(V13) 迁移文件:`src/main/resources/db/migration/V13__offers.sql`(兼容 MySQL 8 / H2 MODE=MySQL)。 新增三张表: **`offers`**(Offer 主表) - 字段:`id, application_id, job_id, company_id, candidate_id, title, salary_min, salary_max, salary_currency, salary_period, work_city, employment_type, probation_months, benefits, description, expire_at, status, active_offer_key, version, created_by, updated_by, created_at, updated_at` - **唯一有效 Offer 约束**:`uk_offers_active UNIQUE (application_id, active_offer_key)`。 `active_offer_key` 为非终态时 `ACTIVE`、`REJECTED/EXPIRED` 时 `NULL`(MySQL 不支持 partial index, 用可空判别列表达"每投递最多一个有效 Offer";`REJECTED/EXPIRED` 释放槽位后允许重新发 Offer)。 - 外键:application → applications、job → jobs、company → companies、candidate → users、created_by → users。 - CHECK:`salary_min/salary_max ≥ 0`、`salary_min ≤ salary_max`、`probation_months ≥ 0`。 - 索引:`(company_id, status)`、`(candidate_id, status)`、`(status, expire_at)`(任务扫描)、`(application_id)`、`(created_at)`。 - 乐观锁:`version BIGINT NOT NULL DEFAULT 0`(`@Version`);审计时间:`created_at/updated_at`。 **`offer_status_history`**(状态历史) - 字段:`id, offer_id, from_status, to_status, operator_id, operator_role, reason, request_id, created_at`。 - 索引:`(offer_id, created_at)`、`(created_at)`。`request_id` 透传请求链路 ID。 **`offer_reminders`**(提醒记录) - 字段:`id, offer_id, reminder_type, remind_at, sent_at, status, created_at`。 - 唯一:`uk_offer_reminders (offer_id, reminder_type)`(保证同类型提醒只创建一次)。 - 索引:`(offer_id)`、`(status)`、`(remind_at)`。 > 说明:`offer_status_history.operator_id` 未加外键,允许系统任务写入 `0`(SYSTEM)而不引用真实用户。 ## 8. Apifox 测试步骤 前置:启动基础设施 → 启动应用(dev profile)→ Apifox 导入 `docs/openapi-rc.json` 生成接口。 > 现网 OpenAPI 亦可访问 `GET /api/v1/v3/api-docs` 导出后导入 Apifox。 测试账号(Mock 短信验证码:应用日志不再输出,本地按 `docs/frontend-integration.md` §3 的受控方式取得;生产禁止查表): | 角色 | 手机号 | 用途 | |---|---|---| | ADMIN | 13800000001 | 管理员 | | STUDENT | 13800000002 | 候选人 | | COMPANY | 13800000003 | 企业 | 步骤(按依赖顺序): 1. **登录企业**:`POST /auth/sms/send?phone=13800000003&purpose=login` → `POST /auth/login`,取 `accessToken`(企业 token)。 2. **登录学生**:同上换 `13800000002`(学生 token)。 3. **准备投递**:企业创建岗位并发布 → 学生投递,记录 `applicationId`。 4. **创建 Offer(企业)**:`POST /offers`,断言 200、`status=DRAFT`、时间线含 `DRAFT` 历史。 5. **重复创建校验**:同一 `applicationId` 再次 `POST /offers` → 409 `DUPLICATE_OFFER`。 6. **修改草稿(企业)**:`PUT /offers/{id}` 修改薪资 → 200。 7. **发送(企业)**:`PATCH /offers/{id}/status` `{"status":"SENT"}` → 200;学生应收到 `OFFER_SENT` 通知。 8. **越权校验**:学生 token 访问 `PUT /offers/{id}` → 403;另一学生 token 访问 `GET /offers/{id}` → 403。 9. **已发送不可改**:企业 `PUT /offers/{id}` → 409 `OFFER_NOT_EDITABLE`。 10. **候选人响应(学生)**:`PATCH /offers/{id}/status` `{"status":"ACCEPTED"}` → 200;企业收到 `OFFER_ACCEPTED` 通知。 11. **幂等校验**:重复提交同状态 `PATCH ... {"status":"ACCEPTED"}` → 200,时间线**不新增**历史。 12. **终态保护**:再次 `{"status":"REJECTED"}` → 409 `INVALID_OFFER_STATUS_TRANSITION`。 13. **非法流转**:新建 Offer 草稿直接 `{"status":"ACCEPTED"}` → 409;`{"status":"INVALID"}` → 400。 14. **提醒**:企业对 `SENT` / `NEGOTIATING` Offer `POST /offers/{id}/remind` → 200;重复调用 → 幂等返回;`DRAFT` / `ACCEPTED` / `REJECTED` → 400 `OFFER_NOT_REMINDABLE`;过期 Offer → 409 `OFFER_EXPIRED`。 15. **到期自动失效**:将 Offer `expireAt` 设为过去(或等待定时任务)→ 状态自动变 `EXPIRED`,时间线含 SYSTEM 记录。 ## 9. 已知限制 1. **手动提醒限制**:同一 Offer 的 `MANUAL` 提醒仅一次(`(offer_id, reminder_type)` 唯一)。如需"多次提醒"需放开该约束并改为幂等窗口去重。 2. **`DRAFT` 不过期**:草稿到期不自动失效,仅阻止后续 `SEND`。若产品要求草稿也自动清理,需在状态机中增加 `DRAFT→EXPIRED`。 3. **重新发 Offer**:`REJECTED/EXPIRED` 后允许对同一投递重新创建 Offer(符合"每投递最多一个**有效** Offer")。 4. **并发 API 冲突**:极端并发下同一 Offer 重复流转会返回 409(幂等仅针对目标状态一致;并发冲突以乐观锁裁决)。 5. **通知依赖 MQ**:`app.notification.mq-enabled=false` 时通知消费者关闭,通知仅发布到 RabbitMQ(测试环境不落库)。 6. **OpenAPI 快照**:`docs/openapi-rc.json` 由 H2 集成测试上下文导出(基础设施未启动时),server URL 为测试默认值;接入现网后可用 `GET /api/v1/v3/api-docs` 重新导出。 7. **薪资隐私**:Offer 薪资对候选人可见(Offer 本身就是录用条款)。企业间数据隔离由 `companyId` 判定,候选人数据隔离由 `candidateId` 判定。 ## 10. 错误码(新增 81xx) | 错误码 | 枚举 | HTTP | 含义 | |---|---|---|---| | 8101 | `OFFER_NOT_FOUND` | 404 | Offer 不存在 | | 8102 | `DUPLICATE_OFFER` | 409 | 该投递已有有效 Offer | | 8103 | `INVALID_OFFER_STATUS_TRANSITION` | 409 | 非法状态流转 | | 8104 | `OFFER_EXPIRED` | 409 | Offer 已过期 | | 8105 | `OFFER_NOT_EDITABLE` | 409 | 非 DRAFT 不可修改 | | 8106 | `OFFER_APPLICATION_NOT_ELIGIBLE` | 400 | 投递状态不允许发 Offer | | 8107 | `OFFER_REMINDER_DUPLICATE` | 409 | 提醒重复(预留) | | 8108 | `OFFER_NOT_REMINDABLE` | 400 | 当前状态不允许手动提醒 |