فهرست منبع

feat: 增加apikey

gatsby 1 ماه پیش
والد
کامیت
83120d35b4

+ 16 - 5
backend/Dockerfile

@@ -1,13 +1,24 @@
-FROM python:3.9-slim
+FROM python:3.11-slim AS builder
 
 WORKDIR /app
 
 COPY requirements.txt .
 
-RUN pip install --no-cache-dir -r requirements.txt
+# 安装依赖,使用清华镜像源
+RUN pip install --no-cache-dir --upgrade pip && \
+    pip install --no-cache-dir -r requirements.txt -i https://pypi.tuna.tsinghua.edu.cn/simple
 
-COPY . .
+FROM python:3.11-slim AS runtime
 
-EXPOSE 8000
+# 设置时区
+ENV TZ Asia/Shanghai
 
-CMD ["uvicorn", "app.api.v1:app", "--host", "0.0.0.0", "--port", "8000", "--reload"]
+WORKDIR /app
+
+COPY --from=builder /app /app
+
+# 暴露端口
+EXPOSE 8001
+
+# 运行应用
+CMD ["python", "main.py", "run", "--env=prod"]

+ 2 - 2
backend/app/api/v1/module_system/__init__.py

@@ -11,7 +11,7 @@ from .notice.controller import NoticeRouter
 from .params.controller import ParamsRouter
 from .position.controller import PositionRouter
 from .role.controller import RoleRouter
-from .tenant.controller import TenantRouter
+from .tenant import TenantModuleRouter
 from .user.controller import UserRouter
 
 system_router = APIRouter(prefix="/system")
@@ -25,5 +25,5 @@ system_router.include_router(NoticeRouter)
 system_router.include_router(ParamsRouter)
 system_router.include_router(PositionRouter)
 system_router.include_router(RoleRouter)
-system_router.include_router(TenantRouter)
+system_router.include_router(TenantModuleRouter)
 system_router.include_router(UserRouter)

+ 9 - 0
backend/app/api/v1/module_system/tenant/__init__.py

@@ -0,0 +1,9 @@
+from fastapi import APIRouter
+
+from app.api.v1.module_system.tenant.controller import TenantRouter
+
+
+TenantModuleRouter = APIRouter(prefix="/tenant")
+
+TenantModuleRouter.include_router(TenantRouter, prefix="")
+

+ 132 - 0
backend/app/core/tenant_api_auth.py

@@ -0,0 +1,132 @@
+import time
+from typing import Optional, Dict, Any, Callable, Awaitable
+
+from fastapi import Request, Response, Depends, HTTPException, security
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from app.core.dependencies import db_getter
+from app.api.v1.module_system.auth.schema import AuthSchema
+from app.plugin.module_payment.apikey.service import TenantApiKeyService
+from app.plugin.module_payment.apikey.model import TenantApiKeyModel
+
+
+class TenantApiKeyAuth:
+    """
+    租户API Key认证
+    """
+    def __init__(self, auto_error: bool = True):
+        self.auto_error = auto_error
+
+    async def __call__(self, request: Request, db: AsyncSession = Depends(db_getter)) -> Optional[TenantApiKeyModel]:
+        """
+        验证API Key
+        """
+        # 记录请求开始时间
+        request.state.start_time = time.time()
+        
+        # 获取Authorization头
+        authorization = request.headers.get("Authorization", None)
+        
+        if not authorization:
+            if self.auto_error:
+                raise HTTPException(status_code=401, detail="Authorization header required")
+            return None
+        
+        # 检查是否为ApiKey认证
+        if not authorization.startswith("ApiKey "):
+            if self.auto_error:
+                raise HTTPException(status_code=401, detail="Invalid authorization format")
+            return None
+        
+        # 提取API Key和签名
+        auth_str = authorization[7:]
+        if ":" in auth_str:
+            api_key, signature = auth_str.split(":", 1)
+        else:
+            api_key, signature = auth_str, None
+        
+        # 创建AuthSchema对象
+        temp_auth = AuthSchema(
+            user=None,
+            check_data_scope=False,
+            db=db,
+            tenant_id=0
+        )
+        
+        # 验证API Key
+        api_key_obj = await TenantApiKeyService.validate_api_key(temp_auth, api_key)
+        if not api_key_obj:
+            # 记录失败日志
+            await TenantApiKeyService.log_api_call(
+                auth=temp_auth,
+                api_key_id=None,
+                tenant_id=0,
+                endpoint=str(request.url.path),
+                method=request.method,
+                request_ip=request.client.host if request.client else "unknown",
+                request_data=None,
+                response_code=401,
+                start_time=request.state.start_time,
+            )
+            if self.auto_error:
+                raise HTTPException(status_code=401, detail="Invalid API Key")
+            return None
+        
+        # 验证签名(如果提供)
+        if signature:
+            # 获取请求数据
+            try:
+                request_data = await request.json()
+            except Exception:
+                request_data = {}
+            
+            if not TenantApiKeyService.verify_signature(api_key_obj.api_secret, request_data, signature):
+                # 记录失败日志
+                await TenantApiKeyService.log_api_call(
+                    auth=temp_auth,
+                    api_key_id=api_key_obj.id,
+                    tenant_id=api_key_obj.tenant_id,
+                    endpoint=str(request.url.path),
+                    method=request.method,
+                    request_ip=request.client.host if request.client else "unknown",
+                    request_data=request_data,
+                    response_code=401,
+                    start_time=request.state.start_time,
+                )
+                if self.auto_error:
+                    raise HTTPException(status_code=401, detail="Invalid Signature")
+                return None
+        
+        # 记录成功日志
+        await TenantApiKeyService.log_api_call(
+            auth=temp_auth,
+            api_key_id=api_key_obj.id,
+            tenant_id=api_key_obj.tenant_id,
+            endpoint=str(request.url.path),
+            method=request.method,
+            request_ip=request.client.host if request.client else "unknown",
+            request_data=None,  # 避免记录敏感数据
+            response_code=200,
+            start_time=request.state.start_time,
+        )
+        
+        # 更新最后使用时间
+        await TenantApiKeyService.update_last_used(temp_auth, api_key_obj.id)
+        
+        # 将API Key对象存储到请求状态
+        request.state.api_key = api_key_obj
+        request.state.tenant_id = api_key_obj.tenant_id
+        
+        return api_key_obj
+
+
+async def get_tenant_from_api_key(
+    request: Request,
+    api_key: Optional[TenantApiKeyModel] = Depends(TenantApiKeyAuth(auto_error=False)),
+) -> Optional[int]:
+    """
+    从API Key获取租户ID
+    """
+    if api_key:
+        return api_key.tenant_id
+    return None

+ 23 - 3
backend/app/plugin/module_payment/account/schema.py

@@ -104,8 +104,8 @@ class AccountDepositSchema(BaseModel):
 class AccountTransferSchema(BaseModel):
     """资金专户转账请求"""
 
-    enterprise_id: str = Field(description="企业ID")
-    account_book_id: Optional[str] = Field(default=None, description="付款方资金专户号")
+    account_book_id: str = Field(description="付款方资金专户号")
+    enterprise_id: Optional[str] = Field(description="企业ID")
     out_biz_no: Optional[str] = Field(default=None, description="商家侧订单号")
     # 转账总金额,单位为元,精确到小数点后两位
     amount: Decimal = Field(max_digits=10, decimal_places=2, gt=0, description="转账金额")
@@ -123,6 +123,26 @@ class AccountTransferSchema(BaseModel):
         return self
 
 
