如何二开 OneAPI 打造企业级 AI 网关

以上游停更的 one-api 为底座做架构级二开:适配器从集中式常量改为自注册、控制台整体重写为 Vue 3、补齐套餐订阅与真实支付计费、LDAP 目录登录与去中心化双活集群,并沉淀出支撑上述改动可持续演进的工程规范

🚪 如何二开 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 上游的痛

上游加一个厂商的完整动作是这样的:

  1. 在 channeltype/define.go 的 iota 枚举里加一个常量;
  2. 在 url.go 的并行数组里加一个默认 base URL;
  3. 在 helper.go 的两级 switch 里加一个分支;
  4. 再新建厂商自己的适配器目录。

问题是前 3 步都在框架文件里。多人协作时这三处常量枚举是稳定的冲突源,而且新增渠道这种纯业务动作要改框架,属于典型的抽象泄漏。

2.2 改造后的结构

核心是一张注册表加一个接口:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
// relay/adaptor/interface.go
type Adaptor interface {
	Init(meta *meta.Meta)
	GetRequestURL(meta *meta.Meta) (string, error)
	SetupRequestHeader(c *gin.Context, req *http.Request, meta *meta.Meta) error
	ConvertRequest(c *gin.Context, relayMode int, request *model.GeneralOpenAIRequest) (any, error)
	ConvertImageRequest(request *model.ImageRequest) (any, error)
	DoRequest(c *gin.Context, meta *meta.Meta, requestBody io.Reader) (*http.Response, error)
	DoResponse(c *gin.Context, resp *http.Response, meta *meta.Meta) (usage *model.Usage, err *model.ErrorWithStatusCode)
	GetModelList() []string
}

注册表本身是「元数据 + 工厂函数」,并且同时维护 ID 与旧数字类型两套索引——因为数据库里的 channel.type 是数字,历史数据不能丢:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
// relay/registry/registry.go
type ChannelMeta struct {
	ID             string // 如 "deepseek"
	Name           string // 如 "DeepSeek"
	DefaultBaseURL string
	LegacyType     int    // 兼容历史 channel.type 数字
}

type AdaptorFactory func() adaptor.Adaptor

var (
	mu               sync.RWMutex
	channelsByID     = make(map[string]*channelEntry)
	channelsByLegacy = make(map[int]*channelEntry)
	channelIDs       []string
)

func Register(meta ChannelMeta, factory AdaptorFactory) { /* 双索引写入 */ }

func GetAdaptor(id string) adaptor.Adaptor              { /* 按 ID 取 */ }
func GetAdaptorByLegacyType(typ int) adaptor.Adaptor    { /* 按旧数字类型取 */ }

于是每个厂商只需在自己包里写一个 init():

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
// relay/adaptor/provider/deepseek/register.go
package deepseek

func init() {
	registry.Register(registry.ChannelMeta{
		ID:             "deepseek",
		Name:           "DeepSeek",
		DefaultBaseURL: "https://api.deepseek.com",
		LegacyType:     36,
	}, func() adaptor.Adaptor {
		return &Adaptor{}
	})
}

最后在 relay/adaptor.go 里用匿名导入把这些包拉进来触发 init():

1
2
3
4
5
6
// relay/adaptor.go
import (
	_ "github.com/modelbus/one-api-pro/relay/adaptor/provider/deepseek"
	_ "github.com/modelbus/one-api-pro/relay/adaptor/provider/gemini"
	// ... 另外 37 行
)

2.3 关键收益:能复用 openai 适配器的厂商,不要重写

改造后大量厂商是"薄壳"——例如 DeepSeek 的接口本身就兼容 OpenAI,它的适配器只做转发,不重复实现逻辑:

1
2
3
4
5
6
7
8
type Adaptor struct {
	openaiAdaptor openai.Adaptor   // 组合,而不是继承
}

func (a *Adaptor) GetRequestURL(meta *meta.Meta) (string, error) {
	return a.openaiAdaptor.GetRequestURL(meta)
}
// SetupRequestHeader / ConvertRequest / ConvertImageRequest 同理直通

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:

1
2
3
4
build: {
  outDir: '../build/default-pro',
  emptyOutDir: true,
}

后端 main.go 用 //go:embed 把整个目录打进去:

1
//go:embed all:web/build

于是交付物就是一个二进制 + 一个数据库,不用单独部署前端、不用担心静态资源路径。开发态则由 Vite 反向代理兜住,前端 3001 端口把 /api 和 /v1 转给后端的 3000:

1
2
3
4
server: {
  port: 3001,
  proxy: { '/api': 'http://localhost:3000', '/v1': 'http://localhost:3000' },
}

3.3 三条前端工程约定

一、API 模块化,统一响应契约。 所有请求走 src/api/index.js 里的 axios 实例,响应拦截器统一处理 401(跳登录并带 expired=true)与 429:

1
2
3
4
5
6
7
8
api.interceptors.response.use(
  (response) => response,
  (error) => {
    const { status } = error.response || {}
    if (status === 401) window.location.href = '/login?expired=true'
    return Promise.reject(error)
  }
)

后端所有接口(含错误)都返回 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>,让浏览器字体与断行行为跟着走:

1
2
3
4
function syncHtmlLang(locale) {
  document.documentElement.setAttribute('lang', locale === 'zh' ? 'zh-CN' : locale)
}
watch(() => i18n.global.locale.value, (val) => syncHtmlLang(val))

三、纯逻辑抽成工具函数 + Node 自带 test runner 单测。 金额计算、校验这类逻辑不允许写在组件里,统一放 src/utils/<name>.js,同目录配 <name>.test.mjs:

1
2
3
4
5
6
7
src/utils/
├── ldap.js         + ldap.test.mjs
├── plan.js         + plan.test.mjs
├── plan_limits.js  + plan_limits.test.mjs
├── quota.js        + quota.test.mjs
├── token.js        + token.test.mjs
└── topup.js        + topup.test.mjs

测试用 Node 内置的 node:test,不引入任何测试框架,跑法就是 node src/utils/*.test.mjs:

1
2
3
4
5
6
import { test } from 'node:test'
import assert from 'node:assert/strict'

test('normalizeFeaturesRaw: null/undefined → []', () => {
  assert.deepEqual(normalizeFeaturesRaw(null), [])
})

对"加点工具函数"这种高频改动,零依赖 + 零配置的收益很直接:改完就能验,不需要为跑一个断言装半套 Vitest。

3.4 两个必踩的 Arco 坑

这两个坑写进了项目规范,因为踩过一次就再也不想踩第二次:

  1. <a-modal> 的可见性属性是 :visible,不是 :model-value。 写成 :model-value 弹窗永远不显示,而且不报错——组件挂上了,就是看不见。
  2. <a-spin> 必须带 style="width: 100%"。 不带会让父容器宽度表现异常,引发布局错乱。

四、企业能力(一):套餐订阅与真实支付

上游完全没有商业化能力,这是"企业级"和"玩具"的分水岭。二开补上的是一套完整的套餐 + 订阅 + 订单 + 真实支付闭环。

4.1 套餐按「加权配额池」判定

套餐要支持「token 计费 / 按次计费」两种口径、周期限额(时 / 周 / 月)、以及按模型粒度的控制。判定逻辑收在 model/plan_quota.go:

1
2
3
4
// 池子容量取 100,让数值直接可读为百分比
const QuotaPoolCapacity float64 = 100
// 每个模型的权重 = QuotaPoolCapacity / limit
// 套餐耗尽条件:Σ(consumed × QuotaPoolCapacity / limit) ≥ QuotaPoolCapacity

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
2
// common/config/config.go
const QuotaPerUnit = 1_000_000.0

语义上等同于微信支付的「分」——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 + 同名字段遮蔽」实现——只声明需要换算的字段,其余字段自动透传:

1
2
3
4
5
6
// 伪代码示意:只遮蔽要换算的金额字段,其它字段零成本透传
type UserDTO struct {
	model.User          // 内嵌,其余字段自动带出
	Quota     float64 `json:"quota"`      // 遮蔽:微元 → 元
	UsedQuota float64 `json:"used_quota"`
}

为什么不下沉到 model 层? 因为跨节点推送、Redis 缓存和订单快照都依赖 model 的 JSON 表示。在 model 层改单位,等于同时改掉了集群事件、缓存内容和历史订单快照的语义——这不是"改个字段",是把三条数据通路一起改坏。

同理,前端彻底不做换算。utils/quota.js 只保留格式化:

1
2
3
4
5
6
7
// 前端只负责显示格式化,不再做任何 1e6 换算
export function formatYuan(amount) {
  const v = Number(amount) || 0
  const abs = Math.abs(v)
  const digits = abs >= 1 ? 2 : abs >= 0.01 ? 4 : 6   // 小数位随量级自适应
  return `¥${v.toLocaleString('en-US', { minimumFractionDigits: digits, maximumFractionDigits: digits })}`
}

精度上也有一处细节:输入框精度按金额自适应——恰为「分」的整数倍用 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 校验流程

两处安全设计值得点出:

  1. 用户名在拼装过滤器前做 RFC4515 转义。(uid={0}) 里的 {0} 是用户可控输入,不转义就是 LDAP 过滤器注入。这条防线不能省。
  2. 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,避免测试依赖真实目录服务:

1
2
3
// ldapAuthenticate 是 LDAP 认证入口。
// 抽成包级变量,便于单测替换为 stub,避免测试依赖真实目录服务。
var ldapAuthenticate = defaultLdapAuthenticate

另外,错误分成两类处理——「账号/密码问题」如实告知用户(哨兵错误),服务端异常(连接失败、服务账号绑定失败)只写日志、对外统一模糊化,不泄露目录服务内部细节。搜索命中上限也做了限制(命中 2 条即判定为同名账号,无需拉回更多)。


七、企业能力(四):去中心化双活集群

上游的多机部署是"多台共享一个 MySQL"。这在跨地域时直接失效——异地访问的延迟全压在中心库上。二开改成每节点独立 MySQL + Redis,靠应用层事件同步:

实现拆成 8 个文件:applier / cluster / config / event / handler / node / pusher / watcher。几个关键设计:

一、同步表用白名单,不是黑名单。 只同步明确列出的表,避免哪天加了一张本地缓存表被误推到全网:

1
2
3
4
5
6
7
var syncTableList = []string{
	"users", "tokens", "channels", "abilities",
	"options", "redemptions", "plans",
	"user_plans", "plan_usages",
	"channel_counters",
}
// logs 表单独由 SyncLogs 开关控制

二、写入侧用 GORM 回调捕获变更,落 sync_events 表,再异步推送。 应用代码不需要知道集群的存在。

三、推送带防抖合并。 高频写入如果一条事件一次 HTTP,会直接把集群打成广播风暴。做法是一个容量 256 的带缓冲 channel 做信号 + 100ms 静默后批量 drain:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
var syncNotifyChan = make(chan struct{}, 256)

func NotifySyncEvent() {
	select {
	case syncNotifyChan <- struct{}{}:
	default:            // 满了就丢信号,反正下面会批量 drain
	}
}

func StartPusher(db *gorm.DB) {
	for range syncNotifyChan {
		time.Sleep(100 * time.Millisecond)
		drainNotifyChan()
		pushEvents(db)   // 批量推送
	}
}

四、启动补推。 节点重启会丢内存里的待推送信号,所以 Init 时扫一遍 pushed = 0 的事件补推:

1
2
3
4
5
6
var count int64
WithSkipHook(db).Model(&model.SyncEvent{}).Where("pushed = 0").Count(&count)
if count > 0 {
	logger.SysLogf("[集群] 发现 %d 条未推送事件,开始补推", count)
	NotifySyncEvent()
}

五、节点自发现。 每个节点维护 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 必须中英双语说明详细变更:
1
2
3
4
5
6
7
8
feat(web): 充值弹窗支持自定义金额 chip

TopupModal: add custom-amount chip revealed on click.

- 新增 CUSTOM_IDX = -1 哨兵索引表示选中「自定义」chip
  Add CUSTOM_IDX = -1 sentinel for custom-amount selection
- allow_custom=true 时,chip 列表末尾追加「自定义」chip
  Append 「自定义」 chip at end of list when allow_custom is true

双语 body 不是为了好看——它是给未来的 review 和 AI 协作留的上下文。一文件一 commit 则让 git bisect 和 revert 都能精确到点。

8.3 注释规范

新增文件必须写 4 行文件头(功能 / 版本 / 日期 / 作者),每个导出符号前带行为说明 + 版本 + 日期。真实效果如下:

1
2
3
4
5
6
7
// formatQuota 将金额格式化为 ¥ 字符串;空值返回 '-',便于表格直接渲染。
// 入参已是「元」(后端口径),不再做任何换算。
// Format an already-yuan amount; returns '-' for empty input so tables can render it directly.
//
// 版本: v0.0.25
// 日期: 2026-10-04
export function formatQuota(amount) { ... }

这套注释有个隐性价值:注释里带版本号和日期,等于给代码留了时间轴。看 版本: 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 提交前必跑三件套

1
2
3
go build ./...
go test ./model/ ./controller/ ./middleware/
cd web/default-pro && npm run build

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 条是同一类问题:数值口径不一致。这不是巧合——计费系统里"单位"是最容易失控的隐式契约。把它显式化成常量、收口到单一转换层、并禁止任何一层私自换算,是唯一有效的解法。


十、总结

二开一个开源项目,真正的工作量分布大概是这样的:

  1. 架构重构(20%) —— 适配器自注册、目录重排、命名冲突消除。这部分不产生任何新功能,但决定了后面 80% 的迭代速度。
  2. 缺口补齐(50%) —— 前端整体重写、套餐订阅、真实支付、LDAP。这些是"企业级"与"玩具"的实际分界。
  3. 口径统一(20%) —— 金额从微元到元的全链路收口。改动量不大,但它是最容易造成资损的地方,也是最需要谨慎的部分。
  4. 规范沉淀(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
comments powered by Disqus
使用 Hugo 构建😊 主题 Stack 由 Jimmy 设计