Files
Volcano-Engine-TTS-UI/docs/UPSTREAM_ADAPTER_GUIDE.md
T
sun ec82e895c3 chore(ci): 删除 ci.yml workflow
该 workflow 没有实际价值:

- 仓库按策略不提供测试源码(*_test.go 被 .gitignore 屏蔽),因此其中的
  go test 步骤在干净克隆上只是空转(打印 no test files 后通过),它连
  "测试是否存在"都不会告诉你,自然守不住行为回归。
- 剩下的 go build / go vet 收益也很有限 —— 真正会用到编译产物的 docker.yml
  在发版打 tag 时本就会完整构建一次。

删除后仓库只保留 docker.yml(打 tag 时构建并推送镜像),不再有多余的自动化。

同步清理对它的引用:
- CHANGELOG 的"新增 CI 校验"条目(改述为"没有自动化质量门")
- .gitignore 里测试策略注释中指向 ci.yml 的那句
- docs/UPSTREAM_ADAPTER_GUIDE.md 第 7 节(原表述"CI 跑不到你的测试"已不准确,
  改为"没有任何自动化会跑你的测试")
2026-10-04 16:26:32 +08:00

24 KiB

上游适配器开发指南

面向对象:要为本项目接入新上游 TTS 服务的开发者(也包括未来的你)。 阅读前提:知道 OpenAI /v1/audio/speech 的请求/响应形状,能读 Go。

⚠️ 本文档分两部分,请先看清区别: 第一部分「现状」描述的是代码里已经存在的东西,可以直接照着读代码。 第二部分「目标架构」是待实施的设计,代码里还不存在 —— 里面的接口、表结构、目录都是提案,需要先按 §4 的分阶段计划落地。 不要把第二部分的接口名当成现有 API 去调用。


1. 愿景与现状

1.1 愿景

把本服务做成多渠道多上游聚合层:对外始终是一个 OpenAI 兼容接口, 对内可以挂火山、OpenAI、Azure、阿里云、自建 GPT-SoVITS / IndexTTS……等任意上游。 调用方不需要知道音频是哪个厂商合成的 —— 换上游对客户端应当透明。

1.2 现状(第一部分)

目前只有火山一个适配器,且不是"插件式"的,而是硬编码进主干的。 这一点必须说清楚,因为它决定了新上游不能"只写一个文件就完事"。

入口         controller/tts.go        OpenaiTTSHandler
                 │
                 │  opts := setting.GetTTSOptions()      ← 类型是 volcano.Options
                 │  volcano.Synthesis(ctx, volcanoClient, opts, ...)   ← 直接调实现,无接口
                 ▼
适配器       adapter/volcano/         Options / Synthesis / ParseStream / UpstreamError
                 ▲
                 │  setting.LoadRuntimeConfig 直接构造 volcano.Options / volcano.Additions
配置         setting/config.go

关键事实:主干里有一处 import "…/adapter/volcano",不是注解式的, 而是类型级耦合。所以"多上游"不是加文件,而是一次重构(见 §4)。

当前只有火山适配器时,以下代码是合理的;接第二个上游时必须逐个处理, 否则会出现"配置是火山的、路由是 OpenAI 的"这种错配。


2. 现有火山适配器契约(照此实现即可)

新适配器要能替代火山适配器,就必须满足下面这些已经被主干依赖的契约。 先读这一节,再读 §3 的接口设计。

2.1 包的职责划分

文件 职责 新适配器对应物
adapter/volcano/options.go 调用参数集合(Options)+ 上游扩展参数(Additions)+ IsZero() provider 私有 opts
adapter/volcano/request.go Options → 上游请求体;buildRequest 做必填校验;convertSpeedToSpeechRate 做 OpenAI speed 换算 请求构造
adapter/volcano/client.go 复用连接的 *HTTPClient(keep-alive 调优);PostStream 发流式请求 传输层
adapter/volcano/synthesis.go 唯一入口 Synthesis(...);编排 埋点 → 构造 → 发送 → 解析 → 收尾 Synthesize(...)
adapter/volcano/response.go ParseStream 解析 NDJSON 流;ParsedStream 累计结果 响应解析
adapter/volcano/errors.go UpstreamError{Code,Message,Stage,Wrapped} + IsAuth() 错误类型
adapter/volcano/audio.go WrapWAVHeader —— 上游只给 PCM 时本地拼标准 wav 头 格式收尾

