feat(auth): 用户端鉴权(登录/授权清单/逐次校验/功能门禁 + owner 数据隔离)
This commit is contained in:
+44
-4
@@ -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 <token>,并要求角色为 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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
+18
-14
@@ -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)
|
||||
|
||||
@@ -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}
|
||||
@@ -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,
|
||||
|
||||
@@ -36,7 +36,12 @@ async def list_capabilities(
|
||||
session_caps: Optional[dict] = None
|
||||
if session_id:
|
||||
try:
|
||||
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):
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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}
|
||||
|
||||
|
||||
+17
-5
@@ -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:
|
||||
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={})
|
||||
|
||||
+47
-28
@@ -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)
|
||||
|
||||
|
||||
@@ -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)",
|
||||
|
||||
Reference in New Issue
Block a user