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

feat: 业务数据仅存数据库,移除浏览器本地兜底与 localStorage 主事实源

parent 61e871cb
...@@ -139,7 +139,7 @@ backend\.venv\Scripts\python.exe backend/scripts/start_server.py --with-frontend ...@@ -139,7 +139,7 @@ backend\.venv\Scripts\python.exe backend/scripts/start_server.py --with-frontend
- **QwenPaw 接入(可选)**:JD 生成 / 简历分析可走独立的 QwenPaw 智能体服务(SSE 流式),失败自动回退。 - **QwenPaw 接入(可选)**:JD 生成 / 简历分析可走独立的 QwenPaw 智能体服务(SSE 流式),失败自动回退。
- **对外 JD 同步**:接收企微 QwenPaw 产出的 JD 并创建 / 更新岗位(`/api/jd/sync-external`) - **对外 JD 同步**:接收企微 QwenPaw 产出的 JD 并创建 / 更新岗位(`/api/jd/sync-external`)
- **录用入职管理**:Offer 草稿生成、审批材料与待入职跟进。 - **录用入职管理**:Offer 草稿生成、审批材料与待入职跟进。
- **数据持久化与导入导出**状态存 SQLite,支持 `GET/PUT /api/state`、localStorage 导入、JSON 备份导出。 - **数据持久化与导入导出**业务数据以 SQLite 为唯一事实源(前端不使用浏览器本地缓存兜底),支持 `GET/PUT /api/state` 整包读写、JSON 备份导出。
- **简历采集接入**:浏览器扩展或外部系统通过 `/api/ingest` 推送简历,支持幂等队列、PDF 下载与解析。 - **简历采集接入**:浏览器扩展或外部系统通过 `/api/ingest` 推送简历,支持幂等队列、PDF 下载与解析。
## API 概览 ## API 概览
......
...@@ -134,7 +134,7 @@ recruit-sys/ ...@@ -134,7 +134,7 @@ recruit-sys/
### 4.3 前端架构 ### 4.3 前端架构
- Vue 3 Composition API + Pinia + Vue Router + Element Plus;无 TypeScript,无组件测试。 - Vue 3 Composition API + Pinia + Vue Router + Element Plus;无 TypeScript,无组件测试。
- **状态同步策略**`stores/recruitment.js` localStorage(key 按工作区分:`recruitment-system-<owner>-v1`,开放模式 `local` 沿用旧 key `recruitment-system-mvp-v1`,见 [constants.js](vue-app/src/utils/constants.js))为主事实源,向后端 `/api/state` 全量保存;启动时尝试读取后端状态,两者有冲突时以后端为准(`meta.persisted` 为 true 时用后端) - **状态同步策略**`stores/recruitment.js`数据库为唯一事实源——启动时从 `GET /api/state` 整包加载,保存时 `PUT /api/state` 全量写入;**不再使用浏览器 localStorage 缓存业务数据**(localStorage 仅存登录令牌 `recruitment-system-auth-v1`)。数据库不可用时显示空工作区并提示「数据库连接失败」,不回退本地缓存
- **登录态**`stores/user.js` 存 Bearer 令牌与用户信息(key `recruitment-system-auth-v1`);axios 请求拦截器自动注入 `Authorization: Bearer`,401 时清空凭证并跳 `/login`;路由守卫 `router/index.js``beforeEach` 做未登录/已登录/角色(admin)三层跳转。 - **登录态**`stores/user.js` 存 Bearer 令牌与用户信息(key `recruitment-system-auth-v1`);axios 请求拦截器自动注入 `Authorization: Bearer`,401 时清空凭证并跳 `/login`;路由守卫 `router/index.js``beforeEach` 做未登录/已登录/角色(admin)三层跳转。
- **工作区切换(admin)**:AppShell 顶栏工作区下拉选择目标用户,`recruitment` store 的 `switchWorkspace()` 先保存当前区、再整包加载目标区数据。 - **工作区切换(admin)**:AppShell 顶栏工作区下拉选择目标用户,`recruitment` store 的 `switchWorkspace()` 先保存当前区、再整包加载目标区数据。
- 大量业务逻辑在 `src/utils/`(22 个纯函数模块):匹配打分、字段推断、JD 质量、任务流程、Offer 模板等。规则与后端 [resume_parser_core.py](backend/app/services/resume_parser_core.py) 共享同一份学校/专业/技能词典(`shared/resume-dictionaries.json`),改一处即可。 - 大量业务逻辑在 `src/utils/`(22 个纯函数模块):匹配打分、字段推断、JD 质量、任务流程、Offer 模板等。规则与后端 [resume_parser_core.py](backend/app/services/resume_parser_core.py) 共享同一份学校/专业/技能词典(`shared/resume-dictionaries.json`),改一处即可。
...@@ -276,7 +276,7 @@ backend\.venv\Scripts\python.exe -m pytest -c backend/pyproject.toml ...@@ -276,7 +276,7 @@ backend\.venv\Scripts\python.exe -m pytest -c backend/pyproject.toml
| 简历文件 | `./uploads/resumes/``FILES_DIR` 可改) | hash 命名,`GET /api/resumes/{storedName}` 下载 | | 简历文件 | `./uploads/resumes/``FILES_DIR` 可改) | hash 命名,`GET /api/resumes/{storedName}` 下载 |
| 状态快照 | `app_state_meta.raw_snapshot` | 整包 JSON 备份(按工作区一行),空库时回读 | | 状态快照 | `app_state_meta.raw_snapshot` | 整包 JSON 备份(按工作区一行),空库时回读 |
| 导出备份 | `GET /api/export/state` | 下载 `recruitment-backup-YYYY-MM-DD.json`,可经 `/api/import/local-state` 导回(当前/指定工作区) | | 导出备份 | `GET /api/export/state` | 下载 `recruitment-backup-YYYY-MM-DD.json`,可经 `/api/import/local-state` 导回(当前/指定工作区) |
| 前端 localStorage | key `recruitment-system-<owner>-v1`(开放模式 `local` 用旧 key `recruitment-system-mvp-v1`) | 前端主事实源,向后端全量同步 | | 业务数据 | 数据库(SQLite) | 唯一事实源;前端不使用浏览器本地缓存兜底 |
| 前端登录态 | key `recruitment-system-auth-v1` | Bearer 令牌 + 用户信息 | | 前端登录态 | key `recruitment-system-auth-v1` | Bearer 令牌 + 用户信息 |
--- ---
...@@ -292,7 +292,7 @@ backend\.venv\Scripts\python.exe -m pytest -c backend/pyproject.toml ...@@ -292,7 +292,7 @@ backend\.venv\Scripts\python.exe -m pytest -c backend/pyproject.toml
- **状态同步是「整包覆盖」而非增量**`PUT /api/state` 会按当前工作区全表删后重建([state_repository.py 的 replace_state](backend/app/repositories/state_repository.py))。**同一工作区内**多端同时编辑会互相覆盖(last-write-wins),建议同一工作区单用户或串行使用;不同用户之间已按 `owner_id` 隔离。 - **状态同步是「整包覆盖」而非增量**`PUT /api/state` 会按当前工作区全表删后重建([state_repository.py 的 replace_state](backend/app/repositories/state_repository.py))。**同一工作区内**多端同时编辑会互相覆盖(last-write-wins),建议同一工作区单用户或串行使用;不同用户之间已按 `owner_id` 隔离。
- **令牌无状态、不主动吊销**:登录令牌是 HMAC 签名的无状态 Bearer,重置密码不会使已签发令牌立即失效(禁用用户会因 `is_active` 检查立即失效);如需要严格吊销需引入 sessions 表(后续可做)。 - **令牌无状态、不主动吊销**:登录令牌是 HMAC 签名的无状态 Bearer,重置密码不会使已签发令牌立即失效(禁用用户会因 `is_active` 检查立即失效);如需要严格吊销需引入 sessions 表(后续可做)。
- **`localStorage` 与后端状态的一致性**:前端先写 localStorage 再同步后端;若 localStorage 有旧数据且后端空库,会以 localStorage 为准导入(`/api/import/local-state` 逻辑存在)**清浏览器数据前请先导出备份**`GET /api/export/state`)。 - **业务数据以数据库为唯一事实源**:前端启动时从 `GET /api/state` 整包加载,保存时 `PUT /api/state` 全量写入;不使用浏览器本地缓存兜底,数据库不可用时提示「数据库连接失败」并显示空工作区**清浏览器数据前请先导出备份**`GET /api/export/state`)。
- **简历解析是纯规则、无 OCR**:扫描件/图片型 PDF 提取不到文本,`parse_status` 会标记 `needs_manual`,需人工补录(见 [ingest_repository.py](backend/app/repositories/ingest_repository.py)`parse_status` 判断)。解析器面向中文文本简历优化,英文简历字段提取能力弱。 - **简历解析是纯规则、无 OCR**:扫描件/图片型 PDF 提取不到文本,`parse_status` 会标记 `needs_manual`,需人工补录(见 [ingest_repository.py](backend/app/repositories/ingest_repository.py)`parse_status` 判断)。解析器面向中文文本简历优化,英文简历字段提取能力弱。
- **`reference/` 是外部参考副本**`browser-extension/reference/` 下的 boss-sync-plugin 是别家方案的复制品,仅作参考,不参与运行,也不应被当作本项目代码维护(已进 .gitignore)。 - **`reference/` 是外部参考副本**`browser-extension/reference/` 下的 boss-sync-plugin 是别家方案的复制品,仅作参考,不参与运行,也不应被当作本项目代码维护(已进 .gitignore)。
......
...@@ -10,11 +10,6 @@ export async function saveState(state, owner = '') { ...@@ -10,11 +10,6 @@ export async function saveState(state, owner = '') {
return data return data
} }
export async function importLocalState(state, owner = '') {
const { data } = await http.post('/api/import/local-state', state, { params: owner ? { owner } : {} })
return data
}
export async function parseResume(file) { export async function parseResume(file) {
const formData = new FormData() const formData = new FormData()
formData.append('resume', file) formData.append('resume', file)
......
...@@ -160,6 +160,8 @@ async function loadWorkspaceOptions() { ...@@ -160,6 +160,8 @@ async function loadWorkspaceOptions() {
} }
onMounted(() => { onMounted(() => {
// 先校正工作区归属(登录/换号后以当前用户为准),再加载数据库数据
store.syncOwner(userStore.workspaceOwner)
store.boot() store.boot()
loadWorkspaceOptions() loadWorkspaceOptions()
}) })
......
import { defineStore } from 'pinia' import { defineStore } from 'pinia'
import { AUTH_STORAGE_KEY, workspaceStorageKey, seedState } from '@/utils/constants' import { AUTH_STORAGE_KEY, seedState } from '@/utils/constants'
import * as api from '@/api/recruitment' import * as api from '@/api/recruitment'
import { import {
enrichCandidateFields, enrichCandidateFields,
...@@ -26,6 +26,9 @@ import { isAiEvaluationReport } from '@/utils/scoring' ...@@ -26,6 +26,9 @@ import { isAiEvaluationReport } from '@/utils/scoring'
import { matchedJobForCandidate, candidateJobTitle } from '@/utils/links' import { matchedJobForCandidate, candidateJobTitle } from '@/utils/links'
import { createOfferRecord } from '@/utils/normalize' import { createOfferRecord } from '@/utils/normalize'
// 业务数据一律以数据库为准,不使用浏览器本地缓存;空工作区用这个骨架初始化
const EMPTY_STATE = { jobs: [], candidates: [], offers: [], tasks: [], eventLog: [] }
function buildRecruitmentEvent(type, candidate = {}, extra = {}) { function buildRecruitmentEvent(type, candidate = {}, extra = {}) {
const at = extra.at || new Date().toISOString() const at = extra.at || new Date().toISOString()
return { return {
...@@ -212,18 +215,9 @@ function initialWorkspaceOwner() { ...@@ -212,18 +215,9 @@ function initialWorkspaceOwner() {
export const useRecruitmentStore = defineStore('recruitment', { export const useRecruitmentStore = defineStore('recruitment', {
state: () => { state: () => {
const owner = initialWorkspaceOwner() const owner = initialWorkspaceOwner()
const storageKey = workspaceStorageKey(owner)
let initial = seedState
try {
const raw = localStorage.getItem(storageKey)
if (raw) initial = JSON.parse(raw)
} catch {
/* ignore */
}
return { return {
state: normalizeState(initial), state: normalizeState(EMPTY_STATE),
workspaceOwner: owner, workspaceOwner: owner,
storageKey,
activePage: 'todo', activePage: 'todo',
saveStateText: '本地已保存', saveStateText: '本地已保存',
loading: false, loading: false,
...@@ -276,28 +270,26 @@ export const useRecruitmentStore = defineStore('recruitment', { ...@@ -276,28 +270,26 @@ export const useRecruitmentStore = defineStore('recruitment', {
actions: { actions: {
async boot() { async boot() {
this.loading = true this.loading = true
const localRaw = localStorage.getItem(this.storageKey)
try { try {
const payload = await api.fetchState(this.workspaceOwner) const payload = await api.fetchState(this.workspaceOwner)
if (payload.meta?.empty && localRaw) {
await api.importLocalState(this.state, this.workspaceOwner)
this.saveStateText = '后端数据库已连接'
return
}
this.applyLoadedState(payload) this.applyLoadedState(payload)
this.saveStateText = '后端数据库已连接' this.saveStateText = '后端数据库已连接'
} catch { } catch {
try { // 数据一律以数据库为准:数据库不可用时显示空工作区,不再回退浏览器本地缓存
this.applyLoadedState(localRaw ? JSON.parse(localRaw) : seedState) this.applyLoadedState(EMPTY_STATE)
} catch { this.saveStateText = '数据库连接失败'
this.applyLoadedState(seedState)
}
this.saveStateText = '使用浏览器本地兜底数据'
} finally { } finally {
this.loading = false this.loading = false
} }
}, },
// 登录/挂载后校正工作区归属:以当前登录用户为准,避免退出后换号登录残留旧工作区
syncOwner(owner = '') {
if (!owner || owner === this.workspaceOwner) return
this.workspaceOwner = owner
this.applyLoadedState(EMPTY_STATE)
},
// 切换工作区(admin 查看/操作其他用户数据):先落当前区,再整包加载目标区 // 切换工作区(admin 查看/操作其他用户数据):先落当前区,再整包加载目标区
async switchWorkspace(owner = 'local') { async switchWorkspace(owner = 'local') {
if (!owner || owner === this.workspaceOwner) return if (!owner || owner === this.workspaceOwner) return
...@@ -307,9 +299,7 @@ export const useRecruitmentStore = defineStore('recruitment', { ...@@ -307,9 +299,7 @@ export const useRecruitmentStore = defineStore('recruitment', {
/* 保存失败不阻断切换 */ /* 保存失败不阻断切换 */
} }
this.workspaceOwner = owner this.workspaceOwner = owner
this.storageKey = workspaceStorageKey(owner) this.applyLoadedState(EMPTY_STATE)
const localRaw = localStorage.getItem(this.storageKey)
this.applyLoadedState(localRaw ? JSON.parse(localRaw) : seedState)
await this.boot() await this.boot()
}, },
...@@ -323,7 +313,6 @@ export const useRecruitmentStore = defineStore('recruitment', { ...@@ -323,7 +313,6 @@ export const useRecruitmentStore = defineStore('recruitment', {
persist(message = '已保存') { persist(message = '已保存') {
this.state.updatedAt = new Date().toISOString() this.state.updatedAt = new Date().toISOString()
localStorage.setItem(this.storageKey, JSON.stringify(this.state))
this.saveStateText = message this.saveStateText = message
this.saveStateToServer() this.saveStateToServer()
}, },
...@@ -334,7 +323,7 @@ export const useRecruitmentStore = defineStore('recruitment', { ...@@ -334,7 +323,7 @@ export const useRecruitmentStore = defineStore('recruitment', {
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 {
this.saveStateText = '本地暂存,后端未保存' this.saveStateText = '保存失败,请检查后端连接'
} }
}, },
......
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