简介:这份资源是捷微H5营销活动平台的Java微信砍价源码,基于P3-Weixin插件开发框架构建,面向需要独立部署微信H5营销活动的开发者与团队,尤其适合应对大用户量、高并发场景。平台采用插件模式开发,松耦合、易扩展,已内置微信砍价、摇一摇送卡券、九宫格、斧头帮砍价等多款活动插件,并附有对应效果图与开发文档。技术栈为SpringMvc + Mybatis + Velocity + Maven,插件通过pom.xml依赖方式引入,便于二次开发与功能叠加。压缩包约10.11MB,文件总数暂未提供,主要包含Java源码、配置文件及前端页面资源,可直接用于活动搭建与学习参考。目前已有178人学习关注,适合希望掌握微信H5活动开发、理解插件化架构的中高级Java开发者参考借鉴。
1. 从一场“砍到 0 元却发不出货”的事故说起
做微信 H5 营销活动的同学,大概率都碰过这样的场景:运营在后台配好一场“邀请好友砍价”活动,用户点进页面,拉三五个人帮忙砍,进度条一路掉到 0 元,结果点“立即领取”时页面转圈、库存没扣、订单没生成。前端看着一切正常,后端日志里却是一堆并发写冲突。这类问题不是前端 bug,而是砍价业务本身就是一个高并发、强一致、还要防刷的复合场景,用普通 CRUD 的思路去写,翻车是迟早的事。
这篇笔记围绕“Java 微信砍价源码”这个方向,把一套基于 P3-Weixin 插件开发框架、面向微信 H5 的营销活动平台拆开讲。核心要回答四件事:砍价活动的数据模型怎么设计、微信授权与分享链路怎么接、并发扣减和防刷怎么做、以及一套能跑起来的本地最小实现长什么样。适合正在做微信生态 H5 活动、或者准备接手一套现成营销平台源码的后端和全栈同学。下面所有代码都是可复现的最小骨架,不是某份现成仓库的原文,你照着改就能落到自己的项目里。
2. 砍价活动的数据模型与状态机:先把账算清楚
砍价这类活动的复杂度,八成来自“账没算清楚”。很多人一上来就写接口,结果写到一半发现:一个人能砍几次?砍过的钱能不能退?活动结束了未领取的订单怎么办?这些问题在数据模型阶段没定死,后面全是补丁。所以第一步不是写 Controller,而是把实体和状态机画明白。
2.1 四张核心表与字段取舍
一套能撑住砍价的库表,最少要这四张:活动主表、活动商品/规则表、砍价记录表、参与记录表。下面是我一般会用的字段结构,用 MySQL 举例。
-- 活动主表:一场砍价活动的全局配置 CREATE TABLE `act_bargain` ( `id` BIGINT NOT NULL AUTO_INCREMENT, `title` VARCHAR(64) NOT NULL COMMENT '活动名称', `start_time` DATETIME NOT NULL COMMENT '开始时间', `end_time` DATETIME NOT NULL COMMENT '结束时间', `status` TINYINT NOT NULL DEFAULT 0 COMMENT '0草稿 1进行中 2已结束', `stock_total` INT NOT NULL DEFAULT 0 COMMENT '总库存', `stock_left` INT NOT NULL DEFAULT 0 COMMENT '剩余库存', `per_user_limit` TINYINT NOT NULL DEFAULT 1 COMMENT '每人可发起次数', `help_per_user` TINYINT NOT NULL DEFAULT 1 COMMENT '每人可帮砍次数', `version` INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本', PRIMARY KEY (`id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- 砍价记录表:每一次“砍一刀”都落一条,是审计和防刷的依据 CREATE TABLE `act_bargain_log` ( `id` BIGINT NOT NULL AUTO_INCREMENT, `act_id` BIGINT NOT NULL, `join_id` BIGINT NOT NULL COMMENT '参与记录ID', `helper_openid` VARCHAR(64) NOT NULL COMMENT '帮砍人openid', `cut_amount` INT NOT NULL COMMENT '本次砍掉金额,单位分', `create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`), UNIQUE KEY `uk_join_helper` (`join_id`, `helper_openid`) COMMENT '同一人对同一单只能砍一次', KEY `idx_act_helper` (`act_id`, `helper_openid`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;这里有两个关键取舍。第一,金额一律用「分」存整数,绝不用 float 或 double,砍价涉及累加和比较,浮点误差会让“砍到 0 元”变成“砍到 0.0000001 元”,用户直接投诉。第二,uk_join_helper这个唯一索引是防重复帮砍的第一道闸,比在代码里查一遍再插入可靠得多——并发下代码查重必然漏。
参与记录表(act_bargain_join)我建议单独拆出来,字段包括:发起人 openid、当前已砍金额、目标金额、状态(进行中/已砍成/已领取/已过期)、创建与过期时间。状态机大致是:进行中 → 已砍成 → 已领取,以及任意时刻可进入已过期。把状态收敛到一张表上,后续所有判断都只认这一个字段,能省掉大量 if-else。
2.2 状态流转为什么必须落库而不是靠计算
有人图省事,不存状态,每次请求时用「已砍金额 >= 目标金额」现算。这在低并发下没问题,但砍价是典型的“临界点并发”:最后几刀可能同时到达,两个请求都读到“还差 1 分”,都判定砍成,于是生成两笔领取记录。血泪经验是,状态一定要落库,并且用带条件的 UPDATE 来推进,让数据库来保证只有一个请求能成功。
-- 推进到“已砍成”,只有当前还是进行中且金额达标才更新 UPDATE act_bargain_join SET status = 2, finish_time = NOW() WHERE id = #{joinId} AND status = 1 AND cut_amount >= target_amount;这条 SQL 的返回影响行数就是你的判据:返回 1 说明这次请求真正完成了状态流转,可以继续走发奖;返回 0 说明别人已经先一步改掉了,当前请求直接返回“已砍成,请勿重复操作”。这种「用影响行数做幂等」的写法,比先 SELECT 再判断再 UPDATE 稳得多,是砍价这类场景的标配。
3. 微信授权与分享链路:H5 里最容易玄学的一环
砍价活动的传播全靠分享,而分享又依赖微信授权拿到 openid。这一环出问题,表现往往很“玄学”:本地测好好的,线上一部分用户就是拿不到 openid,或者分享出去的卡片点开是空白。根因基本都在授权流程和域名配置上。
3.1 网页授权拿 openid 的两段式流程
微信 H5 拿用户信息走的是 OAuth 网页授权,分两步:先跳转到授权页拿 code,再用 code 换 access_token 和 openid。注意 scope 有两种,snsapi_base静默授权只拿 openid,snsapi_userinfo会弹确认框拿昵称头像。砍价活动一般用 base 就够了,弹框会掉转化。
// 第一步:拼接授权跳转地址,引导用户进入微信授权页 public String buildAuthUrl(String appId, String redirectUri, String state) { // redirectUri 必须是 URLEncoder 编码后的完整回调地址,且域名要在公众号后台配置 return "https://open.weixin.qq.com/connect/oauth2/authorize" + "?appid=" + appId + "&redirect_uri=" + urlEncode(redirectUri) + "&response_type=code" + "&scope=snsapi_base" + "&state=" + state + "#wechat_redirect"; }回调地址收到 code 后,服务端再调接口换 openid。这里有个坑:code 只能用一次,且 5 分钟过期。如果前端因为页面刷新重复提交同一个 code,第二次必然失败。我的做法是拿到 code 后先塞进 Redis 做一次性消费标记,重复的直接走缓存里的 openid。
// 第二步:用 code 换 openid,并做一次性消费保护 public String exchangeOpenid(String appId, String secret, String code) { String cacheKey = "wx:code:" + code; String cached = redis.get(cacheKey); if (cached != null) { return cached; // 同一个 code 重复请求,直接返回上次结果 } String url = "https://api.weixin.qq.com/sns/oauth2/access_token" + "?appid=" + appId + "&secret=" + secret + "&code=" + code + "&grant_type=authorization_code"; JSONObject resp = httpGetJson(url); if (resp.containsKey("errcode")) { throw new BizException("授权失败:" + resp.getInteger("errcode")); } String openid = resp.getString("openid"); redis.setex(cacheKey, 300, openid); // 缓存5分钟,覆盖code有效期 return openid; }参数上要盯紧三点:appId和secret必须和当前公众号一致;redirectUri的域名必须和公众号后台「网页授权域名」完全匹配,带 www 和不带 www 算两个域名;state用来防 CSRF,回跳时要校验。这三条任意一条不对,都会表现为“授权后拿不到 openid”。
3.2 分享签名与 JS-SDK 的配置顺序
分享卡片要自定义标题、图片、链接,就得用微信 JS-SDK,而 JS-SDK 依赖jsapi_ticket签名。签名的坑集中在两点:一是 URL 必须是当前页面的完整 URL 且不含#后面的部分;二是noncestr、timestamp、ticket参与签名的顺序和大小写必须严格按文档来,错一个字符签名就失效。
// 生成 JS-SDK 签名,注意参数拼接顺序不能变 public Map<String, String> signJsSdk(String jsapiTicket, String url) { String noncestr = UUID.randomUUID().toString().replace("-", ""); String timestamp = String.valueOf(System.currentTimeMillis() / 1000); // 拼接顺序:jsapi_ticket -> noncestr -> timestamp -> url,全部小写键名 String raw = "jsapi_ticket=" + jsapiTicket + "&noncestr=" + noncestr + "×tamp=" + timestamp + "&url=" + url; String signature = sha1Hex(raw); Map<String, String> result = new HashMap<>(); result.put("appId", appId); result.put("timestamp", timestamp); result.put("nonceStr", noncestr); result.put("signature", signature); return result; }jsapi_ticket有 7200 秒有效期且有调用频率限制,必须全局缓存,不能每次请求都去拉。我一般用 Redis 存,过期前 5 分钟异步刷新,避免整点集中失效导致签名集体失败。分享链接里带上发起人的标识(比如 joinId),好友点进来才能正确关联到“帮谁砍”,这个参数别用明文 openid,用 joinId 更安全。
4. 并发扣减与防刷:砍价真正的技术门槛
前面都是铺垫,砍价真正难的地方在并发和防刷。一场活动做起来,瞬时 QPS 可能几千,全砸在“砍一刀”这个接口上。这个接口要同时做四件事:校验活动有效性、校验帮砍资格、扣减金额、判断是否砍成。任何一步没做好,要么超卖,要么被刷。
4.1 用 Redis 原子操作扛住瞬时并发
金额累加和库存扣减这类操作,直接打 MySQL 行锁在几千 QPS 下会拖垮数据库。常见做法是把热点计数放 Redis,用原子命令做扣减,再异步落库。砍价金额累加可以用HINCRBY,帮砍资格用SETNX做去重。
// 帮砍核心逻辑:先 Redis 去重,再原子累加金额 public CutResult doCut(Long joinId, String helperOpenid, int cutAmount) { // 1. 帮砍去重:同一人对同一单只能砍一次,SETNX 保证原子 String dedupKey = "bargain:helper:" + joinId + ":" + helperOpenid; Boolean first = redis.setnx(dedupKey, "1"); if (Boolean.FALSE.equals(first)) { throw new BizException("您已经帮砍过了"); } redis.expire(dedupKey, 7 * 24 * 3600); // 兜底过期,防止 key 泄漏 // 2. 原子累加已砍金额,返回累加后的值 String amountKey = "bargain:amount:" + joinId; Long total = redis.hincrBy(amountKey, "cut", cutAmount); // 3. 读取目标金额,判断是否砍成 int target = getTargetAmount(joinId); boolean finished = total >= target; if (finished) { // 砍成后推进状态,这里再走一次带条件的 DB 更新做最终幂等 markFinished(joinId); } // 4. 砍价流水异步落库,不阻塞主链路 mq.send(new CutLogMessage(joinId, helperOpenid, cutAmount)); return new CutResult(total, finished); }逻辑说明:setnx是防重复帮砍的第一道闸,比数据库唯一索引更早拦截,减少无效请求打到 DB;hincrBy保证金额累加原子,不会出现两个请求读到同一个旧值再各自加;砍成判断放在 Redis 侧做快速反馈,但最终状态仍以 DB 的条件更新为准,Redis 只是加速层。参数上,dedupKey的过期时间要覆盖整个活动周期,设短了会出现“隔天又能砍一次”的漏洞。
4.2 防刷的三个层次
砍价活动最怕被脚本刷,一个人开几十个小号互相砍,活动预算瞬间被薅空。防刷要分层做,单靠一层都拦不住。
第一层是频率限制,用 Redis 对 openid 和 IP 双维度限流,比如单 openid 每分钟最多 10 次请求,单 IP 每分钟 60 次。第二层是行为校验,正常用户帮砍会有授权、有页面停留,脚本往往直接打接口,可以校验请求是否携带有效的授权态。第三层是关系链分析,同一批 openid 短时间内互相帮砍、且集中在少数 IP,基本可以判定为团伙,直接进风控名单。
// 基于 Redis 的滑动窗口限流,key 维度可传 openid 或 ip public boolean allow(String dimension, String key, int limit, int windowSec) { String redisKey = "rate:" + dimension + ":" + key; Long count = redis.incr(redisKey); if (count == 1) { redis.expire(redisKey, windowSec); // 第一次访问设置窗口 } return count <= limit; }这套限流是固定窗口,实现简单,够用。如果对边界精度要求高,可以换 ZSet 做滑动窗口,代价是内存占用更高。防刷规则一定要可配置,活动期间运营随时要调阈值,硬编码在代码里改一次发一次版,来不及。
5. 避坑与排查:那些让活动半夜挂掉的细节
这一章是我踩过的坑合集,每条都按「现象 → 原因 → 解决」写,你对照排查能省不少时间。
现象一:活动刚开始几分钟,接口大面积超时。原因通常是砍价流水同步写库,瞬时写入把 MySQL 打满。解决是把流水改成消息队列异步落库,主链路只写 Redis,DB 只承担最终状态更新。注意异步落库要有补偿机制,消息丢了要能重放。
现象二:用户反馈“砍到 0 元但领不了”。多半是状态流转和发奖没做成一个整体。Redis 里金额到了,但 DB 状态更新失败,或者发奖接口超时。解决是把「标记砍成」和「生成领取资格」放在同一个事务里,或者用本地消息表保证最终一致,别让两个动作各写各的。
现象三:分享出去的卡片,好友点开提示“活动已结束”。常见原因是分享链接里带了活动 ID,但好友打开时命中了缓存里的旧活动状态。解决是活动状态判断不要走缓存,或者缓存过期时间设短一点,活动开始/结束这种临界时刻直接查库。
现象四:同一批小号把预算薅空。前面防刷章节讲过,根因是只做了单维度限流。解决是叠加关系链分析,对短时间内高频互砍的 openid 集合做聚类,命中就冻结帮砍资格。规则要留人工审核入口,误伤正常用户能申诉。
现象五:微信授权偶发失败,日志里 errcode 是 40029。这是 code 无效,通常是 code 被重复使用或已过期。解决就是前面说的,code 拿到后立刻做一次性消费标记,重复请求走缓存。另外注意服务器时间要和标准时间同步,时间偏差过大也会导致签名和授权异常。
6. 本地跑通一套最小砍价链路:从建表到压测
前面讲了原理和坑,这一章落到能动手的最小实现。目标是在本地把「发起砍价 → 好友帮砍 → 砍成领取」这条链路跑通,并用压测验证并发下的正确性。不需要完整平台,一个 Spring Boot 工程加 Redis、MySQL 就够。
6.1 最小工程结构与依赖
工程分四层:controller 放接口,service 放业务,mapper 放数据访问,common 放工具和常量。依赖上核心是 Spring Boot Web、MyBatis、Redis 客户端,微信相关的 HTTP 调用用 OkHttp 就够,不用引重型 SDK。
<!-- pom.xml 关键依赖,版本按你项目统一管理 --> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>org.mybatis.spring.boot</groupId> <artifactId>mybatis-spring-boot-starter</artifactId> <version>2.3.1</version> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-data-redis</artifactId> </dependency>配置上,Redis 建议单独起一个实例,别和业务缓存混用,砍价的 key 量大且需要独立淘汰策略。MySQL 用 InnoDB,砍价相关表都加上唯一索引,前面建表语句里的uk_join_helper别省。
6.2 发起砍价与帮砍接口的最小实现
发起接口负责创建参与记录,帮砍接口就是第 4 章那段核心逻辑的落地。这里补一个发起接口,重点是把活动校验和库存预占做掉。
@PostMapping("/bargain/start") public Result start(@RequestParam Long actId, @RequestParam String openid) { // 1. 校验活动是否在有效期内且状态为进行中 ActBargain act = actMapper.selectById(actId); if (act == null || act.getStatus() != 1) { return Result.fail("活动不存在或已结束"); } long now = System.currentTimeMillis(); if (now < act.getStartTime().getTime() || now > act.getEndTime().getTime()) { return Result.fail("不在活动时间内"); } // 2. 校验每人发起次数上限 int joined = joinMapper.countByUser(actId, openid); if (joined >= act.getPerUserLimit()) { return Result.fail("您已发起过,不能重复发起"); } // 3. 预占库存,用带条件的 UPDATE 防止超卖 int affected = actMapper.decrStock(actId); if (affected == 0) { return Result.fail("库存不足"); } // 4. 创建参与记录,初始已砍金额为 0 ActBargainJoin join = new ActBargainJoin(); join.setActId(actId); join.setOpenid(openid); join.setCutAmount(0); join.setTargetAmount(act.getTargetAmount()); join.setStatus(1); joinMapper.insert(join); return Result.ok(join.getId()); }decrStock对应的 SQL 是UPDATE act_bargain SET stock_left = stock_left - 1 WHERE id = #{id} AND stock_left > 0,靠影响行数判断是否抢到库存,这是防超卖的标准写法。发起成功后返回 joinId,前端拿它拼分享链接。
6.3 用 JMeter 验证并发正确性
链路跑通不代表并发下正确,必须压测。用 JMeter 起 200 个线程,对同一个 joinId 并发调帮砍接口,观察三件事:Redis 里的金额是否等于所有成功请求的累加值、DB 里的砍价流水条数是否和成功请求数一致、有没有出现重复帮砍。
# 用 ab 快速压一把帮砍接口,-n 总请求数,-c 并发数 ab -n 2000 -c 200 -p cut_body.json -T application/json \ "http://localhost:8080/bargain/cut"压完对账:如果 Redis 金额大于流水累加值,说明有请求在异步落库前丢了,要查消息队列;如果流水条数大于成功响应数,说明幂等没做好,重复请求被当成新请求处理了。压测时把日志级别调到 WARN,避免日志 IO 影响结果。这套验证跑通,你才算真正把砍价链路做扎实了。
我自己的习惯是,任何营销活动上线前,先拿脚本模拟一批“恶意用户”跑一遍,看防刷规则能不能拦住,再拿正常流程跑一遍看体验。砍价这类活动,技术上的坑基本都能靠提前压测和分层防刷避开,真正难的是把状态和账算清楚。希望帮到你。
本文还有配套的精品资源,点击获取