+class TenantTransferCreate(BaseModel):
+    """租户API转账请求"""
+
+    account_book_id: str = Field(description="付款方资金专户号")
+    enterprise_id: Optional[str] = Field(description="企业ID")
+    # 转账总金额,单位为元,精确到小数点后两位
+    amount: Decimal = Field(max_digits=10, decimal_places=2, gt=0, description="转账金额")
+    order_title: Optional[str] = Field(default=None, description="转账标题")
+    remark: Optional[str] = Field(default=None, description="转账备注")
+    payee_info: PayeeInfoSchema = Field(description="收款方信息")
+
+
+class TenantTransferResponse(BaseModel):
+    """租户API转账响应"""
+
+    status: Optional[str] = Field(description="转账状态,SUCCESS:成功(对转账到银行卡的单据, 该状态可能变为退票[REFUND]); FAIL:失败; DEALING:处理中(转账到支付宝账户不涉及); REFUND:退票(转账到支付宝账户不涉及)")
+    order_no: Optional[str] = Field(default=None, description="支付宝转账单号")
+    fund_order_id: Optional[str] = Field(default=None, description="宝支付资金流水号")
+
+
 class AccountWithdrawSchema(BaseModel):
     """资金专户提现请求"""
 
@@ -402,4 +422,4 @@ class ConsumeDetailOutSchema(BaseModel):
 
     model_config = ConfigDict(from_attributes=True)
 
-    consume_info: EcConsumeInfoSchema = Field(description="账单信息")
+    consume_info: EcConsumeInfoSchema = Field(description="账单信息")

+ 117 - 11
backend/app/plugin/module_payment/account/service.py

@@ -27,6 +27,8 @@ from .schema import (
     AccountTransferOutSchema,
     TransferListOutSchema,
     TransferOutSchema,
+    TenantTransferCreate,
+    TenantTransferResponse,
 )
 
 
@@ -222,26 +224,29 @@ class AccountService:
         from alipay.aop.api.domain.BankCardExtInfoDTO import (
             BankCardExtInfoDTO,
         )
-
-        # 检查企业是否存在
-        enterprise = await EnterpriseCRUD(auth).get_by_enterprise_id(data.enterprise_id)
-        if not enterprise:
-            raise CustomException(msg="企业不存在")
-        if enterprise.tenant_id != auth.tenant_id:
-            raise CustomException(msg="无权限操作")
         
         # 检查资金专户是否存在
-        account = await AccountCRUD(auth).get_by_enterprise_id(data.enterprise_id)
+        account = await AccountCRUD(auth).get_by_account_book_id(data.account_book_id)
         if not account:
             raise CustomException(msg="资金账户不存在")
+        if account.tenant_id != auth.tenant_id:
+            raise CustomException(msg="无权限操作")
+        if data.enterprise_id and account.enterprise_id != data.enterprise_id:
+            raise CustomException(msg="参数错误")
+        
+        if not data.order_title and account.enterprise_id:
+            enterprise = await EnterpriseCRUD(auth).get_by_enterprise_id(account.enterprise_id)
+            if not enterprise:
+                raise CustomException(msg="资金账户所属企业不存在")
+            data.order_title = f"来自{enterprise.name}转账"
 
         model = AlipayCommerceEcTransAccountTransferModel()
-        model.enterprise_id = enterprise.enterprise_id
+        model.enterprise_id = account.enterprise_id
         model.account_book_id = account.account_book_id
         model.out_biz_no = get_snowflake_id_str(auth.tenant_id)
         # 转账总金额,单位为元,精确到小数点后两位
         model.amount = str(data.amount)
-        model.order_title = data.order_title or f"{enterprise.name}转账"
+        model.order_title = data.order_title
 
         payee_info = TransParticipant()
         payee_info.identity_type = data.payee_info.identity_type
@@ -291,6 +296,107 @@ class AccountService:
             fund_order_id=result.fund_order_id,
         )
 
+    @classmethod
+    async def tenant_transfer_service(
+        cls,
+        auth: AuthSchema,
+        tenant_id: int,
+        data: TenantTransferCreate,
+        request_ip: str,
+        api_key_id: int | None = None,
+    ) -> TenantTransferResponse:
+        """
+        租户API转账(通过API Key认证)
+
+        调用: alipay.commerce.ec.trans.account.transfer
+        """
+        from alipay.aop.api.request.AlipayCommerceEcTransAccountTransferRequest import (
+            AlipayCommerceEcTransAccountTransferRequest,
+        )
+        from alipay.aop.api.domain.AlipayCommerceEcTransAccountTransferModel import (
+            AlipayCommerceEcTransAccountTransferModel,
+        )
+        from alipay.aop.api.response.AlipayCommerceEcTransAccountTransferResponse import (
+            AlipayCommerceEcTransAccountTransferResponse,
+        )
+        from alipay.aop.api.domain.TransParticipant import (
+            TransParticipant,
+        )
+        from alipay.aop.api.domain.BankCardExtInfoDTO import (
+            BankCardExtInfoDTO,
+        )
+        
+        # 检查资金专户是否存在
+        account = await AccountCRUD(auth).get_by_account_book_id(data.account_book_id)
+        if not account:
+            raise CustomException(msg="资金账户不存在")
+        if account.tenant_id != tenant_id:
+            raise CustomException(msg="无权限操作")
+        if data.enterprise_id and account.enterprise_id != data.enterprise_id:
+            raise CustomException(msg="参数错误")
+        
+        if not data.order_title and account.enterprise_id:
+            enterprise = await EnterpriseCRUD(auth).get_by_enterprise_id(account.enterprise_id)
+            if not enterprise:
+                raise CustomException(msg="资金账户所属企业不存在")
+            data.order_title = f"来自{enterprise.name}转账"
+
+        model = AlipayCommerceEcTransAccountTransferModel()
+        model.enterprise_id = account.enterprise_id
+        model.account_book_id = account.account_book_id
+        model.out_biz_no = get_snowflake_id_str(tenant_id)
+        # 转账总金额,单位为元,精确到小数点后两位
+        model.amount = str(data.amount)
+        model.order_title = data.order_title
+
+        payee_info = TransParticipant()
+        payee_info.identity_type = data.payee_info.identity_type
+        payee_info.name = data.payee_info.name
+        payee_info.identity = data.payee_info.identity
+        if data.payee_info.bankcard_ext_info:
+            payee_info.bankcard_ext_info = BankCardExtInfoDTO.from_alipay_dict(
+                    data.payee_info.bankcard_ext_info.model_dump(exclude_none=True)
+            )
+
+        model.payee_info = payee_info
+
+        request = AlipayCommerceEcTransAccountTransferRequest()
+        request.biz_model = model
+
+        client = AlipayClient.get_client()
+        response = client.execute(request)
+
+        if not response:
+            raise CustomException(msg="转账失败: 无响应")
+
+        result = AlipayCommerceEcTransAccountTransferResponse()
+        result.parse_response_content(response)
+
+        if not result.is_success():
+            log.error(f"支付宝接口调用失败: {result.code} - {result.msg}")
+            raise CustomException(msg=f"转账失败: {result.sub_msg or result.msg or result.code}")
+
+        transfer_crud = TransferCRUD(auth)
+        transfer_data = {
+            "enterprise_id": model.enterprise_id,
+            "out_biz_no": model.out_biz_no,
+            "account_book_id": model.account_book_id,
+            "amount": model.amount,
+            "order_title": model.order_title,
+            "payee_info": data.payee_info.model_dump() if data.payee_info else None,
+            "status": result.status,
+            "order_no": result.order_no,
+            "fund_order_id": result.fund_order_id,
+        }
+
+        await transfer_crud.create(transfer_data)
+
+        return TenantTransferResponse(
+            status=result.status,
+            order_no=result.order_no,
+            fund_order_id=result.fund_order_id,
+        )
+
     # @classmethod
     # async def withdraw_service(
     #     cls,
@@ -604,4 +710,4 @@ class AccountService:
             "peer_pay_amount": getattr(consume_info, 'peer_pay_amount', None),
             "subsidy_amount": getattr(consume_info, 'subsidy_amount', None),
             "ext_infos": getattr(consume_info, 'ext_infos', None),
-        }
+        }

+ 1 - 0
backend/app/plugin/module_payment/apikey/__init__.py

@@ -0,0 +1 @@
+# API Key 模块初始化文件

+ 117 - 0
backend/app/plugin/module_payment/apikey/controller.py

