Skip to content
个人作品推荐
栾媛爱动物
播放动物叫声趣味微信小程序
栾媛爱动物微信小程序
微信扫码体验

插件开发

文档地图(usage_log 相关)

文档读它当你需要…
日志插件.md业务:环境变量、附件、API、流程、excelize
本文机制:ext 包、目录树、新建插件、表初始化、源码摘录、附录。
usage_log与上游合并-源码入侵.md合并:主仓库非 ext 代码块、i18n、go.mod
与上游-Gitee-手动合并步骤.mdGit:无共同祖先时的 merge 与自检。

与同目录其它文档的分工: usage_log 业务日志插件.md 为准;合并时要粘回主仓库的代码usage_log与上游合并-源码入侵.md 为准。本文保留机制、目录全览、Mermaid 与附录。

站点版说明
若将本文单独发布到站外,可忽略指向同目录 .md 的相对链接,仅以文内目录与代码摘录为准;在本仓库内开发时请使用链接跳转以减少重复维护。

速览:后端 ext 包(接入方式)

  • 注册: ext/register.goMustRegister → 各子包 Register(当前含 usage_log)。
  • 主程序:SetRouter 之前调用 ext.MustRegister(server)(通常仅一行)。
  • Relay: 在路由里、请求体解压之后、relay 子路由(如 /v1/...)注册之前调用 ext.RegisterRelayMiddleware(router)(通常仅一行;实现上委托给各扩展的捕获中间件,避免在 SetRouter 之后再全局 Use 导致 Gin 已注册路由链不含该中间件)。

与上游合并时建议保留的接入点

主干文件(示意路径)建议
main.goext.MustRegister(Session 之后、SetRouter 之前)及 import "github.com/QuantumNous/new-api/ext"
router/relay-router.go(或等价 relay 入口)ext.RegisterRelayMiddlewareDecompress + BodyStorageCleanup 之后、第一个 relay 子路由 Group 之前)。
middleware/auth.go须保留 EnforceUserAuthGET /api/ext/attachment/download 本地文件分支内联鉴权);说明见 源码侵入 · middleware
go.mod / go.sum须包含 github.com/xuri/excelize/v2(表格附件),见 日志插件 · excelize源码侵入 · go.mod
service/http.go本方案不要求为扩展修改(非流式 completion 由包装 ResponseWriter 捕获)。
constant/context_key.go扩展专用 context 键宜放在 ext/usage_log/keys.go,减少对公共常量的改动。

// 开始 的可复制原文usage_log与上游合并-源码入侵.md与 Gitee 无共同祖先时的 git 步骤与上游-Gitee-手动合并步骤.md。其余逻辑尽量集中在 ext/**

usage_log 相关源码模块(示意)

模块路径(示意)职责
ext/relay.goRegisterRelayMiddleware,relay 单点接入
ext/usage_log/register.goLoadConfig、表迁移、RegisterRouter
ext/usage_log/keys.go扩展专用 Gin context 键
ext/usage_log/middleware.go路径判断、Body、ResponseWriter 包装、SSE/JSON 解析
ext/usage_log/config.goLOG_EXT_* 环境变量
ext/usage_log/store.gomodel_ext.go扩展表读写与模型
ext/usage_log/router_ext.goGET /api/ext/log-content

逐文件、逐目录的完整说明见 §2.7

速览:前端扩展(usage-logs 示例)

扩展表格等 UI 放在前端工程的扩展目录中,由业务页按需引用。

模块(示意路径)职责
usage-logs/UsageLogsTableWithContent.jsx扩展表格(问题 / 对话详情列)
usage-logs/LogContentCell.jsx请求 GET /api/ext/log-content
usage-logs/PromptCell.jsx问题列展示

入口挂载: web/src/components/table/usage-logs/index.jsxVITE_EXT_USAGE_LOG_CONTENT 选择扩展表(非 false 时启用);原文与 i18n 键usage_log与上游合并-源码入侵 · 前端

下文 「插件概况」「项目问题」第 1~9 节附录:流式问答 为完整说明与源码摘录。


插件概况

流式问答

usage_log流式stream: true)请求会在中间件中包装 ResponseWriter,按 SSE 解析 data: 行并拼接 delta.content 写入扩展表(开关语义见 日志插件.md §8)。这与非流式一次解析 JSON 不同,但列表仍通过同一 request_id 关联 logs

模拟的流式问答(示意): 客户端连续收到 data: {"choices":[{"delta":{"content":"你"}}]}... 直至 [DONE],网关在扩展表中落一条合并后的 completion_text流式问答见 附录:流式问答

