feat: add avatar payments and finance management

This commit is contained in:
stefanfeng
2026-09-08 18:44:21 +08:00
parent 1bcdcead8d
commit 7e7aa06903
36 changed files with 10228 additions and 43 deletions
@@ -1,6 +1,7 @@
"""Signed client for Huihui's production payment-v3 service."""
import hashlib
import os
import random
import string
from datetime import datetime, timedelta, timezone
@@ -122,3 +123,59 @@ class HuihuiPaymentClient:
if not isinstance(data, dict):
raise HuihuiPaymentError("会会支付未返回订单信息")
return data
def request_refund(
self,
*,
huihui_token: str,
huihui_user_id: str,
order_no: str,
refund_no: str,
amount: str,
reason: str,
) -> dict[str, Any]:
"""Submit a full refund to payment-v3.
The refund path remains configurable because private Huihui deployments
may expose the same contract below a different gateway route.
"""
if not self.configured:
raise HuihuiPaymentError("会会支付服务未配置")
if not huihui_token or not huihui_user_id:
raise HuihuiPaymentError("当前会会登录凭证无法发起退款")
path = os.getenv("HUIHUI_PAYMENT_REFUND_PATH", "/payment/refund").strip()
if not path.startswith("/"):
path = f"/{path}"
if ".." in path:
raise HuihuiPaymentError("会会退款接口路径配置不正确")
body = {
"appId": self.app_id,
"masterOrderNo": order_no,
"refundOrderNo": refund_no,
"refundAmt": float(amount),
"refundReason": reason or "后台退款",
}
headers = {
"Authorization": f"Bearer {huihui_token}",
"appId": self.app_id,
"windowAppId": self.app_id,
}
try:
response = httpx.post(
f"{self.base_url}{path}",
headers=headers,
params=self._signed_params(huihui_user_id),
json=body,
timeout=self.timeout,
follow_redirects=True,
)
except httpx.HTTPError as exc:
raise HuihuiPaymentError("会会退款连接失败,请稍后重试") from exc
payload = self._json(response)
code = payload.get("code")
if response.status_code >= 400 or code not in (0, 200, "0", "200"):
raise HuihuiPaymentError(payload.get("message") or "会会退款申请失败")
data = payload.get("data") or {}
return data if isinstance(data, dict) else {"result": data}
@@ -0,0 +1,245 @@
"""WeChat mini-program virtual-payment signing and server API adapter.
The AppKey and session_key never leave the backend. The JSON string returned as
``signData`` is exactly the string used for both HMAC signatures.
"""
from __future__ import annotations
import hashlib
import hmac
import json
import os
import time
import xml.etree.ElementTree as ET
from typing import Any
import httpx
REQUEST_VIRTUAL_PAYMENT_URI = "requestVirtualPayment"
PAYMENT_EVENTS = {"xpay_goods_deliver_notify"}
REFUND_EVENTS = {"xpay_refund_notify"}
class WechatVirtualPaymentError(RuntimeError):
pass
def json_compact(payload: dict[str, Any]) -> str:
return json.dumps(payload, ensure_ascii=False, separators=(",", ":"))
def hmac_sha256_hex(key: str, message: str) -> str:
return hmac.new(key.encode("utf-8"), message.encode("utf-8"), hashlib.sha256).hexdigest()
def virtual_env() -> int:
value = os.getenv("WECHAT_VIRTUAL_ENV", "sandbox").strip().lower()
return 0 if value in {"0", "prod", "production", "live", "online"} else 1
def _app_key(env: int) -> str:
name = "WECHAT_VIRTUAL_APP_KEY" if env == 0 else "WECHAT_VIRTUAL_SANDBOX_APP_KEY"
return os.getenv(name, "").strip()
def _offer_id() -> str:
return os.getenv("WECHAT_VIRTUAL_OFFER_ID", "").strip()
def product_id_for_plan(plan) -> str:
configured = str(getattr(plan, "virtual_product_id", "") or "").strip()
if not configured:
configured = os.getenv(f"WECHAT_VIRTUAL_PRODUCT_{plan.id}", "").strip()
if not configured:
raise WechatVirtualPaymentError(f"套餐 {plan.id} 尚未配置微信虚拟支付商品 ID")
if len(configured) > 64 or not all(ch.isalnum() or ch in "_-" for ch in configured):
raise WechatVirtualPaymentError("微信虚拟支付商品 ID 格式不正确")
return configured
def build_payment_params(*, order, plan, session_key: str) -> dict[str, Any]:
env = virtual_env()
offer_id = _offer_id()
app_key = _app_key(env)
if not offer_id or not app_key or not session_key:
raise WechatVirtualPaymentError("微信小程序虚拟支付配置不完整")
sign_data = json_compact({
"offerId": offer_id,
"buyQuantity": 1,
"env": env,
"currencyType": "CNY",
"productId": product_id_for_plan(plan),
"goodsPrice": int(order.price_cents),
"outTradeNo": order.order_no,
"attach": json_compact({"orderNo": order.order_no, "planId": order.plan_id}),
})
return {
"provider": "wechat_virtual",
"payment_channel": "virtual",
"payment_method": "wechat",
"mode": "short_series_goods",
"signData": sign_data,
"paySig": hmac_sha256_hex(app_key, f"{REQUEST_VIRTUAL_PAYMENT_URI}&{sign_data}"),
"signature": hmac_sha256_hex(session_key, sign_data),
"env": env,
"offerId": offer_id,
"outTradeNo": order.order_no,
}
def exchange_code(code: str) -> dict[str, str]:
app_id = os.getenv("WECHAT_MP_APP_ID", "").strip()
app_secret = os.getenv("WECHAT_MP_APP_SECRET", "").strip()
if not app_id or not app_secret:
raise WechatVirtualPaymentError("微信小程序登录配置不完整")
try:
response = httpx.get(
"https://api.weixin.qq.com/sns/jscode2session",
params={
"appid": app_id,
"secret": app_secret,
"js_code": code,
"grant_type": "authorization_code",
},
timeout=15,
)
data = response.json()
except (httpx.HTTPError, ValueError) as exc:
raise WechatVirtualPaymentError("微信登录态交换失败,请稍后重试") from exc
if response.status_code >= 400 or data.get("errcode"):
raise WechatVirtualPaymentError(data.get("errmsg") or "微信登录态交换失败")
openid = str(data.get("openid") or "").strip()
session_key = str(data.get("session_key") or "").strip()
if not openid or not session_key:
raise WechatVirtualPaymentError("微信未返回完整登录态")
return {"openid": openid, "session_key": session_key}
def verify_callback_signature(signature: str, timestamp: str, nonce: str) -> bool:
token = os.getenv("WECHAT_VIRTUAL_CALLBACK_TOKEN", "").strip()
if not token or not signature or not timestamp or not nonce:
return False
source = "".join(sorted([token, timestamp, nonce]))
expected = hashlib.sha1(source.encode("utf-8")).hexdigest()
return hmac.compare_digest(signature, expected)
def _xml_value(element: ET.Element) -> Any:
children = list(element)
if not children:
return element.text or ""
return {child.tag: _xml_value(child) for child in children}
def parse_callback_body(body: bytes) -> dict[str, Any]:
text = body.decode("utf-8", errors="replace").strip()
if not text:
return {}
try:
payload = json.loads(text)
if isinstance(payload, dict):
return payload
except json.JSONDecodeError:
pass
try:
parsed = _xml_value(ET.fromstring(text))
except ET.ParseError as exc:
raise WechatVirtualPaymentError("微信虚拟支付回调格式不正确") from exc
return parsed if isinstance(parsed, dict) else {}
def case_get(payload: Any, key: str) -> Any:
if not isinstance(payload, dict):
return None
lowered = key.lower()
for current, value in payload.items():
if str(current).lower() == lowered:
return value
return None
def callback_value(payload: dict[str, Any], *path: str) -> Any:
current: Any = payload
for key in path:
current = case_get(current, key)
if current is None:
break
return current
_access_token_cache: tuple[str, float] = ("", 0)
def _access_token() -> str:
global _access_token_cache
token, expires_at = _access_token_cache
if token and expires_at > time.monotonic() + 60:
return token
app_id = os.getenv("WECHAT_MP_APP_ID", "").strip()
app_secret = os.getenv("WECHAT_MP_APP_SECRET", "").strip()
if not app_id or not app_secret:
raise WechatVirtualPaymentError("微信小程序服务端配置不完整")
try:
response = httpx.get(
"https://api.weixin.qq.com/cgi-bin/token",
params={"grant_type": "client_credential", "appid": app_id, "secret": app_secret},
timeout=15,
)
data = response.json()
except (httpx.HTTPError, ValueError) as exc:
raise WechatVirtualPaymentError("微信 access_token 获取失败") from exc
if response.status_code >= 400 or data.get("errcode"):
raise WechatVirtualPaymentError(data.get("errmsg") or "微信 access_token 获取失败")
token = str(data.get("access_token") or "")
if not token:
raise WechatVirtualPaymentError("微信未返回 access_token")
_access_token_cache = (token, time.monotonic() + int(data.get("expires_in") or 7200))
return token
def call_xpay(uri: str, payload: dict[str, Any]) -> dict[str, Any]:
env = int(payload.get("env", virtual_env()))
app_key = _app_key(env)
if not app_key:
raise WechatVirtualPaymentError("微信虚拟支付 AppKey 未配置")
body = json_compact(payload)
pay_sig = hmac_sha256_hex(app_key, f"{uri}&{body}")
try:
response = httpx.post(
f"https://api.weixin.qq.com{uri}",
params={"access_token": _access_token(), "pay_sig": pay_sig},
content=body.encode("utf-8"),
headers={"Content-Type": "application/json"},
timeout=20,
)
data = response.json()
except (httpx.HTTPError, ValueError) as exc:
raise WechatVirtualPaymentError("微信虚拟支付服务暂时不可用") from exc
if response.status_code >= 400 or data.get("errcode") not in (None, 0):
raise WechatVirtualPaymentError(data.get("errmsg") or "微信虚拟支付请求失败")
return data
def request_refund(*, openid: str, order_no: str, refund_no: str, amount_cents: int, reason: int = 3) -> dict[str, Any]:
return call_xpay("/xpay/refund_order", {
"openid": openid,
"order_id": order_no,
"refund_order_id": refund_no,
"left_fee": amount_cents,
"refund_fee": amount_cents,
"biz_meta": json_compact({"orderNo": order_no}),
"refund_reason": int(reason),
"req_from": 1,
"env": virtual_env(),
})
def query_order(*, openid: str, order_no: str) -> dict[str, Any]:
return call_xpay("/xpay/query_order", {
"openid": openid,
"order_id": order_no,
"env": virtual_env(),
})