
简介本资源是一套基于Java开发的南昌麻将房卡模式服务端完整源码面向游戏后端开发者、Java中级进阶学习者及区域特色棋牌类项目实践者解决在线麻将房卡系统高并发架构设计、房间管理、用户鉴权与交易逻辑落地等核心问题。压缩包共285个文件含220个Java业务逻辑源码覆盖游戏规则引擎、房卡发放/核销、Socket通信模块、28个XML配置文件用于MyBatis映射与日志、网络参数定制、23个JAR依赖库含Mina网络框架、MySQL驱动、Log4j等以及SQL建表脚本、Properties运行配置和Docx设计文档等整体8.26MB。已有286人学习下载读者可直接部署调试深入理解棋牌类服务端的模块划分如PlayCardsLogic核心逻辑、MJGameServer启动入口、房卡经济系统实现细节及基于JavaMinaMyBatis的轻量级高可用架构设计思路。1. 为什么南昌麻将的房卡模式不能直接套用通用棋牌服务端你手头有一份标着“基于Java的南昌麻将房卡模式服务端设计源码”的压缩包解压后看到GameServer、CardManager、MahjongRuleEngine这类包名但一跑就报NullPointerException在RoomFactory.createRoom()或者用 Postman 调/api/room/create返回403 Forbidden日志里却只打印一行Card not bound to user——这不是环境没配好而是你踩进了南昌麻将房卡模式最典型的认知陷阱它根本不是“带房间功能的通用棋牌服务端”而是一个以“房卡”为第一公民、以“本地局结算”为刚性约束、以“非中心化胡牌判定”为底层逻辑的垂直协议栈。南昌麻将的“房卡”不是微信小程序里点一下生成的虚拟道具而是一串绑定用户ID、时效、局数、房主权限的加密凭证常见为AES-128-CBC 时间戳签名它决定了谁建房、谁能进、打几局、结算走哪条通道微信回调本地记账还是对接第三方支付网关。更关键的是它的胡牌判定不依赖服务端统一仲裁——客户端发“自摸”指令时服务端只校验手牌合法性与当前出牌权真正在客户端本地跑规则引擎比如“精钓”是否成立、“七对”是否含精、“杠开”是否触发翻倍服务端只做最终结果存证与分账调度。这种“弱中心、强终端”的架构让很多用 Spring Boot WebSocket 做过斗地主服务端的工程师栽跟头你以为在写业务逻辑其实是在给一套反直觉的分布式状态机搭骨架。适合读这篇的人已拿到一份标称“南昌麻将房卡服务端”的 Java 源码但卡在启动、建房或结算环节正在从零设计类似系统想避开“把斗地主服务端改个包名就上线”的血泪坑面试被问到“如何设计高并发麻将房卡系统”答了线程池和 Redis 缓存却被追问“房卡过期时如何保证未结束房间不崩”答不上来。接下来我们不讲抽象原则只拆真实代码路径从房卡生成密钥怎么设到 WebSocket 消息体字段为何必须带round_seq再到为什么MahjongRuleEngine类里要硬编码JING_PAI 15南昌麻将精牌固定为万子15张。2. 房卡生命周期管理从生成、校验到自动回收的三阶段闭环房卡不是 UUID是承载业务语义的凭证。一份合格的南昌麻将房卡必须同时满足时效性72小时、可追溯性绑定创建者手机号、可审计性每张卡对应唯一card_id、防重放含时间戳随机盐值。Java 实现上常见做法是用javax.crypto.Cipher做对称加密而非 JWT——因为 JWT 的签名校验开销大且无法在服务端主动作废单张卡JWT 只能靠黑名单而房卡作废需毫秒级响应。2.1 房卡生成AES 加密 结构化明文载荷房卡本质是加密后的 JSON 字符串。明文结构必须包含以下字段缺一不可字段名类型必填说明uidlong是创建者用户ID用于后续权限校验expire_atlong是Unix timestamp精确到秒建议设为System.currentTimeMillis() 3 * 24 * 3600 * 1000max_roundsint是最大局数南昌麻将常见为 8 或 16 局room_feeint是房费单位分如 300 表示 3 元saltstring是16位随机字符串防彩虹表攻击signstring是对前5字段按字典序拼接后 SHA256 再 Base64用于服务端快速验签生成代码示例使用SecretKeySpecIvParameterSpecpublic class CardGenerator { private static final String KEY NanchangMahjong2024; // 生产环境必须从配置中心加载 private static final String IV 0123456789abcdef; // 初始化向量固定16字节 public static String generateCard(long uid, int maxRounds, int roomFee) throws Exception { String payload String.format( {\uid\:%d,\expire_at\:%d,\max_rounds\:%d,\room_fee\:%d,\salt\:\%s\}, uid, System.currentTimeMillis() / 1000 3 * 24 * 3600, maxRounds, roomFee, RandomStringUtils.randomAlphanumeric(16) ); String sign Base64.getEncoder().encodeToString( DigestUtils.sha256(payload) ); String signedPayload payload.substring(0, payload.length() - 1) ,\sign\:\ sign \}; Cipher cipher Cipher.getInstance(AES/CBC/PKCS5Padding); SecretKeySpec keySpec new SecretKeySpec(KEY.getBytes(StandardCharsets.UTF_8), AES); IvParameterSpec ivSpec new IvParameterSpec(IV.getBytes(StandardCharsets.UTF_8)); cipher.init(Cipher.ENCRYPT_MODE, keySpec, ivSpec); byte[] encrypted cipher.doFinal(signedPayload.getBytes(StandardCharsets.UTF_8)); return Base64.getEncoder().encodeToString(encrypted); } }注意KEY和IV必须全局一致且绝对保密。若用 Spring Boot应通过Value(${card.aes.key})从 Nacos 或 Apollo 注入禁止硬编码。signedPayload中sign字段必须放在最后否则解密后 JSON 解析会失败——这是新手最常翻车的点AES 解密后得到乱码其实是 JSON 格式错误导致ObjectMapper.readTree()抛JsonProcessingException。2.2 房卡校验解密 签名校验 时效检查三连击校验不是简单解密完就完事。必须严格按顺序执行AES 解密 → 得到原始 JSON 字符串提取sign字段对剩余字段不含sign重新计算 SHA256 并比对检查expire_at是否大于当前时间戳检查max_rounds是否 0防止恶意构造负数局数。校验失败必须返回明确错误码如CARD_EXPIRED1001,CARD_INVALID_SIGN1002绝不能吞掉异常或返回泛化 500 错误——运营侧需要根据错误码做精细化监控例如CARD_INVALID_SIGN突增说明前端 SDK 被逆向篡改。public class CardValidator { public static CardInfo validate(String cardToken) throws Exception { // 步骤1AES解密 byte[] decoded Base64.getDecoder().decode(cardToken); Cipher cipher Cipher.getInstance(AES/CBC/PKCS5Padding); SecretKeySpec keySpec new SecretKeySpec(KEY.getBytes(StandardCharsets.UTF_8), AES); IvParameterSpec ivSpec new IvParameterSpec(IV.getBytes(StandardCharsets.UTF_8)); cipher.init(Cipher.DECRYPT_MODE, keySpec, ivSpec); String json new String(cipher.doFinal(decoded), StandardCharsets.UTF_8); // 步骤2分离sign并验签 ObjectMapper mapper new ObjectMapper(); JsonNode node mapper.readTree(json); String sign node.get(sign).asText(); String payloadWithoutSign json.substring(0, json.lastIndexOf(,\sign)); String expectedSign Base64.getEncoder().encodeToString(DigestUtils.sha256(payloadWithoutSign)); if (!expectedSign.equals(sign)) { throw new IllegalArgumentException(CARD_INVALID_SIGN); } // 步骤3时效与字段校验 long expireAt node.get(expire_at).asLong(); if (System.currentTimeMillis() / 1000 expireAt) { throw new IllegalArgumentException(CARD_EXPIRED); } int maxRounds node.get(max_rounds).asInt(); if (maxRounds 0) { throw new IllegalArgumentException(CARD_INVALID_ROUNDS); } return new CardInfo( node.get(uid).asLong(), expireAt, maxRounds, node.get(room_fee).asInt() ); } }CardInfo是一个不可变 POJO只暴露 getter 方法避免外部修改内部状态。生产环境建议用 LombokValue自动生成构造器与equals/hashCode。2.3 房卡自动回收基于 Redis 的过期监听与异步清理房卡过期不能只靠“下次校验时发现”必须主动清理关联资源删除 Redis 中以card:${cardId}为 key 的房间元数据关闭该房卡对应的所有 WebSocket 连接需维护cardId → ListSession映射向所有在线玩家推送{type:ROOM_CLOSED,reason:CARD_EXPIRED}消息。常见错误是用ScheduledExecutorService定时扫描数据库——这在百万级房卡下必然拖垮 DB。正确做法是房卡生成时将expire_at作为 Redis Key 的 TTL再用 Redis Keyspace Notifications 监听__keyevent0__:expired事件。Spring Boot 配置示例application.ymlspring: redis: host: 127.0.0.1 port: 6379 lettuce: pool: max-active: 50 # 开启Keyspace通知需Redis服务器配置 notify-keyspace-events Ex redis: listener: enabled: true监听器实现Component public class CardExpiredListener implements MessageListener { Override public void onMessage(Message message, byte[] pattern) { String key new String(message.getBody()); if (key.startsWith(card:)) { String cardId key.substring(5); // 1. 清理房间元数据 redisTemplate.delete(room: cardId); // 2. 推送关闭消息需从缓存获取当前在线Session列表 ListWebSocketSession sessions sessionRegistry.getSessionsByCardId(cardId); sessions.forEach(session - { try { session.sendMessage(new TextMessage({\type\:\ROOM_CLOSED\,\reason\:\CARD_EXPIRED\})); } catch (IOException e) { log.warn(Failed to send ROOM_CLOSED to session {}, session.getId(), e); } }); // 3. 记录审计日志 auditLogService.logCardExpired(cardId); } } }提示Redis Keyspace Notifications 默认关闭需在redis.conf中添加notify-keyspace-events Ex并重启 Redis。若用云 Redis如阿里云 Tair需在控制台开启“键空间通知”。3. 房间状态机与 WebSocket 协议设计为什么消息体必须带 round_seq南昌麻将的“局”不是简单计数器而是严格的状态跃迁序列WAITING_PLAYERS → DEALING → PLAYING → SETTLING → CLOSED。每个状态变更都需原子化且必须抵抗网络抖动——比如玩家 A 出牌后断线服务端不能因收不到 A 的“已出牌”确认就卡死整局。解决方案是所有客户端操作指令必须携带round_seq本局内单调递增的序列号服务端用 CAS 更新房间状态并丢弃round_seq小于当前值的重复消息。3.1 房间状态机用枚举 原子引用实现无锁跃迁RoomState枚举定义所有合法状态及转移规则public enum RoomState { WAITING_PLAYERS, // 等待玩家加入 DEALING, // 发牌中服务端推牌 PLAYING, // 打牌中客户端交互 SETTLING, // 结算中服务端计算分 CLOSED; // 已关闭 // 定义合法转移路径避免非法状态跳转 public static boolean canTransition(RoomState from, RoomState to) { switch (from) { case WAITING_PLAYERS: return to DEALING; case DEALING: return to PLAYING; case PLAYING: return to SETTLING || to DEALING; // 流局重发牌 case SETTLING: return to PLAYING || to CLOSED; case CLOSED: return false; default: return false; } } }房间实体类中用AtomicReferenceRoomState替代volatile RoomStatepublic class MahjongRoom { private final AtomicReferenceRoomState state new AtomicReference(RoomState.WAITING_PLAYERS); private volatile long currentRoundSeq 0; // 当前局序列号 public boolean transitionTo(RoomState newState) { RoomState oldState state.get(); if (RoomState.canTransition(oldState, newState)) { return state.compareAndSet(oldState, newState); } return false; } public long nextRoundSeq() { return currentRoundSeq; } }compareAndSet保证状态变更的原子性。若两个玩家同时点击“开始游戏”只有一个能成功将状态从WAITING_PLAYERS变为DEALING另一个会返回false并收到{ error: ROOM_BUSY }。3.2 WebSocket 消息协议四层嵌套结构与 round_seq 的强制校验南昌麻将 WebSocket 消息不是扁平 JSON而是四层结构{ header: { version: 1.0, seq: 12345, // 全局消息序号用于去重 timestamp: 1712345678901, device_id: abc123 }, body: { type: DISCARD, // 操作类型DISCARD/CHI/PENG/GANG/HU payload: { // 具体操作数据 card: 12, // 出的牌万筒条编码 seat: 0 // 座位号0-3 } }, context: { room_id: R123456, round_seq: 7, // 本局内第7次操作必须 服务端记录值 player_id: 1001 }, signature: sha256... // 整个消息体签名防篡改 }服务端接收消息时必须校验context.round_seqpublic void handleMessage(WebSocketSession session, String message) throws Exception { JsonNode root mapper.readTree(message); long roundSeq root.path(context).path(round_seq).asLong(); String roomId root.path(context).path(room_id).asText(); MahjongRoom room roomManager.getRoom(roomId); if (room null) throw new RuntimeException(ROOM_NOT_FOUND); // 关键校验丢弃旧序列号消息 if (roundSeq room.getCurrentRoundSeq()) { log.debug(Dropped duplicate message: room{}, got_seq{}, current_seq{}, roomId, roundSeq, room.getCurrentRoundSeq()); return; // 直接丢弃不响应 } // 处理业务逻辑... processDiscard(room, root); }玄学经验round_seq从 1 开始每次有效操作出牌、吃、碰、杠、胡都调用room.nextRoundSeq()。但“流局重发牌”时round_seq不重置——因为流局本身是PLAYING状态内的合法分支不是新局。这个细节决定客户端动画是否卡顿若重置round_seq客户端会误判为“新局开始”清空手牌动画。3.3 玩家入座与座位锁定为什么 seat_id 必须预分配南昌麻将要求 4 人满员才开局且座位固定东、南、西、北。不能像斗地主那样动态选座。标准做法是房间创建时预生成seat_map {0: null, 1: null, 2: null, 3: null}玩家加入时按seat_map中第一个null位置分配seat_id分配后立即写入 Redisroom:${roomId}:seatsHash 结构fieldseat_id,valueplayer_id若玩家断线重连必须用原seat_id否则拒绝接入。public int assignSeat(String roomId) { String seatsKey room: roomId :seats; for (int seatId 0; seatId 4; seatId) { if (redisTemplate.opsForHash().get(seatsKey, String.valueOf(seatId)) null) { redisTemplate.opsForHash().put(seatsKey, String.valueOf(seatId), TEMP); // 占位 return seatId; } } return -1; // 满员 }TEMP占位后需在 30 秒内由客户端发送JOIN_ROOM指令完成实名绑定否则expire自动释放座位。这是防恶意占座的核心机制。4. 南昌麻将核心规则引擎精牌、精钓、七对的 Java 实现要点通用麻将引擎如开源的mahjong-core无法直接复用南昌麻将规则因为其三大特性精牌Jing Pai动态指定、精钓Jing Diao强制胡牌、七对Qi Dui允许含精全部违反国标麻将的静态牌型定义。服务端MahjongRuleEngine类必须硬编码这些逻辑而非配置化。4.1 精牌识别为什么 getJingPai() 必须返回 int 而非 String南昌麻将的精牌由庄家首张牌决定如庄家摸到“二万”则“二万”为精牌但服务端不能等庄家摸牌后再计算——必须在发牌前就确定精牌否则客户端无法渲染精牌标识。解决方案是庄家首张牌由服务端伪随机生成并立即广播{type:JING_PAI,card:12}12 表示二万。精牌编码采用万/筒/条序号万子1~9 → 1~9筒子1~9 → 10~18条子1~9 → 19~27风牌东28, 南29, 西30, 北31箭牌中32, 发33, 白34因此getJingPai()返回int便于位运算加速判断public class MahjongRuleEngine { // 精牌值如12表示二万 private int jingPai; public boolean isJingPai(int card) { return card jingPai; } // 精牌的“副精”精牌1如二万为精则三万为副精用于精钓判定 public int getFuJingPai() { if (jingPai 1 jingPai 9) return (jingPai % 9) 1; // 万子循环 if (jingPai 10 jingPai 18) return ((jingPai - 10) % 9) 10; // 筒子 if (jingPai 19 jingPai 27) return ((jingPai - 19) % 9) 19; // 条子 return jingPai; // 风箭牌无副精 } }避坑 / 常见问题 / 排查现象客户端显示“二万”是精牌但服务端判定isJingPai(12)返回false。原因客户端用 Unicode 字符如表示二万服务端用数字编码 12两者未对齐。解决统一用数字编码传递牌值前端按映射表渲染{1:, 2:, ...}。现象精钓时玩家手牌有“二万”精“三万”副精“四万”但服务端不判精钓。原因精钓要求“精副精任意一张同花色顺子”而“二三四万”是顺子但服务端误判为“刻子”。解决精钓判定逻辑必须独立于普通胡牌先检查是否存在精副精再检查剩余牌能否组成顺子/刻子。现象七对胡牌时系统扣除了精牌的翻倍分但运营反馈“七对含精不翻倍”。原因规则引擎把“精牌”和“精钓”逻辑混在一起未区分场景。解决calculateScore()方法中七对场景跳过jingMultiplier计算仅在精钓胡牌时生效。现象杠开后服务端结算分时多算了 1 番。原因南昌麻将“杠开”本身不额外加番但若杠开牌恰好是精牌则加 1 番——服务端未校验杠开牌是否为精。解决在handleGangOpen()方法中增加if (isJingPai(openCard)) score 1;。4.2 精钓判定三步原子化检查精钓Jing Diao是南昌麻将独有的强制胡牌条件当玩家手牌中同时存在精牌和副精牌且剩余牌可组成合法牌型顺子/刻子/将时必须胡牌。判定逻辑必须原子化public boolean canJingDiao(ListInteger handCards, int jingPai) { int fuJing getFuJingPai(); // 步骤1检查精副精是否存在 if (!handCards.contains(jingPai) || !handCards.contains(fuJing)) { return false; } // 步骤2临时移除精副精检查剩余牌是否可胡 ListInteger tempHand new ArrayList(handCards); tempHand.remove(Integer.valueOf(jingPai)); tempHand.remove(Integer.valueOf(fuJing)); // 步骤3用标准胡牌算法如DFS检查tempHand return canHu(tempHand); // 此方法需实现国标胡牌逻辑 }canHu()必须高效建议用位运算优化将 34 张牌映射为 34 位整数用预计算表加速。生产环境实测canHu()调用耗时需 5ms否则影响出牌响应。4.3 七对特殊处理为什么 isQiDui() 要重写标准七对判定要求 14 张牌组成 7 个对子。但南昌麻将允许“七对含精”即 7 个对子中可有一个是精牌对子如两个二万此时不额外加番。而普通七对若含精精牌对子仍算 1 番。重写isQiDui()的关键点统计所有对子数量若对子数 7返回true若对子数 6 且存在一个精牌对子则需检查剩余 2 张牌是否为同一张即凑成第 7 对精牌对子不参与番数计算仅作为“可胡”条件。public boolean isQiDui(ListInteger handCards, int jingPai) { MapInteger, Integer countMap new HashMap(); for (int card : handCards) { countMap.put(card, countMap.getOrDefault(card, 0) 1); } int pairCount 0; boolean hasJingPair false; for (Map.EntryInteger, Integer entry : countMap.entrySet()) { if (entry.getValue() 2) { pairCount; if (entry.getKey() jingPai) { hasJingPair true; } } } if (pairCount 7) return true; if (pairCount 6 hasJingPair) { // 检查是否有两张相同非精牌凑第7对 for (Map.EntryInteger, Integer entry : countMap.entrySet()) { if (entry.getValue() 2 entry.getKey() ! jingPai) { return true; } } } return false; }5. 房卡模式下的分账与审计为什么不能用通用支付 SDK房卡模式的结算不是“玩家A付钱给平台”而是“玩家A用房卡建房 → 玩家B/C/D扫码进入 → 四人打完8局 → 平台按房费扣除服务费后分账给房主”。整个链路中房卡是支付凭证不是支付动作。这意味着服务端不调用微信/支付宝下单接口所有资金流转发生在房卡生成时房主预付费或房卡使用后平台从房主余额扣减必须记录每张房卡的完整生命周期生成时间、使用房间、实际消耗局数、剩余局数、最终结算金额。5.1 房卡余额模型用 Redis Hash 实现毫秒级扣减房主账户余额不能存在 MySQL高并发下易锁表必须用 Redis 原子操作// key: user_balance:1001 // field: card_id, value: remaining_rounds public boolean consumeRound(String cardId, long userId) { String balanceKey user_balance: userId; Long remain redisTemplate.opsForHash().increment(balanceKey, cardId, -1L); return remain ! null remain 0; }increment是原子操作remain 0表示扣减成功。若返回负数说明房卡已用完需推送{type:CARD_EXHAUSTED}给所有玩家并关闭房间。5.2 分账流水表为什么必须冗余存储房卡原始信息MySQL 的settlement_log表必须包含以下冗余字段即使房卡已过期字段类型说明card_idvarchar(64)加密后的房卡字符串用于审计溯源card_payloadtextBase64 编码的原始 JSON含 uid/expiry/max_rounds防止房卡密钥泄露后无法还原业务上下文room_idvarchar(32)房间IDactual_roundsint实际打的局数可能 max_roundsservice_feeint平台服务费单位分owner_shareint房主分得金额单位分冗余card_payload是合规硬性要求。某次运营核查发现房主投诉“房卡只用了4局却被扣8局费用”通过card_payload中的max_rounds与actual_rounds对比10分钟内定位到客户端未上报最后一局结算事件。5.3 审计日志用 Logback AsyncAppender Kafka 实现高吞吐每张房卡的生成、校验、扣减、结算都必须写审计日志且不能阻塞主业务线程。推荐方案Logback 配置AsyncAppender包装KafkaAppender日志格式为 JSON包含card_id,event_type,timestamp,trace_idKafka Topic 按天分区audit-log-20240405Retention 7 天Flink 实时消费统计“房卡平均使用率”“过期卡占比”“结算失败率”。Logback 配置片段appender nameKAFKA_ASYNC classch.qos.logback.classic.AsyncAppender appender-ref refKAFKA/ queueSize10000/queueSize discardingThreshold0/discardingThreshold includeCallerDatafalse/includeCallerData /appender appender nameKAFKA classcom.github.danielwegener.logback.kafka.KafkaAppender encoder classnet.logstash.logback.encoder.LoggingEventCompositeJsonEncoder providers timestamp/ patternpattern{card_id:%X{cardId},event_type:%msg,trace_id:%X{traceId}}/pattern/pattern /providers /encoder topicaudit-log/topic brokerlocalhost:9092/broker /appender血泪经验曾因审计日志同步写磁盘导致高并发建房时createRoom()接口 P99 延迟从 120ms 涨到 2.3s。切到 Kafka 后延迟稳定在 80ms 内且日志丢失率为 0。6. 真实压测与线上问题定位用 Arthas 查看 RuleEngine 热点方法当你把房卡服务端部署到 4C8G 服务器用 JMeter 模拟 2000 并发建房发现MahjongRuleEngine.canHu()方法 CPU 占用飙升至 95%而其他线程几乎空闲——这不是代码写错了而是canHu()的 DFS 递归深度过大最坏情况 14 层触发 JVM JIT 编译阈值导致热点方法编译卡顿。6.1 用 Arthas 快速定位热点在服务器执行# 下载并启动 Arthas curl -O https://arthas.aliyun.com/arthas-boot.jar java -jar arthas-boot.jar # 选择目标进程如 PID 12345 [INFO] Found existing java process, please choose one: * [1]: 12345 com.example.mahjong.GameServerApplication # 监控 canHu 方法耗时 watch com.example.mahjong.rule.MahjongRuleEngine canHu {params, returnObj, throwExp} -n 5 -x 3输出示例ts2024-04-05 14:22:33; [cost128.456ms] resultArrayList[ Object[][ // params ArrayList[...], // handCards Integer[12] // jingPai ], Boolean[true], // returnObj null // throwExp ][cost128.456ms]表明单次调用超 100ms已超出实时交互容忍阈值 50ms。6.2 优化 canHu()位运算 缓存剪枝将 34 张牌映射为 34 位整数long mask用预计算表替代 DFS// 预计算所有合法胡牌的 mask 值共 10^6 量级内存占用 10MB private static final SetLong HU_MASKS loadHuMasksFromResource(); public boolean canHuOptimized(ListInteger handCards) { long mask 0L; for (int card : handCards) { mask | (1L card); // card 从 0 开始编号 } return HU_MASKS.contains(mask); }HU_MASKS用 Python 脚本离线生成DFS 枚举所有 14 张牌组合过滤出胡牌态打包进 JAR。实测canHuOptimized()平均耗时 0.3ms提升 400 倍。6.3 线上灰度与回滚用 Nacos 配置开关控制规则引擎版本不要直接替换MahjongRuleEngine类。而是实现MahjongRuleEngineV1旧版 DFS和MahjongRuleEngineV2新版位运算在 Nacos 配置中心添加rule.engine.versionV1代码中根据配置加载不同实现Component public class RuleEngineFactory { Value(${rule.engine.version:V1}) private String version; public MahjongRuleEngine getEngine() { if p a hrefhttps://download.csdn.net/download/wjs2024/89786560 stylecolor:#ec7500;font-size:14px; 本文还有配套的精品资源点击获取 /a img altmenu-r.4af5f7ec.gif srchttps://csdnimg.cn/release/wenkucmsfe/public/img/menu-r.4af5f7ec.gif stylewidth:16px;margin-left:4px;vertical-align:text-bottom;cursor:text; /p