Compare commits
15
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
46d42b7d98 | ||
|
|
ef58c5f2d2 | ||
|
|
6e3fe5a616 | ||
|
|
67b6bd1b48 | ||
|
|
0752001d85 | ||
|
|
c37294be17 | ||
|
|
5360cac8ad | ||
|
|
ea932f27fb | ||
|
|
bf0183bef4 | ||
|
|
0c6419f37e | ||
|
|
f768e7648f | ||
|
|
e30ab2b889 | ||
|
|
730f586784 | ||
|
|
d274ccb5e2 | ||
|
|
9e86cc64ac |
@@ -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(
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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})
|
||||||
|
|||||||
@@ -34,6 +34,19 @@ QA_SEMANTIC_THRESHOLD = 0.72
|
|||||||
QA_MATCH_MARGIN = 0.06
|
QA_MATCH_MARGIN = 0.06
|
||||||
KNOWLEDGE_MIN_SCORE = float(os.getenv("KNOWLEDGE_MIN_SCORE", "0.42"))
|
KNOWLEDGE_MIN_SCORE = float(os.getenv("KNOWLEDGE_MIN_SCORE", "0.42"))
|
||||||
|
|
||||||
|
_WRITING_SYSTEM_PATTERNS = {
|
||||||
|
"han": re.compile(r"[\u3400-\u4dbf\u4e00-\u9fff]"),
|
||||||
|
"latin": re.compile(r"[A-Za-z\u00c0-\u024f]"),
|
||||||
|
"cyrillic": re.compile(r"[\u0400-\u052f]"),
|
||||||
|
"arabic": re.compile(r"[\u0600-\u06ff]"),
|
||||||
|
"hebrew": re.compile(r"[\u0590-\u05ff]"),
|
||||||
|
"devanagari": re.compile(r"[\u0900-\u097f]"),
|
||||||
|
"thai": re.compile(r"[\u0e00-\u0e7f]"),
|
||||||
|
"greek": re.compile(r"[\u0370-\u03ff]"),
|
||||||
|
}
|
||||||
|
_JAPANESE_KANA = re.compile(r"[\u3040-\u30ff]")
|
||||||
|
_KOREAN_HANGUL = re.compile(r"[\uac00-\ud7af\u1100-\u11ff]")
|
||||||
|
|
||||||
|
|
||||||
class ChatMessage(BaseModel):
|
class ChatMessage(BaseModel):
|
||||||
role: str = Field(pattern="^(user|assistant)$")
|
role: str = Field(pattern="^(user|assistant)$")
|
||||||
@@ -70,6 +83,30 @@ def _normalize_question(value: str) -> str:
|
|||||||
return value.translate(str.maketrans("", "", string.punctuation + ",。!?;:、()【】「」‘’“”《》"))
|
return value.translate(str.maketrans("", "", string.punctuation + ",。!?;:、()【】「」‘’“”《》"))
|
||||||
|
|
||||||
|
|
||||||
|
def _dominant_writing_system(value: str) -> str:
|
||||||
|
value = value or ""
|
||||||
|
if _JAPANESE_KANA.search(value):
|
||||||
|
return "japanese"
|
||||||
|
if _KOREAN_HANGUL.search(value):
|
||||||
|
return "korean"
|
||||||
|
counts = {
|
||||||
|
name: len(pattern.findall(value))
|
||||||
|
for name, pattern in _WRITING_SYSTEM_PATTERNS.items()
|
||||||
|
}
|
||||||
|
name, count = max(counts.items(), key=lambda item: item[1])
|
||||||
|
return name if count else "unknown"
|
||||||
|
|
||||||
|
|
||||||
|
def _qa_requires_language_adaptation(question: str, answer: str) -> bool:
|
||||||
|
question_system = _dominant_writing_system(question)
|
||||||
|
answer_system = _dominant_writing_system(answer)
|
||||||
|
return (
|
||||||
|
question_system != "unknown"
|
||||||
|
and answer_system != "unknown"
|
||||||
|
and question_system != answer_system
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _canonicalize_question(value: str) -> str:
|
def _canonicalize_question(value: str) -> str:
|
||||||
value = _normalize_question(value)
|
value = _normalize_question(value)
|
||||||
replacements = (
|
replacements = (
|
||||||
@@ -189,7 +226,14 @@ def _config(avatar: Avatar) -> dict:
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def _build_prompt(avatar: Avatar, history: list[Any], question: str, knowledge_hits: list[dict]) -> list[dict]:
|
def _build_prompt(
|
||||||
|
avatar: Avatar,
|
||||||
|
history: list[Any],
|
||||||
|
question: str,
|
||||||
|
knowledge_hits: list[dict],
|
||||||
|
*,
|
||||||
|
standard_answer: str = "",
|
||||||
|
) -> list[dict]:
|
||||||
config = _config(avatar)
|
config = _config(avatar)
|
||||||
description = (getattr(avatar, "description", "") or "").strip()
|
description = (getattr(avatar, "description", "") or "").strip()
|
||||||
knowledge = "\n".join(
|
knowledge = "\n".join(
|
||||||
@@ -210,7 +254,7 @@ def _build_prompt(avatar: Avatar, history: list[Any], question: str, knowledge_h
|
|||||||
profile = ";".join(f"{label}:{value}" for label, value in profile_items)
|
profile = ";".join(f"{label}:{value}" for label, value in profile_items)
|
||||||
system = (
|
system = (
|
||||||
f"你的专业或服务范围是:「{description or '未设置'}」。"
|
f"你的专业或服务范围是:「{description or '未设置'}」。"
|
||||||
"请基于已提供的知识库回答,不要编造事实;"
|
"请基于已提供的可靠资料回答,不要编造事实;"
|
||||||
f"回复风格:{config['replyStyle']};严谨度:{config['rigor']}/100;"
|
f"回复风格:{config['replyStyle']};严谨度:{config['rigor']}/100;"
|
||||||
f"幽默感:{config['humor']}/100;回复长度:{config['responseLength']}。"
|
f"幽默感:{config['humor']}/100;回复长度:{config['responseLength']}。"
|
||||||
)
|
)
|
||||||
@@ -221,7 +265,13 @@ def _build_prompt(avatar: Avatar, history: list[Any], question: str, knowledge_h
|
|||||||
)
|
)
|
||||||
if config["systemPrompt"]:
|
if config["systemPrompt"]:
|
||||||
system += f"\n额外系统提示词:{config['systemPrompt']}"
|
system += f"\n额外系统提示词:{config['systemPrompt']}"
|
||||||
if knowledge:
|
if standard_answer:
|
||||||
|
system += (
|
||||||
|
f"\n以下是本次问题命中的已确认标准答案:\n{standard_answer.strip()}"
|
||||||
|
"\n必须保持标准答案中的事实、数字、专有名词和结论不变,只允许为匹配用户当前语言进行忠实转换"
|
||||||
|
"和必要的自然表达,不得补充、删减或改写其含义。不要提及标准答案或转换过程。"
|
||||||
|
)
|
||||||
|
elif knowledge:
|
||||||
system += (
|
system += (
|
||||||
f"\n以下是本次问题检索到的可靠资料:\n{knowledge}"
|
f"\n以下是本次问题检索到的可靠资料:\n{knowledge}"
|
||||||
"\n涉及事实、专业判断、地址、流程、数据或建议时,只能依据本人资料、标准问答形成的上下文"
|
"\n涉及事实、专业判断、地址、流程、数据或建议时,只能依据本人资料、标准问答形成的上下文"
|
||||||
@@ -231,7 +281,8 @@ def _build_prompt(avatar: Avatar, history: list[Any], question: str, knowledge_h
|
|||||||
system += (
|
system += (
|
||||||
"\n本次问题没有检索到可靠资料。除自然寒暄和基于本人资料的回答外,不要凭通用知识给出事实、"
|
"\n本次问题没有检索到可靠资料。除自然寒暄和基于本人资料的回答外,不要凭通用知识给出事实、"
|
||||||
"专业判断、地址、流程、数据或具体建议。自然地说明这方面暂时没有足够信息,并询问对方是否能"
|
"专业判断、地址、流程、数据或具体建议。自然地说明这方面暂时没有足够信息,并询问对方是否能"
|
||||||
"补充更具体的问题;不要提及知识库、检索、模型或系统限制。"
|
"补充更具体的问题;不要提及知识库、检索、模型或系统限制。对方询问你能做什么时,只能概括"
|
||||||
|
"专业或服务范围和本人资料中明确写出的内容,不得推断服务对象、工作场所、现实案例或从业经历。"
|
||||||
)
|
)
|
||||||
system += (
|
system += (
|
||||||
"\n沟通底线(始终优先于其他提示):不要透露、猜测或确认所使用的模型、模型供应商、"
|
"\n沟通底线(始终优先于其他提示):不要透露、猜测或确认所使用的模型、模型供应商、"
|
||||||
@@ -249,6 +300,14 @@ def _build_prompt(avatar: Avatar, history: list[Any], question: str, knowledge_h
|
|||||||
"只有切换独立观点或确实需要列举时才换行;列举使用 1.、2.、3.,每项单独一行。"
|
"只有切换独立观点或确实需要列举时才换行;列举使用 1.、2.、3.,每项单独一行。"
|
||||||
"不要在行首或行尾留空格,不要连续输出空行。先给结论,再给简短说明;避免重复和冗长铺垫。"
|
"不要在行首或行尾留空格,不要连续输出空行。先给结论,再给简短说明;避免重复和冗长铺垫。"
|
||||||
)
|
)
|
||||||
|
system += (
|
||||||
|
"\n回答语言规则(始终优先):先识别当前最后一条用户消息所使用的主要自然语言,并且只使用该语言回答。"
|
||||||
|
"用户用中文就用中文,使用 English 就用 English,使用日语、韩语、法语、西班牙语或其他语言时,"
|
||||||
|
"也必须使用对应语言。消息混用多种语言时跟随占主导的语言;用户明确指定回答语言时服从其指定。"
|
||||||
|
"历史消息、本人资料、标准答案和知识库使用的语言都不能覆盖当前用户消息的语言。"
|
||||||
|
"专有名词、品牌、地址、代码和必要缩写可保留原文。不要解释语言识别或翻译过程。"
|
||||||
|
"改变回答语言只改变表达语言,绝不能因此增加资料中没有的场景、身份、经历或事实。"
|
||||||
|
)
|
||||||
messages = [{"role": "system", "content": system}]
|
messages = [{"role": "system", "content": system}]
|
||||||
for item in history[-MAX_HISTORY_MESSAGES:]:
|
for item in history[-MAX_HISTORY_MESSAGES:]:
|
||||||
messages.append({"role": item.role, "content": item.content} if hasattr(item, "role") else item)
|
messages.append({"role": item.role, "content": item.content} if hasattr(item, "role") else item)
|
||||||
@@ -384,14 +443,30 @@ def _resolve_reply(
|
|||||||
if qa_pairs is None:
|
if qa_pairs is None:
|
||||||
qa_pairs = db.query(QAPair).filter(QAPair.avatar_id == avatar.id).all()
|
qa_pairs = db.query(QAPair).filter(QAPair.avatar_id == avatar.id).all()
|
||||||
matched = _match_standard_qa(question, qa_pairs)
|
matched = _match_standard_qa(question, qa_pairs)
|
||||||
if matched:
|
adapt_qa_language = bool(
|
||||||
|
matched and _qa_requires_language_adaptation(question, matched.answer)
|
||||||
|
)
|
||||||
|
if matched and not adapt_qa_language:
|
||||||
return {"answer": matched.answer, "source": "qa", "references": []}
|
return {"answer": matched.answer, "source": "qa", "references": []}
|
||||||
|
|
||||||
|
if matched:
|
||||||
|
hits = []
|
||||||
|
messages = _build_prompt(
|
||||||
|
avatar,
|
||||||
|
history,
|
||||||
|
question,
|
||||||
|
hits,
|
||||||
|
standard_answer=matched.answer,
|
||||||
|
)
|
||||||
|
else:
|
||||||
search_fn = search_fn or (lambda query, avatar_id: _search_knowledge(db, avatar_id, query))
|
search_fn = search_fn or (lambda query, avatar_id: _search_knowledge(db, avatar_id, query))
|
||||||
hits = search_fn(question, avatar.id)
|
hits = search_fn(question, avatar.id)
|
||||||
messages = _build_prompt(avatar, history, question, hits)
|
messages = _build_prompt(avatar, history, question, hits)
|
||||||
config = _config(avatar)
|
config = _config(avatar)
|
||||||
temperature = min(0.45 if hits else 0.25, 0.2 + config["creativity"] / 100 * 0.6)
|
temperature = 0.0 if matched else min(
|
||||||
|
0.45 if hits else 0.25,
|
||||||
|
0.2 + config["creativity"] / 100 * 0.6,
|
||||||
|
)
|
||||||
token_usage = None
|
token_usage = None
|
||||||
if model_client is not None:
|
if model_client is not None:
|
||||||
answer = model_client(messages=messages, temperature=temperature)
|
answer = model_client(messages=messages, temperature=temperature)
|
||||||
@@ -423,7 +498,7 @@ def _resolve_reply(
|
|||||||
raise
|
raise
|
||||||
result = {
|
result = {
|
||||||
"answer": answer,
|
"answer": answer,
|
||||||
"source": "knowledge" if hits else "qwen",
|
"source": "qa" if matched else ("knowledge" if hits else "qwen"),
|
||||||
"references": hits,
|
"references": hits,
|
||||||
}
|
}
|
||||||
if token_usage:
|
if token_usage:
|
||||||
@@ -442,14 +517,32 @@ def _stream_reply(
|
|||||||
):
|
):
|
||||||
qa_pairs = db.query(QAPair).filter(QAPair.avatar_id == avatar.id).all()
|
qa_pairs = db.query(QAPair).filter(QAPair.avatar_id == avatar.id).all()
|
||||||
matched = _match_standard_qa(question, qa_pairs)
|
matched = _match_standard_qa(question, qa_pairs)
|
||||||
if matched:
|
adapt_qa_language = bool(
|
||||||
|
matched and _qa_requires_language_adaptation(question, matched.answer)
|
||||||
|
)
|
||||||
|
messages, reservation = [], None
|
||||||
|
if matched and not adapt_qa_language:
|
||||||
source, references, chunks = "qa", [], _iter_text_chunks(matched.answer)
|
source, references, chunks = "qa", [], _iter_text_chunks(matched.answer)
|
||||||
|
else:
|
||||||
|
if matched:
|
||||||
|
references = []
|
||||||
|
source = "qa"
|
||||||
|
messages = _build_prompt(
|
||||||
|
avatar,
|
||||||
|
history,
|
||||||
|
question,
|
||||||
|
references,
|
||||||
|
standard_answer=matched.answer,
|
||||||
|
)
|
||||||
else:
|
else:
|
||||||
references = _search_knowledge(db, avatar.id, question)
|
references = _search_knowledge(db, avatar.id, question)
|
||||||
source = "knowledge" if references else "qwen"
|
source = "knowledge" if references else "qwen"
|
||||||
config = _config(avatar)
|
|
||||||
temperature = min(0.45 if references else 0.25, 0.2 + config["creativity"] / 100 * 0.6)
|
|
||||||
messages = _build_prompt(avatar, history, question, references)
|
messages = _build_prompt(avatar, history, question, references)
|
||||||
|
config = _config(avatar)
|
||||||
|
temperature = 0.0 if matched else min(
|
||||||
|
0.45 if references else 0.25,
|
||||||
|
0.2 + config["creativity"] / 100 * 0.6,
|
||||||
|
)
|
||||||
model_config = get_chat_model_config()
|
model_config = get_chat_model_config()
|
||||||
reservation = reserve_avatar_tokens(
|
reservation = reserve_avatar_tokens(
|
||||||
db,
|
db,
|
||||||
@@ -460,8 +553,6 @@ def _stream_reply(
|
|||||||
model_config.max_tokens,
|
model_config.max_tokens,
|
||||||
)
|
)
|
||||||
chunks = _iter_qwen_stream(messages, temperature, model_config)
|
chunks = _iter_qwen_stream(messages, temperature, model_config)
|
||||||
if matched:
|
|
||||||
messages, reservation = [], None
|
|
||||||
if public:
|
if public:
|
||||||
source, references = "public", []
|
source, references = "public", []
|
||||||
|
|
||||||
|
|||||||
@@ -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:
|
||||||
|
raise ValueError("文档没有可建立索引的文字内容")
|
||||||
vectors = embeddings.embed(chunks)
|
vectors = embeddings.embed(chunks)
|
||||||
for i, (c, v) in enumerate(zip(chunks, vectors)):
|
if len(vectors) != len(chunks):
|
||||||
db.add(
|
raise ValueError("向量服务返回数量与文档分段不一致")
|
||||||
KnowledgeChunk(
|
|
||||||
doc_id=doc.id,
|
|
||||||
avatar_id=avatar_id,
|
|
||||||
content=c,
|
|
||||||
vector=json.dumps(v),
|
|
||||||
chunk_index=i,
|
|
||||||
embedding_model=embeddings.MODEL,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
doc.vectorized = True
|
doc.vectorized = True
|
||||||
doc.embedding_model = embeddings.MODEL
|
doc.embedding_model = embeddings.MODEL
|
||||||
doc.chunk_count = len(chunks)
|
doc.chunk_count = len(chunks)
|
||||||
doc.vectorized_at = datetime.now(timezone.utc)
|
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))
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -5,7 +5,15 @@ from unittest.mock import Mock
|
|||||||
from fastapi import HTTPException
|
from fastapi import HTTPException
|
||||||
|
|
||||||
from models import Avatar, User
|
from models import Avatar, User
|
||||||
from routers.chat import _build_prompt, _iter_text_chunks, _match_standard_qa, _public_avatar_payload, _require_owned_avatar, _resolve_reply
|
from routers.chat import (
|
||||||
|
_build_prompt,
|
||||||
|
_iter_text_chunks,
|
||||||
|
_match_standard_qa,
|
||||||
|
_public_avatar_payload,
|
||||||
|
_qa_requires_language_adaptation,
|
||||||
|
_require_owned_avatar,
|
||||||
|
_resolve_reply,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class ChatOrchestrationTests(unittest.TestCase):
|
class ChatOrchestrationTests(unittest.TestCase):
|
||||||
@@ -50,6 +58,34 @@ class ChatOrchestrationTests(unittest.TestCase):
|
|||||||
self.assertEqual(result["answer"], "标准地址")
|
self.assertEqual(result["answer"], "标准地址")
|
||||||
fake_model.assert_not_called()
|
fake_model.assert_not_called()
|
||||||
|
|
||||||
|
def test_cross_language_qa_is_faithfully_adapted_by_model(self):
|
||||||
|
fake_model = Mock(return_value="Our address is Test Road 1.")
|
||||||
|
fake_search = Mock(return_value=[])
|
||||||
|
result = _resolve_reply(
|
||||||
|
None,
|
||||||
|
self.avatar,
|
||||||
|
"Where is your office?",
|
||||||
|
[],
|
||||||
|
qa_pairs=[SimpleNamespace(question="Where is your office?", answer="地址是测试路1号。", enabled=True)],
|
||||||
|
search_fn=fake_search,
|
||||||
|
model_client=fake_model,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(result["source"], "qa")
|
||||||
|
self.assertEqual(result["answer"], "Our address is Test Road 1.")
|
||||||
|
self.assertEqual(fake_model.call_args.kwargs["temperature"], 0.0)
|
||||||
|
system = fake_model.call_args.kwargs["messages"][0]["content"]
|
||||||
|
self.assertIn("已确认标准答案", system)
|
||||||
|
self.assertIn("地址是测试路1号", system)
|
||||||
|
self.assertIn("只使用该语言回答", system)
|
||||||
|
fake_search.assert_not_called()
|
||||||
|
|
||||||
|
def test_qa_language_adaptation_detects_common_writing_system_changes(self):
|
||||||
|
self.assertTrue(_qa_requires_language_adaptation("Hello", "你好"))
|
||||||
|
self.assertTrue(_qa_requires_language_adaptation("こんにちは", "你好"))
|
||||||
|
self.assertTrue(_qa_requires_language_adaptation("안녕하세요", "你好"))
|
||||||
|
self.assertFalse(_qa_requires_language_adaptation("你好", "您好"))
|
||||||
|
|
||||||
def test_conversational_paraphrase_matches_standard_qa(self):
|
def test_conversational_paraphrase_matches_standard_qa(self):
|
||||||
for question in ("请问一下,你们公司在哪里呀?", "请问去你们那边怎么走"):
|
for question in ("请问一下,你们公司在哪里呀?", "请问去你们那边怎么走"):
|
||||||
with self.subTest(question=question):
|
with self.subTest(question=question):
|
||||||
@@ -106,6 +142,9 @@ class ChatOrchestrationTests(unittest.TestCase):
|
|||||||
self.assertIn("像熟人之间微信聊天一样", messages[0]["content"])
|
self.assertIn("像熟人之间微信聊天一样", messages[0]["content"])
|
||||||
self.assertIn("不隶属于任何机构", messages[0]["content"])
|
self.assertIn("不隶属于任何机构", messages[0]["content"])
|
||||||
self.assertIn("不要连续输出空行", messages[0]["content"])
|
self.assertIn("不要连续输出空行", messages[0]["content"])
|
||||||
|
self.assertIn("回答语言规则", messages[0]["content"])
|
||||||
|
self.assertIn("当前最后一条用户消息", messages[0]["content"])
|
||||||
|
self.assertIn("历史消息", messages[0]["content"])
|
||||||
|
|
||||||
def test_prompt_blocks_ungrounded_factual_answers(self):
|
def test_prompt_blocks_ungrounded_factual_answers(self):
|
||||||
messages = _build_prompt(self.avatar, [], "聊聊国际新闻", [])
|
messages = _build_prompt(self.avatar, [], "聊聊国际新闻", [])
|
||||||
@@ -113,6 +152,8 @@ class ChatOrchestrationTests(unittest.TestCase):
|
|||||||
self.assertIn("没有检索到可靠资料", system)
|
self.assertIn("没有检索到可靠资料", system)
|
||||||
self.assertIn("不要凭通用知识", system)
|
self.assertIn("不要凭通用知识", system)
|
||||||
self.assertIn("不要提及知识库", system)
|
self.assertIn("不要提及知识库", system)
|
||||||
|
self.assertIn("不得推断服务对象", system)
|
||||||
|
self.assertIn("工作场所", system)
|
||||||
|
|
||||||
def test_public_avatar_payload_excludes_internal_configuration(self):
|
def test_public_avatar_payload_excludes_internal_configuration(self):
|
||||||
payload = _public_avatar_payload(self.avatar)
|
payload = _public_avatar_payload(self.avatar)
|
||||||
|
|||||||
@@ -0,0 +1,50 @@
|
|||||||
|
from unittest.mock import Mock, patch
|
||||||
|
|
||||||
|
from services.huihui_payment import HuihuiPaymentClient
|
||||||
|
|
||||||
|
|
||||||
|
def test_create_payment_uses_huihui_payment_v3_contract():
|
||||||
|
client = HuihuiPaymentClient({
|
||||||
|
"HUIHUI_PAYMENT_BASE_URL": "https://open.example/api/payment-v3",
|
||||||
|
"HUIHUI_APP_ID": "app-id",
|
||||||
|
"HUIHUI_ACCESS_ID": "access-id",
|
||||||
|
"HUIHUI_ACCESS_SECRET": "access-secret",
|
||||||
|
})
|
||||||
|
response = Mock()
|
||||||
|
response.status_code = 200
|
||||||
|
response.json.return_value = {
|
||||||
|
"code": 0,
|
||||||
|
"data": {"orderId": "provider-id", "status": "pending", "payMessage": "mock"},
|
||||||
|
}
|
||||||
|
|
||||||
|
with patch("services.huihui_payment.httpx.post", return_value=response) as post:
|
||||||
|
result = client.create_payment(
|
||||||
|
huihui_token="user-token",
|
||||||
|
huihui_user_id="user-id",
|
||||||
|
real_name="测试用户",
|
||||||
|
order_no="AV202608260001",
|
||||||
|
amount="10.00",
|
||||||
|
points_amount=2_000_000,
|
||||||
|
pay_type="WECHAT",
|
||||||
|
pay_way="APP",
|
||||||
|
callback_url="https://digital.example/api/token/payment/callback/secret",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result["orderId"] == "provider-id"
|
||||||
|
assert post.call_args.args[0] == "https://open.example/api/payment-v3/payment/pay"
|
||||||
|
assert post.call_args.kwargs["headers"] == {
|
||||||
|
"Authorization": "Bearer user-token",
|
||||||
|
"appId": "app-id",
|
||||||
|
"windowAppId": "app-id",
|
||||||
|
}
|
||||||
|
params = post.call_args.kwargs["params"]
|
||||||
|
assert params["appId"] == "app-id"
|
||||||
|
assert params["accessId"] == "access-id"
|
||||||
|
assert params["userId"] == "user-id"
|
||||||
|
assert params["signature"]
|
||||||
|
assert "accessSecret" not in params
|
||||||
|
body = post.call_args.kwargs["json"]
|
||||||
|
assert body["payType"] == "WECHAT"
|
||||||
|
assert body["payWay"] == "APP"
|
||||||
|
assert body["masterOrderAmt"] == "10.00"
|
||||||
|
assert body["payAmt"] == 10.0
|
||||||
@@ -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,7 +128,6 @@ 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,6 +137,7 @@ 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)
|
||||||
|
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 == [{"peerId": "200", "content": "你好\n很高兴见到你", "localId": boxim.sent[0]["localId"]}]
|
assert boxim.sent == [{"peerId": "200", "content": "你好\n很高兴见到你", "localId": boxim.sent[0]["localId"]}]
|
||||||
|
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 |
@@ -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',
|
||||||
|
|||||||
@@ -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'
|
||||||
|
}
|
||||||
@@ -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,15 +80,19 @@ 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) => {
|
||||||
|
for (const listener of nativeMessageHandlers) {
|
||||||
try {
|
try {
|
||||||
handler(message)
|
listener(message)
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('[uniBridge] handler error', e)
|
console.error('[uniBridge] handler error', e)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
return () => nativeMessageHandlers.delete(handler)
|
||||||
|
}
|
||||||
|
|
||||||
// 便捷事件
|
// 便捷事件
|
||||||
export const UniEvents = {
|
export const UniEvents = {
|
||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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,9 +35,14 @@
|
|||||||
<!-- 数字分身列表(只放分身相关) -->
|
<!-- 数字分身列表(只放分身相关) -->
|
||||||
<section class="avatar-list-section">
|
<section class="avatar-list-section">
|
||||||
<div class="section-head">
|
<div class="section-head">
|
||||||
|
<div class="section-heading-copy">
|
||||||
<h3 class="section-title">我的数字分身</h3>
|
<h3 class="section-title">我的数字分身</h3>
|
||||||
<span class="count-badge">{{ avatars.length }}</span>
|
<span class="count-badge">{{ avatars.length }}</span>
|
||||||
</div>
|
</div>
|
||||||
|
<button class="section-create-btn" type="button" @click="goCreate">
|
||||||
|
<span aria-hidden="true">+</span> 添加分身
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div v-if="avatars.length" class="avatar-list">
|
<div v-if="avatars.length" class="avatar-list">
|
||||||
<div class="avatar-card" v-for="a in avatars" :key="a.id">
|
<div class="avatar-card" v-for="a in avatars" :key="a.id">
|
||||||
@@ -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; }
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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 布局,已验证可编译)
|
||||||
|
|||||||
Reference in New Issue
Block a user