小区团购群的接龙消息刷了几百条还没统计明白的时候我就在想与其天天人工整理订单不如直接做一个社区团购系统。用JavaVueSpringBoot这套组合把用户下单、团长核销、平台管理整条链路打通也算是把这几年积累的后端和前端技能真正用在一个能落地、有人用的项目上。这篇文章把我从零搭建这个社区团购系统的完整思路写出来包括业务模型怎么拆、技术选型为什么这么定、后端接口和数据表怎么设计、前端页面怎么组织、部署上线会遇到哪些坑。内容偏向实战代码和配置给的是关键部分适合有一定Java和Vue基础、想做一个完整全栈项目练手或者公司真有社区团购需求的开发者参考。1. 做社区团购系统先要把业务模型想清楚很多开发者拿到这类项目容易犯一个毛病上来就建表写接口结果写到订单模块发现逻辑绕不过去又回头改表结构。我在动工之前花了整整两天把业务链路梳理清楚事实证明这个时间花得非常值。1.1 社区团购的核心角色与交易链路社区团购和普通电商最大的区别在于它有一个中心化的预售自提模型。普通电商是用户下单、商家快递发货物流链路是平台到用户社区团购则是平台组织商品、用户在截单时间前下单、平台统一配送到小区自提点、用户去团长那里取货。整个系统涉及三个核心角色C端用户、团长、平台运营。用户在小程序或H5页面浏览当日可团的商品加入购物车后统一结算团长负责维护自己小区的用户群引导用户下单同时承担收货、分拣、核销的职责平台运营负责上架商品、设置团购场次、统计订单、处理售后和结算团长的佣金。交易链路可以概括为用户下单后生成订单并支付平台根据订单汇总生成采购单供应商发货到平台仓平台按小区维度分拣配送团长收货后在系统里核销用户的自提码用户取货后订单完成最后平台和团长做周期结算。这个链路决定了系统的数据流方向也直接决定了数据库表该怎么设计。1.2 三个端一个中心的功能模块划法功能模块我按照三个端一个中心来划分用户端、团长端、管理后台以及一个贯穿全局的订单中心。用户端的核心功能是微信授权登录、浏览商品按分类、按场次、搜索商品、购物车、下单支付、订单列表与详情、售后申请、个人信息维护。这块是C端用户直接接触的交互要轻、打开要快所以前端我用了Vue3配合Vant组件库来做H5。团长端的功能偏工具化团长申请与审核、自提点信息维护、用户取货核销扫码或手动输入核销码、佣金明细查看、提现申请。团长每天高频使用的是核销这个动作所以这个模块一定要做到手机上两步以内完成核销。管理后台是给运营用的Web端功能包括商品管理SKU、库存、上下架、团购场次管理开团时间、截单时间、配送时间、订单管理按小区、按场次、按状态筛选导出、用户管理、团长管理、售后处理、佣金结算、数据看板GMV、订单量、热门商品。订单中心是三端共用的底层模块所有跟订单状态变更、支付回调、库存变更相关的逻辑都收敛在这一层保证三端看到的数据是一致的。1.3 为什么说小区团长是数据建模的关键社区团购系统里小区不是一个简单的地址字段它是整个业务的组织单元。平台按小区设置自提点按小区创建团购场次按小区做配送批次甚至佣金比例也可能按小区维度做差异化配置。我在设计数据库的时候把小区提到了和用户、商品同级的核心实体来处理。用户表里冗余了community_id和小区的名称方便用户端首页直接按小区展示对应的团购场次和自提点团长表也是挂在小区下面的一个小区可以有一个或多个团长其中一个是主理人这样后续做佣金结算和配送批次都能找到清晰的数据归属。这个决策带来的直接好处是后面写按小区聚合订单生成配送单这个功能时一条SQL就能把某个小区某场次的订单全部拉出来不需要再去解析地址字符串。如果你做这类系统时把小区当成普通字符串存在用户表里后端的聚合逻辑会非常痛苦。2. 技术选型不是堆新技术而是看业务撑不撑得住社区团购系统这种业务形态技术上其实不需要特别前沿的东西核心诉求是稳定、快、好维护。我最终选的是SpringBoot 2.7 Vue3 MySQL 8 Redis这套组合下面说下每个选型背后的判断。2.1 后端选SpringBoot的理由SpringBoot已经是Java后端事实上的一站式框架选它主要是三个原因。第一是生态成熟。社区团购涉及的微信支付、微信登录、短信验证码、对象存储这些能力SpringBoot都有非常成熟的整合方案不用自己去造轮子。比如weixin-java-pay和weixin-java-miniapp这两个开源SDK封装了微信支付的统一下单、回调验签、退款以及小程序登录的code2session接入成本很低。第二是开发和部署效率高。SpringBoot内置Tomcat打包成可执行的Jar包直接java -jar就能跑配合Nacos或者Spring Cloud Config做配置中心也比较方便。对于我这种一个人包揽前后端的情况少配置一项就少一个出错的概率。第三是招人容易、团队接手成本低。社区团购如果后面要做大需要扩充开发人员的话Java后端的人才池子最大SpringBoot这套技术栈几乎人人都会不像用Go或者Node后端那样存在团队磨合成本。2.2 前端为什么用Vue3 Vant前端我分了两块用户端和团长端用了H5挂在微信公众号或者微信浏览器里访问管理后台用了独立的PC端Web。H5这块选型Vue3 Vant主要考虑的是开发效率和体验的一致性。Vant是移动端组件库里面的商品卡片、地址编辑、订单列表、支付弹窗这些组件跟我这个系统的页面需求匹配度很高改改样式就能用比从零写一套移动端UI省下大量时间。管理后台用的是Vue3 Element Plus因为后台页面密度大、交互重Element Plus的表格、表单、弹窗、树形组件非常成熟配合vxe-table做大数据量的表格展示也很流畅。前后端分离的好处是部署灵活。H5和服务端可以分开扩容管理后台挂了不影响用户下单而且Vue的工程化开发方式在多人协作时也更有秩序组件复用很顺畅。2.3 MySQL和Redis各自的定位MySQL8承担的是一切核心交易数据的持久化用户、商品、订单、支付流水、佣金明细全部落在MySQL。考虑到读多写少的业务特性表结构设计上做了一些冗余来换取查询性能比如订单表冗余了商品名称和商品图片的缩略图订单列表页就不需要再联表查商品表了。Redis在这里面承担的是三类事缓存热点数据、分布式锁、限流计数器。商品详情页是读压力最大的接口我用Redis做了两级缓存第一级是商品详情缓存第二级是商品库存计数用户提交订单时用Redis的DECR命令预扣库存避免多个用户同时下单时超卖微信支付回调接口做了简单的IP限流防止恶意请求打爆回调地址。消息推送这块我没有引入MQ因为社区团购当前的订单量级用不上。真要做订单超时未支付自动关闭我用的是Redis的过期键监听方案下单时把订单号写入一个带过期时间的Key过期时间设为15分钟收到过期事件后查询订单状态如果还是未支付就自动关闭。这个方案要注意Redis的过期键监听默认不是100%可靠生产上建议还是用定时任务扫表兜底。3. 后端核心模块我把每一个关键设计讲透后端写代码本身不难难的是业务逻辑怎么抽象、数据一致性怎么保证、权限边界怎么划。下面这几个模块是我觉得最核心也最有代表性的。3.1 数据库表设计从商品到订单的主线关系我先给出一组核心表的结构然后逐个解释设计意图。-- 小区表 CREATE TABLE community ( id bigint(20) NOT NULL AUTO_INCREMENT, name varchar(100) NOT NULL COMMENT 小区名称, address varchar(255) DEFAULT NULL COMMENT 详细地址, region_code varchar(20) DEFAULT NULL COMMENT 行政区划编码, status tinyint(4) NOT NULL DEFAULT 1 COMMENT 状态 1-正常 0-停用, create_time datetime NOT NULL, PRIMARY KEY (id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT小区表; -- 商品表 CREATE TABLE product ( id bigint(20) NOT NULL AUTO_INCREMENT, category_id bigint(20) NOT NULL COMMENT 分类ID, name varchar(200) NOT NULL COMMENT 商品名称, sub_title varchar(500) DEFAULT NULL COMMENT 商品副标题, main_image varchar(500) DEFAULT NULL COMMENT 主图URL, detail text COMMENT 商品详情, price decimal(10,2) NOT NULL COMMENT 售价, original_price decimal(10,2) DEFAULT NULL COMMENT 原价, total_stock int(11) NOT NULL COMMENT 总库存, sales int(11) NOT NULL DEFAULT 0 COMMENT 销量, status tinyint(4) NOT NULL DEFAULT 1 COMMENT 状态 1-上架 0-下架, create_time datetime NOT NULL, update_time datetime NOT NULL, PRIMARY KEY (id), KEY idx_category (category_id), KEY idx_status (status) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT商品表; -- 团购场次表 CREATE TABLE session ( id bigint(20) NOT NULL AUTO_INCREMENT, community_id bigint(20) NOT NULL COMMENT 关联小区, name varchar(200) NOT NULL COMMENT 场次名称, start_time datetime NOT NULL COMMENT 开团时间, end_time datetime NOT NULL COMMENT 截单时间, delivery_time datetime DEFAULT NULL COMMENT 预计送达时间, status tinyint(4) NOT NULL COMMENT 状态 1-报名中 2-进行中 3-已结束 4-已取消, PRIMARY KEY (id), KEY idx_community_time (community_id, start_time) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT团购场次表; -- 场次商品表一个场次包含哪些商品 CREATE TABLE session_product ( id bigint(20) NOT NULL AUTO_INCREMENT, session_id bigint(20) NOT NULL, product_id bigint(20) NOT NULL, session_price decimal(10,2) NOT NULL COMMENT 场次价格, stock int(11) NOT NULL COMMENT 场次库存, limit_count int(11) NOT NULL DEFAULT 0 COMMENT 每人限购数量 0-不限, PRIMARY KEY (id), UNIQUE KEY uk_session_product (session_id, product_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT场次商品表; -- 订单表 CREATE TABLE order ( id bigint(20) NOT NULL AUTO_INCREMENT, order_no varchar(64) NOT NULL COMMENT 订单编号, user_id bigint(20) NOT NULL COMMENT 用户ID, session_id bigint(20) NOT NULL COMMENT 场次ID, community_id bigint(20) NOT NULL COMMENT 小区ID, total_amount decimal(10,2) NOT NULL COMMENT 订单总金额, pay_amount decimal(10,2) NOT NULL COMMENT 实付金额, status tinyint(4) NOT NULL COMMENT 订单状态 0-待支付 1-已支付 2-已核销 3-已取消 4-售后中 5-已完成, pickup_code varchar(6) DEFAULT NULL COMMENT 取货码, remark varchar(500) DEFAULT NULL COMMENT 用户备注, create_time datetime NOT NULL, pay_time datetime DEFAULT NULL, finish_time datetime DEFAULT NULL, PRIMARY KEY (id), UNIQUE KEY uk_order_no (order_no), KEY idx_user_id (user_id), KEY idx_session_community (session_id, community_id), KEY idx_status (status) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT订单表; -- 订单明细表 CREATE TABLE order_item ( id bigint(20) NOT NULL AUTO_INCREMENT, order_id bigint(20) NOT NULL, product_id bigint(20) NOT NULL, product_name varchar(200) NOT NULL, product_image varchar(500) DEFAULT NULL, price decimal(10,2) NOT NULL COMMENT 成交单价, quantity int(11) NOT NULL COMMENT 数量, total_price decimal(10,2) NOT NULL COMMENT 小计, PRIMARY KEY (id), KEY idx_order_id (order_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT订单明细表;几个设计的要点补充一下第一商品和场次是多对多的关系所以引入了session_product中间表。同一个商品可以出现在多个场次里不同场次的价格和库存是独立的。社区团购里同样的苹果今天可能卖5块、明天卖4块这就靠场次商品表来控制不能把价格写死在商品表里。第二订单表冗余了session_id和community_id因为订单列表页最常见的就是按用户、按场次、按小区查订单。做了这俩索引后用户端查我某一场的订单和管理端查某个小区某个场次的订单汇总都很快。我调试的时候发现有些慢查询就是因为少了这类联合索引后来在索引上抠了不少性能出来。第三pickup_code是6位数字取货码用户在自提点报码号团长输入后核销。存到订单表而不是单独建核销码表是因为一条订单只有一个核销动作没必要引入额外复杂度。3.2 下单链路库存预扣、幂等和防止超卖用户下单是整个系统最核心、最容易出并发问题的接口。我先说思路再贴关键代码。下单接口的完整流程是用户提交商品和数量 - 查询场次商品信息 - 校验商品是否上架、场次是否在有效期内 - 加Redis分布式锁 - 预扣Redis库存 - 生成订单号 - 插入订单表和明细表 - 删除购物车记录 - 返回待支付订单。用户支付成功后再回调更新订单状态并扣减MySQL库存。防止超卖我的做法是Redis预扣 MySQL兜底校验双层保险。// 下单时预扣库存 public Boolean preDeductStock(Long sessionProductId, Integer quantity) { String stockKey stock:session_product: sessionProductId; // 使用 Lua 脚本保证原子性 String luaScript if redis.call(get, KEYS[1]) tonumber(ARGV[1]) then return redis.call(decrby, KEYS[1], ARGV[1]) else return -1 end; Long result redisTemplate.execute( new DefaultRedisScript(luaScript, Long.class), Collections.singletonList(stockKey), quantity.toString() ); return result ! null result 0; }Redis库存初始值在商品上架或者场次开始前从MySQL加载。预扣成功只是第一步用户如果一直不支付库存会一直被占用所以我还在订单状态为待支付时设置了15分钟超时自动关闭关闭后要回补Redis库存。MySQL端的兜底是在更新库存的SQL里加条件UPDATE session_product SET stock stock - #{quantity} WHERE id #{id} AND stock #{quantity}受影响行数为1才说明扣减成功。这条SQL在并发情况下也不会超卖因为它把检查库存和扣库存合并成了一个原子操作。幂等处理上前端在提交订单时生成一个requestIdUUID后端收到后先查Redis里有没有这个requestId有就直接返回上次创建的订单没有才继续创建。这样用户在弱网下多点了几次提交按钮也不会产生重复订单。3.3 登录鉴权微信小程序登录 JWT方案用户端我做了微信小程序版本的H5登录核心流程是小程序调用wx.login()获取临时code传到后端后端拿code调微信的code2Session接口换取openid根据openid查用户表不存在就自动注册一个新用户然后生成JWT令牌返回给前端。PostMapping(/wx/login) public ResultString wxLogin(RequestBody WxLoginDTO dto) { // 1. code 换 openid WxMaJscode2SessionResult session wxMaService.getUserService() .getSessionInfo(dto.getCode()); String openid session.getOpenid(); // 2. 根据 openid 查用户不存在则注册 User user userMapper.selectOne( new LambdaQueryWrapperUser().eq(User::getOpenid, openid)); if (user null) { user new User(); user.setOpenid(openid); user.setNickname(微信用户 openid.substring(openid.length() - 6)); user.setAvatar(https://cdn.example.com/default_avatar.png); user.setCreateTime(LocalDateTime.now()); userMapper.insert(user); } // 3. 生成 JWT注意过期时间设置为30天减少用户频繁登录 String token JwtUtil.createToken(user.getId(), user.getNickname(), 30); return Result.success(token); }JWT密钥我放在了配置文件的spring节点下没有硬编码到代码里。生成的Token在前端存放于localStorage每次请求在拦截器里加到Authorization头后端通过拦截器统一解析。管理后台的登录没有用微信登录是普通的账号密码登录。密码存储用了BCryptPasswordEncoder加盐哈希不存明文。管理员登录成功后同样发JWT但Token里带了一个role字段权限拦截器会校验当前用户的角色是否允许访问对应接口。团长端的操作也复用这套机制只是角色不同需要额外校验该团长是否属于目标小区。3.4 团长的核销与佣金结算设计团长端最常用的就是核销功能。核销的本质是把订单状态从已支付改为已核销同时把核销人和核销时间记录下来。核销接口我设计了两种入参方式扫码核销和手动输入核销码。扫码就传二维码里的orderNo手动就传用户报的pickupCode。但这里有个细节pickupCode是6位数字同一场次内可能重复。所以核销时不能只根据pickupCode一个条件去查订单表必须加上session_id场次和community_id小区一起作为条件不然可能把另一个用户的单子核销掉。PostMapping(/verify) public ResultString verify(RequestBody VerifyDTO dto) { // 校验当前登录人是该小区的团长 Long communityId getCurrentUserCommunityId(); LambdaQueryWrapperOrder wrapper new LambdaQueryWrapper(); wrapper.eq(Order::getPickupCode, dto.getPickupCode()) .eq(Order::getCommunityId, communityId) .eq(Order::getSessionId, dto.getSessionId()) .eq(Order::getStatus, 1); // 只有已支付才能核销 Order order orderMapper.selectOne(wrapper); if (order null) { return Result.error(未找到可核销的订单请核对接单小区和场次); } BeanUtils.copyProperties(order, status2, verify_timenow()); orderMapper.updateById(order); return Result.success(核销成功); }佣金结算我设计成了每月一次自动生成结算单。结算规则是佣金 当月该团长名下已完成订单的实付金额 × 佣金比例。佣金比例可以按小区配置也可以按团长等级配置。结算时一次性把所有订单算完生成一条佣金记录团长端就可以看到本月预计入账多少、已提现多少。提现申请流程是团长发起提现 - 状态变成提现中 - 财务在管理后台审核打款 - 打款成功后状态变已提现 - 记账流水。这里要注意的是涉及钱的接口必须做幂等防止财务重复点击造成重复打款我在提现表里加了一个batch_no唯一约束请求处理前先查这个批次号有没有处理过。4. 前端实战Vue3页面架构和联调细节后端逻辑理清楚之后前端的工作量其实不小。用户端H5有十几个页面管理后台有二十几个页面如果不好好规划目录结构和公共代码写到后面会非常混乱。4.1 前端项目的目录结构和路由设计我把前端拆成了两个独立的工程community-h5和community-admin分开开发和部署。H5用的Vite Vue3 Vue Router Pinia Vant管理后台用的Vite Vue3 Vue Router Pinia Element Plus。H5的目录结构大概是这样的src/ ├─ api/ # 接口请求封装 │ ├─ product.js │ ├─ cart.js │ ├─ order.js │ └─ user.js ├─ assets/ # 静态资源 ├─ components/ # 公共组件 │ ├─ ProductCard.vue │ ├─ OrderStatusTag.vue │ └─ EmptyView.vue ├─ router/ │ └─ index.js # 路由配置 ├─ stores/ # Pinia 状态 │ ├─ user.js # 用户信息与登录态 │ └─ cart.js # 购物车状态 ├─ utils/ │ ├─ request.js # axios 封装 │ └─ auth.js # token 存取 ├─ views/ │ ├─ home/ # 首页 │ ├─ category/ # 分类 │ ├─ cart/ # 购物车 │ ├─ order/ # 订单相关 │ ├─ user/ # 个人中心 │ └─ login/ # 登录页 └─ App.vue路由设计上H5所有页面都挂在一个布局组件下底部有TabBar首页、分类、购物车、我的路由采用懒加载按需加载页面组件减少首屏体积。管理后台的路由分了两层外层是layout布局内层是各个业务页面通过路由的meta.roles字段控制当前管理员能访问哪些菜单。4.2 登录态管理axios拦截器、Token刷新和401处理前端登录态管理是整个联调阶段最容易被埋坑的地方。我的做法是登录成功后把Token和用户基础信息存到localStorage和Pinia。axios请求拦截器里统一从Pinia取Token放到请求头。响应拦截器里统一处理三种情况请求成功、业务报错后端返回code非200、登录态失效后端返回401。// utils/request.js 核心代码 service.interceptors.request.use(config { const userStore useUserStore() if (userStore.token) { config.headers[Authorization] Bearer userStore.token } return config }) service.interceptors.response.use( response { const res response.data if (res.code ! 200) { Toast(res.message || 请求失败) return Promise.reject(new Error(res.message)) } return res }, error { if (error.response error.response.status 401) { // 登录态失效清空用户信息并跳转登录页 useUserStore().logout() router.push({ path: /login }) } Toast(error.message || 网络异常) return Promise.reject(error) } )Token失效跳转登录页的时候我加了一个redirect参数登录成功后再跳回之前的页面避免用户登录后还得手动找回刚才浏览的位置。这个细节在移动端很影响体验。4.3 下单与订单列表页的前端实现下单页是个典型的复杂表单页选收货方式自提点、选商品数量、填备注、支付。用户从购物车跳转到下单页时把选中的商品列表通过Pinia传递过去下单页展示清单和金额点击提交订单后调用后端接口。支付我集成了微信JSAPI支付流程是后端创建订单后返回payment params前端用wx.requestPayment拉起收银台。开发阶段没有微信商户号我做了一个模拟支付开关后端配置mock-pay: true时支付接口直接返回成功方便把整个流程跑通。上线前再把开关关掉。订单列表页的关键是状态筛选和分页。Vant的Tabs组件配合后端接口的状态参数点击不同Tab就重新请求对应状态的订单。分页用的是滚动加载每次加载10条上拉加载更多。这里要强调一下分页接口必须传lastId或者pageNum/pageSize而且要处理好加载中和没有更多数据两个状态不然用户会不停地触发重复请求。4.4 联调阶段最容易踩的三个坑联调是前后端分开开发的项目最容易出问题的环节我遇到且花时间解决的坑主要有这三个。第一个是跨域问题。前端开发地址是http://localhost:5173后端是http://localhost:8080必然跨域。开发环境下我在Vite的server.proxy配置了代理把/api前缀的请求转发到后端同时后端也配置了CORS允许的域名白名单双保险。第二个是日期格式不一致。后端返回的是2025-01-01T12:00:00这种LocalDateTime格式前端直接显示会很难看。我在后端全局配置了Jackson的日期格式化统一成yyyy-MM-dd HH:mm:ss前端再用dayjs做一层展示格式化。这问题看着小真到了测试阶段才发现到处都是时间格式不统一找起来很费劲。第三个是金额精度问题。Java后端用的是BigDecimalJSON序列化后是一个数字浏览器里的JavaScript浮点数运算会有精度问题。前端展示金额时统一用分做单位后端返回的金额也直接是整数分前端(amount / 100).toFixed(2)转换成元展示。用分做单位从源头避免了0.1 0.2 ! 0.3这类问题。5. 上线前必须处理好的几个关键问题开发环境把功能跑通只是第一步真正上线要面对的是并发压力、安全风险和部署运维的问题。这里我把踩过和提前排查过的坑都整理出来。5.1 库存扣减的三种方案对比我在做库存这块时对比了三种方案简单说一下结论。最简单的是同步扣减MySQL下单就在UPDATE语句里带stock quantity条件扣库存。这个方案实现最简单但并发高时数据库压力大而且下单后如果用户不支付库存先被扣掉需要定期清理无效订单回补库存。第二种是Redis预扣 延迟回补也就是我当前方案。下单先扣Redis支付成功后异步扣MySQL15分钟未支付则回补Redis。这个方案把高并发的压力转移给了Redis体验好很多但多了一道Redis和MySQL库存同步的保障逻辑。第三种是引入MQ串行化下单请求利用消息队列把每个商品的订单请求变成串行处理从根源上避免超卖。这个方案适合量级非常大的系统但对大多数创业期的社区团购来说过于复杂维护成本也高。选型逻辑很简单你的业务并发如果只是几台服务器撑得起的小区级订单量用方案二就够了没必要为了高并发这个名词引入MQ。技术选型永远要匹配业务当前的真实规模。5.2 安全加固接口参数校验、频率限制和金额篡改安全这块主要做了三层。第一层是参数校验。所有接收前端传参的DTO都用Validated注解加上NotNull、Size、DecimalMin等校验规则防止空指针和非法参数进入业务逻辑。比如下单接口的quantity字段必须大于0且不能超过限购数量productId必须存在。第二层是接口频率限制。下单接口和支付回调接口是最容易被刷的我用Redis的INCR命令做了简单的接口限流规则是每个用户每秒钟最多请求5次下单接口超过就返回操作过于频繁。限流逻辑封装成了一个注解RateLimit在需要的接口上直接标注即可。第三层是金额相关的安全设计。下单接口后端不会直接用前端传的金额而是重新从数据库查询商品价格来计算订单总额前端传的价格字段一律忽略。这个设计看起来是常识但真有人会忘了这茬把前端传的总金额直接落库一旦被恶意用户改了请求参数损失是实打实的。5.3 前端构建部署与后端服务发布的完整流程部署架构我用了最简单的单机方案一台云服务器上面装Nginx、MySQL8、Redis和JDK17。前端构建出的纯静态文件交给Nginx托管后端SpringBoot打包成Jar包用systemd管理整体成本低而且容易维护。前端部署流程在项目根目录执行npm run build产物在dist目录把dist下的文件上传到服务器的/usr/share/nginx/html/community目录然后重载Nginx配置即可。Nginx里还要做一个反代把/api请求转发到后端8080端口这样前端代码里请求路径都写相对路径/api开头就行。server { listen 80; server_name your-domain.com; location / { root /usr/share/nginx/html/community; index index.html; try_files $uri $uri/ /index.html; } location /api/ { proxy_pass http://127.0.0.1:8080; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; } }后端部署更简单把项目打成Jar包后上传到服务器写一个systemd服务单元文件systemctl start community就能启动。日志输出到指定文件后面排查问题用journalctl -u community -f实时看日志。这里有一个新手很容易忽略的坑前端路由用createWebHistory模式history模式时刷新页面会404因为Nginx找不到那个非真实的路径。上面配置文件里的try_files $uri $uri/ /index.html;就是为了解决这个问题把请求都重新导向到index.html。5.4 上线后最容易忽略的时区与定时任务问题这部分是我在实际运行里踩到的。后端服务器一般默认是UTC时区而MySQL有时也是UTC导致下单时间比实际时间慢了8小时这种诡异问题。解决方式是明确设置时区JVM启动参数加-Duser.timezoneAsia/ShanghaiMySQL连接串上加serverTimezoneAsia/Shanghai并且保证Linux系统时区也是中国标准时间三步缺一不可。定时任务方面订单超时关闭、佣金月结这类任务我都用的Spring自带的Scheduled没有引入分布式任务调度框架。因为当前是单机部署Scheduled完全够用万一后面扩展成多实例部署只要把需要互斥执行的任务加上Redis分布式锁就能解决重复执行问题不必一上来就上XXL-Job这种重框架。部署后监控这块我额外给关键接口加了一个简单的耗时统计在拦截器里记录每个请求的耗时超过2秒的打到WARN日志。这样即使没有引入SkyWalking这类链路追踪工具也能从日志里快速定位哪个接口慢。上线第一周我就通过这个日志发现查询订单列表的接口经常耗时超过3秒加了联合索引后恢复正常。写在最后的几个建议整个系统从业务梳理到上线我大概用了三周多的业余时间。最深的感受是做一个项目最有价值的不是把代码写出来而是在过程中把每一个业务决策和技术决策的为什么想清楚了。比如为什么订单表要冗余小区ID为什么库存用Redis预扣而不是纯靠MySQL为什么前端金额用分做单位这些问题的答案恰恰是面试和实际工作中真正会被问到、被考验的部分。如果你准备照着这个思路自己做一遍我建议先从订单主链路入手也就是用户下单、支付回调、团长核销这三件事先把它们跑通再往上去补商品管理、场次管理、佣金结算这些外围功能。主链路通了整个系统的骨架也就立住了后面的功能都是往这个骨架上填肉。最后再分享一个我一直在用的小技巧写接口时顺手把请求参数和响应参数各打印一行日志日志里带上用户ID、订单号这类关键业务ID。系统上线后用grep搜日志排查问题时这个习惯能帮你省下大量时间。