06 积分计费与支付深挖问答(简历 bullet ② 的"钱"那一半)
这一篇打的是"你最懂钱"这个牌。简历第二条里的"积分冻结日志防重复扣减"就在这里,而且
credit_holds表迁移是你本人写的(git log --author核对:该迁移 1/1 全是你的提交)。 格式:【考点】 → 【口述答案】(背这段)→ 【备注讲解】 → 【代码依据】。 ⚠️ 本篇有多处"代码事实与简历表述不符",都用 🚩 标出,对应话术见10-压力面与简历修正话术.md。
〇、一页总览:一次生成的钱怎么流动
┌──────────── 下单 ────────────┐
用户提交 → 取价:pricingService.calculateCost(serviceKey, units)
= max(min_cost, round(units × rate, 2)) ← service_pricing 表,Redis 缓存 600s
↓
路径 A(主流 /generate 链路):consumeUserCredits() ← 立即扣减
事务内 SELECT credits,gift_credits,paid_credits FROM users WHERE id=$1 FOR UPDATE
├ UPDATE users 扣余额(splitDebit:先扣赠送池,再扣充值池)
└ INSERT user_credit_transactions (direction='debit')
然后【事后】journalCreditHold() 只补写一条 credit_holds 日志(不做扣减)
↓
路径 B(新链路:长视频/图文成片/声音克隆/换脸/视频编辑/脚本):holdCredits()
同一个事务里:扣减 + INSERT credit_holds('held') —— 真"冻结"
↓
调供应商拿到 taskId → rebindCreditHoldTaskId(平台临时号 → 供应商任务号)
↓
┌── 成片成功 ──→ settleCredits(taskId) UPDATE credit_holds SET 'settled' WHERE status='held'
│ └ 按实际时长:settleCreditsWithAdjustment(..., {refundOnly:true})
└── 失败/超时 ─→ refundCredits(taskId) 退回余额 + 写 'refund' 流水;CAS 幂等
└ 老链路没有 hold:refundLegacyTaskCredits(advisory lock 幂等)
兜底巡检(每小时 / 启动后一次)
├ creditRecovery:扫 status='held' AND created_at < now() - 10 分钟 → 按权威任务表状态 settle / refund / keep
└ creditAudit :只读,抓五类异常(余额漂移 / 拆分漂移 / 多退 / 悬挂 / 负余额)两条铁律
- 先写业务终态,最后才动钱(写反了会出现"钱退了、历史还在生成中、没人补记录")。
- 幂等靠数据库约束,不靠应用层判断——
credit_holds的部分唯一索引是唯一真正生效的那道闸(🚩 详见 Q7)。
Q1. 积分系统整体是怎么设计的?
【考点】 让你先建立全局,也看他会不会一上来就掉进细节。
【口述答案】 积分系统的核心是三个表 + 两层账。 三个表:users 存余额,credit_holds 存"冻结"的状态(held / settled / refunded),user_credit_transactions 存不可变的流水(每一笔余额变动都写一行,带变动前后余额)。 换句话说:users.credits 是当前值,流水是账本,冻结是"钱已经扣了但业务还没结束"的中间态。这三者配合,任何时刻都能回答"这个用户的钱为什么是这个数"。 另外我们做的是双余额账本:gift_credits(赠送)和 paid_credits(充值)分开记,并且用数据库 CHECK (gift_credits + paid_credits = credits) 把不变式焊死。扣减时先扣赠送池,退款时按原比例退回。
【备注讲解】 最后那句"退款按原比例"是最容易被追问的(见 Q6),先埋个钩子。
【代码依据】 backend/migrations/009_credit_holds.up.sql、backend/migrations/20260822100000_dual_credit_ledger.up.sql、backend/migrations/20260823100000_dual_credit_ledger_check.up.sql:53(CHECK 约束)、backend/services/billing/creditSplit.ts。
Q2. 什么是"积分冻结"?为什么不直接扣、失败再补回来?
【考点】 账目一致性的第一性问题。
【口述答案】 "冻结"解决的是异步任务里"这笔钱到底该不该扣"在提交时刻无法确定的问题。 一个生成任务提交后可能成功、可能失败、可能超时;如果直接扣、失败再补,会有一个危险的窗口:用户在这段时间里余额变少了,如果他连续提交多单,第二单可能因为"余额不足"被拒——可第一单最后是失败的,钱本该还在。反过来,如果先不扣、成功后再扣,那用户可以在余额不足的情况下把任务全部提交出去,最后扣不到钱,等于白送。 冻结的做法是:提交时就把钱从可用余额里扣掉,但同时写一条"这笔钱挂在哪个任务上、处于 held 状态"的记录;任务成功就把这条记录转成 settled(确认扣减),失败就转成 refunded(退还余额)。这样余额始终是"可用余额",用户看到的就是真实可用数。
【备注讲解】 专业说法叫 "预留额度 / 资金冻结(authorization hold)",是支付行业的成熟模式(信用卡预授权就是它)。把这个类比说出来,面试官会立刻知道你不是第一次接触这个概念。
【代码依据】 userCreditsStore.ts:656-804(hold/settle/refund 三段实现)。
Q3. 🚩 你的代码里其实有两套"冻结"语义?讲清楚区别。
【考点】 这是本篇最容易被扣分也最容易加分的一题。简历写"积分冻结日志防重复扣减",面试官一读代码会发现有两条路径。
【口述答案】 是的,有两套,而且这个区分我必须讲清楚,因为它正好是我们历史演进的痕迹。
holdCredits()是真冻结:在同一个数据库事务里"扣减余额 + 写credit_holds('held')+ 写扣款流水",三件事原子。这是新链路(长视频、图文成片、声音克隆、换脸、视频编辑、脚本库)用的。consumeUserCredits()是"立即扣减":它只做扣减 + 写流水;credit_holds是事后补写的一条纯日志(journalCreditHold(),注释里写明"不做扣减",而且是 best-effort,失败只catch掉)。主流链路/api/video/generate走的是这条。 为什么会有两套?因为credit_holds是后来为了崩溃可恢复才引入的,当时为了让存量链路不用重写扣费逻辑,就用"补一条日志"的方式把已有流程接进新体系。所以严格说,/generate这条链路存在一个"已扣款但还没建任务"的窗口——这个窗口靠路由层的一个同步 catch 做即时退款兜底(pendingImmediateRefund),但如果进程正好在这几毫秒里被 kill,这笔钱就追不回来了,只能靠对账巡检的balanceDrift发现(因为流水已经写了、任务却不存在)。
【备注讲解】 这道题的正确姿势是主动交底 + 说清演进原因 + 给出正确的目标形态:
"理想状态是全站统一到
holdCredits那种真冻结,consumeUserCredits这条应该逐步迁移掉。现在没迁是因为它覆盖的入口最多、回归面最大,我们选择的是'先保证新链路正确 + 对账兜底老链路'。"
这样讲,面试官看到的不是"设计混乱",而是"你知道债务在哪、也知道往哪走、并且做了风险分级"。
【代码依据】 userCreditsStore.ts:658-679(journalCreditHold 注释"纯日志记录:仅 INSERT,不做扣减")、:682-804(holdCredits 真冻结)、backend/routes/video.ts:1774-1813(pendingImmediateRefund 同步兜底)、:1954/1973(consumeUserCredits + journalCreditHold)。
Q4. 并发提交怎么防止超扣?用了什么隔离级别?
【考点】 数据库并发的硬功夫。答不上来说明只会写 CRUD。
【口述答案】 防超扣靠三层,但真正起作用的只有一层。 第一层是 Redis 分布式锁:SET shatang:lock:credit:<userId> <uuid> EX 10 NX,拿不到会重试 1.5 秒(60ms 间隔),释放时用 Lua 脚本比对 token,防止误删别人的锁。但这一层拿不到锁也不阻断——代码注释写得很清楚,它只是"减少争用的额外护栏",因为把瞬时争用误报成"操作过于频繁"会伤正常用户。 第二层才是真正的串行化机制:数据库行锁。在一个显式事务里 SELECT credits, gift_credits, paid_credits FROM users WHERE id = $1 FOR UPDATE,把同一个用户的所有扣费/退款操作在这行上排成队。余额检查、扣减、写流水全在这个事务里。 隔离级别我们没显式设置,用的是 PostgreSQL 默认的 READ COMMITTED——这是故意的:FOR UPDATE 会把目标行锁住,并在拿到锁之后重新读取该行的最新已提交版本,所以"读到旧余额再扣"这种经典问题不会发生。只有"同一行上的并发更新"场景,READ COMMITTED + 行锁就足够了;如果是"范围条件更新"(比如"余额大于 X 的所有人都扣一遍")才会需要 REPEATABLE READ 或 SERIALIZABLE。 第三层是数据库约束兜底:冻结记录上的部分唯一索引,见 Q5。
【备注讲解】FOR UPDATE 在 READ COMMITTED 下"重新读取最新版本"这个语义是很多人答不上来的点,值得单独强调。另外要能说出为什么不用悲观锁以外的方案:乐观锁(版本号)在高冲突场景会大量重试失败,而用户的余额行本身就是热点,行锁让排队变成串行、总吞吐反而更稳。
【代码依据】 backend/services/lock/creditLock.ts:23(SET NX + 重试)、:34(Lua 比对释放)、userCreditsStore.ts:293-296("拿不到不阻断"的注释)、:305(FOR UPDATE)、backend/services/db.ts:83(transaction() 显式 BEGIN/COMMIT/ROLLBACK)。
Q5. 防重复扣减具体是怎么落地的?说那条索引。
【考点】 简历原话,必须答到索引这一层。
【口述答案】 核心是一条 PostgreSQL 部分唯一索引:
CREATE UNIQUE INDEX idx_credit_holds_task_held
ON credit_holds (task_id) WHERE status = 'held';它的含义是:同一行数据的唯一性只在 status='held' 时生效。于是同一个任务可以有多条历史记录(一条 settled、若干条 refunded),但最多只能有一条 'held'——这正好就是"一个任务只能被冻结一次"这个业务不变式。 配套的写入用 upsert 抢:
INSERT INTO credit_holds (task_id, user_id, amount, status, ...)
VALUES ($1, $2, $3, 'held', ...)
ON CONFLICT (task_id) WHERE status = 'held' DO NOTHING
RETURNING id如果冲突了就拿不到 id,代码直接 throw,整个事务回滚——连余额扣减也一起撤销。所以"重复冻结"在事务层面就是不可能的。 结算和退款也是 CAS:UPDATE credit_holds SET status='settled' WHERE task_id=$1 AND status='held',重复调用返回 0 行,是幂等的 no-op。
【备注讲解】 两个可以加分的延伸:
- 为什么用部分唯一索引而不是"普通唯一索引 + 状态位技巧":因为我们要的约束是"条件唯一",普通唯一索引会阻止历史记录堆积,状态位技巧要在应用层维护。部分唯一索引把不变式下沉到数据库,任何绕过应用层的写入(运维补数据、脚本、另一个服务)都躲不过。
ON CONFLICT必须带WHERE status='held',否则它匹配不上这条部分索引,就会直接报唯一约束冲突而不是走 DO NOTHING。(这是个很常见的写法错误。)
【代码依据】 backend/migrations/009_credit_holds.up.sql:17-21(索引 + 注释)、userCreditsStore.ts:765-775(upsert + throw)、:846-854(settle 的 CAS)、:997-1035(refund 的 CAS)。
Q6. 双余额账本(赠送/充值)为什么退款要按原比例退?
【考点】 业务理解深度题。这题答好了非常亮眼。
【口述答案】 因为不按原比例退会系统性歪曲一个业务指标。 场景是这样的:用户有 100 赠送分 + 100 充值得分,一次消费扣了 150(按规则先扣赠送 100、再扣充值 50)。如果后来这笔消费失败要退 50 分,而我们"优先退充值池",那结果就变成赠送消耗 100、充值消耗 0——赠送消耗被记成 100,而真实只有 50。而"赠送消耗"恰好是老板和运营最关心的指标(它直接对应营销成本)。 更糟的是可被利用:用户可以反复"生成→失败",把充值余额洗成赠送余额。 所以我们的规则是:退款的拆分从 credit_holds.gift_amount / paid_amount 继承原始拆分,绝不按当前池子重算。这两个字段就是在冻结那一刻记下来的。
【备注讲解】 这条可以升华成一句通用原则:"退款不是"反向操作",而是"撤销一笔具体的历史交易",所以它必须携带那笔交易的属性(拆分、归因、期次),不能按当前状态重新推导。"同理的还有归因三列(acting_user_id / acting_project_id / acting_cycle_id)——8 月的视频在 9 月退款,必须退到 8 月那一期,否则两期报表同时错,而且看不出来。
【代码依据】 backend/services/billing/creditSplit.ts:60-77(splitRefund 按原比例)、userCreditsStore.ts:766-770(冻结时记 gift_amount/paid_amount)、:986(结算时把拆分写回)。
Q7. 🚩 流水表的 ON CONFLICT DO NOTHING 真的幂等吗?
【考点】 这是主动性最强的一题。代码里注释声称幂等,实际上因为缺唯一索引是空转。面试官如果读过这块代码,这就是他的杀手题;你主动讲出来,就是你的加分题。
【口述答案】不真。这一点我要明确说清楚。user_credit_transactions 的写入语句里写了 ON CONFLICT DO NOTHING,注释也写着"幂等:ref_task_id 相同时跳过"。但这张表没有任何唯一索引(只有几个普通索引和一个自增主键),而 ON CONFLICT 必须有一个唯一约束/索引才能触发。结果是:这个子句永远不会命中,等于是空转——同一笔扣款被写两次,会真的插两行流水。 所以目前真正生效的幂等约束只有 credit_holds 上那条部分唯一索引。流水表之所以还没出大问题,是因为调用它的人都已经被上游的冻结约束挡住了。 正确的修法是给流水表加一个业务幂等键(比如"任务号 + 方向"的唯一索引),或者把 ON CONFLICT 的目标显式改成那个索引。这是一处已知的、写在待办里的债。
【备注讲解】 为什么这题这么值钱:它证明了两件事——你会去验证 ON CONFLICT 的生效前提,而不是照抄模板;以及你愿意主动说出自己代码里的空转逻辑。字节面试官非常反感"注释说幂等就真以为幂等"。
【代码依据】 userCreditsStore.ts:348、:777-795(带 ON CONFLICT DO NOTHING 的流水插入)、backend/migrations/002_users.up.sql:27-40(表与索引定义)、20260716100000(后续索引,仍无唯一索引)。
Q8. 崩溃在"钱已经扣了,但结果还不知道"的时刻怎么办?
【考点】 崩溃恢复在资金侧的落地,和 04 的队列恢复是配套的两个面。
【口述答案】 有一个专门的巡检 recoverOrphanedCreditHolds(),启动时跑一次、之后每小时跑一次。它扫的是:
status = 'held' AND created_at < now() - interval '10 minutes'也就是"冻结了超过 10 分钟还没结算也没退款"的记录——这些就是悬空的钱。对每一条,它去查权威任务表决定动作:
- 任务根本不存在 → 退款(这笔钱没对应任何业务)
- 任务已经
done→ 结算(业务成了,钱该扣) - 任务
failed→ 退款 - 任务还在跑 → 保持不动(下一轮再看) 这里有一个踩过的坑:视频编辑任务的号是
ve_前缀,它在在途时两张任务表都查不到,会被误判成"真孤儿"而退款——结果就是"任务还在跑、钱已经退了",用户白嫖成片。所以ve_前缀必须在扫描的第一时间就分流出去单独处理。
【备注讲解】 两个延伸点:
- 为什么是 10 分钟而不是更短:因为正常的"扣费 → 建任务 → 回写 taskId"是秒级完成的,10 分钟足够覆盖所有正常慢路径(含第三方 API 超时重试),又不会让悬挂的钱躺太久。
- 这个巡检本身的幂等:它调用的
settleCredits/refundCredits都是WHERE status='held'的 CAS,所以巡检和正常业务路径同时去结算同一个 hold,只会有一个成功。
【代码依据】 backend/services/creditRecovery.ts:9-45(扫描条件与 ve_ 分流)、backend/index.ts:299(启动时)、:326(每小时)。
Q9. 你们怎么发现"钱少了"或者"用户在白嫖"?
【考点】 这是"你有没有把对账当成一等公民"的问题。答案会让人印象深刻。
【口述答案】 有一个只读的对账巡检 creditAudit,每小时跑一次,管理端也能手动触发(GET /api/video/admin/credit-audit)。它抓五类异常,每类针对一种不同的漏钱/白嫖形态:
- 余额漂移(balanceDrift):用户的
credits≠initial_credits + SUM(流水)。正常路径每一笔余额变动都成对写流水,所以任何漂移都说明有代码路径偷偷改了余额而没记账。 - 拆分漂移(splitDrift):
gift_credits + paid_credits ≠ credits。这是双余额账本的不变式——而且这条有一个特殊价值:它是双账本上线过渡期唯一的守卫(因为CHECK约束要等存量数据清洗完才能加,那之前只能靠巡检盯着)。 - 多退(duplicateRefund):同一个任务的退款总额 > 扣款总额。这是最典型的白嫖,而且它漂移检测抓不到——多退时余额和流水是同步增加的,两边都"自洽"。
- 悬挂(stuckHolds):
held超过 30 分钟没动。 - 负余额(negativeBalance):扣减越界。 这里我想特别说多退这条的口径:我们按金额对账,不按退款笔数。早期版本是"同一
ref_task_id出现多于 1 条退款流水就报警",结果长期挂着 11 条误报——因为视频编辑失败后会"退款 + 用同一个 taskId 重扣"再跑一次,一个 taskId 上出现 N 笔扣款 + N 笔退款是完全正常的。按笔数统计,真正的多退反而混在里面看不出来。
【备注讲解】 "对账口径比检测能力更重要"是这道题的核心。误报会让告警失去意义(告警疲劳),而错误的聚合口径正是误报的常见来源。可以补一句:"这个巡检是只读的,发现异常只报警不自动改数据——因为自动修复一个我们没完全理解的异常,风险比留着它更大。"
【代码依据】 backend/services/billing/creditAudit.ts:1-30(五类异常的设计说明)、:33-51(splitDrift)、:76-121(多退的金额口径 + 11 条误报的注释)、backend/index.ts:328。
Q10. 一次生成的"结算"和"退款"分别做了什么?顺序上有什么讲究?
【考点】 细节 + 不变量。
【口述答案】结算(成片成功):把 credit_holds 从 held 转成 settled,写 settled_at。钱在冻结时就已经从余额扣掉了,所以结算本身不再动余额——它只是把"挂着的钱"确认为"已消费"。如果是按实际时长计费的链路(比如创意复刻、批量生产),会走 settleCreditsWithAdjustment:按成片真实时长算出应扣金额,和预估金额做差额调整。 退款(失败):把 held 转成 refunded,把金额退回用户余额(按原拆分退进赠送/充值两个池子),并写一条 direction='refund' 的流水。 顺序上有一条硬不变量:先把业务终态写进 video_tasks 和 video_history,最后才动 credit_holds。 反过来的话——比如先退款成功、再写库时崩了——credit_holds 已经是 refunded,巡检不会再扫描它,可两张业务表还停在"生成中",用户既看不到成片、也没人来补记录,永久悬挂。这条不变量在恢复逻辑的注释里被写成了显式警告。
【备注讲解】 差额结算里还有个业务细节值得讲:我们是 refundOnly(只退不补)。因为供应商交付普遍比下单秒数多 0.1~0.4 秒(生产实测 4 秒单交付 4.096 秒),如果双向补差,几乎每一单都会被多刮一点零头,用户投诉量远大于收益。这是个典型的"数学上更精确、业务上更糟"的例子。
【代码依据】 userCreditsStore.ts:845-854(settle)、:856-868(差额结算 + refundOnly 注释)、:997-1074(refund)、backend/services/taskRecovery.ts:333-335(顺序不变量的显式警告)、backend/services/billing/deliveredDurationSettlement.ts:8-16(只退不补 / ceil / 失败回落原结算三条口径)。
Q11. 按实际时长结算时,如果结算本身失败了怎么办?
【考点】 递归的失败处理,看你想得够不够深。
【口述答案】 我们的选择是:结算失败一律回落到"原结算",而不是让流程卡住。理由是——如果因为差额调整失败而阻塞结算,hold 会一直悬空;而悬空超过一段时间后,会被兜底逻辑整笔退款,那就变成成片白送了。所以"少退一点钱"是可以接受的代价,"结算卡住导致成品白送"不可接受。 另外退款相关的分账金额,必须 await 到位再写历史快照。这里踩过一个很隐蔽的坑:早期五个退款调用点都是"即发即忘"(不 await),结果退款还没落库,历史快照就已经抢跑写完了——恢复出来的失败卡片永远显示"没退过钱",用户来找客服。注释里现在写着"别把 await 再删掉"。
【备注讲解】 这两条都体现同一个判断标准:在分布式系统里,"部分正确 + 可观测"优于"完全正确但可能卡死"。面试官问"为什么退化成这样"时,这个理由就是答案。
【代码依据】 backend/services/billing/deliveredDurationSettlement.ts:14-16(回落原结算)、backend/services/longVideo/generator.js:4707-4710("别把 await 再删掉"的警告注释)。
Q12. 支付是怎么接的?微信支付的验签和回调你做了什么?
【考点】 简历 bullet ④ 的"微信支付接入",而且 wxpayClient.ts 是你本人写的。
【口述答案】 微信支付接的是 V3 API 的 Native 扫码支付(POST /v3/pay/transactions/native),返回二维码内容,前端自己画成二维码给用户扫;另外还接了支付宝的 alipay.trade.precreate(当面付预下单)。两条渠道统一在一个 PaymentChannel 接口下:createOrder / queryOrder / closeOrder / isConfigured,对上层(轮询兜底、启动恢复、管理端补单)屏蔽差异。 微信这条我是手写的 HTTP 客户端(node-fetch + 动态 import),没有用官方 SDK,因为官方 SDK 在 CommonJS/ESM 混合项目里引入成本高。需要自己实现三件事:
- 请求签名:
WECHATPAY2-SHA256-RSA2048,对方法\n路径\n时间戳\n随机串\n请求体\n做 RSA-SHA256 签名; - 回调验签:用
crypto.createVerify('RSA-SHA256')验证头部签名; - 回调解密:
aes-256-gcm,密文末 16 字节取 auth tag。 还有一个容易踩的坑:回调路由必须用express.raw注册,而且要在全局express.json()之前——否则 body 流已经被 JSON 解析器消费掉了,验签和 AES 解密全部失败。
【备注讲解】 "为什么要在 JSON parser 之前注册 raw"这种问题非常体现工程经验,值得主动说出来。另外要主动补一句边界:微信只接了 Native 扫码,没有 JSAPI/H5/小程序支付——简历如果写"微信支付"没写细,被追问时容易显得心虚(见 10 文档的话术)。
【代码依据】 backend/services/payment/wxpayClient.ts:43-52(签名)、:104-146(验签)、:150-172(AES-GCM 解密)、backend/services/payment/channels/types.ts:1-35(渠道接口)、backend/routes/payment.ts:252 + backend/index.ts:47(raw 注册顺序)。
Q13. 用户重复扫码、平台重复收到回调,积分会不会重复到账?
【考点】 支付幂等的经典题,这题你们做得很扎实,可以放心讲。
【口述答案】 不会,我们做了四层:
- 回调入口先 CAS:
UPDATE payment_orders SET status='paid' WHERE id=$3 AND status='pending_payment'——只有第一次能从"待支付"迁移到"已支付",后续重复回调都改不到行。 - 入账在事务里锁订单行:
SELECT ... FROM payment_orders WHERE id=$1 FOR UPDATE,然后先检查credit_idempotency_key,有值就直接返回{duplicate:true}短路。 - 加一个中间态:把订单置成
crediting再入账,这样"进程崩在入账中途"也能被重入恢复,而不是卡在paid。 - 数据库唯一约束兜底:
credit_idempotency_key上有UNIQUE。所以即使前两层因为极端并发都没挡住,第四条 INSERT/UPDATE 会撞23505,外层catch到23505就当成重复请求返回——并发安全最终由数据库保证,而不是应用逻辑。
【备注讲解】 四层的顺序很讲究:应用层短路(快)→ 事务锁(准)→ 中间态(可恢复)→ 唯一约束(绝对)。这套组合拳是支付系统的标准答案,能完整说出来就说明你理解"幂等要防御到数据库这一层"。
【代码依据】 backend/services/payment/paymentOrderStore.ts:189-202(CAS markOrderPaid)、backend/services/payment/creditTopupService.ts:1-120(订单行 FOR UPDATE + 幂等键短路 + crediting 中间态 + 23505 兜底)、backend/migrations/014_payment_orders.up.sql:74(credit_idempotency_key UNIQUE)。
Q14. 用户下单后一直不付款,订单怎么处理?关单有什么风险?
【考点】 超时关单的资损风险,这题能问出"你有没有想过误关已付款订单"。
【口述答案】 订单表上有 expired_at,默认 now() + 15 分钟。到期后由三套机制推进:BullMQ 的 payment-poll 队列(jobId = poll-<orderId> 去重)、30 秒间隔的兜底轮询、以及启动时的 paymentRecovery 扫描。 最关键的一条设计是:本地过期 ≠ 上游未支付,所以绝对不能直接关单。 服务宕机期间用户完全可能已经付款了,只是回调没进来。所以所有关单路径都必须先去上游查一次单:
- 上游返回
success→ 补单补发积分(不能让用户付了钱没有分) - 上游返回
not_paid / closed / failed→ 才关单 - 查单失败 → 保留订单,下一轮再试(宁可挂着,也不能误关) 这个判定我抽成了一个纯函数
decidePollAction,因为它改错会直接造成资损(误关已付款订单 / 漏发积分),必须能单测。 还有一条:恰好到期的瞬间我们再等一轮,用的是严格大于而不是大于等于——"宁可多等,也不误关已付款的单"。
【备注讲解】 另外有个换渠道的坑:如果用户从微信切到支付宝,不能简单把旧单关掉——必须先把两个渠道的单在各自上游都确认关掉/未支付,否则用户两个码都扫就会双倍到账,这是真资损。这类"分布式事务的两个半成品"是最危险的状态,处理原则是"要么都关掉、要么都不动"。
【代码依据】 backend/services/queue/pollDecision.ts:1-33(纯函数 + 资损注释 + 严格大于)、backend/services/payment/paymentRecovery.ts(启动扫描 + 必须先查上游)、backend/services/payment/paymentOrderStore.ts:43(closePendingForSwitch 注释"两个渠道各挂一张单…双倍到账是资损")、backend/migrations/014_payment_orders.up.sql(expired_at 默认 15 分钟 + "一人一单待支付"部分唯一索引)。
Q15. 🚩 你们的支付有没有退款和对账?
【考点】 诚实题。没有就是没有,编了会被追穿。
【口述答案】没有。这一点我说实话。
- 支付侧的退款没实现:
grep微信/支付宝的退款接口是零命中,订单状态机里虽然定义了refunded,但代码里没有任何路径能迁移到它,是个死状态。用户要退款只能走线下人工 + 管理端手工扣减积分。 - 上游对账也没做:没有微信/支付宝对账单的下载与日终核对。我们只有内部的
creditAudit(查内部账目自洽),它不校验上游流水。 这两块是我的判断里"如果再给我一个月,我第一优先会补的"——因为它们是资金业务的底线能力,尤其对账,它决定了你多久能发现"平台少收了钱"。
【备注讲解】 这道题的满分答法就是"承认 + 说清影响 + 给出补齐方案"。补齐方案可以这么讲:
对账的落地路径是"日终拉取渠道账单 → 按
out_trade_no与本地payment_orders双向核对 → 分出四类差异(本地有上游无 / 上游有本地无 / 金额不符 / 状态不符)→ 落差异表 + 人工处理"。前两步是纯工程,一天能出 MVP。
【代码依据】 (反向证据)grep -rn "v3/refund|alipay.trade.refund" backend/services/payment/ 零命中;backend/migrations/014_payment_orders.up.sql(refunded 状态定义);backend/services/billing/creditAudit.ts(只做内部对账)。
Q16. 🚩 微信回调为什么没校验金额?支付宝校验了。
【考点】 防御纵深的对比。这是你自己发现的不对称,讲出来非常加分。
【口述答案】 这是我在做代码审查时发现的一处防御纵深不对称:
- 支付宝那条回调做了五步链式校验:验签 → 校验
app_id→ 订单存在 → 金额校验(total_amount换算成分必须等于订单的amount_fen) → 校验trade_status。它是我们唯一一个无鉴权的公网写入口,所以校验做得最严。 - 微信那条只取了
out_trade_no / trade_state / transaction_id三个字段,全文没有和订单金额做比对。 不过要说清风险量级:微信回调需要先通过 RSA 验签 + AES-GCM 解密,密钥不在攻击者手里,所以实际被利用的概率低;但这是第二道闸缺失——一旦平台证书或 APIv3 密钥泄漏、或者上游出现异常报文,就没有任何一层能拦住"金额不符的到账"。 另外我们也没有做 timestamp 新鲜度校验和 nonce 防重放。 下单侧的金额是安全的:金额来自服务端读payment_packages.amount_fen并写成订单快照,客户端只能传packageId或正整数customCredits,不信任客户端传来的金额。
【备注讲解】 "同一个系统里两个同类入口的校验强度不一致"是安全审计里最常见也最容易被忽略的问题。说出"支付宝做了、微信没做,这是不对称"比说"我们没做金额校验"高一个层级——前者是审计视角,后者只是认错。
【代码依据】 backend/services/payment/alipayNotify.ts(五步校验)、backend/routes/payment.ts:280-305(微信回调只取三个字段)、paymentOrderStore.ts(getOrCreateOrder 从套餐表取金额)。
Q17. 登录鉴权是怎么做的?X-Login-User 这个头能被伪造吗?
【考点】 安全必问题。这题的答案必须精确到"分情况",含糊就翻车。
【口述答案】先说结论:身份最终以 JWT 为准,X-Login-User 是历史遗留的明文头,它能不能被采信取决于一个环境开关。 请求进来先过全局的 userResolverMiddleware:读 Authorization: Bearer → verifyUserToken() 验签(HS256、7 天过期)→ 实时回库比对 token_version(这里刻意不走那个 10 分钟的解析缓存,保证改密/停用能立即吊销)→ 然后把验签得到的用户名覆写回 req.headers['x-login-user'],下游所有 73 处读这个头的代码就自动拿到了可信身份。 所以:
- 当
LEGACY_HEADER_AUTH=false(示例配置的默认值):没有合法 token 时身份被归零,伪造X-Login-User无效。 - 当它为
true:头部按明文采信,伪造任意用户名即可冒充——这是过渡期开关,代码注释自己写着"不能依赖这个例外"。 ⚠️ 我没法从仓库判断生产上这个开关的真实取值(没有 .env、没有部署产物)。这是我面试前要在服务器上确认的第一件事。 另外还有一个更确定的、我更担心的洞:SECRET_KEY在代码里有一个硬编码兜底默认值(userToken.ts和adminAuth.ts各一份,config里还有第三个),而启动时没有任何密钥强度校验。如果生产环境漏配了这个环境变量,攻击者就能用公开的默认密钥签发任意用户的 JWT——这是完整的认证绕过。正确的做法是:生产环境启动时如果发现密钥等于默认值或长度不足,直接 crash 拒绝启动(fail-fast)。
【备注讲解】 这道题的价值全在结构上:先给"最终以 JWT 为准"这个结论 → 再说清 legacy 头的条件分支 → 再说"我无法从仓库验证生产取值"这个诚实边界 → 最后主动抛出更严重的 SECRET_KEY 问题并给出 fail-fast 的修法。 主动交底一个比面试官准备问的更严重的问题,是这题的最优解。
【代码依据】 backend/services/auth/userToken.ts:5(SECRET_KEY 兜底 + signUserToken)、backend/services/auth/userToken.ts:31-51(verifyUserToken)、backend/middleware/userResolver.ts:83-131(decideIdentity 三分支 + 覆写头 + 实时版本校验)、backend/middleware/adminAuth.ts:4(同一密钥)、backend/config/index.ts:414(第三个默认值)、backend/.env.example:13(LEGACY_HEADER_AUTH=false)。
Q18. 微信扫码登录的流程?怎么防 CSRF 和重放?
【考点】 简历 bullet ④ 的后半句。
【口述答案】 走的是微信开放平台的网站应用扫码登录(不是小程序、也不是公众号网页授权):后端生成二维码 URL(open.weixin.qq.com/connect/qrconnect,scope=snsapi_login),前端用 iframe 嵌出来;用户扫码确认后,微信带 code 回调我们的页面;后端拿 code 换 access_token + openid(有 unionid 就用),再取用户资料。 防 CSRF/重放是这么做的:授权 URL 里带一个 state = crypto.randomBytes(16),存在 Redis;回调时校验它存在、且状态是 pending,用完标记成已使用(重复用会返回"该二维码已使用")。前端每 2 秒轮询一次登录状态,成功后由后端签发我们自己的 JWT。 账号绑定用 user_oauth_accounts 表,UNIQUE (provider, provider_uid) 保证一个微信只能绑一个平台账号;openid 和 unionid 双匹配。未知微信不会自动建号——返回 needs_register 加一个一次性的 wechatToken(存 Redis),让用户走注册流程,避免垃圾账号。所以 users.password_hash 也改成了可空。 已知弱点:/auth/wechat/status 这个轮询接口本身不鉴权,凭 state 就能换 JWT——如果 state 通过 Referer 或日志泄漏,就等于账号被接管。
【备注讲解】 "授权码换 token 的流程里,state 就是 CSRF token"——这是 OAuth2 的通用知识,能自然说出来说明你懂协议而不只是抄文档。
【代码依据】 backend/services/wechat/wechatOAuth.ts:32-74(buildQRCodeUrl / exchangeCode / getUserInfo)、backend/services/wechat/wechatAuth.ts:75-81(state 存 Redis)、:150("该二维码已使用")、backend/migrations/019*(UNIQUE (provider, provider_uid))。
Q19. 短信验证码登录呢?防刷怎么做的?
【考点】 常规题,但细节能区分"做过"和"见过"。
【口述答案】 验证码走阿里云短信,OTP 存在 Redis 里,做了四层限制:
- 验证码 TTL 300 秒;
- 单个验证码最多校验 5 次,超了就删 key(防暴力猜 6 位码);
- 同一手机号 60 秒发送冷却;
- 同一手机号每天最多 5 条,另外按 IP 还有小时窗和自然日窗的配额。 还有一个细节:
incrWithTtl会在发现残留 key 没有 TTL 时自动补上——否则一个丢过 TTL 的计数键会让人永久被拒(这是我们限流器里通用的自愈模式,另一个地方也踩过同样的坑)。 另外注册和找回密码用的是两套独立的 Redis 命名空间,并且有专门的测试守着,防止"注册验证码被拿来重置密码"这种横向利用。
【备注讲解】 主动交底:验证码是明文存 Redis 的(没哈希)、比较也不是常量时间比较。对 6 位数字码 + 5 次尝试上限的组合,风险可控,但严格来说应该哈希存储。这种"我知道它不够好、也知道为什么现在够用"的表达最能体现分寸感。
【代码依据】 backend/services/sms/otpStore.ts(TTL/尝试次数/冷却/日限/IP 配额)、backend/services/sms/__tests__/otpNamespaceIsolation.test.ts、backend/middleware/rateLimiter.ts:14-24(短信与找回密码的限流规则)。
Q20. 你们的价格是怎么定的?前端报价和后端实扣怎么保证一致?
【考点】 计费系统的一致性问题。
【口述答案】 价格的单一真相源是 service_pricing 表(字段是 service_key / rate / min_cost / unit / is_active),计算走一个函数:calculateCost = max(min_cost, round(units × rate, 2)),结果在 Redis 里缓存 600 秒,管理端改价后立即删缓存。 视频模型的计价还有一层:modelCatalog 里每个档位(普通/高清/免审/带参考视频)都有自己的 pricingServiceKey,所以档位和价格是绑在同一个目录条目上的,避免"按 A 档报价、按 B 档下单"。 前后端一致这块我们靠的是共享黄金用例:像 shared/storyboard-reference-billing.json、shared/shot-beat-allocation.json 这些 JSON 规则文件是前后端同一份,两边各自实现计算逻辑,但跑同一批 cases,任一侧漂移都会先红测试。 不过要承认一个历史遗留:实际扣费时有些地方 serviceKey 仍然写的是老的 'kuaizi_video'。这是故意保留的历史兼容(老任务的定价键要用它查),不是漏改,但确实和"已经切到腾讯云"的叙事不一致,需要主动说明。
【备注讲解】 "共享黄金用例"是解决前后端一致性最实用的手法(比"共享代码"轻,比"人工对齐"可靠)。可以延伸一句:更彻底的做法是把计价抽成一个共享包(contracts),让 TypeScript 在编译期就拦住漂移。
【代码依据】 backend/services/pricing/pricingService.ts:62-71(calculateCost)、backend/services/modelCatalog.ts:48-64(pricingServiceKey / referenceVideoPricingServiceKey 的显式字段设计)、shared/storyboard-reference-billing.json、backend/routes/video.ts:1846(遗留 serviceKey: 'kuaizi_video')。
Q21. 这套资金系统现在还有什么问题?(主动交底)
【考点】 收尾自省题。
【口述答案】(挑 3~4 条)
- 两套冻结语义并存:
holdCredits真冻结、consumeUserCredits是立即扣减 + 事后补日志,前者理想、后者有"已扣款未建任务"的追不回窗口。目标形态是全站统一到真冻结。 - 流水表的
ON CONFLICT是空转:缺唯一索引支撑,真幂等只靠credit_holds。 - 支付侧无退款、无上游对账:这是资金业务的底线能力缺口。
- 微信回调缺金额校验,与支付宝不对称;也没有 timestamp 新鲜度与 nonce 防重放。
- 无备份:数据库没有定时备份/PITR(这是更上层的问题,见
08-基础设施与部署深挖问答.md),而这张库就是资金的账本——单卷丢失等于账本不可恢复。 SECRET_KEY有硬编码兜底且无启动校验。
【备注讲解】 不要一次说完。挑 3 条讲,剩下的留给面试官"挖"——他挖到你准备好的第 4、5 条时,会认为你对自己系统的认知比说出来的更完整。
Q22. 口述速记版(90 秒)
积分系统是**"余额 + 流水 + 冻结"三张表**:
users.credits是当前值,user_credit_transactions是不可变账本,credit_holds记录"钱扣了但业务没结束"的中间态。 为什么要有冻结?因为异步任务的成败在提交时刻未知:直接扣、失败再补,会让用户中途因余额不足被拒单;先不扣、成功再扣,又会被白嫖。冻结让余额始终等于可用余额。 防重复扣减靠数据库约束:credit_holds上一条部分唯一索引(task_id) WHERE status='held',同一任务最多一条活跃冻结;配上ON CONFLICT ... WHERE status='held' DO NOTHING抢锁,写不进去就整事务回滚。结算和退款都是WHERE status='held'的 CAS,天然幂等。 并发防超扣靠 PG 行锁:事务里SELECT ... FOR UPDATE把同一用户串行化;隔离级别是默认的 READ COMMITTED,因为行锁会重读最新版本,够用。Redis 分布式锁只是护栏,拿不到锁不阻断。 双余额账本:赠送/充值分开记,CHECK约束焊死gift+paid=credits;退款按冻结时记下的原比例退,绝不按当前池子重算——否则会把充值余额洗成赠送余额,还会歪曲"赠送消耗"这个核心指标。 兜底两道:creditRecovery每小时扫"held 超 10 分钟"的悬空记录,按权威任务表状态结算或退款;creditAudit只读巡检抓五类异常(余额漂移、拆分漂移、多退、悬挂、负余额),其中"多退"按金额而不是按笔数对账。 一条不变量:先写业务终态,最后才动钱。 支付侧:微信 V3 Native + 支付宝预下单,回调四层幂等(入口 CAS → 事务锁订单行 →crediting中间态 →credit_idempotency_key唯一约束);关单必须先查上游,因为"本地过期 ≠ 上游未付"。退款和对账没做,这是最大的欠账。