Compare commits

...
Author SHA1 Message Date
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 04e96b6207 fix: accept legacy Huihui callback path for org orders 2026-10-20 14:03:13 +08:00
stefanfeng ce18df29b7 Verify Huihui organization payment callbacks against paid orders 2026-10-20 11:04:27 +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
stefanfeng 643abd13ea Pass recharge product ID to Huihui order API 2026-10-19 14:12:43 +08:00
stefanfeng 3379d5a1b2 Align avatar recharge plan IDs with WeChat virtual products 2026-10-19 14:00:06 +08:00
stefanfeng 5f27cc93cc Merge pull request '调整会会收银台参数并自动关闭超时订单' (#39) from codex/avatar-token-recharge-huihui into main
Reviewed-on: #39
2026-10-16 17:52:44 +08:00
stefanfeng 07cef004d2 Merge pull request '完善数字分身积分充值与财务管理' (#38) from codex/avatar-token-recharge-huihui into main
Reviewed-on: #38
2026-10-16 17:14:39 +08:00
12 changed files with 503 additions and 56 deletions
+38 -8
View File
@@ -9,7 +9,7 @@ from apscheduler.schedulers.asyncio import AsyncIOScheduler
from apscheduler.triggers.interval import IntervalTrigger from apscheduler.triggers.interval import IntervalTrigger
from database import engine, init_db, SessionLocal from database import engine, init_db, SessionLocal
from models import Avatar, Authorization, Organization, TokenAccount, TokenPlan, User from models import Avatar, Authorization, Organization, TokenAccount, TokenPaymentOrder, TokenPlan, User
from fastapi.staticfiles import StaticFiles from fastapi.staticfiles import StaticFiles
import routers.avatars import routers.avatars
import routers.tokens import routers.tokens
@@ -90,18 +90,28 @@ def seed():
db = SessionLocal() db = SessionLocal()
try: try:
plan_specs = [ plan_specs = [
{"id": "1", "name": "基础套餐", "amount": 2_000_000, "price": 10, "badge": "", "desc": "2M 积分"}, {"id": "Basic", "legacy_id": "1", "name": "基础套餐", "amount": 2_000_000, "price": 10, "badge": "", "desc": "2M 积分"},
{"id": "2", "name": "标准套餐", "amount": 20_000_000, "price": 100, "badge": "常用", "desc": "20M 积分"}, {"id": "Standard", "legacy_id": "2", "name": "标准套餐", "amount": 20_000_000, "price": 100, "badge": "常用", "desc": "20M 积分"},
{"id": "3", "name": "专业套餐", "amount": 250_000_000, "price": 1000, "badge": "加赠25%", "desc": "250M 积分"}, {"id": "Professional", "legacy_id": "3", "name": "专业套餐", "amount": 250_000_000, "price": 1000, "badge": "加赠25%", "desc": "250M 积分"},
{"id": "4", "name": "企业套餐", "amount": 2_500_000_000, "price": 10000, "badge": "企业推荐", "desc": "2500M 积分"}, {"id": "Enterprise", "legacy_id": "4", "name": "企业套餐", "amount": 2_500_000_000, "price": 10000, "badge": "企业推荐", "desc": "2500M 积分"},
] ]
for spec in plan_specs: for spec in plan_specs:
plan = db.query(TokenPlan).filter(TokenPlan.id == spec["id"]).first() plan = db.query(TokenPlan).filter(TokenPlan.id == spec["id"]).first()
legacy = db.query(TokenPlan).filter(TokenPlan.id == spec["legacy_id"]).first()
if legacy is not None and plan is None:
legacy.id = spec["id"]
plan = legacy
db.flush()
if legacy is not None and legacy is not plan:
db.delete(legacy)
db.query(TokenPaymentOrder).filter(TokenPaymentOrder.plan_id == spec["legacy_id"]).update(
{TokenPaymentOrder.plan_id: spec["id"]}, synchronize_session=False
)
if plan is None: if plan is None:
db.add(TokenPlan(**spec)) db.add(TokenPlan(**{key: value for key, value in spec.items() if key != "legacy_id"}, virtual_product_id=spec["id"]))
else: else:
for key, value in spec.items(): plan.name = spec["name"]
setattr(plan, key, value) plan.virtual_product_id = spec["id"]
for user in db.query(User).all(): for user in db.query(User).all():
account = db.query(TokenAccount).filter(TokenAccount.user_id == user.id).first() account = db.query(TokenAccount).filter(TokenAccount.user_id == user.id).first()
@@ -272,6 +282,19 @@ def close_expired_pending_orders_job():
db.close() 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(): def start_maintenance_scheduler():
global maintenance_scheduler global maintenance_scheduler
@@ -295,6 +318,13 @@ def start_maintenance_scheduler():
max_instances=1, max_instances=1,
coalesce=True, 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() 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): class PaymentRefund(Base):
__tablename__ = "payment_refunds" __tablename__ = "payment_refunds"
+245 -10
View File
@@ -4,16 +4,18 @@ import json
import os import os
import time import time
import uuid import uuid
from datetime import datetime from datetime import datetime, timedelta
from decimal import Decimal, InvalidOperation, ROUND_HALF_UP from decimal import Decimal, InvalidOperation, ROUND_HALF_UP
from urllib.parse import parse_qs from urllib.parse import parse_qs
from fastapi import APIRouter, Body, Depends, Header, HTTPException, Query, Request, Response from fastapi import APIRouter, Body, Depends, Header, HTTPException, Query, Request, Response
from sqlalchemy import func from sqlalchemy import func
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from database import get_db from database import get_db
from models import ( from models import (
HuihuiOrgPaymentNotice,
InvoiceApplication, InvoiceApplication,
PaymentRefund, PaymentRefund,
PaymentTransaction, PaymentTransaction,
@@ -46,6 +48,13 @@ router = APIRouter(tags=["Token"])
PAYMENT_METHODS = {"wechat": "WECHAT", "alipay": "ALIPAY"} PAYMENT_METHODS = {"wechat": "WECHAT", "alipay": "ALIPAY"}
PAYMENT_SCENES = {"APP", "H5", "LITE", "JSAPI"} PAYMENT_SCENES = {"APP", "H5", "LITE", "JSAPI"}
LEGACY_PLAN_IDS = {"1": "Basic", "2": "Standard", "3": "Professional", "4": "Enterprise"}
def _selected_plan(db: Session, plan_id):
return db.query(TokenPlan).filter(TokenPlan.id == LEGACY_PLAN_IDS.get(str(plan_id), plan_id)).first()
SUCCESS_STATUSES = {"SUCCESS", "SUCCEEDED", "PAID", "COMPLETED", "TRADE_SUCCESS"} SUCCESS_STATUSES = {"SUCCESS", "SUCCEEDED", "PAID", "COMPLETED", "TRADE_SUCCESS"}
FAILED_STATUSES = {"FAIL", "FAILED", "CLOSED", "CANCELLED", "CANCELED", "EXPIRED"} FAILED_STATUSES = {"FAIL", "FAILED", "CLOSED", "CANCELLED", "CANCELED", "EXPIRED"}
@@ -307,7 +316,7 @@ def plans(authorization: str = Header(None), db: Session = Depends(get_db)):
@router.post("/token/charge") @router.post("/token/charge")
def charge(payload: dict = Body(...), authorization: str = Header(None), db: Session = Depends(get_db)): def charge(payload: dict = Body(...), authorization: str = Header(None), db: Session = Depends(get_db)):
user = _require_user(authorization, db) user = _require_user(authorization, db)
plan = db.query(TokenPlan).filter(TokenPlan.id == payload.get("planId")).first() plan = _selected_plan(db, payload.get("planId"))
if not plan: if not plan:
return fail("套餐不存在", 404) return fail("套餐不存在", 404)
@@ -410,7 +419,7 @@ def charge(payload: dict = Body(...), authorization: str = Header(None), db: Ses
@router.post("/token/charge/org") @router.post("/token/charge/org")
def charge_via_huihui_org(payload: dict = Body(...), authorization: str = Header(None), db: Session = Depends(get_db)): def charge_via_huihui_org(payload: dict = Body(...), authorization: str = Header(None), db: Session = Depends(get_db)):
user = _require_user(authorization, db) user = _require_user(authorization, db)
plan = db.query(TokenPlan).filter(TokenPlan.id == payload.get("planId")).first() plan = _selected_plan(db, payload.get("planId"))
if not plan: if not plan:
return fail("套餐不存在", 404) return fail("套餐不存在", 404)
method = str(payload.get("paymentMethod") or "").lower() method = str(payload.get("paymentMethod") or "").lower()
@@ -427,6 +436,7 @@ def charge_via_huihui_org(payload: dict = Body(...), authorization: str = Header
token=user.huihui_token, user_id=user.huihui_user_id, token=user.huihui_token, user_id=user.huihui_user_id,
name=user.nickname, phone=user.phone, avatar=user.avatar_url, name=user.nickname, phone=user.phone, avatar=user.avatar_url,
amount=f"{cents / 100:.2f}", amount=f"{cents / 100:.2f}",
product_id=plan.id,
) )
except HuihuiPaymentError as exc: except HuihuiPaymentError as exc:
return fail(str(exc), 502) return fail(str(exc), 502)
@@ -802,12 +812,10 @@ async def payment_callback(
return ok({"received": True, "paid": True}) return ok({"received": True, "paid": True})
@router.post("/token/payment/huihui/virtual/notify") async def _signed_huihui_notice(request: Request, *, secret_name: str, minimum_length: int) -> dict:
async def huihui_virtual_payment_notify(request: Request, db: Session = Depends(get_db)): secret = os.getenv(secret_name, "").strip()
"""Server-to-server virtual-payment notice using the Huihui HMAC contract.""" if len(secret) < minimum_length:
secret = os.getenv("HUIHUI_VIRTUAL_CALLBACK_SECRET", "").strip() raise HTTPException(status_code=503, detail="payment callback is not configured")
if len(secret) < 32:
raise HTTPException(status_code=503, detail="virtual payment callback is not configured")
timestamp = request.headers.get("x-huihui-timestamp", "").strip() timestamp = request.headers.get("x-huihui-timestamp", "").strip()
signature = request.headers.get("x-huihui-signature", "").strip().lower() signature = request.headers.get("x-huihui-signature", "").strip().lower()
if not timestamp.isascii() or not timestamp.isdecimal() or len(timestamp) > 12: if not timestamp.isascii() or not timestamp.isdecimal() or len(timestamp) > 12:
@@ -832,6 +840,233 @@ async def huihui_virtual_payment_notify(request: Request, db: Session = Depends(
raise HTTPException(status_code=400, detail="invalid callback JSON") raise HTTPException(status_code=400, detail="invalid callback JSON")
if not isinstance(payload, dict): if not isinstance(payload, dict):
raise HTTPException(status_code=400, detail="invalid callback JSON") raise HTTPException(status_code=400, detail="invalid callback JSON")
return payload
@router.post("/token/payment/huihui/org/notify")
async def huihui_org_payment_notify(request: Request, db: Session = Depends(get_db)):
"""Huihui organization recharge notice; independently verify payment before crediting."""
payload = await _signed_huihui_notice(
request, secret_name="HUIHUI_ORG_CALLBACK_SECRET", minimum_length=16,
)
return _process_huihui_org_notice(payload, db)
def _process_huihui_org_notice(payload: dict, db: Session):
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.provider == "huihui_org",
TokenPaymentOrder.order_no == order_no,
).first()
if order is None and order_no.isdigit():
matches = db.query(TokenPaymentOrder).filter(
TokenPaymentOrder.provider == "huihui_org",
TokenPaymentOrder.provider_order_no == order_no,
).limit(2).all()
if len(matches) == 1:
order = matches[0]
if not order:
raise HTTPException(status_code=404, detail="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"))
except (TypeError, ValueError, WechatVirtualPaymentError):
raise HTTPException(status_code=422, detail="invalid payment details")
if (
isinstance(payload.get("amountCents"), bool)
or not user
or not expected_product_id
or str(payload.get("productId") or "") != expected_product_id
or amount_cents != order.price_cents
):
raise HTTPException(status_code=422, detail="payment details mismatch")
if order.status in {"paid", "refunded"}:
return ok({"received": True, "duplicate": True})
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"))
except (TypeError, ValueError):
raise HTTPException(status_code=422, detail="invalid provider order details")
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 "") != 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")
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",
PaymentTransaction.transaction_no == payment_no,
).first()
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()
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")
async def huihui_virtual_payment_notify(request: Request, db: Session = Depends(get_db)):
"""Server-to-server virtual-payment notice using the Huihui HMAC contract."""
# Older Huihui integrations still post organization recharge notices to this
# path. Only use the organization key when no virtual-payment key exists;
# the organization handler independently checks the order's provider.
if len(os.getenv("HUIHUI_VIRTUAL_CALLBACK_SECRET", "").strip()) < 32 and len(
os.getenv("HUIHUI_ORG_CALLBACK_SECRET", "").strip()
) >= 16:
payload = await _signed_huihui_notice(
request, secret_name="HUIHUI_ORG_CALLBACK_SECRET", minimum_length=16,
)
return _process_huihui_org_notice(payload, db)
payload = await _signed_huihui_notice(
request, secret_name="HUIHUI_VIRTUAL_CALLBACK_SECRET", minimum_length=32,
)
order_no = str(payload.get("orderNo") or "").strip() order_no = str(payload.get("orderNo") or "").strip()
payment_no = str(payload.get("paymentNo") or "").strip() payment_no = str(payload.get("paymentNo") or "").strip()
@@ -869,7 +1104,7 @@ async def huihui_virtual_payment_notify(request: Request, db: Session = Depends(
PaymentTransaction.event_type == "payment", PaymentTransaction.event_type == "payment",
PaymentTransaction.transaction_no == payment_no, PaymentTransaction.transaction_no == payment_no,
).first() ).first()
if existing_payment and existing_payment.order_no != order_no: if existing_payment and existing_payment.order_no != order.order_no:
raise HTTPException(status_code=409, detail="payment number belongs to another order") raise HTTPException(status_code=409, detail="payment number belongs to another order")
if order.status in {"paid", "refunded"}: if order.status in {"paid", "refunded"}:
return ok({"received": True, "duplicate": True}) return ok({"received": True, "duplicate": True})
@@ -36,9 +36,10 @@ class HuihuiOrgPaymentClient(HuihuiPaymentClient):
return data return data
def create_order(self, *, token: str, user_id: str, name: str, phone: str, def create_order(self, *, token: str, user_id: str, name: str, phone: str,
avatar: str, amount: str) -> dict: avatar: str, amount: str, product_id: str) -> dict:
body = {"chargeAmount": float(amount), body = {"chargeAmount": float(amount),
"commodityName": "数字分身积分充值", "userId": user_id, "commodityName": "数字分身积分充值", "productId": product_id,
"userId": user_id,
"name": name or "", "phoneNumber": phone or "", "avatar": avatar or ""} "name": name or "", "phoneNumber": phone or "", "avatar": avatar or ""}
return self._request("POST", "/order/tokenRecharge", token=token, user_id=user_id, body=body) return self._request("POST", "/order/tokenRecharge", token=token, user_id=user_id, body=body)
@@ -49,9 +49,7 @@ def _offer_id() -> str:
def product_id_for_plan(plan) -> str: def product_id_for_plan(plan) -> str:
configured = str(getattr(plan, "virtual_product_id", "") or "").strip() configured = str(getattr(plan, "id", "") or "").strip()
if not configured:
configured = os.getenv(f"WECHAT_VIRTUAL_PRODUCT_{plan.id}", "").strip()
if not configured: if not configured:
raise WechatVirtualPaymentError(f"套餐 {plan.id} 尚未配置微信虚拟支付商品 ID") raise WechatVirtualPaymentError(f"套餐 {plan.id} 尚未配置微信虚拟支付商品 ID")
if len(configured) > 64 or not all(ch.isalnum() or ch in "_-" for ch in configured): if len(configured) > 64 or not all(ch.isalnum() or ch in "_-" for ch in configured):
@@ -1,6 +1,7 @@
import os import os
from unittest.mock import Mock, patch from unittest.mock import Mock, patch
from services.huihui_org_payment import HuihuiOrgPaymentClient
from services.huihui_payment import HuihuiPaymentClient from services.huihui_payment import HuihuiPaymentClient
@@ -81,3 +82,31 @@ def test_request_refund_uses_configured_huihui_endpoint_without_exposing_secret(
"refundReason": "用户申请", "refundReason": "用户申请",
} }
assert "accessSecret" not in post.call_args.kwargs["params"] assert "accessSecret" not in post.call_args.kwargs["params"]
def test_org_recharge_order_sends_package_product_id_as_string():
env = {
"HUIHUI_ORG_BASE_URL": "https://fat-open.example/api/huihuiorg",
"HUIHUI_APP_ID": "app-id",
"HUIHUI_ACCESS_ID": "access-id",
"HUIHUI_ACCESS_SECRET": "access-secret",
}
response = Mock(status_code=200)
response.json.return_value = {"code": 200, "data": {"orderNo": "261016111200090123456"}}
with patch.dict(os.environ, env), patch(
"services.huihui_org_payment.httpx.request", return_value=response
) as request:
result = HuihuiOrgPaymentClient().create_order(
token="user-token", user_id="user-id", name="测试用户",
phone="", avatar="", amount="10.00", product_id="Basic",
)
assert result["orderNo"] == "261016111200090123456"
assert request.call_args.args == (
"POST", "https://fat-open.example/api/huihuiorg/order/tokenRecharge"
)
body = request.call_args.kwargs["json"]
assert body["chargeAmount"] == 10.0
assert body["productId"] == "Basic"
assert isinstance(body["productId"], str)
@@ -53,12 +53,15 @@ def test_scheduler_uses_boxim_and_restart_safe_service(
max_message_age_seconds=600, max_message_age_seconds=600,
) )
assert maintenance_scheduler.add_job.call_count == 2 assert maintenance_scheduler.add_job.call_count == 3
cleanup_call, close_orders_call = maintenance_scheduler.add_job.call_args_list cleanup_call, close_orders_call, reconcile_call = maintenance_scheduler.add_job.call_args_list
assert cleanup_call.kwargs["id"] == "chat_attachment_cleanup" 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.args[0] is main.close_expired_pending_orders_job
assert close_orders_call.kwargs["id"] == "close_expired_token_orders" assert close_orders_call.kwargs["id"] == "close_expired_token_orders"
assert close_orders_call.kwargs["trigger"].interval.total_seconds() == 60 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() maintenance_scheduler.start.assert_called_once_with()
assert scheduler.add_job.call_count == 2 assert scheduler.add_job.call_count == 2
@@ -57,6 +57,10 @@ def test_seed_synchronizes_requested_recharge_plans():
db = SessionLocal() db = SessionLocal()
try: try:
plans = db.query(TokenPlan).order_by(TokenPlan.price.asc()).all() plans = db.query(TokenPlan).order_by(TokenPlan.price.asc()).all()
assert {plan.id: plan.virtual_product_id for plan in plans} == {
"Basic": "Basic", "Standard": "Standard",
"Professional": "Professional", "Enterprise": "Enterprise",
}
assert [(plan.price, plan.amount) for plan in plans] == [ assert [(plan.price, plan.amount) for plan in plans] == [
(10, 2_000_000), (10, 2_000_000),
(100, 20_000_000), (100, 20_000_000),
@@ -67,6 +71,33 @@ def test_seed_synchronizes_requested_recharge_plans():
db.close() db.close()
def test_seed_preserves_admin_price_and_migrates_old_order_plan_id():
seed()
db = SessionLocal()
order_no = f"TEST-MIGRATION-{uuid.uuid4().hex}"
try:
plan = db.query(TokenPlan).filter_by(id="Basic").one()
original_price = plan.price
plan.price = 0.01
db.add(TokenPlan(id="1", name="基础套餐", amount=2_000_000, price=10))
db.add(TokenPaymentOrder(
order_no=order_no, user_id="test-user", plan_id="1",
payment_method="wechat", pay_type="WECHAT", pay_way="JSAPI",
points_amount=2_000_000, price_cents=1000, status="pending",
))
db.commit()
seed()
db.expire_all()
assert db.query(TokenPlan).filter_by(id="1").first() is None
assert db.query(TokenPlan).filter_by(id="Basic").one().price == 0.01
assert db.query(TokenPaymentOrder).filter_by(order_no=order_no).one().plan_id == "Basic"
finally:
db.query(TokenPaymentOrder).filter_by(order_no=order_no).delete()
db.query(TokenPlan).filter_by(id="Basic").update({"price": original_price})
db.commit()
db.close()
def test_charge_creates_huihui_order_without_early_points(authorization_context): def test_charge_creates_huihui_order_without_early_points(authorization_context):
context = authorization_context context = authorization_context
_enable_huihui_payment_login(context) _enable_huihui_payment_login(context)
@@ -121,6 +152,7 @@ def test_org_recharge_only_credits_after_verified_provider_order(authorization_c
assert created["balance"] == DEFAULT_TOKEN_GRANT assert created["balance"] == DEFAULT_TOKEN_GRANT
assert created["checkoutToken"] == f"huihui-payment-{context['suffix']}" assert created["checkoutToken"] == f"huihui-payment-{context['suffix']}"
assert "pay_type" not in provider.create_order.call_args.kwargs assert "pay_type" not in provider.create_order.call_args.kwargs
assert provider.create_order.call_args.kwargs["product_id"] == "Basic"
details = {**created_data, "payStatus": 2, details = {**created_data, "payStatus": 2,
"userId": context["owner"].huihui_user_id} "userId": context["owner"].huihui_user_id}
@@ -3,13 +3,14 @@ import hmac
import json import json
import os import os
import time import time
from datetime import datetime, timedelta
from unittest.mock import patch from unittest.mock import patch
from fastapi.testclient import TestClient from fastapi.testclient import TestClient
from database import SessionLocal from database import SessionLocal
from main import app, seed 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 from services.token_billing import DEFAULT_TOKEN_GRANT
@@ -30,8 +31,7 @@ def test_huihui_virtual_callback_requires_signature_and_exact_order_details(auth
user = db.query(User).filter(User.id == context["owner"].id).one() user = db.query(User).filter(User.id == context["owner"].id).one()
user.wechat_mp_openid = "openid-huihui-callback" user.wechat_mp_openid = "openid-huihui-callback"
user.wechat_mp_session_key = "session-huihui-callback" user.wechat_mp_session_key = "session-huihui-callback"
plan = db.query(TokenPlan).filter(TokenPlan.id == "1").one() plan = db.query(TokenPlan).filter(TokenPlan.id == "Basic").one()
plan.virtual_product_id = "points_plan_1"
db.commit() db.commit()
finally: finally:
db.close() db.close()
@@ -47,14 +47,14 @@ def test_huihui_virtual_callback_requires_signature_and_exact_order_details(auth
created = client.post( created = client.post(
"/api/token/charge", "/api/token/charge",
headers=context["owner_headers"], headers=context["owner_headers"],
json={"planId": "1", "paymentMethod": "wechat", "payScene": "LITE"}, json={"planId": "Basic", "paymentMethod": "wechat", "payScene": "LITE"},
).json()["data"] ).json()["data"]
payload = { payload = {
"orderNo": created["orderNo"], "orderNo": created["orderNo"],
"paymentNo": f"huihui-virtual-payment-{context['suffix']}", "paymentNo": f"huihui-virtual-payment-{context['suffix']}",
"status": "SUCCESS", "status": "SUCCESS",
"amountCents": 1000, "amountCents": 1000,
"productId": "points_plan_1", "productId": "Basic",
"openId": "openid-huihui-callback", "openId": "openid-huihui-callback",
"env": 1, "env": 1,
} }
@@ -79,6 +79,109 @@ def test_huihui_virtual_callback_requires_signature_and_exact_order_details(auth
db.close() db.close()
def test_huihui_org_callback_verifies_provider_before_granting_points(authorization_context):
seed()
context = authorization_context
provider_no = "26102010414409000001"
payment_no = f"WV-{context['suffix']}"
order_no = f"AV-{context['suffix']}"
db = SessionLocal()
try:
user = db.query(User).filter(User.id == context["owner"].id).one()
user.huihui_token = "test-huihui-token"
db.add(TokenPaymentOrder(
order_no=order_no, provider_order_no=provider_no,
user_id=user.id, plan_id="Basic", payment_method="wechat",
pay_type="WECHAT", pay_way="LITE", provider="huihui_org",
points_amount=2_000_000, price_cents=100, status="pending",
))
db.commit()
finally:
db.close()
secret = "abcdefghijklmnop"
path = "/api/token/payment/huihui/org/notify"
payload = {
"orderNo": provider_no, "paymentNo": payment_no,
"status": "SUCCESS", "amountCents": 100,
"productId": "Basic",
}
provider_data = {
"orderNo": provider_no, "paymentNo": payment_no,
"payStatus": 1, "chargeType": 9, "chargeAmount": 1,
"productId": "Basic", "userId": context["owner"].huihui_user_id,
}
def send(body_payload, *, signature=True, callback_path=path):
body = json.dumps(body_payload, separators=(",", ":")).encode()
headers = _huihui_virtual_headers(secret, body) if signature else {"X-Huihui-Signature": "bad"}
return client.post(callback_path, content=body, headers=headers)
with patch.dict(os.environ, {
"HUIHUI_ORG_CALLBACK_SECRET": secret,
"HUIHUI_VIRTUAL_CALLBACK_SECRET": "",
}), patch(
"routers.tokens.HuihuiOrgPaymentClient"
) as client_factory:
provider = client_factory.return_value
provider.get_order.return_value = provider_data
assert send(payload, signature=False).status_code == 401
legacy_path = "/api/token/payment/huihui/virtual/notify"
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
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, "duplicate": True}
assert second.json()["data"] == {"received": True, "duplicate": True}
db = SessionLocal()
try:
order = db.query(TokenPaymentOrder).filter(TokenPaymentOrder.order_no == order_no).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
assert provider.get_order.call_count >= 3
finally:
db.close()
def _signature(token, timestamp, nonce): def _signature(token, timestamp, nonce):
return hashlib.sha1("".join(sorted([token, timestamp, nonce])).encode()).hexdigest() return hashlib.sha1("".join(sorted([token, timestamp, nonce])).encode()).hexdigest()
@@ -91,8 +194,7 @@ def test_virtual_payment_callback_and_refund_are_idempotent(authorization_contex
user = db.query(User).filter(User.id == context["owner"].id).one() user = db.query(User).filter(User.id == context["owner"].id).one()
user.wechat_mp_openid = "openid-flow" user.wechat_mp_openid = "openid-flow"
user.wechat_mp_session_key = "session-flow" user.wechat_mp_session_key = "session-flow"
plan = db.query(TokenPlan).filter(TokenPlan.id == "1").one() plan = db.query(TokenPlan).filter(TokenPlan.id == "Basic").one()
plan.virtual_product_id = "points_plan_1"
db.commit() db.commit()
finally: finally:
db.close() db.close()
@@ -108,7 +210,7 @@ def test_virtual_payment_callback_and_refund_are_idempotent(authorization_contex
created = client.post( created = client.post(
"/api/token/charge", "/api/token/charge",
headers=context["owner_headers"], headers=context["owner_headers"],
json={"planId": "1", "paymentMethod": "wechat", "payScene": "LITE"}, json={"planId": "Basic", "paymentMethod": "wechat", "payScene": "LITE"},
).json()["data"] ).json()["data"]
assert created["provider"] == "wechat_virtual" assert created["provider"] == "wechat_virtual"
params = json.loads(created["payMessage"]) params = json.loads(created["payMessage"])
@@ -120,7 +222,7 @@ def test_virtual_payment_callback_and_refund_are_idempotent(authorization_contex
"OutTradeNo": created["orderNo"], "OutTradeNo": created["orderNo"],
"OpenId": "openid-flow", "OpenId": "openid-flow",
"Env": 1, "Env": 1,
"GoodsInfo": json.dumps({"ProductId": "points_plan_1", "ActualPrice": 1000}), "GoodsInfo": json.dumps({"ProductId": "Basic", "ActualPrice": 1000}),
"WeChatPayInfo": json.dumps({"TransactionId": "wx-transaction-1"}), "WeChatPayInfo": json.dumps({"TransactionId": "wx-transaction-1"}),
} }
query = {"timestamp": "100", "nonce": "nonce", "signature": _signature("callback-token", "100", "nonce")} query = {"timestamp": "100", "nonce": "nonce", "signature": _signature("callback-token", "100", "nonce")}
@@ -9,8 +9,8 @@ from services import wechat_virtual_payment as virtual
def test_build_payment_params_signs_the_exact_compact_payload(): def test_build_payment_params_signs_the_exact_compact_payload():
order = SimpleNamespace(order_no="AV202609080001", plan_id="plan-1", price_cents=1000) order = SimpleNamespace(order_no="AV202609080001", plan_id="Basic", price_cents=1000)
plan = SimpleNamespace(id="plan-1", virtual_product_id="points_plan_1") plan = SimpleNamespace(id="Basic", virtual_product_id="stale-product-id")
env = { env = {
"WECHAT_VIRTUAL_ENV": "sandbox", "WECHAT_VIRTUAL_ENV": "sandbox",
"WECHAT_VIRTUAL_SANDBOX_APP_KEY": "sandbox-key", "WECHAT_VIRTUAL_SANDBOX_APP_KEY": "sandbox-key",
@@ -25,10 +25,10 @@ def test_build_payment_params_signs_the_exact_compact_payload():
"buyQuantity": 1, "buyQuantity": 1,
"env": 1, "env": 1,
"currencyType": "CNY", "currencyType": "CNY",
"productId": "points_plan_1", "productId": "Basic",
"goodsPrice": 1000, "goodsPrice": 1000,
"outTradeNo": "AV202609080001", "outTradeNo": "AV202609080001",
"attach": '{"orderNo":"AV202609080001","planId":"plan-1"}', "attach": '{"orderNo":"AV202609080001","planId":"Basic"}',
}, ensure_ascii=False, separators=(",", ":")) }, ensure_ascii=False, separators=(",", ":"))
assert result["paySig"] == hmac.new( assert result["paySig"] == hmac.new(
b"sandbox-key", f"requestVirtualPayment&{sign_data}".encode(), hashlib.sha256 b"sandbox-key", f"requestVirtualPayment&{sign_data}".encode(), hashlib.sha256
@@ -58,10 +58,9 @@ WECHAT_VIRTUAL_SANDBOX_APP_KEY=<沙箱AppKey>
WECHAT_VIRTUAL_APP_KEY=<正式AppKey> WECHAT_VIRTUAL_APP_KEY=<正式AppKey>
WECHAT_VIRTUAL_OFFER_ID=<offer-id> WECHAT_VIRTUAL_OFFER_ID=<offer-id>
WECHAT_VIRTUAL_CALLBACK_TOKEN=<回调校验Token> WECHAT_VIRTUAL_CALLBACK_TOKEN=<回调校验Token>
WECHAT_VIRTUAL_PRODUCT_1=<10元套餐商品ID> HUIHUI_ORG_CALLBACK_SECRET=<会会组织充值支付结果通知验签密钥>
WECHAT_VIRTUAL_PRODUCT_2=<100元套餐商品ID> HUIHUI_VIRTUAL_CALLBACK_SECRET=<微信直连虚拟支付转发通知验签密钥>
WECHAT_VIRTUAL_PRODUCT_3=<1000元套餐商品ID> # 商品 ID 直接使用套餐 ID:Basic、Standard、Professional、Enterprise
WECHAT_VIRTUAL_PRODUCT_4=<10000元套餐商品ID>
AVATAR_DB_DIR=/srv/digital-avatar/data/db AVATAR_DB_DIR=/srv/digital-avatar/data/db
AVATAR_UPLOAD_DIR=/srv/digital-avatar/data/uploads AVATAR_UPLOAD_DIR=/srv/digital-avatar/data/uploads
@@ -92,11 +91,11 @@ CHAT_ATTACHMENT_CLEANUP_MINUTES=60
`EMBEDDING_API_URL` 同时支持 OpenAI 兼容基础地址(如上面的 `/v1`)和完整的 `/v1/embeddings` 地址,后端会统一请求 `/embeddings`。发布后必须在后端容器内执行一次最小向量探针,确认返回向量数量和维度,而不能只检查 `/api/health`。 `EMBEDDING_API_URL` 同时支持 OpenAI 兼容基础地址(如上面的 `/v1`)和完整的 `/v1/embeddings` 地址,后端会统一请求 `/embeddings`。发布后必须在后端容器内执行一次最小向量探针,确认返回向量数量和维度,而不能只检查 `/api/health`。
App 与 H5 积分充值使用会会支付体系的 `payment-v3/payment/pay`,渠道值为 `WECHAT` / `ALIPAY`;App 场景为 `APP`,普通浏览器为 `H5`,微信内 H5 为 `JSAPI`。`HUIHUI_PAYMENT_CALLBACK_SECRET` 只用于为每笔订单生成 HMAC 回调签名,不会发送到前端或直接出现在回调地址中。支付回调确认状态成功且金额与套餐价格完全一致后才增加积分,重复回调不会重复到账。 当前 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 沿用会会普通收银台。支付回调确认状态成功且金额与套餐价格完全一致后才增加积分,重复回调不会重复到账。
微信小程序使用微信虚拟支付:小程序先通过 `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 均不得下发前端或写日志。 微信小程序虚拟支付的服务端接口为:小程序先通过 `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 均不得下发前端或写日志。
如由会会服务端转发微信小程序虚拟支付结果,另有待双方确认的固定回调契约,见 [HUIHUI_VIRTUAL_CALLBACK.md](HUIHUI_VIRTUAL_CALLBACK.md)。该接口使用独立的 `HUIHUI_VIRTUAL_CALLBACK_SECRET`;即使接口代码部署到生产,在协议确认和联调验收前也不得将其视作已上线的支付渠道。 会会组织充值订单和由会会服务端转发的微信小程序虚拟支付结果,分别使用独立的回调地址及验签密钥,见 [HUIHUI_VIRTUAL_CALLBACK.md](HUIHUI_VIRTUAL_CALLBACK.md)。对于组织订单,回调验签后还必须向会会查单确认实付状态与订单详情。即使接口代码部署到生产,在会会发送格式确认和联调验收前也不得将其视作已上线的支付渠道。
管理后台需要配置相同的 `AVATAR_FINANCE_ADMIN_SECRET` 和 `AVATAR_BACKEND_URL=https://digital.99hui.com`。退款只支持整单原路退款;供应商受理后显示“处理中”,收到渠道成功回调(或经渠道后台核对后人工确认)才将订单置为已退款。已消费掉本订单积分时,后台会拒绝主动退款;若渠道外部退款先发生,积分账户允许形成负数以记录欠额并阻止继续消费。 管理后台需要配置相同的 `AVATAR_FINANCE_ADMIN_SECRET` 和 `AVATAR_BACKEND_URL=https://digital.99hui.com`。退款只支持整单原路退款;供应商受理后显示“处理中”,收到渠道成功回调(或经渠道后台核对后人工确认)才将订单置为已退款。已消费掉本订单积分时,后台会拒绝主动退款;若渠道外部退款先发生,积分账户允许形成负数以记录欠额并阻止继续消费。
@@ -1,32 +1,32 @@
# 会会微信小程序虚拟支付回调(待双方确认) # 会会组织充值支付回调
本项目为会会服务端预留固定回调地址: 当前 H5 收银台组织充值订单,对会会提供的测试环境回调地址只有:
`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/wechat/virtual/notify`。 其他历史接收路由仅供服务端兼容旧请求,不作为提供给会会的回调地址,也不应配置为新订单的通知地址。
回调报文及签名协议仍须按双方联调结果确认。以下是本项目实现的接收契约;接口部署与完整支付联调通过是两个独立状态。微信直连虚拟支付回调属于另一条支付链路,不使用本文的组织充值回调地址。
请求体为 JSON,成功支付示例: 请求体为 JSON,成功支付示例:
```json ```json
{ {
"orderNo": "AV202610150000000000000001", "orderNo": "26102010414409234218",
"paymentNo": "会会侧唯一支付流水号", "paymentNo": "会会侧唯一支付流水号",
"status": "SUCCESS", "status": "SUCCESS",
"amountCents": 1000, "amountCents": 1000,
"productId": "points_plan_1", "productId": "Basic"
"openId": "微信小程序用户 OpenID",
"env": 1
} }
``` ```
`amountCents` 单位为分,`env` 为微信虚拟支付环境(0 正式、1 沙箱)。非成功结果可发送 `status: "FAILED"`,但不会入账。订单号、支付流水号和状态必填;成功结果还必须提供金额、商品 ID、OpenID、环境。仅在订单属于微信虚拟支付,且上述信息与本地订单、套餐、用户一致时入账。重复成功通知不会重复发放积分;同一支付流水号不能用于其他订单。 `amountCents` 单位为分。对于经 `/order/tokenRecharge` 创建的会会组织订单,`orderNo` 可以是会会订单号或数字分身 `AV` 订单号;成功通知必须提供 `amountCents`、`productId` 和 `paymentNo`,无需 `openId`、`env`。服务端会再次向会会查单,要求订单号、支付流水、商品 ID、用户、收费类型、金额一致,且会会支付状态为已付款,才发放积分。非成功结果可发送 `status: "FAILED"`,但不会入账。重复成功通知不会重复发放积分;同一支付流水号不能用于其他订单。
请求头: 请求头:
- `X-Huihui-Timestamp`:Unix 秒级时间戳,允许与本服务时间相差不超过 300 秒。 - `X-Huihui-Timestamp`:Unix 秒级时间戳,允许与本服务时间相差不超过 300 秒。
- `X-Huihui-Signature`:小写十六进制 HMAC-SHA256,密钥为双方线下安全交换、服务端配置的 `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。
上线前需由会会确认:其服务端是否能按此格式签名并发送通知、支付成功状态与支付流水号字段的正式定义、失败通知及重试/超时策略。小程序支付发起和退款仍需独立对接和验收;仅完成此回调不代表虚拟支付全流程已可用。 上线前需由会会确认:其服务端是否能按此格式签名并发送通知、支付成功状态与支付流水号字段的正式定义、失败通知及重试/超时策略。小程序支付发起和退款仍需独立对接和验收;仅完成此回调不代表虚拟支付全流程已可用。