Compare commits

..
Author SHA1 Message Date
stefanfeng d88caa75dd docs: clarify Huihui org payment callback URL 2026-10-20 14:53:32 +08:00
stefanfeng cce5130136 Merge pull request '修复会会支付回调先于查单状态更新导致的 409' (#42) from codex/avatar-org-callback-reconcile-20261020 into main
Reviewed-on: #42
2026-10-20 14:41:50 +08:00
stefanfeng 721f8b990d fix: acknowledge signed payment notice before querying provider 2026-10-20 14:37:02 +08:00
stefanfeng 5c992729c4 fix: reconcile Huihui payment callbacks after provider confirmation 2026-10-20 14:27:49 +08:00
stefanfeng 23d9a33a6d Merge pull request '数字分身会会组织充值回调验签与查单确认' (#41) from codex/avatar-virtual-product-ids into main
Reviewed-on: #41
2026-10-20 14:13:29 +08:00
stefanfeng 04e96b6207 fix: accept legacy Huihui callback path for org orders 2026-10-20 14:03:13 +08:00
stefanfeng ce18df29b7 Verify Huihui organization payment callbacks against paid orders 2026-10-20 11:04:27 +08:00
stefanfeng 356f1c8c8d Merge pull request '数字分身套餐 ID 与微信虚拟支付道具 ID 对齐' (#40) from codex/avatar-virtual-product-ids into main
Reviewed-on: #40
2026-10-19 14:21:21 +08:00
stefanfeng 643abd13ea Pass recharge product ID to Huihui order API 2026-10-19 14:12:43 +08:00
stefanfeng 3379d5a1b2 Align avatar recharge plan IDs with WeChat virtual products 2026-10-19 14:00:06 +08:00
stefanfeng 5f27cc93cc Merge pull request '调整会会收银台参数并自动关闭超时订单' (#39) from codex/avatar-token-recharge-huihui into main
Reviewed-on: #39
2026-10-16 17:52:44 +08:00
stefanfeng 0302ee81e0 fix(finance): share avatar sqlite WAL with admin 2026-10-16 17:50:29 +08:00
stefanfeng 08a7bc8b90 feat(payment): expire pending orders after one hour 2026-10-16 17:24:57 +08:00
stefanfeng 07cef004d2 Merge pull request '完善数字分身积分充值与财务管理' (#38) from codex/avatar-token-recharge-huihui into main
Reviewed-on: #38
2026-10-16 17:14:39 +08:00
stefanfeng ddb8ea9802 fix(payment): rely on Huihui callback for status 2026-10-16 17:00:35 +08:00
stefanfeng 85d0c57548 fix(avatar): complete payment method removal 2026-10-16 16:42:34 +08:00
stefanfeng da5822ecfe feat(finance): manage token plans and simplify recharge checkout 2026-10-16 16:34:22 +08:00
stefanfeng 643730f756 fix(avatar): default checkout environment and simplify pay action 2026-10-16 16:16:11 +08:00
stefanfeng 63a0c6692c fix(avatar): show recharge entry for all signed-in users 2026-10-16 15:31:45 +08:00
stefanfeng c62233a7c2 feat(avatar): show recharge entry in uni-app webview 2026-10-16 15:17:21 +08:00
stefanfeng 09aebea7d1 fix(avatar): clear stale checkout context and closed orders 2026-10-16 15:03:03 +08:00
stefanfeng b1bf203018 feat(avatar): route token recharge through Huihui org orders 2026-10-16 14:56:00 +08:00
stefanfeng 9566eb8925 Merge pull request 'feat(avatar): add signed Huihui virtual payment callback' (#37) from codex/huihui-payment-callback into main 2026-10-15 14:28:30 +08:00
stefanfeng e5df72956d feat(avatar): add signed Huihui virtual payment callback 2026-10-15 14:26:06 +08:00
stefanfeng 48f3b9bddf Merge pull request 'fix(avatar): hide ai source note below chat bubbles' (#36) from codex/hide-chat-ai-source-20260920 into main
fix(avatar): hide ai source note below chat bubbles
2026-09-20 16:11:04 +08:00
23 changed files with 1000 additions and 287 deletions
+24 -36
View File
@@ -29,6 +29,30 @@ def summary():
db.close() db.close()
@router.get("/plans")
def token_plans():
db = _session()
try:
return ApiResponse(data=finance_service.list_token_plans(db))
finally:
db.close()
@router.patch("/plans/{plan_id}")
def update_token_plan(plan_id: str, body: dict = Body(...)):
db = _session()
try:
finance_service.update_token_plan(
db, plan_id, amount=int(body.get("amount") or 0), price=float(body.get("price") or 0),
name=str(body.get("name") or ""), badge=str(body.get("badge") or ""), desc=str(body.get("desc") or ""),
)
return ApiResponse(message="充值套餐已更新")
except (ValueError, FinanceServiceError) as exc:
_raise(exc if isinstance(exc, FinanceServiceError) else FinanceServiceError("积分和价格格式不正确"))
finally:
db.close()
@router.get("/orders") @router.get("/orders")
def orders( def orders(
page: int = Query(1, ge=1), page: int = Query(1, ge=1),
@@ -61,42 +85,6 @@ def update_order_status(order_no: str, body: dict = Body(...)):
db.close() db.close()
@router.post("/orders/{order_no}/refund")
def request_refund(order_no: str, body: dict = Body(...)):
try:
data = finance_service.request_refund(
order_no,
reason=str(body.get("reason") or "").strip(),
operator=str(body.get("operator") or "后台管理员").strip(),
)
return ApiResponse(data=data, message="退款申请已提交")
except FinanceServiceError as exc:
_raise(exc)
@router.get("/refunds")
def refunds(
page: int = Query(1, ge=1),
page_size: int = Query(20, ge=1, le=100),
status: str = Query(""),
):
db = _session()
try:
total, items = finance_service.list_refunds(db, page=page, page_size=page_size, status=status)
return ApiResponse(data={"total": total, "page": page, "page_size": page_size, "items": items})
finally:
db.close()
@router.post("/refunds/{refund_no}/confirm")
def confirm_refund(refund_no: str, body: dict = Body(...)):
try:
data = finance_service.confirm_refund(refund_no, body)
return ApiResponse(data=data, message="退款结果已登记")
except FinanceServiceError as exc:
_raise(exc)
@router.get("/invoices") @router.get("/invoices")
def invoices( def invoices(
page: int = Query(1, ge=1), page: int = Query(1, ge=1),
+17
View File
@@ -28,6 +28,23 @@ def _money(cents):
class FinanceService: class FinanceService:
@staticmethod
def list_token_plans(db):
rows = db.execute(text('SELECT id,name,amount,price,badge,"desc",virtual_product_id FROM token_plans ORDER BY price ASC')).fetchall()
return [dict(row._mapping) for row in rows]
@staticmethod
def update_token_plan(db, plan_id: str, *, amount: int, price: float, name: str, badge: str, desc: str):
if amount <= 0 or price < 0 or not name.strip():
raise FinanceServiceError("套餐名称、正数积分和非负价格不正确")
result = db.execute(text('''UPDATE token_plans SET name=:name,amount=:amount,price=:price,badge=:badge,"desc"=:desc WHERE id=:id'''), {
"id": plan_id, "name": name.strip()[:80], "amount": amount, "price": price,
"badge": badge.strip()[:80], "desc": desc.strip()[:200],
})
if result.rowcount == 0:
raise FinanceServiceError("充值套餐不存在", 404)
db.commit()
@staticmethod @staticmethod
def _tables_ready(db) -> bool: def _tables_ready(db) -> bool:
names = { names = {
+64 -9
View File
@@ -9,7 +9,7 @@ from apscheduler.schedulers.asyncio import AsyncIOScheduler
from apscheduler.triggers.interval import IntervalTrigger from apscheduler.triggers.interval import IntervalTrigger
from database import engine, init_db, SessionLocal from database import engine, init_db, SessionLocal
from models import Avatar, Authorization, Organization, TokenAccount, TokenPlan, User from models import Avatar, Authorization, Organization, TokenAccount, TokenPaymentOrder, TokenPlan, User
from fastapi.staticfiles import StaticFiles from fastapi.staticfiles import StaticFiles
import routers.avatars import routers.avatars
import routers.tokens import routers.tokens
@@ -22,7 +22,11 @@ import routers.takeover
from responses import ok from responses import ok
from services.chat_attachment_service import purge_expired_chat_attachments from services.chat_attachment_service import purge_expired_chat_attachments
from services.knowledge_vectorizer import knowledge_vectorizer from services.knowledge_vectorizer import knowledge_vectorizer
from services.token_billing import DEFAULT_TOKEN_GRANT, release_stale_reservations from services.token_billing import (
DEFAULT_TOKEN_GRANT,
close_expired_pending_orders,
release_stale_reservations,
)
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -86,18 +90,28 @@ def seed():
db = SessionLocal() db = SessionLocal()
try: try:
plan_specs = [ plan_specs = [
{"id": "1", "name": "基础套餐", "amount": 2_000_000, "price": 10, "badge": "", "desc": "2M 积分"}, {"id": "Basic", "legacy_id": "1", "name": "基础套餐", "amount": 2_000_000, "price": 10, "badge": "", "desc": "2M 积分"},
{"id": "2", "name": "标准套餐", "amount": 20_000_000, "price": 100, "badge": "常用", "desc": "20M 积分"}, {"id": "Standard", "legacy_id": "2", "name": "标准套餐", "amount": 20_000_000, "price": 100, "badge": "常用", "desc": "20M 积分"},
{"id": "3", "name": "专业套餐", "amount": 250_000_000, "price": 1000, "badge": "加赠25%", "desc": "250M 积分"}, {"id": "Professional", "legacy_id": "3", "name": "专业套餐", "amount": 250_000_000, "price": 1000, "badge": "加赠25%", "desc": "250M 积分"},
{"id": "4", "name": "企业套餐", "amount": 2_500_000_000, "price": 10000, "badge": "企业推荐", "desc": "2500M 积分"}, {"id": "Enterprise", "legacy_id": "4", "name": "企业套餐", "amount": 2_500_000_000, "price": 10000, "badge": "企业推荐", "desc": "2500M 积分"},
] ]
for spec in plan_specs: for spec in plan_specs:
plan = db.query(TokenPlan).filter(TokenPlan.id == spec["id"]).first() plan = db.query(TokenPlan).filter(TokenPlan.id == spec["id"]).first()
legacy = db.query(TokenPlan).filter(TokenPlan.id == spec["legacy_id"]).first()
if legacy is not None and plan is None:
legacy.id = spec["id"]
plan = legacy
db.flush()
if legacy is not None and legacy is not plan:
db.delete(legacy)
db.query(TokenPaymentOrder).filter(TokenPaymentOrder.plan_id == spec["legacy_id"]).update(
{TokenPaymentOrder.plan_id: spec["id"]}, synchronize_session=False
)
if plan is None: if plan is None:
db.add(TokenPlan(**spec)) db.add(TokenPlan(**{key: value for key, value in spec.items() if key != "legacy_id"}, virtual_product_id=spec["id"]))
else: else:
for key, value in spec.items(): plan.name = spec["name"]
setattr(plan, key, value) plan.virtual_product_id = spec["id"]
for user in db.query(User).all(): for user in db.query(User).all():
account = db.query(TokenAccount).filter(TokenAccount.user_id == user.id).first() account = db.query(TokenAccount).filter(TokenAccount.user_id == user.id).first()
@@ -255,10 +269,37 @@ def purge_expired_chat_attachments_job():
db.close() db.close()
def close_expired_pending_orders_job():
db = SessionLocal()
try:
count = close_expired_pending_orders(db)
if count:
logger.info("Closed %s expired pending token payment order(s)", count)
except Exception as exc:
db.rollback()
logger.warning("Failed to close expired token payment orders: %s", exc)
finally:
db.close()
def reconcile_huihui_org_notices_job():
db = SessionLocal()
try:
count = routers.tokens.reconcile_huihui_org_notices(db)
if count:
logger.info("Confirmed %s Huihui organization payment notice(s)", count)
except Exception as exc:
db.rollback()
logger.warning("Failed to reconcile Huihui organization payment notices: %s", exc)
finally:
db.close()
def start_maintenance_scheduler(): def start_maintenance_scheduler():
global maintenance_scheduler global maintenance_scheduler
purge_expired_chat_attachments_job() purge_expired_chat_attachments_job()
close_expired_pending_orders_job()
interval_minutes = max( interval_minutes = max(
5, min(1440, int(os.getenv("CHAT_ATTACHMENT_CLEANUP_MINUTES", "60"))) 5, min(1440, int(os.getenv("CHAT_ATTACHMENT_CLEANUP_MINUTES", "60")))
) )
@@ -270,6 +311,20 @@ def start_maintenance_scheduler():
max_instances=1, max_instances=1,
coalesce=True, coalesce=True,
) )
maintenance_scheduler.add_job(
close_expired_pending_orders_job,
trigger=IntervalTrigger(minutes=1),
id="close_expired_token_orders",
max_instances=1,
coalesce=True,
)
maintenance_scheduler.add_job(
reconcile_huihui_org_notices_job,
trigger=IntervalTrigger(seconds=10),
id="reconcile_huihui_org_notices",
max_instances=1,
coalesce=True,
)
maintenance_scheduler.start() maintenance_scheduler.start()
+19
View File
@@ -395,6 +395,7 @@ class TokenPaymentOrder(Base):
"price": self.price_cents / 100, "price": self.price_cents / 100,
"status": self.status, "status": self.status,
"provider": self.provider, "provider": self.provider,
"providerOrderNo": self.provider_order_no,
"providerStatus": self.provider_status, "providerStatus": self.provider_status,
"payMessage": self.pay_message, "payMessage": self.pay_message,
"failureReason": self.failure_reason, "failureReason": self.failure_reason,
@@ -436,6 +437,24 @@ class PaymentTransaction(Base):
} }
class HuihuiOrgPaymentNotice(Base):
"""Signed success notice awaiting independent confirmation from Huihui."""
__tablename__ = "huihui_org_payment_notices"
order_no = Column(String, primary_key=True)
payment_no = Column(String, nullable=False)
amount_cents = Column(Integer, nullable=False)
product_id = Column(String, nullable=False)
status = Column(String, nullable=False, default="awaiting_provider", index=True)
attempts = Column(Integer, nullable=False, default=0)
next_check_at = Column(DateTime, index=True)
last_provider_status = Column(String, default="")
last_error = Column(String, default="")
created_at = Column(DateTime, server_default=func.now())
updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now())
class PaymentRefund(Base): class PaymentRefund(Base):
__tablename__ = "payment_refunds" __tablename__ = "payment_refunds"
+363 -2
View File
@@ -2,17 +2,20 @@ import hashlib
import hmac import hmac
import json import json
import os import os
import time
import uuid import uuid
from datetime import datetime from datetime import datetime, timedelta
from decimal import Decimal, InvalidOperation, ROUND_HALF_UP from decimal import Decimal, InvalidOperation, ROUND_HALF_UP
from urllib.parse import parse_qs from urllib.parse import parse_qs
from fastapi import APIRouter, Body, Depends, Header, HTTPException, Query, Request, Response from fastapi import APIRouter, Body, Depends, Header, HTTPException, Query, Request, Response
from sqlalchemy import func from sqlalchemy import func
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from database import get_db from database import get_db
from models import ( from models import (
HuihuiOrgPaymentNotice,
InvoiceApplication, InvoiceApplication,
PaymentRefund, PaymentRefund,
PaymentTransaction, PaymentTransaction,
@@ -24,6 +27,7 @@ from models import (
) )
from responses import fail, ok from responses import fail, ok
from services.huihui_payment import HuihuiPaymentClient, HuihuiPaymentError from services.huihui_payment import HuihuiPaymentClient, HuihuiPaymentError
from services.huihui_org_payment import HuihuiOrgPaymentClient
from services.token_billing import get_or_create_account from services.token_billing import get_or_create_account
from services.wechat_virtual_payment import ( from services.wechat_virtual_payment import (
PAYMENT_EVENTS as WECHAT_PAYMENT_EVENTS, PAYMENT_EVENTS as WECHAT_PAYMENT_EVENTS,
@@ -44,6 +48,13 @@ router = APIRouter(tags=["Token"])
PAYMENT_METHODS = {"wechat": "WECHAT", "alipay": "ALIPAY"} PAYMENT_METHODS = {"wechat": "WECHAT", "alipay": "ALIPAY"}
PAYMENT_SCENES = {"APP", "H5", "LITE", "JSAPI"} PAYMENT_SCENES = {"APP", "H5", "LITE", "JSAPI"}
LEGACY_PLAN_IDS = {"1": "Basic", "2": "Standard", "3": "Professional", "4": "Enterprise"}
def _selected_plan(db: Session, plan_id):
return db.query(TokenPlan).filter(TokenPlan.id == LEGACY_PLAN_IDS.get(str(plan_id), plan_id)).first()
SUCCESS_STATUSES = {"SUCCESS", "SUCCEEDED", "PAID", "COMPLETED", "TRADE_SUCCESS"} SUCCESS_STATUSES = {"SUCCESS", "SUCCEEDED", "PAID", "COMPLETED", "TRADE_SUCCESS"}
FAILED_STATUSES = {"FAIL", "FAILED", "CLOSED", "CANCELLED", "CANCELED", "EXPIRED"} FAILED_STATUSES = {"FAIL", "FAILED", "CLOSED", "CANCELLED", "CANCELED", "EXPIRED"}
@@ -269,6 +280,7 @@ def _callback_amount_cents(payload) -> int | None:
"orderAmt", "orderAmt",
"amount", "amount",
"totalAmount", "totalAmount",
"chargeAmount",
) )
if value in (None, ""): if value in (None, ""):
return None return None
@@ -304,7 +316,7 @@ def plans(authorization: str = Header(None), db: Session = Depends(get_db)):
@router.post("/token/charge") @router.post("/token/charge")
def charge(payload: dict = Body(...), authorization: str = Header(None), db: Session = Depends(get_db)): def charge(payload: dict = Body(...), authorization: str = Header(None), db: Session = Depends(get_db)):
user = _require_user(authorization, db) user = _require_user(authorization, db)
plan = db.query(TokenPlan).filter(TokenPlan.id == payload.get("planId")).first() plan = _selected_plan(db, payload.get("planId"))
if not plan: if not plan:
return fail("套餐不存在", 404) return fail("套餐不存在", 404)
@@ -404,6 +416,50 @@ def charge(payload: dict = Body(...), authorization: str = Header(None), db: Ses
return ok(_payment_payload(order, get_or_create_account(db, user.id))) return ok(_payment_payload(order, get_or_create_account(db, user.id)))
@router.post("/token/charge/org")
def charge_via_huihui_org(payload: dict = Body(...), authorization: str = Header(None), db: Session = Depends(get_db)):
user = _require_user(authorization, db)
plan = _selected_plan(db, payload.get("planId"))
if not plan:
return fail("套餐不存在", 404)
method = str(payload.get("paymentMethod") or "").lower()
if method not in PAYMENT_METHODS:
return fail("支付方式不正确", 400)
scene = str(payload.get("payScene") or "APP").upper()
if scene not in PAYMENT_SCENES:
return fail("支付场景不正确", 400)
if scene == "LITE" and method != "wechat":
return fail("微信小程序仅支持微信支付", 400)
cents = _price_cents(plan.price)
try:
result = HuihuiOrgPaymentClient().create_order(
token=user.huihui_token, user_id=user.huihui_user_id,
name=user.nickname, phone=user.phone, avatar=user.avatar_url,
amount=f"{cents / 100:.2f}",
product_id=plan.id,
)
except HuihuiPaymentError as exc:
return fail(str(exc), 502)
provider_no = str(result.get("orderNo") or "")
if not provider_no.isdigit() or int(result.get("payStatus") or 0) != 1 or int(result.get("chargeType") or 0) != 9:
return fail("会会返回的充值订单不正确", 502)
if _callback_amount_cents({"chargeAmount": result.get("chargeAmount")}) != cents:
return fail("会会返回的充值金额不匹配", 502)
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=method,
pay_type=PAYMENT_METHODS[method], pay_way=scene, points_amount=plan.amount,
price_cents=cents, status="pending", provider="huihui_org",
provider_order_no=provider_no, provider_status="1",
)
db.add(order)
db.commit()
result = _payment_payload(order, get_or_create_account(db, user.id))
if "fat-open.99hui.com" in os.getenv("HUIHUI_ORG_BASE_URL", ""):
result["checkoutToken"] = user.huihui_token
return ok(result)
@router.get("/token/payment/{order_id}") @router.get("/token/payment/{order_id}")
def payment_status(order_id: str, authorization: str = Header(None), db: Session = Depends(get_db)): def payment_status(order_id: str, authorization: str = Header(None), db: Session = Depends(get_db)):
user = _require_user(authorization, db) user = _require_user(authorization, db)
@@ -413,6 +469,7 @@ def payment_status(order_id: str, authorization: str = Header(None), db: Session
).first() ).first()
if not order: if not order:
return fail("支付订单不存在", 404) return fail("支付订单不存在", 404)
# 会会组织支付以回调为准;查询此接口只返回本地订单,不向会会轮询查单。
if order.provider == "wechat_virtual" and order.status == "pending" and user.wechat_mp_openid: if order.provider == "wechat_virtual" and order.status == "pending" and user.wechat_mp_openid:
try: try:
provider_data = query_wechat_virtual_order( provider_data = query_wechat_virtual_order(
@@ -559,6 +616,8 @@ def admin_request_refund(
return fail("订单不存在", 404) return fail("订单不存在", 404)
if order.status != "paid" or order.refund_status not in {"", "none", "failed"}: if order.status != "paid" or order.refund_status not in {"", "none", "failed"}:
return fail("该订单当前不可退款", 409) return fail("该订单当前不可退款", 409)
if order.provider == "huihui_org":
return fail("会会组织订单的原路退款接口尚未接入,请先在会会后台处理", 409)
account = get_or_create_account(db, order.user_id) account = get_or_create_account(db, order.user_id)
if int(account.balance or 0) < int(order.points_amount or 0): if int(account.balance or 0) < int(order.points_amount or 0):
return fail("该订单发放的积分已使用,不能执行全额退款", 409) return fail("该订单发放的积分已使用,不能执行全额退款", 409)
@@ -753,6 +812,308 @@ async def payment_callback(
return ok({"received": True, "paid": True}) return ok({"received": True, "paid": True})
async def _signed_huihui_notice(request: Request, *, secret_name: str, minimum_length: int) -> dict:
secret = os.getenv(secret_name, "").strip()
if len(secret) < minimum_length:
raise HTTPException(status_code=503, detail="payment callback is not configured")
timestamp = request.headers.get("x-huihui-timestamp", "").strip()
signature = request.headers.get("x-huihui-signature", "").strip().lower()
if not timestamp.isascii() or not timestamp.isdecimal() or len(timestamp) > 12:
raise HTTPException(status_code=401, detail="invalid callback signature")
try:
issued_at = int(timestamp)
except ValueError:
raise HTTPException(status_code=401, detail="invalid callback signature")
if abs(int(time.time()) - issued_at) > 300:
raise HTTPException(status_code=401, detail="callback timestamp expired")
body = await request.body()
if not body or len(body) > 65536:
raise HTTPException(status_code=400, detail="invalid callback body")
expected = hmac.new(
secret.encode("utf-8"), timestamp.encode("ascii") + b"\n" + body, hashlib.sha256
).hexdigest()
if not hmac.compare_digest(signature, expected):
raise HTTPException(status_code=401, detail="invalid callback signature")
try:
payload = json.loads(body)
except (UnicodeDecodeError, ValueError):
raise HTTPException(status_code=400, detail="invalid callback JSON")
if not isinstance(payload, dict):
raise HTTPException(status_code=400, detail="invalid callback JSON")
return payload
@router.post("/token/payment/huihui/org/notify")
async def huihui_org_payment_notify(request: Request, db: Session = Depends(get_db)):
"""Huihui organization recharge notice; independently verify payment before crediting."""
payload = await _signed_huihui_notice(
request, secret_name="HUIHUI_ORG_CALLBACK_SECRET", minimum_length=16,
)
return _process_huihui_org_notice(payload, db)
def _process_huihui_org_notice(payload: dict, db: Session):
order_no = str(payload.get("orderNo") or "").strip()
payment_no = str(payload.get("paymentNo") or "").strip()
status = str(payload.get("status") or "").upper()
if not order_no or not payment_no or status not in {"SUCCESS", "FAILED"}:
raise HTTPException(status_code=400, detail="missing callback fields")
order = db.query(TokenPaymentOrder).filter(
TokenPaymentOrder.provider == "huihui_org",
TokenPaymentOrder.order_no == order_no,
).first()
if order is None and order_no.isdigit():
matches = db.query(TokenPaymentOrder).filter(
TokenPaymentOrder.provider == "huihui_org",
TokenPaymentOrder.provider_order_no == order_no,
).limit(2).all()
if len(matches) == 1:
order = matches[0]
if not order:
raise HTTPException(status_code=404, detail="payment order not found")
if status == "FAILED":
return ok({"received": True, "paid": False})
user = db.query(User).filter(User.id == order.user_id).first()
plan = db.query(TokenPlan).filter(TokenPlan.id == order.plan_id).first()
try:
expected_product_id = product_id_for_plan(plan) if plan else ""
amount_cents = int(payload.get("amountCents"))
except (TypeError, ValueError, WechatVirtualPaymentError):
raise HTTPException(status_code=422, detail="invalid payment details")
if (
isinstance(payload.get("amountCents"), bool)
or not user
or not expected_product_id
or str(payload.get("productId") or "") != expected_product_id
or amount_cents != order.price_cents
):
raise HTTPException(status_code=422, detail="payment details mismatch")
if order.status in {"paid", "refunded"}:
return ok({"received": True, "duplicate": True})
notice = db.get(HuihuiOrgPaymentNotice, order.order_no)
if notice and notice.payment_no != payment_no:
raise HTTPException(status_code=409, detail="payment number conflicts with prior notice")
if not order.provider_order_no:
raise HTTPException(status_code=422, detail="provider order number missing")
# The provider may not mark its order paid until it receives our HTTP 200.
# Never synchronously query the provider before acknowledging a signed notice.
_save_huihui_org_notice(db, order, payment_no, amount_cents, expected_product_id)
return ok({"received": True, "paid": False, "awaitingProvider": True})
def _huihui_org_provider_status(order: TokenPaymentOrder, user: User, product_id: str, payment_no: str) -> int:
provider_order = HuihuiOrgPaymentClient().get_order(
token=user.huihui_token,
user_id=user.huihui_user_id,
order_no=order.provider_order_no,
)
try:
provider_status = int(provider_order.get("payStatus"))
provider_type = int(provider_order.get("chargeType"))
except (TypeError, ValueError):
raise HTTPException(status_code=422, detail="invalid provider order details")
if (
str(provider_order.get("orderNo") or "") != order.provider_order_no
or str(provider_order.get("paymentNo") or "") != payment_no
or str(provider_order.get("productId") or "") != product_id
or str(provider_order.get("userId") or "") != user.huihui_user_id
or provider_type != 9
or _callback_amount_cents({"chargeAmount": provider_order.get("chargeAmount")}) != order.price_cents
):
raise HTTPException(status_code=422, detail="provider order details mismatch")
return provider_status
def _save_huihui_org_notice(
db: Session, order: TokenPaymentOrder, payment_no: str, amount_cents: int,
product_id: str,
) -> None:
notice = db.get(HuihuiOrgPaymentNotice, order.order_no)
if notice and (
notice.payment_no != payment_no
or notice.amount_cents != amount_cents
or notice.product_id != product_id
):
raise HTTPException(status_code=409, detail="payment notice conflicts with prior notice")
if notice is None:
notice = HuihuiOrgPaymentNotice(
order_no=order.order_no, payment_no=payment_no,
amount_cents=amount_cents, product_id=product_id,
)
db.add(notice)
notice.status = "awaiting_provider"
notice.last_provider_status = "not_checked"
notice.last_error = ""
first_check = datetime.utcnow() + timedelta(seconds=5)
if notice.next_check_at is None or notice.next_check_at > first_check:
notice.next_check_at = first_check
try:
db.commit()
except IntegrityError:
db.rollback()
concurrent = db.get(HuihuiOrgPaymentNotice, order.order_no)
if not concurrent or (
concurrent.payment_no != payment_no
or concurrent.amount_cents != amount_cents
or concurrent.product_id != product_id
):
raise HTTPException(status_code=409, detail="payment notice conflicts with prior notice")
def _huihui_notice_retry_seconds(attempts: int) -> int:
return min(15 * (2 ** min(max(0, attempts - 1), 8)), 3600)
def _settle_verified_huihui_org_order(
db: Session, order: TokenPaymentOrder, payment_no: str,
notice: HuihuiOrgPaymentNotice | None = None,
) -> None:
existing_payment = db.query(PaymentTransaction).filter(
PaymentTransaction.provider == "huihui_org",
PaymentTransaction.event_type == "payment",
PaymentTransaction.transaction_no == payment_no,
).first()
if existing_payment and existing_payment.order_no != order.order_no:
raise HTTPException(status_code=409, detail="payment number belongs to another order")
_settle_paid_order(db, order, provider_status="HUIHUI_SUCCESS", transaction_no=payment_no)
if notice:
notice.status = "confirmed"
notice.next_check_at = None
notice.last_provider_status = "paid"
notice.last_error = ""
db.commit()
def reconcile_huihui_org_notices(db: Session, limit: int = 20) -> int:
"""Retry only signed notices whose provider confirmation lagged the callback."""
now = datetime.utcnow()
due = db.query(HuihuiOrgPaymentNotice).filter(
HuihuiOrgPaymentNotice.status == "awaiting_provider",
HuihuiOrgPaymentNotice.next_check_at <= now,
).order_by(HuihuiOrgPaymentNotice.next_check_at).limit(limit).all()
confirmed = 0
for notice in due:
order = db.query(TokenPaymentOrder).filter(
TokenPaymentOrder.order_no == notice.order_no,
TokenPaymentOrder.provider == "huihui_org",
).first()
if not order:
notice.status = "rejected"
notice.last_error = "order missing"
db.commit()
continue
if order.status in {"paid", "refunded"}:
notice.status = "confirmed"
notice.next_check_at = None
db.commit()
continue
user = db.query(User).filter(User.id == order.user_id).first()
plan = db.query(TokenPlan).filter(TokenPlan.id == order.plan_id).first()
try:
product_id = product_id_for_plan(plan) if plan else ""
except WechatVirtualPaymentError:
product_id = ""
if not user or not product_id or product_id != notice.product_id or order.price_cents != notice.amount_cents:
notice.status = "rejected"
notice.last_error = "local order details mismatch"
db.commit()
continue
try:
status = _huihui_org_provider_status(order, user, product_id, notice.payment_no)
except HuihuiPaymentError:
status = None
except HTTPException as exc:
notice.status = "rejected"
notice.last_error = str(exc.detail)[:200]
db.commit()
continue
if status in {2, 3, 4}:
try:
_settle_verified_huihui_org_order(db, order, notice.payment_no, notice)
confirmed += 1
except HTTPException as exc:
db.rollback()
notice = db.get(HuihuiOrgPaymentNotice, notice.order_no)
notice.status = "rejected"
notice.last_error = str(exc.detail)[:200]
db.commit()
continue
notice.last_provider_status = str(status) if status is not None else "unavailable"
notice.attempts = int(notice.attempts or 0) + 1
if notice.created_at and now - notice.created_at >= timedelta(hours=24):
notice.status = "unconfirmed"
notice.next_check_at = None
notice.last_error = "provider did not confirm payment within 24 hours"
else:
notice.next_check_at = now + timedelta(seconds=_huihui_notice_retry_seconds(notice.attempts))
db.commit()
return confirmed
@router.post("/token/payment/huihui/virtual/notify")
async def huihui_virtual_payment_notify(request: Request, db: Session = Depends(get_db)):
"""Server-to-server virtual-payment notice using the Huihui HMAC contract."""
# Older Huihui integrations still post organization recharge notices to this
# path. Only use the organization key when no virtual-payment key exists;
# the organization handler independently checks the order's provider.
if len(os.getenv("HUIHUI_VIRTUAL_CALLBACK_SECRET", "").strip()) < 32 and len(
os.getenv("HUIHUI_ORG_CALLBACK_SECRET", "").strip()
) >= 16:
payload = await _signed_huihui_notice(
request, secret_name="HUIHUI_ORG_CALLBACK_SECRET", minimum_length=16,
)
return _process_huihui_org_notice(payload, db)
payload = await _signed_huihui_notice(
request, secret_name="HUIHUI_VIRTUAL_CALLBACK_SECRET", minimum_length=32,
)
order_no = str(payload.get("orderNo") or "").strip()
payment_no = str(payload.get("paymentNo") or "").strip()
status = str(payload.get("status") or "").upper()
if not order_no or not payment_no or status not in {"SUCCESS", "FAILED"}:
raise HTTPException(status_code=400, detail="missing callback fields")
order = db.query(TokenPaymentOrder).filter(TokenPaymentOrder.order_no == order_no).first()
if not order or order.provider != "wechat_virtual":
raise HTTPException(status_code=404, detail="virtual payment order not found")
if status == "FAILED":
return ok({"received": True, "paid": False})
user = db.query(User).filter(User.id == order.user_id).first()
plan = db.query(TokenPlan).filter(TokenPlan.id == order.plan_id).first()
try:
expected_product_id = product_id_for_plan(plan) if plan else ""
amount_cents = int(payload.get("amountCents"))
callback_env = int(payload.get("env"))
except (TypeError, ValueError, WechatVirtualPaymentError):
raise HTTPException(status_code=422, detail="invalid virtual payment details")
if (
isinstance(payload.get("amountCents"), bool)
or isinstance(payload.get("env"), bool)
or not user
or not user.wechat_mp_openid
or str(payload.get("openId") or "") != user.wechat_mp_openid
or not expected_product_id
or str(payload.get("productId") or "") != expected_product_id
or amount_cents != order.price_cents
or callback_env != wechat_virtual_env()
):
raise HTTPException(status_code=422, detail="virtual payment details mismatch")
existing_payment = db.query(PaymentTransaction).filter(
PaymentTransaction.provider == "wechat_virtual",
PaymentTransaction.event_type == "payment",
PaymentTransaction.transaction_no == payment_no,
).first()
if existing_payment and existing_payment.order_no != order.order_no:
raise HTTPException(status_code=409, detail="payment number belongs to another order")
if order.status in {"paid", "refunded"}:
return ok({"received": True, "duplicate": True})
_settle_paid_order(db, order, provider_status="HUIHUI_SUCCESS", transaction_no=payment_no)
db.commit()
return ok({"received": True, "paid": True})
def _wechat_notify_response(request: Request, *, success: bool, message: str = ""): def _wechat_notify_response(request: Request, *, success: bool, message: str = ""):
code = 0 if success else 1 code = 0 if success else 1
text = "success" if success else (message or "fail")[:200].replace("]]>", "") text = "success" if success else (message or "fail")[:200].replace("]]>", "")
@@ -0,0 +1,49 @@
"""Huihui organization token-recharge order API."""
import os
import httpx
from services.huihui_payment import HuihuiPaymentClient, HuihuiPaymentError
class HuihuiOrgPaymentClient(HuihuiPaymentClient):
def __init__(self):
super().__init__({
"HUIHUI_PAYMENT_BASE_URL": os.getenv("HUIHUI_ORG_BASE_URL", ""),
"HUIHUI_APP_ID": os.getenv("HUIHUI_APP_ID", ""),
"HUIHUI_ACCESS_ID": os.getenv("HUIHUI_ACCESS_ID", ""),
"HUIHUI_ACCESS_SECRET": os.getenv("HUIHUI_ACCESS_SECRET", ""),
})
def _request(self, method: str, path: str, *, token: str, user_id: str, body=None) -> dict:
if not self.configured or not token or not user_id:
raise HuihuiPaymentError("会会订单服务或登录凭证未配置")
try:
response = httpx.request(
method, f"{self.base_url}{path}",
headers={"Authorization": f"Bearer {token}", "appId": self.app_id},
params=self._signed_params(user_id), json=body,
timeout=self.timeout, follow_redirects=True,
)
except httpx.HTTPError as exc:
raise HuihuiPaymentError("会会订单服务连接失败") from exc
payload = self._json(response)
if response.status_code >= 400 or payload.get("code") not in (0, 200, "0", "200"):
raise HuihuiPaymentError(str(payload.get("message") or "会会订单请求失败"))
data = payload.get("data")
if not isinstance(data, dict):
raise HuihuiPaymentError("会会订单信息缺失")
return data
def create_order(self, *, token: str, user_id: str, name: str, phone: str,
avatar: str, amount: str, product_id: str) -> dict:
body = {"chargeAmount": float(amount),
"commodityName": "数字分身积分充值", "productId": product_id,
"userId": user_id,
"name": name or "", "phoneNumber": phone or "", "avatar": avatar or ""}
return self._request("POST", "/order/tokenRecharge", token=token, user_id=user_id, body=body)
def get_order(self, *, token: str, user_id: str, order_no: str) -> dict:
if not order_no.isdigit():
raise HuihuiPaymentError("会会订单号格式不正确")
return self._request("GET", f"/order/get/{order_no}", token=token, user_id=user_id)
@@ -7,7 +7,7 @@ from datetime import datetime, timedelta
from sqlalchemy.exc import IntegrityError from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from models import Avatar, TokenAccount, TokenUsage, User from models import Avatar, TokenAccount, TokenPaymentOrder, TokenUsage, User
DEFAULT_TOKEN_GRANT = 1_000_000 DEFAULT_TOKEN_GRANT = 1_000_000
@@ -201,3 +201,22 @@ def release_stale_reservations(db: Session, older_than_minutes: int = 10) -> int
"stale_reservation_recovered", "stale_reservation_recovered",
) )
return len(stale) return len(stale)
def close_expired_pending_orders(db: Session, older_than_minutes: int = 60) -> int:
"""Close unpaid recharge orders after their one-hour payment window."""
now = datetime.utcnow()
cutoff = now - timedelta(minutes=older_than_minutes)
closed_count = db.query(TokenPaymentOrder).filter(
TokenPaymentOrder.status == "pending",
TokenPaymentOrder.created_at <= cutoff,
).update(
{
TokenPaymentOrder.status: "closed",
TokenPaymentOrder.failure_reason: "支付超时(超过1小时)",
TokenPaymentOrder.updated_at: now,
},
synchronize_session=False,
)
db.commit()
return int(closed_count or 0)
@@ -49,9 +49,7 @@ def _offer_id() -> str:
def product_id_for_plan(plan) -> str: def product_id_for_plan(plan) -> str:
configured = str(getattr(plan, "virtual_product_id", "") or "").strip() configured = str(getattr(plan, "id", "") or "").strip()
if not configured:
configured = os.getenv(f"WECHAT_VIRTUAL_PRODUCT_{plan.id}", "").strip()
if not configured: if not configured:
raise WechatVirtualPaymentError(f"套餐 {plan.id} 尚未配置微信虚拟支付商品 ID") raise WechatVirtualPaymentError(f"套餐 {plan.id} 尚未配置微信虚拟支付商品 ID")
if len(configured) > 64 or not all(ch.isalnum() or ch in "_-" for ch in configured): if len(configured) > 64 or not all(ch.isalnum() or ch in "_-" for ch in configured):
@@ -1,6 +1,7 @@
import os import os
from unittest.mock import Mock, patch from unittest.mock import Mock, patch
from services.huihui_org_payment import HuihuiOrgPaymentClient
from services.huihui_payment import HuihuiPaymentClient from services.huihui_payment import HuihuiPaymentClient
@@ -81,3 +82,31 @@ def test_request_refund_uses_configured_huihui_endpoint_without_exposing_secret(
"refundReason": "用户申请", "refundReason": "用户申请",
} }
assert "accessSecret" not in post.call_args.kwargs["params"] assert "accessSecret" not in post.call_args.kwargs["params"]
def test_org_recharge_order_sends_package_product_id_as_string():
env = {
"HUIHUI_ORG_BASE_URL": "https://fat-open.example/api/huihuiorg",
"HUIHUI_APP_ID": "app-id",
"HUIHUI_ACCESS_ID": "access-id",
"HUIHUI_ACCESS_SECRET": "access-secret",
}
response = Mock(status_code=200)
response.json.return_value = {"code": 200, "data": {"orderNo": "261016111200090123456"}}
with patch.dict(os.environ, env), patch(
"services.huihui_org_payment.httpx.request", return_value=response
) as request:
result = HuihuiOrgPaymentClient().create_order(
token="user-token", user_id="user-id", name="测试用户",
phone="", avatar="", amount="10.00", product_id="Basic",
)
assert result["orderNo"] == "261016111200090123456"
assert request.call_args.args == (
"POST", "https://fat-open.example/api/huihuiorg/order/tokenRecharge"
)
body = request.call_args.kwargs["json"]
assert body["chargeAmount"] == 10.0
assert body["productId"] == "Basic"
assert isinstance(body["productId"], str)
@@ -53,8 +53,15 @@ def test_scheduler_uses_boxim_and_restart_safe_service(
max_message_age_seconds=600, max_message_age_seconds=600,
) )
maintenance_scheduler.add_job.assert_called_once() assert maintenance_scheduler.add_job.call_count == 3
assert maintenance_scheduler.add_job.call_args.kwargs["id"] == "chat_attachment_cleanup" cleanup_call, close_orders_call, reconcile_call = maintenance_scheduler.add_job.call_args_list
assert cleanup_call.kwargs["id"] == "chat_attachment_cleanup"
assert close_orders_call.args[0] is main.close_expired_pending_orders_job
assert close_orders_call.kwargs["id"] == "close_expired_token_orders"
assert close_orders_call.kwargs["trigger"].interval.total_seconds() == 60
assert reconcile_call.args[0] is main.reconcile_huihui_org_notices_job
assert reconcile_call.kwargs["id"] == "reconcile_huihui_org_notices"
assert reconcile_call.kwargs["trigger"].interval.total_seconds() == 10
maintenance_scheduler.start.assert_called_once_with() maintenance_scheduler.start.assert_called_once_with()
assert scheduler.add_job.call_count == 2 assert scheduler.add_job.call_count == 2
@@ -7,6 +7,7 @@ from threading import Barrier
from unittest.mock import Mock, patch from unittest.mock import Mock, patch
import pytest import pytest
from datetime import datetime, timedelta
from fastapi.testclient import TestClient from fastapi.testclient import TestClient
from database import SessionLocal from database import SessionLocal
@@ -16,6 +17,7 @@ from routers.chat import _resolve_reply, _stream_reply
from services.token_billing import ( from services.token_billing import (
DEFAULT_TOKEN_GRANT, DEFAULT_TOKEN_GRANT,
InsufficientTokensError, InsufficientTokensError,
close_expired_pending_orders,
get_or_create_account, get_or_create_account,
release_reservation, release_reservation,
reserve_avatar_tokens, reserve_avatar_tokens,
@@ -55,6 +57,10 @@ def test_seed_synchronizes_requested_recharge_plans():
db = SessionLocal() db = SessionLocal()
try: try:
plans = db.query(TokenPlan).order_by(TokenPlan.price.asc()).all() plans = db.query(TokenPlan).order_by(TokenPlan.price.asc()).all()
assert {plan.id: plan.virtual_product_id for plan in plans} == {
"Basic": "Basic", "Standard": "Standard",
"Professional": "Professional", "Enterprise": "Enterprise",
}
assert [(plan.price, plan.amount) for plan in plans] == [ assert [(plan.price, plan.amount) for plan in plans] == [
(10, 2_000_000), (10, 2_000_000),
(100, 20_000_000), (100, 20_000_000),
@@ -65,6 +71,33 @@ def test_seed_synchronizes_requested_recharge_plans():
db.close() db.close()
def test_seed_preserves_admin_price_and_migrates_old_order_plan_id():
seed()
db = SessionLocal()
order_no = f"TEST-MIGRATION-{uuid.uuid4().hex}"
try:
plan = db.query(TokenPlan).filter_by(id="Basic").one()
original_price = plan.price
plan.price = 0.01
db.add(TokenPlan(id="1", name="基础套餐", amount=2_000_000, price=10))
db.add(TokenPaymentOrder(
order_no=order_no, user_id="test-user", plan_id="1",
payment_method="wechat", pay_type="WECHAT", pay_way="JSAPI",
points_amount=2_000_000, price_cents=1000, status="pending",
))
db.commit()
seed()
db.expire_all()
assert db.query(TokenPlan).filter_by(id="1").first() is None
assert db.query(TokenPlan).filter_by(id="Basic").one().price == 0.01
assert db.query(TokenPaymentOrder).filter_by(order_no=order_no).one().plan_id == "Basic"
finally:
db.query(TokenPaymentOrder).filter_by(order_no=order_no).delete()
db.query(TokenPlan).filter_by(id="Basic").update({"price": original_price})
db.commit()
db.close()
def test_charge_creates_huihui_order_without_early_points(authorization_context): def test_charge_creates_huihui_order_without_early_points(authorization_context):
context = authorization_context context = authorization_context
_enable_huihui_payment_login(context) _enable_huihui_payment_login(context)
@@ -100,6 +133,85 @@ def test_charge_creates_huihui_order_without_early_points(authorization_context)
assert "test-callback-secret-123456" not in callback_url assert "test-callback-secret-123456" not in callback_url
def test_org_recharge_only_credits_after_verified_provider_order(authorization_context):
seed()
context = authorization_context
_enable_huihui_payment_login(context)
provider_no = "261016111200090123456"
created_data = {"orderNo": provider_no, "chargeType": 9,
"chargeAmount": 10, "payStatus": 1}
with patch.dict(os.environ, {"HUIHUI_ORG_BASE_URL": "https://fat-open.99hui.com/api/huihuiorg"}), patch("routers.tokens.HuihuiOrgPaymentClient") as client_factory:
provider = client_factory.return_value
provider.create_order.return_value = created_data
created = client.post(
"/api/token/charge/org", headers=context["owner_headers"],
json={"planId": "1", "paymentMethod": "wechat", "payScene": "APP"},
).json()["data"]
assert created["providerOrderNo"] == provider_no
assert created["status"] == "pending"
assert created["balance"] == DEFAULT_TOKEN_GRANT
assert created["checkoutToken"] == f"huihui-payment-{context['suffix']}"
assert "pay_type" not in provider.create_order.call_args.kwargs
assert provider.create_order.call_args.kwargs["product_id"] == "Basic"
details = {**created_data, "payStatus": 2,
"userId": context["owner"].huihui_user_id}
provider.get_order.return_value = details
unchanged = client.get(f"/api/token/payment/{created['id']}", headers=context["owner_headers"]).json()["data"]
assert unchanged["status"] == "pending"
assert unchanged["balance"] == DEFAULT_TOKEN_GRANT
provider.get_order.assert_not_called()
def test_org_recharge_does_not_return_checkout_token_outside_test_environment(authorization_context):
seed()
context = authorization_context
_enable_huihui_payment_login(context)
with patch.dict(os.environ, {"HUIHUI_ORG_BASE_URL": "https://open.99hui.com/api/huihuiorg"}), patch(
"routers.tokens.HuihuiOrgPaymentClient"
) as client_factory:
client_factory.return_value.create_order.return_value = {
"orderNo": "261016111200090123457", "chargeType": 9,
"chargeAmount": 10, "payStatus": 1,
}
response = client.post(
"/api/token/charge/org", headers=context["owner_headers"],
json={"planId": "1", "paymentMethod": "wechat", "payScene": "APP"},
)
assert response.status_code == 200
assert "checkoutToken" not in response.json()["data"]
def test_expired_pending_payment_orders_are_closed_without_touching_recent_orders(authorization_context):
context = authorization_context
db = SessionLocal()
try:
expired = TokenPaymentOrder(
order_no=f"EXPIRED-{uuid.uuid4().hex}", user_id=context["owner"].id, plan_id="1",
payment_method="wechat", pay_type="WECHAT", pay_way="H5", points_amount=100,
price_cents=100, status="pending", provider="huihui_org",
created_at=datetime.utcnow() - timedelta(hours=1, seconds=1),
)
recent = TokenPaymentOrder(
order_no=f"RECENT-{uuid.uuid4().hex}", user_id=context["owner"].id, plan_id="1",
payment_method="wechat", pay_type="WECHAT", pay_way="H5", points_amount=100,
price_cents=100, status="pending", provider="huihui_org",
created_at=datetime.utcnow() - timedelta(minutes=59),
)
db.add_all([expired, recent])
db.commit()
assert close_expired_pending_orders(db) == 1
db.refresh(expired)
db.refresh(recent)
assert expired.status == "closed"
assert expired.failure_reason == "支付超时(超过1小时)"
assert recent.status == "pending"
finally:
db.close()
def test_success_callback_credits_once_and_status_is_user_scoped(authorization_context): def test_success_callback_credits_once_and_status_is_user_scoped(authorization_context):
context = authorization_context context = authorization_context
_enable_huihui_payment_login(context) _enable_huihui_payment_login(context)
@@ -1,19 +1,187 @@
import hashlib import hashlib
import hmac
import json import json
import os import os
import time
from datetime import datetime, timedelta
from unittest.mock import patch from unittest.mock import patch
from fastapi.testclient import TestClient from fastapi.testclient import TestClient
from database import SessionLocal from database import SessionLocal
from main import app, seed from main import app, seed
from models import InvoiceApplication, PaymentRefund, TokenAccount, TokenPaymentOrder, TokenPlan, User from models import HuihuiOrgPaymentNotice, InvoiceApplication, PaymentRefund, TokenAccount, TokenPaymentOrder, TokenPlan, User
from services.token_billing import DEFAULT_TOKEN_GRANT from services.token_billing import DEFAULT_TOKEN_GRANT
client = TestClient(app) client = TestClient(app)
def _huihui_virtual_headers(secret, body, timestamp=None):
timestamp = str(timestamp if timestamp is not None else int(time.time()))
signature = hmac.new(secret.encode(), timestamp.encode() + b"\n" + body, hashlib.sha256).hexdigest()
return {"X-Huihui-Timestamp": timestamp, "X-Huihui-Signature": signature}
def test_huihui_virtual_callback_requires_signature_and_exact_order_details(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-huihui-callback"
user.wechat_mp_session_key = "session-huihui-callback"
plan = db.query(TokenPlan).filter(TokenPlan.id == "Basic").one()
db.commit()
finally:
db.close()
secret = "huihui-virtual-callback-test-secret-123456"
env = {
"WECHAT_VIRTUAL_ENV": "sandbox",
"WECHAT_VIRTUAL_SANDBOX_APP_KEY": "sandbox-key",
"WECHAT_VIRTUAL_OFFER_ID": "offer-1",
"HUIHUI_VIRTUAL_CALLBACK_SECRET": secret,
}
with patch.dict(os.environ, env):
created = client.post(
"/api/token/charge",
headers=context["owner_headers"],
json={"planId": "Basic", "paymentMethod": "wechat", "payScene": "LITE"},
).json()["data"]
payload = {
"orderNo": created["orderNo"],
"paymentNo": f"huihui-virtual-payment-{context['suffix']}",
"status": "SUCCESS",
"amountCents": 1000,
"productId": "Basic",
"openId": "openid-huihui-callback",
"env": 1,
}
path = "/api/token/payment/huihui/virtual/notify"
body = json.dumps(payload, separators=(",", ":")).encode()
assert client.post(path, content=body, headers=_huihui_virtual_headers(secret, body, 1)).status_code == 401
assert client.post(path, content=body, headers={"X-Huihui-Timestamp": str(int(time.time())), "X-Huihui-Signature": "bad"}).status_code == 401
wrong = json.dumps({**payload, "amountCents": 999}, separators=(",", ":")).encode()
assert client.post(path, content=wrong, headers=_huihui_virtual_headers(secret, wrong)).status_code == 422
first = client.post(path, content=body, headers=_huihui_virtual_headers(secret, body))
second = client.post(path, content=body, headers=_huihui_virtual_headers(secret, body))
assert first.json()["data"] == {"received": True, "paid": True}
assert second.json()["data"] == {"received": True, "duplicate": True}
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()
assert order.status == "paid"
assert account.balance == DEFAULT_TOKEN_GRANT + 2_000_000
finally:
db.close()
def test_huihui_org_callback_verifies_provider_before_granting_points(authorization_context):
seed()
context = authorization_context
provider_no = "26102010414409000001"
payment_no = f"WV-{context['suffix']}"
order_no = f"AV-{context['suffix']}"
db = SessionLocal()
try:
user = db.query(User).filter(User.id == context["owner"].id).one()
user.huihui_token = "test-huihui-token"
db.add(TokenPaymentOrder(
order_no=order_no, provider_order_no=provider_no,
user_id=user.id, plan_id="Basic", payment_method="wechat",
pay_type="WECHAT", pay_way="LITE", provider="huihui_org",
points_amount=2_000_000, price_cents=100, status="pending",
))
db.commit()
finally:
db.close()
secret = "abcdefghijklmnop"
path = "/api/token/payment/huihui/org/notify"
payload = {
"orderNo": provider_no, "paymentNo": payment_no,
"status": "SUCCESS", "amountCents": 100,
"productId": "Basic",
}
provider_data = {
"orderNo": provider_no, "paymentNo": payment_no,
"payStatus": 1, "chargeType": 9, "chargeAmount": 1,
"productId": "Basic", "userId": context["owner"].huihui_user_id,
}
def send(body_payload, *, signature=True, callback_path=path):
body = json.dumps(body_payload, separators=(",", ":")).encode()
headers = _huihui_virtual_headers(secret, body) if signature else {"X-Huihui-Signature": "bad"}
return client.post(callback_path, content=body, headers=headers)
with patch.dict(os.environ, {
"HUIHUI_ORG_CALLBACK_SECRET": secret,
"HUIHUI_VIRTUAL_CALLBACK_SECRET": "",
}), patch(
"routers.tokens.HuihuiOrgPaymentClient"
) as client_factory:
provider = client_factory.return_value
provider.get_order.return_value = provider_data
assert send(payload, signature=False).status_code == 401
legacy_path = "/api/token/payment/huihui/virtual/notify"
assert send(payload, signature=False, callback_path=legacy_path).status_code == 401
assert send({**payload, "orderNo": "unknown"}, callback_path=legacy_path).status_code == 404
assert send({**payload, "amountCents": 99}).status_code == 422
pending = send(payload, callback_path=legacy_path)
assert pending.status_code == 200
assert pending.json()["data"] == {"received": True, "paid": False, "awaitingProvider": True}
provider.get_order.assert_not_called()
db = SessionLocal()
try:
assert db.get(HuihuiOrgPaymentNotice, order_no).status == "awaiting_provider"
assert db.query(TokenPaymentOrder).filter(TokenPaymentOrder.order_no == order_no).one().status == "pending"
finally:
db.close()
assert send({**payload, "paymentNo": "wrong"}).status_code == 409
db = SessionLocal()
try:
notice = db.get(HuihuiOrgPaymentNotice, order_no)
notice.next_check_at = datetime.utcnow() - timedelta(seconds=1)
db.commit()
from routers.tokens import reconcile_huihui_org_notices
provider.get_order.return_value = {**provider_data, "payStatus": 2, "chargeAmount": 2}
assert reconcile_huihui_org_notices(db) == 0
assert db.get(HuihuiOrgPaymentNotice, order_no).status == "rejected"
assert db.query(TokenPaymentOrder).filter(TokenPaymentOrder.order_no == order_no).one().status == "pending"
assert send(payload).status_code == 200
notice.next_check_at = datetime.utcnow() - timedelta(seconds=1)
db.commit()
provider.get_order.return_value = provider_data
assert reconcile_huihui_org_notices(db) == 0
assert db.get(HuihuiOrgPaymentNotice, order_no).attempts == 1
assert db.query(TokenPaymentOrder).filter(TokenPaymentOrder.order_no == order_no).one().status == "pending"
notice.next_check_at = datetime.utcnow() - timedelta(seconds=1)
db.commit()
provider.get_order.return_value = {**provider_data, "payStatus": 2}
assert reconcile_huihui_org_notices(db) == 1
assert db.get(HuihuiOrgPaymentNotice, order_no).status == "confirmed"
finally:
db.close()
first = send(payload, callback_path=legacy_path)
second = send(payload)
assert first.status_code == 200
assert first.json()["data"] == {"received": True, "duplicate": True}
assert second.json()["data"] == {"received": True, "duplicate": True}
db = SessionLocal()
try:
order = db.query(TokenPaymentOrder).filter(TokenPaymentOrder.order_no == order_no).one()
account = db.query(TokenAccount).filter(TokenAccount.user_id == context["owner"].id).one()
assert order.status == "paid"
assert account.balance == DEFAULT_TOKEN_GRANT + 2_000_000
assert provider.get_order.call_count >= 3
finally:
db.close()
def _signature(token, timestamp, nonce): def _signature(token, timestamp, nonce):
return hashlib.sha1("".join(sorted([token, timestamp, nonce])).encode()).hexdigest() return hashlib.sha1("".join(sorted([token, timestamp, nonce])).encode()).hexdigest()
@@ -26,8 +194,7 @@ def test_virtual_payment_callback_and_refund_are_idempotent(authorization_contex
user = db.query(User).filter(User.id == context["owner"].id).one() user = db.query(User).filter(User.id == context["owner"].id).one()
user.wechat_mp_openid = "openid-flow" user.wechat_mp_openid = "openid-flow"
user.wechat_mp_session_key = "session-flow" user.wechat_mp_session_key = "session-flow"
plan = db.query(TokenPlan).filter(TokenPlan.id == "1").one() plan = db.query(TokenPlan).filter(TokenPlan.id == "Basic").one()
plan.virtual_product_id = "points_plan_1"
db.commit() db.commit()
finally: finally:
db.close() db.close()
@@ -43,7 +210,7 @@ def test_virtual_payment_callback_and_refund_are_idempotent(authorization_contex
created = client.post( created = client.post(
"/api/token/charge", "/api/token/charge",
headers=context["owner_headers"], headers=context["owner_headers"],
json={"planId": "1", "paymentMethod": "wechat", "payScene": "LITE"}, json={"planId": "Basic", "paymentMethod": "wechat", "payScene": "LITE"},
).json()["data"] ).json()["data"]
assert created["provider"] == "wechat_virtual" assert created["provider"] == "wechat_virtual"
params = json.loads(created["payMessage"]) params = json.loads(created["payMessage"])
@@ -55,7 +222,7 @@ def test_virtual_payment_callback_and_refund_are_idempotent(authorization_contex
"OutTradeNo": created["orderNo"], "OutTradeNo": created["orderNo"],
"OpenId": "openid-flow", "OpenId": "openid-flow",
"Env": 1, "Env": 1,
"GoodsInfo": json.dumps({"ProductId": "points_plan_1", "ActualPrice": 1000}), "GoodsInfo": json.dumps({"ProductId": "Basic", "ActualPrice": 1000}),
"WeChatPayInfo": json.dumps({"TransactionId": "wx-transaction-1"}), "WeChatPayInfo": json.dumps({"TransactionId": "wx-transaction-1"}),
} }
query = {"timestamp": "100", "nonce": "nonce", "signature": _signature("callback-token", "100", "nonce")} query = {"timestamp": "100", "nonce": "nonce", "signature": _signature("callback-token", "100", "nonce")}
@@ -9,8 +9,8 @@ from services import wechat_virtual_payment as virtual
def test_build_payment_params_signs_the_exact_compact_payload(): def test_build_payment_params_signs_the_exact_compact_payload():
order = SimpleNamespace(order_no="AV202609080001", plan_id="plan-1", price_cents=1000) order = SimpleNamespace(order_no="AV202609080001", plan_id="Basic", price_cents=1000)
plan = SimpleNamespace(id="plan-1", virtual_product_id="points_plan_1") plan = SimpleNamespace(id="Basic", virtual_product_id="stale-product-id")
env = { env = {
"WECHAT_VIRTUAL_ENV": "sandbox", "WECHAT_VIRTUAL_ENV": "sandbox",
"WECHAT_VIRTUAL_SANDBOX_APP_KEY": "sandbox-key", "WECHAT_VIRTUAL_SANDBOX_APP_KEY": "sandbox-key",
@@ -25,10 +25,10 @@ def test_build_payment_params_signs_the_exact_compact_payload():
"buyQuantity": 1, "buyQuantity": 1,
"env": 1, "env": 1,
"currencyType": "CNY", "currencyType": "CNY",
"productId": "points_plan_1", "productId": "Basic",
"goodsPrice": 1000, "goodsPrice": 1000,
"outTradeNo": "AV202609080001", "outTradeNo": "AV202609080001",
"attach": '{"orderNo":"AV202609080001","planId":"plan-1"}', "attach": '{"orderNo":"AV202609080001","planId":"Basic"}',
}, ensure_ascii=False, separators=(",", ":")) }, ensure_ascii=False, separators=(",", ":"))
assert result["paySig"] == hmac.new( assert result["paySig"] == hmac.new(
b"sandbox-key", f"requestVirtualPayment&{sign_data}".encode(), hashlib.sha256 b"sandbox-key", f"requestVirtualPayment&{sign_data}".encode(), hashlib.sha256
@@ -1,5 +1,7 @@
# 数字分身 H5 生产接入与部署 # 数字分身 H5 生产接入与部署
测试环境入口为 `https://fat-digital.99hui.com/`;测试支付收银台使用 `https://fat-open.99hui.com/huihui-h5/`,生产支付收银台使用 `https://99hui.com/huihui-h5/`。两套环境的订单和验签配置必须隔离,密钥只保存在各自后端配置中,不写入前端或仓库。
## 1. 接入方式 ## 1. 接入方式
生产会会在用户已登录后打开以下地址: 生产会会在用户已登录后打开以下地址:
@@ -56,10 +58,9 @@ WECHAT_VIRTUAL_SANDBOX_APP_KEY=<沙箱AppKey>
WECHAT_VIRTUAL_APP_KEY=<正式AppKey> WECHAT_VIRTUAL_APP_KEY=<正式AppKey>
WECHAT_VIRTUAL_OFFER_ID=<offer-id> WECHAT_VIRTUAL_OFFER_ID=<offer-id>
WECHAT_VIRTUAL_CALLBACK_TOKEN=<回调校验Token> WECHAT_VIRTUAL_CALLBACK_TOKEN=<回调校验Token>
WECHAT_VIRTUAL_PRODUCT_1=<10元套餐商品ID> HUIHUI_ORG_CALLBACK_SECRET=<会会组织充值支付结果通知验签密钥>
WECHAT_VIRTUAL_PRODUCT_2=<100元套餐商品ID> HUIHUI_VIRTUAL_CALLBACK_SECRET=<微信直连虚拟支付转发通知验签密钥>
WECHAT_VIRTUAL_PRODUCT_3=<1000元套餐商品ID> # 商品 ID 直接使用套餐 ID:Basic、Standard、Professional、Enterprise
WECHAT_VIRTUAL_PRODUCT_4=<10000元套餐商品ID>
AVATAR_DB_DIR=/srv/digital-avatar/data/db AVATAR_DB_DIR=/srv/digital-avatar/data/db
AVATAR_UPLOAD_DIR=/srv/digital-avatar/data/uploads AVATAR_UPLOAD_DIR=/srv/digital-avatar/data/uploads
@@ -90,9 +91,11 @@ CHAT_ATTACHMENT_CLEANUP_MINUTES=60
`EMBEDDING_API_URL` 同时支持 OpenAI 兼容基础地址(如上面的 `/v1`)和完整的 `/v1/embeddings` 地址,后端会统一请求 `/embeddings`。发布后必须在后端容器内执行一次最小向量探针,确认返回向量数量和维度,而不能只检查 `/api/health`。 `EMBEDDING_API_URL` 同时支持 OpenAI 兼容基础地址(如上面的 `/v1`)和完整的 `/v1/embeddings` 地址,后端会统一请求 `/embeddings`。发布后必须在后端容器内执行一次最小向量探针,确认返回向量数量和维度,而不能只检查 `/api/health`。
App 与 H5 积分充值使用会会支付体系的 `payment-v3/payment/pay`,渠道值为 `WECHAT` / `ALIPAY`;App 场景为 `APP`,普通浏览器为 `H5`,微信内 H5 为 `JSAPI`。`HUIHUI_PAYMENT_CALLBACK_SECRET` 只用于为每笔订单生成 HMAC 回调签名,不会发送到前端或直接出现在回调地址中。支付回调确认状态成功且金额与套餐价格完全一致后才增加积分,重复回调不会重复到账。 当前 H5 积分充值调用 `/api/token/charge/org` 创建会会组织充值订单。数字分身后端请求会会 `/order/tokenRecharge` 时,把所选套餐 ID 作为字符串 `productId` 传入(`Basic`、`Standard`、`Professional`、`Enterprise`),再用会会返回的订单号打开 `#/payOrder?id=<会会订单号>&isOrgUrl=true&evn=<入口环境>`;测试环境额外透传会会登录 token,生产环境不传。`id` 是收银台查询订单的编号,不能替换为套餐/道具 ID。普通微信 H5 走 JSAPI,App 和其他 H5 沿用会会普通收银台。支付回调确认状态成功且金额与套餐价格完全一致后才增加积分,重复回调不会重复到账。
微信小程序使用微信虚拟支付:小程序先通过 `POST /api/token/wechat/session` 交换临时登录码,再由 `POST /api/token/charge`(`payScene=LITE`)返回已签名的 `requestVirtualPayment` 参数。微信回调地址配置为 `https://digital.99hui.com/api/token/payment/wechat/virtual/notify`。回调会复核签名、OpenID、环境、商品 ID 与实付金额,退款回调确认后才扣回积分。AppKey、AppSecret、session_key 均不得下发前端或写日志。 微信小程序虚拟支付的服务端接口为:小程序先通过 `POST /api/token/wechat/session` 交换临时登录码,再由 `POST /api/token/charge`(`payScene=LITE`)返回已签名的 `requestVirtualPayment` 参数;其中 `productId` 直接采用套餐 ID(`Basic`、`Standard`、`Professional`、`Enterprise`)。小程序原生支付页仍需调用 `wx.requestVirtualPayment`,不能仅打开上述 H5 收银台 URL 来完成虚拟支付。微信回调地址配置为 `https://digital.99hui.com/api/token/payment/wechat/virtual/notify`。回调会复核签名、OpenID、环境、商品 ID 与实付金额,退款回调确认后才扣回积分。AppKey、AppSecret、session_key 均不得下发前端或写日志。
会会组织充值订单和由会会服务端转发的微信小程序虚拟支付结果,分别使用独立的回调地址及验签密钥,见 [HUIHUI_VIRTUAL_CALLBACK.md](HUIHUI_VIRTUAL_CALLBACK.md)。对于组织订单,回调验签后还必须向会会查单确认实付状态与订单详情。即使接口代码部署到生产,在会会发送格式确认和联调验收前也不得将其视作已上线的支付渠道。
管理后台需要配置相同的 `AVATAR_FINANCE_ADMIN_SECRET` 和 `AVATAR_BACKEND_URL=https://digital.99hui.com`。退款只支持整单原路退款;供应商受理后显示“处理中”,收到渠道成功回调(或经渠道后台核对后人工确认)才将订单置为已退款。已消费掉本订单积分时,后台会拒绝主动退款;若渠道外部退款先发生,积分账户允许形成负数以记录欠额并阻止继续消费。 管理后台需要配置相同的 `AVATAR_FINANCE_ADMIN_SECRET` 和 `AVATAR_BACKEND_URL=https://digital.99hui.com`。退款只支持整单原路退款;供应商受理后显示“处理中”,收到渠道成功回调(或经渠道后台核对后人工确认)才将订单置为已退款。已消费掉本订单积分时,后台会拒绝主动退款;若渠道外部退款先发生,积分账户允许形成负数以记录欠额并阻止继续消费。
@@ -0,0 +1,32 @@
# 会会组织充值支付回调
当前 H5 收银台组织充值订单,对会会提供的测试环境回调地址只有:
`POST https://fat-digital.99hui.com/api/token/payment/huihui/org/notify`
其他历史接收路由仅供服务端兼容旧请求,不作为提供给会会的回调地址,也不应配置为新订单的通知地址。
回调报文及签名协议仍须按双方联调结果确认。以下是本项目实现的接收契约;接口部署与完整支付联调通过是两个独立状态。微信直连虚拟支付回调属于另一条支付链路,不使用本文的组织充值回调地址。
请求体为 JSON,成功支付示例:
```json
{
"orderNo": "26102010414409234218",
"paymentNo": "会会侧唯一支付流水号",
"status": "SUCCESS",
"amountCents": 1000,
"productId": "Basic"
}
```
`amountCents` 单位为分。对于经 `/order/tokenRecharge` 创建的会会组织订单,`orderNo` 可以是会会订单号或数字分身 `AV` 订单号;成功通知必须提供 `amountCents`、`productId` 和 `paymentNo`,无需 `openId`、`env`。服务端会再次向会会查单,要求订单号、支付流水、商品 ID、用户、收费类型、金额一致,且会会支付状态为已付款,才发放积分。非成功结果可发送 `status: "FAILED"`,但不会入账。重复成功通知不会重复发放积分;同一支付流水号不能用于其他订单。
请求头:
- `X-Huihui-Timestamp`:Unix 秒级时间戳,允许与本服务时间相差不超过 300 秒。
- `X-Huihui-Signature`:小写十六进制 HMAC-SHA256,待签名数据为 `时间戳 + 换行符 + 原始请求体字节`。组织充值订单使用服务端配置的 `HUIHUI_ORG_CALLBACK_SECRET`(至少 16 字符)。不要重排 JSON 后签名,也不要把密钥发给前端。
成功通知经签名及本地订单信息核对后,先持久保存并立即返回 HTTP 200、`{"received":true,"paid":false,"awaitingProvider":true}`;回调入口**不等待会会查单**,也**不立即发放积分**。后台仅针对已保存的通知做有上限的延迟复核,待会会查单确认已支付、订单号与支付流水等字段一致后才入账。已入账订单的重复通知返回 `duplicate: true`,不会重复发放积分。验签、字段、订单或金额不符返回 HTTP 4xx。服务端未配置密钥时返回 HTTP 503。
上线前需由会会确认:其服务端是否能按此格式签名并发送通知、支付成功状态与支付流水号字段的正式定义、失败通知及重试/超时策略。小程序支付发起和退款仍需独立对接和验收;仅完成此回调不代表虚拟支付全流程已可用。
+9 -1
View File
@@ -156,7 +156,9 @@ export interface TokenPaymentOrder {
pointsAmount: number pointsAmount: number
price: number price: number
status: 'pending' | 'paid' | 'failed' | 'closed' | 'refunded' status: 'pending' | 'paid' | 'failed' | 'closed' | 'refunded'
provider: 'huihui' | 'wechat_virtual' provider: 'huihui' | 'wechat_virtual' | 'huihui_org'
providerOrderNo?: string
checkoutToken?: string
providerStatus: string providerStatus: string
payMessage: string payMessage: string
failureReason: string failureReason: string
@@ -196,6 +198,12 @@ export const chargeToken = (
payScene: 'APP' | 'H5' | 'LITE' | 'JSAPI' payScene: 'APP' | 'H5' | 'LITE' | 'JSAPI'
) => request.post<TokenPaymentOrder>('/token/charge', { planId, paymentMethod, payScene }) ) => request.post<TokenPaymentOrder>('/token/charge', { planId, paymentMethod, payScene })
export const chargeTokenOrg = (
planId: string,
paymentMethod: 'wechat' | 'alipay',
payScene: 'APP' | 'H5' | 'LITE' | 'JSAPI'
) => request.post<TokenPaymentOrder>('/token/charge/org', { planId, paymentMethod, payScene })
export const getTokenPaymentStatus = (orderId: string) => export const getTokenPaymentStatus = (orderId: string) =>
request.get<TokenPaymentOrder>(`/token/payment/${orderId}`) request.get<TokenPaymentOrder>(`/token/payment/${orderId}`)
+2
View File
@@ -15,6 +15,8 @@ app.use(pinia)
// —— 混合架构:在挂载前注入 uniapp 壳传入的认证与会会资料 —— // —— 混合架构:在挂载前注入 uniapp 壳传入的认证与会会资料 ——
const params = getLaunchParams() const params = getLaunchParams()
if (params.evn !== undefined) sessionStorage.setItem('hh_launch_evn', params.evn)
else if (params.token) sessionStorage.removeItem('hh_launch_evn')
const avatarStore = useAvatarStore(pinia) const avatarStore = useAvatarStore(pinia)
const userStore = useUserStore(pinia) const userStore = useUserStore(pinia)
@@ -12,15 +12,16 @@ export interface UniLaunchParams {
nickname?: string nickname?: string
avatar?: string avatar?: string
ts?: string ts?: string
evn?: string
} }
const PARAM_KEYS: (keyof UniLaunchParams)[] = ['token', 'userId', 'nickname', 'avatar', 'ts'] const PARAM_KEYS: (keyof UniLaunchParams)[] = ['token', 'userId', 'nickname', 'avatar', 'ts', 'evn']
function readParams(search: string, target: UniLaunchParams): void { function readParams(search: string, target: UniLaunchParams): void {
const sp = new URLSearchParams(search) const sp = new URLSearchParams(search)
for (const key of PARAM_KEYS) { for (const key of PARAM_KEYS) {
const value = sp.get(key) const value = sp.get(key)
if (value) target[key] = value if (value || (key === 'evn' && sp.has(key))) target[key] = value ?? ''
} }
} }
@@ -20,15 +20,15 @@
</div> </div>
</section> </section>
<!-- 积分余额条:暂时隐藏,保留完整实现便于后续恢复。 --> <!-- 已登录用户均可查看积分余额并进入充值页。 -->
<section v-if="SHOW_POINTS_BALANCE_CARD" class="token-section"> <section class="token-section">
<div class="token-card"> <div class="token-card">
<div class="token-info"> <div class="token-info">
<span class="token-label">积分余额</span> <span class="token-label">积分余额</span>
<span class="token-amount">{{ tokenBalance.toLocaleString() }}</span> <span class="token-amount">{{ tokenBalance.toLocaleString() }}</span>
<span class="token-used">累计使用 {{ tokenConsumed.toLocaleString() }}</span> <span class="token-used">累计使用 {{ tokenConsumed.toLocaleString() }}</span>
</div> </div>
<button class="recharge-btn" @click="goToRecharge">充值</button> <button class="recharge-btn" @click="goToRecharge">充值购买</button>
</div> </div>
</section> </section>
@@ -95,9 +95,6 @@ const avatarStore = useAvatarStore()
const userStore = useUserStore() const userStore = useUserStore()
const isEmbedded = isHuihuiEmbeddedMode() const isEmbedded = isHuihuiEmbeddedMode()
// 临时产品开关:余额卡片代码保留,后续改为 true 即可恢复展示。
const SHOW_POINTS_BALANCE_CARD = false
// 当前登录会会用户的资料(头像 / 昵称) // 当前登录会会用户的资料(头像 / 昵称)
const me = computed(() => userStore.user) const me = computed(() => userStore.user)
+20 -173
View File
@@ -37,29 +37,7 @@
</div> </div>
</section> </section>
<!-- 支付方式 -->
<section class="payment-section"> <section class="payment-section">
<h3 class="section-title">支付方式</h3>
<div class="payment-list">
<div
class="payment-card"
:class="{ selected: paymentMethod === 'wechat' }"
@click="paymentMethod = 'wechat'"
>
<span class="payment-icon wechat-icon"><img :src="wechatPayIcon" alt="" /></span>
<span class="payment-name">微信支付</span>
<span class="payment-check" v-if="paymentMethod === 'wechat'">✓</span>
</div>
<div
class="payment-card"
:class="{ selected: paymentMethod === 'alipay' }"
@click="paymentMethod = 'alipay'"
>
<span class="payment-icon alipay-icon"><img :src="alipayIcon" alt="" /></span>
<span class="payment-name">支付宝</span>
<span class="payment-check" v-if="paymentMethod === 'alipay'">✓</span>
</div>
</div>
<p v-if="paymentNotice" class="payment-notice" :class="paymentNoticeTone">{{ paymentNotice }}</p> <p v-if="paymentNotice" class="payment-notice" :class="paymentNoticeTone">{{ paymentNotice }}</p>
</section> </section>
@@ -67,8 +45,8 @@
<section class="checkout-section"> <section class="checkout-section">
<button <button
class="checkout-btn" class="checkout-btn"
:class="{ disabled: !selectedPlan || charging || Boolean(pendingOrderId) }" :class="{ disabled: !selectedPlan || charging }"
:disabled="!selectedPlan || charging || Boolean(pendingOrderId)" :disabled="!selectedPlan || charging"
@click="doCharge" @click="doCharge"
> >
{{ checkoutLabel }} {{ checkoutLabel }}
@@ -107,20 +85,17 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { computed, onMounted, onUnmounted, ref } from 'vue' import { computed, onMounted, ref } from 'vue'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import { import {
chargeToken, chargeTokenOrg,
applyTokenInvoice, applyTokenInvoice,
getRechargePlans, getRechargePlans,
getTokenBalance, getTokenBalance,
getTokenOrders, getTokenOrders,
getTokenPaymentStatus,
type TokenPaymentOrder type TokenPaymentOrder
} from '@/api' } from '@/api'
import { isInUniWebView, onNativeMessage, UniEvents } from '@/utils/uniapp-bridge' import { isInUniWebView } from '@/utils/uniapp-bridge'
import wechatPayIcon from '@/assets/wechat-pay.svg'
import alipayIcon from '@/assets/alipay.svg'
const router = useRouter() const router = useRouter()
@@ -141,10 +116,8 @@ const plans = ref<Array<{
const selectedPlan = ref<any>(null) const selectedPlan = ref<any>(null)
// 支付方式 // 支付方式
const paymentMethod = ref<'wechat' | 'alipay'>('wechat')
const paymentNotice = ref('') const paymentNotice = ref('')
const paymentNoticeTone = ref<'pending' | 'success' | 'error'>('pending') const paymentNoticeTone = ref<'pending' | 'success' | 'error'>('pending')
const pendingOrderId = ref(sessionStorage.getItem('hh_pending_payment_order') || '')
const recentOrders = ref<Array<TokenPaymentOrder & { invoice?: any }>>([]) const recentOrders = ref<Array<TokenPaymentOrder & { invoice?: any }>>([])
const invoiceOrder = ref<(TokenPaymentOrder & { invoice?: any }) | null>(null) const invoiceOrder = ref<(TokenPaymentOrder & { invoice?: any }) | null>(null)
const invoiceType = ref<'personal' | 'company'>('personal') const invoiceType = ref<'personal' | 'company'>('personal')
@@ -153,9 +126,6 @@ const invoiceTaxNumber = ref('')
const invoiceEmail = ref('') const invoiceEmail = ref('')
const invoiceError = ref('') const invoiceError = ref('')
const invoiceSubmitting = ref(false) const invoiceSubmitting = ref(false)
let pollTimer: number | undefined
let pollDeadline = 0
let removeNativeListener: (() => void) | undefined
// 从后端加载余额与套餐 // 从后端加载余额与套餐
const loadData = async () => { const loadData = async () => {
@@ -230,135 +200,38 @@ const formatPointsCopy = (value: string) => value.replace(/\bToken\b/gi, '积分
const checkoutLabel = computed(() => { const checkoutLabel = computed(() => {
if (!selectedPlan.value) return '请选择充值套餐' if (!selectedPlan.value) return '请选择充值套餐'
if (charging.value) return '正在创建支付订单…' if (charging.value) return '正在创建支付订单…'
if (pendingOrderId.value) return '等待支付结果…' return '去支付'
return `立即支付 ¥${selectedPlan.value.price}`
}) })
const payScene = () => { const payScene = () => {
if (/miniProgram/i.test(navigator.userAgent)) return 'LITE' as const
if (isInUniWebView()) return 'APP' as const if (isInUniWebView()) return 'APP' as const
if (paymentMethod.value === 'wechat' && /MicroMessenger/i.test(navigator.userAgent)) return 'JSAPI' as const if (/MicroMessenger/i.test(navigator.userAgent)) return 'JSAPI' as const
return 'H5' as const return 'H5' as const
} }
const parsePayMessage = (message: string) => {
const text = (message || '').trim()
if (!text) return null
try {
return JSON.parse(text)
} catch {
return text
}
}
const findPaymentUrl = (value: any): string => {
if (typeof value === 'string' && /^https?:\/\//i.test(value)) return value
if (!value || typeof value !== 'object') return ''
for (const key of ['url', 'payUrl', 'mwebUrl', 'mweb_url', 'redirectUrl']) {
if (typeof value[key] === 'string' && /^https?:\/\//i.test(value[key])) return value[key]
}
return ''
}
const invokeWechatJsapi = (params: Record<string, any>) => new Promise<void>((resolve, reject) => {
const invoke = () => {
const bridge = (window as any).WeixinJSBridge
if (!bridge?.invoke) {
reject(new Error('当前微信环境无法拉起支付'))
return
}
bridge.invoke('getBrandWCPayRequest', params, (result: any) => {
const message = String(result?.err_msg || result?.errMsg || '')
if (/\bok\b/i.test(message)) resolve()
else reject(new Error(/cancel/i.test(message) ? '支付已取消' : '微信支付未完成'))
})
}
if ((window as any).WeixinJSBridge) invoke()
else document.addEventListener('WeixinJSBridgeReady', invoke, { once: true })
})
const launchPayment = async (order: TokenPaymentOrder) => {
const parsed = parsePayMessage(order.payMessage)
if (order.payWay === 'JSAPI' && order.paymentMethod === 'wechat' && parsed && typeof parsed === 'object') {
await invokeWechatJsapi(parsed)
return
}
if (isInUniWebView() && UniEvents.payment({
orderId: order.id,
orderNo: order.orderNo,
paymentMethod: order.paymentMethod,
payType: order.payType,
payWay: order.payWay,
payMessage: order.payMessage,
paymentParams: parsed
})) return
const paymentUrl = findPaymentUrl(parsed)
if (paymentUrl) {
window.location.assign(paymentUrl)
return
}
throw new Error('请在会会 App 内完成支付')
}
const clearPendingOrder = () => {
pendingOrderId.value = ''
sessionStorage.removeItem('hh_pending_payment_order')
if (pollTimer) window.clearTimeout(pollTimer)
pollTimer = undefined
}
const pollPayment = async () => {
if (!pendingOrderId.value) return
try {
const order = await getTokenPaymentStatus(pendingOrderId.value)
if (order.status === 'paid') {
currentBalance.value = order.balance
paymentNoticeTone.value = 'success'
paymentNotice.value = `支付成功,${order.pointsAmount.toLocaleString()} 积分已到账`
clearPendingOrder()
void loadData()
return
}
if (order.status === 'failed') {
paymentNoticeTone.value = 'error'
paymentNotice.value = order.failureReason || '支付未完成,请重新发起'
clearPendingOrder()
return
}
} catch (error) {
console.error('查询支付结果失败', error)
}
if (Date.now() < pollDeadline) pollTimer = window.setTimeout(pollPayment, 2000)
else {
paymentNoticeTone.value = 'pending'
paymentNotice.value = '支付结果仍在确认中,可稍后刷新余额或重新发起'
clearPendingOrder()
}
}
const startPolling = (orderId: string) => {
pendingOrderId.value = orderId
sessionStorage.setItem('hh_pending_payment_order', orderId)
pollDeadline = Date.now() + 2 * 60 * 1000
if (pollTimer) window.clearTimeout(pollTimer)
void pollPayment()
}
const doCharge = async () => { const doCharge = async () => {
if (!selectedPlan.value || charging.value) return if (!selectedPlan.value || charging.value) return
charging.value = true charging.value = true
paymentNotice.value = '' paymentNotice.value = ''
try { try {
const order = await chargeToken(selectedPlan.value.id, paymentMethod.value, payScene()) const order = await chargeTokenOrg(selectedPlan.value.id, 'wechat', payScene())
if (order.status === 'failed') throw new Error(order.failureReason || '支付订单创建失败') if (order.status === 'failed') throw new Error(order.failureReason || '支付订单创建失败')
if (!order.providerOrderNo || !/^\d+$/.test(order.providerOrderNo)) throw new Error('会会充值订单号缺失')
paymentNoticeTone.value = 'pending' paymentNoticeTone.value = 'pending'
paymentNotice.value = '支付订单已创建,正在等待付款结果' paymentNotice.value = '支付订单已创建,支付结果将由会会回调确认'
startPolling(order.id) const checkoutBase = window.location.hostname === 'digital.99hui.com'
await launchPayment(order) ? 'https://99hui.com/huihui-h5/'
: 'https://fat-open.99hui.com/huihui-h5/'
const evn = sessionStorage.getItem('hh_launch_evn') || 'wx'
const evnPart = `&evn=${encodeURIComponent(evn)}`
const tokenPart = order.checkoutToken
? `&token=${encodeURIComponent(order.checkoutToken)}`
: ''
window.location.assign(`${checkoutBase}#/payOrder?id=${encodeURIComponent(order.providerOrderNo)}&isOrgUrl=true${tokenPart}${evnPart}`)
} catch (error: any) { } catch (error: any) {
paymentNoticeTone.value = 'error' paymentNoticeTone.value = 'error'
paymentNotice.value = error?.message || '充值失败,请重试' paymentNotice.value = error?.message || '充值失败,请重试'
clearPendingOrder()
} finally { } finally {
charging.value = false charging.value = false
} }
@@ -371,32 +244,6 @@ const goBack = () => {
onMounted(() => { onMounted(() => {
void loadData() void loadData()
removeNativeListener = onNativeMessage((message) => {
if (message?.type !== 'paymentResult') return
if (message.orderId && message.orderId !== pendingOrderId.value) return
if (['cancelled', 'canceled'].includes(String(message.status).toLowerCase())) {
paymentNoticeTone.value = 'error'
paymentNotice.value = '支付已取消'
clearPendingOrder()
return
}
if (String(message.status).toLowerCase() === 'failed') {
paymentNoticeTone.value = 'error'
paymentNotice.value = message.message || '支付未完成,请重新发起'
clearPendingOrder()
return
}
void pollPayment()
})
if (pendingOrderId.value) {
paymentNotice.value = '正在确认上一笔支付结果'
startPolling(pendingOrderId.value)
}
})
onUnmounted(() => {
if (pollTimer) window.clearTimeout(pollTimer)
removeNativeListener?.()
}) })
</script> </script>
+3 -2
View File
@@ -19,14 +19,15 @@ services:
- AES_KEY=your-aes-key-32-chars-change-now! - AES_KEY=your-aes-key-32-chars-change-now!
- AVATAR_MODEL_CONFIG_TOKEN=${AVATAR_MODEL_CONFIG_TOKEN:-} - AVATAR_MODEL_CONFIG_TOKEN=${AVATAR_MODEL_CONFIG_TOKEN:-}
- TZ=Asia/Shanghai - TZ=Asia/Shanghai
- AVATAR_DB_PATH=/app/avatar.db - AVATAR_DB_PATH=/data/avatar-db/avatar.db
- AVATAR_BACKEND_URL=${AVATAR_BACKEND_URL:-} - AVATAR_BACKEND_URL=${AVATAR_BACKEND_URL:-}
- AVATAR_FINANCE_ADMIN_SECRET=${AVATAR_FINANCE_ADMIN_SECRET:-} - AVATAR_FINANCE_ADMIN_SECRET=${AVATAR_FINANCE_ADMIN_SECRET:-}
volumes: volumes:
- ./backend/app:/app/app # ← 核心:代码目录直接挂载,改文件无需重建 - ./backend/app:/app/app # ← 核心:代码目录直接挂载,改文件无需重建
- ./backend/logs:/app/logs - ./backend/logs:/app/logs
- ./backend/config:/app/config - ./backend/config:/app/config
- ./digital-avatar-app/backend/avatar.db:/app/avatar.db # 财务管理需要写入退款与开票状态 # Mount the directory so admin and avatar services share SQLite WAL/SHM files too.
- ./digital-avatar-app/backend:/data/avatar-db
depends_on: depends_on:
- ai-virtual-mysql - ai-virtual-mysql
- ai-virtual-redis - ai-virtual-redis
+2 -3
View File
@@ -96,11 +96,10 @@ export const uploadAvatarPhoto = (id, formData) => request.post(`/avatars/${id}/
// Finance (数字分身积分订单) // Finance (数字分身积分订单)
export const getFinanceSummary = () => request.get('/finance/summary') export const getFinanceSummary = () => request.get('/finance/summary')
export const getFinancePlans = () => request.get('/finance/plans')
export const updateFinancePlan = (id, data) => request.patch(`/finance/plans/${id}`, data)
export const getFinanceOrders = (params) => request.get('/finance/orders', { params }) export const getFinanceOrders = (params) => request.get('/finance/orders', { params })
export const updateFinanceOrderStatus = (orderNo, data) => request.patch(`/finance/orders/${orderNo}/status`, data) export const updateFinanceOrderStatus = (orderNo, data) => request.patch(`/finance/orders/${orderNo}/status`, data)
export const requestFinanceRefund = (orderNo, data) => request.post(`/finance/orders/${orderNo}/refund`, data)
export const getFinanceRefunds = (params) => request.get('/finance/refunds', { params })
export const confirmFinanceRefund = (refundNo, data) => request.post(`/finance/refunds/${refundNo}/confirm`, data)
export const getFinanceInvoices = (params) => request.get('/finance/invoices', { params }) export const getFinanceInvoices = (params) => request.get('/finance/invoices', { params })
export const updateFinanceInvoice = (invoiceId, data) => request.patch(`/finance/invoices/${invoiceId}`, data) export const updateFinanceInvoice = (invoiceId, data) => request.patch(`/finance/invoices/${invoiceId}`, data)
+34 -32
View File
@@ -11,11 +11,20 @@
<div class="summary-grid"> <div class="summary-grid">
<div class="stat-card"><div class="stat-label">实收金额</div><div class="stat-value">¥{{ money(summary.paid_revenue) }}</div><small>{{ summary.paid_orders }} 笔已支付</small></div> <div class="stat-card"><div class="stat-label">实收金额</div><div class="stat-value">¥{{ money(summary.paid_revenue) }}</div><small>{{ summary.paid_orders }} 笔已支付</small></div>
<div class="stat-card"><div class="stat-label">待支付订单</div><div class="stat-value warning">{{ summary.pending_orders }}</div><small>可关闭或标记失败</small></div> <div class="stat-card"><div class="stat-label">待支付订单</div><div class="stat-value warning">{{ summary.pending_orders }}</div><small>可关闭或标记失败</small></div>
<div class="stat-card"><div class="stat-label">处理中退款</div><div class="stat-value danger">{{ summary.processing_refunds }}</div><small>等待支付渠道确认</small></div>
<div class="stat-card"><div class="stat-label">待开发票</div><div class="stat-value success">{{ summary.pending_invoices }}</div><small>等待财务处理</small></div> <div class="stat-card"><div class="stat-label">待开发票</div><div class="stat-value success">{{ summary.pending_invoices }}</div><small>等待财务处理</small></div>
</div> </div>
<el-tabs v-model="activeTab" class="finance-tabs" @tab-change="loadActive"> <el-tabs v-model="activeTab" class="finance-tabs" @tab-change="loadActive">
<el-tab-pane label="充值套餐" name="plans">
<el-table :data="plans" v-loading="loading" class="data-table">
<el-table-column label="套餐名称" prop="name" min-width="150" />
<el-table-column label="积分" min-width="160"><template #default="{ row }">{{ Number(row.amount).toLocaleString() }}</template></el-table-column>
<el-table-column label="价格" width="140"><template #default="{ row }">¥{{ money(row.price) }}</template></el-table-column>
<el-table-column label="标签" prop="badge" min-width="120" />
<el-table-column label="说明" prop="desc" min-width="180" />
<el-table-column label="操作" width="100"><template #default="{ row }"><el-button link type="primary" @click="editPlan(row)">调整</el-button></template></el-table-column>
</el-table>
</el-tab-pane>
<el-tab-pane label="支付订单" name="orders"> <el-tab-pane label="支付订单" name="orders">
<div class="filter-bar"> <div class="filter-bar">
<el-input v-model="filters.keyword" placeholder="订单号、昵称或手机号" clearable style="width:240px" @keyup.enter="loadOrders" /> <el-input v-model="filters.keyword" placeholder="订单号、昵称或手机号" clearable style="width:240px" @keyup.enter="loadOrders" />
@@ -23,13 +32,13 @@
<el-option v-for="item in orderStatuses" :key="item.value" :label="item.label" :value="item.value" /> <el-option v-for="item in orderStatuses" :key="item.value" :label="item.label" :value="item.value" />
</el-select> </el-select>
<el-select v-model="filters.provider" placeholder="支付渠道" clearable style="width:150px" @change="loadOrders"> <el-select v-model="filters.provider" placeholder="支付渠道" clearable style="width:150px" @change="loadOrders">
<el-option label="会会支付" value="huihui" /><el-option label="微信虚拟支付" value="wechat_virtual" /> <el-option label="会会支付" value="huihui" /><el-option label="会会组织支付" value="huihui_org" /><el-option label="微信虚拟支付" value="wechat_virtual" />
</el-select> </el-select>
<el-button type="primary" @click="loadOrders">查询</el-button> <el-button type="primary" @click="loadOrders">查询</el-button>
</div> </div>
<el-table :data="orders" v-loading="loading" class="data-table"> <el-table :data="orders" v-loading="loading" class="data-table">
<el-table-column label="订单 / 用户" min-width="230"> <el-table-column label="订单 / 用户" min-width="260">
<template #default="{ row }"><b>{{ row.order_no }}</b><div class="muted">{{ row.user_nickname || '会会用户' }} · {{ row.user_phone || '--' }}</div></template> <template #default="{ row }"><b>{{ row.order_no }}</b><div class="muted">支付编号:{{ row.provider_order_no || '--' }}</div><div class="muted">{{ row.user_nickname || '会会用户' }} · {{ row.user_phone || '--' }}</div></template>
</el-table-column> </el-table-column>
<el-table-column label="套餐积分" width="130"><template #default="{ row }">{{ Number(row.points_amount || 0).toLocaleString() }}</template></el-table-column> <el-table-column label="套餐积分" width="130"><template #default="{ row }">{{ Number(row.points_amount || 0).toLocaleString() }}</template></el-table-column>
<el-table-column label="金额" width="100"><template #default="{ row }">¥{{ money(row.price) }}</template></el-table-column> <el-table-column label="金额" width="100"><template #default="{ row }">¥{{ money(row.price) }}</template></el-table-column>
@@ -39,33 +48,16 @@
<el-table-column label="下单时间" min-width="165"><template #default="{ row }">{{ dateTime(row.created_at) }}</template></el-table-column> <el-table-column label="下单时间" min-width="165"><template #default="{ row }">{{ dateTime(row.created_at) }}</template></el-table-column>
<el-table-column label="操作" width="165" fixed="right"> <el-table-column label="操作" width="165" fixed="right">
<template #default="{ row }"> <template #default="{ row }">
<el-button v-if="row.status === 'paid' && ['none','',null].includes(row.refund_status)" link type="danger" @click="refund(row)">退款</el-button>
<el-dropdown v-if="row.status === 'pending'" @command="command => closeOrder(row, command)"> <el-dropdown v-if="row.status === 'pending'" @command="command => closeOrder(row, command)">
<el-button link type="warning">状态处理<el-icon><ArrowDown /></el-icon></el-button> <el-button link type="warning">状态处理<el-icon><ArrowDown /></el-icon></el-button>
<template #dropdown><el-dropdown-menu><el-dropdown-item command="closed">关闭订单</el-dropdown-item><el-dropdown-item command="failed">标记失败</el-dropdown-item></el-dropdown-menu></template> <template #dropdown><el-dropdown-menu><el-dropdown-item command="closed">关闭订单</el-dropdown-item><el-dropdown-item command="failed">标记失败</el-dropdown-item></el-dropdown-menu></template>
</el-dropdown> </el-dropdown>
<span v-if="row.status !== 'pending' && !(row.status === 'paid' && ['none','',null].includes(row.refund_status))" class="muted">已处理</span>
</template> </template>
</el-table-column> </el-table-column>
</el-table> </el-table>
<el-pagination v-model:current-page="orderPage" :page-size="20" :total="orderTotal" layout="total, prev, pager, next" @change="loadOrders" /> <el-pagination v-model:current-page="orderPage" :page-size="20" :total="orderTotal" layout="total, prev, pager, next" @change="loadOrders" />
</el-tab-pane> </el-tab-pane>
<el-tab-pane label="退款管理" name="refunds">
<el-table :data="refunds" v-loading="loading" class="data-table">
<el-table-column label="退款单号" min-width="220" prop="refund_no" />
<el-table-column label="原订单" min-width="210" prop="order_no" />
<el-table-column label="用户" width="150"><template #default="{ row }">{{ row.user_nickname || row.user_phone || '--' }}</template></el-table-column>
<el-table-column label="金额" width="100"><template #default="{ row }">¥{{ money(row.amount) }}</template></el-table-column>
<el-table-column label="状态" width="110"><template #default="{ row }"><el-tag :type="tagType(row.status)">{{ statusLabel[row.status] || row.status }}</el-tag></template></el-table-column>
<el-table-column label="原因" min-width="160" prop="reason" show-overflow-tooltip />
<el-table-column label="创建时间" min-width="165"><template #default="{ row }">{{ dateTime(row.created_at) }}</template></el-table-column>
<el-table-column label="对账确认" width="155" fixed="right">
<template #default="{ row }"><template v-if="['pending','processing'].includes(row.status)"><el-button link type="success" @click="confirmRefund(row, 'succeeded')">已退款</el-button><el-button link type="danger" @click="confirmRefund(row, 'failed')">失败</el-button></template><span v-else class="muted">{{ row.failure_reason || '已完成' }}</span></template>
</el-table-column>
</el-table>
</el-tab-pane>
<el-tab-pane label="发票管理" name="invoices"> <el-tab-pane label="发票管理" name="invoices">
<el-table :data="invoices" v-loading="loading" class="data-table"> <el-table :data="invoices" v-loading="loading" class="data-table">
<el-table-column label="订单号" min-width="210" prop="order_no" /> <el-table-column label="订单号" min-width="210" prop="order_no" />
@@ -87,25 +79,37 @@
</el-form> </el-form>
<template #footer><el-button @click="invoiceDialog=false">取消</el-button><el-button type="primary" :loading="saving" @click="issueInvoice">确认开具</el-button></template> <template #footer><el-button @click="invoiceDialog=false">取消</el-button><el-button type="primary" :loading="saving" @click="issueInvoice">确认开具</el-button></template>
</el-dialog> </el-dialog>
<el-dialog v-model="planDialog" title="调整充值套餐" width="480px">
<el-form label-width="90px">
<el-form-item label="套餐名称"><el-input v-model="planForm.name" maxlength="80" /></el-form-item>
<el-form-item label="积分数量"><el-input-number v-model="planForm.amount" :min="1" :step="10000" /></el-form-item>
<el-form-item label="套餐价格"><el-input-number v-model="planForm.price" :min="0" :precision="2" :step="1" /></el-form-item>
<el-form-item label="标签"><el-input v-model="planForm.badge" maxlength="80" /></el-form-item>
<el-form-item label="说明"><el-input v-model="planForm.desc" maxlength="200" /></el-form-item>
</el-form>
<template #footer><el-button @click="planDialog=false">取消</el-button><el-button type="primary" :loading="saving" @click="savePlan">保存</el-button></template>
</el-dialog>
</div> </div>
</template> </template>
<script setup> <script setup>
import { onMounted, ref } from 'vue' import { onMounted, ref } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus' import { ElMessage, ElMessageBox } from 'element-plus'
import { confirmFinanceRefund, getFinanceInvoices, getFinanceOrders, getFinanceRefunds, getFinanceSummary, requestFinanceRefund, updateFinanceInvoice, updateFinanceOrderStatus } from '@/api' import { getFinanceInvoices, getFinanceOrders, getFinancePlans, getFinanceSummary, updateFinanceInvoice, updateFinanceOrderStatus, updateFinancePlan } from '@/api'
const activeTab = ref('orders') const activeTab = ref('orders')
const loading = ref(false) const loading = ref(false)
const saving = ref(false) const saving = ref(false)
const summary = ref({ paid_revenue: 0, paid_orders: 0, pending_orders: 0, processing_refunds: 0, pending_invoices: 0 }) const summary = ref({ paid_revenue: 0, paid_orders: 0, pending_orders: 0, pending_invoices: 0 })
const orders = ref([]), refunds = ref([]), invoices = ref([]) const orders = ref([]), invoices = ref([])
const plans = ref([]), planDialog = ref(false), currentPlan = ref(null)
const planForm = ref({ name: '', amount: 1, price: 0, badge: '', desc: '' })
const orderPage = ref(1), orderTotal = ref(0) const orderPage = ref(1), orderTotal = ref(0)
const filters = ref({ keyword: '', status: '', provider: '' }) const filters = ref({ keyword: '', status: '', provider: '' })
const invoiceDialog = ref(false), currentInvoice = ref(null) const invoiceDialog = ref(false), currentInvoice = ref(null)
const invoiceForm = ref({ invoiceNo: '', invoiceUrl: '', remark: '' }) const invoiceForm = ref({ invoiceNo: '', invoiceUrl: '', remark: '' })
const orderStatuses = [{ label: '待支付', value: 'pending' }, { label: '已支付', value: 'paid' }, { label: '失败', value: 'failed' }, { label: '已关闭', value: 'closed' }, { label: '已退款', value: 'refunded' }] const orderStatuses = [{ label: '待支付', value: 'pending' }, { label: '已支付', value: 'paid' }, { label: '失败', value: 'failed' }, { label: '已关闭', value: 'closed' }, { label: '已退款', value: 'refunded' }]
const providerLabel = { huihui: '会会支付', wechat_virtual: '微信虚拟支付' } const providerLabel = { huihui: '会会支付', huihui_org: '会会组织支付', wechat_virtual: '微信虚拟支付' }
const statusLabel = { pending: '待处理', processing: '处理中', paid: '已支付', succeeded: '已成功', failed: '失败', closed: '已关闭', refunded: '已退款' } const statusLabel = { pending: '待处理', processing: '处理中', paid: '已支付', succeeded: '已成功', failed: '失败', closed: '已关闭', refunded: '已退款' }
const invoiceStatusLabel = { pending: '待开具', issued: '已开具', rejected: '已驳回' } const invoiceStatusLabel = { pending: '待开具', issued: '已开具', rejected: '已驳回' }
const money = value => Number(value || 0).toFixed(2) const money = value => Number(value || 0).toFixed(2)
@@ -113,17 +117,15 @@ const dateTime = value => value ? new Date(value).toLocaleString('zh-CN', { hour
const tagType = value => ({ paid: 'success', succeeded: 'success', issued: 'success', pending: 'warning', processing: 'warning', failed: 'danger', rejected: 'danger', refunded: 'info', closed: 'info' }[value] || 'info') const tagType = value => ({ paid: 'success', succeeded: 'success', issued: 'success', pending: 'warning', processing: 'warning', failed: 'danger', rejected: 'danger', refunded: 'info', closed: 'info' }[value] || 'info')
async function loadSummary() { const res = await getFinanceSummary(); summary.value = res.data || summary.value } async function loadSummary() { const res = await getFinanceSummary(); summary.value = res.data || summary.value }
async function loadPlans() { const res = await getFinancePlans(); plans.value = res.data || [] }
async function loadOrders() { const res = await getFinanceOrders({ page: orderPage.value, page_size: 20, ...filters.value }); orders.value = res.data?.items || []; orderTotal.value = res.data?.total || 0 } async function loadOrders() { const res = await getFinanceOrders({ page: orderPage.value, page_size: 20, ...filters.value }); orders.value = res.data?.items || []; orderTotal.value = res.data?.total || 0 }
async function loadRefunds() { const res = await getFinanceRefunds({ page: 1, page_size: 100 }); refunds.value = res.data?.items || [] }
async function loadInvoices() { const res = await getFinanceInvoices({ page: 1, page_size: 100 }); invoices.value = res.data?.items || [] } async function loadInvoices() { const res = await getFinanceInvoices({ page: 1, page_size: 100 }); invoices.value = res.data?.items || [] }
async function loadActive() { loading.value = true; try { if (activeTab.value === 'orders') await loadOrders(); if (activeTab.value === 'refunds') await loadRefunds(); if (activeTab.value === 'invoices') await loadInvoices() } finally { loading.value = false } } async function loadActive() { loading.value = true; try { if (activeTab.value === 'plans') await loadPlans(); if (activeTab.value === 'orders') await loadOrders(); if (activeTab.value === 'invoices') await loadInvoices() } finally { loading.value = false } }
async function loadAll() { loading.value = true; try { await Promise.all([loadSummary(), loadOrders(), loadRefunds(), loadInvoices()]) } finally { loading.value = false } } async function loadAll() { loading.value = true; try { await Promise.all([loadSummary(), loadPlans(), loadOrders(), loadInvoices()]) } finally { loading.value = false } }
function editPlan(row) { currentPlan.value = row; planForm.value = { name: row.name, amount: Number(row.amount), price: Number(row.price), badge: row.badge || '', desc: row.desc || '' }; planDialog.value = true }
async function savePlan() { if (!currentPlan.value) return; saving.value = true; try { await updateFinancePlan(currentPlan.value.id, planForm.value); ElMessage.success('充值套餐已更新'); planDialog.value = false; await loadPlans() } finally { saving.value = false } }
async function refund(row) {
try { const { value } = await ElMessageBox.prompt('退款成功后会扣回本订单发放的全部积分。', `退款 ¥${money(row.price)}`, { inputPlaceholder: '请输入退款原因', inputValidator: value => value?.trim() ? true : '退款原因不能为空', confirmButtonText: '提交退款', cancelButtonText: '取消' }); await requestFinanceRefund(row.order_no, { reason: value, operator: '后台管理员' }); ElMessage.success('退款已提交渠道处理'); await loadAll() } catch (error) { if (error !== 'cancel' && error !== 'close') console.error(error) }
}
async function closeOrder(row, status) { try { await ElMessageBox.confirm(`确认将订单标记为“${status === 'closed' ? '已关闭' : '失败'}”?`, '订单状态处理', { type: 'warning' }); await updateFinanceOrderStatus(row.order_no, { status, reason: '后台管理员处理' }); ElMessage.success('订单状态已更新'); await loadAll() } catch {} } async function closeOrder(row, status) { try { await ElMessageBox.confirm(`确认将订单标记为“${status === 'closed' ? '已关闭' : '失败'}”?`, '订单状态处理', { type: 'warning' }); await updateFinanceOrderStatus(row.order_no, { status, reason: '后台管理员处理' }); ElMessage.success('订单状态已更新'); await loadAll() } catch {} }
async function confirmRefund(row, status) { try { const message = status === 'succeeded' ? '仅在支付渠道后台已确认退款到账时操作。' : '确认供应商退款失败?'; await ElMessageBox.confirm(message, '退款对账确认', { type: 'warning' }); await confirmFinanceRefund(row.refund_no, { status, failureReason: status === 'failed' ? '供应商退款失败' : '' }); ElMessage.success('退款结果已登记'); await loadAll() } catch {} }
function openInvoice(row) { currentInvoice.value = row; invoiceForm.value = { invoiceNo: '', invoiceUrl: '', remark: '' }; invoiceDialog.value = true } function openInvoice(row) { currentInvoice.value = row; invoiceForm.value = { invoiceNo: '', invoiceUrl: '', remark: '' }; invoiceDialog.value = true }
async function issueInvoice() { if (!invoiceForm.value.invoiceNo.trim()) return ElMessage.warning('请填写发票号码'); saving.value = true; try { await updateFinanceInvoice(currentInvoice.value.id, { status: 'issued', ...invoiceForm.value }); ElMessage.success('发票已登记'); invoiceDialog.value = false; await loadAll() } finally { saving.value = false } } async function issueInvoice() { if (!invoiceForm.value.invoiceNo.trim()) return ElMessage.warning('请填写发票号码'); saving.value = true; try { await updateFinanceInvoice(currentInvoice.value.id, { status: 'issued', ...invoiceForm.value }); ElMessage.success('发票已登记'); invoiceDialog.value = false; await loadAll() } finally { saving.value = false } }
async function rejectInvoice(row) { try { const { value } = await ElMessageBox.prompt('请输入驳回原因', '驳回发票申请', { inputValidator: value => value?.trim() ? true : '驳回原因不能为空' }); await updateFinanceInvoice(row.id, { status: 'rejected', remark: value }); ElMessage.success('发票申请已驳回'); await loadAll() } catch {} } async function rejectInvoice(row) { try { const { value } = await ElMessageBox.prompt('请输入驳回原因', '驳回发票申请', { inputValidator: value => value?.trim() ? true : '驳回原因不能为空' }); await updateFinanceInvoice(row.id, { status: 'rejected', remark: value }); ElMessage.success('发票申请已驳回'); await loadAll() } catch {} }