2.2 主干对适配器的硬性依赖(必须逐条满足)

# 主干行为 位置 新适配器必须
1 用包级 *HTTPClient 发请求,连接复用 controller/tts.go:27,32 提供可复用的 client,不要每次 new
2 可用 ctx 控制超时 controller/tts.go:203 Synthesize 必须接受 context.Context
3 埋点接口 3 个方法 adapter/volcano/synthesis.go:20-24 见 §2.3
4 错误按 Stage 分类成 status label controller/tts.go:242-256 错误必须携带阶段:request/http/stream/wrap
5 错误码进 metrics label metrics/metrics.go:160-172 非零 code 会被聚合成 client/server/upstream
6 返回结果结构 dto.SynthesisResult 见 §2.4
7 上游不支持的输出格式要降级 synthesis.go:63-65、README 格式表 见 §2.5

2.3 埋点接口(MetricsRecorder)

适配器不 import telemetry 包,而是由主干注入一个接口实现 (controller/tts.go:28 注入 metrics.AdapterRecorder{})。这是刻意的解耦,新适配器请沿用。

type MetricsRecorder interface {
    UpstreamStarted(speaker, model, format string)
    UpstreamFinished(speaker, model, format, status string,
        duration, ttfb time.Duration, chunks, audioBytes, errCode int)
    UpstreamUsage(model string, textWords int)
}

现状实现里 speaker 是语料无关的上游音色 ID,会被 telemetry.SpeakerLabel 做 sha1[:8] 哈希后才当 label(隐私保护,见 metrics/metrics.go:127)。 新适配器传自己的音色标识即可,主干不解释它的含义。

status 的取值约定(沿用即可,聚合看板依赖它们): ok / request_error / transport_error / http_<code> / stream_error / wrap_error。

2.4 返回结果(dto.SynthesisResult)

type SynthesisResult struct {
    AudioData  []byte        // 最终可直接回给客户端的音频字节(已收尾,如 wav 拼好头)
    Format     string        // 真实输出格式,决定 Content-Type
    SampleRate int
    ReqID      string        // 回写 X-Request-Id,排障用
    TextWords  int           // 上游计费字符数(取不到填 0)
    Chunks     int
    AudioBytes int
    TTFB       time.Duration // 首字节耗时
    Duration   time.Duration
}

Format 必须是主干认识的取值(见 controller/tts.go:258 contentTypeFor): mp3 / wav / pcm / ogg_opus(别名 opus)。填别的会导致 Content-Type 错。

2.5 格式降级规则(容易踩)

客户端要的 response_format 和上游能给的格式往往不一致,必须显式降级:

客户端要 火山做法 为什么
wav 上游请求 pcm,本地 WrapWAVHeader 拼头 流式 wav 每 chunk 带独立头,拼接即坏
aac / flac 降级成 mp3,Content-Type 报 audio/mpeg 火山不支持
opus 上游 ogg_opus 命名差异

新适配器同样要回答这个问题:你要的格式上游给不了时,降级到什么? 并且必须在 Format 字段里如实回报真实格式,否则客户端拿到的是"声明 mp3 实际 pcm"。

2.6 实测踩过的坑(上游协议层面)

这些是火山适配器真实修过的 bug,接新上游时同类问题大概率复现:

  1. 事件分发必须白名单:ParseStream 只把 event == "sentence" 或事件为空的帧 当音频,未知事件只记日志绝不拼进音频(response.go:71-113)。 早期实现把 TTSSubtitle 也当音频,导致音频流被污染。
  2. 结束帧要提前 break:上游用 code == 20000000 表示流正常结束,收到后要 把剩余行读完再 break,否则连接不释放。
  3. 单行可能很长:scanner.Buffer 上限要放大(火山设到 8MB),否则大 chunk 会 EOF。
  4. 所有音频帧 base64 解码失败要返回错误,不能跳过 —— 跳过等于静默产出半截音频。
  5. 一个字节音频都没收到要报错,不能返回空切片当成功(response.go:125-131)。
  6. 错误响应体可能含换行:直接拼进错误消息会伪造日志行,火山做了 \n/\r 转义(synthesis.go:99-101),新适配器照做。

3. 目标架构:Provider 抽象(待实施)

