feat: add avatar payments and finance management
This commit is contained in:
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user