Skip to content

08 基础设施与运维工程化深挖问答(简历 bullet ④) ​

本分册的特殊性:这是三份材料里唯一需要"边讲边修正简历"的一份。 简历第三条写的是"生产级基础设施:多环境配置(本地磁盘 vs 阿里云 OSS)、Docker Compose 一键部署、微信登录+微信支付接入"。 我逐条到代码里核过,三条里有一条半与代码有出入:compose.yml 里根本没有应用容器,README.md:67 至今还写着 MySQL(这是我简历写错的直接来源)。 所以这份材料的主线是:先讲真实实现 → 再主动交底欠账 → 最后给出得体的修正话术。 阅读顺序:先看第一节的架构总览和"简历 vs 代码"对照表,再按 Q1→Q23 顺序读。 每题统一四段式:【考点】(面试官在测什么)→ 【口述答案】(背这段)→ 【备注讲解】(不要背出声,是理解用的)→ 【代码依据】(IDE 里能跳过去验证)。


〇、一页总览(先在纸上画出来再开口) ​

真实部署架构(文字版架构图) ​

                    浏览器(shatang.top)
                            │  HTTPS
                            ▼
┌─────────────────────────────────────────────────────────────────┐
│  宿主 nginx(conf 不在仓库!deploy/ 下只有 *.example.conf 模板)    │
│    · 静态资源:vite build 产物 + admin 产物                        │
│    · /api/ → proxy_pass http://127.0.0.1:3001(整段 URI 原样转)   │
│    · proxy_read_timeout 900s(为长耗时生成接口留的)                │
└───────────────────────────────┬─────────────────────────────────┘
                                ▼
┌─────────────────────────────────────────────────────────────────┐
│  pm2 进程 shatangai-backend(生产 /opt/shatangAI,端口 3001)      │
│    · Node 22 + Express 5 + TypeScript                             │
│    · 启动命令:node ./node_modules/tsx/dist/cli.mjs index.ts       │
│      ★ 没有 dist/、没有 tsc 构建产物,运行时由 tsx 直接跑 .ts      │
│    · 单实例,无 cluster;pm2 配置不在仓库里(服务器上手工起的)      │
│    · 启动顺序:查 ffmpeg → 跑迁移 → 连 Redis → 起 9 个 Worker      │
│      → 视频编辑恢复 → 【最后才 listen(3001)】                       │
└──────┬──────────────────┬──────────────────┬────────────────────┘
       │ pg (pg 库)        │ ioredis          │ ali-oss
       ▼                  ▼                  ▼
┌──────────────┐  ┌──────────────┐  ┌──────────────────────┐
│ PostgreSQL 17│  │  Redis 7     │  │ 阿里云 OSS            │
│  docker 容器  │  │  docker 容器  │  │ (bucket public-read)│
│  host:5511   │  │  host:6511   │  │ 上传/删除/tag/直传签名 │
│  db=shatang_ai│ │ AOF everysec │  └──────────────────────┘
│ 池 max20/min2│  │ + RDB 三档    │
└──────────────┘  └──────────────┘
       ▲                  ▲
       └──── 同一个 docker compose 文件(compose.yml)起这两个容器 ────┘
              ★ compose 里【没有 app service、没有 Dockerfile】

同一台机器上还并存:
  测试环境 /opt/shatang_test  → pm2 名 shatang-test,端口 3002
  (两者共用同一台 Redis 主机?不是——测试走 6379,生产走 6511,靠端口区分环境段)

三条必须开口就说的定位 ​

  1. 我的部署是"基础设施容器化 + 应用侧 pm2/nginx",不是"全栈 Docker Compose 一键部署"。数据库和 Redis 在容器里,Node 应用和 nginx 在宿主上。
  2. 发布靠一个 196 行的 bash 脚本,它的核心价值不是"少敲几行命令",而是不让失败静默通过——git pull 没成功就绝不继续 migrate/build/restart。
  3. 最大的欠账是备份和告警:没有自动备份、没有 PITR、告警通道是 0。这两条我一定会主动说,因为它们是"能力缺口"而不是"没来得及做"。

简历 vs 代码(自己心里先有数) ​

简历原话代码真相怎么处理
"多环境配置(本地磁盘 vs 阿里云 OSS)"✅ 成立,但更强:USE_REAL_OSS 只决定上传后端,且未配置时会静默降级到本地磁盘保留,但要能讲清降级风险
"Docker Compose 一键部署"❌ 不成立。compose.yml 只起 PG 17 + Redis 7;没有 app service、没有 Dockerfile改口径(Q2 给话术)
"微信登录 + 微信支付接入"✅ 成立:微信开放平台扫码登录(snsapi_login)+ 微信支付 V3(含回调解密验签)保留,补上支付宝与"证书无自动轮换"的欠账
(简历没写,但仓库里写着)MySQL❌ 全仓只有 pg,无 mysql2。但这个错不在我随手编,README.md:67 至今写着 "MySQL (mysql2)"主动认错 + 讲清沿革(Q15)

一、部署架构全景 ​

Q1. 线上这套系统是怎么跑起来的?从浏览器到数据库走一遍。 ​

【考点】 你有没有"自己这台机器上跑着什么"的完整图景。答不上来说明代码是你抄的。

【口述答案】 整条链路是四段。 第一段 nginx:shatang.top 的流量先到宿主 nginx,它负责两件事——静态资源(vite build 的前端产物和 admin 产物)直出,以及 /api/ 整段前缀原样 proxy_pass 到 127.0.0.1:3001。这里有个我踩过的坑写在了模板里:proxy_pass 末尾多写一个 / 会把 /api 前缀剥掉,上游收到 /video/... 而后端路由挂在 /api/video 上,全是 404。另外我们给长耗时接口把 proxy_read_timeout 开到了 900 秒。 需要坦白一句:线上真实的 nginx conf 不在仓库里,deploy/ 下只有两个 *.example.conf 模板(API 反代 + admin 子域名)。这是配置未版本化的问题。 第二段 Node 应用:pm2 里一个叫 shatangai-backend 的进程,Node 22 + Express 5 + TypeScript,监听 3001。它是单实例、没有 cluster,而且没有构建产物——启动命令是 node ./node_modules/tsx/dist/cli.mjs index.ts,运行时由 tsx 直接把 TS 跑起来。 第三段数据:PostgreSQL 17 和 Redis 7 都在 docker 容器里,宿主端口分别是 5511 和 6511。 第四段同机并存:这台机器上还有一套测试环境在 /opt/shatang_test,pm2 名 shatang-test、端口 3002,跟生产用的是同一个发布脚本——脚本靠"在哪个目录跑"来判断该重启谁。

【备注讲解】 "四段"这个讲法值得记住,因为它把责任边界划清楚了:nginx 负责 TLS/静态/转发,Node 负责业务,容器负责有状态服务,pm2 负责进程守护。面试官追问"哪一段最容易出问题",答案是第二段和第四段——第二段因为单实例 + tsx 无产物,启动很慢;第四段因为生产和测试同机,是"发布打错目标"这类事故的温床(见 Q6)。 另外一定要主动说 nginx conf 不在仓库——部署配置不进版本控制 = 部署不可复现,这是基础设施工程化的第一条红线。

【代码依据】 deploy/nginx-api-proxy.example.conf:7-22(upstream + 保留 /api 前缀 + 900s 超时)、deploy/nginx-admin-subdomain.example.conf:10-33、backend/package.json 的 "start": "node ./node_modules/tsx/dist/cli.mjs index.ts"、backend/index.ts:200-215(startHttpServer)、scripts/deploy.sh:11-14(生产/测试目录与进程名)、scripts/deploy.sh:43(测试端口 3002)。


Q2. 你的 Docker Compose 是怎么"一键部署"的? ​

【考点】 这题是陷阱题。简历写了"Docker Compose 一键部署",面试官要么看过代码,要么会让你往下讲细节——讲不出应用容器就露馅。必须主动拆掉。

【口述答案】 这里我要先修正一下我简历的措辞,因为它不准确。 代码里的真相是:compose.yml 只起两个服务——postgres:17 和 redis:7,也就是只容器化了两个基础设施。里面没有 app service,整个仓库也没有一个 Dockerfile。Node 应用是 pm2 直接管宿主进程,nginx 也是宿主的。 而且这个 compose 文件主要是给本地开发/初始化用的,它的默认值跟生产对不上:默认数据库是 app、默认端口 5432、默认密码 postgres123;而生产库是 shatang_ai、宿主端口 5511,密码从 .env 来。Redis 默认密码 redis123,生产也是另一套。 所以正确的说法是:"我做了基础设施容器化 + 应用侧进程守护(pm2)与反向代理(nginx)的部署方案;compose 负责让 PG17 和 Redis7 一条命令起来并带健康检查和 AOF 持久化,应用层用 pm2 管理进程生命周期、用 nginx 做 TLS 与转发。" 我为什么当初没做成全栈 compose?两个真实原因:一是应用侧要跑 git pull + 迁移 + 前端构建,这套流程在容器里做反而更绕;二是后端是 tsx 直跑 TS、依赖宿主上的 ffmpeg/ffprobe,塞进容器要额外处理二进制依赖。但我不认为这是好理由——全栈 compose 或者 K8s 能拿到"构建产物不可变 + 环境一致",我们现在的代价是宿主机状态无法复现。

【备注讲解】 这题的正确应答结构是 "先纠正 → 再给真实架构 → 再给修正后的口径 → 最后解释取舍"。千万不要硬撑"compose 就是我的部署方案",因为你一旦被追问"application service 在哪个 yaml 里",就会全线崩。主动纠正简历不扣分,被拆穿才扣分。 一个可以加分的延伸:compose 里 Redis 的配置其实做得很细(appendonly yes + appendfsync everysec + AOF 重写阈值 + 三档 RDB save 规则),但 PostgreSQL 用的是镜像默认配置,没有任何 postgresql.conf 挂载。同一个文件里,一个有状态服务被认真调过、另一个没有——这正好说明"调优是被人记得才做的,不是有流程保证的"。

【代码依据】 compose.yml:6-62(全文只有 postgres 与 redis 两个 service)、compose.yml:13-14,17(postgres123 / app / 5432 默认值)、compose.yml:33-42(Redis AOF + RDB 调优)、仓库内无 Dockerfile(find . -iname "Dockerfile*" 无结果)、backend/scripts/verify-grant-concurrency.ts:18-19(PG_PORT=5511 即生产库的环境护栏)、backend/config/index.ts:653(生产 Redis 6511 / 测试 6379)。


Q3. 为什么生产是 tsx 直接跑 TS,不用编译产物?node dist/index.js 呢? ​

【考点】 判断你有没有意识到"运行时形态"是一个需要决策的工程选择,而不是随手写的 npm script。

【口述答案】现状:backend/package.json 的 start 就是 node ./node_modules/tsx/dist/cli.mjs index.ts,没有 build 脚本,没有 dist/ 目录,node dist/index.js 从来没有落地过。仓库里唯一出现这条命令的地方是 CHORE.md:310 的一段"如果以后要做 TS 迁移"的规划草稿。 为什么当初这么选:这套后端是从 CommonJS 的 JS 项目原地迁到 TS 的,用的是 tsx 直跑,好处是零构建、改完重启就行、不会有"源码和产物不一致"。对一个单实例、自己发版节奏的小团队来说,这个取舍当时是划算的。 代价我也清楚,有三条: 第一,启动慢。tsx 在启动时要 JIT 编译整个 TS 依赖图,而我们的启动路径还特别长——先查 ffmpeg/ffprobe、再跑数据库迁移、再连 Redis、再起 9 个 Worker、再做视频编辑恢复,最后才 server.listen(3001)。端口没开之前上游全是 502。 第二,没有"构建产物不可变"这层保障。TS 类型错误在运行时不会拦你,tsc --noEmit 只是个可选的 npm script,不在发布流程里。 第三,类型检查形同虚设。前后端都有 typecheck 脚本,但没有任何一道门禁强制它跑过(见 Q21)。 怎么补:把后端改成 tsc 构建到 dist/、pm2 跑 node dist/index.js,并且在发布脚本的第 5 步(构建)里加上 tsc --noEmit——这样"类型错误"和"启动慢"一起解决,同时拿到不可变产物。

【备注讲解】 这里有个可以展开的抽象:"零构建"是一个用启动时间换开发速度的交易,它在单实例、低发布频率下是理性的,但它同时放弃了三样东西——类型检查的执行点、产物的不可变性、启动时间的可预测性。 当系统从"一个人改代码"变成"要发布给真实用户"时,这笔交易就该重谈。 另外一个真实的技术细节值得点出来:startHttpServer() 被放在 startup() 的最后一行(backend/index.ts:358),也就是说迁移没跑完,端口就不开。这是一个刻意的正确选择(避免"新代码 + 旧 schema"),但它把"启动耗时"直接放大成了"停机窗口"。

【代码依据】 backend/package.json(start/dev/migrate:* 全部走 tsx/dist/cli.mjs,无 build)、CHORE.md:302-312(未落地的 node dist/index.js 规划)、backend/index.ts:236-243(启动先查媒体工具、再跑迁移)、backend/index.ts:358(startHttpServer() 在最后)、backend/index.ts:200-202(server.listen)。


