第一章:Go语言搜题的底层逻辑与生态定位
Go语言并非为教育场景原生设计,但其并发模型、静态编译与极简运行时,意外契合搜题系统对低延迟响应、高吞吐检索及边缘部署的严苛要求。搜题本质是“图像/文本→结构化题干→语义匹配→精准答案”的链式处理,Go凭借原生goroutine与channel,在OCR后处理、题干标准化、向量相似度计算等I/O密集与轻量CPU任务间实现无锁协同,避免Java或Python因GC停顿或GIL导致的响应抖动。
核心能力适配性
- 零依赖二进制分发:
go build -o search-engine main.go生成单文件可执行程序,直接部署至树莓派或边缘网关,无需目标环境安装运行时; - 内存安全边界清晰:无指针算术与手动内存管理,规避C/C++在题库解析(如LaTeX公式AST遍历)中常见的缓冲区溢出风险;
- 模块化依赖治理:通过
go.mod精确锁定github.com/tidwall/gjson(JSON题库快速路径提取)、github.com/blevesearch/bleve(轻量全文检索)等组件版本,杜绝“依赖地狱”。
典型技术栈对比
| 维度 | Go方案 | Python方案 |
|---|---|---|
| 冷启动耗时 | 200+ms(解释器加载+包导入) | |
| 并发连接数 | 10万+(goroutine内存开销2KB) | ~5千(线程栈默认8MB) |
| 部署包体积 | 12MB(含HTTP服务+检索引擎) | 300MB+(含conda环境) |
关键代码片段:题干实时标准化流水线
func normalizeQuestion(ctx context.Context, raw string) (string, error) {
// 步骤1:异步清洗(去广告符号、统一空格)
clean := strings.Map(func(r rune) rune {
if unicode.IsSpace(r) { return ' ' }
if r == '×' || r == '*' { return '×' } // 统一乘号
return r
}, raw)
// 步骤2:同步正则归一化(数字单位、括号格式)
clean = regexp.MustCompile(`\s+`).ReplaceAllString(clean, " ")
clean = regexp.MustCompile(`\(([^)]+)\)`).ReplaceAllString(clean, "【$1】") // 圆括号→方括号
// 步骤3:超时控制,防恶意长文本阻塞
select {
case <-time.After(50 * time.Millisecond):
return "", fmt.Errorf("timeout in normalization")
default:
return clean, nil
}
}
该函数在毫秒级完成多阶段文本规约,体现Go对确定性延迟的工程承诺——这正是搜题服务SLA保障的底层支点。
第二章:go.dev/search 隐藏参数深度解析与实战调优
2.1 检索语法与字段限定符:title:、pkg:、example: 的精确匹配原理与误用案例
字段限定符通过前缀强制约束检索范围,底层依赖倒排索引的 term-level 精确匹配(非分词匹配),跳过全文分析流程。
匹配原理示意
# 正确:严格字面匹配,区分大小写与空格
title:"Spring Boot Actuator"
pkg:org.springframework.boot.autoconfigure
example:WebMvcConfigurer
上述查询直接查找
title字段中完整等于"Spring Boot Actuator"的文档;pkg:要求pkg字段值完全等于org.springframework.boot.autoconfigure(不匹配org.springframework.boot.autoconfigure.web);example:同理,仅命中显式标注为该类名的示例片段。
常见误用场景
- ❌
title:spring boot→ 触发分词,可能匹配spring或boot单独出现的文档 - ❌
pkg:boot→pkg字段未做子串索引,无法模糊匹配 - ✅ 应始终使用英文双引号包裹多词值,单词也建议加引号确保原子性
| 限定符 | 匹配类型 | 是否支持通配符 | 示例(有效) |
|---|---|---|---|
title: |
完全匹配 | 否(* 不生效) |
title:"@Transactional" |
pkg: |
完全匹配 | 否 | pkg:"com.example.api" |
example: |
完全匹配 | 否 | example:RestTemplate |
2.2 版本过滤参数 v= 与 go.mod 兼容性验证:如何绕过缓存获取真实历史版本结果
Go 模块代理(如 proxy.golang.org)默认对 v= 参数的版本查询强缓存,导致 go list -m -versions 返回非权威结果。需强制穿透缓存获取真实历史版本。
绕过缓存的核心方法
使用 GOPROXY=direct 并配合 -insecure(仅限私有仓库)或设置 GOINSECURE 环境变量:
# 直连模块源,跳过所有代理与缓存
GOPROXY=direct go list -m -versions github.com/gin-gonic/gin@v1.9.0
逻辑分析:
GOPROXY=direct禁用代理链,使go命令直接向模块源(如 GitHub)发起GET /@v/list请求;v=参数在此模式下被严格解析为语义化版本前缀匹配,不受 CDN 缓存干扰。
兼容性验证要点
- ✅
go.mod中require行版本格式(v1.9.0)必须与v=参数完全一致 - ❌ 不支持通配符(如
v1.9.*)或模糊匹配
| 场景 | 是否返回真实历史版本 | 原因 |
|---|---|---|
GOPROXY=https://proxy.golang.org |
否(缓存主导) | 代理对 /@v/list 强缓存 |
GOPROXY=direct |
是 | 直连源站,实时响应 |
graph TD
A[go list -m -versions] --> B{GOPROXY 设置}
B -->|direct| C[直连模块源<br>/@v/list]
B -->|proxy.golang.org| D[代理缓存响应]
C --> E[真实历史版本列表]
2.3 代码片段高亮与上下文还原机制:search?q= 的 AST 级语义识别边界与失效场景
AST 边界识别原理
当解析 search?q=console.log("x") 时,引擎需在 URL query 中定位 JavaScript 表达式起止——这依赖于 acorn 构建的 AST 节点边界(如 ExpressionStatement 的 start/end 字段),而非简单正则匹配。
// 基于 acorn.parse 的 AST 边界提取示例
const ast = acorn.parse('console.log("x")', {
ecmaVersion: 2022,
locations: true // ✅ 关键:启用 source location
});
console.log(ast.body[0].start); // → 0
console.log(ast.body[0].end); // → 19
locations: true启用源码位置信息;start/end是字节偏移量,用于映射原始 URL 字符串中的高亮范围。若缺失该选项,AST 将无位置上下文,导致高亮漂移。
失效典型场景
| 场景 | 原因 | 影响 |
|---|---|---|
模板字符串嵌套 ${eval('x')} |
AST 中 TemplateLiteral 子节点位置不连续 |
上下文还原断裂 |
search?q= 后含未闭合引号 |
acorn 抛 SyntaxError,无法生成 AST |
高亮机制完全降级为纯文本匹配 |
graph TD
A[URL query] --> B{acorn.parse?}
B -->|Success| C[AST with locations]
B -->|Fail| D[回退至正则粗匹配]
C --> E[精确高亮+作用域还原]
D --> F[无语法感知,易误标]
2.4 并发请求限流与 User-Agent 策略:高频搜题时触发 429 响应的底层 HTTP/2 连接复用陷阱
当客户端在毫秒级间隔内并发发起数十个搜题请求,看似独立的 HTTP/2 流(stream)实则共享同一 TCP 连接与 TLS 会话。服务端基于连接粒度统计请求频次,导致 429 Too Many Requests 提前触发。
HTTP/2 复用下的隐式限流放大效应
- 同一
:authority+User-Agent的所有流被归入同一限流桶 - 浏览器/SDK 默认复用连接,未显式控制
max-concurrent-streams - 服务端限流中间件(如 nginx
limit_req)常按$binary_remote_addr+$http_user_agent组合键计数
User-Agent 的双刃剑角色
| User-Agent 值 | 限流影响 | 风险示例 |
|---|---|---|
Mozilla/5.0 (X11; Linux) |
易与其他工具共用桶,误伤正常用户 | 教育类 App 被连带限流 |
XueBaApp/3.8.2 (iOS) |
精确隔离,但需确保唯一性 | 版本号硬编码致灰度失效 |
# 模拟 HTTP/2 连接复用场景下的限流误判
import httpx
client = httpx.Client(http2=True, limits=httpx.Limits(max_connections=10))
for i in range(50):
# 所有请求复用同一连接池中的连接
client.get("https://api.example.com/search",
headers={"User-Agent": "XueBaApp/3.8.2 (Android)"})
此代码中
httpx.Client默认启用连接复用,50 次请求可能仅通过 2–3 个底层 TCP 连接发出;服务端若按连接维度聚合请求速率,将远超单连接10 req/s限值,直接返回 429。
graph TD
A[客户端发起50个搜题请求] --> B{HTTP/2 复用策略}
B --> C[合并至2个TCP连接]
C --> D[每个连接承载25个stream]
D --> E[服务端按连接ID+UA计数]
E --> F[单连接请求速率达25req/s > 限制阈值10]
F --> G[返回429]
2.5 搜索结果排序权重逆向工程:基于 Go 文档生成时间、模块导入热度与 example 覆盖率的隐式排序逻辑
GoDoc 与 pkg.go.dev 的搜索排序并非仅依赖关键词匹配,而是融合三项隐式信号:
- 文档生成时间(
doc_age_score):越新发布的模块版本,时间衰减因子越小 - 模块导入热度(
import_count):从proxy.golang.org元数据中统计跨项目引用频次 - Example 覆盖率(
ex_ratio):/example子目录下可执行示例占导出函数比例
权重计算核心逻辑(Go 实现)
// score.go: 综合排序得分计算(简化版)
func ComputeRankScore(mod *Module, now time.Time) float64 {
ageDays := now.Sub(mod.DocGenTime).Hours() / 24
timeWeight := math.Max(0.1, 1.0-math.Log10(ageDays+1)) // 对数衰减,7天后≈0.7
importWeight := math.Min(1.0, math.Log10(float64(mod.ImportCount)+10)) // 平滑对数缩放
exRatio := float64(mod.ExampleCount) / float64(mod.ExportFuncCount+1)
exWeight := 0.3 + 0.7*exRatio // 基础分0.3,满覆盖得1.0
return 0.4*timeWeight + 0.35*importWeight + 0.25*exWeight
}
该函数将三维度归一至 [0,1] 区间,并按经验权重加权。DocGenTime 精确到秒,避免同日发布模块的排序随机性;ImportCount 经 proxy 日志聚合,排除自引用;ExampleCount 由 go list -f '{{len .Examples}}' 动态提取。
排序信号影响对比(典型值)
| 信号 | 新模块(v1.5.0) | 旧模块(v0.8.2) | 高导入(stdlib) | 无 example 模块 |
|---|---|---|---|---|
timeWeight |
0.92 | 0.41 | 0.88 | 0.75 |
importWeight |
0.53 | 0.39 | 1.00 | 0.62 |
exWeight |
0.85 | 0.20 | 0.91 | 0.30 |
数据同步机制
graph TD
A[proxy.golang.org 日志流] --> B[ImportCount 聚合服务]
C[go doc -json 输出] --> D[DocGenTime & ExampleCount 提取]
B & D --> E[每日增量更新 rank_cache.db]
E --> F[pkg.go.dev 搜索 API 实时查表]
第三章:Go Module Proxy 缓存一致性危机
3.1 GOPROXY=direct vs proxy.golang.org 的缓存 TTL 差异:为什么本地 go list -m -u 显示更新而 go.dev/search 仍返回旧版
数据同步机制
go.dev/search 依赖 proxy.golang.org 的只读镜像,其模块元数据缓存 TTL 为 24 小时(不可配置);而本地 GOPROXY=direct 直连源仓库,实时拉取 go.mod 和 @latest 信息。
缓存行为对比
| 源 | 缓存策略 | TTL | 是否可绕过 |
|---|---|---|---|
proxy.golang.org |
CDN + 内部 LRU | 24h | ❌ |
GOPROXY=direct |
无缓存,直连 VCS | — | ✅ |
# 强制刷新本地模块索引(不改变 proxy.golang.org 缓存)
go list -m -u all # 实时查询 origin 的 tag/commit
该命令跳过代理,直接解析 https://$VCS/$repo/@v/list,故立即反映新版本;而 go.dev/search 从已缓存的 proxy.golang.org 元数据中读取,尚未触发后台刷新。
同步延迟路径
graph TD
A[作者推送 v1.2.0] --> B[proxy.golang.org 拉取]
B --> C{CDN 缓存写入}
C --> D[go.dev/search 查询]
D --> E[返回 v1.1.0(TTL 未过期)]
3.2 sum.golang.org 校验失败引发的 proxy 回退链路:如何定位被静默跳过的 module 版本
当 go get 遇到 sum.golang.org 校验失败时,Go 工具链会自动触发 proxy 回退机制——先尝试 proxy.golang.org,再降级至直接 fetch 源仓库(如 GitHub),且不报错、不提示跳过版本。
数据同步机制
sum.golang.org 与 proxy.golang.org 并非强一致:
- sumdb 每 30 分钟批量拉取新 module 版本并计算 checksum
- proxy 可能已缓存未录入 sumdb 的预发布版本(如
v1.2.3-20230401abcdef)
定位静默跳过的版本
# 启用调试日志,暴露回退决策
GODEBUG=gosumcheck=1 go list -m -json all 2>&1 | grep -E "(sum|proxy|fetch)"
该命令输出中若出现
skipping sum check: not found in sum db,即表示该 module 版本被静默跳过校验,并触发 proxy 回退。GODEBUG=gosumcheck=1强制打印校验路径决策,-json确保结构化输出便于解析。
回退链路流程
graph TD
A[go get] --> B{sum.golang.org 查询}
B -- 404/5xx --> C[proxy.golang.org 请求]
C -- 200 + no sum --> D[直接 git clone]
D --> E[写入 go.sum 无 checksum]
| 阶段 | 触发条件 | 可观测性线索 |
|---|---|---|
| SumDB缺失 | GET https://sum.golang.org/sumdb/... 返回 404 |
gosumcheck=1 日志含 not found in sum db |
| Proxy缓存命中 | proxy.golang.org/.../@v/vX.Y.Z.info 200 |
go env GOPROXY 为默认值时生效 |
| 直接fetch | proxy 返回无 go.mod 或 checksum |
go.sum 中该行无 h1: 前缀哈希值 |
3.3 代理层 gzip 压缩与 Content-Encoding 失配导致的 go.mod 解析异常
当 Go 工具链(如 go get)从私有代理拉取模块时,若代理服务器对 go.mod 文件错误启用 gzip 压缩,但未正确设置 Content-Encoding: gzip 响应头,或反之——头已声明但实际未压缩,则 cmd/go 内部的 modfetch 模块会因解码失败而静默截断内容。
典型失配场景
- 代理返回
Content-Encoding: gzip,但响应体为明文go.mod - 代理返回真实 gzip 压缩体,却遗漏
Content-Encoding头 - Go 客户端依据头字段决定是否解压;头体不一致 →
invalid module zip或unexpected EOF
复现代码片段
# curl 模拟 go get 行为(忽略 Content-Encoding 自动解压)
curl -H "Accept: application/vnd.go+json" \
https://proxy.example.com/github.com/user/repo/@v/v1.2.3.mod
此请求若被代理篡改编码状态,Go 的
http.DefaultClient不会二次校验压缩一致性,直接将乱字节传入modfile.Read,触发syntax error at line 1, column 0。
| 环境变量 | 作用 |
|---|---|
GOPROXY |
指定代理地址(支持逗号分隔) |
GODEBUG=http2server=0 |
排除 HTTP/2 压缩干扰 |
graph TD
A[go get] --> B{HTTP GET /@v/v1.2.3.mod}
B --> C[Proxy Server]
C -->|错误添加 gzip header| D[Raw go.mod body]
C -->|漏发 header| E[Gzipped body]
D --> F[go.mod parse fail]
E --> F
第四章:跨工具链搜题协同失效的四大盲区
4.1 go doc -cmd 与 go.dev/search 的命令行入口差异:为何 go run -help 不在文档搜索结果中出现
文档索引范围的根本区别
go doc -cmd 仅索引 已安装的可执行命令(即 $GOROOT/bin 和 $GOBIN 中存在的二进制),而 go.dev/search 基于 Go 官方模块索引(index.golang.org)构建,仅收录 main 包中导出的、且被 go list -json -f '{{.Name}}' ./... 识别为 main 的包。
go run -help 不出现的原因
go run 是 go 工具链内置子命令,不对应独立 main 包源码,也不生成单独二进制;其帮助信息由 cmd/go 主程序动态解析,未被 go.dev 索引器捕获。
关键对比表
| 特性 | go doc -cmd |
go.dev/search |
|---|---|---|
| 数据源 | 本地 $GOBIN 可执行文件 |
远程模块索引 + main 包元数据 |
go run 是否可见 |
❌(非独立二进制) | ❌(无对应 main 模块) |
go fmt 是否可见 |
✅($GOROOT/bin/go-fmt) |
✅(golang.org/x/tools/cmd/go-fmt) |
# 查看 go doc -cmd 实际扫描路径
go env GOPATH | xargs -I{} echo "{}/bin" "$GOROOT/bin"
# 输出示例:
# /home/user/go/bin
# /usr/local/go/bin
该命令列出所有 go doc -cmd 能感知的命令目录;go run 不在此路径下存在对应文件,故无法被索引。
4.2 VS Code Go 扩展的本地 symbol 索引与远程 go.dev/search 的语义割裂:跳转到定义失效的 cache 重建方案
当 gopls 本地索引未及时同步模块变更时,Go: Restart Language Server 仅刷新 LSP session,不重建磁盘缓存。
触发重建的三步法
- 删除
$HOME/Library/Caches/org.golang.go/gopls/(macOS)或%LOCALAPPDATA%\gopls\(Windows) - 运行
gopls cache delete -all - 在 VS Code 中执行
Go: Toggle Verbose Logs后重启窗口
关键参数说明
gopls cache delete -all --verbose
# --verbose:输出被清除的 module path 和 snapshot ID
# -all:强制遍历所有 workspace roots,非仅当前打开文件夹
该命令清空 gopls 的 cache/module 和 cache/snapshot 目录,迫使下次 textDocument/definition 请求触发完整符号重解析。
| 缓存类型 | 存储位置 | 是否跨 workspace 共享 |
|---|---|---|
| Module metadata | cache/module/ |
是 |
| Snapshot state | cache/snapshot/ |
否(按 workspace root 隔离) |
graph TD
A[用户触发跳转失败] --> B{gopls 是否命中 stale snapshot?}
B -->|是| C[检查 cache/snapshot/ 时间戳]
C --> D[执行 gopls cache delete -all]
D --> E[重建 module graph + AST cache]
4.3 gopls 的 workspace module 模式对 search 结果的影响:多模块项目中 go.dev/search 默认仅检索主 module 的根源分析
gopls 在 workspace module 模式下,将 go.work 或首个 go.mod 所在目录视作 workspace root,仅索引该 module 下的源码。
数据同步机制
gopls 启动时通过 go list -mod=readonly -deps -f '{{.ImportPath}} {{.Dir}}' ./... 构建符号图谱——此命令默认作用于当前 module,不跨 replace 或 go.work 中的其他 module。
典型行为对比
| 场景 | 索引范围 | Go to Definition 可达性 |
|---|---|---|
| 单 module | 全量包 | ✅ |
go.work 多 module |
仅主 module(含 replace 路径) |
❌ 非主 module 中定义不可跳转 |
# gopls 启动时实际执行的依赖扫描命令(简化)
go list -mod=readonly -deps -f '{{.ImportPath}} {{.Dir}}' .
# 注意:此处的 "." 是 workspace root,非 project root
该命令不递归遍历 go.work 中其他 use ./other-module 目录,导致 other-module 的符号未进入缓存,search(如 textDocument/definition)自然返回空。
根因流程图
graph TD
A[gopls 启动] --> B[解析 workspace root]
B --> C{存在 go.work?}
C -->|是| D[取第一个 use 目录或当前 dir 为 root]
C -->|否| E[取 nearest go.mod 的父目录]
D & E --> F[执行 go list ./...]
F --> G[仅索引该目录下 go.mod 定义的 module]
4.4 go get -d 下载的源码与 go.dev/search 展示的在线源码不一致:proxy 缓存、vcs tag 解析偏差与 commit hash 锁定策略
数据同步机制
go.dev/search 展示的是模块代理(如 proxy.golang.org)快照时刻的索引数据,而 go get -d 默认拉取的是本地 GOPROXY 缓存中已存在的版本,可能滞后于 VCS 最新状态。
关键差异来源
- Proxy 缓存未及时刷新(TTL 默认 10m)
v1.2.3tag 在 Git 中可能被 force-push 覆盖,但 proxy 仍缓存旧 commitgo.mod中require example.com/m v1.2.3不锁定 commit hash,解析依赖 tag 指向
验证一致性
# 查看 proxy 实际返回的 commit
curl -s "https://proxy.golang.org/example.com/m/@v/v1.2.3.info" | jq .Version
# 输出: "v1.2.3" → 但 .Info 中的 "Time" 和 "Version" 可能与 VCS 当前 tag 不符
该命令请求 proxy 的版本元信息;.Version 字段是 proxy 记录的解析结果,非实时 VCS commit hash。
| 场景 | go get -d 获取 |
go.dev/search 显示 |
原因 |
|---|---|---|---|
| tag 重写后 | 旧 commit(缓存命中) | 新 commit(重新索引) | proxy 缓存未失效 |
| 无 tag 的 commit | v0.0.0-20230101000000-abc123 |
不显示或标记为 “unversioned” | go.dev 仅索引 tagged 版本 |
graph TD
A[go get -d] --> B{GOPROXY enabled?}
B -->|Yes| C[Fetch from proxy cache]
B -->|No| D[Clone VCS directly]
C --> E[May return stale commit]
D --> F[Reflects current VCS state]
第五章:构建可验证、可审计的 Go 搜题工作流
在某省级教育科技公司的在线作业平台中,搜题服务日均处理超 120 万次 OCR+语义匹配请求。为满足等保三级与《未成年人网络保护条例》对操作留痕、结果可回溯的强制要求,团队基于 Go 1.22 构建了具备端到端可验证能力的工作流。
题目识别链路的原子化审计埋点
每张用户上传的题目图片在进入 pipeline 后,立即生成唯一 trace_id(如 TR-20240523-8a7f9c1e),并贯穿后续所有阶段:
- 图像预处理(灰度化、二值化)→ 记录 OpenCV 版本与参数哈希(
sha256("thresh=180,blur=3")) - OCR 识别(PaddleOCR v2.7)→ 保存原始识别置信度矩阵与坐标框 JSON
- 公式归一化(LaTeX AST 解析)→ 输出结构化树形 diff(含节点类型、系数标准化标记)
所有中间产物以只读模式写入对象存储(MinIO),路径格式为audit/{date}/{trace_id}/{stage}.json,权限策略禁止覆盖或删除。
结果签名与第三方验证机制
最终返回的搜题结果 JSON 包含嵌入式数字签名字段:
type SearchResult struct {
QuestionID string `json:"qid"`
MatchScore float64 `json:"score"`
SourceURL string `json:"source"`
Signature string `json:"sig"` // base64(Ed25519(sign(sha256(qid+score+source+timestamp))) )
Timestamp int64 `json:"ts"`
}
教育局监管系统可通过公开密钥实时校验签名有效性,并调用 /v1/audit/verify?trace_id=TR-20240523-8a7f9c1e 接口获取全链路审计摘要。
审计日志的不可篡改设计
采用 Merkle Tree 对每日审计事件进行聚合签名:
flowchart LR
A[2024-05-23 00:00:00] --> B[Leaf Hash: SHA256(trace_id+stage+hash)]
B --> C[Merkle Node]
D[2024-05-23 23:59:59] --> E[Leaf Hash]
E --> C
C --> F[Root Hash: 0x7a3f...d9c2]
F --> G[上链至 Hyperledger Fabric]
关键审计事件类型及保留周期如下表所示:
| 事件类型 | 示例字段 | 保留周期 | 存储位置 |
|---|---|---|---|
| OCR失败重试 | retry_count=3, error="timeout" |
180天 | ClickHouse audit_log |
| 敏感词拦截 | blocked_keywords=["答案","解析"] |
永久 | 写入区块链存证合约 |
| 人工复核记录 | reviewer_id="EDU-ADMIN-782", verdict="reject" |
365天 | 加密数据库(AES-256-GCM) |
实时审计看板与异常熔断
部署 Prometheus + Grafana 实时监控三项核心指标:
search_audit_integrity_ratio(审计日志完整率,目标 ≥99.99%)signature_verification_failures_total(签名验证失败数,阈值 >5/min 触发告警)merkle_root_mismatch_count(Merkle 根不一致次数,>0 即自动冻结对应时段所有搜题服务)
当某次批量更新题库时误删了 37 道真题的 LaTeX 归一化规则,审计系统在 42 秒内捕获 formula_normalization_diff_count{delta="+12"} 异常跃升,并通过 webhook 通知运维团队回滚配置。
跨部门协同审计接口
提供符合 GB/T 35273-2020 的标准 API,供学校教务处按班级维度导出结构化审计包:
curl -X POST https://api.examtech.edu/v1/audit/export \
-H "Authorization: Bearer school-token-9a3f" \
-d '{"class_id":"G10-MATH-2024Q2","start_ts":1716422400,"end_ts":1716508799}'
响应体包含 ZIP 包,内含 metadata.json(含校验和)、questions.csv(题干+来源+时间戳)、provenance.log(全链路 trace_id 映射表)。
