1. 项目初始化与基础架构 #

本节完成安装 Flask 与数据库相关依赖,通过 .env 管理运行配置,采用应用工厂模式(create_app)组织代码,并建立 SQLAlchemy 数据模型与首个健康检查接口。

整体分层如下:

层级 职责 关键文件
入口 启动 Web 服务 main.py
应用工厂 组装 Flask、DB、路由 app/__init__.py
配置 读取环境变量 app/config.py、.env
扩展 数据库单例 app/extensions.py
模型 简历与分析记录 app/models/
路由 HTTP 接口 app/routes/

项目初始化完整时序如下:

sequenceDiagram autonumber actor Dev as 开发者 participant Main as main.py participant Factory as create_app() participant Env as Config / .env participant App as Flask participant DB as SQLAlchemy participant BP as health_bp participant MySQL as MySQL Dev->>Main: python main.py Main->>Factory: create_app() Factory->>Env: load_dotenv() 加载 .env Factory->>App: Flask(__name__) Factory->>Env: app.config.from_object(Config) Factory->>DB: db.init_app(app) Factory->>BP: register_blueprints(app) Factory->>DB: db.create_all() DB->>MySQL: 创建 resumes / analyses 表 Factory-->>Main: 返回 app 实例 Main->>App: app.run(host, port, debug) Note over Dev,MySQL: 启动后验证数据库连通性 Dev->>BP: GET /api/health BP->>DB: session.execute("SELECT 1") DB->>MySQL: 测试连接 MySQL-->>DB: 返回结果 alt 连接正常 BP-->>Dev: 200 {"success": true, "database": "ok"} else 连接失败 BP-->>Dev: 503 {"success": false, "database": "error"} end

1.1 安装依赖 #

uv add flask  flask-sqlalchemy pymysql python-dotenv openai DrissionPage tzdata

1.2. .env #

.env

# 是否开启Flask调试模式,1表示开启
FLASK_DEBUG=1
# Flask服务器监听的主机地址
FLASK_HOST=127.0.0.1
# Flask服务器监听的端口号
FLASK_PORT=5000
# Flask应用的密钥,用于安全相关(如session)
SECRET_KEY=change-me
# 数据库连接URL,格式为“mysql+pymysql://用户名:密码@主机:端口/数据库名”
DATABASE_URL=mysql+pymysql://root:root@127.0.0.1:3306/job3

1.3. init.py #

app/init.py

# 从Flask框架中导入Flask类
from flask import Flask
# 从app.config模块导入Config配置类
from app.config import Config
# 从app.extensions模块导入db(数据库扩展)
from app.extensions import db
# 从app.routes模块导入register_blueprints函数
from app.routes import register_blueprints

# 定义创建应用工厂函数,可接收一个配置类参数
def create_app(config_class=Config):
    # 创建Flask应用实例,指定模板和静态文件目录
    app = Flask(__name__,
                template_folder=str(config_class.TEMPLATE_DIR),
                static_folder=str(config_class.STATIC_DIR))
    # 从配置类加载配置信息
    app.config.from_object(config_class)
    # 初始化数据库,将app绑定到db上
    db.init_app(app)
    # 注册蓝图到app
    register_blueprints(app)
    # 在应用上下文中创建所有数据库表
    with app.app_context():
        db.create_all()
    # 返回应用实例
    return app

1.4. config.py #

app/config.py

# 导入操作系统相关模块
import os
# 导入Path对象,用于处理文件和目录路径
from pathlib import Path
# 导入load_dotenv函数,用于加载.env环境变量文件
from dotenv import load_dotenv

# 获取当前文件的上上级目录作为项目的根目录
BASE_DIR = Path(__file__).resolve().parent.parent
# 从根目录下加载.env文件,override=True表示覆盖已存在的环境变量
load_dotenv(BASE_DIR / ".env", override=True)

# 定义配置类
class Config:
    # 项目根目录路径
    BASE_DIR = BASE_DIR
    # 模板文件夹路径
    TEMPLATE_DIR = BASE_DIR / "templates"
    # 静态文件夹路径
    STATIC_DIR = BASE_DIR / "static"
    # 从环境变量获取SECRET_KEY, 若无则使用默认值"dev-secret-key"
    SECRET_KEY = os.getenv("SECRET_KEY", "dev-secret-key")
    # 从环境变量获取数据库连接地址,若无则用默认的MySQL连接字符串
    SQLALCHEMY_DATABASE_URI = os.getenv(
        "DATABASE_URL",
        "mysql+pymysql://root:password@127.0.0.1:3306/job2",
    )
    # 是否追踪数据库修改,这里默认关闭
    SQLALCHEMY_TRACK_MODIFICATIONS = False
    # 从环境变量获取FLASK_DEBUG,字符串为"1"时为True,否则为False
    FLASK_DEBUG = os.getenv("FLASK_DEBUG", "1") == "1"
    # 从环境变量获取Flask服务的主机地址,默认为"127.0.0.1"
    FLASK_HOST = os.getenv("FLASK_HOST", "127.0.0.1")
    # 从环境变量获取Flask服务的端口号,默认为5000,并转换为int类型
    FLASK_PORT = int(os.getenv("FLASK_PORT", "5000"))

1.5. extensions.py #

app/extensions.py

# 从 flask_sqlalchemy 包中导入 SQLAlchemy 类
from flask_sqlalchemy import SQLAlchemy
# 创建 SQLAlchemy 数据库实例对象
db = SQLAlchemy()

1.6. init.py #

app/models/init.py

# 从 app.models.resume 模块中导入 Resume 类
from app.models.resume import Resume
# 从 app.models.analysis 模块中导入 Analysis 类
from app.models.analysis import Analysis

1.7. analysis.py #

app/models/analysis.py

# 导入用于获取当前时间的datetime模块
from datetime import datetime
# 导入数据库扩展对象db
from app.extensions import db

# 定义Analysis模型类,继承自db.Model
class Analysis(db.Model):
    # 指定数据表名称为 "analyses"
    __tablename__ = "analyses"
    # 定义主键id,类型为整数
    id = db.Column(db.Integer, primary_key=True)
    # 定义外键resume_id,关联到"resumes"表的id,不能为空
    resume_id = db.Column(db.Integer, db.ForeignKey("resumes.id"), nullable=False)
    # 定义岗位描述文本字段,类型为Text,不能为空
    jd_text = db.Column(db.Text, nullable=False)
    # 定义分析结果字段,类型为JSON,可以为空
    result = db.Column(db.JSON, nullable=True)
    # 定义匹配得分字段,类型为整数,可以为空
    score = db.Column(db.Integer, nullable=True)
    # 定义是否推荐投递结果,类型为长度16的字符串,可以为空
    recommend_apply = db.Column(db.String(16), nullable=True)
    # 定义创建时间,类型为DateTime,默认当前时间,不能为空
    created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False)
    # 定义与Resume模型的关系,允许反向查找所有analysis
    resume = db.relationship("Resume", back_populates="analyses")

1.8. resume.py #

app/models/resume.py

# 导入 datetime 和 timezone 模块用于处理时间
from datetime import datetime, timezone
# 导入数据库扩展对象 db
from app.extensions import db

# 定义简历模型 Resume,继承自 db.Model
class Resume(db.Model):
    # 指定数据库表名为 'resumes'
    __tablename__ = "resumes"
    # 定义主键 id,类型为整数
    id = db.Column(db.Integer, primary_key=True)
    # 定义文件名字段 filename,类型为字符串,最大长度为256,不能为空
    filename = db.Column(db.String(256), nullable=False)
    # 定义文本内容字段 text_content,类型为文本,可以为NULL
    text_content = db.Column(db.Text, nullable=True)
    # 定义创建时间字段 created_at,类型为日期时间,默认值为当前UTC时间,不能为空
    created_at = db.Column(
        db.DateTime, default=lambda: datetime.now(timezone.utc), nullable=False
    )
    # 定义与 Analysis 模型的关系,反向引用名为 resume
    analyses = db.relationship("Analysis", back_populates="resume")

1.9. init.py #

app/routes/init.py

# 从app.routes.health模块导入health_bp蓝图
from app.routes.health import health_bp

# 定义注册蓝图的函数,参数为Flask应用实例app
def register_blueprints(app):
    # 将health_bp蓝图注册到Flask应用中
    app.register_blueprint(health_bp)

1.10. health.py #

app/routes/health.py

# 从flask框架中导入Blueprint和jsonify用于创建蓝图和返回json响应
from flask import Blueprint, jsonify
# 从sqlalchemy中导入text用于执行原生SQL语句
from sqlalchemy import text
# 从自定义的app.extensions模块中导入db对象,作为数据库连接
from app.extensions import db

# 创建名为"health"的蓝图实例
health_bp = Blueprint("health", __name__)

# 使用get方法装饰health_check函数,使其响应"/api/health"路由
@health_bp.get("/api/health")
# 定义健康检查的视图函数
def health_check():
    # 尝试执行数据库操作,以检测数据库是否正常
    try:
        # 执行一条测试SQL语句"SELECT 1"以检测数据库连接
        db.session.execute(text("SELECT 1"))
        # 如果上述代码没有异常,则返回数据库状态正常的json响应和200状态码
        return jsonify({"success": True, "database": "ok"}), 200
    # 如果执行过程中发生异常
    except Exception as exc:
        # 返回数据库异常的json响应,包含异常详情,并返回503状态码
        return jsonify({"success": False, "database": "error", "detail": str(exc)}), 503

1.11. main.py #

main.py

# 从app模块导入create_app函数
from app import create_app
# 从app.config模块导入Config配置类
from app.config import Config

# 调用create_app函数创建一个Flask应用实例
app = create_app()

# 定义主函数
def main():
    # 运行Flask应用,主机、端口和调试信息从Config类中获取
    app.run(host=Config.FLASK_HOST, port=Config.FLASK_PORT, debug=Config.FLASK_DEBUG)

# 判断当前模块是否为主程序入口
if __name__ == "__main__":
    # 如果是,则调用主函数main()
    main()

2. 简历上传与文件解析 #

本节实现流程第一步「上传简历」:用户通过 /upload 页面上传 PDF 或 DOCX,后端校验并解析为纯文本,写入 Resume 表,并将 resume_id 存入 Session,供后续 JD 输入与分析步骤使用。

涉及模块如下:

层级 职责 关键文件
路由 处理上传请求、Session 绑定 app/routes/resume.py、helpers.py
服务 格式校验、PDF/DOCX 文本提取 app/services/resume_parser.py
模型 持久化文件名与解析文本 app/models/resume.py(第 1 节已建)
模板 上传页、步骤条、Flash 提示 templates/
静态资源 页面样式与交互 static/css/style.css、static/js/common.js

简历上传的完整时序如下:

sequenceDiagram autonumber actor User as 用户 participant Page as upload.html participant Route as resume_bp /upload participant Helper as get_current_resume() participant Parser as resume_parser participant DB as SQLAlchemy participant Session as Flask Session User->>Route: GET /upload Route->>Helper: 读取 session["resume_id"] Helper->>DB: 查询 Resume(若有) Route->>Page: render_template(current_step=1) Page-->>User: 展示上传表单 / 已上传文件 User->>Page: 选择 PDF/DOCX 并提交 Page->>Route: POST multipart/form-data Route->>Route: file.read() 读取字节流 Route->>Parser: parse_resume(filename, data) Parser->>Parser: validate_resume_file() 校验格式与大小 alt PDF Parser->>Parser: parse_pdf() 逐页提取文本 else DOCX Parser->>Parser: parse_docx() 提取段落文本 end Parser-->>Route: 返回 text_content Route->>DB: add(Resume) + commit() Route->>Session: session["resume_id"] = resume.id Route->>Route: flash("简历上传成功") Route-->>User: redirect → GET /upload Page-->>User: 显示文件名、字数与「下一步」入口

按以下小节顺序逐步创建各文件即可。

2.1 安装依赖 #

uv add pdfplumber  python-docx

2.2. helpers.py #

app/routes/helpers.py

# 从flask导入session对象,用于会话管理
from flask import session
# 导入数据库db实例
from app.extensions import db
# 导入Resume模型
from app.models import Resume

# 定义获取当前简历的方法
def get_current_resume():
    # 从session中获取"resume_id"
    resume_id = session.get("resume_id")
    # 如果没有"resume_id",返回None
    if not resume_id:
        return None
    # 通过resume_id从数据库中获取Resume对象
    resume = db.session.get(Resume, resume_id)
    # 如果没有获取到简历对象,则删除session中的"resume_id"
    if not resume:
        session.pop("resume_id", None)
    # 返回获取到的简历对象
    return resume

2.3. resume.py #

app/routes/resume.py

