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:
zhangtao
2026-07-13 01:14:20 +08:00
parent 6a5f8cf0dd
commit cf88ab8897
102 changed files with 4078 additions and 3777 deletions
@@ -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 ControllerCRUD + 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