相关代码(示意路径): ext/usage_log/middleware.goext/usage_log/config.go。流式开关语义见 日志插件 · 第 8 节。入口:main.goext.MustRegister、relay 中的 ext.RegisterRelayMiddleware;合并上游代码块见 usage_log与上游合并-源码入侵.md


项目问题

流式问答

常见疑问:流式回答在列表里不完整? 多为客户端中断或超长被截断;只想记问题、不记流式回答?日志插件.md §8 中流式相关变量。流式问答见 附录:流式问答

相关代码: ext/usage_log/store.goSaveContent)、common/gin.goKeyBodyStorage / GetBodyStorage)。Relay 异常时核对 LOG_EXT_RECORD_PROMPT_COMPLETION 与服务端日志中 do request failed 的具体原因。


1. ext 插件机制与扩展步骤

1.1 前置条件

  • 熟悉 Go(包、func、接口)与 Gin(gin.Enginegin.HandlerFunc);前端扩展需了解 React。
  • 会在终端使用 go rungo testbun run dev
  • 不必通读整个网关;以 ext/usage_log/ 为参考实现即可。

1.2 约定

说明
入口仅在 main.go 调用一次 ext.MustRegister(server)(须在 Session 之后、router.SetRouter 之前;捕获中间件在 SetRelayRouter 内解压后挂载,见 第 9.1 节)。
聚合ext/register.goMustRegister 中调用各子包 Register(server)
子包每个扩展一个目录(如 ext/usage_log/),对外提供 Register(*gin.Engine)
Session扩展路由若使用 middleware.UserAuth(),必须先挂载 Session,再 MustRegister
JSON业务代码使用 common.Marshal / common.Unmarshalcommon/json.go),勿直接使用 encoding/json
数据库扩展表用 GORM,须兼容 SQLite / MySQL / PostgreSQL。

1.3 目录结构

项目根目录
├── main.go
├── router/relay-router.go  # 接入 ext.RegisterRelayMiddleware(示意)
├── ext/
│   ├── doc.go, register.go, relay.go, README.md
│   ├── usage_log/
│   │   ├── doc.go, register.go, config.go, keys.go
│   │   ├── model_ext.go, store.go, middleware.go, router_ext.go
│   │   └── middleware_test.go
│   └── table_init/         # init_table.go + usage_log_content_*.sql + usage_log_attachment_*.sql
└── web/src/ext/usage-logs/ # 前端扩展(示例)

(与 §2.7.1 一致,细节见该节。)

1.4 新建插件流程

  1. 跑通主程序:go run main.go;前端在 web/bun install && bun run dev(见第 6 节)。
  2. 理解挂载链:main.goext.MustRegisterext/register.goext/<name>/register.go(对照第 9.1 节9.3 节)。
  3. ext/ 下新建子目录;可复制 ext/usage_log/ 后改名替换,或先写最小 register.go 只挂中间件验证。
  4. ext/register.go 增加 import(路径以 go.mod 为准)并调用 your_pkg.Register(server)
  5. 用环境变量控制开关(参考 usage_logLoadConfig第 9.4 节)。
  6. 测试:go test ./ext/usage_log/ -v 或联调(第 6 节)。
  7. (可选)前端:在 web/src/ext/ 增组件;使用日志扩展示例挂载于 web/src/components/table/usage-logs/index.jsx第 9.11 节)。
  8. 合并上游后核对第 8 节清单。

最小 Register 骨架示例:

go
// ext/my_plugin/register.go
package my_plugin

import "github.com/gin-gonic/gin"

func Register(server *gin.Engine) {
    // server.Use(MyMiddleware())
}

1.5 两种使用方式

场景做法
只使用现有 usage_log扩展默认开启;关闭时设 LOG_EXT_RECORD_PROMPT_COMPLETION=false。按需执行表初始化(第 5 节),前端可用 VITE_EXT_USAGE_LOG_CONTENT 控制。
开发新插件按 1.4 在 ext/ 新建子包并在 ext/register.go 注册。

1.6 常见问题

现象排查
编译找不到包go.mod 与 import 路径是否一致。
API 401Session 是否在 MustRegister 之前;路由是否 UserAuth
中间件不执行Register 是否接入;环境变量是否关闭扩展。Gin 在注册路由时固定中间件链CaptureMiddleware 须在 SetRelayRouterDecompress 之后、Group("/v1") 之前挂载(见 9.1),不可仅在 SetRouter 之后 server.Use 了事。
无表或写库失败AutoMigrateext/table_init/第 5 节)。
开启扩展后 relay 异常捕获中间件已将请求体写入 common.KeyBodyStorage 并与 c.Request.Body 对齐;若仍异常,核对 LOG_EXT_RECORD_PROMPT_COMPLETION 与日志。

2. usage_log 扩展说明

