Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2f287ef538 | ||
|
|
f5cbbe9eef | ||
|
|
5fc56143ee | ||
|
|
d521585bf2 | ||
|
|
35e47bf0a1 | ||
|
|
f52e42d9c0 | ||
|
|
1ab56ad0b1 | ||
|
|
1889c8ebba | ||
|
|
910a05107a | ||
|
|
857d6f2562 | ||
|
|
5ce12771b9 | ||
|
|
4c152230aa | ||
|
|
9ce46cd883 | ||
|
|
140ac20281 | ||
|
|
9afc2d5a6c | ||
|
|
8585d101d5 | ||
|
|
62eb9578fd | ||
|
|
848657219e | ||
|
|
1bcdcead8d | ||
|
|
434caac056 | ||
|
|
bb9e9da1f3 | ||
|
|
28fcd5373b | ||
|
|
2a01a9946a | ||
|
|
2ce1079bb6 | ||
|
|
6fba6dbaaa | ||
|
|
e71267cf86 | ||
|
|
359e558dbe | ||
|
|
3edf92c7cc | ||
|
|
97c4c73b58 | ||
|
|
08c58fe0e6 | ||
|
|
6b7201e890 | ||
|
|
28553aba15 | ||
|
|
95f91450d0 | ||
|
|
b98a2b9507 | ||
|
|
59350fb41d | ||
|
|
6a4b35c49a | ||
|
|
207bbd02cf | ||
|
|
7cac96356d | ||
|
|
03c32309a8 | ||
|
|
0fc43908ae | ||
|
|
3d999f9472 | ||
|
|
540edb58c4 | ||
|
|
a7eb6ac2a5 | ||
|
|
016bc22c05 | ||
|
|
094f8cd40f | ||
|
|
6794e88d53 | ||
|
|
7884430b3d | ||
|
|
46d42b7d98 | ||
|
|
ef58c5f2d2 | ||
|
|
6e3fe5a616 | ||
|
|
67b6bd1b48 | ||
|
|
0752001d85 | ||
|
|
c37294be17 | ||
|
|
5360cac8ad | ||
|
|
ea932f27fb | ||
|
|
bf0183bef4 | ||
|
|
0c6419f37e | ||
|
|
f768e7648f | ||
|
|
e30ab2b889 | ||
|
|
730f586784 | ||
|
|
ef2b1c6dd6 | ||
|
|
c693899b12 | ||
|
|
d274ccb5e2 | ||
|
|
5d19992f00 | ||
|
|
81aec1c63a | ||
|
|
4029c31ed7 | ||
|
|
e24e89d326 | ||
|
|
dc34a03357 | ||
|
|
3f7ff9329a | ||
|
|
699bbbde57 | ||
|
|
7a0199e685 | ||
|
|
9e86cc64ac |
@@ -9,6 +9,8 @@ backend/logs/
|
||||
# Node
|
||||
frontend/node_modules/
|
||||
frontend/dist/
|
||||
uniapp-avatar/node_modules/
|
||||
uniapp-avatar/dist/
|
||||
|
||||
# macOS
|
||||
.DS_Store
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"""API路由汇总"""
|
||||
from fastapi import APIRouter
|
||||
from app.api.endpoints import users, interactions, ai_models, dashboard, system, logs, avatars
|
||||
from app.api.endpoints import users, interactions, ai_models, dashboard, system, logs, avatars, finance
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@@ -11,3 +11,4 @@ router.include_router(dashboard.router, prefix="/dashboard", tags=["数据看板
|
||||
router.include_router(system.router, prefix="/system", tags=["系统设置"])
|
||||
router.include_router(logs.router, prefix="/logs", tags=["日志管理"])
|
||||
router.include_router(avatars.router, prefix="/avatars", tags=["数字分身管理"])
|
||||
router.include_router(finance.router, prefix="/finance", tags=["财务管理"])
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
"""AI模型配置接口"""
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
import secrets
|
||||
|
||||
from fastapi import APIRouter, Depends, Header, HTTPException
|
||||
from sqlalchemy import select, update
|
||||
|
||||
from app.core.database import get_db
|
||||
from app.core.config import settings
|
||||
from app.schemas import ApiResponse, AIModelCreateRequest, AIModelUpdateRequest, AIModelTestRequest
|
||||
from app.models import AIModelConfig
|
||||
from app.utils.crypto import encrypt, decrypt
|
||||
@@ -22,13 +25,20 @@ async def list_models(db=Depends(get_db)):
|
||||
@router.post("")
|
||||
async def create_model(req: AIModelCreateRequest, db=Depends(get_db)):
|
||||
if req.is_default:
|
||||
await db.execute(update(AIModelConfig).values(is_default=0))
|
||||
await db.execute(
|
||||
update(AIModelConfig)
|
||||
.where(AIModelConfig.usage_scope == req.usage_scope)
|
||||
.values(is_default=0)
|
||||
)
|
||||
model = AIModelConfig(
|
||||
model_name=req.model_name,
|
||||
provider=req.provider,
|
||||
usage_scope=req.usage_scope,
|
||||
api_base_url=req.api_base_url,
|
||||
api_key_enc=encrypt(req.api_key) if req.api_key else None,
|
||||
model_version=req.model_version,
|
||||
vision_model_version=req.vision_model_version,
|
||||
ocr_model_version=req.ocr_model_version,
|
||||
temperature=req.temperature,
|
||||
max_tokens=req.max_tokens,
|
||||
timeout_seconds=req.timeout_seconds,
|
||||
@@ -47,8 +57,16 @@ async def update_model(model_id: int, req: AIModelUpdateRequest, db=Depends(get_
|
||||
model = result.scalar_one_or_none()
|
||||
if not model:
|
||||
raise HTTPException(status_code=404, detail="模型不存在")
|
||||
if req.is_default:
|
||||
await db.execute(update(AIModelConfig).where(AIModelConfig.id != model_id).values(is_default=0))
|
||||
target_scope = req.usage_scope or model.usage_scope
|
||||
if req.is_default or (req.usage_scope and model.is_default):
|
||||
await db.execute(
|
||||
update(AIModelConfig)
|
||||
.where(
|
||||
AIModelConfig.id != model_id,
|
||||
AIModelConfig.usage_scope == target_scope,
|
||||
)
|
||||
.values(is_default=0)
|
||||
)
|
||||
for field, val in req.model_dump(exclude_none=True).items():
|
||||
if field == "api_key":
|
||||
model.api_key_enc = encrypt(val) if val else None
|
||||
@@ -59,6 +77,39 @@ async def update_model(model_id: int, req: AIModelUpdateRequest, db=Depends(get_
|
||||
return ApiResponse(data=_format_model(model), message="更新成功")
|
||||
|
||||
|
||||
@router.get("/runtime/digital-avatar")
|
||||
async def get_digital_avatar_runtime_model(
|
||||
x_avatar_config_token: str | None = Header(default=None),
|
||||
db=Depends(get_db),
|
||||
):
|
||||
expected = settings.AVATAR_MODEL_CONFIG_TOKEN
|
||||
if not expected:
|
||||
raise HTTPException(status_code=503, detail="数字分身模型配置服务未启用")
|
||||
if not x_avatar_config_token or not secrets.compare_digest(x_avatar_config_token, expected):
|
||||
raise HTTPException(status_code=401, detail="无权读取数字分身模型配置")
|
||||
|
||||
result = await db.execute(
|
||||
select(AIModelConfig).where(
|
||||
AIModelConfig.usage_scope == "digital_avatar",
|
||||
AIModelConfig.is_default == 1,
|
||||
AIModelConfig.is_enabled == 1,
|
||||
)
|
||||
)
|
||||
model = result.scalar_one_or_none()
|
||||
if not model:
|
||||
raise HTTPException(status_code=404, detail="尚未配置启用的数字分身专用模型")
|
||||
return ApiResponse(data={
|
||||
"api_base_url": model.api_base_url or "https://api.openai.com/v1",
|
||||
"api_key": decrypt(model.api_key_enc) if model.api_key_enc else "",
|
||||
"model": model.model_version or model.model_name,
|
||||
"vision_model": model.vision_model_version or "qwen3.6-flash",
|
||||
"ocr_model": model.ocr_model_version or "qwen-vl-ocr",
|
||||
"temperature": model.temperature,
|
||||
"max_tokens": model.max_tokens,
|
||||
"timeout_seconds": model.timeout_seconds,
|
||||
})
|
||||
|
||||
|
||||
@router.delete("/{model_id}")
|
||||
async def delete_model(model_id: int, db=Depends(get_db)):
|
||||
result = await db.execute(select(AIModelConfig).where(AIModelConfig.id == model_id))
|
||||
@@ -79,8 +130,11 @@ async def test_model(req: AIModelTestRequest, db=Depends(get_db)):
|
||||
def _format_model(m: AIModelConfig) -> dict:
|
||||
return {
|
||||
"id": m.id, "model_name": m.model_name, "provider": m.provider,
|
||||
"usage_scope": m.usage_scope,
|
||||
"api_base_url": m.api_base_url, "has_api_key": bool(m.api_key_enc),
|
||||
"model_version": m.model_version, "temperature": m.temperature,
|
||||
"vision_model_version": m.vision_model_version,
|
||||
"ocr_model_version": m.ocr_model_version,
|
||||
"max_tokens": m.max_tokens, "timeout_seconds": m.timeout_seconds,
|
||||
"is_default": m.is_default, "is_enabled": m.is_enabled,
|
||||
"created_at": m.created_at.isoformat(),
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
"""Admin finance API for avatar Token orders, refunds and invoices."""
|
||||
|
||||
from fastapi import APIRouter, Body, HTTPException, Query
|
||||
|
||||
from app.schemas import ApiResponse
|
||||
from app.services.avatar_service import get_session, is_available
|
||||
from app.services.finance_service import FinanceServiceError, finance_service
|
||||
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def _session():
|
||||
if not is_available():
|
||||
raise HTTPException(status_code=503, detail="数字分身数据库尚未初始化")
|
||||
return get_session()
|
||||
|
||||
|
||||
def _raise(exc: FinanceServiceError):
|
||||
raise HTTPException(status_code=exc.status_code, detail=str(exc))
|
||||
|
||||
|
||||
@router.get("/summary")
|
||||
def summary():
|
||||
db = _session()
|
||||
try:
|
||||
return ApiResponse(data=finance_service.summary(db))
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
@router.get("/orders")
|
||||
def orders(
|
||||
page: int = Query(1, ge=1),
|
||||
page_size: int = Query(20, ge=1, le=100),
|
||||
keyword: str = Query(""),
|
||||
status: str = Query(""),
|
||||
provider: str = Query(""),
|
||||
):
|
||||
db = _session()
|
||||
try:
|
||||
total, items = finance_service.list_orders(
|
||||
db, page=page, page_size=page_size, keyword=keyword.strip(), status=status, provider=provider
|
||||
)
|
||||
return ApiResponse(data={"total": total, "page": page, "page_size": page_size, "items": items})
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
@router.patch("/orders/{order_no}/status")
|
||||
def update_order_status(order_no: str, body: dict = Body(...)):
|
||||
db = _session()
|
||||
try:
|
||||
finance_service.close_order(
|
||||
db, order_no, status=str(body.get("status") or ""), reason=str(body.get("reason") or "")
|
||||
)
|
||||
return ApiResponse(message="订单状态已更新")
|
||||
except FinanceServiceError as exc:
|
||||
_raise(exc)
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
@router.post("/orders/{order_no}/refund")
|
||||
def request_refund(order_no: str, body: dict = Body(...)):
|
||||
try:
|
||||
data = finance_service.request_refund(
|
||||
order_no,
|
||||
reason=str(body.get("reason") or "").strip(),
|
||||
operator=str(body.get("operator") or "后台管理员").strip(),
|
||||
)
|
||||
return ApiResponse(data=data, message="退款申请已提交")
|
||||
except FinanceServiceError as exc:
|
||||
_raise(exc)
|
||||
|
||||
|
||||
@router.get("/refunds")
|
||||
def refunds(
|
||||
page: int = Query(1, ge=1),
|
||||
page_size: int = Query(20, ge=1, le=100),
|
||||
status: str = Query(""),
|
||||
):
|
||||
db = _session()
|
||||
try:
|
||||
total, items = finance_service.list_refunds(db, page=page, page_size=page_size, status=status)
|
||||
return ApiResponse(data={"total": total, "page": page, "page_size": page_size, "items": items})
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
@router.post("/refunds/{refund_no}/confirm")
|
||||
def confirm_refund(refund_no: str, body: dict = Body(...)):
|
||||
try:
|
||||
data = finance_service.confirm_refund(refund_no, body)
|
||||
return ApiResponse(data=data, message="退款结果已登记")
|
||||
except FinanceServiceError as exc:
|
||||
_raise(exc)
|
||||
|
||||
|
||||
@router.get("/invoices")
|
||||
def invoices(
|
||||
page: int = Query(1, ge=1),
|
||||
page_size: int = Query(20, ge=1, le=100),
|
||||
status: str = Query(""),
|
||||
):
|
||||
db = _session()
|
||||
try:
|
||||
total, items = finance_service.list_invoices(db, page=page, page_size=page_size, status=status)
|
||||
return ApiResponse(data={"total": total, "page": page, "page_size": page_size, "items": items})
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
@router.patch("/invoices/{invoice_id}")
|
||||
def update_invoice(invoice_id: str, body: dict = Body(...)):
|
||||
db = _session()
|
||||
try:
|
||||
finance_service.update_invoice(
|
||||
db,
|
||||
invoice_id,
|
||||
status=str(body.get("status") or ""),
|
||||
invoice_no=str(body.get("invoiceNo") or ""),
|
||||
invoice_url=str(body.get("invoiceUrl") or ""),
|
||||
remark=str(body.get("remark") or ""),
|
||||
)
|
||||
return ApiResponse(message="发票申请已处理")
|
||||
except FinanceServiceError as exc:
|
||||
_raise(exc)
|
||||
finally:
|
||||
db.close()
|
||||
@@ -19,6 +19,7 @@ class Settings(BaseSettings):
|
||||
# 安全
|
||||
SECRET_KEY: str = os.getenv("SECRET_KEY", "dev-secret-key-change-in-prod")
|
||||
AES_KEY: str = os.getenv("AES_KEY", "your-aes-key-32-chars-change-now!")
|
||||
AVATAR_MODEL_CONFIG_TOKEN: str = os.getenv("AVATAR_MODEL_CONFIG_TOKEN", "")
|
||||
|
||||
# 新闻平台
|
||||
NEWS_PLATFORM_BASE_URL: str = os.getenv(
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"""数据库连接管理"""
|
||||
import asyncio
|
||||
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession, async_sessionmaker
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.orm import DeclarativeBase
|
||||
from app.core.config import settings
|
||||
from app.core.logger import logger
|
||||
@@ -64,6 +65,37 @@ async def init_db():
|
||||
VirtualUser, UserPersonality, InteractionRecord,
|
||||
PendingReplyTask, TokenStat, AIModelConfig, SystemConfig, LoginLog
|
||||
)
|
||||
async with engine.begin() as conn:
|
||||
await conn.execute(text("SELECT GET_LOCK('ai_model_config_migration', 30)"))
|
||||
try:
|
||||
columns = (
|
||||
(
|
||||
"usage_scope",
|
||||
"ALTER TABLE ai_model_configs ADD COLUMN usage_scope "
|
||||
"VARCHAR(16) NOT NULL DEFAULT 'general' AFTER provider",
|
||||
),
|
||||
(
|
||||
"vision_model_version",
|
||||
"ALTER TABLE ai_model_configs ADD COLUMN vision_model_version "
|
||||
"VARCHAR(64) NULL AFTER model_version",
|
||||
),
|
||||
(
|
||||
"ocr_model_version",
|
||||
"ALTER TABLE ai_model_configs ADD COLUMN ocr_model_version "
|
||||
"VARCHAR(64) NULL AFTER vision_model_version",
|
||||
),
|
||||
)
|
||||
for column_name, ddl in columns:
|
||||
result = await conn.execute(text(
|
||||
"SELECT COUNT(*) FROM information_schema.COLUMNS "
|
||||
"WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'ai_model_configs' "
|
||||
"AND COLUMN_NAME = :column_name"
|
||||
), {"column_name": column_name})
|
||||
if result.scalar_one() == 0:
|
||||
await conn.execute(text(ddl))
|
||||
logger.info("AI模型配置表已增加 %s 字段", column_name)
|
||||
finally:
|
||||
await conn.execute(text("SELECT RELEASE_LOCK('ai_model_config_migration')"))
|
||||
logger.info("✅ 数据库模型注册成功")
|
||||
logger.info("✅ 数据库初始化完成")
|
||||
|
||||
|
||||
@@ -122,9 +122,12 @@ class AIModelConfig(Base):
|
||||
id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True)
|
||||
model_name: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||
provider: Mapped[str] = mapped_column(String(32), nullable=False)
|
||||
usage_scope: Mapped[str] = mapped_column(String(16), nullable=False, default="general")
|
||||
api_base_url: Mapped[str | None] = mapped_column(String(256))
|
||||
api_key_enc: Mapped[str | None] = mapped_column(String(512))
|
||||
model_version: Mapped[str | None] = mapped_column(String(64))
|
||||
vision_model_version: Mapped[str | None] = mapped_column(String(64))
|
||||
ocr_model_version: Mapped[str | None] = mapped_column(String(64))
|
||||
temperature: Mapped[float] = mapped_column(Float, default=0.7)
|
||||
max_tokens: Mapped[int] = mapped_column(Integer, default=1000)
|
||||
timeout_seconds: Mapped[int] = mapped_column(Integer, default=30)
|
||||
|
||||
@@ -154,9 +154,12 @@ class InteractionResponse(BaseModel):
|
||||
class AIModelCreateRequest(BaseModel):
|
||||
model_name: str = Field(..., min_length=1, max_length=64)
|
||||
provider: str = Field(..., pattern="^(openai|zhipu|wenxin|qianwen|local)$")
|
||||
usage_scope: str = Field(default="general", pattern="^(general|digital_avatar)$")
|
||||
api_base_url: Optional[str] = None
|
||||
api_key: Optional[str] = None
|
||||
model_version: Optional[str] = None
|
||||
vision_model_version: Optional[str] = Field(None, max_length=64)
|
||||
ocr_model_version: Optional[str] = Field(None, max_length=64)
|
||||
temperature: float = Field(default=0.7, ge=0.0, le=2.0)
|
||||
max_tokens: int = Field(default=1000, ge=1, le=32000)
|
||||
timeout_seconds: int = Field(default=30, ge=5, le=300)
|
||||
@@ -165,9 +168,13 @@ class AIModelCreateRequest(BaseModel):
|
||||
|
||||
class AIModelUpdateRequest(BaseModel):
|
||||
model_name: Optional[str] = None
|
||||
provider: Optional[str] = Field(None, pattern="^(openai|zhipu|wenxin|qianwen|local)$")
|
||||
usage_scope: Optional[str] = Field(None, pattern="^(general|digital_avatar)$")
|
||||
api_base_url: Optional[str] = None
|
||||
api_key: Optional[str] = None
|
||||
model_version: Optional[str] = None
|
||||
vision_model_version: Optional[str] = Field(None, max_length=64)
|
||||
ocr_model_version: Optional[str] = Field(None, max_length=64)
|
||||
temperature: Optional[float] = Field(None, ge=0.0, le=2.0)
|
||||
max_tokens: Optional[int] = Field(None, ge=1, le=32000)
|
||||
timeout_seconds: Optional[int] = Field(None, ge=5, le=300)
|
||||
@@ -179,9 +186,12 @@ class AIModelResponse(BaseModel):
|
||||
id: int
|
||||
model_name: str
|
||||
provider: str
|
||||
usage_scope: str
|
||||
api_base_url: Optional[str]
|
||||
has_api_key: bool
|
||||
model_version: Optional[str]
|
||||
vision_model_version: Optional[str]
|
||||
ocr_model_version: Optional[str]
|
||||
temperature: float
|
||||
max_tokens: int
|
||||
timeout_seconds: int
|
||||
|
||||
@@ -28,7 +28,9 @@ class AIService:
|
||||
async def _get_default_model(self, db: AsyncSession) -> Optional[AIModelConfig]:
|
||||
result = await db.execute(
|
||||
select(AIModelConfig).where(
|
||||
AIModelConfig.is_default == 1, AIModelConfig.is_enabled == 1
|
||||
AIModelConfig.usage_scope == "general",
|
||||
AIModelConfig.is_default == 1,
|
||||
AIModelConfig.is_enabled == 1,
|
||||
)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
@@ -1,16 +1,24 @@
|
||||
"""数字分身管理服务层 — 同步连接数字分身应用的 SQLite 数据库"""
|
||||
import json
|
||||
import os
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Optional, Tuple
|
||||
|
||||
from sqlalchemy import create_engine, text
|
||||
from sqlalchemy import create_engine, select, text
|
||||
from sqlalchemy.orm import sessionmaker, Session
|
||||
|
||||
from app.core.config import settings
|
||||
from app.core.logger import logger
|
||||
from app.models import UserPersonality, VirtualUser
|
||||
|
||||
|
||||
_engine = None
|
||||
_SessionLocal: Optional[sessionmaker] = None
|
||||
|
||||
AVATAR_ACCOUNT_PREFIX = "__avatar__:"
|
||||
SQUARE_INTERACTION_PERMISSION = "interact"
|
||||
SQUARE_INTERACTION_ACTIONS = frozenset({"like", "collect", "comment", "reply"})
|
||||
|
||||
|
||||
def _get_engine_and_session():
|
||||
global _engine, _SessionLocal
|
||||
@@ -27,7 +35,7 @@ def _get_engine_and_session():
|
||||
return None, None
|
||||
_engine = create_engine(
|
||||
f"sqlite:///{db_path}",
|
||||
connect_args={"check_same_thread": False},
|
||||
connect_args={"check_same_thread": False, "timeout": 30},
|
||||
)
|
||||
_SessionLocal = sessionmaker(bind=_engine, autoflush=False, expire_on_commit=False)
|
||||
return _engine, _SessionLocal()
|
||||
@@ -66,6 +74,185 @@ def _get_global_token_balance(db: Session) -> int:
|
||||
return 0
|
||||
|
||||
|
||||
def _decode_config(value) -> dict:
|
||||
if isinstance(value, dict):
|
||||
return value
|
||||
if isinstance(value, str):
|
||||
try:
|
||||
decoded = json.loads(value)
|
||||
return decoded if isinstance(decoded, dict) else {}
|
||||
except (json.JSONDecodeError, ValueError):
|
||||
return {}
|
||||
return {}
|
||||
|
||||
|
||||
def is_delegated_avatar_user(user: VirtualUser | None) -> bool:
|
||||
return bool(user and (user.account or "").startswith(AVATAR_ACCOUNT_PREFIX))
|
||||
|
||||
|
||||
def delegated_avatar_id(user: VirtualUser | None) -> str:
|
||||
if not is_delegated_avatar_user(user):
|
||||
return ""
|
||||
return (user.account or "")[len(AVATAR_ACCOUNT_PREFIX):]
|
||||
|
||||
|
||||
def _list_square_interaction_authorizations(db: Session) -> list[dict]:
|
||||
"""读取已明确授权分身参与广场互动的身份与会会令牌。"""
|
||||
rows = db.execute(text("""
|
||||
SELECT
|
||||
a.id AS avatar_id,
|
||||
a.name AS avatar_name,
|
||||
a.display_name AS avatar_display_name,
|
||||
a.description AS avatar_description,
|
||||
a.photo_url AS avatar_photo_url,
|
||||
a.config AS avatar_config,
|
||||
u.huihui_user_id,
|
||||
u.nickname AS owner_nickname,
|
||||
u.avatar_url AS owner_avatar_url,
|
||||
u.huihui_token
|
||||
FROM avatars a
|
||||
JOIN users u ON u.huihui_user_id = a.owner_id
|
||||
WHERE a.status = 'active'
|
||||
""")).fetchall()
|
||||
|
||||
authorized = []
|
||||
for row in rows:
|
||||
config = _decode_config(row.avatar_config)
|
||||
permissions = config.get("authorizationPermissions", [])
|
||||
if not isinstance(permissions, list) or SQUARE_INTERACTION_PERMISSION not in permissions:
|
||||
continue
|
||||
platform_uid = str(row.huihui_user_id or "").strip()
|
||||
token = str(row.huihui_token or "").strip()
|
||||
if not platform_uid or not token:
|
||||
continue
|
||||
authorized.append({
|
||||
"avatar_id": str(row.avatar_id),
|
||||
"avatar_name": row.avatar_display_name or row.avatar_name or row.owner_nickname or "数字分身",
|
||||
"avatar_description": row.avatar_description or "",
|
||||
"avatar_url": _resolve_photo_url(row.avatar_photo_url or row.owner_avatar_url or ""),
|
||||
"config": config,
|
||||
"platform_uid": platform_uid,
|
||||
"token": token,
|
||||
})
|
||||
return authorized
|
||||
|
||||
|
||||
def get_square_interaction_permissions(avatar_id: str) -> frozenset[str]:
|
||||
"""实时复核授权;数据库不可用、令牌失效或撤权时一律拒绝执行。"""
|
||||
avatar_db = get_session()
|
||||
if avatar_db is None:
|
||||
return frozenset()
|
||||
try:
|
||||
authorized_ids = {
|
||||
item["avatar_id"] for item in _list_square_interaction_authorizations(avatar_db)
|
||||
}
|
||||
return SQUARE_INTERACTION_ACTIONS if avatar_id in authorized_ids else frozenset()
|
||||
except Exception as exc:
|
||||
logger.error(f"读取数字分身广场互动授权失败: {exc}")
|
||||
return frozenset()
|
||||
finally:
|
||||
avatar_db.close()
|
||||
|
||||
|
||||
def _word_count_range(config: dict) -> tuple[int, int]:
|
||||
ranges = {
|
||||
"short": (10, 35),
|
||||
"medium": (20, 60),
|
||||
"long": (30, 80),
|
||||
}
|
||||
return ranges.get(str(config.get("responseLength") or "medium"), (20, 60))
|
||||
|
||||
|
||||
async def sync_square_interaction_users(db) -> set[str]:
|
||||
"""把已授权分身同步为调度器身份,并刷新其会会会话。"""
|
||||
avatar_db = get_session()
|
||||
if avatar_db is None:
|
||||
logger.warning("数字分身数据库不可用,跳过广场互动授权同步")
|
||||
return set()
|
||||
try:
|
||||
authorized = _list_square_interaction_authorizations(avatar_db)
|
||||
except Exception as exc:
|
||||
logger.error(f"同步数字分身广场互动授权失败: {exc}")
|
||||
return set()
|
||||
finally:
|
||||
avatar_db.close()
|
||||
|
||||
from app.core.redis_client import delete_session, set_session
|
||||
|
||||
result = await db.execute(
|
||||
select(VirtualUser).where(VirtualUser.account.like(f"{AVATAR_ACCOUNT_PREFIX}%"))
|
||||
)
|
||||
existing_users = {delegated_avatar_id(user): user for user in result.scalars().all()}
|
||||
authorized_ids = {item["avatar_id"] for item in authorized}
|
||||
|
||||
for avatar_id, user in existing_users.items():
|
||||
if avatar_id not in authorized_ids:
|
||||
user.is_enabled = 0
|
||||
user.status = 0
|
||||
user.session_token = None
|
||||
user.session_expires_at = None
|
||||
await delete_session(user.id)
|
||||
|
||||
for item in authorized:
|
||||
avatar_id = item["avatar_id"]
|
||||
user = existing_users.get(avatar_id)
|
||||
if user is None:
|
||||
user = VirtualUser(
|
||||
nickname=item["avatar_name"],
|
||||
account=f"{AVATAR_ACCOUNT_PREFIX}{avatar_id}",
|
||||
password_enc="",
|
||||
status=2,
|
||||
is_enabled=1,
|
||||
platform_uid=item["platform_uid"],
|
||||
remark="用户授权的数字分身广场互动身份",
|
||||
)
|
||||
db.add(user)
|
||||
await db.flush()
|
||||
|
||||
expires_at = datetime.now() + timedelta(days=1)
|
||||
user.nickname = item["avatar_name"]
|
||||
user.real_name = item["avatar_name"]
|
||||
user.avatar_url = item["avatar_url"]
|
||||
user.platform_uid = item["platform_uid"]
|
||||
user.session_token = item["token"]
|
||||
user.session_expires_at = expires_at
|
||||
user.last_login_at = datetime.now()
|
||||
user.status = 2
|
||||
user.is_enabled = 1
|
||||
|
||||
config = item["config"]
|
||||
personality_result = await db.execute(
|
||||
select(UserPersonality).where(UserPersonality.user_id == user.id)
|
||||
)
|
||||
personality = personality_result.scalar_one_or_none()
|
||||
word_min, word_max = _word_count_range(config)
|
||||
prompt_parts = [item["avatar_description"], str(config.get("systemPrompt") or "")]
|
||||
style_prompt = "\n".join(part.strip() for part in prompt_parts if part and part.strip())
|
||||
if personality is None:
|
||||
personality = UserPersonality(user_id=user.id)
|
||||
db.add(personality)
|
||||
personality.language_style = str(config.get("replyStyle") or "professional")
|
||||
personality.personality_desc = item["avatar_description"]
|
||||
personality.comment_style_prompt = style_prompt
|
||||
personality.word_count_min = word_min
|
||||
personality.word_count_max = word_max
|
||||
|
||||
await set_session(user.id, {
|
||||
"token": item["token"],
|
||||
"session_id": f"avatar:{avatar_id}",
|
||||
"platform_uid": item["platform_uid"],
|
||||
"org_id": "",
|
||||
"login_time": datetime.now().isoformat(),
|
||||
"nickname": item["avatar_name"],
|
||||
"real_name": item["avatar_name"],
|
||||
"avatar": item["avatar_url"],
|
||||
"delegated_avatar_id": avatar_id,
|
||||
}, expire=86400)
|
||||
|
||||
await db.commit()
|
||||
return authorized_ids
|
||||
|
||||
|
||||
class AvatarService:
|
||||
|
||||
@staticmethod
|
||||
|
||||
@@ -0,0 +1,254 @@
|
||||
"""Finance operations for digital-avatar Token purchases."""
|
||||
|
||||
import os
|
||||
from datetime import datetime
|
||||
|
||||
import httpx
|
||||
from sqlalchemy import text
|
||||
|
||||
from app.core.config import settings
|
||||
|
||||
|
||||
class FinanceServiceError(RuntimeError):
|
||||
def __init__(self, message: str, status_code: int = 400):
|
||||
super().__init__(message)
|
||||
self.status_code = status_code
|
||||
|
||||
|
||||
def _mapping(row):
|
||||
return dict(row._mapping) if row is not None else None
|
||||
|
||||
|
||||
def _iso(value):
|
||||
return value.isoformat() if hasattr(value, "isoformat") else value
|
||||
|
||||
|
||||
def _money(cents):
|
||||
return round(int(cents or 0) / 100, 2)
|
||||
|
||||
|
||||
class FinanceService:
|
||||
@staticmethod
|
||||
def _tables_ready(db) -> bool:
|
||||
names = {
|
||||
row[0]
|
||||
for row in db.execute(text(
|
||||
"SELECT name FROM sqlite_master WHERE type='table' AND "
|
||||
"name IN ('token_payment_orders','payment_refunds','invoice_applications')"
|
||||
)).fetchall()
|
||||
}
|
||||
return len(names) == 3
|
||||
|
||||
@classmethod
|
||||
def summary(cls, db) -> dict:
|
||||
if not cls._tables_ready(db):
|
||||
return {
|
||||
"paid_revenue": 0,
|
||||
"paid_orders": 0,
|
||||
"pending_orders": 0,
|
||||
"processing_refunds": 0,
|
||||
"pending_invoices": 0,
|
||||
}
|
||||
row = db.execute(text("""
|
||||
SELECT
|
||||
COALESCE(SUM(CASE WHEN status='paid' THEN price_cents ELSE 0 END), 0) paid_revenue,
|
||||
SUM(CASE WHEN status='paid' THEN 1 ELSE 0 END) paid_orders,
|
||||
SUM(CASE WHEN status='pending' THEN 1 ELSE 0 END) pending_orders
|
||||
FROM token_payment_orders
|
||||
""")).fetchone()
|
||||
processing_refunds = db.execute(text(
|
||||
"SELECT COUNT(*) FROM payment_refunds WHERE status IN ('pending','processing')"
|
||||
)).scalar() or 0
|
||||
pending_invoices = db.execute(text(
|
||||
"SELECT COUNT(*) FROM invoice_applications WHERE status='pending'"
|
||||
)).scalar() or 0
|
||||
return {
|
||||
"paid_revenue": _money(row.paid_revenue),
|
||||
"paid_orders": int(row.paid_orders or 0),
|
||||
"pending_orders": int(row.pending_orders or 0),
|
||||
"processing_refunds": int(processing_refunds),
|
||||
"pending_invoices": int(pending_invoices),
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def list_orders(cls, db, *, page=1, page_size=20, keyword="", status="", provider=""):
|
||||
if not cls._tables_ready(db):
|
||||
return 0, []
|
||||
clauses = ["1=1"]
|
||||
params = {}
|
||||
if keyword:
|
||||
clauses.append("(o.order_no LIKE :keyword OR u.phone LIKE :keyword OR u.nickname LIKE :keyword)")
|
||||
params["keyword"] = f"%{keyword}%"
|
||||
if status:
|
||||
clauses.append("o.status=:status")
|
||||
params["status"] = status
|
||||
if provider:
|
||||
clauses.append("o.provider=:provider")
|
||||
params["provider"] = provider
|
||||
where = " AND ".join(clauses)
|
||||
total = db.execute(text(f"""
|
||||
SELECT COUNT(*) FROM token_payment_orders o
|
||||
LEFT JOIN users u ON u.id=o.user_id WHERE {where}
|
||||
"""), params).scalar() or 0
|
||||
params.update({"limit": page_size, "offset": (page - 1) * page_size})
|
||||
rows = db.execute(text(f"""
|
||||
SELECT o.*, u.nickname user_nickname, u.phone user_phone,
|
||||
i.status invoice_status, i.id invoice_id
|
||||
FROM token_payment_orders o
|
||||
LEFT JOIN users u ON u.id=o.user_id
|
||||
LEFT JOIN invoice_applications i ON i.order_no=o.order_no
|
||||
WHERE {where}
|
||||
ORDER BY o.created_at DESC LIMIT :limit OFFSET :offset
|
||||
"""), params).fetchall()
|
||||
items = []
|
||||
for row in rows:
|
||||
item = _mapping(row)
|
||||
item["price"] = _money(item.pop("price_cents"))
|
||||
for key in ("created_at", "updated_at", "paid_at", "refunded_at"):
|
||||
item[key] = _iso(item.get(key))
|
||||
item.pop("pay_message", None)
|
||||
items.append(item)
|
||||
return int(total), items
|
||||
|
||||
@classmethod
|
||||
def list_refunds(cls, db, *, page=1, page_size=20, status=""):
|
||||
if not cls._tables_ready(db):
|
||||
return 0, []
|
||||
where = "WHERE r.status=:status" if status else ""
|
||||
params = {"status": status} if status else {}
|
||||
total = db.execute(text(f"SELECT COUNT(*) FROM payment_refunds r {where}"), params).scalar() or 0
|
||||
params.update({"limit": page_size, "offset": (page - 1) * page_size})
|
||||
rows = db.execute(text(f"""
|
||||
SELECT r.*, o.provider, o.payment_method, u.nickname user_nickname, u.phone user_phone
|
||||
FROM payment_refunds r
|
||||
JOIN token_payment_orders o ON o.order_no=r.order_no
|
||||
LEFT JOIN users u ON u.id=o.user_id
|
||||
{where}
|
||||
ORDER BY r.created_at DESC LIMIT :limit OFFSET :offset
|
||||
"""), params).fetchall()
|
||||
items = []
|
||||
for row in rows:
|
||||
item = _mapping(row)
|
||||
item["amount"] = _money(item.pop("amount_cents"))
|
||||
for key in ("created_at", "updated_at", "completed_at"):
|
||||
item[key] = _iso(item.get(key))
|
||||
items.append(item)
|
||||
return int(total), items
|
||||
|
||||
@classmethod
|
||||
def list_invoices(cls, db, *, page=1, page_size=20, status=""):
|
||||
if not cls._tables_ready(db):
|
||||
return 0, []
|
||||
where = "WHERE i.status=:status" if status else ""
|
||||
params = {"status": status} if status else {}
|
||||
total = db.execute(text(f"SELECT COUNT(*) FROM invoice_applications i {where}"), params).scalar() or 0
|
||||
params.update({"limit": page_size, "offset": (page - 1) * page_size})
|
||||
rows = db.execute(text(f"""
|
||||
SELECT i.*, u.nickname user_nickname, u.phone user_phone
|
||||
FROM invoice_applications i
|
||||
LEFT JOIN users u ON u.id=i.user_id
|
||||
{where}
|
||||
ORDER BY i.created_at DESC LIMIT :limit OFFSET :offset
|
||||
"""), params).fetchall()
|
||||
items = []
|
||||
for row in rows:
|
||||
item = _mapping(row)
|
||||
item["amount"] = _money(item.pop("amount_cents"))
|
||||
for key in ("created_at", "updated_at", "issued_at"):
|
||||
item[key] = _iso(item.get(key))
|
||||
items.append(item)
|
||||
return int(total), items
|
||||
|
||||
@staticmethod
|
||||
def close_order(db, order_no: str, *, status: str, reason: str):
|
||||
if status not in {"closed", "failed"}:
|
||||
raise FinanceServiceError("后台只能将待支付订单关闭或标记失败")
|
||||
order = db.execute(text(
|
||||
"SELECT status FROM token_payment_orders WHERE order_no=:order_no"
|
||||
), {"order_no": order_no}).fetchone()
|
||||
if not order:
|
||||
raise FinanceServiceError("订单不存在", 404)
|
||||
if order.status != "pending":
|
||||
raise FinanceServiceError("只有待支付订单可以修改状态", 409)
|
||||
db.execute(text("""
|
||||
UPDATE token_payment_orders
|
||||
SET status=:status, failure_reason=:reason, updated_at=:updated_at
|
||||
WHERE order_no=:order_no
|
||||
"""), {
|
||||
"status": status,
|
||||
"reason": (reason or "后台关闭订单")[:500],
|
||||
"updated_at": datetime.utcnow(),
|
||||
"order_no": order_no,
|
||||
})
|
||||
db.commit()
|
||||
|
||||
@staticmethod
|
||||
def _avatar_admin_call(path: str, payload: dict):
|
||||
base_url = (settings.AVATAR_BACKEND_URL or os.getenv("AVATAR_BACKEND_URL", "")).rstrip("/")
|
||||
secret = os.getenv("AVATAR_FINANCE_ADMIN_SECRET", "").strip()
|
||||
if not base_url or len(secret) < 16:
|
||||
raise FinanceServiceError("数字分身财务服务尚未完成配置", 503)
|
||||
try:
|
||||
response = httpx.post(
|
||||
f"{base_url}/api{path}",
|
||||
json=payload,
|
||||
headers={"X-Avatar-Finance-Key": secret},
|
||||
timeout=35,
|
||||
)
|
||||
data = response.json()
|
||||
except (httpx.HTTPError, ValueError) as exc:
|
||||
raise FinanceServiceError("数字分身财务服务暂时不可用", 502) from exc
|
||||
if response.status_code >= 400 or data.get("code") not in (0, 200, "0", "200"):
|
||||
raise FinanceServiceError(data.get("message") or data.get("detail") or "财务操作失败", response.status_code)
|
||||
return data.get("data")
|
||||
|
||||
@classmethod
|
||||
def request_refund(cls, order_no: str, *, reason: str, operator: str):
|
||||
return cls._avatar_admin_call(
|
||||
f"/token/admin/orders/{order_no}/refund",
|
||||
{"reason": reason, "operator": operator},
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def confirm_refund(cls, refund_no: str, payload: dict):
|
||||
return cls._avatar_admin_call(f"/token/admin/refunds/{refund_no}/confirm", payload)
|
||||
|
||||
@staticmethod
|
||||
def update_invoice(db, invoice_id: str, *, status: str, invoice_no="", invoice_url="", remark=""):
|
||||
row = db.execute(text(
|
||||
"SELECT * FROM invoice_applications WHERE id=:invoice_id"
|
||||
), {"invoice_id": invoice_id}).fetchone()
|
||||
if not row:
|
||||
raise FinanceServiceError("发票申请不存在", 404)
|
||||
if row.status != "pending":
|
||||
raise FinanceServiceError("该发票申请已处理", 409)
|
||||
if status == "issued":
|
||||
if not invoice_no.strip():
|
||||
raise FinanceServiceError("请填写发票号码")
|
||||
if invoice_url.strip() and not invoice_url.strip().lower().startswith(("https://", "http://")):
|
||||
raise FinanceServiceError("电子发票地址必须是 HTTP 或 HTTPS 链接")
|
||||
issued_at = datetime.utcnow()
|
||||
elif status == "rejected":
|
||||
if not remark.strip():
|
||||
raise FinanceServiceError("请填写驳回原因")
|
||||
issued_at = None
|
||||
else:
|
||||
raise FinanceServiceError("发票状态只能是已开具或已驳回")
|
||||
db.execute(text("""
|
||||
UPDATE invoice_applications
|
||||
SET status=:status, invoice_no=:invoice_no, invoice_url=:invoice_url,
|
||||
remark=:remark, issued_at=:issued_at, updated_at=:updated_at
|
||||
WHERE id=:invoice_id
|
||||
"""), {
|
||||
"status": status,
|
||||
"invoice_no": invoice_no.strip()[:120],
|
||||
"invoice_url": invoice_url.strip()[:500],
|
||||
"remark": remark.strip()[:500],
|
||||
"issued_at": issued_at,
|
||||
"updated_at": datetime.utcnow(),
|
||||
"invoice_id": invoice_id,
|
||||
})
|
||||
db.commit()
|
||||
|
||||
|
||||
finance_service = FinanceService()
|
||||
@@ -23,6 +23,7 @@ class SchedulerService:
|
||||
from app.core.database import AsyncSessionLocal
|
||||
logger.info("⚡ 立即触发互动任务")
|
||||
async with AsyncSessionLocal() as session:
|
||||
await self._sync_delegated_avatar_users(session)
|
||||
try:
|
||||
max_concurrent = int(await self._get_config(session, "max_concurrent_users", "5"))
|
||||
except (TypeError, ValueError):
|
||||
@@ -146,7 +147,9 @@ class SchedulerService:
|
||||
async def _check_sessions(self):
|
||||
"""定时校验登录状态"""
|
||||
from app.services.news_service import news_service
|
||||
from app.services.avatar_service import is_delegated_avatar_user
|
||||
async with AsyncSessionLocal() as db:
|
||||
await self._sync_delegated_avatar_users(db)
|
||||
result = await db.execute(
|
||||
select(VirtualUser).where(VirtualUser.status == 2, VirtualUser.is_enabled == 1)
|
||||
)
|
||||
@@ -154,7 +157,7 @@ class SchedulerService:
|
||||
for user in users:
|
||||
try:
|
||||
valid = await news_service.check_session(db, user)
|
||||
if not valid:
|
||||
if not valid and not is_delegated_avatar_user(user):
|
||||
logger.warning(f"用户 {user.account} 会话失效,尝试重登")
|
||||
await news_service.login(db, user)
|
||||
except Exception as e:
|
||||
@@ -163,6 +166,7 @@ class SchedulerService:
|
||||
async def _run_interactions(self):
|
||||
"""执行互动任务"""
|
||||
async with AsyncSessionLocal() as db:
|
||||
await self._sync_delegated_avatar_users(db)
|
||||
# 检查调度器开关
|
||||
enabled = await self._get_config(db, "scheduler_enabled", "true")
|
||||
if enabled != "true":
|
||||
@@ -184,8 +188,11 @@ class SchedulerService:
|
||||
logger.debug(f"[调度] 当前北京时间 {now_time} 不在互动时段 {start_str}-{end_str}")
|
||||
return
|
||||
|
||||
# 获取最小互动间隔(秒)
|
||||
min_interval = int(await self._get_config(db, "interact_min_interval", "300"))
|
||||
# 获取互动间隔范围(秒),与调度设置页面字段保持一致
|
||||
min_interval = await self._get_int_config(db, "interact_interval_min", 300)
|
||||
max_interval = await self._get_int_config(db, "interact_interval_max", min_interval)
|
||||
min_interval = max(0, min_interval)
|
||||
max_interval = max(min_interval, max_interval)
|
||||
|
||||
# 获取最大并发
|
||||
max_concurrent = int(await self._get_config(db, "max_concurrent_users", "5"))
|
||||
@@ -204,7 +211,7 @@ class SchedulerService:
|
||||
await self._try_login_users(db)
|
||||
return
|
||||
|
||||
# 检查互动间隔:过滤掉最近 min_interval 秒内已互动的用户
|
||||
# 每个用户在其最小/最大间隔内取得稳定随机值,直到下次互动后再变化
|
||||
now_dt = datetime.now()
|
||||
eligible = []
|
||||
for u in all_users:
|
||||
@@ -212,11 +219,17 @@ class SchedulerService:
|
||||
eligible.append(u)
|
||||
else:
|
||||
elapsed = (now_dt - u.last_interact_at).total_seconds()
|
||||
if elapsed >= min_interval:
|
||||
interval = random.Random(
|
||||
f"{u.id}:{u.last_interact_at.isoformat()}"
|
||||
).randint(min_interval, max_interval)
|
||||
if elapsed >= interval:
|
||||
eligible.append(u)
|
||||
|
||||
if not eligible:
|
||||
logger.debug(f"[调度] 所有 {len(all_users)} 个用户在 {min_interval}s 内已互动,跳过本次")
|
||||
logger.debug(
|
||||
f"[调度] 所有 {len(all_users)} 个用户尚未达到 "
|
||||
f"{min_interval}-{max_interval}s 随机互动间隔,跳过本次"
|
||||
)
|
||||
return
|
||||
|
||||
# 按最后互动时间升序排序:最久没互动的用户优先
|
||||
@@ -257,10 +270,12 @@ class SchedulerService:
|
||||
async def _try_login_users(self, db):
|
||||
"""尝试登录未登录的用户"""
|
||||
from app.services.news_service import news_service
|
||||
from app.services.avatar_service import AVATAR_ACCOUNT_PREFIX
|
||||
result = await db.execute(
|
||||
select(VirtualUser).where(
|
||||
VirtualUser.status.in_([0, 3]),
|
||||
VirtualUser.is_enabled == 1
|
||||
VirtualUser.is_enabled == 1,
|
||||
~VirtualUser.account.like(f"{AVATAR_ACCOUNT_PREFIX}%"),
|
||||
).limit(3)
|
||||
)
|
||||
users = result.scalars().all()
|
||||
@@ -275,6 +290,11 @@ class SchedulerService:
|
||||
"""执行单用户互动 - 基于真实接口"""
|
||||
from app.services.news_service import news_service
|
||||
from app.services.ai_service import ai_service
|
||||
from app.services.avatar_service import (
|
||||
delegated_avatar_id,
|
||||
get_square_interaction_permissions,
|
||||
is_delegated_avatar_user,
|
||||
)
|
||||
|
||||
async with AsyncSessionLocal() as db:
|
||||
try:
|
||||
@@ -289,6 +309,23 @@ class SchedulerService:
|
||||
"interactions": [],
|
||||
}
|
||||
|
||||
allowed_actions = {"like", "collect", "comment", "reply", "forward"}
|
||||
if is_delegated_avatar_user(user):
|
||||
allowed_actions = set(
|
||||
get_square_interaction_permissions(delegated_avatar_id(user))
|
||||
)
|
||||
if not allowed_actions:
|
||||
user.status = 0
|
||||
user.is_enabled = 0
|
||||
await db.commit()
|
||||
return {
|
||||
"user_id": user.id,
|
||||
"account": user.account,
|
||||
"status": "skipped",
|
||||
"reason": "avatar_interaction_not_authorized",
|
||||
"interactions": [],
|
||||
}
|
||||
|
||||
# 检查今日评论限额
|
||||
can_comment = True
|
||||
if user.today_comment_count >= user.daily_comment_limit:
|
||||
@@ -398,14 +435,53 @@ class SchedulerService:
|
||||
interactions_done = []
|
||||
action_failures = []
|
||||
|
||||
# ① 先记录阅读(每次必做,模拟真实用户打开文章)
|
||||
done_on_this = today_done.get(news_id, set())
|
||||
wants = {
|
||||
"like": (
|
||||
"like" in allowed_actions
|
||||
and "like" not in done_on_this
|
||||
and random.random() < like_prob
|
||||
),
|
||||
"collect": (
|
||||
"collect" in allowed_actions
|
||||
and "collect" not in done_on_this
|
||||
and random.random() < collect_prob
|
||||
),
|
||||
"forward": (
|
||||
"forward" in allowed_actions
|
||||
and "forward" not in done_on_this
|
||||
and random.random() < forward_prob
|
||||
),
|
||||
"reply": (
|
||||
"reply" in allowed_actions
|
||||
and can_comment
|
||||
and personality is not None
|
||||
and random.random() < reply_prob
|
||||
),
|
||||
"comment": (
|
||||
"comment" in allowed_actions
|
||||
and can_comment
|
||||
and personality is not None
|
||||
and not already_commented_this
|
||||
and random.random() < comment_prob
|
||||
),
|
||||
}
|
||||
if not any(wants.values()):
|
||||
return {
|
||||
"user_id": user.id,
|
||||
"account": user.account,
|
||||
"status": "skipped",
|
||||
"reason": "no_actions_triggered",
|
||||
"interactions": [],
|
||||
"article_id": news_id,
|
||||
"article_title": news_title,
|
||||
}
|
||||
|
||||
# 只有动作命中调度概率后才打开文章
|
||||
await news_service.read_news(db, user, news_id)
|
||||
|
||||
# 今日已对此文章做过的互动类型
|
||||
done_on_this = today_done.get(news_id, set())
|
||||
|
||||
# ② 点赞(每篇文章每用户每天只点赞一次)
|
||||
if "like" not in done_on_this and random.random() < like_prob:
|
||||
if wants["like"]:
|
||||
success, err = await news_service.like_news(db, user, news_id, org_id=article_org_id, to_user_id=news_author, title=news_title)
|
||||
await self._save_record(db, user, news_id, news_title, "like", None, 0, success, err)
|
||||
if success:
|
||||
@@ -415,16 +491,17 @@ class SchedulerService:
|
||||
action_failures.append({"type": "like", "error": err})
|
||||
|
||||
# ③ 收藏(每篇文章每用户每天只收藏一次)
|
||||
if "collect" not in done_on_this and random.random() < collect_prob:
|
||||
if wants["collect"]:
|
||||
success, err = await news_service.collect_news(db, user, news_id, org_id=article_org_id, to_user_id=news_author, title=news_title)
|
||||
await self._save_record(db, user, news_id, news_title, "collect", None, 0, success, err)
|
||||
if success:
|
||||
interactions_done.append("collect")
|
||||
await self._incr_total(db, user_id)
|
||||
else:
|
||||
action_failures.append({"type": "collect", "error": err})
|
||||
|
||||
# ④ 转发(每篇文章每用户每天只转发一次)
|
||||
if "forward" not in done_on_this and random.random() < forward_prob:
|
||||
if wants["forward"]:
|
||||
success, err = await news_service.forward_news(db, user, news_id)
|
||||
await self._save_record(db, user, news_id, news_title, "forward", None, 0, success, err)
|
||||
if success:
|
||||
@@ -438,7 +515,7 @@ class SchedulerService:
|
||||
style_prompt = personality.comment_style_prompt or ""
|
||||
safe_word_max = min(personality.word_count_max, 80)
|
||||
|
||||
if random.random() < reply_prob:
|
||||
if wants["reply"]:
|
||||
reply_actions, reply_failures = await self._run_reply_interaction_chain(
|
||||
db=db,
|
||||
starter=user,
|
||||
@@ -455,7 +532,7 @@ class SchedulerService:
|
||||
action_failures.extend(reply_failures)
|
||||
|
||||
# 每篇文章每个用户每天只发一条顶层评论;回复不再要求先评论
|
||||
if not already_commented_this and random.random() < comment_prob:
|
||||
if wants["comment"]:
|
||||
comment_text, tokens = await ai_service.generate_comment(
|
||||
db, news_title, news_content,
|
||||
style_prompt, personality.word_count_min, safe_word_max
|
||||
@@ -679,6 +756,7 @@ class SchedulerService:
|
||||
|
||||
async with AsyncSessionLocal() as db:
|
||||
try:
|
||||
await self._sync_delegated_avatar_users(db)
|
||||
now = datetime.now()
|
||||
await db.execute(
|
||||
update(PendingReplyTask)
|
||||
@@ -706,6 +784,12 @@ class SchedulerService:
|
||||
logger.error(f"待发送回复队列处理异常: {e}")
|
||||
|
||||
async def _process_pending_reply_task(self, db, task: PendingReplyTask, news_service, ai_service):
|
||||
from app.services.avatar_service import (
|
||||
delegated_avatar_id,
|
||||
get_square_interaction_permissions,
|
||||
is_delegated_avatar_user,
|
||||
)
|
||||
|
||||
task.status = 1
|
||||
task.locked_at = datetime.now()
|
||||
task.attempts = (task.attempts or 0) + 1
|
||||
@@ -716,6 +800,13 @@ class SchedulerService:
|
||||
task.status = 3
|
||||
task.last_error = "用户未登录或已禁用"
|
||||
return
|
||||
if (
|
||||
is_delegated_avatar_user(actor)
|
||||
and "reply" not in get_square_interaction_permissions(delegated_avatar_id(actor))
|
||||
):
|
||||
task.status = 3
|
||||
task.last_error = "数字分身广场互动授权已撤销"
|
||||
return
|
||||
|
||||
reply_result = await self._post_contextual_reply(
|
||||
db=db,
|
||||
@@ -858,6 +949,16 @@ class SchedulerService:
|
||||
except (TypeError, ValueError):
|
||||
return default
|
||||
|
||||
async def _sync_delegated_avatar_users(self, db):
|
||||
from app.services.avatar_service import sync_square_interaction_users
|
||||
|
||||
try:
|
||||
return await sync_square_interaction_users(db)
|
||||
except Exception as exc:
|
||||
await db.rollback()
|
||||
logger.error(f"数字分身广场互动身份同步异常: {exc}")
|
||||
return set()
|
||||
|
||||
async def _incr_total(self, db, user_id: int):
|
||||
await db.execute(
|
||||
update(VirtualUser).where(VirtualUser.id == user_id).values(
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
import json
|
||||
import os
|
||||
import sqlite3
|
||||
import tempfile
|
||||
import unittest
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import patch
|
||||
|
||||
from app.services import avatar_service
|
||||
|
||||
|
||||
class AvatarSquareAuthorizationTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
fd, self.db_path = tempfile.mkstemp(suffix=".db")
|
||||
os.close(fd)
|
||||
connection = sqlite3.connect(self.db_path)
|
||||
connection.executescript("""
|
||||
CREATE TABLE users (
|
||||
huihui_user_id TEXT,
|
||||
nickname TEXT,
|
||||
avatar_url TEXT,
|
||||
huihui_token TEXT
|
||||
);
|
||||
CREATE TABLE avatars (
|
||||
id TEXT,
|
||||
owner_id TEXT,
|
||||
name TEXT,
|
||||
display_name TEXT,
|
||||
description TEXT,
|
||||
photo_url TEXT,
|
||||
config TEXT,
|
||||
status TEXT
|
||||
);
|
||||
""")
|
||||
connection.execute(
|
||||
"INSERT INTO users VALUES (?, ?, ?, ?)",
|
||||
("huihui-7", "主人", "/owner.jpg", "huihui-token"),
|
||||
)
|
||||
connection.commit()
|
||||
connection.close()
|
||||
avatar_service._engine = None
|
||||
avatar_service._SessionLocal = None
|
||||
self.path_patch = patch.object(avatar_service.settings, "AVATAR_DB_PATH", self.db_path)
|
||||
self.path_patch.start()
|
||||
|
||||
def tearDown(self):
|
||||
self.path_patch.stop()
|
||||
if avatar_service._engine is not None:
|
||||
avatar_service._engine.dispose()
|
||||
avatar_service._engine = None
|
||||
avatar_service._SessionLocal = None
|
||||
os.unlink(self.db_path)
|
||||
|
||||
def _insert_avatar(self, permissions, *, status="active", token=None):
|
||||
connection = sqlite3.connect(self.db_path)
|
||||
connection.execute(
|
||||
"INSERT INTO avatars VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
|
||||
(
|
||||
"avatar-7",
|
||||
"huihui-7",
|
||||
"avatar",
|
||||
"小会",
|
||||
"语气友好,表达简洁",
|
||||
"/avatar.jpg",
|
||||
json.dumps({
|
||||
"authorizationPermissions": permissions,
|
||||
"replyStyle": "warm",
|
||||
"responseLength": "short",
|
||||
}),
|
||||
status,
|
||||
),
|
||||
)
|
||||
if token is not None:
|
||||
connection.execute(
|
||||
"UPDATE users SET huihui_token = ? WHERE huihui_user_id = ?",
|
||||
(token, "huihui-7"),
|
||||
)
|
||||
connection.commit()
|
||||
connection.close()
|
||||
|
||||
def test_interact_permission_exposes_only_requested_square_actions(self):
|
||||
self._insert_avatar(["chat", "interact"])
|
||||
|
||||
permissions = avatar_service.get_square_interaction_permissions("avatar-7")
|
||||
|
||||
self.assertEqual(
|
||||
permissions,
|
||||
frozenset({"like", "collect", "comment", "reply"}),
|
||||
)
|
||||
self.assertNotIn("forward", permissions)
|
||||
|
||||
def test_missing_permission_inactive_avatar_or_missing_token_denies_execution(self):
|
||||
scenarios = [
|
||||
(["chat"], "active", "huihui-token"),
|
||||
(["interact"], "inactive", "huihui-token"),
|
||||
(["interact"], "active", ""),
|
||||
]
|
||||
for permissions, status, token in scenarios:
|
||||
with self.subTest(permissions=permissions, status=status, token=token):
|
||||
connection = sqlite3.connect(self.db_path)
|
||||
connection.execute("DELETE FROM avatars")
|
||||
connection.commit()
|
||||
connection.close()
|
||||
self._insert_avatar(permissions, status=status, token=token)
|
||||
self.assertEqual(
|
||||
avatar_service.get_square_interaction_permissions("avatar-7"),
|
||||
frozenset(),
|
||||
)
|
||||
|
||||
def test_delegated_avatar_identity_is_recognized_without_matching_normal_users(self):
|
||||
delegated = SimpleNamespace(account="__avatar__:avatar-7")
|
||||
normal = SimpleNamespace(account="13800000000")
|
||||
|
||||
self.assertTrue(avatar_service.is_delegated_avatar_user(delegated))
|
||||
self.assertEqual(avatar_service.delegated_avatar_id(delegated), "avatar-7")
|
||||
self.assertFalse(avatar_service.is_delegated_avatar_user(normal))
|
||||
self.assertEqual(avatar_service.delegated_avatar_id(normal), "")
|
||||
|
||||
def test_response_length_maps_to_scheduler_comment_limits(self):
|
||||
self.assertEqual(avatar_service._word_count_range({"responseLength": "short"}), (10, 35))
|
||||
self.assertEqual(avatar_service._word_count_range({"responseLength": "long"}), (30, 80))
|
||||
self.assertEqual(avatar_service._word_count_range({"responseLength": "unknown"}), (20, 60))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,91 @@
|
||||
from sqlalchemy import create_engine, text
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.services.finance_service import FinanceService
|
||||
|
||||
|
||||
def _db():
|
||||
engine = create_engine("sqlite:///:memory:")
|
||||
db = Session(engine)
|
||||
db.execute(text("""
|
||||
CREATE TABLE users (id TEXT PRIMARY KEY, nickname TEXT, phone TEXT)
|
||||
"""))
|
||||
db.execute(text("""
|
||||
CREATE TABLE token_payment_orders (
|
||||
id TEXT, order_no TEXT PRIMARY KEY, user_id TEXT, plan_id TEXT,
|
||||
payment_method TEXT, pay_type TEXT, pay_way TEXT, points_amount INTEGER,
|
||||
price_cents INTEGER, status TEXT, provider TEXT, provider_order_id TEXT,
|
||||
provider_order_no TEXT, provider_status TEXT, pay_message TEXT,
|
||||
failure_reason TEXT, refund_status TEXT, created_at TEXT, updated_at TEXT,
|
||||
paid_at TEXT, refunded_at TEXT
|
||||
)
|
||||
"""))
|
||||
db.execute(text("""
|
||||
CREATE TABLE payment_refunds (
|
||||
id TEXT, refund_no TEXT, order_no TEXT, amount_cents INTEGER,
|
||||
points_amount INTEGER, reason TEXT, status TEXT, provider_refund_no TEXT,
|
||||
requested_by TEXT, failure_reason TEXT, created_at TEXT, updated_at TEXT,
|
||||
completed_at TEXT
|
||||
)
|
||||
"""))
|
||||
db.execute(text("""
|
||||
CREATE TABLE invoice_applications (
|
||||
id TEXT, order_no TEXT, user_id TEXT, amount_cents INTEGER, title TEXT,
|
||||
invoice_type TEXT, tax_number TEXT, email TEXT, status TEXT,
|
||||
invoice_no TEXT, invoice_url TEXT, remark TEXT, created_at TEXT,
|
||||
updated_at TEXT, issued_at TEXT
|
||||
)
|
||||
"""))
|
||||
db.execute(text("INSERT INTO users VALUES ('u1','测试用户','13800000000')"))
|
||||
db.execute(text("""
|
||||
INSERT INTO token_payment_orders VALUES (
|
||||
'o1','AV1','u1','1','wechat','WECHAT','APP',2000000,1000,'paid','huihui',
|
||||
'','','SUCCESS','secret-payment-message','','none','2026-09-08 12:00:00',
|
||||
'2026-09-08 12:01:00','2026-09-08 12:01:00',NULL
|
||||
)
|
||||
"""))
|
||||
db.execute(text("""
|
||||
INSERT INTO invoice_applications VALUES (
|
||||
'i1','AV1','u1',1000,'测试用户','personal','','u@example.com','pending',
|
||||
'','','','2026-09-08 12:02:00','2026-09-08 12:02:00',NULL
|
||||
)
|
||||
"""))
|
||||
db.commit()
|
||||
return db
|
||||
|
||||
|
||||
def test_finance_summary_and_orders_hide_provider_payment_payload():
|
||||
db = _db()
|
||||
try:
|
||||
summary = FinanceService.summary(db)
|
||||
assert summary == {
|
||||
"paid_revenue": 10.0,
|
||||
"paid_orders": 1,
|
||||
"pending_orders": 0,
|
||||
"processing_refunds": 0,
|
||||
"pending_invoices": 1,
|
||||
}
|
||||
total, orders = FinanceService.list_orders(db, keyword="测试用户")
|
||||
assert total == 1
|
||||
assert orders[0]["price"] == 10.0
|
||||
assert orders[0]["invoice_status"] == "pending"
|
||||
assert "pay_message" not in orders[0]
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
def test_invoice_can_be_issued_and_pending_order_can_be_closed():
|
||||
db = _db()
|
||||
try:
|
||||
FinanceService.update_invoice(db, "i1", status="issued", invoice_no="FP-001", invoice_url="", remark="")
|
||||
assert db.execute(text("SELECT status, invoice_no FROM invoice_applications WHERE id='i1'" )).fetchone() == ("issued", "FP-001")
|
||||
db.execute(text("""
|
||||
INSERT INTO token_payment_orders
|
||||
(id,order_no,user_id,plan_id,payment_method,pay_type,pay_way,points_amount,price_cents,status,provider,refund_status)
|
||||
VALUES ('o2','AV2','u1','1','alipay','ALIPAY','H5',1,100,'pending','huihui','none')
|
||||
"""))
|
||||
db.commit()
|
||||
FinanceService.close_order(db, "AV2", status="closed", reason="超时")
|
||||
assert db.execute(text("SELECT status FROM token_payment_orders WHERE order_no='AV2'" )).scalar() == "closed"
|
||||
finally:
|
||||
db.close()
|
||||
@@ -1,12 +1,16 @@
|
||||
# 构建阶段:安装依赖并打包 H5
|
||||
FROM node:18-alpine AS build
|
||||
|
||||
ARG APP_GIT_SHA=unknown
|
||||
ARG APP_BUILD_TIME=unknown
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY package*.json ./
|
||||
RUN npm ci
|
||||
|
||||
COPY . .
|
||||
RUN printf '{"gitSha":"%s","buildTime":"%s"}\n' "$APP_GIT_SHA" "$APP_BUILD_TIME" > public/version.json
|
||||
RUN npm run build
|
||||
|
||||
# 运行阶段:nginx 托管静态资源并反向代理 /api 到后端
|
||||
@@ -14,6 +18,11 @@ RUN npm run build
|
||||
# 新版 nginx(>=1.31) 用 pwrite 写 pid 文件会被拦导致致命退出;1.28 用 write() 可正常启动。
|
||||
FROM nginx:1.28-alpine
|
||||
|
||||
ARG APP_GIT_SHA=unknown
|
||||
ARG APP_BUILD_TIME=unknown
|
||||
LABEL org.opencontainers.image.revision=${APP_GIT_SHA} \
|
||||
org.opencontainers.image.created=${APP_BUILD_TIME}
|
||||
|
||||
COPY --from=build /app/dist /usr/share/nginx/html
|
||||
# 覆盖 nginx 默认主配置(含唯一可写的 pid /tmp/nginx.pid,规避受限容器内 /run 不可写导致反复重启)
|
||||
COPY nginx.conf /etc/nginx/nginx.conf
|
||||
|
||||
@@ -7,6 +7,13 @@ WORKDIR /app
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir --timeout 120 --retries 10 -i https://pypi.tuna.tsinghua.edu.cn/simple -r requirements.txt
|
||||
|
||||
ARG APP_GIT_SHA=unknown
|
||||
ARG APP_BUILD_TIME=unknown
|
||||
ENV APP_GIT_SHA=${APP_GIT_SHA} \
|
||||
APP_BUILD_TIME=${APP_BUILD_TIME}
|
||||
LABEL org.opencontainers.image.revision=${APP_GIT_SHA} \
|
||||
org.opencontainers.image.created=${APP_BUILD_TIME}
|
||||
|
||||
COPY . .
|
||||
|
||||
# 后端使用 SQLite(avatar.db 落在 /app 内),平铺结构以 `uvicorn main:app` 启动
|
||||
|
||||
@@ -1,16 +1,30 @@
|
||||
import os
|
||||
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy import create_engine, event
|
||||
from sqlalchemy.orm import sessionmaker, declarative_base, Session
|
||||
|
||||
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
|
||||
DB_FILE = os.path.join(BASE_DIR, "avatar.db")
|
||||
DATABASE_URL = os.getenv("DATABASE_URL", f"sqlite:///{DB_FILE}")
|
||||
|
||||
IS_SQLITE = DATABASE_URL.startswith("sqlite:")
|
||||
engine = create_engine(
|
||||
DATABASE_URL,
|
||||
connect_args={"check_same_thread": False} if DATABASE_URL.startswith("sqlite:") else {},
|
||||
connect_args={"check_same_thread": False, "timeout": 30} if IS_SQLITE else {},
|
||||
)
|
||||
|
||||
|
||||
if IS_SQLITE:
|
||||
@event.listens_for(engine, "connect")
|
||||
def _configure_sqlite_connection(dbapi_connection, _connection_record):
|
||||
cursor = dbapi_connection.cursor()
|
||||
try:
|
||||
cursor.execute("PRAGMA synchronous=NORMAL")
|
||||
cursor.execute("PRAGMA busy_timeout=30000")
|
||||
finally:
|
||||
cursor.close()
|
||||
|
||||
|
||||
SessionLocal = sessionmaker(bind=engine, autoflush=False, expire_on_commit=False)
|
||||
Base = declarative_base()
|
||||
|
||||
@@ -26,6 +40,10 @@ def get_db():
|
||||
def init_db():
|
||||
import models
|
||||
|
||||
if IS_SQLITE:
|
||||
with engine.connect() as conn:
|
||||
conn.exec_driver_sql("PRAGMA journal_mode=WAL")
|
||||
conn.commit()
|
||||
Base.metadata.create_all(bind=engine)
|
||||
|
||||
# 轻量迁移:为已存在的表补充新列(SQLite 不支持自动 ALTER,逐列尝试)
|
||||
@@ -35,13 +53,31 @@ def init_db():
|
||||
("knowledge_docs", "embedding_model", "VARCHAR DEFAULT ''"),
|
||||
("knowledge_docs", "chunk_count", "INTEGER DEFAULT 0"),
|
||||
("knowledge_docs", "vectorized_at", "TIMESTAMP"),
|
||||
("knowledge_docs", "error_message", "VARCHAR DEFAULT ''"),
|
||||
("knowledge_docs", "index_stage", "VARCHAR DEFAULT ''"),
|
||||
("knowledge_docs", "index_progress", "INTEGER DEFAULT 0"),
|
||||
("avatars", "owner_id", "VARCHAR DEFAULT ''"),
|
||||
("authorizations", "takeover_enabled", "BOOLEAN DEFAULT 0"),
|
||||
("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"),
|
||||
("token_account", "user_id", "VARCHAR DEFAULT ''"),
|
||||
("token_account", "total_granted", "BIGINT DEFAULT 0"),
|
||||
("token_account", "total_consumed", "BIGINT DEFAULT 0"),
|
||||
("token_account", "created_at", "TIMESTAMP"),
|
||||
("token_account", "updated_at", "TIMESTAMP"),
|
||||
("token_plans", "virtual_product_id", "VARCHAR DEFAULT ''"),
|
||||
("token_payment_orders", "provider", "VARCHAR DEFAULT 'huihui'"),
|
||||
("token_payment_orders", "refund_status", "VARCHAR DEFAULT 'none'"),
|
||||
("token_payment_orders", "refunded_at", "TIMESTAMP"),
|
||||
("users", "wechat_mp_openid", "VARCHAR DEFAULT ''"),
|
||||
("users", "wechat_mp_session_key", "VARCHAR DEFAULT ''"),
|
||||
("takeover_messages", "attachment_id", "VARCHAR DEFAULT NULL"),
|
||||
)
|
||||
_normalize_optional_unique_values()
|
||||
_normalize_takeover_delays()
|
||||
_create_token_indexes()
|
||||
_create_payment_indexes()
|
||||
|
||||
|
||||
def _try_add_columns(*cols):
|
||||
@@ -58,3 +94,36 @@ def _try_add_columns(*cols):
|
||||
def _normalize_optional_unique_values():
|
||||
with engine.begin() as conn:
|
||||
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():
|
||||
with engine.begin() as conn:
|
||||
conn.exec_driver_sql(
|
||||
"CREATE UNIQUE INDEX IF NOT EXISTS ux_token_account_user_id "
|
||||
"ON token_account(user_id) WHERE user_id <> ''"
|
||||
)
|
||||
|
||||
|
||||
def _create_payment_indexes():
|
||||
with engine.begin() as conn:
|
||||
conn.exec_driver_sql(
|
||||
"CREATE INDEX IF NOT EXISTS ix_token_payment_orders_provider "
|
||||
"ON token_payment_orders(provider)"
|
||||
)
|
||||
conn.exec_driver_sql(
|
||||
"CREATE INDEX IF NOT EXISTS ix_token_payment_orders_refund_status "
|
||||
"ON token_payment_orders(refund_status)"
|
||||
)
|
||||
conn.exec_driver_sql(
|
||||
"CREATE INDEX IF NOT EXISTS ix_users_wechat_mp_openid "
|
||||
"ON users(wechat_mp_openid)"
|
||||
)
|
||||
|
||||
@@ -18,6 +18,14 @@ EMBED_DIM = 256
|
||||
MODEL = os.getenv("EMBEDDING_MODEL", "mock-hash-embed-v1")
|
||||
|
||||
|
||||
def _embedding_endpoint(api_url):
|
||||
"""Accept either an OpenAI-compatible base URL or its full endpoint."""
|
||||
api_url = (api_url or "").strip().rstrip("/")
|
||||
if not api_url or api_url.endswith("/embeddings"):
|
||||
return api_url
|
||||
return f"{api_url}/embeddings"
|
||||
|
||||
|
||||
def _tokenize(text):
|
||||
text = (text or "").lower()
|
||||
# 英文/数字按词,CJK 逐字(中文无空格,需拆到字级才能命中子词)
|
||||
@@ -43,11 +51,11 @@ def _hash_embedding(texts, dim=EMBED_DIM):
|
||||
return vecs
|
||||
|
||||
|
||||
def embed(texts):
|
||||
def embed(texts, on_progress=None):
|
||||
"""返回 list[list[float]],与输入顺序一致。"""
|
||||
if not texts:
|
||||
return []
|
||||
api_url = os.getenv("EMBEDDING_API_URL")
|
||||
api_url = _embedding_endpoint(os.getenv("EMBEDDING_API_URL"))
|
||||
if api_url:
|
||||
api_key = os.getenv("EMBEDDING_API_KEY", "")
|
||||
model = os.getenv("EMBEDDING_MODEL", "text-embedding-3-small")
|
||||
@@ -56,6 +64,7 @@ def embed(texts):
|
||||
except ValueError:
|
||||
batch_size = 10
|
||||
embeddings = []
|
||||
total = len(texts)
|
||||
for start in range(0, len(texts), batch_size):
|
||||
batch = texts[start:start + batch_size]
|
||||
payload = json.dumps({"input": batch, "model": model}).encode("utf-8")
|
||||
@@ -76,8 +85,13 @@ def embed(texts):
|
||||
if len(items) != len(batch):
|
||||
raise ValueError("embedding response count does not match request")
|
||||
embeddings.extend(item["embedding"] for item in items)
|
||||
if on_progress:
|
||||
on_progress(len(embeddings), total)
|
||||
return embeddings
|
||||
return _hash_embedding(texts)
|
||||
vectors = _hash_embedding(texts)
|
||||
if on_progress:
|
||||
on_progress(len(vectors), len(texts))
|
||||
return vectors
|
||||
|
||||
|
||||
def cosine(a, b):
|
||||
|
||||
@@ -1,14 +1,15 @@
|
||||
from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
|
||||
import os
|
||||
import importlib.util
|
||||
import logging
|
||||
import os
|
||||
|
||||
from apscheduler.schedulers.asyncio import AsyncIOScheduler
|
||||
from apscheduler.triggers.interval import IntervalTrigger
|
||||
|
||||
from database import init_db, SessionLocal
|
||||
from models import Avatar, Authorization, Organization, TokenAccount, TokenPlan
|
||||
from database import engine, init_db, SessionLocal
|
||||
from models import Avatar, Authorization, Organization, TokenAccount, TokenPlan, User
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
import routers.avatars
|
||||
import routers.tokens
|
||||
@@ -19,10 +20,14 @@ import routers.huihui_auth
|
||||
import routers.chat
|
||||
import routers.takeover
|
||||
from responses import ok
|
||||
from services.chat_attachment_service import purge_expired_chat_attachments
|
||||
from services.knowledge_vectorizer import knowledge_vectorizer
|
||||
from services.token_billing import DEFAULT_TOKEN_GRANT, release_stale_reservations
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
takeover_scheduler = None
|
||||
maintenance_scheduler = None
|
||||
|
||||
app = FastAPI(title="会会数字分身 API", version="1.0.0")
|
||||
|
||||
@@ -50,23 +55,59 @@ app.mount("/api/files", StaticFiles(directory=UPLOAD_DIR), name="knowledge-files
|
||||
|
||||
@app.get("/api/health")
|
||||
def health():
|
||||
return ok({"status": "ok"})
|
||||
checks = _runtime_checks()
|
||||
return ok({
|
||||
"status": "ok" if all(checks.values()) else "degraded",
|
||||
"gitSha": os.getenv("APP_GIT_SHA", "unknown"),
|
||||
"buildTime": os.getenv("APP_BUILD_TIME", "unknown"),
|
||||
"checks": checks,
|
||||
})
|
||||
|
||||
|
||||
def _runtime_checks():
|
||||
return {
|
||||
"database": _database_is_ready(),
|
||||
"uploads": os.path.isdir(UPLOAD_DIR) and os.access(UPLOAD_DIR, os.W_OK),
|
||||
"pdfOcr": importlib.util.find_spec("pymupdf") is not None,
|
||||
}
|
||||
|
||||
|
||||
def _database_is_ready():
|
||||
try:
|
||||
with engine.connect() as connection:
|
||||
connection.exec_driver_sql("SELECT 1")
|
||||
return True
|
||||
except Exception:
|
||||
logger.exception("Database readiness check failed")
|
||||
return False
|
||||
|
||||
|
||||
def seed():
|
||||
db = SessionLocal()
|
||||
try:
|
||||
if db.query(TokenAccount).first() is None:
|
||||
db.add(TokenAccount(balance=1250))
|
||||
plan_specs = [
|
||||
{"id": "1", "name": "基础套餐", "amount": 2_000_000, "price": 10, "badge": "", "desc": "2M 积分"},
|
||||
{"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 积分"},
|
||||
{"id": "4", "name": "企业套餐", "amount": 2_500_000_000, "price": 10000, "badge": "企业推荐", "desc": "2500M 积分"},
|
||||
]
|
||||
for spec in plan_specs:
|
||||
plan = db.query(TokenPlan).filter(TokenPlan.id == spec["id"]).first()
|
||||
if plan is None:
|
||||
db.add(TokenPlan(**spec))
|
||||
else:
|
||||
for key, value in spec.items():
|
||||
setattr(plan, key, value)
|
||||
|
||||
if db.query(TokenPlan).count() == 0:
|
||||
plans = [
|
||||
TokenPlan(id="1", name="新手体验", amount=1000, price=9.9, desc="新手体验"),
|
||||
TokenPlan(id="2", name="热门套餐", amount=5000, price=39.9, badge="热门"),
|
||||
TokenPlan(id="3", name="超值套餐", amount=12000, price=89.9, badge="超值"),
|
||||
TokenPlan(id="4", name="企业推荐", amount=30000, price=199, badge="企业推荐", desc="适合高频使用"),
|
||||
]
|
||||
db.add_all(plans)
|
||||
for user in db.query(User).all():
|
||||
account = db.query(TokenAccount).filter(TokenAccount.user_id == user.id).first()
|
||||
if account is None:
|
||||
db.add(TokenAccount(
|
||||
user_id=user.id,
|
||||
balance=DEFAULT_TOKEN_GRANT,
|
||||
total_granted=DEFAULT_TOKEN_GRANT,
|
||||
total_consumed=0,
|
||||
))
|
||||
|
||||
if db.query(Avatar).count() == 0:
|
||||
avatar = Avatar(
|
||||
@@ -105,6 +146,7 @@ def seed():
|
||||
db.add_all(orgs)
|
||||
|
||||
db.commit()
|
||||
release_stale_reservations(db)
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
@@ -115,9 +157,19 @@ def on_startup():
|
||||
|
||||
init_db()
|
||||
seed()
|
||||
knowledge_vectorizer.start()
|
||||
|
||||
# Release stale resources when startup is invoked again by a reload/test.
|
||||
stop_takeover_scheduler()
|
||||
stop_maintenance_scheduler()
|
||||
try:
|
||||
start_maintenance_scheduler()
|
||||
except Exception as exc:
|
||||
stop_maintenance_scheduler()
|
||||
logger.warning(
|
||||
"Failed to initialize chat attachment cleanup, app will continue: %s",
|
||||
exc,
|
||||
)
|
||||
|
||||
# --- Takeover scheduler ---
|
||||
try:
|
||||
@@ -138,7 +190,14 @@ def on_startup():
|
||||
boxim_client = BoxIMClient(boxim_config)
|
||||
|
||||
from services.takeover_service import TakeoverService
|
||||
takeover_service = TakeoverService(SessionLocal, boxim_client)
|
||||
takeover_service = TakeoverService(
|
||||
SessionLocal,
|
||||
boxim_client,
|
||||
poll_concurrency=int(os.getenv("BOXIM_POLL_CONCURRENCY", "8")),
|
||||
max_message_age_seconds=int(
|
||||
os.getenv("BOXIM_MAX_MESSAGE_AGE_SECONDS", "600")
|
||||
),
|
||||
)
|
||||
|
||||
poll_interval = max(0.5, float(os.getenv("BOXIM_POLL_INTERVAL_SECONDS", "1")))
|
||||
takeover_scheduler = AsyncIOScheduler()
|
||||
@@ -182,6 +241,51 @@ def stop_takeover_scheduler():
|
||||
finally:
|
||||
takeover_scheduler = None
|
||||
|
||||
|
||||
def purge_expired_chat_attachments_job():
|
||||
db = SessionLocal()
|
||||
try:
|
||||
count = purge_expired_chat_attachments(db)
|
||||
if count:
|
||||
logger.info("Purged %s expired chat image attachment(s)", count)
|
||||
except Exception as exc:
|
||||
db.rollback()
|
||||
logger.warning("Failed to purge expired chat image attachments: %s", exc)
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
def start_maintenance_scheduler():
|
||||
global maintenance_scheduler
|
||||
|
||||
purge_expired_chat_attachments_job()
|
||||
interval_minutes = max(
|
||||
5, min(1440, int(os.getenv("CHAT_ATTACHMENT_CLEANUP_MINUTES", "60")))
|
||||
)
|
||||
maintenance_scheduler = AsyncIOScheduler()
|
||||
maintenance_scheduler.add_job(
|
||||
purge_expired_chat_attachments_job,
|
||||
trigger=IntervalTrigger(minutes=interval_minutes),
|
||||
id="chat_attachment_cleanup",
|
||||
max_instances=1,
|
||||
coalesce=True,
|
||||
)
|
||||
maintenance_scheduler.start()
|
||||
|
||||
|
||||
def stop_maintenance_scheduler():
|
||||
global maintenance_scheduler
|
||||
|
||||
if maintenance_scheduler is not None:
|
||||
try:
|
||||
if maintenance_scheduler.running:
|
||||
maintenance_scheduler.shutdown(wait=False)
|
||||
except Exception as exc:
|
||||
logger.warning("Failed to stop maintenance scheduler cleanly: %s", exc)
|
||||
finally:
|
||||
maintenance_scheduler = None
|
||||
|
||||
@app.on_event("shutdown")
|
||||
def on_shutdown():
|
||||
stop_takeover_scheduler()
|
||||
stop_maintenance_scheduler()
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import uuid
|
||||
|
||||
from sqlalchemy import (
|
||||
BigInteger,
|
||||
Boolean,
|
||||
Column,
|
||||
DateTime,
|
||||
@@ -66,7 +67,7 @@ class Authorization(Base):
|
||||
status = Column(String, default="active") # active | inactive
|
||||
takeover_enabled = Column(Boolean, default=False) # 是否开启分身接管
|
||||
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())
|
||||
|
||||
def to_dict(self):
|
||||
@@ -119,13 +120,14 @@ class TakeoverMessage(Base):
|
||||
direction = Column(String, nullable=False) # incoming | outgoing
|
||||
message_type = Column(Integer, default=0)
|
||||
content = Column(Text, default="")
|
||||
attachment_id = Column(String, nullable=True)
|
||||
is_avatar = Column(Boolean, default=False)
|
||||
send_time = Column(DateTime, nullable=False)
|
||||
created_at = Column(DateTime, server_default=func.now())
|
||||
|
||||
|
||||
class TakeoverReplyTask(Base):
|
||||
"""Restart-safe three-second BOXIM reply task."""
|
||||
"""Restart-safe delayed BOXIM reply task."""
|
||||
|
||||
__tablename__ = "takeover_reply_tasks"
|
||||
__table_args__ = (
|
||||
@@ -187,7 +189,10 @@ class KnowledgeDoc(Base):
|
||||
file_type = Column(String, default="") # pdf | doc | docx | xlsx
|
||||
file_size = Column(Integer, default=0)
|
||||
file_url = Column(String, default="")
|
||||
status = Column(String, default="uploaded") # uploaded | parsing | ready
|
||||
status = Column(String, default="uploaded") # uploaded | parsing | ready | failed
|
||||
error_message = Column(String, default="") # 建立索引失败原因
|
||||
index_stage = Column(String, default="") # queued | extracting | chunking | embedding | ready | failed
|
||||
index_progress = Column(Integer, default=0) # 0-100
|
||||
vectorized = Column(Boolean, default=False) # 是否已向量化
|
||||
embedding_model = Column(String, default="") # 向量模型标识
|
||||
chunk_count = Column(Integer, default=0) # 切片数量
|
||||
@@ -203,6 +208,9 @@ class KnowledgeDoc(Base):
|
||||
"fileSize": self.file_size,
|
||||
"fileUrl": self.file_url,
|
||||
"status": self.status,
|
||||
"errorMessage": self.error_message or "",
|
||||
"indexStage": self.index_stage or "",
|
||||
"indexProgress": int(self.index_progress or 0),
|
||||
"vectorized": bool(self.vectorized),
|
||||
"embeddingModel": self.embedding_model,
|
||||
"chunkCount": self.chunk_count,
|
||||
@@ -256,20 +264,87 @@ class KnowledgeChunk(Base):
|
||||
}
|
||||
|
||||
|
||||
class ChatAttachment(Base):
|
||||
"""Private, avatar-scoped result of one chat image analysis."""
|
||||
|
||||
__tablename__ = "chat_attachments"
|
||||
id = Column(String, primary_key=True, default=lambda: uuid.uuid4().hex)
|
||||
avatar_id = Column(String, nullable=False, default="", index=True)
|
||||
uploader_kind = Column(String, default="owner") # owner | public | boxim
|
||||
filename = Column(String, default="")
|
||||
mime_type = Column(String, default="")
|
||||
file_size = Column(Integer, default=0)
|
||||
status = Column(String, default="processing") # processing | ready | failed
|
||||
category = Column(String, default="general_image")
|
||||
summary = Column(Text, default="")
|
||||
extracted_text = Column(Text, default="")
|
||||
structured_data = Column(JSON, default=dict)
|
||||
warning = Column(Text, default="")
|
||||
vision_model = Column(String, default="")
|
||||
ocr_model = Column(String, default="")
|
||||
used_at = Column(DateTime)
|
||||
expires_at = Column(DateTime, nullable=False)
|
||||
created_at = Column(DateTime, server_default=func.now())
|
||||
|
||||
def to_dict(self):
|
||||
return {
|
||||
"id": self.id,
|
||||
"avatarId": self.avatar_id,
|
||||
"filename": self.filename,
|
||||
"mimeType": self.mime_type,
|
||||
"fileSize": self.file_size,
|
||||
"status": self.status,
|
||||
"category": self.category,
|
||||
"summary": self.summary,
|
||||
"warning": self.warning,
|
||||
"expiresAt": _iso(self.expires_at),
|
||||
"createdAt": _iso(self.created_at),
|
||||
}
|
||||
|
||||
|
||||
class TokenAccount(Base):
|
||||
__tablename__ = "token_account"
|
||||
id = Column(Integer, primary_key=True)
|
||||
balance = Column(Integer, default=1250)
|
||||
user_id = Column(String, nullable=False, default="", index=True)
|
||||
balance = Column(BigInteger, default=1_000_000)
|
||||
total_granted = Column(BigInteger, default=1_000_000)
|
||||
total_consumed = Column(BigInteger, default=0)
|
||||
created_at = Column(DateTime, server_default=func.now())
|
||||
updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now())
|
||||
|
||||
|
||||
class TokenUsage(Base):
|
||||
__tablename__ = "token_usage"
|
||||
__table_args__ = (
|
||||
Index("ix_token_usage_user_created", "user_id", "created_at"),
|
||||
Index("ix_token_usage_avatar_created", "avatar_id", "created_at"),
|
||||
)
|
||||
|
||||
id = Column(String, primary_key=True, default=lambda: uuid.uuid4().hex)
|
||||
user_id = Column(String, nullable=False, index=True)
|
||||
avatar_id = Column(String, nullable=False, default="", index=True)
|
||||
source = Column(String, nullable=False, default="chat")
|
||||
model = Column(String, default="")
|
||||
status = Column(String, nullable=False, default="reserved")
|
||||
reserved_tokens = Column(BigInteger, default=0)
|
||||
prompt_tokens = Column(BigInteger, default=0)
|
||||
completion_tokens = Column(BigInteger, default=0)
|
||||
total_tokens = Column(BigInteger, default=0)
|
||||
balance_after = Column(BigInteger, default=0)
|
||||
failure_reason = Column(String, default="")
|
||||
created_at = Column(DateTime, server_default=func.now())
|
||||
settled_at = Column(DateTime)
|
||||
|
||||
|
||||
class TokenPlan(Base):
|
||||
__tablename__ = "token_plans"
|
||||
id = Column(String, primary_key=True)
|
||||
name = Column(String, default="")
|
||||
amount = Column(Integer, default=0)
|
||||
amount = Column(BigInteger, default=0)
|
||||
price = Column(Float, default=0)
|
||||
badge = Column(String, default="")
|
||||
desc = Column(String, default="")
|
||||
virtual_product_id = Column(String, default="")
|
||||
|
||||
def to_dict(self):
|
||||
return {
|
||||
@@ -279,6 +354,157 @@ class TokenPlan(Base):
|
||||
"price": self.price,
|
||||
"badge": self.badge,
|
||||
"desc": self.desc,
|
||||
"virtualProductId": self.virtual_product_id,
|
||||
}
|
||||
|
||||
|
||||
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 = Column(String, nullable=False, default="huihui", 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="")
|
||||
refund_status = Column(String, nullable=False, default="none", index=True)
|
||||
created_at = Column(DateTime, server_default=func.now())
|
||||
updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now())
|
||||
paid_at = Column(DateTime)
|
||||
refunded_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,
|
||||
"provider": self.provider,
|
||||
"providerStatus": self.provider_status,
|
||||
"payMessage": self.pay_message,
|
||||
"failureReason": self.failure_reason,
|
||||
"refundStatus": self.refund_status,
|
||||
"createdAt": _iso(self.created_at),
|
||||
"paidAt": _iso(self.paid_at),
|
||||
"refundedAt": _iso(self.refunded_at),
|
||||
}
|
||||
|
||||
|
||||
class PaymentTransaction(Base):
|
||||
"""Auditable provider event for one Token purchase order."""
|
||||
|
||||
__tablename__ = "payment_transactions"
|
||||
__table_args__ = (
|
||||
Index("ix_payment_transactions_order_created", "order_no", "created_at"),
|
||||
)
|
||||
|
||||
id = Column(String, primary_key=True, default=lambda: uuid.uuid4().hex)
|
||||
order_no = Column(String, nullable=False, index=True)
|
||||
provider = Column(String, nullable=False, default="huihui")
|
||||
transaction_no = Column(String, nullable=False, default="")
|
||||
event_type = Column(String, nullable=False, default="payment")
|
||||
status = Column(String, nullable=False, default="pending")
|
||||
amount_cents = Column(Integer, nullable=False, default=0)
|
||||
raw_summary = Column(Text, default="")
|
||||
created_at = Column(DateTime, server_default=func.now())
|
||||
|
||||
def to_dict(self):
|
||||
return {
|
||||
"id": self.id,
|
||||
"orderNo": self.order_no,
|
||||
"provider": self.provider,
|
||||
"transactionNo": self.transaction_no,
|
||||
"eventType": self.event_type,
|
||||
"status": self.status,
|
||||
"amount": self.amount_cents / 100,
|
||||
"createdAt": _iso(self.created_at),
|
||||
}
|
||||
|
||||
|
||||
class PaymentRefund(Base):
|
||||
__tablename__ = "payment_refunds"
|
||||
|
||||
id = Column(String, primary_key=True, default=lambda: uuid.uuid4().hex)
|
||||
refund_no = Column(String, nullable=False, unique=True, index=True)
|
||||
order_no = Column(String, nullable=False, index=True)
|
||||
amount_cents = Column(Integer, nullable=False)
|
||||
points_amount = Column(BigInteger, nullable=False)
|
||||
reason = Column(String, default="")
|
||||
status = Column(String, nullable=False, default="pending", index=True)
|
||||
provider_refund_no = Column(String, default="")
|
||||
requested_by = Column(String, default="admin")
|
||||
failure_reason = Column(String, default="")
|
||||
created_at = Column(DateTime, server_default=func.now())
|
||||
updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now())
|
||||
completed_at = Column(DateTime)
|
||||
|
||||
def to_dict(self):
|
||||
return {
|
||||
"id": self.id,
|
||||
"refundNo": self.refund_no,
|
||||
"orderNo": self.order_no,
|
||||
"amount": self.amount_cents / 100,
|
||||
"pointsAmount": self.points_amount,
|
||||
"reason": self.reason,
|
||||
"status": self.status,
|
||||
"providerRefundNo": self.provider_refund_no,
|
||||
"requestedBy": self.requested_by,
|
||||
"failureReason": self.failure_reason,
|
||||
"createdAt": _iso(self.created_at),
|
||||
"completedAt": _iso(self.completed_at),
|
||||
}
|
||||
|
||||
|
||||
class InvoiceApplication(Base):
|
||||
__tablename__ = "invoice_applications"
|
||||
|
||||
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)
|
||||
amount_cents = Column(Integer, nullable=False)
|
||||
title = Column(String, nullable=False)
|
||||
invoice_type = Column(String, nullable=False, default="personal")
|
||||
tax_number = Column(String, default="")
|
||||
email = Column(String, default="")
|
||||
status = Column(String, nullable=False, default="pending", index=True)
|
||||
invoice_no = Column(String, default="")
|
||||
invoice_url = Column(String, default="")
|
||||
remark = Column(String, default="")
|
||||
created_at = Column(DateTime, server_default=func.now())
|
||||
updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now())
|
||||
issued_at = Column(DateTime)
|
||||
|
||||
def to_dict(self):
|
||||
return {
|
||||
"id": self.id,
|
||||
"orderNo": self.order_no,
|
||||
"userId": self.user_id,
|
||||
"amount": self.amount_cents / 100,
|
||||
"title": self.title,
|
||||
"invoiceType": self.invoice_type,
|
||||
"taxNumber": self.tax_number,
|
||||
"email": self.email,
|
||||
"status": self.status,
|
||||
"invoiceNo": self.invoice_no,
|
||||
"invoiceUrl": self.invoice_url,
|
||||
"remark": self.remark,
|
||||
"createdAt": _iso(self.created_at),
|
||||
"issuedAt": _iso(self.issued_at),
|
||||
}
|
||||
|
||||
|
||||
@@ -293,6 +519,9 @@ class User(Base):
|
||||
avatar_url = Column(String, default="")
|
||||
huihui_token = Column(String, default="") # 会会 access_token
|
||||
app_token = Column(String, default="") # 本系统会话 token
|
||||
wechat_mp_openid = Column(String, default="", index=True)
|
||||
# 微信 session_key 仅保存在服务端,用于虚拟支付用户态签名,绝不下发客户端。
|
||||
wechat_mp_session_key = Column(String, default="")
|
||||
last_login_at = Column(DateTime)
|
||||
created_at = Column(DateTime, server_default=func.now())
|
||||
updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now())
|
||||
|
||||
@@ -5,6 +5,8 @@ pydantic
|
||||
python-multipart
|
||||
httpx
|
||||
pypdf
|
||||
PyMuPDF>=1.24,<2
|
||||
python-docx
|
||||
openpyxl
|
||||
apscheduler>=3.10
|
||||
Pillow>=10.4
|
||||
|
||||
@@ -2,7 +2,7 @@ from fastapi import APIRouter, Body, Depends, Header, HTTPException
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from database import get_db
|
||||
from models import Authorization, TakeoverCursor, TakeoverReplyTask
|
||||
from models import Authorization, Avatar, TakeoverCursor, TakeoverReplyTask
|
||||
from responses import fail, ok
|
||||
from routers.avatars import _require_owned_avatar
|
||||
|
||||
@@ -14,6 +14,10 @@ ALLOWED_PERMISSIONS = set(PERMISSION_ORDER)
|
||||
AVATAR_PERMISSION_ORDER = PERMISSION_ORDER
|
||||
AVATAR_PERMISSION_KEY = "authorizationPermissions"
|
||||
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 = {
|
||||
"read": "browse",
|
||||
"reply": "chat",
|
||||
@@ -91,9 +95,62 @@ def _permission_settings_payload(avatar) -> dict:
|
||||
return {
|
||||
"avatarId": avatar.id,
|
||||
"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:
|
||||
authorization = (
|
||||
db.query(Authorization)
|
||||
@@ -144,10 +201,19 @@ def update_permission_settings(
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
avatar = _require_owned_avatar(db, avatar_id, authorization)
|
||||
if "permissions" not in payload:
|
||||
return fail("缺少 permissions", 400)
|
||||
if "permissions" not in payload and TAKEOVER_DELAY_KEY not in payload:
|
||||
return fail("缺少授权设置", 400)
|
||||
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:
|
||||
return fail(str(exc), 400)
|
||||
|
||||
@@ -155,7 +221,9 @@ def update_permission_settings(
|
||||
avatar.config = {
|
||||
**(avatar.config or {}),
|
||||
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()
|
||||
if cursor and "takeover" in permissions and "takeover" not in previous_permissions:
|
||||
cursor.initialized = False
|
||||
@@ -179,7 +247,9 @@ def update_permission_settings(
|
||||
task.locked_at = None
|
||||
db.commit()
|
||||
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")
|
||||
@@ -240,7 +310,7 @@ def create_auth(
|
||||
status="active",
|
||||
takeover_enabled=False,
|
||||
takeover_mode="immediate",
|
||||
takeover_delay_seconds=30,
|
||||
takeover_delay_seconds=DEFAULT_TAKEOVER_DELAY_SECONDS,
|
||||
)
|
||||
db.add(item)
|
||||
db.commit()
|
||||
|
||||
@@ -6,7 +6,17 @@ from sqlalchemy.orm import Session
|
||||
|
||||
from database import get_db
|
||||
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
|
||||
|
||||
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}")
|
||||
def get_avatar(avatar_id: str, db: Session = Depends(get_db)):
|
||||
a = db.query(Avatar).filter(Avatar.id == avatar_id).first()
|
||||
if not a:
|
||||
return fail("分身不存在", 404)
|
||||
return ok(a.to_dict())
|
||||
def get_avatar(
|
||||
avatar_id: str,
|
||||
authorization: str = Header(None),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
return ok(_require_owned_avatar(db, avatar_id, authorization).to_dict())
|
||||
|
||||
|
||||
@router.post("/avatar")
|
||||
def create_avatar(payload: dict = Body(...), authorization: str = Header(None), db: Session = Depends(get_db)):
|
||||
user = _resolve_user(authorization, db)
|
||||
if not user:
|
||||
raise HTTPException(status_code=401, detail="未登录")
|
||||
a = Avatar(
|
||||
owner_id=user.huihui_user_id if user else "",
|
||||
owner_id=user.huihui_user_id,
|
||||
name=payload.get("name", "未命名分身"),
|
||||
display_name=payload.get("displayName", "") or payload.get("display_name", ""),
|
||||
description=payload.get("description", ""),
|
||||
@@ -102,10 +115,13 @@ def create_avatar(payload: dict = Body(...), authorization: str = Header(None),
|
||||
|
||||
|
||||
@router.put("/avatar/{avatar_id}")
|
||||
def update_avatar(avatar_id: str, payload: dict = Body(...), db: Session = Depends(get_db)):
|
||||
a = db.query(Avatar).filter(Avatar.id == avatar_id).first()
|
||||
if not a:
|
||||
return fail("分身不存在", 404)
|
||||
def update_avatar(
|
||||
avatar_id: str,
|
||||
payload: dict = Body(...),
|
||||
authorization: str = Header(None),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
a = _require_owned_avatar(db, avatar_id, authorization)
|
||||
mapping = {
|
||||
"displayName": "display_name",
|
||||
"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"):
|
||||
if key in payload:
|
||||
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.refresh(a)
|
||||
return ok(a.to_dict())
|
||||
|
||||
|
||||
@router.delete("/avatar/{avatar_id}")
|
||||
def delete_avatar(avatar_id: str, db: Session = Depends(get_db)):
|
||||
a = db.query(Avatar).filter(Avatar.id == avatar_id).first()
|
||||
if not a:
|
||||
return fail("分身不存在", 404)
|
||||
def delete_avatar(
|
||||
avatar_id: str,
|
||||
authorization: str = Header(None),
|
||||
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(KnowledgeChunk).filter(KnowledgeChunk.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(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.commit()
|
||||
return ok({"success": True})
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -29,6 +29,7 @@ _CN_TZ = timezone(timedelta(hours=8))
|
||||
from database import get_db
|
||||
from models import Avatar, TakeoverCursor, TakeoverMessage, TakeoverReplyTask, User
|
||||
from responses import ok, fail
|
||||
from services.boxim_client import BoxIMClient, BoxIMError
|
||||
|
||||
router = APIRouter(tags=["会会账号"])
|
||||
|
||||
@@ -111,6 +112,19 @@ def _cfg_ready() -> bool:
|
||||
return bool(AUTH_BASE_URL and APP_ID and ACCESS_ID and ACCESS_SECRET)
|
||||
|
||||
|
||||
def _create_boxim_client() -> BoxIMClient:
|
||||
return BoxIMClient({
|
||||
"HUIHUI_PLATFORM_BASE_URL": os.getenv(
|
||||
"HUIHUI_PLATFORM_BASE_URL", "https://open.99hui.com/api"
|
||||
),
|
||||
"BOXIM_API_BASE_URL": os.getenv("BOXIM_API_BASE_URL", "https://im.99hui.com/api"),
|
||||
"HUIHUI_APP_ID": APP_ID,
|
||||
"HUIHUI_ACCESS_ID": ACCESS_ID,
|
||||
"HUIHUI_ACCESS_SECRET": ACCESS_SECRET,
|
||||
"BOXIM_TIMEOUT_SECONDS": os.getenv("BOXIM_TIMEOUT_SECONDS", "20"),
|
||||
})
|
||||
|
||||
|
||||
def _call_huihui(path: str, params: dict, as_query: bool = False):
|
||||
"""调用会会接口,返回 (ok: bool, payload: dict, http_status: int)"""
|
||||
url = f"{AUTH_BASE_URL}{path}"
|
||||
@@ -281,6 +295,47 @@ def pwd_login(body: dict = Body(...), db: Session = Depends(get_db)):
|
||||
})
|
||||
|
||||
|
||||
@router.post("/huihui/token/login")
|
||||
async def token_login(body: dict = Body(...), db: Session = Depends(get_db)):
|
||||
"""Validate a production Huihui token through BOXIM and issue an app session."""
|
||||
huihui_token = (body.get("token") or "").strip()
|
||||
if not huihui_token or len(huihui_token) > 8192:
|
||||
return fail("会会登录凭证无效或已过期", 401)
|
||||
if not _cfg_ready():
|
||||
return fail("会会登录服务未配置", 500)
|
||||
|
||||
client = _create_boxim_client()
|
||||
try:
|
||||
token_data = await client.exchange_access_token(huihui_token)
|
||||
profile = await client.get_self(token_data["accessToken"])
|
||||
except BoxIMError as exc:
|
||||
if exc.auth_error:
|
||||
return fail("会会登录凭证无效或已过期", 401)
|
||||
return fail("会会登录服务暂时不可用,请稍后重试", 502)
|
||||
|
||||
# BOXIM's id is its internal IM id. Account ownership must use huihuiUserId.
|
||||
huihui_user_id = str(profile.get("huihuiUserId") or "").strip()
|
||||
if not huihui_user_id:
|
||||
return fail("会会未返回用户标识", 502)
|
||||
|
||||
phone = str(_pick(profile, "mobile", "phone", default="")).strip()
|
||||
nickname = str(_pick(profile, "nickName", "nickname", "name", "userName", default="")).strip()
|
||||
avatar_url = str(
|
||||
_pick(profile, "headImage", "headImageThumb", "avatar", "avatarUrl", default="")
|
||||
).strip()
|
||||
return _issue_session(
|
||||
db,
|
||||
phone,
|
||||
{
|
||||
"userId": huihui_user_id,
|
||||
"nickname": nickname,
|
||||
"avatarUrl": avatar_url,
|
||||
"token": huihui_token,
|
||||
},
|
||||
reuse_existing_session=True,
|
||||
)
|
||||
|
||||
|
||||
def _transfer_avatar_ownership(db: Session, old_owner_id: str, new_owner_id: str) -> int:
|
||||
"""Move one user's avatar-owned data to a replacement Huihui identity."""
|
||||
if not old_owner_id or old_owner_id == new_owner_id:
|
||||
@@ -332,7 +387,13 @@ def _find_or_link_user(db: Session, phone: str, huihui_user_id: str) -> User:
|
||||
return user
|
||||
|
||||
|
||||
def _issue_session(db: Session, phone: str, info: dict):
|
||||
def _issue_session(
|
||||
db: Session,
|
||||
phone: str,
|
||||
info: dict,
|
||||
*,
|
||||
reuse_existing_session: bool = False,
|
||||
):
|
||||
"""建/链本地用户并签发本系统会话 token"""
|
||||
huihui_user_id = info.get("userId", "")
|
||||
user = _find_or_link_user(db, phone, huihui_user_id)
|
||||
@@ -343,12 +404,16 @@ def _issue_session(db: Session, phone: str, info: dict):
|
||||
if info.get("avatarUrl"):
|
||||
user.avatar_url = info["avatarUrl"]
|
||||
user.huihui_token = info.get("token", "")
|
||||
user.app_token = uuid.uuid4().hex
|
||||
if not reuse_existing_session or not user.app_token:
|
||||
user.app_token = uuid.uuid4().hex
|
||||
user.last_login_at = datetime.now()
|
||||
db.add(user)
|
||||
db.commit()
|
||||
db.refresh(user)
|
||||
|
||||
from services.token_billing import get_or_create_account
|
||||
get_or_create_account(db, user.id)
|
||||
|
||||
return ok({
|
||||
"token": user.app_token,
|
||||
"user": user.to_dict(),
|
||||
@@ -356,7 +421,6 @@ def _issue_session(db: Session, phone: str, info: dict):
|
||||
"userId": huihui_user_id,
|
||||
"nickname": info.get("nickname", ""),
|
||||
"avatarUrl": info.get("avatarUrl", ""),
|
||||
"token": info.get("token", ""),
|
||||
},
|
||||
})
|
||||
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import os
|
||||
import json
|
||||
import shutil
|
||||
import time
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from fastapi import APIRouter, UploadFile, File, Depends, Header, HTTPException
|
||||
from pydantic import BaseModel
|
||||
@@ -11,15 +12,19 @@ from database import get_db
|
||||
from models import KnowledgeDoc, QAPair, KnowledgeChunk, Avatar, User
|
||||
from responses import ok, fail
|
||||
import embeddings
|
||||
from services.knowledge_vectorizer import knowledge_vectorizer
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
|
||||
UPLOAD_DIR = os.path.abspath(os.getenv("UPLOAD_DIR", os.path.join(BASE_DIR, "uploads")))
|
||||
os.makedirs(UPLOAD_DIR, exist_ok=True)
|
||||
|
||||
ALLOWED_EXT = {".md", ".txt", ".pdf", ".doc", ".docx", ".xlsx"}
|
||||
MAX_UPLOAD_BYTES = 10 * 1024 * 1024
|
||||
MAX_UPLOAD_BYTES = 50 * 1024 * 1024
|
||||
UPLOAD_CHUNK_BYTES = 1024 * 1024
|
||||
MULTIPART_CHUNK_BYTES = 5 * 1024 * 1024
|
||||
MULTIPART_ROOT = ".multipart"
|
||||
MULTIPART_TTL_SECONDS = 24 * 60 * 60
|
||||
|
||||
|
||||
class QAIn(BaseModel):
|
||||
@@ -32,6 +37,74 @@ class EnabledIn(BaseModel):
|
||||
enabled: bool = True
|
||||
|
||||
|
||||
class MultipartUploadIn(BaseModel):
|
||||
filename: str
|
||||
fileSize: int
|
||||
totalChunks: int
|
||||
|
||||
|
||||
def _validate_document(filename: str, file_size: int):
|
||||
ext = os.path.splitext(filename or "")[1].lower()
|
||||
if ext not in ALLOWED_EXT:
|
||||
return None, f"不支持的文件类型:{ext or '空'},仅支持 md/txt/pdf/doc/docx/xlsx"
|
||||
if file_size <= 0:
|
||||
return None, "文件内容不能为空"
|
||||
if file_size > MAX_UPLOAD_BYTES:
|
||||
return None, "文件不能超过 50MB"
|
||||
return ext, ""
|
||||
|
||||
|
||||
def _multipart_dir(avatar_id: str, upload_id: str) -> str:
|
||||
safe_avatar_id = os.path.basename(avatar_id)
|
||||
safe_upload_id = os.path.basename(upload_id)
|
||||
if (
|
||||
safe_avatar_id != avatar_id
|
||||
or safe_upload_id != upload_id
|
||||
or len(upload_id) != 32
|
||||
or any(character not in "0123456789abcdef" for character in upload_id)
|
||||
):
|
||||
raise HTTPException(status_code=400, detail="上传标识无效")
|
||||
return os.path.join(UPLOAD_DIR, MULTIPART_ROOT, safe_avatar_id, safe_upload_id)
|
||||
|
||||
|
||||
def _purge_stale_multipart_uploads(avatar_id: str):
|
||||
avatar_upload_root = os.path.join(UPLOAD_DIR, MULTIPART_ROOT, os.path.basename(avatar_id))
|
||||
if not os.path.isdir(avatar_upload_root):
|
||||
return
|
||||
cutoff = time.time() - MULTIPART_TTL_SECONDS
|
||||
for entry in os.scandir(avatar_upload_root):
|
||||
if entry.is_dir(follow_symlinks=False) and entry.stat(follow_symlinks=False).st_mtime < cutoff:
|
||||
shutil.rmtree(entry.path, ignore_errors=True)
|
||||
|
||||
|
||||
def _read_multipart_metadata(avatar_id: str, upload_id: str) -> tuple[str, dict]:
|
||||
upload_dir = _multipart_dir(avatar_id, upload_id)
|
||||
metadata_path = os.path.join(upload_dir, "metadata.json")
|
||||
if not os.path.isfile(metadata_path):
|
||||
raise HTTPException(status_code=404, detail="上传任务不存在或已过期")
|
||||
with open(metadata_path, "r", encoding="utf-8") as stream:
|
||||
return upload_dir, json.load(stream)
|
||||
|
||||
|
||||
def _create_knowledge_doc(db: Session, avatar_id: str, filename: str, ext: str, file_size: int, stored: str):
|
||||
doc = KnowledgeDoc(
|
||||
id=uuid.uuid4().hex,
|
||||
avatar_id=avatar_id,
|
||||
filename=filename,
|
||||
file_type=ext.lstrip("."),
|
||||
file_size=file_size,
|
||||
file_url=f"/api/files/{avatar_id}/{stored}",
|
||||
status="parsing",
|
||||
index_stage="queued",
|
||||
index_progress=0,
|
||||
)
|
||||
db.add(doc)
|
||||
db.commit()
|
||||
db.refresh(doc)
|
||||
knowledge_vectorizer.enqueue(doc.id)
|
||||
return doc
|
||||
|
||||
|
||||
def _doc_payload(doc: KnowledgeDoc) -> dict:
|
||||
payload = doc.to_dict()
|
||||
stored_name = os.path.basename(doc.file_url or "")
|
||||
@@ -75,60 +148,172 @@ def list_docs(avatar_id: str, authorization: str = Header(None), db: Session = D
|
||||
@router.post("/avatar/{avatar_id}/knowledge/docs")
|
||||
async def upload_doc(avatar_id: str, file: UploadFile = File(...), authorization: str = Header(None), db: Session = Depends(get_db)):
|
||||
_require_owned_avatar(db, avatar_id, authorization)
|
||||
ext = os.path.splitext(file.filename or "")[1].lower()
|
||||
if ext not in ALLOWED_EXT:
|
||||
return fail(f"不支持的文件类型:{ext or '空'},仅支持 md/txt/pdf/doc/docx/xlsx", code=400)
|
||||
ext, validation_error = _validate_document(file.filename or "", 1)
|
||||
if validation_error:
|
||||
return fail(validation_error, code=400)
|
||||
avatar_dir = os.path.join(UPLOAD_DIR, avatar_id)
|
||||
os.makedirs(avatar_dir, exist_ok=True)
|
||||
stored = f"{uuid.uuid4().hex}{ext}"
|
||||
path = os.path.join(avatar_dir, stored)
|
||||
content = await file.read()
|
||||
if len(content) > MAX_UPLOAD_BYTES:
|
||||
return fail("文件不能超过 10MB", code=400)
|
||||
with open(path, "wb") as f:
|
||||
f.write(content)
|
||||
doc = KnowledgeDoc(
|
||||
avatar_id=avatar_id,
|
||||
filename=file.filename,
|
||||
file_type=ext.lstrip("."),
|
||||
file_size=len(content),
|
||||
file_url=f"/api/files/{avatar_id}/{stored}",
|
||||
status="parsing",
|
||||
file_size = 0
|
||||
try:
|
||||
# Stream large files to disk so a 100MB upload does not occupy 100MB RAM.
|
||||
with open(path, "wb") as f:
|
||||
while chunk := await file.read(UPLOAD_CHUNK_BYTES):
|
||||
file_size += len(chunk)
|
||||
if file_size > MAX_UPLOAD_BYTES:
|
||||
raise ValueError("文件不能超过 50MB")
|
||||
f.write(chunk)
|
||||
except ValueError as exc:
|
||||
if os.path.exists(path):
|
||||
os.remove(path)
|
||||
return fail(str(exc), code=400)
|
||||
if file_size == 0:
|
||||
if os.path.exists(path):
|
||||
os.remove(path)
|
||||
return fail("文件内容不能为空", code=400)
|
||||
|
||||
doc = _create_knowledge_doc(db, avatar_id, file.filename or stored, ext, file_size, stored)
|
||||
return ok(_doc_payload(doc))
|
||||
|
||||
|
||||
@router.post("/avatar/{avatar_id}/knowledge/uploads")
|
||||
def create_multipart_upload(
|
||||
avatar_id: str,
|
||||
body: MultipartUploadIn,
|
||||
authorization: str = Header(None),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
_require_owned_avatar(db, avatar_id, authorization)
|
||||
ext, validation_error = _validate_document(body.filename, body.fileSize)
|
||||
if validation_error:
|
||||
return fail(validation_error, code=400)
|
||||
expected_chunks = (body.fileSize + MULTIPART_CHUNK_BYTES - 1) // MULTIPART_CHUNK_BYTES
|
||||
if body.totalChunks != expected_chunks:
|
||||
return fail("文件分片数量不正确", code=400)
|
||||
|
||||
_purge_stale_multipart_uploads(avatar_id)
|
||||
upload_id = uuid.uuid4().hex
|
||||
upload_dir = _multipart_dir(avatar_id, upload_id)
|
||||
os.makedirs(upload_dir, exist_ok=False)
|
||||
metadata = {
|
||||
"filename": body.filename,
|
||||
"fileSize": body.fileSize,
|
||||
"totalChunks": body.totalChunks,
|
||||
"extension": ext,
|
||||
}
|
||||
with open(os.path.join(upload_dir, "metadata.json"), "w", encoding="utf-8") as stream:
|
||||
json.dump(metadata, stream, ensure_ascii=False)
|
||||
return ok({"uploadId": upload_id, "chunkSize": MULTIPART_CHUNK_BYTES})
|
||||
|
||||
|
||||
@router.post("/avatar/{avatar_id}/knowledge/uploads/{upload_id}/chunks/{chunk_index}")
|
||||
async def upload_multipart_chunk(
|
||||
avatar_id: str,
|
||||
upload_id: str,
|
||||
chunk_index: int,
|
||||
file: UploadFile = File(...),
|
||||
authorization: str = Header(None),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
_require_owned_avatar(db, avatar_id, authorization)
|
||||
upload_dir, metadata = _read_multipart_metadata(avatar_id, upload_id)
|
||||
total_chunks = int(metadata["totalChunks"])
|
||||
if chunk_index < 0 or chunk_index >= total_chunks:
|
||||
return fail("文件分片序号不正确", code=400)
|
||||
|
||||
expected_size = min(
|
||||
MULTIPART_CHUNK_BYTES,
|
||||
int(metadata["fileSize"]) - chunk_index * MULTIPART_CHUNK_BYTES,
|
||||
)
|
||||
db.add(doc)
|
||||
part_path = os.path.join(upload_dir, f"{chunk_index}.part")
|
||||
temporary_path = f"{part_path}.uploading"
|
||||
received = 0
|
||||
try:
|
||||
with open(temporary_path, "wb") as stream:
|
||||
while chunk := await file.read(UPLOAD_CHUNK_BYTES):
|
||||
received += len(chunk)
|
||||
if received > expected_size:
|
||||
raise ValueError("文件分片大小不正确")
|
||||
stream.write(chunk)
|
||||
if received != expected_size:
|
||||
raise ValueError("文件分片大小不正确")
|
||||
os.replace(temporary_path, part_path)
|
||||
except ValueError as exc:
|
||||
if os.path.exists(temporary_path):
|
||||
os.remove(temporary_path)
|
||||
return fail(str(exc), code=400)
|
||||
return ok({"chunkIndex": chunk_index, "uploadedBytes": received})
|
||||
|
||||
|
||||
@router.post("/avatar/{avatar_id}/knowledge/uploads/{upload_id}/complete")
|
||||
def complete_multipart_upload(
|
||||
avatar_id: str,
|
||||
upload_id: str,
|
||||
authorization: str = Header(None),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
_require_owned_avatar(db, avatar_id, authorization)
|
||||
upload_dir, metadata = _read_multipart_metadata(avatar_id, upload_id)
|
||||
total_chunks = int(metadata["totalChunks"])
|
||||
part_paths = [os.path.join(upload_dir, f"{index}.part") for index in range(total_chunks)]
|
||||
if not all(os.path.isfile(path) for path in part_paths):
|
||||
return fail("文件分片尚未上传完整", code=400)
|
||||
if sum(os.path.getsize(path) for path in part_paths) != int(metadata["fileSize"]):
|
||||
return fail("文件分片总大小不正确", code=400)
|
||||
|
||||
avatar_dir = os.path.join(UPLOAD_DIR, avatar_id)
|
||||
os.makedirs(avatar_dir, exist_ok=True)
|
||||
stored = f"{uuid.uuid4().hex}{metadata['extension']}"
|
||||
final_path = os.path.join(avatar_dir, stored)
|
||||
temporary_path = f"{final_path}.assembling"
|
||||
try:
|
||||
with open(temporary_path, "wb") as output:
|
||||
for part_path in part_paths:
|
||||
with open(part_path, "rb") as source:
|
||||
shutil.copyfileobj(source, output, UPLOAD_CHUNK_BYTES)
|
||||
os.replace(temporary_path, final_path)
|
||||
doc = _create_knowledge_doc(
|
||||
db,
|
||||
avatar_id,
|
||||
metadata["filename"],
|
||||
metadata["extension"],
|
||||
int(metadata["fileSize"]),
|
||||
stored,
|
||||
)
|
||||
except Exception:
|
||||
if os.path.exists(temporary_path):
|
||||
os.remove(temporary_path)
|
||||
raise
|
||||
shutil.rmtree(upload_dir, ignore_errors=True)
|
||||
return ok(_doc_payload(doc))
|
||||
|
||||
|
||||
@router.post("/avatar/{avatar_id}/knowledge/docs/{doc_id}/retry")
|
||||
def retry_doc(avatar_id: str, doc_id: str, authorization: str = Header(None), db: Session = Depends(get_db)):
|
||||
_require_owned_avatar(db, avatar_id, authorization)
|
||||
doc = db.query(KnowledgeDoc).filter(
|
||||
KnowledgeDoc.id == doc_id, KnowledgeDoc.avatar_id == avatar_id
|
||||
).first()
|
||||
if not doc:
|
||||
return fail("文档不存在", code=404)
|
||||
if doc.vectorized and doc.status == "ready":
|
||||
return ok(_doc_payload(doc))
|
||||
stored_name = os.path.basename(doc.file_url or "")
|
||||
if not stored_name or not os.path.isfile(os.path.join(UPLOAD_DIR, avatar_id, stored_name)):
|
||||
return fail("原文件不可用,请重新上传", code=400)
|
||||
db.query(KnowledgeChunk).filter(KnowledgeChunk.doc_id == doc.id).delete()
|
||||
doc.status = "parsing"
|
||||
doc.vectorized = False
|
||||
doc.embedding_model = ""
|
||||
doc.chunk_count = 0
|
||||
doc.vectorized_at = None
|
||||
doc.error_message = ""
|
||||
doc.index_stage = "queued"
|
||||
doc.index_progress = 0
|
||||
db.commit()
|
||||
db.refresh(doc)
|
||||
|
||||
# 向量化:抽取文本 -> 分块 -> 调第三方/本地嵌入 -> 存切片
|
||||
try:
|
||||
text = embeddings.extract_text(path, ext)
|
||||
chunks = embeddings.chunk_text(text)
|
||||
if chunks:
|
||||
vectors = embeddings.embed(chunks)
|
||||
for i, (c, v) in enumerate(zip(chunks, vectors)):
|
||||
db.add(
|
||||
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.embedding_model = embeddings.MODEL
|
||||
doc.chunk_count = len(chunks)
|
||||
doc.vectorized_at = datetime.now(timezone.utc)
|
||||
doc.status = "ready"
|
||||
db.commit()
|
||||
db.refresh(doc)
|
||||
except Exception as e:
|
||||
print("vectorize failed:", e)
|
||||
doc.status = "ready" # 上传成功但向量化失败,仍可展示
|
||||
db.commit()
|
||||
db.refresh(doc)
|
||||
|
||||
knowledge_vectorizer.enqueue(doc.id)
|
||||
return ok(_doc_payload(doc))
|
||||
|
||||
|
||||
|
||||
@@ -8,13 +8,25 @@ from sqlalchemy.orm import Session
|
||||
from database import get_db
|
||||
from models import TakeoverCursor, TakeoverReplyTask, User
|
||||
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
|
||||
|
||||
router = APIRouter(tags=["分身接管"])
|
||||
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")
|
||||
def get_takeover_status(
|
||||
avatar_id: str,
|
||||
@@ -24,6 +36,7 @@ def get_takeover_status(
|
||||
avatar = _require_owned_avatar(db, avatar_id, authorization)
|
||||
permissions = (avatar.config or {}).get("authorizationPermissions", [])
|
||||
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()
|
||||
cursor = db.query(TakeoverCursor).filter(TakeoverCursor.avatar_id == avatar.id).first()
|
||||
pending_count = (
|
||||
@@ -49,7 +62,10 @@ def get_takeover_status(
|
||||
and cursor.last_polled_at
|
||||
>= datetime.utcnow() - timedelta(seconds=BOXIM_STATUS_FRESH_SECONDS)
|
||||
):
|
||||
status, message = "ready", "BOXIM 已连接,收到私聊消息 3 秒后自动回复"
|
||||
status, message = (
|
||||
"ready",
|
||||
f"BOXIM 已连接,收到私聊消息 {_delay_label(reply_delay_seconds)}后自动回复",
|
||||
)
|
||||
else:
|
||||
status, message = "connecting", "正在连接 BOXIM"
|
||||
|
||||
@@ -59,6 +75,7 @@ def get_takeover_status(
|
||||
"status": status,
|
||||
"message": message,
|
||||
"pendingCount": pending_count,
|
||||
"takeoverReplyDelaySeconds": reply_delay_seconds,
|
||||
"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))
|
||||
enabled = bool(auth.takeover_enabled)
|
||||
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"):
|
||||
raw_enabled = _read(payload, "takeoverEnabled", "takeover_enabled")
|
||||
@@ -106,8 +123,12 @@ def update_takeover_config(
|
||||
|
||||
if _has(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:
|
||||
return fail("延迟时间需在 5 到 3600 秒之间", 400)
|
||||
if (
|
||||
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":
|
||||
return fail("本期仅支持对会会用户开启单聊接管", 400)
|
||||
|
||||
@@ -1,37 +1,914 @@
|
||||
from fastapi import APIRouter, Depends, Body
|
||||
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, Query, Request, Response
|
||||
from sqlalchemy import func
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from database import get_db
|
||||
from models import TokenAccount, TokenPlan
|
||||
from responses import ok, fail
|
||||
from models import (
|
||||
InvoiceApplication,
|
||||
PaymentRefund,
|
||||
PaymentTransaction,
|
||||
TokenAccount,
|
||||
TokenPaymentOrder,
|
||||
TokenPlan,
|
||||
TokenUsage,
|
||||
User,
|
||||
)
|
||||
from responses import fail, ok
|
||||
from services.huihui_payment import HuihuiPaymentClient, HuihuiPaymentError
|
||||
from services.token_billing import get_or_create_account
|
||||
from services.wechat_virtual_payment import (
|
||||
PAYMENT_EVENTS as WECHAT_PAYMENT_EVENTS,
|
||||
REFUND_EVENTS as WECHAT_REFUND_EVENTS,
|
||||
WechatVirtualPaymentError,
|
||||
build_payment_params as build_wechat_virtual_payment_params,
|
||||
callback_value as wechat_callback_value,
|
||||
exchange_code as exchange_wechat_code,
|
||||
parse_callback_body as parse_wechat_callback_body,
|
||||
product_id_for_plan,
|
||||
query_order as query_wechat_virtual_order,
|
||||
request_refund as request_wechat_virtual_refund,
|
||||
verify_callback_signature as verify_wechat_callback_signature,
|
||||
virtual_env as wechat_virtual_env,
|
||||
)
|
||||
|
||||
router = APIRouter(tags=["Token"])
|
||||
|
||||
PAYMENT_METHODS = {"wechat": "WECHAT", "alipay": "ALIPAY"}
|
||||
PAYMENT_SCENES = {"APP", "H5", "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:
|
||||
if not authorization:
|
||||
raise HTTPException(status_code=401, detail="未登录")
|
||||
token = authorization.replace("Bearer ", "", 1).replace("bearer ", "", 1).strip()
|
||||
user = db.query(User).filter(User.app_token == token).first()
|
||||
if not user:
|
||||
raise HTTPException(status_code=401, detail="会话无效或已过期")
|
||||
return user
|
||||
|
||||
|
||||
def _require_finance_admin(value: str | None):
|
||||
expected = os.getenv("AVATAR_FINANCE_ADMIN_SECRET", "").strip()
|
||||
provided = str(value or "").strip()
|
||||
if len(expected) < 16 or not hmac.compare_digest(provided, expected):
|
||||
raise HTTPException(status_code=403, detail="财务管理凭证无效")
|
||||
|
||||
|
||||
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 _safe_event_summary(payload: dict) -> str:
|
||||
"""Persist only reconciliation fields, never signatures, tokens or session keys."""
|
||||
summary = {}
|
||||
for key in (
|
||||
"Event", "OutTradeNo", "OpenId", "Env", "MchOrderId", "MchRefundId",
|
||||
"WxRefundId", "RefundFee", "RetCode", "RetMsg",
|
||||
):
|
||||
value = wechat_callback_value(payload, key)
|
||||
if value not in (None, ""):
|
||||
summary[key] = value
|
||||
goods = wechat_callback_value(payload, "GoodsInfo")
|
||||
if isinstance(goods, dict):
|
||||
summary["GoodsInfo"] = {
|
||||
key: goods.get(key)
|
||||
for key in ("ProductId", "Quantity", "OrigPrice", "ActualPrice")
|
||||
if goods.get(key) not in (None, "")
|
||||
}
|
||||
return json.dumps(summary, ensure_ascii=False, separators=(",", ":"))[:2000]
|
||||
|
||||
|
||||
def _record_transaction(
|
||||
db: Session,
|
||||
*,
|
||||
order: TokenPaymentOrder,
|
||||
provider: str,
|
||||
status: str,
|
||||
amount_cents: int,
|
||||
event_type: str = "payment",
|
||||
transaction_no: str = "",
|
||||
raw_summary: str = "",
|
||||
):
|
||||
if transaction_no:
|
||||
duplicate = db.query(PaymentTransaction).filter(
|
||||
PaymentTransaction.provider == provider,
|
||||
PaymentTransaction.transaction_no == transaction_no,
|
||||
PaymentTransaction.event_type == event_type,
|
||||
).first()
|
||||
if duplicate:
|
||||
return duplicate
|
||||
row = PaymentTransaction(
|
||||
order_no=order.order_no,
|
||||
provider=provider,
|
||||
transaction_no=transaction_no,
|
||||
event_type=event_type,
|
||||
status=status,
|
||||
amount_cents=amount_cents,
|
||||
raw_summary=raw_summary,
|
||||
)
|
||||
db.add(row)
|
||||
return row
|
||||
|
||||
|
||||
def _settle_paid_order(
|
||||
db: Session,
|
||||
order: TokenPaymentOrder,
|
||||
*,
|
||||
provider_status: str,
|
||||
transaction_no: str = "",
|
||||
raw_summary: str = "",
|
||||
) -> bool:
|
||||
if order.status in {"paid", "refunded"}:
|
||||
return False
|
||||
updated = db.query(TokenPaymentOrder).filter(
|
||||
TokenPaymentOrder.id == order.id,
|
||||
TokenPaymentOrder.status.in_(["pending", "failed", "closed"]),
|
||||
).update({
|
||||
TokenPaymentOrder.status: "paid",
|
||||
TokenPaymentOrder.provider_status: provider_status,
|
||||
TokenPaymentOrder.paid_at: datetime.utcnow(),
|
||||
TokenPaymentOrder.failure_reason: "",
|
||||
}, synchronize_session=False)
|
||||
if not updated:
|
||||
return False
|
||||
account = get_or_create_account(db, order.user_id)
|
||||
account.balance = int(account.balance or 0) + order.points_amount
|
||||
account.total_granted = int(account.total_granted or 0) + order.points_amount
|
||||
_record_transaction(
|
||||
db,
|
||||
order=order,
|
||||
provider=order.provider,
|
||||
status="paid",
|
||||
amount_cents=order.price_cents,
|
||||
transaction_no=transaction_no,
|
||||
raw_summary=raw_summary,
|
||||
)
|
||||
return True
|
||||
|
||||
|
||||
def _complete_refund(
|
||||
db: Session,
|
||||
order: TokenPaymentOrder,
|
||||
refund: PaymentRefund,
|
||||
*,
|
||||
provider_refund_no: str = "",
|
||||
failure_reason: str = "",
|
||||
):
|
||||
if failure_reason:
|
||||
refund.status = "failed"
|
||||
refund.failure_reason = failure_reason[:500]
|
||||
order.refund_status = "failed"
|
||||
return
|
||||
if refund.status == "succeeded":
|
||||
return
|
||||
account = get_or_create_account(db, order.user_id)
|
||||
# Provider-confirmed refunds must claw back the full grant. A negative
|
||||
# balance records consumed refunded points and blocks further usage.
|
||||
account.balance = int(account.balance or 0) - int(refund.points_amount or 0)
|
||||
account.total_granted = max(0, int(account.total_granted or 0) - int(refund.points_amount or 0))
|
||||
refund.status = "succeeded"
|
||||
refund.provider_refund_no = provider_refund_no[:128]
|
||||
refund.failure_reason = ""
|
||||
refund.completed_at = datetime.utcnow()
|
||||
order.status = "refunded"
|
||||
order.refund_status = "succeeded"
|
||||
order.refunded_at = datetime.utcnow()
|
||||
_record_transaction(
|
||||
db,
|
||||
order=order,
|
||||
provider=order.provider,
|
||||
status="succeeded",
|
||||
amount_cents=refund.amount_cents,
|
||||
event_type="refund",
|
||||
transaction_no=provider_refund_no or refund.refund_no,
|
||||
)
|
||||
|
||||
|
||||
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")
|
||||
def balance(db: Session = Depends(get_db)):
|
||||
acc = db.query(TokenAccount).first()
|
||||
return ok({"balance": acc.balance if acc else 0})
|
||||
def balance(authorization: str = Header(None), db: Session = Depends(get_db)):
|
||||
user = _require_user(authorization, db)
|
||||
acc = get_or_create_account(db, user.id)
|
||||
return ok({
|
||||
"balance": acc.balance,
|
||||
"totalGranted": acc.total_granted,
|
||||
"totalConsumed": acc.total_consumed,
|
||||
})
|
||||
|
||||
|
||||
@router.get("/token/plans")
|
||||
def plans(db: Session = Depends(get_db)):
|
||||
def plans(authorization: str = Header(None), db: Session = Depends(get_db)):
|
||||
_require_user(authorization, db)
|
||||
items = db.query(TokenPlan).order_by(TokenPlan.price.asc()).all()
|
||||
return ok([p.to_dict() for p in items])
|
||||
|
||||
|
||||
# 积分只会在会会支付回调确认成功后到账。
|
||||
@router.post("/token/charge")
|
||||
def charge(payload: dict = Body(...), db: Session = Depends(get_db)):
|
||||
plan_id = payload.get("planId")
|
||||
plan = db.query(TokenPlan).filter(TokenPlan.id == plan_id).first()
|
||||
def charge(payload: dict = Body(...), authorization: str = Header(None), db: Session = Depends(get_db)):
|
||||
user = _require_user(authorization, db)
|
||||
plan = db.query(TokenPlan).filter(TokenPlan.id == payload.get("planId")).first()
|
||||
if not plan:
|
||||
return fail("套餐不存在", 404)
|
||||
acc = db.query(TokenAccount).first()
|
||||
if not acc:
|
||||
acc = TokenAccount(balance=0)
|
||||
db.add(acc)
|
||||
db.commit()
|
||||
db.refresh(acc)
|
||||
acc.balance += plan.amount
|
||||
|
||||
payment_method = str(payload.get("paymentMethod") or "").lower()
|
||||
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)
|
||||
if pay_way == "LITE" and payment_method != "wechat":
|
||||
return fail("微信小程序虚拟支付仅支持微信支付", 400)
|
||||
|
||||
cents = _price_cents(plan.price)
|
||||
provider = "wechat_virtual" if pay_way == "LITE" else "huihui"
|
||||
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",
|
||||
provider=provider,
|
||||
)
|
||||
db.add(order)
|
||||
db.commit()
|
||||
return ok({"balance": acc.balance, "charged": plan.amount})
|
||||
|
||||
if provider == "wechat_virtual":
|
||||
if not user.wechat_mp_openid or not user.wechat_mp_session_key:
|
||||
order.status = "failed"
|
||||
order.failure_reason = "微信小程序登录态尚未准备好,请重新进入支付页"
|
||||
db.commit()
|
||||
return fail(order.failure_reason, 409)
|
||||
try:
|
||||
result = build_wechat_virtual_payment_params(
|
||||
order=order,
|
||||
plan=plan,
|
||||
session_key=user.wechat_mp_session_key,
|
||||
)
|
||||
except WechatVirtualPaymentError as exc:
|
||||
order.status = "failed"
|
||||
order.failure_reason = str(exc)[:500]
|
||||
db.commit()
|
||||
return fail(str(exc), 503)
|
||||
order.provider_order_id = order.order_no
|
||||
order.provider_order_no = order.order_no
|
||||
order.provider_status = "CREATED"
|
||||
order.pay_message = json.dumps(result, ensure_ascii=False, separators=(",", ":"))
|
||||
db.commit()
|
||||
return ok(_payment_payload(order, get_or_create_account(db, user.id)))
|
||||
|
||||
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)
|
||||
if order.provider == "wechat_virtual" and order.status == "pending" and user.wechat_mp_openid:
|
||||
try:
|
||||
provider_data = query_wechat_virtual_order(
|
||||
openid=user.wechat_mp_openid,
|
||||
order_no=order.order_no,
|
||||
)
|
||||
provider_order = provider_data.get("order") or {}
|
||||
provider_status = int(provider_order.get("status", 0) or 0)
|
||||
paid_cents = int(provider_order.get("paid_fee") or provider_order.get("order_fee") or 0)
|
||||
order.provider_status = str(provider_status)
|
||||
if provider_status in {2, 3, 4} and paid_cents == order.price_cents:
|
||||
_settle_paid_order(
|
||||
db,
|
||||
order,
|
||||
provider_status=f"XPAY_{provider_status}",
|
||||
transaction_no=str(
|
||||
provider_order.get("wxpay_order_id")
|
||||
or provider_order.get("channel_order_id")
|
||||
or order.order_no
|
||||
),
|
||||
)
|
||||
elif provider_status == 6:
|
||||
order.status = "failed"
|
||||
order.failure_reason = "微信虚拟支付订单已关闭"
|
||||
db.commit()
|
||||
db.refresh(order)
|
||||
except WechatVirtualPaymentError:
|
||||
# 回调仍是首选确认路径;短暂查询失败不覆盖订单状态。
|
||||
pass
|
||||
return ok(_payment_payload(order, get_or_create_account(db, user.id)))
|
||||
|
||||
|
||||
@router.post("/token/wechat/session")
|
||||
def bind_wechat_session(
|
||||
payload: dict = Body(...),
|
||||
authorization: str = Header(None),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
user = _require_user(authorization, db)
|
||||
code = str(payload.get("code") or "").strip()
|
||||
if not code or len(code) > 256:
|
||||
return fail("微信登录凭证无效", 400)
|
||||
try:
|
||||
session = exchange_wechat_code(code)
|
||||
except WechatVirtualPaymentError as exc:
|
||||
return fail(str(exc), 502)
|
||||
|
||||
conflict = db.query(User).filter(
|
||||
User.wechat_mp_openid == session["openid"],
|
||||
User.id != user.id,
|
||||
).first()
|
||||
if conflict:
|
||||
return fail("该微信账号已绑定其他会会账号", 409)
|
||||
user.wechat_mp_openid = session["openid"]
|
||||
user.wechat_mp_session_key = session["session_key"]
|
||||
db.commit()
|
||||
return ok({"ready": True})
|
||||
|
||||
|
||||
@router.get("/token/orders")
|
||||
def list_user_orders(
|
||||
page: int = Query(1, ge=1),
|
||||
page_size: int = Query(20, ge=1, le=100),
|
||||
authorization: str = Header(None),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
user = _require_user(authorization, db)
|
||||
query = db.query(TokenPaymentOrder).filter(TokenPaymentOrder.user_id == user.id)
|
||||
total = query.count()
|
||||
orders = query.order_by(TokenPaymentOrder.created_at.desc()).offset((page - 1) * page_size).limit(page_size).all()
|
||||
invoice_by_order = {
|
||||
item.order_no: item.to_dict()
|
||||
for item in db.query(InvoiceApplication).filter(
|
||||
InvoiceApplication.order_no.in_([order.order_no for order in orders])
|
||||
).all()
|
||||
} if orders else {}
|
||||
return ok({
|
||||
"total": total,
|
||||
"page": page,
|
||||
"pageSize": page_size,
|
||||
"items": [
|
||||
{**_payment_payload(order, get_or_create_account(db, user.id)), "invoice": invoice_by_order.get(order.order_no)}
|
||||
for order in orders
|
||||
],
|
||||
})
|
||||
|
||||
|
||||
@router.post("/token/orders/{order_no}/invoice")
|
||||
def apply_invoice(
|
||||
order_no: str,
|
||||
payload: dict = Body(...),
|
||||
authorization: str = Header(None),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
user = _require_user(authorization, db)
|
||||
order = db.query(TokenPaymentOrder).filter(
|
||||
TokenPaymentOrder.order_no == order_no,
|
||||
TokenPaymentOrder.user_id == user.id,
|
||||
).first()
|
||||
if not order:
|
||||
return fail("订单不存在", 404)
|
||||
if order.status != "paid" or order.refund_status not in {"", "none"}:
|
||||
return fail("只有已支付且未退款的订单可以申请发票", 409)
|
||||
title = str(payload.get("title") or "").strip()
|
||||
invoice_type = str(payload.get("invoiceType") or "personal").strip().lower()
|
||||
tax_number = str(payload.get("taxNumber") or "").strip().upper()
|
||||
email = str(payload.get("email") or "").strip()
|
||||
if not title or len(title) > 120:
|
||||
return fail("请填写正确的发票抬头", 400)
|
||||
if invoice_type not in {"personal", "company"}:
|
||||
return fail("发票类型不正确", 400)
|
||||
if invoice_type == "company" and (len(tax_number) < 15 or len(tax_number) > 20):
|
||||
return fail("请填写正确的企业税号", 400)
|
||||
if email and ("@" not in email or len(email) > 160):
|
||||
return fail("请填写正确的接收邮箱", 400)
|
||||
|
||||
invoice = db.query(InvoiceApplication).filter(InvoiceApplication.order_no == order_no).first()
|
||||
if invoice and invoice.status not in {"rejected", "cancelled"}:
|
||||
return fail("该订单已申请发票", 409)
|
||||
if invoice is None:
|
||||
invoice = InvoiceApplication(order_no=order_no, user_id=user.id, amount_cents=order.price_cents)
|
||||
db.add(invoice)
|
||||
invoice.title = title
|
||||
invoice.invoice_type = invoice_type
|
||||
invoice.tax_number = tax_number if invoice_type == "company" else ""
|
||||
invoice.email = email
|
||||
invoice.status = "pending"
|
||||
invoice.remark = ""
|
||||
db.commit()
|
||||
db.refresh(invoice)
|
||||
return ok(invoice.to_dict())
|
||||
|
||||
|
||||
@router.post("/token/admin/orders/{order_no}/refund")
|
||||
def admin_request_refund(
|
||||
order_no: str,
|
||||
payload: dict = Body(...),
|
||||
finance_key: str = Header(None, alias="X-Avatar-Finance-Key"),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
_require_finance_admin(finance_key)
|
||||
order = db.query(TokenPaymentOrder).filter(TokenPaymentOrder.order_no == order_no).first()
|
||||
if not order:
|
||||
return fail("订单不存在", 404)
|
||||
if order.status != "paid" or order.refund_status not in {"", "none", "failed"}:
|
||||
return fail("该订单当前不可退款", 409)
|
||||
account = get_or_create_account(db, order.user_id)
|
||||
if int(account.balance or 0) < int(order.points_amount or 0):
|
||||
return fail("该订单发放的积分已使用,不能执行全额退款", 409)
|
||||
invoice = db.query(InvoiceApplication).filter(InvoiceApplication.order_no == order.order_no).first()
|
||||
if invoice and invoice.status == "issued":
|
||||
return fail("该订单发票已开具,请先完成红冲再退款", 409)
|
||||
reason = str(payload.get("reason") or "后台退款").strip()
|
||||
if not reason or len(reason) > 200:
|
||||
return fail("请填写 200 字以内的退款原因", 400)
|
||||
|
||||
refund = PaymentRefund(
|
||||
refund_no=f"RF{datetime.utcnow().strftime('%Y%m%d%H%M%S')}{uuid.uuid4().hex[:10].upper()}",
|
||||
order_no=order.order_no,
|
||||
amount_cents=order.price_cents,
|
||||
points_amount=order.points_amount,
|
||||
reason=reason,
|
||||
status="processing",
|
||||
requested_by=str(payload.get("operator") or "admin")[:80],
|
||||
)
|
||||
claimed = db.query(TokenPaymentOrder).filter(
|
||||
TokenPaymentOrder.id == order.id,
|
||||
TokenPaymentOrder.status == "paid",
|
||||
TokenPaymentOrder.refund_status.in_(["", "none", "failed"]),
|
||||
).update({TokenPaymentOrder.refund_status: "processing"}, synchronize_session=False)
|
||||
if not claimed:
|
||||
db.rollback()
|
||||
return fail("该订单已有退款任务正在处理", 409)
|
||||
db.add(refund)
|
||||
if invoice and invoice.status == "pending":
|
||||
invoice.status = "cancelled"
|
||||
invoice.remark = "订单已申请退款,发票申请自动取消"
|
||||
db.commit()
|
||||
|
||||
user = db.query(User).filter(User.id == order.user_id).first()
|
||||
try:
|
||||
if order.provider == "wechat_virtual":
|
||||
if not user or not user.wechat_mp_openid:
|
||||
raise WechatVirtualPaymentError("订单缺少微信 OpenID,无法退款")
|
||||
provider_result = request_wechat_virtual_refund(
|
||||
openid=user.wechat_mp_openid,
|
||||
order_no=order.order_no,
|
||||
refund_no=refund.refund_no,
|
||||
amount_cents=refund.amount_cents,
|
||||
)
|
||||
else:
|
||||
provider_result = _payment_client().request_refund(
|
||||
huihui_token=user.huihui_token if user else "",
|
||||
huihui_user_id=user.huihui_user_id if user else "",
|
||||
order_no=order.order_no,
|
||||
refund_no=refund.refund_no,
|
||||
amount=f"{refund.amount_cents / 100:.2f}",
|
||||
reason=reason,
|
||||
)
|
||||
except (WechatVirtualPaymentError, HuihuiPaymentError) as exc:
|
||||
_complete_refund(db, order, refund, failure_reason=str(exc))
|
||||
db.commit()
|
||||
return fail(str(exc), 502)
|
||||
|
||||
provider_status = str(
|
||||
provider_result.get("status")
|
||||
or provider_result.get("refundStatus")
|
||||
or provider_result.get("result")
|
||||
or "PROCESSING"
|
||||
).upper()
|
||||
provider_refund_no = str(
|
||||
provider_result.get("refundNo")
|
||||
or provider_result.get("refundId")
|
||||
or provider_result.get("wx_refund_id")
|
||||
or ""
|
||||
)
|
||||
refund.provider_refund_no = provider_refund_no[:128]
|
||||
if provider_status in {"SUCCESS", "SUCCEEDED", "REFUNDED", "COMPLETED"}:
|
||||
_complete_refund(db, order, refund, provider_refund_no=provider_refund_no)
|
||||
db.commit()
|
||||
db.refresh(refund)
|
||||
return ok(refund.to_dict())
|
||||
|
||||
|
||||
@router.post("/token/admin/refunds/{refund_no}/confirm")
|
||||
def admin_confirm_refund(
|
||||
refund_no: str,
|
||||
payload: dict = Body(...),
|
||||
finance_key: str = Header(None, alias="X-Avatar-Finance-Key"),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""Record a provider-console reconciliation result for asynchronous refunds."""
|
||||
_require_finance_admin(finance_key)
|
||||
refund = db.query(PaymentRefund).filter(PaymentRefund.refund_no == refund_no).first()
|
||||
if not refund:
|
||||
return fail("退款单不存在", 404)
|
||||
order = db.query(TokenPaymentOrder).filter(TokenPaymentOrder.order_no == refund.order_no).first()
|
||||
if not order:
|
||||
return fail("原支付订单不存在", 404)
|
||||
status = str(payload.get("status") or "").lower()
|
||||
if status == "succeeded":
|
||||
_complete_refund(
|
||||
db,
|
||||
order,
|
||||
refund,
|
||||
provider_refund_no=str(payload.get("providerRefundNo") or refund.provider_refund_no or ""),
|
||||
)
|
||||
elif status == "failed":
|
||||
_complete_refund(
|
||||
db,
|
||||
order,
|
||||
refund,
|
||||
failure_reason=str(payload.get("failureReason") or "供应商退款失败"),
|
||||
)
|
||||
else:
|
||||
return fail("退款确认状态只能是 succeeded 或 failed", 400)
|
||||
db.commit()
|
||||
db.refresh(refund)
|
||||
return ok(refund.to_dict())
|
||||
|
||||
|
||||
@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})
|
||||
if order.status == "refunded":
|
||||
return ok({"received": True, "duplicate": True, "refunded": 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]
|
||||
_record_transaction(
|
||||
db,
|
||||
order=order,
|
||||
provider="huihui",
|
||||
status="failed",
|
||||
amount_cents=order.price_cents,
|
||||
transaction_no=str(_find_value(payload, "transactionId", "tradeNo") or ""),
|
||||
)
|
||||
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)
|
||||
|
||||
_settle_paid_order(
|
||||
db,
|
||||
order,
|
||||
provider_status=provider_status,
|
||||
transaction_no=str(_find_value(payload, "transactionId", "tradeNo", "paymentNo") or ""),
|
||||
)
|
||||
db.commit()
|
||||
return ok({"received": True, "paid": True})
|
||||
|
||||
|
||||
def _wechat_notify_response(request: Request, *, success: bool, message: str = ""):
|
||||
code = 0 if success else 1
|
||||
text = "success" if success else (message or "fail")[:200].replace("]]>", "")
|
||||
if "xml" in (request.headers.get("content-type") or "").lower():
|
||||
return Response(
|
||||
content=f"<xml><ErrCode>{code}</ErrCode><ErrMsg><![CDATA[{text}]]></ErrMsg></xml>",
|
||||
media_type="application/xml",
|
||||
)
|
||||
return {"ErrCode": code, "ErrMsg": text}
|
||||
|
||||
|
||||
@router.get("/token/payment/wechat/virtual/notify")
|
||||
def validate_wechat_virtual_notify(
|
||||
signature: str = Query(""),
|
||||
timestamp: str = Query(""),
|
||||
nonce: str = Query(""),
|
||||
echostr: str = Query(""),
|
||||
):
|
||||
if not verify_wechat_callback_signature(signature, timestamp, nonce):
|
||||
raise HTTPException(status_code=403, detail="invalid signature")
|
||||
return Response(content=echostr or "ok", media_type="text/plain")
|
||||
|
||||
|
||||
@router.post("/token/payment/wechat/virtual/notify")
|
||||
async def wechat_virtual_notify(
|
||||
request: Request,
|
||||
signature: str = Query(""),
|
||||
timestamp: str = Query(""),
|
||||
nonce: str = Query(""),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
if not verify_wechat_callback_signature(signature, timestamp, nonce):
|
||||
return _wechat_notify_response(request, success=False, message="invalid signature")
|
||||
try:
|
||||
payload = _nested_payload(parse_wechat_callback_body(await request.body()))
|
||||
except WechatVirtualPaymentError as exc:
|
||||
return _wechat_notify_response(request, success=False, message=str(exc))
|
||||
|
||||
event = str(wechat_callback_value(payload, "Event") or "").lower()
|
||||
if event in WECHAT_PAYMENT_EVENTS:
|
||||
order_no = str(wechat_callback_value(payload, "OutTradeNo") or "").strip()
|
||||
order = db.query(TokenPaymentOrder).filter(TokenPaymentOrder.order_no == order_no).first()
|
||||
if not order or order.provider != "wechat_virtual":
|
||||
return _wechat_notify_response(request, success=False, message="order not found")
|
||||
user = db.query(User).filter(User.id == order.user_id).first()
|
||||
openid = str(wechat_callback_value(payload, "OpenId") or "").strip()
|
||||
if not user or not openid or openid != user.wechat_mp_openid:
|
||||
return _wechat_notify_response(request, success=False, message="openid mismatch")
|
||||
try:
|
||||
callback_env = int(wechat_callback_value(payload, "Env"))
|
||||
actual_price = int(wechat_callback_value(payload, "GoodsInfo", "ActualPrice"))
|
||||
except (TypeError, ValueError):
|
||||
return _wechat_notify_response(request, success=False, message="invalid payment amount")
|
||||
plan = db.query(TokenPlan).filter(TokenPlan.id == order.plan_id).first()
|
||||
product_id = str(wechat_callback_value(payload, "GoodsInfo", "ProductId") or "")
|
||||
try:
|
||||
expected_product_id = product_id_for_plan(plan) if plan else ""
|
||||
except WechatVirtualPaymentError:
|
||||
expected_product_id = ""
|
||||
if (
|
||||
callback_env != wechat_virtual_env()
|
||||
or actual_price != order.price_cents
|
||||
or not expected_product_id
|
||||
or product_id != expected_product_id
|
||||
):
|
||||
return _wechat_notify_response(request, success=False, message="payment verification failed")
|
||||
transaction_no = str(
|
||||
wechat_callback_value(payload, "WeChatPayInfo", "TransactionId")
|
||||
or wechat_callback_value(payload, "WeChatPayInfo", "MchOrderNo")
|
||||
or order_no
|
||||
)
|
||||
_settle_paid_order(
|
||||
db,
|
||||
order,
|
||||
provider_status=event,
|
||||
transaction_no=transaction_no,
|
||||
raw_summary=_safe_event_summary(payload),
|
||||
)
|
||||
db.commit()
|
||||
return _wechat_notify_response(request, success=True)
|
||||
|
||||
if event in WECHAT_REFUND_EVENTS:
|
||||
order_no = str(wechat_callback_value(payload, "MchOrderId") or "").strip()
|
||||
refund_no = str(wechat_callback_value(payload, "MchRefundId") or "").strip()
|
||||
order = db.query(TokenPaymentOrder).filter(TokenPaymentOrder.order_no == order_no).first()
|
||||
if not order or order.provider != "wechat_virtual":
|
||||
return _wechat_notify_response(request, success=False, message="order not found")
|
||||
if order.status == "refunded" or order.refund_status == "succeeded":
|
||||
return _wechat_notify_response(request, success=True)
|
||||
try:
|
||||
refund_cents = int(wechat_callback_value(payload, "RefundFee") or 0)
|
||||
result_code_value = wechat_callback_value(payload, "RetCode")
|
||||
if result_code_value in (None, ""):
|
||||
raise ValueError("missing RetCode")
|
||||
result_code = int(result_code_value)
|
||||
except (TypeError, ValueError):
|
||||
return _wechat_notify_response(request, success=False, message="invalid refund")
|
||||
refund = db.query(PaymentRefund).filter(PaymentRefund.refund_no == refund_no).first()
|
||||
if refund is None:
|
||||
refund = PaymentRefund(
|
||||
refund_no=refund_no or f"WR{uuid.uuid4().hex[:20].upper()}",
|
||||
order_no=order.order_no,
|
||||
amount_cents=refund_cents,
|
||||
points_amount=order.points_amount,
|
||||
reason="微信侧退款",
|
||||
status="processing",
|
||||
requested_by="wechat",
|
||||
)
|
||||
db.add(refund)
|
||||
if refund_cents != refund.amount_cents:
|
||||
return _wechat_notify_response(request, success=False, message="refund amount mismatch")
|
||||
if result_code == 0:
|
||||
_complete_refund(
|
||||
db,
|
||||
order,
|
||||
refund,
|
||||
provider_refund_no=str(wechat_callback_value(payload, "WxRefundId") or refund_no),
|
||||
)
|
||||
else:
|
||||
_complete_refund(
|
||||
db,
|
||||
order,
|
||||
refund,
|
||||
failure_reason=str(wechat_callback_value(payload, "RetMsg") or "微信退款失败"),
|
||||
)
|
||||
db.commit()
|
||||
return _wechat_notify_response(request, success=True)
|
||||
|
||||
# Irrelevant official-account events should not be retried as payment failures.
|
||||
return _wechat_notify_response(request, success=True)
|
||||
|
||||
|
||||
@router.get("/token/usage")
|
||||
def usage(authorization: str = Header(None), db: Session = Depends(get_db)):
|
||||
user = _require_user(authorization, db)
|
||||
rows = (
|
||||
db.query(
|
||||
TokenUsage.avatar_id,
|
||||
TokenUsage.source,
|
||||
func.sum(TokenUsage.prompt_tokens),
|
||||
func.sum(TokenUsage.completion_tokens),
|
||||
func.sum(TokenUsage.total_tokens),
|
||||
func.count(TokenUsage.id),
|
||||
)
|
||||
.filter(TokenUsage.user_id == user.id, TokenUsage.status == "completed")
|
||||
.group_by(TokenUsage.avatar_id, TokenUsage.source)
|
||||
.all()
|
||||
)
|
||||
return ok([
|
||||
{
|
||||
"avatarId": avatar_id,
|
||||
"source": source,
|
||||
"promptTokens": int(prompt_tokens or 0),
|
||||
"completionTokens": int(completion_tokens or 0),
|
||||
"totalTokens": int(total_tokens or 0),
|
||||
"requestCount": int(request_count or 0),
|
||||
}
|
||||
for avatar_id, source, prompt_tokens, completion_tokens, total_tokens, request_count in rows
|
||||
])
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
"""Parse and safely download image payloads from BOXIM private messages."""
|
||||
|
||||
import ipaddress
|
||||
import json
|
||||
import os
|
||||
import socket
|
||||
from dataclasses import dataclass
|
||||
from pathlib import PurePosixPath
|
||||
from urllib.parse import unquote, urljoin, urlsplit
|
||||
|
||||
import httpx
|
||||
|
||||
|
||||
MAX_REDIRECTS = 3
|
||||
|
||||
|
||||
class BoxIMImageError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class DownloadedBoxIMImage:
|
||||
content: bytes
|
||||
filename: str
|
||||
mime_type: str
|
||||
source_url: str
|
||||
|
||||
|
||||
def parse_boxim_image_url(content: str, *, base_url: str = "") -> str:
|
||||
try:
|
||||
payload = json.loads(content or "")
|
||||
except (TypeError, ValueError) as exc:
|
||||
raise BoxIMImageError("BOXIM 图片消息格式无效") from exc
|
||||
if not isinstance(payload, dict):
|
||||
raise BoxIMImageError("BOXIM 图片消息格式无效")
|
||||
|
||||
value = payload.get("originUrl") or payload.get("thumbUrl") or payload.get("url")
|
||||
if not isinstance(value, str) or not value.strip():
|
||||
raise BoxIMImageError("BOXIM 图片消息缺少图片地址")
|
||||
value = value.strip()
|
||||
if value.startswith("/"):
|
||||
if not base_url:
|
||||
raise BoxIMImageError("BOXIM 图片地址不完整")
|
||||
value = urljoin(f"{base_url.rstrip('/')}/", value)
|
||||
return value
|
||||
|
||||
|
||||
def _configured_hosts(name: str) -> set[str]:
|
||||
return {
|
||||
value.strip().lower().rstrip(".")
|
||||
for value in os.getenv(name, "").split(",")
|
||||
if value.strip()
|
||||
}
|
||||
|
||||
|
||||
def _host_matches(host: str, configured: set[str]) -> bool:
|
||||
return any(host == value or host.endswith(f".{value}") for value in configured)
|
||||
|
||||
|
||||
def _resolved_addresses(host: str, port: int) -> set[ipaddress.IPv4Address | ipaddress.IPv6Address]:
|
||||
try:
|
||||
return {
|
||||
ipaddress.ip_address(item[4][0])
|
||||
for item in socket.getaddrinfo(host, port, type=socket.SOCK_STREAM)
|
||||
}
|
||||
except (OSError, ValueError) as exc:
|
||||
raise BoxIMImageError("BOXIM 图片地址无法解析") from exc
|
||||
|
||||
|
||||
def _is_safe_remote_url(url: str) -> None:
|
||||
parsed = urlsplit(url)
|
||||
scheme = parsed.scheme.lower()
|
||||
allow_http = os.getenv("BOXIM_IMAGE_ALLOW_HTTP", "").lower() in {"1", "true", "yes"}
|
||||
if scheme not in ({"https", "http"} if allow_http else {"https"}):
|
||||
raise BoxIMImageError("BOXIM 图片地址必须使用 HTTPS")
|
||||
if parsed.username or parsed.password or not parsed.hostname:
|
||||
raise BoxIMImageError("BOXIM 图片地址无效")
|
||||
|
||||
host = parsed.hostname.lower().rstrip(".")
|
||||
allowed_hosts = _configured_hosts("BOXIM_IMAGE_ALLOWED_HOSTS")
|
||||
if allowed_hosts and not _host_matches(host, allowed_hosts):
|
||||
raise BoxIMImageError("BOXIM 图片地址不在允许的域名范围内")
|
||||
|
||||
private_hosts = _configured_hosts("BOXIM_IMAGE_PRIVATE_HOSTS")
|
||||
try:
|
||||
addresses = {ipaddress.ip_address(host)}
|
||||
except ValueError:
|
||||
addresses = _resolved_addresses(host, parsed.port or (443 if scheme == "https" else 80))
|
||||
if not addresses:
|
||||
raise BoxIMImageError("BOXIM 图片地址无法解析")
|
||||
if _host_matches(host, private_hosts):
|
||||
return
|
||||
if any(not address.is_global for address in addresses):
|
||||
raise BoxIMImageError("BOXIM 图片地址指向受限网络")
|
||||
|
||||
|
||||
def _filename_from_url(url: str) -> str:
|
||||
value = unquote(PurePosixPath(urlsplit(url).path).name).strip()
|
||||
value = value.replace("\x00", "")
|
||||
return (value or "boxim-image")[:255]
|
||||
|
||||
|
||||
def download_boxim_image(
|
||||
content: str,
|
||||
*,
|
||||
base_url: str = "",
|
||||
transport: httpx.BaseTransport | None = None,
|
||||
) -> DownloadedBoxIMImage:
|
||||
"""Download one BOXIM image without redirects or oversized responses escaping checks."""
|
||||
url = parse_boxim_image_url(content, base_url=base_url)
|
||||
max_bytes = max(1024, int(os.getenv("CHAT_IMAGE_MAX_BYTES", str(8 * 1024 * 1024))))
|
||||
timeout = max(1.0, min(float(os.getenv("BOXIM_IMAGE_TIMEOUT_SECONDS", "15")), 60.0))
|
||||
|
||||
with httpx.Client(
|
||||
timeout=timeout,
|
||||
follow_redirects=False,
|
||||
trust_env=False,
|
||||
transport=transport,
|
||||
) as client:
|
||||
for _ in range(MAX_REDIRECTS + 1):
|
||||
_is_safe_remote_url(url)
|
||||
try:
|
||||
with client.stream("GET", url, headers={"Accept": "image/*"}) as response:
|
||||
if response.status_code in {301, 302, 303, 307, 308}:
|
||||
location = response.headers.get("location", "").strip()
|
||||
if not location:
|
||||
raise BoxIMImageError("BOXIM 图片跳转地址无效")
|
||||
url = urljoin(url, location)
|
||||
continue
|
||||
response.raise_for_status()
|
||||
raw_length = response.headers.get("content-length", "")
|
||||
if raw_length.isdigit() and int(raw_length) > max_bytes:
|
||||
raise BoxIMImageError("BOXIM 图片超过大小限制")
|
||||
chunks = bytearray()
|
||||
for chunk in response.iter_bytes():
|
||||
chunks.extend(chunk)
|
||||
if len(chunks) > max_bytes:
|
||||
raise BoxIMImageError("BOXIM 图片超过大小限制")
|
||||
if not chunks:
|
||||
raise BoxIMImageError("BOXIM 图片内容为空")
|
||||
return DownloadedBoxIMImage(
|
||||
content=bytes(chunks),
|
||||
filename=_filename_from_url(url),
|
||||
mime_type=response.headers.get("content-type", "").split(";", 1)[0][:100],
|
||||
source_url=url,
|
||||
)
|
||||
except BoxIMImageError:
|
||||
raise
|
||||
except (httpx.HTTPError, OSError) as exc:
|
||||
raise BoxIMImageError("BOXIM 图片下载失败") from exc
|
||||
raise BoxIMImageError("BOXIM 图片跳转次数过多")
|
||||
@@ -0,0 +1,20 @@
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from models import ChatAttachment
|
||||
|
||||
|
||||
def purge_expired_chat_attachments(
|
||||
db: Session,
|
||||
*,
|
||||
now: datetime | None = None,
|
||||
) -> int:
|
||||
"""Remove expired derived image data; raw image bytes are never persisted."""
|
||||
count = db.query(ChatAttachment).filter(
|
||||
ChatAttachment.expires_at < (now or datetime.utcnow())
|
||||
).delete(synchronize_session=False)
|
||||
if count:
|
||||
db.commit()
|
||||
db.expire_all()
|
||||
return count
|
||||
@@ -0,0 +1,115 @@
|
||||
import logging
|
||||
import os
|
||||
import threading
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
|
||||
import httpx
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ChatModelConfig:
|
||||
api_base_url: str
|
||||
api_key: str
|
||||
model: str
|
||||
max_tokens: int
|
||||
timeout_seconds: float
|
||||
vision_model: str
|
||||
ocr_model: str
|
||||
vision_max_tokens: int
|
||||
vision_timeout_seconds: float
|
||||
source: str
|
||||
|
||||
|
||||
_cache_lock = threading.Lock()
|
||||
_cached_config: ChatModelConfig | None = None
|
||||
_cache_expires_at = 0.0
|
||||
|
||||
|
||||
def _environment_config() -> ChatModelConfig:
|
||||
return ChatModelConfig(
|
||||
api_base_url=os.getenv(
|
||||
"CHAT_API_URL", "https://dashscope.aliyuncs.com/compatible-mode/v1"
|
||||
).rstrip("/"),
|
||||
api_key=os.getenv("CHAT_API_KEY", ""),
|
||||
model=os.getenv("CHAT_MODEL", "qwen-plus"),
|
||||
max_tokens=max(128, int(os.getenv("CHAT_MAX_OUTPUT_TOKENS", "1024"))),
|
||||
timeout_seconds=max(5.0, float(os.getenv("CHAT_TIMEOUT_SECONDS", "30"))),
|
||||
vision_model=os.getenv("VISION_MODEL", "qwen3.6-flash"),
|
||||
ocr_model=os.getenv("VISION_OCR_MODEL", "qwen-vl-ocr"),
|
||||
vision_max_tokens=max(256, int(os.getenv("VISION_MAX_OUTPUT_TOKENS", "2048"))),
|
||||
vision_timeout_seconds=max(10.0, float(os.getenv("VISION_TIMEOUT_SECONDS", "90"))),
|
||||
source="environment",
|
||||
)
|
||||
|
||||
|
||||
def _fetch_runtime_config() -> ChatModelConfig | None:
|
||||
url = os.getenv("CHAT_MODEL_CONFIG_URL", "").strip()
|
||||
token = os.getenv("AVATAR_MODEL_CONFIG_TOKEN", "").strip()
|
||||
if not url or not token:
|
||||
return None
|
||||
response = httpx.get(
|
||||
url,
|
||||
headers={"X-Avatar-Config-Token": token},
|
||||
timeout=max(2.0, float(os.getenv("CHAT_MODEL_CONFIG_TIMEOUT_SECONDS", "5"))),
|
||||
)
|
||||
response.raise_for_status()
|
||||
payload = response.json().get("data") or {}
|
||||
api_base_url = str(payload.get("api_base_url") or "").rstrip("/")
|
||||
api_key = str(payload.get("api_key") or "")
|
||||
model = str(payload.get("model") or "")
|
||||
if not api_base_url or not api_key or not model:
|
||||
raise ValueError("数字分身专用模型配置不完整")
|
||||
return ChatModelConfig(
|
||||
api_base_url=api_base_url,
|
||||
api_key=api_key,
|
||||
model=model,
|
||||
max_tokens=max(128, int(payload.get("max_tokens") or 1024)),
|
||||
timeout_seconds=max(5.0, float(payload.get("timeout_seconds") or 30)),
|
||||
vision_model=str(
|
||||
payload.get("vision_model")
|
||||
or os.getenv("VISION_MODEL", "qwen3.6-flash")
|
||||
),
|
||||
ocr_model=str(
|
||||
payload.get("ocr_model")
|
||||
or os.getenv("VISION_OCR_MODEL", "qwen-vl-ocr")
|
||||
),
|
||||
vision_max_tokens=max(
|
||||
256, int(os.getenv("VISION_MAX_OUTPUT_TOKENS", "2048"))
|
||||
),
|
||||
vision_timeout_seconds=max(
|
||||
10.0, float(os.getenv("VISION_TIMEOUT_SECONDS", "90"))
|
||||
),
|
||||
source="admin",
|
||||
)
|
||||
|
||||
|
||||
def get_chat_model_config(*, force_refresh: bool = False) -> ChatModelConfig:
|
||||
global _cached_config, _cache_expires_at
|
||||
|
||||
now = time.monotonic()
|
||||
if not force_refresh and _cached_config is not None and now < _cache_expires_at:
|
||||
return _cached_config
|
||||
|
||||
with _cache_lock:
|
||||
now = time.monotonic()
|
||||
if not force_refresh and _cached_config is not None and now < _cache_expires_at:
|
||||
return _cached_config
|
||||
try:
|
||||
config = _fetch_runtime_config() or _environment_config()
|
||||
except (httpx.HTTPError, ValueError, TypeError) as exc:
|
||||
logger.warning("读取数字分身专用模型配置失败,暂时使用环境变量配置: %s", exc)
|
||||
config = _environment_config()
|
||||
_cached_config = config
|
||||
ttl = max(5, int(os.getenv("CHAT_MODEL_CONFIG_CACHE_SECONDS", "60")))
|
||||
_cache_expires_at = now + ttl
|
||||
return config
|
||||
|
||||
|
||||
def clear_chat_model_config_cache() -> None:
|
||||
global _cached_config, _cache_expires_at
|
||||
with _cache_lock:
|
||||
_cached_config = None
|
||||
_cache_expires_at = 0.0
|
||||
@@ -0,0 +1,181 @@
|
||||
"""Signed client for Huihui's production payment-v3 service."""
|
||||
|
||||
import hashlib
|
||||
import os
|
||||
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
|
||||
|
||||
def request_refund(
|
||||
self,
|
||||
*,
|
||||
huihui_token: str,
|
||||
huihui_user_id: str,
|
||||
order_no: str,
|
||||
refund_no: str,
|
||||
amount: str,
|
||||
reason: str,
|
||||
) -> dict[str, Any]:
|
||||
"""Submit a full refund to payment-v3.
|
||||
|
||||
The refund path remains configurable because private Huihui deployments
|
||||
may expose the same contract below a different gateway route.
|
||||
"""
|
||||
if not self.configured:
|
||||
raise HuihuiPaymentError("会会支付服务未配置")
|
||||
if not huihui_token or not huihui_user_id:
|
||||
raise HuihuiPaymentError("当前会会登录凭证无法发起退款")
|
||||
|
||||
path = os.getenv("HUIHUI_PAYMENT_REFUND_PATH", "/payment/refund").strip()
|
||||
if not path.startswith("/"):
|
||||
path = f"/{path}"
|
||||
if ".." in path:
|
||||
raise HuihuiPaymentError("会会退款接口路径配置不正确")
|
||||
body = {
|
||||
"appId": self.app_id,
|
||||
"masterOrderNo": order_no,
|
||||
"refundOrderNo": refund_no,
|
||||
"refundAmt": float(amount),
|
||||
"refundReason": reason or "后台退款",
|
||||
}
|
||||
headers = {
|
||||
"Authorization": f"Bearer {huihui_token}",
|
||||
"appId": self.app_id,
|
||||
"windowAppId": self.app_id,
|
||||
}
|
||||
try:
|
||||
response = httpx.post(
|
||||
f"{self.base_url}{path}",
|
||||
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 {}
|
||||
return data if isinstance(data, dict) else {"result": data}
|
||||
@@ -0,0 +1,160 @@
|
||||
"""Durable, serial knowledge-document indexing for the avatar knowledge base."""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import queue
|
||||
import threading
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from database import SessionLocal
|
||||
from models import Avatar, KnowledgeChunk, KnowledgeDoc
|
||||
from services.pdf_ocr_service import extract_scanned_pdf_text
|
||||
import embeddings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
BACKEND_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
UPLOAD_DIR = os.path.abspath(
|
||||
os.getenv("UPLOAD_DIR", os.path.join(BACKEND_DIR, "routers", "uploads"))
|
||||
)
|
||||
|
||||
|
||||
class KnowledgeVectorizer:
|
||||
"""Indexes one document at a time so slow providers cannot block uploads."""
|
||||
|
||||
def __init__(self):
|
||||
self._queue: queue.Queue[str] = queue.Queue()
|
||||
self._queued: set[str] = set()
|
||||
self._lock = threading.Lock()
|
||||
self._thread: threading.Thread | None = None
|
||||
|
||||
def start(self):
|
||||
if self._thread and self._thread.is_alive():
|
||||
return
|
||||
self._thread = threading.Thread(
|
||||
target=self._run, name="knowledge-vectorizer", daemon=True
|
||||
)
|
||||
self._thread.start()
|
||||
db = SessionLocal()
|
||||
try:
|
||||
# A process restart must not abandon documents already accepted by upload.
|
||||
for (doc_id,) in db.query(KnowledgeDoc.id).filter(KnowledgeDoc.status == "parsing"):
|
||||
self.enqueue(doc_id)
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
def enqueue(self, doc_id: str):
|
||||
with self._lock:
|
||||
if doc_id in self._queued:
|
||||
return
|
||||
self._queued.add(doc_id)
|
||||
self._queue.put(doc_id)
|
||||
|
||||
def _run(self):
|
||||
while True:
|
||||
doc_id = self._queue.get()
|
||||
try:
|
||||
self.vectorize_document(doc_id)
|
||||
except Exception:
|
||||
logger.exception("Unexpected knowledge vectorizer failure for %s", doc_id)
|
||||
finally:
|
||||
with self._lock:
|
||||
self._queued.discard(doc_id)
|
||||
self._queue.task_done()
|
||||
|
||||
def vectorize_document(self, doc_id: str):
|
||||
db = SessionLocal()
|
||||
try:
|
||||
doc = db.get(KnowledgeDoc, doc_id)
|
||||
if not doc or doc.status != "parsing":
|
||||
return
|
||||
|
||||
stored_name = os.path.basename(doc.file_url or "")
|
||||
path = os.path.join(UPLOAD_DIR, doc.avatar_id, stored_name)
|
||||
if not stored_name or not os.path.isfile(path):
|
||||
raise FileNotFoundError("原文件不可用,请重新上传")
|
||||
|
||||
self._set_progress(db, doc, "extracting", 8)
|
||||
text = embeddings.extract_text(path, f".{doc.file_type}")
|
||||
if doc.file_type == "pdf" and not text.strip():
|
||||
avatar = db.get(Avatar, doc.avatar_id)
|
||||
if not avatar:
|
||||
raise ValueError("文档所属分身不存在")
|
||||
|
||||
def ocr_progress(done: int, total: int):
|
||||
percent = 8 + int((done / max(1, total)) * 20)
|
||||
self._set_progress(db, doc, "ocr", min(percent, 28))
|
||||
|
||||
self._set_progress(db, doc, "ocr", 8)
|
||||
text = extract_scanned_pdf_text(
|
||||
db,
|
||||
avatar,
|
||||
path,
|
||||
on_progress=ocr_progress,
|
||||
)
|
||||
self._set_progress(db, doc, "chunking", 29)
|
||||
chunks = embeddings.chunk_text(text)
|
||||
if not chunks:
|
||||
raise ValueError("文档没有可建立索引的文字内容")
|
||||
self._set_progress(db, doc, "embedding", 30)
|
||||
|
||||
def embedding_progress(done: int, total: int):
|
||||
percent = 30 + int((done / max(1, total)) * 65)
|
||||
self._set_progress(db, doc, "embedding", min(percent, 95))
|
||||
|
||||
vectors = embeddings.embed(chunks, on_progress=embedding_progress)
|
||||
if len(vectors) != len(chunks):
|
||||
raise ValueError("向量服务返回数量与文档分段不一致")
|
||||
|
||||
# Commit the document and every chunk together. Chat only sees complete indexes.
|
||||
db.query(KnowledgeChunk).filter(KnowledgeChunk.doc_id == doc.id).delete()
|
||||
db.add_all(
|
||||
[
|
||||
KnowledgeChunk(
|
||||
doc_id=doc.id,
|
||||
avatar_id=doc.avatar_id,
|
||||
content=chunk,
|
||||
vector=json.dumps(vector),
|
||||
chunk_index=index,
|
||||
embedding_model=embeddings.MODEL,
|
||||
)
|
||||
for index, (chunk, vector) in enumerate(zip(chunks, vectors))
|
||||
]
|
||||
)
|
||||
doc.vectorized = True
|
||||
doc.embedding_model = embeddings.MODEL
|
||||
doc.chunk_count = len(chunks)
|
||||
doc.vectorized_at = datetime.now(timezone.utc)
|
||||
doc.status = "ready"
|
||||
doc.error_message = ""
|
||||
doc.index_stage = "ready"
|
||||
doc.index_progress = 100
|
||||
db.commit()
|
||||
logger.info("Knowledge document %s indexed with %s chunks", doc.id, len(chunks))
|
||||
except Exception as exc:
|
||||
db.rollback()
|
||||
failed_doc = db.get(KnowledgeDoc, doc_id)
|
||||
if failed_doc:
|
||||
db.query(KnowledgeChunk).filter(KnowledgeChunk.doc_id == failed_doc.id).delete()
|
||||
failed_doc.status = "failed"
|
||||
failed_doc.vectorized = False
|
||||
failed_doc.embedding_model = ""
|
||||
failed_doc.chunk_count = 0
|
||||
failed_doc.vectorized_at = None
|
||||
failed_doc.error_message = str(exc)[:300] or "建立知识索引失败"
|
||||
failed_doc.index_stage = "failed"
|
||||
failed_doc.index_progress = 0
|
||||
db.commit()
|
||||
logger.exception("Knowledge vectorization failed for %s: %s", doc_id, exc)
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
@staticmethod
|
||||
def _set_progress(db, doc, stage: str, progress: int):
|
||||
doc.index_stage = stage
|
||||
doc.index_progress = progress
|
||||
db.commit()
|
||||
|
||||
|
||||
knowledge_vectorizer = KnowledgeVectorizer()
|
||||
@@ -0,0 +1,130 @@
|
||||
"""OCR fallback for image-only PDF knowledge documents."""
|
||||
|
||||
import logging
|
||||
import os
|
||||
import time
|
||||
from typing import Callable
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from models import Avatar
|
||||
from services.chat_model_config import get_chat_model_config
|
||||
from services.token_billing import (
|
||||
estimate_fallback_usage,
|
||||
release_reservation,
|
||||
reserve_avatar_tokens,
|
||||
settle_reservation,
|
||||
)
|
||||
from services.vision_service import call_vision_model, prepare_image
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
PDF_OCR_PROMPT = (
|
||||
"请逐字转录这一页扫描文档中的全部可见文字和表格,只输出转录内容,不要解释,不要使用 Markdown 代码块。"
|
||||
"保留标题、段落、项目编号、数值和自然换行;看不清的内容写作[无法辨认],不要猜测、纠错或补全。"
|
||||
)
|
||||
|
||||
|
||||
def _positive_int(name: str, default: int, minimum: int, maximum: int) -> int:
|
||||
try:
|
||||
value = int(os.getenv(name, str(default)))
|
||||
except ValueError:
|
||||
value = default
|
||||
return max(minimum, min(maximum, value))
|
||||
|
||||
|
||||
def extract_scanned_pdf_text(
|
||||
db: Session,
|
||||
avatar: Avatar,
|
||||
path: str,
|
||||
*,
|
||||
on_progress: Callable[[int, int], None] | None = None,
|
||||
) -> str:
|
||||
"""Render and OCR an image-only PDF while preserving page order."""
|
||||
try:
|
||||
import pymupdf
|
||||
except ImportError as exc:
|
||||
raise RuntimeError("扫描型 PDF 识别组件未安装") from exc
|
||||
|
||||
max_pages = _positive_int("KNOWLEDGE_PDF_OCR_MAX_PAGES", 80, 1, 300)
|
||||
render_dpi = _positive_int("KNOWLEDGE_PDF_OCR_DPI", 144, 96, 200)
|
||||
max_attempts = _positive_int("KNOWLEDGE_PDF_OCR_ATTEMPTS", 3, 1, 5)
|
||||
model_config = get_chat_model_config()
|
||||
model = model_config.ocr_model or model_config.vision_model
|
||||
if not model_config.api_key or not model:
|
||||
raise RuntimeError("扫描型 PDF 需要配置视觉 OCR 模型")
|
||||
|
||||
texts: list[str] = []
|
||||
with pymupdf.open(path) as document:
|
||||
total_pages = document.page_count
|
||||
if total_pages <= 0:
|
||||
raise ValueError("PDF 没有可识别页面")
|
||||
if total_pages > max_pages:
|
||||
raise ValueError(
|
||||
f"扫描型 PDF 共 {total_pages} 页,超过单次 OCR 上限 {max_pages} 页,请拆分后上传"
|
||||
)
|
||||
|
||||
scale = render_dpi / 72
|
||||
for page_index in range(total_pages):
|
||||
page = document.load_page(page_index)
|
||||
pixmap = page.get_pixmap(
|
||||
matrix=pymupdf.Matrix(scale, scale),
|
||||
colorspace=pymupdf.csRGB,
|
||||
alpha=False,
|
||||
)
|
||||
prepared = prepare_image(pixmap.tobytes("jpeg", jpg_quality=88))
|
||||
estimate_messages = [{
|
||||
"role": "user",
|
||||
"content": f"[扫描 PDF 第 {page_index + 1}/{total_pages} 页]\n{PDF_OCR_PROMPT}",
|
||||
}]
|
||||
reservation = reserve_avatar_tokens(
|
||||
db,
|
||||
avatar,
|
||||
"knowledge_pdf_ocr",
|
||||
model,
|
||||
estimate_messages,
|
||||
model_config.vision_max_tokens,
|
||||
)
|
||||
try:
|
||||
result = None
|
||||
for attempt in range(1, max_attempts + 1):
|
||||
try:
|
||||
result = call_vision_model(
|
||||
prepared,
|
||||
model_config,
|
||||
model=model,
|
||||
prompt=PDF_OCR_PROMPT,
|
||||
json_output=False,
|
||||
)
|
||||
break
|
||||
except RuntimeError:
|
||||
if attempt == max_attempts:
|
||||
raise
|
||||
time.sleep(min(4, attempt))
|
||||
content = str((result or {}).get("content") or "").strip()
|
||||
if not content:
|
||||
raise RuntimeError("扫描型 PDF 页面识别结果为空")
|
||||
settle_reservation(
|
||||
db,
|
||||
reservation,
|
||||
(result or {}).get("usage"),
|
||||
fallback_total=estimate_fallback_usage(estimate_messages, content),
|
||||
)
|
||||
except Exception as exc:
|
||||
release_reservation(db, reservation, str(exc))
|
||||
raise RuntimeError(
|
||||
f"扫描型 PDF 第 {page_index + 1}/{total_pages} 页识别失败:{exc}"
|
||||
) from exc
|
||||
|
||||
texts.append(f"[第 {page_index + 1} 页]\n{content}")
|
||||
if on_progress:
|
||||
on_progress(page_index + 1, total_pages)
|
||||
logger.info(
|
||||
"Scanned PDF OCR completed avatar=%s page=%s/%s",
|
||||
avatar.id,
|
||||
page_index + 1,
|
||||
total_pages,
|
||||
)
|
||||
|
||||
return "\n\n".join(texts).strip()
|
||||
@@ -3,6 +3,7 @@
|
||||
import asyncio
|
||||
import hashlib
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import secrets
|
||||
import time
|
||||
@@ -13,21 +14,50 @@ from sqlalchemy.orm import Session
|
||||
|
||||
from models import (
|
||||
Avatar,
|
||||
ChatAttachment,
|
||||
TakeoverCursor,
|
||||
TakeoverMessage,
|
||||
TakeoverReplyTask,
|
||||
User,
|
||||
)
|
||||
from services.boxim_client import BoxIMClient, BoxIMError
|
||||
from services.boxim_image_service import (
|
||||
BoxIMImageError,
|
||||
download_boxim_image,
|
||||
parse_boxim_image_url,
|
||||
)
|
||||
from services.vision_service import ImageValidationError
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
ACTIVE_TASK_STATUSES = ("pending", "generating", "ready", "sending")
|
||||
GENERATABLE_TASK_STATUSES = ("pending",)
|
||||
MAX_PROMPT_LENGTH = 4000
|
||||
MAX_STALE_SECONDS = 120
|
||||
DEFAULT_MAX_MESSAGE_AGE_SECONDS = 600
|
||||
MAX_SEND_OVERDUE_SECONDS = 120
|
||||
STUCK_LOCK_SECONDS = 90
|
||||
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"
|
||||
BOXIM_TEXT_MESSAGE_TYPE = 0
|
||||
BOXIM_IMAGE_MESSAGE_TYPE = 1
|
||||
BOXIM_IMAGE_PROMPT = "请看看这张图片。"
|
||||
BOXIM_IMAGE_UNAVAILABLE_REPLY = "这张图片我暂时没看清,麻烦重新发送一张清晰的原图。"
|
||||
IMAGE_CONTEXT_LOOKBACK_SECONDS = 1800
|
||||
IMAGE_REFERENCE_LOOKBACK_SECONDS = 172_800
|
||||
MAX_RECENT_IMAGE_CONTEXTS = 3
|
||||
|
||||
_IMAGE_REFERENCE_PATTERN = re.compile(
|
||||
r"(?:图片|图像|照片|截图|这张图|刚才.{0,8}图|病例|病历|检查单|检验单|化验单|报告|影像|"
|
||||
r"\b(?:image|photo|picture|screenshot|scan|report)\b)",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
|
||||
def _utcnow() -> datetime:
|
||||
@@ -70,24 +100,69 @@ def _plain_text_reply(value: str) -> str:
|
||||
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
|
||||
|
||||
|
||||
def _event_prompt(event: TakeoverMessage) -> str:
|
||||
if event.message_type == BOXIM_TEXT_MESSAGE_TYPE:
|
||||
return event.content.strip()
|
||||
if event.message_type == BOXIM_IMAGE_MESSAGE_TYPE:
|
||||
return BOXIM_IMAGE_PROMPT
|
||||
return ""
|
||||
|
||||
|
||||
def _references_recent_image(value: str) -> bool:
|
||||
return bool(_IMAGE_REFERENCE_PATTERN.search(value or ""))
|
||||
|
||||
|
||||
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__(
|
||||
self,
|
||||
session_factory: Callable[[], Session],
|
||||
boxim_client: BoxIMClient,
|
||||
*,
|
||||
reply_delay_seconds: int = 3,
|
||||
reply_delay_seconds: int | None = None,
|
||||
poll_concurrency: int = 8,
|
||||
max_message_age_seconds: int = DEFAULT_MAX_MESSAGE_AGE_SECONDS,
|
||||
now: Callable[[], datetime] = _utcnow,
|
||||
):
|
||||
self.session_factory = session_factory
|
||||
self.boxim = boxim_client
|
||||
self.reply_delay_seconds = reply_delay_seconds
|
||||
self.poll_concurrency = max(1, min(int(poll_concurrency), 64))
|
||||
self.max_message_age_seconds = max(60, int(max_message_age_seconds))
|
||||
self.now = now
|
||||
self._sessions: dict[str, dict] = {}
|
||||
self._poll_lock = asyncio.Lock()
|
||||
self._process_lock = asyncio.Lock()
|
||||
self._persist_lock = asyncio.Lock()
|
||||
|
||||
async def poll_and_process_messages(self):
|
||||
"""Run one complete cycle for callers that do not use the split scheduler."""
|
||||
@@ -102,8 +177,48 @@ class TakeoverService:
|
||||
self._recover_stuck_tasks()
|
||||
avatar_ids = self._enabled_avatar_ids()
|
||||
self._cancel_disabled_tasks(set(avatar_ids))
|
||||
for avatar_id in avatar_ids:
|
||||
await self._sync_avatar(avatar_id)
|
||||
self._ensure_takeover_cursors(avatar_ids)
|
||||
semaphore = asyncio.Semaphore(self.poll_concurrency)
|
||||
|
||||
async def sync(avatar_id: str):
|
||||
async with semaphore:
|
||||
return await self._sync_avatar(avatar_id)
|
||||
|
||||
results = await asyncio.gather(
|
||||
*(sync(avatar_id) for avatar_id in avatar_ids),
|
||||
return_exceptions=True,
|
||||
)
|
||||
for avatar_id, result in zip(avatar_ids, results):
|
||||
if isinstance(result, Exception):
|
||||
logger.warning("BOXIM poll crashed for avatar %s: %s", avatar_id, result)
|
||||
|
||||
def _ensure_takeover_cursors(self, avatar_ids: list[str]):
|
||||
"""Create durable cursors before concurrent network polling starts."""
|
||||
if not avatar_ids:
|
||||
return
|
||||
db = self.session_factory()
|
||||
try:
|
||||
existing = {
|
||||
row[0]
|
||||
for row in db.query(TakeoverCursor.avatar_id)
|
||||
.filter(TakeoverCursor.avatar_id.in_(avatar_ids))
|
||||
.all()
|
||||
}
|
||||
avatars = (
|
||||
db.query(Avatar.id, Avatar.owner_id)
|
||||
.filter(
|
||||
Avatar.id.in_(
|
||||
[avatar_id for avatar_id in avatar_ids if avatar_id not in existing]
|
||||
)
|
||||
)
|
||||
.all()
|
||||
)
|
||||
for avatar_id, owner_id in avatars:
|
||||
db.add(TakeoverCursor(avatar_id=avatar_id, owner_id=owner_id))
|
||||
if avatars:
|
||||
db.commit()
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
async def process_reply_tasks(self):
|
||||
"""Generate and send replies independently from BOXIM's long poll."""
|
||||
@@ -119,11 +234,17 @@ class TakeoverService:
|
||||
def _enabled_avatar_ids(self) -> list[str]:
|
||||
db = self.session_factory()
|
||||
try:
|
||||
return [
|
||||
avatar.id
|
||||
for avatar in db.query(Avatar).filter(Avatar.status == "active").all()
|
||||
if _takeover_enabled(avatar)
|
||||
]
|
||||
avatars = (
|
||||
db.query(Avatar)
|
||||
.filter(Avatar.status == "active")
|
||||
.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:
|
||||
db.close()
|
||||
|
||||
@@ -201,13 +322,20 @@ class TakeoverService:
|
||||
def _forget_boxim_session(self, user_id: str):
|
||||
self._sessions.pop(user_id, None)
|
||||
|
||||
def _disable_after_connection_failure(
|
||||
def _record_connection_failure(
|
||||
self,
|
||||
db: Session,
|
||||
avatar: Avatar,
|
||||
cursor: TakeoverCursor,
|
||||
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", [])
|
||||
avatar.config = {
|
||||
**(avatar.config or {}),
|
||||
@@ -217,8 +345,6 @@ class TakeoverService:
|
||||
if permission != TAKEOVER_PERMISSION
|
||||
],
|
||||
}
|
||||
cursor.last_error = message
|
||||
cursor.last_polled_at = self.now()
|
||||
tasks = (
|
||||
db.query(TakeoverReplyTask)
|
||||
.filter(
|
||||
@@ -243,13 +369,17 @@ class TakeoverService:
|
||||
if not cursor:
|
||||
cursor = TakeoverCursor(avatar_id=avatar.id, owner_id=avatar.owner_id)
|
||||
db.add(cursor)
|
||||
db.flush()
|
||||
db.commit()
|
||||
else:
|
||||
# Release SQLite's read transaction before the long network poll.
|
||||
db.commit()
|
||||
if not user or not user.huihui_token:
|
||||
self._disable_after_connection_failure(
|
||||
self._record_connection_failure(
|
||||
db,
|
||||
avatar,
|
||||
cursor,
|
||||
"请重新登录会会生产账号后再开启主动接管",
|
||||
disable_takeover=True,
|
||||
)
|
||||
db.commit()
|
||||
return False
|
||||
@@ -268,11 +398,24 @@ class TakeoverService:
|
||||
if isinstance(exc, BoxIMError) and exc.auth_error:
|
||||
self._forget_boxim_session(user.id)
|
||||
message = "BOXIM 授权已失效,请重新登录会会生产账号"
|
||||
disable_takeover = True
|
||||
else:
|
||||
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()
|
||||
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
|
||||
|
||||
messages.sort(key=lambda item: (_numeric_id(item.get("id")), item.get("sendTime") or 0))
|
||||
@@ -280,13 +423,6 @@ class TakeoverService:
|
||||
max_message_id = _numeric_id(cursor.last_message_id)
|
||||
read_receipts: dict[str, int] = {}
|
||||
for message in messages:
|
||||
self._record_message(
|
||||
db,
|
||||
avatar,
|
||||
cursor.boxim_owner_id,
|
||||
message,
|
||||
schedule_reply=not priming,
|
||||
)
|
||||
message_id = _numeric_id(message.get("id"))
|
||||
max_message_id = max(max_message_id, message_id)
|
||||
send_id = str(message.get("sendId") or "")
|
||||
@@ -301,11 +437,22 @@ class TakeoverService:
|
||||
session["access_token"], peer_id, message_id
|
||||
)
|
||||
|
||||
cursor.last_message_id = str(max_message_id)
|
||||
cursor.initialized = True
|
||||
cursor.last_polled_at = self.now()
|
||||
cursor.last_error = ""
|
||||
db.commit()
|
||||
# Keep SQLite write transactions short. The read-receipt request above
|
||||
# can block on the network and must not hold the database write lock.
|
||||
async with self._persist_lock:
|
||||
for message in messages:
|
||||
self._record_message(
|
||||
db,
|
||||
avatar,
|
||||
cursor.boxim_owner_id,
|
||||
message,
|
||||
schedule_reply=not priming,
|
||||
)
|
||||
cursor.last_message_id = str(max_message_id)
|
||||
cursor.initialized = True
|
||||
cursor.last_polled_at = self.now()
|
||||
cursor.last_error = ""
|
||||
db.commit()
|
||||
return True
|
||||
except Exception:
|
||||
db.rollback()
|
||||
@@ -350,13 +497,21 @@ class TakeoverService:
|
||||
|
||||
now = self.now()
|
||||
send_time = _boxim_time(message.get("sendTime"), now)
|
||||
is_avatar = False
|
||||
if direction == "outgoing" and local_id:
|
||||
is_avatar = _is_avatar_local_id(local_id)
|
||||
if not is_avatar and local_id:
|
||||
is_avatar = bool(
|
||||
db.query(TakeoverReplyTask)
|
||||
.filter(
|
||||
TakeoverReplyTask.owner_id == avatar.owner_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",
|
||||
)
|
||||
.first()
|
||||
@@ -381,12 +536,96 @@ class TakeoverService:
|
||||
if not is_avatar:
|
||||
self._cancel_conversation(db, avatar.owner_id, peer_id, "owner_replied")
|
||||
return
|
||||
if not schedule_reply or event.message_type != 0 or not event.content.strip():
|
||||
if not schedule_reply or event.message_type not in {
|
||||
BOXIM_TEXT_MESSAGE_TYPE,
|
||||
BOXIM_IMAGE_MESSAGE_TYPE,
|
||||
}:
|
||||
return
|
||||
if (now - send_time).total_seconds() > MAX_STALE_SECONDS:
|
||||
if event.message_type == BOXIM_TEXT_MESSAGE_TYPE and not event.content.strip():
|
||||
return
|
||||
if event.message_type == BOXIM_IMAGE_MESSAGE_TYPE:
|
||||
try:
|
||||
parse_boxim_image_url(
|
||||
event.content,
|
||||
base_url=getattr(self.boxim, "im_base_url", ""),
|
||||
)
|
||||
except BoxIMImageError as exc:
|
||||
logger.warning(
|
||||
"Ignored invalid BOXIM image message %s for avatar %s: %s",
|
||||
message_id,
|
||||
avatar.id,
|
||||
exc,
|
||||
)
|
||||
return
|
||||
if (now - send_time).total_seconds() > self.max_message_age_seconds:
|
||||
logger.info(
|
||||
"Ignored stale BOXIM message %s for avatar %s (age=%ss)",
|
||||
message_id,
|
||||
avatar.id,
|
||||
int((now - send_time).total_seconds()),
|
||||
)
|
||||
return
|
||||
if is_avatar:
|
||||
self._cancel_conversation(db, avatar.owner_id, peer_id, "peer_avatar_message")
|
||||
logger.info(
|
||||
"Skipped BOXIM reply for avatar %s message %s: peer_avatar_message",
|
||||
avatar.id,
|
||||
message_id,
|
||||
)
|
||||
return
|
||||
if self._human_pause_active(db, avatar.owner_id, peer_id, now):
|
||||
self._cancel_conversation(db, avatar.owner_id, peer_id, "owner_active")
|
||||
logger.info(
|
||||
"Skipped BOXIM reply for avatar %s message %s: owner_active",
|
||||
avatar.id,
|
||||
message_id,
|
||||
)
|
||||
return
|
||||
if self._conversation_rate_limited(db, avatar.owner_id, peer_id, now):
|
||||
self._cancel_conversation(db, avatar.owner_id, peer_id, "rate_limited")
|
||||
logger.info(
|
||||
"Skipped BOXIM reply for avatar %s message %s: rate_limited",
|
||||
avatar.id,
|
||||
message_id,
|
||||
)
|
||||
return
|
||||
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
|
||||
def _cancel_conversation(db: Session, owner_id: str, peer_id: str, reason: str):
|
||||
tasks = (
|
||||
@@ -424,12 +663,25 @@ class TakeoverService:
|
||||
task.status = "cancelled"
|
||||
task.cancel_reason = "newer_incoming_message"
|
||||
task.locked_at = None
|
||||
prompt_parts.append(event.content.strip())
|
||||
if event.message_type == BOXIM_TEXT_MESSAGE_TYPE:
|
||||
for image_event in self._recent_unhandled_images(
|
||||
db,
|
||||
avatar,
|
||||
event,
|
||||
source_ids,
|
||||
):
|
||||
prompt_parts.append(_event_prompt(image_event))
|
||||
source_ids.append(image_event.boxim_message_id)
|
||||
prompt_parts.append(_event_prompt(event))
|
||||
source_ids.append(event.boxim_message_id)
|
||||
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 = max(
|
||||
event.send_time
|
||||
+ timedelta(seconds=_configured_reply_delay(avatar, self.reply_delay_seconds)),
|
||||
self.now(),
|
||||
)
|
||||
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(
|
||||
TakeoverReplyTask(
|
||||
id=task_id,
|
||||
@@ -445,6 +697,68 @@ class TakeoverService:
|
||||
)
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _recent_unhandled_images(
|
||||
db: Session,
|
||||
avatar: Avatar,
|
||||
event: TakeoverMessage,
|
||||
current_source_ids: list[str],
|
||||
) -> list[TakeoverMessage]:
|
||||
"""Recover missed images, or reuse a referenced image from the last two days."""
|
||||
references_image = _references_recent_image(event.content)
|
||||
lookback_seconds = (
|
||||
IMAGE_REFERENCE_LOOKBACK_SECONDS
|
||||
if references_image
|
||||
else IMAGE_CONTEXT_LOOKBACK_SECONDS
|
||||
)
|
||||
threshold = event.send_time - timedelta(seconds=lookback_seconds)
|
||||
candidates = (
|
||||
db.query(TakeoverMessage)
|
||||
.filter(
|
||||
TakeoverMessage.avatar_id == avatar.id,
|
||||
TakeoverMessage.owner_id == avatar.owner_id,
|
||||
TakeoverMessage.peer_id == event.peer_id,
|
||||
TakeoverMessage.direction == "incoming",
|
||||
TakeoverMessage.message_type == BOXIM_IMAGE_MESSAGE_TYPE,
|
||||
TakeoverMessage.is_avatar.is_(False),
|
||||
TakeoverMessage.send_time >= threshold,
|
||||
TakeoverMessage.send_time <= event.send_time,
|
||||
)
|
||||
.order_by(TakeoverMessage.send_time.desc())
|
||||
.limit(MAX_RECENT_IMAGE_CONTEXTS)
|
||||
.all()
|
||||
)
|
||||
if not candidates:
|
||||
return []
|
||||
|
||||
current_ids = set(current_source_ids)
|
||||
if references_image:
|
||||
return [
|
||||
image
|
||||
for image in reversed(candidates)
|
||||
if image.boxim_message_id not in current_ids
|
||||
]
|
||||
|
||||
handled_ids = set(current_ids)
|
||||
task_sources = (
|
||||
db.query(TakeoverReplyTask.source_message_ids)
|
||||
.filter(
|
||||
TakeoverReplyTask.avatar_id == avatar.id,
|
||||
TakeoverReplyTask.owner_id == avatar.owner_id,
|
||||
TakeoverReplyTask.peer_id == event.peer_id,
|
||||
TakeoverReplyTask.created_at >= threshold,
|
||||
)
|
||||
.all()
|
||||
)
|
||||
for (source_message_ids,) in task_sources:
|
||||
handled_ids.update(source_message_ids or [])
|
||||
|
||||
return [
|
||||
image
|
||||
for image in reversed(candidates)
|
||||
if image.boxim_message_id not in handled_ids
|
||||
]
|
||||
|
||||
async def _prepare_replies(self) -> int:
|
||||
db = self.session_factory()
|
||||
try:
|
||||
@@ -455,6 +769,7 @@ class TakeoverService:
|
||||
.filter(
|
||||
TakeoverReplyTask.status.in_(GENERATABLE_TASK_STATUSES),
|
||||
TakeoverReplyTask.response_text == "",
|
||||
TakeoverReplyTask.scheduled_at <= self.now(),
|
||||
)
|
||||
.order_by(TakeoverReplyTask.created_at.asc())
|
||||
.limit(10)
|
||||
@@ -478,6 +793,50 @@ class TakeoverService:
|
||||
results = await asyncio.gather(*(generate(task_id) for task_id in task_ids))
|
||||
return sum(bool(result) for result in results)
|
||||
|
||||
def _takeover_image_attachment(
|
||||
self,
|
||||
db: Session,
|
||||
avatar: Avatar,
|
||||
event: TakeoverMessage,
|
||||
) -> ChatAttachment:
|
||||
now = self.now()
|
||||
if event.attachment_id:
|
||||
cached = db.get(ChatAttachment, event.attachment_id)
|
||||
if cached and cached.status == "ready" and cached.expires_at > now:
|
||||
cached.used_at = now
|
||||
db.commit()
|
||||
return cached
|
||||
|
||||
downloaded = download_boxim_image(
|
||||
event.content,
|
||||
base_url=getattr(
|
||||
self.boxim,
|
||||
"im_base_url",
|
||||
os.getenv("BOXIM_API_BASE_URL", "https://im.99hui.com/api"),
|
||||
),
|
||||
)
|
||||
from routers.chat import _analyze_image_bytes
|
||||
|
||||
attachment = _analyze_image_bytes(
|
||||
db,
|
||||
avatar,
|
||||
downloaded.content,
|
||||
filename=downloaded.filename,
|
||||
mime_type=downloaded.mime_type,
|
||||
uploader_kind="boxim",
|
||||
)
|
||||
event.attachment_id = attachment.id
|
||||
attachment.used_at = now
|
||||
db.commit()
|
||||
logger.info(
|
||||
"BOXIM image analyzed message=%s attachment=%s avatar=%s category=%s",
|
||||
event.boxim_message_id,
|
||||
attachment.id,
|
||||
avatar.id,
|
||||
attachment.category,
|
||||
)
|
||||
return attachment
|
||||
|
||||
def _generate_reply(self, task_id: str) -> bool:
|
||||
db = self.session_factory()
|
||||
try:
|
||||
@@ -496,19 +855,59 @@ class TakeoverService:
|
||||
db.commit()
|
||||
|
||||
excluded_ids = set(task.source_message_ids or [])
|
||||
source_events = {
|
||||
event.boxim_message_id: event
|
||||
for event in (
|
||||
db.query(TakeoverMessage)
|
||||
.filter(
|
||||
TakeoverMessage.owner_id == task.owner_id,
|
||||
TakeoverMessage.peer_id == task.peer_id,
|
||||
TakeoverMessage.avatar_id == task.avatar_id,
|
||||
TakeoverMessage.boxim_message_id.in_(excluded_ids),
|
||||
)
|
||||
.all()
|
||||
if excluded_ids
|
||||
else []
|
||||
)
|
||||
}
|
||||
events = (
|
||||
db.query(TakeoverMessage)
|
||||
.filter(
|
||||
TakeoverMessage.owner_id == task.owner_id,
|
||||
TakeoverMessage.peer_id == task.peer_id,
|
||||
TakeoverMessage.avatar_id == task.avatar_id,
|
||||
)
|
||||
.order_by(TakeoverMessage.send_time.desc())
|
||||
.limit(30)
|
||||
.all()
|
||||
)
|
||||
image_attachments = []
|
||||
image_failed = False
|
||||
for message_id in (task.source_message_ids or [])[-3:]:
|
||||
event = source_events.get(message_id)
|
||||
if not event or event.message_type != BOXIM_IMAGE_MESSAGE_TYPE:
|
||||
continue
|
||||
try:
|
||||
image_attachments.append(
|
||||
self._takeover_image_attachment(db, avatar, event)
|
||||
)
|
||||
except (BoxIMImageError, ImageValidationError) as exc:
|
||||
image_failed = True
|
||||
logger.warning(
|
||||
"BOXIM image unavailable message=%s avatar=%s: %s",
|
||||
event.boxim_message_id,
|
||||
avatar.id,
|
||||
exc,
|
||||
)
|
||||
history = []
|
||||
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 event.message_type != BOXIM_TEXT_MESSAGE_TYPE
|
||||
or not event.content.strip()
|
||||
):
|
||||
continue
|
||||
if event.direction == "incoming" and event.is_avatar:
|
||||
continue
|
||||
history.append(
|
||||
{
|
||||
@@ -518,10 +917,21 @@ class TakeoverService:
|
||||
)
|
||||
history = history[-10:]
|
||||
|
||||
from routers.chat import _resolve_reply
|
||||
from routers.chat import _attachment_contexts, _resolve_reply
|
||||
|
||||
result = _resolve_reply(db, avatar, task.prompt, history)
|
||||
answer = _plain_text_reply(result.get("answer", ""))
|
||||
image_contexts = _attachment_contexts(image_attachments)
|
||||
if image_failed and not image_contexts:
|
||||
answer = BOXIM_IMAGE_UNAVAILABLE_REPLY
|
||||
else:
|
||||
result = _resolve_reply(
|
||||
db,
|
||||
avatar,
|
||||
task.prompt,
|
||||
history,
|
||||
usage_source="takeover",
|
||||
image_contexts=image_contexts,
|
||||
)
|
||||
answer = _plain_text_reply(result.get("answer", ""))
|
||||
db.refresh(task)
|
||||
if task.status != "generating":
|
||||
return False
|
||||
@@ -582,11 +992,20 @@ class TakeoverService:
|
||||
task.cancel_reason = "takeover_disabled"
|
||||
db.commit()
|
||||
return False
|
||||
if (self.now() - task.scheduled_at).total_seconds() > MAX_STALE_SECONDS:
|
||||
if (self.now() - task.scheduled_at).total_seconds() > MAX_SEND_OVERDUE_SECONDS:
|
||||
task.status = "cancelled"
|
||||
task.cancel_reason = "stale_reply"
|
||||
db.commit()
|
||||
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()
|
||||
if not user or not user.huihui_token:
|
||||
raise BoxIMError("缺少会会登录凭证", auth_error=True)
|
||||
|
||||
@@ -0,0 +1,203 @@
|
||||
"""User-scoped token accounting for every avatar model request."""
|
||||
|
||||
import math
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from models import Avatar, TokenAccount, TokenUsage, User
|
||||
|
||||
DEFAULT_TOKEN_GRANT = 1_000_000
|
||||
|
||||
|
||||
class InsufficientTokensError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class TokenReservation:
|
||||
usage_id: str
|
||||
user_id: str
|
||||
reserved_tokens: int
|
||||
|
||||
|
||||
def get_or_create_account(db: Session, user_id: str) -> TokenAccount:
|
||||
account = db.query(TokenAccount).filter(TokenAccount.user_id == user_id).first()
|
||||
if account:
|
||||
return account
|
||||
account = TokenAccount(
|
||||
user_id=user_id,
|
||||
balance=DEFAULT_TOKEN_GRANT,
|
||||
total_granted=DEFAULT_TOKEN_GRANT,
|
||||
total_consumed=0,
|
||||
)
|
||||
db.add(account)
|
||||
try:
|
||||
db.commit()
|
||||
except IntegrityError:
|
||||
# A concurrent first request may have created the same user account.
|
||||
db.rollback()
|
||||
account = db.query(TokenAccount).filter(TokenAccount.user_id == user_id).first()
|
||||
if account is None:
|
||||
raise
|
||||
db.refresh(account)
|
||||
return account
|
||||
|
||||
|
||||
def avatar_owner_user(db: Session, avatar: Avatar) -> User | None:
|
||||
owner_id = (avatar.owner_id or "").strip()
|
||||
if not owner_id:
|
||||
return None
|
||||
return db.query(User).filter(User.huihui_user_id == owner_id).first()
|
||||
|
||||
|
||||
def estimate_request_tokens(messages: list[dict], max_output_tokens: int) -> int:
|
||||
# UTF-8 bytes / 2 deliberately overestimates mixed Chinese/English prompts;
|
||||
# the unused reservation is returned after provider usage is received.
|
||||
content_bytes = sum(
|
||||
len(str(item.get("content", "")).encode("utf-8"))
|
||||
for item in messages
|
||||
)
|
||||
prompt_reserve = max(1, math.ceil(content_bytes / 2) + len(messages) * 6)
|
||||
return prompt_reserve + max(1, int(max_output_tokens))
|
||||
|
||||
|
||||
def estimate_fallback_usage(messages: list[dict], output: str) -> int:
|
||||
content_bytes = sum(
|
||||
len(str(item.get("content", "")).encode("utf-8"))
|
||||
for item in messages
|
||||
) + len((output or "").encode("utf-8"))
|
||||
return max(1, math.ceil(content_bytes / 3) + len(messages) * 4)
|
||||
|
||||
|
||||
def reserve_avatar_tokens(
|
||||
db: Session,
|
||||
avatar: Avatar,
|
||||
source: str,
|
||||
model: str,
|
||||
messages: list[dict],
|
||||
max_output_tokens: int,
|
||||
*,
|
||||
minimum_reserve_tokens: int = 0,
|
||||
) -> TokenReservation:
|
||||
user = avatar_owner_user(db, avatar)
|
||||
if not user:
|
||||
raise InsufficientTokensError("分身尚未关联有效用户,暂时无法使用积分")
|
||||
account = get_or_create_account(db, user.id)
|
||||
reserved = max(
|
||||
estimate_request_tokens(messages, max_output_tokens),
|
||||
max(0, int(minimum_reserve_tokens or 0)),
|
||||
)
|
||||
updated = (
|
||||
db.query(TokenAccount)
|
||||
.filter(TokenAccount.id == account.id, TokenAccount.balance >= reserved)
|
||||
.update(
|
||||
{TokenAccount.balance: TokenAccount.balance - reserved},
|
||||
synchronize_session=False,
|
||||
)
|
||||
)
|
||||
if updated != 1:
|
||||
db.rollback()
|
||||
raise InsufficientTokensError("积分余额不足,请充值后继续")
|
||||
db.refresh(account)
|
||||
usage = TokenUsage(
|
||||
user_id=user.id,
|
||||
avatar_id=avatar.id,
|
||||
source=source,
|
||||
model=model,
|
||||
status="reserved",
|
||||
reserved_tokens=reserved,
|
||||
)
|
||||
db.add(usage)
|
||||
db.flush()
|
||||
usage.balance_after = account.balance
|
||||
db.commit()
|
||||
return TokenReservation(usage.id, user.id, reserved)
|
||||
|
||||
|
||||
def settle_reservation(
|
||||
db: Session,
|
||||
reservation: TokenReservation,
|
||||
usage: dict | None,
|
||||
*,
|
||||
fallback_total: int,
|
||||
) -> dict:
|
||||
record = db.query(TokenUsage).filter(TokenUsage.id == reservation.usage_id).first()
|
||||
if not record or record.status != "reserved":
|
||||
return {}
|
||||
provider_usage = usage or {}
|
||||
prompt_tokens = max(0, int(provider_usage.get("prompt_tokens") or 0))
|
||||
completion_tokens = max(0, int(provider_usage.get("completion_tokens") or 0))
|
||||
provider_total = max(
|
||||
int(provider_usage.get("total_tokens") or 0),
|
||||
prompt_tokens + completion_tokens,
|
||||
)
|
||||
total_tokens = max(1, provider_total or int(fallback_total or 0))
|
||||
updated = (
|
||||
db.query(TokenAccount)
|
||||
.filter(TokenAccount.user_id == reservation.user_id)
|
||||
.update(
|
||||
{
|
||||
TokenAccount.balance: TokenAccount.balance + reservation.reserved_tokens - total_tokens,
|
||||
TokenAccount.total_consumed: TokenAccount.total_consumed + total_tokens,
|
||||
},
|
||||
synchronize_session=False,
|
||||
)
|
||||
)
|
||||
if updated != 1:
|
||||
raise RuntimeError("积分账户不存在")
|
||||
db.expire_all()
|
||||
account = db.query(TokenAccount).filter(TokenAccount.user_id == reservation.user_id).first()
|
||||
record.prompt_tokens = prompt_tokens
|
||||
record.completion_tokens = completion_tokens
|
||||
record.total_tokens = total_tokens
|
||||
record.balance_after = account.balance
|
||||
record.status = "completed"
|
||||
record.settled_at = datetime.utcnow()
|
||||
db.commit()
|
||||
return {
|
||||
"promptTokens": prompt_tokens,
|
||||
"completionTokens": completion_tokens,
|
||||
"totalTokens": total_tokens,
|
||||
"balance": account.balance,
|
||||
}
|
||||
|
||||
|
||||
def release_reservation(db: Session, reservation: TokenReservation, reason: str = "") -> None:
|
||||
record = db.query(TokenUsage).filter(TokenUsage.id == reservation.usage_id).first()
|
||||
if not record or record.status != "reserved":
|
||||
return
|
||||
updated = (
|
||||
db.query(TokenAccount)
|
||||
.filter(TokenAccount.user_id == reservation.user_id)
|
||||
.update(
|
||||
{TokenAccount.balance: TokenAccount.balance + reservation.reserved_tokens},
|
||||
synchronize_session=False,
|
||||
)
|
||||
)
|
||||
if updated:
|
||||
db.expire_all()
|
||||
account = db.query(TokenAccount).filter(TokenAccount.user_id == reservation.user_id).first()
|
||||
record = db.query(TokenUsage).filter(TokenUsage.id == reservation.usage_id).first()
|
||||
record.balance_after = account.balance
|
||||
record.status = "failed"
|
||||
record.failure_reason = (reason or "model_request_failed")[:255]
|
||||
record.settled_at = datetime.utcnow()
|
||||
db.commit()
|
||||
|
||||
|
||||
def release_stale_reservations(db: Session, older_than_minutes: int = 10) -> int:
|
||||
cutoff = datetime.utcnow() - timedelta(minutes=older_than_minutes)
|
||||
stale = db.query(TokenUsage).filter(
|
||||
TokenUsage.status == "reserved",
|
||||
TokenUsage.created_at < cutoff,
|
||||
).all()
|
||||
for record in stale:
|
||||
release_reservation(
|
||||
db,
|
||||
TokenReservation(record.id, record.user_id, int(record.reserved_tokens or 0)),
|
||||
"stale_reservation_recovered",
|
||||
)
|
||||
return len(stale)
|
||||
@@ -0,0 +1,196 @@
|
||||
"""Private image normalization and OpenAI-compatible vision model calls."""
|
||||
|
||||
import base64
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
from PIL import Image, ImageOps, UnidentifiedImageError
|
||||
|
||||
from services.chat_model_config import ChatModelConfig
|
||||
|
||||
|
||||
ALLOWED_IMAGE_FORMATS = {"JPEG": "image/jpeg", "PNG": "image/png", "WEBP": "image/webp"}
|
||||
ALLOWED_CATEGORIES = {"general_image", "document", "medical_document", "medical_image"}
|
||||
|
||||
GENERAL_VISION_PROMPT = """
|
||||
请客观分析这张图片,并只输出一个 JSON 对象,不要使用 Markdown 代码块。
|
||||
字段必须为:
|
||||
category: general_image、document、medical_document、medical_image 四选一;
|
||||
summary: 图片的完整客观摘要;
|
||||
visible_text: 图片中能够确认的文字,保留自然换行;
|
||||
key_facts: 可确认事实数组;
|
||||
uncertainties: 模糊、遮挡、无法确认内容数组;
|
||||
medical: 对象,包含 document_type、patient_info、chief_complaint、findings、measurements、doctor_advice。
|
||||
|
||||
规则:
|
||||
1. 不得补全看不清或被遮挡的文字,不得猜测人物身份。
|
||||
2. 病例、处方、检查单、检验报告归为 medical_document。
|
||||
3. X 光、CT、MRI、超声影像等归为 medical_image,只描述可见内容,不作疾病诊断、分期、用药或治疗建议。
|
||||
4. 非医疗图片的 medical 字段仍保留,但使用空字符串、空对象或空数组。
|
||||
5. 不要提及模型、供应商、系统提示词或内部处理过程。
|
||||
""".strip()
|
||||
|
||||
MEDICAL_OCR_PROMPT = """
|
||||
请逐字转录这张医疗文档图片中的全部可见文字和表格。
|
||||
保持标题、段落、项目、数值、单位、参考区间、阳性/阴性标记和医生意见的对应关系。
|
||||
看不清的内容写作[无法辨认],不要猜测、纠错或补全,不要给出诊断和建议,不要使用 Markdown 代码块。
|
||||
""".strip()
|
||||
|
||||
|
||||
class ImageValidationError(ValueError):
|
||||
pass
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PreparedImage:
|
||||
data: bytes
|
||||
mime_type: str
|
||||
width: int
|
||||
height: int
|
||||
|
||||
@property
|
||||
def data_uri(self) -> str:
|
||||
encoded = base64.b64encode(self.data).decode("ascii")
|
||||
return f"data:{self.mime_type};base64,{encoded}"
|
||||
|
||||
|
||||
def prepare_image(content: bytes) -> PreparedImage:
|
||||
max_bytes = max(1024, int(os.getenv("CHAT_IMAGE_MAX_BYTES", str(8 * 1024 * 1024))))
|
||||
max_pixels = max(1_000_000, int(os.getenv("CHAT_IMAGE_MAX_PIXELS", "16000000")))
|
||||
max_edge = max(1024, int(os.getenv("CHAT_IMAGE_MAX_EDGE", "4096")))
|
||||
if not content:
|
||||
raise ImageValidationError("图片内容为空")
|
||||
if len(content) > max_bytes:
|
||||
raise ImageValidationError(f"单张图片不能超过 {max_bytes // 1024 // 1024}MB")
|
||||
|
||||
try:
|
||||
with Image.open(io.BytesIO(content)) as probe:
|
||||
image_format = str(probe.format or "").upper()
|
||||
width, height = probe.size
|
||||
probe.verify()
|
||||
except (UnidentifiedImageError, OSError, SyntaxError) as exc:
|
||||
raise ImageValidationError("图片格式无效或文件已损坏") from exc
|
||||
|
||||
if image_format not in ALLOWED_IMAGE_FORMATS:
|
||||
raise ImageValidationError("仅支持 JPG、PNG、WebP 图片")
|
||||
if width <= 0 or height <= 0 or width * height > max_pixels:
|
||||
raise ImageValidationError("图片像素过大,请压缩后重新上传")
|
||||
|
||||
try:
|
||||
with Image.open(io.BytesIO(content)) as original:
|
||||
image = ImageOps.exif_transpose(original)
|
||||
image.load()
|
||||
if max(image.size) > max_edge:
|
||||
image.thumbnail((max_edge, max_edge), Image.Resampling.LANCZOS)
|
||||
if image.mode in {"RGBA", "LA"}:
|
||||
canvas = Image.new("RGB", image.size, "white")
|
||||
alpha = image.getchannel("A")
|
||||
canvas.paste(image.convert("RGB"), mask=alpha)
|
||||
image = canvas
|
||||
elif image.mode != "RGB":
|
||||
image = image.convert("RGB")
|
||||
output = io.BytesIO()
|
||||
image.save(output, format="JPEG", quality=92, optimize=True)
|
||||
normalized = output.getvalue()
|
||||
normalized_width, normalized_height = image.size
|
||||
except (OSError, ValueError) as exc:
|
||||
raise ImageValidationError("图片解码失败,请重新选择图片") from exc
|
||||
|
||||
return PreparedImage(
|
||||
data=normalized,
|
||||
mime_type="image/jpeg",
|
||||
width=normalized_width,
|
||||
height=normalized_height,
|
||||
)
|
||||
|
||||
|
||||
def call_vision_model(
|
||||
prepared: PreparedImage,
|
||||
model_config: ChatModelConfig,
|
||||
*,
|
||||
model: str,
|
||||
prompt: str,
|
||||
json_output: bool,
|
||||
) -> dict:
|
||||
if not model_config.api_key:
|
||||
raise RuntimeError("视觉模型服务未配置")
|
||||
payload: dict[str, Any] = {
|
||||
"model": model,
|
||||
"messages": [
|
||||
{
|
||||
"role": "user",
|
||||
"content": [
|
||||
{"type": "image_url", "image_url": {"url": prepared.data_uri}},
|
||||
{"type": "text", "text": prompt},
|
||||
],
|
||||
}
|
||||
],
|
||||
"temperature": 0,
|
||||
"max_tokens": model_config.vision_max_tokens,
|
||||
}
|
||||
if json_output:
|
||||
payload["response_format"] = {"type": "json_object"}
|
||||
try:
|
||||
response = httpx.post(
|
||||
f"{model_config.api_base_url}/chat/completions",
|
||||
headers={"Authorization": f"Bearer {model_config.api_key}"},
|
||||
json=payload,
|
||||
timeout=model_config.vision_timeout_seconds,
|
||||
)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
content = data.get("choices", [{}])[0].get("message", {}).get("content", "")
|
||||
except (httpx.HTTPError, ValueError, KeyError, IndexError) as exc:
|
||||
raise RuntimeError("图片识别服务暂时不可用") from exc
|
||||
if not isinstance(content, str) or not content.strip():
|
||||
raise RuntimeError("图片识别服务没有返回有效结果")
|
||||
return {"content": content.strip(), "usage": data.get("usage") or {}}
|
||||
|
||||
|
||||
def parse_vision_analysis(content: str) -> dict:
|
||||
value = (content or "").strip()
|
||||
fenced = re.match(r"^```(?:json)?\s*(.*?)\s*```$", value, re.DOTALL | re.IGNORECASE)
|
||||
if fenced:
|
||||
value = fenced.group(1).strip()
|
||||
try:
|
||||
payload = json.loads(value)
|
||||
except (TypeError, ValueError) as exc:
|
||||
raise RuntimeError("图片识别结果格式无效") from exc
|
||||
if not isinstance(payload, dict):
|
||||
raise RuntimeError("图片识别结果格式无效")
|
||||
|
||||
category = str(payload.get("category") or "general_image").strip().lower()
|
||||
if category not in ALLOWED_CATEGORIES:
|
||||
category = "general_image"
|
||||
medical = payload.get("medical") if isinstance(payload.get("medical"), dict) else {}
|
||||
return {
|
||||
"category": category,
|
||||
"summary": str(payload.get("summary") or "").strip(),
|
||||
"visible_text": str(payload.get("visible_text") or "").strip(),
|
||||
"key_facts": _string_list(payload.get("key_facts")),
|
||||
"uncertainties": _string_list(payload.get("uncertainties")),
|
||||
"medical": medical,
|
||||
}
|
||||
|
||||
|
||||
def build_attachment_warning(analysis: dict, *, ocr_failed: bool = False) -> str:
|
||||
warnings = list(analysis.get("uncertainties") or [])
|
||||
category = analysis.get("category")
|
||||
if ocr_failed:
|
||||
warnings.append("精确文字识别暂时不可用,请人工核对图片原文")
|
||||
if category == "medical_document":
|
||||
warnings.append("病例识别结果仅供辅助,不能替代医生诊断,请核对原始文档")
|
||||
elif category == "medical_image":
|
||||
warnings.append("医学影像仅作客观描述,不能替代影像报告和医生诊断")
|
||||
return ";".join(dict.fromkeys(item for item in warnings if item))
|
||||
|
||||
|
||||
def _string_list(value: Any) -> list[str]:
|
||||
if not isinstance(value, list):
|
||||
return []
|
||||
return [str(item).strip() for item in value if str(item).strip()]
|
||||
@@ -0,0 +1,245 @@
|
||||
"""WeChat mini-program virtual-payment signing and server API adapter.
|
||||
|
||||
The AppKey and session_key never leave the backend. The JSON string returned as
|
||||
``signData`` is exactly the string used for both HMAC signatures.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
import os
|
||||
import time
|
||||
import xml.etree.ElementTree as ET
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
|
||||
|
||||
REQUEST_VIRTUAL_PAYMENT_URI = "requestVirtualPayment"
|
||||
PAYMENT_EVENTS = {"xpay_goods_deliver_notify"}
|
||||
REFUND_EVENTS = {"xpay_refund_notify"}
|
||||
|
||||
|
||||
class WechatVirtualPaymentError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
def json_compact(payload: dict[str, Any]) -> str:
|
||||
return json.dumps(payload, ensure_ascii=False, separators=(",", ":"))
|
||||
|
||||
|
||||
def hmac_sha256_hex(key: str, message: str) -> str:
|
||||
return hmac.new(key.encode("utf-8"), message.encode("utf-8"), hashlib.sha256).hexdigest()
|
||||
|
||||
|
||||
def virtual_env() -> int:
|
||||
value = os.getenv("WECHAT_VIRTUAL_ENV", "sandbox").strip().lower()
|
||||
return 0 if value in {"0", "prod", "production", "live", "online"} else 1
|
||||
|
||||
|
||||
def _app_key(env: int) -> str:
|
||||
name = "WECHAT_VIRTUAL_APP_KEY" if env == 0 else "WECHAT_VIRTUAL_SANDBOX_APP_KEY"
|
||||
return os.getenv(name, "").strip()
|
||||
|
||||
|
||||
def _offer_id() -> str:
|
||||
return os.getenv("WECHAT_VIRTUAL_OFFER_ID", "").strip()
|
||||
|
||||
|
||||
def product_id_for_plan(plan) -> str:
|
||||
configured = str(getattr(plan, "virtual_product_id", "") or "").strip()
|
||||
if not configured:
|
||||
configured = os.getenv(f"WECHAT_VIRTUAL_PRODUCT_{plan.id}", "").strip()
|
||||
if not configured:
|
||||
raise WechatVirtualPaymentError(f"套餐 {plan.id} 尚未配置微信虚拟支付商品 ID")
|
||||
if len(configured) > 64 or not all(ch.isalnum() or ch in "_-" for ch in configured):
|
||||
raise WechatVirtualPaymentError("微信虚拟支付商品 ID 格式不正确")
|
||||
return configured
|
||||
|
||||
|
||||
def build_payment_params(*, order, plan, session_key: str) -> dict[str, Any]:
|
||||
env = virtual_env()
|
||||
offer_id = _offer_id()
|
||||
app_key = _app_key(env)
|
||||
if not offer_id or not app_key or not session_key:
|
||||
raise WechatVirtualPaymentError("微信小程序虚拟支付配置不完整")
|
||||
|
||||
sign_data = json_compact({
|
||||
"offerId": offer_id,
|
||||
"buyQuantity": 1,
|
||||
"env": env,
|
||||
"currencyType": "CNY",
|
||||
"productId": product_id_for_plan(plan),
|
||||
"goodsPrice": int(order.price_cents),
|
||||
"outTradeNo": order.order_no,
|
||||
"attach": json_compact({"orderNo": order.order_no, "planId": order.plan_id}),
|
||||
})
|
||||
return {
|
||||
"provider": "wechat_virtual",
|
||||
"payment_channel": "virtual",
|
||||
"payment_method": "wechat",
|
||||
"mode": "short_series_goods",
|
||||
"signData": sign_data,
|
||||
"paySig": hmac_sha256_hex(app_key, f"{REQUEST_VIRTUAL_PAYMENT_URI}&{sign_data}"),
|
||||
"signature": hmac_sha256_hex(session_key, sign_data),
|
||||
"env": env,
|
||||
"offerId": offer_id,
|
||||
"outTradeNo": order.order_no,
|
||||
}
|
||||
|
||||
|
||||
def exchange_code(code: str) -> dict[str, str]:
|
||||
app_id = os.getenv("WECHAT_MP_APP_ID", "").strip()
|
||||
app_secret = os.getenv("WECHAT_MP_APP_SECRET", "").strip()
|
||||
if not app_id or not app_secret:
|
||||
raise WechatVirtualPaymentError("微信小程序登录配置不完整")
|
||||
try:
|
||||
response = httpx.get(
|
||||
"https://api.weixin.qq.com/sns/jscode2session",
|
||||
params={
|
||||
"appid": app_id,
|
||||
"secret": app_secret,
|
||||
"js_code": code,
|
||||
"grant_type": "authorization_code",
|
||||
},
|
||||
timeout=15,
|
||||
)
|
||||
data = response.json()
|
||||
except (httpx.HTTPError, ValueError) as exc:
|
||||
raise WechatVirtualPaymentError("微信登录态交换失败,请稍后重试") from exc
|
||||
if response.status_code >= 400 or data.get("errcode"):
|
||||
raise WechatVirtualPaymentError(data.get("errmsg") or "微信登录态交换失败")
|
||||
openid = str(data.get("openid") or "").strip()
|
||||
session_key = str(data.get("session_key") or "").strip()
|
||||
if not openid or not session_key:
|
||||
raise WechatVirtualPaymentError("微信未返回完整登录态")
|
||||
return {"openid": openid, "session_key": session_key}
|
||||
|
||||
|
||||
def verify_callback_signature(signature: str, timestamp: str, nonce: str) -> bool:
|
||||
token = os.getenv("WECHAT_VIRTUAL_CALLBACK_TOKEN", "").strip()
|
||||
if not token or not signature or not timestamp or not nonce:
|
||||
return False
|
||||
source = "".join(sorted([token, timestamp, nonce]))
|
||||
expected = hashlib.sha1(source.encode("utf-8")).hexdigest()
|
||||
return hmac.compare_digest(signature, expected)
|
||||
|
||||
|
||||
def _xml_value(element: ET.Element) -> Any:
|
||||
children = list(element)
|
||||
if not children:
|
||||
return element.text or ""
|
||||
return {child.tag: _xml_value(child) for child in children}
|
||||
|
||||
|
||||
def parse_callback_body(body: bytes) -> dict[str, Any]:
|
||||
text = body.decode("utf-8", errors="replace").strip()
|
||||
if not text:
|
||||
return {}
|
||||
try:
|
||||
payload = json.loads(text)
|
||||
if isinstance(payload, dict):
|
||||
return payload
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
try:
|
||||
parsed = _xml_value(ET.fromstring(text))
|
||||
except ET.ParseError as exc:
|
||||
raise WechatVirtualPaymentError("微信虚拟支付回调格式不正确") from exc
|
||||
return parsed if isinstance(parsed, dict) else {}
|
||||
|
||||
|
||||
def case_get(payload: Any, key: str) -> Any:
|
||||
if not isinstance(payload, dict):
|
||||
return None
|
||||
lowered = key.lower()
|
||||
for current, value in payload.items():
|
||||
if str(current).lower() == lowered:
|
||||
return value
|
||||
return None
|
||||
|
||||
|
||||
def callback_value(payload: dict[str, Any], *path: str) -> Any:
|
||||
current: Any = payload
|
||||
for key in path:
|
||||
current = case_get(current, key)
|
||||
if current is None:
|
||||
break
|
||||
return current
|
||||
|
||||
|
||||
_access_token_cache: tuple[str, float] = ("", 0)
|
||||
|
||||
|
||||
def _access_token() -> str:
|
||||
global _access_token_cache
|
||||
token, expires_at = _access_token_cache
|
||||
if token and expires_at > time.monotonic() + 60:
|
||||
return token
|
||||
app_id = os.getenv("WECHAT_MP_APP_ID", "").strip()
|
||||
app_secret = os.getenv("WECHAT_MP_APP_SECRET", "").strip()
|
||||
if not app_id or not app_secret:
|
||||
raise WechatVirtualPaymentError("微信小程序服务端配置不完整")
|
||||
try:
|
||||
response = httpx.get(
|
||||
"https://api.weixin.qq.com/cgi-bin/token",
|
||||
params={"grant_type": "client_credential", "appid": app_id, "secret": app_secret},
|
||||
timeout=15,
|
||||
)
|
||||
data = response.json()
|
||||
except (httpx.HTTPError, ValueError) as exc:
|
||||
raise WechatVirtualPaymentError("微信 access_token 获取失败") from exc
|
||||
if response.status_code >= 400 or data.get("errcode"):
|
||||
raise WechatVirtualPaymentError(data.get("errmsg") or "微信 access_token 获取失败")
|
||||
token = str(data.get("access_token") or "")
|
||||
if not token:
|
||||
raise WechatVirtualPaymentError("微信未返回 access_token")
|
||||
_access_token_cache = (token, time.monotonic() + int(data.get("expires_in") or 7200))
|
||||
return token
|
||||
|
||||
|
||||
def call_xpay(uri: str, payload: dict[str, Any]) -> dict[str, Any]:
|
||||
env = int(payload.get("env", virtual_env()))
|
||||
app_key = _app_key(env)
|
||||
if not app_key:
|
||||
raise WechatVirtualPaymentError("微信虚拟支付 AppKey 未配置")
|
||||
body = json_compact(payload)
|
||||
pay_sig = hmac_sha256_hex(app_key, f"{uri}&{body}")
|
||||
try:
|
||||
response = httpx.post(
|
||||
f"https://api.weixin.qq.com{uri}",
|
||||
params={"access_token": _access_token(), "pay_sig": pay_sig},
|
||||
content=body.encode("utf-8"),
|
||||
headers={"Content-Type": "application/json"},
|
||||
timeout=20,
|
||||
)
|
||||
data = response.json()
|
||||
except (httpx.HTTPError, ValueError) as exc:
|
||||
raise WechatVirtualPaymentError("微信虚拟支付服务暂时不可用") from exc
|
||||
if response.status_code >= 400 or data.get("errcode") not in (None, 0):
|
||||
raise WechatVirtualPaymentError(data.get("errmsg") or "微信虚拟支付请求失败")
|
||||
return data
|
||||
|
||||
|
||||
def request_refund(*, openid: str, order_no: str, refund_no: str, amount_cents: int, reason: int = 3) -> dict[str, Any]:
|
||||
return call_xpay("/xpay/refund_order", {
|
||||
"openid": openid,
|
||||
"order_id": order_no,
|
||||
"refund_order_id": refund_no,
|
||||
"left_fee": amount_cents,
|
||||
"refund_fee": amount_cents,
|
||||
"biz_meta": json_compact({"orderNo": order_no}),
|
||||
"refund_reason": int(reason),
|
||||
"req_from": 1,
|
||||
"env": virtual_env(),
|
||||
})
|
||||
|
||||
|
||||
def query_order(*, openid: str, order_no: str) -> dict[str, Any]:
|
||||
return call_xpay("/xpay/query_order", {
|
||||
"openid": openid,
|
||||
"order_id": order_no,
|
||||
"env": virtual_env(),
|
||||
})
|
||||
@@ -5,9 +5,16 @@ from database import init_db, SessionLocal
|
||||
from models import (
|
||||
Authorization,
|
||||
Avatar,
|
||||
ChatAttachment,
|
||||
InvoiceApplication,
|
||||
PaymentRefund,
|
||||
PaymentTransaction,
|
||||
TakeoverCursor,
|
||||
TakeoverMessage,
|
||||
TakeoverReplyTask,
|
||||
TokenAccount,
|
||||
TokenPaymentOrder,
|
||||
TokenUsage,
|
||||
User,
|
||||
)
|
||||
|
||||
@@ -92,6 +99,9 @@ def authorization_context():
|
||||
finally:
|
||||
db.rollback()
|
||||
avatar_ids = [avatar.id, other_avatar.id]
|
||||
db.query(ChatAttachment).filter(
|
||||
ChatAttachment.avatar_id.in_(avatar_ids)
|
||||
).delete(synchronize_session=False)
|
||||
db.query(TakeoverReplyTask).filter(
|
||||
TakeoverReplyTask.avatar_id.in_(avatar_ids)
|
||||
).delete(synchronize_session=False)
|
||||
@@ -107,6 +117,31 @@ def authorization_context():
|
||||
db.query(Avatar).filter(Avatar.id.in_(avatar_ids)).delete(
|
||||
synchronize_session=False
|
||||
)
|
||||
user_ids = [owner.id, other.id]
|
||||
order_numbers = [
|
||||
row[0] for row in db.query(TokenPaymentOrder.order_no).filter(
|
||||
TokenPaymentOrder.user_id.in_(user_ids)
|
||||
).all()
|
||||
]
|
||||
if order_numbers:
|
||||
db.query(InvoiceApplication).filter(InvoiceApplication.order_no.in_(order_numbers)).delete(
|
||||
synchronize_session=False
|
||||
)
|
||||
db.query(PaymentRefund).filter(PaymentRefund.order_no.in_(order_numbers)).delete(
|
||||
synchronize_session=False
|
||||
)
|
||||
db.query(PaymentTransaction).filter(PaymentTransaction.order_no.in_(order_numbers)).delete(
|
||||
synchronize_session=False
|
||||
)
|
||||
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(
|
||||
synchronize_session=False
|
||||
)
|
||||
db.query(TokenAccount).filter(TokenAccount.user_id.in_(user_ids)).delete(
|
||||
synchronize_session=False
|
||||
)
|
||||
db.query(User).filter(User.id.in_([owner.id, other.id])).delete(
|
||||
synchronize_session=False
|
||||
)
|
||||
|
||||
@@ -103,6 +103,7 @@ def test_avatar_permission_settings_default_and_persist(authorization_context):
|
||||
assert initial["data"] == {
|
||||
"avatarId": context["avatar"].id,
|
||||
"permissions": ["friend", "chat"],
|
||||
"takeoverReplyDelaySeconds": 180,
|
||||
}
|
||||
|
||||
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()
|
||||
assert reloaded["data"]["permissions"] == ["friend", "publish", "interact", "takeover"]
|
||||
assert reloaded["data"]["takeoverReplyDelaySeconds"] == 180
|
||||
|
||||
|
||||
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)
|
||||
assert unauthenticated.status_code == 401
|
||||
|
||||
|
||||
def test_takeover_delay_minimum_and_single_active_avatar_per_owner(authorization_context):
|
||||
from database import SessionLocal
|
||||
from models import Avatar
|
||||
|
||||
context = authorization_context
|
||||
endpoint = f"/api/avatar/{context['avatar'].id}/permission-settings"
|
||||
invalid = client.put(
|
||||
endpoint,
|
||||
headers=context["owner_headers"],
|
||||
json={"permissions": ["chat"], "takeoverReplyDelaySeconds": 2},
|
||||
).json()
|
||||
assert invalid["code"] == 400
|
||||
|
||||
second_avatar_id = f"second-{context['suffix']}"
|
||||
db = SessionLocal()
|
||||
try:
|
||||
db.add(
|
||||
Avatar(
|
||||
id=second_avatar_id,
|
||||
owner_id=context["owner"].huihui_user_id,
|
||||
name="第二个分身",
|
||||
status="active",
|
||||
config={"authorizationPermissions": ["chat", "takeover"]},
|
||||
)
|
||||
)
|
||||
db.commit()
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
try:
|
||||
updated = client.put(
|
||||
endpoint,
|
||||
headers=context["owner_headers"],
|
||||
json={"permissions": ["chat", "takeover"], "takeoverReplyDelaySeconds": 3},
|
||||
).json()
|
||||
assert updated["code"] == 200
|
||||
assert updated["data"]["takeoverReplyDelaySeconds"] == 3
|
||||
assert updated["data"]["disabledAvatarIds"] == [second_avatar_id]
|
||||
|
||||
db = SessionLocal()
|
||||
try:
|
||||
second = db.query(Avatar).filter(Avatar.id == second_avatar_id).one()
|
||||
assert "takeover" not in second.config["authorizationPermissions"]
|
||||
finally:
|
||||
db.close()
|
||||
finally:
|
||||
db = SessionLocal()
|
||||
try:
|
||||
db.query(Avatar).filter(Avatar.id == second_avatar_id).delete()
|
||||
db.commit()
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
"""Ownership and configuration-isolation tests for digital avatars."""
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from database import SessionLocal
|
||||
from main import app
|
||||
from models import Avatar
|
||||
|
||||
|
||||
client = TestClient(app)
|
||||
|
||||
|
||||
def test_avatar_detail_and_update_require_the_owner(authorization_context):
|
||||
context = authorization_context
|
||||
avatar_id = context["avatar"].id
|
||||
|
||||
assert client.get(f"/api/avatar/{avatar_id}").status_code == 401
|
||||
assert client.get(
|
||||
f"/api/avatar/{avatar_id}", headers=context["other_headers"]
|
||||
).status_code == 403
|
||||
|
||||
updated = client.put(
|
||||
f"/api/avatar/{avatar_id}",
|
||||
headers=context["owner_headers"],
|
||||
json={
|
||||
"description": "独立描述",
|
||||
"config": {"replyStyle": "concise"},
|
||||
},
|
||||
)
|
||||
assert updated.status_code == 200
|
||||
assert updated.json()["data"]["description"] == "独立描述"
|
||||
|
||||
forbidden = client.put(
|
||||
f"/api/avatar/{avatar_id}",
|
||||
headers=context["other_headers"],
|
||||
json={"description": "越权修改"},
|
||||
)
|
||||
assert forbidden.status_code == 403
|
||||
|
||||
|
||||
def test_avatar_config_updates_do_not_erase_takeover_or_knowledge_scope(authorization_context):
|
||||
context = authorization_context
|
||||
avatar_id = context["avatar"].id
|
||||
db = SessionLocal()
|
||||
try:
|
||||
avatar = db.query(Avatar).filter(Avatar.id == avatar_id).one()
|
||||
avatar.config = {
|
||||
"authorizationPermissions": ["chat", "takeover"],
|
||||
"takeoverReplyDelaySeconds": 180,
|
||||
}
|
||||
db.commit()
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
response = client.put(
|
||||
f"/api/avatar/{avatar_id}",
|
||||
headers=context["owner_headers"],
|
||||
json={"config": {"replyStyle": "warm", "creativity": 25}},
|
||||
).json()
|
||||
config = response["data"]["config"]
|
||||
assert config["replyStyle"] == "warm"
|
||||
assert config["creativity"] == 25
|
||||
assert config["authorizationPermissions"] == ["chat", "takeover"]
|
||||
assert config["takeoverReplyDelaySeconds"] == 180
|
||||
|
||||
|
||||
def test_avatar_create_and_delete_require_login_and_ownership(authorization_context):
|
||||
context = authorization_context
|
||||
assert client.post("/api/avatar", json={"name": "匿名分身"}).status_code == 401
|
||||
|
||||
created = client.post(
|
||||
"/api/avatar",
|
||||
headers=context["owner_headers"],
|
||||
json={"name": "待删除分身"},
|
||||
)
|
||||
assert created.status_code == 200
|
||||
avatar_id = created.json()["data"]["id"]
|
||||
|
||||
try:
|
||||
assert client.delete(
|
||||
f"/api/avatar/{avatar_id}", headers=context["other_headers"]
|
||||
).status_code == 403
|
||||
deleted = client.delete(
|
||||
f"/api/avatar/{avatar_id}", headers=context["owner_headers"]
|
||||
).json()
|
||||
assert deleted["code"] == 200
|
||||
finally:
|
||||
db = SessionLocal()
|
||||
try:
|
||||
db.query(Avatar).filter(Avatar.id == avatar_id).delete()
|
||||
db.commit()
|
||||
finally:
|
||||
db.close()
|
||||
@@ -0,0 +1,71 @@
|
||||
import ipaddress
|
||||
import json
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from services.boxim_image_service import (
|
||||
BoxIMImageError,
|
||||
download_boxim_image,
|
||||
parse_boxim_image_url,
|
||||
)
|
||||
|
||||
|
||||
def test_parse_boxim_image_prefers_origin_and_supports_relative_url():
|
||||
content = json.dumps({"originUrl": "/files/original.png", "thumbUrl": "/thumb.png"})
|
||||
assert parse_boxim_image_url(content, base_url="https://im.example/api") == (
|
||||
"https://im.example/files/original.png"
|
||||
)
|
||||
|
||||
|
||||
def test_download_boxim_image_streams_public_https(monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
"services.boxim_image_service._resolved_addresses",
|
||||
lambda _host, _port: {ipaddress.ip_address("8.8.8.8")},
|
||||
)
|
||||
transport = httpx.MockTransport(
|
||||
lambda request: httpx.Response(
|
||||
200,
|
||||
headers={"content-type": "image/png"},
|
||||
content=b"png-bytes",
|
||||
request=request,
|
||||
)
|
||||
)
|
||||
|
||||
image = download_boxim_image(
|
||||
json.dumps({"originUrl": "https://cdn.example/case%20photo.png"}),
|
||||
transport=transport,
|
||||
)
|
||||
|
||||
assert image.content == b"png-bytes"
|
||||
assert image.filename == "case photo.png"
|
||||
assert image.mime_type == "image/png"
|
||||
|
||||
|
||||
def test_download_boxim_image_rejects_private_network_url():
|
||||
with pytest.raises(BoxIMImageError, match="受限网络"):
|
||||
download_boxim_image(
|
||||
json.dumps({"originUrl": "https://127.0.0.1/private.png"}),
|
||||
transport=httpx.MockTransport(lambda request: httpx.Response(200, request=request)),
|
||||
)
|
||||
|
||||
|
||||
def test_download_boxim_image_stops_oversized_stream(monkeypatch):
|
||||
monkeypatch.setenv("CHAT_IMAGE_MAX_BYTES", "1024")
|
||||
monkeypatch.setattr(
|
||||
"services.boxim_image_service._resolved_addresses",
|
||||
lambda _host, _port: {ipaddress.ip_address("8.8.8.8")},
|
||||
)
|
||||
transport = httpx.MockTransport(
|
||||
lambda request: httpx.Response(
|
||||
200,
|
||||
headers={"content-length": "2048"},
|
||||
request=request,
|
||||
)
|
||||
)
|
||||
|
||||
with pytest.raises(BoxIMImageError, match="超过大小限制"):
|
||||
download_boxim_image(
|
||||
json.dumps({"originUrl": "https://cdn.example/large.png"}),
|
||||
transport=transport,
|
||||
)
|
||||
@@ -0,0 +1,332 @@
|
||||
import json
|
||||
from datetime import datetime, timedelta
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
import pytest
|
||||
from fastapi import HTTPException
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from database import SessionLocal
|
||||
from main import app
|
||||
from models import ChatAttachment
|
||||
from routers.chat import (
|
||||
ChatIn,
|
||||
_answer_denies_available_image,
|
||||
_attachment_contexts,
|
||||
_load_chat_attachments,
|
||||
_resolve_reply,
|
||||
)
|
||||
from services.chat_attachment_service import purge_expired_chat_attachments
|
||||
from services.token_billing import InsufficientTokensError
|
||||
from services.vision_service import PreparedImage
|
||||
|
||||
|
||||
client = TestClient(app)
|
||||
|
||||
|
||||
GENERAL_RESULT = {
|
||||
"content": json.dumps({
|
||||
"category": "general_image",
|
||||
"summary": "一张包含产品路线图的截图",
|
||||
"visible_text": "产品路线图",
|
||||
"key_facts": ["包含三个阶段"],
|
||||
"uncertainties": [],
|
||||
"medical": {},
|
||||
}, ensure_ascii=False),
|
||||
"usage": {"total_tokens": 120},
|
||||
}
|
||||
|
||||
|
||||
def test_owner_can_upload_and_cache_image_analysis(authorization_context):
|
||||
context = authorization_context
|
||||
prepared = PreparedImage(b"jpeg", "image/jpeg", 100, 80)
|
||||
with (
|
||||
patch("routers.chat.prepare_image", return_value=prepared),
|
||||
patch("routers.chat._run_billed_vision_call", return_value=GENERAL_RESULT),
|
||||
):
|
||||
response = client.post(
|
||||
f"/api/avatar/{context['avatar'].id}/chat/images",
|
||||
headers=context["owner_headers"],
|
||||
files={"file": ("roadmap.png", b"image-bytes", "image/png")},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
payload = response.json()["data"]
|
||||
assert payload["status"] == "ready"
|
||||
assert payload["category"] == "general_image"
|
||||
assert payload["summary"] == "一张包含产品路线图的截图"
|
||||
db = SessionLocal()
|
||||
try:
|
||||
stored = db.query(ChatAttachment).filter(ChatAttachment.id == payload["id"]).one()
|
||||
assert stored.avatar_id == context["avatar"].id
|
||||
assert stored.extracted_text == "产品路线图"
|
||||
assert stored.structured_data["key_facts"] == ["包含三个阶段"]
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
def test_non_owner_cannot_upload_chat_image(authorization_context):
|
||||
context = authorization_context
|
||||
response = client.post(
|
||||
f"/api/avatar/{context['avatar'].id}/chat/images",
|
||||
headers=context["other_headers"],
|
||||
files={"file": ("private.png", b"image-bytes", "image/png")},
|
||||
)
|
||||
|
||||
assert response.status_code == 403
|
||||
|
||||
|
||||
def test_image_upload_preserves_insufficient_points_response(authorization_context):
|
||||
context = authorization_context
|
||||
with patch(
|
||||
"routers.chat._analyze_image_bytes",
|
||||
side_effect=InsufficientTokensError("积分余额不足"),
|
||||
):
|
||||
response = client.post(
|
||||
f"/api/avatar/{context['avatar'].id}/chat/images",
|
||||
headers=context["owner_headers"],
|
||||
files={"file": ("private.png", b"image-bytes", "image/png")},
|
||||
)
|
||||
|
||||
assert response.status_code == 402
|
||||
assert response.json()["detail"] == "积分余额不足"
|
||||
|
||||
|
||||
def test_public_share_can_upload_without_exposing_analysis_details(authorization_context):
|
||||
context = authorization_context
|
||||
db = SessionLocal()
|
||||
try:
|
||||
avatar = db.get(type(context["avatar"]), context["avatar"].id)
|
||||
avatar.share_token = f"share-{context['suffix']}"
|
||||
db.commit()
|
||||
share_token = avatar.share_token
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
with (
|
||||
patch(
|
||||
"routers.chat.prepare_image",
|
||||
return_value=PreparedImage(b"jpeg", "image/jpeg", 100, 80),
|
||||
),
|
||||
patch("routers.chat._run_billed_vision_call", return_value=GENERAL_RESULT),
|
||||
):
|
||||
response = client.post(
|
||||
f"/api/public/avatar/{share_token}/chat/images",
|
||||
files={"file": ("visitor.png", b"image-bytes", "image/png")},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
payload = response.json()["data"]
|
||||
assert payload["status"] == "ready"
|
||||
assert "structuredData" not in payload
|
||||
assert "extractedText" not in payload
|
||||
assert "visionModel" not in payload
|
||||
assert "ocrModel" not in payload
|
||||
|
||||
db = SessionLocal()
|
||||
try:
|
||||
stored = db.get(ChatAttachment, payload["id"])
|
||||
assert stored.uploader_kind == "public"
|
||||
assert stored.avatar_id == context["avatar"].id
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
def test_medical_document_uses_ocr_result(authorization_context):
|
||||
context = authorization_context
|
||||
general = {
|
||||
"content": json.dumps({
|
||||
"category": "medical_document",
|
||||
"summary": "血常规报告",
|
||||
"visible_text": "初步文字",
|
||||
"key_facts": [],
|
||||
"uncertainties": [],
|
||||
"medical": {"document_type": "检验报告"},
|
||||
}, ensure_ascii=False),
|
||||
"usage": {},
|
||||
}
|
||||
ocr = {"content": "白细胞 11.2 x10^9/L", "usage": {}}
|
||||
with (
|
||||
patch("routers.chat.prepare_image", return_value=PreparedImage(b"jpeg", "image/jpeg", 100, 80)),
|
||||
patch("routers.chat._run_billed_vision_call", side_effect=[general, ocr]) as model,
|
||||
):
|
||||
response = client.post(
|
||||
f"/api/avatar/{context['avatar'].id}/chat/images",
|
||||
headers=context["owner_headers"],
|
||||
files={"file": ("report.jpg", b"image-bytes", "image/jpeg")},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
attachment_id = response.json()["data"]["id"]
|
||||
assert model.call_count == 2
|
||||
assert model.call_args_list[1].kwargs["source"] == "vision_medical_ocr"
|
||||
db = SessionLocal()
|
||||
try:
|
||||
stored = db.query(ChatAttachment).filter(ChatAttachment.id == attachment_id).one()
|
||||
assert stored.extracted_text == "白细胞 11.2 x10^9/L"
|
||||
assert stored.ocr_model == "qwen-vl-ocr"
|
||||
assert "不能替代医生诊断" in stored.warning
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
def test_attachment_cannot_cross_avatar_boundary(authorization_context):
|
||||
context = authorization_context
|
||||
db = SessionLocal()
|
||||
try:
|
||||
attachment = ChatAttachment(
|
||||
avatar_id=context["avatar"].id,
|
||||
filename="private.jpg",
|
||||
status="ready",
|
||||
expires_at=datetime.utcnow() + timedelta(hours=1),
|
||||
)
|
||||
db.add(attachment)
|
||||
db.commit()
|
||||
body = ChatIn(message="看看图片", attachmentIds=[attachment.id])
|
||||
with pytest.raises(HTTPException, match="不属于当前分身") as caught:
|
||||
_load_chat_attachments(db, context["other_avatar"].id, body)
|
||||
assert caught.value.status_code == 400
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
def test_expired_attachment_is_removed(authorization_context):
|
||||
context = authorization_context
|
||||
db = SessionLocal()
|
||||
try:
|
||||
attachment = ChatAttachment(
|
||||
avatar_id=context["avatar"].id,
|
||||
filename="expired.jpg",
|
||||
status="ready",
|
||||
expires_at=datetime.utcnow() - timedelta(seconds=1),
|
||||
)
|
||||
db.add(attachment)
|
||||
db.commit()
|
||||
attachment_id = attachment.id
|
||||
body = ChatIn(message="看看图片", attachmentIds=[attachment_id])
|
||||
with pytest.raises(HTTPException):
|
||||
_load_chat_attachments(db, context["avatar"].id, body)
|
||||
assert db.query(ChatAttachment).filter(ChatAttachment.id == attachment_id).first() is None
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
def test_cleanup_keeps_unexpired_attachment(authorization_context):
|
||||
context = authorization_context
|
||||
now = datetime.utcnow()
|
||||
db = SessionLocal()
|
||||
try:
|
||||
expired = ChatAttachment(
|
||||
avatar_id=context["avatar"].id,
|
||||
filename="expired.jpg",
|
||||
status="ready",
|
||||
expires_at=now - timedelta(seconds=1),
|
||||
)
|
||||
active = ChatAttachment(
|
||||
avatar_id=context["avatar"].id,
|
||||
filename="active.jpg",
|
||||
status="ready",
|
||||
expires_at=now + timedelta(hours=1),
|
||||
)
|
||||
db.add_all([expired, active])
|
||||
db.commit()
|
||||
expired_id, active_id = expired.id, active.id
|
||||
|
||||
assert purge_expired_chat_attachments(db, now=now) == 1
|
||||
assert db.get(ChatAttachment, expired_id) is None
|
||||
assert db.get(ChatAttachment, active_id) is not None
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
def test_image_context_keeps_standard_answer_authoritative():
|
||||
avatar = SimpleNamespace(
|
||||
id="avatar-vision",
|
||||
name="测试分身",
|
||||
description="产品顾问",
|
||||
config={},
|
||||
)
|
||||
model = Mock(return_value="标准退款期限是七天;图片显示的是商品包装。")
|
||||
result = _resolve_reply(
|
||||
None,
|
||||
avatar,
|
||||
"退款期限是多少?",
|
||||
[],
|
||||
qa_pairs=[SimpleNamespace(question="退款期限是多少?", answer="七天", enabled=True)],
|
||||
search_fn=Mock(return_value=[]),
|
||||
model_client=model,
|
||||
image_contexts=[{
|
||||
"id": "attachment",
|
||||
"filename": "product.jpg",
|
||||
"category": "general_image",
|
||||
"summary": "商品包装",
|
||||
"extractedText": "",
|
||||
"structuredData": {},
|
||||
"warning": "",
|
||||
}],
|
||||
)
|
||||
|
||||
assert result["source"] == "qa"
|
||||
system = model.call_args.kwargs["messages"][0]["content"]
|
||||
assert "已确认标准答案" in system
|
||||
assert "七天" in system
|
||||
assert "商品包装" in system
|
||||
assert "标准答题对中的事实优先级高于图片资料" in system
|
||||
|
||||
|
||||
def test_ready_image_context_never_returns_whole_image_access_denial():
|
||||
avatar = SimpleNamespace(
|
||||
id="avatar-vision",
|
||||
name="测试分身",
|
||||
description="产品顾问",
|
||||
config={},
|
||||
)
|
||||
model = Mock(return_value="抱歉,我无法查看或识别图片,请重新上传。")
|
||||
result = _resolve_reply(
|
||||
None,
|
||||
avatar,
|
||||
"请看看这张图片",
|
||||
[],
|
||||
qa_pairs=[],
|
||||
search_fn=Mock(return_value=[]),
|
||||
model_client=model,
|
||||
image_contexts=[{
|
||||
"id": "attachment",
|
||||
"filename": "report.jpg",
|
||||
"category": "medical_document",
|
||||
"summary": "一份耳鼻喉科门诊记录",
|
||||
"extractedText": "主诉:咽痛三天",
|
||||
"structuredData": {"key_facts": ["主诉为咽痛三天"]},
|
||||
"warning": "请核对原始资料",
|
||||
}],
|
||||
)
|
||||
|
||||
assert result["source"] == "vision"
|
||||
assert "一份耳鼻喉科门诊记录" in result["answer"]
|
||||
assert "主诉为咽痛三天" in result["answer"]
|
||||
assert "无法查看" not in result["answer"]
|
||||
system = model.call_args.kwargs["messages"][0]["content"]
|
||||
assert "当前会话图片已经成功读取" in system
|
||||
assert "禁止声称无法查看" in system
|
||||
|
||||
|
||||
def test_image_denial_detector_allows_uncertain_field_in_ready_image():
|
||||
assert _answer_denies_available_image("我无法查看这张图片") is True
|
||||
assert _answer_denies_available_image("图片中患者姓名无法辨认,主诉为咽痛三天。") is False
|
||||
|
||||
|
||||
def test_attachment_context_does_not_expose_internal_fields():
|
||||
row = SimpleNamespace(
|
||||
id="attachment",
|
||||
filename="case.jpg",
|
||||
category="medical_document",
|
||||
summary="门诊病例",
|
||||
extracted_text="主诉:咳嗽",
|
||||
structured_data={"medical": {"chief_complaint": "咳嗽"}},
|
||||
warning="请核对原文",
|
||||
)
|
||||
context = _attachment_contexts([row])[0]
|
||||
assert context["filename"] == "case.jpg"
|
||||
assert "avatar_id" not in context
|
||||
assert "vision_model" not in context
|
||||
@@ -0,0 +1,87 @@
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
import httpx
|
||||
|
||||
from services.chat_model_config import (
|
||||
clear_chat_model_config_cache,
|
||||
get_chat_model_config,
|
||||
)
|
||||
|
||||
|
||||
def setup_function():
|
||||
clear_chat_model_config_cache()
|
||||
|
||||
|
||||
def teardown_function():
|
||||
clear_chat_model_config_cache()
|
||||
|
||||
|
||||
def test_admin_runtime_config_takes_priority(monkeypatch):
|
||||
monkeypatch.setenv("CHAT_MODEL_CONFIG_URL", "http://config.test/runtime")
|
||||
monkeypatch.setenv("AVATAR_MODEL_CONFIG_TOKEN", "shared-secret")
|
||||
response = Mock()
|
||||
response.raise_for_status.return_value = None
|
||||
response.json.return_value = {
|
||||
"data": {
|
||||
"api_base_url": "https://model.test/v1/",
|
||||
"api_key": "runtime-key",
|
||||
"model": "avatar-model",
|
||||
"vision_model": "avatar-vision-model",
|
||||
"ocr_model": "avatar-ocr-model",
|
||||
"max_tokens": 2048,
|
||||
"timeout_seconds": 42,
|
||||
}
|
||||
}
|
||||
|
||||
with patch("services.chat_model_config.httpx.get", return_value=response) as request:
|
||||
config = get_chat_model_config()
|
||||
|
||||
assert config.source == "admin"
|
||||
assert config.api_base_url == "https://model.test/v1"
|
||||
assert config.model == "avatar-model"
|
||||
assert config.vision_model == "avatar-vision-model"
|
||||
assert config.ocr_model == "avatar-ocr-model"
|
||||
assert config.max_tokens == 2048
|
||||
request.assert_called_once_with(
|
||||
"http://config.test/runtime",
|
||||
headers={"X-Avatar-Config-Token": "shared-secret"},
|
||||
timeout=5.0,
|
||||
)
|
||||
|
||||
|
||||
def test_runtime_failure_falls_back_to_environment(monkeypatch):
|
||||
monkeypatch.setenv("CHAT_MODEL_CONFIG_URL", "http://config.test/runtime")
|
||||
monkeypatch.setenv("AVATAR_MODEL_CONFIG_TOKEN", "shared-secret")
|
||||
monkeypatch.setenv("CHAT_API_URL", "https://fallback.test/v1/")
|
||||
monkeypatch.setenv("CHAT_API_KEY", "fallback-key")
|
||||
monkeypatch.setenv("CHAT_MODEL", "fallback-model")
|
||||
monkeypatch.setenv("VISION_MODEL", "fallback-vision")
|
||||
monkeypatch.setenv("VISION_OCR_MODEL", "fallback-ocr")
|
||||
monkeypatch.setenv("CHAT_MAX_OUTPUT_TOKENS", "1536")
|
||||
|
||||
request = httpx.Request("GET", "http://config.test/runtime")
|
||||
with patch(
|
||||
"services.chat_model_config.httpx.get",
|
||||
side_effect=httpx.ConnectError("offline", request=request),
|
||||
):
|
||||
config = get_chat_model_config()
|
||||
|
||||
assert config.source == "environment"
|
||||
assert config.api_base_url == "https://fallback.test/v1"
|
||||
assert config.api_key == "fallback-key"
|
||||
assert config.model == "fallback-model"
|
||||
assert config.vision_model == "fallback-vision"
|
||||
assert config.ocr_model == "fallback-ocr"
|
||||
assert config.max_tokens == 1536
|
||||
|
||||
|
||||
def test_runtime_config_is_cached(monkeypatch):
|
||||
monkeypatch.setenv("CHAT_MODEL_CONFIG_URL", "")
|
||||
monkeypatch.setenv("CHAT_MODEL", "first-model")
|
||||
first = get_chat_model_config()
|
||||
monkeypatch.setenv("CHAT_MODEL", "second-model")
|
||||
|
||||
second = get_chat_model_config()
|
||||
|
||||
assert first is second
|
||||
assert second.model == "first-model"
|
||||
@@ -5,7 +5,18 @@ from unittest.mock import Mock
|
||||
from fastapi import HTTPException
|
||||
|
||||
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 (
|
||||
_answer_requires_language_repair,
|
||||
_build_prompt,
|
||||
_iter_text_chunks,
|
||||
_match_standard_qa,
|
||||
_public_avatar_payload,
|
||||
_qa_requires_language_adaptation,
|
||||
_qa_requires_per_turn_rendering,
|
||||
_require_owned_avatar,
|
||||
_resolve_reply,
|
||||
_turn_language_name,
|
||||
)
|
||||
|
||||
|
||||
class ChatOrchestrationTests(unittest.TestCase):
|
||||
@@ -50,6 +61,98 @@ class ChatOrchestrationTests(unittest.TestCase):
|
||||
self.assertEqual(result["answer"], "标准地址")
|
||||
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_conversation_qa_is_rendered_for_the_current_turn_language(self):
|
||||
history = [SimpleNamespace(role="user", content="Please answer in English.")]
|
||||
self.assertTrue(_qa_requires_per_turn_rendering("Quelle est votre adresse ?", "Our address is Test Road 1.", history))
|
||||
|
||||
fake_model = Mock(return_value="Notre adresse est Test Road 1.")
|
||||
result = _resolve_reply(
|
||||
None,
|
||||
self.avatar,
|
||||
"Quelle est votre adresse ?",
|
||||
history,
|
||||
qa_pairs=[SimpleNamespace(question="Quelle est votre adresse ?", answer="Our address is Test Road 1.", enabled=True)],
|
||||
search_fn=Mock(),
|
||||
model_client=fake_model,
|
||||
)
|
||||
|
||||
self.assertEqual(result["source"], "qa")
|
||||
self.assertEqual(result["answer"], "Notre adresse est Test Road 1.")
|
||||
messages = fake_model.call_args.kwargs["messages"]
|
||||
self.assertEqual(messages[-1], {"role": "user", "content": "Quelle est votre adresse ?"})
|
||||
self.assertEqual(messages[-2]["role"], "system")
|
||||
self.assertIn("MANDATORY OUTPUT LANGUAGE", messages[-2]["content"])
|
||||
self.assertIn("French", messages[-2]["content"])
|
||||
|
||||
def test_latest_user_message_has_an_adjacent_language_override(self):
|
||||
history = [
|
||||
SimpleNamespace(role="user", content="请用中文回答"),
|
||||
SimpleNamespace(role="assistant", content="好的,请问有什么可以帮你?"),
|
||||
]
|
||||
messages = _build_prompt(self.avatar, history, "What can you help me with?", [])
|
||||
|
||||
self.assertEqual(messages[-1], {"role": "user", "content": "What can you help me with?"})
|
||||
self.assertEqual(messages[-2]["role"], "system")
|
||||
self.assertIn("MANDATORY OUTPUT LANGUAGE", messages[-2]["content"])
|
||||
self.assertIn("English", messages[-2]["content"])
|
||||
|
||||
def test_reported_alzheimer_question_is_explicitly_english(self):
|
||||
question = "I have a friend who has symptoms of Alzheimer's disease"
|
||||
|
||||
self.assertEqual(_turn_language_name(question), "English")
|
||||
messages = _build_prompt(self.avatar, [], question, [])
|
||||
self.assertIn("MANDATORY OUTPUT LANGUAGE FOR THIS TURN: English", messages[-2]["content"])
|
||||
|
||||
def test_non_stream_reply_repairs_a_wrong_writing_system_before_sending(self):
|
||||
question = "I have a friend who has symptoms of Alzheimer's disease"
|
||||
fake_model = Mock(side_effect=["建议尽快就医评估。", "Please arrange a medical assessment soon."])
|
||||
|
||||
result = _resolve_reply(
|
||||
None,
|
||||
self.avatar,
|
||||
question,
|
||||
[],
|
||||
qa_pairs=[],
|
||||
search_fn=lambda *_args, **_kwargs: [],
|
||||
model_client=fake_model,
|
||||
usage_source="takeover",
|
||||
)
|
||||
|
||||
self.assertEqual(result["answer"], "Please arrange a medical assessment soon.")
|
||||
self.assertEqual(fake_model.call_count, 2)
|
||||
repair_messages = fake_model.call_args.kwargs["messages"]
|
||||
self.assertIn("English", repair_messages[0]["content"])
|
||||
self.assertIn("建议尽快就医评估", repair_messages[-1]["content"])
|
||||
self.assertTrue(_answer_requires_language_repair(question, "建议尽快就医评估。"))
|
||||
|
||||
def test_conversational_paraphrase_matches_standard_qa(self):
|
||||
for question in ("请问一下,你们公司在哪里呀?", "请问去你们那边怎么走"):
|
||||
with self.subTest(question=question):
|
||||
@@ -106,6 +209,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"])
|
||||
|
||||
def test_prompt_blocks_ungrounded_factual_answers(self):
|
||||
messages = _build_prompt(self.avatar, [], "聊聊国际新闻", [])
|
||||
@@ -113,6 +219,8 @@ class ChatOrchestrationTests(unittest.TestCase):
|
||||
self.assertIn("没有检索到可靠资料", system)
|
||||
self.assertIn("不要凭通用知识", system)
|
||||
self.assertIn("不要提及知识库", system)
|
||||
self.assertIn("不得推断服务对象", system)
|
||||
self.assertIn("工作场所", system)
|
||||
|
||||
def test_public_avatar_payload_excludes_internal_configuration(self):
|
||||
payload = _public_avatar_payload(self.avatar)
|
||||
|
||||
@@ -48,9 +48,12 @@ class RemoteEmbeddingTests(unittest.TestCase):
|
||||
def test_large_input_is_split_into_provider_safe_batches(self):
|
||||
texts = [f"chunk-{index}" for index in range(14)]
|
||||
batch_sizes = []
|
||||
requested_urls = []
|
||||
progress_updates = []
|
||||
|
||||
def fake_urlopen(request, timeout):
|
||||
self.assertEqual(timeout, 30)
|
||||
requested_urls.append(request.full_url)
|
||||
payload = json.loads(request.data.decode("utf-8"))
|
||||
batch_sizes.append(len(payload["input"]))
|
||||
return FakeResponse({
|
||||
@@ -61,15 +64,29 @@ class RemoteEmbeddingTests(unittest.TestCase):
|
||||
})
|
||||
|
||||
with patch.dict(os.environ, {
|
||||
"EMBEDDING_API_URL": "https://embedding.example/v1/embeddings",
|
||||
"EMBEDDING_API_URL": "https://embedding.example/v1",
|
||||
"EMBEDDING_API_KEY": "test-key",
|
||||
"EMBEDDING_MODEL": "text-embedding-v4",
|
||||
"EMBEDDING_BATCH_SIZE": "10",
|
||||
}), patch("embeddings.urllib.request.urlopen", side_effect=fake_urlopen):
|
||||
result = embeddings.embed(texts)
|
||||
result = embeddings.embed(
|
||||
texts,
|
||||
on_progress=lambda completed, total: progress_updates.append((completed, total)),
|
||||
)
|
||||
|
||||
self.assertEqual(batch_sizes, [10, 4])
|
||||
self.assertEqual(requested_urls, [
|
||||
"https://embedding.example/v1/embeddings",
|
||||
"https://embedding.example/v1/embeddings",
|
||||
])
|
||||
self.assertEqual(result, [[float(index)] for index in range(14)])
|
||||
self.assertEqual(progress_updates, [(10, 14), (14, 14)])
|
||||
|
||||
def test_full_embedding_endpoint_is_not_modified(self):
|
||||
self.assertEqual(
|
||||
embeddings._embedding_endpoint("https://embedding.example/v1/embeddings/"),
|
||||
"https://embedding.example/v1/embeddings",
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import main
|
||||
|
||||
|
||||
def test_health_reports_release_and_runtime_capabilities(monkeypatch):
|
||||
monkeypatch.setenv("APP_GIT_SHA", "test-sha")
|
||||
monkeypatch.setenv("APP_BUILD_TIME", "2026-09-09T00:00:00Z")
|
||||
monkeypatch.setattr(main, "_runtime_checks", lambda: {
|
||||
"database": True,
|
||||
"uploads": True,
|
||||
"pdfOcr": True,
|
||||
})
|
||||
|
||||
response = main.health()
|
||||
|
||||
assert response["code"] == 200
|
||||
assert response["data"]["status"] == "ok"
|
||||
assert response["data"]["gitSha"] == "test-sha"
|
||||
assert response["data"]["buildTime"] == "2026-09-09T00:00:00Z"
|
||||
assert response["data"]["checks"] == {
|
||||
"database": True,
|
||||
"uploads": True,
|
||||
"pdfOcr": True,
|
||||
}
|
||||
|
||||
|
||||
def test_health_is_degraded_when_a_required_capability_is_missing(monkeypatch):
|
||||
monkeypatch.setattr(main, "_runtime_checks", lambda: {
|
||||
"database": True,
|
||||
"uploads": True,
|
||||
"pdfOcr": False,
|
||||
})
|
||||
|
||||
assert main.health()["data"]["status"] == "degraded"
|
||||
@@ -1,6 +1,7 @@
|
||||
"""Tests for preserving local avatar ownership when Huihui IDs change."""
|
||||
|
||||
from datetime import datetime
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
import pytest
|
||||
from sqlalchemy import create_engine
|
||||
@@ -9,7 +10,8 @@ from sqlalchemy.pool import StaticPool
|
||||
|
||||
from database import Base
|
||||
from models import Avatar, TakeoverCursor, TakeoverMessage, TakeoverReplyTask, User
|
||||
from routers.huihui_auth import _issue_session
|
||||
from routers.huihui_auth import _issue_session, token_login
|
||||
from services.boxim_client import BoxIMError
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
@@ -135,3 +137,55 @@ def test_ambiguous_phone_matches_do_not_move_existing_avatars(db):
|
||||
assert db.query(User).count() == 3
|
||||
_assert_avatar_data_owner(db, first_avatar.id, "fat-1")
|
||||
_assert_avatar_data_owner(db, second_avatar.id, "fat-2")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_token_login_uses_huihui_user_id_and_keeps_upstream_token_server_side(db):
|
||||
existing = User(
|
||||
id="existing-local",
|
||||
huihui_user_id="huihui-user-88",
|
||||
app_token="existing-app-session",
|
||||
)
|
||||
db.add(existing)
|
||||
db.commit()
|
||||
|
||||
client = AsyncMock()
|
||||
client.exchange_access_token.return_value = {"accessToken": "boxim-token"}
|
||||
client.get_self.return_value = {
|
||||
"id": 998877,
|
||||
"huihuiUserId": "huihui-user-88",
|
||||
"nickName": "会会用户",
|
||||
"headImage": "https://cdn.example/avatar.jpg",
|
||||
}
|
||||
with patch("routers.huihui_auth._cfg_ready", return_value=True), patch(
|
||||
"routers.huihui_auth._create_boxim_client", return_value=client
|
||||
):
|
||||
response = await token_login({"token": "production-huihui-token"}, db)
|
||||
|
||||
assert response["code"] == 200
|
||||
assert response["data"]["token"] == "existing-app-session"
|
||||
assert "token" not in response["data"]["huihui"]
|
||||
user = db.query(User).one()
|
||||
assert user.huihui_user_id == "huihui-user-88"
|
||||
assert user.huihui_user_id != "998877"
|
||||
assert user.huihui_token == "production-huihui-token"
|
||||
assert user.nickname == "会会用户"
|
||||
assert user.avatar_url == "https://cdn.example/avatar.jpg"
|
||||
client.exchange_access_token.assert_awaited_once_with("production-huihui-token")
|
||||
client.get_self.assert_awaited_once_with("boxim-token")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_token_login_rejects_expired_huihui_token_without_creating_user(db):
|
||||
client = AsyncMock()
|
||||
client.exchange_access_token.side_effect = BoxIMError(
|
||||
"expired", auth_error=True
|
||||
)
|
||||
with patch("routers.huihui_auth._cfg_ready", return_value=True), patch(
|
||||
"routers.huihui_auth._create_boxim_client", return_value=client
|
||||
):
|
||||
response = await token_login({"token": "expired-token"}, db)
|
||||
|
||||
assert response["code"] == 401
|
||||
assert response["message"] == "会会登录凭证无效或已过期"
|
||||
assert db.query(User).count() == 0
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
import os
|
||||
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
|
||||
|
||||
|
||||
def test_request_refund_uses_configured_huihui_endpoint_without_exposing_secret():
|
||||
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(status_code=200)
|
||||
response.json.return_value = {"code": 200, "data": {"status": "PROCESSING", "refundNo": "provider-rf"}}
|
||||
with patch.dict(os.environ, {"HUIHUI_PAYMENT_REFUND_PATH": "/payment/refund"}), patch(
|
||||
"services.huihui_payment.httpx.post", return_value=response
|
||||
) as post:
|
||||
result = client.request_refund(
|
||||
huihui_token="user-token",
|
||||
huihui_user_id="user-id",
|
||||
order_no="AV1",
|
||||
refund_no="RF1",
|
||||
amount="10.00",
|
||||
reason="用户申请",
|
||||
)
|
||||
assert result["refundNo"] == "provider-rf"
|
||||
assert post.call_args.args[0] == "https://open.example/api/payment-v3/payment/refund"
|
||||
assert post.call_args.kwargs["json"] == {
|
||||
"appId": "app-id",
|
||||
"masterOrderNo": "AV1",
|
||||
"refundOrderNo": "RF1",
|
||||
"refundAmt": 10.0,
|
||||
"refundReason": "用户申请",
|
||||
}
|
||||
assert "accessSecret" not in post.call_args.kwargs["params"]
|
||||
@@ -2,7 +2,16 @@ from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
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 services.knowledge_vectorizer import knowledge_vectorizer
|
||||
|
||||
|
||||
client = TestClient(app)
|
||||
|
||||
|
||||
def test_doc_payload_reports_whether_the_persisted_file_exists(tmp_path: Path):
|
||||
@@ -21,3 +30,394 @@ def test_doc_payload_reports_whether_the_persisted_file_exists(tmp_path: Path):
|
||||
assert _doc_payload(doc)["filePresent"] is False
|
||||
stored_file.write_text("knowledge", encoding="utf-8")
|
||||
assert _doc_payload(doc)["filePresent"] is True
|
||||
|
||||
|
||||
def test_upload_returns_before_background_vectorization(
|
||||
tmp_path: Path,
|
||||
authorization_context,
|
||||
):
|
||||
context = authorization_context
|
||||
with (
|
||||
patch("routers.knowledge.UPLOAD_DIR", str(tmp_path)),
|
||||
patch("routers.knowledge.knowledge_vectorizer.enqueue") as enqueue,
|
||||
):
|
||||
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"] == "parsing"
|
||||
assert payload["vectorized"] is False
|
||||
assert payload["chunkCount"] == 0
|
||||
enqueue.assert_called_once_with(payload["id"])
|
||||
|
||||
db = SessionLocal()
|
||||
try:
|
||||
stored = db.query(KnowledgeDoc).filter(KnowledgeDoc.id == payload["id"]).one()
|
||||
assert stored.status == "parsing"
|
||||
assert db.query(KnowledgeChunk).filter(KnowledgeChunk.doc_id == stored.id).count() == 0
|
||||
db.delete(stored)
|
||||
db.commit()
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
def test_upload_rejects_oversize_file_before_queuing_indexing(
|
||||
tmp_path: Path,
|
||||
authorization_context,
|
||||
):
|
||||
context = authorization_context
|
||||
with (
|
||||
patch("routers.knowledge.UPLOAD_DIR", str(tmp_path)),
|
||||
patch("routers.knowledge.MAX_UPLOAD_BYTES", 4),
|
||||
patch("routers.knowledge.knowledge_vectorizer.enqueue") as enqueue,
|
||||
):
|
||||
response = client.post(
|
||||
f"/api/avatar/{context['avatar'].id}/knowledge/docs",
|
||||
headers=context["owner_headers"],
|
||||
files={"file": ("oversize.md", b"12345", "text/markdown")},
|
||||
)
|
||||
|
||||
payload = response.json()
|
||||
assert payload["code"] == 400
|
||||
assert payload["message"] == "文件不能超过 50MB"
|
||||
enqueue.assert_not_called()
|
||||
assert not list((tmp_path / context["avatar"].id).glob("*"))
|
||||
|
||||
|
||||
def test_multipart_upload_reassembles_file_before_queuing_indexing(
|
||||
tmp_path: Path,
|
||||
authorization_context,
|
||||
):
|
||||
context = authorization_context
|
||||
avatar_id = context["avatar"].id
|
||||
content = b"0123456789"
|
||||
with (
|
||||
patch("routers.knowledge.UPLOAD_DIR", str(tmp_path)),
|
||||
patch("routers.knowledge.MULTIPART_CHUNK_BYTES", 4),
|
||||
patch("routers.knowledge.knowledge_vectorizer.enqueue") as enqueue,
|
||||
):
|
||||
created = client.post(
|
||||
f"/api/avatar/{avatar_id}/knowledge/uploads",
|
||||
headers=context["owner_headers"],
|
||||
json={"filename": "large.pdf", "fileSize": len(content), "totalChunks": 3},
|
||||
).json()["data"]
|
||||
|
||||
for index, chunk in enumerate((content[:4], content[4:8], content[8:])):
|
||||
response = client.post(
|
||||
f"/api/avatar/{avatar_id}/knowledge/uploads/{created['uploadId']}/chunks/{index}",
|
||||
headers=context["owner_headers"],
|
||||
files={"file": (f"chunk-{index}", chunk, "application/octet-stream")},
|
||||
)
|
||||
assert response.json()["code"] == 200
|
||||
|
||||
completed = client.post(
|
||||
f"/api/avatar/{avatar_id}/knowledge/uploads/{created['uploadId']}/complete",
|
||||
headers=context["owner_headers"],
|
||||
).json()["data"]
|
||||
|
||||
assert completed["status"] == "parsing"
|
||||
assert completed["fileSize"] == len(content)
|
||||
enqueue.assert_called_once_with(completed["id"])
|
||||
stored_path = tmp_path / avatar_id / Path(completed["fileUrl"]).name
|
||||
assert stored_path.read_bytes() == content
|
||||
assert not (tmp_path / ".multipart" / avatar_id / created["uploadId"]).exists()
|
||||
|
||||
db = SessionLocal()
|
||||
try:
|
||||
stored = db.query(KnowledgeDoc).filter(KnowledgeDoc.id == completed["id"]).one()
|
||||
db.delete(stored)
|
||||
db.commit()
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
def test_multipart_upload_rejects_incomplete_parts(
|
||||
tmp_path: Path,
|
||||
authorization_context,
|
||||
):
|
||||
context = authorization_context
|
||||
avatar_id = context["avatar"].id
|
||||
with (
|
||||
patch("routers.knowledge.UPLOAD_DIR", str(tmp_path)),
|
||||
patch("routers.knowledge.MULTIPART_CHUNK_BYTES", 4),
|
||||
patch("routers.knowledge.knowledge_vectorizer.enqueue") as enqueue,
|
||||
):
|
||||
created = client.post(
|
||||
f"/api/avatar/{avatar_id}/knowledge/uploads",
|
||||
headers=context["owner_headers"],
|
||||
json={"filename": "large.pdf", "fileSize": 6, "totalChunks": 2},
|
||||
).json()["data"]
|
||||
client.post(
|
||||
f"/api/avatar/{avatar_id}/knowledge/uploads/{created['uploadId']}/chunks/0",
|
||||
headers=context["owner_headers"],
|
||||
files={"file": ("chunk-0", b"0123", "application/octet-stream")},
|
||||
)
|
||||
response = client.post(
|
||||
f"/api/avatar/{avatar_id}/knowledge/uploads/{created['uploadId']}/complete",
|
||||
headers=context["owner_headers"],
|
||||
)
|
||||
|
||||
assert response.json()["code"] == 400
|
||||
assert response.json()["message"] == "文件分片尚未上传完整"
|
||||
enqueue.assert_not_called()
|
||||
|
||||
|
||||
def test_background_vectorizer_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.knowledge_vectorizer.enqueue"),
|
||||
):
|
||||
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"] == "parsing"
|
||||
with (
|
||||
patch("services.knowledge_vectorizer.UPLOAD_DIR", str(tmp_path)),
|
||||
patch("services.knowledge_vectorizer.embeddings.embed", return_value=[[1.0, 0.0]]),
|
||||
):
|
||||
knowledge_vectorizer.vectorize_document(payload["id"])
|
||||
|
||||
db = SessionLocal()
|
||||
try:
|
||||
stored = db.query(KnowledgeDoc).filter(KnowledgeDoc.id == payload["id"]).one()
|
||||
assert stored.status == "ready"
|
||||
assert stored.vectorized is True
|
||||
assert stored.chunk_count == 1
|
||||
assert stored.index_stage == "ready"
|
||||
assert stored.index_progress == 100
|
||||
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_background_vectorizer_keeps_failure_reason_for_retry(
|
||||
tmp_path: Path,
|
||||
authorization_context,
|
||||
):
|
||||
context = authorization_context
|
||||
with (
|
||||
patch("routers.knowledge.UPLOAD_DIR", str(tmp_path)),
|
||||
patch("routers.knowledge.knowledge_vectorizer.enqueue"),
|
||||
):
|
||||
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"]
|
||||
with (
|
||||
patch("services.knowledge_vectorizer.UPLOAD_DIR", str(tmp_path)),
|
||||
patch("services.knowledge_vectorizer.embeddings.embed", side_effect=RuntimeError("provider unavailable")),
|
||||
):
|
||||
knowledge_vectorizer.vectorize_document(payload["id"])
|
||||
|
||||
db = SessionLocal()
|
||||
try:
|
||||
stored = db.query(KnowledgeDoc).filter(KnowledgeDoc.id == payload["id"]).one()
|
||||
assert stored.status == "failed"
|
||||
assert stored.error_message == "provider unavailable"
|
||||
assert db.query(KnowledgeChunk).filter(KnowledgeChunk.doc_id == stored.id).count() == 0
|
||||
db.delete(stored)
|
||||
db.commit()
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
def test_background_vectorizer_uses_ocr_for_image_only_pdf(
|
||||
tmp_path: Path,
|
||||
authorization_context,
|
||||
):
|
||||
context = authorization_context
|
||||
with (
|
||||
patch("routers.knowledge.UPLOAD_DIR", str(tmp_path)),
|
||||
patch("routers.knowledge.knowledge_vectorizer.enqueue"),
|
||||
):
|
||||
response = client.post(
|
||||
f"/api/avatar/{context['avatar'].id}/knowledge/docs",
|
||||
headers=context["owner_headers"],
|
||||
files={"file": ("scanned.pdf", b"image-only-pdf", "application/pdf")},
|
||||
)
|
||||
|
||||
payload = response.json()["data"]
|
||||
progress = []
|
||||
with (
|
||||
patch("services.knowledge_vectorizer.UPLOAD_DIR", str(tmp_path)),
|
||||
patch("services.knowledge_vectorizer.embeddings.extract_text", return_value=""),
|
||||
patch(
|
||||
"services.knowledge_vectorizer.extract_scanned_pdf_text",
|
||||
side_effect=lambda _db, _avatar, _path, on_progress: (
|
||||
on_progress(1, 2), on_progress(2, 2), "扫描页文字"
|
||||
)[-1],
|
||||
) as ocr,
|
||||
patch("services.knowledge_vectorizer.embeddings.embed", return_value=[[1.0, 0.0]]),
|
||||
patch.object(knowledge_vectorizer, "_set_progress", wraps=knowledge_vectorizer._set_progress) as set_progress,
|
||||
):
|
||||
knowledge_vectorizer.vectorize_document(payload["id"])
|
||||
progress = [(call.args[2], call.args[3]) for call in set_progress.call_args_list]
|
||||
|
||||
db = SessionLocal()
|
||||
try:
|
||||
stored = db.query(KnowledgeDoc).filter(KnowledgeDoc.id == payload["id"]).one()
|
||||
assert stored.status == "ready"
|
||||
assert stored.chunk_count == 1
|
||||
assert ("ocr", 18) in progress
|
||||
assert ("ocr", 28) in progress
|
||||
ocr.assert_called_once()
|
||||
db.query(KnowledgeChunk).filter(KnowledgeChunk.doc_id == stored.id).delete()
|
||||
db.delete(stored)
|
||||
db.commit()
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
def test_retry_queues_a_failed_document_again(
|
||||
tmp_path: Path,
|
||||
authorization_context,
|
||||
):
|
||||
context = authorization_context
|
||||
document_id = f"retry-doc-{context['suffix']}"
|
||||
avatar_dir = tmp_path / context["avatar"].id
|
||||
avatar_dir.mkdir()
|
||||
(avatar_dir / "retry.md").write_text("retry content", encoding="utf-8")
|
||||
db = SessionLocal()
|
||||
try:
|
||||
db.add(
|
||||
KnowledgeDoc(
|
||||
id=document_id,
|
||||
avatar_id=context["avatar"].id,
|
||||
filename="retry.md",
|
||||
file_type="md",
|
||||
file_url=f"/api/files/{context['avatar'].id}/retry.md",
|
||||
status="failed",
|
||||
error_message="provider unavailable",
|
||||
)
|
||||
)
|
||||
db.commit()
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
with (
|
||||
patch("routers.knowledge.UPLOAD_DIR", str(tmp_path)),
|
||||
patch("routers.knowledge.knowledge_vectorizer.enqueue") as enqueue,
|
||||
):
|
||||
response = client.post(
|
||||
f"/api/avatar/{context['avatar'].id}/knowledge/docs/{document_id}/retry",
|
||||
headers=context["owner_headers"],
|
||||
)
|
||||
|
||||
payload = response.json()["data"]
|
||||
assert payload["status"] == "parsing"
|
||||
assert payload["errorMessage"] == ""
|
||||
enqueue.assert_called_once_with(document_id)
|
||||
db = SessionLocal()
|
||||
try:
|
||||
db.query(KnowledgeDoc).filter(KnowledgeDoc.id == document_id).delete()
|
||||
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()
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from services.pdf_ocr_service import extract_scanned_pdf_text
|
||||
|
||||
|
||||
class FakePixmap:
|
||||
def tobytes(self, *_args, **_kwargs):
|
||||
return b"jpeg-page"
|
||||
|
||||
|
||||
class FakePage:
|
||||
def get_pixmap(self, **_kwargs):
|
||||
return FakePixmap()
|
||||
|
||||
|
||||
class FakeDocument:
|
||||
page_count = 2
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, *_args):
|
||||
return None
|
||||
|
||||
def load_page(self, _index):
|
||||
return FakePage()
|
||||
|
||||
|
||||
def test_scanned_pdf_ocr_preserves_page_order_and_reports_progress(monkeypatch):
|
||||
fake_pymupdf = SimpleNamespace(
|
||||
open=lambda _path: FakeDocument(),
|
||||
Matrix=lambda x, y: (x, y),
|
||||
csRGB="rgb",
|
||||
)
|
||||
monkeypatch.setitem(__import__("sys").modules, "pymupdf", fake_pymupdf)
|
||||
progress = []
|
||||
reservation = SimpleNamespace()
|
||||
config = SimpleNamespace(
|
||||
api_key="configured",
|
||||
ocr_model="qwen-vl-ocr",
|
||||
vision_model="vision",
|
||||
vision_max_tokens=2048,
|
||||
)
|
||||
|
||||
with (
|
||||
patch("services.pdf_ocr_service.get_chat_model_config", return_value=config),
|
||||
patch("services.pdf_ocr_service.prepare_image", return_value=SimpleNamespace()),
|
||||
patch(
|
||||
"services.pdf_ocr_service.call_vision_model",
|
||||
side_effect=[
|
||||
{"content": "第一页文字", "usage": {"total_tokens": 10}},
|
||||
{"content": "第二页文字", "usage": {"total_tokens": 12}},
|
||||
],
|
||||
),
|
||||
patch("services.pdf_ocr_service.reserve_avatar_tokens", return_value=reservation) as reserve,
|
||||
patch("services.pdf_ocr_service.settle_reservation") as settle,
|
||||
):
|
||||
text = extract_scanned_pdf_text(
|
||||
MagicMock(),
|
||||
SimpleNamespace(id="avatar-1"),
|
||||
"/tmp/scanned.pdf",
|
||||
on_progress=lambda done, total: progress.append((done, total)),
|
||||
)
|
||||
|
||||
assert text == "[第 1 页]\n第一页文字\n\n[第 2 页]\n第二页文字"
|
||||
assert progress == [(1, 2), (2, 2)]
|
||||
assert reserve.call_count == 2
|
||||
assert settle.call_count == 2
|
||||
|
||||
|
||||
def test_scanned_pdf_ocr_releases_tokens_after_retries_fail(monkeypatch):
|
||||
fake_document = FakeDocument()
|
||||
fake_document.page_count = 1
|
||||
fake_pymupdf = SimpleNamespace(
|
||||
open=lambda _path: fake_document,
|
||||
Matrix=lambda x, y: (x, y),
|
||||
csRGB="rgb",
|
||||
)
|
||||
monkeypatch.setitem(__import__("sys").modules, "pymupdf", fake_pymupdf)
|
||||
monkeypatch.setenv("KNOWLEDGE_PDF_OCR_ATTEMPTS", "2")
|
||||
reservation = SimpleNamespace()
|
||||
config = SimpleNamespace(
|
||||
api_key="configured",
|
||||
ocr_model="qwen-vl-ocr",
|
||||
vision_model="vision",
|
||||
vision_max_tokens=2048,
|
||||
)
|
||||
|
||||
with (
|
||||
patch("services.pdf_ocr_service.get_chat_model_config", return_value=config),
|
||||
patch("services.pdf_ocr_service.prepare_image", return_value=SimpleNamespace()),
|
||||
patch("services.pdf_ocr_service.call_vision_model", side_effect=RuntimeError("timeout")) as call,
|
||||
patch("services.pdf_ocr_service.reserve_avatar_tokens", return_value=reservation),
|
||||
patch("services.pdf_ocr_service.release_reservation") as release,
|
||||
patch("services.pdf_ocr_service.time.sleep"),
|
||||
):
|
||||
with pytest.raises(RuntimeError, match="第 1/1 页识别失败"):
|
||||
extract_scanned_pdf_text(MagicMock(), SimpleNamespace(id="avatar-1"), "/tmp/scanned.pdf")
|
||||
|
||||
assert call.call_count == 2
|
||||
release.assert_called_once()
|
||||
@@ -13,7 +13,7 @@ def test_authorization_takeover_fields():
|
||||
assert hasattr(auth, 'takeover_delay_seconds')
|
||||
assert auth.takeover_enabled == False
|
||||
assert auth.takeover_mode == 'immediate'
|
||||
assert auth.takeover_delay_seconds == 30
|
||||
assert auth.takeover_delay_seconds == 180
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
@@ -20,8 +20,9 @@ def test_scheduler_uses_boxim_and_restart_safe_service(
|
||||
):
|
||||
import main
|
||||
|
||||
maintenance_scheduler = MagicMock()
|
||||
scheduler = MagicMock()
|
||||
mock_scheduler_class.return_value = scheduler
|
||||
mock_scheduler_class.side_effect = [maintenance_scheduler, scheduler]
|
||||
boxim = MagicMock()
|
||||
mock_boxim_class.return_value = boxim
|
||||
takeover = MagicMock()
|
||||
@@ -45,7 +46,16 @@ def test_scheduler_uses_boxim_and_restart_safe_service(
|
||||
config = mock_boxim_class.call_args.args[0]
|
||||
assert config["HUIHUI_PLATFORM_BASE_URL"] == "https://open.example/api"
|
||||
assert config["BOXIM_API_BASE_URL"] == "https://im.example/api"
|
||||
mock_takeover_class.assert_called_once_with(main.SessionLocal, boxim)
|
||||
mock_takeover_class.assert_called_once_with(
|
||||
main.SessionLocal,
|
||||
boxim,
|
||||
poll_concurrency=8,
|
||||
max_message_age_seconds=600,
|
||||
)
|
||||
|
||||
maintenance_scheduler.add_job.assert_called_once()
|
||||
assert maintenance_scheduler.add_job.call_args.kwargs["id"] == "chat_attachment_cleanup"
|
||||
maintenance_scheduler.start.assert_called_once_with()
|
||||
|
||||
assert scheduler.add_job.call_count == 2
|
||||
poll_call, process_call = scheduler.add_job.call_args_list
|
||||
@@ -62,6 +72,7 @@ def test_scheduler_uses_boxim_and_restart_safe_service(
|
||||
scheduler.start.assert_called_once_with()
|
||||
|
||||
main.takeover_scheduler = None
|
||||
main.maintenance_scheduler = None
|
||||
|
||||
|
||||
@patch("main.AsyncIOScheduler")
|
||||
@@ -73,6 +84,7 @@ def test_scheduler_failure_does_not_stop_the_api(mock_scheduler_class):
|
||||
main.on_startup()
|
||||
|
||||
assert main.takeover_scheduler is None
|
||||
assert main.maintenance_scheduler is None
|
||||
|
||||
|
||||
def test_shutdown_stops_only_the_scheduler():
|
||||
@@ -80,9 +92,14 @@ def test_shutdown_stops_only_the_scheduler():
|
||||
|
||||
scheduler = MagicMock()
|
||||
scheduler.running = True
|
||||
maintenance_scheduler = MagicMock()
|
||||
maintenance_scheduler.running = True
|
||||
main.takeover_scheduler = scheduler
|
||||
main.maintenance_scheduler = maintenance_scheduler
|
||||
|
||||
main.on_shutdown()
|
||||
|
||||
scheduler.shutdown.assert_called_once_with(wait=False)
|
||||
maintenance_scheduler.shutdown.assert_called_once_with(wait=False)
|
||||
assert main.takeover_scheduler is None
|
||||
assert main.maintenance_scheduler is None
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
"""End-to-end service tests for BOXIM takeover timing and human priority."""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from threading import Barrier
|
||||
from unittest.mock import AsyncMock, patch
|
||||
@@ -7,12 +9,17 @@ from unittest.mock import AsyncMock, patch
|
||||
import pytest
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
from sqlalchemy.pool import StaticPool
|
||||
|
||||
from database import Base
|
||||
from models import Avatar, TakeoverCursor, TakeoverMessage, TakeoverReplyTask, User
|
||||
from models import Avatar, ChatAttachment, TakeoverCursor, TakeoverMessage, TakeoverReplyTask, User
|
||||
from services.boxim_client import BoxIMError
|
||||
from services.takeover_service import TakeoverService, _plain_text_reply
|
||||
from services.boxim_image_service import DownloadedBoxIMImage
|
||||
from services.takeover_service import (
|
||||
AVATAR_LOCAL_ID_PREFIX,
|
||||
TakeoverService,
|
||||
_avatar_local_id,
|
||||
_plain_text_reply,
|
||||
)
|
||||
|
||||
|
||||
class Clock:
|
||||
@@ -58,12 +65,54 @@ class FakeBoxIM:
|
||||
return {"id": 900 + len(self.sent), "localId": int(local_id)}
|
||||
|
||||
|
||||
class ConcurrentPollingBoxIM(FakeBoxIM):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.active_polls = 0
|
||||
self.peak_active_polls = 0
|
||||
|
||||
async def exchange_access_token(self, huihui_token):
|
||||
return {"accessToken": huihui_token, "accessTokenExpiresIn": 3600}
|
||||
|
||||
async def get_self(self, access_token):
|
||||
return {"id": 100 if access_token == "prod-huihui-token" else 101}
|
||||
|
||||
async def fetch_private_messages(self, access_token, min_id="0"):
|
||||
self.active_polls += 1
|
||||
self.peak_active_polls = max(self.peak_active_polls, self.active_polls)
|
||||
await asyncio.sleep(0.05)
|
||||
self.active_polls -= 1
|
||||
return []
|
||||
|
||||
|
||||
class ConcurrentMessagePollingBoxIM(ConcurrentPollingBoxIM):
|
||||
async def fetch_private_messages(self, access_token, min_id="0"):
|
||||
await super().fetch_private_messages(access_token, min_id)
|
||||
owner_id = 100 if access_token == "prod-huihui-token" else 101
|
||||
return [
|
||||
{
|
||||
"id": owner_id,
|
||||
"localId": owner_id,
|
||||
"sendId": owner_id + 100,
|
||||
"recvId": owner_id,
|
||||
"sendTime": 1_700_000_000_000,
|
||||
"type": 0,
|
||||
"content": "并发写入测试",
|
||||
}
|
||||
]
|
||||
|
||||
async def mark_private_messages_read(self, access_token, friend_id, message_id):
|
||||
await asyncio.sleep(0.05)
|
||||
self.read_receipts.append(
|
||||
{"friendId": str(friend_id), "messageId": str(message_id)}
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def service_context():
|
||||
def service_context(tmp_path):
|
||||
engine = create_engine(
|
||||
"sqlite://",
|
||||
f"sqlite:///{tmp_path / 'takeover.db'}",
|
||||
connect_args={"check_same_thread": False},
|
||||
poolclass=StaticPool,
|
||||
)
|
||||
session_factory = sessionmaker(bind=engine, autoflush=False, expire_on_commit=False)
|
||||
Base.metadata.create_all(engine)
|
||||
@@ -79,7 +128,10 @@ def service_context():
|
||||
owner_id=user.huihui_user_id,
|
||||
name="分身",
|
||||
status="active",
|
||||
config={"authorizationPermissions": ["chat", "takeover"]},
|
||||
config={
|
||||
"authorizationPermissions": ["chat", "takeover"],
|
||||
"takeoverReplyDelaySeconds": 3,
|
||||
},
|
||||
)
|
||||
db.add_all([user, avatar])
|
||||
db.commit()
|
||||
@@ -122,8 +174,7 @@ async def test_incoming_message_is_prepared_then_sent_at_three_seconds(service_c
|
||||
{"id": 11, "localId": 2, "sendId": 200, "recvId": 100, "sendTime": clock.millis(), "type": 0, "content": "你好"}
|
||||
)
|
||||
|
||||
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.read_receipts == [{"friendId": "200", "messageId": "11"}]
|
||||
|
||||
@@ -132,7 +183,8 @@ async def test_incoming_message_is_prepared_then_sent_at_three_seconds(service_c
|
||||
assert boxim.sent == []
|
||||
|
||||
clock.advance(1)
|
||||
await service.poll_and_process_messages()
|
||||
with patch("routers.chat._resolve_reply", return_value={"answer": "**你好**\n\n很高兴见到你"}):
|
||||
await service.poll_and_process_messages()
|
||||
assert boxim.sent == [{"peerId": "200", "content": "你好\n很高兴见到你", "localId": boxim.sent[0]["localId"]}]
|
||||
|
||||
db = session_factory()
|
||||
@@ -144,6 +196,453 @@ async def test_incoming_message_is_prepared_then_sent_at_three_seconds(service_c
|
||||
db.close()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_incoming_image_is_analyzed_and_used_in_takeover_reply(service_context):
|
||||
session_factory, service, boxim, clock = service_context
|
||||
await service.poll_and_process_messages()
|
||||
boxim.messages.append(
|
||||
{
|
||||
"id": 111,
|
||||
"localId": 111,
|
||||
"sendId": 200,
|
||||
"recvId": 100,
|
||||
"sendTime": clock.millis(),
|
||||
"type": 1,
|
||||
"content": json.dumps(
|
||||
{
|
||||
"originUrl": "https://cdn.example/case.png",
|
||||
"thumbUrl": "https://cdn.example/case-thumb.png",
|
||||
}
|
||||
),
|
||||
}
|
||||
)
|
||||
|
||||
await service.poll_and_process_messages()
|
||||
db = session_factory()
|
||||
try:
|
||||
scheduled = db.query(TakeoverReplyTask).filter_by(trigger_message_id="111").one()
|
||||
assert scheduled.status == "pending"
|
||||
assert scheduled.prompt == "请看看这张图片。"
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
clock.advance(3)
|
||||
|
||||
def analyze(db, avatar, content, **kwargs):
|
||||
assert content == b"image-content"
|
||||
attachment = ChatAttachment(
|
||||
avatar_id=avatar.id,
|
||||
uploader_kind=kwargs["uploader_kind"],
|
||||
filename=kwargs["filename"],
|
||||
mime_type="image/jpeg",
|
||||
file_size=len(content),
|
||||
status="ready",
|
||||
category="medical_document",
|
||||
summary="一张门诊病例",
|
||||
extracted_text="主诉:咳嗽三天",
|
||||
structured_data={"medical": {"chief_complaint": "咳嗽三天"}},
|
||||
warning="请核对原始资料",
|
||||
expires_at=clock.now() + timedelta(hours=24),
|
||||
)
|
||||
db.add(attachment)
|
||||
db.commit()
|
||||
db.refresh(attachment)
|
||||
return attachment
|
||||
|
||||
downloaded = DownloadedBoxIMImage(
|
||||
content=b"image-content",
|
||||
filename="case.png",
|
||||
mime_type="image/png",
|
||||
source_url="https://cdn.example/case.png",
|
||||
)
|
||||
with (
|
||||
patch("services.takeover_service.download_boxim_image", return_value=downloaded),
|
||||
patch("routers.chat._analyze_image_bytes", side_effect=analyze) as analyzer,
|
||||
patch("routers.chat._resolve_reply", return_value={"answer": "这份资料里写的是咳嗽三天。"}) as resolver,
|
||||
):
|
||||
await service.poll_and_process_messages()
|
||||
|
||||
analyzer.assert_called_once()
|
||||
assert resolver.call_args.args[2] == "请看看这张图片。"
|
||||
image_contexts = resolver.call_args.kwargs["image_contexts"]
|
||||
assert image_contexts[0]["summary"] == "一张门诊病例"
|
||||
assert image_contexts[0]["extractedText"] == "主诉:咳嗽三天"
|
||||
assert [item["content"] for item in boxim.sent] == ["这份资料里写的是咳嗽三天。"]
|
||||
|
||||
db = session_factory()
|
||||
try:
|
||||
event = db.query(TakeoverMessage).filter_by(boxim_message_id="111").one()
|
||||
task = db.query(TakeoverReplyTask).filter_by(trigger_message_id="111").one()
|
||||
assert event.attachment_id
|
||||
assert db.get(ChatAttachment, event.attachment_id).uploader_kind == "boxim"
|
||||
assert task.status == "sent"
|
||||
with patch(
|
||||
"services.takeover_service.download_boxim_image",
|
||||
side_effect=AssertionError("cached image must not be downloaded again"),
|
||||
):
|
||||
cached = service._takeover_image_attachment(db, db.get(Avatar, "avatar-1"), event)
|
||||
assert cached.id == event.attachment_id
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_followup_text_recovers_recent_image_recorded_without_task(service_context):
|
||||
session_factory, service, boxim, clock = service_context
|
||||
await service.poll_and_process_messages()
|
||||
image_message = {
|
||||
"id": 113,
|
||||
"localId": 113,
|
||||
"sendId": 200,
|
||||
"recvId": 100,
|
||||
"sendTime": clock.millis(),
|
||||
"type": 1,
|
||||
"content": json.dumps(
|
||||
{
|
||||
"originUrl": "https://cdn.example/case.png",
|
||||
"thumbUrl": "https://cdn.example/case-thumb.png",
|
||||
}
|
||||
),
|
||||
}
|
||||
|
||||
db = session_factory()
|
||||
try:
|
||||
avatar = db.get(Avatar, "avatar-1")
|
||||
service._record_message(db, avatar, "100", image_message, schedule_reply=False)
|
||||
cursor = db.query(TakeoverCursor).one()
|
||||
cursor.last_message_id = "113"
|
||||
db.commit()
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
clock.advance(60)
|
||||
boxim.messages.extend(
|
||||
[
|
||||
image_message,
|
||||
{
|
||||
"id": 114,
|
||||
"localId": 114,
|
||||
"sendId": 200,
|
||||
"recvId": 100,
|
||||
"sendTime": clock.millis(),
|
||||
"type": 0,
|
||||
"content": "请帮我看看这张图",
|
||||
},
|
||||
]
|
||||
)
|
||||
await service.poll_messages()
|
||||
|
||||
db = session_factory()
|
||||
try:
|
||||
task = db.query(TakeoverReplyTask).filter_by(trigger_message_id="114").one()
|
||||
assert task.source_message_ids == ["113", "114"]
|
||||
assert task.prompt == "请看看这张图片。\n请帮我看看这张图"
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
clock.advance(1)
|
||||
boxim.messages.append(
|
||||
{
|
||||
"id": 115,
|
||||
"localId": 115,
|
||||
"sendId": 200,
|
||||
"recvId": 100,
|
||||
"sendTime": clock.millis(),
|
||||
"type": 0,
|
||||
"content": "图里写了什么",
|
||||
}
|
||||
)
|
||||
await service.poll_messages()
|
||||
|
||||
db = session_factory()
|
||||
try:
|
||||
latest = db.query(TakeoverReplyTask).filter_by(trigger_message_id="115").one()
|
||||
assert latest.source_message_ids == ["113", "114", "115"]
|
||||
assert latest.source_message_ids.count("113") == 1
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_explicit_followup_reuses_handled_image_within_two_days(service_context):
|
||||
session_factory, service, boxim, clock = service_context
|
||||
await service.poll_and_process_messages()
|
||||
image_message = {
|
||||
"id": 116,
|
||||
"localId": 116,
|
||||
"sendId": 200,
|
||||
"recvId": 100,
|
||||
"sendTime": clock.millis(),
|
||||
"type": 1,
|
||||
"content": json.dumps({"originUrl": "https://cdn.example/handled-case.png"}),
|
||||
}
|
||||
boxim.messages.append(image_message)
|
||||
await service.poll_messages()
|
||||
|
||||
db = session_factory()
|
||||
try:
|
||||
image_task = db.query(TakeoverReplyTask).filter_by(trigger_message_id="116").one()
|
||||
image_task.status = "sent"
|
||||
image_task.sent_at = clock.now()
|
||||
db.commit()
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
clock.advance(47 * 60 * 60)
|
||||
boxim.messages.append(
|
||||
{
|
||||
"id": 117,
|
||||
"localId": 117,
|
||||
"sendId": 200,
|
||||
"recvId": 100,
|
||||
"sendTime": clock.millis(),
|
||||
"type": 0,
|
||||
"content": "重新看一下刚才那张病例图片",
|
||||
}
|
||||
)
|
||||
await service.poll_messages()
|
||||
|
||||
db = session_factory()
|
||||
try:
|
||||
task = db.query(TakeoverReplyTask).filter_by(trigger_message_id="117").one()
|
||||
assert task.source_message_ids == ["116", "117"]
|
||||
assert task.prompt == "请看看这张图片。\n重新看一下刚才那张病例图片"
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_invalid_image_message_is_recorded_but_not_scheduled(service_context):
|
||||
session_factory, service, boxim, clock = service_context
|
||||
await service.poll_and_process_messages()
|
||||
boxim.messages.append(
|
||||
{
|
||||
"id": 112,
|
||||
"localId": 112,
|
||||
"sendId": 200,
|
||||
"recvId": 100,
|
||||
"sendTime": clock.millis(),
|
||||
"type": 1,
|
||||
"content": json.dumps({"width": 100, "height": 100}),
|
||||
}
|
||||
)
|
||||
|
||||
await service.poll_and_process_messages()
|
||||
|
||||
db = session_factory()
|
||||
try:
|
||||
assert db.query(TakeoverMessage).filter_by(boxim_message_id="112").one()
|
||||
assert db.query(TakeoverReplyTask).count() == 0
|
||||
finally:
|
||||
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_multiple_avatar_owners_are_polled_concurrently(service_context):
|
||||
session_factory, _service, _boxim, clock = service_context
|
||||
db = session_factory()
|
||||
try:
|
||||
db.add_all(
|
||||
[
|
||||
User(
|
||||
id="owner-local-2",
|
||||
huihui_user_id="owner-huihui-2",
|
||||
huihui_token="prod-huihui-token-2",
|
||||
app_token="app-token-2",
|
||||
),
|
||||
Avatar(
|
||||
id="avatar-2",
|
||||
owner_id="owner-huihui-2",
|
||||
name="分身二",
|
||||
status="active",
|
||||
config={"authorizationPermissions": ["chat", "takeover"]},
|
||||
),
|
||||
]
|
||||
)
|
||||
db.commit()
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
boxim = ConcurrentMessagePollingBoxIM()
|
||||
service = TakeoverService(
|
||||
session_factory,
|
||||
boxim,
|
||||
poll_concurrency=2,
|
||||
now=clock.now,
|
||||
)
|
||||
|
||||
await service.poll_messages()
|
||||
|
||||
assert boxim.peak_active_polls == 2
|
||||
db = session_factory()
|
||||
try:
|
||||
assert db.query(TakeoverCursor).filter(TakeoverCursor.initialized.is_(True)).count() == 2
|
||||
assert db.query(TakeoverMessage).count() == 2
|
||||
assert len(boxim.read_receipts) == 2
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delayed_poll_still_schedules_recent_message(service_context):
|
||||
session_factory, service, boxim, clock = service_context
|
||||
await service.poll_messages()
|
||||
delayed_send_time = int(
|
||||
(clock.value - timedelta(seconds=150)).replace(tzinfo=timezone.utc).timestamp()
|
||||
* 1000
|
||||
)
|
||||
boxim.messages.append(
|
||||
{
|
||||
"id": 13,
|
||||
"localId": 13,
|
||||
"sendId": 200,
|
||||
"recvId": 100,
|
||||
"sendTime": delayed_send_time,
|
||||
"type": 0,
|
||||
"content": "排队后仍需回复",
|
||||
}
|
||||
)
|
||||
|
||||
await service.poll_messages()
|
||||
|
||||
db = session_factory()
|
||||
try:
|
||||
task = db.query(TakeoverReplyTask).one()
|
||||
assert task.status == "pending"
|
||||
assert task.scheduled_at == clock.now()
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
with patch("routers.chat._resolve_reply", return_value={"answer": "已经收到"}):
|
||||
await service.process_reply_tasks()
|
||||
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
|
||||
async def test_different_contacts_generate_without_blocking_each_other(service_context):
|
||||
session_factory, service, boxim, clock = service_context
|
||||
@@ -156,15 +655,15 @@ async def test_different_contacts_generate_without_blocking_each_other(service_c
|
||||
)
|
||||
both_generating = Barrier(2, timeout=2)
|
||||
|
||||
def resolve(_db, _avatar, prompt, _history):
|
||||
def resolve(_db, _avatar, prompt, _history, **_kwargs):
|
||||
both_generating.wait()
|
||||
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)
|
||||
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} == {
|
||||
("200", "回复甲"),
|
||||
("300", "回复乙"),
|
||||
@@ -239,6 +738,34 @@ async def test_owner_message_cancels_pending_reply(service_context):
|
||||
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
|
||||
async def test_quick_successive_messages_are_coalesced_into_one_reply(service_context):
|
||||
session_factory, service, boxim, clock = service_context
|
||||
@@ -246,19 +773,18 @@ async def test_quick_successive_messages_are_coalesced_into_one_reply(service_co
|
||||
boxim.messages.append(
|
||||
{"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)
|
||||
boxim.messages.append(
|
||||
{"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:
|
||||
await service.poll_and_process_messages()
|
||||
assert resolver.call_args.args[2] == "第一句\n第二句"
|
||||
|
||||
clock.advance(3)
|
||||
await service.poll_and_process_messages()
|
||||
assert [item["content"] for item in boxim.sent] == ["合并回复"]
|
||||
|
||||
db = session_factory()
|
||||
@@ -293,5 +819,41 @@ async def test_connection_failure_disables_takeover_and_stops_retrying(service_c
|
||||
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():
|
||||
assert _plain_text_reply("## 建议\n\n**不能自行用药**\n`必要时就医`") == "建议\n不能自行用药\n必要时就医"
|
||||
|
||||
@@ -0,0 +1,430 @@
|
||||
import uuid
|
||||
import os
|
||||
import hashlib
|
||||
import hmac
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from threading import Barrier
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from database import SessionLocal
|
||||
from main import app, seed
|
||||
from models import Avatar, TokenAccount, TokenPaymentOrder, TokenPlan, TokenUsage, User
|
||||
from routers.chat import _resolve_reply, _stream_reply
|
||||
from services.token_billing import (
|
||||
DEFAULT_TOKEN_GRANT,
|
||||
InsufficientTokensError,
|
||||
get_or_create_account,
|
||||
release_reservation,
|
||||
reserve_avatar_tokens,
|
||||
settle_reservation,
|
||||
)
|
||||
|
||||
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):
|
||||
context = authorization_context
|
||||
owner = client.get("/api/token/balance", headers=context["owner_headers"])
|
||||
other = client.get("/api/token/balance", headers=context["other_headers"])
|
||||
|
||||
assert owner.status_code == 200
|
||||
assert owner.json()["data"] == {
|
||||
"balance": DEFAULT_TOKEN_GRANT,
|
||||
"totalGranted": DEFAULT_TOKEN_GRANT,
|
||||
"totalConsumed": 0,
|
||||
}
|
||||
assert other.json()["data"]["balance"] == DEFAULT_TOKEN_GRANT
|
||||
assert client.get("/api/token/balance").status_code == 401
|
||||
|
||||
|
||||
def test_seed_synchronizes_requested_recharge_plans():
|
||||
seed()
|
||||
db = SessionLocal()
|
||||
try:
|
||||
plans = db.query(TokenPlan).order_by(TokenPlan.price.asc()).all()
|
||||
assert [(plan.price, plan.amount) for plan in plans] == [
|
||||
(10, 2_000_000),
|
||||
(100, 20_000_000),
|
||||
(1000, 250_000_000),
|
||||
(10000, 2_500_000_000),
|
||||
]
|
||||
finally:
|
||||
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():
|
||||
suffix = uuid.uuid4().hex
|
||||
db = SessionLocal()
|
||||
user = User(id=f"token-user-{suffix}", huihui_user_id=f"token-huihui-{suffix}")
|
||||
first = Avatar(id=f"token-avatar-a-{suffix}", owner_id=user.huihui_user_id, name="甲")
|
||||
second = Avatar(id=f"token-avatar-b-{suffix}", owner_id=user.huihui_user_id, name="乙")
|
||||
db.add_all([user, first, second])
|
||||
db.commit()
|
||||
try:
|
||||
first_reservation = reserve_avatar_tokens(db, first, "chat", "qwen-test", [{"content": "问题一"}], 128)
|
||||
settle_reservation(
|
||||
db,
|
||||
first_reservation,
|
||||
{"prompt_tokens": 60, "completion_tokens": 40, "total_tokens": 100},
|
||||
fallback_total=999,
|
||||
)
|
||||
second_reservation = reserve_avatar_tokens(db, second, "takeover", "qwen-test", [{"content": "问题二"}], 128)
|
||||
settle_reservation(
|
||||
db,
|
||||
second_reservation,
|
||||
{"prompt_tokens": 120, "completion_tokens": 80, "total_tokens": 200},
|
||||
fallback_total=999,
|
||||
)
|
||||
|
||||
account = get_or_create_account(db, user.id)
|
||||
assert account.balance == DEFAULT_TOKEN_GRANT - 300
|
||||
assert account.total_consumed == 300
|
||||
usages = db.query(TokenUsage).filter(TokenUsage.user_id == user.id).order_by(TokenUsage.total_tokens).all()
|
||||
assert [(row.avatar_id, row.source, row.total_tokens) for row in usages] == [
|
||||
(first.id, "chat", 100),
|
||||
(second.id, "takeover", 200),
|
||||
]
|
||||
finally:
|
||||
db.query(TokenUsage).filter(TokenUsage.user_id == user.id).delete(synchronize_session=False)
|
||||
db.query(TokenAccount).filter(TokenAccount.user_id == user.id).delete(synchronize_session=False)
|
||||
db.query(Avatar).filter(Avatar.id.in_([first.id, second.id])).delete(synchronize_session=False)
|
||||
db.query(User).filter(User.id == user.id).delete(synchronize_session=False)
|
||||
db.commit()
|
||||
db.close()
|
||||
|
||||
|
||||
def test_concurrent_settlements_do_not_overwrite_each_other():
|
||||
suffix = uuid.uuid4().hex
|
||||
db = SessionLocal()
|
||||
user = User(id=f"concurrent-user-{suffix}", huihui_user_id=f"concurrent-huihui-{suffix}")
|
||||
avatar = Avatar(id=f"concurrent-avatar-{suffix}", owner_id=user.huihui_user_id, name="并发测试")
|
||||
db.add_all([user, avatar])
|
||||
db.commit()
|
||||
first = reserve_avatar_tokens(db, avatar, "takeover", "qwen-test", [{"content": "甲"}], 128)
|
||||
second = reserve_avatar_tokens(db, avatar, "takeover", "qwen-test", [{"content": "乙"}], 128)
|
||||
db.close()
|
||||
barrier = Barrier(2, timeout=3)
|
||||
|
||||
def settle(reservation, total):
|
||||
thread_db = SessionLocal()
|
||||
try:
|
||||
barrier.wait()
|
||||
settle_reservation(
|
||||
thread_db,
|
||||
reservation,
|
||||
{"prompt_tokens": total - 20, "completion_tokens": 20, "total_tokens": total},
|
||||
fallback_total=999,
|
||||
)
|
||||
finally:
|
||||
thread_db.close()
|
||||
|
||||
with ThreadPoolExecutor(max_workers=2) as pool:
|
||||
list(pool.map(lambda args: settle(*args), [(first, 100), (second, 200)]))
|
||||
|
||||
db = SessionLocal()
|
||||
try:
|
||||
account = get_or_create_account(db, user.id)
|
||||
assert account.balance == DEFAULT_TOKEN_GRANT - 300
|
||||
assert account.total_consumed == 300
|
||||
finally:
|
||||
db.query(TokenUsage).filter(TokenUsage.user_id == user.id).delete(synchronize_session=False)
|
||||
db.query(TokenAccount).filter(TokenAccount.user_id == user.id).delete(synchronize_session=False)
|
||||
db.query(Avatar).filter(Avatar.id == avatar.id).delete(synchronize_session=False)
|
||||
db.query(User).filter(User.id == user.id).delete(synchronize_session=False)
|
||||
db.commit()
|
||||
db.close()
|
||||
|
||||
|
||||
def test_failed_model_request_returns_the_full_reservation():
|
||||
suffix = uuid.uuid4().hex
|
||||
db = SessionLocal()
|
||||
user = User(id=f"refund-user-{suffix}", huihui_user_id=f"refund-huihui-{suffix}")
|
||||
avatar = Avatar(id=f"refund-avatar-{suffix}", owner_id=user.huihui_user_id, name="退款测试")
|
||||
db.add_all([user, avatar])
|
||||
db.commit()
|
||||
try:
|
||||
reservation = reserve_avatar_tokens(db, avatar, "chat", "qwen-test", [{"content": "问题"}], 128)
|
||||
release_reservation(db, reservation, "provider error")
|
||||
account = get_or_create_account(db, user.id)
|
||||
usage = db.query(TokenUsage).filter(TokenUsage.id == reservation.usage_id).one()
|
||||
assert account.balance == DEFAULT_TOKEN_GRANT
|
||||
assert account.total_consumed == 0
|
||||
assert usage.status == "failed"
|
||||
finally:
|
||||
db.query(TokenUsage).filter(TokenUsage.user_id == user.id).delete(synchronize_session=False)
|
||||
db.query(TokenAccount).filter(TokenAccount.user_id == user.id).delete(synchronize_session=False)
|
||||
db.query(Avatar).filter(Avatar.id == avatar.id).delete(synchronize_session=False)
|
||||
db.query(User).filter(User.id == user.id).delete(synchronize_session=False)
|
||||
db.commit()
|
||||
db.close()
|
||||
|
||||
|
||||
def test_insufficient_balance_rejects_before_model_usage_is_created():
|
||||
suffix = uuid.uuid4().hex
|
||||
db = SessionLocal()
|
||||
user = User(id=f"empty-user-{suffix}", huihui_user_id=f"empty-huihui-{suffix}")
|
||||
avatar = Avatar(id=f"empty-avatar-{suffix}", owner_id=user.huihui_user_id, name="余额不足")
|
||||
db.add_all([user, avatar])
|
||||
db.commit()
|
||||
try:
|
||||
account = get_or_create_account(db, user.id)
|
||||
account.balance = 1
|
||||
db.commit()
|
||||
with pytest.raises(InsufficientTokensError):
|
||||
reserve_avatar_tokens(db, avatar, "chat", "qwen-test", [{"content": "问题"}], 128)
|
||||
db.refresh(account)
|
||||
assert account.balance == 1
|
||||
assert db.query(TokenUsage).filter(TokenUsage.user_id == user.id).count() == 0
|
||||
finally:
|
||||
db.query(TokenUsage).filter(TokenUsage.user_id == user.id).delete(synchronize_session=False)
|
||||
db.query(TokenAccount).filter(TokenAccount.user_id == user.id).delete(synchronize_session=False)
|
||||
db.query(Avatar).filter(Avatar.id == avatar.id).delete(synchronize_session=False)
|
||||
db.query(User).filter(User.id == user.id).delete(synchronize_session=False)
|
||||
db.commit()
|
||||
db.close()
|
||||
|
||||
|
||||
def test_chat_settles_from_provider_usage_not_fallback_estimate():
|
||||
suffix = uuid.uuid4().hex
|
||||
db = SessionLocal()
|
||||
user = User(id=f"chat-user-{suffix}", huihui_user_id=f"chat-huihui-{suffix}")
|
||||
avatar = Avatar(id=f"chat-avatar-{suffix}", owner_id=user.huihui_user_id, name="聊天测试", config={})
|
||||
db.add_all([user, avatar])
|
||||
db.commit()
|
||||
try:
|
||||
with patch(
|
||||
"routers.chat._call_qwen",
|
||||
return_value={
|
||||
"answer": "测试回答",
|
||||
"usage": {"prompt_tokens": 80, "completion_tokens": 20, "total_tokens": 100},
|
||||
},
|
||||
):
|
||||
result = _resolve_reply(
|
||||
db,
|
||||
avatar,
|
||||
"测试问题",
|
||||
[],
|
||||
qa_pairs=[],
|
||||
search_fn=lambda *_args: [],
|
||||
)
|
||||
assert result["tokenUsage"]["totalTokens"] == 100
|
||||
assert result["tokenUsage"]["balance"] == DEFAULT_TOKEN_GRANT - 100
|
||||
finally:
|
||||
db.query(TokenUsage).filter(TokenUsage.user_id == user.id).delete(synchronize_session=False)
|
||||
db.query(TokenAccount).filter(TokenAccount.user_id == user.id).delete(synchronize_session=False)
|
||||
db.query(Avatar).filter(Avatar.id == avatar.id).delete(synchronize_session=False)
|
||||
db.query(User).filter(User.id == user.id).delete(synchronize_session=False)
|
||||
db.commit()
|
||||
db.close()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_streaming_chat_settles_final_provider_usage():
|
||||
suffix = uuid.uuid4().hex
|
||||
db = SessionLocal()
|
||||
user = User(id=f"stream-user-{suffix}", huihui_user_id=f"stream-huihui-{suffix}")
|
||||
avatar = Avatar(id=f"stream-avatar-{suffix}", owner_id=user.huihui_user_id, name="流式测试", config={})
|
||||
db.add_all([user, avatar])
|
||||
db.commit()
|
||||
try:
|
||||
chunks = iter([
|
||||
{"content": "流式"},
|
||||
{"content": "回答"},
|
||||
{"usage": {"prompt_tokens": 90, "completion_tokens": 10, "total_tokens": 100}},
|
||||
])
|
||||
with patch("routers.chat._iter_qwen_stream", return_value=chunks):
|
||||
response = _stream_reply(db, avatar, "测试问题", [])
|
||||
body = []
|
||||
async for chunk in response.body_iterator:
|
||||
body.append(chunk.decode() if isinstance(chunk, bytes) else chunk)
|
||||
assert "流式" in "".join(body)
|
||||
account = get_or_create_account(db, user.id)
|
||||
usage = db.query(TokenUsage).filter(TokenUsage.user_id == user.id).one()
|
||||
assert account.balance == DEFAULT_TOKEN_GRANT - 100
|
||||
assert usage.source == "chat_stream"
|
||||
assert usage.total_tokens == 100
|
||||
finally:
|
||||
db.query(TokenUsage).filter(TokenUsage.user_id == user.id).delete(synchronize_session=False)
|
||||
db.query(TokenAccount).filter(TokenAccount.user_id == user.id).delete(synchronize_session=False)
|
||||
db.query(Avatar).filter(Avatar.id == avatar.id).delete(synchronize_session=False)
|
||||
db.query(User).filter(User.id == user.id).delete(synchronize_session=False)
|
||||
db.commit()
|
||||
db.close()
|
||||
@@ -0,0 +1,104 @@
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
from unittest.mock import patch
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from database import SessionLocal
|
||||
from main import app, seed
|
||||
from models import InvoiceApplication, PaymentRefund, TokenAccount, TokenPaymentOrder, TokenPlan, User
|
||||
from services.token_billing import DEFAULT_TOKEN_GRANT
|
||||
|
||||
|
||||
client = TestClient(app)
|
||||
|
||||
|
||||
def _signature(token, timestamp, nonce):
|
||||
return hashlib.sha1("".join(sorted([token, timestamp, nonce])).encode()).hexdigest()
|
||||
|
||||
|
||||
def test_virtual_payment_callback_and_refund_are_idempotent(authorization_context):
|
||||
seed()
|
||||
context = authorization_context
|
||||
db = SessionLocal()
|
||||
try:
|
||||
user = db.query(User).filter(User.id == context["owner"].id).one()
|
||||
user.wechat_mp_openid = "openid-flow"
|
||||
user.wechat_mp_session_key = "session-flow"
|
||||
plan = db.query(TokenPlan).filter(TokenPlan.id == "1").one()
|
||||
plan.virtual_product_id = "points_plan_1"
|
||||
db.commit()
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
env = {
|
||||
"WECHAT_VIRTUAL_ENV": "sandbox",
|
||||
"WECHAT_VIRTUAL_SANDBOX_APP_KEY": "sandbox-key",
|
||||
"WECHAT_VIRTUAL_OFFER_ID": "offer-1",
|
||||
"WECHAT_VIRTUAL_CALLBACK_TOKEN": "callback-token",
|
||||
"AVATAR_FINANCE_ADMIN_SECRET": "finance-admin-secret-123",
|
||||
}
|
||||
with patch.dict(os.environ, env):
|
||||
created = client.post(
|
||||
"/api/token/charge",
|
||||
headers=context["owner_headers"],
|
||||
json={"planId": "1", "paymentMethod": "wechat", "payScene": "LITE"},
|
||||
).json()["data"]
|
||||
assert created["provider"] == "wechat_virtual"
|
||||
params = json.loads(created["payMessage"])
|
||||
assert params["mode"] == "short_series_goods"
|
||||
assert "session-flow" not in created["payMessage"]
|
||||
|
||||
notify = {
|
||||
"Event": "xpay_goods_deliver_notify",
|
||||
"OutTradeNo": created["orderNo"],
|
||||
"OpenId": "openid-flow",
|
||||
"Env": 1,
|
||||
"GoodsInfo": json.dumps({"ProductId": "points_plan_1", "ActualPrice": 1000}),
|
||||
"WeChatPayInfo": json.dumps({"TransactionId": "wx-transaction-1"}),
|
||||
}
|
||||
query = {"timestamp": "100", "nonce": "nonce", "signature": _signature("callback-token", "100", "nonce")}
|
||||
assert client.post("/api/token/payment/wechat/virtual/notify", params=query, json=notify).json()["ErrCode"] == 0
|
||||
assert client.post("/api/token/payment/wechat/virtual/notify", params=query, json=notify).json()["ErrCode"] == 0
|
||||
|
||||
invoice = client.post(
|
||||
f"/api/token/orders/{created['orderNo']}/invoice",
|
||||
headers=context["owner_headers"],
|
||||
json={"title": "测试用户", "invoiceType": "personal", "email": "test@example.com"},
|
||||
).json()["data"]
|
||||
assert invoice["status"] == "pending"
|
||||
|
||||
with patch("routers.tokens.request_wechat_virtual_refund", return_value={"errcode": 0}):
|
||||
refund_response = client.post(
|
||||
f"/api/token/admin/orders/{created['orderNo']}/refund",
|
||||
headers={"X-Avatar-Finance-Key": "finance-admin-secret-123"},
|
||||
json={"reason": "用户申请退款", "operator": "tester"},
|
||||
)
|
||||
assert refund_response.json()["data"]["status"] == "processing"
|
||||
refund_no = refund_response.json()["data"]["refundNo"]
|
||||
|
||||
refund_notify = {
|
||||
"Event": "xpay_refund_notify",
|
||||
"MchOrderId": created["orderNo"],
|
||||
"MchRefundId": refund_no,
|
||||
"WxRefundId": "wx-refund-1",
|
||||
"RefundFee": 1000,
|
||||
"RetCode": 0,
|
||||
}
|
||||
assert client.post("/api/token/payment/wechat/virtual/notify", params=query, json=refund_notify).json()["ErrCode"] == 0
|
||||
assert client.post("/api/token/payment/wechat/virtual/notify", params=query, json=refund_notify).json()["ErrCode"] == 0
|
||||
assert client.post("/api/token/payment/wechat/virtual/notify", params=query, json=notify).json()["ErrCode"] == 0
|
||||
|
||||
db = SessionLocal()
|
||||
try:
|
||||
order = db.query(TokenPaymentOrder).filter(TokenPaymentOrder.order_no == created["orderNo"]).one()
|
||||
account = db.query(TokenAccount).filter(TokenAccount.user_id == context["owner"].id).one()
|
||||
refund = db.query(PaymentRefund).filter(PaymentRefund.refund_no == refund_no).one()
|
||||
invoice = db.query(InvoiceApplication).filter(InvoiceApplication.order_no == created["orderNo"]).one()
|
||||
assert order.status == "refunded"
|
||||
assert refund.status == "succeeded"
|
||||
assert invoice.status == "cancelled"
|
||||
assert account.balance == DEFAULT_TOKEN_GRANT
|
||||
finally:
|
||||
db.close()
|
||||
@@ -0,0 +1,98 @@
|
||||
import io
|
||||
import json
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
import pytest
|
||||
from PIL import Image
|
||||
|
||||
from services.chat_model_config import ChatModelConfig
|
||||
from services.vision_service import (
|
||||
ImageValidationError,
|
||||
build_attachment_warning,
|
||||
call_vision_model,
|
||||
parse_vision_analysis,
|
||||
prepare_image,
|
||||
)
|
||||
|
||||
|
||||
def _image_bytes(fmt="PNG", size=(120, 80)):
|
||||
output = io.BytesIO()
|
||||
Image.new("RGB", size, "#f97316").save(output, format=fmt)
|
||||
return output.getvalue()
|
||||
|
||||
|
||||
def _config():
|
||||
return ChatModelConfig(
|
||||
api_base_url="https://model.test/v1",
|
||||
api_key="secret-key",
|
||||
model="chat-model",
|
||||
max_tokens=1024,
|
||||
timeout_seconds=30,
|
||||
vision_model="vision-model",
|
||||
ocr_model="ocr-model",
|
||||
vision_max_tokens=2048,
|
||||
vision_timeout_seconds=90,
|
||||
source="test",
|
||||
)
|
||||
|
||||
|
||||
def test_prepare_image_validates_and_reencodes_without_metadata():
|
||||
prepared = prepare_image(_image_bytes())
|
||||
|
||||
assert prepared.mime_type == "image/jpeg"
|
||||
assert prepared.width == 120
|
||||
assert prepared.height == 80
|
||||
with Image.open(io.BytesIO(prepared.data)) as image:
|
||||
assert image.format == "JPEG"
|
||||
assert not image.getexif()
|
||||
|
||||
|
||||
def test_prepare_image_rejects_non_image_content():
|
||||
with pytest.raises(ImageValidationError, match="格式无效"):
|
||||
prepare_image(b"not-an-image")
|
||||
|
||||
|
||||
def test_vision_request_uses_openai_compatible_image_content():
|
||||
response = Mock()
|
||||
response.raise_for_status.return_value = None
|
||||
response.json.return_value = {
|
||||
"choices": [{"message": {"content": '{"category":"general_image"}'}}],
|
||||
"usage": {"total_tokens": 88},
|
||||
}
|
||||
prepared = prepare_image(_image_bytes())
|
||||
|
||||
with patch("services.vision_service.httpx.post", return_value=response) as request:
|
||||
result = call_vision_model(
|
||||
prepared,
|
||||
_config(),
|
||||
model="vision-model",
|
||||
prompt="describe",
|
||||
json_output=True,
|
||||
)
|
||||
|
||||
payload = request.call_args.kwargs["json"]
|
||||
content = payload["messages"][0]["content"]
|
||||
assert payload["model"] == "vision-model"
|
||||
assert payload["response_format"] == {"type": "json_object"}
|
||||
assert content[0]["type"] == "image_url"
|
||||
assert content[0]["image_url"]["url"].startswith("data:image/jpeg;base64,")
|
||||
assert content[1] == {"type": "text", "text": "describe"}
|
||||
assert result["usage"]["total_tokens"] == 88
|
||||
|
||||
|
||||
def test_parse_medical_analysis_and_build_warning():
|
||||
analysis = parse_vision_analysis(json.dumps({
|
||||
"category": "medical_document",
|
||||
"summary": "血常规报告",
|
||||
"visible_text": "白细胞 11.2",
|
||||
"key_facts": ["白细胞偏高"],
|
||||
"uncertainties": ["日期模糊"],
|
||||
"medical": {"document_type": "检验报告"},
|
||||
}, ensure_ascii=False))
|
||||
|
||||
assert analysis["category"] == "medical_document"
|
||||
assert analysis["medical"]["document_type"] == "检验报告"
|
||||
warning = build_attachment_warning(analysis, ocr_failed=True)
|
||||
assert "日期模糊" in warning
|
||||
assert "人工核对" in warning
|
||||
assert "不能替代医生诊断" in warning
|
||||
@@ -0,0 +1,72 @@
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
import os
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
from services import wechat_virtual_payment as virtual
|
||||
|
||||
|
||||
def test_build_payment_params_signs_the_exact_compact_payload():
|
||||
order = SimpleNamespace(order_no="AV202609080001", plan_id="plan-1", price_cents=1000)
|
||||
plan = SimpleNamespace(id="plan-1", virtual_product_id="points_plan_1")
|
||||
env = {
|
||||
"WECHAT_VIRTUAL_ENV": "sandbox",
|
||||
"WECHAT_VIRTUAL_SANDBOX_APP_KEY": "sandbox-key",
|
||||
"WECHAT_VIRTUAL_OFFER_ID": "offer-1",
|
||||
}
|
||||
with patch.dict(os.environ, env, clear=False):
|
||||
result = virtual.build_payment_params(order=order, plan=plan, session_key="session-key")
|
||||
|
||||
sign_data = result["signData"]
|
||||
assert sign_data == json.dumps({
|
||||
"offerId": "offer-1",
|
||||
"buyQuantity": 1,
|
||||
"env": 1,
|
||||
"currencyType": "CNY",
|
||||
"productId": "points_plan_1",
|
||||
"goodsPrice": 1000,
|
||||
"outTradeNo": "AV202609080001",
|
||||
"attach": '{"orderNo":"AV202609080001","planId":"plan-1"}',
|
||||
}, ensure_ascii=False, separators=(",", ":"))
|
||||
assert result["paySig"] == hmac.new(
|
||||
b"sandbox-key", f"requestVirtualPayment&{sign_data}".encode(), hashlib.sha256
|
||||
).hexdigest()
|
||||
assert result["signature"] == hmac.new(
|
||||
b"session-key", sign_data.encode(), hashlib.sha256
|
||||
).hexdigest()
|
||||
|
||||
|
||||
def test_callback_signature_and_xml_body_are_supported():
|
||||
with patch.dict(os.environ, {"WECHAT_VIRTUAL_CALLBACK_TOKEN": "callback-token"}):
|
||||
signature = hashlib.sha1("".join(sorted(["callback-token", "100", "nonce"])).encode()).hexdigest()
|
||||
assert virtual.verify_callback_signature(signature, "100", "nonce")
|
||||
payload = virtual.parse_callback_body(
|
||||
b"<xml><Event>xpay_refund_notify</Event><GoodsInfo><ActualPrice>1000</ActualPrice></GoodsInfo></xml>"
|
||||
)
|
||||
assert virtual.callback_value(payload, "event") == "xpay_refund_notify"
|
||||
assert virtual.callback_value(payload, "goodsinfo", "actualprice") == "1000"
|
||||
|
||||
|
||||
def test_xpay_request_uses_server_access_token_and_pay_signature():
|
||||
token_response = Mock(status_code=200)
|
||||
token_response.json.return_value = {"access_token": "server-token", "expires_in": 7200}
|
||||
pay_response = Mock(status_code=200)
|
||||
pay_response.json.return_value = {"errcode": 0, "order": {"status": 2}}
|
||||
virtual._access_token_cache = ("", 0)
|
||||
env = {
|
||||
"WECHAT_MP_APP_ID": "wx-app",
|
||||
"WECHAT_MP_APP_SECRET": "wx-secret",
|
||||
"WECHAT_VIRTUAL_SANDBOX_APP_KEY": "sandbox-key",
|
||||
"WECHAT_VIRTUAL_ENV": "sandbox",
|
||||
}
|
||||
with patch.dict(os.environ, env), patch.object(virtual.httpx, "get", return_value=token_response), patch.object(
|
||||
virtual.httpx, "post", return_value=pay_response
|
||||
) as post:
|
||||
result = virtual.query_order(openid="openid", order_no="AV1")
|
||||
assert result["order"]["status"] == 2
|
||||
body = '{"openid":"openid","order_id":"AV1","env":1}'
|
||||
expected = hmac.new(b"sandbox-key", f"/xpay/query_order&{body}".encode(), hashlib.sha256).hexdigest()
|
||||
assert post.call_args.args[0] == "https://api.weixin.qq.com/xpay/query_order"
|
||||
assert post.call_args.kwargs["params"] == {"access_token": "server-token", "pay_sig": expected}
|
||||
@@ -1,39 +1,56 @@
|
||||
# 会会数字分身 —— Docker 测试实例(独立端口,不干扰现有 :8088 huihui 部署)
|
||||
services:
|
||||
avatar-backend:
|
||||
build: ./backend
|
||||
image: avatar-test-backend:latest
|
||||
build:
|
||||
context: ./backend
|
||||
args:
|
||||
APP_GIT_SHA: ${APP_GIT_SHA:?APP_GIT_SHA must be the full release commit}
|
||||
APP_BUILD_TIME: ${APP_BUILD_TIME:?APP_BUILD_TIME must be set}
|
||||
image: avatar-test-backend:${APP_GIT_SHA}
|
||||
container_name: avatar-test-backend
|
||||
restart: unless-stopped
|
||||
env_file:
|
||||
- .env
|
||||
environment:
|
||||
DATABASE_URL: sqlite:////data/avatar.db
|
||||
DATABASE_URL: sqlite:////data/db/avatar.db
|
||||
UPLOAD_DIR: /data/uploads
|
||||
CHAT_MODEL_CONFIG_URL: http://host.docker.internal:8000/api/ai-models/runtime/digital-avatar
|
||||
extra_hosts:
|
||||
- "host.docker.internal:host-gateway"
|
||||
volumes:
|
||||
- avatar-data:/data
|
||||
# Mount the directory, not only avatar.db: SQLite WAL/SHM files must survive recreation.
|
||||
- ${AVATAR_DB_DIR:?AVATAR_DB_DIR must contain the persistent avatar.db}:/data/db
|
||||
- ${AVATAR_UPLOAD_DIR:?AVATAR_UPLOAD_DIR must point to persistent uploads}:/data/uploads
|
||||
expose:
|
||||
- "8000"
|
||||
ports:
|
||||
- "8011:8000" # 仅用于直接调试 API;前端经内部网络访问,不走 host 端口
|
||||
healthcheck:
|
||||
test: ["CMD", "python", "-c", "import json,urllib.request; d=json.load(urllib.request.urlopen('http://127.0.0.1:8000/api/health', timeout=5))['data']; assert d['status']=='ok' and all(d['checks'].values())"]
|
||||
interval: 10s
|
||||
timeout: 8s
|
||||
retries: 12
|
||||
start_period: 20s
|
||||
networks:
|
||||
- avatar-net
|
||||
|
||||
avatar-frontend:
|
||||
build: .
|
||||
image: avatar-test-frontend:latest
|
||||
build:
|
||||
context: .
|
||||
args:
|
||||
APP_GIT_SHA: ${APP_GIT_SHA:?APP_GIT_SHA must be the full release commit}
|
||||
APP_BUILD_TIME: ${APP_BUILD_TIME:?APP_BUILD_TIME must be set}
|
||||
image: avatar-test-frontend:${APP_GIT_SHA}
|
||||
container_name: avatar-test-frontend
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "8099:80" # 浏览器访问 http://<host>:8099
|
||||
depends_on:
|
||||
- avatar-backend
|
||||
avatar-backend:
|
||||
condition: service_healthy
|
||||
networks:
|
||||
- avatar-net
|
||||
|
||||
networks:
|
||||
avatar-net:
|
||||
driver: bridge
|
||||
|
||||
volumes:
|
||||
avatar-data:
|
||||
|
||||
@@ -0,0 +1,182 @@
|
||||
# 数字分身 H5 生产接入与部署
|
||||
|
||||
## 1. 接入方式
|
||||
|
||||
生产会会在用户已登录后打开以下地址:
|
||||
|
||||
```text
|
||||
https://digital.99hui.com/#/avatar/manage?token=<encodeURIComponent(会会 access token)>
|
||||
```
|
||||
|
||||
测试环境示例:
|
||||
|
||||
```text
|
||||
http://192.168.1.188:8099/#/avatar/manage?token=<encodeURIComponent(token)>
|
||||
```
|
||||
|
||||
兼容参数位于域名查询串的形式,但生产统一使用上面的 hash 路由形式。必须对 token 调用 `encodeURIComponent`,不能拼接用户 ID 代替 token。
|
||||
|
||||
免登录流程如下:
|
||||
|
||||
1. H5 在页面渲染前读取 `token`,立即通过 `history.replaceState` 从地址栏和浏览器历史中移除。
|
||||
2. H5 调用 `POST /api/huihui/token/login`,不会把会会 token 当作数字分身接口 token 直接使用。
|
||||
3. 后端通过会会生产接口 `/im/box/netease` 换取 BOXIM 凭证,再调用 BOXIM `/user/self` 校验用户身份。
|
||||
4. 后端以返回的 `huihuiUserId` 绑定本地用户,保存会会凭证供 BOXIM 接管功能使用,并签发本系统 `app_token`。
|
||||
5. 浏览器只保存 `app_token` 和非敏感用户资料。会会原始 token 不返回浏览器存储。
|
||||
6. token 无效、过期或上游校验失败时清除旧会话并进入登录页,不会沿用上一位用户的缓存身份。
|
||||
|
||||
## 2. 生产配置
|
||||
|
||||
后端 `.env` 至少配置以下内容,密钥由部署平台注入,禁止提交 Git:
|
||||
|
||||
```dotenv
|
||||
HUIHUI_DEV_MOCK=false
|
||||
HUIHUI_AUTH_BASE_URL=https://99hui.com/api/usercenter
|
||||
HUIHUI_PLATFORM_BASE_URL=https://open.99hui.com/api
|
||||
BOXIM_API_BASE_URL=https://im.99hui.com/api
|
||||
HUIHUI_APP_ID=<production-app-id>
|
||||
HUIHUI_ACCESS_ID=<production-access-id>
|
||||
HUIHUI_ACCESS_SECRET=<production-access-secret>
|
||||
HUIHUI_CLIENT_CODE=<production-client-code>
|
||||
BOXIM_TIMEOUT_SECONDS=20
|
||||
BOXIM_POLL_CONCURRENCY=8
|
||||
BOXIM_MAX_MESSAGE_AGE_SECONDS=600
|
||||
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
|
||||
HUIHUI_PAYMENT_REFUND_PATH=/payment/refund
|
||||
AVATAR_FINANCE_ADMIN_SECRET=<至少32位随机密钥,与管理后台一致>
|
||||
|
||||
# 微信小程序虚拟支付;联调先使用 sandbox
|
||||
WECHAT_MP_APP_ID=<小程序AppID>
|
||||
WECHAT_MP_APP_SECRET=<小程序AppSecret>
|
||||
WECHAT_VIRTUAL_ENV=sandbox
|
||||
WECHAT_VIRTUAL_SANDBOX_APP_KEY=<沙箱AppKey>
|
||||
WECHAT_VIRTUAL_APP_KEY=<正式AppKey>
|
||||
WECHAT_VIRTUAL_OFFER_ID=<offer-id>
|
||||
WECHAT_VIRTUAL_CALLBACK_TOKEN=<回调校验Token>
|
||||
WECHAT_VIRTUAL_PRODUCT_1=<10元套餐商品ID>
|
||||
WECHAT_VIRTUAL_PRODUCT_2=<100元套餐商品ID>
|
||||
WECHAT_VIRTUAL_PRODUCT_3=<1000元套餐商品ID>
|
||||
WECHAT_VIRTUAL_PRODUCT_4=<10000元套餐商品ID>
|
||||
|
||||
AVATAR_DB_DIR=/srv/digital-avatar/data/db
|
||||
AVATAR_UPLOAD_DIR=/srv/digital-avatar/data/uploads
|
||||
DATABASE_URL=sqlite:////data/db/avatar.db
|
||||
UPLOAD_DIR=/data/uploads
|
||||
CHAT_MODEL_CONFIG_URL=http://<huihuisquare-api>/api/ai-models/runtime/digital-avatar
|
||||
EMBEDDING_API_URL=https://dashscope.aliyuncs.com/compatible-mode/v1
|
||||
EMBEDDING_API_KEY=<production-embedding-api-key>
|
||||
EMBEDDING_MODEL=text-embedding-v3
|
||||
EMBEDDING_BATCH_SIZE=10
|
||||
|
||||
VISION_MODEL=qwen3.6-flash
|
||||
VISION_OCR_MODEL=qwen-vl-ocr
|
||||
VISION_MAX_OUTPUT_TOKENS=2048
|
||||
VISION_TIMEOUT_SECONDS=90
|
||||
VISION_TOKEN_RESERVE=12000
|
||||
APP_GIT_SHA=<本次发布的完整提交SHA>
|
||||
APP_BUILD_TIME=<UTC ISO-8601构建时间>
|
||||
CHAT_IMAGE_MAX_BYTES=8388608
|
||||
CHAT_IMAGE_MAX_PIXELS=16000000
|
||||
CHAT_ATTACHMENT_RETENTION_HOURS=24
|
||||
CHAT_ATTACHMENT_CLEANUP_MINUTES=60
|
||||
```
|
||||
|
||||
如生产 AI 配置中心不可用,还应提供当前项目支持的 `OPENAI_API_KEY`、`OPENAI_BASE_URL`、`CHAT_MODEL` 等兜底配置。数据库文件与上传目录必须从宿主机显式挂载,不能存放在容器临时层。
|
||||
|
||||
`AVATAR_DB_DIR` 和 `AVATAR_UPLOAD_DIR` 必须是已备份的宿主机绝对路径,编排缺少任一变量都会直接拒绝构建或启动,防止误挂空卷造成用户、分身或知识库“丢失”的假象。SQLite 必须挂载整个数据库目录,不能只挂载 `avatar.db` 单文件,否则 `avatar.db-wal` 和 `avatar.db-shm` 会留在容器临时层,换容器后可能出现数据状态回退。
|
||||
|
||||
`EMBEDDING_API_URL` 同时支持 OpenAI 兼容基础地址(如上面的 `/v1`)和完整的 `/v1/embeddings` 地址,后端会统一请求 `/embeddings`。发布后必须在后端容器内执行一次最小向量探针,确认返回向量数量和维度,而不能只检查 `/api/health`。
|
||||
|
||||
App 与 H5 积分充值使用会会支付体系的 `payment-v3/payment/pay`,渠道值为 `WECHAT` / `ALIPAY`;App 场景为 `APP`,普通浏览器为 `H5`,微信内 H5 为 `JSAPI`。`HUIHUI_PAYMENT_CALLBACK_SECRET` 只用于为每笔订单生成 HMAC 回调签名,不会发送到前端或直接出现在回调地址中。支付回调确认状态成功且金额与套餐价格完全一致后才增加积分,重复回调不会重复到账。
|
||||
|
||||
微信小程序使用微信虚拟支付:小程序先通过 `POST /api/token/wechat/session` 交换临时登录码,再由 `POST /api/token/charge`(`payScene=LITE`)返回已签名的 `requestVirtualPayment` 参数。微信回调地址配置为 `https://digital.99hui.com/api/token/payment/wechat/virtual/notify`。回调会复核签名、OpenID、环境、商品 ID 与实付金额,退款回调确认后才扣回积分。AppKey、AppSecret、session_key 均不得下发前端或写日志。
|
||||
|
||||
管理后台需要配置相同的 `AVATAR_FINANCE_ADMIN_SECRET` 和 `AVATAR_BACKEND_URL=https://digital.99hui.com`。退款只支持整单原路退款;供应商受理后显示“处理中”,收到渠道成功回调(或经渠道后台核对后人工确认)才将订单置为已退款。已消费掉本订单积分时,后台会拒绝主动退款;若渠道外部退款先发生,积分账户允许形成负数以记录欠额并阻止继续消费。
|
||||
|
||||
## 3. 构建与发布
|
||||
|
||||
首次发布前备份数据:
|
||||
|
||||
```bash
|
||||
BACKUP_DIR="backups/$(date +%Y%m%d-%H%M%S)"
|
||||
mkdir -p "$BACKUP_DIR"
|
||||
cp /srv/digital-avatar/data/db/avatar.db "$BACKUP_DIR/"
|
||||
tar -C /srv/digital-avatar/data -czf "$BACKUP_DIR/uploads.tgz" uploads
|
||||
```
|
||||
|
||||
在发布目录执行:
|
||||
|
||||
```bash
|
||||
git fetch origin
|
||||
git checkout <已验收的提交SHA>
|
||||
cd digital-avatar-app
|
||||
export APP_GIT_SHA="$(git rev-parse HEAD)"
|
||||
export APP_BUILD_TIME="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
|
||||
docker compose build --pull --no-cache avatar-backend avatar-frontend
|
||||
docker compose up -d --force-recreate --wait avatar-backend avatar-frontend
|
||||
docker compose ps
|
||||
python3 scripts/verify-deployment.py \
|
||||
https://digital.99hui.com "$APP_GIT_SHA" \
|
||||
--backend-container avatar-backend \
|
||||
--frontend-container avatar-frontend \
|
||||
--expected-db-source /srv/digital-avatar/data/db \
|
||||
--expected-upload-source /srv/digital-avatar/data/uploads
|
||||
docker compose exec avatar-backend python -c 'import embeddings; v=embeddings.embed(["部署向量探针"]); print(len(v), len(v[0]))'
|
||||
```
|
||||
|
||||
Jenkins 必须以 `verify-deployment.py` 返回成功作为发布成功条件,不能只以镜像构建或容器启动成功作为条件。脚本会同时核对公网前后端 Git SHA、数据库可读、上传目录可写、PDF OCR 依赖和宿主机数据挂载;任意一项不一致都会返回非零状态并阻止发布标绿。镜像使用 Git SHA 标签,不再依赖可被旧缓存覆盖的 `latest`。
|
||||
|
||||
生产编排应把示例中的测试端口改为内网暴露,由统一 HTTPS 网关接入。后端暂时使用 SQLite,必须保持单实例写入;若扩展为多后端实例,应先迁移到 PostgreSQL,并把延迟接管任务改为共享队列。
|
||||
|
||||
## 4. 网关要求
|
||||
|
||||
必须使用 HTTPS。同域部署时,H5 静态资源与 `/api/` 由同一域名提供,可避免跨域和 Cookie/来源策略问题。Nginx 关键配置示例:
|
||||
|
||||
```nginx
|
||||
server_name digital.99hui.com;
|
||||
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
|
||||
location /api/ {
|
||||
proxy_pass http://avatar-backend:8000;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_buffering off;
|
||||
proxy_read_timeout 300s;
|
||||
client_max_body_size 100m;
|
||||
}
|
||||
```
|
||||
|
||||
`proxy_buffering off` 用于数字分身 SSE 流式吐字,`client_max_body_size` 同时用于知识库文件和聊天图片上传。应用只保存图片识别结果,不保存原图;识别结果 24 小时失效,后台默认每小时清理一次。公开分享图片识别会消耗分身所有者积分,生产网关应针对 `/api/public/avatar/*/chat/images` 设置每 IP 和每分享令牌的上传频率限制,防止恶意消耗。
|
||||
|
||||
网关和应用日志必须关闭完整 URL 查询参数记录,任何异常日志都不得输出 token、Authorization、图片 Base64、病例正文或平台密钥。建议同时设置严格的 `Referrer-Policy: no-referrer`。
|
||||
|
||||
## 5. 发布验收
|
||||
|
||||
1. 已登录会会用户通过带 token 链接打开后直接进入 `/avatar/manage`,不出现登录页或创建新账号页。
|
||||
2. 页面加载后地址栏中不再包含 `token`,刷新页面仍使用本地 `app_token` 正常访问。
|
||||
3. 后端用户绑定的是 BOXIM 返回的 `huihuiUserId`,不是 BOXIM 内部 `id`;原有数字分身、独立知识库和积分余额均存在。
|
||||
4. A、B 两个会会用户分别进入时只能看到各自的数字分身与知识库,不会继承上一用户缓存。
|
||||
5. 使用过期或伪造 token 时进入登录页并显示凭证失效,不得继续访问旧用户数据。
|
||||
6. 分身聊天 SSE 逐段输出正常,Markdown 正常渲染,知识库优先级和积分扣费正常。
|
||||
7. 开启 BOXIM 主动接管后保持在线,默认三分钟回复、自定义等待时间、已读回执、分身防回环和主人发言暂停均正常。
|
||||
8. 重建容器后数据库、头像、知识库文档仍存在,`/api/health` 的 `gitSha` 与发布 SHA 一致,`database`、`uploads`、`pdfOcr` 三项检查均为 `true`。
|
||||
9. `https://digital.99hui.com/api/health` 可访问,证书域名和有效期正确,HTTP 自动跳转 HTTPS。
|
||||
10. 微信和支付宝各创建一笔最小套餐订单,未付款时积分不变;支付成功后回调到账一次,重复回调积分不重复增加。
|
||||
11. 微信虚拟支付在沙箱环境完成下单、支付回调、查单兜底和退款回调;错误 OpenID、商品、环境或金额均被拒绝。
|
||||
12. 财务后台能筛选订单、关闭待支付订单、发起整单退款、登记退款对账结果,并处理个人/企业电子发票申请。
|
||||
13. 私聊和公开分享各上传 JPG、PNG、WebP 图片并完成追问;上传非图片、超过 8MB 或跨分身附件时必须拒绝。
|
||||
12. 病例图片可以提取可见文字并标记待核对内容,医学影像不作确定诊断;视觉与 OCR 调用分别扣减积分。
|
||||
13. 检查服务器上传目录不残留聊天原图,数据库过期图片识别记录在清理周期后删除,日志不出现 Base64 或病例正文。
|
||||
|
||||
## 6. 回滚
|
||||
|
||||
保留上一版前后端镜像标签和发布前数据库/上传文件备份。代码回滚优先切回上一镜像;只有新版本执行了不可逆数据变更时才恢复数据库。恢复前先停止后端写入,恢复后对比用户数、分身数、知识库文档数并完成一次免登录和聊天验收。
|
||||
@@ -0,0 +1,184 @@
|
||||
# 数字分身图片与病例理解详细设计
|
||||
|
||||
## 1. 目标与边界
|
||||
|
||||
本功能让数字分身在私聊和公开分享聊天中接收图片,并围绕图片内容继续使用现有的“标准答题对 -> 分身独立知识库 -> Qwen 兼容模型”链路回答。
|
||||
|
||||
第一期支持 JPEG、PNG、WebP,覆盖以下场景:
|
||||
|
||||
1. 普通照片、截图、图表和界面图片的内容理解。
|
||||
2. 病例、处方、检查单、检验报告等图片文档的文字和表格提取。
|
||||
3. X 光、CT、MRI 等医学影像的客观可见内容描述。
|
||||
|
||||
第一期不把通用视觉模型的输出当作医学诊断,不自动把图片或病例写入知识库,不保存原图供长期访问,也不支持 DICOM 原始影像。
|
||||
|
||||
## 2. 核心原则
|
||||
|
||||
- **资料优先级不变**:标准答题对最高,分身独立知识库其次,图片识别结果属于待核对的会话资料,最后才由模型组织表达。
|
||||
- **病例最小留存**:应用不把原图写入业务存储,上传内容在内存中归一化并调用视觉服务;数据库只保存结构化结果和必要元数据。
|
||||
- **严格隔离**:每条图片记录必须绑定 `avatar_id`,私聊校验分身所有者,公开聊天校验分享令牌对应的分身。
|
||||
- **不确定性显式化**:OCR 看不清、表格列错位、医学影像无法确认时必须指出待核对项,不允许补齐缺失内容。
|
||||
- **可计量**:视觉理解和病例 OCR 分别计入分身所有者的积分消耗,失败时释放预留积分。
|
||||
- **可降级**:OCR 失败但通用视觉结果有效时仍可回答;视觉主调用失败则不进入聊天发送。
|
||||
|
||||
## 3. 总体流程
|
||||
|
||||
```text
|
||||
用户选择图片
|
||||
-> 前端本地预览
|
||||
-> 私聊/公开图片上传接口
|
||||
-> 文件大小、MIME、真实格式、像素数校验
|
||||
-> 自动旋转、缩放、去 EXIF、统一 JPEG
|
||||
-> 通用视觉模型分类并输出结构化 JSON
|
||||
-> 若为病例/检查单,再调用 OCR 模型精确转录
|
||||
-> 保存结构化结果,不持久化原图
|
||||
-> 返回 attachmentId
|
||||
-> 用户发送文字 + attachmentIds
|
||||
-> 标准答题对匹配
|
||||
-> 用文字 + 图片提取结果检索独立知识库
|
||||
-> 把标准答案、知识片段、图片资料注入系统上下文
|
||||
-> Qwen SSE 流式回答
|
||||
```
|
||||
|
||||
## 4. 模型编排
|
||||
|
||||
### 4.1 通用视觉模型
|
||||
|
||||
默认 `qwen3.6-flash`,可在后台数字分身专用模型配置中修改。输入为归一化后的 Base64 Data URL,要求返回 JSON:
|
||||
|
||||
```json
|
||||
{
|
||||
"category": "general_image|document|medical_document|medical_image",
|
||||
"summary": "客观、完整的图片描述",
|
||||
"visible_text": "图片中可确认的文字",
|
||||
"key_facts": ["事实1", "事实2"],
|
||||
"uncertainties": ["无法确认的内容"],
|
||||
"medical": {
|
||||
"document_type": "",
|
||||
"patient_info": {},
|
||||
"chief_complaint": "",
|
||||
"findings": [],
|
||||
"measurements": [],
|
||||
"doctor_advice": ""
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
模型提示词禁止诊断、补全被遮挡文字、猜测患者身份和输出模型信息。
|
||||
|
||||
### 4.2 病例 OCR
|
||||
|
||||
当 `category=medical_document` 时追加调用 `qwen-vl-ocr`,按原布局转录文字和表格。OCR 文本优先替换通用视觉输出中的 `visible_text`,但保留通用视觉模型提供的分类、摘要和不确定项。
|
||||
|
||||
### 4.3 医学影像
|
||||
|
||||
当 `category=medical_image` 时只保存客观描述,不输出疾病结论、分期、用药或治疗方案。聊天提示词必须要求结合正规影像报告和医生意见,并显示“图片识别结果仅供辅助,不能替代医生诊断”。
|
||||
|
||||
## 5. 数据模型
|
||||
|
||||
新增 `chat_attachments`:
|
||||
|
||||
| 字段 | 说明 |
|
||||
|---|---|
|
||||
| `id` | 不可猜测的附件 ID |
|
||||
| `avatar_id` | 所属数字分身,强制隔离 |
|
||||
| `filename` | 原文件名,去除路径 |
|
||||
| `mime_type` / `file_size` | 上传元数据 |
|
||||
| `status` | `processing / ready / failed` |
|
||||
| `category` | 图片分类 |
|
||||
| `summary` | 通用视觉摘要 |
|
||||
| `extracted_text` | 可确认文字/OCR 结果 |
|
||||
| `structured_data` | 结构化 JSON |
|
||||
| `warning` | 不确定项和医学提示 |
|
||||
| `vision_model` / `ocr_model` | 实际调用模型 |
|
||||
| `created_at` / `used_at` | 创建和最近使用时间 |
|
||||
|
||||
不保存公开原图 URL。应用层不落盘原图;框架上传缓冲在请求结束时关闭,处理结果在 24 小时后自动清理。
|
||||
|
||||
## 6. API 设计
|
||||
|
||||
### 6.1 上传并解析
|
||||
|
||||
- `POST /api/avatar/{avatar_id}/chat/images`
|
||||
- `POST /api/public/avatar/{share_token}/chat/images`
|
||||
- `multipart/form-data: file`
|
||||
|
||||
成功返回:
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "attachment-id",
|
||||
"filename": "病例.jpg",
|
||||
"status": "ready",
|
||||
"category": "medical_document",
|
||||
"summary": "门诊检查单",
|
||||
"warning": "部分手写内容需要人工核对"
|
||||
}
|
||||
```
|
||||
|
||||
### 6.2 聊天
|
||||
|
||||
原聊天接口增加:
|
||||
|
||||
```json
|
||||
{
|
||||
"message": "请帮我看看异常指标",
|
||||
"attachmentIds": ["attachment-id"],
|
||||
"history": [
|
||||
{"role": "user", "content": "上一条问题", "attachmentIds": ["attachment-id"]}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
当前消息最多 3 张图,历史最多引用最近 3 个不同附件。后端只读取与当前 `avatar_id` 相同且状态为 `ready` 的记录。
|
||||
|
||||
## 7. 安全与隐私
|
||||
|
||||
- 单图最大 8MB,解码后最大 1600 万像素,最长边归一化到 4096 像素以内。
|
||||
- 使用 Pillow 验证真实图片格式并防止解压炸弹;重新编码时清除 EXIF、GPS 和其他元数据。
|
||||
- 图片不会写入 FastAPI `StaticFiles` 或知识库目录,模型请求和日志不得输出 Base64 内容。
|
||||
- 日志只记录附件 ID、分身 ID、状态、耗时和模型,不记录图片 Base64、OCR 全文、病例内容或 API Key。
|
||||
- 公开分享上传仍消耗分身所有者积分;余额不足时拒绝视觉调用。
|
||||
- 生产环境需要补充用户授权、数据处理协议、存储地域和模型供应商留存策略确认。
|
||||
|
||||
## 8. 前端交互
|
||||
|
||||
- 输入框左侧增加图片按钮,支持相册选择和移动端拍照。
|
||||
- 选择后显示本地缩略图和“正在识别图片”,识别完成前禁止发送。
|
||||
- 用户可删除待发送图片;发送后图片保留在当前会话气泡中,但刷新页面后不恢复原图。
|
||||
- 病例和医学影像在输入区及回答下方显示辅助提示,不使用恐吓式红色告警。
|
||||
- 上传或识别失败时保留文字输入,明确提示重新选择图片,不产生空白消息。
|
||||
|
||||
## 9. 配置
|
||||
|
||||
数字分身专用模型配置新增:
|
||||
|
||||
- `vision_model_version`,默认 `qwen3.6-flash`
|
||||
- `ocr_model_version`,默认 `qwen-vl-ocr`
|
||||
|
||||
环境变量兜底:
|
||||
|
||||
```dotenv
|
||||
VISION_MODEL=qwen3.6-flash
|
||||
VISION_OCR_MODEL=qwen-vl-ocr
|
||||
VISION_MAX_OUTPUT_TOKENS=2048
|
||||
VISION_TIMEOUT_SECONDS=90
|
||||
VISION_TOKEN_RESERVE=12000
|
||||
CHAT_IMAGE_MAX_BYTES=8388608
|
||||
CHAT_IMAGE_MAX_PIXELS=16000000
|
||||
CHAT_ATTACHMENT_RETENTION_HOURS=24
|
||||
CHAT_ATTACHMENT_CLEANUP_MINUTES=60
|
||||
```
|
||||
|
||||
视觉调用复用数字分身专用配置的 `api_base_url` 和 `api_key`,不额外复制密钥。
|
||||
|
||||
## 10. 验收标准
|
||||
|
||||
1. 普通照片、截图和图表能够返回与图片一致的描述并支持追问。
|
||||
2. 病例图片可以提取标题、患者字段、检查结果、异常指标和医生意见,模糊内容明确标记待核对。
|
||||
3. 上传后服务器业务目录不残留原图,响应和日志不包含 Base64 或完整病例正文。
|
||||
4. A 分身无法引用 B 分身附件;公开分享令牌无法访问其他分身附件。
|
||||
5. 有图片时标准答题对仍作为最高优先级事实,知识库命中次之。
|
||||
6. 视觉与 OCR 积分分别结算,失败调用释放预留积分。
|
||||
7. SSE 打字效果、Markdown、用户头像、公开分享和纯文本聊天均无回归。
|
||||
8. CT、MRI、X 光回答不作确定诊断,并显示人工复核提示。
|
||||
@@ -7,10 +7,9 @@
|
||||
content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover"
|
||||
/>
|
||||
<title>会会数字分身</title>
|
||||
<!-- uniapp web-view 桥接:加载后全局出现 window.uni.webView,H5 才能与原生壳通信 -->
|
||||
<script type="text/javascript" src="https://unpkg.com/@dcloudio/uni-webview-js@0.0.10/index.js"></script>
|
||||
<!-- 混合架构部署配置:web-view 内请把 apiBase 设为后端公网地址(如 'https://geo.99hui.com/api')。
|
||||
留空则回退为 '/api'(开发态由 Vite 代理到 :8000)。 -->
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<!-- digital.99hui.com 同域部署保持 apiBase 为空,接口使用 /api。
|
||||
仅跨域部署时才显式配置后端公网地址。 -->
|
||||
<script type="text/javascript">
|
||||
window.__APP_CONFIG__ = { apiBase: '' }
|
||||
</script>
|
||||
|
||||
@@ -23,6 +23,10 @@ http {
|
||||
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
# Keep the application gateway aligned with the production edge gateway.
|
||||
# Without this Nginx rejects ordinary PDF uploads with HTTP 413 before
|
||||
# FastAPI can return its user-facing file-size validation message.
|
||||
client_max_body_size 100m;
|
||||
|
||||
# SPA 兜底(hash 路由下深链接也可正常加载)
|
||||
location / {
|
||||
|
||||
Generated
+7
@@ -8,6 +8,7 @@
|
||||
"name": "digital-avatar-app",
|
||||
"version": "1.0.0",
|
||||
"dependencies": {
|
||||
"@dcloudio/uni-webview-js": "^0.0.3",
|
||||
"axios": "^1.6.0",
|
||||
"pinia": "^2.1.0",
|
||||
"vue": "^3.3.0",
|
||||
@@ -66,6 +67,12 @@
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@dcloudio/uni-webview-js": {
|
||||
"version": "0.0.3",
|
||||
"resolved": "https://registry.npmmirror.com/@dcloudio/uni-webview-js/-/uni-webview-js-0.0.3.tgz",
|
||||
"integrity": "sha512-3XDnLXg22QKC/aTLtGim6zbOK+euiDYoRf98BjGDE0lqMm1U4SLFHAvOlAHa0qck91phaceKwo73ScQQgSojQg==",
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/@esbuild/aix-ppc64": {
|
||||
"version": "0.21.5",
|
||||
"resolved": "https://registry.npmmirror.com/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz",
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@dcloudio/uni-webview-js": "^0.0.3",
|
||||
"axios": "^1.6.0",
|
||||
"pinia": "^2.1.0",
|
||||
"vue": "^3.3.0",
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
8f54097b01d5b0433f8aa5ea70ca930b
|
||||
@@ -0,0 +1 @@
|
||||
8f54097b01d5b0433f8aa5ea70ca930b
|
||||
@@ -0,0 +1,11 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
|
||||
<defs>
|
||||
<linearGradient id="avatar-gradient" x1="8" y1="8" x2="56" y2="56" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#fb923c"/>
|
||||
<stop offset="1" stop-color="#f97316"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<rect width="64" height="64" rx="16" fill="url(#avatar-gradient)"/>
|
||||
<circle cx="32" cy="24" r="10" fill="#fff"/>
|
||||
<path d="M15 52c1.8-10 8-15 17-15s15.2 5 17 15" fill="#fff"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 474 B |
@@ -0,0 +1,107 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Fail a deployment unless frontend and backend run the expected release."""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import urllib.request
|
||||
|
||||
|
||||
def fetch_json(url):
|
||||
with urllib.request.urlopen(url, timeout=20) as response:
|
||||
if response.status != 200:
|
||||
raise RuntimeError(f"{url} returned HTTP {response.status}")
|
||||
return json.load(response)
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("base_url", help="Public site URL, for example https://digital.99hui.com")
|
||||
parser.add_argument("expected_sha", help="Full Git commit SHA being deployed")
|
||||
parser.add_argument("--backend-container", help="Backend container name for image and mount checks")
|
||||
parser.add_argument("--frontend-container", help="Frontend container name for image checks")
|
||||
parser.add_argument("--expected-db-source", help="Required host directory mounted for SQLite and its WAL files")
|
||||
parser.add_argument("--expected-upload-source", help="Required host source mounted as the upload directory")
|
||||
args = parser.parse_args()
|
||||
|
||||
base_url = args.base_url.rstrip("/")
|
||||
errors = []
|
||||
try:
|
||||
health = fetch_json(f"{base_url}/api/health").get("data") or {}
|
||||
except Exception as exc:
|
||||
errors.append(f"cannot read backend release metadata: {exc}")
|
||||
health = {}
|
||||
try:
|
||||
frontend = fetch_json(f"{base_url}/version.json")
|
||||
except Exception as exc:
|
||||
errors.append(f"cannot read frontend release metadata: {exc}")
|
||||
frontend = {}
|
||||
|
||||
if health.get("status") != "ok":
|
||||
errors.append(f"backend status is {health.get('status')!r}")
|
||||
failed_checks = [name for name, passed in (health.get("checks") or {}).items() if not passed]
|
||||
if failed_checks:
|
||||
errors.append("backend checks failed: " + ", ".join(failed_checks))
|
||||
if health.get("gitSha") != args.expected_sha:
|
||||
errors.append(f"backend SHA is {health.get('gitSha')!r}")
|
||||
if frontend.get("gitSha") != args.expected_sha:
|
||||
errors.append(f"frontend SHA is {frontend.get('gitSha')!r}")
|
||||
|
||||
if args.backend_container:
|
||||
backend = inspect_container(args.backend_container, errors)
|
||||
check_container_revision(backend, args.expected_sha, "backend", errors)
|
||||
check_mount(backend, args.expected_db_source, "database", errors)
|
||||
check_mount(backend, args.expected_upload_source, "uploads", errors)
|
||||
elif args.expected_db_source or args.expected_upload_source:
|
||||
errors.append("--backend-container is required when checking data mounts")
|
||||
|
||||
if args.frontend_container:
|
||||
frontend_container = inspect_container(args.frontend_container, errors)
|
||||
check_container_revision(frontend_container, args.expected_sha, "frontend", errors)
|
||||
|
||||
if errors:
|
||||
print("Deployment verification failed:", file=sys.stderr)
|
||||
for error in errors:
|
||||
print(f"- {error}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
print(f"Deployment verified: {args.expected_sha}")
|
||||
print("Backend checks: database, uploads, pdfOcr")
|
||||
return 0
|
||||
|
||||
|
||||
def inspect_container(name, errors):
|
||||
try:
|
||||
output = subprocess.check_output(
|
||||
["docker", "inspect", name], universal_newlines=True
|
||||
)
|
||||
return json.loads(output)[0]
|
||||
except Exception as exc:
|
||||
errors.append(f"cannot inspect container {name!r}: {exc}")
|
||||
return {}
|
||||
|
||||
|
||||
def check_container_revision(container, expected_sha, label, errors):
|
||||
actual = ((container.get("Config") or {}).get("Labels") or {}).get(
|
||||
"org.opencontainers.image.revision"
|
||||
)
|
||||
if actual != expected_sha:
|
||||
errors.append(f"{label} container image SHA is {actual!r}")
|
||||
|
||||
|
||||
def check_mount(container, expected_source, label, errors):
|
||||
if not expected_source:
|
||||
return
|
||||
expected = os.path.realpath(expected_source)
|
||||
sources = {
|
||||
os.path.realpath(mount.get("Source", ""))
|
||||
for mount in container.get("Mounts") or []
|
||||
}
|
||||
if expected not in sources:
|
||||
errors.append(f"{label} mount source {expected!r} is not attached")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<div id="app">
|
||||
<div id="app" :class="{ 'embedded-shell': isInUniAppWebView }">
|
||||
<router-view />
|
||||
<!-- 底部导航栏 -->
|
||||
<nav class="bottom-nav" v-if="showNav">
|
||||
@@ -20,12 +20,13 @@
|
||||
<span class="nav-label">授权管理</span>
|
||||
</button>
|
||||
<button
|
||||
v-if="isInUniAppWebView"
|
||||
class="nav-item"
|
||||
:class="{ active: currentRoute === '/token/charge' }"
|
||||
@click="navigateTo('/token/charge')"
|
||||
>
|
||||
<span class="nav-icon">💰</span>
|
||||
<span class="nav-label">Token</span>
|
||||
<span class="nav-label">充值购买</span>
|
||||
</button>
|
||||
</nav>
|
||||
</div>
|
||||
@@ -34,16 +35,17 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, watch } from 'vue'
|
||||
import { useRouter, useRoute } from 'vue-router'
|
||||
import { isInUniWebView } from '@/utils/uniapp-bridge'
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
const isInUniAppWebView = isInUniWebView()
|
||||
|
||||
const currentRoute = ref<string>(route.path)
|
||||
const showNav = ref<boolean>(shouldShowNav(route.path))
|
||||
|
||||
function shouldShowNav(path: string) {
|
||||
return path !== '/'
|
||||
&& path !== '/authorization'
|
||||
&& path !== '/avatar/create'
|
||||
&& path !== '/login/sms'
|
||||
&& !path.startsWith('/avatar/edit')
|
||||
@@ -82,6 +84,12 @@ onMounted(() => {
|
||||
padding-bottom: env(safe-area-inset-bottom);
|
||||
}
|
||||
|
||||
/* 原生 App / 微信小程序容器已经提供了自己的导航栏,H5 不再重复显示页头。 */
|
||||
.embedded-shell .page-header,
|
||||
.embedded-shell .chat-header {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* 底部导航栏 */
|
||||
.bottom-nav {
|
||||
position: fixed;
|
||||
|
||||
@@ -9,8 +9,8 @@ interface ApiClient {
|
||||
|
||||
// API 基址:优先级 window.__APP_CONFIG__.apiBase > 环境变量 > 默认 '/api'
|
||||
// - 开发/Vite 代理:'/api'(由 vite.config 代理到后端 :8000)
|
||||
// - web-view 内(混合架构):需配置为后端公网地址,例如 'https://geo.99hui.com/api'
|
||||
// - 同域部署的构建产物:可保持 '/api'
|
||||
// - digital.99hui.com 同域部署:保持 '/api'
|
||||
// - 仅跨域部署时配置后端公网地址
|
||||
function resolveBaseURL(): string {
|
||||
const cfg = (window as any).__APP_CONFIG__
|
||||
if (cfg && cfg.apiBase) return cfg.apiBase as string
|
||||
@@ -131,17 +131,90 @@ export const deleteAvatar = (id: string) =>
|
||||
|
||||
// ==================== Token 管理 API ====================
|
||||
|
||||
export interface TokenBalance {
|
||||
balance: number
|
||||
totalGranted: number
|
||||
totalConsumed: number
|
||||
}
|
||||
|
||||
export interface TokenUsageSummary {
|
||||
avatarId: string
|
||||
source: string
|
||||
promptTokens: number
|
||||
completionTokens: number
|
||||
totalTokens: number
|
||||
requestCount: number
|
||||
}
|
||||
|
||||
export interface TokenPaymentOrder {
|
||||
id: string
|
||||
orderNo: string
|
||||
planId: string
|
||||
paymentMethod: 'wechat' | 'alipay'
|
||||
payType: 'WECHAT' | 'ALIPAY'
|
||||
payWay: 'APP' | 'H5' | 'LITE' | 'JSAPI'
|
||||
pointsAmount: number
|
||||
price: number
|
||||
status: 'pending' | 'paid' | 'failed' | 'closed' | 'refunded'
|
||||
provider: 'huihui' | 'wechat_virtual'
|
||||
providerStatus: string
|
||||
payMessage: string
|
||||
failureReason: string
|
||||
refundStatus: 'none' | 'pending' | 'processing' | 'succeeded' | 'failed'
|
||||
createdAt: string | null
|
||||
paidAt: string | null
|
||||
refundedAt: string | null
|
||||
balance: number
|
||||
}
|
||||
|
||||
export interface TokenInvoice {
|
||||
id: string
|
||||
orderNo: string
|
||||
title: string
|
||||
invoiceType: 'personal' | 'company'
|
||||
taxNumber: string
|
||||
email: string
|
||||
amount: number
|
||||
status: 'pending' | 'issued' | 'rejected' | 'cancelled'
|
||||
invoiceNo: string
|
||||
invoiceUrl: string
|
||||
remark: string
|
||||
}
|
||||
|
||||
// 获取 Token 余额
|
||||
export const getTokenBalance = () =>
|
||||
request.get<{ balance: number }>('/token/balance')
|
||||
request.get<TokenBalance>('/token/balance')
|
||||
|
||||
// 获取充值套餐
|
||||
export const getRechargePlans = () =>
|
||||
request.get<Array<{ id: string; name: string; amount: number; price: number }>>('/token/plans')
|
||||
|
||||
// 执行充值
|
||||
export const chargeToken = (planId: string) =>
|
||||
request.post<{ balance: number; charged: number }>('/token/charge', { planId })
|
||||
// 创建会会支付订单;支付回调成功后积分到账。
|
||||
export const chargeToken = (
|
||||
planId: string,
|
||||
paymentMethod: 'wechat' | 'alipay',
|
||||
payScene: 'APP' | 'H5' | 'LITE' | 'JSAPI'
|
||||
) => request.post<TokenPaymentOrder>('/token/charge', { planId, paymentMethod, payScene })
|
||||
|
||||
export const getTokenPaymentStatus = (orderId: string) =>
|
||||
request.get<TokenPaymentOrder>(`/token/payment/${orderId}`)
|
||||
|
||||
export const bindWechatVirtualSession = (code: string) =>
|
||||
request.post<{ ready: boolean }>('/token/wechat/session', { code })
|
||||
|
||||
export const getTokenOrders = (page = 1, pageSize = 20) =>
|
||||
request.get<{ total: number; page: number; pageSize: number; items: Array<TokenPaymentOrder & { invoice?: TokenInvoice }> }>(
|
||||
'/token/orders', { params: { page, page_size: pageSize } }
|
||||
)
|
||||
|
||||
export const applyTokenInvoice = (
|
||||
orderNo: string,
|
||||
payload: { title: string; invoiceType: 'personal' | 'company'; taxNumber?: string; email?: string }
|
||||
) => request.post<TokenInvoice>(`/token/orders/${orderNo}/invoice`, payload)
|
||||
|
||||
// 按分身和使用场景汇总 Token 消耗
|
||||
export const getTokenUsage = () =>
|
||||
request.get<TokenUsageSummary[]>('/token/usage')
|
||||
|
||||
// ==================== 授权管理 API ====================
|
||||
|
||||
@@ -150,19 +223,28 @@ export type AvatarPermission = 'friend' | 'chat' | 'publish' | 'browse' | 'inter
|
||||
export interface AvatarPermissionSettings {
|
||||
avatarId: string
|
||||
permissions: AvatarPermission[]
|
||||
takeoverReplyDelaySeconds: number
|
||||
disabledAvatarIds?: string[]
|
||||
}
|
||||
|
||||
export const getAvatarPermissionSettings = (avatarId: string) =>
|
||||
request.get<AvatarPermissionSettings>(`/avatar/${avatarId}/permission-settings`)
|
||||
|
||||
export const updateAvatarPermissionSettings = (avatarId: string, permissions: AvatarPermission[]) =>
|
||||
request.put<AvatarPermissionSettings>(`/avatar/${avatarId}/permission-settings`, { permissions })
|
||||
export const updateAvatarPermissionSettings = (
|
||||
avatarId: string,
|
||||
permissions: AvatarPermission[],
|
||||
takeoverReplyDelaySeconds: number
|
||||
) => request.put<AvatarPermissionSettings>(`/avatar/${avatarId}/permission-settings`, {
|
||||
permissions,
|
||||
takeoverReplyDelaySeconds,
|
||||
})
|
||||
|
||||
export interface TakeoverStatus {
|
||||
enabled: boolean
|
||||
status: 'disabled' | 'connecting' | 'ready' | 'needs_login' | 'error'
|
||||
message: string
|
||||
pendingCount: number
|
||||
takeoverReplyDelaySeconds: number
|
||||
lastPolledAt: string | null
|
||||
}
|
||||
|
||||
@@ -255,6 +337,9 @@ export interface KnowledgeDoc {
|
||||
vectorized?: boolean
|
||||
embeddingModel?: string
|
||||
chunkCount?: number
|
||||
errorMessage?: string
|
||||
indexStage?: string
|
||||
indexProgress?: number
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
@@ -280,12 +365,83 @@ export interface SearchResult {
|
||||
export const getKnowledgeDocs = (avatarId: string) =>
|
||||
request.get<KnowledgeDoc[]>(`/avatar/${avatarId}/knowledge/docs`)
|
||||
|
||||
// 上传文档(支持 md/txt/pdf/doc/docx/xlsx)
|
||||
export const uploadKnowledgeDoc = (avatarId: string, file: File) => {
|
||||
const KNOWLEDGE_UPLOAD_CHUNK_SIZE = 5 * 1024 * 1024
|
||||
|
||||
const uploadKnowledgeChunk = async (
|
||||
avatarId: string,
|
||||
uploadId: string,
|
||||
chunkIndex: number,
|
||||
chunk: Blob,
|
||||
onProgress?: (loaded: number) => void
|
||||
) => {
|
||||
const form = new FormData()
|
||||
form.append('file', chunk, `chunk-${chunkIndex}`)
|
||||
let reportedLoaded = 0
|
||||
for (let attempt = 1; attempt <= 3; attempt += 1) {
|
||||
try {
|
||||
await request.post(
|
||||
`/avatar/${avatarId}/knowledge/uploads/${uploadId}/chunks/${chunkIndex}`,
|
||||
form,
|
||||
{
|
||||
headers: { 'Content-Type': 'multipart/form-data' },
|
||||
timeout: 2 * 60 * 1000,
|
||||
onUploadProgress: (event) => {
|
||||
reportedLoaded = Math.max(reportedLoaded, Math.min(event.loaded, chunk.size))
|
||||
onProgress?.(reportedLoaded)
|
||||
}
|
||||
}
|
||||
)
|
||||
return
|
||||
} catch (error: any) {
|
||||
const status = Number(error?.response?.status || 0)
|
||||
const retryable = !status || status === 408 || status === 429 || status >= 500
|
||||
if (!retryable || attempt === 3) throw error
|
||||
await new Promise((resolve) => window.setTimeout(resolve, attempt * 800))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 大文件拆成 5MB 分片,避免生产代理的请求体限制拦截整个文件。
|
||||
export const uploadKnowledgeDoc = async (
|
||||
avatarId: string,
|
||||
file: File,
|
||||
onUploadProgress?: (loaded: number, total: number) => void
|
||||
) => {
|
||||
if (file.size > KNOWLEDGE_UPLOAD_CHUNK_SIZE) {
|
||||
const totalChunks = Math.ceil(file.size / KNOWLEDGE_UPLOAD_CHUNK_SIZE)
|
||||
const upload: any = await request.post(`/avatar/${avatarId}/knowledge/uploads`, {
|
||||
filename: file.name,
|
||||
fileSize: file.size,
|
||||
totalChunks
|
||||
})
|
||||
let uploadedBytes = 0
|
||||
for (let index = 0; index < totalChunks; index += 1) {
|
||||
const start = index * KNOWLEDGE_UPLOAD_CHUNK_SIZE
|
||||
const chunk = file.slice(start, Math.min(start + KNOWLEDGE_UPLOAD_CHUNK_SIZE, file.size))
|
||||
await uploadKnowledgeChunk(
|
||||
avatarId,
|
||||
upload.uploadId,
|
||||
index,
|
||||
chunk,
|
||||
(chunkLoaded) => onUploadProgress?.(uploadedBytes + chunkLoaded, file.size)
|
||||
)
|
||||
uploadedBytes += chunk.size
|
||||
onUploadProgress?.(uploadedBytes, file.size)
|
||||
}
|
||||
return request.post<KnowledgeDoc>(
|
||||
`/avatar/${avatarId}/knowledge/uploads/${upload.uploadId}/complete`,
|
||||
undefined,
|
||||
{ timeout: 2 * 60 * 1000 }
|
||||
)
|
||||
}
|
||||
|
||||
const form = new FormData()
|
||||
form.append('file', file)
|
||||
return request.post<KnowledgeDoc>(`/avatar/${avatarId}/knowledge/docs`, form, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' }
|
||||
headers: { 'Content-Type': 'multipart/form-data' },
|
||||
// A slow mobile uplink must not be mistaken for a failed upload.
|
||||
timeout: 10 * 60 * 1000,
|
||||
onUploadProgress: (event) => onUploadProgress?.(event.loaded, event.total || file.size)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -293,6 +449,9 @@ export const uploadKnowledgeDoc = (avatarId: string, file: File) => {
|
||||
export const deleteKnowledgeDoc = (avatarId: string, docId: string) =>
|
||||
request.delete(`/avatar/${avatarId}/knowledge/docs/${docId}`)
|
||||
|
||||
export const retryKnowledgeDoc = (avatarId: string, docId: string) =>
|
||||
request.post<KnowledgeDoc>(`/avatar/${avatarId}/knowledge/docs/${docId}/retry`)
|
||||
|
||||
// 标准问答对列表
|
||||
export const getQAPairs = (avatarId: string) =>
|
||||
request.get<QAPair[]>(`/avatar/${avatarId}/knowledge/qa`)
|
||||
@@ -324,15 +483,35 @@ export const searchKnowledge = (avatarId: string, q: string, topK = 5) =>
|
||||
export interface ChatMessage {
|
||||
role: 'user' | 'assistant'
|
||||
content: string
|
||||
attachmentIds?: string[]
|
||||
}
|
||||
|
||||
export interface ChatResponse {
|
||||
answer: string
|
||||
source: 'qa' | 'knowledge' | 'qwen'
|
||||
source: 'qa' | 'knowledge' | 'vision' | 'qwen'
|
||||
references?: Array<{ docId?: string; filename?: string; fileType?: string; snippet?: string; score?: number }>
|
||||
}
|
||||
|
||||
export const sendAvatarChat = (avatarId: string, payload: { message: string; history?: ChatMessage[] }) =>
|
||||
export interface ChatAttachment {
|
||||
id: string
|
||||
avatarId: string
|
||||
filename: string
|
||||
mimeType: string
|
||||
fileSize: number
|
||||
status: 'processing' | 'ready' | 'failed'
|
||||
category: 'general_image' | 'document' | 'medical_document' | 'medical_image'
|
||||
summary: string
|
||||
warning: string
|
||||
expiresAt: string
|
||||
}
|
||||
|
||||
export interface ChatPayload {
|
||||
message: string
|
||||
attachmentIds?: string[]
|
||||
history?: ChatMessage[]
|
||||
}
|
||||
|
||||
export const sendAvatarChat = (avatarId: string, payload: ChatPayload) =>
|
||||
request.post<ChatResponse>(`/avatar/${avatarId}/chat`, payload)
|
||||
|
||||
export interface PublicAvatar {
|
||||
@@ -351,19 +530,41 @@ export const createAvatarShareLink = (avatarId: string) =>
|
||||
export const getPublicAvatar = (shareToken: string) =>
|
||||
request.get<PublicAvatar>(`/public/avatar/${shareToken}`)
|
||||
|
||||
export const sendPublicAvatarChat = (shareToken: string, payload: { message: string; history?: ChatMessage[] }) =>
|
||||
export const sendPublicAvatarChat = (shareToken: string, payload: ChatPayload) =>
|
||||
request.post<ChatResponse>(`/public/avatar/${shareToken}/chat`, payload)
|
||||
|
||||
const imageForm = (file: File) => {
|
||||
const form = new FormData()
|
||||
form.append('file', file)
|
||||
return form
|
||||
}
|
||||
|
||||
export const uploadAvatarChatImage = (avatarId: string, file: File) =>
|
||||
request.post<ChatAttachment>(`/avatar/${avatarId}/chat/images`, imageForm(file), {
|
||||
headers: { 'Content-Type': 'multipart/form-data' },
|
||||
timeout: 120000
|
||||
})
|
||||
|
||||
export const uploadPublicAvatarChatImage = (shareToken: string, file: File) =>
|
||||
request.post<ChatAttachment>(`/public/avatar/${shareToken}/chat/images`, imageForm(file), {
|
||||
headers: { 'Content-Type': 'multipart/form-data' },
|
||||
timeout: 120000
|
||||
})
|
||||
|
||||
type ChatStreamHandlers = {
|
||||
onMeta: (meta: Pick<ChatResponse, 'source' | 'references'>) => void
|
||||
onDelta: (content: string) => void
|
||||
}
|
||||
|
||||
const streamChat = async (path: string, payload: { message: string; history?: ChatMessage[] }, handlers: ChatStreamHandlers) => {
|
||||
const streamChat = async (path: string, payload: ChatPayload, handlers: ChatStreamHandlers) => {
|
||||
const headers: Record<string, string> = { 'Content-Type': 'application/json', Accept: 'text/event-stream' }
|
||||
if (_authToken) headers.Authorization = `Bearer ${_authToken}`
|
||||
const response = await fetch(`${resolveBaseURL()}${path}`, { method: 'POST', headers, body: JSON.stringify(payload) })
|
||||
if (!response.ok || !response.body) throw new Error(`对话请求失败(${response.status})`)
|
||||
if (!response.ok) {
|
||||
const errorBody = await response.json().catch(() => null)
|
||||
throw new Error(errorBody?.detail || errorBody?.message || `对话请求失败(${response.status})`)
|
||||
}
|
||||
if (!response.body) throw new Error('对话响应为空,请稍后重试')
|
||||
|
||||
const reader = response.body.getReader()
|
||||
const decoder = new TextDecoder()
|
||||
@@ -386,10 +587,10 @@ const streamChat = async (path: string, payload: { message: string; history?: Ch
|
||||
}
|
||||
}
|
||||
|
||||
export const streamAvatarChat = (avatarId: string, payload: { message: string; history?: ChatMessage[] }, handlers: ChatStreamHandlers) =>
|
||||
export const streamAvatarChat = (avatarId: string, payload: ChatPayload, handlers: ChatStreamHandlers) =>
|
||||
streamChat(`/avatar/${avatarId}/chat/stream`, payload, handlers)
|
||||
|
||||
export const streamPublicAvatarChat = (shareToken: string, payload: { message: string; history?: ChatMessage[] }, handlers: ChatStreamHandlers) =>
|
||||
export const streamPublicAvatarChat = (shareToken: string, payload: ChatPayload, handlers: ChatStreamHandlers) =>
|
||||
streamChat(`/public/avatar/${shareToken}/chat/stream`, payload, handlers)
|
||||
|
||||
// ==================== 会会用户资料 API ====================
|
||||
@@ -409,7 +610,7 @@ export const getUserProfile = () =>
|
||||
export interface SmsLoginResult {
|
||||
token: string
|
||||
user: UserProfile & { huihuiUserId: string; phone: string; createdAt?: string; lastLoginAt?: string }
|
||||
huihui: { userId: string; nickname: string; avatarUrl: string; token: string }
|
||||
huihui: { userId: string; nickname: string; avatarUrl: string }
|
||||
}
|
||||
|
||||
// 发送短信验证码(演示模式会额外返回 devCode / dev 标记)
|
||||
@@ -424,6 +625,10 @@ export const loginBySms = (phone: string, code: string) =>
|
||||
export const loginByPassword = (account: string, password: string) =>
|
||||
request.post<SmsLoginResult>('/huihui/pwd/login', { account, password })
|
||||
|
||||
// Validate a production Huihui access token and exchange it for an app session.
|
||||
export const loginByHuihuiToken = (token: string) =>
|
||||
request.post<SmsLoginResult>('/huihui/token/login', { token })
|
||||
|
||||
// 当前登录用户
|
||||
export const getCurrentUser = () =>
|
||||
request.get<UserProfile & { huihuiUserId: string; phone: string }>('/huihui/me')
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
<svg fill="#1677FF" role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||
<title>支付宝</title>
|
||||
<path d="M19.695 15.07c3.426 1.158 4.203 1.22 4.203 1.22V3.846c0-2.124-1.705-3.845-3.81-3.845H3.914C1.808.001.102 1.722.102 3.846v16.31c0 2.123 1.706 3.845 3.813 3.845h16.173c2.105 0 3.81-1.722 3.81-3.845v-.157s-6.19-2.602-9.315-4.119c-2.096 2.602-4.8 4.181-7.607 4.181-4.75 0-6.361-4.19-4.112-6.949.49-.602 1.324-1.175 2.617-1.497 2.025-.502 5.247.313 8.266 1.317a16.796 16.796 0 0 0 1.341-3.302H5.781v-.952h4.799V6.975H4.77v-.953h5.81V3.591s0-.409.411-.409h2.347v2.84h5.744v.951h-5.744v1.704h4.69a19.453 19.453 0 0 1-1.986 5.06c1.424.52 2.702 1.011 3.654 1.333m-13.81-2.032c-.596.06-1.71.325-2.321.869-1.83 1.608-.735 4.55 2.968 4.55 2.151 0 4.301-1.388 5.99-3.61-2.403-1.182-4.438-2.028-6.637-1.809"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 828 B |
@@ -0,0 +1,4 @@
|
||||
<svg fill="#07C160" role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||
<title>微信支付</title>
|
||||
<path d="M8.691 2.188C3.891 2.188 0 5.476 0 9.53c0 2.212 1.17 4.203 3.002 5.55a.59.59 0 0 1 .213.665l-.39 1.48c-.019.07-.048.141-.048.213 0 .163.13.295.29.295a.326.326 0 0 0 .167-.054l1.903-1.114a.864.864 0 0 1 .717-.098 10.16 10.16 0 0 0 2.837.403c.276 0 .543-.027.811-.05-.857-2.578.157-4.972 1.932-6.446 1.703-1.415 3.882-1.98 5.853-1.838-.576-3.583-4.196-6.348-8.596-6.348zM5.785 5.991c.642 0 1.162.529 1.162 1.18a1.17 1.17 0 0 1-1.162 1.178A1.17 1.17 0 0 1 4.623 7.17c0-.651.52-1.18 1.162-1.18zm5.813 0c.642 0 1.162.529 1.162 1.18a1.17 1.17 0 0 1-1.162 1.178 1.17 1.17 0 0 1-1.162-1.178c0-.651.52-1.18 1.162-1.18zm5.34 2.867c-1.797-.052-3.746.512-5.28 1.786-1.72 1.428-2.687 3.72-1.78 6.22.942 2.453 3.666 4.229 6.884 4.229.826 0 1.622-.12 2.361-.336a.722.722 0 0 1 .598.082l1.584.926a.272.272 0 0 0 .14.047c.134 0 .24-.111.24-.247 0-.06-.023-.12-.038-.177l-.327-1.233a.582.582 0 0 1-.023-.156.49.49 0 0 1 .201-.398C23.024 18.48 24 16.82 24 14.98c0-3.21-2.931-5.837-6.656-6.088V8.89c-.135-.01-.27-.027-.407-.03zm-2.53 3.274c.535 0 .969.44.969.982a.976.976 0 0 1-.969.983.976.976 0 0 1-.969-.983c0-.542.434-.982.97-.982zm4.844 0c.535 0 .969.44.969.982a.976.976 0 0 1-.969.983.976.976 0 0 1-.969-.983c0-.542.434-.982.969-.982z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.3 KiB |
@@ -1,14 +1,16 @@
|
||||
import { createApp } from 'vue'
|
||||
import uniWebView from '@dcloudio/uni-webview-js'
|
||||
import App from './App.vue'
|
||||
import router from './router'
|
||||
import pinia from './store'
|
||||
import { getLaunchParams, onNativeMessage, UniEvents } from '@/utils/uniapp-bridge'
|
||||
import { getLaunchParams, onNativeMessage, stripLaunchToken, UniEvents } from '@/utils/uniapp-bridge'
|
||||
import { useAvatarStore } from '@/store/avatar'
|
||||
import { useUserStore } from '@/store/user'
|
||||
import { setAuthToken } from '@/api'
|
||||
|
||||
// Bundle the bridge locally so the H5 does not depend on an external CDN.
|
||||
;(window as any).uni = (window as any).uni || uniWebView
|
||||
|
||||
const app = createApp(App)
|
||||
app.use(router)
|
||||
app.use(pinia)
|
||||
|
||||
// —— 混合架构:在挂载前注入 uniapp 壳传入的认证与会会资料 ——
|
||||
@@ -16,10 +18,8 @@ const params = getLaunchParams()
|
||||
const avatarStore = useAvatarStore(pinia)
|
||||
const userStore = useUserStore(pinia)
|
||||
|
||||
// 恢复本地短信登录会话(会会 userId ↔ 本系统用户)
|
||||
userStore.loadFromStorage()
|
||||
if (userStore.isLogin && userStore.user) {
|
||||
setAuthToken(userStore.token)
|
||||
function syncAvatarProfile() {
|
||||
if (!userStore.isLogin || !userStore.user) return
|
||||
avatarStore.setNativeProfile({
|
||||
userId: (userStore.user as any).huihuiUserId || '',
|
||||
nickname: userStore.user.nickname || '',
|
||||
@@ -27,29 +27,55 @@ if (userStore.isLogin && userStore.user) {
|
||||
})
|
||||
}
|
||||
|
||||
if (params.token) {
|
||||
setAuthToken(params.token)
|
||||
}
|
||||
if (params.userId || params.nickname || params.avatar) {
|
||||
avatarStore.setNativeProfile({
|
||||
userId: params.userId || '',
|
||||
nickname: params.nickname || '',
|
||||
avatarUrl: params.avatar || ''
|
||||
})
|
||||
async function exchangeInjectedToken(token: string) {
|
||||
stripLaunchToken()
|
||||
userStore.clearSession()
|
||||
await userStore.loginByToken(token)
|
||||
syncAvatarProfile()
|
||||
}
|
||||
|
||||
// 原生 → H5:注册消息处理(壳通过 web-view.evalJS 调用)
|
||||
onNativeMessage((msg) => {
|
||||
if (!msg || !msg.type) return
|
||||
if (msg.type === 'tokenRefresh' && msg.token) {
|
||||
setAuthToken(msg.token)
|
||||
void exchangeInjectedToken(msg.token).catch(() => {
|
||||
sessionStorage.setItem('hh_sso_error', '会会登录凭证无效或已过期,请重新进入')
|
||||
void router.replace('/login/sms')
|
||||
})
|
||||
}
|
||||
if (msg.type === 'userUpdate' && msg.user) {
|
||||
avatarStore.setNativeProfile(msg.user)
|
||||
}
|
||||
})
|
||||
|
||||
app.mount('#app')
|
||||
async function bootstrap() {
|
||||
// A URL token represents the current production user and must override stale storage.
|
||||
if (params.token) {
|
||||
try {
|
||||
await exchangeInjectedToken(params.token)
|
||||
await router.replace(window.location.hash.slice(1) || '/avatar/manage')
|
||||
} catch {
|
||||
sessionStorage.setItem('hh_sso_error', '会会登录凭证无效或已过期,请重新进入')
|
||||
await router.replace('/login/sms')
|
||||
}
|
||||
} else {
|
||||
userStore.loadFromStorage()
|
||||
syncAvatarProfile()
|
||||
}
|
||||
|
||||
// 通知原生壳:H5 已就绪
|
||||
UniEvents.ready()
|
||||
if (params.userId || params.nickname || params.avatar) {
|
||||
avatarStore.setNativeProfile({
|
||||
userId: params.userId || '',
|
||||
nickname: params.nickname || '',
|
||||
avatarUrl: params.avatar || ''
|
||||
})
|
||||
}
|
||||
|
||||
app.use(router)
|
||||
app.mount('#app')
|
||||
|
||||
// 通知原生壳:H5 已就绪
|
||||
UniEvents.ready()
|
||||
}
|
||||
|
||||
void bootstrap()
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { createRouter, createWebHashHistory } from 'vue-router'
|
||||
import type { RouteRecordRaw } from 'vue-router'
|
||||
import { getAuthToken } from '@/api'
|
||||
import { isInUniWebView } from '@/utils/uniapp-bridge'
|
||||
|
||||
const routes: RouteRecordRaw[] = [
|
||||
{
|
||||
@@ -45,11 +46,17 @@ const routes: RouteRecordRaw[] = [
|
||||
component: () => import('@/views/AuthorizationManage.vue'),
|
||||
meta: { title: '授权管理', requiresAuth: true }
|
||||
},
|
||||
{
|
||||
path: '/avatar/:avatarId/authorization',
|
||||
name: 'AvatarAuthorizationManage',
|
||||
component: () => import('@/views/AuthorizationManage.vue'),
|
||||
meta: { title: '授权管理', requiresAuth: true }
|
||||
},
|
||||
{
|
||||
path: '/token/charge',
|
||||
name: 'TokenCharge',
|
||||
component: () => import('@/views/TokenCharge.vue'),
|
||||
meta: { title: 'Token充值', requiresAuth: true }
|
||||
meta: { title: '积分充值', requiresAuth: true, requiresUniWebView: true }
|
||||
},
|
||||
{
|
||||
path: '/avatar/card',
|
||||
@@ -81,6 +88,12 @@ const routes: RouteRecordRaw[] = [
|
||||
component: () => import('@/views/KnowledgeManage.vue'),
|
||||
meta: { title: '知识库管理', requiresAuth: true }
|
||||
},
|
||||
{
|
||||
path: '/avatar/:avatarId/knowledge',
|
||||
name: 'AvatarKnowledgeManage',
|
||||
component: () => import('@/views/KnowledgeManage.vue'),
|
||||
meta: { title: '知识库管理', requiresAuth: true }
|
||||
},
|
||||
{
|
||||
path: '/knowledge/qa/create',
|
||||
name: 'QaPairCreate',
|
||||
@@ -93,6 +106,18 @@ const routes: RouteRecordRaw[] = [
|
||||
component: () => import('@/views/QaPairEdit.vue'),
|
||||
meta: { title: '编辑问答对', requiresAuth: true }
|
||||
},
|
||||
{
|
||||
path: '/avatar/:avatarId/knowledge/qa/create',
|
||||
name: 'AvatarQaPairCreate',
|
||||
component: () => import('@/views/QaPairEdit.vue'),
|
||||
meta: { title: '添加问答对', requiresAuth: true }
|
||||
},
|
||||
{
|
||||
path: '/avatar/:avatarId/knowledge/qa/:qaId/edit',
|
||||
name: 'AvatarQaPairEdit',
|
||||
component: () => import('@/views/QaPairEdit.vue'),
|
||||
meta: { title: '编辑问答对', requiresAuth: true }
|
||||
},
|
||||
{
|
||||
path: '/login/sms',
|
||||
name: 'SmsLogin',
|
||||
@@ -109,6 +134,10 @@ const router = createRouter({
|
||||
|
||||
router.beforeEach((to, from, next) => {
|
||||
document.title = to.meta.title as string || '会会数字分身'
|
||||
if (to.meta.requiresUniWebView && !isInUniWebView()) {
|
||||
next({ path: '/avatar/manage' })
|
||||
return
|
||||
}
|
||||
const hasLocalSession = Boolean(localStorage.getItem('hh_app_token'))
|
||||
const hasInjectedSession = Boolean(getAuthToken())
|
||||
if (to.meta.requiresAuth && !hasLocalSession && !hasInjectedSession) {
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref } from 'vue'
|
||||
import { getAvatarList, createAvatar as apiCreate, deleteAvatar as apiDelete, getTokenBalance, getUserProfile } from '@/api'
|
||||
import { getAvatarList, createAvatar as apiCreate, deleteAvatar as apiDelete, getTokenBalance, getTokenUsage, getUserProfile } from '@/api'
|
||||
import { unwrapListData } from '@/utils/avatar-page-data'
|
||||
|
||||
export const useAvatarStore = defineStore('avatar', () => {
|
||||
// 已创建的分身列表(来自后端)
|
||||
const avatars = ref<any[]>([])
|
||||
// 全局 Token 余额(来自后端)
|
||||
// 当前用户所有分身共享的 Token 账户
|
||||
const tokenBalance = ref<number>(0)
|
||||
const tokenConsumed = ref<number>(0)
|
||||
const tokenUsageByAvatar = ref<Record<string, number>>({})
|
||||
// 当前选中分身 id
|
||||
const currentAvatarId = ref<string | null>(null)
|
||||
// 会会用户资料(头像/昵称,来自会会接口)
|
||||
@@ -29,11 +31,24 @@ export const useAvatarStore = defineStore('avatar', () => {
|
||||
try {
|
||||
const res = await getTokenBalance()
|
||||
tokenBalance.value = (res as any)?.balance ?? 0
|
||||
tokenConsumed.value = (res as any)?.totalConsumed ?? 0
|
||||
} catch (e) {
|
||||
console.error('加载余额失败', e)
|
||||
}
|
||||
}
|
||||
|
||||
const loadTokenUsage = async () => {
|
||||
try {
|
||||
const rows = await getTokenUsage()
|
||||
tokenUsageByAvatar.value = rows.reduce<Record<string, number>>((result, row) => {
|
||||
result[row.avatarId] = (result[row.avatarId] || 0) + row.totalTokens
|
||||
return result
|
||||
}, {})
|
||||
} catch (e) {
|
||||
console.error('加载 Token 用量失败', e)
|
||||
}
|
||||
}
|
||||
|
||||
// 拉取会会用户资料(头像/昵称)
|
||||
const loadUserProfile = async () => {
|
||||
// 若已通过 uniapp 壳注入(混合架构),优先保留,不回退到后端 mock
|
||||
@@ -82,10 +97,13 @@ export const useAvatarStore = defineStore('avatar', () => {
|
||||
return {
|
||||
avatars,
|
||||
tokenBalance,
|
||||
tokenConsumed,
|
||||
tokenUsageByAvatar,
|
||||
currentAvatarId,
|
||||
userProfile,
|
||||
loadAvatars,
|
||||
loadTokenBalance,
|
||||
loadTokenUsage,
|
||||
loadUserProfile,
|
||||
setNativeProfile,
|
||||
addAvatar,
|
||||
|
||||
@@ -1,6 +1,16 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref } from 'vue'
|
||||
import { setAuthToken, sendSmsCode, loginBySms, loginByPassword, logoutUser, type UserProfile } from '@/api'
|
||||
import {
|
||||
setAuthToken,
|
||||
sendSmsCode,
|
||||
loginBySms,
|
||||
loginByPassword,
|
||||
loginByHuihuiToken,
|
||||
logoutUser,
|
||||
type SmsLoginResult,
|
||||
type UserProfile
|
||||
} from '@/api'
|
||||
import { clearHuihuiEmbeddedMode, markHuihuiEmbeddedMode } from '@/utils/embed-mode'
|
||||
|
||||
const TOKEN_KEY = 'hh_app_token'
|
||||
const USER_KEY = 'hh_app_user'
|
||||
@@ -37,6 +47,23 @@ export const useUserStore = defineStore('smsuser', () => {
|
||||
localStorage.removeItem(USER_KEY)
|
||||
}
|
||||
|
||||
const clearSession = () => {
|
||||
token.value = ''
|
||||
user.value = null
|
||||
isLogin.value = false
|
||||
setAuthToken(null)
|
||||
clearLocal()
|
||||
}
|
||||
|
||||
const acceptLogin = (res: SmsLoginResult) => {
|
||||
token.value = res.token
|
||||
user.value = { ...(res.user || {}), ...(res.huihui || {}) }
|
||||
isLogin.value = true
|
||||
setAuthToken(res.token)
|
||||
persist()
|
||||
return res
|
||||
}
|
||||
|
||||
// 发送验证码(返回结果,演示模式含 devCode)
|
||||
const sendCode = async (phone: string) => {
|
||||
return await sendSmsCode(phone)
|
||||
@@ -44,24 +71,22 @@ export const useUserStore = defineStore('smsuser', () => {
|
||||
|
||||
// 短信登录
|
||||
const login = async (phone: string, code: string) => {
|
||||
const res: any = await loginBySms(phone, code)
|
||||
token.value = res.token
|
||||
user.value = { ...(res.user || {}), ...(res.huihui || {}) }
|
||||
isLogin.value = true
|
||||
setAuthToken(res.token)
|
||||
persist()
|
||||
return res
|
||||
const result = await loginBySms(phone, code)
|
||||
clearHuihuiEmbeddedMode()
|
||||
return acceptLogin(result)
|
||||
}
|
||||
|
||||
// 账号密码登录
|
||||
const loginByPwd = async (account: string, password: string) => {
|
||||
const res: any = await loginByPassword(account, password)
|
||||
token.value = res.token
|
||||
user.value = { ...(res.user || {}), ...(res.huihui || {}) }
|
||||
isLogin.value = true
|
||||
setAuthToken(res.token)
|
||||
persist()
|
||||
return res
|
||||
const result = await loginByPassword(account, password)
|
||||
clearHuihuiEmbeddedMode()
|
||||
return acceptLogin(result)
|
||||
}
|
||||
|
||||
const loginByToken = async (huihuiToken: string) => {
|
||||
const result = await loginByHuihuiToken(huihuiToken)
|
||||
markHuihuiEmbeddedMode()
|
||||
return acceptLogin(result)
|
||||
}
|
||||
|
||||
// 退出
|
||||
@@ -71,12 +96,20 @@ export const useUserStore = defineStore('smsuser', () => {
|
||||
} catch {
|
||||
/* 忽略网络错误,本地清除即可 */
|
||||
}
|
||||
token.value = ''
|
||||
user.value = null
|
||||
isLogin.value = false
|
||||
setAuthToken(null)
|
||||
clearLocal()
|
||||
clearHuihuiEmbeddedMode()
|
||||
clearSession()
|
||||
}
|
||||
|
||||
return { token, user, isLogin, loadFromStorage, sendCode, login, loginByPwd, logout }
|
||||
return {
|
||||
token,
|
||||
user,
|
||||
isLogin,
|
||||
loadFromStorage,
|
||||
clearSession,
|
||||
sendCode,
|
||||
login,
|
||||
loginByPwd,
|
||||
loginByToken,
|
||||
logout
|
||||
}
|
||||
})
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
declare module '@dcloudio/uni-webview-js' {
|
||||
const uniWebView: unknown
|
||||
export default uniWebView
|
||||
}
|
||||
@@ -41,5 +41,10 @@ export function pickAvatarId(
|
||||
currentAvatarId: string | null | undefined,
|
||||
avatars?: AvatarPageRecord[]
|
||||
): string | null
|
||||
export function pickScopedAvatarId(
|
||||
routeAvatarId: string | string[] | null | undefined,
|
||||
currentAvatarId: string | null | undefined,
|
||||
avatars?: AvatarPageRecord[]
|
||||
): string | null
|
||||
export function normalizeAvatarEditForm(avatar?: AvatarPageRecord): AvatarEditForm
|
||||
export function buildAvatarUpdatePayload(form: AvatarEditForm): AvatarUpdatePayload
|
||||
|
||||
@@ -8,6 +8,11 @@ export function pickAvatarId(currentAvatarId, avatars) {
|
||||
return currentAvatarId || avatars?.[0]?.id || null
|
||||
}
|
||||
|
||||
export function pickScopedAvatarId(routeAvatarId, currentAvatarId, avatars) {
|
||||
const requested = Array.isArray(routeAvatarId) ? routeAvatarId[0] : routeAvatarId
|
||||
return requested ? String(requested) : pickAvatarId(currentAvatarId, avatars)
|
||||
}
|
||||
|
||||
export function normalizeAvatarEditForm(avatar = {}) {
|
||||
const config = avatar.config || {}
|
||||
return {
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
@@ -1,11 +1,10 @@
|
||||
// 会会数字分身 H5 ↔ uniapp 原生壳 桥接工具
|
||||
// 协议详见 uniapp-avatar/README.md
|
||||
//
|
||||
// 引入方式:在 index.html 中加载 uniapp web-view bridge:
|
||||
// <script src="https://unpkg.com/@dcloudio/uni-webview-js@0.0.10/index.js"></script>
|
||||
// 引入后全局会出现 window.uni.webView,H5 即可用 postMessage 与原生通信。
|
||||
// uni-webview bridge is bundled by main.ts; no external CDN is required.
|
||||
|
||||
const BRIDGE_HANDLER = '__uniBridgeHandle__'
|
||||
const nativeMessageHandlers = new Set<(message: any) => void>()
|
||||
|
||||
export interface UniLaunchParams {
|
||||
token?: string
|
||||
@@ -13,30 +12,80 @@ export interface UniLaunchParams {
|
||||
nickname?: string
|
||||
avatar?: string
|
||||
ts?: string
|
||||
nativeShell?: string
|
||||
}
|
||||
|
||||
const PARAM_KEYS: (keyof UniLaunchParams)[] = ['token', 'userId', 'nickname', 'avatar', 'ts', 'nativeShell']
|
||||
|
||||
function readParams(search: string, target: UniLaunchParams): void {
|
||||
const sp = new URLSearchParams(search)
|
||||
for (const key of PARAM_KEYS) {
|
||||
const value = sp.get(key)
|
||||
if (value) target[key] = value
|
||||
}
|
||||
}
|
||||
|
||||
function hasNativeShellMarker(): boolean {
|
||||
const params = getLaunchParams()
|
||||
if (params.nativeShell === 'uniapp') return true
|
||||
|
||||
// Compatibility for already-installed shells. They have always appended a
|
||||
// timestamp together with the native SSO context, even before the explicit
|
||||
// nativeShell marker existed.
|
||||
return Boolean(params.ts && (params.token || params.userId))
|
||||
}
|
||||
|
||||
// 是否运行在 uniapp web-view 环境中
|
||||
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
|
||||
)
|
||||
// `plus` can be injected after the H5 entry point runs. The native shell
|
||||
// therefore adds a URL marker while creating its web-view URL, so the
|
||||
// payment entry does not disappear during that startup window.
|
||||
return Boolean(runtime.uni?.webView && (isDCloudApp || isMiniProgram || hasNativeShellMarker()))
|
||||
}
|
||||
|
||||
// 解析 web-view 加载 URL 时原生注入的参数(token / 会会用户)
|
||||
export function getLaunchParams(): UniLaunchParams {
|
||||
const sp = new URLSearchParams(window.location.search)
|
||||
const params: UniLaunchParams = {}
|
||||
const token = sp.get('token')
|
||||
const userId = sp.get('userId')
|
||||
const nickname = sp.get('nickname')
|
||||
const avatar = sp.get('avatar')
|
||||
const ts = sp.get('ts')
|
||||
if (token) params.token = token
|
||||
if (userId) params.userId = userId
|
||||
if (nickname) params.nickname = decodeURIComponent(nickname)
|
||||
if (avatar) params.avatar = decodeURIComponent(avatar)
|
||||
if (ts) params.ts = ts
|
||||
readParams(window.location.search, params)
|
||||
const hashQueryIndex = window.location.hash.indexOf('?')
|
||||
if (hashQueryIndex >= 0) {
|
||||
readParams(window.location.hash.slice(hashQueryIndex + 1), params)
|
||||
}
|
||||
return params
|
||||
}
|
||||
|
||||
// Remove the one-time login credential before any route is rendered or logged.
|
||||
export function stripLaunchToken(): void {
|
||||
const url = new URL(window.location.href)
|
||||
url.searchParams.delete('token')
|
||||
|
||||
const hash = url.hash.slice(1)
|
||||
const queryIndex = hash.indexOf('?')
|
||||
if (queryIndex >= 0) {
|
||||
const path = hash.slice(0, queryIndex)
|
||||
const hashParams = new URLSearchParams(hash.slice(queryIndex + 1))
|
||||
hashParams.delete('token')
|
||||
const query = hashParams.toString()
|
||||
url.hash = `${path}${query ? `?${query}` : ''}`
|
||||
}
|
||||
|
||||
window.history.replaceState(window.history.state, '', `${url.pathname}${url.search}${url.hash}`)
|
||||
}
|
||||
|
||||
// H5 → 原生:发送事件(需引入 uniapp web-view bridge)
|
||||
export function postToNative(message: Record<string, any>): boolean {
|
||||
if (!isInUniWebView()) return false
|
||||
@@ -45,14 +94,18 @@ export function postToNative(message: Record<string, any>): boolean {
|
||||
}
|
||||
|
||||
// 原生 → 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) => {
|
||||
try {
|
||||
handler(message)
|
||||
} catch (e) {
|
||||
console.error('[uniBridge] handler error', e)
|
||||
for (const listener of nativeMessageHandlers) {
|
||||
try {
|
||||
listener(message)
|
||||
} catch (e) {
|
||||
console.error('[uniBridge] handler error', e)
|
||||
}
|
||||
}
|
||||
}
|
||||
return () => nativeMessageHandlers.delete(handler)
|
||||
}
|
||||
|
||||
// 便捷事件
|
||||
@@ -61,5 +114,6 @@ export const UniEvents = {
|
||||
needLogin: () => postToNative({ type: 'needLogin' }),
|
||||
setTitle: (title: string) => postToNative({ type: 'setTitle', title }),
|
||||
navigate: (path: string) => postToNative({ type: 'navigate', path }),
|
||||
payment: (payment: Record<string, any>) => postToNative({ type: 'payment', payment }),
|
||||
back: () => postToNative({ type: 'back' })
|
||||
}
|
||||
|
||||
@@ -1,726 +1,219 @@
|
||||
<template>
|
||||
<div class="authorization-page">
|
||||
<div class="auth-manage-page">
|
||||
<header class="page-header">
|
||||
<button class="back-button" type="button" aria-label="返回数字分身管理" @click="goBack">
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path d="m15 18-6-6 6-6" />
|
||||
</svg>
|
||||
</button>
|
||||
<h1>分身授权管理</h1>
|
||||
<span class="header-balance" aria-hidden="true"></span>
|
||||
<button class="back-btn" @click="goBack">‹</button>
|
||||
<h1 class="page-title">授权管理</h1>
|
||||
<span class="header-spacer"></span>
|
||||
</header>
|
||||
|
||||
<main class="page-content">
|
||||
<template v-if="activeAvatarId">
|
||||
<section class="permission-intro" aria-label="授权说明">
|
||||
<span class="intro-icon" aria-hidden="true">
|
||||
<svg viewBox="0 0 24 24">
|
||||
<rect x="5" y="10" width="14" height="11" rx="2" />
|
||||
<path d="M8 10V7a4 4 0 0 1 8 0v3" />
|
||||
</svg>
|
||||
<section class="intro-card">
|
||||
<div class="intro-icon">🛡️</div>
|
||||
<div>
|
||||
<h2>由你决定分身能做什么</h2>
|
||||
<p>授权后,数字分身会以你的会会身份参与广场互动;撤销后不再创建新的互动。</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="auth-list">
|
||||
<div class="auth-card square-card">
|
||||
<div class="auth-icon application">📰</div>
|
||||
<div class="auth-info">
|
||||
<h3 class="auth-name">会会广场互动</h3>
|
||||
<p class="auth-type">按照广场调度器设置自动执行</p>
|
||||
<div class="auth-permissions">
|
||||
<span class="permission-tag" v-for="permission in squareAuthorization.permissions" :key="permission">
|
||||
{{ getPermissionText(permission) }}
|
||||
</span>
|
||||
</div>
|
||||
<p class="scheduler-note">功能已开发,当前仍在验证中;执行时段、互动间隔及各操作触发概率由运营后台统一控制。</p>
|
||||
</div>
|
||||
<div class="auth-actions">
|
||||
<span class="auth-status" :class="squareAuthorization.status">
|
||||
{{ squareAuthorization.status === 'active' ? '已授权' : '未授权' }}
|
||||
</span>
|
||||
<p>设置数字分身可以执行的操作权限。开启后,分身将代表你自动进行相应操作。</p>
|
||||
</section>
|
||||
|
||||
<section class="permissions-card" :class="{ loading }" :aria-busy="loading">
|
||||
<button
|
||||
v-for="item in permissionItems"
|
||||
:key="item.key"
|
||||
class="permission-row"
|
||||
:class="{ takeover: item.key === 'takeover' }"
|
||||
type="button"
|
||||
role="switch"
|
||||
:aria-checked="permissionState[item.key]"
|
||||
:aria-label="`${permissionState[item.key] ? '关闭' : '开启'}${item.title}`"
|
||||
:disabled="loading || saving"
|
||||
@click="togglePermission(item.key)"
|
||||
class="auth-toggle-btn"
|
||||
:class="{ revoke: squareAuthorization.status === 'active' }"
|
||||
:disabled="saving"
|
||||
@click="toggleSquareAuthorization"
|
||||
>
|
||||
<span class="permission-icon" :class="item.tone" aria-hidden="true">
|
||||
<svg v-if="item.key === 'friend'" viewBox="0 0 24 24">
|
||||
<path d="M9.5 11a3.5 3.5 0 1 0 0-7 3.5 3.5 0 0 0 0 7Z" />
|
||||
<path d="M3.5 20a6 6 0 0 1 12 0M17 8v6m-3-3h6" />
|
||||
</svg>
|
||||
<svg v-else-if="item.key === 'chat'" viewBox="0 0 24 24">
|
||||
<path d="M20 14a4 4 0 0 1-4 4H8l-5 3 1.5-5A7 7 0 0 1 3 12V8a4 4 0 0 1 4-4h9a4 4 0 0 1 4 4v6Z" />
|
||||
</svg>
|
||||
<svg v-else-if="item.key === 'publish'" viewBox="0 0 24 24">
|
||||
<path d="M14.7 5.3 18.7 9.3M4 20l4.1-.8L19.5 7.8a2.8 2.8 0 0 0-4-4L4.8 14.5 4 20Z" />
|
||||
</svg>
|
||||
<svg v-else-if="item.key === 'browse'" viewBox="0 0 24 24">
|
||||
<rect x="4" y="4" width="6" height="6" rx="1" />
|
||||
<rect x="14" y="4" width="6" height="6" rx="1" />
|
||||
<rect x="4" y="14" width="6" height="6" rx="1" />
|
||||
<rect x="14" y="14" width="6" height="6" rx="1" />
|
||||
</svg>
|
||||
<svg v-else-if="item.key === 'interact'" viewBox="0 0 24 24">
|
||||
<path d="M7 10v10H4a2 2 0 0 1-2-2v-6a2 2 0 0 1 2-2h3Zm0 10h9.5a2 2 0 0 0 1.9-1.4l2.3-7A2 2 0 0 0 18.8 9H14l.8-3.2A2.3 2.3 0 0 0 12.6 3L7 10v10Z" />
|
||||
</svg>
|
||||
<svg v-else viewBox="0 0 24 24">
|
||||
<path d="M20 13.5a4.5 4.5 0 0 1-4.5 4.5H9l-5 3 1.4-4.5A6.5 6.5 0 0 1 3 11.4V8.5A4.5 4.5 0 0 1 7.5 4H14" />
|
||||
<path d="M18 3v5m-2.5-2.5h5" />
|
||||
</svg>
|
||||
</span>
|
||||
|
||||
<span class="permission-copy">
|
||||
<strong>{{ item.title }}</strong>
|
||||
<small>
|
||||
{{ item.description }}
|
||||
<span
|
||||
v-if="item.key === 'takeover' && takeoverConnectionLabel"
|
||||
class="connection-state"
|
||||
:class="takeoverConnectionTone"
|
||||
> · {{ takeoverConnectionLabel }}</span>
|
||||
</small>
|
||||
</span>
|
||||
|
||||
<span class="permission-switch" :class="{ active: permissionState[item.key] }" aria-hidden="true">
|
||||
<span></span>
|
||||
</span>
|
||||
{{ saving ? '处理中' : squareAuthorization.status === 'active' ? '撤销' : '授权' }}
|
||||
</button>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p v-if="errorMessage" class="error-message" role="alert">{{ errorMessage }}</p>
|
||||
</template>
|
||||
<div v-for="feature in unavailableFeatures" :key="feature.id" class="auth-card unavailable-card">
|
||||
<div class="auth-icon application">{{ feature.icon }}</div>
|
||||
<div class="auth-info">
|
||||
<h3 class="auth-name">{{ feature.name }}</h3>
|
||||
<p class="auth-type">{{ feature.description }}</p>
|
||||
</div>
|
||||
<div class="auth-actions">
|
||||
<span class="auth-status inactive">未开启</span>
|
||||
<button class="auth-toggle-btn unavailable-toggle" @click="showUnavailableNotice">开启</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section v-else-if="!loading" class="empty-state">
|
||||
<span class="empty-icon" aria-hidden="true">
|
||||
<svg viewBox="0 0 24 24">
|
||||
<path d="M8 11a4 4 0 1 1 8 0v1M5 20a7 7 0 0 1 14 0M19 8v4m-2-2h4" />
|
||||
</svg>
|
||||
</span>
|
||||
<h2>还没有数字分身</h2>
|
||||
<p>创建分身后即可设置操作权限。</p>
|
||||
<button type="button" @click="goCreate">创建数字分身</button>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<footer v-if="activeAvatarId" class="save-area">
|
||||
<button class="save-button" type="button" :disabled="loading || saving" @click="saveSettings()">
|
||||
<span v-if="saving" class="saving-spinner" aria-hidden="true"></span>
|
||||
{{ saving ? '保存中...' : '保存授权设置' }}
|
||||
</button>
|
||||
</footer>
|
||||
|
||||
<Transition name="toast">
|
||||
<p v-if="toastMessage" class="page-toast" role="status">{{ toastMessage }}</p>
|
||||
</Transition>
|
||||
<div class="auth-card" v-for="auth in otherAuthorizations" :key="auth.id">
|
||||
<div class="auth-icon" :class="auth.targetType">{{ getAuthIcon(auth.targetType) }}</div>
|
||||
<div class="auth-info">
|
||||
<h3 class="auth-name">{{ getAuthTargetName(auth.targetName) }}</h3>
|
||||
<p class="auth-type">{{ getAuthTypeText(auth.targetType) }}</p>
|
||||
<div class="auth-permissions">
|
||||
<span class="permission-tag" v-for="permission in auth.permissions" :key="permission">
|
||||
{{ getPermissionText(permission) }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="auth-actions">
|
||||
<span class="auth-status" :class="auth.status">{{ auth.status === 'active' ? '已授权' : '已撤销' }}</span>
|
||||
<button class="auth-toggle-btn" :disabled="saving" @click="toggleExistingAuthorization(auth)">
|
||||
{{ auth.status === 'active' ? '撤销' : '授权' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onBeforeUnmount, onMounted, reactive, ref } from 'vue'
|
||||
import { computed, ref, onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import {
|
||||
getAvatarPermissionSettings,
|
||||
getTakeoverStatus,
|
||||
updateAvatarPermissionSettings,
|
||||
type AvatarPermission,
|
||||
type TakeoverStatus,
|
||||
} from '@/api'
|
||||
import { useAvatarStore } from '@/store/avatar'
|
||||
import { pickAvatarId } from '@/utils/avatar-page-data.js'
|
||||
|
||||
type PermissionState = Record<AvatarPermission, boolean>
|
||||
import {
|
||||
getAuthorizationList,
|
||||
updateAuthorization,
|
||||
SQUARE_INTERACTION_TARGET_ID,
|
||||
SQUARE_INTERACTION_PERMISSIONS,
|
||||
type Authorization
|
||||
} from '@/api'
|
||||
import { pickAvatarId, unwrapListData } from '@/utils/avatar-page-data.js'
|
||||
|
||||
const router = useRouter()
|
||||
const avatarStore = useAvatarStore()
|
||||
const authList = ref<Authorization[]>([])
|
||||
const saving = ref(false)
|
||||
const UNAVAILABLE_MESSAGE = '该功能尚未向公众用户开放,如需使用请联系会会运营团队'
|
||||
|
||||
const permissionItems: Array<{
|
||||
key: AvatarPermission
|
||||
title: string
|
||||
description: string
|
||||
tone: string
|
||||
}> = [
|
||||
{
|
||||
key: 'friend',
|
||||
title: '交友与添加好友',
|
||||
description: '允许分身主动添加好友或接受好友请求',
|
||||
tone: 'orange',
|
||||
},
|
||||
{
|
||||
key: 'chat',
|
||||
title: '主动发起聊天',
|
||||
description: '允许分身主动与陌生人或好友发起聊天',
|
||||
tone: 'blue',
|
||||
},
|
||||
{
|
||||
key: 'publish',
|
||||
title: '发布微博内容',
|
||||
description: '允许分身自动发布动态内容',
|
||||
tone: 'green',
|
||||
},
|
||||
{
|
||||
key: 'browse',
|
||||
title: '浏览会会广场',
|
||||
description: '允许分身浏览广场内容',
|
||||
tone: 'violet',
|
||||
},
|
||||
{
|
||||
key: 'interact',
|
||||
title: '广场互动操作',
|
||||
description: '点赞、收藏、评论、回复等操作',
|
||||
tone: 'pink',
|
||||
},
|
||||
{
|
||||
key: 'takeover',
|
||||
title: '分身主动接管聊天回复',
|
||||
description: '收到私聊消息 3 秒后回复,主人发言时暂停',
|
||||
tone: 'cyan',
|
||||
},
|
||||
const unavailableFeatures = [
|
||||
{ id: 'make-friends', name: '交友与添加好友', description: '暂未开放', icon: '👥' },
|
||||
{ id: 'start-chat', name: '主动发起聊天', description: '暂未开放', icon: '💬' },
|
||||
{ id: 'publish-microblog', name: '发布微播内容', description: '暂未开放', icon: '📝' },
|
||||
{ id: 'browse-square', name: '浏览会会广场', description: '暂未开放', icon: '🔎' }
|
||||
]
|
||||
|
||||
const permissionState = reactive<PermissionState>({
|
||||
friend: true,
|
||||
chat: true,
|
||||
publish: false,
|
||||
browse: false,
|
||||
interact: false,
|
||||
takeover: false,
|
||||
})
|
||||
const activeAvatarId = ref('')
|
||||
const loading = ref(true)
|
||||
const saving = ref(false)
|
||||
const errorMessage = ref('')
|
||||
const toastMessage = ref('')
|
||||
const takeoverStatus = ref<TakeoverStatus | null>(null)
|
||||
let toastTimer: number | undefined
|
||||
let takeoverStatusTimer: number | undefined
|
||||
|
||||
const takeoverConnectionLabel = computed(() => {
|
||||
if (takeoverStatus.value?.status === 'error' || takeoverStatus.value?.status === 'needs_login') {
|
||||
return '连接失败'
|
||||
const squareAuthorization = computed<Authorization>(() => {
|
||||
return authList.value.find((item) => item.targetId === SQUARE_INTERACTION_TARGET_ID) || {
|
||||
id: '',
|
||||
avatarId: avatarStore.currentAvatarId || '',
|
||||
targetType: 'application',
|
||||
targetId: SQUARE_INTERACTION_TARGET_ID,
|
||||
targetName: '会会广场互动',
|
||||
permissions: [...SQUARE_INTERACTION_PERMISSIONS],
|
||||
status: 'inactive',
|
||||
createdAt: ''
|
||||
}
|
||||
if (takeoverStatus.value?.status === 'ready') return '已连接'
|
||||
if (permissionState.takeover) return '正在连接'
|
||||
return ''
|
||||
})
|
||||
|
||||
const takeoverConnectionTone = computed(() => {
|
||||
if (takeoverStatus.value?.status === 'ready') return 'ready'
|
||||
if (takeoverStatus.value?.status === 'error' || takeoverStatus.value?.status === 'needs_login') {
|
||||
return 'failed'
|
||||
}
|
||||
return 'connecting'
|
||||
})
|
||||
const otherAuthorizations = computed(() =>
|
||||
authList.value.filter((item) => item.targetId !== SQUARE_INTERACTION_TARGET_ID && !isUnavailableFeature(item.targetName))
|
||||
)
|
||||
|
||||
const setPermissions = (permissions: AvatarPermission[]) => {
|
||||
const enabled = new Set(permissions)
|
||||
for (const item of permissionItems) permissionState[item.key] = enabled.has(item.key)
|
||||
const isUnavailableFeature = (name: string) => unavailableFeatures.some((feature) =>
|
||||
name.replaceAll('发布微博内容', '发布微播内容') === feature.name
|
||||
)
|
||||
|
||||
const currentAvatarId = async () => {
|
||||
if (!avatarStore.avatars.length) await avatarStore.loadAvatars()
|
||||
return pickAvatarId(avatarStore.currentAvatarId, avatarStore.avatars)
|
||||
}
|
||||
|
||||
const selectedPermissions = () => permissionItems
|
||||
.filter((item) => permissionState[item.key])
|
||||
.map((item) => item.key)
|
||||
|
||||
const readableError = (error: any, fallback: string) =>
|
||||
error?.response?.data?.detail || error?.response?.data?.message || error?.message || fallback
|
||||
|
||||
const showToast = (message: string) => {
|
||||
toastMessage.value = message
|
||||
if (toastTimer) window.clearTimeout(toastTimer)
|
||||
toastTimer = window.setTimeout(() => { toastMessage.value = '' }, 2400)
|
||||
}
|
||||
|
||||
const togglePermission = async (permission: AvatarPermission) => {
|
||||
errorMessage.value = ''
|
||||
const previousValue = permissionState[permission]
|
||||
permissionState[permission] = !permissionState[permission]
|
||||
if (permission === 'takeover') {
|
||||
takeoverStatus.value = null
|
||||
if (takeoverStatusTimer) {
|
||||
window.clearTimeout(takeoverStatusTimer)
|
||||
takeoverStatusTimer = undefined
|
||||
}
|
||||
const saved = await saveSettings(true)
|
||||
if (!saved) permissionState.takeover = previousValue
|
||||
}
|
||||
}
|
||||
|
||||
const scheduleTakeoverStatusRefresh = () => {
|
||||
if (takeoverStatusTimer) window.clearTimeout(takeoverStatusTimer)
|
||||
if (!permissionState.takeover || !activeAvatarId.value) {
|
||||
takeoverStatusTimer = undefined
|
||||
return
|
||||
}
|
||||
takeoverStatusTimer = window.setTimeout(async () => {
|
||||
await loadTakeoverStatus()
|
||||
scheduleTakeoverStatusRefresh()
|
||||
}, 3000)
|
||||
}
|
||||
|
||||
const loadSettings = async () => {
|
||||
if (!activeAvatarId.value) {
|
||||
loading.value = false
|
||||
return
|
||||
}
|
||||
loading.value = true
|
||||
errorMessage.value = ''
|
||||
const loadAuth = async () => {
|
||||
try {
|
||||
const settings = await getAvatarPermissionSettings(activeAvatarId.value)
|
||||
setPermissions(settings.permissions || [])
|
||||
await loadTakeoverStatus()
|
||||
scheduleTakeoverStatusRefresh()
|
||||
} catch (error: any) {
|
||||
errorMessage.value = readableError(error, '授权设置加载失败,请稍后重试')
|
||||
} finally {
|
||||
loading.value = false
|
||||
const avatarId = await currentAvatarId()
|
||||
authList.value = avatarId ? unwrapListData(await getAuthorizationList(avatarId)) : []
|
||||
} catch (error) {
|
||||
console.error('加载授权失败', error)
|
||||
}
|
||||
}
|
||||
|
||||
const loadTakeoverStatus = async () => {
|
||||
if (!activeAvatarId.value) return
|
||||
try {
|
||||
takeoverStatus.value = await getTakeoverStatus(activeAvatarId.value)
|
||||
if (!takeoverStatus.value.enabled && permissionState.takeover) {
|
||||
permissionState.takeover = false
|
||||
}
|
||||
} catch {
|
||||
takeoverStatus.value = null
|
||||
}
|
||||
}
|
||||
|
||||
const saveSettings = async (takeoverToggle = false): Promise<boolean> => {
|
||||
if (!activeAvatarId.value || loading.value || saving.value) return false
|
||||
const toggleSquareAuthorization = async () => {
|
||||
const avatarId = await currentAvatarId()
|
||||
if (!avatarId || saving.value) return
|
||||
saving.value = true
|
||||
errorMessage.value = ''
|
||||
try {
|
||||
const settings = await updateAvatarPermissionSettings(activeAvatarId.value, selectedPermissions())
|
||||
setPermissions(settings.permissions || [])
|
||||
await loadTakeoverStatus()
|
||||
scheduleTakeoverStatusRefresh()
|
||||
if (takeoverToggle) {
|
||||
showToast(permissionState.takeover ? '接管已开启,正在连接' : '接管已关闭')
|
||||
} else {
|
||||
showToast('授权设置已保存')
|
||||
}
|
||||
return true
|
||||
} catch (error: any) {
|
||||
errorMessage.value = readableError(error, '授权设置保存失败,请重试')
|
||||
return false
|
||||
authList.value = unwrapListData(await updateAuthorization(avatarId, {
|
||||
id: squareAuthorization.value.id || undefined,
|
||||
targetId: SQUARE_INTERACTION_TARGET_ID,
|
||||
targetName: '会会广场互动',
|
||||
targetType: 'application',
|
||||
permissions: [...SQUARE_INTERACTION_PERMISSIONS],
|
||||
status: squareAuthorization.value.status === 'active' ? 'inactive' : 'active'
|
||||
}))
|
||||
} catch (error) {
|
||||
alert('操作失败,请重试')
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const goBack = () => router.push('/avatar/manage')
|
||||
const goCreate = () => router.push('/avatar/create')
|
||||
const toggleExistingAuthorization = async (auth: Authorization) => {
|
||||
const avatarId = await currentAvatarId()
|
||||
if (!avatarId || saving.value) return
|
||||
saving.value = true
|
||||
try {
|
||||
authList.value = unwrapListData(await updateAuthorization(avatarId, {
|
||||
id: auth.id,
|
||||
status: auth.status === 'active' ? 'inactive' : 'active'
|
||||
}))
|
||||
} catch (error) {
|
||||
alert('操作失败,请重试')
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
if (!avatarStore.avatars.length) await avatarStore.loadAvatars()
|
||||
activeAvatarId.value = pickAvatarId(avatarStore.currentAvatarId, avatarStore.avatars) || ''
|
||||
await loadSettings()
|
||||
})
|
||||
const showUnavailableNotice = () => alert(UNAVAILABLE_MESSAGE)
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (toastTimer) window.clearTimeout(toastTimer)
|
||||
if (takeoverStatusTimer) window.clearTimeout(takeoverStatusTimer)
|
||||
})
|
||||
const getAuthIcon = (type: string) => ({ user: '👤', organization: '🏢', application: '📱' }[type] || '🔑')
|
||||
const getAuthTypeText = (type: string) => ({ user: '用户', organization: '组织', application: '应用' }[type] || type)
|
||||
const getAuthTargetName = (name: string) => name.replaceAll('发布微博内容', '发布微播内容')
|
||||
const getPermissionText = (permission: string) => ({
|
||||
read: '读取', write: '写入', reply: '回复', edit: '编辑',
|
||||
like: '点赞', collect: '收藏', comment: '评论'
|
||||
}[permission] || permission)
|
||||
const goBack = () => router.back()
|
||||
|
||||
onMounted(loadAuth)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.authorization-page {
|
||||
--orange: #f58632;
|
||||
--orange-deep: #f2792f;
|
||||
--ink: #18191c;
|
||||
--muted: #a0a5b4;
|
||||
position: relative;
|
||||
width: min(100%, 390px);
|
||||
min-height: 100dvh;
|
||||
margin: 0 auto;
|
||||
padding-bottom: calc(92px + env(safe-area-inset-bottom));
|
||||
overflow-x: hidden;
|
||||
color: var(--ink);
|
||||
background: #fafafa;
|
||||
font-family: "Noto Sans SC", "PingFang SC", "Microsoft YaHei", sans-serif;
|
||||
}
|
||||
|
||||
button {
|
||||
font: inherit;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
|
||||
button:disabled {
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
svg {
|
||||
width: 1em;
|
||||
height: 1em;
|
||||
fill: none;
|
||||
stroke: currentColor;
|
||||
stroke-width: 1.7;
|
||||
stroke-linecap: round;
|
||||
stroke-linejoin: round;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
height: calc(72px + env(safe-area-inset-top));
|
||||
padding: env(safe-area-inset-top) 20px 0;
|
||||
display: grid;
|
||||
grid-template-columns: 34px 1fr 34px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.page-header h1 {
|
||||
margin: 0;
|
||||
text-align: center;
|
||||
font-size: 17px;
|
||||
font-weight: 700;
|
||||
line-height: 1.4;
|
||||
letter-spacing: .01em;
|
||||
}
|
||||
|
||||
.back-button {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
padding: 0;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border: 0;
|
||||
border-radius: 50%;
|
||||
color: #7f8490;
|
||||
background: #f3f4f6;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.back-button svg {
|
||||
font-size: 19px;
|
||||
}
|
||||
|
||||
.page-content {
|
||||
padding: 0 20px;
|
||||
}
|
||||
|
||||
.permission-intro {
|
||||
min-height: 96px;
|
||||
padding: 15px 16px 14px;
|
||||
border: 1px solid rgba(255, 227, 195, .45);
|
||||
border-radius: 13px;
|
||||
background: linear-gradient(115deg, #fff8eb 0%, #fff4ec 100%);
|
||||
}
|
||||
|
||||
.intro-icon {
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
color: #f18837;
|
||||
}
|
||||
|
||||
.intro-icon svg {
|
||||
font-size: 24px;
|
||||
stroke-width: 1.9;
|
||||
}
|
||||
|
||||
.permission-intro p {
|
||||
margin: 7px 0 0;
|
||||
color: #a66d45;
|
||||
font-size: 12px;
|
||||
line-height: 1.75;
|
||||
}
|
||||
|
||||
.permissions-card {
|
||||
margin-top: 16px;
|
||||
padding: 5px 0;
|
||||
overflow: hidden;
|
||||
border-radius: 15px;
|
||||
background: #fff;
|
||||
box-shadow: 0 8px 26px rgba(69, 55, 43, .025);
|
||||
transition: opacity .2s ease;
|
||||
}
|
||||
|
||||
.permissions-card.loading {
|
||||
opacity: .62;
|
||||
}
|
||||
|
||||
.permission-row {
|
||||
width: 100%;
|
||||
min-height: 65px;
|
||||
padding: 8px 15px;
|
||||
display: grid;
|
||||
grid-template-columns: 34px minmax(0, 1fr) 43px;
|
||||
align-items: center;
|
||||
gap: 11px;
|
||||
border: 0;
|
||||
color: inherit;
|
||||
text-align: left;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.permission-row:active:not(:disabled) {
|
||||
background: #fcfcfd;
|
||||
}
|
||||
|
||||
.permission-row.takeover {
|
||||
min-height: 76px;
|
||||
}
|
||||
|
||||
.permission-icon {
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.permission-icon svg {
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.permission-icon.orange {
|
||||
color: #ec9a45;
|
||||
background: #fff9ee;
|
||||
}
|
||||
|
||||
.permission-icon.blue {
|
||||
color: #619ae7;
|
||||
background: #f3f8ff;
|
||||
}
|
||||
|
||||
.permission-icon.green {
|
||||
color: #64c792;
|
||||
background: #f0fbf5;
|
||||
}
|
||||
|
||||
.permission-icon.violet {
|
||||
color: #8b7de4;
|
||||
background: #f6f4ff;
|
||||
}
|
||||
|
||||
.permission-icon.pink {
|
||||
color: #ec78a3;
|
||||
background: #fff3f7;
|
||||
}
|
||||
|
||||
.permission-icon.cyan {
|
||||
color: #35a6a2;
|
||||
background: #effaf9;
|
||||
}
|
||||
|
||||
.permission-copy {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.permission-copy strong,
|
||||
.permission-copy small {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.connection-state {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.connection-state.connecting {
|
||||
color: #d78532;
|
||||
}
|
||||
|
||||
.connection-state.ready {
|
||||
color: #31966b;
|
||||
}
|
||||
|
||||
.connection-state.failed {
|
||||
color: #d95849;
|
||||
}
|
||||
|
||||
.permission-copy strong {
|
||||
color: #26272b;
|
||||
font-size: 14px;
|
||||
font-weight: 650;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.permission-copy small {
|
||||
margin-top: 3px;
|
||||
overflow: hidden;
|
||||
color: var(--muted);
|
||||
font-size: 10.5px;
|
||||
font-weight: 400;
|
||||
line-height: 1.45;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.permission-row.takeover .permission-copy small {
|
||||
display: -webkit-box;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: normal;
|
||||
-webkit-box-orient: vertical;
|
||||
-webkit-line-clamp: 2;
|
||||
}
|
||||
|
||||
.permission-switch {
|
||||
position: relative;
|
||||
width: 43px;
|
||||
height: 25px;
|
||||
justify-self: end;
|
||||
border-radius: 999px;
|
||||
background: #d8dde5;
|
||||
box-shadow: inset 0 0 0 1px rgba(140, 148, 160, .08);
|
||||
transition: background .2s ease;
|
||||
}
|
||||
|
||||
.permission-switch span {
|
||||
position: absolute;
|
||||
top: 2px;
|
||||
left: 2px;
|
||||
width: 21px;
|
||||
height: 21px;
|
||||
border-radius: 50%;
|
||||
background: #fff;
|
||||
box-shadow: 0 1px 4px rgba(74, 72, 69, .2);
|
||||
transition: transform .2s cubic-bezier(.22, .85, .35, 1);
|
||||
}
|
||||
|
||||
.permission-switch.active {
|
||||
background: linear-gradient(120deg, #f59d3c, #f47d31);
|
||||
}
|
||||
|
||||
.permission-switch.active span {
|
||||
transform: translateX(18px);
|
||||
}
|
||||
|
||||
.error-message {
|
||||
margin: 13px 4px 0;
|
||||
color: #d95849;
|
||||
font-size: 12px;
|
||||
line-height: 1.6;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.save-area {
|
||||
position: fixed;
|
||||
z-index: 20;
|
||||
left: 50%;
|
||||
bottom: 0;
|
||||
width: min(100%, 390px);
|
||||
padding: 12px 20px calc(20px + env(safe-area-inset-bottom));
|
||||
background: linear-gradient(to bottom, rgba(250, 250, 250, 0), #fafafa 20%, #fafafa 100%);
|
||||
transform: translateX(-50%);
|
||||
}
|
||||
|
||||
.save-button {
|
||||
width: 100%;
|
||||
height: 48px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
border: 0;
|
||||
border-radius: 24px;
|
||||
color: #fff;
|
||||
background: linear-gradient(105deg, #f7a33d 0%, #f47b2f 100%);
|
||||
box-shadow: 0 8px 20px rgba(240, 119, 43, .16);
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.save-button:disabled {
|
||||
opacity: .68;
|
||||
}
|
||||
|
||||
.saving-spinner {
|
||||
width: 15px;
|
||||
height: 15px;
|
||||
border: 2px solid rgba(255, 255, 255, .48);
|
||||
border-top-color: #fff;
|
||||
border-radius: 50%;
|
||||
animation: spin .8s linear infinite;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
min-height: 470px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.empty-icon {
|
||||
width: 58px;
|
||||
height: 58px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border-radius: 18px;
|
||||
color: #ed8739;
|
||||
background: #fff4e9;
|
||||
}
|
||||
|
||||
.empty-icon svg {
|
||||
font-size: 28px;
|
||||
}
|
||||
|
||||
.empty-state h2 {
|
||||
margin: 18px 0 0;
|
||||
font-size: 17px;
|
||||
}
|
||||
|
||||
.empty-state p {
|
||||
margin: 7px 0 20px;
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.empty-state button {
|
||||
padding: 11px 24px;
|
||||
border: 0;
|
||||
border-radius: 22px;
|
||||
color: #fff;
|
||||
background: linear-gradient(105deg, #f7a33d, #f47b2f);
|
||||
}
|
||||
|
||||
.page-toast {
|
||||
position: fixed;
|
||||
z-index: 50;
|
||||
left: 50%;
|
||||
bottom: calc(88px + env(safe-area-inset-bottom));
|
||||
max-width: min(310px, calc(100vw - 40px));
|
||||
margin: 0;
|
||||
padding: 10px 18px;
|
||||
border-radius: 22px;
|
||||
color: #fff;
|
||||
background: rgba(36, 36, 39, .9);
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, .14);
|
||||
font-size: 12px;
|
||||
text-align: center;
|
||||
transform: translateX(-50%);
|
||||
}
|
||||
|
||||
.toast-enter-active,
|
||||
.toast-leave-active {
|
||||
transition: opacity .2s ease, transform .2s ease;
|
||||
}
|
||||
|
||||
.toast-enter-from,
|
||||
.toast-leave-to {
|
||||
opacity: 0;
|
||||
transform: translate(-50%, 8px);
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
@media (min-width: 600px) {
|
||||
.authorization-page {
|
||||
border-radius: 0 0 34px 34px;
|
||||
box-shadow: 0 18px 55px rgba(32, 30, 28, .1);
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.permission-switch,
|
||||
.permission-switch span,
|
||||
.toast-enter-active,
|
||||
.toast-leave-active {
|
||||
transition: none;
|
||||
}
|
||||
|
||||
.saving-spinner {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
.auth-manage-page { min-height: 100vh; background: #f8f9fa; padding-bottom: 80px; }
|
||||
.page-header { display: flex; align-items: center; justify-content: space-between; padding: 16px 20px; background: white; border-bottom: 1px solid #edeef1; }
|
||||
.back-btn { background: none; border: none; font-size: 24px; cursor: pointer; padding: 4px 8px; color: #18191c; }
|
||||
.page-title { font-size: 17px; font-weight: 600; margin: 0; color: #18191c; }
|
||||
.header-spacer { width: 40px; }
|
||||
.intro-card { display: flex; gap: 12px; margin: 20px 16px 0; padding: 16px; color: #7c2d12; background: #fff7ed; border: 1px solid #fed7aa; border-radius: 14px; }
|
||||
.intro-icon { font-size: 24px; }
|
||||
.intro-card h2 { margin: 0 0 6px; font-size: 15px; }
|
||||
.intro-card p { margin: 0; font-size: 12px; line-height: 1.6; color: #9a3412; }
|
||||
.auth-list { padding: 16px 16px 20px; display: flex; flex-direction: column; gap: 12px; }
|
||||
.auth-card { display: flex; align-items: flex-start; gap: 12px; padding: 16px; background: white; border-radius: 12px; box-shadow: 0 2px 8px rgba(0, 0, 0, .05); }
|
||||
.square-card { border: 1px solid #ffedd5; }
|
||||
.unavailable-card { opacity: .78; }
|
||||
.auth-icon { font-size: 24px; width: 48px; height: 48px; display: flex; align-items: center; justify-content: center; border-radius: 12px; background: #fff0e6; flex-shrink: 0; }
|
||||
.auth-info { flex: 1; min-width: 0; }
|
||||
.auth-name { font-size: 15px; font-weight: 600; margin: 0 0 4px; color: #18191c; }
|
||||
.auth-type { font-size: 12px; color: #9398ae; margin: 0 0 8px; }
|
||||
.auth-permissions { display: flex; gap: 6px; flex-wrap: wrap; }
|
||||
.permission-tag { padding: 4px 8px; background: #f3f4f6; border-radius: 6px; font-size: 11px; color: #6b7280; }
|
||||
.scheduler-note { margin: 10px 0 0; color: #9398ae; font-size: 11px; line-height: 1.5; white-space: normal; overflow-wrap: anywhere; word-break: break-word; }
|
||||
.auth-actions { display: flex; flex-direction: column; align-items: flex-end; gap: 8px; flex-shrink: 0; }
|
||||
.auth-status { font-size: 12px; font-weight: 500; }
|
||||
.auth-status.active { color: #22c55e; }
|
||||
.auth-status.inactive { color: #9398ae; }
|
||||
.auth-toggle-btn { padding: 6px 12px; border-radius: 8px; font-size: 12px; font-weight: 500; cursor: pointer; border: none; background: #f97316; color: white; }
|
||||
.auth-toggle-btn.revoke { color: #6b7280; background: #f3f4f6; }
|
||||
.unavailable-toggle { background: #d1d5db; color: #4b5563; }
|
||||
.auth-toggle-btn:disabled { cursor: not-allowed; opacity: .6; }
|
||||
</style>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<div class="chat-page">
|
||||
<div class="chat-page" :class="{ 'has-pending-images': pendingImages.length }">
|
||||
<header class="chat-header">
|
||||
<button v-if="!isPublic" class="back-btn" @click="router.back()">‹</button>
|
||||
<div class="avatar-heading">
|
||||
@@ -31,6 +31,12 @@
|
||||
<span v-else>{{ avatar?.emoji || '🤖' }}</span>
|
||||
</div>
|
||||
<div class="message-column">
|
||||
<div v-if="message.attachments?.length" class="message-images">
|
||||
<figure v-for="attachment in message.attachments" :key="attachment.id" class="message-image-card">
|
||||
<img :src="attachment.previewUrl" :alt="attachment.filename" />
|
||||
<figcaption v-if="attachment.warning">{{ attachment.warning }}</figcaption>
|
||||
</figure>
|
||||
</div>
|
||||
<div class="message-bubble" :class="{ streaming: sending && message.role === 'assistant' && index === messages.length - 1 }">
|
||||
<template v-if="message.role === 'assistant'">
|
||||
<span
|
||||
@@ -70,24 +76,66 @@
|
||||
</main>
|
||||
|
||||
<form class="composer" @submit.prevent="sendMessage(inputText)">
|
||||
<textarea v-model="inputText" rows="1" :disabled="sending" placeholder="输入你想聊的内容…" @keydown.enter.exact.prevent="sendMessage(inputText)"></textarea>
|
||||
<button class="send-btn" type="submit" :disabled="sending || !inputText.trim()">发送</button>
|
||||
<div v-if="pendingImages.length" class="pending-images">
|
||||
<div v-for="image in pendingImages" :key="image.localId" class="pending-image" :class="image.status">
|
||||
<img :src="image.previewUrl" :alt="image.filename" />
|
||||
<div class="pending-image-copy">
|
||||
<strong>{{ image.status === 'uploading' ? '正在识别图片…' : image.summary || image.filename }}</strong>
|
||||
<span>{{ image.status === 'uploading' ? '正在提取图片中的可见内容' : categoryLabel(image.category) }}</span>
|
||||
</div>
|
||||
<button type="button" aria-label="移除图片" :disabled="sending" @click="removePendingImage(image.localId)">×</button>
|
||||
</div>
|
||||
<p v-if="hasPendingMedicalImage" class="medical-note">病例与医学影像识别仅供辅助,请以原始资料和医生意见为准。</p>
|
||||
</div>
|
||||
<div class="composer-row">
|
||||
<button class="image-btn" type="button" :disabled="sending || uploadingImage || pendingImages.length >= 3" aria-label="选择图片" @click="imageInput?.click()">
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M4 5.5A2.5 2.5 0 0 1 6.5 3h11A2.5 2.5 0 0 1 20 5.5v13a2.5 2.5 0 0 1-2.5 2.5h-11A2.5 2.5 0 0 1 4 18.5v-13Zm2 12.7 3.8-4.2 2.7 2.8 1.7-1.8 3.8 3.2V5.5a.5.5 0 0 0-.5-.5h-11a.5.5 0 0 0-.5.5v12.7Zm8.3-7.8a1.7 1.7 0 1 0 0-3.4 1.7 1.7 0 0 0 0 3.4Z"/></svg>
|
||||
</button>
|
||||
<input ref="imageInput" class="image-input" type="file" accept="image/jpeg,image/png,image/webp" multiple @change="selectImages" />
|
||||
<textarea v-model="inputText" rows="1" :disabled="sending" placeholder="输入问题,或选择一张图片…" @keydown.enter.exact.prevent="sendMessage(inputText)"></textarea>
|
||||
<button class="send-btn" type="submit" :disabled="sending || uploadingImage || (!inputText.trim() && !readyPendingImages.length)">发送</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, onMounted, reactive, ref } from 'vue'
|
||||
import { computed, nextTick, onBeforeUnmount, onMounted, reactive, ref } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { getAvatarDetail, getPublicAvatar, streamAvatarChat, streamPublicAvatarChat, type ChatMessage } from '@/api'
|
||||
import {
|
||||
getAvatarDetail,
|
||||
getPublicAvatar,
|
||||
streamAvatarChat,
|
||||
streamPublicAvatarChat,
|
||||
uploadAvatarChatImage,
|
||||
uploadPublicAvatarChatImage,
|
||||
type ChatAttachment,
|
||||
type ChatMessage
|
||||
} from '@/api'
|
||||
import { useAvatarStore } from '@/store/avatar'
|
||||
import { useUserStore } from '@/store/user'
|
||||
import { renderChatMarkdownCharacters } from '@/utils/chat-markdown.js'
|
||||
|
||||
type DisplayMessage = ChatMessage & {
|
||||
source?: 'qa' | 'knowledge' | 'qwen' | 'public'
|
||||
source?: 'qa' | 'knowledge' | 'vision' | 'qwen' | 'public'
|
||||
references?: Array<{ filename?: string }>
|
||||
characters?: string[]
|
||||
attachments?: MessageAttachment[]
|
||||
}
|
||||
|
||||
type MessageAttachment = {
|
||||
id: string
|
||||
filename: string
|
||||
previewUrl: string
|
||||
category?: ChatAttachment['category']
|
||||
summary?: string
|
||||
warning?: string
|
||||
}
|
||||
|
||||
type PendingImage = MessageAttachment & {
|
||||
localId: string
|
||||
attachmentId?: string
|
||||
status: 'uploading' | 'ready'
|
||||
}
|
||||
|
||||
const route = useRoute()
|
||||
@@ -103,8 +151,11 @@ const inputText = ref('')
|
||||
const sending = ref(false)
|
||||
const thinking = ref(false)
|
||||
const errorMessage = ref('')
|
||||
const lastQuestion = ref('')
|
||||
const lastRequest = ref<{ question: string; attachments: MessageAttachment[] } | null>(null)
|
||||
const messageList = ref<HTMLElement | null>(null)
|
||||
const imageInput = ref<HTMLInputElement | null>(null)
|
||||
const pendingImages = ref<PendingImage[]>([])
|
||||
const previewUrls = new Set<string>()
|
||||
let scrollFrame: number | null = null
|
||||
|
||||
const userAvatarUrl = computed(() => userStore.user?.avatarUrl || store.userProfile?.avatarUrl || '')
|
||||
@@ -115,14 +166,25 @@ const avatarStatus = computed(() => {
|
||||
if (status === 'training') return { tone: 'training', label: '知识训练中' }
|
||||
return { tone: 'active', label: '在线,随时可以和我聊聊' }
|
||||
})
|
||||
const readyPendingImages = computed(() => pendingImages.value.filter((image) => image.status === 'ready' && image.attachmentId))
|
||||
const uploadingImage = computed(() => pendingImages.value.some((image) => image.status === 'uploading'))
|
||||
const hasPendingMedicalImage = computed(() => readyPendingImages.value.some((image) => ['medical_document', 'medical_image'].includes(image.category || '')))
|
||||
|
||||
const sourceLabels: Record<NonNullable<DisplayMessage['source']>, string> = {
|
||||
qa: '标准问答对',
|
||||
knowledge: '参考文件知识库',
|
||||
vision: '图片理解',
|
||||
qwen: '智能回答',
|
||||
public: ''
|
||||
}
|
||||
const sourceLabel = (source?: DisplayMessage['source']) => source ? sourceLabels[source] : ''
|
||||
const categoryLabels: Record<ChatAttachment['category'], string> = {
|
||||
general_image: '图片内容已识别',
|
||||
document: '文档图片已识别',
|
||||
medical_document: '病例文字已提取,请核对原文',
|
||||
medical_image: '医学影像已作客观描述'
|
||||
}
|
||||
const categoryLabel = (category?: ChatAttachment['category']) => category ? categoryLabels[category] : '图片内容已识别'
|
||||
|
||||
const scrollToBottom = async () => {
|
||||
await nextTick()
|
||||
@@ -214,20 +276,90 @@ const loadAvatar = async () => {
|
||||
document.title = avatar.value?.displayName || avatar.value?.name || '会会数字分身'
|
||||
}
|
||||
|
||||
const sendMessage = async (value: string) => {
|
||||
const question = value.trim()
|
||||
if (!question || sending.value) return
|
||||
lastQuestion.value = question
|
||||
const removePendingImage = (localId: string) => {
|
||||
const target = pendingImages.value.find((image) => image.localId === localId)
|
||||
if (target) {
|
||||
URL.revokeObjectURL(target.previewUrl)
|
||||
previewUrls.delete(target.previewUrl)
|
||||
}
|
||||
pendingImages.value = pendingImages.value.filter((image) => image.localId !== localId)
|
||||
}
|
||||
|
||||
const selectImages = async (event: Event) => {
|
||||
const input = event.target as HTMLInputElement
|
||||
const slots = Math.max(0, 3 - pendingImages.value.length)
|
||||
const files = Array.from(input.files || []).slice(0, slots)
|
||||
input.value = ''
|
||||
for (const file of files) {
|
||||
if (!['image/jpeg', 'image/png', 'image/webp'].includes(file.type)) {
|
||||
errorMessage.value = '仅支持 JPG、PNG、WebP 图片'
|
||||
continue
|
||||
}
|
||||
if (file.size > 8 * 1024 * 1024) {
|
||||
errorMessage.value = '单张图片不能超过 8MB'
|
||||
continue
|
||||
}
|
||||
const previewUrl = URL.createObjectURL(file)
|
||||
previewUrls.add(previewUrl)
|
||||
const localId = `local-${Date.now()}-${Math.random().toString(16).slice(2)}`
|
||||
pendingImages.value.push({
|
||||
id: localId,
|
||||
localId,
|
||||
filename: file.name,
|
||||
previewUrl,
|
||||
status: 'uploading'
|
||||
})
|
||||
errorMessage.value = ''
|
||||
try {
|
||||
const result = isPublic
|
||||
? await uploadPublicAvatarChatImage(shareToken, file)
|
||||
: await uploadAvatarChatImage(avatarId.value, file)
|
||||
const pending = pendingImages.value.find((image) => image.localId === localId)
|
||||
if (!pending) continue
|
||||
Object.assign(pending, {
|
||||
id: result.id,
|
||||
attachmentId: result.id,
|
||||
status: 'ready',
|
||||
category: result.category,
|
||||
summary: result.summary,
|
||||
warning: result.warning
|
||||
})
|
||||
} catch (error: any) {
|
||||
removePendingImage(localId)
|
||||
errorMessage.value = error?.response?.data?.detail || error?.message || '图片识别失败,请重新选择图片'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const sendMessage = async (value: string, retryAttachments?: MessageAttachment[]) => {
|
||||
const selectedAttachments = retryAttachments || readyPendingImages.value.map((image) => ({
|
||||
id: image.attachmentId || image.id,
|
||||
filename: image.filename,
|
||||
previewUrl: image.previewUrl,
|
||||
category: image.category,
|
||||
summary: image.summary,
|
||||
warning: image.warning
|
||||
}))
|
||||
const question = value.trim() || (selectedAttachments.length ? '请帮我看看这张图片。' : '')
|
||||
if (!question || sending.value || (!retryAttachments && uploadingImage.value)) return
|
||||
const history = messages.value.slice(-10).map(({ role, content, attachments }) => ({
|
||||
role,
|
||||
content,
|
||||
attachmentIds: attachments?.map((attachment) => attachment.id) || []
|
||||
}))
|
||||
lastRequest.value = { question, attachments: selectedAttachments }
|
||||
inputText.value = ''
|
||||
errorMessage.value = ''
|
||||
messages.value.push({ role: 'user', content: question })
|
||||
if (!retryAttachments) pendingImages.value = []
|
||||
messages.value.push({ role: 'user', content: question, attachments: selectedAttachments })
|
||||
sending.value = true
|
||||
thinking.value = true
|
||||
await scrollToBottom()
|
||||
try {
|
||||
const payload = {
|
||||
message: question,
|
||||
history: messages.value.slice(-10).map(({ role, content }) => ({ role, content }))
|
||||
attachmentIds: selectedAttachments.map((attachment) => attachment.id),
|
||||
history
|
||||
}
|
||||
const streamed = createStreamReply()
|
||||
const handlers = {
|
||||
@@ -256,13 +388,17 @@ const sendMessage = async (value: string) => {
|
||||
}
|
||||
|
||||
const retryLast = () => {
|
||||
if (!lastQuestion.value || sending.value) return
|
||||
const last = messages.value[messages.value.length - 1]
|
||||
if (last?.role === 'user') messages.value.pop()
|
||||
sendMessage(lastQuestion.value)
|
||||
if (!lastRequest.value || sending.value) return
|
||||
while (messages.value[messages.value.length - 1]?.role === 'assistant') messages.value.pop()
|
||||
if (messages.value[messages.value.length - 1]?.role === 'user') messages.value.pop()
|
||||
void sendMessage(lastRequest.value.question, lastRequest.value.attachments)
|
||||
}
|
||||
|
||||
onMounted(loadAvatar)
|
||||
onBeforeUnmount(() => {
|
||||
previewUrls.forEach((url) => URL.revokeObjectURL(url))
|
||||
previewUrls.clear()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@@ -275,6 +411,7 @@ onMounted(loadAvatar)
|
||||
.avatar-heading h1 { margin: 0; font-size: 17px; }
|
||||
.online-state { display: flex; align-items: center; gap: 4px; margin-top: 3px; font-size: 11px; opacity: .9; }.online-state i { width: 7px; height: 7px; border-radius: 50%; background: #86EFAC; box-shadow: 0 0 0 2px rgba(255,255,255,.22); }.online-state.training i { background: #FDE68A; }.online-state.inactive i { background: #FDA4AF; }
|
||||
.message-list { min-height: 0; flex: 1 1 auto; width: min(760px, 100%); box-sizing: border-box; margin: 0 auto; padding: 24px 18px 120px; overflow-y: auto; overscroll-behavior: contain; }
|
||||
.chat-page.has-pending-images .message-list { padding-bottom: min(330px, 42vh); }
|
||||
.welcome-card { padding: 28px 20px; text-align: center; background: rgba(255,255,255,.72); border: 1px solid #FFE1C2; border-radius: 22px; box-shadow: 0 10px 28px rgba(181, 99, 35, .08); }
|
||||
.welcome-avatar { width: 64px; height: 64px; display: grid; place-items: center; margin: 0 auto 14px; overflow: hidden; border: 3px solid #fff; border-radius: 50%; background: #FFE4C7; box-shadow: 0 7px 16px rgba(181, 99, 35, .18); font-size: 32px; }.welcome-avatar img { width: 100%; height: 100%; object-fit: cover; }
|
||||
.welcome-card h2 { margin: 0 0 8px; font-size: 20px; }.welcome-description { max-width: 340px; margin: 0 auto; color: #8B6B58; font-size: 14px; line-height: 1.65; }
|
||||
@@ -282,6 +419,10 @@ onMounted(loadAvatar)
|
||||
.message-row.user { justify-content: flex-end; }
|
||||
.message-avatar { flex: 0 0 auto; width: 42px; height: 42px; display: grid; place-items: center; overflow: hidden; border: 2px solid rgba(255,255,255,.9); border-radius: 14px; background: #FFE4C7; box-shadow: 0 3px 10px rgba(96, 52, 21, .12); font-size: 16px; }.message-avatar img { width: 100%; height: 100%; object-fit: cover; }.user-message-face { color: #fff; background: #D97706; }
|
||||
.message-column { max-width: min(78%, 560px); }
|
||||
.message-images { display: grid; grid-template-columns: repeat(2, minmax(0, 150px)); gap: 8px; margin-bottom: 8px; }
|
||||
.message-image-card { margin: 0; overflow: hidden; border: 1px solid #F4D4B8; border-radius: 14px; background: #fff; box-shadow: 0 4px 14px rgba(96, 52, 21, .08); }
|
||||
.message-image-card img { display: block; width: 100%; max-height: 210px; object-fit: cover; }
|
||||
.message-image-card figcaption { padding: 7px 9px; color: #8A5A3B; background: #FFF6ED; font-size: 10px; line-height: 1.45; }
|
||||
.message-bubble { padding: 12px 14px; white-space: pre-wrap; line-height: 1.6; font-size: 15px; border-radius: 4px 16px 16px 16px; background: white; box-shadow: 0 3px 12px rgba(96, 52, 21, .07); }
|
||||
.message-bubble.streaming::after { content: ''; display: inline-block; width: 2px; height: 1.05em; margin-left: 3px; vertical-align: -0.16em; background: currentColor; animation: type-cursor .75s step-end infinite; }
|
||||
.typing-character { display: inline-block; animation: character-in .24s cubic-bezier(.2,.72,.25,1) both; }.typing-character.newline { display: block; height: 0; }
|
||||
@@ -298,7 +439,27 @@ onMounted(loadAvatar)
|
||||
@keyframes type-cursor { 50% { opacity: 0; } }
|
||||
@keyframes character-in { from { opacity: 0; transform: translateY(3px); } to { opacity: 1; transform: translateY(0); } }
|
||||
.chat-error { margin: 4px auto; color: #B42318; font-size: 13px; }.chat-error button { border: 0; background: none; color: #C15F18; cursor: pointer; text-decoration: underline; }
|
||||
.composer { position: fixed; left: 0; right: 0; bottom: 0; display: flex; gap: 10px; padding: 12px max(18px, calc((100vw - 760px) / 2 + 18px)); background: rgba(255,255,255,.92); border-top: 1px solid #F4DCC7; backdrop-filter: blur(12px); }
|
||||
.composer { position: fixed; left: 0; right: 0; bottom: 0; display: flex; flex-direction: column; gap: 9px; padding: 10px max(18px, calc((100vw - 760px) / 2 + 18px)) 12px; background: rgba(255,255,255,.94); border-top: 1px solid #F4DCC7; backdrop-filter: blur(14px); }
|
||||
.composer-row { display: flex; align-items: flex-end; gap: 9px; }
|
||||
.composer textarea { flex: 1; resize: none; min-height: 22px; max-height: 100px; padding: 11px 13px; border: 1px solid #EED8C5; border-radius: 13px; font: inherit; color: #3B2417; outline: none; }.composer textarea:focus { border-color: #F97316; }
|
||||
.image-input { display: none; }
|
||||
.image-btn { flex: 0 0 auto; width: 44px; height: 44px; display: grid; place-items: center; border: 1px solid #EED8C5; border-radius: 13px; color: #C65A11; background: #FFF8F1; cursor: pointer; }
|
||||
.image-btn svg { width: 22px; height: 22px; fill: currentColor; }
|
||||
.image-btn:disabled { opacity: .4; cursor: not-allowed; }
|
||||
.pending-images { display: grid; gap: 7px; }
|
||||
.pending-image { display: grid; grid-template-columns: 48px minmax(0, 1fr) 30px; align-items: center; gap: 9px; min-height: 48px; padding: 6px 8px; border: 1px solid #F1D4BB; border-radius: 14px; background: #FFF9F3; }
|
||||
.pending-image img { width: 48px; height: 48px; object-fit: cover; border-radius: 10px; }
|
||||
.pending-image-copy { min-width: 0; display: flex; flex-direction: column; gap: 2px; }
|
||||
.pending-image-copy strong { overflow: hidden; color: #4B2B19; font-size: 12px; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.pending-image-copy span { color: #9A7159; font-size: 10px; }
|
||||
.pending-image.uploading strong::after { content: ''; display: inline-block; width: 7px; height: 7px; margin-left: 7px; border: 2px solid #F6B889; border-top-color: #F97316; border-radius: 50%; animation: image-spin .7s linear infinite; }
|
||||
.pending-image > button { width: 28px; height: 28px; border: 0; border-radius: 9px; color: #9A7159; background: #F8E8D9; font-size: 19px; cursor: pointer; }
|
||||
.medical-note { margin: 0; padding: 0 2px; color: #9A5A2E; font-size: 10px; line-height: 1.45; }
|
||||
.send-btn { align-self: flex-end; padding: 11px 18px; border: 0; border-radius: 12px; color: white; background: #F97316; cursor: pointer; }.send-btn:disabled { opacity: .45; cursor: not-allowed; }
|
||||
@keyframes image-spin { to { transform: rotate(360deg); } }
|
||||
@media (max-width: 520px) {
|
||||
.message-images { grid-template-columns: minmax(0, 220px); }
|
||||
.message-column { max-width: 80%; }
|
||||
.send-btn { padding-inline: 14px; }
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,12 +1,10 @@
|
||||
<template>
|
||||
<div class="edit-avatar-page">
|
||||
<!-- 顶部导航 -->
|
||||
<header class="page-header">
|
||||
<header v-if="!isEmbedded" class="page-header">
|
||||
<button class="back-btn" @click="goBack">‹</button>
|
||||
<h1 class="page-title">分身微调</h1>
|
||||
<button class="save-btn" :disabled="loading || saving || uploadingPhoto" @click="saveChanges">
|
||||
{{ saving ? '保存中...' : '保存' }}
|
||||
</button>
|
||||
<span class="header-spacer" aria-hidden="true"></span>
|
||||
</header>
|
||||
|
||||
<div v-if="loading" class="status-banner">加载中...</div>
|
||||
@@ -159,6 +157,13 @@
|
||||
{{ deleting ? '删除中...' : '删除数字分身' }}
|
||||
</button>
|
||||
</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>
|
||||
</template>
|
||||
|
||||
@@ -168,11 +173,13 @@ import { useRoute, useRouter } from 'vue-router'
|
||||
import { deleteAvatar as apiDeleteAvatar, getAvatarDetail, updateAvatar, uploadAvatarPhoto } from '@/api'
|
||||
import { useAvatarStore } from '@/store/avatar'
|
||||
import { buildAvatarUpdatePayload, normalizeAvatarEditForm } from '@/utils/avatar-page-data.js'
|
||||
import { isHuihuiEmbeddedMode } from '@/utils/embed-mode'
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
const avatarStore = useAvatarStore()
|
||||
const avatarId = route.params.id as string
|
||||
const isEmbedded = isHuihuiEmbeddedMode()
|
||||
|
||||
// 表单数据
|
||||
const formData = reactive({
|
||||
@@ -288,7 +295,7 @@ onMounted(async () => {
|
||||
.edit-avatar-page {
|
||||
min-height: 100vh;
|
||||
background: #F8F9FA;
|
||||
padding-bottom: 40px;
|
||||
padding-bottom: calc(104px + env(safe-area-inset-bottom));
|
||||
}
|
||||
|
||||
/* 顶部导航 */
|
||||
@@ -331,16 +338,7 @@ onMounted(async () => {
|
||||
color: #B91C1C;
|
||||
}
|
||||
|
||||
.save-btn {
|
||||
background: #F97316;
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 8px 20px;
|
||||
border-radius: 8px;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
}
|
||||
.header-spacer { width: 40px; }
|
||||
|
||||
/* 头像上传 */
|
||||
.photo-section {
|
||||
@@ -580,4 +578,47 @@ onMounted(async () => {
|
||||
background: #EF4444;
|
||||
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>
|
||||
|
||||
@@ -1,15 +1,11 @@
|
||||
<template>
|
||||
<div class="avatar-manage-page">
|
||||
<!-- 顶部导航 -->
|
||||
<header class="page-header">
|
||||
<header v-if="!isEmbedded" class="page-header">
|
||||
<div class="header-left">
|
||||
<button class="back-btn" @click="goBack">‹</button>
|
||||
<h1 class="page-title">数字分身管理</h1>
|
||||
</div>
|
||||
<!-- 右上角创建入口 -->
|
||||
<div class="header-right">
|
||||
<button class="icon-btn" @click="goCreate" title="创建数字分身">➕</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- 用户资料头(会会登录账号的头像 / 昵称) -->
|
||||
@@ -24,26 +20,35 @@
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Token 余额条 -->
|
||||
<section class="token-section">
|
||||
<!-- 积分余额条:仅在 uni-app 原生壳内开放充值购买。 -->
|
||||
<section v-if="SHOW_POINTS_BALANCE_CARD" class="token-section">
|
||||
<div class="token-card">
|
||||
<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-used">累计使用 {{ tokenConsumed.toLocaleString() }}</span>
|
||||
</div>
|
||||
<button class="recharge-btn" @click="goToRecharge">充值</button>
|
||||
<button class="recharge-btn" @click="goToRecharge">充值购买</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 数字分身列表(只放分身相关) -->
|
||||
<section class="avatar-list-section">
|
||||
<div class="section-head">
|
||||
<h3 class="section-title">我的数字分身</h3>
|
||||
<span class="count-badge">{{ avatars.length }}</span>
|
||||
<div class="section-heading-copy">
|
||||
<h3 class="section-title">我的数字分身</h3>
|
||||
<span class="count-badge">{{ avatars.length }}</span>
|
||||
</div>
|
||||
<button class="section-create-btn" type="button" @click="goCreate">
|
||||
<span aria-hidden="true">+</span> 添加分身
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-if="avatars.length" class="avatar-list">
|
||||
<div class="avatar-card" v-for="a in avatars" :key="a.id">
|
||||
<button class="card-edit" @click="goToEdit(a.id)" :aria-label="`编辑${a.displayName || a.name}`">
|
||||
<span aria-hidden="true">✎</span> 编辑
|
||||
</button>
|
||||
<div class="avatar-card-main">
|
||||
<div class="avatar-photo">
|
||||
<img v-if="a.photoUrl" :src="a.photoUrl" alt="" referrerpolicy="no-referrer" class="avatar-img" />
|
||||
@@ -52,13 +57,14 @@
|
||||
<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>
|
||||
<p class="avatar-desc">{{ a.description || '暂无描述' }}</p>
|
||||
<span class="avatar-token-usage">累计使用 {{ avatarTokenUsage(a.id).toLocaleString() }} 积分</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="avatar-actions">
|
||||
<button class="chat-link" @click="goToChat(a.id)"><span>💬</span> 对话</button>
|
||||
<button class="knowledge-link" @click="goToKnowledge(a.id)"><span>📚</span> 知识库</button>
|
||||
<button class="authorization-link" @click="goToAuthorization(a.id)"><span>🔑</span> 授权</button>
|
||||
<button class="share-link" @click="shareAvatar(a)"><span>↗</span> 分享</button>
|
||||
<button class="edit-link" @click="goToEdit(a.id)">编辑</button>
|
||||
<button class="del-link" @click="askDelete(a)" aria-label="删除分身">删除</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -72,88 +78,7 @@
|
||||
|
||||
<p v-if="shareToast" class="share-toast">{{ shareToast }}</p>
|
||||
|
||||
<!-- 分身工具入口 -->
|
||||
<section class="tools-section">
|
||||
<h3 class="section-title">分身工具</h3>
|
||||
<div class="tools-grid">
|
||||
<div class="tool-card" @click="goToKnowledge">
|
||||
<div class="tool-icon">📚</div>
|
||||
<div class="tool-info">
|
||||
<span class="tool-name">知识库管理</span>
|
||||
<span class="tool-desc">上传文档与标准问答</span>
|
||||
</div>
|
||||
<span class="tool-arrow">›</span>
|
||||
</div>
|
||||
<div class="tool-card" @click="goToAvatarCard">
|
||||
<div class="tool-icon">🪪</div>
|
||||
<div class="tool-info">
|
||||
<span class="tool-name">分身名片</span>
|
||||
<span class="tool-desc">生成并分享名片</span>
|
||||
</div>
|
||||
<span class="tool-arrow">›</span>
|
||||
</div>
|
||||
<div class="tool-card" @click="goToAvatarContacts">
|
||||
<div class="tool-icon">🤝</div>
|
||||
<div class="tool-info">
|
||||
<span class="tool-name">分身人脉</span>
|
||||
<span class="tool-desc">管理社交关系</span>
|
||||
</div>
|
||||
<span class="tool-arrow">›</span>
|
||||
</div>
|
||||
<div class="tool-card" @click="goToMyProjects">
|
||||
<div class="tool-icon">📁</div>
|
||||
<div class="tool-info">
|
||||
<span class="tool-name">我的项目</span>
|
||||
<span class="tool-desc">查看参与项目</span>
|
||||
</div>
|
||||
<span class="tool-arrow">›</span>
|
||||
</div>
|
||||
<div class="tool-card" @click="goToCreateOrg">
|
||||
<div class="tool-icon">🏢</div>
|
||||
<div class="tool-info">
|
||||
<span class="tool-name">创建组织</span>
|
||||
<span class="tool-desc">新建组织团队</span>
|
||||
</div>
|
||||
<span class="tool-arrow">›</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 分身动态列表 -->
|
||||
<section class="activities-section">
|
||||
<h3 class="section-title">分身动态</h3>
|
||||
<div class="activity-list" v-if="activities.length > 0">
|
||||
<div class="activity-item" v-for="activity in activities" :key="activity.id">
|
||||
<div class="activity-icon" :class="activity.type">{{ activityIcon(activity.type) }}</div>
|
||||
<div class="activity-content">
|
||||
<p class="activity-text">{{ activity.text }}</p>
|
||||
<span class="activity-time">{{ formatTime(activity.createdAt) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="empty-state" v-else>
|
||||
<span class="empty-icon">📭</span>
|
||||
<p class="empty-text">暂无动态</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 删除确认弹窗 -->
|
||||
<div v-if="showDelete" class="modal-mask" @click.self="cancelDelete">
|
||||
<div class="modal">
|
||||
<div class="modal-icon">⚠️</div>
|
||||
<h3 class="modal-title">删除数字分身</h3>
|
||||
<p class="modal-text">
|
||||
确认删除「{{ pendingDelete?.displayName || pendingDelete?.name }}」?<br />
|
||||
其知识库、问答对、授权等关联数据将一并清除,且<b>不可恢复</b>。
|
||||
</p>
|
||||
<div class="modal-actions">
|
||||
<button class="modal-cancel" @click="cancelDelete">取消</button>
|
||||
<button class="modal-confirm" :disabled="deleting" @click="confirmDelete">
|
||||
{{ deleting ? '删除中...' : '确认删除' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 分身工具和分身动态暂不展示,入口统一收拢到每张分身卡片。 -->
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -163,30 +88,28 @@ import { useRouter } from 'vue-router'
|
||||
import { useAvatarStore } from '@/store/avatar'
|
||||
import { useUserStore } from '@/store/user'
|
||||
import { createAvatarShareLink } from '@/api'
|
||||
import { isHuihuiEmbeddedMode } from '@/utils/embed-mode'
|
||||
import { isInUniWebView } from '@/utils/uniapp-bridge'
|
||||
|
||||
const router = useRouter()
|
||||
const avatarStore = useAvatarStore()
|
||||
const userStore = useUserStore()
|
||||
const isEmbedded = isHuihuiEmbeddedMode()
|
||||
|
||||
// 充值购买只在 uni-app 原生壳内提供,避免普通 H5 进入支付链路。
|
||||
const SHOW_POINTS_BALANCE_CARD = isInUniWebView()
|
||||
|
||||
// 当前登录会会用户的资料(头像 / 昵称)
|
||||
const me = computed(() => userStore.user)
|
||||
|
||||
// 状态(来自 store / 后端)
|
||||
const tokenBalance = computed(() => avatarStore.tokenBalance)
|
||||
const tokenConsumed = computed(() => avatarStore.tokenConsumed)
|
||||
const avatars = computed(() => avatarStore.avatars)
|
||||
const avatarTokenUsage = (id: string) => avatarStore.tokenUsageByAvatar[id] || 0
|
||||
|
||||
// 删除确认弹窗状态
|
||||
const showDelete = ref(false)
|
||||
const pendingDelete = ref<any>(null)
|
||||
const deleting = ref(false)
|
||||
const shareToast = ref('')
|
||||
|
||||
const activities = ref<Array<{ id: string; type: string; text: string; createdAt: string }>>([
|
||||
{ id: '1', type: 'create', text: '数字分身创建成功', createdAt: new Date(Date.now() - 86400000).toISOString() },
|
||||
{ id: '2', type: 'edit', text: '更新了分身描述', createdAt: new Date(Date.now() - 43200000).toISOString() },
|
||||
{ id: '3', type: 'authorize', text: '授权微信小程序访问', createdAt: new Date(Date.now() - 3600000).toISOString() }
|
||||
])
|
||||
|
||||
// 状态文本
|
||||
const statusText = (status: string) => {
|
||||
const map: Record<string, string> = {
|
||||
@@ -197,55 +120,6 @@ const statusText = (status: string) => {
|
||||
return map[status] || status
|
||||
}
|
||||
|
||||
// 活动图标
|
||||
const activityIcon = (type: string) => {
|
||||
const map: Record<string, string> = {
|
||||
'create': '✨',
|
||||
'edit': '✏️',
|
||||
'authorize': '🔑',
|
||||
'interact': '💬'
|
||||
}
|
||||
return map[type] || '📌'
|
||||
}
|
||||
|
||||
// 格式化时间
|
||||
const formatTime = (time: string) => {
|
||||
const date = new Date(time)
|
||||
const now = new Date()
|
||||
const diff = now.getTime() - date.getTime()
|
||||
const minutes = Math.floor(diff / 60000)
|
||||
const hours = Math.floor(diff / 3600000)
|
||||
const days = Math.floor(diff / 86400000)
|
||||
|
||||
if (minutes < 60) return `${minutes}分钟前`
|
||||
if (hours < 24) return `${hours}小时前`
|
||||
return `${days}天前`
|
||||
}
|
||||
|
||||
// 删除流程
|
||||
const askDelete = (a: any) => {
|
||||
pendingDelete.value = a
|
||||
showDelete.value = true
|
||||
}
|
||||
const cancelDelete = () => {
|
||||
if (deleting.value) return
|
||||
showDelete.value = false
|
||||
pendingDelete.value = null
|
||||
}
|
||||
const confirmDelete = async () => {
|
||||
if (!pendingDelete.value || deleting.value) return
|
||||
deleting.value = true
|
||||
try {
|
||||
await avatarStore.removeAvatar(pendingDelete.value.id)
|
||||
showDelete.value = false
|
||||
pendingDelete.value = null
|
||||
} catch (e: any) {
|
||||
alert(e?.message || '删除失败,请稍后重试')
|
||||
} finally {
|
||||
deleting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 导航
|
||||
const goBack = () => {
|
||||
router.back()
|
||||
@@ -259,8 +133,12 @@ const goCreate = () => {
|
||||
router.push('/avatar/create')
|
||||
}
|
||||
|
||||
const goToKnowledge = () => {
|
||||
router.push('/knowledge')
|
||||
const goToKnowledge = (id: string) => {
|
||||
router.push({ name: 'AvatarKnowledgeManage', params: { avatarId: id } })
|
||||
}
|
||||
|
||||
const goToAuthorization = (id: string) => {
|
||||
router.push({ name: 'AvatarAuthorizationManage', params: { avatarId: id } })
|
||||
}
|
||||
|
||||
const goToEdit = (id: string) => {
|
||||
@@ -303,26 +181,11 @@ const shareAvatar = async (avatar: any) => {
|
||||
}
|
||||
}
|
||||
|
||||
const goToAvatarCard = () => {
|
||||
router.push('/avatar/card')
|
||||
}
|
||||
|
||||
const goToAvatarContacts = () => {
|
||||
router.push('/avatar/contacts')
|
||||
}
|
||||
|
||||
const goToMyProjects = () => {
|
||||
router.push('/avatar/projects')
|
||||
}
|
||||
|
||||
const goToCreateOrg = () => {
|
||||
router.push('/avatar/org/create')
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
userStore.loadFromStorage()
|
||||
avatarStore.loadAvatars()
|
||||
avatarStore.loadTokenBalance()
|
||||
avatarStore.loadTokenUsage()
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -330,7 +193,7 @@ onMounted(() => {
|
||||
.avatar-manage-page {
|
||||
min-height: 100vh;
|
||||
background: #F8F9FA;
|
||||
padding-bottom: 80px;
|
||||
padding-bottom: calc(28px + env(safe-area-inset-bottom));
|
||||
}
|
||||
|
||||
/* 顶部导航 */
|
||||
@@ -433,7 +296,7 @@ onMounted(() => {
|
||||
background: rgba(255, 255, 255, 0.3);
|
||||
}
|
||||
|
||||
/* Token 余额条 */
|
||||
/* 积分余额条 */
|
||||
.token-section {
|
||||
padding: 16px 20px;
|
||||
}
|
||||
@@ -465,6 +328,12 @@ onMounted(() => {
|
||||
color: #F97316;
|
||||
}
|
||||
|
||||
.token-used {
|
||||
margin-top: 3px;
|
||||
color: #A0A5B4;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.recharge-btn {
|
||||
padding: 8px 16px;
|
||||
background: #F97316;
|
||||
@@ -489,10 +358,36 @@ onMounted(() => {
|
||||
.section-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
justify-content: space-between;
|
||||
gap: 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 {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
@@ -516,13 +411,32 @@ onMounted(() => {
|
||||
}
|
||||
|
||||
.avatar-card {
|
||||
position: relative;
|
||||
padding: 16px;
|
||||
background: white;
|
||||
border: 1px solid #F4E5D9;
|
||||
border-radius: 18px;
|
||||
box-shadow: 0 8px 22px rgba(112, 62, 22, .07);
|
||||
}
|
||||
.avatar-card-main { display: flex; align-items: center; gap: 14px; }
|
||||
.avatar-card-main { display: flex; align-items: center; gap: 14px; padding: 4px 68px 2px 0; }
|
||||
|
||||
.card-edit {
|
||||
position: absolute;
|
||||
top: 12px;
|
||||
right: 12px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 7px 10px;
|
||||
border: 1px solid #E9E2DC;
|
||||
border-radius: 999px;
|
||||
color: #6B625C;
|
||||
background: #FAF8F6;
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.card-edit:active { background: #F2EDE8; }
|
||||
|
||||
.avatar-photo {
|
||||
width: 66px;
|
||||
@@ -570,6 +484,13 @@ onMounted(() => {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.avatar-token-usage {
|
||||
display: inline-block;
|
||||
margin-top: 5px;
|
||||
color: #A0A5B4;
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.avatar-status {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
@@ -598,56 +519,39 @@ onMounted(() => {
|
||||
}
|
||||
|
||||
.avatar-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 8px;
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.avatar-actions button {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 4px;
|
||||
min-width: 0;
|
||||
min-height: 58px;
|
||||
padding: 8px 3px;
|
||||
border-radius: 12px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.avatar-actions button span { font-size: 17px; line-height: 1; }
|
||||
|
||||
.chat-link {
|
||||
flex: 1;
|
||||
padding: 10px 8px;
|
||||
background: linear-gradient(135deg, #F97316, #FB923C);
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: 10px;
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.chat-link span, .share-link span { margin-right: 3px; }
|
||||
.share-link { flex: 1; padding: 10px 8px; border: 1px solid #FFD5AF; border-radius: 10px; color: #C15F18; background: #FFF8F1; font-size: 13px; cursor: pointer; }
|
||||
.knowledge-link { border: 1px solid #F5D7B8; color: #A85418; background: #FFF8F1; }
|
||||
.authorization-link { border: 1px solid #DCE5F2; color: #47627D; background: #F6F9FC; }
|
||||
.share-link { border: 1px solid #E5E1DD; color: #675E58; background: #FAF8F6; }
|
||||
|
||||
.edit-link {
|
||||
padding: 10px 10px;
|
||||
background: #F3F4F6;
|
||||
color: #6B7280;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.edit-link:hover {
|
||||
background: #E5E7EB;
|
||||
}
|
||||
|
||||
.del-link {
|
||||
padding: 10px 2px;
|
||||
background: transparent;
|
||||
color: #B6BCC8;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.share-toast { position: fixed; left: 50%; bottom: 92px; z-index: 300; max-width: calc(100vw - 48px); transform: translateX(-50%); padding: 10px 14px; border-radius: 10px; color: white; background: rgba(39, 32, 28, .88); font-size: 13px; text-align: center; }
|
||||
|
||||
.del-link:hover {
|
||||
background: #FEE2E2;
|
||||
}
|
||||
.share-toast { position: fixed; left: 50%; bottom: 24px; z-index: 300; max-width: calc(100vw - 48px); transform: translateX(-50%); padding: 10px 14px; border-radius: 10px; color: white; background: rgba(39, 32, 28, .88); font-size: 13px; text-align: center; }
|
||||
|
||||
/* 空状态 */
|
||||
.empty-state {
|
||||
@@ -684,197 +588,4 @@ onMounted(() => {
|
||||
box-shadow: 0 4px 12px rgba(249, 115, 22, 0.3);
|
||||
}
|
||||
|
||||
/* 分身工具入口 */
|
||||
.tools-section {
|
||||
padding: 8px 20px 16px;
|
||||
}
|
||||
|
||||
.tools-grid {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.tool-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 14px 16px;
|
||||
background: white;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05);
|
||||
cursor: pointer;
|
||||
transition: transform 0.2s, box-shadow 0.2s;
|
||||
}
|
||||
|
||||
.tool-card:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.tool-icon {
|
||||
font-size: 24px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.tool-info {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.tool-name {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: #18191C;
|
||||
}
|
||||
|
||||
.tool-desc {
|
||||
font-size: 12px;
|
||||
color: #9398AE;
|
||||
}
|
||||
|
||||
.tool-arrow {
|
||||
font-size: 18px;
|
||||
color: #C9CDD2;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* 分身动态列表 */
|
||||
.activities-section {
|
||||
padding: 0 20px;
|
||||
}
|
||||
|
||||
.activity-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.activity-item {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
padding: 14px 16px;
|
||||
background: white;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
.activity-icon {
|
||||
font-size: 20px;
|
||||
flex-shrink: 0;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 10px;
|
||||
background: #FFF0E6;
|
||||
}
|
||||
|
||||
.activity-content {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.activity-text {
|
||||
font-size: 14px;
|
||||
color: #18191C;
|
||||
margin: 0 0 4px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.activity-time {
|
||||
font-size: 12px;
|
||||
color: #9398AE;
|
||||
}
|
||||
|
||||
/* 删除确认弹窗 */
|
||||
.modal-mask {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.45);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 24px;
|
||||
z-index: 50;
|
||||
animation: fade 0.2s ease;
|
||||
}
|
||||
|
||||
@keyframes fade { from { opacity: 0; } to { opacity: 1; } }
|
||||
|
||||
.modal {
|
||||
width: 100%;
|
||||
max-width: 320px;
|
||||
background: white;
|
||||
border-radius: 18px;
|
||||
padding: 24px 22px 18px;
|
||||
text-align: center;
|
||||
box-shadow: 0 20px 50px rgba(0, 0, 0, 0.25);
|
||||
animation: pop 0.25s cubic-bezier(0.16, 1, 0.3, 1);
|
||||
}
|
||||
|
||||
@keyframes pop { from { opacity: 0; transform: scale(0.94); } to { opacity: 1; transform: none; } }
|
||||
|
||||
.modal-icon {
|
||||
font-size: 36px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.modal-title {
|
||||
font-size: 17px;
|
||||
font-weight: 700;
|
||||
color: #18191C;
|
||||
margin: 0 0 10px;
|
||||
}
|
||||
|
||||
.modal-text {
|
||||
font-size: 13px;
|
||||
line-height: 1.6;
|
||||
color: #6B7280;
|
||||
margin: 0 0 20px;
|
||||
}
|
||||
|
||||
.modal-actions {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.modal-cancel {
|
||||
flex: 1;
|
||||
padding: 12px;
|
||||
background: #F3F4F6;
|
||||
color: #6B7280;
|
||||
border: none;
|
||||
border-radius: 12px;
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.modal-confirm {
|
||||
flex: 1;
|
||||
padding: 12px;
|
||||
background: #EF4444;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 12px;
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
|
||||
.modal-confirm:hover {
|
||||
opacity: 0.92;
|
||||
}
|
||||
|
||||
.modal-confirm:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<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">
|
||||
<button class="back-btn" @click="goBack">‹</button>
|
||||
<h1 class="page-title">知识库管理</h1>
|
||||
@@ -15,7 +15,7 @@
|
||||
|
||||
<template v-else>
|
||||
<div class="tab-switcher" role="tablist" aria-label="知识库类型">
|
||||
<button class="tab-btn" :class="{ active: activeTab === 'docs' }" role="tab" :aria-selected="activeTab === 'docs'" @click="activeTab = 'docs'">文档知识库 <b>{{ docs.length }}</b></button>
|
||||
<button class="tab-btn" :class="{ active: activeTab === 'docs' }" role="tab" :aria-selected="activeTab === 'docs'" @click="activeTab = 'docs'">文档知识库 <b>{{ displayDocs.length }}</b></button>
|
||||
<button class="tab-btn" :class="{ active: activeTab === 'qa' }" role="tab" :aria-selected="activeTab === 'qa'" @click="activeTab = 'qa'">标准问答对 <b>{{ qaPairs.length }}</b></button>
|
||||
</div>
|
||||
|
||||
@@ -23,26 +23,37 @@
|
||||
<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-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>
|
||||
<input ref="fileInput" type="file" accept=".md,.txt,.pdf,.doc,.docx,.xlsx" class="hidden-input" @change="onFileChange" />
|
||||
<input ref="fileInput" type="file" multiple accept=".md,.txt,.pdf,.doc,.docx,.xlsx" class="hidden-input" @change="onFileChange" />
|
||||
</div>
|
||||
<p v-if="uploading" class="uploading-text">上传并向量化中…</p>
|
||||
<p v-if="uploading" class="uploading-text">{{ pendingUploads.length }} 个文件正在上传</p>
|
||||
<p v-if="uploadError" class="error-text">{{ uploadError }}</p>
|
||||
</div>
|
||||
|
||||
<div v-if="docs.length" class="mobile-card-list">
|
||||
<article v-for="doc in docs" :key="doc.id" class="knowledge-card">
|
||||
<div v-if="displayDocs.length" class="mobile-card-list">
|
||||
<article v-for="doc in displayDocs" :key="doc.id" class="knowledge-card document-card">
|
||||
<div class="card-icon">{{ fileEmoji(doc.fileType) }}</div>
|
||||
<div class="card-content">
|
||||
<div class="card-title-row">
|
||||
<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>
|
||||
<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 v-if="documentState(doc).progress !== undefined" class="progress-track" :aria-label="`${documentState(doc).label} ${documentState(doc).progress}%`">
|
||||
<span class="progress-fill" :style="{ width: `${documentState(doc).progress}%` }"></span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-actions">
|
||||
<button v-if="!doc.localUploading" class="card-delete" @click="removeDoc(doc.id)">{{ doc.localOnly ? '移除' : '删除' }}</button>
|
||||
</div>
|
||||
<div v-if="canRetryDoc(doc)" class="card-retry-area">
|
||||
<span v-if="retryErrors[doc.id]" class="card-retry-error">{{ retryErrors[doc.id] }}</span>
|
||||
<button class="card-retry" :disabled="retryingDocs[doc.id]" @click="retryDoc(doc)">
|
||||
{{ retryingDocs[doc.id] ? '重新索引中…' : '重新索引' }}
|
||||
</button>
|
||||
</div>
|
||||
<button class="card-delete" @click="removeDoc(doc.id)">删除</button>
|
||||
</article>
|
||||
</div>
|
||||
<div v-else class="card-empty">📂 暂无文档,先上传一个知识文件</div>
|
||||
@@ -78,14 +89,16 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, computed } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { ref, onMounted, onUnmounted, computed } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { useAvatarStore } from '@/store/avatar'
|
||||
import { pickAvatarId, unwrapListData } from '@/utils/avatar-page-data.js'
|
||||
import { pickScopedAvatarId, unwrapListData } from '@/utils/avatar-page-data.js'
|
||||
import { isHuihuiEmbeddedMode } from '@/utils/embed-mode'
|
||||
import {
|
||||
getKnowledgeDocs,
|
||||
uploadKnowledgeDoc,
|
||||
deleteKnowledgeDoc,
|
||||
retryKnowledgeDoc,
|
||||
getQAPairs,
|
||||
deleteQAPair,
|
||||
searchKnowledge,
|
||||
@@ -93,28 +106,80 @@ import {
|
||||
} from '@/api'
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
const store = useAvatarStore()
|
||||
const isEmbedded = isHuihuiEmbeddedMode()
|
||||
|
||||
const avatarId = computed(() => pickAvatarId(store.currentAvatarId, store.avatars))
|
||||
const avatarId = computed(() => pickScopedAvatarId(route.params.avatarId, store.currentAvatarId, store.avatars))
|
||||
const activeTab = ref<'docs' | 'qa'>('docs')
|
||||
|
||||
const docs = ref<any[]>([])
|
||||
const pendingUploads = ref<any[]>([])
|
||||
const qaPairs = ref<any[]>([])
|
||||
const uploading = ref(false)
|
||||
const uploading = computed(() => pendingUploads.value.some((doc) => doc.localUploading))
|
||||
const uploadError = ref('')
|
||||
const dragOver = ref(false)
|
||||
const fileInput = ref<HTMLInputElement | null>(null)
|
||||
const retryingDocs = ref<Record<string, boolean>>({})
|
||||
const retryErrors = ref<Record<string, string>>({})
|
||||
let documentPollingTimer: ReturnType<typeof setInterval> | undefined
|
||||
|
||||
const query = ref('')
|
||||
const searching = ref(false)
|
||||
const searched = ref(false)
|
||||
const searchResults = ref<any[]>([])
|
||||
|
||||
const displayDocs = computed(() => [...pendingUploads.value, ...docs.value])
|
||||
|
||||
const documentState = (doc: any) => {
|
||||
if (doc.localUploading) {
|
||||
return { tone: 'pending', label: '上传中', detail: `正在上传 ${doc.uploadProgress || 0}%`, progress: doc.uploadProgress || 0 }
|
||||
}
|
||||
if (doc.localOnly) {
|
||||
return { tone: 'failed', label: '上传失败', detail: doc.errorMessage || '文件未上传成功,请移除后重试' }
|
||||
}
|
||||
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())) {
|
||||
const stage = String(doc.indexStage || 'queued').toLowerCase()
|
||||
const labels: Record<string, string> = {
|
||||
queued: '等待处理', extracting: '解析文档', ocr: '扫描件识别', chunking: '切分文本', embedding: '向量化中'
|
||||
}
|
||||
const progress = Math.max(0, Math.min(99, Number(doc.indexProgress || 0)))
|
||||
return { tone: 'pending', label: labels[stage] || '处理中', detail: `${labels[stage] || '正在建立知识索引'} ${progress}%`, progress }
|
||||
}
|
||||
return { tone: 'failed', label: '处理失败', detail: doc.errorMessage || '未能建立知识索引,请重新索引或重新上传' }
|
||||
}
|
||||
|
||||
const hasPendingDocuments = () => docs.value.some((doc) =>
|
||||
['uploaded', 'parsing'].includes(String(doc.status || '').toLowerCase())
|
||||
)
|
||||
|
||||
const stopDocumentPolling = () => {
|
||||
if (documentPollingTimer) {
|
||||
clearInterval(documentPollingTimer)
|
||||
documentPollingTimer = undefined
|
||||
}
|
||||
}
|
||||
|
||||
const startDocumentPolling = () => {
|
||||
if (documentPollingTimer || !hasPendingDocuments()) return
|
||||
documentPollingTimer = setInterval(async () => {
|
||||
await loadDocs()
|
||||
if (!hasPendingDocuments()) stopDocumentPolling()
|
||||
}, 2000)
|
||||
}
|
||||
|
||||
const loadDocs = async () => {
|
||||
if (!avatarId.value) return
|
||||
try {
|
||||
const res: any = await getKnowledgeDocs(avatarId.value)
|
||||
docs.value = unwrapListData(res)
|
||||
startDocumentPolling()
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
}
|
||||
@@ -133,40 +198,92 @@ const loadQA = async () => {
|
||||
const triggerFile = () => fileInput.value?.click()
|
||||
|
||||
const onFileChange = (e: Event) => {
|
||||
const f = (e.target as HTMLInputElement).files?.[0]
|
||||
if (f) doUpload(f)
|
||||
const files = Array.from((e.target as HTMLInputElement).files || [])
|
||||
if (files.length) uploadFiles(files)
|
||||
;(e.target as HTMLInputElement).value = ''
|
||||
}
|
||||
|
||||
const onDrop = (e: DragEvent) => {
|
||||
dragOver.value = false
|
||||
const f = e.dataTransfer?.files?.[0]
|
||||
if (f) doUpload(f)
|
||||
const files = Array.from(e.dataTransfer?.files || [])
|
||||
if (files.length) uploadFiles(files)
|
||||
}
|
||||
|
||||
const doUpload = async (file: File) => {
|
||||
const uploadFiles = (files: File[]) => {
|
||||
uploadError.value = ''
|
||||
const ext = '.' + (file.name.split('.').pop() || '').toLowerCase()
|
||||
if (!['.md', '.txt', '.pdf', '.doc', '.docx', '.xlsx'].includes(ext)) {
|
||||
uploadError.value = `不支持的类型:${ext},仅支持 md/txt/pdf/doc/docx/xlsx`
|
||||
return
|
||||
}
|
||||
if (!avatarId.value) {
|
||||
uploadError.value = '请先创建数字分身'
|
||||
return
|
||||
}
|
||||
uploading.value = true
|
||||
for (const file of files) {
|
||||
const ext = '.' + (file.name.split('.').pop() || '').toLowerCase()
|
||||
if (!['.md', '.txt', '.pdf', '.doc', '.docx', '.xlsx'].includes(ext)) {
|
||||
uploadError.value = `不支持的类型:${ext},仅支持 md/txt/pdf/doc/docx/xlsx`
|
||||
continue
|
||||
}
|
||||
void uploadOne(file, ext)
|
||||
}
|
||||
}
|
||||
|
||||
const uploadOne = async (file: File, ext: string) => {
|
||||
if (!avatarId.value) return
|
||||
const localId = `upload-${Date.now()}-${Math.random().toString(16).slice(2)}`
|
||||
const card = {
|
||||
id: localId,
|
||||
filename: file.name,
|
||||
fileType: ext.slice(1),
|
||||
fileSize: file.size,
|
||||
createdAt: new Date().toISOString(),
|
||||
localUploading: true,
|
||||
localOnly: true,
|
||||
uploadProgress: 0,
|
||||
errorMessage: ''
|
||||
}
|
||||
pendingUploads.value.unshift(card)
|
||||
try {
|
||||
await uploadKnowledgeDoc(avatarId.value, file)
|
||||
await loadDocs()
|
||||
const created: any = await uploadKnowledgeDoc(avatarId.value, file, (loaded, total) => {
|
||||
const current = pendingUploads.value.find((doc) => doc.id === localId)
|
||||
if (current) current.uploadProgress = Math.min(99, Math.round((loaded / Math.max(1, total)) * 100))
|
||||
})
|
||||
pendingUploads.value = pendingUploads.value.filter((doc) => doc.id !== localId)
|
||||
docs.value = [created, ...docs.value.filter((doc) => doc.id !== created.id)]
|
||||
startDocumentPolling()
|
||||
} catch (e: any) {
|
||||
uploadError.value = e?.message || '上传失败'
|
||||
const current = pendingUploads.value.find((doc) => doc.id === localId)
|
||||
if (current) {
|
||||
current.localUploading = false
|
||||
current.errorMessage = e?.message || '上传失败'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const canRetryDoc = (doc: any) =>
|
||||
!doc.localOnly && doc.filePresent !== false && documentState(doc).tone === 'failed'
|
||||
|
||||
const retryDoc = async (doc: any) => {
|
||||
if (!avatarId.value || !canRetryDoc(doc) || retryingDocs.value[doc.id]) return
|
||||
retryingDocs.value = { ...retryingDocs.value, [doc.id]: true }
|
||||
retryErrors.value = { ...retryErrors.value, [doc.id]: '' }
|
||||
try {
|
||||
const updated: any = await retryKnowledgeDoc(avatarId.value, doc.id)
|
||||
Object.assign(doc, updated)
|
||||
startDocumentPolling()
|
||||
} catch (e: any) {
|
||||
retryErrors.value = {
|
||||
...retryErrors.value,
|
||||
[doc.id]: e?.response?.data?.message || e?.response?.data?.detail || e?.message || '重新索引失败'
|
||||
}
|
||||
} finally {
|
||||
uploading.value = false
|
||||
retryingDocs.value = { ...retryingDocs.value, [doc.id]: false }
|
||||
}
|
||||
}
|
||||
|
||||
const removeDoc = async (id: string) => {
|
||||
const local = pendingUploads.value.find((doc) => doc.id === id)
|
||||
if (local?.localOnly) {
|
||||
pendingUploads.value = pendingUploads.value.filter((doc) => doc.id !== id)
|
||||
return
|
||||
}
|
||||
if (!avatarId.value) return
|
||||
await deleteKnowledgeDoc(avatarId.value, id)
|
||||
await loadDocs()
|
||||
@@ -200,9 +317,21 @@ const toggleQa = async (qa: any, e: Event) => {
|
||||
}
|
||||
}
|
||||
|
||||
const goAddQa = () => router.push('/knowledge/qa/create')
|
||||
const goAddQa = () => {
|
||||
if (route.params.avatarId && avatarId.value) {
|
||||
router.push({ name: 'AvatarQaPairCreate', params: { avatarId: avatarId.value } })
|
||||
return
|
||||
}
|
||||
router.push('/knowledge/qa/create')
|
||||
}
|
||||
|
||||
const goEditQa = (qa: any) => router.push(`/knowledge/qa/${qa.id}/edit`)
|
||||
const goEditQa = (qa: any) => {
|
||||
if (route.params.avatarId && avatarId.value) {
|
||||
router.push({ name: 'AvatarQaPairEdit', params: { avatarId: avatarId.value, qaId: qa.id } })
|
||||
return
|
||||
}
|
||||
router.push(`/knowledge/qa/${qa.id}/edit`)
|
||||
}
|
||||
|
||||
const removeQa = async (id: string) => {
|
||||
if (!avatarId.value) return
|
||||
@@ -226,15 +355,18 @@ onMounted(async () => {
|
||||
if (!store.avatars.length) {
|
||||
await store.loadAvatars()
|
||||
}
|
||||
if (avatarId.value) store.currentAvatarId = avatarId.value
|
||||
await Promise.all([loadDocs(), loadQA()])
|
||||
})
|
||||
|
||||
onUnmounted(stopDocumentPolling)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.knowledge-page {
|
||||
min-height: 100vh;
|
||||
background: #F8F9FA;
|
||||
padding-bottom: 80px;
|
||||
padding-bottom: calc(28px + env(safe-area-inset-bottom));
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
@@ -263,16 +395,31 @@ onMounted(async () => {
|
||||
.panel-heading p { margin: -5px 0 0; color: #9398AE; font-size: 12px; }
|
||||
.mobile-card-list { display: grid; grid-template-columns: minmax(0, 1fr); width: 100%; min-width: 0; gap: 10px; }
|
||||
.knowledge-card { display: flex; align-items: center; width: 100%; min-width: 0; box-sizing: border-box; gap: 11px; padding: 14px; background: #fff; border: 1px solid #F1E1D3; border-radius: 16px; box-shadow: 0 5px 16px rgba(112, 62, 22, .04); }
|
||||
.document-card { display: grid; grid-template-columns: 42px minmax(0, 1fr) auto; align-items: center; }
|
||||
.card-icon { flex: 0 0 auto; width: 42px; height: 42px; display: grid; place-items: center; border-radius: 13px; background: #FFF3E6; font-size: 22px; }
|
||||
.card-content { min-width: 0; flex: 1; overflow: hidden; }
|
||||
.card-title-row { display: flex; align-items: center; gap: 8px; min-width: 0; }
|
||||
.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.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-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; }
|
||||
.progress-track { width: 100%; height: 4px; margin-top: 8px; overflow: hidden; border-radius: 999px; background: #FDE7D1; }
|
||||
.progress-fill { display: block; height: 100%; border-radius: inherit; background: linear-gradient(90deg, #FB923C, #F97316); transition: width .25s ease; }
|
||||
.card-actions { flex: 0 0 auto; display: flex; align-items: center; }
|
||||
.card-delete, .card-retry { align-self: center; border: 0; border-radius: 8px; padding: 7px 9px; font-size: 12px; cursor: pointer; white-space: nowrap; }
|
||||
.card-delete { color: #EF4444; background: #FEF2F2; }
|
||||
.card-retry { color: #C15F18; background: #FFF3E6; }
|
||||
.card-retry:disabled { cursor: wait; opacity: .65; }
|
||||
.card-retry-area { grid-column: 1 / -1; display: flex; align-items: center; justify-content: flex-end; gap: 10px; min-width: 0; }
|
||||
.card-retry-error { min-width: 0; overflow: hidden; color: #DC2626; font-size: 11px; line-height: 1.35; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.card-empty { padding: 42px 16px; border: 1px dashed #F1D9C3; border-radius: 16px; color: #9398AE; background: #fff; font-size: 14px; text-align: center; }
|
||||
.qa-card { align-items: stretch; }.qa-card.qa-disabled { opacity: .58; }
|
||||
.qa-card { align-items: stretch; text-align: left; }.qa-card.qa-disabled { opacity: .58; }
|
||||
.qa-card .card-content,
|
||||
.qa-card .qa-question,
|
||||
.qa-card .qa-answer,
|
||||
.qa-card .card-meta,
|
||||
.qa-card .qa-card-actions { text-align: left; }
|
||||
.qa-card-head { display: flex; align-items: center; justify-content: space-between; margin-bottom: 8px; }.qa-label { color: #C15F18; font-size: 11px; font-weight: 700; }
|
||||
.qa-question { display: block; color: #27201C; font-size: 15px; line-height: 1.5; }.qa-answer { display: -webkit-box; margin: 7px 0 0; overflow: hidden; color: #6B7280; font-size: 13px; line-height: 1.55; -webkit-box-orient: vertical; -webkit-line-clamp: 3; }
|
||||
.qa-card-actions { display: flex; gap: 8px; margin-top: 11px; }
|
||||
@@ -491,8 +638,13 @@ onMounted(async () => {
|
||||
@media (max-width: 520px) {
|
||||
.knowledge-panel { padding: 0 12px; }
|
||||
.knowledge-card { display: grid; grid-template-columns: 42px minmax(0, 1fr); align-items: start; gap: 10px; padding: 13px; }
|
||||
.document-card { grid-template-columns: 42px minmax(0, 1fr) auto; }
|
||||
.card-content { grid-column: 2; }
|
||||
.card-delete { grid-column: 2; justify-self: end; margin-top: -2px; }
|
||||
.card-actions { grid-column: 3; grid-row: 1; }
|
||||
.card-delete { justify-self: end; margin-top: -2px; }
|
||||
.card-retry-area { grid-column: 1 / -1; }
|
||||
.qa-card { display: block; }
|
||||
.qa-card .card-content { width: 100%; grid-column: 1; }
|
||||
.card-title-row { align-items: flex-start; flex-wrap: wrap; gap: 5px 7px; }
|
||||
.status-pill { order: 2; }
|
||||
.search-bar { gap: 8px; }
|
||||
|
||||
@@ -10,10 +10,12 @@
|
||||
<section class="form-section">
|
||||
<label class="field-label">问题</label>
|
||||
<textarea
|
||||
ref="questionInput"
|
||||
v-model="form.question"
|
||||
class="field-input"
|
||||
rows="3"
|
||||
class="field-input question-input"
|
||||
rows="1"
|
||||
placeholder="例如:你们的退款政策是什么?"
|
||||
@input="resizeQuestion"
|
||||
></textarea>
|
||||
|
||||
<label class="field-label">标准答案</label>
|
||||
@@ -46,23 +48,31 @@
|
||||
</template>
|
||||
|
||||
<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 { useAvatarStore } from '@/store/avatar'
|
||||
import { pickAvatarId, unwrapListData } from '@/utils/avatar-page-data.js'
|
||||
import { pickScopedAvatarId, unwrapListData } from '@/utils/avatar-page-data.js'
|
||||
import { getQAPairs, createQAPair, updateQAPair } from '@/api'
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
const store = useAvatarStore()
|
||||
|
||||
const avatarId = computed(() => pickAvatarId(store.currentAvatarId, store.avatars))
|
||||
const avatarId = computed(() => pickScopedAvatarId(route.params.avatarId, store.currentAvatarId, store.avatars))
|
||||
const qaId = computed(() => (route.params.qaId as string) || null)
|
||||
const isEdit = computed(() => !!qaId.value)
|
||||
|
||||
const form = reactive({ question: '', answer: '', enabled: true })
|
||||
const saving = ref(false)
|
||||
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()
|
||||
|
||||
@@ -104,8 +114,11 @@ const save = async () => {
|
||||
} else {
|
||||
await createQAPair(avatarId.value, payload)
|
||||
}
|
||||
// 保存成功返回知识库管理页
|
||||
router.replace('/knowledge')
|
||||
if (route.params.avatarId) {
|
||||
router.replace({ name: 'AvatarKnowledgeManage', params: { avatarId: avatarId.value } })
|
||||
} else {
|
||||
router.replace('/knowledge')
|
||||
}
|
||||
} catch (e: any) {
|
||||
error.value = e?.message || '保存失败'
|
||||
} finally {
|
||||
@@ -117,9 +130,12 @@ onMounted(async () => {
|
||||
if (!store.avatars.length) {
|
||||
await store.loadAvatars()
|
||||
}
|
||||
if (avatarId.value) store.currentAvatarId = avatarId.value
|
||||
if (isEdit.value) {
|
||||
await loadForEdit()
|
||||
}
|
||||
await nextTick()
|
||||
resizeQuestion()
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -190,6 +206,13 @@ onMounted(async () => {
|
||||
border-color: #F97316;
|
||||
}
|
||||
|
||||
.question-input {
|
||||
min-height: 44px;
|
||||
overflow: hidden;
|
||||
resize: none;
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
.switch-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -138,7 +138,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { reactive, ref, computed, onUnmounted } from 'vue'
|
||||
import { reactive, ref, computed, onMounted, onUnmounted } from 'vue'
|
||||
import { useRouter, useRoute } from 'vue-router'
|
||||
import { useUserStore } from '@/store/user'
|
||||
import { useAvatarStore } from '@/store/avatar'
|
||||
@@ -161,6 +161,14 @@ const password = ref('')
|
||||
const loading = ref(false)
|
||||
const errorMsg = ref('')
|
||||
|
||||
onMounted(() => {
|
||||
const ssoError = sessionStorage.getItem('hh_sso_error')
|
||||
if (ssoError) {
|
||||
errorMsg.value = ssoError
|
||||
sessionStorage.removeItem('hh_sso_error')
|
||||
}
|
||||
})
|
||||
|
||||
const counting = ref(false)
|
||||
const countdown = ref(60)
|
||||
let timer: any = null
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
<!-- 顶部导航 -->
|
||||
<header class="page-header">
|
||||
<button class="back-btn" @click="goBack">‹</button>
|
||||
<h1 class="page-title">Token 充值</h1>
|
||||
<h1 class="page-title">积分充值</h1>
|
||||
<div class="header-right"></div>
|
||||
</header>
|
||||
|
||||
@@ -12,7 +12,8 @@
|
||||
<div class="balance-card">
|
||||
<span class="balance-label">当前余额</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() }} 积分</span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -28,10 +29,10 @@
|
||||
@click="selectedPlan = plan"
|
||||
>
|
||||
<div class="plan-badge" v-if="plan.badge">{{ plan.badge }}</div>
|
||||
<div class="plan-amount">{{ plan.amount.toLocaleString() }}</div>
|
||||
<div class="plan-unit">Token</div>
|
||||
<div class="plan-amount">{{ formatTokenAmount(plan.amount) }}</div>
|
||||
<div class="plan-unit">积分</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>
|
||||
</section>
|
||||
@@ -45,7 +46,7 @@
|
||||
:class="{ selected: 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-check" v-if="paymentMethod === 'wechat'">✓</span>
|
||||
</div>
|
||||
@@ -54,36 +55,78 @@
|
||||
:class="{ selected: 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-check" v-if="paymentMethod === 'alipay'">✓</span>
|
||||
</div>
|
||||
</div>
|
||||
<p v-if="paymentNotice" class="payment-notice" :class="paymentNoticeTone">{{ paymentNotice }}</p>
|
||||
</section>
|
||||
|
||||
<!-- 充值按钮 -->
|
||||
<section class="checkout-section">
|
||||
<button
|
||||
class="checkout-btn"
|
||||
:class="{ disabled: !selectedPlan }"
|
||||
:disabled="!selectedPlan"
|
||||
:class="{ disabled: !selectedPlan || charging || Boolean(pendingOrderId) }"
|
||||
:disabled="!selectedPlan || charging || Boolean(pendingOrderId)"
|
||||
@click="doCharge"
|
||||
>
|
||||
{{ selectedPlan ? `立即支付 ¥${selectedPlan.price}` : '请选择充值套餐' }}
|
||||
{{ checkoutLabel }}
|
||||
</button>
|
||||
</section>
|
||||
|
||||
<section v-if="recentOrders.length" class="orders-section">
|
||||
<h3 class="section-title">充值记录</h3>
|
||||
<div v-for="order in recentOrders" :key="order.id" class="order-card">
|
||||
<div>
|
||||
<strong>{{ order.pointsAmount.toLocaleString() }} 积分</strong>
|
||||
<p>{{ order.orderNo }} · {{ order.createdAt ? new Date(order.createdAt).toLocaleDateString('zh-CN') : '' }}</p>
|
||||
</div>
|
||||
<div class="order-side">
|
||||
<strong>¥{{ order.price.toFixed(2) }}</strong>
|
||||
<button v-if="canInvoice(order)" class="text-btn" @click="openInvoice(order)">申请发票</button>
|
||||
<span v-else class="order-status">{{ orderStatus(order) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div v-if="invoiceOrder" class="modal-mask" @click.self="invoiceOrder = null">
|
||||
<form class="invoice-modal" @submit.prevent="submitInvoice">
|
||||
<h3>申请电子发票</h3>
|
||||
<label>发票类型
|
||||
<select v-model="invoiceType"><option value="personal">个人</option><option value="company">企业</option></select>
|
||||
</label>
|
||||
<label>发票抬头<input v-model.trim="invoiceTitle" maxlength="120" required /></label>
|
||||
<label v-if="invoiceType === 'company'">企业税号<input v-model.trim="invoiceTaxNumber" minlength="15" maxlength="20" required /></label>
|
||||
<label>接收邮箱<input v-model.trim="invoiceEmail" type="email" placeholder="选填" /></label>
|
||||
<p v-if="invoiceError" class="invoice-error">{{ invoiceError }}</p>
|
||||
<div class="modal-actions"><button type="button" @click="invoiceOrder = null">取消</button><button class="primary" :disabled="invoiceSubmitting">{{ invoiceSubmitting ? '提交中…' : '提交申请' }}</button></div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { computed, onMounted, onUnmounted, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { getTokenBalance, getRechargePlans, chargeToken } from '@/api'
|
||||
import {
|
||||
chargeToken,
|
||||
applyTokenInvoice,
|
||||
getRechargePlans,
|
||||
getTokenBalance,
|
||||
getTokenOrders,
|
||||
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 currentBalance = ref<number>(1250)
|
||||
const currentBalance = ref<number>(0)
|
||||
const totalConsumed = ref<number>(0)
|
||||
|
||||
// 充值套餐
|
||||
const plans = ref<Array<{
|
||||
@@ -99,12 +142,27 @@ const selectedPlan = ref<any>(null)
|
||||
|
||||
// 支付方式
|
||||
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') || '')
|
||||
const recentOrders = ref<Array<TokenPaymentOrder & { invoice?: any }>>([])
|
||||
const invoiceOrder = ref<(TokenPaymentOrder & { invoice?: any }) | null>(null)
|
||||
const invoiceType = ref<'personal' | 'company'>('personal')
|
||||
const invoiceTitle = ref('')
|
||||
const invoiceTaxNumber = ref('')
|
||||
const invoiceEmail = ref('')
|
||||
const invoiceError = ref('')
|
||||
const invoiceSubmitting = ref(false)
|
||||
let pollTimer: number | undefined
|
||||
let pollDeadline = 0
|
||||
let removeNativeListener: (() => void) | undefined
|
||||
|
||||
// 从后端加载余额与套餐
|
||||
const loadData = async () => {
|
||||
try {
|
||||
const b: any = await getTokenBalance()
|
||||
currentBalance.value = b?.balance ?? 0
|
||||
totalConsumed.value = b?.totalConsumed ?? 0
|
||||
} catch (e) {
|
||||
console.error('加载余额失败', e)
|
||||
}
|
||||
@@ -114,20 +172,193 @@ const loadData = async () => {
|
||||
} catch (e) {
|
||||
console.error('加载套餐失败', e)
|
||||
}
|
||||
try {
|
||||
const result = await getTokenOrders(1, 10)
|
||||
recentOrders.value = result?.items || []
|
||||
} catch (e) {
|
||||
console.error('加载充值记录失败', e)
|
||||
}
|
||||
}
|
||||
|
||||
// 执行充值(写入后端)
|
||||
const canInvoice = (order: TokenPaymentOrder & { invoice?: any }) =>
|
||||
order.status === 'paid' && (!order.refundStatus || order.refundStatus === 'none') &&
|
||||
(!order.invoice || ['rejected', 'cancelled'].includes(order.invoice.status))
|
||||
const orderStatus = (order: TokenPaymentOrder & { invoice?: any }) => {
|
||||
if (order.invoice?.status === 'issued') return '发票已开具'
|
||||
if (order.invoice?.status === 'pending') return '发票处理中'
|
||||
if (order.invoice?.status === 'rejected') return '发票已驳回'
|
||||
return ({ pending: '待支付', paid: '已支付', failed: '支付失败', closed: '已关闭', refunded: '已退款' } as Record<string, string>)[order.status] || order.status
|
||||
}
|
||||
const openInvoice = (order: TokenPaymentOrder & { invoice?: any }) => {
|
||||
invoiceOrder.value = order
|
||||
invoiceType.value = 'personal'
|
||||
invoiceTitle.value = ''
|
||||
invoiceTaxNumber.value = ''
|
||||
invoiceEmail.value = ''
|
||||
invoiceError.value = ''
|
||||
}
|
||||
const submitInvoice = async () => {
|
||||
if (!invoiceOrder.value || invoiceSubmitting.value) return
|
||||
invoiceSubmitting.value = true
|
||||
invoiceError.value = ''
|
||||
try {
|
||||
await applyTokenInvoice(invoiceOrder.value.orderNo, {
|
||||
title: invoiceTitle.value,
|
||||
invoiceType: invoiceType.value,
|
||||
taxNumber: invoiceTaxNumber.value,
|
||||
email: invoiceEmail.value
|
||||
})
|
||||
paymentNoticeTone.value = 'success'
|
||||
paymentNotice.value = '发票申请已提交,请等待财务处理'
|
||||
invoiceOrder.value = null
|
||||
await loadData()
|
||||
} catch (error: any) {
|
||||
invoiceError.value = error?.message || '发票申请提交失败'
|
||||
} finally {
|
||||
invoiceSubmitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 会会支付订单创建与到账确认
|
||||
const charging = ref(false)
|
||||
const formatTokenAmount = (amount: number) => {
|
||||
if (amount >= 1_000_000 && amount % 1_000_000 === 0) return `${amount / 1_000_000}M`
|
||||
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 (isInUniWebView()) return 'APP' as const
|
||||
if (paymentMethod.value === 'wechat' && /MicroMessenger/i.test(navigator.userAgent)) return 'JSAPI' as const
|
||||
return 'H5' as const
|
||||
}
|
||||
|
||||
const parsePayMessage = (message: string) => {
|
||||
const text = (message || '').trim()
|
||||
if (!text) return null
|
||||
try {
|
||||
return JSON.parse(text)
|
||||
} catch {
|
||||
return text
|
||||
}
|
||||
}
|
||||
|
||||
const findPaymentUrl = (value: any): string => {
|
||||
if (typeof value === 'string' && /^https?:\/\//i.test(value)) return value
|
||||
if (!value || typeof value !== 'object') return ''
|
||||
for (const key of ['url', 'payUrl', 'mwebUrl', 'mweb_url', 'redirectUrl']) {
|
||||
if (typeof value[key] === 'string' && /^https?:\/\//i.test(value[key])) return value[key]
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
const invokeWechatJsapi = (params: Record<string, any>) => new Promise<void>((resolve, reject) => {
|
||||
const invoke = () => {
|
||||
const bridge = (window as any).WeixinJSBridge
|
||||
if (!bridge?.invoke) {
|
||||
reject(new Error('当前微信环境无法拉起支付'))
|
||||
return
|
||||
}
|
||||
bridge.invoke('getBrandWCPayRequest', params, (result: any) => {
|
||||
const message = String(result?.err_msg || result?.errMsg || '')
|
||||
if (/\bok\b/i.test(message)) resolve()
|
||||
else reject(new Error(/cancel/i.test(message) ? '支付已取消' : '微信支付未完成'))
|
||||
})
|
||||
}
|
||||
if ((window as any).WeixinJSBridge) invoke()
|
||||
else document.addEventListener('WeixinJSBridgeReady', invoke, { once: true })
|
||||
})
|
||||
|
||||
const launchPayment = async (order: TokenPaymentOrder) => {
|
||||
const parsed = parsePayMessage(order.payMessage)
|
||||
if (order.payWay === 'JSAPI' && order.paymentMethod === 'wechat' && parsed && typeof parsed === 'object') {
|
||||
await invokeWechatJsapi(parsed)
|
||||
return
|
||||
}
|
||||
if (isInUniWebView() && UniEvents.payment({
|
||||
orderId: order.id,
|
||||
orderNo: order.orderNo,
|
||||
paymentMethod: order.paymentMethod,
|
||||
payType: order.payType,
|
||||
payWay: order.payWay,
|
||||
payMessage: order.payMessage,
|
||||
paymentParams: parsed
|
||||
})) return
|
||||
|
||||
const paymentUrl = findPaymentUrl(parsed)
|
||||
if (paymentUrl) {
|
||||
window.location.assign(paymentUrl)
|
||||
return
|
||||
}
|
||||
throw new Error('请在会会 App 内完成支付')
|
||||
}
|
||||
|
||||
const clearPendingOrder = () => {
|
||||
pendingOrderId.value = ''
|
||||
sessionStorage.removeItem('hh_pending_payment_order')
|
||||
if (pollTimer) window.clearTimeout(pollTimer)
|
||||
pollTimer = undefined
|
||||
}
|
||||
|
||||
const pollPayment = async () => {
|
||||
if (!pendingOrderId.value) return
|
||||
try {
|
||||
const order = await getTokenPaymentStatus(pendingOrderId.value)
|
||||
if (order.status === 'paid') {
|
||||
currentBalance.value = order.balance
|
||||
paymentNoticeTone.value = 'success'
|
||||
paymentNotice.value = `支付成功,${order.pointsAmount.toLocaleString()} 积分已到账`
|
||||
clearPendingOrder()
|
||||
void loadData()
|
||||
return
|
||||
}
|
||||
if (order.status === 'failed') {
|
||||
paymentNoticeTone.value = 'error'
|
||||
paymentNotice.value = order.failureReason || '支付未完成,请重新发起'
|
||||
clearPendingOrder()
|
||||
return
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('查询支付结果失败', error)
|
||||
}
|
||||
if (Date.now() < pollDeadline) pollTimer = window.setTimeout(pollPayment, 2000)
|
||||
else {
|
||||
paymentNoticeTone.value = 'pending'
|
||||
paymentNotice.value = '支付结果仍在确认中,可稍后刷新余额或重新发起'
|
||||
clearPendingOrder()
|
||||
}
|
||||
}
|
||||
|
||||
const startPolling = (orderId: string) => {
|
||||
pendingOrderId.value = orderId
|
||||
sessionStorage.setItem('hh_pending_payment_order', orderId)
|
||||
pollDeadline = Date.now() + 2 * 60 * 1000
|
||||
if (pollTimer) window.clearTimeout(pollTimer)
|
||||
void pollPayment()
|
||||
}
|
||||
|
||||
const doCharge = async () => {
|
||||
if (!selectedPlan.value || charging.value) return
|
||||
charging.value = true
|
||||
paymentNotice.value = ''
|
||||
try {
|
||||
const methodText = paymentMethod.value === 'wechat' ? '微信支付' : '支付宝'
|
||||
const res: any = await chargeToken(selectedPlan.value.id)
|
||||
currentBalance.value = res?.balance ?? currentBalance.value
|
||||
alert(`已通过${methodText}成功充值,当前余额:${currentBalance.value} Token`)
|
||||
} catch (e) {
|
||||
alert('充值失败,请重试')
|
||||
const order = await chargeToken(selectedPlan.value.id, paymentMethod.value, payScene())
|
||||
if (order.status === 'failed') throw new Error(order.failureReason || '支付订单创建失败')
|
||||
paymentNoticeTone.value = 'pending'
|
||||
paymentNotice.value = '支付订单已创建,正在等待付款结果'
|
||||
startPolling(order.id)
|
||||
await launchPayment(order)
|
||||
} catch (error: any) {
|
||||
paymentNoticeTone.value = 'error'
|
||||
paymentNotice.value = error?.message || '充值失败,请重试'
|
||||
clearPendingOrder()
|
||||
} finally {
|
||||
charging.value = false
|
||||
}
|
||||
@@ -139,7 +370,33 @@ const goBack = () => {
|
||||
}
|
||||
|
||||
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>
|
||||
|
||||
@@ -187,6 +444,7 @@ onMounted(() => {
|
||||
|
||||
.balance-card {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: baseline;
|
||||
gap: 8px;
|
||||
padding: 20px;
|
||||
@@ -211,6 +469,12 @@ onMounted(() => {
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.balance-used {
|
||||
flex-basis: 100%;
|
||||
font-size: 12px;
|
||||
opacity: 0.82;
|
||||
}
|
||||
|
||||
/* 充值套餐 */
|
||||
.plans-section {
|
||||
padding: 0 20px 20px;
|
||||
@@ -319,7 +583,24 @@ onMounted(() => {
|
||||
}
|
||||
|
||||
.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 {
|
||||
@@ -342,11 +623,42 @@ onMounted(() => {
|
||||
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 {
|
||||
padding: 0 20px;
|
||||
}
|
||||
|
||||
.orders-section { padding: 24px 20px 0; }
|
||||
.order-card { display:flex; align-items:center; justify-content:space-between; padding:14px 16px; margin-bottom:10px; background:#fff; border:1px solid #EDEEF1; border-radius:12px; }
|
||||
.order-card strong { color:#18191C; font-size:14px; }.order-card p,.order-status { color:#9398AE; font-size:11px; margin:5px 0 0; }
|
||||
.order-side { text-align:right; }.text-btn { display:block; margin-top:5px; padding:0; border:0; background:none; color:#F97316; font-size:12px; cursor:pointer; }
|
||||
.modal-mask { position:fixed; inset:0; z-index:20; display:grid; place-items:center; padding:20px; background:rgba(15,23,42,.45); }
|
||||
.invoice-modal { width:min(100%,420px); padding:22px; border-radius:16px; background:#fff; box-shadow:0 18px 50px rgba(15,23,42,.2); }
|
||||
.invoice-modal h3 { margin:0 0 18px; }.invoice-modal label { display:grid; gap:7px; margin:12px 0; color:#4B5563; font-size:13px; }
|
||||
.invoice-modal input,.invoice-modal select { width:100%; height:42px; padding:0 12px; border:1px solid #D9DCE3; border-radius:9px; background:#fff; color:#18191C; font-size:14px; }
|
||||
.invoice-error { color:#B42318; font-size:12px; }.modal-actions { display:flex; justify-content:flex-end; gap:10px; margin-top:20px; }
|
||||
.modal-actions button { padding:9px 18px; border:1px solid #D9DCE3; border-radius:9px; background:#fff; }.modal-actions .primary { border-color:#F97316; background:#F97316; color:#fff; }
|
||||
|
||||
.checkout-btn {
|
||||
width: 100%;
|
||||
padding: 16px;
|
||||
|
||||
+4
-1
@@ -17,13 +17,16 @@ services:
|
||||
- REDIS_PORT=6379
|
||||
- SECRET_KEY=your-secret-key-change-in-production
|
||||
- AES_KEY=your-aes-key-32-chars-change-now!
|
||||
- AVATAR_MODEL_CONFIG_TOKEN=${AVATAR_MODEL_CONFIG_TOKEN:-}
|
||||
- TZ=Asia/Shanghai
|
||||
- AVATAR_DB_PATH=/app/avatar.db
|
||||
- AVATAR_BACKEND_URL=${AVATAR_BACKEND_URL:-}
|
||||
- AVATAR_FINANCE_ADMIN_SECRET=${AVATAR_FINANCE_ADMIN_SECRET:-}
|
||||
volumes:
|
||||
- ./backend/app:/app/app # ← 核心:代码目录直接挂载,改文件无需重建
|
||||
- ./backend/logs:/app/logs
|
||||
- ./backend/config:/app/config
|
||||
- ./digital-avatar-app/backend/avatar.db:/app/avatar.db:ro # 数字分身 SQLite(只读)
|
||||
- ./digital-avatar-app/backend/avatar.db:/app/avatar.db # 财务管理需要写入退款与开票状态
|
||||
depends_on:
|
||||
- ai-virtual-mysql
|
||||
- ai-virtual-redis
|
||||
|
||||
@@ -94,6 +94,7 @@ CREATE TABLE IF NOT EXISTS `ai_model_configs` (
|
||||
`id` bigint NOT NULL AUTO_INCREMENT,
|
||||
`model_name` varchar(64) NOT NULL COMMENT '模型名称',
|
||||
`provider` varchar(32) NOT NULL COMMENT 'openai/zhipu/wenxin/qianwen/local',
|
||||
`usage_scope` varchar(16) NOT NULL DEFAULT 'general' COMMENT '用途:general/digital_avatar',
|
||||
`api_base_url` varchar(256) DEFAULT NULL COMMENT 'API地址',
|
||||
`api_key_enc` varchar(512) DEFAULT NULL COMMENT '加密API Key',
|
||||
`model_version` varchar(64) DEFAULT NULL COMMENT '模型版本',
|
||||
|
||||
+2
-1
@@ -6,7 +6,8 @@ RUN npm install
|
||||
COPY . .
|
||||
RUN npm run build
|
||||
|
||||
FROM nginx:alpine
|
||||
# Nginx 1.31 uses syscalls that are blocked by the test server's legacy kernel.
|
||||
FROM nginx:1.28.3-alpine
|
||||
COPY --from=build /app/dist /usr/share/nginx/html
|
||||
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||
EXPOSE 80
|
||||
|
||||
@@ -94,5 +94,15 @@ export const uploadAvatarPhoto = (id, formData) => request.post(`/avatars/${id}/
|
||||
headers: { 'Content-Type': 'multipart/form-data' }
|
||||
})
|
||||
|
||||
// Finance (数字分身积分订单)
|
||||
export const getFinanceSummary = () => request.get('/finance/summary')
|
||||
export const getFinanceOrders = (params) => request.get('/finance/orders', { params })
|
||||
export const updateFinanceOrderStatus = (orderNo, data) => request.patch(`/finance/orders/${orderNo}/status`, data)
|
||||
export const requestFinanceRefund = (orderNo, data) => request.post(`/finance/orders/${orderNo}/refund`, data)
|
||||
export const getFinanceRefunds = (params) => request.get('/finance/refunds', { params })
|
||||
export const confirmFinanceRefund = (refundNo, data) => request.post(`/finance/refunds/${refundNo}/confirm`, data)
|
||||
export const getFinanceInvoices = (params) => request.get('/finance/invoices', { params })
|
||||
export const updateFinanceInvoice = (invoiceId, data) => request.patch(`/finance/invoices/${invoiceId}`, data)
|
||||
|
||||
export default request
|
||||
export const uploadAvatar = (userId, formData) => request.post(`/users/${userId}/upload-avatar`, formData, { headers: { "Content-Type": "multipart/form-data" } })
|
||||
|
||||
@@ -15,6 +15,10 @@
|
||||
<el-icon><UserFilled /></el-icon>
|
||||
<span>数字分身管理</span>
|
||||
</el-menu-item>
|
||||
<el-menu-item index="/finance">
|
||||
<el-icon><WalletFilled /></el-icon>
|
||||
<span>财务管理</span>
|
||||
</el-menu-item>
|
||||
<el-menu-item index="/users">
|
||||
<el-icon><User /></el-icon>
|
||||
<span>虚拟用户</span>
|
||||
|
||||
@@ -8,6 +8,7 @@ const routes = [
|
||||
{ path: '', redirect: '/dashboard' },
|
||||
{ path: 'dashboard', component: () => import('@/views/Dashboard.vue'), meta: { title: '数据看板' } },
|
||||
{ path: 'avatars', component: () => import('@/views/Avatars.vue'), meta: { title: '数字分身管理' } },
|
||||
{ path: 'finance', component: () => import('@/views/Finance.vue'), meta: { title: '财务管理' } },
|
||||
{ path: 'users', component: () => import('@/views/Users.vue'), meta: { title: '虚拟用户管理' } },
|
||||
{ path: 'interactions', component: () => import('@/views/Interactions.vue'), meta: { title: '互动记录' } },
|
||||
{ path: 'ai-models', component: () => import('@/views/AIModels.vue'), meta: { title: 'AI模型配置' } },
|
||||
|
||||
@@ -15,12 +15,17 @@
|
||||
<span class="model-title">{{ m.model_name }}</span>
|
||||
</div>
|
||||
<div style="display:flex;gap:6px;align-items:center">
|
||||
<el-tag :type="m.usage_scope === 'digital_avatar' ? 'warning' : 'info'" size="small">
|
||||
{{ scopeLabels[m.usage_scope] || '通用业务' }}
|
||||
</el-tag>
|
||||
<el-tag v-if="m.is_default" type="success" size="small">默认</el-tag>
|
||||
<el-tag v-if="!m.is_enabled" type="danger" size="small">禁用</el-tag>
|
||||
</div>
|
||||
</div>
|
||||
<div class="model-meta">
|
||||
<span>版本: {{ m.model_version || '--' }}</span>
|
||||
<span v-if="m.usage_scope === 'digital_avatar'">视觉: {{ m.vision_model_version || 'qwen3.6-flash' }}</span>
|
||||
<span v-if="m.usage_scope === 'digital_avatar'">病例OCR: {{ m.ocr_model_version || 'qwen-vl-ocr' }}</span>
|
||||
<span>温度: {{ m.temperature }}</span>
|
||||
<span>Max Tokens: {{ m.max_tokens }}</span>
|
||||
<span>超时: {{ m.timeout_seconds }}s</span>
|
||||
@@ -49,6 +54,13 @@
|
||||
<el-option v-for="(l,v) in providerLabels" :key="v" :label="l" :value="v" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="使用场景" prop="usage_scope">
|
||||
<el-radio-group v-model="form.usage_scope">
|
||||
<el-radio-button value="general">通用业务</el-radio-button>
|
||||
<el-radio-button value="digital_avatar">数字分身专用</el-radio-button>
|
||||
</el-radio-group>
|
||||
<div class="scope-tip">数字分身专用模型仅用于分身对话和主动接管回复</div>
|
||||
</el-form-item>
|
||||
<el-form-item label="API地址">
|
||||
<el-input v-model="form.api_base_url" placeholder="留空使用默认地址" />
|
||||
</el-form-item>
|
||||
@@ -58,6 +70,15 @@
|
||||
<el-form-item label="模型版本">
|
||||
<el-input v-model="form.model_version" placeholder="如: gpt-4-turbo, glm-4" />
|
||||
</el-form-item>
|
||||
<template v-if="form.usage_scope === 'digital_avatar'">
|
||||
<el-form-item label="视觉模型">
|
||||
<el-input v-model="form.vision_model_version" placeholder="如: qwen3.6-flash" />
|
||||
</el-form-item>
|
||||
<el-form-item label="病例OCR模型">
|
||||
<el-input v-model="form.ocr_model_version" placeholder="如: qwen-vl-ocr" />
|
||||
<div class="scope-tip">识别为病例、处方、检查单后自动调用,普通图片不会重复调用。</div>
|
||||
</el-form-item>
|
||||
</template>
|
||||
<el-row :gutter="16">
|
||||
<el-col :span="12">
|
||||
<el-form-item label="温度">
|
||||
@@ -131,8 +152,9 @@ const testResult = ref(null)
|
||||
const testing = ref(false)
|
||||
|
||||
const providerLabels = { openai: 'OpenAI', zhipu: '智谱GLM', wenxin: '文心一言', qianwen: '通义千问', local: '本地模型' }
|
||||
const form = reactive({ model_name: '', provider: 'openai', api_base_url: '', api_key: '', model_version: '', temperature: 0.7, max_tokens: 1000, timeout_seconds: 30, is_default: 0 })
|
||||
const rules = { model_name: [{ required: true, message: '请输入模型名称' }], provider: [{ required: true }] }
|
||||
const scopeLabels = { general: '通用业务', digital_avatar: '数字分身专用' }
|
||||
const form = reactive({ model_name: '', provider: 'openai', usage_scope: 'general', api_base_url: '', api_key: '', model_version: '', vision_model_version: 'qwen3.6-flash', ocr_model_version: 'qwen-vl-ocr', temperature: 0.7, max_tokens: 1000, timeout_seconds: 30, is_default: 0 })
|
||||
const rules = { model_name: [{ required: true, message: '请输入模型名称' }], provider: [{ required: true }], usage_scope: [{ required: true }] }
|
||||
|
||||
async function load() {
|
||||
const res = await getAIModels()
|
||||
@@ -155,13 +177,13 @@ function onProviderChange(provider) {
|
||||
|
||||
function openCreate() {
|
||||
editModel.value = null
|
||||
Object.assign(form, { model_name: '', provider: 'openai', api_base_url: PROVIDER_DEFAULTS.openai.api_base_url, api_key: '', model_version: PROVIDER_DEFAULTS.openai.model_version, temperature: 0.7, max_tokens: 1000, timeout_seconds: 30, is_default: 0 })
|
||||
Object.assign(form, { model_name: '', provider: 'openai', usage_scope: 'general', api_base_url: PROVIDER_DEFAULTS.openai.api_base_url, api_key: '', model_version: PROVIDER_DEFAULTS.openai.model_version, vision_model_version: 'qwen3.6-flash', ocr_model_version: 'qwen-vl-ocr', temperature: 0.7, max_tokens: 1000, timeout_seconds: 30, is_default: 0 })
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
function openEdit(m) {
|
||||
editModel.value = m
|
||||
Object.assign(form, { model_name: m.model_name, provider: m.provider, api_base_url: m.api_base_url || '', api_key: '', model_version: m.model_version || '', temperature: m.temperature, max_tokens: m.max_tokens, timeout_seconds: m.timeout_seconds, is_default: m.is_default })
|
||||
Object.assign(form, { model_name: m.model_name, provider: m.provider, usage_scope: m.usage_scope || 'general', api_base_url: m.api_base_url || '', api_key: '', model_version: m.model_version || '', vision_model_version: m.vision_model_version || 'qwen3.6-flash', ocr_model_version: m.ocr_model_version || 'qwen-vl-ocr', temperature: m.temperature, max_tokens: m.max_tokens, timeout_seconds: m.timeout_seconds, is_default: m.is_default })
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
@@ -236,4 +258,5 @@ onMounted(load)
|
||||
.result-meta { display: flex; align-items: center; gap: 10px; margin-bottom: 10px; }
|
||||
.result-content { background: var(--color-bg); border: 1px solid var(--color-border); border-radius: 8px; padding: 12px; font-size: 13px; line-height: 1.6; white-space: pre-wrap; max-height: 200px; overflow-y: auto; }
|
||||
.empty-state { grid-column: 1/-1; padding: 40px; }
|
||||
.scope-tip { margin-top: 6px; color: var(--color-text-muted); font-size: 12px; line-height: 1.5; }
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
<template>
|
||||
<div class="page-container finance-page">
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<div class="page-title">财务管理</div>
|
||||
<div class="subtitle">数字分身积分订单、退款与发票处理</div>
|
||||
</div>
|
||||
<el-button :loading="loading" @click="loadAll"><el-icon><Refresh /></el-icon>刷新</el-button>
|
||||
</div>
|
||||
|
||||
<div class="summary-grid">
|
||||
<div class="stat-card"><div class="stat-label">实收金额</div><div class="stat-value">¥{{ money(summary.paid_revenue) }}</div><small>{{ summary.paid_orders }} 笔已支付</small></div>
|
||||
<div class="stat-card"><div class="stat-label">待支付订单</div><div class="stat-value warning">{{ summary.pending_orders }}</div><small>可关闭或标记失败</small></div>
|
||||
<div class="stat-card"><div class="stat-label">处理中退款</div><div class="stat-value danger">{{ summary.processing_refunds }}</div><small>等待支付渠道确认</small></div>
|
||||
<div class="stat-card"><div class="stat-label">待开发票</div><div class="stat-value success">{{ summary.pending_invoices }}</div><small>等待财务处理</small></div>
|
||||
</div>
|
||||
|
||||
<el-tabs v-model="activeTab" class="finance-tabs" @tab-change="loadActive">
|
||||
<el-tab-pane label="支付订单" name="orders">
|
||||
<div class="filter-bar">
|
||||
<el-input v-model="filters.keyword" placeholder="订单号、昵称或手机号" clearable style="width:240px" @keyup.enter="loadOrders" />
|
||||
<el-select v-model="filters.status" placeholder="订单状态" clearable style="width:140px" @change="loadOrders">
|
||||
<el-option v-for="item in orderStatuses" :key="item.value" :label="item.label" :value="item.value" />
|
||||
</el-select>
|
||||
<el-select v-model="filters.provider" placeholder="支付渠道" clearable style="width:150px" @change="loadOrders">
|
||||
<el-option label="会会支付" value="huihui" /><el-option label="微信虚拟支付" value="wechat_virtual" />
|
||||
</el-select>
|
||||
<el-button type="primary" @click="loadOrders">查询</el-button>
|
||||
</div>
|
||||
<el-table :data="orders" v-loading="loading" class="data-table">
|
||||
<el-table-column label="订单 / 用户" min-width="230">
|
||||
<template #default="{ row }"><b>{{ row.order_no }}</b><div class="muted">{{ row.user_nickname || '会会用户' }} · {{ row.user_phone || '--' }}</div></template>
|
||||
</el-table-column>
|
||||
<el-table-column label="套餐积分" width="130"><template #default="{ row }">{{ Number(row.points_amount || 0).toLocaleString() }}</template></el-table-column>
|
||||
<el-table-column label="金额" width="100"><template #default="{ row }">¥{{ money(row.price) }}</template></el-table-column>
|
||||
<el-table-column label="渠道" width="130"><template #default="{ row }">{{ providerLabel[row.provider] || row.provider }}</template></el-table-column>
|
||||
<el-table-column label="支付状态" width="110"><template #default="{ row }"><el-tag :type="tagType(row.status)">{{ statusLabel[row.status] || row.status }}</el-tag></template></el-table-column>
|
||||
<el-table-column label="退款" width="100"><template #default="{ row }"><el-tag v-if="row.refund_status && row.refund_status !== 'none'" :type="tagType(row.refund_status)">{{ statusLabel[row.refund_status] || row.refund_status }}</el-tag><span v-else>--</span></template></el-table-column>
|
||||
<el-table-column label="下单时间" min-width="165"><template #default="{ row }">{{ dateTime(row.created_at) }}</template></el-table-column>
|
||||
<el-table-column label="操作" width="165" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button v-if="row.status === 'paid' && ['none','',null].includes(row.refund_status)" link type="danger" @click="refund(row)">退款</el-button>
|
||||
<el-dropdown v-if="row.status === 'pending'" @command="command => closeOrder(row, command)">
|
||||
<el-button link type="warning">状态处理<el-icon><ArrowDown /></el-icon></el-button>
|
||||
<template #dropdown><el-dropdown-menu><el-dropdown-item command="closed">关闭订单</el-dropdown-item><el-dropdown-item command="failed">标记失败</el-dropdown-item></el-dropdown-menu></template>
|
||||
</el-dropdown>
|
||||
<span v-if="row.status !== 'pending' && !(row.status === 'paid' && ['none','',null].includes(row.refund_status))" class="muted">已处理</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<el-pagination v-model:current-page="orderPage" :page-size="20" :total="orderTotal" layout="total, prev, pager, next" @change="loadOrders" />
|
||||
</el-tab-pane>
|
||||
|
||||
<el-tab-pane label="退款管理" name="refunds">
|
||||
<el-table :data="refunds" v-loading="loading" class="data-table">
|
||||
<el-table-column label="退款单号" min-width="220" prop="refund_no" />
|
||||
<el-table-column label="原订单" min-width="210" prop="order_no" />
|
||||
<el-table-column label="用户" width="150"><template #default="{ row }">{{ row.user_nickname || row.user_phone || '--' }}</template></el-table-column>
|
||||
<el-table-column label="金额" width="100"><template #default="{ row }">¥{{ money(row.amount) }}</template></el-table-column>
|
||||
<el-table-column label="状态" width="110"><template #default="{ row }"><el-tag :type="tagType(row.status)">{{ statusLabel[row.status] || row.status }}</el-tag></template></el-table-column>
|
||||
<el-table-column label="原因" min-width="160" prop="reason" show-overflow-tooltip />
|
||||
<el-table-column label="创建时间" min-width="165"><template #default="{ row }">{{ dateTime(row.created_at) }}</template></el-table-column>
|
||||
<el-table-column label="对账确认" width="155" fixed="right">
|
||||
<template #default="{ row }"><template v-if="['pending','processing'].includes(row.status)"><el-button link type="success" @click="confirmRefund(row, 'succeeded')">已退款</el-button><el-button link type="danger" @click="confirmRefund(row, 'failed')">失败</el-button></template><span v-else class="muted">{{ row.failure_reason || '已完成' }}</span></template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</el-tab-pane>
|
||||
|
||||
<el-tab-pane label="发票管理" name="invoices">
|
||||
<el-table :data="invoices" v-loading="loading" class="data-table">
|
||||
<el-table-column label="订单号" min-width="210" prop="order_no" />
|
||||
<el-table-column label="抬头" min-width="180" prop="title" />
|
||||
<el-table-column label="类型 / 税号" min-width="190"><template #default="{ row }">{{ row.invoice_type === 'company' ? '企业' : '个人' }}<div class="muted">{{ row.tax_number || '--' }}</div></template></el-table-column>
|
||||
<el-table-column label="金额" width="100"><template #default="{ row }">¥{{ money(row.amount) }}</template></el-table-column>
|
||||
<el-table-column label="邮箱" min-width="175" prop="email" />
|
||||
<el-table-column label="状态" width="100"><template #default="{ row }"><el-tag :type="tagType(row.status)">{{ invoiceStatusLabel[row.status] || row.status }}</el-tag></template></el-table-column>
|
||||
<el-table-column label="操作" width="145" fixed="right"><template #default="{ row }"><template v-if="row.status === 'pending'"><el-button link type="primary" @click="openInvoice(row)">开具</el-button><el-button link type="danger" @click="rejectInvoice(row)">驳回</el-button></template><a v-else-if="row.invoice_url" :href="row.invoice_url" target="_blank">查看发票</a><span v-else class="muted">{{ row.invoice_no || row.remark || '已处理' }}</span></template></el-table-column>
|
||||
</el-table>
|
||||
</el-tab-pane>
|
||||
</el-tabs>
|
||||
|
||||
<el-dialog v-model="invoiceDialog" title="登记已开发票" width="480px">
|
||||
<el-form label-width="92px">
|
||||
<el-form-item label="发票号码"><el-input v-model="invoiceForm.invoiceNo" maxlength="120" /></el-form-item>
|
||||
<el-form-item label="发票地址"><el-input v-model="invoiceForm.invoiceUrl" placeholder="电子发票下载地址(选填)" /></el-form-item>
|
||||
<el-form-item label="备注"><el-input v-model="invoiceForm.remark" type="textarea" /></el-form-item>
|
||||
</el-form>
|
||||
<template #footer><el-button @click="invoiceDialog=false">取消</el-button><el-button type="primary" :loading="saving" @click="issueInvoice">确认开具</el-button></template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { confirmFinanceRefund, getFinanceInvoices, getFinanceOrders, getFinanceRefunds, getFinanceSummary, requestFinanceRefund, updateFinanceInvoice, updateFinanceOrderStatus } from '@/api'
|
||||
|
||||
const activeTab = ref('orders')
|
||||
const loading = ref(false)
|
||||
const saving = ref(false)
|
||||
const summary = ref({ paid_revenue: 0, paid_orders: 0, pending_orders: 0, processing_refunds: 0, pending_invoices: 0 })
|
||||
const orders = ref([]), refunds = ref([]), invoices = ref([])
|
||||
const orderPage = ref(1), orderTotal = ref(0)
|
||||
const filters = ref({ keyword: '', status: '', provider: '' })
|
||||
const invoiceDialog = ref(false), currentInvoice = ref(null)
|
||||
const invoiceForm = ref({ invoiceNo: '', invoiceUrl: '', remark: '' })
|
||||
const orderStatuses = [{ label: '待支付', value: 'pending' }, { label: '已支付', value: 'paid' }, { label: '失败', value: 'failed' }, { label: '已关闭', value: 'closed' }, { label: '已退款', value: 'refunded' }]
|
||||
const providerLabel = { huihui: '会会支付', wechat_virtual: '微信虚拟支付' }
|
||||
const statusLabel = { pending: '待处理', processing: '处理中', paid: '已支付', succeeded: '已成功', failed: '失败', closed: '已关闭', refunded: '已退款' }
|
||||
const invoiceStatusLabel = { pending: '待开具', issued: '已开具', rejected: '已驳回' }
|
||||
const money = value => Number(value || 0).toFixed(2)
|
||||
const dateTime = value => value ? new Date(value).toLocaleString('zh-CN', { hour12: false }) : '--'
|
||||
const tagType = value => ({ paid: 'success', succeeded: 'success', issued: 'success', pending: 'warning', processing: 'warning', failed: 'danger', rejected: 'danger', refunded: 'info', closed: 'info' }[value] || 'info')
|
||||
|
||||
async function loadSummary() { const res = await getFinanceSummary(); summary.value = res.data || summary.value }
|
||||
async function loadOrders() { const res = await getFinanceOrders({ page: orderPage.value, page_size: 20, ...filters.value }); orders.value = res.data?.items || []; orderTotal.value = res.data?.total || 0 }
|
||||
async function loadRefunds() { const res = await getFinanceRefunds({ page: 1, page_size: 100 }); refunds.value = res.data?.items || [] }
|
||||
async function loadInvoices() { const res = await getFinanceInvoices({ page: 1, page_size: 100 }); invoices.value = res.data?.items || [] }
|
||||
async function loadActive() { loading.value = true; try { if (activeTab.value === 'orders') await loadOrders(); if (activeTab.value === 'refunds') await loadRefunds(); if (activeTab.value === 'invoices') await loadInvoices() } finally { loading.value = false } }
|
||||
async function loadAll() { loading.value = true; try { await Promise.all([loadSummary(), loadOrders(), loadRefunds(), loadInvoices()]) } finally { loading.value = false } }
|
||||
|
||||
async function refund(row) {
|
||||
try { const { value } = await ElMessageBox.prompt('退款成功后会扣回本订单发放的全部积分。', `退款 ¥${money(row.price)}`, { inputPlaceholder: '请输入退款原因', inputValidator: value => value?.trim() ? true : '退款原因不能为空', confirmButtonText: '提交退款', cancelButtonText: '取消' }); await requestFinanceRefund(row.order_no, { reason: value, operator: '后台管理员' }); ElMessage.success('退款已提交渠道处理'); await loadAll() } catch (error) { if (error !== 'cancel' && error !== 'close') console.error(error) }
|
||||
}
|
||||
async function closeOrder(row, status) { try { await ElMessageBox.confirm(`确认将订单标记为“${status === 'closed' ? '已关闭' : '失败'}”?`, '订单状态处理', { type: 'warning' }); await updateFinanceOrderStatus(row.order_no, { status, reason: '后台管理员处理' }); ElMessage.success('订单状态已更新'); await loadAll() } catch {} }
|
||||
async function confirmRefund(row, status) { try { const message = status === 'succeeded' ? '仅在支付渠道后台已确认退款到账时操作。' : '确认供应商退款失败?'; await ElMessageBox.confirm(message, '退款对账确认', { type: 'warning' }); await confirmFinanceRefund(row.refund_no, { status, failureReason: status === 'failed' ? '供应商退款失败' : '' }); ElMessage.success('退款结果已登记'); await loadAll() } catch {} }
|
||||
function openInvoice(row) { currentInvoice.value = row; invoiceForm.value = { invoiceNo: '', invoiceUrl: '', remark: '' }; invoiceDialog.value = true }
|
||||
async function issueInvoice() { if (!invoiceForm.value.invoiceNo.trim()) return ElMessage.warning('请填写发票号码'); saving.value = true; try { await updateFinanceInvoice(currentInvoice.value.id, { status: 'issued', ...invoiceForm.value }); ElMessage.success('发票已登记'); invoiceDialog.value = false; await loadAll() } finally { saving.value = false } }
|
||||
async function rejectInvoice(row) { try { const { value } = await ElMessageBox.prompt('请输入驳回原因', '驳回发票申请', { inputValidator: value => value?.trim() ? true : '驳回原因不能为空' }); await updateFinanceInvoice(row.id, { status: 'rejected', remark: value }); ElMessage.success('发票申请已驳回'); await loadAll() } catch {} }
|
||||
onMounted(loadAll)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.finance-page { overflow-y: auto; }
|
||||
.subtitle, .muted, small { color: var(--color-text-muted); font-size: 12px; margin-top: 5px; }
|
||||
.summary-grid { display:grid; grid-template-columns:repeat(4,minmax(0,1fr)); gap:16px; margin-bottom:20px; }
|
||||
.stat-value { margin:8px 0 4px; }.stat-value.warning{color:var(--color-accent-orange)}.stat-value.danger{color:var(--color-accent-red)}.stat-value.success{color:var(--color-accent-green)}
|
||||
.finance-tabs { background:#fff; border:1px solid var(--color-border); border-radius:12px; padding:8px 16px 16px; box-shadow:var(--shadow-sm); }
|
||||
.filter-bar { display:flex; gap:10px; margin:8px 0 16px; }.el-pagination{justify-content:flex-end;margin-top:16px}
|
||||
a { color:var(--color-accent); text-decoration:none; }
|
||||
@media (max-width: 1100px) { .summary-grid{grid-template-columns:repeat(2,1fr)} }
|
||||
</style>
|
||||
+13
-8
@@ -54,18 +54,24 @@ H5 引入 uniapp web-view bridge 后调用:
|
||||
| `needLogin` | — | token 失效,请求壳重新登录 |
|
||||
| `setTitle` | `title` | 设置原生导航栏标题 |
|
||||
| `navigate` | `path` | 请求原生跳转(打开原生页/新 web-view) |
|
||||
| `payment` | `payment` | 拉起会会原生支付;包含 `orderId/orderNo/payType/payWay/payMessage/paymentParams` |
|
||||
| `back` | — | 请求原生返回 |
|
||||
|
||||
### 2.3 原生 → H5(壳主动推送)
|
||||
壳通过 `web-view.evalJS` 调用 H5 全局函数 `window.__uniBridgeHandle__(message)`:
|
||||
| type | payload | 含义 |
|
||||
|------|---------|------|
|
||||
| `context` | `platform, version` | 注入运行环境信息 |
|
||||
| `context` | `surface, version` | 注入运行环境;`surface` 为 `app` / `mp-weixin` / `h5` |
|
||||
| `tokenRefresh` | `token` | 登录刷新后下发新 token |
|
||||
| `userUpdate` | `user` | 会会资料变更 |
|
||||
| `paymentResult` | `orderId,status` | 原生支付结束通知;`status` 为 `success/cancelled/failed` |
|
||||
|
||||
> H5 侧用 `onNativeMessage(cb)` 注册 `window.__uniBridgeHandle__`,见 `digital-avatar-app/src/utils/uniapp-bridge.ts`。
|
||||
|
||||
壳收到 `payment` 后应调用会会 App 已有的微信/支付宝支付能力(或 `uni.requestPayment`),把 `payMessage/paymentParams` 原样交给对应渠道。原生 SDK 返回后再发送 `paymentResult`;H5 不以原生返回作为到账依据,只会轮询本地订单,最终由会会服务端支付回调确认并增加积分。
|
||||
|
||||
微信小程序虚拟支付本期只交付后端能力(登录态交换、签名下单参数、服务端查单/退款和回调验收)。小程序原生充值页接入 `requestVirtualPayment` 后,应把后端返回的 `signData/paySig/signature/mode/env/offerId` 原样传入微信 API;不要在 web-view 中发起虚拟支付。
|
||||
|
||||
---
|
||||
|
||||
## 3. 项目结构(uni CLI / src 布局,已验证可编译)
|
||||
@@ -80,13 +86,13 @@ uniapp-avatar/
|
||||
├── manifest.json # 应用配置(名称/AppID/模块)
|
||||
├── pages.json # 页面路由
|
||||
├── uni.scss # 全局样式变量
|
||||
├── App.vue # 启动即做会会登录(onLaunch → userStore.init)
|
||||
├── App.vue # 启动时恢复会会登录态(onLaunch → userStore.init)
|
||||
├── main.js # createSSRApp + pinia
|
||||
├── pages/index/index.vue # web-view 容器(内嵌 digital-avatar-app H5)
|
||||
├── store/user.js # 会会会话(token/资料,本地缓存)
|
||||
├── store/user.js # 会会会话(宿主调用 applySession 注入并缓存)
|
||||
└── utils/
|
||||
├── bridge.js # H5 URL 构造 + 原生→H5 推送
|
||||
└── huihui.js # 会会登录(MOCK,留真实接入位)
|
||||
└── payment.js # App 微信/支付宝原生支付适配
|
||||
```
|
||||
> 构建产物:`npm run build:h5` → `dist/build/h5/`(含 index.html + assets)。
|
||||
|
||||
@@ -110,10 +116,9 @@ npm run build:h5 # 生产构建 → dist/build/h5/
|
||||
> 若 npm 依赖版本与本地 HBuilderX 不一致,执行 `npx @dcloudio/uvm` 对齐。
|
||||
|
||||
### 会会登录接入
|
||||
- 当前 `utils/huihui.js` 为 **MOCK**(`MOCK_AUTH = true`),便于联调。
|
||||
- 生产接入:把 `MOCK_AUTH` 改为 `false`,在 `loginHuihui()` 接入会会开放平台授权,
|
||||
换取 `access_token`、`userId`;`getUserInfo()` 请求会会 `usercenter` 真实资料接口
|
||||
(接口基址见 `docs/production-interface-inventory.md`)。
|
||||
- 壳只恢复会会宿主已经持有的登录态,不内置演示账号,也不会伪造会会 token。
|
||||
- 会会主 App 完成登录或刷新后调用 `userStore.applySession({ token, userId, nickname, avatarUrl })`;数字分身 H5 会把一次性会会 token 换成本系统会话并立即从地址中清除。
|
||||
- 独立打开且没有宿主会话时,H5 会进入已有的短信登录流程。
|
||||
|
||||
---
|
||||
|
||||
|
||||
Generated
+7937
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user