以下代码当前不存在,是建议落地的设计。

3.1 接口定义

建议新增 adapter/provider/provider.go(新包,不放在 adapter/volcano 里, 避免主干继续依赖具体实现):

// Provider 是一个上游 TTS 服务的统一抽象。
// 实现者只需要关心:把我的能力说清楚、把一次合成做完。
type Provider interface {
    // Name 稳定标识,用于配置键、库表字段、metrics label。
    // 约定全小写无空格:volcano / openai / azure / aliyun / gpt-sovits。
    Name() string

    // Capabilities 声明本适配器支持什么。
    // 主干据此做校验与降级,不靠 if-else 猜实现。
    Capabilities() Capabilities

    // Synthesize 执行一次合成,阻塞到完成。
    // 必须遵守:ctx 取消即返回;上游不支持 clientFormat 时按 Capabilities 降级,
    // 并在 Result.Format 里如实回报真实格式。
    Synthesize(ctx context.Context, req Request, mtr MetricsRecorder) (*dto.SynthesisResult, error)
}

type Capabilities struct {
    // 上游原生能产出的格式;客户端要的格式不在其中时,主干负责降级或拒绝。
    Formats []string
    // 是否支持用倍率控制语速。不支持时主干忽略 speed 而不是报错。
    Speed bool
    // 是否按字符计费并回传用量(决定 UpstreamUsage 是否有意义)。
    Usage bool
    // 是否支持多音色路由。不支持时该 provider 只能配一个默认音色。
    Voices bool
    // 是否需要资源/部署/项目 ID 这类额外维度(火山需要,OpenAI 不需要)。
    ExtraScopes bool
}

// Request 是主干交给适配器的、已归一化的合成请求。
// 注意:这里刻意不出现任何厂商专属字段。
type Request struct {
    Text         string
    VoiceKey     string            // voices 表的主键语义(对外 voice 名或上游音色 ID)
    Model        string
    Format       string            // 客户端期望格式(主干已归一化:mp3/wav/pcm/ogg_opus)
    SampleRate   int
    Speed        float64           // 1.0 = 原速
    Language     string
    Extra        map[string]string // 厂商专属配置,从 voices/settings 的 JSON 列读

    // Credentials 由 provider 自己解释:
    // 火山读 api_key + resource_id;OpenAI 只读 api_key。
    Credentials Credentials
}

type Credentials struct {
    APIKey string
    // 厂商额外凭证/路由维度。火山: resource_id;Azure: region;自建: base_url。
    Scope map[string]string
}

3.2 注册表

// adapter/provider/registry.go
func Register(p Provider)        // 通常在包的 init() 里调用
func Get(name string) (Provider, bool)
func Names() []string            // admin UI 下拉框用

要点:注册要放在主干之外。建议 main.go 显式空导入:

import (
    _ "github.com/volcano-tts/tts-api/adapter/volcano"  // 注册 volcano
    _ "github.com/volcano-tts/tts-api/adapter/openai"   // 注册 openai
)

这样主干只依赖 provider 包,新增上游不需要改主干任何一行 —— 这是本架构的核心收益, 请务必守住。controller/tts.go 里不应再出现 adapter/volcano。


4. 分阶段实施计划

强烈建议按阶段做,每阶段结束都能编译、能跑、行为不变。 一次性重构"配置 + 路由 + 库表 + 前端 + metrics"五个面,几乎必然做出半成品。

阶段 0 · 抽出接口,零行为变更(纯重构)

目标:主干不再依赖具体实现,但行为完全不变,回归风险最低。

  • 新增 adapter/provider 包,放 Provider / Capabilities / Request / Credentials / MetricsRecorder
  • 让 volcano 包实现 Provider(本质是把 Options 包一层适配,Synthesis 改成方法)
  • controller/tts.go:把 volcanoClient + volcano.Synthesis 换成 provider.Get(name).Synthesize
  • MetricsRecorder 从 adapter/volcano 移出到 provider 包(否则接口还是火山专属)
  • setting.LoadRuntimeConfig 暂时继续构造 volcano.Options,通过一个 「volcano provider 的配置映射函数」转成 provider.Request
  • 验收:go build ./... && go vet ./... && go test ./... -count=1 全绿, 且 /v1/audio/speech 行为与重构前逐字节一致(拿同一段文本对比音频长度与格式)