2.1 原则与数据表

  • 不修改 model.Log / logs 表;扩展数据在独立表 usage_log_content
  • 不改动 RecordConsumeLog 等核心写日志逻辑。
  • 通过已有 request_idlogs 关联。

usage_log_content 主要字段: request_id(唯一)、system_prompt_textprompt_textcompletion_textcreated_at。注册时对 model.LOG_DB 执行 AutoMigrate

2.2 环境变量

完整默认值、业务含义及附件相关变量日志插件.md §7–§8(以该文与 ext/usage_log/config.go 为准)。下文本节 9.4 中的 LoadConfig 摘录仅为示意,勿与上表混用为文档默认值

口头说法(与 日志插件.md §8 一致):「流式只记问题」→ 关闭流式回答记录(见该节 LOG_EXT_RECORD_STREAM_COMPLETION / LOG_EXT_STREAM_ONLY 说明);「默认记流式回答」→ 按该节默认行为。

2.3 捕获范围与中间件行为

捕获路径列表日志插件.md §4.1请求/响应侧解析与流式规则日志插件.md §4–§5。未开启总开关则直接 Next();实现细节见 ext/usage_log/middleware.go第 9.6 节)。

2.4 relay 与有效 system 提示词

渠道可能注入或改写 system。中间件先从请求体取 system,c.Next() 后若存在 usage_log.KeyEffectiveSystemPrompt(见 ext/usage_log/keys.go)则用于落库。relay 侧若写入该键,请使用同一常量,避免与 constant 混用。

2.5 只读 API

  • GET /api/ext/log-content?request_id=xxx
  • middleware.UserAuth();仅日志所属用户或管理员可访问。

2.6 注册顺序

usage_log.RegisterLoadConfigmigrateExtTableRegisterRouterCaptureMiddlewareext.RegisterRelayMiddlewarerouter/relay-router.go 中、于 DecompressRequestMiddleware BodyStorageCleanup 之后/v1 等 relay 子路由注册之前 挂载(实现见 ext/relay.go)。main.goext.MustRegister 须在 router.SetRouter 之前。详见第 9.1 节第 9.3 节

2.7 插件目录与文件全览

以下路径均相对网关工程根目录(示意);模块名以当前实现为准。速查表亦见上文 速览:后端 ext

2.7.1 目录树(当前实现)

项目根目录
├── main.go                          # 接入:Session 之后调用 ext.MustRegister(见 §2.6、§9.1)
├── router/
│   └── relay-router.go              # 接入:解压后调用 ext.RegisterRelayMiddleware(见 §2.6、§9.1)
├── ext/
│   ├── README.md                    # 仓库内简要说明(与本文可并行维护)
│   ├── doc.go                       # 包注释:约定、文档入口(无运行时代码)
│   ├── register.go                  # MustRegister:聚合各子扩展的 Register
│   ├── relay.go                     # RegisterRelayMiddleware → usage_log.MountCaptureMiddleware
│   ├── usage_log/                   # 使用日志「问题与回答」子扩展
│   │   ├── doc.go                   # 包注释
│   │   ├── register.go              # Register:LoadConfig、迁移、RegisterRouter;MountCaptureMiddleware
│   │   ├── config.go                # LOG_EXT_* 环境变量与 LoadConfig / getEnvBool / getEnvInt
│   │   ├── keys.go                  # KeyEffectiveSystemPrompt(Gin context 键,避免改公共 constant)
│   │   ├── model_ext.go             # UsageLogContent 模型、TableName、migrateExtTable、getLogDB
│   │   ├── store.go                 # SaveContent(按 request_id upsert)、GetByRequestId
│   │   ├── middleware.go            # CaptureMiddleware、路径列表、Writer 包装、SSE/JSON 解析与截断
│   │   ├── router_ext.go            # RegisterRouter、GET /api/ext/log-content 及权限校验
│   │   └── middleware_test.go       # 单测:路径、prompt/completion/stream 解析、截断等
│   └── table_init/                  # 可选:独立 DDL 初始化扩展表(与 AutoMigrate 二选一或并存)
│       ├── init_table.go            # 可执行:读 .env DSN,执行对应 SQL 文件
│       ├── usage_log_content_sqlite.sql
│       ├── usage_log_content_mysql.sql
│       ├── usage_log_content_pgsql.sql
│       ├── usage_log_attachment_sqlite.sql
│       ├── usage_log_attachment_mysql.sql
│       └── usage_log_attachment_pgsql.sql
└── web/src/ext/                     # 前端扩展(与后端 /api/ext 配合)
    ├── README.md                    # 仓库内前端扩展说明
    └── usage-logs/
        ├── UsageLogsTableWithContent.jsx   # 扩展表格:在重试列后插入「问题」「对话详情」
        ├── PromptCell.jsx                  # 问题列:请求 log-content,展示 prompt_text
        └── LogContentCell.jsx              # 对话详情:弹窗内请求并展示全文

