简介:一套面向新零售场景的移动电商系统源码,使用Java SpringBoot搭建后端,Vue Element UI实现Web管理端,UniApp开发移动端,前后端分离,适合有Java基础的全栈学习者、毕业设计开发者及企业二次开发。资源总计2000个文件,其中922个Java文件构成业务后端,304个Vue文件与207个JS文件支撑管理界面和交互,另有UniApp跨端页面、SQL初始化脚本、XML配置文件、JSON及静态图片等资源,压缩包仅28.46MB,结构清晰易检索。已有2183人进行学习下载。项目代码注释详细,附完整系统手册,覆盖标准RESTful接口、Redis队列削峰、事件机制扩展、按钮级权限管控、Vue表单拖拽配置生成、ECharts统计图表等亮点,可直接部署运行,也便于按需改造,是理解电商全栈架构的实用资料。
1. 不是又一个购物车:这套 Java 商城的选型逻辑
最开始拆这套基于 Java SpringBoot + Vue(Element UI) + UniApp 的新零售移动电商系统时,我最关心的是它会不会又是一堆堆在 Controller 里的 CRUD。实际看下来,它更像一个把“管理端、移动端、后端”三块拆干净的标准工程:PC 管理端用 Vue + Element UI,移动端用 UniApp 一套代码跑 H5 和小程序,后端统一暴露 RESTful 接口。对我这种需要快速交付电商业务的团队来说,这套组合最大的价值不是页面多,而是代码注释完整、有系统手册、权限控制到按钮、用 Redis 队列削峰,二次开发不用猜设计者的意图。适合三类人:想拿商城项目练手的中级 Java 开发,要给公司搭新零售中后台的前端,以及需要快速复刻一套移动电商业务做私有化交付的团队。
2. SpringBoot 后端:RESTful 分层、Redis 队列与按钮级权限
看这套系统的后端核心,建议先盯三件事:接口返回标准是否统一,高流量路径是否解耦,权限能不能落到按钮。下面按这三个点展开,顺序就是我在实际项目里推进的顺序。
2.1 先用统一返回结构定接口规范
商城系统的前端有 PC 管理端、UniApp 移动端,还有后续可能要接公众号 H5 或其他第三方。如果每个接口返回格式不一样,前端每个请求都要写一层兼容逻辑,后面会非常痛苦。这套系统的思路是后端定义统一的Result包装类,所有 RESTful 接口都走同一套code / msg / data结构。
public class Result<T> { private Integer code; private String msg; private T data; public Result(Integer code, String msg, T data) { this.code = code; this.msg = msg; this.data = data; } public static <T> Result<T> ok(T data) { return new Result<>(200, "success", data); } public static <T> Result<T> fail(String msg) { return new Result<>(500, msg, null); } public Integer getCode() { return code; } public String getMsg() { return msg; } public T getData() { return data; } }这段代码本身很基础,但它是整个前后端联调的契约。code不等于 HTTP 状态码,code = 200表示业务成功,code = 500表示业务失败,HTTP 状态码可能仍然是 200。前端请求封装里只要判断code即可,不要把 HTTP 状态码和业务码混在一起处理。
实际项目里我一般还会加一个TraceId字段,把一次请求从 Nginx 日志到后端日志再到异常通知串起来。这套系统虽然没有做成全链路追踪,但接口返回结构里预留了扩展位,我后面接入 SkyWalking 或者日志切面都比较省事。
2.2 订单模块用 Redis 队列削峰
新零售商城最典型的流量高峰是秒杀、限时抢购、营销活动开抢。直接用同步接口落订单,数据库会被瞬间打满。这套系统在订单路径里引入了 Redis 队列,目的是“降低流量高峰,解除耦合,高可用”,这是官方描述里写得比较清楚的一点。
我拆代码时看到的核心写法是:Controller 只负责把订单请求推进队列,真正的入库逻辑放到消费者线程里异步执行。生产端用ListOperations.leftPush往队列写入 JSON 数据,消费端用rightPop带阻塞超时从队列取任务。
@Service public class OrderQueueService { @Autowired private StringRedisTemplate redisTemplate; // 订单请求进入队列,不直接操作数据库 public void pushOrder(OrderCreateDTO dto) { String key = "order:queue"; redisTemplate.opsForList().leftPush(key, JSON.toJSONString(dto)); } // 消费端:阻塞读取,避免空轮询打满 CPU public String pollOrder() { String key = "order:queue"; return redisTemplate.opsForList().rightPop(key, 5, TimeUnit.SECONDS); } }这段代码的关键点有两个。leftPush和rightPop组合使用,相当于从队列头部写入、尾部取出,避免对 Redis 同一个 key 做高并发读写时出现数据竞争。rightPop(key, 5, TimeUnit.SECONDS)是阻塞读取,如果队列为空,最多等 5 秒再返回null,这样消费线程不会变成死循环。如果这里用普通的rightPop,队列空闲时 CPU 会被大量浪费。
消费端我看到的实现是启动一个独立线程跑pollOrder,拿到订单数据后再做库存扣减、订单落库、日志记录。这里要注意线程数和 Redis 连接池的配比,常见做法是支持通过配置文件动态调整消费线程数量。
@Component public class OrderMessageConsumer { @Autowired private OrderQueueService orderQueueService; @PostConstruct public void start() { new Thread(() -> { while (true) { try { String payload = orderQueueService.pollOrder(); if (payload == null) { continue; } // 订单落库逻辑 placeOrder(JSON.parseObject(payload, OrderCreateDTO.class)); } catch (Exception e) { // 记录失败任务,后续做补偿 log.error("订单消费失败", e); } } }, "order-queue-consumer").start(); } }消费线程必须包一层try/catch,否则单条脏数据会把整个消费者线程打断。更稳的做法是把失败订单写入一个order:dead-letter队列,定时任务再去重试,这套系统的事件机制已经预留了扩展点,我在接通知服务和库存回滚时基本都是在这个环节加逻辑。
2.3 权限控制到按钮级别
这套系统的后台角色权限不是简单控制到菜单,而是“多重身份权限管理,权限可以控制到按钮级别的操作”。也就是说,两个角色都能进入订单管理页面,但一个能看到“导出”按钮,另一个看不到。
后端权限模型一般是用户sys_user、角色sys_role、权限点sys_permission、菜单sys_menu四张表打底,再用中间表把角色和权限点关联起来。权限点的典型记录如下。
INSERT INTO sys_permission (perm_code, perm_name, perm_type) VALUES ('order:export', '订单导出', 3); INSERT INTO sys_role_permission (role_id, perm_id) VALUES (1, LAST_INSERT_ID());perm_type = 3表示按钮权限,区别于类型1的目录和类型2的菜单。后端接口上再加一层@PreAuthorize或者自定义注解,前端才会真正按按钮展示逻辑鉴权,例如:
@PreAuthorize("hasAuthority('order:export')") @PostMapping("/order/export") public void exportOrder(@RequestBody OrderExportDTO dto) { orderService.export(dto); }这里容易犯的错是只做了前端判断,后端没有校验。有人会在管理端页面上把导出按钮用v-if隐藏,但接口依然可以被人用 Postman 直接调用,等于权限形同虚设。正确做法是前端按钮级权限只解决体验,后端接口必须用 Spring Security 的权限表达式或者 AOP 注解二次校验。
3. Vue + Element UI 管理端:动态表单、权限指令与统计图表
管理端不是简单搭一个 Vue 后台模板。我拆完发现重点是三块:按钮级权限如何映射到指令,拖拽表单生成器怎么减少重复表单,以及 ECharts 统计图表如何接后端 RESTful 数据。这一章逐个落地。
3.1 按钮级权限在前端怎么做
后台返回给前端的是当前用户拥有的按钮权限码数组,比如["order:list", "order:export"]。Element UI 的模板里不可能每个按钮都写一遍v-if="permissions.includes('order:export')",所以我一般会把权限判断封装成 Vue 全局指令。
// main.js 里注册全局权限指令 Vue.directive('permission', { inserted(el, binding) { const requiredPerm = binding.value const userPerms = store.getters.permissions || [] if (!userPerms.includes(requiredPerm)) { el.parentNode && el.parentNode.removeChild(el) } } })使用方式:
<el-button v-permission="'order:export'" type="warning" @click="handleExport">导出</el-button>这段代码的核心是inserted钩子会在按钮插入 DOM 后立刻判断权限,没有权限就直接把它从父节点移除。如果必须保留按钮但禁用状态,改用v-if或者自定义指令里设置disabled会更合适。
需要注意这种指令不能用于隐藏路由,因为路由是整页级别,应该放到 Vue Router 的beforeEach守卫里判断菜单权限。按钮权限指令只管按钮,管不了页面。
3.2 拖拽表单生成器是给后端配置人员用的
系统手册里提到的“Vue 表单生成控件,拖拽配置表单”,在代码里本质是两个部分:左侧组件面板、中间表单画布。拖拽完成后,真正产出的是一个 JSON schema,不是直接生成一堆el-form-item写死在页面上。
{ "formName": "商品添加", "fields": [ { "prop": "goodsName", "label": "商品名称", "type": "input", "required": true }, { "prop": "categoryId", "label": "所属分类", "type": "select", "options": [ { "label": "手机", "value": 1 }, { "label": "电脑", "value": 2 } ]}, { "prop": "remark", "label": "备注", "type": "textarea" } ] }前端拿到这份 schema 后,用一个递归渲染组件就能按配置展示表单:
<template> <el-form :model="form" :rules="rules"> <el-form-item v-for="field in schema.fields" :key="field.prop" :label="field.label" :required="field.required"> <el-input v-if="field.type === 'input'" v-model="form[field.prop]" /> <el-select v-else-if="field.type === 'select'" v-model="form[field.prop]"> <el-option v-for="opt in field.options" :key="opt.value" :label="opt.label" :value="opt.value" /> </el-select> <el-input v-else-if="field.type === 'textarea'" type="textarea" v-model="form[field.prop]" /> </el-form-item> </el-form> </template>我一般在v-for渲染的el-form-item上不加prop之外的复杂 rules,因为动态表单的校验规则也来自 schema。比如required: true会转换成rules[field.prop] = { required: true, message: field.label + '不能为空' }。这套机制最大价值是减少前端重复表单工作量,运营后台的商品筛选、优惠券创建、配送规则配置都可以直接复用。
这里有一个非常实际的小坑:如果项目保持 Vue2 + Element UI,而某个页面需要“Popconfirm 气泡确认框内再增加一个输入框”,原生el-popconfirm的默认插槽并不好用。常见做法是包一层自定义组件,里面用el-popover手动实现焦点管理和确认按钮,必要时候再叠加el-input的v-model和@keyup.enter.native。不要为了一个确认框去升级 Element Plus,收益不高。
3.3 ECharts 统计页面的数据对齐方式
用户、产品、订单、资金四类统计分析,管理端用的是 ECharts 图表。代码层面最核心的不是 chart 配置,而是图表数据怎么和后端 RESTful 接口对齐。
后端接口返回的一般是 JSON:
{ "code": 200, "data": { "dates": ["2025-01-01", "2025-01-02", "2025-01-03"], "orderAmount": [1200.5, 3000.0, 2800.75], "orderCount": [12, 31, 27] } }前端初始化图表:
const res = await getOrderStatistic({ type: 'week' }) this.chart = echarts.init(this.$refs.chartRef) this.chart.setOption({ tooltip: { trigger: 'axis' }, xAxis: { type: 'category', data: res.data.dates }, yAxis: { type: 'value', name: '订单金额' }, series: [ { name: '订单金额', type: 'line', smooth: true, areaStyle: {}, data: res.data.orderAmount } ] })ECharts 的init必须在 DOM 渲染完成后调用,所以在$nextTick里初始化更安全。还有一个容易忽略的点是容器宽度,管理端页面如果被标签页折叠,chart容器宽度会变成 0,需要调用this.chart.resize()。常见做法是监听页面resize事件,并且在activated钩子里重新resize,否则图表从菜单切换回来会显示挤压。
4. UniApp 移动端:请求封装、分享和打包前配置
移动端既然用 UniApp,就不能只把它当成“多个小程序同时编译”。我从实际业务里挑三个高频问题讲:请求层怎么统一处理登录态,H5/小程序分享怎么做,以及打包上架前 manifest 配置要检查什么。
4.1 一个能切环境又不蹦的请求封装
UniApp 的uni.request直接散落在每个页面里,后面改接口域或者统一加 token 会很痛苦。我在这套系统里会先写一个request.js封装。
// utils/request.js const BASE_URL = process.env.NODE_ENV === 'development' ? 'http://192.168.1.10:8080' : 'https://api.example.com' export function request(options) { return new Promise((resolve, reject) => { uni.request({ url: BASE_URL + options.url, method: options.method || 'GET', data: options.data || {}, header: { Authorization: uni.getStorageSync('token') || '', 'Content-Type': 'application/json' }, success: (res) => { if (res.data.code === 200) { resolve(res.data.data) } else if (res.data.code === 401) { uni.removeStorageSync('token') uni.navigateTo({ url: '/pages/login/login' }) reject(res.data) } else { uni.showToast({ title: res.data.msg, icon: 'none' }) reject(res.data) } }, fail: (err) => { reject(err) } }) }) }这里code === 401要单独处理,因为登录态过期时,后端只是业务码返回 401,HTTP 状态码可能还是 200。客户端统一清除 token 并跳转登录页,比每个页面单独判断要省事。页面里调用时只要await request({ url: '/order/list' }),取到的就是业务数据,不用每处都解包一次data。
这套系统是前后端分离,所以移动端和 PC 管理端共用同一套 RESTful 接口。环境切分放在process.env.NODE_ENV判断,开发模式下后端可以开 Debug 日志,发布到小程序时再把BASE_URL换成线上域名。
4.2 H5 嵌入微信公众号定位与微信小程序自定义分享
如果移动端要嵌入微信公众号,很多页面需要拿到用户位置。UniApp 的uni.getLocation在 App 端和微信小程序端都没问题,但 H5 端受浏览器限制,需要依赖公众号的 JS-SDK 授权。
onLoad() { // H5 端公众号定位 uni.getLocation({ type: 'gcj02', success: (res) => { this.latitude = res.latitude this.longitude = res.longitude }, fail: () => { // 用户拒绝授权时,使用后端 IP 定位兜底 } }) }H5 端如果要读取微信环境,一般会先调后端获取 JS-SDK 签名,然后在前端配置wx.config。我见过很多项目把wx.config写死在index.html,结果每次换公众号或换域名都要改前端重新打包,正确做法是通过接口动态获取appId、timestamp、nonceStr、signature。
微信小程序自定义分享好友,页面里直接监听onShareAppMessage即可。这属于微信小程序的生命周期函数,UniApp 中不需要额外插件。
onShareAppMessage() { return { title: this.goodsTitle, path: `/pages/goods/detail?id=${this.goodsId}`, imageUrl: this.shareImage } }这里要注意path必须是/pages/...开头的完整路径,不能只写相对路径。imageUrl建议使用带域名的 HTTPS 图片,否则分享到微信时可能拉取不到封面图。
4.3 manifest 配置与打包上架
UniApp 项目的manifest.json是打包上线前最容易漏配置的地方。许多开发者在 HBuilderX 里浏览器预览没问题,一到真机和应用市场就各种报错。
| 目标平台 | 必查配置 | 常见问题 |
|---|---|---|
| H5 | 路由模式、基础路径、跨域代理 | 公众号 JS-SDK 安全域名未配置 |
| 微信小程序 | AppID、小程序基础库 | 开发工具勾选了不校验合法域名 |
| Android | 包名、证书 SHA1/SHA256、Android 版本适配 | 未使用证书签名导致安装包不能覆盖安装 |
| iOS | Bundle ID、推送证书、App Store Connect 密钥 | 签名证书过期,真机调试时反复失败 |
Android 上架到应用市场,我一般是用 HBuilderX 的云打包,但要注意云打包使用的证书 aliases 要和官网签名一致。iOS 打包前面坑最多,manifest.json中必须填写正确的 Bundle ID,并上传.p12证书和.mobileprovision描述文件。如果遇到“Unable to launch”或者签名验证失败,第一件事不是去改代码,而是检查证书是否过期。
5. 上线前一晚我会先改这几个地方
商城类系统真正上线前,功能流程大多已经没问题,问题反而集中在加载体验、视频资源和环境切换这几个不起眼的位置。
5.1 加载页和启动配置
UniApp 默认启动页在pages.json里配置,真正上线前要确认navigationStyle和backgroundColor是否符合要求。尤其是 H5 端嵌入微信公众号时,刚进入的加载页如果一直白屏,通常不是前端代码出问题,而是首屏请求过多。常见做法是把首屏需要的数据提前放到本地缓存,或者改成并行请求。manifest.json中 H5 端如果开了history路由,刷新页面会 404,需要后端做try_files回退到index.html。
5.2 m3u8 视频在移动端的播放差异
商城商品详情页经常放视频,很多团队直接用<video src="xxx.m3u8">就完事。在微信小程序端还好,UniApp 编译时原生video组件能直接播放 m3u8;但 H5 端在 iOS Safari 和部分 Android Chrome 上会出现只能放一秒就停住的情况。
常见做法是 H5 端引入hls.js,先判断浏览器原生是否支持 HLS 播放,不支持就走 Hls 实例。
if (this.platform === 'h5' && this.videoUrl.indexOf('.m3u8') > -1) { if (video.canPlayType('application/vnd.apple.mpegurl')) { video.src = this.videoUrl } else { const hls = new Hls() hls.loadSource(this.videoUrl) hls.attachMedia(video) } }这段逻辑背后的原理是 iOS Safari 原生支持 HLS,而 Android 原生 Chrome 不支持。video.canPlayType('application/vnd.apple.mpegurl')是一个兼容性探测,不要靠写死系统版本判断。UniApp 在 App 端一般直接用原生video组件即可,不要在 H5 端和 App 端共用同一套视频播放判断逻辑。
5.3 用订单导出验证整条链路
权限、统计、导出这三个功能经常是最后联调的。上线前我会用一条 curl 命令把订单导出接口、后端权限注解、前端权限指令三个环节一次验证完。
curl -X POST 'http://localhost:8080/api/order/export' \ -H 'Authorization: Bearer <token>' \ -H 'Content-Type: application/json' \ -d '{"startDate":"2025-01-01","endDate":"2025-01-31"}'如果返回 403,说明后端权限注解生效;如果能正常下载文件,说明角色权限点配置正确。前端再换一个没有导出权限的账号登录,确认页面上的导出按钮已经被v-permission指令移除。这样一次就能把“按钮级权限”从数据库到接口再到页面串起来。
如果你把 SpringBoot 版本升得过高,比如从 2.x 直接升到 3.x,这段链路很可能会先崩在 Redis 队列上,因为javax到jakarta的包名变更会影响启动扫描,Redis 反序列化也可能把OrderCreateDTO序列化出类名前缀。这时候不要急着换框架,先回到第一版的依赖版本,把升级单独放在分支里处理。商城系统能不能安全上线,关键不在功能多,而在这些边界配置是否提前验证过。
本文还有配套的精品资源,点击获取