Compare commits

...

2 Commits

Author SHA1 Message Date
CI Bot 17f0ec95d2 style: auto-format with black + isort + ruff + prettier [skip ci-format-check]
CI/CD Pipeline / Check push changed paths (pull_request) Has been skipped
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (pull_request) Successful in 2s
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 2s
CI/CD Pipeline / Frontend Lint (pull_request) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / PR Build Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 48s
CI/CD Pipeline / Retag skipped Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been skipped
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 2m2s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 3m1s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 2m48s
CI/CD Pipeline / Integration Tests (pull_request) Successful in 3m53s
CI/CD Pipeline / Validate - Security (pull_request) Successful in 4m11s
CI/CD Pipeline / Validate - Python (mypy + alembic) (pull_request) Successful in 4m39s
CI/CD Pipeline / Validate - Style (pull_request) Failing after 5m47s
AI Code Review / AI Code Review (pull_request) Successful in 6m38s
CI/CD Pipeline / Unit Tests (pull_request) Failing after 13m18s
CI/CD Pipeline / Build Production API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Canary Release to Production (pull_request) Has been skipped
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / CI Gate (pull_request) Failing after 3s
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 10m33s
2026-10-03 07:41:51 +00:00
Xiaoxia Agent db39feed74 feat(membership): 微信支付年卡购买/回调履约/积分包下单
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (pull_request) Successful in 2s
CI/CD Pipeline / Check push changed paths (pull_request) Has been skipped
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 2s
CI/CD Pipeline / Frontend Lint (pull_request) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / PR Build Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 2m8s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 2m6s
CI/CD Pipeline / Retag skipped Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (pull_request) Has been skipped
PR Automation / Auto Approve on CI Green (pull_request) Successful in 3m12s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Validate - Security (pull_request) Successful in 4m54s
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been skipped
CI/CD Pipeline / Integration Tests (pull_request) Successful in 5m4s
CI/CD Pipeline / Validate - Python (mypy + alembic) (pull_request) Successful in 5m46s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 6m11s
AI Code Review / AI Code Review (pull_request) Successful in 6m46s
CI/CD Pipeline / Build Production API Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Production Web Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been cancelled
CI/CD Pipeline / Deploy Production (pull_request) Has been cancelled
CI/CD Pipeline / Production Browser E2E (pull_request) Has been cancelled
CI/CD Pipeline / Canary Release to Production (pull_request) Has been cancelled
CI/CD Pipeline / CI Gate (pull_request) Has been cancelled
CI/CD Pipeline / Unit Tests (pull_request) Has been cancelled
CI/CD Pipeline / Validate - Style (pull_request) Has been cancelled
PR Automation / Auto Merge on CI Green + Approved (pull_request) Has been cancelled
- 微信支付V3适配层: JSAPI下单、JSAPI调起签名、回调验签、AES-GCM解密、平台证书缓存
- PaymentService: 会员年卡/积分包下单、回调幂等履约(激活365天+年赠12个月积分+流水)
- 新增接口: POST /subscription/create-order, GET /subscription/orders,
  GET /subscription/orders/{id}, POST /points/create-order, POST /payment/wechat/notify