另:使用日志列表页web/src/components/table/usage-logs/index.jsx 通过环境变量 VITE_EXT_USAGE_LOG_CONTENT 选择使用默认表或 UsageLogsTableWithContent(见 §9.11)。

2.7.2 ext/ 根目录文件

文件作用与内容要点
doc.gopackage ext 的包级注释:扩展目录约定、MustRegister / relay 挂载顺序、指向本文档;可执行函数。
register.go导出 MustRegister(server *gin.Engine):调用 usage_log.Register(server);新增扩展时在此追加 other_pkg.Register(server)。须在主程序 router.SetRouter 之前执行。
relay.go导出 RegisterRelayMiddleware(router *gin.Engine):内部调用 usage_log.MountCaptureMiddleware(router),向 Gin 注册捕获中间件。供 relay 路由文件单行接入,避免 relay 直接依赖 usage_log 子包。
README.md给人看的短说明:注册链、与上游合并时的接入点表、指向本文档(仓库内阅读用)。

2.7.3 ext/usage_log/ 子包(后端核心)

文件作用与内容要点
doc.gopackage usage_log 包注释:独立表、与 logs 关联、文档索引。
register.goRegisterLoadConfig()migrateExtTable()RegisterRouter(server)。迁移失败写 SysLog 但不阻断启动。MountCaptureMiddlewarerouter.Use(CaptureMiddleware()),须在解压后、relay 子路由注册前调用。
config.go定义 LOG_EXT_RECORD_PROMPT_COMPLETIONLOG_EXT_MAX_PROMPT_LENLOG_EXT_MAX_COMPLETION_LENLOG_EXT_STREAM_ONLY 等环境变量名;包级变量 RecordPromptCompletionMaxPromptLenMaxCompletionLenRecordStreamCompletionLoadConfig 在 Register 时拉取(依赖主程序已加载 .env)。
keys.go常量 KeyEffectiveSystemPrompt:供 relay 将来写入「实际 system」与中间件读取一致;类型为 constant.ContextKey,减少改动主干 constant/context_key.go
model_ext.go结构体 UsageLogContent(字段:IdRequestIdSystemPromptTextPromptTextCompletionTextCreatedAt);TableName 返回 usage_log_contentmigrateExtTablemodel.LOG_DB 执行 AutoMigrategetLogDB 返回 model.LOG_DB
store.goSaveContent:按 request_id 做 upsert(clause.OnConflict),写入/更新四段文本与时间;LOG_DB 为 nil 时打日志并跳过。GetByRequestId:只读查询,供 API 使用;无行返回 (nil, nil)
middleware.goCaptureMiddleware:若未开启或路径/方法不匹配则直接 Next()。否则读 Body,与 common.KeyBodyStorage / CreateBodyStorage 对齐以便下游复用同一请求体;解析 system/user prompt、stream;非流式用 responseCaptureWriter 包装 ResponseWriter(实现 WriteWriteStringFlush,避免漏写);c.Next() 后合并 effective system(KeyEffectiveSystemPrompt)、解析 completion(非流式自缓冲 JSON,流式自 SSE ExtractCompletionFromStreamBody),截断后 SaveContent。内含 relayCapturePaths(如 /v1/chat/completions 等)、IsCapturePath、各类 Extract*FromBodyTruncateUTF8 等(测试与复用)。
router_ext.goRegisterRouter:注册路由组 /api/extUserAuthGET /log-content。处理函数校验 request_id、查 logs 是否属于当前用户或管理员、再 GetByRequestId 返回扩展表 JSON;无扩展行时返回成功但 data 为空及提示文案。
middleware_test.go覆盖 IsCapturePath、prompt/stream/completion 解析、TruncateUTF8 等,保证扩展逻辑可独立回归。

2.7.4 ext/table_init/(表结构 DDL 工具)

文件作用与内容要点
init_table.gopackage main:从环境变量读取 LOG_SQL_DSNSQL_DSN,解析驱动类型,依次执行 usage_log_content_*.sqlusage_log_attachment_*.sql,用于在未走 GORM AutoMigrate 或需要与 DBA 脚本对齐时初始化表。用法:go run ./ext/table_init(项目根目录)。
usage_log_content_sqlite.sqlSQLite 版 usage_log_content 建表与 request_id 唯一索引created_at 索引。
usage_log_content_mysql.sqlMySQL 版 DDL(与 SQLite 语义一致,语法适配 MySQL)。
usage_log_content_pgsql.sqlPostgreSQL 版 DDL。

2.7.5 web/src/ext/(前端扩展)

