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 @@ ...@@ -17,6 +17,7 @@
# ---------- 工具索引 ---------- # ---------- 工具索引 ----------
.codegraph/ .codegraph/
.zcode/
# ---------- 日志 ---------- # ---------- 日志 ----------
*.log *.log
......
...@@ -4,7 +4,7 @@ ...@@ -4,7 +4,7 @@
## 1. 项目概述 ## 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) - **backend/**:Python 3.11+ / FastAPI / SQLAlchemy 2 / Alembic / SQLite(可切 MySQL)
- **vue-app/**:Vue 3(Composition API)/ Vite / Pinia / Element Plus / SCSS,**无 TypeScript** - **vue-app/**:Vue 3(Composition API)/ Vite / Pinia / Element Plus / SCSS,**无 TypeScript**
...@@ -24,6 +24,7 @@ ...@@ -24,6 +24,7 @@
| 后端测试 | `... -m pytest -c backend/pyproject.toml` | | 后端测试 | `... -m pytest -c backend/pyproject.toml` |
| 数据库迁移 | `... -m alembic -c backend/alembic.ini upgrade head` | | 数据库迁移 | `... -m alembic -c backend/alembic.ini upgrade head` |
| 简历解析 CLI | `... backend/scripts/parse_resume.py <简历文件>` | | 简历解析 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) | | 前端开发 | `cd vue-app && npm run dev`(http://127.0.0.1:5173,/api 代理到 4177) |
| 前端构建 / 检查 | `npm run build` / `npm run lint` / `npm run format` | | 前端构建 / 检查 | `npm run build` / `npm run lint` / `npm run format` |
...@@ -46,36 +47,43 @@ ...@@ -46,36 +47,43 @@
## 4. 修改必须遵守的架构规则 ## 4. 修改必须遵守的架构规则
### 4.1 状态同步是「整包覆盖」 ### 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`** 学校/专业/技能词典唯一数据源是 **`shared/resume-dictionaries.json`**
- 后端:[resume_parser_core.py](backend/app/services/resume_parser_core.py) 启动时加载(`_DICTIONARIES`); - 后端:[resume_parser_core.py](backend/app/services/resume_parser_core.py) 启动时加载(`_DICTIONARIES`);
- 前端:[constants.js](vue-app/src/utils/constants.js) `import` 同一文件。 - 前端:[constants.js](vue-app/src/utils/constants.js) `import` 同一文件。
**改词典只允许改共享 JSON**,禁止在任一端代码里内嵌重复列表。 **改词典只允许改共享 JSON**,禁止在任一端代码里内嵌重复列表。
### 4.3 AI 调用前的脱敏是硬性要求 ### 4.4 AI 调用前的脱敏是硬性要求
- 候选人信息发往 LLM 前必须脱敏([llm.py 的 sanitize_candidate_for_llm / sanitize_resume_for_llm](backend/app/services/llm.py)):姓名、手机、邮箱、年龄、出生年月等替换为占位符。 - 候选人信息发往 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` 剥离。 - 岗位**对内字段**`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。 - 能力回退链不可破坏:`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` 的推进用例。 [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 字段时注意幂等键是否需扩展;入库后走「待确认队列」→ 前端确认,不要直接改状态。 `/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/` 后重新构建。 `backend/static/` 是构建产物(由 `start_server.py --with-frontend` 生成,已 gitignore)。**禁止手动增删该目录或把它当源码**;页面/资源文件请改 `vue-app/src/` 后重新构建。
### 4.7 浏览器扩展 ### 4.8 浏览器扩展
`browser-extension/` 是原生 JS(MV3),无构建步骤。`reference/` 是外部参考副本,**禁止改动或引用** `browser-extension/` 是原生 JS(MV3),无构建步骤。`reference/` 是外部参考副本,**禁止改动或引用**
## 5. 测试要求 ## 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** - 测试必须自包含:`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` 生成再跑。 - `test_static_security.py` 断言 Vue `index.html` 实际引用的 JS 资源——若 `backend/static/` 为空,先 `start_server.py --with-frontend` 生成再跑。
- 前端目前无自动化测试;改 `utils/` 纯逻辑时至少保持现有行为不被破坏(npm run build 能过)。 - 前端目前无自动化测试;改 `utils/` 纯逻辑时至少保持现有行为不被破坏(npm run build 能过)。
...@@ -85,6 +93,7 @@ ...@@ -85,6 +93,7 @@
- **禁止**提交 `.env` / `.env.local`(已 gitignore);`backend/.env` 内有一个内网 LLM Key,别把它写进代码、注释或文档。 - **禁止**提交 `.env` / `.env.local`(已 gitignore);`backend/.env` 内有一个内网 LLM Key,别把它写进代码、注释或文档。
- 后端命令一律走 `backend/.venv`,否则可能因缺依赖或版本不符失败。 - 后端命令一律走 `backend/.venv`,否则可能因缺依赖或版本不符失败。
- 字段命名:前端 camelCase ↔ 后端 DB 列 snake_case;`data` 列存整个实体 JSON(camelCase)——两边别混用。 - 字段命名:前端 camelCase ↔ 后端 DB 列 snake_case;`data` 列存整个实体 JSON(camelCase)——两边别混用。
- 时间统一 ISO 字符串(`utc_now_iso`);ID 用 `前缀-uuid` 形式(job-、cand-、offer-、task-、evt-…)。 - 时间统一 ISO 字符串(`utc_now_iso`);ID 用 `前缀-uuid` 形式(job-、cand-、offer-、task-、evt-…);用户 ID 用 `usr-<username>`
-`models.py` 后:本地开发由 `create_all` 自动建表,**不需要**手写迁移;正式迁移需 `alembic revision`(对齐 `0001_initial` 全量建表风格)。 -`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,命令用反斜杠)。 - 文件必须 UTF-8;Windows 环境注意路径分隔(代码内用 pathlib,命令用反斜杠)。
...@@ -95,6 +95,39 @@ backend\.venv\Scripts\python.exe backend/scripts/start_server.py --with-frontend ...@@ -95,6 +95,39 @@ backend\.venv\Scripts\python.exe backend/scripts/start_server.py --with-frontend
详细说明见 [browser-extension/README.md](browser-extension/README.md) 详细说明见 [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。 - **待办项**:HR 跟进事项,关联候选人 / 岗位 / Offer。
...@@ -116,8 +149,14 @@ backend\.venv\Scripts\python.exe backend/scripts/start_server.py --with-frontend ...@@ -116,8 +149,14 @@ backend\.venv\Scripts\python.exe backend/scripts/start_server.py --with-frontend
| 方法 | 路径 | 说明 | | 方法 | 路径 | 说明 |
| --- | --- | --- | | --- | --- | --- |
| GET | `/api/ping` | 健康检查(含待确认采集数) | | GET | `/api/ping` | 健康检查(含待确认采集数) |
| GET / PUT | `/api/state` | 读取 / 全量保存系统状态 | | POST | `/api/auth/login` | 登录,返回 Bearer 令牌 |
| POST | `/api/import/local-state` | 导入 localStorage 备份 | | 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 备份 | | GET | `/api/export/state` | 导出 JSON 备份 |
| POST | `/api/parse-resume` | 上传简历并解析(PDF / DOCX / TXT) | | POST | `/api/parse-resume` | 上传简历并解析(PDF / DOCX / TXT) |
| POST | `/api/upload-resume` | 上传并保存简历文件 | | POST | `/api/upload-resume` | 上传并保存简历文件 |
...@@ -142,9 +181,12 @@ backend\.venv\Scripts\python.exe backend/scripts/start_server.py --with-frontend ...@@ -142,9 +181,12 @@ backend\.venv\Scripts\python.exe backend/scripts/start_server.py --with-frontend
| `DATA_DIR` | `./data` | 运行时数据目录(生产建议放源码目录外) | | `DATA_DIR` | `./data` | 运行时数据目录(生产建议放源码目录外) |
| `FILES_DIR` | `./uploads` | 简历等上传文件目录 | | `FILES_DIR` | `./uploads` | 简历等上传文件目录 |
| `DATABASE_URL` | `sqlite:///./data/recruitment.sqlite` | 数据库;可切换 MySQL(`mysql+pymysql://...`,`backend\.venv\Scripts\python.exe -m pip install -e "backend[mysql]"`) | | `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 / 采集接口外) | | `ADMIN_PASSWORD` | 空 | **登录总开关**:设置后启用多用户登录;为空则开放模式(不登录,数据归 `local` 工作区) |
| `SESSION_SECRET` | 开发默认值 | 部署时请修改 | | `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_TOKEN` | 空 | 设置后 `/api/ingest`、`/api/jd/sync-external` `X-Ingest-Token` `?token=` |
| `INGEST_OWNER` | `admin` | 采集接口无登录态时数据落盘的工作区(默认 admin) |
| `CORS_ORIGINS` | `http://127.0.0.1:5173,...` | 允许的跨域来源,逗号分隔 | | `CORS_ORIGINS` | `http://127.0.0.1:5173,...` | 允许的跨域来源,逗号分隔 |
| `MAX_UPLOAD_MB` | `20` | 上传大小限制(MB) | | `MAX_UPLOAD_MB` | `20` | 上传大小限制(MB) |
| `LLM_API_KEY` / `LLM_BASE_URL` / `LLM_MODEL` | 空 | OpenAI 兼容模型;或使用 `DEEPSEEK_*` / `OPENAI_*`,优先级 `LLM_*` > `DEEPSEEK_*` > `OPENAI_*` | | `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 ...@@ -176,6 +218,10 @@ backend\.venv\Scripts\python.exe -m alembic -c backend/alembic.ini upgrade head
# 简历解析 CLI(不启动服务,直接解析简历文件) # 简历解析 CLI(不启动服务,直接解析简历文件)
backend\.venv\Scripts\python.exe backend/scripts/parse_resume.py <简历文件> 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 环境) # 前端(vue-app/ 目录,不依赖 Python 环境)
npm run dev # 开发服务器 http://127.0.0.1:5173 npm run dev # 开发服务器 http://127.0.0.1:5173
npm run build # 生产构建到 dist/ npm run build # 生产构建到 dist/
...@@ -189,6 +235,9 @@ npm run format # Prettier 格式化 ...@@ -189,6 +235,9 @@ npm run format # Prettier 格式化
- `backend/static/` 是前端构建产物的同步目录(已 gitignore),初始为空;使用 `start_server.py --with-frontend` 或手动同步 `vue-app/dist/` 后才会提供页面。未同步时 `/` 会跳转但 `/index.html` 返回 404,属正常状态。 - `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`)已删除。 - 后端启动统一走 `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` 加载)。修改词典请改共享文件。 - 学校/专业/技能词典为前后端共享的一份数据:`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): ...@@ -30,8 +30,11 @@ class Settings(BaseSettings):
files_dir_raw: str = Field(default="./uploads", alias="FILES_DIR") files_dir_raw: str = Field(default="./uploads", alias="FILES_DIR")
database_url: str = Field(default="sqlite:///./data/recruitment.sqlite", alias="DATABASE_URL") database_url: str = Field(default="sqlite:///./data/recruitment.sqlite", alias="DATABASE_URL")
admin_password: str = Field(default="", alias="ADMIN_PASSWORD") 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_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_token: str = Field(default="", alias="INGEST_TOKEN")
ingest_owner: str = Field(default="admin", alias="INGEST_OWNER")
cors_origins_raw: str = Field( cors_origins_raw: str = Field(
default="http://127.0.0.1:5173,http://localhost:5173", alias="CORS_ORIGINS" default="http://127.0.0.1:5173,http://localhost:5173", alias="CORS_ORIGINS"
) )
......
...@@ -48,7 +48,95 @@ def get_db() -> Generator[Session, None, None]: ...@@ -48,7 +48,95 @@ def get_db() -> Generator[Session, None, None]:
db.close() 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: def create_all() -> None:
from backend.app import models # noqa: F401 from backend.app import models # noqa: F401
_refresh_schema_for_owner_columns()
Base.metadata.create_all(bind=engine) 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 ...@@ -4,20 +4,52 @@ from fastapi.exceptions import RequestValidationError
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse, RedirectResponse from fastapi.responses import JSONResponse, RedirectResponse
from fastapi.staticfiles import StaticFiles from fastapi.staticfiles import StaticFiles
from sqlalchemy import select
from starlette.exceptions import HTTPException as StarletteHTTPException from starlette.exceptions import HTTPException as StarletteHTTPException
from backend.app.config import STATIC_ROOT, get_settings from backend.app.config import STATIC_ROOT, get_settings
from backend.app.db import create_all from backend.app.db import SessionLocal, create_all
from backend.app.routers import ai, health, ingest, resume, state 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.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: def create_app() -> FastAPI:
settings = get_settings() settings = get_settings()
settings.ensure_runtime_dirs() settings.ensure_runtime_dirs()
create_all() create_all()
_ensure_admin()
app = FastAPI(title="招聘系统", version="0.5.0") app = FastAPI(title="招聘系统", version="0.6.0")
app.add_middleware( app.add_middleware(
CORSMiddleware, CORSMiddleware,
allow_origins=settings.cors_origins, allow_origins=settings.cors_origins,
...@@ -28,6 +60,7 @@ def create_app() -> FastAPI: ...@@ -28,6 +60,7 @@ def create_app() -> FastAPI:
app.add_middleware(DeploymentAuthMiddleware) app.add_middleware(DeploymentAuthMiddleware)
app.include_router(health.router, prefix="/api") app.include_router(health.router, prefix="/api")
app.include_router(auth.router, prefix="/api")
app.include_router(state.router, prefix="/api") app.include_router(state.router, prefix="/api")
app.include_router(resume.router, prefix="/api") app.include_router(resume.router, prefix="/api")
app.include_router(ai.router, prefix="/api") app.include_router(ai.router, prefix="/api")
......
...@@ -20,7 +20,8 @@ class SchemaMigration(Base): ...@@ -20,7 +20,8 @@ class SchemaMigration(Base):
class AppStateMeta(Base): class AppStateMeta(Base):
__tablename__ = "app_state_meta" __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) updated_at: Mapped[str] = mapped_column(String(40), nullable=False)
imported_from: Mapped[str] = mapped_column(String(80), default="server", nullable=False) imported_from: Mapped[str] = mapped_column(String(80), default="server", nullable=False)
raw_snapshot: Mapped[str | None] = mapped_column(Text) raw_snapshot: Mapped[str | None] = mapped_column(Text)
...@@ -30,9 +31,12 @@ class User(Base): ...@@ -30,9 +31,12 @@ class User(Base):
__tablename__ = "users" __tablename__ = "users"
id: Mapped[str] = mapped_column(String(80), primary_key=True) 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) name: Mapped[str] = mapped_column(String(120), nullable=False)
role: Mapped[str] = mapped_column(String(40), default="admin", nullable=False) role: Mapped[str] = mapped_column(String(40), default="admin", nullable=False)
password_hash: Mapped[str | None] = mapped_column(String(255)) 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) 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) updated_at: Mapped[str] = mapped_column(String(40), default=utc_now_iso, nullable=False)
...@@ -41,6 +45,7 @@ class Job(Base): ...@@ -41,6 +45,7 @@ class Job(Base):
__tablename__ = "jobs" __tablename__ = "jobs"
id: Mapped[str] = mapped_column(String(80), primary_key=True) 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) title: Mapped[str] = mapped_column(String(200), nullable=False)
department: Mapped[str | None] = mapped_column(String(120)) department: Mapped[str | None] = mapped_column(String(120))
status: Mapped[str | None] = mapped_column(String(80)) status: Mapped[str | None] = mapped_column(String(80))
...@@ -61,6 +66,7 @@ class JdVersion(Base): ...@@ -61,6 +66,7 @@ class JdVersion(Base):
__tablename__ = "jd_versions" __tablename__ = "jd_versions"
id: Mapped[str] = mapped_column(String(120), primary_key=True) 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) job_id: Mapped[str] = mapped_column(ForeignKey("jobs.id", ondelete="CASCADE"), nullable=False)
version: Mapped[str] = mapped_column(String(80), nullable=False) version: Mapped[str] = mapped_column(String(80), nullable=False)
status: Mapped[str | None] = mapped_column(String(80)) status: Mapped[str | None] = mapped_column(String(80))
...@@ -74,6 +80,7 @@ class Candidate(Base): ...@@ -74,6 +80,7 @@ class Candidate(Base):
__tablename__ = "candidates" __tablename__ = "candidates"
id: Mapped[str] = mapped_column(String(80), primary_key=True) 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) name: Mapped[str] = mapped_column(String(120), nullable=False)
primary_job_id: Mapped[str | None] = mapped_column(ForeignKey("jobs.id", ondelete="SET NULL")) primary_job_id: Mapped[str | None] = mapped_column(ForeignKey("jobs.id", ondelete="SET NULL"))
job_title: Mapped[str | None] = mapped_column(String(200)) job_title: Mapped[str | None] = mapped_column(String(200))
...@@ -98,6 +105,7 @@ class CandidateApplication(Base): ...@@ -98,6 +105,7 @@ class CandidateApplication(Base):
__table_args__ = (UniqueConstraint("candidate_id", "job_id", name="uq_candidate_application"),) __table_args__ = (UniqueConstraint("candidate_id", "job_id", name="uq_candidate_application"),)
id: Mapped[str] = mapped_column(String(160), primary_key=True) 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) 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")) job_id: Mapped[str | None] = mapped_column(ForeignKey("jobs.id", ondelete="SET NULL"))
stage: Mapped[str | None] = mapped_column(String(80)) stage: Mapped[str | None] = mapped_column(String(80))
...@@ -112,6 +120,7 @@ class ResumeFile(Base): ...@@ -112,6 +120,7 @@ class ResumeFile(Base):
__tablename__ = "resume_files" __tablename__ = "resume_files"
id: Mapped[str] = mapped_column(String(120), primary_key=True) 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")) candidate_id: Mapped[str | None] = mapped_column(ForeignKey("candidates.id", ondelete="SET NULL"))
original_name: Mapped[str] = mapped_column(String(255), nullable=False) original_name: Mapped[str] = mapped_column(String(255), nullable=False)
file_type: Mapped[str | None] = mapped_column(String(40)) file_type: Mapped[str | None] = mapped_column(String(40))
...@@ -128,6 +137,7 @@ class ResumeText(Base): ...@@ -128,6 +137,7 @@ class ResumeText(Base):
__tablename__ = "resume_texts" __tablename__ = "resume_texts"
id: Mapped[str] = mapped_column(String(120), primary_key=True) 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) 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) extraction_method: Mapped[str] = mapped_column(String(80), default="text_layer", nullable=False)
raw_text: Mapped[str | None] = mapped_column(Text) raw_text: Mapped[str | None] = mapped_column(Text)
...@@ -141,6 +151,7 @@ class ResumeProfile(Base): ...@@ -141,6 +151,7 @@ class ResumeProfile(Base):
__tablename__ = "resume_profiles" __tablename__ = "resume_profiles"
id: Mapped[str] = mapped_column(String(120), primary_key=True) 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")) 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")) 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) basic_info: Mapped[str] = mapped_column(Text, default="{}", nullable=False)
...@@ -154,6 +165,7 @@ class CandidateMatchReport(Base): ...@@ -154,6 +165,7 @@ class CandidateMatchReport(Base):
__tablename__ = "candidate_match_reports" __tablename__ = "candidate_match_reports"
id: Mapped[str] = mapped_column(String(120), primary_key=True) 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) 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")) 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")) resume_profile_id: Mapped[str | None] = mapped_column(ForeignKey("resume_profiles.id", ondelete="SET NULL"))
...@@ -168,6 +180,7 @@ class Interview(Base): ...@@ -168,6 +180,7 @@ class Interview(Base):
__tablename__ = "interviews" __tablename__ = "interviews"
id: Mapped[str] = mapped_column(String(120), primary_key=True) 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) 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")) job_id: Mapped[str | None] = mapped_column(ForeignKey("jobs.id", ondelete="SET NULL"))
round: Mapped[str | None] = mapped_column(String(80)) round: Mapped[str | None] = mapped_column(String(80))
...@@ -184,6 +197,7 @@ class Offer(Base): ...@@ -184,6 +197,7 @@ class Offer(Base):
__tablename__ = "offers" __tablename__ = "offers"
id: Mapped[str] = mapped_column(String(120), primary_key=True) 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) candidate_id: Mapped[str] = mapped_column(ForeignKey("candidates.id", ondelete="CASCADE"), unique=True, nullable=False)
status: Mapped[str | None] = mapped_column(String(80)) status: Mapped[str | None] = mapped_column(String(80))
salary: Mapped[str | None] = mapped_column(String(120)) salary: Mapped[str | None] = mapped_column(String(120))
...@@ -198,6 +212,7 @@ class Task(Base): ...@@ -198,6 +212,7 @@ class Task(Base):
__tablename__ = "tasks" __tablename__ = "tasks"
id: Mapped[str] = mapped_column(String(120), primary_key=True) 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) title: Mapped[str] = mapped_column(String(200), nullable=False)
module: Mapped[str | None] = mapped_column(String(80)) module: Mapped[str | None] = mapped_column(String(80))
target: Mapped[str | None] = mapped_column(String(200)) target: Mapped[str | None] = mapped_column(String(200))
...@@ -218,6 +233,7 @@ class RecruitmentEvent(Base): ...@@ -218,6 +233,7 @@ class RecruitmentEvent(Base):
__tablename__ = "recruitment_events" __tablename__ = "recruitment_events"
id: Mapped[str] = mapped_column(String(120), primary_key=True) 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) type: Mapped[str] = mapped_column(String(80), nullable=False)
at: Mapped[str] = mapped_column(String(40), nullable=False) at: Mapped[str] = mapped_column(String(40), nullable=False)
candidate_id: Mapped[str | None] = mapped_column(ForeignKey("candidates.id", ondelete="SET NULL")) candidate_id: Mapped[str | None] = mapped_column(ForeignKey("candidates.id", ondelete="SET NULL"))
...@@ -234,6 +250,7 @@ class IngestItem(Base): ...@@ -234,6 +250,7 @@ class IngestItem(Base):
__tablename__ = "ingest_items" __tablename__ = "ingest_items"
id: Mapped[str] = mapped_column(String(120), primary_key=True) 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) idempotency_key: Mapped[str] = mapped_column(String(80), unique=True, nullable=False)
confirmed: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False) confirmed: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
confirmed_at: Mapped[str | None] = mapped_column(String(40)) 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: ...@@ -34,12 +34,14 @@ def build_ingest_key(raw: dict[str, Any], file_hash: str = "") -> str:
def upsert_ingest_candidate( 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]: ) -> dict[str, Any]:
key = build_ingest_key(raw, saved_file.get("fileHash") if saved_file else "") 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: 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() at = utc_now_iso()
candidate_id = new_id("cand") candidate_id = new_id("cand")
...@@ -86,6 +88,7 @@ def upsert_ingest_candidate( ...@@ -86,6 +88,7 @@ def upsert_ingest_candidate(
session.add( session.add(
Candidate( Candidate(
id=candidate_id, id=candidate_id,
owner_id=owner,
name=candidate["name"], name=candidate["name"],
job_title=candidate["jobTitle"], job_title=candidate["jobTitle"],
source=source, source=source,
...@@ -108,6 +111,7 @@ def upsert_ingest_candidate( ...@@ -108,6 +111,7 @@ def upsert_ingest_candidate(
session.add( session.add(
ResumeFile( ResumeFile(
id=resume_file_id, id=resume_file_id,
owner_id=owner,
candidate_id=candidate_id, candidate_id=candidate_id,
original_name=saved_file["originalName"], original_name=saved_file["originalName"],
file_type=saved_file["fileType"], file_type=saved_file["fileType"],
...@@ -123,6 +127,7 @@ def upsert_ingest_candidate( ...@@ -123,6 +127,7 @@ def upsert_ingest_candidate(
session.add( session.add(
ResumeText( ResumeText(
id=resume_text_id, id=resume_text_id,
owner_id=owner,
resume_file_id=resume_file_id, resume_file_id=resume_file_id,
extraction_method="text_layer", extraction_method="text_layer",
raw_text=resume_text, raw_text=resume_text,
...@@ -135,6 +140,7 @@ def upsert_ingest_candidate( ...@@ -135,6 +140,7 @@ def upsert_ingest_candidate(
session.add( session.add(
ResumeProfile( ResumeProfile(
id=profile_id, id=profile_id,
owner_id=owner,
candidate_id=candidate_id, candidate_id=candidate_id,
resume_file_id=resume_file_id, resume_file_id=resume_file_id,
basic_info=dump_json( basic_info=dump_json(
...@@ -161,6 +167,7 @@ def upsert_ingest_candidate( ...@@ -161,6 +167,7 @@ def upsert_ingest_candidate(
session.add( session.add(
RecruitmentEvent( RecruitmentEvent(
id=event["id"], id=event["id"],
owner_id=owner,
type=event["type"], type=event["type"],
at=at, at=at,
candidate_id=candidate_id, candidate_id=candidate_id,
...@@ -185,6 +192,7 @@ def upsert_ingest_candidate( ...@@ -185,6 +192,7 @@ def upsert_ingest_candidate(
session.add( session.add(
IngestItem( IngestItem(
id=ingest_id, id=ingest_id,
owner_id=owner,
idempotency_key=key, idempotency_key=key,
confirmed=False, confirmed=False,
candidate_id=candidate_id, candidate_id=candidate_id,
...@@ -195,13 +203,16 @@ def upsert_ingest_candidate( ...@@ -195,13 +203,16 @@ def upsert_ingest_candidate(
created_at=at, 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() 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]]: def list_pending_ingest(session: Session, owner: str = "") -> list[dict[str, Any]]:
rows = session.scalars(select(IngestItem).where(IngestItem.confirmed.is_(False)).order_by(IngestItem.created_at.desc())).all() 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 = [] queue = []
for row in rows: for row in rows:
payload = parse_json(row.payload_json, {}) payload = parse_json(row.payload_json, {})
...@@ -231,9 +242,11 @@ def list_pending_ingest(session: Session) -> list[dict[str, Any]]: ...@@ -231,9 +242,11 @@ def list_pending_ingest(session: Session) -> list[dict[str, Any]]:
return queue 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() at = utc_now_iso()
query = select(IngestItem).where(IngestItem.confirmed.is_(False)) query = select(IngestItem).where(IngestItem.confirmed.is_(False))
if owner:
query = query.where(IngestItem.owner_id == owner)
if ids: if ids:
query = query.where(IngestItem.id.in_(ids)) query = query.where(IngestItem.id.in_(ids))
for item in session.scalars(query): for item in session.scalars(query):
......
...@@ -9,6 +9,8 @@ from backend.app.models import ( ...@@ -9,6 +9,8 @@ from backend.app.models import (
Candidate, Candidate,
CandidateApplication, CandidateApplication,
CandidateMatchReport, CandidateMatchReport,
IngestItem,
Interview,
JdVersion, JdVersion,
Job, Job,
Offer, Offer,
...@@ -30,6 +32,23 @@ EMPTY_STATE: dict[str, Any] = { ...@@ -30,6 +32,23 @@ EMPTY_STATE: dict[str, Any] = {
"updatedAt": "", "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: def _id(prefix: str) -> str:
return f"{prefix}-{uuid4().hex[:16]}" return f"{prefix}-{uuid4().hex[:16]}"
...@@ -54,32 +73,53 @@ def state_with_defaults(state: dict[str, Any] | None) -> dict[str, Any]: ...@@ -54,32 +73,53 @@ def state_with_defaults(state: dict[str, Any] | None) -> dict[str, Any]:
return merged 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] 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 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) -> dict[str, Any]:
jobs = [parse_json(item.data, {}) for item in session.scalars(select(Job).order_by(Job.created_at, Job.id))] 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 = [ candidates = [
parse_json(item.data, {}) 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 = [ offers = [
parse_json(item.data, {}) 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 = [ tasks = [
parse_json(item.data, {}) 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 = [ events = [
parse_json(item.data, {}) parse_json(item.data, {})
for item in session.scalars( 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: 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(parse_json(meta.raw_snapshot, EMPTY_STATE))
return state_with_defaults( return state_with_defaults(
...@@ -94,24 +134,12 @@ def read_state(session: Session) -> dict[str, Any]: ...@@ -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 = state_with_defaults(input_state)
state["updatedAt"] = utc_now_iso() state["updatedAt"] = utc_now_iso()
for model in ( for model in _OWNERED_MODELS:
CandidateMatchReport, session.execute(delete(model).where(model.owner_id == owner))
ResumeProfile,
ResumeText,
ResumeFile,
CandidateApplication,
RecruitmentEvent,
Task,
Offer,
Candidate,
JdVersion,
Job,
):
session.execute(delete(model))
for job in state["jobs"]: for job in state["jobs"]:
job_id = job.get("id") or _id("job") job_id = job.get("id") or _id("job")
...@@ -120,6 +148,7 @@ def replace_state(session: Session, input_state: dict[str, Any], imported_from: ...@@ -120,6 +148,7 @@ def replace_state(session: Session, input_state: dict[str, Any], imported_from:
session.add( session.add(
Job( Job(
id=job_id, id=job_id,
owner_id=owner,
title=job.get("title") or "未命名岗位", title=job.get("title") or "未命名岗位",
department=job.get("department") or "", department=job.get("department") or "",
status=job.get("status") or "", status=job.get("status") or "",
...@@ -141,6 +170,7 @@ def replace_state(session: Session, input_state: dict[str, Any], imported_from: ...@@ -141,6 +170,7 @@ def replace_state(session: Session, input_state: dict[str, Any], imported_from:
session.add( session.add(
JdVersion( JdVersion(
id=item.get("id") or f"{job_id}-jd-{index}", id=item.get("id") or f"{job_id}-jd-{index}",
owner_id=owner,
job_id=job_id, job_id=job_id,
version=item.get("version") or job.get("jdVersion") or f"v{index}", version=item.get("version") or job.get("jdVersion") or f"v{index}",
status=item.get("status") or job.get("jdStatus") or "", 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: ...@@ -160,6 +190,7 @@ def replace_state(session: Session, input_state: dict[str, Any], imported_from:
session.add( session.add(
Candidate( Candidate(
id=candidate_id, id=candidate_id,
owner_id=owner,
name=candidate.get("name") or "未命名候选人", name=candidate.get("name") or "未命名候选人",
primary_job_id=candidate.get("jobId") or None, primary_job_id=candidate.get("jobId") or None,
job_title=candidate.get("jobTitle") or "", job_title=candidate.get("jobTitle") or "",
...@@ -183,6 +214,7 @@ def replace_state(session: Session, input_state: dict[str, Any], imported_from: ...@@ -183,6 +214,7 @@ def replace_state(session: Session, input_state: dict[str, Any], imported_from:
session.add( session.add(
CandidateApplication( CandidateApplication(
id=f"{candidate_id}-{candidate['jobId']}", id=f"{candidate_id}-{candidate['jobId']}",
owner_id=owner,
candidate_id=candidate_id, candidate_id=candidate_id,
job_id=candidate["jobId"], job_id=candidate["jobId"],
stage=candidate.get("stage") or "", stage=candidate.get("stage") or "",
...@@ -209,6 +241,7 @@ def replace_state(session: Session, input_state: dict[str, Any], imported_from: ...@@ -209,6 +241,7 @@ def replace_state(session: Session, input_state: dict[str, Any], imported_from:
session.add( session.add(
Offer( Offer(
id=offer_id, id=offer_id,
owner_id=owner,
candidate_id=offer["candidateId"], candidate_id=offer["candidateId"],
status=offer.get("status") or "", status=offer.get("status") or "",
salary=offer.get("salary") or "", salary=offer.get("salary") or "",
...@@ -227,6 +260,7 @@ def replace_state(session: Session, input_state: dict[str, Any], imported_from: ...@@ -227,6 +260,7 @@ def replace_state(session: Session, input_state: dict[str, Any], imported_from:
session.add( session.add(
Task( Task(
id=task_id, id=task_id,
owner_id=owner,
title=task.get("title") or "未命名待办", title=task.get("title") or "未命名待办",
module=task.get("module") or "", module=task.get("module") or "",
target=task.get("target") or "", target=task.get("target") or "",
...@@ -251,6 +285,7 @@ def replace_state(session: Session, input_state: dict[str, Any], imported_from: ...@@ -251,6 +285,7 @@ def replace_state(session: Session, input_state: dict[str, Any], imported_from:
session.add( session.add(
RecruitmentEvent( RecruitmentEvent(
id=event_id, id=event_id,
owner_id=owner,
type=event.get("type") or "unknown", type=event.get("type") or "unknown",
at=at, at=at,
candidate_id=event.get("candidateId") or None, candidate_id=event.get("candidateId") or None,
...@@ -264,10 +299,10 @@ def replace_state(session: Session, input_state: dict[str, Any], imported_from: ...@@ -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.updated_at = state["updatedAt"]
meta.imported_from = imported_from meta.imported_from = imported_from
meta.raw_snapshot = dump_json(state) meta.raw_snapshot = dump_json(state)
session.merge(meta) session.merge(meta)
session.commit() 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 ...@@ -6,6 +6,11 @@ from fastapi import APIRouter, Depends, Request
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from backend.app.db import get_db 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 ( from backend.app.repositories.ingest_repository import (
confirm_ingest, confirm_ingest,
list_pending_ingest, list_pending_ingest,
...@@ -35,6 +40,7 @@ async def ingest(request: Request, db: Session = Depends(get_db)): ...@@ -35,6 +40,7 @@ async def ingest(request: Request, db: Session = Depends(get_db)):
token_error = verify_ingest_token(request) token_error = verify_ingest_token(request)
if token_error: if token_error:
return token_error return token_error
owner = resolve_ingest_owner(request, db)
raw = await request.json() raw = await request.json()
pdf_url = str(raw.get("pdfUrl") or "").strip() pdf_url = str(raw.get("pdfUrl") or "").strip()
parsed: dict[str, Any] = {} parsed: dict[str, Any] = {}
...@@ -51,17 +57,23 @@ async def ingest(request: Request, db: Session = Depends(get_db)): ...@@ -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 "")} parsed = {"parseWarning": f"PDF 下载或解析失败:{exc}", "resumeText": str(raw.get("summary") or "")}
elif raw.get("summary"): elif raw.get("summary"):
parsed = {"resumeText": str(raw.get("summary") or ""), "name": raw.get("name") or "", "jobTitle": raw.get("jobTitle") or ""} 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") @router.get("/ingest-queue")
def ingest_queue(db: Session = Depends(get_db)) -> dict[str, Any]: def ingest_queue(db: Session = Depends(get_db), user: CurrentUser = Depends(get_current_user)) -> dict[str, Any]:
queue = list_pending_ingest(db) owner = "" if user.role == "admin" else user.username
queue = list_pending_ingest(db, owner)
return {"queue": queue, "count": len(queue), "persisted": True} return {"queue": queue, "count": len(queue), "persisted": True}
@router.post("/ingest-queue/clear") @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()] 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} return {"ok": True}
...@@ -4,6 +4,7 @@ from fastapi import APIRouter, Depends, Response ...@@ -4,6 +4,7 @@ from fastapi import APIRouter, Depends, Response
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from backend.app.db import get_db 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.json_utils import pretty_json
from backend.app.repositories.state_repository import database_is_empty, read_state, replace_state from backend.app.repositories.state_repository import database_is_empty, read_state, replace_state
...@@ -11,26 +12,44 @@ router = APIRouter() ...@@ -11,26 +12,44 @@ router = APIRouter()
@router.get("/state") @router.get("/state")
def get_state(db: Session = Depends(get_db)) -> dict[str, Any]: def get_state(
state = read_state(db) db: Session = Depends(get_db),
return {**state, "meta": {"empty": database_is_empty(db), "persisted": True}} _: 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") @router.put("/state")
def put_state(payload: dict[str, Any], db: Session = Depends(get_db)) -> dict[str, Any]: def put_state(
state = replace_state(db, payload, "api-state") payload: dict[str, Any],
return {"ok": True, "state": state, "updatedAt": state["updatedAt"]} 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") @router.post("/import/local-state")
def import_local_state(payload: dict[str, Any], db: Session = Depends(get_db)) -> dict[str, Any]: def import_local_state(
state = replace_state(db, payload, "localStorage-import") payload: dict[str, Any],
return {"ok": True, "imported": True, "state": state, "updatedAt": state["updatedAt"]} 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") @router.get("/export/state")
def export_state(db: Session = Depends(get_db)) -> Response: def export_state(
state = read_state(db) 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" filename = f"recruitment-backup-{state['updatedAt'][:10]}.json"
return Response( return Response(
pretty_json(state), pretty_json(state),
......
...@@ -2,32 +2,50 @@ import base64 ...@@ -2,32 +2,50 @@ import base64
import secrets import secrets
from collections.abc import Awaitable, Callable 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.middleware.base import BaseHTTPMiddleware
from starlette.responses import Response
from backend.app.config import get_settings 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): 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() settings = get_settings()
# 未设置 ADMIN_PASSWORD:开放模式,不校验任何请求
if not settings.admin_password: if not settings.admin_password:
return await call_next(request) return await call_next(request)
path = request.url.path if path in _EXEMPT_API_PATHS:
if path == "/api/ping":
return await call_next(request) 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) return await call_next(request)
if path == "/api/jd/sync-external" and settings.ingest_token: header = request.headers.get("authorization", "")
return await call_next(request) # 兼容旧 Basic Auth(任意用户名 + 正确密码),映射为管理员
password = _read_basic_password(request.headers.get("authorization", "")) password = _read_basic_password(header)
if password and secrets.compare_digest(password, settings.admin_password): 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 await call_next(request)
return Response( return JSONResponse(
"需要登录后访问招聘系统", {"error": "需要登录后访问招聘系统"},
status_code=401, status_code=401,
media_type="text/plain; charset=utf-8", headers={"WWW-Authenticate": "Bearer"},
headers={"WWW-Authenticate": 'Basic realm="Recruitment System", charset="UTF-8"'},
) )
...@@ -41,6 +59,24 @@ def _read_basic_password(header: str) -> str: ...@@ -41,6 +59,24 @@ def _read_basic_password(header: str) -> str:
return decoded.split(":", 1)[1] if ":" in decoded else "" 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: def verify_ingest_token(request: Request) -> Response | None:
settings = get_settings() settings = get_settings()
if not settings.ingest_token: if not settings.ingest_token:
...@@ -48,4 +84,4 @@ def verify_ingest_token(request: Request) -> Response | None: ...@@ -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 "" token = request.headers.get("X-Ingest-Token") or request.query_params.get("token") or ""
if secrets.compare_digest(token, settings.ingest_token): if secrets.compare_digest(token, settings.ingest_token):
return None 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
This diff is collapsed.
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 axios from 'axios'
import { AUTH_STORAGE_KEY } from '@/utils/constants'
// 后端地址:优先取运行时注入的变量,否则默认 4177。 // 后端地址:优先取运行时注入的变量,否则默认 4177。
// 开发时 Vite 会把 /api 代理到后端,因此 baseURL 用空值即可。 // 开发时 Vite 会把 /api 代理到后端,因此 baseURL 用空值即可。
const runtimeBase = (() => { const runtimeBase = (() => {
...@@ -14,10 +16,43 @@ const http = axios.create({ ...@@ -14,10 +16,43 @@ const http = axios.create({
timeout: 60000, 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( http.interceptors.response.use(
(response) => response, (response) => response,
(error) => { (error) => {
const status = error?.response?.status
const message = error?.response?.data?.error || error?.message || '请求失败' 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)) return Promise.reject(new Error(message))
} }
) )
......
import http from '@/api' import http from '@/api'
export async function fetchState() { export async function fetchState(owner = '') {
const { data } = await http.get('/api/state') const { data } = await http.get('/api/state', { params: owner ? { owner } : {} })
return data return data
} }
export async function saveState(state) { export async function saveState(state, owner = '') {
const { data } = await http.put('/api/state', state) const { data } = await http.put('/api/state', state, { params: owner ? { owner } : {} })
return data return data
} }
export async function importLocalState(state) { export async function importLocalState(state, owner = '') {
const { data } = await http.post('/api/import/local-state', state) const { data } = await http.post('/api/import/local-state', state, { params: owner ? { owner } : {} })
return data return data
} }
......
...@@ -147,6 +147,31 @@ textarea { ...@@ -147,6 +147,31 @@ textarea {
strong { strong {
color: var(--ink); 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> <script setup>
import { computed, onMounted, watch } from 'vue' import { computed, onMounted, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router' import { useRoute, useRouter } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus'
import { useRecruitmentStore } from '@/stores/recruitment' import { useRecruitmentStore } from '@/stores/recruitment'
import { useUserStore } from '@/stores/user'
import { navItems, navPageMeta } from '@/router/nav' import { navItems, navPageMeta } from '@/router/nav'
import NavItem from '@/components/common/NavItem.vue' import NavItem from '@/components/common/NavItem.vue'
const store = useRecruitmentStore() const store = useRecruitmentStore()
const userStore = useUserStore()
const route = useRoute() const route = useRoute()
const router = useRouter() const router = useRouter()
const activeRouteKey = computed(() => { const activeRouteKey = computed(() => {
const name = route.name const name = route.name
if (name === 'user-management') return 'user-management'
if (name === 'dashboard') return 'dashboard' if (name === 'dashboard') return 'dashboard'
if (['jobs', 'job-detail', 'job-edit', 'job-create'].includes(name)) return 'jobs' if (['jobs', 'job-detail', 'job-edit', 'job-create'].includes(name)) return 'jobs'
if (name === 'resumes') return 'resumes' if (name === 'resumes') return 'resumes'
...@@ -32,9 +36,13 @@ const todayText = computed(() => ...@@ -32,9 +36,13 @@ const todayText = computed(() =>
new Date().toLocaleDateString('zh-CN', { year: 'numeric', month: '2-digit', day: '2-digit' }) 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) => { const navigate = (key) => {
store.setPage(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) => { const navCount = (key) => {
...@@ -69,12 +77,91 @@ const handleImport = async (event) => { ...@@ -69,12 +77,91 @@ const handleImport = async (event) => {
const handleReset = () => { const handleReset = () => {
if (store.candidates.length === 0) return if (store.candidates.length === 0) return
const ok = window.confirm('确认重置为示例数据吗?将覆盖当前本地数据。') const ok = window.confirm('确认重置为示例数据吗?将覆盖当前工作区本地数据。')
if (ok) store.resetData() 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(() => { onMounted(() => {
store.boot() store.boot()
loadWorkspaceOptions()
}) })
</script> </script>
...@@ -94,6 +181,14 @@ onMounted(() => { ...@@ -94,6 +181,14 @@ onMounted(() => {
:count="navCount(item.key)" :count="navCount(item.key)"
@click="navigate(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> </nav>
<div class="side-actions"> <div class="side-actions">
<el-button type="primary" size="small" @click="handleExport">导出备份</el-button> <el-button type="primary" size="small" @click="handleExport">导出备份</el-button>
...@@ -114,6 +209,28 @@ onMounted(() => { ...@@ -114,6 +209,28 @@ onMounted(() => {
<div class="top-meta"> <div class="top-meta">
<span>{{ store.saveStateText }}</span> <span>{{ store.saveStateText }}</span>
<strong>{{ todayText }}</strong> <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> </div>
</header> </header>
<section class="page"> <section class="page">
...@@ -123,4 +240,22 @@ onMounted(() => { ...@@ -123,4 +240,22 @@ onMounted(() => {
</div> </div>
<div class="toast" v-if="false"></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> </template>
import { createRouter, createWebHistory } from 'vue-router' import { createRouter, createWebHistory } from 'vue-router'
import { useUserStore } from '@/stores/user'
import { navItems, navPageMeta } from './nav' import { navItems, navPageMeta } from './nav'
const routes = [ const routes = [
{
path: '/login',
name: 'login',
component: () => import('@/views/LoginView.vue'),
meta: { public: true },
},
{ {
path: '/', path: '/',
name: 'shell', name: 'shell',
...@@ -48,6 +55,12 @@ const routes = [ ...@@ -48,6 +55,12 @@ const routes = [
name: 'offer', name: 'offer',
component: () => import('@/views/OfferView.vue'), 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({ ...@@ -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 { navItems, navPageMeta }
export default router export default router
import { defineStore } from 'pinia' 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 * as api from '@/api/recruitment'
import { import {
enrichCandidateFields, enrichCandidateFields,
...@@ -195,17 +195,35 @@ function currentPeriodKey() { ...@@ -195,17 +195,35 @@ function currentPeriodKey() {
return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}` 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', { export const useRecruitmentStore = defineStore('recruitment', {
state: () => { state: () => {
const owner = initialWorkspaceOwner()
const storageKey = workspaceStorageKey(owner)
let initial = seedState let initial = seedState
try { try {
const raw = localStorage.getItem(STORAGE_KEY) const raw = localStorage.getItem(storageKey)
if (raw) initial = JSON.parse(raw) if (raw) initial = JSON.parse(raw)
} catch { } catch {
/* ignore */ /* ignore */
} }
return { return {
state: normalizeState(initial), state: normalizeState(initial),
workspaceOwner: owner,
storageKey,
activePage: 'todo', activePage: 'todo',
saveStateText: '本地已保存', saveStateText: '本地已保存',
loading: false, loading: false,
...@@ -258,11 +276,11 @@ export const useRecruitmentStore = defineStore('recruitment', { ...@@ -258,11 +276,11 @@ export const useRecruitmentStore = defineStore('recruitment', {
actions: { actions: {
async boot() { async boot() {
this.loading = true this.loading = true
const localRaw = localStorage.getItem(STORAGE_KEY) const localRaw = localStorage.getItem(this.storageKey)
try { try {
const payload = await api.fetchState() const payload = await api.fetchState(this.workspaceOwner)
if (payload.meta?.empty && localRaw) { if (payload.meta?.empty && localRaw) {
await api.importLocalState(this.state) await api.importLocalState(this.state, this.workspaceOwner)
this.saveStateText = '后端数据库已连接' this.saveStateText = '后端数据库已连接'
return return
} }
...@@ -280,6 +298,21 @@ export const useRecruitmentStore = defineStore('recruitment', { ...@@ -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) { applyLoadedState(nextState) {
this.state = normalizeState(nextState) this.state = normalizeState(nextState)
this.selectedCandidateId = this.state.candidates[0]?.id || '' this.selectedCandidateId = this.state.candidates[0]?.id || ''
...@@ -290,14 +323,14 @@ export const useRecruitmentStore = defineStore('recruitment', { ...@@ -290,14 +323,14 @@ export const useRecruitmentStore = defineStore('recruitment', {
persist(message = '已保存') { persist(message = '已保存') {
this.state.updatedAt = new Date().toISOString() 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.saveStateText = message
this.saveStateToServer() this.saveStateToServer()
}, },
async saveStateToServer() { async saveStateToServer() {
try { 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 if (payload.state?.updatedAt) this.state.updatedAt = payload.state.updatedAt
this.saveStateText = '已保存' this.saveStateText = '已保存'
} catch { } catch {
...@@ -659,6 +692,10 @@ export const useRecruitmentStore = defineStore('recruitment', { ...@@ -659,6 +692,10 @@ export const useRecruitmentStore = defineStore('recruitment', {
this.persist('候选人已确认入职') this.persist('候选人已确认入职')
}, },
exportBackup() {
return JSON.stringify(this.state, null, 2)
},
importBackup(file) { importBackup(file) {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const reader = new FileReader() 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' import dictionaries from '../../../shared/resume-dictionaries.json'
export const STORAGE_KEY = 'recruitment-system-mvp-v1' 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 OFFER_TEMPLATE_VERSION = 'offer-template-v2'
export const navItems = [ export const navItems = [
...@@ -181,10 +189,7 @@ export const seedState = { ...@@ -181,10 +189,7 @@ export const seedState = {
// 修改词典请改共享文件,避免与后端 resume_parser_core.py 两处漂移。 // 修改词典请改共享文件,避免与后端 resume_parser_core.py 两处漂移。
export const project985Schools = new Set(dictionaries.schools985) export const project985Schools = new Set(dictionaries.schools985)
export const project211Schools = new Set([ export const project211Schools = new Set([...project985Schools, ...dictionaries.schools211Extra])
...project985Schools,
...dictionaries.schools211Extra,
])
export const industrySchools = new Set(dictionaries.industrySchools) export const industrySchools = new Set(dictionaries.industrySchools)
...@@ -193,4 +198,3 @@ export const commonMajors = dictionaries.majors ...@@ -193,4 +198,3 @@ export const commonMajors = dictionaries.majors
export const STAGE_ORDER = ['未筛选', '初筛通过', '面试中', 'Offer 中', '待入职', '已入职'] export const STAGE_ORDER = ['未筛选', '初筛通过', '面试中', 'Offer 中', '待入职', '已入职']
export const SKILL_DICTIONARY = dictionaries.skills 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