feat: 完成数字分身多分身管理与生产 H5 接入 #3

Merged
stefanfeng merged 10 commits from codex/avatar-multi-management-integrated-20260825 into main 2026-08-26 09:43:53 +08:00
15 changed files with 798 additions and 52 deletions
Showing only changes of commit 699bbbde57 - Show all commits
+14
View File
@@ -40,8 +40,14 @@ def init_db():
("authorizations", "takeover_mode", "VARCHAR DEFAULT 'immediate'"), ("authorizations", "takeover_mode", "VARCHAR DEFAULT 'immediate'"),
("authorizations", "takeover_delay_seconds", "INTEGER DEFAULT 30"), ("authorizations", "takeover_delay_seconds", "INTEGER DEFAULT 30"),
("avatars", "share_token", "VARCHAR DEFAULT NULL"), ("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"),
) )
_normalize_optional_unique_values() _normalize_optional_unique_values()
_create_token_indexes()
def _try_add_columns(*cols): def _try_add_columns(*cols):
@@ -58,3 +64,11 @@ def _try_add_columns(*cols):
def _normalize_optional_unique_values(): def _normalize_optional_unique_values():
with engine.begin() as conn: with engine.begin() as conn:
conn.exec_driver_sql("UPDATE avatars SET share_token = NULL WHERE share_token = ''") conn.exec_driver_sql("UPDATE avatars SET share_token = NULL WHERE share_token = ''")
def _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 <> ''"
)
+25 -11
View File
@@ -8,7 +8,7 @@ from apscheduler.schedulers.asyncio import AsyncIOScheduler
from apscheduler.triggers.interval import IntervalTrigger from apscheduler.triggers.interval import IntervalTrigger
from database import init_db, SessionLocal from database import init_db, SessionLocal
from models import Avatar, Authorization, Organization, TokenAccount, TokenPlan from models import Avatar, Authorization, Organization, TokenAccount, TokenPlan, User
from fastapi.staticfiles import StaticFiles from fastapi.staticfiles import StaticFiles
import routers.avatars import routers.avatars
import routers.tokens import routers.tokens
@@ -19,6 +19,7 @@ import routers.huihui_auth
import routers.chat import routers.chat
import routers.takeover import routers.takeover
from responses import ok from responses import ok
from services.token_billing import DEFAULT_TOKEN_GRANT, release_stale_reservations
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -56,17 +57,29 @@ def health():
def seed(): def seed():
db = SessionLocal() db = SessionLocal()
try: try:
if db.query(TokenAccount).first() is None: plan_specs = [
db.add(TokenAccount(balance=1250)) {"id": "1", "name": "基础套餐", "amount": 2_000_000, "price": 10, "badge": "", "desc": "2M Token"},
{"id": "2", "name": "标准套餐", "amount": 20_000_000, "price": 100, "badge": "常用", "desc": "20M Token"},
{"id": "3", "name": "专业套餐", "amount": 250_000_000, "price": 1000, "badge": "加赠25%", "desc": "250M Token"},
{"id": "4", "name": "企业套餐", "amount": 2_500_000_000, "price": 10000, "badge": "企业推荐", "desc": "2500M Token"},
]
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: for user in db.query(User).all():
plans = [ account = db.query(TokenAccount).filter(TokenAccount.user_id == user.id).first()
TokenPlan(id="1", name="新手体验", amount=1000, price=9.9, desc="新手体验"), if account is None:
TokenPlan(id="2", name="热门套餐", amount=5000, price=39.9, badge="热门"), db.add(TokenAccount(
TokenPlan(id="3", name="超值套餐", amount=12000, price=89.9, badge="超值"), user_id=user.id,
TokenPlan(id="4", name="企业推荐", amount=30000, price=199, badge="企业推荐", desc="适合高频使用"), balance=DEFAULT_TOKEN_GRANT,
] total_granted=DEFAULT_TOKEN_GRANT,
db.add_all(plans) total_consumed=0,
))
if db.query(Avatar).count() == 0: if db.query(Avatar).count() == 0:
avatar = Avatar( avatar = Avatar(
@@ -105,6 +118,7 @@ def seed():
db.add_all(orgs) db.add_all(orgs)
db.commit() db.commit()
release_stale_reservations(db)
finally: finally:
db.close() db.close()
+31 -2
View File
@@ -1,6 +1,7 @@
import uuid import uuid
from sqlalchemy import ( from sqlalchemy import (
BigInteger,
Boolean, Boolean,
Column, Column,
DateTime, DateTime,
@@ -259,14 +260,42 @@ class KnowledgeChunk(Base):
class TokenAccount(Base): class TokenAccount(Base):
__tablename__ = "token_account" __tablename__ = "token_account"
id = Column(Integer, primary_key=True) 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): class TokenPlan(Base):
__tablename__ = "token_plans" __tablename__ = "token_plans"
id = Column(String, primary_key=True) id = Column(String, primary_key=True)
name = Column(String, default="") name = Column(String, default="")
amount = Column(Integer, default=0) amount = Column(BigInteger, default=0)
price = Column(Float, default=0) price = Column(Float, default=0)
badge = Column(String, default="") badge = Column(String, default="")
desc = Column(String, default="") desc = Column(String, default="")
+131 -15
View File
@@ -16,12 +16,20 @@ import embeddings
from database import get_db from database import get_db
from models import Avatar, KnowledgeChunk, KnowledgeDoc, QAPair, User from models import Avatar, KnowledgeChunk, KnowledgeDoc, QAPair, User
from responses import ok, fail from responses import ok, fail
from services.token_billing import (
InsufficientTokensError,
estimate_fallback_usage,
release_reservation,
reserve_avatar_tokens,
settle_reservation,
)
router = APIRouter(tags=["数字分身聊天"]) router = APIRouter(tags=["数字分身聊天"])
CHAT_API_URL = os.getenv("CHAT_API_URL", "https://dashscope.aliyuncs.com/compatible-mode/v1") CHAT_API_URL = os.getenv("CHAT_API_URL", "https://dashscope.aliyuncs.com/compatible-mode/v1")
CHAT_API_KEY = os.getenv("CHAT_API_KEY", "") CHAT_API_KEY = os.getenv("CHAT_API_KEY", "")
CHAT_MODEL = os.getenv("CHAT_MODEL", "qwen-plus") CHAT_MODEL = os.getenv("CHAT_MODEL", "qwen-plus")
CHAT_MAX_OUTPUT_TOKENS = max(128, int(os.getenv("CHAT_MAX_OUTPUT_TOKENS", "1024")))
MAX_MESSAGE_LENGTH = 4000 MAX_MESSAGE_LENGTH = 4000
MAX_HISTORY_MESSAGES = 10 MAX_HISTORY_MESSAGES = 10
QA_LEXICAL_THRESHOLD = 0.72 QA_LEXICAL_THRESHOLD = 0.72
@@ -279,7 +287,7 @@ def _search_knowledge(db: Session, avatar_id: str, question: str, top_k: int = 5
return results return results
def _call_qwen(messages: list[dict], temperature: float) -> str: def _call_qwen(messages: list[dict], temperature: float) -> dict:
if not CHAT_API_KEY: if not CHAT_API_KEY:
raise RuntimeError("Qwen 模型服务未配置 CHAT_API_KEY") raise RuntimeError("Qwen 模型服务未配置 CHAT_API_KEY")
url = f"{CHAT_API_URL.rstrip('/')}/chat/completions" url = f"{CHAT_API_URL.rstrip('/')}/chat/completions"
@@ -287,6 +295,7 @@ def _call_qwen(messages: list[dict], temperature: float) -> str:
"model": CHAT_MODEL, "model": CHAT_MODEL,
"messages": messages, "messages": messages,
"temperature": temperature, "temperature": temperature,
"max_tokens": CHAT_MAX_OUTPUT_TOKENS,
} }
try: try:
response = httpx.post( response = httpx.post(
@@ -302,7 +311,7 @@ def _call_qwen(messages: list[dict], temperature: float) -> str:
raise RuntimeError("Qwen 模型服务暂时不可用") from exc raise RuntimeError("Qwen 模型服务暂时不可用") from exc
if not isinstance(answer, str) or not answer.strip(): if not isinstance(answer, str) or not answer.strip():
raise RuntimeError("Qwen 模型没有返回有效回答") raise RuntimeError("Qwen 模型没有返回有效回答")
return answer.strip() return {"answer": answer.strip(), "usage": data.get("usage") or {}}
def _iter_qwen_stream(messages: list[dict], temperature: float): def _iter_qwen_stream(messages: list[dict], temperature: float):
@@ -310,7 +319,14 @@ def _iter_qwen_stream(messages: list[dict], temperature: float):
if not CHAT_API_KEY: if not CHAT_API_KEY:
raise RuntimeError("模型服务未配置") raise RuntimeError("模型服务未配置")
url = f"{CHAT_API_URL.rstrip('/')}/chat/completions" url = f"{CHAT_API_URL.rstrip('/')}/chat/completions"
payload = {"model": CHAT_MODEL, "messages": messages, "temperature": temperature, "stream": True} payload = {
"model": CHAT_MODEL,
"messages": messages,
"temperature": temperature,
"max_tokens": CHAT_MAX_OUTPUT_TOKENS,
"stream": True,
"stream_options": {"include_usage": True},
}
try: try:
with httpx.stream("POST", url, headers={"Authorization": f"Bearer {CHAT_API_KEY}"}, json=payload, timeout=45) as response: with httpx.stream("POST", url, headers={"Authorization": f"Bearer {CHAT_API_KEY}"}, json=payload, timeout=45) as response:
response.raise_for_status() response.raise_for_status()
@@ -322,11 +338,15 @@ def _iter_qwen_stream(messages: list[dict], temperature: float):
if data == "[DONE]": if data == "[DONE]":
return return
try: try:
delta = json.loads(data).get("choices", [{}])[0].get("delta", {}).get("content") parsed = json.loads(data)
except (ValueError, IndexError, AttributeError): except (ValueError, IndexError, AttributeError):
continue continue
if parsed.get("usage"):
yield {"usage": parsed["usage"]}
choices = parsed.get("choices") or []
delta = choices[0].get("delta", {}).get("content") if choices else None
if delta: if delta:
yield delta yield {"content": delta}
except httpx.HTTPError as exc: except httpx.HTTPError as exc:
raise RuntimeError("模型服务暂时不可用") from exc raise RuntimeError("模型服务暂时不可用") from exc
@@ -350,6 +370,7 @@ def _resolve_reply(
qa_pairs: list[Any] | None = None, qa_pairs: list[Any] | None = None,
search_fn: Callable[..., list[dict]] | None = None, search_fn: Callable[..., list[dict]] | None = None,
model_client: Callable[..., str] | None = None, model_client: Callable[..., str] | None = None,
usage_source: str = "chat",
) -> dict: ) -> dict:
if qa_pairs is None: if qa_pairs is None:
qa_pairs = db.query(QAPair).filter(QAPair.avatar_id == avatar.id).all() qa_pairs = db.query(QAPair).filter(QAPair.avatar_id == avatar.id).all()
@@ -362,16 +383,49 @@ def _resolve_reply(
messages = _build_prompt(avatar, history, question, hits) messages = _build_prompt(avatar, history, question, hits)
config = _config(avatar) config = _config(avatar)
temperature = min(0.45 if hits else 0.25, 0.2 + config["creativity"] / 100 * 0.6) temperature = min(0.45 if hits else 0.25, 0.2 + config["creativity"] / 100 * 0.6)
model_client = model_client or _call_qwen token_usage = None
answer = model_client(messages=messages, temperature=temperature) if model_client is not None:
return { answer = model_client(messages=messages, temperature=temperature)
else:
reservation = reserve_avatar_tokens(
db,
avatar,
usage_source,
CHAT_MODEL,
messages,
CHAT_MAX_OUTPUT_TOKENS,
)
try:
model_result = _call_qwen(messages=messages, temperature=temperature)
answer = model_result["answer"]
token_usage = settle_reservation(
db,
reservation,
model_result.get("usage"),
fallback_total=estimate_fallback_usage(messages, answer),
)
except Exception as exc:
release_reservation(db, reservation, str(exc))
raise
result = {
"answer": answer, "answer": answer,
"source": "knowledge" if hits else "qwen", "source": "knowledge" if hits else "qwen",
"references": hits, "references": hits,
} }
if token_usage:
result["tokenUsage"] = token_usage
return result
def _stream_reply(db: Session, avatar: Avatar, question: str, history: list[Any], *, public: bool = False): def _stream_reply(
db: Session,
avatar: Avatar,
question: str,
history: list[Any],
*,
public: bool = False,
usage_source: str = "chat_stream",
):
qa_pairs = db.query(QAPair).filter(QAPair.avatar_id == avatar.id).all() qa_pairs = db.query(QAPair).filter(QAPair.avatar_id == avatar.id).all()
matched = _match_standard_qa(question, qa_pairs) matched = _match_standard_qa(question, qa_pairs)
if matched: if matched:
@@ -381,18 +435,62 @@ def _stream_reply(db: Session, avatar: Avatar, question: str, history: list[Any]
source = "knowledge" if references else "qwen" source = "knowledge" if references else "qwen"
config = _config(avatar) config = _config(avatar)
temperature = min(0.45 if references else 0.25, 0.2 + config["creativity"] / 100 * 0.6) temperature = min(0.45 if references else 0.25, 0.2 + config["creativity"] / 100 * 0.6)
chunks = _iter_qwen_stream(_build_prompt(avatar, history, question, references), temperature) messages = _build_prompt(avatar, history, question, references)
reservation = reserve_avatar_tokens(
db,
avatar,
usage_source,
CHAT_MODEL,
messages,
CHAT_MAX_OUTPUT_TOKENS,
)
chunks = _iter_qwen_stream(messages, temperature)
if matched:
messages, reservation = [], None
if public: if public:
source, references = "public", [] source, references = "public", []
def generate(): def generate():
output_parts = []
provider_usage = None
settled = False
try: try:
yield _sse("meta", {"source": source, "references": references}) yield _sse("meta", {"source": source, "references": references})
for content in chunks: for chunk in chunks:
if reservation is None:
content = chunk
else:
provider_usage = chunk.get("usage") or provider_usage
content = chunk.get("content")
if not content:
continue
output_parts.append(content)
yield _sse("delta", {"content": content}) yield _sse("delta", {"content": content})
yield _sse("done", {}) token_usage = None
if reservation is not None:
answer = "".join(output_parts)
token_usage = settle_reservation(
db,
reservation,
provider_usage,
fallback_total=estimate_fallback_usage(messages, answer),
)
settled = True
yield _sse("done", {} if public else {"tokenUsage": token_usage})
except RuntimeError as exc: except RuntimeError as exc:
yield _sse("error", {"message": str(exc)}) yield _sse("error", {"message": str(exc)})
finally:
if reservation is not None and not settled:
answer = "".join(output_parts)
if answer:
settle_reservation(
db,
reservation,
provider_usage,
fallback_total=estimate_fallback_usage(messages, answer),
)
else:
release_reservation(db, reservation, "stream_ended_without_output")
return StreamingResponse( return StreamingResponse(
generate(), generate(),
@@ -441,11 +539,14 @@ def get_shared_avatar(share_token: str, db: Session = Depends(get_db)):
def public_chat(share_token: str, body: ChatIn = Body(...), db: Session = Depends(get_db)): def public_chat(share_token: str, body: ChatIn = Body(...), db: Session = Depends(get_db)):
avatar = _require_shared_avatar(db, share_token) avatar = _require_shared_avatar(db, share_token)
try: try:
result = _resolve_reply(db, avatar, body.message, body.history) result = _resolve_reply(db, avatar, body.message, body.history, usage_source="public_chat")
# 公开访客无需获知知识文件名、检索分数或内部答复来源。 # 公开访客无需获知知识文件名、检索分数或内部答复来源。
result["references"] = [] result["references"] = []
result["source"] = "public" result["source"] = "public"
result.pop("tokenUsage", None)
return ok(result) return ok(result)
except InsufficientTokensError as exc:
return fail(str(exc), code=402)
except RuntimeError as exc: except RuntimeError as exc:
return fail(str(exc), code=502) return fail(str(exc), code=502)
@@ -455,15 +556,30 @@ def chat(avatar_id: str, body: ChatIn = Body(...), authorization: str = Header(N
avatar = _require_owned_avatar(db, avatar_id, authorization) avatar = _require_owned_avatar(db, avatar_id, authorization)
try: try:
return ok(_resolve_reply(db, avatar, body.message, body.history)) return ok(_resolve_reply(db, avatar, body.message, body.history))
except InsufficientTokensError as exc:
return fail(str(exc), code=402)
except RuntimeError as exc: except RuntimeError as exc:
return fail(str(exc), code=502) return fail(str(exc), code=502)
@router.post("/avatar/{avatar_id}/chat/stream") @router.post("/avatar/{avatar_id}/chat/stream")
def chat_stream(avatar_id: str, body: ChatIn = Body(...), authorization: str = Header(None), db: Session = Depends(get_db)): def chat_stream(avatar_id: str, body: ChatIn = Body(...), authorization: str = Header(None), db: Session = Depends(get_db)):
return _stream_reply(db, _require_owned_avatar(db, avatar_id, authorization), body.message, body.history) try:
return _stream_reply(db, _require_owned_avatar(db, avatar_id, authorization), body.message, body.history)
except InsufficientTokensError as exc:
raise HTTPException(status_code=402, detail=str(exc)) from exc
@router.post("/public/avatar/{share_token}/chat/stream") @router.post("/public/avatar/{share_token}/chat/stream")
def public_chat_stream(share_token: str, body: ChatIn = Body(...), db: Session = Depends(get_db)): def public_chat_stream(share_token: str, body: ChatIn = Body(...), db: Session = Depends(get_db)):
return _stream_reply(db, _require_shared_avatar(db, share_token), body.message, body.history, public=True) try:
return _stream_reply(
db,
_require_shared_avatar(db, share_token),
body.message,
body.history,
public=True,
usage_source="public_chat_stream",
)
except InsufficientTokensError as exc:
raise HTTPException(status_code=402, detail=str(exc)) from exc
@@ -349,6 +349,9 @@ def _issue_session(db: Session, phone: str, info: dict):
db.commit() db.commit()
db.refresh(user) db.refresh(user)
from services.token_billing import get_or_create_account
get_or_create_account(db, user.id)
return ok({ return ok({
"token": user.app_token, "token": user.app_token,
"user": user.to_dict(), "user": user.to_dict(),
+57 -13
View File
@@ -1,37 +1,81 @@
from fastapi import APIRouter, Depends, Body from fastapi import APIRouter, Depends, Body, Header, HTTPException
from sqlalchemy import func
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from database import get_db from database import get_db
from models import TokenAccount, TokenPlan from models import TokenAccount, TokenPlan, TokenUsage, User
from responses import ok, fail from responses import ok, fail
from services.token_billing import get_or_create_account
router = APIRouter(tags=["Token"]) router = APIRouter(tags=["Token"])
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
@router.get("/token/balance") @router.get("/token/balance")
def balance(db: Session = Depends(get_db)): def balance(authorization: str = Header(None), db: Session = Depends(get_db)):
acc = db.query(TokenAccount).first() user = _require_user(authorization, db)
return ok({"balance": acc.balance if acc else 0}) 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") @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() items = db.query(TokenPlan).order_by(TokenPlan.price.asc()).all()
return ok([p.to_dict() for p in items]) return ok([p.to_dict() for p in items])
@router.post("/token/charge") @router.post("/token/charge")
def charge(payload: dict = Body(...), db: Session = Depends(get_db)): def charge(payload: dict = Body(...), authorization: str = Header(None), db: Session = Depends(get_db)):
user = _require_user(authorization, db)
plan_id = payload.get("planId") plan_id = payload.get("planId")
plan = db.query(TokenPlan).filter(TokenPlan.id == plan_id).first() plan = db.query(TokenPlan).filter(TokenPlan.id == plan_id).first()
if not plan: if not plan:
return fail("套餐不存在", 404) return fail("套餐不存在", 404)
acc = db.query(TokenAccount).first() acc = get_or_create_account(db, user.id)
if not acc:
acc = TokenAccount(balance=0)
db.add(acc)
db.commit()
db.refresh(acc)
acc.balance += plan.amount acc.balance += plan.amount
acc.total_granted = int(acc.total_granted or 0) + plan.amount
db.commit() db.commit()
return ok({"balance": acc.balance, "charged": plan.amount}) return ok({"balance": acc.balance, "charged": plan.amount})
@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
])
@@ -520,7 +520,7 @@ class TakeoverService:
from routers.chat import _resolve_reply from routers.chat import _resolve_reply
result = _resolve_reply(db, avatar, task.prompt, history) result = _resolve_reply(db, avatar, task.prompt, history, usage_source="takeover")
answer = _plain_text_reply(result.get("answer", "")) answer = _plain_text_reply(result.get("answer", ""))
db.refresh(task) db.refresh(task)
if task.status != "generating": if task.status != "generating":
@@ -0,0 +1,198 @@
"""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,
) -> TokenReservation:
user = avatar_owner_user(db, avatar)
if not user:
raise InsufficientTokensError("分身尚未关联有效用户,暂时无法使用 Token")
account = get_or_create_account(db, user.id)
reserved = estimate_request_tokens(messages, max_output_tokens)
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("Token 余额不足,请充值后继续")
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("Token 账户不存在")
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)
@@ -8,6 +8,8 @@ from models import (
TakeoverCursor, TakeoverCursor,
TakeoverMessage, TakeoverMessage,
TakeoverReplyTask, TakeoverReplyTask,
TokenAccount,
TokenUsage,
User, User,
) )
@@ -107,6 +109,13 @@ def authorization_context():
db.query(Avatar).filter(Avatar.id.in_(avatar_ids)).delete( db.query(Avatar).filter(Avatar.id.in_(avatar_ids)).delete(
synchronize_session=False synchronize_session=False
) )
user_ids = [owner.id, other.id]
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( db.query(User).filter(User.id.in_([owner.id, other.id])).delete(
synchronize_session=False synchronize_session=False
) )
@@ -7,7 +7,6 @@ from unittest.mock import AsyncMock, patch
import pytest import pytest
from sqlalchemy import create_engine from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker from sqlalchemy.orm import sessionmaker
from sqlalchemy.pool import StaticPool
from database import Base from database import Base
from models import Avatar, TakeoverCursor, TakeoverMessage, TakeoverReplyTask, User from models import Avatar, TakeoverCursor, TakeoverMessage, TakeoverReplyTask, User
@@ -59,11 +58,10 @@ class FakeBoxIM:
@pytest.fixture @pytest.fixture
def service_context(): def service_context(tmp_path):
engine = create_engine( engine = create_engine(
"sqlite://", f"sqlite:///{tmp_path / 'takeover.db'}",
connect_args={"check_same_thread": False}, connect_args={"check_same_thread": False},
poolclass=StaticPool,
) )
session_factory = sessionmaker(bind=engine, autoflush=False, expire_on_commit=False) session_factory = sessionmaker(bind=engine, autoflush=False, expire_on_commit=False)
Base.metadata.create_all(engine) Base.metadata.create_all(engine)
@@ -156,7 +154,7 @@ async def test_different_contacts_generate_without_blocking_each_other(service_c
) )
both_generating = Barrier(2, timeout=2) both_generating = Barrier(2, timeout=2)
def resolve(_db, _avatar, prompt, _history): def resolve(_db, _avatar, prompt, _history, **_kwargs):
both_generating.wait() both_generating.wait()
return {"answer": f"回复{prompt[-1]}"} return {"answer": f"回复{prompt[-1]}"}
@@ -0,0 +1,252 @@
import uuid
from concurrent.futures import ThreadPoolExecutor
from threading import Barrier
from unittest.mock import patch
import pytest
from fastapi.testclient import TestClient
from database import SessionLocal
from main import app, seed
from models import Avatar, TokenAccount, 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 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_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()
+20 -1
View File
@@ -131,9 +131,24 @@ export const deleteAvatar = (id: string) =>
// ==================== Token 管理 API ==================== // ==================== 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
}
// 获取 Token 余额 // 获取 Token 余额
export const getTokenBalance = () => export const getTokenBalance = () =>
request.get<{ balance: number }>('/token/balance') request.get<TokenBalance>('/token/balance')
// 获取充值套餐 // 获取充值套餐
export const getRechargePlans = () => export const getRechargePlans = () =>
@@ -143,6 +158,10 @@ export const getRechargePlans = () =>
export const chargeToken = (planId: string) => export const chargeToken = (planId: string) =>
request.post<{ balance: number; charged: number }>('/token/charge', { planId }) request.post<{ balance: number; charged: number }>('/token/charge', { planId })
// 按分身和使用场景汇总 Token 消耗
export const getTokenUsage = () =>
request.get<TokenUsageSummary[]>('/token/usage')
// ==================== 授权管理 API ==================== // ==================== 授权管理 API ====================
export type AvatarPermission = 'friend' | 'chat' | 'publish' | 'browse' | 'interact' | 'takeover' export type AvatarPermission = 'friend' | 'chat' | 'publish' | 'browse' | 'interact' | 'takeover'
+20 -2
View File
@@ -1,13 +1,15 @@
import { defineStore } from 'pinia' import { defineStore } from 'pinia'
import { ref } from 'vue' 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' import { unwrapListData } from '@/utils/avatar-page-data'
export const useAvatarStore = defineStore('avatar', () => { export const useAvatarStore = defineStore('avatar', () => {
// 已创建的分身列表(来自后端) // 已创建的分身列表(来自后端)
const avatars = ref<any[]>([]) const avatars = ref<any[]>([])
// 全局 Token 余额(来自后端) // 当前用户所有分身共享的 Token 账户
const tokenBalance = ref<number>(0) const tokenBalance = ref<number>(0)
const tokenConsumed = ref<number>(0)
const tokenUsageByAvatar = ref<Record<string, number>>({})
// 当前选中分身 id // 当前选中分身 id
const currentAvatarId = ref<string | null>(null) const currentAvatarId = ref<string | null>(null)
// 会会用户资料(头像/昵称,来自会会接口) // 会会用户资料(头像/昵称,来自会会接口)
@@ -29,11 +31,24 @@ export const useAvatarStore = defineStore('avatar', () => {
try { try {
const res = await getTokenBalance() const res = await getTokenBalance()
tokenBalance.value = (res as any)?.balance ?? 0 tokenBalance.value = (res as any)?.balance ?? 0
tokenConsumed.value = (res as any)?.totalConsumed ?? 0
} catch (e) { } catch (e) {
console.error('加载余额失败', 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 () => { const loadUserProfile = async () => {
// 若已通过 uniapp 壳注入(混合架构),优先保留,不回退到后端 mock // 若已通过 uniapp 壳注入(混合架构),优先保留,不回退到后端 mock
@@ -82,10 +97,13 @@ export const useAvatarStore = defineStore('avatar', () => {
return { return {
avatars, avatars,
tokenBalance, tokenBalance,
tokenConsumed,
tokenUsageByAvatar,
currentAvatarId, currentAvatarId,
userProfile, userProfile,
loadAvatars, loadAvatars,
loadTokenBalance, loadTokenBalance,
loadTokenUsage,
loadUserProfile, loadUserProfile,
setNativeProfile, setNativeProfile,
addAvatar, addAvatar,
@@ -30,6 +30,7 @@
<div class="token-info"> <div class="token-info">
<span class="token-label">Token 余额</span> <span class="token-label">Token 余额</span>
<span class="token-amount">{{ tokenBalance.toLocaleString() }}</span> <span class="token-amount">{{ tokenBalance.toLocaleString() }}</span>
<span class="token-used">累计使用 {{ tokenConsumed.toLocaleString() }}</span>
</div> </div>
<button class="recharge-btn" @click="goToRecharge">充值</button> <button class="recharge-btn" @click="goToRecharge">充值</button>
</div> </div>
@@ -55,6 +56,7 @@
<div class="avatar-details"> <div class="avatar-details">
<div class="avatar-name-row"><h2 class="avatar-name">{{ a.displayName || a.name }}</h2><span class="avatar-status"><i class="status-dot" :class="a.status"></i>{{ statusText(a.status) }}</span></div> <div class="avatar-name-row"><h2 class="avatar-name">{{ a.displayName || a.name }}</h2><span class="avatar-status"><i class="status-dot" :class="a.status"></i>{{ statusText(a.status) }}</span></div>
<p class="avatar-desc">{{ a.description || '暂无描述' }}</p> <p class="avatar-desc">{{ a.description || '暂无描述' }}</p>
<span class="avatar-token-usage">累计使用 {{ avatarTokenUsage(a.id).toLocaleString() }} Token</span>
</div> </div>
</div> </div>
<div class="avatar-actions"> <div class="avatar-actions">
@@ -95,7 +97,9 @@ const me = computed(() => userStore.user)
// 状态(来自 store / 后端) // 状态(来自 store / 后端)
const tokenBalance = computed(() => avatarStore.tokenBalance) const tokenBalance = computed(() => avatarStore.tokenBalance)
const tokenConsumed = computed(() => avatarStore.tokenConsumed)
const avatars = computed(() => avatarStore.avatars) const avatars = computed(() => avatarStore.avatars)
const avatarTokenUsage = (id: string) => avatarStore.tokenUsageByAvatar[id] || 0
const shareToast = ref('') const shareToast = ref('')
@@ -174,6 +178,7 @@ onMounted(() => {
userStore.loadFromStorage() userStore.loadFromStorage()
avatarStore.loadAvatars() avatarStore.loadAvatars()
avatarStore.loadTokenBalance() avatarStore.loadTokenBalance()
avatarStore.loadTokenUsage()
}) })
</script> </script>
@@ -316,6 +321,12 @@ onMounted(() => {
color: #F97316; color: #F97316;
} }
.token-used {
margin-top: 3px;
color: #A0A5B4;
font-size: 11px;
}
.recharge-btn { .recharge-btn {
padding: 8px 16px; padding: 8px 16px;
background: #F97316; background: #F97316;
@@ -440,6 +451,13 @@ onMounted(() => {
white-space: nowrap; white-space: nowrap;
} }
.avatar-token-usage {
display: inline-block;
margin-top: 5px;
color: #A0A5B4;
font-size: 10px;
}
.avatar-status { .avatar-status {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
+16 -2
View File
@@ -13,6 +13,7 @@
<span class="balance-label">当前余额</span> <span class="balance-label">当前余额</span>
<span class="balance-amount">{{ currentBalance.toLocaleString() }}</span> <span class="balance-amount">{{ currentBalance.toLocaleString() }}</span>
<span class="balance-unit">Token</span> <span class="balance-unit">Token</span>
<span class="balance-used">累计使用 {{ totalConsumed.toLocaleString() }} Token</span>
</div> </div>
</section> </section>
@@ -28,7 +29,7 @@
@click="selectedPlan = plan" @click="selectedPlan = plan"
> >
<div class="plan-badge" v-if="plan.badge">{{ plan.badge }}</div> <div class="plan-badge" v-if="plan.badge">{{ plan.badge }}</div>
<div class="plan-amount">{{ plan.amount.toLocaleString() }}</div> <div class="plan-amount">{{ formatTokenAmount(plan.amount) }}</div>
<div class="plan-unit">Token</div> <div class="plan-unit">Token</div>
<div class="plan-price">¥{{ plan.price }}</div> <div class="plan-price">¥{{ plan.price }}</div>
<div class="plan-desc" v-if="plan.desc">{{ plan.desc }}</div> <div class="plan-desc" v-if="plan.desc">{{ plan.desc }}</div>
@@ -83,7 +84,8 @@ import { getTokenBalance, getRechargePlans, chargeToken } from '@/api'
const router = useRouter() const router = useRouter()
// 当前余额 // 当前余额
const currentBalance = ref<number>(1250) const currentBalance = ref<number>(0)
const totalConsumed = ref<number>(0)
// 充值套餐 // 充值套餐
const plans = ref<Array<{ const plans = ref<Array<{
@@ -105,6 +107,7 @@ const loadData = async () => {
try { try {
const b: any = await getTokenBalance() const b: any = await getTokenBalance()
currentBalance.value = b?.balance ?? 0 currentBalance.value = b?.balance ?? 0
totalConsumed.value = b?.totalConsumed ?? 0
} catch (e) { } catch (e) {
console.error('加载余额失败', e) console.error('加载余额失败', e)
} }
@@ -118,6 +121,10 @@ const loadData = async () => {
// 执行充值(写入后端) // 执行充值(写入后端)
const charging = ref(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 doCharge = async () => { const doCharge = async () => {
if (!selectedPlan.value || charging.value) return if (!selectedPlan.value || charging.value) return
charging.value = true charging.value = true
@@ -187,6 +194,7 @@ onMounted(() => {
.balance-card { .balance-card {
display: flex; display: flex;
flex-wrap: wrap;
align-items: baseline; align-items: baseline;
gap: 8px; gap: 8px;
padding: 20px; padding: 20px;
@@ -211,6 +219,12 @@ onMounted(() => {
opacity: 0.9; opacity: 0.9;
} }
.balance-used {
flex-basis: 100%;
font-size: 12px;
opacity: 0.82;
}
/* 充值套餐 */ /* 充值套餐 */
.plans-section { .plans-section {
padding: 0 20px 20px; padding: 0 20px 20px;