1、微信回调签名验证绕过问题
2、联系请求竞态条件问题
3、contact列表count过滤bug
4、核心业务表索引添加
This commit is contained in:
2026-06-29 10:15:10 +08:00
parent b21f35582e
commit 92fb64aa99
9 changed files with 243 additions and 72 deletions
@@ -1,8 +1,8 @@
"""add notification indexes for query optimization """add core business indexes and contact submit_date
Revision ID: n123456789ab Revision ID: n123456789ab
Revises: c72a6f69e641 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 from typing import Sequence, Union
@@ -10,7 +10,6 @@ from alembic import op
import sqlalchemy as sa import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = "n123456789ab" revision: str = "n123456789ab"
down_revision: Union[str, None] = "c72a6f69e641" down_revision: Union[str, None] = "c72a6f69e641"
branch_labels: Union[str, Sequence[str], None] = None branch_labels: Union[str, Sequence[str], None] = None
@@ -18,7 +17,120 @@ depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> 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( op.create_index(
"idx_notif_user_isread_created", "idx_notif_user_isread_created",
"notifications", "notifications",
@@ -26,16 +138,13 @@ def upgrade() -> None:
unique=False, unique=False,
) )
# Composite index for notification_reads lookups # 10. Notification reads indexes
op.create_index( op.create_index(
"idx_notif_read_user_notif", "idx_notif_read_user_notif",
"notification_reads", "notification_reads",
["user_id", "notification_id"], ["user_id", "notification_id"],
unique=False, unique=False,
) )
# Unique constraint to prevent duplicate read records
# Check if constraint already exists first (in case of partial migration)
op.create_unique_constraint( op.create_unique_constraint(
"uq_notif_read_user", "uq_notif_read_user",
"notification_reads", "notification_reads",
@@ -47,3 +156,18 @@ def downgrade() -> None:
op.drop_constraint("uq_notif_read_user", "notification_reads", type_="unique") 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_read_user_notif", table_name="notification_reads")
op.drop_index("idx_notif_user_isread_created", table_name="notifications") 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")
+42 -27
View File
@@ -2,6 +2,7 @@ from datetime import datetime, timezone, timedelta
from fastapi import APIRouter, Depends, HTTPException, status from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy import func, select from sqlalchemy import func, select
from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.dependencies import get_db, get_current_user from app.dependencies import get_db, get_current_user
@@ -19,37 +20,48 @@ async def create_contact_request(
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
user: User = Depends(get_current_user), user: User = Depends(get_current_user),
): ):
today_start = datetime.now(timezone.utc).replace(hour=0, minute=0, second=0, microsecond=0) today_str = datetime.now(timezone.utc).strftime("%Y-%m-%d")
today_end = today_start + timedelta(days=1)
count = await db.execute( async with db.begin_nested():
select(func.count(ContactRequest.id)) user_result = await db.execute(
.where(ContactRequest.user_id == user.id) select(User).where(User.id == user.id).with_for_update().limit(1)
.where(ContactRequest.created_at >= today_start) )
.where(ContactRequest.created_at < today_end) locked_user = user_result.scalar_one_or_none()
) if not locked_user:
daily_count = count.scalar_one() 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( raise HTTPException(
status_code=status.HTTP_429_TOO_MANY_REQUESTS, status_code=status.HTTP_429_TOO_MANY_REQUESTS,
detail="每个账号每天只能提交一次联系我们" 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": "提交成功,我们会尽快与您联系"} return {"message": "提交成功,我们会尽快与您联系"}
@@ -64,17 +76,20 @@ async def get_contact_requests(
if not user.is_admin: if not user.is_admin:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="无权限") 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: if is_handled is not None:
query = query.where(ContactRequest.is_handled == is_handled) 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 offset = (page - 1) * page_size
result = await db.execute(query.offset(offset).limit(page_size)) result = await db.execute(query.offset(offset).limit(page_size))
items = result.scalars().all() items = result.scalars().all()
count_result = await db.execute(select(func.count(ContactRequest.id))) total = (await db.execute(count_query)).scalar_one()
total = count_result.scalar_one()
return {"items": items, "total": total} return {"items": items, "total": total}
+24 -22
View File
@@ -127,30 +127,32 @@ async def wechat_callback(request: Request, db: AsyncSession = Depends(get_db)):
signature = headers.get("wechatpay-signature", "") signature = headers.get("wechatpay-signature", "")
serial_no = headers.get("wechatpay-serial", "") 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:
try: is_verified = rsa_verify(
# 构造签名串:timestamp + "\n" + nonce + "\n" + body + "\n" timestamp=timestamp,
# 符合微信支付官方文档规范:https://pay.weixin.qq.com/doc/v3/merchant/4013053249 nonce=nonce,
is_verified = rsa_verify( body=body_str,
timestamp=timestamp, signature=signature,
nonce=nonce, public_key=load_public_key(public_key)
body=body_str, )
signature=signature, if not is_verified:
public_key=load_public_key(public_key) logger.warning(f"WeChat callback signature verification failed: serial={serial_no}")
) return {"code": "FAIL", "message": "Signature verification failed"}
if not is_verified: except Exception as e:
logger.warning(f"WeChat callback signature verification failed: serial={serial_no}") logger.warning(f"WeChat signature verification error: {e}, serial={serial_no}")
raise HTTPException(status_code=400, detail="签名验证失败") return {"code": "FAIL", "message": "Signature verification error"}
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")
# 解密回调数据:使用 API v3 key # 解密回调数据:使用 API v3 key
# 官方文档:https://pay.weixin.qq.com/doc/v3/merchant/4012071382 # 官方文档:https://pay.weixin.qq.com/doc/v3/merchant/4012071382
+9 -2
View File
@@ -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 sqlalchemy.orm import Mapped, mapped_column
from app.models.base import Base, TimestampMixin from app.models.base import Base, TimestampMixin
@@ -14,4 +14,11 @@ class ContactRequest(Base, TimestampMixin):
industry: Mapped[str] = mapped_column(String(64)) industry: Mapped[str] = mapped_column(String(64))
name: Mapped[str] = mapped_column(String(64)) name: Mapped[str] = mapped_column(String(64))
message: Mapped[str | None] = mapped_column(Text, nullable=True) message: Mapped[str | None] = mapped_column(Text, nullable=True)
is_handled: Mapped[bool] = mapped_column(Boolean, default=False) 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'),
)
@@ -1,6 +1,6 @@
from datetime import datetime 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 sqlalchemy.orm import Mapped, mapped_column
from app.models.base import Base, TimestampMixin, SoftDeleteMixin 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) error_message: Mapped[str | None] = mapped_column(Text, nullable=True)
idempotency_key: Mapped[str | None] = mapped_column(String(64), nullable=True, index=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'),
)
+9 -5
View File
@@ -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 sqlalchemy.orm import Mapped, mapped_column
from app.models.base import Base, TimestampMixin from app.models.base import Base, TimestampMixin
@@ -13,7 +13,11 @@ class MenuConfig(Base, TimestampMixin):
icon: Mapped[str] = mapped_column(String(64), default="") icon: Mapped[str] = mapped_column(String(64), default="")
sort_order: Mapped[int] = mapped_column(Integer, default=0) sort_order: Mapped[int] = mapped_column(Integer, default=0)
is_active: Mapped[bool] = mapped_column(Boolean, default=True) is_active: Mapped[bool] = mapped_column(Boolean, default=True)
parent_id: Mapped[str | None] = mapped_column(String(32), ForeignKey("menu_configs.id"), nullable=True) 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") # page / group menu_type: Mapped[str] = mapped_column(String(16), default="page")
menu_target: Mapped[str] = mapped_column(String(16), default="frontend") # frontend / admin / both menu_target: Mapped[str] = mapped_column(String(16), default="frontend")
is_default: Mapped[bool] = mapped_column(Boolean, default=False) # default show for new users is_default: Mapped[bool] = mapped_column(Boolean, default=False)
__table_args__ = (
Index('idx_menu_target_active_sort', 'menu_target', 'is_active', 'sort_order'),
)
+9 -4
View File
@@ -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 sqlalchemy.orm import Mapped, mapped_column
from app.models.base import Base, TimestampMixin 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) id: Mapped[str] = mapped_column(String(32), primary_key=True)
user_id: Mapped[str] = mapped_column(String(32), index=True) user_id: Mapped[str] = mapped_column(String(32), index=True)
username: Mapped[str] = mapped_column(String(64)) username: Mapped[str] = mapped_column(String(64))
action: Mapped[str] = mapped_column(String(128)) # e.g. "创建用户", "修改菜单" action: Mapped[str] = mapped_column(String(128))
method: Mapped[str] = mapped_column(String(10)) # POST/PUT/DELETE method: Mapped[str] = mapped_column(String(10))
path: Mapped[str] = mapped_column(String(256)) 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) 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'),
)
+6 -2
View File
@@ -1,6 +1,6 @@
from datetime import datetime 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 sqlalchemy.orm import Mapped, mapped_column
from app.models.base import Base, TimestampMixin from app.models.base import Base, TimestampMixin
@@ -22,9 +22,13 @@ class PaymentOrder(Base, TimestampMixin):
DateTime(timezone=True), nullable=True DateTime(timezone=True), nullable=True
) )
trade_no: Mapped[str | None] = mapped_column(String(128), 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) refund_trade_no: Mapped[str | None] = mapped_column(String(128), nullable=True)
refunded_at: Mapped[datetime | None] = mapped_column( refunded_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True DateTime(timezone=True), nullable=True
) )
refund_amount: Mapped[float | None] = mapped_column(Float, 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'),
)
+6 -1
View File
@@ -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 sqlalchemy.orm import Mapped, mapped_column
from app.models.base import Base, TimestampMixin, SoftDeleteMixin from app.models.base import Base, TimestampMixin, SoftDeleteMixin
@@ -31,3 +31,8 @@ class UploadTask(Base, TimestampMixin, SoftDeleteMixin):
other_info: Mapped[str | None] = mapped_column( other_info: Mapped[str | None] = mapped_column(
String(500), nullable=True, comment="其他信息" 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'),
)