Q4. 生产和测试跑在同一台机器上,你怎么保证不互相干扰? ​

【考点】 多环境隔离意识。这题答得好非常加分,因为同机双环境是"小团队真实约束"下的典型决策。

【口述答案】 资源上是物理隔离的:

  • 目录:生产 /opt/shatangAI,测试 /opt/shatang_test;
  • 进程:pm2 名分别是 shatangai-backend 和 shatang-test;
  • 端口:生产 3001,测试 3002;
  • 数据库:都连同一台 PG 实例但靠宿主端口区分,生产是 5511;
  • Redis:生产 6511、测试 6379。

这里有个很关键的细节:我们的分布式 key 里必须带环境段。Redis 的 key 前缀、腾讯云并发槽的 key,都带了环境标识,代码注释写得很直白——"测试服和生产如果共用 key,测试环境跑批就能把生产的槽吃光",而且"认端口不认 host,因为三边 host 都是 shatang.top"。这是一个很典型的坑:用域名区分环境在开发机上看着对,上线后发现所有环境域名都一样。 另外我们有一层脚本级护栏:一批会写数据、删数据的验证脚本在开头就判断 PG_PORT === '5511' 就拒绝执行,直接报"检测到生产库,拒绝执行"。比如 verify-grant-concurrency.ts 会 DELETE FROM users WHERE username LIKE 'conctest%',这种脚本要是误连生产就是灾难。

【备注讲解】 这题的核心抽象是:环境隔离有三层——隔离资源(目录/端口/进程)、隔离数据(库/Redis key 空间)、隔离操作(脚本护栏)。只做前两层、不做第三层,就是"人肉记住别在生产跑这个脚本",迟早会出事。 值得主动指出的不足:生产库和测试库其实在同一个 PostgreSQL 实例里(同一个 postgres 容器,不同端口映射到不同实例或不同库),没有独立实例。这意味着测试库跑一个重查询能拖慢生产库的 IO,一个 max_connections 打满两边一起挂。真正的隔离应该是独立实例或至少 cgroup 限额。

【代码依据】 scripts/deploy.sh:11-14,39-47(生产/测试目录与进程名推断)、scripts/deploy.sh:43(测试端口 3002)、backend/config/index.ts:646-663(并发槽 key 必须带环境段 + "认端口不认 host")、backend/scripts/verify-grant-concurrency.ts:7-12 与 backend/scripts/verify-sales-rep-quota.ts:21-25(5511 环境护栏)、backend/services/tencent/slotLease.ts:40-49。


二、发布与回滚 ​

Q5. scripts/deploy.sh 的核心价值是什么?为什么要专门写个脚本? ​

【考点】 你有没有把"发布"当一件工程问题来对待。这题是整份材料里最能体现"运维意识"的一题。

【口述答案】 脚本开头第一段注释就写着它的存在理由:"核心职责不是少敲几行命令,是不让失败静默通过**。"** 起因是一次真实事故:git pull 因为服务器上有个未跟踪文件挡路而失败,但后面的 migrate、build、restart 照跑不误,结果是"更新了但没生效"——用户看到的还是旧代码,而所有步骤都打了绿。这种故障排查成本远高于发布本身。 所以脚本是 set -euo pipefail + 七步串联,而且每一步都"防着自己骗自己":

  1. 拉取前体检:先算出 BEFORE..TARGET 的区间,检查这次要被更新的文件里有没有未跟踪的本地文件(会是 git pull abort 的直接原因),以及有没有脏文件挡路——只查交集,因为工作区长期存在的无关改动不该拦发布。挡路就停下并提示"挪到 /root 备份,别直接 rm"。
  2. git pull --ff-only + HEAD 断言:拉完立刻断言 HEAD === origin/<branch>,不等就 die——"代码没更新,后面的步骤全部作废"。这是最重要的一道闸。
  3. 依赖变更门控:只在 package.json/lockfile 变了才装,而且默认拒绝自动装,要求人工确认后带 DEPS=1 重跑。理由是安装在某些情况下会删掉整个 node_modules 重装,非交互下会直接报错退出,"装没装上得人眼确认"。装的时候统一 --frozen-lockfile——注释里写了 2026-08-21 的白屏事故:一次"顺手加个 vitest"的安装把 vite 从 8.0.5 升到 8.0.10,那个版本产物模块顺序坏了、页面白屏。发布机上安装依赖只该照单执行,不该做任何决策。
  4. 迁移 diff 门控:只有区间里新增了 .up.sql 才跑 migrate:up,并且提醒确认备份。
  5. 构建 diff 门控:前端/src/vite.config.ts 变了才 vite build;admin 变了才构建 admin。因为 vite 会先清空 dist,Nginx 正读这个目录,没改到就别构——避免几十秒 404 窗口。
  6. 重启:只有 backend/shared 变了才重启,且只重启认出来的那一个进程。注释特别强调"别跑 pm2 update,会打死含生产在内的全部进程"。
  7. 探活:curl admin 接口、期望 401(401 说明"路由活着且鉴权生效"),20 次 × 3 秒,拿不到就 die 并提示去看 pm2 logs。

【备注讲解】这七步的设计哲学可以总结成一句话:每一步的失败都必须变成"停下来",而不是"继续往下"。 面试官如果问"你这个脚本最值钱的地方是什么",答案就是第 2 步的 HEAD 断言和第 7 步的探活——一个管"代码真的更新了",一个管"服务真的起来了"。中间的 diff 门控是"只做必要的事"。 还有两个设计细节很值得说:

  • FROM=<sha> 参数:用来处理"上次跑到一半停了"。比如第 3 步要求人工确认 DEPS=1 而那时第 2 步已经 pull 成功了——直接重跑会撞上"无需发布,exit 0",留下"代码拉了但没生效"的半更新状态。用户显式给起点,后面所有"这次改了什么"的判断就都按真实区间算,而且重跑幂等(install 幂等、migrate:up 只跑未应用的、build/restart 本就可重复)。
  • 探活期望 401 而不是 200:这是一个很聪明的选择。200 需要构造合法凭据,而 401 恰好证明了"路由注册成功 + 鉴权中间件生效"两件事。

【代码依据】 scripts/deploy.sh:1-17(存在理由 + set -euo pipefail)、scripts/deploy.sh:77-121(体检 + 未跟踪/脏文件检查)、scripts/deploy.sh:123-129(HEAD 断言)、scripts/deploy.sh:131-151(依赖门控 + 白屏事故注释)、scripts/deploy.sh:153-162(迁移门控)、scripts/deploy.sh:164-172(构建门控 + dist 清空窗口)、scripts/deploy.sh:174-181(只重启一个进程)、scripts/deploy.sh:183-196(401 探活)、scripts/deploy.sh:83-96(FROM 的用途)。


Q6. 这个发布脚本你踩过什么坑? ​

【考点】 判断你是"真的运维过"还是"写了个脚本就交差"。只有真跑过的人才有这两个坑。

【口述答案】 两个坑,都很典型。 坑一:硬编码 pm2 进程名,导致"全绿但什么都没更新"。 脚本原来写死了 pm2 restart shatangai-backend。问题是这台机器上生产和测试并存,我在测试目录跑这个脚本时,它去重启了生产,而测试根本没重启;更糟的是第 7 步探活打的是生产端口 3001——生产当然活着,于是脚本一路全绿退出,而我以为测试更新好了。 修法是认目录、不认名字:脚本先取 pwd -P,然后 pm2 jlist 找出**pm_cwd 正好等于本仓库目录(或它的 backend/)的唯一进程**;端口则从 backend/.env 里读 PORT。关键是认不出来就停下,绝不猜——报错信息里还提示"用 PM2_APP=<进程名> 显式指定后重跑,并先用 pm2 describe 确认它的 exec cwd 就在这个目录下"。 坑二:非交互 SSH 下 PATH 里的 Node 是 v20,pnpm 起不来。 我们的 packageManager 钉的是 pnpm@11.1.3,它要求 Node ≥ 22。而非交互式 SSH 的 PATH 里是 /usr/bin/node v20,于是 pnpm 以 ERR_UNKNOWN_BUILTIN_MODULE 崩掉。最坑的地方是交互式登录有 nvm、人手敲没事,脚本里一跑就挂——这种"人和脚本环境不一样"的问题排查起来很折磨。 修法是脚本开头主动检测 Node 主版本,< 22 就去 ~/.nvm/versions/node/v2[2-9]*/bin 里找最新的一个塞进 PATH,找不到就 die 并说明原因。另外还做了一层兜底:pnpm 不在 PATH 就退到 corepack pnpm,两个都没有才报错。

【备注讲解】 这两个坑的抽象价值完全不同,但都很高:

  • 坑一的教训是"标识符不要硬编码,要从实际状态推断,推断不出来就失败"。这和 Q2 里"compose 默认值和生产不一致"是同一类问题——任何靠人手保持同步的两份配置,迟早会不同步。
  • 坑二的教训是"脚本的执行环境和你交互式的环境不是同一个"。CI、cron、非交互 SSH,这三处都是 PATH/环境变量最容易被忽略的地方。一个可推广的做法是:脚本开头先把关键工具版本断言一遍再干活,别等到第 5 步才炸。 主动交底一句:这两个坑的共同点是"脚本成功退出但结果不对"——比脚本报错危险得多。原因就是我们一开始写的脚本只检查"命令有没有执行",不检查"结果对不对"。现在有了 HEAD 断言和 401 探活,才补上了这一层。

【代码依据】 scripts/deploy.sh:39-47(硬编码进程名的事故注释)、scripts/deploy.sh:50-75(detect_pm2_app + 端口推断 + 认不出就 die)、scripts/deploy.sh:26-37(Node 22 检测 + nvm 兜底 + corepack)。


Q7. 发布失败怎么回滚?发布过程中有停机窗口吗? ​

【考点】 字节必问。这是基础设施里最容易被追问穿透的地方。准备好主动交底,不要等被问。

【口述答案】 先说实话:这个脚本没有回滚。它只有前滚(forward-only)。我要把这条欠账拆清楚,因为它其实是三件不同的事。 第一,没有回滚动作。 脚本跑完 git pull 就开始改状态了,一旦第 5 步构建失败或第 7 步探活失败,脚本会 die 退出,但代码已经拉下来了、迁移已经跑了。人工恢复的方式是"回滚 git 到上一个 sha + 重启",而 migrate:down 需要手动敲、而且要注意它只回滚最近一次。我不会说"我们有回滚",我们有的是"人工前滚或人工回退"。第二,发布不是原子的,有真实的 404 窗口。 前端这一段最明显:vite 构建会先清空 dist,而 Nginx 正在读这个目录,于是有一次构建就有一段几十秒的 404 窗口。我的缓解办法是diff 门控——前端没改就完全跳过构建,把窗口的出现频率降到最低。但窗口本身还在。 第三,后端重启有 502 窗口,而且是"停再起"。 pm2 restart 的语义是先停、再起,没有"先起后停"。我们的启动路径又特别长(tsx 编译 + 迁移 + 连 Redis + 起 9 个 Worker + 恢复),而且 server.listen() 放在最后。我在发布机上手工计时过,实测大约有 70 秒的 502 窗口。要说明的是这个数字是我自己掐表观察的,不是从代码推导出来的;但机制很清楚:端口没开之前,上游每一个请求都是 502。怎么补:① 后端先做成 tsc 构建产物,启动时间立刻降一个量级(Q3);② 用 pm2 的 --wait-ready 或者干脆开两个端口做蓝绿/滚动,nginx upstream 切权重,先起后停,窗口可以从几十秒压到 0;③ 前端改成构建到带时间戳的目录再原子切软链,替代"清空 dist";④ 迁移做向前兼容的加法,让"代码回滚"不需要"迁移回滚"。

【备注讲解】 这道题的正确答案不是"我们有回滚机制",而是**"我清楚这三条欠账,也清楚每一条的最小修法"。 值得展开的抽象是:备份和回滚是"能力",不是"流程"。 你写一句"发布失败就回滚"在文档里,那不是能力;你得有一个能在 60 秒内跑完、并且被演练过的命令,那才叫能力。我们现在的状态是"流程上有印象,能力上没有"。 还有一个很值得说的细节:backend/index.ts 里其实有优雅关闭**——收到 SIGTERM/SIGINT 会 server.close(),然后等 Worker 跑完当前 job,硬超时 5 分钟,再关 Redis 和 PG 池。设计意图是好的(不打断在途任务),但它和 pm2 restart 的默认 kill_timeout 是错配的:pm2 等不了 5 分钟。所以"优雅关闭"在真实重启时基本走不到最后。这是一个典型的"两处配置各写各的"问题。

【代码依据】 scripts/deploy.sh:164-172(vite 清空 dist 的 404 窗口注释)、scripts/deploy.sh:176-181(pm2 restart = 停再起)、backend/index.ts:362-395(shutdown():server.close() + Worker 5 分钟硬超时)、backend/index.ts:397-408(SIGTERM/SIGINT 处理)、backend/index.ts:358(listen 在启动流程最后)。全脚本无任何 git reset/rollback 分支。


三、配置与密钥 ​

Q8. 你的配置体系是怎么组织的? ​

【考点】 配置治理能力。这题答得好能顺势带出"零校验"的交底。

