# -*- 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 .. 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) -> dict: """创建充值订单。 client_type: - "mp"(小程序支付,**统一入口**):不在下单时调微信——桌面端展示小程序码, 微信扫码自动打开小程序「确认支付」页,由小程序侧按 openid 发起 JSAPI 支付 (与「小程序扫码登录」同构:桌面出码 → 小程序内确认/支付 → 桌面轮询状态)。 - "jsapi"(小程序内直充):下单即返回 wx.requestPayment 参数。 - "native"(保留:桌面直接出微信收款码,需公众号 appid 绑定商户号)。 """ 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) if dup and dup.get("code_url"): return _order_view(dup) # 3) 引擎用户(幂等建号)并解析 engine_user_id username = user.get("username", "") 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, }) 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"]) try: 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") 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) 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, engine_user={engine_user_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")), "") 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")