# 导入Flask相关模块
from flask import (
    # 导入Blueprint用于创建蓝图
    Blueprint,
    # 导入flash用于闪现消息
    flash,
    # 导入redirect用于重定向
    redirect,
    # 导入render_template用于渲染模板
    render_template,
    # 导入request用于处理请求数据
    request,
    # 导入session用于会话管理
    session,
    # 导入url_for用于生成URL
    url_for,
)
# 导入数据库对象db
from app.extensions import db
# 导入Resume模型
from app.models import Resume
# 导入简历解析服务parse_resume
from app.services.resume_parser import parse_resume
# 导入获取当前简历的辅助函数get_current_resume
from app.routes.helpers import get_current_resume
# 创建resume蓝图
resume_bp = Blueprint("resume", __name__)
# 配置/upload路由,支持GET和POST方法
@resume_bp.route("/upload", methods=["GET", "POST"])
# 定义upload函数处理简历上传
def upload():
    # 如果请求方法为POST
    if request.method == "POST":
        # 从请求中获取上传的resume文件
        file = request.files.get("resume")
        # 读取文件内容
        data = file.read()
        # 调用简历解析服务获取文本内容
        text_content = parse_resume(file.filename, data)
        # 创建Resume对象并填充文件名和文本内容
        resume = Resume(filename=file.filename, text_content=text_content)
        # 将Resume对象添加到数据库会话
        db.session.add(resume)
        # 提交会话保存数据到数据库
        db.session.commit()
        # 将简历ID保存到session
        session["resume_id"] = resume.id
        # 闪现简历上传成功的提示信息
        flash("简历上传成功", "success")
        # 重定向到resume.upload页面
        return redirect(url_for("resume.upload"))
    # 如果不是POST请求,则渲染上传页面,并传入当前简历和当前步骤数
    return render_template("upload.html", resume=get_current_resume(), current_step=1)

2.4. resume_parser.py #

app/services/resume_parser.py

# 导入io模块,用于处理字节流
import io
# 从pathlib导入Path,用于处理文件路径
from pathlib import Path
# 导入pdfplumber库,用于解析PDF文件
import pdfplumber
# 从docx库导入Document类,用于解析docx文件
from docx import Document

# 定义允许的文件扩展名集合
ALLOWED_EXTENSIONS = {".pdf", ".docx"}
# 定义文件允许的最大大小,这里是10MB
MAX_FILE_SIZE = 10 * 1024 * 1024

# 定义一个函数,用来校验简历文件的文件名和文件大小
def validate_resume_file(filename: str, file_size: int) -> None:
    # 获取文件的扩展名,并转换为小写
    ext = Path(filename).suffix.lower()
    # 判断扩展名是否在允许列表中
    if ext not in ALLOWED_EXTENSIONS:
        # 如果不是支持的格式,抛出异常
        raise ValueError("仅支持 PDF、DOCX 格式")
    # 判断文件大小是否为0
    if file_size == 0:
        # 如果文件为空,抛出异常
        raise ValueError("文件为空,请选择有效文件")
    # 判断文件大小是否超过最大限制
    if file_size > MAX_FILE_SIZE:
        # 如果文件太大,抛出异常
        raise ValueError("文件大小不能超过 10 MB")

# 定义一个用于解析PDF文件的函数
def parse_pdf(data: bytes) -> str:
    # 创建文本片段列表
    text_parts = []
    # 使用pdfplumber打开PDF文件
    with pdfplumber.open(io.BytesIO(data)) as pdf:
        # 遍历PDF的每一页
        for page in pdf.pages:
            # 提取当前页的文本
            page_text = page.extract_text()
            # 如果该页有文本,则加入列表
            if page_text:
                text_parts.append(page_text)
    # 将所有页面的文本合并为一个字符串,并去除首尾空白
    return "\n".join(text_parts).strip()

# 定义一个用于解析docx文件的函数
def parse_docx(data: bytes) -> str:
    # 读取docx文件内容
    doc = Document(io.BytesIO(data))
    # 提取所有非空段落文本,并组成列表
    text_parts = [para.text for para in doc.paragraphs if para.text.strip()]
    # 合并所有段落文本为一个字符串,并去除首尾空白
    return "\n".join(text_parts).strip()

# 定义一个高层函数,根据文件类型调用对应解析方法
def parse_resume(filename: str, data: bytes) -> str:
    # 校验文件名和大小是否符合要求
    validate_resume_file(filename, len(data))
    # 获取文件扩展名并转成小写
    ext = Path(filename).suffix.lower()
    # 如果是PDF文件,则调用PDF解析方法
    if ext == ".pdf":
        text = parse_pdf(data)
    # 否则调用docx解析方法
    else:
        text = parse_docx(data)
    # 如果未能提取到文本,抛出异常
    if not text:
        raise ValueError("未能从文件中提取到文本,请检查文件内容")
    # 返回解析得到的文本
    return text

2.5. style.css #

static/css/style.css

* {
  box-sizing: border-box;
}

body {
  margin: 0;
  font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "PingFang SC",
    "Microsoft YaHei", sans-serif;
  color: #1f2937;
  background: #f3f4f6;
}

.container {
  width: min(960px, 92%);
  margin: 0 auto;
}

.main-content {
  padding: 24px 0 48px;
}

.site-header {
  background: #fff;
  border-bottom: 1px solid #e5e7eb;
}

.header-inner {
  display: flex;
  align-items: center;
  justify-content: space-between;
  min-height: 56px;
}

.logo {
  display: inline-flex;
  align-items: center;
  gap: 8px;
  font-weight: 700;
  color: #2563eb;
  text-decoration: none;
}

.logo-icon {
  display: inline-flex;
  width: 24px;
  height: 24px;
  align-items: center;
  justify-content: center;
  border-radius: 6px;
  background: #2563eb;
  color: #fff;
  font-size: 12px;
}

.header-nav a {
  color: #6b7280;
  text-decoration: none;
}

.header-tagline {
  font-size: 13px;
  color: #9ca3af;
}

.page-card {
  background: #fff;
  border: 1px solid #e5e7eb;
  border-radius: 12px;
  padding: 32px;
}

.steps {
  display: flex;
  align-items: center;
  justify-content: center;
  gap: 0;
  margin-bottom: 32px;
  flex-wrap: wrap;
}

.step {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 8px;
  min-width: 72px;
}

.step-num {
  width: 32px;
  height: 32px;
  border-radius: 50%;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  font-size: 14px;
  font-weight: 600;
  background: #e5e7eb;
  color: #6b7280;
}

.step-label {
  font-size: 12px;
  color: #6b7280;
  white-space: nowrap;
}

.step.is-active .step-num {
  background: #2563eb;
  color: #fff;
}

.step.is-active .step-label {
  color: #2563eb;
  font-weight: 600;
}

.step.is-done .step-num {
  background: #10b981;
  color: #fff;
}

.step.is-done .step-label {
  color: #10b981;
}

.step-line {
  width: 48px;
  height: 2px;
  background: #e5e7eb;
  margin: 0 4px 20px;
}

.step-line.is-done {
  background: #10b981;
}

.page-head {
  text-align: center;
  margin-bottom: 24px;
}

.page-head h1 {
  margin: 0 0 8px;
  font-size: 1.75rem;
}

.page-head p {
  margin: 0;
  color: #6b7280;
}

.flash-list {
  margin-bottom: 16px;
}

.flash {
  padding: 10px 14px;
  border-radius: 8px;
  margin-bottom: 8px;
  font-size: 14px;
}

.flash-success {
  background: #ecfdf5;
  color: #047857;
  border: 1px solid #a7f3d0;
}

.flash-error {
  background: #fef2f2;
  color: #b91c1c;
  border: 1px solid #fecaca;
}

.upload-form {
  margin-bottom: 24px;
}

.upload-zone {
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  min-height: 220px;
  padding: 32px;
  border: 2px dashed #cbd5e1;
  border-radius: 12px;
  background: #f8fafc;
  cursor: pointer;
  transition: border-color 0.2s, background 0.2s;
}

.upload-zone:hover {
  border-color: #2563eb;
  background: #eff6ff;
}

.upload-zone.is-dragover {
  border-color: #2563eb;
  background: #dbeafe;
  border-style: solid;
}

.upload-icon {
  font-size: 48px;
  color: #2563eb;
  margin-bottom: 12px;
}

.upload-title {
  margin: 0 0 8px;
  font-size: 1rem;
  font-weight: 600;
}

.upload-hint {
  margin: 0 0 16px;
  color: #6b7280;
  font-size: 14px;
}

.selected-file {
  margin: 12px 0 0;
  font-size: 14px;
  color: #2563eb;
  min-height: 20px;
}

.uploaded-file {
  display: flex;
  align-items: center;
  gap: 12px;
  padding: 16px;
  border: 1px solid #e5e7eb;
  border-radius: 10px;
  background: #f9fafb;
  margin-bottom: 16px;
}

.file-icon {
  font-size: 28px;
}

.file-info {
  flex: 1;
  min-width: 0;
}

.file-name {
  font-weight: 600;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

.file-meta {
  font-size: 13px;
  color: #6b7280;
  margin-top: 4px;
}

.badge {
  display: inline-block;
  padding: 4px 10px;
  border-radius: 999px;
  font-size: 12px;
  font-weight: 600;
  white-space: nowrap;
}

.badge-success {
  background: #ecfdf5;
  color: #059669;
}

.btn {
  display: inline-block;
  padding: 10px 20px;
  border-radius: 8px;
  text-decoration: none;
  border: none;
  font-size: 15px;
  cursor: pointer;
  text-align: center;
}

.btn-primary {
  background: #2563eb;
  color: #fff;
}

.btn-primary:hover {
  background: #1d4ed8;
}

.btn:disabled {
  opacity: 0.65;
  cursor: not-allowed;
}

.btn-outline {
  background: #fff;
  color: #2563eb;
  border: 1px solid #2563eb;
}

.btn-block {
  display: block;
  width: 100%;
}

.btn-next {
  margin-top: 8px;
  padding: 14px 20px;
  font-size: 16px;
  font-weight: 600;
}

.placeholder-note {
  padding: 16px;
  background: #fffbeb;
  border: 1px solid #fde68a;
  border-radius: 8px;
  color: #92400e;
  margin-bottom: 16px;
}

.page-card-wide {
  width: min(1100px, 96%);
  margin: 0 auto;
}

.jd-tabs {
  display: flex;
  gap: 8px;
  margin-bottom: 20px;
  border-bottom: 1px solid #e5e7eb;
}

.jd-tab {
  padding: 10px 16px;
  text-decoration: none;
  color: #6b7280;
  border-bottom: 2px solid transparent;
  margin-bottom: -1px;
  font-size: 14px;
}

.jd-tab.is-active {
  color: #2563eb;
  border-bottom-color: #2563eb;
  font-weight: 600;
}

.jd-layout {
  display: grid;
  grid-template-columns: 1fr 280px;
  gap: 24px;
}

.jd-main {
  min-width: 0;
}

.field-label {
  display: block;
  margin-bottom: 8px;
  font-weight: 600;
  font-size: 14px;
}

.field-hint {
  margin: 8px 0 16px;
  font-size: 13px;
  color: #6b7280;
}

.jd-textarea {
  width: 100%;
  min-height: 320px;
  padding: 14px;
  border: 1px solid #d1d5db;
  border-radius: 10px;
  font-size: 14px;
  line-height: 1.6;
  resize: vertical;
  font-family: inherit;
}

.jd-textarea:focus {
  outline: none;
  border-color: #2563eb;
  box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.15);
}

.jd-counter {
  text-align: right;
  font-size: 13px;
  color: #6b7280;
  margin: 8px 0 16px;
}

.jd-url-input {
  width: 100%;
  padding: 12px 14px;
  border: 1px solid #d1d5db;
  border-radius: 10px;
  font-size: 14px;
}

.jd-url-input:focus {
  outline: none;
  border-color: #2563eb;
  box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.15);
}

.parsed-preview {
  margin-top: 24px;
  padding: 16px;
  border: 1px solid #e5e7eb;
  border-radius: 10px;
  background: #f9fafb;
}

.parsed-preview-head {
  display: flex;
  justify-content: space-between;
  align-items: center;
  margin-bottom: 12px;
}

.parsed-preview-head a {
  font-size: 13px;
  color: #2563eb;
  text-decoration: none;
}

.parsed-preview pre {
  margin: 0 0 16px;
  white-space: pre-wrap;
  word-break: break-word;
  font-size: 13px;
  color: #374151;
  max-height: 200px;
  overflow: auto;
}

.aside-card {
  padding: 16px;
  border: 1px solid #e5e7eb;
  border-radius: 10px;
  background: #f8fafc;
  font-size: 14px;
}

.aside-card h3 {
  margin: 0 0 12px;
  font-size: 15px;
}

.aside-card p {
  margin: 0 0 10px;
  color: #6b7280;
  line-height: 1.5;
}

.aside-card ul {
  margin: 0;
  padding-left: 18px;
  color: #6b7280;
}

.aside-card li {
  margin-bottom: 6px;
}

.btn-back {
  margin-top: 16px;
}

.analyzing-card {
  text-align: center;
}

.analyzing-body {
  max-width: 520px;
  margin: 0 auto;
}

.analyzing-icon {
  width: 88px;
  height: 88px;
  margin: 0 auto 16px;
  border-radius: 50%;
  background: #eff6ff;
  display: flex;
  align-items: center;
  justify-content: center;
  font-size: 40px;
  border: 3px solid #bfdbfe;
}

.analyzing-body h1 {
  margin: 0 0 8px;
  font-size: 1.5rem;
}

.analyzing-hint {
  color: #6b7280;
  margin: 0 0 12px;
}

.analyzing-meta {
  font-size: 13px;
  color: #9ca3af;
  margin: 0 0 24px;
}

.analyzing-status {
  color: #2563eb;
  font-size: 14px;
  margin: 0;
}

.analyzing-steps {
  list-style: none;
  padding: 0;
  margin: 0 0 24px;
  text-align: left;
}

.analyzing-steps li {
  display: flex;
  align-items: center;
  gap: 10px;
  padding: 10px 0;
  border-bottom: 1px solid #f3f4f6;
  color: #9ca3af;
}