这一阶段不要动库表、不要动前端。目标只有一个:主干里那行 import adapter/volcano 消失。

阶段 1 · 数据结构:provider 维度入库

⚠️ 先纠正一个常见误解:本项目的迁移框架其实没有接线。 store/migrate.go 里定义了 Migration 结构体和 migrations 切片,但那个切片是 空的,store/db.go 的 migrate() 也从不读取它 —— 注释里写得很明白: "本期 M0 阶段 schemaVersion=1,migrate() 在 db.go 内做基础建表,未触发 migrations 调度"。 所以 migrate() 目前只做 CREATE TABLE IF NOT EXISTS,没有任何版本比较, 对已存在的表完全不做变更。

还有一个既有瑕疵:db.go:148-150 的 INSERT OR IGNORE INTO schema_version (version) VALUES (?) 语句里既没有字面量也没传参,绑定到 ? 的是零值,所以表里实际写入的版本号是 0, 不是常量 schemaVersion(=1)。做 v2 迁移时请一并修掉,否则版本判断从一开始就是错的。

结论:给 voices 加列不会自动发生,你必须自己写 ALTER TABLE, 并且在启动时能被重复执行而不报错(老库、新库都要能跑)。

本阶段要做:

  • 在 migrate()(或新启用的迁移调度)里实现幂等加列: 先 PRAGMA table_info(voices) 查现有列,缺哪个 ALTER TABLE ... ADD COLUMN 哪个 (SQLite 的 ADD COLUMN 不支持 IF NOT EXISTS,重复执行会报错,必须先查)
  • voices 表加列(见 §5.1),老库回填 provider='volcano'
  • 修正 schema_version 写入零值的瑕疵,并写出真实版本号
  • 若确实要启用 migrate.go 的调度框架,则把 schemaVersion(db.go,=1) 与 schemaVersionRequested(migrate.go,=1)一并提升到 2; 注意 installer/lock.go 里的 schemaVersion = "1"(字符串)是另一套东西—— 它写在 installed.lock 里做安装标记,与库表结构版本是两回事, 两者要一起提升就一起提升,不要只改一边造成语义分裂
  • store.Voice 结构体加字段 + JSON tag
  • store.GetVoiceForTTS 返回值扩展(见 §5.2),setting.Store 接口同步改
  • cmd/dumpdb 输出新列
  • 验收:用一个装了老数据的 tts.db 启动服务(不要用全新库测), 确认自动加列成功、数据不丢、/v1/audio/speech 仍可用; 再重启一次确认迁移幂等(第二次不报错)

阶段 2 · 配置:按 provider 命名空间化

现状是 settings 表用扁平键,且键名全是火山语义(default_resource_id、 default_speaker、model_type…)。多上游后必须命名空间化。

  • 保留全局键:auth_key / cors_* / timeout / default_provider
  • 厂商专属键改为 provider.<name>.<key>,例如 provider.volcano.api_key、provider.volcano.resource_id、 provider.openai.api_key、provider.openai.base_url
  • LoadRuntimeConfig 只装配当前 default_provider 的 Request, 不再构造 volcano.Options
  • setting.CheckEnvironmentVariables / LogStartupSummary 去掉 BYTEDANCE_TTS_* 硬编码名,改成遍历 provider 的必填项(否则 /health 会误导运维)
  • 迁移:老键 api_key → provider.volcano.api_key 等,一次性搬完并保留旧键只读兜底一轮
  • 验收:除火山外没有任何 provider 时,服务行为仍与阶段 1 相同

阶段 3 · 观测:metrics 加 provider 维度

  • tts_upstream_total / tts_upstream_duration_seconds / tts_upstream_errors_total 等全部上游指标加 provider label
  • metrics.AdapterRecorder 增加 provider 字段(构造时注入 name)
  • 仪表盘 PromQL 补 by (provider)
  • ⚠️ 破坏性变更:加 label 会让现有告警规则里的 tts_upstream_total{...} 聚合口径变化,发布说明里要写明
  • 验收:/metrics 上能按 provider 拆分成功率与 P95