文件/目录作用与内容要点
README.md前端扩展说明与指向本文档(仓库内)。
usage-logs/UsageLogsTableWithContent.jsx基于 getLogsColumnsCardTable 组装与上游一致的列,在「重试」列后插入 问题列对话详情列;组合 PromptCellLogContentCell;依赖 VITE_EXT_USAGE_LOG_CONTENT 由入口决定是否采用本组件。
usage-logs/PromptCell.jsx挂载时按 request_id 请求 GET /api/ext/log-content,展示返回中的 prompt_text(省略号);加载/错误态用 Semi UI 组件。
usage-logs/LogContentCell.jsx「对话记录」按钮;打开 Modal 后再请求 /api/ext/log-content,展示 system_prompt_text / prompt_text / completion_text 等;错误信息来自接口 message 或网络异常。

2.7.6 主干中与插件相关的接入位置(非 ext/ 内)

以下文件不属于 ext/ 目录,但承载扩展的唯一推荐接入逐文件可复制代码块usage_log与上游合并-源码入侵.md

位置(示意)内容要点
main.goSession 之后、router.SetRouter 之前 ext.MustRegister(server)(含 import ext)。
router/relay-router.goDecompressRequestMiddleware + BodyStorageCleanup 之后、第一个 Group("/v1") 之前 ext.RegisterRelayMiddleware(router)(详见 源码侵入 · relay-router)。
middleware/auth.goEnforceUserAuth(附件下载)。
go.modgithub.com/xuri/excelize/v2

3. 流程图(Mermaid)

3.1 请求与响应捕获(主流程)

3.2 扩展表写入(SaveContent)

3.3 前端查询扩展内容


4. 前端接入(web/src/ext)

web/src/ext/usage-logs/ 用组合方式扩展表格,复用 getLogsColumnsuseLogsData列表页入口与 i18nusage_log与上游合并-源码入侵 · 前端

构建: 默认启用扩展表格;关闭时执行 VITE_EXT_USAGE_LOG_CONTENT=false bun run build

数据: 列表仍用 /api/log//api/log/self/;扩展正文按需请求 GET /api/ext/log-content?request_id=...。组件节选见 第 9.11 节9.13 节详情区结构说明日志插件.md §11


5. 表初始化(ext/table_init)

项目根目录执行:

bash
go run ./ext/table_init/

读取 .env,优先 LOG_SQL_DSN,否则 SQL_DSN;按库类型执行 ext/table_init/usage_log_content_*.sqlusage_log_attachment_*.sql


6. 开发、测试与运行

环境: Go 版本见 go.mod;前端用 Bun;数据库三选一;可选 Redis 或 MEMORY_CACHE_ENABLED=true

常用命令:

  • 后端:go run main.go(默认 3000)
  • 前端:cd web && bun install && bun run dev(Vite 默认 5173)
  • 嵌入前端:cd web && bun run build && cd .. && go run main.go,访问 http://localhost:3000

测试:

bash
go test ./ext/usage_log/ -v

手动联调:默认已开启扩展;若曾关闭,可 export LOG_EXT_RECORD_PROMPT_COMPLETION=true 后启动,从响应头取 request_id,查库或请求 /api/ext/log-content


7. 内网依赖与上游渠道

内网 Go 代理(示例):

bash
export GOPROXY=http://data-oceanus.enflame.cn:80/artifactory/go_official_remote/
# 可选: export GONOSUMDB="*"

可先 source scripts/go-env.shgo mod download

渠道: 管理后台新增 OpenAI 类型渠道,Base URL 为上游根地址(勿带末尾 /v1),配置 Key、模型与分组。验证示例:

bash
curl --location --request POST 'http://localhost:3000/v1/chat/completions' \
  -H 'Authorization: Bearer YOUR_TOKEN' \
  -H 'Content-Type: application/json' \
  -d '{"model":"gpt-4o","stream":false,"messages":[{"role":"user","content":"你好"}]}'

8. 已知问题与合并上游

已知: 流式依赖 SSE 缓冲,客户端中断可能导致内容不完整;超长字段受 LOG_EXT_MAX_* 截断;其他能力(如 MCP)需单独方案。许可证见仓库 LICENSE

合并上游后建议核对:

  1. 主仓库侵入点按 usage_log与上游合并-源码入侵.md〔合并段〕 与代码块恢复(含 middleware/auth.go 中的 EnforceUserAuth,供附件下载内联鉴权);挂载顺序见上文 第 9.1 节 与《源码侵入》非 ext 文件一览
  2. ext/register.go 仍调用 usage_log.Register
  3. 若 relay 写入有效 system,使用 ext/usage_log/keys.go 中的 KeyEffectiveSystemPrompt第 2.4 节)。
  4. 前端 usage-logs/index.jsx 与 i18n 见 usage_log与上游合并-源码入侵 · 前端
  5. 表结构变更同步 ext/usage_logext/table_init

