Spring Boot+MyBatis实现国际版多商户团购扫码核销系统源码解析
做本地生活SaaS这些年团购系统我见过几十套但能同时把多商户、多语言、扫码核销这三个硬骨头啃下来的Java源码项目确实不多。这套国际版多商户团购扫码核销系统基于Spring Boot MyBatis这套经典技术栈不光是“用户买券、到店核销”这么简单它把平台方、商户、门店、用户四方角色全部串了起来商户独立上架套餐、独立结算平台做分账和管理用户在App、H5、小程序里下单收银员拿扫码枪或手机摄像头扫一扫就完成核销。对于想快速搭建商用团购系统、又不想从零开始写的团队来说这套源码的参考价值非常直接。下面我把整个系统的设计思路、核心业务实现、部署落地和常见坑位一次讲透。1. 这套源码解决了什么问题业务模块与技术选型的拆解1.1 从单店团购到多商户平台业务模型发生了哪些变化单门店团购的系统其实很简单一个商家后台一套商品一个订单表核销也只是改一个状态字段。但要做多商户模型就完全变了。单纯的加一个merchant_id字段远远不够你需要重新考虑四个层面的问题。第一层是角色权限。平台方要能审核商户入驻、查看所有订单流水、设置抽佣比例商户端要只能看到自己的商品、订单、核销记录和结算数据门店作为商户下的组织单位要能分配到店核销的权限。这套源码里的权限模型就是按照平台、商户、门店、员工四级来设计的核销员单独一张表避免把商户管理员的权限放得过宽。第二层是数据隔离。商户之间绝对不能互相看到订单数据这是SaaS平台的底线。最常见的做法是行级数据隔离也就是所有业务表都带merchant_id查询时强制拼接条件。但光靠开发人员自觉不够需要在持久层做统一拦截后面我会详细说。第三层是资金分账。多商户团购的收入不能直接进平台账户否则给商户结算会变成本非常高的人工活。正常商用路径是用户支付进平台监管账户核销完成后平台按约定比例抽佣剩下的通过结算单系统打给商户。这套源码里的账户流水表、结算单表和佣金规则表就是为这个场景准备的。第四层是商品与库存维度。不同商户的库存模型完全不同餐饮店卖的是套餐券库存是每天可接待量美容店卖的是服务项目可能不需要限制库存实物商品则要扣减真实库存。所以商品表里特意加了inventory_type和inventory字段支持不限制库存、总量库存、日库存三种模式这在实际运营里非常实用。只看业务层面的话我认为这套系统最值钱的地方不是功能多而是把上面四层关系处理得比较干净。毕竟团购类项目的难点一直不是“做出来”而是“多商户共用一套系统还不乱”。1.2 四个核心端的功能边界是怎么划分的拆解这套源码时我会习惯性先看功能边界因为它直接决定了后续所有业务逻辑和数据库设计的走向。平台管理端主要负责商户入驻审核、类目管理、抽佣规则配置、全局营销活动、运营数据看板。这里有个容易忽略的细节平台端看到的订单流水是分商户归集的但是导出时又要能合并。源码里把orders和order_items拆开订单表记商户、用户、金额、状态订单明细表记具体买了哪几个套餐这样无论是按商户维度还是按平台维度统计SQL都很好写。商户端要能自己管理门店、员工、商品、订单和核销记录。更重要的是商户端还要能看到“今日待核销”“已核销”“退款中”这些实时数字。这意味着核销记录不能简单记在订单表里需要单独一张verification_record表按核销时间做索引统计才快。用户端是最直观的部分包含团购商品浏览、下单支付、团购券码展示、申请退款。考虑到国际版场景这里把货币符号、语言、日期格式全部抽成了多语言多区域配置。核销端是整个系统的灵魂。网页端、小程序端、扫码枪设备走的是同一套核销API。网页端适合没有扫码枪的小商户小程序端适合店员手机扫码扫码枪走的是标准串口读码后HTTP请求的链路。三个入口共用同一个校验逻辑能避免“网页端能核销但扫码枪不行”这种分裂问题。1.3 技术选型为什么是Spring Boot MyBatis而不是更重的微服务现在很多新项目一上来就上Spring Cloud全家桶但做SaaS类商用系统尤其是团队规模不大、业务需要快速上线时Spring Boot MyBatis反而是非常稳的选择。Spring Boot最大的价值是把配置的复杂度降下来了。内嵌Tomcat打包成JAR直接跑开发环境和生产环境之间几乎没有鸿沟。MyBatis则更不用多说SQL自己控制复杂的多表关联、报表统计、动态更新都很好写。相比JPA那种全自动ORMMyBatis在团购业务里更有掌控感比如核销时那一条UPDATE ... WHERE status 0程序员必须能精确控制SQL。这套源码实际依赖的核心组件也不复杂MySQL存业务数据Redis做缓存和核销码的原子状态控制RabbitMQ处理订单超时未支付和核销后异步通知Nginx做反向代理。没有引入微服务没有上K8s连分布式事务都没用。为什么敢这么做因为单体能满足的并发绝对不用分布式增加运维成本数据库层面能解决的强一致绝对不用引入额外中间件。等到商户量真的大到需要拆分时按照商户ID做水平拆分也来得及。从我实际跑这个系统的体验来说单机部署、MySQL主从、Redis一主一从这套配置支撑中小规模商用的日活和并发完全没问题。真正会拖垮系统的从来不是框架而是乱用缓存、乱加索引和失控的慢查询。2. 五个必须吃透的核心业务细节2.1 团购订单状态机与核销码生命周期订单状态是整个系统的中枢。很多重复核销、对不上账的问题根子都出在状态设计得太粗。这套源码里订单状态和核销码状态是分开管理的这一点非常关键。订单表的status字段有这些值10待支付、20已支付待核销、21部分核销、30已核销完成、40退款中、50已退款、60已关闭。核销码表单独一个状态字段0未使用、1已使用、2已退款冻结、3已作废。为什么要拆开因为一单可以买多份套餐也就是生成多个核销码订单是否全部核销完毕要看核销码的聚合状态。允许部分核销是团购业务的刚需用户买了五张咖啡券用掉三张后不满意想退剩下两张这时候订单是21部分核销其中两张核销码变成2已退款冻结退款流程走完才变成3已作废。状态流转的约束也很严格只有20已支付待核销的订单能生成核销码只有0未使用的核销码能核销退款只允许从已支付或已核销状态发起且要检查是否已经全部核销。这套源码在状态流转的关键位置都加了校验方法不是随便改字段值。对应表结构大概是这样的CREATE TABLE order_info ( id BIGINT PRIMARY KEY, order_no VARCHAR(32) NOT NULL, merchant_id BIGINT NOT NULL, store_id BIGINT NOT NULL, user_id BIGINT NOT NULL, total_amount DECIMAL(10,2) NOT NULL, pay_amount DECIMAL(10,2) NOT NULL, status TINYINT NOT NULL COMMENT 10待支付 20已支付 21部分核销 30已完成 40退款中 50已退款 60已关闭, created_at DATETIME NOT NULL, updated_at DATETIME NOT NULL, UNIQUE KEY uk_order_no (order_no), KEY idx_merchant_status (merchant_id, status) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; CREATE TABLE verification_code ( id BIGINT PRIMARY KEY, code VARCHAR(32) NOT NULL, order_id BIGINT NOT NULL, merchant_id BIGINT NOT NULL, store_id BIGINT NOT NULL, product_id BIGINT NOT NULL, status TINYINT NOT NULL DEFAULT 0 COMMENT 0未使用 1已使用 2退款冻结 3作废, used_at DATETIME DEFAULT NULL, expire_time DATETIME NOT NULL, created_at DATETIME NOT NULL, UNIQUE KEY uk_code (code), KEY idx_order_id (order_id), KEY idx_store_status (store_id, status) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;字段不算多但每个状态都有索引支撑。实际排查订单问题时一条SQL就能从订单表跳到核销码表再跳到核销记录表整个过程非常顺。2.2 核销码生成规则既要防猜又要够短核销码是用户到店出示的凭证它有两个硬指标一是不能太长店员手动输入时要友好所以一般控制在10到12位二是不能被人批量枚举出来否则就会被人拿脚本把所有有效码试出来反复薅羊毛。UUID直接转上去长度是32位根本没法用。纯随机数倒是短但并发量大时数据库唯一索引碰撞会拖慢插入速度。这套源码里用的是雪花ID再做Base62编码。雪花ID本身是分布式唯一的Long型数值每个ID转成Base62之后大概是10位左右既保证全局唯一又有足够随机性。生成核销码的核心代码思路类似这样public String generateVerificationCode(long orderId) { long snowId IdWorker.nextId(orderId); return Base62.encode(snowId); }Base62用0-9a-zA-Z去掉容易混淆的字符也可以但要保证解码一致。真正防枚举不能只靠随机性接口层面还要加两层保护查询核销码必须要求用户登录态数据权限校验当前用户必须是这个核销码对应用单的归属用户核销接口则要求店员登录态并且只能查询到自己门店权限范围内的核销码。另外核销码在数据库里必须建唯一索引。这是最后一道防线不管代码里怎么校验唯一索引兜底重复插入直接报错就不可能产生两个一模一样的有效码。2.3 扫码核销的完整业务链路与幂等设计扫码核销看起来只是“扫一下、点确认”背后的调用链是完整的收银端扫到码后先请求校验接口接口返回券码对应的商品名、门店名、有效期、使用状态店员确认后再请求核销接口核销成功后才提示用户。这里最怕出现的问题是并发重复核销。举个很常见的场景顾客手机屏幕上有两个核销二维码的截图店员扫码枪扫了一下没反应又扫了一下两次请求几乎同时到达后端。如果代码是select status再update status两个事务都读到状态为0就会产生两个成功的更新操作。解决方式是用一句SQL做原子状态变更UPDATE verification_code SET status 1, used_at NOW(), verify_staff_id #{staffId} WHERE id #{id} AND status 0这样两个并发请求同时执行时只有一条SQL能影响一行数据另一个影响行数为0直接告知“该券已核销或不存在”。这是核销系统里最重要的兜底逻辑。如果想把体验做得更好可以先在Redis里用SETNX做一次预占把核销中的券码标记为处理中状态这样第二个请求在进入数据库之前就被挡掉连SQL都不用打。但Redis的方案要处理锁超时和宕机恢复数据库乐观更新反而是最不容易出错的主路径。核销成功后的动作也不能阻塞主流程。发通知、写营销流水、更新缓存这些操作统一丢到MQ里异步消费。这样一次核销接口的响应时间能稳定控制在100毫秒以内扫码枪和H5页面都会舒服很多。2.4 多商户数据隔离的行级权限实现多商户系统最隐蔽的问题是越权。常见表现是商户A的店长登录后抓包改了请求里的商户ID结果看到了商户B的订单列表。这种越权在单体系统里特别容易发生因为开发时很容易漏掉某个查询条件。这套源码的做法是在MyBatis层做统一拦截写了一个MerchantLineInterceptor拦截所有带merchant_id字段的Mapper方法自动把当前登录商户的ID拼接到SQL上。实现原理不复杂用MyBatis的Interceptor接口在Executor执行SQL前修改BoundSql检测SQL里是否存在merchant_id占位符存在则强制替换为当前上下文的值。商户上下文用ThreadLocal保存用户登录时把商户ID和门店ID塞进去请求结束时清理。这样做的好处是开发人员在写Mapper时甚至不用关注merchant_id的注入只需要在XML里写merchant_id #{merchantId}拦截器会自动填充。坏处是如果某张业务表不需要按当前商户过滤就要用注解标记否则会被强制拦截。我要提醒的是光靠拦截器还不够。订单详情查询、核销码校验这些方法除了行级隔离还要做归属校验。比如核销员只能核销本门店的券码这个无法靠统一的merchant_id过滤完成必须在方法逻辑里单独校验store_id。我见过不少线上事故就是核销员把同一个平台的商户B的券码在商户A核销了。所以行级权限是基础门店级校验是必须的补充。2.5 多语言国际化的四个落地细节标题里最容易被忽略的是“国际版”三个字。很多团队以为多语言就是翻译页面的按钮文案真正做起来才发现商品的名称、退款原因、短信通知、日期时区、货币格式全都是需要国际化的点。这套源码的文案类国际化用的是Spring标准的MessageSource机制。在resources下维护多个properties文件messages.properties做默认中文messages_en.properties做英文运行时通过LocaleContextHolder.getLocale()获取当前语言。请求进来时通过拦截器从Header的Accept-Language或lang参数解析语言环境设置到上下文里。动态内容的国际化比静态文案更麻烦。商品名称、商家公告这些数据是商户自己录入的不可能去改代码。所以这类表都设计了多语言字段比如product_name_json字段里面存的是{zh_CN:美甲套餐,en_US:Nail Set}这类JSON查询时根据当前语言取对应值。如果商品信息要支持检索再单独建一张product_language表做全文索引。货币和时区是国际版容易漏掉的点。订单金额、退款金额、佣金比例这些字段自始至终用Decimal存储展示层根据当前区域配置做格式化。订单创建时间统一存UTC查询时转成门店所在时区。这套源码在SystemConfig表里维护了一个区域配置项包含default_language、default_currency、timezone、date_format商户入驻时可以单独设置自己的区域配置而不是全局一套。如果以后接跨境支付还有关税、海外短信网关、多币种结算的问题。但作为团购扫码核销这个垂直场景先把货币和时区做好已经能覆盖大多数国际版的商用需求了。3. 从源码到商用部署的完整落地流程3.1 项目初始化与依赖清单拿这套源码做商用部署第一步不是改业务代码而是把运行环境对齐。JDK建议直接用JDK 17Spring Boot用2.7.x或3.x都可以MyBatis用mybatis-spring-boot-starterMySQL用8.0Redis用6.x以上。依赖清单按类型整理如下dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependency dependency groupIdorg.mybatis.spring.boot/groupId artifactIdmybatis-spring-boot-starter/artifactId /dependency dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-data-redis/artifactId /dependency dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-amqp/artifactId /dependency dependency groupIdcom.mysql/groupId artifactIdmysql-connector-j/artifactId /dependency目录结构建议保持清晰的模块化controller只做参数接收和路由service负责业务编排mapper只做数据库访问common放鉴权、异常处理、多语言解析、拦截器这些横向逻辑。不要为了赶进度把业务全堆在controller里后期排查核销问题会非常痛苦。3.2 数据库表设计里不能省的关键字段核心表的表结构在2.1里已经看过一部分商用部署前还有几张表必须确认商户表、门店表、员工表、核销记录表、结算表、系统配置表。商户表和门店表的字段是最容易出问题的。商户表的settlement_account字段必须加密存储不能明文门店表要有merchant_id和store_name、contact_phone、address、status。一个商户可能有多家门店核销员归属到门店这个关系一定要在建表时想清楚。核销记录表尤其重要它是后续对账和排查纠纷的核心依据。CREATE TABLE verification_record ( id BIGINT PRIMARY KEY, record_no VARCHAR(32) NOT NULL, verification_code VARCHAR(32) NOT NULL, order_id BIGINT NOT NULL, merchant_id BIGINT NOT NULL, store_id BIGINT NOT NULL, product_id BIGINT NOT NULL, staff_id BIGINT NOT NULL, verify_channel TINYINT NOT NULL COMMENT 1网页 2小程序 3扫码枪, status TINYINT NOT NULL, created_at DATETIME NOT NULL, UNIQUE KEY uk_record_no (record_no), KEY idx_order_id (order_id), KEY idx_code (verification_code) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;注意verify_channel和staff_id不能省。没有staff_id出了问题你根本不知道是哪个店员核销的没有verify_channel你就没法分析到底是哪个核销入口有问题。线上留痕的字段宁可多一些也不要后面再补。3.3 多语言与多租户环境的配置方式多语言的配置可以分成静态和动态两部分。静态文案在application.yml里配置资源文件路径spring: messages: basename: i18n/messages encoding: UTF-8i18n/messages.properties是默认中文i18n/messages_en.properties是英文。语言解析拦截器要根据请求参数优先原则实现有lang参数用它没有就用Header里的Accept-Language再没有就用商户默认语言。多租户的上下文配置也不复杂。写一个TenantContext类内部是ThreadLocalMerchantContextMerchantContext包含merchantId、storeId、staffId、locale。登录拦截器在校验完Token后从Redis里取出用户信息塞满上下文。注意要在Interceptor的afterCompletion里调用TenantContext.clear()否则线程池复用线程时会发生严重的数据串租户问题。另外异步线程里使用TenantContext要特别小心。MQ消费者线程没有Web请求上下文不能直接依赖ThreadLocal传递租户信息。正确做法是在投递MQ消息时带上merchantId消费时再手动构造上下文。这一点源码里已经处理好了但如果你要扩展异步任务一定记得沿用这个模式。3.4 支付回调与退款联动支付是商用系统绕不开的环节。国内用微信支付、支付宝国际版用Stripe、PayPal或者当地主流支付通道。源码里把支付封装成了PaymentProvider接口每个支付渠道一个实现类Controller层完全不感知具体渠道。支付回调的核心逻辑只有一个验签 - 幂等 - 更新订单状态 - 生成核销码。验签失败直接拒绝验签成功但订单已处理则直接返回成功不能再触发一次发码逻辑。退款逻辑同样要幂等而且退款成功后要把对应的核销码状态改成2退款冻结。这里有个坑支付回调里如果同时更新订单状态和核销码状态必须放在同一个数据库事务里。订单状态变成已支付但核销码没生成用户在“我的券包”里就会看到一个空数据。所以在事务边界设计上生成核销码这个动作必须和订单状态更新是同生共死的。3.5 Docker部署与上线前的检查清单部署这套源码最简单的方式是Docker Compose编排。应用容器、MySQL、Redis、RabbitMQ、Nginx各一个服务数据目录挂载到宿主机避免容器重启丢数据。Dockerfile核心内容很短FROM openjdk:17-jdk-slim WORKDIR /app COPY target/merchant-groupon.jar app.jar EXPOSE 8080 ENTRYPOINT [java, -Xms512m, -Xmx1g, -jar, app.jar]上线前我建议按这个顺序自查一遍所有对外接口必须走HTTPS不能用HTTP明文核销码图片接口要加访问时效控制不能让二维码永久有效Redis和MySQL的密码不要用默认密码日志要输出到文件且定期切割订单表和核销记录表要验证索引是否覆盖日常查询备份策略至少要做到每日全量加每2小时增量。代码层面还要检查一下是否有硬编码的语言和货币逻辑。比如日期格式化直接写死yyyy-MM-dd英文环境用户看着就会很别扭金额显示没有按Locale格式化英文环境会出现人民币符号和美元符号混淆的情况。这些问题测试环境不容易发现往往是在真实海外用户使用后才会暴露。4. 上线后最容易踩的坑与排查实录4.1 并发重复核销乐观更新手写错了前面提过核销更新要用WHERE status 0但在实际排查中我发现不少团队会写成先查再改的方式。这个问题的排查方式很简单在测试环境用JMeter开50个线程同时对同一个核销码发起核销请求看最终数据库里的状态和核销记录数。如果出现多条核销记录或状态被覆盖更新那就是SQL写错了。正确做法再强调一遍int rows verificationCodeMapper.updateStatusToUsed(code, staffId); if (rows 0) { throw new BusinessException(核销失败该券可能已被使用); }对应的SQL是UPDATE verification_code SET status 1, used_at NOW(), staff_id #{staffId} WHERE code #{code} AND status 0影响行数为1才是核销成功。这个逻辑无论后续怎么加缓存、加锁数据库这一层都必须保留它是最终的强一致屏障。4.2 多语言切换后页面出现“串词”上线后最诡异的问题是商户后台切到英文后有些菜单还是中文有些商品名却变成了上一家商户的语言。排查到最后发现是两个原因叠加。第一个原因是Redis缓存的key没有带上locale。商品信息查询先查Redis缓存key只包含productId那么第一次用中文查后缓存了中文商品名英文用户访问时就命中中文缓存直接展示错语言。解决办法是缓存key里拼上locale比如product:detail:{locale}:{productId}语言不同就不共享缓存。第二个原因是ThreadLocal没清干净。一个线程处理完中文请求后没有清除LocaleContextHolder线程被复用处理英文请求时语言解析仍沿用上次的中文上下文。这个问题的排查方法是在拦截器的afterCompletion里打日志确认每次请求后的locale是否被重置。4.3 数据越权测试时总漏掉的场景行级权限的越权问题不像并发问题那么明显往往要上线运营一段时间有商户反馈“看到了奇怪的数据”才会暴露。最常见的是商户列表查询接口没有带merchant_id导致商户A登录后能看到全平台的商品。排查这类问题我建议写一套越权测试用例。登录商户A的账号然后尝试访问商户B的订单详情、核销记录、结算单所有接口返回403或404才通过。除了修改路径参数还要重点测List接口的分页参数因为很多越权都发生在分页时全局查询漏加了条件。另外一个容易出问题的点是报表导出。导出功能经常为了图省事直接复用不带权限的统计mapper结果商户A导出的是全平台数据。如果系统里加了MyBatis拦截器务必确认导出查询走的也是同一个Mapper代理而不是自己拼接SQL另起炉灶。4.4 核销响应变慢慢SQL和缓存穿透核销接口从开发环境的毫秒级到线上变成2到3秒这种事我遇到过不止一次。排查思路从慢SQL日志开始重点看有没有全表扫描。核销码表数量级上来之后靠uk_code唯一索引查单条记录没有问题但订单列表页的查询如果没建好联合索引就会非常吃力。idx_merchant_status这种索引必须有否则商户端“今日订单列表”每翻一页都全表扫。另一个常见问题是缓存穿透某个核销码被恶意批量请求每次都不存在请求直接打到数据库。解决办法是查不到也缓存一个空值或者用布隆过滤器挡一层。核销码查询接口还要注意加频控。同一个核销码在短时间内被大量查询正常业务场景几乎不存在绝大多数是脚本扫描。频控的逻辑可以放在网关层也可以先用Redis的INCR做简单计数超过阈值直接返回“操作过于频繁”。这个保护在商用系统里必须做不然后台数据库会被薅到垮。4.5 商用运营必须做的运维安全清单最后说几个商务层面容易漏的事情。国际版商用不是代码写完就结束的至少要注意用户隐私数据的加密存储手机号、邮箱这类敏感字段要加解密日志里不能打印明文支付渠道需要申请正式的商户资质测试号不能用于生产退款流程要不要支持部分退款涉及核销码冻结逻辑必须在产品上线前定清楚平台抽佣比例、结算周期、最低结算金额这些参数要支持后台动态配置不要写死在代码里。监控方面至少要盯住四个指标核销接口的P95耗时、核销码表的慢查询数量、支付回调的成功率、MQ消息积压量。这四个指标直接对应核销体验和资金安全任何一个出事都不能过夜。日志审计也要保留尤其是核销和退款操作必须记录操作人、操作时间、请求来源、变更前后的状态值。后面万一出现用户纠纷这些日志就是最直接的证据。最后再分享一个小经验这套系统上线后建议先选一个小商圈的两到三家商户试运行两周把人工核销、扫码枪核销、H5核销、用户退款这些主链路全部跑一遍。不要一上来就铺几十家商户因为多商户系统真正考验的不是功能而是权限边界和并发状态的一致性问题。小范围试运行阶段把该踩的坑都踩掉再铺开规模整体风险会小很多。我当时就是在一个连锁奶茶品牌的五家门店先跑通的两周内发现并修复了三个并发问题和两处越权漏洞如果没有这段试运营直接放开给所有商户后果不敢想。商用系统最重要的是稳不是快。