资讯动态

微信红包系统设计与实现:从状态机到并发扣减

发布时间:2026/10/15 20:00:29 来源:尧图企业网站定制
简介这是一份基于HTML、CSS和JavaScript实现的仿微信红包前端源码包面向初中级前端开发者适合用于学习交互设计、DOM操作与CSS动画。包内共17个文件包含1个HTML页面、1个CSS样式表、4个JavaScript脚本以及多张PNG/JPG/GIF图片素材总大小约230KB。JS脚本中集成了jQuery、Swiper及红包随机分配逻辑能够实现金额随机拆分、红包飘落动画和打开特效图片素材则覆盖红包背景、领取按钮、分享图标等核心界面元素HTML/CSS/JS分离的目录结构也便于按模块拆解学习。该资源已有936人学习下载通过源码可完整还原微信红包的展示、领取与转发诱导流程包括用户输入校验、随机金额分配、转发弹窗等适合作为前端综合案例练习或课程设计参考。1. 仿微信红包这份源码包解决的不只是「点开有金额」聊到仿微信红包这类项目很多人的第一反应是「不就是发个红包、点开弹金额嘛」真把代码跑起来才发现界面能画出来但红包发出去没人领、领完余额对不上、过期没退回问题一串接一串。这份「仿微信红包 1」的源码包把完整收发链路「发红包、抢红包、拆红包、查记录」串在一个工程里界面交互、金额随机、倒计时、并发扣减都给出了可运行实现。适合两类人想练手完整 App 流程的初学者以及需要快速搭一个红包演示 Demo 用来做二次开发的从业者。下面按我拆包的习惯从状态设计一路讲到服务端一致性最后把最容易翻车的几个坑单独拎出来说。2. 红包状态机与随机算法二倍均值法为什么比线段切割更稳2.1 一张红包的状态流转待领取、已抢完、已过期怎么切换红包能稳定跑起来前提是把状态定义清楚。这个资源包最值得先读的不是界面代码而是状态字段的设计。红包在生命周期里必须经历这几个状态待领取、可抢、已抢完、已过期。聊天气泡里的卡片样式、能不能点开、拆开后显示什么全部由状态驱动。状态客户端表现服务端约束待领取红包卡片亮色可点击剩余金额 0未过期已抢完卡片显示「已抢完」置灰剩余金额 0已过期卡片退回提示不可领超过有效期剩余金额退回原路已领取打开弹窗显示金额该用户领过返回个人领取记录项目里常见做法是给红包表设计一个status整型字段0 待领取1 已抢完2 已过期3 已领取。客户端刷新聊天列表时通过状态决定卡片文案和点击行为。有一点需要注意状态切换不能只由客户端触发。我见过不少仿红包 Demo多人同抢一个红包时A 客户端显示还有 0.01 元B 客户端也显示还有 0.01 元两个人同时点开最后只有一个人领到。原因就是状态判断放在客户端内存里服务端没有做原子扣减。你翻这个资源包时优先看服务端有没有把「查状态」和「扣金额」放在同一个事务里没有的话说明这个资源只适合学界面不适合直接接生产。2.2 金额随机分配二倍均值法与线段切割法的实战取舍拆红包的随机金额算法资源包里有两种实现可切换注释也标得清楚。默认用的是二倍均值法这也是目前各种红包 Demo 里最稳的做法。核心逻辑是每次从剩余金额的[0.01, 剩余均值 * 2]区间取随机值随机值保留到分。import random def split_red_packet(total_cents, num): 二倍均值法拆分红包 :param total_cents: 总金额单位分 :param num: 红包个数 :return: 每个红包的金额列表单位分 if num 0 or total_cents num: raise ValueError(参数不合法) remain total_cents result [] for i in range(num - 1): # 剩余均值 剩余金额 / 剩余个数 avg remain / (num - i) # 随机上限 均值 * 2下限 1 分 upper int(avg * 2) # 最后一个红包直接取剩余金额避免随机误差 money random.randint(1, upper) if upper 1 else 1 result.append(money) remain - money result.append(remain) return result逻辑说明每一次循环都根据「当前剩余金额」和「剩余红包个数」重新计算均值再乘 2 作为随机上限。这里的upper要转成整数再随机因为randint只接受整数区间。最后一个红包不参与随机直接把剩余金额塞进去保证总额守恒。参数说明total_cents是总金额单位是「分」不是「元」。这一点资源包的注释里特别强调过——用浮点数double存元会带来精度误差比如0.1 0.2在计算机里不等于0.3。所有金额计算全部用整数分展示层再统一除以 100 转成元。另一个备选实现是线段切割法把总金额映射成一条线段随机生成num - 1个切点相邻切点之差就是红包金额。这个算法分配结果波动大容易出现一个红包金额特别大、其余全是几分钱的情况。微信红包实际体验你也知道虽然也讲手气但不会极端到让人不想玩。所以做仿红包项目默认给二倍均值就对了。如果你看到资源里两种都写了切线段切割法时记得在 UI 上预判金额波动某些活动场景下极端金额反而会带来用户投诉。2.3 倒计时到期与聊天归属客户端必须自己兜底的三个场景红包有效期通常设定为 24 小时但聊天列表不会一直停在当前会话所以客户端要做三件事一是进入会话时把未读的红包消息状态刷新一遍二是在页面停留期间用本地定时器每秒检查剩余时间到期自动把卡片置灰三是退出会话再回来时重新从服务端拉一次状态不能依赖本地记录。资源包里用一个CountDownTimer处理倒计时onTick 里更新卡片剩余时间文案onFinish 里调接口刷新状态。这里有个小坑列表滑动时卡片会复用bindViewHolder里必须重置定时器。常见做法是把「剩余毫秒数」存在数据模型里绑定卡片时才start滑动离开就cancel。不做这步的话你滑动列表会看到上一行的倒计时间接显示在下一行上这是很典型的复现翻车现场。3. Android 端落地红包卡片、拆开动画与剩余金额刷新3.1 聊天气泡里的红包卡片一个可复用的 XML 布局资源包的聊天页里红包是以消息气泡形式插入的。它不是一个Button也不是直接跳转账房页而是一个独立的RelativeLayout卡片左侧红色图标、中间标题文案、右侧「」箭头。直接看布局文件结构大致如下RelativeLayout android:idid/rl_red_bubble android:layout_widthwrap_content android:layout_heightwrap_content android:backgrounddrawable/bg_red_bubble android:padding12dp ImageView android:idid/iv_red_icon android:layout_width32dp android:layout_height32dp android:layout_alignParentStarttrue android:srcdrawable/ic_red_packet / LinearLayout android:idid/ll_red_text android:layout_widthwrap_content android:layout_heightwrap_content android:layout_marginStart8dp android:layout_toEndOfid/iv_red_icon android:orientationvertical TextView android:idid/tv_red_title android:layout_widthwrap_content android:layout_heightwrap_content android:text恭喜发财 android:textColor#FF4D4F android:textSize15sp / TextView android:idid/tv_red_subtitle android:layout_widthwrap_content android:layout_heightwrap_content android:text领取红包 android:textColor#999999 android:textSize12sp / /LinearLayout ImageView android:idid/iv_red_arrow android:layout_width16dp android:layout_height16dp android:layout_alignParentEndtrue android:layout_centerVerticaltrue android:layout_marginStart8dp android:srcdrawable/ic_arrow_right / /RelativeLayout布局逻辑说明RelativeLayout做根容器图标靠左文字块在图标右侧箭头图标对齐父容器右边缘。这样在聊天列表里无论消息宽度怎么变三个元素始终对齐。bg_red_bubble是圆角矩形背景资源包里给了两个 selector 状态——正常态亮红已抢完态灰色。你二次开发时只需要改这两个 drawable 的着色就能快速换主题色。参数说明padding12dp控制卡片内边距图标固定 32dp文字字号 15sp 主标题、12sp 副标题。这些都和真机上手感有关太小不好点太大在聊天流里又突兀。这个尺寸是从微信实际卡片比例推算来的可以直接沿用不用再调。3.2 拆开弹窗与领取回调Activity 之间的状态传递怎么设计点击红包卡片后资源包的处理方式是弹出一个DialogFragment。为什么不直接用全屏 Activity因为聊天页需要保留背景半透明弹窗更接近真实客户端体验。DialogFragment里加载红包详情点击「开」按钮后调服务端接口领取再根据返回结果展示「已领取 xx 元」还是「手慢了」。class RedPacketDialogFragment : DialogFragment() { private var redPacketId: String private var onOpened: ((OpenResult) - Unit)? null override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) arguments?.let { redPacketId it.getString(red_packet_id, ) } } override fun onCreateView( inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle? ): View? { return inflater.inflate(R.layout.dialog_red_packet, container, false) } fun open(onOpened: (OpenResult) - Unit) { // 调用服务端拆红包接口 val params HashMapString, String() params[red_packet_id] redPacketId params[user_id] UserManager.getUserId() // 关键传入端侧生成的 uuid服务端用这个做幂等 params[request_id] UUID.randomUUID().toString() HttpClient.post(/red_packet/open, params, object : Callback { override fun onSuccess(data: String) { // 解析返回金额与状态 val result Gson().fromJson(data, OpenResult::class.java) activity?.runOnUiThread { showAmount(result.amount) onOpened?.invoke(result) } } override fun onFail(code: Int, msg: String) { activity?.runOnUiThread { if (code 410) { showEmptyHint(手慢了红包派完了) } else { showEmptyHint(msg) } } } }) } }逻辑说明request_id是每次点「开」按钮时新生成的 UUID服务端拿它做幂等判断——同一个用户对同一个红包只有第一个请求会真正扣减金额重复请求直接返回第一次的结果不会重复扣钱。这是仿红包项目里极易漏掉的一环后面避坑章节再展开。回调里的showAmount负责弹窗内的数字滚动动画onOpened把结果回传给聊天页让聊天页刷新列表。参数说明接口返回码 410 表示红包已被抢完这是资源包里自定义的错误码。你接自己后端时建议统一约定 4xx 表示领取被拒、2xx 表示成功。弹窗关闭时记得onOpened只回调一次避免旋转屏幕等配置变更导致重复触发刷新。3.3 剩余金额与手气最佳列表刷新的正确姿势抢完红包后聊天列表那一条消息要原位刷新不能整页重拉。资源包用一个RecyclerView承载消息列表notifyItemChanged(position)只更新红包那一条。刷新内容包括三处卡片副标题从「领取红包」变为「已领取」卡片背景从亮红置灰详情页里的「手气最佳」标记更新。fun updateRedPacketItem(position: Int) { val item messageList[position] as RedPacketMessage val newStatus when { item.leftAmount 0 - RedPacketStatus.FINISHED item.expired - RedPacketStatus.EXPIRED else - RedPacketStatus.AVAILABLE } val newItem item.copy(status newStatus) messageList[position] newItem adapter.notifyItemChanged(position, newItem) }逻辑说明newStatus的三态判断都基于数据模型字段不直接改 UI。copy是 Kotlin 数据类自带的方法只替换status字段其他字段原样保留。notifyItemChanged第二个参数是 payload配合 adapter 的onBindViewHolder里对 payload 的判断可以只刷新副标题背景不重建整条气泡。参数说明leftAmount 0是判断红包被抢完的标准不是「剩余个数 0」。因为有些红包发出来没人领剩余金额不等于 0 但已经过期此时状态应该切到EXPIRED。我一般还会在数据模型里加一个myReceivedAmount字段用于展示「我已领取 xx 元」这个字段只在当前用户领取后才有值避免聊天页每次刷新都重查历史记录。4. 服务端接口与数据一致性并发抢红包不出现负数4.1 拆红包接口设计幂等键与领取状态一起校验服务端是这份资源包里比较见功底的部分。拆红包接口如果只做「金额够就扣」的逻辑并发下面必出负数。资源包定义的接口签名是POST /red_packet/open请求体包含red_packet_id、user_id、request_id三个核心字段。服务端处理顺序是先查红包是否存在再查 request_id 是否处理过再查领取时间是否过期最后扣减金额并插入领取流水。Transactional(rollbackFor Exception.class) public OpenResult open(OpenRequest request) { // 1. 幂等检查同一个 requestId 只处理一次 RedPacketLog existLog logMapper.selectByRequestId(request.getRequestId()); if (existLog ! null) { return OpenResult.build(existLog.getAmount(), existLog.getStatus()); } // 2. 查红包主记录锁定该行防止并发改同一行 RedPacket redPacket redPacketMapper.selectByIdForUpdate(request.getRedPacketId()); if (redPacket null) { throw new BizException(404, 红包不存在); } if (redPacket.getExpireTime().before(new Date())) { throw new BizException(410, 红包已过期); } if (redPacket.getLeftAmount() 0) { throw new BizException(410, 手慢了红包派完了); } // 3. 查当前用户是否已经领过 RedPacketLog userLog logMapper.selectByUserIdAndPacketId( request.getUserId(), request.getRedPacketId()); if (userLog ! null) { throw new BizException(409, 你已经领过这个红包); } return doOpen(redPacket, request); }逻辑说明selectByIdForUpdate是数据库行级锁同一时刻只有一个请求能读到该行并执行扣减。幂等检查放在最前面request_id唯一索引保证同一次客户端请求最多被处理一次。这里我一般会再补一个逻辑selectByUserIdAndPacketId要加联合唯一索引防止同一个用户并发点击两次「开」时两个请求同时通过幂等检查然后各领一次。没有这层索引就得靠userId redPacketId的唯一约束兜底。参数说明410 是「手慢了」的标准错误码409 是「重复领取」404 是「红包不存在」。这三个错误码在客户端弹窗里分别对应不同文案所以服务端不能一律返回 500。Transactional保证「扣减金额 插入流水」要么都成功要么都回滚。4.2 并发扣减乐观锁兜底Redis 预扣提速如果只是学习 DemoselectByIdForUpdate就够了。但资源包在进阶模块里还写了乐观锁方案适合你把它接到有真实并发的环境里看。核心 SQL 是更新时带条件判断剩余金额UPDATE red_packet SET left_amount left_amount - #{amount}, left_count left_count - 1 WHERE red_packet_id #{redPacketId} AND left_amount #{amount} AND left_count 0逻辑说明WHERE条件里带left_amount #{amount}和left_count 0数据库在更新时会检查条件是否满足不满足则影响行数为 0。应用层根据updateCount 1判断扣减是否成功失败就返回「手慢了」。这是乐观锁思路——不锁行靠条件判断保证一致性。相比悲观锁它的优势是读多写少时不阻塞读操作劣势是并发写冲突时需要重试或直接返回失败。参数说明left_count 0这个条件不能省。只判断金额够不够会出现「大额红包被最后一个人以 0 元领取」的逻辑漏洞——极端情况下金额剩余 0.01 元但个数标记还是 1金额和个数必须同时满足。扣减字段用left_amount和left_count两个列的联合条件比只用一个更严谨。资源包里 Redis 预扣方案是把剩余金额缓存到 Redis请求先进缓存扣减异步落库适合高并发场景但丢数据风险随之而来对账逻辑必须跟上。我自己的习惯是Demo 直接上数据库乐观锁学习成本低也不容易出现「缓存扣了、数据库没扣」这种玄学问题。4.3 红包流水表与对账用 SQL 验证总金额守恒这份资源的数据库脚本里红包流水表red_packet_log是核心每次领取、退回都写一条记录。表结构中有五个关键字段log_id、red_packet_id、user_id、amount、type。type为 1 表示领取2 表示过期退回。对账时验证逻辑用一条 SQL 就能查清楚SELECT red_packet_id, SUM(CASE WHEN type 1 THEN amount ELSE 0 END) AS total_received, SUM(CASE WHEN type 2 THEN amount ELSE 0 END) AS total_returned, total_amount FROM red_packet_log JOIN red_packet ON red_packet.red_packet_id red_packet_log.red_packet_id GROUP BY red_packet_id HAVING total_received total_returned ! total_amount;逻辑说明HAVING子句筛选出「已领取 已退回」与原始总金额不一致的记录只要这条 SQL 能查出数据就说明有红包账目不平。常见的不平原因是扣减金额成功但插入流水失败或者退回操作漏了写type2的流水。资源包的运维脚本里通常还会配一条定时任务每天早上把这条 SQL 的结果发到告警群。参数说明type字段的值要严格控制不能在代码里用魔法数字。我一般会在 Java 枚举里定义RECEIVED(1)和RETURNED(2)在写流水的代码里强制走枚举。另外流水表要给red_packet_id user_id建联合索引对账查询用不到它但客户端拉取「我抢到的红包」列表时这个索引能省下全表扫描。5. 避坑记录仿红包项目最容易翻车的五个细节5.1 同一个用户能领两次金额翻倍现象用同一账号连续快速点击「开」按钮弹窗先显示「已领取」刷新后又出现一条新的领取记录金额双倍入账。原因接口没有做幂等也没有加「用户 红包」唯一约束。两个并发请求同时通过「是否已领取」检查都进入扣减逻辑。解决一是发请求时带request_id服务端建唯一索引二是给red_packet_log表加user_id red_packet_id联合唯一索引。两手都做单靠一个不够。5.2 剩余金额变负数UI 显示「已抢 -0.01 元」现象多人同时抢红包最后一个请求在剩余金额只有 0.01 元时仍然成功扣完后数据库出现负数。原因扣减 SQL 的WHERE条件没有带left_amount #{amount}应用层只查了一次剩余金额没有在更新时做二次校验。解决更新语句必须带金额和个数条件影响行数为 0 时直接返回失败。删除「先查后扣」的陈旧写法。5.3 倒计时结束还能打开弹窗现象红包显示已过期但点击卡片仍然能弹窗甚至能领到金额。原因客户端在onFinish里只改了本地状态没有同步服务端服务端也没有在拆红包时校验过期时间。解决倒计时onFinish必须调一次状态刷新接口服务端open方法里增加expire_time判断两边都兜底。只改一边必出问题。5.4 「手气最佳」显示错人金额对不上现象红包详情页里手气最佳显示另一个人但这个人领的金额不是最大。原因详情页排序用了double比较金额从分转元时出现0.3 0.29被误判为相等排序不稳定。解决所有金额比较在「分」粒度上用BigDecimal或long做排序 SQL 里直接按amount字段分排不转元。5.5 过期的红包没有退回总账不平现象对账 SQL 查出total_received total_returned total_amount过期红包的剩余金额凭空消失。原因定时任务只扫描了「过期」状态但没有把剩余金额写回用户余额也没有生成type2的流水记录。解决过期退回必须放在事务里更新红包状态为「已过期」 插入退回流水 用户余额增加三步同生共死。事务范围别只包住其中两步。6. 跑通之后怎么验证十分钟自检清单与二次开发方向资源跑起来不是终点验证才算数。我每次拿到一份仿红包源码会固定走一遍边界场景排除掉「看起来能跑实际经不起并发锤」的问题。先给一份可以照抄的自检清单每一步都有明确的预期结果。场景操作预期结果单人抢全包发起 1 个红包自己领金额等于总金额状态切「已抢完」多人抢完5 人抢 3 个红包第 4 人提示「手慢了」金额守恒重复领取同一账号抢 2 次第二次被拒流水表只有一条记录过期退回设置有效期 1 分钟不领余额增加退回金额流水有 type2金额精度总金额 0.01 元拆 1 个显示 0.01 元不出现 0.009并发同抢模拟 10 线程抢最后 1 个只有 1 人成功无负数验证金额精度这一步我一般写一个小工具类随机生成 100 个红包断言sum(result) total跑一次就知道算法有没有边界遗漏。你在资源包里大概率能找到一个叫RedPacketTest的单元测试类优先跑它。二次开发的方向上我建议先做三件事第一把弹窗动画换成自己的品牌红包样式背景图、开启动效都在drawable目录下替换不影响逻辑第二把金额单位从分扩展到支持「随机祝福语」在红包表加一个blessing字段即可第三把服务端的数据库连接池参数调大压测一次 1000 并发。这三步做完你对这个项目的理解基本就到位了。最后说一个我自己的教训有一次给某公司交付活动红包模块线上出现短暂金额负数告警定位发现是定时任务在跑退回时没关闭旧的事务连接导致同一行数据被多个事务更新。从那以后我每次拿到仿红包项目都会先检查事务边界和数据库连接释放再去看业务逻辑。这套验证习惯救了我很多次希望帮到你。本文还有配套的精品资源点击获取

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