Compare commits
28
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cd0c6e3162 | ||
|
|
b48e33fc6b | ||
|
|
d9f12685fa | ||
|
|
b3367eedaa | ||
|
|
e6a4988cf9 | ||
|
|
becf2c7c52 | ||
|
|
d421a9da72 | ||
|
|
f4389612d1 | ||
|
|
ac5a332bce | ||
|
|
9ba89ef2d1 | ||
|
|
b1023eb783 | ||
|
|
2f287ef538 | ||
|
|
f5cbbe9eef | ||
|
|
5fc56143ee | ||
|
|
d521585bf2 | ||
|
|
35e47bf0a1 | ||
|
|
f52e42d9c0 | ||
|
|
1ab56ad0b1 | ||
|
|
1889c8ebba | ||
|
|
910a05107a | ||
|
|
857d6f2562 | ||
|
|
5ce12771b9 | ||
|
|
4c152230aa | ||
|
|
9ce46cd883 | ||
|
|
140ac20281 | ||
|
|
9afc2d5a6c | ||
|
|
8585d101d5 | ||
|
|
62eb9578fd |
@@ -9,6 +9,8 @@ backend/logs/
|
||||
# Node
|
||||
frontend/node_modules/
|
||||
frontend/dist/
|
||||
uniapp-avatar/node_modules/
|
||||
uniapp-avatar/dist/
|
||||
|
||||
# macOS
|
||||
.DS_Store
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"""API路由汇总"""
|
||||
from fastapi import APIRouter
|
||||
from app.api.endpoints import users, interactions, ai_models, dashboard, system, logs, avatars
|
||||
from app.api.endpoints import users, interactions, ai_models, dashboard, system, logs, avatars, finance
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@@ -11,3 +11,4 @@ router.include_router(dashboard.router, prefix="/dashboard", tags=["数据看板
|
||||
router.include_router(system.router, prefix="/system", tags=["系统设置"])
|
||||
router.include_router(logs.router, prefix="/logs", tags=["日志管理"])
|
||||
router.include_router(avatars.router, prefix="/avatars", tags=["数字分身管理"])
|
||||
router.include_router(finance.router, prefix="/finance", tags=["财务管理"])
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
"""Admin finance API for avatar Token orders, refunds and invoices."""
|
||||
|
||||
from fastapi import APIRouter, Body, HTTPException, Query
|
||||
|
||||
from app.schemas import ApiResponse
|
||||
from app.services.avatar_service import get_session, is_available
|
||||
from app.services.finance_service import FinanceServiceError, finance_service
|
||||
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def _session():
|
||||
if not is_available():
|
||||
raise HTTPException(status_code=503, detail="数字分身数据库尚未初始化")
|
||||
return get_session()
|
||||
|
||||
|
||||
def _raise(exc: FinanceServiceError):
|
||||
raise HTTPException(status_code=exc.status_code, detail=str(exc))
|
||||
|
||||
|
||||
@router.get("/summary")
|
||||
def summary():
|
||||
db = _session()
|
||||
try:
|
||||
return ApiResponse(data=finance_service.summary(db))
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
@router.get("/orders")
|
||||
def orders(
|
||||
page: int = Query(1, ge=1),
|
||||
page_size: int = Query(20, ge=1, le=100),
|
||||
keyword: str = Query(""),
|
||||
status: str = Query(""),
|
||||
provider: str = Query(""),
|
||||
):
|
||||
db = _session()
|
||||
try:
|
||||
total, items = finance_service.list_orders(
|
||||
db, page=page, page_size=page_size, keyword=keyword.strip(), status=status, provider=provider
|
||||
)
|
||||
return ApiResponse(data={"total": total, "page": page, "page_size": page_size, "items": items})
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
@router.patch("/orders/{order_no}/status")
|
||||
def update_order_status(order_no: str, body: dict = Body(...)):
|
||||
db = _session()
|
||||
try:
|
||||
finance_service.close_order(
|
||||
db, order_no, status=str(body.get("status") or ""), reason=str(body.get("reason") or "")
|
||||
)
|
||||
return ApiResponse(message="订单状态已更新")
|
||||
except FinanceServiceError as exc:
|
||||
_raise(exc)
|
||||
finally:
|
||||
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")
|
||||
def invoices(
|
||||
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_invoices(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.patch("/invoices/{invoice_id}")
|
||||
def update_invoice(invoice_id: str, body: dict = Body(...)):
|
||||
db = _session()
|
||||
try:
|
||||
finance_service.update_invoice(
|
||||
db,
|
||||
invoice_id,
|
||||
status=str(body.get("status") or ""),
|
||||
invoice_no=str(body.get("invoiceNo") or ""),
|
||||
invoice_url=str(body.get("invoiceUrl") or ""),
|
||||
remark=str(body.get("remark") or ""),
|
||||
)
|
||||
return ApiResponse(message="发票申请已处理")
|
||||
except FinanceServiceError as exc:
|
||||
_raise(exc)
|
||||
finally:
|
||||
db.close()
|
||||
@@ -35,7 +35,7 @@ def _get_engine_and_session():
|
||||
return None, None
|
||||
_engine = create_engine(
|
||||
f"sqlite:///{db_path}",
|
||||
connect_args={"check_same_thread": False},
|
||||
connect_args={"check_same_thread": False, "timeout": 30},
|
||||
)
|
||||
_SessionLocal = sessionmaker(bind=_engine, autoflush=False, expire_on_commit=False)
|
||||
return _engine, _SessionLocal()
|
||||
|
||||
@@ -0,0 +1,254 @@
|
||||
"""Finance operations for digital-avatar Token purchases."""
|
||||
|
||||
import os
|
||||
from datetime import datetime
|
||||
|
||||
import httpx
|
||||
from sqlalchemy import text
|
||||
|
||||
from app.core.config import settings
|
||||
|
||||
|
||||
class FinanceServiceError(RuntimeError):
|
||||
def __init__(self, message: str, status_code: int = 400):
|
||||
super().__init__(message)
|
||||
self.status_code = status_code
|
||||
|
||||
|
||||
def _mapping(row):
|
||||
return dict(row._mapping) if row is not None else None
|
||||
|
||||
|
||||
def _iso(value):
|
||||
return value.isoformat() if hasattr(value, "isoformat") else value
|
||||
|
||||
|
||||
def _money(cents):
|
||||
return round(int(cents or 0) / 100, 2)
|
||||
|
||||
|
||||
class FinanceService:
|
||||
@staticmethod
|
||||
def _tables_ready(db) -> bool:
|
||||
names = {
|
||||
row[0]
|
||||
for row in db.execute(text(
|
||||
"SELECT name FROM sqlite_master WHERE type='table' AND "
|
||||
"name IN ('token_payment_orders','payment_refunds','invoice_applications')"
|
||||
)).fetchall()
|
||||
}
|
||||
return len(names) == 3
|
||||
|
||||
@classmethod
|
||||
def summary(cls, db) -> dict:
|
||||
if not cls._tables_ready(db):
|
||||
return {
|
||||
"paid_revenue": 0,
|
||||
"paid_orders": 0,
|
||||
"pending_orders": 0,
|
||||
"processing_refunds": 0,
|
||||
"pending_invoices": 0,
|
||||
}
|
||||
row = db.execute(text("""
|
||||
SELECT
|
||||
COALESCE(SUM(CASE WHEN status='paid' THEN price_cents ELSE 0 END), 0) paid_revenue,
|
||||
SUM(CASE WHEN status='paid' THEN 1 ELSE 0 END) paid_orders,
|
||||
SUM(CASE WHEN status='pending' THEN 1 ELSE 0 END) pending_orders
|
||||
FROM token_payment_orders
|
||||
""")).fetchone()
|
||||
processing_refunds = db.execute(text(
|
||||
"SELECT COUNT(*) FROM payment_refunds WHERE status IN ('pending','processing')"
|
||||
)).scalar() or 0
|
||||
pending_invoices = db.execute(text(
|
||||
"SELECT COUNT(*) FROM invoice_applications WHERE status='pending'"
|
||||
)).scalar() or 0
|
||||
return {
|
||||
"paid_revenue": _money(row.paid_revenue),
|
||||
"paid_orders": int(row.paid_orders or 0),
|
||||
"pending_orders": int(row.pending_orders or 0),
|
||||
"processing_refunds": int(processing_refunds),
|
||||
"pending_invoices": int(pending_invoices),
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def list_orders(cls, db, *, page=1, page_size=20, keyword="", status="", provider=""):
|
||||
if not cls._tables_ready(db):
|
||||
return 0, []
|
||||
clauses = ["1=1"]
|
||||
params = {}
|
||||
if keyword:
|
||||
clauses.append("(o.order_no LIKE :keyword OR u.phone LIKE :keyword OR u.nickname LIKE :keyword)")
|
||||
params["keyword"] = f"%{keyword}%"
|
||||
if status:
|
||||
clauses.append("o.status=:status")
|
||||
params["status"] = status
|
||||
if provider:
|
||||
clauses.append("o.provider=:provider")
|
||||
params["provider"] = provider
|
||||
where = " AND ".join(clauses)
|
||||
total = db.execute(text(f"""
|
||||
SELECT COUNT(*) FROM token_payment_orders o
|
||||
LEFT JOIN users u ON u.id=o.user_id WHERE {where}
|
||||
"""), params).scalar() or 0
|
||||
params.update({"limit": page_size, "offset": (page - 1) * page_size})
|
||||
rows = db.execute(text(f"""
|
||||
SELECT o.*, u.nickname user_nickname, u.phone user_phone,
|
||||
i.status invoice_status, i.id invoice_id
|
||||
FROM token_payment_orders o
|
||||
LEFT JOIN users u ON u.id=o.user_id
|
||||
LEFT JOIN invoice_applications i ON i.order_no=o.order_no
|
||||
WHERE {where}
|
||||
ORDER BY o.created_at DESC LIMIT :limit OFFSET :offset
|
||||
"""), params).fetchall()
|
||||
items = []
|
||||
for row in rows:
|
||||
item = _mapping(row)
|
||||
item["price"] = _money(item.pop("price_cents"))
|
||||
for key in ("created_at", "updated_at", "paid_at", "refunded_at"):
|
||||
item[key] = _iso(item.get(key))
|
||||
item.pop("pay_message", None)
|
||||
items.append(item)
|
||||
return int(total), items
|
||||
|
||||
@classmethod
|
||||
def list_refunds(cls, db, *, page=1, page_size=20, status=""):
|
||||
if not cls._tables_ready(db):
|
||||
return 0, []
|
||||
where = "WHERE r.status=:status" if status else ""
|
||||
params = {"status": status} if status else {}
|
||||
total = db.execute(text(f"SELECT COUNT(*) FROM payment_refunds r {where}"), params).scalar() or 0
|
||||
params.update({"limit": page_size, "offset": (page - 1) * page_size})
|
||||
rows = db.execute(text(f"""
|
||||
SELECT r.*, o.provider, o.payment_method, u.nickname user_nickname, u.phone user_phone
|
||||
FROM payment_refunds r
|
||||
JOIN token_payment_orders o ON o.order_no=r.order_no
|
||||
LEFT JOIN users u ON u.id=o.user_id
|
||||
{where}
|
||||
ORDER BY r.created_at DESC LIMIT :limit OFFSET :offset
|
||||
"""), params).fetchall()
|
||||
items = []
|
||||
for row in rows:
|
||||
item = _mapping(row)
|
||||
item["amount"] = _money(item.pop("amount_cents"))
|
||||
for key in ("created_at", "updated_at", "completed_at"):
|
||||
item[key] = _iso(item.get(key))
|
||||
items.append(item)
|
||||
return int(total), items
|
||||
|
||||
@classmethod
|
||||
def list_invoices(cls, db, *, page=1, page_size=20, status=""):
|
||||
if not cls._tables_ready(db):
|
||||
return 0, []
|
||||
where = "WHERE i.status=:status" if status else ""
|
||||
params = {"status": status} if status else {}
|
||||
total = db.execute(text(f"SELECT COUNT(*) FROM invoice_applications i {where}"), params).scalar() or 0
|
||||
params.update({"limit": page_size, "offset": (page - 1) * page_size})
|
||||
rows = db.execute(text(f"""
|
||||
SELECT i.*, u.nickname user_nickname, u.phone user_phone
|
||||
FROM invoice_applications i
|
||||
LEFT JOIN users u ON u.id=i.user_id
|
||||
{where}
|
||||
ORDER BY i.created_at DESC LIMIT :limit OFFSET :offset
|
||||
"""), params).fetchall()
|
||||
items = []
|
||||
for row in rows:
|
||||
item = _mapping(row)
|
||||
item["amount"] = _money(item.pop("amount_cents"))
|
||||
for key in ("created_at", "updated_at", "issued_at"):
|
||||
item[key] = _iso(item.get(key))
|
||||
items.append(item)
|
||||
return int(total), items
|
||||
|
||||
@staticmethod
|
||||
def close_order(db, order_no: str, *, status: str, reason: str):
|
||||
if status not in {"closed", "failed"}:
|
||||
raise FinanceServiceError("后台只能将待支付订单关闭或标记失败")
|
||||
order = db.execute(text(
|
||||
"SELECT status FROM token_payment_orders WHERE order_no=:order_no"
|
||||
), {"order_no": order_no}).fetchone()
|
||||
if not order:
|
||||
raise FinanceServiceError("订单不存在", 404)
|
||||
if order.status != "pending":
|
||||
raise FinanceServiceError("只有待支付订单可以修改状态", 409)
|
||||
db.execute(text("""
|
||||
UPDATE token_payment_orders
|
||||
SET status=:status, failure_reason=:reason, updated_at=:updated_at
|
||||
WHERE order_no=:order_no
|
||||
"""), {
|
||||
"status": status,
|
||||
"reason": (reason or "后台关闭订单")[:500],
|
||||
"updated_at": datetime.utcnow(),
|
||||
"order_no": order_no,
|
||||
})
|
||||
db.commit()
|
||||
|
||||
@staticmethod
|
||||
def _avatar_admin_call(path: str, payload: dict):
|
||||
base_url = (settings.AVATAR_BACKEND_URL or os.getenv("AVATAR_BACKEND_URL", "")).rstrip("/")
|
||||
secret = os.getenv("AVATAR_FINANCE_ADMIN_SECRET", "").strip()
|
||||
if not base_url or len(secret) < 16:
|
||||
raise FinanceServiceError("数字分身财务服务尚未完成配置", 503)
|
||||
try:
|
||||
response = httpx.post(
|
||||
f"{base_url}/api{path}",
|
||||
json=payload,
|
||||
headers={"X-Avatar-Finance-Key": secret},
|
||||
timeout=35,
|
||||
)
|
||||
data = response.json()
|
||||
except (httpx.HTTPError, ValueError) as exc:
|
||||
raise FinanceServiceError("数字分身财务服务暂时不可用", 502) from exc
|
||||
if response.status_code >= 400 or data.get("code") not in (0, 200, "0", "200"):
|
||||
raise FinanceServiceError(data.get("message") or data.get("detail") or "财务操作失败", response.status_code)
|
||||
return data.get("data")
|
||||
|
||||
@classmethod
|
||||
def request_refund(cls, order_no: str, *, reason: str, operator: str):
|
||||
return cls._avatar_admin_call(
|
||||
f"/token/admin/orders/{order_no}/refund",
|
||||
{"reason": reason, "operator": operator},
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def confirm_refund(cls, refund_no: str, payload: dict):
|
||||
return cls._avatar_admin_call(f"/token/admin/refunds/{refund_no}/confirm", payload)
|
||||
|
||||
@staticmethod
|
||||
def update_invoice(db, invoice_id: str, *, status: str, invoice_no="", invoice_url="", remark=""):
|
||||
row = db.execute(text(
|
||||
"SELECT * FROM invoice_applications WHERE id=:invoice_id"
|
||||
), {"invoice_id": invoice_id}).fetchone()
|
||||
if not row:
|
||||
raise FinanceServiceError("发票申请不存在", 404)
|
||||
if row.status != "pending":
|
||||
raise FinanceServiceError("该发票申请已处理", 409)
|
||||
if status == "issued":
|
||||
if not invoice_no.strip():
|
||||
raise FinanceServiceError("请填写发票号码")
|
||||
if invoice_url.strip() and not invoice_url.strip().lower().startswith(("https://", "http://")):
|
||||
raise FinanceServiceError("电子发票地址必须是 HTTP 或 HTTPS 链接")
|
||||
issued_at = datetime.utcnow()
|
||||
elif status == "rejected":
|
||||
if not remark.strip():
|
||||
raise FinanceServiceError("请填写驳回原因")
|
||||
issued_at = None
|
||||
else:
|
||||
raise FinanceServiceError("发票状态只能是已开具或已驳回")
|
||||
db.execute(text("""
|
||||
UPDATE invoice_applications
|
||||
SET status=:status, invoice_no=:invoice_no, invoice_url=:invoice_url,
|
||||
remark=:remark, issued_at=:issued_at, updated_at=:updated_at
|
||||
WHERE id=:invoice_id
|
||||
"""), {
|
||||
"status": status,
|
||||
"invoice_no": invoice_no.strip()[:120],
|
||||
"invoice_url": invoice_url.strip()[:500],
|
||||
"remark": remark.strip()[:500],
|
||||
"issued_at": issued_at,
|
||||
"updated_at": datetime.utcnow(),
|
||||
"invoice_id": invoice_id,
|
||||
})
|
||||
db.commit()
|
||||
|
||||
|
||||
finance_service = FinanceService()
|
||||
@@ -0,0 +1,91 @@
|
||||
from sqlalchemy import create_engine, text
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.services.finance_service import FinanceService
|
||||
|
||||
|
||||
def _db():
|
||||
engine = create_engine("sqlite:///:memory:")
|
||||
db = Session(engine)
|
||||
db.execute(text("""
|
||||
CREATE TABLE users (id TEXT PRIMARY KEY, nickname TEXT, phone TEXT)
|
||||
"""))
|
||||
db.execute(text("""
|
||||
CREATE TABLE token_payment_orders (
|
||||
id TEXT, order_no TEXT PRIMARY KEY, user_id TEXT, plan_id TEXT,
|
||||
payment_method TEXT, pay_type TEXT, pay_way TEXT, points_amount INTEGER,
|
||||
price_cents INTEGER, status TEXT, provider TEXT, provider_order_id TEXT,
|
||||
provider_order_no TEXT, provider_status TEXT, pay_message TEXT,
|
||||
failure_reason TEXT, refund_status TEXT, created_at TEXT, updated_at TEXT,
|
||||
paid_at TEXT, refunded_at TEXT
|
||||
)
|
||||
"""))
|
||||
db.execute(text("""
|
||||
CREATE TABLE payment_refunds (
|
||||
id TEXT, refund_no TEXT, order_no TEXT, amount_cents INTEGER,
|
||||
points_amount INTEGER, reason TEXT, status TEXT, provider_refund_no TEXT,
|
||||
requested_by TEXT, failure_reason TEXT, created_at TEXT, updated_at TEXT,
|
||||
completed_at TEXT
|
||||
)
|
||||
"""))
|
||||
db.execute(text("""
|
||||
CREATE TABLE invoice_applications (
|
||||
id TEXT, order_no TEXT, user_id TEXT, amount_cents INTEGER, title TEXT,
|
||||
invoice_type TEXT, tax_number TEXT, email TEXT, status TEXT,
|
||||
invoice_no TEXT, invoice_url TEXT, remark TEXT, created_at TEXT,
|
||||
updated_at TEXT, issued_at TEXT
|
||||
)
|
||||
"""))
|
||||
db.execute(text("INSERT INTO users VALUES ('u1','测试用户','13800000000')"))
|
||||
db.execute(text("""
|
||||
INSERT INTO token_payment_orders VALUES (
|
||||
'o1','AV1','u1','1','wechat','WECHAT','APP',2000000,1000,'paid','huihui',
|
||||
'','','SUCCESS','secret-payment-message','','none','2026-09-08 12:00:00',
|
||||
'2026-09-08 12:01:00','2026-09-08 12:01:00',NULL
|
||||
)
|
||||
"""))
|
||||
db.execute(text("""
|
||||
INSERT INTO invoice_applications VALUES (
|
||||
'i1','AV1','u1',1000,'测试用户','personal','','u@example.com','pending',
|
||||
'','','','2026-09-08 12:02:00','2026-09-08 12:02:00',NULL
|
||||
)
|
||||
"""))
|
||||
db.commit()
|
||||
return db
|
||||
|
||||
|
||||
def test_finance_summary_and_orders_hide_provider_payment_payload():
|
||||
db = _db()
|
||||
try:
|
||||
summary = FinanceService.summary(db)
|
||||
assert summary == {
|
||||
"paid_revenue": 10.0,
|
||||
"paid_orders": 1,
|
||||
"pending_orders": 0,
|
||||
"processing_refunds": 0,
|
||||
"pending_invoices": 1,
|
||||
}
|
||||
total, orders = FinanceService.list_orders(db, keyword="测试用户")
|
||||
assert total == 1
|
||||
assert orders[0]["price"] == 10.0
|
||||
assert orders[0]["invoice_status"] == "pending"
|
||||
assert "pay_message" not in orders[0]
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
def test_invoice_can_be_issued_and_pending_order_can_be_closed():
|
||||
db = _db()
|
||||
try:
|
||||
FinanceService.update_invoice(db, "i1", status="issued", invoice_no="FP-001", invoice_url="", remark="")
|
||||
assert db.execute(text("SELECT status, invoice_no FROM invoice_applications WHERE id='i1'" )).fetchone() == ("issued", "FP-001")
|
||||
db.execute(text("""
|
||||
INSERT INTO token_payment_orders
|
||||
(id,order_no,user_id,plan_id,payment_method,pay_type,pay_way,points_amount,price_cents,status,provider,refund_status)
|
||||
VALUES ('o2','AV2','u1','1','alipay','ALIPAY','H5',1,100,'pending','huihui','none')
|
||||
"""))
|
||||
db.commit()
|
||||
FinanceService.close_order(db, "AV2", status="closed", reason="超时")
|
||||
assert db.execute(text("SELECT status FROM token_payment_orders WHERE order_no='AV2'" )).scalar() == "closed"
|
||||
finally:
|
||||
db.close()
|
||||
@@ -1,12 +1,16 @@
|
||||
# 构建阶段:安装依赖并打包 H5
|
||||
FROM node:18-alpine AS build
|
||||
|
||||
ARG APP_GIT_SHA=unknown
|
||||
ARG APP_BUILD_TIME=unknown
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY package*.json ./
|
||||
RUN npm ci
|
||||
|
||||
COPY . .
|
||||
RUN printf '{"gitSha":"%s","buildTime":"%s"}\n' "$APP_GIT_SHA" "$APP_BUILD_TIME" > public/version.json
|
||||
RUN npm run build
|
||||
|
||||
# 运行阶段:nginx 托管静态资源并反向代理 /api 到后端
|
||||
@@ -14,6 +18,11 @@ RUN npm run build
|
||||
# 新版 nginx(>=1.31) 用 pwrite 写 pid 文件会被拦导致致命退出;1.28 用 write() 可正常启动。
|
||||
FROM nginx:1.28-alpine
|
||||
|
||||
ARG APP_GIT_SHA=unknown
|
||||
ARG APP_BUILD_TIME=unknown
|
||||
LABEL org.opencontainers.image.revision=${APP_GIT_SHA} \
|
||||
org.opencontainers.image.created=${APP_BUILD_TIME}
|
||||
|
||||
COPY --from=build /app/dist /usr/share/nginx/html
|
||||
# 覆盖 nginx 默认主配置(含唯一可写的 pid /tmp/nginx.pid,规避受限容器内 /run 不可写导致反复重启)
|
||||
COPY nginx.conf /etc/nginx/nginx.conf
|
||||
|
||||
@@ -7,6 +7,13 @@ WORKDIR /app
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir --timeout 120 --retries 10 -i https://pypi.tuna.tsinghua.edu.cn/simple -r requirements.txt
|
||||
|
||||
ARG APP_GIT_SHA=unknown
|
||||
ARG APP_BUILD_TIME=unknown
|
||||
ENV APP_GIT_SHA=${APP_GIT_SHA} \
|
||||
APP_BUILD_TIME=${APP_BUILD_TIME}
|
||||
LABEL org.opencontainers.image.revision=${APP_GIT_SHA} \
|
||||
org.opencontainers.image.created=${APP_BUILD_TIME}
|
||||
|
||||
COPY . .
|
||||
|
||||
# 后端使用 SQLite(avatar.db 落在 /app 内),平铺结构以 `uvicorn main:app` 启动
|
||||
|
||||
@@ -66,11 +66,18 @@ def init_db():
|
||||
("token_account", "total_consumed", "BIGINT DEFAULT 0"),
|
||||
("token_account", "created_at", "TIMESTAMP"),
|
||||
("token_account", "updated_at", "TIMESTAMP"),
|
||||
("token_plans", "virtual_product_id", "VARCHAR DEFAULT ''"),
|
||||
("token_payment_orders", "provider", "VARCHAR DEFAULT 'huihui'"),
|
||||
("token_payment_orders", "refund_status", "VARCHAR DEFAULT 'none'"),
|
||||
("token_payment_orders", "refunded_at", "TIMESTAMP"),
|
||||
("users", "wechat_mp_openid", "VARCHAR DEFAULT ''"),
|
||||
("users", "wechat_mp_session_key", "VARCHAR DEFAULT ''"),
|
||||
("takeover_messages", "attachment_id", "VARCHAR DEFAULT NULL"),
|
||||
)
|
||||
_normalize_optional_unique_values()
|
||||
_normalize_takeover_delays()
|
||||
_create_token_indexes()
|
||||
_create_payment_indexes()
|
||||
|
||||
|
||||
def _try_add_columns(*cols):
|
||||
@@ -104,3 +111,19 @@ def _create_token_indexes():
|
||||
"CREATE UNIQUE INDEX IF NOT EXISTS ux_token_account_user_id "
|
||||
"ON token_account(user_id) WHERE user_id <> ''"
|
||||
)
|
||||
|
||||
|
||||
def _create_payment_indexes():
|
||||
with engine.begin() as conn:
|
||||
conn.exec_driver_sql(
|
||||
"CREATE INDEX IF NOT EXISTS ix_token_payment_orders_provider "
|
||||
"ON token_payment_orders(provider)"
|
||||
)
|
||||
conn.exec_driver_sql(
|
||||
"CREATE INDEX IF NOT EXISTS ix_token_payment_orders_refund_status "
|
||||
"ON token_payment_orders(refund_status)"
|
||||
)
|
||||
conn.exec_driver_sql(
|
||||
"CREATE INDEX IF NOT EXISTS ix_users_wechat_mp_openid "
|
||||
"ON users(wechat_mp_openid)"
|
||||
)
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
|
||||
import os
|
||||
import importlib.util
|
||||
import logging
|
||||
import os
|
||||
|
||||
from apscheduler.schedulers.asyncio import AsyncIOScheduler
|
||||
from apscheduler.triggers.interval import IntervalTrigger
|
||||
|
||||
from database import init_db, SessionLocal
|
||||
from database import engine, init_db, SessionLocal
|
||||
from models import Avatar, Authorization, Organization, TokenAccount, TokenPlan, User
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
import routers.avatars
|
||||
@@ -54,7 +55,31 @@ app.mount("/api/files", StaticFiles(directory=UPLOAD_DIR), name="knowledge-files
|
||||
|
||||
@app.get("/api/health")
|
||||
def health():
|
||||
return ok({"status": "ok"})
|
||||
checks = _runtime_checks()
|
||||
return ok({
|
||||
"status": "ok" if all(checks.values()) else "degraded",
|
||||
"gitSha": os.getenv("APP_GIT_SHA", "unknown"),
|
||||
"buildTime": os.getenv("APP_BUILD_TIME", "unknown"),
|
||||
"checks": checks,
|
||||
})
|
||||
|
||||
|
||||
def _runtime_checks():
|
||||
return {
|
||||
"database": _database_is_ready(),
|
||||
"uploads": os.path.isdir(UPLOAD_DIR) and os.access(UPLOAD_DIR, os.W_OK),
|
||||
"pdfOcr": importlib.util.find_spec("pymupdf") is not None,
|
||||
}
|
||||
|
||||
|
||||
def _database_is_ready():
|
||||
try:
|
||||
with engine.connect() as connection:
|
||||
connection.exec_driver_sql("SELECT 1")
|
||||
return True
|
||||
except Exception:
|
||||
logger.exception("Database readiness check failed")
|
||||
return False
|
||||
|
||||
|
||||
def seed():
|
||||
|
||||
@@ -344,6 +344,7 @@ class TokenPlan(Base):
|
||||
price = Column(Float, default=0)
|
||||
badge = Column(String, default="")
|
||||
desc = Column(String, default="")
|
||||
virtual_product_id = Column(String, default="")
|
||||
|
||||
def to_dict(self):
|
||||
return {
|
||||
@@ -353,6 +354,7 @@ class TokenPlan(Base):
|
||||
"price": self.price,
|
||||
"badge": self.badge,
|
||||
"desc": self.desc,
|
||||
"virtualProductId": self.virtual_product_id,
|
||||
}
|
||||
|
||||
|
||||
@@ -369,14 +371,17 @@ class TokenPaymentOrder(Base):
|
||||
points_amount = Column(BigInteger, nullable=False)
|
||||
price_cents = Column(Integer, nullable=False)
|
||||
status = Column(String, nullable=False, default="pending", index=True)
|
||||
provider = Column(String, nullable=False, default="huihui", index=True)
|
||||
provider_order_id = Column(String, default="")
|
||||
provider_order_no = Column(String, default="")
|
||||
provider_status = Column(String, default="")
|
||||
pay_message = Column(Text, default="")
|
||||
failure_reason = Column(String, default="")
|
||||
refund_status = Column(String, nullable=False, default="none", index=True)
|
||||
created_at = Column(DateTime, server_default=func.now())
|
||||
updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now())
|
||||
paid_at = Column(DateTime)
|
||||
refunded_at = Column(DateTime)
|
||||
|
||||
def to_dict(self):
|
||||
return {
|
||||
@@ -389,11 +394,117 @@ class TokenPaymentOrder(Base):
|
||||
"pointsAmount": self.points_amount,
|
||||
"price": self.price_cents / 100,
|
||||
"status": self.status,
|
||||
"provider": self.provider,
|
||||
"providerStatus": self.provider_status,
|
||||
"payMessage": self.pay_message,
|
||||
"failureReason": self.failure_reason,
|
||||
"refundStatus": self.refund_status,
|
||||
"createdAt": _iso(self.created_at),
|
||||
"paidAt": _iso(self.paid_at),
|
||||
"refundedAt": _iso(self.refunded_at),
|
||||
}
|
||||
|
||||
|
||||
class PaymentTransaction(Base):
|
||||
"""Auditable provider event for one Token purchase order."""
|
||||
|
||||
__tablename__ = "payment_transactions"
|
||||
__table_args__ = (
|
||||
Index("ix_payment_transactions_order_created", "order_no", "created_at"),
|
||||
)
|
||||
|
||||
id = Column(String, primary_key=True, default=lambda: uuid.uuid4().hex)
|
||||
order_no = Column(String, nullable=False, index=True)
|
||||
provider = Column(String, nullable=False, default="huihui")
|
||||
transaction_no = Column(String, nullable=False, default="")
|
||||
event_type = Column(String, nullable=False, default="payment")
|
||||
status = Column(String, nullable=False, default="pending")
|
||||
amount_cents = Column(Integer, nullable=False, default=0)
|
||||
raw_summary = Column(Text, default="")
|
||||
created_at = Column(DateTime, server_default=func.now())
|
||||
|
||||
def to_dict(self):
|
||||
return {
|
||||
"id": self.id,
|
||||
"orderNo": self.order_no,
|
||||
"provider": self.provider,
|
||||
"transactionNo": self.transaction_no,
|
||||
"eventType": self.event_type,
|
||||
"status": self.status,
|
||||
"amount": self.amount_cents / 100,
|
||||
"createdAt": _iso(self.created_at),
|
||||
}
|
||||
|
||||
|
||||
class PaymentRefund(Base):
|
||||
__tablename__ = "payment_refunds"
|
||||
|
||||
id = Column(String, primary_key=True, default=lambda: uuid.uuid4().hex)
|
||||
refund_no = Column(String, nullable=False, unique=True, index=True)
|
||||
order_no = Column(String, nullable=False, index=True)
|
||||
amount_cents = Column(Integer, nullable=False)
|
||||
points_amount = Column(BigInteger, nullable=False)
|
||||
reason = Column(String, default="")
|
||||
status = Column(String, nullable=False, default="pending", index=True)
|
||||
provider_refund_no = Column(String, default="")
|
||||
requested_by = Column(String, default="admin")
|
||||
failure_reason = Column(String, default="")
|
||||
created_at = Column(DateTime, server_default=func.now())
|
||||
updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now())
|
||||
completed_at = Column(DateTime)
|
||||
|
||||
def to_dict(self):
|
||||
return {
|
||||
"id": self.id,
|
||||
"refundNo": self.refund_no,
|
||||
"orderNo": self.order_no,
|
||||
"amount": self.amount_cents / 100,
|
||||
"pointsAmount": self.points_amount,
|
||||
"reason": self.reason,
|
||||
"status": self.status,
|
||||
"providerRefundNo": self.provider_refund_no,
|
||||
"requestedBy": self.requested_by,
|
||||
"failureReason": self.failure_reason,
|
||||
"createdAt": _iso(self.created_at),
|
||||
"completedAt": _iso(self.completed_at),
|
||||
}
|
||||
|
||||
|
||||
class InvoiceApplication(Base):
|
||||
__tablename__ = "invoice_applications"
|
||||
|
||||
id = Column(String, primary_key=True, default=lambda: uuid.uuid4().hex)
|
||||
order_no = Column(String, nullable=False, unique=True, index=True)
|
||||
user_id = Column(String, nullable=False, index=True)
|
||||
amount_cents = Column(Integer, nullable=False)
|
||||
title = Column(String, nullable=False)
|
||||
invoice_type = Column(String, nullable=False, default="personal")
|
||||
tax_number = Column(String, default="")
|
||||
email = Column(String, default="")
|
||||
status = Column(String, nullable=False, default="pending", index=True)
|
||||
invoice_no = Column(String, default="")
|
||||
invoice_url = Column(String, default="")
|
||||
remark = Column(String, default="")
|
||||
created_at = Column(DateTime, server_default=func.now())
|
||||
updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now())
|
||||
issued_at = Column(DateTime)
|
||||
|
||||
def to_dict(self):
|
||||
return {
|
||||
"id": self.id,
|
||||
"orderNo": self.order_no,
|
||||
"userId": self.user_id,
|
||||
"amount": self.amount_cents / 100,
|
||||
"title": self.title,
|
||||
"invoiceType": self.invoice_type,
|
||||
"taxNumber": self.tax_number,
|
||||
"email": self.email,
|
||||
"status": self.status,
|
||||
"invoiceNo": self.invoice_no,
|
||||
"invoiceUrl": self.invoice_url,
|
||||
"remark": self.remark,
|
||||
"createdAt": _iso(self.created_at),
|
||||
"issuedAt": _iso(self.issued_at),
|
||||
}
|
||||
|
||||
|
||||
@@ -408,6 +519,9 @@ class User(Base):
|
||||
avatar_url = Column(String, default="")
|
||||
huihui_token = Column(String, default="") # 会会 access_token
|
||||
app_token = Column(String, default="") # 本系统会话 token
|
||||
wechat_mp_openid = Column(String, default="", index=True)
|
||||
# 微信 session_key 仅保存在服务端,用于虚拟支付用户态签名,绝不下发客户端。
|
||||
wechat_mp_session_key = Column(String, default="")
|
||||
last_login_at = Column(DateTime)
|
||||
created_at = Column(DateTime, server_default=func.now())
|
||||
updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now())
|
||||
|
||||
@@ -7,20 +7,43 @@ from datetime import datetime
|
||||
from decimal import Decimal, InvalidOperation, ROUND_HALF_UP
|
||||
from urllib.parse import parse_qs
|
||||
|
||||
from fastapi import APIRouter, Body, Depends, Header, HTTPException, Request
|
||||
from fastapi import APIRouter, Body, Depends, Header, HTTPException, Query, Request, Response
|
||||
from sqlalchemy import func
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from database import get_db
|
||||
from models import TokenAccount, TokenPaymentOrder, TokenPlan, TokenUsage, User
|
||||
from models import (
|
||||
InvoiceApplication,
|
||||
PaymentRefund,
|
||||
PaymentTransaction,
|
||||
TokenAccount,
|
||||
TokenPaymentOrder,
|
||||
TokenPlan,
|
||||
TokenUsage,
|
||||
User,
|
||||
)
|
||||
from responses import fail, ok
|
||||
from services.huihui_payment import HuihuiPaymentClient, HuihuiPaymentError
|
||||
from services.token_billing import DEFAULT_TOKEN_GRANT, get_or_create_account
|
||||
from services.token_billing import get_or_create_account
|
||||
from services.wechat_virtual_payment import (
|
||||
PAYMENT_EVENTS as WECHAT_PAYMENT_EVENTS,
|
||||
REFUND_EVENTS as WECHAT_REFUND_EVENTS,
|
||||
WechatVirtualPaymentError,
|
||||
build_payment_params as build_wechat_virtual_payment_params,
|
||||
callback_value as wechat_callback_value,
|
||||
exchange_code as exchange_wechat_code,
|
||||
parse_callback_body as parse_wechat_callback_body,
|
||||
product_id_for_plan,
|
||||
query_order as query_wechat_virtual_order,
|
||||
request_refund as request_wechat_virtual_refund,
|
||||
verify_callback_signature as verify_wechat_callback_signature,
|
||||
virtual_env as wechat_virtual_env,
|
||||
)
|
||||
|
||||
router = APIRouter(tags=["Token"])
|
||||
|
||||
PAYMENT_METHODS = {"wechat": "WECHAT", "alipay": "ALIPAY"}
|
||||
PAYMENT_SCENES = {"APP", "LITE", "JSAPI"}
|
||||
PAYMENT_SCENES = {"APP", "H5", "LITE", "JSAPI"}
|
||||
SUCCESS_STATUSES = {"SUCCESS", "SUCCEEDED", "PAID", "COMPLETED", "TRADE_SUCCESS"}
|
||||
FAILED_STATUSES = {"FAIL", "FAILED", "CLOSED", "CANCELLED", "CANCELED", "EXPIRED"}
|
||||
|
||||
@@ -35,6 +58,13 @@ def _require_user(authorization: str | None, db: Session) -> User:
|
||||
return user
|
||||
|
||||
|
||||
def _require_finance_admin(value: str | None):
|
||||
expected = os.getenv("AVATAR_FINANCE_ADMIN_SECRET", "").strip()
|
||||
provided = str(value or "").strip()
|
||||
if len(expected) < 16 or not hmac.compare_digest(provided, expected):
|
||||
raise HTTPException(status_code=403, detail="财务管理凭证无效")
|
||||
|
||||
|
||||
def _payment_client() -> HuihuiPaymentClient:
|
||||
return HuihuiPaymentClient({
|
||||
"HUIHUI_PAYMENT_BASE_URL": os.getenv(
|
||||
@@ -70,6 +100,132 @@ def _payment_payload(order: TokenPaymentOrder, account: TokenAccount) -> dict:
|
||||
return {**order.to_dict(), "balance": account.balance}
|
||||
|
||||
|
||||
def _safe_event_summary(payload: dict) -> str:
|
||||
"""Persist only reconciliation fields, never signatures, tokens or session keys."""
|
||||
summary = {}
|
||||
for key in (
|
||||
"Event", "OutTradeNo", "OpenId", "Env", "MchOrderId", "MchRefundId",
|
||||
"WxRefundId", "RefundFee", "RetCode", "RetMsg",
|
||||
):
|
||||
value = wechat_callback_value(payload, key)
|
||||
if value not in (None, ""):
|
||||
summary[key] = value
|
||||
goods = wechat_callback_value(payload, "GoodsInfo")
|
||||
if isinstance(goods, dict):
|
||||
summary["GoodsInfo"] = {
|
||||
key: goods.get(key)
|
||||
for key in ("ProductId", "Quantity", "OrigPrice", "ActualPrice")
|
||||
if goods.get(key) not in (None, "")
|
||||
}
|
||||
return json.dumps(summary, ensure_ascii=False, separators=(",", ":"))[:2000]
|
||||
|
||||
|
||||
def _record_transaction(
|
||||
db: Session,
|
||||
*,
|
||||
order: TokenPaymentOrder,
|
||||
provider: str,
|
||||
status: str,
|
||||
amount_cents: int,
|
||||
event_type: str = "payment",
|
||||
transaction_no: str = "",
|
||||
raw_summary: str = "",
|
||||
):
|
||||
if transaction_no:
|
||||
duplicate = db.query(PaymentTransaction).filter(
|
||||
PaymentTransaction.provider == provider,
|
||||
PaymentTransaction.transaction_no == transaction_no,
|
||||
PaymentTransaction.event_type == event_type,
|
||||
).first()
|
||||
if duplicate:
|
||||
return duplicate
|
||||
row = PaymentTransaction(
|
||||
order_no=order.order_no,
|
||||
provider=provider,
|
||||
transaction_no=transaction_no,
|
||||
event_type=event_type,
|
||||
status=status,
|
||||
amount_cents=amount_cents,
|
||||
raw_summary=raw_summary,
|
||||
)
|
||||
db.add(row)
|
||||
return row
|
||||
|
||||
|
||||
def _settle_paid_order(
|
||||
db: Session,
|
||||
order: TokenPaymentOrder,
|
||||
*,
|
||||
provider_status: str,
|
||||
transaction_no: str = "",
|
||||
raw_summary: str = "",
|
||||
) -> bool:
|
||||
if order.status in {"paid", "refunded"}:
|
||||
return False
|
||||
updated = db.query(TokenPaymentOrder).filter(
|
||||
TokenPaymentOrder.id == order.id,
|
||||
TokenPaymentOrder.status.in_(["pending", "failed", "closed"]),
|
||||
).update({
|
||||
TokenPaymentOrder.status: "paid",
|
||||
TokenPaymentOrder.provider_status: provider_status,
|
||||
TokenPaymentOrder.paid_at: datetime.utcnow(),
|
||||
TokenPaymentOrder.failure_reason: "",
|
||||
}, synchronize_session=False)
|
||||
if not updated:
|
||||
return False
|
||||
account = get_or_create_account(db, order.user_id)
|
||||
account.balance = int(account.balance or 0) + order.points_amount
|
||||
account.total_granted = int(account.total_granted or 0) + order.points_amount
|
||||
_record_transaction(
|
||||
db,
|
||||
order=order,
|
||||
provider=order.provider,
|
||||
status="paid",
|
||||
amount_cents=order.price_cents,
|
||||
transaction_no=transaction_no,
|
||||
raw_summary=raw_summary,
|
||||
)
|
||||
return True
|
||||
|
||||
|
||||
def _complete_refund(
|
||||
db: Session,
|
||||
order: TokenPaymentOrder,
|
||||
refund: PaymentRefund,
|
||||
*,
|
||||
provider_refund_no: str = "",
|
||||
failure_reason: str = "",
|
||||
):
|
||||
if failure_reason:
|
||||
refund.status = "failed"
|
||||
refund.failure_reason = failure_reason[:500]
|
||||
order.refund_status = "failed"
|
||||
return
|
||||
if refund.status == "succeeded":
|
||||
return
|
||||
account = get_or_create_account(db, order.user_id)
|
||||
# Provider-confirmed refunds must claw back the full grant. A negative
|
||||
# balance records consumed refunded points and blocks further usage.
|
||||
account.balance = int(account.balance or 0) - int(refund.points_amount or 0)
|
||||
account.total_granted = max(0, int(account.total_granted or 0) - int(refund.points_amount or 0))
|
||||
refund.status = "succeeded"
|
||||
refund.provider_refund_no = provider_refund_no[:128]
|
||||
refund.failure_reason = ""
|
||||
refund.completed_at = datetime.utcnow()
|
||||
order.status = "refunded"
|
||||
order.refund_status = "succeeded"
|
||||
order.refunded_at = datetime.utcnow()
|
||||
_record_transaction(
|
||||
db,
|
||||
order=order,
|
||||
provider=order.provider,
|
||||
status="succeeded",
|
||||
amount_cents=refund.amount_cents,
|
||||
event_type="refund",
|
||||
transaction_no=provider_refund_no or refund.refund_no,
|
||||
)
|
||||
|
||||
|
||||
def _nested_payload(value):
|
||||
if isinstance(value, str):
|
||||
text = value.strip()
|
||||
@@ -159,8 +315,11 @@ def charge(payload: dict = Body(...), authorization: str = Header(None), db: Ses
|
||||
pay_way = str(payload.get("payScene") or "APP").upper()
|
||||
if pay_way not in PAYMENT_SCENES:
|
||||
return fail("当前支付场景不受支持", 400)
|
||||
if pay_way == "LITE" and payment_method != "wechat":
|
||||
return fail("微信小程序虚拟支付仅支持微信支付", 400)
|
||||
|
||||
cents = _price_cents(plan.price)
|
||||
provider = "wechat_virtual" if pay_way == "LITE" else "huihui"
|
||||
order = TokenPaymentOrder(
|
||||
order_no=f"AV{datetime.utcnow().strftime('%Y%m%d%H%M%S')}{uuid.uuid4().hex[:12].upper()}",
|
||||
user_id=user.id,
|
||||
@@ -171,10 +330,35 @@ def charge(payload: dict = Body(...), authorization: str = Header(None), db: Ses
|
||||
points_amount=plan.amount,
|
||||
price_cents=cents,
|
||||
status="pending",
|
||||
provider=provider,
|
||||
)
|
||||
db.add(order)
|
||||
db.commit()
|
||||
|
||||
if provider == "wechat_virtual":
|
||||
if not user.wechat_mp_openid or not user.wechat_mp_session_key:
|
||||
order.status = "failed"
|
||||
order.failure_reason = "微信小程序登录态尚未准备好,请重新进入支付页"
|
||||
db.commit()
|
||||
return fail(order.failure_reason, 409)
|
||||
try:
|
||||
result = build_wechat_virtual_payment_params(
|
||||
order=order,
|
||||
plan=plan,
|
||||
session_key=user.wechat_mp_session_key,
|
||||
)
|
||||
except WechatVirtualPaymentError as exc:
|
||||
order.status = "failed"
|
||||
order.failure_reason = str(exc)[:500]
|
||||
db.commit()
|
||||
return fail(str(exc), 503)
|
||||
order.provider_order_id = order.order_no
|
||||
order.provider_order_no = order.order_no
|
||||
order.provider_status = "CREATED"
|
||||
order.pay_message = json.dumps(result, ensure_ascii=False, separators=(",", ":"))
|
||||
db.commit()
|
||||
return ok(_payment_payload(order, get_or_create_account(db, user.id)))
|
||||
|
||||
try:
|
||||
callback_url = _callback_url(order.order_no)
|
||||
except HuihuiPaymentError as exc:
|
||||
@@ -229,9 +413,267 @@ def payment_status(order_id: str, authorization: str = Header(None), db: Session
|
||||
).first()
|
||||
if not order:
|
||||
return fail("支付订单不存在", 404)
|
||||
if order.provider == "wechat_virtual" and order.status == "pending" and user.wechat_mp_openid:
|
||||
try:
|
||||
provider_data = query_wechat_virtual_order(
|
||||
openid=user.wechat_mp_openid,
|
||||
order_no=order.order_no,
|
||||
)
|
||||
provider_order = provider_data.get("order") or {}
|
||||
provider_status = int(provider_order.get("status", 0) or 0)
|
||||
paid_cents = int(provider_order.get("paid_fee") or provider_order.get("order_fee") or 0)
|
||||
order.provider_status = str(provider_status)
|
||||
if provider_status in {2, 3, 4} and paid_cents == order.price_cents:
|
||||
_settle_paid_order(
|
||||
db,
|
||||
order,
|
||||
provider_status=f"XPAY_{provider_status}",
|
||||
transaction_no=str(
|
||||
provider_order.get("wxpay_order_id")
|
||||
or provider_order.get("channel_order_id")
|
||||
or order.order_no
|
||||
),
|
||||
)
|
||||
elif provider_status == 6:
|
||||
order.status = "failed"
|
||||
order.failure_reason = "微信虚拟支付订单已关闭"
|
||||
db.commit()
|
||||
db.refresh(order)
|
||||
except WechatVirtualPaymentError:
|
||||
# 回调仍是首选确认路径;短暂查询失败不覆盖订单状态。
|
||||
pass
|
||||
return ok(_payment_payload(order, get_or_create_account(db, user.id)))
|
||||
|
||||
|
||||
@router.post("/token/wechat/session")
|
||||
def bind_wechat_session(
|
||||
payload: dict = Body(...),
|
||||
authorization: str = Header(None),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
user = _require_user(authorization, db)
|
||||
code = str(payload.get("code") or "").strip()
|
||||
if not code or len(code) > 256:
|
||||
return fail("微信登录凭证无效", 400)
|
||||
try:
|
||||
session = exchange_wechat_code(code)
|
||||
except WechatVirtualPaymentError as exc:
|
||||
return fail(str(exc), 502)
|
||||
|
||||
conflict = db.query(User).filter(
|
||||
User.wechat_mp_openid == session["openid"],
|
||||
User.id != user.id,
|
||||
).first()
|
||||
if conflict:
|
||||
return fail("该微信账号已绑定其他会会账号", 409)
|
||||
user.wechat_mp_openid = session["openid"]
|
||||
user.wechat_mp_session_key = session["session_key"]
|
||||
db.commit()
|
||||
return ok({"ready": True})
|
||||
|
||||
|
||||
@router.get("/token/orders")
|
||||
def list_user_orders(
|
||||
page: int = Query(1, ge=1),
|
||||
page_size: int = Query(20, ge=1, le=100),
|
||||
authorization: str = Header(None),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
user = _require_user(authorization, db)
|
||||
query = db.query(TokenPaymentOrder).filter(TokenPaymentOrder.user_id == user.id)
|
||||
total = query.count()
|
||||
orders = query.order_by(TokenPaymentOrder.created_at.desc()).offset((page - 1) * page_size).limit(page_size).all()
|
||||
invoice_by_order = {
|
||||
item.order_no: item.to_dict()
|
||||
for item in db.query(InvoiceApplication).filter(
|
||||
InvoiceApplication.order_no.in_([order.order_no for order in orders])
|
||||
).all()
|
||||
} if orders else {}
|
||||
return ok({
|
||||
"total": total,
|
||||
"page": page,
|
||||
"pageSize": page_size,
|
||||
"items": [
|
||||
{**_payment_payload(order, get_or_create_account(db, user.id)), "invoice": invoice_by_order.get(order.order_no)}
|
||||
for order in orders
|
||||
],
|
||||
})
|
||||
|
||||
|
||||
@router.post("/token/orders/{order_no}/invoice")
|
||||
def apply_invoice(
|
||||
order_no: str,
|
||||
payload: dict = Body(...),
|
||||
authorization: str = Header(None),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
user = _require_user(authorization, db)
|
||||
order = db.query(TokenPaymentOrder).filter(
|
||||
TokenPaymentOrder.order_no == order_no,
|
||||
TokenPaymentOrder.user_id == user.id,
|
||||
).first()
|
||||
if not order:
|
||||
return fail("订单不存在", 404)
|
||||
if order.status != "paid" or order.refund_status not in {"", "none"}:
|
||||
return fail("只有已支付且未退款的订单可以申请发票", 409)
|
||||
title = str(payload.get("title") or "").strip()
|
||||
invoice_type = str(payload.get("invoiceType") or "personal").strip().lower()
|
||||
tax_number = str(payload.get("taxNumber") or "").strip().upper()
|
||||
email = str(payload.get("email") or "").strip()
|
||||
if not title or len(title) > 120:
|
||||
return fail("请填写正确的发票抬头", 400)
|
||||
if invoice_type not in {"personal", "company"}:
|
||||
return fail("发票类型不正确", 400)
|
||||
if invoice_type == "company" and (len(tax_number) < 15 or len(tax_number) > 20):
|
||||
return fail("请填写正确的企业税号", 400)
|
||||
if email and ("@" not in email or len(email) > 160):
|
||||
return fail("请填写正确的接收邮箱", 400)
|
||||
|
||||
invoice = db.query(InvoiceApplication).filter(InvoiceApplication.order_no == order_no).first()
|
||||
if invoice and invoice.status not in {"rejected", "cancelled"}:
|
||||
return fail("该订单已申请发票", 409)
|
||||
if invoice is None:
|
||||
invoice = InvoiceApplication(order_no=order_no, user_id=user.id, amount_cents=order.price_cents)
|
||||
db.add(invoice)
|
||||
invoice.title = title
|
||||
invoice.invoice_type = invoice_type
|
||||
invoice.tax_number = tax_number if invoice_type == "company" else ""
|
||||
invoice.email = email
|
||||
invoice.status = "pending"
|
||||
invoice.remark = ""
|
||||
db.commit()
|
||||
db.refresh(invoice)
|
||||
return ok(invoice.to_dict())
|
||||
|
||||
|
||||
@router.post("/token/admin/orders/{order_no}/refund")
|
||||
def admin_request_refund(
|
||||
order_no: str,
|
||||
payload: dict = Body(...),
|
||||
finance_key: str = Header(None, alias="X-Avatar-Finance-Key"),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
_require_finance_admin(finance_key)
|
||||
order = db.query(TokenPaymentOrder).filter(TokenPaymentOrder.order_no == order_no).first()
|
||||
if not order:
|
||||
return fail("订单不存在", 404)
|
||||
if order.status != "paid" or order.refund_status not in {"", "none", "failed"}:
|
||||
return fail("该订单当前不可退款", 409)
|
||||
account = get_or_create_account(db, order.user_id)
|
||||
if int(account.balance or 0) < int(order.points_amount or 0):
|
||||
return fail("该订单发放的积分已使用,不能执行全额退款", 409)
|
||||
invoice = db.query(InvoiceApplication).filter(InvoiceApplication.order_no == order.order_no).first()
|
||||
if invoice and invoice.status == "issued":
|
||||
return fail("该订单发票已开具,请先完成红冲再退款", 409)
|
||||
reason = str(payload.get("reason") or "后台退款").strip()
|
||||
if not reason or len(reason) > 200:
|
||||
return fail("请填写 200 字以内的退款原因", 400)
|
||||
|
||||
refund = PaymentRefund(
|
||||
refund_no=f"RF{datetime.utcnow().strftime('%Y%m%d%H%M%S')}{uuid.uuid4().hex[:10].upper()}",
|
||||
order_no=order.order_no,
|
||||
amount_cents=order.price_cents,
|
||||
points_amount=order.points_amount,
|
||||
reason=reason,
|
||||
status="processing",
|
||||
requested_by=str(payload.get("operator") or "admin")[:80],
|
||||
)
|
||||
claimed = db.query(TokenPaymentOrder).filter(
|
||||
TokenPaymentOrder.id == order.id,
|
||||
TokenPaymentOrder.status == "paid",
|
||||
TokenPaymentOrder.refund_status.in_(["", "none", "failed"]),
|
||||
).update({TokenPaymentOrder.refund_status: "processing"}, synchronize_session=False)
|
||||
if not claimed:
|
||||
db.rollback()
|
||||
return fail("该订单已有退款任务正在处理", 409)
|
||||
db.add(refund)
|
||||
if invoice and invoice.status == "pending":
|
||||
invoice.status = "cancelled"
|
||||
invoice.remark = "订单已申请退款,发票申请自动取消"
|
||||
db.commit()
|
||||
|
||||
user = db.query(User).filter(User.id == order.user_id).first()
|
||||
try:
|
||||
if order.provider == "wechat_virtual":
|
||||
if not user or not user.wechat_mp_openid:
|
||||
raise WechatVirtualPaymentError("订单缺少微信 OpenID,无法退款")
|
||||
provider_result = request_wechat_virtual_refund(
|
||||
openid=user.wechat_mp_openid,
|
||||
order_no=order.order_no,
|
||||
refund_no=refund.refund_no,
|
||||
amount_cents=refund.amount_cents,
|
||||
)
|
||||
else:
|
||||
provider_result = _payment_client().request_refund(
|
||||
huihui_token=user.huihui_token if user else "",
|
||||
huihui_user_id=user.huihui_user_id if user else "",
|
||||
order_no=order.order_no,
|
||||
refund_no=refund.refund_no,
|
||||
amount=f"{refund.amount_cents / 100:.2f}",
|
||||
reason=reason,
|
||||
)
|
||||
except (WechatVirtualPaymentError, HuihuiPaymentError) as exc:
|
||||
_complete_refund(db, order, refund, failure_reason=str(exc))
|
||||
db.commit()
|
||||
return fail(str(exc), 502)
|
||||
|
||||
provider_status = str(
|
||||
provider_result.get("status")
|
||||
or provider_result.get("refundStatus")
|
||||
or provider_result.get("result")
|
||||
or "PROCESSING"
|
||||
).upper()
|
||||
provider_refund_no = str(
|
||||
provider_result.get("refundNo")
|
||||
or provider_result.get("refundId")
|
||||
or provider_result.get("wx_refund_id")
|
||||
or ""
|
||||
)
|
||||
refund.provider_refund_no = provider_refund_no[:128]
|
||||
if provider_status in {"SUCCESS", "SUCCEEDED", "REFUNDED", "COMPLETED"}:
|
||||
_complete_refund(db, order, refund, provider_refund_no=provider_refund_no)
|
||||
db.commit()
|
||||
db.refresh(refund)
|
||||
return ok(refund.to_dict())
|
||||
|
||||
|
||||
@router.post("/token/admin/refunds/{refund_no}/confirm")
|
||||
def admin_confirm_refund(
|
||||
refund_no: str,
|
||||
payload: dict = Body(...),
|
||||
finance_key: str = Header(None, alias="X-Avatar-Finance-Key"),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""Record a provider-console reconciliation result for asynchronous refunds."""
|
||||
_require_finance_admin(finance_key)
|
||||
refund = db.query(PaymentRefund).filter(PaymentRefund.refund_no == refund_no).first()
|
||||
if not refund:
|
||||
return fail("退款单不存在", 404)
|
||||
order = db.query(TokenPaymentOrder).filter(TokenPaymentOrder.order_no == refund.order_no).first()
|
||||
if not order:
|
||||
return fail("原支付订单不存在", 404)
|
||||
status = str(payload.get("status") or "").lower()
|
||||
if status == "succeeded":
|
||||
_complete_refund(
|
||||
db,
|
||||
order,
|
||||
refund,
|
||||
provider_refund_no=str(payload.get("providerRefundNo") or refund.provider_refund_no or ""),
|
||||
)
|
||||
elif status == "failed":
|
||||
_complete_refund(
|
||||
db,
|
||||
order,
|
||||
refund,
|
||||
failure_reason=str(payload.get("failureReason") or "供应商退款失败"),
|
||||
)
|
||||
else:
|
||||
return fail("退款确认状态只能是 succeeded 或 failed", 400)
|
||||
db.commit()
|
||||
db.refresh(refund)
|
||||
return ok(refund.to_dict())
|
||||
|
||||
|
||||
@router.post("/token/payment/callback/{order_no}/{callback_signature}")
|
||||
async def payment_callback(
|
||||
order_no: str,
|
||||
@@ -271,6 +713,8 @@ async def payment_callback(
|
||||
return fail("支付订单不存在", 404)
|
||||
if order.status == "paid":
|
||||
return ok({"received": True, "duplicate": True})
|
||||
if order.status == "refunded":
|
||||
return ok({"received": True, "duplicate": True, "refunded": True})
|
||||
|
||||
provider_status = str(_find_value(
|
||||
payload, "status", "payStatus", "tradeStatus", "paymentStatus"
|
||||
@@ -282,6 +726,14 @@ async def payment_callback(
|
||||
order.failure_reason = str(
|
||||
_find_value(payload, "message", "errorMsg", "failReason") or "支付失败"
|
||||
)[:500]
|
||||
_record_transaction(
|
||||
db,
|
||||
order=order,
|
||||
provider="huihui",
|
||||
status="failed",
|
||||
amount_cents=order.price_cents,
|
||||
transaction_no=str(_find_value(payload, "transactionId", "tradeNo") or ""),
|
||||
)
|
||||
db.commit()
|
||||
return ok({"received": True, "paid": False})
|
||||
|
||||
@@ -291,32 +743,148 @@ async def payment_callback(
|
||||
db.commit()
|
||||
return fail("支付金额不匹配", 422)
|
||||
|
||||
updated = db.query(TokenPaymentOrder).filter(
|
||||
TokenPaymentOrder.id == order.id,
|
||||
TokenPaymentOrder.status != "paid",
|
||||
).update({
|
||||
TokenPaymentOrder.status: "paid",
|
||||
TokenPaymentOrder.provider_status: provider_status,
|
||||
TokenPaymentOrder.paid_at: datetime.utcnow(),
|
||||
TokenPaymentOrder.failure_reason: "",
|
||||
}, synchronize_session=False)
|
||||
if updated:
|
||||
account = db.query(TokenAccount).filter(TokenAccount.user_id == order.user_id).first()
|
||||
if account is None:
|
||||
account = TokenAccount(
|
||||
user_id=order.user_id,
|
||||
balance=DEFAULT_TOKEN_GRANT,
|
||||
total_granted=DEFAULT_TOKEN_GRANT,
|
||||
total_consumed=0,
|
||||
)
|
||||
db.add(account)
|
||||
db.flush()
|
||||
account.balance = int(account.balance or 0) + order.points_amount
|
||||
account.total_granted = int(account.total_granted or 0) + order.points_amount
|
||||
_settle_paid_order(
|
||||
db,
|
||||
order,
|
||||
provider_status=provider_status,
|
||||
transaction_no=str(_find_value(payload, "transactionId", "tradeNo", "paymentNo") or ""),
|
||||
)
|
||||
db.commit()
|
||||
return ok({"received": True, "paid": True})
|
||||
|
||||
|
||||
def _wechat_notify_response(request: Request, *, success: bool, message: str = ""):
|
||||
code = 0 if success else 1
|
||||
text = "success" if success else (message or "fail")[:200].replace("]]>", "")
|
||||
if "xml" in (request.headers.get("content-type") or "").lower():
|
||||
return Response(
|
||||
content=f"<xml><ErrCode>{code}</ErrCode><ErrMsg><![CDATA[{text}]]></ErrMsg></xml>",
|
||||
media_type="application/xml",
|
||||
)
|
||||
return {"ErrCode": code, "ErrMsg": text}
|
||||
|
||||
|
||||
@router.get("/token/payment/wechat/virtual/notify")
|
||||
def validate_wechat_virtual_notify(
|
||||
signature: str = Query(""),
|
||||
timestamp: str = Query(""),
|
||||
nonce: str = Query(""),
|
||||
echostr: str = Query(""),
|
||||
):
|
||||
if not verify_wechat_callback_signature(signature, timestamp, nonce):
|
||||
raise HTTPException(status_code=403, detail="invalid signature")
|
||||
return Response(content=echostr or "ok", media_type="text/plain")
|
||||
|
||||
|
||||
@router.post("/token/payment/wechat/virtual/notify")
|
||||
async def wechat_virtual_notify(
|
||||
request: Request,
|
||||
signature: str = Query(""),
|
||||
timestamp: str = Query(""),
|
||||
nonce: str = Query(""),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
if not verify_wechat_callback_signature(signature, timestamp, nonce):
|
||||
return _wechat_notify_response(request, success=False, message="invalid signature")
|
||||
try:
|
||||
payload = _nested_payload(parse_wechat_callback_body(await request.body()))
|
||||
except WechatVirtualPaymentError as exc:
|
||||
return _wechat_notify_response(request, success=False, message=str(exc))
|
||||
|
||||
event = str(wechat_callback_value(payload, "Event") or "").lower()
|
||||
if event in WECHAT_PAYMENT_EVENTS:
|
||||
order_no = str(wechat_callback_value(payload, "OutTradeNo") or "").strip()
|
||||
order = db.query(TokenPaymentOrder).filter(TokenPaymentOrder.order_no == order_no).first()
|
||||
if not order or order.provider != "wechat_virtual":
|
||||
return _wechat_notify_response(request, success=False, message="order not found")
|
||||
user = db.query(User).filter(User.id == order.user_id).first()
|
||||
openid = str(wechat_callback_value(payload, "OpenId") or "").strip()
|
||||
if not user or not openid or openid != user.wechat_mp_openid:
|
||||
return _wechat_notify_response(request, success=False, message="openid mismatch")
|
||||
try:
|
||||
callback_env = int(wechat_callback_value(payload, "Env"))
|
||||
actual_price = int(wechat_callback_value(payload, "GoodsInfo", "ActualPrice"))
|
||||
except (TypeError, ValueError):
|
||||
return _wechat_notify_response(request, success=False, message="invalid payment amount")
|
||||
plan = db.query(TokenPlan).filter(TokenPlan.id == order.plan_id).first()
|
||||
product_id = str(wechat_callback_value(payload, "GoodsInfo", "ProductId") or "")
|
||||
try:
|
||||
expected_product_id = product_id_for_plan(plan) if plan else ""
|
||||
except WechatVirtualPaymentError:
|
||||
expected_product_id = ""
|
||||
if (
|
||||
callback_env != wechat_virtual_env()
|
||||
or actual_price != order.price_cents
|
||||
or not expected_product_id
|
||||
or product_id != expected_product_id
|
||||
):
|
||||
return _wechat_notify_response(request, success=False, message="payment verification failed")
|
||||
transaction_no = str(
|
||||
wechat_callback_value(payload, "WeChatPayInfo", "TransactionId")
|
||||
or wechat_callback_value(payload, "WeChatPayInfo", "MchOrderNo")
|
||||
or order_no
|
||||
)
|
||||
_settle_paid_order(
|
||||
db,
|
||||
order,
|
||||
provider_status=event,
|
||||
transaction_no=transaction_no,
|
||||
raw_summary=_safe_event_summary(payload),
|
||||
)
|
||||
db.commit()
|
||||
return _wechat_notify_response(request, success=True)
|
||||
|
||||
if event in WECHAT_REFUND_EVENTS:
|
||||
order_no = str(wechat_callback_value(payload, "MchOrderId") or "").strip()
|
||||
refund_no = str(wechat_callback_value(payload, "MchRefundId") or "").strip()
|
||||
order = db.query(TokenPaymentOrder).filter(TokenPaymentOrder.order_no == order_no).first()
|
||||
if not order or order.provider != "wechat_virtual":
|
||||
return _wechat_notify_response(request, success=False, message="order not found")
|
||||
if order.status == "refunded" or order.refund_status == "succeeded":
|
||||
return _wechat_notify_response(request, success=True)
|
||||
try:
|
||||
refund_cents = int(wechat_callback_value(payload, "RefundFee") or 0)
|
||||
result_code_value = wechat_callback_value(payload, "RetCode")
|
||||
if result_code_value in (None, ""):
|
||||
raise ValueError("missing RetCode")
|
||||
result_code = int(result_code_value)
|
||||
except (TypeError, ValueError):
|
||||
return _wechat_notify_response(request, success=False, message="invalid refund")
|
||||
refund = db.query(PaymentRefund).filter(PaymentRefund.refund_no == refund_no).first()
|
||||
if refund is None:
|
||||
refund = PaymentRefund(
|
||||
refund_no=refund_no or f"WR{uuid.uuid4().hex[:20].upper()}",
|
||||
order_no=order.order_no,
|
||||
amount_cents=refund_cents,
|
||||
points_amount=order.points_amount,
|
||||
reason="微信侧退款",
|
||||
status="processing",
|
||||
requested_by="wechat",
|
||||
)
|
||||
db.add(refund)
|
||||
if refund_cents != refund.amount_cents:
|
||||
return _wechat_notify_response(request, success=False, message="refund amount mismatch")
|
||||
if result_code == 0:
|
||||
_complete_refund(
|
||||
db,
|
||||
order,
|
||||
refund,
|
||||
provider_refund_no=str(wechat_callback_value(payload, "WxRefundId") or refund_no),
|
||||
)
|
||||
else:
|
||||
_complete_refund(
|
||||
db,
|
||||
order,
|
||||
refund,
|
||||
failure_reason=str(wechat_callback_value(payload, "RetMsg") or "微信退款失败"),
|
||||
)
|
||||
db.commit()
|
||||
return _wechat_notify_response(request, success=True)
|
||||
|
||||
# Irrelevant official-account events should not be retried as payment failures.
|
||||
return _wechat_notify_response(request, success=True)
|
||||
|
||||
|
||||
@router.get("/token/usage")
|
||||
def usage(authorization: str = Header(None), db: Session = Depends(get_db)):
|
||||
user = _require_user(authorization, db)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"""Signed client for Huihui's production payment-v3 service."""
|
||||
|
||||
import hashlib
|
||||
import os
|
||||
import random
|
||||
import string
|
||||
from datetime import datetime, timedelta, timezone
|
||||
@@ -122,3 +123,59 @@ class HuihuiPaymentClient:
|
||||
if not isinstance(data, dict):
|
||||
raise HuihuiPaymentError("会会支付未返回订单信息")
|
||||
return data
|
||||
|
||||
def request_refund(
|
||||
self,
|
||||
*,
|
||||
huihui_token: str,
|
||||
huihui_user_id: str,
|
||||
order_no: str,
|
||||
refund_no: str,
|
||||
amount: str,
|
||||
reason: str,
|
||||
) -> dict[str, Any]:
|
||||
"""Submit a full refund to payment-v3.
|
||||
|
||||
The refund path remains configurable because private Huihui deployments
|
||||
may expose the same contract below a different gateway route.
|
||||
"""
|
||||
if not self.configured:
|
||||
raise HuihuiPaymentError("会会支付服务未配置")
|
||||
if not huihui_token or not huihui_user_id:
|
||||
raise HuihuiPaymentError("当前会会登录凭证无法发起退款")
|
||||
|
||||
path = os.getenv("HUIHUI_PAYMENT_REFUND_PATH", "/payment/refund").strip()
|
||||
if not path.startswith("/"):
|
||||
path = f"/{path}"
|
||||
if ".." in path:
|
||||
raise HuihuiPaymentError("会会退款接口路径配置不正确")
|
||||
body = {
|
||||
"appId": self.app_id,
|
||||
"masterOrderNo": order_no,
|
||||
"refundOrderNo": refund_no,
|
||||
"refundAmt": float(amount),
|
||||
"refundReason": reason or "后台退款",
|
||||
}
|
||||
headers = {
|
||||
"Authorization": f"Bearer {huihui_token}",
|
||||
"appId": self.app_id,
|
||||
"windowAppId": self.app_id,
|
||||
}
|
||||
try:
|
||||
response = httpx.post(
|
||||
f"{self.base_url}{path}",
|
||||
headers=headers,
|
||||
params=self._signed_params(huihui_user_id),
|
||||
json=body,
|
||||
timeout=self.timeout,
|
||||
follow_redirects=True,
|
||||
)
|
||||
except httpx.HTTPError as exc:
|
||||
raise HuihuiPaymentError("会会退款连接失败,请稍后重试") from exc
|
||||
|
||||
payload = self._json(response)
|
||||
code = payload.get("code")
|
||||
if response.status_code >= 400 or code not in (0, 200, "0", "200"):
|
||||
raise HuihuiPaymentError(payload.get("message") or "会会退款申请失败")
|
||||
data = payload.get("data") or {}
|
||||
return data if isinstance(data, dict) else {"result": data}
|
||||
|
||||
@@ -0,0 +1,245 @@
|
||||
"""WeChat mini-program virtual-payment signing and server API adapter.
|
||||
|
||||
The AppKey and session_key never leave the backend. The JSON string returned as
|
||||
``signData`` is exactly the string used for both HMAC signatures.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
import os
|
||||
import time
|
||||
import xml.etree.ElementTree as ET
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
|
||||
|
||||
REQUEST_VIRTUAL_PAYMENT_URI = "requestVirtualPayment"
|
||||
PAYMENT_EVENTS = {"xpay_goods_deliver_notify"}
|
||||
REFUND_EVENTS = {"xpay_refund_notify"}
|
||||
|
||||
|
||||
class WechatVirtualPaymentError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
def json_compact(payload: dict[str, Any]) -> str:
|
||||
return json.dumps(payload, ensure_ascii=False, separators=(",", ":"))
|
||||
|
||||
|
||||
def hmac_sha256_hex(key: str, message: str) -> str:
|
||||
return hmac.new(key.encode("utf-8"), message.encode("utf-8"), hashlib.sha256).hexdigest()
|
||||
|
||||
|
||||
def virtual_env() -> int:
|
||||
value = os.getenv("WECHAT_VIRTUAL_ENV", "sandbox").strip().lower()
|
||||
return 0 if value in {"0", "prod", "production", "live", "online"} else 1
|
||||
|
||||
|
||||
def _app_key(env: int) -> str:
|
||||
name = "WECHAT_VIRTUAL_APP_KEY" if env == 0 else "WECHAT_VIRTUAL_SANDBOX_APP_KEY"
|
||||
return os.getenv(name, "").strip()
|
||||
|
||||
|
||||
def _offer_id() -> str:
|
||||
return os.getenv("WECHAT_VIRTUAL_OFFER_ID", "").strip()
|
||||
|
||||
|
||||
def product_id_for_plan(plan) -> str:
|
||||
configured = str(getattr(plan, "virtual_product_id", "") or "").strip()
|
||||
if not configured:
|
||||
configured = os.getenv(f"WECHAT_VIRTUAL_PRODUCT_{plan.id}", "").strip()
|
||||
if not configured:
|
||||
raise WechatVirtualPaymentError(f"套餐 {plan.id} 尚未配置微信虚拟支付商品 ID")
|
||||
if len(configured) > 64 or not all(ch.isalnum() or ch in "_-" for ch in configured):
|
||||
raise WechatVirtualPaymentError("微信虚拟支付商品 ID 格式不正确")
|
||||
return configured
|
||||
|
||||
|
||||
def build_payment_params(*, order, plan, session_key: str) -> dict[str, Any]:
|
||||
env = virtual_env()
|
||||
offer_id = _offer_id()
|
||||
app_key = _app_key(env)
|
||||
if not offer_id or not app_key or not session_key:
|
||||
raise WechatVirtualPaymentError("微信小程序虚拟支付配置不完整")
|
||||
|
||||
sign_data = json_compact({
|
||||
"offerId": offer_id,
|
||||
"buyQuantity": 1,
|
||||
"env": env,
|
||||
"currencyType": "CNY",
|
||||
"productId": product_id_for_plan(plan),
|
||||
"goodsPrice": int(order.price_cents),
|
||||
"outTradeNo": order.order_no,
|
||||
"attach": json_compact({"orderNo": order.order_no, "planId": order.plan_id}),
|
||||
})
|
||||
return {
|
||||
"provider": "wechat_virtual",
|
||||
"payment_channel": "virtual",
|
||||
"payment_method": "wechat",
|
||||
"mode": "short_series_goods",
|
||||
"signData": sign_data,
|
||||
"paySig": hmac_sha256_hex(app_key, f"{REQUEST_VIRTUAL_PAYMENT_URI}&{sign_data}"),
|
||||
"signature": hmac_sha256_hex(session_key, sign_data),
|
||||
"env": env,
|
||||
"offerId": offer_id,
|
||||
"outTradeNo": order.order_no,
|
||||
}
|
||||
|
||||
|
||||
def exchange_code(code: str) -> dict[str, str]:
|
||||
app_id = os.getenv("WECHAT_MP_APP_ID", "").strip()
|
||||
app_secret = os.getenv("WECHAT_MP_APP_SECRET", "").strip()
|
||||
if not app_id or not app_secret:
|
||||
raise WechatVirtualPaymentError("微信小程序登录配置不完整")
|
||||
try:
|
||||
response = httpx.get(
|
||||
"https://api.weixin.qq.com/sns/jscode2session",
|
||||
params={
|
||||
"appid": app_id,
|
||||
"secret": app_secret,
|
||||
"js_code": code,
|
||||
"grant_type": "authorization_code",
|
||||
},
|
||||
timeout=15,
|
||||
)
|
||||
data = response.json()
|
||||
except (httpx.HTTPError, ValueError) as exc:
|
||||
raise WechatVirtualPaymentError("微信登录态交换失败,请稍后重试") from exc
|
||||
if response.status_code >= 400 or data.get("errcode"):
|
||||
raise WechatVirtualPaymentError(data.get("errmsg") or "微信登录态交换失败")
|
||||
openid = str(data.get("openid") or "").strip()
|
||||
session_key = str(data.get("session_key") or "").strip()
|
||||
if not openid or not session_key:
|
||||
raise WechatVirtualPaymentError("微信未返回完整登录态")
|
||||
return {"openid": openid, "session_key": session_key}
|
||||
|
||||
|
||||
def verify_callback_signature(signature: str, timestamp: str, nonce: str) -> bool:
|
||||
token = os.getenv("WECHAT_VIRTUAL_CALLBACK_TOKEN", "").strip()
|
||||
if not token or not signature or not timestamp or not nonce:
|
||||
return False
|
||||
source = "".join(sorted([token, timestamp, nonce]))
|
||||
expected = hashlib.sha1(source.encode("utf-8")).hexdigest()
|
||||
return hmac.compare_digest(signature, expected)
|
||||
|
||||
|
||||
def _xml_value(element: ET.Element) -> Any:
|
||||
children = list(element)
|
||||
if not children:
|
||||
return element.text or ""
|
||||
return {child.tag: _xml_value(child) for child in children}
|
||||
|
||||
|
||||
def parse_callback_body(body: bytes) -> dict[str, Any]:
|
||||
text = body.decode("utf-8", errors="replace").strip()
|
||||
if not text:
|
||||
return {}
|
||||
try:
|
||||
payload = json.loads(text)
|
||||
if isinstance(payload, dict):
|
||||
return payload
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
try:
|
||||
parsed = _xml_value(ET.fromstring(text))
|
||||
except ET.ParseError as exc:
|
||||
raise WechatVirtualPaymentError("微信虚拟支付回调格式不正确") from exc
|
||||
return parsed if isinstance(parsed, dict) else {}
|
||||
|
||||
|
||||
def case_get(payload: Any, key: str) -> Any:
|
||||
if not isinstance(payload, dict):
|
||||
return None
|
||||
lowered = key.lower()
|
||||
for current, value in payload.items():
|
||||
if str(current).lower() == lowered:
|
||||
return value
|
||||
return None
|
||||
|
||||
|
||||
def callback_value(payload: dict[str, Any], *path: str) -> Any:
|
||||
current: Any = payload
|
||||
for key in path:
|
||||
current = case_get(current, key)
|
||||
if current is None:
|
||||
break
|
||||
return current
|
||||
|
||||
|
||||
_access_token_cache: tuple[str, float] = ("", 0)
|
||||
|
||||
|
||||
def _access_token() -> str:
|
||||
global _access_token_cache
|
||||
token, expires_at = _access_token_cache
|
||||
if token and expires_at > time.monotonic() + 60:
|
||||
return token
|
||||
app_id = os.getenv("WECHAT_MP_APP_ID", "").strip()
|
||||
app_secret = os.getenv("WECHAT_MP_APP_SECRET", "").strip()
|
||||
if not app_id or not app_secret:
|
||||
raise WechatVirtualPaymentError("微信小程序服务端配置不完整")
|
||||
try:
|
||||
response = httpx.get(
|
||||
"https://api.weixin.qq.com/cgi-bin/token",
|
||||
params={"grant_type": "client_credential", "appid": app_id, "secret": app_secret},
|
||||
timeout=15,
|
||||
)
|
||||
data = response.json()
|
||||
except (httpx.HTTPError, ValueError) as exc:
|
||||
raise WechatVirtualPaymentError("微信 access_token 获取失败") from exc
|
||||
if response.status_code >= 400 or data.get("errcode"):
|
||||
raise WechatVirtualPaymentError(data.get("errmsg") or "微信 access_token 获取失败")
|
||||
token = str(data.get("access_token") or "")
|
||||
if not token:
|
||||
raise WechatVirtualPaymentError("微信未返回 access_token")
|
||||
_access_token_cache = (token, time.monotonic() + int(data.get("expires_in") or 7200))
|
||||
return token
|
||||
|
||||
|
||||
def call_xpay(uri: str, payload: dict[str, Any]) -> dict[str, Any]:
|
||||
env = int(payload.get("env", virtual_env()))
|
||||
app_key = _app_key(env)
|
||||
if not app_key:
|
||||
raise WechatVirtualPaymentError("微信虚拟支付 AppKey 未配置")
|
||||
body = json_compact(payload)
|
||||
pay_sig = hmac_sha256_hex(app_key, f"{uri}&{body}")
|
||||
try:
|
||||
response = httpx.post(
|
||||
f"https://api.weixin.qq.com{uri}",
|
||||
params={"access_token": _access_token(), "pay_sig": pay_sig},
|
||||
content=body.encode("utf-8"),
|
||||
headers={"Content-Type": "application/json"},
|
||||
timeout=20,
|
||||
)
|
||||
data = response.json()
|
||||
except (httpx.HTTPError, ValueError) as exc:
|
||||
raise WechatVirtualPaymentError("微信虚拟支付服务暂时不可用") from exc
|
||||
if response.status_code >= 400 or data.get("errcode") not in (None, 0):
|
||||
raise WechatVirtualPaymentError(data.get("errmsg") or "微信虚拟支付请求失败")
|
||||
return data
|
||||
|
||||
|
||||
def request_refund(*, openid: str, order_no: str, refund_no: str, amount_cents: int, reason: int = 3) -> dict[str, Any]:
|
||||
return call_xpay("/xpay/refund_order", {
|
||||
"openid": openid,
|
||||
"order_id": order_no,
|
||||
"refund_order_id": refund_no,
|
||||
"left_fee": amount_cents,
|
||||
"refund_fee": amount_cents,
|
||||
"biz_meta": json_compact({"orderNo": order_no}),
|
||||
"refund_reason": int(reason),
|
||||
"req_from": 1,
|
||||
"env": virtual_env(),
|
||||
})
|
||||
|
||||
|
||||
def query_order(*, openid: str, order_no: str) -> dict[str, Any]:
|
||||
return call_xpay("/xpay/query_order", {
|
||||
"openid": openid,
|
||||
"order_id": order_no,
|
||||
"env": virtual_env(),
|
||||
})
|
||||
@@ -6,6 +6,9 @@ from models import (
|
||||
Authorization,
|
||||
Avatar,
|
||||
ChatAttachment,
|
||||
InvoiceApplication,
|
||||
PaymentRefund,
|
||||
PaymentTransaction,
|
||||
TakeoverCursor,
|
||||
TakeoverMessage,
|
||||
TakeoverReplyTask,
|
||||
@@ -115,6 +118,21 @@ def authorization_context():
|
||||
synchronize_session=False
|
||||
)
|
||||
user_ids = [owner.id, other.id]
|
||||
order_numbers = [
|
||||
row[0] for row in db.query(TokenPaymentOrder.order_no).filter(
|
||||
TokenPaymentOrder.user_id.in_(user_ids)
|
||||
).all()
|
||||
]
|
||||
if order_numbers:
|
||||
db.query(InvoiceApplication).filter(InvoiceApplication.order_no.in_(order_numbers)).delete(
|
||||
synchronize_session=False
|
||||
)
|
||||
db.query(PaymentRefund).filter(PaymentRefund.order_no.in_(order_numbers)).delete(
|
||||
synchronize_session=False
|
||||
)
|
||||
db.query(PaymentTransaction).filter(PaymentTransaction.order_no.in_(order_numbers)).delete(
|
||||
synchronize_session=False
|
||||
)
|
||||
db.query(TokenPaymentOrder).filter(TokenPaymentOrder.user_id.in_(user_ids)).delete(
|
||||
synchronize_session=False
|
||||
)
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import main
|
||||
|
||||
|
||||
def test_health_reports_release_and_runtime_capabilities(monkeypatch):
|
||||
monkeypatch.setenv("APP_GIT_SHA", "test-sha")
|
||||
monkeypatch.setenv("APP_BUILD_TIME", "2026-09-09T00:00:00Z")
|
||||
monkeypatch.setattr(main, "_runtime_checks", lambda: {
|
||||
"database": True,
|
||||
"uploads": True,
|
||||
"pdfOcr": True,
|
||||
})
|
||||
|
||||
response = main.health()
|
||||
|
||||
assert response["code"] == 200
|
||||
assert response["data"]["status"] == "ok"
|
||||
assert response["data"]["gitSha"] == "test-sha"
|
||||
assert response["data"]["buildTime"] == "2026-09-09T00:00:00Z"
|
||||
assert response["data"]["checks"] == {
|
||||
"database": True,
|
||||
"uploads": True,
|
||||
"pdfOcr": True,
|
||||
}
|
||||
|
||||
|
||||
def test_health_is_degraded_when_a_required_capability_is_missing(monkeypatch):
|
||||
monkeypatch.setattr(main, "_runtime_checks", lambda: {
|
||||
"database": True,
|
||||
"uploads": True,
|
||||
"pdfOcr": False,
|
||||
})
|
||||
|
||||
assert main.health()["data"]["status"] == "degraded"
|
||||
@@ -1,3 +1,4 @@
|
||||
import os
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
from services.huihui_payment import HuihuiPaymentClient
|
||||
@@ -48,3 +49,35 @@ def test_create_payment_uses_huihui_payment_v3_contract():
|
||||
assert body["payWay"] == "APP"
|
||||
assert body["masterOrderAmt"] == "10.00"
|
||||
assert body["payAmt"] == 10.0
|
||||
|
||||
|
||||
def test_request_refund_uses_configured_huihui_endpoint_without_exposing_secret():
|
||||
client = HuihuiPaymentClient({
|
||||
"HUIHUI_PAYMENT_BASE_URL": "https://open.example/api/payment-v3",
|
||||
"HUIHUI_APP_ID": "app-id",
|
||||
"HUIHUI_ACCESS_ID": "access-id",
|
||||
"HUIHUI_ACCESS_SECRET": "access-secret",
|
||||
})
|
||||
response = Mock(status_code=200)
|
||||
response.json.return_value = {"code": 200, "data": {"status": "PROCESSING", "refundNo": "provider-rf"}}
|
||||
with patch.dict(os.environ, {"HUIHUI_PAYMENT_REFUND_PATH": "/payment/refund"}), patch(
|
||||
"services.huihui_payment.httpx.post", return_value=response
|
||||
) as post:
|
||||
result = client.request_refund(
|
||||
huihui_token="user-token",
|
||||
huihui_user_id="user-id",
|
||||
order_no="AV1",
|
||||
refund_no="RF1",
|
||||
amount="10.00",
|
||||
reason="用户申请",
|
||||
)
|
||||
assert result["refundNo"] == "provider-rf"
|
||||
assert post.call_args.args[0] == "https://open.example/api/payment-v3/payment/refund"
|
||||
assert post.call_args.kwargs["json"] == {
|
||||
"appId": "app-id",
|
||||
"masterOrderNo": "AV1",
|
||||
"refundOrderNo": "RF1",
|
||||
"refundAmt": 10.0,
|
||||
"refundReason": "用户申请",
|
||||
}
|
||||
assert "accessSecret" not in post.call_args.kwargs["params"]
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
from unittest.mock import patch
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from database import SessionLocal
|
||||
from main import app, seed
|
||||
from models import InvoiceApplication, PaymentRefund, TokenAccount, TokenPaymentOrder, TokenPlan, User
|
||||
from services.token_billing import DEFAULT_TOKEN_GRANT
|
||||
|
||||
|
||||
client = TestClient(app)
|
||||
|
||||
|
||||
def _signature(token, timestamp, nonce):
|
||||
return hashlib.sha1("".join(sorted([token, timestamp, nonce])).encode()).hexdigest()
|
||||
|
||||
|
||||
def test_virtual_payment_callback_and_refund_are_idempotent(authorization_context):
|
||||
seed()
|
||||
context = authorization_context
|
||||
db = SessionLocal()
|
||||
try:
|
||||
user = db.query(User).filter(User.id == context["owner"].id).one()
|
||||
user.wechat_mp_openid = "openid-flow"
|
||||
user.wechat_mp_session_key = "session-flow"
|
||||
plan = db.query(TokenPlan).filter(TokenPlan.id == "1").one()
|
||||
plan.virtual_product_id = "points_plan_1"
|
||||
db.commit()
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
env = {
|
||||
"WECHAT_VIRTUAL_ENV": "sandbox",
|
||||
"WECHAT_VIRTUAL_SANDBOX_APP_KEY": "sandbox-key",
|
||||
"WECHAT_VIRTUAL_OFFER_ID": "offer-1",
|
||||
"WECHAT_VIRTUAL_CALLBACK_TOKEN": "callback-token",
|
||||
"AVATAR_FINANCE_ADMIN_SECRET": "finance-admin-secret-123",
|
||||
}
|
||||
with patch.dict(os.environ, env):
|
||||
created = client.post(
|
||||
"/api/token/charge",
|
||||
headers=context["owner_headers"],
|
||||
json={"planId": "1", "paymentMethod": "wechat", "payScene": "LITE"},
|
||||
).json()["data"]
|
||||
assert created["provider"] == "wechat_virtual"
|
||||
params = json.loads(created["payMessage"])
|
||||
assert params["mode"] == "short_series_goods"
|
||||
assert "session-flow" not in created["payMessage"]
|
||||
|
||||
notify = {
|
||||
"Event": "xpay_goods_deliver_notify",
|
||||
"OutTradeNo": created["orderNo"],
|
||||
"OpenId": "openid-flow",
|
||||
"Env": 1,
|
||||
"GoodsInfo": json.dumps({"ProductId": "points_plan_1", "ActualPrice": 1000}),
|
||||
"WeChatPayInfo": json.dumps({"TransactionId": "wx-transaction-1"}),
|
||||
}
|
||||
query = {"timestamp": "100", "nonce": "nonce", "signature": _signature("callback-token", "100", "nonce")}
|
||||
assert client.post("/api/token/payment/wechat/virtual/notify", params=query, json=notify).json()["ErrCode"] == 0
|
||||
assert client.post("/api/token/payment/wechat/virtual/notify", params=query, json=notify).json()["ErrCode"] == 0
|
||||
|
||||
invoice = client.post(
|
||||
f"/api/token/orders/{created['orderNo']}/invoice",
|
||||
headers=context["owner_headers"],
|
||||
json={"title": "测试用户", "invoiceType": "personal", "email": "test@example.com"},
|
||||
).json()["data"]
|
||||
assert invoice["status"] == "pending"
|
||||
|
||||
with patch("routers.tokens.request_wechat_virtual_refund", return_value={"errcode": 0}):
|
||||
refund_response = client.post(
|
||||
f"/api/token/admin/orders/{created['orderNo']}/refund",
|
||||
headers={"X-Avatar-Finance-Key": "finance-admin-secret-123"},
|
||||
json={"reason": "用户申请退款", "operator": "tester"},
|
||||
)
|
||||
assert refund_response.json()["data"]["status"] == "processing"
|
||||
refund_no = refund_response.json()["data"]["refundNo"]
|
||||
|
||||
refund_notify = {
|
||||
"Event": "xpay_refund_notify",
|
||||
"MchOrderId": created["orderNo"],
|
||||
"MchRefundId": refund_no,
|
||||
"WxRefundId": "wx-refund-1",
|
||||
"RefundFee": 1000,
|
||||
"RetCode": 0,
|
||||
}
|
||||
assert client.post("/api/token/payment/wechat/virtual/notify", params=query, json=refund_notify).json()["ErrCode"] == 0
|
||||
assert client.post("/api/token/payment/wechat/virtual/notify", params=query, json=refund_notify).json()["ErrCode"] == 0
|
||||
assert client.post("/api/token/payment/wechat/virtual/notify", params=query, json=notify).json()["ErrCode"] == 0
|
||||
|
||||
db = SessionLocal()
|
||||
try:
|
||||
order = db.query(TokenPaymentOrder).filter(TokenPaymentOrder.order_no == created["orderNo"]).one()
|
||||
account = db.query(TokenAccount).filter(TokenAccount.user_id == context["owner"].id).one()
|
||||
refund = db.query(PaymentRefund).filter(PaymentRefund.refund_no == refund_no).one()
|
||||
invoice = db.query(InvoiceApplication).filter(InvoiceApplication.order_no == created["orderNo"]).one()
|
||||
assert order.status == "refunded"
|
||||
assert refund.status == "succeeded"
|
||||
assert invoice.status == "cancelled"
|
||||
assert account.balance == DEFAULT_TOKEN_GRANT
|
||||
finally:
|
||||
db.close()
|
||||
@@ -0,0 +1,72 @@
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
import os
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
from services import wechat_virtual_payment as virtual
|
||||
|
||||
|
||||
def test_build_payment_params_signs_the_exact_compact_payload():
|
||||
order = SimpleNamespace(order_no="AV202609080001", plan_id="plan-1", price_cents=1000)
|
||||
plan = SimpleNamespace(id="plan-1", virtual_product_id="points_plan_1")
|
||||
env = {
|
||||
"WECHAT_VIRTUAL_ENV": "sandbox",
|
||||
"WECHAT_VIRTUAL_SANDBOX_APP_KEY": "sandbox-key",
|
||||
"WECHAT_VIRTUAL_OFFER_ID": "offer-1",
|
||||
}
|
||||
with patch.dict(os.environ, env, clear=False):
|
||||
result = virtual.build_payment_params(order=order, plan=plan, session_key="session-key")
|
||||
|
||||
sign_data = result["signData"]
|
||||
assert sign_data == json.dumps({
|
||||
"offerId": "offer-1",
|
||||
"buyQuantity": 1,
|
||||
"env": 1,
|
||||
"currencyType": "CNY",
|
||||
"productId": "points_plan_1",
|
||||
"goodsPrice": 1000,
|
||||
"outTradeNo": "AV202609080001",
|
||||
"attach": '{"orderNo":"AV202609080001","planId":"plan-1"}',
|
||||
}, ensure_ascii=False, separators=(",", ":"))
|
||||
assert result["paySig"] == hmac.new(
|
||||
b"sandbox-key", f"requestVirtualPayment&{sign_data}".encode(), hashlib.sha256
|
||||
).hexdigest()
|
||||
assert result["signature"] == hmac.new(
|
||||
b"session-key", sign_data.encode(), hashlib.sha256
|
||||
).hexdigest()
|
||||
|
||||
|
||||
def test_callback_signature_and_xml_body_are_supported():
|
||||
with patch.dict(os.environ, {"WECHAT_VIRTUAL_CALLBACK_TOKEN": "callback-token"}):
|
||||
signature = hashlib.sha1("".join(sorted(["callback-token", "100", "nonce"])).encode()).hexdigest()
|
||||
assert virtual.verify_callback_signature(signature, "100", "nonce")
|
||||
payload = virtual.parse_callback_body(
|
||||
b"<xml><Event>xpay_refund_notify</Event><GoodsInfo><ActualPrice>1000</ActualPrice></GoodsInfo></xml>"
|
||||
)
|
||||
assert virtual.callback_value(payload, "event") == "xpay_refund_notify"
|
||||
assert virtual.callback_value(payload, "goodsinfo", "actualprice") == "1000"
|
||||
|
||||
|
||||
def test_xpay_request_uses_server_access_token_and_pay_signature():
|
||||
token_response = Mock(status_code=200)
|
||||
token_response.json.return_value = {"access_token": "server-token", "expires_in": 7200}
|
||||
pay_response = Mock(status_code=200)
|
||||
pay_response.json.return_value = {"errcode": 0, "order": {"status": 2}}
|
||||
virtual._access_token_cache = ("", 0)
|
||||
env = {
|
||||
"WECHAT_MP_APP_ID": "wx-app",
|
||||
"WECHAT_MP_APP_SECRET": "wx-secret",
|
||||
"WECHAT_VIRTUAL_SANDBOX_APP_KEY": "sandbox-key",
|
||||
"WECHAT_VIRTUAL_ENV": "sandbox",
|
||||
}
|
||||
with patch.dict(os.environ, env), patch.object(virtual.httpx, "get", return_value=token_response), patch.object(
|
||||
virtual.httpx, "post", return_value=pay_response
|
||||
) as post:
|
||||
result = virtual.query_order(openid="openid", order_no="AV1")
|
||||
assert result["order"]["status"] == 2
|
||||
body = '{"openid":"openid","order_id":"AV1","env":1}'
|
||||
expected = hmac.new(b"sandbox-key", f"/xpay/query_order&{body}".encode(), hashlib.sha256).hexdigest()
|
||||
assert post.call_args.args[0] == "https://api.weixin.qq.com/xpay/query_order"
|
||||
assert post.call_args.kwargs["params"] == {"access_token": "server-token", "pay_sig": expected}
|
||||
@@ -1,42 +1,56 @@
|
||||
# 会会数字分身 —— Docker 测试实例(独立端口,不干扰现有 :8088 huihui 部署)
|
||||
services:
|
||||
avatar-backend:
|
||||
build: ./backend
|
||||
image: avatar-test-backend:latest
|
||||
build:
|
||||
context: ./backend
|
||||
args:
|
||||
APP_GIT_SHA: ${APP_GIT_SHA:?APP_GIT_SHA must be the full release commit}
|
||||
APP_BUILD_TIME: ${APP_BUILD_TIME:?APP_BUILD_TIME must be set}
|
||||
image: avatar-test-backend:${APP_GIT_SHA}
|
||||
container_name: avatar-test-backend
|
||||
restart: unless-stopped
|
||||
env_file:
|
||||
- .env
|
||||
environment:
|
||||
DATABASE_URL: sqlite:////data/avatar.db
|
||||
DATABASE_URL: sqlite:////data/db/avatar.db
|
||||
UPLOAD_DIR: /data/uploads
|
||||
CHAT_MODEL_CONFIG_URL: http://host.docker.internal:8000/api/ai-models/runtime/digital-avatar
|
||||
extra_hosts:
|
||||
- "host.docker.internal:host-gateway"
|
||||
volumes:
|
||||
- avatar-data:/data
|
||||
# Mount the directory, not only avatar.db: SQLite WAL/SHM files must survive recreation.
|
||||
- ${AVATAR_DB_DIR:?AVATAR_DB_DIR must contain the persistent avatar.db}:/data/db
|
||||
- ${AVATAR_UPLOAD_DIR:?AVATAR_UPLOAD_DIR must point to persistent uploads}:/data/uploads
|
||||
expose:
|
||||
- "8000"
|
||||
ports:
|
||||
- "8011:8000" # 仅用于直接调试 API;前端经内部网络访问,不走 host 端口
|
||||
healthcheck:
|
||||
test: ["CMD", "python", "-c", "import json,urllib.request; d=json.load(urllib.request.urlopen('http://127.0.0.1:8000/api/health', timeout=5))['data']; assert d['status']=='ok' and all(d['checks'].values())"]
|
||||
interval: 10s
|
||||
timeout: 8s
|
||||
retries: 12
|
||||
start_period: 20s
|
||||
networks:
|
||||
- avatar-net
|
||||
|
||||
avatar-frontend:
|
||||
build: .
|
||||
image: avatar-test-frontend:latest
|
||||
build:
|
||||
context: .
|
||||
args:
|
||||
APP_GIT_SHA: ${APP_GIT_SHA:?APP_GIT_SHA must be the full release commit}
|
||||
APP_BUILD_TIME: ${APP_BUILD_TIME:?APP_BUILD_TIME must be set}
|
||||
image: avatar-test-frontend:${APP_GIT_SHA}
|
||||
container_name: avatar-test-frontend
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "8099:80" # 浏览器访问 http://<host>:8099
|
||||
depends_on:
|
||||
- avatar-backend
|
||||
avatar-backend:
|
||||
condition: service_healthy
|
||||
networks:
|
||||
- avatar-net
|
||||
|
||||
networks:
|
||||
avatar-net:
|
||||
driver: bridge
|
||||
|
||||
volumes:
|
||||
avatar-data:
|
||||
|
||||
@@ -45,8 +45,25 @@ HUIHUI_PAYMENT_BASE_URL=https://open.99hui.com/api/payment-v3
|
||||
HUIHUI_PAYMENT_CALLBACK_BASE_URL=https://digital.99hui.com
|
||||
HUIHUI_PAYMENT_CALLBACK_SECRET=<至少32位随机密钥>
|
||||
HUIHUI_PAYMENT_TIMEOUT_SECONDS=30
|
||||
HUIHUI_PAYMENT_REFUND_PATH=/payment/refund
|
||||
AVATAR_FINANCE_ADMIN_SECRET=<至少32位随机密钥,与管理后台一致>
|
||||
|
||||
DATABASE_URL=sqlite:////data/avatar.db
|
||||
# 微信小程序虚拟支付;联调先使用 sandbox
|
||||
WECHAT_MP_APP_ID=<小程序AppID>
|
||||
WECHAT_MP_APP_SECRET=<小程序AppSecret>
|
||||
WECHAT_VIRTUAL_ENV=sandbox
|
||||
WECHAT_VIRTUAL_SANDBOX_APP_KEY=<沙箱AppKey>
|
||||
WECHAT_VIRTUAL_APP_KEY=<正式AppKey>
|
||||
WECHAT_VIRTUAL_OFFER_ID=<offer-id>
|
||||
WECHAT_VIRTUAL_CALLBACK_TOKEN=<回调校验Token>
|
||||
WECHAT_VIRTUAL_PRODUCT_1=<10元套餐商品ID>
|
||||
WECHAT_VIRTUAL_PRODUCT_2=<100元套餐商品ID>
|
||||
WECHAT_VIRTUAL_PRODUCT_3=<1000元套餐商品ID>
|
||||
WECHAT_VIRTUAL_PRODUCT_4=<10000元套餐商品ID>
|
||||
|
||||
AVATAR_DB_DIR=/srv/digital-avatar/data/db
|
||||
AVATAR_UPLOAD_DIR=/srv/digital-avatar/data/uploads
|
||||
DATABASE_URL=sqlite:////data/db/avatar.db
|
||||
UPLOAD_DIR=/data/uploads
|
||||
CHAT_MODEL_CONFIG_URL=http://<huihuisquare-api>/api/ai-models/runtime/digital-avatar
|
||||
EMBEDDING_API_URL=https://dashscope.aliyuncs.com/compatible-mode/v1
|
||||
@@ -59,17 +76,25 @@ VISION_OCR_MODEL=qwen-vl-ocr
|
||||
VISION_MAX_OUTPUT_TOKENS=2048
|
||||
VISION_TIMEOUT_SECONDS=90
|
||||
VISION_TOKEN_RESERVE=12000
|
||||
APP_GIT_SHA=<本次发布的完整提交SHA>
|
||||
APP_BUILD_TIME=<UTC ISO-8601构建时间>
|
||||
CHAT_IMAGE_MAX_BYTES=8388608
|
||||
CHAT_IMAGE_MAX_PIXELS=16000000
|
||||
CHAT_ATTACHMENT_RETENTION_HOURS=24
|
||||
CHAT_ATTACHMENT_CLEANUP_MINUTES=60
|
||||
```
|
||||
|
||||
如生产 AI 配置中心不可用,还应提供当前项目支持的 `OPENAI_API_KEY`、`OPENAI_BASE_URL`、`CHAT_MODEL` 等兜底配置。`/data` 必须挂载持久卷,数据库与知识库文件不可存放在容器临时层。
|
||||
如生产 AI 配置中心不可用,还应提供当前项目支持的 `OPENAI_API_KEY`、`OPENAI_BASE_URL`、`CHAT_MODEL` 等兜底配置。数据库文件与上传目录必须从宿主机显式挂载,不能存放在容器临时层。
|
||||
|
||||
`AVATAR_DB_DIR` 和 `AVATAR_UPLOAD_DIR` 必须是已备份的宿主机绝对路径,编排缺少任一变量都会直接拒绝构建或启动,防止误挂空卷造成用户、分身或知识库“丢失”的假象。SQLite 必须挂载整个数据库目录,不能只挂载 `avatar.db` 单文件,否则 `avatar.db-wal` 和 `avatar.db-shm` 会留在容器临时层,换容器后可能出现数据状态回退。
|
||||
|
||||
`EMBEDDING_API_URL` 同时支持 OpenAI 兼容基础地址(如上面的 `/v1`)和完整的 `/v1/embeddings` 地址,后端会统一请求 `/embeddings`。发布后必须在后端容器内执行一次最小向量探针,确认返回向量数量和维度,而不能只检查 `/api/health`。
|
||||
|
||||
积分充值使用会会支付体系的 `payment-v3/payment/pay`,渠道值为 `WECHAT` / `ALIPAY`,端内支付场景为 `APP`,微信内 H5 使用 `JSAPI`。`HUIHUI_PAYMENT_CALLBACK_SECRET` 只用于为每笔订单生成 HMAC 回调签名,不会发送到前端或直接出现在回调地址中。支付回调确认状态成功且金额与套餐价格完全一致后才增加积分,重复回调不会重复到账。
|
||||
App 与 H5 积分充值使用会会支付体系的 `payment-v3/payment/pay`,渠道值为 `WECHAT` / `ALIPAY`;App 场景为 `APP`,普通浏览器为 `H5`,微信内 H5 为 `JSAPI`。`HUIHUI_PAYMENT_CALLBACK_SECRET` 只用于为每笔订单生成 HMAC 回调签名,不会发送到前端或直接出现在回调地址中。支付回调确认状态成功且金额与套餐价格完全一致后才增加积分,重复回调不会重复到账。
|
||||
|
||||
微信小程序使用微信虚拟支付:小程序先通过 `POST /api/token/wechat/session` 交换临时登录码,再由 `POST /api/token/charge`(`payScene=LITE`)返回已签名的 `requestVirtualPayment` 参数。微信回调地址配置为 `https://digital.99hui.com/api/token/payment/wechat/virtual/notify`。回调会复核签名、OpenID、环境、商品 ID 与实付金额,退款回调确认后才扣回积分。AppKey、AppSecret、session_key 均不得下发前端或写日志。
|
||||
|
||||
管理后台需要配置相同的 `AVATAR_FINANCE_ADMIN_SECRET` 和 `AVATAR_BACKEND_URL=https://digital.99hui.com`。退款只支持整单原路退款;供应商受理后显示“处理中”,收到渠道成功回调(或经渠道后台核对后人工确认)才将订单置为已退款。已消费掉本订单积分时,后台会拒绝主动退款;若渠道外部退款先发生,积分账户允许形成负数以记录欠额并阻止继续消费。
|
||||
|
||||
## 3. 构建与发布
|
||||
|
||||
@@ -78,7 +103,7 @@ CHAT_ATTACHMENT_CLEANUP_MINUTES=60
|
||||
```bash
|
||||
BACKUP_DIR="backups/$(date +%Y%m%d-%H%M%S)"
|
||||
mkdir -p "$BACKUP_DIR"
|
||||
cp /srv/digital-avatar/data/avatar.db "$BACKUP_DIR/"
|
||||
cp /srv/digital-avatar/data/db/avatar.db "$BACKUP_DIR/"
|
||||
tar -C /srv/digital-avatar/data -czf "$BACKUP_DIR/uploads.tgz" uploads
|
||||
```
|
||||
|
||||
@@ -88,13 +113,22 @@ tar -C /srv/digital-avatar/data -czf "$BACKUP_DIR/uploads.tgz" uploads
|
||||
git fetch origin
|
||||
git checkout <已验收的提交SHA>
|
||||
cd digital-avatar-app
|
||||
docker compose build --pull avatar-backend avatar-frontend
|
||||
docker compose up -d avatar-backend avatar-frontend
|
||||
export APP_GIT_SHA="$(git rev-parse HEAD)"
|
||||
export APP_BUILD_TIME="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
|
||||
docker compose build --pull --no-cache avatar-backend avatar-frontend
|
||||
docker compose up -d --force-recreate --wait avatar-backend avatar-frontend
|
||||
docker compose ps
|
||||
curl -fsS http://127.0.0.1:8099/api/health
|
||||
python3 scripts/verify-deployment.py \
|
||||
https://digital.99hui.com "$APP_GIT_SHA" \
|
||||
--backend-container avatar-backend \
|
||||
--frontend-container avatar-frontend \
|
||||
--expected-db-source /srv/digital-avatar/data/db \
|
||||
--expected-upload-source /srv/digital-avatar/data/uploads
|
||||
docker compose exec avatar-backend python -c 'import embeddings; v=embeddings.embed(["部署向量探针"]); print(len(v), len(v[0]))'
|
||||
```
|
||||
|
||||
Jenkins 必须以 `verify-deployment.py` 返回成功作为发布成功条件,不能只以镜像构建或容器启动成功作为条件。脚本会同时核对公网前后端 Git SHA、数据库可读、上传目录可写、PDF OCR 依赖和宿主机数据挂载;任意一项不一致都会返回非零状态并阻止发布标绿。镜像使用 Git SHA 标签,不再依赖可被旧缓存覆盖的 `latest`。
|
||||
|
||||
生产编排应把示例中的测试端口改为内网暴露,由统一 HTTPS 网关接入。后端暂时使用 SQLite,必须保持单实例写入;若扩展为多后端实例,应先迁移到 PostgreSQL,并把延迟接管任务改为共享队列。
|
||||
|
||||
## 4. 网关要求
|
||||
@@ -134,10 +168,12 @@ location /api/ {
|
||||
5. 使用过期或伪造 token 时进入登录页并显示凭证失效,不得继续访问旧用户数据。
|
||||
6. 分身聊天 SSE 逐段输出正常,Markdown 正常渲染,知识库优先级和积分扣费正常。
|
||||
7. 开启 BOXIM 主动接管后保持在线,默认三分钟回复、自定义等待时间、已读回执、分身防回环和主人发言暂停均正常。
|
||||
8. 重建容器后数据库、头像、知识库文档仍存在,`/api/health` 返回成功。
|
||||
8. 重建容器后数据库、头像、知识库文档仍存在,`/api/health` 的 `gitSha` 与发布 SHA 一致,`database`、`uploads`、`pdfOcr` 三项检查均为 `true`。
|
||||
9. `https://digital.99hui.com/api/health` 可访问,证书域名和有效期正确,HTTP 自动跳转 HTTPS。
|
||||
10. 微信和支付宝各创建一笔最小套餐订单,未付款时积分不变;支付成功后回调到账一次,重复回调积分不重复增加。
|
||||
11. 私聊和公开分享各上传 JPG、PNG、WebP 图片并完成追问;上传非图片、超过 8MB 或跨分身附件时必须拒绝。
|
||||
11. 微信虚拟支付在沙箱环境完成下单、支付回调、查单兜底和退款回调;错误 OpenID、商品、环境或金额均被拒绝。
|
||||
12. 财务后台能筛选订单、关闭待支付订单、发起整单退款、登记退款对账结果,并处理个人/企业电子发票申请。
|
||||
13. 私聊和公开分享各上传 JPG、PNG、WebP 图片并完成追问;上传非图片、超过 8MB 或跨分身附件时必须拒绝。
|
||||
12. 病例图片可以提取可见文字并标记待核对内容,医学影像不作确定诊断;视觉与 OCR 调用分别扣减积分。
|
||||
13. 检查服务器上传目录不残留聊天原图,数据库过期图片识别记录在清理周期后删除,日志不出现 Base64 或病例正文。
|
||||
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Fail a deployment unless frontend and backend run the expected release."""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import urllib.request
|
||||
|
||||
|
||||
def fetch_json(url):
|
||||
with urllib.request.urlopen(url, timeout=20) as response:
|
||||
if response.status != 200:
|
||||
raise RuntimeError(f"{url} returned HTTP {response.status}")
|
||||
return json.load(response)
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("base_url", help="Public site URL, for example https://digital.99hui.com")
|
||||
parser.add_argument("expected_sha", help="Full Git commit SHA being deployed")
|
||||
parser.add_argument("--backend-container", help="Backend container name for image and mount checks")
|
||||
parser.add_argument("--frontend-container", help="Frontend container name for image checks")
|
||||
parser.add_argument("--expected-db-source", help="Required host directory mounted for SQLite and its WAL files")
|
||||
parser.add_argument("--expected-upload-source", help="Required host source mounted as the upload directory")
|
||||
args = parser.parse_args()
|
||||
|
||||
base_url = args.base_url.rstrip("/")
|
||||
errors = []
|
||||
try:
|
||||
health = fetch_json(f"{base_url}/api/health").get("data") or {}
|
||||
except Exception as exc:
|
||||
errors.append(f"cannot read backend release metadata: {exc}")
|
||||
health = {}
|
||||
try:
|
||||
frontend = fetch_json(f"{base_url}/version.json")
|
||||
except Exception as exc:
|
||||
errors.append(f"cannot read frontend release metadata: {exc}")
|
||||
frontend = {}
|
||||
|
||||
if health.get("status") != "ok":
|
||||
errors.append(f"backend status is {health.get('status')!r}")
|
||||
failed_checks = [name for name, passed in (health.get("checks") or {}).items() if not passed]
|
||||
if failed_checks:
|
||||
errors.append("backend checks failed: " + ", ".join(failed_checks))
|
||||
if health.get("gitSha") != args.expected_sha:
|
||||
errors.append(f"backend SHA is {health.get('gitSha')!r}")
|
||||
if frontend.get("gitSha") != args.expected_sha:
|
||||
errors.append(f"frontend SHA is {frontend.get('gitSha')!r}")
|
||||
|
||||
if args.backend_container:
|
||||
backend = inspect_container(args.backend_container, errors)
|
||||
check_container_revision(backend, args.expected_sha, "backend", errors)
|
||||
check_mount(backend, args.expected_db_source, "database", errors)
|
||||
check_mount(backend, args.expected_upload_source, "uploads", errors)
|
||||
elif args.expected_db_source or args.expected_upload_source:
|
||||
errors.append("--backend-container is required when checking data mounts")
|
||||
|
||||
if args.frontend_container:
|
||||
frontend_container = inspect_container(args.frontend_container, errors)
|
||||
check_container_revision(frontend_container, args.expected_sha, "frontend", errors)
|
||||
|
||||
if errors:
|
||||
print("Deployment verification failed:", file=sys.stderr)
|
||||
for error in errors:
|
||||
print(f"- {error}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
print(f"Deployment verified: {args.expected_sha}")
|
||||
print("Backend checks: database, uploads, pdfOcr")
|
||||
return 0
|
||||
|
||||
|
||||
def inspect_container(name, errors):
|
||||
try:
|
||||
output = subprocess.check_output(
|
||||
["docker", "inspect", name], universal_newlines=True
|
||||
)
|
||||
return json.loads(output)[0]
|
||||
except Exception as exc:
|
||||
errors.append(f"cannot inspect container {name!r}: {exc}")
|
||||
return {}
|
||||
|
||||
|
||||
def check_container_revision(container, expected_sha, label, errors):
|
||||
actual = ((container.get("Config") or {}).get("Labels") or {}).get(
|
||||
"org.opencontainers.image.revision"
|
||||
)
|
||||
if actual != expected_sha:
|
||||
errors.append(f"{label} container image SHA is {actual!r}")
|
||||
|
||||
|
||||
def check_mount(container, expected_source, label, errors):
|
||||
if not expected_source:
|
||||
return
|
||||
expected = os.path.realpath(expected_source)
|
||||
sources = {
|
||||
os.path.realpath(mount.get("Source", ""))
|
||||
for mount in container.get("Mounts") or []
|
||||
}
|
||||
if expected not in sources:
|
||||
errors.append(f"{label} mount source {expected!r} is not attached")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -152,16 +152,35 @@ export interface TokenPaymentOrder {
|
||||
planId: string
|
||||
paymentMethod: 'wechat' | 'alipay'
|
||||
payType: 'WECHAT' | 'ALIPAY'
|
||||
payWay: 'APP' | 'LITE' | 'JSAPI'
|
||||
payWay: 'APP' | 'H5' | 'LITE' | 'JSAPI'
|
||||
pointsAmount: number
|
||||
price: number
|
||||
status: 'pending' | 'paid' | 'failed'
|
||||
status: 'pending' | 'paid' | 'failed' | 'closed' | 'refunded'
|
||||
provider: 'huihui' | 'wechat_virtual'
|
||||
providerStatus: string
|
||||
payMessage: string
|
||||
failureReason: string
|
||||
refundStatus: 'none' | 'pending' | 'processing' | 'succeeded' | 'failed'
|
||||
createdAt: string | null
|
||||
paidAt: string | null
|
||||
refundedAt: string | null
|
||||
balance: number
|
||||
}
|
||||
|
||||
export interface TokenInvoice {
|
||||
id: string
|
||||
orderNo: string
|
||||
title: string
|
||||
invoiceType: 'personal' | 'company'
|
||||
taxNumber: string
|
||||
email: string
|
||||
amount: number
|
||||
status: 'pending' | 'issued' | 'rejected' | 'cancelled'
|
||||
invoiceNo: string
|
||||
invoiceUrl: string
|
||||
remark: string
|
||||
}
|
||||
|
||||
// 获取 Token 余额
|
||||
export const getTokenBalance = () =>
|
||||
request.get<TokenBalance>('/token/balance')
|
||||
@@ -174,12 +193,25 @@ export const getRechargePlans = () =>
|
||||
export const chargeToken = (
|
||||
planId: string,
|
||||
paymentMethod: 'wechat' | 'alipay',
|
||||
payScene: 'APP' | 'LITE' | 'JSAPI'
|
||||
payScene: 'APP' | 'H5' | 'LITE' | 'JSAPI'
|
||||
) => request.post<TokenPaymentOrder>('/token/charge', { planId, paymentMethod, payScene })
|
||||
|
||||
export const getTokenPaymentStatus = (orderId: string) =>
|
||||
request.get<TokenPaymentOrder>(`/token/payment/${orderId}`)
|
||||
|
||||
export const bindWechatVirtualSession = (code: string) =>
|
||||
request.post<{ ready: boolean }>('/token/wechat/session', { code })
|
||||
|
||||
export const getTokenOrders = (page = 1, pageSize = 20) =>
|
||||
request.get<{ total: number; page: number; pageSize: number; items: Array<TokenPaymentOrder & { invoice?: TokenInvoice }> }>(
|
||||
'/token/orders', { params: { page, page_size: pageSize } }
|
||||
)
|
||||
|
||||
export const applyTokenInvoice = (
|
||||
orderNo: string,
|
||||
payload: { title: string; invoiceType: 'personal' | 'company'; taxNumber?: string; email?: string }
|
||||
) => request.post<TokenInvoice>(`/token/orders/${orderNo}/invoice`, payload)
|
||||
|
||||
// 按分身和使用场景汇总 Token 消耗
|
||||
export const getTokenUsage = () =>
|
||||
request.get<TokenUsageSummary[]>('/token/usage')
|
||||
|
||||
@@ -179,7 +179,7 @@ const permissionItems: Array<{
|
||||
},
|
||||
{
|
||||
key: 'publish',
|
||||
title: '发布微博内容',
|
||||
title: '发布微播内容',
|
||||
description: '允许分身自动发布动态内容',
|
||||
tone: 'green',
|
||||
},
|
||||
@@ -691,7 +691,9 @@ svg {
|
||||
font-weight: 400;
|
||||
line-height: 1.45;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
white-space: normal;
|
||||
overflow-wrap: anywhere;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.permission-row.takeover .permission-copy small {
|
||||
|
||||
@@ -74,6 +74,35 @@
|
||||
{{ checkoutLabel }}
|
||||
</button>
|
||||
</section>
|
||||
|
||||
<section v-if="recentOrders.length" class="orders-section">
|
||||
<h3 class="section-title">充值记录</h3>
|
||||
<div v-for="order in recentOrders" :key="order.id" class="order-card">
|
||||
<div>
|
||||
<strong>{{ order.pointsAmount.toLocaleString() }} 积分</strong>
|
||||
<p>{{ order.orderNo }} · {{ order.createdAt ? new Date(order.createdAt).toLocaleDateString('zh-CN') : '' }}</p>
|
||||
</div>
|
||||
<div class="order-side">
|
||||
<strong>¥{{ order.price.toFixed(2) }}</strong>
|
||||
<button v-if="canInvoice(order)" class="text-btn" @click="openInvoice(order)">申请发票</button>
|
||||
<span v-else class="order-status">{{ orderStatus(order) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div v-if="invoiceOrder" class="modal-mask" @click.self="invoiceOrder = null">
|
||||
<form class="invoice-modal" @submit.prevent="submitInvoice">
|
||||
<h3>申请电子发票</h3>
|
||||
<label>发票类型
|
||||
<select v-model="invoiceType"><option value="personal">个人</option><option value="company">企业</option></select>
|
||||
</label>
|
||||
<label>发票抬头<input v-model.trim="invoiceTitle" maxlength="120" required /></label>
|
||||
<label v-if="invoiceType === 'company'">企业税号<input v-model.trim="invoiceTaxNumber" minlength="15" maxlength="20" required /></label>
|
||||
<label>接收邮箱<input v-model.trim="invoiceEmail" type="email" placeholder="选填" /></label>
|
||||
<p v-if="invoiceError" class="invoice-error">{{ invoiceError }}</p>
|
||||
<div class="modal-actions"><button type="button" @click="invoiceOrder = null">取消</button><button class="primary" :disabled="invoiceSubmitting">{{ invoiceSubmitting ? '提交中…' : '提交申请' }}</button></div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -82,8 +111,10 @@ import { computed, onMounted, onUnmounted, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import {
|
||||
chargeToken,
|
||||
applyTokenInvoice,
|
||||
getRechargePlans,
|
||||
getTokenBalance,
|
||||
getTokenOrders,
|
||||
getTokenPaymentStatus,
|
||||
type TokenPaymentOrder
|
||||
} from '@/api'
|
||||
@@ -114,6 +145,14 @@ const paymentMethod = ref<'wechat' | 'alipay'>('wechat')
|
||||
const paymentNotice = ref('')
|
||||
const paymentNoticeTone = ref<'pending' | 'success' | 'error'>('pending')
|
||||
const pendingOrderId = ref(sessionStorage.getItem('hh_pending_payment_order') || '')
|
||||
const recentOrders = ref<Array<TokenPaymentOrder & { invoice?: any }>>([])
|
||||
const invoiceOrder = ref<(TokenPaymentOrder & { invoice?: any }) | null>(null)
|
||||
const invoiceType = ref<'personal' | 'company'>('personal')
|
||||
const invoiceTitle = ref('')
|
||||
const invoiceTaxNumber = ref('')
|
||||
const invoiceEmail = ref('')
|
||||
const invoiceError = ref('')
|
||||
const invoiceSubmitting = ref(false)
|
||||
let pollTimer: number | undefined
|
||||
let pollDeadline = 0
|
||||
let removeNativeListener: (() => void) | undefined
|
||||
@@ -133,6 +172,51 @@ const loadData = async () => {
|
||||
} catch (e) {
|
||||
console.error('加载套餐失败', e)
|
||||
}
|
||||
try {
|
||||
const result = await getTokenOrders(1, 10)
|
||||
recentOrders.value = result?.items || []
|
||||
} catch (e) {
|
||||
console.error('加载充值记录失败', e)
|
||||
}
|
||||
}
|
||||
|
||||
const canInvoice = (order: TokenPaymentOrder & { invoice?: any }) =>
|
||||
order.status === 'paid' && (!order.refundStatus || order.refundStatus === 'none') &&
|
||||
(!order.invoice || ['rejected', 'cancelled'].includes(order.invoice.status))
|
||||
const orderStatus = (order: TokenPaymentOrder & { invoice?: any }) => {
|
||||
if (order.invoice?.status === 'issued') return '发票已开具'
|
||||
if (order.invoice?.status === 'pending') return '发票处理中'
|
||||
if (order.invoice?.status === 'rejected') return '发票已驳回'
|
||||
return ({ pending: '待支付', paid: '已支付', failed: '支付失败', closed: '已关闭', refunded: '已退款' } as Record<string, string>)[order.status] || order.status
|
||||
}
|
||||
const openInvoice = (order: TokenPaymentOrder & { invoice?: any }) => {
|
||||
invoiceOrder.value = order
|
||||
invoiceType.value = 'personal'
|
||||
invoiceTitle.value = ''
|
||||
invoiceTaxNumber.value = ''
|
||||
invoiceEmail.value = ''
|
||||
invoiceError.value = ''
|
||||
}
|
||||
const submitInvoice = async () => {
|
||||
if (!invoiceOrder.value || invoiceSubmitting.value) return
|
||||
invoiceSubmitting.value = true
|
||||
invoiceError.value = ''
|
||||
try {
|
||||
await applyTokenInvoice(invoiceOrder.value.orderNo, {
|
||||
title: invoiceTitle.value,
|
||||
invoiceType: invoiceType.value,
|
||||
taxNumber: invoiceTaxNumber.value,
|
||||
email: invoiceEmail.value
|
||||
})
|
||||
paymentNoticeTone.value = 'success'
|
||||
paymentNotice.value = '发票申请已提交,请等待财务处理'
|
||||
invoiceOrder.value = null
|
||||
await loadData()
|
||||
} catch (error: any) {
|
||||
invoiceError.value = error?.message || '发票申请提交失败'
|
||||
} finally {
|
||||
invoiceSubmitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 会会支付订单创建与到账确认
|
||||
@@ -151,8 +235,9 @@ const checkoutLabel = computed(() => {
|
||||
})
|
||||
|
||||
const payScene = () => {
|
||||
if (isInUniWebView()) return 'APP' as const
|
||||
if (paymentMethod.value === 'wechat' && /MicroMessenger/i.test(navigator.userAgent)) return 'JSAPI' as const
|
||||
return 'APP' as const
|
||||
return 'H5' as const
|
||||
}
|
||||
|
||||
const parsePayMessage = (message: string) => {
|
||||
@@ -231,6 +316,7 @@ const pollPayment = async () => {
|
||||
paymentNoticeTone.value = 'success'
|
||||
paymentNotice.value = `支付成功,${order.pointsAmount.toLocaleString()} 积分已到账`
|
||||
clearPendingOrder()
|
||||
void loadData()
|
||||
return
|
||||
}
|
||||
if (order.status === 'failed') {
|
||||
@@ -562,6 +648,17 @@ onUnmounted(() => {
|
||||
padding: 0 20px;
|
||||
}
|
||||
|
||||
.orders-section { padding: 24px 20px 0; }
|
||||
.order-card { display:flex; align-items:center; justify-content:space-between; padding:14px 16px; margin-bottom:10px; background:#fff; border:1px solid #EDEEF1; border-radius:12px; }
|
||||
.order-card strong { color:#18191C; font-size:14px; }.order-card p,.order-status { color:#9398AE; font-size:11px; margin:5px 0 0; }
|
||||
.order-side { text-align:right; }.text-btn { display:block; margin-top:5px; padding:0; border:0; background:none; color:#F97316; font-size:12px; cursor:pointer; }
|
||||
.modal-mask { position:fixed; inset:0; z-index:20; display:grid; place-items:center; padding:20px; background:rgba(15,23,42,.45); }
|
||||
.invoice-modal { width:min(100%,420px); padding:22px; border-radius:16px; background:#fff; box-shadow:0 18px 50px rgba(15,23,42,.2); }
|
||||
.invoice-modal h3 { margin:0 0 18px; }.invoice-modal label { display:grid; gap:7px; margin:12px 0; color:#4B5563; font-size:13px; }
|
||||
.invoice-modal input,.invoice-modal select { width:100%; height:42px; padding:0 12px; border:1px solid #D9DCE3; border-radius:9px; background:#fff; color:#18191C; font-size:14px; }
|
||||
.invoice-error { color:#B42318; font-size:12px; }.modal-actions { display:flex; justify-content:flex-end; gap:10px; margin-top:20px; }
|
||||
.modal-actions button { padding:9px 18px; border:1px solid #D9DCE3; border-radius:9px; background:#fff; }.modal-actions .primary { border-color:#F97316; background:#F97316; color:#fff; }
|
||||
|
||||
.checkout-btn {
|
||||
width: 100%;
|
||||
padding: 16px;
|
||||
|
||||
+3
-1
@@ -20,11 +20,13 @@ services:
|
||||
- AVATAR_MODEL_CONFIG_TOKEN=${AVATAR_MODEL_CONFIG_TOKEN:-}
|
||||
- TZ=Asia/Shanghai
|
||||
- AVATAR_DB_PATH=/app/avatar.db
|
||||
- AVATAR_BACKEND_URL=${AVATAR_BACKEND_URL:-}
|
||||
- AVATAR_FINANCE_ADMIN_SECRET=${AVATAR_FINANCE_ADMIN_SECRET:-}
|
||||
volumes:
|
||||
- ./backend/app:/app/app # ← 核心:代码目录直接挂载,改文件无需重建
|
||||
- ./backend/logs:/app/logs
|
||||
- ./backend/config:/app/config
|
||||
- ./digital-avatar-app/backend/avatar.db:/app/avatar.db:ro # 数字分身 SQLite(只读)
|
||||
- ./digital-avatar-app/backend/avatar.db:/app/avatar.db # 财务管理需要写入退款与开票状态
|
||||
depends_on:
|
||||
- ai-virtual-mysql
|
||||
- ai-virtual-redis
|
||||
|
||||
@@ -94,5 +94,15 @@ export const uploadAvatarPhoto = (id, formData) => request.post(`/avatars/${id}/
|
||||
headers: { 'Content-Type': 'multipart/form-data' }
|
||||
})
|
||||
|
||||
// Finance (数字分身积分订单)
|
||||
export const getFinanceSummary = () => request.get('/finance/summary')
|
||||
export const getFinanceOrders = (params) => request.get('/finance/orders', { params })
|
||||
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 updateFinanceInvoice = (invoiceId, data) => request.patch(`/finance/invoices/${invoiceId}`, data)
|
||||
|
||||
export default request
|
||||
export const uploadAvatar = (userId, formData) => request.post(`/users/${userId}/upload-avatar`, formData, { headers: { "Content-Type": "multipart/form-data" } })
|
||||
|
||||
@@ -15,6 +15,10 @@
|
||||
<el-icon><UserFilled /></el-icon>
|
||||
<span>数字分身管理</span>
|
||||
</el-menu-item>
|
||||
<el-menu-item index="/finance">
|
||||
<el-icon><WalletFilled /></el-icon>
|
||||
<span>财务管理</span>
|
||||
</el-menu-item>
|
||||
<el-menu-item index="/users">
|
||||
<el-icon><User /></el-icon>
|
||||
<span>虚拟用户</span>
|
||||
|
||||
@@ -8,6 +8,7 @@ const routes = [
|
||||
{ path: '', redirect: '/dashboard' },
|
||||
{ path: 'dashboard', component: () => import('@/views/Dashboard.vue'), meta: { title: '数据看板' } },
|
||||
{ path: 'avatars', component: () => import('@/views/Avatars.vue'), meta: { title: '数字分身管理' } },
|
||||
{ path: 'finance', component: () => import('@/views/Finance.vue'), meta: { title: '财务管理' } },
|
||||
{ path: 'users', component: () => import('@/views/Users.vue'), meta: { title: '虚拟用户管理' } },
|
||||
{ path: 'interactions', component: () => import('@/views/Interactions.vue'), meta: { title: '互动记录' } },
|
||||
{ path: 'ai-models', component: () => import('@/views/AIModels.vue'), meta: { title: 'AI模型配置' } },
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
<template>
|
||||
<div class="page-container finance-page">
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<div class="page-title">财务管理</div>
|
||||
<div class="subtitle">数字分身积分订单、退款与发票处理</div>
|
||||
</div>
|
||||
<el-button :loading="loading" @click="loadAll"><el-icon><Refresh /></el-icon>刷新</el-button>
|
||||
</div>
|
||||
|
||||
<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 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>
|
||||
|
||||
<el-tabs v-model="activeTab" class="finance-tabs" @tab-change="loadActive">
|
||||
<el-tab-pane label="支付订单" name="orders">
|
||||
<div class="filter-bar">
|
||||
<el-input v-model="filters.keyword" placeholder="订单号、昵称或手机号" clearable style="width:240px" @keyup.enter="loadOrders" />
|
||||
<el-select v-model="filters.status" placeholder="订单状态" clearable style="width:140px" @change="loadOrders">
|
||||
<el-option v-for="item in orderStatuses" :key="item.value" :label="item.label" :value="item.value" />
|
||||
</el-select>
|
||||
<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-select>
|
||||
<el-button type="primary" @click="loadOrders">查询</el-button>
|
||||
</div>
|
||||
<el-table :data="orders" v-loading="loading" class="data-table">
|
||||
<el-table-column label="订单 / 用户" min-width="230">
|
||||
<template #default="{ row }"><b>{{ row.order_no }}</b><div class="muted">{{ row.user_nickname || '会会用户' }} · {{ row.user_phone || '--' }}</div></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="130"><template #default="{ row }">{{ providerLabel[row.provider] || row.provider }}</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="退款" width="100"><template #default="{ row }"><el-tag v-if="row.refund_status && row.refund_status !== 'none'" :type="tagType(row.refund_status)">{{ statusLabel[row.refund_status] || row.refund_status }}</el-tag><span v-else>--</span></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">
|
||||
<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-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>
|
||||
</el-dropdown>
|
||||
<span v-if="row.status !== 'pending' && !(row.status === 'paid' && ['none','',null].includes(row.refund_status))" class="muted">已处理</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<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 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-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="180" prop="title" />
|
||||
<el-table-column label="类型 / 税号" min-width="190"><template #default="{ row }">{{ row.invoice_type === 'company' ? '企业' : '个人' }}<div class="muted">{{ row.tax_number || '--' }}</div></template></el-table-column>
|
||||
<el-table-column label="金额" width="100"><template #default="{ row }">¥{{ money(row.amount) }}</template></el-table-column>
|
||||
<el-table-column label="邮箱" min-width="175" prop="email" />
|
||||
<el-table-column label="状态" width="100"><template #default="{ row }"><el-tag :type="tagType(row.status)">{{ invoiceStatusLabel[row.status] || row.status }}</el-tag></template></el-table-column>
|
||||
<el-table-column label="操作" width="145" fixed="right"><template #default="{ row }"><template v-if="row.status === 'pending'"><el-button link type="primary" @click="openInvoice(row)">开具</el-button><el-button link type="danger" @click="rejectInvoice(row)">驳回</el-button></template><a v-else-if="row.invoice_url" :href="row.invoice_url" target="_blank">查看发票</a><span v-else class="muted">{{ row.invoice_no || row.remark || '已处理' }}</span></template></el-table-column>
|
||||
</el-table>
|
||||
</el-tab-pane>
|
||||
</el-tabs>
|
||||
|
||||
<el-dialog v-model="invoiceDialog" title="登记已开发票" width="480px">
|
||||
<el-form label-width="92px">
|
||||
<el-form-item label="发票号码"><el-input v-model="invoiceForm.invoiceNo" maxlength="120" /></el-form-item>
|
||||
<el-form-item label="发票地址"><el-input v-model="invoiceForm.invoiceUrl" placeholder="电子发票下载地址(选填)" /></el-form-item>
|
||||
<el-form-item label="备注"><el-input v-model="invoiceForm.remark" type="textarea" /></el-form-item>
|
||||
</el-form>
|
||||
<template #footer><el-button @click="invoiceDialog=false">取消</el-button><el-button type="primary" :loading="saving" @click="issueInvoice">确认开具</el-button></template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { confirmFinanceRefund, getFinanceInvoices, getFinanceOrders, getFinanceRefunds, getFinanceSummary, requestFinanceRefund, updateFinanceInvoice, updateFinanceOrderStatus } from '@/api'
|
||||
|
||||
const activeTab = ref('orders')
|
||||
const loading = 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 orders = ref([]), refunds = ref([]), invoices = ref([])
|
||||
const orderPage = ref(1), orderTotal = ref(0)
|
||||
const filters = ref({ keyword: '', status: '', provider: '' })
|
||||
const invoiceDialog = ref(false), currentInvoice = ref(null)
|
||||
const invoiceForm = ref({ invoiceNo: '', invoiceUrl: '', remark: '' })
|
||||
const orderStatuses = [{ label: '待支付', value: 'pending' }, { label: '已支付', value: 'paid' }, { label: '失败', value: 'failed' }, { label: '已关闭', value: 'closed' }, { label: '已退款', value: 'refunded' }]
|
||||
const providerLabel = { huihui: '会会支付', wechat_virtual: '微信虚拟支付' }
|
||||
const statusLabel = { pending: '待处理', processing: '处理中', paid: '已支付', succeeded: '已成功', failed: '失败', closed: '已关闭', refunded: '已退款' }
|
||||
const invoiceStatusLabel = { pending: '待开具', issued: '已开具', rejected: '已驳回' }
|
||||
const money = value => Number(value || 0).toFixed(2)
|
||||
const dateTime = value => value ? new Date(value).toLocaleString('zh-CN', { hour12: false }) : '--'
|
||||
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 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 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 loadAll() { loading.value = true; try { await Promise.all([loadSummary(), loadOrders(), loadRefunds(), loadInvoices()]) } finally { loading.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 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 }
|
||||
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 {} }
|
||||
onMounted(loadAll)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.finance-page { overflow-y: auto; }
|
||||
.subtitle, .muted, small { color: var(--color-text-muted); font-size: 12px; margin-top: 5px; }
|
||||
.summary-grid { display:grid; grid-template-columns:repeat(4,minmax(0,1fr)); gap:16px; margin-bottom:20px; }
|
||||
.stat-value { margin:8px 0 4px; }.stat-value.warning{color:var(--color-accent-orange)}.stat-value.danger{color:var(--color-accent-red)}.stat-value.success{color:var(--color-accent-green)}
|
||||
.finance-tabs { background:#fff; border:1px solid var(--color-border); border-radius:12px; padding:8px 16px 16px; box-shadow:var(--shadow-sm); }
|
||||
.filter-bar { display:flex; gap:10px; margin:8px 0 16px; }.el-pagination{justify-content:flex-end;margin-top:16px}
|
||||
a { color:var(--color-accent); text-decoration:none; }
|
||||
@media (max-width: 1100px) { .summary-grid{grid-template-columns:repeat(2,1fr)} }
|
||||
</style>
|
||||
@@ -61,7 +61,7 @@ H5 引入 uniapp web-view bridge 后调用:
|
||||
壳通过 `web-view.evalJS` 调用 H5 全局函数 `window.__uniBridgeHandle__(message)`:
|
||||
| type | payload | 含义 |
|
||||
|------|---------|------|
|
||||
| `context` | `platform, version` | 注入运行环境信息 |
|
||||
| `context` | `surface, version` | 注入运行环境;`surface` 为 `app` / `mp-weixin` / `h5` |
|
||||
| `tokenRefresh` | `token` | 登录刷新后下发新 token |
|
||||
| `userUpdate` | `user` | 会会资料变更 |
|
||||
| `paymentResult` | `orderId,status` | 原生支付结束通知;`status` 为 `success/cancelled/failed` |
|
||||
@@ -70,6 +70,8 @@ H5 引入 uniapp web-view bridge 后调用:
|
||||
|
||||
壳收到 `payment` 后应调用会会 App 已有的微信/支付宝支付能力(或 `uni.requestPayment`),把 `payMessage/paymentParams` 原样交给对应渠道。原生 SDK 返回后再发送 `paymentResult`;H5 不以原生返回作为到账依据,只会轮询本地订单,最终由会会服务端支付回调确认并增加积分。
|
||||
|
||||
微信小程序虚拟支付本期只交付后端能力(登录态交换、签名下单参数、服务端查单/退款和回调验收)。小程序原生充值页接入 `requestVirtualPayment` 后,应把后端返回的 `signData/paySig/signature/mode/env/offerId` 原样传入微信 API;不要在 web-view 中发起虚拟支付。
|
||||
|
||||
---
|
||||
|
||||
## 3. 项目结构(uni CLI / src 布局,已验证可编译)
|
||||
@@ -84,13 +86,13 @@ uniapp-avatar/
|
||||
├── manifest.json # 应用配置(名称/AppID/模块)
|
||||
├── pages.json # 页面路由
|
||||
├── uni.scss # 全局样式变量
|
||||
├── App.vue # 启动即做会会登录(onLaunch → userStore.init)
|
||||
├── App.vue # 启动时恢复会会登录态(onLaunch → userStore.init)
|
||||
├── main.js # createSSRApp + pinia
|
||||
├── pages/index/index.vue # web-view 容器(内嵌 digital-avatar-app H5)
|
||||
├── store/user.js # 会会会话(token/资料,本地缓存)
|
||||
├── store/user.js # 会会会话(宿主调用 applySession 注入并缓存)
|
||||
└── utils/
|
||||
├── bridge.js # H5 URL 构造 + 原生→H5 推送
|
||||
└── huihui.js # 会会登录(MOCK,留真实接入位)
|
||||
└── payment.js # App 微信/支付宝原生支付适配
|
||||
```
|
||||
> 构建产物:`npm run build:h5` → `dist/build/h5/`(含 index.html + assets)。
|
||||
|
||||
@@ -114,10 +116,9 @@ npm run build:h5 # 生产构建 → dist/build/h5/
|
||||
> 若 npm 依赖版本与本地 HBuilderX 不一致,执行 `npx @dcloudio/uvm` 对齐。
|
||||
|
||||
### 会会登录接入
|
||||
- 当前 `utils/huihui.js` 为 **MOCK**(`MOCK_AUTH = true`),便于联调。
|
||||
- 生产接入:把 `MOCK_AUTH` 改为 `false`,在 `loginHuihui()` 接入会会开放平台授权,
|
||||
换取 `access_token`、`userId`;`getUserInfo()` 请求会会 `usercenter` 真实资料接口
|
||||
(接口基址见 `docs/production-interface-inventory.md`)。
|
||||
- 壳只恢复会会宿主已经持有的登录态,不内置演示账号,也不会伪造会会 token。
|
||||
- 会会主 App 完成登录或刷新后调用 `userStore.applySession({ token, userId, nickname, avatarUrl })`;数字分身 H5 会把一次性会会 token 换成本系统会话并立即从地址中清除。
|
||||
- 独立打开且没有宿主会话时,H5 会进入已有的短信登录流程。
|
||||
|
||||
---
|
||||
|
||||
|
||||
Generated
+7937
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"name": "uniapp-avatar",
|
||||
"version": "1.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev:h5": "uni -p h5",
|
||||
"build:h5": "uni build -p h5",
|
||||
"dev:mp-weixin": "uni -p mp-weixin",
|
||||
"build:mp-weixin": "uni build -p mp-weixin",
|
||||
"dev:app": "uni -p app",
|
||||
"build:app": "uni build -p app"
|
||||
},
|
||||
"dependencies": {
|
||||
"@dcloudio/uni-app": "3.0.0-4010520240507001",
|
||||
"@dcloudio/uni-app-plus": "3.0.0-4010520240507001",
|
||||
"@dcloudio/uni-components": "3.0.0-4010520240507001",
|
||||
"@dcloudio/uni-h5": "3.0.0-4010520240507001",
|
||||
"@dcloudio/uni-mp-weixin": "3.0.0-4010520240507001",
|
||||
"pinia": "^2.0.36",
|
||||
"vue": "^3.4.21"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@dcloudio/uni-cli-shared": "3.0.0-4010520240507001",
|
||||
"@dcloudio/vite-plugin-uni": "3.0.0-4010520240507001",
|
||||
"vite": "^5.2.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
<script setup>
|
||||
import { onLaunch } from '@dcloudio/uni-app'
|
||||
import { useUserStore } from '@/store/user'
|
||||
|
||||
onLaunch(() => useUserStore().init())
|
||||
</script>
|
||||
|
||||
<style>
|
||||
page { background:#f5f6f8; }
|
||||
</style>
|
||||
@@ -0,0 +1,9 @@
|
||||
import { createSSRApp } from 'vue'
|
||||
import { createPinia } from 'pinia'
|
||||
import App from './App.vue'
|
||||
|
||||
export function createApp() {
|
||||
const app = createSSRApp(App)
|
||||
app.use(createPinia())
|
||||
return { app }
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"name": "会会数字分身",
|
||||
"appid": "",
|
||||
"description": "会会数字分身 uni-app 混合壳",
|
||||
"versionName": "1.1.0",
|
||||
"versionCode": "110",
|
||||
"transformPx": false,
|
||||
"app-plus": {
|
||||
"usingComponents": true,
|
||||
"compilerVersion": 3,
|
||||
"modules": { "Payment": {} },
|
||||
"distribute": {
|
||||
"android": { "permissions": ["<uses-permission android:name=\"android.permission.INTERNET\"/>"] },
|
||||
"ios": {},
|
||||
"sdkConfigs": {}
|
||||
}
|
||||
},
|
||||
"mp-weixin": { "appid": "", "setting": { "urlCheck": true }, "usingComponents": true },
|
||||
"h5": { "publicPath": "./", "router": { "base": "./" } },
|
||||
"vueVersion": "3"
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"pages": [{
|
||||
"path": "pages/index/index",
|
||||
"style": {
|
||||
"navigationBarTitleText": "会会数字分身",
|
||||
"navigationBarBackgroundColor": "#0F2B4C",
|
||||
"navigationBarTextStyle": "white"
|
||||
}
|
||||
}],
|
||||
"globalStyle": {
|
||||
"navigationBarTextStyle": "white",
|
||||
"navigationBarTitleText": "会会数字分身",
|
||||
"navigationBarBackgroundColor": "#0F2B4C",
|
||||
"backgroundColor": "#F5F6F8"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
<template>
|
||||
<view class="shell">
|
||||
<web-view ref="webview" :src="h5Url" @message="onH5Message" />
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, ref } from 'vue'
|
||||
import { useUserStore } from '@/store/user'
|
||||
import { buildH5Url, parseH5Message, postToH5 } from '@/utils/bridge'
|
||||
import { requestAppPayment } from '@/utils/payment'
|
||||
|
||||
const userStore = useUserStore()
|
||||
const webview = ref(null)
|
||||
const H5_BASE_URL = import.meta.env.VITE_AVATAR_H5_URL || 'https://digital.99hui.com/'
|
||||
const h5Url = computed(() => buildH5Url(H5_BASE_URL, userStore.$state))
|
||||
|
||||
function runtimeSurface() {
|
||||
let value = 'h5'
|
||||
// #ifdef APP-PLUS
|
||||
value = 'app'
|
||||
// #endif
|
||||
// #ifdef MP-WEIXIN
|
||||
value = 'mp-weixin'
|
||||
// #endif
|
||||
return value
|
||||
}
|
||||
|
||||
async function handlePayment(payment) {
|
||||
try {
|
||||
await requestAppPayment(payment)
|
||||
postToH5(webview.value, { type: 'paymentResult', orderId: payment.orderId, status: 'success' })
|
||||
} catch (error) {
|
||||
const message = error?.message || '支付未完成'
|
||||
const status = /cancel/i.test(message) || /取消/.test(message) ? 'cancelled' : 'failed'
|
||||
postToH5(webview.value, { type: 'paymentResult', orderId: payment.orderId, status, message })
|
||||
}
|
||||
}
|
||||
|
||||
function onH5Message(event) {
|
||||
const message = parseH5Message(event)
|
||||
if (!message?.type) return
|
||||
if (message.type === 'ready') {
|
||||
postToH5(webview.value, { type: 'context', surface: runtimeSurface(), version: '1.1.0' })
|
||||
} else if (message.type === 'payment') {
|
||||
void handlePayment(message.payment)
|
||||
} else if (message.type === 'setTitle' && message.title) {
|
||||
uni.setNavigationBarTitle({ title: message.title })
|
||||
} else if (message.type === 'back') {
|
||||
uni.navigateBack({ delta: 1 })
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style>.shell{width:100%;height:100vh}</style>
|
||||
@@ -0,0 +1,24 @@
|
||||
import { defineStore } from 'pinia'
|
||||
|
||||
export const useUserStore = defineStore('user', {
|
||||
state: () => ({ token: '', userId: '', nickname: '', avatarUrl: '', initialized: false }),
|
||||
getters: { isLogin: state => Boolean(state.token) },
|
||||
actions: {
|
||||
init() {
|
||||
this.$patch({
|
||||
token: uni.getStorageSync('hh_token') || '',
|
||||
userId: uni.getStorageSync('hh_userId') || '',
|
||||
nickname: uni.getStorageSync('hh_nickname') || '',
|
||||
avatarUrl: uni.getStorageSync('hh_avatarUrl') || '',
|
||||
initialized: true
|
||||
})
|
||||
},
|
||||
applySession(session) {
|
||||
this.$patch(session)
|
||||
uni.setStorageSync('hh_token', this.token)
|
||||
uni.setStorageSync('hh_userId', this.userId)
|
||||
uni.setStorageSync('hh_nickname', this.nickname)
|
||||
uni.setStorageSync('hh_avatarUrl', this.avatarUrl)
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,3 @@
|
||||
$brand-navy: #0f2b4c;
|
||||
$brand-orange: #f97316;
|
||||
$bg-page: #f5f6f8;
|
||||
@@ -0,0 +1,33 @@
|
||||
export function buildH5Url(base, session) {
|
||||
const url = new URL(base)
|
||||
if (session.token) url.searchParams.set('token', session.token)
|
||||
if (session.userId) url.searchParams.set('userId', session.userId)
|
||||
if (session.nickname) url.searchParams.set('nickname', session.nickname)
|
||||
if (session.avatarUrl) url.searchParams.set('avatar', session.avatarUrl)
|
||||
url.searchParams.set('ts', String(Date.now()))
|
||||
return url.toString()
|
||||
}
|
||||
|
||||
export function postToH5(webviewRef, message) {
|
||||
if (!webviewRef) return false
|
||||
const js = `window.__uniBridgeHandle__&&window.__uniBridgeHandle__(${JSON.stringify(message)})`
|
||||
try {
|
||||
if (typeof webviewRef.evalJS === 'function') {
|
||||
webviewRef.evalJS(js)
|
||||
return true
|
||||
}
|
||||
// #ifdef APP-PLUS
|
||||
const child = plus.webview.currentWebview().children()[0]
|
||||
if (child) { child.evalJS(js); return true }
|
||||
// #endif
|
||||
} catch (error) {
|
||||
console.error('[bridge] postToH5 failed', error)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
export function parseH5Message(event) {
|
||||
const items = event?.detail?.data
|
||||
if (Array.isArray(items) && items.length) return items[items.length - 1]
|
||||
return event?.detail || null
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
function parseOrderInfo(payment) {
|
||||
if (payment.paymentParams && typeof payment.paymentParams === 'object') return payment.paymentParams
|
||||
if (typeof payment.payMessage !== 'string') return payment.payMessage
|
||||
try { return JSON.parse(payment.payMessage) } catch { return payment.payMessage }
|
||||
}
|
||||
|
||||
export function requestAppPayment(payment) {
|
||||
if (!payment || payment.payWay !== 'APP') {
|
||||
return Promise.reject(new Error('当前订单不是 App 支付订单'))
|
||||
}
|
||||
const provider = payment.paymentMethod === 'alipay' ? 'alipay' : 'wxpay'
|
||||
const orderInfo = parseOrderInfo(payment)
|
||||
if (!orderInfo) return Promise.reject(new Error('支付参数为空'))
|
||||
return new Promise((resolve, reject) => {
|
||||
uni.requestPayment({
|
||||
provider,
|
||||
orderInfo,
|
||||
success: resolve,
|
||||
fail: error => reject(new Error(error?.errMsg || '支付未完成'))
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import uni from '@dcloudio/vite-plugin-uni'
|
||||
|
||||
const uniPlugin = typeof uni === 'function' ? uni : uni.default
|
||||
export default defineConfig({ plugins: [uniPlugin()] })
|
||||
Reference in New Issue
Block a user