做运动户外电商小程序,是不是一定得先搞个商品管理后台,再写用户端?很多同学从大厂开源项目里复制一个商城模板,结果改了一周,连商品 SKU 都还没理顺,最后只能把需求砍掉。这次要拆解的“运动户外交易小程序”,定位很明确:后端用 SpringBoot 系列,管理端用 Vue3,用户端是小程序商城。如果你手里正缺一个能跑通“商品、下单、支付、管理”全链路的项目参考,或者准备把一套传统 Web 电商迁到微信生态里,这篇就把它的架构逻辑、核心代码和部署验证关键点一次讲透。
这个项目真正有价值的地方,不只是“能买能卖”,而是它的技术选型很适合从零搭建中小型电商:SpringBoot 负责提供稳定 REST API,Vue3 负责后台商品管理和订单处理,小程序端承接 C 端流量。三者组合可以拆开用,也可以整体复用。很多二开项目之所以改到一半就放弃,是因为一开始没规划清楚三端边界。本文会从需求拆分、表结构设计、后端 API 结构、Vue3 管理端和小程序端代码示例一直讲到联调排错,适合正在做毕业设计、私活外包或公司内部电商中台的同学收藏对照。
1. 为什么运动户外类商城适合做小程序,而不是纯 H5
先给结论:户外运动商品的购买决策,越来越依赖场景化内容和即时比价,而小程序正好卡在“打开成本低、用户留存可控、支付链路完整”这个位置。
相比传统电商 App,小程序不需要用户去应用商店下载;相比 H5 商城,小程序在微信内可以通过扫一扫、搜索、公众号菜单、分享卡片直接进入。特别是户外运动类目,比如露营装备、跑步服饰、骑行配件,用户经常是在微信群看到活动讨论后临时起意购买。小程序分享到群里可以带商品图片和价格信息,体验比 H5 链接更原生,打开路径也短。
从开发侧看,运动户外商城和小程序结合还有几个隐形优势:
- 服务端可以只输出 JSON 接口。前端不管是 Vue3 管理后台、小程序还是未来的 App,都能共用一套 SpringBoot API。
- 库存和订单逻辑集中在后端处理。户外装备有些规格复杂,比如帐篷分几人帐、颜色分几款,SKU 设计好坏直接决定代码量。
- 微信公众号生态可以二次引流。运动户外用户常看攻略类推文,文章插入小程序卡片后,购买转化路径非常短。
但如果反过来问:什么时候不太适合小程序?答案是:如果你有很强的品牌会员体系和私域复购需求,需要复杂 CRM 和个性化首页,那原生 App 或者更大体量的电商平台更合适。小程序更适合场景驱动、轻量交易、依赖社交传播的类目。运动户外恰好符合这个画像。
2. 项目整体技术架构与核心模块划分
这个运动户外电商项目从结构上看,基本是经典的“管理端 + 用户端 + 服务端”三端分离。管理端承载运营人员操作,用户端就是用户在微信里看到的小程序商城,服务端提供所有数据接口、权限校验、业务规则。
2.1 后端技术栈与核心设计
后端主框架是 SpringBoot。按当前行业习惯,如果是 SpringBoot3 及以上的版本,会引入 Spring Security 做认证授权,搭配 JWT 做无状态登录。数据库主要以 MySQL 为主,缓存可以使用 Redis。因为在商城场景里,首页商品列表和热门推荐对响应速度要求高,直接查 MySQL 也能跑,但并发上来后 Redis 的价值会立刻体现。
后端模块规划上,比较推荐的拆分方式是:
- controller 接收前端请求,做参数校验 - service 处理业务逻辑,比如下单、支付回调、库存扣减 - mapper 数据库操作层 - entity 数据库实体映射 - dto 前端接口传输对象,避免直接暴露数据库字段 - vo 给前端展示用的视图对象 - config 各种配置类,比如跨域、安全配置 - utils 通用工具,比如 JWT、日期处理2.2 Vue3 管理后台的设计方向
管理端技术栈为 Vue3。相比 Vue2,Vue3 的 Composition API 在复杂表单、商品编辑、订单筛选这类页面里非常有优势。你可以把商品分类、品牌管理、SKU 规格、订单状态全部抽象成独立的 Composable 函数,多个页面复用同一套逻辑,不用像 Options API 那样在 data、methods、computed 之间来回跳。
管理端核心页面建议至少包含:
- 仪表盘:今日订单量、销售额、待发货数量
- 商品管理:商品列表、新增/编辑商品、SKU 库存管理、上下架
- 分类管理:运动户外类目树形管理
- 订单管理:订单列表、订单详情、发货操作、退款处理
- 用户管理:会员列表、用户状态
- Banner 管理:首页轮播图、活动位配置
2.3 小程序端技术选型
小程序端没有采用纯微信原生开发,而是用更通用的小程序跨端方案。原因很简单:运动户外商城如果以后要扩展到支付宝小程序或抖音小程序,纯原生微信代码几乎无法复用。跨端框架让你保留一套代码,编译到多个小程序平台。
跨端框架下的页面结构,通常采用“页面 + 组件”的组织方式。首页、分类页、购物车、个人中心、商品详情、订单确认、支付结果等页面,在工程目录里都应有清晰的对应关系。
一个典型的小程序端目录结构参考:
src ├── api // 接口请求 ├── components // 通用组件,如商品卡片、价格标签 ├── pages // 页面 │ ├── index // 首页 │ ├── category // 分类 │ ├── cart // 购物车 │ ├── user // 个人中心 │ ├── goods // 商品详情 │ └── order // 订单确认与列表 ├── static // 静态资源 ├── stores // 全局状态管理 └── utils // 请求封装、登录态管理3. 核心数据表设计与关系梳理
电商项目跑不掉的几张核心表是:用户表、商品表、分类表、SKU 库存表、购物车表、订单表、订单明细表。做运动户外商城时,还要根据类目特点,在商品表中预留品牌、适用季节、活动场景等字段。这些字段的规划,直接决定你后期做筛选时是想直接写 SQL 还是需要单独做搜索引擎。
下面是一个最简可用的表结构参考。先不要急着加几十个字段,第一版能满足下单流程,后续再迭代。
3.1 用户表
CREATE TABLE `user` ( `id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键', `openid` VARCHAR(64) DEFAULT NULL COMMENT '微信小程序用户openid', `nickname` VARCHAR(64) DEFAULT NULL COMMENT '昵称', `phone` VARCHAR(20) DEFAULT NULL COMMENT '手机号', `avatar` VARCHAR(255) DEFAULT NULL COMMENT '头像', `status` TINYINT DEFAULT 1 COMMENT '状态 1正常 0禁用', `create_time` DATETIME DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`), UNIQUE KEY `uk_openid` (`openid`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户表';3.2 商品表和 SKU 表
CREATE TABLE `product` ( `id` BIGINT NOT NULL AUTO_INCREMENT, `category_id` BIGINT NOT NULL COMMENT '分类id', `name` VARCHAR(128) NOT NULL COMMENT '商品名称', `subtitle` VARCHAR(255) DEFAULT NULL COMMENT '副标题', `main_image` VARCHAR(255) DEFAULT NULL COMMENT '主图', `detail` TEXT COMMENT '商品详情', `status` TINYINT DEFAULT 1 COMMENT '1上架 0下架', `create_time` DATETIME DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='商品表'; CREATE TABLE `sku` ( `id` BIGINT NOT NULL AUTO_INCREMENT, `product_id` BIGINT NOT NULL COMMENT '商品id', `spec_info` VARCHAR(255) DEFAULT NULL COMMENT '规格描述 如 黑色/L', `price` DECIMAL(10,2) NOT NULL COMMENT '价格', `stock` INT NOT NULL DEFAULT 0 COMMENT '库存', `image` VARCHAR(255) DEFAULT NULL COMMENT '规格图片', PRIMARY KEY (`id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='商品SKU表';3.3 订单表与订单明细表
订单表主要记录一笔订单的整体信息,订单明细表记录订单下的每个 SKU。这种主从表设计是电商订单的标准做法,退款、统计时拆开查都很方便。
CREATE TABLE `orders` ( `id` BIGINT NOT NULL AUTO_INCREMENT, `order_no` VARCHAR(64) NOT NULL COMMENT '订单编号', `user_id` BIGINT NOT NULL COMMENT '用户id', `total_amount` DECIMAL(10,2) NOT NULL COMMENT '订单总金额', `pay_amount` DECIMAL(10,2) NOT NULL COMMENT '实付金额', `pay_status` TINYINT DEFAULT 0 COMMENT '0未支付 1已支付 2已退款', `receiver_name` VARCHAR(32) DEFAULT NULL, `receiver_phone` VARCHAR(20) DEFAULT NULL, `receiver_address` VARCHAR(255) DEFAULT NULL, `create_time` DATETIME DEFAULT CURRENT_TIMESTAMP, `pay_time` DATETIME DEFAULT NULL, PRIMARY KEY (`id`), UNIQUE KEY `uk_order_no` (`order_no`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='订单表'; CREATE TABLE `order_item` ( `id` BIGINT NOT NULL AUTO_INCREMENT, `order_id` BIGINT NOT NULL, `product_id` BIGINT NOT NULL, `sku_id` BIGINT NOT NULL, `product_name` VARCHAR(128) NOT NULL, `spec_info` VARCHAR(255) DEFAULT NULL, `price` DECIMAL(10,2) NOT NULL, `quantity` INT NOT NULL, PRIMARY KEY (`id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='订单明细表';3.4 表设计时容易踩的坑
- 金额类型必须用 DECIMAL,不要用 DOUBLE,否则到分以后对账会出现奇奇怪怪的误差。
- 订单号要单独加唯一索引。生成时建议用“时间戳 + 用户ID后几位 + 随机数”的组合,避免用数据库自增 ID 直接当订单号,容易被别人看出单量。
- 商品表的详情字段建议用 TEXT,但如果你以后要存富文本格式详情,最好把详情迁移到对象存储,数据库里只保存 HTML 或者渲染地址。
- SKU 的规格描述不要拆得过碎。比如“颜色 黑 尺码 L”可以合并成一个字符串
黑色/L,在购物车页展示时直接显示即可,避免为了一个展示逻辑写一大堆动态查询。
4. SpringBoot 后端接口设计与代码实现
后端代码是整个系统的核心。代码结构再清晰,如果接口设计不规范,前端对接时仍然会毫无头绪。下面从登录鉴权、商品浏览、下单三个关键链路分别给出核心思路和示例。
4.1 登录鉴权方案
移动电商场景里,小程序端一般使用微信登录。流程是:小程序端先调用微信的登录接口拿到code,然后传给后端;后端拿着code请求微信接口换openid;如果用户是第一次登录,自动注册;老用户直接登录成功。
服务端实现一个面向小程序端的通用登录接口:
@RestController @RequestMapping("/api/user") public class UserController { @Resource private UserService userService; @PostMapping("/login") public Result<String> login(@RequestBody LoginRequest request) { if (request.getCode() == null || request.getCode().isEmpty()) { return Result.error("code不能为空"); } String token = userService.loginByWechatCode(request.getCode()); return Result.success(token); } }loginByWechatCode方法中携带code调用微信接口,拿到 openid 后根据业务签名生成自己的登录 token。这里需要注意,小程序端登录成功后,Token 统一放在请求头Authorization里传回给后端,后端通过拦截器校验。
4.2 首页商品列表接口
首页通常需要返回分类、Banner 和推荐商品。为避免前端一次请求太多次,可以从聚合角度做一个首页数据接口。
@GetMapping("/index") public Result<IndexVO> index() { IndexVO vo = new IndexVO(); vo.setBannerList(productService.listBanners()); vo.setCategoryList(categoryService.listAllCategories()); vo.setHotProducts(productService.listHotProducts()); return Result.success(vo); }注意IndexVO这个对象,是聚合返回前端数据的载体。很多初学者直接把数据库实体 List 返回给前端,一旦某天表结构变化,前端就会不稳定。所以后端接口层尽量定义清楚的 VO,把想要的字段放进去,数据库字段变化时不影响对外协议。
4.3 商品详情接口
商品详情页展示的信息通常很丰富,包括商品基本信息、图集、SKU 列表、销售属性。小程序端打开商品详情页时,建议一次性拿到 SKU 数据,前端做规格选择,不需要用户每次点击规格都请求后端。
商品详情接口示例:
@GetMapping("/detail/{productId}") public Result<ProductDetailVO> detail(@PathVariable Long productId) { ProductDetailVO detail = new ProductDetailVO(); Product product = productService.getById(productId); BeanUtils.copyProperties(product, detail); List<SkuVO> skus = skuService.listByProductId(productId); detail.setSkuList(skus); return Result.success(detail); }这里有个小技巧:如果商品详情中的图片较多,可以返回到一个List<String>,前端用 Swiper 渲染即可,不需要返回拼接好的 HTML。后续如果要把详情改成视频介绍或者其他多媒体内容,只需要在 VO 里增加字段,不影响现有字段。
4.4 购物车逻辑
购物车功能在小程序端管理比较合适,因为购物车本身实时性要求不高,可以把数据保存在前端状态中。但也存在一个问题:用户换手机或者清掉微信缓存后,购物车可能会丢失。
更稳妥的方案是,登录状态下购物车数据存后端。比如用户添加购物车时,调用接口写入一张购物车表。如果未登录,再使用前端本地缓存。很多商城项目第一版为了快点上线,前后端各做一套,结果登录用户和非登录用户的数据很难合并,工程复杂度反而上去了。
建议第一版直接做后端购物车,表结构也比较简单:
public class CartItem { private Long id; private Long userId; private Long skuId; private Integer quantity; private Integer checked; // 是否选中 }购物车不需要存价格和商品名,展示购物车时再联表查询 SKU 表和商品表。这样价格变化时,购物车列表能立刻展示最新价格,不会出现“加购时 99 元,订单页变成 89 元”之后还要额外判断的历史包袱。
4.5 下单接口与库存扣减
下单是整个电商项目中逻辑最复杂的一个接口。这里给一个最小可跑通的顺序:
- 从前端拿到 skuId、数量、收货地址等信息。
- 后端查询 SKU 当前库存,检查是否足够。
- 扣减库存。
- 计算订单总金额,生成订单主表和订单明细。
- 如果支付成功,更新订单状态;如果未支付,超过时间后取消订单恢复库存。
在单机环境下,使用数据库行锁或乐观锁控制库存足够:
@Transactional(rollbackFor = Exception.class) public Long createOrder(OrderCreateRequest request, Long userId) { for (OrderItemRequest itemRequest : request.getItems()) { Sku sku = skuMapper.selectBySkuIdForUpdate(itemRequest.getSkuId()); if (sku == null || sku.getStock() < itemRequest.getQuantity()) { throw new ServiceException("库存不足"); } } Orders order = buildOrder(request, userId); ordersMapper.insert(order); for (OrderItemRequest itemRequest : request.getItems()) { orderItemMapper.insert(buildOrderItem(order.getId(), itemRequest)); skuMapper.decreaseStock(itemRequest.getSkuId(), itemRequest.getQuantity()); } return order.getId(); }这里有一个必须注意的点:@Transactional注解只对运行时异常回滚。如果你在业务逻辑里手动 catch 了异常并且不抛出,事务是不会回滚的。所以在下单接口里,库存不足等异常要主动抛给上层,让事务统一回滚。否则很可能出现一种情况:订单没创建成功,但接口返回成功,用户以为下单成功了,数据库库存也没有扣减,后面对账时异常麻烦。
小额交易场景里,使用selectBySkuIdForUpdate这种行锁方式已经能保证防超卖。但它的代价是锁库存的并发性能有限。如果未来商城单量变大,再考虑 Redis Lua 脚本做预扣减,或者引入消息队列做异步下单。项目第一版不要为了性能把架构搞得太复杂,能稳定跑通核心链路更重要。
5. Vue3 管理后台:从登录到商品上下架
管理后台用的是 Vue3,配合 Element Plus 之类的组件库。Vue3 相比 Vue2 最大的体感差异是:代码组织更灵活,状态管理和数据请求可以独立封装,页面组件不再被复杂的this搞得稀里糊涂。
5.1 创建项目与基本配置
创建 Vue3 项目有两种常用路径:Vite 和 Vue CLI。Vite 目前更主流,创建命令很直接。
npm create vite@latest sports-admin -- --template vue cd sports-admin npm install npm run dev如果需要使用 TypeScript,可以把vue模板改为vue-ts。但中小型管理后台不一定非要用 TS,团队习惯优先。
安装 Element Plus 和路由:
npm install element-plus npm install vue-router@4 npm install axios在main.js中注册 Element Plus:
import { createApp } from 'vue' import ElementPlus from 'element-plus' import 'element-plus/dist/index.css' import App from './App.vue' import router from './router' const app = createApp(App) app.use(ElementPlus) app.use(router) app.mount('#app')5.2 封装 Axios 请求
商城项目中,管理员登录成功后返回的 Token 需要附带在每次请求的 Header 中。这里建议统一封装 Axios 实例,响应层统一处理错误码和登录过期,而不是每个页面单独写拦截器。
// src/utils/request.js import axios from 'axios' import { ElMessage } from 'element-plus' import { useUserStore } from '../stores/user' const request = axios.create({ baseURL: '/api', timeout: 10000 }) request.interceptors.request.use(config => { const userStore = useUserStore() if (userStore.token) { config.headers.Authorization = userStore.token } return config }) request.interceptors.response.use( response => { const res = response.data if (res.code !== 200) { ElMessage.error(res.message || '请求失败') return Promise.reject(new Error(res.message)) } return res }, error => { if (error.response && error.response.status === 401) { ElMessage.error('登录状态已过期,请重新登录') // 跳转登录页 } else { ElMessage.error('网络异常,请稍后重试') } return Promise.reject(error) } ) export default request5.3 商品列表页
商品列表页用el-table展示,每个商品行需要有编辑、上下架按钮。Vue3 的 Composition API 写起来很自然:
<template> <div class="product-list"> <el-card> <el-button type="primary" @click="goCreate">新增商品</el-button> </el-card> <el-table :data="productList" v-loading="loading" border> <el-table-column prop="id" label="ID" width="80" /> <el-table-column prop="name" label="商品名称" min-width="200" /> <el-table-column prop="price" label="价格" width="100" /> <el-table-column label="状态" width="100"> <template #default="{ row }"> <el-tag :type="row.status === 1 ? 'success' : 'info'"> {{ row.status === 1 ? '上架' : '下架' }} </el-tag> </template> </el-table-column> <el-table-column label="操作" width="200"> <template #default="{ row }"> <el-button size="small" @click="goEdit(row.id)">编辑</el-button> <el-button size="small" :type="row.status === 1 ? 'warning' : 'success'" @click="toggleStatus(row)"> {{ row.status === 1 ? '下架' : '上架' }} </el-button> </template> </el-table-column> </el-table> </div> </template> <script setup> import { ref, onMounted } from 'vue' import { useRouter } from 'vue-router' import { ElMessage } from 'element-plus' import request from '../../utils/request' const router = useRouter() const productList = ref([]) const loading = ref(false) const loadProductList = async () => { loading.value = true const res = await request.get('/admin/product/page', { params: { page: 1, limit: 10 } }) productList.value = res.data.records loading.value = false } const toggleStatus = async row => { await request.put(`/admin/product/status/${row.id}`) ElMessage.success('操作成功') loadProductList() } const goCreate = () => router.push('/product/edit') const goEdit = id => router.push(`/product/edit/${id}`) onMounted(loadProductList) </script>5.4 商品编辑页
商品编辑页是整个管理后台里比较复杂的一个。至少包含基本信息、图片上传、SKU 设置三块。
一个较实用的做法是:后端先接收商品基础信息并返回商品 ID,然后再单独调用 SKU 接口保存 SKU 列表,避免嵌套数据校验麻烦,也方便改动 SKU。例如保存完商品基本信息后,再调用 SKU 保存接口,一次性把前端收集到的 SKU 数组传给后端,后端先删掉旧的 SKU 再批量插入新的 SKU。这种“先删后插”的方式在中小项目里很常见。但要注意一个前提:在下单过程中,如果有用户正在查询该商品详情,会短暂看不到 SKU。这时候最好在事务里执行,保证删除和插入要么同时成功,要么同时回滚。
6. 运动户外小程序端代码示例
小程序端是用户直接触达的入口,开发时重点要处理好三件事:登录态保存、商品数据渲染、支付调起。核心页面建议先做好首页、商品列表、商品详情、购物车、确认订单、订单列表。
6.1 登录流程封装
小程序端登录逻辑不能简单用“后端登录后返回 token,前端保存到本地”一句话带过。这里需要处理两个关键问题:静默登录失败怎么办、Token 过期怎么办。
很多刚接触小程序开发的同学会直接写一个wx.login然后调后端登录接口。实际上,对于需要获取头像昵称的商城,建议进入个人中心或下单时才弹出授权,而首页和商品列表用静默登录就能完成。
下面是最小可用的静默登录代码:
export function silentLogin() { return new Promise((resolve, reject) => { uni.login({ provider: 'weixin', success: async loginRes => { const { code } = loginRes try { const res = await request.post('/api/user/login', { code }) uni.setStorageSync('token', res.data.token) resolve(res.data.token) } catch (err) { reject(err) } }, fail: err => reject(err) }) }) }6.2 首页商品列表渲染
首页通常包含 Banner、金刚区图标、热卖商品瀑布流。在跨端框架中,商品列表建议用自定义组件封装,商品卡片组件可以在首页、搜索页、推荐位、订单相关页面复用。
一个简单商品卡片组件的思路:
<template> <view class="goods-card" @click="goDetail"> <image class="goods-image" :src="goods.mainImage" mode="aspectFill" /> <view class="goods-info"> <view class="goods-name">{{ goods.name }}</view> <view class="goods-price">¥{{ goods.price }}</view> </view> </view> </template> <script setup> const props = defineProps({ goods: { type: Object, default: () => ({}) } }) const goDetail = () => { uni.navigateTo({ url: `/pages/goods/detail?id=${props.goods.id}` }) } </script>这里需要注意mode="aspectFill",如果不设置,网络图片在小程序里可能显示不出正常的比例,尤其是户外装备这种需要展示完整外观的图片,图片比例被拉伸会非常影响真实感。
6.3 商品详情与 SKU 选择交互
商品详情页的 SKU 选择,是小程序商城体验的核心。后端返回 SKU 数组后,前端需要根据规格维度把可选和不可选的选项区分出来。这里不做太复杂算法,给一个常用处理思路:
- 把所有 SKU 的规格信息解析出来,比如颜色、尺码两个维度。
- 用户点击颜色时,查看哪些尺码可选,不可选的置灰。
- 选完所有维度后,匹配出唯一 SKU,展示对应价格和图片。
这样实现时需要把规格维度数据化。比如后端返回的 SKU 数组为:
[ { skuId: 1, specInfo: '黑色/L', price: 599, stock: 10 }, { skuId: 2, specInfo: '黑色/M', price: 599, stock: 0 }, { skuId: 3, specInfo: '蓝色/L', price: 599, stock: 5 } ]前端可以按specInfo拆分开,得到颜色数组['黑色', '蓝色']、尺码数组['L', 'M'],并把每个“颜色+尺码”的组合映射到 SKU ID。这样选择交互并不难实现。
6.4 调起微信支付
当用户提交订单后,后端会生成一笔待支付订单。此时小程序端需要拿到支付参数并调起微信支付。
一个典型的支付调起代码:
const pay = async orderId => { const res = await request.post('/api/pay/unifiedOrder', { orderId }) const payParams = res.data uni.requestPayment({ provider: 'wxpay', timeStamp: payParams.timeStamp, nonceStr: payParams.nonceStr, package: payParams.package, signType: payParams.signType, paySign: payParams.paySign, success: () => { uni.showToast({ title: '支付成功' }) uni.redirectTo({ url: '/pages/order/detail?id=' + orderId }) }, fail: err => { // 用户取消支付或支付失败,跳转到订单列表待支付 Tab uni.redirectTo({ url: '/pages/order/list?status=0' }) } }) }支付回调顺序建议:后端先收到微信支付回调,更新订单支付状态,再通知前端跳转。但实际网络环境下,回调可能稍有延迟,前端拿到支付成功状态后,可以先轮询订单接口,确认后端已经更新了订单状态再跳转,避免用户看到订单还是待支付。
7. 项目启动与运行验证
很多读者拿到项目后第一步不是改代码,而是想知道怎么把它跑起来。下面按“后端、管理端、小程序端”三部分给出验证路径。
7.1 后端启动
启动 SpringBoot 项目前,先确认:
- Java 版本与项目要求一致,如果是 SpringBoot3 系列,一般需要 Java17 及以上。
- MySQL 已启动,项目配置的数据库名、用户名、密码正确。
- 如果代码里使用了 Redis,确保 Redis 服务也在运行。
启动时直接运行主类:
mvn spring-boot:run看到类似Started Application in xx seconds的日志,就说明后端启动成功。接着可以验证一个公开接口:
curl http://localhost:8080/api/product/detail/1如果返回 JSON,说明接口正常。
7.2 Vue3 管理端启动与跨域问题
在admin目录下安装依赖并启动:
npm install npm run devVite 默认端口一般是 5173。后端接口如果运行在 8080,需要配置 Vite 代理解决跨域:
// vite.config.js export default defineConfig({ server: { port: 5173, proxy: { '/api': { target: 'http://localhost:8080', changeOrigin: true } } } })配置好后,管理端页面里的/api请求会自动转发到后端,不会出现浏览器 CORS 报错。
7.3 小程序端启动
小程序端一般用 HBuilderX 或微信开发者工具打开。如果使用跨端框架,需要先安装依赖,然后编译到微信小程序。
在 HBuilderX 中运行到微信开发者工具的常见步骤是:
- 在工具里导入小程序端源码目录,或者通过 CLI 初始化工程。
- 安装依赖,执行
npm run dev:mp-weixin或点击 HBuilderX 的运行按钮,选择“运行到小程序模拟器 - 微信开发者工具”。 - 保证微信开发者工具已开启服务端口。
如果运行时提示“不是开发者”,需要在微信开发者工具的“设置-安全设置”中打开服务端口,同时使用有权限的微信账号扫码登录。
另外,小程序请求后端时,本地开发环境中不能直接请求http://localhost。微信开发者工具里可以勾选“不校验合法域名”,但在真机预览时,需要在小程序后台配置 HTTPS 合法域名。如果你只是本地调试,建议先把后端接口跑在一个局域网 IP,然后在小程序请求封装里改成你的局域网地址,方便手机和电脑联调。上线前再替换成正式的 HTTPS 域名。
8. 常见问题与排查思路
电商商城项目启动后,问题往往不是集中在大功能上,而是分布在环境、接口、数据一致性这些容易被忽视的环节。下面整理几个最常见的问题和排查路径。
| 问题现象 | 可能原因 | 排查方式 | 解决方案 |
|---|---|---|---|
| 后端启动失败 | Java 版本不匹配或依赖冲突 | 查看启动日志;执行mvn dependency:tree看冲突 | 按项目要求切换 JDK 版本;统一 SpringBoot 相关依赖版本 |
| 前端请求接口跨域 | 管理端直接请求了不同域名或端口 | 打开浏览器 Network 看响应头;检查 Vite 代理配置 | 用 dev server 代理;后端开启 CORS 配置 |
| 小程序图片加载失败 | 图片域名未配置到合法域名,或网络图片禁用了 HTTP | 在开发者工具中查看 Network 请求 | 图片上传到对象存储并配置 HTTPS;开发阶段可临时勾选不校验域名 |
| 登录提示 code 无效 | 微信code使用过一次就失效,重复调用会被拒绝 | 检查接口是否只调用一次wx.login | 每次登录重新调用wx.login获取新 code |
| 下单时库存不扣减 | 事务未生效或异常被吞掉 | 检查 Service 方法是否有@Transactional,是否被当前类内部调用 | 保证入口方法由 Controller 调用,Service 内部自调用事务不生效 |
| 支付成功后订单仍是待支付 | 微信支付回调未能正常通知后端 | 查看后端回调日志;检查回调地址是否可公网访问 | 本地可用内网穿透工具收回调;线上检查回调 URL 是否正确 |
| 页面刷新后登录态丢失 | Token 未持久化 | 查看前端是否有统一读取和写入 Token | Vue3 管理端用 localStorage/sessionStorage;小程序用 Storage 保存 |
8.1 事务边界问题深入说明
很多同学第一次做订单功能时,会在OrderService内部再调用一个本地updateStock方法,然后发现自己明明把updateStock也加了@Transactional,库存还是没扣,订单却创建了。
原因在于 Spring 事务默认基于 AOP 代理,同类内部调用不会走代理。OrderServiceImpl里的方法调用另一个方法,本质上是this调用,没有进入代理逻辑,所以内层方法上的@Transactional不生效。
解决办法有两种:要么把库存扣减逻辑拆到另一个 Service 类中,让OrderService注入该 Service;要么在OrderServiceImpl中通过AopContext.currentProxy()获取代理对象再调用内部方法。推荐第一种,职责也更清晰。
8.2 图片上传的域名配置问题
小程序网络图片、管理后台图片、后端存储图片,看起来是三个前端文件路径,实际上都指向同一个对象存储服务。你上传图片时如果返回的是http://链接,在微信小程序里默认是不能显示的,因为微信小程序要求所有网络资源都必须是 HTTPS。
这里的处理路径是:开发环境直接用开发者工具的“不校验合法域名”,联调和线上推荐传到对象存储,然后通过其自带 HTTPS 域名访问。如果你只是个人项目,也可以用一些免费的图床,但稳定性不好保证。总之不要在数据库表里直接保存本地磁盘路径D://xxx.jpg,一旦换服务器,全部图片都会失效。
8.3 时间字段时区问题
SpringBoot 返回 JSON 时,LocalDateTime默认可能序列化为数组格式,前端拿到后很难解析。处理方法是统一配置 Jackson:
spring.jackson.date-format=yyyy-MM-dd HH:mm:ss spring.jackson.time-zone=GMT+8如果项目中大量使用LocalDateTime,配合@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")使用会更稳妥。小程序端拿到时间后,可以用原生字符串直接展示,不用额外引入日期处理库。
9. 最佳实践与工程建议
9.1 接口文档先行
商城这种多端联调项目,接口文档是最不能省的成本。后端写接口前,先整理一份“接口路径、请求参数、返回结构”的表格或文档。只要前端、后端按同一份文档开发,联调阶段至少能减少一半争执。常见的接口文档方案有 Swagger/OpenAPI,也可以直接用 Apifox 之类的工具管理。
建议在 SpringBoot 中集成 springdoc-openapi 等工具,启动项目后自动生成接口文档页面,前端开发照着文档调试,效率比自己翻代码高很多。
9.2 商品状态和 SKU 状态联动
商品有上下架状态,SKU 表也要考虑启用状态。不能只改商品主状态,SKU 库存全部照常售卖。如果一个商品下架了,用户端商品详情页仍然可以打开历史链接,但应该显示“已下架”并禁止加购。这里更稳妥的方式是商品详情接口首先校验商品状态,如果下架则直接返回提示,而不是把详情数据照常给到前端。
另外,SKU 库存字段的更新要遵循最小更新原则,只更新指定 SKU,不能因为编辑了整个商品就把所有 SKU 的库存覆盖成同一个数。编辑页保存时,建议把未修改的 SKU 原样提交,而不是清空后重新赋值,避免人为填错库存。
9.3 订单状态机设计
商户系统中,订单状态不能随便改。比较规范的做法是定义一组状态常量:
0 待支付 1 已支付待发货 2 已发货 3 已完成 4 已取消 5 已退款后端每个更新订单状态的服务方法里,只允许从某个特定状态流转到下一个状态。例如“发货”操作只允许从“已支付待发货”变为“已发货”。如果收到一个从“待支付”直接发货的请求,应直接拒绝。这样能防止运营重复点击按钮或接口被异常调用后,订单状态错乱。
9.4 分页查询规范
商品列表、订单列表、用户列表都需要分页。推荐统一使用 Page 对象接收pageNum和pageSize,返回结构也固定为:
{ total: 100, records: [] }不建议用page=1&limit=10这种给每个接口各自定义字段的风格。前后端统一定义分页协议后,列表页的切换和刷新代码都可以抽象成通用组件。
9.5 日志与可观测性
支付、下单这类关键操作,一定要打日志。日志里应记录请求用户 ID、订单号、操作类型、入参关键信息。例如下单日志:
[下单] userId=1001, skuId=2001, quantity=2, orderNo=202504100001但要注意,日志不要记录完整手机号、身份证号、支付密钥等敏感信息。下单接口的日志尽量避免打印整个请求体,特别是包含收货人手机号的请求体。可以用脱敏处理或者只打印 ID 类信息。
9.6 前后端环境区分
项目里一般需要三套环境配置:本地开发、测试环境、生产环境。后端的application.yml可以按不同 profile 拆分:
application-dev.yml application-test.yml application-prod.ymlVue3 管理端和小程序端也要注意接口地址需要可配置。不要把http://192.168.x.x:8080硬编码在代码里。管理端可以用.env.development和.env.production区分环境变量;小程序端可以把请求基地址抽取到一个config.js文件里,打包时按平台或环境注入,方便测试和发布时切换。只要环境切换做到配置化,后面交付部署时会省大量时间。
9.7 缓存使用优先级
商城首页会重复请求分类和推荐位,这一块可以做缓存。但要注意,缓存更新不能只在“修改商品”时执行,还要考虑上下架操作。一个简单有效的方案是:后台任何商品变动都主动删除相关缓存 Key,下次请求时重新从数据库加载。相比复杂的双写一致性方案,“删缓存 + 延迟重建”对中小项目更友好。
Redis 缓存时,不建议把整个商品大对象直接缓存,因为字段很多很杂,更新频度也不一样。建议给首页聚合数据、Banner 列表、商品简要卡片这些可明确区隔的数据分别设置缓存。热卖商品列表可以缓存 5 到 10 分钟,后台人工上架新商品后,最坏延迟 10 分钟看到新商品,通常可以接受。
10. 项目扩展方向
运动户外电商系统跑通第一版后,后面的迭代方向可以看实际运营需要。
第一个方向是营销能力。运动户外类目非常依赖活动运营,比如“春季跑步节”“露营装备专场”。相应的,系统需要增加优惠券、满减、限时秒杀、拼团等模块。这些模块和订单模块耦合比较高,建议提前把订单金额计算逻辑抽成单独的OrderCalculator,不要等到新增优惠规则时,才发现所有下单代码散落在各个 Service 里。
第二个方向是进销存与供应链。做实物电商,无论如何绕不开库存管理。如果是自己发货还好,如果是供应商代发模式,还需要考虑采购单、入库单、盘点单。运动户外装备的 SKU 数量天然比普通服装类更多,一套可控的库存管理系统比前端页面炫不炫重要得多。
第三个方向是数据统计。当订单量上来后,后台仪表盘需要展示销售趋势、热销商品 Top10、转化率等指标。第一版可以直接用 SQL 聚合统计,但数据量大以后可能比较吃力。到那个阶段再考虑引入定时任务把统计数据同步到统计表,或使用专门的分析数据库。先别急着在第一版上数据仓库和大数据组件,因为初期最大的成本还是在业务链路本身。
11. 写在最后的几点提醒
这里不打算做一个标准化的“全文总结”,只想给要动手做类似项目的同学留三个实际经验。
第一,核心链路优先跑通,别一上来就铺满数十张表。一个能正常登录、浏览商品、下单、支付、后台发货的商城,比一张庞大的数据库设计文档更能让你理解系统的真实痛点。最开始表结构多不是优势,反而会让联调和改需求时不停调整 SQL。
第二,全栈项目要尤其注意数据类型一致性。后端金额使用BigDecimal,前端处理金额时建议统一使用“分”为单位还是“元”为单位,要在一个文档里写明白。很多支付对账的脏数据,不是算法写错了,而是端到端单位没统一。
第三,小程序和后台管理端是两套独立前端,不要试图通过复制代码来同步功能。Vue3 管理端关注表格、弹窗、批量操作,小程序端关注触摸交互、图片懒加载、弱网状态,两者重心不同。如果以后团队人手多了,可以再考虑用同一套组件库或设计系统来统一视觉规范,但业务代码没法简单复用。
如果你正计划把一个运动户外交易商城项目落地,先用本文的思路把项目拆成“后端 API、管理端、小程序端”三个模块,确定每个模块的分页协议、登录方案和订单状态流转,然后再开始写代码。这样整个项目会清晰非常多。建议把文章里的代码和表结构先跑通一遍,再根据自己实际类目做调整。