From d9abd0b86ac52d708b6dda9596dfa0cc30384b33 Mon Sep 17 00:00:00 2001 From: admin Date: Fri, 16 Oct 2026 14:42:23 +0800 Subject: [PATCH] =?UTF-8?q?feat(auth):=20=E7=94=A8=E6=88=B7=E7=AB=AF?= =?UTF-8?q?=E9=89=B4=E6=9D=83(=E7=99=BB=E5=BD=95/=E6=8E=88=E6=9D=83?= =?UTF-8?q?=E6=B8=85=E5=8D=95/=E9=80=90=E6=AC=A1=E6=A0=A1=E9=AA=8C/?= =?UTF-8?q?=E5=8A=9F=E8=83=BD=E9=97=A8=E7=A6=81=20+=20owner=20=E6=95=B0?= =?UTF-8?q?=E6=8D=AE=E9=9A=94=E7=A6=BB)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- sdk/app/auth.py | 48 ++++++++++++++++++-- sdk/app/config.py | 4 ++ sdk/app/db.py | 8 ++++ sdk/app/main.py | 32 ++++++++------ sdk/app/routers/auth.py | 52 ++++++++++++++++++++++ sdk/app/routers/bioflow_proxy.py | 6 +++ sdk/app/routers/capabilities.py | 7 ++- sdk/app/routers/docgen.py | 23 +++++++++- sdk/app/routers/paper.py | 26 +++++++++-- sdk/app/routers/qa.py | 28 ++++++++---- sdk/app/routers/sessions.py | 75 ++++++++++++++++++++------------ sdk/app/services/auth.py | 5 ++- 12 files changed, 252 insertions(+), 62 deletions(-) create mode 100644 sdk/app/routers/auth.py diff --git a/sdk/app/auth.py b/sdk/app/auth.py index 400a670..b6cb7e4 100644 --- a/sdk/app/auth.py +++ b/sdk/app/auth.py @@ -1,8 +1,15 @@ -"""auth.py —— 管理后台鉴权依赖 + 鉴权异常。""" +"""auth.py —— 鉴权依赖(管理员 + 用户级)+ 鉴权异常。 + +- `get_current_user`:受 auth_enabled 开关控制;关=匿名(None),开=校验 token(401 失败)。 +- `require_user`:强制登录(不受开关控制),用于 /api/auth/me、/logout 等必须登录的端点。 +- `require_admin`:管理员;复用 require_user + role 校验。 +- `require_authorization(unit)`:功能门禁;关=放行,开=admin 或 unit 在 authorizations 才放行。 +""" from __future__ import annotations from fastapi import Depends, Request +from .config import get_settings from .db import Database, get_db from .logwriter import log_event from .services.auth import resolve_token @@ -21,15 +28,48 @@ def _bearer(request: Request) -> str: return header[7:] if header.startswith("Bearer ") else "" -async def require_admin(request: Request, db: Database = Depends(get_db)) -> dict: - """校验 Authorization: Bearer ,并要求角色为 admin;失败抛 AuthError 并留痕。""" +async def get_current_user(request: Request, db: Database = Depends(get_db)) -> dict | None: + """用户级鉴权;auth_enabled 为假时返回 None(匿名放行)。""" + if not get_settings().auth_enabled: + return None token = _bearer(request) user = await resolve_token(db, token) if token else None if user is None: log_event("call_denied", method=request.method, path=request.url.path, detail={"reason": "unauthorized"}) raise AuthError(401, "UNAUTHORIZED", "未登录或登录已过期") + request.state.user = user + return user + + +async def require_user(request: Request, db: Database = Depends(get_db)) -> dict: + """强制登录(不受 auth_enabled 开关控制)。""" + token = _bearer(request) + user = await resolve_token(db, token) if token else None + if user is None: + log_event("call_denied", method=request.method, path=request.url.path, detail={"reason": "unauthorized"}) + raise AuthError(401, "UNAUTHORIZED", "未登录或登录已过期") + request.state.user = user + return user + + +async def require_admin(request: Request, db: Database = Depends(get_db)) -> dict: + user = await require_user(request, db) if user["role"] != "admin": log_event("call_denied", user_id=user["id"], method=request.method, path=request.url.path, detail={"reason": "not_admin"}) raise AuthError(403, "FORBIDDEN", "非管理员账号") - request.state.user = user return user + + +def require_authorization(unit: str): + """功能门禁依赖工厂;auth_enabled 关=放行,开=admin 或 unit 在 authorizations 放行。""" + + async def _dep(request: Request, db: Database = Depends(get_db)) -> dict | None: + if not get_settings().auth_enabled: + return None + user = await require_user(request, db) + if user["role"] != "admin" and unit not in (user.get("authorizations") or []): + log_event("call_denied", user_id=user["id"], method=request.method, path=request.url.path, detail={"reason": "forbidden", "unit": unit}) + raise AuthError(403, "FORBIDDEN", "无权访问该功能") + return user + + return _dep diff --git a/sdk/app/config.py b/sdk/app/config.py index f841fe4..7159c24 100644 --- a/sdk/app/config.py +++ b/sdk/app/config.py @@ -94,6 +94,10 @@ class Settings(BaseSettings): log_dir: str = "data/logs" # 行为日志目录(每日一个 JSONL 文件) log_retention_days: int = 0 # 日志保留天数(0=不清理) + # ---- 用户端鉴权(普通用户登录;AUTH_ENABLED 为假=匿名放行,兼容浸泡期)---- + auth_enabled: bool = False + user_token_ttl_seconds: int = 86400 # 普通用户会话凭据有效期(秒) + # ---- 生产形态:BFF 静态托管 ui/dist(ARCHITECTURE.md §3,M6 挂载)---- frontend_dist_dir: str = "ui/dist" # 生产形态:静态托管 ui/dist diff --git a/sdk/app/db.py b/sdk/app/db.py index 4a894c6..ee8f465 100644 --- a/sdk/app/db.py +++ b/sdk/app/db.py @@ -209,6 +209,14 @@ class Database: await self._conn.executescript(_ADMIN_SCHEMA) except Exception: logger.exception("admin 表迁移失败(管理后台将不可用,其余不受影响)") + # sessions 加 owner_id(用户级数据隔离,幂等 ALTER) + try: + cur = await self._conn.execute("PRAGMA table_info(sessions)") + cols = {row[1] for row in await cur.fetchall()} + if "owner_id" not in cols: + await self._conn.execute("ALTER TABLE sessions ADD COLUMN owner_id TEXT") + except Exception: + logger.exception("sessions owner_id 迁移失败") await self._conn.commit() async def close(self) -> None: diff --git a/sdk/app/main.py b/sdk/app/main.py index 8a69654..d810dfe 100644 --- a/sdk/app/main.py +++ b/sdk/app/main.py @@ -15,7 +15,7 @@ import time from contextlib import asynccontextmanager from pathlib import Path -from fastapi import FastAPI, Request +from fastapi import Depends, FastAPI, Request from fastapi.exceptions import RequestValidationError from fastapi.middleware.cors import CORSMiddleware from fastapi.responses import JSONResponse @@ -29,8 +29,9 @@ from .routers import paper from .routers import private_docs from .routers import review_proxy from .routers import admin +from .routers import auth from . import logwriter -from .auth import AuthError +from .auth import AuthError, get_current_user, require_authorization from .services.auth import seed_admin from .services.settings import get_hub_settings from .upstream import close_client @@ -61,8 +62,10 @@ def create_app() -> FastAPI: start = time.monotonic() response = await call_next(request) if request.url.path.startswith("/api/"): + user = getattr(request.state, "user", None) logwriter.log_event( "http_request", + user_id=user["id"] if user else None, method=request.method, path=request.url.path, status=response.status_code, @@ -79,21 +82,22 @@ def create_app() -> FastAPI: ) app.include_router(health.router) - app.include_router(sessions.router) - app.include_router(qa.router) - app.include_router(capabilities.router) - app.include_router(kg.router) - app.include_router(extraction.router) - app.include_router(figures.router) - app.include_router(legacy.router) - app.include_router(docgen.router) # docgen(P2) - app.include_router(private_docs.router) # 私有资料问答(FR-PRIV-004) + app.include_router(auth.router) # /api/auth/*(login/status 免鉴权,me/logout 需登录) + app.include_router(sessions.router, dependencies=[Depends(get_current_user)]) + app.include_router(qa.router, dependencies=[Depends(require_authorization("qa"))]) + app.include_router(capabilities.router, dependencies=[Depends(get_current_user)]) + app.include_router(kg.router, dependencies=[Depends(require_authorization("kg"))]) + app.include_router(extraction.router, dependencies=[Depends(require_authorization("extraction"))]) + app.include_router(figures.router, dependencies=[Depends(require_authorization("figures"))]) + app.include_router(legacy.router, dependencies=[Depends(require_authorization("legacy"))]) + app.include_router(docgen.router, dependencies=[Depends(require_authorization("docgen"))]) # docgen(P2) + app.include_router(private_docs.router, dependencies=[Depends(require_authorization("private_docs"))]) # 私有资料问答(FR-PRIV-004) if get_settings().bioflow_enabled: - app.include_router(bioflow_proxy.router) # 生信反代(BIOFLOW_ENABLED 开关,FR-BIO-028) + app.include_router(bioflow_proxy.router, dependencies=[Depends(require_authorization("bioflow"))]) # 生信反代(BIOFLOW_ENABLED 开关,FR-BIO-028) if get_settings().paper_writer_enabled: - app.include_router(paper.router) # 论文撰写(PAPER_WRITER_ENABLED 开关) + app.include_router(paper.router, dependencies=[Depends(require_authorization("paper"))]) # 论文撰写(PAPER_WRITER_ENABLED 开关) if get_settings().review_enabled: - app.include_router(review_proxy.router) # 科研审稿反代(REVIEW_ENABLED 开关) + app.include_router(review_proxy.router, dependencies=[Depends(get_current_user)]) # 科研审稿反代(REVIEW_ENABLED 开关) app.include_router(admin.router) # 管理后台(/api/admin/*) @app.exception_handler(RequestValidationError) diff --git a/sdk/app/routers/auth.py b/sdk/app/routers/auth.py new file mode 100644 index 0000000..307cd19 --- /dev/null +++ b/sdk/app/routers/auth.py @@ -0,0 +1,52 @@ +"""routers/auth.py —— 用户端鉴权端点(登录/登出/me/status)。""" +from __future__ import annotations + +from fastapi import APIRouter, Depends, Request +from fastapi.responses import JSONResponse +from pydantic import BaseModel + +from ..auth import require_user +from ..config import get_settings +from ..db import Database, get_db +from ..logwriter import log_event +from ..services.auth import verify_login, issue_token, revoke_token +from ..upstream import envelope + +router = APIRouter(prefix="/api/auth", tags=["auth"]) + + +class LoginBody(BaseModel): + username: str + password: str + + +@router.get("/status") +async def status(): + """免鉴权:告知前端是否开启鉴权(决定是否显示登录页)。""" + return {"ok": True, "auth_enabled": get_settings().auth_enabled} + + +@router.post("/login") +async def login(body: LoginBody, db: Database = Depends(get_db)): + user = await verify_login(db, body.username, body.password) + if user is None: + log_event("login_fail", detail={"username": body.username}) + content, status = envelope("UNAUTHORIZED", "用户名或密码错误", status=401) + return JSONResponse(status_code=status, content=content) + token = await issue_token(db, user["id"], ttl_seconds=get_settings().user_token_ttl_seconds) + log_event("login_ok", user_id=user["id"], detail={"username": body.username}) + return {"ok": True, "token": token, "user": user} + + +@router.post("/logout") +async def logout(request: Request, db: Database = Depends(get_db), _user: dict = Depends(require_user)): + header = request.headers.get("Authorization", "") + token = header[7:] if header.startswith("Bearer ") else "" + if token: + await revoke_token(db, token) + return {"ok": True} + + +@router.get("/me") +async def me(_user: dict = Depends(require_user)): + return {"ok": True, "user": _user} diff --git a/sdk/app/routers/bioflow_proxy.py b/sdk/app/routers/bioflow_proxy.py index d10adf2..639ec90 100644 --- a/sdk/app/routers/bioflow_proxy.py +++ b/sdk/app/routers/bioflow_proxy.py @@ -138,6 +138,12 @@ async def register_report(request: Request, db: Database = Depends(get_db)) -> A import uuid data = payload.get("data", payload) if isinstance(payload, dict) else {} + sid = str(data.get("session_id") or "") + user = getattr(request.state, "user", None) + if user and sid: + owner_row = await db.fetchone("SELECT owner_id FROM sessions WHERE id = ?", (sid,)) + if owner_row is None or owner_row["owner_id"] != user["id"]: + return proxied # 归属不符:不落库,仍透传生信响应 artifact_id = uuid.uuid4().hex[:16] await db.execute( """INSERT INTO doc_artifacts (id, session_id, status, title, template_name, diff --git a/sdk/app/routers/capabilities.py b/sdk/app/routers/capabilities.py index 2831f83..f5461fd 100644 --- a/sdk/app/routers/capabilities.py +++ b/sdk/app/routers/capabilities.py @@ -36,7 +36,12 @@ async def list_capabilities( session_caps: Optional[dict] = None if session_id: try: - row = await db.fetchone("SELECT meta FROM sessions WHERE id = ?", (session_id,)) + user = getattr(request.state, "user", None) + owner_id = user["id"] if user else None + if owner_id is None: + row = await db.fetchone("SELECT meta FROM sessions WHERE id = ?", (session_id,)) + else: + row = await db.fetchone("SELECT meta FROM sessions WHERE id = ? AND owner_id = ?", (session_id, owner_id)) if row is not None: meta = loads_json(row["meta"], default={}) if isinstance(meta, dict): diff --git a/sdk/app/routers/docgen.py b/sdk/app/routers/docgen.py index bceea50..0747a36 100644 --- a/sdk/app/routers/docgen.py +++ b/sdk/app/routers/docgen.py @@ -49,7 +49,7 @@ from datetime import datetime, timezone from pathlib import Path from typing import Any -from fastapi import APIRouter, Depends, File, Form, Query, Response, UploadFile +from fastapi import APIRouter, Depends, File, Form, Query, Request, Response, UploadFile from fastapi.responses import FileResponse, JSONResponse from ..config import get_settings @@ -109,6 +109,13 @@ def _envelope(code: str, message: str | None = None, degraded: bool | None = Non return JSONResponse(status_code=http, content=body) +async def _session_owned(db: Database, session_id: str, owner_id: str | None) -> bool: + if owner_id is None: + return True + row = await db.fetchone("SELECT owner_id FROM sessions WHERE id = ?", (session_id,)) + return row is not None and row["owner_id"] == owner_id + + def _validation(message: str) -> JSONResponse: return _envelope("VALIDATION", message=message, status=422) @@ -646,6 +653,7 @@ async def _run_docgen_task( @router.post("/generate") async def generate_doc( payload: DocgenGenerateRequest, + request: Request, db: Database = Depends(get_db), ): """异步生成(缺陷 #52,2026-09-10):快路径校验 → 插任务行 → 后台生成 → 202。 @@ -657,6 +665,9 @@ async def generate_doc( 202 {ok, task_id, status:"queued"}(契约见 docs/acceptance-r3.md 附录 B)。 chat 消息落库由前端走现有 sessions 机制,本端点不碰。 """ + user = getattr(request.state, "user", None) + if user and payload.session_id and not await _session_owned(db, payload.session_id, user["id"]): + return _envelope("NOT_FOUND", message="会话不存在", status=404) bad = _check_description(payload.description) if bad is not None: return bad @@ -767,15 +778,25 @@ async def get_docgen_task(task_id: str, db: Database = Depends(get_db)): @router.get("/tasks") async def list_docgen_tasks( + request: Request, session_id: str = Query("", description="按会话过滤;空=全部(上限 100)"), db: Database = Depends(get_db), ): """会话任务列表(缺陷 #52 契约,附录 B)。""" + user = getattr(request.state, "user", None) + if user and session_id and not await _session_owned(db, session_id, user["id"]): + return _envelope("NOT_FOUND", message="会话不存在", status=404) if session_id: rows = await db.fetchall( "SELECT id FROM docgen_tasks WHERE session_id = ? ORDER BY created_at DESC LIMIT 100", (session_id,), ) + elif user: + rows = await db.fetchall( + "SELECT t.id FROM docgen_tasks t JOIN sessions s ON s.id = t.session_id " + "WHERE s.owner_id = ? ORDER BY t.created_at DESC LIMIT 100", + (user["id"],), + ) else: rows = await db.fetchall( "SELECT id FROM docgen_tasks ORDER BY created_at DESC LIMIT 100" diff --git a/sdk/app/routers/paper.py b/sdk/app/routers/paper.py index ba96997..869b4ec 100644 --- a/sdk/app/routers/paper.py +++ b/sdk/app/routers/paper.py @@ -9,13 +9,15 @@ import asyncio from typing import Any import httpx -from fastapi import APIRouter, Depends +from fastapi import APIRouter, Depends, Request +from fastapi.responses import JSONResponse from pydantic import BaseModel from ..config import get_settings from ..db import Database, get_db from ..services.paper import agent from ..services.paper.mcp_client import PaperMcpClient +from ..upstream import envelope router = APIRouter(prefix="/api/paper", tags=["paper"]) @@ -36,6 +38,13 @@ def _client() -> PaperMcpClient: return PaperMcpClient(s.paper_writer_url, s.paper_writer_read_timeout) +async def _session_owned(db: Database, session_id: str, owner_id: str | None) -> bool: + if owner_id is None: + return True + row = await db.fetchone("SELECT owner_id FROM sessions WHERE id = ?", (session_id,)) + return row is not None and row["owner_id"] == owner_id + + @router.get("/health") async def paper_health() -> dict[str, Any]: """探活反代 8097 /health;离线返回 offline 标记(前端据此隐藏 paper 入口)。""" @@ -50,19 +59,28 @@ async def paper_health() -> dict[str, Any]: @router.post("/turns") -async def paper_turn(payload: PaperTurnRequest, db: Database = Depends(get_db)) -> dict[str, Any]: +async def paper_turn(payload: PaperTurnRequest, request: Request, db: Database = Depends(get_db)) -> dict[str, Any]: + user = getattr(request.state, "user", None) + if user and not await _session_owned(db, payload.session_id, user["id"]): + return JSONResponse(status_code=404, content=envelope("NOT_FOUND", message="会话不存在", status=404)[0]) blocks = await agent.handle_turn(db, payload.session_id, payload.text, payload.paper_id, payload.template_key) return {"ok": True, "blocks": blocks} @router.post("/papers/{paper_id}/confirm") -async def paper_confirm(paper_id: str, payload: PaperConfirmRequest, db: Database = Depends(get_db)) -> dict[str, Any]: +async def paper_confirm(paper_id: str, payload: PaperConfirmRequest, request: Request, db: Database = Depends(get_db)) -> dict[str, Any]: + user = getattr(request.state, "user", None) + if user and not await _session_owned(db, payload.session_id, user["id"]): + return JSONResponse(status_code=404, content=envelope("NOT_FOUND", message="会话不存在", status=404)[0]) asyncio.create_task(agent.run_writing_loop(db, paper_id, payload.session_id)) return {"ok": True, "paper_id": paper_id} @router.post("/papers/{paper_id}/resume") -async def paper_resume(paper_id: str, payload: PaperConfirmRequest, db: Database = Depends(get_db)) -> dict[str, Any]: +async def paper_resume(paper_id: str, payload: PaperConfirmRequest, request: Request, db: Database = Depends(get_db)) -> dict[str, Any]: + user = getattr(request.state, "user", None) + if user and not await _session_owned(db, payload.session_id, user["id"]): + return JSONResponse(status_code=404, content=envelope("NOT_FOUND", message="会话不存在", status=404)[0]) asyncio.create_task(agent.run_writing_loop(db, paper_id, payload.session_id)) return {"ok": True, "paper_id": paper_id} diff --git a/sdk/app/routers/qa.py b/sdk/app/routers/qa.py index d23f7f1..c1a8358 100644 --- a/sdk/app/routers/qa.py +++ b/sdk/app/routers/qa.py @@ -100,13 +100,14 @@ async def _proxy( retry_invalid_citation: bool, mode: str, normalizer=None, + owner_id: str | None = None, ) -> Any: """非流式上游代理:重试、错误信封、归一化。返回 (JSONResponse)。""" client = sse_relay.get_qa_client() normalizer = normalizer or sse_relay.get_qa_normalizer() url = f"{sse_relay.qa_base_url()}{_upstream_path(endpoint)}" timeout = _read_timeout(read_timeout) - json_body, _humanize = await _inject_history(body) + json_body, _humanize = await _inject_history(body, owner_id=owner_id) for attempt in (1, 2): try: @@ -206,8 +207,10 @@ async def qa_info(): async def qa_chat(request: Request): """非流式问答:上游 /chat + 归一化 + invalid_llm_citation 重试一次。""" body = await request.json() + user = getattr(request.state, "user", None) + owner_id = user["id"] if user else None return await _proxy( - "chat", body, read_timeout=CHAT_READ_TIMEOUT, retry_invalid_citation=True, mode="chat" + "chat", body, read_timeout=CHAT_READ_TIMEOUT, retry_invalid_citation=True, mode="chat", owner_id=owner_id ) @@ -224,7 +227,9 @@ async def qa_chat_stream(request: Request): except Exception: return _envelope("VALIDATION", 422) evidence_first = bool(body.pop("evidence_first", False)) - body, humanize_on = await _inject_history(body) + user = getattr(request.state, "user", None) + owner_id = user["id"] if user else None + body, humanize_on = await _inject_history(body, owner_id=owner_id) return StreamingResponse( sse_relay.relay_stream(body, evidence_first=evidence_first, humanize_enabled=humanize_on), media_type="text/event-stream", @@ -242,8 +247,10 @@ async def qa_search(request: Request): """ body = await request.json() body = {**body, "use_llm": False, "native_chain_enabled": False} + user = getattr(request.state, "user", None) + owner_id = user["id"] if user else None return await _proxy( - "search", body, read_timeout=SEARCH_READ_TIMEOUT, retry_invalid_citation=False, mode="search" + "search", body, read_timeout=SEARCH_READ_TIMEOUT, retry_invalid_citation=False, mode="search", owner_id=owner_id ) @@ -251,7 +258,7 @@ async def qa_search(request: Request): # 多轮记忆(S2):剥离 session_id → 组装 history 注入上游 # --------------------------------------------------------------------------- -async def _inject_history(body: Optional[dict]) -> tuple[dict, bool]: +async def _inject_history(body: Optional[dict], owner_id: str | None = None) -> tuple[dict, bool]: """剥离 BFF 契约字段 session_id,读会话 meta 注入 history/capabilities。 - 不传 session_id / 会话不存在 → 行为与现役一致(history/capabilities 缺省) @@ -270,9 +277,14 @@ async def _inject_history(body: Optional[dict]) -> tuple[dict, bool]: if body.get("history"): return body, False # 显式传入的历史优先(上游直传场景;capabilities 由请求体自带) try: - row = await bff_db.fetchone( - "SELECT meta FROM sessions WHERE id = ?", (session_id,) - ) + if owner_id is None: + row = await bff_db.fetchone( + "SELECT meta FROM sessions WHERE id = ?", (session_id,) + ) + else: + row = await bff_db.fetchone( + "SELECT meta FROM sessions WHERE id = ? AND owner_id = ?", (session_id, owner_id) + ) if row is None: return body, False meta = loads_json(row["meta"], default={}) diff --git a/sdk/app/routers/sessions.py b/sdk/app/routers/sessions.py index a72b1ae..4f66700 100644 --- a/sdk/app/routers/sessions.py +++ b/sdk/app/routers/sessions.py @@ -8,7 +8,8 @@ - GET /api/sessions/{id}/sources —— 全会话来源聚合:按 paper_id 去重(无 paper_id 按 id), 带 count(出现次数)。 -404 → NOT_FOUND 信封;时间 ISO 8601 UTC。 +用户级数据隔离:auth_enabled 开启时按 sessions.owner_id 过滤/校验(get_current_user 注入当前用户; +匿名模式下 user=None 则不过滤,兼容旧形态)。404 → NOT_FOUND 信封;时间 ISO 8601 UTC。 """ from __future__ import annotations @@ -20,6 +21,7 @@ from typing import Any from fastapi import APIRouter, Depends, Response, status as http_status from fastapi.responses import JSONResponse +from ..auth import get_current_user from ..db import Database, dumps_json, get_db, loads_json from ..schemas import ( MessageCreate, @@ -80,10 +82,14 @@ def _row_to_message(row: Any) -> MessageOut: async def _get_session_or_none( - db: Database, session_id: str, *, with_count: bool = True + db: Database, session_id: str, *, with_count: bool = True, owner_id: str | None = None ) -> SessionOut | None: sql = _SESSION_SELECT + " WHERE s.id = ?" - row = await db.fetchone(sql, (session_id,)) + params: list[Any] = [session_id] + if owner_id is not None: + sql += " AND s.owner_id = ?" + params.append(owner_id) + row = await db.fetchone(sql, tuple(params)) return _row_to_session(row) if row else None @@ -101,31 +107,40 @@ async def _get_message_or_none( # --------------------------------------------------------------------------- @router.get("", response_model=list[SessionOut]) -async def list_sessions(db: Database = Depends(get_db)) -> list[SessionOut]: - rows = await db.fetchall(_SESSION_SELECT + " ORDER BY s.updated_at DESC") +async def list_sessions(db: Database = Depends(get_db), user: dict | None = Depends(get_current_user)) -> list[SessionOut]: + owner_id = user["id"] if user else None + sql = _SESSION_SELECT + params: list[Any] = [] + if owner_id is not None: + sql += " WHERE s.owner_id = ?" + params.append(owner_id) + sql += " ORDER BY s.updated_at DESC" + rows = await db.fetchall(sql, tuple(params)) return [_row_to_session(r) for r in rows] @router.post("", response_model=SessionOut, status_code=http_status.HTTP_201_CREATED) async def create_session( - payload: SessionCreate, db: Database = Depends(get_db) + payload: SessionCreate, db: Database = Depends(get_db), user: dict | None = Depends(get_current_user) ) -> SessionOut: now = _db_now() session_id = str(uuid.uuid4()) title = payload.normalized_title() + owner_id = user["id"] if user else None await db.execute( - "INSERT INTO sessions (id, title, status, meta, created_at, updated_at) " - "VALUES (?, ?, 'idle', ?, ?, ?)", - (session_id, title, dumps_json({"title_manual": False}), now, now), + "INSERT INTO sessions (id, title, status, meta, owner_id, created_at, updated_at) " + "VALUES (?, ?, 'idle', ?, ?, ?, ?)", + (session_id, title, dumps_json({"title_manual": False}), owner_id, now, now), ) - out = await _get_session_or_none(db, session_id) + out = await _get_session_or_none(db, session_id, owner_id=owner_id) assert out is not None # 刚插入必存在 return out @router.get("/{session_id}", response_model=SessionDetail) -async def get_session(session_id: str, db: Database = Depends(get_db)): - session = await _get_session_or_none(db, session_id) +async def get_session(session_id: str, db: Database = Depends(get_db), user: dict | None = Depends(get_current_user)): + owner_id = user["id"] if user else None + session = await _get_session_or_none(db, session_id, owner_id=owner_id) if session is None: return _not_found(session_id) rows = await db.fetchall( @@ -136,9 +151,10 @@ async def get_session(session_id: str, db: Database = Depends(get_db)): @router.patch("/{session_id}", response_model=SessionOut) async def patch_session( - session_id: str, payload: SessionPatch, db: Database = Depends(get_db) + session_id: str, payload: SessionPatch, db: Database = Depends(get_db), user: dict | None = Depends(get_current_user) ): - session = await _get_session_or_none(db, session_id) + owner_id = user["id"] if user else None + session = await _get_session_or_none(db, session_id, owner_id=owner_id) if session is None: return _not_found(session_id) @@ -169,14 +185,15 @@ async def patch_session( (dumps_json(meta), _db_now(), session_id), ) - out = await _get_session_or_none(db, session_id) + out = await _get_session_or_none(db, session_id, owner_id=owner_id) assert out is not None return out @router.delete("/{session_id}", status_code=http_status.HTTP_204_NO_CONTENT) -async def delete_session(session_id: str, db: Database = Depends(get_db)) -> Response: - session = await _get_session_or_none(db, session_id) +async def delete_session(session_id: str, db: Database = Depends(get_db), user: dict | None = Depends(get_current_user)) -> Response: + owner_id = user["id"] if user else None + session = await _get_session_or_none(db, session_id, owner_id=owner_id) if session is None: return _not_found_response(session_id) await db.execute("DELETE FROM sessions WHERE id = ?", (session_id,)) @@ -189,9 +206,10 @@ async def delete_session(session_id: str, db: Database = Depends(get_db)) -> Res @router.post("/{session_id}/messages", response_model=MessageOut, status_code=201) async def create_message( - session_id: str, payload: MessageCreate, db: Database = Depends(get_db) + session_id: str, payload: MessageCreate, db: Database = Depends(get_db), user: dict | None = Depends(get_current_user) ): - session = await _get_session_or_none(db, session_id) + owner_id = user["id"] if user else None + session = await _get_session_or_none(db, session_id, owner_id=owner_id) if session is None: return _not_found(session_id) @@ -206,7 +224,6 @@ async def create_message( (message_id, session_id, payload.kind, text, now, now), ) else: - # 助手流式骨架:text 置空,blocks 落库初始块 blocks = [b.model_dump() for b in payload.blocks] await db.execute( "INSERT INTO messages (id, session_id, role, kind, status, blocks, raw, text, " @@ -215,7 +232,6 @@ async def create_message( dumps_json(blocks), now, now), ) - # 消息活动 → 会话 updated_at 前移、idle → running await db.execute( "UPDATE sessions SET status = CASE WHEN status = 'idle' THEN 'running' ELSE status END, " "updated_at = ? WHERE id = ?", @@ -233,7 +249,13 @@ async def patch_message( message_id: str, payload: MessagePatch, db: Database = Depends(get_db), + user: dict | None = Depends(get_current_user), ): + owner_id = user["id"] if user else None + session = await _get_session_or_none(db, session_id, owner_id=owner_id) + if session is None: + return _not_found(session_id) + message = await _get_message_or_none(db, session_id, message_id) if message is None: return _not_found(session_id, message_id) @@ -258,7 +280,6 @@ async def patch_message( await db.execute( f"UPDATE messages SET {', '.join(sets)} WHERE id = ?", params ) - # 同步前移会话 updated_at await db.execute( "UPDATE sessions SET updated_at = ? WHERE id = ?", (_db_now(), session_id), @@ -274,12 +295,10 @@ async def patch_message( # --------------------------------------------------------------------------- @router.get("/{session_id}/sources") -async def get_session_sources(session_id: str, db: Database = Depends(get_db)): - """按 paper_id 去重(无 paper_id 时按 id),带 count(出现次数)。 - - 来源来自该会话全部助手消息 blocks 里的 sources 块(§2 块联合)。 - """ - session = await _get_session_or_none(db, session_id) +async def get_session_sources(session_id: str, db: Database = Depends(get_db), user: dict | None = Depends(get_current_user)): + """按 paper_id 去重(无 paper_id 时按 id),带 count(出现次数)。""" + owner_id = user["id"] if user else None + session = await _get_session_or_none(db, session_id, owner_id=owner_id) if session is None: return _not_found(session_id) diff --git a/sdk/app/services/auth.py b/sdk/app/services/auth.py index 0ae01c3..59c668b 100644 --- a/sdk/app/services/auth.py +++ b/sdk/app/services/auth.py @@ -72,11 +72,12 @@ async def verify_login(db: Database, username: str, password: str) -> dict[str, # ---- 会话凭据 ---- -async def issue_token(db: Database, user_id: str) -> str: +async def issue_token(db: Database, user_id: str, ttl_seconds: int | None = None) -> str: settings = get_settings() + ttl = ttl_seconds if ttl_seconds is not None else settings.admin_token_ttl_seconds token = secrets.token_urlsafe(32) now = _now_iso() - expires = datetime.now(timezone.utc) + timedelta(seconds=settings.admin_token_ttl_seconds) + expires = datetime.now(timezone.utc) + timedelta(seconds=ttl) await db.execute( "INSERT INTO auth_tokens (token, user_id, expires_at, created_at, revoked) " "VALUES (?, ?, ?, ?, 0)",