1. 项目全貌:同城活动系统的业务逻辑与架构解构
1.1 用户画像与核心需求拆解
做这个"Spring Boot基于微信小程序的同城活动组织系统"之前,我一开始以为就是个普通的信息发布平台,等真正开始梳理需求才发现,同城活动的业务链条比想象中长很多。它至少要覆盖三条链路:活动发起人从创建、编辑、发布到活动结束后的数据统计;参与者从浏览、报名、支付(或免费占位)、到场签到到活动评价;平台侧其实还隐含一条审核和风控链路,用来处理异常活动、用户举报、名额超售等等。
同城活动这个概念本身有个很关键的特质:它是强地理属性的。跟电商、内容资讯这类纯线上业务不同,活动参与者必须依赖地理位置来完成筛选,否则一个在北京的用户看到广州的活动毫无意义。所以在需求拆解阶段,我花了大量时间在“同城”这个维度上做文章:按城市定位默认展示本地活动、按距离倒序展示附近活动、按行政区划做列表筛选。这些看起来只是产品交互问题,真正落到后端就是一套带经纬度字段的活动表、一种地理位置搜索方案、以及前后端关于定位精度的约定。
用户角色的差异也会直接影响权限设计。组织者能创建活动、管理报名名单、核销签到码;普通用户只能浏览和报名。平台管理员则需要一套独立的后台,审核活动内容、处理投诉、下架违规活动。这三类角色如果一开始不做清楚,后续写接口会非常痛苦,尤其是那种“这个字段普通人也能改吗”的边界问题。
1.2 功能切片:三类角色的闭环
把整个系统切成功能模块之后,我习惯先画一张简单的功能清单。组织者端是活动管理模块,包括新建活动、编辑活动、设置名额和报名截止时间、生成签到二维码、查看报名数据;参与者端是活动发现模块,包括首页信息流、搜索、筛选、报名、取消报名、查看我的活动、签到;管理端需要实现活动审核、分类管理、用户封禁、内容举报处理。
这套功能切片的逻辑是:每个角色都有一个从“入口到闭环”的核心路径。参与者最核心的闭环是“看到活动 -> 报名 -> 到场 -> 签到成功”;组织者的闭环是“创建活动 -> 审核通过 -> 有人报名 -> 现场核销 -> 活动结束”;管理端的闭环是“内容上报 -> 审核 -> 通过或者驳回”。我后来在功能开发时发现,只要某个闭环里有一个环节断裂,用户就会流失。比如参与者报名成功后在小程序里根本找不到自己的订单记录,那这个系统就相当于白做了。
所以在设计时,我特意把“我的活动”拆成三个页签:待开始、进行中、已结束。这个看起来很小的设计,实际上解决了参与者对状态感知的痛点,也减轻了后端接口的压力,因为用户总是带着明确的查询条件过来拿数据。
1.3 技术选型的理由推演
选 Spring Boot + 微信小程序这套组合,很多人觉得是“经典到没啥好说”的方案,但真对比过就知道这组合的实际优势在哪里。小程序端的优势不用多说,微信的生态让用户省去了下载安装 App 的过程,分享、支付、订阅消息全部走微信原生能力,对一个同城活动这种低频但强社交属性场景来说,获客成本和传播效率是碾压式的。
后端选 Spring Boot 的理由其实有两层。一层是它的开发效率在 Java 技术栈里确实最能打,内置 Tomcat、自动配置、starter 生态非常完整,写 CRUD 接口可以很快;另一层是它周边的基础设施特别成熟——Spring Security、Redis、MySQL、MyBatis-Plus,随便一个组合都有大量现成方案,出了问题 Stack Overflow 上一搜就是答案,这对独立开发者和小团队特别友好。换成 Node.js 或 Go 也有很多不错的实践,但如果是接手一个需要长期维护的项目,Spring Boot 的稳定性和团队招聘的匹配度会更好。
架构上的核心思路是前后端完全分离,小程序端只通过 HTTP 接口与后端通信,服务端只做数据存储和业务逻辑,不关心页面长什么样。部署结构上我用了比较常规的三层:Nginx 负责静态资源与反向代理,后端服务部署在一个独立的应用节点上,Redis 做缓存和分布式锁,MySQL 做持久化存储。
2. Spring Boot 后端落地实录
2.1 工程骨架与依赖清单
后端部分我采用的是 Spring Boot 2.7 + JDK 8 的组合。2.7 版本是 2.x 系列里最成熟的一个,兼容性也稳,对 JDK 8 的支持非常友好。初始化工程我直接用阿里云脚手架生成,选好 Web、MyBatis-Plus、Redis、Validation、Lombok 这几个核心依赖即可。
POM 文件里有一个很容易被新手忽略的点:微信小程序的接口调用,其实还需要引入 HTTP 客户端工具,比如 Hutool 的 HttpUtil 或者 Spring 自带的 RestTemplate。我一直习惯用 Hutool,因为它封装的够好,省掉了 HttpClient 那一大堆配置细节。微信接口要调用的场景不算多,主要是 code2Session、获取 access_token、发送订阅消息这几类,但每一类都需要可靠的 HTTP 调用能力。
一个完整的依赖清单大致长这样:
- spring-boot-starter-web、spring-boot-starter-validation
- mybatis-plus-boot-starter
- mysql-connector-java
- spring-boot-starter-data-redis
- spring-boot-starter-security(只用于 JWT 校验和权限拦截,不用它的表单登录)
- hutool-all
- fastjson2(处理 JSON 比 Gson 更顺手)
2.2 数据库表设计:活动、报名、用户、签到
数据库设计是这类系统的地基,我踩过一次表设计不合理的坑之后,现在都会多花一些心思在表结构的扩展性和约束设计上。核心表一共四张:用户表(user)、活动表(activity)、报名表(enrollment)、签到记录表(checkin)。
活动表是最复杂的,字段基本可以分成四组。基础信息有标题、封面图、详情描述、分类;时间地点有开始时间、结束时间、报名截止时间、省市区编码、详细地址、经度、纬度;控制维度有总名额、已报名人数、发布状态、审核状态;附加配置有是否需要付费、每人限报名次数、是否需要审核报名。
报名表设计时我额外加了一个字段:报名码。这个字段平时看起来没用,但现场签到时它就是参与者的核验凭证,对应组织者小程序里要展示的二维码内容。所以报名码必须具备唯一性和随机性,用 8 位随机字符串拼接时间戳就够了。
用户表在微信登录的前提下设计得比较轻量。核心字段是 openid、unionid、昵称、头像、手机号。unionid 这里需要重点说明一下:如果系统以后要扩展到公众号或者其他微信应用,unionid 就是打通用户身份的钥匙。但拿到 unionid 的前提是前端在调用 wx.login 时必须带着同一开放平台账号下的其他应用信息,这个在小程序刚起步时经常被忽略,现在养成习惯先预留字段。
2.3 登录认证:微信登录 + JWT 的方案细节
登录流程是用户第一次打开小程序的必经之路。小程序端调用 wx.login 拿到一个临时 code,后端拿这个 code 去请求微信的接口换 openid 和 session_key,然后后端生成自己的 JWT 令牌返回给小程序。小程序后续的每个请求都在 Header 里带上这个 token,后端拦截器解析 token 就能知道当前是哪个用户。
这里有一个非常常见的坑:小程序每次冷启动时 code 都会变化,但换出来的 openid 是固定的。所以后端判断“是不是同一个用户”必须用 openid,不能用 code。JWT 令牌中真正不能少的信息就是 userId 和 openid,能不放多余信息就别放,减少被解密的暴露面。
JWT 的具体实现我用的是 jjwt 库,关键代码大致是一个登录控制器加上一个拦截器。拦截器负责从请求头拿 token,用密钥验签,再把用户信息塞到 ThreadLocal 里面,方便后续业务代码使用。整个流程跑通之后,有一个容易遗漏的点是 token 过期时间。太短的话用户频繁被踢下线,太长的话被盗用的风险又变高。我最终设的是 30 天,对一个低频活动报名类小程序来说,这个时长体验和安全性相对平衡。
2.4 活动发布接口与状态机设计
活动发布不是简单写一条数据到数据库,它背后有一个状态机在流转。我一开始没做状态机,结果运营反馈后台经常出现“活动已经结束但前端还在展示”的尴尬,后来我老老实实加了状态字段,并且把这个状态的迁移规则写清楚。
活动的审核状态有:待审核、审核通过、审核驳回。发布状态相对简单:草稿、已发布、已下线。用户提交活动之后,它首先进入待审核状态,平台管理端审核通过后才允许发布;如果被驳回,组织者可以修改后再次提交。程序化约束是,只有审核通过的活动才能在小程序首页被搜索到,只有已发布状态的活动才接受报名。
实现状态机的过程里,我最深的体会是:不要让状态字段散落在各个接口里到处赋值。我专门写了一个 ActivityStatusService,所有关于状态的变更都收敛在这个类里面,每个变更方法内部先校验当前状态是否允许迁移。这样看起来多一点代码,但后期运营提各种状态变来变去的需求时,你只会庆幸当初这么做了。
2.5 报名接口:名额校验与防超卖
报名接口是整个系统里并发压力最集中的地方。一个热门活动放 50 个名额,开抢的时候可能有几千个人同时点报名,如果数据库层面没有做并发控制,马上就会出现超卖问题。
常规做法是乐观锁加版本号:报名的时候 UPDATE activity SET version = version + 1, enrolled = enrolled + 1 WHERE id = ? AND version = ?。如果更新影响行数为 0,说明有人抢先改了数据,当前请求只能重试或者提示名额已满。这个方案在绝大多数场景下都够用,但我这里用的是 Redis 分布式锁 + Lua 脚本的方式,原因很简单:报名流程不止一次更新,它需要同时检查名额、插入报名记录、扣减库存、更新报名人数,多个操作放在一起必须保证原子性。Redis 的 Lua 脚本天然支持原子执行,把这几个操作全部塞进脚本里面,既能防止超卖,又能把事务的复杂度跟数据库解耦。
这里要特别强调一下 Lua 脚本的细节。名额扣减前,要先用 EXISTS 判断活动库存 key 是否存在;如果不存在,说明活动是第一次被报名,需要从数据库加载初始名额;如果存在,就直接判断剩余名额是否大于 0。脚本执行完成后,再把报名明细异步写入 MySQL。为什么是异步?因为 Redis 事实上已经承担了名额控制的职责,MySQL 只需要最终一致性地记录报名数据,用户是不用等在数据库落库之后的。异步带来的另一个好处是,即使某次异步落库失败,也可以通过定时任务补偿,不会影响用户报名操作的成功感知。
3. 微信小程序端实现细节
3.1 登录态管理与 token 打通
小程序端登录流程有个体验上的细节:尽量做静默登录,不要弹任何授权框。wx.login 在基础库 2.21.0 之后已经不会再强制用户授权了,所以默认进小程序就先调 wx.login 拿 code,然后请求后端登录接口换取自定义 token,存到 storage 里。
之后每次请求都从 storage 取 token,塞进 Header 的 Authorization 字段。如果接口返回 401,就清空 storage 并再次静默登录。这个机制说起来简单,但真机上有一种很隐蔽的问题:开发工具里正常,真机上第一轮 401 之后重新登录成功了,但之前那个请求的数据没回来。我用 Promise 封装了一个 request 方法,对所有 401 请求做重放队列处理,也就是把并发请求先暂存,等刷新 token 回来后再统一重放,这样就不会出现请求丢失的现象。
3.2 首页信息流与距离筛选
首页是用户进入系统后的第一面。我的设计方案是列表页默认展示“同城全部”,服务端根据当前城市编码返回活动列表;顶部加一个筛选区,支持按距离、按分类、按时间排序。距离排序的实现很直观:前端通过 wx.getLocation 拿到用户当前经纬度,传给后端,后端用经纬度做距离计算和排序。
数据库层面做地理查询,最轻量的方案是用 MySQL 的 HAVERSINE 公式直接在 SQL 里算距离:
SELECT *, ROUND(6371 * ACOS(COS(RADIANS( ? )) * COS(RADIANS(lat)) * COS(RADIANS(lng) - RADIANS( ? )) + SIN(RADIANS( ? )) * SIN(RADIANS(lat))), 2) AS distance FROM activity WHERE status = 1 AND audit_status = 1 HAVING distance < ? ORDER BY distance LIMIT 20这个方案适合数据量在一万以内的场景,效果很直接。如果活动数据量上了十万、百万,就要考虑换 MongoDB 或 Elasticsearch 的地理索引,不过那就属于另一个量级的问题了,这里不做展开。前端拿到列表数据后,用距离字段做标记展示,比如“距离你 1.2km”,用户的选择成本会降低很多。
3.3 活动详情、表单校验与报名
活动详情页展示图片轮播、活动信息、组织者信息、剩余名额、已报名人数。详情页的报名按钮做了三种状态:未开始、已满、进行中。未开始时按钮置灰,点击提示“报名尚未开始”;已满时按钮显示“已报满”;进行中才可以点击跳转报名页。
报名页要处理的是动态表单逻辑。免费活动只需要填姓名和手机号(手机号用于现场核销短信通知,选填的可以留空);付费活动则要接微信支付。如果活动开启了报名审核,报名提交后状态是“待审核”,审核通过后才能生成签到码。这里我给小程序端增加一个定时轮询的逻辑:用户在待审核页面停留时,每 10 秒请求一次报名状态,审核通过后自动跳转到“已报名”页面,同时弹窗提示“审核通过,请按时到场”。
前端报名提交时还有一个重要校验:活动开始前 30 分钟不允许取消报名。这个校验前端要做,后端接口也要做,否则用户绕过小程序直接调接口就能破坏规则。我在后端报名接口里用 System.currentTimeMillis() 去比对了当前时间和活动开始时间,差值小于 30 分钟直接抛业务异常。
3.4 组织者核销与签到
组织者端目前是小程序里的一个隐藏入口,通过角色权限判断展示。组织者点开自己发布的活动,能看到一个“签到核销”按钮,点击后进入扫码页面。签到码的形式我选用的是动态二维码,内容是一个加密后的串,包含报名记录 ID 和活动 ID。
扫到二维码之后,组织者端调用后端核销接口,后端解密二维码内容、校验用户是否报名过该活动、校验活动是否处于进行中时间段,然后把签到记录写入表里、更新签到时间和签到状态。这个流程最需要处理的是防复用:同一个报名码只能签到一次。我在签到记录表中给报名记录 ID 建了唯一索引,从数据库层面彻底杜绝重复签到的可能。
另一个细节是:组织者需要离线核销的场景经常被忽略。现场网络不稳定,扫半天码没有反应,体验非常糟糕。我的方案是给组织者端增加一个“离线缓存名单”功能,活动开始前可以一键把报名名单下载到本地,现场扫码时先走本地匹配,等网络恢复后再异步同步到服务端。这个功能投入不大,但对线下场景的可靠性提升非常明显。
4. 消息通知与定时任务的设计
4.1 订阅消息的使用限制
订阅消息是微信小程序触达用户的核心手段,但它的规则有个很关键的限制:每个用户授权一次,只能发送一次通知。也就是说,用户点了一次“允许”,后端只能给他发一条订阅消息,发完额度就没了。这个限制直接决定了消息发送策略的设计方向。
我在系统里设计了三种订阅场景:报名成功后提醒、活动开始前提醒、审核结果通知。每个场景对应不同的模板 ID,用户需要在对应的页面手动点击“订阅”按钮。因为一次性订阅的限制,我之前“报名成功后顺便订阅活动开始提醒”的做法行不通了,从产品上妥协为:报名流程里引导用户连续点击两次订阅按钮,一次绑定报名结果通知,一次绑定活动开始提醒。虽然交互上略繁琐,但至少逻辑上走得通。
4.2 活动开始前的提醒任务
活动开始前提醒,不可能靠用户手动触发,必须由后端定时任务批量发送。我的实现方案是使用 Spring Task,每个小时跑一次任务,扫描接下来 2 小时内开始、且未发送过提醒的活动,把该活动的所有有效报名记录捞出来,逐条发送订阅消息。
这里有个数据库层面的小设计:因为一次性订阅额度限制,我不能每次去订阅消息表里捞那些已经消耗过额度的用户。所以我在订阅消息记录表里增加了 send_status 字段,发送成功后置为 1,定时任务只捞 send_status = 0 的记录。再配合 Redis 的幂等标记,防止同一个用户在并发场景下收到两条重复消息。
批量发送还有一个性能问题:一次活动可能有上千条报名记录,逐条调用微信接口发消息太慢。我用的方案是分批发送,每批次 100 条,批次间暂停 100 毫秒,避免被微信接口限流。实践下来这个节奏是稳的,没出现过被风控的情况。
4.3 名额释放与候补机制
取消报名这个操作,除了要删报名记录,还要把活动名额释放出来。释放的逻辑是在 Redis 里执行 INCR,把之前扣减的库存补回去。这里最容易踩的坑是:如果用户已经签到或者活动已经开始了,就不允许取消,释放逻辑也要做相应的状态判断。
候补机制是后期运营提的需求:活动名额已满时,用户可以选择“候补”,当有别的用户取消报名时,候补用户按顺序顶替。实现方式我选择了简单可靠的队列思路:候补名单底层用 Redis List,先进先出;释放名额时从 List 左侧弹出一个候补用户,自动帮他完成报名,然后给他发送订阅消息告诉“候补成功”。这套机制上线后,活动的报名率肉眼可见提升了几个点,因为用户知道自己放弃后还有候补机会,反而更愿意报名。
5. 常见坑位与排查清单
5.1 并发报名死锁与超卖
我第一次上线活动报名功能时,当天就出了事故。用户抢票的瞬间,后端 MySQL 报了死锁错误,部分用户报错“系统繁忙”。排查下来其实不是分布式锁的问题,而是我在 MySQL 事务里先更新活动表、再插入报名记录,多个并发事务持锁顺序不一致导致死锁。解决办法有两个:一是调整 SQL 顺序,所有事务都先更新活动表再插入报名表,保持锁顺序一致;二是把核心名额控制挪到 Redis,也就是前面说的 Lua 脚本方案,数据库只做结果落库。采用 Redis 方案之后,并发能力和稳定性都有了明显改善。
5.2 文件上传与安全审核
活动封面图、详情里的图片,我一开始直接上传到服务器本地目录,结果后台很快就收到用户反馈图片加载太慢,而且运维同事还发现服务器磁盘快满了。后来迁移到对象存储,前后端直传的方式:前端先向后端要一个临时上传凭证,然后直接传文件到对象存储,成功后把文件 URL 回传给后端保存。整个流程能省掉服务器中转带宽,加载速度提升非常明显。
图片安全审核这块不能省。同城户外活动经常涉及人员聚集场景,内容审核是平台必须承担的责任。我的方案是接入了图片内容检测服务,上传时自动调用一次,返回结果为“违规”时直接拒绝上传。人工审核作为兜底,管理后台可以查看所有待审核图片,做二次确认。
5.3 小程序审核与真实域名
小程序发布前必须过微信官方审核,这是最容易让人提前掉头发的环节。审核不通过的常见原因有:类目选择不对、功能未开发完成(审核员会点进空页面)、页面必须有真实的用户体验而不能是纯测试数据。
我建议在上线前一个月就准备审核材料:把小程序类目选对,比如“社交-社区/论坛”或者“生活服务-活动/票务”;真机测试录制操作录屏,作为补充说明随提交材料一起塞进去。域名必须是 HTTPS 并且备案过的,在开发者后台配置 request 合法域名时,注意同一时间只能有 5 个内网域名,开发阶段要合理规划好是装 18 还是 20 个。
5.4 定位权限与地图差异
小程序获取定位权限,基础库版本不同行为也会不一样。低版本直接弹窗让用户授权,高版本必须先去 wx.getSetting 检查授权状态,未授权时引导用户去开启。我写了一个统一的定位工具封装,核心逻辑是:先取缓存里的城市和经纬度,缓存过期或没有时再走 wx.getLocation,取失败则默认到北京。这种降级策略保证了用户即使拒绝授权,首页也能正常展示活动列表。
地图相关的坑更多。小程序的 map 组件经纬度坐标标准是 gcj-02,如果你后端存的坐标是 wgs84 标准的,两者相差几百米到几公里不等。所以我在后端单独存一份火星坐标系的经纬度字段,地图展示和距离计算统一用这组数据,避免坐标系错乱。
5.5 真机与开发者工具的差异
开发工具里跑得好好的代码,真机上翻车的情况我遇到太多回了。最典型的几个问题:一是开发者工具里 wx.request 默认不校验合法域名,真机上必须配置正确;二是开发者工具的网络请求不会触发 429 限流,真机上频控一来直接大量报错;三是开发者工具的 storage 不会清理,真机上小程序回收后 storage 可能丢失,必须做容错处理,token 为空时重新静默登录。
所以我的建议从一开始就是:每周至少抽半天用真机回归一遍主流程。不要依赖开发者工具的模拟器,模拟器代表不了真实用户的网络环境和性能。
做这套系统最深刻的体会是,领域业务复杂的地方不是技术难度,而是对线下场景的理解。我自己就在一次模拟现场活动时踩过签到的坑:活动开始时间到了,用户迟迟扫不了码,查了半天发现是手机网络切到 4G 之后二维码组件加载失败,前端本地缓存没有兜底,最后全靠现场临时找纸质签到表救场。后来给二维码加了一个离线预览的本地缓存,情况才好很多。
最后再分享一个小技巧:开发这类同城系统时,尽量把后台管理页面提前规划成一个独立的 PC Web 管理端,不要想着在小程序里塞一个完整的管理后台。移动端能做管理操作的交互空间实在有限,页面越多审核也越难通过。管理端独立后,数据和接口复用同一套后端服务,权限粒度可以做得更细,运营效率也更高。这个取舍我认为是整个项目里最值得的架构决策之一。