Compare commits

...
Author SHA1 Message Date
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
23 changed files with 452 additions and 290 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 = {
+44 -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,24 @@ 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 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 +298,13 @@ 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.start() maintenance_scheduler.start()
+1
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,
+57 -1
View File
@@ -25,6 +25,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,
@@ -45,6 +46,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"}
@@ -270,6 +278,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
@@ -305,7 +314,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)
@@ -405,6 +414,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)
@@ -414,6 +467,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(
@@ -560,6 +614,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)
@@ -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,12 @@ 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 == 2
assert maintenance_scheduler.add_job.call_args.kwargs["id"] == "chat_attachment_cleanup" cleanup_call, close_orders_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
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)
@@ -30,8 +30,7 @@ def test_huihui_virtual_callback_requires_signature_and_exact_order_details(auth
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-huihui-callback" user.wechat_mp_openid = "openid-huihui-callback"
user.wechat_mp_session_key = "session-huihui-callback" user.wechat_mp_session_key = "session-huihui-callback"
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()
@@ -47,14 +46,14 @@ def test_huihui_virtual_callback_requires_signature_and_exact_order_details(auth
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"]
payload = { payload = {
"orderNo": created["orderNo"], "orderNo": created["orderNo"],
"paymentNo": f"huihui-virtual-payment-{context['suffix']}", "paymentNo": f"huihui-virtual-payment-{context['suffix']}",
"status": "SUCCESS", "status": "SUCCESS",
"amountCents": 1000, "amountCents": 1000,
"productId": "points_plan_1", "productId": "Basic",
"openId": "openid-huihui-callback", "openId": "openid-huihui-callback",
"env": 1, "env": 1,
} }
@@ -91,8 +90,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()
@@ -108,7 +106,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"])
@@ -120,7 +118,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,7 @@ 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> # 商品 ID 直接使用套餐 ID:Basic、Standard、Professional、Enterprise
WECHAT_VIRTUAL_PRODUCT_2=<100元套餐商品ID>
WECHAT_VIRTUAL_PRODUCT_3=<1000元套餐商品ID>
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 +89,9 @@ 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)。该接口使用独立的 `HUIHUI_VIRTUAL_CALLBACK_SECRET`;即使接口代码部署到生产,在协议确认和联调验收前也不得将其视作已上线的支付渠道。 如由会会服务端转发微信小程序虚拟支付结果,另有待双方确认的固定回调契约,见 [HUIHUI_VIRTUAL_CALLBACK.md](HUIHUI_VIRTUAL_CALLBACK.md)。该接口使用独立的 `HUIHUI_VIRTUAL_CALLBACK_SECRET`;即使接口代码部署到生产,在协议确认和联调验收前也不得将其视作已上线的支付渠道。
@@ -14,7 +14,7 @@
"paymentNo": "会会侧唯一支付流水号", "paymentNo": "会会侧唯一支付流水号",
"status": "SUCCESS", "status": "SUCCESS",
"amountCents": 1000, "amountCents": 1000,
"productId": "points_plan_1", "productId": "Basic",
"openId": "微信小程序用户 OpenID", "openId": "微信小程序用户 OpenID",
"env": 1 "env": 1
} }
+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 {} }