"""支付服务:充值套餐 / 下单 / 查单 / 取消 / 回调幂等入账 + 推荐返利(对应《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"}