Compare commits

...
Author SHA1 Message Date
stefanfeng 6e3fe5a616 Merge pull request 'feat(avatar): 接入会会支付并统一积分展示' (#7) from codex/avatar-token-copy-to-points-20260826 into main
Reviewed-on: #7
2026-08-26 14:53:58 +08:00
stefanfeng 67b6bd1b48 Merge pull request 'fix(avatar): 部署重启后自动恢复 BOXIM 接管' (#6) from codex/avatar-takeover-restart-safe-20260826 into main
Reviewed-on: #6
2026-08-26 14:53:49 +08:00
stefanfeng 0752001d85 Merge pull request 'feat(avatar): 数字分身自动跟随用户语言回答' (#5) from codex/avatar-auto-reply-language-20260826 into main
Reviewed-on: #5
2026-08-26 14:53:40 +08:00
stefanfeng c37294be17 feat(avatar): integrate Huihui payments 2026-08-26 14:41:21 +08:00
stefanfeng 5360cac8ad fix(avatar): normalize legacy plan copy 2026-08-26 14:02:38 +08:00
stefanfeng ea932f27fb feat(avatar): rename Token display to points 2026-08-26 14:01:12 +08:00
deploy bf0183bef4 add wechat mini-program domain verification file 2026-08-26 13:44:56 +08:00
stefanfeng 0c6419f37e fix(avatar): restore BOXIM takeover after restart 2026-08-26 13:25:16 +08:00
stefanfeng f768e7648f fix(avatar): prevent inferred reply scenarios 2026-08-26 11:56:54 +08:00
stefanfeng e30ab2b889 feat(avatar): follow user language in replies 2026-08-26 11:52:32 +08:00
stefanfeng 730f586784 Merge pull request 'docs(avatar): 配置 digital.99hui.com 生产域名' (#4) from codex/avatar-multi-management-integrated-20260825 into main
Reviewed-on: #4
2026-08-26 10:17:40 +08:00
stefanfeng d274ccb5e2 Merge pull request 'feat: 完成数字分身多分身管理与生产 H5 接入' (#3) from codex/avatar-multi-management-integrated-20260825 into main
Reviewed-on: #3
2026-08-26 09:43:52 +08:00
stefanfeng 9e86cc64ac Merge pull request 'Codex/avatar integrated 20260819' (#2) from codex/avatar-integrated-20260819 into main
Reviewed-on: #2
2026-08-21 09:31:43 +08:00
22 changed files with 1196 additions and 81 deletions
+4 -4
View File
@@ -58,10 +58,10 @@ def seed():
db = SessionLocal()
try:
plan_specs = [
{"id": "1", "name": "基础套餐", "amount": 2_000_000, "price": 10, "badge": "", "desc": "2M Token"},
{"id": "2", "name": "标准套餐", "amount": 20_000_000, "price": 100, "badge": "常用", "desc": "20M Token"},
{"id": "3", "name": "专业套餐", "amount": 250_000_000, "price": 1000, "badge": "加赠25%", "desc": "250M Token"},
{"id": "4", "name": "企业套餐", "amount": 2_500_000_000, "price": 10000, "badge": "企业推荐", "desc": "2500M Token"},
{"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 积分"},
]
for spec in plan_specs:
plan = db.query(TokenPlan).filter(TokenPlan.id == spec["id"]).first()
+41
View File
@@ -311,6 +311,47 @@ class TokenPlan(Base):
}
class TokenPaymentOrder(Base):
__tablename__ = "token_payment_orders"
id = Column(String, primary_key=True, default=lambda: uuid.uuid4().hex)
order_no = Column(String, nullable=False, unique=True, index=True)
user_id = Column(String, nullable=False, index=True)
plan_id = Column(String, nullable=False)
payment_method = Column(String, nullable=False)
pay_type = Column(String, nullable=False)
pay_way = Column(String, nullable=False)
points_amount = Column(BigInteger, nullable=False)
price_cents = Column(Integer, nullable=False)
status = Column(String, nullable=False, default="pending", index=True)
provider_order_id = Column(String, default="")
provider_order_no = Column(String, default="")
provider_status = Column(String, default="")
pay_message = Column(Text, default="")
failure_reason = Column(String, default="")
created_at = Column(DateTime, server_default=func.now())
updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now())
paid_at = Column(DateTime)
def to_dict(self):
return {
"id": self.id,
"orderNo": self.order_no,
"planId": self.plan_id,
"paymentMethod": self.payment_method,
"payType": self.pay_type,
"payWay": self.pay_way,
"pointsAmount": self.points_amount,
"price": self.price_cents / 100,
"status": self.status,
"providerStatus": self.provider_status,
"payMessage": self.pay_message,
"failureReason": self.failure_reason,
"createdAt": _iso(self.created_at),
"paidAt": _iso(self.paid_at),
}
class User(Base):
"""会会用户 ↔ 本地用户体系映射(短信验证码登录落库)"""
+108 -17
View File
@@ -34,6 +34,19 @@ QA_SEMANTIC_THRESHOLD = 0.72
QA_MATCH_MARGIN = 0.06
KNOWLEDGE_MIN_SCORE = float(os.getenv("KNOWLEDGE_MIN_SCORE", "0.42"))
_WRITING_SYSTEM_PATTERNS = {
"han": re.compile(r"[\u3400-\u4dbf\u4e00-\u9fff]"),
"latin": re.compile(r"[A-Za-z\u00c0-\u024f]"),
"cyrillic": re.compile(r"[\u0400-\u052f]"),
"arabic": re.compile(r"[\u0600-\u06ff]"),
"hebrew": re.compile(r"[\u0590-\u05ff]"),
"devanagari": re.compile(r"[\u0900-\u097f]"),
"thai": re.compile(r"[\u0e00-\u0e7f]"),
"greek": re.compile(r"[\u0370-\u03ff]"),
}
_JAPANESE_KANA = re.compile(r"[\u3040-\u30ff]")
_KOREAN_HANGUL = re.compile(r"[\uac00-\ud7af\u1100-\u11ff]")
class ChatMessage(BaseModel):
role: str = Field(pattern="^(user|assistant)$")
@@ -70,6 +83,30 @@ def _normalize_question(value: str) -> str:
return value.translate(str.maketrans("", "", string.punctuation + ",。!?;:、()【】「」‘’“”《》"))
def _dominant_writing_system(value: str) -> str:
value = value or ""
if _JAPANESE_KANA.search(value):
return "japanese"
if _KOREAN_HANGUL.search(value):
return "korean"
counts = {
name: len(pattern.findall(value))
for name, pattern in _WRITING_SYSTEM_PATTERNS.items()
}
name, count = max(counts.items(), key=lambda item: item[1])
return name if count else "unknown"
def _qa_requires_language_adaptation(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 _canonicalize_question(value: str) -> str:
value = _normalize_question(value)
replacements = (
@@ -189,7 +226,14 @@ def _config(avatar: Avatar) -> dict:
}
def _build_prompt(avatar: Avatar, history: list[Any], question: str, knowledge_hits: list[dict]) -> list[dict]:
def _build_prompt(
avatar: Avatar,
history: list[Any],
question: str,
knowledge_hits: list[dict],
*,
standard_answer: str = "",
) -> list[dict]:
config = _config(avatar)
description = (getattr(avatar, "description", "") or "").strip()
knowledge = "\n".join(
@@ -210,7 +254,7 @@ def _build_prompt(avatar: Avatar, history: list[Any], question: str, knowledge_h
profile = ";".join(f"{label}:{value}" for label, value in profile_items)
system = (
f"你的专业或服务范围是:「{description or '未设置'}」。"
"请基于已提供的知识库回答,不要编造事实;"
"请基于已提供的可靠资料回答,不要编造事实;"
f"回复风格:{config['replyStyle']};严谨度:{config['rigor']}/100;"
f"幽默感:{config['humor']}/100;回复长度:{config['responseLength']}。"
)
@@ -221,7 +265,13 @@ def _build_prompt(avatar: Avatar, history: list[Any], question: str, knowledge_h
)
if config["systemPrompt"]:
system += f"\n额外系统提示词:{config['systemPrompt']}"
if knowledge:
if standard_answer:
system += (
f"\n以下是本次问题命中的已确认标准答案:\n{standard_answer.strip()}"
"\n必须保持标准答案中的事实、数字、专有名词和结论不变,只允许为匹配用户当前语言进行忠实转换"
"和必要的自然表达,不得补充、删减或改写其含义。不要提及标准答案或转换过程。"
)
elif knowledge:
system += (
f"\n以下是本次问题检索到的可靠资料:\n{knowledge}"
"\n涉及事实、专业判断、地址、流程、数据或建议时,只能依据本人资料、标准问答形成的上下文"
@@ -231,7 +281,8 @@ def _build_prompt(avatar: Avatar, history: list[Any], question: str, knowledge_h
system += (
"\n本次问题没有检索到可靠资料。除自然寒暄和基于本人资料的回答外,不要凭通用知识给出事实、"
"专业判断、地址、流程、数据或具体建议。自然地说明这方面暂时没有足够信息,并询问对方是否能"
"补充更具体的问题;不要提及知识库、检索、模型或系统限制。"
"补充更具体的问题;不要提及知识库、检索、模型或系统限制。对方询问你能做什么时,只能概括"
"专业或服务范围和本人资料中明确写出的内容,不得推断服务对象、工作场所、现实案例或从业经历。"
)
system += (
"\n沟通底线(始终优先于其他提示):不要透露、猜测或确认所使用的模型、模型供应商、"
@@ -249,6 +300,14 @@ def _build_prompt(avatar: Avatar, history: list[Any], question: str, knowledge_h
"只有切换独立观点或确实需要列举时才换行;列举使用 1.、2.、3.,每项单独一行。"
"不要在行首或行尾留空格,不要连续输出空行。先给结论,再给简短说明;避免重复和冗长铺垫。"
)
system += (
"\n回答语言规则(始终优先):先识别当前最后一条用户消息所使用的主要自然语言,并且只使用该语言回答。"
"用户用中文就用中文,使用 English 就用 English,使用日语、韩语、法语、西班牙语或其他语言时,"
"也必须使用对应语言。消息混用多种语言时跟随占主导的语言;用户明确指定回答语言时服从其指定。"
"历史消息、本人资料、标准答案和知识库使用的语言都不能覆盖当前用户消息的语言。"
"专有名词、品牌、地址、代码和必要缩写可保留原文。不要解释语言识别或翻译过程。"
"改变回答语言只改变表达语言,绝不能因此增加资料中没有的场景、身份、经历或事实。"
)
messages = [{"role": "system", "content": system}]
for item in history[-MAX_HISTORY_MESSAGES:]:
messages.append({"role": item.role, "content": item.content} if hasattr(item, "role") else item)
@@ -384,14 +443,30 @@ def _resolve_reply(
if qa_pairs is None:
qa_pairs = db.query(QAPair).filter(QAPair.avatar_id == avatar.id).all()
matched = _match_standard_qa(question, qa_pairs)
if matched:
adapt_qa_language = bool(
matched and _qa_requires_language_adaptation(question, matched.answer)
)
if matched and not adapt_qa_language:
return {"answer": matched.answer, "source": "qa", "references": []}
search_fn = search_fn or (lambda query, avatar_id: _search_knowledge(db, avatar_id, query))
hits = search_fn(question, avatar.id)
messages = _build_prompt(avatar, history, question, hits)
if matched:
hits = []
messages = _build_prompt(
avatar,
history,
question,
hits,
standard_answer=matched.answer,
)
else:
search_fn = search_fn or (lambda query, avatar_id: _search_knowledge(db, avatar_id, query))
hits = search_fn(question, avatar.id)
messages = _build_prompt(avatar, history, question, hits)
config = _config(avatar)
temperature = min(0.45 if hits else 0.25, 0.2 + config["creativity"] / 100 * 0.6)
temperature = 0.0 if matched else min(
0.45 if hits else 0.25,
0.2 + config["creativity"] / 100 * 0.6,
)
token_usage = None
if model_client is not None:
answer = model_client(messages=messages, temperature=temperature)
@@ -423,7 +498,7 @@ def _resolve_reply(
raise
result = {
"answer": answer,
"source": "knowledge" if hits else "qwen",
"source": "qa" if matched else ("knowledge" if hits else "qwen"),
"references": hits,
}
if token_usage:
@@ -442,14 +517,32 @@ def _stream_reply(
):
qa_pairs = db.query(QAPair).filter(QAPair.avatar_id == avatar.id).all()
matched = _match_standard_qa(question, qa_pairs)
if matched:
adapt_qa_language = bool(
matched and _qa_requires_language_adaptation(question, matched.answer)
)
messages, reservation = [], None
if matched and not adapt_qa_language:
source, references, chunks = "qa", [], _iter_text_chunks(matched.answer)
else:
references = _search_knowledge(db, avatar.id, question)
source = "knowledge" if references else "qwen"
if matched:
references = []
source = "qa"
messages = _build_prompt(
avatar,
history,
question,
references,
standard_answer=matched.answer,
)
else:
references = _search_knowledge(db, avatar.id, question)
source = "knowledge" if references else "qwen"
messages = _build_prompt(avatar, history, question, references)
config = _config(avatar)
temperature = min(0.45 if references else 0.25, 0.2 + config["creativity"] / 100 * 0.6)
messages = _build_prompt(avatar, history, question, references)
temperature = 0.0 if matched else min(
0.45 if references else 0.25,
0.2 + config["creativity"] / 100 * 0.6,
)
model_config = get_chat_model_config()
reservation = reserve_avatar_tokens(
db,
@@ -460,8 +553,6 @@ def _stream_reply(
model_config.max_tokens,
)
chunks = _iter_qwen_stream(messages, temperature, model_config)
if matched:
messages, reservation = [], None
if public:
source, references = "public", []
+275 -10
View File
@@ -1,14 +1,29 @@
from fastapi import APIRouter, Depends, Body, Header, HTTPException
import hashlib
import hmac
import json
import os
import uuid
from datetime import datetime
from decimal import Decimal, InvalidOperation, ROUND_HALF_UP
from urllib.parse import parse_qs
from fastapi import APIRouter, Body, Depends, Header, HTTPException, Request
from sqlalchemy import func
from sqlalchemy.orm import Session
from database import get_db
from models import TokenAccount, TokenPlan, TokenUsage, User
from responses import ok, fail
from services.token_billing import get_or_create_account
from models import TokenAccount, TokenPaymentOrder, TokenPlan, TokenUsage, User
from responses import fail, ok
from services.huihui_payment import HuihuiPaymentClient, HuihuiPaymentError
from services.token_billing import DEFAULT_TOKEN_GRANT, get_or_create_account
router = APIRouter(tags=["Token"])
PAYMENT_METHODS = {"wechat": "WECHAT", "alipay": "ALIPAY"}
PAYMENT_SCENES = {"APP", "LITE", "JSAPI"}
SUCCESS_STATUSES = {"SUCCESS", "SUCCEEDED", "PAID", "COMPLETED", "TRADE_SUCCESS"}
FAILED_STATUSES = {"FAIL", "FAILED", "CLOSED", "CANCELLED", "CANCELED", "EXPIRED"}
def _require_user(authorization: str | None, db: Session) -> User:
if not authorization:
@@ -20,6 +35,97 @@ def _require_user(authorization: str | None, db: Session) -> User:
return user
def _payment_client() -> HuihuiPaymentClient:
return HuihuiPaymentClient({
"HUIHUI_PAYMENT_BASE_URL": os.getenv(
"HUIHUI_PAYMENT_BASE_URL", "https://open.99hui.com/api/payment-v3"
),
"HUIHUI_APP_ID": os.getenv("HUIHUI_APP_ID", ""),
"HUIHUI_ACCESS_ID": os.getenv("HUIHUI_ACCESS_ID", ""),
"HUIHUI_ACCESS_SECRET": os.getenv("HUIHUI_ACCESS_SECRET", ""),
"HUIHUI_PAYMENT_TIMEOUT_SECONDS": os.getenv("HUIHUI_PAYMENT_TIMEOUT_SECONDS", "30"),
})
def _callback_url(order_no: str) -> str:
base = os.getenv(
"HUIHUI_PAYMENT_CALLBACK_BASE_URL", "https://digital.99hui.com"
).rstrip("/")
secret = os.getenv("HUIHUI_PAYMENT_CALLBACK_SECRET", "").strip()
if len(secret) < 16:
raise HuihuiPaymentError("会会支付回调密钥未配置")
signature = hmac.new(secret.encode(), order_no.encode(), hashlib.sha256).hexdigest()
return f"{base}/api/token/payment/callback/{order_no}/{signature}"
def _price_cents(price: float) -> int:
return int(
(Decimal(str(price)) * Decimal("100")).quantize(
Decimal("1"), rounding=ROUND_HALF_UP
)
)
def _payment_payload(order: TokenPaymentOrder, account: TokenAccount) -> dict:
return {**order.to_dict(), "balance": account.balance}
def _nested_payload(value):
if isinstance(value, str):
text = value.strip()
if text[:1] in ("{", "["):
try:
return _nested_payload(json.loads(text))
except (TypeError, ValueError):
return value
return value
if isinstance(value, list):
return [_nested_payload(item) for item in value]
if isinstance(value, dict):
return {key: _nested_payload(item) for key, item in value.items()}
return value
def _find_value(payload, *names):
expected = {name.lower() for name in names}
if isinstance(payload, dict):
for key, value in payload.items():
if key.lower() in expected and value not in (None, ""):
return value
for value in payload.values():
found = _find_value(value, *names)
if found not in (None, ""):
return found
elif isinstance(payload, list):
for value in payload:
found = _find_value(value, *names)
if found not in (None, ""):
return found
return None
def _callback_amount_cents(payload) -> int | None:
value = _find_value(
payload,
"actualAmt",
"payAmt",
"masterOrderAmt",
"orderAmt",
"amount",
"totalAmount",
)
if value in (None, ""):
return None
try:
return int(
(Decimal(str(value)) * Decimal("100")).quantize(
Decimal("1"), rounding=ROUND_HALF_UP
)
)
except (InvalidOperation, TypeError, ValueError):
return None
@router.get("/token/balance")
def balance(authorization: str = Header(None), db: Session = Depends(get_db)):
user = _require_user(authorization, db)
@@ -38,18 +144,177 @@ def plans(authorization: str = Header(None), db: Session = Depends(get_db)):
return ok([p.to_dict() for p in items])
# 积分只会在会会支付回调确认成功后到账。
@router.post("/token/charge")
def charge(payload: dict = Body(...), authorization: str = Header(None), db: Session = Depends(get_db)):
user = _require_user(authorization, db)
plan_id = payload.get("planId")
plan = db.query(TokenPlan).filter(TokenPlan.id == plan_id).first()
plan = db.query(TokenPlan).filter(TokenPlan.id == payload.get("planId")).first()
if not plan:
return fail("套餐不存在", 404)
acc = get_or_create_account(db, user.id)
acc.balance += plan.amount
acc.total_granted = int(acc.total_granted or 0) + plan.amount
payment_method = str(payload.get("paymentMethod") or "").lower()
pay_type = PAYMENT_METHODS.get(payment_method)
if not pay_type:
return fail("请选择正确的支付方式", 400)
pay_way = str(payload.get("payScene") or "APP").upper()
if pay_way not in PAYMENT_SCENES:
return fail("当前支付场景不受支持", 400)
cents = _price_cents(plan.price)
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=payment_method,
pay_type=pay_type,
pay_way=pay_way,
points_amount=plan.amount,
price_cents=cents,
status="pending",
)
db.add(order)
db.commit()
return ok({"balance": acc.balance, "charged": plan.amount})
try:
callback_url = _callback_url(order.order_no)
except HuihuiPaymentError as exc:
order.status = "failed"
order.failure_reason = str(exc)
db.commit()
return fail(str(exc), 503)
try:
result = _payment_client().create_payment(
huihui_token=user.huihui_token,
huihui_user_id=user.huihui_user_id,
real_name=user.nickname,
order_no=order.order_no,
amount=f"{cents / 100:.2f}",
points_amount=plan.amount,
pay_type=pay_type,
pay_way=pay_way,
callback_url=callback_url,
)
except HuihuiPaymentError as exc:
order.status = "failed"
order.failure_reason = str(exc)[:500]
db.commit()
return fail(str(exc), 502)
db.refresh(order)
if order.status != "paid":
order.provider_order_id = str(result.get("orderId") or "")
order.provider_order_no = str(result.get("orderNo") or "")
order.provider_status = str(result.get("status") or "pending")
message = result.get("payMessage") or ""
order.pay_message = (
json.dumps(message, ensure_ascii=False)
if isinstance(message, (dict, list))
else str(message)
)
if order.provider_status.upper() in FAILED_STATUSES:
order.status = "failed"
order.failure_reason = str(result.get("bankReturnMsg") or "支付下单失败")[:500]
db.commit()
return ok(_payment_payload(order, get_or_create_account(db, user.id)))
@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)
order = db.query(TokenPaymentOrder).filter(
TokenPaymentOrder.id == order_id,
TokenPaymentOrder.user_id == user.id,
).first()
if not order:
return fail("支付订单不存在", 404)
return ok(_payment_payload(order, get_or_create_account(db, user.id)))
@router.post("/token/payment/callback/{order_no}/{callback_signature}")
async def payment_callback(
order_no: str,
callback_signature: str,
request: Request,
db: Session = Depends(get_db),
):
secret = os.getenv("HUIHUI_PAYMENT_CALLBACK_SECRET", "").strip()
expected = hmac.new(secret.encode(), order_no.encode(), hashlib.sha256).hexdigest()
if len(secret) < 16 or not hmac.compare_digest(callback_signature, expected):
raise HTTPException(status_code=404, detail="Not found")
content_type = request.headers.get("content-type", "").lower()
if "application/json" in content_type:
try:
payload = await request.json()
except ValueError:
return fail("支付回调格式不正确", 400)
else:
raw = (await request.body()).decode("utf-8", errors="replace")
payload = {key: values[-1] for key, values in parse_qs(raw).items()}
payload = _nested_payload(payload)
payload_order_no = str(_find_value(
payload,
"masterOrderNo",
"master_order_no",
"orderNo",
"order_no",
"bizOrderNo",
) or "").strip()
if payload_order_no and payload_order_no != order_no:
return fail("支付回调订单号不匹配", 422)
order = db.query(TokenPaymentOrder).filter(TokenPaymentOrder.order_no == order_no).first()
if not order:
return fail("支付订单不存在", 404)
if order.status == "paid":
return ok({"received": True, "duplicate": True})
provider_status = str(_find_value(
payload, "status", "payStatus", "tradeStatus", "paymentStatus"
) or "").upper()
order.provider_status = provider_status
if provider_status not in SUCCESS_STATUSES:
if provider_status in FAILED_STATUSES:
order.status = "failed"
order.failure_reason = str(
_find_value(payload, "message", "errorMsg", "failReason") or "支付失败"
)[:500]
db.commit()
return ok({"received": True, "paid": False})
paid_cents = _callback_amount_cents(payload)
if paid_cents is None or paid_cents != order.price_cents:
order.failure_reason = "支付回调金额不匹配"
db.commit()
return fail("支付金额不匹配", 422)
updated = db.query(TokenPaymentOrder).filter(
TokenPaymentOrder.id == order.id,
TokenPaymentOrder.status != "paid",
).update({
TokenPaymentOrder.status: "paid",
TokenPaymentOrder.provider_status: provider_status,
TokenPaymentOrder.paid_at: datetime.utcnow(),
TokenPaymentOrder.failure_reason: "",
}, synchronize_session=False)
if updated:
account = db.query(TokenAccount).filter(TokenAccount.user_id == order.user_id).first()
if account is None:
account = TokenAccount(
user_id=order.user_id,
balance=DEFAULT_TOKEN_GRANT,
total_granted=DEFAULT_TOKEN_GRANT,
total_consumed=0,
)
db.add(account)
db.flush()
account.balance = int(account.balance or 0) + order.points_amount
account.total_granted = int(account.total_granted or 0) + order.points_amount
db.commit()
return ok({"received": True, "paid": True})
@router.get("/token/usage")
@@ -0,0 +1,124 @@
"""Signed client for Huihui's production payment-v3 service."""
import hashlib
import random
import string
from datetime import datetime, timedelta, timezone
from typing import Any
import httpx
_CN_TZ = timezone(timedelta(hours=8))
class HuihuiPaymentError(RuntimeError):
pass
class HuihuiPaymentClient:
def __init__(self, config: dict):
self.base_url = config.get(
"HUIHUI_PAYMENT_BASE_URL", "https://open.99hui.com/api/payment-v3"
).rstrip("/")
self.app_id = config.get("HUIHUI_APP_ID", "")
self.access_id = config.get("HUIHUI_ACCESS_ID", "")
self.access_secret = config.get("HUIHUI_ACCESS_SECRET", "")
self.timeout = float(config.get("HUIHUI_PAYMENT_TIMEOUT_SECONDS", 30))
@property
def configured(self) -> bool:
return bool(self.base_url and self.app_id and self.access_id and self.access_secret)
def _signed_params(self, user_id: str) -> dict:
params = {
"appId": self.app_id,
"accessId": self.access_id,
"nonce": "".join(random.choices(string.ascii_lowercase + string.digits, k=12)),
"timestamp": datetime.now(_CN_TZ).strftime("%Y%m%d%H%M%S"),
"signType": "MD5",
"signVersion": "1.0",
"userId": user_id,
}
source = "&".join(
f"{key}={params[key]}"
for key in sorted(params)
if params[key] not in (None, "", [])
)
source += f"&accessSecret={self.access_secret}"
params["signature"] = hashlib.md5(source.encode("utf-8")).hexdigest().upper()
return params
@staticmethod
def _json(response: httpx.Response) -> dict:
try:
payload = response.json()
except ValueError as exc:
raise HuihuiPaymentError("会会支付返回了无效响应") from exc
if not isinstance(payload, dict):
raise HuihuiPaymentError("会会支付返回格式不正确")
return payload
def create_payment(
self,
*,
huihui_token: str,
huihui_user_id: str,
real_name: str,
order_no: str,
amount: str,
points_amount: int,
pay_type: str,
pay_way: str,
callback_url: str,
) -> dict[str, Any]:
if not self.configured:
raise HuihuiPaymentError("会会支付服务未配置")
if not huihui_token or not huihui_user_id:
raise HuihuiPaymentError("当前会会登录凭证无法发起支付")
now = datetime.now(_CN_TZ)
body = {
"appId": self.app_id,
"callbackUrl": callback_url,
"chargeType": 4,
"currency": "cny",
"description": f"充值 {points_amount} 积分",
"expend": {},
"masterOrderAmt": amount,
"masterOrderNo": order_no,
"memberId": huihui_user_id,
"orderDesc": "数字分身积分充值",
"orderTime": now.isoformat(),
"orderTitle": "数字分身积分充值",
"payAmt": float(amount),
"payType": pay_type,
"payWay": pay_way,
"realName": real_name or "会会用户",
"timeExpire": (now + timedelta(hours=2)).strftime("%Y%m%d%H%M%S"),
}
headers = {
"Authorization": f"Bearer {huihui_token}",
"appId": self.app_id,
"windowAppId": self.app_id,
}
try:
response = httpx.post(
f"{self.base_url}/payment/pay",
headers=headers,
params=self._signed_params(huihui_user_id),
json=body,
timeout=self.timeout,
follow_redirects=True,
)
except httpx.HTTPError as exc:
raise HuihuiPaymentError("会会支付连接失败,请稍后重试") from exc
payload = self._json(response)
code = payload.get("code")
if response.status_code >= 400 or code not in (0, 200, "0", "200"):
raise HuihuiPaymentError(payload.get("message") or "会会支付下单失败")
data = payload.get("data") or {}
if not isinstance(data, dict):
raise HuihuiPaymentError("会会支付未返回订单信息")
return data
@@ -201,13 +201,20 @@ class TakeoverService:
def _forget_boxim_session(self, user_id: str):
self._sessions.pop(user_id, None)
def _disable_after_connection_failure(
def _record_connection_failure(
self,
db: Session,
avatar: Avatar,
cursor: TakeoverCursor,
message: str,
*,
disable_takeover: bool,
):
cursor.last_error = message
cursor.last_polled_at = self.now()
if not disable_takeover:
return
permissions = (avatar.config or {}).get("authorizationPermissions", [])
avatar.config = {
**(avatar.config or {}),
@@ -217,8 +224,6 @@ class TakeoverService:
if permission != TAKEOVER_PERMISSION
],
}
cursor.last_error = message
cursor.last_polled_at = self.now()
tasks = (
db.query(TakeoverReplyTask)
.filter(
@@ -245,11 +250,12 @@ class TakeoverService:
db.add(cursor)
db.flush()
if not user or not user.huihui_token:
self._disable_after_connection_failure(
self._record_connection_failure(
db,
avatar,
cursor,
"请重新登录会会生产账号后再开启主动接管",
disable_takeover=True,
)
db.commit()
return False
@@ -268,11 +274,24 @@ class TakeoverService:
if isinstance(exc, BoxIMError) and exc.auth_error:
self._forget_boxim_session(user.id)
message = "BOXIM 授权已失效,请重新登录会会生产账号"
disable_takeover = True
else:
message = f"BOXIM 暂时连接失败:{str(exc)[:160]}"
self._disable_after_connection_failure(db, avatar, cursor, message)
disable_takeover = False
self._record_connection_failure(
db,
avatar,
cursor,
message,
disable_takeover=disable_takeover,
)
db.commit()
logger.warning("BOXIM sync failed for avatar %s: %s", avatar.id, exc)
logger.warning(
"BOXIM sync failed for avatar %s (will_retry=%s): %s",
avatar.id,
not disable_takeover,
exc,
)
return False
messages.sort(key=lambda item: (_numeric_id(item.get("id")), item.get("sendTime") or 0))
@@ -82,7 +82,7 @@ def reserve_avatar_tokens(
) -> TokenReservation:
user = avatar_owner_user(db, avatar)
if not user:
raise InsufficientTokensError("分身尚未关联有效用户,暂时无法使用 Token")
raise InsufficientTokensError("分身尚未关联有效用户,暂时无法使用积分")
account = get_or_create_account(db, user.id)
reserved = estimate_request_tokens(messages, max_output_tokens)
updated = (
@@ -95,7 +95,7 @@ def reserve_avatar_tokens(
)
if updated != 1:
db.rollback()
raise InsufficientTokensError("Token 余额不足,请充值后继续")
raise InsufficientTokensError("积分余额不足,请充值后继续")
db.refresh(account)
usage = TokenUsage(
user_id=user.id,
@@ -142,7 +142,7 @@ def settle_reservation(
)
)
if updated != 1:
raise RuntimeError("Token 账户不存在")
raise RuntimeError("积分账户不存在")
db.expire_all()
account = db.query(TokenAccount).filter(TokenAccount.user_id == reservation.user_id).first()
record.prompt_tokens = prompt_tokens
@@ -9,6 +9,7 @@ from models import (
TakeoverMessage,
TakeoverReplyTask,
TokenAccount,
TokenPaymentOrder,
TokenUsage,
User,
)
@@ -110,6 +111,9 @@ def authorization_context():
synchronize_session=False
)
user_ids = [owner.id, other.id]
db.query(TokenPaymentOrder).filter(TokenPaymentOrder.user_id.in_(user_ids)).delete(
synchronize_session=False
)
db.query(TokenUsage).filter(TokenUsage.user_id.in_(user_ids)).delete(
synchronize_session=False
)
@@ -5,7 +5,15 @@ from unittest.mock import Mock
from fastapi import HTTPException
from models import Avatar, User
from routers.chat import _build_prompt, _iter_text_chunks, _match_standard_qa, _public_avatar_payload, _require_owned_avatar, _resolve_reply
from routers.chat import (
_build_prompt,
_iter_text_chunks,
_match_standard_qa,
_public_avatar_payload,
_qa_requires_language_adaptation,
_require_owned_avatar,
_resolve_reply,
)
class ChatOrchestrationTests(unittest.TestCase):
@@ -50,6 +58,34 @@ class ChatOrchestrationTests(unittest.TestCase):
self.assertEqual(result["answer"], "标准地址")
fake_model.assert_not_called()
def test_cross_language_qa_is_faithfully_adapted_by_model(self):
fake_model = Mock(return_value="Our address is Test Road 1.")
fake_search = Mock(return_value=[])
result = _resolve_reply(
None,
self.avatar,
"Where is your office?",
[],
qa_pairs=[SimpleNamespace(question="Where is your office?", answer="地址是测试路1号。", enabled=True)],
search_fn=fake_search,
model_client=fake_model,
)
self.assertEqual(result["source"], "qa")
self.assertEqual(result["answer"], "Our address is Test Road 1.")
self.assertEqual(fake_model.call_args.kwargs["temperature"], 0.0)
system = fake_model.call_args.kwargs["messages"][0]["content"]
self.assertIn("已确认标准答案", system)
self.assertIn("地址是测试路1号", system)
self.assertIn("只使用该语言回答", system)
fake_search.assert_not_called()
def test_qa_language_adaptation_detects_common_writing_system_changes(self):
self.assertTrue(_qa_requires_language_adaptation("Hello", "你好"))
self.assertTrue(_qa_requires_language_adaptation("こんにちは", "你好"))
self.assertTrue(_qa_requires_language_adaptation("안녕하세요", "你好"))
self.assertFalse(_qa_requires_language_adaptation("你好", "您好"))
def test_conversational_paraphrase_matches_standard_qa(self):
for question in ("请问一下,你们公司在哪里呀?", "请问去你们那边怎么走"):
with self.subTest(question=question):
@@ -106,6 +142,9 @@ class ChatOrchestrationTests(unittest.TestCase):
self.assertIn("像熟人之间微信聊天一样", messages[0]["content"])
self.assertIn("不隶属于任何机构", messages[0]["content"])
self.assertIn("不要连续输出空行", messages[0]["content"])
self.assertIn("回答语言规则", messages[0]["content"])
self.assertIn("当前最后一条用户消息", messages[0]["content"])
self.assertIn("历史消息", messages[0]["content"])
def test_prompt_blocks_ungrounded_factual_answers(self):
messages = _build_prompt(self.avatar, [], "聊聊国际新闻", [])
@@ -113,6 +152,8 @@ class ChatOrchestrationTests(unittest.TestCase):
self.assertIn("没有检索到可靠资料", system)
self.assertIn("不要凭通用知识", system)
self.assertIn("不要提及知识库", system)
self.assertIn("不得推断服务对象", system)
self.assertIn("工作场所", system)
def test_public_avatar_payload_excludes_internal_configuration(self):
payload = _public_avatar_payload(self.avatar)
@@ -0,0 +1,50 @@
from unittest.mock import Mock, patch
from services.huihui_payment import HuihuiPaymentClient
def test_create_payment_uses_huihui_payment_v3_contract():
client = HuihuiPaymentClient({
"HUIHUI_PAYMENT_BASE_URL": "https://open.example/api/payment-v3",
"HUIHUI_APP_ID": "app-id",
"HUIHUI_ACCESS_ID": "access-id",
"HUIHUI_ACCESS_SECRET": "access-secret",
})
response = Mock()
response.status_code = 200
response.json.return_value = {
"code": 0,
"data": {"orderId": "provider-id", "status": "pending", "payMessage": "mock"},
}
with patch("services.huihui_payment.httpx.post", return_value=response) as post:
result = client.create_payment(
huihui_token="user-token",
huihui_user_id="user-id",
real_name="测试用户",
order_no="AV202608260001",
amount="10.00",
points_amount=2_000_000,
pay_type="WECHAT",
pay_way="APP",
callback_url="https://digital.example/api/token/payment/callback/secret",
)
assert result["orderId"] == "provider-id"
assert post.call_args.args[0] == "https://open.example/api/payment-v3/payment/pay"
assert post.call_args.kwargs["headers"] == {
"Authorization": "Bearer user-token",
"appId": "app-id",
"windowAppId": "app-id",
}
params = post.call_args.kwargs["params"]
assert params["appId"] == "app-id"
assert params["accessId"] == "access-id"
assert params["userId"] == "user-id"
assert params["signature"]
assert "accessSecret" not in params
body = post.call_args.kwargs["json"]
assert body["payType"] == "WECHAT"
assert body["payWay"] == "APP"
assert body["masterOrderAmt"] == "10.00"
assert body["payAmt"] == 10.0
@@ -291,5 +291,41 @@ async def test_connection_failure_disables_takeover_and_stops_retrying(service_c
boxim.exchange_access_token.assert_awaited_once_with("prod-huihui-token")
@pytest.mark.asyncio
async def test_transient_connection_failure_keeps_takeover_and_recovers(service_context):
session_factory, service, boxim, _ = service_context
boxim.exchange_access_token = AsyncMock(
side_effect=[
BoxIMError("连接超时"),
{"accessToken": "box-token", "accessTokenExpiresIn": 3600},
]
)
await service.poll_messages()
db = session_factory()
try:
avatar = db.query(Avatar).one()
cursor = db.query(TakeoverCursor).one()
assert "takeover" in avatar.config["authorizationPermissions"]
assert cursor.initialized is False
assert "暂时连接失败" in cursor.last_error
finally:
db.close()
await service.poll_messages()
db = session_factory()
try:
avatar = db.query(Avatar).one()
cursor = db.query(TakeoverCursor).one()
assert "takeover" in avatar.config["authorizationPermissions"]
assert cursor.initialized is True
assert cursor.last_error == ""
finally:
db.close()
assert boxim.exchange_access_token.await_count == 2
def test_plain_text_reply_removes_markdown_and_empty_lines():
assert _plain_text_reply("## 建议\n\n**不能自行用药**\n`必要时就医`") == "建议\n不能自行用药\n必要时就医"
@@ -1,14 +1,17 @@
import uuid
import os
import hashlib
import hmac
from concurrent.futures import ThreadPoolExecutor
from threading import Barrier
from unittest.mock import patch
from unittest.mock import Mock, patch
import pytest
from fastapi.testclient import TestClient
from database import SessionLocal
from main import app, seed
from models import Avatar, TokenAccount, TokenPlan, TokenUsage, User
from models import Avatar, TokenAccount, TokenPaymentOrder, TokenPlan, TokenUsage, User
from routers.chat import _resolve_reply, _stream_reply
from services.token_billing import (
DEFAULT_TOKEN_GRANT,
@@ -22,6 +25,16 @@ from services.token_billing import (
client = TestClient(app)
def _enable_huihui_payment_login(context):
db = SessionLocal()
try:
user = db.query(User).filter(User.id == context["owner"].id).one()
user.huihui_token = f"huihui-payment-{context['suffix']}"
db.commit()
finally:
db.close()
def test_balance_is_user_scoped_and_defaults_to_one_million(authorization_context):
context = authorization_context
owner = client.get("/api/token/balance", headers=context["owner_headers"])
@@ -52,6 +65,171 @@ def test_seed_synchronizes_requested_recharge_plans():
db.close()
def test_charge_creates_huihui_order_without_early_points(authorization_context):
context = authorization_context
_enable_huihui_payment_login(context)
payment_client = Mock()
payment_client.create_payment.return_value = {
"orderId": "huihui-payment-id",
"orderNo": "huihui-payment-no",
"payMessage": {"mock": "payment-params"},
"payType": "WECHAT",
"paySubType": "APP",
"status": "pending",
}
env = {
"HUIHUI_PAYMENT_CALLBACK_BASE_URL": "https://digital.example",
"HUIHUI_PAYMENT_CALLBACK_SECRET": "test-callback-secret-123456",
}
with patch.dict(os.environ, env), patch("routers.tokens._payment_client", return_value=payment_client):
response = client.post(
"/api/token/charge",
headers=context["owner_headers"],
json={"planId": "1", "paymentMethod": "wechat", "payScene": "APP"},
)
assert response.status_code == 200
result = response.json()["data"]
assert result["status"] == "pending"
assert result["payType"] == "WECHAT"
assert result["payWay"] == "APP"
assert result["balance"] == DEFAULT_TOKEN_GRANT
assert payment_client.create_payment.call_args.kwargs["amount"] == "10.00"
callback_url = payment_client.create_payment.call_args.kwargs["callback_url"]
assert callback_url.startswith("https://digital.example/api/token/payment/callback/AV")
assert "test-callback-secret-123456" not in callback_url
def test_success_callback_credits_once_and_status_is_user_scoped(authorization_context):
context = authorization_context
_enable_huihui_payment_login(context)
payment_client = Mock()
payment_client.create_payment.return_value = {
"orderId": "huihui-payment-id",
"orderNo": "huihui-payment-no",
"payMessage": "payment-message",
"status": "pending",
}
secret = "test-callback-secret-123456"
env = {
"HUIHUI_PAYMENT_CALLBACK_BASE_URL": "https://digital.example",
"HUIHUI_PAYMENT_CALLBACK_SECRET": secret,
}
with patch.dict(os.environ, env), patch("routers.tokens._payment_client", return_value=payment_client):
created = client.post(
"/api/token/charge",
headers=context["owner_headers"],
json={"planId": "1", "paymentMethod": "alipay", "payScene": "APP"},
).json()["data"]
callback_body = {
"data": {
"masterOrderNo": created["orderNo"],
"status": "succeeded",
"payAmt": "10.00",
}
}
signature = hmac.new(
secret.encode(), created["orderNo"].encode(), hashlib.sha256
).hexdigest()
callback_path = f"/api/token/payment/callback/{created['orderNo']}/{signature}"
first = client.post(callback_path, json=callback_body)
second = client.post(callback_path, json=callback_body)
assert first.json()["data"] == {"received": True, "paid": True}
assert second.json()["data"] == {"received": True, "duplicate": True}
status = client.get(
f"/api/token/payment/{created['id']}", headers=context["owner_headers"]
).json()["data"]
assert status["status"] == "paid"
assert status["balance"] == DEFAULT_TOKEN_GRANT + 2_000_000
assert client.get(
f"/api/token/payment/{created['id']}", headers=context["other_headers"]
).json()["code"] == 404
def test_callback_amount_mismatch_never_credits_points(authorization_context):
context = authorization_context
_enable_huihui_payment_login(context)
payment_client = Mock()
payment_client.create_payment.return_value = {"status": "pending", "payMessage": "mock"}
secret = "test-callback-secret-123456"
env = {
"HUIHUI_PAYMENT_CALLBACK_BASE_URL": "https://digital.example",
"HUIHUI_PAYMENT_CALLBACK_SECRET": secret,
}
with patch.dict(os.environ, env), patch("routers.tokens._payment_client", return_value=payment_client):
created = client.post(
"/api/token/charge",
headers=context["owner_headers"],
json={"planId": "1", "paymentMethod": "wechat", "payScene": "APP"},
).json()["data"]
signature = hmac.new(
secret.encode(), created["orderNo"].encode(), hashlib.sha256
).hexdigest()
callback = client.post(
f"/api/token/payment/callback/{created['orderNo']}/{signature}",
json={
"masterOrderNo": created["orderNo"],
"status": "success",
"actualAmt": "9.99",
},
)
assert callback.json()["code"] == 422
db = SessionLocal()
try:
order = db.query(TokenPaymentOrder).filter(TokenPaymentOrder.id == created["id"]).one()
account = get_or_create_account(db, context["owner"].id)
assert order.status == "pending"
assert account.balance == DEFAULT_TOKEN_GRANT
finally:
db.close()
def test_payment_callback_creates_missing_account_in_same_settlement(authorization_context):
context = authorization_context
_enable_huihui_payment_login(context)
payment_client = Mock()
payment_client.create_payment.return_value = {"status": "pending", "payMessage": "mock"}
secret = "test-callback-secret-123456"
env = {
"HUIHUI_PAYMENT_CALLBACK_BASE_URL": "https://digital.example",
"HUIHUI_PAYMENT_CALLBACK_SECRET": secret,
}
with patch.dict(os.environ, env), patch("routers.tokens._payment_client", return_value=payment_client):
created = client.post(
"/api/token/charge",
headers=context["owner_headers"],
json={"planId": "1", "paymentMethod": "alipay", "payScene": "APP"},
).json()["data"]
db = SessionLocal()
try:
db.query(TokenAccount).filter(TokenAccount.user_id == context["owner"].id).delete()
db.commit()
finally:
db.close()
signature = hmac.new(
secret.encode(), created["orderNo"].encode(), hashlib.sha256
).hexdigest()
callback = client.post(
f"/api/token/payment/callback/{created['orderNo']}/{signature}",
json={
"masterOrderNo": created["orderNo"],
"status": "success",
"payAmt": "10.00",
},
)
assert callback.json()["data"] == {"received": True, "paid": True}
db = SessionLocal()
try:
account = db.query(TokenAccount).filter(TokenAccount.user_id == context["owner"].id).one()
assert account.balance == DEFAULT_TOKEN_GRANT + 2_000_000
assert account.total_granted == DEFAULT_TOKEN_GRANT + 2_000_000
finally:
db.close()
def test_multiple_avatars_share_owner_balance_and_usage_is_itemized():
suffix = uuid.uuid4().hex
db = SessionLocal()
@@ -39,6 +39,10 @@ HUIHUI_ACCESS_ID=<production-access-id>
HUIHUI_ACCESS_SECRET=<production-access-secret>
HUIHUI_CLIENT_CODE=<production-client-code>
BOXIM_TIMEOUT_SECONDS=20
HUIHUI_PAYMENT_BASE_URL=https://open.99hui.com/api/payment-v3
HUIHUI_PAYMENT_CALLBACK_BASE_URL=https://digital.99hui.com
HUIHUI_PAYMENT_CALLBACK_SECRET=<至少32位随机密钥>
HUIHUI_PAYMENT_TIMEOUT_SECONDS=30
DATABASE_URL=sqlite:////data/avatar.db
UPLOAD_DIR=/data/uploads
@@ -47,6 +51,8 @@ CHAT_MODEL_CONFIG_URL=http://<huihuisquare-api>/api/ai-models/runtime/digital-av
如生产 AI 配置中心不可用,还应提供当前项目支持的 `OPENAI_API_KEY`、`OPENAI_BASE_URL`、`CHAT_MODEL` 等兜底配置。`/data` 必须挂载持久卷,数据库与知识库文件不可存放在容器临时层。
积分充值使用会会支付体系的 `payment-v3/payment/pay`,渠道值为 `WECHAT` / `ALIPAY`,端内支付场景为 `APP`,微信内 H5 使用 `JSAPI`。`HUIHUI_PAYMENT_CALLBACK_SECRET` 只用于为每笔订单生成 HMAC 回调签名,不会发送到前端或直接出现在回调地址中。支付回调确认状态成功且金额与套餐价格完全一致后才增加积分,重复回调不会重复到账。
## 3. 构建与发布
首次发布前备份数据:
@@ -102,13 +108,14 @@ location /api/ {
1. 已登录会会用户通过带 token 链接打开后直接进入 `/avatar/manage`,不出现登录页或创建新账号页。
2. 页面加载后地址栏中不再包含 `token`,刷新页面仍使用本地 `app_token` 正常访问。
3. 后端用户绑定的是 BOXIM 返回的 `huihuiUserId`,不是 BOXIM 内部 `id`;原有数字分身、独立知识库和 Token 余额均存在。
3. 后端用户绑定的是 BOXIM 返回的 `huihuiUserId`,不是 BOXIM 内部 `id`;原有数字分身、独立知识库和积分余额均存在。
4. A、B 两个会会用户分别进入时只能看到各自的数字分身与知识库,不会继承上一用户缓存。
5. 使用过期或伪造 token 时进入登录页并显示凭证失效,不得继续访问旧用户数据。
6. 分身聊天 SSE 逐段输出正常,Markdown 正常渲染,知识库优先级和 Token 扣费正常。
6. 分身聊天 SSE 逐段输出正常,Markdown 正常渲染,知识库优先级和积分扣费正常。
7. 开启 BOXIM 主动接管后保持在线,收到消息、三秒回复、已读回执和主人发言暂停均正常。
8. 重建容器后数据库、头像、知识库文档仍存在,`/api/health` 返回成功。
9. `https://digital.99hui.com/api/health` 可访问,证书域名和有效期正确,HTTP 自动跳转 HTTPS。
10. 微信和支付宝各创建一笔最小套餐订单,未付款时积分不变;支付成功后回调到账一次,重复回调积分不重复增加。
## 6. 回滚
@@ -0,0 +1 @@
8f54097b01d5b0433f8aa5ea70ca930b
+25 -3
View File
@@ -146,6 +146,22 @@ export interface TokenUsageSummary {
requestCount: number
}
export interface TokenPaymentOrder {
id: string
orderNo: string
planId: string
paymentMethod: 'wechat' | 'alipay'
payType: 'WECHAT' | 'ALIPAY'
payWay: 'APP' | 'LITE' | 'JSAPI'
pointsAmount: number
price: number
status: 'pending' | 'paid' | 'failed'
providerStatus: string
payMessage: string
failureReason: string
balance: number
}
// 获取 Token 余额
export const getTokenBalance = () =>
request.get<TokenBalance>('/token/balance')
@@ -154,9 +170,15 @@ export const getTokenBalance = () =>
export const getRechargePlans = () =>
request.get<Array<{ id: string; name: string; amount: number; price: number }>>('/token/plans')
// 执行充值
export const chargeToken = (planId: string) =>
request.post<{ balance: number; charged: number }>('/token/charge', { planId })
// 创建会会支付订单;支付回调成功后积分到账。
export const chargeToken = (
planId: string,
paymentMethod: 'wechat' | 'alipay',
payScene: 'APP' | 'LITE' | 'JSAPI'
) => request.post<TokenPaymentOrder>('/token/charge', { planId, paymentMethod, payScene })
export const getTokenPaymentStatus = (orderId: string) =>
request.get<TokenPaymentOrder>(`/token/payment/${orderId}`)
// 按分身和使用场景汇总 Token 消耗
export const getTokenUsage = () =>
+4
View File
@@ -0,0 +1,4 @@
<svg fill="#1677FF" role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<title>支付宝</title>
<path d="M19.695 15.07c3.426 1.158 4.203 1.22 4.203 1.22V3.846c0-2.124-1.705-3.845-3.81-3.845H3.914C1.808.001.102 1.722.102 3.846v16.31c0 2.123 1.706 3.845 3.813 3.845h16.173c2.105 0 3.81-1.722 3.81-3.845v-.157s-6.19-2.602-9.315-4.119c-2.096 2.602-4.8 4.181-7.607 4.181-4.75 0-6.361-4.19-4.112-6.949.49-.602 1.324-1.175 2.617-1.497 2.025-.502 5.247.313 8.266 1.317a16.796 16.796 0 0 0 1.341-3.302H5.781v-.952h4.799V6.975H4.77v-.953h5.81V3.591s0-.409.411-.409h2.347v2.84h5.744v.951h-5.744v1.704h4.69a19.453 19.453 0 0 1-1.986 5.06c1.424.52 2.702 1.011 3.654 1.333m-13.81-2.032c-.596.06-1.71.325-2.321.869-1.83 1.608-.735 4.55 2.968 4.55 2.151 0 4.301-1.388 5.99-3.61-2.403-1.182-4.438-2.028-6.637-1.809"/>
</svg>

After

Width:  |  Height:  |  Size: 828 B

@@ -0,0 +1,4 @@
<svg fill="#07C160" role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<title>微信支付</title>
<path d="M8.691 2.188C3.891 2.188 0 5.476 0 9.53c0 2.212 1.17 4.203 3.002 5.55a.59.59 0 0 1 .213.665l-.39 1.48c-.019.07-.048.141-.048.213 0 .163.13.295.29.295a.326.326 0 0 0 .167-.054l1.903-1.114a.864.864 0 0 1 .717-.098 10.16 10.16 0 0 0 2.837.403c.276 0 .543-.027.811-.05-.857-2.578.157-4.972 1.932-6.446 1.703-1.415 3.882-1.98 5.853-1.838-.576-3.583-4.196-6.348-8.596-6.348zM5.785 5.991c.642 0 1.162.529 1.162 1.18a1.17 1.17 0 0 1-1.162 1.178A1.17 1.17 0 0 1 4.623 7.17c0-.651.52-1.18 1.162-1.18zm5.813 0c.642 0 1.162.529 1.162 1.18a1.17 1.17 0 0 1-1.162 1.178 1.17 1.17 0 0 1-1.162-1.178c0-.651.52-1.18 1.162-1.18zm5.34 2.867c-1.797-.052-3.746.512-5.28 1.786-1.72 1.428-2.687 3.72-1.78 6.22.942 2.453 3.666 4.229 6.884 4.229.826 0 1.622-.12 2.361-.336a.722.722 0 0 1 .598.082l1.584.926a.272.272 0 0 0 .14.047c.134 0 .24-.111.24-.247 0-.06-.023-.12-.038-.177l-.327-1.233a.582.582 0 0 1-.023-.156.49.49 0 0 1 .201-.398C23.024 18.48 24 16.82 24 14.98c0-3.21-2.931-5.837-6.656-6.088V8.89c-.135-.01-.27-.027-.407-.03zm-2.53 3.274c.535 0 .969.44.969.982a.976.976 0 0 1-.969.983.976.976 0 0 1-.969-.983c0-.542.434-.982.97-.982zm4.844 0c.535 0 .969.44.969.982a.976.976 0 0 1-.969.983.976.976 0 0 1-.969-.983c0-.542.434-.982.969-.982z"/>
</svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

+1 -1
View File
@@ -55,7 +55,7 @@ const routes: RouteRecordRaw[] = [
path: '/token/charge',
name: 'TokenCharge',
component: () => import('@/views/TokenCharge.vue'),
meta: { title: 'Token充值', requiresAuth: true }
meta: { title: '积分充值', requiresAuth: true }
},
{
path: '/avatar/card',
+26 -6
View File
@@ -4,6 +4,7 @@
// uni-webview bridge is bundled by main.ts; no external CDN is required.
const BRIDGE_HANDLER = '__uniBridgeHandle__'
const nativeMessageHandlers = new Set<(message: any) => void>()
export interface UniLaunchParams {
token?: string
@@ -25,7 +26,21 @@ function readParams(search: string, target: UniLaunchParams): void {
// 是否运行在 uniapp web-view 环境中
export function isInUniWebView(): boolean {
return !!(window as any).uni?.webView
const runtime = window as any
const userAgent = navigator.userAgent
const isDCloudApp = Boolean(
runtime.plus ||
runtime.__dcloud_weex_postMessage ||
runtime.__dcloud_weex_ ||
/uni-app|Html5Plus/i.test(userAgent)
)
const isMiniProgram = Boolean(
(/miniProgram/i.test(userAgent) && (runtime.wx?.miniProgram || runtime.qq?.miniProgram)) ||
runtime.my ||
runtime.swan?.webView ||
runtime.tt?.miniProgram
)
return Boolean(runtime.uni?.webView && (isDCloudApp || isMiniProgram))
}
// 解析 web-view 加载 URL 时原生注入的参数(token / 会会用户)
@@ -65,14 +80,18 @@ export function postToNative(message: Record<string, any>): boolean {
}
// 原生 → H5:注册消息处理(原生通过 web-view.evalJS 调用 window.__uniBridgeHandle__)
export function onNativeMessage(handler: (message: any) => void): void {
export function onNativeMessage(handler: (message: any) => void): () => void {
nativeMessageHandlers.add(handler)
;(window as any)[BRIDGE_HANDLER] = (message: any) => {
try {
handler(message)
} catch (e) {
console.error('[uniBridge] handler error', e)
for (const listener of nativeMessageHandlers) {
try {
listener(message)
} catch (e) {
console.error('[uniBridge] handler error', e)
}
}
}
return () => nativeMessageHandlers.delete(handler)
}
// 便捷事件
@@ -81,5 +100,6 @@ export const UniEvents = {
needLogin: () => postToNative({ type: 'needLogin' }),
setTitle: (title: string) => postToNative({ type: 'setTitle', title }),
navigate: (path: string) => postToNative({ type: 'navigate', path }),
payment: (payment: Record<string, any>) => postToNative({ type: 'payment', payment }),
back: () => postToNative({ type: 'back' })
}
@@ -24,11 +24,11 @@
</div>
</section>
<!-- Token 余额条 -->
<section class="token-section">
<!-- 积分余额条:暂时隐藏,保留完整实现便于后续恢复。 -->
<section v-if="SHOW_POINTS_BALANCE_CARD" class="token-section">
<div class="token-card">
<div class="token-info">
<span class="token-label">Token 余额</span>
<span class="token-label">积分余额</span>
<span class="token-amount">{{ tokenBalance.toLocaleString() }}</span>
<span class="token-used">累计使用 {{ tokenConsumed.toLocaleString() }}</span>
</div>
@@ -56,7 +56,7 @@
<div class="avatar-details">
<div class="avatar-name-row"><h2 class="avatar-name">{{ a.displayName || a.name }}</h2><span class="avatar-status"><i class="status-dot" :class="a.status"></i>{{ statusText(a.status) }}</span></div>
<p class="avatar-desc">{{ a.description || '暂无描述' }}</p>
<span class="avatar-token-usage">累计使用 {{ avatarTokenUsage(a.id).toLocaleString() }} Token</span>
<span class="avatar-token-usage">累计使用 {{ avatarTokenUsage(a.id).toLocaleString() }} 积分</span>
</div>
</div>
<div class="avatar-actions">
@@ -92,6 +92,9 @@ const router = useRouter()
const avatarStore = useAvatarStore()
const userStore = useUserStore()
// 临时产品开关:余额卡片代码保留,后续改为 true 即可恢复展示。
const SHOW_POINTS_BALANCE_CARD = false
// 当前登录会会用户的资料(头像 / 昵称)
const me = computed(() => userStore.user)
@@ -289,7 +292,7 @@ onMounted(() => {
background: rgba(255, 255, 255, 0.3);
}
/* Token 余额条 */
/* 积分余额条 */
.token-section {
padding: 16px 20px;
}
+222 -21
View File
@@ -3,7 +3,7 @@
<!-- 顶部导航 -->
<header class="page-header">
<button class="back-btn" @click="goBack">‹</button>
<h1 class="page-title">Token 充值</h1>
<h1 class="page-title">积分充值</h1>
<div class="header-right"></div>
</header>
@@ -12,8 +12,8 @@
<div class="balance-card">
<span class="balance-label">当前余额</span>
<span class="balance-amount">{{ currentBalance.toLocaleString() }}</span>
<span class="balance-unit">Token</span>
<span class="balance-used">累计使用 {{ totalConsumed.toLocaleString() }} Token</span>
<span class="balance-unit">积分</span>
<span class="balance-used">累计使用 {{ totalConsumed.toLocaleString() }} 积分</span>
</div>
</section>
@@ -30,9 +30,9 @@
>
<div class="plan-badge" v-if="plan.badge">{{ plan.badge }}</div>
<div class="plan-amount">{{ formatTokenAmount(plan.amount) }}</div>
<div class="plan-unit">Token</div>
<div class="plan-unit">积分</div>
<div class="plan-price">¥{{ plan.price }}</div>
<div class="plan-desc" v-if="plan.desc">{{ plan.desc }}</div>
<div class="plan-desc" v-if="plan.desc">{{ formatPointsCopy(plan.desc) }}</div>
</div>
</div>
</section>
@@ -46,7 +46,7 @@
:class="{ selected: paymentMethod === 'wechat' }"
@click="paymentMethod = 'wechat'"
>
<span class="payment-icon">💚</span>
<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>
@@ -55,31 +55,41 @@
:class="{ selected: paymentMethod === 'alipay' }"
@click="paymentMethod = 'alipay'"
>
<span class="payment-icon">💙</span>
<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>
<!-- 充值按钮 -->
<section class="checkout-section">
<button
class="checkout-btn"
:class="{ disabled: !selectedPlan }"
:disabled="!selectedPlan"
:class="{ disabled: !selectedPlan || charging || Boolean(pendingOrderId) }"
:disabled="!selectedPlan || charging || Boolean(pendingOrderId)"
@click="doCharge"
>
{{ selectedPlan ? `立即支付 ¥${selectedPlan.price}` : '请选择充值套餐' }}
{{ checkoutLabel }}
</button>
</section>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { computed, onMounted, onUnmounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import { getTokenBalance, getRechargePlans, chargeToken } from '@/api'
import {
chargeToken,
getRechargePlans,
getTokenBalance,
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'
const router = useRouter()
@@ -101,6 +111,12 @@ 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') || '')
let pollTimer: number | undefined
let pollDeadline = 0
let removeNativeListener: (() => void) | undefined
// 从后端加载余额与套餐
const loadData = async () => {
@@ -119,22 +135,144 @@ const loadData = async () => {
}
}
// 执行充值(写入后端)
// 会会支付订单创建与到账确认
const charging = ref(false)
const formatTokenAmount = (amount: number) => {
if (amount >= 1_000_000 && amount % 1_000_000 === 0) return `${amount / 1_000_000}M`
return amount.toLocaleString()
}
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}`
})
const payScene = () => {
if (paymentMethod.value === 'wechat' && /MicroMessenger/i.test(navigator.userAgent)) return 'JSAPI' as const
return 'APP' 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()
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 methodText = paymentMethod.value === 'wechat' ? '微信支付' : '支付宝'
const res: any = await chargeToken(selectedPlan.value.id)
currentBalance.value = res?.balance ?? currentBalance.value
alert(`已通过${methodText}成功充值,当前余额:${currentBalance.value} Token`)
} catch (e) {
alert('充值失败,请重试')
const order = await chargeToken(selectedPlan.value.id, paymentMethod.value, payScene())
if (order.status === 'failed') throw new Error(order.failureReason || '支付订单创建失败')
paymentNoticeTone.value = 'pending'
paymentNotice.value = '支付订单已创建,正在等待付款结果'
startPolling(order.id)
await launchPayment(order)
} catch (error: any) {
paymentNoticeTone.value = 'error'
paymentNotice.value = error?.message || '充值失败,请重试'
clearPendingOrder()
} finally {
charging.value = false
}
@@ -146,7 +284,33 @@ const goBack = () => {
}
onMounted(() => {
loadData()
void loadData()
removeNativeListener = onNativeMessage((message) => {
if (message?.type !== 'paymentResult') return
if (message.orderId && message.orderId !== pendingOrderId.value) return
if (['cancelled', 'canceled'].includes(String(message.status).toLowerCase())) {
paymentNoticeTone.value = 'error'
paymentNotice.value = '支付已取消'
clearPendingOrder()
return
}
if (String(message.status).toLowerCase() === 'failed') {
paymentNoticeTone.value = 'error'
paymentNotice.value = message.message || '支付未完成,请重新发起'
clearPendingOrder()
return
}
void pollPayment()
})
if (pendingOrderId.value) {
paymentNotice.value = '正在确认上一笔支付结果'
startPolling(pendingOrderId.value)
}
})
onUnmounted(() => {
if (pollTimer) window.clearTimeout(pollTimer)
removeNativeListener?.()
})
</script>
@@ -333,7 +497,24 @@ onMounted(() => {
}
.payment-icon {
font-size: 24px;
width: 38px;
height: 38px;
display: grid;
place-items: center;
border-radius: 11px;
}
.payment-icon img {
width: 25px;
height: 25px;
}
.wechat-icon {
background: #EAF9F0;
}
.alipay-icon {
background: #EAF3FF;
}
.payment-name {
@@ -356,6 +537,26 @@ onMounted(() => {
font-weight: 600;
}
.payment-notice {
margin: 12px 2px 0;
padding: 10px 12px;
border-radius: 10px;
color: #9A5B16;
background: #FFF5E8;
font-size: 13px;
line-height: 1.5;
}
.payment-notice.success {
color: #08783E;
background: #EAF9F0;
}
.payment-notice.error {
color: #B42318;
background: #FFF0EE;
}
/* 充值按钮 */
.checkout-section {
padding: 0 20px;
+4
View File
@@ -54,6 +54,7 @@ H5 引入 uniapp web-view bridge 后调用:
| `needLogin` | — | token 失效,请求壳重新登录 |
| `setTitle` | `title` | 设置原生导航栏标题 |
| `navigate` | `path` | 请求原生跳转(打开原生页/新 web-view) |
| `payment` | `payment` | 拉起会会原生支付;包含 `orderId/orderNo/payType/payWay/payMessage/paymentParams` |
| `back` | — | 请求原生返回 |
### 2.3 原生 → H5(壳主动推送)
@@ -63,9 +64,12 @@ H5 引入 uniapp web-view bridge 后调用:
| `context` | `platform, version` | 注入运行环境信息 |
| `tokenRefresh` | `token` | 登录刷新后下发新 token |
| `userUpdate` | `user` | 会会资料变更 |
| `paymentResult` | `orderId,status` | 原生支付结束通知;`status` 为 `success/cancelled/failed` |
> H5 侧用 `onNativeMessage(cb)` 注册 `window.__uniBridgeHandle__`,见 `digital-avatar-app/src/utils/uniapp-bridge.ts`。
壳收到 `payment` 后应调用会会 App 已有的微信/支付宝支付能力(或 `uni.requestPayment`),把 `payMessage/paymentParams` 原样交给对应渠道。原生 SDK 返回后再发送 `paymentResult`;H5 不以原生返回作为到账依据,只会轮询本地订单,最终由会会服务端支付回调确认并增加积分。
---
## 3. 项目结构(uni CLI / src 布局,已验证可编译)