mirror of
https://github.com/fastapiadmin/FastapiAdmin.git
synced 2026-09-23 21:18:09 +00:00
refactor: 整合仪表盘功能到监控模块,清理冗余代码
- 移除原监控仪表盘独立模块,将相关功能合并到在线监控模块 - 重构租户配置字段名,统一使用logo_url和name替代tenant_logo/tenant_name - 优化搜索工具函数,移除重复导入 - 调整参数配置模型字段长度限制,移除config_value的max_length约束 - 清理冗余的常量定义和导入语句 - 修复批量状态设置接口的redis依赖注入 - 增强OAuth登录安全性,添加租户默认归属和state一次性消费 - 优化资源目录缓存逻辑,减少重复计算 - 新增API Token模块基础框架 - 完善用户token版本管理,支持主动失效JWT - 调整AI模型配置缓存过期时间 - 修复菜单类型字段索引,提升查询性能 - 简化前端刷新token调用逻辑 - 新增滑块验证完成接口和忘记密码验证码校验 - 调整系统配置默认值,添加操作日志保留天数和接口白名单配置 - 限制Mock支付回调仅在开发环境可用 - 重构websocket认证方式,支持更安全的subprotocol传参
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
"""API Token 模块:租户级访问令牌(用于外部系统/API 集成调用)。
|
||||
|
||||
模块路径:``module_system.api_token``,通过 module_system/__init__.py 注册路由。
|
||||
外部 API 端点通过 ``api_external_router`` 注册到 ``/external`` 路径下。
|
||||
"""
|
||||
@@ -0,0 +1,108 @@
|
||||
"""API Token Controller:CRUD + reveal 二次验证
|
||||
"""
|
||||
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Body, Depends, Path, Query, Security
|
||||
from fastapi.responses import JSONResponse
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.common.response import ResponseSchema, SuccessResponse
|
||||
from app.core.base_schema import AuthSchema, PageResultSchema, PaginationQueryParam
|
||||
from app.core.dependencies import AuthPermission, db_getter
|
||||
from app.core.router_class import OperationLogRoute
|
||||
|
||||
from .schema import (
|
||||
ApiTokenCreatedSchema,
|
||||
ApiTokenCreateSchema,
|
||||
ApiTokenOutSchema,
|
||||
ApiTokenQueryParam,
|
||||
ApiTokenResetSchema,
|
||||
ApiTokenRevealOutSchema,
|
||||
ApiTokenRevealSchema,
|
||||
)
|
||||
from .service import ApiTokenService
|
||||
|
||||
ApiTokenRouter = APIRouter(route_class=OperationLogRoute, prefix="/token", tags=["平台-API令牌"])
|
||||
|
||||
|
||||
@ApiTokenRouter.post("/create", summary="创建 API Token", response_model=ResponseSchema[ApiTokenCreatedSchema])
|
||||
async def create_token_controller(
|
||||
auth: Annotated[AuthSchema, Security(AuthPermission(["module_system:token:create"]))],
|
||||
db: Annotated[AsyncSession, Depends(db_getter)],
|
||||
data: Annotated[ApiTokenCreateSchema, Body(description="创建参数")],
|
||||
) -> JSONResponse:
|
||||
"""创建后会完整返回明文 token,请立即保存。"""
|
||||
result = await ApiTokenService(auth, db).create(data=data)
|
||||
return SuccessResponse(data=result, msg="创建 token 成功")
|
||||
|
||||
|
||||
@ApiTokenRouter.get("/list", summary="查询 token 列表", response_model=ResponseSchema[PageResultSchema[ApiTokenOutSchema]])
|
||||
async def get_token_list_controller(
|
||||
auth: Annotated[AuthSchema, Security(AuthPermission(["module_system:token:query"]))],
|
||||
db: Annotated[AsyncSession, Depends(db_getter)],
|
||||
page: Annotated[PaginationQueryParam, Query(description="分页参数")],
|
||||
search: Annotated[ApiTokenQueryParam, Query(description="查询参数")],
|
||||
) -> JSONResponse:
|
||||
result = await ApiTokenService(auth, db).page(
|
||||
page_no=page.page_no,
|
||||
page_size=page.page_size,
|
||||
search=search,
|
||||
order_by=page.order_by,
|
||||
)
|
||||
return SuccessResponse(data=result, msg="查询成功")
|
||||
|
||||
|
||||
@ApiTokenRouter.get("/detail/{id}", summary="token 详情", response_model=ResponseSchema[ApiTokenOutSchema])
|
||||
async def get_token_detail_controller(
|
||||
auth: Annotated[AuthSchema, Security(AuthPermission(["module_system:token:detail"]))],
|
||||
db: Annotated[AsyncSession, Depends(db_getter)],
|
||||
id: Annotated[int, Path(description="token ID", ge=1)],
|
||||
) -> JSONResponse:
|
||||
result = await ApiTokenService(auth, db).detail(id=id)
|
||||
return SuccessResponse(data=result, msg="查询成功")
|
||||
|
||||
|
||||
@ApiTokenRouter.post("/{id}/reset", summary="重置 token(重新生成 secret)", response_model=ResponseSchema[ApiTokenCreatedSchema])
|
||||
async def reset_token_controller(
|
||||
auth: Annotated[AuthSchema, Security(AuthPermission(["module_system:token:reset"]))],
|
||||
db: Annotated[AsyncSession, Depends(db_getter)],
|
||||
id: Annotated[int, Path(description="token ID", ge=1)],
|
||||
data: Annotated[ApiTokenResetSchema, Body(description="可选项")],
|
||||
) -> JSONResponse:
|
||||
"""重置后会再次返回完整明文(仅此一次)。"""
|
||||
result = await ApiTokenService(auth, db).reset(id=id, data=data)
|
||||
return SuccessResponse(data=result, msg="重置 token 成功")
|
||||
|
||||
|
||||
@ApiTokenRouter.patch("/{id}/status", summary="启用/禁用 token", response_model=ResponseSchema[None])
|
||||
async def set_token_status_controller(
|
||||
auth: Annotated[AuthSchema, Security(AuthPermission(["module_system:token:patch"]))],
|
||||
db: Annotated[AsyncSession, Depends(db_getter)],
|
||||
id: Annotated[int, Path(description="token ID", ge=1)],
|
||||
status: Annotated[int, Body(description="状态", ge=0, le=2)],
|
||||
) -> JSONResponse:
|
||||
await ApiTokenService(auth, db).set_status(id=id, status=status)
|
||||
return SuccessResponse(msg="状态修改成功")
|
||||
|
||||
|
||||
@ApiTokenRouter.delete("/{id}", summary="删除 token", response_model=ResponseSchema[None])
|
||||
async def delete_token_controller(
|
||||
auth: Annotated[AuthSchema, Security(AuthPermission(["module_system:token:delete"]))],
|
||||
db: Annotated[AsyncSession, Depends(db_getter)],
|
||||
id: Annotated[int, Path(description="token ID", ge=1)],
|
||||
) -> JSONResponse:
|
||||
await ApiTokenService(auth, db).delete(id=id)
|
||||
return SuccessResponse(msg="删除成功")
|
||||
|
||||
|
||||
@ApiTokenRouter.post("/{id}/reveal", summary="查看 token 明文(需二次验证)", response_model=ResponseSchema[ApiTokenRevealOutSchema])
|
||||
async def reveal_token_controller(
|
||||
auth: Annotated[AuthSchema, Security(AuthPermission(["module_system:token:reveal"]))],
|
||||
db: Annotated[AsyncSession, Depends(db_getter)],
|
||||
id: Annotated[int, Path(description="token ID", ge=1)],
|
||||
data: Annotated[ApiTokenRevealSchema, Body(description="需输入当前用户密码")],
|
||||
) -> JSONResponse:
|
||||
"""高权限端点:会返回完整明文,需要二次密码验证。"""
|
||||
result = await ApiTokenService(auth, db).reveal(id=id, data=data)
|
||||
return SuccessResponse(data=result, msg="reveal 成功")
|
||||
@@ -0,0 +1,14 @@
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.base_crud import CRUDBase
|
||||
from app.core.base_schema import AuthSchema
|
||||
|
||||
from .model import ApiTokenModel
|
||||
from .schema import ApiTokenCreateSchema
|
||||
|
||||
|
||||
class ApiTokenCRUD(CRUDBase[ApiTokenModel, ApiTokenCreateSchema, ApiTokenCreateSchema]):
|
||||
"""平台 API Token CRUD 基础实现"""
|
||||
|
||||
def __init__(self, auth: AuthSchema, db: AsyncSession) -> None:
|
||||
super().__init__(auth=auth, model=ApiTokenModel, db=db)
|
||||
@@ -0,0 +1,52 @@
|
||||
"""API Token 数据模型
|
||||
|
||||
设计要点:
|
||||
- token 全名:``fastpat_<tenant_code>_<tenant_id_hex>_<48-random>``,明文存 ``token_plain`` 字段
|
||||
(按业务需求选择明文存储,便于长期使用的 webhook / 集成 token)
|
||||
- ``token_prefix`` 字段存前 12 字符用于列表展示,剩余部分用 ``fp_xxxx****yz`` 形式脱敏
|
||||
- 支持 ``scopes``、``expires_at``、``rate_limit``、``status`` 等运营字段
|
||||
- ``last_used_at`` 与 ``used_count`` 提供审计
|
||||
"""
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import ForeignKey, Index, Integer, String, Text
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.core.base_model import ModelMixin, TenantMixin, UserMixin
|
||||
|
||||
|
||||
class ApiTokenModel(ModelMixin, TenantMixin, UserMixin):
|
||||
"""租户 API 访问令牌(用于外部系统/集成调用)
|
||||
|
||||
token 全名格式:
|
||||
fastpat_<tenant_code>_<tenant_id_hex>_<48-char-base64url-secret>
|
||||
|
||||
字段:
|
||||
- ``token_plain``:明文令牌(创建时一次性返回,后续可读但不推荐直接读)
|
||||
- ``token_prefix``:用于列表展示的前 12 字符
|
||||
- ``scopes``:JSON 数组(``["order:read", "user:write"]``),控制 API 可访问范围
|
||||
- ``expires_at``:过期时间(空=永久)
|
||||
- ``rate_limit``:每小时请求配额(默认 1000)
|
||||
- ``status``:0=启用 1=禁用 2=吊销
|
||||
- ``last_used_at/used_count/last_used_ip``:调用审计
|
||||
"""
|
||||
|
||||
__tablename__: str = "sys_api_token"
|
||||
__table_args__ = (
|
||||
Index("idx_tenant_token_prefix", "tenant_id", "token_prefix"),
|
||||
{"comment": "租户 API 访问令牌"},
|
||||
)
|
||||
__loader_options__: list[str] = ["tenant_by", "created_by", "updated_by"]
|
||||
|
||||
name: Mapped[str] = mapped_column(String(64), nullable=False, comment="令牌名称(业务语义,如:CRM-对账集成)")
|
||||
token_prefix: Mapped[str] = mapped_column(String(32), nullable=False, index=True, comment="明文 token 前 12 字符(用于展示)")
|
||||
token_plain: Mapped[str] = mapped_column(Text, nullable=False, comment="明文 token(自管理,按需用于外部集成)")
|
||||
owner_user_id: Mapped[int | None] = mapped_column(Integer, ForeignKey("sys_user.id", ondelete="SET NULL", onupdate="CASCADE"), nullable=True, index=True, comment="所属用户ID(创建者/操作者)")
|
||||
scopes: Mapped[str] = mapped_column(String(255), nullable=False, default="*", comment="可用 scope(逗号或 JSON 数组字符串)")
|
||||
expires_at: Mapped[datetime | None] = mapped_column(nullable=True, comment="过期时间(NULL=永不过期)")
|
||||
status: Mapped[int] = mapped_column(Integer, default=0, nullable=False, comment="状态(0:启用 1:禁用 2:吊销)", index=True)
|
||||
rate_limit: Mapped[int] = mapped_column(Integer, default=1000, nullable=False, comment="每小时请求上限")
|
||||
used_count: Mapped[int] = mapped_column(Integer, default=0, nullable=False, comment="累计调用次数")
|
||||
last_used_at: Mapped[datetime | None] = mapped_column(nullable=True, comment="最近一次调用时间")
|
||||
last_used_ip: Mapped[str | None] = mapped_column(String(64), nullable=True, comment="最近一次调用 IP")
|
||||
description: Mapped[str | None] = mapped_column(Text, nullable=True, comment="备注")
|
||||
@@ -0,0 +1,91 @@
|
||||
from datetime import datetime
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
from app.core.base_schema import PaginationQueryParam
|
||||
|
||||
|
||||
class ApiTokenCreateSchema(BaseModel):
|
||||
"""创建 API Token"""
|
||||
|
||||
name: str = Field(..., min_length=2, max_length=64, description="令牌业务名称")
|
||||
scopes: list[str] = Field(default_factory=lambda: ["*"], description="可用 scope,``*`` 表示全部")
|
||||
expires_at: datetime | None = Field(default=None, description="过期时间(NULL=永不过期)")
|
||||
rate_limit: int = Field(default=1000, ge=1, le=1_000_000, description="每小时请求上限")
|
||||
description: str | None = Field(default=None, max_length=512, description="备注")
|
||||
|
||||
|
||||
class ApiTokenResetSchema(BaseModel):
|
||||
"""重置(重新生成 secret)— 沿用同名 token,仅替换 secret 段"""
|
||||
|
||||
name: str | None = Field(default=None, max_length=64, description="新名称(不传则保持原值)")
|
||||
scopes: list[str] | None = Field(default=None, description="新 scope(不传则保持原值)")
|
||||
expires_at: datetime | None = Field(default=None, description="新过期时间")
|
||||
rate_limit: int | None = Field(default=None, ge=1, le=1_000_000, description="新配额(NULL 保持原值)")
|
||||
|
||||
|
||||
class ApiTokenQueryParam(PaginationQueryParam):
|
||||
"""列表查询条件"""
|
||||
|
||||
name: str | None = Field(default=None, description="名称模糊匹配")
|
||||
status: int | None = Field(default=None, description="状态精确匹配")
|
||||
|
||||
|
||||
class ApiTokenRevealSchema(BaseModel):
|
||||
"""查看明文 — 需当前用户密码二次验证"""
|
||||
|
||||
password: str = Field(..., min_length=6, max_length=128, description="当前用户登录密码")
|
||||
|
||||
|
||||
class ApiTokenOutSchema(BaseModel):
|
||||
"""列表/详情输出:脱敏(不含 token_plain)"""
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: int
|
||||
name: str
|
||||
token_prefix: str = Field(..., description="明文 token 前 12 字符,用于识别")
|
||||
token_mask: str = Field(..., description="脱敏展示,例如 ``fastpat_xxxx****yz3w``")
|
||||
owner_user_id: int | None
|
||||
scopes: str
|
||||
status: int
|
||||
rate_limit: int
|
||||
expires_at: datetime | None
|
||||
used_count: int
|
||||
last_used_at: datetime | None
|
||||
last_used_ip: str | None
|
||||
description: str | None
|
||||
tenant_id: int
|
||||
created_id: int | None
|
||||
updated_id: int | None
|
||||
created_time: datetime | None
|
||||
updated_time: datetime | None
|
||||
|
||||
|
||||
class ApiTokenCreatedSchema(BaseModel):
|
||||
"""创建/重置响应:唯一含明文 token 的输出"""
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: int
|
||||
name: str
|
||||
token: str = Field(..., description="完整明文 token(仅此一次返回,请妥善保管)")
|
||||
token_prefix: str
|
||||
scopes: list[str]
|
||||
expires_at: datetime | None
|
||||
rate_limit: int
|
||||
status: int
|
||||
tenant_id: int
|
||||
created_time: datetime | None
|
||||
warning: str = Field(
|
||||
default="请立即保存此 token。关闭此页面后将无法再次完整查看明文,如遗失请重置。",
|
||||
description="安全提示",
|
||||
)
|
||||
|
||||
|
||||
class ApiTokenRevealOutSchema(BaseModel):
|
||||
"""reveal 响应:含完整明文 + 警告"""
|
||||
|
||||
token: str
|
||||
name: str
|
||||
warning: str = "此为完整明文,仅高权限场景下返回,请勿写入日志/代码/对话。"
|
||||
@@ -0,0 +1,322 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import secrets
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
from redis.asyncio.client import Redis
|
||||
from sqlalchemy import update as sa_update
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.api.v1.module_platform.tenant.crud import TenantCRUD
|
||||
from app.api.v1.module_system.user.crud import UserCRUD
|
||||
from app.core.base_schema import AuthSchema, PageResultSchema
|
||||
from app.core.database import async_db_session
|
||||
from app.core.exceptions import CustomException
|
||||
from app.core.logger import logger
|
||||
from app.utils.password_util import PwdUtil
|
||||
|
||||
from .crud import ApiTokenCRUD
|
||||
from .model import ApiTokenModel
|
||||
from .schema import (
|
||||
ApiTokenCreatedSchema,
|
||||
ApiTokenCreateSchema,
|
||||
ApiTokenOutSchema,
|
||||
ApiTokenQueryParam,
|
||||
ApiTokenResetSchema,
|
||||
ApiTokenRevealOutSchema,
|
||||
ApiTokenRevealSchema,
|
||||
)
|
||||
|
||||
_TOKEN_PREFIX_HEADER = "fastpat_"
|
||||
_TOKEN_PREFIX_DISPLAY_LEN = 12
|
||||
_REDIS_RATE_KEY_PREFIX = "api_token:rate:"
|
||||
|
||||
|
||||
def _generate_full_token(tenant_code: str, tenant_id: int) -> str:
|
||||
"""生成完整 token:``fastpat_<tenant_code>_<tenant_id_hex>_<48-base64url>``"""
|
||||
secret_part = secrets.token_urlsafe(36)
|
||||
return f"{_TOKEN_PREFIX_HEADER}{tenant_code}_{tenant_id:x}_{secret_part}"
|
||||
|
||||
|
||||
def _mask_token(full_token: str) -> str:
|
||||
"""脱敏展示:保留头部 + ``****`` + 尾部 4 字符"""
|
||||
if len(full_token) <= 16:
|
||||
return "****"
|
||||
return f"{full_token[:14]}****{full_token[-4:]}"
|
||||
|
||||
|
||||
def _to_out_schema(token: ApiTokenModel) -> ApiTokenOutSchema:
|
||||
return ApiTokenOutSchema(
|
||||
id=token.id,
|
||||
name=token.name,
|
||||
token_prefix=token.token_prefix,
|
||||
token_mask=_mask_token(token.token_plain),
|
||||
owner_user_id=token.owner_user_id,
|
||||
scopes=token.scopes,
|
||||
status=token.status,
|
||||
rate_limit=token.rate_limit,
|
||||
expires_at=token.expires_at,
|
||||
used_count=token.used_count,
|
||||
last_used_at=token.last_used_at,
|
||||
last_used_ip=token.last_used_ip,
|
||||
description=token.description,
|
||||
tenant_id=token.tenant_id,
|
||||
created_id=token.created_id,
|
||||
updated_id=token.updated_id,
|
||||
created_time=token.created_time,
|
||||
updated_time=token.updated_time,
|
||||
)
|
||||
|
||||
|
||||
def _parse_scopes(scopes_str: str) -> list[str]:
|
||||
if not scopes_str:
|
||||
return []
|
||||
if scopes_str == "*":
|
||||
return ["*"]
|
||||
try:
|
||||
loaded = json.loads(scopes_str)
|
||||
if isinstance(loaded, list):
|
||||
return loaded
|
||||
except (json.JSONDecodeError, ValueError):
|
||||
pass
|
||||
return [s.strip() for s in scopes_str.split(",") if s.strip()]
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────
|
||||
# Service
|
||||
# ──────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class ApiTokenService:
|
||||
"""API Token 业务逻辑层"""
|
||||
|
||||
MAX_TOKENS_PER_TENANT: int = 50
|
||||
|
||||
def __init__(self, auth: AuthSchema, db: AsyncSession) -> None:
|
||||
self.auth = auth
|
||||
self.db = db
|
||||
|
||||
# ── 租户隔离检查 ──────────────────────────────────────
|
||||
|
||||
def _check_tenant_access(self, token: ApiTokenModel) -> None:
|
||||
"""非超管只能访问本租户 token"""
|
||||
if not self.auth.user.is_superuser and token.tenant_id != self.auth.user.tenant_id:
|
||||
raise CustomException(msg="无权操作其他租户的 token")
|
||||
|
||||
# ── 创建 ──────────────────────────────────────────────
|
||||
|
||||
async def create(self, data: ApiTokenCreateSchema) -> ApiTokenCreatedSchema:
|
||||
tenant = await TenantCRUD(self.auth, self.db).get(id=self.auth.user.tenant_id)
|
||||
if not tenant:
|
||||
raise CustomException(msg="租户上下文失效,无法创建 token")
|
||||
|
||||
existing = await ApiTokenCRUD(self.auth, self.db).get_list(
|
||||
search={"tenant_id": tenant.id},
|
||||
)
|
||||
active_count = sum(1 for t in existing if t.status == 0 and not t.is_deleted)
|
||||
if active_count >= self.MAX_TOKENS_PER_TENANT:
|
||||
raise CustomException(msg=f"该租户 API Token 数量已达上限 ({self.MAX_TOKENS_PER_TENANT}),请先删除或禁用旧 token")
|
||||
|
||||
full_token = _generate_full_token(tenant_code=tenant.code, tenant_id=tenant.id)
|
||||
token_prefix = full_token[:_TOKEN_PREFIX_DISPLAY_LEN]
|
||||
|
||||
scopes_str = ",".join(data.scopes) if data.scopes else "*"
|
||||
crud = ApiTokenCRUD(self.auth, self.db)
|
||||
token_obj = await crud.create(
|
||||
data={ # pyright: ignore[reportArgumentType]
|
||||
"name": data.name,
|
||||
"token_prefix": token_prefix,
|
||||
"token_plain": full_token,
|
||||
"owner_user_id": self.auth.user.id,
|
||||
"scopes": scopes_str,
|
||||
"expires_at": data.expires_at,
|
||||
"status": 0,
|
||||
"rate_limit": data.rate_limit,
|
||||
"description": data.description,
|
||||
},
|
||||
)
|
||||
if not token_obj:
|
||||
raise CustomException(msg="创建 token 失败")
|
||||
|
||||
logger.info(f"租户[{tenant.id}]新 token 创建成功: id={token_obj.id} name={data.name}")
|
||||
return ApiTokenCreatedSchema(
|
||||
id=token_obj.id,
|
||||
name=token_obj.name,
|
||||
token=full_token,
|
||||
token_prefix=token_prefix,
|
||||
scopes=data.scopes,
|
||||
expires_at=token_obj.expires_at,
|
||||
rate_limit=token_obj.rate_limit,
|
||||
status=token_obj.status,
|
||||
tenant_id=token_obj.tenant_id,
|
||||
created_time=token_obj.created_time,
|
||||
)
|
||||
|
||||
# ── 查询 ──────────────────────────────────────────────
|
||||
|
||||
async def page(
|
||||
self,
|
||||
page_no: int,
|
||||
page_size: int,
|
||||
search: ApiTokenQueryParam,
|
||||
order_by: list[dict[str, str]] | None = None,
|
||||
) -> PageResultSchema[ApiTokenOutSchema]:
|
||||
crud = ApiTokenCRUD(self.auth, self.db)
|
||||
search_dict: dict[str, Any] = {}
|
||||
if search.name:
|
||||
search_dict["name"] = ("like", f"%{search.name}%")
|
||||
if search.status is not None:
|
||||
search_dict["status"] = search.status
|
||||
result = await crud.page(
|
||||
offset=(page_no - 1) * page_size,
|
||||
limit=page_size,
|
||||
search=search_dict,
|
||||
order_by=order_by or [{"id": "asc"}],
|
||||
)
|
||||
items_out = [_to_out_schema(row) for row in result.items]
|
||||
return PageResultSchema[ApiTokenOutSchema](
|
||||
page_no=result.page_no,
|
||||
page_size=result.page_size,
|
||||
total=result.total,
|
||||
has_next=result.has_next,
|
||||
items=items_out,
|
||||
)
|
||||
|
||||
async def detail(self, id: int) -> ApiTokenOutSchema:
|
||||
crud = ApiTokenCRUD(self.auth, self.db)
|
||||
token = await crud.get_or_404(id=id)
|
||||
self._check_tenant_access(token)
|
||||
return _to_out_schema(token)
|
||||
|
||||
# ── 状态/重置 ──────────────────────────────────────────
|
||||
|
||||
async def reset(self, id: int, data: ApiTokenResetSchema) -> ApiTokenCreatedSchema:
|
||||
crud = ApiTokenCRUD(self.auth, self.db)
|
||||
token = await crud.get_or_404(id=id)
|
||||
self._check_tenant_access(token)
|
||||
|
||||
tenant = await TenantCRUD(self.auth, self.db).get(id=token.tenant_id)
|
||||
if not tenant:
|
||||
raise CustomException(msg="租户不存在")
|
||||
|
||||
full_token = _generate_full_token(tenant_code=tenant.code, tenant_id=tenant.id)
|
||||
token_prefix_new = full_token[:_TOKEN_PREFIX_DISPLAY_LEN]
|
||||
|
||||
values: dict[str, Any] = {
|
||||
"token_prefix": token_prefix_new,
|
||||
"token_plain": full_token,
|
||||
"used_count": 0,
|
||||
}
|
||||
if data.name is not None:
|
||||
values["name"] = data.name
|
||||
if data.scopes is not None:
|
||||
values["scopes"] = ",".join(data.scopes)
|
||||
if data.expires_at is not None:
|
||||
values["expires_at"] = data.expires_at
|
||||
if data.rate_limit is not None:
|
||||
values["rate_limit"] = data.rate_limit
|
||||
|
||||
await self.db.execute(sa_update(ApiTokenModel).where(ApiTokenModel.id == id).values(**values))
|
||||
await self.db.flush()
|
||||
await self.db.refresh(token)
|
||||
|
||||
logger.info(f"租户[{tenant.id}] token[{id}] 已重置,新前缀={token_prefix_new}")
|
||||
return ApiTokenCreatedSchema(
|
||||
id=token.id,
|
||||
name=token.name,
|
||||
token=full_token,
|
||||
token_prefix=token_prefix_new,
|
||||
scopes=_parse_scopes(token.scopes),
|
||||
expires_at=token.expires_at,
|
||||
rate_limit=token.rate_limit,
|
||||
status=token.status,
|
||||
tenant_id=token.tenant_id,
|
||||
created_time=token.created_time,
|
||||
)
|
||||
|
||||
async def set_status(self, id: int, status: int) -> None:
|
||||
if status not in (0, 1, 2):
|
||||
raise CustomException(msg="状态值不合法(0:启用 1:禁用 2:吊销)")
|
||||
crud = ApiTokenCRUD(self.auth, self.db)
|
||||
token = await crud.get_or_404(id=id)
|
||||
self._check_tenant_access(token)
|
||||
await crud.update(id=id, data={"status": status}) # pyright: ignore[reportArgumentType]
|
||||
|
||||
async def delete(self, id: int) -> None:
|
||||
crud = ApiTokenCRUD(self.auth, self.db)
|
||||
token = await crud.get_or_404(id=id)
|
||||
self._check_tenant_access(token)
|
||||
await crud.delete(ids=[id])
|
||||
|
||||
# ── reveal:二次验证后展示明文 ─────────────────────────
|
||||
|
||||
async def reveal(self, id: int, data: ApiTokenRevealSchema) -> ApiTokenRevealOutSchema:
|
||||
user_row = await UserCRUD(self.auth, self.db).get(id=self.auth.user.id)
|
||||
if not user_row:
|
||||
raise CustomException(msg="用户不存在")
|
||||
if not PwdUtil.verify_password(plain_password=data.password, password_hash=user_row.password):
|
||||
logger.warning(f"reveal 二次验证失败: user_id={self.auth.user.id}")
|
||||
raise CustomException(msg="密码错误,无法 reveal 明文")
|
||||
|
||||
crud = ApiTokenCRUD(self.auth, self.db)
|
||||
token = await crud.get_or_404(id=id)
|
||||
self._check_tenant_access(token)
|
||||
|
||||
return ApiTokenRevealOutSchema(token=token.token_plain, name=token.name)
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────
|
||||
# 外部 API Bearer 验证(公开接口)
|
||||
# ──────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
async def authenticate_api_token(token: str, request_ip: str | None = None, redis: Redis | None = None) -> ApiTokenModel:
|
||||
"""外部 API 鉴权:从 Authorization Bearer 中解析 fastpat token,记录调用次数。"""
|
||||
if not token or not token.startswith(_TOKEN_PREFIX_HEADER):
|
||||
raise CustomException(msg="API Token 格式不合法", code=10401, status_code=401)
|
||||
|
||||
async with async_db_session() as db:
|
||||
crud = ApiTokenCRUD(AuthSchema(check_data_scope=False), db)
|
||||
candidate = await crud.get_list(search={"token_plain": ("=", token)})
|
||||
if not candidate:
|
||||
raise CustomException(msg="API Token 无效", code=10401, status_code=401)
|
||||
token_row = candidate[0]
|
||||
|
||||
if token_row.status != 0:
|
||||
raise CustomException(msg="API Token 已禁用或吊销", code=10401, status_code=401)
|
||||
if token_row.expires_at is not None and token_row.expires_at < datetime.now():
|
||||
raise CustomException(msg="API Token 已过期", code=10401, status_code=401)
|
||||
if token_row.is_deleted:
|
||||
raise CustomException(msg="API Token 已删除", code=10401, status_code=401)
|
||||
|
||||
# 限流(每小时)
|
||||
if redis is not None:
|
||||
try:
|
||||
key = f"{_REDIS_RATE_KEY_PREFIX}{token_row.id}:{datetime.now().strftime('%Y%m%d%H')}"
|
||||
current = await redis.incr(key)
|
||||
if current == 1:
|
||||
await redis.expire(key, 3600)
|
||||
if current > token_row.rate_limit:
|
||||
raise CustomException(
|
||||
msg=f"API Token 限流:本小时已调用 {current} 次,上限 {token_row.rate_limit}",
|
||||
code=10429,
|
||||
status_code=429,
|
||||
)
|
||||
except CustomException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.warning(f"API Token 限流检查失败(继续放行): {e!s}")
|
||||
|
||||
await db.execute(
|
||||
sa_update(ApiTokenModel)
|
||||
.where(ApiTokenModel.id == token_row.id)
|
||||
.values(
|
||||
used_count=ApiTokenModel.used_count + 1,
|
||||
last_used_at=datetime.now(),
|
||||
last_used_ip=request_ip,
|
||||
),
|
||||
)
|
||||
await db.commit()
|
||||
return token_row
|
||||
Reference in New Issue
Block a user