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): ...@@ -34,7 +34,6 @@ class Settings(BaseSettings):
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") 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="*", alias="CORS_ORIGINS" default="*", alias="CORS_ORIGINS"
) )
......
"""认证与工作区依赖注入。 """认证与工作区依赖注入。
- get_current_user:开放模式返回合成本地用户,关闭模式解析会话/Basic 映射,失败抛 401 - get_current_user:要求有效登录态(会话/Basic 映射),失败抛 401;不再有开放模式合成用户
- require_admin:管理员限定。 - require_admin:管理员限定。
- resolve_owner:解析目标工作区(默认当前用户,admin 可 ?owner= 切换)。 - resolve_owner:解析目标工作区(默认当前用户,admin 可 ?owner= 切换)。
- resolve_ingest_owner:采集接口归属(带有效 Bearer 令牌归该用户,否则回落 INGEST_OWNER) - 采集接口(/api/ingest)直接依赖 get_current_user,以登录用户作为 owner,无回落
""" """
from dataclasses import dataclass from dataclasses import dataclass
...@@ -14,10 +14,6 @@ from sqlalchemy.orm import Session ...@@ -14,10 +14,6 @@ from sqlalchemy.orm import Session
from backend.app.config import get_settings from backend.app.config import get_settings
from backend.app.db import get_db from backend.app.db import get_db
from backend.app.models import User from backend.app.models import User
from backend.app.services.auth_security import verify_token
# 开放模式(未设置 ADMIN_PASSWORD)下所有请求归属的合成本地工作区
LOCAL_OWNER = "local"
@dataclass(frozen=True) @dataclass(frozen=True)
...@@ -44,8 +40,6 @@ def user_public(user: CurrentUser) -> dict: ...@@ -44,8 +40,6 @@ def user_public(user: CurrentUser) -> dict:
def get_current_user(request: Request, db: Session = Depends(get_db)) -> CurrentUser: 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): if getattr(request.state, "auth_via_basic", False):
user = db.scalar(select(User).where(User.username == get_settings().admin_username)) user = db.scalar(select(User).where(User.username == get_settings().admin_username))
else: else:
...@@ -70,22 +64,3 @@ def resolve_owner(request: Request, user: CurrentUser = Depends(get_current_user ...@@ -70,22 +64,3 @@ def resolve_owner(request: Request, user: CurrentUser = Depends(get_current_user
if owner != user.username and user.role != "admin": if owner != user.username and user.role != "admin":
raise HTTPException(status_code=403, detail="无权访问其他用户的工作区") raise HTTPException(status_code=403, detail="无权访问其他用户的工作区")
return owner 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 ...@@ -6,17 +6,12 @@ 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 ( from backend.app.dependencies import CurrentUser, get_current_user
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,
upsert_ingest_candidate, 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.file_storage import save_resume_bytes
from backend.app.services.resume_parser import parse_resume_path_async 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: ...@@ -36,11 +31,12 @@ async def download_pdf(url: str, cookies: dict[str, Any] | None) -> bytes:
@router.post("/ingest", response_model=None) @router.post("/ingest", response_model=None)
async def ingest(request: Request, db: Session = Depends(get_db)): async def ingest(
token_error = verify_ingest_token(request) request: Request,
if token_error: db: Session = Depends(get_db),
return token_error user: CurrentUser = Depends(get_current_user),
owner = resolve_ingest_owner(request, db) ):
owner = user.username
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] = {}
...@@ -77,3 +73,4 @@ def clear_ingest_queue( ...@@ -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()] ids = [item.strip() for item in str(request.query_params.get("ids") or "").split(",") if item.strip()]
confirm_ingest(db, ids, owner) confirm_ingest(db, ids, owner)
return {"ok": True} return {"ok": True}
...@@ -23,13 +23,10 @@ class DeploymentAuthMiddleware(BaseHTTPMiddleware): ...@@ -23,13 +23,10 @@ class DeploymentAuthMiddleware(BaseHTTPMiddleware):
if not path.startswith("/api"): if not path.startswith("/api"):
return await call_next(request) return await call_next(request)
settings = get_settings() settings = get_settings()
# 未设置 ADMIN_PASSWORD:开放模式,不校验任何请求
if not settings.admin_password:
return await call_next(request)
if path in _EXEMPT_API_PATHS: if path in _EXEMPT_API_PATHS:
return await call_next(request) return await call_next(request)
# 配置了 INGEST_TOKEN 时,采集接口交给路由内令牌校验 # 配置了 INGEST_TOKEN 时,智能体/外部 JD 同步等机器调用交给路由内令牌校验;/api/ingest 不再豁免,走统一登录
if path in ("/api/ingest", "/api/jd/sync-external", "/api/agent/jobs", "/api/agent/recruiting-rules") and settings.ingest_token: if path in ("/api/jd/sync-external", "/api/agent/jobs", "/api/agent/recruiting-rules") and settings.ingest_token:
return await call_next(request) return await call_next(request)
# 分析报告公网只读(ADR 0002):匿名放行 GET,路由内部校验 report_id + token。 # 分析报告公网只读(ADR 0002):匿名放行 GET,路由内部校验 report_id + token。
if path.startswith("/api/share/") and request.method == "GET": if path.startswith("/api/share/") and request.method == "GET":
......
...@@ -86,7 +86,8 @@ def main(argv: list[str] | None = None) -> int: ...@@ -86,7 +86,8 @@ def main(argv: list[str] | None = None) -> int:
uvicorn.run( uvicorn.run(
"backend.app.main:app", "backend.app.main:app",
host=host, # host=host,
host="0.0.0.0",
port=port, port=port,
reload=args.reload, reload=args.reload,
reload_dirs=[str(BACKEND_DIR)] if args.reload else None, reload_dirs=[str(BACKEND_DIR)] if args.reload else None,
......
...@@ -6,6 +6,8 @@ os.environ.setdefault("RECRUITMENT_SKIP_ENV_FILES", "1") ...@@ -6,6 +6,8 @@ os.environ.setdefault("RECRUITMENT_SKIP_ENV_FILES", "1")
import pytest import pytest
from fastapi.testclient import TestClient from fastapi.testclient import TestClient
_TEST_ADMIN_PASSWORD = "test-admin-secret"
@pytest.fixture() @pytest.fixture()
def client(tmp_path, monkeypatch): def client(tmp_path, monkeypatch):
...@@ -13,7 +15,9 @@ 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("DATABASE_URL", f"sqlite:///{(tmp_path / 'test.sqlite').as_posix()}")
monkeypatch.setenv("DATA_DIR", str(tmp_path / "data")) monkeypatch.setenv("DATA_DIR", str(tmp_path / "data"))
monkeypatch.setenv("FILES_DIR", str(tmp_path / "uploads")) 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("INGEST_TOKEN", "")
monkeypatch.setenv("LLM_API_KEY", "") monkeypatch.setenv("LLM_API_KEY", "")
monkeypatch.setenv("DEEPSEEK_API_KEY", "") monkeypatch.setenv("DEEPSEEK_API_KEY", "")
...@@ -36,6 +40,12 @@ def client(tmp_path, monkeypatch): ...@@ -36,6 +40,12 @@ def client(tmp_path, monkeypatch):
app = create_app() app = create_app()
with TestClient(app) as test_client: 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 yield test_client
db.engine.dispose() db.engine.dispose()
config.get_settings.cache_clear() config.get_settings.cache_clear()
"""多用户登录与工作区隔离测试。 """多用户登录与工作区隔离测试(闭合模式)
- 开放模式(ADMIN_PASSWORD 为空):沿用 conftest 的 client fixture,行为与旧版一致 - 默认 client fixture 已闭合且自动注入 admin 令牌(见 conftest)
- 关闭模式(ADMIN_PASSWORD 已设置):用本文件 auth_client fixture(独立 SQLite + 环境变量) - 本文件用 auth_client fixture(独立 SQLite + 环境变量)测试登录/权限/工作区隔离
""" """
import pytest import pytest
...@@ -52,17 +52,6 @@ def _auth_headers(token): ...@@ -52,17 +52,6 @@ def _auth_headers(token):
return {"Authorization": f"Bearer {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): ...@@ -251,3 +240,4 @@ def test_change_password(auth_client):
assert ok.status_code == 200 assert ok.status_code == 200
assert _login(auth_client, "admin", "admin-secret").status_code == 401 assert _login(auth_client, "admin", "admin-secret").status_code == 401
assert _login(auth_client, "admin", "newpass123").status_code == 200 assert _login(auth_client, "admin", "newpass123").status_code == 200
...@@ -121,7 +121,7 @@ def test_claim_creates_job_in_workspace(client): ...@@ -121,7 +121,7 @@ def test_claim_creates_job_in_workspace(client):
assert [j["title"] for j in state["jobs"]] == ["高级前端工程师"] assert [j["title"] for j in state["jobs"]] == ["高级前端工程师"]
after = client.get("/api/job-claims").json() after = client.get("/api/job-claims").json()
assert after["claims"][0]["claimed"] is True 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"] assert after["claims"][0]["jobId"] == job["id"]
# 重复认领 -> 409 # 重复认领 -> 409
...@@ -205,3 +205,4 @@ def test_reserved_names_and_state_guards(auth_client): ...@@ -205,3 +205,4 @@ def test_reserved_names_and_state_guards(auth_client):
# 保留区禁止整包 PUT(对齐 _pool) # 保留区禁止整包 PUT(对齐 _pool)
payload = {"jobs": [], "candidates": [], "offers": [], "tasks": [], "eventLog": []} payload = {"jobs": [], "candidates": [], "offers": [], "tasks": [], "eventLog": []}
assert auth_client.put("/api/state", json=payload, params={"owner": "_external"}, headers=admin_headers).status_code == 400 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): ...@@ -83,3 +83,19 @@ def test_ingest_communicated_job_does_not_override_job_title(client):
assert resp.json()["ok"] is True assert resp.json()["ok"] is True
candidate = client.get("/api/state").json()["candidates"][0] candidate = client.get("/api/state").json()["candidates"][0]
assert candidate["jobTitle"] == "高级产品经理" 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): ...@@ -78,7 +78,7 @@ def test_pool_move_read_claim_open_mode(client):
assert moved.status_code == 200 assert moved.status_code == 200
pooled = moved.json()["candidate"] pooled = moved.json()["candidate"]
assert pooled["pool"] is True assert pooled["pool"] is True
assert pooled["pooledBy"] == "local" assert pooled["pooledBy"] == "admin"
assert pooled["pooledNote"] == "面完未通过" assert pooled["pooledNote"] == "面完未通过"
# 原工作区不再包含该候选人,池里有 1 人 # 原工作区不再包含该候选人,池里有 1 人
...@@ -181,3 +181,4 @@ def test_reserved_username_rejected(auth_client): ...@@ -181,3 +181,4 @@ def test_reserved_username_rejected(auth_client):
headers=headers, headers=headers,
) )
assert resp.status_code == 400 assert resp.status_code == 400
...@@ -77,7 +77,7 @@ def test_rules_default_and_write_open_mode(client): ...@@ -77,7 +77,7 @@ def test_rules_default_and_write_open_mode(client):
assert resp.status_code == 200 assert resp.status_code == 200
rules = resp.json()["rules"] rules = resp.json()["rules"]
assert rules["hardRules"][0]["job"] == "销售岗" assert rules["hardRules"][0]["job"] == "销售岗"
assert rules["updatedBy"] == "local" assert rules["updatedBy"] == "admin"
assert rules["updatedAt"] assert rules["updatedAt"]
again = client.get("/api/recruiting-rules").json()["rules"] again = client.get("/api/recruiting-rules").json()["rules"]
...@@ -157,3 +157,4 @@ def test_agent_rules_and_jobs_readonly(agent_client): ...@@ -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() missing = agent_client.get("/api/agent/jobs?jobId=job-nope", headers=agent_headers).json()
assert missing["total"] == 0 assert missing["total"] == 0
assert agent_client.get("/api/agent/jobs", headers=agent_headers).json()["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): ...@@ -50,7 +50,7 @@ def _build_app(tmp_path, monkeypatch, env_overrides, db_name):
"DATABASE_URL": f"sqlite:///{(tmp_path / db_name).as_posix()}", "DATABASE_URL": f"sqlite:///{(tmp_path / db_name).as_posix()}",
"DATA_DIR": str(tmp_path / "data"), "DATA_DIR": str(tmp_path / "data"),
"FILES_DIR": str(tmp_path / "uploads"), "FILES_DIR": str(tmp_path / "uploads"),
"ADMIN_PASSWORD": "", "ADMIN_PASSWORD": "test-reports-secret",
"ADMIN_USERNAME": "admin", "ADMIN_USERNAME": "admin",
"SESSION_SECRET": "test-session-secret", "SESSION_SECRET": "test-session-secret",
"INGEST_TOKEN": "", "INGEST_TOKEN": "",
...@@ -92,6 +92,12 @@ def share_open_client(tmp_path, monkeypatch): ...@@ -92,6 +92,12 @@ def share_open_client(tmp_path, monkeypatch):
"test-reports-open.sqlite", "test-reports-open.sqlite",
) )
with TestClient(app) as test_client: 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 yield test_client
db.engine.dispose() db.engine.dispose()
config.get_settings.cache_clear() config.get_settings.cache_clear()
...@@ -144,7 +150,7 @@ def test_generate_report_and_public_read(share_open_client): ...@@ -144,7 +150,7 @@ def test_generate_report_and_public_read(share_open_client):
assert report["snapshot"]["candidateName"] == "张三" assert report["snapshot"]["candidateName"] == "张三"
snapshot = report["snapshot"] snapshot = report["snapshot"]
assert "phone" not in snapshot and "email" not in snapshot and "resumeText" not in 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"]) report_id, token = _share_params(report["shareUrl"])
public = share_open_client.get(f"/api/share/report/{report_id}", params={"token": token}) 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): ...@@ -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 assert auth_reports_client.get("/api/state").status_code == 401
...@@ -7,12 +7,26 @@ ...@@ -7,12 +7,26 @@
1. 启动后端:`python -m uvicorn backend.app.main:app --host 127.0.0.1 --port 4177` 1. 启动后端:`python -m uvicorn backend.app.main:app --host 127.0.0.1 --port 4177`
2. 打开 Chrome 或 Edge 的扩展程序页面并开启开发者模式。 2. 打开 Chrome 或 Edge 的扩展程序页面并开启开发者模式。
3. 选择“加载已解压的扩展程序”,选择本目录。 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/` 是外部方案参考副本,不属于运行时扩展。 本扩展从 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) ## v1.3(需求 23 / 25)
采集时补抓两项字段并随 payload 上传(抓不到则留空,不影响原有采集): 采集时补抓两项字段并随 payload 上传(抓不到则留空,不影响原有采集):
...@@ -20,3 +34,6 @@ ...@@ -20,3 +34,6 @@
- `communicatedJob`:平台「沟通职位」文本;仅在 DOM/明确标签命中且通过岗位名校验时才上传,避免把简历里的期望岗位误当沟通职位。 - `communicatedJob`:平台「沟通职位」文本;仅在 DOM/明确标签命中且通过岗位名校验时才上传,避免把简历里的期望岗位误当沟通职位。
升级后请在扩展管理页点击刷新(重新加载扩展),再在 BOSS/猎聘候选人页重新采集一次,旧页面会带着新字段入库。 升级后请在扩展管理页点击刷新(重新加载扩展),再在 BOSS/猎聘候选人页重新采集一次,旧页面会带着新字段入库。
...@@ -35,23 +35,21 @@ async function getAllCookies(domain) { ...@@ -35,23 +35,21 @@ async function getAllCookies(domain) {
}); });
} }
// 从地址推导主机的 match pattern,如 http://172.18.100.59:4177/api/ingest -> http://172.18.100.59:4177/* // 后台发 POST 到后端(扩展上下文拥有目标主机权限时可绕过 CORS;后端放开 CORS 时也可直接通过)
function originPatternFromUrl(raw) { async function doIngest(apiUrl, payload) {
var token = '';
try { try {
var u = new URL(raw); var st = await chrome.storage.local.get('token');
if (u.protocol !== 'http:' && u.protocol !== 'https:') return ''; token = (st && st.token) || '';
return u.protocol + '//' + u.host + '/*';
} catch (e) { } catch (e) {
return ''; token = '';
} }
} var headers = { 'Content-Type': 'application/json' };
if (token) headers['Authorization'] = 'Bearer ' + token;
// 后台发 POST 到后端(扩展上下文拥有目标主机权限时可绕过 CORS)
async function doIngest(apiUrl, payload) {
try { try {
var r = await fetch(apiUrl, { var r = await fetch(apiUrl, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: headers,
body: JSON.stringify(payload), body: JSON.stringify(payload),
}); });
return r.ok ? await r.json() : { ok: false, message: '服务器返回 ' + r.status }; return r.ok ? await r.json() : { ok: false, message: '服务器返回 ' + r.status };
...@@ -73,22 +71,10 @@ chrome.runtime.onMessage.addListener(function (msg, sender, sendResponse) { ...@@ -73,22 +71,10 @@ chrome.runtime.onMessage.addListener(function (msg, sender, sendResponse) {
} }
if (msg.action === 'ingest' && msg.apiUrl && msg.payload) { if (msg.action === 'ingest' && msg.apiUrl && msg.payload) {
var apiUrl = msg.apiUrl; doIngest(msg.apiUrl, msg.payload).then(sendResponse);
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);
}
return true; // 保持异步通道 return true; // 保持异步通道
} }
}); });
console.log('[简历采集器 bg] Service Worker 已启动'); console.log('[简历采集器 bg] Service Worker 已启动');
{ {
"manifest_version": 3, "manifest_version": 3,
"name": "简历采集器", "name": "简历采集器",
"version": "1.3", "version": "1.5",
"description": "从 BOSS 直聘 / 猎聘自动采集简历 PDF,同步到本地招聘系统", "description": "从 BOSS 直聘 / 猎聘自动采集简历 PDF,同步到本地招聘系统",
"permissions": ["activeTab", "storage", "cookies", "scripting"], "permissions": ["activeTab", "storage", "cookies", "scripting"],
"optional_host_permissions": ["http://*:*/*", "https://*:*/*"],
"host_permissions": [ "host_permissions": [
"*://*.zhipin.com/*", "*://*.zhipin.com/*",
"*://zhipin.com/*", "*://zhipin.com/*",
...@@ -47,3 +45,6 @@ ...@@ -47,3 +45,6 @@
"128": "icon.png" "128": "icon.png"
} }
} }
...@@ -26,7 +26,7 @@ ...@@ -26,7 +26,7 @@
.section input:focus { border-color: #5874c8; } .section input:focus { border-color: #5874c8; }
button { button {
width: 100%; padding: 10px; border: 0; border-radius: 10px; 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 { button.primary {
background: linear-gradient(135deg, #5874c8, #75c3dd); background: linear-gradient(135deg, #5874c8, #75c3dd);
...@@ -34,6 +34,8 @@ ...@@ -34,6 +34,8 @@
} }
button.primary:hover { opacity: .92; } button.primary:hover { opacity: .92; }
button.primary:disabled { opacity: .5; cursor: not-allowed; } button.primary:disabled { opacity: .5; cursor: not-allowed; }
button.secondary { background: #eef2f7; color: #334155; }
button.secondary:hover { background: #e2e8f0; }
.log { .log {
max-height: 120px; overflow-y: auto; margin-top: 8px; max-height: 120px; overflow-y: auto; margin-top: 8px;
font-size: 11px; color: #6f7b8d; font-size: 11px; color: #6f7b8d;
...@@ -54,6 +56,22 @@ ...@@ -54,6 +56,22 @@
<input id="apiUrl" type="text" value="http://127.0.0.1:4177/api/ingest"> <input id="apiUrl" type="text" value="http://127.0.0.1:4177/api/ingest">
</div> </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> <button id="testBtn" class="primary">测试连接</button>
<div class="log" id="log"></div> <div class="log" id="log"></div>
......
const apiUrlInput = document.getElementById("apiUrl"); 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 statusEl = document.getElementById("statusEl");
const testBtn = document.getElementById("testBtn"); const testBtn = document.getElementById("testBtn");
const logEl = document.getElementById("log"); const logEl = document.getElementById("log");
// 从类似 http://host:4177/api/ingest 的地址推导后端根地址 http://host:4177
function baseOf(apiUrl) {
return (apiUrl || "").replace(/\/api\/ingest\/?$/, "").replace(/\/$/, "");
}
// 读取当前页面信息 // 读取当前页面信息
(async () => { (async () => {
const [tab] = await chrome.tabs.query({ active: true, currentWindow: true }); const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });
const url = tab?.url || ""; const url = tab?.url || "";
if (url.includes("zhipin.com")) { if (url.includes("zhipin.com")) {
statusEl.textContent = "✅ 已检测到 BOSS 直聘页面 — 点击右下角按钮采集"; statusEl.textContent = "✅ 已检测到 BOSS 直聘页面 — 点击右下角按钮采集";
statusEl.className = "status on"; statusEl.className = "status on";
...@@ -20,27 +29,99 @@ const logEl = document.getElementById("log"); ...@@ -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.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", () => { apiUrlInput.addEventListener("change", () => {
chrome.storage.local.set({ apiUrl: apiUrlInput.value }); 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) { function setAuthUI(loggedIn, displayName) {
try { if (loggedIn) {
const u = new URL(raw); authStatus.textContent = `✅ 已登录:${displayName}`;
if (u.protocol !== "http:" && u.protocol !== "https:") return null; authStatus.className = "status on";
return `${u.protocol}//${u.host}/*`; document.getElementById("loginBox").style.display = "none";
} catch { logoutBtn.style.display = "block";
return null; } 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() { function resetBtn() {
testBtn.disabled = false; testBtn.disabled = false;
testBtn.textContent = "测试连接"; testBtn.textContent = "测试连接";
...@@ -53,13 +134,9 @@ async function runTest(apiUrl) { ...@@ -53,13 +134,9 @@ async function runTest(apiUrl) {
method: "GET", method: "GET",
signal: AbortSignal.timeout(3000), signal: AbortSignal.timeout(3000),
}); });
if (resp.ok) { if (resp.ok) addLog("ok", "✅ 本地系统连接正常");
addLog("ok", "✅ 本地系统连接正常"); else addLog("err", `⚠️ 服务器返回 ${resp.status}`);
} else {
addLog("err", `⚠️ 服务器返回 ${resp.status}`);
}
} catch { } catch {
// 如果 /api/ping 不存在,尝试直接连根路径
try { try {
const resp = await fetch(apiUrl.replace("/api/ingest", "/"), { const resp = await fetch(apiUrl.replace("/api/ingest", "/"), {
method: "HEAD", method: "HEAD",
...@@ -72,29 +149,16 @@ async function runTest(apiUrl) { ...@@ -72,29 +149,16 @@ async function runTest(apiUrl) {
} }
} }
// 测试连接(点击按钮时同步申请该后端主机权限,换后端只需此处授权一次,无需改 manifest)
testBtn.addEventListener("click", () => { testBtn.addEventListener("click", () => {
testBtn.disabled = true; testBtn.disabled = true;
testBtn.textContent = "测试中..."; testBtn.textContent = "测试中...";
const apiUrl = apiUrlInput.value; const apiUrl = apiUrlInput.value;
chrome.storage.local.set({ apiUrl }); chrome.storage.local.set({ apiUrl });
if (!/^https?:\/\//i.test(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 {
addLog("err", "❌ 后端地址格式不正确,应为 http(s)://主机:端口/api/ingest"); addLog("err", "❌ 后端地址格式不正确,应为 http(s)://主机:端口/api/ingest");
return resetBtn(); return resetBtn();
} }
runTest(apiUrl);
}); });
function addLog(type, msg) { 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