前阵子一个做金融培训的客户找到我,说要上一套模拟理财实战平台。需求乍一听很简单:学员用虚拟币去投资虚拟理财产品,产品由老师在后台自由创建、配置收益率和周期,学员赚到虚拟收益后还能互相送礼物。这听起来像是游戏化的积分玩法,但真正落地的时候,涉及的东西一点都不少——产品生命周期、计息与结算、并发扣款、账户流水、礼物资产与理财账户的打通,哪一环没捋清楚,上线就得炸。
最近我把这套系统的源码结构完整梳理了一遍,发现"理财系统源码 + 虚拟投资 + 礼物系统 + 可自定义产品"这几个关键词背后,其实是一套可以通用的"虚拟资产增值与流转"平台。本文不聊虚的,直接从业务边界、技术选型、核心表设计、投资结算引擎、礼物闭环、并发安全到实际踩坑,完整拆给你看。不管你是想直接拿源码二次开发,还是想自己从零写一套,这篇文章都能帮你少走弯路。
1. 这套系统到底在做什么:先界定业务边界再动代码
1.1 不是金融系统,是"虚拟资产增值玩法"
很多人看到"理财系统"四个字,第一反应是——是不是要对接真实银行、券商、基金,搞真实的投资交易?还真不是。从"虚拟投资"这个关键词就能看出来,这本质上是一套模拟盘玩法。用户手里的"钱"是虚拟币、积分、体验金或者礼物兑换产生的余额,投资标的也是后台管理员自定义的虚拟理财产品,不涉及任何真实的资金流转和金融牌照问题。
我接触过的需求方,大致可以归成三类:
第一类是教育培训机构。金融理财课程、财商训练营经常需要学员实操演练,但不可能让学员拿真钱去市场里跑,所以用一套模拟行情加虚拟理财产品,让学员体验"买入—持有—到期—收益到账"的完整闭环。这类客户对产品的多样性要求比较高,最好后台能自由定义产品的收益率、周期、风险等级。
第二类是企业内部的"生态积分"运营。很多互联网公司或零售企业的App里都有积分体系,积分除了兑换商品,还能"买入"内部的虚拟理财产品,享受积分增值。这类玩法在员工福利平台、会员成长体系里特别常见,核心诉求是把积分从"消耗品"变成"可增值资产"。
第三类是游戏或社区里的虚拟经济玩法。用户的虚拟资产可以购买游戏内的"基金""债券",获得额外收益,收益再去买礼物送给好友,形成社交加经济的小闭环。
这三种场景业务形态差别不小,但落到技术层,核心能力惊人地一致:一个可以灵活配置的理财产品管理后台、一套虚拟账户与交易流水体系、一个投资持仓与收益结算引擎、一个礼物赠送与兑换模块。所以我最后做的时候,直接把这四块当成核心模块来设计,而不是为某一个客户专门定制。这也是"理财系统源码"这类项目能够通用化的前提。
1.2 角色与权限:谁在系统里操作
把系统里的角色盘一下,其实就三类:
- 系统管理员:管后台全局,配置理财产品、管理礼物SKU、维护用户余额、查看全量流水。
- 运营/老师:创建产品、上下架产品、查看用户持仓,只开放部分后台权限。
- 普通用户:领取/充值虚拟币、投资产品、查看收益、赠送和收取礼物、兑换礼物。
这个角色划分必须在一开始就定死,因为后面所有的权限设计、接口设计、菜单设计都围绕它展开。这里特别提醒一句:运营/老师这个角色很容易被忽略。如果系统只有超级管理员,老师每次开新产品都得找管理员,体验会非常差,客户第一个不满意。
我给运营角色单独开了"产品管理"菜单,但限制了"余额调节"和"系统配置"的权限。虚拟资产系统里,权限边界就是资金安全的第一道闸,尤其是余额调节这种高危操作,哪怕是在虚拟环境里,也必须做到可追溯、可审计。
2. 技术栈与模块拆分:为什么这样选型
2.1 技术栈选型
这套系统我最终选的是:Spring Boot做后端,MySQL做主库,Redis做缓存和分布式锁,管理后台用Vue3做前后端分离。
可能有人会问,为什么不用Python?不是不能做,但这类系统对事务一致性和并发扣款的要求比较高,Spring生态里声明式事务、成熟ORM、分布式锁组件一抓一大把,写起来心智负担低。MySQL和Redis的组合也足够支撑几千到几万用户的中小体量运营,完全没必要一上来就搞微服务、消息队列,那是给自己找麻烦。
数据库层面,我特别想强调一张表——账户流水表。这类系统的核心不是"怎么配置产品",而是"每一分虚拟币从哪里来、到哪里去"。账户流水把所有资金变动全部记录下来,将来对账、排查问题、给运营出报表,全靠它。没有流水表支撑的理财系统,等于没有记账本的财会,出了事根本没法查。
2.2 模块划分
模块划分我遵循的是"单体应用、模块分层"的思路,没有服务化:
- 用户与账户模块:用户注册登录、虚拟币领取/充值、余额查询。
- 产品管理模块:理财产品的自定义配置、状态管理、版本快照。
- 投资交易模块:买入、到期结算、提前赎回、收益计算。
- 礼物系统模块:礼物SKU管理、赠送、收取、礼物账单。
- 后台管理模块:角色权限、用户管理、数据看板、手动补单。
每个模块独立成包,共享同一个数据库。我的建议是初始阶段别过度设计,单体应用加模块分层是最稳妥的。现在很多开源源码一上来就是微服务架构,部署成本高得吓人,实际运行中小项目根本用不上。
2.3 两张核心表设计:产品表与账户流水表
这两张表是整个系统的地基,建表字段值得反复斟酌。我贴一下核心结构,字段注释都标好了,可以直接拿去改。
-- 理财产品表 CREATE TABLE `la_product` ( `id` bigint(20) NOT NULL AUTO_INCREMENT, `product_no` varchar(32) NOT NULL COMMENT '产品编号', `name` varchar(64) NOT NULL COMMENT '产品名称', `product_type` tinyint(4) NOT NULL DEFAULT '1' COMMENT '产品类型:1固定收益 2浮动收益 3体验型', `annual_rate` decimal(10,4) NOT NULL COMMENT '年化收益率,如0.0500表示5%', `invest_period` int(11) NOT NULL COMMENT '投资周期(天)', `min_amount` decimal(20,2) NOT NULL COMMENT '起投金额', `max_amount` decimal(20,2) NOT NULL DEFAULT '0.00' COMMENT '单笔投资上限,0表示不限', `total_amount` decimal(20,2) NOT NULL COMMENT '总募集额度', `raised_amount` decimal(20,2) NOT NULL DEFAULT '0.00' COMMENT '已募集额度', `repay_type` tinyint(4) NOT NULL DEFAULT '1' COMMENT '还款方式:1到期还本付息 2按日计息到期付本 3等额本息', `allow_early_redemption` tinyint(1) NOT NULL DEFAULT '1' COMMENT '是否允许提前赎回', `early_redemption_fee_rate` decimal(10,4) NOT NULL DEFAULT '0.0000' COMMENT '提前赎回费率', `risk_level` tinyint(4) NOT NULL DEFAULT '1' COMMENT '风险等级:1低 2中 3高', `status` tinyint(4) NOT NULL DEFAULT '0' COMMENT '状态:0草稿 1待上架 2投资中 3已满标 4结算中 5已完成 6已下架', `cover_url` varchar(255) DEFAULT NULL COMMENT '封面图', `description` text COMMENT '产品介绍', `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, `update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (`id`), UNIQUE KEY `uk_product_no` (`product_no`) ) ENGINE=InnoDB COMMENT='理财产品表';这张表里最关键的是raised_amount字段,每次用户买入都要累加。并发场景下这个字段很容易被改坏,后面我会专门讲怎么做防超卖。
-- 账户流水表 CREATE TABLE `la_account_flow` ( `id` bigint(20) NOT NULL AUTO_INCREMENT, `flow_no` varchar(64) NOT NULL COMMENT '流水号', `user_id` bigint(20) NOT NULL COMMENT '用户ID', `change_type` tinyint(4) NOT NULL COMMENT '变动类型:1充值 2买入 3收益 4赎回 5赠送支出 6收取礼物 7系统调整', `change_amount` decimal(20,2) NOT NULL COMMENT '变动金额,负数表示扣减', `before_balance` decimal(20,2) NOT NULL COMMENT '变动前余额', `after_balance` decimal(20,2) NOT NULL COMMENT '变动后余额', `biz_id` bigint(20) DEFAULT NULL COMMENT '关联业务ID,如订单ID、礼物赠送记录ID', `remark` varchar(255) DEFAULT NULL COMMENT '备注', `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`), KEY `idx_user_id` (`user_id`), KEY `idx_biz_id` (`biz_id`), UNIQUE KEY `uk_flow_no` (`flow_no`) ) ENGINE=InnoDB COMMENT='账户流水表';流水表里我特意加了before_balance和after_balance两个快照字段,这是对账的关键。用户当前余额必须等于其最后一笔流水的after_balance,如果不一致,那肯定是代码有bug或者有人改库了。
为了说明为什么必须用流水表,可以对比两种方案:
| 方案 | 做法 | 优点 | 缺点 |
|---|---|---|---|
| 直接改余额 | 代码里UPDATE user_balance SET balance=balance-100 | 代码简单,性能高 | 无法追溯,没法统计日内收支,出错无法回滚 |
| 流水表+余额 | 先写流水,再更新余额 | 可追溯、可对账、可统计 | 多一次写操作,需要在事务里保证一致性 |
做这类系统,我强烈建议直接用流水表方案。拦截"虚拟资产"的风险与真实资金几乎一样,没有流水,后续所有对账和排查都是大海捞针。
3. 可自定义产品:从后台配置到前台展示的完整链路
3.1 可自定义的参数,比想象中多得多
"可自定义产品"这句话听起来容易,做起来细节比想象中多。一个理财产品在后台到底可以配置哪些参数?我列了一份清单:
| 参数 | 说明 | 示例 |
|---|---|---|
| 产品名称 | 前台展示名称 | "稳健月月盈" |
| 产品类型 | 固定收益、浮动收益、体验型 | 固定收益 |
| 年化收益率 | 核心计息参数 | 5.00% |
| 投资周期 | 天数,决定到期日 | 90天 |
| 起投金额 | 单笔最低投资额 | 1000 |
| 单笔投资上限 | 0表示不限 | 50000 |
| 总募集额度 | 全产品累计可投总额 | 1000000 |
| 还款方式 | 到期还本付息/按日计息到期付本 | 到期还本付息 |
| 提前赎回 | 是否允许、费率多少 | 允许,费率0.5% |
| 风险等级 | 低/中/高 | 低 |
| 封面图与介绍 | 前台展示素材 | 富文本 |
这里有一个设计细节值得展开:收益率用年化还是日化?很多新手做虚拟投资系统,直接配个"日化收益率0.1%",数字看起来挺爽。但真实理财场景里用户习惯看年化收益,拿"0.1%日化"除以365后用户会被吓跑。我的做法是后台统一用"年化收益率"输入,系统自动换算成日利率,前台展示可以按用户习惯切换。这样既符合用户认知,也方便后台做收益率边界校验。
3.2 产品状态机:宁可多打几个状态,也不要上线后再返工
产品的状态流转我设计了七个状态:草稿、待上架、投资中、已满标、结算中、已完成、已下架。
| 状态 | 含义 | 允许的操作 |
|---|---|---|
| 草稿 | 后台正在编辑 | 编辑、删除 |
| 待上架 | 已完成配置待审核/上架 | 上架、编辑、下架 |
| 投资中 | 用户可买入 | 买入、下架(停止新投) |
| 已满标 | 募集额度已满 | 不可买入,等待到期 |
| 结算中 | 系统正在处理到期订单 | 自动/手动结算 |
| 已完成 | 所有订单已结清 | 归档、查看 |
| 已下架 | 运营主动停止 | 重新上架、编辑 |
为什么要搞得这么细?因为每个状态决定了用户能不能买、系统能不能算。比如一个产品处于"投资中"时用户才能买入,一旦满标就必须立刻停止买入,否则超额认购就会出事故。状态混乱最常见的翻车场景是:产品已经结算完了,但前台还能搜到并且能下单,用户付了虚拟币进去发现根本没法产生收益,客诉直接爆炸。
产品状态变更建议集中在后台服务层统一处理,不要散落在各个接口里。我用了一个ProductStatusService,所有状态流转都走这个类,变更时还可以加操作日志,谁在什么时间把产品从"投资中"改成了"已下架"都留痕迹。
3.3 产品参数校验:把运营的手绑住
后台配置产品时,一定要加参数校验。我踩过的教训是,运营误把年化收益率填成0.5(也就是50%),产品刚上架就被内部员工薅了一波羊毛,虽然虚拟币不值钱,但这种事故特别打击客户信任。
校验规则至少要有:
- 年化收益率必须大于0,且不超过预设上限(比如20%)。
- 起投金额大于0,单笔上限大于等于起投金额。
- 总募集额度大于0,且明显不合理的大额(比如超过1亿)需要二次确认。
- 投资周期为正整数,且在1到3650天之间。
- 提前赎回费率在0到1之间。
前端要做校验,后端接口也必须要做同样校验。别指望前端能挡住所有异常输入,我用Postman直接打过自己系统的接口,前端校验形同虚设的例子比比皆是。
3.4 版本快照:修改自定义产品时,老用户怎么办
这是整个自定义产品模块里最容易被忽视、也最容易引发纠纷的设计点。产品上线后,运营想改产品名称、介绍、封面,这没问题。但年化收益率和投资周期能不能直接改?我的建议是:能改,但只能对"以后购买的用户"生效,已经成交的订单必须锁定老的收益率和周期。
实现方案是引入产品版本快照。每次关键参数修改时,生成一条新的版本记录,用户下单时记录当时的版本ID,到期结算时按订单里的版本参数计算,而不是去查产品当前参数。
为什么必须这么做?试想一个场景:用户买了一款90天、年化5%的产品,持有到第50天,管理员把收益率改成了3%。到期的第90天,结算引擎去查产品当前参数,发现收益率是3%。用户不炸才怪。版本快照从数据层面消除了这个争议——一切以用户下单那一刻的产品版本为准。
实现上用一张la_product_version表:
CREATE TABLE `la_product_version` ( `id` bigint(20) NOT NULL AUTO_INCREMENT, `product_id` bigint(20) NOT NULL, `version_no` int(11) NOT NULL DEFAULT '1', `annual_rate` decimal(10,4) NOT NULL, `invest_period` int(11) NOT NULL, `min_amount` decimal(20,2) NOT NULL, `max_amount` decimal(20,2) NOT NULL, `total_amount` decimal(20,2) NOT NULL, `repay_type` tinyint(4) NOT NULL, `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`), UNIQUE KEY `uk_product_version` (`product_id`, `version_no`) ) ENGINE=InnoDB COMMENT='产品版本快照表';订单表la_order里冗余一个product_version_no字段。这样即便产品后来被改得面目全非,结算逻辑依然拿的是订单落库那一刻的参数,清晰无比。
4. 投资与结算引擎:把"钱生钱"的流程跑通
4.1 下单购买的事务链路
用户买入理财产品,从前端点击到后台完成,完整链路是这样的:
- 校验产品状态必须是"投资中",且未满标、未过期。
- 校验用户本次投资金额在起投金额和单笔上限之间。
- 校验用户虚拟余额是否足够。
- 扣减用户余额,写入账户流水。
- 创建用户持仓记录(订单表)。
- 累加产品的已募集额度。
- 若已募集额度达到总募集额度,产品状态改为"已满标"。
这七步必须在同一个数据库事务里。任何一步失败都要整体回滚。尤其是第4步扣余额和第6步累加额度,绝对不能出现"钱扣了持仓没有"或者"额度超卖"的情况。
核心代码逻辑大概是这样的伪代码风格(Java简化版):
@Transactional(rollbackFor = Exception.class) public InvestResult invest(InvestRequest request) { // 1. 锁产品记录(SELECT FOR UPDATE) Product product = productMapper.selectForUpdate(request.getProductId()); // 2. 校验状态 if (product.getStatus() != ProductStatus.INVESTING.getCode()) { throw new BizException("产品当前不可投资"); } // 3. 校验额度 BigDecimal remaining = product.getTotalAmount().subtract(product.getRaisedAmount()); if (request.getAmount().compareTo(remaining) > 0) { throw new BizException("产品剩余可投额度不足"); } // 4. 扣余额,带条件更新防止并发超扣 int rows = accountMapper.deductBalance(request.getUserId(), request.getAmount()); if (rows == 0) { throw new BizException("虚拟余额不足"); } // 5. 写流水 accountFlowMapper.insert(FlowBuilder.buildInvestFlow(user, product, amount)); // 6. 创建订单 orderMapper.insert(OrderBuilder.buildInvestOrder(request, product)); // 7. 累加已募集额度 productMapper.increaseRaisedAmount(product.getId(), request.getAmount()); return InvestResult.success(); }注意第1步的SELECT FOR UPDATE,这是数据库层面的行锁,保证同一时间只有一个线程在修改同一个产品的已募集额度。虚拟资产系统最怕的就是并发超卖,用行锁是最直接有效的做法。
4.2 收益计算的三种模式
不同客户对计息方式的要求不一样。我梳理了最常见的三种,后台用repay_type字段区分:
| 还款方式 | 计息逻辑 | 适用场景 |
|---|---|---|
| 到期还本付息 | 到期收益=本金×(1+年化收益率÷365×周期天数) | 最常用,简单直观 |
| 按日计息到期付本 | 每天产生收益,到期本金+累计收益一起结算 | 教学演示、体验型产品 |
| 等额本息 | 按期间分N期还本付息,每期金额相等 | 模拟贷款类教学场景 |
具体公式:
- 到期还本付息:
到期总额 = 本金 * (1 + 年化收益率 / 365 * 投资天数) - 按日计息:
每日收益 = 本金 * 年化收益率 / 365,每天累加。 - 等额本息:每期还款额 =
本金 × 月利率 × (1+月利率)^期数 / ((1+月利率)^期数 - 1),这个在虚拟教学系统里偶尔会用到。
4.3 金额计算的精度问题,必须提前决定
虚拟币虽然不是真钱,但收益计算的精度必须跟真实金融系统一个标准。这里我给出最稳妥的实践方案:
- 数据库金额字段统一用
DECIMAL(20,2),以"元"为单位存储,但代码里操作一律用整数"分"或者BigDecimal。 - 所有计算用
BigDecimal,除法必须指定精度和舍入模式。我用的是MathContext.DECIMAL64,舍入模式RoundingMode.HALF_UP。 - 禁止使用
double或float做收益计算。
举个例子说明为什么不能用double:日利率 = 0.05 / 365 = 0.00013698630136986…… 这是一个无限循环小数。如果用double直接乘本金,再累加365次,误差会越来越大。10000元本金、5%年化、按日计息365天,double算出来和BigDecimal算出来的结果可能会差好几分钱。如果系统里有几十万用户,对账时这些误差积少成多,查都查不完。
我最终的方案是:金额字段在数据库用DECIMAL(20,2),在Java代码里用BigDecimal,乘除运算时显式指定精度。这套组合下来,精度问题彻底解决。
4.4 到期自动结算与手动补单
产品到期后,系统需要自动完成本息结算。实现方式是用定时任务扫描持有中的订单,筛选条件就是:订单状态为持有中,且产品到期时间小于等于当前时间。
但定时任务有一个问题:服务器重启、任务跑挂、新用户刚买就到期(比如周期只有1天),很容易漏单。所以系统里必须留手动补单的入口,管理员查到哪笔订单到期了但没结算,手动触发结算。
手动补单实现思路很简单:查出所有符合条件但未结算的订单,逐笔执行结算逻辑。这里最关键的是幂等性——绝不能重复结算。我的方案是加一张la_settlement_bill表,针对每笔订单生成结算单,并加唯一索引uk_order_id。同一笔订单只能生成一张结算单,定时任务就算并发跑两遍,第二次插入也会因为唯一索引冲突而失败。
这个唯一索引救了我很多次。没有它,如果定时任务因为网络超时被重复触发,用户的虚拟余额会直接翻倍——账一乱,所有对账工作全报废。
5. 礼物系统与理财系统的联动闭环
5.1 礼物系统解决了什么问题
礼物系统的存在价值,是让虚拟资产真正"流动"起来。如果用户赚了虚拟币只能自己看着数字变大,很快就腻了。但如果可以买礼物送给好友,好友收到礼物可以继续投资理财,这就形成了社交传播加经济循环的闭环。
我在这套系统里设计了两层礼物玩法:
- 普通礼物:用户直接用虚拟币购买静态礼物(鲜花、奖杯、红包封面等),购买后赠送或自用。
- 活动限定礼物:后台自定义礼品项,可以配置限时投放、限量库存,比如"七夕限定礼盒"。
5.2 礼物SKU表设计
礼物SKU表la_gift核心字段:
CREATE TABLE `la_gift` ( `id` bigint(20) NOT NULL AUTO_INCREMENT, `gift_no` varchar(32) NOT NULL, `name` varchar(64) NOT NULL, `cover_url` varchar(255) DEFAULT NULL, `price` decimal(20,2) NOT NULL COMMENT '虚拟价格', `stock` int(11) NOT NULL DEFAULT '999999' COMMENT '库存,-1表示不限', `limit_count_per_user` int(11) NOT NULL DEFAULT '0' COMMENT '每人限购次数,0不限', `status` tinyint(4) NOT NULL DEFAULT '1' COMMENT '1上架 0下架', `sort_order` int(11) NOT NULL DEFAULT '0', `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`), UNIQUE KEY `uk_gift_no` (`gift_no`) ) ENGINE=InnoDB COMMENT='礼物SKU表';这里有个设计细节:礼物要不要做库存?大部分虚拟礼物其实没有库存概念,我建议给个默认大库存甚至不限。但保留库存字段是有必要的——活动限定礼物需要限量,比如"只发1000份",这时候库存字段就派上用场了。
5.3 赠送流程:本质是一笔转账
礼物赠送的完整流程:
- 用户选择礼物,填写接收人。
- 校验发送者虚拟余额是否够。
- 扣减发送者余额,写入账户流水(变动类型=赠送支出)。
- 增加接收者余额,写入账户流水(变动类型=收取礼物)。
- 生成礼物赠送记录表。
这个流程本质上就是"转账模型"。特别需要注意的点是:礼物赠送必须走同一个账户流水表,不能单独搞一套"礼物余额"。否则用户余额、礼物余额两本账,对账时极其痛苦。
我收到礼物后余额立刻到账,但界面上要展示"来自好友XX的礼物"。所以礼物赠送记录表和账户流水表是一对多的配合关系:赠送记录表存的是礼物和人脉关系,流水表存的是资金变化。查询展示时,先查赠送记录,再关联到流水表拿到时间点和金额。
5.4 闭环:收到礼物可以继续投资理财产品
用户收到礼物转化成的余额,是可以直接购买理财产品的。这个闭环是整个系统的爆点:
用户投资理财 → 收益到账 → 购买礼物 → 送给好友 → 好友余额增加 → 好友再投资理财 → 好友收益后又买礼物送回来。
循环就这么转起来了。所以我特意在送礼事件触发时,给接收者发一条站内信通知:"好友XX送给你XX礼物,礼物价值XX虚拟币已放入可用余额。"这一步别小看,它能让用户明确感知到"礼物=可增值资产",而不是一个没有价值的表情包。没有这条通知,用户根本不会想到把礼物余额拿去投资,闭环就断了。
6. 权限、并发与安全:虚拟资产也不能裸奔
6.1 后台权限必须细分
虽然资金是虚拟的,但后台如果有个"余额调节"功能被乱用,后果同样严重。我见过有人把收益率改成100%给自己刷收益的,也见过客服手滑给用户多加了十几万虚拟币的。权限方案我直接做了四层:
| 角色 | 权限范围 |
|---|---|
| 超级管理员 | 全量权限,包括系统配置、余额调节、角色管理 |
| 运营管理员 | 产品管理、礼物管理、用户查询、数据看板 |
| 财务/审计 | 只读流水、对账报表导出 |
| 普通客服 | 仅用户详情查看、工单处理 |
权限控制用Spring Security加自定义注解实现。方法级别的@PreAuthorize("hasRole('ADMIN')")是最基本的,重要的是在余额调节、产品参数修改这些关键接口上,还要加二次操作日志。谁调的、调了多少、给谁调的、什么时间,全部记录下来。权限管理做得好,虚拟资产系统才能扛得住审计。
6.2 并发扣款的三种方案对比
在投资下单、礼物购买、余额扣减这三个高频场景里,并发是不可避免的。我对比过三种方案:
| 方案 | 实现方式 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|---|
| 数据库乐观锁 | UPDATE user_balance SET balance=balance-? WHERE user_id=? AND balance>=? | 简单可靠,原子性由数据库保证 | 并发太高时有少量更新失败 | 余额扣减 |
| 数据库行锁 | SELECT FOR UPDATE锁住产品记录 | 能阻止产品超卖 | 锁竞争较大 | 产品满标额度累加 |
| Redis分布式锁 | 用SETNX或Redisson锁住业务key | 灵活控制锁粒度 | 需要额外维护Redis,需处理锁过期 | 跨服务、复杂业务操作 |
最终我的实践方案是:余额扣减直接用带条件的SQL更新,一条语句完成"判断余额够不够+扣减",靠数据库的原子性保证不会超扣。产品额度累加用SELECT FOR UPDATE行锁,因为这是单条产品记录,锁开销很小。
最忌讳的做法是:先用SELECT查余额,在Java代码里判断够不够,再执行UPDATE。这三步在并发下必然出问题——两个请求同时查出余额100,都判断"够",然后都执行扣减,余额变成负数了。这条我重复强调无数次,但依然有人踩坑。
6.3 流水表是安全的最后一道防线
每个涉及余额变动的操作都必须写流水。流水表有两个字段特别关键:before_balance(变动前余额)和after_balance(变动后余额)。这两个字段让对账变得非常简单:
对某用户,按时间正序查所有流水,第一条的before_balance应该等于上一条的after_balance,最后一条after_balance必须等于当前余额。哪一条对不上,说明那一笔操作出了问题。
运维规范上,线上环境账户流水表只允许INSERT,不允许UPDATE和DELETE。任何需要修改流水的需求,直接拒绝或者走"冲正"流程——再写一笔反向流水把错误抹平,而不是直接改原来的记录。这一点和真实银行的记账原则完全一致。
6.4 防刷与风控:虚拟系统的"薅羊毛"同样疯狂
虚拟系统的羊毛党从来不会缺席。我见过最离谱的:有人写脚本批量注册账号,批量领新手礼包,然后互送礼物把虚拟币集中到一个小号上,再通过某些变现场景换成真实权益。
基础风控手段至少要有四层:
- 注册阶段:手机号验证码校验,同一设备ID限制注册数量。
- 礼物赠送阶段:同一接收人每天最多收取N次礼物,单日累计金额设上限。
- 接口层面:用Redis+Lua做限流,对投资、赠送、提现类接口做固定窗口或令牌桶限流。
- 福利活动阶段:新手礼包、活动奖励,发放后设置冷却时间(比如48小时内不能赠送出去)。
风控的本质不是拦截所有异常,而是让"薅羊毛的成本"大于"薅羊毛的收益"。各种限制规则一出,脚本批量操作基本就废了。
7. 踩坑实录:三个最容易翻车的位置
7.1 收益精度坑:double算出来的负数
之前有一版代码,收益计算用的是double类型。有一款产品年化收益率配置成3.65%,按日计息。某天跑批结算时,我扫了一眼日志,发现有一笔订单的收益金额是"-0.01"。
排查后发现,日利率0.0365/365 = 0.0001,但double在二进制里没法精确表示0.0001。累加100天、200天之后,误差被放大,某一天的收益就变成了负数。虽然单笔金额很小,但用户看到"收益-0.01元"会觉得这是个垃圾系统。
修复方案就是前面说的:全链路用BigDecimal,除法指定精度和舍入模式。修完之后,我把所有历史订单重新跑了一遍收益重算,光对账就花了两个星期。从那以后,我对精度问题的态度就是:没有BigDecimal,代码不许合入。
7.2 后台改了收益率,用户的历史订单全乱套
这个坑发生在版本快照机制上线之前。某天客户运营在后台批量修改了5个产品的收益率,本来只想改"未来产品",结果因为产品表只有一个当前参数,已经成交的订单结算时也读到了新收益率。好几个用户发现收益率变低了,客诉电话一个接一个。
当时我紧急补了几个小时的SQL脚本,把所有受影响的订单手动重算,再把参数改回去。那感觉就像在一堆烂账里找针。后面彻底学乖了,新增了版本快照表,用户下单时强制记录版本号。现在运营再怎么改产品参数,老订单分毫不受影响,新订单用新参数,逻辑清清楚楚。
7.3 礼物跨端赠送的时序问题
礼物赠送接口没有做幂等处理的时候,出现过一次很诡异的bug:用户手机端点击赠送,因为网络卡顿,前端自动重试了一次,后端两次请求都成功,用户被扣了两次钱,但好友只收到一份礼物。
问题出在赠送接口没有防重令牌。修复方案是:前端在发起赠送时生成一个UUID作为幂等键,后端收到请求后先去查赠送记录表,如果这个幂等键已经存在,直接返回成功结果,不重复扣款。这个方案不复杂,但能把重复请求挡得严严实实。
给所有"写操作"接口都加上幂等键,是我做了这套系统之后最大的习惯之一。尤其是涉及余额减少的操作,没有幂等保护等于让用户资金裸奔。
整套系统做下来,我最大的体会是:虚拟资产系统的核心不在于代码多么炫技,而在于每一分虚拟币都必须有据可查、有迹可循。产品可以自定义,但事务边界不能乱;礼物可以满天飞,但流水必须一笔不差。如果你也准备做或改一套类似的系统,建议先抓好两件事:一是把账户流水的表结构设计好,二是把并发下的扣款和额度变更机制想清楚,这两件事稳了,剩下的都是锦上添花。