feat(avatar): route token recharge through Huihui org orders

This commit is contained in:
stefanfeng
2026-10-16 14:56:00 +08:00
parent 9566eb8925
commit b1bf203018
9 changed files with 171 additions and 67 deletions
+1
View File
@@ -395,6 +395,7 @@ class TokenPaymentOrder(Base):
"price": self.price_cents / 100, "price": self.price_cents / 100,
"status": self.status, "status": self.status,
"provider": self.provider, "provider": self.provider,
"providerOrderNo": self.provider_order_no,
"providerStatus": self.provider_status, "providerStatus": self.provider_status,
"payMessage": self.pay_message, "payMessage": self.pay_message,
"failureReason": self.failure_reason, "failureReason": self.failure_reason,
@@ -25,6 +25,7 @@ from models import (
) )
from responses import fail, ok from responses import fail, ok
from services.huihui_payment import HuihuiPaymentClient, HuihuiPaymentError from services.huihui_payment import HuihuiPaymentClient, HuihuiPaymentError
from services.huihui_org_payment import HuihuiOrgPaymentClient
from services.token_billing import get_or_create_account from services.token_billing import get_or_create_account
from services.wechat_virtual_payment import ( from services.wechat_virtual_payment import (
PAYMENT_EVENTS as WECHAT_PAYMENT_EVENTS, PAYMENT_EVENTS as WECHAT_PAYMENT_EVENTS,
@@ -270,6 +271,7 @@ def _callback_amount_cents(payload) -> int | None:
"orderAmt", "orderAmt",
"amount", "amount",
"totalAmount", "totalAmount",
"chargeAmount",
) )
if value in (None, ""): if value in (None, ""):
return None return None
@@ -405,6 +407,47 @@ def charge(payload: dict = Body(...), authorization: str = Header(None), db: Ses
return ok(_payment_payload(order, get_or_create_account(db, user.id))) return ok(_payment_payload(order, get_or_create_account(db, user.id)))
@router.post("/token/charge/org")
def charge_via_huihui_org(payload: dict = Body(...), authorization: str = Header(None), db: Session = Depends(get_db)):
user = _require_user(authorization, db)
plan = db.query(TokenPlan).filter(TokenPlan.id == payload.get("planId")).first()
if not plan:
return fail("套餐不存在", 404)
method = str(payload.get("paymentMethod") or "").lower()
if method not in PAYMENT_METHODS:
return fail("支付方式不正确", 400)
scene = str(payload.get("payScene") or "APP").upper()
if scene not in PAYMENT_SCENES:
return fail("支付场景不正确", 400)
pay_type = 6 if scene == "LITE" else (1 if method == "wechat" else 2)
if scene == "LITE" and method != "wechat":
return fail("微信小程序仅支持微信支付", 400)
cents = _price_cents(plan.price)
try:
result = HuihuiOrgPaymentClient().create_order(
token=user.huihui_token, user_id=user.huihui_user_id,
name=user.nickname, phone=user.phone, avatar=user.avatar_url,
amount=f"{cents / 100:.2f}", pay_type=pay_type,
)
except HuihuiPaymentError as exc:
return fail(str(exc), 502)
provider_no = str(result.get("orderNo") or "")
if not provider_no.isdigit() or int(result.get("payStatus") or 0) != 1 or int(result.get("chargeType") or 0) != 9:
return fail("会会返回的充值订单不正确", 502)
if _callback_amount_cents({"chargeAmount": result.get("chargeAmount")}) != cents:
return fail("会会返回的充值金额不匹配", 502)
order = TokenPaymentOrder(
order_no=f"AV{datetime.utcnow().strftime('%Y%m%d%H%M%S')}{uuid.uuid4().hex[:12].upper()}",
user_id=user.id, plan_id=plan.id, payment_method=method,
pay_type=PAYMENT_METHODS[method], pay_way=scene, points_amount=plan.amount,
price_cents=cents, status="pending", provider="huihui_org",
provider_order_no=provider_no, provider_status="1",
)
db.add(order)
db.commit()
return ok(_payment_payload(order, get_or_create_account(db, user.id)))
@router.get("/token/payment/{order_id}") @router.get("/token/payment/{order_id}")
def payment_status(order_id: str, authorization: str = Header(None), db: Session = Depends(get_db)): def payment_status(order_id: str, authorization: str = Header(None), db: Session = Depends(get_db)):
user = _require_user(authorization, db) user = _require_user(authorization, db)
@@ -414,6 +457,27 @@ def payment_status(order_id: str, authorization: str = Header(None), db: Session
).first() ).first()
if not order: if not order:
return fail("支付订单不存在", 404) return fail("支付订单不存在", 404)
if order.provider == "huihui_org" and order.status == "pending":
try:
details = HuihuiOrgPaymentClient().get_order(
token=user.huihui_token, user_id=user.huihui_user_id,
order_no=order.provider_order_no,
)
status = int(details.get("payStatus") or 0)
if (str(details.get("orderNo") or "") == order.provider_order_no
and str(details.get("userId") or "") == user.huihui_user_id
and int(details.get("chargeType") or 0) == 9
and _callback_amount_cents({"chargeAmount": details.get("chargeAmount")}) == order.price_cents):
order.provider_status = str(status)
if status in (2, 3, 4):
_settle_paid_order(db, order, provider_status=f"ORG_{status}",
transaction_no=order.provider_order_no)
elif status == 5:
order.status = "closed"
db.commit()
db.refresh(order)
except (HuihuiPaymentError, TypeError, ValueError):
pass # 查询失败时保持待支付,不将未经核对的结果入账。
if order.provider == "wechat_virtual" and order.status == "pending" and user.wechat_mp_openid: if order.provider == "wechat_virtual" and order.status == "pending" and user.wechat_mp_openid:
try: try:
provider_data = query_wechat_virtual_order( provider_data = query_wechat_virtual_order(
@@ -560,6 +624,8 @@ def admin_request_refund(
return fail("订单不存在", 404) return fail("订单不存在", 404)
if order.status != "paid" or order.refund_status not in {"", "none", "failed"}: if order.status != "paid" or order.refund_status not in {"", "none", "failed"}:
return fail("该订单当前不可退款", 409) return fail("该订单当前不可退款", 409)
if order.provider == "huihui_org":
return fail("会会组织订单的原路退款接口尚未接入,请先在会会后台处理", 409)
account = get_or_create_account(db, order.user_id) account = get_or_create_account(db, order.user_id)
if int(account.balance or 0) < int(order.points_amount or 0): if int(account.balance or 0) < int(order.points_amount or 0):
return fail("该订单发放的积分已使用,不能执行全额退款", 409) return fail("该订单发放的积分已使用,不能执行全额退款", 409)
@@ -0,0 +1,48 @@
"""Huihui organization token-recharge order API."""
import os
import httpx
from services.huihui_payment import HuihuiPaymentClient, HuihuiPaymentError
class HuihuiOrgPaymentClient(HuihuiPaymentClient):
def __init__(self):
super().__init__({
"HUIHUI_PAYMENT_BASE_URL": os.getenv("HUIHUI_ORG_BASE_URL", ""),
"HUIHUI_APP_ID": os.getenv("HUIHUI_APP_ID", ""),
"HUIHUI_ACCESS_ID": os.getenv("HUIHUI_ACCESS_ID", ""),
"HUIHUI_ACCESS_SECRET": os.getenv("HUIHUI_ACCESS_SECRET", ""),
})
def _request(self, method: str, path: str, *, token: str, user_id: str, body=None) -> dict:
if not self.configured or not token or not user_id:
raise HuihuiPaymentError("会会订单服务或登录凭证未配置")
try:
response = httpx.request(
method, f"{self.base_url}{path}",
headers={"Authorization": f"Bearer {token}", "appId": self.app_id},
params=self._signed_params(user_id), json=body,
timeout=self.timeout, follow_redirects=True,
)
except httpx.HTTPError as exc:
raise HuihuiPaymentError("会会订单服务连接失败") from exc
payload = self._json(response)
if response.status_code >= 400 or payload.get("code") not in (0, 200, "0", "200"):
raise HuihuiPaymentError(str(payload.get("message") or "会会订单请求失败"))
data = payload.get("data")
if not isinstance(data, dict):
raise HuihuiPaymentError("会会订单信息缺失")
return data
def create_order(self, *, token: str, user_id: str, name: str, phone: str,
avatar: str, amount: str, pay_type: int) -> dict:
body = {"chargeAmount": float(amount), "payType": pay_type,
"commodityName": "数字分身积分充值", "userId": user_id,
"name": name or "", "phoneNumber": phone or "", "avatar": avatar or ""}
return self._request("POST", "/order/tokenRecharge", token=token, user_id=user_id, body=body)
def get_order(self, *, token: str, user_id: str, order_no: str) -> dict:
if not order_no.isdigit():
raise HuihuiPaymentError("会会订单号格式不正确")
return self._request("GET", f"/order/get/{order_no}", token=token, user_id=user_id)
@@ -100,6 +100,37 @@ def test_charge_creates_huihui_order_without_early_points(authorization_context)
assert "test-callback-secret-123456" not in callback_url assert "test-callback-secret-123456" not in callback_url
def test_org_recharge_only_credits_after_verified_provider_order(authorization_context):
seed()
context = authorization_context
_enable_huihui_payment_login(context)
provider_no = "261016111200090123456"
created_data = {"orderNo": provider_no, "chargeType": 9,
"chargeAmount": 10, "payStatus": 1}
with patch("routers.tokens.HuihuiOrgPaymentClient") as client_factory:
provider = client_factory.return_value
provider.create_order.return_value = created_data
created = client.post(
"/api/token/charge/org", headers=context["owner_headers"],
json={"planId": "1", "paymentMethod": "wechat", "payScene": "APP"},
).json()["data"]
assert created["providerOrderNo"] == provider_no
assert created["status"] == "pending"
assert created["balance"] == DEFAULT_TOKEN_GRANT
assert provider.create_order.call_args.kwargs["pay_type"] == 1
details = {**created_data, "payStatus": 2,
"userId": context["owner"].huihui_user_id}
provider.get_order.return_value = {**details, "chargeAmount": 9}
assert client.get(f"/api/token/payment/{created['id']}", headers=context["owner_headers"]).json()["data"]["status"] == "pending"
provider.get_order.return_value = details
paid = client.get(f"/api/token/payment/{created['id']}", headers=context["owner_headers"]).json()["data"]
assert paid["status"] == "paid"
assert paid["balance"] == DEFAULT_TOKEN_GRANT + 2_000_000
duplicate = client.get(f"/api/token/payment/{created['id']}", headers=context["owner_headers"]).json()["data"]
assert duplicate["balance"] == paid["balance"]
def test_success_callback_credits_once_and_status_is_user_scoped(authorization_context): def test_success_callback_credits_once_and_status_is_user_scoped(authorization_context):
context = authorization_context context = authorization_context
_enable_huihui_payment_login(context) _enable_huihui_payment_login(context)
@@ -1,5 +1,7 @@
# 数字分身 H5 生产接入与部署 # 数字分身 H5 生产接入与部署
测试环境入口为 `https://fat-digital.99hui.com/`;测试支付收银台使用 `https://fat-open.99hui.com/huihui-h5/`,生产支付收银台使用 `https://99hui.com/huihui-h5/`。两套环境的订单和验签配置必须隔离,密钥只保存在各自后端配置中,不写入前端或仓库。
## 1. 接入方式 ## 1. 接入方式
生产会会在用户已登录后打开以下地址: 生产会会在用户已登录后打开以下地址:
+8 -1
View File
@@ -156,7 +156,8 @@ export interface TokenPaymentOrder {
pointsAmount: number pointsAmount: number
price: number price: number
status: 'pending' | 'paid' | 'failed' | 'closed' | 'refunded' status: 'pending' | 'paid' | 'failed' | 'closed' | 'refunded'
provider: 'huihui' | 'wechat_virtual' provider: 'huihui' | 'wechat_virtual' | 'huihui_org'
providerOrderNo?: string
providerStatus: string providerStatus: string
payMessage: string payMessage: string
failureReason: string failureReason: string
@@ -196,6 +197,12 @@ export const chargeToken = (
payScene: 'APP' | 'H5' | 'LITE' | 'JSAPI' payScene: 'APP' | 'H5' | 'LITE' | 'JSAPI'
) => request.post<TokenPaymentOrder>('/token/charge', { planId, paymentMethod, payScene }) ) => request.post<TokenPaymentOrder>('/token/charge', { planId, paymentMethod, payScene })
export const chargeTokenOrg = (
planId: string,
paymentMethod: 'wechat' | 'alipay',
payScene: 'APP' | 'H5' | 'LITE' | 'JSAPI'
) => request.post<TokenPaymentOrder>('/token/charge/org', { planId, paymentMethod, payScene })
export const getTokenPaymentStatus = (orderId: string) => export const getTokenPaymentStatus = (orderId: string) =>
request.get<TokenPaymentOrder>(`/token/payment/${orderId}`) request.get<TokenPaymentOrder>(`/token/payment/${orderId}`)
+1
View File
@@ -15,6 +15,7 @@ app.use(pinia)
// —— 混合架构:在挂载前注入 uniapp 壳传入的认证与会会资料 —— // —— 混合架构:在挂载前注入 uniapp 壳传入的认证与会会资料 ——
const params = getLaunchParams() const params = getLaunchParams()
if (params.evn !== undefined) sessionStorage.setItem('hh_launch_evn', params.evn)
const avatarStore = useAvatarStore(pinia) const avatarStore = useAvatarStore(pinia)
const userStore = useUserStore(pinia) const userStore = useUserStore(pinia)
@@ -12,15 +12,16 @@ export interface UniLaunchParams {
nickname?: string nickname?: string
avatar?: string avatar?: string
ts?: string ts?: string
evn?: string
} }
const PARAM_KEYS: (keyof UniLaunchParams)[] = ['token', 'userId', 'nickname', 'avatar', 'ts'] const PARAM_KEYS: (keyof UniLaunchParams)[] = ['token', 'userId', 'nickname', 'avatar', 'ts', 'evn']
function readParams(search: string, target: UniLaunchParams): void { function readParams(search: string, target: UniLaunchParams): void {
const sp = new URLSearchParams(search) const sp = new URLSearchParams(search)
for (const key of PARAM_KEYS) { for (const key of PARAM_KEYS) {
const value = sp.get(key) const value = sp.get(key)
if (value) target[key] = value if (value || (key === 'evn' && sp.has(key))) target[key] = value ?? ''
} }
} }
+11 -64
View File
@@ -110,7 +110,7 @@
import { computed, onMounted, onUnmounted, ref } from 'vue' import { computed, onMounted, onUnmounted, ref } from 'vue'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import { import {
chargeToken, chargeTokenOrg,
applyTokenInvoice, applyTokenInvoice,
getRechargePlans, getRechargePlans,
getTokenBalance, getTokenBalance,
@@ -118,7 +118,7 @@ import {
getTokenPaymentStatus, getTokenPaymentStatus,
type TokenPaymentOrder type TokenPaymentOrder
} from '@/api' } from '@/api'
import { isInUniWebView, onNativeMessage, UniEvents } from '@/utils/uniapp-bridge' import { isInUniWebView, onNativeMessage } from '@/utils/uniapp-bridge'
import wechatPayIcon from '@/assets/wechat-pay.svg' import wechatPayIcon from '@/assets/wechat-pay.svg'
import alipayIcon from '@/assets/alipay.svg' import alipayIcon from '@/assets/alipay.svg'
@@ -235,71 +235,12 @@ const checkoutLabel = computed(() => {
}) })
const payScene = () => { const payScene = () => {
if (paymentMethod.value === 'wechat' && /miniProgram/i.test(navigator.userAgent)) return 'LITE' as const
if (isInUniWebView()) return 'APP' as const if (isInUniWebView()) return 'APP' as const
if (paymentMethod.value === 'wechat' && /MicroMessenger/i.test(navigator.userAgent)) return 'JSAPI' as const if (paymentMethod.value === 'wechat' && /MicroMessenger/i.test(navigator.userAgent)) return 'JSAPI' as const
return 'H5' as const return 'H5' as const
} }
const parsePayMessage = (message: string) => {
const text = (message || '').trim()
if (!text) return null
try {
return JSON.parse(text)
} catch {
return text
}
}
const findPaymentUrl = (value: any): string => {
if (typeof value === 'string' && /^https?:\/\//i.test(value)) return value
if (!value || typeof value !== 'object') return ''
for (const key of ['url', 'payUrl', 'mwebUrl', 'mweb_url', 'redirectUrl']) {
if (typeof value[key] === 'string' && /^https?:\/\//i.test(value[key])) return value[key]
}
return ''
}
const invokeWechatJsapi = (params: Record<string, any>) => new Promise<void>((resolve, reject) => {
const invoke = () => {
const bridge = (window as any).WeixinJSBridge
if (!bridge?.invoke) {
reject(new Error('当前微信环境无法拉起支付'))
return
}
bridge.invoke('getBrandWCPayRequest', params, (result: any) => {
const message = String(result?.err_msg || result?.errMsg || '')
if (/\bok\b/i.test(message)) resolve()
else reject(new Error(/cancel/i.test(message) ? '支付已取消' : '微信支付未完成'))
})
}
if ((window as any).WeixinJSBridge) invoke()
else document.addEventListener('WeixinJSBridgeReady', invoke, { once: true })
})
const launchPayment = async (order: TokenPaymentOrder) => {
const parsed = parsePayMessage(order.payMessage)
if (order.payWay === 'JSAPI' && order.paymentMethod === 'wechat' && parsed && typeof parsed === 'object') {
await invokeWechatJsapi(parsed)
return
}
if (isInUniWebView() && UniEvents.payment({
orderId: order.id,
orderNo: order.orderNo,
paymentMethod: order.paymentMethod,
payType: order.payType,
payWay: order.payWay,
payMessage: order.payMessage,
paymentParams: parsed
})) return
const paymentUrl = findPaymentUrl(parsed)
if (paymentUrl) {
window.location.assign(paymentUrl)
return
}
throw new Error('请在会会 App 内完成支付')
}
const clearPendingOrder = () => { const clearPendingOrder = () => {
pendingOrderId.value = '' pendingOrderId.value = ''
sessionStorage.removeItem('hh_pending_payment_order') sessionStorage.removeItem('hh_pending_payment_order')
@@ -349,12 +290,18 @@ const doCharge = async () => {
charging.value = true charging.value = true
paymentNotice.value = '' paymentNotice.value = ''
try { try {
const order = await chargeToken(selectedPlan.value.id, paymentMethod.value, payScene()) const order = await chargeTokenOrg(selectedPlan.value.id, paymentMethod.value, payScene())
if (order.status === 'failed') throw new Error(order.failureReason || '支付订单创建失败') if (order.status === 'failed') throw new Error(order.failureReason || '支付订单创建失败')
if (!order.providerOrderNo || !/^\d+$/.test(order.providerOrderNo)) throw new Error('会会充值订单号缺失')
paymentNoticeTone.value = 'pending' paymentNoticeTone.value = 'pending'
paymentNotice.value = '支付订单已创建,正在等待付款结果' paymentNotice.value = '支付订单已创建,正在等待付款结果'
startPolling(order.id) startPolling(order.id)
await launchPayment(order) const checkoutBase = window.location.hostname === 'digital.99hui.com'
? 'https://99hui.com/huihui-h5/'
: 'https://fat-open.99hui.com/huihui-h5/'
const evn = sessionStorage.getItem('hh_launch_evn')
const evnPart = evn ? `&evn=${encodeURIComponent(evn)}` : '&evn'
window.location.assign(`${checkoutBase}#/payOrder?orderNo=${encodeURIComponent(order.providerOrderNo)}&isOrg=true${evnPart}`)
} catch (error: any) { } catch (error: any) {
paymentNoticeTone.value = 'error' paymentNoticeTone.value = 'error'
paymentNotice.value = error?.message || '充值失败,请重试' paymentNotice.value = error?.message || '充值失败,请重试'