🚪 如何二开 OneAPI 打造企业级 AI 网关
底座:
one-api(上游 2024 年已停更) · 二开产物:one-api-pro/ BU22 AI · v0.0.26 技术栈:Go 1.25 + Gin + GORM · Vue 3 + Vite 8 + Arco Design Vue · MySQL / PostgreSQL / SQLite
一、先说清楚为什么要二开,而不是重写
one-api 是一个很扎实的起点:多厂商渠道统一成 OpenAI 兼容接口、令牌与额度、渠道负载均衡、多级权限,骨架都在。但它的上游在 2024 年停止维护了,而且有几处设计在企业场景下会直接卡住你:
| 维度 | one-api | 二开之后 |
|---|---|---|
| 适配器架构 | 集中式常量:channeltype/define.go 一个 56 行 iota + url.go 里并行数组 + helper.go 里两级 switch。加一个厂商要改 4 个框架文件 |
自注册(registry + register.go)。加厂商只需新建一个包 + 一次 Register 调用,框架零改动 |
| 目录布局 | relay/adaptor/ 平铺 40 个目录,基础协议和具体厂商混在一起;relay/model/ 和根 model/ 撞名 |
用 adaptor/openai、adaptor/anthropic 表达基础协议,adaptor/provider/ 收纳 37 个厂商,relay/schema/ 消除命名冲突 |
| 商业化能力 | 无套餐 / 订阅体系 | 完整套餐订阅 + 周期限额 + 按模型控制 + 真实支付 |
| 多机部署 | 多台共享同一个 MySQL | 去中心化双活集群:每节点独立 MySQL + Redis,靠应用层事件同步 |
| 权限 | Admin 与用户边界模糊,系统设置接口谁都能调 | 分级权限 + 修掉上游授权漏洞 |
| 控制台 | 3 个 React 主题(default / berry / air),功能基础 |
全新 Vue 3 + Arco Design 控制台,带可视化仪表盘 |
结论:重写的成本远高于二开——渠道转发、计费、令牌、限流这些"脏活"一个人重写要几个月,而且踩过的坑一个都省不掉。正确姿势是保留骨架、换掉两个大件(适配器架构 + 控制台)、再补企业能力。
这次二开的主线一共四条,下面就按这四条讲。
二、架构级改造(一):适配器从「集中式常量」到「自注册」
这是整个二开里最有价值的一处改动,因为它决定的是长期迭代成本。
2.1 上游的痛
上游加一个厂商的完整动作是这样的:
- 在
channeltype/define.go的iota枚举里加一个常量; - 在
url.go的并行数组里加一个默认 base URL; - 在
helper.go的两级switch里加一个分支; - 再新建厂商自己的适配器目录。
问题是前 3 步都在框架文件里。多人协作时这三处常量枚举是稳定的冲突源,而且新增渠道这种纯业务动作要改框架,属于典型的抽象泄漏。
2.2 改造后的结构
核心是一张注册表加一个接口:
|
|
注册表本身是「元数据 + 工厂函数」,并且同时维护 ID 与旧数字类型两套索引——因为数据库里的 channel.type 是数字,历史数据不能丢:
|
|
于是每个厂商只需在自己包里写一个 init():
|
|
最后在 relay/adaptor.go 里用匿名导入把这些包拉进来触发 init():
|
|
2.3 关键收益:能复用 openai 适配器的厂商,不要重写
改造后大量厂商是"薄壳"——例如 DeepSeek 的接口本身就兼容 OpenAI,它的适配器只做转发,不重复实现逻辑:
|
|
DoRequest 则统一走 adaptor.DoRequestHelper(a, c, meta, requestBody),把 HTTP 发出、错误包装这些共性动作收口到一处。
这就是重构的价值:新增一个 OpenAI 兼容厂商,实际工作量是"新建目录、抄一份 register.go、改 3 个常量",十分钟的事。
三、架构级改造(二):前端从「3 个 React 主题」到单一 Vue 3 控制台
3.1 目录收敛
上游留了 default / berry / air 三套 React 主题,维护成本是乘三的。二开之后前端只保留 web/default-pro/ 一个维护目录,其余 web/air、web/default、web/berry 全部冻结成历史目录,web/THEMES 退化为构建脚本读取的名单,web/build/ 只是产物。
目前规模:137 个源文件、约 21600 行,最大的几个文件:
| 文件 | 行数 | 说明 |
|---|---|---|
views/dashboard/Dashboard.vue |
1530 | 用户仪表盘:核心指标、用量趋势、模型分布 |
views/admin/AdminDashboard.vue |
1444 | 管理端总览 |
views/subscription/Subscription.vue |
1367 | 订阅详情与额度用量 |
i18n/locales.js |
1355 | 中英文案主包 |
views/token/Token.vue |
1349 | 令牌管理 |
views/user/User.vue |
1333 | 用户管理 |
技术栈:Vue 3 <script setup> + Vite 8 + Arco Design Vue 2.58 + Pinia 4 + vue-router 4.6 + axios + ECharts 6 / vue-echarts + vue-i18n 9。
3.2 单二进制交付:构建产物被 Go 直接嵌进可执行文件
这是前后端二开里最容易忽略、但交付体验提升最大的一环。前端 vite.config.js 把产物输出到 web/build/default-pro:
|
|
后端 main.go 用 //go:embed 把整个目录打进去:
|
|
于是交付物就是一个二进制 + 一个数据库,不用单独部署前端、不用担心静态资源路径。开发态则由 Vite 反向代理兜住,前端 3001 端口把 /api 和 /v1 转给后端的 3000:
|
|
3.3 三条前端工程约定
一、API 模块化,统一响应契约。 所有请求走 src/api/index.js 里的 axios 实例,响应拦截器统一处理 401(跳登录并带 expired=true)与 429:
|
|
后端所有接口(含错误)都返回 HTTP 200 包一层 { success, message, data },前端调用侧就固定成 if (data.success && data.data) 这一个模式,不用一页一样地猜。
二、i18n 按页拆包 + 深合并。 1355 行的 locales.js 如果继续膨胀会无法维护,所以页面级文案拆进 src/i18n/pages/*.js(adminDashPage / channelPage / plansOrders / settingPageA / settingPageB / subPage / token / topupRedeem / userPage …),加载时深合并。同时把语言选择同步到 <html lang>,让浏览器字体与断行行为跟着走:
|
|
三、纯逻辑抽成工具函数 + Node 自带 test runner 单测。 金额计算、校验这类逻辑不允许写在组件里,统一放 src/utils/<name>.js,同目录配 <name>.test.mjs:
|
|
测试用 Node 内置的 node:test,不引入任何测试框架,跑法就是 node src/utils/*.test.mjs:
|
|
对"加点工具函数"这种高频改动,零依赖 + 零配置的收益很直接:改完就能验,不需要为跑一个断言装半套 Vitest。
3.4 两个必踩的 Arco 坑
这两个坑写进了项目规范,因为踩过一次就再也不想踩第二次:
<a-modal>的可见性属性是:visible,不是:model-value。 写成:model-value弹窗永远不显示,而且不报错——组件挂上了,就是看不见。<a-spin>必须带style="width: 100%"。 不带会让父容器宽度表现异常,引发布局错乱。
四、企业能力(一):套餐订阅与真实支付
上游完全没有商业化能力,这是"企业级"和"玩具"的分水岭。二开补上的是一套完整的套餐 + 订阅 + 订单 + 真实支付闭环。
4.1 套餐按「加权配额池」判定
套餐要支持「token 计费 / 按次计费」两种口径、周期限额(时 / 周 / 月)、以及按模型粒度的控制。判定逻辑收在 model/plan_quota.go:
|
|
CheckPlanQuota 的判定顺序(也即修复后的完整口径):
这里有一条必须坚持的判定:model_limits 解析失败时,判为不可用,而不是放行。
历史实现把它当成"不限量"直接放行,结果就是配置写错 = 全免费。这种"失败即放行"的默认值在计费系统里是致命的——它把配置错误变成资损。同一逻辑里,
model_limits为空表示该套餐不覆盖任何模型,也应当跳过并回落全局余额。
4.2 订单与支付
所有交易落同一张 orders 表,用 type 区分业务,用单号前缀区分人肉排查:
| 类型 | 常量 | 单号前缀 | 说明 |
|---|---|---|---|
| 套餐订阅 | OrderTypePlanSubscription = 1 |
TB |
新订 |
| 套餐升级 | — | UP |
差价升级 |
| 余额充值 | OrderTypeTopup = 2 |
TP |
在线充值 |
订单里会持久化套餐快照 JSON(plan_info),并保留订单号、用户、金额、支付渠道、状态、支付时间、第三方交易号——这是一条完整的审计链,出了纠纷能还原到当时的配置。
支付通道原生接了微信支付 Native(PC 扫码) 与 支付宝当面付(TradePrecreate),异步回调落在 /api/payment/{wechat,alipay}/notify,由 processNotify 按 order.Type 分发:
两个细节值得单独说:
- 激活必须幂等:已支付的订单再次进入激活流程直接返回,不重复加额度。回调重放是支付集成的常态,不是异常。
- 升级按剩余天数折算差价,且拒绝同档与降档。升级模式(
stack叠加 /price_diff差价)在「设置 → 套餐运营」里热切换。
五、企业能力(二):把金额口径收成一根线
这一节是整个项目里最痛也最值钱的一次重构,值得完整复盘。
5.1 基准常量:1 元 = 1,000,000 额度
|
|
语义上等同于微信支付的「分」——1 quota = 1e-6 元。它被从可写配置改成了系统常量:不再写入 options 表、不出现在 OptionMap,PUT /api/option 想改 QuotaPerUnit 会被明确拒绝。/api/status 仍然只读下发 quota_per_unit 供前端折算。
为什么必须常量化?因为它一旦可改,就是"改一个配置把全库余额的语义全改掉"。这类全局基准不能是运行时可变状态。
5.2 口径演进踩过的三个坑
| 版本 | 改了什么 | 踩的坑 |
|---|---|---|
| v0.0.10 | 快捷金额用「元」口径 | — |
| v0.0.24 | 改为微元口径,但没迁移存量数据 | {amount: 10, bonus_quota: 10} 被解读为"支付 10 元、到账 ¥0.00001",前端校验直接拦死,充值设置页无法保存 |
| v0.0.25 | API 边界统一为「元」进「元」出 | 旧前端会把元当微元解读(相差 1e6 倍),前后端必须同时升级 |
配套的修法也一并落地:
- 移除
topup.exchange_rate:自定义金额恒 1:1,营销赠送(“充 10 得 15”)改由快捷金额的**「支付金额 / 到账金额」双列**承担,并强制到账金额不低于支付金额——从结构上杜绝"充值缩水"。 - 一次性迁移脚本
cmd/migrate_topup_presets修正历史脏数据,默认干跑,--apply才写回。 - 取整用
math.Round而不是int64()截断:0.07 元 × 1e6的浮点结果是69999.99…,截断会得到69999,少 1 个额度。数值换算上的"差一点点"在计费系统里就是账对不平。
5.3 换算收口在 controller 层,不能下沉到 model
这是本次重构最重要的架构决策。做法是 controller/quota_dto.go 作为唯一换算收口,用「内嵌 model + 同名字段遮蔽」实现——只声明需要换算的字段,其余字段自动透传:
|
|
为什么不下沉到 model 层? 因为跨节点推送、Redis 缓存和订单快照都依赖 model 的 JSON 表示。在 model 层改单位,等于同时改掉了集群事件、缓存内容和历史订单快照的语义——这不是"改个字段",是把三条数据通路一起改坏。
同理,前端彻底不做换算。utils/quota.js 只保留格式化:
|
|
精度上也有一处细节:输入框精度按金额自适应——恰为「分」的整数倍用 2 位,否则用 6 位。判断用「×100 后是否仍为整数」加 1e-9 容差(因为 IEEE 754 里 0.07 * 100 === 7.000000000000001)。这是为了解决一个真实的资损:管理端额度输入框曾用 toFixed(2) 回填,把 ¥0.0005(500 额度)截断成 0.00,保存后即被清零。
5.4 按次计费必须精确预扣
按次定价(per_request)的模型此前沿用 token 估算口径预扣(只有几百额度),结算时才按按次价格扣款——单次调用就能把 users.quota 扣成负数。
修法是把文本链路和音频链路统一到精确预扣:
- 预扣额 = 结算额,差值恒为 0;
- 余额不足在预扣阶段直接返回 403,而不是结算时才发现透支;
- 同时豁免"usage 为 0 不计费"的守卫,否则按次费用会被误退回;
- 语音合成 / 转写此前自带一套独立的预扣逻辑(与文本链路不一致,同样会透支),一并复用统一实现。
顺带修了一个展示问题:结算日志的 per_request 分支误取 token 单价,导致 tts / whisper 这类无 token 单价的模型显示「¥0.000000」,补 PerRequestPrice 字段后改用按次单价。
六、企业能力(三):LDAP 目录登录
企业内网的第一诉求通常是"别给我再整一套账号"。v0.0.26 补上了 LDAP。
6.1 校验流程
两处安全设计值得点出:
- 用户名在拼装过滤器前做 RFC4515 转义。
(uid={0})里的{0}是用户可控输入,不转义就是 LDAP 过滤器注入。这条防线不能省。 - LDAP 用户在本地不保存可登录的密码。
password列有not null约束,所以写入一个随机串的 bcrypt 值——本地密码登录对 LDAP 用户天然不可用。
6.2 两个务实的可配置项
- 姓名属性可配(
ldap.attr_display_name):原先硬编码displayName,但各目录实现差异很大,常见取值是cn(中文名)、en(英文名)或displayName,留空回退displayName。搜索与建档都使用该属性。 - 自动注册开关(
ldap.auto_register):开启后首次登录自动在本地建档(普通用户角色、按新用户策略赠额、生成默认令牌);关闭则未注册账号被拒绝并提示联系管理员。这个开关直接决定了这套系统是"内部工具"还是"对外的 SaaS"。
6.3 可测性设计
LDAP 认证入口被抽成包级变量,便于单测替换为 stub,避免测试依赖真实目录服务:
|
|
另外,错误分成两类处理——「账号/密码问题」如实告知用户(哨兵错误),服务端异常(连接失败、服务账号绑定失败)只写日志、对外统一模糊化,不泄露目录服务内部细节。搜索命中上限也做了限制(命中 2 条即判定为同名账号,无需拉回更多)。
七、企业能力(四):去中心化双活集群
上游的多机部署是"多台共享一个 MySQL"。这在跨地域时直接失效——异地访问的延迟全压在中心库上。二开改成每节点独立 MySQL + Redis,靠应用层事件同步:
实现拆成 8 个文件:applier / cluster / config / event / handler / node / pusher / watcher。几个关键设计:
一、同步表用白名单,不是黑名单。 只同步明确列出的表,避免哪天加了一张本地缓存表被误推到全网:
|
|
二、写入侧用 GORM 回调捕获变更,落 sync_events 表,再异步推送。 应用代码不需要知道集群的存在。
三、推送带防抖合并。 高频写入如果一条事件一次 HTTP,会直接把集群打成广播风暴。做法是一个容量 256 的带缓冲 channel 做信号 + 100ms 静默后批量 drain:
|
|
四、启动补推。 节点重启会丢内存里的待推送信号,所以 Init 时扫一遍 pushed = 0 的事件补推:
|
|
五、节点自发现。 每个节点维护 cluster_nodes 表(含 status / last_heartbeat / ping_failures),支持配置种子节点,首个节点留空或填自己即可。启动时 discoverOnce + 定时 StartDiscovery,另有两个常驻 goroutine 负责推送与事件清理。
八、让二开能持续:把规范写进 AGENTS.md
这部分看着不"技术",但它决定了三个月后这个 fork 还能不能维护。二开最大的风险不是写不出来,是写完之后没人敢改。 所以项目里有一份 AGENTS.md,把"从代码里推不出来但每次都必须遵守"的约定显式写下来:
8.1 目录纪律
前端只在 web/default-pro/ 维护。 明确禁止改动 web/air/、web/default/、web/berry/、web/THEMES、web/build/ —— 前三个是废弃的历史主题,THEMES 是构建脚本读的名单,build/ 是产物。不做这条约束,迟早有人改到产物目录里,然后构建一跑就被覆盖。
8.2 提交规范
- Conventional Commits,scope 限定在
web | backend | model | controller | router | relay | channel | middleware | common | docs | release - 粒度强约束:一个 commit = 一个文件 或 一个功能,同文件多功能必须拆;禁止"fix: 改了点东西"这类无差异描述
- commit body 必须中英双语说明详细变更:
|
|
双语 body 不是为了好看——它是给未来的 review 和 AI 协作留的上下文。一文件一 commit 则让 git bisect 和 revert 都能精确到点。
8.3 注释规范
新增文件必须写 4 行文件头(功能 / 版本 / 日期 / 作者),每个导出符号前带行为说明 + 版本 + 日期。真实效果如下:
|
|
这套注释有个隐性价值:注释里带版本号和日期,等于给代码留了时间轴。看 版本: v0.0.26 / 日期: 2026-10-14 就知道这块是刚加的;逻辑变更时要求同步更新日期,破坏性变更同时要改版本号。
8.4 分层与命名
model/是纯数据 + 业务函数,不许引入 HTTP / Gin 依赖;controller/只做参数解析、调用 model、组装响应- 系统设置统一进
model.SystemSetting的 kv 表,key 命名"<category>.<sub>.<field>",category 收敛在payment/plan/topup/ldap/general - 旧字段迁移后 UI 不再读写,但 DB 行保留、不主动删——
plan.allow_topup迁到topup.*、topup.exchange_rate被移除,两处都遵循这条 - 命名全局统一:内部代码一律
topup,禁止混用recharge,对外文案用「充值」
8.5 提交前必跑三件套
|
|
Go 测试统一用 glebarez/sqlite 内存库 + gorm.AutoMigrate 建表,测试名 TestXxx_Yyy(行为_场景),关键路径(设置保存、金额计算、订单激活幂等)必须覆盖。
九、踩坑清单
按"踩了会疼"排序:
| 坑 | 症状 | 解法 |
|---|---|---|
| 适配器架构用集中式常量 | 加一个厂商要改 4 个框架文件,常量枚举长期冲突 | 自注册 registry + init() + 匿名导入 |
金额基准 QuotaPerUnit 做成可写配置 |
改一个配置就能改掉全库余额语义 | 定为系统常量 1_000_000,PUT /api/option 明确拒绝 |
| 改口径不迁存量数据 | 充值设置页因历史数据直接无法保存 | 迁移脚本默认干跑、--apply 才写回 |
| 前后端前后升级 | 旧前端把「元」当微元,相差 1e6 倍 | 口径变更必须前后端同时上线 |
| 金额换算下沉到 model 层 | 集群事件 / Redis 缓存 / 订单快照语义同时被改坏 | 换算收口 controller/quota_dto.go |
int64() 截断代替四舍五入 |
0.07 × 1e6 = 69999.99… → 少 1 额度 |
统一 math.Round |
管理端额度回填用 toFixed(2) |
¥0.0005 被截断成 0.00,保存即清零 | 无损回填 + 输入精度按金额自适应(2 位 / 6 位) |
| 按次计费用 token 估算预扣 | 单次调用把 users.quota 扣成负数 |
精确预扣,预扣额=结算额,余额不足预扣阶段返回 403 |
model_limits 解析失败当成「不限量」 |
配置写错 = 全免费 | 解析失败判为不可用 |
| 支付回调不幂等 | 回调重放导致重复加额度 | 已支付订单直接返回 |
| LDAP 用户名直接拼进过滤器 | 过滤器注入 | RFC4515 转义 |
| LDAP 用户在本地存可登录密码 | 可绕过目录服务本地登录 | 写随机串 bcrypt(列有 not null 约束,必须填) |
| 集群事件无防抖 | 高频写把集群打成广播风暴 | syncNotifyChan + 100ms 批量 drain |
| 节点重启丢待推送信号 | 事件永久停留在未推送 | 启动扫 pushed = 0 补推 |
| 前端混改历史主题目录 | 构建产物被覆盖,改动白做 | 前端只在 web/default-pro/ 维护 |
Arco <a-modal> 用 :model-value |
弹窗永远不显示,且不报错 | 用 :visible |
Arco <a-spin> 不带 width |
父容器宽度异常、布局错乱 | style="width: 100%" |
前 8 条里有 5 条是同一类问题:数值口径不一致。这不是巧合——计费系统里"单位"是最容易失控的隐式契约。把它显式化成常量、收口到单一转换层、并禁止任何一层私自换算,是唯一有效的解法。
十、总结
二开一个开源项目,真正的工作量分布大概是这样的:
- 架构重构(20%) —— 适配器自注册、目录重排、命名冲突消除。这部分不产生任何新功能,但决定了后面 80% 的迭代速度。
- 缺口补齐(50%) —— 前端整体重写、套餐订阅、真实支付、LDAP。这些是"企业级"与"玩具"的实际分界。
- 口径统一(20%) —— 金额从微元到元的全链路收口。改动量不大,但它是最容易造成资损的地方,也是最需要谨慎的部分。
- 规范沉淀(10%) ——
AGENTS.md。看着最不像"技术活",但它决定这个 fork 三个月后还能不能被别人(或你自己)安全地改动。
参考资料
- 项目 README 与
AGENTS.md(BU22 AI 二开规范) CHANGELOG/v0.0.24.md/v0.0.25.md/v0.0.26.md(金额口径、LDAP)- 上游项目:one-api(2024 年停止维护)
- 二开产物:one-api-pro