Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0302ee81e0 | ||
|
|
08a7bc8b90 | ||
|
|
ddb8ea9802 | ||
|
|
85d0c57548 | ||
|
|
da5822ecfe | ||
|
|
643730f756 | ||
|
|
63a0c6692c | ||
|
|
c62233a7c2 | ||
|
|
09aebea7d1 | ||
|
|
b1bf203018 | ||
|
|
9566eb8925 | ||
|
|
e5df72956d | ||
|
|
48f3b9bddf | ||
|
|
2122615725 | ||
|
|
cd0c6e3162 | ||
|
|
b48e33fc6b | ||
|
|
d9f12685fa | ||
|
|
b3367eedaa | ||
|
|
e6a4988cf9 | ||
|
|
becf2c7c52 | ||
|
|
d421a9da72 |
@@ -29,6 +29,30 @@ def summary():
|
|||||||
db.close()
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/plans")
|
||||||
|
def token_plans():
|
||||||
|
db = _session()
|
||||||
|
try:
|
||||||
|
return ApiResponse(data=finance_service.list_token_plans(db))
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/plans/{plan_id}")
|
||||||
|
def update_token_plan(plan_id: str, body: dict = Body(...)):
|
||||||
|
db = _session()
|
||||||
|
try:
|
||||||
|
finance_service.update_token_plan(
|
||||||
|
db, plan_id, amount=int(body.get("amount") or 0), price=float(body.get("price") or 0),
|
||||||
|
name=str(body.get("name") or ""), badge=str(body.get("badge") or ""), desc=str(body.get("desc") or ""),
|
||||||
|
)
|
||||||
|
return ApiResponse(message="充值套餐已更新")
|
||||||
|
except (ValueError, FinanceServiceError) as exc:
|
||||||
|
_raise(exc if isinstance(exc, FinanceServiceError) else FinanceServiceError("积分和价格格式不正确"))
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
@router.get("/orders")
|
@router.get("/orders")
|
||||||
def orders(
|
def orders(
|
||||||
page: int = Query(1, ge=1),
|
page: int = Query(1, ge=1),
|
||||||
@@ -61,42 +85,6 @@ def update_order_status(order_no: str, body: dict = Body(...)):
|
|||||||
db.close()
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
@router.post("/orders/{order_no}/refund")
|
|
||||||
def request_refund(order_no: str, body: dict = Body(...)):
|
|
||||||
try:
|
|
||||||
data = finance_service.request_refund(
|
|
||||||
order_no,
|
|
||||||
reason=str(body.get("reason") or "").strip(),
|
|
||||||
operator=str(body.get("operator") or "后台管理员").strip(),
|
|
||||||
)
|
|
||||||
return ApiResponse(data=data, message="退款申请已提交")
|
|
||||||
except FinanceServiceError as exc:
|
|
||||||
_raise(exc)
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/refunds")
|
|
||||||
def refunds(
|
|
||||||
page: int = Query(1, ge=1),
|
|
||||||
page_size: int = Query(20, ge=1, le=100),
|
|
||||||
status: str = Query(""),
|
|
||||||
):
|
|
||||||
db = _session()
|
|
||||||
try:
|
|
||||||
total, items = finance_service.list_refunds(db, page=page, page_size=page_size, status=status)
|
|
||||||
return ApiResponse(data={"total": total, "page": page, "page_size": page_size, "items": items})
|
|
||||||
finally:
|
|
||||||
db.close()
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/refunds/{refund_no}/confirm")
|
|
||||||
def confirm_refund(refund_no: str, body: dict = Body(...)):
|
|
||||||
try:
|
|
||||||
data = finance_service.confirm_refund(refund_no, body)
|
|
||||||
return ApiResponse(data=data, message="退款结果已登记")
|
|
||||||
except FinanceServiceError as exc:
|
|
||||||
_raise(exc)
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/invoices")
|
@router.get("/invoices")
|
||||||
def invoices(
|
def invoices(
|
||||||
page: int = Query(1, ge=1),
|
page: int = Query(1, ge=1),
|
||||||
|
|||||||
@@ -28,6 +28,23 @@ def _money(cents):
|
|||||||
|
|
||||||
|
|
||||||
class FinanceService:
|
class FinanceService:
|
||||||
|
@staticmethod
|
||||||
|
def list_token_plans(db):
|
||||||
|
rows = db.execute(text('SELECT id,name,amount,price,badge,"desc",virtual_product_id FROM token_plans ORDER BY price ASC')).fetchall()
|
||||||
|
return [dict(row._mapping) for row in rows]
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def update_token_plan(db, plan_id: str, *, amount: int, price: float, name: str, badge: str, desc: str):
|
||||||
|
if amount <= 0 or price < 0 or not name.strip():
|
||||||
|
raise FinanceServiceError("套餐名称、正数积分和非负价格不正确")
|
||||||
|
result = db.execute(text('''UPDATE token_plans SET name=:name,amount=:amount,price=:price,badge=:badge,"desc"=:desc WHERE id=:id'''), {
|
||||||
|
"id": plan_id, "name": name.strip()[:80], "amount": amount, "price": price,
|
||||||
|
"badge": badge.strip()[:80], "desc": desc.strip()[:200],
|
||||||
|
})
|
||||||
|
if result.rowcount == 0:
|
||||||
|
raise FinanceServiceError("充值套餐不存在", 404)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _tables_ready(db) -> bool:
|
def _tables_ready(db) -> bool:
|
||||||
names = {
|
names = {
|
||||||
|
|||||||
@@ -22,7 +22,11 @@ import routers.takeover
|
|||||||
from responses import ok
|
from responses import ok
|
||||||
from services.chat_attachment_service import purge_expired_chat_attachments
|
from services.chat_attachment_service import purge_expired_chat_attachments
|
||||||
from services.knowledge_vectorizer import knowledge_vectorizer
|
from services.knowledge_vectorizer import knowledge_vectorizer
|
||||||
from services.token_billing import DEFAULT_TOKEN_GRANT, release_stale_reservations
|
from services.token_billing import (
|
||||||
|
DEFAULT_TOKEN_GRANT,
|
||||||
|
close_expired_pending_orders,
|
||||||
|
release_stale_reservations,
|
||||||
|
)
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -255,10 +259,24 @@ def purge_expired_chat_attachments_job():
|
|||||||
db.close()
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
|
def close_expired_pending_orders_job():
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
count = close_expired_pending_orders(db)
|
||||||
|
if count:
|
||||||
|
logger.info("Closed %s expired pending token payment order(s)", count)
|
||||||
|
except Exception as exc:
|
||||||
|
db.rollback()
|
||||||
|
logger.warning("Failed to close expired token payment orders: %s", exc)
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
def start_maintenance_scheduler():
|
def start_maintenance_scheduler():
|
||||||
global maintenance_scheduler
|
global maintenance_scheduler
|
||||||
|
|
||||||
purge_expired_chat_attachments_job()
|
purge_expired_chat_attachments_job()
|
||||||
|
close_expired_pending_orders_job()
|
||||||
interval_minutes = max(
|
interval_minutes = max(
|
||||||
5, min(1440, int(os.getenv("CHAT_ATTACHMENT_CLEANUP_MINUTES", "60")))
|
5, min(1440, int(os.getenv("CHAT_ATTACHMENT_CLEANUP_MINUTES", "60")))
|
||||||
)
|
)
|
||||||
@@ -270,6 +288,13 @@ def start_maintenance_scheduler():
|
|||||||
max_instances=1,
|
max_instances=1,
|
||||||
coalesce=True,
|
coalesce=True,
|
||||||
)
|
)
|
||||||
|
maintenance_scheduler.add_job(
|
||||||
|
close_expired_pending_orders_job,
|
||||||
|
trigger=IntervalTrigger(minutes=1),
|
||||||
|
id="close_expired_token_orders",
|
||||||
|
max_instances=1,
|
||||||
|
coalesce=True,
|
||||||
|
)
|
||||||
maintenance_scheduler.start()
|
maintenance_scheduler.start()
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -395,6 +395,7 @@ class TokenPaymentOrder(Base):
|
|||||||
"price": self.price_cents / 100,
|
"price": self.price_cents / 100,
|
||||||
"status": self.status,
|
"status": self.status,
|
||||||
"provider": self.provider,
|
"provider": self.provider,
|
||||||
|
"providerOrderNo": self.provider_order_no,
|
||||||
"providerStatus": self.provider_status,
|
"providerStatus": self.provider_status,
|
||||||
"payMessage": self.pay_message,
|
"payMessage": self.pay_message,
|
||||||
"failureReason": self.failure_reason,
|
"failureReason": self.failure_reason,
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -24,6 +25,7 @@ from models import (
|
|||||||
)
|
)
|
||||||
from responses import fail, ok
|
from responses import fail, ok
|
||||||
from services.huihui_payment import HuihuiPaymentClient, HuihuiPaymentError
|
from services.huihui_payment import HuihuiPaymentClient, HuihuiPaymentError
|
||||||
|
from services.huihui_org_payment import HuihuiOrgPaymentClient
|
||||||
from services.token_billing import get_or_create_account
|
from services.token_billing import get_or_create_account
|
||||||
from services.wechat_virtual_payment import (
|
from services.wechat_virtual_payment import (
|
||||||
PAYMENT_EVENTS as WECHAT_PAYMENT_EVENTS,
|
PAYMENT_EVENTS as WECHAT_PAYMENT_EVENTS,
|
||||||
@@ -269,6 +271,7 @@ def _callback_amount_cents(payload) -> int | None:
|
|||||||
"orderAmt",
|
"orderAmt",
|
||||||
"amount",
|
"amount",
|
||||||
"totalAmount",
|
"totalAmount",
|
||||||
|
"chargeAmount",
|
||||||
)
|
)
|
||||||
if value in (None, ""):
|
if value in (None, ""):
|
||||||
return None
|
return None
|
||||||
@@ -404,6 +407,49 @@ def charge(payload: dict = Body(...), authorization: str = Header(None), db: Ses
|
|||||||
return ok(_payment_payload(order, get_or_create_account(db, user.id)))
|
return ok(_payment_payload(order, get_or_create_account(db, user.id)))
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/token/charge/org")
|
||||||
|
def charge_via_huihui_org(payload: dict = Body(...), authorization: str = Header(None), db: Session = Depends(get_db)):
|
||||||
|
user = _require_user(authorization, db)
|
||||||
|
plan = db.query(TokenPlan).filter(TokenPlan.id == payload.get("planId")).first()
|
||||||
|
if not plan:
|
||||||
|
return fail("套餐不存在", 404)
|
||||||
|
method = str(payload.get("paymentMethod") or "").lower()
|
||||||
|
if method not in PAYMENT_METHODS:
|
||||||
|
return fail("支付方式不正确", 400)
|
||||||
|
scene = str(payload.get("payScene") or "APP").upper()
|
||||||
|
if scene not in PAYMENT_SCENES:
|
||||||
|
return fail("支付场景不正确", 400)
|
||||||
|
if scene == "LITE" and method != "wechat":
|
||||||
|
return fail("微信小程序仅支持微信支付", 400)
|
||||||
|
cents = _price_cents(plan.price)
|
||||||
|
try:
|
||||||
|
result = HuihuiOrgPaymentClient().create_order(
|
||||||
|
token=user.huihui_token, user_id=user.huihui_user_id,
|
||||||
|
name=user.nickname, phone=user.phone, avatar=user.avatar_url,
|
||||||
|
amount=f"{cents / 100:.2f}",
|
||||||
|
)
|
||||||
|
except HuihuiPaymentError as exc:
|
||||||
|
return fail(str(exc), 502)
|
||||||
|
provider_no = str(result.get("orderNo") or "")
|
||||||
|
if not provider_no.isdigit() or int(result.get("payStatus") or 0) != 1 or int(result.get("chargeType") or 0) != 9:
|
||||||
|
return fail("会会返回的充值订单不正确", 502)
|
||||||
|
if _callback_amount_cents({"chargeAmount": result.get("chargeAmount")}) != cents:
|
||||||
|
return fail("会会返回的充值金额不匹配", 502)
|
||||||
|
order = TokenPaymentOrder(
|
||||||
|
order_no=f"AV{datetime.utcnow().strftime('%Y%m%d%H%M%S')}{uuid.uuid4().hex[:12].upper()}",
|
||||||
|
user_id=user.id, plan_id=plan.id, payment_method=method,
|
||||||
|
pay_type=PAYMENT_METHODS[method], pay_way=scene, points_amount=plan.amount,
|
||||||
|
price_cents=cents, status="pending", provider="huihui_org",
|
||||||
|
provider_order_no=provider_no, provider_status="1",
|
||||||
|
)
|
||||||
|
db.add(order)
|
||||||
|
db.commit()
|
||||||
|
result = _payment_payload(order, get_or_create_account(db, user.id))
|
||||||
|
if "fat-open.99hui.com" in os.getenv("HUIHUI_ORG_BASE_URL", ""):
|
||||||
|
result["checkoutToken"] = user.huihui_token
|
||||||
|
return ok(result)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/token/payment/{order_id}")
|
@router.get("/token/payment/{order_id}")
|
||||||
def payment_status(order_id: str, authorization: str = Header(None), db: Session = Depends(get_db)):
|
def payment_status(order_id: str, authorization: str = Header(None), db: Session = Depends(get_db)):
|
||||||
user = _require_user(authorization, db)
|
user = _require_user(authorization, db)
|
||||||
@@ -413,6 +459,7 @@ def payment_status(order_id: str, authorization: str = Header(None), db: Session
|
|||||||
).first()
|
).first()
|
||||||
if not order:
|
if not order:
|
||||||
return fail("支付订单不存在", 404)
|
return fail("支付订单不存在", 404)
|
||||||
|
# 会会组织支付以回调为准;查询此接口只返回本地订单,不向会会轮询查单。
|
||||||
if order.provider == "wechat_virtual" and order.status == "pending" and user.wechat_mp_openid:
|
if order.provider == "wechat_virtual" and order.status == "pending" and user.wechat_mp_openid:
|
||||||
try:
|
try:
|
||||||
provider_data = query_wechat_virtual_order(
|
provider_data = query_wechat_virtual_order(
|
||||||
@@ -559,6 +606,8 @@ def admin_request_refund(
|
|||||||
return fail("订单不存在", 404)
|
return fail("订单不存在", 404)
|
||||||
if order.status != "paid" or order.refund_status not in {"", "none", "failed"}:
|
if order.status != "paid" or order.refund_status not in {"", "none", "failed"}:
|
||||||
return fail("该订单当前不可退款", 409)
|
return fail("该订单当前不可退款", 409)
|
||||||
|
if order.provider == "huihui_org":
|
||||||
|
return fail("会会组织订单的原路退款接口尚未接入,请先在会会后台处理", 409)
|
||||||
account = get_or_create_account(db, order.user_id)
|
account = get_or_create_account(db, order.user_id)
|
||||||
if int(account.balance or 0) < int(order.points_amount or 0):
|
if int(account.balance or 0) < int(order.points_amount or 0):
|
||||||
return fail("该订单发放的积分已使用,不能执行全额退款", 409)
|
return fail("该订单发放的积分已使用,不能执行全额退款", 409)
|
||||||
@@ -753,6 +802,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("]]>", "")
|
||||||
|
|||||||
@@ -0,0 +1,48 @@
|
|||||||
|
"""Huihui organization token-recharge order API."""
|
||||||
|
import os
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
from services.huihui_payment import HuihuiPaymentClient, HuihuiPaymentError
|
||||||
|
|
||||||
|
|
||||||
|
class HuihuiOrgPaymentClient(HuihuiPaymentClient):
|
||||||
|
def __init__(self):
|
||||||
|
super().__init__({
|
||||||
|
"HUIHUI_PAYMENT_BASE_URL": os.getenv("HUIHUI_ORG_BASE_URL", ""),
|
||||||
|
"HUIHUI_APP_ID": os.getenv("HUIHUI_APP_ID", ""),
|
||||||
|
"HUIHUI_ACCESS_ID": os.getenv("HUIHUI_ACCESS_ID", ""),
|
||||||
|
"HUIHUI_ACCESS_SECRET": os.getenv("HUIHUI_ACCESS_SECRET", ""),
|
||||||
|
})
|
||||||
|
|
||||||
|
def _request(self, method: str, path: str, *, token: str, user_id: str, body=None) -> dict:
|
||||||
|
if not self.configured or not token or not user_id:
|
||||||
|
raise HuihuiPaymentError("会会订单服务或登录凭证未配置")
|
||||||
|
try:
|
||||||
|
response = httpx.request(
|
||||||
|
method, f"{self.base_url}{path}",
|
||||||
|
headers={"Authorization": f"Bearer {token}", "appId": self.app_id},
|
||||||
|
params=self._signed_params(user_id), json=body,
|
||||||
|
timeout=self.timeout, follow_redirects=True,
|
||||||
|
)
|
||||||
|
except httpx.HTTPError as exc:
|
||||||
|
raise HuihuiPaymentError("会会订单服务连接失败") from exc
|
||||||
|
payload = self._json(response)
|
||||||
|
if response.status_code >= 400 or payload.get("code") not in (0, 200, "0", "200"):
|
||||||
|
raise HuihuiPaymentError(str(payload.get("message") or "会会订单请求失败"))
|
||||||
|
data = payload.get("data")
|
||||||
|
if not isinstance(data, dict):
|
||||||
|
raise HuihuiPaymentError("会会订单信息缺失")
|
||||||
|
return data
|
||||||
|
|
||||||
|
def create_order(self, *, token: str, user_id: str, name: str, phone: str,
|
||||||
|
avatar: str, amount: str) -> dict:
|
||||||
|
body = {"chargeAmount": float(amount),
|
||||||
|
"commodityName": "数字分身积分充值", "userId": user_id,
|
||||||
|
"name": name or "", "phoneNumber": phone or "", "avatar": avatar or ""}
|
||||||
|
return self._request("POST", "/order/tokenRecharge", token=token, user_id=user_id, body=body)
|
||||||
|
|
||||||
|
def get_order(self, *, token: str, user_id: str, order_no: str) -> dict:
|
||||||
|
if not order_no.isdigit():
|
||||||
|
raise HuihuiPaymentError("会会订单号格式不正确")
|
||||||
|
return self._request("GET", f"/order/get/{order_no}", token=token, user_id=user_id)
|
||||||
@@ -7,7 +7,7 @@ from datetime import datetime, timedelta
|
|||||||
from sqlalchemy.exc import IntegrityError
|
from sqlalchemy.exc import IntegrityError
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from models import Avatar, TokenAccount, TokenUsage, User
|
from models import Avatar, TokenAccount, TokenPaymentOrder, TokenUsage, User
|
||||||
|
|
||||||
DEFAULT_TOKEN_GRANT = 1_000_000
|
DEFAULT_TOKEN_GRANT = 1_000_000
|
||||||
|
|
||||||
@@ -201,3 +201,22 @@ def release_stale_reservations(db: Session, older_than_minutes: int = 10) -> int
|
|||||||
"stale_reservation_recovered",
|
"stale_reservation_recovered",
|
||||||
)
|
)
|
||||||
return len(stale)
|
return len(stale)
|
||||||
|
|
||||||
|
|
||||||
|
def close_expired_pending_orders(db: Session, older_than_minutes: int = 60) -> int:
|
||||||
|
"""Close unpaid recharge orders after their one-hour payment window."""
|
||||||
|
now = datetime.utcnow()
|
||||||
|
cutoff = now - timedelta(minutes=older_than_minutes)
|
||||||
|
closed_count = db.query(TokenPaymentOrder).filter(
|
||||||
|
TokenPaymentOrder.status == "pending",
|
||||||
|
TokenPaymentOrder.created_at <= cutoff,
|
||||||
|
).update(
|
||||||
|
{
|
||||||
|
TokenPaymentOrder.status: "closed",
|
||||||
|
TokenPaymentOrder.failure_reason: "支付超时(超过1小时)",
|
||||||
|
TokenPaymentOrder.updated_at: now,
|
||||||
|
},
|
||||||
|
synchronize_session=False,
|
||||||
|
)
|
||||||
|
db.commit()
|
||||||
|
return int(closed_count or 0)
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ from fastapi import HTTPException
|
|||||||
|
|
||||||
from models import Avatar, User
|
from 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 ("请问一下,你们公司在哪里呀?", "请问去你们那边怎么走"):
|
||||||
|
|||||||
@@ -53,8 +53,12 @@ def test_scheduler_uses_boxim_and_restart_safe_service(
|
|||||||
max_message_age_seconds=600,
|
max_message_age_seconds=600,
|
||||||
)
|
)
|
||||||
|
|
||||||
maintenance_scheduler.add_job.assert_called_once()
|
assert maintenance_scheduler.add_job.call_count == 2
|
||||||
assert maintenance_scheduler.add_job.call_args.kwargs["id"] == "chat_attachment_cleanup"
|
cleanup_call, close_orders_call = maintenance_scheduler.add_job.call_args_list
|
||||||
|
assert cleanup_call.kwargs["id"] == "chat_attachment_cleanup"
|
||||||
|
assert close_orders_call.args[0] is main.close_expired_pending_orders_job
|
||||||
|
assert close_orders_call.kwargs["id"] == "close_expired_token_orders"
|
||||||
|
assert close_orders_call.kwargs["trigger"].interval.total_seconds() == 60
|
||||||
maintenance_scheduler.start.assert_called_once_with()
|
maintenance_scheduler.start.assert_called_once_with()
|
||||||
|
|
||||||
assert scheduler.add_job.call_count == 2
|
assert scheduler.add_job.call_count == 2
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ from threading import Barrier
|
|||||||
from unittest.mock import Mock, patch
|
from unittest.mock import Mock, patch
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
from datetime import datetime, timedelta
|
||||||
from fastapi.testclient import TestClient
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
from database import SessionLocal
|
from database import SessionLocal
|
||||||
@@ -16,6 +17,7 @@ from routers.chat import _resolve_reply, _stream_reply
|
|||||||
from services.token_billing import (
|
from services.token_billing import (
|
||||||
DEFAULT_TOKEN_GRANT,
|
DEFAULT_TOKEN_GRANT,
|
||||||
InsufficientTokensError,
|
InsufficientTokensError,
|
||||||
|
close_expired_pending_orders,
|
||||||
get_or_create_account,
|
get_or_create_account,
|
||||||
release_reservation,
|
release_reservation,
|
||||||
reserve_avatar_tokens,
|
reserve_avatar_tokens,
|
||||||
@@ -100,6 +102,84 @@ def test_charge_creates_huihui_order_without_early_points(authorization_context)
|
|||||||
assert "test-callback-secret-123456" not in callback_url
|
assert "test-callback-secret-123456" not in callback_url
|
||||||
|
|
||||||
|
|
||||||
|
def test_org_recharge_only_credits_after_verified_provider_order(authorization_context):
|
||||||
|
seed()
|
||||||
|
context = authorization_context
|
||||||
|
_enable_huihui_payment_login(context)
|
||||||
|
provider_no = "261016111200090123456"
|
||||||
|
created_data = {"orderNo": provider_no, "chargeType": 9,
|
||||||
|
"chargeAmount": 10, "payStatus": 1}
|
||||||
|
with patch.dict(os.environ, {"HUIHUI_ORG_BASE_URL": "https://fat-open.99hui.com/api/huihuiorg"}), patch("routers.tokens.HuihuiOrgPaymentClient") as client_factory:
|
||||||
|
provider = client_factory.return_value
|
||||||
|
provider.create_order.return_value = created_data
|
||||||
|
created = client.post(
|
||||||
|
"/api/token/charge/org", headers=context["owner_headers"],
|
||||||
|
json={"planId": "1", "paymentMethod": "wechat", "payScene": "APP"},
|
||||||
|
).json()["data"]
|
||||||
|
assert created["providerOrderNo"] == provider_no
|
||||||
|
assert created["status"] == "pending"
|
||||||
|
assert created["balance"] == DEFAULT_TOKEN_GRANT
|
||||||
|
assert created["checkoutToken"] == f"huihui-payment-{context['suffix']}"
|
||||||
|
assert "pay_type" not in provider.create_order.call_args.kwargs
|
||||||
|
|
||||||
|
details = {**created_data, "payStatus": 2,
|
||||||
|
"userId": context["owner"].huihui_user_id}
|
||||||
|
provider.get_order.return_value = details
|
||||||
|
unchanged = client.get(f"/api/token/payment/{created['id']}", headers=context["owner_headers"]).json()["data"]
|
||||||
|
assert unchanged["status"] == "pending"
|
||||||
|
assert unchanged["balance"] == DEFAULT_TOKEN_GRANT
|
||||||
|
provider.get_order.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
def test_org_recharge_does_not_return_checkout_token_outside_test_environment(authorization_context):
|
||||||
|
seed()
|
||||||
|
context = authorization_context
|
||||||
|
_enable_huihui_payment_login(context)
|
||||||
|
with patch.dict(os.environ, {"HUIHUI_ORG_BASE_URL": "https://open.99hui.com/api/huihuiorg"}), patch(
|
||||||
|
"routers.tokens.HuihuiOrgPaymentClient"
|
||||||
|
) as client_factory:
|
||||||
|
client_factory.return_value.create_order.return_value = {
|
||||||
|
"orderNo": "261016111200090123457", "chargeType": 9,
|
||||||
|
"chargeAmount": 10, "payStatus": 1,
|
||||||
|
}
|
||||||
|
response = client.post(
|
||||||
|
"/api/token/charge/org", headers=context["owner_headers"],
|
||||||
|
json={"planId": "1", "paymentMethod": "wechat", "payScene": "APP"},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert "checkoutToken" not in response.json()["data"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_expired_pending_payment_orders_are_closed_without_touching_recent_orders(authorization_context):
|
||||||
|
context = authorization_context
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
expired = TokenPaymentOrder(
|
||||||
|
order_no=f"EXPIRED-{uuid.uuid4().hex}", user_id=context["owner"].id, plan_id="1",
|
||||||
|
payment_method="wechat", pay_type="WECHAT", pay_way="H5", points_amount=100,
|
||||||
|
price_cents=100, status="pending", provider="huihui_org",
|
||||||
|
created_at=datetime.utcnow() - timedelta(hours=1, seconds=1),
|
||||||
|
)
|
||||||
|
recent = TokenPaymentOrder(
|
||||||
|
order_no=f"RECENT-{uuid.uuid4().hex}", user_id=context["owner"].id, plan_id="1",
|
||||||
|
payment_method="wechat", pay_type="WECHAT", pay_way="H5", points_amount=100,
|
||||||
|
price_cents=100, status="pending", provider="huihui_org",
|
||||||
|
created_at=datetime.utcnow() - timedelta(minutes=59),
|
||||||
|
)
|
||||||
|
db.add_all([expired, recent])
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
assert close_expired_pending_orders(db) == 1
|
||||||
|
db.refresh(expired)
|
||||||
|
db.refresh(recent)
|
||||||
|
assert expired.status == "closed"
|
||||||
|
assert expired.failure_reason == "支付超时(超过1小时)"
|
||||||
|
assert recent.status == "pending"
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
def test_success_callback_credits_once_and_status_is_user_scoped(authorization_context):
|
def test_success_callback_credits_once_and_status_is_user_scoped(authorization_context):
|
||||||
context = authorization_context
|
context = authorization_context
|
||||||
_enable_huihui_payment_login(context)
|
_enable_huihui_payment_login(context)
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
# 数字分身 H5 生产接入与部署
|
# 数字分身 H5 生产接入与部署
|
||||||
|
|
||||||
|
测试环境入口为 `https://fat-digital.99hui.com/`;测试支付收银台使用 `https://fat-open.99hui.com/huihui-h5/`,生产支付收银台使用 `https://99hui.com/huihui-h5/`。两套环境的订单和验签配置必须隔离,密钥只保存在各自后端配置中,不写入前端或仓库。
|
||||||
|
|
||||||
## 1. 接入方式
|
## 1. 接入方式
|
||||||
|
|
||||||
生产会会在用户已登录后打开以下地址:
|
生产会会在用户已登录后打开以下地址:
|
||||||
@@ -94,6 +96,8 @@ App 与 H5 积分充值使用会会支付体系的 `payment-v3/payment/pay`,
|
|||||||
|
|
||||||
微信小程序使用微信虚拟支付:小程序先通过 `POST /api/token/wechat/session` 交换临时登录码,再由 `POST /api/token/charge`(`payScene=LITE`)返回已签名的 `requestVirtualPayment` 参数。微信回调地址配置为 `https://digital.99hui.com/api/token/payment/wechat/virtual/notify`。回调会复核签名、OpenID、环境、商品 ID 与实付金额,退款回调确认后才扣回积分。AppKey、AppSecret、session_key 均不得下发前端或写日志。
|
微信小程序使用微信虚拟支付:小程序先通过 `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。
|
||||||
|
|
||||||
|
上线前需由会会确认:其服务端是否能按此格式签名并发送通知、支付成功状态与支付流水号字段的正式定义、失败通知及重试/超时策略。小程序支付发起和退款仍需独立对接和验收;仅完成此回调不代表虚拟支付全流程已可用。
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -156,7 +156,9 @@ export interface TokenPaymentOrder {
|
|||||||
pointsAmount: number
|
pointsAmount: number
|
||||||
price: number
|
price: number
|
||||||
status: 'pending' | 'paid' | 'failed' | 'closed' | 'refunded'
|
status: 'pending' | 'paid' | 'failed' | 'closed' | 'refunded'
|
||||||
provider: 'huihui' | 'wechat_virtual'
|
provider: 'huihui' | 'wechat_virtual' | 'huihui_org'
|
||||||
|
providerOrderNo?: string
|
||||||
|
checkoutToken?: string
|
||||||
providerStatus: string
|
providerStatus: string
|
||||||
payMessage: string
|
payMessage: string
|
||||||
failureReason: string
|
failureReason: string
|
||||||
@@ -196,6 +198,12 @@ export const chargeToken = (
|
|||||||
payScene: 'APP' | 'H5' | 'LITE' | 'JSAPI'
|
payScene: 'APP' | 'H5' | 'LITE' | 'JSAPI'
|
||||||
) => request.post<TokenPaymentOrder>('/token/charge', { planId, paymentMethod, payScene })
|
) => request.post<TokenPaymentOrder>('/token/charge', { planId, paymentMethod, payScene })
|
||||||
|
|
||||||
|
export const chargeTokenOrg = (
|
||||||
|
planId: string,
|
||||||
|
paymentMethod: 'wechat' | 'alipay',
|
||||||
|
payScene: 'APP' | 'H5' | 'LITE' | 'JSAPI'
|
||||||
|
) => request.post<TokenPaymentOrder>('/token/charge/org', { planId, paymentMethod, payScene })
|
||||||
|
|
||||||
export const getTokenPaymentStatus = (orderId: string) =>
|
export const getTokenPaymentStatus = (orderId: string) =>
|
||||||
request.get<TokenPaymentOrder>(`/token/payment/${orderId}`)
|
request.get<TokenPaymentOrder>(`/token/payment/${orderId}`)
|
||||||
|
|
||||||
|
|||||||
@@ -15,6 +15,8 @@ app.use(pinia)
|
|||||||
|
|
||||||
// —— 混合架构:在挂载前注入 uniapp 壳传入的认证与会会资料 ——
|
// —— 混合架构:在挂载前注入 uniapp 壳传入的认证与会会资料 ——
|
||||||
const params = getLaunchParams()
|
const params = getLaunchParams()
|
||||||
|
if (params.evn !== undefined) sessionStorage.setItem('hh_launch_evn', params.evn)
|
||||||
|
else if (params.token) sessionStorage.removeItem('hh_launch_evn')
|
||||||
const avatarStore = useAvatarStore(pinia)
|
const avatarStore = useAvatarStore(pinia)
|
||||||
const userStore = useUserStore(pinia)
|
const userStore = useUserStore(pinia)
|
||||||
|
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -12,29 +12,19 @@ export interface UniLaunchParams {
|
|||||||
nickname?: string
|
nickname?: string
|
||||||
avatar?: string
|
avatar?: string
|
||||||
ts?: string
|
ts?: string
|
||||||
nativeShell?: string
|
evn?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
const PARAM_KEYS: (keyof UniLaunchParams)[] = ['token', 'userId', 'nickname', 'avatar', 'ts', 'nativeShell']
|
const PARAM_KEYS: (keyof UniLaunchParams)[] = ['token', 'userId', 'nickname', 'avatar', 'ts', 'evn']
|
||||||
|
|
||||||
function readParams(search: string, target: UniLaunchParams): void {
|
function readParams(search: string, target: UniLaunchParams): void {
|
||||||
const sp = new URLSearchParams(search)
|
const sp = new URLSearchParams(search)
|
||||||
for (const key of PARAM_KEYS) {
|
for (const key of PARAM_KEYS) {
|
||||||
const value = sp.get(key)
|
const value = sp.get(key)
|
||||||
if (value) target[key] = value
|
if (value || (key === 'evn' && sp.has(key))) target[key] = value ?? ''
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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 +41,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
@@ -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,8 +20,8 @@
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<!-- 积分余额条:仅在 uni-app 原生壳内开放充值购买。 -->
|
<!-- 已登录用户均可查看积分余额并进入充值页。 -->
|
||||||
<section v-if="SHOW_POINTS_BALANCE_CARD" class="token-section">
|
<section class="token-section">
|
||||||
<div class="token-card">
|
<div class="token-card">
|
||||||
<div class="token-info">
|
<div class="token-info">
|
||||||
<span class="token-label">积分余额</span>
|
<span class="token-label">积分余额</span>
|
||||||
@@ -89,16 +89,12 @@ 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 进入支付链路。
|
|
||||||
const SHOW_POINTS_BALANCE_CARD = isInUniWebView()
|
|
||||||
|
|
||||||
// 当前登录会会用户的资料(头像 / 昵称)
|
// 当前登录会会用户的资料(头像 / 昵称)
|
||||||
const me = computed(() => userStore.user)
|
const me = computed(() => userStore.user)
|
||||||
|
|
||||||
|
|||||||
@@ -37,29 +37,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<!-- 支付方式 -->
|
|
||||||
<section class="payment-section">
|
<section class="payment-section">
|
||||||
<h3 class="section-title">支付方式</h3>
|
|
||||||
<div class="payment-list">
|
|
||||||
<div
|
|
||||||
class="payment-card"
|
|
||||||
:class="{ selected: paymentMethod === 'wechat' }"
|
|
||||||
@click="paymentMethod = 'wechat'"
|
|
||||||
>
|
|
||||||
<span class="payment-icon wechat-icon"><img :src="wechatPayIcon" alt="" /></span>
|
|
||||||
<span class="payment-name">微信支付</span>
|
|
||||||
<span class="payment-check" v-if="paymentMethod === 'wechat'">✓</span>
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
class="payment-card"
|
|
||||||
:class="{ selected: paymentMethod === 'alipay' }"
|
|
||||||
@click="paymentMethod = 'alipay'"
|
|
||||||
>
|
|
||||||
<span class="payment-icon alipay-icon"><img :src="alipayIcon" alt="" /></span>
|
|
||||||
<span class="payment-name">支付宝</span>
|
|
||||||
<span class="payment-check" v-if="paymentMethod === 'alipay'">✓</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<p v-if="paymentNotice" class="payment-notice" :class="paymentNoticeTone">{{ paymentNotice }}</p>
|
<p v-if="paymentNotice" class="payment-notice" :class="paymentNoticeTone">{{ paymentNotice }}</p>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
@@ -67,8 +45,8 @@
|
|||||||
<section class="checkout-section">
|
<section class="checkout-section">
|
||||||
<button
|
<button
|
||||||
class="checkout-btn"
|
class="checkout-btn"
|
||||||
:class="{ disabled: !selectedPlan || charging || Boolean(pendingOrderId) }"
|
:class="{ disabled: !selectedPlan || charging }"
|
||||||
:disabled="!selectedPlan || charging || Boolean(pendingOrderId)"
|
:disabled="!selectedPlan || charging"
|
||||||
@click="doCharge"
|
@click="doCharge"
|
||||||
>
|
>
|
||||||
{{ checkoutLabel }}
|
{{ checkoutLabel }}
|
||||||
@@ -107,20 +85,17 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, onMounted, onUnmounted, ref } from 'vue'
|
import { computed, onMounted, ref } from 'vue'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
import {
|
import {
|
||||||
chargeToken,
|
chargeTokenOrg,
|
||||||
applyTokenInvoice,
|
applyTokenInvoice,
|
||||||
getRechargePlans,
|
getRechargePlans,
|
||||||
getTokenBalance,
|
getTokenBalance,
|
||||||
getTokenOrders,
|
getTokenOrders,
|
||||||
getTokenPaymentStatus,
|
|
||||||
type TokenPaymentOrder
|
type TokenPaymentOrder
|
||||||
} from '@/api'
|
} from '@/api'
|
||||||
import { isInUniWebView, onNativeMessage, UniEvents } from '@/utils/uniapp-bridge'
|
import { isInUniWebView } from '@/utils/uniapp-bridge'
|
||||||
import wechatPayIcon from '@/assets/wechat-pay.svg'
|
|
||||||
import alipayIcon from '@/assets/alipay.svg'
|
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
|
||||||
@@ -141,10 +116,8 @@ const plans = ref<Array<{
|
|||||||
const selectedPlan = ref<any>(null)
|
const selectedPlan = ref<any>(null)
|
||||||
|
|
||||||
// 支付方式
|
// 支付方式
|
||||||
const paymentMethod = ref<'wechat' | 'alipay'>('wechat')
|
|
||||||
const paymentNotice = ref('')
|
const paymentNotice = ref('')
|
||||||
const paymentNoticeTone = ref<'pending' | 'success' | 'error'>('pending')
|
const paymentNoticeTone = ref<'pending' | 'success' | 'error'>('pending')
|
||||||
const pendingOrderId = ref(sessionStorage.getItem('hh_pending_payment_order') || '')
|
|
||||||
const recentOrders = ref<Array<TokenPaymentOrder & { invoice?: any }>>([])
|
const recentOrders = ref<Array<TokenPaymentOrder & { invoice?: any }>>([])
|
||||||
const invoiceOrder = ref<(TokenPaymentOrder & { invoice?: any }) | null>(null)
|
const invoiceOrder = ref<(TokenPaymentOrder & { invoice?: any }) | null>(null)
|
||||||
const invoiceType = ref<'personal' | 'company'>('personal')
|
const invoiceType = ref<'personal' | 'company'>('personal')
|
||||||
@@ -153,9 +126,6 @@ const invoiceTaxNumber = ref('')
|
|||||||
const invoiceEmail = ref('')
|
const invoiceEmail = ref('')
|
||||||
const invoiceError = ref('')
|
const invoiceError = ref('')
|
||||||
const invoiceSubmitting = ref(false)
|
const invoiceSubmitting = ref(false)
|
||||||
let pollTimer: number | undefined
|
|
||||||
let pollDeadline = 0
|
|
||||||
let removeNativeListener: (() => void) | undefined
|
|
||||||
|
|
||||||
// 从后端加载余额与套餐
|
// 从后端加载余额与套餐
|
||||||
const loadData = async () => {
|
const loadData = async () => {
|
||||||
@@ -230,135 +200,38 @@ const formatPointsCopy = (value: string) => value.replace(/\bToken\b/gi, '积分
|
|||||||
const checkoutLabel = computed(() => {
|
const checkoutLabel = computed(() => {
|
||||||
if (!selectedPlan.value) return '请选择充值套餐'
|
if (!selectedPlan.value) return '请选择充值套餐'
|
||||||
if (charging.value) return '正在创建支付订单…'
|
if (charging.value) return '正在创建支付订单…'
|
||||||
if (pendingOrderId.value) return '等待支付结果…'
|
return '去支付'
|
||||||
return `立即支付 ¥${selectedPlan.value.price}`
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const payScene = () => {
|
const payScene = () => {
|
||||||
|
if (/miniProgram/i.test(navigator.userAgent)) return 'LITE' as const
|
||||||
if (isInUniWebView()) return 'APP' as const
|
if (isInUniWebView()) return 'APP' as const
|
||||||
if (paymentMethod.value === 'wechat' && /MicroMessenger/i.test(navigator.userAgent)) return 'JSAPI' as const
|
if (/MicroMessenger/i.test(navigator.userAgent)) return 'JSAPI' as const
|
||||||
return 'H5' as const
|
return 'H5' as const
|
||||||
}
|
}
|
||||||
|
|
||||||
const parsePayMessage = (message: string) => {
|
|
||||||
const text = (message || '').trim()
|
|
||||||
if (!text) return null
|
|
||||||
try {
|
|
||||||
return JSON.parse(text)
|
|
||||||
} catch {
|
|
||||||
return text
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const findPaymentUrl = (value: any): string => {
|
|
||||||
if (typeof value === 'string' && /^https?:\/\//i.test(value)) return value
|
|
||||||
if (!value || typeof value !== 'object') return ''
|
|
||||||
for (const key of ['url', 'payUrl', 'mwebUrl', 'mweb_url', 'redirectUrl']) {
|
|
||||||
if (typeof value[key] === 'string' && /^https?:\/\//i.test(value[key])) return value[key]
|
|
||||||
}
|
|
||||||
return ''
|
|
||||||
}
|
|
||||||
|
|
||||||
const invokeWechatJsapi = (params: Record<string, any>) => new Promise<void>((resolve, reject) => {
|
|
||||||
const invoke = () => {
|
|
||||||
const bridge = (window as any).WeixinJSBridge
|
|
||||||
if (!bridge?.invoke) {
|
|
||||||
reject(new Error('当前微信环境无法拉起支付'))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
bridge.invoke('getBrandWCPayRequest', params, (result: any) => {
|
|
||||||
const message = String(result?.err_msg || result?.errMsg || '')
|
|
||||||
if (/\bok\b/i.test(message)) resolve()
|
|
||||||
else reject(new Error(/cancel/i.test(message) ? '支付已取消' : '微信支付未完成'))
|
|
||||||
})
|
|
||||||
}
|
|
||||||
if ((window as any).WeixinJSBridge) invoke()
|
|
||||||
else document.addEventListener('WeixinJSBridgeReady', invoke, { once: true })
|
|
||||||
})
|
|
||||||
|
|
||||||
const launchPayment = async (order: TokenPaymentOrder) => {
|
|
||||||
const parsed = parsePayMessage(order.payMessage)
|
|
||||||
if (order.payWay === 'JSAPI' && order.paymentMethod === 'wechat' && parsed && typeof parsed === 'object') {
|
|
||||||
await invokeWechatJsapi(parsed)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (isInUniWebView() && UniEvents.payment({
|
|
||||||
orderId: order.id,
|
|
||||||
orderNo: order.orderNo,
|
|
||||||
paymentMethod: order.paymentMethod,
|
|
||||||
payType: order.payType,
|
|
||||||
payWay: order.payWay,
|
|
||||||
payMessage: order.payMessage,
|
|
||||||
paymentParams: parsed
|
|
||||||
})) return
|
|
||||||
|
|
||||||
const paymentUrl = findPaymentUrl(parsed)
|
|
||||||
if (paymentUrl) {
|
|
||||||
window.location.assign(paymentUrl)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
throw new Error('请在会会 App 内完成支付')
|
|
||||||
}
|
|
||||||
|
|
||||||
const clearPendingOrder = () => {
|
|
||||||
pendingOrderId.value = ''
|
|
||||||
sessionStorage.removeItem('hh_pending_payment_order')
|
|
||||||
if (pollTimer) window.clearTimeout(pollTimer)
|
|
||||||
pollTimer = undefined
|
|
||||||
}
|
|
||||||
|
|
||||||
const pollPayment = async () => {
|
|
||||||
if (!pendingOrderId.value) return
|
|
||||||
try {
|
|
||||||
const order = await getTokenPaymentStatus(pendingOrderId.value)
|
|
||||||
if (order.status === 'paid') {
|
|
||||||
currentBalance.value = order.balance
|
|
||||||
paymentNoticeTone.value = 'success'
|
|
||||||
paymentNotice.value = `支付成功,${order.pointsAmount.toLocaleString()} 积分已到账`
|
|
||||||
clearPendingOrder()
|
|
||||||
void loadData()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (order.status === 'failed') {
|
|
||||||
paymentNoticeTone.value = 'error'
|
|
||||||
paymentNotice.value = order.failureReason || '支付未完成,请重新发起'
|
|
||||||
clearPendingOrder()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('查询支付结果失败', error)
|
|
||||||
}
|
|
||||||
if (Date.now() < pollDeadline) pollTimer = window.setTimeout(pollPayment, 2000)
|
|
||||||
else {
|
|
||||||
paymentNoticeTone.value = 'pending'
|
|
||||||
paymentNotice.value = '支付结果仍在确认中,可稍后刷新余额或重新发起'
|
|
||||||
clearPendingOrder()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const startPolling = (orderId: string) => {
|
|
||||||
pendingOrderId.value = orderId
|
|
||||||
sessionStorage.setItem('hh_pending_payment_order', orderId)
|
|
||||||
pollDeadline = Date.now() + 2 * 60 * 1000
|
|
||||||
if (pollTimer) window.clearTimeout(pollTimer)
|
|
||||||
void pollPayment()
|
|
||||||
}
|
|
||||||
|
|
||||||
const doCharge = async () => {
|
const doCharge = async () => {
|
||||||
if (!selectedPlan.value || charging.value) return
|
if (!selectedPlan.value || charging.value) return
|
||||||
charging.value = true
|
charging.value = true
|
||||||
paymentNotice.value = ''
|
paymentNotice.value = ''
|
||||||
try {
|
try {
|
||||||
const order = await chargeToken(selectedPlan.value.id, paymentMethod.value, payScene())
|
const order = await chargeTokenOrg(selectedPlan.value.id, 'wechat', payScene())
|
||||||
if (order.status === 'failed') throw new Error(order.failureReason || '支付订单创建失败')
|
if (order.status === 'failed') throw new Error(order.failureReason || '支付订单创建失败')
|
||||||
|
if (!order.providerOrderNo || !/^\d+$/.test(order.providerOrderNo)) throw new Error('会会充值订单号缺失')
|
||||||
paymentNoticeTone.value = 'pending'
|
paymentNoticeTone.value = 'pending'
|
||||||
paymentNotice.value = '支付订单已创建,正在等待付款结果'
|
paymentNotice.value = '支付订单已创建,支付结果将由会会回调确认'
|
||||||
startPolling(order.id)
|
const checkoutBase = window.location.hostname === 'digital.99hui.com'
|
||||||
await launchPayment(order)
|
? 'https://99hui.com/huihui-h5/'
|
||||||
|
: 'https://fat-open.99hui.com/huihui-h5/'
|
||||||
|
const evn = sessionStorage.getItem('hh_launch_evn') || 'wx'
|
||||||
|
const evnPart = `&evn=${encodeURIComponent(evn)}`
|
||||||
|
const tokenPart = order.checkoutToken
|
||||||
|
? `&token=${encodeURIComponent(order.checkoutToken)}`
|
||||||
|
: ''
|
||||||
|
window.location.assign(`${checkoutBase}#/payOrder?id=${encodeURIComponent(order.providerOrderNo)}&isOrgUrl=true${tokenPart}${evnPart}`)
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
paymentNoticeTone.value = 'error'
|
paymentNoticeTone.value = 'error'
|
||||||
paymentNotice.value = error?.message || '充值失败,请重试'
|
paymentNotice.value = error?.message || '充值失败,请重试'
|
||||||
clearPendingOrder()
|
|
||||||
} finally {
|
} finally {
|
||||||
charging.value = false
|
charging.value = false
|
||||||
}
|
}
|
||||||
@@ -371,32 +244,6 @@ const goBack = () => {
|
|||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
void 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>
|
</script>
|
||||||
|
|
||||||
|
|||||||
+3
-2
@@ -19,14 +19,15 @@ services:
|
|||||||
- AES_KEY=your-aes-key-32-chars-change-now!
|
- AES_KEY=your-aes-key-32-chars-change-now!
|
||||||
- AVATAR_MODEL_CONFIG_TOKEN=${AVATAR_MODEL_CONFIG_TOKEN:-}
|
- AVATAR_MODEL_CONFIG_TOKEN=${AVATAR_MODEL_CONFIG_TOKEN:-}
|
||||||
- TZ=Asia/Shanghai
|
- TZ=Asia/Shanghai
|
||||||
- AVATAR_DB_PATH=/app/avatar.db
|
- AVATAR_DB_PATH=/data/avatar-db/avatar.db
|
||||||
- AVATAR_BACKEND_URL=${AVATAR_BACKEND_URL:-}
|
- AVATAR_BACKEND_URL=${AVATAR_BACKEND_URL:-}
|
||||||
- AVATAR_FINANCE_ADMIN_SECRET=${AVATAR_FINANCE_ADMIN_SECRET:-}
|
- AVATAR_FINANCE_ADMIN_SECRET=${AVATAR_FINANCE_ADMIN_SECRET:-}
|
||||||
volumes:
|
volumes:
|
||||||
- ./backend/app:/app/app # ← 核心:代码目录直接挂载,改文件无需重建
|
- ./backend/app:/app/app # ← 核心:代码目录直接挂载,改文件无需重建
|
||||||
- ./backend/logs:/app/logs
|
- ./backend/logs:/app/logs
|
||||||
- ./backend/config:/app/config
|
- ./backend/config:/app/config
|
||||||
- ./digital-avatar-app/backend/avatar.db:/app/avatar.db # 财务管理需要写入退款与开票状态
|
# Mount the directory so admin and avatar services share SQLite WAL/SHM files too.
|
||||||
|
- ./digital-avatar-app/backend:/data/avatar-db
|
||||||
depends_on:
|
depends_on:
|
||||||
- ai-virtual-mysql
|
- ai-virtual-mysql
|
||||||
- ai-virtual-redis
|
- ai-virtual-redis
|
||||||
|
|||||||
@@ -96,11 +96,10 @@ export const uploadAvatarPhoto = (id, formData) => request.post(`/avatars/${id}/
|
|||||||
|
|
||||||
// Finance (数字分身积分订单)
|
// Finance (数字分身积分订单)
|
||||||
export const getFinanceSummary = () => request.get('/finance/summary')
|
export const getFinanceSummary = () => request.get('/finance/summary')
|
||||||
|
export const getFinancePlans = () => request.get('/finance/plans')
|
||||||
|
export const updateFinancePlan = (id, data) => request.patch(`/finance/plans/${id}`, data)
|
||||||
export const getFinanceOrders = (params) => request.get('/finance/orders', { params })
|
export const getFinanceOrders = (params) => request.get('/finance/orders', { params })
|
||||||
export const updateFinanceOrderStatus = (orderNo, data) => request.patch(`/finance/orders/${orderNo}/status`, data)
|
export const updateFinanceOrderStatus = (orderNo, data) => request.patch(`/finance/orders/${orderNo}/status`, data)
|
||||||
export const requestFinanceRefund = (orderNo, data) => request.post(`/finance/orders/${orderNo}/refund`, data)
|
|
||||||
export const getFinanceRefunds = (params) => request.get('/finance/refunds', { params })
|
|
||||||
export const confirmFinanceRefund = (refundNo, data) => request.post(`/finance/refunds/${refundNo}/confirm`, data)
|
|
||||||
export const getFinanceInvoices = (params) => request.get('/finance/invoices', { params })
|
export const getFinanceInvoices = (params) => request.get('/finance/invoices', { params })
|
||||||
export const updateFinanceInvoice = (invoiceId, data) => request.patch(`/finance/invoices/${invoiceId}`, data)
|
export const updateFinanceInvoice = (invoiceId, data) => request.patch(`/finance/invoices/${invoiceId}`, data)
|
||||||
|
|
||||||
|
|||||||
@@ -11,11 +11,20 @@
|
|||||||
<div class="summary-grid">
|
<div class="summary-grid">
|
||||||
<div class="stat-card"><div class="stat-label">实收金额</div><div class="stat-value">¥{{ money(summary.paid_revenue) }}</div><small>{{ summary.paid_orders }} 笔已支付</small></div>
|
<div class="stat-card"><div class="stat-label">实收金额</div><div class="stat-value">¥{{ money(summary.paid_revenue) }}</div><small>{{ summary.paid_orders }} 笔已支付</small></div>
|
||||||
<div class="stat-card"><div class="stat-label">待支付订单</div><div class="stat-value warning">{{ summary.pending_orders }}</div><small>可关闭或标记失败</small></div>
|
<div class="stat-card"><div class="stat-label">待支付订单</div><div class="stat-value warning">{{ summary.pending_orders }}</div><small>可关闭或标记失败</small></div>
|
||||||
<div class="stat-card"><div class="stat-label">处理中退款</div><div class="stat-value danger">{{ summary.processing_refunds }}</div><small>等待支付渠道确认</small></div>
|
|
||||||
<div class="stat-card"><div class="stat-label">待开发票</div><div class="stat-value success">{{ summary.pending_invoices }}</div><small>等待财务处理</small></div>
|
<div class="stat-card"><div class="stat-label">待开发票</div><div class="stat-value success">{{ summary.pending_invoices }}</div><small>等待财务处理</small></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<el-tabs v-model="activeTab" class="finance-tabs" @tab-change="loadActive">
|
<el-tabs v-model="activeTab" class="finance-tabs" @tab-change="loadActive">
|
||||||
|
<el-tab-pane label="充值套餐" name="plans">
|
||||||
|
<el-table :data="plans" v-loading="loading" class="data-table">
|
||||||
|
<el-table-column label="套餐名称" prop="name" min-width="150" />
|
||||||
|
<el-table-column label="积分" min-width="160"><template #default="{ row }">{{ Number(row.amount).toLocaleString() }}</template></el-table-column>
|
||||||
|
<el-table-column label="价格" width="140"><template #default="{ row }">¥{{ money(row.price) }}</template></el-table-column>
|
||||||
|
<el-table-column label="标签" prop="badge" min-width="120" />
|
||||||
|
<el-table-column label="说明" prop="desc" min-width="180" />
|
||||||
|
<el-table-column label="操作" width="100"><template #default="{ row }"><el-button link type="primary" @click="editPlan(row)">调整</el-button></template></el-table-column>
|
||||||
|
</el-table>
|
||||||
|
</el-tab-pane>
|
||||||
<el-tab-pane label="支付订单" name="orders">
|
<el-tab-pane label="支付订单" name="orders">
|
||||||
<div class="filter-bar">
|
<div class="filter-bar">
|
||||||
<el-input v-model="filters.keyword" placeholder="订单号、昵称或手机号" clearable style="width:240px" @keyup.enter="loadOrders" />
|
<el-input v-model="filters.keyword" placeholder="订单号、昵称或手机号" clearable style="width:240px" @keyup.enter="loadOrders" />
|
||||||
@@ -23,13 +32,13 @@
|
|||||||
<el-option v-for="item in orderStatuses" :key="item.value" :label="item.label" :value="item.value" />
|
<el-option v-for="item in orderStatuses" :key="item.value" :label="item.label" :value="item.value" />
|
||||||
</el-select>
|
</el-select>
|
||||||
<el-select v-model="filters.provider" placeholder="支付渠道" clearable style="width:150px" @change="loadOrders">
|
<el-select v-model="filters.provider" placeholder="支付渠道" clearable style="width:150px" @change="loadOrders">
|
||||||
<el-option label="会会支付" value="huihui" /><el-option label="微信虚拟支付" value="wechat_virtual" />
|
<el-option label="会会支付" value="huihui" /><el-option label="会会组织支付" value="huihui_org" /><el-option label="微信虚拟支付" value="wechat_virtual" />
|
||||||
</el-select>
|
</el-select>
|
||||||
<el-button type="primary" @click="loadOrders">查询</el-button>
|
<el-button type="primary" @click="loadOrders">查询</el-button>
|
||||||
</div>
|
</div>
|
||||||
<el-table :data="orders" v-loading="loading" class="data-table">
|
<el-table :data="orders" v-loading="loading" class="data-table">
|
||||||
<el-table-column label="订单 / 用户" min-width="230">
|
<el-table-column label="订单 / 用户" min-width="260">
|
||||||
<template #default="{ row }"><b>{{ row.order_no }}</b><div class="muted">{{ row.user_nickname || '会会用户' }} · {{ row.user_phone || '--' }}</div></template>
|
<template #default="{ row }"><b>{{ row.order_no }}</b><div class="muted">支付编号:{{ row.provider_order_no || '--' }}</div><div class="muted">{{ row.user_nickname || '会会用户' }} · {{ row.user_phone || '--' }}</div></template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column label="套餐积分" width="130"><template #default="{ row }">{{ Number(row.points_amount || 0).toLocaleString() }}</template></el-table-column>
|
<el-table-column label="套餐积分" width="130"><template #default="{ row }">{{ Number(row.points_amount || 0).toLocaleString() }}</template></el-table-column>
|
||||||
<el-table-column label="金额" width="100"><template #default="{ row }">¥{{ money(row.price) }}</template></el-table-column>
|
<el-table-column label="金额" width="100"><template #default="{ row }">¥{{ money(row.price) }}</template></el-table-column>
|
||||||
@@ -39,33 +48,16 @@
|
|||||||
<el-table-column label="下单时间" min-width="165"><template #default="{ row }">{{ dateTime(row.created_at) }}</template></el-table-column>
|
<el-table-column label="下单时间" min-width="165"><template #default="{ row }">{{ dateTime(row.created_at) }}</template></el-table-column>
|
||||||
<el-table-column label="操作" width="165" fixed="right">
|
<el-table-column label="操作" width="165" fixed="right">
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
<el-button v-if="row.status === 'paid' && ['none','',null].includes(row.refund_status)" link type="danger" @click="refund(row)">退款</el-button>
|
|
||||||
<el-dropdown v-if="row.status === 'pending'" @command="command => closeOrder(row, command)">
|
<el-dropdown v-if="row.status === 'pending'" @command="command => closeOrder(row, command)">
|
||||||
<el-button link type="warning">状态处理<el-icon><ArrowDown /></el-icon></el-button>
|
<el-button link type="warning">状态处理<el-icon><ArrowDown /></el-icon></el-button>
|
||||||
<template #dropdown><el-dropdown-menu><el-dropdown-item command="closed">关闭订单</el-dropdown-item><el-dropdown-item command="failed">标记失败</el-dropdown-item></el-dropdown-menu></template>
|
<template #dropdown><el-dropdown-menu><el-dropdown-item command="closed">关闭订单</el-dropdown-item><el-dropdown-item command="failed">标记失败</el-dropdown-item></el-dropdown-menu></template>
|
||||||
</el-dropdown>
|
</el-dropdown>
|
||||||
<span v-if="row.status !== 'pending' && !(row.status === 'paid' && ['none','',null].includes(row.refund_status))" class="muted">已处理</span>
|
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
</el-table>
|
</el-table>
|
||||||
<el-pagination v-model:current-page="orderPage" :page-size="20" :total="orderTotal" layout="total, prev, pager, next" @change="loadOrders" />
|
<el-pagination v-model:current-page="orderPage" :page-size="20" :total="orderTotal" layout="total, prev, pager, next" @change="loadOrders" />
|
||||||
</el-tab-pane>
|
</el-tab-pane>
|
||||||
|
|
||||||
<el-tab-pane label="退款管理" name="refunds">
|
|
||||||
<el-table :data="refunds" v-loading="loading" class="data-table">
|
|
||||||
<el-table-column label="退款单号" min-width="220" prop="refund_no" />
|
|
||||||
<el-table-column label="原订单" min-width="210" prop="order_no" />
|
|
||||||
<el-table-column label="用户" width="150"><template #default="{ row }">{{ row.user_nickname || row.user_phone || '--' }}</template></el-table-column>
|
|
||||||
<el-table-column label="金额" width="100"><template #default="{ row }">¥{{ money(row.amount) }}</template></el-table-column>
|
|
||||||
<el-table-column label="状态" width="110"><template #default="{ row }"><el-tag :type="tagType(row.status)">{{ statusLabel[row.status] || row.status }}</el-tag></template></el-table-column>
|
|
||||||
<el-table-column label="原因" min-width="160" prop="reason" show-overflow-tooltip />
|
|
||||||
<el-table-column label="创建时间" min-width="165"><template #default="{ row }">{{ dateTime(row.created_at) }}</template></el-table-column>
|
|
||||||
<el-table-column label="对账确认" width="155" fixed="right">
|
|
||||||
<template #default="{ row }"><template v-if="['pending','processing'].includes(row.status)"><el-button link type="success" @click="confirmRefund(row, 'succeeded')">已退款</el-button><el-button link type="danger" @click="confirmRefund(row, 'failed')">失败</el-button></template><span v-else class="muted">{{ row.failure_reason || '已完成' }}</span></template>
|
|
||||||
</el-table-column>
|
|
||||||
</el-table>
|
|
||||||
</el-tab-pane>
|
|
||||||
|
|
||||||
<el-tab-pane label="发票管理" name="invoices">
|
<el-tab-pane label="发票管理" name="invoices">
|
||||||
<el-table :data="invoices" v-loading="loading" class="data-table">
|
<el-table :data="invoices" v-loading="loading" class="data-table">
|
||||||
<el-table-column label="订单号" min-width="210" prop="order_no" />
|
<el-table-column label="订单号" min-width="210" prop="order_no" />
|
||||||
@@ -87,25 +79,37 @@
|
|||||||
</el-form>
|
</el-form>
|
||||||
<template #footer><el-button @click="invoiceDialog=false">取消</el-button><el-button type="primary" :loading="saving" @click="issueInvoice">确认开具</el-button></template>
|
<template #footer><el-button @click="invoiceDialog=false">取消</el-button><el-button type="primary" :loading="saving" @click="issueInvoice">确认开具</el-button></template>
|
||||||
</el-dialog>
|
</el-dialog>
|
||||||
|
<el-dialog v-model="planDialog" title="调整充值套餐" width="480px">
|
||||||
|
<el-form label-width="90px">
|
||||||
|
<el-form-item label="套餐名称"><el-input v-model="planForm.name" maxlength="80" /></el-form-item>
|
||||||
|
<el-form-item label="积分数量"><el-input-number v-model="planForm.amount" :min="1" :step="10000" /></el-form-item>
|
||||||
|
<el-form-item label="套餐价格"><el-input-number v-model="planForm.price" :min="0" :precision="2" :step="1" /></el-form-item>
|
||||||
|
<el-form-item label="标签"><el-input v-model="planForm.badge" maxlength="80" /></el-form-item>
|
||||||
|
<el-form-item label="说明"><el-input v-model="planForm.desc" maxlength="200" /></el-form-item>
|
||||||
|
</el-form>
|
||||||
|
<template #footer><el-button @click="planDialog=false">取消</el-button><el-button type="primary" :loading="saving" @click="savePlan">保存</el-button></template>
|
||||||
|
</el-dialog>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { onMounted, ref } from 'vue'
|
import { onMounted, ref } from 'vue'
|
||||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||||
import { confirmFinanceRefund, getFinanceInvoices, getFinanceOrders, getFinanceRefunds, getFinanceSummary, requestFinanceRefund, updateFinanceInvoice, updateFinanceOrderStatus } from '@/api'
|
import { getFinanceInvoices, getFinanceOrders, getFinancePlans, getFinanceSummary, updateFinanceInvoice, updateFinanceOrderStatus, updateFinancePlan } from '@/api'
|
||||||
|
|
||||||
const activeTab = ref('orders')
|
const activeTab = ref('orders')
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const saving = ref(false)
|
const saving = ref(false)
|
||||||
const summary = ref({ paid_revenue: 0, paid_orders: 0, pending_orders: 0, processing_refunds: 0, pending_invoices: 0 })
|
const summary = ref({ paid_revenue: 0, paid_orders: 0, pending_orders: 0, pending_invoices: 0 })
|
||||||
const orders = ref([]), refunds = ref([]), invoices = ref([])
|
const orders = ref([]), invoices = ref([])
|
||||||
|
const plans = ref([]), planDialog = ref(false), currentPlan = ref(null)
|
||||||
|
const planForm = ref({ name: '', amount: 1, price: 0, badge: '', desc: '' })
|
||||||
const orderPage = ref(1), orderTotal = ref(0)
|
const orderPage = ref(1), orderTotal = ref(0)
|
||||||
const filters = ref({ keyword: '', status: '', provider: '' })
|
const filters = ref({ keyword: '', status: '', provider: '' })
|
||||||
const invoiceDialog = ref(false), currentInvoice = ref(null)
|
const invoiceDialog = ref(false), currentInvoice = ref(null)
|
||||||
const invoiceForm = ref({ invoiceNo: '', invoiceUrl: '', remark: '' })
|
const invoiceForm = ref({ invoiceNo: '', invoiceUrl: '', remark: '' })
|
||||||
const orderStatuses = [{ label: '待支付', value: 'pending' }, { label: '已支付', value: 'paid' }, { label: '失败', value: 'failed' }, { label: '已关闭', value: 'closed' }, { label: '已退款', value: 'refunded' }]
|
const orderStatuses = [{ label: '待支付', value: 'pending' }, { label: '已支付', value: 'paid' }, { label: '失败', value: 'failed' }, { label: '已关闭', value: 'closed' }, { label: '已退款', value: 'refunded' }]
|
||||||
const providerLabel = { huihui: '会会支付', wechat_virtual: '微信虚拟支付' }
|
const providerLabel = { huihui: '会会支付', huihui_org: '会会组织支付', wechat_virtual: '微信虚拟支付' }
|
||||||
const statusLabel = { pending: '待处理', processing: '处理中', paid: '已支付', succeeded: '已成功', failed: '失败', closed: '已关闭', refunded: '已退款' }
|
const statusLabel = { pending: '待处理', processing: '处理中', paid: '已支付', succeeded: '已成功', failed: '失败', closed: '已关闭', refunded: '已退款' }
|
||||||
const invoiceStatusLabel = { pending: '待开具', issued: '已开具', rejected: '已驳回' }
|
const invoiceStatusLabel = { pending: '待开具', issued: '已开具', rejected: '已驳回' }
|
||||||
const money = value => Number(value || 0).toFixed(2)
|
const money = value => Number(value || 0).toFixed(2)
|
||||||
@@ -113,17 +117,15 @@ const dateTime = value => value ? new Date(value).toLocaleString('zh-CN', { hour
|
|||||||
const tagType = value => ({ paid: 'success', succeeded: 'success', issued: 'success', pending: 'warning', processing: 'warning', failed: 'danger', rejected: 'danger', refunded: 'info', closed: 'info' }[value] || 'info')
|
const tagType = value => ({ paid: 'success', succeeded: 'success', issued: 'success', pending: 'warning', processing: 'warning', failed: 'danger', rejected: 'danger', refunded: 'info', closed: 'info' }[value] || 'info')
|
||||||
|
|
||||||
async function loadSummary() { const res = await getFinanceSummary(); summary.value = res.data || summary.value }
|
async function loadSummary() { const res = await getFinanceSummary(); summary.value = res.data || summary.value }
|
||||||
|
async function loadPlans() { const res = await getFinancePlans(); plans.value = res.data || [] }
|
||||||
async function loadOrders() { const res = await getFinanceOrders({ page: orderPage.value, page_size: 20, ...filters.value }); orders.value = res.data?.items || []; orderTotal.value = res.data?.total || 0 }
|
async function loadOrders() { const res = await getFinanceOrders({ page: orderPage.value, page_size: 20, ...filters.value }); orders.value = res.data?.items || []; orderTotal.value = res.data?.total || 0 }
|
||||||
async function loadRefunds() { const res = await getFinanceRefunds({ page: 1, page_size: 100 }); refunds.value = res.data?.items || [] }
|
|
||||||
async function loadInvoices() { const res = await getFinanceInvoices({ page: 1, page_size: 100 }); invoices.value = res.data?.items || [] }
|
async function loadInvoices() { const res = await getFinanceInvoices({ page: 1, page_size: 100 }); invoices.value = res.data?.items || [] }
|
||||||
async function loadActive() { loading.value = true; try { if (activeTab.value === 'orders') await loadOrders(); if (activeTab.value === 'refunds') await loadRefunds(); if (activeTab.value === 'invoices') await loadInvoices() } finally { loading.value = false } }
|
async function loadActive() { loading.value = true; try { if (activeTab.value === 'plans') await loadPlans(); if (activeTab.value === 'orders') await loadOrders(); if (activeTab.value === 'invoices') await loadInvoices() } finally { loading.value = false } }
|
||||||
async function loadAll() { loading.value = true; try { await Promise.all([loadSummary(), loadOrders(), loadRefunds(), loadInvoices()]) } finally { loading.value = false } }
|
async function loadAll() { loading.value = true; try { await Promise.all([loadSummary(), loadPlans(), loadOrders(), loadInvoices()]) } finally { loading.value = false } }
|
||||||
|
function editPlan(row) { currentPlan.value = row; planForm.value = { name: row.name, amount: Number(row.amount), price: Number(row.price), badge: row.badge || '', desc: row.desc || '' }; planDialog.value = true }
|
||||||
|
async function savePlan() { if (!currentPlan.value) return; saving.value = true; try { await updateFinancePlan(currentPlan.value.id, planForm.value); ElMessage.success('充值套餐已更新'); planDialog.value = false; await loadPlans() } finally { saving.value = false } }
|
||||||
|
|
||||||
async function refund(row) {
|
|
||||||
try { const { value } = await ElMessageBox.prompt('退款成功后会扣回本订单发放的全部积分。', `退款 ¥${money(row.price)}`, { inputPlaceholder: '请输入退款原因', inputValidator: value => value?.trim() ? true : '退款原因不能为空', confirmButtonText: '提交退款', cancelButtonText: '取消' }); await requestFinanceRefund(row.order_no, { reason: value, operator: '后台管理员' }); ElMessage.success('退款已提交渠道处理'); await loadAll() } catch (error) { if (error !== 'cancel' && error !== 'close') console.error(error) }
|
|
||||||
}
|
|
||||||
async function closeOrder(row, status) { try { await ElMessageBox.confirm(`确认将订单标记为“${status === 'closed' ? '已关闭' : '失败'}”?`, '订单状态处理', { type: 'warning' }); await updateFinanceOrderStatus(row.order_no, { status, reason: '后台管理员处理' }); ElMessage.success('订单状态已更新'); await loadAll() } catch {} }
|
async function closeOrder(row, status) { try { await ElMessageBox.confirm(`确认将订单标记为“${status === 'closed' ? '已关闭' : '失败'}”?`, '订单状态处理', { type: 'warning' }); await updateFinanceOrderStatus(row.order_no, { status, reason: '后台管理员处理' }); ElMessage.success('订单状态已更新'); await loadAll() } catch {} }
|
||||||
async function confirmRefund(row, status) { try { const message = status === 'succeeded' ? '仅在支付渠道后台已确认退款到账时操作。' : '确认供应商退款失败?'; await ElMessageBox.confirm(message, '退款对账确认', { type: 'warning' }); await confirmFinanceRefund(row.refund_no, { status, failureReason: status === 'failed' ? '供应商退款失败' : '' }); ElMessage.success('退款结果已登记'); await loadAll() } catch {} }
|
|
||||||
function openInvoice(row) { currentInvoice.value = row; invoiceForm.value = { invoiceNo: '', invoiceUrl: '', remark: '' }; invoiceDialog.value = true }
|
function openInvoice(row) { currentInvoice.value = row; invoiceForm.value = { invoiceNo: '', invoiceUrl: '', remark: '' }; invoiceDialog.value = true }
|
||||||
async function issueInvoice() { if (!invoiceForm.value.invoiceNo.trim()) return ElMessage.warning('请填写发票号码'); saving.value = true; try { await updateFinanceInvoice(currentInvoice.value.id, { status: 'issued', ...invoiceForm.value }); ElMessage.success('发票已登记'); invoiceDialog.value = false; await loadAll() } finally { saving.value = false } }
|
async function issueInvoice() { if (!invoiceForm.value.invoiceNo.trim()) return ElMessage.warning('请填写发票号码'); saving.value = true; try { await updateFinanceInvoice(currentInvoice.value.id, { status: 'issued', ...invoiceForm.value }); ElMessage.success('发票已登记'); invoiceDialog.value = false; await loadAll() } finally { saving.value = false } }
|
||||||
async function rejectInvoice(row) { try { const { value } = await ElMessageBox.prompt('请输入驳回原因', '驳回发票申请', { inputValidator: value => value?.trim() ? true : '驳回原因不能为空' }); await updateFinanceInvoice(row.id, { status: 'rejected', remark: value }); ElMessage.success('发票申请已驳回'); await loadAll() } catch {} }
|
async function rejectInvoice(row) { try { const { value } = await ElMessageBox.prompt('请输入驳回原因', '驳回发票申请', { inputValidator: value => value?.trim() ? true : '驳回原因不能为空' }); await updateFinanceInvoice(row.id, { status: 'rejected', remark: value }); ElMessage.success('发票申请已驳回'); await loadAll() } catch {} }
|
||||||
|
|||||||
@@ -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() {
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
Reference in New Issue
Block a user