统一积分系统的设计实践
积分系统看起来简单。用户有余额,消费扣钱,失败退款。但真正运行起来,问题会一个接一个出现。
两套积分系统会让用户困惑。Header 显示一个数字,生成视频时检查的是另一个数字。前端展示的余额和后端验证的不一致。技术上也麻烦,需要维护两套扣费逻辑、两套退款逻辑,还要防止它们相互干扰。
统一成单一积分池,这个决定听起来理所当然。但实现时必须处理好原子性。两个用户同时提交任务,都看到余额充足,系统可能让余额变成负数。这不是理论问题,是生产环境会真实发生的事。
💡 Click the maximize icon to view in fullscreen
行级锁的必要性
数据库事务保证一致性,但不保证并发安全。两个请求在同一毫秒到达,都读到 credits = 50,都扣除 10,最终余额可能是 40 而不是 30。
FOR UPDATE 锁定用户的行。第一个事务获得锁,第二个事务必须等待。等待不是浪费,是正确性的代价。用户宁可等 100 毫秒,也不想看到余额突然变负。
这个锁只存在于事务内。事务提交后,锁立即释放。所以它不会造成长时间阻塞,只是串行化了同一用户的并发操作。
PostgreSQL 默认的 READ COMMITTED 隔离级别下,普通的 SELECT 不会阻止其他事务的写操作。只有显式使用 SELECT ... FOR UPDATE 才会获得行级写锁,确保在事务提交前其他事务无法修改该行。
这也是为什么不能只用应用层的 if 判断来检查余额 —— 判断和扣费之间存在时间窗口,并发请求可能穿过这个窗口。
失败时的退款
任务失败需要退款。但怎么知道该退多少?
记录很重要。每个任务在 ai_generations 表中有 credits_consumed 字段。扣费时写入,退款时读取。不能依赖前端传来的参数,那可能被篡改。不能从模型配置重新计算,那可能已经改变。
退款函数查询这个字段。如果是 NULL 或 0,说明没有扣费,不需要退。如果有值,把这个数字加回用户余额,然后清空这个字段。清空很重要,防止重复退款。
-- 简化的退款逻辑
SELECT user_id, credits_consumed INTO v_user_id, v_credits
FROM ai_generations
WHERE id = p_job_id;
IF v_credits > 0 THEN
UPDATE profiles
SET credits = credits + v_credits
WHERE id = v_user_id;
UPDATE ai_generations
SET credits_consumed = NULL
WHERE id = p_job_id;
END IF;
退款失败怎么办?记录到 Dead Letter Queue。手动处理。自动重试可能造成重复退款,那比不退款更糟。
相关的幂等性设计可以参考 idempotency-check。RPC 原子操作 中也讨论了类似的一致性保证问题。
前后端的双重计算
前端需要实时显示成本,不能每次都请求后端。用户改变参数,成本立即更新,这是基本的体验。
后端不能信任前端的计算。恶意用户可能修改 JavaScript,传一个低成本参数。所以后端必须独立计算。
两边用同样的公式。从数据库读取模型的 credit_cost_rules,应用相同的乘法逻辑。前端算出 10,后端也算出 10,扣费 10。如果前端传来 5,后端忽略,按自己算出的 10 扣费。
这不是冗余,是分工。前端负责体验,后端负责正确性。
成本公式的存储
成本规则存在数据库的 JSONB 字段里:
{
"calculationType": "formula",
"formula": "base * duration * resolution",
"base_cost": 10,
"duration_multiplier": {
"3s": 1.0,
"5s": 1.5,
"10s": 2.5
},
"resolution_multiplier": {
"480p": 1.0,
"720p": 1.5,
"1080p": 2.0
}
}
这个设计让成本调整变简单。不用改代码,不用重新部署。更新数据库,立即生效。前后端都读这个配置,所以它们始终同步。
但这也要求计算逻辑必须通用。不能针对某个模型写特殊代码。所有模型都用同一套计算器,只是参数不同。
迁移的风险
从双积分系统迁移到单积分系统,最危险的是数据丢失。用户的 video_credits 怎么办?
先查询。生产环境中,986 个用户,0 个人有视频积分。这个发现很关键。如果有人有余额,必须先合并,或者给用户发通知。
删除列之前,删除所有引用它的函数。函数可能有多个版本,签名不同,必须全删干净。PostgreSQL 不会自动处理函数重载,DROP FUNCTION 需要指定完整的参数列表。
Migration 048 做了删除,但遗漏了一些旧版本的函数。结果 Migration 049 花了更多时间清理。这个教训是:数据库迁移需要验证,不能假设。查询实际的函数定义,确认没有 video_credits 的引用,才能算完成。
关于数据库迁移的更多实践可以参考 database-migration-methods。prisma-migration-with-mcp 中讨论了使用 MCP 工具验证迁移的方法。
💡 Click the maximize icon to view in fullscreen
监控与审计
积分系统需要监控。不是为了抓作弊,是为了发现 bug。
用户报告"明明有积分却提示不足",这可能是前后端不同步。查询他的任务历史,看 credits_consumed 的总和,对比当前余额,就能找到差异。
积分余额突然变负,这肯定是 bug。可能是退款逻辑错误,可能是并发控制失效。监控脚本每小时跑一次,发现异常就报警。
每日统计也有用。消费总量和购买总量的比率,显示了系统的经济健康度。如果消费远大于购买,可能是成本定价太低。如果购买远大于消费,可能是功能吸引力不够。
简单的价值
统一积分系统的最大价值不是技术优雅,是用户理解起来简单。
一个数字代表所有余额。所有功能消耗同一个池子。没有"图片积分"和"视频积分"的区别,没有"这个积分不能用在那个功能"的限制。
技术上也简单。一个扣费函数,一个退款函数。所有功能调用同样的接口。添加新的 AI 功能,不用考虑是否需要新的积分类型。
简单不是偷懒。简单是经过深思熟虑后的选择。复杂系统看起来功能更强,但维护成本会随时间指数增长。统一积分系统放弃了差异化定价的灵活性,换来的是长期的可维护性。
这个取舍值得。
- 如何处理部分完成的任务?视频生成了一半就失败,该退多少积分?
- 如果要支持积分过期,怎么设计?FIFO 消费还是统一过期时间?
- 免费试用额度应该是独立字段还是混在 credits 里?
- 如何防止用户通过快速重复请求绕过余额检查?
Related Posts
Articles you might also find interesting
适配器模式:对现实的妥协
当 PayPro 要求 IP 白名单而 Stripe 不需要,当一个按秒计费另一个按请求计费,架构设计不是消除约束——而是管理约束。适配器模式不是优雅设计,而是对现实混乱的务实投降。
多厂商 AI 调度:统一混乱的供应商生态
当你依赖第三方 AI 服务时,单点故障是最大的风险。多厂商调度不只是技术架构,更是对不确定性的应对策略。
分布式 Workers 的解耦设计
通过微服务架构和队列系统,实现高可用的 AI 任务处理。从单体到分布式,每个设计决策都是对复杂度的权衡。
Studio 系统架构:从状态机到端到端流程
深入 Studio 系统的状态管理中心、组件协调机制和 AI 生成的完整数据流,理解前后端集成的设计逻辑
定价界面优化的三层方法
数据诚实、决策引导、视觉精调——三层递进的优化方法。从移除虚假功能到帮助用户选择,再到像素级修复,每一步都在解决真实问题
离屏渲染:照片捕获为什么需要独立的 canvas
实时流与静态合成的本质冲突,决定了系统必须分离。理解这种分离,就理解了架构设计中最重要的原则。
集中式配置:让 Reddit 组件脱离重复泥潭
当同一份数据散落在多个文件中,维护成本呈指数级增长。集中式配置不是技术选择,而是对抗熵增的必然手段。
让文档跟着代码走
文档过时是熵增的必然。对抗衰败的方法不是更频繁的手工维护,而是让文档"活"起来——跟随代码自动更新。三种文档形态,三种生命周期。
双重导出管道的架构选择
在用户生成内容场景中,速度与质量的权衡决定了导出架构。理解两种不同管道的设计逻辑,能够更准确地把握产品体验的边界。
Purikura的页面系统
通过五层分层继承复用架构,实现零代码修改的页面生成系统。从类型定义到页面渲染,每一层专注单一职责,实现真正的数据驱动开发。
重复数据的迁移实践:从 N 个文件到 1 个真相源
当同一份 Reddit posts 配置散落在多个文件中,维护成本以文件数量指数增长。迁移到集中式配置不是技术选择,而是对复杂度的清算。
在运行的系统上生长新功能
扩展不是推倒重来,而是理解边界,找到生长点。管理层作为观察者和调节器,附着在核心系统上,监测它,影响它,但不改变它的运行逻辑。
分层修复
生产问题没有银弹。P0 止血,P1 加固,P2 优化。优先级不是排序,而是在不确定性下的决策框架。
RPC函数的原子化处理
当一个远程函数做太多事情,失败就变得难以理解
Studio 前端架构:从画布到组件的设计思考
深入 Purikura Studio 前端架构设计,探讨 DOM-based 画布、状态管理和组件化的实践经验
Context 驱动的认证状态管理
认证系统的核心不在登录按钮,而在状态同步。如何让整个应用感知用户状态变化,决定了用户体验的流畅度。
第三方回调的状态映射完整性
KIE.AI 视频生成的三个修复揭示了一个本质问题:回调不只是接收结果,是建立状态映射。没有 vendor_task_id,系统就失去了追溯能力。