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 __future__ import annotations
|
||||||
|
|
||||||
from fastapi import Depends, Request
|
from fastapi import Depends, Request
|
||||||
|
|
||||||
|
from .config import get_settings
|
||||||
from .db import Database, get_db
|
from .db import Database, get_db
|
||||||
from .logwriter import log_event
|
from .logwriter import log_event
|
||||||
from .services.auth import resolve_token
|
from .services.auth import resolve_token
|
||||||
@@ -21,15 +28,48 @@ def _bearer(request: Request) -> str:
|
|||||||
return header[7:] if header.startswith("Bearer ") else ""
|
return header[7:] if header.startswith("Bearer ") else ""
|
||||||
|
|
||||||
|
|
||||||
async def require_admin(request: Request, db: Database = Depends(get_db)) -> dict:
|
async def get_current_user(request: Request, db: Database = Depends(get_db)) -> dict | None:
|
||||||
"""校验 Authorization: Bearer <token>,并要求角色为 admin;失败抛 AuthError 并留痕。"""
|
"""用户级鉴权;auth_enabled 为假时返回 None(匿名放行)。"""
|
||||||
|
if not get_settings().auth_enabled:
|
||||||
|
return None
|
||||||
token = _bearer(request)
|
token = _bearer(request)
|
||||||
user = await resolve_token(db, token) if token else None
|
user = await resolve_token(db, token) if token else None
|
||||||
if user is None:
|
if user is None:
|
||||||
log_event("call_denied", method=request.method, path=request.url.path, detail={"reason": "unauthorized"})
|
log_event("call_denied", method=request.method, path=request.url.path, detail={"reason": "unauthorized"})
|
||||||
raise AuthError(401, "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":
|
if user["role"] != "admin":
|
||||||
log_event("call_denied", user_id=user["id"], method=request.method, path=request.url.path, detail={"reason": "not_admin"})
|
log_event("call_denied", user_id=user["id"], method=request.method, path=request.url.path, detail={"reason": "not_admin"})
|
||||||
raise AuthError(403, "FORBIDDEN", "非管理员账号")
|
raise AuthError(403, "FORBIDDEN", "非管理员账号")
|
||||||
request.state.user = user
|
|
||||||
return 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_dir: str = "data/logs" # 行为日志目录(每日一个 JSONL 文件)
|
||||||
log_retention_days: int = 0 # 日志保留天数(0=不清理)
|
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 挂载)----
|
# ---- 生产形态:BFF 静态托管 ui/dist(ARCHITECTURE.md §3,M6 挂载)----
|
||||||
frontend_dist_dir: str = "ui/dist" # 生产形态:静态托管 ui/dist
|
frontend_dist_dir: str = "ui/dist" # 生产形态:静态托管 ui/dist
|
||||||
|
|
||||||
|
|||||||
@@ -209,6 +209,14 @@ class Database:
|
|||||||
await self._conn.executescript(_ADMIN_SCHEMA)
|
await self._conn.executescript(_ADMIN_SCHEMA)
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.exception("admin 表迁移失败(管理后台将不可用,其余不受影响)")
|
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()
|
await self._conn.commit()
|
||||||
|
|
||||||
async def close(self) -> None:
|
async def close(self) -> None:
|
||||||
|
|||||||
+18
-14
@@ -15,7 +15,7 @@ import time
|
|||||||
from contextlib import asynccontextmanager
|
from contextlib import asynccontextmanager
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from fastapi import FastAPI, Request
|
from fastapi import Depends, FastAPI, Request
|
||||||
from fastapi.exceptions import RequestValidationError
|
from fastapi.exceptions import RequestValidationError
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
from fastapi.responses import JSONResponse
|
from fastapi.responses import JSONResponse
|
||||||
@@ -29,8 +29,9 @@ from .routers import paper
|
|||||||
from .routers import private_docs
|
from .routers import private_docs
|
||||||
from .routers import review_proxy
|
from .routers import review_proxy
|
||||||
from .routers import admin
|
from .routers import admin
|
||||||
|
from .routers import auth
|
||||||
from . import logwriter
|
from . import logwriter
|
||||||
from .auth import AuthError
|
from .auth import AuthError, get_current_user, require_authorization
|
||||||
from .services.auth import seed_admin
|
from .services.auth import seed_admin
|
||||||
from .services.settings import get_hub_settings
|
from .services.settings import get_hub_settings
|
||||||
from .upstream import close_client
|
from .upstream import close_client
|
||||||
@@ -61,8 +62,10 @@ def create_app() -> FastAPI:
|
|||||||
start = time.monotonic()
|
start = time.monotonic()
|
||||||
response = await call_next(request)
|
response = await call_next(request)
|
||||||
if request.url.path.startswith("/api/"):
|
if request.url.path.startswith("/api/"):
|
||||||
|
user = getattr(request.state, "user", None)
|
||||||
logwriter.log_event(
|
logwriter.log_event(
|
||||||
"http_request",
|
"http_request",
|
||||||
|
user_id=user["id"] if user else None,
|
||||||
method=request.method,
|
method=request.method,
|
||||||
path=request.url.path,
|
path=request.url.path,
|
||||||
status=response.status_code,
|
status=response.status_code,
|
||||||
@@ -79,21 +82,22 @@ def create_app() -> FastAPI:
|
|||||||
)
|
)
|
||||||
|
|
||||||
app.include_router(health.router)
|
app.include_router(health.router)
|
||||||
app.include_router(sessions.router)
|
app.include_router(auth.router) # /api/auth/*(login/status 免鉴权,me/logout 需登录)
|
||||||
app.include_router(qa.router)
|
app.include_router(sessions.router, dependencies=[Depends(get_current_user)])
|
||||||
app.include_router(capabilities.router)
|
app.include_router(qa.router, dependencies=[Depends(require_authorization("qa"))])
|
||||||
app.include_router(kg.router)
|
app.include_router(capabilities.router, dependencies=[Depends(get_current_user)])
|
||||||
app.include_router(extraction.router)
|
app.include_router(kg.router, dependencies=[Depends(require_authorization("kg"))])
|
||||||
app.include_router(figures.router)
|
app.include_router(extraction.router, dependencies=[Depends(require_authorization("extraction"))])
|
||||||
app.include_router(legacy.router)
|
app.include_router(figures.router, dependencies=[Depends(require_authorization("figures"))])
|
||||||
app.include_router(docgen.router) # docgen(P2)
|
app.include_router(legacy.router, dependencies=[Depends(require_authorization("legacy"))])
|
||||||
app.include_router(private_docs.router) # 私有资料问答(FR-PRIV-004)
|
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:
|
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:
|
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:
|
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.include_router(admin.router) # 管理后台(/api/admin/*)
|
||||||
|
|
||||||
@app.exception_handler(RequestValidationError)
|
@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
|
import uuid
|
||||||
|
|
||||||
data = payload.get("data", payload) if isinstance(payload, dict) else {}
|
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]
|
artifact_id = uuid.uuid4().hex[:16]
|
||||||
await db.execute(
|
await db.execute(
|
||||||
"""INSERT INTO doc_artifacts (id, session_id, status, title, template_name,
|
"""INSERT INTO doc_artifacts (id, session_id, status, title, template_name,
|
||||||
|
|||||||
@@ -36,7 +36,12 @@ async def list_capabilities(
|
|||||||
session_caps: Optional[dict] = None
|
session_caps: Optional[dict] = None
|
||||||
if session_id:
|
if session_id:
|
||||||
try:
|
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:
|
if row is not None:
|
||||||
meta = loads_json(row["meta"], default={})
|
meta = loads_json(row["meta"], default={})
|
||||||
if isinstance(meta, dict):
|
if isinstance(meta, dict):
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ from datetime import datetime, timezone
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any
|
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 fastapi.responses import FileResponse, JSONResponse
|
||||||
|
|
||||||
from ..config import get_settings
|
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)
|
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:
|
def _validation(message: str) -> JSONResponse:
|
||||||
return _envelope("VALIDATION", message=message, status=422)
|
return _envelope("VALIDATION", message=message, status=422)
|
||||||
|
|
||||||
@@ -646,6 +653,7 @@ async def _run_docgen_task(
|
|||||||
@router.post("/generate")
|
@router.post("/generate")
|
||||||
async def generate_doc(
|
async def generate_doc(
|
||||||
payload: DocgenGenerateRequest,
|
payload: DocgenGenerateRequest,
|
||||||
|
request: Request,
|
||||||
db: Database = Depends(get_db),
|
db: Database = Depends(get_db),
|
||||||
):
|
):
|
||||||
"""异步生成(缺陷 #52,2026-09-10):快路径校验 → 插任务行 → 后台生成 → 202。
|
"""异步生成(缺陷 #52,2026-09-10):快路径校验 → 插任务行 → 后台生成 → 202。
|
||||||
@@ -657,6 +665,9 @@ async def generate_doc(
|
|||||||
202 {ok, task_id, status:"queued"}(契约见 docs/acceptance-r3.md 附录 B)。
|
202 {ok, task_id, status:"queued"}(契约见 docs/acceptance-r3.md 附录 B)。
|
||||||
chat 消息落库由前端走现有 sessions 机制,本端点不碰。
|
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)
|
bad = _check_description(payload.description)
|
||||||
if bad is not None:
|
if bad is not None:
|
||||||
return bad
|
return bad
|
||||||
@@ -767,15 +778,25 @@ async def get_docgen_task(task_id: str, db: Database = Depends(get_db)):
|
|||||||
|
|
||||||
@router.get("/tasks")
|
@router.get("/tasks")
|
||||||
async def list_docgen_tasks(
|
async def list_docgen_tasks(
|
||||||
|
request: Request,
|
||||||
session_id: str = Query("", description="按会话过滤;空=全部(上限 100)"),
|
session_id: str = Query("", description="按会话过滤;空=全部(上限 100)"),
|
||||||
db: Database = Depends(get_db),
|
db: Database = Depends(get_db),
|
||||||
):
|
):
|
||||||
"""会话任务列表(缺陷 #52 契约,附录 B)。"""
|
"""会话任务列表(缺陷 #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:
|
if session_id:
|
||||||
rows = await db.fetchall(
|
rows = await db.fetchall(
|
||||||
"SELECT id FROM docgen_tasks WHERE session_id = ? ORDER BY created_at DESC LIMIT 100",
|
"SELECT id FROM docgen_tasks WHERE session_id = ? ORDER BY created_at DESC LIMIT 100",
|
||||||
(session_id,),
|
(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:
|
else:
|
||||||
rows = await db.fetchall(
|
rows = await db.fetchall(
|
||||||
"SELECT id FROM docgen_tasks ORDER BY created_at DESC LIMIT 100"
|
"SELECT id FROM docgen_tasks ORDER BY created_at DESC LIMIT 100"
|
||||||
|
|||||||
@@ -9,13 +9,15 @@ import asyncio
|
|||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
from fastapi import APIRouter, Depends
|
from fastapi import APIRouter, Depends, Request
|
||||||
|
from fastapi.responses import JSONResponse
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
|
|
||||||
from ..config import get_settings
|
from ..config import get_settings
|
||||||
from ..db import Database, get_db
|
from ..db import Database, get_db
|
||||||
from ..services.paper import agent
|
from ..services.paper import agent
|
||||||
from ..services.paper.mcp_client import PaperMcpClient
|
from ..services.paper.mcp_client import PaperMcpClient
|
||||||
|
from ..upstream import envelope
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/paper", tags=["paper"])
|
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)
|
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")
|
@router.get("/health")
|
||||||
async def paper_health() -> dict[str, Any]:
|
async def paper_health() -> dict[str, Any]:
|
||||||
"""探活反代 8097 /health;离线返回 offline 标记(前端据此隐藏 paper 入口)。"""
|
"""探活反代 8097 /health;离线返回 offline 标记(前端据此隐藏 paper 入口)。"""
|
||||||
@@ -50,19 +59,28 @@ async def paper_health() -> dict[str, Any]:
|
|||||||
|
|
||||||
|
|
||||||
@router.post("/turns")
|
@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)
|
blocks = await agent.handle_turn(db, payload.session_id, payload.text, payload.paper_id, payload.template_key)
|
||||||
return {"ok": True, "blocks": blocks}
|
return {"ok": True, "blocks": blocks}
|
||||||
|
|
||||||
|
|
||||||
@router.post("/papers/{paper_id}/confirm")
|
@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))
|
asyncio.create_task(agent.run_writing_loop(db, paper_id, payload.session_id))
|
||||||
return {"ok": True, "paper_id": paper_id}
|
return {"ok": True, "paper_id": paper_id}
|
||||||
|
|
||||||
|
|
||||||
@router.post("/papers/{paper_id}/resume")
|
@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))
|
asyncio.create_task(agent.run_writing_loop(db, paper_id, payload.session_id))
|
||||||
return {"ok": True, "paper_id": paper_id}
|
return {"ok": True, "paper_id": paper_id}
|
||||||
|
|
||||||
|
|||||||
+20
-8
@@ -100,13 +100,14 @@ async def _proxy(
|
|||||||
retry_invalid_citation: bool,
|
retry_invalid_citation: bool,
|
||||||
mode: str,
|
mode: str,
|
||||||
normalizer=None,
|
normalizer=None,
|
||||||
|
owner_id: str | None = None,
|
||||||
) -> Any:
|
) -> Any:
|
||||||
"""非流式上游代理:重试、错误信封、归一化。返回 (JSONResponse)。"""
|
"""非流式上游代理:重试、错误信封、归一化。返回 (JSONResponse)。"""
|
||||||
client = sse_relay.get_qa_client()
|
client = sse_relay.get_qa_client()
|
||||||
normalizer = normalizer or sse_relay.get_qa_normalizer()
|
normalizer = normalizer or sse_relay.get_qa_normalizer()
|
||||||
url = f"{sse_relay.qa_base_url()}{_upstream_path(endpoint)}"
|
url = f"{sse_relay.qa_base_url()}{_upstream_path(endpoint)}"
|
||||||
timeout = _read_timeout(read_timeout)
|
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):
|
for attempt in (1, 2):
|
||||||
try:
|
try:
|
||||||
@@ -206,8 +207,10 @@ async def qa_info():
|
|||||||
async def qa_chat(request: Request):
|
async def qa_chat(request: Request):
|
||||||
"""非流式问答:上游 /chat + 归一化 + invalid_llm_citation 重试一次。"""
|
"""非流式问答:上游 /chat + 归一化 + invalid_llm_citation 重试一次。"""
|
||||||
body = await request.json()
|
body = await request.json()
|
||||||
|
user = getattr(request.state, "user", None)
|
||||||
|
owner_id = user["id"] if user else None
|
||||||
return await _proxy(
|
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:
|
except Exception:
|
||||||
return _envelope("VALIDATION", 422)
|
return _envelope("VALIDATION", 422)
|
||||||
evidence_first = bool(body.pop("evidence_first", False))
|
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(
|
return StreamingResponse(
|
||||||
sse_relay.relay_stream(body, evidence_first=evidence_first, humanize_enabled=humanize_on),
|
sse_relay.relay_stream(body, evidence_first=evidence_first, humanize_enabled=humanize_on),
|
||||||
media_type="text/event-stream",
|
media_type="text/event-stream",
|
||||||
@@ -242,8 +247,10 @@ async def qa_search(request: Request):
|
|||||||
"""
|
"""
|
||||||
body = await request.json()
|
body = await request.json()
|
||||||
body = {**body, "use_llm": False, "native_chain_enabled": False}
|
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(
|
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 注入上游
|
# 多轮记忆(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。
|
"""剥离 BFF 契约字段 session_id,读会话 meta 注入 history/capabilities。
|
||||||
|
|
||||||
- 不传 session_id / 会话不存在 → 行为与现役一致(history/capabilities 缺省)
|
- 不传 session_id / 会话不存在 → 行为与现役一致(history/capabilities 缺省)
|
||||||
@@ -270,9 +277,14 @@ async def _inject_history(body: Optional[dict]) -> tuple[dict, bool]:
|
|||||||
if body.get("history"):
|
if body.get("history"):
|
||||||
return body, False # 显式传入的历史优先(上游直传场景;capabilities 由请求体自带)
|
return body, False # 显式传入的历史优先(上游直传场景;capabilities 由请求体自带)
|
||||||
try:
|
try:
|
||||||
row = await bff_db.fetchone(
|
if owner_id is None:
|
||||||
"SELECT meta FROM sessions WHERE id = ?", (session_id,)
|
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:
|
if row is None:
|
||||||
return body, False
|
return body, False
|
||||||
meta = loads_json(row["meta"], default={})
|
meta = loads_json(row["meta"], default={})
|
||||||
|
|||||||
+47
-28
@@ -8,7 +8,8 @@
|
|||||||
- GET /api/sessions/{id}/sources —— 全会话来源聚合:按 paper_id 去重(无 paper_id 按 id),
|
- GET /api/sessions/{id}/sources —— 全会话来源聚合:按 paper_id 去重(无 paper_id 按 id),
|
||||||
带 count(出现次数)。
|
带 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
|
from __future__ import annotations
|
||||||
|
|
||||||
@@ -20,6 +21,7 @@ from typing import Any
|
|||||||
from fastapi import APIRouter, Depends, Response, status as http_status
|
from fastapi import APIRouter, Depends, Response, status as http_status
|
||||||
from fastapi.responses import JSONResponse
|
from fastapi.responses import JSONResponse
|
||||||
|
|
||||||
|
from ..auth import get_current_user
|
||||||
from ..db import Database, dumps_json, get_db, loads_json
|
from ..db import Database, dumps_json, get_db, loads_json
|
||||||
from ..schemas import (
|
from ..schemas import (
|
||||||
MessageCreate,
|
MessageCreate,
|
||||||
@@ -80,10 +82,14 @@ def _row_to_message(row: Any) -> MessageOut:
|
|||||||
|
|
||||||
|
|
||||||
async def _get_session_or_none(
|
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:
|
) -> SessionOut | None:
|
||||||
sql = _SESSION_SELECT + " WHERE s.id = ?"
|
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
|
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])
|
@router.get("", response_model=list[SessionOut])
|
||||||
async def list_sessions(db: Database = Depends(get_db)) -> list[SessionOut]:
|
async def list_sessions(db: Database = Depends(get_db), user: dict | None = Depends(get_current_user)) -> list[SessionOut]:
|
||||||
rows = await db.fetchall(_SESSION_SELECT + " ORDER BY s.updated_at DESC")
|
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]
|
return [_row_to_session(r) for r in rows]
|
||||||
|
|
||||||
|
|
||||||
@router.post("", response_model=SessionOut, status_code=http_status.HTTP_201_CREATED)
|
@router.post("", response_model=SessionOut, status_code=http_status.HTTP_201_CREATED)
|
||||||
async def create_session(
|
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:
|
) -> SessionOut:
|
||||||
now = _db_now()
|
now = _db_now()
|
||||||
session_id = str(uuid.uuid4())
|
session_id = str(uuid.uuid4())
|
||||||
title = payload.normalized_title()
|
title = payload.normalized_title()
|
||||||
|
owner_id = user["id"] if user else None
|
||||||
await db.execute(
|
await db.execute(
|
||||||
"INSERT INTO sessions (id, title, status, meta, created_at, updated_at) "
|
"INSERT INTO sessions (id, title, status, meta, owner_id, created_at, updated_at) "
|
||||||
"VALUES (?, ?, 'idle', ?, ?, ?)",
|
"VALUES (?, ?, 'idle', ?, ?, ?, ?)",
|
||||||
(session_id, title, dumps_json({"title_manual": False}), now, now),
|
(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 # 刚插入必存在
|
assert out is not None # 刚插入必存在
|
||||||
return out
|
return out
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{session_id}", response_model=SessionDetail)
|
@router.get("/{session_id}", response_model=SessionDetail)
|
||||||
async def get_session(session_id: str, db: Database = Depends(get_db)):
|
async def get_session(session_id: str, 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:
|
if session is None:
|
||||||
return _not_found(session_id)
|
return _not_found(session_id)
|
||||||
rows = await db.fetchall(
|
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)
|
@router.patch("/{session_id}", response_model=SessionOut)
|
||||||
async def patch_session(
|
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:
|
if session is None:
|
||||||
return _not_found(session_id)
|
return _not_found(session_id)
|
||||||
|
|
||||||
@@ -169,14 +185,15 @@ async def patch_session(
|
|||||||
(dumps_json(meta), _db_now(), session_id),
|
(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
|
assert out is not None
|
||||||
return out
|
return out
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/{session_id}", status_code=http_status.HTTP_204_NO_CONTENT)
|
@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:
|
async def delete_session(session_id: str, db: Database = Depends(get_db), user: dict | None = Depends(get_current_user)) -> Response:
|
||||||
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:
|
if session is None:
|
||||||
return _not_found_response(session_id)
|
return _not_found_response(session_id)
|
||||||
await db.execute("DELETE FROM sessions WHERE id = ?", (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)
|
@router.post("/{session_id}/messages", response_model=MessageOut, status_code=201)
|
||||||
async def create_message(
|
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:
|
if session is None:
|
||||||
return _not_found(session_id)
|
return _not_found(session_id)
|
||||||
|
|
||||||
@@ -206,7 +224,6 @@ async def create_message(
|
|||||||
(message_id, session_id, payload.kind, text, now, now),
|
(message_id, session_id, payload.kind, text, now, now),
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
# 助手流式骨架:text 置空,blocks 落库初始块
|
|
||||||
blocks = [b.model_dump() for b in payload.blocks]
|
blocks = [b.model_dump() for b in payload.blocks]
|
||||||
await db.execute(
|
await db.execute(
|
||||||
"INSERT INTO messages (id, session_id, role, kind, status, blocks, raw, text, "
|
"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),
|
dumps_json(blocks), now, now),
|
||||||
)
|
)
|
||||||
|
|
||||||
# 消息活动 → 会话 updated_at 前移、idle → running
|
|
||||||
await db.execute(
|
await db.execute(
|
||||||
"UPDATE sessions SET status = CASE WHEN status = 'idle' THEN 'running' ELSE status END, "
|
"UPDATE sessions SET status = CASE WHEN status = 'idle' THEN 'running' ELSE status END, "
|
||||||
"updated_at = ? WHERE id = ?",
|
"updated_at = ? WHERE id = ?",
|
||||||
@@ -233,7 +249,13 @@ async def patch_message(
|
|||||||
message_id: str,
|
message_id: str,
|
||||||
payload: MessagePatch,
|
payload: MessagePatch,
|
||||||
db: Database = Depends(get_db),
|
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)
|
message = await _get_message_or_none(db, session_id, message_id)
|
||||||
if message is None:
|
if message is None:
|
||||||
return _not_found(session_id, message_id)
|
return _not_found(session_id, message_id)
|
||||||
@@ -258,7 +280,6 @@ async def patch_message(
|
|||||||
await db.execute(
|
await db.execute(
|
||||||
f"UPDATE messages SET {', '.join(sets)} WHERE id = ?", params
|
f"UPDATE messages SET {', '.join(sets)} WHERE id = ?", params
|
||||||
)
|
)
|
||||||
# 同步前移会话 updated_at
|
|
||||||
await db.execute(
|
await db.execute(
|
||||||
"UPDATE sessions SET updated_at = ? WHERE id = ?",
|
"UPDATE sessions SET updated_at = ? WHERE id = ?",
|
||||||
(_db_now(), session_id),
|
(_db_now(), session_id),
|
||||||
@@ -274,12 +295,10 @@ async def patch_message(
|
|||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
@router.get("/{session_id}/sources")
|
@router.get("/{session_id}/sources")
|
||||||
async def get_session_sources(session_id: str, db: Database = Depends(get_db)):
|
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(出现次数)。
|
"""按 paper_id 去重(无 paper_id 时按 id),带 count(出现次数)。"""
|
||||||
|
owner_id = user["id"] if user else None
|
||||||
来源来自该会话全部助手消息 blocks 里的 sources 块(§2 块联合)。
|
session = await _get_session_or_none(db, session_id, owner_id=owner_id)
|
||||||
"""
|
|
||||||
session = await _get_session_or_none(db, session_id)
|
|
||||||
if session is None:
|
if session is None:
|
||||||
return _not_found(session_id)
|
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()
|
settings = get_settings()
|
||||||
|
ttl = ttl_seconds if ttl_seconds is not None else settings.admin_token_ttl_seconds
|
||||||
token = secrets.token_urlsafe(32)
|
token = secrets.token_urlsafe(32)
|
||||||
now = _now_iso()
|
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(
|
await db.execute(
|
||||||
"INSERT INTO auth_tokens (token, user_id, expires_at, created_at, revoked) "
|
"INSERT INTO auth_tokens (token, user_id, expires_at, created_at, revoked) "
|
||||||
"VALUES (?, ?, ?, ?, 0)",
|
"VALUES (?, ?, ?, ?, 0)",
|
||||||
|
|||||||
Reference in New Issue
Block a user