9. 源码摘录

以下与仓库当前实现一致;行号可能变化,以本地文件为准

9.1 main.go:import、Session 与挂载顺序

文件: main.gorouter/relay-router.go

为何 MustRegisterSetRouter 之前: ext.MustRegister 负责 LoadConfig、迁移、RegisterRouter/api/ext/...)。捕获中间件不能SetRouter 之后再全局 server.Use(CaptureMiddleware):Gin 在注册路由时固定中间件链,事后追加的全局 Use 不会进入已注册的 relay 处理链。

解压与捕获顺序: CaptureMiddlewareext.RegisterRelayMiddleware(router) 挂在 DecompressRequestMiddlewareBodyStorageCleanup 之后第一个 relay 子路由 Group 之前;业务侧说明见 日志插件 · 第 2 节

与仓库一致的、带 // 开始 / // 结束 标记的可复制片段(含 import ext、session 块、MustRegisterRegisterRelayMiddleware)见 usage_log与上游合并-源码入侵.md,避免本文与源码漂移时双处修改。下列为最小顺序示意(无标记、可能非最新):

go
	// … Session …
	ext.MustRegister(server)
	router.SetRouter(server, buildFS, indexPage)
go
	router.Use(middleware.DecompressRequestMiddleware())
	router.Use(middleware.BodyStorageCleanup())
	ext.RegisterRelayMiddleware(router)
	router.Use(middleware.StatsMiddleware())
	modelsRouter := router.Group("/v1/models")

9.2 ext/register.go

文件: ext/register.go — 约 8–13 行

go
// MustRegister 在 main 中单点挂载:注册所有扩展(迁移、中间件、API)。
// 新增扩展时在此处追加对应子包的 Register 调用。
func MustRegister(server *gin.Engine) {
	usage_log.Register(server)
	// 后续扩展示例:other_ext.Register(server)
}

9.3 ext/usage_log/register.go

文件: ext/usage_log/register.go — 节选

go
func Register(server *gin.Engine) {
	LoadConfig()
	if err := migrateExtTable(); err != nil {
		common.SysLog("ext/usage_log: migration skipped or failed: " + err.Error())
	}
	RegisterRouter(server)
}

ext/relay.go(relay 单点接入):

go
func RegisterRelayMiddleware(router *gin.Engine) {
	usage_log.MountCaptureMiddleware(router)
}

9.4 ext/usage_log/config.go

文件: ext/usage_log/config.go。环境变量名称与业务含义日志插件.md §8数字默认值(如截断长度)以 defaultMaxPromptLen / defaultMaxCompletionLen日志插件.md §7 为准

go
func LoadConfig() {
	RecordPromptCompletion = getEnvBool(envRecordPromptCompletion, true)
	MaxPromptLen = getEnvInt(envMaxPromptLen, defaultMaxPromptLen)
	MaxCompletionLen = getEnvInt(envMaxCompletionLen, defaultMaxCompletionLen)
	// RecordStreamCompletion:优先 LOG_EXT_RECORD_STREAM_COMPLETION,否则 LOG_EXT_STREAM_ONLY(见 config.go 全文)
	// … 附件、file_url、PDF 注入等见同文件 …
}

9.5 ext/usage_log/model_ext.go

文件: ext/usage_log/model_ext.go — 约 10–33 行

go
type UsageLogContent struct {
	Id               int    `json:"id" gorm:"primaryKey;autoIncrement"`
	RequestId        string `json:"request_id" gorm:"type:varchar(64);uniqueIndex:idx_usage_log_content_request_id"`
	SystemPromptText string `json:"system_prompt_text" gorm:"type:text"`
	PromptText       string `json:"prompt_text" gorm:"type:text"`
	CompletionText   string `json:"completion_text" gorm:"type:text"`
	CreatedAt        int64  `json:"created_at" gorm:"bigint;index"`
}

func (UsageLogContent) TableName() string {
	return "usage_log_content"
}

func migrateExtTable() error {
	return getLogDB().AutoMigrate(&UsageLogContent{})
}

9.6 ext/usage_log/middleware.go(节选)

文件: ext/usage_log/middleware.go — 约 15–31 行

go
var relayCapturePaths = []string{
	"/v1/chat/completions",
	"/v1/completions",
	"/v1/messages",
	"/pg/chat/completions",
}

func IsCapturePath(path string) bool {
	for _, p := range relayCapturePaths {
		if path == p {
			return true
		}
	}
	return false
}

同一文件 — 约 46–112 行,CaptureMiddleware

