Commit d588921d authored by 李文光's avatar 李文光

feat: 多用户登录与按用户隔离工作区

- 激活 User 表(username/role/password_hash/is_active/last_login_at),13 张业务表加 owner_id,AppStateMeta 改 String 主键(工作区键)
- 认证中间件支持 Bearer 令牌 + Basic 兼容;ADMIN_PASSWORD 为登录总开关,未设置保持开放模式(local 工作区)
- 密码 PBKDF2-SHA256 哈希、HMAC 签名令牌(SESSION_SECRET),零新依赖
- 新增 /api/auth/* 路由:登录/登出/me/改密/用户 CRUD(admin 专属,含最后管理员保护)
- 状态读写/采集落库按 owner 隔离,admin 可 ?owner= 切换工作区
- 前端:登录页、用户管理页、user store、路由守卫、AppShell 用户菜单/工作区切换器,localStorage 按工作区分键
- 旧库启动自动补 owner_id 回填 local 并重建 users/app_state_meta,历史数据不丢
- 新增 create_user.py CLI(create/list/reset/disable/enable)
- 测试 25 passed(新增 test_auth.py 14 用例),文档同步更新
parent cca6d880
......@@ -17,6 +17,7 @@
# ---------- 工具索引 ----------
.codegraph/
.zcode/
# ---------- 日志 ----------
*.log
......
......@@ -4,7 +4,7 @@
## 1. 项目概述
招聘管理系统(recruit-sys,v0.5.0),覆盖「岗位需求梳理 → JD 生成 → 简历采集/解析 → 候选人评估 → Offer 流程」。三端分离:
招聘管理系统(recruit-sys,v0.6.0),覆盖「岗位需求梳理 → JD 生成 → 简历采集/解析 → 候选人评估 → Offer 流程」,支持**多用户登录 + 按用户隔离工作区**。三端分离:
- **backend/**:Python 3.11+ / FastAPI / SQLAlchemy 2 / Alembic / SQLite(可切 MySQL)
- **vue-app/**:Vue 3(Composition API)/ Vite / Pinia / Element Plus / SCSS,**无 TypeScript**
......@@ -24,6 +24,7 @@
| 后端测试 | `... -m pytest -c backend/pyproject.toml` |
| 数据库迁移 | `... -m alembic -c backend/alembic.ini upgrade head` |
| 简历解析 CLI | `... backend/scripts/parse_resume.py <简历文件>` |
| 用户管理 CLI | `... backend/scripts/create_user.py create/list/reset/disable/enable` |
| 前端开发 | `cd vue-app && npm run dev`(http://127.0.0.1:5173,/api 代理到 4177) |
| 前端构建 / 检查 | `npm run build` / `npm run lint` / `npm run format` |
......@@ -46,36 +47,43 @@
## 4. 修改必须遵守的架构规则
### 4.1 状态同步是「整包覆盖」
`PUT /api/state` 会先**清空全部业务表再重写**[state_repository.py 的 replace_state](backend/app/repositories/state_repository.py))。新增/修改业务实体时:**models.py(表)与 state_repository.py(读写映射)必须同步改**;前端 `utils/normalize.js``utils/constants.js` 的字段也要对应。禁止绕过此机制另搞一套增量同步。
`PUT /api/state` 会先**按当前工作区清空业务表再重写**[state_repository.py 的 replace_state](backend/app/repositories/state_repository.py))。新增/修改业务实体时:**models.py(表)与 state_repository.py(读写映射)必须同步改**;前端 `utils/normalize.js``utils/constants.js` 的字段也要对应。禁止绕过此机制另搞一套增量同步。
### 4.2 共享词典只有一份
### 4.2 多用户与工作区隔离(硬性)
- **所有业务表必须有 `owner_id` 列**(归属用户名);新增业务表时**必须**`owner_id`(String 80, index),否则状态读写会漏隔离。
- 接口按工作区隔离:`read_state/replace_state` 一律带 owner;`?owner=` 只允许 admin 指定他人,普通用户指定他人返回 403([dependencies.py 的 resolve_owner](backend/app/dependencies.py))。
- 认证依赖:需要登录态的接口用 `get_current_user`;仅 admin 可用 `require_admin`**禁止在非 auth 路由里手写绕过认证**。开放模式(未设 `ADMIN_PASSWORD`)下所有请求归属 `local` 工作区。
- 新增用户相关能力走 [routers/auth.py](backend/app/routers/auth.py),密码哈希/令牌用 [services/auth_security.py](backend/app/services/auth_security.py)(PBKDF2 + HMAC),**禁止**明文存密码或自造哈希。
### 4.3 共享词典只有一份
学校/专业/技能词典唯一数据源是 **`shared/resume-dictionaries.json`**
- 后端:[resume_parser_core.py](backend/app/services/resume_parser_core.py) 启动时加载(`_DICTIONARIES`);
- 前端:[constants.js](vue-app/src/utils/constants.js) `import` 同一文件。
**改词典只允许改共享 JSON**,禁止在任一端代码里内嵌重复列表。
### 4.3 AI 调用前的脱敏是硬性要求
### 4.4 AI 调用前的脱敏是硬性要求
- 候选人信息发往 LLM 前必须脱敏([llm.py 的 sanitize_candidate_for_llm / sanitize_resume_for_llm](backend/app/services/llm.py)):姓名、手机、邮箱、年龄、出生年月等替换为占位符。
- 岗位**对内字段**`sensitive``genderRestriction``ageRestriction``probationPeriod``probationCriteria``nonCompete``internalFeasibility``verification`,即 `llm.py``_INTERNAL_ONLY_JOB_KEYS`**严禁**进入对外 JD 或 LLM prompt;生成 JD 前必须经 `_sanitize_job_for_external` 剥离。
- 能力回退链不可破坏:`generate_jd_draft` = QwenPaw → LLM → 本地结构化;`analyze_resume` = LLM → 本地规则。无 Key 时必须能回退到 `local-structured`,不得 500。
### 4.4 JD 逼问式访谈是无状态状态机
### 4.5 JD 逼问式访谈是无状态状态机
[jd_grill.py](backend/app/services/jd_grill.py):前端持有会话 `state`,每次 POST `{state, answer}` 推进。敏感项只写入 `job.sensitive`。新增问题 = 在 `QUESTION_BANK` 加条目(注意 `skip_when`/`tech_only`),并同步更新 `test_ai.py` 的推进用例。
### 4.5 采集幂等与队列
### 4.6 采集幂等与队列
`/api/ingest` 幂等键 = sha256(source|sourceUrl|pdfUrl|fileHash|name|jobTitle)([ingest_repository.py](backend/app/repositories/ingest_repository.py))。改动采集 payload 字段时注意幂等键是否需扩展;入库后走「待确认队列」→ 前端确认,不要直接改状态。
### 4.6 前端产物目录
### 4.7 前端产物目录
`backend/static/` 是构建产物(由 `start_server.py --with-frontend` 生成,已 gitignore)。**禁止手动增删该目录或把它当源码**;页面/资源文件请改 `vue-app/src/` 后重新构建。
### 4.7 浏览器扩展
### 4.8 浏览器扩展
`browser-extension/` 是原生 JS(MV3),无构建步骤。`reference/` 是外部参考副本,**禁止改动或引用**
## 5. 测试要求
- **改完后端代码必须跑 `backend\.venv\Scripts\python.exe -m pytest -c backend/pyproject.toml`,期望 11 passed**
- **改完后端代码必须跑 `backend\.venv\Scripts\python.exe -m pytest -c backend/pyproject.toml`,期望 25 passed**
- 测试必须自包含:`conftest.py` 已隔离(`RECRUITMENT_SKIP_ENV_FILES=1` + 临时 SQLite/目录),**禁止依赖真实 `.env`、网络、LLM Key**
- 新增测试放 `backend/tests/`,用 `client` fixture;改动访谈题单/状态机时同步更新 `test_ai.py`
- 新增测试放 `backend/tests/`,用 `client` fixture(开放模式);**改认证/权限/工作区逻辑时同步更新 `test_auth.py`**(其 `auth_client` fixture 设置了 `ADMIN_PASSWORD` 等,需模拟登录拿 token)。
- 改动访谈题单/状态机时同步更新 `test_ai.py`
- `test_static_security.py` 断言 Vue `index.html` 实际引用的 JS 资源——若 `backend/static/` 为空,先 `start_server.py --with-frontend` 生成再跑。
- 前端目前无自动化测试;改 `utils/` 纯逻辑时至少保持现有行为不被破坏(npm run build 能过)。
......@@ -85,6 +93,7 @@
- **禁止**提交 `.env` / `.env.local`(已 gitignore);`backend/.env` 内有一个内网 LLM Key,别把它写进代码、注释或文档。
- 后端命令一律走 `backend/.venv`,否则可能因缺依赖或版本不符失败。
- 字段命名:前端 camelCase ↔ 后端 DB 列 snake_case;`data` 列存整个实体 JSON(camelCase)——两边别混用。
- 时间统一 ISO 字符串(`utc_now_iso`);ID 用 `前缀-uuid` 形式(job-、cand-、offer-、task-、evt-…)。
-`models.py` 后:本地开发由 `create_all` 自动建表,**不需要**手写迁移;正式迁移需 `alembic revision`(对齐 `0001_initial` 全量建表风格)。
- 时间统一 ISO 字符串(`utc_now_iso`);ID 用 `前缀-uuid` 形式(job-、cand-、offer-、task-、evt-…);用户 ID 用 `usr-<username>`
-`models.py` 后:本地开发由 `create_all` 自动建表,**不需要**手写迁移;正式迁移需 `alembic revision`(对齐 `0001_initial` 全量建表风格)。**给已有业务表加 `owner_id` 时**会走 [db.py 的 `_refresh_schema_for_owner_columns`](backend/app/db.py) 自动补列回填 `local`,注意保持该函数与新模型一致。
- 登录模式:设置 `ADMIN_PASSWORD` 后首次启动自动建管理员(`_ensure_admin`);测试/脚本用 `RECRUITMENT_SKIP_ENV_FILES=1` 时会跳过 .env,需显式设 env 变量。
- 文件必须 UTF-8;Windows 环境注意路径分隔(代码内用 pathlib,命令用反斜杠)。
......@@ -95,6 +95,39 @@ backend\.venv\Scripts\python.exe backend/scripts/start_server.py --with-frontend
详细说明见 [browser-extension/README.md](browser-extension/README.md)
### 5. 多用户登录与工作区
系统支持多用户登录,每位用户拥有**独立的工作区**(岗位/候选人/Offer/待办等互不可见),管理员可在顶栏切换工作区查看/操作任意用户的数据。启用方式:
```powershell
# 1. 在 backend/.env 中设置(未设置则保持开放模式,不要求登录)
ADMIN_PASSWORD=你的管理员密码
ADMIN_USERNAME=admin # 可选,首次启动自动创建管理员
SESSION_SECRET=随机长字符串 # 必须修改,否则令牌可被预测
# 2. 启动后端(首次启动自动建管理员账号)
backend\.venv\Scripts\python.exe backend/scripts/start_server.py --with-frontend
# 3. 浏览器访问 http://127.0.0.1:4177 → 用管理员登录
# 4. 管理员在「用户管理」中创建普通用户;用户登录后进入自己的工作区
```
要点:
- **角色两级**:`admin`(用户管理 + 全部工作区读写)/ `user`(自己工作区读写,不能进用户管理)。
- **用户管理**:登录后侧边栏「用户管理」新增/编辑/禁用/重置密码;禁用后旧登录令牌立即失效。
- **工作区隔离**:所有业务数据按 `owner_id` 隔离,普通用户无法通过 `?owner=` 访问他人工作区;管理员可在顶栏工作区切换器跨用户查看/操作。
- **修改密码**:顶栏用户菜单 →「修改密码」。
- **CLI 备选**(无界面场景):
```powershell
backend\.venv\Scripts\python.exe backend/scripts/create_user.py create alice --name Alice --role user --password alice123
backend\.venv\Scripts\python.exe backend/scripts/create_user.py list
backend\.venv\Scripts\python.exe backend/scripts/create_user.py reset alice --password newpass123
backend\.venv\Scripts\python.exe backend/scripts/create_user.py disable alice
```
- **旧版兼容**:登录接口仍是 `POST /api/auth/login` 返回 Bearer 令牌;同时保留 `ADMIN_PASSWORD` 的 Basic 认证作为兼容通道(任意用户名 + 密码)
- **已有数据迁移**:旧库首次启动会自动为业务表补 `owner_id` 列并归入 `local` 工作区(开放模式下页面仍读 `local`,数据不丢)
## 主要功能
- **待办项**:HR 跟进事项,关联候选人 / 岗位 / Offer。
......@@ -116,8 +149,14 @@ backend\.venv\Scripts\python.exe backend/scripts/start_server.py --with-frontend
| 方法 | 路径 | 说明 |
| --- | --- | --- |
| GET | `/api/ping` | 健康检查(含待确认采集数) |
| GET / PUT | `/api/state` | 读取 / 全量保存系统状态 |
| POST | `/api/import/local-state` | 导入 localStorage 备份 |
| POST | `/api/auth/login` | 登录,返回 Bearer 令牌 |
| POST | `/api/auth/logout` | 退出登录(无状态令牌,客户端丢弃即可) |
| GET | `/api/auth/me` | 当前用户信息 |
| POST | `/api/auth/change-password` | 修改自己的密码 |
| GET / POST | `/api/auth/users` | 用户列表 / 新建用户(admin) |
| PATCH / DELETE | `/api/auth/users/{id}` | 编辑(改角色/禁用/重置密码)/ 删除用户(admin) |
| GET / PUT | `/api/state` | 读取 / 全量保存系统状态(默认当前用户工作区,admin 可用 `?owner=` 指定) |
| POST | `/api/import/local-state` | 导入 localStorage 备份(到当前/指定工作区) |
| GET | `/api/export/state` | 导出 JSON 备份 |
| POST | `/api/parse-resume` | 上传简历并解析(PDF / DOCX / TXT) |
| POST | `/api/upload-resume` | 上传并保存简历文件 |
......@@ -142,9 +181,12 @@ backend\.venv\Scripts\python.exe backend/scripts/start_server.py --with-frontend
| `DATA_DIR` | `./data` | 运行时数据目录(生产建议放源码目录外) |
| `FILES_DIR` | `./uploads` | 简历等上传文件目录 |
| `DATABASE_URL` | `sqlite:///./data/recruitment.sqlite` | 数据库;可切换 MySQL(`mysql+pymysql://...`,`backend\.venv\Scripts\python.exe -m pip install -e "backend[mysql]"`) |
| `ADMIN_PASSWORD` | 空 | 设置后启用 HTTP Basic 登录(除 ping / 采集接口外) |
| `SESSION_SECRET` | 开发默认值 | 部署时请修改 |
| `ADMIN_PASSWORD` | 空 | **登录总开关**:设置后启用多用户登录;为空则开放模式(不登录,数据归 `local` 工作区) |
| `ADMIN_USERNAME` | `admin` | 首次启动自动创建的管理员用户名(需同时设置 `ADMIN_PASSWORD`) |
| `SESSION_SECRET` | 开发默认值 | 会话令牌签名密钥,**部署必须修改**(随机串,如 `python -c "import secrets;print(secrets.token_hex(32))"`) |
| `SESSION_TTL_DAYS` | `7` | 登录令牌有效期(天) |
| `INGEST_TOKEN` | 空 | 设置后 `/api/ingest`、`/api/jd/sync-external` `X-Ingest-Token` `?token=` |
| `INGEST_OWNER` | `admin` | 采集接口无登录态时数据落盘的工作区(默认 admin) |
| `CORS_ORIGINS` | `http://127.0.0.1:5173,...` | 允许的跨域来源,逗号分隔 |
| `MAX_UPLOAD_MB` | `20` | 上传大小限制(MB) |
| `LLM_API_KEY` / `LLM_BASE_URL` / `LLM_MODEL` | 空 | OpenAI 兼容模型;或使用 `DEEPSEEK_*` / `OPENAI_*`,优先级 `LLM_*` > `DEEPSEEK_*` > `OPENAI_*` |
......@@ -176,6 +218,10 @@ backend\.venv\Scripts\python.exe -m alembic -c backend/alembic.ini upgrade head
# 简历解析 CLI(不启动服务,直接解析简历文件)
backend\.venv\Scripts\python.exe backend/scripts/parse_resume.py <简历文件>
# 用户管理 CLI(创建/重置/禁用用户,需已启用登录)
backend\.venv\Scripts\python.exe backend/scripts/create_user.py create <用户名> --role user --password <密码>
backend\.venv\Scripts\python.exe backend/scripts/create_user.py list
# 前端(vue-app/ 目录,不依赖 Python 环境)
npm run dev # 开发服务器 http://127.0.0.1:5173
npm run build # 生产构建到 dist/
......@@ -189,6 +235,9 @@ npm run format # Prettier 格式化
- `backend/static/` 是前端构建产物的同步目录(已 gitignore),初始为空;使用 `start_server.py --with-frontend` 或手动同步 `vue-app/dist/` 后才会提供页面。未同步时 `/` 会跳转但 `/index.html` 返回 404,属正常状态。
- 后端启动统一走 `backend/scripts/start_server.py`;历史 `backend/package.json` 与失效脚本(`sync_frontend_static.py``legacy-server.mjs`)已删除。
- 学校/专业/技能词典为前后端共享的一份数据:`shared/resume-dictionaries.json`(前端经 `vue-app/src/utils/constants.js` 引用,后端经 `backend/app/services/resume_parser_core.py` 加载)。修改词典请改共享文件。
- **多用户边界**:同一工作区内多人同时编辑仍受「整包覆盖」限制(`PUT /api/state` 全量重写),建议同一工作区单用户或串行使用;登录令牌无状态,重置密码不会使已签发令牌立即失效(但禁用用户会立即失效)。
- **文件级隔离**:简历文件按内容哈希共享存储,任何已登录用户凭 URL(含哈希)可访问,未做严格文件级权限隔离。
- **采集归属**:浏览器扩展采集(`/api/ingest`)无登录态时默认进 `INGEST_OWNER` 指定用户(默认 admin)的工作区;开放模式进 `local`
## 更多文档
......
......@@ -30,8 +30,11 @@ class Settings(BaseSettings):
files_dir_raw: str = Field(default="./uploads", alias="FILES_DIR")
database_url: str = Field(default="sqlite:///./data/recruitment.sqlite", alias="DATABASE_URL")
admin_password: str = Field(default="", alias="ADMIN_PASSWORD")
admin_username: str = Field(default="admin", alias="ADMIN_USERNAME")
session_secret: str = Field(default="dev-session-secret", alias="SESSION_SECRET")
session_token_ttl_days: int = Field(default=7, alias="SESSION_TTL_DAYS")
ingest_token: str = Field(default="", alias="INGEST_TOKEN")
ingest_owner: str = Field(default="admin", alias="INGEST_OWNER")
cors_origins_raw: str = Field(
default="http://127.0.0.1:5173,http://localhost:5173", alias="CORS_ORIGINS"
)
......
......@@ -48,7 +48,95 @@ def get_db() -> Generator[Session, None, None]:
db.close()
def _refresh_schema_for_owner_columns() -> None:
"""旧库升级:为业务表补 owner_id 列并回填 "local"。
多用户改造前,业务表没有 owner_id;改造后该列不可空且有默认语义
(开放模式的历史数据归 local 工作区)。SQLite 支持 ADD COLUMN 加带默认值的
NOT NULL 列,故直接补列 + 建索引即可保留全部现有数据。
"""
from sqlalchemy import inspect, text
inspector = inspect(engine)
table_names = set(inspector.get_table_names())
if not table_names:
return
for name in _owner_column_tables():
if name not in table_names:
continue
columns = {col["name"] for col in inspector.get_columns(name)}
if "owner_id" not in columns:
with engine.begin() as conn:
conn.execute(text(f"ALTER TABLE {name} ADD COLUMN owner_id VARCHAR(80) NOT NULL DEFAULT 'local'"))
conn.execute(text(f"CREATE INDEX IF NOT EXISTS ix_{name}_owner_id ON {name} (owner_id)"))
# users 旧表缺 username 且无法 ADD COLUMN 加唯一约束:仅在确认无数据时 DROP 重建
if "users" in table_names:
columns = {col["name"] for col in inspector.get_columns("users")}
if "username" not in columns:
with engine.connect() as conn:
count = conn.scalar(text("SELECT COUNT(*) FROM users"))
if count:
raise RuntimeError(
"检测到旧版 users 表且已有数据,无法自动迁移。请先导出数据后删除 "
"data/recruitment.sqlite 重建(多用户改造前 users 表从不写入,正常不会触发此错误)。"
)
with engine.begin() as conn:
conn.execute(text("DROP TABLE users"))
# app_state_meta 主键由 Integer(固定 id=1)改为 String(owner 用户名):
# 旧结构 id 为 INTEGER,raw_snapshot 只是业务表的冗余快照缓存,直接 DROP 后
# 由 create_all 按新结构重建,业务数据不受影响。
if "app_state_meta" in table_names:
id_type = ""
for col in inspector.get_columns("app_state_meta"):
if col["name"] == "id":
id_type = str(col["type"]).upper()
break
if id_type.startswith("INT"):
with engine.begin() as conn:
conn.execute(text("DROP TABLE app_state_meta"))
def _owner_column_tables() -> list[str]:
from backend.app.models import (
Candidate,
CandidateApplication,
CandidateMatchReport,
IngestItem,
Interview,
JdVersion,
Job,
Offer,
RecruitmentEvent,
ResumeFile,
ResumeProfile,
ResumeText,
Task,
)
return [
model.__tablename__
for model in (
Job,
JdVersion,
Candidate,
CandidateApplication,
ResumeFile,
ResumeText,
ResumeProfile,
CandidateMatchReport,
Interview,
Offer,
Task,
RecruitmentEvent,
IngestItem,
)
]
def create_all() -> None:
from backend.app import models # noqa: F401
_refresh_schema_for_owner_columns()
Base.metadata.create_all(bind=engine)
"""认证与工作区依赖注入。
- get_current_user:开放模式返回合成本地用户,关闭模式解析会话/Basic 映射,失败抛 401。
- require_admin:管理员限定。
- resolve_owner:解析目标工作区(默认当前用户,admin 可 ?owner= 切换)。
- resolve_ingest_owner:采集接口归属(带有效 Bearer 令牌归该用户,否则回落 INGEST_OWNER)。
"""
from dataclasses import dataclass
from fastapi import Depends, HTTPException, Request
from sqlalchemy import select
from sqlalchemy.orm import Session
from backend.app.config import get_settings
from backend.app.db import get_db
from backend.app.models import User
from backend.app.services.auth_security import verify_token
# 开放模式(未设置 ADMIN_PASSWORD)下所有请求归属的合成本地工作区
LOCAL_OWNER = "local"
@dataclass(frozen=True)
class CurrentUser:
id: str
username: str
name: str
role: str
is_active: bool
def current_user_from_model(user: User) -> CurrentUser:
return CurrentUser(id=user.id, username=user.username, name=user.name, role=user.role, is_active=user.is_active)
def user_public(user: CurrentUser) -> dict:
return {
"id": user.id,
"username": user.username,
"name": user.name,
"role": user.role,
"isActive": user.is_active,
}
def get_current_user(request: Request, db: Session = Depends(get_db)) -> CurrentUser:
if not get_settings().admin_password:
return CurrentUser(id=LOCAL_OWNER, username=LOCAL_OWNER, name="本地用户", role="admin", is_active=True)
if getattr(request.state, "auth_via_basic", False):
user = db.scalar(select(User).where(User.username == get_settings().admin_username))
else:
user_id = getattr(request.state, "auth_user_id", None)
if not user_id:
raise HTTPException(status_code=401, detail="未登录")
user = db.get(User, user_id)
if user is None or not user.is_active:
raise HTTPException(status_code=401, detail="登录已失效,请重新登录")
return current_user_from_model(user)
def require_admin(user: CurrentUser = Depends(get_current_user)) -> CurrentUser:
if user.role != "admin":
raise HTTPException(status_code=403, detail="需要管理员权限")
return user
def resolve_owner(request: Request, user: CurrentUser = Depends(get_current_user)) -> str:
"""解析目标工作区:默认当前用户;admin 可通过 ?owner=<用户名> 查看/操作他人工作区。"""
owner = (request.query_params.get("owner") or "").strip() or user.username
if owner != user.username and user.role != "admin":
raise HTTPException(status_code=403, detail="无权访问其他用户的工作区")
return owner
def resolve_ingest_owner(request: Request, db: Session) -> str:
"""采集接口的目标工作区:带有效 Bearer 令牌时归属该用户,否则回落 INGEST_OWNER。
开放模式(未设置 ADMIN_PASSWORD)下页面归属 local 工作区,采集也归 local,
保证开放模式行为与旧版一致(采集候选人可直接在页面看到)。
"""
if not get_settings().admin_password:
return LOCAL_OWNER
header = request.headers.get("authorization", "")
token = header[7:].strip() if header.lower().startswith("bearer ") else ""
if token:
user_id = verify_token(token)
if user_id:
user = db.get(User, user_id)
if user is not None and user.is_active:
return user.username
return get_settings().ingest_owner or "admin"
......@@ -4,20 +4,52 @@ from fastapi.exceptions import RequestValidationError
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse, RedirectResponse
from fastapi.staticfiles import StaticFiles
from sqlalchemy import select
from starlette.exceptions import HTTPException as StarletteHTTPException
from backend.app.config import STATIC_ROOT, get_settings
from backend.app.db import create_all
from backend.app.routers import ai, health, ingest, resume, state
from backend.app.db import SessionLocal, create_all
from backend.app.models import User, utc_now_iso
from backend.app.routers import ai, auth, health, ingest, resume, state
from backend.app.security import DeploymentAuthMiddleware
from backend.app.services.auth_security import hash_password
def _ensure_admin() -> None:
"""设置了 ADMIN_PASSWORD 时,首次启动自动创建管理员账号(幂等)。"""
settings = get_settings()
if not settings.admin_password:
return
username = (settings.admin_username or "admin").strip()
session = SessionLocal()
try:
exists = session.scalar(select(User).where(User.username == username))
if exists is None:
now = utc_now_iso()
session.add(
User(
id=f"usr-{username}",
username=username,
name="管理员",
role="admin",
password_hash=hash_password(settings.admin_password),
is_active=True,
created_at=now,
updated_at=now,
)
)
session.commit()
finally:
session.close()
def create_app() -> FastAPI:
settings = get_settings()
settings.ensure_runtime_dirs()
create_all()
_ensure_admin()
app = FastAPI(title="招聘系统", version="0.5.0")
app = FastAPI(title="招聘系统", version="0.6.0")
app.add_middleware(
CORSMiddleware,
allow_origins=settings.cors_origins,
......@@ -28,6 +60,7 @@ def create_app() -> FastAPI:
app.add_middleware(DeploymentAuthMiddleware)
app.include_router(health.router, prefix="/api")
app.include_router(auth.router, prefix="/api")
app.include_router(state.router, prefix="/api")
app.include_router(resume.router, prefix="/api")
app.include_router(ai.router, prefix="/api")
......
......@@ -20,7 +20,8 @@ class SchemaMigration(Base):
class AppStateMeta(Base):
__tablename__ = "app_state_meta"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
# 主键即工作区归属:多用户模式下为 owner 用户名,开放模式为 "local"
id: Mapped[str] = mapped_column(String(80), primary_key=True)
updated_at: Mapped[str] = mapped_column(String(40), nullable=False)
imported_from: Mapped[str] = mapped_column(String(80), default="server", nullable=False)
raw_snapshot: Mapped[str | None] = mapped_column(Text)
......@@ -30,9 +31,12 @@ class User(Base):
__tablename__ = "users"
id: Mapped[str] = mapped_column(String(80), primary_key=True)
username: Mapped[str] = mapped_column(String(80), unique=True, nullable=False)
name: Mapped[str] = mapped_column(String(120), nullable=False)
role: Mapped[str] = mapped_column(String(40), default="admin", nullable=False)
password_hash: Mapped[str | None] = mapped_column(String(255))
is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
last_login_at: Mapped[str | None] = mapped_column(String(40))
created_at: Mapped[str] = mapped_column(String(40), default=utc_now_iso, nullable=False)
updated_at: Mapped[str] = mapped_column(String(40), default=utc_now_iso, nullable=False)
......@@ -41,6 +45,7 @@ class Job(Base):
__tablename__ = "jobs"
id: Mapped[str] = mapped_column(String(80), primary_key=True)
owner_id: Mapped[str] = mapped_column(String(80), nullable=False, index=True)
title: Mapped[str] = mapped_column(String(200), nullable=False)
department: Mapped[str | None] = mapped_column(String(120))
status: Mapped[str | None] = mapped_column(String(80))
......@@ -61,6 +66,7 @@ class JdVersion(Base):
__tablename__ = "jd_versions"
id: Mapped[str] = mapped_column(String(120), primary_key=True)
owner_id: Mapped[str] = mapped_column(String(80), nullable=False, index=True)
job_id: Mapped[str] = mapped_column(ForeignKey("jobs.id", ondelete="CASCADE"), nullable=False)
version: Mapped[str] = mapped_column(String(80), nullable=False)
status: Mapped[str | None] = mapped_column(String(80))
......@@ -74,6 +80,7 @@ class Candidate(Base):
__tablename__ = "candidates"
id: Mapped[str] = mapped_column(String(80), primary_key=True)
owner_id: Mapped[str] = mapped_column(String(80), nullable=False, index=True)
name: Mapped[str] = mapped_column(String(120), nullable=False)
primary_job_id: Mapped[str | None] = mapped_column(ForeignKey("jobs.id", ondelete="SET NULL"))
job_title: Mapped[str | None] = mapped_column(String(200))
......@@ -98,6 +105,7 @@ class CandidateApplication(Base):
__table_args__ = (UniqueConstraint("candidate_id", "job_id", name="uq_candidate_application"),)
id: Mapped[str] = mapped_column(String(160), primary_key=True)
owner_id: Mapped[str] = mapped_column(String(80), nullable=False, index=True)
candidate_id: Mapped[str] = mapped_column(ForeignKey("candidates.id", ondelete="CASCADE"), nullable=False)
job_id: Mapped[str | None] = mapped_column(ForeignKey("jobs.id", ondelete="SET NULL"))
stage: Mapped[str | None] = mapped_column(String(80))
......@@ -112,6 +120,7 @@ class ResumeFile(Base):
__tablename__ = "resume_files"
id: Mapped[str] = mapped_column(String(120), primary_key=True)
owner_id: Mapped[str] = mapped_column(String(80), nullable=False, index=True)
candidate_id: Mapped[str | None] = mapped_column(ForeignKey("candidates.id", ondelete="SET NULL"))
original_name: Mapped[str] = mapped_column(String(255), nullable=False)
file_type: Mapped[str | None] = mapped_column(String(40))
......@@ -128,6 +137,7 @@ class ResumeText(Base):
__tablename__ = "resume_texts"
id: Mapped[str] = mapped_column(String(120), primary_key=True)
owner_id: Mapped[str] = mapped_column(String(80), nullable=False, index=True)
resume_file_id: Mapped[str] = mapped_column(ForeignKey("resume_files.id", ondelete="CASCADE"), nullable=False)
extraction_method: Mapped[str] = mapped_column(String(80), default="text_layer", nullable=False)
raw_text: Mapped[str | None] = mapped_column(Text)
......@@ -141,6 +151,7 @@ class ResumeProfile(Base):
__tablename__ = "resume_profiles"
id: Mapped[str] = mapped_column(String(120), primary_key=True)
owner_id: Mapped[str] = mapped_column(String(80), nullable=False, index=True)
candidate_id: Mapped[str | None] = mapped_column(ForeignKey("candidates.id", ondelete="CASCADE"))
resume_file_id: Mapped[str | None] = mapped_column(ForeignKey("resume_files.id", ondelete="SET NULL"))
basic_info: Mapped[str] = mapped_column(Text, default="{}", nullable=False)
......@@ -154,6 +165,7 @@ class CandidateMatchReport(Base):
__tablename__ = "candidate_match_reports"
id: Mapped[str] = mapped_column(String(120), primary_key=True)
owner_id: Mapped[str] = mapped_column(String(80), nullable=False, index=True)
candidate_id: Mapped[str] = mapped_column(ForeignKey("candidates.id", ondelete="CASCADE"), nullable=False)
job_id: Mapped[str | None] = mapped_column(ForeignKey("jobs.id", ondelete="SET NULL"))
resume_profile_id: Mapped[str | None] = mapped_column(ForeignKey("resume_profiles.id", ondelete="SET NULL"))
......@@ -168,6 +180,7 @@ class Interview(Base):
__tablename__ = "interviews"
id: Mapped[str] = mapped_column(String(120), primary_key=True)
owner_id: Mapped[str] = mapped_column(String(80), nullable=False, index=True)
candidate_id: Mapped[str] = mapped_column(ForeignKey("candidates.id", ondelete="CASCADE"), nullable=False)
job_id: Mapped[str | None] = mapped_column(ForeignKey("jobs.id", ondelete="SET NULL"))
round: Mapped[str | None] = mapped_column(String(80))
......@@ -184,6 +197,7 @@ class Offer(Base):
__tablename__ = "offers"
id: Mapped[str] = mapped_column(String(120), primary_key=True)
owner_id: Mapped[str] = mapped_column(String(80), nullable=False, index=True)
candidate_id: Mapped[str] = mapped_column(ForeignKey("candidates.id", ondelete="CASCADE"), unique=True, nullable=False)
status: Mapped[str | None] = mapped_column(String(80))
salary: Mapped[str | None] = mapped_column(String(120))
......@@ -198,6 +212,7 @@ class Task(Base):
__tablename__ = "tasks"
id: Mapped[str] = mapped_column(String(120), primary_key=True)
owner_id: Mapped[str] = mapped_column(String(80), nullable=False, index=True)
title: Mapped[str] = mapped_column(String(200), nullable=False)
module: Mapped[str | None] = mapped_column(String(80))
target: Mapped[str | None] = mapped_column(String(200))
......@@ -218,6 +233,7 @@ class RecruitmentEvent(Base):
__tablename__ = "recruitment_events"
id: Mapped[str] = mapped_column(String(120), primary_key=True)
owner_id: Mapped[str] = mapped_column(String(80), nullable=False, index=True)
type: Mapped[str] = mapped_column(String(80), nullable=False)
at: Mapped[str] = mapped_column(String(40), nullable=False)
candidate_id: Mapped[str | None] = mapped_column(ForeignKey("candidates.id", ondelete="SET NULL"))
......@@ -234,6 +250,7 @@ class IngestItem(Base):
__tablename__ = "ingest_items"
id: Mapped[str] = mapped_column(String(120), primary_key=True)
owner_id: Mapped[str] = mapped_column(String(80), nullable=False, index=True)
idempotency_key: Mapped[str] = mapped_column(String(80), unique=True, nullable=False)
confirmed: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
confirmed_at: Mapped[str | None] = mapped_column(String(40))
......
......@@ -34,12 +34,14 @@ def build_ingest_key(raw: dict[str, Any], file_hash: str = "") -> str:
def upsert_ingest_candidate(
session: Session, raw: dict[str, Any], parsed: dict[str, Any], saved_file: dict[str, Any] | None
session: Session, owner: str, raw: dict[str, Any], parsed: dict[str, Any], saved_file: dict[str, Any] | None
) -> dict[str, Any]:
key = build_ingest_key(raw, saved_file.get("fileHash") if saved_file else "")
existing = session.scalar(select(IngestItem).where(IngestItem.idempotency_key == key))
existing = session.scalar(
select(IngestItem).where(IngestItem.idempotency_key == key, IngestItem.owner_id == owner)
)
if existing:
return {"ok": True, "skipped": True, "id": existing.id, "candidateId": existing.candidate_id}
return {"ok": True, "skipped": True, "id": existing.id, "candidateId": existing.candidate_id, "owner": owner}
at = utc_now_iso()
candidate_id = new_id("cand")
......@@ -86,6 +88,7 @@ def upsert_ingest_candidate(
session.add(
Candidate(
id=candidate_id,
owner_id=owner,
name=candidate["name"],
job_title=candidate["jobTitle"],
source=source,
......@@ -108,6 +111,7 @@ def upsert_ingest_candidate(
session.add(
ResumeFile(
id=resume_file_id,
owner_id=owner,
candidate_id=candidate_id,
original_name=saved_file["originalName"],
file_type=saved_file["fileType"],
......@@ -123,6 +127,7 @@ def upsert_ingest_candidate(
session.add(
ResumeText(
id=resume_text_id,
owner_id=owner,
resume_file_id=resume_file_id,
extraction_method="text_layer",
raw_text=resume_text,
......@@ -135,6 +140,7 @@ def upsert_ingest_candidate(
session.add(
ResumeProfile(
id=profile_id,
owner_id=owner,
candidate_id=candidate_id,
resume_file_id=resume_file_id,
basic_info=dump_json(
......@@ -161,6 +167,7 @@ def upsert_ingest_candidate(
session.add(
RecruitmentEvent(
id=event["id"],
owner_id=owner,
type=event["type"],
at=at,
candidate_id=candidate_id,
......@@ -185,6 +192,7 @@ def upsert_ingest_candidate(
session.add(
IngestItem(
id=ingest_id,
owner_id=owner,
idempotency_key=key,
confirmed=False,
candidate_id=candidate_id,
......@@ -195,13 +203,16 @@ def upsert_ingest_candidate(
created_at=at,
)
)
session.merge(AppStateMeta(id=1, updated_at=at, imported_from="ingest", raw_snapshot=None))
session.merge(AppStateMeta(id=owner, updated_at=at, imported_from="ingest", raw_snapshot=None))
session.commit()
return {"ok": True, "id": ingest_id, "candidateId": candidate_id, "resumeFileId": resume_file_id}
return {"ok": True, "id": ingest_id, "candidateId": candidate_id, "resumeFileId": resume_file_id, "owner": owner}
def list_pending_ingest(session: Session) -> list[dict[str, Any]]:
rows = session.scalars(select(IngestItem).where(IngestItem.confirmed.is_(False)).order_by(IngestItem.created_at.desc())).all()
def list_pending_ingest(session: Session, owner: str = "") -> list[dict[str, Any]]:
query = select(IngestItem).where(IngestItem.confirmed.is_(False))
if owner:
query = query.where(IngestItem.owner_id == owner)
rows = session.scalars(query.order_by(IngestItem.created_at.desc())).all()
queue = []
for row in rows:
payload = parse_json(row.payload_json, {})
......@@ -231,9 +242,11 @@ def list_pending_ingest(session: Session) -> list[dict[str, Any]]:
return queue
def confirm_ingest(session: Session, ids: list[str]) -> None:
def confirm_ingest(session: Session, ids: list[str], owner: str = "") -> None:
at = utc_now_iso()
query = select(IngestItem).where(IngestItem.confirmed.is_(False))
if owner:
query = query.where(IngestItem.owner_id == owner)
if ids:
query = query.where(IngestItem.id.in_(ids))
for item in session.scalars(query):
......
......@@ -9,6 +9,8 @@ from backend.app.models import (
Candidate,
CandidateApplication,
CandidateMatchReport,
IngestItem,
Interview,
JdVersion,
Job,
Offer,
......@@ -30,6 +32,23 @@ EMPTY_STATE: dict[str, Any] = {
"updatedAt": "",
}
# 所有带 owner_id 的业务表;replace_state 按此顺序清理当前工作区
_OWNERED_MODELS = (
CandidateMatchReport,
ResumeProfile,
ResumeText,
ResumeFile,
CandidateApplication,
RecruitmentEvent,
Task,
Offer,
Candidate,
Interview,
JdVersion,
IngestItem,
Job,
)
def _id(prefix: str) -> str:
return f"{prefix}-{uuid4().hex[:16]}"
......@@ -54,32 +73,53 @@ def state_with_defaults(state: dict[str, Any] | None) -> dict[str, Any]:
return merged
def database_is_empty(session: Session) -> bool:
def database_is_empty(session: Session, owner: str = "local") -> bool:
tables = [Job, Candidate, Offer, Task, RecruitmentEvent]
return all(session.scalar(select(func.count()).select_from(table)) == 0 for table in tables)
return all(
session.scalar(select(func.count()).select_from(table).where(table.owner_id == owner)) == 0 for table in tables
)
def read_state(session: Session) -> dict[str, Any]:
jobs = [parse_json(item.data, {}) for item in session.scalars(select(Job).order_by(Job.created_at, Job.id))]
def workspace_has_data(session: Session, owner: str) -> bool:
"""该 owner 的工作区是否存在任何业务数据(用于删除用户前的安全校验)。"""
for table in _OWNERED_MODELS:
if session.scalar(select(func.count()).select_from(table).where(table.owner_id == owner)):
return True
return False
def read_state(session: Session, owner: str = "local") -> dict[str, Any]:
jobs = [
parse_json(item.data, {})
for item in session.scalars(select(Job).where(Job.owner_id == owner).order_by(Job.created_at, Job.id))
]
candidates = [
parse_json(item.data, {})
for item in session.scalars(select(Candidate).order_by(Candidate.created_at.desc(), Candidate.id.desc()))
for item in session.scalars(
select(Candidate).where(Candidate.owner_id == owner).order_by(Candidate.created_at.desc(), Candidate.id.desc())
)
]
offers = [
parse_json(item.data, {})
for item in session.scalars(select(Offer).order_by(Offer.created_at.desc(), Offer.id.desc()))
for item in session.scalars(
select(Offer).where(Offer.owner_id == owner).order_by(Offer.created_at.desc(), Offer.id.desc())
)
]
tasks = [
parse_json(item.data, {})
for item in session.scalars(select(Task).order_by(Task.created_at.desc(), Task.id.desc()))
for item in session.scalars(
select(Task).where(Task.owner_id == owner).order_by(Task.created_at.desc(), Task.id.desc())
)
]
events = [
parse_json(item.data, {})
for item in session.scalars(
select(RecruitmentEvent).order_by(RecruitmentEvent.at.desc(), RecruitmentEvent.id.desc())
select(RecruitmentEvent)
.where(RecruitmentEvent.owner_id == owner)
.order_by(RecruitmentEvent.at.desc(), RecruitmentEvent.id.desc())
)
]
meta = session.get(AppStateMeta, 1)
meta = session.get(AppStateMeta, owner)
if not jobs and not candidates and not offers and not tasks and not events and meta and meta.raw_snapshot:
return state_with_defaults(parse_json(meta.raw_snapshot, EMPTY_STATE))
return state_with_defaults(
......@@ -94,24 +134,12 @@ def read_state(session: Session) -> dict[str, Any]:
)
def replace_state(session: Session, input_state: dict[str, Any], imported_from: str = "api") -> dict[str, Any]:
def replace_state(session: Session, owner: str, input_state: dict[str, Any], imported_from: str = "api") -> dict[str, Any]:
state = state_with_defaults(input_state)
state["updatedAt"] = utc_now_iso()
for model in (
CandidateMatchReport,
ResumeProfile,
ResumeText,
ResumeFile,
CandidateApplication,
RecruitmentEvent,
Task,
Offer,
Candidate,
JdVersion,
Job,
):
session.execute(delete(model))
for model in _OWNERED_MODELS:
session.execute(delete(model).where(model.owner_id == owner))
for job in state["jobs"]:
job_id = job.get("id") or _id("job")
......@@ -120,6 +148,7 @@ def replace_state(session: Session, input_state: dict[str, Any], imported_from:
session.add(
Job(
id=job_id,
owner_id=owner,
title=job.get("title") or "未命名岗位",
department=job.get("department") or "",
status=job.get("status") or "",
......@@ -141,6 +170,7 @@ def replace_state(session: Session, input_state: dict[str, Any], imported_from:
session.add(
JdVersion(
id=item.get("id") or f"{job_id}-jd-{index}",
owner_id=owner,
job_id=job_id,
version=item.get("version") or job.get("jdVersion") or f"v{index}",
status=item.get("status") or job.get("jdStatus") or "",
......@@ -160,6 +190,7 @@ def replace_state(session: Session, input_state: dict[str, Any], imported_from:
session.add(
Candidate(
id=candidate_id,
owner_id=owner,
name=candidate.get("name") or "未命名候选人",
primary_job_id=candidate.get("jobId") or None,
job_title=candidate.get("jobTitle") or "",
......@@ -183,6 +214,7 @@ def replace_state(session: Session, input_state: dict[str, Any], imported_from:
session.add(
CandidateApplication(
id=f"{candidate_id}-{candidate['jobId']}",
owner_id=owner,
candidate_id=candidate_id,
job_id=candidate["jobId"],
stage=candidate.get("stage") or "",
......@@ -209,6 +241,7 @@ def replace_state(session: Session, input_state: dict[str, Any], imported_from:
session.add(
Offer(
id=offer_id,
owner_id=owner,
candidate_id=offer["candidateId"],
status=offer.get("status") or "",
salary=offer.get("salary") or "",
......@@ -227,6 +260,7 @@ def replace_state(session: Session, input_state: dict[str, Any], imported_from:
session.add(
Task(
id=task_id,
owner_id=owner,
title=task.get("title") or "未命名待办",
module=task.get("module") or "",
target=task.get("target") or "",
......@@ -251,6 +285,7 @@ def replace_state(session: Session, input_state: dict[str, Any], imported_from:
session.add(
RecruitmentEvent(
id=event_id,
owner_id=owner,
type=event.get("type") or "unknown",
at=at,
candidate_id=event.get("candidateId") or None,
......@@ -264,10 +299,10 @@ def replace_state(session: Session, input_state: dict[str, Any], imported_from:
)
)
meta = session.get(AppStateMeta, 1) or AppStateMeta(id=1, updated_at=state["updatedAt"])
meta = session.get(AppStateMeta, owner) or AppStateMeta(id=owner, updated_at=state["updatedAt"])
meta.updated_at = state["updatedAt"]
meta.imported_from = imported_from
meta.raw_snapshot = dump_json(state)
session.merge(meta)
session.commit()
return read_state(session)
return read_state(session, owner)
from typing import Any
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy import func, select
from sqlalchemy.orm import Session
from backend.app.config import get_settings
from backend.app.db import get_db
from backend.app.dependencies import (
CurrentUser,
get_current_user,
require_admin,
user_public,
)
from backend.app.models import User, utc_now_iso
from backend.app.repositories.state_repository import workspace_has_data
from backend.app.services.auth_security import create_token, hash_password, verify_password
router = APIRouter()
_PASSWORD_MIN_LENGTH = 6
def _user_dict(user: User) -> dict[str, Any]:
return {
"id": user.id,
"username": user.username,
"name": user.name,
"role": user.role,
"isActive": user.is_active,
"lastLoginAt": user.last_login_at,
"createdAt": user.created_at,
}
def _validate_password(password: str) -> str:
if not password or len(password) < _PASSWORD_MIN_LENGTH:
raise HTTPException(status_code=400, detail=f"密码至少 {_PASSWORD_MIN_LENGTH} 位")
return password
@router.post("/auth/login")
def login(payload: dict[str, Any], db: Session = Depends(get_db)) -> dict[str, Any]:
if not get_settings().admin_password:
raise HTTPException(status_code=400, detail="系统未启用登录(未设置 ADMIN_PASSWORD)")
username = str(payload.get("username") or "").strip()
password = str(payload.get("password") or "")
if not username or not password:
raise HTTPException(status_code=400, detail="请输入用户名和密码")
user = db.scalar(select(User).where(User.username == username))
if user is None or not verify_password(password, user.password_hash):
raise HTTPException(status_code=401, detail="用户名或密码错误")
if not user.is_active:
raise HTTPException(status_code=403, detail="账号已被禁用,请联系管理员")
user.last_login_at = utc_now_iso()
db.commit()
return {"ok": True, "token": create_token(user.id), "user": _user_dict(user)}
@router.post("/auth/logout")
def logout() -> dict[str, bool]:
# 无状态令牌,客户端丢弃即可
return {"ok": True}
@router.get("/auth/me")
def me(user: CurrentUser = Depends(get_current_user)) -> dict[str, Any]:
return {"ok": True, "user": user_public(user)}
@router.post("/auth/change-password")
def change_password(payload: dict[str, Any], db: Session = Depends(get_db), user: CurrentUser = Depends(get_current_user)) -> dict[str, Any]:
if not get_settings().admin_password:
raise HTTPException(status_code=400, detail="开放模式无需修改密码")
old_password = str(payload.get("oldPassword") or "")
new_password = str(payload.get("newPassword") or "")
_validate_password(new_password)
model = db.get(User, user.id)
if model is None or not verify_password(old_password, model.password_hash):
raise HTTPException(status_code=400, detail="原密码不正确")
model.password_hash = hash_password(new_password)
model.updated_at = utc_now_iso()
db.commit()
return {"ok": True}
@router.get("/auth/users")
def list_users(db: Session = Depends(get_db), _: CurrentUser = Depends(require_admin)) -> dict[str, Any]:
rows = db.scalars(select(User).order_by(User.created_at)).all()
return {"ok": True, "users": [_user_dict(row) for row in rows]}
@router.post("/auth/users")
def create_user(payload: dict[str, Any], db: Session = Depends(get_db), _: CurrentUser = Depends(require_admin)) -> dict[str, Any]:
username = str(payload.get("username") or "").strip()
name = str(payload.get("name") or "").strip() or username
role = str(payload.get("role") or "user").strip()
password = _validate_password(str(payload.get("password") or ""))
if not username:
raise HTTPException(status_code=400, detail="用户名不能为空")
if role not in ("admin", "user"):
raise HTTPException(status_code=400, detail="角色只能是 admin 或 user")
if db.scalar(select(User).where(User.username == username)):
raise HTTPException(status_code=400, detail="用户名已存在")
now = utc_now_iso()
user = User(
id=f"usr-{username}",
username=username,
name=name,
role=role,
password_hash=hash_password(password),
is_active=True,
created_at=now,
updated_at=now,
)
db.add(user)
db.commit()
return {"ok": True, "user": _user_dict(user)}
@router.patch("/auth/users/{user_id}")
def update_user(
user_id: str, payload: dict[str, Any], db: Session = Depends(get_db), actor: CurrentUser = Depends(require_admin)
) -> dict[str, Any]:
user = db.get(User, user_id)
if user is None:
raise HTTPException(status_code=404, detail="用户不存在")
is_self = user.username == actor.username
if "role" in payload:
new_role = str(payload["role"]).strip()
if new_role not in ("admin", "user"):
raise HTTPException(status_code=400, detail="角色只能是 admin 或 user")
if is_self and new_role != user.role:
raise HTTPException(status_code=400, detail="不能修改自己的角色")
user.role = new_role
if "isActive" in payload:
active = bool(payload["isActive"])
if is_self and not active:
raise HTTPException(status_code=400, detail="不能禁用自己")
if not active and not _has_other_admin(db, user):
raise HTTPException(status_code=400, detail="不能禁用最后一个管理员")
user.is_active = active
if "name" in payload:
user.name = str(payload["name"]).strip() or user.username
if "password" in payload and str(payload.get("password") or ""):
user.password_hash = hash_password(_validate_password(str(payload["password"])))
user.updated_at = utc_now_iso()
db.commit()
return {"ok": True, "user": _user_dict(user)}
@router.delete("/auth/users/{user_id}")
def delete_user(user_id: str, db: Session = Depends(get_db), actor: CurrentUser = Depends(require_admin)) -> dict[str, Any]:
user = db.get(User, user_id)
if user is None:
raise HTTPException(status_code=404, detail="用户不存在")
if user.username == actor.username:
raise HTTPException(status_code=400, detail="不能删除自己")
if user.role == "admin" and not _has_other_admin(db, user):
raise HTTPException(status_code=400, detail="不能删除最后一个管理员")
if workspace_has_data(db, user.username):
raise HTTPException(status_code=400, detail="该用户工作区仍有数据,请先禁用或清理数据")
db.delete(user)
db.commit()
return {"ok": True}
def _has_other_admin(db: Session, exclude: User) -> bool:
count = db.scalar(select(func.count()).select_from(User).where(User.role == "admin", User.is_active.is_(True), User.id != exclude.id)) or 0
return count > 0
......@@ -6,6 +6,11 @@ from fastapi import APIRouter, Depends, Request
from sqlalchemy.orm import Session
from backend.app.db import get_db
from backend.app.dependencies import (
CurrentUser,
get_current_user,
resolve_ingest_owner,
)
from backend.app.repositories.ingest_repository import (
confirm_ingest,
list_pending_ingest,
......@@ -35,6 +40,7 @@ async def ingest(request: Request, db: Session = Depends(get_db)):
token_error = verify_ingest_token(request)
if token_error:
return token_error
owner = resolve_ingest_owner(request, db)
raw = await request.json()
pdf_url = str(raw.get("pdfUrl") or "").strip()
parsed: dict[str, Any] = {}
......@@ -51,17 +57,23 @@ async def ingest(request: Request, db: Session = Depends(get_db)):
parsed = {"parseWarning": f"PDF 下载或解析失败:{exc}", "resumeText": str(raw.get("summary") or "")}
elif raw.get("summary"):
parsed = {"resumeText": str(raw.get("summary") or ""), "name": raw.get("name") or "", "jobTitle": raw.get("jobTitle") or ""}
return upsert_ingest_candidate(db, raw, parsed, saved_file)
return upsert_ingest_candidate(db, owner, raw, parsed, saved_file)
@router.get("/ingest-queue")
def ingest_queue(db: Session = Depends(get_db)) -> dict[str, Any]:
queue = list_pending_ingest(db)
def ingest_queue(db: Session = Depends(get_db), user: CurrentUser = Depends(get_current_user)) -> dict[str, Any]:
owner = "" if user.role == "admin" else user.username
queue = list_pending_ingest(db, owner)
return {"queue": queue, "count": len(queue), "persisted": True}
@router.post("/ingest-queue/clear")
def clear_ingest_queue(request: Request, db: Session = Depends(get_db)) -> dict[str, bool]:
def clear_ingest_queue(
request: Request,
db: Session = Depends(get_db),
user: CurrentUser = Depends(get_current_user),
) -> dict[str, bool]:
owner = "" if user.role == "admin" else user.username
ids = [item.strip() for item in str(request.query_params.get("ids") or "").split(",") if item.strip()]
confirm_ingest(db, ids)
confirm_ingest(db, ids, owner)
return {"ok": True}
......@@ -4,6 +4,7 @@ from fastapi import APIRouter, Depends, Response
from sqlalchemy.orm import Session
from backend.app.db import get_db
from backend.app.dependencies import CurrentUser, get_current_user, resolve_owner
from backend.app.repositories.json_utils import pretty_json
from backend.app.repositories.state_repository import database_is_empty, read_state, replace_state
......@@ -11,26 +12,44 @@ router = APIRouter()
@router.get("/state")
def get_state(db: Session = Depends(get_db)) -> dict[str, Any]:
state = read_state(db)
return {**state, "meta": {"empty": database_is_empty(db), "persisted": True}}
def get_state(
db: Session = Depends(get_db),
_: CurrentUser = Depends(get_current_user),
owner: str = Depends(resolve_owner),
) -> dict[str, Any]:
state = read_state(db, owner)
return {**state, "meta": {"empty": database_is_empty(db, owner), "persisted": True, "owner": owner}}
@router.put("/state")
def put_state(payload: dict[str, Any], db: Session = Depends(get_db)) -> dict[str, Any]:
state = replace_state(db, payload, "api-state")
return {"ok": True, "state": state, "updatedAt": state["updatedAt"]}
def put_state(
payload: dict[str, Any],
db: Session = Depends(get_db),
_: CurrentUser = Depends(get_current_user),
owner: str = Depends(resolve_owner),
) -> dict[str, Any]:
state = replace_state(db, owner, payload, "api-state")
return {"ok": True, "state": state, "updatedAt": state["updatedAt"], "owner": owner}
@router.post("/import/local-state")
def import_local_state(payload: dict[str, Any], db: Session = Depends(get_db)) -> dict[str, Any]:
state = replace_state(db, payload, "localStorage-import")
return {"ok": True, "imported": True, "state": state, "updatedAt": state["updatedAt"]}
def import_local_state(
payload: dict[str, Any],
db: Session = Depends(get_db),
_: CurrentUser = Depends(get_current_user),
owner: str = Depends(resolve_owner),
) -> dict[str, Any]:
state = replace_state(db, owner, payload, "localStorage-import")
return {"ok": True, "imported": True, "state": state, "updatedAt": state["updatedAt"], "owner": owner}
@router.get("/export/state")
def export_state(db: Session = Depends(get_db)) -> Response:
state = read_state(db)
def export_state(
db: Session = Depends(get_db),
_: CurrentUser = Depends(get_current_user),
owner: str = Depends(resolve_owner),
) -> Response:
state = read_state(db, owner)
filename = f"recruitment-backup-{state['updatedAt'][:10]}.json"
return Response(
pretty_json(state),
......
......@@ -2,32 +2,50 @@ import base64
import secrets
from collections.abc import Awaitable, Callable
from fastapi import Request, Response
from fastapi import Request
from fastapi.responses import JSONResponse
from starlette.middleware.base import BaseHTTPMiddleware
from starlette.responses import Response
from backend.app.config import get_settings
from backend.app.db import SessionLocal
from backend.app.models import User
from backend.app.services.auth_security import verify_token
# 无需登录即可访问的 API 路径
_EXEMPT_API_PATHS = ("/api/ping", "/api/auth/login")
class DeploymentAuthMiddleware(BaseHTTPMiddleware):
async def dispatch(self, request: Request, call_next: Callable[[Request], Awaitable[Response]]) -> Response:
async def dispatch(self, request: Request, call_next: Callable[[Request], Awaitable[Response]]) -> Response: # noqa: ARG001
path = request.url.path
# 静态资源与登录页本身不设防,否则未登录时页面无法加载
if not path.startswith("/api"):
return await call_next(request)
settings = get_settings()
# 未设置 ADMIN_PASSWORD:开放模式,不校验任何请求
if not settings.admin_password:
return await call_next(request)
path = request.url.path
if path == "/api/ping":
if path in _EXEMPT_API_PATHS:
return await call_next(request)
if path == "/api/ingest" and settings.ingest_token:
# 配置了 INGEST_TOKEN 时,采集接口交给路由内令牌校验
if path in ("/api/ingest", "/api/jd/sync-external") and settings.ingest_token:
return await call_next(request)
if path == "/api/jd/sync-external" and settings.ingest_token:
return await call_next(request)
password = _read_basic_password(request.headers.get("authorization", ""))
header = request.headers.get("authorization", "")
# 兼容旧 Basic Auth(任意用户名 + 正确密码),映射为管理员
password = _read_basic_password(header)
if password and secrets.compare_digest(password, settings.admin_password):
request.state.auth_via_basic = True
return await call_next(request)
# 多用户 Bearer 会话令牌
user_id = _read_bearer_user_id(header)
if user_id and _user_is_active(user_id):
request.state.auth_user_id = user_id
return await call_next(request)
return Response(
"需要登录后访问招聘系统",
return JSONResponse(
{"error": "需要登录后访问招聘系统"},
status_code=401,
media_type="text/plain; charset=utf-8",
headers={"WWW-Authenticate": 'Basic realm="Recruitment System", charset="UTF-8"'},
headers={"WWW-Authenticate": "Bearer"},
)
......@@ -41,6 +59,24 @@ def _read_basic_password(header: str) -> str:
return decoded.split(":", 1)[1] if ":" in decoded else ""
def _read_bearer_user_id(header: str) -> str | None:
if not header.lower().startswith("bearer "):
return None
token = header[7:].strip()
if not token:
return None
return verify_token(token)
def _user_is_active(user_id: str) -> bool:
session = SessionLocal()
try:
user = session.get(User, user_id)
return bool(user and user.is_active)
finally:
session.close()
def verify_ingest_token(request: Request) -> Response | None:
settings = get_settings()
if not settings.ingest_token:
......@@ -48,4 +84,4 @@ def verify_ingest_token(request: Request) -> Response | None:
token = request.headers.get("X-Ingest-Token") or request.query_params.get("token") or ""
if secrets.compare_digest(token, settings.ingest_token):
return None
return Response('{"error":"采集接口令牌无效"}', status_code=401, media_type="application/json")
return JSONResponse({"error": "采集接口令牌无效"}, status_code=401)
"""密码哈希与无状态会话令牌(标准库实现,零第三方依赖)。
- 密码哈希:PBKDF2-HMAC-SHA256,格式 `pbkdf2_sha256$迭代$盐$哈希`,常数时间比较。
- 会话令牌:HMAC-SHA256(SESSION_SECRET) 签名,载荷含用户 id 与过期时间,无状态,无需落库。
"""
import base64
import hashlib
import hmac
import os
import time
from backend.app.config import get_settings
_PBKDF2_ITERATIONS = 260_000
_TOKEN_VERSION = "v1"
# 令牌过期容忍窗口(秒),用于边界处的浮点误差
_TOKEN_LEEWAY = 60
def hash_password(password: str) -> str:
salt = os.urandom(16)
digest = hashlib.pbkdf2_hmac("sha256", password.encode("utf-8"), salt, _PBKDF2_ITERATIONS)
return "pbkdf2_sha256${}${}${}".format(
_PBKDF2_ITERATIONS,
base64.b64encode(salt).decode("ascii"),
base64.b64encode(digest).decode("ascii"),
)
def verify_password(password: str, stored_hash: str | None) -> bool:
if not password or not stored_hash:
return False
try:
algorithm, iterations, salt_b64, digest_b64 = stored_hash.split("$", 3)
except ValueError:
return False
if algorithm != "pbkdf2_sha256":
return False
try:
salt = base64.b64decode(salt_b64)
expected = base64.b64decode(digest_b64)
except Exception:
return False
actual = hashlib.pbkdf2_hmac("sha256", password.encode("utf-8"), salt, int(iterations))
return hmac.compare_digest(actual, expected)
def create_token(user_id: str) -> str:
"""生成签名会话令牌,有效期默认 7 天(SESSION_TTL_DAYS 可配)。"""
settings = get_settings()
ttl_days = max(1, int(settings.session_token_ttl_days or 7))
expires = int(time.time()) + ttl_days * 24 * 3600
payload = f"{_TOKEN_VERSION}.{user_id}.{expires}"
signature = hmac.new(settings.session_secret.encode("utf-8"), payload.encode("utf-8"), hashlib.sha256).hexdigest()
return f"{payload}.{signature}"
def verify_token(token: str) -> str | None:
"""校验令牌,返回用户 id;无效或过期返回 None。"""
settings = get_settings()
parts = token.split(".")
if len(parts) != 4 or parts[0] != _TOKEN_VERSION:
return None
_, user_id, expires, signature = parts
payload = f"{parts[0]}.{user_id}.{expires}"
expected = hmac.new(settings.session_secret.encode("utf-8"), payload.encode("utf-8"), hashlib.sha256).hexdigest()
if not hmac.compare_digest(signature, expected):
return None
try:
if int(expires) + _TOKEN_LEEWAY < time.time():
return None
except ValueError:
return None
return user_id
"""多用户账号管理 CLI(需在已配置 ADMIN_PASSWORD 的登录模式下使用)。
用法(项目根目录执行,推荐使用 backend/.venv 环境):
backend\\.venv\\Scripts\\python.exe backend/scripts/create_user.py create alice --name "Alice" --role user
backend\\.venv\\Scripts\\python.exe backend/scripts/create_user.py reset alice --password "newpass123"
backend\\.venv\\Scripts\\python.exe backend/scripts/create_user.py disable alice
backend\\.venv\\Scripts\\python.exe backend/scripts/create_user.py enable alice
backend\\.venv\\Scripts\\python.exe backend/scripts/create_user.py list
说明:
- create 的密码通过 --password 传入;不传则交互式输入(不回显)。
- 与界面「用户管理」等价,供无前端/脚本场景使用。
"""
from __future__ import annotations
import argparse
import getpass
import sys
from pathlib import Path
PROJECT_ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(PROJECT_ROOT))
from backend.app.config import get_settings # noqa: E402
from backend.app.db import SessionLocal, create_all # noqa: E402
from backend.app.models import User, utc_now_iso # noqa: E402
from backend.app.services.auth_security import hash_password # noqa: E402
# 确保 users 表存在(开发环境依赖 create_all 建表,CLI 同理会先建表)
create_all()
def _get_or_exit(session, username: str) -> User:
from sqlalchemy import select
user = session.scalar(select(User).where(User.username == username))
if user is None:
sys.exit(f"用户 {username} 不存在")
return user
def _prompt_password(args, label: str) -> str:
if args.password:
return args.password
if not sys.stdin.isatty():
sys.exit(f"未提供 --password,且当前输入不可交互,无法设置{label}")
return getpass.getpass(f"{label}: ")
def cmd_create(args) -> int:
from sqlalchemy import select
settings = get_settings()
if not settings.admin_password:
sys.exit("未设置 ADMIN_PASSWORD,系统处于开放模式,无需创建用户。")
session = SessionLocal()
try:
username = (args.username or "").strip()
if not username:
sys.exit("用户名不能为空")
if session.scalar(select(User).where(User.username == username)):
sys.exit(f"用户名 {username} 已存在")
password = _prompt_password(args, "密码")
if len(password) < 6:
sys.exit("密码至少 6 位")
now = utc_now_iso()
session.add(
User(
id=f"usr-{username}",
username=username,
name=args.name or username,
role=args.role,
password_hash=hash_password(password),
is_active=True,
created_at=now,
updated_at=now,
)
)
session.commit()
print(f"已创建用户 {username}(角色 {args.role})")
finally:
session.close()
return 0
def cmd_reset(args) -> int:
session = SessionLocal()
try:
user = _get_or_exit(session, args.username)
password = _prompt_password(args, "新密码")
if len(password) < 6:
sys.exit("密码至少 6 位")
user.password_hash = hash_password(password)
user.updated_at = utc_now_iso()
session.commit()
print(f"已重置用户 {args.username} 的密码")
finally:
session.close()
return 0
def cmd_set_active(args, active: bool) -> int:
session = SessionLocal()
try:
user = _get_or_exit(session, args.username)
user.is_active = active
user.updated_at = utc_now_iso()
session.commit()
print(f"已{'禁用' if not active else '启用'}用户 {args.username}")
finally:
session.close()
return 0
def cmd_list(args) -> int: # noqa: ARG001
from sqlalchemy import select
session = SessionLocal()
try:
rows = session.scalars(select(User).order_by(User.created_at)).all()
if not rows:
print("(无用户)")
return 0
for user in rows:
state = "启用" if user.is_active else "禁用"
print(f"{user.username}\t{user.name}\t{user.role}\t{state}\t创建于 {user.created_at[:19]}")
finally:
session.close()
return 0
def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(description="多用户账号管理 CLI。")
sub = parser.add_subparsers(dest="command", required=True)
p_create = sub.add_parser("create", help="创建用户")
p_create.add_argument("username")
p_create.add_argument("--name", default="")
p_create.add_argument("--role", choices=["admin", "user"], default="user")
p_create.add_argument("--password", default="")
p_create.set_defaults(func=cmd_create)
p_reset = sub.add_parser("reset", help="重置密码")
p_reset.add_argument("username")
p_reset.add_argument("--password", default="")
p_reset.set_defaults(func=cmd_reset)
p_disable = sub.add_parser("disable", help="禁用用户")
p_disable.add_argument("username")
p_disable.set_defaults(func=lambda a: cmd_set_active(a, False))
p_enable = sub.add_parser("enable", help="启用用户")
p_enable.add_argument("username")
p_enable.set_defaults(func=lambda a: cmd_set_active(a, True))
sub.add_parser("list", help="列出用户").set_defaults(func=cmd_list)
args = parser.parse_args(argv)
return args.func(args)
if __name__ == "__main__":
raise SystemExit(main())
"""多用户登录与工作区隔离测试。
- 开放模式(ADMIN_PASSWORD 为空):沿用 conftest 的 client fixture,行为与旧版一致。
- 关闭模式(ADMIN_PASSWORD 已设置):用本文件 auth_client fixture(独立 SQLite + 环境变量)。
"""
import pytest
from fastapi.testclient import TestClient
@pytest.fixture()
def auth_client(tmp_path, monkeypatch):
monkeypatch.setenv("RECRUITMENT_SKIP_ENV_FILES", "1")
monkeypatch.setenv("DATABASE_URL", f"sqlite:///{(tmp_path / 'test-auth.sqlite').as_posix()}")
monkeypatch.setenv("DATA_DIR", str(tmp_path / "data"))
monkeypatch.setenv("FILES_DIR", str(tmp_path / "uploads"))
monkeypatch.setenv("ADMIN_PASSWORD", "admin-secret")
monkeypatch.setenv("ADMIN_USERNAME", "admin")
monkeypatch.setenv("SESSION_SECRET", "test-session-secret")
monkeypatch.setenv("INGEST_TOKEN", "")
monkeypatch.setenv("LLM_API_KEY", "")
monkeypatch.setenv("DEEPSEEK_API_KEY", "")
monkeypatch.setenv("OPENAI_API_KEY", "")
import backend.app.config as config
import backend.app.db as db
config.get_settings.cache_clear()
db.engine.dispose()
db.settings = config.get_settings()
db.engine = db.create_engine(
db.normalize_database_url(db.settings.database_url),
connect_args={"check_same_thread": False},
future=True,
)
db.SessionLocal.configure(bind=db.engine)
from backend.app.main import create_app
app = create_app()
with TestClient(app) as test_client:
yield test_client
db.engine.dispose()
config.get_settings.cache_clear()
def _login(client, username, password):
return client.post("/api/auth/login", json={"username": username, "password": password})
def _auth_headers(token):
return {"Authorization": f"Bearer {token}"}
# ---- 开放模式 ----
def test_open_mode_allows_state_access(client):
assert client.get("/api/state").status_code == 200
assert client.get("/api/auth/me").json()["user"]["username"] == "local"
def test_open_mode_login_rejected(client):
# 开放模式下登录接口明确拒绝
response = _login(client, "admin", "anything")
assert response.status_code == 400
# ---- 关闭模式:认证 ----
def test_closed_mode_requires_auth(auth_client):
assert auth_client.get("/api/state").status_code == 401
assert auth_client.get("/api/auth/me").status_code == 401
def test_login_success(auth_client):
response = _login(auth_client, "admin", "admin-secret")
assert response.status_code == 200
body = response.json()
assert body["ok"] is True
assert body["token"]
assert body["user"]["username"] == "admin"
assert body["user"]["role"] == "admin"
def test_login_wrong_password(auth_client):
assert _login(auth_client, "admin", "wrong").status_code == 401
def test_login_unknown_user(auth_client):
assert _login(auth_client, "nobody", "admin-secret").status_code == 401
def test_me_with_token(auth_client):
token = _login(auth_client, "admin", "admin-secret").json()["token"]
response = auth_client.get("/api/auth/me", headers=_auth_headers(token))
assert response.status_code == 200
assert response.json()["user"]["username"] == "admin"
def test_state_with_token(auth_client):
token = _login(auth_client, "admin", "admin-secret").json()["token"]
headers = _auth_headers(token)
payload = {
"jobs": [{"id": "job-1", "title": "高级前端工程师", "department": "技术部"}],
"candidates": [],
"offers": [],
"tasks": [],
"eventLog": [],
}
put = auth_client.put("/api/state", json=payload, headers=headers)
assert put.status_code == 200
loaded = auth_client.get("/api/state", headers=headers).json()
assert loaded["jobs"][0]["title"] == "高级前端工程师"
# ---- 关闭模式:工作区隔离 ----
def test_workspace_isolation(auth_client):
admin_token = _login(auth_client, "admin", "admin-secret").json()["token"]
admin_headers = _auth_headers(admin_token)
# admin 建一个普通用户
created = auth_client.post(
"/api/auth/users",
json={"username": "alice", "name": "Alice", "role": "user", "password": "alice123"},
headers=admin_headers,
)
assert created.status_code == 200
alice_token = _login(auth_client, "alice", "alice123").json()["token"]
alice_headers = _auth_headers(alice_token)
# admin 写入自己的工作区
auth_client.put(
"/api/state",
json={"jobs": [{"id": "job-admin", "title": "管理员岗位"}], "candidates": [], "offers": [], "tasks": [], "eventLog": []},
headers=admin_headers,
)
# alice 的工作区应该是空的
alice_state = auth_client.get("/api/state", headers=alice_headers).json()
assert alice_state["meta"]["empty"] is True
assert alice_state["jobs"] == []
# alice 写入自己的工作区
auth_client.put(
"/api/state",
json={"jobs": [{"id": "job-alice", "title": "Alice 岗位"}], "candidates": [], "offers": [], "tasks": [], "eventLog": []},
headers=alice_headers,
)
# 双方互不可见
admin_state = auth_client.get("/api/state", headers=admin_headers).json()
assert [job["title"] for job in admin_state["jobs"]] == ["管理员岗位"]
alice_state = auth_client.get("/api/state", headers=alice_headers).json()
assert [job["title"] for job in alice_state["jobs"]] == ["Alice 岗位"]
def test_user_cannot_switch_workspace(auth_client):
admin_token = _login(auth_client, "admin", "admin-secret").json()["token"]
admin_headers = _auth_headers(admin_token)
auth_client.post(
"/api/auth/users",
json={"username": "bob", "name": "Bob", "role": "user", "password": "bob12345"},
headers=admin_headers,
)
bob_token = _login(auth_client, "bob", "bob12345").json()["token"]
bob_headers = _auth_headers(bob_token)
assert auth_client.get("/api/state", headers=bob_headers, params={"owner": "admin"}).status_code == 403
assert auth_client.get("/api/state", headers=bob_headers).status_code == 200
def test_admin_switch_workspace(auth_client):
admin_token = _login(auth_client, "admin", "admin-secret").json()["token"]
admin_headers = _auth_headers(admin_token)
auth_client.post(
"/api/auth/users",
json={"username": "carol", "name": "Carol", "role": "user", "password": "carol123"},
headers=admin_headers,
)
carol_token = _login(auth_client, "carol", "carol123").json()["token"]
carol_headers = _auth_headers(carol_token)
auth_client.put(
"/api/state",
json={"jobs": [{"id": "job-carol", "title": "Carol 岗位"}], "candidates": [], "offers": [], "tasks": [], "eventLog": []},
headers=carol_headers,
)
# admin 通过 ?owner= 查看他人工作区
viewed = auth_client.get("/api/state", headers=admin_headers, params={"owner": "carol"}).json()
assert [job["title"] for job in viewed["jobs"]] == ["Carol 岗位"]
# ---- 关闭模式:用户管理与权限 ----
def test_user_management_requires_admin(auth_client):
admin_token = _login(auth_client, "admin", "admin-secret").json()["token"]
admin_headers = _auth_headers(admin_token)
auth_client.post(
"/api/auth/users",
json={"username": "dave", "name": "Dave", "role": "user", "password": "dave1234"},
headers=admin_headers,
)
dave_token = _login(auth_client, "dave", "dave1234").json()["token"]
dave_headers = _auth_headers(dave_token)
assert auth_client.get("/api/auth/users", headers=dave_headers).status_code == 403
assert auth_client.post("/api/auth/users", json={"username": "x", "role": "user", "password": "123456"}, headers=dave_headers).status_code == 403
def test_admin_list_and_disable_user(auth_client):
admin_token = _login(auth_client, "admin", "admin-secret").json()["token"]
admin_headers = _auth_headers(admin_token)
created = auth_client.post(
"/api/auth/users",
json={"username": "eve", "name": "Eve", "role": "user", "password": "eve12345"},
headers=admin_headers,
)
assert created.status_code == 200
user_id = created.json()["user"]["id"]
listed = auth_client.get("/api/auth/users", headers=admin_headers).json()
assert any(user["username"] == "eve" for user in listed["users"])
# 禁用后无法登录
assert auth_client.patch(f"/api/auth/users/{user_id}", json={"isActive": False}, headers=admin_headers).status_code == 200
assert _login(auth_client, "eve", "eve12345").status_code == 403
# 禁用后旧 token 立即失效
assert auth_client.patch(f"/api/auth/users/{user_id}", json={"isActive": True}, headers=admin_headers).status_code == 200
eve_token = _login(auth_client, "eve", "eve12345").json()["token"]
assert auth_client.patch(f"/api/auth/users/{user_id}", json={"isActive": False}, headers=admin_headers).status_code == 200
assert auth_client.get("/api/auth/me", headers=_auth_headers(eve_token)).status_code == 401
def test_change_password(auth_client):
admin_token = _login(auth_client, "admin", "admin-secret").json()["token"]
admin_headers = _auth_headers(admin_token)
# 原密码错误
bad = auth_client.post(
"/api/auth/change-password",
json={"oldPassword": "wrong", "newPassword": "newpass123"},
headers=admin_headers,
)
assert bad.status_code == 400
ok = auth_client.post(
"/api/auth/change-password",
json={"oldPassword": "admin-secret", "newPassword": "newpass123"},
headers=admin_headers,
)
assert ok.status_code == 200
assert _login(auth_client, "admin", "admin-secret").status_code == 401
assert _login(auth_client, "admin", "newpass123").status_code == 200
# 招聘系统项目交接文档
- 版本:0.5.0
- 版本:0.6.0
- 位置:`D:\Hgny\Py\recruit-sys`
- 生成日期:2026-08-31(基于当前代码实际分析)
- 技术栈:FastAPI(Python 3.11+)+ SQLAlchemy 2 / SQLite + Vue 3 / Vite + Chrome MV3 扩展
......@@ -30,7 +30,9 @@
| 能力 | 端点 | 代码位置 |
| --- | --- | --- |
| 健康检查 | `GET /api/ping`(含待确认采集数) | [backend/app/routers/health.py](backend/app/routers/health.py) |
| 全量状态读写 | `GET/PUT /api/state``POST /api/import/local-state``GET /api/export/state` | [backend/app/routers/state.py](backend/app/routers/state.py) |
| 多用户认证 | `POST /api/auth/login``POST /api/auth/logout``GET /api/auth/me``POST /api/auth/change-password` | [backend/app/routers/auth.py](backend/app/routers/auth.py) |
| 用户管理(admin) | `GET/POST /api/auth/users``PATCH/DELETE /api/auth/users/{id}` | [backend/app/routers/auth.py](backend/app/routers/auth.py) |
| 全量状态读写 | `GET/PUT /api/state``POST /api/import/local-state``GET /api/export/state`(按工作区隔离,admin 可 `?owner=` 切换) | [backend/app/routers/state.py](backend/app/routers/state.py) |
| 简历解析 / 上传 | `POST /api/parse-resume``POST /api/upload-resume``GET /api/resumes/{filename}` | [backend/app/routers/resume.py](backend/app/routers/resume.py) |
| AI 简历分析 | `POST /api/analyze-resume`(LLM 优先,回退本地规则) | [backend/app/routers/ai.py](backend/app/routers/ai.py)[backend/app/services/llm.py](backend/app/services/llm.py) |
| JD 生成 | `POST /api/generate-jd`(QwenPaw → LLM → 本地结构化) | [backend/app/routers/ai.py](backend/app/routers/ai.py)[backend/app/services/llm.py](backend/app/services/llm.py) |
......@@ -72,18 +74,19 @@
recruit-sys/
├── backend/ # FastAPI 后端(Python 包,根目录下以 backend.app.* 导入)
│ ├── app/
│ │ ├── main.py # 应用工厂:CORS、Basic Auth、路由挂载、静态托管、异常处理
│ │ ├── main.py # 应用工厂:CORS、认证中间件、admin 引导、路由挂载、静态托管、异常处理
│ │ ├── config.py # Settings(.env / .env.local 读取,Pydantic)
│ │ ├── db.py # SQLAlchemy engine/session,create_all 自动建表
│ │ ├── models.py # 14 张表 ORM 模型
│ │ ├── security.py # Basic Auth 中间件 + ingest token 校验
│ │ ├── routers/ # 5 个路由模块(health/state/resume/ai/ingest)
│ │ ├── db.py # SQLAlchemy engine/session,create_all 自动建表 + 旧库 owner_id 升级
│ │ ├── models.py # 16 张表 ORM 模型(业务表全部带 owner_id)
│ │ ├── security.py # 认证中间件(Bearer 令牌 + Basic 兼容)+ ingest token 校验
│ │ ├── dependencies.py # 认证/角色/工作区依赖注入(get_current_user/require_admin/resolve_owner)
│ │ ├── routers/ # 6 个路由模块(health/auth/state/resume/ai/ingest)
│ │ ├── services/ # llm / jd_grill / qwenpaw_client / resume_parser(_core) /
│ │ │ # file_storage / external_sync
│ │ │ # file_storage / external_sync / auth_security(密码哈希+令牌)
│ │ └── repositories/ # state_repository / ingest_repository / json_utils
│ ├── alembic/ # 迁移(versions/0001_initial.py 全量建表)
│ ├── scripts/ # parse_resume.py(简历解析 CLI)、start_server.py(启动后端,可选构建/同步前端)、start-deepseek.example.ps1(历史示例)
│ ├── tests/ # 5 个测试文件(详见 §6)
│ ├── scripts/ # start_server.py(启动,可选构建/同步前端)、create_user.py(用户管理 CLI)、parse_resume.py(简历解析 CLI)、start-deepseek.example.ps1(历史示例)
│ ├── tests/ # 7 个测试文件(含 test_auth.py,详见 §6)
│ ├── static/ # 前端发布目录(FastAPI 挂载 /;由 start_server.py --with-frontend 同步,初始为空)
│ ├── pyproject.toml # 依赖 + pytest/ruff 配置
│ ├── requirements.txt # 入口:-e .[dev]
......@@ -91,11 +94,11 @@ recruit-sys/
│ └── start-server.cmd # Windows 一键启动(含日志输出)
├── vue-app/ # Vue 3 前端
│ ├── src/
│ │ ├── api/ # axios 实例 + recruitment.js(9 个 API 封装)
│ │ ├── stores/recruitment.js # Pinia store(领域动作、事件回填、任务关联
│ │ ├── router/ # 路由 + 导航配置(5 个模块)
│ │ ├── layouts/AppShell.vue # 侧边栏 + 顶部栏 + 页面容器
│ │ ├── views/ # 7 个视图(todo/dashboard/jobs 列表+详情+编辑/resumes/offer)
│ │ ├── api/ # axios 实例(Bearer 注入 + 401 跳登录)+ auth.js(认证 API)+ recruitment.js
│ │ ├── stores/ # recruitment.js(领域 store,按工作区分键)+ user.js(登录态 store
│ │ ├── router/ # 路由 + 登录/角色守卫 + 导航配置(5 个模块)
│ │ ├── layouts/AppShell.vue # 侧边栏 + 顶部栏(用户菜单/工作区切换器)+ 页面容器
│ │ ├── views/ # 9 个视图(login/user-management/todo/dashboard/jobs 列表+详情+编辑/resumes/offer)
│ │ ├── components/ # common / todo / dashboard / jobs / resumes / offer
│ │ └── utils/ # 纯业务逻辑(匹配、评分、推断、流程等 22 个文件)
│ ├── dist/ # 生产构建产物(当前不在 git 跟踪范围)
......@@ -121,17 +124,20 @@ recruit-sys/
### 4.2 数据模型与状态同步机制(重点)
- 全库共 14 张表([models.py](backend/app/models.py)):SchemaMigration、AppStateMeta、User、Job、JdVersion、Candidate、CandidateApplication、ResumeFile、ResumeText、ResumeProfile、CandidateMatchReport、Interview、Offer、Task、RecruitmentEvent、IngestItem。
- **核心机制**:业务数据以「整包 JSON 状态」为事实源。`GET /api/state` 把各表读回合并为一个 `{jobs, candidates, offers, tasks, eventLog, updatedAt}` 对象;`PUT /api/state` 则先清空再全量重写。`AppStateMeta.raw_snapshot` 存整包快照,供无数据时回读。
- 这意味着:**这不是增量/事件溯源架构,而是「快照全量替换」**。多端并发写同一库会互相覆盖(last-write-wins)。
- 全库共 16 张表([models.py](backend/app/models.py)):SchemaMigration、AppStateMeta、User、Job、JdVersion、Candidate、CandidateApplication、ResumeFile、ResumeText、ResumeProfile、CandidateMatchReport、Interview、Offer、Task、RecruitmentEvent、IngestItem。
- **多用户与工作区隔离**:全部业务表带 `owner_id`(归属用户名);`AppStateMeta.id` 即工作区键(多用户下为用户名,开放模式为 `local`)。`User` 表存 `username`(唯一)/`role`(admin|user)/`password_hash`(PBKDF2)/`is_active`/`last_login_at`,登录令牌为 HMAC-SHA256(SESSION_SECRET) 签名的无状态 Bearer 令牌(见 [services/auth_security.py](backend/app/services/auth_security.py))。
- **核心机制**:业务数据以「整包 JSON 状态」为事实源。`GET /api/state` 把当前 owner 的各表读回合并为一个 `{jobs, candidates, offers, tasks, eventLog, updatedAt}` 对象;`PUT /api/state` 则先按 owner 清空再全量重写。`AppStateMeta.raw_snapshot` 存整包快照,供无数据时回读。
- 这意味着:**这不是增量/事件溯源架构,而是「快照全量替换」**。同一工作区内多端并发写会互相覆盖(last-write-wins);不同用户之间通过 `owner_id` 天然隔离。
- 简历文件走独立存储(uploads/resumes/,hash 命名),与状态快照分离,见 [file_storage.py](backend/app/services/file_storage.py)
- 采集链路(`/api/ingest`[ingest_repository.py](backend/app/repositories/ingest_repository.py)):幂等键 = sha256(source|sourceUrl|pdfUrl|fileHash|name|jobTitle),重复提交返回 `skipped`;每条采集产生 Candidate + ResumeFile + ResumeText + ResumeProfile + RecruitmentEvent + IngestItem(未确认),前端看板通过 `/api/ingest-queue` 确认后进正式列表。
- 采集链路(`/api/ingest`[ingest_repository.py](backend/app/repositories/ingest_repository.py)):幂等键 = sha256(source|sourceUrl|pdfUrl|fileHash|name|jobTitle),按 owner 去重,重复提交返回 `skipped`;每条采集产生 Candidate + ResumeFile + ResumeText + ResumeProfile + RecruitmentEvent + IngestItem(未确认),前端看板通过 `/api/ingest-queue` 确认后进正式列表。
### 4.3 前端架构
- Vue 3 Composition API + Pinia + Vue Router + Element Plus;无 TypeScript,无组件测试。
- **状态同步策略**`stores/recruitment.js` 以 localStorage(key `recruitment-system-mvp-v1`,见 [constants.js](vue-app/src/utils/constants.js))为主事实源,向后端 `/api/state` 全量保存;启动时尝试读取后端状态,两者有冲突时以后端为准(`meta.persisted` 为 true 时用后端)。
- 大量业务逻辑在 `src/utils/`(22 个纯函数模块):匹配打分、字段推断、JD 质量、任务流程、Offer 模板等。规则与后端 `resume_parser_core.py` 的学校/专业/技能词典**是两套独立副本**(前端 `constants.js``project985Schools` 等与后端 [resume_parser_core.py](backend/app/services/resume_parser_core.py) 中集合重复),改一处需同步另一处。
- **状态同步策略**`stores/recruitment.js` 以 localStorage(key 按工作区分:`recruitment-system-<owner>-v1`,开放模式 `local` 沿用旧 key `recruitment-system-mvp-v1`,见 [constants.js](vue-app/src/utils/constants.js))为主事实源,向后端 `/api/state` 全量保存;启动时尝试读取后端状态,两者有冲突时以后端为准(`meta.persisted` 为 true 时用后端)。
- **登录态**`stores/user.js` 存 Bearer 令牌与用户信息(key `recruitment-system-auth-v1`);axios 请求拦截器自动注入 `Authorization: Bearer`,401 时清空凭证并跳 `/login`;路由守卫 `router/index.js``beforeEach` 做未登录/已登录/角色(admin)三层跳转。
- **工作区切换(admin)**:AppShell 顶栏工作区下拉选择目标用户,`recruitment` store 的 `switchWorkspace()` 先保存当前区、再整包加载目标区数据。
- 大量业务逻辑在 `src/utils/`(22 个纯函数模块):匹配打分、字段推断、JD 质量、任务流程、Offer 模板等。规则与后端 [resume_parser_core.py](backend/app/services/resume_parser_core.py) 共享同一份学校/专业/技能词典(`shared/resume-dictionaries.json`),改一处即可。
---
......@@ -164,8 +170,9 @@ backend\.venv\Scripts\python.exe -m pip install -e "backend[dev]"
# ── 第 3 步:配置 ──
copy backend\.env.example backend\.env
# 必改项(首次部门部署):
# ADMIN_PASSWORD=强密码 # 设置后启用 HTTP Basic 登录
# SESSION_SECRET=随机长字符串 # 部署环境务必改
# ADMIN_PASSWORD=强密码 # 登录总开关:设置后启用多用户登录;为空则开放模式
# ADMIN_USERNAME=admin # 首次启动自动创建的管理员用户名(可选,默认 admin)
# SESSION_SECRET=随机长字符串 # 会话令牌签名密钥,部署环境务必改(如 python -c "import secrets;print(secrets.token_hex(32))")
# INGEST_TOKEN=随机字符串 # 设置后扩展采集接口需要 token
# LLM_API_KEY / LLM_BASE_URL / LLM_MODEL # 如需要 AI 能力
......@@ -192,11 +199,21 @@ backend\.venv\Scripts\python.exe backend/scripts/start_server.py --with-frontend
```
- 首次启动自动创建 `data/`(SQLite)与 `uploads/` 目录并自动建表([db.py 的 create_all](backend/app/db.py)),**不需要**先跑迁移。
- 访问 `http://127.0.0.1:4177/` 会 307 跳转到 `/index.html``/docs` 是 FastAPI 交互文档。
- 设置了 `ADMIN_PASSWORD` 时,首次启动自动创建管理员账号(`ADMIN_USERNAME`,默认 `admin`,密码取 `ADMIN_PASSWORD`),见 [main.py 的 `_ensure_admin`](backend/app/main.py)
- 访问 `http://127.0.0.1:4177/` 会 307 跳转到 `/index.html`;未登录时前端路由守卫跳 `/login``/docs` 是 FastAPI 交互文档。
- 启动脚本说明与参数见 [backend/scripts/start_server.py](backend/scripts/start_server.py)
- Windows 一键启动脚本:[backend/start-server.cmd](backend/start-server.cmd)(同样 4177,`--reload`**已配置为使用 `.venv` 启动**,日志写入 `backend/server.out.log` / `server.err.log`)。
### 5.4 启动前端(开发模式)
### 5.4 多用户与工作区
- **启用登录**`backend/.env``ADMIN_PASSWORD`(+ 可选 `ADMIN_USERNAME`)后重启;未设置则开放模式(不要求登录,数据归 `local` 工作区,行为同旧版)。
- **登录**:浏览器访问 `/login`,用账号密码登录,返回 Bearer 令牌存 localStorage(key `recruitment-system-auth-v1`)。
- **角色**`admin`(用户管理 + 全部工作区读写)/ `user`(自己工作区读写)。用户管理入口在侧边栏「用户管理」(仅 admin 可见)。
- **工作区隔离**:普通用户只能读写自己的工作区(`?owner=` 越权返回 403);admin 可在顶栏工作区下拉切换查看/操作任意用户数据。
- **用户管理 CLI**(无界面场景):`backend/scripts/create_user.py`,支持 `create`/`list`/`reset`/`disable`/`enable`(见 README §5)。
- **旧库升级**:旧版 SQLite 首次启动自动为业务表补 `owner_id` 列并回填 `local`、重建 `users`/`app_state_meta`(无数据时),历史数据不丢(见 [db.py 的 `_refresh_schema_for_owner_columns`](backend/app/db.py))。
### 5.5 启动前端(开发模式)
```powershell
cd D:\Hgny\Py\recruit-sys\vue-app
......@@ -207,7 +224,7 @@ npm run dev # http://127.0.0.1:5173
- Vite 把 `/api` 代理到 `http://127.0.0.1:4177`(可用环境变量 `RECRUITMENT_BACKEND_PORT` 改后端端口,见 [vite.config.js](vue-app/vite.config.js))。
- 开发时浏览器访问 5173,后端必须已启动。
### 5.5 生产部署(同源托管)
### 5.7 生产部署(同源托管)
```powershell
# 一条命令完成:构建前端 → 同步到 backend/static → 启动后端
......@@ -219,13 +236,13 @@ backend\.venv\Scripts\python.exe backend/scripts/start_server.py --with-frontend
- 前端 API 地址默认空(同源);如需指向其他后端,注入全局变量 `window.RECRUITMENT_API_BASE_URL`(见 [vue-app/src/api/index.js](vue-app/src/api/index.js))。
- 也可以手动同步:`cd vue-app && npm run build` 后把 `dist/` 复制到 `backend/static/`
### 5.6 浏览器扩展(可选)
### 5.8 浏览器扩展(可选)
1. 启动后端(扩展连 4177);
2. Chrome/Edge 扩展页开启开发者模式 → 加载已解压的扩展程序 → 选 `browser-extension/`
3. 在 BOSS 直聘 / 猎聘候选人页面点击右下角宠物按钮采集;弹窗可改 API 地址并测试连接。
### 5.7 数据库迁移(如需要)
### 5.9 数据库迁移(如需要)
项目默认 `create_all` 自动建表;Alembic 迁移已配置([alembic/env.py](backend/alembic/env.py) 从 Settings 读 DATABASE_URL),切换 MySQL 或需要版本化管理时:
......@@ -242,10 +259,10 @@ cd D:\Hgny\Py\recruit-sys
backend\.venv\Scripts\python.exe -m pytest -c backend/pyproject.toml
```
**当前结果:11 通过,0 失败**(已实测)。
**当前结果:25 通过,0 失败**(已实测)。
- 说明:曾有一个失败(`test_static_security.py`),原因是后端静态目录为空且该测试断言旧版 `app.js` 存在。已把静态托管链路改为 `start_server.py --with-frontend` 自动同步,并将该测试改为断言 Vue 前端 `index.html` 实际引用的 JS 资源,现已全绿。
- 测试覆盖:AI 本地回退([test_ai.py](backend/tests/test_ai.py))、访谈状态机全流程 14 题推进 + 已有字段跳过、采集幂等去重 + 队列确认([test_ingest.py](backend/tests/test_ingest.py))、状态整包 roundtrip + localStorage 导入([test_state.py](backend/tests/test_state.py))、简历解析字段提取 + CLI --help([test_resume_parser.py](backend/tests/test_resume_parser.py))、首页跳转 + Vue 静态资源 + CORS 白名单 + .env 不可被静态托管泄露([test_static_security.py](backend/tests/test_static_security.py))。
- 测试覆盖:AI 本地回退([test_ai.py](backend/tests/test_ai.py))、访谈状态机全流程 14 题推进 + 已有字段跳过、采集幂等去重 + 队列确认([test_ingest.py](backend/tests/test_ingest.py))、状态整包 roundtrip + localStorage 导入([test_state.py](backend/tests/test_state.py))、简历解析字段提取 + CLI --help([test_resume_parser.py](backend/tests/test_resume_parser.py))、首页跳转 + Vue 静态资源 + CORS 白名单 + .env 不可被静态托管泄露([test_static_security.py](backend/tests/test_static_security.py)**多用户登录 / 工作区隔离 / 权限 / 改密 / 用户管理([test_auth.py](backend/tests/test_auth.py),14 个用例)**
- 测试环境隔离:conftest 用临时目录的 SQLite + 清空 env,见 [backend/tests/conftest.py](backend/tests/conftest.py)
---
......@@ -254,11 +271,13 @@ backend\.venv\Scripts\python.exe -m pytest -c backend/pyproject.toml
| 内容 | 位置 | 说明 |
| --- | --- | --- |
| SQLite 数据库 | `./data/recruitment.sqlite`(相对项目根目录;`DATABASE_URL` 可改) | 14 张表;业务数据以整包 JSON 快照为准 |
| SQLite 数据库 | `./data/recruitment.sqlite`(相对项目根目录;`DATABASE_URL` 可改) | 16 张表;业务数据以整包 JSON 快照为准,按 `owner_id` 分工作区 |
| 用户与登录 | `users` 表 | 账号/角色/密码哈希(PBKDF2)/启用状态;登录令牌为签名 Bearer(`SESSION_SECRET`) |
| 简历文件 | `./uploads/resumes/``FILES_DIR` 可改) | hash 命名,`GET /api/resumes/{storedName}` 下载 |
| 状态快照 | `app_state_meta.raw_snapshot` | 整包 JSON 备份,空库时回读 |
| 导出备份 | `GET /api/export/state` | 下载 `recruitment-backup-YYYY-MM-DD.json`,可经 `/api/import/local-state` 导回 |
| 前端 localStorage | key `recruitment-system-mvp-v1` | 前端主事实源,向后端全量同步 |
| 状态快照 | `app_state_meta.raw_snapshot` | 整包 JSON 备份(按工作区一行),空库时回读 |
| 导出备份 | `GET /api/export/state` | 下载 `recruitment-backup-YYYY-MM-DD.json`,可经 `/api/import/local-state` 导回(当前/指定工作区) |
| 前端 localStorage | key `recruitment-system-<owner>-v1`(开放模式 `local` 用旧 key `recruitment-system-mvp-v1`) | 前端主事实源,向后端全量同步 |
| 前端登录态 | key `recruitment-system-auth-v1` | Bearer 令牌 + 用户信息 |
---
......@@ -271,16 +290,18 @@ backend\.venv\Scripts\python.exe -m pytest -c backend/pyproject.toml
### 8.2 设计边界(非缺陷,使用前须知)
- **状态同步是「整包覆盖」而非增量**`PUT /api/state` 会全表删后重建([state_repository.py 的 replace_state](backend/app/repositories/state_repository.py))。多端同时使用会互相覆盖,**不适合多人并发操作**;单机/单 HR 使用无问题。
- **状态同步是「整包覆盖」而非增量**`PUT /api/state` 会按当前工作区全表删后重建([state_repository.py 的 replace_state](backend/app/repositories/state_repository.py))。**同一工作区内**多端同时编辑会互相覆盖(last-write-wins),建议同一工作区单用户或串行使用;不同用户之间已按 `owner_id` 隔离。
- **令牌无状态、不主动吊销**:登录令牌是 HMAC 签名的无状态 Bearer,重置密码不会使已签发令牌立即失效(禁用用户会因 `is_active` 检查立即失效);如需要严格吊销需引入 sessions 表(后续可做)。
- **`localStorage` 与后端状态的一致性**:前端先写 localStorage 再同步后端;若 localStorage 有旧数据且后端空库,会以 localStorage 为准导入(`/api/import/local-state` 逻辑存在)。**清浏览器数据前请先导出备份**`GET /api/export/state`)。
- **简历解析是纯规则、无 OCR**:扫描件/图片型 PDF 提取不到文本,`parse_status` 会标记 `needs_manual`,需人工补录(见 [ingest_repository.py](backend/app/repositories/ingest_repository.py)`parse_status` 判断)。解析器面向中文文本简历优化,英文简历字段提取能力弱。
- **`reference/` 是外部参考副本**`browser-extension/reference/` 下的 boss-sync-plugin 是别家方案的复制品,仅作参考,不参与运行,也不应被当作本项目代码维护(已进 .gitignore)。
### 8.3 部署安全清单(部署前必须知道)
- 不设 `ADMIN_PASSWORD` 时系统**完全开放**(无登录),仅适合本机使用;部署到内网/公网必须设密码 + 改 `SESSION_SECRET` + 设 `INGEST_TOKEN`
- 不设 `ADMIN_PASSWORD` 时系统**完全开放**(无登录,数据归 `local` 工作区),仅适合本机使用;部署到内网/公网必须设 `ADMIN_PASSWORD` + 改 `SESSION_SECRET`(随机串)+ 设 `INGEST_TOKEN`
- 扩展采集会把**带 cookie 的 PDF URL** 发给后端(后端用这些 cookie 下载 PDF)——cookie 只在本机 4177 传输,但请确认网络环境可信。
- CORS 默认只放行 5173 来源;同源部署时页面自己调自己不受 CORS 限制。
- **文件级隔离边界**:简历文件按内容哈希共享存储,任何已登录用户凭 URL(含哈希)可访问;未做严格文件级权限隔离,多用户场景下如需严格隔离需后续扩展。
---
......@@ -288,14 +309,16 @@ backend\.venv\Scripts\python.exe -m pytest -c backend/pyproject.toml
按优先级排序(基于当前代码缺口):
1. **并发写入保护**`replace_state` 的整包覆盖对多端是隐患;如要多人使用,应改为按实体增量 upsert(现在 `replace_state` 已经是按实体拆表写入,可在此基础上做单实体 PUT),或明确「单用户使用」边界并写进文档。
2. **补充前端测试**:目前只有后端 pytest,前端无任何测试(`vue-app/package.json` 无 test script);至少对 `src/utils/` 的纯逻辑(匹配、评分)加单元测试。
3. **简历解析增强**:OCR(扫描件)、更多格式(doc/xls)、英文简历;`parse_status=needs_manual` 时提供「重新解析 / 人工修正」的 UI 入口(目前仅打标,无前端引导)。
4. **QwenPaw 与 LLM 链路验证**`QWENPAW_ENABLED` 与真实 Key 的端到端未在本机验证(本机只有 local-structured 路径有测试覆盖);部署 AI 能力前应先手动验证 `/api/analyze-resume``/api/generate-jd` 在配置 Key 后的行为。
5. **部署文档化**:补一份部署检查单(改密码/secret/token、数据目录外置、备份策略),把 §8.3 的注意事项固化为流程。
6. **前端构建依赖**`start_server.py --with-frontend` 依赖 `vue-app/node_modules`(已 gitignore),新机器首次需先 `cd vue-app && npm install`;脚本已给出明确报错提示。
1. **并发写入保护**:同一工作区内 `replace_state` 整包覆盖对多端是隐患;如多人要共用一个工作区,应改为按实体增量 upsert(现在 `replace_state` 已经是按实体拆表写入,可在此基础上做单实体 PUT),或明确「同一工作区单用户」边界并写进文档(已在 §8.2 标注)。
2. **令牌吊销**:无状态 Bearer 令牌无法服务端主动吊销;如需「改密后旧令牌立即失效」,引入 sessions 表存有效令牌。
3. **文件级隔离**:简历文件当前按哈希共享,未按 owner 做访问控制;多用户严格隔离需在 `GET /api/resumes/{filename}` 校验文件归属。
4. **补充前端测试**:目前只有后端 pytest(25 个),前端无任何测试(`vue-app/package.json` 无 test script);至少对 `src/utils/` 的纯逻辑(匹配、评分)与登录守卫加单元测试。
5. **简历解析增强**:OCR(扫描件)、更多格式(doc/xls)、英文简历;`parse_status=needs_manual` 时提供「重新解析 / 人工修正」的 UI 入口(目前仅打标,无前端引导)。
6. **QwenPaw 与 LLM 链路验证**`QWENPAW_ENABLED` 与真实 Key 的端到端未在本机验证(本机只有 local-structured 路径有测试覆盖);部署 AI 能力前应先手动验证 `/api/analyze-resume``/api/generate-jd` 在配置 Key 后的行为。
7. **部署文档化**:补一份部署检查单(改密码/secret/token、数据目录外置、备份策略),把 §8.3 的注意事项固化为流程。
8. **前端构建依赖**`start_server.py --with-frontend` 依赖 `vue-app/node_modules`(已 gitignore),新机器首次需先 `cd vue-app && npm install`;脚本已给出明确报错提示。
> 已完成的修复:启动脚本统一(`backend/scripts/start_server.py`,删除 `backend/package.json` 与失效脚本 `sync_frontend_static.py`/`legacy-server.mjs`);静态托管链路(`--with-frontend` 构建+同步);词典去重(前后端共享 `shared/resume-dictionaries.json`);测试断言更新为 Vue 前端资源(11 过 0 挂)。
> 已完成的修复:启动脚本统一(`backend/scripts/start_server.py`,删除 `backend/package.json` 与失效脚本 `sync_frontend_static.py`/`legacy-server.mjs`);静态托管链路(`--with-frontend` 构建+同步);词典去重(前后端共享 `shared/resume-dictionaries.json`);测试断言更新为 Vue 前端资源;**多用户登录 + 按用户隔离工作区 + 用户管理 + 认证中间件(Bearer/Basic 兼容)+ 用户管理 CLI(`create_user.py`)+ 旧库自动升级**(后端测试 25 过 0 挂)。
---
......@@ -303,22 +326,27 @@ backend\.venv\Scripts\python.exe -m pytest -c backend/pyproject.toml
| 关注点 | 文件 |
| --- | --- |
| 应用入口 / 路由挂载 / 中间件 | [backend/app/main.py](backend/app/main.py) |
| 应用入口 / 路由挂载 / 中间件 / admin 引导 | [backend/app/main.py](backend/app/main.py) |
| 配置与 env 读取 | [backend/app/config.py](backend/app/config.py) |
| 数据库与自动建表 | [backend/app/db.py](backend/app/db.py) |
| ORM 模型(14 表) | [backend/app/models.py](backend/app/models.py) |
| 认证 / 采集令牌 | [backend/app/security.py](backend/app/security.py) |
| 状态整包同步 | [backend/app/repositories/state_repository.py](backend/app/repositories/state_repository.py) |
| 采集入库链路 | [backend/app/repositories/ingest_repository.py](backend/app/repositories/ingest_repository.py) |
| 数据库与自动建表 + 旧库 owner_id 升级 | [backend/app/db.py](backend/app/db.py) |
| ORM 模型(16 表,业务表带 owner_id) | [backend/app/models.py](backend/app/models.py) |
| 认证中间件(Bearer 令牌 + Basic 兼容)/ 采集令牌 | [backend/app/security.py](backend/app/security.py) |
| 认证依赖注入(当前用户/角色/工作区) | [backend/app/dependencies.py](backend/app/dependencies.py) |
| 密码哈希 + 会话令牌 | [backend/app/services/auth_security.py](backend/app/services/auth_security.py) |
| 登录 / 用户管理路由 | [backend/app/routers/auth.py](backend/app/routers/auth.py) |
| 状态整包同步(按工作区) | [backend/app/repositories/state_repository.py](backend/app/repositories/state_repository.py) |
| 采集入库链路(按工作区) | [backend/app/repositories/ingest_repository.py](backend/app/repositories/ingest_repository.py) |
| 用户管理 CLI | [backend/scripts/create_user.py](backend/scripts/create_user.py) |
| AI 分析 / JD 生成 / 脱敏 | [backend/app/services/llm.py](backend/app/services/llm.py) |
| 逼问式访谈状态机 | [backend/app/services/jd_grill.py](backend/app/services/jd_grill.py) |
| QwenPaw 客户端 | [backend/app/services/qwenpaw_client.py](backend/app/services/qwenpaw_client.py) |
| 简历解析器(纯规则) | [backend/app/services/resume_parser_core.py](backend/app/services/resume_parser_core.py) |
| 文件存储 | [backend/app/services/file_storage.py](backend/app/services/file_storage.py) |
| 对外 JD 同步 | [backend/app/services/external_sync.py](backend/app/services/external_sync.py) |
| 前端入口 / 路由 / store | [vue-app/src/main.js](vue-app/src/main.js) / [router/index.js](vue-app/src/router/index.js) / [stores/recruitment.js](vue-app/src/stores/recruitment.js) |
| 前端 API 封装 | [vue-app/src/api/recruitment.js](vue-app/src/api/recruitment.js) |
| 前端常量(词典/种子数据) | [vue-app/src/utils/constants.js](vue-app/src/utils/constants.js)(词典来自共享 [shared/resume-dictionaries.json](shared/resume-dictionaries.json)) |
| 前端入口 / 路由与守卫 / store | [vue-app/src/main.js](vue-app/src/main.js) / [router/index.js](vue-app/src/router/index.js) / [stores/recruitment.js](vue-app/src/stores/recruitment.js) / [stores/user.js](vue-app/src/stores/user.js) |
| 前端 API 封装(认证 / 业务) | [vue-app/src/api/auth.js](vue-app/src/api/auth.js) / [recruitment.js](vue-app/src/api/recruitment.js) |
| 前端登录页 / 用户管理页 | [vue-app/src/views/LoginView.vue](vue-app/src/views/LoginView.vue) / [UserManageView.vue](vue-app/src/views/UserManageView.vue) |
| 前端常量(词典/种子数据/存储键) | [vue-app/src/utils/constants.js](vue-app/src/utils/constants.js)(词典来自共享 [shared/resume-dictionaries.json](shared/resume-dictionaries.json)) |
| 扩展采集逻辑 | [browser-extension/content.js](browser-extension/content.js) / [background.js](browser-extension/background.js) |
| 扩展清单 | [browser-extension/manifest.json](browser-extension/manifest.json) |
| 测试 | [backend/tests/](backend/tests/) |
| 测试 | [backend/tests/](backend/tests/)(含 [test_auth.py](backend/tests/test_auth.py) |
import http from '@/api'
export async function login(payload) {
const { data } = await http.post('/api/auth/login', payload)
if (data.error) throw new Error(data.error)
return data
}
export async function logout() {
const { data } = await http.post('/api/auth/logout')
return data
}
export async function fetchMe() {
const { data } = await http.get('/api/auth/me')
if (data.error) throw new Error(data.error)
return data
}
export async function changePassword(payload) {
const { data } = await http.post('/api/auth/change-password', payload)
if (data.error) throw new Error(data.error)
return data
}
export async function listUsers() {
const { data } = await http.get('/api/auth/users')
if (data.error) throw new Error(data.error)
return data
}
export async function createUser(payload) {
const { data } = await http.post('/api/auth/users', payload)
if (data.error) throw new Error(data.error)
return data
}
export async function updateUser(id, payload) {
const { data } = await http.patch(`/api/auth/users/${id}`, payload)
if (data.error) throw new Error(data.error)
return data
}
export async function deleteUser(id) {
const { data } = await http.delete(`/api/auth/users/${id}`)
if (data.error) throw new Error(data.error)
return data
}
import axios from 'axios'
import { AUTH_STORAGE_KEY } from '@/utils/constants'
// 后端地址:优先取运行时注入的变量,否则默认 4177。
// 开发时 Vite 会把 /api 代理到后端,因此 baseURL 用空值即可。
const runtimeBase = (() => {
......@@ -14,10 +16,43 @@ const http = axios.create({
timeout: 60000,
})
function readToken() {
try {
const raw = localStorage.getItem(AUTH_STORAGE_KEY)
if (!raw) return ''
const parsed = JSON.parse(raw)
return parsed.token || ''
} catch {
return ''
}
}
function clearAuth() {
try {
localStorage.removeItem(AUTH_STORAGE_KEY)
} catch {
/* ignore */
}
}
http.interceptors.request.use((config) => {
const token = readToken()
if (token) config.headers.Authorization = `Bearer ${token}`
return config
})
http.interceptors.response.use(
(response) => response,
(error) => {
const status = error?.response?.status
const message = error?.response?.data?.error || error?.message || '请求失败'
const url = error?.config?.url || ''
if (status === 401 && !url.includes('/api/auth/login')) {
clearAuth()
if (typeof window !== 'undefined' && window.location.pathname !== '/login') {
window.location.href = '/login'
}
}
return Promise.reject(new Error(message))
}
)
......
import http from '@/api'
export async function fetchState() {
const { data } = await http.get('/api/state')
export async function fetchState(owner = '') {
const { data } = await http.get('/api/state', { params: owner ? { owner } : {} })
return data
}
export async function saveState(state) {
const { data } = await http.put('/api/state', state)
export async function saveState(state, owner = '') {
const { data } = await http.put('/api/state', state, { params: owner ? { owner } : {} })
return data
}
export async function importLocalState(state) {
const { data } = await http.post('/api/import/local-state', state)
export async function importLocalState(state, owner = '') {
const { data } = await http.post('/api/import/local-state', state, { params: owner ? { owner } : {} })
return data
}
......
......@@ -147,6 +147,31 @@ textarea {
strong {
color: var(--ink);
}
.workspace-switch {
width: 130px;
}
.user-entry {
display: inline-flex;
align-items: center;
gap: 8px;
cursor: pointer;
padding: 4px 8px;
border-radius: 8px;
color: var(--ink);
&:hover {
background: var(--line);
}
.user-name {
max-width: 140px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
}
}
}
......
<script setup>
import { computed, onMounted, watch } from 'vue'
import { computed, onMounted, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus'
import { useRecruitmentStore } from '@/stores/recruitment'
import { useUserStore } from '@/stores/user'
import { navItems, navPageMeta } from '@/router/nav'
import NavItem from '@/components/common/NavItem.vue'
const store = useRecruitmentStore()
const userStore = useUserStore()
const route = useRoute()
const router = useRouter()
const activeRouteKey = computed(() => {
const name = route.name
if (name === 'user-management') return 'user-management'
if (name === 'dashboard') return 'dashboard'
if (['jobs', 'job-detail', 'job-edit', 'job-create'].includes(name)) return 'jobs'
if (name === 'resumes') return 'resumes'
......@@ -32,9 +36,13 @@ const todayText = computed(() =>
new Date().toLocaleDateString('zh-CN', { year: 'numeric', month: '2-digit', day: '2-digit' })
)
const displayName = computed(() => userStore.user?.name || userStore.user?.username || '未登录')
const isAdmin = computed(() => userStore.isAdmin)
const navigate = (key) => {
store.setPage(key)
router.push({ name: key })
if (key === 'user-management') router.push({ name: 'user-management' })
else router.push({ name: key })
}
const navCount = (key) => {
......@@ -69,12 +77,91 @@ const handleImport = async (event) => {
const handleReset = () => {
if (store.candidates.length === 0) return
const ok = window.confirm('确认重置为示例数据吗?将覆盖当前本地数据。')
const ok = window.confirm('确认重置为示例数据吗?将覆盖当前工作区本地数据。')
if (ok) store.resetData()
}
// ---- 用户菜单 ----
const changePwdOpen = ref(false)
const pwdForm = ref({ oldPassword: '', newPassword: '', confirm: '' })
function handleUserCommand(command) {
if (command === 'change-pwd') {
pwdForm.value = { oldPassword: '', newPassword: '', confirm: '' }
changePwdOpen.value = true
} else if (command === 'logout') {
handleLogout()
}
}
async function handleChangePassword() {
if (!pwdForm.value.oldPassword || !pwdForm.value.newPassword) {
ElMessage.warning('请填写原密码和新密码')
return
}
if (pwdForm.value.newPassword.length < 6) {
ElMessage.warning('新密码至少 6 位')
return
}
if (pwdForm.value.newPassword !== pwdForm.value.confirm) {
ElMessage.warning('两次输入的新密码不一致')
return
}
try {
await userStore.changePassword(pwdForm.value.oldPassword, pwdForm.value.newPassword)
ElMessage.success('密码已修改')
changePwdOpen.value = false
} catch (error) {
ElMessage.error(error.message || '修改失败')
}
}
async function handleLogout() {
try {
await ElMessageBox.confirm('确认退出登录?', '退出登录', {
confirmButtonText: '退出',
cancelButtonText: '取消',
type: 'warning',
})
} catch {
return
}
userStore.logout()
router.push({ name: 'login' })
}
// ---- 工作区切换(admin)----
const workspaceOptions = computed(() => {
const usernames = new Set(
[userStore.user?.username, ...(userStore.userList || []).map((user) => user.username)].filter(Boolean)
)
return [...usernames].map((username) => ({ value: username, label: username }))
})
const workspaceValue = computed({
get: () => store.workspaceOwner,
set: async (owner) => {
try {
await store.switchWorkspace(owner)
ElMessage.success(`已切换到 ${owner} 的工作区`)
} catch (error) {
ElMessage.error(error.message || '切换失败')
}
},
})
async function loadWorkspaceOptions() {
if (!isAdmin.value) return
try {
await userStore.loadUsers()
} catch {
/* 用户列表加载失败不阻断主流程 */
}
}
onMounted(() => {
store.boot()
loadWorkspaceOptions()
})
</script>
......@@ -94,6 +181,14 @@ onMounted(() => {
:count="navCount(item.key)"
@click="navigate(item.key)"
/>
<NavItem
v-if="isAdmin"
key="user-management"
:item="{ key: 'user-management', label: '用户管理' }"
:active="store.activePage === 'user-management'"
:count="0"
@click="navigate('user-management')"
/>
</nav>
<div class="side-actions">
<el-button type="primary" size="small" @click="handleExport">导出备份</el-button>
......@@ -114,6 +209,28 @@ onMounted(() => {
<div class="top-meta">
<span>{{ store.saveStateText }}</span>
<strong>{{ todayText }}</strong>
<el-select v-if="isAdmin" v-model="workspaceValue" class="workspace-switch" size="small" placeholder="工作区">
<el-option
v-for="option in workspaceOptions"
:key="option.value"
:label="option.label"
:value="option.value"
/>
</el-select>
<el-dropdown trigger="click" @command="handleUserCommand">
<span class="user-entry">
<el-avatar :size="26">{{ displayName.slice(0, 1).toUpperCase() }}</el-avatar>
<span class="user-name">{{ displayName }}</span>
<el-tag v-if="isAdmin" size="small" type="danger">管理员</el-tag>
<el-icon><arrow-down /></el-icon>
</span>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item command="change-pwd">修改密码</el-dropdown-item>
<el-dropdown-item command="logout" divided>退出登录</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</div>
</header>
<section class="page">
......@@ -123,4 +240,22 @@ onMounted(() => {
</div>
<div class="toast" v-if="false"></div>
<el-dialog v-model="changePwdOpen" title="修改密码" width="420px">
<el-form label-width="90px">
<el-form-item label="原密码">
<el-input v-model="pwdForm.oldPassword" type="password" show-password />
</el-form-item>
<el-form-item label="新密码">
<el-input v-model="pwdForm.newPassword" type="password" show-password />
</el-form-item>
<el-form-item label="确认新密码">
<el-input v-model="pwdForm.confirm" type="password" show-password />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="changePwdOpen = false">取消</el-button>
<el-button type="primary" @click="handleChangePassword">保存</el-button>
</template>
</el-dialog>
</template>
import { createRouter, createWebHistory } from 'vue-router'
import { useUserStore } from '@/stores/user'
import { navItems, navPageMeta } from './nav'
const routes = [
{
path: '/login',
name: 'login',
component: () => import('@/views/LoginView.vue'),
meta: { public: true },
},
{
path: '/',
name: 'shell',
......@@ -48,6 +55,12 @@ const routes = [
name: 'offer',
component: () => import('@/views/OfferView.vue'),
},
{
path: '/users',
name: 'user-management',
component: () => import('@/views/UserManageView.vue'),
meta: { requiresAdmin: true },
},
],
},
]
......@@ -60,6 +73,19 @@ const router = createRouter({
},
})
// 登录守卫:未认证跳 /login,已认证访问 /login 回首页,admin 专属页校验角色。
router.beforeEach(async (to) => {
const userStore = useUserStore()
await userStore.ensureAuth()
const authenticated = userStore.isAuthenticated
if (to.meta?.public) {
return authenticated ? { name: 'todo' } : true
}
if (!authenticated) return { name: 'login' }
if (to.meta?.requiresAdmin && !userStore.isAdmin) return { name: 'todo' }
return true
})
export { navItems, navPageMeta }
export default router
import { defineStore } from 'pinia'
import { STORAGE_KEY, seedState, STAGE_ORDER } from '@/utils/constants'
import { AUTH_STORAGE_KEY, workspaceStorageKey, seedState } from '@/utils/constants'
import * as api from '@/api/recruitment'
import {
enrichCandidateFields,
......@@ -195,17 +195,35 @@ function currentPeriodKey() {
return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}`
}
// 初始工作区归属:优先取已登录用户,开放模式(未启用登录)为 local(兼容旧数据键)
function initialWorkspaceOwner() {
try {
const raw = localStorage.getItem(AUTH_STORAGE_KEY)
if (raw) {
const parsed = JSON.parse(raw)
if (parsed.user?.username) return parsed.user.username
}
} catch {
/* ignore */
}
return 'local'
}
export const useRecruitmentStore = defineStore('recruitment', {
state: () => {
const owner = initialWorkspaceOwner()
const storageKey = workspaceStorageKey(owner)
let initial = seedState
try {
const raw = localStorage.getItem(STORAGE_KEY)
const raw = localStorage.getItem(storageKey)
if (raw) initial = JSON.parse(raw)
} catch {
/* ignore */
}
return {
state: normalizeState(initial),
workspaceOwner: owner,
storageKey,
activePage: 'todo',
saveStateText: '本地已保存',
loading: false,
......@@ -258,11 +276,11 @@ export const useRecruitmentStore = defineStore('recruitment', {
actions: {
async boot() {
this.loading = true
const localRaw = localStorage.getItem(STORAGE_KEY)
const localRaw = localStorage.getItem(this.storageKey)
try {
const payload = await api.fetchState()
const payload = await api.fetchState(this.workspaceOwner)
if (payload.meta?.empty && localRaw) {
await api.importLocalState(this.state)
await api.importLocalState(this.state, this.workspaceOwner)
this.saveStateText = '后端数据库已连接'
return
}
......@@ -280,6 +298,21 @@ export const useRecruitmentStore = defineStore('recruitment', {
}
},
// 切换工作区(admin 查看/操作其他用户数据):先落当前区,再整包加载目标区
async switchWorkspace(owner = 'local') {
if (!owner || owner === this.workspaceOwner) return
try {
await this.saveStateToServer()
} catch {
/* 保存失败不阻断切换 */
}
this.workspaceOwner = owner
this.storageKey = workspaceStorageKey(owner)
const localRaw = localStorage.getItem(this.storageKey)
this.applyLoadedState(localRaw ? JSON.parse(localRaw) : seedState)
await this.boot()
},
applyLoadedState(nextState) {
this.state = normalizeState(nextState)
this.selectedCandidateId = this.state.candidates[0]?.id || ''
......@@ -290,14 +323,14 @@ export const useRecruitmentStore = defineStore('recruitment', {
persist(message = '已保存') {
this.state.updatedAt = new Date().toISOString()
localStorage.setItem(STORAGE_KEY, JSON.stringify(this.state))
localStorage.setItem(this.storageKey, JSON.stringify(this.state))
this.saveStateText = message
this.saveStateToServer()
},
async saveStateToServer() {
try {
const payload = await api.saveState(this.state)
const payload = await api.saveState(this.state, this.workspaceOwner)
if (payload.state?.updatedAt) this.state.updatedAt = payload.state.updatedAt
this.saveStateText = '已保存'
} catch {
......@@ -659,6 +692,10 @@ export const useRecruitmentStore = defineStore('recruitment', {
this.persist('候选人已确认入职')
},
exportBackup() {
return JSON.stringify(this.state, null, 2)
},
importBackup(file) {
return new Promise((resolve, reject) => {
const reader = new FileReader()
......
import { defineStore } from 'pinia'
import * as authApi from '@/api/auth'
import { AUTH_STORAGE_KEY } from '@/utils/constants'
function loadPersisted() {
let token = ''
let user = null
try {
const raw = localStorage.getItem(AUTH_STORAGE_KEY)
if (raw) {
const parsed = JSON.parse(raw)
token = parsed.token || ''
user = parsed.user || null
}
} catch {
/* ignore */
}
return { token, user }
}
export const useUserStore = defineStore('user', {
state: () => {
const persisted = loadPersisted()
return {
token: persisted.token,
user: persisted.user,
// 开放模式(未设置 ADMIN_PASSWORD):系统不要求登录,视作 local 已认证
openMode: false,
authChecked: false,
// 用户管理列表(admin 拉取)
userList: [],
}
},
getters: {
isAuthenticated: (s) => Boolean(s.user) && (s.openMode || Boolean(s.token)),
isAdmin: (s) => s.user?.role === 'admin',
// 开放模式(未启用登录)下工作区归属 local,保持旧行为
workspaceOwner: (s) => (s.openMode || !s.user?.username ? 'local' : s.user.username),
},
actions: {
persist() {
localStorage.setItem(AUTH_STORAGE_KEY, JSON.stringify({ token: this.token, user: this.user }))
},
// 启动时探测一次:有 token 则校验有效性,无 token 则探测是否开放模式。
// 开放模式下 /api/auth/me 返回合成 local 用户,据此放行全部页面。
async ensureAuth() {
if (this.authChecked) return
this.authChecked = true
if (this.token) {
try {
await this.fetchMe()
} catch {
this.logout()
}
return
}
try {
const data = await authApi.fetchMe()
if (data.user?.username === 'local') {
this.openMode = true
this.user = data.user
}
} catch {
// 关闭模式且无 token:保持未登录,由路由守卫跳转 /login
}
},
async login(username, password) {
const data = await authApi.login({ username, password })
this.token = data.token
this.user = data.user
this.openMode = false
this.authChecked = true
this.persist()
return data.user
},
logout() {
this.token = ''
this.user = null
this.openMode = false
this.userList = []
localStorage.removeItem(AUTH_STORAGE_KEY)
},
async fetchMe() {
const data = await authApi.fetchMe()
this.user = data.user
this.persist()
return data.user
},
async changePassword(oldPassword, newPassword) {
await authApi.changePassword({ oldPassword, newPassword })
},
async loadUsers() {
const data = await authApi.listUsers()
this.userList = data.users || []
return this.userList
},
async createUser(payload) {
const data = await authApi.createUser(payload)
await this.loadUsers()
return data.user
},
async updateUser(id, payload) {
const data = await authApi.updateUser(id, payload)
await this.loadUsers()
return data.user
},
async removeUser(id) {
await authApi.deleteUser(id)
await this.loadUsers()
},
},
})
import dictionaries from '../../../shared/resume-dictionaries.json'
export const STORAGE_KEY = 'recruitment-system-mvp-v1'
// 兼容旧键:开放模式(未启用登录)沿用 recruitment-system-mvp-v1,避免已有浏览器数据丢失
export const AUTH_STORAGE_KEY = 'recruitment-system-auth-v1'
export function workspaceStorageKey(owner = 'local') {
if (!owner || owner === 'local') return STORAGE_KEY
return `recruitment-system-${owner}-v1`
}
export const OFFER_TEMPLATE_VERSION = 'offer-template-v2'
export const navItems = [
......@@ -181,10 +189,7 @@ export const seedState = {
// 修改词典请改共享文件,避免与后端 resume_parser_core.py 两处漂移。
export const project985Schools = new Set(dictionaries.schools985)
export const project211Schools = new Set([
...project985Schools,
...dictionaries.schools211Extra,
])
export const project211Schools = new Set([...project985Schools, ...dictionaries.schools211Extra])
export const industrySchools = new Set(dictionaries.industrySchools)
......@@ -193,4 +198,3 @@ export const commonMajors = dictionaries.majors
export const STAGE_ORDER = ['未筛选', '初筛通过', '面试中', 'Offer 中', '待入职', '已入职']
export const SKILL_DICTIONARY = dictionaries.skills
<script setup>
import { ref } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import { useUserStore } from '@/stores/user'
const router = useRouter()
const userStore = useUserStore()
const form = ref({ username: '', password: '' })
const loading = ref(false)
async function handleSubmit() {
if (!form.value.username || !form.value.password) {
ElMessage.warning('请输入用户名和密码')
return
}
loading.value = true
try {
await userStore.login(form.value.username, form.value.password)
ElMessage.success('登录成功')
router.push({ name: 'todo' })
} catch (error) {
ElMessage.error(error.message || '登录失败')
} finally {
loading.value = false
}
}
</script>
<template>
<div class="login-page">
<div class="login-card">
<div class="brand">
<div class="brand-mark">HR</div>
<div class="brand-text">
<strong>招聘系统</strong>
<span>请登录后使用</span>
</div>
</div>
<el-form label-position="top" @submit.prevent="handleSubmit">
<el-form-item label="用户名">
<el-input
v-model="form.username"
placeholder="请输入用户名"
autocomplete="username"
@keyup.enter="handleSubmit"
/>
</el-form-item>
<el-form-item label="密码">
<el-input
v-model="form.password"
type="password"
placeholder="请输入密码"
show-password
autocomplete="current-password"
@keyup.enter="handleSubmit"
/>
</el-form-item>
<el-button type="primary" class="submit" :loading="loading" @click="handleSubmit">登 录</el-button>
</el-form>
<p class="hint">账号由管理员在「用户管理」中创建</p>
</div>
</div>
</template>
<style lang="scss" scoped>
.login-page {
min-height: 100vh;
display: grid;
place-items: center;
background: var(--bg);
}
.login-card {
width: 360px;
max-width: calc(100vw - 32px);
padding: 32px 28px;
background: var(--panel);
border: 1px solid var(--line);
border-radius: 12px;
box-shadow: var(--shadow);
.brand {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 24px;
.brand-mark {
width: 44px;
height: 44px;
display: grid;
place-items: center;
border-radius: 10px;
background: var(--blue);
color: #fff;
font-weight: 700;
}
.brand-text {
display: flex;
flex-direction: column;
strong {
font-size: 18px;
}
span {
color: var(--muted);
font-size: 12px;
}
}
}
.submit {
width: 100%;
margin-top: 4px;
}
.hint {
margin: 16px 0 0;
text-align: center;
color: var(--muted);
font-size: 12px;
}
}
</style>
<script setup>
import { onMounted, ref } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { useUserStore } from '@/stores/user'
const userStore = useUserStore()
const loading = ref(false)
const dialogOpen = ref(false)
const editingId = ref('')
const form = ref({ username: '', name: '', role: 'user', password: '' })
const formRules = {
username: [{ required: true, message: '请输入用户名', trigger: 'blur' }],
role: [{ required: true, message: '请选择角色', trigger: 'change' }],
}
const formRef = ref(null)
const roleLabel = (role) => (role === 'admin' ? '管理员' : '普通用户')
async function loadUsers() {
loading.value = true
try {
await userStore.loadUsers()
} catch (error) {
ElMessage.error(error.message || '加载用户失败')
} finally {
loading.value = false
}
}
function openCreate() {
editingId.value = ''
form.value = { username: '', name: '', role: 'user', password: '' }
dialogOpen.value = true
}
function openEdit(user) {
editingId.value = user.id
form.value = { username: user.username, name: user.name, role: user.role, password: '' }
dialogOpen.value = true
}
async function handleSubmit() {
const valid = await formRef.value?.validate().catch(() => false)
if (!valid) return
try {
if (editingId.value) {
const payload = { name: form.value.name || form.value.username, role: form.value.role }
if (form.value.password) payload.password = form.value.password
await userStore.updateUser(editingId.value, payload)
ElMessage.success('用户已更新')
} else {
await userStore.createUser({
username: form.value.username,
name: form.value.name || form.value.username,
role: form.value.role,
password: form.value.password,
})
ElMessage.success('用户已创建')
}
dialogOpen.value = false
} catch (error) {
ElMessage.error(error.message || '保存失败')
}
}
async function handleToggleActive(user) {
try {
await userStore.updateUser(user.id, { isActive: !user.isActive })
ElMessage.success(user.isActive ? '已禁用该用户' : '已启用该用户')
} catch (error) {
ElMessage.error(error.message || '操作失败')
}
}
async function handleDelete(user) {
try {
await ElMessageBox.confirm(`确认删除用户「${user.username}」?该用户工作区有数据时将无法删除。`, '删除用户', {
type: 'warning',
confirmButtonText: '删除',
cancelButtonText: '取消',
})
await userStore.removeUser(user.id)
ElMessage.success('用户已删除')
} catch (error) {
if (error !== 'cancel' && error !== 'close') ElMessage.error(error.message || '删除失败')
}
}
onMounted(loadUsers)
</script>
<template>
<div class="user-manage">
<div class="toolbar">
<el-button type="primary" @click="openCreate">新建用户</el-button>
</div>
<el-table v-loading="loading" :data="userStore.userList" border stripe>
<el-table-column prop="username" label="用户名" min-width="120" />
<el-table-column prop="name" label="姓名" min-width="120" />
<el-table-column label="角色" width="110">
<template #default="{ row }">
<el-tag :type="row.role === 'admin' ? 'danger' : 'info'">{{ roleLabel(row.role) }}</el-tag>
</template>
</el-table-column>
<el-table-column label="状态" width="100">
<template #default="{ row }">
<el-tag :type="row.isActive ? 'success' : 'info'">{{ row.isActive ? '启用' : '禁用' }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="lastLoginAt" label="最近登录" min-width="160">
<template #default="{ row }">{{
row.lastLoginAt ? new Date(row.lastLoginAt).toLocaleString('zh-CN') : '—'
}}</template>
</el-table-column>
<el-table-column label="操作" width="240" fixed="right">
<template #default="{ row }">
<el-button size="small" @click="openEdit(row)">编辑</el-button>
<el-button size="small" :type="row.isActive ? 'warning' : 'success'" @click="handleToggleActive(row)">
{{ row.isActive ? '禁用' : '启用' }}
</el-button>
<el-button size="small" type="danger" @click="handleDelete(row)">删除</el-button>
</template>
</el-table-column>
</el-table>
<el-dialog v-model="dialogOpen" :title="editingId ? '编辑用户' : '新建用户'" width="440px">
<el-form ref="formRef" :model="form" :rules="formRules" label-width="72px">
<el-form-item label="用户名" prop="username">
<el-input v-model="form.username" :disabled="Boolean(editingId)" placeholder="登录用户名" />
</el-form-item>
<el-form-item label="姓名">
<el-input v-model="form.name" placeholder="显示名称(留空用用户名)" />
</el-form-item>
<el-form-item label="角色" prop="role">
<el-select v-model="form.role">
<el-option label="普通用户" value="user" />
<el-option label="管理员" value="admin" />
</el-select>
</el-form-item>
<el-form-item :label="editingId ? '重置密码' : '初始密码'">
<el-input
v-model="form.password"
type="password"
show-password
:placeholder="editingId ? '留空则不修改' : '至少 6 位'"
/>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="dialogOpen = false">取消</el-button>
<el-button type="primary" @click="handleSubmit">保存</el-button>
</template>
</el-dialog>
</div>
</template>
<style lang="scss" scoped>
.toolbar {
margin-bottom: 14px;
display: flex;
justify-content: flex-end;
}
</style>
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment