Compare commits

..
Author SHA1 Message Date
stefanfeng 03b343fd0b fix: pass Huihui token to production cashier 2026-10-22 09:29:56 +08:00
stefanfeng 33f53ff9d1 Merge pull request '虚拟支付回调 openId 改为可选' (#44) from codex/optional-openid-callback-20261021 into main
Reviewed-on: #44
2026-10-21 17:43:46 +08:00
stefanfeng 70eb7c223f test: cover Huihui callback without openid 2026-10-21 11:53:51 +08:00
stefanfeng 0a588576bf fix: make virtual payment callback openid optional 2026-10-21 11:53:22 +08:00
stefanfeng 16a72acaf9 Merge pull request '明确会会组织充值的唯一对外支付回调地址' (#43) from codex/avatar-org-notify-doc-20261020 into main
Reviewed-on: #43
2026-10-20 15:03:06 +08:00
stefanfeng d88caa75dd docs: clarify Huihui org payment callback URL 2026-10-20 14:53:32 +08:00
stefanfeng cce5130136 Merge pull request '修复会会支付回调先于查单状态更新导致的 409' (#42) from codex/avatar-org-callback-reconcile-20261020 into main
Reviewed-on: #42
2026-10-20 14:41:50 +08:00
stefanfeng 721f8b990d fix: acknowledge signed payment notice before querying provider 2026-10-20 14:37:02 +08:00
stefanfeng 5c992729c4 fix: reconcile Huihui payment callbacks after provider confirmation 2026-10-20 14:27:49 +08:00
stefanfeng 23d9a33a6d Merge pull request '数字分身会会组织充值回调验签与查单确认' (#41) from codex/avatar-virtual-product-ids into main
Reviewed-on: #41
2026-10-20 14:13:29 +08:00
stefanfeng 356f1c8c8d Merge pull request '数字分身套餐 ID 与微信虚拟支付道具 ID 对齐' (#40) from codex/avatar-virtual-product-ids into main
Reviewed-on: #40
2026-10-19 14:21:21 +08:00
8 changed files with 236 additions and 42 deletions
+20
View File
@@ -282,6 +282,19 @@ def close_expired_pending_orders_job():
db.close()
def reconcile_huihui_org_notices_job():
db = SessionLocal()
try:
count = routers.tokens.reconcile_huihui_org_notices(db)
if count:
logger.info("Confirmed %s Huihui organization payment notice(s)", count)
except Exception as exc:
db.rollback()
logger.warning("Failed to reconcile Huihui organization payment notices: %s", exc)
finally:
db.close()
def start_maintenance_scheduler():
global maintenance_scheduler
@@ -305,6 +318,13 @@ def start_maintenance_scheduler():
max_instances=1,
coalesce=True,
)
maintenance_scheduler.add_job(
reconcile_huihui_org_notices_job,
trigger=IntervalTrigger(seconds=10),
id="reconcile_huihui_org_notices",
max_instances=1,
coalesce=True,
)
maintenance_scheduler.start()
+18
View File
@@ -437,6 +437,24 @@ class PaymentTransaction(Base):
}
class HuihuiOrgPaymentNotice(Base):
"""Signed success notice awaiting independent confirmation from Huihui."""
__tablename__ = "huihui_org_payment_notices"
order_no = Column(String, primary_key=True)
payment_no = Column(String, nullable=False)
amount_cents = Column(Integer, nullable=False)
product_id = Column(String, nullable=False)
status = Column(String, nullable=False, default="awaiting_provider", index=True)
attempts = Column(Integer, nullable=False, default=0)
next_check_at = Column(DateTime, index=True)
last_provider_status = Column(String, default="")
last_error = Column(String, default="")
created_at = Column(DateTime, server_default=func.now())
updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now())
class PaymentRefund(Base):
__tablename__ = "payment_refunds"
+142 -19
View File
@@ -4,16 +4,18 @@ import json
import os
import time
import uuid
from datetime import datetime
from datetime import datetime, timedelta
from decimal import Decimal, InvalidOperation, ROUND_HALF_UP
from urllib.parse import parse_qs
from fastapi import APIRouter, Body, Depends, Header, HTTPException, Query, Request, Response
from sqlalchemy import func
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session
from database import get_db
from models import (
HuihuiOrgPaymentNotice,
InvoiceApplication,
PaymentRefund,
PaymentTransaction,
@@ -453,8 +455,7 @@ def charge_via_huihui_org(payload: dict = Body(...), authorization: str = Header
db.add(order)
db.commit()
result = _payment_payload(order, get_or_create_account(db, user.id))
if "fat-open.99hui.com" in os.getenv("HUIHUI_ORG_BASE_URL", ""):
result["checkoutToken"] = user.huihui_token
result["checkoutToken"] = user.huihui_token
return ok(result)
@@ -889,16 +890,23 @@ def _process_huihui_org_notice(payload: dict, db: Session):
raise HTTPException(status_code=422, detail="payment details mismatch")
if order.status in {"paid", "refunded"}:
return ok({"received": True, "duplicate": True})
if not order.provider_order_no or not user.huihui_token or not user.huihui_user_id:
raise HTTPException(status_code=503, detail="provider order verification unavailable")
try:
provider_order = HuihuiOrgPaymentClient().get_order(
token=user.huihui_token,
user_id=user.huihui_user_id,
order_no=order.provider_order_no,
)
except HuihuiPaymentError as exc:
raise HTTPException(status_code=503, detail="provider order verification unavailable") from exc
notice = db.get(HuihuiOrgPaymentNotice, order.order_no)
if notice and notice.payment_no != payment_no:
raise HTTPException(status_code=409, detail="payment number conflicts with prior notice")
if not order.provider_order_no:
raise HTTPException(status_code=422, detail="provider order number missing")
# The provider may not mark its order paid until it receives our HTTP 200.
# Never synchronously query the provider before acknowledging a signed notice.
_save_huihui_org_notice(db, order, payment_no, amount_cents, expected_product_id)
return ok({"received": True, "paid": False, "awaitingProvider": True})
def _huihui_org_provider_status(order: TokenPaymentOrder, user: User, product_id: str, payment_no: str) -> int:
provider_order = HuihuiOrgPaymentClient().get_order(
token=user.huihui_token,
user_id=user.huihui_user_id,
order_no=order.provider_order_no,
)
try:
provider_status = int(provider_order.get("payStatus"))
provider_type = int(provider_order.get("chargeType"))
@@ -907,14 +915,59 @@ def _process_huihui_org_notice(payload: dict, db: Session):
if (
str(provider_order.get("orderNo") or "") != order.provider_order_no
or str(provider_order.get("paymentNo") or "") != payment_no
or str(provider_order.get("productId") or "") != expected_product_id
or str(provider_order.get("productId") or "") != product_id
or str(provider_order.get("userId") or "") != user.huihui_user_id
or provider_type != 9
or _callback_amount_cents({"chargeAmount": provider_order.get("chargeAmount")}) != order.price_cents
):
raise HTTPException(status_code=422, detail="provider order details mismatch")
if provider_status not in {2, 3, 4}:
raise HTTPException(status_code=409, detail="provider payment not confirmed")
return provider_status
def _save_huihui_org_notice(
db: Session, order: TokenPaymentOrder, payment_no: str, amount_cents: int,
product_id: str,
) -> None:
notice = db.get(HuihuiOrgPaymentNotice, order.order_no)
if notice and (
notice.payment_no != payment_no
or notice.amount_cents != amount_cents
or notice.product_id != product_id
):
raise HTTPException(status_code=409, detail="payment notice conflicts with prior notice")
if notice is None:
notice = HuihuiOrgPaymentNotice(
order_no=order.order_no, payment_no=payment_no,
amount_cents=amount_cents, product_id=product_id,
)
db.add(notice)
notice.status = "awaiting_provider"
notice.last_provider_status = "not_checked"
notice.last_error = ""
first_check = datetime.utcnow() + timedelta(seconds=5)
if notice.next_check_at is None or notice.next_check_at > first_check:
notice.next_check_at = first_check
try:
db.commit()
except IntegrityError:
db.rollback()
concurrent = db.get(HuihuiOrgPaymentNotice, order.order_no)
if not concurrent or (
concurrent.payment_no != payment_no
or concurrent.amount_cents != amount_cents
or concurrent.product_id != product_id
):
raise HTTPException(status_code=409, detail="payment notice conflicts with prior notice")
def _huihui_notice_retry_seconds(attempts: int) -> int:
return min(15 * (2 ** min(max(0, attempts - 1), 8)), 3600)
def _settle_verified_huihui_org_order(
db: Session, order: TokenPaymentOrder, payment_no: str,
notice: HuihuiOrgPaymentNotice | None = None,
) -> None:
existing_payment = db.query(PaymentTransaction).filter(
PaymentTransaction.provider == "huihui_org",
PaymentTransaction.event_type == "payment",
@@ -923,8 +976,78 @@ def _process_huihui_org_notice(payload: dict, db: Session):
if existing_payment and existing_payment.order_no != order.order_no:
raise HTTPException(status_code=409, detail="payment number belongs to another order")
_settle_paid_order(db, order, provider_status="HUIHUI_SUCCESS", transaction_no=payment_no)
if notice:
notice.status = "confirmed"
notice.next_check_at = None
notice.last_provider_status = "paid"
notice.last_error = ""
db.commit()
return ok({"received": True, "paid": True})
def reconcile_huihui_org_notices(db: Session, limit: int = 20) -> int:
"""Retry only signed notices whose provider confirmation lagged the callback."""
now = datetime.utcnow()
due = db.query(HuihuiOrgPaymentNotice).filter(
HuihuiOrgPaymentNotice.status == "awaiting_provider",
HuihuiOrgPaymentNotice.next_check_at <= now,
).order_by(HuihuiOrgPaymentNotice.next_check_at).limit(limit).all()
confirmed = 0
for notice in due:
order = db.query(TokenPaymentOrder).filter(
TokenPaymentOrder.order_no == notice.order_no,
TokenPaymentOrder.provider == "huihui_org",
).first()
if not order:
notice.status = "rejected"
notice.last_error = "order missing"
db.commit()
continue
if order.status in {"paid", "refunded"}:
notice.status = "confirmed"
notice.next_check_at = None
db.commit()
continue
user = db.query(User).filter(User.id == order.user_id).first()
plan = db.query(TokenPlan).filter(TokenPlan.id == order.plan_id).first()
try:
product_id = product_id_for_plan(plan) if plan else ""
except WechatVirtualPaymentError:
product_id = ""
if not user or not product_id or product_id != notice.product_id or order.price_cents != notice.amount_cents:
notice.status = "rejected"
notice.last_error = "local order details mismatch"
db.commit()
continue
try:
status = _huihui_org_provider_status(order, user, product_id, notice.payment_no)
except HuihuiPaymentError:
status = None
except HTTPException as exc:
notice.status = "rejected"
notice.last_error = str(exc.detail)[:200]
db.commit()
continue
if status in {2, 3, 4}:
try:
_settle_verified_huihui_org_order(db, order, notice.payment_no, notice)
confirmed += 1
except HTTPException as exc:
db.rollback()
notice = db.get(HuihuiOrgPaymentNotice, notice.order_no)
notice.status = "rejected"
notice.last_error = str(exc.detail)[:200]
db.commit()
continue
notice.last_provider_status = str(status) if status is not None else "unavailable"
notice.attempts = int(notice.attempts or 0) + 1
if notice.created_at and now - notice.created_at >= timedelta(hours=24):
notice.status = "unconfirmed"
notice.next_check_at = None
notice.last_error = "provider did not confirm payment within 24 hours"
else:
notice.next_check_at = now + timedelta(seconds=_huihui_notice_retry_seconds(notice.attempts))
db.commit()
return confirmed
@router.post("/token/payment/huihui/virtual/notify")
@@ -963,12 +1086,12 @@ async def huihui_virtual_payment_notify(request: Request, db: Session = Depends(
callback_env = int(payload.get("env"))
except (TypeError, ValueError, WechatVirtualPaymentError):
raise HTTPException(status_code=422, detail="invalid virtual payment details")
callback_open_id = str(payload.get("openId") or "").strip()
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 (callback_open_id and callback_open_id != user.wechat_mp_openid)
or not expected_product_id
or str(payload.get("productId") or "") != expected_product_id
or amount_cents != order.price_cents
@@ -53,12 +53,15 @@ def test_scheduler_uses_boxim_and_restart_safe_service(
max_message_age_seconds=600,
)
assert maintenance_scheduler.add_job.call_count == 2
cleanup_call, close_orders_call = maintenance_scheduler.add_job.call_args_list
assert maintenance_scheduler.add_job.call_count == 3
cleanup_call, close_orders_call, reconcile_call = maintenance_scheduler.add_job.call_args_list
assert cleanup_call.kwargs["id"] == "chat_attachment_cleanup"
assert close_orders_call.args[0] is main.close_expired_pending_orders_job
assert close_orders_call.kwargs["id"] == "close_expired_token_orders"
assert close_orders_call.kwargs["trigger"].interval.total_seconds() == 60
assert reconcile_call.args[0] is main.reconcile_huihui_org_notices_job
assert reconcile_call.kwargs["id"] == "reconcile_huihui_org_notices"
assert reconcile_call.kwargs["trigger"].interval.total_seconds() == 10
maintenance_scheduler.start.assert_called_once_with()
assert scheduler.add_job.call_count == 2
@@ -163,7 +163,7 @@ def test_org_recharge_only_credits_after_verified_provider_order(authorization_c
provider.get_order.assert_not_called()
def test_org_recharge_does_not_return_checkout_token_outside_test_environment(authorization_context):
def test_org_recharge_returns_checkout_token_for_production_cashier(authorization_context):
seed()
context = authorization_context
_enable_huihui_payment_login(context)
@@ -180,7 +180,7 @@ def test_org_recharge_does_not_return_checkout_token_outside_test_environment(au
)
assert response.status_code == 200
assert "checkoutToken" not in response.json()["data"]
assert response.json()["data"]["checkoutToken"] == f"huihui-payment-{context['suffix']}"
def test_expired_pending_payment_orders_are_closed_without_touching_recent_orders(authorization_context):
@@ -3,13 +3,14 @@ import hmac
import json
import os
import time
from datetime import datetime, timedelta
from unittest.mock import patch
from fastapi.testclient import TestClient
from database import SessionLocal
from main import app, seed
from models import InvoiceApplication, PaymentRefund, TokenAccount, TokenPaymentOrder, TokenPlan, User
from models import HuihuiOrgPaymentNotice, InvoiceApplication, PaymentRefund, TokenAccount, TokenPaymentOrder, TokenPlan, User
from services.token_billing import DEFAULT_TOKEN_GRANT
@@ -54,7 +55,6 @@ def test_huihui_virtual_callback_requires_signature_and_exact_order_details(auth
"status": "SUCCESS",
"amountCents": 1000,
"productId": "Basic",
"openId": "openid-huihui-callback",
"env": 1,
}
path = "/api/token/payment/huihui/virtual/notify"
@@ -63,6 +63,8 @@ def test_huihui_virtual_callback_requires_signature_and_exact_order_details(auth
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
wrong_openid = json.dumps({**payload, "openId": "different-user"}, separators=(",", ":")).encode()
assert client.post(path, content=wrong_openid, headers=_huihui_virtual_headers(secret, wrong_openid)).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))
@@ -129,17 +131,46 @@ def test_huihui_org_callback_verifies_provider_before_granting_points(authorizat
assert send(payload, signature=False, callback_path=legacy_path).status_code == 401
assert send({**payload, "orderNo": "unknown"}, callback_path=legacy_path).status_code == 404
assert send({**payload, "amountCents": 99}).status_code == 422
assert send(payload).status_code == 409
provider.get_order.return_value = {**provider_data, "payStatus": 2}
assert send({**payload, "paymentNo": "wrong"}).status_code == 422
provider.get_order.return_value = {**provider_data, "payStatus": 2, "chargeAmount": 2}
assert send(payload).status_code == 422
provider.get_order.return_value = {**provider_data, "payStatus": 2}
pending = send(payload, callback_path=legacy_path)
assert pending.status_code == 200
assert pending.json()["data"] == {"received": True, "paid": False, "awaitingProvider": True}
provider.get_order.assert_not_called()
db = SessionLocal()
try:
assert db.get(HuihuiOrgPaymentNotice, order_no).status == "awaiting_provider"
assert db.query(TokenPaymentOrder).filter(TokenPaymentOrder.order_no == order_no).one().status == "pending"
finally:
db.close()
assert send({**payload, "paymentNo": "wrong"}).status_code == 409
db = SessionLocal()
try:
notice = db.get(HuihuiOrgPaymentNotice, order_no)
notice.next_check_at = datetime.utcnow() - timedelta(seconds=1)
db.commit()
from routers.tokens import reconcile_huihui_org_notices
provider.get_order.return_value = {**provider_data, "payStatus": 2, "chargeAmount": 2}
assert reconcile_huihui_org_notices(db) == 0
assert db.get(HuihuiOrgPaymentNotice, order_no).status == "rejected"
assert db.query(TokenPaymentOrder).filter(TokenPaymentOrder.order_no == order_no).one().status == "pending"
assert send(payload).status_code == 200
notice.next_check_at = datetime.utcnow() - timedelta(seconds=1)
db.commit()
provider.get_order.return_value = provider_data
assert reconcile_huihui_org_notices(db) == 0
assert db.get(HuihuiOrgPaymentNotice, order_no).attempts == 1
assert db.query(TokenPaymentOrder).filter(TokenPaymentOrder.order_no == order_no).one().status == "pending"
notice.next_check_at = datetime.utcnow() - timedelta(seconds=1)
db.commit()
provider.get_order.return_value = {**provider_data, "payStatus": 2}
assert reconcile_huihui_org_notices(db) == 1
assert db.get(HuihuiOrgPaymentNotice, order_no).status == "confirmed"
finally:
db.close()
first = send(payload, callback_path=legacy_path)
second = send(payload)
assert first.status_code == 200
assert first.json()["data"] == {"received": True, "paid": True}
assert first.json()["data"] == {"received": True, "duplicate": True}
assert second.json()["data"] == {"received": True, "duplicate": True}
db = SessionLocal()
try:
@@ -91,7 +91,7 @@ CHAT_ATTACHMENT_CLEANUP_MINUTES=60
`EMBEDDING_API_URL` 同时支持 OpenAI 兼容基础地址(如上面的 `/v1`)和完整的 `/v1/embeddings` 地址,后端会统一请求 `/embeddings`。发布后必须在后端容器内执行一次最小向量探针,确认返回向量数量和维度,而不能只检查 `/api/health`。
当前 H5 积分充值调用 `/api/token/charge/org` 创建会会组织充值订单。数字分身后端请求会会 `/order/tokenRecharge` 时,把所选套餐 ID 作为字符串 `productId` 传入(`Basic`、`Standard`、`Professional`、`Enterprise`),再用会会返回的订单号打开 `#/payOrder?id=<会会订单号>&isOrgUrl=true&evn=<入口环境>`;测试环境额外透传会会登录 token,生产环境不传。`id` 是收银台查询订单的编号,不能替换为套餐/道具 ID。普通微信 H5 走 JSAPI,App 和其他 H5 沿用会会普通收银台。支付回调确认状态成功且金额与套餐价格完全一致后才增加积分,重复回调不会重复到账。
当前 H5 积分充值调用 `/api/token/charge/org` 创建会会组织充值订单。数字分身后端请求会会 `/order/tokenRecharge` 时,把所选套餐 ID 作为字符串 `productId` 传入(`Basic`、`Standard`、`Professional`、`Enterprise`),再用会会返回的订单号打开 `#/payOrder?id=<会会订单号>&isOrgUrl=true&token=<会会登录 token>&evn=<入口环境>`。测试和生产收银台都需要此 token 查询订单金额;`id` 是收银台查询订单的编号,不能替换为套餐/道具 ID。普通微信 H5 走 JSAPI,App 和其他 H5 沿用会会普通收银台。支付回调确认状态成功且金额与套餐价格完全一致后才增加积分,重复回调不会重复到账。
微信小程序虚拟支付的服务端接口为:小程序先通过 `POST /api/token/wechat/session` 交换临时登录码,再由 `POST /api/token/charge`(`payScene=LITE`)返回已签名的 `requestVirtualPayment` 参数;其中 `productId` 直接采用套餐 ID(`Basic`、`Standard`、`Professional`、`Enterprise`)。小程序原生支付页仍需调用 `wx.requestVirtualPayment`,不能仅打开上述 H5 收银台 URL 来完成虚拟支付。微信回调地址配置为 `https://digital.99hui.com/api/token/payment/wechat/virtual/notify`。回调会复核签名、OpenID、环境、商品 ID 与实付金额,退款回调确认后才扣回积分。AppKey、AppSecret、session_key 均不得下发前端或写日志。
@@ -1,13 +1,12 @@
# 会会支付回调(待双方确认)
# 会会组织充值支付回调
本项目为会会服务端预留两条固定回调地址:
当前 H5 收银台组织充值订单,对会会提供的测试环境回调地址只有:
- 会会组织充值订单(当前 H5 收银台):`POST https://fat-digital.99hui.com/api/token/payment/huihui/org/notify`(测试环境)
- 微信直连虚拟支付订单:`POST https://digital.99hui.com/api/token/payment/huihui/virtual/notify`(生产环境示例)
`POST https://fat-digital.99hui.com/api/token/payment/huihui/org/notify`
测试环境兼容提示:如果会会仍将上述组织充值订单发往 `/api/token/payment/huihui/virtual/notify`,而该环境未配置独立的虚拟支付密钥,旧地址会使用组织充值密钥验签并走相同的组织订单复核流程。该兼容入口不会处理微信直连虚拟支付订单;新接入仍应使用 `/org/notify`,生产环境不依赖此兼容行为。
其他历史接收路由仅供服务端兼容旧请求,不作为提供给会会的回调地址,也不应配置为新订单的通知地址。
回调报文及签名协议尚未与会会确认。以下是本项目实现的拟议对接契约,会会服务端必须按此格式发送才能完成联调。接口代码部署与会会联调通过是两个独立状态。它不替代现有微信直连回调 `/api/token/payment/wechat/virtual/notify`。
回调报文及签名协议仍须按双方联调结果确认。以下是本项目实现的接收契约;接口部署与完整支付联调通过是两个独立状态。微信直连虚拟支付回调属于另一条支付链路,不使用本文的组织充值回调地址。
请求体为 JSON,成功支付示例:
@@ -21,13 +20,13 @@
}
```
`amountCents` 单位为分。对于经 `/order/tokenRecharge` 创建的会会组织订单,`orderNo` 可以是会会订单号或数字分身 `AV` 订单号;成功通知必须提供 `amountCents`、`productId` 和 `paymentNo`,无需 `openId`、`env`。服务端会再次向会会查单,要求订单号、支付流水、商品 ID、用户、收费类型、金额一致,且会会支付状态为已付款,才发放积分。对于微信直连订单,仍需 `openId` 和 `env`(0 正式、1 沙箱)一致。非成功结果可发送 `status: "FAILED"`,但不会入账。重复成功通知不会重复发放积分;同一支付流水号不能用于其他订单。
`amountCents` 单位为分。对于经 `/order/tokenRecharge` 创建的会会组织订单,`orderNo` 可以是会会订单号或数字分身 `AV` 订单号;成功通知必须提供 `amountCents`、`productId` 和 `paymentNo`,无需 `openId`、`env`。服务端会再次向会会查单,要求订单号、支付流水、商品 ID、用户、收费类型、金额一致,且会会支付状态为已付款,才发放积分。非成功结果可发送 `status: "FAILED"`,但不会入账。重复成功通知不会重复发放积分;同一支付流水号不能用于其他订单。
请求头:
- `X-Huihui-Timestamp`:Unix 秒级时间戳,允许与本服务时间相差不超过 300 秒。
- `X-Huihui-Signature`:小写十六进制 HMAC-SHA256,待签名数据为 `时间戳 + 换行符 + 原始请求体字节`。组织充值订单使用服务端配置的 `HUIHUI_ORG_CALLBACK_SECRET`(至少 16 字符),微信直连虚拟支付订单使用独立的 `HUIHUI_VIRTUAL_CALLBACK_SECRET`(至少 32 字符)。不要重排 JSON 后签名,也不要把密钥发给前端。
- `X-Huihui-Signature`:小写十六进制 HMAC-SHA256,待签名数据为 `时间戳 + 换行符 + 原始请求体字节`。组织充值订单使用服务端配置的 `HUIHUI_ORG_CALLBACK_SECRET`(至少 16 字符)。不要重排 JSON 后签名,也不要把密钥发给前端。
成功接收返回 HTTP 200,`{"code":200,"message":"success","data":{"received":true,"paid":true}}`。重复通知返回 `duplicate: true`。验签、字段、订单或金额不符返回 HTTP 4xx,会会可按其重试策略处理。服务端未配置密钥时返回 HTTP 503。
成功通知经签名及本地订单信息核对后,先持久保存并立即返回 HTTP 200、`{"received":true,"paid":false,"awaitingProvider":true}`;回调入口**不等待会会查单**,也**不立即发放积分**。后台仅针对已保存的通知做有上限的延迟复核,待会会查单确认已支付、订单号与支付流水等字段一致后才入账。已入账订单的重复通知返回 `duplicate: true`,不会重复发放积分。验签、字段、订单或金额不符返回 HTTP 4xx。服务端未配置密钥时返回 HTTP 503。
上线前需由会会确认:其服务端是否能按此格式签名并发送通知、支付成功状态与支付流水号字段的正式定义、失败通知及重试/超时策略。小程序支付发起和退款仍需独立对接和验收;仅完成此回调不代表虚拟支付全流程已可用。