【口述答案】 配置是单点收口的:所有环境变量都集中读在 backend/config/index.ts,181 个 env 键收敛成一个大对象导出,业务代码不允许直接读 process.env。 为什么这么做?因为在这个重构之前,process.env.XXX 散落在各个 service 里,改一个变量名要全仓搜,而且没人知道某个变量到底有没有被用过。收口之后至少有三个好处:能看见全貌、能统一给默认值、能集中做派生。 但是——我必须有话直说,这个收口是"形式上的收口,不是质量上的收口",两个数很能说明问题:

  • config/index.ts 里读了 181 个 env 键;而整个后端代码里出现的 process.env 唯一键是 291 个。也就是说还有 100 多个 env 是绕过 config 直接读的,收口没有收干净。而且 backend/services/db.ts 就是最典型的例子之一:连接池那一段直接 process.env.PG_HOST、process.env.POOL_MAX 一路读下来,而 config/index.ts 的文件头第一行注释恰恰写着"统一配置收口:所有 process.env 读取集中于此文件"。注释和代码当场矛盾。
  • backend/.env.example(503 行,含大量注释)里只声明了 127 个键。而整个后端引用的 291 个 env 键里,167 个(约 57%)在 .env.example 里没有声明;只算 config 收口的 181 个,也有 76 个(42%)没有文档。这意味着新同事要配一个新环境,只能去翻代码。

结构上,导出的对象有 33 个顶层键,其中 25 个是分组对象(oss/db/redis/tencentVod/wxpay/alipay…),8 个是标量(port/nodeEnv/secretKey…)。

【备注讲解】"配置项没有文档"是一个可以拿来量化自己欠账的好例子(57% 未声明、只看 config 也有 42%),比说"文档不完善"有力得多。它背后的原则是:配置的对外契约不是 config 对象,而是 .env.example。 一个配置项如果在 .env.example 里没有出现,那它对部署者来说就不存在,只能靠读代码猜。这就是为什么"能跑起来"和"能被别人部署起来"是两件事。 关于"291 vs 181"这个差距,还有一个更深的观察:直接的 process.env 读取往往出现在最关键的路径上(数据库连接池、Redis 客户端、中间件的鉴权开关),因为它们是"基础设施代码",写得比业务代码早。收口改造总是从业务层开始,最后留在基础层的那些恰恰是最要紧的。 这句话说出来很有分量。

【代码依据】 backend/config/index.ts:1("统一配置收口"的注释)、backend/config/index.ts:411-829(导出对象,33 个顶层键)、backend/services/db.ts:26-40(连接池直读 process.env,与注释矛盾)、backend/.env.example(127 个 KEY= 声明)、backend/services/redis.ts:17-29(Redis 客户端同样直读)。


Q9. 派生配置和开关风格上有什么设计?.env.local 的加载顺序呢? ​

【考点】 细节判断题。答得出说明你真读过自己的配置文件,而不是只知道"配了环境变量"。

【口述答案】 三件事。 第一,派生值用 IIFE 现算,而不是让运维手填两个变量。 配置里有 7 处 IIFE 派生。最典型的是腾讯云"免审账号"那一段:enabled 不是单独一个开关 env,而是由"三个必填项是否齐全"推导出来的——代码注释写得很清楚:"单独开关会出现'开了开关但没填密钥'的半配置态"。tencentSlot.env 也是一个派生值,而且这里有个真实的坑:它先看显式的 TENCENT_SLOT_ENV,没有才回落到 NODE_ENV,注释里明确写了**"别图省事写成 String(a || b ? 'prod' : 'dev')——那个三元的优先级会让显式设的 test 也解析成 prod"。这属于踩过优先级坑之后留下的注释。 第二,布尔开关有两套风格混用。 全文件里有 6 处 === 'true' 和 4 处 !== 'false'。这两个的默认值语义正好相反**:=== 'true' 是"默认关",!== 'false' 是"默认开"。混用的后果是——运维看到 QWEN_IMAGE_PROMPT_EXTEND=false 能关掉,就以为所有开关都是这个写法,于是对某个 === 'true' 的开关写了 =false(没生效,因为本来就是关的)或者干脆没写(以为默认开,其实默认关)。这不是审美问题,这是"猜错成本由运维承担"的问题。第三,.env.local 会在非 production 下覆盖 .env。config/index.ts 开头:先 dotenv.config('.env'),然后如果 NODE_ENV 不是 production,再 dotenv.config('.env.local', { override: true })。而全仓库搜不到任何一处 NODE_ENV=production 字样——连 backend/.env.example:5 写的都是 NODE_ENV=development。 这就意味着:生产的 NODE_ENV 到底设成什么,仓库里没有任何证据。如果服务器上没设,那生产就是 development 模式,于是 .env.local 会覆盖 .env,而且所有 nodeEnv !== 'production' 的分支都按开发环境走。这个后果不只是配置覆盖——wxpayClient.ts 的验签失败分支是 return config.nodeEnv !== 'production',在非 production 下验签失败会返回 true。所以"生产环境忘了设 NODE_ENV"会直接变成"微信支付回调验签可被绕过"。这是我必须主动讲的一条。

【备注讲解】 三条的抽象价值从低到高:

  • IIFE 派生体现的是"消灭半配置态"——不要让运维有机会填出一个逻辑上不可能的配置组合。这是很好的设计,可以推广:能推导的配置就不要让用户填。
  • 开关风格混用体现的是"默认值语义必须是显式契约"。更好的做法是用一个统一解析函数 + 明确的默认值声明(boolEnv('X', { default: false }))。
  • .env.local + NODE_ENV 缺失是最严重的一条,因为它把一个配置项变成了一个安全开关。正确的做法是:安全相关的分支不应该依赖 NODE_ENV 这种"约定式"变量,而应该依赖一个必须显式设置的、启动时校验的变量。 而且启动时应该 fail-fast——发现 NODE_ENV 不是期望值就拒绝启动,而不是默默降级。

【代码依据】 backend/config/index.ts:6-9(.env → 非 production 时 .env.local override)、backend/config/index.ts:413(nodeEnv 默认 'development')、backend/config/index.ts:561-578(tencentVodNoAudit 的 enabled 由必填项推导)、backend/config/index.ts:655-661(tencentSlot.env IIFE + 优先级坑注释)、backend/.env.example:5(NODE_ENV=development)、backend/services/payment/wxpayClient.ts:113,127(验签失败时按 nodeEnv !== 'production' 放行)、全仓无 NODE_ENV=production 字面量。


Q10. 你的密钥和默认账号是怎么管的?(主动交底) ​

【考点】 这是安全题的必考项。 面试官其实知道小项目一定有硬编码,他看的是你知不知道、敢不敢说、有没有改进路径。

【口述答案】 这块我主动交底,讲四条,每条我都说清"当初为什么这么写"和"现在怎么补"。 第一条,硬编码的默认账号。 userCreditsStore.ts 里有一张 ACCOUNT_RULES 表,写死了 admin / shatangAI,shatangvip1 到 shatangvip5 / shatangNB666,另外还有 st0001~st0005(密码就是用户名)。更麻烦的是 seedDefaultDbUsers() 被 findUserRecord() 和 authenticateUser() 两处调用,也就是每次登录都会补种一次——你把 admin 删了,下次谁来登录一下就种回来了。 为什么当初这么写:这是早期的演示/内测账号,走的是"本地开关账号"的路径。ACCOUNT_RULES 上方还有一段 IIFE:非 production 且没设 DISABLE_LOCAL_TEST_ACCOUNT 时会再加一个 test/test 的无限制账号。当时想着"生产有 NODE_ENV 挡着",但结合 Q9,NODE_ENV 在生产到底是不是 production 是没有证据的,所以这个"挡着"是不可靠的。 怎么补:① 把默认账号从代码里删掉,改成从 env 读或者在首次部署时由运维显式创建;② seedDefaultDbUsers 至少要从登录路径上摘掉(这是每次登录一次 SELECT + 最多 11 次 bcrypt.hash 的开销,性能和安全的双重问题);③ 已有账号要强制改密。 第二条,JWT 密钥有硬编码兜底默认值。 有三处声明:services/auth/userToken.ts:5、middleware/adminAuth.ts:4,两处都是 process.env.SECRET_KEY || 'shatangai-secret-key-change-in-production';config/index.ts:414 还有第三个默认值 'change_me_to_a_long_random_string'。因为 userToken 和 adminAuth 是各自读 process.env,所以它们实际上共用同一个值——但这是"巧合共用",不是"设计共用":config 里那个 secretKey 默认值跟它们不是同一个字符串,一旦哪天有人只改了 config、让两者从 config 取值,就立刻变成"签发的 token 验不过"。启动时没有任何强度校验,用默认值也能正常起服务。 怎么补:启动时断言 SECRET_KEY 存在、长度 ≥ 32、且不等于任何一个已知默认值,否则 process.exit(1)。这是典型的 fail-fast 场景。 第三条,compose 的默认凭据和端口绑定。 compose.yml 默认 postgres123 / redis123,而且端口映射是 ${PG_PORT:-5432}:5432——绑在 0.0.0.0。本地开发无所谓,但如果有人把这个文件直接拿去服务器用,就等于把 PG 和 Redis 暴露在公网上。正确写法是默认绑 127.0.0.1:${PG_PORT}:5432。 第四条,我们有密钥扫描脚本,但它没被接进任何门禁,而且有盲区。 scripts/security/check-secrets.cjs 只有 5 条规则,是个 npm script(security:keys),没接 CI、没接 pre-push hook(core.hooksPath 没设,.git/hooks 下全是 .sample)。更关键的是它的规则盲区:那条"硬编码凭据"规则要求key 名后紧跟 : 或 = 再跟字面量,而本项目最常见的写法 process.env.X || '字面量' 恰好不匹配——我实测过,secretKey: process.env.SECRET_KEY || 'change_me...' 是 MISS。 还有个反直觉的事实:这个脚本现在跑起来是失败的(exit 1),报了 33 条,大部分在 __tests__ 和 migrate-json-to-pg.ts 里。所以"把它接进 CI"不是一个免费的改动——它会立刻变红,得先清存量、先把 __tests__ 排除掉、再补上 || '字面量' 的规则。

【备注讲解】 这四条的组织方式很重要:每条都是"事实 → 当初的原因 → 怎么补",而不是"我们没有这个问题"。 几个可以展开的原则:

  • "硬编码默认值 + 启动不校验" = 一个可以被静默继承的漏洞。 默认值本身不是问题,问题是默认值能用。正确做法是:生产必需的密钥没有默认值,缺失就启动失败。
  • "每次登录补种账号"是"幂等"被用错了地方。 幂等的目的是"重复执行不出错",不是"把删掉的东西复活"。用户删除是一个意图,自动补种把这个意图覆盖掉了。这类 bug 的通用名字叫"自愈逻辑覆盖了用户的显式意图"。
  • 扫描脚本的盲区比它没接 CI 更值得讲,因为它说明"我们有一个安全工具"这件事本身可能给人虚假的安全感。一个会漏报的检查比没有检查更危险,因为你不再人工看那一块了。

【代码依据】 backend/services/user/userCreditsStore.ts:17-25(st0001~admin~shatangvip1-5 硬编码)、backend/services/user/userCreditsStore.ts:27-36(非 production 追加 test/test)、backend/services/user/userCreditsStore.ts:81-107(seedDefaultDbUsers)、userCreditsStore.ts:117 与 :142(登录路径调用)、backend/services/auth/userToken.ts:2,5、backend/middleware/adminAuth.ts:4、backend/config/index.ts:414、compose.yml:13,17,35,44、scripts/security/check-secrets.cjs:11-20、无 core.hooksPath(git config core.hooksPath 为空、.git/hooks/ 仅 .sample)。


四、存储与文件生命周期 ​

Q11. 你的存储抽象层支持几种后端? ​

【考点】 这是简历措辞的第二个风险点("多环境配置:本地磁盘 vs 阿里云 OSS")。有人会顺着问"你们是不是接了好几家云存储",答错就露馅。

