Commit 755cfd9d authored by 李文光's avatar 李文光

feat(ingest): 采集数据按登录用户归属(ADR 0007) + 扩展 v1.5 + 后端监听 0.0.0.0

/api/ingest 改为依赖 get_current_user, 以登录用户名作为 owner; 移除 ingest_owner 配置与开放模式合成用户/INGEST_OWNER 回落, 采集归属与登录态强绑定

security: /api/ingest 不再豁免 DeploymentAuthMiddleware(改走统一登录); 智能体/外部JD同步等机器调用仍走 INGEST_TOKEN

同步更新后端测试(conftest/auth/ingest/external_claims/pool/reports/recruiting_rules_agent), 122 passed

浏览器扩展 v1.5(弹窗/后台/README) 与 start_server.py 监听 0.0.0.0

新增 docs/adr/0007-per-user-ingest-attribution.md
parent de329be8
......@@ -34,7 +34,6 @@ class Settings(BaseSettings):
session_secret: str = Field(default="dev-session-secret", alias="SESSION_SECRET")
session_token_ttl_days: int = Field(default=7, alias="SESSION_TTL_DAYS")
ingest_token: str = Field(default="", alias="INGEST_TOKEN")
ingest_owner: str = Field(default="admin", alias="INGEST_OWNER")
cors_origins_raw: str = Field(
default="*", alias="CORS_ORIGINS"
)
......
"""认证与工作区依赖注入。
- get_current_user:开放模式返回合成本地用户,关闭模式解析会话/Basic 映射,失败抛 401
- get_current_user:要求有效登录态(会话/Basic 映射),失败抛 401;不再有开放模式合成用户
- require_admin:管理员限定。
- resolve_owner:解析目标工作区(默认当前用户,admin 可 ?owner= 切换)。
- resolve_ingest_owner:采集接口归属(带有效 Bearer 令牌归该用户,否则回落 INGEST_OWNER)
- 采集接口(/api/ingest)直接依赖 get_current_user,以登录用户作为 owner,无回落
"""
from dataclasses import dataclass
......@@ -14,10 +14,6 @@ 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)
......@@ -44,8 +40,6 @@ def user_public(user: CurrentUser) -> dict:
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:
......@@ -70,22 +64,3 @@ def resolve_owner(request: Request, user: CurrentUser = Depends(get_current_user
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"
......@@ -6,17 +6,12 @@ from fastapi import APIRouter, Depends, Request
from sqlalchemy.orm import Session
from backend.app.db import get_db
from backend.app.dependencies import (
CurrentUser,
get_current_user,
resolve_ingest_owner,
)
from backend.app.dependencies import CurrentUser, get_current_user
from backend.app.repositories.ingest_repository import (
confirm_ingest,
list_pending_ingest,
upsert_ingest_candidate,
)
from backend.app.security import verify_ingest_token
from backend.app.services.file_storage import save_resume_bytes
from backend.app.services.resume_parser import parse_resume_path_async
......@@ -36,11 +31,12 @@ async def download_pdf(url: str, cookies: dict[str, Any] | None) -> bytes:
@router.post("/ingest", response_model=None)
async def ingest(request: Request, db: Session = Depends(get_db)):
token_error = verify_ingest_token(request)
if token_error:
return token_error
owner = resolve_ingest_owner(request, db)
async def ingest(
request: Request,
db: Session = Depends(get_db),
user: CurrentUser = Depends(get_current_user),
):
owner = user.username
raw = await request.json()
pdf_url = str(raw.get("pdfUrl") or "").strip()
parsed: dict[str, Any] = {}
......@@ -77,3 +73,4 @@ def clear_ingest_queue(
ids = [item.strip() for item in str(request.query_params.get("ids") or "").split(",") if item.strip()]
confirm_ingest(db, ids, owner)
return {"ok": True}
......@@ -23,13 +23,10 @@ class DeploymentAuthMiddleware(BaseHTTPMiddleware):
if not path.startswith("/api"):
return await call_next(request)
settings = get_settings()
# 未设置 ADMIN_PASSWORD:开放模式,不校验任何请求
if not settings.admin_password:
return await call_next(request)
if path in _EXEMPT_API_PATHS:
return await call_next(request)
# 配置了 INGEST_TOKEN 时,采集接口交给路由内令牌校验
if path in ("/api/ingest", "/api/jd/sync-external", "/api/agent/jobs", "/api/agent/recruiting-rules") and settings.ingest_token:
# 配置了 INGEST_TOKEN 时,智能体/外部 JD 同步等机器调用交给路由内令牌校验;/api/ingest 不再豁免,走统一登录
if path in ("/api/jd/sync-external", "/api/agent/jobs", "/api/agent/recruiting-rules") and settings.ingest_token:
return await call_next(request)
# 分析报告公网只读(ADR 0002):匿名放行 GET,路由内部校验 report_id + token。
if path.startswith("/api/share/") and request.method == "GET":
......
......@@ -86,7 +86,8 @@ def main(argv: list[str] | None = None) -> int:
uvicorn.run(
"backend.app.main:app",
host=host,
# host=host,
host="0.0.0.0",
port=port,
reload=args.reload,
reload_dirs=[str(BACKEND_DIR)] if args.reload else None,
......
......@@ -6,6 +6,8 @@ os.environ.setdefault("RECRUITMENT_SKIP_ENV_FILES", "1")
import pytest
from fastapi.testclient import TestClient
_TEST_ADMIN_PASSWORD = "test-admin-secret"
@pytest.fixture()
def client(tmp_path, monkeypatch):
......@@ -13,7 +15,9 @@ def client(tmp_path, monkeypatch):
monkeypatch.setenv("DATABASE_URL", f"sqlite:///{(tmp_path / 'test.sqlite').as_posix()}")
monkeypatch.setenv("DATA_DIR", str(tmp_path / "data"))
monkeypatch.setenv("FILES_DIR", str(tmp_path / "uploads"))
monkeypatch.setenv("ADMIN_PASSWORD", "")
monkeypatch.setenv("ADMIN_PASSWORD", _TEST_ADMIN_PASSWORD)
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", "")
......@@ -36,6 +40,12 @@ def client(tmp_path, monkeypatch):
app = create_app()
with TestClient(app) as test_client:
# 闭合模式:自动登录 admin 并注入 Authorization 头,使大部分用例对外无感
resp = test_client.post("/api/auth/login", json={"username": "admin", "password": _TEST_ADMIN_PASSWORD})
if resp.status_code != 200:
raise RuntimeError("测试环境 admin 登录失败: " + resp.text)
token = resp.json()["token"]
test_client.headers.update({"Authorization": f"Bearer {token}"})
yield test_client
db.engine.dispose()
config.get_settings.cache_clear()
"""多用户登录与工作区隔离测试。
"""多用户登录与工作区隔离测试(闭合模式)
- 开放模式(ADMIN_PASSWORD 为空):沿用 conftest 的 client fixture,行为与旧版一致
- 关闭模式(ADMIN_PASSWORD 已设置):用本文件 auth_client fixture(独立 SQLite + 环境变量)
- 默认 client fixture 已闭合且自动注入 admin 令牌(见 conftest)
- 本文件用 auth_client fixture(独立 SQLite + 环境变量)测试登录/权限/工作区隔离
"""
import pytest
......@@ -52,17 +52,6 @@ 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
# ---- 关闭模式:认证 ----
......@@ -251,3 +240,4 @@ def test_change_password(auth_client):
assert ok.status_code == 200
assert _login(auth_client, "admin", "admin-secret").status_code == 401
assert _login(auth_client, "admin", "newpass123").status_code == 200
......@@ -121,7 +121,7 @@ def test_claim_creates_job_in_workspace(client):
assert [j["title"] for j in state["jobs"]] == ["高级前端工程师"]
after = client.get("/api/job-claims").json()
assert after["claims"][0]["claimed"] is True
assert after["claims"][0]["claimedBy"] == "local"
assert after["claims"][0]["claimedBy"] == "admin"
assert after["claims"][0]["jobId"] == job["id"]
# 重复认领 -> 409
......@@ -205,3 +205,4 @@ def test_reserved_names_and_state_guards(auth_client):
# 保留区禁止整包 PUT(对齐 _pool)
payload = {"jobs": [], "candidates": [], "offers": [], "tasks": [], "eventLog": []}
assert auth_client.put("/api/state", json=payload, params={"owner": "_external"}, headers=admin_headers).status_code == 400
......@@ -83,3 +83,19 @@ def test_ingest_communicated_job_does_not_override_job_title(client):
assert resp.json()["ok"] is True
candidate = client.get("/api/state").json()["candidates"][0]
assert candidate["jobTitle"] == "高级产品经理"
def test_ingest_requires_auth_when_closed(client):
"""ADR 0007:闭合模式下 /api/ingest 未携带登录令牌 -> 401,绝不静默落默认账号。"""
resp = client.post("/api/ingest", json={"name": "无名", "summary": "无身份"}, headers={"Authorization": ""})
assert resp.status_code == 401
def test_ingest_attributes_to_logged_in_user(client):
"""ADR 0007:采集归属当前登录用户工作区(闭合模式下 client 为 admin)。"""
payload = {"name": "周八", "source": "Boss", "sourceUrl": "https://example.test/resume/5", "summary": "后端工程化"}
resp = client.post("/api/ingest", json=payload)
assert resp.status_code == 200
assert resp.json()["ok"] is True
state = client.get("/api/state").json()
assert any(c["name"] == "周八" for c in state["candidates"])
......@@ -78,7 +78,7 @@ def test_pool_move_read_claim_open_mode(client):
assert moved.status_code == 200
pooled = moved.json()["candidate"]
assert pooled["pool"] is True
assert pooled["pooledBy"] == "local"
assert pooled["pooledBy"] == "admin"
assert pooled["pooledNote"] == "面完未通过"
# 原工作区不再包含该候选人,池里有 1 人
......@@ -181,3 +181,4 @@ def test_reserved_username_rejected(auth_client):
headers=headers,
)
assert resp.status_code == 400
......@@ -77,7 +77,7 @@ def test_rules_default_and_write_open_mode(client):
assert resp.status_code == 200
rules = resp.json()["rules"]
assert rules["hardRules"][0]["job"] == "销售岗"
assert rules["updatedBy"] == "local"
assert rules["updatedBy"] == "admin"
assert rules["updatedAt"]
again = client.get("/api/recruiting-rules").json()["rules"]
......@@ -157,3 +157,4 @@ def test_agent_rules_and_jobs_readonly(agent_client):
missing = agent_client.get("/api/agent/jobs?jobId=job-nope", headers=agent_headers).json()
assert missing["total"] == 0
assert agent_client.get("/api/agent/jobs", headers=agent_headers).json()["total"] == 0
......@@ -50,7 +50,7 @@ def _build_app(tmp_path, monkeypatch, env_overrides, db_name):
"DATABASE_URL": f"sqlite:///{(tmp_path / db_name).as_posix()}",
"DATA_DIR": str(tmp_path / "data"),
"FILES_DIR": str(tmp_path / "uploads"),
"ADMIN_PASSWORD": "",
"ADMIN_PASSWORD": "test-reports-secret",
"ADMIN_USERNAME": "admin",
"SESSION_SECRET": "test-session-secret",
"INGEST_TOKEN": "",
......@@ -92,6 +92,12 @@ def share_open_client(tmp_path, monkeypatch):
"test-reports-open.sqlite",
)
with TestClient(app) as test_client:
# 闭合模式:自动登录 admin 并注入 Authorization 头
resp = test_client.post("/api/auth/login", json={"username": "admin", "password": "test-reports-secret"})
if resp.status_code != 200:
raise RuntimeError("share_open_client admin 登录失败: " + resp.text)
token = resp.json()["token"]
test_client.headers.update({"Authorization": f"Bearer {token}"})
yield test_client
db.engine.dispose()
config.get_settings.cache_clear()
......@@ -144,7 +150,7 @@ def test_generate_report_and_public_read(share_open_client):
assert report["snapshot"]["candidateName"] == "张三"
snapshot = report["snapshot"]
assert "phone" not in snapshot and "email" not in snapshot and "resumeText" not in snapshot
assert snapshot["author"]["name"] == "本地用户"
assert snapshot["author"]["name"] == "管理员"
report_id, token = _share_params(report["shareUrl"])
public = share_open_client.get(f"/api/share/report/{report_id}", params={"token": token})
......@@ -380,3 +386,5 @@ def test_reports_workspace_isolation_and_public_share(auth_reports_client):
# 匿名仍不能访问受保护业务接口
assert auth_reports_client.get("/api/state").status_code == 401
......@@ -7,12 +7,26 @@
1. 启动后端:`python -m uvicorn backend.app.main:app --host 127.0.0.1 --port 4177`
2. 打开 Chrome 或 Edge 的扩展程序页面并开启开发者模式。
3. 选择“加载已解压的扩展程序”,选择本目录。
4. 在扩展弹窗中确认 API 地址为 `http://127.0.0.1:4177/api/ingest`
4. 在扩展弹窗中确认 API 地址为 `http://127.0.0.1:4177/api/ingest`,并用你的招聘账号登录
> 部署到其他地址时:直接在弹窗里修改 API 地址并保存,点「测试连接」时 Chrome 会弹出一次访问授权,允许后即可;**无需改动 `manifest.json`**(扩展通过 `optional_host_permissions` 按需申请任意 http/https 主机权限,已授权的地址会直接通过)。
> 后端地址、账号可在弹窗直接改,**无需改动 `manifest.json`**。
>
> - **后端地址**:换服务器时改弹窗地址即可;后端需放开 CORS(默认 `CORS_ORIGINS=*`)。
> - **登录**:用招聘系统账号(用户名+密码)登录,采集的简历将归属该账号工作区;登录态自动存于扩展,下次免登录,失效时提示重新登录。
本扩展从 BOSS 直聘和猎聘页面采集候选人简历并提交给后端。`reference/` 是外部方案参考副本,不属于运行时扩展。
## v1.5
- 采集改为**按登录用户归属**:扩展内登录(用户名+密码),后台以 `Authorization: Bearer` 携带会话令牌提交 `/api/ingest`,简历同步到**本人工作区**;不再使用全局 `X-Ingest-Token`
- 弹窗自动检测已存 token(`chrome.storage.local`)并校验,有效即免登录,失效提示重新登录。
## v1.4
- 采集请求改经后台 Service Worker 转发,解决内容脚本跨源请求被 CORS 拦截导致的「本地服务未启动」。
- 新增「采集令牌」输入框,后台发 `X-Ingest-Token` 头;后端需在 `.env` 设置 `INGEST_TOKEN`
- 后端地址随意更换,只需改弹窗地址;无需再为每个后端手动申请主机权限(后端已默认放开 CORS)。
## v1.3(需求 23 / 25)
采集时补抓两项字段并随 payload 上传(抓不到则留空,不影响原有采集):
......@@ -20,3 +34,6 @@
- `communicatedJob`:平台「沟通职位」文本;仅在 DOM/明确标签命中且通过岗位名校验时才上传,避免把简历里的期望岗位误当沟通职位。
升级后请在扩展管理页点击刷新(重新加载扩展),再在 BOSS/猎聘候选人页重新采集一次,旧页面会带着新字段入库。
......@@ -35,23 +35,21 @@ async function getAllCookies(domain) {
});
}
// 从地址推导主机的 match pattern,如 http://172.18.100.59:4177/api/ingest -> http://172.18.100.59:4177/*
function originPatternFromUrl(raw) {
// 后台发 POST 到后端(扩展上下文拥有目标主机权限时可绕过 CORS;后端放开 CORS 时也可直接通过)
async function doIngest(apiUrl, payload) {
var token = '';
try {
var u = new URL(raw);
if (u.protocol !== 'http:' && u.protocol !== 'https:') return '';
return u.protocol + '//' + u.host + '/*';
var st = await chrome.storage.local.get('token');
token = (st && st.token) || '';
} catch (e) {
return '';
token = '';
}
}
// 后台发 POST 到后端(扩展上下文拥有目标主机权限时可绕过 CORS)
async function doIngest(apiUrl, payload) {
var headers = { 'Content-Type': 'application/json' };
if (token) headers['Authorization'] = 'Bearer ' + token;
try {
var r = await fetch(apiUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
headers: headers,
body: JSON.stringify(payload),
});
return r.ok ? await r.json() : { ok: false, message: '服务器返回 ' + r.status };
......@@ -73,22 +71,10 @@ chrome.runtime.onMessage.addListener(function (msg, sender, sendResponse) {
}
if (msg.action === 'ingest' && msg.apiUrl && msg.payload) {
var apiUrl = msg.apiUrl;
var payload = msg.payload;
var pattern = originPatternFromUrl(apiUrl);
if (pattern) {
chrome.permissions.contains({ origins: [pattern] }, function (has) {
if (!has) {
sendResponse({ ok: false, message: '未授权访问该后端,请先在弹窗点「测试连接」完成授权' });
return;
}
doIngest(apiUrl, payload).then(sendResponse);
});
} else {
doIngest(apiUrl, payload).then(sendResponse);
}
doIngest(msg.apiUrl, msg.payload).then(sendResponse);
return true; // 保持异步通道
}
});
console.log('[简历采集器 bg] Service Worker 已启动');
{
"manifest_version": 3,
"name": "简历采集器",
"version": "1.3",
"version": "1.5",
"description": "从 BOSS 直聘 / 猎聘自动采集简历 PDF,同步到本地招聘系统",
"permissions": ["activeTab", "storage", "cookies", "scripting"],
"optional_host_permissions": ["http://*:*/*", "https://*:*/*"],
"host_permissions": [
"*://*.zhipin.com/*",
"*://zhipin.com/*",
......@@ -47,3 +45,6 @@
"128": "icon.png"
}
}
......@@ -26,7 +26,7 @@
.section input:focus { border-color: #5874c8; }
button {
width: 100%; padding: 10px; border: 0; border-radius: 10px;
font-size: 13px; font-weight: 700; cursor: pointer;
font-size: 13px; font-weight: 700; cursor: pointer; margin-bottom: 8px;
}
button.primary {
background: linear-gradient(135deg, #5874c8, #75c3dd);
......@@ -34,6 +34,8 @@
}
button.primary:hover { opacity: .92; }
button.primary:disabled { opacity: .5; cursor: not-allowed; }
button.secondary { background: #eef2f7; color: #334155; }
button.secondary:hover { background: #e2e8f0; }
.log {
max-height: 120px; overflow-y: auto; margin-top: 8px;
font-size: 11px; color: #6f7b8d;
......@@ -54,6 +56,22 @@
<input id="apiUrl" type="text" value="http://127.0.0.1:4177/api/ingest">
</div>
<div id="authStatus" class="status off">未登录</div>
<div id="loginBox">
<div class="section">
<label>用户名</label>
<input id="username" type="text" placeholder="招聘系统用户名" autocomplete="username">
</div>
<div class="section">
<label>密码</label>
<input id="password" type="password" placeholder="招聘系统密码" autocomplete="current-password">
</div>
<button id="loginBtn" class="primary">登录</button>
</div>
<button id="logoutBtn" class="secondary" style="display:none">退出登录</button>
<button id="testBtn" class="primary">测试连接</button>
<div class="log" id="log"></div>
......
const apiUrlInput = document.getElementById("apiUrl");
const usernameInput = document.getElementById("username");
const passwordInput = document.getElementById("password");
const loginBtn = document.getElementById("loginBtn");
const logoutBtn = document.getElementById("logoutBtn");
const authStatus = document.getElementById("authStatus");
const statusEl = document.getElementById("statusEl");
const testBtn = document.getElementById("testBtn");
const logEl = document.getElementById("log");
// 从类似 http://host:4177/api/ingest 的地址推导后端根地址 http://host:4177
function baseOf(apiUrl) {
return (apiUrl || "").replace(/\/api\/ingest\/?$/, "").replace(/\/$/, "");
}
// 读取当前页面信息
(async () => {
const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });
const url = tab?.url || "";
if (url.includes("zhipin.com")) {
statusEl.textContent = "✅ 已检测到 BOSS 直聘页面 — 点击右下角按钮采集";
statusEl.className = "status on";
......@@ -20,27 +29,99 @@ const logEl = document.getElementById("log");
}
})();
// 加载保存的 API 地址
chrome.storage.local.get("apiUrl", (data) => {
// 加载保存的配置
chrome.storage.local.get(["apiUrl", "token", "username"], (data) => {
if (data.apiUrl) apiUrlInput.value = data.apiUrl;
if (data.username) usernameInput.value = data.username;
const token = data.token || "";
if (token) validateToken(token);
else refreshAuthUI();
});
// 保存 API 地址
// 保存地址
apiUrlInput.addEventListener("change", () => {
chrome.storage.local.set({ apiUrl: apiUrlInput.value });
});
// 从地址推导主机的 match pattern,如 http://172.18.100.59:4177/api/ingest -> http://172.18.100.59:4177/*
function originPatternFromUrl(raw) {
try {
const u = new URL(raw);
if (u.protocol !== "http:" && u.protocol !== "https:") return null;
return `${u.protocol}//${u.host}/*`;
} catch {
return null;
// —— 登录态相关 ——
function setAuthUI(loggedIn, displayName) {
if (loggedIn) {
authStatus.textContent = `✅ 已登录:${displayName}`;
authStatus.className = "status on";
document.getElementById("loginBox").style.display = "none";
logoutBtn.style.display = "block";
} else {
authStatus.textContent = "未登录";
authStatus.className = "status off";
document.getElementById("loginBox").style.display = "block";
logoutBtn.style.display = "none";
}
}
function refreshAuthUI() {
const token = "";
chrome.storage.local.get(["token", "username"], (data) => {
if (data.token) validateToken(data.token);
else setAuthUI(false);
});
}
function validateToken(token) {
const base = baseOf(apiUrlInput.value);
if (!base) return setAuthUI(false);
fetch(base + "/api/auth/me", { headers: { Authorization: `Bearer ${token}` }, signal: AbortSignal.timeout(5000) })
.then((r) => (r.ok ? r.json() : Promise.reject(new Error("status " + r.status))))
.then((data) => {
const name = data.user?.name || data.user?.username || "";
chrome.storage.local.set({ token, username: data.user?.username || "" });
setAuthUI(true, name);
})
.catch(() => {
chrome.storage.local.remove(["token", "username"]);
setAuthUI(false);
});
}
loginBtn.addEventListener("click", () => {
loginBtn.disabled = true;
loginBtn.textContent = "登录中...";
const base = baseOf(apiUrlInput.value);
const username = usernameInput.value.trim();
const password = passwordInput.value;
if (!base || !username || !password) {
addLog("err", "❌ 请填写后端地址、用户名和密码");
loginBtn.disabled = false;
loginBtn.textContent = "登录";
return;
}
fetch(base + "/api/auth/login", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ username, password }),
signal: AbortSignal.timeout(8000),
})
.then(async (r) => {
const data = await r.json().catch(() => ({}));
if (!r.ok) throw new Error(data.detail || `服务器返回 ${r.status}`);
chrome.storage.local.set({ token: data.token, username });
setAuthUI(true, data.user?.name || username);
addLog("ok", `✅ 登录成功:${data.user?.name || username}`);
})
.catch((err) => {
addLog("err", ` 登录失败:${err.message || "无法连接"}`);
})
.finally(() => {
loginBtn.disabled = false;
loginBtn.textContent = "登录";
});
});
logoutBtn.addEventListener("click", () => {
chrome.storage.local.remove(["token", "username"]);
setAuthUI(false);
addLog("ok", "已退出登录");
});
function resetBtn() {
testBtn.disabled = false;
testBtn.textContent = "测试连接";
......@@ -53,13 +134,9 @@ async function runTest(apiUrl) {
method: "GET",
signal: AbortSignal.timeout(3000),
});
if (resp.ok) {
addLog("ok", "✅ 本地系统连接正常");
} else {
addLog("err", `⚠️ 服务器返回 ${resp.status}`);
}
if (resp.ok) addLog("ok", "✅ 本地系统连接正常");
else addLog("err", `⚠️ 服务器返回 ${resp.status}`);
} catch {
// 如果 /api/ping 不存在,尝试直接连根路径
try {
const resp = await fetch(apiUrl.replace("/api/ingest", "/"), {
method: "HEAD",
......@@ -72,29 +149,16 @@ async function runTest(apiUrl) {
}
}
// 测试连接(点击按钮时同步申请该后端主机权限,换后端只需此处授权一次,无需改 manifest)
testBtn.addEventListener("click", () => {
testBtn.disabled = true;
testBtn.textContent = "测试中...";
const apiUrl = apiUrlInput.value;
chrome.storage.local.set({ apiUrl });
const pattern = originPatternFromUrl(apiUrl);
// 同步发起权限申请以保留用户手势;已授权过的会直接返回 true,不弹窗
if (pattern) {
chrome.permissions.request({ origins: [pattern] }, (res) => {
if (chrome.runtime.lastError || !res) {
addLog("err", "❌ 未授权访问该后端地址,请在弹窗中允许后重试");
return resetBtn();
}
runTest(apiUrl);
});
} else {
if (!/^https?:\/\//i.test(apiUrl)) {
addLog("err", "❌ 后端地址格式不正确,应为 http(s)://主机:端口/api/ingest");
return resetBtn();
}
runTest(apiUrl);
});
function addLog(type, msg) {
......
# ADR 0007:采集归属按人(多用户)——扩展登录 + 会话令牌,移除全局采集令牌与开放模式
- 状态:已接受(2026-09-07)
- 关联需求:多用户部署下,每位招聘专员采集的简历应归属本人工作区,而非统一落入 admin。
- 关联 CONTEXT 词表:工作区、招聘账号、开放模式(`_Avoid`)、采集归属(新增)、会话令牌(新增)。
## 背景 / Context
采集插件(browser-extension)通过 `POST /api/ingest` 投递简历。原实现为:
- 扩展只带全局共享密钥 `X-Ingest-Token`,从不携带用户身份;
- `resolve_ingest_owner` 在没有有效 Bearer 令牌时回落到 `INGEST_OWNER`(默认 `"admin"`)。
后果:后端一旦多用户使用,**所有采集简历都进入 admin 工作区**,各招聘专员的数据混在一起,无法区分归属。这与「服务提供给多用户、按账号隔离工作区」的目标冲突。
## 决策 / Decision
1. **采集归属按人**`/api/ingest` 的归属唯一来自**请求携带的有效登录会话令牌(Bearer)**;无有效身份 → **401 拒绝**,绝不回落默认账号。
2. **采集接口不再用全局 `X-Ingest-Token`**`/api/ingest` 从中间件的 `INGEST_TOKEN` 豁免中移除,与其它 API 一样走统一登录鉴权;删除 `INGEST_OWNER` 回落到默认账号的行为。`INGEST_TOKEN` / `verify_ingest_token` **保留**,仅作为 QwenPaw 智能体 / OA 机器端点(`/api/agent/*``/api/jd/sync-external`)的机器凭据,与采集归属无关。
3. **扩展内登录 + 自存自用**:扩展弹窗提供登录(用户名+密码 → `/api/auth/login`),成功后把 Bearer 令牌存入 `chrome.storage.local`;再次打开自动读取并校验(`/api/auth/me`),有效即免登录;失效/过期则提示重新登录。
4. **应用强制登录(移除开放模式)**`get_current_user` 不再产生合成本地用户 `local`;未设置 `ADMIN_PASSWORD` 时不再放行,而是要求登录。系统不再存在无登录可用的单机/开放形态。
5. **旧账号过渡**:已有 `admin` 账号保留;`INGEST_TOKEN` 若已配置,部署时移除,改由各用户自行登录扩展。
## 影响 / Consequences
- 正向:多用户采集各归其主,杜绝串号;无共享后门;一人一扩展登录态,符合按人隔离。
- 代价:
- 测试:`client`(开放模式)夹具需改为**闭合模式 + 自动登录 admin 并注入 Authorization 头**;大量依赖开放语义(`local` 归属)的用例需调整。
- 扩展:新增登录 UI、token 存储与过期提示;`X-Ingest-Token` 输入框移除。
- 部署:必须至少配置一位账号密码;不再有「零配置单机」形态。
- 后续可做:若需「公司人才池」采集,走独立共享工作区(`_pool`),不依赖个人令牌回退。
## 备选方案
- **A(仅采集强制身份,保留开放模式)**:改动小,但应用仍可有开放形态,与「纯多用户」定位不完全一致。
- **B(每用户长效个人采集令牌)**:免登录、不过期,但需新增个人令牌模型/接口。
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