diff --git a/video-gen-api/alembic/versions/n123456789ab_add_notification_indexes.py b/video-gen-api/alembic/versions/n123456789ab_add_notification_indexes.py index eecbe7e3..7213f42f 100644 --- a/video-gen-api/alembic/versions/n123456789ab_add_notification_indexes.py +++ b/video-gen-api/alembic/versions/n123456789ab_add_notification_indexes.py @@ -1,8 +1,8 @@ -"""add notification indexes for query optimization +"""add core business indexes and contact submit_date Revision ID: n123456789ab Revises: c72a6f69e641 -Create Date: 2026-06-29 12:00:00.000000 +Create Date: 2026-06-29 14:00:00.000000 """ from typing import Sequence, Union @@ -10,7 +10,6 @@ from alembic import op import sqlalchemy as sa -# revision identifiers, used by Alembic. revision: str = "n123456789ab" down_revision: Union[str, None] = "c72a6f69e641" branch_labels: Union[str, Sequence[str], None] = None @@ -18,7 +17,120 @@ depends_on: Union[str, Sequence[str], None] = None def upgrade() -> None: - # Composite index for personal notifications: (user_id, is_read, created_at) + # 1. Add submit_date column to contact_requests + op.add_column( + "contact_requests", + sa.Column("submit_date", sa.String(10), nullable=True) + ) + + # Backfill submit_date from created_at for existing records + op.execute( + "UPDATE contact_requests SET submit_date = TO_CHAR(created_at AT TIME ZONE 'UTC', 'YYYY-MM-DD') WHERE submit_date IS NULL" + ) + + # Set NOT NULL after backfill + op.alter_column("contact_requests", "submit_date", nullable=False) + + # Create index on submit_date + op.create_index( + "ix_contact_requests_submit_date", + "contact_requests", + ["submit_date"], + unique=False, + ) + + # 2. Contact requests unique constraint: (user_id, submit_date) + op.create_unique_constraint( + "uq_contact_user_date", + "contact_requests", + ["user_id", "submit_date"], + ) + + # 3. Contact requests composite indexes + op.create_index( + "idx_contact_handled_created", + "contact_requests", + ["is_handled", "created_at"], + unique=False, + ) + op.create_index( + "idx_contact_user_date_created", + "contact_requests", + ["user_id", "submit_date", "created_at"], + unique=False, + ) + + # 4. Generation records composite indexes + op.create_index( + "idx_genrec_user_status_created", + "generation_records", + ["user_id", "status", "created_at"], + unique=False, + ) + op.create_index( + "idx_genrec_project_status", + "generation_records", + ["project_id", "status"], + unique=False, + ) + + # 5. Payment orders composite indexes + op.create_index( + "idx_payorder_user_status_created", + "payment_orders", + ["user_id", "status", "created_at"], + unique=False, + ) + op.create_index( + "idx_payorder_status_created", + "payment_orders", + ["status", "created_at"], + unique=False, + ) + + # 6. Upload task composite indexes + op.create_index( + "idx_upload_user_status_created", + "upload_task", + ["user_id", "status", "created_at"], + unique=False, + ) + op.create_index( + "idx_upload_oauth_status", + "upload_task", + ["oauth_id", "status"], + unique=False, + ) + + # 7. Menu configs indexes + op.create_index( + op.f("ix_menu_configs_parent_id"), + "menu_configs", + ["parent_id"], + unique=False, + ) + op.create_index( + "idx_menu_target_active_sort", + "menu_configs", + ["menu_target", "is_active", "sort_order"], + unique=False, + ) + + # 8. Operation logs composite indexes + op.create_index( + "idx_oplog_user_created", + "operation_logs", + ["user_id", "created_at"], + unique=False, + ) + op.create_index( + "idx_oplog_created", + "operation_logs", + ["created_at"], + unique=False, + ) + + # 9. Notification indexes (from previous optimization) op.create_index( "idx_notif_user_isread_created", "notifications", @@ -26,16 +138,13 @@ def upgrade() -> None: unique=False, ) - # Composite index for notification_reads lookups + # 10. Notification reads indexes op.create_index( "idx_notif_read_user_notif", "notification_reads", ["user_id", "notification_id"], unique=False, ) - - # Unique constraint to prevent duplicate read records - # Check if constraint already exists first (in case of partial migration) op.create_unique_constraint( "uq_notif_read_user", "notification_reads", @@ -47,3 +156,18 @@ def downgrade() -> None: op.drop_constraint("uq_notif_read_user", "notification_reads", type_="unique") op.drop_index("idx_notif_read_user_notif", table_name="notification_reads") op.drop_index("idx_notif_user_isread_created", table_name="notifications") + op.drop_index("idx_oplog_created", table_name="operation_logs") + op.drop_index("idx_oplog_user_created", table_name="operation_logs") + op.drop_index("idx_menu_target_active_sort", table_name="menu_configs") + op.drop_index(op.f("ix_menu_configs_parent_id"), table_name="menu_configs") + op.drop_index("idx_upload_oauth_status", table_name="upload_task") + op.drop_index("idx_upload_user_status_created", table_name="upload_task") + op.drop_index("idx_payorder_status_created", table_name="payment_orders") + op.drop_index("idx_payorder_user_status_created", table_name="payment_orders") + op.drop_index("idx_genrec_project_status", table_name="generation_records") + op.drop_index("idx_genrec_user_status_created", table_name="generation_records") + op.drop_index("idx_contact_user_date_created", table_name="contact_requests") + op.drop_index("idx_contact_handled_created", table_name="contact_requests") + op.drop_constraint("uq_contact_user_date", "contact_requests", type_="unique") + op.drop_index("ix_contact_requests_submit_date", table_name="contact_requests") + op.drop_column("contact_requests", "submit_date") diff --git a/video-gen-api/app/api/v1/contact.py b/video-gen-api/app/api/v1/contact.py index 66b43b5e..bfb10121 100644 --- a/video-gen-api/app/api/v1/contact.py +++ b/video-gen-api/app/api/v1/contact.py @@ -2,6 +2,7 @@ from datetime import datetime, timezone, timedelta from fastapi import APIRouter, Depends, HTTPException, status from sqlalchemy import func, select +from sqlalchemy.exc import IntegrityError from sqlalchemy.ext.asyncio import AsyncSession from app.dependencies import get_db, get_current_user @@ -19,37 +20,48 @@ async def create_contact_request( db: AsyncSession = Depends(get_db), user: User = Depends(get_current_user), ): - today_start = datetime.now(timezone.utc).replace(hour=0, minute=0, second=0, microsecond=0) - today_end = today_start + timedelta(days=1) + today_str = datetime.now(timezone.utc).strftime("%Y-%m-%d") - count = await db.execute( - select(func.count(ContactRequest.id)) - .where(ContactRequest.user_id == user.id) - .where(ContactRequest.created_at >= today_start) - .where(ContactRequest.created_at < today_end) - ) - daily_count = count.scalar_one() + async with db.begin_nested(): + user_result = await db.execute( + select(User).where(User.id == user.id).with_for_update().limit(1) + ) + locked_user = user_result.scalar_one_or_none() + if not locked_user: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="用户不存在") + + existing = await db.execute( + select(ContactRequest.id) + .where(ContactRequest.user_id == user.id) + .where(ContactRequest.submit_date == today_str) + .limit(1) + ) + if existing.scalar_one_or_none(): + raise HTTPException( + status_code=status.HTTP_429_TOO_MANY_REQUESTS, + detail="每个账号每天只能提交一次联系我们" + ) - if daily_count >= 1: + try: + contact_request = ContactRequest( + id=generate_id(), + user_id=user.id, + phone=request.phone, + company_name=request.company_name, + industry=request.industry, + name=request.name, + message=request.message, + submit_date=today_str, + ) + db.add(contact_request) + await db.commit() + except IntegrityError: + await db.rollback() raise HTTPException( status_code=status.HTTP_429_TOO_MANY_REQUESTS, detail="每个账号每天只能提交一次联系我们" ) - contact_request = ContactRequest( - id=generate_id(), - user_id=user.id, - phone=request.phone, - company_name=request.company_name, - industry=request.industry, - name=request.name, - message=request.message, - ) - - db.add(contact_request) - await db.commit() - await db.refresh(contact_request) - return {"message": "提交成功,我们会尽快与您联系"} @@ -64,17 +76,20 @@ async def get_contact_requests( if not user.is_admin: raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="无权限") - query = select(ContactRequest).order_by(ContactRequest.created_at.desc()) + query = select(ContactRequest) + count_query = select(func.count(ContactRequest.id)) if is_handled is not None: query = query.where(ContactRequest.is_handled == is_handled) + count_query = count_query.where(ContactRequest.is_handled == is_handled) + + query = query.order_by(ContactRequest.created_at.desc()) offset = (page - 1) * page_size result = await db.execute(query.offset(offset).limit(page_size)) items = result.scalars().all() - count_result = await db.execute(select(func.count(ContactRequest.id))) - total = count_result.scalar_one() + total = (await db.execute(count_query)).scalar_one() return {"items": items, "total": total} diff --git a/video-gen-api/app/api/v1/payments.py b/video-gen-api/app/api/v1/payments.py index 376e9f47..60c12a77 100644 --- a/video-gen-api/app/api/v1/payments.py +++ b/video-gen-api/app/api/v1/payments.py @@ -127,30 +127,32 @@ async def wechat_callback(request: Request, db: AsyncSession = Depends(get_db)): signature = headers.get("wechatpay-signature", "") serial_no = headers.get("wechatpay-serial", "") + # 安全要求:非mock模式下必须验证签名,配置缺失直接拒绝 + if not public_key: + logger.error("WeChat platform public key not configured, cannot verify callback signature") + return {"code": "FAIL", "message": "Platform public key not configured"} + if not serial_no: + logger.error("Wechatpay-Serial header missing in callback") + return {"code": "FAIL", "message": "Missing Wechatpay-Serial header"} + if not timestamp or not nonce or not signature: + logger.error("WeChat callback missing required signature headers") + return {"code": "FAIL", "message": "Missing signature headers"} # 验证签名:使用平台公钥验证 - if public_key and serial_no: - try: - # 构造签名串:timestamp + "\n" + nonce + "\n" + body + "\n" - # 符合微信支付官方文档规范:https://pay.weixin.qq.com/doc/v3/merchant/4013053249 - is_verified = rsa_verify( - timestamp=timestamp, - nonce=nonce, - body=body_str, - signature=signature, - public_key=load_public_key(public_key) - ) - if not is_verified: - logger.warning(f"WeChat callback signature verification failed: serial={serial_no}") - raise HTTPException(status_code=400, detail="签名验证失败") - except Exception as e: - logger.warning(f"WeChat signature verification error: {e}, serial={serial_no}") - raise HTTPException(status_code=400, detail="签名验证失败") - else: - if not public_key: - logger.warning("WeChat platform public key not configured, skipping signature verification") - if not serial_no: - logger.warning("Wechatpay-Serial header missing, skipping signature verification") + try: + is_verified = rsa_verify( + timestamp=timestamp, + nonce=nonce, + body=body_str, + signature=signature, + public_key=load_public_key(public_key) + ) + if not is_verified: + logger.warning(f"WeChat callback signature verification failed: serial={serial_no}") + return {"code": "FAIL", "message": "Signature verification failed"} + except Exception as e: + logger.warning(f"WeChat signature verification error: {e}, serial={serial_no}") + return {"code": "FAIL", "message": "Signature verification error"} # 解密回调数据:使用 API v3 key # 官方文档:https://pay.weixin.qq.com/doc/v3/merchant/4012071382 diff --git a/video-gen-api/app/models/contact_request.py b/video-gen-api/app/models/contact_request.py index d11a74c4..5eda6556 100644 --- a/video-gen-api/app/models/contact_request.py +++ b/video-gen-api/app/models/contact_request.py @@ -1,4 +1,4 @@ -from sqlalchemy import Boolean, ForeignKey, String, Text +from sqlalchemy import Boolean, ForeignKey, String, Text, UniqueConstraint, Index from sqlalchemy.orm import Mapped, mapped_column from app.models.base import Base, TimestampMixin @@ -14,4 +14,11 @@ class ContactRequest(Base, TimestampMixin): industry: Mapped[str] = mapped_column(String(64)) name: Mapped[str] = mapped_column(String(64)) message: Mapped[str | None] = mapped_column(Text, nullable=True) - is_handled: Mapped[bool] = mapped_column(Boolean, default=False) \ No newline at end of file + is_handled: Mapped[bool] = mapped_column(Boolean, default=False) + submit_date: Mapped[str] = mapped_column(String(10), index=True) + + __table_args__ = ( + UniqueConstraint('user_id', 'submit_date', name='uq_contact_user_date'), + Index('idx_contact_handled_created', 'is_handled', 'created_at'), + Index('idx_contact_user_date_created', 'user_id', 'submit_date', 'created_at'), + ) \ No newline at end of file diff --git a/video-gen-api/app/models/generation_record.py b/video-gen-api/app/models/generation_record.py index cb8fdb2b..0aa585eb 100644 --- a/video-gen-api/app/models/generation_record.py +++ b/video-gen-api/app/models/generation_record.py @@ -1,6 +1,6 @@ from datetime import datetime -from sqlalchemy import DateTime, ForeignKey, Integer, String, Text, Float +from sqlalchemy import DateTime, ForeignKey, Integer, String, Text, Float, Index from sqlalchemy.orm import Mapped, mapped_column from app.models.base import Base, TimestampMixin, SoftDeleteMixin @@ -45,3 +45,8 @@ class GenerationRecord(Base, TimestampMixin, SoftDeleteMixin): ) error_message: Mapped[str | None] = mapped_column(Text, nullable=True) idempotency_key: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True) + + __table_args__ = ( + Index('idx_genrec_user_status_created', 'user_id', 'status', 'created_at'), + Index('idx_genrec_project_status', 'project_id', 'status'), + ) diff --git a/video-gen-api/app/models/menu_config.py b/video-gen-api/app/models/menu_config.py index 537894d0..9e0208d9 100644 --- a/video-gen-api/app/models/menu_config.py +++ b/video-gen-api/app/models/menu_config.py @@ -1,4 +1,4 @@ -from sqlalchemy import Boolean, Integer, String, ForeignKey +from sqlalchemy import Boolean, Integer, String, ForeignKey, Index from sqlalchemy.orm import Mapped, mapped_column from app.models.base import Base, TimestampMixin @@ -13,7 +13,11 @@ class MenuConfig(Base, TimestampMixin): icon: Mapped[str] = mapped_column(String(64), default="") sort_order: Mapped[int] = mapped_column(Integer, default=0) is_active: Mapped[bool] = mapped_column(Boolean, default=True) - parent_id: Mapped[str | None] = mapped_column(String(32), ForeignKey("menu_configs.id"), nullable=True) - menu_type: Mapped[str] = mapped_column(String(16), default="page") # page / group - menu_target: Mapped[str] = mapped_column(String(16), default="frontend") # frontend / admin / both - is_default: Mapped[bool] = mapped_column(Boolean, default=False) # default show for new users + parent_id: Mapped[str | None] = mapped_column(String(32), ForeignKey("menu_configs.id"), nullable=True, index=True) + menu_type: Mapped[str] = mapped_column(String(16), default="page") + menu_target: Mapped[str] = mapped_column(String(16), default="frontend") + is_default: Mapped[bool] = mapped_column(Boolean, default=False) + + __table_args__ = ( + Index('idx_menu_target_active_sort', 'menu_target', 'is_active', 'sort_order'), + ) diff --git a/video-gen-api/app/models/operation_log.py b/video-gen-api/app/models/operation_log.py index 819a0541..25249a4f 100644 --- a/video-gen-api/app/models/operation_log.py +++ b/video-gen-api/app/models/operation_log.py @@ -1,4 +1,4 @@ -from sqlalchemy import Integer, String, Text +from sqlalchemy import Integer, String, Text, Index from sqlalchemy.orm import Mapped, mapped_column from app.models.base import Base, TimestampMixin @@ -10,8 +10,13 @@ class OperationLog(Base, TimestampMixin): id: Mapped[str] = mapped_column(String(32), primary_key=True) user_id: Mapped[str] = mapped_column(String(32), index=True) username: Mapped[str] = mapped_column(String(64)) - action: Mapped[str] = mapped_column(String(128)) # e.g. "创建用户", "修改菜单" - method: Mapped[str] = mapped_column(String(10)) # POST/PUT/DELETE + action: Mapped[str] = mapped_column(String(128)) + method: Mapped[str] = mapped_column(String(10)) path: Mapped[str] = mapped_column(String(256)) - detail: Mapped[str | None] = mapped_column(Text, nullable=True) # JSON detail + detail: Mapped[str | None] = mapped_column(Text, nullable=True) ip: Mapped[str | None] = mapped_column(String(64), nullable=True) + + __table_args__ = ( + Index('idx_oplog_user_created', 'user_id', 'created_at'), + Index('idx_oplog_created', 'created_at'), + ) diff --git a/video-gen-api/app/models/payment_order.py b/video-gen-api/app/models/payment_order.py index d378d7ee..c9da1ea4 100644 --- a/video-gen-api/app/models/payment_order.py +++ b/video-gen-api/app/models/payment_order.py @@ -1,6 +1,6 @@ from datetime import datetime -from sqlalchemy import DateTime, Float, ForeignKey, Integer, String +from sqlalchemy import DateTime, Float, ForeignKey, Integer, String, Index from sqlalchemy.orm import Mapped, mapped_column from app.models.base import Base, TimestampMixin @@ -22,9 +22,13 @@ class PaymentOrder(Base, TimestampMixin): DateTime(timezone=True), nullable=True ) trade_no: Mapped[str | None] = mapped_column(String(128), nullable=True) - # Refund fields refund_trade_no: Mapped[str | None] = mapped_column(String(128), nullable=True) refunded_at: Mapped[datetime | None] = mapped_column( DateTime(timezone=True), nullable=True ) refund_amount: Mapped[float | None] = mapped_column(Float, nullable=True) + + __table_args__ = ( + Index('idx_payorder_user_status_created', 'user_id', 'status', 'created_at'), + Index('idx_payorder_status_created', 'status', 'created_at'), + ) diff --git a/video-gen-api/app/models/upload_task.py b/video-gen-api/app/models/upload_task.py index d56958ed..1661b58f 100644 --- a/video-gen-api/app/models/upload_task.py +++ b/video-gen-api/app/models/upload_task.py @@ -1,4 +1,4 @@ -from sqlalchemy import String, Text, Integer +from sqlalchemy import String, Text, Integer, Index from sqlalchemy.orm import Mapped, mapped_column from app.models.base import Base, TimestampMixin, SoftDeleteMixin @@ -31,3 +31,8 @@ class UploadTask(Base, TimestampMixin, SoftDeleteMixin): other_info: Mapped[str | None] = mapped_column( String(500), nullable=True, comment="其他信息" ) + + __table_args__ = ( + Index('idx_upload_user_status_created', 'user_id', 'status', 'created_at'), + Index('idx_upload_oauth_status', 'oauth_id', 'status'), + )