915 lines
34 KiB
Python
915 lines
34 KiB
Python
import hashlib
|
|
import hmac
|
|
import json
|
|
import os
|
|
import uuid
|
|
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, Query, Request, Response
|
|
from sqlalchemy import func
|
|
from sqlalchemy.orm import Session
|
|
|
|
from database import get_db
|
|
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 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", "H5", "LITE", "JSAPI"}
|
|
SUCCESS_STATUSES = {"SUCCESS", "SUCCEEDED", "PAID", "COMPLETED", "TRADE_SUCCESS"}
|
|
FAILED_STATUSES = {"FAIL", "FAILED", "CLOSED", "CANCELLED", "CANCELED", "EXPIRED"}
|
|
|
|
|
|
def _require_user(authorization: str | None, db: Session) -> User:
|
|
if not authorization:
|
|
raise HTTPException(status_code=401, detail="未登录")
|
|
token = authorization.replace("Bearer ", "", 1).replace("bearer ", "", 1).strip()
|
|
user = db.query(User).filter(User.app_token == token).first()
|
|
if not user:
|
|
raise HTTPException(status_code=401, detail="会话无效或已过期")
|
|
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(
|
|
"HUIHUI_PAYMENT_BASE_URL", "https://open.99hui.com/api/payment-v3"
|
|
),
|
|
"HUIHUI_APP_ID": os.getenv("HUIHUI_APP_ID", ""),
|
|
"HUIHUI_ACCESS_ID": os.getenv("HUIHUI_ACCESS_ID", ""),
|
|
"HUIHUI_ACCESS_SECRET": os.getenv("HUIHUI_ACCESS_SECRET", ""),
|
|
"HUIHUI_PAYMENT_TIMEOUT_SECONDS": os.getenv("HUIHUI_PAYMENT_TIMEOUT_SECONDS", "30"),
|
|
})
|
|
|
|
|
|
def _callback_url(order_no: str) -> str:
|
|
base = os.getenv(
|
|
"HUIHUI_PAYMENT_CALLBACK_BASE_URL", "https://digital.99hui.com"
|
|
).rstrip("/")
|
|
secret = os.getenv("HUIHUI_PAYMENT_CALLBACK_SECRET", "").strip()
|
|
if len(secret) < 16:
|
|
raise HuihuiPaymentError("会会支付回调密钥未配置")
|
|
signature = hmac.new(secret.encode(), order_no.encode(), hashlib.sha256).hexdigest()
|
|
return f"{base}/api/token/payment/callback/{order_no}/{signature}"
|
|
|
|
|
|
def _price_cents(price: float) -> int:
|
|
return int(
|
|
(Decimal(str(price)) * Decimal("100")).quantize(
|
|
Decimal("1"), rounding=ROUND_HALF_UP
|
|
)
|
|
)
|
|
|
|
|
|
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()
|
|
if text[:1] in ("{", "["):
|
|
try:
|
|
return _nested_payload(json.loads(text))
|
|
except (TypeError, ValueError):
|
|
return value
|
|
return value
|
|
if isinstance(value, list):
|
|
return [_nested_payload(item) for item in value]
|
|
if isinstance(value, dict):
|
|
return {key: _nested_payload(item) for key, item in value.items()}
|
|
return value
|
|
|
|
|
|
def _find_value(payload, *names):
|
|
expected = {name.lower() for name in names}
|
|
if isinstance(payload, dict):
|
|
for key, value in payload.items():
|
|
if key.lower() in expected and value not in (None, ""):
|
|
return value
|
|
for value in payload.values():
|
|
found = _find_value(value, *names)
|
|
if found not in (None, ""):
|
|
return found
|
|
elif isinstance(payload, list):
|
|
for value in payload:
|
|
found = _find_value(value, *names)
|
|
if found not in (None, ""):
|
|
return found
|
|
return None
|
|
|
|
|
|
def _callback_amount_cents(payload) -> int | None:
|
|
value = _find_value(
|
|
payload,
|
|
"actualAmt",
|
|
"payAmt",
|
|
"masterOrderAmt",
|
|
"orderAmt",
|
|
"amount",
|
|
"totalAmount",
|
|
)
|
|
if value in (None, ""):
|
|
return None
|
|
try:
|
|
return int(
|
|
(Decimal(str(value)) * Decimal("100")).quantize(
|
|
Decimal("1"), rounding=ROUND_HALF_UP
|
|
)
|
|
)
|
|
except (InvalidOperation, TypeError, ValueError):
|
|
return None
|
|
|
|
|
|
@router.get("/token/balance")
|
|
def balance(authorization: str = Header(None), db: Session = Depends(get_db)):
|
|
user = _require_user(authorization, db)
|
|
acc = get_or_create_account(db, user.id)
|
|
return ok({
|
|
"balance": acc.balance,
|
|
"totalGranted": acc.total_granted,
|
|
"totalConsumed": acc.total_consumed,
|
|
})
|
|
|
|
|
|
@router.get("/token/plans")
|
|
def plans(authorization: str = Header(None), db: Session = Depends(get_db)):
|
|
_require_user(authorization, db)
|
|
items = db.query(TokenPlan).order_by(TokenPlan.price.asc()).all()
|
|
return ok([p.to_dict() for p in items])
|
|
|
|
|
|
# 积分只会在会会支付回调确认成功后到账。
|
|
@router.post("/token/charge")
|
|
def charge(payload: dict = Body(...), authorization: str = Header(None), db: Session = Depends(get_db)):
|
|
user = _require_user(authorization, db)
|
|
plan = db.query(TokenPlan).filter(TokenPlan.id == payload.get("planId")).first()
|
|
if not plan:
|
|
return fail("套餐不存在", 404)
|
|
|
|
payment_method = str(payload.get("paymentMethod") or "").lower()
|
|
pay_type = PAYMENT_METHODS.get(payment_method)
|
|
if not pay_type:
|
|
return fail("请选择正确的支付方式", 400)
|
|
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,
|
|
plan_id=plan.id,
|
|
payment_method=payment_method,
|
|
pay_type=pay_type,
|
|
pay_way=pay_way,
|
|
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:
|
|
order.status = "failed"
|
|
order.failure_reason = str(exc)
|
|
db.commit()
|
|
return fail(str(exc), 503)
|
|
|
|
try:
|
|
result = _payment_client().create_payment(
|
|
huihui_token=user.huihui_token,
|
|
huihui_user_id=user.huihui_user_id,
|
|
real_name=user.nickname,
|
|
order_no=order.order_no,
|
|
amount=f"{cents / 100:.2f}",
|
|
points_amount=plan.amount,
|
|
pay_type=pay_type,
|
|
pay_way=pay_way,
|
|
callback_url=callback_url,
|
|
)
|
|
except HuihuiPaymentError as exc:
|
|
order.status = "failed"
|
|
order.failure_reason = str(exc)[:500]
|
|
db.commit()
|
|
return fail(str(exc), 502)
|
|
|
|
db.refresh(order)
|
|
if order.status != "paid":
|
|
order.provider_order_id = str(result.get("orderId") or "")
|
|
order.provider_order_no = str(result.get("orderNo") or "")
|
|
order.provider_status = str(result.get("status") or "pending")
|
|
message = result.get("payMessage") or ""
|
|
order.pay_message = (
|
|
json.dumps(message, ensure_ascii=False)
|
|
if isinstance(message, (dict, list))
|
|
else str(message)
|
|
)
|
|
if order.provider_status.upper() in FAILED_STATUSES:
|
|
order.status = "failed"
|
|
order.failure_reason = str(result.get("bankReturnMsg") or "支付下单失败")[:500]
|
|
db.commit()
|
|
|
|
return ok(_payment_payload(order, get_or_create_account(db, user.id)))
|
|
|
|
|
|
@router.get("/token/payment/{order_id}")
|
|
def payment_status(order_id: str, authorization: str = Header(None), db: Session = Depends(get_db)):
|
|
user = _require_user(authorization, db)
|
|
order = db.query(TokenPaymentOrder).filter(
|
|
TokenPaymentOrder.id == order_id,
|
|
TokenPaymentOrder.user_id == user.id,
|
|
).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,
|
|
callback_signature: str,
|
|
request: Request,
|
|
db: Session = Depends(get_db),
|
|
):
|
|
secret = os.getenv("HUIHUI_PAYMENT_CALLBACK_SECRET", "").strip()
|
|
expected = hmac.new(secret.encode(), order_no.encode(), hashlib.sha256).hexdigest()
|
|
if len(secret) < 16 or not hmac.compare_digest(callback_signature, expected):
|
|
raise HTTPException(status_code=404, detail="Not found")
|
|
|
|
content_type = request.headers.get("content-type", "").lower()
|
|
if "application/json" in content_type:
|
|
try:
|
|
payload = await request.json()
|
|
except ValueError:
|
|
return fail("支付回调格式不正确", 400)
|
|
else:
|
|
raw = (await request.body()).decode("utf-8", errors="replace")
|
|
payload = {key: values[-1] for key, values in parse_qs(raw).items()}
|
|
payload = _nested_payload(payload)
|
|
|
|
payload_order_no = str(_find_value(
|
|
payload,
|
|
"masterOrderNo",
|
|
"master_order_no",
|
|
"orderNo",
|
|
"order_no",
|
|
"bizOrderNo",
|
|
) or "").strip()
|
|
if payload_order_no and payload_order_no != order_no:
|
|
return fail("支付回调订单号不匹配", 422)
|
|
|
|
order = db.query(TokenPaymentOrder).filter(TokenPaymentOrder.order_no == order_no).first()
|
|
if not order:
|
|
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"
|
|
) or "").upper()
|
|
order.provider_status = provider_status
|
|
if provider_status not in SUCCESS_STATUSES:
|
|
if provider_status in FAILED_STATUSES:
|
|
order.status = "failed"
|
|
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})
|
|
|
|
paid_cents = _callback_amount_cents(payload)
|
|
if paid_cents is None or paid_cents != order.price_cents:
|
|
order.failure_reason = "支付回调金额不匹配"
|
|
db.commit()
|
|
return fail("支付金额不匹配", 422)
|
|
|
|
_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)
|
|
rows = (
|
|
db.query(
|
|
TokenUsage.avatar_id,
|
|
TokenUsage.source,
|
|
func.sum(TokenUsage.prompt_tokens),
|
|
func.sum(TokenUsage.completion_tokens),
|
|
func.sum(TokenUsage.total_tokens),
|
|
func.count(TokenUsage.id),
|
|
)
|
|
.filter(TokenUsage.user_id == user.id, TokenUsage.status == "completed")
|
|
.group_by(TokenUsage.avatar_id, TokenUsage.source)
|
|
.all()
|
|
)
|
|
return ok([
|
|
{
|
|
"avatarId": avatar_id,
|
|
"source": source,
|
|
"promptTokens": int(prompt_tokens or 0),
|
|
"completionTokens": int(completion_tokens or 0),
|
|
"totalTokens": int(total_tokens or 0),
|
|
"requestCount": int(request_count or 0),
|
|
}
|
|
for avatar_id, source, prompt_tokens, completion_tokens, total_tokens, request_count in rows
|
|
])
|