Compare commits

...
Author SHA1 Message Date
stefanfeng 0302ee81e0 fix(finance): share avatar sqlite WAL with admin 2026-10-16 17:50:29 +08:00
stefanfeng 08a7bc8b90 feat(payment): expire pending orders after one hour 2026-10-16 17:24:57 +08:00
stefanfeng ddb8ea9802 fix(payment): rely on Huihui callback for status 2026-10-16 17:00:35 +08:00
stefanfeng 85d0c57548 fix(avatar): complete payment method removal 2026-10-16 16:42:34 +08:00
stefanfeng da5822ecfe feat(finance): manage token plans and simplify recharge checkout 2026-10-16 16:34:22 +08:00
stefanfeng 643730f756 fix(avatar): default checkout environment and simplify pay action 2026-10-16 16:16:11 +08:00
stefanfeng 63a0c6692c fix(avatar): show recharge entry for all signed-in users 2026-10-16 15:31:45 +08:00
stefanfeng c62233a7c2 feat(avatar): show recharge entry in uni-app webview 2026-10-16 15:17:21 +08:00
stefanfeng 09aebea7d1 fix(avatar): clear stale checkout context and closed orders 2026-10-16 15:03:03 +08:00
stefanfeng b1bf203018 feat(avatar): route token recharge through Huihui org orders 2026-10-16 14:56:00 +08:00
stefanfeng 9566eb8925 Merge pull request 'feat(avatar): add signed Huihui virtual payment callback' (#37) from codex/huihui-payment-callback into main 2026-10-15 14:28:30 +08:00
stefanfeng e5df72956d feat(avatar): add signed Huihui virtual payment callback 2026-10-15 14:26:06 +08:00
stefanfeng 48f3b9bddf Merge pull request 'fix(avatar): hide ai source note below chat bubbles' (#36) from codex/hide-chat-ai-source-20260920 into main
fix(avatar): hide ai source note below chat bubbles
2026-09-20 16:11:04 +08:00
stefanfeng 2122615725 fix(avatar): hide ai source note below chat bubbles 2026-09-20 16:10:42 +08:00
stefanfeng cd0c6e3162 Merge pull request 'fix(avatar): restore microblog label and description wrapping' (#35) from codex/restore-authorization-label-wrap-20260916 into main
fix(avatar): restore microblog label and description wrapping
2026-09-16 15:35:42 +08:00
stefanfeng b48e33fc6b fix(avatar): restore microblog label and description wrapping 2026-09-16 15:35:20 +08:00
stefanfeng d9f12685fa Merge pull request 'revert: sync production to verified test version' (#34) from codex/sync-production-to-test-20260916 into main
revert: sync production to verified test version
2026-09-16 14:59:56 +08:00
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 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
29 changed files with 789 additions and 460 deletions
+24 -36
View File
@@ -29,6 +29,30 @@ def summary():
db.close()
@router.get("/plans")
def token_plans():
db = _session()
try:
return ApiResponse(data=finance_service.list_token_plans(db))
finally:
db.close()
@router.patch("/plans/{plan_id}")
def update_token_plan(plan_id: str, body: dict = Body(...)):
db = _session()
try:
finance_service.update_token_plan(
db, plan_id, amount=int(body.get("amount") or 0), price=float(body.get("price") or 0),
name=str(body.get("name") or ""), badge=str(body.get("badge") or ""), desc=str(body.get("desc") or ""),
)
return ApiResponse(message="充值套餐已更新")
except (ValueError, FinanceServiceError) as exc:
_raise(exc if isinstance(exc, FinanceServiceError) else FinanceServiceError("积分和价格格式不正确"))
finally:
db.close()
@router.get("/orders")
def orders(
page: int = Query(1, ge=1),
@@ -61,42 +85,6 @@ def update_order_status(order_no: str, body: dict = Body(...)):
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),
+17
View File
@@ -28,6 +28,23 @@ def _money(cents):
class FinanceService:
@staticmethod
def list_token_plans(db):
rows = db.execute(text('SELECT id,name,amount,price,badge,"desc",virtual_product_id FROM token_plans ORDER BY price ASC')).fetchall()
return [dict(row._mapping) for row in rows]
@staticmethod
def update_token_plan(db, plan_id: str, *, amount: int, price: float, name: str, badge: str, desc: str):
if amount <= 0 or price < 0 or not name.strip():
raise FinanceServiceError("套餐名称、正数积分和非负价格不正确")
result = db.execute(text('''UPDATE token_plans SET name=:name,amount=:amount,price=:price,badge=:badge,"desc"=:desc WHERE id=:id'''), {
"id": plan_id, "name": name.strip()[:80], "amount": amount, "price": price,
"badge": badge.strip()[:80], "desc": desc.strip()[:200],
})
if result.rowcount == 0:
raise FinanceServiceError("充值套餐不存在", 404)
db.commit()
@staticmethod
def _tables_ready(db) -> bool:
names = {
+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` 启动
+54 -4
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
@@ -21,7 +22,11 @@ import routers.takeover
from responses import ok
from services.chat_attachment_service import purge_expired_chat_attachments
from services.knowledge_vectorizer import knowledge_vectorizer
from services.token_billing import DEFAULT_TOKEN_GRANT, release_stale_reservations
from services.token_billing import (
DEFAULT_TOKEN_GRANT,
close_expired_pending_orders,
release_stale_reservations,
)
logger = logging.getLogger(__name__)
@@ -54,7 +59,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():
@@ -230,10 +259,24 @@ def purge_expired_chat_attachments_job():
db.close()
def close_expired_pending_orders_job():
db = SessionLocal()
try:
count = close_expired_pending_orders(db)
if count:
logger.info("Closed %s expired pending token payment order(s)", count)
except Exception as exc:
db.rollback()
logger.warning("Failed to close expired token payment orders: %s", exc)
finally:
db.close()
def start_maintenance_scheduler():
global maintenance_scheduler
purge_expired_chat_attachments_job()
close_expired_pending_orders_job()
interval_minutes = max(
5, min(1440, int(os.getenv("CHAT_ATTACHMENT_CLEANUP_MINUTES", "60")))
)
@@ -245,6 +288,13 @@ def start_maintenance_scheduler():
max_instances=1,
coalesce=True,
)
maintenance_scheduler.add_job(
close_expired_pending_orders_job,
trigger=IntervalTrigger(minutes=1),
id="close_expired_token_orders",
max_instances=1,
coalesce=True,
)
maintenance_scheduler.start()
+1
View File
@@ -395,6 +395,7 @@ class TokenPaymentOrder(Base):
"price": self.price_cents / 100,
"status": self.status,
"provider": self.provider,
"providerOrderNo": self.provider_order_no,
"providerStatus": self.provider_status,
"payMessage": self.pay_message,
"failureReason": self.failure_reason,
+23 -142
View File
@@ -79,34 +79,6 @@ _WRITING_SYSTEM_PATTERNS = {
_JAPANESE_KANA = re.compile(r"[\u3040-\u30ff]")
_KOREAN_HANGUL = re.compile(r"[\uac00-\ud7af\u1100-\u11ff]")
_LATIN_LANGUAGE_MARKERS = {
"English": re.compile(
r"\b(?:i|you|we|they|he|she|have|has|had|friend|who|what|where|when|why|how|"
r"symptoms?|disease|please|can|could|would|should|is|are|was|were|the|this|that)\b",
re.IGNORECASE,
),
"French": re.compile(
r"\b(?:je|tu|vous|nous|ils|elle|une|des|avec|pour|pourquoi|comment|bonjour|est|sont)\b",
re.IGNORECASE,
),
"Spanish": re.compile(
r"\b(?:yo|tu|usted|nosotros|ellos|ella|una|con|para|por que|como|hola|esta|son)\b",
re.IGNORECASE,
),
"German": re.compile(
r"\b(?:ich|du|sie|wir|eine|mit|fur|warum|wie|hallo|ist|sind|haben)\b",
re.IGNORECASE,
),
"Portuguese": re.compile(
r"\b(?:eu|voce|nos|eles|ela|uma|com|para|porque|como|ola|esta|sao|tenho)\b",
re.IGNORECASE,
),
"Italian": re.compile(
r"\b(?:io|tu|voi|noi|loro|una|con|per|perche|come|ciao|sono|avere)\b",
re.IGNORECASE,
),
}
class ChatMessage(BaseModel):
model_config = ConfigDict(populate_by_name=True)
@@ -515,67 +487,15 @@ def _qa_requires_per_turn_rendering(
return bool(history) or _qa_requires_language_adaptation(question, answer)
def _latin_language_name(value: str) -> str:
scores = {
language: len(pattern.findall(value or ""))
for language, pattern in _LATIN_LANGUAGE_MARKERS.items()
}
language, score = max(scores.items(), key=lambda item: item[1])
return language if score else "the same natural language as the latest user message"
def _turn_language_name(value: str) -> str:
writing_system = _dominant_writing_system(value)
return {
"han": "Chinese",
"japanese": "Japanese",
"korean": "Korean",
"cyrillic": "the same Cyrillic-script language as the latest user message",
"arabic": "the same Arabic-script language as the latest user message",
"hebrew": "Hebrew",
"devanagari": "the same Devanagari-script language as the latest user message",
"thai": "Thai",
"greek": "Greek",
"latin": _latin_language_name(value),
}.get(writing_system, "the same natural language as the latest user message")
def _per_turn_language_instruction(question: str = "") -> str:
language = _turn_language_name(question)
def _per_turn_language_instruction() -> str:
return (
f"MANDATORY OUTPUT LANGUAGE FOR THIS TURN: {language}. "
"Write the entire answer only in that language. This instruction overrides the languages used by "
"conversation history, profile data, standard answers, retrieved documents, and custom prompts. "
"Translate grounded source material faithfully when necessary. Do not mention language detection, "
"translation, or this instruction."
"本轮语言覆盖指令:只根据紧随其后的最新用户消息判断本轮回答语言。"
"即使此前整段对话一直使用另一种语言,只要最新消息切换了语言,本轮就必须立即切换到相同语言;"
"不要沿用上一轮语言。若最新消息明确指定回答语言,以该指定为准;若混用多种语言,使用其中占主导的"
"自然语言。不要说明你检测、切换或翻译了语言。"
)
def _answer_requires_language_repair(question: str, answer: str) -> bool:
question_system = _dominant_writing_system(question)
answer_system = _dominant_writing_system(answer)
return (
question_system != "unknown"
and answer_system != "unknown"
and question_system != answer_system
)
def _language_repair_messages(question: str, answer: str) -> list[dict]:
return [
{"role": "system", "content": _per_turn_language_instruction(question)},
{
"role": "system",
"content": (
"Rewrite the supplied draft in the mandatory output language. Preserve every grounded fact, "
"number, proper noun, uncertainty, and safety qualification. Add no new information and output "
"only the rewritten answer."
),
},
{"role": "user", "content": answer.strip()},
]
def _canonicalize_question(value: str) -> str:
value = _normalize_question(value)
replacements = (
@@ -808,7 +728,7 @@ def _build_prompt(
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(question)})
messages.append({"role": "system", "content": _per_turn_language_instruction()})
messages.append({"role": "user", "content": question.strip()})
return messages
@@ -871,41 +791,6 @@ def _call_qwen(
return {"answer": answer.strip(), "usage": data.get("usage") or {}}
def _call_billed_qwen(
db: Session,
avatar: Avatar,
messages: list[dict],
temperature: float,
usage_source: str,
model_config: ChatModelConfig,
) -> tuple[str, dict]:
reservation = reserve_avatar_tokens(
db,
avatar,
usage_source,
model_config.model,
messages,
model_config.max_tokens,
)
try:
model_result = _call_qwen(
messages=messages,
temperature=temperature,
model_config=model_config,
)
answer = model_result["answer"]
token_usage = settle_reservation(
db,
reservation,
model_result.get("usage"),
fallback_total=estimate_fallback_usage(messages, answer),
)
return answer, token_usage
except Exception as exc:
release_reservation(db, reservation, str(exc))
raise
def _iter_qwen_stream(
messages: list[dict], temperature: float, model_config: ChatModelConfig | None = None
):
@@ -1014,36 +899,32 @@ def _resolve_reply(
token_usage = None
if model_client is not None:
answer = model_client(messages=messages, temperature=temperature)
if _answer_requires_language_repair(question, str(answer or "")):
answer = model_client(
messages=_language_repair_messages(question, str(answer)),
temperature=0.0,
)
else:
model_config = get_chat_model_config()
answer, token_usage = _call_billed_qwen(
reservation = reserve_avatar_tokens(
db,
avatar,
usage_source,
model_config.model,
messages,
temperature,
usage_source,
model_config,
model_config.max_tokens,
)
if _answer_requires_language_repair(question, answer):
logger.warning(
"chat response language mismatch avatar=%s source=%s expected=%s",
avatar.id,
usage_source,
_turn_language_name(question),
try:
model_result = _call_qwen(
messages=messages,
temperature=temperature,
model_config=model_config,
)
answer, token_usage = _call_billed_qwen(
answer = model_result["answer"]
token_usage = settle_reservation(
db,
avatar,
_language_repair_messages(question, answer),
0.0,
f"{usage_source}_language_repair",
model_config,
reservation,
model_result.get("usage"),
fallback_total=estimate_fallback_usage(messages, answer),
)
except Exception as exc:
release_reservation(db, reservation, str(exc))
raise
answer = str(answer or "").strip()
if image_contexts and _answer_denies_available_image(answer):
logger.warning(
@@ -2,6 +2,7 @@ import hashlib
import hmac
import json
import os
import time
import uuid
from datetime import datetime
from decimal import Decimal, InvalidOperation, ROUND_HALF_UP
@@ -24,6 +25,7 @@ from models import (
)
from responses import fail, ok
from services.huihui_payment import HuihuiPaymentClient, HuihuiPaymentError
from services.huihui_org_payment import HuihuiOrgPaymentClient
from services.token_billing import get_or_create_account
from services.wechat_virtual_payment import (
PAYMENT_EVENTS as WECHAT_PAYMENT_EVENTS,
@@ -269,6 +271,7 @@ def _callback_amount_cents(payload) -> int | None:
"orderAmt",
"amount",
"totalAmount",
"chargeAmount",
)
if value in (None, ""):
return None
@@ -404,6 +407,49 @@ def charge(payload: dict = Body(...), authorization: str = Header(None), db: Ses
return ok(_payment_payload(order, get_or_create_account(db, user.id)))
@router.post("/token/charge/org")
def charge_via_huihui_org(payload: dict = Body(...), authorization: str = Header(None), db: Session = Depends(get_db)):
user = _require_user(authorization, db)
plan = db.query(TokenPlan).filter(TokenPlan.id == payload.get("planId")).first()
if not plan:
return fail("套餐不存在", 404)
method = str(payload.get("paymentMethod") or "").lower()
if method not in PAYMENT_METHODS:
return fail("支付方式不正确", 400)
scene = str(payload.get("payScene") or "APP").upper()
if scene not in PAYMENT_SCENES:
return fail("支付场景不正确", 400)
if scene == "LITE" and method != "wechat":
return fail("微信小程序仅支持微信支付", 400)
cents = _price_cents(plan.price)
try:
result = HuihuiOrgPaymentClient().create_order(
token=user.huihui_token, user_id=user.huihui_user_id,
name=user.nickname, phone=user.phone, avatar=user.avatar_url,
amount=f"{cents / 100:.2f}",
)
except HuihuiPaymentError as exc:
return fail(str(exc), 502)
provider_no = str(result.get("orderNo") or "")
if not provider_no.isdigit() or int(result.get("payStatus") or 0) != 1 or int(result.get("chargeType") or 0) != 9:
return fail("会会返回的充值订单不正确", 502)
if _callback_amount_cents({"chargeAmount": result.get("chargeAmount")}) != cents:
return fail("会会返回的充值金额不匹配", 502)
order = TokenPaymentOrder(
order_no=f"AV{datetime.utcnow().strftime('%Y%m%d%H%M%S')}{uuid.uuid4().hex[:12].upper()}",
user_id=user.id, plan_id=plan.id, payment_method=method,
pay_type=PAYMENT_METHODS[method], pay_way=scene, points_amount=plan.amount,
price_cents=cents, status="pending", provider="huihui_org",
provider_order_no=provider_no, provider_status="1",
)
db.add(order)
db.commit()
result = _payment_payload(order, get_or_create_account(db, user.id))
if "fat-open.99hui.com" in os.getenv("HUIHUI_ORG_BASE_URL", ""):
result["checkoutToken"] = user.huihui_token
return ok(result)
@router.get("/token/payment/{order_id}")
def payment_status(order_id: str, authorization: str = Header(None), db: Session = Depends(get_db)):
user = _require_user(authorization, db)
@@ -413,6 +459,7 @@ 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(
@@ -559,6 +606,8 @@ def admin_request_refund(
return fail("订单不存在", 404)
if order.status != "paid" or order.refund_status not in {"", "none", "failed"}:
return fail("该订单当前不可退款", 409)
if order.provider == "huihui_org":
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)
@@ -753,6 +802,83 @@ async def payment_callback(
return ok({"received": True, "paid": True})
@router.post("/token/payment/huihui/virtual/notify")
async def huihui_virtual_payment_notify(request: Request, db: Session = Depends(get_db)):
"""Server-to-server virtual-payment notice using the Huihui HMAC contract."""
secret = os.getenv("HUIHUI_VIRTUAL_CALLBACK_SECRET", "").strip()
if len(secret) < 32:
raise HTTPException(status_code=503, detail="virtual payment callback is not configured")
timestamp = request.headers.get("x-huihui-timestamp", "").strip()
signature = request.headers.get("x-huihui-signature", "").strip().lower()
if not timestamp.isascii() or not timestamp.isdecimal() or len(timestamp) > 12:
raise HTTPException(status_code=401, detail="invalid callback signature")
try:
issued_at = int(timestamp)
except ValueError:
raise HTTPException(status_code=401, detail="invalid callback signature")
if abs(int(time.time()) - issued_at) > 300:
raise HTTPException(status_code=401, detail="callback timestamp expired")
body = await request.body()
if not body or len(body) > 65536:
raise HTTPException(status_code=400, detail="invalid callback body")
expected = hmac.new(
secret.encode("utf-8"), timestamp.encode("ascii") + b"\n" + body, hashlib.sha256
).hexdigest()
if not hmac.compare_digest(signature, expected):
raise HTTPException(status_code=401, detail="invalid callback signature")
try:
payload = json.loads(body)
except (UnicodeDecodeError, ValueError):
raise HTTPException(status_code=400, detail="invalid callback JSON")
if not isinstance(payload, dict):
raise HTTPException(status_code=400, detail="invalid callback JSON")
order_no = str(payload.get("orderNo") or "").strip()
payment_no = str(payload.get("paymentNo") or "").strip()
status = str(payload.get("status") or "").upper()
if not order_no or not payment_no or status not in {"SUCCESS", "FAILED"}:
raise HTTPException(status_code=400, detail="missing callback fields")
order = db.query(TokenPaymentOrder).filter(TokenPaymentOrder.order_no == order_no).first()
if not order or order.provider != "wechat_virtual":
raise HTTPException(status_code=404, detail="virtual payment order not found")
if status == "FAILED":
return ok({"received": True, "paid": False})
user = db.query(User).filter(User.id == order.user_id).first()
plan = db.query(TokenPlan).filter(TokenPlan.id == order.plan_id).first()
try:
expected_product_id = product_id_for_plan(plan) if plan else ""
amount_cents = int(payload.get("amountCents"))
callback_env = int(payload.get("env"))
except (TypeError, ValueError, WechatVirtualPaymentError):
raise HTTPException(status_code=422, detail="invalid virtual payment details")
if (
isinstance(payload.get("amountCents"), bool)
or isinstance(payload.get("env"), bool)
or not user
or not user.wechat_mp_openid
or str(payload.get("openId") or "") != user.wechat_mp_openid
or not expected_product_id
or str(payload.get("productId") or "") != expected_product_id
or amount_cents != order.price_cents
or callback_env != wechat_virtual_env()
):
raise HTTPException(status_code=422, detail="virtual payment details mismatch")
existing_payment = db.query(PaymentTransaction).filter(
PaymentTransaction.provider == "wechat_virtual",
PaymentTransaction.event_type == "payment",
PaymentTransaction.transaction_no == payment_no,
).first()
if existing_payment and existing_payment.order_no != order_no:
raise HTTPException(status_code=409, detail="payment number belongs to another order")
if order.status in {"paid", "refunded"}:
return ok({"received": True, "duplicate": True})
_settle_paid_order(db, order, provider_status="HUIHUI_SUCCESS", transaction_no=payment_no)
db.commit()
return ok({"received": True, "paid": True})
def _wechat_notify_response(request: Request, *, success: bool, message: str = ""):
code = 0 if success else 1
text = "success" if success else (message or "fail")[:200].replace("]]>", "")
@@ -0,0 +1,48 @@
"""Huihui organization token-recharge order API."""
import os
import httpx
from services.huihui_payment import HuihuiPaymentClient, HuihuiPaymentError
class HuihuiOrgPaymentClient(HuihuiPaymentClient):
def __init__(self):
super().__init__({
"HUIHUI_PAYMENT_BASE_URL": os.getenv("HUIHUI_ORG_BASE_URL", ""),
"HUIHUI_APP_ID": os.getenv("HUIHUI_APP_ID", ""),
"HUIHUI_ACCESS_ID": os.getenv("HUIHUI_ACCESS_ID", ""),
"HUIHUI_ACCESS_SECRET": os.getenv("HUIHUI_ACCESS_SECRET", ""),
})
def _request(self, method: str, path: str, *, token: str, user_id: str, body=None) -> dict:
if not self.configured or not token or not user_id:
raise HuihuiPaymentError("会会订单服务或登录凭证未配置")
try:
response = httpx.request(
method, f"{self.base_url}{path}",
headers={"Authorization": f"Bearer {token}", "appId": self.app_id},
params=self._signed_params(user_id), json=body,
timeout=self.timeout, follow_redirects=True,
)
except httpx.HTTPError as exc:
raise HuihuiPaymentError("会会订单服务连接失败") from exc
payload = self._json(response)
if response.status_code >= 400 or payload.get("code") not in (0, 200, "0", "200"):
raise HuihuiPaymentError(str(payload.get("message") or "会会订单请求失败"))
data = payload.get("data")
if not isinstance(data, dict):
raise HuihuiPaymentError("会会订单信息缺失")
return data
def create_order(self, *, token: str, user_id: str, name: str, phone: str,
avatar: str, amount: str) -> dict:
body = {"chargeAmount": float(amount),
"commodityName": "数字分身积分充值", "userId": user_id,
"name": name or "", "phoneNumber": phone or "", "avatar": avatar or ""}
return self._request("POST", "/order/tokenRecharge", token=token, user_id=user_id, body=body)
def get_order(self, *, token: str, user_id: str, order_no: str) -> dict:
if not order_no.isdigit():
raise HuihuiPaymentError("会会订单号格式不正确")
return self._request("GET", f"/order/get/{order_no}", token=token, user_id=user_id)
@@ -7,7 +7,7 @@ from datetime import datetime, timedelta
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session
from models import Avatar, TokenAccount, TokenUsage, User
from models import Avatar, TokenAccount, TokenPaymentOrder, TokenUsage, User
DEFAULT_TOKEN_GRANT = 1_000_000
@@ -201,3 +201,22 @@ def release_stale_reservations(db: Session, older_than_minutes: int = 10) -> int
"stale_reservation_recovered",
)
return len(stale)
def close_expired_pending_orders(db: Session, older_than_minutes: int = 60) -> int:
"""Close unpaid recharge orders after their one-hour payment window."""
now = datetime.utcnow()
cutoff = now - timedelta(minutes=older_than_minutes)
closed_count = db.query(TokenPaymentOrder).filter(
TokenPaymentOrder.status == "pending",
TokenPaymentOrder.created_at <= cutoff,
).update(
{
TokenPaymentOrder.status: "closed",
TokenPaymentOrder.failure_reason: "支付超时(超过1小时)",
TokenPaymentOrder.updated_at: now,
},
synchronize_session=False,
)
db.commit()
return int(closed_count or 0)
@@ -6,7 +6,6 @@ from fastapi import HTTPException
from models import Avatar, User
from routers.chat import (
_answer_requires_language_repair,
_build_prompt,
_iter_text_chunks,
_match_standard_qa,
@@ -15,7 +14,6 @@ from routers.chat import (
_qa_requires_per_turn_rendering,
_require_owned_avatar,
_resolve_reply,
_turn_language_name,
)
@@ -109,8 +107,8 @@ class ChatOrchestrationTests(unittest.TestCase):
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("MANDATORY OUTPUT LANGUAGE", messages[-2]["content"])
self.assertIn("French", messages[-2]["content"])
self.assertIn("本轮语言覆盖指令", messages[-2]["content"])
self.assertIn("不要沿用上一轮语言", messages[-2]["content"])
def test_latest_user_message_has_an_adjacent_language_override(self):
history = [
@@ -121,37 +119,8 @@ class ChatOrchestrationTests(unittest.TestCase):
self.assertEqual(messages[-1], {"role": "user", "content": "What can you help me with?"})
self.assertEqual(messages[-2]["role"], "system")
self.assertIn("MANDATORY OUTPUT LANGUAGE", messages[-2]["content"])
self.assertIn("English", messages[-2]["content"])
def test_reported_alzheimer_question_is_explicitly_english(self):
question = "I have a friend who has symptoms of Alzheimer's disease"
self.assertEqual(_turn_language_name(question), "English")
messages = _build_prompt(self.avatar, [], question, [])
self.assertIn("MANDATORY OUTPUT LANGUAGE FOR THIS TURN: English", messages[-2]["content"])
def test_non_stream_reply_repairs_a_wrong_writing_system_before_sending(self):
question = "I have a friend who has symptoms of Alzheimer's disease"
fake_model = Mock(side_effect=["建议尽快就医评估。", "Please arrange a medical assessment soon."])
result = _resolve_reply(
None,
self.avatar,
question,
[],
qa_pairs=[],
search_fn=lambda *_args, **_kwargs: [],
model_client=fake_model,
usage_source="takeover",
)
self.assertEqual(result["answer"], "Please arrange a medical assessment soon.")
self.assertEqual(fake_model.call_count, 2)
repair_messages = fake_model.call_args.kwargs["messages"]
self.assertIn("English", repair_messages[0]["content"])
self.assertIn("建议尽快就医评估", repair_messages[-1]["content"])
self.assertTrue(_answer_requires_language_repair(question, "建议尽快就医评估。"))
self.assertIn("最新用户消息", messages[-2]["content"])
self.assertIn("立即切换到相同语言", messages[-2]["content"])
def test_conversational_paraphrase_matches_standard_qa(self):
for question in ("请问一下,你们公司在哪里呀?", "请问去你们那边怎么走"):
@@ -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"
@@ -53,8 +53,12 @@ def test_scheduler_uses_boxim_and_restart_safe_service(
max_message_age_seconds=600,
)
maintenance_scheduler.add_job.assert_called_once()
assert maintenance_scheduler.add_job.call_args.kwargs["id"] == "chat_attachment_cleanup"
assert maintenance_scheduler.add_job.call_count == 2
cleanup_call, close_orders_call = maintenance_scheduler.add_job.call_args_list
assert cleanup_call.kwargs["id"] == "chat_attachment_cleanup"
assert close_orders_call.args[0] is main.close_expired_pending_orders_job
assert close_orders_call.kwargs["id"] == "close_expired_token_orders"
assert close_orders_call.kwargs["trigger"].interval.total_seconds() == 60
maintenance_scheduler.start.assert_called_once_with()
assert scheduler.add_job.call_count == 2
@@ -7,6 +7,7 @@ from threading import Barrier
from unittest.mock import Mock, patch
import pytest
from datetime import datetime, timedelta
from fastapi.testclient import TestClient
from database import SessionLocal
@@ -16,6 +17,7 @@ from routers.chat import _resolve_reply, _stream_reply
from services.token_billing import (
DEFAULT_TOKEN_GRANT,
InsufficientTokensError,
close_expired_pending_orders,
get_or_create_account,
release_reservation,
reserve_avatar_tokens,
@@ -100,6 +102,84 @@ def test_charge_creates_huihui_order_without_early_points(authorization_context)
assert "test-callback-secret-123456" not in callback_url
def test_org_recharge_only_credits_after_verified_provider_order(authorization_context):
seed()
context = authorization_context
_enable_huihui_payment_login(context)
provider_no = "261016111200090123456"
created_data = {"orderNo": provider_no, "chargeType": 9,
"chargeAmount": 10, "payStatus": 1}
with patch.dict(os.environ, {"HUIHUI_ORG_BASE_URL": "https://fat-open.99hui.com/api/huihuiorg"}), patch("routers.tokens.HuihuiOrgPaymentClient") as client_factory:
provider = client_factory.return_value
provider.create_order.return_value = created_data
created = client.post(
"/api/token/charge/org", headers=context["owner_headers"],
json={"planId": "1", "paymentMethod": "wechat", "payScene": "APP"},
).json()["data"]
assert created["providerOrderNo"] == provider_no
assert created["status"] == "pending"
assert created["balance"] == DEFAULT_TOKEN_GRANT
assert created["checkoutToken"] == f"huihui-payment-{context['suffix']}"
assert "pay_type" not in provider.create_order.call_args.kwargs
details = {**created_data, "payStatus": 2,
"userId": context["owner"].huihui_user_id}
provider.get_order.return_value = details
unchanged = client.get(f"/api/token/payment/{created['id']}", headers=context["owner_headers"]).json()["data"]
assert unchanged["status"] == "pending"
assert unchanged["balance"] == DEFAULT_TOKEN_GRANT
provider.get_order.assert_not_called()
def test_org_recharge_does_not_return_checkout_token_outside_test_environment(authorization_context):
seed()
context = authorization_context
_enable_huihui_payment_login(context)
with patch.dict(os.environ, {"HUIHUI_ORG_BASE_URL": "https://open.99hui.com/api/huihuiorg"}), patch(
"routers.tokens.HuihuiOrgPaymentClient"
) as client_factory:
client_factory.return_value.create_order.return_value = {
"orderNo": "261016111200090123457", "chargeType": 9,
"chargeAmount": 10, "payStatus": 1,
}
response = client.post(
"/api/token/charge/org", headers=context["owner_headers"],
json={"planId": "1", "paymentMethod": "wechat", "payScene": "APP"},
)
assert response.status_code == 200
assert "checkoutToken" not in response.json()["data"]
def test_expired_pending_payment_orders_are_closed_without_touching_recent_orders(authorization_context):
context = authorization_context
db = SessionLocal()
try:
expired = TokenPaymentOrder(
order_no=f"EXPIRED-{uuid.uuid4().hex}", user_id=context["owner"].id, plan_id="1",
payment_method="wechat", pay_type="WECHAT", pay_way="H5", points_amount=100,
price_cents=100, status="pending", provider="huihui_org",
created_at=datetime.utcnow() - timedelta(hours=1, seconds=1),
)
recent = TokenPaymentOrder(
order_no=f"RECENT-{uuid.uuid4().hex}", user_id=context["owner"].id, plan_id="1",
payment_method="wechat", pay_type="WECHAT", pay_way="H5", points_amount=100,
price_cents=100, status="pending", provider="huihui_org",
created_at=datetime.utcnow() - timedelta(minutes=59),
)
db.add_all([expired, recent])
db.commit()
assert close_expired_pending_orders(db) == 1
db.refresh(expired)
db.refresh(recent)
assert expired.status == "closed"
assert expired.failure_reason == "支付超时(超过1小时)"
assert recent.status == "pending"
finally:
db.close()
def test_success_callback_credits_once_and_status_is_user_scoped(authorization_context):
context = authorization_context
_enable_huihui_payment_login(context)
@@ -1,6 +1,8 @@
import hashlib
import hmac
import json
import os
import time
from unittest.mock import patch
from fastapi.testclient import TestClient
@@ -14,6 +16,69 @@ from services.token_billing import DEFAULT_TOKEN_GRANT
client = TestClient(app)
def _huihui_virtual_headers(secret, body, timestamp=None):
timestamp = str(timestamp if timestamp is not None else int(time.time()))
signature = hmac.new(secret.encode(), timestamp.encode() + b"\n" + body, hashlib.sha256).hexdigest()
return {"X-Huihui-Timestamp": timestamp, "X-Huihui-Signature": signature}
def test_huihui_virtual_callback_requires_signature_and_exact_order_details(authorization_context):
seed()
context = authorization_context
db = SessionLocal()
try:
user = db.query(User).filter(User.id == context["owner"].id).one()
user.wechat_mp_openid = "openid-huihui-callback"
user.wechat_mp_session_key = "session-huihui-callback"
plan = db.query(TokenPlan).filter(TokenPlan.id == "1").one()
plan.virtual_product_id = "points_plan_1"
db.commit()
finally:
db.close()
secret = "huihui-virtual-callback-test-secret-123456"
env = {
"WECHAT_VIRTUAL_ENV": "sandbox",
"WECHAT_VIRTUAL_SANDBOX_APP_KEY": "sandbox-key",
"WECHAT_VIRTUAL_OFFER_ID": "offer-1",
"HUIHUI_VIRTUAL_CALLBACK_SECRET": secret,
}
with patch.dict(os.environ, env):
created = client.post(
"/api/token/charge",
headers=context["owner_headers"],
json={"planId": "1", "paymentMethod": "wechat", "payScene": "LITE"},
).json()["data"]
payload = {
"orderNo": created["orderNo"],
"paymentNo": f"huihui-virtual-payment-{context['suffix']}",
"status": "SUCCESS",
"amountCents": 1000,
"productId": "points_plan_1",
"openId": "openid-huihui-callback",
"env": 1,
}
path = "/api/token/payment/huihui/virtual/notify"
body = json.dumps(payload, separators=(",", ":")).encode()
assert client.post(path, content=body, headers=_huihui_virtual_headers(secret, body, 1)).status_code == 401
assert client.post(path, content=body, headers={"X-Huihui-Timestamp": str(int(time.time())), "X-Huihui-Signature": "bad"}).status_code == 401
wrong = json.dumps({**payload, "amountCents": 999}, separators=(",", ":")).encode()
assert client.post(path, content=wrong, headers=_huihui_virtual_headers(secret, wrong)).status_code == 422
first = client.post(path, content=body, headers=_huihui_virtual_headers(secret, body))
second = client.post(path, content=body, headers=_huihui_virtual_headers(secret, body))
assert first.json()["data"] == {"received": True, "paid": True}
assert second.json()["data"] == {"received": True, "duplicate": True}
db = SessionLocal()
try:
order = db.query(TokenPaymentOrder).filter(TokenPaymentOrder.order_no == created["orderNo"]).one()
account = db.query(TokenAccount).filter(TokenAccount.user_id == context["owner"].id).one()
assert order.status == "paid"
assert account.balance == DEFAULT_TOKEN_GRANT + 2_000_000
finally:
db.close()
def _signature(token, timestamp, nonce):
return hashlib.sha1("".join(sorted([token, timestamp, nonce])).encode()).hexdigest()
+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:
@@ -1,5 +1,7 @@
# 数字分身 H5 生产接入与部署
测试环境入口为 `https://fat-digital.99hui.com/`;测试支付收银台使用 `https://fat-open.99hui.com/huihui-h5/`,生产支付收银台使用 `https://99hui.com/huihui-h5/`。两套环境的订单和验签配置必须隔离,密钥只保存在各自后端配置中,不写入前端或仓库。
## 1. 接入方式
生产会会在用户已登录后打开以下地址:
@@ -61,7 +63,9 @@ WECHAT_VIRTUAL_PRODUCT_2=<100元套餐商品ID>
WECHAT_VIRTUAL_PRODUCT_3=<1000元套餐商品ID>
WECHAT_VIRTUAL_PRODUCT_4=<10000元套餐商品ID>
DATABASE_URL=sqlite:////data/avatar.db
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
@@ -74,13 +78,17 @@ 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`。
@@ -88,6 +96,8 @@ App 与 H5 积分充值使用会会支付体系的 `payment-v3/payment/pay`,
微信小程序使用微信虚拟支付:小程序先通过 `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 均不得下发前端或写日志。
如由会会服务端转发微信小程序虚拟支付结果,另有待双方确认的固定回调契约,见 [HUIHUI_VIRTUAL_CALLBACK.md](HUIHUI_VIRTUAL_CALLBACK.md)。该接口使用独立的 `HUIHUI_VIRTUAL_CALLBACK_SECRET`;即使接口代码部署到生产,在协议确认和联调验收前也不得将其视作已上线的支付渠道。
管理后台需要配置相同的 `AVATAR_FINANCE_ADMIN_SECRET` 和 `AVATAR_BACKEND_URL=https://digital.99hui.com`。退款只支持整单原路退款;供应商受理后显示“处理中”,收到渠道成功回调(或经渠道后台核对后人工确认)才将订单置为已退款。已消费掉本订单积分时,后台会拒绝主动退款;若渠道外部退款先发生,积分账户允许形成负数以记录欠额并阻止继续消费。
## 3. 构建与发布
@@ -97,7 +107,7 @@ App 与 H5 积分充值使用会会支付体系的 `payment-v3/payment/pay`,
```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
```
@@ -107,13 +117,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. 网关要求
@@ -153,7 +172,7 @@ 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. 微信虚拟支付在沙箱环境完成下单、支付回调、查单兜底和退款回调;错误 OpenID、商品、环境或金额均被拒绝。
@@ -0,0 +1,32 @@
# 会会微信小程序虚拟支付回调(待双方确认)
本项目为会会服务端预留固定回调地址:
`POST https://digital.99hui.com/api/token/payment/huihui/virtual/notify`
回调报文及签名协议尚未与会会确认。以下是本项目实现的拟议对接契约,双方确认并安全交换密钥后才能进行测试支付。接口代码部署与会会联调通过是两个独立状态。它不替代现有微信直连回调 `/api/token/payment/wechat/virtual/notify`。
请求体为 JSON,成功支付示例:
```json
{
"orderNo": "AV202610150000000000000001",
"paymentNo": "会会侧唯一支付流水号",
"status": "SUCCESS",
"amountCents": 1000,
"productId": "points_plan_1",
"openId": "微信小程序用户 OpenID",
"env": 1
}
```
`amountCents` 单位为分,`env` 为微信虚拟支付环境(0 正式、1 沙箱)。非成功结果可发送 `status: "FAILED"`,但不会入账。订单号、支付流水号和状态必填;成功结果还必须提供金额、商品 ID、OpenID、环境。仅在订单属于微信虚拟支付,且上述信息与本地订单、套餐、用户一致时入账。重复成功通知不会重复发放积分;同一支付流水号不能用于其他订单。
请求头:
- `X-Huihui-Timestamp`:Unix 秒级时间戳,允许与本服务时间相差不超过 300 秒。
- `X-Huihui-Signature`:小写十六进制 HMAC-SHA256,密钥为双方线下安全交换、服务端配置的 `HUIHUI_VIRTUAL_CALLBACK_SECRET`(至少 32 字符),待签名数据为 `时间戳 + 换行符 + 原始请求体字节`。不要重排 JSON 后签名,也不要把密钥发给前端。
成功接收返回 HTTP 200,`{"code":200,"message":"success","data":{"received":true,"paid":true}}`。重复通知返回 `duplicate: true`。验签、字段、订单或金额不符返回 HTTP 4xx,会会可按其重试策略处理。服务端未配置密钥时返回 HTTP 503。
上线前需由会会确认:其服务端是否能按此格式签名并发送通知、支付成功状态与支付流水号字段的正式定义、失败通知及重试/超时策略。小程序支付发起和退款仍需独立对接和验收;仅完成此回调不代表虚拟支付全流程已可用。
@@ -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())
+9 -1
View File
@@ -156,7 +156,9 @@ export interface TokenPaymentOrder {
pointsAmount: number
price: number
status: 'pending' | 'paid' | 'failed' | 'closed' | 'refunded'
provider: 'huihui' | 'wechat_virtual'
provider: 'huihui' | 'wechat_virtual' | 'huihui_org'
providerOrderNo?: string
checkoutToken?: string
providerStatus: string
payMessage: string
failureReason: string
@@ -196,6 +198,12 @@ export const chargeToken = (
payScene: 'APP' | 'H5' | 'LITE' | 'JSAPI'
) => request.post<TokenPaymentOrder>('/token/charge', { planId, paymentMethod, payScene })
export const chargeTokenOrg = (
planId: string,
paymentMethod: 'wechat' | 'alipay',
payScene: 'APP' | 'H5' | 'LITE' | 'JSAPI'
) => request.post<TokenPaymentOrder>('/token/charge/org', { planId, paymentMethod, payScene })
export const getTokenPaymentStatus = (orderId: string) =>
request.get<TokenPaymentOrder>(`/token/payment/${orderId}`)
+2
View File
@@ -15,6 +15,8 @@ app.use(pinia)
// —— 混合架构:在挂载前注入 uniapp 壳传入的认证与会会资料 ——
const params = getLaunchParams()
if (params.evn !== undefined) sessionStorage.setItem('hh_launch_evn', params.evn)
else if (params.token) sessionStorage.removeItem('hh_launch_evn')
const avatarStore = useAvatarStore(pinia)
const userStore = useUserStore(pinia)
@@ -12,15 +12,16 @@ export interface UniLaunchParams {
nickname?: string
avatar?: string
ts?: string
evn?: string
}
const PARAM_KEYS: (keyof UniLaunchParams)[] = ['token', 'userId', 'nickname', 'avatar', 'ts']
const PARAM_KEYS: (keyof UniLaunchParams)[] = ['token', 'userId', 'nickname', 'avatar', 'ts', 'evn']
function readParams(search: string, target: UniLaunchParams): void {
const sp = new URLSearchParams(search)
for (const key of PARAM_KEYS) {
const value = sp.get(key)
if (value) target[key] = value
if (value || (key === 'evn' && sp.has(key))) target[key] = value ?? ''
}
}
@@ -179,7 +179,7 @@ const permissionItems: Array<{
},
{
key: 'publish',
title: '发布微博内容',
title: '发布微播内容',
description: '允许分身自动发布动态内容',
tone: 'green',
},
@@ -691,7 +691,9 @@ svg {
font-weight: 400;
line-height: 1.45;
text-overflow: ellipsis;
white-space: nowrap;
white-space: normal;
overflow-wrap: anywhere;
word-break: break-word;
}
.permission-row.takeover .permission-copy small {
+3 -2
View File
@@ -54,7 +54,7 @@
</template>
<template v-else>{{ message.content }}</template>
</div>
<div v-if="message.source || message.references?.length" class="message-source">
<div v-if="sourceLabel(message.source) || message.references?.length" class="message-source">
{{ sourceLabel(message.source) }}
<span v-if="message.references?.length"> · {{ message.references.map((item) => item.filename).filter(Boolean).join('、') }}</span>
</div>
@@ -174,7 +174,8 @@ const sourceLabels: Record<NonNullable<DisplayMessage['source']>, string> = {
qa: '标准问答对',
knowledge: '参考文件知识库',
vision: '图片理解',
qwen: '智能回答',
// qwen 来源不再在聊天气泡下显示“智能回答/知能回答”标注。
qwen: '',
public: ''
}
const sourceLabel = (source?: DisplayMessage['source']) => source ? sourceLabels[source] : ''
@@ -20,15 +20,15 @@
</div>
</section>
<!-- 积分余额条:暂时隐藏,保留完整实现便于后续恢复。 -->
<section v-if="SHOW_POINTS_BALANCE_CARD" class="token-section">
<!-- 已登录用户均可查看积分余额并进入充值页。 -->
<section class="token-section">
<div class="token-card">
<div class="token-info">
<span class="token-label">积分余额</span>
<span class="token-amount">{{ tokenBalance.toLocaleString() }}</span>
<span class="token-used">累计使用 {{ tokenConsumed.toLocaleString() }}</span>
</div>
<button class="recharge-btn" @click="goToRecharge">充值</button>
<button class="recharge-btn" @click="goToRecharge">充值购买</button>
</div>
</section>
@@ -95,9 +95,6 @@ const avatarStore = useAvatarStore()
const userStore = useUserStore()
const isEmbedded = isHuihuiEmbeddedMode()
// 临时产品开关:余额卡片代码保留,后续改为 true 即可恢复展示。
const SHOW_POINTS_BALANCE_CARD = false
// 当前登录会会用户的资料(头像 / 昵称)
const me = computed(() => userStore.user)
+20 -173
View File
@@ -37,29 +37,7 @@
</div>
</section>
<!-- 支付方式 -->
<section class="payment-section">
<h3 class="section-title">支付方式</h3>
<div class="payment-list">
<div
class="payment-card"
:class="{ selected: paymentMethod === 'wechat' }"
@click="paymentMethod = 'wechat'"
>
<span class="payment-icon wechat-icon"><img :src="wechatPayIcon" alt="" /></span>
<span class="payment-name">微信支付</span>
<span class="payment-check" v-if="paymentMethod === 'wechat'">✓</span>
</div>
<div
class="payment-card"
:class="{ selected: paymentMethod === 'alipay' }"
@click="paymentMethod = 'alipay'"
>
<span class="payment-icon alipay-icon"><img :src="alipayIcon" alt="" /></span>
<span class="payment-name">支付宝</span>
<span class="payment-check" v-if="paymentMethod === 'alipay'">✓</span>
</div>
</div>
<p v-if="paymentNotice" class="payment-notice" :class="paymentNoticeTone">{{ paymentNotice }}</p>
</section>
@@ -67,8 +45,8 @@
<section class="checkout-section">
<button
class="checkout-btn"
:class="{ disabled: !selectedPlan || charging || Boolean(pendingOrderId) }"
:disabled="!selectedPlan || charging || Boolean(pendingOrderId)"
:class="{ disabled: !selectedPlan || charging }"
:disabled="!selectedPlan || charging"
@click="doCharge"
>
{{ checkoutLabel }}
@@ -107,20 +85,17 @@
</template>
<script setup lang="ts">
import { computed, onMounted, onUnmounted, ref } from 'vue'
import { computed, onMounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import {
chargeToken,
chargeTokenOrg,
applyTokenInvoice,
getRechargePlans,
getTokenBalance,
getTokenOrders,
getTokenPaymentStatus,
type TokenPaymentOrder
} from '@/api'
import { isInUniWebView, onNativeMessage, UniEvents } from '@/utils/uniapp-bridge'
import wechatPayIcon from '@/assets/wechat-pay.svg'
import alipayIcon from '@/assets/alipay.svg'
import { isInUniWebView } from '@/utils/uniapp-bridge'
const router = useRouter()
@@ -141,10 +116,8 @@ const plans = ref<Array<{
const selectedPlan = ref<any>(null)
// 支付方式
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')
@@ -153,9 +126,6 @@ const invoiceTaxNumber = ref('')
const invoiceEmail = ref('')
const invoiceError = ref('')
const invoiceSubmitting = ref(false)
let pollTimer: number | undefined
let pollDeadline = 0
let removeNativeListener: (() => void) | undefined
// 从后端加载余额与套餐
const loadData = async () => {
@@ -230,135 +200,38 @@ const formatPointsCopy = (value: string) => value.replace(/\bToken\b/gi, '积分
const checkoutLabel = computed(() => {
if (!selectedPlan.value) return '请选择充值套餐'
if (charging.value) return '正在创建支付订单…'
if (pendingOrderId.value) return '等待支付结果…'
return `立即支付 ¥${selectedPlan.value.price}`
return '去支付'
})
const payScene = () => {
if (/miniProgram/i.test(navigator.userAgent)) return 'LITE' as const
if (isInUniWebView()) return 'APP' as const
if (paymentMethod.value === 'wechat' && /MicroMessenger/i.test(navigator.userAgent)) return 'JSAPI' as const
if (/MicroMessenger/i.test(navigator.userAgent)) return 'JSAPI' as const
return 'H5' as const
}
const parsePayMessage = (message: string) => {
const text = (message || '').trim()
if (!text) return null
try {
return JSON.parse(text)
} catch {
return text
}
}
const findPaymentUrl = (value: any): string => {
if (typeof value === 'string' && /^https?:\/\//i.test(value)) return value
if (!value || typeof value !== 'object') return ''
for (const key of ['url', 'payUrl', 'mwebUrl', 'mweb_url', 'redirectUrl']) {
if (typeof value[key] === 'string' && /^https?:\/\//i.test(value[key])) return value[key]
}
return ''
}
const invokeWechatJsapi = (params: Record<string, any>) => new Promise<void>((resolve, reject) => {
const invoke = () => {
const bridge = (window as any).WeixinJSBridge
if (!bridge?.invoke) {
reject(new Error('当前微信环境无法拉起支付'))
return
}
bridge.invoke('getBrandWCPayRequest', params, (result: any) => {
const message = String(result?.err_msg || result?.errMsg || '')
if (/\bok\b/i.test(message)) resolve()
else reject(new Error(/cancel/i.test(message) ? '支付已取消' : '微信支付未完成'))
})
}
if ((window as any).WeixinJSBridge) invoke()
else document.addEventListener('WeixinJSBridgeReady', invoke, { once: true })
})
const launchPayment = async (order: TokenPaymentOrder) => {
const parsed = parsePayMessage(order.payMessage)
if (order.payWay === 'JSAPI' && order.paymentMethod === 'wechat' && parsed && typeof parsed === 'object') {
await invokeWechatJsapi(parsed)
return
}
if (isInUniWebView() && UniEvents.payment({
orderId: order.id,
orderNo: order.orderNo,
paymentMethod: order.paymentMethod,
payType: order.payType,
payWay: order.payWay,
payMessage: order.payMessage,
paymentParams: parsed
})) return
const paymentUrl = findPaymentUrl(parsed)
if (paymentUrl) {
window.location.assign(paymentUrl)
return
}
throw new Error('请在会会 App 内完成支付')
}
const clearPendingOrder = () => {
pendingOrderId.value = ''
sessionStorage.removeItem('hh_pending_payment_order')
if (pollTimer) window.clearTimeout(pollTimer)
pollTimer = undefined
}
const pollPayment = async () => {
if (!pendingOrderId.value) return
try {
const order = await getTokenPaymentStatus(pendingOrderId.value)
if (order.status === 'paid') {
currentBalance.value = order.balance
paymentNoticeTone.value = 'success'
paymentNotice.value = `支付成功,${order.pointsAmount.toLocaleString()} 积分已到账`
clearPendingOrder()
void loadData()
return
}
if (order.status === 'failed') {
paymentNoticeTone.value = 'error'
paymentNotice.value = order.failureReason || '支付未完成,请重新发起'
clearPendingOrder()
return
}
} catch (error) {
console.error('查询支付结果失败', error)
}
if (Date.now() < pollDeadline) pollTimer = window.setTimeout(pollPayment, 2000)
else {
paymentNoticeTone.value = 'pending'
paymentNotice.value = '支付结果仍在确认中,可稍后刷新余额或重新发起'
clearPendingOrder()
}
}
const startPolling = (orderId: string) => {
pendingOrderId.value = orderId
sessionStorage.setItem('hh_pending_payment_order', orderId)
pollDeadline = Date.now() + 2 * 60 * 1000
if (pollTimer) window.clearTimeout(pollTimer)
void pollPayment()
}
const doCharge = async () => {
if (!selectedPlan.value || charging.value) return
charging.value = true
paymentNotice.value = ''
try {
const order = await chargeToken(selectedPlan.value.id, paymentMethod.value, payScene())
const order = await chargeTokenOrg(selectedPlan.value.id, 'wechat', payScene())
if (order.status === 'failed') throw new Error(order.failureReason || '支付订单创建失败')
if (!order.providerOrderNo || !/^\d+$/.test(order.providerOrderNo)) throw new Error('会会充值订单号缺失')
paymentNoticeTone.value = 'pending'
paymentNotice.value = '支付订单已创建,正在等待付款结果'
startPolling(order.id)
await launchPayment(order)
paymentNotice.value = '支付订单已创建,支付结果将由会会回调确认'
const checkoutBase = window.location.hostname === 'digital.99hui.com'
? 'https://99hui.com/huihui-h5/'
: 'https://fat-open.99hui.com/huihui-h5/'
const evn = sessionStorage.getItem('hh_launch_evn') || 'wx'
const evnPart = `&evn=${encodeURIComponent(evn)}`
const tokenPart = order.checkoutToken
? `&token=${encodeURIComponent(order.checkoutToken)}`
: ''
window.location.assign(`${checkoutBase}#/payOrder?id=${encodeURIComponent(order.providerOrderNo)}&isOrgUrl=true${tokenPart}${evnPart}`)
} catch (error: any) {
paymentNoticeTone.value = 'error'
paymentNotice.value = error?.message || '充值失败,请重试'
clearPendingOrder()
} finally {
charging.value = false
}
@@ -371,32 +244,6 @@ const goBack = () => {
onMounted(() => {
void loadData()
removeNativeListener = onNativeMessage((message) => {
if (message?.type !== 'paymentResult') return
if (message.orderId && message.orderId !== pendingOrderId.value) return
if (['cancelled', 'canceled'].includes(String(message.status).toLowerCase())) {
paymentNoticeTone.value = 'error'
paymentNotice.value = '支付已取消'
clearPendingOrder()
return
}
if (String(message.status).toLowerCase() === 'failed') {
paymentNoticeTone.value = 'error'
paymentNotice.value = message.message || '支付未完成,请重新发起'
clearPendingOrder()
return
}
void pollPayment()
})
if (pendingOrderId.value) {
paymentNotice.value = '正在确认上一笔支付结果'
startPolling(pendingOrderId.value)
}
})
onUnmounted(() => {
if (pollTimer) window.clearTimeout(pollTimer)
removeNativeListener?.()
})
</script>
+3 -2
View File
@@ -19,14 +19,15 @@ services:
- AES_KEY=your-aes-key-32-chars-change-now!
- AVATAR_MODEL_CONFIG_TOKEN=${AVATAR_MODEL_CONFIG_TOKEN:-}
- TZ=Asia/Shanghai
- AVATAR_DB_PATH=/app/avatar.db
- AVATAR_DB_PATH=/data/avatar-db/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 # 财务管理需要写入退款与开票状态
# Mount the directory so admin and avatar services share SQLite WAL/SHM files too.
- ./digital-avatar-app/backend:/data/avatar-db
depends_on:
- ai-virtual-mysql
- ai-virtual-redis
+2 -3
View File
@@ -96,11 +96,10 @@ export const uploadAvatarPhoto = (id, formData) => request.post(`/avatars/${id}/
// Finance (数字分身积分订单)
export const getFinanceSummary = () => request.get('/finance/summary')
export const getFinancePlans = () => request.get('/finance/plans')
export const updateFinancePlan = (id, data) => request.patch(`/finance/plans/${id}`, data)
export const getFinanceOrders = (params) => request.get('/finance/orders', { params })
export const 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)
+34 -32
View File
@@ -11,11 +11,20 @@
<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="plans">
<el-table :data="plans" v-loading="loading" class="data-table">
<el-table-column label="套餐名称" prop="name" min-width="150" />
<el-table-column label="积分" min-width="160"><template #default="{ row }">{{ Number(row.amount).toLocaleString() }}</template></el-table-column>
<el-table-column label="价格" width="140"><template #default="{ row }">¥{{ money(row.price) }}</template></el-table-column>
<el-table-column label="标签" prop="badge" min-width="120" />
<el-table-column label="说明" prop="desc" min-width="180" />
<el-table-column label="操作" width="100"><template #default="{ row }"><el-button link type="primary" @click="editPlan(row)">调整</el-button></template></el-table-column>
</el-table>
</el-tab-pane>
<el-tab-pane label="支付订单" name="orders">
<div class="filter-bar">
<el-input v-model="filters.keyword" placeholder="订单号、昵称或手机号" clearable style="width:240px" @keyup.enter="loadOrders" />
@@ -23,13 +32,13 @@
<el-option v-for="item in orderStatuses" :key="item.value" :label="item.label" :value="item.value" />
</el-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-option label="会会支付" value="huihui" /><el-option label="会会组织支付" value="huihui_org" /><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 label="订单 / 用户" min-width="260">
<template #default="{ row }"><b>{{ row.order_no }}</b><div class="muted">支付编号:{{ row.provider_order_no || '--' }}</div><div class="muted">{{ row.user_nickname || '会会用户' }} · {{ row.user_phone || '--' }}</div></template>
</el-table-column>
<el-table-column 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>
@@ -39,33 +48,16 @@
<el-table-column label="下单时间" min-width="165"><template #default="{ row }">{{ dateTime(row.created_at) }}</template></el-table-column>
<el-table-column label="操作" 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" />
@@ -87,25 +79,37 @@
</el-form>
<template #footer><el-button @click="invoiceDialog=false">取消</el-button><el-button type="primary" :loading="saving" @click="issueInvoice">确认开具</el-button></template>
</el-dialog>
<el-dialog v-model="planDialog" title="调整充值套餐" width="480px">
<el-form label-width="90px">
<el-form-item label="套餐名称"><el-input v-model="planForm.name" maxlength="80" /></el-form-item>
<el-form-item label="积分数量"><el-input-number v-model="planForm.amount" :min="1" :step="10000" /></el-form-item>
<el-form-item label="套餐价格"><el-input-number v-model="planForm.price" :min="0" :precision="2" :step="1" /></el-form-item>
<el-form-item label="标签"><el-input v-model="planForm.badge" maxlength="80" /></el-form-item>
<el-form-item label="说明"><el-input v-model="planForm.desc" maxlength="200" /></el-form-item>
</el-form>
<template #footer><el-button @click="planDialog=false">取消</el-button><el-button type="primary" :loading="saving" @click="savePlan">保存</el-button></template>
</el-dialog>
</div>
</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'
import { getFinanceInvoices, getFinanceOrders, getFinancePlans, getFinanceSummary, updateFinanceInvoice, updateFinanceOrderStatus, updateFinancePlan } 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 summary = ref({ paid_revenue: 0, paid_orders: 0, pending_orders: 0, pending_invoices: 0 })
const orders = ref([]), invoices = ref([])
const plans = ref([]), planDialog = ref(false), currentPlan = ref(null)
const planForm = ref({ name: '', amount: 1, price: 0, badge: '', desc: '' })
const orderPage = ref(1), orderTotal = ref(0)
const 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 providerLabel = { huihui: '会会支付', huihui_org: '会会组织支付', wechat_virtual: '微信虚拟支付' }
const statusLabel = { pending: '待处理', processing: '处理中', paid: '已支付', succeeded: '已成功', failed: '失败', closed: '已关闭', refunded: '已退款' }
const invoiceStatusLabel = { pending: '待开具', issued: '已开具', rejected: '已驳回' }
const money = value => Number(value || 0).toFixed(2)
@@ -113,17 +117,15 @@ const dateTime = value => value ? new Date(value).toLocaleString('zh-CN', { hour
const tagType = value => ({ paid: 'success', succeeded: 'success', issued: 'success', pending: 'warning', processing: 'warning', failed: 'danger', rejected: 'danger', refunded: 'info', closed: 'info' }[value] || 'info')
async function loadSummary() { const res = await getFinanceSummary(); summary.value = res.data || summary.value }
async function loadPlans() { const res = await getFinancePlans(); plans.value = res.data || [] }
async function loadOrders() { const res = await getFinanceOrders({ page: orderPage.value, page_size: 20, ...filters.value }); orders.value = res.data?.items || []; orderTotal.value = res.data?.total || 0 }
async function 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 loadActive() { loading.value = true; try { if (activeTab.value === 'plans') await loadPlans(); if (activeTab.value === 'orders') await loadOrders(); if (activeTab.value === 'invoices') await loadInvoices() } finally { loading.value = false } }
async function loadAll() { loading.value = true; try { await Promise.all([loadSummary(), loadPlans(), loadOrders(), loadInvoices()]) } finally { loading.value = false } }
function editPlan(row) { currentPlan.value = row; planForm.value = { name: row.name, amount: Number(row.amount), price: Number(row.price), badge: row.badge || '', desc: row.desc || '' }; planDialog.value = true }
async function savePlan() { if (!currentPlan.value) return; saving.value = true; try { await updateFinancePlan(currentPlan.value.id, planForm.value); ElMessage.success('充值套餐已更新'); planDialog.value = false; await loadPlans() } finally { saving.value = false } }
async function refund(row) {
try { const { value } = await ElMessageBox.prompt('退款成功后会扣回本订单发放的全部积分。', `退款 ¥${money(row.price)}`, { inputPlaceholder: '请输入退款原因', inputValidator: value => value?.trim() ? true : '退款原因不能为空', confirmButtonText: '提交退款', cancelButtonText: '取消' }); await requestFinanceRefund(row.order_no, { reason: value, operator: '后台管理员' }); ElMessage.success('退款已提交渠道处理'); await loadAll() } catch (error) { if (error !== 'cancel' && error !== 'close') console.error(error) }
}
async function closeOrder(row, status) { try { await ElMessageBox.confirm(`确认将订单标记为“${status === 'closed' ? '已关闭' : '失败'}”?`, '订单状态处理', { type: 'warning' }); await updateFinanceOrderStatus(row.order_no, { status, reason: '后台管理员处理' }); ElMessage.success('订单状态已更新'); await loadAll() } catch {} }
async function 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 {} }