feat(avatar): route token recharge through Huihui org orders
This commit is contained in:
@@ -395,6 +395,7 @@ class TokenPaymentOrder(Base):
|
||||
"price": self.price_cents / 100,
|
||||
"status": self.status,
|
||||
"provider": self.provider,
|
||||
"providerOrderNo": self.provider_order_no,
|
||||
"providerStatus": self.provider_status,
|
||||
"payMessage": self.pay_message,
|
||||
"failureReason": self.failure_reason,
|
||||
|
||||
@@ -25,6 +25,7 @@ from models import (
|
||||
)
|
||||
from responses import fail, ok
|
||||
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.wechat_virtual_payment import (
|
||||
PAYMENT_EVENTS as WECHAT_PAYMENT_EVENTS,
|
||||
@@ -270,6 +271,7 @@ def _callback_amount_cents(payload) -> int | None:
|
||||
"orderAmt",
|
||||
"amount",
|
||||
"totalAmount",
|
||||
"chargeAmount",
|
||||
)
|
||||
if value in (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)))
|
||||
|
||||
|
||||
@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}")
|
||||
def payment_status(order_id: str, authorization: str = Header(None), db: Session = Depends(get_db)):
|
||||
user = _require_user(authorization, db)
|
||||
@@ -414,6 +457,27 @@ def payment_status(order_id: str, authorization: str = Header(None), db: Session
|
||||
).first()
|
||||
if not order:
|
||||
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:
|
||||
try:
|
||||
provider_data = query_wechat_virtual_order(
|
||||
@@ -560,6 +624,8 @@ def admin_request_refund(
|
||||
return fail("订单不存在", 404)
|
||||
if order.status != "paid" or order.refund_status not in {"", "none", "failed"}:
|
||||
return fail("该订单当前不可退款", 409)
|
||||
if order.provider == "huihui_org":
|
||||
return fail("会会组织订单的原路退款接口尚未接入,请先在会会后台处理", 409)
|
||||
account = get_or_create_account(db, order.user_id)
|
||||
if int(account.balance or 0) < int(order.points_amount or 0):
|
||||
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
|
||||
|
||||
|
||||
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):
|
||||
context = authorization_context
|
||||
_enable_huihui_payment_login(context)
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
# 数字分身 H5 生产接入与部署
|
||||
|
||||
测试环境入口为 `https://fat-digital.99hui.com/`;测试支付收银台使用 `https://fat-open.99hui.com/huihui-h5/`,生产支付收银台使用 `https://99hui.com/huihui-h5/`。两套环境的订单和验签配置必须隔离,密钥只保存在各自后端配置中,不写入前端或仓库。
|
||||
|
||||
## 1. 接入方式
|
||||
|
||||
生产会会在用户已登录后打开以下地址:
|
||||
|
||||
@@ -156,7 +156,8 @@ export interface TokenPaymentOrder {
|
||||
pointsAmount: number
|
||||
price: number
|
||||
status: 'pending' | 'paid' | 'failed' | 'closed' | 'refunded'
|
||||
provider: 'huihui' | 'wechat_virtual'
|
||||
provider: 'huihui' | 'wechat_virtual' | 'huihui_org'
|
||||
providerOrderNo?: string
|
||||
providerStatus: string
|
||||
payMessage: string
|
||||
failureReason: string
|
||||
@@ -196,6 +197,12 @@ export const chargeToken = (
|
||||
payScene: 'APP' | 'H5' | 'LITE' | 'JSAPI'
|
||||
) => 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) =>
|
||||
request.get<TokenPaymentOrder>(`/token/payment/${orderId}`)
|
||||
|
||||
|
||||
@@ -15,6 +15,7 @@ app.use(pinia)
|
||||
|
||||
// —— 混合架构:在挂载前注入 uniapp 壳传入的认证与会会资料 ——
|
||||
const params = getLaunchParams()
|
||||
if (params.evn !== undefined) sessionStorage.setItem('hh_launch_evn', params.evn)
|
||||
const avatarStore = useAvatarStore(pinia)
|
||||
const userStore = useUserStore(pinia)
|
||||
|
||||
|
||||
@@ -12,15 +12,16 @@ export interface UniLaunchParams {
|
||||
nickname?: string
|
||||
avatar?: 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 {
|
||||
const sp = new URLSearchParams(search)
|
||||
for (const key of PARAM_KEYS) {
|
||||
const value = sp.get(key)
|
||||
if (value) target[key] = value
|
||||
if (value || (key === 'evn' && sp.has(key))) target[key] = value ?? ''
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -110,7 +110,7 @@
|
||||
import { computed, onMounted, onUnmounted, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import {
|
||||
chargeToken,
|
||||
chargeTokenOrg,
|
||||
applyTokenInvoice,
|
||||
getRechargePlans,
|
||||
getTokenBalance,
|
||||
@@ -118,7 +118,7 @@ import {
|
||||
getTokenPaymentStatus,
|
||||
type TokenPaymentOrder
|
||||
} 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 alipayIcon from '@/assets/alipay.svg'
|
||||
|
||||
@@ -235,71 +235,12 @@ const checkoutLabel = computed(() => {
|
||||
})
|
||||
|
||||
const payScene = () => {
|
||||
if (paymentMethod.value === 'wechat' && /miniProgram/i.test(navigator.userAgent)) return 'LITE' as const
|
||||
if (isInUniWebView()) return 'APP' as const
|
||||
if (paymentMethod.value === 'wechat' && /MicroMessenger/i.test(navigator.userAgent)) return 'JSAPI' 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 = () => {
|
||||
pendingOrderId.value = ''
|
||||
sessionStorage.removeItem('hh_pending_payment_order')
|
||||
@@ -349,12 +290,18 @@ const doCharge = async () => {
|
||||
charging.value = true
|
||||
paymentNotice.value = ''
|
||||
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.providerOrderNo || !/^\d+$/.test(order.providerOrderNo)) throw new Error('会会充值订单号缺失')
|
||||
paymentNoticeTone.value = 'pending'
|
||||
paymentNotice.value = '支付订单已创建,正在等待付款结果'
|
||||
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) {
|
||||
paymentNoticeTone.value = 'error'
|
||||
paymentNotice.value = error?.message || '充值失败,请重试'
|
||||
|
||||
Reference in New Issue
Block a user