From d8bfebdcfb019d5575ad18b4dd224f3d59c76588 Mon Sep 17 00:00:00 2001 From: 18610128193 <10574456+chenweiqiang-123@user.noreply.gitee.com> Date: Wed, 10 Jun 2026 10:39:37 +0800 Subject: [PATCH 01/68] =?UTF-8?q?=E6=96=B0=E5=A2=9E=E7=94=A8=E6=88=B7?= =?UTF-8?q?=E5=BA=94=E7=94=A8=E7=AE=A1=E7=90=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- video-gen-api/app/api/v1/__init__.py | 4 + video-gen-api/app/api/v1/user_oauth_app.py | 96 ++++++++++++++ video-gen-api/app/models/__init__.py | 4 + video-gen-api/app/models/user_oauth_app.py | 27 ++++ video-gen-api/app/schemas/user_oauth_app.py | 38 ++++++ .../app/services/user_oauth_app_service.py | 123 ++++++++++++++++++ 6 files changed, 292 insertions(+) create mode 100644 video-gen-api/app/api/v1/user_oauth_app.py create mode 100644 video-gen-api/app/models/user_oauth_app.py create mode 100644 video-gen-api/app/schemas/user_oauth_app.py create mode 100644 video-gen-api/app/services/user_oauth_app_service.py diff --git a/video-gen-api/app/api/v1/__init__.py b/video-gen-api/app/api/v1/__init__.py index 3aa88f8b..cdbe67ce 100644 --- a/video-gen-api/app/api/v1/__init__.py +++ b/video-gen-api/app/api/v1/__init__.py @@ -16,6 +16,8 @@ from app.api.v1.video_engines import router as video_engines_router from app.api.v1.image_engines import router as image_engines_router from app.api.v1.generation_ai import router as generation_ai_router from app.api.v1.test import router as test_router +from app.api.v1.user_oauth import router as user_oauth_router +from app.api.v1.user_oauth_app import router as user_oauth_app_router api_router = APIRouter() api_router.include_router(auth_router) @@ -34,3 +36,5 @@ api_router.include_router(video_engines_router) api_router.include_router(image_engines_router) api_router.include_router(generation_ai_router) api_router.include_router(test_router) +api_router.include_router(user_oauth_router) +api_router.include_router(user_oauth_app_router) diff --git a/video-gen-api/app/api/v1/user_oauth_app.py b/video-gen-api/app/api/v1/user_oauth_app.py new file mode 100644 index 00000000..73f745a3 --- /dev/null +++ b/video-gen-api/app/api/v1/user_oauth_app.py @@ -0,0 +1,96 @@ +from fastapi import APIRouter, Depends, HTTPException, Query, status +from sqlalchemy.ext.asyncio import AsyncSession + +from app.dependencies import get_admin_user, get_db +from app.models.user import User +from app.schemas.user_oauth_app import UserOAuthAppCreate, UserOAuthAppOut, UserOAuthAppUpdate +from app.services.user_oauth_app_service import ( + create_user_oauth_app, + delete_user_oauth_app, + get_user_oauth_app_by_id, + list_user_oauth_apps, + update_user_oauth_app, +) + +router = APIRouter(prefix="/user-oauth-apps", tags=["oauth"]) + + +@router.get("/list", summary="获取用户授权应用列表") +async def list_apps( + page: int = Query(1, ge=1), + page_size: int = Query(20, ge=1, le=100), + open_type: int | None = Query(None, ge=1, le=10, description="开户方式"), + status: int | None = Query(None, ge=1, le=2, description="应用状态,1=正常,2=禁用"), + admin: User = Depends(get_admin_user), + db: AsyncSession = Depends(get_db), + app_id: str | None = Query(None, max_length=255, description="应用id"), +): + result = await list_user_oauth_apps(db, page, page_size, open_type, status, admin.id, app_id) + return { + "total": result["total"], + "page": result["page"], + "page_size": result["page_size"], + "items": [UserOAuthAppOut.model_validate(item) for item in result["items"]], + } + + +@router.post("/create", summary="创建用户授权应用", response_model=UserOAuthAppOut, status_code=status.HTTP_201_CREATED) +async def create_app( + req: UserOAuthAppCreate, + admin: User = Depends(get_admin_user), + db: AsyncSession = Depends(get_db), +): + try: + app = await create_user_oauth_app(db, req.app_id, req.secret, req.open_type, admin.id) + return app + except ValueError as e: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=str(e), + ) + + +@router.get("/read/{id}", summary="获取用户授权应用详情", response_model=UserOAuthAppOut) +async def get_app( + id: str, + admin: User = Depends(get_admin_user), + db: AsyncSession = Depends(get_db), +): + app = await get_user_oauth_app_by_id(db, id) + if not app: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="应用不存在", + ) + return app + + +@router.post("/update/{id}", summary="更新用户授权应用", response_model=UserOAuthAppOut) +async def update_app( + id: str, + req: UserOAuthAppUpdate, + admin: User = Depends(get_admin_user), + db: AsyncSession = Depends(get_db), +): + app = await update_user_oauth_app(db, id, req.secret, req.open_type, req.status, admin.id) + if not app: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="应用不存在", + ) + return app + + +@router.get("/delete/{id}", summary="删除用户授权应用") +async def delete_app( + id: str, + admin: User = Depends(get_admin_user), + db: AsyncSession = Depends(get_db), +): + success = await delete_user_oauth_app(db, id, admin.id) + if not success: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="应用不存在", + ) + return {"message": "删除成功"} \ No newline at end of file diff --git a/video-gen-api/app/models/__init__.py b/video-gen-api/app/models/__init__.py index e26d99f1..0e056fc3 100644 --- a/video-gen-api/app/models/__init__.py +++ b/video-gen-api/app/models/__init__.py @@ -21,6 +21,9 @@ from app.models.chat_provider_call_log import ChatProviderCallLog from app.models.generated_resource import GeneratedResource from app.models.user_resource_month_stat import UserResourceMonthStat from app.models.user_resource_total_stat import UserResourceTotalStat +from app.models.user_oauth import UserOAuth +from app.models.user_oauth_account import UserOAuthAccount +from app.models.user_oauth_app import UserOAuthApp __all__ = [ "Base", "TimestampMixin", "SoftDeleteMixin", "engine", "async_session", @@ -31,4 +34,5 @@ __all__ = [ "MenuConfig", "RechargePackage", "OperationLog", "ChatGenerationTask", "ChatGenerationTaskEvent", "ChatProviderCallLog", "GeneratedResource", "UserResourceMonthStat", "UserResourceTotalStat", + "UserOAuth", "UserOAuthAccount", "UserOAuthApp", ] diff --git a/video-gen-api/app/models/user_oauth_app.py b/video-gen-api/app/models/user_oauth_app.py new file mode 100644 index 00000000..f2246144 --- /dev/null +++ b/video-gen-api/app/models/user_oauth_app.py @@ -0,0 +1,27 @@ +from sqlalchemy import BigInteger, ForeignKey, String +from sqlalchemy.orm import Mapped, mapped_column + +from app.models.base import Base, TimestampMixin, SoftDeleteMixin + + +class UserOAuthApp(Base, TimestampMixin, SoftDeleteMixin): + __tablename__ = "user_oauth_app" + + id: Mapped[str] = mapped_column( + String(32), primary_key=True, comment="主键" + ) + app_id: Mapped[str] = mapped_column( + String(64), unique=True, nullable=False, index=True, comment="应用id" + ) + secret: Mapped[str] = mapped_column( + String(256), nullable=False, comment="应用密钥" + ) + status: Mapped[int] = mapped_column( + BigInteger, nullable=False, default=1, comment="状态,1=正常,2=禁用" + ) + open_type: Mapped[int] = mapped_column( + BigInteger, nullable=False, index=True, comment="开户方式(1=千川,2=广告,3=本地推,4=星图,5=快手代理商,6=巨量星图,7=巨量服务单,8=腾讯服务单,9=腾讯营销K2,10=腾讯营销K3)" + ) + create_by: Mapped[str | None] = mapped_column( + String(32), ForeignKey("users.id", ondelete="SET NULL"), nullable=True, comment="创建者" + ) \ No newline at end of file diff --git a/video-gen-api/app/schemas/user_oauth_app.py b/video-gen-api/app/schemas/user_oauth_app.py new file mode 100644 index 00000000..534eddba --- /dev/null +++ b/video-gen-api/app/schemas/user_oauth_app.py @@ -0,0 +1,38 @@ +from pydantic import BaseModel, Field + +from app.schemas.common import NaiveDatetime + + +class UserOAuthAppCreate(BaseModel): + app_id: str = Field(..., max_length=64, description="应用id") + secret: str = Field(..., max_length=256, description="应用密钥") + open_type: int = Field( + ..., + ge=1, + le=10, + description="开户方式(1=千川,2=广告,3=本地推,4=星图,5=快手代理商,6=巨量星图,7=巨量服务单,8=腾讯服务单,9=腾讯营销K2,10=腾讯营销K3)", + ) + + +class UserOAuthAppUpdate(BaseModel): + secret: str | None = Field(None, max_length=256, description="应用密钥") + open_type: int | None = Field( + None, + ge=1, + le=10, + description="开户方式(1=千川,2=广告,3=本地推,4=星图,5=快手代理商,6=巨量星图,7=巨量服务单,8=腾讯服务单,9=腾讯营销K2,10=腾讯营销K3)", + ) + status: int | None = Field(None, ge=1, le=2, description="应用状态(1=正常,2=禁用)") + + +class UserOAuthAppOut(BaseModel): + id: str = Field(..., description="主键") + app_id: str = Field(..., description="应用id") + secret: str = Field(..., description="应用密钥") + status: int = Field(..., description="状态,1=正常,2=禁用") + open_type: int = Field(..., description="开户方式") + create_by: str | None = Field(None, description="创建者") + created_at: NaiveDatetime = Field(..., description="创建时间") + updated_at: NaiveDatetime = Field(..., description="更新时间") + + model_config = {"from_attributes": True} \ No newline at end of file diff --git a/video-gen-api/app/services/user_oauth_app_service.py b/video-gen-api/app/services/user_oauth_app_service.py new file mode 100644 index 00000000..2da4dd1e --- /dev/null +++ b/video-gen-api/app/services/user_oauth_app_service.py @@ -0,0 +1,123 @@ +from sqlalchemy import func, select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.models.user_oauth_app import UserOAuthApp +from app.utils.id_gen import generate_id + + +async def list_user_oauth_apps( + db: AsyncSession, + page: int = 1, + page_size: int = 20, + open_type: int | None = None, + status: int | None = None, + create_by: str | None = None, + app_id: str | None = None, +) -> dict: + query = select(UserOAuthApp).where(UserOAuthApp.deleted_at.is_(None)).order_by(UserOAuthApp.created_at.desc()) + + if open_type is not None: + query = query.where(UserOAuthApp.open_type == open_type) + + if status is not None: + query = query.where(UserOAuthApp.status == status) + + if create_by is not None: + query = query.where(UserOAuthApp.create_by == create_by) + + if app_id is not None: + query = query.where(UserOAuthApp.app_id.like(f"%{app_id}%")) + + total_result = await db.execute(select(func.count(UserOAuthApp.id)).where(UserOAuthApp.deleted_at.is_(None))) + total = total_result.scalar() or 0 + + result = await db.execute(query.offset((page - 1) * page_size).limit(page_size)) + items = result.scalars().all() + + return { + "total": total, + "page": page, + "page_size": page_size, + "items": items, + } + + +async def get_user_oauth_app_by_id(db: AsyncSession, id: str) -> UserOAuthApp | None: + result = await db.execute( + select(UserOAuthApp).where(UserOAuthApp.id == id, UserOAuthApp.deleted_at.is_(None)).limit(1) + ) + return result.scalar_one_or_none() + + +async def get_user_oauth_app_by_app_id(db: AsyncSession, app_id: str) -> UserOAuthApp | None: + result = await db.execute( + select(UserOAuthApp).where(UserOAuthApp.app_id == app_id, UserOAuthApp.deleted_at.is_(None)).limit(1) + ) + return result.scalar_one_or_none() + + +async def create_user_oauth_app( + db: AsyncSession, + app_id: str, + secret: str, + open_type: int, + create_by: str | None = None, +) -> UserOAuthApp: + existing = await get_user_oauth_app_by_app_id(db, app_id) + if existing: + raise ValueError("应用id已存在") + + app = UserOAuthApp( + id=generate_id(), + app_id=app_id, + secret=secret, + open_type=open_type, + create_by=create_by, + ) + db.add(app) + await db.flush() + return app + + +async def update_user_oauth_app( + db: AsyncSession, + id: str, + secret: str | None = None, + open_type: int | None = None, + status: int | None = None, + create_by: str | None = None, +) -> UserOAuthApp | None: + app = await get_user_oauth_app_by_id(db, id) + if not app: + return None + + if secret is not None: + app.secret = secret + if open_type is not None: + app.open_type = open_type + if status is not None: + app.status = status + if create_by is not None: + app.create_by = create_by + + await db.flush() + return app + + +async def delete_user_oauth_app(db: AsyncSession, id: str, create_by: str | None = None) -> bool: + app = await get_user_oauth_app_by_id(db, id) + if not app: + return False + + app.deleted_at = func.now() + if create_by is not None: + app.create_by = create_by + await db.flush() + return True + + +async def get_apps_by_open_type(db: AsyncSession, open_type: int) -> list[UserOAuthApp]: + result = await db.execute( + select(UserOAuthApp).where(UserOAuthApp.open_type == open_type, UserOAuthApp.deleted_at.is_(None)) + ) + return result.scalars().all() \ No newline at end of file From b24c176b884f5bfa8a8b7b813704307b04197c5d Mon Sep 17 00:00:00 2001 From: 18610128193 <10574456+chenweiqiang-123@user.noreply.gitee.com> Date: Wed, 10 Jun 2026 10:47:18 +0800 Subject: [PATCH 02/68] =?UTF-8?q?=E6=8F=90=E4=BA=A4=E5=88=9D=E5=A7=8B?= =?UTF-8?q?=E6=96=87=E4=BB=B6=EF=BC=8C=E5=A2=9E=E5=8A=A0=E6=8E=88=E6=9D=83?= =?UTF-8?q?=E5=BA=94=E7=94=A8=E8=B7=AF=E7=94=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- video-gen-api/app/api/v1/test.py | 2 +- video-gen-api/app/api/v1/user_oauth.py | 124 ++++++ video-gen-api/app/models/user_oauth.py | 58 +++ .../app/models/user_oauth_account.py | 25 ++ video-gen-api/app/schemas/user_oauth.py | 29 ++ .../app/services/user_oauth_service.py | 353 ++++++++++++++++++ 6 files changed, 590 insertions(+), 1 deletion(-) create mode 100644 video-gen-api/app/api/v1/user_oauth.py create mode 100644 video-gen-api/app/models/user_oauth.py create mode 100644 video-gen-api/app/models/user_oauth_account.py create mode 100644 video-gen-api/app/schemas/user_oauth.py create mode 100644 video-gen-api/app/services/user_oauth_service.py diff --git a/video-gen-api/app/api/v1/test.py b/video-gen-api/app/api/v1/test.py index c00efa3e..507cfd95 100644 --- a/video-gen-api/app/api/v1/test.py +++ b/video-gen-api/app/api/v1/test.py @@ -5,6 +5,6 @@ router = APIRouter(prefix="/test", tags=["test"]) @router.get("/index") -async def test(req: Request): +async def test(req: Request,current_user: User = Depends(get_current_user)): return {"message": "test","code":200} diff --git a/video-gen-api/app/api/v1/user_oauth.py b/video-gen-api/app/api/v1/user_oauth.py new file mode 100644 index 00000000..d1582a05 --- /dev/null +++ b/video-gen-api/app/api/v1/user_oauth.py @@ -0,0 +1,124 @@ +from fastapi import APIRouter, Depends, HTTPException, Query, status +from sqlalchemy.ext.asyncio import AsyncSession + +from app.dependencies import get_current_user, get_db +from app.models.user import User +from app.schemas.user_oauth import RequestOAuthRequest, RequestOAuthResponse, UserOAuthOut +from app.services.user_oauth_service import ( + build_oauth_url, + get_account_info_by_type, + get_token_by_type, + save_oauth_token, +) + +router = APIRouter(prefix="/user-oauth", tags=["user-oauth"]) + + +@router.post( + "/request_oauth", + summary="获取授权链接", + description="用户提交oauth_type,返回对应的第三方授权链接", + response_model=RequestOAuthResponse, +) +async def request_oauth( + req: RequestOAuthRequest, + current_user: User = Depends(get_current_user), +): + try: + auth_url = await build_oauth_url(req.oauth_type, current_user.id) + return {"auth_url": auth_url} + except ValueError as e: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=str(e), + ) + + +@router.get( + "/juliang_callback", + summary="巨量授权回调", + description="巨量引擎授权回调地址,接收code和state参数,获取token并保存", +) +async def juliang_callback( + auth_code: str = Query(..., description="第三方返回的授权码"), + state: str = Query(..., description="请求时传递的自定义参数"), + db: AsyncSession = Depends(get_db), +): + try: + parts = state.split(":") + if len(parts) != 4: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="无效的state参数", + ) + + oauth_type = int(parts[0]) + user_id = parts[1] + app_id = parts[2] + app_type = parts[3] + + token = await get_token_by_type(auth_code, oauth_type, app_id, app_type) + account_info = await get_account_info_by_type(token, oauth_type, app_type) + user_oauth = await save_oauth_token(db, user_id, oauth_type, token, account_info, app_id) + + return { + "message": "授权成功", + "code": 0, + "data": UserOAuthOut.model_validate(user_oauth), + } + + except ValueError as e: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=str(e), + ) + except Exception as e: + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"授权失败: {str(e)}", + ) + + +@router.get( + "/callback", + summary="通用授权回调", + description="其他平台授权回调地址,接收code和state参数", +) +async def oauth_callback( + code: str = Query(..., description="第三方返回的授权码"), + state: str = Query(..., description="请求时传递的自定义参数"), + db: AsyncSession = Depends(get_db), +): + try: + parts = state.split(":") + if len(parts) != 4: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="无效的state参数", + ) + + oauth_type = int(parts[0]) + user_id = parts[1] + app_id = parts[2] + app_type = parts[3] + + token = await get_token_by_type(code, oauth_type, app_id, app_type) + account_info = await get_account_info_by_type(token, oauth_type, app_type) + user_oauth = await save_oauth_token(db, user_id, oauth_type, token, account_info, app_id) + + return { + "message": "授权成功", + "code": 0, + "data": UserOAuthOut.model_validate(user_oauth), + } + + except ValueError as e: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=str(e), + ) + except Exception as e: + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"授权失败: {str(e)}", + ) \ No newline at end of file diff --git a/video-gen-api/app/models/user_oauth.py b/video-gen-api/app/models/user_oauth.py new file mode 100644 index 00000000..245b447b --- /dev/null +++ b/video-gen-api/app/models/user_oauth.py @@ -0,0 +1,58 @@ +from datetime import datetime + +from sqlalchemy import Boolean, DateTime, ForeignKey, Integer, String, Text +from sqlalchemy.orm import Mapped, mapped_column + +from app.models.base import Base, TimestampMixin, SoftDeleteMixin + + +class UserOAuth(Base, TimestampMixin, SoftDeleteMixin): + __tablename__ = "user_oauth" + + id: Mapped[str] = mapped_column( + String(32), primary_key=True, comment="主键" + ) + account_id: Mapped[str] = mapped_column( + String(64), nullable=False, index=True, comment="授权账户id" + ) + account_name: Mapped[str] = mapped_column( + String(128), nullable=False, comment="授权账户name" + ) + account_role: Mapped[str | None] = mapped_column( + String(64), nullable=True, comment="授权账户角色" + ) + account_username: Mapped[str | None] = mapped_column( + String(128), nullable=True, comment="授权账户登录账号" + ) + user_id: Mapped[str] = mapped_column( + String(32), ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True, + comment="用户id" + ) + open_type: Mapped[int] = mapped_column( + Integer, nullable=False, + comment="开户方式(1=千川,2=广告,3=本地推,4=星图,5=快手代理商,6=巨量星图,7=巨量服务单,8=腾讯服务单,9=腾讯营销K2,10=腾讯营销K3)" + ) + port_type: Mapped[int] = mapped_column( + Integer, nullable=False, + comment="平台端口(1=巨量,2=磁力,3=巨量星图,4=服务单,5=腾讯)" + ) + appid: Mapped[str | None] = mapped_column( + String(64), nullable=True, comment="授权应用id" + ) + access_token: Mapped[str | None] = mapped_column( + Text, nullable=True, comment="授权token" + ) + access_token_expired: Mapped[datetime | None] = mapped_column( + DateTime(timezone=True), nullable=True, + comment="token过期时间" + ) + refresh_token: Mapped[str | None] = mapped_column( + Text, nullable=True, comment="授权刷新token" + ) + refresh_token_expired: Mapped[datetime | None] = mapped_column( + DateTime(timezone=True), nullable=True, + comment="刷新token过期时间" + ) + material_auth_status: Mapped[bool] = mapped_column( + Boolean, default=False, comment="是否敏感物料授权(true=是,false=否)" + ) \ No newline at end of file diff --git a/video-gen-api/app/models/user_oauth_account.py b/video-gen-api/app/models/user_oauth_account.py new file mode 100644 index 00000000..7bb53d50 --- /dev/null +++ b/video-gen-api/app/models/user_oauth_account.py @@ -0,0 +1,25 @@ +from sqlalchemy import ForeignKey, String +from sqlalchemy.orm import Mapped, mapped_column + +from app.models.base import Base, TimestampMixin, SoftDeleteMixin + + +class UserOAuthAccount(Base, TimestampMixin, SoftDeleteMixin): + __tablename__ = "user_oauth_account" + + id: Mapped[str] = mapped_column( + String(32), primary_key=True, comment="主键" + ) + account_id: Mapped[str] = mapped_column( + String(64), ForeignKey("user_oauth.account_id", ondelete="CASCADE"), + nullable=False, index=True, comment="授权账户id(user_oauth表中同一个)" + ) + advertiser_id: Mapped[str | None] = mapped_column( + String(64), nullable=True, index=True, comment="广告账户id" + ) + advertiser_name: Mapped[str | None] = mapped_column( + String(128), nullable=True, comment="广告账户名" + ) + advertiser_role: Mapped[str | None] = mapped_column( + String(64), nullable=True, comment="广告账户类型" + ) \ No newline at end of file diff --git a/video-gen-api/app/schemas/user_oauth.py b/video-gen-api/app/schemas/user_oauth.py new file mode 100644 index 00000000..d43d6ff6 --- /dev/null +++ b/video-gen-api/app/schemas/user_oauth.py @@ -0,0 +1,29 @@ +from datetime import datetime + +from pydantic import BaseModel, Field + + +class RequestOAuthRequest(BaseModel): + oauth_type: int = Field( + ..., + description="开户方式(1=巨量广告,2=巨量千川,3=快手,4=腾讯)", + ) + + +class RequestOAuthResponse(BaseModel): + auth_url: str = Field(..., description="第三方授权链接") + + +class UserOAuthOut(BaseModel): + id: str = Field(..., description="主键") + account_id: str = Field(..., description="授权账户id") + account_name: str = Field(..., description="授权账户name") + account_role: str | None = Field(None, description="授权账户角色") + account_username: str | None = Field(None, description="授权账户登录账号") + user_id: str = Field(..., description="用户id") + open_type: int = Field(..., description="开户方式") + port_type: int = Field(..., description="平台端口") + appid: str | None = Field(None, description="授权应用id") + material_auth_status: bool = Field(False, description="是否敏感物料授权") + created_at: datetime = Field(..., description="创建时间") + updated_at: datetime = Field(..., description="更新时间") \ No newline at end of file diff --git a/video-gen-api/app/services/user_oauth_service.py b/video-gen-api/app/services/user_oauth_service.py new file mode 100644 index 00000000..fefcb541 --- /dev/null +++ b/video-gen-api/app/services/user_oauth_service.py @@ -0,0 +1,353 @@ +import random +from datetime import datetime + +import httpx +from sqlalchemy import select, func +from sqlalchemy.ext.asyncio import AsyncSession + +from app.config import settings +from app.models.user_oauth import UserOAuth +from app.utils.id_gen import generate_id + + +OAUTH_TYPE_CONFIG = { + 1: {"port_type": 1, "name": "千川", "app_type": "juliang_qianchuan"}, + 2: {"port_type": 1, "name": "广告", "app_type": "juliang_ad"}, + 3: {"port_type": 1, "name": "本地推", "app_type": "juliang_ad"}, + 4: {"port_type": 1, "name": "星图", "app_type": "juliang_ad"}, + 5: {"port_type": 2, "name": "快手代理商", "app_type": "kuaishou"}, + 6: {"port_type": 3, "name": "巨量星图", "app_type": "juliang_ad"}, + 7: {"port_type": 4, "name": "巨量服务单", "app_type": "juliang_ad"}, + 8: {"port_type": 4, "name": "腾讯服务单", "app_type": "tencent"}, + 9: {"port_type": 5, "name": "腾讯营销K2", "app_type": "tencent"}, + 10: {"port_type": 5, "name": "腾讯营销K3", "app_type": "tencent"}, +} + + +async def get_available_app(app_type: str, db: AsyncSession) -> dict: + if app_type == "juliang_ad": + apps = settings.JULIANG_AD_APPS + elif app_type == "juliang_qianchuan": + apps = settings.JULIANG_QIANCHUAN_APPS + elif app_type == "kuaishou": + apps = settings.KUAISHOU_APPS + elif app_type == "tencent": + apps = settings.TENCENT_APPS + else: + raise ValueError(f"不支持的应用类型: {app_type}") + + if not apps: + raise ValueError(f"{app_type}未配置应用") + + available_apps = [] + for app in apps: + app_id = app.get("app_id") + if not app_id: + continue + + result = await db.execute( + select(func.count(UserOAuth.id)).where(UserOAuth.appid == app_id) + ) + count = result.scalar() or 0 + + if count < 5000: + available_apps.append(app) + + if not available_apps: + raise ValueError("所有应用授权已超过最大数量") + + return random.choice(available_apps) + + +async def build_oauth_url(oauth_type: int, user_id: str) -> str: + if oauth_type == 1: + return await _build_juliang_oauth_url(oauth_type, user_id, app_type) + elif oauth_type == 2: + return await _build_kuaishou_oauth_url(oauth_type, user_id) + elif oauth_type == 3: + return await _build_tencent_oauth_url(oauth_type, user_id) + elif oauth_type == 4: + return await _build_tencent_oauth_url(oauth_type, user_id) + else: + raise ValueError(f"不支持的应用类型: {app_type}") + + +async def _build_juliang_oauth_url(oauth_type: int, user_id: str, app_type: str) -> str: + async with AsyncSession() as db: + app = await get_available_app(app_type, db) + app_id = app.get("app_id") + + redirect_uri = "https://open.oceanengine.com/audit/oauth.html" + rid = "ktm0cl7napb" + if oauth_type == 1: + redirect_uri = "https://qianchuan.jinritemai.com/openapi/qc/audit/oauth.html" + rid = "vr7kclvmvs9" + + params = { + "app_id": app_id, + "state": f"{oauth_type}:{user_id}:{app_id}:{app_type}", + "material_auth": 1, + "rid": rid, + } + query_string = "&".join(f"{k}={v}" for k, v in params.items()) + return f"{redirect_uri}?{query_string}" + + +async def _build_kuaishou_oauth_url(oauth_type: int, user_id: str) -> str: + async with AsyncSession() as db: + app = await get_available_app("kuaishou", db) + app_id = app.get("app_id") + + redirect_uri = f"{settings.BASE_URL}/api/user-oauth/callback" + params = { + "app_id": app_id, + "redirect_uri": redirect_uri, + "response_type": "code", + "scope": "basic", + "state": f"{oauth_type}:{user_id}:{app_id}:kuaishou", + } + query_string = "&".join(f"{k}={v}" for k, v in params.items()) + return f"https://open.kuaishou.com/oauth2/authorize?{query_string}" + + +async def _build_tencent_oauth_url(oauth_type: int, user_id: str) -> str: + async with AsyncSession() as db: + app = await get_available_app("tencent", db) + app_id = app.get("app_id") + + redirect_uri = f"{settings.BASE_URL}/api/user-oauth/callback" + params = { + "app_id": app_id, + "redirect_uri": redirect_uri, + "response_type": "code", + "scope": "get_user_info", + "state": f"{oauth_type}:{user_id}:{app_id}:tencent", + } + query_string = "&".join(f"{k}={v}" for k, v in params.items()) + return f"https://api.e.qq.com/oauth/authorize?{query_string}" + + +async def get_token_by_type(code: str, oauth_type: int, app_id: str, app_type: str) -> dict: + if app_type in ("juliang_ad", "juliang_qianchuan"): + return await get_juliang_token(code, oauth_type, app_id, app_type) + elif app_type == "kuaishou": + return await get_kuaishou_token(code, oauth_type, app_id) + elif app_type == "tencent": + return await get_tencent_token(code, oauth_type, app_id) + else: + raise ValueError(f"不支持的应用类型: {app_type}") + + +async def get_juliang_token(code: str, oauth_type: int, app_id: str, app_type: str) -> dict: + url = "https://api.oceanengine.com/open_api/oauth2/access_token/" + + if app_type == "juliang_ad": + apps = settings.JULIANG_AD_APPS + else: + apps = settings.JULIANG_QIANCHUAN_APPS + + app = next((a for a in apps if a.get("app_id") == app_id), None) + if not app: + raise ValueError("应用配置不存在") + + async with httpx.AsyncClient() as client: + response = await client.post( + url, + data={ + "app_id": app_id, + "secret": app.get("secret"), + "auth_code": code, + }, + ) + response.raise_for_status() + content = response.json() + if content.get("code") != 0: + raise ValueError(content.get("message", "获取token失败")) + return content.get("data", {}) + + +async def get_kuaishou_token(code: str, oauth_type: int, app_id: str) -> dict: + url = "https://open.kuaishou.com/oauth2/token" + redirect_uri = f"{settings.BASE_URL}/api/user-oauth/callback" + + app = next((a for a in settings.KUAISHOU_APPS if a.get("app_id") == app_id), None) + if not app: + raise ValueError("应用配置不存在") + + async with httpx.AsyncClient() as client: + response = await client.post( + url, + data={ + "app_id": app_id, + "secret": app.get("secret"), + "code": code, + "grant_type": "authorization_code", + "redirect_uri": redirect_uri, + }, + ) + response.raise_for_status() + return response.json() + + +async def get_tencent_token(code: str, oauth_type: int, app_id: str) -> dict: + url = "https://api.e.qq.com/oauth/token" + redirect_uri = f"{settings.BASE_URL}/api/user-oauth/callback" + + app = next((a for a in settings.TENCENT_APPS if a.get("app_id") == app_id), None) + if not app: + raise ValueError("应用配置不存在") + + async with httpx.AsyncClient() as client: + response = await client.post( + url, + data={ + "app_id": app_id, + "secret": app.get("secret"), + "code": code, + "grant_type": "authorization_code", + "redirect_uri": redirect_uri, + }, + ) + response.raise_for_status() + return response.json() + + +async def get_account_info_by_type(token: dict, oauth_type: int, app_type: str) -> dict: + if app_type in ("juliang_ad", "juliang_qianchuan"): + return await _get_juliang_account_info(token) + elif app_type == "kuaishou": + return await _get_kuaishou_account_info(token) + elif app_type == "tencent": + return await _get_tencent_account_info(token) + else: + raise ValueError(f"不支持的应用类型: {app_type}") + + +async def _get_juliang_account_info(token: dict) -> dict: + access_token = token.get("access_token") + url = "https://ad.oceanengine.com/openapi/oauth/user/info/" + + async with httpx.AsyncClient() as client: + response = await client.get( + url, + headers={"Access-Token": access_token}, + ) + response.raise_for_status() + data = response.json() + if data.get("code") != 0: + raise ValueError(data.get("message", "获取账户信息失败")) + data = data.get("data", {}) + return { + "account_id": data.get("advertiser_id", data.get("account_id", "")), + "account_name": data.get("advertiser_name", data.get("account_name", "")), + "account_role": data.get("role", ""), + "account_username": data.get("username", ""), + } + + +async def _get_kuaishou_account_info(token: dict) -> dict: + access_token = token.get("access_token") + url = "https://open.kuaishou.com/api/user/info" + + async with httpx.AsyncClient() as client: + response = await client.get( + url, + headers={"Authorization": f"Bearer {access_token}"}, + ) + response.raise_for_status() + data = response.json() + return { + "account_id": data.get("account_id", ""), + "account_name": data.get("account_name", ""), + "account_role": data.get("role", ""), + "account_username": data.get("username", ""), + } + + +async def _get_tencent_account_info(token: dict) -> dict: + access_token = token.get("access_token") + url = "https://api.e.qq.com/user/info" + + async with httpx.AsyncClient() as client: + response = await client.get( + url, + headers={"Authorization": f"Bearer {access_token}"}, + ) + response.raise_for_status() + data = response.json() + return { + "account_id": data.get("account_id", ""), + "account_name": data.get("account_name", ""), + "account_role": data.get("role", ""), + "account_username": data.get("username", ""), + } + + +async def save_oauth_token( + db: AsyncSession, + user_id: str, + oauth_type: int, + token: dict, + account_info: dict, + app_id: str, +) -> UserOAuth: + config = OAUTH_TYPE_CONFIG.get(oauth_type) + if not config: + raise ValueError(f"不支持的oauth_type: {oauth_type}") + + access_token = token.get("access_token") + access_token_expired = token.get("expires_in") + refresh_token = token.get("refresh_token") + refresh_token_expired = token.get("refresh_token_expires_in") + + expires_at = None + if access_token_expired: + expires_at = datetime.now().timestamp() + int(access_token_expired) + expires_at = datetime.fromtimestamp(expires_at) + + refresh_expires_at = None + if refresh_token_expired: + refresh_expires_at = datetime.now().timestamp() + int(refresh_token_expired) + refresh_expires_at = datetime.fromtimestamp(refresh_expires_at) + + existing = await db.execute( + select(UserOAuth).where( + UserOAuth.user_id == user_id, + UserOAuth.open_type == oauth_type, + UserOAuth.account_id == account_info.get("account_id", ""), + ).limit(1) + ) + existing_oauth = existing.scalar_one_or_none() + + if existing_oauth: + existing_oauth.access_token = access_token + existing_oauth.access_token_expired = expires_at + existing_oauth.refresh_token = refresh_token + existing_oauth.refresh_token_expired = refresh_expires_at + existing_oauth.account_name = account_info.get("account_name", "") + existing_oauth.account_role = account_info.get("account_role", "") + existing_oauth.account_username = account_info.get("account_username", "") + existing_oauth.appid = app_id + await db.flush() + return existing_oauth + + user_oauth = UserOAuth( + id=generate_id(), + account_id=account_info.get("account_id", ""), + account_name=account_info.get("account_name", ""), + account_role=account_info.get("account_role", ""), + account_username=account_info.get("account_username", ""), + user_id=user_id, + open_type=oauth_type, + port_type=config["port_type"], + appid=app_id, + access_token=access_token, + access_token_expired=expires_at, + refresh_token=refresh_token, + refresh_token_expired=refresh_expires_at, + material_auth_status=True, + ) + + db.add(user_oauth) + await db.flush() + return user_oauth \ No newline at end of file From 522839d8e410c5831d5969fbbf7db8ac315a7801 Mon Sep 17 00:00:00 2001 From: 18610128193 <10574456+chenweiqiang-123@user.noreply.gitee.com> Date: Wed, 10 Jun 2026 11:02:35 +0800 Subject: [PATCH 03/68] =?UTF-8?q?=E5=88=A0=E9=99=A4=E6=B5=8B=E8=AF=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- video-gen-api/app/api/v1/test.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/video-gen-api/app/api/v1/test.py b/video-gen-api/app/api/v1/test.py index 507cfd95..02961f89 100644 --- a/video-gen-api/app/api/v1/test.py +++ b/video-gen-api/app/api/v1/test.py @@ -5,6 +5,6 @@ router = APIRouter(prefix="/test", tags=["test"]) @router.get("/index") -async def test(req: Request,current_user: User = Depends(get_current_user)): +async def test(): return {"message": "test","code":200} From cd8184c81c2afffc40ee4cd18b141e6591d61325 Mon Sep 17 00:00:00 2001 From: 18610128193 <10574456+chenweiqiang-123@user.noreply.gitee.com> Date: Wed, 10 Jun 2026 11:12:47 +0800 Subject: [PATCH 04/68] =?UTF-8?q?=E4=BF=AE=E6=94=B9=E6=8E=88=E6=9D=83?= =?UTF-8?q?=E8=A1=A8=EF=BC=8C=E5=8E=BB=E6=8E=89=E5=A4=96=E9=94=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- video-gen-api/app/models/user_oauth.py | 2 +- video-gen-api/app/models/user_oauth_account.py | 2 +- video-gen-api/app/models/user_oauth_app.py | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/video-gen-api/app/models/user_oauth.py b/video-gen-api/app/models/user_oauth.py index 245b447b..4b346220 100644 --- a/video-gen-api/app/models/user_oauth.py +++ b/video-gen-api/app/models/user_oauth.py @@ -25,7 +25,7 @@ class UserOAuth(Base, TimestampMixin, SoftDeleteMixin): String(128), nullable=True, comment="授权账户登录账号" ) user_id: Mapped[str] = mapped_column( - String(32), ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True, + String(32), nullable=False, index=True, comment="用户id" ) open_type: Mapped[int] = mapped_column( diff --git a/video-gen-api/app/models/user_oauth_account.py b/video-gen-api/app/models/user_oauth_account.py index 7bb53d50..35a01240 100644 --- a/video-gen-api/app/models/user_oauth_account.py +++ b/video-gen-api/app/models/user_oauth_account.py @@ -11,7 +11,7 @@ class UserOAuthAccount(Base, TimestampMixin, SoftDeleteMixin): String(32), primary_key=True, comment="主键" ) account_id: Mapped[str] = mapped_column( - String(64), ForeignKey("user_oauth.account_id", ondelete="CASCADE"), + String(64), nullable=False, index=True, comment="授权账户id(user_oauth表中同一个)" ) advertiser_id: Mapped[str | None] = mapped_column( diff --git a/video-gen-api/app/models/user_oauth_app.py b/video-gen-api/app/models/user_oauth_app.py index f2246144..c71b9850 100644 --- a/video-gen-api/app/models/user_oauth_app.py +++ b/video-gen-api/app/models/user_oauth_app.py @@ -23,5 +23,5 @@ class UserOAuthApp(Base, TimestampMixin, SoftDeleteMixin): BigInteger, nullable=False, index=True, comment="开户方式(1=千川,2=广告,3=本地推,4=星图,5=快手代理商,6=巨量星图,7=巨量服务单,8=腾讯服务单,9=腾讯营销K2,10=腾讯营销K3)" ) create_by: Mapped[str | None] = mapped_column( - String(32), ForeignKey("users.id", ondelete="SET NULL"), nullable=True, comment="创建者" + String(32), nullable=True, comment="创建者" ) \ No newline at end of file From d5a7ba8f143c279d8a54f2d0d5dbf79c90ce6901 Mon Sep 17 00:00:00 2001 From: wwwwwwwww <526125649@qq.com> Date: Wed, 10 Jun 2026 11:32:36 +0800 Subject: [PATCH 05/68] =?UTF-8?q?=E6=8F=90=E4=BA=A4=E4=BF=A1=E6=81=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../versions/ed59aefc83da_描述改动内容.py | 29 +++++++++++++++++++ 1 file changed, 29 insertions(+) create mode 100644 video-gen-api/alembic/versions/ed59aefc83da_描述改动内容.py diff --git a/video-gen-api/alembic/versions/ed59aefc83da_描述改动内容.py b/video-gen-api/alembic/versions/ed59aefc83da_描述改动内容.py new file mode 100644 index 00000000..fbb7a58f --- /dev/null +++ b/video-gen-api/alembic/versions/ed59aefc83da_描述改动内容.py @@ -0,0 +1,29 @@ +"""描述改动内容 + +Revision ID: ed59aefc83da +Revises: 476b259992de +Create Date: 2026-06-10 11:28:49.178706 +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = 'ed59aefc83da' +down_revision: Union[str, None] = '476b259992de' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + pass + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + pass + # ### end Alembic commands ### From 913dfee9e013c77007d70d19a302dac6747b3b77 Mon Sep 17 00:00:00 2001 From: wwwwwwwww <526125649@qq.com> Date: Wed, 10 Jun 2026 11:33:23 +0800 Subject: [PATCH 06/68] =?UTF-8?q?=E5=A2=9E=E5=8A=A0=E6=94=AF=E4=BB=98?= =?UTF-8?q?=E5=AE=9D=E6=94=AF=E4=BB=98=E7=9B=B8=E5=85=B3=E9=A1=B5=E9=9D=A2?= =?UTF-8?q?=E5=92=8C=E7=A8=8B=E5=BA=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 1 + DEPLOYMENT.md | 7 + video-gen-api/app/api/v1/payments.py | 50 ++- video-gen-api/app/config.py | 1 + video-gen-api/app/schemas/payment.py | 2 + video-gen-api/app/services/payment.py | 297 ++++++++++++++++-- video-gen-api/pyproject.toml | 2 + video-gen-app/src/api/index.ts | 8 + .../src/components/Layout/AppLayout.tsx | 172 +++++++--- 9 files changed, 444 insertions(+), 96 deletions(-) diff --git a/.gitignore b/.gitignore index 4887c932..4c3b48dc 100644 --- a/.gitignore +++ b/.gitignore @@ -22,3 +22,4 @@ video-gen-api/dist/ # 忽略特定类型文件但保留目录 # *.pyc # !dir/*.pycnode_modules/ +*.tmp.* \ No newline at end of file diff --git a/DEPLOYMENT.md b/DEPLOYMENT.md index c4017611..6fd2c113 100644 --- a/DEPLOYMENT.md +++ b/DEPLOYMENT.md @@ -21,6 +21,7 @@ video_item/ | PostgreSQL | >= 14 | 推荐 16 | | Redis | >= 6 | 可选,推荐用于限流/验证码/Celery | | FFmpeg | 任意 | 可选,用于视频封面截帧 | +| alipay-sdk-python | >=3.7.1160 | 可选,用于支付 | --- @@ -46,6 +47,12 @@ pip install -e ".[pg,redis]" # 如需 Celery 异步任务(ChatAPI 生成流水线) pip install -e ".[pg,redis,celery]" + +#安装阿里支付sdk +pip install -e ".[pg,redis,celery,alipay]" + +#安装火山sdk +pip install -e ".[pg,redis,celery,alipay,volc]" ``` ### 2. 配置环境变量 diff --git a/video-gen-api/app/api/v1/payments.py b/video-gen-api/app/api/v1/payments.py index 6b2d8ca4..3f606892 100644 --- a/video-gen-api/app/api/v1/payments.py +++ b/video-gen-api/app/api/v1/payments.py @@ -1,13 +1,22 @@ +import logging + from fastapi import APIRouter, Depends, HTTPException, Request from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession +logger = logging.getLogger("videogen") + from app.dependencies import get_db, get_current_user from app.models.user import User from app.models.payment_order import PaymentOrder from app.models.recharge_package import RechargePackage from app.schemas.payment import RechargeRequest, PaymentOrderOut -from app.services.payment import create_recharge_order, verify_wechat_callback, verify_alipay_callback, process_payment_success +from app.services.payment import ( + create_recharge_order, + verify_wechat_callback, + verify_alipay_callback, + process_payment_success_by_order_no, +) router = APIRouter(prefix="/payments", tags=["payments"]) @@ -18,6 +27,9 @@ async def recharge( current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): + if req.method not in ("wechat", "alipay"): + raise HTTPException(status_code=400, detail="不支持的支付方式") + result = await db.execute( select(RechargePackage).where( RechargePackage.id == req.plan, @@ -35,6 +47,7 @@ async def recharge( price=pkg.price, label=pkg.name, bonus_credits=pkg.bonus_credits, + method=req.method, ) return order @@ -42,30 +55,35 @@ async def recharge( @router.post("/wechat/callback") async def wechat_callback(request: Request, db: AsyncSession = Depends(get_db)): data = await request.json() - if not await verify_wechat_callback(data): + if not await verify_wechat_callback(data, db): raise HTTPException(status_code=400, detail="签名验证失败") order_no = data.get("out_trade_no") - result = await db.execute( - select(PaymentOrder).where(PaymentOrder.order_no == order_no).limit(1) - ) - order = result.scalar_one_or_none() - if order: - await process_payment_success(db, order.id) + if order_no: + await process_payment_success_by_order_no(db, order_no) return {"code": "SUCCESS", "message": "OK"} @router.post("/alipay/callback") async def alipay_callback(request: Request, db: AsyncSession = Depends(get_db)): - data = await request.form() - if not await verify_alipay_callback(dict(data)): + form_data = await request.form() + data = dict(form_data) + logger.info(f"Alipay callback received: {list(data.keys())}") + + # Verify signature first + if not await verify_alipay_callback(data, db): raise HTTPException(status_code=400, detail="签名验证失败") + + # Check trade_status – only "TRADE_SUCCESS" and "TRADE_FINISHED" mean paid + trade_status = data.get("trade_status", "") + if trade_status not in ("TRADE_SUCCESS", "TRADE_FINISHED"): + logger.info(f"Alipay callback trade_status={trade_status}, ignoring") + return "success" + order_no = data.get("out_trade_no") - result = await db.execute( - select(PaymentOrder).where(PaymentOrder.order_no == order_no).limit(1) - ) - order = result.scalar_one_or_none() - if order: - await process_payment_success(db, order.id) + trade_no = data.get("trade_no", "") + if order_no: + await process_payment_success_by_order_no(db, order_no, trade_no) + return "success" diff --git a/video-gen-api/app/config.py b/video-gen-api/app/config.py index 5b3bf258..3118204d 100644 --- a/video-gen-api/app/config.py +++ b/video-gen-api/app/config.py @@ -56,6 +56,7 @@ class Settings(BaseSettings): ALIPAY_APP_ID: str = "" ALIPAY_PRIVATE_KEY: str = "" ALIPAY_PUBLIC_KEY: str = "" + ALIPAY_NOTIFY_URL: str = "" PAYMENT_MOCK: bool = True STORAGE_TYPE: str = "local" diff --git a/video-gen-api/app/schemas/payment.py b/video-gen-api/app/schemas/payment.py index cf611557..135b4172 100644 --- a/video-gen-api/app/schemas/payment.py +++ b/video-gen-api/app/schemas/payment.py @@ -3,6 +3,7 @@ from pydantic import BaseModel class RechargeRequest(BaseModel): plan: str # package id + method: str = "wechat" # "wechat" or "alipay" class PaymentOrderOut(BaseModel): @@ -12,5 +13,6 @@ class PaymentOrderOut(BaseModel): credits: float payment_method: str status: str + qr_url: str | None = None # Alipay QR code URL (transient, not persisted) model_config = {"from_attributes": True} diff --git a/video-gen-api/app/services/payment.py b/video-gen-api/app/services/payment.py index ed0e8946..9261cb36 100644 --- a/video-gen-api/app/services/payment.py +++ b/video-gen-api/app/services/payment.py @@ -1,16 +1,87 @@ import logging from datetime import datetime +from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from app.config import settings from app.models.payment_order import PaymentOrder +from app.models.system_config import SystemConfig from app.services.credits import add_credits from app.utils.id_gen import generate_id, generate_order_no logger = logging.getLogger("videogen") +# --------------------------------------------------------------------------- +# Config helpers – read from system_configs table (admin panel) +# --------------------------------------------------------------------------- + + +async def _get_payment_configs(db: AsyncSession) -> dict[str, str]: + """Read all payment_* configs from the database, return as a dict.""" + result = await db.execute( + select(SystemConfig).where(SystemConfig.key.like("payment_%")) + ) + return {c.key: c.value for c in result.scalars().all()} + + +def _is_mock_mode(db_configs: dict[str, str]) -> bool: + """Check if payment mock mode is enabled (from DB or env).""" + db_val = db_configs.get("payment_mock", "") + if db_val: + return db_val.lower() in ("true", "1", "yes") + return settings.PAYMENT_MOCK + + +# --------------------------------------------------------------------------- +# Alipay client (lazy singleton, recreated when config changes) +# --------------------------------------------------------------------------- +_alipay_client = None +_alipay_client_app_id = None + + +def _get_alipay_client(app_id: str, private_key: str, public_key: str): + """Get or create an Alipay client. Recreated if app_id changes.""" + global _alipay_client, _alipay_client_app_id + + if _alipay_client is not None and _alipay_client_app_id == app_id: + return _alipay_client + + try: + from alipay.aop.api.AlipayClientConfig import AlipayClientConfig + from alipay.aop.api.DefaultAlipayClient import DefaultAlipayClient + except ImportError: + logger.error( + "alipay-sdk-python is not installed. " + "Install it with: pip install alipay-sdk-python" + ) + return None + + config = AlipayClientConfig() + config.server_url = "https://openapi.alipay.com/gateway.do" + config.app_id = app_id + config.app_private_key = private_key + config.alipay_public_key = public_key + config.sign_type = "RSA2" + config.charset = "utf-8" + + try: + _alipay_client = DefaultAlipayClient(config) + _alipay_client_app_id = app_id + except Exception: + logger.exception("Failed to initialize Alipay client") + _alipay_client = None + _alipay_client_app_id = None + + return _alipay_client + + +# --------------------------------------------------------------------------- +# Create recharge order +# --------------------------------------------------------------------------- + + async def create_recharge_order( db: AsyncSession, user_id: str, @@ -20,7 +91,12 @@ async def create_recharge_order( bonus_credits: float = 0.0, method: str = "wechat", ) -> PaymentOrder: - """Create a payment order. In mock mode, immediately completes payment.""" + """Create a payment order. + + Reads payment config from the database (admin panel). + Returns the order; for Alipay the ``qr_url`` attribute will be populated + with the scan-to-pay URL. + """ total_credits = credits + bonus_credits order = PaymentOrder( id=generate_id(), @@ -34,7 +110,11 @@ async def create_recharge_order( db.add(order) await db.flush() - if settings.PAYMENT_MOCK: + # Read config from database + db_configs = await _get_payment_configs(db) + mock_mode = _is_mock_mode(db_configs) + + if mock_mode: # Mock: immediately complete payment order.status = "paid" order.paid_at = datetime.now() @@ -52,57 +132,184 @@ async def create_recharge_order( else: # Real payment: delegate to WeChat or Alipay if method == "wechat": - _create_wechat_order(order) + _create_wechat_order(order, db_configs) elif method == "alipay": - _create_alipay_order(order) + qr_url = _create_alipay_order(order, db_configs) + if qr_url: + # Attach QR URL to the order instance (transient, not persisted) + order.qr_url = qr_url # type: ignore[attr-defined] return order -def _create_wechat_order(order: PaymentOrder) -> None: +# --------------------------------------------------------------------------- +# WeChat (stub) +# --------------------------------------------------------------------------- + + +def _create_wechat_order(order: PaymentOrder, db_configs: dict[str, str]) -> None: """Create a WeChat Pay order. Stub for real integration.""" - if not settings.WECHAT_MCH_ID or not settings.WECHAT_API_KEY: - logger.warning("WeChat payment config missing (WECHAT_MCH_ID / WECHAT_API_KEY)") + mch_id = db_configs.get("payment_wechat_mch_id", "") + api_key = db_configs.get("payment_wechat_api_key", "") + if not mch_id or not api_key: + logger.warning("WeChat payment config missing in database") return logger.info( - f"WeChat order created: mch_id={settings.WECHAT_MCH_ID}, " + f"WeChat order created: mch_id={mch_id}, " f"order_no={order.order_no}, amount={order.amount}" ) -def _create_alipay_order(order: PaymentOrder) -> None: - """Create an Alipay order. Stub for real integration.""" - if not settings.ALIPAY_APP_ID or not settings.ALIPAY_PRIVATE_KEY: - logger.warning("Alipay payment config missing (ALIPAY_APP_ID / ALIPAY_PRIVATE_KEY)") - return - logger.info( - f"Alipay order created: app_id={settings.ALIPAY_APP_ID}, " - f"order_no={order.order_no}, amount={order.amount}" - ) +# --------------------------------------------------------------------------- +# Alipay – trade.precreate (当面付 预下单) +# --------------------------------------------------------------------------- -async def verify_wechat_callback(data: dict) -> bool: +def _create_alipay_order(order: PaymentOrder, db_configs: dict[str, str]) -> str | None: + """Call Alipay ``trade.precreate`` to obtain a QR code URL. + + Reads all Alipay config from the database (admin panel). + Returns the ``qr_code`` URL on success, or ``None`` on failure. + """ + app_id = db_configs.get("payment_alipay_app_id", "") + private_key = db_configs.get("payment_alipay_private_key", "") + public_key = db_configs.get("payment_alipay_public_key", "") + notify_url = db_configs.get("payment_alipay_notify_url", "") + + if not app_id or not private_key: + logger.warning("Alipay config missing in database (app_id / private_key)") + return None + + client = _get_alipay_client(app_id, private_key, public_key) + if client is None: + return None + + try: + from alipay.aop.api.domain.AlipayTradePrecreateModel import ( + AlipayTradePrecreateModel, + ) + from alipay.aop.api.request.AlipayTradePrecreateRequest import ( + AlipayTradePrecreateRequest, + ) + + model = AlipayTradePrecreateModel() + model.out_trade_no = order.order_no + model.total_amount = f"{order.amount:.2f}" + model.subject = f"充值订单 {order.order_no}" + + body_parts = [] + if order.credits > 0: + body_parts.append(f"{order.credits}积分") + if body_parts: + model.body = " ".join(body_parts) + + request = AlipayTradePrecreateRequest() + request.biz_model = model + if notify_url: + request.notify_url = notify_url + + response = client.execute(request) + + if response.code == "10000": + qr_url = response.qr_code + logger.info( + f"Alipay precreate success: order_no={order.order_no}, " + f"qr_url={qr_url}" + ) + return qr_url + else: + logger.error( + f"Alipay precreate failed: code={response.code}, " + f"msg={response.msg}, sub_code={response.sub_code}, " + f"sub_msg={response.sub_msg}, order_no={order.order_no}" + ) + return None + + except Exception: + logger.exception(f"Alipay precreate exception: order_no={order.order_no}") + return None + + +# --------------------------------------------------------------------------- +# Alipay callback verification +# --------------------------------------------------------------------------- + + +async def verify_alipay_callback(data: dict, db: AsyncSession) -> bool: + """Verify Alipay payment callback (async notify) signature. + + Reads the Alipay public key from the database and uses the SDK's + built-in RSA2 verification. + """ + db_configs = await _get_payment_configs(db) + mock_mode = _is_mock_mode(db_configs) + if mock_mode: + return True + + public_key = db_configs.get("payment_alipay_public_key", "") + if not public_key: + logger.warning("ALIPAY_PUBLIC_KEY not found in database, cannot verify callback") + return False + + try: + sign = data.get("sign") + if not sign: + logger.warning("Alipay callback missing 'sign' field") + return False + + # Build verification params (exclude sign and sign_type) + verify_data = { + k: v for k, v in data.items() + if k not in ("sign", "sign_type") and v is not None and v != "" + } + + from alipay.aop.api.util.Signature import verify_with_rsa + + sign_content = "&".join( + f"{k}={v}" for k, v in sorted(verify_data.items()) + ) + + is_valid = verify_with_rsa( + public_key.encode("utf-8"), + sign_content.encode("utf-8"), + sign, + ) + + if not is_valid: + logger.warning("Alipay callback signature verification FAILED") + + return is_valid + + except ImportError: + logger.error("alipay-sdk-python not installed, skipping signature verification") + return True + except Exception: + logger.exception("Alipay callback verification error") + return False + + +# --------------------------------------------------------------------------- +# WeChat callback verification (stub) +# --------------------------------------------------------------------------- + + +async def verify_wechat_callback(data: dict, db: AsyncSession) -> bool: """Verify WeChat payment callback signature.""" - if settings.PAYMENT_MOCK: + db_configs = await _get_payment_configs(db) + mock_mode = _is_mock_mode(db_configs) + if mock_mode: return True - # Real verification would use WECHAT_API_KEY to verify signature logger.info("WeChat callback verification (real mode not implemented)") return True -async def verify_alipay_callback(data: dict) -> bool: - """Verify Alipay payment callback signature.""" - if settings.PAYMENT_MOCK: - return True - # Real verification would use ALIPAY_PUBLIC_KEY to verify signature - logger.info("Alipay callback verification (real mode not implemented)") - return True +# --------------------------------------------------------------------------- +# Process successful payment +# --------------------------------------------------------------------------- async def process_payment_success(db: AsyncSession, order_id: str): """Process successful payment: update order and add credits.""" - from sqlalchemy import select - result = await db.execute( select(PaymentOrder).where(PaymentOrder.id == order_id).limit(1) ) @@ -120,3 +327,35 @@ async def process_payment_success(db: AsyncSession, order_id: str): related_id=order.id, ) await db.flush() + + +async def process_payment_success_by_order_no(db: AsyncSession, order_no: str, trade_no: str = ""): + """Process successful payment by order_no (used by Alipay/WeChat callbacks). + + Args: + db: async database session + order_no: the merchant order number (out_trade_no) + trade_no: the Alipay trade number (trade_no), optional + """ + result = await db.execute( + select(PaymentOrder).where(PaymentOrder.order_no == order_no).limit(1) + ) + order = result.scalar_one_or_none() + if not order or order.status != "pending": + logger.info(f"Order {order_no} not found or already processed, skipping") + return + + order.status = "paid" + order.paid_at = datetime.now() + if trade_no: + order.trade_no = trade_no + + await add_credits( + db, + order.user_id, + order.credits, + f"充值成功({order.credits}积分)", + related_id=order.id, + ) + await db.flush() + logger.info(f"Payment success processed: order_no={order_no}, trade_no={trade_no}") diff --git a/video-gen-api/pyproject.toml b/video-gen-api/pyproject.toml index ff8435bb..77da0839 100644 --- a/video-gen-api/pyproject.toml +++ b/video-gen-api/pyproject.toml @@ -22,6 +22,8 @@ dependencies = [ pg = ["asyncpg>=0.30.0"] redis = ["redis>=5.2.0"] celery = ["celery>=5.4.0", "redis>=5.2.0"] +alipay = ["alipay-sdk-python>=3.7.1160"] +volc = ["volcengine-python-sdk>=1.1.0"] dev = [ "pytest>=8.3.0", "pytest-asyncio>=0.24.0", diff --git a/video-gen-app/src/api/index.ts b/video-gen-app/src/api/index.ts index 762389f1..fad28707 100644 --- a/video-gen-app/src/api/index.ts +++ b/video-gen-app/src/api/index.ts @@ -319,6 +319,14 @@ export async function getRechargePackages(): Promise { return api.get('/recharge-packages'); } +export async function createRechargeOrder(planId: string, method: string = 'wechat'): Promise { + return api.post('/payments/recharge', { plan: planId, method }); +} + +export async function getPaymentOrders(): Promise { + return api.get('/payments/orders'); +} + export async function getCreditRatios(): Promise { return api.get('/credits/ratios'); diff --git a/video-gen-app/src/components/Layout/AppLayout.tsx b/video-gen-app/src/components/Layout/AppLayout.tsx index 115834ff..aca844bc 100644 --- a/video-gen-app/src/components/Layout/AppLayout.tsx +++ b/video-gen-app/src/components/Layout/AppLayout.tsx @@ -1,5 +1,5 @@ -import React, { useEffect, useState, useMemo } from 'react'; -import { Layout, Avatar, Dropdown, Space, Modal, Form, Input, message, Tooltip, Tag, Button, Typography } from 'antd'; +import React, { useEffect, useState, useCallback, useRef } from 'react'; +import { Layout, Avatar, Dropdown, Space, Modal, Form, Input, message, Tooltip, Tag, Button, Typography, Radio } from 'antd'; import { QRCodeSVG } from 'qrcode.react'; import { PlayCircleOutlined, @@ -21,12 +21,13 @@ import { FireFilled, CrownFilled, BankFilled, - QrcodeOutlined, CloseOutlined, + WechatOutlined, + AlipayCircleOutlined, } from '@ant-design/icons'; import { Outlet, useNavigate, useLocation } from 'react-router-dom'; import { useAuthStore } from '../../store/useAuthStore'; -import { getMenuConfigs, getRechargePackages, getNotifications, markNotificationRead, getSiteInfo } from '../../api'; +import { getMenuConfigs, getRechargePackages, createRechargeOrder, getPaymentOrders, getNotifications, markNotificationRead, getSiteInfo } from '../../api'; import NotificationPopup from '../NotificationPopup'; interface MenuConfig { @@ -88,7 +89,10 @@ const AppLayout: React.FC = () => { const [siteName, setSiteName] = useState('VideoGen.AI'); const [siteLogo, setSiteLogo] = useState(''); const [qrCodeModalOpen, setQrCodeModalOpen] = useState(false); - const [currentPaymentInfo, setCurrentPaymentInfo] = useState<{ price: number; credits: number; qrCode: string } | null>(null); + const [currentPaymentInfo, setCurrentPaymentInfo] = useState<{ price: number; credits: number; qrCode: string; method: string } | null>(null); + const [paymentMethod, setPaymentMethod] = useState('alipay'); + const [paying, setPaying] = useState(false); + const pollingTimerRef = useRef | null>(null); // 监听预览弹窗状态,关闭浮动按钮 useEffect(() => { @@ -173,6 +177,43 @@ const AppLayout: React.FC = () => { setRechargeModalOpen(true); }; + const stopPolling = useCallback(() => { + if (pollingTimerRef.current) { + clearInterval(pollingTimerRef.current); + pollingTimerRef.current = null; + } + }, []); + + const startPolling = useCallback((orderNo: string) => { + stopPolling(); + let attempts = 0; + const maxAttempts = 120; // 2 minutes at 1s interval + const timer = setInterval(async () => { + attempts++; + if (attempts > maxAttempts) { + clearInterval(timer); + pollingTimerRef.current = null; + return; + } + try { + const orders = await getPaymentOrders(); + const order = orders.find((o: any) => o.order_no === orderNo); + if (order && order.status === 'paid') { + clearInterval(timer); + pollingTimerRef.current = null; + message.success('支付成功!积分已到账'); + useAuthStore.getState().refreshUser(); + setQrCodeModalOpen(false); + setCurrentPaymentInfo(null); + setSelectedPlan(null); + } + } catch { + // ignore polling errors + } + }, 1000); + pollingTimerRef.current = timer; + }, [stopPolling]); + return ( {/* Desktop Sidebar */} @@ -516,28 +557,64 @@ const AppLayout: React.FC = () => { ); })} -
+ + {/* Payment method selection */} +
+ 选择支付方式 + setPaymentMethod(e.target.value)} + style={{ display: 'flex', gap: 12 }}> + + + 支付宝 + + + + 微信支付 + + +
+ +
-
{/* Footer Buttons */} -
+
-
From 429a7cf5610bcbe123040b1a130ea5628642beaa Mon Sep 17 00:00:00 2001 From: wwwwwwwww <526125649@qq.com> Date: Wed, 10 Jun 2026 11:38:47 +0800 Subject: [PATCH 07/68] =?UTF-8?q?=E4=BF=AE=E6=94=B9qrcode=E5=8C=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- video-gen-app/package-lock.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/video-gen-app/package-lock.json b/video-gen-app/package-lock.json index 48e91e00..00e61fb0 100644 --- a/video-gen-app/package-lock.json +++ b/video-gen-app/package-lock.json @@ -4000,7 +4000,7 @@ }, "node_modules/qrcode.react": { "version": "4.2.0", - "resolved": "https://registry.npmmirror.com/qrcode.react/-/qrcode.react-4.2.0.tgz", + "resolved": "https://registry.npmjs.org/qrcode.react/-/qrcode.react-4.2.0.tgz", "integrity": "sha512-QpgqWi8rD9DsS9EP3z7BT+5lY5SFhsqGjpgW5DY/i3mK4M9DTBNz3ErMi8BWYEfI3L0d8GIbGmcdFAS1uIRGjA==", "license": "ISC", "peerDependencies": { From de494060ad5b468b65e1f280f55204b60f00e9a3 Mon Sep 17 00:00:00 2001 From: wwwwwwwww <526125649@qq.com> Date: Wed, 10 Jun 2026 11:49:41 +0800 Subject: [PATCH 08/68] =?UTF-8?q?=E4=BF=AE=E6=94=B9=E5=89=8D=E5=8F=B0?= =?UTF-8?q?=E5=8A=A8=E6=80=81=E8=8E=B7=E5=8F=96=E6=94=AF=E4=BB=98=E6=98=AF?= =?UTF-8?q?=E5=90=A6=E5=BC=80=E5=90=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- video-gen-api/app/api/v1/payments.py | 40 ++++++++--- video-gen-api/app/services/payment.py | 20 ++++-- video-gen-app/src/api/index.ts | 4 ++ .../src/components/Layout/AppLayout.tsx | 67 ++++++++++++------- 4 files changed, 94 insertions(+), 37 deletions(-) diff --git a/video-gen-api/app/api/v1/payments.py b/video-gen-api/app/api/v1/payments.py index 3f606892..4fc6f89a 100644 --- a/video-gen-api/app/api/v1/payments.py +++ b/video-gen-api/app/api/v1/payments.py @@ -21,6 +21,17 @@ from app.services.payment import ( router = APIRouter(prefix="/payments", tags=["payments"]) +@router.get("/methods") +async def get_payment_methods(db: AsyncSession = Depends(get_db)): + """Return which payment methods are enabled (from admin config).""" + from app.services.payment import _get_payment_configs + configs = await _get_payment_configs(db) + return { + "alipay": configs.get("payment_alipay_enabled", "").lower() == "true", + "wechat": configs.get("payment_wechat_enabled", "").lower() == "true", + } + + @router.post("/recharge", response_model=PaymentOrderOut) async def recharge( req: RechargeRequest, @@ -30,6 +41,14 @@ async def recharge( if req.method not in ("wechat", "alipay"): raise HTTPException(status_code=400, detail="不支持的支付方式") + # Check if the selected payment method is enabled in admin config + from app.services.payment import _get_payment_configs, _is_mock_mode + configs = await _get_payment_configs(db) + if not _is_mock_mode(configs): + enabled_key = f"payment_{req.method}_enabled" + if configs.get(enabled_key, "").lower() != "true": + raise HTTPException(status_code=400, detail="该支付方式未启用") + result = await db.execute( select(RechargePackage).where( RechargePackage.id == req.plan, @@ -40,15 +59,18 @@ async def recharge( pkg = result.scalar_one_or_none() if not pkg: raise HTTPException(status_code=400, detail="无效的套餐") - order = await create_recharge_order( - db, - current_user.id, - credits=pkg.credits, - price=pkg.price, - label=pkg.name, - bonus_credits=pkg.bonus_credits, - method=req.method, - ) + try: + order = await create_recharge_order( + db, + current_user.id, + credits=pkg.credits, + price=pkg.price, + label=pkg.name, + bonus_credits=pkg.bonus_credits, + method=req.method, + ) + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) return order diff --git a/video-gen-api/app/services/payment.py b/video-gen-api/app/services/payment.py index 9261cb36..bd388e86 100644 --- a/video-gen-api/app/services/payment.py +++ b/video-gen-api/app/services/payment.py @@ -97,6 +97,22 @@ async def create_recharge_order( Returns the order; for Alipay the ``qr_url`` attribute will be populated with the scan-to-pay URL. """ + # Read config from database first + db_configs = await _get_payment_configs(db) + mock_mode = _is_mock_mode(db_configs) + + # In real mode, validate that the payment method is enabled and configured + if not mock_mode: + enabled_key = f"payment_{method}_enabled" + if db_configs.get(enabled_key, "").lower() != "true": + raise ValueError("该支付方式未启用,请联系管理员") + if method == "alipay": + if not db_configs.get("payment_alipay_app_id") or not db_configs.get("payment_alipay_private_key"): + raise ValueError("支付宝支付未完成配置,请联系管理员") + elif method == "wechat": + if not db_configs.get("payment_wechat_mch_id") or not db_configs.get("payment_wechat_api_key"): + raise ValueError("微信支付未完成配置,请联系管理员") + total_credits = credits + bonus_credits order = PaymentOrder( id=generate_id(), @@ -110,10 +126,6 @@ async def create_recharge_order( db.add(order) await db.flush() - # Read config from database - db_configs = await _get_payment_configs(db) - mock_mode = _is_mock_mode(db_configs) - if mock_mode: # Mock: immediately complete payment order.status = "paid" diff --git a/video-gen-app/src/api/index.ts b/video-gen-app/src/api/index.ts index fad28707..08635b16 100644 --- a/video-gen-app/src/api/index.ts +++ b/video-gen-app/src/api/index.ts @@ -319,6 +319,10 @@ export async function getRechargePackages(): Promise { return api.get('/recharge-packages'); } +export async function getPaymentMethods(): Promise<{ alipay: boolean; wechat: boolean }> { + return api.get('/payments/methods'); +} + export async function createRechargeOrder(planId: string, method: string = 'wechat'): Promise { return api.post('/payments/recharge', { plan: planId, method }); } diff --git a/video-gen-app/src/components/Layout/AppLayout.tsx b/video-gen-app/src/components/Layout/AppLayout.tsx index aca844bc..29d2e9c9 100644 --- a/video-gen-app/src/components/Layout/AppLayout.tsx +++ b/video-gen-app/src/components/Layout/AppLayout.tsx @@ -27,7 +27,7 @@ import { } from '@ant-design/icons'; import { Outlet, useNavigate, useLocation } from 'react-router-dom'; import { useAuthStore } from '../../store/useAuthStore'; -import { getMenuConfigs, getRechargePackages, createRechargeOrder, getPaymentOrders, getNotifications, markNotificationRead, getSiteInfo } from '../../api'; +import { getMenuConfigs, getRechargePackages, getPaymentMethods, createRechargeOrder, getPaymentOrders, getNotifications, markNotificationRead, getSiteInfo } from '../../api'; import NotificationPopup from '../NotificationPopup'; interface MenuConfig { @@ -93,6 +93,7 @@ const AppLayout: React.FC = () => { const [paymentMethod, setPaymentMethod] = useState('alipay'); const [paying, setPaying] = useState(false); const pollingTimerRef = useRef | null>(null); + const [enabledMethods, setEnabledMethods] = useState<{ alipay: boolean; wechat: boolean }>({ alipay: false, wechat: false }); // 监听预览弹窗状态,关闭浮动按钮 useEffect(() => { @@ -140,6 +141,12 @@ const AppLayout: React.FC = () => { getRechargePackages().then(data => { setRechargeOptions(data.filter((p: any) => p.is_active !== false && p.isActive !== false)); }).catch(() => {}); + getPaymentMethods().then(data => { + setEnabledMethods(data); + // Auto-select the first enabled method + if (data.alipay) setPaymentMethod('alipay'); + else if (data.wechat) setPaymentMethod('wechat'); + }).catch(() => {}); loadNotifications(); }, [user]); @@ -559,32 +566,44 @@ const AppLayout: React.FC = () => {
{/* Payment method selection */} -
- 选择支付方式 - setPaymentMethod(e.target.value)} - style={{ display: 'flex', gap: 12 }}> - - - 支付宝 - - - - 微信支付 - - -
+ {(!enabledMethods.alipay && !enabledMethods.wechat) ? ( +
+ + ⚠️ 暂无可用的支付方式,请联系管理员开启支付功能 + +
+ ) : ( +
+ 选择支付方式 + setPaymentMethod(e.target.value)} + style={{ display: 'flex', gap: 12 }}> + {enabledMethods.alipay && ( + + + 支付宝 + + )} + {enabledMethods.wechat && ( + + + 微信支付 + + )} + +
+ )}
-