15 KiB
Offer 管理模块接口说明
职来 (mydreamjob) · Offer 管理基础模块 基础路径:
/api/v1· 除明确标注外全部接口需Authorization: Bearer <accessToken>
1. 概述
Offer 用于管理企业向求职者发出的录用邀请,覆盖:创建草稿、发送、候选人接受/拒绝/进入洽谈、 到期自动失效、完整状态历史、到期提醒与手动提醒。本轮不包含双向匹配、公平性检测、SHAP、 AI 简历/面试、评分算法、数据看板等模块。
2. 状态机
状态统一命名(大小写敏感):
| 状态 | 含义 | 是否终态 |
|---|---|---|
DRAFT |
草稿(企业已创建、未发送) | 否 |
SENT |
已发送(等待候选人响应) | 否 |
NEGOTIATING |
洽谈中 | 否 |
ACCEPTED |
已接受 | 是 |
REJECTED |
已拒绝 | 是 |
EXPIRED |
已过期(定时任务触发) | 是 |
状态流转:
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/REJECTEDOffer 进行手动提醒(400OFFER_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不允许(400OFFER_APPLICATION_NOT_ELIGIBLE); - 该投递没有有效 Offer(409
DUPLICATE_OFFER)。
默认状态 DRAFT;创建不发送任何通知。
响应(HTTP 200,业务 code=200):
{
"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倒序。
响应:
{ "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
请求体:
{ "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 返回 409OFFER_EXPIRED; status非法枚举返回 400(校验注解)。
3.6 状态时间线
GET /offers/{id}/timeline
权限:同详情。返回按时间升序的完整状态变更历史:
{ "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 | 企业 |
步骤(按依赖顺序):
- 登录企业:
POST /auth/sms/send?phone=13800000003&purpose=login→POST /auth/login,取accessToken(企业 token)。 - 登录学生:同上换
13800000002(学生 token)。 - 准备投递:企业创建岗位并发布 → 学生投递,记录
applicationId。 - 创建 Offer(企业):
POST /offers,断言 200、status=DRAFT、时间线含DRAFT历史。 - 重复创建校验:同一
applicationId再次POST /offers→ 409DUPLICATE_OFFER。 - 修改草稿(企业):
PUT /offers/{id}修改薪资 → 200。 - 发送(企业):
PATCH /offers/{id}/status{"status":"SENT"}→ 200;学生应收到OFFER_SENT通知。 - 越权校验:学生 token 访问
PUT /offers/{id}→ 403;另一学生 token 访问GET /offers/{id}→ 403。 - 已发送不可改:企业
PUT /offers/{id}→ 409OFFER_NOT_EDITABLE。 - 候选人响应(学生):
PATCH /offers/{id}/status{"status":"ACCEPTED"}→ 200;企业收到OFFER_ACCEPTED通知。 - 幂等校验:重复提交同状态
PATCH ... {"status":"ACCEPTED"}→ 200,时间线不新增历史。 - 终态保护:再次
{"status":"REJECTED"}→ 409INVALID_OFFER_STATUS_TRANSITION。 - 非法流转:新建 Offer 草稿直接
{"status":"ACCEPTED"}→ 409;{"status":"INVALID"}→ 400。 - 提醒:企业对
SENT/NEGOTIATINGOfferPOST /offers/{id}/remind→ 200;重复调用 → 幂等返回;DRAFT/ACCEPTED/REJECTED→ 400OFFER_NOT_REMINDABLE;过期 Offer → 409OFFER_EXPIRED。 - 到期自动失效:将 Offer
expireAt设为过去(或等待定时任务)→ 状态自动变EXPIRED,时间线含 SYSTEM 记录。
9. 已知限制
- 手动提醒限制:同一 Offer 的
MANUAL提醒仅一次((offer_id, reminder_type)唯一)。如需"多次提醒"需放开该约束并改为幂等窗口去重。 DRAFT不过期:草稿到期不自动失效,仅阻止后续SEND。若产品要求草稿也自动清理,需在状态机中增加DRAFT→EXPIRED。- 重新发 Offer:
REJECTED/EXPIRED后允许对同一投递重新创建 Offer(符合"每投递最多一个有效 Offer")。 - 并发 API 冲突:极端并发下同一 Offer 重复流转会返回 409(幂等仅针对目标状态一致;并发冲突以乐观锁裁决)。
- 通知依赖 MQ:
app.notification.mq-enabled=false时通知消费者关闭,通知仅发布到 RabbitMQ(测试环境不落库)。 - OpenAPI 快照:
docs/openapi-rc.json由 H2 集成测试上下文导出(基础设施未启动时),server URL 为测试默认值;接入现网后可用GET /api/v1/v3/api-docs重新导出。 - 薪资隐私: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 | 当前状态不允许手动提醒 |