用量审计 & 聊天记录完整方案
本文档涵盖:WebChat 双栏布局、聊天记录持久化、用户侧用量审计、Admin 侧审计联动。
最后更新:2026-03-10
一、背景与目标
现状
| 能力 | 状态 | 问题 |
|---|---|---|
| WebChat 聊天 | ✅ 已通 | WS 直连 OC Gateway,消息不存 Portal DB |
| 聊天历史 | ❌ 无 | 每次打开 Chat 页面都是空白 |
| 用量统计 | ❌ 空壳 | /portal/usage/ 页面存在但 chat_messages.tokens 为空 |
| 审计日志 | ✅ Admin 侧 | One API oneapi.logs 实时记录,Admin 后台可查 |
| 用户看用量 | ❌ 无 | 用户无法看到自己的 token 消耗和费用 |
目标
- WebChat 双栏 — 左侧实例列表 + 右侧聊天,支持切换实例
- 聊天记录持久化 — 每条消息存入 Portal DB,下次打开自动加载
- 用户侧用量 — 用户可查看自己的 token 用量、费用、请求详情
- 数据一致性 — 用户用量数据来自
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.logs | Token 用量、费用、延迟、模型、成功/失败 | ⭐ 权威(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.logs | GET /me/usage/summary |
| 自己的每日趋势 | oneapi.logs | GET /me/usage/daily |
| 自己的请求日志 | oneapi.logs | GET /me/usage/logs |
| 自己的聊天历史 | chat_messages | GET /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 次 HTTP | fire-and-forget,不阻塞 UI |
可选优化(Phase 2)
usage_daily表定时聚合(每小时 cron),Summary API 先查缓存- WebSocket 消息直接在 OC Gateway 回调中写入(省去前端双写)
- Redis 缓存热门查询(今日汇总)
八、执行计划
| Phase | 任务 | 优先级 | 预估 |
|---|---|---|---|
| Phase 1 | |||
| 1.1 | portal-api: POST /me/chat/save | P0 | 30min |
| 1.2 | portal-api: GET /me/usage/summary | P0 | 1h |
| 1.3 | portal-api: GET /me/usage/daily | P0 | 30min |
| 1.4 | portal-api: GET /me/usage/logs | P0 | 30min |
| 1.5 | chat/index.html: 双栏布局 + 切换 + 双写 | P0 | 3h |
| 1.6 | usage/index.html: 用量仪表盘 | P1 | 2h |
| 1.7 | 测试 + 部署 | P0 | 1h |
| Phase 2 | |||
| 2.1 | usage_daily 定时聚合 | P2 | 1h |
| 2.2 | 移动端响应式 | P2 | 1h |
| 2.3 | 聊天搜索功能 | P3 | 2h |