✨ feat(vscode): 代码阅读加 FastAPI 接口卡片:请求表(URL、方法、路径 / 查询参数、请求头、Cookie、请求体)、按状态码的响应表(成功、responses、函数里抛的异常、自动带的 422);别的文件里的异常状态码、类型别名经工作区索引和语言扩展查

This commit is contained in:
cruldra committed 2026-09-27 20:19:24 +08:00
1 parent 1dfc0c7b1e
commit 0798f7d1c7
18 files changed
+1745 -28

No files matched your search

@@ -0,0 +1,194 @@
"""用户管理 console (admin) 路由。
提供给管理后台用的用户 CRUD、批量开号、批量删除与启用状态切换接口,
路径前缀 ``/admin/users``, 全部需要管理员权限。
"""
from __future__ import annotations
import uuid
from typing import Annotated, Literal
from fastapi import APIRouter, Depends, Query
from langgraph.checkpoint.base import BaseCheckpointSaver
from luffy_billing_sdk.client import BillingClient
from sqlalchemy.ext.asyncio import AsyncSession
from luffy_agent.acquisition.service import acquisition_service
from luffy_agent.agent.runtime.deps import (
get_agent_pool,
get_checkpointer,
get_session_runner,
)
from luffy_agent.agent.runtime.pool import AgentPool
from luffy_agent.agent.runtime.runner import SessionRunner
from luffy_agent.campaign.triggers import emit_user_registered
from luffy_agent.core.db import get_db
from luffy_agent.core.db.schemas import BulkDeleteFailedItem
from luffy_agent.core.exceptions import Forbidden
from luffy_agent.core.filter.deps import FilterQuery
from luffy_agent.core.params import Int4Path
from luffy_agent.deployment.deps import get_billing_client
from luffy_agent.user.auth.deps import (
ConsolePrincipal,
declares_permission,
require_console_user,
require_permission,
)
from luffy_agent.user.console.rbac.service import (
bind_console_audit_request,
console_rbac_service,
)
from luffy_agent.user.console.schemas import (
ImpersonateRequest,
ImpersonateResponse,
UserBatchCreateItem,
UserBatchCreateRequest,
UserBatchCreateResponse,
UserBulkDeleteRequest,
UserBulkDeleteResponse,
UserCreateRequest,
UserListItem,
UserListResponse,
UserPhoneBackfillConflictItem,
UserPhoneBackfillFilledItem,
UserPhoneBackfillResponse,
UserToggleActiveResponse,
)
from luffy_agent.user.models import User, UserRole
from luffy_agent.user.schemas import UserDetailResponse, UserUpdateRequest
from luffy_agent.user.services import BatchUserResult, user_service
router = APIRouter(tags=["用户管理-运维"], prefix="/admin/users")
def _assert_permission(principal: ConsolePrincipal, key: str) -> None:
if not principal.is_root and key not in principal.permissions:
raise Forbidden("无操作权限")
def _reject_admin_role(role: UserRole) -> None:
if role == UserRole.ADMIN:
raise Forbidden("不能设置管理员角色")
def _to_detail(user: User) -> UserDetailResponse:
return UserDetailResponse(
id=user.id,
username=user.username,
display_name=user.display_name,
role=user.role.value,
email=user.email,
phone=user.phone,
bio=user.bio,
avatar_url=user.avatar_url,
is_active=user.is_active,
created_at=user.created_at,
updated_at=user.updated_at,
)
@declares_permission("users:activate")
async def require_toggle_active_permission(
user_id: int,
db: AsyncSession = Depends(get_db),
principal: ConsolePrincipal = Depends(require_console_user),
) -> ConsolePrincipal:
user = await user_service.get_user(db, user_id=user_id)
key = "users:deactivate" if user.is_active else "users:activate"
_assert_permission(principal, key)
return principal
require_users_view = require_permission("users:view")
require_users_create = require_permission("users:create")
require_users_batch_create = require_permission("users:batch_create")
require_users_update = require_permission("users:update")
require_users_delete = require_permission("users:delete")
require_users_bulk_delete = require_permission("users:bulk_delete")
require_users_impersonate = require_permission("users:impersonate")
@router.get("", response_model=UserListResponse)
async def list_users(
filters: FilterQuery,
page: Annotated[int, Query(ge=1, description="页码")] = 1,
page_size: Annotated[int, Query(ge=1, le=100, description="每页数量")] = 20,
acquisition_channel_id: Annotated[
int | None, Query(description="按来源渠道 ID 精确筛选")
] = None,
referred: Annotated[
Literal["yes", "no"] | None,
Query(description="按是否曾被邀请筛选: yes=曾受邀 / no=从未受邀"),
] = None,
db: AsyncSession = Depends(get_db),
_: object = Depends(require_users_view),
) -> UserListResponse:
"""获取用户列表(支持筛选)。
需要管理员权限。
"""
skip = (page - 1) * page_size
rows, total = await user_service.get_admin_users(
db,
skip=skip,
limit=page_size,
filters=filters.filters,
acquisition_channel_id=acquisition_channel_id,
referred=None if referred is None else referred == "yes",
)
return UserListResponse(
items=[
UserListItem(
id=row.user.id,
username=row.user.username,
display_name=row.user.display_name,
role=row.user.role.value,
phone=row.user.phone,
is_active=row.user.is_active,
acquisition_channel_id=row.channel.id,
acquisition_channel_code=row.channel.code,
acquisition_channel_name=row.channel.name,
referred=row.referred,
created_at=row.user.created_at,
updated_at=row.user.updated_at,
)
for row in rows
],
total=total,
page=page,
page_size=page_size,
)
@router.post("", response_model=UserDetailResponse, status_code=201)
async def create_user(
body: UserCreateRequest,
db: AsyncSession = Depends(get_db),
_: object = Depends(require_users_create),
) -> UserDetailResponse:
"""创建新用户。
需要管理员权限。密码会自动哈希。
"""
_reject_admin_role(body.role)
admin_channel_id = await acquisition_service.resolve_registration_channel_id(
db,
created_via="password",
channel_code=None,
admin_created=True,
)
user = await user_service.create_user(
db,
username=body.username,
password=body.password,
display_name=body.display_name,
acquisition_channel_id=admin_channel_id,
role=body.role,
is_active=body.is_active,
)
emit_user_registered(user.id)
return _to_detail(user)
@@ -0,0 +1,20 @@
"""路由入参的共用类型: 数据库整型主键必须落在 int4 范围内, 越界在入口就 422.
Python 的 int 不会溢出, 11 位手机号误当 user_id 传进来会一路走到 asyncpg, 绑到
INTEGER 列时才抛 DataError, 请求变成 500 且污染当前事务. 主键类路径 / 查询参数
一律用这里的类型, 上界与 :data:`luffy_agent.core.db.PG_INT4_MAX` 同源.
"""
from __future__ import annotations
from typing import Annotated
from fastapi import Path, Query
from luffy_agent.core.db import PG_INT4_MAX
Int4Path = Annotated[int, Path(ge=1, le=PG_INT4_MAX)]
"""路径里的整型主键 (``/users/{user_id}`` 一类)."""
Int4Query = Annotated[int | None, Query(ge=1, le=PG_INT4_MAX)]
"""查询串里可选的整型主键过滤 (``?user_id=``)."""
@@ -0,0 +1,111 @@
"""技能商店用户端路由.
注: prefix 不带 /api/v1, create_app include 时叠 settings.API_V1_PREFIX.
"""
from __future__ import annotations
from typing import Annotated
from fastapi import APIRouter, Depends, HTTPException, Query, Request
from sqlalchemy.ext.asyncio import AsyncSession
from luffy_agent.core.db import get_db, get_redis
from luffy_agent.sandbox.deps import get_sandbox_registry
from luffy_agent.skill_hub import service
from luffy_agent.skill_hub.models import HubSkillStatus
from luffy_agent.skill_hub.rate_limit import check_mutation_rate
from luffy_agent.skill_hub.repositories import hub_skill_repository
from luffy_agent.skill_hub.schemas import (
AppSkillItem,
HubSkillDetail,
HubSkillListItem,
)
from luffy_agent.user.auth.deps import get_current_user
from luffy_agent.user.models import User
skill_hub_router = APIRouter(prefix="/skill-hub", tags=["skill-hub"])
@skill_hub_router.get("/skills", response_model=list[HubSkillListItem])
async def list_skills(
db: Annotated[AsyncSession, Depends(get_db)],
_: Annotated[User, Depends(get_current_user)],
query: Annotated[str | None, Query(max_length=80)] = None,
tag: Annotated[str | None, Query(max_length=40)] = None,
skip: Annotated[int, Query(ge=0)] = 0,
limit: Annotated[int, Query(ge=1, le=100)] = 50,
) -> list[HubSkillListItem]:
rows = await hub_skill_repository.list_published(
db, q=query, tag=tag, skip=skip, limit=limit
)
return [HubSkillListItem.model_validate(row) for row in rows]
@skill_hub_router.get("/skills/{slug}", response_model=HubSkillDetail)
async def get_skill(
slug: str,
db: Annotated[AsyncSession, Depends(get_db)],
_: Annotated[User, Depends(get_current_user)],
) -> HubSkillDetail:
skill = await hub_skill_repository.get_by_slug(db, slug)
if skill is None or skill.status != HubSkillStatus.PUBLISHED:
raise HTTPException(status_code=404, detail=f"技能不存在: {slug}")
return HubSkillDetail.model_validate(skill)
@skill_hub_router.get("/apps/{app_id}/skills", response_model=list[AppSkillItem])
async def list_installed(
app_id: str,
db: Annotated[AsyncSession, Depends(get_db)],
current_user: Annotated[User, Depends(get_current_user)],
) -> list[AppSkillItem]:
rows = await service.list_app_skills(db, user_id=current_user.id, app_id=app_id)
return [
AppSkillItem(
slug=skill.slug,
name=skill.name,
description=skill.description,
version=record.version,
installed_at=record.installed_at,
)
for record, skill in rows
]
@skill_hub_router.post("/apps/{app_id}/skills/{slug}", status_code=204)
async def install(
app_id: str,
slug: str,
request: Request,
db: Annotated[AsyncSession, Depends(get_db)],
current_user: Annotated[User, Depends(get_current_user)],
) -> None:
await check_mutation_rate(get_redis(), user_id=current_user.id)
registry = get_sandbox_registry(request)
await service.install_skill(
db,
user_id=current_user.id,
app_id=app_id,
slug=slug,
sandbox_registry=registry,
)
@skill_hub_router.delete("/apps/{app_id}/skills/{slug}", status_code=204)
async def uninstall(
app_id: str,
slug: str,
request: Request,
db: Annotated[AsyncSession, Depends(get_db)],
current_user: Annotated[User, Depends(get_current_user)],
) -> None:
await check_mutation_rate(get_redis(), user_id=current_user.id)
registry = get_sandbox_registry(request)
await service.uninstall_skill(
db,
user_id=current_user.id,
app_id=app_id,
slug=slug,
sandbox_registry=registry,
)