阶段 4 · 管理界面:provider 选择与音色归属

  • /setup 向导第 1 步加"上游类型"选择,后续步骤按所选 provider 动态渲染字段 (火山显示 resource_id,OpenAI 显示 base_url 且不显示 resource_id)
  • router/admin-voices.html 新增/编辑音色时选 provider 与 model
  • /api/voices 的请求/响应加 provider 字段,controller/admin.go 校验 "provider 必须已注册",否则 400(不要留到合成时才 500)
  • 删除 provider 最后一个音色时的保护逻辑(现在的 ErrInUse 只管 default_speaker)
  • 验收:UI 上能并存火山与 OpenAI 音色,各自路由正确

阶段 5 · 第二个真实适配器(OpenAI 兼容)

这一步才是对抽象的真正验收。选 OpenAI 兼容上游(或其代理)最省事,因为 本项目对外就是 OpenAI 形状 —— 可以直接验证"请求/响应穿透"是否正确。

  • 新建 adapter/openai/,只实现 §3.1 的接口
  • 全程不改 controller/ setting/ store/ 任何一行
  • 如果为了实现它而必须改主干,说明 §3.1 的抽象漏了维度 —— 回头修接口,而不是打补丁
  • 验收:同一份 voices 配置里两个 provider 可同时服务, 客户端只看到 voice 名不同

5. 数据模型改动细节

5.1 voices 表

现状(真实 schema,store/db.go:129-142):

CREATE TABLE IF NOT EXISTS voices (
    id          INTEGER PRIMARY KEY AUTOINCREMENT,
    name        TEXT NOT NULL UNIQUE,
    speaker     TEXT NOT NULL,      -- 上游音色 ID
    resource_id TEXT NOT NULL,      -- ← 火山专属概念,却写成 NOT NULL 通用列
    model       TEXT DEFAULT '',
    language    TEXT DEFAULT '',
    description TEXT DEFAULT '',
    enabled     INTEGER NOT NULL DEFAULT 1,
    created_at  TEXT NOT NULL DEFAULT (datetime('now')),
    updated_at  TEXT NOT NULL DEFAULT (datetime('now'))
);

问题:resource_id 是火山独有概念(决定模型版本与计费),却成了全表必填列。 OpenAI 上游根本没有这个字段 —— 这正是"单上游假设"渗进数据模型的典型症状。

建议改为:

ALTER TABLE voices ADD COLUMN provider TEXT NOT NULL DEFAULT 'volcano';
ALTER TABLE voices ADD COLUMN vendor_params TEXT NOT NULL DEFAULT '{}';  -- JSON,厂商私有
-- resource_id 保留但放宽为可空(或整体搬进 vendor_params,看迁移成本)
  • provider 建索引:CREATE INDEX idx_voices_provider ON voices(provider)
  • vendor_params 存厂商私有键值(火山:{"resource_id":"seed-icl-2.0","model_type":4}), 主干不解释它的内容,原样交给对应 provider
  • 迁移:UPDATE voices SET provider='volcano' WHERE provider IS NULL OR provider=''

5.2 GetVoiceForTTS 的签名

现状(setting/config.go:426-436,刻意用 5 个返回值避免循环 import):

GetVoiceForTTS(name string) (speaker, resourceID, model string, found bool, err error)

多上游后要扩成:

// 返回该 voice 的 provider 名 + 厂商私有参数,主干据此把请求路由给对应适配器。
GetVoiceForTTS(name string) (providerName, speakerID, model string, vendorParams map[string]string, found bool, err error)

注意:当年之所以不用 (VoiceRef, error),是为了让 store 不 import setting (避免循环 import)。加字段时别再引入新的结构体依赖,继续用扁平返回值或 定义在 store 包里的中性结构体。


6. 新增一个适配器的完整清单

假设要加 foo 上游:

代码

  1. adapter/foo/foo.go —— 实现 provider.Provider(Name() 返回 "foo")
  2. adapter/foo/request.go —— provider.Request → foo 的 HTTP 请求体
  3. adapter/foo/response.go —— 解析 foo 的响应;严格遵守 §2.6 的 6 条协议纪律
  4. adapter/foo/errors.go —— 错误要带阶段,Stage 用统一枚举
  5. adapter/foo/foo_test.go —— 至少覆盖:正常流、未知事件不污染音频、空音频报错、 base64 解码失败报错、格式降级回报正确 Format、ctx 取消能及时返回 (测试文件按项目策略不入库,本地保留并在合并前手动跑)
  6. 在 foo.go 的 init() 里 provider.Register(...)
  7. main.go 加一行空导入