【口述答案】真正实现的后端只有两个:阿里云 OSS(用 ali-oss)和本地磁盘。 这里我要澄清一个容易误判的地方:仓库里确实还能看到 @volcengine/tos-sdk 和 cos-nodejs-sdk-v5 这两个 SDK,但它们只是点状使用的签名助手,不是存储后端——tos-sdk 只出现在 services/volcVod/tos.ts,cos-sdk-v5 只出现在 services/tencent/mpsSubtitleErase.ts,都是在调那两家的具体服务时需要构造请求头/签名。所以"支持四家存储"是不成立的。抽象的形态也要说清楚:services/storage/index.ts 是个鸭子类型的转发层——它不导出 TS interface,而是 let adapter: Record<string, (...args: unknown[]) => unknown>,然后把 uploadFile/putBuffer/deleteFile/generateSignedUrl 等 9 个方法逐个转发给选中的 adapter。判断逻辑在 getAdapter():useRealOss 为真就 require('./ossAdapter'),但如果 adapter.isRealOssEnabled() 为假,就打印一行日志然后换成 localAdapter。 这就是我要交底的最危险的地方:静默降级。 只要 OSS_REGION/OSS_BUCKET_NAME/OSS_ACCESS_KEY_ID/OSS_ACCESS_KEY_SECRET 里任何一个配错或为空,initClient() 返回 null,isRealOssEnabled() 就是 false,整个应用就悄悄改用本地磁盘。表现是什么?服务照常启动、日志里只有一行 [Storage] OSS 客户端不可用,回退到本地磁盘模式;用户的视频生成成功、库里存的是本地磁盘路径;然后第三方(腾讯云 VOD、DashScope)来拉素材时拿到的是一个它们访问不到的地址,直接失败。排查的时候你会去看腾讯云、去看网络,因为"存储"看起来是好的。怎么补:这是最典型的 fail-fast 场景。生产环境如果 USE_REAL_OSS=true 而 OSS 客户端初始化失败,应该在启动时直接退出,而不是降级。至少也要在 /api/health 里暴露成 degraded。现在的写法等于"用可用性换正确性",而且是偷偷换的。 另外两个细节:对外 URL 是硬编码拼的——https://${bucket}.${region}.aliyuncs.com/${objectKey},在 ossAdapter.buildPublicUrl() 和 routes/video.ts 的直传签名里各写了一遍,没有 CDN 配置。也就是说所有素材和成片都走 OSS 源站带宽,没有任何加速和回源策略。

【备注讲解】"静默降级为什么危险"值得单独展开,这是一个很好的抽象考点: 区分两种失败——fail-fast(立刻可见的失败) 和 silent degradation(静默降级)。降级本身不是坏事(比如缓存挂了走 DB),但降级有两条铁律:① 只允许降级到语义等价的实现;② 降级必须被观测到。 这里的降级违反了第一条——本地磁盘和 OSS 对下游第三方根本不是等价的(前者外部不可达),所以这不是"降级",是"换了语义"。而且它也没做到第二条(只有一行 console.log,进的是 1.8GB 不轮转的 pm2 日志,见 Q19)。 还有一条:"鸭子类型"在这里是技术债不是设计。 用 Record<string, Function> 转发,代价是编译器完全帮不上忙——localAdapter 少实现一个方法、或者签名不一致,都要等到运行时才发现。这个抽象层总共 41 行,写一个 interface StorageAdapter 的成本极低,收益是把这类错误从运行时挪到编译期。

【代码依据】 backend/services/storage/index.ts:7-26(鸭子类型转发 + 静默降级)、backend/services/storage/ossAdapter.ts:28-66(initClient 缺配置只 console.warn)、ossAdapter.ts:68-71(buildPublicUrl 硬编码域名)、ossAdapter.ts:399、backend/services/volcVod/tos.ts 与 backend/services/tencent/mpsSubtitleErase.ts(两个 SDK 的点状使用,全仓仅此两处)、backend/routes/video.ts:711,7935,8360(调用方按 isRealOssEnabled() 分支)、backend/config/index.ts:418,422-431。


Q12. /api/video/upload-signature 这条直传路由,你评估过它的安全风险吗? ​

【考点】 一个很具体的攻击面题。能自己主动指出"这条路由没鉴权"是高水平的信号。

【口述答案】 我评估过,风险是真实存在的,讲三点。 第一,它发的是长期 AccessKey,不是 STS 临时凭证。 路由用 config.oss 的 accessKeyId/accessKeySecret 算一个 POST policy 的 HMAC-SHA1 签名,有效期 1 小时、单文件上限 2GB,然后在 formFields 里把 OSSAccessKeyId 原样回传给前端。这是 OSS 表单直传的标准做法,但它意味着长期密钥的 ID 暴露在浏览器里。虽然光有 KeyId 没有 Secret 签不出新请求(这是直传模式安全的前提),但任何一次 Secret 泄露都会直接变成"全桶可写"。正确做法是走 STS AssumeRole 发临时凭证(带最小权限策略和过期时间),这样即使凭证泄露也受时间和权限约束。 第二,这条路由没有鉴权。 userResolverMiddleware 挂在全局,但它的职责是"解析身份,从不拒绝"——它只是把 X-Login-User/Bearer 解析成 req.currentUser,然后 next(),没有任何 401 分支。所以 upload-signature 是匿名可调用的:任何人都能拿到一个 1 小时有效、2GB 上限的直传签名。路由里唯一的"身份"动作是把 req.currentUser?.userId 记进 reserveTempUpload(objectKey, userId)——而 userId 允许为 null。也就是说匿名上传是设计上放开的。它的实际影响被两个东西限制住了:对象 key 是服务端生成的(uploads/direct/<ts>-<rand>-<basename>,前缀固定、不可遍历),以及有 7 天 TTL 的临时追踪 + 每小时清理。但匿名者依然可以拿它当免费图床,而且桶是 public-read 的。 第三,桶是 public-read 的。 这一点和 Q13 的生命周期设计耦合在一起——所以我们对"删除"这件事格外小心,因为文件一旦上传就是公开可读的。 怎么补:① 这条路由加上必须登录的守卫(req.currentUser 为空就 401);② AK 换 STS;③ 桶从 public-read 改成私有 + 签名 URL 读取(或者用 CDN 回源 + referer/签名防盗链);④ 给签名接口加按用户的限流。

【备注讲解】 这题最重要的是第一句话:"我评估过,风险是真实存在的"——先给判断,再给拆解。 值得点出的抽象:"解析身份"和"校验身份"是两个完全不同的中间件职责,把它们合在一个中间件里是很常见的错误。 我们那个 userResolverMiddleware 覆盖了用户端所有路由(包括 /upload-signature),它看起来像一道"鉴权墙",其实是一道"身份注入器"。看到 app.use(authMiddleware) 就以为全站鉴权了,是最容易骗过自己的错觉之一。 正确的做法是把"解析"和"要求登录"拆成两个中间件,默认要求登录,白名单显式列出免登录路由——默认安全,例外显式。 另外一句可以加分的:直传模式(前端直接传 OSS)本身是正确决策——它绕开了服务端中转大文件的带宽和内存问题,这也是我们给 multer 中转路径也做了 2GB/文件大小限制的原因。不要因为有安全问题就否定设计模式,要分清楚"模式选对了、实现没加固"。

【代码依据】 backend/routes/video.ts:1115-1166(签名路由全文:无 auth 守卫、HMAC-SHA1、1h、2GB、回传 OSSAccessKeyId)、backend/routes/video.ts:1151-1152(host/publicUrl 硬编码)、backend/middleware/userResolver.ts:104-131(只解析、只 next()、无 401)、backend/index.ts:53(全局挂载)、backend/services/storage/ossAdapter.ts:32-36(缺配置即不可用)、backend/services/storage/ossLifecycleService.ts:274-300(7 天 TTL 清理兜底)。


Q13. OSS 文件生命周期是怎么设计的?为什么不用引用计数? ​

【考点】 这是候选人本人深度参与的模块之一,也是最能体现"设计能力 + 事故学习"的地方。答好这题权重很高。

【口述答案】 先说核心设计决策,再说机制,最后说事故。 核心决策:业务表就是档案,不建全局文件表。 早期的设计有一张 oss_objects 表把每个文件都记一遍,问题是它和业务表互为冗余、极难同步。所以改成了只追踪两种"特殊状态"的文件:

  • 临时文件(temp_oss_uploads):已上传但还没入库的,expires_at = now() + 7 天,入库即出表;
  • 待删除文件(oss_delete_queue):素材库软删后入队,delete_after = now() + 30 天,物理删除后写 deleted_at。

为什么不用引用计数?这是我被问过最多的一个问题,答案很明确:引用计数需要"计数"和"引用"永远一致,而我们系统里有 12 张以上的表以各种形态引用同一个 object_key——asset_images、asset_voices、assets.try_ons(JSONB 里嵌两层:imageUrl 和 anchorImages[].url)、video_history、video_tasks、video_task_segments、video_retouch_tasks、video_edit_tasks、assistant_conversations.snapshot、script_library_entries…… 任何一条写入路径忘了 +1,那个文件就会被永久删掉;而任何一条删除路径忘了 -1,文件就永远删不掉。前者是数据丢失,后者只是浪费空间。所以我们的选择是**"引用存在性判定"而不是"引用计数"——用一个 PG 函数 oss_key_in_use(key) 直接去这些表里 EXISTS 一遍:"只要还有人引用,就是 false(不安全);只要任何一处判断漏了,就会删错。"** 计数漂移是静默的、累积的;存在性检查是每次都在问当下的真相。代价是每次判定都要扫这些表,但调用频率是每小时一次、表都是小表(assistant_conversations 791 行 / 9.8MB),成本可以接受。 三道防线保证"该删的才删":

  1. DB 触发器:写入 asset_images/asset_voices → 自动移出 temp_oss_uploads 并撤销删除队列;从这些表删除 → 自动入队,但入队前必须先过 NOT oss_key_in_use()。注释里写着一句很硬的话:"物理删除不可逆。任何写入 oss_delete_queue 的路径都必须先过 oss_key_in_use()。"
  2. 唯一约束:oss_delete_queue_object_key_key。加这个之前,触发器里的 INSERT ... ON CONFLICT DO NOTHING 形同虚设——没有唯一约束,ON CONFLICT 根本不生效,同一个 key 会被反复入队,一条到期物理删除后,剩下的重复行再打 tag/删除就报 "The specified key does not exist",无限重试无限刷日志。
  3. 物理删除前四处复检:processDeleteQueue() 的 WHERE 里同时要求 delete_after < now()、deleted_at IS NULL、revoked_at IS NULL、AND NOT oss_key_in_use(object_key)。"宁可漏删,不可错删"——漏删的代价是浪费存储,错删的代价是用户素材永久消失。 Oss 侧还有一层标签机制:待删对象打 oss-status=pending-delete 标签,配合桶的生命周期规则做兜底。 最后一个设计我要特别讲,因为它是我改了两次才改对的:撤销路径必须和删除路径对称。 撤回一个待删文件时,不能直接 DELETE 队列行——因为打标签是 OSS 侧的副作用,DB 触发器调不了 OSS API。直接删行会让标签永远留在正在使用的文件上。所以现在的做法是先标 revoked_at 保留行,由 untagRevokedQueue() 每小时摘掉 OSS 标签后再出队。这条机制的起因是:排查素材库碎图时发现三张仍被活跃模特引用的定妆照,带着 pending-delete 标签活了 51 天。它们之所以没被删,纯属侥幸——当时的桶规则要求三个标签同时命中,而 putObjectTagging 是整体替换标签集,没有对象能集齐三个。规则一旦被简化成单标签匹配,这些在用文件当场就被物理删除。

【备注讲解】 这题的内容量很大,建议按"决策 → 机制 → 事故"三段讲,并且主动说出这句总结:"这个模块的复杂度不是设计出来的,是事故逼出来的。初始设计只有两张表加四个触发器,现在多出来的是 oss_key_in_use 的覆盖面、唯一约束、revoked_at/tagged_at 两个状态列、四处复检。每一处都能对应一次真实事故。" 几个可以延伸的抽象原则:

  • "引用存在性"优于"引用计数",当引用来源分散且不可枚举时。 反过来,如果引用来源集中、有统一入口,计数更高效。判断依据是"我能不能保证所有 ±1 都走同一个函数"。
  • "物理删除之前必须复检"是可推广的(杀进程前、drop 表前、删对象存储前)。因为删除不可逆,而"不可逆操作前的最后一次检查"成本极低。
  • "有副作用的操作,撤销必须和操作一样有副作用对称性":打标签要摘标签、开端口要关端口、加索引要删索引。在 DB 事务里做了一半的跨系统操作,是最容易留下永久残留的地方。
  • ON CONFLICT DO NOTHING 依赖唯一约束,这是个很典型的"以为写了就生效"的坑。

【代码依据】 README.md:181-215("业务表即档案"的设计原则与两张表)、backend/migrations/20260606130000_oss_lifecycle.up.sql(建表 + 7 天 / 30 天 TTL)、backend/migrations/20260606140000_oss_lifecycle_triggers.up.sql:7-15,17-57,59-98(oss_extract_key + 四个触发器 + fn_queue_removed_tryons)、backend/migrations/20260606150000_oss_delete_queue_tagged_at.up.sql、backend/migrations/20260608000000_oss_delete_queue_unique_key.up.sql(唯一约束 + ON CONFLICT 失效说明)、backend/migrations/20260709120000_oss_delete_queue_guard.up.sql:15-52(oss_key_in_use 初版)、backend/migrations/20260909120000_oss_key_in_use_conversations.up.sql(覆盖面扩展到对话/脚本库)、backend/services/storage/ossLifecycleService.ts:186-215,222-262,274-300,305-355,360-390、backend/migrations/20260813120000_oss_revoked_untag.up.sql:1-24(51 天事故全文)、backend/index.ts:334-357。


Q14. 这个生命周期机制出过几次误删事故?定时任务怎么跑? ​

【考点】 这题是"你有没有被生产打过脸"的照妖镜。 有四份迁移的注释就是事故报告,讲出来极有说服力。

