"""Bailian admin + employee REST endpoints. * ``GET /api/v1/bailian/workspaces`` — list workspaces * ``GET /api/v1/bailian/api-keys`` — list issued keys * ``GET /api/v1/bailian/usage`` — daily usage rows * ``GET /api/v1/bailian/usage/summary`` — aggregate dashboard payload * ``POST /api/v1/bailian/sync`` — trigger an immediate sync * ``PATCH /api/v1/bailian/workspaces/{id}/owner`` — reassign workspace owner * ``PATCH /api/v1/bailian/workspaces/{id}/default`` — pick the auto-grant target * ``PATCH /api/v1/bailian/api-keys/{id}/owner`` — reassign API-key owner Every endpoint is gated on ``can_manage_cloud`` (IT Admin / cloud admin) because Bailian is a cloud-managed resource. Employee read of "my keys" goes through the normal apikey assignment endpoints — no duplicate "/my/bailian" surface here. """ from __future__ import annotations from datetime import date from uuid import UUID from fastapi import APIRouter, Depends, Query, Request from sqlalchemy.ext.asyncio import AsyncSession from app.auth.models import AuthUser from app.core.database import get_db from app.core.dependencies import get_current_user, require_browser_session from app.core.exceptions import ForbiddenError from app.core.rbac import can_manage_cloud from app.core.response import success_response from app.domains.bailian.schemas import ( BailianApiKeyOwnerUpdate, BailianSyncTriggerRequest, BailianWorkspaceDefaultUpdate, BailianWorkspaceOwnerUpdate, ) from app.domains.bailian.service import BailianService router = APIRouter(prefix="/api/v1/bailian", tags=["bailian"]) def _require_cloud_admin(user: AuthUser) -> None: if not can_manage_cloud(user.role_names or []): raise ForbiddenError( "Only cloud-admin / global_admin can access Bailian admin.", ) async def require_cloud_admin( user: AuthUser = Depends(get_current_user), ) -> AuthUser: """FastAPI dependency form so a missing gate becomes a route- definition error rather than a silently-unguarded handler. Pre-launch hardening 2026-05-04: the previous inline ``_require_cloud_admin(user)`` calls in every handler relied on every author remembering to invoke them — one missing call would silently expose the endpoint. Use this Depends(...) form for new endpoints; the legacy free-function above is kept for the callers that already exist below. """ _require_cloud_admin(user) return user @router.get("/workspaces") async def list_workspaces( request: Request, integration_id: str | None = Query(default=None), owner_person_id: str | None = Query(default=None), user: AuthUser = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): _require_cloud_admin(user) service = BailianService(db) rows = await service.list_workspaces( integration_id=integration_id, owner_person_id=owner_person_id, ) return success_response(data=rows, request=request) @router.get("/api-keys") async def list_api_keys( request: Request, integration_id: str | None = Query(default=None), owner_person_id: str | None = Query(default=None), user: AuthUser = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): _require_cloud_admin(user) service = BailianService(db) rows = await service.list_api_keys( integration_id=integration_id, owner_person_id=owner_person_id, ) return success_response(data=rows, request=request) @router.get("/usage") async def list_usage( request: Request, integration_id: str | None = Query(default=None), workspace_pk: UUID | None = Query(default=None), api_key_pk: UUID | None = Query(default=None), bailian_api_key_id: str | None = Query(default=None), aliyun_account_id: str | None = Query(default=None), owner_person_id: str | None = Query(default=None), start_date: date | None = Query(default=None), end_date: date | None = Query(default=None), user: AuthUser = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): _require_cloud_admin(user) service = BailianService(db) rows = await service.list_usage_daily( integration_id=integration_id, workspace_pk=workspace_pk, api_key_pk=api_key_pk, bailian_api_key_id=bailian_api_key_id, aliyun_account_id=aliyun_account_id, owner_person_id=owner_person_id, start_date=start_date, end_date=end_date, ) return success_response(data=rows, request=request) @router.get("/usage/summary") async def usage_summary( request: Request, integration_id: str = Query(...), period_start: date | None = Query(default=None), period_end: date | None = Query(default=None), user: AuthUser = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): _require_cloud_admin(user) service = BailianService(db) summary = await service.usage_summary( integration_id=integration_id, period_start=period_start, period_end=period_end, ) return success_response(data=summary, request=request) @router.post("/sync") async def sync_now( request: Request, body: BailianSyncTriggerRequest, user: AuthUser = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): """Run a full or partial sync immediately. Empty ``capabilities`` list runs everything: workspaces → api_keys → usage. Capabilities run sequentially since api_keys fans out per workspace. """ _require_cloud_admin(user) service = BailianService(db) capabilities = ( body.capabilities or ["workspaces", "api_keys", "usage"] ) # One transaction PER capability (#2147). Run as one, the workspace # UPDATEs that ``sync_workspaces`` flushes stayed exclusively locked # for the whole of the following two stages — a per-workspace # ListApiKeys call each, plus seven days of paginated BSS billing # calls. An admin clicking 立即同步 while the 04:00/04:45/05:00 cron # touches the same workspace rows meant one of them waited out # ``innodb_lock_wait_timeout`` and failed as 1205 (which opens an # issue automatically). Committing between stages keeps each lock # window to its own stage's writes; the stages are already # independent — the API-key sync re-reads workspaces from the DB. results: dict[str, dict] = {} if "workspaces" in capabilities: results["workspaces"] = await service.sync_workspaces( integration_id=body.integration_id, ) await db.commit() if "api_keys" in capabilities: results["api_keys"] = await service.sync_api_keys( integration_id=body.integration_id, ) await db.commit() if "usage" in capabilities: results["usage"] = await service.sync_usage_daily( integration_id=body.integration_id, days=7, ) await db.commit() return success_response(data=results, request=request) @router.patch("/workspaces/{workspace_pk}/owner") async def update_workspace_owner( request: Request, workspace_pk: UUID, body: BailianWorkspaceOwnerUpdate, user: AuthUser = Depends(get_current_user), db: AsyncSession = Depends(get_db), # Round-49: owner rebind is catastrophic-if-stolen-PAT — same # threat class as the Round-46-fixed cmdb/iam-users/{id}/owner. # A stolen cloud_admin PAT could otherwise silently re-attribute # Bailian API-key spend and chargeback rules. _gate: None = Depends(require_browser_session), ): _require_cloud_admin(user) service = BailianService(db) row = await service.reassign_workspace_owner( workspace_pk=workspace_pk, owner_person_id=body.owner_person_id, actor_id=user.id, reason=body.reason, trace_id=request.state.trace_id, ) await db.commit() return success_response(data=row, request=request) @router.patch("/workspaces/{workspace_pk}/default") async def update_workspace_default( request: Request, workspace_pk: UUID, body: BailianWorkspaceDefaultUpdate, user: AuthUser = Depends(require_cloud_admin), db: AsyncSession = Depends(get_db), # Same posture as the owner rebind below: this decides which # workspace every future auto-granted key is minted in (and billed # to), so a stolen cloud_admin PAT must not be able to flip it. _gate: None = Depends(require_browser_session), ): """Choose the workspace API-key auto-grant mints into. ModelStudio is per-region and every region carries its own 「默认业务空间」, so a tenant synced across three regions offers the auto-grant three equally-plausible targets. Resolution prefers the integration's own Bailian region, but an admin who wants a specific workspace (a dedicated project space, a different region) pins it here — this is the only surface that writes the flag. """ service = BailianService(db) row = await service.set_default_workspace( workspace_pk=workspace_pk, is_default=body.is_default, actor_id=user.id, reason=body.reason, trace_id=request.state.trace_id, ) await db.commit() return success_response(data=row, request=request) @router.patch("/api-keys/{api_key_pk}/owner") async def update_api_key_owner( request: Request, api_key_pk: UUID, body: BailianApiKeyOwnerUpdate, user: AuthUser = Depends(get_current_user), db: AsyncSession = Depends(get_db), # Round-49: owner rebind catastrophic-if-stolen-PAT (see comment # on ``update_workspace_owner``). _gate: None = Depends(require_browser_session), ): """Re-assign the owner of a Bailian API key. Auto-grant flows write owner_person_id directly when an employee requests a key via Feishu. This endpoint covers the manual case: keys created in the Aliyun console (e.g. "for eric", "For Wish") or keys whose owner needs to be transferred to a different person. """ _require_cloud_admin(user) service = BailianService(db) row = await service.reassign_api_key_owner( api_key_pk=api_key_pk, owner_person_id=body.owner_person_id, actor_id=user.id, reason=body.reason, trace_id=request.state.trace_id, ) await db.commit() return success_response(data=row, request=request)