Spring Boot 3.5 + Java 25 + Cloud Native 系列(六):插件化活动引擎
目录
在之前的文章中,我们看了 API Gateway、BFF 聚合、领域服务和事件驱动架构。本文继续整理 Shop Platform 里我觉得比较有意思的一块——activity-service 的插件化游戏引擎。
📦 本文基于的完整项目源码:https://github.com/meirongdev/shop
上一篇:(五)事件驱动架构
2026-04 实践更新 当前主线代码里,
RedEnvelopePlugin和AntiCheatGuard都已经从StringRedisTemplate统一迁到RedissonClient;本文讨论的 Lua 原子性和反作弊思路不变,但 Redis 客户端层已经与早期版本不同。
为什么需要活动引擎#
电商平台经常需要举办营销活动来吸引用户:
- 砸金蛋/抽奖:用户消耗次数参与,随机获得奖品
- 抢红包:限时限量,用户拼手速抢金额
- 集卡:集齐一套卡片兑换大奖
- 虚拟养成:每天浇水/喂养,成熟后收获奖品
如果每个活动都独立开发一个新服务:
- 代码大量重复(参与记录、奖品发放、反作弊逻辑)
- 每次上线新活动都要走完整的发版流程
- 活动下线后代码变成死代码
这次项目里我更倾向用插件化引擎:一个服务、一套运行框架,新增活动时先实现一个插件接口,再让 Spring 负责发现和注册。
GamePlugin SPI 接口设计#
核心接口#
public interface GamePlugin {
/** 声明支持的游戏类型 */
GameType supportedType();
/** 游戏激活时的初始化(如生成红包金额) */
default void initialize(ActivityGame game) {}
/** 核心玩法:参与一次 */
ParticipateResult participate(ParticipateContext ctx);
/** 游戏结束时的清理(如 Redis 数据回收) */
default void settle(ActivityGame game) {}
/** 扩展表前缀(插件自带数据表时使用) */
default Optional<String> extensionTablePrefix() {
return Optional.empty();
}
}
生命周期:
插件注册#
@Component
public class GamePluginRegistry {
private final Map<GameType, GamePlugin> plugins;
public GamePluginRegistry(List<GamePlugin> pluginList) {
this.plugins = pluginList.stream()
.collect(Collectors.toMap(
GamePlugin::supportedType,
Function.identity()));
}
public GamePlugin getPlugin(GameType type) { return plugins.get(type); }
public GamePlugin requirePlugin(GameType type) { ... }
}
Spring 自动注入所有 GamePlugin Bean,按 GameType 索引。新增游戏类型只需要:
- 创建新的
@Component实现GamePlugin接口 - 在
GameType枚举中添加类型 - 不需要修改引擎核心编排代码;至于是否需要重启或额外部署,仍取决于最终交付方式
GameEngine 编排#
@Service
public class GameEngine {
@Transactional
public ParticipateResult participate(String gameId, String buyerId, String payload,
String ipAddress, String deviceFingerprint) {
ActivityGame game = gameRepository.findById(gameId)
.orElseThrow(() -> new BusinessException(CommonErrorCode.NOT_FOUND, "Game not found"));
if (!game.isActive()) {
throw new BusinessException(CommonErrorCode.VALIDATION_ERROR, "Game is not active");
}
antiCheatGuard.check(game, buyerId, ipAddress, deviceFingerprint);
validateLimits(game, buyerId);
ParticipateResult result = pluginRegistry.requirePlugin(game.getType())
.participate(new ParticipateContext(
gameId, game.getType(), buyerId, null,
game.getConfig(), payload, Instant.now()));
ActivityParticipation participation =
new ActivityParticipation(UUID.randomUUID().toString(), gameId, game.getType(), buyerId);
if (result.win()) {
participation.markWin(result.prizeId(), buildPrizeSnapshot(result));
if (result.prizeType() == PrizeType.CARD || result.prizeType() == PrizeType.PROGRESS) {
participation.markRewardSkipped();
}
} else {
participation.markMiss();
}
participationRepository.save(participation);
return result;
}
}
引擎本身不关心具体游戏规则,只负责通用的流程控制:校验 → 反作弊 → 次数限制 → 插件派发 → 结果记录。
四种插件实现#
下面的代码片段做了少量归纳,只保留本文要讨论的关键路径;字段校验、日志和异常细节请以仓库源码为准。
1. InstantLotteryPlugin:砸金蛋/抽奖#
@Component
public class InstantLotteryPlugin implements GamePlugin {
@Override
public GameType supportedType() {
return GameType.INSTANT_LOTTERY;
}
@Override
public ParticipateResult participate(ParticipateContext ctx) {
// 从 activity_reward_prize 表读取奖品列表(按 displayOrder 排序)
List<ActivityRewardPrize> prizes = prizeRepository.findByGameIdOrderByDisplayOrderAsc(ctx.gameId());
// 加权随机抽取
ActivityRewardPrize prize = weightedRandom(prizes);
// 原子扣减奖品库存
if (!prize.decrementStock()) {
return ParticipateResult.miss("奖品已售罄");
}
return ParticipateResult.win(
prize.getId(), prize.getName(),
prize.getType(), prize.getValue(),
"golden-egg"); // 动画提示
}
}
加权随机:每个奖品有 probability 字段,引擎按比例随机选择。如果所有奖品库存耗尽,返回明确的 NOTHING 奖品(未中奖),但前端仍然播放动画。
2. RedEnvelopePlugin:抢红包#
这是四种插件中最复杂的一个,涉及并发抢红包的原子性保证。
初始化——二均值算法生成红包金额:
@Override
public void initialize(ActivityGame game) {
// 解析游戏配置:总金额、红包数量
EnvelopeConfig config = parseConfig(game.getConfig());
// 二均值算法生成红包金额
List<BigDecimal> amounts = generatePackets(
config.totalAmount(), config.packetCount());
// 存入 Redis List
String key = "re:packets:" + game.getId();
redisTemplate.opsForList().rightPushAll(key,
amounts.stream().map(BigDecimal::toPlainString).toList());
}
当前实现采用的是一种常见的“剩余均值上界”分包思路:每次金额落在 [0.01, 剩余均值 × 2] 的可行范围内,再在最后打散顺序。它的目标是:
- 所有人抢到的金额之和 = 总金额
- 每个人至少抢到 0.01 元
- 前面几次不至于太早把金额全部拿空,同时保留一定随机性
参与——Lua 脚本原子抢:
@Override
public ParticipateResult participate(ParticipateContext ctx) {
String packetsKey = "re:packets:" + ctx.gameId();
String claimsKey = "re:claims:" + ctx.gameId();
// Lua 脚本
String script = """
-- 检查是否已抢过
if redis.call('HEXISTS', KEYS[2], ARGV[1]) == 1 then
return {-1, redis.call('HGET', KEYS[2], ARGV[1])}
end
-- 抢一个红包
local amount = redis.call('LPOP', KEYS[1])
if not amount then
return {-2}
end
-- 记录已抢
redis.call('HSET', KEYS[2], ARGV[1], amount)
return {1, amount}
""";
List<Object> result = redisTemplate.execute(
new DefaultRedisScript<>(script, List.class),
List.of(packetsKey, claimsKey),
ctx.buyerId());
return switch (Integer.parseInt(result.get(0).toString())) {
case -1 -> ParticipateResult.miss("你已经抢过了");
case -2 -> ParticipateResult.miss("红包已被抢完");
default -> ParticipateResult.win(
ctx.gameId(), "红包",
PrizeType.POINTS, new BigDecimal(result.get(1).toString()),
"red-envelope");
};
}
为什么这里用了 Lua 脚本:
在纯 Java 中,抢红包的逻辑是"检查是否抢过 → LPOP 取金额 → 记录已抢"。这三个操作如果不在一个原子操作中,可能出现:
线程 A: HEXISTS → false(没抢过)
线程 B: HEXISTS → false(没抢过)
线程 A: LPOP → 抢到 5 元
线程 B: LPOP → 也抢到 5 元(同一个红包被两个人抢!)
Lua 脚本在 Redis 中是原子执行的,整个脚本不会被其他命令打断,可以把这类并发窗口收敛到脚本里,避免多个实例同时修改时出现错乱。
结算——Redis 与 DB 对账:
@Override
public void settle(ActivityGame game) {
// 对比 Redis 中已抢人数和 DB 中参与记录数
Long redisCount = redisTemplate.opsForHash().size("re:claims:" + game.getId());
long dbCount = participationRepository.countWinningParticipationsByGameId(game.getId());
if (!redisCount.equals(dbCount)) {
log.warn("Red envelope reconciliation mismatch: redis={}, db={}",
redisCount, dbCount);
}
// 清理 Redis 临时数据
redisTemplate.delete(List.of(
"re:packets:" + game.getId(),
"re:claims:" + game.getId()));
}
3. CollectCardPlugin:集卡#
@Component
public class CollectCardPlugin implements GamePlugin {
@Override
public GameType supportedType() {
return GameType.COLLECT_CARD;
}
@Override
public void initialize(ActivityGame game) {
// 解析卡片定义,存入 activity_collect_card_def
List<ActivityCollectCardDefinition> cards = parseDefinitions(game);
definitionRepository.saveAll(cards);
}
@Override
public ParticipateResult participate(ParticipateContext ctx) {
List<ActivityCollectCardDefinition> definitions =
definitionRepository.findByGameIdOrderByCardNameAsc(ctx.gameId());
ActivityCollectCardDefinition drawn = weightedRandom(definitions);
long uniqueBefore = playerCardRepository
.countDistinctCardIdByGameIdAndBuyerId(ctx.gameId(), ctx.buyerId());
boolean duplicate = playerCardRepository
.countByGameIdAndBuyerIdAndCardId(ctx.gameId(), ctx.buyerId(), drawn.getId()) > 0;
playerCardRepository.save(new ActivityPlayerCard(
UUID.randomUUID().toString(), ctx.gameId(), ctx.buyerId(), drawn.getId(), "DROP"));
long uniqueAfter = duplicate ? uniqueBefore : uniqueBefore + 1;
boolean fullSet = uniqueAfter >= definitions.size();
return ParticipateResult.win(
drawn.getId(), drawn.getCardName(),
PrizeType.CARD, null,
fullSet ? "full-set" : (duplicate ? "duplicate-card" : "new-card"));
}
@Override
public Optional<String> extensionTablePrefix() {
return Optional.of("collect_card");
}
}
卡牌 ID 通过 SHA-256(gameId:cardName) 自动生成,这样在同一个游戏内更容易保持可重现。重复卡牌不是靠数据库唯一约束“硬拦截”,而是先查 countByGameIdAndBuyerIdAndCardId(...) 判断,再把抽到的结果照常写入 ActivityPlayerCard,这样既能知道是不是重复卡,也能保留完整抽卡历史。
4. VirtualFarmPlugin:虚拟养成#
@Component
public class VirtualFarmPlugin implements GamePlugin {
@Override
public GameType supportedType() {
return GameType.VIRTUAL_FARM;
}
@Override
public ParticipateResult participate(ParticipateContext ctx) {
FarmAction action = parseAction(ctx.payload());
FarmConfig config = parseConfig(ctx.gameConfig());
ActivityVirtualFarm farm = farmRepository.findByGameIdAndBuyerId(ctx.gameId(), ctx.buyerId())
.orElseGet(() -> new ActivityVirtualFarm(
UUID.randomUUID().toString(), ctx.gameId(), ctx.buyerId(),
config.maxStage(), config.stageProgress()));
if (action == FarmAction.HARVEST) {
return harvest(ctx.gameId(), farm);
}
if (farm.isHarvested()) {
return ParticipateResult.miss("Farm reward has already been claimed");
}
farm.water(config.waterProgress());
farmRepository.save(farm);
return new ParticipateResult(true, null, "Virtual Farm", PrizeType.PROGRESS, null,
buildAnimationHint(farm, FarmAction.WATER),
farm.isMatured() ? "Farm matured. Use action HARVEST to claim reward"
: "Farm progress +" + config.waterProgress());
}
@Override
public Optional<String> extensionTablePrefix() {
return Optional.of("virtual_farm");
}
}
虚拟养成的核心是状态机:每个农场有 (stage, progress) 两个状态变量,WATER 操作增加进度并可能推进阶段,HARVEST 操作在成熟后领取奖品。
Anti-CheatGuard:反作弊机制#
活动引擎里一个常见挑战是作弊——刷接口、多账号、机器脚本。AntiCheatGuard 提供了三层 Redis 防护:
@Component
public class AntiCheatGuard {
public void check(ActivityGame game, String buyerId, String ipAddress, String deviceFingerprint) {
// 第一层:玩家频率限制
checkPlayerRateLimit(game.getId(), buyerId);
// 第二层:IP 频率限制
checkIpRateLimit(game.getId(), ipAddress);
// 第三层:设备指纹绑定
checkDeviceFingerprint(game.getId(), buyerId, deviceFingerprint);
}
private void checkPlayerRateLimit(String gameId, String buyerId) {
String key = "activity:ac:player:" + gameId + ":" + buyerId;
Long count = redisTemplate.opsForValue().increment(key);
if (count == 1) {
redisTemplate.expire(key, Duration.ofSeconds(properties.antiCheat().windowSeconds()));
}
if (count > properties.antiCheat().playerRequestsPerWindow()) {
throw new BusinessException(CommonErrorCode.TOO_MANY_REQUESTS, "Rate limit exceeded");
}
}
private void checkDeviceFingerprint(String gameId, String buyerId,
String fingerprint) {
if (fingerprint == null) return;
String key = "activity:ac:device:" + gameId + ":" + fingerprint;
String existingBuyerId = (String) redisTemplate.opsForValue().get(key);
if (existingBuyerId != null && !existingBuyerId.equals(buyerId)) {
throw new BusinessException(CommonErrorCode.FORBIDDEN,
"Device fingerprint reused by different buyer");
}
if (existingBuyerId == null) {
redisTemplate.opsForValue().set(key, buyerId,
Duration.ofHours(properties.antiCheat().deviceFingerprintTtlHours()));
}
}
}
三层防护#
| 层 | 检测对象 | Key 模式 | 当前默认阈值 |
|---|---|---|---|
| 玩家限频 | 同一买家的请求频率 | activity:ac:player:{gameId}:{buyerId} |
5 次/10 秒 |
| IP 限频 | 同一 IP 的请求频率 | activity:ac:ip:{gameId}:{ip} |
20 次/10 秒 |
| 设备指纹 | 设备与买家绑定 | activity:ac:device:{gameId}:{fingerprint} |
24 小时 TTL |
这些阈值来自 ActivityProperties 的默认值,可以通过配置覆盖;它们更像一套保守默认策略,而不是写死的“唯一正确答案”。
游戏调度器#
GameScheduler 两个定时任务自动管理游戏生命周期:
@Scheduled(fixedDelay = 60_000)
public void activateScheduledGames() {
// 自动激活到达开始时间的游戏
List<ActivityGame> games = gameRepository
.findByStatusAndStartAtBefore(GameStatus.SCHEDULED, Instant.now());
for (ActivityGame game : games) {
game.activate();
gameRepository.save(game);
pluginRegistry.requirePlugin(game.getType()).initialize(game);
}
}
@Scheduled(fixedDelay = 60_000)
public void endExpiredGames() {
// 自动结束过期游戏
List<ActivityGame> games = gameRepository
.findByStatusAndEndAtBefore(GameStatus.ACTIVE, Instant.now());
for (ActivityGame game : games) {
game.end();
gameRepository.save(game);
pluginRegistry.requirePlugin(game.getType()).settle(game);
}
}
这样运营人员只需要提前配置好游戏时间和规则,不需要在关键时刻手动操作。
扩展性设计#
插件自带数据表#
插件通过 extensionTablePrefix() 声明自己的扩展表前缀:
// CollectCardPlugin
public Optional<String> extensionTablePrefix() {
return Optional.of("collect_card");
}
// → 使用 activity_collect_card_def 和 activity_player_card 表
// VirtualFarmPlugin
public Optional<String> extensionTablePrefix() {
return Optional.of("virtual_farm");
}
// → 使用 activity_virtual_farm 表
Flyway 迁移也是按插件逐步添加:
V1__init.sql → 核心表(game, participation, prize, outbox)
V2__collect_card_tables.sql → 集卡插件表
V3__virtual_farm_tables.sql → 虚拟养成表
V4__rename_player_id_to_buyer_id.sql → 命名统一
Redis Key 命名空间隔离#
re:packets:{gameId} → 抢红包金额
re:claims:{gameId} → 抢红包记录
activity:ac:player:{gameId}:* → 玩家限频
activity:ac:ip:{gameId}:* → IP 限频
activity:ac:device:{gameId}:* → 设备指纹
每个游戏、每种功能独立 key 前缀,不会互相干扰,活动结束时可以按前缀清理。
奖品派发可插拔#
GameEngine.participate() 在记录参与结果后,会根据 PrizeType 决定是否把奖励标成“稍后派发”:
if (result.win()) {
participation.markWin(result.prizeId(), buildPrizeSnapshot(result));
if (result.prizeType() == PrizeType.CARD || result.prizeType() == PrizeType.PROGRESS) {
participation.markRewardSkipped();
}
}
真正的派发发生在后续调度器里:
@Scheduled(fixedDelay = 60_000)
@Transactional
public void compensatePendingRewards() {
Instant threshold = Instant.now().minus(2, ChronoUnit.MINUTES);
List<ActivityParticipation> pending = participationRepository.findPendingRewards(threshold);
for (ActivityParticipation p : pending) {
dispatch(p); // 当前仍是日志 stub
p.markDispatched(p.getId());
participationRepository.save(p);
}
}
也就是说,当前实现明确还是 stub(只打日志并更新状态),真正接入 loyalty-service / promotion-service / wallet-service 的 HTTP 发奖链路还属于后续工作。
当前的水平扩展前提#
这套设计本身具备水平扩展的几个基础条件:
- Redis 作为主要状态存储:抢红包、限频、设备指纹全部在 Redis 中原子操作,不依赖 DB
- 无状态设计:
activity-service本身无状态,所有状态在 MySQL + Redis 中,可以随意水平扩展 - Lua 脚本原子操作:多实例同时抢红包时,核心临界区仍然由 Redis 串行执行
- 调度与派发解耦:游戏生命周期调度、奖励补偿调度都不依赖单机内存状态
但更准确地说,仓库里目前能直接证明的是“架构上便于水平扩展”,而不是“已经压测验证 50 个 Pod 稳定运行”。我没有在主线代码或 CI 里看到 HPA、自定义 activity_rps 指标或 50 Pod 压测证据,所以这里不把它写成既成事实。
未来可扩展的游戏类型#
GameType 枚举中已预留但尚未实现插件的类型:
public enum GameType {
INSTANT_LOTTERY, // ✅ 砸金蛋/抽奖
RED_ENVELOPE, // ✅ 抢红包
COLLECT_CARD, // ✅ 集卡
VIRTUAL_FARM, // ✅ 虚拟养成
QUIZ, // 🔲 问答游戏
SLASH_PRICE, // 🔲 砍价
GROUP_BUY // 🔲 拼团
}
新增一种游戏类型只需要:
- 实现
GamePlugin接口 - 在
GameType中添加枚举值(如果需要) - 编写 Flyway 迁移脚本(如果需要扩展表)
- 不需要修改
GameEngine或任何其他插件
参考与实现位置#
- Redis Scripting:https://redis.io/docs/latest/develop/interact/programmability/eval-intro/
- 仓库实现入口:
services/activity-service/src/main/java/dev/meirong/shop/activity/engine/GamePlugin.java、services/activity-service/src/main/java/dev/meirong/shop/activity/engine/GameEngine.java、services/activity-service/src/main/java/dev/meirong/shop/activity/engine/RedEnvelopePlugin.java、services/activity-service/src/main/java/dev/meirong/shop/activity/engine/CollectCardPlugin.java、services/activity-service/src/main/java/dev/meirong/shop/activity/engine/VirtualFarmPlugin.java、services/activity-service/src/main/java/dev/meirong/shop/activity/service/AntiCheatGuard.java、services/activity-service/src/main/java/dev/meirong/shop/activity/service/RewardDispatcher.java、services/activity-service/src/main/java/dev/meirong/shop/activity/config/ActivityProperties.java
小结#
这版插件化活动引擎里,我比较看重的点:
- GamePlugin SPI 接口:
supportedType()+initialize()+participate()+settle(),新增游戏时至少不需要改引擎核心流程 - 四种游戏插件:砸金蛋(加权随机 + 库存扣减)、抢红包(二均值算法 + Lua 原子脚本)、集卡(加权抽卡 + 集齐检测)、虚拟养成(阶段状态机)
- Redis Lua 脚本:抢红包的
HEXISTS → LPOP → HSET三步原子执行,避免多实例竞争下的数据错乱 - Anti-CheatGuard:玩家/IP 限频 + 设备指纹绑定,当前默认阈值可配置
- 扩展性设计:插件自带表前缀、Flyway 分步迁移、Redis key 命名空间隔离
下一篇也是系列的最后一篇,将深入 架构质量Quality Gates:19 条 ArchUnit 规则如何自动执行架构约束、6 个 Maven Archetype 如何一键生成标准化服务骨架、以及 WireMock contract testing 如何保证 BFF 和领域服务的接口兼容性。
项目仓库:github.com/meirongdev/shop(公开,可结合源码一起看)