Files
2026-10-16 13:51:00 +08:00

256 lines
10 KiB
Python

"""支付服务:充值套餐 / 下单 / 查单 / 取消 / 回调幂等入账 + 推荐返利(对应《03-接口文档》第 8 章)"""
from datetime import datetime, timedelta
from decimal import Decimal
import redis.asyncio as aioredis
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import biz_config
from app.core import errors
from app.core.response import BizException
from app.models.billing import RechargeOrder, RechargePackage
from app.models.incentive import Referral
from app.payment import alipay, usdt, wechat
from app.services import config_service, incentive_service, membership_service, wallet_service
ORDER_STATUS_TEXT = {0: "待支付", 1: "已支付", 2: "已关闭", 3: "已退款"}
PAY_METHODS = [
{"method": 1, "name": "支付宝"},
{"method": 2, "name": "微信支付"},
{"method": 3, "name": "USDT (TRC20)"},
]
def _fmt(dt: datetime | None) -> str | None:
return dt.strftime("%Y-%m-%d %H:%M:%S") if dt else None
def _points_per_yuan() -> int:
return int((biz_config.get("points") or {}).get("points_per_yuan", 10))
def _usdt_cfg() -> dict:
return (biz_config.get("recharge") or {}).get("usdt") or {}
# ---------- 套餐 / 下单 ----------
async def list_packages(db: AsyncSession, redis: aioredis.Redis) -> dict:
"""充值套餐(8.1):档位读 recharge_packages 表(后台实时可配),自定义金额按汇率换算"""
rows = (await db.execute(
select(RechargePackage).where(RechargePackage.status == 1).order_by(RechargePackage.sort, RechargePackage.id)
)).scalars().all()
custom_enabled = str(await config_service.get_config(db, redis, "recharge.custom.enabled", "1")) == "1"
custom_min = await config_service.get_config_int(db, redis, "recharge.custom.min_yuan", 1)
custom_max = await config_service.get_config_int(db, redis, "recharge.custom.max_yuan", 10000)
return {
"packages": [
{"id": p.id, "amount_yuan": p.amount_yuan, "points": p.points, "bonus": p.bonus_points}
for p in rows
],
"custom": {
"enabled": custom_enabled,
"min_yuan": custom_min,
"max_yuan": custom_max,
"points_per_yuan": _points_per_yuan(),
},
"pay_methods": PAY_METHODS,
"usdt_rate": float(_usdt_cfg().get("rate", 7.2)),
}
async def _resolve_points(db: AsyncSession, redis: aioredis.Redis, amount_yuan: int) -> int:
"""金额 → 到账积分:优先匹配套餐档位(含赠送),否则走自定义金额按汇率换算"""
pkg = (await db.execute(
select(RechargePackage).where(RechargePackage.status == 1, RechargePackage.amount_yuan == amount_yuan)
)).scalar_one_or_none()
if pkg:
return pkg.points + pkg.bonus_points
custom_enabled = str(await config_service.get_config(db, redis, "recharge.custom.enabled", "1")) == "1"
custom_min = await config_service.get_config_int(db, redis, "recharge.custom.min_yuan", 1)
custom_max = await config_service.get_config_int(db, redis, "recharge.custom.max_yuan", 10000)
if not custom_enabled or not (custom_min <= amount_yuan <= custom_max):
raise BizException(errors.PARAM_ERROR, "充值金额不在可选档位")
return amount_yuan * _points_per_yuan()
async def create_recharge(db: AsyncSession, redis: aioredis.Redis, user_id: int, req, platform: str | None = None) -> dict:
"""创建充值订单(8.2):iOS 开关校验 → 档位校验 → 建单 → 按渠道产出支付参数"""
# iOS 虚拟支付合规开关(《01-开发文档》:审核期引导至网页端充值)
if platform == "ios" and str(await config_service.get_config(db, redis, "ios_payment.enabled", "1")) == "0":
raise BizException(errors.PARAM_ERROR, "iOS 端暂不支持应用内充值,请前往网页端充值")
points = await _resolve_points(db, redis, req.amount_yuan)
expire_min = int(_usdt_cfg().get("order_expire_min", 30))
expire_at = datetime.now() + timedelta(minutes=expire_min)
order = RechargeOrder(
order_no=membership_service.gen_order_no("R"),
user_id=user_id,
amount_fen=req.amount_yuan * 100,
points=points,
pay_method=req.pay_method,
expire_at=expire_at,
)
resp: dict = {}
if req.pay_method == 3:
# USDT:金额含随机尾差用于订单匹配,有效期内唯一
rate = Decimal(str(_usdt_cfg().get("rate", 7.2)))
for _ in range(10):
amount = usdt.gen_usdt_amount(req.amount_yuan, rate)
clash = (await db.execute(
select(RechargeOrder.id).where(
RechargeOrder.status == 0, RechargeOrder.usdt_amount == amount
)
)).first()
if not clash:
break
else:
raise BizException(errors.INTERNAL_ERROR, "USDT 金额分配失败,请重试")
order.usdt_amount = amount
order.usdt_rate = rate
db.add(order)
await db.commit()
resp = {
"usdt_rate": float(rate),
"usdt_amount": str(amount),
"receive_address": _usdt_cfg().get("address", ""),
"network": _usdt_cfg().get("network", "TRC20"),
}
else:
db.add(order)
await db.commit()
if req.pay_method == 1:
resp = {"pay_params": alipay.create_app_pay_params(order.order_no, order.amount_fen)}
else:
resp = {"pay_params": await wechat.create_app_pay_params(order.order_no, order.amount_fen)}
return {
"order_no": order.order_no,
"pay_method": order.pay_method,
"amount_yuan": req.amount_yuan,
"points": points,
"expire_at": _fmt(expire_at),
**resp,
}
# ---------- 查单 / 取消 ----------
async def _get_own_order(db: AsyncSession, user_id: int, order_no: str) -> RechargeOrder:
order = (await db.execute(
select(RechargeOrder).where(RechargeOrder.order_no == order_no)
)).scalar_one_or_none()
if order is None or order.user_id != user_id:
raise BizException(errors.ORDER_NOT_FOUND)
return order
async def get_order(db: AsyncSession, user_id: int, order_no: str) -> dict:
"""查询订单(8.3):App 轮询用,附当前钱包积分"""
order = await _get_own_order(db, user_id, order_no)
return {
"order_no": order.order_no,
"status": order.status,
"status_text": ORDER_STATUS_TEXT.get(order.status, ""),
"points": order.points,
"paid_at": _fmt(order.paid_at),
"wallet_points": await wallet_service.get_balance(db, user_id),
}
async def cancel_order(db: AsyncSession, user_id: int, order_no: str) -> None:
"""取消订单(8.4):仅待支付可取消"""
order = await _get_own_order(db, user_id, order_no)
if order.status != 0:
raise BizException(errors.ORDER_STATUS_ERROR, "仅待支付订单可取消")
order.status = 2
await db.commit()
# ---------- 入账(回调 / 人工补单共用) ----------
async def settle_order(
db: AsyncSession, order_no: str, third_trade_no: str, paid_amount_fen: int | None = None
) -> RechargeOrder:
"""幂等入账:行锁 → 状态/金额校验 → 置已支付 → 积分到账 → 推荐返利(事件触发,同步补偿)。
third_trade_no 有唯一索引,第三方支付商重复回调天然幂等。
"""
order = (await db.execute(
select(RechargeOrder).where(RechargeOrder.order_no == order_no).with_for_update()
)).scalar_one_or_none()
if order is None:
raise BizException(errors.ORDER_NOT_FOUND)
if order.status != 0:
return order # 已入账/已关闭:幂等直接返回
if paid_amount_fen is not None and paid_amount_fen != order.amount_fen:
raise BizException(errors.AMOUNT_MISMATCH)
order.status = 1
order.third_trade_no = third_trade_no
order.paid_at = datetime.now()
await wallet_service.change_points(
db, order.user_id, order.points, wallet_service.BIZ_RECHARGE,
biz_id=order.order_no, remark="充值到账",
)
await _rebate_for_order(db, order)
await db.commit()
return order
async def _rebate_for_order(db: AsyncSession, order: RechargeOrder) -> None:
"""推荐返利(《02-技术文档》6.6):被推荐人充值成功 → 推荐人为会员则发放 充值积分×5% 资产"""
referral = (await db.execute(
select(Referral).where(Referral.invitee_id == order.user_id, Referral.status == 1)
)).scalar_one_or_none()
if referral is None:
return
if not await membership_service.is_member(db, referral.inviter_id):
return # 发放时点校验推荐人会员身份
percent = Decimal(str((biz_config.get("referral") or {}).get("rebate_percent", 5)))
rebate = (Decimal(order.points) * percent / 100).quantize(Decimal("0.1"))
if rebate <= 0:
return
await incentive_service.change_assets(
db, referral.inviter_id, rebate, incentive_service.ASSET_BIZ_REBATE,
biz_id=order.order_no, remark="推荐用户充值返利",
)
# ---------- 支付平台回调 ----------
async def handle_alipay_callback(db: AsyncSession, form: dict) -> str:
"""支付宝回调(8.5):RSA2 验签 → 校验单号/金额 → 幂等入账 → 纯文本 success"""
if not alipay.verify_callback(form):
return "failure"
if form.get("trade_status") not in ("TRADE_SUCCESS", "TRADE_FINISHED"):
return "failure"
order_no = form.get("out_trade_no", "")
try:
paid_fen = int(Decimal(form.get("total_amount", "0")) * 100)
await settle_order(db, order_no, form.get("trade_no", ""), paid_fen)
except BizException:
return "failure"
return "success"
async def handle_wechat_callback(db: AsyncSession, headers: dict, body: bytes) -> dict:
"""微信支付回调(8.6):V3 验签 + AES-GCM 解密 → 幂等入账 → {"code": "SUCCESS"}"""
plain = wechat.verify_callback(headers, body)
if not plain:
return {"code": "FAIL", "message": "验签失败"}
if plain.get("trade_state") != "SUCCESS":
return {"code": "FAIL", "message": "非成功状态"}
try:
await settle_order(
db,
plain.get("out_trade_no", ""),
plain.get("transaction_id", ""),
int((plain.get("amount") or {}).get("total", 0)),
)
except BizException as e:
return {"code": "FAIL", "message": e.message}
return {"code": "SUCCESS"}