199 lines
8.0 KiB
Python
199 lines
8.0 KiB
Python
"""usage_events 聚合读(报表側)—— 与写侧 usage.py 同层收口(2026-07-23)。
|
|
|
|
背景:`UsageEvent.units` JSONB 的 key(tokens_in/tokens_out/cache_hit_tokens)
|
|
由 usage.py 写入,此前读侧的 `cast(units[...].astext, BigInteger)` 散在
|
|
web/admin.py 与 web/app.py 各自硬编码 —— 写读跨文件隐式耦合,改 key 极易漏改
|
|
导致计费统计静默错。本模块是 units 结构**唯一的读侧出口**:web 层只调函数,
|
|
不再直接碰 JSONB cast。改 units key 时,写侧 usage.py 与本模块同文件夹同 PR 改。
|
|
|
|
口径约定(与前端展示一致,tests/test_usage_report.py 锁行为):
|
|
- cost_cny:全 kind 合计(chat+image+video+vision+...)= 真实花费
|
|
- tokens_in/out + cache_hit:仅 kind='chat'。三者同源,缓存命中率
|
|
cache_hit/tokens_in 恒 ≤100%(绝不能拿 tasks.tokens_prompt 当分母 ——
|
|
那列会被「清空对话」重置而 usage_events 不重置)。
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
from typing import Any, Optional
|
|
|
|
from sqlalchemy import BigInteger, and_, cast, func, select
|
|
|
|
from .models import UsageEvent, User
|
|
|
|
# ── units JSONB 读侧列表达式(单一事实源;写侧 key 见 usage.py record_chat_usage)──
|
|
_CHAT = UsageEvent.kind == "chat"
|
|
_TIN = cast(UsageEvent.units["tokens_in"].astext, BigInteger)
|
|
_TOUT = cast(UsageEvent.units["tokens_out"].astext, BigInteger)
|
|
_HIT = cast(UsageEvent.units["cache_hit_tokens"].astext, BigInteger)
|
|
|
|
|
|
def task_usage_aggregates(s: Any, tids: list) -> dict:
|
|
"""按 task_id 批量聚合:真实成本 + chat token + 缓存命中。
|
|
|
|
单查询 GROUP BY(复用列表接口 msg_counts 同款批量范式,无 N+1)。on-the-fly 现算,
|
|
不落 tasks 列 —— 对所有历史 task 即时准确,免回填。
|
|
返回 {task_id: {"cost_cny": float, "tokens_in": int, "tokens_out": int,
|
|
"tokens_cache_hit": int}}。
|
|
"""
|
|
if not tids:
|
|
return {}
|
|
rows = s.execute(
|
|
select(
|
|
UsageEvent.task_id,
|
|
func.coalesce(func.sum(UsageEvent.cost_cny), 0),
|
|
func.coalesce(func.sum(_TIN).filter(_CHAT), 0),
|
|
func.coalesce(func.sum(_TOUT).filter(_CHAT), 0),
|
|
func.coalesce(func.sum(_HIT).filter(_CHAT), 0),
|
|
)
|
|
.where(UsageEvent.task_id.in_(tids))
|
|
.group_by(UsageEvent.task_id)
|
|
).all()
|
|
return {
|
|
tid: {
|
|
"cost_cny": float(cost or 0),
|
|
"tokens_in": int(tin or 0),
|
|
"tokens_out": int(tout or 0),
|
|
"tokens_cache_hit": int(hit or 0),
|
|
}
|
|
for tid, cost, tin, tout, hit in rows
|
|
}
|
|
|
|
|
|
def usage_overview(s: Any, cutoff_7d) -> dict:
|
|
"""全局合计(all-time)+ 近 7d 按天趋势(admin overview 的 usage section)。
|
|
|
|
按模型 / 各用户用量是独立带筛选排序的函数(models_usage / user_usage_page),
|
|
不在此 bundle。
|
|
"""
|
|
# 全局合计(all-time)
|
|
g = s.execute(
|
|
select(
|
|
func.coalesce(func.sum(UsageEvent.cost_cny), 0),
|
|
func.coalesce(func.sum(_TIN).filter(_CHAT), 0),
|
|
func.coalesce(func.sum(_TOUT).filter(_CHAT), 0),
|
|
func.coalesce(func.sum(_HIT).filter(_CHAT), 0),
|
|
func.count(),
|
|
)
|
|
).one()
|
|
total = {
|
|
"cost_cny": float(g[0] or 0),
|
|
"tokens_in": int(g[1] or 0),
|
|
"tokens_out": int(g[2] or 0),
|
|
"tokens_cache_hit": int(g[3] or 0),
|
|
"n_events": int(g[4] or 0),
|
|
}
|
|
|
|
# 近 7d 按天(date 截断;前端画成条/数字均可);按日期倒序 —— 最新一天在最上面
|
|
day = func.date(UsageEvent.created_at)
|
|
by_day = [
|
|
{
|
|
"date": str(d),
|
|
"cost_cny": float(c or 0),
|
|
"tokens_in": int(ti or 0),
|
|
"tokens_out": int(to or 0),
|
|
}
|
|
for d, c, ti, to in s.execute(
|
|
select(
|
|
day,
|
|
func.coalesce(func.sum(UsageEvent.cost_cny), 0),
|
|
func.coalesce(func.sum(_TIN).filter(_CHAT), 0),
|
|
func.coalesce(func.sum(_TOUT).filter(_CHAT), 0),
|
|
)
|
|
.where(UsageEvent.created_at >= cutoff_7d)
|
|
.group_by(day)
|
|
.order_by(day.desc())
|
|
).all()
|
|
]
|
|
|
|
return {"total": total, "by_day_7d": by_day}
|
|
|
|
|
|
def models_usage(s: Any, cutoff, sort: str) -> list:
|
|
"""按模型用量(支持时间筛选 + 排序)。sort: cost(按成本)/ tokens(按用量=输入+输出)。
|
|
|
|
cutoff=None 即全部;cost 全 kind 合计,token 仅 chat。模型集合从 usage_events 现取
|
|
(无"全模型"基线),故时间条件直接进 WHERE。
|
|
"""
|
|
cost_sum = func.coalesce(func.sum(UsageEvent.cost_cny), 0)
|
|
tin_sum = func.coalesce(func.sum(_TIN).filter(_CHAT), 0)
|
|
tout_sum = func.coalesce(func.sum(_TOUT).filter(_CHAT), 0)
|
|
order = (tin_sum + tout_sum).desc() if sort == "tokens" else cost_sum.desc()
|
|
|
|
q = select(
|
|
UsageEvent.model_profile, cost_sum, tin_sum, tout_sum, func.count(),
|
|
)
|
|
if cutoff is not None:
|
|
q = q.where(UsageEvent.created_at >= cutoff)
|
|
q = q.group_by(UsageEvent.model_profile).order_by(order, UsageEvent.model_profile)
|
|
return [
|
|
{
|
|
"model_profile": mp,
|
|
"cost_cny": float(c or 0),
|
|
"tokens_in": int(ti or 0),
|
|
"tokens_out": int(to or 0),
|
|
"n_events": int(n or 0),
|
|
}
|
|
for mp, c, ti, to, n in s.execute(q).all()
|
|
]
|
|
|
|
|
|
def user_usage_page(
|
|
s: Any, page: int, page_size: int, cutoff, sort: str,
|
|
) -> dict:
|
|
"""分页的各用户 token 用量(时间筛选 + 排序),含零用量用户(LEFT JOIN users)。
|
|
|
|
`各用户` 取自 users 全表 LEFT JOIN usage_events,故没产生过用量的用户也出现(0);
|
|
时间筛选放 JOIN ON(非 WHERE),否则带 cutoff 时会把零用量用户挤掉。
|
|
sort: cost(按成本)/ tokens(按用量=输入+输出);+ user_id 兜底稳定分页。
|
|
cost 全 kind 合计;token/cache_hit 仅 chat。返回 {page, page_size, total_users, rows}。
|
|
"""
|
|
cost_sum = func.coalesce(func.sum(UsageEvent.cost_cny), 0)
|
|
tin_sum = func.coalesce(func.sum(_TIN).filter(_CHAT), 0)
|
|
tout_sum = func.coalesce(func.sum(_TOUT).filter(_CHAT), 0)
|
|
order = (tin_sum + tout_sum).desc() if sort == "tokens" else cost_sum.desc()
|
|
|
|
join_cond = UsageEvent.user_id == User.user_id
|
|
if cutoff is not None:
|
|
join_cond = and_(join_cond, UsageEvent.created_at >= cutoff)
|
|
|
|
# 最近使用时间:取全量(不随 range 筛选变),否则 7d/30d 会把更早的真实 last-used 藏掉。
|
|
last_used_sq = (
|
|
select(func.max(UsageEvent.created_at))
|
|
.where(UsageEvent.user_id == User.user_id)
|
|
.correlate(User)
|
|
.scalar_subquery()
|
|
)
|
|
|
|
total_users = s.execute(select(func.count()).select_from(User)).scalar_one()
|
|
rows = [
|
|
{
|
|
"user_id": str(uid),
|
|
"email": email or "",
|
|
"name": name or "",
|
|
"user_name": uname or "",
|
|
"role": role or "user",
|
|
"plan": plan or "", # 模型档位(空 → default 档),admin UI 内联下拉用
|
|
"cost_cny": float(c or 0),
|
|
"tokens_in": int(ti or 0),
|
|
"tokens_out": int(to or 0),
|
|
"tokens_cache_hit": int(h or 0),
|
|
"n_events": int(n or 0),
|
|
"last_used_at": last_used.isoformat() if last_used else None,
|
|
}
|
|
for uid, email, name, uname, role, plan, c, ti, to, h, n, last_used in s.execute(
|
|
select(
|
|
User.user_id, User.email, User.name, User.user_name, User.role, User.plan,
|
|
cost_sum, tin_sum, tout_sum,
|
|
func.coalesce(func.sum(_HIT).filter(_CHAT), 0),
|
|
func.count(UsageEvent.event_id),
|
|
last_used_sq.label("last_used_at"),
|
|
)
|
|
.join(UsageEvent, join_cond, isouter=True)
|
|
.group_by(User.user_id, User.email, User.name, User.user_name, User.role, User.plan)
|
|
.order_by(order, User.user_id)
|
|
.limit(page_size)
|
|
.offset(page * page_size)
|
|
).all()
|
|
]
|
|
return {"page": page, "page_size": page_size, "total_users": total_users, "rows": rows}
|