.analyzing-steps li.is-done {
  color: #059669;
}

.analyzing-steps li.is-active {
  color: #2563eb;
  font-weight: 600;
}

.analyzing-steps li span {
  width: 20px;
  text-align: center;
}

.analyzing-spin {
  animation: pulse 1.5s ease-in-out infinite;
}

@keyframes pulse {
  0%, 100% { transform: scale(1); opacity: 1; }
  50% { transform: scale(1.05); opacity: 0.85; }
}

.btn-sm {
  padding: 6px 12px;
  font-size: 13px;
}

.report-top {
  display: grid;
  grid-template-columns: 200px 1fr;
  gap: 20px;
  margin-bottom: 28px;
}

.score-card,
.recommend-card {
  border: 1px solid #e5e7eb;
  border-radius: 12px;
  padding: 20px;
  background: #f9fafb;
}

.score-card {
  text-align: center;
}

.score-ring {
  width: 120px;
  height: 120px;
  margin: 0 auto 8px;
  border-radius: 50%;
  background: conic-gradient(#2563eb calc(var(--score) * 1%), #e5e7eb 0);
  display: flex;
  align-items: center;
  justify-content: center;
  position: relative;
}

.score-ring.score-high {
  background: conic-gradient(#10b981 calc(var(--score) * 1%), #e5e7eb 0);
}

.score-ring.score-mid {
  background: conic-gradient(#f59e0b calc(var(--score) * 1%), #e5e7eb 0);
}

.score-ring.score-low {
  background: conic-gradient(#ef4444 calc(var(--score) * 1%), #e5e7eb 0);
}

.score-ring::before {
  content: "";
  position: absolute;
  inset: 10px;
  background: #f9fafb;
  border-radius: 50%;
}

.score-value {
  position: relative;
  font-size: 2rem;
  font-weight: 700;
  color: #2563eb;
}

.score-value.score-high { color: #059669; }
.score-value.score-mid { color: #d97706; }
.score-value.score-low { color: #dc2626; }

.score-value span {
  font-size: 0.9rem;
  color: #6b7280;
}

.score-label {
  margin: 0;
  color: #6b7280;
}

.recommend-badge {
  display: inline-block;
  padding: 6px 14px;
  border-radius: 999px;
  font-weight: 700;
  margin-bottom: 12px;
}

.recommend-apply .recommend-badge {
  background: #ecfdf5;
  color: #059669;
}

.recommend-watch .recommend-badge {
  background: #fffbeb;
  color: #d97706;
}

.recommend-skip .recommend-badge {
  background: #fef2f2;
  color: #dc2626;
}

.recommend-reason {
  margin: 0;
  line-height: 1.6;
  color: #374151;
}

.report-section {
  margin-bottom: 28px;
}

.report-section h2 {
  margin: 0 0 14px;
  font-size: 1.1rem;
}

.section-head {
  display: flex;
  justify-content: space-between;
  align-items: center;
  margin-bottom: 14px;
}

.section-head h2 {
  margin: 0;
}

.table-wrap {
  overflow-x: auto;
}

.report-table {
  width: 100%;
  border-collapse: collapse;
  font-size: 14px;
}

.report-table th,
.report-table td {
  border: 1px solid #e5e7eb;
  padding: 10px 12px;
  text-align: left;
}

.report-table th {
  background: #f9fafb;
}

.tag-list {
  display: flex;
  flex-wrap: wrap;
  gap: 8px;
}

.tag {
  display: inline-block;
  padding: 4px 10px;
  border-radius: 999px;
  font-size: 13px;
}

.tag-green {
  background: #ecfdf5;
  color: #059669;
}

.tag-red {
  background: #fef2f2;
  color: #dc2626;
}

.tag-blue {
  background: #eff6ff;
  color: #2563eb;
}

.tag-orange {
  background: #fff7ed;
  color: #ea580c;
}

.skill-group {
  margin-bottom: 14px;
}

.skill-group h3 {
  margin: 0 0 8px;
  font-size: 14px;
  color: #6b7280;
}

.muted {
  color: #9ca3af;
  font-size: 14px;
}

.edit-list {
  display: grid;
  gap: 14px;
}

.edit-item {
  border: 1px solid #e5e7eb;
  border-radius: 10px;
  padding: 14px;
  background: #fafafa;
}

.edit-head {
  display: flex;
  justify-content: space-between;
  align-items: center;
  margin-bottom: 10px;
}

.edit-label {
  margin: 0 0 4px;
  font-size: 12px;
  color: #6b7280;
}

.edit-text {
  margin: 0 0 10px;
  line-height: 1.6;
}

.edit-suggested {
  color: #1d4ed8;
}

.edit-reason {
  margin: 0;
  font-size: 13px;
  color: #6b7280;
}

.learning-list {
  display: grid;
  gap: 12px;
}

.learning-item {
  border: 1px solid #e5e7eb;
  border-radius: 10px;
  padding: 14px;
}

.learning-title {
  font-weight: 600;
  margin-bottom: 6px;
}

.learning-resources {
  margin: 6px 0 0;
  font-size: 13px;
  color: #6b7280;
}

.disclaimer {
  padding: 12px 14px;
  background: #f3f4f6;
  border-radius: 8px;
  font-size: 13px;
  color: #6b7280;
  margin-bottom: 16px;
}

.report-actions {
  display: flex;
  gap: 12px;
  flex-wrap: wrap;
  margin-bottom: 12px;
}

.report-meta {
  font-size: 12px;
  color: #9ca3af;
  margin: 0;
}

.app-toast {
  position: fixed;
  left: 50%;
  bottom: 32px;
  transform: translateX(-50%) translateY(12px);
  padding: 10px 18px;
  background: #1f2937;
  color: #fff;
  border-radius: 8px;
  font-size: 14px;
  opacity: 0;
  pointer-events: none;
  transition: opacity 0.2s, transform 0.2s;
  z-index: 1000;
}

.app-toast.is-visible {
  opacity: 1;
  transform: translateX(-50%) translateY(0);
}

@media (max-width: 768px) {
  .page-card {
    padding: 20px 16px;
  }

  .jd-layout {
    grid-template-columns: 1fr;
  }

  .jd-aside {
    order: -1;
  }

  .report-top {
    grid-template-columns: 1fr;
  }
}

2.6. common.js #

static/js/common.js

// 立即执行函数,形成作用域
(function () {
  // 显示提示信息(Toast)的方法
  function showToast(message) {
    // 获取id为app-toast的元素
    let toast = document.getElementById("app-toast");
    // 如果还没创建,则新建一个提示元素
    if (!toast) {
      // 创建div元素
      toast = document.createElement("div");
      // 设置id为app-toast
      toast.id = "app-toast";
      // 设置class为app-toast
      toast.className = "app-toast";
      // 添加到页面body
      document.body.appendChild(toast);
    }
    // 设置提示内容
    toast.textContent = message;
    // 添加显示的类
    toast.classList.add("is-visible");
    // 清除上一个定时器
    clearTimeout(toast._timer);
    // 2秒后隐藏提示信息
    toast._timer = setTimeout(() => toast.classList.remove("is-visible"), 2000);
  }

  // 复制文本到剪贴板的异步方法
  async function copyText(text, successMessage) {
    // 如果文本为空则返回false
    if (!text) return false;
    try {
      // 尝试写入剪贴板
      await navigator.clipboard.writeText(text);
      // 显示复制成功的提示
      showToast(successMessage);
      // 返回true表示成功
      return true;
    } catch (e) {
      // 复制失败显示错误提示
      showToast("复制失败,请手动选择文本复制");
      // 返回false表示失败
      return false;
    }
  }

  // 为所有表单添加提交时的处理
  document.querySelectorAll("form").forEach((form) => {
    // 绑定表单submit事件
    form.addEventListener("submit", () => {
      // 查找所有提交按钮
      form.querySelectorAll('button[type="submit"], input[type="submit"]').forEach((btn) => {
        // 如果按钮已禁用则跳过
        if (btn.disabled) return;
        // 禁用按钮,防止重复提交
        btn.disabled = true;
        // 获取加载中的提示文本
        const loadingText = btn.dataset.loadingText;
        // 如果存在加载文本,切换显示加载文本
        if (loadingText) {
          // 保存原始文本
          btn.dataset.originalText = btn.textContent;
          // 显示加载中的文本
          btn.textContent = loadingText;
        }
      });
    });
  });

  // 为所有带copy-edit类的按钮添加复制事件
  document.querySelectorAll(".copy-edit").forEach((btn) => {
    // 绑定点击事件
    btn.addEventListener("click", async () => {
      // 查找最近的edit-item内的edit-suggested内容,去除两端空白
      const text = btn.closest(".edit-item")?.querySelector(".edit-suggested")?.textContent.trim() || "";
      // 复制文本并显示提示
      await copyText(text, "已复制");
    });
  });

  // 给“全部复制”按钮绑定点击事件
  document.getElementById("copy-all-edits")?.addEventListener("click", async () => {
    // 收集所有edit-suggested内容,并去除空白
    const texts = [...document.querySelectorAll(".edit-suggested")].map((el) => el.textContent.trim());
    // 合并所有内容用两个换行分隔并复制
    await copyText(texts.join("\n\n"), "已全部复制");
  });

  // 将showToast函数挂到window对象,供其他地方使用
  window.appShowToast = showToast;
  // 将copyText函数挂到window对象,供其他地方使用
  window.appCopyText = copyText;
})();

2.7. base.html #

templates/base.html

<!DOCTYPE html>
<html lang="zh-CN">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>{% block title %}职策 AI{% endblock %}</title>
  <link rel="stylesheet" href="{{ url_for('static', filename='css/style.css') }}">
  {% block head %}{% endblock %}
</head>
<body>
  <header class="site-header">
    <div class="container header-inner">
      <a href="{{ url_for('resume.upload') }}" class="logo">
        <span class="logo-icon">◎</span> 职策 AI
      </a>
      <nav class="header-nav">
        <span class="header-tagline">简历 × JD 匹配分析</span>
      </nav>
    </div>
  </header>

  <main class="container main-content">
    {% include "partials/flash.html" %}
    {% block content %}{% endblock %}
  </main>

  <script src="{{ url_for('static', filename='js/common.js') }}"></script>
  {% block scripts %}{% endblock %}
</body>
</html>

2.8. flash.html #

templates/partials/flash.html

{% with messages = get_flashed_messages(with_categories=true) %}
  {% if messages %}
    <div class="flash-list">
      {% for category, message in messages %}
        <div class="flash flash-{{ category }}">{{ message }}</div>
      {% endfor %}
    </div>
  {% endif %}
{% endwith %}

2.9. steps.html #

templates/partials/steps.html

<nav class="steps" aria-label="流程步骤">
  <div class="step {% if current_step == 1 %}is-active{% elif current_step > 1 %}is-done{% endif %}">
    <span class="step-num">{% if current_step > 1 %}✓{% else %}1{% endif %}</span>
    <span class="step-label">上传简历</span>
  </div>
  <div class="step-line {% if current_step > 1 %}is-done{% endif %}"></div>
  <div class="step {% if current_step == 2 %}is-active{% elif current_step > 2 %}is-done{% endif %}">
    <span class="step-num">{% if current_step > 2 %}✓{% else %}2{% endif %}</span>
    <span class="step-label">输入 JD</span>
  </div>
  <div class="step-line {% if current_step > 2 %}is-done{% endif %}"></div>
  <div class="step {% if current_step == 3 %}is-active{% elif current_step > 3 %}is-done{% endif %}">
    <span class="step-num">{% if current_step > 3 %}✓{% else %}3{% endif %}</span>
    <span class="step-label">AI 分析</span>
  </div>
  <div class="step-line {% if current_step > 3 %}is-done{% endif %}"></div>
  <div class="step {% if current_step == 4 %}is-active{% elif current_step > 4 %}is-done{% endif %}">
    <span class="step-num">{% if current_step > 4 %}✓{% else %}4{% endif %}</span>
    <span class="step-label">查看报告</span>
  </div>
</nav>

2.10. upload.html #

templates/upload.html

{% extends "base.html" %}

{% block title %}上传简历 — 职策 AI{% endblock %}

{% block content %}
<div class="page-card">
  {% include "partials/steps.html" %}

  <div class="page-head">
    <h1>上传你的简历</h1>
    <p>支持 PDF、DOCX 格式,文件大小不超过 10 MB</p>
  </div>

  <form class="upload-form" method="post" enctype="multipart/form-data" action="{{ url_for('resume.upload') }}">
    <label class="upload-zone" id="upload-zone" for="resume-input">
      <div class="upload-icon">☁</div>
      <p class="upload-title">点击选择文件或拖拽到此处上传</p>
      <p class="upload-hint">支持 PDF、DOCX 格式</p>
      <span class="btn btn-outline">选择文件</span>
      <input id="resume-input" type="file" name="resume" accept=".pdf,.docx,application/pdf,application/vnd.openxmlformats-officedocument.wordprocessingml.document" hidden>
    </label>
    <p class="selected-file" id="selected-file-name"></p>
    <button type="submit" class="btn btn-primary btn-block" data-loading-text="上传中...">上传简历</button>
  </form>

  {% if resume %}
  <div class="uploaded-file">
    <div class="file-icon">📄</div>
    <div class="file-info">
      <div class="file-name">{{ resume.filename }}</div>
      <div class="file-meta">已解析 {{ (resume.text_content or '')|length }} 字</div>
    </div>
    <span class="badge badge-success">上传成功</span>
  </div>

  <a href="" class="btn btn-primary btn-block btn-next">
    下一步:输入 JD →
  </a>
  {% endif %}
</div>
{% endblock %}

{% block scripts %}
<script>
  // 获取文件输入框元素
  const input = document.getElementById("resume-input");
  // 获取拖拽区域元素
  const zone = document.getElementById("upload-zone");
  // 获取显示已选文件名称的标签
  const label = document.getElementById("selected-file-name");

  // 设置已选中的文件信息
  function setSelectedFile(file) {
    // 如果没有文件则直接返回
    if (!file) return;
    // 获取文件的扩展名并转为小写
    const ext = file.name.slice(file.name.lastIndexOf(".")).toLowerCase();
    // 如果文件扩展名不是.pdf或.docx则提示并返回
    if (![".pdf", ".docx"].includes(ext)) {
      window.appShowToast?.("仅支持 PDF、DOCX 格式");
      return;
    }
    // 显示已选择的文件名
    label.textContent = "已选择:" + file.name;
  }

  // 监听文件选择变化事件
  input?.addEventListener("change", () => {
    // 如果有文件被选中,则调用setSelectedFile设置
    if (input.files?.[0]) setSelectedFile(input.files[0]);
  });

  // 监听拖拽到拖拽区域的事件
  zone?.addEventListener("dragover", (e) => {
    // 阻止默认行为(防止浏览器打开文件)
    e.preventDefault();
    // 给拖拽区域加上高亮样式
    zone.classList.add("is-dragover");
  });

  // 监听拖拽离开拖拽区域的事件
  zone?.addEventListener("dragleave", () => {
    // 移除高亮样式
    zone.classList.remove("is-dragover");
  });

  // 监听文件被拖拽放下到拖拽区域的事件
  zone?.addEventListener("drop", (e) => {
    // 阻止默认行为
    e.preventDefault();
    // 移除拖拽高亮
    zone.classList.remove("is-dragover");
    // 获取拖拽的文件
    const file = e.dataTransfer?.files?.[0];
    // 如果没有文件或input为空则返回
    if (!file || !input) return;
    // 创建DataTransfer对象,用于设置input的files
    const dt = new DataTransfer();
    // 将文件添加到DataTransfer中
    dt.items.add(file);
    // 设置input的文件为拖拽选择的文件
    input.files = dt.files;
    // 设置已选择文件的显示
    setSelectedFile(file);
  });
</script>
{% endblock %}

2.11. init.py #

app/routes/init.py

# 从app.routes.health模块导入health_bp蓝图
from app.routes.health import health_bp
+from app.routes.resume import resume_bp
# 定义注册蓝图的函数,参数为Flask应用实例app
def register_blueprints(app):
    # 将health_bp蓝图注册到Flask应用中
    app.register_blueprint(health_bp)
+   app.register_blueprint(resume_bp)

3. 职位描述(JD)输入与链接解析 #

本节实现流程第二步「输入 JD」:用户在 /jd 页面粘贴职位描述全文,或提交智联等招聘链接,由 DrissionPage 驱动 Chrome 抓取页面 DOM 并提取文本。校验通过后,将 JD 内容写入 Session,为下一步 AI 分析做准备。

涉及模块如下:

层级 职责 关键文件
路由 双 Tab 表单、Session 读写、跳转控制 app/routes/jd.py
服务 文本校验、URL 标准化、浏览器抓取 app/services/jd_parser.py
辅助 清除 JD 相关 Session app/routes/helpers.py
配置 JD 最大字数上限 app/config.py(MAX_JD_LENGTH)
模板 粘贴文本 / 粘贴链接双模式页面 templates/jd.html

JD 输入的完整时序如下:

sequenceDiagram autonumber actor User as 用户 participant Page as jd.html participant Route as jd_bp /jd participant Helper as get_current_resume() participant Parser as jd_parser participant Chrome as DrissionPage / Chrome participant Session as Flask Session User->>Route: GET /jd Route->>Helper: 检查 session["resume_id"] alt 未上传简历 Route-->>User: flash 错误 → redirect /upload else 已有简历 Route->>Session: 读取 jd_text / jd_url Route->>Page: render_template(current_step=2) Page-->>User: 展示「粘贴文本」或「粘贴链接」Tab end alt 粘贴链接(action=parse_url) User->>Page: 提交招聘链接 Page->>Route: POST jd_url Route->>Session: session["jd_url"] = url Route->>Parser: parse_jd_url(url) Parser->>Parser: normalize_url() Parser->>Chrome: 打开页面并等待加载 Chrome->>Parser: DOM 选择器提取 JD 文本 alt 解析成功 Parser-->>Route: 返回 jd_text Route->>Session: session["jd_text"] = jd_text Route-->>User: flash 成功 → redirect tab=text else 解析失败 Route-->>User: flash 错误 → redirect tab=link end else 开始分析(action=analyze) User->>Page: 提交 JD 文本 Page->>Route: POST jd_text Route->>Parser: validate_jd_text() 校验长度 Parser-->>Route: 返回规范化文本 Route->>Session: session["jd_text"] = jd_text Route-->>User: 进入分析流程(后续章节) end

按以下小节顺序逐步创建各文件即可。

3.1. jd.py #

app/routes/jd.py

# 引入Flask框架相关模块
from flask import Blueprint, flash, redirect, render_template, request, session, url_for

# 引入自定义配置、工具函数和JD解析服务
from app.config import Config
from app.routes.helpers import clear_jd_session, get_current_resume
from app.services.jd_parser import parse_jd_url, validate_jd_text

# 创建一个名为"jd"的蓝图,用于JD相关的路由
jd_bp = Blueprint("jd", __name__)

# 定义/jd路由,支持GET和POST请求
@jd_bp.route("/jd", methods=["GET", "POST"])
def input():
    # 获取当前已上传的简历
    resume = get_current_resume()
    # 如果没有简历,则提示用户先上传简历,并重定向到上传简历页面
    if not resume:
        flash("请先上传简历", "error")
        return redirect(url_for("resume.upload"))

    # 处理GET请求逻辑
    if request.method == "GET":
        # 如果请求参数fresh为"1",则清空JD的会话信息并刷新页面
        if request.args.get("fresh") == "1":
            clear_jd_session()
            return redirect(url_for("jd.input"))
        # 获取前端传来的tab参数,默认为"text"
        active_tab = request.args.get("tab", "text")
        # 判断tab参数是否合法,不合法则默认"text"
        if active_tab not in ("text", "link"):
            active_tab = "text"
        # 从session读取已保存的jd_text和jd_url,若没有则为空字符串
        jd_text = session.get("jd_text", "")
        jd_url = session.get("jd_url", "")
    else:
        # 对于非GET请求(即POST),active_tab默认“text”,同时读取session中的jd_text和jd_url
        active_tab = "text"
        jd_text = session.get("jd_text", "")
        jd_url = session.get("jd_url", "")

    # 处理POST请求逻辑
    if request.method == "POST":
        # 获取当前动作类型,默认为“analyze”
        action = request.form.get("action", "analyze")

        # 如果动作为解析JD链接
        if action == "parse_url":
            # 获取用户输入的JD链接并去除首尾空格并存入session
            jd_url = request.form.get("jd_url", "").strip()
            session["jd_url"] = jd_url
            try:
                # 调用业务方法解析链接内容
                jd_text = parse_jd_url(jd_url)
                # 解析成功后将内容存入session
                session["jd_text"] = jd_text
                # 显示成功提示
                flash("链接解析成功,已填入 JD 文本", "success")
                # 重定向回/jd页面并切换到text标签
                return redirect(url_for("jd.input", tab="text"))
            except ValueError as exc:
                # 解析异常时提示错误,并跳到link标签
                flash(str(exc), "error")
                return redirect(url_for("jd.input", tab="link"))

        try:
            # 判断form表单是否传递了jd_text字段
            if "jd_text" in request.form:
                source_text = request.form.get("jd_text", "")
            else:
                # 否则从session提取JD文本
                source_text = session.get("jd_text", "")
            # 校验并规范化JD文本内容
            jd_text = validate_jd_text(source_text)
            # 校验成功后将JD文本存入session
            session["jd_text"] = jd_text
            # 返回简单文本表示正在分析(实际生产可能redirect到另一个页面)
            #return redirect(url_for("analyze.waiting"))
            return  "分析中..."
        except ValueError as exc:
            # 校验失败则提示错误
            flash(str(exc), "error")
            # 获取表单或者session中已有的JD文本
            jd_text = request.form.get("jd_text", session.get("jd_text", ""))
            # 如果form提交了jd_text则为text标签,否则为link标签
            active_tab = "text" if "jd_text" in request.form else "link"

    # 渲染jd.html模板,并传递相关上下文参数
    return render_template(
        "jd.html",
        resume=resume,
        current_step=2,
        jd_text=jd_text,
        jd_url=jd_url,
        active_tab=active_tab,
        max_jd_length=Config.MAX_JD_LENGTH,
    )

3.2. jd_parser.py #

app/services/jd_parser.py


# 导入日志模块
import logging
# 导入操作系统相关模块
import os
# 从DrissionPage库导入ChromiumPage和ChromiumOptions
from DrissionPage import ChromiumPage, ChromiumOptions
# 从app.config模块导入Config配置
from app.config import Config
# 获取logger对象,用于写日志
logger = logging.getLogger(__name__)
# 定义一组DOM选择器,用于查找JD内容
DOM_SELECTORS = (
    ".describtion__detail-content",
    ".summary-plane",
    ".job-detail-content",
    "css:div[class*='describtion']",
    "css:div[class*='job-detail']",
)
# 判断是否使用无头浏览器
def _browser_headless() -> bool:
    # 从环境变量获取JD_BROWSER_HEADLESS,默认为"0",等于"1"则为无头模式
    return os.getenv("JD_BROWSER_HEADLESS", "0") == "1"
# 校验并清洗JD文本内容
def validate_jd_text(jd_text: str) -> str:
    # 去除首尾空格
    text = jd_text.strip()
    # 如果长度小于20,抛出异常
    if len(text) < 20:
        raise ValueError("JD 内容过短,请粘贴完整的职位描述")
    # 如果长度超过配置上限,也抛出异常
    if len(text) > Config.MAX_JD_LENGTH:
        raise ValueError(f"JD 内容不能超过 {Config.MAX_JD_LENGTH} 字")
    # 返回合法文本
    return text

# 对输入URL进行标准化处理
def normalize_url(url: str) -> str:
    # 去除首尾空格
    url = url.strip()
    # 如果不是http开头,补充https://
    if not url.startswith("http"):
        url = "https://" + url.lstrip("/")
    # 返回标准化后的url
    return url
# 创建Chromium浏览器页面对象
def _create_browser_page() -> ChromiumPage:
    # 初始化Chromium参数配置对象
    options = ChromiumOptions()
    # 如果无头模式,设置无头
    if _browser_headless():
        options.headless()
    # 关闭自动化检测功能
    options.set_argument("--disable-blink-features=AutomationControlled")
    # 关闭首次运行弹窗
    options.set_argument("--no-first-run")
    # 不询问是否设为默认浏览器
    options.set_argument("--no-default-browser-check")

    # 从环境变量获取用户数据目录
    user_data = os.getenv("JD_BROWSER_USER_DATA", "").strip()
    # 获取浏览器Profile
    profile = os.getenv("JD_BROWSER_PROFILE", "Default").strip()
    # 如果设置了用户数据
    if user_data:
        # 指定用户数据路径
        options.set_user_data_path(user_data)
        # 指定Profile目录
        options.set_argument(f"--profile-directory={profile}")

    # 返回配置好的浏览器页面对象
    return ChromiumPage(options)  
# 从页面DOM中提取JD文本
def _extract_from_dom(page: ChromiumPage) -> str | None:
    # 遍历所有DOM选择器
    for selector in DOM_SELECTORS:
        try:
            # 查找元素,超时时间3秒
            element = page.ele(selector, timeout=3)
            # 如果没有找到,继续下一个选择器
            if not element:
                continue
            # 获取元素文本内容并去除首尾空白
            text = (element.text or "").strip()
            # 内容长度大于等于30字认为抓到了有效JD
            if len(text) >= 30:
                return text
        # 捕捉所有异常,忽略并尝试下一个
        except Exception:
            continue
    # 没找到则返回None
    return None      
# 抓取并返回页面的JD文本
def _fetch_page(url: str) -> tuple[str, str | None]:
    # 创建浏览器页面对象
    page = _create_browser_page()
    try:
        # 加载指定url
        page.get(url)
        # 等待页面文档加载完成,最长20秒
        page.wait.doc_loaded(timeout=20)
        # 再等待2秒,确保资源加载
        page.wait(2)
        # 从DOM中提取内容
        dom_text = _extract_from_dom(page)
        # 返回提取到的文本
        return dom_text
    # 无论如何都需要关闭浏览器实例
    finally:
        try:
            # 关闭页面
            page.quit()
        except Exception:
            # 关闭失败打印警告日志
            logger.warning("关闭浏览器实例失败", exc_info=True)

# 从JD链接解析JD文本
def parse_jd_url(url: str) -> str:
    # 对url标准化
    normalized = normalize_url(url)
    try:
        # 通过浏览器拉取并解析内容
        dom_text = _fetch_page(normalized)
    # 抓取过程出现异常则记录警告日志并抛自定义异常
    except Exception as exc:
        logger.warning("DrissionPage 抓取失败: %s", exc)
        raise ValueError(
            "链接访问失败(需本机安装 Chrome),请直接粘贴 JD 文本"
        ) from exc
    # 如果未能提取内容,也抛出异常
    if not dom_text:
        raise ValueError("链接解析失败,请直接粘贴 JD 文本")

    # 返回最终提取的JD文本
    return dom_text

3.3. jd.html #

templates/jd.html

{% extends "base.html" %}

{% block title %}输入 JD — 职策 AI{% endblock %}

{% block content %}
<div class="page-card page-card-wide">
  {% include "partials/steps.html" %}

  <div class="page-head">
    <h1>输入职位描述(JD)</h1>
    <p>支持粘贴文本或招聘链接,当前简历:{{ resume.filename }}</p>
  </div>

  <div class="jd-tabs">
    <a href="{{ url_for('jd.input', tab='text') }}"
       class="jd-tab {% if active_tab == 'text' %}is-active{% endif %}">粘贴文本</a>
    <a href="{{ url_for('jd.input', tab='link') }}"
       class="jd-tab {% if active_tab == 'link' %}is-active{% endif %}">粘贴链接</a>
  </div>

  <div class="jd-layout">
    <div class="jd-main">
      {% if active_tab == 'text' %}
      <form method="post" action="{{ url_for('jd.input') }}">
        <input type="hidden" name="action" value="analyze">
        <label class="field-label" for="jd-text">JD 全文</label>
        <textarea id="jd-text" name="jd_text" class="jd-textarea" maxlength="{{ max_jd_length }}"
                  placeholder="请粘贴职位描述(JD)全文...">{{ jd_text }}</textarea>
        <div class="jd-counter">
          <span id="jd-char-count">{{ jd_text|length }}</span> / {{ max_jd_length }}
        </div>
        <button type="submit" class="btn btn-primary btn-block btn-next" data-loading-text="准备分析...">🚀 开始分析</button>
      </form>
      {% else %}
      <form method="post" action="{{ url_for('jd.input') }}">
        <input type="hidden" name="action" value="parse_url">
        <label class="field-label" for="jd-url">智联招聘职位链接</label>
        <input id="jd-url" type="url" name="jd_url" class="jd-url-input"
               value="{{ jd_url }}"
               placeholder="https://www.zhaopin.com/jobdetail/xxx.htm">
        <p class="field-hint">示例:https://www.zhaopin.com/jobdetail/CCL1525384510J40948598308.htm</p>
        <p class="field-hint">使用 DrissionPage 打开 Chrome 解析,首次可能需 10–20 秒;若触发安全验证请改粘贴文本。</p>
        <button type="submit" class="btn btn-primary btn-block" data-loading-text="解析中,请稍候...">解析链接</button>
      </form>

      {% if jd_text %}
      <div class="parsed-preview">
        <div class="parsed-preview-head">
          <strong>解析结果预览</strong>
          <a href="{{ url_for('jd.input', tab='text') }}">编辑文本 →</a>
        </div>
        <pre>{{ jd_text[:500] }}{% if jd_text|length > 500 %}...{% endif %}</pre>
        <form method="post" action="{{ url_for('jd.input') }}">
          <input type="hidden" name="action" value="analyze">
          <button type="submit" class="btn btn-primary btn-block btn-next" data-loading-text="准备分析...">🚀 开始分析</button>
        </form>
      </div>
      {% endif %}
      {% endif %}

      <a href="{{ url_for('resume.upload') }}" class="btn btn-outline btn-back">← 返回上传简历</a>
    </div>

    <aside class="jd-aside">
      <div class="aside-card">
        <h3>粘贴链接说明</h3>
        <p>在招聘网站打开职位详情页,复制浏览器地址栏链接粘贴到左侧。</p>
        <p>若解析失败,请切换到「粘贴文本」手动复制 JD 内容。</p>
        <ul>
          <li>使用 DrissionPage + Chrome 解析</li>
          <li>解析约 10–20 秒,可能触发安全验证</li>
        </ul>
      </div>
    </aside>
  </div>
</div>
{% endblock %}

{% block scripts %}
<script>
  // 获取 id 为 "jd-text" 的 textarea 元素
  const textarea = document.getElementById("jd-text");
  // 获取 id 为 "jd-char-count" 的字符计数显示元素
  const counter = document.getElementById("jd-char-count");
  // 如果 textarea 和 counter 都存在
  if (textarea && counter) {
    // 定义更新计数的函数,将 textarea 的字符长度赋值给 counter 的文本
    const updateCount = () => { counter.textContent = textarea.value.length; };
    // 监听 textarea 的输入事件,内容变动时调用 updateCount 更新计数
    textarea.addEventListener("input", updateCount);
    // 页面加载时立即执行一次计数更新
    updateCount();
  }
</script>
{% endblock %}

3.4. config.py #

app/config.py

# 导入操作系统相关模块
import os
# 导入Path对象,用于处理文件和目录路径
from pathlib import Path
# 导入load_dotenv函数,用于加载.env环境变量文件
from dotenv import load_dotenv

# 获取当前文件的上上级目录作为项目的根目录
BASE_DIR = Path(__file__).resolve().parent.parent
# 从根目录下加载.env文件,override=True表示覆盖已存在的环境变量
load_dotenv(BASE_DIR / ".env", override=True)

# 定义配置类
class Config:
    # 项目根目录路径
    BASE_DIR = BASE_DIR
    # 模板文件夹路径
    TEMPLATE_DIR = BASE_DIR / "templates"
    # 静态文件夹路径
    STATIC_DIR = BASE_DIR / "static"
    # 从环境变量获取SECRET_KEY, 若无则使用默认值"dev-secret-key"
    SECRET_KEY = os.getenv("SECRET_KEY", "dev-secret-key")
    # 从环境变量获取数据库连接地址,若无则用默认的MySQL连接字符串
    SQLALCHEMY_DATABASE_URI = os.getenv(
        "DATABASE_URL",
        "mysql+pymysql://root:password@127.0.0.1:3306/job2",
    )
    # 是否追踪数据库修改,这里默认关闭
    SQLALCHEMY_TRACK_MODIFICATIONS = False
    # 从环境变量获取FLASK_DEBUG,字符串为"1"时为True,否则为False
    FLASK_DEBUG = os.getenv("FLASK_DEBUG", "1") == "1"
    # 从环境变量获取Flask服务的主机地址,默认为"127.0.0.1"
    FLASK_HOST = os.getenv("FLASK_HOST", "127.0.0.1")
    # 从环境变量获取Flask服务的端口号,默认为5000,并转换为int类型
    FLASK_PORT = int(os.getenv("FLASK_PORT", "5000"))
    # 从环境变量获取最大JD长度,默认为10000
+   MAX_JD_LENGTH = int(os.getenv("MAX_JD_LENGTH", "10000"))

3.5. init.py #

app/routes/init.py

# 从app.routes.health模块导入health_bp蓝图
from app.routes.health import health_bp
# 从app.routes.resume模块导入resume_bp蓝图
from app.routes.resume import resume_bp
# 从app.routes.jd模块导入jd_bp蓝图
+from app.routes.jd import jd_bp
# 定义注册蓝图的函数,参数为Flask应用实例app
def register_blueprints(app):
    # 将health_bp蓝图注册到Flask应用中
    app.register_blueprint(health_bp)
    # 将resume_bp蓝图注册到Flask应用中
+   app.register_blueprint(resume_bp)
    # 将jd_bp蓝图注册到Flask应用中
+   app.register_blueprint(jd_bp)

3.6. helpers.py #

app/routes/helpers.py

# 从flask导入session对象,用于会话管理
from flask import session
# 导入数据库db实例
from app.extensions import db
# 导入Resume模型
from app.models import Resume

# 定义获取当前简历的方法
def get_current_resume():
    # 从session中获取"resume_id"
    resume_id = session.get("resume_id")
    # 如果没有"resume_id",返回None
    if not resume_id:
        return None
    # 通过resume_id从数据库中获取Resume对象
    resume = db.session.get(Resume, resume_id)
    # 如果没有获取到简历对象,则删除session中的"resume_id"
    if not resume:
        session.pop("resume_id", None)
    # 返回获取到的简历对象
    return resume

# 定义一个函数用于清除与JD相关的session信息
+def clear_jd_session() -> None:
    # 移除session中的"jd_text"项,如果不存在则不报错
+   session.pop("jd_text", None)
    # 移除session中的"jd_url"项,如果不存在则不报错
+   session.pop("jd_url", None)

3.7. upload.html #

templates/upload.html

{% extends "base.html" %}

{% block title %}上传简历 — 职策 AI{% endblock %}

{% block content %}
<div class="page-card">
  {% include "partials/steps.html" %}

  <div class="page-head">
    <h1>上传你的简历</h1>
    <p>支持 PDF、DOCX 格式,文件大小不超过 10 MB</p>
  </div>

  <form class="upload-form" method="post" enctype="multipart/form-data" action="{{ url_for('resume.upload') }}">
    <label class="upload-zone" id="upload-zone" for="resume-input">
      <div class="upload-icon">☁</div>
      <p class="upload-title">点击选择文件或拖拽到此处上传</p>
      <p class="upload-hint">支持 PDF、DOCX 格式</p>
      <span class="btn btn-outline">选择文件</span>
      <input id="resume-input" type="file" name="resume" accept=".pdf,.docx,application/pdf,application/vnd.openxmlformats-officedocument.wordprocessingml.document" hidden>
    </label>
    <p class="selected-file" id="selected-file-name"></p>
    <button type="submit" class="btn btn-primary btn-block" data-loading-text="上传中...">上传简历</button>
  </form>

  {% if resume %}
  <div class="uploaded-file">
    <div class="file-icon">📄</div>
    <div class="file-info">
      <div class="file-name">{{ resume.filename }}</div>
      <div class="file-meta">已解析 {{ (resume.text_content or '')|length }} 字</div>
    </div>
    <span class="badge badge-success">上传成功</span>
  </div>

+ <a href="{{ url_for('jd.input') }}" class="btn btn-primary btn-block btn-next">
    下一步:输入 JD →
  </a>
  {% endif %}
</div>
{% endblock %}

{% block scripts %}
<script>
  // 获取文件输入框元素
  const input = document.getElementById("resume-input");
  // 获取拖拽区域元素
  const zone = document.getElementById("upload-zone");
  // 获取显示已选文件名称的标签
  const label = document.getElementById("selected-file-name");

  // 设置已选中的文件信息
  function setSelectedFile(file) {
    // 如果没有文件则直接返回
    if (!file) return;
    // 获取文件的扩展名并转为小写
    const ext = file.name.slice(file.name.lastIndexOf(".")).toLowerCase();
    // 如果文件扩展名不是.pdf或.docx则提示并返回
    if (![".pdf", ".docx"].includes(ext)) {
      window.appShowToast?.("仅支持 PDF、DOCX 格式");
      return;
    }
    // 显示已选择的文件名
    label.textContent = "已选择:" + file.name;
  }

  // 监听文件选择变化事件
  input?.addEventListener("change", () => {
    // 如果有文件被选中,则调用setSelectedFile设置
    if (input.files?.[0]) setSelectedFile(input.files[0]);
  });

  // 监听拖拽到拖拽区域的事件
  zone?.addEventListener("dragover", (e) => {
    // 阻止默认行为(防止浏览器打开文件)
    e.preventDefault();
    // 给拖拽区域加上高亮样式
    zone.classList.add("is-dragover");
  });

  // 监听拖拽离开拖拽区域的事件
  zone?.addEventListener("dragleave", () => {
    // 移除高亮样式
    zone.classList.remove("is-dragover");
  });

  // 监听文件被拖拽放下到拖拽区域的事件
  zone?.addEventListener("drop", (e) => {
    // 阻止默认行为
    e.preventDefault();
    // 移除拖拽高亮
    zone.classList.remove("is-dragover");
    // 获取拖拽的文件
    const file = e.dataTransfer?.files?.[0];
    // 如果没有文件或input为空则返回
    if (!file || !input) return;
    // 创建DataTransfer对象,用于设置input的files
    const dt = new DataTransfer();
    // 将文件添加到DataTransfer中
    dt.items.add(file);
    // 设置input的文件为拖拽选择的文件
    input.files = dt.files;
    // 设置已选择文件的显示
    setSelectedFile(file);
  });
</script>
{% endblock %}

4. 匹配分析 #

本节实现流程第三步「AI 分析」:用户在 JD 页点击「开始分析」后跳转至 /analyze,页面自动提交分析请求;后端调用 DeepSeek API(通过 OpenAI SDK),将简历与 JD 做匹配分析,校验 JSON 结果后写入 Analysis 表,为下一步查看报告做准备。

涉及模块如下:

层级 职责 关键文件
路由 分析等待页、触发分析、结果入库 app/routes/analyze.py
服务 Prompt 构建、API 调用、JSON 校验与重试 app/services/ai_analyzer.py
模型 持久化分析结果 app/models/analysis.py(第 1 节已建)
配置 DeepSeek API Key、模型、文本长度上限 .env、app/config.py
模板 分析进度页(自动提交表单) templates/analyzing.html

AI 分析的完整时序如下:

sequenceDiagram autonumber actor User as 用户 participant JD as jd_bp /jd participant Page as analyzing.html participant Route as analyze_bp participant AI as ai_analyzer participant API as DeepSeek API participant DB as SQLAlchemy User->>JD: POST 开始分析 JD->>JD: validate_jd_text() JD-->>User: redirect → GET /analyze User->>Route: GET /analyze Route->>Route: 校验 resume + jd_text Route->>Page: render_template(current_step=3) Page->>Route: 自动 POST /analyze/run Route->>AI: analyze_resume_jd(resume, jd) AI->>AI: 截断超长文本,构建 Prompt AI->>API: chat.completions.create(json_object) API-->>AI: 返回 JSON 分析结果 AI->>AI: 解析并 _validate_result() alt 格式/校验失败 AI->>API: 带 retry_hint 重试(最多 2 次) end AI-->>Route: 返回 result dict Route->>DB: add(Analysis) + commit() alt 成功 Route-->>User: 跳转报告页(后续章节) else API/保存失败 Route-->>User: flash 错误 → redirect /analyze end

按以下小节顺序逐步创建各文件即可。

4.1. analyze.py #

app/routes/analyze.py

# 导入Blueprint、flash、redirect、render_template、session、url_for,用于路由、消息、跳转、渲染模板和会话管理
from flask import Blueprint, flash, redirect, render_template, session, url_for

# 导入数据库扩展db
from app.extensions import db
# 导入Analysis模型类
from app.models import Analysis
# 导入获取当前简历的辅助函数
from app.routes.helpers import get_current_resume
# 导入用于简历与JD分析的AI服务
from app.services.ai_analyzer import analyze_resume_jd

# 创建名为"analyze"的蓝图
analyze_bp = Blueprint("analyze", __name__)


# 定义GET请求路由/analyze
@analyze_bp.get("/analyze")
def waiting():
    # 获取当前会话下的简历
    resume = get_current_resume()
    # 如果简历未上传或不存在,提示并跳转到简历上传页面
    if not resume:
        flash("请先上传简历", "error")
        return redirect(url_for("resume.upload"))

    # 获取已输入的JD文本并去除首尾空格
    jd_text = session.get("jd_text", "").strip()
    # 如果JD文本为空,提示并跳转到JD输入页面
    if not jd_text:
        flash("请先输入 JD", "error")
        return redirect(url_for("jd.input"))

    # 渲染分析中的页面
    return render_template(
        "analyzing.html",
        resume=resume,
        jd_preview=jd_text[:120] + ("..." if len(jd_text) > 120 else ""),
        current_step=3,
    )


# 定义POST请求路由/analyze/run
@analyze_bp.post("/analyze/run")
def run():
    # 获取当前会话下的简历
    resume = get_current_resume()
    # 如果简历未上传或不存在,提示并跳转到简历上传页面
    if not resume:
        flash("请先上传简历", "error")
        return redirect(url_for("resume.upload"))

    # 获取已输入的JD文本并去除首尾空格
    jd_text = session.get("jd_text", "").strip()
    # 如果JD文本为空,提示并跳转到JD输入页面
    if not jd_text:
        flash("请先输入 JD", "error")
        return redirect(url_for("jd.input"))

    # 若简历文本内容为空,提示并跳转到简历上传页面
    if not resume.text_content:
        flash("简历文本为空,请重新上传", "error")
        return redirect(url_for("resume.upload"))

    # 尝试调用AI分析服务获取结果
    try:
        result = analyze_resume_jd(resume.text_content, jd_text)
    # 捕获分析服务抛出的ValueError异常,显示异常消息,并重定向回等待页面
    except ValueError as exc:
        flash(str(exc), "error")
        return redirect(url_for("analyze.waiting"))
    # 捕获其它异常,提示分析失败,并重定向回等待页面
    except Exception:
        flash("分析失败,请稍后重试", "error")
        return redirect(url_for("analyze.waiting"))

    # 构建Analysis对象保存本次分析的各项数据
    analysis = Analysis(
        resume_id=resume.id,
        jd_text=jd_text,
        result=result,
        score=result.get("score"),
        recommend_apply=result.get("recommend_apply"),
    )
    # 把Analysis对象加入数据库会话
    db.session.add(analysis)
    # 尝试提交数据库操作
    try:
        db.session.commit()
    # 如果提交失败,回滚并提示,重定向回等待页面
    except Exception:
        db.session.rollback()
        flash("分析结果保存失败,请稍后重试", "error")
        return redirect(url_for("analyze.waiting"))

    # # 跳转到分析结果页面(原注释掉的代码)
    # return redirect(url_for("report.show", analysis_id=analysis.id))
    # 临时直接返回文本“查看分析结果...”
    return "查看分析结果..."

4.2. ai_analyzer.py #

app/services/ai_analyzer.py

# 导入 json 模块用于处理 JSON 数据
import json
# 导入 logging 模块用于日志记录
import logging
# 导入 re 模块用于正则表达式处理
import re

# 导入 OpenAI SDK,用于调用 DeepSeek 兼容接口
from openai import APIConnectionError, APIStatusError, AuthenticationError, OpenAI
# 从 Flask 中导入 current_app 获取当前应用配置
from flask import current_app

# 创建名为 logger 的日志记录器,名称为当前模块名
logger = logging.getLogger(__name__)

# 定义有效的推荐动作集合
VALID_RECOMMEND = {"apply", "watch", "skip"}


# 定义用于文本截断的私有函数
def _truncate(text: str, max_chars: int, label: str) -> str:
    # 如果文本长度小于等于最大字符数则返回原文本
    if len(text) <= max_chars:
        return text
    # 记录文本超长被截断的日志
    logger.info("%s 过长(%d 字),截断至 %d 字", label, len(text), max_chars)
    # 截断文本并添加截断提示
    return text[:max_chars] + "\n\n[内容已截断]"


# 构建 system prompt 的函数
def _build_system_prompt() -> str:
    # 返回用于 AI 的 system prompt
    return (
        "你是资深技术招聘专家。请根据简历与 JD 做匹配分析。"
        "必须严格输出 JSON,不要输出 JSON 以外的任何内容。"
        "score 为 0-100 整数;recommend_apply 只能是 apply、watch、skip 之一;"
        "resume_edits 至少 3 条,每条包含 section、original、suggested、reason。"
    )


# 构建 user prompt 的函数
def _build_user_prompt(resume_text: str, jd_text: str, *, retry_hint: bool = False) -> str:
    # 构造用于给 AI 的 JSON schema 示例
    schema = {
        "score": 82,
        "recommend_apply": "apply",
        "recommend_reason": "一句话决策理由",
        "hard_requirements": {
            "experience": {"required": "3-5年", "match": True, "note": ""},
            "education": {"required": "本科", "match": True, "note": ""},
            "city": {"required": "北京", "match": True, "note": ""},
            "salary": {"required": "25-40K", "match": None, "note": ""},
        },
        "skills": {
            "matched": ["Python"],
            "missing": ["Go"],
            "bonus": ["Redis"],
        },
        "resume_edits": [
            {
                "section": "项目经验",
                "original": "原文",
                "suggested": "改后",
                "reason": "改写理由",
            }
        ],
        "learning_path": {
            "needed": True,
            "items": [
                {
                    "skill": "Go",
                    "priority": "中",
                    "estimated_weeks": 4,
                    "resources": [],
                }
            ],
        },
    }
    # 拼接 prompt,包含 JD 和简历内容及 JSON schema 模板
    prompt = (
        f"【JD】\n{jd_text}\n\n"
        f"【简历】\n{resume_text}\n\n"
        f"请按以下 JSON Schema 输出分析结果(仅输出 JSON):\n"
        f"{json.dumps(schema, ensure_ascii=False, indent=2)}"
    )
    # 如果是重试,提示 AI 重新输出完整 JSON
    if retry_hint:
        prompt += "\n\n上次输出格式错误或字段不完整,请严格按 Schema 重新输出完整 JSON。"
    # 返回拼接好的 prompt
    return prompt


# 提取 JSON 内容的函数
def _extract_json_content(raw: str) -> str:
    # 去除字符串首尾空白
    text = raw.strip()
    # 使用正则提取 markdown ```json ... ``` 代码块内容
    fence_match = re.search(r"```(?:json)?\s*([\s\S]*?)```", text, re.IGNORECASE)
    # 如果有代码块提取出来
    if fence_match:
        return fence_match.group(1).strip()
    # 否则返回原始文本
    return text


# 规范化分数字段的函数
def _normalize_score(score) -> int:
    # 如果 score 是布尔值,抛出异常
    if isinstance(score, bool):
        raise ValueError("score 必须是数字")
    # 如果 score 是浮点数,则四舍五入转为整数
    if isinstance(score, float):
        score = int(round(score))
    # 如果 score 是数字字符串,转为 int
    if isinstance(score, str) and score.isdigit():
        score = int(score)
    # 如果 score 不是 0-100 间的整数,抛出异常
    if not isinstance(score, int) or score < 0 or score > 100:
        raise ValueError("score 必须是 0-100 的整数")
    # 返回规范化后的分数
    return score


# 校验 AI 返回结果格式的函数
def _validate_result(data: dict) -> dict: 
    # 规范化分数
    data["score"] = _normalize_score(data.get("score"))

    # 获取推荐字段
    recommend = data.get("recommend_apply")
    # 如果推荐字段无效,抛出异常
    if recommend not in VALID_RECOMMEND:
        raise ValueError("recommend_apply 无效")

    # 若无推荐理由,抛出异常
    if not data.get("recommend_reason"):
        raise ValueError("缺少 recommend_reason")

    # 获取简历修改项
    resume_edits = data.get("resume_edits")
    # 如果 resume_edits 不是列表或为空,抛出异常
    if not isinstance(resume_edits, list) or not resume_edits:
        raise ValueError("resume_edits 不能为空")

    # 用于存放校验后的编辑项
    normalized_edits = []
    # 遍历每一个编辑项,格式化内容
    for item in resume_edits:
        # 只处理字典类型的项
        if not isinstance(item, dict):
            continue
        # 获取并去除前后空白
        section = str(item.get("section", "")).strip()
        original = str(item.get("original", "")).strip()
        suggested = str(item.get("suggested", "")).strip()
        reason = str(item.get("reason", "")).strip()
        # 若所有字段都不为空则添加到列表
        if section and original and suggested and reason:
            normalized_edits.append(
                {
                    "section": section,
                    "original": original,
                    "suggested": suggested,
                    "reason": reason,
                }
            )
    # 若没有有效编辑项,抛出异常
    if not normalized_edits:
        raise ValueError("resume_edits 格式无效")
    # 更新结果为格式化后的编辑项
    data["resume_edits"] = normalized_edits

    # 获取 skills 字段
    skills = data.get("skills")
    # skills 必须为字典
    if not isinstance(skills, dict):
        raise ValueError("skills 格式无效")
    # 对 skills 下的各类技能字段做规范化
    for key in ("matched", "missing", "bonus"):
        items = skills.get(key, [])
        # 如果不是列表则置空
        if not isinstance(items, list):
            skills[key] = []
        else:
            # 去肥空白,仅保留真实内容
            skills[key] = [str(s).strip() for s in items if str(s).strip()]
    # 回写规范化后的 skills
    data["skills"] = skills

    # 返回校验和规范化后的数据
    return data


# 格式化 API 错误信息的函数
def _format_api_error(exc: Exception) -> str:
    if isinstance(exc, AuthenticationError):
        return "DeepSeek API Key 无效,请检查 .env 中的 DEEPSEEK_API_KEY"
    if isinstance(exc, APIStatusError):
        detail = exc.message
        logger.error("DeepSeek API 错误 %s: %s", exc.status_code, detail)
        if exc.status_code == 402:
            return "DeepSeek 账户余额不足,请充值后重试"
        if detail:
            return f"AI 分析请求失败:{detail}"
        return f"AI 分析请求失败(HTTP {exc.status_code})"
    if isinstance(exc, APIConnectionError):
        logger.error("DeepSeek 网络错误: %s", exc)
        return "AI 分析网络请求失败,请检查网络或 API 地址"
    logger.error("DeepSeek 未知错误: %s", exc)
    return "AI 分析请求失败,请稍后重试"


# 调用 deepseek 服务的函数
def _call_deepseek(resume_text: str, jd_text: str, *, retry_hint: bool = False) -> dict:
    # 动态导入应用配置
    from app.config import Config

    # 对简历文本做截断处理
    resume_text = _truncate(
        resume_text, Config.MAX_AI_RESUME_CHARS, "简历"
    )
    # 对 JD 文本做截断
    jd_text = _truncate(jd_text, Config.MAX_JD_LENGTH, "JD")

    # 获取 deepseek 的 API key
    api_key = current_app.config.get("DEEPSEEK_API_KEY", "").strip()
    # 没有配置 key 时抛出异常
    if not api_key:
        raise ValueError("未配置 DEEPSEEK_API_KEY,请在 .env 中设置")

    # 获取 deepseek 的基础 API 地址
    base_url = current_app.config.get(
        "DEEPSEEK_BASE_URL", "https://api.deepseek.com/v1"
    ).rstrip("/")
    # 获取模型名
    model = current_app.config.get("DEEPSEEK_MODEL", "deepseek-v4-pro")

    client = OpenAI(api_key=api_key, base_url=base_url, timeout=120.0)
    response = client.chat.completions.create(
        model=model,
        messages=[
            {"role": "system", "content": _build_system_prompt()},
            {
                "role": "user",
                "content": _build_user_prompt(
                    resume_text, jd_text, retry_hint=retry_hint
                ),
            },
        ],
        response_format={"type": "json_object"},
        temperature=0.3,
    )
    content = response.choices[0].message.content
    data = json.loads(_extract_json_content(content))
    return _validate_result(data)


# 对外提供的简历与 JD 分析主函数
def analyze_resume_jd(resume_text: str, jd_text: str) -> dict:
    # 标记是否重试
    retry_hint = False
    # 存放上一次的错误对象
    last_error: Exception | None = None

    # 最多尝试 2 次
    for attempt in range(2):
        try:
            # 调用 deepseek,初次不带重试提示,失败后带提示
            return _call_deepseek(resume_text, jd_text, retry_hint=retry_hint)
        # 处理网络请求异常
        except (APIConnectionError, APIStatusError) as exc:
            raise ValueError(_format_api_error(exc)) from exc
        # 处理 json 解析错误
        except json.JSONDecodeError as exc:
            last_error = exc
            logger.warning("AI 返回 JSON 解析失败(第 %d 次): %s", attempt + 1, exc)
        # 处理结构异常
        except KeyError as exc:
            last_error = exc
            logger.warning("AI 返回结构异常(第 %d 次): %s", attempt + 1, exc)
        # 处理校验异常
        except ValueError as exc:
            last_error = exc
            logger.warning("AI 返回校验失败(第 %d 次): %s", attempt + 1, exc)

        # 首次失败后,第二次尝试加重试提示
        if attempt == 0:
            retry_hint = True
            continue

    # 处理两次尝试均失败后的不同异常类型
    if isinstance(last_error, json.JSONDecodeError):
        logger.error("返回 JSON 解析失败,已重试: %s", last_error)
        raise ValueError("返回格式无法解析,请稍后重试") from last_error
    if isinstance(last_error, KeyError):
        logger.error("返回结构异常,已重试: %s", last_error)
        raise ValueError("返回结构异常,请稍后重试") from last_error
    if isinstance(last_error, ValueError):
        raise last_error
    # 兜底错误
    raise ValueError("分析失败,请稍后重试")

4.3. analyzing.html #

templates/analyzing.html

{% extends "base.html" %}

{% block title %}分析中 — 职策{% endblock %}

{% block content %}
<div class="page-card analyzing-card">
  {% include "partials/steps.html" %}

  <div class="analyzing-body">
    <div class="analyzing-icon analyzing-spin">⏳</div>
    <h1>系统正在分析中,请稍候...</h1>
    <p class="analyzing-hint">预计需要 20–40 秒,请勿关闭页面</p>
    <p class="analyzing-meta">JD 预览:{{ jd_preview }}</p>

    <ul class="analyzing-steps">
      <li class="is-done"><span>✓</span> 解析简历内容</li>
      <li class="is-done"><span>✓</span> 提取 JD 关键信息</li>
      <li class="is-active"><span>◌</span> 匹配度计算</li>
      <li class="is-active"><span>◌</span> 生成改写建议</li>
      <li><span>○</span> 生成学习路径</li>
    </ul>

    <form id="analyze-form" method="post" action="{{ url_for('analyze.run') }}"></form>
    <p class="analyzing-status">正在分析,请耐心等待...</p>
  </div>
</div>
{% endblock %}

{% block scripts %}
<script>
  document.getElementById("analyze-form")?.submit();
</script>
{% endblock %}

4.4. .env #

.env

# 是否开启Flask调试模式,1表示开启
FLASK_DEBUG=1
# Flask服务器监听的主机地址
FLASK_HOST=127.0.0.1
# Flask服务器监听的端口号
FLASK_PORT=5000
# Flask应用的密钥,用于安全相关(如session)
SECRET_KEY=change-me
# 数据库连接URL,格式为“mysql+pymysql://用户名:密码@主机:端口/数据库名”
DATABASE_URL=mysql+pymysql://root:root@127.0.0.1:3306/job3


+DEEPSEEK_API_KEY=sk-755da8248cfa4fc4ab0c4ff7015b2a47
+DEEPSEEK_BASE_URL=https://api.deepseek.com/v1
+DEEPSEEK_MODEL=deepseek-v4-pro

4.5. config.py #

app/config.py

# 导入操作系统相关模块
import os
# 导入Path对象,用于处理文件和目录路径
from pathlib import Path
# 导入load_dotenv函数,用于加载.env环境变量文件
from dotenv import load_dotenv

# 获取当前文件的上上级目录作为项目的根目录
BASE_DIR = Path(__file__).resolve().parent.parent
# 从根目录下加载.env文件,override=True表示覆盖已存在的环境变量
load_dotenv(BASE_DIR / ".env", override=True)

# 定义配置类
class Config:
    # 项目根目录路径
    BASE_DIR = BASE_DIR
    # 模板文件夹路径
    TEMPLATE_DIR = BASE_DIR / "templates"
    # 静态文件夹路径
    STATIC_DIR = BASE_DIR / "static"
    # 从环境变量获取SECRET_KEY, 若无则使用默认值"dev-secret-key"
    SECRET_KEY = os.getenv("SECRET_KEY", "dev-secret-key")
    # 从环境变量获取数据库连接地址,若无则用默认的MySQL连接字符串
    SQLALCHEMY_DATABASE_URI = os.getenv(
        "DATABASE_URL",
        "mysql+pymysql://root:password@127.0.0.1:3306/job2",
    )
    # 是否追踪数据库修改,这里默认关闭
    SQLALCHEMY_TRACK_MODIFICATIONS = False
    # 从环境变量获取FLASK_DEBUG,字符串为"1"时为True,否则为False
    FLASK_DEBUG = os.getenv("FLASK_DEBUG", "1") == "1"
    # 从环境变量获取Flask服务的主机地址,默认为"127.0.0.1"
    FLASK_HOST = os.getenv("FLASK_HOST", "127.0.0.1")
    # 从环境变量获取Flask服务的端口号,默认为5000,并转换为int类型
    FLASK_PORT = int(os.getenv("FLASK_PORT", "5000"))
    # 从环境变量获取最大JD长度,默认为10000
    MAX_JD_LENGTH = int(os.getenv("MAX_JD_LENGTH", "10000"))
    # 从环境变量获取DEEPSEEK_API_KEY,默认为空字符串,去除首尾空格
+   DEEPSEEK_API_KEY = os.getenv("DEEPSEEK_API_KEY", "").strip()
    # 从环境变量获取DEEPSEEK_BASE_URL,默认为"https://api.deepseek.com/v1",去除结尾斜杠
+   DEEPSEEK_BASE_URL = os.getenv(
+       "DEEPSEEK_BASE_URL", "https://api.deepseek.com/v1"
+   ).rstrip("/")
    # 从环境变量获取DEEPSEEK_MODEL,默认为"deepseek-v4-pro",去除首尾空格
+   DEEPSEEK_MODEL = os.getenv("DEEPSEEK_MODEL", "deepseek-v4-pro").strip()
    # 从环境变量获取最大AI简历字符数,默认为12000,并转为整数
+   MAX_AI_RESUME_CHARS = int(os.getenv("MAX_AI_RESUME_CHARS", "12000"))
    # 从环境变量获取JD_BROWSER_HEADLESS,若为"1"则为True,否则为False
+   JD_BROWSER_HEADLESS = os.getenv("JD_BROWSER_HEADLESS", "0") == "1"

4.6. init.py #

app/routes/init.py

# 从app.routes.health模块导入health_bp蓝图
from app.routes.health import health_bp
# 从app.routes.resume模块导入resume_bp蓝图
from app.routes.resume import resume_bp
# 从app.routes.jd模块导入jd_bp蓝图
from app.routes.jd import jd_bp
# 从app.routes.analyze模块导入analyze_bp蓝图
+from app.routes.analyze import analyze_bp
# 定义注册蓝图的函数,参数为Flask应用实例app
def register_blueprints(app):
    # 将health_bp蓝图注册到Flask应用中
    app.register_blueprint(health_bp)
    # 将resume_bp蓝图注册到Flask应用中
    app.register_blueprint(resume_bp)
    # 将jd_bp蓝图注册到Flask应用中
    app.register_blueprint(jd_bp)
    # 将analyze_bp蓝图注册到Flask应用中
+   app.register_blueprint(analyze_bp)

4.7. jd.py #

app/routes/jd.py

# 引入Flask框架相关模块
from flask import Blueprint, flash, redirect, render_template, request, session, url_for

# 引入自定义配置、工具函数和JD解析服务
from app.config import Config
# 引入清除JD会话、获取当前简历的辅助函数和JD解析服务
from app.routes.helpers import clear_jd_session, get_current_resume
# 引入JD解析服务
from app.services.jd_parser import parse_jd_url, validate_jd_text

# 创建一个名为"jd"的蓝图,用于JD相关的路由
jd_bp = Blueprint("jd", __name__)

# 定义/jd路由,支持GET和POST请求
@jd_bp.route("/jd", methods=["GET", "POST"])
def input():
    # 获取当前已上传的简历
    resume = get_current_resume()
    # 如果没有简历,则提示用户先上传简历,并重定向到上传简历页面
    if not resume:
        flash("请先上传简历", "error")
        return redirect(url_for("resume.upload"))

    # 处理GET请求逻辑
    if request.method == "GET":
        # 如果请求参数fresh为"1",则清空JD的会话信息并刷新页面
        if request.args.get("fresh") == "1":
            clear_jd_session()
            return redirect(url_for("jd.input"))
        # 获取前端传来的tab参数,默认为"text"
        active_tab = request.args.get("tab", "text")
        # 判断tab参数是否合法,不合法则默认"text"
        if active_tab not in ("text", "link"):
            active_tab = "text"
        # 从session读取已保存的jd_text和jd_url,若没有则为空字符串
        jd_text = session.get("jd_text", "")
        jd_url = session.get("jd_url", "")
    else:
        # 对于非GET请求(即POST),active_tab默认“text”,同时读取session中的jd_text和jd_url
        active_tab = "text"
        jd_text = session.get("jd_text", "")
        jd_url = session.get("jd_url", "")

    # 处理POST请求逻辑
    if request.method == "POST":
        # 获取当前动作类型,默认为“analyze”
        action = request.form.get("action", "analyze")

        # 如果动作为解析JD链接
        if action == "parse_url":
            # 获取用户输入的JD链接并去除首尾空格并存入session
            jd_url = request.form.get("jd_url", "").strip()
            session["jd_url"] = jd_url
            try:
                # 调用业务方法解析链接内容
                jd_text = parse_jd_url(jd_url)
                # 解析成功后将内容存入session
                session["jd_text"] = jd_text
                # 显示成功提示
                flash("链接解析成功,已填入 JD 文本", "success")
                # 重定向回/jd页面并切换到text标签
                return redirect(url_for("jd.input", tab="text"))
            except ValueError as exc:
                # 解析异常时提示错误,并跳到link标签
                flash(str(exc), "error")
                return redirect(url_for("jd.input", tab="link"))

        try:
            # 判断form表单是否传递了jd_text字段
            if "jd_text" in request.form:
                source_text = request.form.get("jd_text", "")
            else:
                # 否则从session提取JD文本
                source_text = session.get("jd_text", "")
            # 校验并规范化JD文本内容
            jd_text = validate_jd_text(source_text)
            # 校验成功后将JD文本存入session
            session["jd_text"] = jd_text
+           return redirect(url_for("analyze.waiting"))
        except ValueError as exc:
            # 校验失败则提示错误
            flash(str(exc), "error")
            # 获取表单或者session中已有的JD文本
            jd_text = request.form.get("jd_text", session.get("jd_text", ""))
            # 如果form提交了jd_text则为text标签,否则为link标签
            active_tab = "text" if "jd_text" in request.form else "link"

    # 渲染jd.html模板,并传递相关上下文参数
    return render_template(
        "jd.html",
        resume=resume,
        current_step=2,
        jd_text=jd_text,
        jd_url=jd_url,
        active_tab=active_tab,
        max_jd_length=Config.MAX_JD_LENGTH,
    )

5. 匹配报告展示 #

本节实现流程第四步「查看报告」:AI 分析完成后跳转至 /report/<analysis_id>,从数据库读取 Analysis 记录,将匹配分数、投递建议、硬门槛、技能对比、简历改写建议与学习路径渲染为可视化报告页,并提供「分析下一个 JD」「重新上传简历」等后续操作入口。

涉及模块如下:

层级 职责 关键文件
路由 按 ID 查询分析记录、映射展示字段 app/routes/report.py
模板 分数环、推荐卡片、表格与改写列表 templates/report.html
应用工厂 UTC → 上海时区的 localtime 过滤器 app/__init__.py
分析路由 分析完成后重定向到报告页 app/routes/analyze.py(改动)
模型 读取 result JSON 与元数据 app/models/analysis.py(第 1 节已建)

报告展示的完整时序如下:

sequenceDiagram autonumber actor User as 用户 participant Analyze as analyze_bp /run participant DB as SQLAlchemy participant Report as report_bp /report/<id> participant Page as report.html User->>Analyze: POST /analyze/run(第 4 节) Analyze->>DB: commit(Analysis) Analyze-->>User: redirect → /report/{analysis_id} User->>Report: GET /report/{analysis_id} Report->>DB: session.get(Analysis, id) alt 记录不存在或 result 为空 Report-->>User: 404 else 记录有效 Report->>Report: 映射 recommend_label / score_tier Report->>Page: render_template(current_step=4) Page-->>User: 展示匹配分数、硬门槛、技能、改写建议 end Note over User,Page: 用户后续操作 User->>Page: 复制改写建议(common.js) User->>Page: 分析下一个 JD → /jd?fresh=1 User->>Page: 重新上传简历 → /upload

按以下小节顺序逐步创建各文件即可。

5.1. report.py #

app/routes/report.py

# 从flask中导入Blueprint、abort和render_template
from flask import Blueprint, abort, render_template

# 从app.extensions导入db
from app.extensions import db
# 从app.models导入Analysis模型
from app.models import Analysis

# 创建一个名为"report"的蓝图对象
report_bp = Blueprint("report", __name__)

# 定义一个推荐标签的字典,将英文key映射为中文
RECOMMEND_LABELS = {
    "apply": "建议投递",
    "watch": "观望",
    "skip": "不建议",
}

# 定义一个推荐类别的字典,将英文key映射为css类名
RECOMMEND_CLASSES = {
    "apply": "recommend-apply",
    "watch": "recommend-watch",
    "skip": "recommend-skip",
}

# 定义一个用于根据分数返回分数等级字符串的辅助函数
def _score_tier(score: int | None) -> str:
    # 如果分数为None,则返回中等分数等级
    if score is None:
        return "score-mid"
    # 如果分数大于等于70,返回高分等级
    if score >= 70:
        return "score-high"
    # 如果分数大于等于50,返回中等分数等级
    if score >= 50:
        return "score-mid"
    # 否则返回低分等级
    return "score-low"

# 注册一个GET请求的路由,路径为/report/<int:analysis_id>
@report_bp.get("/report/<int:analysis_id>")
# 定义视图函数show,参数为analysis_id
def show(analysis_id: int):
    # 从数据库中获取对应的Analysis对象
    analysis = db.session.get(Analysis, analysis_id)
    # 如果未找到analysis或者其result属性为None,则抛出404错误
    if not analysis or not analysis.result:
        abort(404)

    # 获取分析结果
    result = analysis.result
    # 获取推荐类别,如果不存在则默认为"watch"
    recommend = result.get("recommend_apply", "watch")
    # 获取分数
    score = result.get("score")

    # 渲染report.html模板,并传入相关参数
    return render_template(
        "report.html",
        analysis=analysis,
        result=result,
        current_step=4,
        recommend_label=RECOMMEND_LABELS.get(recommend, recommend),
        recommend_class=RECOMMEND_CLASSES.get(recommend, "recommend-watch"),
        score_tier=_score_tier(score),
    )

5.2. report.html #

templates/report.html

{% extends "base.html" %}

{% block title %}匹配报告 — 职策 AI{% endblock %}

{% block content %}
<div class="page-card page-card-wide report-page">
  {% include "partials/steps.html" %}

  <div class="report-top">
    <div class="score-card">
      <div class="score-ring {{ score_tier }}" style="--score: {{ result.score }}">
        <div class="score-value {{ score_tier }}">{{ result.score }}<span>/100</span></div>
      </div>
      <p class="score-label">匹配度</p>
    </div>

    <div class="recommend-card {{ recommend_class }}">
      <div class="recommend-badge">{{ recommend_label }}</div>
      <p class="recommend-reason">{{ result.recommend_reason }}</p>
    </div>
  </div>

  <section class="report-section">
    <h2>硬门槛匹配</h2>
    <div class="table-wrap">
      <table class="report-table">
        <thead>
          <tr><th>项目</th><th>要求</th><th>匹配</th><th>说明</th></tr>
        </thead>
        <tbody>
          {% set hr = result.hard_requirements or {} %}
          {% for key, label in [('experience','经验'),('education','学历'),('city','城市'),('salary','薪资')] %}
            {% set item = hr.get(key, {}) %}
            <tr>
              <td>{{ label }}</td>
              <td>{{ item.get('required', '-') }}</td>
              <td>
                {% if item.get('match') is sameas true %}
                  <span class="tag tag-green">匹配</span>
                {% elif item.get('match') is sameas false %}
                  <span class="tag tag-red">不匹配</span>
                {% else %}
                  <span class="tag tag-orange">未提及</span>
                {% endif %}
              </td>
              <td>{{ item.get('note') or '-' }}</td>
            </tr>
          {% endfor %}
        </tbody>
      </table>
    </div>
  </section>

  <section class="report-section">
    <h2>技能匹配</h2>
    {% set skills = result.skills or {} %}
    <div class="skill-group">
      <h3>匹配技能</h3>
      <div class="tag-list">
        {% for s in skills.get('matched', []) %}
          <span class="tag tag-green">{{ s }}</span>
        {% else %}
          <span class="muted">无</span>
        {% endfor %}
      </div>
    </div>
    <div class="skill-group">
      <h3>缺失技能</h3>
      <div class="tag-list">
        {% for s in skills.get('missing', []) %}
          <span class="tag tag-red">{{ s }}</span>
        {% else %}
          <span class="muted">无</span>
        {% endfor %}
      </div>
    </div>
    <div class="skill-group">
      <h3>加分技能</h3>
      <div class="tag-list">
        {% for s in skills.get('bonus', []) %}
          <span class="tag tag-blue">{{ s }}</span>
        {% else %}
          <span class="muted">无</span>
        {% endfor %}
      </div>
    </div>
  </section>

  <section class="report-section">
    <div class="section-head">
      <h2>简历改写建议</h2>
      <button type="button" class="btn btn-outline btn-sm" id="copy-all-edits">复制全部</button>
    </div>
    <div class="edit-list">
      {% for edit in result.resume_edits %}
      <div class="edit-item">
        <div class="edit-head">
          <strong>{{ edit.section }}</strong>
          <button type="button" class="btn btn-outline btn-sm copy-edit">复制改后</button>
        </div>
        <p class="edit-label">原文</p>
        <p class="edit-text">{{ edit.original }}</p>
        <p class="edit-label">建议改为</p>
        <p class="edit-text edit-suggested">{{ edit.suggested }}</p>
        <p class="edit-reason">理由:{{ edit.reason }}</p>
      </div>
      {% endfor %}
    </div>
  </section>

  {% set lp = result.learning_path or {} %}
  {% if lp.get('needed') and lp.get('items') %}
  <section class="report-section">
    <h2>学习路径(提升建议)</h2>
    <div class="learning-list">
      {% for item in lp.get('items', []) %}
      <div class="learning-item">
        <div class="learning-title">{{ item.skill }} · 优先级 {{ item.priority }}</div>
        <p>预计 {{ item.estimated_weeks }} 周</p>
        {% if item.resources %}
          <p class="learning-resources">资源:{{ item.resources | join('、') }}</p>
        {% endif %}
      </div>
      {% endfor %}
    </div>
  </section>
  {% endif %}

  <p class="disclaimer">AI 分析结果仅供参考,不构成投递建议或录用承诺。请结合个人判断做出决策。</p>

  <div class="report-actions">
    <a href="{{ url_for('jd.input', fresh=1) }}" class="btn btn-outline">分析下一个 JD</a>
    <a href="{{ url_for('resume.upload') }}" class="btn btn-primary">重新上传简历</a>
  </div>

  <p class="report-meta">分析编号 #{{ analysis.id }} · {{ analysis.created_at|localtime }}</p>
</div>
{% endblock %}

5.3. init.py #

app/init.py

# 从Flask框架中导入Flask类
from flask import Flask
# 从datetime模块导入timezone类,用于处理时区信息
+from datetime import timezone
# 从zoneinfo模块导入ZoneInfo类,用于指定具体的时区
+from zoneinfo import ZoneInfo
# 从app.config模块导入Config配置类
from app.config import Config
# 从app.extensions模块导入db(数据库扩展)
from app.extensions import db
# 从app.routes模块导入register_blueprints函数
from app.routes import register_blueprints
+LOCAL_TZ = ZoneInfo("Asia/Shanghai")
# 定义创建应用工厂函数,可接收一个配置类参数
def create_app(config_class=Config):
    # 创建Flask应用实例,指定模板和静态文件目录
    app = Flask(__name__,
                template_folder=str(config_class.TEMPLATE_DIR),
                static_folder=str(config_class.STATIC_DIR))
    # 从配置类加载配置信息
    app.config.from_object(config_class)

    # 定义一个模板过滤器,名称为"localtime"
+   @app.template_filter("localtime")
    # 定义一个用于格式化本地时间的函数,接受时间dt和格式fmt参数,fmt默认值为"%Y-%m-%d %H:%M"
+   def format_localtime(dt, fmt="%Y-%m-%d %H:%M"):
        # 如果dt为None或False,直接返回空字符串
+       if not dt:
+           return ""
        # 将传入的dt对象的时区设置为UTC时间
+       utc_dt = dt.replace(tzinfo=timezone.utc)
        # 将UTC时间转换为上海本地时区,并按照指定格式输出字符串
+       return utc_dt.astimezone(LOCAL_TZ).strftime(fmt)

    # 初始化数据库,将app绑定到db上
    db.init_app(app)
    # 注册蓝图到app
    register_blueprints(app)
    # 在应用上下文中创建所有数据库表
    with app.app_context():
        db.create_all()
    # 返回应用实例
    return app

5.4. init.py #

app/routes/init.py

# 从app.routes.health模块导入health_bp蓝图
from app.routes.health import health_bp
# 从app.routes.resume模块导入resume_bp蓝图
from app.routes.resume import resume_bp
# 从app.routes.jd模块导入jd_bp蓝图
from app.routes.jd import jd_bp
# 从app.routes.analyze模块导入analyze_bp蓝图
from app.routes.analyze import analyze_bp
# 从app.routes.report模块导入report_bp蓝图
+from app.routes.report import report_bp
# 定义注册蓝图的函数,参数为Flask应用实例app
def register_blueprints(app):
    # 将health_bp蓝图注册到Flask应用中
    app.register_blueprint(health_bp)
    # 将resume_bp蓝图注册到Flask应用中
    app.register_blueprint(resume_bp)
    # 将jd_bp蓝图注册到Flask应用中
    app.register_blueprint(jd_bp)
    # 将analyze_bp蓝图注册到Flask应用中
    app.register_blueprint(analyze_bp)
    # 将report_bp蓝图注册到Flask应用中
+   app.register_blueprint(report_bp)

5.5. analyze.py #

app/routes/analyze.py

# 导入Blueprint、flash、redirect、render_template、session、url_for,用于路由、消息、跳转、渲染模板和会话管理
from flask import Blueprint, flash, redirect, render_template, session, url_for

# 导入数据库扩展db
from app.extensions import db
# 导入Analysis模型类
from app.models import Analysis
# 导入获取当前简历的辅助函数
from app.routes.helpers import get_current_resume
# 导入用于简历与JD分析的AI服务
from app.services.ai_analyzer import analyze_resume_jd

# 创建名为"analyze"的蓝图
analyze_bp = Blueprint("analyze", __name__)


# 定义GET请求路由/analyze
@analyze_bp.get("/analyze")
def waiting():
    # 获取当前会话下的简历
    resume = get_current_resume()
    # 如果简历未上传或不存在,提示并跳转到简历上传页面
    if not resume:
        flash("请先上传简历", "error")
        return redirect(url_for("resume.upload"))

    # 获取已输入的JD文本并去除首尾空格
    jd_text = session.get("jd_text", "").strip()
    # 如果JD文本为空,提示并跳转到JD输入页面
    if not jd_text:
        flash("请先输入 JD", "error")
        return redirect(url_for("jd.input"))

    # 渲染分析中的页面
    return render_template(
        "analyzing.html",
        resume=resume,
        jd_preview=jd_text[:120] + ("..." if len(jd_text) > 120 else ""),
        current_step=3,
    )


# 定义POST请求路由/analyze/run
@analyze_bp.post("/analyze/run")
def run():
    # 获取当前会话下的简历
    resume = get_current_resume()
    # 如果简历未上传或不存在,提示并跳转到简历上传页面
    if not resume:
        flash("请先上传简历", "error")
        return redirect(url_for("resume.upload"))

    # 获取已输入的JD文本并去除首尾空格
    jd_text = session.get("jd_text", "").strip()
    # 如果JD文本为空,提示并跳转到JD输入页面
    if not jd_text:
        flash("请先输入 JD", "error")
        return redirect(url_for("jd.input"))

    # 若简历文本内容为空,提示并跳转到简历上传页面
    if not resume.text_content:
        flash("简历文本为空,请重新上传", "error")
        return redirect(url_for("resume.upload"))

    # 尝试调用AI分析服务获取结果
    try:
        result = analyze_resume_jd(resume.text_content, jd_text)
    # 捕获分析服务抛出的ValueError异常,显示异常消息,并重定向回等待页面
    except ValueError as exc:
        flash(str(exc), "error")
        return redirect(url_for("analyze.waiting"))
    # 捕获其它异常,提示分析失败,并重定向回等待页面
    except Exception:
        flash("分析失败,请稍后重试", "error")
        return redirect(url_for("analyze.waiting"))

    # 构建Analysis对象保存本次分析的各项数据
    analysis = Analysis(
        resume_id=resume.id,
        jd_text=jd_text,
        result=result,
        score=result.get("score"),
        recommend_apply=result.get("recommend_apply"),
    )
    # 把Analysis对象加入数据库会话
    db.session.add(analysis)
    # 尝试提交数据库操作
    try:
        db.session.commit()
    # 如果提交失败,回滚并提示,重定向回等待页面
    except Exception:
        db.session.rollback()
        flash("分析结果保存失败,请稍后重试", "error")
        return redirect(url_for("analyze.waiting"))

    # 跳转到分析结果页面(
+   return redirect(url_for("report.show", analysis_id=analysis.id))