go
func CaptureMiddleware() gin.HandlerFunc {
	return func(c *gin.Context) {
		if !RecordPromptCompletion {
			c.Next()
			return
		}
		if c.Request.Method != http.MethodPost || !IsCapturePath(c.Request.URL.Path) {
			c.Next()
			return
		}
		requestId := c.GetString(common.RequestIdKey)
		if requestId == "" {
			c.Next()
			return
		}

		body, err := io.ReadAll(c.Request.Body)
		if err != nil {
			c.Next()
			return
		}
		c.Request.Body = io.NopCloser(bytes.NewReader(body))

		systemPromptText := ExtractSystemPromptFromBody(body)
		systemPromptText = TruncateUTF8(systemPromptText, MaxPromptLen)
		promptText := ExtractPromptFromBody(body)
		promptText = TruncateUTF8(promptText, MaxPromptLen)
		isStream := ExtractStreamFromBody(body)

		var completionText string
		if !isStream {
			capWriter := &responseCaptureWriter{
				ResponseWriter: c.Writer,
				body:          &bytes.Buffer{},
			}
			c.Writer = capWriter
			c.Next()
			if effective := common.GetContextKeyString(c, KeyEffectiveSystemPrompt); effective != "" {
				systemPromptText = TruncateUTF8(effective, MaxPromptLen)
			}
			completionText = ExtractCompletionFromBody(capWriter.body.Bytes())
			completionText = TruncateUTF8(completionText, MaxCompletionLen)
			SaveContent(requestId, systemPromptText, promptText, completionText)
			return
		}
		capWriter := &responseCaptureWriter{
			ResponseWriter: c.Writer,
			body:          &bytes.Buffer{},
		}
		c.Writer = capWriter
		c.Next()
		if effective := common.GetContextKeyString(c, KeyEffectiveSystemPrompt); effective != "" {
			systemPromptText = TruncateUTF8(effective, MaxPromptLen)
		}
		if !RecordStreamCompletion {
			SaveContent(requestId, systemPromptText, promptText, "")
			return
		}
		completionText = ExtractCompletionFromStreamBody(capWriter.body.Bytes())
		completionText = TruncateUTF8(completionText, MaxCompletionLen)
		SaveContent(requestId, systemPromptText, promptText, completionText)
	}
}

9.7 ext/usage_log/store.go(节选)

文件: ext/usage_log/store.go — 约 11–37 行

go
func SaveContent(requestId, systemPromptText, promptText, completionText string) {
	if requestId == "" {
		return
	}
	db := getLogDB()
	now := time.Now().Unix()
	var row UsageLogContent
	err := db.Where("request_id = ?", requestId).First(&row).Error
	if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
		common.SysLog("ext/usage_log: failed to query usage_log_content: " + err.Error())
		return
	}
	if errors.Is(err, gorm.ErrRecordNotFound) {
		row = UsageLogContent{
			RequestId:        requestId,
			SystemPromptText: systemPromptText,
			PromptText:       promptText,
			CompletionText:   completionText,
			CreatedAt:        now,
		}
		if err := db.Create(&row).Error; err != nil {
			common.SysLog("ext/usage_log: failed to create usage_log_content: " + err.Error())
		}
		return
	}

9.8 ext/usage_log/router_ext.go

文件: ext/usage_log/router_ext.go — 约 12–68 行

go
func RegisterRouter(server *gin.Engine) {
	g := server.Group("/api/ext")
	g.Use(middleware.UserAuth())
	g.GET("/log-content", handleGetLogContent)
}

func handleGetLogContent(c *gin.Context) {
	requestId := c.Query("request_id")
	if requestId == "" {
		c.JSON(http.StatusBadRequest, gin.H{
			"success": false,
			"message": "request_id 不能为空",
		})
		return
	}

	// 校验:该 request_id 对应的 log 须属于当前用户,或当前用户为管理员
	var logRow model.Log
	err := getLogDB().Model(&model.Log{}).Where("request_id = ?", requestId).First(&logRow).Error
	if err != nil {
		c.JSON(http.StatusNotFound, gin.H{
			"success": false,
			"message": "未找到对应日志",
		})
		return
	}
	userId := c.GetInt("id")
	role := c.GetInt("role")
	if logRow.UserId != userId && role < common.RoleAdminUser {
		c.JSON(http.StatusForbidden, gin.H{
			"success": false,
			"message": "无权限查看该日志内容",
		})
		return
	}

	row, err := GetByRequestId(requestId)
	if err != nil || row == nil {
		c.JSON(http.StatusOK, gin.H{
			"success": true,
			"data":    nil,
			"message": "无扩展内容",
		})
		return
	}
	c.JSON(http.StatusOK, gin.H{
		"success": true,
		"data": gin.H{
			"request_id":         row.RequestId,
			"system_prompt_text": row.SystemPromptText,
			"prompt_text":        row.PromptText,
			"completion_text":   row.CompletionText,
			"created_at":        row.CreatedAt,
		},
	})
}

