Compare commits

..
Author SHA1 Message Date
stefanfeng b3367eedaa revert: sync production code to verified test version 2026-09-16 14:57:37 +08:00
stefanfeng e6a4988cf9 Merge pull request 'revert: restore production to avatar PR 28' (#33) from codex/rollback-production-to-pr28-20260916 into main 2026-09-16 11:27:37 +08:00
stefanfeng becf2c7c52 revert: restore production to avatar PR 28 2026-09-16 11:26:47 +08:00
stefanfeng d421a9da72 Merge pull request 'revert: restore authorization management behavior' (#32) from codex/rollback-auth-availability-20260915 into main
Reviewed-on: #32
2026-09-15 15:45:20 +08:00
stefanfeng f4389612d1 revert: restore authorization management behavior 2026-09-15 15:43:23 +08:00
stefanfeng ac5a332bce Merge pull request 'fix: align authorization page with frontend api types' (#31) from codex/fix-frontend-build-20260915 into main
Reviewed-on: #31
2026-09-15 15:04:54 +08:00
stefanfeng 9ba89ef2d1 fix: align authorization page with frontend api types 2026-09-15 14:42:20 +08:00
stefanfeng b1023eb783 Merge pull request 'feat: refine embedded avatar authorization controls' (#30) from codex/avatar-auth-controls-20260915 into main
Reviewed-on: #30
2026-09-15 14:34:38 +08:00
stefanfeng 2f287ef538 feat: refine embedded avatar authorization controls 2026-09-15 14:27:22 +08:00
stefanfeng f5cbbe9eef Merge pull request 'fix(avatar): restore app shell session before loading h5' (#28) from codex/avatar-app-open-hotfix into main
Reviewed-on: #28
2026-09-11 15:47:55 +08:00
stefanfeng 5fc56143ee fix(avatar): restore app shell session before loading h5 2026-09-11 15:46:31 +08:00
stefanfeng d521585bf2 Merge pull request 'fix(avatar): mark native shell before bridge readiness' (#27) from codex/avatar-shell-recharge-hotfix into main
Reviewed-on: #27
2026-09-11 13:40:03 +08:00
stefanfeng 35e47bf0a1 fix(avatar): mark native shell before bridge readiness 2026-09-11 13:39:41 +08:00
stefanfeng f52e42d9c0 Merge pull request 'fix(avatar): 仅在 uniapp 容器展示充值入口' (#26) from codex/avatar-payments-finance-20260908 into main
Reviewed-on: #26
2026-09-11 12:12:25 +08:00
stefanfeng 1ab56ad0b1 fix(avatar): 仅在 uniapp 容器展示充值入口 2026-09-11 11:56:48 +08:00
stefanfeng 1889c8ebba Merge pull request 'fix(avatar): 强制每轮跟随对方当前语言' (#25) from codex/avatar-language-enforcement-20260909 into main
Reviewed-on: #25
2026-09-11 11:55:21 +08:00
stefanfeng 910a05107a Merge pull request 'fix(avatar): 杜绝发布后继续运行旧镜像' (#24) from codex/avatar-deploy-integrity-20260909 into main
Reviewed-on: #24
2026-09-11 11:55:13 +08:00
stefanfeng 857d6f2562 fix(avatar): enforce each turn response language 2026-09-09 13:33:45 +08:00
stefanfeng 5ce12771b9 fix(avatar): persist SQLite WAL across releases 2026-09-09 11:06:03 +08:00
stefanfeng 4c152230aa build(avatar): preserve backend dependency cache 2026-09-09 10:58:09 +08:00
stefanfeng 9ce46cd883 fix(avatar): support legacy deployment Python 2026-09-09 10:56:43 +08:00
stefanfeng 140ac20281 fix(avatar): prevent stale deployment images 2026-09-09 10:47:18 +08:00
stefanfeng 9afc2d5a6c Merge pull request 'feat: 数字分身支付与财务管理' (#23) from codex/avatar-payments-finance-20260908 into main
Reviewed-on: #23
2026-09-09 10:13:41 +08:00
stefanfeng 8585d101d5 feat: add avatar payments and finance management 2026-09-08 18:47:29 +08:00
stefanfeng 62eb9578fd Merge pull request 'fix(avatar): 支持扫描型 PDF 知识库 OCR 索引' (#22) from codex/avatar-scanned-pdf-ocr-20260908 into main
Reviewed-on: #22
2026-09-08 17:19:47 +08:00
stefanfeng 848657219e fix(avatar): OCR image-only knowledge PDFs 2026-09-08 16:44:18 +08:00
stefanfeng 1bcdcead8d Merge pull request 'feat: 数字分身按授权参与广场互动' (#21) from codex/avatar-square-interaction-authorization-20260908 into main
Reviewed-on: #21
2026-09-08 15:09:08 +08:00
stefanfeng bb9e9da1f3 Merge pull request 'fix(avatar): 每轮对话跟随用户当前语言' (#20) from codex/avatar-per-turn-language-20260908 into main
Reviewed-on: #20
2026-09-08 13:06:10 +08:00
stefanfeng 28fcd5373b fix(avatar): follow language changes each turn 2026-09-08 10:23:34 +08:00
50 changed files with 10818 additions and 68 deletions
+2
View File
@@ -9,6 +9,8 @@ backend/logs/
# Node
frontend/node_modules/
frontend/dist/
uniapp-avatar/node_modules/
uniapp-avatar/dist/
# macOS
.DS_Store
+2 -1
View File
@@ -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=["财务管理"])
+130
View File
@@ -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()
+1 -1
View File
@@ -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()
+254
View File
@@ -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()
+91
View File
@@ -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()
+9
View File
@@ -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
View File
@@ -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` 启动
+23
View File
@@ -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)"
)
+28 -3
View File
@@ -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():
+114
View File
@@ -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())
@@ -5,6 +5,7 @@ pydantic
python-multipart
httpx
pypdf
PyMuPDF>=1.24,<2
python-docx
openpyxl
apscheduler>=3.10
+22 -2
View File
@@ -478,6 +478,24 @@ def _qa_requires_language_adaptation(question: str, answer: str) -> bool:
)
def _qa_requires_per_turn_rendering(
question: str,
answer: str,
history: list[Any],
) -> bool:
"""Keep the direct QA fast path only when no conversation can bias language."""
return bool(history) or _qa_requires_language_adaptation(question, answer)
def _per_turn_language_instruction() -> str:
return (
"本轮语言覆盖指令:只根据紧随其后的最新用户消息判断本轮回答语言。"
"即使此前整段对话一直使用另一种语言,只要最新消息切换了语言,本轮就必须立即切换到相同语言;"
"不要沿用上一轮语言。若最新消息明确指定回答语言,以该指定为准;若混用多种语言,使用其中占主导的"
"自然语言。不要说明你检测、切换或翻译了语言。"
)
def _canonicalize_question(value: str) -> str:
value = _normalize_question(value)
replacements = (
@@ -709,6 +727,8 @@ def _build_prompt(
messages = [{"role": "system", "content": system}]
for item in history[-MAX_HISTORY_MESSAGES:]:
messages.append({"role": item.role, "content": item.content} if hasattr(item, "role") else item)
# Keep the language instruction adjacent to the current turn so long histories cannot override it.
messages.append({"role": "system", "content": _per_turn_language_instruction()})
messages.append({"role": "user", "content": question.strip()})
return messages
@@ -845,7 +865,7 @@ def _resolve_reply(
qa_pairs = db.query(QAPair).filter(QAPair.avatar_id == avatar.id).all()
matched = _match_standard_qa(question, qa_pairs)
adapt_qa_language = bool(
matched and _qa_requires_language_adaptation(question, matched.answer)
matched and _qa_requires_per_turn_rendering(question, matched.answer, history)
)
if matched and not adapt_qa_language and not image_contexts:
return {"answer": matched.answer, "source": "qa", "references": []}
@@ -940,7 +960,7 @@ def _stream_reply(
qa_pairs = db.query(QAPair).filter(QAPair.avatar_id == avatar.id).all()
matched = _match_standard_qa(question, qa_pairs)
adapt_qa_language = bool(
matched and _qa_requires_language_adaptation(question, matched.answer)
matched and _qa_requires_per_turn_rendering(question, matched.answer, history)
)
messages, reservation = [], None
if matched and not adapt_qa_language and not image_contexts:
+594 -26
View File
@@ -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}
@@ -8,7 +8,8 @@ import threading
from datetime import datetime, timezone
from database import SessionLocal
from models import KnowledgeChunk, KnowledgeDoc
from models import Avatar, KnowledgeChunk, KnowledgeDoc
from services.pdf_ocr_service import extract_scanned_pdf_text
import embeddings
logger = logging.getLogger(__name__)
@@ -76,7 +77,23 @@ class KnowledgeVectorizer:
self._set_progress(db, doc, "extracting", 8)
text = embeddings.extract_text(path, f".{doc.file_type}")
self._set_progress(db, doc, "chunking", 22)
if doc.file_type == "pdf" and not text.strip():
avatar = db.get(Avatar, doc.avatar_id)
if not avatar:
raise ValueError("文档所属分身不存在")
def ocr_progress(done: int, total: int):
percent = 8 + int((done / max(1, total)) * 20)
self._set_progress(db, doc, "ocr", min(percent, 28))
self._set_progress(db, doc, "ocr", 8)
text = extract_scanned_pdf_text(
db,
avatar,
path,
on_progress=ocr_progress,
)
self._set_progress(db, doc, "chunking", 29)
chunks = embeddings.chunk_text(text)
if not chunks:
raise ValueError("文档没有可建立索引的文字内容")
@@ -0,0 +1,130 @@
"""OCR fallback for image-only PDF knowledge documents."""
import logging
import os
import time
from typing import Callable
from sqlalchemy.orm import Session
from models import Avatar
from services.chat_model_config import get_chat_model_config
from services.token_billing import (
estimate_fallback_usage,
release_reservation,
reserve_avatar_tokens,
settle_reservation,
)
from services.vision_service import call_vision_model, prepare_image
logger = logging.getLogger(__name__)
PDF_OCR_PROMPT = (
"请逐字转录这一页扫描文档中的全部可见文字和表格,只输出转录内容,不要解释,不要使用 Markdown 代码块。"
"保留标题、段落、项目编号、数值和自然换行;看不清的内容写作[无法辨认],不要猜测、纠错或补全。"
)
def _positive_int(name: str, default: int, minimum: int, maximum: int) -> int:
try:
value = int(os.getenv(name, str(default)))
except ValueError:
value = default
return max(minimum, min(maximum, value))
def extract_scanned_pdf_text(
db: Session,
avatar: Avatar,
path: str,
*,
on_progress: Callable[[int, int], None] | None = None,
) -> str:
"""Render and OCR an image-only PDF while preserving page order."""
try:
import pymupdf
except ImportError as exc:
raise RuntimeError("扫描型 PDF 识别组件未安装") from exc
max_pages = _positive_int("KNOWLEDGE_PDF_OCR_MAX_PAGES", 80, 1, 300)
render_dpi = _positive_int("KNOWLEDGE_PDF_OCR_DPI", 144, 96, 200)
max_attempts = _positive_int("KNOWLEDGE_PDF_OCR_ATTEMPTS", 3, 1, 5)
model_config = get_chat_model_config()
model = model_config.ocr_model or model_config.vision_model
if not model_config.api_key or not model:
raise RuntimeError("扫描型 PDF 需要配置视觉 OCR 模型")
texts: list[str] = []
with pymupdf.open(path) as document:
total_pages = document.page_count
if total_pages <= 0:
raise ValueError("PDF 没有可识别页面")
if total_pages > max_pages:
raise ValueError(
f"扫描型 PDF 共 {total_pages} 页,超过单次 OCR 上限 {max_pages} 页,请拆分后上传"
)
scale = render_dpi / 72
for page_index in range(total_pages):
page = document.load_page(page_index)
pixmap = page.get_pixmap(
matrix=pymupdf.Matrix(scale, scale),
colorspace=pymupdf.csRGB,
alpha=False,
)
prepared = prepare_image(pixmap.tobytes("jpeg", jpg_quality=88))
estimate_messages = [{
"role": "user",
"content": f"[扫描 PDF 第 {page_index + 1}/{total_pages} 页]\n{PDF_OCR_PROMPT}",
}]
reservation = reserve_avatar_tokens(
db,
avatar,
"knowledge_pdf_ocr",
model,
estimate_messages,
model_config.vision_max_tokens,
)
try:
result = None
for attempt in range(1, max_attempts + 1):
try:
result = call_vision_model(
prepared,
model_config,
model=model,
prompt=PDF_OCR_PROMPT,
json_output=False,
)
break
except RuntimeError:
if attempt == max_attempts:
raise
time.sleep(min(4, attempt))
content = str((result or {}).get("content") or "").strip()
if not content:
raise RuntimeError("扫描型 PDF 页面识别结果为空")
settle_reservation(
db,
reservation,
(result or {}).get("usage"),
fallback_total=estimate_fallback_usage(estimate_messages, content),
)
except Exception as exc:
release_reservation(db, reservation, str(exc))
raise RuntimeError(
f"扫描型 PDF 第 {page_index + 1}/{total_pages} 页识别失败:{exc}"
) from exc
texts.append(f"[第 {page_index + 1} 页]\n{content}")
if on_progress:
on_progress(page_index + 1, total_pages)
logger.info(
"Scanned PDF OCR completed avatar=%s page=%s/%s",
avatar.id,
page_index + 1,
total_pages,
)
return "\n\n".join(texts).strip()
@@ -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
)
@@ -11,6 +11,7 @@ from routers.chat import (
_match_standard_qa,
_public_avatar_payload,
_qa_requires_language_adaptation,
_qa_requires_per_turn_rendering,
_require_owned_avatar,
_resolve_reply,
)
@@ -86,6 +87,41 @@ class ChatOrchestrationTests(unittest.TestCase):
self.assertTrue(_qa_requires_language_adaptation("안녕하세요", "你好"))
self.assertFalse(_qa_requires_language_adaptation("你好", "您好"))
def test_conversation_qa_is_rendered_for_the_current_turn_language(self):
history = [SimpleNamespace(role="user", content="Please answer in English.")]
self.assertTrue(_qa_requires_per_turn_rendering("Quelle est votre adresse ?", "Our address is Test Road 1.", history))
fake_model = Mock(return_value="Notre adresse est Test Road 1.")
result = _resolve_reply(
None,
self.avatar,
"Quelle est votre adresse ?",
history,
qa_pairs=[SimpleNamespace(question="Quelle est votre adresse ?", answer="Our address is Test Road 1.", enabled=True)],
search_fn=Mock(),
model_client=fake_model,
)
self.assertEqual(result["source"], "qa")
self.assertEqual(result["answer"], "Notre adresse est Test Road 1.")
messages = fake_model.call_args.kwargs["messages"]
self.assertEqual(messages[-1], {"role": "user", "content": "Quelle est votre adresse ?"})
self.assertEqual(messages[-2]["role"], "system")
self.assertIn("本轮语言覆盖指令", messages[-2]["content"])
self.assertIn("不要沿用上一轮语言", messages[-2]["content"])
def test_latest_user_message_has_an_adjacent_language_override(self):
history = [
SimpleNamespace(role="user", content="请用中文回答"),
SimpleNamespace(role="assistant", content="好的,请问有什么可以帮你?"),
]
messages = _build_prompt(self.avatar, history, "What can you help me with?", [])
self.assertEqual(messages[-1], {"role": "user", "content": "What can you help me with?"})
self.assertEqual(messages[-2]["role"], "system")
self.assertIn("最新用户消息", messages[-2]["content"])
self.assertIn("立即切换到相同语言", messages[-2]["content"])
def test_conversational_paraphrase_matches_standard_qa(self):
for question in ("请问一下,你们公司在哪里呀?", "请问去你们那边怎么走"):
with self.subTest(question=question):
@@ -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"]
@@ -238,6 +238,53 @@ def test_background_vectorizer_keeps_failure_reason_for_retry(
db.close()
def test_background_vectorizer_uses_ocr_for_image_only_pdf(
tmp_path: Path,
authorization_context,
):
context = authorization_context
with (
patch("routers.knowledge.UPLOAD_DIR", str(tmp_path)),
patch("routers.knowledge.knowledge_vectorizer.enqueue"),
):
response = client.post(
f"/api/avatar/{context['avatar'].id}/knowledge/docs",
headers=context["owner_headers"],
files={"file": ("scanned.pdf", b"image-only-pdf", "application/pdf")},
)
payload = response.json()["data"]
progress = []
with (
patch("services.knowledge_vectorizer.UPLOAD_DIR", str(tmp_path)),
patch("services.knowledge_vectorizer.embeddings.extract_text", return_value=""),
patch(
"services.knowledge_vectorizer.extract_scanned_pdf_text",
side_effect=lambda _db, _avatar, _path, on_progress: (
on_progress(1, 2), on_progress(2, 2), "扫描页文字"
)[-1],
) as ocr,
patch("services.knowledge_vectorizer.embeddings.embed", return_value=[[1.0, 0.0]]),
patch.object(knowledge_vectorizer, "_set_progress", wraps=knowledge_vectorizer._set_progress) as set_progress,
):
knowledge_vectorizer.vectorize_document(payload["id"])
progress = [(call.args[2], call.args[3]) for call in set_progress.call_args_list]
db = SessionLocal()
try:
stored = db.query(KnowledgeDoc).filter(KnowledgeDoc.id == payload["id"]).one()
assert stored.status == "ready"
assert stored.chunk_count == 1
assert ("ocr", 18) in progress
assert ("ocr", 28) in progress
ocr.assert_called_once()
db.query(KnowledgeChunk).filter(KnowledgeChunk.doc_id == stored.id).delete()
db.delete(stored)
db.commit()
finally:
db.close()
def test_retry_queues_a_failed_document_again(
tmp_path: Path,
authorization_context,
@@ -0,0 +1,104 @@
from types import SimpleNamespace
from unittest.mock import MagicMock, patch
import pytest
from services.pdf_ocr_service import extract_scanned_pdf_text
class FakePixmap:
def tobytes(self, *_args, **_kwargs):
return b"jpeg-page"
class FakePage:
def get_pixmap(self, **_kwargs):
return FakePixmap()
class FakeDocument:
page_count = 2
def __enter__(self):
return self
def __exit__(self, *_args):
return None
def load_page(self, _index):
return FakePage()
def test_scanned_pdf_ocr_preserves_page_order_and_reports_progress(monkeypatch):
fake_pymupdf = SimpleNamespace(
open=lambda _path: FakeDocument(),
Matrix=lambda x, y: (x, y),
csRGB="rgb",
)
monkeypatch.setitem(__import__("sys").modules, "pymupdf", fake_pymupdf)
progress = []
reservation = SimpleNamespace()
config = SimpleNamespace(
api_key="configured",
ocr_model="qwen-vl-ocr",
vision_model="vision",
vision_max_tokens=2048,
)
with (
patch("services.pdf_ocr_service.get_chat_model_config", return_value=config),
patch("services.pdf_ocr_service.prepare_image", return_value=SimpleNamespace()),
patch(
"services.pdf_ocr_service.call_vision_model",
side_effect=[
{"content": "第一页文字", "usage": {"total_tokens": 10}},
{"content": "第二页文字", "usage": {"total_tokens": 12}},
],
),
patch("services.pdf_ocr_service.reserve_avatar_tokens", return_value=reservation) as reserve,
patch("services.pdf_ocr_service.settle_reservation") as settle,
):
text = extract_scanned_pdf_text(
MagicMock(),
SimpleNamespace(id="avatar-1"),
"/tmp/scanned.pdf",
on_progress=lambda done, total: progress.append((done, total)),
)
assert text == "[第 1 页]\n第一页文字\n\n[第 2 页]\n第二页文字"
assert progress == [(1, 2), (2, 2)]
assert reserve.call_count == 2
assert settle.call_count == 2
def test_scanned_pdf_ocr_releases_tokens_after_retries_fail(monkeypatch):
fake_document = FakeDocument()
fake_document.page_count = 1
fake_pymupdf = SimpleNamespace(
open=lambda _path: fake_document,
Matrix=lambda x, y: (x, y),
csRGB="rgb",
)
monkeypatch.setitem(__import__("sys").modules, "pymupdf", fake_pymupdf)
monkeypatch.setenv("KNOWLEDGE_PDF_OCR_ATTEMPTS", "2")
reservation = SimpleNamespace()
config = SimpleNamespace(
api_key="configured",
ocr_model="qwen-vl-ocr",
vision_model="vision",
vision_max_tokens=2048,
)
with (
patch("services.pdf_ocr_service.get_chat_model_config", return_value=config),
patch("services.pdf_ocr_service.prepare_image", return_value=SimpleNamespace()),
patch("services.pdf_ocr_service.call_vision_model", side_effect=RuntimeError("timeout")) as call,
patch("services.pdf_ocr_service.reserve_avatar_tokens", return_value=reservation),
patch("services.pdf_ocr_service.release_reservation") as release,
patch("services.pdf_ocr_service.time.sleep"),
):
with pytest.raises(RuntimeError, match="第 1/1 页识别失败"):
extract_scanned_pdf_text(MagicMock(), SimpleNamespace(id="avatar-1"), "/tmp/scanned.pdf")
assert call.call_count == 2
release.assert_called_once()
@@ -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}
+24 -10
View File
@@ -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())
+35 -3
View File
@@ -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')
@@ -147,7 +147,7 @@ const documentState = (doc: any) => {
if (['uploaded', 'parsing'].includes(String(doc.status || '').toLowerCase())) {
const stage = String(doc.indexStage || 'queued').toLowerCase()
const labels: Record<string, string> = {
queued: '等待处理', extracting: '解析文档', chunking: '切分文本', embedding: '向量化中'
queued: '等待处理', extracting: '解析文档', ocr: '扫描件识别', chunking: '切分文本', embedding: '向量化中'
}
const progress = Math.max(0, Math.min(99, Number(doc.indexProgress || 0)))
return { tone: 'pending', label: labels[stage] || '处理中', detail: `${labels[stage] || '正在建立知识索引'} ${progress}%`, progress }
+98 -1
View File
@@ -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
View File
@@ -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
+10
View File
@@ -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" } })
+4
View File
@@ -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>
+1
View File
@@ -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模型配置' } },
+142
View File
@@ -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>
+9 -8
View File
@@ -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 会进入已有的短信登录流程。
---
+7937
View File
File diff suppressed because it is too large Load Diff
+28
View File
@@ -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"
}
}
+10
View File
@@ -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>
+9
View File
@@ -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 }
}
+21
View File
@@ -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"
}
+16
View File
@@ -0,0 +1,16 @@
{
"pages": [{
"path": "pages/index/index",
"style": {
"navigationBarTitleText": "会会数字分身",
"navigationBarBackgroundColor": "#0F2B4C",
"navigationBarTextStyle": "white"
}
}],
"globalStyle": {
"navigationBarTextStyle": "white",
"navigationBarTitleText": "会会数字分身",
"navigationBarBackgroundColor": "#0F2B4C",
"backgroundColor": "#F5F6F8"
}
}
+55
View File
@@ -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>
+24
View File
@@ -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)
}
}
})
+3
View File
@@ -0,0 +1,3 @@
$brand-navy: #0f2b4c;
$brand-orange: #f97316;
$bg-page: #f5f6f8;
+33
View File
@@ -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
}
+22
View File
@@ -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 || '支付未完成'))
})
})
}
+5
View File
@@ -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()] })