@@ -0,0 +1,117 @@
+from typing import Annotated
+from fastapi import APIRouter, Depends, HTTPException, Path
+from fastapi.responses import JSONResponse
+
+from app.api.v1.module_system.auth.schema import AuthSchema
+from app.plugin.module_payment.apikey.schema import (
+    TenantApiKeyCreate,
+    TenantApiKeyResponse,
+    TenantApiKeyListResponse,
+    TenantApiKeyUpdate,
+    ApiKeyQueryParam,
+)
+from app.plugin.module_payment.apikey.service import TenantApiKeyService
+from app.common.response import ResponseSchema, SuccessResponse
+from app.core.base_params import PaginationQueryParam
+from app.core.dependencies import AuthPermission
+from app.core.logger import log
+
+ApiKeyRouter = APIRouter(prefix="/api-key", tags=["租户API Key管理"])
+
+
+@ApiKeyRouter.post(
+    "",
+    summary="创建API Key",
+    description="为租户创建新的API Key",
+    response_model=ResponseSchema[TenantApiKeyResponse],
+)
+async def create_api_key_controller(
+    data: TenantApiKeyCreate,
+    auth: Annotated[AuthSchema, Depends(AuthPermission(["module_system:tenant:api-key:create"]))],
+) -> JSONResponse:
+    tenant_id = data.tenant_id or auth.tenant_id
+    api_key_obj = await TenantApiKeyService.create_api_key(
+        auth=auth,
+        tenant_id=tenant_id,
+        expired_days=data.expired_days,
+        description=data.description,
+    )
+    log.info(f"创建API Key成功: 租户ID={tenant_id}")
+    return SuccessResponse(
+        data=TenantApiKeyResponse(
+            id=api_key_obj.id,
+            api_key=api_key_obj.api_key,
+            api_secret=api_key_obj.api_secret,
+            status=api_key_obj.status,
+            expired_at=api_key_obj.expired_at,
+            created_time=api_key_obj.created_time,
+        )
+    )
+
+
+@ApiKeyRouter.get(
+    "/list",
+    summary="查询API Key",
+    description="查询API Key",
+    response_model=ResponseSchema[list[TenantApiKeyListResponse]],
+)
+async def get_api_key_list_controller(
+    page: Annotated[PaginationQueryParam, Depends()],
+    search: Annotated[ApiKeyQueryParam, Depends()],
+    auth: Annotated[AuthSchema, Depends(AuthPermission(["module_system:tenant:api-key:query"]))],
+) -> JSONResponse:
+    target_tenant_id = search.tenant_id or auth.tenant_id
+    result_dict = await TenantApiKeyService.get_api_key_page_service(
+        auth=auth,
+        page_no=page.page_no,
+        page_size=page.page_size,
+        tenant_id=target_tenant_id,
+        status=search.status,
+    )
+    log.info("查询API Key成功")
+    return SuccessResponse(data=result_dict)
+
+
+@ApiKeyRouter.put(
+    "/{api_key_id}",
+    summary="更新API Key状态",
+    description="更新API Key的状态",
+    response_model=ResponseSchema[TenantApiKeyListResponse],
+)
+async def update_api_key_status_controller(
+    api_key_id: Annotated[int, Path(description="API Key ID")],
+    data: TenantApiKeyUpdate,
+    auth: Annotated[AuthSchema, Depends(AuthPermission(["module_system:tenant:api-key:update"]))],
+) -> JSONResponse:
+    api_key_obj = await TenantApiKeyService.update_api_key_status(auth=auth, api_key_id=api_key_id, status=data.status)
+    if not api_key_obj:
+        raise HTTPException(status_code=404, detail="API Key不存在")
+    log.info(f"更新API Key状态成功: ID={api_key_id}, 状态={data.status}")
+    return SuccessResponse(
+        data=TenantApiKeyListResponse(
+            id=api_key_obj.id,
+            api_key=api_key_obj.api_key,
+            status=api_key_obj.status,
+            expired_at=api_key_obj.expired_at,
+            last_used_at=api_key_obj.last_used_at,
+            created_time=api_key_obj.created_time,
+            description=api_key_obj.description,
+        )
+    )
+
+
+@ApiKeyRouter.delete(
+    "/{api_key_id}",
+    summary="删除API Key",
+    description="删除API Key",
+    response_model=ResponseSchema[dict],
+)
+async def delete_api_key_controller(
+    api_key_id: Annotated[int, Path(description="API Key ID")],
+    auth: Annotated[AuthSchema, Depends(AuthPermission(["module_system:tenant:api-key:delete"]))],
+) -> JSONResponse:
+    success = await TenantApiKeyService.delete_api_key(auth=auth, api_key_id=api_key_id)
+    if not success:
+        raise HTTPException(status_code=404, detail="API Key不存在")
+    log.info(f"删除API Key成功: ID={api_key_id}")
+    return SuccessResponse(data={"success": True})

+ 135 - 0
backend/app/plugin/module_payment/apikey/crud.py

@@ -0,0 +1,135 @@
+from collections.abc import Sequence
+from datetime import datetime
+from typing import Any
+
+from app.api.v1.module_system.auth.schema import AuthSchema
+from app.plugin.module_payment.apikey.model import TenantApiKeyModel, TenantApiLogModel
+from app.plugin.module_payment.apikey.schema import (
+    TenantApiKeyCreate,
+    TenantApiKeyUpdate,
+    TenantApiLogCreate,
+)
+from app.core.base_crud import CRUDBase
+
+
+class TenantApiKeyCRUD(CRUDBase[TenantApiKeyModel, TenantApiKeyCreate, TenantApiKeyUpdate]):
+    """租户API Key数据层"""
+
+    def __init__(self, auth: AuthSchema) -> None:
+        self.auth = auth
+        super().__init__(model=TenantApiKeyModel, auth=auth)
+
+    async def get_by_id_crud(
+        self, id: int, preload: list[str | Any] | None = None
+    ) -> TenantApiKeyModel | None:
+        return await self.get(id=id, preload=preload)
+
+    async def get_by_api_key(self, api_key: str) -> TenantApiKeyModel | None:
+        return await self.get(
+            preload=[],
+            api_key=api_key,
+            status="0",
+            expired_at=("None", None),
+        )
+
+    async def create_crud(
+        self,
+        api_key: str,
+        api_secret: str,
+        tenant_id: int,
+        expired_at: datetime,
+        description: str | None = None,
+    ) -> TenantApiKeyModel:
+        data = {
+            "tenant_id": tenant_id,
+            "description": description,
+            "api_key": api_key,
+            "api_secret": api_secret,
+            "expired_at": expired_at,
+        }
+        obj = await self.create(data=data, skip_tenant_id=True)
+        return obj
+
+    async def update_status_crud(self, api_key_id: int, status: str) -> TenantApiKeyModel | None:
+        return await self.update(id=api_key_id, data={"status": status})
+
+    async def update_last_used_crud(self, api_key_id: int) -> None:
+        api_key_obj = await self.get_by_id_crud(api_key_id)
+        if api_key_obj:
+            api_key_obj.last_used_at = datetime.now()
+            await self.auth.db.flush()
+
+    async def delete_crud(self, api_key_id: int) -> bool:
+        try:
+            await self.delete(ids=[api_key_id])
+            return True
+        except Exception:
+            return False
+
+    async def get_list_crud(
+        self,
+        tenant_id: int | None = None,
+        status: str | None = None,
+        skip: int = 0,
+        limit: int = 100,
+    ) -> Sequence[TenantApiKeyModel]:
+        search: dict[str, Any] = {}
+        if tenant_id:
+            search["tenant_id"] = tenant_id
+        if status:
+            search["status"] = status
+        return await self.list(
+            search=search if search else None,
+            order_by=[{"created_time": "desc"}],
+        )
+
+
+class TenantApiLogCRUD(CRUDBase[TenantApiLogModel, TenantApiLogCreate, TenantApiLogCreate]):
+    """租户API调用日志数据层"""
+
+    def __init__(self, auth: AuthSchema) -> None:
+        self.auth = auth
+        super().__init__(model=TenantApiLogModel, auth=auth)
+
+    async def create_crud(
+        self,
+        api_key_id: int | None,
+        tenant_id: int,
+        endpoint: str,
+        method: str,
+        request_ip: str,
+        request_data: str | None,
+        response_code: int,
+        response_time: float,
+    ) -> TenantApiLogModel:
+        data = TenantApiLogCreate(
+            api_key_id=api_key_id,
+            tenant_id=tenant_id,
+            endpoint=endpoint,
+            method=method,
+            request_ip=request_ip,
+            request_data=request_data,
+            response_code=response_code,
+            response_time=response_time,
+        )
+        return await self.create(data=data, skip_tenant_id=True)
+
+    async def get_list_crud(
+        self,
+        tenant_id: int | None = None,
+        api_key_id: int | None = None,
+        endpoint: str | None = None,
+        skip: int = 0,
+        limit: int = 100,
+    ) -> Sequence[TenantApiLogModel]:
+        search: dict[str, Any] = {}
+        if tenant_id:
+            search["tenant_id"] = tenant_id
+        if api_key_id:
+            search["api_key_id"] = api_key_id
+        if endpoint:
+            search["endpoint"] = ("like", endpoint)
+        return await self.list(
+            search=search if search else None,
+            order_by=[{"created_time": "desc"}],
+        )

