Commit 84e9cff1 authored by 李文光's avatar 李文光

feat: 分析报告公网分享(ADR 0002) + 外部岗位认领 Phase1(ADR 0003) + skill 镜像与词表

- 报告分享: candidate_reports 独立快照表 + 令牌公网只读(reports/report_repository/ShareReportView/security 放行/配置开关)
- 外部岗位认领: external_job_claims 待认领区 + sync-external 落区 + 认领行级接口(GET /api/job-claims, POST /api/job-claims/{id}/claim) + 前端「岗位认领」页 + 外部已审批岗位「核对后确认生效」流转
- docs/recruit-grill skill 镜像、CONTEXT.md 术语(外部岗位/待认领岗位/岗位认领/岗位对内档案/招聘规则库/OA 审批直读)、ADR 0003 与落地清单
- 后端 90 passed; vue-app npm run build 通过
parent ba4d5ed2
......@@ -22,10 +22,29 @@
- **认领 / 复活(Claim / Resurrect)**:把人才池中的候选人迁入本人工作区,重新开始招聘流程。
- **入池备注 / 入池时间 / 放入人**:记录候选人进入人才池的原因、时间与操作账号,便于复用与溯源。
## 外部岗位与审批
- **外部岗位(External Job)**:用人部门经企微智能体(QwenPaw + recruit-grill)产出并经 OA 审批的招聘需求,不经招聘系统先行创建;区别于 HR 在系统内直接创建的岗位。
- **待认领岗位(Unclaimed Job)**:已通过 OA 审批、携带岗位全量信息、尚未被招聘专员认领进任何工作区的招聘需求;公司级共享可见,不属任何个人工作区。
- **岗位认领(Job Claim)**:招聘专员把待认领岗位迁入本人工作区、成为正式岗位的动作;认领后对他人显示已认领。_Avoid_: 认领 / 复活(人才池语境,对象为候选人,勿混用)。
- **岗位对内档案(Job Internal Profile)**:岗位行中仅供招聘侧内部使用的信息(命脉 / 验证信号 / 排除信号 / 敏感约束 / 试用期 / 竞业与访谈笔记等),禁止进入对外 JD 或 LLM prompt。
- **招聘规则库(Recruiting Rules Base)**:公司级共享的招聘硬规则 / 术语表 / 待定决策知识,独立于本领域词表保存;不进入对外 JD。
- **OA 审批直读(Approval Read-through)**:招聘系统以 OA 审批记录为岗位审批结果来源(OA 为审批生命周期 owner),自身不实现审批流的设计。
## 评估与匹配
- **自述优势(Self-Claimed Strengths / 自我评价)**:候选人在简历中对自己能力、性格、经历的主观自述(如「自我评价」「核心竞争力」「个人优势」段落)。规则:不作为 AI 匹配 / 亮点 / 证据依据,只能作为面试待验证线索。
- **简历事实(Resume Facts)**:可从简历客观内容核实或引用的匹配依据——工作 / 项目 / 实习经历的职责、产出与结果数据、教育背景、证书与明确列出的技能。AI 评估只以此类内容作为匹配与亮点证据。
- **风险固定维度(Fixed Risk Dimensions)**:候选人风险只按四个固定维度评估——跳槽频率(任职时间线推算)、竞业协议(岗位对内 `nonCompete` 是否命中,需人力确认上一家竞业)、职业空窗(任职间隔 gap ≥12/24 个月提示)、高层职业道德(总监/VP/首席等高层专属背调提示)。规则本地确定性计算(`risk_profile.py`),每项输出「提示 + 依据(简历时间线/岗位说明)」,由 HR 人工确认,不直接扣分。
## 报告与分享
- **AI 评估报告(评估记录 Evaluation Record)**:候选人工作区内的实时评估结果——得分、初筛结论、总结、评估点/匹配点/风险/面试确认点、风险固定维度(`candidate.analysis`);随整包状态保存、可随时重新生成。_Avoid_: 分析报告(与下面的分享产物混淆)。
- **分析报告(候选人报告 Candidate Report)**:由 AI 评估在某一时刻冻结生成的正式产物,供分享给用人部门审阅;带独立报告编号与生成时间,生成后内容不再随候选人资料变化。_Avoid_: 评估报告、导出报告、AI 报告。
- **报告快照(Report Snapshot)**:生成分析报告那一刻固定下来的内容——候选人姓名、关联岗位、评估结论与明细、风险与面试确认点、生成人;**不含**电话/邮箱等联系方式与原简历文件。
- **分享链接(Share Link)**:分析报告免登录访问的唯一入口,令牌不可猜、可单独停用;候选人被删除后自动失效。
- **报告停用(Report Disable)**:使某条分享链接失效但保留报告内容的动作,可重新启用;区别于删除记录。
- **报告生成人(Report Author)**:生成并分享该分析报告的招聘账号,公网报告页可见,供用人部门对接。
## 流程与联动
......
......@@ -170,6 +170,10 @@ backend\.venv\Scripts\python.exe backend/scripts/start_server.py --with-frontend
| POST | `/api/ingest` | 浏览器扩展 / 外部简历采集入口 |
| GET | `/api/ingest-queue` | 查看待确认采集队列 |
| POST | `/api/ingest-queue/clear` | 确认并清空采集队列 |
| POST | `/api/reports` | 生成候选人分析报告(冻结 AI 评估快照 + 公网分享链接) |
| GET | `/api/reports?candidateId=` | 列出候选人分析报告历史 |
| PATCH | `/api/reports/{reportId}` | 停用 / 重新启用分析报告分享 |
| GET | `/api/share/report/{reportId}` | 分析报告公网匿名只读(需链接内 token) |
## 后端配置
......@@ -191,6 +195,8 @@ backend\.venv\Scripts\python.exe backend/scripts/start_server.py --with-frontend
| `MAX_UPLOAD_MB` | `20` | 上传大小限制(MB) |
| `LLM_API_KEY` / `LLM_BASE_URL` / `LLM_MODEL` | 空 | OpenAI 兼容模型;或使用 `DEEPSEEK_*` / `OPENAI_*`,优先级 `LLM_*` > `DEEPSEEK_*` > `OPENAI_*` |
| `QWENPAW_ENABLED` 等 | `false` | QwenPaw 独立智能体服务接入(可选) |
| `REPORT_SHARE_ENABLED` | `false` | 分析报告公网分享开关(ADR 0002):开启后分享链接可被免登录访问 |
| `PUBLIC_BASE_URL` | 空 | 分享链接基准地址(如 `https://hr.example.com`);留空回退请求 Host |
## 数据库迁移
......
......@@ -23,6 +23,12 @@ CORS_ORIGINS=http://127.0.0.1:5173,http://localhost:5173
# 上传限制,单位 MB
MAX_UPLOAD_MB=20
# 分析报告公网分享(ADR 0002)
# 默认关闭:关闭时报告仍可生成与管理,但公网分享链接统一按失效处理。
# 公网部署时开启,并把 PUBLIC_BASE_URL 填为对外访问地址(如 https://hr.example.com)。
REPORT_SHARE_ENABLED=false
PUBLIC_BASE_URL=
# OpenAI/DeepSeek 兼容模型配置。三组变量按 LLM_* > DEEPSEEK_* > OPENAI_* 优先级读取。
LLM_API_KEY=
LLM_BASE_URL=
......
......@@ -50,6 +50,11 @@ class Settings(BaseSettings):
openai_base_url: str = Field(default="https://api.openai.com/v1", alias="OPENAI_BASE_URL")
openai_model: str = Field(default="gpt-4o-mini", alias="OPENAI_MODEL")
# 分析报告分享(ADR 0002):默认关闭;开启后匿名分享接口才可用。
report_share_enabled: bool = Field(default=False, alias="REPORT_SHARE_ENABLED")
# 公网分享链接的基准地址(如 https://hr.example.com);留空则回退请求 Host。
report_public_base_url: str = Field(default="", alias="PUBLIC_BASE_URL")
# QwenPaw 智能体服务接入。启用后 JD 生成 / 简历分析可走 QwenPaw 独立服务。
qwenpaw_enabled: bool = Field(default=False, alias="QWENPAW_ENABLED")
qwenpaw_base_url: str = Field(default="http://127.0.0.1:8088", alias="QWENPAW_BASE_URL")
......
......@@ -2,7 +2,7 @@ import uvicorn
from fastapi import FastAPI, Request
from fastapi.exceptions import RequestValidationError
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse, RedirectResponse
from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse
from fastapi.staticfiles import StaticFiles
from sqlalchemy import select
from starlette.exceptions import HTTPException as StarletteHTTPException
......@@ -10,7 +10,17 @@ from starlette.exceptions import HTTPException as StarletteHTTPException
from backend.app.config import STATIC_ROOT, get_settings
from backend.app.db import SessionLocal, create_all
from backend.app.models import User, utc_now_iso
from backend.app.routers import ai, auth, health, ingest, pool, resume, state
from backend.app.routers import (
ai,
auth,
external_claims,
health,
ingest,
pool,
reports,
resume,
state,
)
from backend.app.security import DeploymentAuthMiddleware
from backend.app.services.auth_security import hash_password
......@@ -66,13 +76,26 @@ def create_app() -> FastAPI:
app.include_router(ai.router, prefix="/api")
app.include_router(ingest.router, prefix="/api")
app.include_router(pool.router, prefix="/api")
app.include_router(external_claims.router, prefix="/api")
app.include_router(reports.router, prefix="/api")
@app.get("/", include_in_schema=False)
async def homepage() -> RedirectResponse:
return RedirectResponse(url="/index.html", status_code=307)
@app.exception_handler(StarletteHTTPException)
async def http_exception_handler(request: Request, exc: StarletteHTTPException) -> JSONResponse: # noqa: ARG001
async def http_exception_handler(request: Request, exc: StarletteHTTPException) -> JSONResponse:
# SPA history 路由直链回退:非 /api 的 GET 404 返回 index.html,
# 让 /share/report/... 这类公开分享页可被直接打开(ADR 0002)。
if (
exc.status_code == 404
and request.method == "GET"
and not request.url.path.startswith("/api")
and "." not in request.url.path.rsplit("/", 1)[-1]
):
index_file = STATIC_ROOT / "index.html"
if index_file.exists():
return HTMLResponse(index_file.read_text(encoding="utf-8"), status_code=200)
detail = exc.detail if isinstance(exc.detail, str) else "请求失败"
return JSONResponse({"error": detail}, status_code=exc.status_code)
......
......@@ -262,9 +262,60 @@ class IngestItem(Base):
created_at: Mapped[str] = mapped_column(String(40), default=utc_now_iso, nullable=False)
class CandidateReport(Base):
"""分析报告(候选人报告,ADR 0002):由 AI 评估冻结生成的分享快照。
独立于整包 state 同步——生成即冻结,带 report_id 与不可猜分享令牌,
供公网匿名只读查看。候选行被删除(整包 state 覆盖)后公开链接按失效处理;
移入人才池(owner 迁到 _pool)链接仍有效。
"""
__tablename__ = "candidate_reports"
id: Mapped[str] = mapped_column(String(120), primary_key=True)
owner_id: Mapped[str] = mapped_column(String(80), nullable=False, index=True)
candidate_id: Mapped[str] = mapped_column(String(80), nullable=False, index=True)
report_id: Mapped[str] = mapped_column(String(120), unique=True, nullable=False)
share_token: Mapped[str] = mapped_column(String(120), nullable=False)
status: Mapped[str] = mapped_column(String(40), default="active", nullable=False)
expires_at: Mapped[str | None] = mapped_column(String(40))
data: Mapped[str] = mapped_column(Text, nullable=False)
created_at: Mapped[str] = mapped_column(String(40), default=utc_now_iso, nullable=False)
updated_at: Mapped[str] = mapped_column(String(40), default=utc_now_iso, nullable=False)
Index("idx_candidate_reports_candidate", CandidateReport.candidate_id)
Index("idx_candidates_primary_job", Candidate.primary_job_id)
Index("idx_candidates_source", Candidate.source)
Index("idx_candidates_stage", Candidate.stage)
Index("idx_tasks_status", Task.status)
Index("idx_events_at", RecruitmentEvent.at)
Index("idx_ingest_confirmed", IngestItem.confirmed)
class ExternalJobClaim(Base):
"""外部岗位认领单:企微/OA 审批通过的外部 JD 落「待认领区」,由招聘专员认领进岗位管理(ADR 0003)。
该表不进整包 state(不在 state_repository._OWNERED_MODELS 内),replace_state 不会清空;
owner_id 固定为保留归属 CLAIM_OWNER("_external"),公司级共享可见,不属任何个人工作区。
"""
__tablename__ = "external_job_claims"
id: Mapped[str] = mapped_column(String(120), primary_key=True)
owner_id: Mapped[str] = mapped_column(String(80), nullable=False, index=True, default="_external")
external_id: Mapped[str | None] = mapped_column(String(160), index=True)
title: Mapped[str] = mapped_column(String(200), nullable=False)
department: Mapped[str | None] = mapped_column(String(120))
source: Mapped[str | None] = mapped_column(String(80))
approval: Mapped[str | None] = mapped_column(String(120))
claimed: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
claimed_by: Mapped[str | None] = mapped_column(String(80))
claimed_at: Mapped[str | None] = mapped_column(String(40))
job_id: Mapped[str | None] = mapped_column(String(80))
payload_json: Mapped[str] = mapped_column(Text, nullable=False)
data: Mapped[str] = mapped_column(Text, nullable=False)
created_at: Mapped[str] = mapped_column(String(40), default=utc_now_iso, nullable=False)
updated_at: Mapped[str] = mapped_column(String(40), default=utc_now_iso, nullable=False)
"""外部岗位认领仓储(ADR 0003)。
企微/OA 产出的外部 JD 先落公司级「待认领区」(external_job_claims 表,
owner_id 固定保留归属 CLAIM_OWNER="_external"),招聘专员认领后才生成 jobId、
进入本人岗位管理。该表不在整包 state 的 _OWNERED_MODELS 里,replace_state
不会清空它;认领采用行级写入 + 失效工作区整包快照(对齐 ADR 0001 人才池 /
ADR 0002 报告),防止「认领了还在」与跨账号整包覆盖。
"""
from typing import Any
from uuid import uuid4
from sqlalchemy import select
from sqlalchemy.orm import Session
from backend.app.models import (
AppStateMeta,
ExternalJobClaim,
Job,
RecruitmentEvent,
Task,
utc_now_iso,
)
from backend.app.repositories.json_utils import dump_json, parse_json
# 公司级「待认领区」保留归属:不作为 User 存在,禁止创建同名账号
CLAIM_OWNER = "_external"
# 对外摘要/认领预览不携带的对内字段;对内字段只在认领后进入认领人岗位档案
_INTERNAL_ONLY_JOB_KEYS = (
"sensitive",
"genderRestriction",
"ageRestriction",
"internalFeasibility",
"verification",
"probationPeriod",
"probationCriteria",
"nonCompete",
)
_PREVIEW_FIELDS = (
"jd",
"responsibilities",
"requirements",
"mustHave",
"niceToHave",
"knockout",
"competency",
"matchKeywords",
)
class ClaimError(Exception):
"""待认领岗位操作失败,携带 HTTP 状态与面向用户的中文提示。"""
def __init__(self, status_code: int, detail: str) -> None:
super().__init__(detail)
self.status_code = status_code
self.detail = detail
def _id(prefix: str) -> str:
return f"{prefix}-{uuid4().hex[:12]}"
def _str(value: Any, limit: int = 200, default: str = "") -> str:
return str(value or default).strip()[:limit]
def _int_or_none(value: Any) -> int | None:
try:
if value is None or str(value).strip() == "":
return None
return int(value)
except (TypeError, ValueError):
return None
def _pick_job_payload(payload: dict[str, Any]) -> dict[str, Any]:
raw_job = dict(payload.get("job") or {})
title = _str(raw_job.get("title") or payload.get("title"), 200)
if not title:
raise ClaimError(400, "缺少岗位标题")
job = dict(raw_job)
job.pop("id", None)
job["title"] = title
return job
def _claim_summary(row: ExternalJobClaim, with_job: bool = False) -> dict[str, Any]:
job = parse_json(row.data, {}) if row.data else {}
preview = {key: job[key] for key in _PREVIEW_FIELDS if job.get(key)}
summary: dict[str, Any] = {
"id": row.id,
"externalId": row.external_id or "",
"title": row.title,
"department": row.department or "",
"source": row.source or "",
"approval": row.approval or "",
"createdAt": row.created_at,
"updatedAt": row.updated_at,
"claimed": bool(row.claimed),
"claimedBy": row.claimed_by or "",
"claimedAt": row.claimed_at or "",
"jobId": row.job_id or "",
"headcount": _int_or_none(job.get("headcount")),
"jobPreview": preview,
}
if with_job:
summary["job"] = {key: value for key, value in job.items() if key not in _INTERNAL_ONLY_JOB_KEYS}
return summary
def upsert_external_claim(session: Session, payload: dict[str, Any]) -> dict[str, Any]:
"""把外部 JD 落入待认领区(幂等 upsert)。稳定键:externalId/external_id/externalJobId/oaBillNo,
无稳定键时按 标题+部门+来源 合并未认领单。已认领的同键单据只跳过不覆盖。
"""
job = _pick_job_payload(payload)
title = job["title"]
department = _str(job.get("department") or payload.get("department"), 120)
source = _str(job.get("source") or payload.get("source") or "qwenpaw-external", 80)
external_id = _str(
job.get("externalId") or payload.get("external_id") or payload.get("externalJobId") or payload.get("oaBillNo"),
160,
)
approval = _str(payload.get("approvalStatus") or job.get("approvalStatus") or "待用人部门认领", 120)
now = utc_now_iso()
existing = None
if external_id:
existing = session.scalar(
select(ExternalJobClaim)
.where(ExternalJobClaim.external_id == external_id)
.order_by(ExternalJobClaim.created_at.desc(), ExternalJobClaim.id.desc())
)
if existing is None:
existing = session.scalar(
select(ExternalJobClaim)
.where(
ExternalJobClaim.title == title,
ExternalJobClaim.department == department,
ExternalJobClaim.source == source,
ExternalJobClaim.claimed.is_(False),
)
.order_by(ExternalJobClaim.created_at.desc(), ExternalJobClaim.id.desc())
)
if existing is not None:
if existing.claimed:
return {"ok": True, "operation": "skipped", "reason": "already_claimed", "claim": _claim_summary(existing)}
existing.title = title
existing.department = department
existing.source = source
existing.approval = approval
if external_id:
existing.external_id = external_id
existing.data = dump_json(job)
existing.payload_json = dump_json(payload)
existing.updated_at = now
session.commit()
return {"ok": True, "operation": "updated", "claim": _claim_summary(existing)}
row = ExternalJobClaim(
id=_id("claim"),
owner_id=CLAIM_OWNER,
external_id=external_id or None,
title=title,
department=department,
source=source,
approval=approval,
claimed=False,
data=dump_json(job),
payload_json=dump_json(payload),
created_at=now,
updated_at=now,
)
session.add(row)
session.commit()
return {"ok": True, "operation": "created", "claim": _claim_summary(row)}
def list_external_claims(session: Session) -> list[dict[str, Any]]:
"""读取全部待认领/已认领外部岗位(公司级共享,任何已登录用户可见)。"""
rows = session.scalars(
select(ExternalJobClaim).order_by(
ExternalJobClaim.claimed.asc(), ExternalJobClaim.created_at.desc(), ExternalJobClaim.id.desc()
)
).all()
return [_claim_summary(row) for row in rows]
def claim_external_job(session: Session, claim_id: str, target_owner: str) -> dict[str, Any]:
"""把待认领岗位认领到调用者工作区:生成 jobId、写 Job 行 + 事件/待办,并失效其整包快照。"""
if target_owner == CLAIM_OWNER:
raise ClaimError(400, "待认领区为共享区,不能认领给自己")
row = session.get(ExternalJobClaim, claim_id)
if row is None:
raise ClaimError(404, "待认领岗位不存在")
if row.claimed:
raise ClaimError(409, "该岗位已被其他招聘专员认领")
job = parse_json(row.data, {}) if row.data else {}
if not job:
raise ClaimError(400, "认领单缺少岗位内容")
now = utc_now_iso()
job_id = _id("job")
headcount = _int_or_none(job.get("headcount"))
job = {
**job,
"id": job_id,
"title": row.title,
"department": row.department or job.get("department") or "",
"owner": target_owner,
"status": job.get("status") or "",
"priority": job.get("priority") or "中",
"headcount": headcount,
"hired": _int_or_none(job.get("hired")) or 0,
"jdStatus": "待确认",
"approvalStatus": "审批通过",
"approvalSource": "oa-external",
"jdVersion": job.get("jdVersion") or "v1",
"matchRuleStatus": "已配置" if (job.get("matchKeywords") or job.get("mustHave") or job.get("competency")) else "未配置",
"channelStatus": "未发布",
"nextAction": "核对外部 JD 并确认生效。",
"source": row.source or "qwenpaw-external",
"createdAt": now,
"updatedAt": now,
"claimId": row.id,
"externalId": row.external_id or "",
"oaBillNo": job.get("oaBillNo") or "",
"claimedBy": target_owner,
"claimedAt": now,
}
session.add(
Job(
id=job_id,
owner_id=target_owner,
title=job["title"],
department=job.get("department") or "",
status=job.get("status") or "",
owner=target_owner,
priority=job.get("priority") or "",
headcount=headcount,
hired=_int_or_none(job.get("hired")) or 0,
jd_status=job["jdStatus"],
approval_status=job["approvalStatus"],
jd_version=job.get("jdVersion") or "",
match_rule_status=job.get("matchRuleStatus") or "",
data=dump_json(job),
created_at=now,
updated_at=now,
)
)
event = {
"id": _id("evt"),
"type": "job_claimed",
"at": now,
"jobId": job_id,
"jobTitle": row.title,
"candidateId": "",
"candidateName": "",
"source": row.source or "",
"stage": "",
"note": f"招聘专员 {target_owner} 认领外部岗位并进入岗位管理",
}
session.add(
RecruitmentEvent(
id=event["id"],
owner_id=target_owner,
type=event["type"],
at=now,
candidate_id=None,
candidate_name="",
job_id=job_id,
job_title=row.title,
source=row.source or "",
stage="",
data=dump_json(event),
created_at=now,
)
)
task = {
"id": _id("task"),
"title": "核对外部 JD 并确认生效",
"module": "岗位管理",
"target": row.title,
"due": "今日",
"priority": "中",
"status": "待处理",
"type": "JD审批",
"currentNode": "JD发布与审核",
"jobId": job_id,
}
session.add(
Task(
id=task["id"],
owner_id=target_owner,
title=task["title"],
module=task["module"],
target=task["target"],
due=task["due"],
priority=task["priority"],
status=task["status"],
type=task["type"],
current_node=task["currentNode"],
candidate_id=None,
job_id=job_id,
offer_id=None,
data=dump_json(task),
created_at=now,
updated_at=now,
)
)
row.claimed = True
row.claimed_by = target_owner
row.claimed_at = now
row.job_id = job_id
row.updated_at = now
session.merge(AppStateMeta(id=target_owner, updated_at=now, imported_from="job-claim", raw_snapshot=None))
session.commit()
return job
"""分析报告(候选人报告)仓储:独立于整包 state 的分享快照(ADR 0002)。
- 报告行带 owner_id,但**不进** state_repository 的整包读写,避免每次前端
PUT /api/state 清空重建时把报告冲掉(同 users / 池行级机制)。
- 快照只收录非敏感字段:姓名/岗位/来源/阶段 + analysis/matchReport;
电话、邮箱、原简历正文与文件一律不进快照(对齐 AGENTS §4.4 对外边界)。
- 候选行被删除(整包 state 覆盖)后公开链接自动按失效处理;移入人才池
(owner 迁到 `_pool`,行仍在)链接继续有效。
"""
import secrets
from datetime import datetime
from typing import Any
from uuid import uuid4
from sqlalchemy import select
from sqlalchemy.orm import Session
from backend.app.models import Candidate, CandidateReport, utc_now_iso
from backend.app.repositories.json_utils import dump_json, parse_json
ACTIVE = "active"
DISABLED = "disabled"
VALID_STATUSES = {ACTIVE, DISABLED}
class ReportError(Exception):
def __init__(self, status_code: int, detail: str) -> None:
self.status_code = status_code
self.detail = detail
super().__init__(detail)
def build_snapshot(candidate: Candidate) -> dict[str, Any]:
"""从候选人行 data JSON 冻结分享快照,只取对外安全字段。"""
data = parse_json(candidate.data, {})
analysis = data.get("analysis")
if not isinstance(analysis, dict) or analysis.get("score") in (None, ""):
raise ReportError(400, "候选人尚未生成有效的 AI 评估报告,请先生成后再分享")
return {
"candidateId": candidate.id,
"candidateName": candidate.name,
"jobTitle": data.get("jobTitle") or candidate.job_title or "",
"source": data.get("source") or candidate.source or "",
"stage": data.get("stage") or candidate.stage or "",
"analysis": analysis,
"matchReport": data.get("matchReport"),
"quickMatchScore": data.get("quickMatchScore"),
}
def report_dict(row: CandidateReport, *, base_url: str = "") -> dict[str, Any]:
base = (base_url or "").rstrip("/")
return {
"id": row.id,
"reportId": row.report_id,
"candidateId": row.candidate_id,
"status": row.status,
"generatedAt": row.created_at,
"updatedAt": row.updated_at,
"expiresAt": row.expires_at,
"shareUrl": f"{base}/share/report/{row.report_id}?token={row.share_token}",
"snapshot": parse_json(row.data, {}),
}
def _new_report_id(session: Session) -> str:
while True:
report_id = f"REP-{datetime.now().strftime('%Y%m%d')}-{secrets.token_hex(3).upper()}"
exists = session.scalar(select(CandidateReport.id).where(CandidateReport.report_id == report_id))
if exists is None:
return report_id
def create_report(
session: Session,
*,
owner: str,
candidate: Candidate,
author_username: str,
author_name: str,
base_url: str,
) -> dict[str, Any]:
snapshot = build_snapshot(candidate)
snapshot["author"] = {"username": author_username, "name": author_name}
snapshot["generatedAt"] = utc_now_iso()
now = utc_now_iso()
row = CandidateReport(
id=f"report-{uuid4().hex[:16]}",
owner_id=owner,
candidate_id=candidate.id,
report_id=_new_report_id(session),
share_token=secrets.token_urlsafe(32),
status=ACTIVE,
data=dump_json(snapshot),
created_at=now,
updated_at=now,
)
session.add(row)
session.commit()
session.refresh(row)
return report_dict(row, base_url=base_url)
def list_reports(session: Session, *, owner: str, candidate_id: str, base_url: str) -> list[dict[str, Any]]:
rows = session.scalars(
select(CandidateReport)
.where(CandidateReport.owner_id == owner, CandidateReport.candidate_id == candidate_id)
.order_by(CandidateReport.created_at.desc(), CandidateReport.id.desc())
).all()
return [report_dict(row, base_url=base_url) for row in rows]
def get_owned_report(session: Session, *, owner: str, report_id: str) -> CandidateReport | None:
return session.scalar(
select(CandidateReport).where(
CandidateReport.owner_id == owner, CandidateReport.report_id == report_id
)
)
def set_report_status(
session: Session, *, owner: str, report_id: str, status: str, base_url: str
) -> dict[str, Any]:
if status not in VALID_STATUSES:
raise ReportError(400, "状态只能是 active 或 disabled")
row = get_owned_report(session, owner=owner, report_id=report_id)
if row is None:
raise ReportError(404, "报告不存在")
row.status = status
row.updated_at = utc_now_iso()
session.commit()
session.refresh(row)
return report_dict(row, base_url=base_url)
def read_public_report(
session: Session, *, report_id: str, token: str, share_enabled: bool, base_url: str
) -> tuple[str, dict[str, Any] | None]:
"""公开只读解析。返回 (结果, 报告字典);结果 ∈ {ok, disabled, not_found}。
失败统一返回 not_found(不泄露报告是否存在);已停用返回 disabled。
"""
if not share_enabled:
return "not_found", None
row = session.scalar(select(CandidateReport).where(CandidateReport.report_id == report_id))
if row is None or not secrets.compare_digest(str(token or ""), row.share_token or ""):
return "not_found", None
if row.status != ACTIVE:
return "disabled", None
if row.expires_at and row.expires_at <= utc_now_iso():
return "disabled", None
# 候选人被删除后公开链接自动失效(ADR 0002 边界)
candidate_exists = session.scalar(select(Candidate.id).where(Candidate.id == row.candidate_id))
if candidate_exists is None:
return "not_found", None
return "ok", report_dict(row, base_url=base_url)
......@@ -32,7 +32,9 @@ EMPTY_STATE: dict[str, Any] = {
"updatedAt": "",
}
# 所有带 owner_id 的业务表;replace_state 按此顺序清理当前工作区
# 所有带 owner_id 的业务表;replace_state 按此顺序清理当前工作区。
# 注意: CandidateReport(ADR 0002) 与 users / 人才池行级机制一样,刻意**不进**整包同步,
# 请勿把该表加入此列表,否则每次前端保存都会把分享报告清空。
_OWNERED_MODELS = (
CandidateMatchReport,
ResumeProfile,
......
......@@ -141,7 +141,8 @@ async def qwenpaw_chat_route(payload: dict[str, Any]) -> StreamingResponse:
@router.post("/jd/sync-external")
async def jd_sync_external_route(request: Request, payload: dict, db: Session = Depends(get_db)) -> dict:
"""对外 JD 同步:接收企微 QwenPaw 多轮追问产出的 JD,创建/更新岗位。
"""对外 JD 同步:接收企微 QwenPaw 多轮追问产出的 JD,落入「待认领区」(ADR 0003),
由招聘专员在岗位认领菜单认领后才创建/更新岗位。
认证:使用与 /api/ingest 相同的 X-Ingest-Token(或 ?token=)。未配置
INGEST_TOKEN 时开放,适合内网调用。
......
......@@ -98,7 +98,7 @@ def create_user(payload: dict[str, Any], db: Session = Depends(get_db), _: Curre
password = _validate_password(str(payload.get("password") or ""))
if not username:
raise HTTPException(status_code=400, detail="用户名不能为空")
if username in ("local", "_pool"):
if username in ("local", "_pool", "_external"):
raise HTTPException(status_code=400, detail="该用户名为系统保留,请更换")
if role not in ("admin", "user"):
raise HTTPException(status_code=400, detail="角色只能是 admin 或 user")
......
"""外部岗位认领路由(ADR 0003)。
- GET /api/job-claims 读取全部待认领/已认领外部岗位(任何已登录用户)
- POST /api/job-claims/{id}/claim 把待认领岗位认领进本人岗位管理(生成 jobId)
所有端点均走 get_current_user,不绕过认证;待认领区归属不通过 ?owner= 指定他人,
只操作「待认领区 -> 本人工作区」边界,天然满足工作区隔离。
"""
from typing import Any
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session
from backend.app.db import get_db
from backend.app.dependencies import CurrentUser, get_current_user
from backend.app.repositories.external_claim_repository import (
CLAIM_OWNER,
ClaimError,
claim_external_job,
list_external_claims,
)
router = APIRouter()
@router.get("/job-claims")
def get_job_claims(
db: Session = Depends(get_db),
_: CurrentUser = Depends(get_current_user),
) -> dict[str, Any]:
claims = list_external_claims(db)
return {"ok": True, "claims": claims, "total": len(claims), "owner": CLAIM_OWNER}
@router.post("/job-claims/{claim_id}/claim")
def claim_job(
claim_id: str,
db: Session = Depends(get_db),
user: CurrentUser = Depends(get_current_user),
) -> dict[str, Any]:
try:
job = claim_external_job(db, claim_id, user.username)
except ClaimError as exc:
raise HTTPException(status_code=exc.status_code, detail=exc.detail) from exc
return {"ok": True, "job": job}
"""分析报告(候选人报告)路由(ADR 0002)。
- POST /api/reports 为当前工作区候选人冻结一份分享快照
- GET /api/reports?candidateId=... 列出该候选人报告历史(附分享开关状态)
- PATCH /api/reports/{report_id} 停用 / 重新启用({status: active|disabled})
- GET /api/share/report/{report_id} 公网匿名只读(带不可猜 token,security.py 放行)
管理端点一律登录态 + resolve_owner 工作区隔离;公开只读端点独立于账号体系,
路由内部校验 report_id + token,失败统一按「不存在/已失效」处理。
"""
from typing import Any
from fastapi import APIRouter, Depends, HTTPException, Request
from sqlalchemy import select
from sqlalchemy.orm import Session
from backend.app.config import get_settings
from backend.app.db import get_db
from backend.app.dependencies import CurrentUser, get_current_user, resolve_owner
from backend.app.models import Candidate
from backend.app.repositories.pool_repository import POOL_OWNER
from backend.app.repositories.report_repository import (
ReportError,
create_report,
list_reports,
read_public_report,
set_report_status,
)
router = APIRouter()
def _base_url(request: Request) -> str:
"""分享链接基准:PUBLIC_BASE_URL 配置优先,否则回退当前请求 Host。"""
configured = (get_settings().report_public_base_url or "").strip().rstrip("/")
return configured or str(request.base_url).rstrip("/")
def _candidate_id(payload: dict[str, Any]) -> str:
value = str(payload.get("candidateId") or "").strip()
if not value:
raise HTTPException(status_code=400, detail="缺少候选人 id")
return value
@router.post("/reports")
def generate_report(
payload: dict[str, Any],
request: Request,
db: Session = Depends(get_db),
user: CurrentUser = Depends(get_current_user),
owner: str = Depends(resolve_owner),
) -> dict[str, Any]:
if owner == POOL_OWNER:
raise HTTPException(status_code=400, detail="人才池候选人的分析报告暂不支持,请先认领到工作区")
candidate_id = _candidate_id(payload)
candidate = db.scalar(select(Candidate).where(Candidate.id == candidate_id, Candidate.owner_id == owner))
if candidate is None:
raise HTTPException(status_code=404, detail="候选人不存在或不属于当前工作区")
try:
report = create_report(
db,
owner=owner,
candidate=candidate,
author_username=user.username,
author_name=user.name,
base_url=_base_url(request),
)
except ReportError as exc:
raise HTTPException(status_code=exc.status_code, detail=exc.detail) from exc
return {"ok": True, "report": report}
@router.get("/reports")
def reports_list(
request: Request,
db: Session = Depends(get_db),
_: CurrentUser = Depends(get_current_user),
owner: str = Depends(resolve_owner),
) -> dict[str, Any]:
candidate_id = str(request.query_params.get("candidateId") or "").strip()
if not candidate_id:
raise HTTPException(status_code=400, detail="缺少候选人 id")
reports = list_reports(db, owner=owner, candidate_id=candidate_id, base_url=_base_url(request))
return {
"ok": True,
"reports": reports,
"shareEnabled": bool(get_settings().report_share_enabled),
"shareBaseUrl": _base_url(request),
}
@router.patch("/reports/{report_id}")
def update_report_status(
report_id: str,
payload: dict[str, Any],
request: Request,
db: Session = Depends(get_db),
_: CurrentUser = Depends(get_current_user),
owner: str = Depends(resolve_owner),
) -> dict[str, Any]:
try:
report = set_report_status(
db,
owner=owner,
report_id=report_id,
status=str(payload.get("status") or "").strip(),
base_url=_base_url(request),
)
except ReportError as exc:
raise HTTPException(status_code=exc.status_code, detail=exc.detail) from exc
return {"ok": True, "report": report}
@router.get("/share/report/{report_id}")
def public_share_report(report_id: str, request: Request, db: Session = Depends(get_db)) -> dict[str, Any]:
"""公网匿名只读:不依赖登录态,报告路由在 security.py 已放行 GET。"""
token = str(request.query_params.get("token") or "").strip()
reason, report = read_public_report(
db,
report_id=report_id,
token=token,
share_enabled=bool(get_settings().report_share_enabled),
base_url=_base_url(request),
)
if reason == "ok" and report is not None:
return {"ok": True, "report": report}
return {"ok": False, "reason": reason}
......@@ -5,6 +5,7 @@ from sqlalchemy.orm import Session
from backend.app.db import get_db
from backend.app.dependencies import CurrentUser, get_current_user, resolve_owner
from backend.app.repositories.external_claim_repository import CLAIM_OWNER
from backend.app.repositories.json_utils import pretty_json
from backend.app.repositories.pool_repository import POOL_OWNER
from backend.app.repositories.state_repository import database_is_empty, read_state, replace_state
......@@ -29,7 +30,7 @@ def put_state(
_: CurrentUser = Depends(get_current_user),
owner: str = Depends(resolve_owner),
) -> dict[str, Any]:
if owner == POOL_OWNER:
if owner in (POOL_OWNER, CLAIM_OWNER):
raise HTTPException(status_code=400, detail='总库(人才池)请通过人才池接口维护,禁止整包覆盖')
state = replace_state(db, owner, payload, "api-state")
return {"ok": True, "state": state, "updatedAt": state["updatedAt"], "owner": owner}
......@@ -42,7 +43,7 @@ def import_local_state(
_: CurrentUser = Depends(get_current_user),
owner: str = Depends(resolve_owner),
) -> dict[str, Any]:
if owner == POOL_OWNER:
if owner in (POOL_OWNER, CLAIM_OWNER):
raise HTTPException(status_code=400, detail='总库(人才池)请通过人才池接口维护,禁止整包覆盖')
state = replace_state(db, owner, payload, "localStorage-import")
return {"ok": True, "imported": True, "state": state, "updatedAt": state["updatedAt"], "owner": owner}
......
......@@ -31,6 +31,9 @@ class DeploymentAuthMiddleware(BaseHTTPMiddleware):
# 配置了 INGEST_TOKEN 时,采集接口交给路由内令牌校验
if path in ("/api/ingest", "/api/jd/sync-external") and settings.ingest_token:
return await call_next(request)
# 分析报告公网只读(ADR 0002):匿名放行 GET,路由内部校验 report_id + token。
if path.startswith("/api/share/") and request.method == "GET":
return await call_next(request)
header = request.headers.get("authorization", "")
# 兼容旧 Basic Auth(任意用户名 + 正确密码),映射为管理员
password = _read_basic_password(header)
......
"""对外 JD 同步:接收企微 QwenPaw / 用人部门智能体产出的 JD,落库为岗位
"""对外 JD 同步:接收企微 QwenPaw / 用人部门智能体产出的 JD,落入「待认领区」
企微里的 QwenPaw 通过多轮追问用人部门产出 JD 后,可用本服务把结果
同步回招聘系统,创建或更新对应岗位。
企微里的 QwenPaw 通过多轮追问用人部门产出 JD 后,把结果同步回招聘系统。
按 ADR 0003 的设计,外部 JD 不再直接创建/更新岗位,而是先落公司级「待认领区」
(external_job_claims 表),由招聘专员在「岗位认领」菜单里认领进自己的岗位管理
(认领时才生成 jobId)。OA 同库直读接通后,本接口退化为本地/测试的过渡期入口,
与 OA 审批直读共用同一个待认领源(source 可切换)。
"""
from typing import Any
from __future__ import annotations
from uuid import uuid4
from sqlalchemy import select
from sqlalchemy.orm import Session
from backend.app.models import Job
from backend.app.repositories.json_utils import dump_json
def _make_id(prefix: str) -> str:
return f"{prefix}-{uuid4().hex[:8]}"
from backend.app.repositories.external_claim_repository import upsert_external_claim
def sync_external_jd(db: Session, payload: dict) -> dict:
"""把外部 QwenPaw 产出的 JD 同步为招聘系统岗位
def sync_external_jd(db: Session, payload: dict[str, Any]) -> dict[str, Any]:
"""把外部产出的 JD 落入待认领区(幂等 upsert),返回认领单摘要
以 payload["external_id"] 或 payload["job"]["title"] 作为匹配键:
- 命中已有岗位 → 更新其 JD 字段
- 未命中 → 新建岗位
返回 upsert 后的岗位 JSON 与操作类型。
稳定键:payload["external_id"] / externalJobId / oaBillNo / job.externalId 任一命中时
更新同一认领单内容(未认领时);已认领则跳过。不直接创建岗位。
"""
job = payload.get("job") or {}
title = str(job.get("title") or payload.get("title") or "未命名岗位").strip()
external_id = str(payload.get("external_id") or payload.get("externalJobId") or "").strip()
# 匹配:external_id 优先,其次按 title
match_id = external_id or title
existing = None
if match_id:
existing = db.scalar(select(Job).where(Job.id == match_id))
if existing is None and title:
existing = db.scalar(select(Job).where(Job.title == title).order_by(Job.updated_at.desc()))
jd_version = str(job.get("jdVersion") or payload.get("jdVersion") or "v1 外部同步")
jd_status = str(job.get("jdStatus") or payload.get("jdStatus") or "待确认")
approval_status = str(job.get("approvalStatus") or payload.get("approvalStatus") or "企微智能体产出,待用人部门确认")
if existing is not None:
old_data = dump_json(_job_dict(existing))
merged = {
**_job_dict(existing),
**job,
"jdVersion": jd_version,
"jdStatus": jd_status,
"approvalStatus": approval_status,
}
existing.title = merged.get("title") or existing.title
existing.department = merged.get("department") or existing.department
existing.jd_status = jd_status
existing.approval_status = approval_status
existing.jd_version = jd_version
existing.data = dump_json(merged)
operation = "updated"
result = merged
else:
new_id = match_id or _make_id("job")
merged = {
"id": new_id,
"title": title,
"department": job.get("department") or "",
"status": job.get("status") or "",
"owner": job.get("owner") or "",
"priority": job.get("priority") or "",
"headcount": job.get("headcount"),
"hired": job.get("hired"),
"jdVersion": jd_version,
"jdStatus": jd_status,
"approvalStatus": approval_status,
"matchRuleStatus": job.get("matchRuleStatus") or "",
"jd": job.get("jd") or "",
"responsibilities": job.get("responsibilities") or "",
"requirements": job.get("requirements") or "",
"mustHave": job.get("mustHave") or "",
"niceToHave": job.get("niceToHave") or "",
"knockout": job.get("knockout") or "",
"competency": job.get("competency") or "",
"matchKeywords": job.get("matchKeywords") or "",
"nextAction": job.get("nextAction") or "推送用人部门确认 JD 草稿。",
"source": payload.get("source") or "qwenpaw-external",
}
row = Job(
id=new_id,
title=title,
department=merged.get("department") or "",
status=merged.get("status") or "",
owner=merged.get("owner") or "",
priority=merged.get("priority") or "",
headcount=merged.get("headcount"),
hired=merged.get("hired"),
jd_status=jd_status,
approval_status=approval_status,
jd_version=jd_version,
match_rule_status=merged.get("matchRuleStatus") or "",
data=dump_json(merged),
)
db.add(row)
operation = "created"
result = merged
db.commit()
db.refresh(existing) if existing is not None else None
return {"ok": True, "operation": operation, "job": result}
def _job_dict(job: Job) -> dict:
import json as _json
data = job.data
try:
return _json.loads(data) if isinstance(data, str) else (data or {})
except Exception:
return {}
return upsert_external_claim(db, payload)
"""外部岗位认领(ADR 0003)测试:sync-external 落待认领区 + 全 HR 可见 + 认领进岗位管理。
- 开放模式(client fixture):走完整 同步 -> 待认领列表 -> 认领 -> 岗位管理 闭环。
- 关闭模式(auth_client fixture):两个用户 A/B,A 同步,B 可见并认领,
验证工作区隔离不被破坏;admin 也不能对保留区整包 PUT;保留用户名不可注册。
"""
import pytest
from fastapi.testclient import TestClient
@pytest.fixture()
def auth_client(tmp_path, monkeypatch):
monkeypatch.setenv("RECRUITMENT_SKIP_ENV_FILES", "1")
monkeypatch.setenv("DATABASE_URL", f"sqlite:///{(tmp_path / 'test-claims-auth.sqlite').as_posix()}")
monkeypatch.setenv("DATA_DIR", str(tmp_path / "data"))
monkeypatch.setenv("FILES_DIR", str(tmp_path / "uploads"))
monkeypatch.setenv("ADMIN_PASSWORD", "admin-secret")
monkeypatch.setenv("ADMIN_USERNAME", "admin")
monkeypatch.setenv("SESSION_SECRET", "test-session-secret")
monkeypatch.setenv("INGEST_TOKEN", "")
monkeypatch.setenv("LLM_API_KEY", "")
monkeypatch.setenv("DEEPSEEK_API_KEY", "")
monkeypatch.setenv("OPENAI_API_KEY", "")
import backend.app.config as config
import backend.app.db as db
config.get_settings.cache_clear()
db.engine.dispose()
db.settings = config.get_settings()
db.engine = db.create_engine(
db.normalize_database_url(db.settings.database_url),
connect_args={"check_same_thread": False},
future=True,
)
db.SessionLocal.configure(bind=db.engine)
from backend.app.main import create_app
app = create_app()
with TestClient(app) as test_client:
yield test_client
db.engine.dispose()
config.get_settings.cache_clear()
def _login(client, username, password):
return client.post("/api/auth/login", json={"username": username, "password": password})
def _headers(token):
return {"Authorization": f"Bearer {token}"}
def _external_jd_payload(title="高级前端工程师", external_id="bill-ext-001"):
return {
"external_id": external_id,
"source": "qwenpaw-external",
"approvalStatus": "审批通过",
"job": {
"title": title,
"department": "技术部",
"headcount": 2,
"jd": "负责前端架构与落地",
"responsibilities": "前端架构、工程化",
"requirements": "5 年经验",
"mustHave": "Vue3",
"niceToHave": "React",
"knockout": "无真实项目",
"matchKeywords": "前端 Vue",
"competency": "专业能力",
},
}
# ---- 开放模式:完整闭环 ----
def test_external_jd_staged_not_direct_job(client):
# 同步外部 JD -> 只进待认领区,不直接建岗位
resp = client.post("/api/jd/sync-external", json=_external_jd_payload())
assert resp.status_code == 200
body = resp.json()
assert body["ok"] is True
assert body["operation"] == "created"
claim = body["claim"]
assert claim["claimed"] is False
assert claim["title"] == "高级前端工程师"
assert claim["jobId"] == ""
# 岗位管理里还没有岗位(不直接建岗)
state = client.get("/api/state").json()
assert state["jobs"] == []
def test_sync_idempotent_by_external_id(client):
first = client.post("/api/jd/sync-external", json=_external_jd_payload()).json()
second = client.post("/api/jd/sync-external", json=_external_jd_payload()).json()
assert first["claim"]["id"] == second["claim"]["id"]
assert second["operation"] == "updated"
claims = client.get("/api/job-claims").json()["claims"]
assert len(claims) == 1
def test_claim_creates_job_in_workspace(client):
claim_id = client.post("/api/jd/sync-external", json=_external_jd_payload()).json()["claim"]["id"]
listed = client.get("/api/job-claims").json()
assert listed["total"] == 1
assert listed["claims"][0]["claimed"] is False
claimed = client.post(f"/api/job-claims/{claim_id}/claim").json()
job = claimed["job"]
assert job["id"].startswith("job-")
assert job["title"] == "高级前端工程师"
assert job["jdStatus"] == "待确认"
assert job["approvalStatus"] == "审批通过"
assert job["claimId"] == claim_id
# 岗位已进入认领人工作区,待认领区标记已认领
state = client.get("/api/state").json()
assert [j["title"] for j in state["jobs"]] == ["高级前端工程师"]
after = client.get("/api/job-claims").json()
assert after["claims"][0]["claimed"] is True
assert after["claims"][0]["claimedBy"] == "local"
assert after["claims"][0]["jobId"] == job["id"]
# 重复认领 -> 409
again = client.post(f"/api/job-claims/{claim_id}/claim")
assert again.status_code == 409
# 已认领后同键再同步 -> skipped
resynced = client.post("/api/jd/sync-external", json=_external_jd_payload()).json()
assert resynced["operation"] == "skipped"
def test_claim_unknown_and_missing_title(client):
assert client.post("/api/job-claims/nope/claim").status_code == 404
resp = client.post("/api/jd/sync-external", json={"job": {}})
assert resp.status_code == 200
assert resp.json()["ok"] is False
def test_reserved_owner_state_and_username(client):
# 开放模式 owner 是 local;保留归属 _external 禁止被当作用户创建(关闭模式验证)
empty = client.get("/api/job-claims").json()
assert empty["owner"] == "_external"
# ---- 关闭模式:多用户跨账号待认领区 ----
def test_job_claims_company_shared_and_isolated(auth_client):
admin_token = _login(auth_client, "admin", "admin-secret").json()["token"]
admin_headers = _headers(admin_token)
for username in ("alice", "bob"):
resp = auth_client.post(
"/api/auth/users",
json={"username": username, "name": username.title(), "role": "user", "password": f"{username}12345"},
headers=admin_headers,
)
assert resp.status_code == 200
alice_headers = _headers(_login(auth_client, "alice", "alice12345").json()["token"])
bob_headers = _headers(_login(auth_client, "bob", "bob12345").json()["token"])
# 未登录不可访问
assert auth_client.get("/api/job-claims").status_code == 401
assert auth_client.post("/api/job-claims/x/claim").status_code == 401
# admin 同步外部 JD(带 Bearer,开放 token 为空时路由内不校验令牌)
synced = auth_client.post("/api/jd/sync-external", json=_external_jd_payload(external_id="bill-shared-1"), headers=admin_headers)
assert synced.status_code == 200
claim_id = synced.json()["claim"]["id"]
# alice/bob 都能看到公司级待认领区
assert auth_client.get("/api/job-claims", headers=alice_headers).json()["total"] == 1
assert auth_client.get("/api/job-claims", headers=bob_headers).json()["total"] == 1
# bob 认领 -> 进入 bob 工作区;alice 工作区为空;清单显示已认领
claimed = auth_client.post(f"/api/job-claims/{claim_id}/claim", headers=bob_headers)
assert claimed.status_code == 200
bob_state = auth_client.get("/api/state", headers=bob_headers).json()
assert [j["title"] for j in bob_state["jobs"]] == ["高级前端工程师"]
alice_state = auth_client.get("/api/state", headers=alice_headers).json()
assert alice_state["jobs"] == []
after = auth_client.get("/api/job-claims", headers=alice_headers).json()
assert after["claims"][0]["claimed"] is True
assert after["claims"][0]["claimedBy"] == "bob"
# alice 不能重复认领(已认领 -> 409)
assert auth_client.post(f"/api/job-claims/{claim_id}/claim", headers=alice_headers).status_code == 409
def test_reserved_names_and_state_guards(auth_client):
admin_token = _login(auth_client, "admin", "admin-secret").json()["token"]
admin_headers = _headers(admin_token)
# 保留用户名 _external 不可注册
resp = auth_client.post(
"/api/auth/users",
json={"username": "_external", "name": "x", "role": "user", "password": "pass12345"},
headers=admin_headers,
)
assert resp.status_code == 400
# 保留区禁止整包 PUT(对齐 _pool)
payload = {"jobs": [], "candidates": [], "offers": [], "tasks": [], "eventLog": []}
assert auth_client.put("/api/state", json=payload, params={"owner": "_external"}, headers=admin_headers).status_code == 400
"""分析报告(候选人报告)测试:生成/列表/停用/公网只读 + 隔离与生命周期边界(ADR 0002)。
- 开放模式(share_open_client):完整闭环 生成 -> 公网读取 -> 停用 -> 重新启用;
候选人删除后链接失效;移入人才池后链接仍有效。
- 分享默认关闭(client fixture):生成可用,但公网读取统一 not_found。
- 关闭模式(auth_reports_client):管理端点工作区隔离;公网只读匿名可访问。
"""
from urllib.parse import parse_qs, urlparse
import pytest
from fastapi.testclient import TestClient
def _candidate(candidate_id="cand-r1", **overrides):
base = {
"id": candidate_id,
"name": "张三",
"jobTitle": "Java 开发工程师",
"source": "Boss",
"stage": "初筛通过",
"analysis": {
"provider": "llm:deepseek-chat",
"reportVersion": "ai-evaluation-v2",
"score": 86,
"decision": "建议优先推进面试",
"summary": "三年 Java 后端经验,熟悉 Spring Cloud,与岗位匹配度较高。",
"hardMatch": ["熟悉 Java/Spring Cloud"],
"experienceMatch": ["三年后端研发经验"],
"projectEvidence": ["主导订单中心重构"],
"risks": [],
"interviewQuestions": ["请说明项目贡献"],
"scoreBreakdown": {"hardMatchScore": 30, "finalScore": 86},
},
"matchReport": {"matched": ["Java", "Spring Cloud"]},
"quickMatchScore": 86,
}
base.update(overrides)
return base
def _state(candidate):
return {"jobs": [], "candidates": [candidate], "offers": [], "tasks": [], "eventLog": []}
def _build_app(tmp_path, monkeypatch, env_overrides, db_name):
env = {
"RECRUITMENT_SKIP_ENV_FILES": "1",
"DATABASE_URL": f"sqlite:///{(tmp_path / db_name).as_posix()}",
"DATA_DIR": str(tmp_path / "data"),
"FILES_DIR": str(tmp_path / "uploads"),
"ADMIN_PASSWORD": "",
"ADMIN_USERNAME": "admin",
"SESSION_SECRET": "test-session-secret",
"INGEST_TOKEN": "",
"LLM_API_KEY": "",
"DEEPSEEK_API_KEY": "",
"OPENAI_API_KEY": "",
"REPORT_SHARE_ENABLED": "",
"PUBLIC_BASE_URL": "",
}
env.update(env_overrides)
for key, value in env.items():
monkeypatch.setenv(key, value)
import backend.app.config as config
import backend.app.db as db
config.get_settings.cache_clear()
db.engine.dispose()
db.settings = config.get_settings()
db.engine = db.create_engine(
db.normalize_database_url(db.settings.database_url),
connect_args={"check_same_thread": False},
future=True,
)
db.SessionLocal.configure(bind=db.engine)
from backend.app.main import create_app
app = create_app()
return config, db, app
@pytest.fixture()
def share_open_client(tmp_path, monkeypatch):
config, db, app = _build_app(
tmp_path,
monkeypatch,
{"REPORT_SHARE_ENABLED": "1", "PUBLIC_BASE_URL": "https://hr.example.com"},
"test-reports-open.sqlite",
)
with TestClient(app) as test_client:
yield test_client
db.engine.dispose()
config.get_settings.cache_clear()
@pytest.fixture()
def auth_reports_client(tmp_path, monkeypatch):
config, db, app = _build_app(
tmp_path,
monkeypatch,
{
"ADMIN_PASSWORD": "admin-secret",
"REPORT_SHARE_ENABLED": "1",
"PUBLIC_BASE_URL": "https://hr.example.com",
},
"test-reports-auth.sqlite",
)
with TestClient(app) as test_client:
yield test_client
db.engine.dispose()
config.get_settings.cache_clear()
def _login(client, username, password):
return client.post("/api/auth/login", json={"username": username, "password": password}).json()["token"]
def _headers(token):
return {"Authorization": f"Bearer {token}"}
def _share_params(share_url):
parsed = urlparse(share_url)
token = parse_qs(parsed.query).get("token", [""])[0]
report_id = parsed.path.rsplit("/", 1)[-1]
return report_id, token
# ---- 开放模式 + 分享开启:完整闭环 ----
def test_generate_report_and_public_read(share_open_client):
assert share_open_client.put("/api/state", json=_state(_candidate())).status_code == 200
created = share_open_client.post("/api/reports", json={"candidateId": "cand-r1"})
assert created.status_code == 200
report = created.json()["report"]
assert report["reportId"].startswith("REP-")
assert report["status"] == "active"
assert report["shareUrl"].startswith("https://hr.example.com/share/report/")
assert "token=" in report["shareUrl"]
assert report["snapshot"]["candidateName"] == "张三"
snapshot = report["snapshot"]
assert "phone" not in snapshot and "email" not in snapshot and "resumeText" not in snapshot
assert snapshot["author"]["name"] == "本地用户"
report_id, token = _share_params(report["shareUrl"])
public = share_open_client.get(f"/api/share/report/{report_id}", params={"token": token})
assert public.status_code == 200
body = public.json()
assert body["ok"] is True
assert body["report"]["reportId"] == report_id
assert body["report"]["snapshot"]["analysis"]["score"] == 86
listed = share_open_client.get("/api/reports", params={"candidateId": "cand-r1"}).json()
assert listed["shareEnabled"] is True
assert len(listed["reports"]) == 1
def test_generate_requires_ai_analysis(share_open_client):
candidate = _candidate()
candidate["analysis"] = None
assert share_open_client.put("/api/state", json=_state(candidate)).status_code == 200
response = share_open_client.post("/api/reports", json={"candidateId": "cand-r1"})
assert response.status_code == 400
def test_generate_unknown_candidate_404(share_open_client):
assert share_open_client.post("/api/reports", json={"candidateId": "cand-nope"}).status_code == 404
def test_disable_reenable_report(share_open_client):
share_open_client.put("/api/state", json=_state(_candidate()))
report = share_open_client.post("/api/reports", json={"candidateId": "cand-r1"}).json()["report"]
report_id, token = _share_params(report["shareUrl"])
disabled = share_open_client.patch(f"/api/reports/{report_id}", json={"status": "disabled"})
assert disabled.status_code == 200
assert disabled.json()["report"]["status"] == "disabled"
public = share_open_client.get(f"/api/share/report/{report_id}", params={"token": token}).json()
assert public["ok"] is False and public["reason"] == "disabled"
enabled = share_open_client.patch(f"/api/reports/{report_id}", json={"status": "active"})
assert enabled.json()["report"]["status"] == "active"
public = share_open_client.get(f"/api/share/report/{report_id}", params={"token": token}).json()
assert public["ok"] is True
def test_public_read_wrong_token_or_unknown(share_open_client):
share_open_client.put("/api/state", json=_state(_candidate()))
report = share_open_client.post("/api/reports", json={"candidateId": "cand-r1"}).json()["report"]
report_id, _ = _share_params(report["shareUrl"])
body = share_open_client.get(f"/api/share/report/{report_id}", params={"token": "wrong-token"}).json()
assert body["ok"] is False and body["reason"] == "not_found"
body = share_open_client.get("/api/share/report/REP-20000101-XXXXXX", params={"token": "x"}).json()
assert body["ok"] is False and body["reason"] == "not_found"
# ---- SPA 直链回退(公网分享页深链需返回 index.html)----
def test_spa_deep_link_fallback_serves_index(client):
response = client.get("/share/report/REP-20260101-XXXXXX")
assert response.status_code == 200
assert "text/html" in response.headers.get("content-type", "")
# ---- 分享默认关闭 ----
def test_share_disabled_by_default(client):
assert client.put("/api/state", json=_state(_candidate())).status_code == 200
report = client.post("/api/reports", json={"candidateId": "cand-r1"}).json()["report"]
listed = client.get("/api/reports", params={"candidateId": "cand-r1"}).json()
assert listed["shareEnabled"] is False
report_id, token = _share_params(report["shareUrl"])
public = client.get(f"/api/share/report/{report_id}", params={"token": token}).json()
assert public["ok"] is False and public["reason"] == "not_found"
# ---- 生命周期边界 ----
def test_candidate_deleted_invalidates_link(share_open_client):
share_open_client.put("/api/state", json=_state(_candidate()))
report = share_open_client.post("/api/reports", json={"candidateId": "cand-r1"}).json()["report"]
report_id, token = _share_params(report["shareUrl"])
empty_state = {"jobs": [], "candidates": [], "offers": [], "tasks": [], "eventLog": []}
assert share_open_client.put("/api/state", json=empty_state).status_code == 200
public = share_open_client.get(f"/api/share/report/{report_id}", params={"token": token}).json()
assert public["ok"] is False and public["reason"] == "not_found"
def test_pool_move_keeps_link_valid(share_open_client):
share_open_client.put("/api/state", json=_state(_candidate()))
report = share_open_client.post("/api/reports", json={"candidateId": "cand-r1"}).json()["report"]
report_id, token = _share_params(report["shareUrl"])
moved = share_open_client.post("/api/pool/move", json={"candidate": {"id": "cand-r1"}})
assert moved.status_code == 200
public = share_open_client.get(f"/api/share/report/{report_id}", params={"token": token}).json()
assert public["ok"] is True
def test_pool_candidate_generate_blocked(share_open_client):
share_open_client.put("/api/state", json=_state(_candidate()))
share_open_client.post("/api/pool/move", json={"candidate": {"id": "cand-r1"}})
# 候选已属 _pool,普通工作区视角 404(v1 不支持池内生成)
assert share_open_client.post("/api/reports", json={"candidateId": "cand-r1"}).status_code == 404
# ---- 关闭模式:工作区隔离 + 匿名公网只读 ----
def test_reports_workspace_isolation_and_public_share(auth_reports_client):
admin_token = _login(auth_reports_client, "admin", "admin-secret")
admin_headers = _headers(admin_token)
auth_reports_client.post(
"/api/auth/users",
json={"username": "hrb", "name": "专员B", "role": "user", "password": "pass-b-123"},
headers=admin_headers,
)
state = _state(_candidate("cand-a1"))
assert auth_reports_client.put("/api/state", json=state, headers=admin_headers).status_code == 200
report = auth_reports_client.post(
"/api/reports", json={"candidateId": "cand-a1"}, headers=admin_headers
).json()["report"]
# 匿名公网只读可用(无需登录)
report_id, token = _share_params(report["shareUrl"])
public = auth_reports_client.get(f"/api/share/report/{report_id}", params={"token": token})
assert public.status_code == 200 and public.json()["ok"] is True
# 普通用户不能跨工作区访问
b_token = _login(auth_reports_client, "hrb", "pass-b-123")
b_headers = _headers(b_token)
assert (
auth_reports_client.get("/api/reports", params={"candidateId": "cand-a1", "owner": "admin"}, headers=b_headers).status_code
== 403
)
assert auth_reports_client.patch(f"/api/reports/{report_id}", json={"status": "disabled"}, headers=b_headers).status_code == 404
# admin 可显式 owner 查看
listed = auth_reports_client.get(
"/api/reports", params={"candidateId": "cand-a1", "owner": "admin"}, headers=admin_headers
).json()
assert len(listed["reports"]) == 1
# 匿名仍不能访问受保护业务接口
assert auth_reports_client.get("/api/state").status_code == 401
# ADR 0002:分析报告 = 独立快照表 + 令牌公网分享(不进整包 state)
- 状态:已接受(2026-09-03)
- 关联决策:ADR 0001(人才池 `_pool` 行级接口);AGENTS.md §4.1(整包 state 覆盖)与 §4.4(候选人信息脱敏纪律)
- 背景:系统定位从「单机/内网工具」转向**公网部署**——分公司用人部门经公网打开候选人报告链接。需求:候选人档案抽屉(`ResumeDetail.vue`)新增「分析报告生成」,导出后凭链接访问(参照 AI秒聘 `/share/ai-report/...?report_id=...`)。
## 背景 / Context
- 现状:候选人实时评估 = `candidate.analysis`,作为整包 JSON state 的一部分随 `PUT /api/state` 保存;`replace_state`**先清空该 owner 业务表再重写**。若把分享报告做成 state 同步业务表,每次前端保存都会把它冲掉,因此报告必须**独立存储**(同 `users`/`ingest_items`/池行级机制)。
- 现状:所有 `/api``DeploymentAuthMiddleware` 拦截(只放行 ping/login);公网匿名访问需要一个**显式放行 + 令牌校验**的只读接口。
- 现状:候选人含真实 PII(姓名/电话/邮箱/原简历文件);对外分享必须走字段白名单,不能整包外泄。
## 决策 / Decision
1. **新增独立业务表 `candidate_reports`(带 `owner_id`),不进整包 state 同步**;报告为**自包含快照**,公开渲染不依赖候选人行。生成时由后端从候选人行 data JSON(当前 owner 名下)冻结内容,避免信任前端字段。
2. **快照语义**:每次「生成」= 若候选人无有效 AI 评估则先跑一次(前端复用现有 `generateEval` 的重用/刷新逻辑),persist 后冻结成新报告;`report_id` 形如 `REP-YYYYMMDD-XXXX`,记录 `generated_at`;链接永远指向该快照,再次生成 = 新报告。
3. **分享**:公网匿名**只读** GET 接口(`/api/share/report/{report_id}?token=...`)在 `DeploymentAuthMiddleware` 显式放行;令牌不可猜、校验失败/已停用/候选行已删除 → 按失效处理。生成/列表/停用/重新启用等管理接口一律走登录态 + `resolve_owner` 工作区隔离。
4. **公开可见字段白名单**:真实姓名、关联岗位、评估方式、得分、初筛结论、总结、匹配点/风险点/面试确认点、风险固定维度、生成人。**禁止**电话/邮箱/原简历文件进入快照与分享页(对齐 AGENTS §4.4 的对外边界)。
5. **生命周期**:停用(disable)= 链接打开显示「报告已失效」,记录保留、可重新启用;候选被删除(整包 state 覆盖后行消失)→ 链接自动按失效处理;移入人才池(行迁到 `_pool`)→ 链接继续有效。v1 不做自动过期,但表结构预留到期字段。
6. **开关与地址**:分享功能**默认关闭**,需显式配置(`REPORT_SHARE_ENABLED` + `PUBLIC_BASE_URL`)才开放匿名分享;分享链接 base 取 `PUBLIC_BASE_URL`,未配置时回退请求 Host。
7. **v1 范围**:仅个人工作区简历库抽屉;人才池候选人的报告生成/分享放 v2(池视图与权限语义不同)。
8. **前端形态**:抽屉内「分析报告」面板管理历史(打开/复制链接/停用/重新启用/生成新报告);公网报告页为 SPA 公开路由(免登录可看,已登录用户也能看,不做跳转),移动端友好、可打印,不含应用壳。
## 影响 / Consequences
- 正向:分享产物与实时评估解耦,符合「导出时刻所见即所得」;不破坏整包 state 机制;匿名面小(只读 + 令牌 + 白名单 + 默认关)。
- 代价/注意:报告独立存储 → **整包「导出备份」不含报告**,需要时后续补报告专用导出/迁移;快照内容在生成后不随候选人资料更新(这是刻意语义,分享页应显示「报告生成时间」);公开接口需在 `security.py` 放行名单中精确按前缀处理,避免误放其它 `/api/share` 业务。
- 后续可做:Phase B 更丰富的 AI 报告模板(一句话结论 + 维度/亮点/风险 + JD 逐条比对表,同 AI秒聘);PDF(水印)导出;自动过期;人才池报告;报告备份/迁移。
# ADR 0003:外部岗位认领 = 企微产出 JD 直推 OA、审批通过后由招聘专员认领入岗位管理(OA 审批源可切换)
- 状态:已接受(2026-09-03)
- 关联决策:ADR 0001(人才池 `_pool` 行级接口 + 认领模式)、ADR 0002(独立快照表不进整包 state);AGENTS.md §4.1(整包 state)与 §4.4(对内字段脱敏纪律);需求 1(企微智能体建岗后自动进系统)延伸
## 背景 / Context
岗位有两条进入招聘流程的入口:① HR 在招聘系统内直接建岗(网页 + 系统内 grill,`jd_grill.py`);② 用人部门在企微里跟 QwenPaw(`recruit-grill` skill)对话产出 JD。两条最终都需经 OA 审批后才进入招聘。公司确认**招聘系统后期与 OA(hget_oa,MySQL)共用同一套数据库**,审批通过后招聘系统直接读 OA 审批表即可,不走 HTTP 回写。
recruit-grill skill 的产出是四件套(对外 JD / 对内笔记 / CONTEXT 硬规则 / 关键词迭代表),曾纠结"CONTEXT.md 与产出文件放 qwenpaw 服务目录还是招聘系统目录"。若堆在 qwenpaw 工作区(机器本地、gitignored、企微与本地两套实例、无多用户/权限/联动),HR 侧用不上、也无法驱动评估与流程;若与招聘系统根 `CONTEXT.md`(领域词表)混放,语义冲突。
## 决策 / Decision
1. **落位分层**:skill 本体(recruit-grill / jd-sync)以招聘系统仓库为**唯一维护源**`docs/recruit-grill`),qwenpaw 各工作区只放运行副本;**长期产出不进 qwenpaw 目录**。岗位级产出(对外字段 + 对内字段 + 访谈笔记归档)落招聘系统 `job` 数据;公司级知识(招聘硬规则/术语表/待定决策)落招聘系统侧**轻量规则库**(运行时文件 + 读写 API,admin 可写、登录用户与授权 agent 只读),首版**不建业务表**;关键词迭代表落岗位 `matchKeywords`;在招岗位表由岗位状态派生,不落 markdown。根 `CONTEXT.md` 保持领域词表单义,招聘规则库与其剥离。
2. **入口2 形态**:企微那台把 JD **直接推 OA 审批(不经招聘系统先行占位)**;审批通过后,招聘系统新增**「岗位认领」菜单**,招聘专员把审批通过的岗位**认领进本人岗位管理**(生成 jobId 后才成为正式岗位)。
3. **待认领源做成可切换插槽**:「OA 审批直读(后期同库,hget_oa 只读)」与「`sync-external` 直接落待认领区(过渡期/本地测试)」统一走同一个**待认领池**入口;外部 JD 不再直接建进岗位管理(旧直建行为降级为本地导入兜底)。
4. **认领语义复刻 ADR 0001 行级模式**:OA 审批表只读;招聘系统本地维护 bill↔job↔owner 映射(不进整包 state,类比 ADR 0002);认领 = 行级迁移 + 生成 jobId + 占位(他人见"已认领")+ 清待认领快照 + 事件/待办联动。列表全 HR 可见,认领归属本人。
5. **认领落点状态**:认领后 `jdStatus=待确认``approvalStatus=审批通过(来源 OA)`;招聘专员核对 JD 内容后置 `已生效` 再开招,不自动生效。
6. **OA bill 数据契约**:入口2 审批单必须携带**岗位全量 JSON(对外字段 + 对内字段/笔记分列标记)**,认领时按 normalize 规则解析入库;对内字段继续受 `_INTERNAL_ONLY_JOB_KEYS` 脱敏保护,不落入对外 JD / LLM prompt。
7. **入口1 审批源 seam**:保留系统内简化审批(草稿→待确认→审批中→已生效)为**过渡态**`approvalStatus` 设计为可切换源(内部简化 ↔ OA 直读),同库接通后映射 OA 表状态(待审/通过/驳回)到系统枚举;OA 是审批生命周期 owner,招聘系统只读消费。
## 影响 / Consequences
- 正向:企微产出与 HR 流程解耦又收口(统一经"待认领池 → 认领 → 岗位管理");认领复用人才池已验证的行级模式,不破坏整包 state / owner 隔离;对内字段脱敏纪律不变;skill 与规则库单一维护源,企微那台与本地不再漂移。
- 代价/注意:待认领清单与 bill 映射为整包 state 之外的独立存储 → 整包导出备份不含待认领区,需在认领/迁移逻辑中显式处理;OA 未接通前认领菜单只有过渡期数据源(sync-external 落点);入口2 若 OA 侧未按契约携带全量 JSON,认领会缺对内档案(契约需在企微那台 skill 分发时一并固化)。
- 后续可做:OA 同库直读落地(hget_oa,审批表结构确认后实现 Reader 映射);规则库从文件升级为业务表(当公司级规则需要驱动 AI 初筛时);认领前简历去重 / 批量认领。
\ No newline at end of file
# 外部岗位认领 + OA 审批直读 —— 设计定稿与落地清单
> 关联:ADR 0003(决策正文与理由)、根 CONTEXT.md(术语:外部岗位/待认领岗位/岗位认领/岗位对内档案/招聘规则库/OA 审批直读)
> 状态:**Phase 1 已落地(2026-09-03)**:后端待认领表 + sync-external 落待认领区 + 认领接口(89 tests passed);前端「岗位认领」菜单/页面 + 外部已审批岗位「核对后确认生效」流转(npm run build 通过)。
> 未做:Phase 2(规则库读写 API + agent 只读查询)、Phase 3(OA 同库直读 Reader + 审批源切换)。
## 一、定稿流程
```
入口1:HR 在 recruit-sys 建岗(网页/系统内 grill) ──提交 OA(bill↔jobId)──▶ OA 审批
入口2:用人部门在企微 QwenPaw(recruit-grill) 产出 JD ──JD 直推 OA(bill 携带岗位全量 JSON)──▶ OA 审批
│ 审批通过
recruit-sys「岗位认领」菜单(待认领源可切换:OA 同库直读 / 过渡期 sync-external)
│ 招聘专员认领 → 生成 jobId → 进本人岗位管理
岗位 待确认(approvalStatus=审批通过·来源OA)→ HR 核对 → 已生效 → 发布 → 招聘流程
```
## 二、现状已具备(无需改)
- 入口1:HR 建岗 + 系统内逼问式访谈(`jd_grill.py` / `JdGrillModal.vue`
- 岗位对内字段承载与脱敏(`llm.py _INTERNAL_ONLY_JOB_KEYS` / `_sanitize_job_for_external`
- 对外 JD 同步接口雏形(`external_sync.py` + `POST /api/jd/sync-external`,仅对外字段子集)
- 简化审批状态机(`vue-app/src/utils/jobStatus.js``APPROVAL_STATUS` / `JD_STATUS` + `jobFlow.js`
- 人才池认领行级模式(ADR 0001:`_pool` + move/claim + 清快照)可复刻到岗位认领
- skill 仓库镜像 `docs/recruit-grill/`(未提交,待提升为维护源)
## 三、待改动(按模块,落地时按此核对)
### backend
1. `external_sync.py` 契约扩展:接收岗位**全量 JSON(对外字段 + 对内字段/笔记分列标记)**,解析对齐 normalize;新字段纳入 `_INTERNAL_ONLY_JOB_KEYS` 隔离
2. 同步落点改为「待认领区」(行级表,不复用 owner 整包写);旧直建行为降级为本地导入兜底(二选一,默认前者)
3. 待认领源抽象 + 岗位认领行级接口:列表(全 HR 可见)/ 认领(生成 jobId、写 owner、清快照、事件/待办 `job_claimed`
4. bill↔job↔owner 映射表(独立存储,不进整包 state,类比 ADR 0002);bill 记录 claimed_by / claimed_at
5. 招聘规则库读写 API(admin 写、登录用户只读、agent token 只读);首版文件形态不进业务表
6. agent 只读查询接口:按 title/jobId 读岗位(含对内字段,仅授权 agent)+ 读规则库
7. 审批源 seam:`approvalStatus` 增加来源标记 `internal | oa`;同库后实现 OA Reader 映射(OA 待审/通过/驳回 ↔ 系统枚举);现阶段保留系统内简化审批
### vue-app
8. 「岗位认领」菜单 + 待认领列表页(含"已认领"徽标)→ 认领 → 进岗位管理
9. 岗位详情/编辑:岗位对内档案展示与编辑(仅 HR 可见,不出对外);认领后"核对并确认生效"动作
10. `jobFlow.js` / `jobStatus.js``approvalStatus` 增补「审批通过·来源OA」等流转文案与步骤;常量集中定义
11. `utils/normalize.js` / `utils/constants.js`:新增字段(含对内笔记归档)与后端 data JSON 对齐
### skill / 分发
12. `docs/recruit-grill/` 提升为唯一维护源并提交(SKILL.md 注明产出路径约定 = 岗位全量 JSON 走 bill,不落 qwenpaw 目录)
13. `jd-sync` / `recruit-grill` 产出契约与 bill 全量字段一致;SKILL.md「改版纪律」四件套载体同步检查
14. 分发:本地 qwenpaw workspace 与 ~/.agents/skills 副本以仓库为准;企微那台部署按同一份拷贝 + OA 侧契约
### 测试与规范
15. 保持后端 25 passed;涉及 models/state_repository 时同步改(§4.1);新表 owner_id 硬规则(§4.2);`test_auth`/`test_ai`/`test_state`/`test_pool` 相应补充;前端改完 `npm run build`
16. 幂等键评估(§4.6):bill 维度是否需要纳入(认领去重以 bill 为键)
## 四、后续项(本期不做,设计预留)
- OA 同库直读落地:确认 hget_oa 审批表结构后实现 Reader + 状态映射(沿用 hr-oa「只增不减」融合先例,OA 表只读)
- 企微那台部署:skill 分发 + 「JD 直推 OA」的对接在 OA/企微侧,招聘系统只消费结果
- 招聘规则库升级为业务表(当公司级规则需驱动 AI 初筛时,再评估 ADR 0001 `_pool` 式共享实体)
- 批量认领 / 认领前查重
## 五、落地顺序建议
- Phase 1(闭环最短):skill 维护源提交 → sync 契约扩展 + 待认领区 → 认领接口与页面(过渡期可完整走通"企微产出→待认领→认领→岗位")
- Phase 2:规则库读写 API + agent 只读查询(老岗位迭代/增量 grill 可读回)
- Phase 3(OA):同库直读 Reader + 审批源 seam 切换 + 前端状态映射
- 每阶段结束:`backend\.venv\Scripts\python.exe -m pytest -c backend/pyproject.toml` 25 passed + `cd vue-app && npm run build`
\ No newline at end of file
---
name: recruit-grill
description: 从用人部门的一句话招聘需求出发,用逼问式访谈把模糊需求梳理成可执行的初筛标准,产出对外 JD、对内寻源笔记、CONTEXT.md 硬规则更新与搜索词。当用户(用人部门/业务负责人)说"我要招人"、"有新需求"、"新开一个岗位"、"梳理 XX 岗的要求"、"帮我写 JD"、"XX 岗重新招/迭代"时使用——尤其是只有一句话、没有 JD、说不清具体要求的时候。区别于 market-talent-mapping(市场盘点),本 skill 是一次只梳理一个岗位的需求本身。
---
# recruit-grill —— 从一句话需求到可执行初筛标准
## 定位
这是**用人部门发起招聘需求**的入口 skill。核心不变:从一句话需求出发,通过逼问式一步步梳理成可执行的初筛标准。一次只梳理一个岗位。
用人部门发起需求时通常说不清"要什么样的人"——不是他们不专业,是**需求本身没被逼问过**。这个 skill 的价值就是把模糊的"我要招个人"逼成可判断的硬门槛、判定标准、排除信号和搜索词。
## 访谈纪律(最重要)
- **一轮可问 2~3 个相关问题**,组内合并问——全流程 **8 问 / 6 轮闭环**(顺序见 `references/question-bank.md`)。**绝不增加提问轮次**:用人部门反馈提问过多,收敛轮次是本次优化的核心诉求。
- **每个问题都给推荐答案 / 选项**:基于已知信息(公司说明书 / CONTEXT / 旧 JD)给"我建议是 X,因为 Y"或选项组,让用户确认或纠正——比开放题快得多。
- **用户给出的即标准答案,不追加追问**:自填内容、给的数值、选的口径直接采信;模糊词("资深的 / 能力强的 / 最好懂 XX")用推荐答案逼成可判断的标准,一次到位,不在同一项上二次追问。
- **能自己查到的不问**:先读公司说明书(若已配置)、CONTEXT.md、老岗位的旧 JD 与对内笔记、台账;只问文件里没有的。
- **性别/年龄是敏感要求**:访谈确认后进 CONTEXT 硬规则与对内笔记(内部可见);对外 JD、打招呼话术、候选人沟通**一律不出现**
- **沉淀术语**:访谈中出现黑话、简称、内部代号,随手写进 CONTEXT 术语表。
## 改版纪律(维护者必读)
**每次修改本 skill(SKILL.md / question-bank.md / 任何内部文件),同步检查四件套载体是否需要跟随修改:**
1. **对外 JD 结构**`01-jd/<role>.md` 的生成规则,定义于本文件「产出」节)——问题答案若影响 JD 字段,确认生成规则同步;
2. **对内笔记模板**`_shared/templates/jd-internal.md`)——字段增删(本次即删除「面试问什么」);
3. **CONTEXT.md 结构**(硬规则分列 / 在招岗位表 / 术语表 / 待定决策)——新增问答项若需落 CONTEXT,确认区块存在;
4. **关键词迭代表**(R1 生成逻辑与格式)——命脉 / 判定标准的问法若变,确认 R1 生成依据同步。
**改完任何一处,把四件套对应字段 / 结构过一遍,避免提问与产出脱节。**
## 第 0 步 · 入口分流(最先,1 轮 2 问,决定路径)
1. **路径三选一**(让用户选一个):
-**全新岗位**:从零开始,做一轮完整调研(读公司说明书 / 台账;竞对参照为可选 C——仅当输入只有一句话时查竞对同类岗位,能查到就查、查不到不阻塞,访谈推荐答案垫底);
-**旧岗位迭代**:只问改动处,不再全量提问(先读旧 JD + 对内笔记 + 台账做增量更新);
-**需求已清晰**:直接写 JD 模板,只确认关键项(存在原因 / 核心解决什么 / 年龄 / 性别 / 带团队 / 竞业)。
2. **内部消化检查**:现有团队有没有人可培训 / 胜任?为什么必须外招?
- 先逼出"确实要外招"的理由再往下走——这是**降成本的第一道闸**。若有人可培训,提醒用户考虑内部培养或调整需求,而不是直接开招。
## 第 1 步 · 调研(按需,不阻塞)
- **公司说明书**(路径配置化,见 CONTEXT;当前维护于 Obsidian `C:\Users\chens\Documents\Obsidian Vault\2-工作相关\招聘规则说明书.md`,读到就用,读不到跳过并访谈里问)——业务一句话、竞对名单、岗位族偏好、部门用人偏好与负责人性格。
- **部门偏好 / 负责人性格 = 软性上下文**:用于推荐答案更贴合;**不写进对外 JD、不自动变成排除信号**;与 CONTEXT 硬规则冲突时以硬规则为准并向用户确认。
- **一句话需求**:竞对参照为**可选 C**——仅当输入只有一句话时,查竞对同类岗位(求职端),能查到就查、查不到不阻塞,用调研结果垫高访谈质量("我查了 XX 竞对这个岗一般要求 Y,你们是不是也按这个来?")。
- **老岗位**:读 `01-jd/<role>.md` + `01-jd/_internal/<role>.md` + 台账筛选数据。
## 第 2 步 · 访谈(8 问 / 6 轮闭环,顺序见 question-bank.md)
入口分流(2)→ 职责 + 判定标准(2,含链式命脉问)→ 命脉验证 + 排除(2)→ 汇报线 + 薪资(1)→ 入职约束(1)。核心逻辑:
- **职责 + 判定标准组**(命脉的源头):定"80% 时间干什么、最核心解决的业务问题",链式逼出**判定标准(命脉)**——缺了就不对口的一票否决项;硬门槛组一次问完学历 / 专业 / 年限 / 年龄 / 性别 / 出差(**选项模式,全程点选**)。
- **命脉验证组**紧跟判定标准:简历看什么信号能确认;排除组逼出"看着像其实不对"的画像。
- **汇报线 + 薪资组**:带宽未定 → 触发薪酬调研;**仅管理职级(主管 / 经理 / 总监 / 副总 / 部长 等)追加问带团队**,普通职级(java 工程师 / 会计专员 / 人事专员 等)默认不问。
- ⚠️ **研发/技术类岗位必问技术栈(question-bank.md 第 1 组条件问)**:主语言 / 框架 / 版本、熟练还是精通、硬性还是可迁移——技术栈是技术岗的命脉入口,不能因为"表层可放宽"就跳过;答案进 JD 任职要求(硬性)与对内笔记命脉区。
- 硬底子 vs 表层、深度 vs 广度、AI 应用能力**不再单独成问**:命脉就是硬底子本身;表层(行业/工具/赛道经验)可放宽,三个月追平;AI 应用能力默认按加分项处理,除非用户明确要求硬性。
## 第 3 步 · 薪酬调研(仅当带宽未定,访谈已给就跳过)
**触发判定(先问再查)**:访谈第 3 组(问题 7)用户已给薪酬范围值(含结构)→ **L0 直接采用,不跑任何调研**。"待市场复核"不是触发信号,除非用户明确说"帮我看看市场价"。
访谈没给带宽时,分层渠道(从快到慢,有哪个用哪个):
- **L0**:公司给定带宽 / 已有市场薪酬报告 → 直接用(零成本,优先)。
- **L1**:结构化接口查询(MCP / API / CLI 求职端岗位搜索)——同一路径模式,配置了哪个用哪个:MCP/API 若环境已配置则用;CLI 求职端搜索(若 CLI 支持)等价位。
- **L2**:web-access 求职端小抽样(兜底)→ 3-5 家竞对、每家 1-2 个 JD,只取薪资区间,不滚动深挖。
⚠️ **只从求职端(岗位 JD 薪资区间)取市场价**;招聘端人才库里的候选人期望薪资是"求职者预期",不是岗位定价,不能当市场薪酬依据。
汇总竞对薪资分布 → 给用人部门推荐带宽("竞对同岗多在 25-45K,建议 25-40K + 提成")→ 确认或调整。
## 产出(每岗四件,全部写完才算梳理完)
1. **对外 JD** `01-jd/<role>.md`(可直接发候选人/挂平台)。**简洁版标准格式**
- 标题 `# 岗位名(地点)`
- **背景**:业务/团队一句话(一段,≤3 行);
- **薪资**:一行 `薪资:X/月`(可含结构说明)——**必须体现**,来源 = 访谈带宽或薪酬调研结论;
- **岗位职责**`1、` 编号条目,**动词起句**(负责/主导/参与/支撑…),一条一句、`;`结尾(末条`。`),通常 4-8 条;
- **岗位要求**`1、` 编号条目,**能力动词起句**(熟悉/掌握/具备/精通/了解…)或门槛句(学历/专业/年限),硬性要求放本块、软性项放加分项;
- **加分项**(可选):`1、` 编号条目。
- ⚠️ **不写**:spec 表(定位/存在意义/汇报线)等内部对齐内容、「人话版」、面试验证、带宽的调研依据与内部定位口径(进对内笔记与 CONTEXT 维护)、商业敏感信息、寻源策略、"一票否决"等内部口径、**性别/年龄等敏感要求**(仅内部可见)。
2. **对内笔记** `01-jd/_internal/<role>.md`——套 `_shared/templates/jd-internal.md`:岗位判断一句话、定位(汇报线 / 带团队)、能力拆分表、命脉技能与验证方式(简历信号)、目标公司锚点、参考简历信号、排除信号、关键词迭代表。写完提醒用户:**这份不外发**
3. **CONTEXT.md 更新**:初筛硬规则按岗分列(冲突时向用户确认,记「已对齐决策」带日期)、在招岗位表、术语表、待定决策(如签约额指标待定)。
4. **初始搜索词** → 关键词迭代表 R1:从命脉 + 目标公司锚点 + 公司说明书竞对名单**自动生成**(不再单独成问),日期留待首轮寻源后回填效果。
## 收尾确认(不算访谈问数)
- 确认岗位优先级(相对其他在招岗位)→ 写进 CONTEXT 在招岗位表。
- 确认四件套内容后,把访谈中沉淀的术语补进术语表。
## 什么时候重新 grill(增量更新)
- **老岗位迭代重发**:读旧 JD + 台账做增量更新,只问变化点,改动记进「已对齐决策」。
- 寻了几轮源发现标准明显不对(全是错的人 / 池子里没这种人)→ 带台账数据重走硬门槛和命脉部分。
- 用人经理改需求 → 增量更新。
# 岗位梳理问题清单 v3.1(8 问 / 6 轮;技术岗 +1 条件问技术栈、管理职级 +1 条件问带团队)
> 顺序有依赖,别乱序。**核心纪律:绝不增加提问轮次**(本次优化即因用人部门反馈提问过多);一轮可问 2~3 个相关问题;每题先给推荐答案 / 选项,**用户给出的即标准答案,不追加追问**。
> 〔方括号里是为什么问 / 什么时候可跳。〕
> 旧岗位迭代(②路径):只问变化点,本清单中带「老岗位确认」标记的项需与旧 JD 对照确认。
## 第 0 组 · 入口分流(1 轮 2 问,决定路径)
**1. 路径三选一**(让用户选一个):
-**全新岗位**:从零开始,做一轮完整调研(读公司说明书 / 台账;竞对参照为可选 C——仅当输入只有一句话时查竞对同类岗位,能查到就查、查不到不阻塞,访谈推荐答案垫底)
-**旧岗位迭代**:只问改动处,不再全量提问
-**需求已清晰**:直接写 JD 模板,只确认关键项(存在原因 / 核心解决什么 / 年龄 / 性别 / 带团队 / 竞业)
〔决定路径。①③ 走全量(③ 精简版),② 走增量。〕
**2. 内部消化检查**:现有团队没人能培训 / 胜任吗?为什么必须外招?
〔降成本第一闸。有人可培训 → 提醒考虑内部培养或调整需求,先别急着开招。〕
---
## 第 1 组 · 职责 + 判定标准(1 轮 2 问,命脉的源头)
**3. 职责 / 核心问题 / 判定标准(链式一问)**
> 这个岗位 80% 的时间花在哪几件事上?**最核心解决的业务问题,一句话说是什么**?——要解决它,**最卡人的是哪 1-2 个能力**?候选人没有这个,其他条件再好也白搭(**这就是判定标准,一票否决**)。
- 职责至少梳理 **3 条**(进 JD 职责区与任职要求)。
- 〔条件问 · 仅技术岗〕具体技术栈:主语言 / 框架 / 版本?要求硬性还是可迁移?熟练还是精通?
- 推荐答案:给典型「痛点 → 判定标准」对子让用户选 / 纠正:
- 销售岗:华东区业绩起不来 → **客户资源** or **成交能力**
- 技术岗:系统稳定性差 → **高并发架构经验** or **工程规范**
- 〔老岗位②路径:仅确认是否与原来一致。〕
**4. 硬门槛(选项模式,全程点选,免填写)**
- **学历**:① 不限 ② 大专及以上 ③ 本科及以上 ④ 硕士及以上
- **专业**:① 不限 ② 相关专业优先(括号预填推荐专业)③ 自填
- ② 括号预填**一直保留**:按岗位类型直接推荐(例:招会计 → 相关专业优先(会计 / 财务管理 / 审计 / 金融))。**预填仅是建议,用不用由用人部门定**;不用推荐 → 选 ③ 自填(例:招电力交易员 → ③ 自填:理工科、数据科学专业)。
- **自填内容即标准答案,不追加任何追问**
- **不建专业推荐清单 / 对照表**,始终有自填入口兜底。
- **年限**:① 不限(应届可培养)② 1-3 年 ③ 3-5 年 ④ 5-8 年 ⑤ 8 年以上 ⑥ 特殊口径(如"看行业资源年限,不看工作年限")
- **年龄**:① 不设线 ② 有上限(?)③ 有降权线(?)——⚠️ 仅内部
- **性别**:① 不设 ② 有惯例(?)——⚠️ 仅内部
- **出差**:① 不需要 ② 偶尔(每月几次以内)③ 频繁 ④ 长期驻外 / 异地
⚠️ 年龄 / 性别答案只进 CONTEXT 硬规则与对内笔记,严禁出现在对外 JD、打招呼话术、候选人沟通中。
〔老岗位②路径:仅确认是否与原来一致。〕
---
## 第 2 组 · 命脉验证 + 排除(1 轮 2 问,紧跟判定标准)
**5. 判定标准怎么验证(简历信号)**
> 你刚说判定标准是 X(引用第 3 问答案)——那简历上看出这个人有 X 的**信号**是什么?
〔没有验证方式的判定标准是空话。答案进对内笔记;「面试问什么」暂不收集。〕
**6. 排除信号 + 来源**
> 什么样的背景看着像、其实不对(简历关键词全命中但干不了活)?有没有明确不要的来源?
〔初筛最大时间黑洞。预列 3-5 条典型排除画像让用户勾选 / 补充。〕
---
## 第 3 组 · 汇报线 + 薪资(1 轮 1 问)
**7. 汇报对象是谁**(内部汇报线)?**薪资带宽(结构)**多少?
- 〔条件问 · 仅管理职级〕岗位名称 / 职级为 **主管 / 经理 / 总监 / 副总 / 部长 等** → 追加确认:带团队吗?带几人?
- **普通职级**(java 工程师 / 会计专员 / 人事专员 等)→ 默认普通岗位,**不问带团队**
- 〔带宽未定 → 触发薪酬调研:L0 公司给定带宽 → L1 结构化接口 → L2 web-access 求职端小抽样(3-5 家竞对,只取薪资区间)。只从求职端取市场价,候选人期望薪资不作定价依据。〕
---
## 第 4 组 · 入职约束(1 轮 1 问)
**8. 试用期 + 竞业**
> 试用期多长?试用期内看什么信号判断转正?这个岗位入职需要签**竞业协议**吗?
- 试用期时长与转正信号**只进对内笔记**(入职约束区),不写进对外 JD。
- 竞业**每岗必问,不给预设默认**(参考:资源型大销售岗 2026-08-26 确认不需要签——别拿岗位类型猜)。
- 挖人时默认介意候选人原单位竞业限制,无需再问。
---
> **岗位优先级(相对其他在招岗位的紧急程度)不计入本清单**——访谈收尾确认时问(见 SKILL.md「收尾确认」),答案写进 CONTEXT 在招岗位表。
......@@ -18,6 +18,18 @@ export async function claimPoolCandidate(candidateId) {
return data
}
export async function fetchJobClaims() {
const { data } = await http.get('/api/job-claims')
if (data.error) throw new Error(data.error)
return data
}
export async function claimExternalJob(claimId) {
const { data } = await http.post(`/api/job-claims/${claimId}/claim`)
if (data.error) throw new Error(data.error)
return data
}
export async function fetchState(owner = '') {
const { data } = await http.get('/api/state', { params: owner ? { owner } : {} })
return data
......@@ -159,3 +171,28 @@ export async function qwenpawChatStream(payload, onEvent) {
}
return doneReply
}
// ---- 分析报告(候选人报告,ADR 0002)----
export async function listCandidateReports(candidateId) {
const { data } = await http.get('/api/reports', { params: { candidateId } })
if (data.error) throw new Error(data.error)
return data
}
export async function createCandidateReport(candidateId) {
const { data } = await http.post('/api/reports', { candidateId })
if (data.error) throw new Error(data.error)
return data
}
export async function setCandidateReportStatus(reportId, status) {
const { data } = await http.patch(`/api/reports/${reportId}`, { status })
if (data.error) throw new Error(data.error)
return data
}
// 公网匿名只读:无需登录;后端放行 GET /api/share/report/...。
export async function fetchSharedReport(reportId, token = '') {
const { data } = await http.get(`/api/share/report/${reportId}`, { params: { token } })
return data
}
......@@ -238,6 +238,43 @@ const approveJd = async () => {
activeStep.value = 3
}
const jdExternalApproved = computed(() => {
const st = job.value?.jdStatus
return Boolean(
job.value?.approvalSource === 'oa-external' &&
job.value?.approvalStatus === APPROVAL_STATUS.APPROVED &&
[JD_STATUS.DRAFT, JD_STATUS.PENDING_CONFIRM].includes(st)
)
})
// ADR 0003:认领的外部岗位已通过企微/OA 审批,HR 核对内容后直接确认生效(跳过系统内再次审批)。
const activateExternalJd = async () => {
if (!job.value) return
try {
await ElMessageBox.confirm(
'该岗位已通过企微/OA 外部审批。核对 JD 内容无误后确认生效,生效后即可发布招聘渠道。确认生效?',
'JD 生效',
{ confirmButtonText: '确认生效', cancelButtonText: '再核对一下', type: 'warning' }
)
} catch {
return
}
job.value.jdStatus = JD_STATUS.EFFECTIVE
job.value.approvalStatus = APPROVAL_STATUS.APPROVED
job.value.approvalSource = job.value.approvalSource || 'oa-external'
job.value.status =
job.value.channelStatus === CHANNEL_STATUS.SYNCED ? JOB_STATUS.RECRUITING : JOB_STATUS.PENDING_CONFIRM
job.value.channelStatus =
job.value.channelStatus === CHANNEL_STATUS.SYNCED ? CHANNEL_STATUS.SYNCED : CHANNEL_STATUS.PENDING_SYNC
job.value.matchRuleStatus = job.value.matchRuleStatus || '已配置'
job.value.jdVersion = (job.value.jdVersion || 'v1').replace('待确认', '当前生效').replace('草稿', '当前生效')
appendJdHistory('外部审批通过,HR 核对确认生效')
store.completeTasksForJob(job.value, [/核对外部 JD|跟进JD审批/])
store.persist('JD已确认生效')
showToast('JD 已确认生效')
activeStep.value = 3
}
const refreshCandidates = async () => {
if (!job.value) return
const related = flow.value.related
......@@ -741,7 +778,7 @@ const mainAction = async () => {
</div>
<div class="step-pane-actions">
<el-button
v-if="jdPendingConfirm && !jdConfirmed"
v-if="jdPendingConfirm && !jdConfirmed && !jdExternalApproved"
size="small"
type="primary"
@click="confirmAndSubmitApproval"
......@@ -751,13 +788,21 @@ const mainAction = async () => {
<el-button v-if="jdPendingConfirm && jdConfirmed" size="small" @click="submitApproval">
提交审批
</el-button>
<el-button v-if="jdPendingConfirm && !jdConfirmed" size="small" @click="confirmOwner">
<el-button v-if="jdExternalApproved" size="small" type="primary" @click="activateExternalJd">
核对后确认生效
</el-button>
<el-tag v-if="jdExternalApproved" type="success" size="small">企微/OA 已审批通过</el-tag>
<el-button
v-if="jdPendingConfirm && !jdConfirmed && !jdExternalApproved"
size="small"
@click="confirmOwner"
>
仅确认内容
</el-button>
<el-tag v-if="job.jdStatus === JD_STATUS.APPROVING" type="warning" size="small">审批进行中</el-tag>
</div>
</div>
<div v-if="jdPendingConfirm && !jdConfirmed" class="step-action-hint">
<div v-if="jdPendingConfirm && !jdConfirmed && !jdExternalApproved" class="step-action-hint">
{{
hasJdContent
? '可以先一键「确认并提交审批」,也可以先点「仅确认内容」再单独提交。'
......@@ -788,13 +833,18 @@ const mainAction = async () => {
<el-button v-if="job.jdStatus === JD_STATUS.APPROVING" size="small" type="primary" @click="approveJd"
>审批通过并生效</el-button
>
<el-button v-if="jdExternalApproved" size="small" type="primary" @click="activateExternalJd"
>核对后确认生效</el-button
>
<el-tag v-if="job.jdStatus === JD_STATUS.EFFECTIVE" size="small" type="success">JD 已生效</el-tag>
<el-button size="small" @click="openChannelDialog">同步发布渠道</el-button>
<el-button size="small" :disabled="!hasJdContent" @click="openJdMarkdown">查看 Markdown</el-button>
</div>
</div>
<div
v-if="job.jdStatus !== JD_STATUS.APPROVING && job.jdStatus !== JD_STATUS.EFFECTIVE"
v-if="
job.jdStatus !== JD_STATUS.APPROVING && job.jdStatus !== JD_STATUS.EFFECTIVE && !jdExternalApproved
"
class="step-action-hint"
>
先在「用人确认」确认 JD 内容并提交审批,审批中才能在这里通过并生效。
......@@ -878,9 +928,13 @@ const mainAction = async () => {
<el-button v-if="job.jdStatus === JD_STATUS.APPROVING" type="primary" @click="approveJd"
>审批通过并生效</el-button
>
<el-button v-if="jdExternalApproved" type="primary" @click="activateExternalJd">核对后确认生效</el-button>
<el-tag v-if="job.jdStatus === JD_STATUS.EFFECTIVE" type="success">JD 已生效</el-tag>
</div>
<p v-if="job.jdStatus !== JD_STATUS.APPROVING && job.jdStatus !== JD_STATUS.EFFECTIVE" class="publish-hint">
<p
v-if="job.jdStatus !== JD_STATUS.APPROVING && job.jdStatus !== JD_STATUS.EFFECTIVE && !jdExternalApproved"
class="publish-hint"
>
先在「JD 流程」完成用人确认并提交审批,才能在这里审批生效。
</p>
</div>
......
......@@ -5,6 +5,7 @@ import { useRoute } from 'vue-router'
import { ElMessageBox } from 'element-plus'
import http from '@/api'
import * as reportApi from '@/api/recruitment'
import { useRecruitmentStore } from '@/stores/recruitment'
import { CANDIDATE_TAG_OPTIONS, candidateTagTone } from '@/utils/constants'
import { candidateWorkflow, workflowStageClass } from '@/utils/task'
......@@ -314,6 +315,116 @@ const generateEval = async (candidate) => {
}
}
// ---- 分析报告(可分享的冻结快照,ADR 0002)----
const reportDialogOpen = ref(false)
const reportListLoading = ref(false)
const reportList = ref([])
const reportGenerating = ref(false)
const reportShareEnabled = ref(true)
const formatReportTime = (iso = '') => {
if (!iso) return ''
const date = new Date(iso)
if (Number.isNaN(date.getTime())) return iso
const pad = (n) => String(n).padStart(2, '0')
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}`
}
const copyText = async (text = '') => {
try {
await navigator.clipboard.writeText(text)
} catch {
const textarea = document.createElement('textarea')
textarea.value = text
document.body.appendChild(textarea)
textarea.select()
document.execCommand('copy')
textarea.remove()
}
}
const reportSnapshotScore = (snapshot = {}) => {
const value = Number(snapshot.analysis?.score ?? snapshot.quickMatchScore ?? 0)
return value ? Math.round(value) : '--'
}
const openReportPanel = async () => {
if (!selected.value) return
reportDialogOpen.value = true
await loadReports()
}
const loadReports = async () => {
const candidate = selected.value
if (!candidate) return
reportListLoading.value = true
try {
const payload = await reportApi.listCandidateReports(candidate.id)
reportList.value = Array.isArray(payload.reports) ? payload.reports : []
reportShareEnabled.value = payload.shareEnabled !== false
} catch (error) {
reportList.value = []
showToast(error.message || '报告列表加载失败', 'error')
} finally {
reportListLoading.value = false
}
}
const generateReport = async () => {
const candidate = selected.value
if (!candidate || reportGenerating.value) return
if (!reportShareEnabled.value) {
showToast('系统未开启公网分享,请联系管理员配置后使用', 'warning')
return
}
reportGenerating.value = true
try {
if (!store.localMatch(candidate.id)) throw new Error('请先为候选人选择并确认关联岗位')
const previous = candidate.analysis
const reusable = isAiEvaluationReport(previous) && !analysisNeedsRefresh(candidate)
if (!reusable) {
showToast('正在生成 AI 评估报告...', 'info')
candidate.analysis = await store.analyzeResume(candidate)
store.resumeDetailMode = 'ai'
}
await store.saveStateToServer()
const payload = await reportApi.createCandidateReport(candidate.id)
const report = payload.report
store.logEvent('report_generated', candidate, {
reportId: report.reportId,
score: reportSnapshotScore(report.snapshot),
})
store.persist('分析报告已生成')
await copyText(report.shareUrl)
showToast('分析报告已生成,链接已复制到剪贴板', 'success')
await loadReports()
} catch (error) {
showToast(error.message || '分析报告生成失败', 'error')
} finally {
reportGenerating.value = false
}
}
const copyReportLink = async (report) => {
await copyText(report.shareUrl)
showToast('分享链接已复制', 'success')
}
const openReportLink = (report) => {
if (report?.shareUrl) window.open(report.shareUrl, '_blank')
}
const toggleReportStatus = async (report) => {
const next = report.status === 'active' ? 'disabled' : 'active'
try {
await reportApi.setCandidateReportStatus(report.reportId, next)
showToast(next === 'disabled' ? '报告已停用,链接将失效' : '报告已重新启用', 'success')
await loadReports()
} catch (error) {
showToast(error.message || '操作失败', 'error')
}
}
const runLocalMatchAction = (candidate) => {
if (!store.localMatch(candidate.id)) {
showToast('请先为候选人选择并确认关联岗位', 'warning')
......@@ -589,6 +700,10 @@ const saveInterview = (candidate) => {
<el-icon><ArrowRight /></el-icon>
</el-button>
</div>
<el-button size="small" plain @click="openReportPanel">
<el-icon class="btn-icon"><Share /></el-icon>
分析报告
</el-button>
<el-button size="small" @click="enterEditMode">修正字段</el-button>
<el-button size="small" type="primary" circle aria-label="关闭" title="关闭(Esc)" @click="close">
<el-icon><Close /></el-icon>
......@@ -969,6 +1084,79 @@ const saveInterview = (candidate) => {
</div>
</footer>
</div>
<el-dialog v-model="reportDialogOpen" title="分析报告" width="620px" append-to-body class="report-panel-dialog">
<div class="report-panel">
<div class="report-panel-head">
<div class="report-panel-identity">
<strong>{{ selected?.name || '未命名候选人' }}</strong>
<span>{{ selected?.jobTitle || '待匹配岗位' }}</span>
</div>
<el-button type="primary" :loading="reportGenerating" :disabled="!reportShareEnabled" @click="generateReport">
生成新报告
</el-button>
</div>
<el-alert
v-if="!reportShareEnabled"
class="report-alert"
type="warning"
:closable="false"
show-icon
title="公网分享未开启"
description="当前部署未开启 REPORT_SHARE_ENABLED,生成的链接外部无法访问;请联系管理员在配置中开启。"
/>
<p class="report-hint">
每次「生成新报告」会基于当前 AI
评估冻结一份快照并创建公网链接;分享页仅含姓名、岗位与评估结论,不含联系方式与原简历。
</p>
<div v-if="reportListLoading" class="report-list-loading">加载中…</div>
<div v-else-if="!reportList.length" class="report-empty">还没有分析报告,点击右上角「生成新报告」创建。</div>
<div v-else class="report-list">
<div v-for="reportItem in reportList" :key="reportItem.id" class="report-item">
<div class="report-item-main">
<div class="report-item-title">
<b>{{ reportItem.reportId }}</b>
<el-tag :type="reportItem.status === 'active' ? 'success' : 'info'" size="small" effect="light">
{{ reportItem.status === 'active' ? '有效' : '已停用' }}
</el-tag>
</div>
<div class="report-item-meta">
生成于 {{ formatReportTime(reportItem.generatedAt) }} · 得分
{{ reportSnapshotScore(reportItem.snapshot) }}/100
</div>
</div>
<div class="report-item-actions">
<el-button
size="small"
text
type="primary"
:disabled="reportItem.status !== 'active'"
@click="openReportLink(reportItem)"
>
打开链接
</el-button>
<el-button
size="small"
text
type="primary"
:disabled="reportItem.status !== 'active'"
@click="copyReportLink(reportItem)"
>
复制链接
</el-button>
<el-button
size="small"
text
:type="reportItem.status === 'active' ? 'danger' : 'success'"
@click="toggleReportStatus(reportItem)"
>
{{ reportItem.status === 'active' ? '停用' : '重新启用' }}
</el-button>
</div>
</div>
</div>
</div>
</el-dialog>
</el-drawer>
</template>
<style lang="scss" scoped>
......@@ -1985,4 +2173,98 @@ const saveInterview = (candidate) => {
}
}
}
// 分析报告面板(ADR 0002)
.report-panel {
.report-panel-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-bottom: 12px;
.report-panel-identity {
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0;
strong {
font-size: 15px;
color: var(--ink);
}
span {
font-size: 12px;
color: var(--muted);
}
}
}
.report-alert {
margin-bottom: 10px;
}
.report-hint {
margin: 0 0 12px;
padding: 8px 10px;
border: 1px dashed var(--line);
border-radius: 8px;
background: rgba(255, 255, 255, 0.6);
font-size: 12px;
color: var(--muted);
line-height: 1.6;
}
.report-list-loading,
.report-empty {
padding: 34px 12px;
text-align: center;
font-size: 13px;
color: var(--muted);
}
.report-list {
display: flex;
flex-direction: column;
gap: 8px;
max-height: 46vh;
overflow: auto;
}
.report-item {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 10px 12px;
border: 1px solid var(--line);
border-radius: 10px;
background: rgba(255, 255, 255, 0.8);
.report-item-title {
display: flex;
align-items: center;
gap: 8px;
b {
font-family: ui-monospace, SFMono-Regular, Consolas, 'Courier New', monospace;
font-size: 12px;
color: var(--ink);
}
}
.report-item-meta {
margin-top: 4px;
font-size: 12px;
color: var(--muted);
}
.report-item-actions {
display: flex;
align-items: center;
gap: 2px;
flex-shrink: 0;
}
}
}
</style>
......@@ -18,6 +18,7 @@ const activeRouteKey = computed(() => {
if (name === 'user-management') return 'user-management'
if (name === 'dashboard') return 'dashboard'
if (['jobs', 'job-detail', 'job-edit', 'job-create'].includes(name)) return 'jobs'
if (name === 'job-claims') return 'job-claims'
if (name === 'resumes') return 'resumes'
if (name === 'pool') return 'pool'
if (name === 'offer') return 'offer'
......@@ -47,7 +48,7 @@ const navGroups = computed(() => {
const byKey = Object.fromEntries(navItems.map((item) => [item.key, item]))
const groups = [
{ title: '工作台', items: [byKey.todo, byKey.dashboard].filter(Boolean) },
{ title: '招聘流程', items: [byKey.jobs, byKey.resumes, byKey.pool, byKey.offer].filter(Boolean) },
{ title: '招聘流程', items: [byKey.jobs, byKey.jobClaims, byKey.resumes, byKey.pool, byKey.offer].filter(Boolean) },
]
if (isAdmin.value) {
groups.push({ title: '系统', items: [{ key: 'user-management', label: '用户管理', icon: 'Setting' }] })
......
......@@ -4,6 +4,12 @@ import { useUserStore } from '@/stores/user'
import { navItems, navPageMeta } from './nav'
const routes = [
{
path: '/share/report/:reportId',
name: 'share-report',
component: () => import('@/views/ShareReportView.vue'),
meta: { public: true, share: true },
},
{
path: '/login',
name: 'login',
......@@ -35,6 +41,11 @@ const routes = [
name: 'job-detail',
component: () => import('@/views/JobDetailView.vue'),
},
{
path: '/job-claims',
name: 'job-claims',
component: () => import('@/views/JobClaimsView.vue'),
},
{
path: '/jobs/new',
name: 'job-create',
......@@ -80,6 +91,8 @@ const router = createRouter({
// 登录守卫:未认证跳 /login,已认证访问 /login 回首页,admin 专属页校验角色。
router.beforeEach(async (to) => {
// 分析报告公网分享页:免登录、已登录也可直接查看,不做任何跳转(ADR 0002)
if (to.meta?.share) return true
const userStore = useUserStore()
await userStore.ensureAuth()
const authenticated = userStore.isAuthenticated
......
......@@ -8,6 +8,13 @@ export const navItems = [
icon: 'DataAnalysis',
},
{ key: 'jobs', label: '岗位管理', desc: '维护岗位、JD、招聘目标和岗位状态。', path: '/jobs', icon: 'Briefcase' },
{
key: 'job-claims',
label: '岗位认领',
desc: '认领企微/OA 审批通过的外部岗位到岗位管理。',
path: '/job-claims',
icon: 'FolderChecked',
},
{ key: 'resumes', label: '简历库', desc: '管理候选人、简历来源、匹配度和流程状态。', path: '/resumes', icon: 'User' },
{
key: 'pool',
......
......@@ -104,6 +104,21 @@ export function jobActionFor(job = {}) {
step: 1,
}
}
// ADR 0003:认领进来的外部岗位(approvalSource=oa-external)已通过企微/OA 审批,
// 不再走系统内「用人确认->提交审批」,HR 核对内容后直接确认生效。
if (
job.approvalStatus === APPROVAL_STATUS.APPROVED &&
job.approvalSource === 'oa-external' &&
job.jdStatus !== JD_STATUS.EFFECTIVE
) {
return {
title: '外部审批已通过,核对后确认生效',
description: '该岗位 JD 已通过企微/OA 外部审批,核对内容后确认生效并进入发布。',
label: '去确认生效',
tab: 'flow',
step: 3,
}
}
if ([JD_STATUS.DRAFT, JD_STATUS.PENDING_CONFIRM, JD_STATUS.APPROVING].includes(job.jdStatus)) {
const approving = job.jdStatus === JD_STATUS.APPROVING
const confirmed = job.approvalStatus === APPROVAL_STATUS.DEPARTMENT_CONFIRMED
......@@ -193,6 +208,16 @@ export function jobWorkflow(job = {}, candidates = []) {
let next
if (!hasJdContent(job)) {
next = { title: '生成 JD 草稿', desc: '先生成岗位说明,再交给用人部门确认。', action: '生成 JD 草稿' }
} else if (
job.approvalStatus === APPROVAL_STATUS.APPROVED &&
job.approvalSource === 'oa-external' &&
status !== JD_STATUS.EFFECTIVE
) {
next = {
title: '确认 JD 生效',
desc: '企微/OA 已审批通过,核对 JD 内容后确认生效并发布招聘渠道。',
action: '去确认生效',
}
} else if (status === JD_STATUS.DRAFT) {
const confirmed = job.approvalStatus === APPROVAL_STATUS.DEPARTMENT_CONFIRMED
next = confirmed
......
<script setup>
import { computed, onMounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus'
import * as api from '@/api/recruitment'
import { useRecruitmentStore } from '@/stores/recruitment'
const store = useRecruitmentStore()
const router = useRouter()
const claims = ref([])
const loading = ref(false)
const claimingId = ref('')
const unclaimedCount = computed(() => claims.value.filter((claim) => !claim.claimed).length)
function shortDate(value = '') {
return String(value || '').slice(0, 10) || '—'
}
async function load() {
loading.value = true
try {
const payload = await api.fetchJobClaims()
claims.value = Array.isArray(payload.claims) ? payload.claims : []
} catch (error) {
ElMessage.error(error.message || '加载待认领岗位失败')
} finally {
loading.value = false
}
}
// ---- 认领:把待认领岗位迁入本人岗位管理(后端生成 jobId)----
async function claim(item) {
if (item.claimed || claimingId.value) return
try {
await ElMessageBox.confirm(
`确认把「${item.title}」认领到你的岗位管理吗?认领后需核对 JD 内容并「确认生效」即可开招。`,
'认领岗位',
{ confirmButtonText: '认领到岗位管理', cancelButtonText: '取消', type: 'info' }
)
} catch {
return
}
claimingId.value = item.id
try {
const payload = await api.claimExternalJob(item.id)
const job = payload.job || {}
ElMessage.success(`已认领「${job.title || item.title}」到岗位管理`)
// 后端已写入岗位 + 事件 + 待办,重新拉取权威状态后再进岗位详情
await store.boot()
await load()
if (job.id) router.push({ name: 'job-detail', params: { id: job.id } })
} catch (error) {
ElMessage.error(error.message || '认领失败,请重试')
} finally {
claimingId.value = ''
}
}
// ---- JD 预览 ----
const previewOpen = ref(false)
const previewClaim = ref(null)
const previewText = computed(() => {
const job = previewClaim.value?.jobPreview || {}
const lines = []
const push = (label, value) => {
if (value) lines.push(`${label}\n${value}\n`)
}
push('岗位说明 / JD', job.jd)
push('核心职责', job.responsibilities)
push('任职要求', job.requirements)
push('硬性条件', job.mustHave)
push('加分项', job.niceToHave)
push('淘汰项', job.knockout)
push('匹配关键词', job.matchKeywords)
return lines.length ? lines.join('\n') : '暂无 JD 内容'
})
function openPreview(item) {
previewClaim.value = item
previewOpen.value = true
}
onMounted(load)
</script>
<template>
<div class="job-claims-page">
<div class="claims-toolbar">
<p class="claims-tip">
企微 / OA 审批通过的外部 JD 会先落在「待认领区」({{ unclaimedCount }} 个待认领)。认领后进入你的岗位管理, 核对
JD 内容并「确认生效」即可发布开招。
</p>
<el-button :loading="loading" @click="load">刷新</el-button>
</div>
<el-table v-loading="loading" :data="claims" class="claims-table" empty-text="暂无待认领岗位">
<el-table-column label="岗位" min-width="200">
<template #default="{ row }">
<div class="cell-main">{{ row.title }}</div>
<div v-if="row.department" class="cell-sub">{{ row.department }}</div>
</template>
</el-table-column>
<el-table-column prop="source" label="来源" width="150" />
<el-table-column label="状态" width="170">
<template #default="{ row }">
<el-tag :type="row.claimed ? 'info' : 'success'" size="small" effect="light">
{{ row.claimed ? '已认领' : '待认领' }}
</el-tag>
<div class="cell-sub">{{ row.approval }}</div>
</template>
</el-table-column>
<el-table-column label="创建时间" width="120">
<template #default="{ row }">{{ shortDate(row.createdAt) }}</template>
</el-table-column>
<el-table-column label="认领人" width="120">
<template #default="{ row }">{{ row.claimed ? row.claimedBy : '—' }}</template>
</el-table-column>
<el-table-column label="操作" width="170" fixed="right">
<template #default="{ row }">
<el-button size="small" @click="openPreview(row)">预览</el-button>
<el-button
size="small"
type="primary"
:disabled="row.claimed"
:loading="claimingId === row.id"
@click="claim(row)"
>
{{ row.claimed ? '已认领' : '认领' }}
</el-button>
</template>
</el-table-column>
</el-table>
<el-dialog v-model="previewOpen" :title="previewClaim?.title || 'JD 预览'" width="720px" top="8vh">
<pre class="preview-text">{{ previewText }}</pre>
</el-dialog>
</div>
</template>
<style lang="scss" scoped>
.job-claims-page {
padding: 18px 20px;
display: flex;
flex-direction: column;
gap: 14px;
}
.claims-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 14px;
.claims-tip {
margin: 0;
font-size: 13px;
line-height: 1.7;
color: var(--p-muted, #6b7280);
}
}
.claims-table {
background: #fff;
border-radius: 12px;
overflow: hidden;
.cell-main {
font-weight: 600;
color: var(--p-ink, #1f2937);
}
.cell-sub {
margin-top: 3px;
font-size: 12px;
color: var(--p-muted, #9ca3af);
}
}
.preview-text {
margin: 0;
white-space: pre-wrap;
word-break: break-word;
font-size: 13px;
line-height: 1.7;
color: #374151;
max-height: 60vh;
overflow: auto;
}
</style>
<script setup>
// 分析报告公网分享页(ADR 0002):免登录只读展示候选人 AI 初筛分析快照。
// 数据来自后端匿名只读接口 /api/share/report/{reportId}?token=...;
// 渲染逻辑复用 utils/report.js(与抽屉「评估」同源),快照不含电话/邮箱/原简历。
import { computed, onMounted, ref } from 'vue'
import { useRoute } from 'vue-router'
import { fetchSharedReport } from '@/api/recruitment'
import { providerLabel } from '@/utils/scoring'
import { assessmentDecision, assessmentReportRows, assessmentScoreText, riskProfileRows } from '@/utils/report'
const route = useRoute()
const loading = ref(true)
const errorReason = ref('') // '' | 'disabled' | 'not_found'
const report = ref(null)
const snapshot = computed(() => report.value?.snapshot || {})
const candidate = computed(() => ({
id: snapshot.value.candidateId,
name: snapshot.value.candidateName,
jobTitle: snapshot.value.jobTitle,
analysis: snapshot.value.analysis,
matchReport: snapshot.value.matchReport,
quickMatchScore: snapshot.value.quickMatchScore,
}))
const scoreText = computed(() => (report.value ? assessmentScoreText(candidate.value, 'ai') : ''))
const decisionInfo = computed(() => (report.value ? assessmentDecision(candidate.value, 'ai') : {}))
const reportRows = computed(() => (report.value ? assessmentReportRows(candidate.value, 'ai') : []))
const riskProfile = computed(() => riskProfileRows(snapshot.value.analysis || {}))
const author = computed(() => snapshot.value.author || {})
const provider = computed(() => providerLabel(snapshot.value.analysis?.provider || ''))
const toneTag = (level) =>
({
ok: { type: 'success', text: '正常' },
hint: { type: 'warning', text: '提示' },
warn: { type: 'danger', text: '需确认' },
})[level] || { type: 'info', text: level }
const formatTime = (iso = '') => {
if (!iso) return ''
const date = new Date(iso)
if (Number.isNaN(date.getTime())) return iso
const pad = (n) => String(n).padStart(2, '0')
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}`
}
const printPage = () => window.print()
onMounted(async () => {
const reportId = String(route.params.reportId || '')
const token = String(route.query.token || '')
try {
const data = await fetchSharedReport(reportId, token)
if (data.ok && data.report) {
report.value = data.report
} else {
errorReason.value = data.reason === 'disabled' ? 'disabled' : 'not_found'
}
} catch {
errorReason.value = 'not_found'
} finally {
loading.value = false
}
})
</script>
<template>
<div class="share-page">
<main class="report-card">
<header class="report-head">
<div class="brand">
<div class="brand-mark">HR</div>
<div class="brand-copy">
<strong>招聘系统</strong>
<span>候选人 AI 初筛分析报告</span>
</div>
</div>
<el-button size="small" class="print-btn no-print" @click="printPage">
<el-icon><Printer /></el-icon>
打印 / 存 PDF
</el-button>
</header>
<div v-if="loading" class="state-box">
<span class="state-dot"></span>
<p>报告加载中…</p>
</div>
<div v-else-if="errorReason" class="state-box invalid">
<el-icon class="state-icon"><WarningFilled /></el-icon>
<h3>{{ errorReason === 'disabled' ? '该报告已失效' : '报告不存在或链接无效' }}</h3>
<p v-if="errorReason === 'disabled'">发布方已停用此分享,请联系发布人获取最新报告。</p>
<p v-else>链接可能已过期、被删除,或分享功能未开启。</p>
</div>
<template v-else-if="report">
<section class="candidate-head">
<div class="name-row">
<h1>{{ snapshot.candidateName || '未命名候选人' }}</h1>
<el-tag size="small" effect="plain">{{ snapshot.jobTitle || '待匹配岗位' }}</el-tag>
<el-tag v-if="snapshot.source" size="small" type="info" effect="plain">{{ snapshot.source }}</el-tag>
</div>
<div class="meta-row">
<span>报告编号:{{ report.reportId }}</span>
<span>生成时间:{{ formatTime(report.generatedAt) }}</span>
<span>评估方式:{{ provider }}</span>
<span>生成人:{{ author.name || author.username || '—' }}</span>
</div>
</section>
<section class="summary-strip" :class="`tone-${decisionInfo.tone}`">
<div class="score-block">
<strong class="score-value">{{ scoreText }}</strong>
<span>综合得分</span>
</div>
<div class="decision-block">
<span class="eyebrow">初筛建议</span>
<strong>{{ decisionInfo.text }}</strong>
<p>{{ decisionInfo.note }}</p>
</div>
</section>
<section v-if="snapshot.analysis?.summary" class="block">
<h4>评估摘要</h4>
<p class="summary-text">{{ snapshot.analysis.summary }}</p>
</section>
<section v-if="riskProfile.length" class="block">
<h4>风险点评估</h4>
<div class="risk-list">
<div v-for="item in riskProfile" :key="item.dimension" class="risk-item" :class="`level-${item.level}`">
<el-tag :type="toneTag(item.level).type" size="small" effect="light">
{{ toneTag(item.level).text }}
</el-tag>
<b>{{ item.label }}</b>
<p>{{ item.summary }}</p>
<span v-for="(ev, index) in item.evidence || []" :key="`${item.dimension}-${index}`">依据:{{ ev }}</span>
</div>
</div>
</section>
<section v-if="reportRows.length" class="block">
<h4>评估明细</h4>
<div v-for="row in reportRows" :key="row.label" class="detail-card" :class="`tone-${row.tone}`">
<div class="detail-head">
<b>{{ row.label }}</b>
<el-tag :type="row.tone" size="small" effect="light">{{ row.status }}</el-tag>
</div>
<ul v-if="row.items.length">
<li v-for="(item, index) in row.items" :key="index">{{ item }}</li>
</ul>
<p v-else class="detail-empty">暂无</p>
</div>
</section>
<footer class="report-footer">
<p>本报告由 AI 辅助生成,基于生成时刻的简历与岗位信息冻结,仅供内部初筛参考,请以人工复核与面试为准。</p>
<p>不含候选人联系方式与原简历文件。</p>
</footer>
</template>
</main>
</div>
</template>
<style lang="scss" scoped>
.share-page {
min-height: 100vh;
background: var(--bg);
padding: 28px 16px 48px;
display: flex;
justify-content: center;
}
.report-card {
width: 860px;
max-width: 100%;
background: var(--panel);
border: 1px solid var(--line);
border-radius: 16px;
box-shadow: var(--shadow);
padding: 22px 26px 26px;
}
.report-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding-bottom: 16px;
border-bottom: 1px solid var(--line);
.brand {
display: flex;
align-items: center;
gap: 10px;
.brand-mark {
width: 38px;
height: 38px;
display: grid;
place-items: center;
border-radius: 10px;
background: linear-gradient(135deg, var(--blue), var(--cyan));
color: #fff;
font-weight: 700;
}
.brand-copy {
display: flex;
flex-direction: column;
strong {
font-size: 16px;
}
span {
font-size: 12px;
color: var(--muted);
}
}
}
}
.state-box {
padding: 72px 24px;
text-align: center;
color: var(--muted);
.state-dot {
width: 14px;
height: 14px;
display: inline-block;
border-radius: 50%;
background: var(--blue);
animation: pulse 1.2s ease-in-out infinite;
}
.state-icon {
font-size: 34px;
color: var(--yellow);
}
&.invalid h3 {
margin: 10px 0 6px;
color: var(--ink);
font-size: 17px;
}
p {
margin: 6px 0 0;
font-size: 13px;
}
}
.candidate-head {
padding: 18px 0 14px;
.name-row {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 8px;
h1 {
margin: 0;
font-size: 24px;
}
}
.meta-row {
display: flex;
flex-wrap: wrap;
gap: 6px 18px;
margin-top: 10px;
font-size: 12px;
color: var(--muted);
}
}
.summary-strip {
display: flex;
align-items: stretch;
gap: 0;
border: 1px solid var(--line);
border-radius: 12px;
overflow: hidden;
margin-bottom: 16px;
.score-block {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 4px;
min-width: 168px;
padding: 16px 18px;
background: rgba(255, 255, 255, 0.8);
.score-value {
font-size: 30px;
line-height: 1;
}
span {
font-size: 12px;
color: var(--muted);
}
}
.decision-block {
flex: 1;
min-width: 0;
padding: 14px 18px;
display: flex;
flex-direction: column;
justify-content: center;
border-left: 1px solid var(--line);
.eyebrow {
font-size: 11px;
letter-spacing: 1px;
color: var(--muted);
}
strong {
margin: 4px 0 2px;
font-size: 17px;
}
p {
margin: 0;
font-size: 12px;
color: var(--muted);
line-height: 1.5;
}
}
&.tone-success {
border-color: color-mix(in srgb, var(--green) 55%, var(--line));
.score-value,
.decision-block strong {
color: var(--green);
}
}
&.tone-warning {
border-color: color-mix(in srgb, var(--yellow) 55%, var(--line));
.score-value,
.decision-block strong {
color: var(--yellow);
}
}
&.tone-danger {
border-color: color-mix(in srgb, var(--rose) 55%, var(--line));
.score-value,
.decision-block strong {
color: var(--rose);
}
}
&.tone-info {
.score-value,
.decision-block strong {
color: var(--blue);
}
}
}
.block {
margin-bottom: 16px;
h4 {
margin: 0 0 8px;
font-size: 14px;
}
.summary-text {
margin: 0;
padding: 12px 14px;
border: 1px solid var(--line);
border-radius: 10px;
background: rgba(255, 255, 255, 0.7);
font-size: 13px;
line-height: 1.7;
color: var(--ink);
}
}
.risk-list {
display: flex;
flex-direction: column;
gap: 8px;
.risk-item {
display: grid;
grid-template-columns: auto auto 1fr;
align-items: center;
gap: 4px 10px;
padding: 10px 12px;
border-radius: 10px;
border: 1px solid var(--line);
background: rgba(255, 255, 255, 0.6);
b {
font-size: 13px;
}
p {
grid-column: 1 / -1;
margin: 2px 0 0;
font-size: 13px;
line-height: 1.6;
}
span {
grid-column: 1 / -1;
font-size: 12px;
color: var(--muted);
line-height: 1.5;
}
&.level-warn {
border-color: color-mix(in srgb, var(--rose) 45%, var(--line));
background: color-mix(in srgb, var(--rose) 6%, #fff);
}
&.level-hint {
border-color: color-mix(in srgb, var(--yellow) 45%, var(--line));
background: color-mix(in srgb, var(--yellow) 7%, #fff);
}
}
}
.detail-card {
margin-bottom: 10px;
padding: 12px 14px;
border: 1px solid var(--line);
border-left-width: 3px;
border-radius: 10px;
background: rgba(255, 255, 255, 0.7);
&.tone-success {
border-left-color: var(--green);
}
&.tone-warning {
border-left-color: var(--yellow);
}
&.tone-danger {
border-left-color: var(--rose);
}
&.tone-info {
border-left-color: var(--blue);
}
.detail-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
margin-bottom: 6px;
b {
font-size: 14px;
}
}
ul {
margin: 0;
padding-left: 18px;
display: flex;
flex-direction: column;
gap: 5px;
li {
font-size: 13px;
line-height: 1.6;
color: var(--ink);
}
}
.detail-empty {
margin: 0;
font-size: 13px;
color: var(--muted);
}
}
.report-footer {
margin-top: 18px;
padding-top: 12px;
border-top: 1px dashed var(--line);
font-size: 12px;
color: var(--muted);
line-height: 1.6;
p {
margin: 2px 0;
}
}
@keyframes pulse {
0%,
100% {
opacity: 1;
transform: scale(1);
}
50% {
opacity: 0.35;
transform: scale(0.85);
}
}
@media (max-width: 620px) {
.summary-strip {
flex-direction: column;
.score-block {
min-width: 0;
padding: 14px;
}
.decision-block {
border-left: 0;
border-top: 1px solid var(--line);
}
}
.report-head {
align-items: flex-start;
}
}
@media print {
.no-print {
display: none !important;
}
.share-page {
padding: 0;
background: #fff;
}
.report-card {
box-shadow: none;
border: 0;
width: 100%;
}
}
</style>
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment