跳到主要内容

用量审计 & 聊天记录完整方案

本文档涵盖:WebChat 双栏布局、聊天记录持久化、用户侧用量审计、Admin 侧审计联动。
最后更新:2026-03-10


一、背景与目标

现状

能力状态问题
WebChat 聊天✅ 已通WS 直连 OC Gateway,消息不存 Portal DB
聊天历史❌ 无每次打开 Chat 页面都是空白
用量统计❌ 空壳/portal/usage/ 页面存在但 chat_messages.tokens 为空
审计日志✅ Admin 侧One API oneapi.logs 实时记录,Admin 后台可查
用户看用量❌ 无用户无法看到自己的 token 消耗和费用

目标

  1. WebChat 双栏 — 左侧实例列表 + 右侧聊天,支持切换实例
  2. 聊天记录持久化 — 每条消息存入 Portal DB,下次打开自动加载
  3. 用户侧用量 — 用户可查看自己的 token 用量、费用、请求详情
  4. 数据一致性 — 用户用量数据来自 oneapi.logs(权威数据源),不自己造数据

二、数据架构

数据流

用户发消息

├──→ WS → OC Gateway → One API → LLM Provider
│ │
│ ▼
│ oneapi.logs (实时写入)
│ ┌──────────────────────┐
│ │ token_name (=实例ID) │
│ │ model_name │
│ │ prompt_tokens │
│ │ completion_tokens │
│ │ quota (费用) │
│ │ elapsed_time (延迟) │
│ │ created_at │
│ └──────────────────────┘

├──→ POST /me/chat/save → chat_messages (Portal DB)
│ ┌──────────────────────┐
│ │ user_id │
│ │ instance_id │
│ │ role (user/assistant) │
│ │ content │
│ │ tokens │
│ │ created_at │
│ └──────────────────────┘

AI 回复完成

└──→ POST /me/chat/save (role=assistant)

数据源职责

数据源职责权威性
oneapi.logsToken 用量、费用、延迟、模型、成功/失败⭐ 权威(One API 实时写入)
chat_messages聊天内容(user/assistant 文本)辅助(前端双写)
usage_daily日汇总缓存(可选,加速查询)缓存(定时聚合)

用户关联链路

oneapi.logs.token_name (如 "oc-ai-jp-2-02")
↓ = instance_id
sayclaw_portal.user_instances.instance_id → user_id
↓ JOIN
sayclaw_portal.users.id → email, name
关键约束

One API 的 token_name 必须与 user_instances.instance_id 完全一致。
创建令牌时命名规则:token_name = instance_id(如 oc-ai-jp-3-08)。


三、后端 API 设计

3.1 聊天消息保存

POST /api/v1/me/chat/save

请求体:

{
"instance_id": "oc-ai-jp-3-08",
"role": "user", // "user" | "assistant"
"content": "你好",
"tokens": 0 // assistant 回复时填 token 数,user 填 0
}

实现:

func chatSaveHandler(c *gin.Context) {
userID := c.GetString("user_id")
var req struct {
InstanceID string `json:"instance_id" binding:"required"`
Role string `json:"role" binding:"required"`
Content string `json:"content" binding:"required"`
Tokens int `json:"tokens"`
}
if err := c.ShouldBindJSON(&req); err != nil {
fail(c, 400, "bad request")
return
}
// 验证用户有权访问该实例
var count int
db.QueryRow("SELECT COUNT(*) FROM user_instances WHERE user_id=? AND instance_id=?",
userID, req.InstanceID).Scan(&count)
if count == 0 {
fail(c, 403, "no access")
return
}
_, err := db.Exec(
"INSERT INTO chat_messages (user_id, instance_id, role, content, tokens) VALUES (?,?,?,?,?)",
userID, req.InstanceID, req.Role, req.Content, req.Tokens,
)
if err != nil {
fail(c, 500, "save failed")
return
}
ok(c, nil)
}

3.2 用量汇总

GET /api/v1/me/usage/summary?instance_id=xxx  (可选 instance_id 过滤)

返回:

{
"code": "0000",
"data": {
"today": {
"requests": 12,
"prompt_tokens": 3400,
"completion_tokens": 1200,
"total_tokens": 4600,
"cost_usd": 0.034
},
"week": { ... },
"month": { ... },
"all_time": { ... }
}
}

SQL 核心(跨库查询 oneapi.logs + sayclaw_portal.user_instances):

SELECT 
COUNT(*) AS requests,
COALESCE(SUM(l.prompt_tokens), 0) AS prompt_tokens,
COALESCE(SUM(l.completion_tokens), 0) AS completion_tokens,
COALESCE(SUM(l.prompt_tokens + l.completion_tokens), 0) AS total_tokens,
COALESCE(SUM(l.quota), 0) / 500000.0 AS cost_usd
FROM oneapi.logs l
JOIN sayclaw_portal.user_instances ui ON l.token_name = ui.instance_id
WHERE ui.user_id = ?
AND l.created_at >= UNIX_TIMESTAMP(CURDATE()) -- 今日
AND l.type = 2 -- 成功请求
费用换算

One API 的 quota 单位需要换算:cost_usd = quota / 500000
这是 One API 内部的额度倍率,1 美元 = 500000 quota。

3.3 每日用量趋势

GET /api/v1/me/usage/daily?days=30&instance_id=xxx

返回:

{
"code": "0000",
"data": {
"daily": [
{
"date": "2026-03-10",
"requests": 12,
"prompt_tokens": 3400,
"completion_tokens": 1200,
"cost_usd": 0.034
},
...
]
}
}

SQL:

SELECT 
DATE(FROM_UNIXTIME(l.created_at)) AS date,
COUNT(*) AS requests,
SUM(l.prompt_tokens) AS prompt_tokens,
SUM(l.completion_tokens) AS completion_tokens,
SUM(l.quota) / 500000.0 AS cost_usd
FROM oneapi.logs l
JOIN sayclaw_portal.user_instances ui ON l.token_name = ui.instance_id
WHERE ui.user_id = ?
AND l.created_at >= UNIX_TIMESTAMP(DATE_SUB(CURDATE(), INTERVAL ? DAY))
AND l.type = 2
GROUP BY DATE(FROM_UNIXTIME(l.created_at))
ORDER BY date DESC

3.4 请求日志明细

GET /api/v1/me/usage/logs?instance_id=xxx&page=1&size=20

返回:

{
"code": "0000",
"data": {
"logs": [
{
"time": "2026-03-10T13:32:00+09:00",
"instance_id": "oc-ai-jp-3-08",
"model": "claude-sonnet-4-6",
"prompt_tokens": 850,
"completion_tokens": 320,
"cost_usd": 0.012,
"latency_ms": 1764,
"status": "success",
"is_stream": true
}
],
"total": 142,
"page": 1,
"size": 20
}
}

SQL:

SELECT 
FROM_UNIXTIME(l.created_at) AS time,
l.token_name AS instance_id,
l.model_name AS model,
l.prompt_tokens,
l.completion_tokens,
l.quota / 500000.0 AS cost_usd,
l.elapsed_time AS latency_ms,
CASE WHEN l.type = 2 THEN 'success' ELSE 'error' END AS status,
l.is_stream
FROM oneapi.logs l
JOIN sayclaw_portal.user_instances ui ON l.token_name = ui.instance_id
WHERE ui.user_id = ?
ORDER BY l.created_at DESC
LIMIT ?, ?

3.5 DB 权限注意

portal-api 的 MySQL DSN 需要读取 oneapi.logs 表:

-- 在小龙 MySQL 执行
GRANT SELECT ON oneapi.logs TO 'root'@'%';
-- 或创建专用只读账号
CREATE USER 'portal_reader'@'localhost' IDENTIFIED BY 'xxx';
GRANT SELECT ON sayclaw_portal.* TO 'portal_reader'@'localhost';
GRANT SELECT ON oneapi.logs TO 'portal_reader'@'localhost';

由于 portal-api 使用 root 账号,默认已有跨库 SELECT 权限。


四、前端方案

4.1 WebChat 双栏布局 (/portal/chat/)

┌─────────────────────────────────────────────────────────┐
│ 左栏 (280px, 可折叠) │ 右栏 (flex:1) │
│ │ │
│ MY INSTANCES │ Header: 实例名 · 模型 🟢 │
│ ┌──────────────────────┐ │ ────────────────────────── │
│ │ 🟢 小龙-主实例 ◄──│─│ │
│ │ claude-sonnet-4-6 │ │ 聊天消息区 │
│ │ 今日 1.2k tk · $0.02│ │ │
│ └──────────────────────┘ │ 👤 13:30 你好 │
│ ┌──────────────────────┐ │ 🤖 13:30 你好!有什么... │
│ │ ⚪ jp-3-08 │ │ │
│ │ anthropic/claude │ │ │
│ │ 今日 0 tk │ │ │
│ └──────────────────────┘ │ ────────────────────────── │
│ │ [📎] [输入框] [发送] │
│ ── 📊 今日汇总 ── │ │
│ 请求: 15 次 │ │
│ Tokens: 4.6k │ │
│ 费用: $0.03 │ │
│ [详细用量 →] │ │
└─────────────────────────────────────────────────────────┘

交互逻辑:

  • 页面加载 → 调 GET /me/instances 获取实例列表
  • GET /me/usage/summary 获取今日汇总
  • 默认选中第一个实例(或 URL 参数指定的)
  • 点击实例 → engine.destroy() → 新建 engine → engine.init()
  • 右侧 header、聊天区清空重来
  • 左栏底部 "详细用量" 链接到 /portal/usage/
  • 移动端 (< 768px):左栏隐藏,顶部加 ☰ 按钮展开

消息双写:

// 用户发消息后
addMsg('user', text)
engine.send(text)
fetch(API + '/me/chat/save', {
method: 'POST', headers: authHeaders,
body: JSON.stringify({ instance_id: instanceId, role: 'user', content: text, tokens: 0 })
})

// AI 回复完成 (isFinal=true) 后
onMessage: (text, isFinal) => {
if (isFinal && text) {
fetch(API + '/me/chat/save', {
method: 'POST', headers: authHeaders,
body: JSON.stringify({ instance_id: instanceId, role: 'assistant', content: text, tokens: 0 })
})
}
}

4.2 用量页面 (/portal/usage/)

┌────────────────────────────────────────────────────┐
│ 📊 用量概览 │
│ ┌────────┐ ┌────────┐ ┌────────┐ ┌──────────┐ │
│ │ 今日 │ │ 本周 │ │ 本月 │ │ 总费用 │ │
│ │ 3.4k tk│ │ 28k tk │ │ 128k tk│ │ $1.23 │ │
│ │ 15 次 │ │ 89 次 │ │ 342 次 │ │ │ │
│ └────────┘ └────────┘ └────────┘ └──────────┘ │
│ │
│ 📈 每日趋势 (30天) │
│ ┌────────────────────────────────────────────┐ │
│ │ ▁▂▃▅▇▅▃▂▁▂▃▅▇▅▃▂▁▂▃▅▇▅▃▂▁▂▃▅ │ │
│ │ 03/01 03/10 03/20 03/30│ │
│ └────────────────────────────────────────────┘ │
│ [按 Tokens ▼] [按实例 ▼] │
│ │
│ 📋 请求日志 │
│ ┌──────────────────────────────────────────────┐ │
│ │ 时间 │ 实例 │ 模型 │ Tokens │ 费用 │ │
│ │ 13:32 │ jp-3-08 │ claude │ 850 │ $0.01│ │
│ │ 13:30 │ 主实例 │ claude │ 1.2k │ $0.02│ │
│ │ 12:15 │ jp-3-08 │ gpt-4o │ 320 │ $0.00│ │
│ └──────────────────────────────────────────────┘ │
│ [← 上一页] 1 / 8 [下一页 →] │
└────────────────────────────────────────────────────┘

图表: 使用 Chart.js CDN(轻量,无需 build),折线图展示 30 天趋势。


五、与 Admin 审计的关系

Admin 侧(已有)

功能状态数据源
LLM 请求日志列表oneapi.logs
按实例/模型/日期筛选oneapi.logs
关联用户邮箱oneapi.logs JOIN user_instances JOIN users
管理操作审计admin_logs

Portal 用户侧(待实现)

功能数据源API
自己的用量汇总oneapi.logsGET /me/usage/summary
自己的每日趋势oneapi.logsGET /me/usage/daily
自己的请求日志oneapi.logsGET /me/usage/logs
自己的聊天历史chat_messagesGET /me/chat/history

数据一致性

Admin 看到的审计数据
↕ 完全相同的数据源 (oneapi.logs)
用户看到的用量数据

区别只在于:

  • Admin:看所有实例、所有用户
  • 用户:只看自己的实例(通过 user_instances 过滤)

六、安全考量

风险点措施
用户看到他人数据所有查询强制 WHERE ui.user_id = ?,通过 JWT 获取
聊天内容泄露chat_messages 按 user_id 隔离,API 层校验 instance 归属
oneapi.logs 读权限portal-api 只需 SELECT 权限,不写入 oneapi 库
费用显示精度quota / 500000 换算,前端显示 2 位小数
高频写入 chat_save异步非阻塞(fire-and-forget),失败不影响聊天
SQL 注入全部 prepared statement(Go db.Query 参数化)

七、性能考量

场景数据量预估策略
oneapi.logs 查询日 1k~10k 条已有 created_at + token_name 索引
chat_messages 查询每用户日 50~200 条已有 idx_user_instance 索引
用量汇总月 10w 级可选:定时聚合到 usage_daily 缓存表
前端双写 chat_save每条消息 1 次 HTTPfire-and-forget,不阻塞 UI

可选优化(Phase 2)

  • usage_daily 表定时聚合(每小时 cron),Summary API 先查缓存
  • WebSocket 消息直接在 OC Gateway 回调中写入(省去前端双写)
  • Redis 缓存热门查询(今日汇总)

八、执行计划

Phase任务优先级预估
Phase 1
1.1portal-api: POST /me/chat/saveP030min
1.2portal-api: GET /me/usage/summaryP01h
1.3portal-api: GET /me/usage/dailyP030min
1.4portal-api: GET /me/usage/logsP030min
1.5chat/index.html: 双栏布局 + 切换 + 双写P03h
1.6usage/index.html: 用量仪表盘P12h
1.7测试 + 部署P01h
Phase 2
2.1usage_daily 定时聚合P21h
2.2移动端响应式P21h
2.3聊天搜索功能P32h