- 积分订单增加out_trade_no等微信字段, 迁移094
- P1: 订阅到期实时判断、取消订阅完善、会员身份实时判断
- requirements增加cryptography
2026-10-03 15:27:50 +08:00
14 changed files with 1404 additions and 68 deletions
@@ -0,0 +1,50 @@
"""points_orders 新增微信支付链路字段
Revision ID: 094_points_orders_wechat_fields
Revises: 093
Create Date: 2026-10-03
新增列:
- prepay_id: 微信预支付ID
- product_name: 下单商品名称(冗余,便于对账)
- payer_openid: 支付者 openid
- expire_at: 订单过期时间(未支付超时关闭用)
"""
import sqlalchemy as sa
from alembic import op
revision = "094_points_orders_wechat_fields"
down_revision = "093"
branch_labels = None
depends_on = None
def upgrade() -> None:
conn = op.get_bind()
cols = {c["name"] for c in sa.inspect(conn).get_columns("points_orders")}
if "out_trade_no" not in cols:
op.add_column("points_orders", sa.Column("out_trade_no", sa.String(64), nullable=True))
op.create_index("ix_points_orders_out_trade_no", "points_orders", ["out_trade_no"])
if "prepay_id" not in cols:
op.add_column("points_orders", sa.Column("prepay_id", sa.String(128), nullable=True))
if "product_name" not in cols:
op.add_column("points_orders", sa.Column("product_name", sa.String(100), nullable=True))
if "payer_openid" not in cols:
op.add_column("points_orders", sa.Column("payer_openid", sa.String(128), nullable=True))
if "expire_at" not in cols:
op.add_column("points_orders", sa.Column("expire_at", sa.DateTime(), nullable=True))
def downgrade() -> None:
conn = op.get_bind()
cols = {c["name"] for c in sa.inspect(conn).get_columns("points_orders")}
try:
op.drop_index("ix_points_orders_out_trade_no", table_name="points_orders")
except Exception:
pass
for col in ("out_trade_no", "prepay_id", "product_name", "payer_openid", "expire_at"):
if col in cols:
op.drop_column("points_orders", col)
+6
View File
@@ -21,6 +21,7 @@ from app.api.routes.health import router as health_check_router
from app.api.routes.ingest_jobs import router as ingest_jobs_router
from app.api.routes.internal_render import router as internal_render_router
from app.api.routes.lipsync import router as lipsync_router
from app.api.routes.payment import router as payment_router
from app.api.routes.points import router as points_router
from app.api.routes.points import usage_router
from app.api.routes.projects import router as projects_router
@@ -226,6 +227,11 @@ api_router.include_router(
prefix="/ai-avatar/render",
tags=["AI Avatar Render"],
)
api_router.include_router(
payment_router,
prefix="/payment",
tags=["Payment"],
)
api_router.include_router(
points_router,
prefix="/points",
+69
View File
@@ -0,0 +1,69 @@
"""微信支付回调路由。
POST /api/v1/payment/wechat/notify
- 不做用户鉴权(微信服务器调用),靠平台证书签名保证来源可信
- 返回微信要求的 JSON:{"code": "SUCCESS", "message": "成功"}
"""
from __future__ import annotations
import logging
from app.dependencies import get_db_session
from fastapi import APIRouter, Depends, Request, Response
from sqlalchemy.orm import Session
from packages.adapters.wechat_cert_store import get_platform_public_key
from packages.adapters.wechat_pay import WeChatPayError
from packages.application.payment_service import PaymentConfigError, PaymentService
logger = logging.getLogger(__name__)
router = APIRouter()
def _wx_response(code: str, message: str, http_status: int = 200) -> Response:
"""构造微信要求的回调应答。"""
import json
return Response(
content=json.dumps({"code": code, "message": message}, ensure_ascii=False),
media_type="application/json",
status_code=http_status,
)
@router.post("/wechat/notify")
async def wechat_pay_notify(
request: Request,
db: Session = Depends(get_db_session),
):
"""微信支付结果通知。
- 验签失败 / 配置缺失 / 解密失败 → 返回 FAIL(微信会重试)
- 履约成功(含重复通知幂等)→ 返回 SUCCESS
"""
body = await request.body()
headers = {k.lower(): v for k, v in request.headers.items()}
svc = PaymentService()
try:
result = svc.handle_wechat_notification(
headers=headers,
body=body,
platform_public_key_loader=get_platform_public_key,
db=db,
)
except PaymentConfigError as e:
logger.error("微信回调时支付配置异常: %s", e)
return _wx_response("ERROR", "支付配置异常", http_status=503)
except WeChatPayError as e:
logger.warning("微信回调处理失败: %s", e)
return _wx_response("FAIL", str(e)[:200])
except Exception: # noqa: BLE001
logger.exception("微信回调未知异常")
return _wx_response("FAIL", "系统繁忙")
if result.get("ignored"):
return _wx_response("SUCCESS", "成功")
return _wx_response("SUCCESS", "成功")
+53 -24
View File
@@ -8,7 +8,7 @@
from __future__ import annotations
import logging
from datetime import datetime, timedelta, timezone
from datetime import datetime, timezone
from typing import Optional
from app.auth import AuthenticatedUser, get_current_user
@@ -61,8 +61,14 @@ def _get_service() -> PointsService:
def _is_member(user: AuthenticatedUser) -> bool:
"""判断用户是否为付费会员。"""
return getattr(user.user, "is_member", False)
"""判断用户是否为有效付费会员(实时判断到期时间)。"""
if not getattr(user.user, "is_member", False):
return False
expires_at = getattr(user.user, "member_expires_at", None)
if expires_at is not None and expires_at <= datetime.now(timezone.utc):
return False
# 已标记取消但未到期:会员权益仍有效
return True
def _member_type(user: AuthenticatedUser) -> str | None:
@@ -281,32 +287,55 @@ def refund_points(
)
@points_router.post("/recharge", response_model=PointsOrderResponse)
@points_router.post("/create-order", response_model=PointsOrderResponse)
def create_points_purchase_order(
body: PointsRechargeRequest,
current_user: AuthenticatedUser = Depends(get_current_user),
db: Session = Depends(get_db_session),
):
"""创建积分包购买订单(微信 JSAPI 支付)。
返回小程序调起支付参数;支付成功后积分自动到账(微信回调履约)。
"""
from packages.adapters.wechat_pay import WeChatPayError
from packages.application.payment_service import PaymentConfigError, PaymentService
openid = getattr(current_user.user, "wechat_openid", None)
svc = PaymentService()
try:
result = svc.create_points_order(
user_id=current_user.user.id,
package_code=body.package_id,
openid=openid,
db=db,
)
except PaymentConfigError as e:
raise HTTPException(status_code=503, detail=f"支付通道不可用:{e}") from None
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e)) from None
except WeChatPayError as e:
raise HTTPException(status_code=502, detail=f"微信支付下单失败:{e}") from None
return PointsOrderResponse(
id=result["order_id"],
order_type="points",
product_code=body.package_id,
amount_cents=result["amount_cents"],
points_amount=result["points_amount"],
status="pending",
pay_params=result["pay_params"],
expire_at=result["expire_at"],
)
@points_router.post("/recharge", response_model=PointsOrderResponse, deprecated=True)
def create_recharge_order(
body: PointsRechargeRequest,
current_user: AuthenticatedUser = Depends(get_current_user),
db: Session = Depends(get_db_session),
):
"""创建积分充值订单。pay_params 在支付通道接入后填入 prepay_id/payment_url;当前为空 dict。"""
svc = _get_service()
try:
order = svc.create_order(
user_id=current_user.user.id,
order_type="points",
product_code=body.package_id,
db=db,
)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e)) from None
package = POINTS_PACKAGES.get(body.package_id, {})
now = datetime.now(timezone.utc)
expire_at = now + timedelta(hours=48)
# TODO: 接入微信/支付宝后填充真实 prepay_id / payment_url
order["points_amount"] = package.get("points", 0)
order["pay_params"] = {}
order["expire_at"] = expire_at.isoformat()
return PointsOrderResponse(**order)
"""[已废弃] 旧积分充值入口,保留向后兼容,内部转发到 /points/create-order。"""
return create_points_purchase_order(body, current_user, db)
@points_router.get("/subscription/membership", response_model=MembershipStatusResponse)
+132 -15
View File
@@ -8,18 +8,23 @@ from datetime import UTC, datetime
from typing import Any
from app.auth import AuthenticatedUser, get_current_user
from app.dependencies import get_user_repository
from app.dependencies import get_db_session, get_user_repository
from app.schemas.subscription import (
BillingCycle,
BillingRecord,
ChangePlanRequest,
ChangePlanResponse,
CreateOrderRequest,
CreateOrderResponse,
MembershipType,
OrderItem,
OrderListResponse,
SimpleResponse,
SubscriptionInfo,
ToggleAutoRenewRequest,
)
from fastapi import APIRouter, Depends, HTTPException, status
from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy.orm import Session
from packages.ports.user_repository import UserRepository
@@ -43,30 +48,53 @@ def _get_plan_name(plan_id: str) -> str:
def _build_subscription_info(user: AuthenticatedUser) -> SubscriptionInfo:
"""构建订阅信息响应"""
"""构建订阅信息响应(P1-8:实时判断是否过期)。"""
now = datetime.now(UTC)
if user.user.subscription_expires_at:
period_end = user.user.subscription_expires_at.isoformat()
period_start = now.isoformat()
else:
period_start = now.isoformat()
period_end = now.isoformat()
expires_at = user.user.subscription_expires_at
# 优先使用会员体系字段
member_expires = getattr(user.user, "member_expires_at", None)
if member_expires is not None:
expires_at = member_expires
plan_id = user.user.subscription_plan or MembershipType.FREE
# 会员类型字段(member_type 与积分体系一致)
member_type = getattr(user.user, "member_type", None)
if member_type:
plan_id = member_type
# 旧档位(standard/pro/enterprise)统一降级为 monthly,避免前端炸掉
if plan_id in {"standard", "pro", "enterprise"}:
plan_id = MembershipType.MONTHLY
# 实时过期判断:到期即降级 free / expired(不改库,查询时计算)
is_expired = expires_at is not None and expires_at <= now
cancelled = (user.user.subscription_status or "") == "cancelled"
if is_expired:
effective_plan = MembershipType.FREE
effective_status = "expired"
elif cancelled:
effective_plan = plan_id
effective_status = "cancelled"
else:
effective_plan = plan_id
effective_status = user.user.subscription_status or (
"active" if plan_id != MembershipType.FREE else "active"
)
period_start = now.isoformat()
period_end = expires_at.isoformat() if expires_at else now.isoformat()
return SubscriptionInfo(
id=f"sub-{user.user.id[:8]}",
plan_id=plan_id,
plan_name=_get_plan_name(plan_id),
status=user.user.subscription_status or "active",
plan_id=effective_plan,
plan_name=_get_plan_name(effective_plan),
status=effective_status,
billing_cycle=plan_id if plan_id != MembershipType.FREE else BillingCycle.MONTHLY,
current_period_start=period_start,
current_period_end=period_end,
amount=0 if plan_id == MembershipType.FREE else 0, # 金额由前端 /plans 接口展示
auto_renew=True,
amount=0, # 金额由前端 /plans 接口展示
auto_renew=False, # 一期不做自动续费
created_at=user.user.created_at.isoformat() if user.user.created_at else now.isoformat(),
)
@@ -216,12 +244,24 @@ async def cancel_subscription(
detail="免费用户无需取消订阅",
)
# 已过期/未生效:直接报错,不允许取消
expires_at = user.subscription_expires_at
member_expires = getattr(user, "member_expires_at", None)
if member_expires is not None:
expires_at = member_expires
if expires_at is not None and expires_at <= datetime.now(UTC):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="订阅已到期,无需取消",
)
updated_user = replace(user, subscription_status="cancelled")
user_repository.save(updated_user)
end_text = expires_at.strftime("%Y-%m-%d") if expires_at else "当前周期结束"
return SimpleResponse(
success=True,
message="订阅已取消,当前周期结束后将降级为免费用户",
message=f"已取消续费,{end_text} 前仍可正常使用会员权益,到期后自动降级为免费用户",
)
@@ -294,3 +334,80 @@ async def toggle_auto_renew(
"""切换自动续费"""
status_text = "已开启自动续费" if request.enabled else "已关闭自动续费"
return SimpleResponse(success=True, message=status_text)
# ════════════════════════════════════════════════════════════════
# 微信支付:下单 / 订单查询
# ════════════════════════════════════════════════════════════════
@router.post("/create-order")
def create_membership_order(
body: CreateOrderRequest,
current_user: AuthenticatedUser = Depends(get_current_user),
db: Session = Depends(get_db_session),
):
"""创建会员年卡订单(微信 JSAPI 支付)。
- 一期只做年卡,billing_cycle 默认 yearly
- 返回小程序调起支付所需的 pay_params
- 支付结果通过 POST /api/v1/payment/wechat/notify 异步通知履约
"""
from packages.adapters.wechat_pay import WeChatPayError
from packages.application.payment_service import PaymentConfigError, PaymentService
openid = getattr(current_user.user, "wechat_openid", None)
svc = PaymentService()
try:
result = svc.create_membership_order(
user_id=current_user.user.id,
plan_id=body.plan_id,
billing_cycle=body.billing_cycle or "yearly",
openid=openid,
db=db,
)
except PaymentConfigError as e:
raise HTTPException(status_code=503, detail=f"支付通道不可用:{e}") from None
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e)) from None
except WeChatPayError as e:
raise HTTPException(status_code=502, detail=f"微信支付下单失败:{e}") from None
return CreateOrderResponse(**result)
@router.get("/orders", response_model=OrderListResponse)
def list_my_orders(
order_type: str | None = Query(None, description="筛选类型: membership/points"),
page: int = Query(1, ge=1),
page_size: int = Query(20, ge=1, le=100),
current_user: AuthenticatedUser = Depends(get_current_user),
db: Session = Depends(get_db_session),
):
"""查询当前用户的订单列表(会员+积分包,分页)。"""
from packages.application.payment_service import PaymentService
return PaymentService().list_orders(
user_id=current_user.user.id,
order_type=order_type,
page=page,
page_size=page_size,
db=db,
)
@router.get("/orders/{order_id}", response_model=OrderItem)
def get_my_order(
order_id: str,
current_user: AuthenticatedUser = Depends(get_current_user),
db: Session = Depends(get_db_session),
):
"""查询单个订单详情(仅能查自己的订单)。"""
from packages.application.payment_service import PaymentService
order = PaymentService().get_order(
user_id=current_user.user.id, order_id=order_id, db=db
)
if order is None:
raise HTTPException(status_code=404, detail="订单不存在")
return order
+58
View File
@@ -110,3 +110,61 @@ class ToggleAutoRenewRequest(BaseModel):
"""切换自动续费请求"""
enabled: bool = Field(..., description="是否开启自动续费")
# ============ 微信支付下单/订单 Schemas ============
class CreateOrderRequest(BaseModel):
"""会员购买下单请求(一期只做年卡)。"""
plan_id: str = Field(..., description="套餐ID(admin plans 表UUID);兼容传 yearly")
billing_cycle: str = Field("yearly", description="计费周期,一期默认 yearly")
class WeChatPayParams(BaseModel):
"""小程序调起微信支付参数。"""
appId: str
timeStamp: str
nonceStr: str
package: str
signType: str
paySign: str
class CreateOrderResponse(BaseModel):
"""会员下单响应。"""
order_id: str
out_trade_no: str
prepay_id: str
amount_cents: int
pay_params: WeChatPayParams
expire_at: str
class OrderItem(BaseModel):
"""订单条目。"""
id: str
order_type: str
product_code: str
product_name: Optional[str] = None
amount_cents: int
points_amount: int = 0
status: str
payment_method: Optional[str] = None
prepay_id: Optional[str] = None
paid_at: Optional[str] = None
expire_at: Optional[str] = None
created_at: Optional[str] = None
class OrderListResponse(BaseModel):
"""订单列表(分页)。"""
total: int
page: int
page_size: int
items: list[OrderItem]
@@ -0,0 +1,55 @@
"""商业交易模型 —— 与 xiaoxia-admin 共享表的 ORM 映射。
xiaoxia-admin(管理后台)与 xiaoxia-saas(用户端)共享同一个数据库:
- plans / subscriptions 表由 admin 的 alembic 迁移创建(admin_alembic_version)
- 用户端在支付链路中需要读取套餐、写入订阅,因此在此做最小映射
注意:不要在此给这些表建迁移;表结构变更走 xiaoxia-admin 仓库。
"""
from __future__ import annotations
from datetime import UTC, datetime
from sqlalchemy import Boolean, Column, DateTime, Integer, Numeric, String, Text
from sqlalchemy.orm import declarative_base
CommerceBase: Any = declarative_base()
def _now() -> datetime:
return datetime.now(UTC)
class PlanModel(CommerceBase):
"""admin 端 plans 套餐表(只读映射)。"""
__tablename__ = "plans"
id = Column(String(36), primary_key=True)
level_id = Column(String(36), nullable=True)
plan_key = Column(String(50), nullable=False)
name = Column(String(100), nullable=False)
description = Column(Text, nullable=True)
monthly_price = Column(Numeric(10, 2), nullable=False, default=0)
yearly_price = Column(Numeric(10, 2), nullable=False, default=0)
is_enabled = Column(Boolean, nullable=False, default=True)
display_order = Column(Integer, default=0)
created_at = Column(DateTime, default=_now)
class SubscriptionRecordModel(CommerceBase):
"""admin 端 subscriptions 订阅记录表(支付成功后写入)。"""
__tablename__ = "subscriptions"
id = Column(String(36), primary_key=True)
user_id = Column(String(36), nullable=False, index=True)
plan_id = Column(String(36), nullable=False, index=True)
status = Column(String(20), nullable=False, default="active")
billing_cycle = Column(String(10), nullable=False)
start_date = Column(DateTime, nullable=False)
end_date = Column(DateTime, nullable=False)
cancelled_at = Column(DateTime, nullable=True)
created_at = Column(DateTime, default=_now)
updated_at = Column(DateTime, default=_now)
@@ -816,6 +816,12 @@ class PointsOrderModel(Base):
payment_method = Column(String(50), nullable=True)
payment_id = Column(String(100), nullable=True)
paid_at = Column(DateTime, nullable=True)
# 微信支付链路补充字段
out_trade_no = Column(String(64), nullable=True, index=True)
prepay_id = Column(String(128), nullable=True)
product_name = Column(String(100), nullable=True)
payer_openid = Column(String(128), nullable=True)
expire_at = Column(DateTime, nullable=True)
created_at = Column(DateTime, nullable=False, default=lambda: datetime.now(UTC))
+97
View File
@@ -0,0 +1,97 @@
"""微信支付平台证书缓存。
回调验签需要「微信支付平台公钥」。通过 V3 接口
GET /v3/certificates 获取(响应用本商户 APIv3 密钥加密),
带内存缓存(证书有效期通常约12个月)。
部署为单进程时内存缓存足够;多副本部署可改为 Redis 缓存。
"""
from __future__ import annotations
import base64
import logging
import time
from typing import Any
import httpx
from cryptography.hazmat.primitives import serialization
from packages.adapters.wechat_pay import (
WECHAT_BASE_URL,
build_authorization,
load_private_key,
)
logger = logging.getLogger(__name__)
CERT_PATH = "/v3/certificates"
# serial -> {"public_key": obj, "expires_at": ts}
_cache: dict[str, dict[str, Any]] = {}
def _decrypt_cert_blob(*, api_v3_key: str, nonce: str, associated_data: str, ciphertext_b64: str) -> bytes:
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
blob = base64.b64decode(ciphertext_b64)
tag, data = blob[-16:], blob[:-16]
decryptor = Cipher(algorithms.AES(api_v3_key.encode()), modes.GCM(nonce.encode(), tag)).decryptor()
return decryptor.update(data) + decryptor.finalize_with_associated_data(associated_data.encode())
def refresh_platform_certificates() -> dict[str, Any]:
"""拉取并刷新平台证书缓存。"""
from app.config import settings
s = settings
if not getattr(s, "wechat_pay_configured", False):
raise RuntimeError("微信支付未配置,无法拉取平台证书")
private_key = load_private_key(s.wechat_private_key)
auth = build_authorization("GET", CERT_PATH, s.wechat_mch_id, s.wechat_appid, s.wechat_cert_serial, private_key)
with httpx.Client(timeout=15.0) as client:
resp = client.get(
f"{WECHAT_BASE_URL}{CERT_PATH}",
headers={"Authorization": auth, "Accept": "application/json"},
)
resp.raise_for_status()
data = resp.json()
new_cache: dict[str, dict[str, Any]] = {}
for item in data.get("data", []):
serial = item["serial_no"]
enc = item["encrypt_certificate"]
pem = _decrypt_cert_blob(
api_v3_key=s.wechat_api_v3_key,
nonce=enc["nonce"],
associated_data=enc["associated_data"],
ciphertext_b64=enc["ciphertext"],
)
cert = serialization.load_pem_x509_certificate(pem)
public_key = cert.public_key()
expires_at = (
time.mktime(cert.not_valid_after_utc.timetuple())
if hasattr(cert, "not_valid_after_utc")
else time.time() + 365 * 24 * 3600
)
new_cache[serial] = {"public_key": public_key, "expires_at": expires_at}
_cache.clear()
_cache.update(new_cache)
logger.info("微信平台证书已刷新,共 %s 张", len(new_cache))
return {"count": len(new_cache), "serials": list(new_cache.keys())}
def get_platform_public_key(serial: str):
"""按证书序列号取平台公钥;缓存缺失/过期时自动拉取。"""
now = time.time()
entry = _cache.get(serial)
if entry is None or entry["expires_at"] <= now:
try:
refresh_platform_certificates()
except Exception: # noqa: BLE001
logger.exception("刷新微信平台证书失败")
entry = _cache.get(serial)
return entry["public_key"] if entry else None
+232
View File
@@ -0,0 +1,232 @@
"""微信支付 V3 适配层 —— JSAPI 下单、回调验签与解密、前端支付参数签名。
纯适配,不含业务逻辑:
- create_jsapi_order: JSAPI 统一下单,返回 prepay_id
- build_jsapi_pay_params: 生成调起微信支付所需参数(商户私钥二次签名)
- verify_notification_signature: 校验回调平台证书签名(含时间戳防重放)
- decrypt_resource: 用 APIv3 密钥解密 resource(AEAD_AES_256_GCM)
依赖:httpx + cryptography(requirements-base.txt 中已含 httpx)。
"""
from __future__ import annotations
import base64
import json
import logging
import time
import uuid
from typing import Any
import httpx
from cryptography.hazmat.primitives import hashes, serialization
from cryptography.hazmat.primitives.asymmetric import padding
logger = logging.getLogger(__name__)
WECHAT_BASE_URL = "https://api.mch.weixin.qq.com"
JSAPI_ORDER_PATH = "/v3/pay/transactions/jsapi"
# 回调重放窗口:通知时间戳距当前超过 5 分钟视为过期
NOTIFY_MAX_AGE_SECONDS = 300
class WeChatPayError(Exception):
"""微信支付调用错误。"""
# ──────────────────────── 密钥/证书加载 ────────────────────────
def load_private_key(private_key_src: str):
"""加载商户 API 私钥。
支持:
- PEM 文本(含 BEGIN PRIVATE KEY / BEGIN RSA PRIVATE KEY)
- PEM 文件绝对路径
- 单行 base64 编码的 PEM(容器 env 不支持换行时使用)
"""
src = (private_key_src or "").strip()
if not src:
raise WeChatPayError("微信商户私钥为空")
if "BEGIN" not in src and "\n" not in src:
# 先尝试文件路径
try:
with open(src, "rb") as f:
data = f.read()
return serialization.load_pem_private_key(data, password=None)
except (OSError, ValueError):
pass
# 再尝试单行 base64
try:
src = base64.b64decode(src).decode()
except Exception as e:
raise WeChatPayError("无法解析微信商户私钥(不是PEM文本/文件/base64)") from e
if "\\n" in src:
src = src.replace("\\n", "\n")
try:
return serialization.load_pem_private_key(src.encode(), password=None)
except ValueError as e:
raise WeChatPayError(f"微信商户私钥格式无效: {e}") from e
def _rsa_sign(private_key, message: str) -> str:
"""RSA-SHA256 签名,返回 base64。"""
signature = private_key.sign(message.encode(), padding.PKCS1v15(), hashes.SHA256())
return base64.b64encode(signature).decode()
# ──────────────────────── Authorization 头 ────────────────────────
def build_authorization(
method: str,
url_path: str,
mch_id: str,
appid: str,
cert_serial: str,
private_key,
body: str = "",
) -> str:
"""构造微信 V3 API 的 Authorization 头。"""
timestamp = str(int(time.time()))
nonce = uuid.uuid4().hex
message = f"{method}\n{url_path}\n{timestamp}\n{nonce}\n{body}\n"
signature = _rsa_sign(private_key, message)
return (
f'WECHATPAY2-SHA256-RSA2048 mchid="{mch_id}",nonce_str="{nonce}",'
f'timestamp="{timestamp}",serial_no="{cert_serial}",signature="{signature}"'
)
# ──────────────────────── JSAPI 统一下单 ────────────────────────
def create_jsapi_order(
*,
appid: str,
mch_id: str,
cert_serial: str,
private_key,
description: str,
out_trade_no: str,
amount_cents: int,
openid: str,
notify_url: str,
attach: str = "",
) -> dict[str, Any]:
"""调用 V3 JSAPI 统一下单,返回 {"prepay_id": ...}。"""
payload: dict[str, Any] = {
"appid": appid,
"mchid": mch_id,
"description": description,
"out_trade_no": out_trade_no,
"notify_url": notify_url,
"amount": {"total": int(amount_cents), "currency": "CNY"},
"payer": {"openid": openid},
}
if attach:
payload["attach"] = attach
body = json.dumps(payload, separators=(",", ":"), ensure_ascii=False)
authorization = build_authorization("POST", JSAPI_ORDER_PATH, mch_id, appid, cert_serial, private_key, body)
try:
with httpx.Client(timeout=15.0) as client:
resp = client.post(
f"{WECHAT_BASE_URL}{JSAPI_ORDER_PATH}",
content=body.encode("utf-8"),
headers={
"Authorization": authorization,
"Accept": "application/json",
"Content-Type": "application/json",
},
)
except httpx.HTTPError as e:
raise WeChatPayError(f"微信下单网络异常: {e}") from e
if resp.status_code >= 400:
logger.error("微信下单失败: status=%s body=%s", resp.status_code, resp.text)
raise WeChatPayError(f"微信下单失败({resp.status_code}): {resp.text}")
data = resp.json()
if not data.get("prepay_id"):
raise WeChatPayError(f"微信下单未返回 prepay_id: {data}")
return data
def build_jsapi_pay_params(appid: str, prepay_id: str, private_key) -> dict[str, str]:
"""生成小程序调起支付参数(对 prepay_id 二次签名)。"""
timestamp = str(int(time.time()))
nonce = uuid.uuid4().hex
package = f"prepay_id={prepay_id}"
message = f"{appid}\n{timestamp}\n{nonce}\n{package}\n"
return {
"appId": appid,
"timeStamp": timestamp,
"nonceStr": nonce,
"package": package,
"signType": "RSA",
"paySign": _rsa_sign(private_key, message),
}
# ──────────────────────── 回调验签与解密 ────────────────────────
def verify_notification_signature(
*,
timestamp: str,
nonce: str,
body: bytes,
signature_b64: str,
platform_public_key,
) -> bool:
"""用微信平台公钥校验回调签名,并检查时间戳防重放。"""
try:
ts = int(timestamp)
except (TypeError, ValueError):
return False
if abs(time.time() - ts) > NOTIFY_MAX_AGE_SECONDS:
logger.warning("微信回调时间戳超出允许窗口: %s", timestamp)
return False
message = f"{timestamp}\n{nonce}\n".encode() + body + b"\n"
try:
platform_public_key.verify(
base64.b64decode(signature_b64),
message,
padding.PKCS1v15(),
hashes.SHA256(),
)
return True
except Exception:
logger.warning("微信回调签名校验失败")
return False
def decrypt_resource(
*,
api_v3_key: str,
nonce: str,
associated_data: str,
ciphertext_b64: str,
) -> dict[str, Any]:
"""解密回调 resource(AEAD_AES_256_GCM)。"""
try:
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
ciphertext = base64.b64decode(ciphertext_b64)
tag = ciphertext[-16:]
data = ciphertext[:-16]
decryptor = Cipher(
algorithms.AES(api_v3_key.encode("utf-8")),
modes.GCM(nonce.encode("utf-8"), tag),
).decryptor()
plaintext = decryptor.update(data) + decryptor.finalize_with_associated_data(associated_data.encode("utf-8"))
return json.loads(plaintext.decode("utf-8"))
except Exception as e:
raise WeChatPayError(f"回调解密失败: {e}") from e
+520
View File
@@ -0,0 +1,520 @@
"""支付应用服务 —— 会员年卡/积分包购买的下单、回调履约、订单查询。
编排 xiaoxia-saas 自身能力(积分账户/用户表)与微信支付适配层,
同时写入 admin 端共享的 subscriptions 表,保持后台数据一致。
一期范围:
- 会员年卡:plan_id 支持 admin plans 表 UUID(主)与 legacy "yearly"(兼容)
- 积分包:product_code 对应 POINTS_PACKAGES
- 自动续费不做,auto_renew 固定 false
"""
from __future__ import annotations
import logging
import uuid
from datetime import UTC, datetime, timedelta
from typing import Any
from sqlalchemy.orm import Session
from packages.adapters.wechat_pay import (
WeChatPayError,
build_jsapi_pay_params,
create_jsapi_order,
decrypt_resource,
load_private_key,
verify_notification_signature,
)
from packages.domain.points_rules import MEMBERSHIP_PRICES, POINTS_PACKAGES
from packages.domain.points_service import PointsService
logger = logging.getLogger(__name__)
# legacy plan_id -> 会员天数
_LEGACY_DURATION = {
"monthly": 30,
"quarterly": 90,
"yearly": 365,
}
_ORDER_TTL_HOURS = 48
class PaymentConfigError(Exception):
"""支付配置缺失。"""
class PaymentService:
"""会员/积分支付编排。"""
def __init__(self) -> None:
self.points_service = PointsService()
# ──────────────────────── 内部工具 ────────────────────────
@staticmethod
def _settings():
from app.config import settings
return settings
def _require_wechat(self):
"""校验微信配置并返回 (settings, private_key)。"""
settings = self._settings()
if not getattr(settings, "wechat_pay_configured", False):
raise PaymentConfigError(
"微信支付未配置或配置不完整(WECHAT_PAY_ENABLED/AppID/MchId/APIv3Key/CertSerial/PrivateKey/NotifyUrl)"
)
return settings, load_private_key(settings.wechat_private_key)
@staticmethod
def _resolve_plan(db: Session, plan_id: str, billing_cycle: str) -> dict[str, Any]:
"""解析套餐:优先 admin plans 表 UUID;否则按 legacy 会员类型。
Returns:
{"plan_id", "name", "amount_cents", "duration_days", "db_plan"}
"""
# 1) admin plans 表
from packages.adapters.sqlalchemy_impl.commerce_models import PlanModel
plan = db.query(PlanModel).filter(PlanModel.id == plan_id).first()
if plan is not None:
if not plan.is_enabled:
raise ValueError("套餐已下架")
if billing_cycle == "yearly":
price = plan.yearly_price
days = 365
elif billing_cycle == "monthly":
price = plan.monthly_price
days = 30
else:
raise ValueError("一期仅支持 monthly/yearly 计费周期")
amount_cents = int(round(float(price) * 100))
if amount_cents <= 0:
raise ValueError("该计费周期价格未配置(价格为0),无法下单")
return {
"plan_id": plan.id,
"name": plan.name,
"amount_cents": amount_cents,
"duration_days": days,
"db_plan": plan,
}
# 2) legacy:plan_id 本身是会员类型
if plan_id in _LEGACY_DURATION and plan_id == billing_cycle:
info = MEMBERSHIP_PRICES.get(plan_id)
if not info:
raise ValueError(f"未知会员类型: {plan_id}")
return {
"plan_id": plan_id,
"name": info["name"],
"amount_cents": info["price_cents"],
"duration_days": info["duration_days"],
"db_plan": None,
}
raise ValueError(f"套餐不存在: {plan_id}")
# ──────────────────────── 会员下单 ────────────────────────
def create_membership_order(
self,
*,
user_id: str,
plan_id: str,
billing_cycle: str,
openid: str,
db: Session,
) -> dict[str, Any]:
"""创建会员订单并向微信下单,返回订单 + 前端支付参数。"""
from packages.adapters.sqlalchemy_impl.models import PointsOrderModel
settings, private_key = self._require_wechat()
if not openid:
raise ValueError("用户未绑定微信(缺少 openid),请先使用微信登录")
resolved = self._resolve_plan(db, plan_id, billing_cycle)
now = datetime.now(UTC)
out_trade_no = f"mb{now.strftime('%Y%m%d%H%M%S')}{uuid.uuid4().hex[:10]}"
expire_at = now + timedelta(hours=_ORDER_TTL_HOURS)
order = PointsOrderModel(
id=uuid.uuid4().hex,
user_id=user_id,
order_type="membership",
product_code=resolved["plan_id"],
amount_cents=resolved["amount_cents"],
original_amount_cents=resolved["amount_cents"],
product_name=resolved["name"],
payer_openid=openid,
status="pending",
expire_at=expire_at,
)
db.add(order)
db.flush() # 先不 commit,微信失败则回滚
try:
wx_resp = create_jsapi_order(
appid=settings.wechat_appid,
mch_id=settings.wechat_mch_id,
cert_serial=settings.wechat_cert_serial,
private_key=private_key,
description=resolved["name"],
out_trade_no=out_trade_no,
amount_cents=resolved["amount_cents"],
openid=openid,
notify_url=settings.wechat_notify_url,
attach=order.id,
)
except WeChatPayError as e:
db.rollback()
raise WeChatPayError(str(e)) from e
prepay_id = wx_resp["prepay_id"]
order.prepay_id = prepay_id
order.out_trade_no = out_trade_no
db.commit()
pay_params = build_jsapi_pay_params(settings.wechat_appid, prepay_id, private_key)
return {
"order_id": order.id,
"out_trade_no": out_trade_no,
"prepay_id": prepay_id,
"pay_params": pay_params,
"amount_cents": resolved["amount_cents"],
"expire_at": expire_at.isoformat(),
}
# ──────────────────────── 积分包下单 ────────────────────────
def create_points_order(
self,
*,
user_id: str,
package_code: str,
openid: str,
db: Session,
) -> dict[str, Any]:
"""创建积分包订单并向微信下单。"""
from packages.adapters.sqlalchemy_impl.models import PointsOrderModel
settings, private_key = self._require_wechat()
package = POINTS_PACKAGES.get(package_code)
if not package:
raise ValueError(f"积分包不存在: {package_code}")
if not openid:
raise ValueError("用户未绑定微信(缺少 openid),请先使用微信登录")
now = datetime.now(UTC)
out_trade_no = f"pt{now.strftime('%Y%m%d%H%M%S')}{uuid.uuid4().hex[:10]}"
expire_at = now + timedelta(hours=_ORDER_TTL_HOURS)
order = PointsOrderModel(
id=uuid.uuid4().hex,
user_id=user_id,
order_type="points",
product_code=package_code,
amount_cents=package["price_cents"],
original_amount_cents=package["price_cents"],
points_amount=package["points"],
product_name=package["name"],
payer_openid=openid,
status="pending",
expire_at=expire_at,
)
db.add(order)
db.flush()
try:
wx_resp = create_jsapi_order(
appid=settings.wechat_appid,
mch_id=settings.wechat_mch_id,
cert_serial=settings.wechat_cert_serial,
private_key=private_key,
description=package["name"],
out_trade_no=out_trade_no,
amount_cents=package["price_cents"],
openid=openid,
notify_url=settings.wechat_notify_url,
attach=order.id,
)
except WeChatPayError as e:
db.rollback()
raise WeChatPayError(str(e)) from e
prepay_id = wx_resp["prepay_id"]
order.prepay_id = prepay_id
order.out_trade_no = out_trade_no
db.commit()
pay_params = build_jsapi_pay_params(settings.wechat_appid, prepay_id, private_key)
return {
"order_id": order.id,
"out_trade_no": out_trade_no,
"prepay_id": prepay_id,
"pay_params": pay_params,
"amount_cents": package["price_cents"],
"points_amount": package["points"],
"expire_at": expire_at.isoformat(),
}
# ──────────────────────── 回调履约 ────────────────────────
def handle_wechat_notification(
self,
*,
headers: dict[str, str],
body: bytes,
platform_public_key_loader,
db: Session,
) -> dict[str, Any]:
"""处理微信支付回调:验签 → 解密 → 验金额 → 履约。
Args:
platform_public_key_loader: callable(serial: str) -> 公钥对象 | None
"""
from packages.adapters.sqlalchemy_impl.models import PointsOrderModel
settings = self._settings()
timestamp = headers.get("wechatpay-timestamp", "")
nonce = headers.get("wechatpay-nonce", "")
serial = headers.get("wechatpay-serial", "")
signature = headers.get("wechatpay-signature", "")
if not all([timestamp, nonce, serial, signature]):
raise WeChatPayError("回调头不完整")
platform_public_key = platform_public_key_loader(serial)
if platform_public_key is None:
raise WeChatPayError("未找到对应微信平台证书")
if not verify_notification_signature(
timestamp=timestamp,
nonce=nonce,
body=body,
signature_b64=signature,
platform_public_key=platform_public_key,
):
raise WeChatPayError("回调签名校验失败")
envelope = _json_loads(body)
resource = envelope.get("resource", {})
event_type = envelope.get("event_type", "")
if event_type != "TRANSACTION.SUCCESS":
logger.info("忽略非支付成功事件: %s", event_type)
return {"ignored": True, "event_type": event_type}
payment = decrypt_resource(
api_v3_key=settings.wechat_api_v3_key,
nonce=resource.get("nonce", ""),
associated_data=resource.get("associated_data", ""),
ciphertext_b64=resource.get("ciphertext", ""),
)
out_trade_no = payment.get("out_trade_no", "")
transaction_id = payment.get("transaction_id", "")
payer_total = int(payment.get("amount", {}).get("payer_total", -1))
trade_state = payment.get("trade_state", "")
# 行锁取订单:
# 1) out_trade_no 独立列(主)
# 2) attach(下单时写入的 order.id)
# 3) payment_id(兼容旧数据)
order = (
db.query(PointsOrderModel).filter(PointsOrderModel.out_trade_no == out_trade_no).with_for_update().first()
)
if order is None:
attach_id = payment.get("attach") or envelope.get("id")
if attach_id:
order = db.query(PointsOrderModel).filter(PointsOrderModel.id == attach_id).with_for_update().first()
if order is None:
order = (
db.query(PointsOrderModel).filter(PointsOrderModel.payment_id == out_trade_no).with_for_update().first()
)
if order is None:
raise WeChatPayError(f"订单不存在: {out_trade_no}")
if order.status == "paid":
# 幂等:微信可能重复通知
return {"success": True, "order_id": order.id, "idempotent": True}
if trade_state and trade_state != "SUCCESS":
raise WeChatPayError(f"交易状态非成功: {trade_state}")
if payer_total != order.amount_cents:
raise WeChatPayError(f"金额不一致: 订单 {order.amount_cents} / 实付 {payer_total}")
# ── 履约 ──
now = datetime.now(UTC)
order.status = "paid"
order.payment_method = "wechat"
order.payment_id = transaction_id
order.paid_at = now
if order.order_type == "points":
self.points_service.add_points(
user_id=order.user_id,
amount=order.points_amount,
source=f"recharge:{order.product_code}",
db=db,
description=f"积分充值: {order.product_name or order.product_code}",
ref_id=order.id,
)
elif order.order_type == "membership":
duration = self._duration_for(order)
self._activate_membership(
user_id=order.user_id,
plan_id=order.product_code,
duration_days=duration,
order_id=order.id,
now=now,
db=db,
)
else:
raise WeChatPayError(f"未知订单类型: {order.order_type}")
db.commit()
return {"success": True, "order_id": order.id}
@staticmethod
def _duration_for(order: Any) -> int:
"""根据订单商品推断会员天数。"""
code = order.product_code
if code in _LEGACY_DURATION:
return _LEGACY_DURATION[code]
# admin plan:查 plan_key / 名称或固定365(年卡订单)
return 365
def _activate_membership(
self,
*,
user_id: str,
plan_id: str,
duration_days: int,
order_id: str,
now: datetime,
db: Session,
) -> None:
"""激活会员:更新 users 表 + 写 admin subscriptions + 发积分。"""
from packages.adapters.sqlalchemy_impl.commerce_models import (
PlanModel,
SubscriptionRecordModel,
)
from packages.adapters.sqlalchemy_impl.models import UserModel
user = db.query(UserModel).filter(UserModel.id == user_id).with_for_update().first()
if user is None:
raise WeChatPayError(f"用户不存在: {user_id}")
base = user.member_expires_at or now
if base < now:
base = now
expires_at = base + timedelta(days=duration_days)
user.is_member = True
user.member_type = plan_id
user.member_expires_at = expires_at
user.subscription_plan = plan_id if plan_id in _LEGACY_DURATION else "yearly"
user.subscription_status = "active"
user.subscription_expires_at = expires_at
user.max_projects = -1
user.max_storage_gb = 100
# 写 admin subscriptions 表(后台可见;UUID 套餐才存在真实 plan_id)
admin_plan = db.query(PlanModel).filter(PlanModel.id == plan_id).first()
if admin_plan is not None:
record = SubscriptionRecordModel(
id=uuid.uuid4().hex,
user_id=user_id,
plan_id=admin_plan.id,
status="active",
billing_cycle="yearly" if duration_days >= 365 else "monthly",
start_date=now,
end_date=expires_at,
)
db.add(record)
# 发积分:优先 quotas.monthly_credits,年卡一期按一年一次性发放
monthly_credits = 0
level_id = admin_plan.level_id
if level_id:
from sqlalchemy import text as sa_text
row = db.execute(
sa_text("SELECT monthly_credits FROM membership_levels WHERE id = :lid"),
{"lid": level_id},
).first()
if row:
monthly_credits = int(row[0] or 0)
# 年卡:一次性发放 12 个月积分(任务9简化方案)
grant = monthly_credits * (12 if duration_days >= 365 else 1)
if grant > 0:
self.points_service.add_points(
user_id=user_id,
amount=float(grant),
source=f"membership:{plan_id}",
db=db,
description=f"会员开通赠积分({admin_plan.name})",
ref_id=order_id,
)
# ──────────────────────── 订单查询 ────────────────────────
def list_orders(
self,
*,
user_id: str,
order_type: str | None,
page: int,
page_size: int,
db: Session,
) -> dict[str, Any]:
from packages.adapters.sqlalchemy_impl.models import PointsOrderModel
query = db.query(PointsOrderModel).filter(PointsOrderModel.user_id == user_id)
if order_type:
query = query.filter(PointsOrderModel.order_type == order_type)
total = query.count()
rows = query.order_by(PointsOrderModel.created_at.desc()).offset((page - 1) * page_size).limit(page_size).all()
return {
"total": total,
"page": page,
"page_size": page_size,
"items": [self._order_dict(o) for o in rows],
}
def get_order(self, *, user_id: str, order_id: str, db: Session) -> dict[str, Any] | None:
from packages.adapters.sqlalchemy_impl.models import PointsOrderModel
order = (
db.query(PointsOrderModel)
.filter(PointsOrderModel.id == order_id, PointsOrderModel.user_id == user_id)
.first()
)
return self._order_dict(order) if order else None
@staticmethod
def _order_dict(order: Any) -> dict[str, Any]:
return {
"id": order.id,
"order_type": order.order_type,
"product_code": order.product_code,
"product_name": order.product_name,
"amount_cents": order.amount_cents,
"points_amount": order.points_amount,
"status": order.status,
"payment_method": order.payment_method,
"prepay_id": order.prepay_id,
"paid_at": order.paid_at.isoformat() if order.paid_at else None,
"expire_at": order.expire_at.isoformat() if order.expire_at else None,
"created_at": order.created_at.isoformat() if order.created_at else None,
}
def _json_loads(body: bytes) -> dict[str, Any]:
import json
return json.loads(body.decode("utf-8"))
+51
View File
@@ -138,6 +138,57 @@ class SharedSettings(BaseSettings):
self.credits_enabled = bool(value)
self.points_enabled_compat = False
# ── 微信支付(V3 API)──────────────────────────────────────────────
# 未配置时支付下单接口返回明确错误,不会发出真实请求。
# staging 可先配沙箱参数;通过环境变量注入。
wechat_pay_enabled: bool = Field(
default=False,
validation_alias=AliasChoices("WECHAT_PAY_ENABLED", "wechat_pay_enabled"),
)
# 公众号/小程序 AppID
wechat_appid: str = Field(
default="",
validation_alias=AliasChoices("WECHAT_APPID", "wechat_appid"),
)
# 微信支付商户号
wechat_mch_id: str = Field(
default="",
validation_alias=AliasChoices("WECHAT_MCH_ID", "wechat_mch_id"),
)
# APIv3 密钥(32字符,用于回调解密)
wechat_api_v3_key: str = Field(
default="",
validation_alias=AliasChoices("WECHAT_API_V3_KEY", "wechat_api_v3_key"),
)
# 商户API证书序列号
wechat_cert_serial: str = Field(
default="",
validation_alias=AliasChoices("WECHAT_CERT_SERIAL", "wechat_cert_serial"),
)
# 商户API私钥(PEM 内容;也支持填写文件绝对路径)
wechat_private_key: str = Field(
default="",
validation_alias=AliasChoices("WECHAT_PRIVATE_KEY", "wechat_private_key"),
)
# 支付回调通知地址(完整 https URL)
wechat_notify_url: str = Field(
default="",
validation_alias=AliasChoices("WECHAT_NOTIFY_URL", "wechat_notify_url"),
)
@property
def wechat_pay_configured(self) -> bool:
"""微信支付必要配置是否齐全(启用 + 六要素非空)。"""
return bool(
self.wechat_pay_enabled
and self.wechat_appid
and self.wechat_mch_id
and self.wechat_api_v3_key
and self.wechat_cert_serial
and self.wechat_private_key
and self.wechat_notify_url
)
# ── GPU MuseTalk 反向轮询 Worker ────────────────────────────────────
# Worker 用这个长期 Token 鉴权(不是用户 JWT)。多 Worker 共用同一个 Token;
# worker_id 用于区分具体机器。生产必须配置;development 留空会跳过校验。
+1
View File
@@ -26,6 +26,7 @@ oss2==2.18.4
# HTTP 客户端(pin 间接依赖防止版本漂移)
httpx==0.27.2
cryptography==50.0.2
httpcore==1.0.7
h2==4.1.0
+74 -29
View File
@@ -31,49 +31,94 @@ def _make_cu(user_id="user-1", is_member=False, member_type=None):
class TestRechargeOrderResponse:
def test_recharge_returns_pay_params_points_amount_expire_at(self):
"""recharge 响应必须包含 pay_params / points_amount / expire_at。"""
from app.api.routes.points import create_recharge_order
"""积分包下单接口(create-order + 兼容 recharge 别名)。"""
def _patch_payment(self, result=None, side_effect=None):
from packages.application.payment_service import PaymentService
inst = MagicMock()
if side_effect is not None:
inst.create_points_order.side_effect = side_effect
else:
inst.create_points_order.return_value = result
return patch.object(PaymentService, "__new__", return_value=inst), inst
def test_create_order_returns_pay_params(self):
"""create-order 响应必须包含 pay_params / points_amount / expire_at。"""
from app.api.routes.points import create_points_purchase_order
from app.schemas.points import PointsRechargeRequest
svc = MagicMock()
svc.create_order.return_value = {
"id": "order-1",
"order_type": "points",
"product_code": "starter_pack",
result = {
"order_id": "order-1",
"out_trade_no": "pt123",
"prepay_id": "prepay-1",
"amount_cents": 990,
"status": "pending",
"created_at": datetime.now(UTC).isoformat(),
"points_amount": 100,
"expire_at": (datetime.now(UTC) + timedelta(hours=48)).isoformat(),
"pay_params": {"appId": "wx", "paySign": "s"},
}
db = MagicMock()
p, _inst = self._patch_payment(result=result)
cu = _make_cu()
cu.user.wechat_openid = "o-1"
body = PointsRechargeRequest(package_id="starter_pack")
before = datetime.now(UTC)
with patch("app.api.routes.points._get_service", return_value=svc):
resp = create_recharge_order(body=body, current_user=cu, db=db)
after = datetime.now(UTC) + timedelta(hours=48)
with p:
resp = create_points_purchase_order(body=body, current_user=cu, db=MagicMock())
assert resp.points_amount == 100 # starter_pack 100 分
assert isinstance(resp.pay_params, dict)
assert resp.expire_at is not None
expire_dt = datetime.fromisoformat(resp.expire_at)
assert expire_dt >= before + timedelta(hours=47, minutes=55)
assert expire_dt <= after
assert resp.points_amount == 100
assert resp.pay_params["appId"] == "wx"
assert resp.expire_at == result["expire_at"]
assert resp.id == "order-1"
def test_recharge_invalid_package_returns_400(self):
def test_create_order_invalid_package_returns_400(self):
from app.api.routes.points import create_points_purchase_order
from app.schemas.points import PointsRechargeRequest
p, _inst = self._patch_payment(side_effect=ValueError("积分包不存在: x"))
cu = _make_cu()
cu.user.wechat_openid = "o-1"
body = PointsRechargeRequest(package_id="x")
with p, pytest.raises(HTTPException) as exc:
create_points_purchase_order(body=body, current_user=cu, db=MagicMock())
assert exc.value.status_code == 400
def test_recharge_alias_forwards_to_create_order(self):
"""旧 /recharge 端点保留且内部转发。"""
from app.api.routes.points import create_recharge_order
from app.schemas.points import PointsRechargeRequest
svc = MagicMock()
svc.create_order.side_effect = ValueError("invalid package")
db = MagicMock()
result = {
"order_id": "order-2",
"out_trade_no": "pt2",
"prepay_id": "p",
"amount_cents": 990,
"points_amount": 100,
"expire_at": "2026-10-05T00:00:00+00:00",
"pay_params": {"appId": "wx"},
}
p, _inst = self._patch_payment(result=result)
cu = _make_cu()
body = PointsRechargeRequest(package_id="nonexistent")
cu.user.wechat_openid = "o-1"
body = PointsRechargeRequest(package_id="starter_pack")
with p:
resp = create_recharge_order(body=body, current_user=cu, db=MagicMock())
assert resp.id == "order-2"
with pytest.raises(HTTPException) as exc, patch("app.api.routes.points._get_service", return_value=svc):
create_recharge_order(body=body, current_user=cu, db=db)
assert exc.value.status_code == 400
def test_create_order_payment_disabled_returns_503(self):
"""微信未配置时返回 503。"""
from app.api.routes.points import create_points_purchase_order
from app.schemas.points import PointsRechargeRequest
from packages.application.payment_service import PaymentConfigError
p, _inst = self._patch_payment(side_effect=PaymentConfigError("未配置"))
cu = _make_cu()
cu.user.wechat_openid = "o-1"
body = PointsRechargeRequest(package_id="starter_pack")
with p, pytest.raises(HTTPException) as exc:
create_points_purchase_order(body=body, current_user=cu, db=MagicMock())
assert exc.value.status_code == 503
# ── P0-2: check 任意 scene_key(已下线场景返回 cost=0) ──────────────────