在之前的文章中,我们看了 API Gateway、BFF 聚合、领域服务和事件驱动架构。本文继续整理 Shop Platform 里我觉得比较有意思的一块——activity-service 的插件化游戏引擎

📦 本文基于的完整项目源码:https://github.com/meirongdev/shop

上一篇:(五)事件驱动架构

2026-04 实践更新 当前主线代码里,RedEnvelopePluginAntiCheatGuard 都已经从 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();
    }
}

生命周期:

stateDiagram-v2 [*] --> 创建: ActivityGame 创建 创建 --> 初始化: activate() → initialize() 初始化 --> 参与中: 用户 participate() × N 次 参与中 --> 结算中: end() → settle() 结算中 --> [*]: 清理完成

插件注册#

@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 索引。新增游戏类型只需要:

  1. 创建新的 @Component 实现 GamePlugin 接口
  2. GameType 枚举中添加类型
  3. 不需要修改引擎核心编排代码;至于是否需要重启或额外部署,仍取决于最终交付方式

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           // 🔲 拼团
}

新增一种游戏类型只需要:

  1. 实现 GamePlugin 接口
  2. GameType 中添加枚举值(如果需要)
  3. 编写 Flyway 迁移脚本(如果需要扩展表)
  4. 不需要修改 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.javaservices/activity-service/src/main/java/dev/meirong/shop/activity/engine/GameEngine.javaservices/activity-service/src/main/java/dev/meirong/shop/activity/engine/RedEnvelopePlugin.javaservices/activity-service/src/main/java/dev/meirong/shop/activity/engine/CollectCardPlugin.javaservices/activity-service/src/main/java/dev/meirong/shop/activity/engine/VirtualFarmPlugin.javaservices/activity-service/src/main/java/dev/meirong/shop/activity/service/AntiCheatGuard.javaservices/activity-service/src/main/java/dev/meirong/shop/activity/service/RewardDispatcher.javaservices/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(公开,可结合源码一起看)