feat(auth): 用户端鉴权(登录/授权清单/逐次校验/功能门禁 + owner 数据隔离)

This commit is contained in:
2026-10-16 14:42:23 +08:00
parent 282415fcb1
commit d9abd0b86a
12 changed files with 252 additions and 62 deletions
+44 -4
View File
@@ -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
+4
View File
@@ -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
+8
View File
@@ -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
View File
@@ -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)
+52
View File
@@ -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}
+6
View File
@@ -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,
+6 -1
View File
@@ -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):
+22 -1
View File
@@ -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"
+22 -4
View File
@@ -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}
+20 -8
View File
@@ -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={})
+47 -28
View File
@@ -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)
+3 -2
View File
@@ -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)",