Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d88caa75dd | ||
|
|
cce5130136 | ||
|
|
721f8b990d | ||
|
|
5c992729c4 | ||
|
|
23d9a33a6d | ||
|
|
04e96b6207 | ||
|
|
ce18df29b7 | ||
|
|
356f1c8c8d | ||
|
|
643abd13ea | ||
|
|
3379d5a1b2 | ||
|
|
5f27cc93cc | ||
|
|
07cef004d2 |
@@ -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()
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|
||||||
|
|||||||
@@ -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。
|
||||||
|
|
||||||
上线前需由会会确认:其服务端是否能按此格式签名并发送通知、支付成功状态与支付流水号字段的正式定义、失败通知及重试/超时策略。小程序支付发起和退款仍需独立对接和验收;仅完成此回调不代表虚拟支付全流程已可用。
|
上线前需由会会确认:其服务端是否能按此格式签名并发送通知、支付成功状态与支付流水号字段的正式定义、失败通知及重试/超时策略。小程序支付发起和退款仍需独立对接和验收;仅完成此回调不代表虚拟支付全流程已可用。
|
||||||
|
|||||||
Reference in New Issue
Block a user