配置

  1. 定义必填项清单(供 /health 与启动摘要显示),不要写死在前端
  2. 在 /setup 向导里加该 provider 的字段定义(阶段 4 之后是数据驱动,只需加配置)

验收

  1. go build ./... && go vet ./... && go test ./... -count=1 全绿
  2. 起服 → /setup 选 foo → 填凭证 → 加一个音色 → curl /v1/audio/speech 出声
  3. 故意填错凭证 → 客户端应拿到明确的 4xx/5xx 与 code,日志里凭证是打码的
  4. 不支持的 response_format → 按 Capabilities 降级,且 Content-Type 与真实字节一致

7. 测试策略(注意本项目的特殊约束)

本项目测试文件不入库(.gitignore 里的 *_test.go), 测试保留在本地磁盘。这带来两个直接后果,必须知道:

  1. 没有任何自动化会跑你的测试。仓库里不提供 CI 质量门: GitHub 上只有一个 workflow,且它只在发版打 tag 时构建 Docker 镜像, 既不编译校验也不跑测试。所以适配器测试必须在本地跑过再合并:
    go test ./... -count=1
    
  2. 不要依赖测试文件来传递知识。协议细节、踩坑记录要写进代码注释和本文档, 否则对下一个人等于不存在。

适配器测试的最小可用集合(按 §2.6 的坑逐条覆盖):

用例 断言
正常流 音频字节非空;Chunks / TTFB / Format 合理
未知事件帧 不进入音频;不报错;不改变 Chunks
字幕帧 进 Subtitles,不进 AudioData
全流无音频 返回错误,不是"成功 + 空字节"
base64 损坏 返回错误,不是跳过继续
超长单行(>1MB) 不 EOF
上游 HTTP 4xx/5xx 错误 Stage == "http",携带 code
ctx 取消 及时返回,不泄漏 goroutine
格式降级 Result.Format 是真实格式,不是客户端要的格式

8. 反模式清单(明确不要做)

  • ❌ 不要在 controller/ 里写 switch providerName 分发。 分发只能走注册表,否则每加一个上游都要改主干,抽象就白做了。
  • ❌ 不要让 provider 包 import 任何具体适配器。依赖方向永远是 具体适配器 → provider 包 ← 主干。
  • ❌ 不要在 provider.Request 里加厂商专属字段(如 ResourceID)。 厂商私有参数一律走 Credentials.Scope / Request.Extra / voices.vendor_params。
  • ❌ 不要把上游返回的原始音频直接当结果返回而不收尾。 PCM 要拼头,降级要如实回报 Format(见 §2.5)。
  • ❌ 不要静默跳过解析失败的帧(§2.6 第 4 条)。
  • ❌ 不要在日志里打印明文凭证或音色 ID。沿用 telemetry.MaskSpeaker / MaskResourceID,新适配器的私有敏感字段也要加等价打码。
  • ❌ 不要用一个 settings 键名承载多个 provider 的配置。
  • ❌ 不要为了省事把 provider 名写进 settings 的扁平键里当"事实上的路由", 路由的唯一权威是 voices.provider 与 default_provider。

9. 关键文件索引

关注点 文件 说明
请求入口 / 校验 / 路由 controller/tts.go resolveClientFormat、voice 查库、错误归一
合成编排参考实现 adapter/volcano/synthesis.go 埋点顺序、格式降级、错误分阶段
流解析参考实现 adapter/volcano/response.go 事件白名单、结束帧、base64
传输层参考实现 adapter/volcano/client.go 连接复用、nil 安全
配置装配 setting/config.go LoadRuntimeConfig、setting.Store 接口
库表与迁移 store/db.go、store/migrate.go schemaVersion、migrate()(迁移框架未接线,见阶段 1 警示);installer/lock.go 的 schemaVersion 是另一套
音色模型 store/voices.go Voice、GetVoiceForTTS
指标 metrics/metrics.go AdapterRecorder、codeLabel
结果类型 dto/tts.go SynthesisResult、SubtitleEntry
未来抽象落点 adapter/provider/ 尚不存在,待阶段 0 创建

文档版本:v1 · 对应代码基线:develop @ ad342ec(2026-10-03) 本文「现状」部分与代码逐行核对过;「目标架构」为提案,实施时请同步更新本文档。