c8870d5b3b
- users 新增 compute_personal_balance(分):个人充值/企业转入个人账本, 与 company_members.compute_balance(企业分配)彻底分离 - relay 转发引擎后按实际用量记账(deduct_usage_post): 先扣企业分配余额(折扣低→高)、不足扣个人余额,写 compute_usage_records; 流式 SSE 透传时解析 usage chunk 记账,JWT 登录态调用生效、PAT 调用不重复记账 - 个人充值到账(pay/service)与 企业↔个人互转 同步更新个人账本 - 成员退出企业(leave)与管理员移出成员(remove_company_member): 仅返还企业分配剩余(member.compute_balance)至企业余额,同步扣减引擎 quota, 绝不触碰个人充值余额 - GET /compute/user/balance 个人余额改读独立账本(不再引擎倒推) - 迁移 0067;存量个人充值/互转净额已回填
681 lines
34 KiB
Python
681 lines
34 KiB
Python
# -*- coding: utf-8 -*-
|
||
"""充值业务服务:下单 / 回调到账 / 对账补单 / 幂等。
|
||
|
||
到账链路(与全平台算力统一口径一致):
|
||
支付成功 → compute_engine users.quota 增加(1 元 = 1,000,000 微元)→ 平台镜像回写。
|
||
幂等三重保证:Redis SETNX 锁(30s)+ ``mark_paid`` 单向状态机 + 订单终态判定。
|
||
事务边界:引擎到账是跨服务 HTTP,无法与本地 DB 同事务 → 先本地置 paid 终态,
|
||
再引擎加款;引擎失败置 failed,由 status 对账接口补单(不重复加款)。
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
import json
|
||
import logging
|
||
import secrets
|
||
import time
|
||
from datetime import datetime, timedelta, timezone
|
||
|
||
from sqlalchemy import select
|
||
|
||
from .. import config
|
||
from ..infrastructure.repositories import Database, utcnow_iso
|
||
from ..services import compute_client
|
||
from . import config as pay_config
|
||
from . import profitsharing, wxpay
|
||
from .repository import PaymentBindingRepository
|
||
|
||
logger = logging.getLogger("pay.service")
|
||
|
||
# 订单号前缀(回调按前缀分发,为未来其他支付业务留扩展位)
|
||
ORDER_PREFIX = "CR_" # 算力充值
|
||
ORDER_PREFIX_EVENT = "EV_" # 活动报名(课程/沙龙定价,见 event_service)
|
||
|
||
# 套餐缓存(60s:DB 配置优先,env 兜底)
|
||
_pkg_cache: tuple[float, list[dict]] | None = None
|
||
_PKG_TTL = 60.0
|
||
|
||
|
||
def _iso_in(seconds: int) -> str:
|
||
return datetime.fromtimestamp(
|
||
datetime.now(timezone.utc).timestamp() + seconds, tz=timezone.utc,
|
||
).isoformat()
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 套餐
|
||
# ---------------------------------------------------------------------------
|
||
def _normalize_packages(raw) -> list[dict]:
|
||
"""套餐数据清洗:[{id,amount(元),bonus(元),discount(%),enabled,label}],金额全整数分口径。
|
||
|
||
- discount:充值折扣(默认 100=无折扣),实付 = amount * discount / 100
|
||
- enabled:false 时不下发(运营端可临时下架)
|
||
"""
|
||
items: list[dict] = []
|
||
if not isinstance(raw, list):
|
||
return items
|
||
for it in raw:
|
||
if isinstance(it, dict) and it.get("enabled") is False:
|
||
continue
|
||
try:
|
||
amount = int(round(float(it.get("amount", 0)) * 100))
|
||
bonus = int(round(float(it.get("bonus", 0) or 0) * 100))
|
||
discount = int(it.get("discount", 100) or 100)
|
||
except (TypeError, ValueError):
|
||
continue
|
||
if amount <= 0:
|
||
continue
|
||
discount = max(1, min(100, discount))
|
||
items.append({
|
||
"id": str(it.get("id") or f"p{amount}"),
|
||
"amount": amount, # 分(原价)
|
||
"bonus": bonus, # 分(赠送折算金额)
|
||
"discount": discount, # %(100=原价)
|
||
"pay_fen": amount * discount // 100, # 分(实付)
|
||
"label": str(it.get("label") or f"{amount // 100} 元"),
|
||
})
|
||
return items
|
||
|
||
|
||
async def packages(db: Database) -> dict:
|
||
"""充值套餐(system_configs.recharge_packages 优先,env 兜底,60s 缓存)+ 支付开关。"""
|
||
global _pkg_cache
|
||
now = time.monotonic()
|
||
items: list[dict] | None = None
|
||
if _pkg_cache and now - _pkg_cache[0] < _PKG_TTL:
|
||
items = _pkg_cache[1]
|
||
else:
|
||
try:
|
||
raw = await db.config.get("recharge_packages")
|
||
if raw:
|
||
items = _normalize_packages(json.loads(raw))
|
||
except (ValueError, TypeError): # noqa: PERF203
|
||
items = None
|
||
if not items:
|
||
try:
|
||
items = _normalize_packages(json.loads(pay_config.DEFAULT_PACKAGES_JSON))
|
||
except (ValueError, TypeError):
|
||
items = []
|
||
_pkg_cache = (now, items)
|
||
|
||
enabled = pay_config.pay_enabled()
|
||
return {
|
||
"enabled": enabled,
|
||
"items": [
|
||
{
|
||
"id": p["id"], "amount": p["amount"] // 100, "bonus": p["bonus"] // 100,
|
||
"discount": p["discount"], "pay": p["pay_fen"] // 100,
|
||
"label": p["label"],
|
||
"quota_micro": (p["pay_fen"] + p["bonus"]) // 100 * pay_config.QUOTA_PER_YUAN,
|
||
}
|
||
for p in items
|
||
] if enabled else [],
|
||
"min_yuan": pay_config.RECHARGE_MIN_YUAN,
|
||
"max_yuan": pay_config.RECHARGE_MAX_YUAN,
|
||
}
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 下单
|
||
# ---------------------------------------------------------------------------
|
||
async def create_order(db: Database, user: dict, *, client_type: str,
|
||
package_id: str = "", amount_yuan: float | None = None,
|
||
target_type: str = "user", target_id: str = "") -> dict:
|
||
"""创建充值订单。
|
||
|
||
client_type:
|
||
- "mp"(小程序支付,**统一入口**):不在下单时调微信——桌面端展示小程序码,
|
||
微信扫码自动打开小程序「确认支付」页,由小程序侧按 openid 发起 JSAPI 支付
|
||
(与「小程序扫码登录」同构:桌面出码 → 小程序内确认/支付 → 桌面轮询状态)。
|
||
- "jsapi"(小程序内直充):下单即返回 wx.requestPayment 参数。
|
||
- "native"(保留:桌面直接出微信收款码,需公众号 appid 绑定商户号)。
|
||
|
||
target_type:
|
||
- "user":到账到个人 compute-engine quota(默认)
|
||
- "company":到账到企业 park_companies.compute_balance
|
||
"""
|
||
if client_type not in ("mp", "native", "jsapi"):
|
||
raise ValueError("client_type 仅支持 mp/jsapi/native")
|
||
if not pay_config.pay_enabled():
|
||
raise RuntimeError("支付未配置,暂不可用")
|
||
|
||
# 1) 金额:套餐固定价,或自由金额(整数分口径)
|
||
plist = (await packages(db))["items"]
|
||
if package_id:
|
||
pkg = next((p for p in plist if p["id"] == package_id), None)
|
||
if pkg is None:
|
||
raise ValueError("套餐不存在或已下架")
|
||
# 实付按折扣计算(discount 100=原价);packages() 返回的 amount/bonus 单位为元
|
||
amount_fen = pkg["amount"] * pkg["discount"] # 元 × % → 分
|
||
bonus_quota_micro = pkg["bonus"] * pay_config.QUOTA_PER_YUAN
|
||
else:
|
||
amount_fen = int(round(float(amount_yuan or 0) * 100))
|
||
bonus_quota_micro = 0
|
||
if amount_fen <= 0:
|
||
raise ValueError("金额必须大于 0")
|
||
min_fen = int(round(pay_config.RECHARGE_MIN_YUAN * 100))
|
||
max_fen = int(round(pay_config.RECHARGE_MAX_YUAN * 100))
|
||
if not (min_fen <= amount_fen <= max_fen):
|
||
raise ValueError(f"金额需在 {pay_config.RECHARGE_MIN_YUAN:g} ~ {pay_config.RECHARGE_MAX_YUAN:g} 元之间")
|
||
|
||
# 2) 复用未过期同金额 pending 单(防重复扫码)
|
||
now_iso = utcnow_iso()
|
||
dup = await db.compute_recharges.find_pending_same_amount(user["id"], amount_fen, now_iso, target_type)
|
||
if dup and dup.get("code_url"):
|
||
return _order_view(dup)
|
||
|
||
# 3) 引擎用户(幂等建号)并解析 engine_user_id(企业充值不需要,置 0)
|
||
username = user.get("username", "")
|
||
engine_user_id = 0
|
||
if target_type == "user":
|
||
await compute_client.ensure_user(username)
|
||
engine_user_id = await _resolve_engine_user_id(username)
|
||
|
||
# 4) 下单:"mp" 只落库(支付由小程序扫码后发起);native/jsapi 此处即调微信
|
||
order_no = f"{ORDER_PREFIX}{int(time.time())}_{secrets.token_hex(4).upper()}"
|
||
expires_at = _iso_in(pay_config.RECHARGE_EXPIRE_MINUTES * 60)
|
||
|
||
wx_result: dict = {}
|
||
if client_type != "mp":
|
||
try:
|
||
wx_result = await wxpay.create_order(
|
||
client_type=client_type, out_trade_no=order_no, total_fen=amount_fen,
|
||
description=f"算力充值 - {amount_fen // 100} 元", openid=user.get("wx_mini_openid", ""),
|
||
)
|
||
except RuntimeError as exc:
|
||
logger.error("充值下单失败 user=%s: %s", username, exc)
|
||
raise ValueError(f"微信下单失败:{exc}") from exc
|
||
|
||
order = await db.compute_recharges.create({
|
||
"order_no": order_no, "user_id": user["id"], "username": username,
|
||
"engine_user_id": engine_user_id, "package_id": package_id,
|
||
"amount_fen": amount_fen,
|
||
"quota_micro": amount_fen // 100 * pay_config.QUOTA_PER_YUAN,
|
||
"bonus_quota_micro": bonus_quota_micro,
|
||
"client_type": client_type, "appid": wx_result.get("appid", ""),
|
||
"prepay_id": wx_result.get("prepay_id", ""), "code_url": wx_result.get("code_url", ""),
|
||
"expires_at": expires_at,
|
||
"target_type": target_type, "target_id": target_id,
|
||
})
|
||
view = _order_view(order)
|
||
if client_type == "jsapi":
|
||
view["pay_params"] = wx_result.get("pay_params") # wx.requestPayment 直接参数
|
||
if client_type == "mp":
|
||
# 小程序码:微信扫码自动打开小程序「确认支付」页并携带 scene=order_no
|
||
# (order_no 22 字符,满足 wxacode scene ≤32 限制)
|
||
from ..services import wechat
|
||
try:
|
||
png = await wechat.get_wxacode(order_no, page="pages-extra/pay/index")
|
||
import base64 as _b64
|
||
view["qr_image"] = f"data:image/png;base64,{_b64.b64encode(png).decode('ascii')}"
|
||
except wechat.WechatError as exc:
|
||
logger.error("生成小程序码失败 order=%s: %s", order_no, exc)
|
||
view["qr_image"] = ""
|
||
return view
|
||
|
||
|
||
async def build_pay_params(db: Database, user: dict, order_no: str) -> dict:
|
||
"""小程序扫码进入「确认支付」页:按当前小程序用户 openid 发起 JSAPI 下单,
|
||
返回订单摘要 + wx.requestPayment 参数。支持代付:任意登录用户均可支付,
|
||
余额到账到订单创建者(按订单 user_id)。"""
|
||
order = await db.compute_recharges.get_by_order_no(order_no)
|
||
if order is None:
|
||
raise ValueError("订单不存在")
|
||
if order["status"] != "pending":
|
||
return {"order": _order_view(order), "pay_params": None, "finished": True}
|
||
if order["expires_at"] and order["expires_at"] < utcnow_iso():
|
||
await db.compute_recharges.mark_status(order_no, "closed")
|
||
return {"order": await db.compute_recharges.get_by_order_no(order_no), "pay_params": None, "finished": True}
|
||
openid = user.get("wx_mini_openid", "")
|
||
if not openid:
|
||
raise ValueError("当前账号未绑定小程序微信身份,请先用微信登录小程序")
|
||
try:
|
||
result = await wxpay.create_order(
|
||
client_type="jsapi", out_trade_no=order_no, total_fen=int(order["amount_fen"]),
|
||
description=f"算力充值 - {int(order['amount_fen']) // 100} 元", openid=openid,
|
||
)
|
||
except RuntimeError as exc:
|
||
logger.error("小程序拉起支付失败 order=%s: %s", order_no, exc)
|
||
raise ValueError(f"微信下单失败:{exc}") from exc
|
||
# 记录 prepay/appid(不改变 pending 状态)
|
||
await db.compute_recharges.set_prepay(order_no, result.get("appid", ""), result.get("prepay_id", ""))
|
||
return {
|
||
"order": _order_view(order),
|
||
"pay_params": result.get("pay_params"),
|
||
"finished": False,
|
||
}
|
||
|
||
|
||
def _order_view(order: dict) -> dict:
|
||
"""对外的订单视图(不含内部载荷)。"""
|
||
return {
|
||
"order_no": order["order_no"], "amount_fen": order["amount_fen"],
|
||
"quota_micro": order["quota_micro"], "bonus_quota_micro": order["bonus_quota_micro"],
|
||
"status": order["status"], "code_url": order["code_url"],
|
||
"prepay_id": order.get("prepay_id", ""), "expires_at": order["expires_at"],
|
||
"created_at": order["created_at"], "paid_at": order.get("paid_at", ""),
|
||
}
|
||
|
||
|
||
async def _resolve_engine_user_id(username: str) -> int:
|
||
"""按用户名在引擎侧解析用户 id(失败返回 0,到账时再试)。"""
|
||
try:
|
||
for page in (1, 2):
|
||
for it in await compute_client.list_engine_users(page=page, page_size=100):
|
||
if str(it.get("username") or "") == username:
|
||
return int(it.get("id") or 0)
|
||
except Exception as exc: # noqa: BLE001
|
||
logger.warning("解析引擎用户 id 失败 %s: %s", username, exc)
|
||
return 0
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 回调 / 到账
|
||
# ---------------------------------------------------------------------------
|
||
async def handle_notify(db: Database, result: dict) -> bool:
|
||
"""微信回调分发:CR_=充值到账,EV_=活动报名确认,MK_=市场购买;未知前缀忽略。"""
|
||
out_trade_no = result.get("out_trade_no", "")
|
||
if out_trade_no.startswith(ORDER_PREFIX):
|
||
return await process_paid_order(db, out_trade_no, result)
|
||
if out_trade_no.startswith(ORDER_PREFIX_EVENT):
|
||
from .event_service import process_paid_event_order
|
||
return await process_paid_event_order(db, out_trade_no, result)
|
||
if out_trade_no.startswith("MK_"):
|
||
from ..market import service as market_service
|
||
return await market_service.handle_notify(db, out_trade_no, result)
|
||
logger.warning("未知订单类型回调: %s", out_trade_no)
|
||
return True
|
||
|
||
|
||
async def process_paid_order(db: Database, order_no: str, wechat_data: dict) -> bool:
|
||
"""支付成功 → 引擎加款(幂等)。返回 False = 处理失败(触发微信重试)。"""
|
||
order = await db.compute_recharges.get_by_order_no(order_no)
|
||
if order is None:
|
||
logger.warning("充值订单不存在: %s", order_no)
|
||
return True
|
||
if order["status"] in ("paid", "credited"):
|
||
return True # 已处理(回调重放/并发),幂等成功
|
||
|
||
# 幂等排他:仅 pending → paid 成功者继续到账
|
||
payload_json = json.dumps(wechat_data, ensure_ascii=False)[:8000]
|
||
got = await db.compute_recharges.mark_paid(order_no, wechat_data.get("transaction_id", ""), payload_json)
|
||
if not got:
|
||
return True
|
||
|
||
total_micro = int(order["quota_micro"]) + int(order["bonus_quota_micro"])
|
||
target_type = order.get("target_type", "user")
|
||
target_id = order.get("target_id", "")
|
||
try:
|
||
if target_type == "company":
|
||
# 企业充值:到账到 park_companies.compute_balance(单位分)
|
||
# 1元 = 100分 = 1,000,000 micro → 1分 = 10,000 micro
|
||
total_fen = total_micro // 10000
|
||
from ..park.models import ParkCompany
|
||
result = await db.session.execute(
|
||
select(ParkCompany).where(ParkCompany.id == target_id)
|
||
)
|
||
company = result.scalar_one_or_none()
|
||
if company is None:
|
||
raise RuntimeError(f"企业不存在: {target_id}")
|
||
company.compute_balance = (company.compute_balance or 0) + total_fen
|
||
await db.session.commit()
|
||
engine_user_id = 0
|
||
await db.audit.add(action="compute.company_recharge_credited", resource="park_company",
|
||
resource_id=target_id, detail=f"{total_fen} fen ({total_micro} micro)",
|
||
user_id=order["user_id"])
|
||
else:
|
||
# 个人充值:到账到 compute-engine user quota
|
||
engine_user_id = int(order["engine_user_id"] or 0)
|
||
if not engine_user_id:
|
||
engine_user_id = await _resolve_engine_user_id(order["username"])
|
||
if not engine_user_id:
|
||
raise RuntimeError(f"引擎用户未解析: {order['username']}")
|
||
await compute_client.adjust_user_quota(engine_user_id, total_micro, "add")
|
||
# 平台侧个人余额账本同步(分):个人充值 → users.compute_personal_balance
|
||
from ..infrastructure.models import User as _User
|
||
from sqlalchemy import update as _sa_update
|
||
total_fen = total_micro // 10000
|
||
if total_fen > 0:
|
||
await db.session.execute(
|
||
_sa_update(_User)
|
||
.where(_User.id == order["user_id"])
|
||
.values(compute_personal_balance=_User.compute_personal_balance + total_fen)
|
||
)
|
||
except Exception as exc: # noqa: BLE001
|
||
logger.error("充值到账失败 order=%s: %s", order_no, exc, exc_info=True)
|
||
await db.compute_recharges.mark_status(order_no, "failed")
|
||
await db.audit.add(action="compute.recharge_failed", resource="compute_recharge_order",
|
||
resource_id=order_no, detail=f"{total_micro} micro: {exc}",
|
||
user_id=order["user_id"])
|
||
return False
|
||
|
||
await db.compute_recharges.mark_credited(order_no)
|
||
if target_type == "user" and engine_user_id:
|
||
await compute_client.sync_user_mirror(db, engine_user_id) # best-effort 镜像回写
|
||
await db.audit.add(action="compute.recharge_credited", resource="compute_recharge_order",
|
||
resource_id=order_no, detail=f"{total_micro} micro, target={target_type}:{target_id}",
|
||
user_id=order["user_id"])
|
||
logger.info("充值到账成功 order=%s user=%s micro=%s", order_no, order["username"], total_micro)
|
||
return True
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 对账 / 过期
|
||
# ---------------------------------------------------------------------------
|
||
async def reconcile(db: Database, order: dict) -> dict:
|
||
"""pending 单对账:查微信侧状态,已支付则补单;超时未付置 closed。"""
|
||
if order["status"] != "pending":
|
||
return order
|
||
if order["expires_at"] and order["expires_at"] < utcnow_iso():
|
||
await db.compute_recharges.mark_status(order["order_no"], "closed")
|
||
return await db.compute_recharges.get_by_order_no(order["order_no"]) or order
|
||
data = await wxpay.query_order(order["order_no"])
|
||
state = (data or {}).get("trade_state", "")
|
||
if state == "SUCCESS":
|
||
if not await process_paid_order(db, order["order_no"], data):
|
||
logger.error("对账补单失败 order=%s", order["order_no"])
|
||
elif state in ("CLOSED", "PAY_ERROR", "REVOKED"):
|
||
await db.compute_recharges.mark_status(order["order_no"], "closed")
|
||
return await db.compute_recharges.get_by_order_no(order["order_no"]) or order
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 分账结果回调(微信服务商分账)
|
||
# ---------------------------------------------------------------------------
|
||
async def handle_profitsharing_notify(db: Database, result: dict) -> bool:
|
||
"""分账回调分发 → 结算服务确认 escrow released(幂等)。"""
|
||
from ..services.settlement_service import SettlementService
|
||
return await SettlementService(db).handle_split_notify(result)
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# OPC 收款绑定(个人 openid / 商户子商户号 双路径)
|
||
# ---------------------------------------------------------------------------
|
||
def _bindings(db: Database) -> PaymentBindingRepository:
|
||
return PaymentBindingRepository(db.session)
|
||
|
||
|
||
async def bind_personal(db: Database, user: dict, real_name: str = "") -> dict:
|
||
"""个人绑定:把 OPC 小程序 openid 添加为 PERSONAL_OPENID 分账接收方。
|
||
|
||
前置:用户已用小程序微信登录(users.wx_mini_openid 已存)。
|
||
"""
|
||
openid = user.get("wx_mini_openid", "")
|
||
if not openid:
|
||
raise ValueError("当前账号未绑定小程序微信身份,请先用微信登录小程序")
|
||
if not pay_config.profitsharing_enabled():
|
||
raise RuntimeError("服务商分账未配置,暂不可绑定")
|
||
name = real_name or user.get("nickname", "")
|
||
# 服务商分账添加个人接收方需要"出资特约商户号";优先取该用户已进件的商户绑定,否则用平台默认特约商户号
|
||
rows = await _bindings(db).list_by_user(user["id"])
|
||
payer_sub_mchid = next((b.get("sub_mchid") for b in rows
|
||
if b.get("bind_type") == "merchant" and b.get("sub_mchid")), "")
|
||
if not payer_sub_mchid:
|
||
payer_sub_mchid = pay_config.WECHATPAY_DEFAULT_SUB_MCHID
|
||
if not payer_sub_mchid:
|
||
raise RuntimeError(
|
||
"平台未配置默认特约商户号(PINEAGENTS_WX_DEFAULT_SUB_MCHID),"
|
||
"个人收款需指定出资特约商户号。请先在服务商平台完成特约商户进件并配置。"
|
||
)
|
||
try:
|
||
await profitsharing.add_receiver(
|
||
account_type="PERSONAL_OPENID", account=openid, name=name,
|
||
sub_mchid=payer_sub_mchid,
|
||
)
|
||
except RuntimeError as exc:
|
||
raise ValueError(f"微信添加分账接收方失败:{exc}") from exc
|
||
|
||
repo = _bindings(db)
|
||
existing = await repo.get_active(user["id"], "personal")
|
||
if existing is not None:
|
||
return await repo.update(existing["id"], {"real_name": name, "status": "active"})
|
||
return await repo.create({
|
||
"user_id": user["id"], "bind_type": "personal",
|
||
"openid": openid, "real_name": name, "status": "active",
|
||
})
|
||
|
||
|
||
async def bind_merchant(db: Database, user: dict, sub_mchid: str = "",
|
||
applyment_id: str = "") -> dict:
|
||
"""商户绑定:记录特约商户进件(子商户号)并把 sub_mchid 添加为 MERCHANT_ID 接收方。
|
||
|
||
- 进件为异步微信审核:有 applyment_id 时记录 applying,子商户号回填后置 active;
|
||
- 有 sub_mchid 时直接调微信添加接收方并置 active。
|
||
"""
|
||
if not sub_mchid and not applyment_id:
|
||
raise ValueError("sub_mchid 或 applyment_id 至少提供一个")
|
||
repo = _bindings(db)
|
||
binding = await repo.create({
|
||
"user_id": user["id"], "bind_type": "merchant",
|
||
"sub_mchid": sub_mchid, "applyment_id": applyment_id,
|
||
"status": "active" if sub_mchid else "applying",
|
||
})
|
||
if sub_mchid and pay_config.profitsharing_enabled():
|
||
try:
|
||
# 出资方=OPC 自己的特约商户号(与 release_escrow 分账模型一致:资金冻结在 OPC 账户)
|
||
await profitsharing.add_receiver(
|
||
account_type="MERCHANT_ID", account=sub_mchid, sub_mchid=sub_mchid,
|
||
)
|
||
except RuntimeError as exc:
|
||
# 接收方添加失败不影响进件记录;置 failed 待人工处理
|
||
return await repo.set_status(binding["id"], "failed",
|
||
detail=str(exc)[:300])
|
||
return binding
|
||
|
||
|
||
async def update_merchant_applyment(db: Database, user_id: str, applyment_id: str,
|
||
sub_mchid: str) -> dict | None:
|
||
"""进件审核通过后回填子商户号并激活(运营/回调调用)。"""
|
||
repo = _bindings(db)
|
||
rows = await repo.list_by_user(user_id)
|
||
b = next((x for x in rows if x["applyment_id"] == applyment_id), None)
|
||
if b is None:
|
||
return None
|
||
return await repo.update(b["id"], {"sub_mchid": sub_mchid, "status": "active"})
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 特约商户进件(主动进件路径:平台代提交 → 微信审核 → OPC 扫码签约 → 回填激活)
|
||
# ---------------------------------------------------------------------------
|
||
def _already_merchant_applyment(bindings: list[dict]) -> dict | None:
|
||
"""已有进行中(applying/active)的商户绑定则返回,避免重复进件。"""
|
||
return next((b for b in bindings if b["bind_type"] == "merchant"
|
||
and b["status"] in ("applying", "active")), None)
|
||
|
||
|
||
async def create_merchant_applyment(db: Database, user: dict, *, contact_info: dict,
|
||
subject_info: dict, business_info: dict,
|
||
settlement_info: dict, bank_account_info: dict,
|
||
addition_info: dict | None = None) -> dict:
|
||
"""发起特约商户进件(平台以服务商身份代提交)。
|
||
|
||
- 幂等:该 OPC 已有 applying/active 的商户绑定 → 直接返回已有,不重复进件;
|
||
- 敏感资料仅透传微信(wechatpayv3 内部加密),平台只落 applyment_id / status;
|
||
- 返回 {applyment_id, applyment_state, binding}。
|
||
"""
|
||
if not pay_config.profitsharing_enabled():
|
||
raise RuntimeError("服务商分账未配置,暂不可进件")
|
||
repo = _bindings(db)
|
||
rows = await repo.list_by_user(user["id"])
|
||
existing = _already_merchant_applyment(rows)
|
||
if existing is not None:
|
||
return {"applyment_id": existing["applyment_id"], "applyment_state": "EXISTING",
|
||
"binding": existing}
|
||
|
||
# 补充超级管理员 openid(小程序身份;用于进件后微信端签约激活)
|
||
if contact_info.get("openid") in (None, ""):
|
||
openid = user.get("wx_mini_openid", "")
|
||
if openid:
|
||
contact_info = {**contact_info, "openid": openid}
|
||
|
||
business_code = f"AP{int(time.time())}_{secrets.token_hex(4).upper()}"
|
||
try:
|
||
wx = await profitsharing.submit_applyment(
|
||
business_code=business_code, contact_info=contact_info,
|
||
subject_info=subject_info, business_info=business_info,
|
||
settlement_info=settlement_info, bank_account_info=bank_account_info,
|
||
addition_info=addition_info,
|
||
)
|
||
except RuntimeError as exc:
|
||
raise ValueError(f"微信进件提交失败:{exc}") from exc
|
||
|
||
applyment_id = str(wx.get("applyment_id") or "")
|
||
# 提交接口一般只返回 applyment_id;若返回 state 则规范化(去掉 APPLYMENT_STATE_ 前缀)
|
||
state = profitsharing.normalize_applyment_state(wx.get("applyment_state") or "") or "AUDITING"
|
||
if not applyment_id:
|
||
raise ValueError("微信未返回进件申请单号,请稍后重试")
|
||
# 进件提交阶段微信即可能返回 sign_url(超管扫码完成验证+签约的入口)
|
||
sign_url = str(wx.get("sign_url") or "")
|
||
binding = await repo.create({
|
||
"user_id": user["id"], "bind_type": "merchant",
|
||
"applyment_id": applyment_id, "status": "applying",
|
||
"applyment_state": state, "sign_url": sign_url,
|
||
"audit_detail_json": json.dumps({"applyment_state_msg": str(
|
||
wx.get("applyment_state_msg") or "")}, ensure_ascii=False),
|
||
})
|
||
logger.info("发起特约商户进件 user=%s applyment_id=%s state=%s",
|
||
user["id"], applyment_id, state)
|
||
return {"applyment_id": applyment_id, "applyment_state": state,
|
||
"sign_url": sign_url, "binding": binding}
|
||
|
||
|
||
async def query_merchant_applyment(db: Database, user: dict, binding_id: str) -> dict:
|
||
"""查询进件状态;按微信 applyment_state 全量映射并落库。
|
||
|
||
- FINISHED → 回填 sub_mchid,调微信添加 MERCHANT_ID 接收方 → active;
|
||
- TO_BE_SIGNED → 已出 sub_mchid,待超管扫码签约 → 回填 sub_mchid + sign_url(不激活);
|
||
- TO_BE_CONFIRMED / SIGNING → 落库 sign_url / account_validation,前端展示扫码引导;
|
||
- REJECTED → 置 rejected(落库 audit_detail 驳回原因);
|
||
- EDITTING / AUDITING / CANCELED → 状态如实返回。
|
||
"""
|
||
repo = _bindings(db)
|
||
b = await repo.get(binding_id)
|
||
if b is None or b["user_id"] != user["id"]:
|
||
raise ValueError("绑定不存在")
|
||
if b["status"] == "active" and b.get("sub_mchid"):
|
||
return {"applyment_id": b["applyment_id"], "applyment_state": "FINISHED",
|
||
"sub_mchid": b["sub_mchid"], "binding": b}
|
||
applyment_id = b.get("applyment_id") or ""
|
||
if not applyment_id:
|
||
return {"applyment_state": "NO_APPLYMENT", "binding": b}
|
||
if not pay_config.profitsharing_enabled():
|
||
return {"applyment_state": "NOT_CONFIGURED", "binding": b}
|
||
|
||
data = await profitsharing.query_applyment(applyment_id=applyment_id)
|
||
if data is None:
|
||
raise RuntimeError("查询进件状态失败,请稍后重试")
|
||
state = data.get("applyment_state") or ""
|
||
sub_mchid = str(data.get("sub_mchid") or "")
|
||
sign_url = str(data.get("sign_url") or "")
|
||
account_validation = data.get("account_validation")
|
||
audit_detail = data.get("audit_detail")
|
||
state_msg = data.get("applyment_state_msg") or ""
|
||
audit_json = {}
|
||
if audit_detail is not None:
|
||
audit_json["audit_detail"] = audit_detail
|
||
if state_msg:
|
||
audit_json["applyment_state_msg"] = state_msg
|
||
valid_json = json.dumps(account_validation or {}, ensure_ascii=False)
|
||
audit_json_str = json.dumps(audit_json, ensure_ascii=False) if audit_json else "{}"
|
||
|
||
def _persist(extra: dict) -> dict:
|
||
fields = {"applyment_state": state, "account_validation_json": valid_json,
|
||
"audit_detail_json": audit_json_str}
|
||
if sign_url:
|
||
fields["sign_url"] = sign_url
|
||
if sub_mchid:
|
||
fields["sub_mchid"] = sub_mchid
|
||
fields.update(extra)
|
||
return repo.update(binding_id, fields)
|
||
|
||
if state == "FINISHED":
|
||
if not sub_mchid:
|
||
raise RuntimeError("进件已通过但未返回子商户号,请稍后重试")
|
||
try:
|
||
# 出资方=OPC 自己的特约商户号;微信允许出资商户作为自身接收方(分账回留/解冻模型)
|
||
await profitsharing.add_receiver(
|
||
account_type="MERCHANT_ID", account=sub_mchid, sub_mchid=sub_mchid,
|
||
)
|
||
b = await _persist({"status": "active", "detail": "进件通过,已添加分账接收方"})
|
||
except RuntimeError as exc:
|
||
msg = str(exc)
|
||
if any(k in msg for k in ("已存在", "EXIST", "RECEIVER", "already")):
|
||
b = await _persist({"status": "active", "detail": "进件通过,接收方已存在"})
|
||
else:
|
||
b = await _persist({"status": "failed", "detail": f"添加接收方失败:{msg[:200]}"})
|
||
logger.info("进件完成回填 user=%s applyment_id=%s sub_mchid=%s state=%s",
|
||
user["id"], applyment_id, sub_mchid, b["status"])
|
||
return {"applyment_id": applyment_id, "applyment_state": state,
|
||
"sub_mchid": sub_mchid, "sign_url": sign_url, "binding": b}
|
||
|
||
if state == "TO_BE_SIGNED":
|
||
# 已下发子商户号,待超管扫码完成签约授权(账户验证 + 签约一体)
|
||
b = await _persist({"status": "applying",
|
||
"detail": state_msg or "待超管扫码完成签约授权(含账户验证)"})
|
||
return {"applyment_id": applyment_id, "applyment_state": state,
|
||
"sub_mchid": sub_mchid, "sign_url": sign_url,
|
||
"account_validation": account_validation, "binding": b}
|
||
|
||
if state in profitsharing.APPLYMENT_ACTION_STATES:
|
||
# TO_BE_CONFIRMED(待账户验证)/ SIGNING(开通权限中):落库 sign_url 供前端扫码引导
|
||
b = await _persist({"status": "applying", "detail": state_msg or
|
||
profitsharing.APPLYMENT_STATE.get(state, state)})
|
||
return {"applyment_id": applyment_id, "applyment_state": state,
|
||
"sign_url": sign_url, "account_validation": account_validation, "binding": b}
|
||
|
||
if state == "REJECTED":
|
||
reason = ""
|
||
if isinstance(audit_detail, dict):
|
||
reasons = audit_detail.get("reject_reason") or []
|
||
reason = ";".join(str(r) for r in reasons) if reasons else ""
|
||
reason = reason or state_msg or str(audit_detail or "")[:300]
|
||
b = await _persist({"status": "rejected", "detail": reason or "进件被驳回"})
|
||
return {"applyment_id": applyment_id, "applyment_state": state,
|
||
"audit_detail": audit_detail, "binding": b}
|
||
# EDITTING / AUDITING / CANCELED 等:状态如实返回
|
||
b = await _persist({})
|
||
return {"applyment_id": applyment_id, "applyment_state": state,
|
||
"sign_url": sign_url, "binding": b}
|
||
|
||
|
||
async def query_split_permission(db: Database, user: dict, binding_id: str) -> dict:
|
||
"""查询子商户分账授权最大比例(/v3/profitsharing/merchant-configs)。
|
||
|
||
分账授权需子商户在【商户平台 > 产品中心 > 分账】开通分账并设置允许服务商分账的最大比例
|
||
(默认上限 30%,接口无独立开关字段,以能否查到 max_ratio 为准)。
|
||
本接口查询微信侧真实配置回填 payment_bindings.split_max_ratio(万分比),供运营/OPC 确认分账可用性。
|
||
"""
|
||
repo = _bindings(db)
|
||
b = await repo.get(binding_id)
|
||
if b is None or b["user_id"] != user["id"]:
|
||
raise ValueError("绑定不存在")
|
||
if b["bind_type"] != "merchant" or not b.get("sub_mchid"):
|
||
raise ValueError("仅已下放子商户号的商户绑定可查询分账授权")
|
||
cfg = await profitsharing.query_split_config(sub_mchid=b["sub_mchid"])
|
||
if cfg is None:
|
||
# 接口返回非 200(如 403 NO_AUTH)→ 子商户未开通分账或服务商未开通分账权限
|
||
raise RuntimeError("子商户尚未开通分账授权:请子商户在微信商户平台【产品中心>分账】开通,"
|
||
"并设置允许服务商分账的最大比例;同时确认服务商已开通分账权限")
|
||
max_ratio_bp = int(cfg.get("max_ratio") or 0) # 万分比(2000=20%)
|
||
max_ratio_pct = float(cfg.get("max_ratio_percent") or 0) # 百分比
|
||
b = await repo.update(binding_id, {
|
||
"split_allowed": "OPEN" if max_ratio_bp > 0 else "CLOSED",
|
||
"split_max_ratio": max_ratio_bp,
|
||
})
|
||
return {"sub_mchid": b["sub_mchid"], "split_allowed": b["split_allowed"],
|
||
"split_max_ratio": max_ratio_bp, "split_max_ratio_percent": max_ratio_pct,
|
||
"binding": b}
|
||
|
||
|
||
async def list_bindings(db: Database, user: dict) -> list[dict]:
|
||
"""我的收款绑定列表。"""
|
||
return await _bindings(db).list_by_user(user["id"])
|
||
|
||
|
||
async def disable_binding(db: Database, user: dict, binding_id: str) -> dict:
|
||
"""停用绑定(结清后可停用,避免误分账)。"""
|
||
repo = _bindings(db)
|
||
b = await repo.get(binding_id)
|
||
if b is None or b["user_id"] != user["id"]:
|
||
raise ValueError("绑定不存在")
|
||
return await repo.set_status(binding_id, "disabled")
|