+ 100 - 0
backend/app/plugin/module_payment/apikey/model.py

@@ -0,0 +1,100 @@
+from datetime import datetime
+from typing import Optional
+
+from sqlalchemy import String, DateTime, ForeignKey, Text, Integer
+from sqlalchemy.orm import Mapped, mapped_column
+
+from app.core.base_model import ModelMixin, TenantMixin
+
+
+class TenantApiKeyModel(ModelMixin, TenantMixin):
+    """
+    租户API Key模型
+    """
+
+    __tablename__ = "sys_tenant_api_key"
+
+    # 核心字段
+    api_key: Mapped[str] = mapped_column(
+        String(256),
+        nullable=False,
+        unique=True,
+        comment="API Key",
+        index=True,
+    )
+    api_secret: Mapped[str] = mapped_column(
+        String(256),
+        nullable=False,
+        comment="API Secret",
+    )
+    expired_at: Mapped[Optional[datetime]] = mapped_column(
+        DateTime,
+        default=None,
+        nullable=True,
+        comment="过期时间",
+        index=True,
+    )
+    last_used_at: Mapped[Optional[datetime]] = mapped_column(
+        DateTime,
+        default=None,
+        nullable=True,
+        comment="最后使用时间",
+    )
+    # 关联字段
+    tenant_id: Mapped[int] = mapped_column(
+        Integer,
+        ForeignKey("sys_tenant.id", ondelete="CASCADE", onupdate="CASCADE"),
+        nullable=False,
+        index=True,
+        comment="租户ID",
+    )
+
+
+class TenantApiLogModel(ModelMixin, TenantMixin):
+    """
+    租户API调用日志模型
+    """
+
+    __tablename__ = "sys_tenant_api_log"
+
+    # 核心字段
+    api_key_id: Mapped[int] = mapped_column(
+        Integer,
+        nullable=True,
+        index=True,
+        comment="API Key ID",
+    )
+    endpoint: Mapped[str] = mapped_column(
+        String(256),
+        nullable=False,
+        comment="调用的接口",
+        index=True,
+    )
+    method: Mapped[str] = mapped_column(
+        String(10),
+        nullable=False,
+        comment="请求方法",
+    )
+    request_ip: Mapped[str] = mapped_column(
+        String(50),
+        nullable=False,
+        comment="请求IP",
+        index=True,
+    )
+    request_data: Mapped[Optional[str]] = mapped_column(
+        Text,
+        default=None,
+        nullable=True,
+        comment="请求数据(脱敏存储)",
+    )
+    response_code: Mapped[int] = mapped_column(
+        Integer,
+        nullable=False,
+        comment="响应码",
+        index=True,
+    )
+    response_time: Mapped[float] = mapped_column(
+        Integer,
+        nullable=False,
+        comment="响应时间(毫秒)",
+    )

+ 52 - 0
backend/app/plugin/module_payment/apikey/openapi_controller.py

@@ -0,0 +1,52 @@
+from typing import Annotated
+
+from agno.agent import AgentSession
+from fastapi import APIRouter, Depends, HTTPException
+from fastapi.responses import JSONResponse
+
+from app.core.dependencies import db_getter
+from app.plugin.module_payment.apikey.model import TenantApiKeyModel
+from app.common.response import ResponseSchema, SuccessResponse
+from app.core.logger import log
+from app.core.tenant_api_auth import TenantApiKeyAuth
+from app.plugin.module_payment.account.schema import AccountTransferSchema
+from app.plugin.module_payment.account.service import AccountService
+from app.api.v1.module_system.auth.schema import AuthSchema
+
+from sqlalchemy.ext.asyncio import AsyncSession
+
+
+OpenapiRouter = APIRouter(
+    prefix="/openapi",
+    tags=["开放接口"],
+)
+
+
+@OpenapiRouter.post(
+    "/account/transfer",
+    summary="租户资金专户转账",
+    description="从资金专户转账到支付宝账户/银行卡/资金专户(支持API Key认证)",
+    response_model=ResponseSchema[dict],
+)
+async def tenant_transfer_controller(
+    db: Annotated[AsyncSession, Depends(db_getter)],
+    data: AccountTransferSchema,
+    api_key: TenantApiKeyModel = Depends(TenantApiKeyAuth()),
+) -> JSONResponse:
+    """
+    租户资金专户转账接口
+
+    支持通过API Key进行认证,适用于平台租户调用
+    """
+    # 构建认证信息
+    auth = AuthSchema(
+        user=None,
+        check_data_scope=False,
+        tenant_id=api_key.tenant_id,
+        db=db,
+    )
+
+    # 执行转账
+    result = await AccountService.transfer_service(auth=auth, data=data)
+    log.info(f"租户资金专户转账发起成功: 企业: {data.enterprise_id}, 金额: {data.amount}")
+    return SuccessResponse(data=result, msg="")

+ 82 - 0
backend/app/plugin/module_payment/apikey/schema.py

@@ -0,0 +1,82 @@
+from datetime import datetime
+from typing import Optional
+
+from fastapi import Query
+from pydantic import BaseModel, Field
+
+
+class TenantApiKeyCreate(BaseModel):
+    """创建API Key的请求模型"""
+    tenant_id: Optional[int] = Field(None, description="租户ID,默认使用当前用户的租户ID")
+    description: Optional[str] = Field(None, description="备注")
+    expired_days: Optional[int] = Field(365, description="过期天数,默认365天")
+
+
+class TenantApiKeyResponse(BaseModel):
+    """API Key的响应模型"""
+    id: int
+    api_key: str
+    api_secret: str
+    status: str
+    expired_at: Optional[datetime]
+    created_time: datetime
+
+    class Config:
+        from_attributes = True
+
+
+class TenantApiKeyListResponse(BaseModel):
+    """API Key列表的响应模型"""
+    id: int
+    api_key: str
+    status: str
+    expired_at: Optional[datetime]
+    last_used_at: Optional[datetime]
+    created_time: datetime
+    description: Optional[str]
+
+    class Config:
+        from_attributes = True
+
+
+class TenantApiKeyUpdate(BaseModel):
+    """更新API Key的请求模型"""
+    status: str = Field(..., description="状态:0-正常,1-禁用")
+
+
+class TenantApiLogCreate(BaseModel):
+    """创建API调用日志的请求模型"""
+    api_key_id: Optional[int] = Field(None, description="API Key ID")
+    tenant_id: int = Field(..., description="租户ID")
+    endpoint: str = Field(..., description="调用的接口")
+    method: str = Field(..., description="请求方法")
+    request_ip: str = Field(..., description="请求IP")
+    request_data: Optional[str] = Field(None, description="请求数据")
+    response_code: int = Field(..., description="响应码")
+    response_time: float = Field(..., description="响应时间(毫秒)")
+
+
+class TenantApiLogResponse(BaseModel):
+    """API调用日志的响应模型"""
+    id: int
+    endpoint: str
+    method: str
+    request_ip: str
+    response_code: int
+    response_time: float
+    created_time: datetime
+
+    class Config:
+        from_attributes = True
+
+
+class ApiKeyQueryParam:
+    """API Key管理查询参数"""
+
+    def __init__(
+        self,
+        tenant_id: int | None = Query(None, description="租户ID,默认使用当前用户的租户ID"),
+        status: str | None = Query(None, description="状态:0-正常,1-禁用"),
+    ) -> None:
+        self.tenant_id = tenant_id
+        self.status = status