【口述答案】四次误删事故,加上一次标签残留事故。 我按时间讲。 事故一(20260709120000):"先全删再全插"把在用文件排进了删除队列。 资产库更新素材的实现原来是"先全删再全插"——用户编辑一次产品,名下所有图片就都进了删除队列,随后又被原样插回 asset_images。但入库触发器只清 temp_oss_uploads,不会把 key 从 oss_delete_queue 撤回。于是这批仍在使用的文件在 30 天后被真删。源头在 c2c6a20 改成了差量替换堵住了,但已经入队的记录无人清理。这次修复做了三件事:写出 oss_key_in_use()、清空队列中仍被引用的待删行止血、给入队加前置校验。 事故二(20260709160000):中文文件名的编码问题,两条杀链。 OSS 对象的真实 key 是解码后的(uploads/xxx-万界小区2.wav),而 URL 里是 percent-encoded 的。而 oss_extract_key() 只切域名前缀、不做 decode。后果有两条链:① oss-unmark-active.ts 拿编码 key 去 deleteObjectTagging(),OSS 报 NoSuchKey,脚本 catch 掉当成功——真实对象上的 oss-lifecycle=temp 标签从未被摘掉,15 天后被桶规则物理删除,这是音色/图片集中消失的直接死因;② 另一个脚本写进 temp_oss_uploads 的是真实 key,而入库触发器按编码 key 删不掉那行,文件明明已入库、temp 行却留着,到期后 cleanupExpiredTempFiles 拿真实 key 去删,这次真删得掉。修法是加 oss_url_decode()、把 oss_extract_key() 统一到真实 key、补齐 oss_key_in_use() 的覆盖面、并把 default-voices/ 前缀设为受保护。 事故三(20260709170000):清空 try_ons 误伤跨素材共享的定妆照。 触发器 fn_queue_removed_tryons 有两处缺陷:① 手写 regexp_replace 提 key,绕过了 percent-decode 归一化;② 入队前不做引用检查。而定妆照 library-anchor/<assetId>/{front,side,back}.jpg 是跨素材共享的——deleteLibraryItem 第一步就清空 try_ons,于是别的活跃模特仍在引用的定妆照被排进队列,30 天后物理删除。实测:删除模特 lib-1779008112028-w9lixd 时,它的 front.jpg 仍被活跃模特 lib-1779113323661-klbdp5 引用,照样入了队。 事故四(20260909120000):对话上传的图只落 snapshot,7 天后被 GC。 首页对话里上传的图只落在 assistant_conversations.snapshot 里,从不写 asset_images。而 oss_key_in_use() 当时查了八张表,唯独没查对话快照。于是"上传后没存进素材库"的图一律被判无人引用,7 天后物理删除,而对话里那条 URL 还在。线上表现:用户 8/31 传的图 9/7 前后被删,9/9 再生成分镜板时 @图片1 下载 404 → 整批中止 → 500。我们做了普查:近 60 天 1638 处引用里约 1136 处已失联,抽样 40 个实测 31 个 404,涉及 61 个账号 / 459 个会话。 事故五(20260813120000):pending-delete 标签在被用文件上活了 51 天(详情见 Q13)。 定时任务怎么跑:backend/index.ts:334-357,每小时一个 setInterval,顺序做五件事——先用引用判定从队列里摘掉仍在使用的行(打过标签的标 revoked_at 留行等摘标签,没打过标签的直接出队)、摘掉撤回行的 OSS 标签、清理过期临时文件、给待删对象打标签、处理删除队列。 这里有一条欠账我要说:这是进程内 setInterval,没有分布式锁。现在单实例没问题,一旦水平扩成两个实例,两边会各扫一遍——好在这些操作大部分是幂等的(deleted_at/tagged_at 条件更新),但"两个进程同时给同一个 key 打标签"是浪费且会互相覆盖标签集的。要做多实例,必须换成 Redis 锁或者选主。

【备注讲解】这题的价值不在于"我们改好了",而在于"四次事故有四个不同的根因",可以这样总结:

事故根因类别一句话
一写入模式"先删后插"制造了假删除
二编码不一致同一个 key 有两种形态
三复制粘贴绕过手搓 key 提取跳过了归一化
四覆盖面遗漏引用判定少查了一张表
五副作用不对称打了标签没人摘

这五条几乎穷举了"引用式生命周期管理"的所有失败模式。这句总结非常好用:它把一串 bug 变成了一个分类法,而分类法是可以用来做 checklist 的——现在我们每加一条新的上传路径,都要问三个问题:它写进哪张表?oss_key_in_use() 覆盖它了吗?它的删除路径过引用检查了吗? 还有一个必须主动提的:事故四的普查数字(1638 处引用里约 1136 处失联)说明这个 bug 被容忍了很久而没人发现,因为它的表现是"用户偶尔 404",会被当成偶发网络问题。这正是"没有告警通道"的直接代价(见 Q19)——一个每小时在删用户文件的定时任务,没有任何人被告知。

【代码依据】 上面每条都已给出迁移文件号;另外 backend/index.ts:334-357(每小时五步清理)、backend/services/assets/referenceImageAvailability.ts:1-10("存量已删的图"注释)、backend/services/storage/ossLifecycleService.ts:186-215(撤销路径的两种处理)、backend/scripts/verify-revoke-quota-rollback.ts 等环境护栏脚本。


五、数据库与迁移 ​

Q15. 你们用的数据库是什么?简历上写的 MySQL 是怎么回事? ​

【考点】 诚实题。 面试官会拿简历对代码。这题必须自己先开口。

【口述答案】 现在的答案是独家 PostgreSQL 17,没有任何 MySQL。backend/package.json 的依赖里只有 pg@8.20,全仓搜不到 mysql2。 MySQL 这三个字的来源我查清楚了,是这么一条时间线(都能用 git 证据说明):

  • 2026-04-16 前后:数据在本地 JSON 文件里(素材库、视频历史按用户各存一个 JSON);
  • 2026-04-23 f0365c7b:加了 MySQL 支撑的账号认证和管理后台用户管理,引入 mysql2,用 USE_DB_AUTH 开关控制"走 MySQL 还是走 JSON"。注意这时候 MySQL 只承担"账号"这一块,素材库和视频历史还是 JSON;
  • 2026-05-13 00853bbc("feat: add pg and redis"):一个提交里硬切到 PostgreSQL,同时引入 Redis 和 BullMQ。这一个提交里有 001~008 八组迁移、db.ts 连接池、migrationRunner.ts、userResolver、rateLimiter,并给 config/index.ts 和 index.ts 做了大改。产物是一次性整体替换,不是渐进迁移;
  • 2026-05-20 bc192aca:迁移命名为数字前缀(Migration 017);
  • 2026-05-22 前后:迁移命名改成 14 位时间戳前缀(YYYYMMDDHHmmss),schema_migrations.version 也从 INT 升到 BIGINT——因为 14 位时间戳放不进 INT。这个改动就写在 migrationRunner.ts:19-33 的注释里。 那为什么简历会写 MySQL?因为我参照的是 README。 README.md:67 的技术栈那一行至今还写着"后端: Express 5, TypeScript, MySQL (mysql2), Alibaba Cloud OSS",而且 README.md:88-89 的"存储模式"还写着"USE_DB_AUTH=true → MySQL 存储用户和积分数据 / USE_DB_AUTH=false → 本地 JSON 文件存储"——USE_DB_AUTH 这个变量在现在的代码里已经根本不存在了。同样过期的还有 docs/db.md、ARCH.md、CHORE.md。 所以我犯的错不是"技术记错",是**"拿过期的项目文档当事实来源,没有回代码核对"**。这是我这次准备面试学到的最重要的一课:文档漂移本身就是一种技术债,而且它比代码债更危险,因为它会污染所有下游的陈述——包括简历。

【备注讲解】 这题的处理方式是**"认错 + 讲清沿革 + 点出系统性原因"**,而不是含糊过去。 几个加分点:

  • 用 git 证据说沿革(f0365c7b → 00853bbc → bc192aca)比说"我们早期用过 MySQL"可信得多。
  • 主动指出"一个提交硬切"这件事本身的风险:00853bbc 是同一天内换掉驱动、换掉存储、引入队列、引入缓存。这在当时(单用户、内测阶段)是理性的——渐进迁移的成本高于收益;但它意味着没有回滚路径,一旦有问题只能整体回退。
  • 把"文档漂移"上升到工程问题:README 是唯一一份"对外契约",它错了,所有基于它写的简历、交接文档、答辩材料都会错。正确做法是把 README 的技术栈段落纳入发布 checklist,或者更彻底——让一部分事实从代码生成(比如从 package.json 依赖表生成技术栈列表,从 config/index.ts 生成 env 文档)。这正好呼应 Q8 里"60% 的配置没有文档"。

【代码依据】 backend/package.json 依赖(仅 pg@^8.20.0,无 mysql2)、README.md:67("MySQL (mysql2)")、README.md:88-91(USE_DB_AUTH,全仓代码已无此变量)、git show --stat 00853bbc(2026-05-13 "feat: add pg and redis",一次提交落地 001-008 迁移 + db.ts + migrationRunner.ts)、git log --format="%h %ad %s" --date=short | grep f0365c7b(2026-04-23 "feat: add mysql-backed account auth and admin user management")、git log 中 bc192aca(2026-05-20 Migration 017 数字前缀)、backend/services/migrationRunner.ts:19-33(BIGINT 与 14 位时间戳的说明)、docs/db.md:1-3,27-28("规划阶段(待评审)"的过期文档,仍在说驱动程序不匹配)。


Q16. 迁移系统是你自己写的?怎么保证不会漏跑或跑错? ​

【考点】 自研组件的设计能力。这题答好很加分,因为这是一个"有很多现成工具但我们自己写了一版"的决策。

【口述答案】 是自己写的,两个文件:services/migrationRunner.ts(304 行核心)+ scripts/migrate.ts(CLI 入口,有 up/down/status/create 四个命令)。现在是 270 个迁移文件。 表结构:schema_migrations(version BIGINT PK, name VARCHAR(255), checksum VARCHAR(64), applied_at TIMESTAMPTZ)。 四个设计点:第一,一条迁移一个事务。 BEGIN → 执行 SQL → INSERT INTO schema_migrations → COMMIT,出错 ROLLBACK 并向上抛。所以单条迁移不会半执行。 第二,sha256 校验漂移。 每次 migrateUp 都会重新读已应用迁移的文件内容算 checksum,和库里存的对比。不一致说明"文件被改过、改动不会生效",报 warning。设计原则写在注释里:"已应用的迁移文件应当视为只读,需要改结构请新建一条迁移。"第三,findDuplicateVersions 硬失败。 这是我最想讲的一条,因为它源于一次真实事故。已应用的迁移只按 version 记录,所以两个文件共用一个版本号时,先落库的那个会让后来者被永久判定为"已应用"而静默跳过。我们丢过一条:006_voice_assets 就是这么没被执行的,事后只能补一条 20260528100000_add_voice_info_column 救回来。现在的做法是 discoverMigrations() 里一旦发现版本号重复就 throw,注释写得很狠:"宁可在这里炸掉,也不要再静默跳过一次。"第四,verifyAppliedIntegrity 盖住第二种撞号。 findDuplicateVersions 只能看盘上的文件,盖不住"版本号被库里另一条迁移占了、而盘上只有一个文件"——在测试库跑过未合并分支的迁移就会这样(比如 20260721100000_credit_vendor_cost,main 上根本没这个文件)。此后 main 上任何用了同一版本号的新迁移都会被静默跳过。所以现在会把 schema_migrations 里的 name 和 checksum 一起读出来做交叉校验:版本号相同但 name 不同 → error(那条永远不会被执行,必须换号);name 相同但 checksum 不同 → warning;库里有记录但盘上没文件 → warning(版本号已被占用,别在新迁移里复用)。 命名规则:<14 位时间戳>_<snake_case 名>.up.sql / .down.sql,migrate:create 自动生成时间戳前缀,保证全局唯一且按时间自然排序。 还有一点:迁移在两个地方会被执行——服务启动时(backend/index.ts:241 的 runMigrations())和发布脚本第 4 步(pnpm run migrate:up,而且只在区间里新增了 .up.sql 才跑)。

【备注讲解】 "为什么不用现成工具(Flyway/Prisma/Knex)"是一个很可能被追问的点。可以答:当时的需求是"一个能跟踪版本、能做上下线、能和服务启动集成"的最小系统,总共 304 行、零额外依赖、完全可读。 而且我们在实践中补齐了三个现成工具都有、而一开始没有的能力:重复版本号硬失败、已应用文件的完整性强校验、时间戳命名防撞号。这个演进路径本身就是有价值的——先用最小实现跑起来,再用事故把护栏一件件补上。 这一题最有分量的总结是:"findDuplicateVersions 和 verifyAppliedIntegrity 这两个函数的存在,是两次'迁移被静默跳过'的事故换来的。它们的共同教训是:一个'跳过'如果只打一行日志,就等于没有。凡是'本该执行但被跳过'的路径,必须让进程失败。"

【代码依据】 backend/services/migrationRunner.ts:19-34(表结构 + BIGINT 升级)、:38-54(findDuplicateVersions + 006_voice_assets 事故注释)、:56-93(discoverMigrations 撞号即 throw)、:95-97(sha256)、:105-140(verifyAppliedIntegrity + 20260721100000 事故注释)、:155-213(单事务 migrateUp + ON CONFLICT DO NOTHING)、:279-298(migrateCreate 14 位时间戳)、backend/scripts/migrate.ts:1-10(CLI 与"避免迁移误连远端数据库"的 config 预加载)、backend/index.ts:240-242(启动跑迁移)、scripts/deploy.sh:153-162(发布 diff 门控跑迁移)、.github/workflows/ci.yml:28-30(CI 里那条"真实 migrations 目录无重复版本号"的回归断言)。


