diff --git a/.kiro/steering/项目结构说明.md b/.kiro/steering/项目结构说明.md index 11c5525..c65a1e1 100644 --- a/.kiro/steering/项目结构说明.md +++ b/.kiro/steering/项目结构说明.md @@ -71,7 +71,8 @@ offerpie_python_ai/ │ ├─ resume_diagnosis_report.py # 简历诊断报告表(bg_resume_diagnosis_report) │ ├─ resume_diagnosis_issue.py # 简历诊断问题表(bg_resume_diagnosis_issue) │ ├─ job.py # 岗位表(bg_job,只读,用于技能差距分析) - │ └─ job_agent_config.py # 求职助手配置表(bg_job_agent_config) + │ ├─ job_agent_config.py # 求职助手配置表(bg_job_agent_config) + │ └─ user_job_customize_resume.py # 用户岗位定制简历表(bg_user_job_customize_resume) │ ├─ tool/ # **工具层**(无状态、无业务依赖的通用工具) │ ├─ file_parser.py # 文件解析工具(PDF/Word/TXT → 纯文本,parse_to_text 入口方法) @@ -90,7 +91,7 @@ offerpie_python_ai/ ├─ resume_diagnose_service.py # 简历诊断服务(加载简历→AI并行诊断→统计评级→写入报告) ├─ skill_gap_service.py # 技能差距分析服务(差距分析→定制简历生成→AI对话编辑) ├─ resume_loader.py # 简历统一查询模块(按ID查/自动选默认+5张子表,返回 ResumeDetail dataclass) - ├─ customize_resume_store.py # 定制简历 Redis 存取模块(保存自动回滚备份、查询、回滚) + ├─ customize_resume_store.py # 定制简历存取模块(数据库持久化 + Redis回滚备份、按用户+岗位维度存取) ├─ job_agent_chat_service.py # 求职助手对话服务(查简历→序列化→调AI模块完成对话) └─ nova_chat_service.py # Nova 对话助手服务(查简历+查岗位(可选)→调AI,纯对话不持久化) ``` @@ -102,9 +103,9 @@ offerpie_python_ai/ | **core** | 核心基础设施:数据库连接、Redis连接、鉴权、日志、中间件、异常处理、统一响应 | `database.py`、`redis.py`、`auth.py`、`middleware.py`、`exceptions.py`、`logger.py`、`StandardResponse` | | **ai** | AI 模型管理 + 业务 AI 能力 | `LLM` 枚举、`resume_extractor/`(简历并行提取)、`resume_diagnoser/`(简历诊断)、`skill_gap_analyzer/`(技能差距分析 + 定制简历优化 + Agent 原子化规划 + 单条记录修改/新增)、`job_agent/`(求职助手对话 + 岗位简历优化)、`nova_chat/`(Nova 对话助手,纯对话) | | **api** | REST API 路由定义 | `health.py`(健康检查)、`resume.py`(简历上传解析)、`resume_diagnose.py`(简历诊断)、`skill_gap.py`(技能差距分析 + 生成定制简历 + AI对话编辑)、`customize_resume.py`(定制简历查询/修改/回滚)、`job_agent_chat.py`(求职助手对话 + 岗位简历优化)、`nova_chat.py`(Nova 对话助手) | -| **models** | SQLAlchemy ORM 模型,与 Java 端共享同一数据库 | `FuncPermission`、`UserFuncPermissionStock`、`UserFuncUsageLog`、`UserResume`、`UserResumeEducation`/`Work`/`Internship`/`Project`/`Competition`、`ResumeDiagnosisReport`、`ResumeDiagnosisIssue`、`Job`(只读)、`JobAgentConfig` | +| **models** | SQLAlchemy ORM 模型,与 Java 端共享同一数据库 | `FuncPermission`、`UserFuncPermissionStock`、`UserFuncUsageLog`、`UserResume`、`UserResumeEducation`/`Work`/`Internship`/`Project`/`Competition`、`ResumeDiagnosisReport`、`ResumeDiagnosisIssue`、`Job`(只读)、`JobAgentConfig`、`UserJobCustomizeResume` | | **tool** | 无状态通用工具,不依赖数据库/Redis/用户上下文 | `file_parser.py`(PDF/Word/TXT 文件解析为纯文本)、`json_helper.py`(AI 输出 JSON 解析,去 markdown 代码块 + json_repair 容错)、`snowflake.py`(雪花ID生成) | -| **services** | 业务逻辑实现 | `FuncPermissionService`(功能权限校验、扣减、回退)、`ResumeParseService`(简历文件解析→AI结构化→入库)、`ResumeDiagnoseService`(简历诊断→AI并行分析→评级→入库)、`SkillGapService`(技能差距分析→定制简历生成→AI对话编辑)、`resume_loader`(简历统一查询,返回ResumeDetail)、`customize_resume_store`(定制简历Redis存取+数据构建,自动回滚备份)、`JobAgentChatService`(求职助手对话+岗位简历优化)、`NovaChatService`(Nova对话助手,查简历+查岗位→调AI) | +| **services** | 业务逻辑实现 | `FuncPermissionService`(功能权限校验、扣减、回退)、`ResumeParseService`(简历文件解析→AI结构化→入库)、`ResumeDiagnoseService`(简历诊断→AI并行分析→评级→入库)、`SkillGapService`(技能差距分析→定制简历生成→AI对话编辑)、`resume_loader`(简历统一查询,返回ResumeDetail)、`customize_resume_store`(定制简历数据库存取+数据构建,按用户+岗位维度,Redis回滚备份)、`JobAgentChatService`(求职助手对话+岗位简历优化)、`NovaChatService`(Nova对话助手,查简历+查岗位→调AI) | ## 3️⃣ 技术栈 | 类别 | 技术选型 | 说明 | diff --git a/app/api/customize_resume.py b/app/api/customize_resume.py index 330511a..605ecb9 100644 --- a/app/api/customize_resume.py +++ b/app/api/customize_resume.py @@ -1,6 +1,6 @@ """定制简历接口(查询/修改/回滚)""" -from fastapi import APIRouter +from fastapi import APIRouter, Query from app.core.context import RequestContext from app.schemas.customize_resume import CustomizeResume @@ -10,21 +10,21 @@ router = APIRouter(prefix="/job", tags=["定制简历"]) @router.get("/customize-resume", summary="查询定制简历") -async def get_customize_resume(): - """查询当前用户的定制简历""" +async def get_customize_resume(job_id: int = Query(..., description="岗位ID")): + """查询当前用户针对某岗位的定制简历""" user_id = RequestContext.user_id.get() - return await customize_resume_store.get(user_id) + return await customize_resume_store.get(user_id, job_id) @router.put("/customize-resume", summary="修改定制简历") -async def update_customize_resume(data: CustomizeResume): +async def update_customize_resume(job_id: int = Query(..., description="岗位ID"), data: CustomizeResume = ...): """手动编辑定制简历(整体覆盖)""" user_id = RequestContext.user_id.get() - await customize_resume_store.save(user_id, data) + await customize_resume_store.save(user_id, job_id, data) @router.post("/customize-resume/rollback", summary="回滚定制简历") -async def rollback_customize_resume(): +async def rollback_customize_resume(job_id: int = Query(..., description="岗位ID")): """回滚到上一版本的定制简历""" user_id = RequestContext.user_id.get() - await customize_resume_store.rollback(user_id) + await customize_resume_store.rollback(user_id, job_id) diff --git a/app/models/user_job_customize_resume.py b/app/models/user_job_customize_resume.py new file mode 100644 index 0000000..263d6cf --- /dev/null +++ b/app/models/user_job_customize_resume.py @@ -0,0 +1,24 @@ +"""用户岗位定制简历表(bg_user_job_customize_resume) + +一个用户 + 一个岗位 = 一份定制简历,content 字段存完整 CustomizeResume JSON。 +""" + +from datetime import datetime +from typing import Optional + +from sqlalchemy import BigInteger, DateTime, JSON +from sqlalchemy.orm import Mapped, mapped_column + +from app.core.database import Base + + +class UserJobCustomizeResume(Base): + """用户岗位定制简历表 bg_user_job_customize_resume""" + __tablename__ = "bg_user_job_customize_resume" + + id: Mapped[int] = mapped_column(BigInteger, primary_key=True) + user_id: Mapped[int] = mapped_column(BigInteger, nullable=False, comment="用户ID") + job_id: Mapped[int] = mapped_column(BigInteger, nullable=False, comment="岗位ID,关联 bg_job.id") + content: Mapped[Optional[dict]] = mapped_column(JSON, nullable=False, comment="定制简历完整 JSON(CustomizeResume 结构)") + create_time: Mapped[datetime] = mapped_column(DateTime, default=datetime.now, comment="创建时间") + update_time: Mapped[datetime] = mapped_column(DateTime, default=datetime.now, onupdate=datetime.now, comment="更新时间") diff --git a/app/services/customize_resume_store.py b/app/services/customize_resume_store.py index eaf44ba..2f65a7b 100644 --- a/app/services/customize_resume_store.py +++ b/app/services/customize_resume_store.py @@ -1,22 +1,25 @@ -"""定制简历 Redis 存取 + 数据转换模块 +"""定制简历存取 + 数据转换模块 -提供定制简历的保存(自动回滚备份)、查询、回滚、从 ResumeDetail 构建能力。 -各 Service 统一复用,不直接操作 Redis key。 +提供定制简历的保存(数据库持久化)、查询、回滚(Redis临时备份)、从 ResumeDetail 构建能力。 +各 Service 统一复用,不直接操作数据库表和 Redis key。 """ import random import string +from sqlalchemy import select, update + +from app.core.database import async_session_factory from app.core.redis import RedisManager +from app.models.user_job_customize_resume import UserJobCustomizeResume from app.schemas.customize_resume import ( CustomizeResume, ResumeProfile, Education, Work, Internship, Project, Competition, Paragraph, ) -from app.services.resume_loader import ResumeDetail +from app.services.resume_loader import ResumeDetail, load_default_resume_detail +from app.tool.snowflake import next_id -# Redis 常量 -KEY_PREFIX = "customize:resume:" -EXPIRE = 12 * 60 * 60 # 12小时 -ROLLBACK_KEY_PREFIX = "customize:resume:rollback:" +# Redis 回滚常量 +ROLLBACK_PREFIX = "customize:resume:rollback:" ROLLBACK_EXPIRE = 30 * 60 # 30分钟 _CHARS = string.ascii_letters + string.digits @@ -65,31 +68,57 @@ def build_from_detail(detail: ResumeDetail) -> CustomizeResume: ) -async def save(user_id: int, cr: CustomizeResume) -> None: - """保存定制简历,自动备份旧版本到回滚 key""" - key = f"{KEY_PREFIX}{user_id}" - rollback_key = f"{ROLLBACK_KEY_PREFIX}{user_id}" - old_data = await RedisManager.client.get(key) - if old_data: - await RedisManager.client.set(rollback_key, old_data, ex=ROLLBACK_EXPIRE) - await RedisManager.client.set(key, cr.model_dump_json(by_alias=True), ex=EXPIRE) +def _rollback_key(user_id: int, job_id: int) -> str: + return f"{ROLLBACK_PREFIX}{user_id}:{job_id}" -async def get(user_id: int) -> dict | None: - """查询定制简历,返回 dict 或 None""" - key = f"{KEY_PREFIX}{user_id}" - data = await RedisManager.client.get(key) - if data: - return CustomizeResume.model_validate_json(data).model_dump(by_alias=True) - return None +async def save(user_id: int, job_id: int, cr: CustomizeResume) -> None: + """保存定制简历:备份旧版本到 Redis 用于回滚 + 写数据库""" + content = cr.model_dump(by_alias=True) + + async with async_session_factory() as session: + # 查已有记录,备份旧版本到 Redis 用于回滚 + result = await session.execute(select(UserJobCustomizeResume).where(UserJobCustomizeResume.user_id == user_id, UserJobCustomizeResume.job_id == job_id)) + record = result.scalar_one_or_none() + if record: + old_cr = CustomizeResume.model_validate(record.content) + await RedisManager.client.set(_rollback_key(user_id, job_id), old_cr.model_dump_json(by_alias=True), ex=ROLLBACK_EXPIRE) + await session.execute(update(UserJobCustomizeResume).where( + UserJobCustomizeResume.id == record.id).values(content=content)) + else: + session.add(UserJobCustomizeResume(id=next_id(), user_id=user_id, job_id=job_id, content=content)) + await session.commit() -async def rollback(user_id: int) -> None: - """回滚定制简历到上一版本""" - rollback_key = f"{ROLLBACK_KEY_PREFIX}{user_id}" - data = await RedisManager.client.get(rollback_key) +async def get(user_id: int, job_id: int) -> dict | None: + """查询定制简历,查不到则加载默认简历构建返回""" + async with async_session_factory() as session: + result = await session.execute(select(UserJobCustomizeResume).where( + UserJobCustomizeResume.user_id == user_id, UserJobCustomizeResume.job_id == job_id)) + record = result.scalar_one_or_none() + if record: + return CustomizeResume.model_validate(record.content).model_dump(by_alias=True) + # 没有定制简历,加载默认简历构建 + detail = await load_default_resume_detail(session, user_id) + return build_from_detail(detail).model_dump(by_alias=True) + + +async def rollback(user_id: int, job_id: int) -> None: + """回滚定制简历到上一版本:从 Redis 取回滚数据写入数据库""" + rollback_k = _rollback_key(user_id, job_id) + data = await RedisManager.client.get(rollback_k) if not data: raise ValueError("没有可回滚的版本") - key = f"{KEY_PREFIX}{user_id}" - await RedisManager.client.set(key, data, ex=EXPIRE) - await RedisManager.client.delete(rollback_key) + content = CustomizeResume.model_validate_json(data).model_dump(by_alias=True) + + async with async_session_factory() as session: + result = await session.execute(select(UserJobCustomizeResume).where( + UserJobCustomizeResume.user_id == user_id, UserJobCustomizeResume.job_id == job_id)) + record = result.scalar_one_or_none() + if record: + await session.execute(update(UserJobCustomizeResume).where( + UserJobCustomizeResume.id == record.id).values(content=content)) + else: + session.add(UserJobCustomizeResume(id=next_id(), user_id=user_id, job_id=job_id, content=content)) + await session.commit() + await RedisManager.client.delete(rollback_k) diff --git a/app/services/job_agent_chat_service.py b/app/services/job_agent_chat_service.py index 739242f..ab4759a 100644 --- a/app/services/job_agent_chat_service.py +++ b/app/services/job_agent_chat_service.py @@ -96,8 +96,8 @@ class JobAgentChatService: log.warning(f"岗位简历优化[{key}]失败: {result}") continue self._apply_optimize_result(cr, key, result) - # 4. 存 Redis - await customize_resume_store.save(user_id, cr) + # 4. 存数据库 + await customize_resume_store.save(user_id, job_id, cr) # 5. 返回 return cr.model_dump(by_alias=True) diff --git a/app/services/skill_gap_service.py b/app/services/skill_gap_service.py index 6fe4759..4c071e6 100644 --- a/app/services/skill_gap_service.py +++ b/app/services/skill_gap_service.py @@ -126,8 +126,8 @@ class SkillGapService: if "skills" in optimize_modules and add_skills: existing = set(cr.resume.skills) cr.resume.skills.extend([s for s in add_skills if s not in existing]) - # 5. 存 Redis - await customize_resume_store.save(user_id, cr) + # 5. 存数据库 + await customize_resume_store.save(user_id, job_id, cr) @staticmethod def _experience_tasks(cr: CustomizeResume, job_title: str, job_desc: str) -> list[tuple[str, str]]: @@ -161,7 +161,7 @@ class SkillGapService: instruction: str, chat_history: list) -> dict: """AI 对话式编辑定制简历(原子化操作版)""" # 1. 取当前定制简历 - cr_data = await customize_resume_store.get(user_id) + cr_data = await customize_resume_store.get(user_id, job_id) if not cr_data: raise ValueError("定制简历不存在,请先生成") cr = CustomizeResume.model_validate(cr_data) @@ -228,7 +228,7 @@ class SkillGapService: elif op_type == "add": self._apply_record_add(cr, mod_name, result) # 6. 保存(自动备份回滚) - await customize_resume_store.save(user_id, cr) + await customize_resume_store.save(user_id, job_id, cr) # 拼接更新模块标签 updated_modules = list(dict.fromkeys(op.get("module", "") for op in operations)) label = "、".join(_MODULE_LABELS.get(m, m) for m in updated_modules if m)