插件开发
文档地图(usage_log 相关)
| 文档 | 读它当你需要… |
|---|---|
| 日志插件.md | 业务:环境变量、附件、API、流程、excelize。 |
| 本文 | 机制:ext 包、目录树、新建插件、表初始化、源码摘录、附录。 |
| usage_log与上游合并-源码入侵.md | 合并:主仓库非 ext 代码块、i18n、go.mod。 |
| 与上游-Gitee-手动合并步骤.md | Git:无共同祖先时的 merge 与自检。 |
与同目录其它文档的分工: usage_log 业务以 日志插件.md 为准;合并时要粘回主仓库的代码以 usage_log与上游合并-源码入侵.md 为准。本文保留机制、目录全览、Mermaid 与附录。
站点版说明
若将本文单独发布到站外,可忽略指向同目录.md的相对链接,仅以文内目录与代码摘录为准;在本仓库内开发时请使用链接跳转以减少重复维护。
速览:后端 ext 包(接入方式)
- 注册:
ext/register.go→MustRegister→ 各子包Register(当前含usage_log)。 - 主程序: 在
SetRouter之前调用ext.MustRegister(server)(通常仅一行)。 - Relay: 在路由里、请求体解压之后、relay 子路由(如
/v1/...)注册之前调用ext.RegisterRelayMiddleware(router)(通常仅一行;实现上委托给各扩展的捕获中间件,避免在SetRouter之后再全局Use导致 Gin 已注册路由链不含该中间件)。
与上游合并时建议保留的接入点
| 主干文件(示意路径) | 建议 |
|---|---|
main.go | ext.MustRegister(Session 之后、SetRouter 之前)及 import "github.com/QuantumNous/new-api/ext"。 |
router/relay-router.go(或等价 relay 入口) | ext.RegisterRelayMiddleware(Decompress + BodyStorageCleanup 之后、第一个 relay 子路由 Group 之前)。 |
middleware/auth.go | 须保留 EnforceUserAuth(GET /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.go | RegisterRelayMiddleware,relay 单点接入 |
ext/usage_log/register.go | LoadConfig、表迁移、RegisterRouter |
ext/usage_log/keys.go | 扩展专用 Gin context 键 |
ext/usage_log/middleware.go | 路径判断、Body、ResponseWriter 包装、SSE/JSON 解析 |
ext/usage_log/config.go | LOG_EXT_* 环境变量 |
ext/usage_log/store.go、model_ext.go | 扩展表读写与模型 |
ext/usage_log/router_ext.go | GET /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.jsx 由 VITE_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.go、ext/usage_log/config.go。流式开关语义见 日志插件 · 第 8 节。入口:main.go 的 ext.MustRegister、relay 中的 ext.RegisterRelayMiddleware;合并上游代码块见 usage_log与上游合并-源码入侵.md。
项目问题
流式问答
常见疑问:流式回答在列表里不完整? 多为客户端中断或超长被截断;只想记问题、不记流式回答? 见 日志插件.md §8 中流式相关变量。流式问答见 附录:流式问答。
相关代码: ext/usage_log/store.go(SaveContent)、common/gin.go(KeyBodyStorage / GetBodyStorage)。Relay 异常时核对 LOG_EXT_RECORD_PROMPT_COMPLETION 与服务端日志中 do request failed 的具体原因。
1. ext 插件机制与扩展步骤
1.1 前置条件
- 熟悉 Go(包、
func、接口)与 Gin(gin.Engine、gin.HandlerFunc);前端扩展需了解 React。 - 会在终端使用
go run、go test、bun run dev。 - 不必通读整个网关;以
ext/usage_log/为参考实现即可。
1.2 约定
| 项 | 说明 |
|---|---|
| 入口 | 仅在 main.go 调用一次 ext.MustRegister(server)(须在 Session 之后、router.SetRouter 之前;捕获中间件在 SetRelayRouter 内解压后挂载,见 第 9.1 节)。 |
| 聚合 | 在 ext/register.go 的 MustRegister 中调用各子包 Register(server)。 |
| 子包 | 每个扩展一个目录(如 ext/usage_log/),对外提供 Register(*gin.Engine)。 |
| Session | 扩展路由若使用 middleware.UserAuth(),必须先挂载 Session,再 MustRegister。 |
| JSON | 业务代码使用 common.Marshal / common.Unmarshal(common/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 新建插件流程
- 跑通主程序:
go run main.go;前端在web/下bun install && bun run dev(见第 6 节)。 - 理解挂载链:
main.go→ext.MustRegister→ext/register.go→ext/<name>/register.go(对照第 9.1 节–9.3 节)。 - 在
ext/下新建子目录;可复制ext/usage_log/后改名替换,或先写最小register.go只挂中间件验证。 - 在
ext/register.go增加import(路径以go.mod为准)并调用your_pkg.Register(server)。 - 用环境变量控制开关(参考
usage_log的LoadConfig,第 9.4 节)。 - 测试:
go test ./ext/usage_log/ -v或联调(第 6 节)。 - (可选)前端:在
web/src/ext/增组件;使用日志扩展示例挂载于web/src/components/table/usage-logs/index.jsx(第 9.11 节)。 - 合并上游后核对第 8 节清单。
最小 Register 骨架示例:
// 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 401 | Session 是否在 MustRegister 之前;路由是否 UserAuth。 |
| 中间件不执行 | Register 是否接入;环境变量是否关闭扩展。Gin 在注册路由时固定中间件链:CaptureMiddleware 须在 SetRelayRouter 内 Decompress 之后、Group("/v1") 之前挂载(见 9.1),不可仅在 SetRouter 之后 server.Use 了事。 |
| 无表或写库失败 | AutoMigrate 或 ext/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_id与logs关联。
表 usage_log_content 主要字段: request_id(唯一)、system_prompt_text、prompt_text、completion_text、created_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=xxxmiddleware.UserAuth();仅日志所属用户或管理员可访问。
2.6 注册顺序
usage_log.Register:LoadConfig → migrateExtTable → RegisterRouter;CaptureMiddleware 由 ext.RegisterRelayMiddleware 在 router/relay-router.go 中、于 DecompressRequestMiddleware 与 BodyStorageCleanup 之后、/v1 等 relay 子路由注册之前 挂载(实现见 ext/relay.go)。main.go 中 ext.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.go | package 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.go | package usage_log 包注释:独立表、与 logs 关联、文档索引。 |
register.go | Register:LoadConfig() → migrateExtTable() → RegisterRouter(server)。迁移失败写 SysLog 但不阻断启动。MountCaptureMiddleware:router.Use(CaptureMiddleware()),须在解压后、relay 子路由注册前调用。 |
config.go | 定义 LOG_EXT_RECORD_PROMPT_COMPLETION、LOG_EXT_MAX_PROMPT_LEN、LOG_EXT_MAX_COMPLETION_LEN、LOG_EXT_STREAM_ONLY 等环境变量名;包级变量 RecordPromptCompletion、MaxPromptLen、MaxCompletionLen、RecordStreamCompletion;LoadConfig 在 Register 时拉取(依赖主程序已加载 .env)。 |
keys.go | 常量 KeyEffectiveSystemPrompt:供 relay 将来写入「实际 system」与中间件读取一致;类型为 constant.ContextKey,减少改动主干 constant/context_key.go。 |
model_ext.go | 结构体 UsageLogContent(字段:Id、RequestId、SystemPromptText、PromptText、CompletionText、CreatedAt);TableName 返回 usage_log_content;migrateExtTable 对 model.LOG_DB 执行 AutoMigrate;getLogDB 返回 model.LOG_DB。 |
store.go | SaveContent:按 request_id 做 upsert(clause.OnConflict),写入/更新四段文本与时间;LOG_DB 为 nil 时打日志并跳过。GetByRequestId:只读查询,供 API 使用;无行返回 (nil, nil)。 |
middleware.go | CaptureMiddleware:若未开启或路径/方法不匹配则直接 Next()。否则读 Body,与 common.KeyBodyStorage / CreateBodyStorage 对齐以便下游复用同一请求体;解析 system/user prompt、stream;非流式用 responseCaptureWriter 包装 ResponseWriter(实现 Write、WriteString、Flush,避免漏写);c.Next() 后合并 effective system(KeyEffectiveSystemPrompt)、解析 completion(非流式自缓冲 JSON,流式自 SSE ExtractCompletionFromStreamBody),截断后 SaveContent。内含 relayCapturePaths(如 /v1/chat/completions 等)、IsCapturePath、各类 Extract*FromBody、TruncateUTF8 等(测试与复用)。 |
router_ext.go | RegisterRouter:注册路由组 /api/ext,UserAuth,GET /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.go | package main:从环境变量读取 LOG_SQL_DSN 或 SQL_DSN,解析驱动类型,依次执行 usage_log_content_*.sql 与 usage_log_attachment_*.sql,用于在未走 GORM AutoMigrate 或需要与 DBA 脚本对齐时初始化表。用法:go run ./ext/table_init(项目根目录)。 |
usage_log_content_sqlite.sql | SQLite 版 usage_log_content 建表与 request_id 唯一索引、created_at 索引。 |
usage_log_content_mysql.sql | MySQL 版 DDL(与 SQLite 语义一致,语法适配 MySQL)。 |
usage_log_content_pgsql.sql | PostgreSQL 版 DDL。 |
2.7.5 web/src/ext/(前端扩展)
| 文件/目录 | 作用与内容要点 |
|---|---|
README.md | 前端扩展说明与指向本文档(仓库内)。 |
usage-logs/UsageLogsTableWithContent.jsx | 基于 getLogsColumns、CardTable 组装与上游一致的列,在「重试」列后插入 问题列、对话详情列;组合 PromptCell、LogContentCell;依赖 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.go | Session 之后、router.SetRouter 之前 ext.MustRegister(server)(含 import ext)。 |
router/relay-router.go | DecompressRequestMiddleware + BodyStorageCleanup 之后、第一个 Group("/v1")… 之前 ext.RegisterRelayMiddleware(router)(详见 源码侵入 · relay-router)。 |
middleware/auth.go | EnforceUserAuth(附件下载)。 |
go.mod | github.com/xuri/excelize/v2。 |
3. 流程图(Mermaid)
3.1 请求与响应捕获(主流程)
3.2 扩展表写入(SaveContent)
3.3 前端查询扩展内容
4. 前端接入(web/src/ext)
在 web/src/ext/usage-logs/ 用组合方式扩展表格,复用 getLogsColumns 与 useLogsData;列表页入口与 i18n见 usage_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)
项目根目录执行:
go run ./ext/table_init/读取 .env,优先 LOG_SQL_DSN,否则 SQL_DSN;按库类型执行 ext/table_init/ 下 usage_log_content_*.sql 与 usage_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
测试:
go test ./ext/usage_log/ -v手动联调:默认已开启扩展;若曾关闭,可 export LOG_EXT_RECORD_PROMPT_COMPLETION=true 后启动,从响应头取 request_id,查库或请求 /api/ext/log-content。
7. 内网依赖与上游渠道
内网 Go 代理(示例):
export GOPROXY=http://data-oceanus.enflame.cn:80/artifactory/go_official_remote/
# 可选: export GONOSUMDB="*"可先 source scripts/go-env.sh 再 go mod download。
渠道: 管理后台新增 OpenAI 类型渠道,Base URL 为上游根地址(勿带末尾 /v1),配置 Key、模型与分组。验证示例:
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。
合并上游后建议核对:
- 主仓库侵入点按 usage_log与上游合并-源码入侵.md 各 〔合并段〕 与代码块恢复(含
middleware/auth.go中的EnforceUserAuth,供附件下载内联鉴权);挂载顺序见上文 第 9.1 节 与《源码侵入》非 ext 文件一览。 ext/register.go仍调用usage_log.Register。- 若 relay 写入有效 system,使用
ext/usage_log/keys.go中的KeyEffectiveSystemPrompt(第 2.4 节)。 - 前端
usage-logs/index.jsx与 i18n 见 usage_log与上游合并-源码入侵 · 前端。 - 表结构变更同步
ext/usage_log与ext/table_init。
9. 源码摘录
以下与仓库当前实现一致;行号可能变化,以本地文件为准。
9.1 main.go:import、Session 与挂载顺序
文件: main.go、router/relay-router.go。
为何 MustRegister 在 SetRouter 之前: ext.MustRegister 负责 LoadConfig、迁移、RegisterRouter(/api/ext/...)。捕获中间件不能在 SetRouter 之后再全局 server.Use(CaptureMiddleware):Gin 在注册路由时固定中间件链,事后追加的全局 Use 不会进入已注册的 relay 处理链。
解压与捕获顺序: CaptureMiddleware 经 ext.RegisterRelayMiddleware(router) 挂在 DecompressRequestMiddleware 与 BodyStorageCleanup 之后、第一个 relay 子路由 Group 之前;业务侧说明见 日志插件 · 第 2 节。
与仓库一致的、带 // 开始 / // 结束 标记的可复制片段(含 import ext、session 块、MustRegister、RegisterRelayMiddleware)见 usage_log与上游合并-源码入侵.md,避免本文与源码漂移时双处修改。下列为最小顺序示意(无标记、可能非最新):
// … Session …
ext.MustRegister(server)
router.SetRouter(server, buildFS, indexPage) 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 行
// 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 — 节选
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 单点接入):
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 为准。
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 行
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 行
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
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 行
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 行
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,合并上游时冲突更少。
const KeyEffectiveSystemPrompt constant.ContextKey = "effective_system_prompt"非流式 completion 仅从 responseCaptureWriter 捕获,不修改 service/http.go 或 common/gin.go。若将来在 relay 中写入「有效 system」,请使用 common.SetContextKey(c, usage_log.KeyEffectiveSystemPrompt, ...)(与中间件读取的键一致)。
9.11 web 使用日志页挂载
文件: web/src/components/table/usage-logs/index.jsx — 约 24–29、65 行
import UsageLogsTableWithContent from '../../../ext/usage-logs/UsageLogsTableWithContent';
const LogsTableComponent =
import.meta.env.VITE_EXT_USAGE_LOG_CONTENT !== 'false'
? UsageLogsTableWithContent
: LogsTable; <LogsTableComponent {...logsData} />9.12 UsageLogsTableWithContent.jsx(节选)
文件: web/src/ext/usage-logs/UsageLogsTableWithContent.jsx — 约 40–58、98–109 行
const allColumns = useMemo(() => {
return getLogsColumns({
t,
COLUMN_KEYS,
copyText,
showUserInfoFunc,
openChannelAffinityUsageCacheModal,
isAdminUser,
billingDisplayMode,
});
}, [
t,
COLUMN_KEYS,
copyText,
showUserInfoFunc,
openChannelAffinityUsageCacheModal,
isAdminUser,
billingDisplayMode,
]); 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 行
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_COMPLETION | true | 总开关;默认开启;设为 false 关闭捕获。 |
LOG_EXT_STREAM_ONLY | true | 为 true 时流式也会解析 SSE 写入 completion_text;为 false 则流式只记问题侧。 |
模拟流式请求(curl)
将 YOUR_TOKEN 换为网关令牌;stream 为 true 时响应为 SSE,终端会持续输出直至结束:
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 节 已知问题)。
实现以仓库源码为准。
