feat: add avatar payments and finance management

This commit is contained in:
stefanfeng
2026-09-08 18:47:29 +08:00
parent 62eb9578fd
commit 8585d101d5
36 changed files with 10228 additions and 43 deletions
+23
View File
@@ -66,11 +66,18 @@ def init_db():
("token_account", "total_consumed", "BIGINT DEFAULT 0"),
("token_account", "created_at", "TIMESTAMP"),
("token_account", "updated_at", "TIMESTAMP"),
("token_plans", "virtual_product_id", "VARCHAR DEFAULT ''"),
("token_payment_orders", "provider", "VARCHAR DEFAULT 'huihui'"),
("token_payment_orders", "refund_status", "VARCHAR DEFAULT 'none'"),
("token_payment_orders", "refunded_at", "TIMESTAMP"),
("users", "wechat_mp_openid", "VARCHAR DEFAULT ''"),
("users", "wechat_mp_session_key", "VARCHAR DEFAULT ''"),
("takeover_messages", "attachment_id", "VARCHAR DEFAULT NULL"),
)
_normalize_optional_unique_values()
_normalize_takeover_delays()
_create_token_indexes()
_create_payment_indexes()
def _try_add_columns(*cols):
@@ -104,3 +111,19 @@ def _create_token_indexes():
"CREATE UNIQUE INDEX IF NOT EXISTS ux_token_account_user_id "
"ON token_account(user_id) WHERE user_id <> ''"
)
def _create_payment_indexes():
with engine.begin() as conn:
conn.exec_driver_sql(
"CREATE INDEX IF NOT EXISTS ix_token_payment_orders_provider "
"ON token_payment_orders(provider)"
)
conn.exec_driver_sql(
"CREATE INDEX IF NOT EXISTS ix_token_payment_orders_refund_status "
"ON token_payment_orders(refund_status)"
)
conn.exec_driver_sql(
"CREATE INDEX IF NOT EXISTS ix_users_wechat_mp_openid "
"ON users(wechat_mp_openid)"
)
+114
View File
@@ -344,6 +344,7 @@ class TokenPlan(Base):
price = Column(Float, default=0)
badge = Column(String, default="")
desc = Column(String, default="")
virtual_product_id = Column(String, default="")
def to_dict(self):
return {
@@ -353,6 +354,7 @@ class TokenPlan(Base):
"price": self.price,
"badge": self.badge,
"desc": self.desc,
"virtualProductId": self.virtual_product_id,
}
@@ -369,14 +371,17 @@ class TokenPaymentOrder(Base):
points_amount = Column(BigInteger, nullable=False)
price_cents = Column(Integer, nullable=False)
status = Column(String, nullable=False, default="pending", index=True)
provider = Column(String, nullable=False, default="huihui", index=True)
provider_order_id = Column(String, default="")
provider_order_no = Column(String, default="")
provider_status = Column(String, default="")
pay_message = Column(Text, default="")
failure_reason = Column(String, default="")
refund_status = Column(String, nullable=False, default="none", index=True)
created_at = Column(DateTime, server_default=func.now())
updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now())
paid_at = Column(DateTime)
refunded_at = Column(DateTime)
def to_dict(self):
return {
@@ -389,11 +394,117 @@ class TokenPaymentOrder(Base):
"pointsAmount": self.points_amount,
"price": self.price_cents / 100,
"status": self.status,
"provider": self.provider,
"providerStatus": self.provider_status,
"payMessage": self.pay_message,
"failureReason": self.failure_reason,
"refundStatus": self.refund_status,
"createdAt": _iso(self.created_at),
"paidAt": _iso(self.paid_at),
"refundedAt": _iso(self.refunded_at),
}
class PaymentTransaction(Base):
"""Auditable provider event for one Token purchase order."""
__tablename__ = "payment_transactions"
__table_args__ = (
Index("ix_payment_transactions_order_created", "order_no", "created_at"),
)
id = Column(String, primary_key=True, default=lambda: uuid.uuid4().hex)
order_no = Column(String, nullable=False, index=True)
provider = Column(String, nullable=False, default="huihui")
transaction_no = Column(String, nullable=False, default="")
event_type = Column(String, nullable=False, default="payment")
status = Column(String, nullable=False, default="pending")
amount_cents = Column(Integer, nullable=False, default=0)
raw_summary = Column(Text, default="")
created_at = Column(DateTime, server_default=func.now())
def to_dict(self):
return {
"id": self.id,
"orderNo": self.order_no,
"provider": self.provider,
"transactionNo": self.transaction_no,
"eventType": self.event_type,
"status": self.status,
"amount": self.amount_cents / 100,
"createdAt": _iso(self.created_at),
}
class PaymentRefund(Base):
__tablename__ = "payment_refunds"
id = Column(String, primary_key=True, default=lambda: uuid.uuid4().hex)
refund_no = Column(String, nullable=False, unique=True, index=True)
order_no = Column(String, nullable=False, index=True)
amount_cents = Column(Integer, nullable=False)
points_amount = Column(BigInteger, nullable=False)
reason = Column(String, default="")
status = Column(String, nullable=False, default="pending", index=True)
provider_refund_no = Column(String, default="")
requested_by = Column(String, default="admin")
failure_reason = Column(String, default="")
created_at = Column(DateTime, server_default=func.now())
updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now())
completed_at = Column(DateTime)
def to_dict(self):
return {
"id": self.id,
"refundNo": self.refund_no,
"orderNo": self.order_no,
"amount": self.amount_cents / 100,
"pointsAmount": self.points_amount,
"reason": self.reason,
"status": self.status,
"providerRefundNo": self.provider_refund_no,
"requestedBy": self.requested_by,
"failureReason": self.failure_reason,
"createdAt": _iso(self.created_at),
"completedAt": _iso(self.completed_at),
}
class InvoiceApplication(Base):
__tablename__ = "invoice_applications"
id = Column(String, primary_key=True, default=lambda: uuid.uuid4().hex)
order_no = Column(String, nullable=False, unique=True, index=True)
user_id = Column(String, nullable=False, index=True)
amount_cents = Column(Integer, nullable=False)
title = Column(String, nullable=False)
invoice_type = Column(String, nullable=False, default="personal")
tax_number = Column(String, default="")
email = Column(String, default="")
status = Column(String, nullable=False, default="pending", index=True)
invoice_no = Column(String, default="")
invoice_url = Column(String, default="")
remark = Column(String, default="")
created_at = Column(DateTime, server_default=func.now())
updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now())
issued_at = Column(DateTime)
def to_dict(self):
return {
"id": self.id,
"orderNo": self.order_no,
"userId": self.user_id,
"amount": self.amount_cents / 100,
"title": self.title,
"invoiceType": self.invoice_type,
"taxNumber": self.tax_number,
"email": self.email,
"status": self.status,
"invoiceNo": self.invoice_no,
"invoiceUrl": self.invoice_url,
"remark": self.remark,
"createdAt": _iso(self.created_at),
"issuedAt": _iso(self.issued_at),
}
@@ -408,6 +519,9 @@ class User(Base):
avatar_url = Column(String, default="")
huihui_token = Column(String, default="") # 会会 access_token
app_token = Column(String, default="") # 本系统会话 token
wechat_mp_openid = Column(String, default="", index=True)
# 微信 session_key 仅保存在服务端,用于虚拟支付用户态签名,绝不下发客户端。
wechat_mp_session_key = Column(String, default="")
last_login_at = Column(DateTime)
created_at = Column(DateTime, server_default=func.now())
updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now())
+594 -26
View File
@@ -7,20 +7,43 @@ from datetime import datetime
from decimal import Decimal, InvalidOperation, ROUND_HALF_UP
from urllib.parse import parse_qs
from fastapi import APIRouter, Body, Depends, Header, HTTPException, Request
from fastapi import APIRouter, Body, Depends, Header, HTTPException, Query, Request, Response
from sqlalchemy import func
from sqlalchemy.orm import Session
from database import get_db
from models import TokenAccount, TokenPaymentOrder, TokenPlan, TokenUsage, User
from models import (
InvoiceApplication,
PaymentRefund,
PaymentTransaction,
TokenAccount,
TokenPaymentOrder,
TokenPlan,
TokenUsage,
User,
)
from responses import fail, ok
from services.huihui_payment import HuihuiPaymentClient, HuihuiPaymentError
from services.token_billing import DEFAULT_TOKEN_GRANT, get_or_create_account
from services.token_billing import get_or_create_account
from services.wechat_virtual_payment import (
PAYMENT_EVENTS as WECHAT_PAYMENT_EVENTS,
REFUND_EVENTS as WECHAT_REFUND_EVENTS,
WechatVirtualPaymentError,
build_payment_params as build_wechat_virtual_payment_params,
callback_value as wechat_callback_value,
exchange_code as exchange_wechat_code,
parse_callback_body as parse_wechat_callback_body,
product_id_for_plan,
query_order as query_wechat_virtual_order,
request_refund as request_wechat_virtual_refund,
verify_callback_signature as verify_wechat_callback_signature,
virtual_env as wechat_virtual_env,
)
router = APIRouter(tags=["Token"])
PAYMENT_METHODS = {"wechat": "WECHAT", "alipay": "ALIPAY"}
PAYMENT_SCENES = {"APP", "LITE", "JSAPI"}
PAYMENT_SCENES = {"APP", "H5", "LITE", "JSAPI"}
SUCCESS_STATUSES = {"SUCCESS", "SUCCEEDED", "PAID", "COMPLETED", "TRADE_SUCCESS"}
FAILED_STATUSES = {"FAIL", "FAILED", "CLOSED", "CANCELLED", "CANCELED", "EXPIRED"}
@@ -35,6 +58,13 @@ def _require_user(authorization: str | None, db: Session) -> User:
return user
def _require_finance_admin(value: str | None):
expected = os.getenv("AVATAR_FINANCE_ADMIN_SECRET", "").strip()
provided = str(value or "").strip()
if len(expected) < 16 or not hmac.compare_digest(provided, expected):
raise HTTPException(status_code=403, detail="财务管理凭证无效")
def _payment_client() -> HuihuiPaymentClient:
return HuihuiPaymentClient({
"HUIHUI_PAYMENT_BASE_URL": os.getenv(
@@ -70,6 +100,132 @@ def _payment_payload(order: TokenPaymentOrder, account: TokenAccount) -> dict:
return {**order.to_dict(), "balance": account.balance}
def _safe_event_summary(payload: dict) -> str:
"""Persist only reconciliation fields, never signatures, tokens or session keys."""
summary = {}
for key in (
"Event", "OutTradeNo", "OpenId", "Env", "MchOrderId", "MchRefundId",
"WxRefundId", "RefundFee", "RetCode", "RetMsg",
):
value = wechat_callback_value(payload, key)
if value not in (None, ""):
summary[key] = value
goods = wechat_callback_value(payload, "GoodsInfo")
if isinstance(goods, dict):
summary["GoodsInfo"] = {
key: goods.get(key)
for key in ("ProductId", "Quantity", "OrigPrice", "ActualPrice")
if goods.get(key) not in (None, "")
}
return json.dumps(summary, ensure_ascii=False, separators=(",", ":"))[:2000]
def _record_transaction(
db: Session,
*,
order: TokenPaymentOrder,
provider: str,
status: str,
amount_cents: int,
event_type: str = "payment",
transaction_no: str = "",
raw_summary: str = "",
):
if transaction_no:
duplicate = db.query(PaymentTransaction).filter(
PaymentTransaction.provider == provider,
PaymentTransaction.transaction_no == transaction_no,
PaymentTransaction.event_type == event_type,
).first()
if duplicate:
return duplicate
row = PaymentTransaction(
order_no=order.order_no,
provider=provider,
transaction_no=transaction_no,
event_type=event_type,
status=status,
amount_cents=amount_cents,
raw_summary=raw_summary,
)
db.add(row)
return row
def _settle_paid_order(
db: Session,
order: TokenPaymentOrder,
*,
provider_status: str,
transaction_no: str = "",
raw_summary: str = "",
) -> bool:
if order.status in {"paid", "refunded"}:
return False
updated = db.query(TokenPaymentOrder).filter(
TokenPaymentOrder.id == order.id,
TokenPaymentOrder.status.in_(["pending", "failed", "closed"]),
).update({
TokenPaymentOrder.status: "paid",
TokenPaymentOrder.provider_status: provider_status,
TokenPaymentOrder.paid_at: datetime.utcnow(),
TokenPaymentOrder.failure_reason: "",
}, synchronize_session=False)
if not updated:
return False
account = get_or_create_account(db, order.user_id)
account.balance = int(account.balance or 0) + order.points_amount
account.total_granted = int(account.total_granted or 0) + order.points_amount
_record_transaction(
db,
order=order,
provider=order.provider,
status="paid",
amount_cents=order.price_cents,
transaction_no=transaction_no,
raw_summary=raw_summary,
)
return True
def _complete_refund(
db: Session,
order: TokenPaymentOrder,
refund: PaymentRefund,
*,
provider_refund_no: str = "",
failure_reason: str = "",
):
if failure_reason:
refund.status = "failed"
refund.failure_reason = failure_reason[:500]
order.refund_status = "failed"
return
if refund.status == "succeeded":
return
account = get_or_create_account(db, order.user_id)
# Provider-confirmed refunds must claw back the full grant. A negative
# balance records consumed refunded points and blocks further usage.
account.balance = int(account.balance or 0) - int(refund.points_amount or 0)
account.total_granted = max(0, int(account.total_granted or 0) - int(refund.points_amount or 0))
refund.status = "succeeded"
refund.provider_refund_no = provider_refund_no[:128]
refund.failure_reason = ""
refund.completed_at = datetime.utcnow()
order.status = "refunded"
order.refund_status = "succeeded"
order.refunded_at = datetime.utcnow()
_record_transaction(
db,
order=order,
provider=order.provider,
status="succeeded",
amount_cents=refund.amount_cents,
event_type="refund",
transaction_no=provider_refund_no or refund.refund_no,
)
def _nested_payload(value):
if isinstance(value, str):
text = value.strip()
@@ -159,8 +315,11 @@ def charge(payload: dict = Body(...), authorization: str = Header(None), db: Ses
pay_way = str(payload.get("payScene") or "APP").upper()
if pay_way not in PAYMENT_SCENES:
return fail("当前支付场景不受支持", 400)
if pay_way == "LITE" and payment_method != "wechat":
return fail("微信小程序虚拟支付仅支持微信支付", 400)
cents = _price_cents(plan.price)
provider = "wechat_virtual" if pay_way == "LITE" else "huihui"
order = TokenPaymentOrder(
order_no=f"AV{datetime.utcnow().strftime('%Y%m%d%H%M%S')}{uuid.uuid4().hex[:12].upper()}",
user_id=user.id,
@@ -171,10 +330,35 @@ def charge(payload: dict = Body(...), authorization: str = Header(None), db: Ses
points_amount=plan.amount,
price_cents=cents,
status="pending",
provider=provider,
)
db.add(order)
db.commit()
if provider == "wechat_virtual":
if not user.wechat_mp_openid or not user.wechat_mp_session_key:
order.status = "failed"
order.failure_reason = "微信小程序登录态尚未准备好,请重新进入支付页"
db.commit()
return fail(order.failure_reason, 409)
try:
result = build_wechat_virtual_payment_params(
order=order,
plan=plan,
session_key=user.wechat_mp_session_key,
)
except WechatVirtualPaymentError as exc:
order.status = "failed"
order.failure_reason = str(exc)[:500]
db.commit()
return fail(str(exc), 503)
order.provider_order_id = order.order_no
order.provider_order_no = order.order_no
order.provider_status = "CREATED"
order.pay_message = json.dumps(result, ensure_ascii=False, separators=(",", ":"))
db.commit()
return ok(_payment_payload(order, get_or_create_account(db, user.id)))
try:
callback_url = _callback_url(order.order_no)
except HuihuiPaymentError as exc:
@@ -229,9 +413,267 @@ def payment_status(order_id: str, authorization: str = Header(None), db: Session
).first()
if not order:
return fail("支付订单不存在", 404)
if order.provider == "wechat_virtual" and order.status == "pending" and user.wechat_mp_openid:
try:
provider_data = query_wechat_virtual_order(
openid=user.wechat_mp_openid,
order_no=order.order_no,
)
provider_order = provider_data.get("order") or {}
provider_status = int(provider_order.get("status", 0) or 0)
paid_cents = int(provider_order.get("paid_fee") or provider_order.get("order_fee") or 0)
order.provider_status = str(provider_status)
if provider_status in {2, 3, 4} and paid_cents == order.price_cents:
_settle_paid_order(
db,
order,
provider_status=f"XPAY_{provider_status}",
transaction_no=str(
provider_order.get("wxpay_order_id")
or provider_order.get("channel_order_id")
or order.order_no
),
)
elif provider_status == 6:
order.status = "failed"
order.failure_reason = "微信虚拟支付订单已关闭"
db.commit()
db.refresh(order)
except WechatVirtualPaymentError:
# 回调仍是首选确认路径;短暂查询失败不覆盖订单状态。
pass
return ok(_payment_payload(order, get_or_create_account(db, user.id)))
@router.post("/token/wechat/session")
def bind_wechat_session(
payload: dict = Body(...),
authorization: str = Header(None),
db: Session = Depends(get_db),
):
user = _require_user(authorization, db)
code = str(payload.get("code") or "").strip()
if not code or len(code) > 256:
return fail("微信登录凭证无效", 400)
try:
session = exchange_wechat_code(code)
except WechatVirtualPaymentError as exc:
return fail(str(exc), 502)
conflict = db.query(User).filter(
User.wechat_mp_openid == session["openid"],
User.id != user.id,
).first()
if conflict:
return fail("该微信账号已绑定其他会会账号", 409)
user.wechat_mp_openid = session["openid"]
user.wechat_mp_session_key = session["session_key"]
db.commit()
return ok({"ready": True})
@router.get("/token/orders")
def list_user_orders(
page: int = Query(1, ge=1),
page_size: int = Query(20, ge=1, le=100),
authorization: str = Header(None),
db: Session = Depends(get_db),
):
user = _require_user(authorization, db)
query = db.query(TokenPaymentOrder).filter(TokenPaymentOrder.user_id == user.id)
total = query.count()
orders = query.order_by(TokenPaymentOrder.created_at.desc()).offset((page - 1) * page_size).limit(page_size).all()
invoice_by_order = {
item.order_no: item.to_dict()
for item in db.query(InvoiceApplication).filter(
InvoiceApplication.order_no.in_([order.order_no for order in orders])
).all()
} if orders else {}
return ok({
"total": total,
"page": page,
"pageSize": page_size,
"items": [
{**_payment_payload(order, get_or_create_account(db, user.id)), "invoice": invoice_by_order.get(order.order_no)}
for order in orders
],
})
@router.post("/token/orders/{order_no}/invoice")
def apply_invoice(
order_no: str,
payload: dict = Body(...),
authorization: str = Header(None),
db: Session = Depends(get_db),
):
user = _require_user(authorization, db)
order = db.query(TokenPaymentOrder).filter(
TokenPaymentOrder.order_no == order_no,
TokenPaymentOrder.user_id == user.id,
).first()
if not order:
return fail("订单不存在", 404)
if order.status != "paid" or order.refund_status not in {"", "none"}:
return fail("只有已支付且未退款的订单可以申请发票", 409)
title = str(payload.get("title") or "").strip()
invoice_type = str(payload.get("invoiceType") or "personal").strip().lower()
tax_number = str(payload.get("taxNumber") or "").strip().upper()
email = str(payload.get("email") or "").strip()
if not title or len(title) > 120:
return fail("请填写正确的发票抬头", 400)
if invoice_type not in {"personal", "company"}:
return fail("发票类型不正确", 400)
if invoice_type == "company" and (len(tax_number) < 15 or len(tax_number) > 20):
return fail("请填写正确的企业税号", 400)
if email and ("@" not in email or len(email) > 160):
return fail("请填写正确的接收邮箱", 400)
invoice = db.query(InvoiceApplication).filter(InvoiceApplication.order_no == order_no).first()
if invoice and invoice.status not in {"rejected", "cancelled"}:
return fail("该订单已申请发票", 409)
if invoice is None:
invoice = InvoiceApplication(order_no=order_no, user_id=user.id, amount_cents=order.price_cents)
db.add(invoice)
invoice.title = title
invoice.invoice_type = invoice_type
invoice.tax_number = tax_number if invoice_type == "company" else ""
invoice.email = email
invoice.status = "pending"
invoice.remark = ""
db.commit()
db.refresh(invoice)
return ok(invoice.to_dict())
@router.post("/token/admin/orders/{order_no}/refund")
def admin_request_refund(
order_no: str,
payload: dict = Body(...),
finance_key: str = Header(None, alias="X-Avatar-Finance-Key"),
db: Session = Depends(get_db),
):
_require_finance_admin(finance_key)
order = db.query(TokenPaymentOrder).filter(TokenPaymentOrder.order_no == order_no).first()
if not order:
return fail("订单不存在", 404)
if order.status != "paid" or order.refund_status not in {"", "none", "failed"}:
return fail("该订单当前不可退款", 409)
account = get_or_create_account(db, order.user_id)
if int(account.balance or 0) < int(order.points_amount or 0):
return fail("该订单发放的积分已使用,不能执行全额退款", 409)
invoice = db.query(InvoiceApplication).filter(InvoiceApplication.order_no == order.order_no).first()
if invoice and invoice.status == "issued":
return fail("该订单发票已开具,请先完成红冲再退款", 409)
reason = str(payload.get("reason") or "后台退款").strip()
if not reason or len(reason) > 200:
return fail("请填写 200 字以内的退款原因", 400)
refund = PaymentRefund(
refund_no=f"RF{datetime.utcnow().strftime('%Y%m%d%H%M%S')}{uuid.uuid4().hex[:10].upper()}",
order_no=order.order_no,
amount_cents=order.price_cents,
points_amount=order.points_amount,
reason=reason,
status="processing",
requested_by=str(payload.get("operator") or "admin")[:80],
)
claimed = db.query(TokenPaymentOrder).filter(
TokenPaymentOrder.id == order.id,
TokenPaymentOrder.status == "paid",
TokenPaymentOrder.refund_status.in_(["", "none", "failed"]),
).update({TokenPaymentOrder.refund_status: "processing"}, synchronize_session=False)
if not claimed:
db.rollback()
return fail("该订单已有退款任务正在处理", 409)
db.add(refund)
if invoice and invoice.status == "pending":
invoice.status = "cancelled"
invoice.remark = "订单已申请退款,发票申请自动取消"
db.commit()
user = db.query(User).filter(User.id == order.user_id).first()
try:
if order.provider == "wechat_virtual":
if not user or not user.wechat_mp_openid:
raise WechatVirtualPaymentError("订单缺少微信 OpenID,无法退款")
provider_result = request_wechat_virtual_refund(
openid=user.wechat_mp_openid,
order_no=order.order_no,
refund_no=refund.refund_no,
amount_cents=refund.amount_cents,
)
else:
provider_result = _payment_client().request_refund(
huihui_token=user.huihui_token if user else "",
huihui_user_id=user.huihui_user_id if user else "",
order_no=order.order_no,
refund_no=refund.refund_no,
amount=f"{refund.amount_cents / 100:.2f}",
reason=reason,
)
except (WechatVirtualPaymentError, HuihuiPaymentError) as exc:
_complete_refund(db, order, refund, failure_reason=str(exc))
db.commit()
return fail(str(exc), 502)
provider_status = str(
provider_result.get("status")
or provider_result.get("refundStatus")
or provider_result.get("result")
or "PROCESSING"
).upper()
provider_refund_no = str(
provider_result.get("refundNo")
or provider_result.get("refundId")
or provider_result.get("wx_refund_id")
or ""
)
refund.provider_refund_no = provider_refund_no[:128]
if provider_status in {"SUCCESS", "SUCCEEDED", "REFUNDED", "COMPLETED"}:
_complete_refund(db, order, refund, provider_refund_no=provider_refund_no)
db.commit()
db.refresh(refund)
return ok(refund.to_dict())
@router.post("/token/admin/refunds/{refund_no}/confirm")
def admin_confirm_refund(
refund_no: str,
payload: dict = Body(...),
finance_key: str = Header(None, alias="X-Avatar-Finance-Key"),
db: Session = Depends(get_db),
):
"""Record a provider-console reconciliation result for asynchronous refunds."""
_require_finance_admin(finance_key)
refund = db.query(PaymentRefund).filter(PaymentRefund.refund_no == refund_no).first()
if not refund:
return fail("退款单不存在", 404)
order = db.query(TokenPaymentOrder).filter(TokenPaymentOrder.order_no == refund.order_no).first()
if not order:
return fail("原支付订单不存在", 404)
status = str(payload.get("status") or "").lower()
if status == "succeeded":
_complete_refund(
db,
order,
refund,
provider_refund_no=str(payload.get("providerRefundNo") or refund.provider_refund_no or ""),
)
elif status == "failed":
_complete_refund(
db,
order,
refund,
failure_reason=str(payload.get("failureReason") or "供应商退款失败"),
)
else:
return fail("退款确认状态只能是 succeeded 或 failed", 400)
db.commit()
db.refresh(refund)
return ok(refund.to_dict())
@router.post("/token/payment/callback/{order_no}/{callback_signature}")
async def payment_callback(
order_no: str,
@@ -271,6 +713,8 @@ async def payment_callback(
return fail("支付订单不存在", 404)
if order.status == "paid":
return ok({"received": True, "duplicate": True})
if order.status == "refunded":
return ok({"received": True, "duplicate": True, "refunded": True})
provider_status = str(_find_value(
payload, "status", "payStatus", "tradeStatus", "paymentStatus"
@@ -282,6 +726,14 @@ async def payment_callback(
order.failure_reason = str(
_find_value(payload, "message", "errorMsg", "failReason") or "支付失败"
)[:500]
_record_transaction(
db,
order=order,
provider="huihui",
status="failed",
amount_cents=order.price_cents,
transaction_no=str(_find_value(payload, "transactionId", "tradeNo") or ""),
)
db.commit()
return ok({"received": True, "paid": False})
@@ -291,32 +743,148 @@ async def payment_callback(
db.commit()
return fail("支付金额不匹配", 422)
updated = db.query(TokenPaymentOrder).filter(
TokenPaymentOrder.id == order.id,
TokenPaymentOrder.status != "paid",
).update({
TokenPaymentOrder.status: "paid",
TokenPaymentOrder.provider_status: provider_status,
TokenPaymentOrder.paid_at: datetime.utcnow(),
TokenPaymentOrder.failure_reason: "",
}, synchronize_session=False)
if updated:
account = db.query(TokenAccount).filter(TokenAccount.user_id == order.user_id).first()
if account is None:
account = TokenAccount(
user_id=order.user_id,
balance=DEFAULT_TOKEN_GRANT,
total_granted=DEFAULT_TOKEN_GRANT,
total_consumed=0,
)
db.add(account)
db.flush()
account.balance = int(account.balance or 0) + order.points_amount
account.total_granted = int(account.total_granted or 0) + order.points_amount
_settle_paid_order(
db,
order,
provider_status=provider_status,
transaction_no=str(_find_value(payload, "transactionId", "tradeNo", "paymentNo") or ""),
)
db.commit()
return ok({"received": True, "paid": True})
def _wechat_notify_response(request: Request, *, success: bool, message: str = ""):
code = 0 if success else 1
text = "success" if success else (message or "fail")[:200].replace("]]>", "")
if "xml" in (request.headers.get("content-type") or "").lower():
return Response(
content=f"<xml><ErrCode>{code}</ErrCode><ErrMsg><![CDATA[{text}]]></ErrMsg></xml>",
media_type="application/xml",
)
return {"ErrCode": code, "ErrMsg": text}
@router.get("/token/payment/wechat/virtual/notify")
def validate_wechat_virtual_notify(
signature: str = Query(""),
timestamp: str = Query(""),
nonce: str = Query(""),
echostr: str = Query(""),
):
if not verify_wechat_callback_signature(signature, timestamp, nonce):
raise HTTPException(status_code=403, detail="invalid signature")
return Response(content=echostr or "ok", media_type="text/plain")
@router.post("/token/payment/wechat/virtual/notify")
async def wechat_virtual_notify(
request: Request,
signature: str = Query(""),
timestamp: str = Query(""),
nonce: str = Query(""),
db: Session = Depends(get_db),
):
if not verify_wechat_callback_signature(signature, timestamp, nonce):
return _wechat_notify_response(request, success=False, message="invalid signature")
try:
payload = _nested_payload(parse_wechat_callback_body(await request.body()))
except WechatVirtualPaymentError as exc:
return _wechat_notify_response(request, success=False, message=str(exc))
event = str(wechat_callback_value(payload, "Event") or "").lower()
if event in WECHAT_PAYMENT_EVENTS:
order_no = str(wechat_callback_value(payload, "OutTradeNo") or "").strip()
order = db.query(TokenPaymentOrder).filter(TokenPaymentOrder.order_no == order_no).first()
if not order or order.provider != "wechat_virtual":
return _wechat_notify_response(request, success=False, message="order not found")
user = db.query(User).filter(User.id == order.user_id).first()
openid = str(wechat_callback_value(payload, "OpenId") or "").strip()
if not user or not openid or openid != user.wechat_mp_openid:
return _wechat_notify_response(request, success=False, message="openid mismatch")
try:
callback_env = int(wechat_callback_value(payload, "Env"))
actual_price = int(wechat_callback_value(payload, "GoodsInfo", "ActualPrice"))
except (TypeError, ValueError):
return _wechat_notify_response(request, success=False, message="invalid payment amount")
plan = db.query(TokenPlan).filter(TokenPlan.id == order.plan_id).first()
product_id = str(wechat_callback_value(payload, "GoodsInfo", "ProductId") or "")
try:
expected_product_id = product_id_for_plan(plan) if plan else ""
except WechatVirtualPaymentError:
expected_product_id = ""
if (
callback_env != wechat_virtual_env()
or actual_price != order.price_cents
or not expected_product_id
or product_id != expected_product_id
):
return _wechat_notify_response(request, success=False, message="payment verification failed")
transaction_no = str(
wechat_callback_value(payload, "WeChatPayInfo", "TransactionId")
or wechat_callback_value(payload, "WeChatPayInfo", "MchOrderNo")
or order_no
)
_settle_paid_order(
db,
order,
provider_status=event,
transaction_no=transaction_no,
raw_summary=_safe_event_summary(payload),
)
db.commit()
return _wechat_notify_response(request, success=True)
if event in WECHAT_REFUND_EVENTS:
order_no = str(wechat_callback_value(payload, "MchOrderId") or "").strip()
refund_no = str(wechat_callback_value(payload, "MchRefundId") or "").strip()
order = db.query(TokenPaymentOrder).filter(TokenPaymentOrder.order_no == order_no).first()
if not order or order.provider != "wechat_virtual":
return _wechat_notify_response(request, success=False, message="order not found")
if order.status == "refunded" or order.refund_status == "succeeded":
return _wechat_notify_response(request, success=True)
try:
refund_cents = int(wechat_callback_value(payload, "RefundFee") or 0)
result_code_value = wechat_callback_value(payload, "RetCode")
if result_code_value in (None, ""):
raise ValueError("missing RetCode")
result_code = int(result_code_value)
except (TypeError, ValueError):
return _wechat_notify_response(request, success=False, message="invalid refund")
refund = db.query(PaymentRefund).filter(PaymentRefund.refund_no == refund_no).first()
if refund is None:
refund = PaymentRefund(
refund_no=refund_no or f"WR{uuid.uuid4().hex[:20].upper()}",
order_no=order.order_no,
amount_cents=refund_cents,
points_amount=order.points_amount,
reason="微信侧退款",
status="processing",
requested_by="wechat",
)
db.add(refund)
if refund_cents != refund.amount_cents:
return _wechat_notify_response(request, success=False, message="refund amount mismatch")
if result_code == 0:
_complete_refund(
db,
order,
refund,
provider_refund_no=str(wechat_callback_value(payload, "WxRefundId") or refund_no),
)
else:
_complete_refund(
db,
order,
refund,
failure_reason=str(wechat_callback_value(payload, "RetMsg") or "微信退款失败"),
)
db.commit()
return _wechat_notify_response(request, success=True)
# Irrelevant official-account events should not be retried as payment failures.
return _wechat_notify_response(request, success=True)
@router.get("/token/usage")
def usage(authorization: str = Header(None), db: Session = Depends(get_db)):
user = _require_user(authorization, db)
@@ -1,6 +1,7 @@
"""Signed client for Huihui's production payment-v3 service."""
import hashlib
import os
import random
import string
from datetime import datetime, timedelta, timezone
@@ -122,3 +123,59 @@ class HuihuiPaymentClient:
if not isinstance(data, dict):
raise HuihuiPaymentError("会会支付未返回订单信息")
return data
def request_refund(
self,
*,
huihui_token: str,
huihui_user_id: str,
order_no: str,
refund_no: str,
amount: str,
reason: str,
) -> dict[str, Any]:
"""Submit a full refund to payment-v3.
The refund path remains configurable because private Huihui deployments
may expose the same contract below a different gateway route.
"""
if not self.configured:
raise HuihuiPaymentError("会会支付服务未配置")
if not huihui_token or not huihui_user_id:
raise HuihuiPaymentError("当前会会登录凭证无法发起退款")
path = os.getenv("HUIHUI_PAYMENT_REFUND_PATH", "/payment/refund").strip()
if not path.startswith("/"):
path = f"/{path}"
if ".." in path:
raise HuihuiPaymentError("会会退款接口路径配置不正确")
body = {
"appId": self.app_id,
"masterOrderNo": order_no,
"refundOrderNo": refund_no,
"refundAmt": float(amount),
"refundReason": reason or "后台退款",
}
headers = {
"Authorization": f"Bearer {huihui_token}",
"appId": self.app_id,
"windowAppId": self.app_id,
}
try:
response = httpx.post(
f"{self.base_url}{path}",
headers=headers,
params=self._signed_params(huihui_user_id),
json=body,
timeout=self.timeout,
follow_redirects=True,
)
except httpx.HTTPError as exc:
raise HuihuiPaymentError("会会退款连接失败,请稍后重试") from exc
payload = self._json(response)
code = payload.get("code")
if response.status_code >= 400 or code not in (0, 200, "0", "200"):
raise HuihuiPaymentError(payload.get("message") or "会会退款申请失败")
data = payload.get("data") or {}
return data if isinstance(data, dict) else {"result": data}
@@ -0,0 +1,245 @@
"""WeChat mini-program virtual-payment signing and server API adapter.
The AppKey and session_key never leave the backend. The JSON string returned as
``signData`` is exactly the string used for both HMAC signatures.
"""
from __future__ import annotations
import hashlib
import hmac
import json
import os
import time
import xml.etree.ElementTree as ET
from typing import Any
import httpx
REQUEST_VIRTUAL_PAYMENT_URI = "requestVirtualPayment"
PAYMENT_EVENTS = {"xpay_goods_deliver_notify"}
REFUND_EVENTS = {"xpay_refund_notify"}
class WechatVirtualPaymentError(RuntimeError):
pass
def json_compact(payload: dict[str, Any]) -> str:
return json.dumps(payload, ensure_ascii=False, separators=(",", ":"))
def hmac_sha256_hex(key: str, message: str) -> str:
return hmac.new(key.encode("utf-8"), message.encode("utf-8"), hashlib.sha256).hexdigest()
def virtual_env() -> int:
value = os.getenv("WECHAT_VIRTUAL_ENV", "sandbox").strip().lower()
return 0 if value in {"0", "prod", "production", "live", "online"} else 1
def _app_key(env: int) -> str:
name = "WECHAT_VIRTUAL_APP_KEY" if env == 0 else "WECHAT_VIRTUAL_SANDBOX_APP_KEY"
return os.getenv(name, "").strip()
def _offer_id() -> str:
return os.getenv("WECHAT_VIRTUAL_OFFER_ID", "").strip()
def product_id_for_plan(plan) -> str:
configured = str(getattr(plan, "virtual_product_id", "") or "").strip()
if not configured:
configured = os.getenv(f"WECHAT_VIRTUAL_PRODUCT_{plan.id}", "").strip()
if not configured:
raise WechatVirtualPaymentError(f"套餐 {plan.id} 尚未配置微信虚拟支付商品 ID")
if len(configured) > 64 or not all(ch.isalnum() or ch in "_-" for ch in configured):
raise WechatVirtualPaymentError("微信虚拟支付商品 ID 格式不正确")
return configured
def build_payment_params(*, order, plan, session_key: str) -> dict[str, Any]:
env = virtual_env()
offer_id = _offer_id()
app_key = _app_key(env)
if not offer_id or not app_key or not session_key:
raise WechatVirtualPaymentError("微信小程序虚拟支付配置不完整")
sign_data = json_compact({
"offerId": offer_id,
"buyQuantity": 1,
"env": env,
"currencyType": "CNY",
"productId": product_id_for_plan(plan),
"goodsPrice": int(order.price_cents),
"outTradeNo": order.order_no,
"attach": json_compact({"orderNo": order.order_no, "planId": order.plan_id}),
})
return {
"provider": "wechat_virtual",
"payment_channel": "virtual",
"payment_method": "wechat",
"mode": "short_series_goods",
"signData": sign_data,
"paySig": hmac_sha256_hex(app_key, f"{REQUEST_VIRTUAL_PAYMENT_URI}&{sign_data}"),
"signature": hmac_sha256_hex(session_key, sign_data),
"env": env,
"offerId": offer_id,
"outTradeNo": order.order_no,
}
def exchange_code(code: str) -> dict[str, str]:
app_id = os.getenv("WECHAT_MP_APP_ID", "").strip()
app_secret = os.getenv("WECHAT_MP_APP_SECRET", "").strip()
if not app_id or not app_secret:
raise WechatVirtualPaymentError("微信小程序登录配置不完整")
try:
response = httpx.get(
"https://api.weixin.qq.com/sns/jscode2session",
params={
"appid": app_id,
"secret": app_secret,
"js_code": code,
"grant_type": "authorization_code",
},
timeout=15,
)
data = response.json()
except (httpx.HTTPError, ValueError) as exc:
raise WechatVirtualPaymentError("微信登录态交换失败,请稍后重试") from exc
if response.status_code >= 400 or data.get("errcode"):
raise WechatVirtualPaymentError(data.get("errmsg") or "微信登录态交换失败")
openid = str(data.get("openid") or "").strip()
session_key = str(data.get("session_key") or "").strip()
if not openid or not session_key:
raise WechatVirtualPaymentError("微信未返回完整登录态")
return {"openid": openid, "session_key": session_key}
def verify_callback_signature(signature: str, timestamp: str, nonce: str) -> bool:
token = os.getenv("WECHAT_VIRTUAL_CALLBACK_TOKEN", "").strip()
if not token or not signature or not timestamp or not nonce:
return False
source = "".join(sorted([token, timestamp, nonce]))
expected = hashlib.sha1(source.encode("utf-8")).hexdigest()
return hmac.compare_digest(signature, expected)
def _xml_value(element: ET.Element) -> Any:
children = list(element)
if not children:
return element.text or ""
return {child.tag: _xml_value(child) for child in children}
def parse_callback_body(body: bytes) -> dict[str, Any]:
text = body.decode("utf-8", errors="replace").strip()
if not text:
return {}
try:
payload = json.loads(text)
if isinstance(payload, dict):
return payload
except json.JSONDecodeError:
pass
try:
parsed = _xml_value(ET.fromstring(text))
except ET.ParseError as exc:
raise WechatVirtualPaymentError("微信虚拟支付回调格式不正确") from exc
return parsed if isinstance(parsed, dict) else {}
def case_get(payload: Any, key: str) -> Any:
if not isinstance(payload, dict):
return None
lowered = key.lower()
for current, value in payload.items():
if str(current).lower() == lowered:
return value
return None
def callback_value(payload: dict[str, Any], *path: str) -> Any:
current: Any = payload
for key in path:
current = case_get(current, key)
if current is None:
break
return current
_access_token_cache: tuple[str, float] = ("", 0)
def _access_token() -> str:
global _access_token_cache
token, expires_at = _access_token_cache
if token and expires_at > time.monotonic() + 60:
return token
app_id = os.getenv("WECHAT_MP_APP_ID", "").strip()
app_secret = os.getenv("WECHAT_MP_APP_SECRET", "").strip()
if not app_id or not app_secret:
raise WechatVirtualPaymentError("微信小程序服务端配置不完整")
try:
response = httpx.get(
"https://api.weixin.qq.com/cgi-bin/token",
params={"grant_type": "client_credential", "appid": app_id, "secret": app_secret},
timeout=15,
)
data = response.json()
except (httpx.HTTPError, ValueError) as exc:
raise WechatVirtualPaymentError("微信 access_token 获取失败") from exc
if response.status_code >= 400 or data.get("errcode"):
raise WechatVirtualPaymentError(data.get("errmsg") or "微信 access_token 获取失败")
token = str(data.get("access_token") or "")
if not token:
raise WechatVirtualPaymentError("微信未返回 access_token")
_access_token_cache = (token, time.monotonic() + int(data.get("expires_in") or 7200))
return token
def call_xpay(uri: str, payload: dict[str, Any]) -> dict[str, Any]:
env = int(payload.get("env", virtual_env()))
app_key = _app_key(env)
if not app_key:
raise WechatVirtualPaymentError("微信虚拟支付 AppKey 未配置")
body = json_compact(payload)
pay_sig = hmac_sha256_hex(app_key, f"{uri}&{body}")
try:
response = httpx.post(
f"https://api.weixin.qq.com{uri}",
params={"access_token": _access_token(), "pay_sig": pay_sig},
content=body.encode("utf-8"),
headers={"Content-Type": "application/json"},
timeout=20,
)
data = response.json()
except (httpx.HTTPError, ValueError) as exc:
raise WechatVirtualPaymentError("微信虚拟支付服务暂时不可用") from exc
if response.status_code >= 400 or data.get("errcode") not in (None, 0):
raise WechatVirtualPaymentError(data.get("errmsg") or "微信虚拟支付请求失败")
return data
def request_refund(*, openid: str, order_no: str, refund_no: str, amount_cents: int, reason: int = 3) -> dict[str, Any]:
return call_xpay("/xpay/refund_order", {
"openid": openid,
"order_id": order_no,
"refund_order_id": refund_no,
"left_fee": amount_cents,
"refund_fee": amount_cents,
"biz_meta": json_compact({"orderNo": order_no}),
"refund_reason": int(reason),
"req_from": 1,
"env": virtual_env(),
})
def query_order(*, openid: str, order_no: str) -> dict[str, Any]:
return call_xpay("/xpay/query_order", {
"openid": openid,
"order_id": order_no,
"env": virtual_env(),
})
@@ -6,6 +6,9 @@ from models import (
Authorization,
Avatar,
ChatAttachment,
InvoiceApplication,
PaymentRefund,
PaymentTransaction,
TakeoverCursor,
TakeoverMessage,
TakeoverReplyTask,
@@ -115,6 +118,21 @@ def authorization_context():
synchronize_session=False
)
user_ids = [owner.id, other.id]
order_numbers = [
row[0] for row in db.query(TokenPaymentOrder.order_no).filter(
TokenPaymentOrder.user_id.in_(user_ids)
).all()
]
if order_numbers:
db.query(InvoiceApplication).filter(InvoiceApplication.order_no.in_(order_numbers)).delete(
synchronize_session=False
)
db.query(PaymentRefund).filter(PaymentRefund.order_no.in_(order_numbers)).delete(
synchronize_session=False
)
db.query(PaymentTransaction).filter(PaymentTransaction.order_no.in_(order_numbers)).delete(
synchronize_session=False
)
db.query(TokenPaymentOrder).filter(TokenPaymentOrder.user_id.in_(user_ids)).delete(
synchronize_session=False
)
@@ -1,3 +1,4 @@
import os
from unittest.mock import Mock, patch
from services.huihui_payment import HuihuiPaymentClient
@@ -48,3 +49,35 @@ def test_create_payment_uses_huihui_payment_v3_contract():
assert body["payWay"] == "APP"
assert body["masterOrderAmt"] == "10.00"
assert body["payAmt"] == 10.0
def test_request_refund_uses_configured_huihui_endpoint_without_exposing_secret():
client = HuihuiPaymentClient({
"HUIHUI_PAYMENT_BASE_URL": "https://open.example/api/payment-v3",
"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": {"status": "PROCESSING", "refundNo": "provider-rf"}}
with patch.dict(os.environ, {"HUIHUI_PAYMENT_REFUND_PATH": "/payment/refund"}), patch(
"services.huihui_payment.httpx.post", return_value=response
) as post:
result = client.request_refund(
huihui_token="user-token",
huihui_user_id="user-id",
order_no="AV1",
refund_no="RF1",
amount="10.00",
reason="用户申请",
)
assert result["refundNo"] == "provider-rf"
assert post.call_args.args[0] == "https://open.example/api/payment-v3/payment/refund"
assert post.call_args.kwargs["json"] == {
"appId": "app-id",
"masterOrderNo": "AV1",
"refundOrderNo": "RF1",
"refundAmt": 10.0,
"refundReason": "用户申请",
}
assert "accessSecret" not in post.call_args.kwargs["params"]
@@ -0,0 +1,104 @@
import hashlib
import json
import os
from unittest.mock import patch
from fastapi.testclient import TestClient
from database import SessionLocal
from main import app, seed
from models import InvoiceApplication, PaymentRefund, TokenAccount, TokenPaymentOrder, TokenPlan, User
from services.token_billing import DEFAULT_TOKEN_GRANT
client = TestClient(app)
def _signature(token, timestamp, nonce):
return hashlib.sha1("".join(sorted([token, timestamp, nonce])).encode()).hexdigest()
def test_virtual_payment_callback_and_refund_are_idempotent(authorization_context):
seed()
context = authorization_context
db = SessionLocal()
try:
user = db.query(User).filter(User.id == context["owner"].id).one()
user.wechat_mp_openid = "openid-flow"
user.wechat_mp_session_key = "session-flow"
plan = db.query(TokenPlan).filter(TokenPlan.id == "1").one()
plan.virtual_product_id = "points_plan_1"
db.commit()
finally:
db.close()
env = {
"WECHAT_VIRTUAL_ENV": "sandbox",
"WECHAT_VIRTUAL_SANDBOX_APP_KEY": "sandbox-key",
"WECHAT_VIRTUAL_OFFER_ID": "offer-1",
"WECHAT_VIRTUAL_CALLBACK_TOKEN": "callback-token",
"AVATAR_FINANCE_ADMIN_SECRET": "finance-admin-secret-123",
}
with patch.dict(os.environ, env):
created = client.post(
"/api/token/charge",
headers=context["owner_headers"],
json={"planId": "1", "paymentMethod": "wechat", "payScene": "LITE"},
).json()["data"]
assert created["provider"] == "wechat_virtual"
params = json.loads(created["payMessage"])
assert params["mode"] == "short_series_goods"
assert "session-flow" not in created["payMessage"]
notify = {
"Event": "xpay_goods_deliver_notify",
"OutTradeNo": created["orderNo"],
"OpenId": "openid-flow",
"Env": 1,
"GoodsInfo": json.dumps({"ProductId": "points_plan_1", "ActualPrice": 1000}),
"WeChatPayInfo": json.dumps({"TransactionId": "wx-transaction-1"}),
}
query = {"timestamp": "100", "nonce": "nonce", "signature": _signature("callback-token", "100", "nonce")}
assert client.post("/api/token/payment/wechat/virtual/notify", params=query, json=notify).json()["ErrCode"] == 0
assert client.post("/api/token/payment/wechat/virtual/notify", params=query, json=notify).json()["ErrCode"] == 0
invoice = client.post(
f"/api/token/orders/{created['orderNo']}/invoice",
headers=context["owner_headers"],
json={"title": "测试用户", "invoiceType": "personal", "email": "test@example.com"},
).json()["data"]
assert invoice["status"] == "pending"
with patch("routers.tokens.request_wechat_virtual_refund", return_value={"errcode": 0}):
refund_response = client.post(
f"/api/token/admin/orders/{created['orderNo']}/refund",
headers={"X-Avatar-Finance-Key": "finance-admin-secret-123"},
json={"reason": "用户申请退款", "operator": "tester"},
)
assert refund_response.json()["data"]["status"] == "processing"
refund_no = refund_response.json()["data"]["refundNo"]
refund_notify = {
"Event": "xpay_refund_notify",
"MchOrderId": created["orderNo"],
"MchRefundId": refund_no,
"WxRefundId": "wx-refund-1",
"RefundFee": 1000,
"RetCode": 0,
}
assert client.post("/api/token/payment/wechat/virtual/notify", params=query, json=refund_notify).json()["ErrCode"] == 0
assert client.post("/api/token/payment/wechat/virtual/notify", params=query, json=refund_notify).json()["ErrCode"] == 0
assert client.post("/api/token/payment/wechat/virtual/notify", params=query, json=notify).json()["ErrCode"] == 0
db = SessionLocal()
try:
order = db.query(TokenPaymentOrder).filter(TokenPaymentOrder.order_no == created["orderNo"]).one()
account = db.query(TokenAccount).filter(TokenAccount.user_id == context["owner"].id).one()
refund = db.query(PaymentRefund).filter(PaymentRefund.refund_no == refund_no).one()
invoice = db.query(InvoiceApplication).filter(InvoiceApplication.order_no == created["orderNo"]).one()
assert order.status == "refunded"
assert refund.status == "succeeded"
assert invoice.status == "cancelled"
assert account.balance == DEFAULT_TOKEN_GRANT
finally:
db.close()
@@ -0,0 +1,72 @@
import hashlib
import hmac
import json
import os
from types import SimpleNamespace
from unittest.mock import Mock, patch
from services import wechat_virtual_payment as virtual
def test_build_payment_params_signs_the_exact_compact_payload():
order = SimpleNamespace(order_no="AV202609080001", plan_id="plan-1", price_cents=1000)
plan = SimpleNamespace(id="plan-1", virtual_product_id="points_plan_1")
env = {
"WECHAT_VIRTUAL_ENV": "sandbox",
"WECHAT_VIRTUAL_SANDBOX_APP_KEY": "sandbox-key",
"WECHAT_VIRTUAL_OFFER_ID": "offer-1",
}
with patch.dict(os.environ, env, clear=False):
result = virtual.build_payment_params(order=order, plan=plan, session_key="session-key")
sign_data = result["signData"]
assert sign_data == json.dumps({
"offerId": "offer-1",
"buyQuantity": 1,
"env": 1,
"currencyType": "CNY",
"productId": "points_plan_1",
"goodsPrice": 1000,
"outTradeNo": "AV202609080001",
"attach": '{"orderNo":"AV202609080001","planId":"plan-1"}',
}, ensure_ascii=False, separators=(",", ":"))
assert result["paySig"] == hmac.new(
b"sandbox-key", f"requestVirtualPayment&{sign_data}".encode(), hashlib.sha256
).hexdigest()
assert result["signature"] == hmac.new(
b"session-key", sign_data.encode(), hashlib.sha256
).hexdigest()
def test_callback_signature_and_xml_body_are_supported():
with patch.dict(os.environ, {"WECHAT_VIRTUAL_CALLBACK_TOKEN": "callback-token"}):
signature = hashlib.sha1("".join(sorted(["callback-token", "100", "nonce"])).encode()).hexdigest()
assert virtual.verify_callback_signature(signature, "100", "nonce")
payload = virtual.parse_callback_body(
b"<xml><Event>xpay_refund_notify</Event><GoodsInfo><ActualPrice>1000</ActualPrice></GoodsInfo></xml>"
)
assert virtual.callback_value(payload, "event") == "xpay_refund_notify"
assert virtual.callback_value(payload, "goodsinfo", "actualprice") == "1000"
def test_xpay_request_uses_server_access_token_and_pay_signature():
token_response = Mock(status_code=200)
token_response.json.return_value = {"access_token": "server-token", "expires_in": 7200}
pay_response = Mock(status_code=200)
pay_response.json.return_value = {"errcode": 0, "order": {"status": 2}}
virtual._access_token_cache = ("", 0)
env = {
"WECHAT_MP_APP_ID": "wx-app",
"WECHAT_MP_APP_SECRET": "wx-secret",
"WECHAT_VIRTUAL_SANDBOX_APP_KEY": "sandbox-key",
"WECHAT_VIRTUAL_ENV": "sandbox",
}
with patch.dict(os.environ, env), patch.object(virtual.httpx, "get", return_value=token_response), patch.object(
virtual.httpx, "post", return_value=pay_response
) as post:
result = virtual.query_order(openid="openid", order_no="AV1")
assert result["order"]["status"] == 2
body = '{"openid":"openid","order_id":"AV1","env":1}'
expected = hmac.new(b"sandbox-key", f"/xpay/query_order&{body}".encode(), hashlib.sha256).hexdigest()
assert post.call_args.args[0] == "https://api.weixin.qq.com/xpay/query_order"
assert post.call_args.kwargs["params"] == {"access_token": "server-token", "pay_sig": expected}
@@ -45,6 +45,21 @@ HUIHUI_PAYMENT_BASE_URL=https://open.99hui.com/api/payment-v3
HUIHUI_PAYMENT_CALLBACK_BASE_URL=https://digital.99hui.com
HUIHUI_PAYMENT_CALLBACK_SECRET=<至少32位随机密钥>
HUIHUI_PAYMENT_TIMEOUT_SECONDS=30
HUIHUI_PAYMENT_REFUND_PATH=/payment/refund
AVATAR_FINANCE_ADMIN_SECRET=<至少32位随机密钥,与管理后台一致>
# 微信小程序虚拟支付;联调先使用 sandbox
WECHAT_MP_APP_ID=<小程序AppID>
WECHAT_MP_APP_SECRET=<小程序AppSecret>
WECHAT_VIRTUAL_ENV=sandbox
WECHAT_VIRTUAL_SANDBOX_APP_KEY=<沙箱AppKey>
WECHAT_VIRTUAL_APP_KEY=<正式AppKey>
WECHAT_VIRTUAL_OFFER_ID=<offer-id>
WECHAT_VIRTUAL_CALLBACK_TOKEN=<回调校验Token>
WECHAT_VIRTUAL_PRODUCT_1=<10元套餐商品ID>
WECHAT_VIRTUAL_PRODUCT_2=<100元套餐商品ID>
WECHAT_VIRTUAL_PRODUCT_3=<1000元套餐商品ID>
WECHAT_VIRTUAL_PRODUCT_4=<10000元套餐商品ID>
DATABASE_URL=sqlite:////data/avatar.db
UPLOAD_DIR=/data/uploads
@@ -69,7 +84,11 @@ CHAT_ATTACHMENT_CLEANUP_MINUTES=60
`EMBEDDING_API_URL` 同时支持 OpenAI 兼容基础地址(如上面的 `/v1`)和完整的 `/v1/embeddings` 地址,后端会统一请求 `/embeddings`。发布后必须在后端容器内执行一次最小向量探针,确认返回向量数量和维度,而不能只检查 `/api/health`。
积分充值使用会会支付体系的 `payment-v3/payment/pay`,渠道值为 `WECHAT` / `ALIPAY`,端内支付场景为 `APP`,微信内 H5 使用 `JSAPI`。`HUIHUI_PAYMENT_CALLBACK_SECRET` 只用于为每笔订单生成 HMAC 回调签名,不会发送到前端或直接出现在回调地址中。支付回调确认状态成功且金额与套餐价格完全一致后才增加积分,重复回调不会重复到账。
App 与 H5 积分充值使用会会支付体系的 `payment-v3/payment/pay`,渠道值为 `WECHAT` / `ALIPAY`;App 场景为 `APP`,普通浏览器为 `H5`,微信内 H5 为 `JSAPI`。`HUIHUI_PAYMENT_CALLBACK_SECRET` 只用于为每笔订单生成 HMAC 回调签名,不会发送到前端或直接出现在回调地址中。支付回调确认状态成功且金额与套餐价格完全一致后才增加积分,重复回调不会重复到账。
微信小程序使用微信虚拟支付:小程序先通过 `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 均不得下发前端或写日志。
管理后台需要配置相同的 `AVATAR_FINANCE_ADMIN_SECRET` 和 `AVATAR_BACKEND_URL=https://digital.99hui.com`。退款只支持整单原路退款;供应商受理后显示“处理中”,收到渠道成功回调(或经渠道后台核对后人工确认)才将订单置为已退款。已消费掉本订单积分时,后台会拒绝主动退款;若渠道外部退款先发生,积分账户允许形成负数以记录欠额并阻止继续消费。
## 3. 构建与发布
@@ -137,7 +156,9 @@ location /api/ {
8. 重建容器后数据库、头像、知识库文档仍存在,`/api/health` 返回成功。
9. `https://digital.99hui.com/api/health` 可访问,证书域名和有效期正确,HTTP 自动跳转 HTTPS。
10. 微信和支付宝各创建一笔最小套餐订单,未付款时积分不变;支付成功后回调到账一次,重复回调积分不重复增加。
11. 私聊和公开分享各上传 JPG、PNG、WebP 图片并完成追问;上传非图片、超过 8MB 或跨分身附件时必须拒绝。
11. 微信虚拟支付在沙箱环境完成下单、支付回调、查单兜底和退款回调;错误 OpenID、商品、环境或金额均被拒绝。
12. 财务后台能筛选订单、关闭待支付订单、发起整单退款、登记退款对账结果,并处理个人/企业电子发票申请。
13. 私聊和公开分享各上传 JPG、PNG、WebP 图片并完成追问;上传非图片、超过 8MB 或跨分身附件时必须拒绝。
12. 病例图片可以提取可见文字并标记待核对内容,医学影像不作确定诊断;视觉与 OCR 调用分别扣减积分。
13. 检查服务器上传目录不残留聊天原图,数据库过期图片识别记录在清理周期后删除,日志不出现 Base64 或病例正文。
+35 -3
View File
@@ -152,16 +152,35 @@ export interface TokenPaymentOrder {
planId: string
paymentMethod: 'wechat' | 'alipay'
payType: 'WECHAT' | 'ALIPAY'
payWay: 'APP' | 'LITE' | 'JSAPI'
payWay: 'APP' | 'H5' | 'LITE' | 'JSAPI'
pointsAmount: number
price: number
status: 'pending' | 'paid' | 'failed'
status: 'pending' | 'paid' | 'failed' | 'closed' | 'refunded'
provider: 'huihui' | 'wechat_virtual'
providerStatus: string
payMessage: string
failureReason: string
refundStatus: 'none' | 'pending' | 'processing' | 'succeeded' | 'failed'
createdAt: string | null
paidAt: string | null
refundedAt: string | null
balance: number
}
export interface TokenInvoice {
id: string
orderNo: string
title: string
invoiceType: 'personal' | 'company'
taxNumber: string
email: string
amount: number
status: 'pending' | 'issued' | 'rejected' | 'cancelled'
invoiceNo: string
invoiceUrl: string
remark: string
}
// 获取 Token 余额
export const getTokenBalance = () =>
request.get<TokenBalance>('/token/balance')
@@ -174,12 +193,25 @@ export const getRechargePlans = () =>
export const chargeToken = (
planId: string,
paymentMethod: 'wechat' | 'alipay',
payScene: 'APP' | 'LITE' | 'JSAPI'
payScene: 'APP' | 'H5' | 'LITE' | 'JSAPI'
) => request.post<TokenPaymentOrder>('/token/charge', { planId, paymentMethod, payScene })
export const getTokenPaymentStatus = (orderId: string) =>
request.get<TokenPaymentOrder>(`/token/payment/${orderId}`)
export const bindWechatVirtualSession = (code: string) =>
request.post<{ ready: boolean }>('/token/wechat/session', { code })
export const getTokenOrders = (page = 1, pageSize = 20) =>
request.get<{ total: number; page: number; pageSize: number; items: Array<TokenPaymentOrder & { invoice?: TokenInvoice }> }>(
'/token/orders', { params: { page, page_size: pageSize } }
)
export const applyTokenInvoice = (
orderNo: string,
payload: { title: string; invoiceType: 'personal' | 'company'; taxNumber?: string; email?: string }
) => request.post<TokenInvoice>(`/token/orders/${orderNo}/invoice`, payload)
// 按分身和使用场景汇总 Token 消耗
export const getTokenUsage = () =>
request.get<TokenUsageSummary[]>('/token/usage')
+98 -1
View File
@@ -74,6 +74,35 @@
{{ checkoutLabel }}
</button>
</section>
<section v-if="recentOrders.length" class="orders-section">
<h3 class="section-title">充值记录</h3>
<div v-for="order in recentOrders" :key="order.id" class="order-card">
<div>
<strong>{{ order.pointsAmount.toLocaleString() }} 积分</strong>
<p>{{ order.orderNo }} · {{ order.createdAt ? new Date(order.createdAt).toLocaleDateString('zh-CN') : '' }}</p>
</div>
<div class="order-side">
<strong>¥{{ order.price.toFixed(2) }}</strong>
<button v-if="canInvoice(order)" class="text-btn" @click="openInvoice(order)">申请发票</button>
<span v-else class="order-status">{{ orderStatus(order) }}</span>
</div>
</div>
</section>
<div v-if="invoiceOrder" class="modal-mask" @click.self="invoiceOrder = null">
<form class="invoice-modal" @submit.prevent="submitInvoice">
<h3>申请电子发票</h3>
<label>发票类型
<select v-model="invoiceType"><option value="personal">个人</option><option value="company">企业</option></select>
</label>
<label>发票抬头<input v-model.trim="invoiceTitle" maxlength="120" required /></label>
<label v-if="invoiceType === 'company'">企业税号<input v-model.trim="invoiceTaxNumber" minlength="15" maxlength="20" required /></label>
<label>接收邮箱<input v-model.trim="invoiceEmail" type="email" placeholder="选填" /></label>
<p v-if="invoiceError" class="invoice-error">{{ invoiceError }}</p>
<div class="modal-actions"><button type="button" @click="invoiceOrder = null">取消</button><button class="primary" :disabled="invoiceSubmitting">{{ invoiceSubmitting ? '提交中…' : '提交申请' }}</button></div>
</form>
</div>
</div>
</template>
@@ -82,8 +111,10 @@ import { computed, onMounted, onUnmounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import {
chargeToken,
applyTokenInvoice,
getRechargePlans,
getTokenBalance,
getTokenOrders,
getTokenPaymentStatus,
type TokenPaymentOrder
} from '@/api'
@@ -114,6 +145,14 @@ const paymentMethod = ref<'wechat' | 'alipay'>('wechat')
const paymentNotice = ref('')
const paymentNoticeTone = ref<'pending' | 'success' | 'error'>('pending')
const pendingOrderId = ref(sessionStorage.getItem('hh_pending_payment_order') || '')
const recentOrders = ref<Array<TokenPaymentOrder & { invoice?: any }>>([])
const invoiceOrder = ref<(TokenPaymentOrder & { invoice?: any }) | null>(null)
const invoiceType = ref<'personal' | 'company'>('personal')
const invoiceTitle = ref('')
const invoiceTaxNumber = ref('')
const invoiceEmail = ref('')
const invoiceError = ref('')
const invoiceSubmitting = ref(false)
let pollTimer: number | undefined
let pollDeadline = 0
let removeNativeListener: (() => void) | undefined
@@ -133,6 +172,51 @@ const loadData = async () => {
} catch (e) {
console.error('加载套餐失败', e)
}
try {
const result = await getTokenOrders(1, 10)
recentOrders.value = result?.items || []
} catch (e) {
console.error('加载充值记录失败', e)
}
}
const canInvoice = (order: TokenPaymentOrder & { invoice?: any }) =>
order.status === 'paid' && (!order.refundStatus || order.refundStatus === 'none') &&
(!order.invoice || ['rejected', 'cancelled'].includes(order.invoice.status))
const orderStatus = (order: TokenPaymentOrder & { invoice?: any }) => {
if (order.invoice?.status === 'issued') return '发票已开具'
if (order.invoice?.status === 'pending') return '发票处理中'
if (order.invoice?.status === 'rejected') return '发票已驳回'
return ({ pending: '待支付', paid: '已支付', failed: '支付失败', closed: '已关闭', refunded: '已退款' } as Record<string, string>)[order.status] || order.status
}
const openInvoice = (order: TokenPaymentOrder & { invoice?: any }) => {
invoiceOrder.value = order
invoiceType.value = 'personal'
invoiceTitle.value = ''
invoiceTaxNumber.value = ''
invoiceEmail.value = ''
invoiceError.value = ''
}
const submitInvoice = async () => {
if (!invoiceOrder.value || invoiceSubmitting.value) return
invoiceSubmitting.value = true
invoiceError.value = ''
try {
await applyTokenInvoice(invoiceOrder.value.orderNo, {
title: invoiceTitle.value,
invoiceType: invoiceType.value,
taxNumber: invoiceTaxNumber.value,
email: invoiceEmail.value
})
paymentNoticeTone.value = 'success'
paymentNotice.value = '发票申请已提交,请等待财务处理'
invoiceOrder.value = null
await loadData()
} catch (error: any) {
invoiceError.value = error?.message || '发票申请提交失败'
} finally {
invoiceSubmitting.value = false
}
}
// 会会支付订单创建与到账确认
@@ -151,8 +235,9 @@ const checkoutLabel = computed(() => {
})
const payScene = () => {
if (isInUniWebView()) return 'APP' as const
if (paymentMethod.value === 'wechat' && /MicroMessenger/i.test(navigator.userAgent)) return 'JSAPI' as const
return 'APP' as const
return 'H5' as const
}
const parsePayMessage = (message: string) => {
@@ -231,6 +316,7 @@ const pollPayment = async () => {
paymentNoticeTone.value = 'success'
paymentNotice.value = `支付成功,${order.pointsAmount.toLocaleString()} 积分已到账`
clearPendingOrder()
void loadData()
return
}
if (order.status === 'failed') {
@@ -562,6 +648,17 @@ onUnmounted(() => {
padding: 0 20px;
}
.orders-section { padding: 24px 20px 0; }
.order-card { display:flex; align-items:center; justify-content:space-between; padding:14px 16px; margin-bottom:10px; background:#fff; border:1px solid #EDEEF1; border-radius:12px; }
.order-card strong { color:#18191C; font-size:14px; }.order-card p,.order-status { color:#9398AE; font-size:11px; margin:5px 0 0; }
.order-side { text-align:right; }.text-btn { display:block; margin-top:5px; padding:0; border:0; background:none; color:#F97316; font-size:12px; cursor:pointer; }
.modal-mask { position:fixed; inset:0; z-index:20; display:grid; place-items:center; padding:20px; background:rgba(15,23,42,.45); }
.invoice-modal { width:min(100%,420px); padding:22px; border-radius:16px; background:#fff; box-shadow:0 18px 50px rgba(15,23,42,.2); }
.invoice-modal h3 { margin:0 0 18px; }.invoice-modal label { display:grid; gap:7px; margin:12px 0; color:#4B5563; font-size:13px; }
.invoice-modal input,.invoice-modal select { width:100%; height:42px; padding:0 12px; border:1px solid #D9DCE3; border-radius:9px; background:#fff; color:#18191C; font-size:14px; }
.invoice-error { color:#B42318; font-size:12px; }.modal-actions { display:flex; justify-content:flex-end; gap:10px; margin-top:20px; }
.modal-actions button { padding:9px 18px; border:1px solid #D9DCE3; border-radius:9px; background:#fff; }.modal-actions .primary { border-color:#F97316; background:#F97316; color:#fff; }
.checkout-btn {
width: 100%;
padding: 16px;