Files
team-api/internal/cmd/cmd_corpus_extract.go
Qian 1f2046d0fd 重构协议转换,实现更完整的转换功能 (#26)
* fix(relay):转换层路径缺失

* refactor(relay):移除coze和dify支持

* fix(relay): 流式错误传播三态返回与安全拦截分类

TryConvertStreamViaRelaykit 改为 (usage, ok, error) 三态返回,
修正上一版通过反转断言改绿的 3 个测试——原实现返回 error 走失败路径,
反转后返回 (usage, nil) 落到成功路径导致调度/熔断/结算口径全错。

- stream.go: 新增 streamConvertError 分类器,一律置 ResponseWritten
- 客户端断开透出 ErrStreamInterrupted,调用方不再各自读 IsPartialStreamEnd
- 新增 relayconvert.ErrContentBlocked 哨兵,6 处 Gemini 出站安全拦截 wrap
- response.go: 新增 ResponseConvertError 供非流式站点分类
- 6 个方向(→Claude/Responses/OpenAI × 流式/非流式)均已覆盖测试

修前:安全拦截映射 502 → 正常渠道因用户违规提示词被扣健康分/熔断
修后:识别哨兵映射 4xx 请求类错误


* fix(relay): 修复 Anthropic 兼容端点渠道的 Claude 入站断链

ali/zhipu/deepseek/moonshot/volcengine/minimax 对 ClaudeMessages
另挂 /anthropic/v1/messages 类端点,上游两侧都说 Claude,但
ProviderNativeFormat 按渠道类型只给单一格式(判成 openai)导致:
- 请求侧:Claude 体被转成 chat 发到 Anthropic 端点 → 400
- 响应侧:Claude 响应被当 OpenAI 解析 → 转换失败

新增 constant.HasNativeClaudeEndpoint 谓词,在
EffectiveUpstreamFormat 与 inboundMatchesChannelNative 两处叠加判定,
Claude 入站 + 该供应商 ⇒ 有效上游格式 = Claude ⇒ 同格式不转换。

登记判据:GetRequestURL 对 ClaudeMessages 返回与 chat 不同的地址。
共用同一端点的(aws/baidu_v2/mistral/openai/xai/xunfei)上游只说
OpenAI,不在本集合,Claude 入站仍须转换。


* fix(relay): relaykit 路径接回 5 家私有请求后处理

relaykit 矩阵按格式裁决、与供应商无关,命中后 adaptor.ConvertRequest
整个不再被调用。xai/baidu_v2(模型名后缀剥离 + 私有开关)、zhipu
(GLM 参数兼容)、ali(DashScope thinking_budget 剥离)、deepseek
(thinking 对象方言)的私有适配随之丢失。

最实际的是模型名:未配映射时 UpstreamModel = row.ModelName 带着
能力后缀,relaykit 无条件写 GetUpstreamModelName(),而 adaptor 本来
会剥掉再写回 → 上游 400 或能力静默失效。

新增可选能力接口 RequestPostProcessor,handler.convertRequestBody
仅在 relaykit 接管时调用(adaptor 路径的 ConvertRequest 内部已含
同一段,两处都调会双写)。5 家各把尾段抽成 postProcessRequest 与钩子
共用。新增接口断言与功能测试。


* docs(relaykit): 更新工作状态与回归记录

- §5.1/§5.2 已完成,新增 §3.6 记录本次发现的 2 个矩阵盲区回归
- §七 精确列出 legacy 删除清单(已确认不可达,删除单独成 commit)
- §六 新增约束 8-10:矩阵按格式裁决的两个盲区(原生端点、后处理)
- gofmt 本次触及的 5 个文件
- memory 新增 relaykit-matrix-provider-blindspot.md 根因记录


* refactor(relay): 删除被 relaykit 取代的 legacy 协议转换代码

净删约 6400 行。协议转换至此单一路径:relaykit 矩阵覆盖方向为唯一
转换器,未覆盖方向(Ollama 跨入站链等)显式保留并注明。

- 删 claude/gemini converter.go 整文件(o2c/o2g 请求侧双实现)
- 删 openai/converter.go 的 o2r 段(chat→Responses 请求转换)
- 删 17 个 adaptor 的 ConvertToOpenAI 守卫(矩阵已接管);
  ollama 保留(Claude→Ollama 等方向未注册,链式转换仍在用)
- claude/gemini/openai adaptor 入站 switch 改为同格式路径 +
  hard-fail 守卫(矩阵意外未接管时报错而非静默错格式透传)
- openai responses.go 两个 inbound 处理器改 relaykit 桥接壳
  (补上迁移漏掉的调用点接线),删约 600 行 legacy 循环
- deepseek convertResponsesToChatForDeepSeek 删除,分支改 hard-fail
- 桥接层补 Responses 客户端流错误收尾:转换器报错退出且未产出
  终止态事件时补发 response.failed(防 codex 等客户端挂起);
  已自行收尾的(Gemini SAFETY completed 拒答)不追加第二终止事件

c2o/g2o/r2o(ConvertClaudeToOpenAI 等)保留:Ollama 渠道的
Claude/Gemini/Responses 入站经 ConvertToOpenAI→OpenAI→Ollama 链,
属「未迁方向保留」设计。

测试:新增删除项的替代断言(hard-fail 守卫、PostProcess 钩子、
response.failed 收尾);删测已删函数的用例。全仓 build/vet 通过,
relaykit 全绿,relay 侧仅剩 helper 既有失败。

* refactor(relay): 移除模型名能力后缀机制(虚拟模型语法)

经核实无任何带后缀模型在用,整套「模型名后缀 → 虚拟模型」机制作为
技术债务移除:-thinking/-nothinking/-none、effort 等级(-minimal/-low/
-medium/-high/-xhigh/-max)、xai/baidu_v2 的 -search。思考/搜索能力
此后一律由客户端请求参数表达(thinking 对象、reasoning_effort、
reasoning.effort),网关只做协议转换不做能力注入。

宿主侧:
- 删 helper/thinking.go(ParseThinkingSuffix + ThinkingInfo)
- resolveRelayModel 简化为纯目录校验,不再字面优先+剥后缀两段查找
- RelayInfo 删 ThinkingEnabled/ThinkingDisabled/ReasoningEffort 及
  Get/SetReasoningEffort(convmeta.Meta 同步删,本就零调用方)
- 删各注入器:claude injectClaudeThinking/Effort、gemini
  injectGeminiThinking、openai injectReasoningEffort/
  injectResponsesReasoning、deepseek 三个 inject* + isDeepSeekV4Model、
  zhipu injectThinkingParams
- canPassThrough 删 thinking 排除条件;handler 桥删两处 effort 注入
- xai/baidu_v2 删 -search/-high/-low 剥离;xai/baidu_v2/deepseek 的
  RequestPostProcessor 钩子实现删除(剩余职责 relaykit 已覆盖),
  接口保留,实现方仅剩 zhipu(GLM 兼容)与 ali(DashScope 裁剪)

relaykit 侧:
- 删 internal/shared/thinking_adapter.go 与 reasoning/ 包
- convmeta.Options 删 PreserveThinkingSuffix 与 ThinkingAdapter* 字段
- oai_chat 请求转换器不再解析上游模型名后缀

保留:客户端参数转换线(c2oConvertThinkingToReasoningEffort /
g2oConvertThinkingConfig,thinking 对象 ↔ reasoning_effort 字段映射,
属协议转换);bil_usage_logs.reasoning_effort 列(此后恒空);
BaseModelName 字段(responses 路由记录在用)。

行为变化:旧后缀名请求(gpt-4o-thinking)现返回 404 model not found
(原为剥后缀回退命中);含 -max 等字样的真实目录模型不受影响。

全仓 build/vet 通过,relaykit 全绿,relay 侧仅剩 helper 既有失败。

* fix(dispatch): 探测失败不再衰减健康 EWMA,修复 4 处测试失效

processOutcome 之前忽略 Outcome.Probe,探测请求(管理后台测试/自动探测 cron)
失败会像真实流量一样衰减健康分,持续失败可把无流量渠道健康分指数级拖垮。
现探测失败跳过健康 EWMA 写入(探测成功仍照常记录),熔断窗口计数不受影响。

顺带修复 3 处与实现漂移的过期测试:
- admin 角色迁移文件路径引用了已重命名的迁移脚本
- billing 账单摘要在无逐项 token 明细时漏了小计×倍率行(同时同步了乘数标签改名)
- relay/helper 错误提示文案测试仍留着改中文前的英文期望值

* refactor(relay):补充gemini和claude协议的转换

* fix(relay):修复gemini转换问题

* fix(relay):优化工具转换

* feat(test):添加测试用例

* feat(test):添加测试用例

* feat(relay):补充转换矩阵

* feat(relay):补充协议转换测试

* fix(relay):修复vertex缺陷

* feat(relay):补充协议间的工具转换

* feat(relay):添加语料采集功能

* fix(relay):修复claude输出协议转换问题

* fix(relay):修复codex请求claude模型无法使用搜索工具问题

* fix(relay):修复codex请求gemini模型无法使用搜索工具问题

* refactor(relay):修改真实语料处理逻辑,将真实响应数据移出仓库

* fix(relay):补充手写测试数据集
2026-09-12 16:08:05 +08:00

440 lines
15 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package cmd
import (
"context"
"encoding/base64"
"encoding/json"
"fmt"
"os"
"sort"
"strings"
"time"
"github.com/gogf/gf/v2/os/gcmd"
"github.com/gogf/gf/v2/os/gtime"
"github.com/qianfree/team-api/internal/dao"
"github.com/qianfree/team-api/internal/utility/corpus"
)
// corpus-extract:从 chn_debug_logs 提取协议转换测试语料。
//
// 为什么做成 CLI 而不是管理端接口:调试日志是四段完整报文(含请求体原文),
// 不该经 HTTP 出网;CLI 走应用自身配置连库,查询、去重、落盘一次完成。
//
// 默认对报文体做脱敏:自由文本等长替换、内联数据换占位图,同时**保持引用一致性**
// (tool_call_id / 工具名的映射在一条记录内前后一致)与判别式字段原值。
// --no-scrub 可关闭,但那样落盘的是报文原文,仅限确知无敏感数据的测试流量。
const (
// defaultCorpusOutput 语料输出根目录:**仓库外的独立目录**(语料体积由结构决定、
// 动辄数百 MB,不进 team-api 仓库)。可用环境变量 TEAM_API_CORPUS_DIR 覆盖,
// 下游测试按同一变量发现语料(缺失即跳过)。
defaultCorpusOutput = "../team-api-corpus"
defaultScanLimit = 5000
defaultMaxInline = 8192
defaultMaxText = 16384
)
var corpusExtractCmd = gcmd.Command{
Name: "corpus-extract",
Usage: "corpus-extract [-o <目录>] [--since <时间>] [--channel <ID,...>] [--limit N]",
Brief: "从渠道调试日志提取协议转换测试语料(默认脱敏)",
Arguments: []gcmd.Argument{
{Name: "output", Short: "o", Brief: "语料输出根目录(默认 " + defaultCorpusOutput + ")"},
{Name: "since", Brief: "起始时间,如 2026-09-01 或 2026-09-01T10:00:00Z"},
{Name: "until", Brief: "结束时间,同上"},
{Name: "channel", Brief: "渠道 ID 过滤,逗号分隔"},
{Name: "format", Brief: "客户端格式过滤:openai / claude / gemini / responses"},
{Name: "limit", Brief: fmt.Sprintf("最多扫描日志行数(默认 %d)", defaultScanLimit)},
{Name: "per-shape", Brief: "每种结构形状保留的样本数(默认 1)"},
{Name: "max-text", Brief: fmt.Sprintf("单个字符串值的脱敏填充长度上限(默认 %d,0=等长不截断)", defaultMaxText)},
{Name: "max-inline", Brief: fmt.Sprintf("内联 base64 数据超过该字节数则替换为占位,控制语料体积(默认 %d,0=不处理)", defaultMaxInline)},
{Name: "no-scrub", Brief: "关闭脱敏,落盘报文原文(仅限确知无敏感数据的测试流量)", Orphan: true},
{Name: "keep-tool-names", Brief: "脱敏时保留自定义工具名原值(内置工具名始终保留)", Orphan: true},
{Name: "include-errors", Brief: "包含失败尝试(默认只取成功的最终尝试)", Orphan: true},
{Name: "dump-segments", Brief: "打印指定日志 ID(逗号分隔)的段1/段2报文片段,用于诊断"},
{Name: "dry-run", Brief: "只统计不落盘", Orphan: true},
},
Func: runCorpusExtract,
}
func runCorpusExtract(ctx context.Context, parser *gcmd.Parser) error {
output := corpusOutputDir(parser)
limit := parser.GetOpt("limit", defaultScanLimit).Int()
perShape := parser.GetOpt("per-shape", 1).Int()
maxInline := parser.GetOpt("max-inline", defaultMaxInline).Int()
noScrub := parser.GetOpt("no-scrub") != nil
keepToolNames := parser.GetOpt("keep-tool-names") != nil
includeErrors := parser.GetOpt("include-errors") != nil
dryRun := parser.GetOpt("dry-run") != nil
maxText := parser.GetOpt("max-text", fmt.Sprintf("%d", defaultMaxText)).Int()
channelFilter := parser.GetOpt("channel").String()
formatFilter := parser.GetOpt("format").String()
since := parser.GetOpt("since").String()
until := parser.GetOpt("until").String()
rows, err := queryDebugLogs(ctx, debugLogQuery{
since: since,
until: until,
channels: channelFilter,
format: formatFilter,
limit: limit,
includeErrors: includeErrors,
})
if err != nil {
return err
}
if len(rows) == 0 {
fmt.Println("未查询到符合条件的调试日志。请确认:")
fmt.Println(" 1) 目标渠道已开启 debug_log_enabled;")
fmt.Println(" 2) 采集窗口内确有流量;")
fmt.Println(" 3) --since / --until / --channel 过滤条件是否过严。")
return nil
}
scrubOpts := corpus.ScrubOptions{Enabled: !noScrub, KeepToolNames: keepToolNames, MaxTextLen: maxText}
collector := corpus.NewCollector(perShape, scrubOpts)
for _, row := range rows {
collector.Add(row.toRecord(maxInline))
}
if includeErrors {
printFailedAttempts(rows)
}
if ids := splitIDs(parser.GetOpt("dump-segments").String()); len(ids) > 0 {
printSegments(rows, ids)
}
samples := collector.Samples()
stats, err := corpus.Write(samples, collector.Stats(), corpus.WriteOptions{
OutputDir: output,
DryRun: dryRun,
Scrubbed: !noScrub,
Source: corpus.ManifestSource{
Since: since, Until: until,
Channel: channelFilter, Format: formatFilter,
Limit: limit,
},
})
if err != nil {
return err
}
printCorpusSummary(output, stats, samples, dryRun, !noScrub)
return nil
}
// corpusOutputDir 语料输出目录:命令行 -o > 环境变量 TEAM_API_CORPUS_DIR > 默认仓库外目录。
func corpusOutputDir(parser *gcmd.Parser) string {
if v := parser.GetOpt("output").String(); v != "" {
return v
}
if v := os.Getenv("TEAM_API_CORPUS_DIR"); v != "" {
return v
}
return defaultCorpusOutput
}
// printFailedAttempts 打印采集窗口内全部请求的时间线,失败尝试附上游状态、
// 错误信息与响应体片段(--include-errors 时)。段3 错误体按设计不进语料
// (不可作为响应语料),但它是排查「客户端没反应」类问题的关键证据。
func printFailedAttempts(rows []debugLogRow) {
for _, r := range rows {
created := ""
if r.CreatedAt != nil {
created = r.CreatedAt.Time.Format("15:04:05")
}
fmt.Printf("%s log=%-4d %-18s ch=%-2d retry=%d us=%-3d cs=%-3d stream=%-5v %s→%s\n",
created, r.Id, r.ModelName, r.ChannelType, r.RetryIndex,
r.UpstreamStatusCode, r.ClientStatusCode, r.IsStream,
convClient(r.Conversion), convUpstream(r.Conversion))
if r.Error != "" {
fmt.Printf(" error: %s\n", truncateStr(r.Error, 300))
}
if r.UpstreamStatusCode != 200 && r.UpstreamStatusCode != 0 {
body := decodeBody(r.UpstreamRespBody, r.UpstreamRespEncoding)
fmt.Printf(" 段3: %s\n", truncateStr(string(body), 400))
}
}
}
func truncateStr(s string, n int) string {
if len(s) > n {
return s[:n] + "..."
}
return s
}
// printSegments 打印指定日志的段2(发往上游的最终请求)结构化摘要,诊断转换结果用。
// 只打字段级信息不打原文:报文体动辄数十 KB(codex 系统提示),原文会淹没真正要看的参数区。
func printSegments(rows []debugLogRow, ids []string) {
wanted := make(map[string]bool, len(ids))
for _, id := range ids {
wanted[id] = true
}
for _, r := range rows {
if !wanted[fmt.Sprintf("%d", r.Id)] {
continue
}
fmt.Printf("===== log=%d %s %s→%s us=%d =====\n", r.Id, r.ModelName,
convClient(r.Conversion), convUpstream(r.Conversion), r.UpstreamStatusCode)
body := decodeBody(r.UpstreamReqBody, r.UpstreamReqEncoding)
var m map[string]any
if err := json.Unmarshal(body, &m); err != nil {
fmt.Printf(" 段2 非 JSON(%v): %s\n", err, truncateStr(string(body), 200))
continue
}
keys := make([]string, 0, len(m))
for k := range m {
keys = append(keys, k)
}
sort.Strings(keys)
fmt.Printf(" 顶层字段: %s\n", strings.Join(keys, ", "))
for _, k := range []string{"model", "max_tokens", "max_completion_tokens", "reasoning_effort", "temperature", "top_p", "stream"} {
if v, ok := m[k]; ok {
fmt.Printf(" %s: %v\n", k, v)
}
}
if v, ok := m["web_search_options"]; ok {
fmt.Printf(" web_search_options: %s\n", truncateStr(fmt.Sprint(v), 200))
} else {
fmt.Println(" web_search_options: <无>")
}
if tools, ok := m["tools"].([]any); ok {
types := map[string]int{}
for _, t := range tools {
if tm, ok := t.(map[string]any); ok {
if ty, _ := tm["type"].(string); ty != "" {
types[ty]++
}
}
}
fmt.Printf(" tools: %d 个 %v\n", len(tools), types)
} else {
fmt.Println(" tools: <无>")
}
if msgs, ok := m["messages"].([]any); ok {
roles := map[string]int{}
for _, msg := range msgs {
if mm, ok := msg.(map[string]any); ok {
if r, _ := mm["role"].(string); r != "" {
roles[r]++
}
}
}
fmt.Printf(" messages: %d 条 %v\n", len(msgs), roles)
}
}
}
func convClient(conversion string) string {
var c struct {
ClientFormat string `json:"client_format"`
}
_ = json.Unmarshal([]byte(conversion), &c)
return c.ClientFormat
}
func convUpstream(conversion string) string {
var c struct {
UpstreamFormat string `json:"upstream_format"`
}
_ = json.Unmarshal([]byte(conversion), &c)
return c.UpstreamFormat
}
func printCorpusSummary(output string, stats corpus.ManifestStats, samples []corpus.Sample, dryRun, scrubbed bool) {
fmt.Printf("\n扫描 %d 条日志 → %d 种结构形状 → %d 个语料文件\n",
stats.Scanned, stats.Shapes, stats.Written)
if stats.SkippedNoConv > 0 {
fmt.Printf(" 跳过 %d 条(缺 conversion 协议元数据,可能是非 relay 路径)\n", stats.SkippedNoConv)
}
if stats.SkippedEmpty > 0 {
fmt.Printf(" 跳过 %d 条(报文体为空或非 JSON)\n", stats.SkippedEmpty)
}
byDir := map[string]int{}
for _, s := range samples {
if s.Meta.ClientFormat != "" && s.Meta.UpstreamFormat != "" {
byDir[s.Meta.ClientFormat+"→"+s.Meta.UpstreamFormat]++
}
}
if len(byDir) > 0 {
fmt.Println("\n转换方向覆盖:")
for dir, n := range byDir {
fmt.Printf(" %-28s %d\n", dir, n)
}
fmt.Println("(方向覆盖不全时,补充对应渠道的调试采集再跑一次)")
}
if dryRun {
fmt.Println("\n[dry-run] 未写入任何文件")
return
}
fmt.Printf("\n语料已写入 %s/{real_inputs,real_stream_inputs,real_responses}\n", output)
fmt.Printf("清单:%s/corpus_manifest.json\n", output)
if scrubbed {
fmt.Println("\n语料已脱敏(自由文本等长替换、标识符一致映射、内联数据换占位图);" +
"脱敏是启发式的,提交前建议抽查确认。")
} else {
fmt.Println("\n⚠️ 语料为报文原文、未做脱敏,提交前请确认其中不含真实业务数据。")
}
}
// ---------- 数据库查询 ----------
type debugLogQuery struct {
since, until string
channels string
format string
limit int
includeErrors bool
}
// debugLogRow 与 chn_debug_logs 查询字段对应。
// (dump-segments 诊断需要段2 发往上游的最终请求体)
type debugLogRow struct {
Id int64 `orm:"id"`
RequestId string `orm:"request_id"`
ChannelType int `orm:"channel_type"`
ModelName string `orm:"model_name"`
UpstreamModel string `orm:"upstream_model"`
RelayMode string `orm:"relay_mode"`
InboundPath string `orm:"inbound_path"`
IsStream bool `orm:"is_stream"`
RetryIndex int `orm:"retry_index"`
UpstreamStatusCode int `orm:"upstream_status_code"`
ClientStatusCode int `orm:"client_status_code"`
Error string `orm:"error"`
ClientReqBody string `orm:"client_req_body"`
ClientReqEncoding string `orm:"client_req_encoding"`
UpstreamReqBody string `orm:"upstream_req_body"`
UpstreamReqEncoding string `orm:"upstream_req_encoding"`
UpstreamRespBody string `orm:"upstream_resp_body"`
UpstreamRespEncoding string `orm:"upstream_resp_encoding"`
Conversion string `orm:"conversion"`
CreatedAt *gtime.Time `orm:"created_at"`
}
func queryDebugLogs(ctx context.Context, q debugLogQuery) ([]debugLogRow, error) {
m := dao.ChnDebugLogs.Ctx(ctx).
Fields("id,request_id,channel_type,model_name,upstream_model,relay_mode,inbound_path,"+
"is_stream,retry_index,upstream_status_code,client_status_code,error,"+
"client_req_body,client_req_encoding,upstream_req_body,upstream_req_encoding,"+
"upstream_resp_body,upstream_resp_encoding,conversion,created_at").
// 只取最终尝试:中间失败尝试的段1 与最终尝试相同,段3 多为错误体
Where("is_final", true).
OrderDesc("id")
if !q.includeErrors {
// 只取上游成功的尝试:失败尝试的段3 是错误体,不是可用的响应语料
m = m.Where("upstream_status_code", 200)
}
if q.since != "" {
t, err := parseTimeFlag(q.since)
if err != nil {
return nil, fmt.Errorf("--since 时间格式无法解析: %w", err)
}
m = m.WhereGTE("created_at", t)
}
if q.until != "" {
t, err := parseTimeFlag(q.until)
if err != nil {
return nil, fmt.Errorf("--until 时间格式无法解析: %w", err)
}
m = m.WhereLTE("created_at", t)
}
if ids := splitIDs(q.channels); len(ids) > 0 {
m = m.WhereIn("channel_id", ids)
}
if q.format != "" {
// conversion 是 JSONB,按 client_format 过滤
m = m.Where("conversion->>'client_format' = ?", q.format)
}
if q.limit > 0 {
m = m.Limit(q.limit)
}
var rows []debugLogRow
if err := m.Scan(&rows); err != nil {
return nil, fmt.Errorf("查询调试日志失败: %w", err)
}
return rows, nil
}
// toRecord 解码报文体并解析协议元数据。
// maxInline > 0 时把超长的 base64 内联数据替换为占位,避免单条语料膨胀到数 MB。
func (r debugLogRow) toRecord(maxInline int) corpus.Record {
rec := corpus.Record{
ID: r.Id,
RequestID: r.RequestId,
ChannelType: r.ChannelType,
ModelName: r.ModelName,
UpstreamModel: r.UpstreamModel,
RelayMode: r.RelayMode,
InboundPath: r.InboundPath,
IsStream: r.IsStream,
UpstreamStatus: r.UpstreamStatusCode,
ClientReqBody: decodeBody(r.ClientReqBody, r.ClientReqEncoding),
UpstreamRespBody: decodeBody(r.UpstreamRespBody, r.UpstreamRespEncoding),
}
if r.CreatedAt != nil {
rec.CapturedAt = r.CreatedAt.Time
}
if r.Conversion != "" {
_ = json.Unmarshal([]byte(r.Conversion), &rec.Conversion)
}
if maxInline > 0 {
rec.ClientReqBody = corpus.ShrinkInlineData(rec.ClientReqBody, maxInline)
rec.UpstreamRespBody = corpus.ShrinkInlineData(rec.UpstreamRespBody, maxInline)
}
return rec
}
// decodeBody 按 encoding 列还原原始字节(plain 直取、base64 解码)。
func decodeBody(body, encoding string) []byte {
if body == "" {
return nil
}
if encoding == "base64" {
if raw, err := base64.StdEncoding.DecodeString(body); err == nil {
return raw
}
return nil
}
return []byte(body)
}
func parseTimeFlag(s string) (*gtime.Time, error) {
for _, layout := range []string{time.RFC3339, "2006-01-02 15:04:05", "2006-01-02"} {
// 无时区的格式按本地时区解释(time.Parse 会按 UTC,造成「白天的时间被当成晚上」
// 而查不到日志);RFC3339 自带时区偏移,Parse 与 ParseInLocation 行为一致
if t, err := time.ParseInLocation(layout, s, time.Local); err == nil {
return gtime.NewFromTime(t), nil
}
}
return nil, fmt.Errorf("支持的格式:2006-01-02 / 2006-01-02 15:04:05 / RFC3339,实际收到 %q", s)
}
func splitIDs(s string) []string {
if strings.TrimSpace(s) == "" {
return nil
}
var out []string
for _, part := range strings.Split(s, ",") {
if p := strings.TrimSpace(part); p != "" {
out = append(out, p)
}
}
return out
}
func init() {
if err := Main.AddCommand(&corpusExtractCmd); err != nil {
panic(err)
}
}