Q17. 迁移这块还有什么缺口?连接池配置合理吗? ​

【考点】 主动交底题。"没有 advisory lock"和"缺 statement_timeout"是两个很具体的、能体现深度的点。

【口述答案】 三个缺口。 第一,迁移没有 advisory lock。migrateUp 从头到尾没有任何 pg_advisory_lock。而我们要跑的时机有两个:发布脚本里跑一次,服务启动时还会再跑一次。如果发布时是"先重启后探活"而重启的服务在启动时也跑迁移,就会出现两个进程同时对同一批 pending 迁移执行。现在没有被这个打到,是因为 applied 快照是各自在开始时读的,而 INSERT ... ON CONFLICT (version) DO NOTHING 让重复标记不至于报错——但两条迁移的 DDL 会真的执行两遍:CREATE TABLE IF NOT EXISTS 幂等所以没事,但一条 ALTER TABLE ... ADD COLUMN 或者 UPDATE 数据迁移,第二条就会因为"列已存在"或者重复执行而报错,把启动流程 throw 掉。 该怎么补我在别处已经有现成经验:这个仓库里 pg_advisory_xact_lock 已经在三个地方用得很熟了——分销分牌、注册赠分、以及积分退款的幂等标记。所以修法就是照抄:migrateUp 开头加一把事务级 advisory lock(或者一条独立的 pg_advisory_lock / pg_advisory_unlock),全库一个固定 key。 第二,连接池配置缺三样。 现在的配置是:max 20、min 2、idleTimeoutMillis 30s、connectionTimeoutMillis 15s、maxUses 7500、keepAlive: true + keepAliveInitialDelayMillis 10s。这个配置里我认为做得好的是 maxUses 和 keepAlive——前者防长连接的内存泄漏,后者注释写明是为了"防止防火墙/NAT 静默关闭空闲 TCP 连接后 pool 拿到死连接报 ETIMEDOUT",这是我们被公网链路坑过之后加的。 缺的三样:① 没有 statement_timeout,一条忘记加 LIMIT 的查询可以无限占用一个连接,20 个连接被占满整个服务就死了;② 没有 query_timeout(客户端侧的超时);③ 没有 ssl,因为 PG 在同机 docker 里、走的是宿主端口,所以"通讯不上 TLS"——这意味着任何能访问 5511 端口的东西都能明文拿到数据库凭据。这也是为什么 compose.yml 的端口绑定应该默认收窄到 127.0.0.1(见 Q10)。 第三,db.ts 直读 process.env,和"配置收口"的约定矛盾。config/index.ts 第一行写着"所有 process.env 读取集中于此文件",而连接池这 8 个值一个都没走 config。后果是:改配置时你不知道去哪找,而且 PG_HOST/PG_PORT 的默认值(127.0.0.1/5432)和 compose 的默认值(5432/app)各写各的——一旦有人忘了配 PG_PORT,它会静默连到 5432 的另一个库上。

【备注讲解】 这一题的组织是"一条严重的(advisory lock)+ 一组配置缺口 + 一处约定违反"。 值得展开的抽象:

  • "幂等"和"可并发"是两件事。 CREATE TABLE IF NOT EXISTS 幂等,不代表整个迁移流程可以并发跑。判断迁移能不能并发的标准不是"DDL 幂不幂等",而是"有没有一条语句会因为是第二次执行而报错"。这个区别很容易被忽略。
  • "连接池没有超时"是很典型的存量欠账:池的参数是照着博客抄的(max/min/idle 三件套),而 statement_timeout/query_timeout 属于"被慢查询打过才会加"的那一类。判断一个池配置是否成熟,就看它有没有超时参数,而不是看 max 是多少。
  • 默认值不一致是一种"跨文件耦合":db.ts 的默认值和 compose.yml 的默认值需要保持一致,但它们之间没有任何机制保证。这类问题的解法是让默认值只存在一处(收口到 config),或者更彻底——生产必需的连接参数不给默认值,缺失就启动失败。

【代码依据】 backend/services/migrationRunner.ts:155-213(migrateUp 全文,无 advisory lock)、backend/services/db.ts:14-42(池配置 8 项 + keepAlive 注释,无 statement_timeout/query_ssl)、backend/config/index.ts:1("所有 process.env 读取集中于此文件")、compose.yml:17(端口绑定)、仓库内 pg_advisory_xact_lock 的既有用法:backend/services/salesAssign/salesAssignStore.ts:147-149、backend/services/user/userCreditsStore.ts:1073-1079、backend/services/invite/signupGrantService.ts:146-155。


六、备份与容灾 ​

Q18. 你们的备份和容灾方案是什么? ​

【考点】 这是最大的一条欠账,也是最容易被问到的一条。 字节这类公司对"数据能不能恢复"非常敏感。必须主动交底,而且要把"这是能力缺口"说清楚。

【口述答案】 我先直接回答:备份和容灾目前是零。 不是"做得不好",是没有。我拆成四条说。 第一,没有任何自动备份。 没有 pg_dump 的定时任务,crontab 里没有备份任务,仓库里没有一个备份脚本。我们在文档里的原话是"生产库没有任何自动备份"。 第二,没有 PITR,没有 WAL 归档,没有副本。 容器用的是 PostgreSQL 镜像的默认配置——compose.yml 里 没有挂载任何 postgresql.conf,所以 shared_buffers 是默认的 128MB、max_connections 是默认的 100,当然也没有 archive_mode。这也意味着没有任何"恢复到某个时间点"的能力。 第三,唯一的一次性备份是靠人手。 我们做"双余额账本"改造时,计划里写得很清楚:改造前必须手动在服务器上执行 docker exec -e PGPASSWORD="$PW" postgres pg_dump -U shatang_user -d shatang_ai -Fc > /opt/backups/shatang_ai-$(date +%Y%m%d-%H%M).dump。而且当时踩了两个环境问题:服务器上没装 psql/pg_dump,只能进容器;容器里没有 postgres 角色,必须用 .env 里的 shatang_user。 计划书里还记着一条更刺眼的事实:/opt/backups 最近的库备份停在 2026-07-17——也就是说,在 2026-08-21 那次改造前,最近的备份是一个多月前的。 第四,后果要说清楚。 PG 的数据在一个 docker volume(compose.yml 的 postgres_data)里。这个卷丢了,积分余额和支付账本就不可恢复——不是"部分丢失",是没有任何恢复路径。对一个有真实付费、有积分资产、有 production_usage_ledger 追加账本的系统来说,这是最严重的一条。 为什么当初长成这样:两个真实原因。一是它是从单机内测演化过来的,数据量一直在"一个卷装得下"的量级,从来没触到"需要 DBA"的痛点;二是备份是一件"只有出事那天才体现价值"的工作,在没有 SRE、没有值班的压力下,它永远排在功能后面。 怎么补,我很清楚,而且分优先级:

  1. 最优先级、半小时能做完:一个 pg_dump -Fc 的 cron(每天)+ 异地存储(同步到 OSS,不是放同一台机器上)+ 保留 N 天。这一步能覆盖"误删数据/误改结构"这类最常见的场景。
  2. 第二优先级:开 WAL 归档(archive_mode + archive_command)或者直接上 pgBackRest/wal-g,拿到 PITR。这能覆盖"误执行了一条 DELETE"。
  3. 第三优先级:PG 上流复制 + 从库。这能覆盖"主机挂了"。
  4. 贯穿始终的一条:恢复演练。没演练过的备份不算备份——我甚至不能确定那个 2026-07-17 的 dump 能不能还原成功。 另外有个对比很说明问题:同一个 compose.yml 里,Redis 被认真调过(appendonly yes + appendfsync everysec + AOF 重写阈值 + 三档 RDB),而 PostgreSQL 一行配置都没有。Redis 挂了我们还能从 PG 重建缓存,PG 挂了两边一起完。调优的优先级搞反了。

【备注讲解】 这道题的正确答案是**"我清楚这是零,我知道后果,我知道补的顺序"。千万不要说"我们有备份,是手动的"——"手动的备份"在这个语境里约等于没有**。 几个可以展开的、很有分量的抽象:

  • "备份是能力,不是流程。" 你写一句"发布前记得备份"不是能力;一个能在 5 分钟内执行完、有异地副本、并且被演练验证过还原的机制,才是能力。我们现在的状态是"知道应该备份",这属于流程认知,不是能力。
  • "RPO/RTO 是这道题的真正考点。" 面试官会追问"你接受丢多少数据"。如果答案是"我们没有任何机制",那 RPO 就是无限(从上次手工 dump 到现在)。用 RPO/RTO 的语言来承认缺口,比说"我们没做备份"专业得多。
  • "单卷 = 单点。" 只要数据只存在一个 volume 里,任何一次误操作、任何一次磁盘故障、任何一次 docker volume rm 都是不可逆的。而我们的删除路径上恰好有 processDeleteQueue() 这种每小时真删文件的任务(Q13)——在没有备份的前提下跑不可逆的定时任务,是这套系统里最不对称的风险组合。
  • 主动加一句:如果让我重来,我会在写第一条迁移之前就把备份 cron 写上,因为它的成本是半小时,而它保护的是"前面所有工作的成果"。

【代码依据】 docs/superpowers/plans/2026-08-21-dual-credit-ledger.md:2599-2615("生产库没有任何自动备份" + 手动 pg_dump 步骤 + /opt/backups 停在 2026-07-17)、docs/superpowers/specs/2026-08-21-dual-credit-ledger-design.md:340(同一事实的设计文档侧记录)、compose.yml:18-19,45-46(postgres_data / redis_data 两个 volume)、compose.yml:7-27(PG 无 config 挂载、无 archive_mode)、compose.yml:29-54(Redis 精细调优对比)、仓库内无任何备份脚本(ls backend/scripts | grep -i "backup\|dump" 无结果)。


七、可观测性与告警 ​

Q19. 线上出问题你怎么知道?你的告警通道是什么? ​

【考点】 生产可用性意识。"告警通道 = 0"要主动说,并且要说清代价。

【口述答案】 如实说:我们没有告警通道。 没有钉钉/飞书/webhook 机器人,没有邮件,没有 PagerDuty 之类的东西。出问题的方式是用户投诉,或者我自己去看日志。 现有的可观测手段有四个,我按从强到弱排: 第一,健康检查 /api/health(这块相对扎实)。 它查三项:PG 连通性(pgHealthCheck())、Redis 状态、以及 ffmpeg/ffprobe 可用性,返回 status: ok | degraded,并且把三项分别列出来(postgres/redis/media/ffmpeg/ffprobe),所以即使总状态是 ok 你也能看到哪一项不健康。 第二,结构化程度很低的应用日志。 全部是 console.log / console.error,没有任何日志库——依赖里没有 pino/winston/morgan。我们有一个约定:关键异常用 [ALERT][标签] 前缀,比如 [ALERT][video-poll-audio]、[ALERT][FaceMosaic]、[ALERT][enhance],这样日志系统可以按前缀做规则。但这个约定的覆盖面很小,全仓只有 24 处、11 个标签,而且没有接入任何日志采集系统——它现在的作用只是"我 grep 的时候能捞出来"。 第三,定时巡检兜底。 每 10 分钟扫悬挂任务、每小时跑积分对账,另外"双余额账本"上线后还有 production_usage_ledger 这个只追加、永不 UPDATE/DELETE 的用量账本,用来回答"这批到底花了多少"。 第四,成本侧只有内部计价,不做上游核账。 我们的账本口径是内部积分 + 一批 production_usage_ledger 记录(generate/deliver 事件、秒数、模型、provider_task_id),代码注释里写了它是"机器成本口径,对火山账单"——也就是说这份账本是被设计成"理论上能和厂商账单对行"的,但我们没有写任何自动对账任务。厂商账单来了没人核。 几个我认为很严重的缺口,我一条条说:

  • 日志没有 request id。 一个用户报"我生成失败了",我们只能拿 taskId 去搜,因为同一次 HTTP 请求打出的十几行日志之间没有任何关联标识。
  • 没有结构化日志、没有 metrics、没有 APM/链路追踪、没有 Sentry。 队列积压量、轮询次数分布、上游 P99 延迟、DB 慢查询——这些全看不到。所以像 Q14 事故四那种"每小时在删用户文件"的任务,运行了很久都没人知道。
  • pm2 日志没有轮转,现在是 1.8GB。 我们自己的文档里两次写到"只能事后翻 1.8G 的 pm2 日志"。没有装 pm2-logrotate,也没配 logrotate。

