From e5df72956dbd2edc12b97b309b655327acc48d21 Mon Sep 17 00:00:00 2001 From: stefanfeng Date: Thu, 15 Oct 2026 14:26:06 +0800 Subject: [PATCH] feat(avatar): add signed Huihui virtual payment callback --- digital-avatar-app/backend/routers/tokens.py | 78 +++++++++++++++++++ .../tests/test_virtual_payment_flow.py | 65 ++++++++++++++++ .../docs/H5_PRODUCTION_DEPLOYMENT.md | 2 + .../docs/HUIHUI_VIRTUAL_CALLBACK.md | 32 ++++++++ 4 files changed, 177 insertions(+) create mode 100644 digital-avatar-app/docs/HUIHUI_VIRTUAL_CALLBACK.md diff --git a/digital-avatar-app/backend/routers/tokens.py b/digital-avatar-app/backend/routers/tokens.py index 57e44b1..ff4fbd4 100644 --- a/digital-avatar-app/backend/routers/tokens.py +++ b/digital-avatar-app/backend/routers/tokens.py @@ -2,6 +2,7 @@ import hashlib import hmac import json import os +import time import uuid from datetime import datetime from decimal import Decimal, InvalidOperation, ROUND_HALF_UP @@ -753,6 +754,83 @@ async def payment_callback( return ok({"received": True, "paid": True}) +@router.post("/token/payment/huihui/virtual/notify") +async def huihui_virtual_payment_notify(request: Request, db: Session = Depends(get_db)): + """Server-to-server virtual-payment notice using the Huihui HMAC contract.""" + secret = os.getenv("HUIHUI_VIRTUAL_CALLBACK_SECRET", "").strip() + if len(secret) < 32: + raise HTTPException(status_code=503, detail="virtual payment callback is not configured") + timestamp = request.headers.get("x-huihui-timestamp", "").strip() + signature = request.headers.get("x-huihui-signature", "").strip().lower() + if not timestamp.isascii() or not timestamp.isdecimal() or len(timestamp) > 12: + raise HTTPException(status_code=401, detail="invalid callback signature") + try: + issued_at = int(timestamp) + except ValueError: + raise HTTPException(status_code=401, detail="invalid callback signature") + if abs(int(time.time()) - issued_at) > 300: + raise HTTPException(status_code=401, detail="callback timestamp expired") + body = await request.body() + if not body or len(body) > 65536: + raise HTTPException(status_code=400, detail="invalid callback body") + expected = hmac.new( + secret.encode("utf-8"), timestamp.encode("ascii") + b"\n" + body, hashlib.sha256 + ).hexdigest() + if not hmac.compare_digest(signature, expected): + raise HTTPException(status_code=401, detail="invalid callback signature") + try: + payload = json.loads(body) + except (UnicodeDecodeError, ValueError): + raise HTTPException(status_code=400, detail="invalid callback JSON") + if not isinstance(payload, dict): + raise HTTPException(status_code=400, detail="invalid callback JSON") + + order_no = str(payload.get("orderNo") or "").strip() + payment_no = str(payload.get("paymentNo") or "").strip() + status = str(payload.get("status") or "").upper() + if not order_no or not payment_no or status not in {"SUCCESS", "FAILED"}: + raise HTTPException(status_code=400, detail="missing callback fields") + order = db.query(TokenPaymentOrder).filter(TokenPaymentOrder.order_no == order_no).first() + if not order or order.provider != "wechat_virtual": + raise HTTPException(status_code=404, detail="virtual payment order not found") + if status == "FAILED": + return ok({"received": True, "paid": False}) + + user = db.query(User).filter(User.id == order.user_id).first() + plan = db.query(TokenPlan).filter(TokenPlan.id == order.plan_id).first() + try: + expected_product_id = product_id_for_plan(plan) if plan else "" + amount_cents = int(payload.get("amountCents")) + callback_env = int(payload.get("env")) + except (TypeError, ValueError, WechatVirtualPaymentError): + raise HTTPException(status_code=422, detail="invalid virtual payment details") + if ( + isinstance(payload.get("amountCents"), bool) + or isinstance(payload.get("env"), bool) + or not user + or not user.wechat_mp_openid + or str(payload.get("openId") or "") != user.wechat_mp_openid + or not expected_product_id + or str(payload.get("productId") or "") != expected_product_id + or amount_cents != order.price_cents + or callback_env != wechat_virtual_env() + ): + raise HTTPException(status_code=422, detail="virtual payment details mismatch") + existing_payment = db.query(PaymentTransaction).filter( + PaymentTransaction.provider == "wechat_virtual", + PaymentTransaction.event_type == "payment", + PaymentTransaction.transaction_no == payment_no, + ).first() + if existing_payment and existing_payment.order_no != order_no: + raise HTTPException(status_code=409, detail="payment number belongs to another order") + if order.status in {"paid", "refunded"}: + return ok({"received": True, "duplicate": True}) + + _settle_paid_order(db, order, provider_status="HUIHUI_SUCCESS", transaction_no=payment_no) + db.commit() + return ok({"received": True, "paid": True}) + + def _wechat_notify_response(request: Request, *, success: bool, message: str = ""): code = 0 if success else 1 text = "success" if success else (message or "fail")[:200].replace("]]>", "") diff --git a/digital-avatar-app/backend/tests/test_virtual_payment_flow.py b/digital-avatar-app/backend/tests/test_virtual_payment_flow.py index ef9f8e6..6b0dd1d 100644 --- a/digital-avatar-app/backend/tests/test_virtual_payment_flow.py +++ b/digital-avatar-app/backend/tests/test_virtual_payment_flow.py @@ -1,6 +1,8 @@ import hashlib +import hmac import json import os +import time from unittest.mock import patch from fastapi.testclient import TestClient @@ -14,6 +16,69 @@ from services.token_billing import DEFAULT_TOKEN_GRANT client = TestClient(app) +def _huihui_virtual_headers(secret, body, timestamp=None): + timestamp = str(timestamp if timestamp is not None else int(time.time())) + signature = hmac.new(secret.encode(), timestamp.encode() + b"\n" + body, hashlib.sha256).hexdigest() + return {"X-Huihui-Timestamp": timestamp, "X-Huihui-Signature": signature} + + +def test_huihui_virtual_callback_requires_signature_and_exact_order_details(authorization_context): + seed() + context = authorization_context + db = SessionLocal() + try: + user = db.query(User).filter(User.id == context["owner"].id).one() + user.wechat_mp_openid = "openid-huihui-callback" + user.wechat_mp_session_key = "session-huihui-callback" + plan = db.query(TokenPlan).filter(TokenPlan.id == "1").one() + plan.virtual_product_id = "points_plan_1" + db.commit() + finally: + db.close() + + secret = "huihui-virtual-callback-test-secret-123456" + env = { + "WECHAT_VIRTUAL_ENV": "sandbox", + "WECHAT_VIRTUAL_SANDBOX_APP_KEY": "sandbox-key", + "WECHAT_VIRTUAL_OFFER_ID": "offer-1", + "HUIHUI_VIRTUAL_CALLBACK_SECRET": secret, + } + with patch.dict(os.environ, env): + created = client.post( + "/api/token/charge", + headers=context["owner_headers"], + json={"planId": "1", "paymentMethod": "wechat", "payScene": "LITE"}, + ).json()["data"] + payload = { + "orderNo": created["orderNo"], + "paymentNo": f"huihui-virtual-payment-{context['suffix']}", + "status": "SUCCESS", + "amountCents": 1000, + "productId": "points_plan_1", + "openId": "openid-huihui-callback", + "env": 1, + } + path = "/api/token/payment/huihui/virtual/notify" + body = json.dumps(payload, separators=(",", ":")).encode() + assert client.post(path, content=body, headers=_huihui_virtual_headers(secret, body, 1)).status_code == 401 + assert client.post(path, content=body, headers={"X-Huihui-Timestamp": str(int(time.time())), "X-Huihui-Signature": "bad"}).status_code == 401 + wrong = json.dumps({**payload, "amountCents": 999}, separators=(",", ":")).encode() + assert client.post(path, content=wrong, headers=_huihui_virtual_headers(secret, wrong)).status_code == 422 + first = client.post(path, content=body, headers=_huihui_virtual_headers(secret, body)) + second = client.post(path, content=body, headers=_huihui_virtual_headers(secret, body)) + + assert first.json()["data"] == {"received": True, "paid": True} + assert second.json()["data"] == {"received": True, "duplicate": True} + db = SessionLocal() + try: + order = db.query(TokenPaymentOrder).filter(TokenPaymentOrder.order_no == created["orderNo"]).one() + account = db.query(TokenAccount).filter(TokenAccount.user_id == context["owner"].id).one() + assert order.status == "paid" + assert account.balance == DEFAULT_TOKEN_GRANT + 2_000_000 + finally: + db.close() + + def _signature(token, timestamp, nonce): return hashlib.sha1("".join(sorted([token, timestamp, nonce])).encode()).hexdigest() diff --git a/digital-avatar-app/docs/H5_PRODUCTION_DEPLOYMENT.md b/digital-avatar-app/docs/H5_PRODUCTION_DEPLOYMENT.md index b6e0f57..7341b7f 100644 --- a/digital-avatar-app/docs/H5_PRODUCTION_DEPLOYMENT.md +++ b/digital-avatar-app/docs/H5_PRODUCTION_DEPLOYMENT.md @@ -94,6 +94,8 @@ App 与 H5 积分充值使用会会支付体系的 `payment-v3/payment/pay`, 微信小程序使用微信虚拟支付:小程序先通过 `POST /api/token/wechat/session` 交换临时登录码,再由 `POST /api/token/charge`(`payScene=LITE`)返回已签名的 `requestVirtualPayment` 参数。微信回调地址配置为 `https://digital.99hui.com/api/token/payment/wechat/virtual/notify`。回调会复核签名、OpenID、环境、商品 ID 与实付金额,退款回调确认后才扣回积分。AppKey、AppSecret、session_key 均不得下发前端或写日志。 +如由会会服务端转发微信小程序虚拟支付结果,另有待双方确认的固定回调契约,见 [HUIHUI_VIRTUAL_CALLBACK.md](HUIHUI_VIRTUAL_CALLBACK.md)。该接口使用独立的 `HUIHUI_VIRTUAL_CALLBACK_SECRET`;即使接口代码部署到生产,在协议确认和联调验收前也不得将其视作已上线的支付渠道。 + 管理后台需要配置相同的 `AVATAR_FINANCE_ADMIN_SECRET` 和 `AVATAR_BACKEND_URL=https://digital.99hui.com`。退款只支持整单原路退款;供应商受理后显示“处理中”,收到渠道成功回调(或经渠道后台核对后人工确认)才将订单置为已退款。已消费掉本订单积分时,后台会拒绝主动退款;若渠道外部退款先发生,积分账户允许形成负数以记录欠额并阻止继续消费。 ## 3. 构建与发布 diff --git a/digital-avatar-app/docs/HUIHUI_VIRTUAL_CALLBACK.md b/digital-avatar-app/docs/HUIHUI_VIRTUAL_CALLBACK.md new file mode 100644 index 0000000..712c9c5 --- /dev/null +++ b/digital-avatar-app/docs/HUIHUI_VIRTUAL_CALLBACK.md @@ -0,0 +1,32 @@ +# 会会微信小程序虚拟支付回调(待双方确认) + +本项目为会会服务端预留固定回调地址: + +`POST https://digital.99hui.com/api/token/payment/huihui/virtual/notify` + +回调报文及签名协议尚未与会会确认。以下是本项目实现的拟议对接契约,双方确认并安全交换密钥后才能进行测试支付。接口代码部署与会会联调通过是两个独立状态。它不替代现有微信直连回调 `/api/token/payment/wechat/virtual/notify`。 + +请求体为 JSON,成功支付示例: + +```json +{ + "orderNo": "AV202610150000000000000001", + "paymentNo": "会会侧唯一支付流水号", + "status": "SUCCESS", + "amountCents": 1000, + "productId": "points_plan_1", + "openId": "微信小程序用户 OpenID", + "env": 1 +} +``` + +`amountCents` 单位为分,`env` 为微信虚拟支付环境(0 正式、1 沙箱)。非成功结果可发送 `status: "FAILED"`,但不会入账。订单号、支付流水号和状态必填;成功结果还必须提供金额、商品 ID、OpenID、环境。仅在订单属于微信虚拟支付,且上述信息与本地订单、套餐、用户一致时入账。重复成功通知不会重复发放积分;同一支付流水号不能用于其他订单。 + +请求头: + +- `X-Huihui-Timestamp`:Unix 秒级时间戳,允许与本服务时间相差不超过 300 秒。 +- `X-Huihui-Signature`:小写十六进制 HMAC-SHA256,密钥为双方线下安全交换、服务端配置的 `HUIHUI_VIRTUAL_CALLBACK_SECRET`(至少 32 字符),待签名数据为 `时间戳 + 换行符 + 原始请求体字节`。不要重排 JSON 后签名,也不要把密钥发给前端。 + +成功接收返回 HTTP 200,`{"code":200,"message":"success","data":{"received":true,"paid":true}}`。重复通知返回 `duplicate: true`。验签、字段、订单或金额不符返回 HTTP 4xx,会会可按其重试策略处理。服务端未配置密钥时返回 HTTP 503。 + +上线前需由会会确认:其服务端是否能按此格式签名并发送通知、支付成功状态与支付流水号字段的正式定义、失败通知及重试/超时策略。小程序支付发起和退款仍需独立对接和验收;仅完成此回调不代表虚拟支付全流程已可用。 -- 2.54.0