Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 17f0ec95d2 | |||
| db39feed74 |
@@ -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)
|
||||
@@ -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",
|
||||
|
||||
@@ -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", "成功")
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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))
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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"))
|
||||
@@ -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 留空会跳过校验。
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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) ──────────────────
|
||||
|
||||
Reference in New Issue
Block a user