9.9 ext/usage_log/keys.go

扩展专用 context 键放在 ext 内,避免改动 constant/context_key.go,合并上游时冲突更少。

go
const KeyEffectiveSystemPrompt constant.ContextKey = "effective_system_prompt"

非流式 completion 仅从 responseCaptureWriter 捕获,修改 service/http.gocommon/gin.go。若将来在 relay 中写入「有效 system」,请使用 common.SetContextKey(c, usage_log.KeyEffectiveSystemPrompt, ...)(与中间件读取的键一致)。

9.11 web 使用日志页挂载

文件: web/src/components/table/usage-logs/index.jsx — 约 24–29、65 行

javascript
import UsageLogsTableWithContent from '../../../ext/usage-logs/UsageLogsTableWithContent';
const LogsTableComponent =
  import.meta.env.VITE_EXT_USAGE_LOG_CONTENT !== 'false'
    ? UsageLogsTableWithContent
    : LogsTable;
javascript
        <LogsTableComponent {...logsData} />

9.12 UsageLogsTableWithContent.jsx(节选)

文件: web/src/ext/usage-logs/UsageLogsTableWithContent.jsx — 约 40–58、98–109 行

javascript
  const allColumns = useMemo(() => {
    return getLogsColumns({
      t,
      COLUMN_KEYS,
      copyText,
      showUserInfoFunc,
      openChannelAffinityUsageCacheModal,
      isAdminUser,
      billingDisplayMode,
    });
  }, [
    t,
    COLUMN_KEYS,
    copyText,
    showUserInfoFunc,
    openChannelAffinityUsageCacheModal,
    isAdminUser,
    billingDisplayMode,
  ]);
javascript
  const tableColumns = useMemo(() => {
    const base = compactMode
      ? visibleColumnsList.map(({ fixed, ...rest }) => rest)
      : visibleColumnsList;
    const retryIdx = base.findIndex((col) => col.key === COLUMN_KEYS.RETRY);
    const insertIdx = retryIdx >= 0 ? retryIdx + 1 : base.length;
    return [
      ...base.slice(0, insertIdx),
      ...extColumnsAfterRetry,
      ...base.slice(insertIdx),
    ];
  }, [compactMode, visibleColumnsList, extColumnsAfterRetry, COLUMN_KEYS.RETRY]);

9.13 LogContentCell.jsx(节选)

文件: web/src/ext/usage-logs/LogContentCell.jsx — 约 20–28 行

javascript
  const fetchContent = async () => {
    if (!requestId) return;
    setLoading(true);
    setError(null);
    setData(null);
    try {
      const res = await API.get('/api/ext/log-content', {
        params: { request_id: requestId },
      });

附录:流式问答

本节对应上文 插件概况 → 流式问答项目问题 → 流式问答 中的「流式问答见附录」链接。

环境变量(流式)

变量默认说明
LOG_EXT_RECORD_PROMPT_COMPLETIONtrue总开关;默认开启;设为 false 关闭捕获。
LOG_EXT_STREAM_ONLYtrue为 true 时流式也会解析 SSE 写入 completion_text;为 false 则流式只记问题侧。

模拟流式请求(curl)

YOUR_TOKEN 换为网关令牌;streamtrue 时响应为 SSE,终端会持续输出直至结束:

bash
curl -N -X POST 'http://localhost:3000/v1/chat/completions' \
  -H 'Authorization: Bearer YOUR_TOKEN' \
  -H 'Content-Type: application/json' \
  -d '{"model":"gpt-4o","stream":true,"messages":[{"role":"user","content":"你好"}]}'

联调时从响应头取 request_id,再请求 GET /api/ext/log-content?request_id=... 查看扩展表中合并后的回答文本。

非流式:request_id 与上游 JSON 里的 id

  • 网关为每次请求生成 X-Oneapi-Request-Id(与使用日志表中的 request_id 列一致),扩展表 usage_log_content 也按该值关联。
  • 上游返回 JSON 里的 id(如 DeepSeek/OpenAI 的 completion id)不是网关的 request_id,不能用于 /api/ext/log-content?request_id=,也不能在界面里用该字段对扩展内容。
  • 用 curl 联调时请带 -v-i,从响应头读取 X-Oneapi-Request-Id,再打开使用日志中同一列 request_id 查看「对话记录」。

说明

  • 「模拟」指用 curl 等客户端模拟浏览器/应用的流式消费;网关侧行为与真实业务调用一致。
  • 流式依赖 SSE 缓冲,客户端提前断开可能导致 completion_text 不完整(见 第 8 节 已知问题)。

实现以仓库源码为准。