Compare commits

...
Author SHA1 Message Date
stefanfeng 721f8b990d fix: acknowledge signed payment notice before querying provider 2026-10-20 14:37:02 +08:00
stefanfeng 5c992729c4 fix: reconcile Huihui payment callbacks after provider confirmation 2026-10-20 14:27:49 +08:00
stefanfeng 23d9a33a6d Merge pull request '数字分身会会组织充值回调验签与查单确认' (#41) from codex/avatar-virtual-product-ids into main
Reviewed-on: #41
2026-10-20 14:13:29 +08:00
stefanfeng 04e96b6207 fix: accept legacy Huihui callback path for org orders 2026-10-20 14:03:13 +08:00
stefanfeng ce18df29b7 Verify Huihui organization payment callbacks against paid orders 2026-10-20 11:04:27 +08:00
stefanfeng 356f1c8c8d Merge pull request '数字分身套餐 ID 与微信虚拟支付道具 ID 对齐' (#40) from codex/avatar-virtual-product-ids into main
Reviewed-on: #40
2026-10-19 14:21:21 +08:00
stefanfeng 643abd13ea Pass recharge product ID to Huihui order API 2026-10-19 14:12:43 +08:00
stefanfeng 3379d5a1b2 Align avatar recharge plan IDs with WeChat virtual products 2026-10-19 14:00:06 +08:00
stefanfeng 5f27cc93cc Merge pull request '调整会会收银台参数并自动关闭超时订单' (#39) from codex/avatar-token-recharge-huihui into main
Reviewed-on: #39
2026-10-16 17:52:44 +08:00
stefanfeng 0302ee81e0 fix(finance): share avatar sqlite WAL with admin 2026-10-16 17:50:29 +08:00
stefanfeng 08a7bc8b90 feat(payment): expire pending orders after one hour 2026-10-16 17:24:57 +08:00
stefanfeng 07cef004d2 Merge pull request '完善数字分身积分充值与财务管理' (#38) from codex/avatar-token-recharge-huihui into main
Reviewed-on: #38
2026-10-16 17:14:39 +08:00
stefanfeng ddb8ea9802 fix(payment): rely on Huihui callback for status 2026-10-16 17:00:35 +08:00
stefanfeng 85d0c57548 fix(avatar): complete payment method removal 2026-10-16 16:42:34 +08:00
stefanfeng da5822ecfe feat(finance): manage token plans and simplify recharge checkout 2026-10-16 16:34:22 +08:00
stefanfeng 643730f756 fix(avatar): default checkout environment and simplify pay action 2026-10-16 16:16:11 +08:00
stefanfeng 63a0c6692c fix(avatar): show recharge entry for all signed-in users 2026-10-16 15:31:45 +08:00
stefanfeng c62233a7c2 feat(avatar): show recharge entry in uni-app webview 2026-10-16 15:17:21 +08:00
stefanfeng 09aebea7d1 fix(avatar): clear stale checkout context and closed orders 2026-10-16 15:03:03 +08:00
stefanfeng b1bf203018 feat(avatar): route token recharge through Huihui org orders 2026-10-16 14:56:00 +08:00
stefanfeng 9566eb8925 Merge pull request 'feat(avatar): add signed Huihui virtual payment callback' (#37) from codex/huihui-payment-callback into main 2026-10-15 14:28:30 +08:00
stefanfeng e5df72956d feat(avatar): add signed Huihui virtual payment callback 2026-10-15 14:26:06 +08:00
stefanfeng 48f3b9bddf Merge pull request 'fix(avatar): hide ai source note below chat bubbles' (#36) from codex/hide-chat-ai-source-20260920 into main
fix(avatar): hide ai source note below chat bubbles
2026-09-20 16:11:04 +08:00
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
32 changed files with 1265 additions and 488 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` 启动
+92 -12
View File
@@ -1,14 +1,15 @@
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 models import Avatar, Authorization, Organization, TokenAccount, TokenPlan, User
from database import engine, init_db, SessionLocal
from models import Avatar, Authorization, Organization, TokenAccount, TokenPaymentOrder, TokenPlan, User
from fastapi.staticfiles import StaticFiles
import routers.avatars
import routers.tokens
@@ -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,25 +59,59 @@ 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():
db = SessionLocal()
try:
plan_specs = [
{"id": "1", "name": "基础套餐", "amount": 2_000_000, "price": 10, "badge": "", "desc": "2M 积分"},
{"id": "2", "name": "标准套餐", "amount": 20_000_000, "price": 100, "badge": "常用", "desc": "20M 积分"},
{"id": "3", "name": "专业套餐", "amount": 250_000_000, "price": 1000, "badge": "加赠25%", "desc": "250M 积分"},
{"id": "4", "name": "企业套餐", "amount": 2_500_000_000, "price": 10000, "badge": "企业推荐", "desc": "2500M 积分"},
{"id": "Basic", "legacy_id": "1", "name": "基础套餐", "amount": 2_000_000, "price": 10, "badge": "", "desc": "2M 积分"},
{"id": "Standard", "legacy_id": "2", "name": "标准套餐", "amount": 20_000_000, "price": 100, "badge": "常用", "desc": "20M 积分"},
{"id": "Professional", "legacy_id": "3", "name": "专业套餐", "amount": 250_000_000, "price": 1000, "badge": "加赠25%", "desc": "250M 积分"},
{"id": "Enterprise", "legacy_id": "4", "name": "企业套餐", "amount": 2_500_000_000, "price": 10000, "badge": "企业推荐", "desc": "2500M 积分"},
]
for spec in plan_specs:
plan = db.query(TokenPlan).filter(TokenPlan.id == spec["id"]).first()
legacy = db.query(TokenPlan).filter(TokenPlan.id == spec["legacy_id"]).first()
if legacy is not None and plan is None:
legacy.id = spec["id"]
plan = legacy
db.flush()
if legacy is not None and legacy is not plan:
db.delete(legacy)
db.query(TokenPaymentOrder).filter(TokenPaymentOrder.plan_id == spec["legacy_id"]).update(
{TokenPaymentOrder.plan_id: spec["id"]}, synchronize_session=False
)
if plan is None:
db.add(TokenPlan(**spec))
db.add(TokenPlan(**{key: value for key, value in spec.items() if key != "legacy_id"}, virtual_product_id=spec["id"]))
else:
for key, value in spec.items():
setattr(plan, key, value)
plan.name = spec["name"]
plan.virtual_product_id = spec["id"]
for user in db.query(User).all():
account = db.query(TokenAccount).filter(TokenAccount.user_id == user.id).first()
@@ -230,10 +269,37 @@ 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 reconcile_huihui_org_notices_job():
db = SessionLocal()
try:
count = routers.tokens.reconcile_huihui_org_notices(db)
if count:
logger.info("Confirmed %s Huihui organization payment notice(s)", count)
except Exception as exc:
db.rollback()
logger.warning("Failed to reconcile Huihui organization payment notices: %s", exc)
finally:
db.close()
def start_maintenance_scheduler():
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 +311,20 @@ 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.add_job(
reconcile_huihui_org_notices_job,
trigger=IntervalTrigger(seconds=10),
id="reconcile_huihui_org_notices",
max_instances=1,
coalesce=True,
)
maintenance_scheduler.start()
+19
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,
@@ -436,6 +437,24 @@ class PaymentTransaction(Base):
}
class HuihuiOrgPaymentNotice(Base):
"""Signed success notice awaiting independent confirmation from Huihui."""
__tablename__ = "huihui_org_payment_notices"
order_no = Column(String, primary_key=True)
payment_no = Column(String, nullable=False)
amount_cents = Column(Integer, nullable=False)
product_id = Column(String, nullable=False)
status = Column(String, nullable=False, default="awaiting_provider", index=True)
attempts = Column(Integer, nullable=False, default=0)
next_check_at = Column(DateTime, index=True)
last_provider_status = Column(String, default="")
last_error = Column(String, default="")
created_at = Column(DateTime, server_default=func.now())
updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now())
class PaymentRefund(Base):
__tablename__ = "payment_refunds"
+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(
+363 -2
View File
@@ -2,17 +2,20 @@ import hashlib
import hmac
import json
import os
import time
import uuid
from datetime import datetime
from datetime import datetime, timedelta
from decimal import Decimal, InvalidOperation, ROUND_HALF_UP
from urllib.parse import parse_qs
from fastapi import APIRouter, Body, Depends, Header, HTTPException, Query, Request, Response
from sqlalchemy import func
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session
from database import get_db
from models import (
HuihuiOrgPaymentNotice,
InvoiceApplication,
PaymentRefund,
PaymentTransaction,
@@ -24,6 +27,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,
@@ -44,6 +48,13 @@ router = APIRouter(tags=["Token"])
PAYMENT_METHODS = {"wechat": "WECHAT", "alipay": "ALIPAY"}
PAYMENT_SCENES = {"APP", "H5", "LITE", "JSAPI"}
LEGACY_PLAN_IDS = {"1": "Basic", "2": "Standard", "3": "Professional", "4": "Enterprise"}
def _selected_plan(db: Session, plan_id):
return db.query(TokenPlan).filter(TokenPlan.id == LEGACY_PLAN_IDS.get(str(plan_id), plan_id)).first()
SUCCESS_STATUSES = {"SUCCESS", "SUCCEEDED", "PAID", "COMPLETED", "TRADE_SUCCESS"}
FAILED_STATUSES = {"FAIL", "FAILED", "CLOSED", "CANCELLED", "CANCELED", "EXPIRED"}
@@ -269,6 +280,7 @@ def _callback_amount_cents(payload) -> int | None:
"orderAmt",
"amount",
"totalAmount",
"chargeAmount",
)
if value in (None, ""):
return None
@@ -304,7 +316,7 @@ def plans(authorization: str = Header(None), db: Session = Depends(get_db)):
@router.post("/token/charge")
def charge(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()
plan = _selected_plan(db, payload.get("planId"))
if not plan:
return fail("套餐不存在", 404)
@@ -404,6 +416,50 @@ def charge(payload: dict = Body(...), authorization: str = Header(None), db: Ses
return ok(_payment_payload(order, get_or_create_account(db, user.id)))
@router.post("/token/charge/org")
def charge_via_huihui_org(payload: dict = Body(...), authorization: str = Header(None), db: Session = Depends(get_db)):
user = _require_user(authorization, db)
plan = _selected_plan(db, payload.get("planId"))
if not plan:
return fail("套餐不存在", 404)
method = str(payload.get("paymentMethod") or "").lower()
if method not in PAYMENT_METHODS:
return fail("支付方式不正确", 400)
scene = str(payload.get("payScene") or "APP").upper()
if scene not in PAYMENT_SCENES:
return fail("支付场景不正确", 400)
if scene == "LITE" and method != "wechat":
return fail("微信小程序仅支持微信支付", 400)
cents = _price_cents(plan.price)
try:
result = HuihuiOrgPaymentClient().create_order(
token=user.huihui_token, user_id=user.huihui_user_id,
name=user.nickname, phone=user.phone, avatar=user.avatar_url,
amount=f"{cents / 100:.2f}",
product_id=plan.id,
)
except HuihuiPaymentError as exc:
return fail(str(exc), 502)
provider_no = str(result.get("orderNo") or "")
if not provider_no.isdigit() or int(result.get("payStatus") or 0) != 1 or int(result.get("chargeType") or 0) != 9:
return fail("会会返回的充值订单不正确", 502)
if _callback_amount_cents({"chargeAmount": result.get("chargeAmount")}) != cents:
return fail("会会返回的充值金额不匹配", 502)
order = TokenPaymentOrder(
order_no=f"AV{datetime.utcnow().strftime('%Y%m%d%H%M%S')}{uuid.uuid4().hex[:12].upper()}",
user_id=user.id, plan_id=plan.id, payment_method=method,
pay_type=PAYMENT_METHODS[method], pay_way=scene, points_amount=plan.amount,
price_cents=cents, status="pending", provider="huihui_org",
provider_order_no=provider_no, provider_status="1",
)
db.add(order)
db.commit()
result = _payment_payload(order, get_or_create_account(db, user.id))
if "fat-open.99hui.com" in os.getenv("HUIHUI_ORG_BASE_URL", ""):
result["checkoutToken"] = user.huihui_token
return ok(result)
@router.get("/token/payment/{order_id}")
def payment_status(order_id: str, authorization: str = Header(None), db: Session = Depends(get_db)):
user = _require_user(authorization, db)
@@ -413,6 +469,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 +616,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 +812,308 @@ async def payment_callback(
return ok({"received": True, "paid": True})
async def _signed_huihui_notice(request: Request, *, secret_name: str, minimum_length: int) -> dict:
secret = os.getenv(secret_name, "").strip()
if len(secret) < minimum_length:
raise HTTPException(status_code=503, detail="payment callback is not configured")
timestamp = request.headers.get("x-huihui-timestamp", "").strip()
signature = request.headers.get("x-huihui-signature", "").strip().lower()
if not timestamp.isascii() or not timestamp.isdecimal() or len(timestamp) > 12:
raise HTTPException(status_code=401, detail="invalid callback signature")
try:
issued_at = int(timestamp)
except ValueError:
raise HTTPException(status_code=401, detail="invalid callback signature")
if abs(int(time.time()) - issued_at) > 300:
raise HTTPException(status_code=401, detail="callback timestamp expired")
body = await request.body()
if not body or len(body) > 65536:
raise HTTPException(status_code=400, detail="invalid callback body")
expected = hmac.new(
secret.encode("utf-8"), timestamp.encode("ascii") + b"\n" + body, hashlib.sha256
).hexdigest()
if not hmac.compare_digest(signature, expected):
raise HTTPException(status_code=401, detail="invalid callback signature")
try:
payload = json.loads(body)
except (UnicodeDecodeError, ValueError):
raise HTTPException(status_code=400, detail="invalid callback JSON")
if not isinstance(payload, dict):
raise HTTPException(status_code=400, detail="invalid callback JSON")
return payload
@router.post("/token/payment/huihui/org/notify")
async def huihui_org_payment_notify(request: Request, db: Session = Depends(get_db)):
"""Huihui organization recharge notice; independently verify payment before crediting."""
payload = await _signed_huihui_notice(
request, secret_name="HUIHUI_ORG_CALLBACK_SECRET", minimum_length=16,
)
return _process_huihui_org_notice(payload, db)
def _process_huihui_org_notice(payload: dict, db: Session):
order_no = str(payload.get("orderNo") or "").strip()
payment_no = str(payload.get("paymentNo") or "").strip()
status = str(payload.get("status") or "").upper()
if not order_no or not payment_no or status not in {"SUCCESS", "FAILED"}:
raise HTTPException(status_code=400, detail="missing callback fields")
order = db.query(TokenPaymentOrder).filter(
TokenPaymentOrder.provider == "huihui_org",
TokenPaymentOrder.order_no == order_no,
).first()
if order is None and order_no.isdigit():
matches = db.query(TokenPaymentOrder).filter(
TokenPaymentOrder.provider == "huihui_org",
TokenPaymentOrder.provider_order_no == order_no,
).limit(2).all()
if len(matches) == 1:
order = matches[0]
if not order:
raise HTTPException(status_code=404, detail="payment order not found")
if status == "FAILED":
return ok({"received": True, "paid": False})
user = db.query(User).filter(User.id == order.user_id).first()
plan = db.query(TokenPlan).filter(TokenPlan.id == order.plan_id).first()
try:
expected_product_id = product_id_for_plan(plan) if plan else ""
amount_cents = int(payload.get("amountCents"))
except (TypeError, ValueError, WechatVirtualPaymentError):
raise HTTPException(status_code=422, detail="invalid payment details")
if (
isinstance(payload.get("amountCents"), bool)
or not user
or not expected_product_id
or str(payload.get("productId") or "") != expected_product_id
or amount_cents != order.price_cents
):
raise HTTPException(status_code=422, detail="payment details mismatch")
if order.status in {"paid", "refunded"}:
return ok({"received": True, "duplicate": True})
notice = db.get(HuihuiOrgPaymentNotice, order.order_no)
if notice and notice.payment_no != payment_no:
raise HTTPException(status_code=409, detail="payment number conflicts with prior notice")
if not order.provider_order_no:
raise HTTPException(status_code=422, detail="provider order number missing")
# The provider may not mark its order paid until it receives our HTTP 200.
# Never synchronously query the provider before acknowledging a signed notice.
_save_huihui_org_notice(db, order, payment_no, amount_cents, expected_product_id)
return ok({"received": True, "paid": False, "awaitingProvider": True})
def _huihui_org_provider_status(order: TokenPaymentOrder, user: User, product_id: str, payment_no: str) -> int:
provider_order = HuihuiOrgPaymentClient().get_order(
token=user.huihui_token,
user_id=user.huihui_user_id,
order_no=order.provider_order_no,
)
try:
provider_status = int(provider_order.get("payStatus"))
provider_type = int(provider_order.get("chargeType"))
except (TypeError, ValueError):
raise HTTPException(status_code=422, detail="invalid provider order details")
if (
str(provider_order.get("orderNo") or "") != order.provider_order_no
or str(provider_order.get("paymentNo") or "") != payment_no
or str(provider_order.get("productId") or "") != product_id
or str(provider_order.get("userId") or "") != user.huihui_user_id
or provider_type != 9
or _callback_amount_cents({"chargeAmount": provider_order.get("chargeAmount")}) != order.price_cents
):
raise HTTPException(status_code=422, detail="provider order details mismatch")
return provider_status
def _save_huihui_org_notice(
db: Session, order: TokenPaymentOrder, payment_no: str, amount_cents: int,
product_id: str,
) -> None:
notice = db.get(HuihuiOrgPaymentNotice, order.order_no)
if notice and (
notice.payment_no != payment_no
or notice.amount_cents != amount_cents
or notice.product_id != product_id
):
raise HTTPException(status_code=409, detail="payment notice conflicts with prior notice")
if notice is None:
notice = HuihuiOrgPaymentNotice(
order_no=order.order_no, payment_no=payment_no,
amount_cents=amount_cents, product_id=product_id,
)
db.add(notice)
notice.status = "awaiting_provider"
notice.last_provider_status = "not_checked"
notice.last_error = ""
first_check = datetime.utcnow() + timedelta(seconds=5)
if notice.next_check_at is None or notice.next_check_at > first_check:
notice.next_check_at = first_check
try:
db.commit()
except IntegrityError:
db.rollback()
concurrent = db.get(HuihuiOrgPaymentNotice, order.order_no)
if not concurrent or (
concurrent.payment_no != payment_no
or concurrent.amount_cents != amount_cents
or concurrent.product_id != product_id
):
raise HTTPException(status_code=409, detail="payment notice conflicts with prior notice")
def _huihui_notice_retry_seconds(attempts: int) -> int:
return min(15 * (2 ** min(max(0, attempts - 1), 8)), 3600)
def _settle_verified_huihui_org_order(
db: Session, order: TokenPaymentOrder, payment_no: str,
notice: HuihuiOrgPaymentNotice | None = None,
) -> None:
existing_payment = db.query(PaymentTransaction).filter(
PaymentTransaction.provider == "huihui_org",
PaymentTransaction.event_type == "payment",
PaymentTransaction.transaction_no == payment_no,
).first()
if existing_payment and existing_payment.order_no != order.order_no:
raise HTTPException(status_code=409, detail="payment number belongs to another order")
_settle_paid_order(db, order, provider_status="HUIHUI_SUCCESS", transaction_no=payment_no)
if notice:
notice.status = "confirmed"
notice.next_check_at = None
notice.last_provider_status = "paid"
notice.last_error = ""
db.commit()
def reconcile_huihui_org_notices(db: Session, limit: int = 20) -> int:
"""Retry only signed notices whose provider confirmation lagged the callback."""
now = datetime.utcnow()
due = db.query(HuihuiOrgPaymentNotice).filter(
HuihuiOrgPaymentNotice.status == "awaiting_provider",
HuihuiOrgPaymentNotice.next_check_at <= now,
).order_by(HuihuiOrgPaymentNotice.next_check_at).limit(limit).all()
confirmed = 0
for notice in due:
order = db.query(TokenPaymentOrder).filter(
TokenPaymentOrder.order_no == notice.order_no,
TokenPaymentOrder.provider == "huihui_org",
).first()
if not order:
notice.status = "rejected"
notice.last_error = "order missing"
db.commit()
continue
if order.status in {"paid", "refunded"}:
notice.status = "confirmed"
notice.next_check_at = None
db.commit()
continue
user = db.query(User).filter(User.id == order.user_id).first()
plan = db.query(TokenPlan).filter(TokenPlan.id == order.plan_id).first()
try:
product_id = product_id_for_plan(plan) if plan else ""
except WechatVirtualPaymentError:
product_id = ""
if not user or not product_id or product_id != notice.product_id or order.price_cents != notice.amount_cents:
notice.status = "rejected"
notice.last_error = "local order details mismatch"
db.commit()
continue
try:
status = _huihui_org_provider_status(order, user, product_id, notice.payment_no)
except HuihuiPaymentError:
status = None
except HTTPException as exc:
notice.status = "rejected"
notice.last_error = str(exc.detail)[:200]
db.commit()
continue
if status in {2, 3, 4}:
try:
_settle_verified_huihui_org_order(db, order, notice.payment_no, notice)
confirmed += 1
except HTTPException as exc:
db.rollback()
notice = db.get(HuihuiOrgPaymentNotice, notice.order_no)
notice.status = "rejected"
notice.last_error = str(exc.detail)[:200]
db.commit()
continue
notice.last_provider_status = str(status) if status is not None else "unavailable"
notice.attempts = int(notice.attempts or 0) + 1
if notice.created_at and now - notice.created_at >= timedelta(hours=24):
notice.status = "unconfirmed"
notice.next_check_at = None
notice.last_error = "provider did not confirm payment within 24 hours"
else:
notice.next_check_at = now + timedelta(seconds=_huihui_notice_retry_seconds(notice.attempts))
db.commit()
return confirmed
@router.post("/token/payment/huihui/virtual/notify")
async def huihui_virtual_payment_notify(request: Request, db: Session = Depends(get_db)):
"""Server-to-server virtual-payment notice using the Huihui HMAC contract."""
# Older Huihui integrations still post organization recharge notices to this
# path. Only use the organization key when no virtual-payment key exists;
# the organization handler independently checks the order's provider.
if len(os.getenv("HUIHUI_VIRTUAL_CALLBACK_SECRET", "").strip()) < 32 and len(
os.getenv("HUIHUI_ORG_CALLBACK_SECRET", "").strip()
) >= 16:
payload = await _signed_huihui_notice(
request, secret_name="HUIHUI_ORG_CALLBACK_SECRET", minimum_length=16,
)
return _process_huihui_org_notice(payload, db)
payload = await _signed_huihui_notice(
request, secret_name="HUIHUI_VIRTUAL_CALLBACK_SECRET", minimum_length=32,
)
order_no = str(payload.get("orderNo") or "").strip()
payment_no = str(payload.get("paymentNo") or "").strip()
status = str(payload.get("status") or "").upper()
if not order_no or not payment_no or status not in {"SUCCESS", "FAILED"}:
raise HTTPException(status_code=400, detail="missing callback fields")
order = db.query(TokenPaymentOrder).filter(TokenPaymentOrder.order_no == order_no).first()
if not order or order.provider != "wechat_virtual":
raise HTTPException(status_code=404, detail="virtual payment order not found")
if status == "FAILED":
return ok({"received": True, "paid": False})
user = db.query(User).filter(User.id == order.user_id).first()
plan = db.query(TokenPlan).filter(TokenPlan.id == order.plan_id).first()
try:
expected_product_id = product_id_for_plan(plan) if plan else ""
amount_cents = int(payload.get("amountCents"))
callback_env = int(payload.get("env"))
except (TypeError, ValueError, WechatVirtualPaymentError):
raise HTTPException(status_code=422, detail="invalid virtual payment details")
if (
isinstance(payload.get("amountCents"), bool)
or isinstance(payload.get("env"), bool)
or not user
or not user.wechat_mp_openid
or str(payload.get("openId") or "") != user.wechat_mp_openid
or not expected_product_id
or str(payload.get("productId") or "") != expected_product_id
or amount_cents != order.price_cents
or callback_env != wechat_virtual_env()
):
raise HTTPException(status_code=422, detail="virtual payment details mismatch")
existing_payment = db.query(PaymentTransaction).filter(
PaymentTransaction.provider == "wechat_virtual",
PaymentTransaction.event_type == "payment",
PaymentTransaction.transaction_no == payment_no,
).first()
if existing_payment and existing_payment.order_no != order.order_no:
raise HTTPException(status_code=409, detail="payment number belongs to another order")
if order.status in {"paid", "refunded"}:
return ok({"received": True, "duplicate": True})
_settle_paid_order(db, order, provider_status="HUIHUI_SUCCESS", transaction_no=payment_no)
db.commit()
return ok({"received": True, "paid": True})
def _wechat_notify_response(request: Request, *, success: bool, message: str = ""):
code = 0 if success else 1
text = "success" if success else (message or "fail")[:200].replace("]]>", "")
@@ -0,0 +1,49 @@
"""Huihui organization token-recharge order API."""
import os
import httpx
from services.huihui_payment import HuihuiPaymentClient, HuihuiPaymentError
class HuihuiOrgPaymentClient(HuihuiPaymentClient):
def __init__(self):
super().__init__({
"HUIHUI_PAYMENT_BASE_URL": os.getenv("HUIHUI_ORG_BASE_URL", ""),
"HUIHUI_APP_ID": os.getenv("HUIHUI_APP_ID", ""),
"HUIHUI_ACCESS_ID": os.getenv("HUIHUI_ACCESS_ID", ""),
"HUIHUI_ACCESS_SECRET": os.getenv("HUIHUI_ACCESS_SECRET", ""),
})
def _request(self, method: str, path: str, *, token: str, user_id: str, body=None) -> dict:
if not self.configured or not token or not user_id:
raise HuihuiPaymentError("会会订单服务或登录凭证未配置")
try:
response = httpx.request(
method, f"{self.base_url}{path}",
headers={"Authorization": f"Bearer {token}", "appId": self.app_id},
params=self._signed_params(user_id), json=body,
timeout=self.timeout, follow_redirects=True,
)
except httpx.HTTPError as exc:
raise HuihuiPaymentError("会会订单服务连接失败") from exc
payload = self._json(response)
if response.status_code >= 400 or payload.get("code") not in (0, 200, "0", "200"):
raise HuihuiPaymentError(str(payload.get("message") or "会会订单请求失败"))
data = payload.get("data")
if not isinstance(data, dict):
raise HuihuiPaymentError("会会订单信息缺失")
return data
def create_order(self, *, token: str, user_id: str, name: str, phone: str,
avatar: str, amount: str, product_id: str) -> dict:
body = {"chargeAmount": float(amount),
"commodityName": "数字分身积分充值", "productId": product_id,
"userId": user_id,
"name": name or "", "phoneNumber": phone or "", "avatar": avatar or ""}
return self._request("POST", "/order/tokenRecharge", token=token, user_id=user_id, body=body)
def get_order(self, *, token: str, user_id: str, order_no: str) -> dict:
if not order_no.isdigit():
raise HuihuiPaymentError("会会订单号格式不正确")
return self._request("GET", f"/order/get/{order_no}", token=token, user_id=user_id)
@@ -7,7 +7,7 @@ from datetime import datetime, timedelta
from sqlalchemy.exc import IntegrityError
from sqlalchemy.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)
@@ -49,9 +49,7 @@ def _offer_id() -> str:
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()
configured = str(getattr(plan, "id", "") or "").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):
@@ -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"
@@ -1,6 +1,7 @@
import os
from unittest.mock import Mock, patch
from services.huihui_org_payment import HuihuiOrgPaymentClient
from services.huihui_payment import HuihuiPaymentClient
@@ -81,3 +82,31 @@ def test_request_refund_uses_configured_huihui_endpoint_without_exposing_secret(
"refundReason": "用户申请",
}
assert "accessSecret" not in post.call_args.kwargs["params"]
def test_org_recharge_order_sends_package_product_id_as_string():
env = {
"HUIHUI_ORG_BASE_URL": "https://fat-open.example/api/huihuiorg",
"HUIHUI_APP_ID": "app-id",
"HUIHUI_ACCESS_ID": "access-id",
"HUIHUI_ACCESS_SECRET": "access-secret",
}
response = Mock(status_code=200)
response.json.return_value = {"code": 200, "data": {"orderNo": "261016111200090123456"}}
with patch.dict(os.environ, env), patch(
"services.huihui_org_payment.httpx.request", return_value=response
) as request:
result = HuihuiOrgPaymentClient().create_order(
token="user-token", user_id="user-id", name="测试用户",
phone="", avatar="", amount="10.00", product_id="Basic",
)
assert result["orderNo"] == "261016111200090123456"
assert request.call_args.args == (
"POST", "https://fat-open.example/api/huihuiorg/order/tokenRecharge"
)
body = request.call_args.kwargs["json"]
assert body["chargeAmount"] == 10.0
assert body["productId"] == "Basic"
assert isinstance(body["productId"], str)
@@ -53,8 +53,15 @@ def test_scheduler_uses_boxim_and_restart_safe_service(
max_message_age_seconds=600,
)
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 == 3
cleanup_call, close_orders_call, reconcile_call = maintenance_scheduler.add_job.call_args_list
assert cleanup_call.kwargs["id"] == "chat_attachment_cleanup"
assert close_orders_call.args[0] is main.close_expired_pending_orders_job
assert close_orders_call.kwargs["id"] == "close_expired_token_orders"
assert close_orders_call.kwargs["trigger"].interval.total_seconds() == 60
assert reconcile_call.args[0] is main.reconcile_huihui_org_notices_job
assert reconcile_call.kwargs["id"] == "reconcile_huihui_org_notices"
assert reconcile_call.kwargs["trigger"].interval.total_seconds() == 10
maintenance_scheduler.start.assert_called_once_with()
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,
@@ -55,6 +57,10 @@ def test_seed_synchronizes_requested_recharge_plans():
db = SessionLocal()
try:
plans = db.query(TokenPlan).order_by(TokenPlan.price.asc()).all()
assert {plan.id: plan.virtual_product_id for plan in plans} == {
"Basic": "Basic", "Standard": "Standard",
"Professional": "Professional", "Enterprise": "Enterprise",
}
assert [(plan.price, plan.amount) for plan in plans] == [
(10, 2_000_000),
(100, 20_000_000),
@@ -65,6 +71,33 @@ def test_seed_synchronizes_requested_recharge_plans():
db.close()
def test_seed_preserves_admin_price_and_migrates_old_order_plan_id():
seed()
db = SessionLocal()
order_no = f"TEST-MIGRATION-{uuid.uuid4().hex}"
try:
plan = db.query(TokenPlan).filter_by(id="Basic").one()
original_price = plan.price
plan.price = 0.01
db.add(TokenPlan(id="1", name="基础套餐", amount=2_000_000, price=10))
db.add(TokenPaymentOrder(
order_no=order_no, user_id="test-user", plan_id="1",
payment_method="wechat", pay_type="WECHAT", pay_way="JSAPI",
points_amount=2_000_000, price_cents=1000, status="pending",
))
db.commit()
seed()
db.expire_all()
assert db.query(TokenPlan).filter_by(id="1").first() is None
assert db.query(TokenPlan).filter_by(id="Basic").one().price == 0.01
assert db.query(TokenPaymentOrder).filter_by(order_no=order_no).one().plan_id == "Basic"
finally:
db.query(TokenPaymentOrder).filter_by(order_no=order_no).delete()
db.query(TokenPlan).filter_by(id="Basic").update({"price": original_price})
db.commit()
db.close()
def test_charge_creates_huihui_order_without_early_points(authorization_context):
context = authorization_context
_enable_huihui_payment_login(context)
@@ -100,6 +133,85 @@ def test_charge_creates_huihui_order_without_early_points(authorization_context)
assert "test-callback-secret-123456" not in callback_url
def test_org_recharge_only_credits_after_verified_provider_order(authorization_context):
seed()
context = authorization_context
_enable_huihui_payment_login(context)
provider_no = "261016111200090123456"
created_data = {"orderNo": provider_no, "chargeType": 9,
"chargeAmount": 10, "payStatus": 1}
with patch.dict(os.environ, {"HUIHUI_ORG_BASE_URL": "https://fat-open.99hui.com/api/huihuiorg"}), patch("routers.tokens.HuihuiOrgPaymentClient") as client_factory:
provider = client_factory.return_value
provider.create_order.return_value = created_data
created = client.post(
"/api/token/charge/org", headers=context["owner_headers"],
json={"planId": "1", "paymentMethod": "wechat", "payScene": "APP"},
).json()["data"]
assert created["providerOrderNo"] == provider_no
assert created["status"] == "pending"
assert created["balance"] == DEFAULT_TOKEN_GRANT
assert created["checkoutToken"] == f"huihui-payment-{context['suffix']}"
assert "pay_type" not in provider.create_order.call_args.kwargs
assert provider.create_order.call_args.kwargs["product_id"] == "Basic"
details = {**created_data, "payStatus": 2,
"userId": context["owner"].huihui_user_id}
provider.get_order.return_value = details
unchanged = client.get(f"/api/token/payment/{created['id']}", headers=context["owner_headers"]).json()["data"]
assert unchanged["status"] == "pending"
assert unchanged["balance"] == DEFAULT_TOKEN_GRANT
provider.get_order.assert_not_called()
def test_org_recharge_does_not_return_checkout_token_outside_test_environment(authorization_context):
seed()
context = authorization_context
_enable_huihui_payment_login(context)
with patch.dict(os.environ, {"HUIHUI_ORG_BASE_URL": "https://open.99hui.com/api/huihuiorg"}), patch(
"routers.tokens.HuihuiOrgPaymentClient"
) as client_factory:
client_factory.return_value.create_order.return_value = {
"orderNo": "261016111200090123457", "chargeType": 9,
"chargeAmount": 10, "payStatus": 1,
}
response = client.post(
"/api/token/charge/org", headers=context["owner_headers"],
json={"planId": "1", "paymentMethod": "wechat", "payScene": "APP"},
)
assert response.status_code == 200
assert "checkoutToken" not in response.json()["data"]
def test_expired_pending_payment_orders_are_closed_without_touching_recent_orders(authorization_context):
context = authorization_context
db = SessionLocal()
try:
expired = TokenPaymentOrder(
order_no=f"EXPIRED-{uuid.uuid4().hex}", user_id=context["owner"].id, plan_id="1",
payment_method="wechat", pay_type="WECHAT", pay_way="H5", points_amount=100,
price_cents=100, status="pending", provider="huihui_org",
created_at=datetime.utcnow() - timedelta(hours=1, seconds=1),
)
recent = TokenPaymentOrder(
order_no=f"RECENT-{uuid.uuid4().hex}", user_id=context["owner"].id, plan_id="1",
payment_method="wechat", pay_type="WECHAT", pay_way="H5", points_amount=100,
price_cents=100, status="pending", provider="huihui_org",
created_at=datetime.utcnow() - timedelta(minutes=59),
)
db.add_all([expired, recent])
db.commit()
assert close_expired_pending_orders(db) == 1
db.refresh(expired)
db.refresh(recent)
assert expired.status == "closed"
assert expired.failure_reason == "支付超时(超过1小时)"
assert recent.status == "pending"
finally:
db.close()
def test_success_callback_credits_once_and_status_is_user_scoped(authorization_context):
context = authorization_context
_enable_huihui_payment_login(context)
@@ -1,19 +1,187 @@
import hashlib
import hmac
import json
import os
import time
from datetime import datetime, timedelta
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 models import HuihuiOrgPaymentNotice, InvoiceApplication, PaymentRefund, TokenAccount, TokenPaymentOrder, TokenPlan, User
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 == "Basic").one()
db.commit()
finally:
db.close()
secret = "huihui-virtual-callback-test-secret-123456"
env = {
"WECHAT_VIRTUAL_ENV": "sandbox",
"WECHAT_VIRTUAL_SANDBOX_APP_KEY": "sandbox-key",
"WECHAT_VIRTUAL_OFFER_ID": "offer-1",
"HUIHUI_VIRTUAL_CALLBACK_SECRET": secret,
}
with patch.dict(os.environ, env):
created = client.post(
"/api/token/charge",
headers=context["owner_headers"],
json={"planId": "Basic", "paymentMethod": "wechat", "payScene": "LITE"},
).json()["data"]
payload = {
"orderNo": created["orderNo"],
"paymentNo": f"huihui-virtual-payment-{context['suffix']}",
"status": "SUCCESS",
"amountCents": 1000,
"productId": "Basic",
"openId": "openid-huihui-callback",
"env": 1,
}
path = "/api/token/payment/huihui/virtual/notify"
body = json.dumps(payload, separators=(",", ":")).encode()
assert client.post(path, content=body, headers=_huihui_virtual_headers(secret, body, 1)).status_code == 401
assert client.post(path, content=body, headers={"X-Huihui-Timestamp": str(int(time.time())), "X-Huihui-Signature": "bad"}).status_code == 401
wrong = json.dumps({**payload, "amountCents": 999}, separators=(",", ":")).encode()
assert client.post(path, content=wrong, headers=_huihui_virtual_headers(secret, wrong)).status_code == 422
first = client.post(path, content=body, headers=_huihui_virtual_headers(secret, body))
second = client.post(path, content=body, headers=_huihui_virtual_headers(secret, body))
assert first.json()["data"] == {"received": True, "paid": True}
assert second.json()["data"] == {"received": True, "duplicate": True}
db = SessionLocal()
try:
order = db.query(TokenPaymentOrder).filter(TokenPaymentOrder.order_no == created["orderNo"]).one()
account = db.query(TokenAccount).filter(TokenAccount.user_id == context["owner"].id).one()
assert order.status == "paid"
assert account.balance == DEFAULT_TOKEN_GRANT + 2_000_000
finally:
db.close()
def test_huihui_org_callback_verifies_provider_before_granting_points(authorization_context):
seed()
context = authorization_context
provider_no = "26102010414409000001"
payment_no = f"WV-{context['suffix']}"
order_no = f"AV-{context['suffix']}"
db = SessionLocal()
try:
user = db.query(User).filter(User.id == context["owner"].id).one()
user.huihui_token = "test-huihui-token"
db.add(TokenPaymentOrder(
order_no=order_no, provider_order_no=provider_no,
user_id=user.id, plan_id="Basic", payment_method="wechat",
pay_type="WECHAT", pay_way="LITE", provider="huihui_org",
points_amount=2_000_000, price_cents=100, status="pending",
))
db.commit()
finally:
db.close()
secret = "abcdefghijklmnop"
path = "/api/token/payment/huihui/org/notify"
payload = {
"orderNo": provider_no, "paymentNo": payment_no,
"status": "SUCCESS", "amountCents": 100,
"productId": "Basic",
}
provider_data = {
"orderNo": provider_no, "paymentNo": payment_no,
"payStatus": 1, "chargeType": 9, "chargeAmount": 1,
"productId": "Basic", "userId": context["owner"].huihui_user_id,
}
def send(body_payload, *, signature=True, callback_path=path):
body = json.dumps(body_payload, separators=(",", ":")).encode()
headers = _huihui_virtual_headers(secret, body) if signature else {"X-Huihui-Signature": "bad"}
return client.post(callback_path, content=body, headers=headers)
with patch.dict(os.environ, {
"HUIHUI_ORG_CALLBACK_SECRET": secret,
"HUIHUI_VIRTUAL_CALLBACK_SECRET": "",
}), patch(
"routers.tokens.HuihuiOrgPaymentClient"
) as client_factory:
provider = client_factory.return_value
provider.get_order.return_value = provider_data
assert send(payload, signature=False).status_code == 401
legacy_path = "/api/token/payment/huihui/virtual/notify"
assert send(payload, signature=False, callback_path=legacy_path).status_code == 401
assert send({**payload, "orderNo": "unknown"}, callback_path=legacy_path).status_code == 404
assert send({**payload, "amountCents": 99}).status_code == 422
pending = send(payload, callback_path=legacy_path)
assert pending.status_code == 200
assert pending.json()["data"] == {"received": True, "paid": False, "awaitingProvider": True}
provider.get_order.assert_not_called()
db = SessionLocal()
try:
assert db.get(HuihuiOrgPaymentNotice, order_no).status == "awaiting_provider"
assert db.query(TokenPaymentOrder).filter(TokenPaymentOrder.order_no == order_no).one().status == "pending"
finally:
db.close()
assert send({**payload, "paymentNo": "wrong"}).status_code == 409
db = SessionLocal()
try:
notice = db.get(HuihuiOrgPaymentNotice, order_no)
notice.next_check_at = datetime.utcnow() - timedelta(seconds=1)
db.commit()
from routers.tokens import reconcile_huihui_org_notices
provider.get_order.return_value = {**provider_data, "payStatus": 2, "chargeAmount": 2}
assert reconcile_huihui_org_notices(db) == 0
assert db.get(HuihuiOrgPaymentNotice, order_no).status == "rejected"
assert db.query(TokenPaymentOrder).filter(TokenPaymentOrder.order_no == order_no).one().status == "pending"
assert send(payload).status_code == 200
notice.next_check_at = datetime.utcnow() - timedelta(seconds=1)
db.commit()
provider.get_order.return_value = provider_data
assert reconcile_huihui_org_notices(db) == 0
assert db.get(HuihuiOrgPaymentNotice, order_no).attempts == 1
assert db.query(TokenPaymentOrder).filter(TokenPaymentOrder.order_no == order_no).one().status == "pending"
notice.next_check_at = datetime.utcnow() - timedelta(seconds=1)
db.commit()
provider.get_order.return_value = {**provider_data, "payStatus": 2}
assert reconcile_huihui_org_notices(db) == 1
assert db.get(HuihuiOrgPaymentNotice, order_no).status == "confirmed"
finally:
db.close()
first = send(payload, callback_path=legacy_path)
second = send(payload)
assert first.status_code == 200
assert first.json()["data"] == {"received": True, "duplicate": True}
assert second.json()["data"] == {"received": True, "duplicate": True}
db = SessionLocal()
try:
order = db.query(TokenPaymentOrder).filter(TokenPaymentOrder.order_no == order_no).one()
account = db.query(TokenAccount).filter(TokenAccount.user_id == context["owner"].id).one()
assert order.status == "paid"
assert account.balance == DEFAULT_TOKEN_GRANT + 2_000_000
assert provider.get_order.call_count >= 3
finally:
db.close()
def _signature(token, timestamp, nonce):
return hashlib.sha1("".join(sorted([token, timestamp, nonce])).encode()).hexdigest()
@@ -26,8 +194,7 @@ def test_virtual_payment_callback_and_refund_are_idempotent(authorization_contex
user = db.query(User).filter(User.id == context["owner"].id).one()
user.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"
plan = db.query(TokenPlan).filter(TokenPlan.id == "Basic").one()
db.commit()
finally:
db.close()
@@ -43,7 +210,7 @@ def test_virtual_payment_callback_and_refund_are_idempotent(authorization_contex
created = client.post(
"/api/token/charge",
headers=context["owner_headers"],
json={"planId": "1", "paymentMethod": "wechat", "payScene": "LITE"},
json={"planId": "Basic", "paymentMethod": "wechat", "payScene": "LITE"},
).json()["data"]
assert created["provider"] == "wechat_virtual"
params = json.loads(created["payMessage"])
@@ -55,7 +222,7 @@ def test_virtual_payment_callback_and_refund_are_idempotent(authorization_contex
"OutTradeNo": created["orderNo"],
"OpenId": "openid-flow",
"Env": 1,
"GoodsInfo": json.dumps({"ProductId": "points_plan_1", "ActualPrice": 1000}),
"GoodsInfo": json.dumps({"ProductId": "Basic", "ActualPrice": 1000}),
"WeChatPayInfo": json.dumps({"TransactionId": "wx-transaction-1"}),
}
query = {"timestamp": "100", "nonce": "nonce", "signature": _signature("callback-token", "100", "nonce")}
@@ -9,8 +9,8 @@ from services import wechat_virtual_payment as virtual
def test_build_payment_params_signs_the_exact_compact_payload():
order = SimpleNamespace(order_no="AV202609080001", plan_id="plan-1", price_cents=1000)
plan = SimpleNamespace(id="plan-1", virtual_product_id="points_plan_1")
order = SimpleNamespace(order_no="AV202609080001", plan_id="Basic", price_cents=1000)
plan = SimpleNamespace(id="Basic", virtual_product_id="stale-product-id")
env = {
"WECHAT_VIRTUAL_ENV": "sandbox",
"WECHAT_VIRTUAL_SANDBOX_APP_KEY": "sandbox-key",
@@ -25,10 +25,10 @@ def test_build_payment_params_signs_the_exact_compact_payload():
"buyQuantity": 1,
"env": 1,
"currencyType": "CNY",
"productId": "points_plan_1",
"productId": "Basic",
"goodsPrice": 1000,
"outTradeNo": "AV202609080001",
"attach": '{"orderNo":"AV202609080001","planId":"plan-1"}',
"attach": '{"orderNo":"AV202609080001","planId":"Basic"}',
}, ensure_ascii=False, separators=(",", ":"))
assert result["paySig"] == hmac.new(
b"sandbox-key", f"requestVirtualPayment&{sign_data}".encode(), hashlib.sha256
+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. 接入方式
生产会会在用户已登录后打开以下地址:
@@ -56,12 +58,13 @@ 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>
HUIHUI_ORG_CALLBACK_SECRET=<会会组织充值支付结果通知验签密钥>
HUIHUI_VIRTUAL_CALLBACK_SECRET=<微信直连虚拟支付转发通知验签密钥>
# 商品 ID 直接使用套餐 ID:Basic、Standard、Professional、Enterprise
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,19 +77,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`。
App 与 H5 积分充值使用会会支付体系的 `payment-v3/payment/pay`,渠道值为 `WECHAT` / `ALIPAY`;App 场景为 `APP`,普通浏览器为 `H5`,微信内 H5 为 `JSAPI`。`HUIHUI_PAYMENT_CALLBACK_SECRET` 只用于为每笔订单生成 HMAC 回调签名,不会发送到前端或直接出现在回调地址中。支付回调确认状态成功且金额与套餐价格完全一致后才增加积分,重复回调不会重复到账。
当前 H5 积分充值调用 `/api/token/charge/org` 创建会会组织充值订单。数字分身后端请求会会 `/order/tokenRecharge` 时,把所选套餐 ID 作为字符串 `productId` 传入(`Basic`、`Standard`、`Professional`、`Enterprise`),再用会会返回的订单号打开 `#/payOrder?id=<会会订单号>&isOrgUrl=true&evn=<入口环境>`;测试环境额外透传会会登录 token,生产环境不传。`id` 是收银台查询订单的编号,不能替换为套餐/道具 ID。普通微信 H5 走 JSAPI,App 和其他 H5 沿用会会普通收银台。支付回调确认状态成功且金额与套餐价格完全一致后才增加积分,重复回调不会重复到账。
微信小程序使用微信虚拟支付:小程序先通过 `POST /api/token/wechat/session` 交换临时登录码,再由 `POST /api/token/charge`(`payScene=LITE`)返回已签名的 `requestVirtualPayment` 参数。微信回调地址配置为 `https://digital.99hui.com/api/token/payment/wechat/virtual/notify`。回调会复核签名、OpenID、环境、商品 ID 与实付金额,退款回调确认后才扣回积分。AppKey、AppSecret、session_key 均不得下发前端或写日志。
微信小程序虚拟支付的服务端接口为:小程序先通过 `POST /api/token/wechat/session` 交换临时登录码,再由 `POST /api/token/charge`(`payScene=LITE`)返回已签名的 `requestVirtualPayment` 参数;其中 `productId` 直接采用套餐 ID(`Basic`、`Standard`、`Professional`、`Enterprise`)。小程序原生支付页仍需调用 `wx.requestVirtualPayment`,不能仅打开上述 H5 收银台 URL 来完成虚拟支付。微信回调地址配置为 `https://digital.99hui.com/api/token/payment/wechat/virtual/notify`。回调会复核签名、OpenID、环境、商品 ID 与实付金额,退款回调确认后才扣回积分。AppKey、AppSecret、session_key 均不得下发前端或写日志。
会会组织充值订单和由会会服务端转发的微信小程序虚拟支付结果,分别使用独立的回调地址及验签密钥,见 [HUIHUI_VIRTUAL_CALLBACK.md](HUIHUI_VIRTUAL_CALLBACK.md)。对于组织订单,回调验签后还必须向会会查单确认实付状态与订单详情。即使接口代码部署到生产,在会会发送格式确认和联调验收前也不得将其视作已上线的支付渠道。
管理后台需要配置相同的 `AVATAR_FINANCE_ADMIN_SECRET` 和 `AVATAR_BACKEND_URL=https://digital.99hui.com`。退款只支持整单原路退款;供应商受理后显示“处理中”,收到渠道成功回调(或经渠道后台核对后人工确认)才将订单置为已退款。已消费掉本订单积分时,后台会拒绝主动退款;若渠道外部退款先发生,积分账户允许形成负数以记录欠额并阻止继续消费。
@@ -97,7 +106,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 +116,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 +171,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,33 @@
# 会会支付回调(待双方确认)
本项目为会会服务端预留两条固定回调地址:
- 会会组织充值订单(当前 H5 收银台):`POST https://fat-digital.99hui.com/api/token/payment/huihui/org/notify`(测试环境)
- 微信直连虚拟支付订单:`POST https://digital.99hui.com/api/token/payment/huihui/virtual/notify`(生产环境示例)
测试环境兼容提示:如果会会仍将上述组织充值订单发往 `/api/token/payment/huihui/virtual/notify`,而该环境未配置独立的虚拟支付密钥,旧地址会使用组织充值密钥验签并走相同的组织订单复核流程。该兼容入口不会处理微信直连虚拟支付订单;新接入仍应使用 `/org/notify`,生产环境不依赖此兼容行为。
回调报文及签名协议尚未与会会确认。以下是本项目实现的拟议对接契约,会会服务端必须按此格式发送才能完成联调。接口代码部署与会会联调通过是两个独立状态。它不替代现有微信直连回调 `/api/token/payment/wechat/virtual/notify`。
请求体为 JSON,成功支付示例:
```json
{
"orderNo": "26102010414409234218",
"paymentNo": "会会侧唯一支付流水号",
"status": "SUCCESS",
"amountCents": 1000,
"productId": "Basic"
}
```
`amountCents` 单位为分。对于经 `/order/tokenRecharge` 创建的会会组织订单,`orderNo` 可以是会会订单号或数字分身 `AV` 订单号;成功通知必须提供 `amountCents`、`productId` 和 `paymentNo`,无需 `openId`、`env`。服务端会再次向会会查单,要求订单号、支付流水、商品 ID、用户、收费类型、金额一致,且会会支付状态为已付款,才发放积分。对于微信直连订单,仍需 `openId` 和 `env`(0 正式、1 沙箱)一致。非成功结果可发送 `status: "FAILED"`,但不会入账。重复成功通知不会重复发放积分;同一支付流水号不能用于其他订单。
请求头:
- `X-Huihui-Timestamp`:Unix 秒级时间戳,允许与本服务时间相差不超过 300 秒。
- `X-Huihui-Signature`:小写十六进制 HMAC-SHA256,待签名数据为 `时间戳 + 换行符 + 原始请求体字节`。组织充值订单使用服务端配置的 `HUIHUI_ORG_CALLBACK_SECRET`(至少 16 字符),微信直连虚拟支付订单使用独立的 `HUIHUI_VIRTUAL_CALLBACK_SECRET`(至少 32 字符)。不要重排 JSON 后签名,也不要把密钥发给前端。
成功通知经签名及本地订单信息核对后,先持久保存并立即返回 HTTP 200、`{"received":true,"paid":false,"awaitingProvider":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 {} }