256 lines
10 KiB
Python
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"}
|