这是这个 fork 从 v0.1 到 v0.5 真实趟过的坑、用脑子撞出的解法。 跟 troubleshooting.md 的区别: 那篇按"现象"写给运维查;本篇按"开发顺序"写给写代码的人避雷。
- A. 路由 / 渠道相关(伤害最大的几个)
- B. GORM / SQLite 数据层
- C. 自动化注册(chromedp / 反爬)
- D. UI / Semi Design / 前端
- E. 编译 / embed / 二进制
- F. Telegram / 第三方 API
- G. 流程 / 协作 / 文档
伤害指数:⭐⭐⭐⭐⭐(彻底骗自己 + 排查 4 小时)
后台「渠道管理」里看到 channel 是绿的、status=1;调 /v1/chat/completions 必报 No available channel。
- group 不对?查 → 是
default,gemini,✅ - status 不对?查 → 是 1,✅
- models 没填?查 → 已经填了,✅
- 是不是
abilities表没数据?查 → 0 行
建 channel 时用了 model.DB.Create(ch),完全跳过 Channel.Insert() 里的 AddAbilities()。
// ❌ 错的
model.DB.Create(ch)
// ✅ 对的
ch.Insert() // 内部会走 AddAbilities()AddAbilities() 把 channel.Group × channel.Models 笛卡尔积写到 abilities 表,这才是路由。
搜全代码:
rg 'DB\.Create\(ch\)|DB\.Create\(&ch\)' new-api-src/全部替换为 ch.Insert()(涉及 controller/pool.go::AddPoolAccount ManualSubmitJobResult BindPoolAccountChannel + service/pool_worker.go::runJob)。
- 在
coding-standards.md写死规则 - 在
decisions.md写 ADR-0005 - code review 时
rg一遍这个模式
伤害指数:⭐⭐⭐⭐
跟 A1 长得一模一样,No available channel for model X under group default。
最初实现 controller/pool.go::AddPoolAccount 写:
ch.Group = recipe.Provider // "gemini"而用户 token 默认 users.group = 'default' → abilities WHERE group='default' AND model='gemini-2.5-flash' → 0 行。
group 写双值:
chGroup := "default"
if groupName != "" && groupName != "default" {
chGroup = "default," + groupName
}
ch.Group = chGroupAddAbilities 内部 strings.Split(ch.Group, ",") 会写两份记录(default 一份 + gemini 一份),两类用户都命中。
- ADR-0006 立规矩
- 测试用例:建一个 channel 后查
abilities表是否有default行
伤害指数:⭐⭐⭐
abilities 表有几行 default,xxx 但每个 channel 只有 1-2 条 model。
建 channel 时 models 留空,等待用户进「渠道管理」点「获取模型列表」补全。但用户根本不知道要补全。
- 给
PoolRecipe加DefaultModels字段(text, 逗号分隔) - Seed 19 条 Recipe 全部填好(OpenAI 写 30+ 模型,Gemini 写 8 个,DeepSeek 写 2 个等)
- 自动建 channel 时
ch.Models = recipe.DefaultModels
DefaultModels 写满 ≠ 该 Key 实际有权限的模型。但路由不到比 401 更糟。
伤害指数:⭐⭐
ch := &model.Channel{
Tag: "pool-auto", // ❌ 编译报错:cannot use string as *string
}poolTag := "pool-auto"
ch.Tag = &poolTag看 model/channel.go 字段类型 → 知道有些指针字段是为了"区分 NULL vs 空字符串"。
伤害指数:⭐⭐⭐⭐⭐(最阴险)
Seed 文件写:
{Key: "deepseek-direct", ManualMode: true},
{Key: "openai-runner", ManualMode: false}, // 想让它走自动 runner启动后查 db → 全是 manual_mode=1。
PoolRecipe 字段:
ManualMode bool `gorm:"default:true"`GORM 上送 Create() 时,ManualMode=false 是 zero-value,触发 default 规则被覆盖成 true。
Save() 也一样,因为 GORM 不知道你"是真的想存 false"还是"留空"。
- 字段去掉
gorm:"default:true" - Seed 显式写
ManualMode: true/false,不依赖 default - 一次性迁移 SQL 修正旧数据:
UPDATE pool_recipes SET manual_mode=1 WHERE manual_mode=0 AND (webhook_url IS NULL OR webhook_url='');
预防(ADR-0009)
- 任何
bool字段都不要写gorm:"default:..." - 任何
int字段写gorm:"default:0"也要小心 zero-value - Seed 都用结构体字面量显式赋值,不靠 default
伤害指数:⭐⭐
SELECT group FROM tokens;
-- Error: in prepare, no such column: group_报"no such column" 把人误导得以为字段不存在。
SELECT `group` FROM tokens;
SELECT `key`, value FROM options;GORM ORM 写法没事(自动加引号),手写 SQL / model.DB.Raw(...) 必须自己加。
排查脚本里的 SQL 第一时间检查关键字。
伤害指数:⭐⭐⭐⭐
purge mock 数据:
sqlite3 new-api.db "DELETE FROM pool_jobs WHERE recipe_key='debug-mock';"
# 0 rows affected重启服务 mock 数据还在。
项目目录同时有两个 db 文件(早期改名残留):
one-api.db← 实际运行的new-api.db← 误命名 / 历史残留
服务启动时按代码里的 DB_PATH 默认 one-api.db。
file *.db看 sqlite 头确认是 sqlite dblsof -p $(pgrep new-api-macos) | grep -i sqlite看进程实际打开哪个- 删冗余文件,只留
one-api.db
- 部署文档明确标注唯一 db 路径
- 备份脚本只对
one-api.db操作
伤害指数:⭐⭐
拷了 one-api.db,恢复后发现少了几小时数据。
SQLite WAL 模式下,未 checkpoint 的写入在 *.db-wal 里,shared memory 在 *.db-shm。只拷主 db = 拿了快照不一致的状态。
- 冷备份:先停服,再
cp one-api.db one-api.db-wal one-api.db-shm - 热备份:用
sqlite3 one-api.db ".backup 'backup.db'"(自动处理一致性)
伤害指数:⭐⭐
本地测试时 DELETE FROM pool_recipes;,然后重启 → 表里没有数据。
但是预期 SeedDefaultPoolRecipes() 应该重新插入。
SeedDefaultPoolRecipes() 只在表为空时插入新条目,但单条记录的更新逻辑是 upsertPoolRecipe(existing, seed)——如果 db 里已有一条同 key 的,不会强制重置。
但如果整个表空,分支走的是 INSERT 全部。
如果你 DELETE 了一部分,SeedDefault 不会回填那部分。
启动时迁移逻辑加:
for _, seed := range defaults {
var existing PoolRecipe
err := DB.Where("`key` = ?", seed.Key).First(&existing).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
DB.Create(&seed) // 缺哪条补哪条
} else {
upsertPoolRecipe(&existing, &seed)
}
}不要靠"DELETE → 重启自愈"。要重置就 DELETE 后手动 INSERT,或写专门的 reset 脚本。
伤害指数:⭐⭐⭐⭐⭐(直接毙了 7 个 Runner)
跑 OpenRouter Runner,能进 sign-up 页,能填 email + password,点 Continue → 卡在 Cloudflare Turnstile checkbox 几分钟 → timeout。
Cloudflare Turnstile 检查 event.isTrusted:
- 真人鼠标点击 =
true - chromedp
chromedp.Click(...)=false
被发现 = 不允许通过。
chromedp.MouseClick按坐标 → 仍 isTrusted=false- 注 fake
Object.defineProperty(MouseEvent.prototype, 'isTrusted', {get: () => true})→ CF 能识别注入 - residential proxy + headed 模式 → 通过率仍 < 30%
- undetected_chromedriver / puppeteer-extra-plugin-stealth → 略好但不稳定
放弃。删除所有商业站点的 chromedp Runner(ADR-0002)。
保留 RecipeRunner 接口(万一以后接打码服务再说)。
所有 19 条内置 Recipe 改 ManualMode=true。
- 诚实优于显得能干:跑得了 30% 不如直接告诉用户"半自动 3 分钟"
- 反爬是个无底洞,业务侧没人会因为这个给你预算
伤害指数:⭐⭐⭐⭐
Cerebras 注册(曾经能跑):
- 浏览器自动化 ✅ 邮件验证 ✅ 设置密码 ✅ 进 onboarding
- 选 Free plan → "This organization does not exist"
- console 里
recaptcha__zh_cn.js+Failed to fetch
Cerebras 注册流第二步触发 reCAPTCHA v3 → 客户端从 gstatic.com 取脚本 → 国内被墙 → reCAPTCHA timeout → 后端拒建 organization。
注册账号其实已建,但卡在 organization 关联那步。
- 用境外住宅代理 / V2Ray:解决得了,但成本高
- 走 OAuth 而不是邮箱注册:reCAPTCHA 不在 OAuth 流程里
- 改 Recipe 标记
ManualMode=true让用户自己用 VPN 注册
即使浏览器能自动化,网络层也可能挡你(reCAPTCHA / hCaptcha 都依赖外部 CDN)。
伤害指数:⭐⭐⭐⭐
HuggingFace 注册卡在"找出不一样的图片"拼图。点击 iframe 没响应。
hCaptcha iframe 是跨域 + 加密通信,外部脚本根本无法 DOM 操作里面的图片。 即使能截图传给打码服务,回传坐标点击仍被 isTrusted 检测干掉。
放弃。同 C1。
伤害指数:⭐⭐⭐
用 mail.tm 临时邮箱注册 → 提交时被拒"This email address is not allowed"。
OpenAI / Anthropic / Cerebras 都维护 disposable email 黑名单(mail.tm / temp-mail / guerrillamail 全在内)。
- 用真实邮箱(gmail + 别名
you+xxx@gmail.com) - 用付费一次性邮箱服务(domain 没在黑名单里)
- 标记这些 Recipe 为
ManualMode + RequiredMaterials="真实邮箱(不要用 tempmail)"
伤害指数:⭐⭐
账号有 30 RUB(约 $0.4),尝试 OpenAI 短信验证 → "Insufficient funds"。
OpenAI 用美国号码(USA),单价 ~30 RUB。但俄罗斯本地号 5 RUB。系统不会自动选最便宜的。
service/pool_sms_provider.go::fivesimProvider加PriceLimit参数- API 选号要传
country=any+maxPrice让 5sim 路由 - 文档注明充值至少 100 RUB 起步(保证够买美区号几次)
伤害指数:⭐⭐⭐⭐(直接被老板骂)
点「编辑」 → Modal 弹出 → 表单字段全空。
<Form initValues={editing}> 的 initValues 只在 Form mount 时读一次。第一次打开 Modal mount 完,第二次打开 Modal 已经在 mount 状态,initValues 不会重新读取。
三连:
<Modal destroyOnClose visible={open} ...>
<Form
key={editing?.id} // ① 不同 id → 重新 mount
initValues={editing}
/>
</Modal>加 destroyOnClose 也起作用(Modal 关掉时整体卸载)。
key={editing?.id} 是更稳的"强制重渲染"信号。
- 在
coding-standards.md立规矩 - code review 看到
<Form initValues>必有这个 key
伤害指数:⭐
import { IllustrationNoContent } from '@douyinfe/semi-illustrations';
// ❌ Module has no exported member 'IllustrationNoContent'Semi 包 @douyinfe/semi-illustrations 只导出特定几个 Illustration(NoResult / NoAccess / Construction),没有 NoContent。
找近义词:
import { IllustrationNoResult } from '@douyinfe/semi-illustrations';开发前先 node -e "console.log(Object.keys(require('@douyinfe/semi-illustrations')))" 看导出清单。
伤害指数:⭐
连续点几次按钮 → Console 大量 Notification with same content already exists。
给 notification 一个 ID:
Notification.error({
id: 'pool-bind-error', // 同 id 会自动去重
title: '...',
content: '...',
});或用 useRef 防抖。
伤害指数:⭐⭐
切换 <Form.Switch> → 视图正常变化,但 formApi.getValues().manual_mode 还是旧的。
Switch 不是受控组件时,要用 field props 让 Form 接管。
<Form.Switch
field="manual_mode" // ✅ Form 接管
label="..."
/>不要混用 value/onChange 和 field。
伤害指数:⭐⭐⭐
前端改了文案,编译完跑起来浏览器仍是老版本。
Go 编译器看 //go:embed web/dist 的源文件没变,直接用 build cache——即使 dist/index.html 改了。
强制重编:
cd new-api-src
touch main.go # 让 go 觉得源码变了
go build -a -o ../new-api/new-api-macos-a = 重编所有依赖。
- 写一个
build.sh标准脚本,里面带touch + -a - CI 永远 fresh build
伤害指数:⭐
SCP new-api-macos 到 ubuntu → 跑不起来。
macOS arm64 ↔ Linux x64 不兼容,要交叉编译。
GOOS=linux GOARCH=amd64 go build -o new-api-linux-x64
GOOS=linux GOARCH=arm64 go build -o new-api-linux-arm64
GOOS=darwin GOARCH=arm64 go build -o new-api-macos- 命名带架构后缀
- 部署文档先
uname -m再下二进制
伤害指数:⭐⭐
点了没反应;右键打开 → 弹"无法验证开发者"。
chmod +x start.command new-api-macos
xattr -d com.apple.quarantine start.command new-api-macos 2>/dev/nullREADME / start.command 里写 first-run 说明。
伤害指数:⭐⭐
脚本:
#!/bin/bash
mv ~/Downloads/new-api-macos new-api-macos
./new-api-macos执行 → "Text file busy"。
旧进程还在跑,mv 时 macOS 不允许覆盖正在执行的二进制。
分两步:
pkill -f new-api-macos
sleep 2
mv new-api-macos.new new-api-macos
./new-api-macos或文件名分版本:new-api-macos-v0.5.0,软链 new-api-macos -> new-api-macos-v0.5.0。
伤害指数:⭐⭐
Bot Token + Chat Id 都填了,发消息返回 400。
chat_id 用错了:
- 用了用户名
@yourname→ 私聊不一定支持 - 用了群组数字 但 bot 没被加进群
- 让 bot 先发一条任意消息到目标位置
- 用
getUpdates读真实 chat id:curl "https://api.telegram.org/bot<TOKEN>/getUpdates" | jq '.result[].message.chat'
- 把那个数字 id 填到「Telegram 设置」
SendTelegramMessage 解析 API 返回的 description 字段("chat not found" / "bot was blocked by the user"),把人类可读错误回传到前端。
伤害指数:⭐
告警消息里有 _ 或 * → Telegram 不解析或返回 can't parse entities。
func tgEscape(s string) string {
chars := "_*[]()~`>#+-=|{}.!"
for _, c := range chars {
s = strings.ReplaceAll(s, string(c), "\\"+string(c))
}
return s
}或干脆用 parse_mode=HTML,转义 </>/& 即可。
伤害指数:⭐⭐
长流程跑到一半 → mail.tm API 返回 401。
拿 token 时一并存 expire ts,每次 API 调用前检查,过期就重新 login。
伤害指数:⭐⭐⭐⭐
v0.2 加了 5 个新接口,commit 写"docs todo"。半年后排查问题,前端写的字段名和后端看到的不一致 → 4 小时往返才查清楚。
把"改文档"挂到同一个 commit:
git add controller/pool.go docs/07-reference/pool-api.md
git commit -m "feat(pool): 加 BindPoolAccountChannel + 文档"
写 PR / 合并清单时把"改文档"列死,不勾不让合。
伤害指数:⭐⭐⭐
为了开发 worker 写了 debug-mock Recipe + Runner,本地建了 5 条 mock PoolAccount + Channel。后来在生产 db 里发现还在。
SeedDefaultPoolRecipes()把debug-mock也写进默认了- 老板看到一堆名字奇怪的 channel + 价格估算虚高
- 删
service/pool_runner_mock.go - 删
buildDefaultRecipeSeeds()里的debug-mock项 - 一次性清理 SQL:
DELETE FROM channels WHERE name LIKE 'mock%'; DELETE FROM pool_accounts WHERE provider='mock'; DELETE FROM pool_recipes WHERE \`key\`='debug-mock'; DELETE FROM pool_jobs WHERE recipe_key='debug-mock'; DELETE FROM abilities WHERE channel_id IN (...) ;
- 永远不把
mock/test/debug字样的 Seed 放默认列表 - 临时数据用专门的 seed 函数 +
if env=="dev"包起来
伤害指数:⭐
准备演示 manual-result:
curl -X POST $BASE/api/pool/jobs/2/manual-result -d '{...}'
# Error: record not found我创建了 Job 但拿到 job_id=1,curl 默写了 2。
脚本里必从响应里拿 id,不要硬编码:
JOB_ID=$(curl -s ... | jq -r '.data.job_id')
curl -X POST $BASE/api/pool/jobs/$JOB_ID/manual-result ...伤害指数:⭐⭐⭐⭐⭐(最严重的隐患)
git status 里有 .env,差点 git add .。
.gitignore提前写好:.env *.env.local one-api.db* data/logs/ backups/- 装
git secrets:brew install git-secrets && git secrets --install
- 项目根放
.env.example(不含真值) - pre-commit hook 检查含
sk-AIzaAKIA的字符串
伤害指数:⭐⭐⭐
"加 Recipe + 改路由 + 改 UI + 改 Telegram"塞一个 commit。事后发现 Telegram 部分有 bug,想 revert 单 Telegram 改动 → 整 commit 回滚就把其他全没了。
- 一个改动 = 一个 commit
- 改前先
git status看清当前 dirty - 改完
git add -p分块加(不是一锅git add .)
- Channel 入库 →
ch.Insert(),绝不用DB.Create(ch) - bool 字段 → 不写
gorm:"default:..." - Modal 编辑表单 →
destroyOnClose + key + initValues三件套 - 改代码 → 同 commit 改文档
- 不存在的反爬绕过——chromedp 不能赢 Cloudflare/reCAPTCHA/hCaptcha,承认这一点节省 80% 时间
下一次踩到新坑,马上追加到这里。半年后接手的人会感谢你(很可能就是你自己)。