Compare commits
18
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e5df72956d | ||
|
|
48f3b9bddf | ||
|
|
2122615725 | ||
|
|
cd0c6e3162 | ||
|
|
b48e33fc6b | ||
|
|
d9f12685fa | ||
|
|
b3367eedaa | ||
|
|
e6a4988cf9 | ||
|
|
becf2c7c52 | ||
|
|
d421a9da72 | ||
|
|
f4389612d1 | ||
|
|
ac5a332bce | ||
|
|
9ba89ef2d1 | ||
|
|
b1023eb783 | ||
|
|
2f287ef538 | ||
|
|
f5cbbe9eef | ||
|
|
5fc56143ee | ||
|
|
d521585bf2 |
@@ -79,34 +79,6 @@ _WRITING_SYSTEM_PATTERNS = {
|
||||
_JAPANESE_KANA = re.compile(r"[\u3040-\u30ff]")
|
||||
_KOREAN_HANGUL = re.compile(r"[\uac00-\ud7af\u1100-\u11ff]")
|
||||
|
||||
_LATIN_LANGUAGE_MARKERS = {
|
||||
"English": re.compile(
|
||||
r"\b(?:i|you|we|they|he|she|have|has|had|friend|who|what|where|when|why|how|"
|
||||
r"symptoms?|disease|please|can|could|would|should|is|are|was|were|the|this|that)\b",
|
||||
re.IGNORECASE,
|
||||
),
|
||||
"French": re.compile(
|
||||
r"\b(?:je|tu|vous|nous|ils|elle|une|des|avec|pour|pourquoi|comment|bonjour|est|sont)\b",
|
||||
re.IGNORECASE,
|
||||
),
|
||||
"Spanish": re.compile(
|
||||
r"\b(?:yo|tu|usted|nosotros|ellos|ella|una|con|para|por que|como|hola|esta|son)\b",
|
||||
re.IGNORECASE,
|
||||
),
|
||||
"German": re.compile(
|
||||
r"\b(?:ich|du|sie|wir|eine|mit|fur|warum|wie|hallo|ist|sind|haben)\b",
|
||||
re.IGNORECASE,
|
||||
),
|
||||
"Portuguese": re.compile(
|
||||
r"\b(?:eu|voce|nos|eles|ela|uma|com|para|porque|como|ola|esta|sao|tenho)\b",
|
||||
re.IGNORECASE,
|
||||
),
|
||||
"Italian": re.compile(
|
||||
r"\b(?:io|tu|voi|noi|loro|una|con|per|perche|come|ciao|sono|avere)\b",
|
||||
re.IGNORECASE,
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
class ChatMessage(BaseModel):
|
||||
model_config = ConfigDict(populate_by_name=True)
|
||||
@@ -515,67 +487,15 @@ def _qa_requires_per_turn_rendering(
|
||||
return bool(history) or _qa_requires_language_adaptation(question, answer)
|
||||
|
||||
|
||||
def _latin_language_name(value: str) -> str:
|
||||
scores = {
|
||||
language: len(pattern.findall(value or ""))
|
||||
for language, pattern in _LATIN_LANGUAGE_MARKERS.items()
|
||||
}
|
||||
language, score = max(scores.items(), key=lambda item: item[1])
|
||||
return language if score else "the same natural language as the latest user message"
|
||||
|
||||
|
||||
def _turn_language_name(value: str) -> str:
|
||||
writing_system = _dominant_writing_system(value)
|
||||
return {
|
||||
"han": "Chinese",
|
||||
"japanese": "Japanese",
|
||||
"korean": "Korean",
|
||||
"cyrillic": "the same Cyrillic-script language as the latest user message",
|
||||
"arabic": "the same Arabic-script language as the latest user message",
|
||||
"hebrew": "Hebrew",
|
||||
"devanagari": "the same Devanagari-script language as the latest user message",
|
||||
"thai": "Thai",
|
||||
"greek": "Greek",
|
||||
"latin": _latin_language_name(value),
|
||||
}.get(writing_system, "the same natural language as the latest user message")
|
||||
|
||||
|
||||
def _per_turn_language_instruction(question: str = "") -> str:
|
||||
language = _turn_language_name(question)
|
||||
def _per_turn_language_instruction() -> str:
|
||||
return (
|
||||
f"MANDATORY OUTPUT LANGUAGE FOR THIS TURN: {language}. "
|
||||
"Write the entire answer only in that language. This instruction overrides the languages used by "
|
||||
"conversation history, profile data, standard answers, retrieved documents, and custom prompts. "
|
||||
"Translate grounded source material faithfully when necessary. Do not mention language detection, "
|
||||
"translation, or this instruction."
|
||||
"本轮语言覆盖指令:只根据紧随其后的最新用户消息判断本轮回答语言。"
|
||||
"即使此前整段对话一直使用另一种语言,只要最新消息切换了语言,本轮就必须立即切换到相同语言;"
|
||||
"不要沿用上一轮语言。若最新消息明确指定回答语言,以该指定为准;若混用多种语言,使用其中占主导的"
|
||||
"自然语言。不要说明你检测、切换或翻译了语言。"
|
||||
)
|
||||
|
||||
|
||||
def _answer_requires_language_repair(question: str, answer: str) -> bool:
|
||||
question_system = _dominant_writing_system(question)
|
||||
answer_system = _dominant_writing_system(answer)
|
||||
return (
|
||||
question_system != "unknown"
|
||||
and answer_system != "unknown"
|
||||
and question_system != answer_system
|
||||
)
|
||||
|
||||
|
||||
def _language_repair_messages(question: str, answer: str) -> list[dict]:
|
||||
return [
|
||||
{"role": "system", "content": _per_turn_language_instruction(question)},
|
||||
{
|
||||
"role": "system",
|
||||
"content": (
|
||||
"Rewrite the supplied draft in the mandatory output language. Preserve every grounded fact, "
|
||||
"number, proper noun, uncertainty, and safety qualification. Add no new information and output "
|
||||
"only the rewritten answer."
|
||||
),
|
||||
},
|
||||
{"role": "user", "content": answer.strip()},
|
||||
]
|
||||
|
||||
|
||||
def _canonicalize_question(value: str) -> str:
|
||||
value = _normalize_question(value)
|
||||
replacements = (
|
||||
@@ -808,7 +728,7 @@ def _build_prompt(
|
||||
for item in history[-MAX_HISTORY_MESSAGES:]:
|
||||
messages.append({"role": item.role, "content": item.content} if hasattr(item, "role") else item)
|
||||
# Keep the language instruction adjacent to the current turn so long histories cannot override it.
|
||||
messages.append({"role": "system", "content": _per_turn_language_instruction(question)})
|
||||
messages.append({"role": "system", "content": _per_turn_language_instruction()})
|
||||
messages.append({"role": "user", "content": question.strip()})
|
||||
return messages
|
||||
|
||||
@@ -871,41 +791,6 @@ def _call_qwen(
|
||||
return {"answer": answer.strip(), "usage": data.get("usage") or {}}
|
||||
|
||||
|
||||
def _call_billed_qwen(
|
||||
db: Session,
|
||||
avatar: Avatar,
|
||||
messages: list[dict],
|
||||
temperature: float,
|
||||
usage_source: str,
|
||||
model_config: ChatModelConfig,
|
||||
) -> tuple[str, dict]:
|
||||
reservation = reserve_avatar_tokens(
|
||||
db,
|
||||
avatar,
|
||||
usage_source,
|
||||
model_config.model,
|
||||
messages,
|
||||
model_config.max_tokens,
|
||||
)
|
||||
try:
|
||||
model_result = _call_qwen(
|
||||
messages=messages,
|
||||
temperature=temperature,
|
||||
model_config=model_config,
|
||||
)
|
||||
answer = model_result["answer"]
|
||||
token_usage = settle_reservation(
|
||||
db,
|
||||
reservation,
|
||||
model_result.get("usage"),
|
||||
fallback_total=estimate_fallback_usage(messages, answer),
|
||||
)
|
||||
return answer, token_usage
|
||||
except Exception as exc:
|
||||
release_reservation(db, reservation, str(exc))
|
||||
raise
|
||||
|
||||
|
||||
def _iter_qwen_stream(
|
||||
messages: list[dict], temperature: float, model_config: ChatModelConfig | None = None
|
||||
):
|
||||
@@ -1014,36 +899,32 @@ def _resolve_reply(
|
||||
token_usage = None
|
||||
if model_client is not None:
|
||||
answer = model_client(messages=messages, temperature=temperature)
|
||||
if _answer_requires_language_repair(question, str(answer or "")):
|
||||
answer = model_client(
|
||||
messages=_language_repair_messages(question, str(answer)),
|
||||
temperature=0.0,
|
||||
)
|
||||
else:
|
||||
model_config = get_chat_model_config()
|
||||
answer, token_usage = _call_billed_qwen(
|
||||
reservation = reserve_avatar_tokens(
|
||||
db,
|
||||
avatar,
|
||||
usage_source,
|
||||
model_config.model,
|
||||
messages,
|
||||
temperature,
|
||||
usage_source,
|
||||
model_config,
|
||||
model_config.max_tokens,
|
||||
)
|
||||
if _answer_requires_language_repair(question, answer):
|
||||
logger.warning(
|
||||
"chat response language mismatch avatar=%s source=%s expected=%s",
|
||||
avatar.id,
|
||||
usage_source,
|
||||
_turn_language_name(question),
|
||||
try:
|
||||
model_result = _call_qwen(
|
||||
messages=messages,
|
||||
temperature=temperature,
|
||||
model_config=model_config,
|
||||
)
|
||||
answer, token_usage = _call_billed_qwen(
|
||||
answer = model_result["answer"]
|
||||
token_usage = settle_reservation(
|
||||
db,
|
||||
avatar,
|
||||
_language_repair_messages(question, answer),
|
||||
0.0,
|
||||
f"{usage_source}_language_repair",
|
||||
model_config,
|
||||
reservation,
|
||||
model_result.get("usage"),
|
||||
fallback_total=estimate_fallback_usage(messages, answer),
|
||||
)
|
||||
except Exception as exc:
|
||||
release_reservation(db, reservation, str(exc))
|
||||
raise
|
||||
answer = str(answer or "").strip()
|
||||
if image_contexts and _answer_denies_available_image(answer):
|
||||
logger.warning(
|
||||
|
||||
@@ -2,6 +2,7 @@ import hashlib
|
||||
import hmac
|
||||
import json
|
||||
import os
|
||||
import time
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from decimal import Decimal, InvalidOperation, ROUND_HALF_UP
|
||||
@@ -753,6 +754,83 @@ async def payment_callback(
|
||||
return ok({"received": True, "paid": True})
|
||||
|
||||
|
||||
@router.post("/token/payment/huihui/virtual/notify")
|
||||
async def huihui_virtual_payment_notify(request: Request, db: Session = Depends(get_db)):
|
||||
"""Server-to-server virtual-payment notice using the Huihui HMAC contract."""
|
||||
secret = os.getenv("HUIHUI_VIRTUAL_CALLBACK_SECRET", "").strip()
|
||||
if len(secret) < 32:
|
||||
raise HTTPException(status_code=503, detail="virtual payment callback is not configured")
|
||||
timestamp = request.headers.get("x-huihui-timestamp", "").strip()
|
||||
signature = request.headers.get("x-huihui-signature", "").strip().lower()
|
||||
if not timestamp.isascii() or not timestamp.isdecimal() or len(timestamp) > 12:
|
||||
raise HTTPException(status_code=401, detail="invalid callback signature")
|
||||
try:
|
||||
issued_at = int(timestamp)
|
||||
except ValueError:
|
||||
raise HTTPException(status_code=401, detail="invalid callback signature")
|
||||
if abs(int(time.time()) - issued_at) > 300:
|
||||
raise HTTPException(status_code=401, detail="callback timestamp expired")
|
||||
body = await request.body()
|
||||
if not body or len(body) > 65536:
|
||||
raise HTTPException(status_code=400, detail="invalid callback body")
|
||||
expected = hmac.new(
|
||||
secret.encode("utf-8"), timestamp.encode("ascii") + b"\n" + body, hashlib.sha256
|
||||
).hexdigest()
|
||||
if not hmac.compare_digest(signature, expected):
|
||||
raise HTTPException(status_code=401, detail="invalid callback signature")
|
||||
try:
|
||||
payload = json.loads(body)
|
||||
except (UnicodeDecodeError, ValueError):
|
||||
raise HTTPException(status_code=400, detail="invalid callback JSON")
|
||||
if not isinstance(payload, dict):
|
||||
raise HTTPException(status_code=400, detail="invalid callback JSON")
|
||||
|
||||
order_no = str(payload.get("orderNo") or "").strip()
|
||||
payment_no = str(payload.get("paymentNo") or "").strip()
|
||||
status = str(payload.get("status") or "").upper()
|
||||
if not order_no or not payment_no or status not in {"SUCCESS", "FAILED"}:
|
||||
raise HTTPException(status_code=400, detail="missing callback fields")
|
||||
order = db.query(TokenPaymentOrder).filter(TokenPaymentOrder.order_no == order_no).first()
|
||||
if not order or order.provider != "wechat_virtual":
|
||||
raise HTTPException(status_code=404, detail="virtual payment order not found")
|
||||
if status == "FAILED":
|
||||
return ok({"received": True, "paid": False})
|
||||
|
||||
user = db.query(User).filter(User.id == order.user_id).first()
|
||||
plan = db.query(TokenPlan).filter(TokenPlan.id == order.plan_id).first()
|
||||
try:
|
||||
expected_product_id = product_id_for_plan(plan) if plan else ""
|
||||
amount_cents = int(payload.get("amountCents"))
|
||||
callback_env = int(payload.get("env"))
|
||||
except (TypeError, ValueError, WechatVirtualPaymentError):
|
||||
raise HTTPException(status_code=422, detail="invalid virtual payment details")
|
||||
if (
|
||||
isinstance(payload.get("amountCents"), bool)
|
||||
or isinstance(payload.get("env"), bool)
|
||||
or not user
|
||||
or not user.wechat_mp_openid
|
||||
or str(payload.get("openId") or "") != user.wechat_mp_openid
|
||||
or not expected_product_id
|
||||
or str(payload.get("productId") or "") != expected_product_id
|
||||
or amount_cents != order.price_cents
|
||||
or callback_env != wechat_virtual_env()
|
||||
):
|
||||
raise HTTPException(status_code=422, detail="virtual payment details mismatch")
|
||||
existing_payment = db.query(PaymentTransaction).filter(
|
||||
PaymentTransaction.provider == "wechat_virtual",
|
||||
PaymentTransaction.event_type == "payment",
|
||||
PaymentTransaction.transaction_no == payment_no,
|
||||
).first()
|
||||
if existing_payment and existing_payment.order_no != order_no:
|
||||
raise HTTPException(status_code=409, detail="payment number belongs to another order")
|
||||
if order.status in {"paid", "refunded"}:
|
||||
return ok({"received": True, "duplicate": True})
|
||||
|
||||
_settle_paid_order(db, order, provider_status="HUIHUI_SUCCESS", transaction_no=payment_no)
|
||||
db.commit()
|
||||
return ok({"received": True, "paid": True})
|
||||
|
||||
|
||||
def _wechat_notify_response(request: Request, *, success: bool, message: str = ""):
|
||||
code = 0 if success else 1
|
||||
text = "success" if success else (message or "fail")[:200].replace("]]>", "")
|
||||
|
||||
@@ -6,7 +6,6 @@ from fastapi import HTTPException
|
||||
|
||||
from models import Avatar, User
|
||||
from routers.chat import (
|
||||
_answer_requires_language_repair,
|
||||
_build_prompt,
|
||||
_iter_text_chunks,
|
||||
_match_standard_qa,
|
||||
@@ -15,7 +14,6 @@ from routers.chat import (
|
||||
_qa_requires_per_turn_rendering,
|
||||
_require_owned_avatar,
|
||||
_resolve_reply,
|
||||
_turn_language_name,
|
||||
)
|
||||
|
||||
|
||||
@@ -109,8 +107,8 @@ class ChatOrchestrationTests(unittest.TestCase):
|
||||
messages = fake_model.call_args.kwargs["messages"]
|
||||
self.assertEqual(messages[-1], {"role": "user", "content": "Quelle est votre adresse ?"})
|
||||
self.assertEqual(messages[-2]["role"], "system")
|
||||
self.assertIn("MANDATORY OUTPUT LANGUAGE", messages[-2]["content"])
|
||||
self.assertIn("French", messages[-2]["content"])
|
||||
self.assertIn("本轮语言覆盖指令", messages[-2]["content"])
|
||||
self.assertIn("不要沿用上一轮语言", messages[-2]["content"])
|
||||
|
||||
def test_latest_user_message_has_an_adjacent_language_override(self):
|
||||
history = [
|
||||
@@ -121,37 +119,8 @@ class ChatOrchestrationTests(unittest.TestCase):
|
||||
|
||||
self.assertEqual(messages[-1], {"role": "user", "content": "What can you help me with?"})
|
||||
self.assertEqual(messages[-2]["role"], "system")
|
||||
self.assertIn("MANDATORY OUTPUT LANGUAGE", messages[-2]["content"])
|
||||
self.assertIn("English", messages[-2]["content"])
|
||||
|
||||
def test_reported_alzheimer_question_is_explicitly_english(self):
|
||||
question = "I have a friend who has symptoms of Alzheimer's disease"
|
||||
|
||||
self.assertEqual(_turn_language_name(question), "English")
|
||||
messages = _build_prompt(self.avatar, [], question, [])
|
||||
self.assertIn("MANDATORY OUTPUT LANGUAGE FOR THIS TURN: English", messages[-2]["content"])
|
||||
|
||||
def test_non_stream_reply_repairs_a_wrong_writing_system_before_sending(self):
|
||||
question = "I have a friend who has symptoms of Alzheimer's disease"
|
||||
fake_model = Mock(side_effect=["建议尽快就医评估。", "Please arrange a medical assessment soon."])
|
||||
|
||||
result = _resolve_reply(
|
||||
None,
|
||||
self.avatar,
|
||||
question,
|
||||
[],
|
||||
qa_pairs=[],
|
||||
search_fn=lambda *_args, **_kwargs: [],
|
||||
model_client=fake_model,
|
||||
usage_source="takeover",
|
||||
)
|
||||
|
||||
self.assertEqual(result["answer"], "Please arrange a medical assessment soon.")
|
||||
self.assertEqual(fake_model.call_count, 2)
|
||||
repair_messages = fake_model.call_args.kwargs["messages"]
|
||||
self.assertIn("English", repair_messages[0]["content"])
|
||||
self.assertIn("建议尽快就医评估", repair_messages[-1]["content"])
|
||||
self.assertTrue(_answer_requires_language_repair(question, "建议尽快就医评估。"))
|
||||
self.assertIn("最新用户消息", messages[-2]["content"])
|
||||
self.assertIn("立即切换到相同语言", messages[-2]["content"])
|
||||
|
||||
def test_conversational_paraphrase_matches_standard_qa(self):
|
||||
for question in ("请问一下,你们公司在哪里呀?", "请问去你们那边怎么走"):
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
import os
|
||||
import time
|
||||
from unittest.mock import patch
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
@@ -14,6 +16,69 @@ from services.token_billing import DEFAULT_TOKEN_GRANT
|
||||
client = TestClient(app)
|
||||
|
||||
|
||||
def _huihui_virtual_headers(secret, body, timestamp=None):
|
||||
timestamp = str(timestamp if timestamp is not None else int(time.time()))
|
||||
signature = hmac.new(secret.encode(), timestamp.encode() + b"\n" + body, hashlib.sha256).hexdigest()
|
||||
return {"X-Huihui-Timestamp": timestamp, "X-Huihui-Signature": signature}
|
||||
|
||||
|
||||
def test_huihui_virtual_callback_requires_signature_and_exact_order_details(authorization_context):
|
||||
seed()
|
||||
context = authorization_context
|
||||
db = SessionLocal()
|
||||
try:
|
||||
user = db.query(User).filter(User.id == context["owner"].id).one()
|
||||
user.wechat_mp_openid = "openid-huihui-callback"
|
||||
user.wechat_mp_session_key = "session-huihui-callback"
|
||||
plan = db.query(TokenPlan).filter(TokenPlan.id == "1").one()
|
||||
plan.virtual_product_id = "points_plan_1"
|
||||
db.commit()
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
secret = "huihui-virtual-callback-test-secret-123456"
|
||||
env = {
|
||||
"WECHAT_VIRTUAL_ENV": "sandbox",
|
||||
"WECHAT_VIRTUAL_SANDBOX_APP_KEY": "sandbox-key",
|
||||
"WECHAT_VIRTUAL_OFFER_ID": "offer-1",
|
||||
"HUIHUI_VIRTUAL_CALLBACK_SECRET": secret,
|
||||
}
|
||||
with patch.dict(os.environ, env):
|
||||
created = client.post(
|
||||
"/api/token/charge",
|
||||
headers=context["owner_headers"],
|
||||
json={"planId": "1", "paymentMethod": "wechat", "payScene": "LITE"},
|
||||
).json()["data"]
|
||||
payload = {
|
||||
"orderNo": created["orderNo"],
|
||||
"paymentNo": f"huihui-virtual-payment-{context['suffix']}",
|
||||
"status": "SUCCESS",
|
||||
"amountCents": 1000,
|
||||
"productId": "points_plan_1",
|
||||
"openId": "openid-huihui-callback",
|
||||
"env": 1,
|
||||
}
|
||||
path = "/api/token/payment/huihui/virtual/notify"
|
||||
body = json.dumps(payload, separators=(",", ":")).encode()
|
||||
assert client.post(path, content=body, headers=_huihui_virtual_headers(secret, body, 1)).status_code == 401
|
||||
assert client.post(path, content=body, headers={"X-Huihui-Timestamp": str(int(time.time())), "X-Huihui-Signature": "bad"}).status_code == 401
|
||||
wrong = json.dumps({**payload, "amountCents": 999}, separators=(",", ":")).encode()
|
||||
assert client.post(path, content=wrong, headers=_huihui_virtual_headers(secret, wrong)).status_code == 422
|
||||
first = client.post(path, content=body, headers=_huihui_virtual_headers(secret, body))
|
||||
second = client.post(path, content=body, headers=_huihui_virtual_headers(secret, body))
|
||||
|
||||
assert first.json()["data"] == {"received": True, "paid": True}
|
||||
assert second.json()["data"] == {"received": True, "duplicate": True}
|
||||
db = SessionLocal()
|
||||
try:
|
||||
order = db.query(TokenPaymentOrder).filter(TokenPaymentOrder.order_no == created["orderNo"]).one()
|
||||
account = db.query(TokenAccount).filter(TokenAccount.user_id == context["owner"].id).one()
|
||||
assert order.status == "paid"
|
||||
assert account.balance == DEFAULT_TOKEN_GRANT + 2_000_000
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
def _signature(token, timestamp, nonce):
|
||||
return hashlib.sha1("".join(sorted([token, timestamp, nonce])).encode()).hexdigest()
|
||||
|
||||
|
||||
@@ -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 均不得下发前端或写日志。
|
||||
|
||||
如由会会服务端转发微信小程序虚拟支付结果,另有待双方确认的固定回调契约,见 [HUIHUI_VIRTUAL_CALLBACK.md](HUIHUI_VIRTUAL_CALLBACK.md)。该接口使用独立的 `HUIHUI_VIRTUAL_CALLBACK_SECRET`;即使接口代码部署到生产,在协议确认和联调验收前也不得将其视作已上线的支付渠道。
|
||||
|
||||
管理后台需要配置相同的 `AVATAR_FINANCE_ADMIN_SECRET` 和 `AVATAR_BACKEND_URL=https://digital.99hui.com`。退款只支持整单原路退款;供应商受理后显示“处理中”,收到渠道成功回调(或经渠道后台核对后人工确认)才将订单置为已退款。已消费掉本订单积分时,后台会拒绝主动退款;若渠道外部退款先发生,积分账户允许形成负数以记录欠额并阻止继续消费。
|
||||
|
||||
## 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。
|
||||
|
||||
上线前需由会会确认:其服务端是否能按此格式签名并发送通知、支付成功状态与支付流水号字段的正式定义、失败通知及重试/超时策略。小程序支付发起和退款仍需独立对接和验收;仅完成此回调不代表虚拟支付全流程已可用。
|
||||
@@ -1,7 +1,6 @@
|
||||
import { createRouter, createWebHashHistory } from 'vue-router'
|
||||
import type { RouteRecordRaw } from 'vue-router'
|
||||
import { getAuthToken } from '@/api'
|
||||
import { isInUniWebView } from '@/utils/uniapp-bridge'
|
||||
|
||||
const routes: RouteRecordRaw[] = [
|
||||
{
|
||||
@@ -56,7 +55,7 @@ const routes: RouteRecordRaw[] = [
|
||||
path: '/token/charge',
|
||||
name: 'TokenCharge',
|
||||
component: () => import('@/views/TokenCharge.vue'),
|
||||
meta: { title: '积分充值', requiresAuth: true, requiresUniWebView: true }
|
||||
meta: { title: '积分充值', requiresAuth: true }
|
||||
},
|
||||
{
|
||||
path: '/avatar/card',
|
||||
@@ -134,10 +133,6 @@ const router = createRouter({
|
||||
|
||||
router.beforeEach((to, from, next) => {
|
||||
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 hasInjectedSession = Boolean(getAuthToken())
|
||||
if (to.meta.requiresAuth && !hasLocalSession && !hasInjectedSession) {
|
||||
|
||||
@@ -12,10 +12,9 @@ export interface UniLaunchParams {
|
||||
nickname?: string
|
||||
avatar?: 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 {
|
||||
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 环境中
|
||||
export function isInUniWebView(): boolean {
|
||||
const runtime = window as any
|
||||
@@ -51,10 +40,7 @@ export function isInUniWebView(): boolean {
|
||||
runtime.swan?.webView ||
|
||||
runtime.tt?.miniProgram
|
||||
)
|
||||
// `plus` can be injected after the H5 entry point runs. The native shell
|
||||
// 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()))
|
||||
return Boolean(runtime.uni?.webView && (isDCloudApp || isMiniProgram))
|
||||
}
|
||||
|
||||
// 解析 web-view 加载 URL 时原生注入的参数(token / 会会用户)
|
||||
|
||||
@@ -179,7 +179,7 @@ const permissionItems: Array<{
|
||||
},
|
||||
{
|
||||
key: 'publish',
|
||||
title: '发布微博内容',
|
||||
title: '发布微播内容',
|
||||
description: '允许分身自动发布动态内容',
|
||||
tone: 'green',
|
||||
},
|
||||
@@ -691,7 +691,9 @@ svg {
|
||||
font-weight: 400;
|
||||
line-height: 1.45;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
white-space: normal;
|
||||
overflow-wrap: anywhere;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.permission-row.takeover .permission-copy small {
|
||||
|
||||
@@ -54,7 +54,7 @@
|
||||
</template>
|
||||
<template v-else>{{ message.content }}</template>
|
||||
</div>
|
||||
<div v-if="message.source || message.references?.length" class="message-source">
|
||||
<div v-if="sourceLabel(message.source) || message.references?.length" class="message-source">
|
||||
{{ sourceLabel(message.source) }}
|
||||
<span v-if="message.references?.length"> · {{ message.references.map((item) => item.filename).filter(Boolean).join('、') }}</span>
|
||||
</div>
|
||||
@@ -174,7 +174,8 @@ const sourceLabels: Record<NonNullable<DisplayMessage['source']>, string> = {
|
||||
qa: '标准问答对',
|
||||
knowledge: '参考文件知识库',
|
||||
vision: '图片理解',
|
||||
qwen: '智能回答',
|
||||
// qwen 来源不再在聊天气泡下显示“智能回答/知能回答”标注。
|
||||
qwen: '',
|
||||
public: ''
|
||||
}
|
||||
const sourceLabel = (source?: DisplayMessage['source']) => source ? sourceLabels[source] : ''
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 积分余额条:仅在 uni-app 原生壳内开放充值购买。 -->
|
||||
<!-- 积分余额条:暂时隐藏,保留完整实现便于后续恢复。 -->
|
||||
<section v-if="SHOW_POINTS_BALANCE_CARD" class="token-section">
|
||||
<div class="token-card">
|
||||
<div class="token-info">
|
||||
@@ -28,7 +28,7 @@
|
||||
<span class="token-amount">{{ tokenBalance.toLocaleString() }}</span>
|
||||
<span class="token-used">累计使用 {{ tokenConsumed.toLocaleString() }}</span>
|
||||
</div>
|
||||
<button class="recharge-btn" @click="goToRecharge">充值购买</button>
|
||||
<button class="recharge-btn" @click="goToRecharge">充值</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -89,15 +89,14 @@ import { useAvatarStore } from '@/store/avatar'
|
||||
import { useUserStore } from '@/store/user'
|
||||
import { createAvatarShareLink } from '@/api'
|
||||
import { isHuihuiEmbeddedMode } from '@/utils/embed-mode'
|
||||
import { isInUniWebView } from '@/utils/uniapp-bridge'
|
||||
|
||||
const router = useRouter()
|
||||
const avatarStore = useAvatarStore()
|
||||
const userStore = useUserStore()
|
||||
const isEmbedded = isHuihuiEmbeddedMode()
|
||||
|
||||
// 充值购买只在 uni-app 原生壳内提供,避免普通 H5 进入支付链路。
|
||||
const SHOW_POINTS_BALANCE_CARD = isInUniWebView()
|
||||
// 临时产品开关:余额卡片代码保留,后续改为 true 即可恢复展示。
|
||||
const SHOW_POINTS_BALANCE_CARD = false
|
||||
|
||||
// 当前登录会会用户的资料(头像 / 昵称)
|
||||
const me = computed(() => userStore.user)
|
||||
|
||||
@@ -1,8 +1,5 @@
|
||||
export function buildH5Url(base, session) {
|
||||
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.userId) url.searchParams.set('userId', session.userId)
|
||||
if (session.nickname) url.searchParams.set('nickname', session.nickname)
|
||||
|
||||
Reference in New Issue
Block a user