diff --git a/app/api/routers/rbac_operator.py b/app/api/routers/rbac_operator.py index ed5e0cd..390e721 100644 --- a/app/api/routers/rbac_operator.py +++ b/app/api/routers/rbac_operator.py @@ -11,7 +11,7 @@ from fastapi import APIRouter, Depends, HTTPException, Request, Response from pydantic import BaseModel from ..dependencies import get_db -from ..schemas.operator import TaskCreateRequest, TaskUpdateRequest, TaskStatusRequest, TaskAssignRequest, TaskRecommendRequest, TaskSelectRecommendRequest, ProviderCreateRequest, ProviderUpdateRequest, ContentCreateRequest, ContentStatusRequest, ConfigUpdateRequest, ComputePingResponse, ComputeProvisionRequest, ComputeProvisionResponse, CourseCreateRequest, CourseStatusRequest, ActivityCreateRequest, ActivityStatusRequest, BookingUpdateRequest, TestCreateRequest, TestStatusRequest +from ..schemas.operator import TaskCreateRequest, TaskUpdateRequest, TaskStatusRequest, TaskAssignRequest, TaskRecommendRequest, TaskSelectRecommendRequest, ProviderCreateRequest, ProviderUpdateRequest, ContentCreateRequest, ContentStatusRequest, ConfigUpdateRequest, ComputePingResponse, ComputeBalanceRequest, ComputeProvisionRequest, ComputeProvisionResponse, CourseCreateRequest, CourseStatusRequest, ActivityCreateRequest, ActivityStatusRequest, BookingUpdateRequest, TestCreateRequest, TestStatusRequest from ...rbac import require_permission, require_roles, write_audit from ...infrastructure.repositories import Database from ...services import compute_client @@ -274,6 +274,7 @@ async def compute_ping( return ComputePingResponse( ok=status in ("ok", "success") or "success" in status or not data.get("detail"), version=data.get("version"), + quota_per_unit=data.get("quota_per_unit"), detail=data.get("detail"), ) @@ -308,6 +309,35 @@ async def compute_provision( ) +@router.post("/compute/user-balance", summary="充值/调整引擎用户余额(add_quota)") +async def compute_user_balance( + req: ComputeBalanceRequest, + request: Request, + db: Database = Depends(get_db), + actor: dict = Depends(require_roles("operator")), +): + """给引擎用户充值/扣减/覆盖余额(配额单位)。 + + value 为配额单位:/api/status 的 quota_per_unit(默认 500000 = $1)。 + mode:add 充值 / subtract 扣减 / override 直接设为该值。 + 引擎按用户余额计量,额度耗尽时调用 /v1 返回 403。 + """ + if req.mode not in ("add", "subtract", "override"): + raise HTTPException(status_code=400, detail="mode 需为 add/subtract/override") + if req.mode != "override" and req.value <= 0: + raise HTTPException(status_code=400, detail="调整量需大于 0") + try: + result = await compute_client.adjust_user_quota(req.engine_user_id, req.value, req.mode) + except compute_client.ComputeError as exc: + raise HTTPException(status_code=502, detail=f"算力引擎对接失败: {exc}") from exc + await write_audit(db, action="compute.balance", resource="compute", + resource_id=str(req.engine_user_id), + detail=f"{req.mode} {req.value} quota" + (f" ({req.reason})" if req.reason else ""), + user=actor, request=request) + return {"ok": result.get("success", True), "engine_user_id": req.engine_user_id, + "mode": req.mode, "value": req.value, "message": result.get("message", "")} + + @router.post("/compute/sync-users", summary="同步引擎用户=平台总用户(对账)") async def sync_compute_users( request: Request, diff --git a/app/api/routers/relay.py b/app/api/routers/relay.py index 701b7de..3b05234 100644 --- a/app/api/routers/relay.py +++ b/app/api/routers/relay.py @@ -22,17 +22,23 @@ from ...services import compute_catalog router = APIRouter(prefix="/v1", tags=["relay"]) -def _auth_headers(request: Request | None = None) -> dict[str, str]: - # 优先透传客户端 Authorization(用户算力 PAT → compute 归户计量)。 - # 无则回落服务端消费令牌(COMPUTE_RELAY_TOKEN)→ compute 计匿名账。 - if request is not None: - client_auth = request.headers.get("Authorization", "") - if client_auth: - return {"Authorization": client_auth} - token = config.COMPUTE_RELAY_TOKEN or config.COMPUTE_ADMIN_TOKEN - if not token: - return {} - return {"Authorization": f"Bearer {token}"} +def _auth_headers(request: Request) -> dict[str, str]: + """模型调用鉴权:把请求归到「真实用户令牌」,由引擎按该用户余额计量。 + + - 优先透传客户端 Authorization(用户算力 PAT → compute 归户计量,引擎按其余额判定)。 + - 无用户令牌:仅回落受限消费令牌 COMPUTE_RELAY_TOKEN(引擎按该受限用户余额计量)。 + - 二者皆无:**拒绝匿名**——绝不回落 COMPUTE_ADMIN_TOKEN(root 无限额度), + 否则「用户无余额仍能免费使用」。(引擎用户额度耗尽会返回 403,见 billing_session)。 + """ + client_auth = request.headers.get("Authorization", "") + if client_auth: + return {"Authorization": client_auth} + if config.COMPUTE_RELAY_TOKEN: + return {"Authorization": f"Bearer {config.COMPUTE_RELAY_TOKEN}"} + raise HTTPException( + status_code=401, + detail="缺少算力令牌:请携带有效的用户算力令牌(Bearer )调用", + ) @router.post("/chat/completions") diff --git a/app/api/schemas/operator.py b/app/api/schemas/operator.py index 3390fc3..ec23ac9 100644 --- a/app/api/schemas/operator.py +++ b/app/api/schemas/operator.py @@ -1,4 +1,4 @@ -from pydantic import BaseModel +from pydantic import BaseModel, Field class TaskCreateRequest(BaseModel): title: str @@ -168,9 +168,19 @@ class TestStatusRequest(BaseModel): class ComputePingResponse(BaseModel): ok: bool version: str | None = None + quota_per_unit: float | None = None # 引擎 1 元 = 多少配额(500000 = $1),来自 /api/status detail: str | None = None +class ComputeBalanceRequest(BaseModel): + """充值/调整引擎用户余额。``value`` 为配额单位(见 quota_per_unit)。""" + + engine_user_id: int + value: int = Field(ge=0, description="调整量(配额单位),add/subtract 用") + mode: str = "add" # add | subtract | override + reason: str = "" + + class ComputeProvisionRequest(BaseModel): user_id: str diff --git a/app/services/compute_client.py b/app/services/compute_client.py index f98094c..a49846a 100644 --- a/app/services/compute_client.py +++ b/app/services/compute_client.py @@ -181,6 +181,22 @@ async def delete_token(token_id: int) -> dict: ) +async def adjust_user_quota(engine_user_id: int, value: int, mode: str = "add") -> dict: + """调整引擎用户余额/额度(充值/扣减/覆盖)。 + + 对应引擎 ``POST /api/user/manage``,action="add_quota": + - mode="add" 充值(增加余额) + - mode="subtract" 扣减 + - mode="override" 直接设为该值 + ``value`` 为配额单位(见 /api/status 的 quota_per_unit:500000 = $1)。 + 被充值用户的其他 PAT/会话会实时扣除,额度耗尽时引擎返回 403。 + """ + return await _request( + method="POST", path="/api/user/manage", headers=_admin_headers(), + json={"id": int(engine_user_id), "action": "add_quota", "value": int(value), "mode": mode}, + ) + + async def sync_user_enabled(username: str, enabled: bool) -> dict: """按 username 同步引擎用户启用/禁用(平台用户生命周期自动同步)。""" return await _request( @@ -207,7 +223,7 @@ async def proxy(method: str, path: str, *, json_body: dict | None = None, params return {"status": 503, "body": '{"success": false, "message": "算力引擎内部令牌未配置(PINEAGENTS_INTERNAL_TOKEN)"}'} url = f"{_base()}/api{path}" try: - async with httpx.AsyncClient(timeout=config.COMPUTE_TIMEOUT) as client: + async with httpx.AsyncClient(timeout=config.COMPUTE_TIMEOUT, follow_redirects=True) as client: resp = await client.request( method, url, json=json_body, params=params, headers=_admin_headers(), )