+ 121 - 0
backend/app/plugin/module_payment/apikey/service.py

@@ -0,0 +1,121 @@
+import hashlib
+import hmac
+import secrets
+import time
+from datetime import datetime, timedelta
+from typing import Optional
+
+from app.api.v1.module_system.auth.schema import AuthSchema
+from app.plugin.module_payment.apikey.crud import TenantApiKeyCRUD, TenantApiLogCRUD
+from app.plugin.module_payment.apikey.model import TenantApiKeyModel
+from app.plugin.module_payment.apikey.schema import TenantApiKeyListResponse
+
+
+class TenantApiKeyService:
+    """租户API Key服务"""
+
+    @staticmethod
+    def generate_api_key(tenant_id: int) -> tuple[str, str]:
+        random_part = secrets.token_hex(32)
+        timestamp = str(int(time.time()))
+        api_key = f"TENANT_{tenant_id}_{timestamp}_{random_part[:16]}"
+        api_secret = secrets.token_hex(64)
+        return api_key, api_secret
+
+    @staticmethod
+    def generate_signature(api_secret: str, request_data: dict) -> str:
+        sorted_data = sorted(request_data.items(), key=lambda x: x[0])
+        sign_str = "&".join([f"{k}={v}" for k, v in sorted_data])
+        signature = hmac.new(
+            api_secret.encode('utf-8'),
+            sign_str.encode('utf-8'),
+            hashlib.sha256
+        ).hexdigest()
+        return signature
+
+    @staticmethod
+    def verify_signature(api_secret: str, request_data: dict, signature: str) -> bool:
+        expected_signature = TenantApiKeyService.generate_signature(api_secret, request_data)
+        return expected_signature == signature
+
+    @staticmethod
+    async def create_api_key(
+        auth: AuthSchema,
+        tenant_id: int,
+        expired_days: Optional[int] = 365,
+        description: Optional[str] = None,
+    ) -> TenantApiKeyModel:
+        api_key, api_secret = TenantApiKeyService.generate_api_key(tenant_id)
+        return await TenantApiKeyCRUD(auth).create_crud(
+            api_key=api_key,
+            api_secret=api_secret,
+            tenant_id=tenant_id,
+            expired_at=datetime.now() + timedelta(days=expired_days or 365),
+            description=description,
+        )
+
+    @staticmethod
+    async def validate_api_key(auth: AuthSchema, api_key: str) -> Optional[TenantApiKeyModel]:
+        return await TenantApiKeyCRUD(auth).get_by_api_key(api_key)
+
+    @staticmethod
+    async def get_api_key_page_service(
+        auth: AuthSchema,
+        page_no: int,
+        page_size: int,
+        tenant_id: Optional[int] = None,
+        status: Optional[str] = None,
+    ) -> dict:
+        offset = (page_no - 1) * page_size
+        search = {}
+        if tenant_id:
+            search["tenant_id"] = tenant_id
+        if status:
+            search["status"] = status
+        return await TenantApiKeyCRUD(auth).page(
+            offset=offset,
+            limit=page_size,
+            order_by=[{"created_time": "desc"}],
+            search=search if search else {},
+            out_schema=TenantApiKeyListResponse,
+        )
+
+    @staticmethod
+    async def update_api_key_status(
+        auth: AuthSchema,
+        api_key_id: int,
+        status: str,
+    ) -> Optional[TenantApiKeyModel]:
+        return await TenantApiKeyCRUD(auth).update_status_crud(api_key_id=api_key_id, status=status)
+
+    @staticmethod
+    async def delete_api_key(auth: AuthSchema, api_key_id: int) -> bool:
+        return await TenantApiKeyCRUD(auth).delete_crud(api_key_id=api_key_id)
+
+    @staticmethod
+    async def log_api_call(
+        auth: AuthSchema,
+        api_key_id: Optional[int],
+        tenant_id: int,
+        endpoint: str,
+        method: str,
+        request_ip: str,
+        request_data: Optional[dict],
+        response_code: int,
+        start_time: float,
+    ) -> None:
+        response_time = (time.time() - start_time) * 1000
+        await TenantApiLogCRUD(auth).create_crud(
+            api_key_id=api_key_id,
+            tenant_id=tenant_id,
+            endpoint=endpoint,
+            method=method,
+            request_ip=request_ip,
+            request_data=str(request_data) if request_data else None,
+            response_code=response_code,
+            response_time=response_time,
+        )
+
+    @staticmethod
+    async def update_last_used(auth: AuthSchema, api_key_id: int) -> None:
+        await TenantApiKeyCRUD(auth).update_last_used_crud(api_key_id)

+ 1 - 1
backend/app/plugin/module_payment/notification/controller.py

@@ -66,4 +66,4 @@ async def alipay_notify_controller(
     },
 )
 async def health_controller() -> PlainTextResponse:
-    return PlainTextResponse(content="ok")
+    return PlainTextResponse(content="ok alipay")

+ 31 - 35
docker-compose.yml

@@ -2,51 +2,47 @@ version: '3.8'
 
 services:
   backend:
+    container_name: backend
     build:
       context: ./backend
-    ports:
-      - "8000:8000"
+      dockerfile: ./Dockerfile
+    image: backend:latest
+    restart: always
     environment:
-      - DATABASE_URL=postgresql://admin:password@database:5432/example_db
-      - REDIS_URL=redis://redis:6379/0
-    depends_on:
-      - database
-      - redis
+      TZ: "Asia/Shanghai"
+      # DATABASE_URL: ${DATABASE_URL:-postgresql+asyncpg://user:password@postgres:5432/payment}
+      # REDIS_URL: ${REDIS_URL:-redis://redis:6379/0}
+      # ALIPAY_APP_ID: ${ALIPAY_APP_ID:-""}
+      # ALIPAY_PRIVATE_KEY: ${ALIPAY_PRIVATE_KEY:-""}
+      # ALIPAY_PUBLIC_KEY: ${ALIPAY_PUBLIC_KEY:-""}
+    ports:
+      - "8001:8001"
+    volumes:
+      - ./backend:/app
     networks:
-      - app-network
+      - app-tier
+    healthcheck:
+      test: ["CMD", "curl", "-f", "http://localhost:8001/api/v1/payment/notify/health"]
+      interval: 30s
+      timeout: 10s
+      retries: 3
+      start_period: 60s
 
   frontend:
+    container_name: frontend
     build:
       context: ./frontend
-    ports:
-      - "80:80"
-    depends_on:
-      - backend
-    networks:
-      - app-network
-
-  database:
-    image: postgres:13
+      dockerfile: ./Dockerfile
+    image: frontend:latest
+    restart: always
     environment:
-      - POSTGRES_USER=admin
-      - POSTGRES_PASSWORD=password
-      - POSTGRES_DB=example_db
-    volumes:
-      - postgres_data:/var/lib/postgresql/data
-    networks:
-      - app-network
-
-  redis:
-    image: redis:6
-    volumes:
-      - redis_data:/data
+      TZ: "Asia/Shanghai"
+      VITE_API_BASE_URL: ${VITE_API_BASE_URL:-http://localhost:8001}
+    ports:
+      - "5173:80"
     networks:
-      - app-network
+      - app-tier
 
 networks:
-  app-network:
+  app-tier:
     driver: bridge
-
-volumes:
-  postgres_data:
-  redis_data:

+ 79 - 0
frontend/src/api/module_payment/apikey/index.ts

@@ -0,0 +1,79 @@
+import request from "@/utils/request";
+
+export interface ApiKeyCreateForm {
+  tenant_id?: number;
+  expired_days: number;
+  description?: string;
+}
+
+export interface ApiKeyUpdateForm {
+  status: string;
+}
+
+export interface ApiKeyQueryParam {
+  tenant_id?: number;
+  status?: string;
+}
+
+export interface ApiKeyResponse {
+  id: number;
+  api_key: string;
+  api_secret?: string;
+  status: string;
+  expired_at: string;
+  created_time: string;
+  last_used_at?: string;
+  description?: string;
+}
+
+export interface ApiKeyTable {
+  id: number;
+  api_key: string;
+  status: string;
+  expired_at: string;
+  last_used_at?: string;
+  created_time: string;
+  description?: string;
+}
+
+export interface ApiKeyListResponse {
+  items: ApiKeyTable[];
+  total: number;
+  page: number;
+  page_size: number;
+}
+
+const ApiKeyAPI = {
+  createApiKey: (data: ApiKeyCreateForm) => {
+    return request({
+      url: "/payment/api-key",
+      method: "POST",
+      data,
+    });
+  },
+
+  listApiKey: (params: { page_no: number; page_size: number; tenant_id?: number; status?: string }) => {
+    return request({
+      url: "/payment/api-key/list",
+      method: "GET",
+      params,
+    });
+  },
+
+  updateApiKeyStatus: (id: number, data: ApiKeyUpdateForm) => {
+    return request({
+      url: `/payment/api-key/${id}`,
+      method: "PUT",
+      data,
+    });
+  },
+
+  deleteApiKey: (id: number) => {
+    return request({
+      url: `/payment/api-key/${id}`,
+      method: "DELETE",
+    });
+  },
+};
+
+export default ApiKeyAPI;

+ 692 - 0
frontend/src/views/module_payment/apikey/index.vue

@@ -0,0 +1,692 @@
+<!-- API Key管理 -->
+<template>
+  <div class="app-container">
+    <el-tabs v-model="activeTab" type="card">
+      <!-- API Key管理 -->
+      <el-tab-pane label="API Key管理" name="management">
+        <PageSearch
+          ref="searchRef"
+          :search-config="searchConfig"
+          @query-click="handleQueryClick"
+          @reset-click="handleResetClick"
+        />
+
+        <PageContent ref="contentRef" :content-config="contentConfig">
+          <template #toolbar="{ toolbarRight, onToolbar, removeIds, cols }">
+            <CrudToolbarLeft
+              :remove-ids="removeIds"
+              :perm-create="['module_system:tenant:api-key:create']"
+              :perm-delete="['module_system:tenant:api-key:delete']"
+              @add="handleOpenDialog('create')"
+              @delete="onToolbar('delete')"
+            />
+            <div class="data-table__toolbar--right">
+              <CrudToolbarRight :buttons="toolbarRight" :cols="cols" :on-toolbar="onToolbar" />
+            </div>
+          </template>
+
+          <template #table="{ data, loading, tableRef, onSelectionChange, pagination }">
+            <div class="data-table__content">
+              <el-table
+                :ref="tableRef as any"
+                v-loading="loading"
+                row-key="id"
+                :data="data"
+                height="100%"
+                border
+                stripe
+                @selection-change="onSelectionChange"
+              >
+                <template #empty>
+                  <el-empty :image-size="80" description="暂无数据" />
+                </template>
+                <el-table-column
+                  v-if="contentCols.find((col) => col.prop === 'selection')?.show"
+                  type="selection"
+                  min-width="55"
+                  align="center"
+                />
+                <el-table-column
+                  v-if="contentCols.find((col) => col.prop === 'index')?.show"
+                  fixed
+                  label="序号"
+                  min-width="60"
+                >
+                  <template #default="scope">
+                    {{ (pagination.currentPage - 1) * pagination.pageSize + scope.$index + 1 }}
+                  </template>
+                </el-table-column>
+                <el-table-column
+                  v-if="contentCols.find((col) => col.prop === 'api_key')?.show"
+                  key="api_key"
+                  label="API Key"
+                  prop="api_key"
+                  min-width="300"
+                  show-overflow-tooltip
+                >
+                  <template #default="scope">
+                    <div class="api-key-container">
+                      <span>{{ scope.row.api_key }}</span>
+                      <el-button
+                        type="text"
+                        size="small"
+                        @click="copyToClipboard(scope.row.api_key)"
+                      >
+                        复制
+                      </el-button>
+                    </div>
+                  </template>
+                </el-table-column>
+                <el-table-column
+                  v-if="contentCols.find((col) => col.prop === 'status')?.show"
+                  key="status"
+                  label="状态"
+                  prop="status"
+                  min-width="80"
+                  align="center"
+                >
+                  <template #default="scope">
+                    <el-tag :type="scope.row.status === '0' ? 'success' : 'danger'">
+                      {{ scope.row.status === "0" ? "正常" : "禁用" }}
+                    </el-tag>
+                  </template>
+                </el-table-column>
+                <el-table-column
+                  v-if="contentCols.find((col) => col.prop === 'expired_at')?.show"
+                  key="expired_at"
+                  label="过期时间"
+                  prop="expired_at"
+                  min-width="180"
+                  show-overflow-tooltip
+                />
+                <!-- <el-table-column
+                  v-if="contentCols.find((col) => col.prop === 'last_used_at')?.show"
+                  key="last_used_at"
+                  label="最后使用时间"
+                  prop="last_used_at"
+                  min-width="180"
+                  show-overflow-tooltip
+                >
+                  <template #default="scope">
+                    {{ scope.row.last_used_at || "未使用" }}
+                  </template>
+                </el-table-column> -->
+                <el-table-column
+                  v-if="contentCols.find((col) => col.prop === 'description')?.show"
+                  key="description"
+                  label="描述"
+                  prop="description"
+                  min-width="150"
+                  show-overflow-tooltip
+                />
+                <el-table-column
+                  v-if="contentCols.find((col) => col.prop === 'created_time')?.show"
+                  key="created_time"
+                  label="创建时间"
+                  prop="created_time"
+                  min-width="180"
+                  show-overflow-tooltip
+                />
+                <el-table-column
+                  v-if="contentCols.find((col) => col.prop === 'operation')?.show"
+                  fixed="right"
+                  label="操作"
+                  align="center"
+                  min-width="200"
+                >
+                  <template #default="scope">
+                    <el-button
+                      v-hasPerm="['module_system:tenant:api-key:update']"
+                      type="primary"
+                      size="small"
+                      link
+                      @click="handleUpdateStatus(scope.row.id, scope.row.status === '0' ? '1' : '0')"
+                    >
+                      {{ scope.row.status === '0' ? '禁用' : '启用' }}
+                    </el-button>
+                    <el-button
+                      v-hasPerm="['module_system:tenant:api-key:delete']"
+                      type="danger"
+                      size="small"
+                      link
+                      @click="handleRowDelete(scope.row.id)"
+                    >
+                      删除
+                    </el-button>
+                  </template>
+                </el-table-column>
+              </el-table>
+            </div>
+          </template>
+        </PageContent>
+
+        <EnhancedDialog
+          v-model="dialogVisible.visible"
+          :title="dialogVisible.title"
+          @close="handleCloseDialog"
+          width="500"
+        >
+          <el-form
+            ref="dataFormRef"
+            :model="formData"
+            :rules="rules"
+            label-suffix=":"
+            label-width="auto"
+            label-position="right"
+          >
+            <!-- <el-form-item label="租户ID" prop="tenant_id">
+              <el-input v-model="formData.tenant_id" placeholder="可选,默认使用当前租户" :maxlength="20" />
+            </el-form-item> -->
+            <el-form-item label="过期天数" prop="expired_days">
+              <el-input v-model.number="formData.expired_days" type="number" placeholder="请输入过期天数" min="1" :maxlength="4" />
+            </el-form-item>
+            <el-form-item label="描述" prop="description">
+              <el-input
+                v-model="formData.description"
+                type="textarea"
+                :rows="3"
+                placeholder="请输入描述"
+                :maxlength="255"
+              />
+            </el-form-item>
+          </el-form>
+
+          <template #footer>
+            <div class="dialog-footer">
+              <el-button @click="handleCloseDialog">取消</el-button>
+              <el-button
+                v-hasPerm="['module_system:tenant:api-key:create']"
+                type="primary"
+                :loading="submitLoading"
+                @click="handleSubmit"
+              >
+                确定
+              </el-button>
+            </div>
+          </template>
+        </EnhancedDialog>
+
+        <EnhancedDialog
+          v-model="apiKeyDetailVisible"
+          title="API Key详情"
+          width="600"
+          :close-on-press-escape="false"
+          :close-on-click-modal="false"
+          @close="handleCloseApiKeyDetail"
+        >
+          <el-alert
+            title="请及时保存API Key和Secret,关闭后无法再次查看"
+            type="warning"
+            :closable="false"
+            show-icon
+            style="margin-bottom: 16px"
+          />
+          <el-descriptions :column="1" border>
+            <el-descriptions-item label="API Key">
+              <div class="api-key-detail">
+                <span>{{ apiKeyDetail.api_key }}</span>
+                <el-button type="text" size="small" @click="copyToClipboard(apiKeyDetail.api_key)">
+                  复制
+                </el-button>
+              </div>
+            </el-descriptions-item>
+            <el-descriptions-item label="API Secret">
+              <div class="api-key-detail">
+                <span>{{ apiKeyDetail.api_secret }}</span>
+                <el-button type="text" size="small" @click="copyToClipboard(apiKeyDetail.api_secret)">
+                  复制
+                </el-button>
+              </div>
+            </el-descriptions-item>
+            <el-descriptions-item label="状态">
+              <el-tag :type="apiKeyDetail.status === '0' ? 'success' : 'danger'">
+                {{ apiKeyDetail.status === "0" ? "正常" : "禁用" }}
+              </el-tag>
+            </el-descriptions-item>
+            <el-descriptions-item label="过期时间">
+              {{ apiKeyDetail.expired_at }}
+            </el-descriptions-item>
+            <el-descriptions-item label="创建时间">
+              {{ apiKeyDetail.created_time }}
+            </el-descriptions-item>
+            <el-descriptions-item label="描述">
+              {{ apiKeyDetail.description || "无" }}
+            </el-descriptions-item>
+          </el-descriptions>
+
+          <template #footer>
+            <div class="dialog-footer">
+              <el-button @click="downloadApiKeyCsv">下载CSV</el-button>
+              <el-button type="primary" @click="handleConfirmApiKeyDetail">确认已保存</el-button>
+            </div>
+          </template>
+        </EnhancedDialog>
+      </el-tab-pane>
+
+      <!-- 接入文档 -->
+      <el-tab-pane label="接入文档" name="docs">
+        <div class="docs-container">
+          <el-card>
+            <template #header>
+              <div class="card-header">
+                <span>API Key认证使用说明</span>
+              </div>
+            </template>
+            <div class="docs-content">
+              <h2>1. 认证方式</h2>
+              <p>使用API Key进行认证时,需要在请求头中添加以下信息:</p>
+              <pre><code>Authorization: ApiKey {api_key}:{signature}</code></pre>
+              <p>其中:</p>
+              <ul>
+                <li><strong>api_key</strong>:从管理界面获取的API Key</li>
+                <li><strong>signature</strong>:可选,请求签名(详见签名验证部分)</li>
+              </ul>
+
+              <h2>2. 签名验证</h2>
+              <p>为了增强安全性,建议在请求中添加签名。签名生成步骤:</p>
+              <ol>
+                <li>将请求数据(JSON格式)按参数名升序排序</li>
+                <li>将排序后的参数拼接为字符串:<code>key1=value1&key2=value2</code></li>
+                <li>使用API Secret作为密钥,通过HMAC-SHA256算法生成签名</li>
+                <li>将签名添加到Authorization头中</li>
+              </ol>
+
+              <h2>3. 注意事项</h2>
+              <ul>
+                <li>API Key和Secret请妥善保管,不要泄露给他人</li>
+                <li>定期更新API Key,建议每3-6个月更换一次</li>
+                <li>如发现API Key泄露,请立即禁用并重新生成</li>
+                <li>签名验证可选,但建议在生产环境中使用</li>
+                <li>API Key有过期时间,请在过期前及时更新</li>
+              </ul>
+
+              <h2>4. cURL示例</h2>
+              <h3>4.1 租户转账接口</h3>
+              <pre><code># 基础认证(不带签名)
+curl -X POST 'https://api.example.com/payment/openapi/account/transfer' \
+  -H 'Authorization: ApiKey your_api_key' \
+  -H 'Content-Type: application/json' \
+  -d '{
+    "account_book_id": "资金账号",
+    "amount": 100.00,
+    "order_title": "转账标题",
+    "payee_info": {
+      "identity_type": "ALIPAY_ACCOUNT",
+      "name": "收款人姓名",
+      "identity": "收款人支付宝账号"
+    }
+  }'
+
+# 带签名认证
+curl -X POST 'https://api.example.com/payment/openapi/account/transfer' \
+  -H 'Authorization: ApiKey your_api_key:your_signature' \
+  -H 'Content-Type: application/json' \
+  -d '{
+    "account_book_id": "资金账号",
+    "amount": 100.00,
+    "order_title": "转账标题",
+    "payee_info": {
+      "identity_type": "ALIPAY_ACCOUNT",
+      "name": "收款人姓名",
+      "identity": "收款人支付宝账号"
+    }
+  }'</code></pre>
+
+              <h3>4.2 签名计算方式</h3>
+              <p>签名是对请求体对象按参数名升序排序后拼接成字符串,再进行HMAC-SHA256计算:</p>
+              <pre><code># Python签名示例
+import hashlib
+import hmac
+
+def calculate_signature(api_secret, request_data):
+    \"\"\"
+    对请求体字典进行签名
+    request_data: dict 请求体数据
+    \"\"\"
+    # 按参数名升序排序
+    sorted_data = sorted(request_data.items(), key=lambda x: x[0])
+    # 拼接为 key1=value1&key2=value2 格式
+    sign_str = "&".join([f"{k}={v}" for k, v in sorted_data])
+    # HMAC-SHA256签名
+    signature = hmac.new(
+        api_secret.encode('utf-8'),
+        sign_str.encode('utf-8'),
+        hashlib.sha256
+    ).hexdigest()
+    return signature
+
+# 示例
+api_secret = "your_api_secret"
+request_data = {
+    "account_book_id": "资金账号",
+    "amount": 100.00
+}
+signature = calculate_signature(api_secret, request_data)
+# Authorization: ApiKey your_api_key:signature</code></pre>
+
+              <h3>4.3 注意事项</h3>
+              <ul>
+                <li>签名使用HMAC-SHA256算法,密钥为API Secret</li>
+                <li>签名对象是请求体字典排序后的键值对字符串(key1=value1&amp;key2=value2)</li>
+                <li>注意是字典排序后拼接,不是JSON字符串</li>
+              </ul>
+            </div>
+          </el-card>
+        </div>
+      </el-tab-pane>
+    </el-tabs>
+  </div>
+</template>
+
+<script setup lang="ts">
+import { ref, reactive } from "vue";
+import ApiKeyAPI, {
+  ApiKeyCreateForm,
+  ApiKeyUpdateForm,
+  ApiKeyPageQuery,
+  ApiKeyResponse,
+  ApiKeyTable,
+} from "@/api/module_payment/apikey";
+import CrudToolbarLeft from "@/components/CURD/CrudToolbarLeft.vue";
+import CrudToolbarRight from "@/components/CURD/CrudToolbarRight.vue";
+import PageSearch from "@/components/CURD/PageSearch.vue";
+import PageContent from "@/components/CURD/PageContent.vue";
+import EnhancedDialog from "@/components/CURD/EnhancedDialog.vue";
+import { useCrudList } from "@/components/CURD/useCrudList";
+import type { IContentConfig, ISearchConfig } from "@/components/CURD/types";
+import { ElMessage } from "element-plus";
+
+defineOptions({
+  name: "ApiKey",
+  inheritAttrs: false,
+});
+
+const activeTab = ref("management");
+const { searchRef, contentRef, handleQueryClick, handleResetClick, refreshList } = useCrudList();
+const dataFormRef = ref();
+const submitLoading = ref(false);
+const apiKeyDetailVisible = ref(false);
+
+const apiKeyDetail = reactive<ApiKeyResponse>({
+  id: 0,
+  api_key: "",
+  api_secret: "",
+  status: "0",
+  expired_at: "",
+  created_time: "",
+});
+
+const searchConfig = reactive<ISearchConfig>({
+  permPrefix: "module_system:tenant:api-key",
+  colon: true,
+  isExpandable: true,
+  showNumber: 2,
+  form: { labelWidth: "auto" },
+  formItems: [
+    // {
+    //   prop: "tenant_id",
+    //   label: "租户ID",
+    //   type: "input",
+    //   attrs: { placeholder: "请输入租户ID", clearable: true },
+    // },
+    {
+      prop: "status",
+      label: "状态",
+      type: "select",
+      attrs: {
+        placeholder: "请选择状态",
+        clearable: true,
+        options: [
+          { label: "正常", value: "0" },
+          { label: "禁用", value: "1" },
+        ],
+        style: { width: "167.5px" },
+      },
+    },
+  ],
+});
+
+const contentCols = reactive<
+  Array<{
+    prop?: string;
+    label?: string;
+    show?: boolean;
+  }>
+>([
+  { prop: "selection", label: "选择框", show: true },
+  { prop: "index", label: "序号", show: true },
+  { prop: "api_key", label: "API Key", show: true },
+  { prop: "status", label: "状态", show: true },
+  { prop: "expired_at", label: "过期时间", show: true },
+  { prop: "last_used_at", label: "最后使用时间", show: true },
+  { prop: "description", label: "描述", show: true },
+  { prop: "created_time", label: "创建时间", show: true },
+  { prop: "operation", label: "操作", show: true },
+]);
+
+const contentConfig = reactive<IContentConfig<ApiKeyPageQuery>>({
+  permPrefix: "module_system:tenant:api-key",
+  pk: "id",
+  cols: contentCols as IContentConfig["cols"],
+  hideColumnFilter: false,
+  toolbar: [],
+  defaultToolbar: [{ name: "refresh", perm: "refresh" }, "filter"],
+  pagination: {
+    pageSize: 10,
+    pageSizes: [10, 20, 30, 50],
+  },
+  request: { page_no: "page", page_size: "page_size" },
+  indexAction: async (params) => {
+    const res = await ApiKeyAPI.listApiKey(params as ApiKeyPageQuery);
+    return {
+      total: res.data.data.total,
+      list: res.data.data.items,
+    };
+  },
+  deleteAction: async (ids) => {
+    const idList = ids
+      .split(",")
+      .map((s) => Number(s.trim()))
+      .filter((n) => !Number.isNaN(n));
+    for (const id of idList) {
+      await ApiKeyAPI.deleteApiKey(id);
+    }
+  },
+  deleteConfirm: {
+    title: "警告",
+    message: "确认删除该项数据?",
+    type: "warning",
+  },
+});
+
+const formData = reactive<ApiKeyCreateForm>({
+  tenant_id: undefined,
+  expired_days: 365,
+  description: "",
+});
+
+const dialogVisible = reactive({
+  title: "",
+  visible: false,
+  type: "create" as "create",
+});
+
+const rules = reactive({
+  expired_days: [{ required: true, message: "请输入过期天数", trigger: "blur" }],
+});
+
+const initialFormData: ApiKeyCreateForm = {
+  tenant_id: undefined,
+  expired_days: 365,
+  description: "",
+};
+
+function handleRowDelete(id: number) {
+  contentRef.value?.handleDelete(id);
+}
+
+async function resetForm() {
+  if (dataFormRef.value) {
+    dataFormRef.value.resetFields();
+    dataFormRef.value.clearValidate();
+  }
+  Object.assign(formData, initialFormData);
+}
+
+async function handleCloseDialog() {
+  dialogVisible.visible = false;
+  await resetForm();
+}
+
+async function handleOpenDialog(type: "create") {
+  dialogVisible.type = type;
+  dialogVisible.title = "创建API Key";
+  dialogVisible.visible = true;
+}
+
+async function handleSubmit() {
+  dataFormRef.value.validate(async (valid: boolean) => {
+    if (valid) {
+      submitLoading.value = true;
+      try {
+        const response = await ApiKeyAPI.createApiKey(formData);
+        Object.assign(apiKeyDetail, response.data.data);
+        apiKeyDetailVisible.value = true;
+        dialogVisible.visible = false;
+        await resetForm();
+        refreshList();
+      } catch (error: unknown) {
+        console.error(error);
+      } finally {
+        submitLoading.value = false;
+      }
+    }
+  });
+}
+
+async function handleUpdateStatus(id: number, status: string) {
+  try {
+    await ApiKeyAPI.updateApiKeyStatus(id, { status });
+    ElMessage.success(`API Key已${status === '0' ? '启用' : '禁用'}`);
+    refreshList();
+  } catch (error: unknown) {
+    console.error(error);
+    ElMessage.error('操作失败');
+  }
+}
+
+function copyToClipboard(text: string) {
+  navigator.clipboard.writeText(text).then(() => {
+    ElMessage.success('复制成功');
+  }).catch(() => {
+    ElMessage.error('复制失败');
+  });
+}
+
+function handleCloseApiKeyDetail() {
+  ElMessage.warning('请务必已保存API Key和Secret,关闭后将无法再次查看');
+  apiKeyDetailVisible.value = false;
+}
+
+function handleConfirmApiKeyDetail() {
+  ElMessage.success('已确认保存');
+  apiKeyDetailVisible.value = false;
+}
+
+function downloadApiKeyCsv() {
+  const csvContent = `API Key,API Secret,状态,过期时间,创建时间,描述\n"${apiKeyDetail.api_key}","${apiKeyDetail.api_secret}","${apiKeyDetail.status === '0' ? '正常' : '禁用'}","${apiKeyDetail.expired_at}","${apiKeyDetail.created_time}","${apiKeyDetail.description || ''}"`;
+  const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' });
+  const link = document.createElement('a');
+  const url = URL.createObjectURL(blob);
+  link.setAttribute('href', url);
+  link.setAttribute('download', `api-key-${apiKeyDetail.id || Date.now()}.csv`);
+  link.style.visibility = 'hidden';
+  document.body.appendChild(link);
+  link.click();
+  document.body.removeChild(link);
+  ElMessage.success('CSV已下载');
+}
+</script>
+
+<style scoped>
+.app-container {
+  padding: 20px;
+}
+
+.api-key-container {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  word-break: break-all;
+}
+
+.api-key-detail {
+  display: flex;
+  align-items: flex-start;
+  justify-content: space-between;
+  flex-wrap: wrap;
+  word-break: break-all;
+  gap: 8px;
+}
+
+.api-key-detail span {
+  word-break: break-all;
+  flex: 1;
+  min-width: 0;
+}
+
+.docs-container {
+  padding: 20px 0;
+}
+
+.card-header {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+}
+
+.docs-content {
+  line-height: 1.6;
+}
+
+.docs-content h2 {
+  margin-top: 30px;
+  margin-bottom: 15px;
+  font-size: 18px;
+  font-weight: bold;
+}
+
+.docs-content h3 {
+  margin-top: 20px;
+  margin-bottom: 10px;
+  font-size: 16px;
+  font-weight: bold;
+}
+
+.docs-content p {
+  margin-bottom: 10px;
+}
+
+.docs-content ul,
+.docs-content ol {
+  margin-left: 20px;
+  margin-bottom: 15px;
+}
+
+.docs-content li {
+  margin-bottom: 5px;
+}
+
+.docs-content pre {
+  background-color: #f5f5f5;
+  padding: 15px;
+  border-radius: 4px;
+  overflow-x: auto;
+  margin-bottom: 15px;
+}
+
+.docs-content code {
+  font-family: 'Courier New', Courier, monospace;
+  font-size: 14px;
+}
+</style>