【备注讲解】 这道题的正确结构是:先承认没有告警 → 再说现有的四个手段(并诚实标注强度)→ 再说缺口的代价。 可以展开的抽象:

  • "日志"和"告警"是两件事。 日志回答"发生了什么",告警回答"现在要不要叫人起来"。我们只有前者。而没有告警的系统,故障发现时间是"用户忍耐到投诉为止"——这是一个由用户决定的、不可控的指标。
  • "[ALERT] 前缀"是一个"假装有告警"的机制,要敢于这么评价自己。它看起来像告警(有固定格式、有标签),但没有任何东西在消费它。这比"完全没有告警"更危险一点,因为它会让团队产生"我们有告警"的错觉。一个约定如果没有执行者,就只是注释。
  • 1.8GB 不轮转的日志本身是一个可用性隐患:它会吃满磁盘。磁盘满 → PG 写不进去 → 整个服务挂。这是"无告警"和"无运维"叠加后的典型次生风险。而且 1.8GB 意味着真的出问题时你不能全量读,只能 tail——日志的价值和它的体积成反比。
  • 改进的第一件事应该是什么? 不是上 Prometheus(那是第二步),而是先接通告警通道:让 [ALERT] 前缀的日志能推到一个群里。成本一天,收益立刻可见。第二步才是 metrics + 追踪。

【代码依据】 backend/index.ts:105-120(/api/health 返回结构)、backend/index.ts:333-357(每小时 OSS 清理,console.log 输出)、backend/index.ts:318-331(10 分钟巡检 + 1 小时积分对账)、backend/migrations/20260729100000_production_usage_ledger.up.sql:1-17(追加账本的定位)、backend/services/production/usageLedger.ts:15("机器成本口径,对火山账单")、无日志/追踪依赖(backend/package.json 中无 pino/winston/morgan/sentry/otel/prom-client)、无 request-id 中间件(grep -rn "x-request-id" backend 无结果)、docs/superpowers/specs/2026-08-28-generation-error-unification-design.md:12 与 docs/superpowers/specs/2026-08-12-generation-failure-i18n-design.md:150(两处"1.8G pm2 日志")。


Q20. /api/health 已经挺全了,为什么还说可观测性不够? ​

【考点】 深挖追问。能指出自己健康检查的漏洞,说明你不是"有个端点就交差"。

【口述答案】 因为它有两个具体的漏洞,而且这两个漏洞让它比看起来弱。 漏洞一:Redis 状态不参与总体判定。status 只由 pgOk && media.healthy 决定——Redis 死活完全不影响 status。也就是说脚本里 [ "$CODE" = "401" ] 探活之外的自动化如果只看 status,Redis 挂了它会告诉你 ok。而 Redis 挂了是什么后果?见 Q23——所有队列 Worker 都不启动,视频生成全线停止。所以"健康"的两个字在这里名不副实:一个让核心业务完全不可用的依赖挂了,健康检查说 ok。漏洞二:ffmpeg/ffprobe 的状态是启动那一刻的快照。getMediaToolsHealth() 返回的是 latestMediaToolsHealth 这个模块级变量,而它只在启动时被赋值一次——checkMediaTools() 的唯一非测试调用点就是启动时的 runMediaStartupCheck()。所以如果 ffmpeg 在运行中被删了、或者磁盘挂载掉了,/api/health 会一直报 health。这不是 bug(设计意图可能是"启动时验一次就够"),但它的语义应该是 media: healthy(from startup),而不是看起来像实时状态。 第三个是我更想说的结构性问题:健康检查不能区分"活着"和"能干活"。/api/health 检查的是"PG 连得上、Redis 连得上、ffmpeg 在"。但对这个业务来说,真正决定"能不能服务用户"的是队列 Worker 有没有在跑、上游供应商的并发槽还有没有、OSS 是不是真的启用了。我们有一套很有信息量的信号——启动日志里明确打印了每一个 Worker 的启动,以及 Redis 不可用时的告警——但没有任何一个被暴露成可查询的指标。所以运维只能从启动日志里"读一次",之后进程里发生了什么就不知道了。 怎么补(按成本排序):① 把 Redis 纳入 status 判定(一行);② 给 ffmpeg 检查加个 TTL,超过 N 分钟未刷新就标 stale;③ 加一个 /api/health/ready,检查队列 Worker 数和 Redis 队列积压量——这才叫"就绪探针";④ 区分 /health(存活)和 /ready(就绪),前者用于 pm2/进程守护,后者用于 nginx upstream 摘流。

【备注讲解】 这题的价值在于区分三种探针语义:liveness(进程还活着吗)、readiness(现在能接流量吗)、health(依赖都正常吗)。我们只有一个混在一起的头,而且它的总状态判定用的是最宽松的那一档。 一个很有分量的比喻:"健康检查的值不在于它检查了几项,而在于它的 degraded 有没有人看、以及它漏报的那一项会不会要命。" 我们这里漏报的恰好是最要命的一项(Redis)。 另外一个可以主动说的设计原则:"启动时检查一次"和"持续报告状态"应该分开。 前者是 fail-fast(该拒绝启动就拒绝启动),后者是观测(该持续暴露)。我们现在是用"启动时检查一次"的实现,冒充"持续报告状态"的接口——这是接口承诺和实现能力不匹配。

【代码依据】 backend/index.ts:105-120(status: pgOk && media.healthy,Redis 不参与)、backend/index.ts:217-235(runMediaStartupCheck,启动时调用一次)、backend/services/media/mediaUtils.ts:58(latestMediaToolsHealth 初值)、:100-112(赋值点与 getMediaToolsHealth() 原样返回)、backend/services/media/mediaUtils.ts:338(checkMediaTools 导出,仅启动调用)、backend/index.ts:245-248(Redis 关闭时 worker 不启动)、backend/middleware/maintenanceMode.ts:13 与 backend/middleware/tabAccessGuard.ts:14(/api/health 在白名单里)。


八、工程质量门禁与依赖治理 ​

Q21. 你的 CI 和测试体系有多强?能拦住什么? ​

【考点】 这题很关键。要给出"测试资产不弱、但门禁很弱"这个反差结论。

