feat: add avatar payments and finance management
This commit is contained in:
@@ -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)"
|
||||
)
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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 或病例正文。
|
||||
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user