Compare commits

..
Author SHA1 Message Date
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
14 changed files with 1080 additions and 519 deletions
+23 -142
View File
@@ -79,34 +79,6 @@ _WRITING_SYSTEM_PATTERNS = {
_JAPANESE_KANA = re.compile(r"[\u3040-\u30ff]") _JAPANESE_KANA = re.compile(r"[\u3040-\u30ff]")
_KOREAN_HANGUL = re.compile(r"[\uac00-\ud7af\u1100-\u11ff]") _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): class ChatMessage(BaseModel):
model_config = ConfigDict(populate_by_name=True) 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) return bool(history) or _qa_requires_language_adaptation(question, answer)
def _latin_language_name(value: str) -> str: def _per_turn_language_instruction() -> 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)
return ( 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: def _canonicalize_question(value: str) -> str:
value = _normalize_question(value) value = _normalize_question(value)
replacements = ( replacements = (
@@ -808,7 +728,7 @@ def _build_prompt(
for item in history[-MAX_HISTORY_MESSAGES:]: for item in history[-MAX_HISTORY_MESSAGES:]:
messages.append({"role": item.role, "content": item.content} if hasattr(item, "role") else item) 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. # 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()}) messages.append({"role": "user", "content": question.strip()})
return messages return messages
@@ -871,41 +791,6 @@ def _call_qwen(
return {"answer": answer.strip(), "usage": data.get("usage") or {}} 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( def _iter_qwen_stream(
messages: list[dict], temperature: float, model_config: ChatModelConfig | None = None messages: list[dict], temperature: float, model_config: ChatModelConfig | None = None
): ):
@@ -1014,36 +899,32 @@ def _resolve_reply(
token_usage = None token_usage = None
if model_client is not None: if model_client is not None:
answer = model_client(messages=messages, temperature=temperature) 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: else:
model_config = get_chat_model_config() model_config = get_chat_model_config()
answer, token_usage = _call_billed_qwen( reservation = reserve_avatar_tokens(
db, db,
avatar, avatar,
messages,
temperature,
usage_source, usage_source,
model_config, model_config.model,
messages,
model_config.max_tokens,
) )
if _answer_requires_language_repair(question, answer): try:
logger.warning( model_result = _call_qwen(
"chat response language mismatch avatar=%s source=%s expected=%s", messages=messages,
avatar.id, temperature=temperature,
usage_source, model_config=model_config,
_turn_language_name(question),
) )
answer, token_usage = _call_billed_qwen( answer = model_result["answer"]
token_usage = settle_reservation(
db, db,
avatar, reservation,
_language_repair_messages(question, answer), model_result.get("usage"),
0.0, fallback_total=estimate_fallback_usage(messages, answer),
f"{usage_source}_language_repair",
model_config,
) )
except Exception as exc:
release_reservation(db, reservation, str(exc))
raise
answer = str(answer or "").strip() answer = str(answer or "").strip()
if image_contexts and _answer_denies_available_image(answer): if image_contexts and _answer_denies_available_image(answer):
logger.warning( logger.warning(
@@ -2,6 +2,7 @@ import hashlib
import hmac import hmac
import json import json
import os import os
import time
import uuid import uuid
from datetime import datetime from datetime import datetime
from decimal import Decimal, InvalidOperation, ROUND_HALF_UP from decimal import Decimal, InvalidOperation, ROUND_HALF_UP
@@ -753,6 +754,83 @@ async def payment_callback(
return ok({"received": True, "paid": True}) return ok({"received": True, "paid": True})
@router.post("/token/payment/huihui/virtual/notify")
async def huihui_virtual_payment_notify(request: Request, db: Session = Depends(get_db)):
"""Server-to-server virtual-payment notice using the Huihui HMAC contract."""
secret = os.getenv("HUIHUI_VIRTUAL_CALLBACK_SECRET", "").strip()
if len(secret) < 32:
raise HTTPException(status_code=503, detail="virtual payment callback is not configured")
timestamp = request.headers.get("x-huihui-timestamp", "").strip()
signature = request.headers.get("x-huihui-signature", "").strip().lower()
if not timestamp.isascii() or not timestamp.isdecimal() or len(timestamp) > 12:
raise HTTPException(status_code=401, detail="invalid callback signature")
try:
issued_at = int(timestamp)
except ValueError:
raise HTTPException(status_code=401, detail="invalid callback signature")
if abs(int(time.time()) - issued_at) > 300:
raise HTTPException(status_code=401, detail="callback timestamp expired")
body = await request.body()
if not body or len(body) > 65536:
raise HTTPException(status_code=400, detail="invalid callback body")
expected = hmac.new(
secret.encode("utf-8"), timestamp.encode("ascii") + b"\n" + body, hashlib.sha256
).hexdigest()
if not hmac.compare_digest(signature, expected):
raise HTTPException(status_code=401, detail="invalid callback signature")
try:
payload = json.loads(body)
except (UnicodeDecodeError, ValueError):
raise HTTPException(status_code=400, detail="invalid callback JSON")
if not isinstance(payload, dict):
raise HTTPException(status_code=400, detail="invalid callback JSON")
order_no = str(payload.get("orderNo") or "").strip()
payment_no = str(payload.get("paymentNo") or "").strip()
status = str(payload.get("status") or "").upper()
if not order_no or not payment_no or status not in {"SUCCESS", "FAILED"}:
raise HTTPException(status_code=400, detail="missing callback fields")
order = db.query(TokenPaymentOrder).filter(TokenPaymentOrder.order_no == order_no).first()
if not order or order.provider != "wechat_virtual":
raise HTTPException(status_code=404, detail="virtual payment order not found")
if status == "FAILED":
return ok({"received": True, "paid": False})
user = db.query(User).filter(User.id == order.user_id).first()
plan = db.query(TokenPlan).filter(TokenPlan.id == order.plan_id).first()
try:
expected_product_id = product_id_for_plan(plan) if plan else ""
amount_cents = int(payload.get("amountCents"))
callback_env = int(payload.get("env"))
except (TypeError, ValueError, WechatVirtualPaymentError):
raise HTTPException(status_code=422, detail="invalid virtual payment details")
if (
isinstance(payload.get("amountCents"), bool)
or isinstance(payload.get("env"), bool)
or not user
or not user.wechat_mp_openid
or str(payload.get("openId") or "") != user.wechat_mp_openid
or not expected_product_id
or str(payload.get("productId") or "") != expected_product_id
or amount_cents != order.price_cents
or callback_env != wechat_virtual_env()
):
raise HTTPException(status_code=422, detail="virtual payment details mismatch")
existing_payment = db.query(PaymentTransaction).filter(
PaymentTransaction.provider == "wechat_virtual",
PaymentTransaction.event_type == "payment",
PaymentTransaction.transaction_no == payment_no,
).first()
if existing_payment and existing_payment.order_no != order_no:
raise HTTPException(status_code=409, detail="payment number belongs to another order")
if order.status in {"paid", "refunded"}:
return ok({"received": True, "duplicate": True})
_settle_paid_order(db, order, provider_status="HUIHUI_SUCCESS", transaction_no=payment_no)
db.commit()
return ok({"received": True, "paid": True})
def _wechat_notify_response(request: Request, *, success: bool, message: str = ""): def _wechat_notify_response(request: Request, *, success: bool, message: str = ""):
code = 0 if success else 1 code = 0 if success else 1
text = "success" if success else (message or "fail")[:200].replace("]]>", "") text = "success" if success else (message or "fail")[:200].replace("]]>", "")
@@ -6,7 +6,6 @@ from fastapi import HTTPException
from models import Avatar, User from models import Avatar, User
from routers.chat import ( from routers.chat import (
_answer_requires_language_repair,
_build_prompt, _build_prompt,
_iter_text_chunks, _iter_text_chunks,
_match_standard_qa, _match_standard_qa,
@@ -15,7 +14,6 @@ from routers.chat import (
_qa_requires_per_turn_rendering, _qa_requires_per_turn_rendering,
_require_owned_avatar, _require_owned_avatar,
_resolve_reply, _resolve_reply,
_turn_language_name,
) )
@@ -109,8 +107,8 @@ class ChatOrchestrationTests(unittest.TestCase):
messages = fake_model.call_args.kwargs["messages"] messages = fake_model.call_args.kwargs["messages"]
self.assertEqual(messages[-1], {"role": "user", "content": "Quelle est votre adresse ?"}) self.assertEqual(messages[-1], {"role": "user", "content": "Quelle est votre adresse ?"})
self.assertEqual(messages[-2]["role"], "system") self.assertEqual(messages[-2]["role"], "system")
self.assertIn("MANDATORY OUTPUT LANGUAGE", messages[-2]["content"]) self.assertIn("本轮语言覆盖指令", messages[-2]["content"])
self.assertIn("French", messages[-2]["content"]) self.assertIn("不要沿用上一轮语言", messages[-2]["content"])
def test_latest_user_message_has_an_adjacent_language_override(self): def test_latest_user_message_has_an_adjacent_language_override(self):
history = [ 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[-1], {"role": "user", "content": "What can you help me with?"})
self.assertEqual(messages[-2]["role"], "system") self.assertEqual(messages[-2]["role"], "system")
self.assertIn("MANDATORY OUTPUT LANGUAGE", messages[-2]["content"]) self.assertIn("最新用户消息", messages[-2]["content"])
self.assertIn("English", messages[-2]["content"]) self.assertIn("立即切换到相同语言", 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, "建议尽快就医评估。"))
def test_conversational_paraphrase_matches_standard_qa(self): def test_conversational_paraphrase_matches_standard_qa(self):
for question in ("请问一下,你们公司在哪里呀?", "请问去你们那边怎么走"): for question in ("请问一下,你们公司在哪里呀?", "请问去你们那边怎么走"):
@@ -1,6 +1,8 @@
import hashlib import hashlib
import hmac
import json import json
import os import os
import time
from unittest.mock import patch from unittest.mock import patch
from fastapi.testclient import TestClient from fastapi.testclient import TestClient
@@ -14,6 +16,69 @@ from services.token_billing import DEFAULT_TOKEN_GRANT
client = TestClient(app) client = TestClient(app)
def _huihui_virtual_headers(secret, body, timestamp=None):
timestamp = str(timestamp if timestamp is not None else int(time.time()))
signature = hmac.new(secret.encode(), timestamp.encode() + b"\n" + body, hashlib.sha256).hexdigest()
return {"X-Huihui-Timestamp": timestamp, "X-Huihui-Signature": signature}
def test_huihui_virtual_callback_requires_signature_and_exact_order_details(authorization_context):
seed()
context = authorization_context
db = SessionLocal()
try:
user = db.query(User).filter(User.id == context["owner"].id).one()
user.wechat_mp_openid = "openid-huihui-callback"
user.wechat_mp_session_key = "session-huihui-callback"
plan = db.query(TokenPlan).filter(TokenPlan.id == "1").one()
plan.virtual_product_id = "points_plan_1"
db.commit()
finally:
db.close()
secret = "huihui-virtual-callback-test-secret-123456"
env = {
"WECHAT_VIRTUAL_ENV": "sandbox",
"WECHAT_VIRTUAL_SANDBOX_APP_KEY": "sandbox-key",
"WECHAT_VIRTUAL_OFFER_ID": "offer-1",
"HUIHUI_VIRTUAL_CALLBACK_SECRET": secret,
}
with patch.dict(os.environ, env):
created = client.post(
"/api/token/charge",
headers=context["owner_headers"],
json={"planId": "1", "paymentMethod": "wechat", "payScene": "LITE"},
).json()["data"]
payload = {
"orderNo": created["orderNo"],
"paymentNo": f"huihui-virtual-payment-{context['suffix']}",
"status": "SUCCESS",
"amountCents": 1000,
"productId": "points_plan_1",
"openId": "openid-huihui-callback",
"env": 1,
}
path = "/api/token/payment/huihui/virtual/notify"
body = json.dumps(payload, separators=(",", ":")).encode()
assert client.post(path, content=body, headers=_huihui_virtual_headers(secret, body, 1)).status_code == 401
assert client.post(path, content=body, headers={"X-Huihui-Timestamp": str(int(time.time())), "X-Huihui-Signature": "bad"}).status_code == 401
wrong = json.dumps({**payload, "amountCents": 999}, separators=(",", ":")).encode()
assert client.post(path, content=wrong, headers=_huihui_virtual_headers(secret, wrong)).status_code == 422
first = client.post(path, content=body, headers=_huihui_virtual_headers(secret, body))
second = client.post(path, content=body, headers=_huihui_virtual_headers(secret, body))
assert first.json()["data"] == {"received": True, "paid": True}
assert second.json()["data"] == {"received": True, "duplicate": True}
db = SessionLocal()
try:
order = db.query(TokenPaymentOrder).filter(TokenPaymentOrder.order_no == created["orderNo"]).one()
account = db.query(TokenAccount).filter(TokenAccount.user_id == context["owner"].id).one()
assert order.status == "paid"
assert account.balance == DEFAULT_TOKEN_GRANT + 2_000_000
finally:
db.close()
def _signature(token, timestamp, nonce): def _signature(token, timestamp, nonce):
return hashlib.sha1("".join(sorted([token, timestamp, nonce])).encode()).hexdigest() return hashlib.sha1("".join(sorted([token, timestamp, nonce])).encode()).hexdigest()
@@ -94,6 +94,8 @@ App 与 H5 积分充值使用会会支付体系的 `payment-v3/payment/pay`,
微信小程序使用微信虚拟支付:小程序先通过 `POST /api/token/wechat/session` 交换临时登录码,再由 `POST /api/token/charge`(`payScene=LITE`)返回已签名的 `requestVirtualPayment` 参数。微信回调地址配置为 `https://digital.99hui.com/api/token/payment/wechat/virtual/notify`。回调会复核签名、OpenID、环境、商品 ID 与实付金额,退款回调确认后才扣回积分。AppKey、AppSecret、session_key 均不得下发前端或写日志。 微信小程序使用微信虚拟支付:小程序先通过 `POST /api/token/wechat/session` 交换临时登录码,再由 `POST /api/token/charge`(`payScene=LITE`)返回已签名的 `requestVirtualPayment` 参数。微信回调地址配置为 `https://digital.99hui.com/api/token/payment/wechat/virtual/notify`。回调会复核签名、OpenID、环境、商品 ID 与实付金额,退款回调确认后才扣回积分。AppKey、AppSecret、session_key 均不得下发前端或写日志。
如由会会服务端转发微信小程序虚拟支付结果,另有待双方确认的固定回调契约,见 [HUIHUI_VIRTUAL_CALLBACK.md](HUIHUI_VIRTUAL_CALLBACK.md)。该接口使用独立的 `HUIHUI_VIRTUAL_CALLBACK_SECRET`;即使接口代码部署到生产,在协议确认和联调验收前也不得将其视作已上线的支付渠道。
管理后台需要配置相同的 `AVATAR_FINANCE_ADMIN_SECRET` 和 `AVATAR_BACKEND_URL=https://digital.99hui.com`。退款只支持整单原路退款;供应商受理后显示“处理中”,收到渠道成功回调(或经渠道后台核对后人工确认)才将订单置为已退款。已消费掉本订单积分时,后台会拒绝主动退款;若渠道外部退款先发生,积分账户允许形成负数以记录欠额并阻止继续消费。 管理后台需要配置相同的 `AVATAR_FINANCE_ADMIN_SECRET` 和 `AVATAR_BACKEND_URL=https://digital.99hui.com`。退款只支持整单原路退款;供应商受理后显示“处理中”,收到渠道成功回调(或经渠道后台核对后人工确认)才将订单置为已退款。已消费掉本订单积分时,后台会拒绝主动退款;若渠道外部退款先发生,积分账户允许形成负数以记录欠额并阻止继续消费。
## 3. 构建与发布 ## 3. 构建与发布
@@ -0,0 +1,32 @@
# 会会微信小程序虚拟支付回调(待双方确认)
本项目为会会服务端预留固定回调地址:
`POST https://digital.99hui.com/api/token/payment/huihui/virtual/notify`
回调报文及签名协议尚未与会会确认。以下是本项目实现的拟议对接契约,双方确认并安全交换密钥后才能进行测试支付。接口代码部署与会会联调通过是两个独立状态。它不替代现有微信直连回调 `/api/token/payment/wechat/virtual/notify`。
请求体为 JSON,成功支付示例:
```json
{
"orderNo": "AV202610150000000000000001",
"paymentNo": "会会侧唯一支付流水号",
"status": "SUCCESS",
"amountCents": 1000,
"productId": "points_plan_1",
"openId": "微信小程序用户 OpenID",
"env": 1
}
```
`amountCents` 单位为分,`env` 为微信虚拟支付环境(0 正式、1 沙箱)。非成功结果可发送 `status: "FAILED"`,但不会入账。订单号、支付流水号和状态必填;成功结果还必须提供金额、商品 ID、OpenID、环境。仅在订单属于微信虚拟支付,且上述信息与本地订单、套餐、用户一致时入账。重复成功通知不会重复发放积分;同一支付流水号不能用于其他订单。
请求头:
- `X-Huihui-Timestamp`:Unix 秒级时间戳,允许与本服务时间相差不超过 300 秒。
- `X-Huihui-Signature`:小写十六进制 HMAC-SHA256,密钥为双方线下安全交换、服务端配置的 `HUIHUI_VIRTUAL_CALLBACK_SECRET`(至少 32 字符),待签名数据为 `时间戳 + 换行符 + 原始请求体字节`。不要重排 JSON 后签名,也不要把密钥发给前端。
成功接收返回 HTTP 200,`{"code":200,"message":"success","data":{"received":true,"paid":true}}`。重复通知返回 `duplicate: true`。验签、字段、订单或金额不符返回 HTTP 4xx,会会可按其重试策略处理。服务端未配置密钥时返回 HTTP 503。
上线前需由会会确认:其服务端是否能按此格式签名并发送通知、支付成功状态与支付流水号字段的正式定义、失败通知及重试/超时策略。小程序支付发起和退款仍需独立对接和验收;仅完成此回调不代表虚拟支付全流程已可用。
+2 -117
View File
@@ -1,75 +1,10 @@
<template> <template>
<div id="app" :class="{ 'embedded-shell': isInUniAppWebView }"> <div id="app">
<router-view /> <router-view />
<!-- 底部导航栏 --> <!-- 底部菜单暂不展示,多分身能力统一从分身卡片进入。 -->
<nav class="bottom-nav" v-if="showNav">
<button
class="nav-item"
:class="{ active: currentRoute === '/' || currentRoute === '/avatar/manage' }"
@click="navigateTo('/avatar/manage')"
>
<span class="nav-icon">🤖</span>
<span class="nav-label">我的分身</span>
</button>
<button
class="nav-item"
:class="{ active: currentRoute === '/authorization' }"
@click="navigateTo('/authorization')"
>
<span class="nav-icon">🔑</span>
<span class="nav-label">授权管理</span>
</button>
<button
v-if="isInUniAppWebView"
class="nav-item"
:class="{ active: currentRoute === '/token/charge' }"
@click="navigateTo('/token/charge')"
>
<span class="nav-icon">💰</span>
<span class="nav-label">充值购买</span>
</button>
</nav>
</div> </div>
</template> </template>
<script setup lang="ts">
import { ref, onMounted, watch } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { isInUniWebView } from '@/utils/uniapp-bridge'
const router = useRouter()
const route = useRoute()
const isInUniAppWebView = isInUniWebView()
const currentRoute = ref<string>(route.path)
const showNav = ref<boolean>(shouldShowNav(route.path))
function shouldShowNav(path: string) {
return path !== '/'
&& path !== '/avatar/create'
&& path !== '/login/sms'
&& !path.startsWith('/avatar/edit')
&& !path.startsWith('/avatar/chat')
&& !path.startsWith('/share/')
}
// 监听路由变化
watch(() => route.path, (newPath) => {
currentRoute.value = newPath
showNav.value = shouldShowNav(newPath)
})
// 导航
const navigateTo = (path: string) => {
router.push(path)
}
onMounted(() => {
currentRoute.value = route.path
showNav.value = shouldShowNav(route.path)
})
</script>
<style> <style>
* { * {
margin: 0; margin: 0;
@@ -83,54 +18,4 @@ onMounted(() => {
background: #F8F9FA; background: #F8F9FA;
padding-bottom: env(safe-area-inset-bottom); padding-bottom: env(safe-area-inset-bottom);
} }
/* 原生 App / 微信小程序容器已经提供了自己的导航栏,H5 不再重复显示页头。 */
.embedded-shell .page-header,
.embedded-shell .chat-header {
display: none;
}
/* 底部导航栏 */
.bottom-nav {
position: fixed;
bottom: 0;
left: 0;
right: 0;
display: flex;
background: white;
border-top: 1px solid #EDEEF1;
padding-bottom: env(safe-area-inset-bottom);
z-index: 100;
}
.nav-item {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
padding: 8px 0;
background: none;
border: none;
cursor: pointer;
transition: color 0.2s;
}
.nav-icon {
font-size: 20px;
}
.nav-label {
font-size: 11px;
color: #9398AE;
font-weight: 500;
}
.nav-item.active .nav-label {
color: #F97316;
}
.nav-item.active .nav-icon {
filter: none;
}
</style> </style>
+1 -6
View File
@@ -1,7 +1,6 @@
import { createRouter, createWebHashHistory } from 'vue-router' import { createRouter, createWebHashHistory } from 'vue-router'
import type { RouteRecordRaw } from 'vue-router' import type { RouteRecordRaw } from 'vue-router'
import { getAuthToken } from '@/api' import { getAuthToken } from '@/api'
import { isInUniWebView } from '@/utils/uniapp-bridge'
const routes: RouteRecordRaw[] = [ const routes: RouteRecordRaw[] = [
{ {
@@ -56,7 +55,7 @@ const routes: RouteRecordRaw[] = [
path: '/token/charge', path: '/token/charge',
name: 'TokenCharge', name: 'TokenCharge',
component: () => import('@/views/TokenCharge.vue'), component: () => import('@/views/TokenCharge.vue'),
meta: { title: '积分充值', requiresAuth: true, requiresUniWebView: true } meta: { title: '积分充值', requiresAuth: true }
}, },
{ {
path: '/avatar/card', path: '/avatar/card',
@@ -134,10 +133,6 @@ const router = createRouter({
router.beforeEach((to, from, next) => { router.beforeEach((to, from, next) => {
document.title = to.meta.title as string || '会会数字分身' document.title = to.meta.title as string || '会会数字分身'
if (to.meta.requiresUniWebView && !isInUniWebView()) {
next({ path: '/avatar/manage' })
return
}
const hasLocalSession = Boolean(localStorage.getItem('hh_app_token')) const hasLocalSession = Boolean(localStorage.getItem('hh_app_token'))
const hasInjectedSession = Boolean(getAuthToken()) const hasInjectedSession = Boolean(getAuthToken())
if (to.meta.requiresAuth && !hasLocalSession && !hasInjectedSession) { if (to.meta.requiresAuth && !hasLocalSession && !hasInjectedSession) {
+2 -16
View File
@@ -12,10 +12,9 @@ export interface UniLaunchParams {
nickname?: string nickname?: string
avatar?: string avatar?: string
ts?: string ts?: string
nativeShell?: string
} }
const PARAM_KEYS: (keyof UniLaunchParams)[] = ['token', 'userId', 'nickname', 'avatar', 'ts', 'nativeShell'] const PARAM_KEYS: (keyof UniLaunchParams)[] = ['token', 'userId', 'nickname', 'avatar', 'ts']
function readParams(search: string, target: UniLaunchParams): void { function readParams(search: string, target: UniLaunchParams): void {
const sp = new URLSearchParams(search) const sp = new URLSearchParams(search)
@@ -25,16 +24,6 @@ function readParams(search: string, target: UniLaunchParams): void {
} }
} }
function hasNativeShellMarker(): boolean {
const params = getLaunchParams()
if (params.nativeShell === 'uniapp') return true
// Compatibility for already-installed shells. They have always appended a
// timestamp together with the native SSO context, even before the explicit
// nativeShell marker existed.
return Boolean(params.ts && (params.token || params.userId))
}
// 是否运行在 uniapp web-view 环境中 // 是否运行在 uniapp web-view 环境中
export function isInUniWebView(): boolean { export function isInUniWebView(): boolean {
const runtime = window as any const runtime = window as any
@@ -51,10 +40,7 @@ export function isInUniWebView(): boolean {
runtime.swan?.webView || runtime.swan?.webView ||
runtime.tt?.miniProgram runtime.tt?.miniProgram
) )
// `plus` can be injected after the H5 entry point runs. The native shell return Boolean(runtime.uni?.webView && (isDCloudApp || isMiniProgram))
// therefore adds a URL marker while creating its web-view URL, so the
// payment entry does not disappear during that startup window.
return Boolean(runtime.uni?.webView && (isDCloudApp || isMiniProgram || hasNativeShellMarker()))
} }
// 解析 web-view 加载 URL 时原生注入的参数(token / 会会用户) // 解析 web-view 加载 URL 时原生注入的参数(token / 会会用户)
File diff suppressed because it is too large Load Diff
+3 -2
View File
@@ -54,7 +54,7 @@
</template> </template>
<template v-else>{{ message.content }}</template> <template v-else>{{ message.content }}</template>
</div> </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) }} {{ sourceLabel(message.source) }}
<span v-if="message.references?.length"> · {{ message.references.map((item) => item.filename).filter(Boolean).join('、') }}</span> <span v-if="message.references?.length"> · {{ message.references.map((item) => item.filename).filter(Boolean).join('、') }}</span>
</div> </div>
@@ -174,7 +174,8 @@ const sourceLabels: Record<NonNullable<DisplayMessage['source']>, string> = {
qa: '标准问答对', qa: '标准问答对',
knowledge: '参考文件知识库', knowledge: '参考文件知识库',
vision: '图片理解', vision: '图片理解',
qwen: '智能回答', // qwen 来源不再在聊天气泡下显示“智能回答/知能回答”标注。
qwen: '',
public: '' public: ''
} }
const sourceLabel = (source?: DisplayMessage['source']) => source ? sourceLabels[source] : '' const sourceLabel = (source?: DisplayMessage['source']) => source ? sourceLabels[source] : ''
@@ -20,7 +20,7 @@
</div> </div>
</section> </section>
<!-- 积分余额条:仅在 uni-app 原生壳内开放充值购买。 --> <!-- 积分余额条:暂时隐藏,保留完整实现便于后续恢复。 -->
<section v-if="SHOW_POINTS_BALANCE_CARD" class="token-section"> <section v-if="SHOW_POINTS_BALANCE_CARD" class="token-section">
<div class="token-card"> <div class="token-card">
<div class="token-info"> <div class="token-info">
@@ -28,7 +28,7 @@
<span class="token-amount">{{ tokenBalance.toLocaleString() }}</span> <span class="token-amount">{{ tokenBalance.toLocaleString() }}</span>
<span class="token-used">累计使用 {{ tokenConsumed.toLocaleString() }}</span> <span class="token-used">累计使用 {{ tokenConsumed.toLocaleString() }}</span>
</div> </div>
<button class="recharge-btn" @click="goToRecharge">充值购买</button> <button class="recharge-btn" @click="goToRecharge">充值</button>
</div> </div>
</section> </section>
@@ -89,15 +89,14 @@ import { useAvatarStore } from '@/store/avatar'
import { useUserStore } from '@/store/user' import { useUserStore } from '@/store/user'
import { createAvatarShareLink } from '@/api' import { createAvatarShareLink } from '@/api'
import { isHuihuiEmbeddedMode } from '@/utils/embed-mode' import { isHuihuiEmbeddedMode } from '@/utils/embed-mode'
import { isInUniWebView } from '@/utils/uniapp-bridge'
const router = useRouter() const router = useRouter()
const avatarStore = useAvatarStore() const avatarStore = useAvatarStore()
const userStore = useUserStore() const userStore = useUserStore()
const isEmbedded = isHuihuiEmbeddedMode() const isEmbedded = isHuihuiEmbeddedMode()
// 充值购买只在 uni-app 原生壳内提供,避免普通 H5 进入支付链路。 // 临时产品开关:余额卡片代码保留,后续改为 true 即可恢复展示。
const SHOW_POINTS_BALANCE_CARD = isInUniWebView() const SHOW_POINTS_BALANCE_CARD = false
// 当前登录会会用户的资料(头像 / 昵称) // 当前登录会会用户的资料(头像 / 昵称)
const me = computed(() => userStore.user) const me = computed(() => userStore.user)
-6
View File
@@ -13,12 +13,6 @@ import { requestAppPayment } from '@/utils/payment'
const userStore = useUserStore() const userStore = useUserStore()
const webview = ref(null) const webview = ref(null)
const H5_BASE_URL = import.meta.env.VITE_AVATAR_H5_URL || 'https://digital.99hui.com/' const H5_BASE_URL = import.meta.env.VITE_AVATAR_H5_URL || 'https://digital.99hui.com/'
// Restore the native shell session before constructing the H5 URL. Without
// this, the web-view opens as an anonymous H5 page and cannot enter the
// authenticated avatar-management route.
userStore.init()
const h5Url = computed(() => buildH5Url(H5_BASE_URL, userStore.$state)) const h5Url = computed(() => buildH5Url(H5_BASE_URL, userStore.$state))
function runtimeSurface() { function runtimeSurface() {
-3
View File
@@ -1,8 +1,5 @@
export function buildH5Url(base, session) { export function buildH5Url(base, session) {
const url = new URL(base) const url = new URL(base)
// This is deliberately explicit instead of relying on the timing of the
// H5+ `plus` injection inside the embedded page.
url.searchParams.set('nativeShell', 'uniapp')
if (session.token) url.searchParams.set('token', session.token) if (session.token) url.searchParams.set('token', session.token)
if (session.userId) url.searchParams.set('userId', session.userId) if (session.userId) url.searchParams.set('userId', session.userId)
if (session.nickname) url.searchParams.set('nickname', session.nickname) if (session.nickname) url.searchParams.set('nickname', session.nickname)