【口述答案】 我的结论是一句话:问题不在测试,而在没有强制执行通道。 我先说测试资产,再说门禁。 测试资产其实不弱:

  • 后端 348 个 .test.ts 文件(都在 __tests__ 下,正好是 pnpm test 的匹配范围)、约 3200 条 test() 用例,跑在 Node 内置 test runner 上(node --test,通过 tsx 加载 TS);
  • 前端 src 下 221 个测试文件、约 1458 条用例。根 pnpm test 会串两个 runner:vitest(分镜编辑器等功能域,配置在 vitest.config.ts)+ node:test(src/**/__tests__/*.test.ts,54 个文件);
  • 但前端 vitest 的 include 是一份显式清单——vitest.config.ts 里自己写了警告:"⚠️ include 是显式清单,新目录不注册等于不跑"。而且它特意说明为什么 __tests__/*.test.ts 不走 vitest(那批 import 'node:test',被 vitest 吸进来会直接失败)。这份清单本身就是"测试存在但不生效"的一个实例,和 Q16 的迁移撞号是同一类问题;
  • backend/evals/agent-intents.v1.json 有 205 条意图回归用例——这是一份"给指定输入、期望指定意图"的评估集,用来防 LLM 路由回归;
  • docs/superpowers/ 下有 44 份 plans 和 41 份 specs——我们是"先写设计文档再实现"的流程,每份计划里都包含测试清单和人工验收清单;
  • 7 个 verify-* 脚本 + 5 个 smoke-* 脚本,而且它们有一个很好的习惯:带环境护栏。比如 verify-grant-concurrency.ts 开头就 if (port === '5511') { console.error('检测到生产库,拒绝执行'); }——会写数据的验证脚本自己拒绝在生产上跑。 但门禁很弱,三个事实:
  1. CI 只有一个 job。 .github/workflows/ci.yml 里只有一个 backend-tests job,跑 cd backend && pnpm test。没有前端测试、没有 typecheck、没有 lint、没有构建、没有迁移 dry-run。 那 221 个前端测试文件和两个 typecheck 脚本在 CI 里都不会被跑到。(顺带说:连后端自己那 348 个文件也只是"能跑",不是"必须跑"——CI 绿不绿取决于 CI 有没有在跑,见第 2 条。)
  2. CI 从 2026-08-08 起因账户欠费没有执行。 也就是说过去一个多月合进去的代码,没有经过任何 CI 验证。这是一个非常具体、非常诚实的事实。
  3. pre-push hook 没装。 git config core.hooksPath 是空的,.git/hooks/ 下全是 .sample 文件。所以"本地提交前跑一遍"这层也没有。 这三条加起来的后果:我们有能力发现问题,但没有一个自动的地方会让问题停下来。 唯一在拦人的是 scripts/deploy.sh,而且它拦的是"发布时",不是"合并时"——坏代码可以自由地进 main,只在发布那一刻才可能被发现,而那时它已经在主干上了。怎么补(我很清楚,按收益排序):
  4. 先把 CI 欠费这件事解决,这是零成本的收益——流程已经写好了,只是没在跑;
  5. 给 CI 加 typecheck(前端和后端各一条),因为类型错误是最便宜的一类拦截,而且我们已经写了脚本,只是没接;
  6. 加一条迁移 dry-run:在 CI 里起一个 pg 容器,把 270 个迁移完整跑一遍。CI 里其实已经有了这条思路的雏形——现在那个 job 的注释写着最要紧的是"migrationRunner 那条'真实 migrations 目录无重复版本号'的回归断言——撞号会让后端启动直接崩,必须在合并前拦住"。方向是对的,只是覆盖面只有断言、没有真跑。
  7. 把 security:keys 和前端测试也接进 CI,但要注意 Q10 里说过的:扫描脚本现在跑是红的,得先清存量。
  8. 最后才是 pre-push hook(本地钩子可以被 --no-verify 绕过,价值低于 CI,但比没有强)。

【备注讲解】 这道题的反差结论是本分册里最值钱的一句话:"我们的测试资产是按工程规范建起来的,但门禁是空的——所以这些资产生效与否,取决于开发者自觉。" 这句话说出来,比"我们测试覆盖率很高"强太多。 几个可以展开的抽象:

  • "测试存在"和"测试生效"之间隔着一个"执行时机"。 一个测试只有在合并前跑,才有质量门禁的意义;在发布时跑才有兜底的意义;只在本地跑等于没有。而且这里还有第二层——"被 include 了"才算"存在"。vitest.config.ts 是一份手写 allowlist,注释里自己承认"新目录不注册等于不跑"。"显式清单"这个模式在本仓库出现过三次:迁移的已应用版本表、oss_key_in_use() 覆盖的表、vitest 的 include 清单。三次都出过"漏了一项、静默不生效"的事故。 这类清单的共同修法是反过来写——用排除法(默认全跑,例外显式排除),或者给清单加一条"盘上存在的测试文件数 == 被 include 的文件数"的断言。
  • "环境护栏"这个实践值得单独表扬:让危险脚本自己拒绝在生产上执行,比写文档说"别在生产跑"强一万倍。这是把安全规则从"人的记忆"搬进"代码的确定性"。
  • CI 欠费这件事本身反映了一个治理问题:关键质量通道依赖一个会过期的付费账户,却没有人在监控"CI 是不是还在跑"。 这又是一个"没有告警"的实例(Q19)——CI 绿不绿是每天会看的,但"CI 根本没跑"是不会去看的。这类"沉默的失效"是所有门禁机制里最容易被忽略的。
  • 值得主动说的一句:"我们的问题不是'不知道该测什么',而是'没有让测试成为必经之路'。" 对字节这种有成熟 CI/CD 文化的团队来说,这句话正好说明我缺的是工程化的纪律,而这恰恰是我想补的。

【代码依据】 backend/package.json(test: node ./node_modules/tsx/dist/cli.mjs --test "**/__tests__/**/*.test.ts")、package.json(根 test: node tools/run-frontend-tests.cjs,该文件串起 vitest + node:test 两个 suite)、vitest.config.ts(显式 include 清单 + "新目录不注册等于不跑"注释 + __tests__/*.test.ts 归 node:test 的原因)、.github/workflows/ci.yml:8-33(唯一 job + 撞号断言注释)、scripts/deploy.sh 作为唯一强制通道(唯一会在失败时 die 的机制)、backend/evals/agent-intents.v1.json(cases 205 条)、docs/superpowers/plans(44 份)与 docs/superpowers/specs(41 份)、backend/scripts/verify-*.ts(7 个)与 smoke-*.ts(5 个)、backend/scripts/verify-grant-concurrency.ts:7-12(生产库护栏示例)、git config core.hooksPath 为空 + .git/hooks/ 仅 .sample、package.json:17(security:keys 只在这里出现)。


Q22. 你的上游依赖有没有单点?断了会怎样? ​

【考点】 依赖治理与风险意识。这题考的是"你有没有画过自己的依赖图"。

【口述答案】 有,而且不止一个。我按严重程度排四层。 第一层:视频生成只剩腾讯云一家。services/video/providers/index.ts 的 resolveProvider() 无条件 return 'tencent-vod'——不管传进来的 model 和 useApi 是什么,都返回腾讯云 VOD。火山那条线(Ark / Seedance / Kuaizi)从 2026-08 起已经停新任务了。这里有个很克制的工程决定我要讲一下:providers 表里火山的实现是故意保留的,代码注释写着原因——"videoPollWorker 是拿 DB 里存的 task.provider 取实现来轮询的,摘掉实现会让发布时仍在途的火山任务全部轮询失败——钱付了、片子拿不到"。这是"停用"和"删除"之间的差别,处理得很对。第二层:模型/编辑功能共用一个百炼 key。DASHSCOPE_API_KEY 一个 key 撑起了十几个功能:脚本向导、refine、分镜 VL、病毒翻拍、创意多样化、创意、分段、产品图、精修,还有 llm.apiKey 的回退(LLM_API_KEY || DASHSCOPE_API_KEY)。gptImage2Client、voiceApi、shotImageGenerator、frameEditor、segmentTranscriber 这些也在用它。这个 key 出问题(欠费/限流/被封)会同时打断十几个功能,而不是一个。 第三层:注册通路只有阿里云短信一条。services/sms/aliyunSmsService.ts 是唯一的短信实现,register.ts/passwordReset.ts/profile.ts 三处都依赖它。它挂了 = 零注册 + 零找回密码。 而且它的配置校验方式是"四个 env 缺一个就 throw"——这意味着它是启动即失败还是运行时失败取决于调用时机,没能在启动时把配置问题暴露出来。 第四层:只有一处有熔断。gptImageRelays.ts 是全仓唯一做了熔断的地方——"某中转连续失败达阈值 → 冷却一段时间内被 orderedRelays() 排到最后",走的是故障转移 + 进程内熔断冷却(配置项 relayCooldownMs)。别的上游一概没有熔断、没有超时分级、没有降级策略。还有一个我特别想讲的:微信支付平台证书没有自动轮换。wxpayClient.ts 的 getPlatformPubKey() 是读一次文件路径然后永久缓存(_platformPubKey)。微信平台证书是有有效期的、会轮换,轮换后我们的验签就会全部失败,而且不会自动重取——只能上服务器换文件重启。代码里对这种情况的处理是"打印诊断信息提示你检查 WXPAY_PLATFORM_CERT_PATH 是否对应那个序列号",也就是它知道自己会失败,但没有自愈能力。正确做法是走微信的"获取平台证书"接口(/v3/certificates)自动下载并缓存、按 Wechatpay-Serial 匹配。 腾讯云并发我们是认真做了治理的,这块反而做得好:用 Redis + Lua 脚本做分布式租约,全局上限 100、我们生产占 50(另外 50 给一个转售服务),而且同一套规则有两份实现——slotLease.ts 的 Lua 负责在 Redis 里原子执行,slotAdmission.ts 是一个纯函数负责可单测,还有 slotLease.contract.test.ts 在两个仓库各断言一次"两侧配额之和 ≤ globalLimit"。这是我在这个项目里最满意的设计之一。

【备注讲解】 这题的答题结构:"分四层 + 指出唯一有熔断的地方 + 表扬一下自己做得好并发租约"。最后那块一定要说,因为它证明你不是只会交底,也有能拿出来讲的设计。 可以展开的抽象:

  • "停用"不等于"删除"——上游切换时保留旧实现的读路径,是"在途任务不丢"的必要条件。这个判断值得单独讲(Q22 里那句"钱付了、片子拿不到")。
  • "一把 key 撑 N 个功能"是隐性单点:它在架构图上不是一条边,而是很多条边汇聚到一个节点。做依赖治理时要画的是凭证级的依赖图,不是服务级的。因为服务级图上"百炼"只是一个点,而凭证级图上它是 10+ 条边。
  • "只有一处有熔断"说明熔断是"被那次故障逼出来的",不是体系能力。真正成熟的做法是把熔断/超时/重试做成统一的 outbound 层,而不是每个 client 各写各的。
  • 证书轮换是一个经典的"运维时间炸弹":它不会因为你的代码有问题而失败,它只会在某个日历日失败。凡是"到期就会挂"的东西(证书、key、token、域名),必须有到期监控——这又回到 Q19 没有告警的问题。

【代码依据】 backend/services/video/providers/index.ts:3-9,18-27(providers 表 + resolveProvider 无条件返回 tencent-vod + 保留火山实现的理由)、backend/config/index.ts:459-479(ai 分组十几个模型全部回落到一个 key)、config/index.ts:480-489(llm.apiKey: LLM_API_KEY || DASHSCOPE_API_KEY)、backend/services/sms/aliyunSmsService.ts:1-24(唯一短信实现 + 缺配置即 throw)、backend/routes/register.ts / passwordReset.ts / profile.ts(三处调用方)、backend/services/gptImageRelays.ts:2,12,20(唯一熔断实现 + relayCooldownMs)、backend/services/payment/wxpayClient.ts:11-27(平台证书读一次永久缓存)、wxpayClient.ts:100-140(验签失败诊断,无自动重取)、backend/services/tencent/slotLease.ts:40-49,62-68,140(Redis Lua 租约)、backend/services/tencent/slotAdmission.ts:1-33(纯函数双实现)、backend/config/index.ts:646-670(globalLimit 100 / ownerQuota 50 + 跨仓库契约测试注释)。


Q23. Redis 挂掉会发生什么? ​

【考点】 降级行为的准确性。这题考的是"你知不知道你的依赖挂了以后系统长什么样"。

【口述答案】 分两种挂法,表现完全不同。 第一种:主动关掉(REDIS_DISABLED=true)。redis.ts 的 getRedis() 会直接返回 null,然后 index.ts 的启动流程里有一段分支:打印一行 warn——"Redis disabled by REDIS_DISABLED=true; queue workers and recovery jobs are not started"——然后直接 startHttpServer() 并 return。 也就是说:HTTP 服务照常启动、能响应请求,但一个队列 Worker 都不启动。 后果是视频生成全线停止——用户能登录、能下单(积分会冻结)、任务能入队(enqueue 依赖 Redis,实际上也会失败),但没有任何 Worker 会去处理它。同时 10 分钟巡检、1 小时积分对账、每小时 OSS 清理也都不跑。这是一个"半死"状态:看起来在服务,实际上核心功能全停。第二种:Redis 中途挂掉或者网络不通。ioredis 会走 retryStrategy——我们配的是指数退避、封顶 3 秒、永不放弃,注释写着原因是"公网 Redis 链路会抖(NAT 掐空闲连接),别轻易永久放弃"。所以它会一直重连。这段时间里:/api/health 的 redis 字段会变成 unhealthy(getRedis()?.status !== 'ready')——但 status 总体还是 ok,因为总状态只看 PG 和 media(见 Q20 的漏洞一)。队列会积压,Worker 拿不到 job。供应商的并发租约也会失效,因为有槽位获取要走 Redis Lua。 为什么这个降级行为是危险的,我总结两点: 第一,"HTTP 起来了"会骗人。 前面说过,REDIS_DISABLED 分支是唯一让 startHttpServer() 提前发生的路径——正常路径下面端口要等到迁移、Redis 连接、9 个 Worker、视频编辑恢复全部完成才开。所以在 Redis 不可用时,服务反而是"最快可用"的。nginx 探活、pm2 看进程、/api/health 都告诉你服务是好的,但用户发一个生成请求就是石沉大海。这是"静默降级"的又一个实例(和 Q11 的存储降级是同一类问题)。 第二,健康检查不足以发现它。 因为 Redis 不参与总状态判定,任何只看 status 的自动化都不会报警。 怎么补:① Redis 必须纳入 status;② REDIS_DISABLED 这种"半边启动"只应该在开发环境允许,生产应该拒绝启动(fail-fast)——要么全功能,要么明确不可用,不要有一个"HTTP 活着但业务死了"的中间态;③ 如果确实需要降级运行,应该在启动日志之外持续暴露一个明确信号(比如 /api/health/ready 直接 503),让 nginx 把它摘掉——"不能干活就别接流量",这比"接着流量然后什么都不做"对用户友好得多。

【备注讲解】 这题的抽象点很清晰:"HTTP 健康"和"业务健康"是两件事,而我们这套系统正好有一个让两者背离的分支。 可以延伸一句更普适的:判断一个系统有没有"优雅降级",要看它降级之后"还能不能对外承诺"。 我们的 REDIS_DISABLED 分支实现了"进程不崩",但没实现"对外诚实"——用户以为自己能用,服务实际上不能。 另外值得说的一句:"永不放弃的重连策略"在缓存场景是对的,在队列场景是危险的。 缓存挂了继续重连、业务走 DB 兜底没问题;但队列的 Redis 是业务正确性的一部分(job 存在 Redis 里),"一直重连、期间不报错"意味着请求会一直挂在 enqueue 上或者静默失败。同一个连接策略服务两种语义,是配置粒度太粗的问题——BullMQ 的 Worker 连接和应用缓存连接应该分开配置(我们现在 Worker 侧的 maxRetriesPerRequest: null 确实是单独设的,但超时/降级策略没有分开)。

【代码依据】 backend/index.ts:6(redisDisabled 解析)、backend/index.ts:245-249(REDIS_DISABLED 分支:warn 后直接 startHttpServer() 并 return)、backend/services/redis.ts:12-29(getRedis() 返回 null + retryStrategy 永不放弃 + keepAlive 注释)、backend/services/redis.ts:40-42(isAlive)、backend/index.ts:105-120(/api/health 中 Redis 字段但不参与 status)、backend/services/imageGenerationService.ts:32(同一开关的第二处判断)、backend/services/tencent/slotLease.ts:62-68(租约 Lua 依赖 Redis)。


口述速记版(60 秒) ​

我的基础设施是**"基础设施容器化 + 应用侧 pm2/nginx",不是全栈 Docker Compose——compose.yml 只起 PG 17 和 Redis 7,没有 app service、没有 Dockerfile,这一点我要修正简历的措辞。 链路是:nginx(conf 没进仓库,deploy/ 只有模板)→ 127.0.0.1:3001 的 pm2 单实例 → Node 22 + Express 5,tsx 直跑 TS、没有构建产物;PG 在 docker、宿主 5511,Redis 6511。同机还有测试环境 /opt/shatang_test(pm2 shatang-test、3002),所以发布脚本认目录不认名字**——以前硬编码进程名,在测试目录跑会"重启生产、测试没重启、探活打生产端口",全绿但什么都没更新。 发布脚本 196 行、七步、set -euo pipefail,核心价值是不让失败静默通过:最重要的是 git pull 后的 HEAD 断言和最后的 401 探活(20×3s)。已知欠账:没有回滚、发布非原子、pm2 restart 是停再起,手工计时约有 70 秒 502 窗口。 配置是 181 个 env 收口到一个 config,但后端实际读了 291 个、.env.example 只声明 127 个(167 个约 57% 没声明),而且零校验——JWT 密钥有三处声明、两个硬编码默认值,启动不校验强度。 存储只有 OSS 和本地磁盘两个后端,tos/cos 只是点状签名助手;最危险的是静默降级——OSS 配错就悄悄落到本地磁盘,第三方拿不到素材 URL。 OSS 生命周期是我参与最深的模块:7 天临时追踪 + 30 天软删队列,核心是 oss_key_in_use() 做引用存在性判定而不是引用计数,加 DB 触发器、唯一约束、物理删除前四处复检;四次误删事故(先删后插 / 中文文件名编码 / try_ons 跨素材共享 / 对话快照漏判)都写在迁移注释里,最近一次普查是"1638 处引用约 1136 处失联"。 数据库是独家 PostgreSQL 17,没有 MySQL——简历写错是因为 README.md:67 至今写着 MySQL,我拿过期文档当事实了。 最大的两条欠账我主动说:备份和容灾是零(无 pg_dump 定时任务、无 PITR、单卷丢失=积分和支付账本不可恢复),告警通道是零(只有 console.log + [ALERT] 前缀,没有 request id、没有 metrics,pm2 日志 1.8GB 不轮转)。 工程质量的结论是:问题不在测试(348 个后端测试文件、约 3200 用例、205 条意图回归),而在没有强制执行通道——CI 只有一个 job、2026-08-08 起因欠费没跑,pre-push hook 也没装;而且前端 vitest 的 include 是手写清单,新目录不注册就等于不跑。


附:修正后的简历第三条(替换原话) ​

基础设施与运维工程化:设计并落地 shatangAI 线上部署与发布体系——PostgreSQL 17 + Redis 7 基础设施容器化(Docker Compose,含 Redis AOF/RDB 持久化与健康检查),应用侧以 pm2 守护 Node 22/Express 5 服务、nginx 反向代理与静态托管;编写 196 行七步发布脚本(set -euo pipefail),以 git pull 后 HEAD 断言、依赖/迁移/构建三重 diff 门控、按 pm_cwd 推断进程名重启、401 探活(20×3s)等机制确保发布失败不静默通过;自研 304 行迁移系统(schema_migrations + 单事务 + sha256 漂移校验 + 重复版本号硬失败),支撑 135 组(270 个文件)数据库迁移的持续演进;独立设计并运维 OSS 文件生命周期体系(7 天临时追踪 + 30 天软删队列 + oss_key_in_use() 跨 12 张业务表的引用存在性判定 + DB 触发器 + 物理删除前四处复检),从 4 次线上误删事故中沉淀出完整的引用式生命周期失败模式清单。

(注意:如果面试官追问"多环境配置",答"USE_REAL_OSS 切换阿里云 OSS 与本地磁盘,加上生产/测试双环境的目录、进程、端口、Redis key 空间隔离";不要说"Docker Compose 一键部署"。)

持续学习,持续构建。