Compare commits

...
35 changed files with 2200 additions and 187 deletions
+11 -1
View File
@@ -38,7 +38,7 @@ def init_db():
("avatars", "owner_id", "VARCHAR DEFAULT ''"), ("avatars", "owner_id", "VARCHAR DEFAULT ''"),
("authorizations", "takeover_enabled", "BOOLEAN DEFAULT 0"), ("authorizations", "takeover_enabled", "BOOLEAN DEFAULT 0"),
("authorizations", "takeover_mode", "VARCHAR DEFAULT 'immediate'"), ("authorizations", "takeover_mode", "VARCHAR DEFAULT 'immediate'"),
("authorizations", "takeover_delay_seconds", "INTEGER DEFAULT 30"), ("authorizations", "takeover_delay_seconds", "INTEGER DEFAULT 180"),
("avatars", "share_token", "VARCHAR DEFAULT NULL"), ("avatars", "share_token", "VARCHAR DEFAULT NULL"),
("token_account", "user_id", "VARCHAR DEFAULT ''"), ("token_account", "user_id", "VARCHAR DEFAULT ''"),
("token_account", "total_granted", "BIGINT DEFAULT 0"), ("token_account", "total_granted", "BIGINT DEFAULT 0"),
@@ -47,6 +47,7 @@ def init_db():
("token_account", "updated_at", "TIMESTAMP"), ("token_account", "updated_at", "TIMESTAMP"),
) )
_normalize_optional_unique_values() _normalize_optional_unique_values()
_normalize_takeover_delays()
_create_token_indexes() _create_token_indexes()
@@ -66,6 +67,15 @@ def _normalize_optional_unique_values():
conn.exec_driver_sql("UPDATE avatars SET share_token = NULL WHERE share_token = ''") conn.exec_driver_sql("UPDATE avatars SET share_token = NULL WHERE share_token = ''")
def _normalize_takeover_delays():
with engine.begin() as conn:
# The old 30-second column default was never wired into the scheduler.
conn.exec_driver_sql(
"UPDATE authorizations SET takeover_delay_seconds = 180 "
"WHERE takeover_delay_seconds IS NULL OR takeover_delay_seconds = 30"
)
def _create_token_indexes(): def _create_token_indexes():
with engine.begin() as conn: with engine.begin() as conn:
conn.exec_driver_sql( conn.exec_driver_sql(
+4 -4
View File
@@ -58,10 +58,10 @@ def seed():
db = SessionLocal() db = SessionLocal()
try: try:
plan_specs = [ plan_specs = [
{"id": "1", "name": "基础套餐", "amount": 2_000_000, "price": 10, "badge": "", "desc": "2M Token"}, {"id": "1", "name": "基础套餐", "amount": 2_000_000, "price": 10, "badge": "", "desc": "2M 积分"},
{"id": "2", "name": "标准套餐", "amount": 20_000_000, "price": 100, "badge": "常用", "desc": "20M Token"}, {"id": "2", "name": "标准套餐", "amount": 20_000_000, "price": 100, "badge": "常用", "desc": "20M 积分"},
{"id": "3", "name": "专业套餐", "amount": 250_000_000, "price": 1000, "badge": "加赠25%", "desc": "250M Token"}, {"id": "3", "name": "专业套餐", "amount": 250_000_000, "price": 1000, "badge": "加赠25%", "desc": "250M 积分"},
{"id": "4", "name": "企业套餐", "amount": 2_500_000_000, "price": 10000, "badge": "企业推荐", "desc": "2500M Token"}, {"id": "4", "name": "企业套餐", "amount": 2_500_000_000, "price": 10000, "badge": "企业推荐", "desc": "2500M 积分"},
] ]
for spec in plan_specs: for spec in plan_specs:
plan = db.query(TokenPlan).filter(TokenPlan.id == spec["id"]).first() plan = db.query(TokenPlan).filter(TokenPlan.id == spec["id"]).first()
+43 -2
View File
@@ -67,7 +67,7 @@ class Authorization(Base):
status = Column(String, default="active") # active | inactive status = Column(String, default="active") # active | inactive
takeover_enabled = Column(Boolean, default=False) # 是否开启分身接管 takeover_enabled = Column(Boolean, default=False) # 是否开启分身接管
takeover_mode = Column(String, default="immediate") # immediate | delayed takeover_mode = Column(String, default="immediate") # immediate | delayed
takeover_delay_seconds = Column(Integer, default=30) # 延迟秒数 takeover_delay_seconds = Column(Integer, default=180) # 延迟秒数,默认 3 分钟
created_at = Column(DateTime, server_default=func.now()) created_at = Column(DateTime, server_default=func.now())
def to_dict(self): def to_dict(self):
@@ -188,7 +188,7 @@ class KnowledgeDoc(Base):
file_type = Column(String, default="") # pdf | doc | docx | xlsx file_type = Column(String, default="") # pdf | doc | docx | xlsx
file_size = Column(Integer, default=0) file_size = Column(Integer, default=0)
file_url = Column(String, default="") file_url = Column(String, default="")
status = Column(String, default="uploaded") # uploaded | parsing | ready status = Column(String, default="uploaded") # uploaded | parsing | ready | failed
vectorized = Column(Boolean, default=False) # 是否已向量化 vectorized = Column(Boolean, default=False) # 是否已向量化
embedding_model = Column(String, default="") # 向量模型标识 embedding_model = Column(String, default="") # 向量模型标识
chunk_count = Column(Integer, default=0) # 切片数量 chunk_count = Column(Integer, default=0) # 切片数量
@@ -311,6 +311,47 @@ class TokenPlan(Base):
} }
class TokenPaymentOrder(Base):
__tablename__ = "token_payment_orders"
id = Column(String, primary_key=True, default=lambda: uuid.uuid4().hex)
order_no = Column(String, nullable=False, unique=True, index=True)
user_id = Column(String, nullable=False, index=True)
plan_id = Column(String, nullable=False)
payment_method = Column(String, nullable=False)
pay_type = Column(String, nullable=False)
pay_way = Column(String, nullable=False)
points_amount = Column(BigInteger, nullable=False)
price_cents = Column(Integer, nullable=False)
status = Column(String, nullable=False, default="pending", index=True)
provider_order_id = Column(String, default="")
provider_order_no = Column(String, default="")
provider_status = Column(String, default="")
pay_message = Column(Text, default="")
failure_reason = Column(String, default="")
created_at = Column(DateTime, server_default=func.now())
updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now())
paid_at = Column(DateTime)
def to_dict(self):
return {
"id": self.id,
"orderNo": self.order_no,
"planId": self.plan_id,
"paymentMethod": self.payment_method,
"payType": self.pay_type,
"payWay": self.pay_way,
"pointsAmount": self.points_amount,
"price": self.price_cents / 100,
"status": self.status,
"providerStatus": self.provider_status,
"payMessage": self.pay_message,
"failureReason": self.failure_reason,
"createdAt": _iso(self.created_at),
"paidAt": _iso(self.paid_at),
}
class User(Base): class User(Base):
"""会会用户 ↔ 本地用户体系映射(短信验证码登录落库)""" """会会用户 ↔ 本地用户体系映射(短信验证码登录落库)"""
@@ -2,7 +2,7 @@ from fastapi import APIRouter, Body, Depends, Header, HTTPException
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from database import get_db from database import get_db
from models import Authorization, TakeoverCursor, TakeoverReplyTask from models import Authorization, Avatar, TakeoverCursor, TakeoverReplyTask
from responses import fail, ok from responses import fail, ok
from routers.avatars import _require_owned_avatar from routers.avatars import _require_owned_avatar
@@ -14,6 +14,10 @@ ALLOWED_PERMISSIONS = set(PERMISSION_ORDER)
AVATAR_PERMISSION_ORDER = PERMISSION_ORDER AVATAR_PERMISSION_ORDER = PERMISSION_ORDER
AVATAR_PERMISSION_KEY = "authorizationPermissions" AVATAR_PERMISSION_KEY = "authorizationPermissions"
DEFAULT_AVATAR_PERMISSIONS = ["friend", "chat"] DEFAULT_AVATAR_PERMISSIONS = ["friend", "chat"]
TAKEOVER_DELAY_KEY = "takeoverReplyDelaySeconds"
DEFAULT_TAKEOVER_DELAY_SECONDS = 180
MIN_TAKEOVER_DELAY_SECONDS = 3
MAX_TAKEOVER_DELAY_SECONDS = 86_400
LEGACY_PERMISSION_MAP = { LEGACY_PERMISSION_MAP = {
"read": "browse", "read": "browse",
"reply": "chat", "reply": "chat",
@@ -91,9 +95,62 @@ def _permission_settings_payload(avatar) -> dict:
return { return {
"avatarId": avatar.id, "avatarId": avatar.id,
"permissions": _stored_avatar_permissions(avatar), "permissions": _stored_avatar_permissions(avatar),
"takeoverReplyDelaySeconds": _stored_takeover_delay(avatar),
} }
def _stored_takeover_delay(avatar) -> int:
raw = (avatar.config or {}).get(TAKEOVER_DELAY_KEY, DEFAULT_TAKEOVER_DELAY_SECONDS)
if isinstance(raw, bool):
return DEFAULT_TAKEOVER_DELAY_SECONDS
try:
delay = int(raw)
except (TypeError, ValueError):
return DEFAULT_TAKEOVER_DELAY_SECONDS
if not MIN_TAKEOVER_DELAY_SECONDS <= delay <= MAX_TAKEOVER_DELAY_SECONDS:
return DEFAULT_TAKEOVER_DELAY_SECONDS
return delay
def _validate_takeover_delay(value) -> int:
if isinstance(value, bool) or not isinstance(value, int):
raise ValueError("自动回复等待时间必须是整数秒")
if not MIN_TAKEOVER_DELAY_SECONDS <= value <= MAX_TAKEOVER_DELAY_SECONDS:
raise ValueError("自动回复等待时间需在 3 秒到 24 小时之间")
return value
def _disable_other_takeovers(db: Session, avatar) -> list[str]:
disabled_ids = []
others = (
db.query(Avatar)
.filter(Avatar.owner_id == avatar.owner_id, Avatar.id != avatar.id)
.all()
)
for other in others:
permissions = _stored_avatar_permissions(other)
if "takeover" not in permissions:
continue
other.config = {
**(other.config or {}),
AVATAR_PERMISSION_KEY: [item for item in permissions if item != "takeover"],
}
disabled_ids.append(other.id)
tasks = (
db.query(TakeoverReplyTask)
.filter(
TakeoverReplyTask.avatar_id == other.id,
TakeoverReplyTask.status.in_(("pending", "generating", "ready", "sending")),
)
.all()
)
for task in tasks:
task.status = "cancelled"
task.cancel_reason = "another_avatar_takeover_enabled"
task.locked_at = None
return disabled_ids
def _require_authorization(db: Session, avatar_id: str, authorization_id: str) -> Authorization: def _require_authorization(db: Session, avatar_id: str, authorization_id: str) -> Authorization:
authorization = ( authorization = (
db.query(Authorization) db.query(Authorization)
@@ -144,10 +201,19 @@ def update_permission_settings(
db: Session = Depends(get_db), db: Session = Depends(get_db),
): ):
avatar = _require_owned_avatar(db, avatar_id, authorization) avatar = _require_owned_avatar(db, avatar_id, authorization)
if "permissions" not in payload: if "permissions" not in payload and TAKEOVER_DELAY_KEY not in payload:
return fail("缺少 permissions", 400) return fail("缺少授权设置", 400)
try: try:
permissions = _normalize_avatar_permissions(payload["permissions"]) permissions = (
_normalize_avatar_permissions(payload["permissions"])
if "permissions" in payload
else _stored_avatar_permissions(avatar)
)
takeover_delay = (
_validate_takeover_delay(payload[TAKEOVER_DELAY_KEY])
if TAKEOVER_DELAY_KEY in payload
else _stored_takeover_delay(avatar)
)
except ValueError as exc: except ValueError as exc:
return fail(str(exc), 400) return fail(str(exc), 400)
@@ -155,7 +221,9 @@ def update_permission_settings(
avatar.config = { avatar.config = {
**(avatar.config or {}), **(avatar.config or {}),
AVATAR_PERMISSION_KEY: permissions, AVATAR_PERMISSION_KEY: permissions,
TAKEOVER_DELAY_KEY: takeover_delay,
} }
disabled_avatar_ids = _disable_other_takeovers(db, avatar) if "takeover" in permissions else []
cursor = db.query(TakeoverCursor).filter(TakeoverCursor.avatar_id == avatar.id).first() cursor = db.query(TakeoverCursor).filter(TakeoverCursor.avatar_id == avatar.id).first()
if cursor and "takeover" in permissions and "takeover" not in previous_permissions: if cursor and "takeover" in permissions and "takeover" not in previous_permissions:
cursor.initialized = False cursor.initialized = False
@@ -179,7 +247,9 @@ def update_permission_settings(
task.locked_at = None task.locked_at = None
db.commit() db.commit()
db.refresh(avatar) db.refresh(avatar)
return ok(_permission_settings_payload(avatar), "授权设置已保存") response = _permission_settings_payload(avatar)
response["disabledAvatarIds"] = disabled_avatar_ids
return ok(response, "授权设置已保存")
@router.get("/avatar/{avatar_id}/authorizations") @router.get("/avatar/{avatar_id}/authorizations")
@@ -240,7 +310,7 @@ def create_auth(
status="active", status="active",
takeover_enabled=False, takeover_enabled=False,
takeover_mode="immediate", takeover_mode="immediate",
takeover_delay_seconds=30, takeover_delay_seconds=DEFAULT_TAKEOVER_DELAY_SECONDS,
) )
db.add(item) db.add(item)
db.commit() db.commit()
+42 -16
View File
@@ -6,7 +6,17 @@ from sqlalchemy.orm import Session
from database import get_db from database import get_db
from routers.knowledge import UPLOAD_DIR from routers.knowledge import UPLOAD_DIR
from models import Avatar, KnowledgeDoc, KnowledgeChunk, QAPair, Authorization, User from models import (
Authorization,
Avatar,
KnowledgeChunk,
KnowledgeDoc,
QAPair,
TakeoverCursor,
TakeoverMessage,
TakeoverReplyTask,
User,
)
from responses import ok, fail from responses import ok, fail
router = APIRouter(tags=["分身"]) router = APIRouter(tags=["分身"])
@@ -74,18 +84,21 @@ def list_avatars(page: int = 1, limit: int = 20, authorization: str = Header(Non
@router.get("/avatar/{avatar_id}") @router.get("/avatar/{avatar_id}")
def get_avatar(avatar_id: str, db: Session = Depends(get_db)): def get_avatar(
a = db.query(Avatar).filter(Avatar.id == avatar_id).first() avatar_id: str,
if not a: authorization: str = Header(None),
return fail("分身不存在", 404) db: Session = Depends(get_db),
return ok(a.to_dict()) ):
return ok(_require_owned_avatar(db, avatar_id, authorization).to_dict())
@router.post("/avatar") @router.post("/avatar")
def create_avatar(payload: dict = Body(...), authorization: str = Header(None), db: Session = Depends(get_db)): def create_avatar(payload: dict = Body(...), authorization: str = Header(None), db: Session = Depends(get_db)):
user = _resolve_user(authorization, db) user = _resolve_user(authorization, db)
if not user:
raise HTTPException(status_code=401, detail="未登录")
a = Avatar( a = Avatar(
owner_id=user.huihui_user_id if user else "", owner_id=user.huihui_user_id,
name=payload.get("name", "未命名分身"), name=payload.get("name", "未命名分身"),
display_name=payload.get("displayName", "") or payload.get("display_name", ""), display_name=payload.get("displayName", "") or payload.get("display_name", ""),
description=payload.get("description", ""), description=payload.get("description", ""),
@@ -102,10 +115,13 @@ def create_avatar(payload: dict = Body(...), authorization: str = Header(None),
@router.put("/avatar/{avatar_id}") @router.put("/avatar/{avatar_id}")
def update_avatar(avatar_id: str, payload: dict = Body(...), db: Session = Depends(get_db)): def update_avatar(
a = db.query(Avatar).filter(Avatar.id == avatar_id).first() avatar_id: str,
if not a: payload: dict = Body(...),
return fail("分身不存在", 404) authorization: str = Header(None),
db: Session = Depends(get_db),
):
a = _require_owned_avatar(db, avatar_id, authorization)
mapping = { mapping = {
"displayName": "display_name", "displayName": "display_name",
"photoUrl": "photo_url", "photoUrl": "photo_url",
@@ -114,22 +130,32 @@ def update_avatar(avatar_id: str, payload: dict = Body(...), db: Session = Depen
for key in ("name", "displayName", "description", "photoUrl", "emoji", "status", "tokenBalance", "config"): for key in ("name", "displayName", "description", "photoUrl", "emoji", "status", "tokenBalance", "config"):
if key in payload: if key in payload:
col = mapping.get(key, key) col = mapping.get(key, key)
setattr(a, col, payload[key]) value = payload[key]
if key == "config":
if not isinstance(value, dict):
return fail("分身配置格式不正确", 400)
value = {**(a.config or {}), **value}
setattr(a, col, value)
db.commit() db.commit()
db.refresh(a) db.refresh(a)
return ok(a.to_dict()) return ok(a.to_dict())
@router.delete("/avatar/{avatar_id}") @router.delete("/avatar/{avatar_id}")
def delete_avatar(avatar_id: str, db: Session = Depends(get_db)): def delete_avatar(
a = db.query(Avatar).filter(Avatar.id == avatar_id).first() avatar_id: str,
if not a: authorization: str = Header(None),
return fail("分身不存在", 404) db: Session = Depends(get_db),
):
a = _require_owned_avatar(db, avatar_id, authorization)
# 级联清理关联数据,避免孤儿记录 # 级联清理关联数据,避免孤儿记录
db.query(KnowledgeDoc).filter(KnowledgeDoc.avatar_id == avatar_id).delete() db.query(KnowledgeDoc).filter(KnowledgeDoc.avatar_id == avatar_id).delete()
db.query(KnowledgeChunk).filter(KnowledgeChunk.avatar_id == avatar_id).delete() db.query(KnowledgeChunk).filter(KnowledgeChunk.avatar_id == avatar_id).delete()
db.query(QAPair).filter(QAPair.avatar_id == avatar_id).delete() db.query(QAPair).filter(QAPair.avatar_id == avatar_id).delete()
db.query(Authorization).filter(Authorization.avatar_id == avatar_id).delete() db.query(Authorization).filter(Authorization.avatar_id == avatar_id).delete()
db.query(TakeoverReplyTask).filter(TakeoverReplyTask.avatar_id == avatar_id).delete()
db.query(TakeoverMessage).filter(TakeoverMessage.avatar_id == avatar_id).delete()
db.query(TakeoverCursor).filter(TakeoverCursor.avatar_id == avatar_id).delete()
db.delete(a) db.delete(a)
db.commit() db.commit()
return ok({"success": True}) return ok({"success": True})
+44 -24
View File
@@ -1,5 +1,6 @@
import os import os
import json import json
import logging
import uuid import uuid
from datetime import datetime, timezone from datetime import datetime, timezone
@@ -13,6 +14,7 @@ from responses import ok, fail
import embeddings import embeddings
router = APIRouter() router = APIRouter()
logger = logging.getLogger(__name__)
BASE_DIR = os.path.dirname(os.path.abspath(__file__)) BASE_DIR = os.path.dirname(os.path.abspath(__file__))
UPLOAD_DIR = os.path.abspath(os.getenv("UPLOAD_DIR", os.path.join(BASE_DIR, "uploads"))) UPLOAD_DIR = os.path.abspath(os.getenv("UPLOAD_DIR", os.path.join(BASE_DIR, "uploads")))
@@ -69,6 +71,15 @@ def list_docs(avatar_id: str, authorization: str = Header(None), db: Session = D
.order_by(KnowledgeDoc.created_at.desc()) .order_by(KnowledgeDoc.created_at.desc())
.all() .all()
) )
# Older synchronous uploads could be interrupted after persisting "parsing".
# New uploads are committed only after indexing finishes, so these rows are stale.
stale_docs = [doc for doc in docs if doc.status == "parsing"]
if stale_docs:
for doc in stale_docs:
doc.status = "failed"
doc.vectorized = False
doc.chunk_count = 0
db.commit()
return ok([_doc_payload(d) for d in docs]) return ok([_doc_payload(d) for d in docs])
@@ -88,6 +99,7 @@ async def upload_doc(avatar_id: str, file: UploadFile = File(...), authorization
with open(path, "wb") as f: with open(path, "wb") as f:
f.write(content) f.write(content)
doc = KnowledgeDoc( doc = KnowledgeDoc(
id=uuid.uuid4().hex,
avatar_id=avatar_id, avatar_id=avatar_id,
filename=file.filename, filename=file.filename,
file_type=ext.lstrip("."), file_type=ext.lstrip("."),
@@ -95,39 +107,47 @@ async def upload_doc(avatar_id: str, file: UploadFile = File(...), authorization
file_url=f"/api/files/{avatar_id}/{stored}", file_url=f"/api/files/{avatar_id}/{stored}",
status="parsing", status="parsing",
) )
db.add(doc)
db.commit()
db.refresh(doc)
# 向量化:抽取文本 -> 分块 -> 调第三方/本地嵌入 -> 存切片 # Complete extraction and embedding before the first database commit so a
# process restart cannot leave a permanent "parsing" row behind.
try: try:
text = embeddings.extract_text(path, ext) text = embeddings.extract_text(path, ext)
chunks = embeddings.chunk_text(text) chunks = embeddings.chunk_text(text)
if chunks: if not chunks:
vectors = embeddings.embed(chunks) raise ValueError("文档没有可建立索引的文字内容")
for i, (c, v) in enumerate(zip(chunks, vectors)): vectors = embeddings.embed(chunks)
db.add( if len(vectors) != len(chunks):
KnowledgeChunk( raise ValueError("向量服务返回数量与文档分段不一致")
doc_id=doc.id, doc.vectorized = True
avatar_id=avatar_id, doc.embedding_model = embeddings.MODEL
content=c, doc.chunk_count = len(chunks)
vector=json.dumps(v), doc.vectorized_at = datetime.now(timezone.utc)
chunk_index=i,
embedding_model=embeddings.MODEL,
)
)
doc.vectorized = True
doc.embedding_model = embeddings.MODEL
doc.chunk_count = len(chunks)
doc.vectorized_at = datetime.now(timezone.utc)
doc.status = "ready" doc.status = "ready"
db.add(doc)
for i, (chunk, vector) in enumerate(zip(chunks, vectors)):
db.add(
KnowledgeChunk(
doc_id=doc.id,
avatar_id=avatar_id,
content=chunk,
vector=json.dumps(vector),
chunk_index=i,
embedding_model=embeddings.MODEL,
)
)
db.commit() db.commit()
db.refresh(doc) db.refresh(doc)
except Exception as e: except Exception as exc:
print("vectorize failed:", e) db.rollback()
doc.status = "ready" # 上传成功但向量化失败,仍可展示 doc.status = "failed"
doc.vectorized = False
doc.embedding_model = ""
doc.chunk_count = 0
doc.vectorized_at = None
db.add(doc)
db.commit() db.commit()
db.refresh(doc) db.refresh(doc)
logger.exception("knowledge vectorization failed for %s: %s", doc.id, exc)
return ok(_doc_payload(doc)) return ok(_doc_payload(doc))
+26 -5
View File
@@ -8,13 +8,25 @@ from sqlalchemy.orm import Session
from database import get_db from database import get_db
from models import TakeoverCursor, TakeoverReplyTask, User from models import TakeoverCursor, TakeoverReplyTask, User
from responses import fail, ok from responses import fail, ok
from routers.authorizations import _require_authorization from routers.authorizations import (
DEFAULT_TAKEOVER_DELAY_SECONDS,
MAX_TAKEOVER_DELAY_SECONDS,
MIN_TAKEOVER_DELAY_SECONDS,
_require_authorization,
_stored_takeover_delay,
)
from routers.avatars import _require_owned_avatar from routers.avatars import _require_owned_avatar
router = APIRouter(tags=["分身接管"]) router = APIRouter(tags=["分身接管"])
BOXIM_STATUS_FRESH_SECONDS = 60 BOXIM_STATUS_FRESH_SECONDS = 60
def _delay_label(seconds: int) -> str:
if seconds % 60 == 0:
return f"{seconds // 60} 分钟"
return f"{seconds} 秒"
@router.get("/avatar/{avatar_id}/takeover/status") @router.get("/avatar/{avatar_id}/takeover/status")
def get_takeover_status( def get_takeover_status(
avatar_id: str, avatar_id: str,
@@ -24,6 +36,7 @@ def get_takeover_status(
avatar = _require_owned_avatar(db, avatar_id, authorization) avatar = _require_owned_avatar(db, avatar_id, authorization)
permissions = (avatar.config or {}).get("authorizationPermissions", []) permissions = (avatar.config or {}).get("authorizationPermissions", [])
enabled = isinstance(permissions, list) and "takeover" in permissions enabled = isinstance(permissions, list) and "takeover" in permissions
reply_delay_seconds = _stored_takeover_delay(avatar)
user = db.query(User).filter(User.huihui_user_id == avatar.owner_id).first() user = db.query(User).filter(User.huihui_user_id == avatar.owner_id).first()
cursor = db.query(TakeoverCursor).filter(TakeoverCursor.avatar_id == avatar.id).first() cursor = db.query(TakeoverCursor).filter(TakeoverCursor.avatar_id == avatar.id).first()
pending_count = ( pending_count = (
@@ -49,7 +62,10 @@ def get_takeover_status(
and cursor.last_polled_at and cursor.last_polled_at
>= datetime.utcnow() - timedelta(seconds=BOXIM_STATUS_FRESH_SECONDS) >= datetime.utcnow() - timedelta(seconds=BOXIM_STATUS_FRESH_SECONDS)
): ):
status, message = "ready", "BOXIM 已连接,收到私聊消息 3 秒后自动回复" status, message = (
"ready",
f"BOXIM 已连接,收到私聊消息 {_delay_label(reply_delay_seconds)}后自动回复",
)
else: else:
status, message = "connecting", "正在连接 BOXIM" status, message = "connecting", "正在连接 BOXIM"
@@ -59,6 +75,7 @@ def get_takeover_status(
"status": status, "status": status,
"message": message, "message": message,
"pendingCount": pending_count, "pendingCount": pending_count,
"takeoverReplyDelaySeconds": reply_delay_seconds,
"lastPolledAt": cursor.last_polled_at.isoformat() if cursor and cursor.last_polled_at else None, "lastPolledAt": cursor.last_polled_at.isoformat() if cursor and cursor.last_polled_at else None,
} }
) )
@@ -91,7 +108,7 @@ def update_takeover_config(
auth = _require_authorization(db, avatar_id, str(auth_id)) auth = _require_authorization(db, avatar_id, str(auth_id))
enabled = bool(auth.takeover_enabled) enabled = bool(auth.takeover_enabled)
mode = auth.takeover_mode or "immediate" mode = auth.takeover_mode or "immediate"
delay = auth.takeover_delay_seconds or 30 delay = auth.takeover_delay_seconds or DEFAULT_TAKEOVER_DELAY_SECONDS
if _has(payload, "takeoverEnabled", "takeover_enabled"): if _has(payload, "takeoverEnabled", "takeover_enabled"):
raw_enabled = _read(payload, "takeoverEnabled", "takeover_enabled") raw_enabled = _read(payload, "takeoverEnabled", "takeover_enabled")
@@ -106,8 +123,12 @@ def update_takeover_config(
if _has(payload, "takeoverDelaySeconds", "takeover_delay_seconds"): if _has(payload, "takeoverDelaySeconds", "takeover_delay_seconds"):
delay = _read(payload, "takeoverDelaySeconds", "takeover_delay_seconds") delay = _read(payload, "takeoverDelaySeconds", "takeover_delay_seconds")
if isinstance(delay, bool) or not isinstance(delay, int) or not 5 <= delay <= 3600: if (
return fail("延迟时间需在 5 到 3600 秒之间", 400) isinstance(delay, bool)
or not isinstance(delay, int)
or not MIN_TAKEOVER_DELAY_SECONDS <= delay <= MAX_TAKEOVER_DELAY_SECONDS
):
return fail("延迟时间需在 3 秒到 24 小时之间", 400)
if enabled and auth.target_type != "user": if enabled and auth.target_type != "user":
return fail("本期仅支持对会会用户开启单聊接管", 400) return fail("本期仅支持对会会用户开启单聊接管", 400)
+275 -10
View File
@@ -1,14 +1,29 @@
from fastapi import APIRouter, Depends, Body, Header, HTTPException import hashlib
import hmac
import json
import os
import uuid
from datetime import datetime
from decimal import Decimal, InvalidOperation, ROUND_HALF_UP
from urllib.parse import parse_qs
from fastapi import APIRouter, Body, Depends, Header, HTTPException, Request
from sqlalchemy import func from sqlalchemy import func
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from database import get_db from database import get_db
from models import TokenAccount, TokenPlan, TokenUsage, User from models import TokenAccount, TokenPaymentOrder, TokenPlan, TokenUsage, User
from responses import ok, fail from responses import fail, ok
from services.token_billing import get_or_create_account from services.huihui_payment import HuihuiPaymentClient, HuihuiPaymentError
from services.token_billing import DEFAULT_TOKEN_GRANT, get_or_create_account
router = APIRouter(tags=["Token"]) router = APIRouter(tags=["Token"])
PAYMENT_METHODS = {"wechat": "WECHAT", "alipay": "ALIPAY"}
PAYMENT_SCENES = {"APP", "LITE", "JSAPI"}
SUCCESS_STATUSES = {"SUCCESS", "SUCCEEDED", "PAID", "COMPLETED", "TRADE_SUCCESS"}
FAILED_STATUSES = {"FAIL", "FAILED", "CLOSED", "CANCELLED", "CANCELED", "EXPIRED"}
def _require_user(authorization: str | None, db: Session) -> User: def _require_user(authorization: str | None, db: Session) -> User:
if not authorization: if not authorization:
@@ -20,6 +35,97 @@ def _require_user(authorization: str | None, db: Session) -> User:
return user return user
def _payment_client() -> HuihuiPaymentClient:
return HuihuiPaymentClient({
"HUIHUI_PAYMENT_BASE_URL": os.getenv(
"HUIHUI_PAYMENT_BASE_URL", "https://open.99hui.com/api/payment-v3"
),
"HUIHUI_APP_ID": os.getenv("HUIHUI_APP_ID", ""),
"HUIHUI_ACCESS_ID": os.getenv("HUIHUI_ACCESS_ID", ""),
"HUIHUI_ACCESS_SECRET": os.getenv("HUIHUI_ACCESS_SECRET", ""),
"HUIHUI_PAYMENT_TIMEOUT_SECONDS": os.getenv("HUIHUI_PAYMENT_TIMEOUT_SECONDS", "30"),
})
def _callback_url(order_no: str) -> str:
base = os.getenv(
"HUIHUI_PAYMENT_CALLBACK_BASE_URL", "https://digital.99hui.com"
).rstrip("/")
secret = os.getenv("HUIHUI_PAYMENT_CALLBACK_SECRET", "").strip()
if len(secret) < 16:
raise HuihuiPaymentError("会会支付回调密钥未配置")
signature = hmac.new(secret.encode(), order_no.encode(), hashlib.sha256).hexdigest()
return f"{base}/api/token/payment/callback/{order_no}/{signature}"
def _price_cents(price: float) -> int:
return int(
(Decimal(str(price)) * Decimal("100")).quantize(
Decimal("1"), rounding=ROUND_HALF_UP
)
)
def _payment_payload(order: TokenPaymentOrder, account: TokenAccount) -> dict:
return {**order.to_dict(), "balance": account.balance}
def _nested_payload(value):
if isinstance(value, str):
text = value.strip()
if text[:1] in ("{", "["):
try:
return _nested_payload(json.loads(text))
except (TypeError, ValueError):
return value
return value
if isinstance(value, list):
return [_nested_payload(item) for item in value]
if isinstance(value, dict):
return {key: _nested_payload(item) for key, item in value.items()}
return value
def _find_value(payload, *names):
expected = {name.lower() for name in names}
if isinstance(payload, dict):
for key, value in payload.items():
if key.lower() in expected and value not in (None, ""):
return value
for value in payload.values():
found = _find_value(value, *names)
if found not in (None, ""):
return found
elif isinstance(payload, list):
for value in payload:
found = _find_value(value, *names)
if found not in (None, ""):
return found
return None
def _callback_amount_cents(payload) -> int | None:
value = _find_value(
payload,
"actualAmt",
"payAmt",
"masterOrderAmt",
"orderAmt",
"amount",
"totalAmount",
)
if value in (None, ""):
return None
try:
return int(
(Decimal(str(value)) * Decimal("100")).quantize(
Decimal("1"), rounding=ROUND_HALF_UP
)
)
except (InvalidOperation, TypeError, ValueError):
return None
@router.get("/token/balance") @router.get("/token/balance")
def balance(authorization: str = Header(None), db: Session = Depends(get_db)): def balance(authorization: str = Header(None), db: Session = Depends(get_db)):
user = _require_user(authorization, db) user = _require_user(authorization, db)
@@ -38,18 +144,177 @@ def plans(authorization: str = Header(None), db: Session = Depends(get_db)):
return ok([p.to_dict() for p in items]) return ok([p.to_dict() for p in items])
# 积分只会在会会支付回调确认成功后到账。
@router.post("/token/charge") @router.post("/token/charge")
def charge(payload: dict = Body(...), authorization: str = Header(None), db: Session = Depends(get_db)): def charge(payload: dict = Body(...), authorization: str = Header(None), db: Session = Depends(get_db)):
user = _require_user(authorization, db) user = _require_user(authorization, db)
plan_id = payload.get("planId") plan = db.query(TokenPlan).filter(TokenPlan.id == payload.get("planId")).first()
plan = db.query(TokenPlan).filter(TokenPlan.id == plan_id).first()
if not plan: if not plan:
return fail("套餐不存在", 404) return fail("套餐不存在", 404)
acc = get_or_create_account(db, user.id)
acc.balance += plan.amount payment_method = str(payload.get("paymentMethod") or "").lower()
acc.total_granted = int(acc.total_granted or 0) + plan.amount pay_type = PAYMENT_METHODS.get(payment_method)
if not pay_type:
return fail("请选择正确的支付方式", 400)
pay_way = str(payload.get("payScene") or "APP").upper()
if pay_way not in PAYMENT_SCENES:
return fail("当前支付场景不受支持", 400)
cents = _price_cents(plan.price)
order = TokenPaymentOrder(
order_no=f"AV{datetime.utcnow().strftime('%Y%m%d%H%M%S')}{uuid.uuid4().hex[:12].upper()}",
user_id=user.id,
plan_id=plan.id,
payment_method=payment_method,
pay_type=pay_type,
pay_way=pay_way,
points_amount=plan.amount,
price_cents=cents,
status="pending",
)
db.add(order)
db.commit() db.commit()
return ok({"balance": acc.balance, "charged": plan.amount})
try:
callback_url = _callback_url(order.order_no)
except HuihuiPaymentError as exc:
order.status = "failed"
order.failure_reason = str(exc)
db.commit()
return fail(str(exc), 503)
try:
result = _payment_client().create_payment(
huihui_token=user.huihui_token,
huihui_user_id=user.huihui_user_id,
real_name=user.nickname,
order_no=order.order_no,
amount=f"{cents / 100:.2f}",
points_amount=plan.amount,
pay_type=pay_type,
pay_way=pay_way,
callback_url=callback_url,
)
except HuihuiPaymentError as exc:
order.status = "failed"
order.failure_reason = str(exc)[:500]
db.commit()
return fail(str(exc), 502)
db.refresh(order)
if order.status != "paid":
order.provider_order_id = str(result.get("orderId") or "")
order.provider_order_no = str(result.get("orderNo") or "")
order.provider_status = str(result.get("status") or "pending")
message = result.get("payMessage") or ""
order.pay_message = (
json.dumps(message, ensure_ascii=False)
if isinstance(message, (dict, list))
else str(message)
)
if order.provider_status.upper() in FAILED_STATUSES:
order.status = "failed"
order.failure_reason = str(result.get("bankReturnMsg") or "支付下单失败")[:500]
db.commit()
return ok(_payment_payload(order, get_or_create_account(db, user.id)))
@router.get("/token/payment/{order_id}")
def payment_status(order_id: str, authorization: str = Header(None), db: Session = Depends(get_db)):
user = _require_user(authorization, db)
order = db.query(TokenPaymentOrder).filter(
TokenPaymentOrder.id == order_id,
TokenPaymentOrder.user_id == user.id,
).first()
if not order:
return fail("支付订单不存在", 404)
return ok(_payment_payload(order, get_or_create_account(db, user.id)))
@router.post("/token/payment/callback/{order_no}/{callback_signature}")
async def payment_callback(
order_no: str,
callback_signature: str,
request: Request,
db: Session = Depends(get_db),
):
secret = os.getenv("HUIHUI_PAYMENT_CALLBACK_SECRET", "").strip()
expected = hmac.new(secret.encode(), order_no.encode(), hashlib.sha256).hexdigest()
if len(secret) < 16 or not hmac.compare_digest(callback_signature, expected):
raise HTTPException(status_code=404, detail="Not found")
content_type = request.headers.get("content-type", "").lower()
if "application/json" in content_type:
try:
payload = await request.json()
except ValueError:
return fail("支付回调格式不正确", 400)
else:
raw = (await request.body()).decode("utf-8", errors="replace")
payload = {key: values[-1] for key, values in parse_qs(raw).items()}
payload = _nested_payload(payload)
payload_order_no = str(_find_value(
payload,
"masterOrderNo",
"master_order_no",
"orderNo",
"order_no",
"bizOrderNo",
) or "").strip()
if payload_order_no and payload_order_no != order_no:
return fail("支付回调订单号不匹配", 422)
order = db.query(TokenPaymentOrder).filter(TokenPaymentOrder.order_no == order_no).first()
if not order:
return fail("支付订单不存在", 404)
if order.status == "paid":
return ok({"received": True, "duplicate": True})
provider_status = str(_find_value(
payload, "status", "payStatus", "tradeStatus", "paymentStatus"
) or "").upper()
order.provider_status = provider_status
if provider_status not in SUCCESS_STATUSES:
if provider_status in FAILED_STATUSES:
order.status = "failed"
order.failure_reason = str(
_find_value(payload, "message", "errorMsg", "failReason") or "支付失败"
)[:500]
db.commit()
return ok({"received": True, "paid": False})
paid_cents = _callback_amount_cents(payload)
if paid_cents is None or paid_cents != order.price_cents:
order.failure_reason = "支付回调金额不匹配"
db.commit()
return fail("支付金额不匹配", 422)
updated = db.query(TokenPaymentOrder).filter(
TokenPaymentOrder.id == order.id,
TokenPaymentOrder.status != "paid",
).update({
TokenPaymentOrder.status: "paid",
TokenPaymentOrder.provider_status: provider_status,
TokenPaymentOrder.paid_at: datetime.utcnow(),
TokenPaymentOrder.failure_reason: "",
}, synchronize_session=False)
if updated:
account = db.query(TokenAccount).filter(TokenAccount.user_id == order.user_id).first()
if account is None:
account = TokenAccount(
user_id=order.user_id,
balance=DEFAULT_TOKEN_GRANT,
total_granted=DEFAULT_TOKEN_GRANT,
total_consumed=0,
)
db.add(account)
db.flush()
account.balance = int(account.balance or 0) + order.points_amount
account.total_granted = int(account.total_granted or 0) + order.points_amount
db.commit()
return ok({"received": True, "paid": True})
@router.get("/token/usage") @router.get("/token/usage")
@@ -0,0 +1,124 @@
"""Signed client for Huihui's production payment-v3 service."""
import hashlib
import random
import string
from datetime import datetime, timedelta, timezone
from typing import Any
import httpx
_CN_TZ = timezone(timedelta(hours=8))
class HuihuiPaymentError(RuntimeError):
pass
class HuihuiPaymentClient:
def __init__(self, config: dict):
self.base_url = config.get(
"HUIHUI_PAYMENT_BASE_URL", "https://open.99hui.com/api/payment-v3"
).rstrip("/")
self.app_id = config.get("HUIHUI_APP_ID", "")
self.access_id = config.get("HUIHUI_ACCESS_ID", "")
self.access_secret = config.get("HUIHUI_ACCESS_SECRET", "")
self.timeout = float(config.get("HUIHUI_PAYMENT_TIMEOUT_SECONDS", 30))
@property
def configured(self) -> bool:
return bool(self.base_url and self.app_id and self.access_id and self.access_secret)
def _signed_params(self, user_id: str) -> dict:
params = {
"appId": self.app_id,
"accessId": self.access_id,
"nonce": "".join(random.choices(string.ascii_lowercase + string.digits, k=12)),
"timestamp": datetime.now(_CN_TZ).strftime("%Y%m%d%H%M%S"),
"signType": "MD5",
"signVersion": "1.0",
"userId": user_id,
}
source = "&".join(
f"{key}={params[key]}"
for key in sorted(params)
if params[key] not in (None, "", [])
)
source += f"&accessSecret={self.access_secret}"
params["signature"] = hashlib.md5(source.encode("utf-8")).hexdigest().upper()
return params
@staticmethod
def _json(response: httpx.Response) -> dict:
try:
payload = response.json()
except ValueError as exc:
raise HuihuiPaymentError("会会支付返回了无效响应") from exc
if not isinstance(payload, dict):
raise HuihuiPaymentError("会会支付返回格式不正确")
return payload
def create_payment(
self,
*,
huihui_token: str,
huihui_user_id: str,
real_name: str,
order_no: str,
amount: str,
points_amount: int,
pay_type: str,
pay_way: str,
callback_url: str,
) -> dict[str, Any]:
if not self.configured:
raise HuihuiPaymentError("会会支付服务未配置")
if not huihui_token or not huihui_user_id:
raise HuihuiPaymentError("当前会会登录凭证无法发起支付")
now = datetime.now(_CN_TZ)
body = {
"appId": self.app_id,
"callbackUrl": callback_url,
"chargeType": 4,
"currency": "cny",
"description": f"充值 {points_amount} 积分",
"expend": {},
"masterOrderAmt": amount,
"masterOrderNo": order_no,
"memberId": huihui_user_id,
"orderDesc": "数字分身积分充值",
"orderTime": now.isoformat(),
"orderTitle": "数字分身积分充值",
"payAmt": float(amount),
"payType": pay_type,
"payWay": pay_way,
"realName": real_name or "会会用户",
"timeExpire": (now + timedelta(hours=2)).strftime("%Y%m%d%H%M%S"),
}
headers = {
"Authorization": f"Bearer {huihui_token}",
"appId": self.app_id,
"windowAppId": self.app_id,
}
try:
response = httpx.post(
f"{self.base_url}/payment/pay",
headers=headers,
params=self._signed_params(huihui_user_id),
json=body,
timeout=self.timeout,
follow_redirects=True,
)
except httpx.HTTPError as exc:
raise HuihuiPaymentError("会会支付连接失败,请稍后重试") from exc
payload = self._json(response)
code = payload.get("code")
if response.status_code >= 400 or code not in (0, 200, "0", "200"):
raise HuihuiPaymentError(payload.get("message") or "会会支付下单失败")
data = payload.get("data") or {}
if not isinstance(data, dict):
raise HuihuiPaymentError("会会支付未返回订单信息")
return data
@@ -28,6 +28,14 @@ MAX_PROMPT_LENGTH = 4000
MAX_STALE_SECONDS = 120 MAX_STALE_SECONDS = 120
STUCK_LOCK_SECONDS = 90 STUCK_LOCK_SECONDS = 90
TAKEOVER_PERMISSION = "takeover" TAKEOVER_PERMISSION = "takeover"
TAKEOVER_DELAY_KEY = "takeoverReplyDelaySeconds"
DEFAULT_REPLY_DELAY_SECONDS = 180
MIN_REPLY_DELAY_SECONDS = 3
MAX_REPLY_DELAY_SECONDS = 86_400
HUMAN_PAUSE_SECONDS = 600
RATE_LIMIT_WINDOW_SECONDS = 300
RATE_LIMIT_MAX_REPLIES = 5
AVATAR_LOCAL_ID_PREFIX = "880"
def _utcnow() -> datetime: def _utcnow() -> datetime:
@@ -70,15 +78,43 @@ def _plain_text_reply(value: str) -> str:
return "\n".join(line for line in lines if line).strip() return "\n".join(line for line in lines if line).strip()
def _avatar_local_id(owner_id: str, trigger_message_id: str) -> str:
"""Build a deterministic BOXIM idempotency key that also marks avatar traffic."""
digest = hashlib.sha256(f"{owner_id}:{trigger_message_id}".encode("utf-8")).digest()
suffix = int.from_bytes(digest[:8], "big") % (10**15)
return f"{AVATAR_LOCAL_ID_PREFIX}{suffix:015d}"
def _is_avatar_local_id(value: str | None) -> bool:
local_id = str(value or "").strip()
return len(local_id) == 18 and local_id.isdigit() and local_id.startswith(AVATAR_LOCAL_ID_PREFIX)
def _configured_reply_delay(avatar: Avatar, fallback: int | None = None) -> int:
raw = (avatar.config or {}).get(
TAKEOVER_DELAY_KEY,
fallback if fallback is not None else DEFAULT_REPLY_DELAY_SECONDS,
)
if isinstance(raw, bool):
return DEFAULT_REPLY_DELAY_SECONDS
try:
delay = int(raw)
except (TypeError, ValueError):
return DEFAULT_REPLY_DELAY_SECONDS
if not MIN_REPLY_DELAY_SECONDS <= delay <= MAX_REPLY_DELAY_SECONDS:
return DEFAULT_REPLY_DELAY_SECONDS
return delay
class TakeoverService: class TakeoverService:
"""Poll BOXIM, prepare replies during the grace period, then send at +3s.""" """Poll BOXIM, honor the owner grace period, then generate and send one reply."""
def __init__( def __init__(
self, self,
session_factory: Callable[[], Session], session_factory: Callable[[], Session],
boxim_client: BoxIMClient, boxim_client: BoxIMClient,
*, *,
reply_delay_seconds: int = 3, reply_delay_seconds: int | None = None,
now: Callable[[], datetime] = _utcnow, now: Callable[[], datetime] = _utcnow,
): ):
self.session_factory = session_factory self.session_factory = session_factory
@@ -119,11 +155,17 @@ class TakeoverService:
def _enabled_avatar_ids(self) -> list[str]: def _enabled_avatar_ids(self) -> list[str]:
db = self.session_factory() db = self.session_factory()
try: try:
return [ avatars = (
avatar.id db.query(Avatar)
for avatar in db.query(Avatar).filter(Avatar.status == "active").all() .filter(Avatar.status == "active")
if _takeover_enabled(avatar) .order_by(Avatar.updated_at.desc(), Avatar.created_at.desc())
] .all()
)
selected = {}
for avatar in avatars:
if _takeover_enabled(avatar) and avatar.owner_id not in selected:
selected[avatar.owner_id] = avatar.id
return list(selected.values())
finally: finally:
db.close() db.close()
@@ -201,13 +243,20 @@ class TakeoverService:
def _forget_boxim_session(self, user_id: str): def _forget_boxim_session(self, user_id: str):
self._sessions.pop(user_id, None) self._sessions.pop(user_id, None)
def _disable_after_connection_failure( def _record_connection_failure(
self, self,
db: Session, db: Session,
avatar: Avatar, avatar: Avatar,
cursor: TakeoverCursor, cursor: TakeoverCursor,
message: str, message: str,
*,
disable_takeover: bool,
): ):
cursor.last_error = message
cursor.last_polled_at = self.now()
if not disable_takeover:
return
permissions = (avatar.config or {}).get("authorizationPermissions", []) permissions = (avatar.config or {}).get("authorizationPermissions", [])
avatar.config = { avatar.config = {
**(avatar.config or {}), **(avatar.config or {}),
@@ -217,8 +266,6 @@ class TakeoverService:
if permission != TAKEOVER_PERMISSION if permission != TAKEOVER_PERMISSION
], ],
} }
cursor.last_error = message
cursor.last_polled_at = self.now()
tasks = ( tasks = (
db.query(TakeoverReplyTask) db.query(TakeoverReplyTask)
.filter( .filter(
@@ -245,11 +292,12 @@ class TakeoverService:
db.add(cursor) db.add(cursor)
db.flush() db.flush()
if not user or not user.huihui_token: if not user or not user.huihui_token:
self._disable_after_connection_failure( self._record_connection_failure(
db, db,
avatar, avatar,
cursor, cursor,
"请重新登录会会生产账号后再开启主动接管", "请重新登录会会生产账号后再开启主动接管",
disable_takeover=True,
) )
db.commit() db.commit()
return False return False
@@ -268,11 +316,24 @@ class TakeoverService:
if isinstance(exc, BoxIMError) and exc.auth_error: if isinstance(exc, BoxIMError) and exc.auth_error:
self._forget_boxim_session(user.id) self._forget_boxim_session(user.id)
message = "BOXIM 授权已失效,请重新登录会会生产账号" message = "BOXIM 授权已失效,请重新登录会会生产账号"
disable_takeover = True
else: else:
message = f"BOXIM 暂时连接失败:{str(exc)[:160]}" message = f"BOXIM 暂时连接失败:{str(exc)[:160]}"
self._disable_after_connection_failure(db, avatar, cursor, message) disable_takeover = False
self._record_connection_failure(
db,
avatar,
cursor,
message,
disable_takeover=disable_takeover,
)
db.commit() db.commit()
logger.warning("BOXIM sync failed for avatar %s: %s", avatar.id, exc) logger.warning(
"BOXIM sync failed for avatar %s (will_retry=%s): %s",
avatar.id,
not disable_takeover,
exc,
)
return False return False
messages.sort(key=lambda item: (_numeric_id(item.get("id")), item.get("sendTime") or 0)) messages.sort(key=lambda item: (_numeric_id(item.get("id")), item.get("sendTime") or 0))
@@ -350,13 +411,21 @@ class TakeoverService:
now = self.now() now = self.now()
send_time = _boxim_time(message.get("sendTime"), now) send_time = _boxim_time(message.get("sendTime"), now)
is_avatar = False is_avatar = _is_avatar_local_id(local_id)
if direction == "outgoing" and local_id: if not is_avatar and local_id:
is_avatar = bool( is_avatar = bool(
db.query(TakeoverReplyTask) db.query(TakeoverReplyTask)
.filter( .filter(
TakeoverReplyTask.owner_id == avatar.owner_id,
TakeoverReplyTask.boxim_local_id == local_id, TakeoverReplyTask.boxim_local_id == local_id,
TakeoverReplyTask.status.in_(("ready", "sending", "sent")),
)
.first()
)
if not is_avatar:
is_avatar = bool(
db.query(TakeoverReplyTask)
.filter(
TakeoverReplyTask.boxim_sent_message_id == message_id,
TakeoverReplyTask.status == "sent", TakeoverReplyTask.status == "sent",
) )
.first() .first()
@@ -385,8 +454,52 @@ class TakeoverService:
return return
if (now - send_time).total_seconds() > MAX_STALE_SECONDS: if (now - send_time).total_seconds() > MAX_STALE_SECONDS:
return return
if is_avatar:
self._cancel_conversation(db, avatar.owner_id, peer_id, "peer_avatar_message")
return
if self._human_pause_active(db, avatar.owner_id, peer_id, now):
self._cancel_conversation(db, avatar.owner_id, peer_id, "owner_active")
return
if self._conversation_rate_limited(db, avatar.owner_id, peer_id, now):
self._cancel_conversation(db, avatar.owner_id, peer_id, "rate_limited")
return
self._schedule_reply(db, avatar, event) self._schedule_reply(db, avatar, event)
@staticmethod
def _human_pause_active(db: Session, owner_id: str, peer_id: str, now: datetime) -> bool:
threshold = now - timedelta(seconds=HUMAN_PAUSE_SECONDS)
return bool(
db.query(TakeoverMessage.id)
.filter(
TakeoverMessage.owner_id == owner_id,
TakeoverMessage.peer_id == peer_id,
TakeoverMessage.direction == "outgoing",
TakeoverMessage.is_avatar.is_(False),
TakeoverMessage.send_time >= threshold,
)
.first()
)
@staticmethod
def _conversation_rate_limited(
db: Session,
owner_id: str,
peer_id: str,
now: datetime,
) -> bool:
threshold = now - timedelta(seconds=RATE_LIMIT_WINDOW_SECONDS)
return (
db.query(TakeoverReplyTask.id)
.filter(
TakeoverReplyTask.owner_id == owner_id,
TakeoverReplyTask.peer_id == peer_id,
TakeoverReplyTask.status == "sent",
TakeoverReplyTask.sent_at >= threshold,
)
.count()
>= RATE_LIMIT_MAX_REPLIES
)
@staticmethod @staticmethod
def _cancel_conversation(db: Session, owner_id: str, peer_id: str, reason: str): def _cancel_conversation(db: Session, owner_id: str, peer_id: str, reason: str):
tasks = ( tasks = (
@@ -427,9 +540,11 @@ class TakeoverService:
prompt_parts.append(event.content.strip()) prompt_parts.append(event.content.strip())
source_ids.append(event.boxim_message_id) source_ids.append(event.boxim_message_id)
prompt = "\n".join(part for part in prompt_parts if part).strip()[-MAX_PROMPT_LENGTH:] prompt = "\n".join(part for part in prompt_parts if part).strip()[-MAX_PROMPT_LENGTH:]
due_at = event.send_time + timedelta(seconds=self.reply_delay_seconds) due_at = event.send_time + timedelta(
seconds=_configured_reply_delay(avatar, self.reply_delay_seconds)
)
task_id = secrets.token_hex(16) task_id = secrets.token_hex(16)
local_id = int(time.time() * 1000) * 1000 + secrets.randbelow(1000) local_id = _avatar_local_id(avatar.owner_id, event.boxim_message_id)
db.add( db.add(
TakeoverReplyTask( TakeoverReplyTask(
id=task_id, id=task_id,
@@ -455,6 +570,7 @@ class TakeoverService:
.filter( .filter(
TakeoverReplyTask.status.in_(GENERATABLE_TASK_STATUSES), TakeoverReplyTask.status.in_(GENERATABLE_TASK_STATUSES),
TakeoverReplyTask.response_text == "", TakeoverReplyTask.response_text == "",
TakeoverReplyTask.scheduled_at <= self.now(),
) )
.order_by(TakeoverReplyTask.created_at.asc()) .order_by(TakeoverReplyTask.created_at.asc())
.limit(10) .limit(10)
@@ -501,6 +617,7 @@ class TakeoverService:
.filter( .filter(
TakeoverMessage.owner_id == task.owner_id, TakeoverMessage.owner_id == task.owner_id,
TakeoverMessage.peer_id == task.peer_id, TakeoverMessage.peer_id == task.peer_id,
TakeoverMessage.avatar_id == task.avatar_id,
) )
.order_by(TakeoverMessage.send_time.desc()) .order_by(TakeoverMessage.send_time.desc())
.limit(30) .limit(30)
@@ -510,6 +627,8 @@ class TakeoverService:
for event in reversed(events): for event in reversed(events):
if event.boxim_message_id in excluded_ids or not event.content.strip(): if event.boxim_message_id in excluded_ids or not event.content.strip():
continue continue
if event.direction == "incoming" and event.is_avatar:
continue
history.append( history.append(
{ {
"role": "user" if event.direction == "incoming" else "assistant", "role": "user" if event.direction == "incoming" else "assistant",
@@ -587,6 +706,15 @@ class TakeoverService:
task.cancel_reason = "stale_reply" task.cancel_reason = "stale_reply"
db.commit() db.commit()
return False return False
cursor = (
db.query(TakeoverCursor)
.filter(TakeoverCursor.avatar_id == task.avatar_id)
.first()
)
if not cursor or not cursor.last_polled_at or cursor.last_polled_at < task.scheduled_at:
# Do not race the owner's final seconds of the grace period. A
# completed poll at/after the due time must confirm no human reply.
return False
user = db.query(User).filter(User.huihui_user_id == task.owner_id).first() user = db.query(User).filter(User.huihui_user_id == task.owner_id).first()
if not user or not user.huihui_token: if not user or not user.huihui_token:
raise BoxIMError("缺少会会登录凭证", auth_error=True) raise BoxIMError("缺少会会登录凭证", auth_error=True)
@@ -82,7 +82,7 @@ def reserve_avatar_tokens(
) -> TokenReservation: ) -> TokenReservation:
user = avatar_owner_user(db, avatar) user = avatar_owner_user(db, avatar)
if not user: if not user:
raise InsufficientTokensError("分身尚未关联有效用户,暂时无法使用 Token") raise InsufficientTokensError("分身尚未关联有效用户,暂时无法使用积分")
account = get_or_create_account(db, user.id) account = get_or_create_account(db, user.id)
reserved = estimate_request_tokens(messages, max_output_tokens) reserved = estimate_request_tokens(messages, max_output_tokens)
updated = ( updated = (
@@ -95,7 +95,7 @@ def reserve_avatar_tokens(
) )
if updated != 1: if updated != 1:
db.rollback() db.rollback()
raise InsufficientTokensError("Token 余额不足,请充值后继续") raise InsufficientTokensError("积分余额不足,请充值后继续")
db.refresh(account) db.refresh(account)
usage = TokenUsage( usage = TokenUsage(
user_id=user.id, user_id=user.id,
@@ -142,7 +142,7 @@ def settle_reservation(
) )
) )
if updated != 1: if updated != 1:
raise RuntimeError("Token 账户不存在") raise RuntimeError("积分账户不存在")
db.expire_all() db.expire_all()
account = db.query(TokenAccount).filter(TokenAccount.user_id == reservation.user_id).first() account = db.query(TokenAccount).filter(TokenAccount.user_id == reservation.user_id).first()
record.prompt_tokens = prompt_tokens record.prompt_tokens = prompt_tokens
@@ -9,6 +9,7 @@ from models import (
TakeoverMessage, TakeoverMessage,
TakeoverReplyTask, TakeoverReplyTask,
TokenAccount, TokenAccount,
TokenPaymentOrder,
TokenUsage, TokenUsage,
User, User,
) )
@@ -110,6 +111,9 @@ def authorization_context():
synchronize_session=False synchronize_session=False
) )
user_ids = [owner.id, other.id] user_ids = [owner.id, other.id]
db.query(TokenPaymentOrder).filter(TokenPaymentOrder.user_id.in_(user_ids)).delete(
synchronize_session=False
)
db.query(TokenUsage).filter(TokenUsage.user_id.in_(user_ids)).delete( db.query(TokenUsage).filter(TokenUsage.user_id.in_(user_ids)).delete(
synchronize_session=False synchronize_session=False
) )
@@ -103,6 +103,7 @@ def test_avatar_permission_settings_default_and_persist(authorization_context):
assert initial["data"] == { assert initial["data"] == {
"avatarId": context["avatar"].id, "avatarId": context["avatar"].id,
"permissions": ["friend", "chat"], "permissions": ["friend", "chat"],
"takeoverReplyDelaySeconds": 180,
} }
updated = client.put( updated = client.put(
@@ -115,6 +116,7 @@ def test_avatar_permission_settings_default_and_persist(authorization_context):
reloaded = client.get(endpoint, headers=context["owner_headers"]).json() reloaded = client.get(endpoint, headers=context["owner_headers"]).json()
assert reloaded["data"]["permissions"] == ["friend", "publish", "interact", "takeover"] assert reloaded["data"]["permissions"] == ["friend", "publish", "interact", "takeover"]
assert reloaded["data"]["takeoverReplyDelaySeconds"] == 180
def test_avatar_permission_settings_allow_all_disabled(authorization_context): def test_avatar_permission_settings_allow_all_disabled(authorization_context):
@@ -156,3 +158,57 @@ def test_avatar_permission_settings_validate_owner_and_permissions(authorization
unauthenticated = client.get(endpoint) unauthenticated = client.get(endpoint)
assert unauthenticated.status_code == 401 assert unauthenticated.status_code == 401
def test_takeover_delay_minimum_and_single_active_avatar_per_owner(authorization_context):
from database import SessionLocal
from models import Avatar
context = authorization_context
endpoint = f"/api/avatar/{context['avatar'].id}/permission-settings"
invalid = client.put(
endpoint,
headers=context["owner_headers"],
json={"permissions": ["chat"], "takeoverReplyDelaySeconds": 2},
).json()
assert invalid["code"] == 400
second_avatar_id = f"second-{context['suffix']}"
db = SessionLocal()
try:
db.add(
Avatar(
id=second_avatar_id,
owner_id=context["owner"].huihui_user_id,
name="第二个分身",
status="active",
config={"authorizationPermissions": ["chat", "takeover"]},
)
)
db.commit()
finally:
db.close()
try:
updated = client.put(
endpoint,
headers=context["owner_headers"],
json={"permissions": ["chat", "takeover"], "takeoverReplyDelaySeconds": 3},
).json()
assert updated["code"] == 200
assert updated["data"]["takeoverReplyDelaySeconds"] == 3
assert updated["data"]["disabledAvatarIds"] == [second_avatar_id]
db = SessionLocal()
try:
second = db.query(Avatar).filter(Avatar.id == second_avatar_id).one()
assert "takeover" not in second.config["authorizationPermissions"]
finally:
db.close()
finally:
db = SessionLocal()
try:
db.query(Avatar).filter(Avatar.id == second_avatar_id).delete()
db.commit()
finally:
db.close()
@@ -0,0 +1,93 @@
"""Ownership and configuration-isolation tests for digital avatars."""
from fastapi.testclient import TestClient
from database import SessionLocal
from main import app
from models import Avatar
client = TestClient(app)
def test_avatar_detail_and_update_require_the_owner(authorization_context):
context = authorization_context
avatar_id = context["avatar"].id
assert client.get(f"/api/avatar/{avatar_id}").status_code == 401
assert client.get(
f"/api/avatar/{avatar_id}", headers=context["other_headers"]
).status_code == 403
updated = client.put(
f"/api/avatar/{avatar_id}",
headers=context["owner_headers"],
json={
"description": "独立描述",
"config": {"replyStyle": "concise"},
},
)
assert updated.status_code == 200
assert updated.json()["data"]["description"] == "独立描述"
forbidden = client.put(
f"/api/avatar/{avatar_id}",
headers=context["other_headers"],
json={"description": "越权修改"},
)
assert forbidden.status_code == 403
def test_avatar_config_updates_do_not_erase_takeover_or_knowledge_scope(authorization_context):
context = authorization_context
avatar_id = context["avatar"].id
db = SessionLocal()
try:
avatar = db.query(Avatar).filter(Avatar.id == avatar_id).one()
avatar.config = {
"authorizationPermissions": ["chat", "takeover"],
"takeoverReplyDelaySeconds": 180,
}
db.commit()
finally:
db.close()
response = client.put(
f"/api/avatar/{avatar_id}",
headers=context["owner_headers"],
json={"config": {"replyStyle": "warm", "creativity": 25}},
).json()
config = response["data"]["config"]
assert config["replyStyle"] == "warm"
assert config["creativity"] == 25
assert config["authorizationPermissions"] == ["chat", "takeover"]
assert config["takeoverReplyDelaySeconds"] == 180
def test_avatar_create_and_delete_require_login_and_ownership(authorization_context):
context = authorization_context
assert client.post("/api/avatar", json={"name": "匿名分身"}).status_code == 401
created = client.post(
"/api/avatar",
headers=context["owner_headers"],
json={"name": "待删除分身"},
)
assert created.status_code == 200
avatar_id = created.json()["data"]["id"]
try:
assert client.delete(
f"/api/avatar/{avatar_id}", headers=context["other_headers"]
).status_code == 403
deleted = client.delete(
f"/api/avatar/{avatar_id}", headers=context["owner_headers"]
).json()
assert deleted["code"] == 200
finally:
db = SessionLocal()
try:
db.query(Avatar).filter(Avatar.id == avatar_id).delete()
db.commit()
finally:
db.close()
@@ -0,0 +1,50 @@
from unittest.mock import Mock, patch
from services.huihui_payment import HuihuiPaymentClient
def test_create_payment_uses_huihui_payment_v3_contract():
client = HuihuiPaymentClient({
"HUIHUI_PAYMENT_BASE_URL": "https://open.example/api/payment-v3",
"HUIHUI_APP_ID": "app-id",
"HUIHUI_ACCESS_ID": "access-id",
"HUIHUI_ACCESS_SECRET": "access-secret",
})
response = Mock()
response.status_code = 200
response.json.return_value = {
"code": 0,
"data": {"orderId": "provider-id", "status": "pending", "payMessage": "mock"},
}
with patch("services.huihui_payment.httpx.post", return_value=response) as post:
result = client.create_payment(
huihui_token="user-token",
huihui_user_id="user-id",
real_name="测试用户",
order_no="AV202608260001",
amount="10.00",
points_amount=2_000_000,
pay_type="WECHAT",
pay_way="APP",
callback_url="https://digital.example/api/token/payment/callback/secret",
)
assert result["orderId"] == "provider-id"
assert post.call_args.args[0] == "https://open.example/api/payment-v3/payment/pay"
assert post.call_args.kwargs["headers"] == {
"Authorization": "Bearer user-token",
"appId": "app-id",
"windowAppId": "app-id",
}
params = post.call_args.kwargs["params"]
assert params["appId"] == "app-id"
assert params["accessId"] == "access-id"
assert params["userId"] == "user-id"
assert params["signature"]
assert "accessSecret" not in params
body = post.call_args.kwargs["json"]
assert body["payType"] == "WECHAT"
assert body["payWay"] == "APP"
assert body["masterOrderAmt"] == "10.00"
assert body["payAmt"] == 10.0
@@ -2,9 +2,17 @@ from pathlib import Path
from types import SimpleNamespace from types import SimpleNamespace
from unittest.mock import patch from unittest.mock import patch
from fastapi.testclient import TestClient
from database import SessionLocal
from main import app
from models import Avatar, KnowledgeChunk, KnowledgeDoc, QAPair
from routers.knowledge import _doc_payload from routers.knowledge import _doc_payload
client = TestClient(app)
def test_doc_payload_reports_whether_the_persisted_file_exists(tmp_path: Path): def test_doc_payload_reports_whether_the_persisted_file_exists(tmp_path: Path):
avatar_id = "avatar-1" avatar_id = "avatar-1"
stored_name = "knowledge.md" stored_name = "knowledge.md"
@@ -21,3 +29,157 @@ def test_doc_payload_reports_whether_the_persisted_file_exists(tmp_path: Path):
assert _doc_payload(doc)["filePresent"] is False assert _doc_payload(doc)["filePresent"] is False
stored_file.write_text("knowledge", encoding="utf-8") stored_file.write_text("knowledge", encoding="utf-8")
assert _doc_payload(doc)["filePresent"] is True assert _doc_payload(doc)["filePresent"] is True
def test_upload_marks_vectorization_failure_instead_of_staying_processing(
tmp_path: Path,
authorization_context,
):
context = authorization_context
with (
patch("routers.knowledge.UPLOAD_DIR", str(tmp_path)),
patch("routers.knowledge.embeddings.embed", side_effect=RuntimeError("provider unavailable")),
):
response = client.post(
f"/api/avatar/{context['avatar'].id}/knowledge/docs",
headers=context["owner_headers"],
files={"file": ("knowledge.md", b"# Knowledge\n\nTest content", "text/markdown")},
)
payload = response.json()["data"]
assert payload["status"] == "failed"
assert payload["vectorized"] is False
assert payload["chunkCount"] == 0
db = SessionLocal()
try:
stored = db.query(KnowledgeDoc).filter(KnowledgeDoc.id == payload["id"]).one()
assert stored.status == "failed"
assert db.query(KnowledgeChunk).filter(KnowledgeChunk.doc_id == stored.id).count() == 0
db.delete(stored)
db.commit()
finally:
db.close()
def test_markdown_upload_commits_ready_document_and_chunks_together(
tmp_path: Path,
authorization_context,
):
context = authorization_context
with (
patch("routers.knowledge.UPLOAD_DIR", str(tmp_path)),
patch("routers.knowledge.embeddings.embed", return_value=[[1.0, 0.0]]),
):
response = client.post(
f"/api/avatar/{context['avatar'].id}/knowledge/docs",
headers=context["owner_headers"],
files={"file": ("knowledge.md", b"# Knowledge\n\nTest content", "text/markdown")},
)
payload = response.json()["data"]
assert payload["status"] == "ready"
assert payload["vectorized"] is True
assert payload["chunkCount"] == 1
db = SessionLocal()
try:
stored = db.query(KnowledgeDoc).filter(KnowledgeDoc.id == payload["id"]).one()
assert stored.status == "ready"
assert db.query(KnowledgeChunk).filter(KnowledgeChunk.doc_id == stored.id).count() == 1
db.query(KnowledgeChunk).filter(KnowledgeChunk.doc_id == stored.id).delete()
db.delete(stored)
db.commit()
finally:
db.close()
def test_each_avatar_has_an_independent_document_and_qa_scope(authorization_context):
context = authorization_context
first_avatar_id = context["avatar"].id
second_avatar_id = f"knowledge-second-{context['suffix']}"
first_doc_id = f"knowledge-first-doc-{context['suffix']}"
second_doc_id = f"knowledge-second-doc-{context['suffix']}"
first_qa_id = f"knowledge-first-qa-{context['suffix']}"
second_qa_id = f"knowledge-second-qa-{context['suffix']}"
db = SessionLocal()
try:
db.add_all(
[
Avatar(
id=second_avatar_id,
owner_id=context["owner"].huihui_user_id,
name="独立知识库分身",
status="active",
config={},
),
KnowledgeDoc(
id=first_doc_id,
avatar_id=first_avatar_id,
filename="first.md",
status="ready",
vectorized=True,
),
KnowledgeDoc(
id=second_doc_id,
avatar_id=second_avatar_id,
filename="second.md",
status="ready",
vectorized=True,
),
QAPair(
id=first_qa_id,
avatar_id=first_avatar_id,
question="第一个分身问题",
answer="第一个分身答案",
),
QAPair(
id=second_qa_id,
avatar_id=second_avatar_id,
question="第二个分身问题",
answer="第二个分身答案",
),
]
)
db.commit()
finally:
db.close()
try:
first_docs = client.get(
f"/api/avatar/{first_avatar_id}/knowledge/docs",
headers=context["owner_headers"],
).json()["data"]
second_docs = client.get(
f"/api/avatar/{second_avatar_id}/knowledge/docs",
headers=context["owner_headers"],
).json()["data"]
first_qa = client.get(
f"/api/avatar/{first_avatar_id}/knowledge/qa",
headers=context["owner_headers"],
).json()["data"]
second_qa = client.get(
f"/api/avatar/{second_avatar_id}/knowledge/qa",
headers=context["owner_headers"],
).json()["data"]
assert [item["id"] for item in first_docs if item["id"] == first_doc_id] == [first_doc_id]
assert second_doc_id not in {item["id"] for item in first_docs}
assert [item["id"] for item in second_docs] == [second_doc_id]
assert first_qa_id in {item["id"] for item in first_qa}
assert second_qa_id not in {item["id"] for item in first_qa}
assert [item["id"] for item in second_qa] == [second_qa_id]
finally:
db = SessionLocal()
try:
db.query(QAPair).filter(QAPair.id.in_([first_qa_id, second_qa_id])).delete(
synchronize_session=False
)
db.query(KnowledgeDoc).filter(
KnowledgeDoc.id.in_([first_doc_id, second_doc_id])
).delete(synchronize_session=False)
db.query(Avatar).filter(Avatar.id == second_avatar_id).delete()
db.commit()
finally:
db.close()
@@ -13,7 +13,7 @@ def test_authorization_takeover_fields():
assert hasattr(auth, 'takeover_delay_seconds') assert hasattr(auth, 'takeover_delay_seconds')
assert auth.takeover_enabled == False assert auth.takeover_enabled == False
assert auth.takeover_mode == 'immediate' assert auth.takeover_mode == 'immediate'
assert auth.takeover_delay_seconds == 30 assert auth.takeover_delay_seconds == 180
finally: finally:
db.close() db.close()
@@ -11,7 +11,12 @@ from sqlalchemy.orm import sessionmaker
from database import Base from database import Base
from models import Avatar, TakeoverCursor, TakeoverMessage, TakeoverReplyTask, User from models import Avatar, TakeoverCursor, TakeoverMessage, TakeoverReplyTask, User
from services.boxim_client import BoxIMError from services.boxim_client import BoxIMError
from services.takeover_service import TakeoverService, _plain_text_reply from services.takeover_service import (
AVATAR_LOCAL_ID_PREFIX,
TakeoverService,
_avatar_local_id,
_plain_text_reply,
)
class Clock: class Clock:
@@ -77,7 +82,10 @@ def service_context(tmp_path):
owner_id=user.huihui_user_id, owner_id=user.huihui_user_id,
name="分身", name="分身",
status="active", status="active",
config={"authorizationPermissions": ["chat", "takeover"]}, config={
"authorizationPermissions": ["chat", "takeover"],
"takeoverReplyDelaySeconds": 3,
},
) )
db.add_all([user, avatar]) db.add_all([user, avatar])
db.commit() db.commit()
@@ -120,8 +128,7 @@ async def test_incoming_message_is_prepared_then_sent_at_three_seconds(service_c
{"id": 11, "localId": 2, "sendId": 200, "recvId": 100, "sendTime": clock.millis(), "type": 0, "content": "你好"} {"id": 11, "localId": 2, "sendId": 200, "recvId": 100, "sendTime": clock.millis(), "type": 0, "content": "你好"}
) )
with patch("routers.chat._resolve_reply", return_value={"answer": "**你好**\n\n很高兴见到你"}): await service.poll_and_process_messages()
await service.poll_and_process_messages()
assert boxim.sent == [] assert boxim.sent == []
assert boxim.read_receipts == [{"friendId": "200", "messageId": "11"}] assert boxim.read_receipts == [{"friendId": "200", "messageId": "11"}]
@@ -130,7 +137,8 @@ async def test_incoming_message_is_prepared_then_sent_at_three_seconds(service_c
assert boxim.sent == [] assert boxim.sent == []
clock.advance(1) clock.advance(1)
await service.poll_and_process_messages() with patch("routers.chat._resolve_reply", return_value={"answer": "**你好**\n\n很高兴见到你"}):
await service.poll_and_process_messages()
assert boxim.sent == [{"peerId": "200", "content": "你好\n很高兴见到你", "localId": boxim.sent[0]["localId"]}] assert boxim.sent == [{"peerId": "200", "content": "你好\n很高兴见到你", "localId": boxim.sent[0]["localId"]}]
db = session_factory() db = session_factory()
@@ -142,6 +150,131 @@ async def test_incoming_message_is_prepared_then_sent_at_three_seconds(service_c
db.close() db.close()
@pytest.mark.asyncio
async def test_default_reply_delay_is_three_minutes(service_context):
session_factory, service, boxim, clock = service_context
db = session_factory()
try:
avatar = db.query(Avatar).one()
avatar.config = {"authorizationPermissions": ["chat", "takeover"]}
db.commit()
finally:
db.close()
await service.poll_and_process_messages()
boxim.messages.append(
{"id": 12, "localId": 12, "sendId": 200, "recvId": 100, "sendTime": clock.millis(), "type": 0, "content": "三分钟后回复"}
)
await service.poll_and_process_messages()
db = session_factory()
try:
task = db.query(TakeoverReplyTask).one()
assert task.scheduled_at == clock.now() + timedelta(seconds=180)
finally:
db.close()
clock.advance(179)
await service.process_reply_tasks()
assert boxim.sent == []
clock.advance(1)
with patch("routers.chat._resolve_reply", return_value={"answer": "好的"}):
await service.poll_and_process_messages()
assert [item["content"] for item in boxim.sent] == ["好的"]
@pytest.mark.asyncio
async def test_avatar_origin_message_never_schedules_a_reply(service_context):
session_factory, service, boxim, clock = service_context
await service.poll_and_process_messages()
local_id = _avatar_local_id("peer-owner", "peer-trigger")
boxim.messages.append(
{"id": 15, "localId": local_id, "sendId": 200, "recvId": 100, "sendTime": clock.millis(), "type": 0, "content": "另一端分身回复"}
)
with patch("routers.chat._resolve_reply") as resolver:
await service.poll_and_process_messages()
resolver.assert_not_called()
db = session_factory()
try:
event = db.query(TakeoverMessage).filter(TakeoverMessage.boxim_message_id == "15").one()
assert event.is_avatar is True
assert db.query(TakeoverReplyTask).count() == 0
finally:
db.close()
@pytest.mark.asyncio
async def test_peer_avatar_messages_are_excluded_from_later_human_context(service_context):
_session_factory, service, boxim, clock = service_context
await service.poll_and_process_messages()
boxim.messages.append(
{
"id": 16,
"localId": _avatar_local_id("peer-owner", "peer-trigger"),
"sendId": 200,
"recvId": 100,
"sendTime": clock.millis(),
"type": 0,
"content": "分身生成的夸张长文",
}
)
await service.poll_and_process_messages()
clock.advance(1)
boxim.messages.append(
{
"id": 17,
"localId": 17,
"sendId": 200,
"recvId": 100,
"sendTime": clock.millis(),
"type": 0,
"content": "真人的新问题",
}
)
await service.poll_and_process_messages()
clock.advance(3)
with patch("routers.chat._resolve_reply", return_value={"answer": "正常回复"}) as resolver:
await service.poll_and_process_messages()
assert resolver.call_args.args[3] == []
@pytest.mark.asyncio
async def test_owner_message_pauses_future_takeover_for_ten_minutes(service_context):
session_factory, service, boxim, clock = service_context
await service.poll_and_process_messages()
boxim.messages.append(
{"id": 18, "localId": 18, "sendId": 100, "recvId": 200, "sendTime": clock.millis(), "type": 0, "content": "我先来回复"}
)
await service.poll_and_process_messages()
clock.advance(30)
boxim.messages.append(
{"id": 19, "localId": 19, "sendId": 200, "recvId": 100, "sendTime": clock.millis(), "type": 0, "content": "收到"}
)
with patch("routers.chat._resolve_reply") as resolver:
await service.poll_and_process_messages()
resolver.assert_not_called()
db = session_factory()
try:
assert db.query(TakeoverReplyTask).count() == 0
finally:
db.close()
def test_avatar_local_id_is_deterministic_and_self_describing():
first = _avatar_local_id("owner", "message-1")
assert first == _avatar_local_id("owner", "message-1")
assert first != _avatar_local_id("owner", "message-2")
assert first.startswith(AVATAR_LOCAL_ID_PREFIX)
assert len(first) == 18
assert first.isdigit()
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_different_contacts_generate_without_blocking_each_other(service_context): async def test_different_contacts_generate_without_blocking_each_other(service_context):
session_factory, service, boxim, clock = service_context session_factory, service, boxim, clock = service_context
@@ -158,11 +291,11 @@ async def test_different_contacts_generate_without_blocking_each_other(service_c
both_generating.wait() both_generating.wait()
return {"answer": f"回复{prompt[-1]}"} return {"answer": f"回复{prompt[-1]}"}
with patch("routers.chat._resolve_reply", side_effect=resolve): await service.poll_and_process_messages()
await service.poll_and_process_messages()
clock.advance(3) clock.advance(3)
await service.process_reply_tasks() with patch("routers.chat._resolve_reply", side_effect=resolve):
await service.poll_and_process_messages()
assert {(item["peerId"], item["content"]) for item in boxim.sent} == { assert {(item["peerId"], item["content"]) for item in boxim.sent} == {
("200", "回复甲"), ("200", "回复甲"),
("300", "回复乙"), ("300", "回复乙"),
@@ -237,6 +370,34 @@ async def test_owner_message_cancels_pending_reply(service_context):
db.close() db.close()
@pytest.mark.asyncio
async def test_owner_message_in_final_second_wins_before_generation(service_context):
session_factory, service, boxim, clock = service_context
await service.poll_and_process_messages()
boxim.messages.append(
{"id": 23, "localId": 23, "sendId": 200, "recvId": 100, "sendTime": clock.millis(), "type": 0, "content": "还在吗"}
)
await service.poll_and_process_messages()
clock.advance(2)
boxim.messages.append(
{"id": 24, "localId": 24, "sendId": 100, "recvId": 200, "sendTime": clock.millis(), "type": 0, "content": "我来处理"}
)
clock.advance(1)
with patch("routers.chat._resolve_reply") as resolver:
await service.poll_and_process_messages()
resolver.assert_not_called()
assert boxim.sent == []
db = session_factory()
try:
task = db.query(TakeoverReplyTask).filter(TakeoverReplyTask.trigger_message_id == "23").one()
assert task.status == "cancelled"
assert task.cancel_reason == "owner_replied"
finally:
db.close()
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_quick_successive_messages_are_coalesced_into_one_reply(service_context): async def test_quick_successive_messages_are_coalesced_into_one_reply(service_context):
session_factory, service, boxim, clock = service_context session_factory, service, boxim, clock = service_context
@@ -244,19 +405,18 @@ async def test_quick_successive_messages_are_coalesced_into_one_reply(service_co
boxim.messages.append( boxim.messages.append(
{"id": 31, "localId": 5, "sendId": 200, "recvId": 100, "sendTime": clock.millis(), "type": 0, "content": "第一句"} {"id": 31, "localId": 5, "sendId": 200, "recvId": 100, "sendTime": clock.millis(), "type": 0, "content": "第一句"}
) )
with patch("routers.chat._resolve_reply", return_value={"answer": "第一版"}): await service.poll_and_process_messages()
await service.poll_and_process_messages()
clock.advance(1) clock.advance(1)
boxim.messages.append( boxim.messages.append(
{"id": 32, "localId": 6, "sendId": 200, "recvId": 100, "sendTime": clock.millis(), "type": 0, "content": "第二句"} {"id": 32, "localId": 6, "sendId": 200, "recvId": 100, "sendTime": clock.millis(), "type": 0, "content": "第二句"}
) )
await service.poll_and_process_messages()
clock.advance(3)
with patch("routers.chat._resolve_reply", return_value={"answer": "合并回复"}) as resolver: with patch("routers.chat._resolve_reply", return_value={"answer": "合并回复"}) as resolver:
await service.poll_and_process_messages() await service.poll_and_process_messages()
assert resolver.call_args.args[2] == "第一句\n第二句" assert resolver.call_args.args[2] == "第一句\n第二句"
clock.advance(3)
await service.poll_and_process_messages()
assert [item["content"] for item in boxim.sent] == ["合并回复"] assert [item["content"] for item in boxim.sent] == ["合并回复"]
db = session_factory() db = session_factory()
@@ -291,5 +451,41 @@ async def test_connection_failure_disables_takeover_and_stops_retrying(service_c
boxim.exchange_access_token.assert_awaited_once_with("prod-huihui-token") boxim.exchange_access_token.assert_awaited_once_with("prod-huihui-token")
@pytest.mark.asyncio
async def test_transient_connection_failure_keeps_takeover_and_recovers(service_context):
session_factory, service, boxim, _ = service_context
boxim.exchange_access_token = AsyncMock(
side_effect=[
BoxIMError("连接超时"),
{"accessToken": "box-token", "accessTokenExpiresIn": 3600},
]
)
await service.poll_messages()
db = session_factory()
try:
avatar = db.query(Avatar).one()
cursor = db.query(TakeoverCursor).one()
assert "takeover" in avatar.config["authorizationPermissions"]
assert cursor.initialized is False
assert "暂时连接失败" in cursor.last_error
finally:
db.close()
await service.poll_messages()
db = session_factory()
try:
avatar = db.query(Avatar).one()
cursor = db.query(TakeoverCursor).one()
assert "takeover" in avatar.config["authorizationPermissions"]
assert cursor.initialized is True
assert cursor.last_error == ""
finally:
db.close()
assert boxim.exchange_access_token.await_count == 2
def test_plain_text_reply_removes_markdown_and_empty_lines(): def test_plain_text_reply_removes_markdown_and_empty_lines():
assert _plain_text_reply("## 建议\n\n**不能自行用药**\n`必要时就医`") == "建议\n不能自行用药\n必要时就医" assert _plain_text_reply("## 建议\n\n**不能自行用药**\n`必要时就医`") == "建议\n不能自行用药\n必要时就医"
@@ -1,14 +1,17 @@
import uuid import uuid
import os
import hashlib
import hmac
from concurrent.futures import ThreadPoolExecutor from concurrent.futures import ThreadPoolExecutor
from threading import Barrier from threading import Barrier
from unittest.mock import patch from unittest.mock import Mock, patch
import pytest import pytest
from fastapi.testclient import TestClient from fastapi.testclient import TestClient
from database import SessionLocal from database import SessionLocal
from main import app, seed from main import app, seed
from models import Avatar, TokenAccount, TokenPlan, TokenUsage, User from models import Avatar, TokenAccount, TokenPaymentOrder, TokenPlan, TokenUsage, User
from routers.chat import _resolve_reply, _stream_reply from routers.chat import _resolve_reply, _stream_reply
from services.token_billing import ( from services.token_billing import (
DEFAULT_TOKEN_GRANT, DEFAULT_TOKEN_GRANT,
@@ -22,6 +25,16 @@ from services.token_billing import (
client = TestClient(app) client = TestClient(app)
def _enable_huihui_payment_login(context):
db = SessionLocal()
try:
user = db.query(User).filter(User.id == context["owner"].id).one()
user.huihui_token = f"huihui-payment-{context['suffix']}"
db.commit()
finally:
db.close()
def test_balance_is_user_scoped_and_defaults_to_one_million(authorization_context): def test_balance_is_user_scoped_and_defaults_to_one_million(authorization_context):
context = authorization_context context = authorization_context
owner = client.get("/api/token/balance", headers=context["owner_headers"]) owner = client.get("/api/token/balance", headers=context["owner_headers"])
@@ -52,6 +65,171 @@ def test_seed_synchronizes_requested_recharge_plans():
db.close() db.close()
def test_charge_creates_huihui_order_without_early_points(authorization_context):
context = authorization_context
_enable_huihui_payment_login(context)
payment_client = Mock()
payment_client.create_payment.return_value = {
"orderId": "huihui-payment-id",
"orderNo": "huihui-payment-no",
"payMessage": {"mock": "payment-params"},
"payType": "WECHAT",
"paySubType": "APP",
"status": "pending",
}
env = {
"HUIHUI_PAYMENT_CALLBACK_BASE_URL": "https://digital.example",
"HUIHUI_PAYMENT_CALLBACK_SECRET": "test-callback-secret-123456",
}
with patch.dict(os.environ, env), patch("routers.tokens._payment_client", return_value=payment_client):
response = client.post(
"/api/token/charge",
headers=context["owner_headers"],
json={"planId": "1", "paymentMethod": "wechat", "payScene": "APP"},
)
assert response.status_code == 200
result = response.json()["data"]
assert result["status"] == "pending"
assert result["payType"] == "WECHAT"
assert result["payWay"] == "APP"
assert result["balance"] == DEFAULT_TOKEN_GRANT
assert payment_client.create_payment.call_args.kwargs["amount"] == "10.00"
callback_url = payment_client.create_payment.call_args.kwargs["callback_url"]
assert callback_url.startswith("https://digital.example/api/token/payment/callback/AV")
assert "test-callback-secret-123456" not in callback_url
def test_success_callback_credits_once_and_status_is_user_scoped(authorization_context):
context = authorization_context
_enable_huihui_payment_login(context)
payment_client = Mock()
payment_client.create_payment.return_value = {
"orderId": "huihui-payment-id",
"orderNo": "huihui-payment-no",
"payMessage": "payment-message",
"status": "pending",
}
secret = "test-callback-secret-123456"
env = {
"HUIHUI_PAYMENT_CALLBACK_BASE_URL": "https://digital.example",
"HUIHUI_PAYMENT_CALLBACK_SECRET": secret,
}
with patch.dict(os.environ, env), patch("routers.tokens._payment_client", return_value=payment_client):
created = client.post(
"/api/token/charge",
headers=context["owner_headers"],
json={"planId": "1", "paymentMethod": "alipay", "payScene": "APP"},
).json()["data"]
callback_body = {
"data": {
"masterOrderNo": created["orderNo"],
"status": "succeeded",
"payAmt": "10.00",
}
}
signature = hmac.new(
secret.encode(), created["orderNo"].encode(), hashlib.sha256
).hexdigest()
callback_path = f"/api/token/payment/callback/{created['orderNo']}/{signature}"
first = client.post(callback_path, json=callback_body)
second = client.post(callback_path, json=callback_body)
assert first.json()["data"] == {"received": True, "paid": True}
assert second.json()["data"] == {"received": True, "duplicate": True}
status = client.get(
f"/api/token/payment/{created['id']}", headers=context["owner_headers"]
).json()["data"]
assert status["status"] == "paid"
assert status["balance"] == DEFAULT_TOKEN_GRANT + 2_000_000
assert client.get(
f"/api/token/payment/{created['id']}", headers=context["other_headers"]
).json()["code"] == 404
def test_callback_amount_mismatch_never_credits_points(authorization_context):
context = authorization_context
_enable_huihui_payment_login(context)
payment_client = Mock()
payment_client.create_payment.return_value = {"status": "pending", "payMessage": "mock"}
secret = "test-callback-secret-123456"
env = {
"HUIHUI_PAYMENT_CALLBACK_BASE_URL": "https://digital.example",
"HUIHUI_PAYMENT_CALLBACK_SECRET": secret,
}
with patch.dict(os.environ, env), patch("routers.tokens._payment_client", return_value=payment_client):
created = client.post(
"/api/token/charge",
headers=context["owner_headers"],
json={"planId": "1", "paymentMethod": "wechat", "payScene": "APP"},
).json()["data"]
signature = hmac.new(
secret.encode(), created["orderNo"].encode(), hashlib.sha256
).hexdigest()
callback = client.post(
f"/api/token/payment/callback/{created['orderNo']}/{signature}",
json={
"masterOrderNo": created["orderNo"],
"status": "success",
"actualAmt": "9.99",
},
)
assert callback.json()["code"] == 422
db = SessionLocal()
try:
order = db.query(TokenPaymentOrder).filter(TokenPaymentOrder.id == created["id"]).one()
account = get_or_create_account(db, context["owner"].id)
assert order.status == "pending"
assert account.balance == DEFAULT_TOKEN_GRANT
finally:
db.close()
def test_payment_callback_creates_missing_account_in_same_settlement(authorization_context):
context = authorization_context
_enable_huihui_payment_login(context)
payment_client = Mock()
payment_client.create_payment.return_value = {"status": "pending", "payMessage": "mock"}
secret = "test-callback-secret-123456"
env = {
"HUIHUI_PAYMENT_CALLBACK_BASE_URL": "https://digital.example",
"HUIHUI_PAYMENT_CALLBACK_SECRET": secret,
}
with patch.dict(os.environ, env), patch("routers.tokens._payment_client", return_value=payment_client):
created = client.post(
"/api/token/charge",
headers=context["owner_headers"],
json={"planId": "1", "paymentMethod": "alipay", "payScene": "APP"},
).json()["data"]
db = SessionLocal()
try:
db.query(TokenAccount).filter(TokenAccount.user_id == context["owner"].id).delete()
db.commit()
finally:
db.close()
signature = hmac.new(
secret.encode(), created["orderNo"].encode(), hashlib.sha256
).hexdigest()
callback = client.post(
f"/api/token/payment/callback/{created['orderNo']}/{signature}",
json={
"masterOrderNo": created["orderNo"],
"status": "success",
"payAmt": "10.00",
},
)
assert callback.json()["data"] == {"received": True, "paid": True}
db = SessionLocal()
try:
account = db.query(TokenAccount).filter(TokenAccount.user_id == context["owner"].id).one()
assert account.balance == DEFAULT_TOKEN_GRANT + 2_000_000
assert account.total_granted == DEFAULT_TOKEN_GRANT + 2_000_000
finally:
db.close()
def test_multiple_avatars_share_owner_balance_and_usage_is_itemized(): def test_multiple_avatars_share_owner_balance_and_usage_is_itemized():
suffix = uuid.uuid4().hex suffix = uuid.uuid4().hex
db = SessionLocal() db = SessionLocal()
@@ -39,6 +39,10 @@ HUIHUI_ACCESS_ID=<production-access-id>
HUIHUI_ACCESS_SECRET=<production-access-secret> HUIHUI_ACCESS_SECRET=<production-access-secret>
HUIHUI_CLIENT_CODE=<production-client-code> HUIHUI_CLIENT_CODE=<production-client-code>
BOXIM_TIMEOUT_SECONDS=20 BOXIM_TIMEOUT_SECONDS=20
HUIHUI_PAYMENT_BASE_URL=https://open.99hui.com/api/payment-v3
HUIHUI_PAYMENT_CALLBACK_BASE_URL=https://digital.99hui.com
HUIHUI_PAYMENT_CALLBACK_SECRET=<至少32位随机密钥>
HUIHUI_PAYMENT_TIMEOUT_SECONDS=30
DATABASE_URL=sqlite:////data/avatar.db DATABASE_URL=sqlite:////data/avatar.db
UPLOAD_DIR=/data/uploads UPLOAD_DIR=/data/uploads
@@ -47,6 +51,8 @@ CHAT_MODEL_CONFIG_URL=http://<huihuisquare-api>/api/ai-models/runtime/digital-av
如生产 AI 配置中心不可用,还应提供当前项目支持的 `OPENAI_API_KEY`、`OPENAI_BASE_URL`、`CHAT_MODEL` 等兜底配置。`/data` 必须挂载持久卷,数据库与知识库文件不可存放在容器临时层。 如生产 AI 配置中心不可用,还应提供当前项目支持的 `OPENAI_API_KEY`、`OPENAI_BASE_URL`、`CHAT_MODEL` 等兜底配置。`/data` 必须挂载持久卷,数据库与知识库文件不可存放在容器临时层。
积分充值使用会会支付体系的 `payment-v3/payment/pay`,渠道值为 `WECHAT` / `ALIPAY`,端内支付场景为 `APP`,微信内 H5 使用 `JSAPI`。`HUIHUI_PAYMENT_CALLBACK_SECRET` 只用于为每笔订单生成 HMAC 回调签名,不会发送到前端或直接出现在回调地址中。支付回调确认状态成功且金额与套餐价格完全一致后才增加积分,重复回调不会重复到账。
## 3. 构建与发布 ## 3. 构建与发布
首次发布前备份数据: 首次发布前备份数据:
@@ -102,13 +108,14 @@ location /api/ {
1. 已登录会会用户通过带 token 链接打开后直接进入 `/avatar/manage`,不出现登录页或创建新账号页。 1. 已登录会会用户通过带 token 链接打开后直接进入 `/avatar/manage`,不出现登录页或创建新账号页。
2. 页面加载后地址栏中不再包含 `token`,刷新页面仍使用本地 `app_token` 正常访问。 2. 页面加载后地址栏中不再包含 `token`,刷新页面仍使用本地 `app_token` 正常访问。
3. 后端用户绑定的是 BOXIM 返回的 `huihuiUserId`,不是 BOXIM 内部 `id`;原有数字分身、独立知识库和 Token 余额均存在。 3. 后端用户绑定的是 BOXIM 返回的 `huihuiUserId`,不是 BOXIM 内部 `id`;原有数字分身、独立知识库和积分余额均存在。
4. A、B 两个会会用户分别进入时只能看到各自的数字分身与知识库,不会继承上一用户缓存。 4. A、B 两个会会用户分别进入时只能看到各自的数字分身与知识库,不会继承上一用户缓存。
5. 使用过期或伪造 token 时进入登录页并显示凭证失效,不得继续访问旧用户数据。 5. 使用过期或伪造 token 时进入登录页并显示凭证失效,不得继续访问旧用户数据。
6. 分身聊天 SSE 逐段输出正常,Markdown 正常渲染,知识库优先级和 Token 扣费正常。 6. 分身聊天 SSE 逐段输出正常,Markdown 正常渲染,知识库优先级和积分扣费正常。
7. 开启 BOXIM 主动接管后保持在线,收到消息、三秒回复、已读回执和主人发言暂停均正常。 7. 开启 BOXIM 主动接管后保持在线,默认三分钟回复、自定义等待时间、已读回执、分身防回环和主人发言暂停均正常。
8. 重建容器后数据库、头像、知识库文档仍存在,`/api/health` 返回成功。 8. 重建容器后数据库、头像、知识库文档仍存在,`/api/health` 返回成功。
9. `https://digital.99hui.com/api/health` 可访问,证书域名和有效期正确,HTTP 自动跳转 HTTPS。 9. `https://digital.99hui.com/api/health` 可访问,证书域名和有效期正确,HTTP 自动跳转 HTTPS。
10. 微信和支付宝各创建一笔最小套餐订单,未付款时积分不变;支付成功后回调到账一次,重复回调积分不重复增加。
## 6. 回滚 ## 6. 回滚
@@ -0,0 +1 @@
8f54097b01d5b0433f8aa5ea70ca930b
+36 -5
View File
@@ -146,6 +146,22 @@ export interface TokenUsageSummary {
requestCount: number requestCount: number
} }
export interface TokenPaymentOrder {
id: string
orderNo: string
planId: string
paymentMethod: 'wechat' | 'alipay'
payType: 'WECHAT' | 'ALIPAY'
payWay: 'APP' | 'LITE' | 'JSAPI'
pointsAmount: number
price: number
status: 'pending' | 'paid' | 'failed'
providerStatus: string
payMessage: string
failureReason: string
balance: number
}
// 获取 Token 余额 // 获取 Token 余额
export const getTokenBalance = () => export const getTokenBalance = () =>
request.get<TokenBalance>('/token/balance') request.get<TokenBalance>('/token/balance')
@@ -154,9 +170,15 @@ export const getTokenBalance = () =>
export const getRechargePlans = () => export const getRechargePlans = () =>
request.get<Array<{ id: string; name: string; amount: number; price: number }>>('/token/plans') request.get<Array<{ id: string; name: string; amount: number; price: number }>>('/token/plans')
// 执行充值 // 创建会会支付订单;支付回调成功后积分到账。
export const chargeToken = (planId: string) => export const chargeToken = (
request.post<{ balance: number; charged: number }>('/token/charge', { planId }) planId: string,
paymentMethod: 'wechat' | 'alipay',
payScene: 'APP' | 'LITE' | 'JSAPI'
) => request.post<TokenPaymentOrder>('/token/charge', { planId, paymentMethod, payScene })
export const getTokenPaymentStatus = (orderId: string) =>
request.get<TokenPaymentOrder>(`/token/payment/${orderId}`)
// 按分身和使用场景汇总 Token 消耗 // 按分身和使用场景汇总 Token 消耗
export const getTokenUsage = () => export const getTokenUsage = () =>
@@ -169,19 +191,28 @@ export type AvatarPermission = 'friend' | 'chat' | 'publish' | 'browse' | 'inter
export interface AvatarPermissionSettings { export interface AvatarPermissionSettings {
avatarId: string avatarId: string
permissions: AvatarPermission[] permissions: AvatarPermission[]
takeoverReplyDelaySeconds: number
disabledAvatarIds?: string[]
} }
export const getAvatarPermissionSettings = (avatarId: string) => export const getAvatarPermissionSettings = (avatarId: string) =>
request.get<AvatarPermissionSettings>(`/avatar/${avatarId}/permission-settings`) request.get<AvatarPermissionSettings>(`/avatar/${avatarId}/permission-settings`)
export const updateAvatarPermissionSettings = (avatarId: string, permissions: AvatarPermission[]) => export const updateAvatarPermissionSettings = (
request.put<AvatarPermissionSettings>(`/avatar/${avatarId}/permission-settings`, { permissions }) avatarId: string,
permissions: AvatarPermission[],
takeoverReplyDelaySeconds: number
) => request.put<AvatarPermissionSettings>(`/avatar/${avatarId}/permission-settings`, {
permissions,
takeoverReplyDelaySeconds,
})
export interface TakeoverStatus { export interface TakeoverStatus {
enabled: boolean enabled: boolean
status: 'disabled' | 'connecting' | 'ready' | 'needs_login' | 'error' status: 'disabled' | 'connecting' | 'ready' | 'needs_login' | 'error'
message: string message: string
pendingCount: number pendingCount: number
takeoverReplyDelaySeconds: number
lastPolledAt: string | null lastPolledAt: string | null
} }
+4
View File
@@ -0,0 +1,4 @@
<svg fill="#1677FF" role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<title>支付宝</title>
<path d="M19.695 15.07c3.426 1.158 4.203 1.22 4.203 1.22V3.846c0-2.124-1.705-3.845-3.81-3.845H3.914C1.808.001.102 1.722.102 3.846v16.31c0 2.123 1.706 3.845 3.813 3.845h16.173c2.105 0 3.81-1.722 3.81-3.845v-.157s-6.19-2.602-9.315-4.119c-2.096 2.602-4.8 4.181-7.607 4.181-4.75 0-6.361-4.19-4.112-6.949.49-.602 1.324-1.175 2.617-1.497 2.025-.502 5.247.313 8.266 1.317a16.796 16.796 0 0 0 1.341-3.302H5.781v-.952h4.799V6.975H4.77v-.953h5.81V3.591s0-.409.411-.409h2.347v2.84h5.744v.951h-5.744v1.704h4.69a19.453 19.453 0 0 1-1.986 5.06c1.424.52 2.702 1.011 3.654 1.333m-13.81-2.032c-.596.06-1.71.325-2.321.869-1.83 1.608-.735 4.55 2.968 4.55 2.151 0 4.301-1.388 5.99-3.61-2.403-1.182-4.438-2.028-6.637-1.809"/>
</svg>

After

Width:  |  Height:  |  Size: 828 B

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

After

Width:  |  Height:  |  Size: 1.3 KiB

+1 -1
View File
@@ -55,7 +55,7 @@ const routes: RouteRecordRaw[] = [
path: '/token/charge', path: '/token/charge',
name: 'TokenCharge', name: 'TokenCharge',
component: () => import('@/views/TokenCharge.vue'), component: () => import('@/views/TokenCharge.vue'),
meta: { title: 'Token充值', requiresAuth: true } meta: { title: '积分充值', requiresAuth: true }
}, },
{ {
path: '/avatar/card', path: '/avatar/card',
+13 -4
View File
@@ -10,6 +10,7 @@ import {
type SmsLoginResult, type SmsLoginResult,
type UserProfile type UserProfile
} from '@/api' } from '@/api'
import { clearHuihuiEmbeddedMode, markHuihuiEmbeddedMode } from '@/utils/embed-mode'
const TOKEN_KEY = 'hh_app_token' const TOKEN_KEY = 'hh_app_token'
const USER_KEY = 'hh_app_user' const USER_KEY = 'hh_app_user'
@@ -70,16 +71,23 @@ export const useUserStore = defineStore('smsuser', () => {
// 短信登录 // 短信登录
const login = async (phone: string, code: string) => { const login = async (phone: string, code: string) => {
return acceptLogin(await loginBySms(phone, code)) const result = await loginBySms(phone, code)
clearHuihuiEmbeddedMode()
return acceptLogin(result)
} }
// 账号密码登录 // 账号密码登录
const loginByPwd = async (account: string, password: string) => { const loginByPwd = async (account: string, password: string) => {
return acceptLogin(await loginByPassword(account, password)) const result = await loginByPassword(account, password)
clearHuihuiEmbeddedMode()
return acceptLogin(result)
} }
const loginByToken = async (huihuiToken: string) => const loginByToken = async (huihuiToken: string) => {
acceptLogin(await loginByHuihuiToken(huihuiToken)) const result = await loginByHuihuiToken(huihuiToken)
markHuihuiEmbeddedMode()
return acceptLogin(result)
}
// 退出 // 退出
const logout = async () => { const logout = async () => {
@@ -88,6 +96,7 @@ export const useUserStore = defineStore('smsuser', () => {
} catch { } catch {
/* 忽略网络错误,本地清除即可 */ /* 忽略网络错误,本地清除即可 */
} }
clearHuihuiEmbeddedMode()
clearSession() clearSession()
} }
@@ -0,0 +1,13 @@
const HUIHUI_EMBED_MODE_KEY = 'hh_huihui_embed_mode'
export function markHuihuiEmbeddedMode(): void {
sessionStorage.setItem(HUIHUI_EMBED_MODE_KEY, '1')
}
export function clearHuihuiEmbeddedMode(): void {
sessionStorage.removeItem(HUIHUI_EMBED_MODE_KEY)
}
export function isHuihuiEmbeddedMode(): boolean {
return sessionStorage.getItem(HUIHUI_EMBED_MODE_KEY) === '1'
}
+26 -6
View File
@@ -4,6 +4,7 @@
// uni-webview bridge is bundled by main.ts; no external CDN is required. // uni-webview bridge is bundled by main.ts; no external CDN is required.
const BRIDGE_HANDLER = '__uniBridgeHandle__' const BRIDGE_HANDLER = '__uniBridgeHandle__'
const nativeMessageHandlers = new Set<(message: any) => void>()
export interface UniLaunchParams { export interface UniLaunchParams {
token?: string token?: string
@@ -25,7 +26,21 @@ function readParams(search: string, target: UniLaunchParams): void {
// 是否运行在 uniapp web-view 环境中 // 是否运行在 uniapp web-view 环境中
export function isInUniWebView(): boolean { export function isInUniWebView(): boolean {
return !!(window as any).uni?.webView const runtime = window as any
const userAgent = navigator.userAgent
const isDCloudApp = Boolean(
runtime.plus ||
runtime.__dcloud_weex_postMessage ||
runtime.__dcloud_weex_ ||
/uni-app|Html5Plus/i.test(userAgent)
)
const isMiniProgram = Boolean(
(/miniProgram/i.test(userAgent) && (runtime.wx?.miniProgram || runtime.qq?.miniProgram)) ||
runtime.my ||
runtime.swan?.webView ||
runtime.tt?.miniProgram
)
return Boolean(runtime.uni?.webView && (isDCloudApp || isMiniProgram))
} }
// 解析 web-view 加载 URL 时原生注入的参数(token / 会会用户) // 解析 web-view 加载 URL 时原生注入的参数(token / 会会用户)
@@ -65,14 +80,18 @@ export function postToNative(message: Record<string, any>): boolean {
} }
// 原生 → H5:注册消息处理(原生通过 web-view.evalJS 调用 window.__uniBridgeHandle__) // 原生 → H5:注册消息处理(原生通过 web-view.evalJS 调用 window.__uniBridgeHandle__)
export function onNativeMessage(handler: (message: any) => void): void { export function onNativeMessage(handler: (message: any) => void): () => void {
nativeMessageHandlers.add(handler)
;(window as any)[BRIDGE_HANDLER] = (message: any) => { ;(window as any)[BRIDGE_HANDLER] = (message: any) => {
try { for (const listener of nativeMessageHandlers) {
handler(message) try {
} catch (e) { listener(message)
console.error('[uniBridge] handler error', e) } catch (e) {
console.error('[uniBridge] handler error', e)
}
} }
} }
return () => nativeMessageHandlers.delete(handler)
} }
// 便捷事件 // 便捷事件
@@ -81,5 +100,6 @@ export const UniEvents = {
needLogin: () => postToNative({ type: 'needLogin' }), needLogin: () => postToNative({ type: 'needLogin' }),
setTitle: (title: string) => postToNative({ type: 'setTitle', title }), setTitle: (title: string) => postToNative({ type: 'setTitle', title }),
navigate: (path: string) => postToNative({ type: 'navigate', path }), navigate: (path: string) => postToNative({ type: 'navigate', path }),
payment: (payment: Record<string, any>) => postToNative({ type: 'payment', payment }),
back: () => postToNative({ type: 'back' }) back: () => postToNative({ type: 'back' })
} }
@@ -1,6 +1,6 @@
<template> <template>
<div class="authorization-page"> <div class="authorization-page" :class="{ embedded: isEmbedded }">
<header class="page-header"> <header v-if="!isEmbedded" class="page-header">
<button class="back-button" type="button" aria-label="返回数字分身管理" @click="goBack"> <button class="back-button" type="button" aria-label="返回数字分身管理" @click="goBack">
<svg viewBox="0 0 24 24" aria-hidden="true"> <svg viewBox="0 0 24 24" aria-hidden="true">
<path d="m15 18-6-6 6-6" /> <path d="m15 18-6-6 6-6" />
@@ -64,7 +64,7 @@
<span class="permission-copy"> <span class="permission-copy">
<strong>{{ item.title }}</strong> <strong>{{ item.title }}</strong>
<small> <small>
{{ item.description }} {{ item.key === 'takeover' ? takeoverDescription : item.description }}
<span <span
v-if="item.key === 'takeover' && takeoverConnectionLabel" v-if="item.key === 'takeover' && takeoverConnectionLabel"
class="connection-state" class="connection-state"
@@ -79,6 +79,33 @@
</button> </button>
</section> </section>
<section v-if="permissionState.takeover" class="takeover-delay-card" aria-label="自动回复等待时间">
<div class="delay-heading">
<div>
<strong>自动回复等待时间</strong>
<small>等待期间主人发言会取消本次回复,最短 3 秒</small>
</div>
<span>{{ formattedTakeoverDelay }}</span>
</div>
<div class="delay-control">
<input
v-model.number="takeoverDelayValue"
type="number"
inputmode="numeric"
step="1"
:min="takeoverDelayUnit === 'minutes' ? 1 : 3"
:max="takeoverDelayUnit === 'minutes' ? 1440 : 86400"
aria-label="等待时间"
:disabled="loading || saving"
@blur="normalizeTakeoverDelay"
/>
<select v-model="takeoverDelayUnit" aria-label="等待时间单位" :disabled="loading || saving">
<option value="seconds">秒</option>
<option value="minutes">分钟</option>
</select>
</div>
</section>
<p v-if="errorMessage" class="error-message" role="alert">{{ errorMessage }}</p> <p v-if="errorMessage" class="error-message" role="alert">{{ errorMessage }}</p>
</template> </template>
@@ -95,6 +122,9 @@
</main> </main>
<footer v-if="activeAvatarId" class="save-area"> <footer v-if="activeAvatarId" class="save-area">
<button v-if="isEmbedded" class="footer-back-button" type="button" :disabled="saving" @click="goBack">
返回
</button>
<button class="save-button" type="button" :disabled="loading || saving" @click="saveSettings()"> <button class="save-button" type="button" :disabled="loading || saving" @click="saveSettings()">
<span v-if="saving" class="saving-spinner" aria-hidden="true"></span> <span v-if="saving" class="saving-spinner" aria-hidden="true"></span>
{{ saving ? '保存中...' : '保存授权设置' }} {{ saving ? '保存中...' : '保存授权设置' }}
@@ -119,6 +149,7 @@ import {
} from '@/api' } from '@/api'
import { useAvatarStore } from '@/store/avatar' import { useAvatarStore } from '@/store/avatar'
import { pickScopedAvatarId } from '@/utils/avatar-page-data.js' import { pickScopedAvatarId } from '@/utils/avatar-page-data.js'
import { isHuihuiEmbeddedMode } from '@/utils/embed-mode'
type PermissionState = Record<AvatarPermission, boolean> type PermissionState = Record<AvatarPermission, boolean>
@@ -126,6 +157,7 @@ const router = useRouter()
const route = useRoute() const route = useRoute()
const avatarStore = useAvatarStore() const avatarStore = useAvatarStore()
const avatarId = computed(() => pickScopedAvatarId(route.params.avatarId, avatarStore.currentAvatarId, avatarStore.avatars)) const avatarId = computed(() => pickScopedAvatarId(route.params.avatarId, avatarStore.currentAvatarId, avatarStore.avatars))
const isEmbedded = isHuihuiEmbeddedMode()
const permissionItems: Array<{ const permissionItems: Array<{
key: AvatarPermission key: AvatarPermission
@@ -166,7 +198,7 @@ const permissionItems: Array<{
{ {
key: 'takeover', key: 'takeover',
title: '分身主动接管聊天回复', title: '分身主动接管聊天回复',
description: '收到私聊消息 3 秒后回复,主人发言时暂停', description: '收到私聊消息后按设定时间回复,主人发言时暂停',
tone: 'cyan', tone: 'cyan',
}, },
] ]
@@ -185,6 +217,8 @@ const saving = ref(false)
const errorMessage = ref('') const errorMessage = ref('')
const toastMessage = ref('') const toastMessage = ref('')
const takeoverStatus = ref<TakeoverStatus | null>(null) const takeoverStatus = ref<TakeoverStatus | null>(null)
const takeoverDelayValue = ref(3)
const takeoverDelayUnit = ref<'seconds' | 'minutes'>('minutes')
let toastTimer: number | undefined let toastTimer: number | undefined
let takeoverStatusTimer: number | undefined let takeoverStatusTimer: number | undefined
@@ -205,6 +239,38 @@ const takeoverConnectionTone = computed(() => {
return 'connecting' return 'connecting'
}) })
const takeoverDelaySeconds = computed(() => {
const value = Math.trunc(Number(takeoverDelayValue.value) || 0)
return takeoverDelayUnit.value === 'minutes' ? value * 60 : value
})
const formattedTakeoverDelay = computed(() => {
const seconds = takeoverDelaySeconds.value
if (seconds > 0 && seconds % 60 === 0) return `${seconds / 60} 分钟`
return `${seconds} 秒`
})
const takeoverDescription = computed(() =>
`收到私聊消息 ${formattedTakeoverDelay.value}后回复,主人发言时暂停`
)
const applyTakeoverDelay = (seconds: number) => {
const normalized = Number.isFinite(seconds) && seconds >= 3 ? Math.trunc(seconds) : 180
if (normalized % 60 === 0) {
takeoverDelayUnit.value = 'minutes'
takeoverDelayValue.value = normalized / 60
} else {
takeoverDelayUnit.value = 'seconds'
takeoverDelayValue.value = normalized
}
}
const normalizeTakeoverDelay = () => {
const min = takeoverDelayUnit.value === 'minutes' ? 1 : 3
const max = takeoverDelayUnit.value === 'minutes' ? 1440 : 86400
takeoverDelayValue.value = Math.min(max, Math.max(min, Math.trunc(Number(takeoverDelayValue.value) || min)))
}
const setPermissions = (permissions: AvatarPermission[]) => { const setPermissions = (permissions: AvatarPermission[]) => {
const enabled = new Set(permissions) const enabled = new Set(permissions)
for (const item of permissionItems) permissionState[item.key] = enabled.has(item.key) for (const item of permissionItems) permissionState[item.key] = enabled.has(item.key)
@@ -260,6 +326,7 @@ const loadSettings = async () => {
try { try {
const settings = await getAvatarPermissionSettings(activeAvatarId.value) const settings = await getAvatarPermissionSettings(activeAvatarId.value)
setPermissions(settings.permissions || []) setPermissions(settings.permissions || [])
applyTakeoverDelay(settings.takeoverReplyDelaySeconds || 180)
await loadTakeoverStatus() await loadTakeoverStatus()
scheduleTakeoverStatusRefresh() scheduleTakeoverStatusRefresh()
} catch (error: any) { } catch (error: any) {
@@ -286,8 +353,18 @@ const saveSettings = async (takeoverToggle = false): Promise<boolean> => {
saving.value = true saving.value = true
errorMessage.value = '' errorMessage.value = ''
try { try {
const settings = await updateAvatarPermissionSettings(activeAvatarId.value, selectedPermissions()) normalizeTakeoverDelay()
if (takeoverDelaySeconds.value < 3 || takeoverDelaySeconds.value > 86400) {
errorMessage.value = '自动回复等待时间需在 3 秒到 24 小时之间'
return false
}
const settings = await updateAvatarPermissionSettings(
activeAvatarId.value,
selectedPermissions(),
takeoverDelaySeconds.value,
)
setPermissions(settings.permissions || []) setPermissions(settings.permissions || [])
applyTakeoverDelay(settings.takeoverReplyDelaySeconds || 180)
await loadTakeoverStatus() await loadTakeoverStatus()
scheduleTakeoverStatusRefresh() scheduleTakeoverStatusRefresh()
if (takeoverToggle) { if (takeoverToggle) {
@@ -394,6 +471,10 @@ svg {
padding: 0 20px; padding: 0 20px;
} }
.authorization-page.embedded .page-content {
padding-top: 16px;
}
.permission-intro { .permission-intro {
min-height: 96px; min-height: 96px;
padding: 15px 16px 14px; padding: 15px 16px 14px;
@@ -459,6 +540,75 @@ svg {
min-height: 76px; min-height: 76px;
} }
.takeover-delay-card {
margin-top: 12px;
padding: 16px;
border: 1px solid #dff1ef;
border-radius: 15px;
background: linear-gradient(135deg, #f5fcfb 0%, #fff 100%);
box-shadow: 0 8px 24px rgba(53, 166, 162, .06);
}
.delay-heading {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
}
.delay-heading strong,
.delay-heading small {
display: block;
}
.delay-heading strong {
font-size: 14px;
line-height: 1.4;
}
.delay-heading small {
margin-top: 5px;
color: #8c929f;
font-size: 11px;
line-height: 1.55;
}
.delay-heading > span {
flex: none;
padding: 4px 8px;
border-radius: 999px;
color: #258e8a;
background: #e8f8f6;
font-size: 11px;
font-weight: 700;
}
.delay-control {
margin-top: 14px;
display: grid;
grid-template-columns: minmax(0, 1fr) 88px;
gap: 10px;
}
.delay-control input,
.delay-control select {
min-width: 0;
height: 42px;
padding: 0 12px;
border: 1px solid #dfe5e8;
border-radius: 11px;
outline: none;
color: #222528;
background: #fff;
font: inherit;
}
.delay-control input:focus,
.delay-control select:focus {
border-color: #35a6a2;
box-shadow: 0 0 0 3px rgba(53, 166, 162, .1);
}
.permission-icon { .permission-icon {
width: 34px; width: 34px;
height: 34px; height: 34px;
@@ -599,12 +749,15 @@ svg {
bottom: 0; bottom: 0;
width: min(100%, 390px); width: min(100%, 390px);
padding: 12px 20px calc(20px + env(safe-area-inset-bottom)); padding: 12px 20px calc(20px + env(safe-area-inset-bottom));
display: flex;
gap: 10px;
background: linear-gradient(to bottom, rgba(250, 250, 250, 0), #fafafa 20%, #fafafa 100%); background: linear-gradient(to bottom, rgba(250, 250, 250, 0), #fafafa 20%, #fafafa 100%);
transform: translateX(-50%); transform: translateX(-50%);
} }
.save-button { .save-button {
width: 100%; min-width: 0;
flex: 1;
height: 48px; height: 48px;
display: flex; display: flex;
align-items: center; align-items: center;
@@ -620,6 +773,20 @@ svg {
cursor: pointer; cursor: pointer;
} }
.footer-back-button {
flex: 0 0 96px;
height: 48px;
border: 1px solid #eadfd6;
border-radius: 24px;
color: #6f665f;
background: #fff;
font-size: 14px;
font-weight: 500;
cursor: pointer;
}
.footer-back-button:disabled { opacity: .58; }
.save-button:disabled { .save-button:disabled {
opacity: .68; opacity: .68;
} }
+56 -15
View File
@@ -1,12 +1,10 @@
<template> <template>
<div class="edit-avatar-page"> <div class="edit-avatar-page">
<!-- 顶部导航 --> <!-- 顶部导航 -->
<header class="page-header"> <header v-if="!isEmbedded" class="page-header">
<button class="back-btn" @click="goBack">‹</button> <button class="back-btn" @click="goBack">‹</button>
<h1 class="page-title">分身微调</h1> <h1 class="page-title">分身微调</h1>
<button class="save-btn" :disabled="loading || saving || uploadingPhoto" @click="saveChanges"> <span class="header-spacer" aria-hidden="true"></span>
{{ saving ? '保存中...' : '保存' }}
</button>
</header> </header>
<div v-if="loading" class="status-banner">加载中...</div> <div v-if="loading" class="status-banner">加载中...</div>
@@ -159,6 +157,13 @@
{{ deleting ? '删除中...' : '删除数字分身' }} {{ deleting ? '删除中...' : '删除数字分身' }}
</button> </button>
</section> </section>
<footer class="edit-action-bar">
<button class="action-back-btn" type="button" :disabled="saving" @click="goBack">返回</button>
<button class="action-save-btn" type="button" :disabled="loading || saving || uploadingPhoto" @click="saveChanges">
{{ saving ? '保存中...' : '保存修改' }}
</button>
</footer>
</div> </div>
</template> </template>
@@ -168,11 +173,13 @@ import { useRoute, useRouter } from 'vue-router'
import { deleteAvatar as apiDeleteAvatar, getAvatarDetail, updateAvatar, uploadAvatarPhoto } from '@/api' import { deleteAvatar as apiDeleteAvatar, getAvatarDetail, updateAvatar, uploadAvatarPhoto } from '@/api'
import { useAvatarStore } from '@/store/avatar' import { useAvatarStore } from '@/store/avatar'
import { buildAvatarUpdatePayload, normalizeAvatarEditForm } from '@/utils/avatar-page-data.js' import { buildAvatarUpdatePayload, normalizeAvatarEditForm } from '@/utils/avatar-page-data.js'
import { isHuihuiEmbeddedMode } from '@/utils/embed-mode'
const router = useRouter() const router = useRouter()
const route = useRoute() const route = useRoute()
const avatarStore = useAvatarStore() const avatarStore = useAvatarStore()
const avatarId = route.params.id as string const avatarId = route.params.id as string
const isEmbedded = isHuihuiEmbeddedMode()
// 表单数据 // 表单数据
const formData = reactive({ const formData = reactive({
@@ -288,7 +295,7 @@ onMounted(async () => {
.edit-avatar-page { .edit-avatar-page {
min-height: 100vh; min-height: 100vh;
background: #F8F9FA; background: #F8F9FA;
padding-bottom: 40px; padding-bottom: calc(104px + env(safe-area-inset-bottom));
} }
/* 顶部导航 */ /* 顶部导航 */
@@ -331,16 +338,7 @@ onMounted(async () => {
color: #B91C1C; color: #B91C1C;
} }
.save-btn { .header-spacer { width: 40px; }
background: #F97316;
color: white;
border: none;
padding: 8px 20px;
border-radius: 8px;
font-size: 14px;
font-weight: 600;
cursor: pointer;
}
/* 头像上传 */ /* 头像上传 */
.photo-section { .photo-section {
@@ -580,4 +578,47 @@ onMounted(async () => {
background: #EF4444; background: #EF4444;
color: white; color: white;
} }
.edit-action-bar {
position: fixed;
z-index: 30;
left: 0;
right: 0;
bottom: 0;
display: flex;
gap: 12px;
padding: 12px 20px calc(14px + env(safe-area-inset-bottom));
border-top: 1px solid rgba(229, 231, 235, .9);
background: rgba(248, 249, 250, .96);
box-shadow: 0 -8px 24px rgba(56, 38, 24, .06);
backdrop-filter: blur(12px);
}
.action-back-btn,
.action-save-btn {
height: 48px;
border-radius: 14px;
font-size: 15px;
font-weight: 600;
cursor: pointer;
}
.action-back-btn {
flex: 0 0 104px;
border: 1px solid #E4E0DC;
color: #655E58;
background: #fff;
}
.action-save-btn {
min-width: 0;
flex: 1;
border: 0;
color: #fff;
background: linear-gradient(105deg, #F79A38, #F97316);
box-shadow: 0 8px 18px rgba(249, 115, 22, .18);
}
.action-back-btn:disabled,
.action-save-btn:disabled { opacity: .6; cursor: not-allowed; }
</style> </style>
+45 -13
View File
@@ -1,15 +1,11 @@
<template> <template>
<div class="avatar-manage-page"> <div class="avatar-manage-page">
<!-- 顶部导航 --> <!-- 顶部导航 -->
<header class="page-header"> <header v-if="!isEmbedded" class="page-header">
<div class="header-left"> <div class="header-left">
<button class="back-btn" @click="goBack">‹</button> <button class="back-btn" @click="goBack">‹</button>
<h1 class="page-title">数字分身管理</h1> <h1 class="page-title">数字分身管理</h1>
</div> </div>
<!-- 右上角创建入口 -->
<div class="header-right">
<button class="icon-btn" @click="goCreate" title="创建数字分身">➕</button>
</div>
</header> </header>
<!-- 用户资料头(会会登录账号的头像 / 昵称) --> <!-- 用户资料头(会会登录账号的头像 / 昵称) -->
@@ -24,11 +20,11 @@
</div> </div>
</section> </section>
<!-- Token 余额条 --> <!-- 积分余额条:暂时隐藏,保留完整实现便于后续恢复。 -->
<section class="token-section"> <section v-if="SHOW_POINTS_BALANCE_CARD" class="token-section">
<div class="token-card"> <div class="token-card">
<div class="token-info"> <div class="token-info">
<span class="token-label">Token 余额</span> <span class="token-label">积分余额</span>
<span class="token-amount">{{ tokenBalance.toLocaleString() }}</span> <span class="token-amount">{{ tokenBalance.toLocaleString() }}</span>
<span class="token-used">累计使用 {{ tokenConsumed.toLocaleString() }}</span> <span class="token-used">累计使用 {{ tokenConsumed.toLocaleString() }}</span>
</div> </div>
@@ -39,8 +35,13 @@
<!-- 数字分身列表(只放分身相关) --> <!-- 数字分身列表(只放分身相关) -->
<section class="avatar-list-section"> <section class="avatar-list-section">
<div class="section-head"> <div class="section-head">
<h3 class="section-title">我的数字分身</h3> <div class="section-heading-copy">
<span class="count-badge">{{ avatars.length }}</span> <h3 class="section-title">我的数字分身</h3>
<span class="count-badge">{{ avatars.length }}</span>
</div>
<button class="section-create-btn" type="button" @click="goCreate">
<span aria-hidden="true">+</span> 添加分身
</button>
</div> </div>
<div v-if="avatars.length" class="avatar-list"> <div v-if="avatars.length" class="avatar-list">
@@ -56,7 +57,7 @@
<div class="avatar-details"> <div class="avatar-details">
<div class="avatar-name-row"><h2 class="avatar-name">{{ a.displayName || a.name }}</h2><span class="avatar-status"><i class="status-dot" :class="a.status"></i>{{ statusText(a.status) }}</span></div> <div class="avatar-name-row"><h2 class="avatar-name">{{ a.displayName || a.name }}</h2><span class="avatar-status"><i class="status-dot" :class="a.status"></i>{{ statusText(a.status) }}</span></div>
<p class="avatar-desc">{{ a.description || '暂无描述' }}</p> <p class="avatar-desc">{{ a.description || '暂无描述' }}</p>
<span class="avatar-token-usage">累计使用 {{ avatarTokenUsage(a.id).toLocaleString() }} Token</span> <span class="avatar-token-usage">累计使用 {{ avatarTokenUsage(a.id).toLocaleString() }} 积分</span>
</div> </div>
</div> </div>
<div class="avatar-actions"> <div class="avatar-actions">
@@ -87,10 +88,15 @@ import { useRouter } from 'vue-router'
import { useAvatarStore } from '@/store/avatar' 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'
const router = useRouter() const router = useRouter()
const avatarStore = useAvatarStore() const avatarStore = useAvatarStore()
const userStore = useUserStore() const userStore = useUserStore()
const isEmbedded = isHuihuiEmbeddedMode()
// 临时产品开关:余额卡片代码保留,后续改为 true 即可恢复展示。
const SHOW_POINTS_BALANCE_CARD = false
// 当前登录会会用户的资料(头像 / 昵称) // 当前登录会会用户的资料(头像 / 昵称)
const me = computed(() => userStore.user) const me = computed(() => userStore.user)
@@ -289,7 +295,7 @@ onMounted(() => {
background: rgba(255, 255, 255, 0.3); background: rgba(255, 255, 255, 0.3);
} }
/* Token 余额条 */ /* 积分余额条 */
.token-section { .token-section {
padding: 16px 20px; padding: 16px 20px;
} }
@@ -351,10 +357,36 @@ onMounted(() => {
.section-head { .section-head {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 8px; justify-content: space-between;
gap: 12px;
margin: 8px 0 12px; margin: 8px 0 12px;
} }
.section-heading-copy {
min-width: 0;
display: flex;
align-items: center;
gap: 8px;
}
.section-create-btn {
flex: 0 0 auto;
display: inline-flex;
align-items: center;
gap: 3px;
padding: 8px 12px;
border: 1px solid #FED7B5;
border-radius: 999px;
color: #E9650C;
background: #FFF7ED;
font-size: 12px;
font-weight: 650;
cursor: pointer;
}
.section-create-btn span { font-size: 17px; line-height: 1; }
.section-create-btn:active { background: #FFEDD5; }
.section-title { .section-title {
font-size: 16px; font-size: 16px;
font-weight: 600; font-weight: 600;
@@ -1,7 +1,7 @@
<template> <template>
<div class="knowledge-page"> <div class="knowledge-page" :class="{ embedded: isEmbedded }">
<!-- 顶部导航 --> <!-- 顶部导航 -->
<header class="page-header"> <header v-if="!isEmbedded" class="page-header">
<div class="header-left"> <div class="header-left">
<button class="back-btn" @click="goBack">‹</button> <button class="back-btn" @click="goBack">‹</button>
<h1 class="page-title">知识库管理</h1> <h1 class="page-title">知识库管理</h1>
@@ -23,7 +23,7 @@
<div class="upload-section"> <div class="upload-section">
<div class="upload-zone" :class="{ 'drag-over': dragOver }" @click="triggerFile" @dragover.prevent="dragOver = true" @dragleave.prevent="dragOver = false" @drop.prevent="onDrop"> <div class="upload-zone" :class="{ 'drag-over': dragOver }" @click="triggerFile" @dragover.prevent="dragOver = true" @dragleave.prevent="dragOver = false" @drop.prevent="onDrop">
<div class="upload-icon">📥</div> <div class="upload-icon">📥</div>
<p class="upload-title">拖拽文件到此处,或<span class="upload-link">点击上传</span></p> <p class="upload-title"><span class="upload-link">点击上传</span></p>
<p class="upload-hint">支持 MD / TXT / PDF / DOC / DOCX / XLSX,上传后自动向量化</p> <p class="upload-hint">支持 MD / TXT / PDF / DOC / DOCX / XLSX,上传后自动向量化</p>
<input ref="fileInput" type="file" accept=".md,.txt,.pdf,.doc,.docx,.xlsx" class="hidden-input" @change="onFileChange" /> <input ref="fileInput" type="file" accept=".md,.txt,.pdf,.doc,.docx,.xlsx" class="hidden-input" @change="onFileChange" />
</div> </div>
@@ -37,10 +37,10 @@
<div class="card-content"> <div class="card-content">
<div class="card-title-row"> <div class="card-title-row">
<strong>{{ doc.filename }}</strong> <strong>{{ doc.filename }}</strong>
<span class="status-pill" :class="{ pending: !doc.vectorized && doc.filePresent !== false, missing: doc.filePresent === false }">{{ doc.filePresent === false ? '文件缺失' : (doc.vectorized ? '已入库' : '处理中') }}</span> <span class="status-pill" :class="documentState(doc).tone">{{ documentState(doc).label }}</span>
</div> </div>
<p class="card-meta">{{ doc.fileType.toUpperCase() }} · {{ formatSize(doc.fileSize) }} · {{ formatDate(doc.createdAt) }}</p> <p class="card-meta">{{ doc.fileType.toUpperCase() }} · {{ formatSize(doc.fileSize) }} · {{ formatDate(doc.createdAt) }}</p>
<p class="card-detail">{{ doc.filePresent === false ? '原文件不可用,请删除后重新上传' : (doc.vectorized ? `已切分 ${doc.chunkCount || 0} 段,可用于对话` : '正在解析并建立知识索引') }}</p> <p class="card-detail">{{ documentState(doc).detail }}</p>
</div> </div>
<button class="card-delete" @click="removeDoc(doc.id)">删除</button> <button class="card-delete" @click="removeDoc(doc.id)">删除</button>
</article> </article>
@@ -82,6 +82,7 @@ import { ref, onMounted, computed } from 'vue'
import { useRoute, useRouter } from 'vue-router' import { useRoute, useRouter } from 'vue-router'
import { useAvatarStore } from '@/store/avatar' import { useAvatarStore } from '@/store/avatar'
import { pickScopedAvatarId, unwrapListData } from '@/utils/avatar-page-data.js' import { pickScopedAvatarId, unwrapListData } from '@/utils/avatar-page-data.js'
import { isHuihuiEmbeddedMode } from '@/utils/embed-mode'
import { import {
getKnowledgeDocs, getKnowledgeDocs,
uploadKnowledgeDoc, uploadKnowledgeDoc,
@@ -95,6 +96,7 @@ import {
const router = useRouter() const router = useRouter()
const route = useRoute() const route = useRoute()
const store = useAvatarStore() const store = useAvatarStore()
const isEmbedded = isHuihuiEmbeddedMode()
const avatarId = computed(() => pickScopedAvatarId(route.params.avatarId, store.currentAvatarId, store.avatars)) const avatarId = computed(() => pickScopedAvatarId(route.params.avatarId, store.currentAvatarId, store.avatars))
const activeTab = ref<'docs' | 'qa'>('docs') const activeTab = ref<'docs' | 'qa'>('docs')
@@ -111,6 +113,19 @@ const searching = ref(false)
const searched = ref(false) const searched = ref(false)
const searchResults = ref<any[]>([]) const searchResults = ref<any[]>([])
const documentState = (doc: any) => {
if (doc.filePresent === false) {
return { tone: 'missing', label: '文件缺失', detail: '原文件不可用,请删除后重新上传' }
}
if (doc.vectorized) {
return { tone: 'ready', label: '已入库', detail: `已切分 ${doc.chunkCount || 0} 段,可用于对话` }
}
if (['uploaded', 'parsing'].includes(String(doc.status || '').toLowerCase())) {
return { tone: 'pending', label: '处理中', detail: '正在解析并建立知识索引' }
}
return { tone: 'failed', label: '处理失败', detail: '未能建立知识索引,请删除后重新上传' }
}
const loadDocs = async () => { const loadDocs = async () => {
if (!avatarId.value) return if (!avatarId.value) return
try { try {
@@ -283,6 +298,7 @@ onMounted(async () => {
.card-title-row strong { min-width: 0; flex: 1; overflow: hidden; color: #27201C; font-size: 14px; text-overflow: ellipsis; white-space: nowrap; } .card-title-row strong { min-width: 0; flex: 1; overflow: hidden; color: #27201C; font-size: 14px; text-overflow: ellipsis; white-space: nowrap; }
.status-pill { flex: 0 0 auto; display: inline-flex; padding: 4px 7px; border-radius: 999px; color: #15803D; background: #ECFDF3; font-size: 10px; white-space: nowrap; }.status-pill.pending { color: #B45309; background: #FFFBEB; } .status-pill { flex: 0 0 auto; display: inline-flex; padding: 4px 7px; border-radius: 999px; color: #15803D; background: #ECFDF3; font-size: 10px; white-space: nowrap; }.status-pill.pending { color: #B45309; background: #FFFBEB; }
.status-pill.missing { color: #B91C1C; background: #FEF2F2; } .status-pill.missing { color: #B91C1C; background: #FEF2F2; }
.status-pill.failed { color: #B91C1C; background: #FEF2F2; }
.card-meta, .card-detail { margin: 5px 0 0; color: #9398AE; font-size: 11px; line-height: 1.4; }.card-detail { color: #8B6B58; } .card-meta, .card-detail { margin: 5px 0 0; color: #9398AE; font-size: 11px; line-height: 1.4; }.card-detail { color: #8B6B58; }
.card-delete { flex: 0 0 auto; align-self: center; border: 0; color: #EF4444; background: #FEF2F2; border-radius: 8px; padding: 7px 9px; font-size: 12px; cursor: pointer; } .card-delete { flex: 0 0 auto; align-self: center; border: 0; color: #EF4444; background: #FEF2F2; border-radius: 8px; padding: 7px 9px; font-size: 12px; cursor: pointer; }
.card-empty { padding: 42px 16px; border: 1px dashed #F1D9C3; border-radius: 16px; color: #9398AE; background: #fff; font-size: 14px; text-align: center; } .card-empty { padding: 42px 16px; border: 1px dashed #F1D9C3; border-radius: 16px; color: #9398AE; background: #fff; font-size: 14px; text-align: center; }
+22 -3
View File
@@ -10,10 +10,12 @@
<section class="form-section"> <section class="form-section">
<label class="field-label">问题</label> <label class="field-label">问题</label>
<textarea <textarea
ref="questionInput"
v-model="form.question" v-model="form.question"
class="field-input" class="field-input question-input"
rows="3" rows="1"
placeholder="例如:你们的退款政策是什么?" placeholder="例如:你们的退款政策是什么?"
@input="resizeQuestion"
></textarea> ></textarea>
<label class="field-label">标准答案</label> <label class="field-label">标准答案</label>
@@ -46,7 +48,7 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, reactive, computed, onMounted } from 'vue' import { ref, reactive, computed, nextTick, onMounted } from 'vue'
import { useRouter, useRoute } from 'vue-router' import { useRouter, useRoute } from 'vue-router'
import { useAvatarStore } from '@/store/avatar' import { useAvatarStore } from '@/store/avatar'
import { pickScopedAvatarId, unwrapListData } from '@/utils/avatar-page-data.js' import { pickScopedAvatarId, unwrapListData } from '@/utils/avatar-page-data.js'
@@ -63,6 +65,14 @@ const isEdit = computed(() => !!qaId.value)
const form = reactive({ question: '', answer: '', enabled: true }) const form = reactive({ question: '', answer: '', enabled: true })
const saving = ref(false) const saving = ref(false)
const error = ref('') const error = ref('')
const questionInput = ref<HTMLTextAreaElement | null>(null)
const resizeQuestion = (event?: Event) => {
const element = (event?.target as HTMLTextAreaElement | null) || questionInput.value
if (!element) return
element.style.height = 'auto'
element.style.height = `${element.scrollHeight}px`
}
const goBack = () => router.back() const goBack = () => router.back()
@@ -124,6 +134,8 @@ onMounted(async () => {
if (isEdit.value) { if (isEdit.value) {
await loadForEdit() await loadForEdit()
} }
await nextTick()
resizeQuestion()
}) })
</script> </script>
@@ -194,6 +206,13 @@ onMounted(async () => {
border-color: #F97316; border-color: #F97316;
} }
.question-input {
min-height: 44px;
overflow: hidden;
resize: none;
line-height: 1.55;
}
.switch-row { .switch-row {
display: flex; display: flex;
align-items: center; align-items: center;
+222 -21
View File
@@ -3,7 +3,7 @@
<!-- 顶部导航 --> <!-- 顶部导航 -->
<header class="page-header"> <header class="page-header">
<button class="back-btn" @click="goBack">‹</button> <button class="back-btn" @click="goBack">‹</button>
<h1 class="page-title">Token 充值</h1> <h1 class="page-title">积分充值</h1>
<div class="header-right"></div> <div class="header-right"></div>
</header> </header>
@@ -12,8 +12,8 @@
<div class="balance-card"> <div class="balance-card">
<span class="balance-label">当前余额</span> <span class="balance-label">当前余额</span>
<span class="balance-amount">{{ currentBalance.toLocaleString() }}</span> <span class="balance-amount">{{ currentBalance.toLocaleString() }}</span>
<span class="balance-unit">Token</span> <span class="balance-unit">积分</span>
<span class="balance-used">累计使用 {{ totalConsumed.toLocaleString() }} Token</span> <span class="balance-used">累计使用 {{ totalConsumed.toLocaleString() }} 积分</span>
</div> </div>
</section> </section>
@@ -30,9 +30,9 @@
> >
<div class="plan-badge" v-if="plan.badge">{{ plan.badge }}</div> <div class="plan-badge" v-if="plan.badge">{{ plan.badge }}</div>
<div class="plan-amount">{{ formatTokenAmount(plan.amount) }}</div> <div class="plan-amount">{{ formatTokenAmount(plan.amount) }}</div>
<div class="plan-unit">Token</div> <div class="plan-unit">积分</div>
<div class="plan-price">¥{{ plan.price }}</div> <div class="plan-price">¥{{ plan.price }}</div>
<div class="plan-desc" v-if="plan.desc">{{ plan.desc }}</div> <div class="plan-desc" v-if="plan.desc">{{ formatPointsCopy(plan.desc) }}</div>
</div> </div>
</div> </div>
</section> </section>
@@ -46,7 +46,7 @@
:class="{ selected: paymentMethod === 'wechat' }" :class="{ selected: paymentMethod === 'wechat' }"
@click="paymentMethod = 'wechat'" @click="paymentMethod = 'wechat'"
> >
<span class="payment-icon">💚</span> <span class="payment-icon wechat-icon"><img :src="wechatPayIcon" alt="" /></span>
<span class="payment-name">微信支付</span> <span class="payment-name">微信支付</span>
<span class="payment-check" v-if="paymentMethod === 'wechat'">✓</span> <span class="payment-check" v-if="paymentMethod === 'wechat'">✓</span>
</div> </div>
@@ -55,31 +55,41 @@
:class="{ selected: paymentMethod === 'alipay' }" :class="{ selected: paymentMethod === 'alipay' }"
@click="paymentMethod = 'alipay'" @click="paymentMethod = 'alipay'"
> >
<span class="payment-icon">💙</span> <span class="payment-icon alipay-icon"><img :src="alipayIcon" alt="" /></span>
<span class="payment-name">支付宝</span> <span class="payment-name">支付宝</span>
<span class="payment-check" v-if="paymentMethod === 'alipay'">✓</span> <span class="payment-check" v-if="paymentMethod === 'alipay'">✓</span>
</div> </div>
</div> </div>
<p v-if="paymentNotice" class="payment-notice" :class="paymentNoticeTone">{{ paymentNotice }}</p>
</section> </section>
<!-- 充值按钮 --> <!-- 充值按钮 -->
<section class="checkout-section"> <section class="checkout-section">
<button <button
class="checkout-btn" class="checkout-btn"
:class="{ disabled: !selectedPlan }" :class="{ disabled: !selectedPlan || charging || Boolean(pendingOrderId) }"
:disabled="!selectedPlan" :disabled="!selectedPlan || charging || Boolean(pendingOrderId)"
@click="doCharge" @click="doCharge"
> >
{{ selectedPlan ? `立即支付 ¥${selectedPlan.price}` : '请选择充值套餐' }} {{ checkoutLabel }}
</button> </button>
</section> </section>
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, onMounted } from 'vue' import { computed, onMounted, onUnmounted, ref } from 'vue'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import { getTokenBalance, getRechargePlans, chargeToken } from '@/api' import {
chargeToken,
getRechargePlans,
getTokenBalance,
getTokenPaymentStatus,
type TokenPaymentOrder
} from '@/api'
import { isInUniWebView, onNativeMessage, UniEvents } from '@/utils/uniapp-bridge'
import wechatPayIcon from '@/assets/wechat-pay.svg'
import alipayIcon from '@/assets/alipay.svg'
const router = useRouter() const router = useRouter()
@@ -101,6 +111,12 @@ const selectedPlan = ref<any>(null)
// 支付方式 // 支付方式
const paymentMethod = ref<'wechat' | 'alipay'>('wechat') const paymentMethod = ref<'wechat' | 'alipay'>('wechat')
const paymentNotice = ref('')
const paymentNoticeTone = ref<'pending' | 'success' | 'error'>('pending')
const pendingOrderId = ref(sessionStorage.getItem('hh_pending_payment_order') || '')
let pollTimer: number | undefined
let pollDeadline = 0
let removeNativeListener: (() => void) | undefined
// 从后端加载余额与套餐 // 从后端加载余额与套餐
const loadData = async () => { const loadData = async () => {
@@ -119,22 +135,144 @@ const loadData = async () => {
} }
} }
// 执行充值(写入后端) // 会会支付订单创建与到账确认
const charging = ref(false) const charging = ref(false)
const formatTokenAmount = (amount: number) => { const formatTokenAmount = (amount: number) => {
if (amount >= 1_000_000 && amount % 1_000_000 === 0) return `${amount / 1_000_000}M` if (amount >= 1_000_000 && amount % 1_000_000 === 0) return `${amount / 1_000_000}M`
return amount.toLocaleString() return amount.toLocaleString()
} }
const formatPointsCopy = (value: string) => value.replace(/\bToken\b/gi, '积分')
const checkoutLabel = computed(() => {
if (!selectedPlan.value) return '请选择充值套餐'
if (charging.value) return '正在创建支付订单…'
if (pendingOrderId.value) return '等待支付结果…'
return `立即支付 ¥${selectedPlan.value.price}`
})
const payScene = () => {
if (paymentMethod.value === 'wechat' && /MicroMessenger/i.test(navigator.userAgent)) return 'JSAPI' as const
return 'APP' as const
}
const parsePayMessage = (message: string) => {
const text = (message || '').trim()
if (!text) return null
try {
return JSON.parse(text)
} catch {
return text
}
}
const findPaymentUrl = (value: any): string => {
if (typeof value === 'string' && /^https?:\/\//i.test(value)) return value
if (!value || typeof value !== 'object') return ''
for (const key of ['url', 'payUrl', 'mwebUrl', 'mweb_url', 'redirectUrl']) {
if (typeof value[key] === 'string' && /^https?:\/\//i.test(value[key])) return value[key]
}
return ''
}
const invokeWechatJsapi = (params: Record<string, any>) => new Promise<void>((resolve, reject) => {
const invoke = () => {
const bridge = (window as any).WeixinJSBridge
if (!bridge?.invoke) {
reject(new Error('当前微信环境无法拉起支付'))
return
}
bridge.invoke('getBrandWCPayRequest', params, (result: any) => {
const message = String(result?.err_msg || result?.errMsg || '')
if (/\bok\b/i.test(message)) resolve()
else reject(new Error(/cancel/i.test(message) ? '支付已取消' : '微信支付未完成'))
})
}
if ((window as any).WeixinJSBridge) invoke()
else document.addEventListener('WeixinJSBridgeReady', invoke, { once: true })
})
const launchPayment = async (order: TokenPaymentOrder) => {
const parsed = parsePayMessage(order.payMessage)
if (order.payWay === 'JSAPI' && order.paymentMethod === 'wechat' && parsed && typeof parsed === 'object') {
await invokeWechatJsapi(parsed)
return
}
if (isInUniWebView() && UniEvents.payment({
orderId: order.id,
orderNo: order.orderNo,
paymentMethod: order.paymentMethod,
payType: order.payType,
payWay: order.payWay,
payMessage: order.payMessage,
paymentParams: parsed
})) return
const paymentUrl = findPaymentUrl(parsed)
if (paymentUrl) {
window.location.assign(paymentUrl)
return
}
throw new Error('请在会会 App 内完成支付')
}
const clearPendingOrder = () => {
pendingOrderId.value = ''
sessionStorage.removeItem('hh_pending_payment_order')
if (pollTimer) window.clearTimeout(pollTimer)
pollTimer = undefined
}
const pollPayment = async () => {
if (!pendingOrderId.value) return
try {
const order = await getTokenPaymentStatus(pendingOrderId.value)
if (order.status === 'paid') {
currentBalance.value = order.balance
paymentNoticeTone.value = 'success'
paymentNotice.value = `支付成功,${order.pointsAmount.toLocaleString()} 积分已到账`
clearPendingOrder()
return
}
if (order.status === 'failed') {
paymentNoticeTone.value = 'error'
paymentNotice.value = order.failureReason || '支付未完成,请重新发起'
clearPendingOrder()
return
}
} catch (error) {
console.error('查询支付结果失败', error)
}
if (Date.now() < pollDeadline) pollTimer = window.setTimeout(pollPayment, 2000)
else {
paymentNoticeTone.value = 'pending'
paymentNotice.value = '支付结果仍在确认中,可稍后刷新余额或重新发起'
clearPendingOrder()
}
}
const startPolling = (orderId: string) => {
pendingOrderId.value = orderId
sessionStorage.setItem('hh_pending_payment_order', orderId)
pollDeadline = Date.now() + 2 * 60 * 1000
if (pollTimer) window.clearTimeout(pollTimer)
void pollPayment()
}
const doCharge = async () => { const doCharge = async () => {
if (!selectedPlan.value || charging.value) return if (!selectedPlan.value || charging.value) return
charging.value = true charging.value = true
paymentNotice.value = ''
try { try {
const methodText = paymentMethod.value === 'wechat' ? '微信支付' : '支付宝' const order = await chargeToken(selectedPlan.value.id, paymentMethod.value, payScene())
const res: any = await chargeToken(selectedPlan.value.id) if (order.status === 'failed') throw new Error(order.failureReason || '支付订单创建失败')
currentBalance.value = res?.balance ?? currentBalance.value paymentNoticeTone.value = 'pending'
alert(`已通过${methodText}成功充值,当前余额:${currentBalance.value} Token`) paymentNotice.value = '支付订单已创建,正在等待付款结果'
} catch (e) { startPolling(order.id)
alert('充值失败,请重试') await launchPayment(order)
} catch (error: any) {
paymentNoticeTone.value = 'error'
paymentNotice.value = error?.message || '充值失败,请重试'
clearPendingOrder()
} finally { } finally {
charging.value = false charging.value = false
} }
@@ -146,7 +284,33 @@ const goBack = () => {
} }
onMounted(() => { onMounted(() => {
loadData() void loadData()
removeNativeListener = onNativeMessage((message) => {
if (message?.type !== 'paymentResult') return
if (message.orderId && message.orderId !== pendingOrderId.value) return
if (['cancelled', 'canceled'].includes(String(message.status).toLowerCase())) {
paymentNoticeTone.value = 'error'
paymentNotice.value = '支付已取消'
clearPendingOrder()
return
}
if (String(message.status).toLowerCase() === 'failed') {
paymentNoticeTone.value = 'error'
paymentNotice.value = message.message || '支付未完成,请重新发起'
clearPendingOrder()
return
}
void pollPayment()
})
if (pendingOrderId.value) {
paymentNotice.value = '正在确认上一笔支付结果'
startPolling(pendingOrderId.value)
}
})
onUnmounted(() => {
if (pollTimer) window.clearTimeout(pollTimer)
removeNativeListener?.()
}) })
</script> </script>
@@ -333,7 +497,24 @@ onMounted(() => {
} }
.payment-icon { .payment-icon {
font-size: 24px; width: 38px;
height: 38px;
display: grid;
place-items: center;
border-radius: 11px;
}
.payment-icon img {
width: 25px;
height: 25px;
}
.wechat-icon {
background: #EAF9F0;
}
.alipay-icon {
background: #EAF3FF;
} }
.payment-name { .payment-name {
@@ -356,6 +537,26 @@ onMounted(() => {
font-weight: 600; font-weight: 600;
} }
.payment-notice {
margin: 12px 2px 0;
padding: 10px 12px;
border-radius: 10px;
color: #9A5B16;
background: #FFF5E8;
font-size: 13px;
line-height: 1.5;
}
.payment-notice.success {
color: #08783E;
background: #EAF9F0;
}
.payment-notice.error {
color: #B42318;
background: #FFF0EE;
}
/* 充值按钮 */ /* 充值按钮 */
.checkout-section { .checkout-section {
padding: 0 20px; padding: 0 20px;
+4
View File
@@ -54,6 +54,7 @@ H5 引入 uniapp web-view bridge 后调用:
| `needLogin` | — | token 失效,请求壳重新登录 | | `needLogin` | — | token 失效,请求壳重新登录 |
| `setTitle` | `title` | 设置原生导航栏标题 | | `setTitle` | `title` | 设置原生导航栏标题 |
| `navigate` | `path` | 请求原生跳转(打开原生页/新 web-view) | | `navigate` | `path` | 请求原生跳转(打开原生页/新 web-view) |
| `payment` | `payment` | 拉起会会原生支付;包含 `orderId/orderNo/payType/payWay/payMessage/paymentParams` |
| `back` | — | 请求原生返回 | | `back` | — | 请求原生返回 |
### 2.3 原生 → H5(壳主动推送) ### 2.3 原生 → H5(壳主动推送)
@@ -63,9 +64,12 @@ H5 引入 uniapp web-view bridge 后调用:
| `context` | `platform, version` | 注入运行环境信息 | | `context` | `platform, version` | 注入运行环境信息 |
| `tokenRefresh` | `token` | 登录刷新后下发新 token | | `tokenRefresh` | `token` | 登录刷新后下发新 token |
| `userUpdate` | `user` | 会会资料变更 | | `userUpdate` | `user` | 会会资料变更 |
| `paymentResult` | `orderId,status` | 原生支付结束通知;`status` 为 `success/cancelled/failed` |
> H5 侧用 `onNativeMessage(cb)` 注册 `window.__uniBridgeHandle__`,见 `digital-avatar-app/src/utils/uniapp-bridge.ts`。 > H5 侧用 `onNativeMessage(cb)` 注册 `window.__uniBridgeHandle__`,见 `digital-avatar-app/src/utils/uniapp-bridge.ts`。
壳收到 `payment` 后应调用会会 App 已有的微信/支付宝支付能力(或 `uni.requestPayment`),把 `payMessage/paymentParams` 原样交给对应渠道。原生 SDK 返回后再发送 `paymentResult`;H5 不以原生返回作为到账依据,只会轮询本地订单,最终由会会服务端支付回调确认并增加积分。
--- ---
## 3. 项目结构(uni CLI / src 布局,已验证可编译) ## 3. 项目结构(uni CLI / src 布局,已验证可编译)