最近在捣鼓一套“化妆品购物商城 + 测评分享”的完整系统,后端用的 SpringBoot,管理后台和 H5 用 Vue,C 端用户端做成了微信小程序和 App。整套系统从需求梳理、表结构设计到前后端联调,前前后后折腾了一个多月,过程中踩了不少坑,也沉淀了不少可以直接拿来就用的经验。
这个项目最大的特点是把“购物”和“测评种草”放在同一个产品里:用户进小程序之后,可以先刷达人和普通用户的真实测评内容,被种草了再去看商品详情、加入购物车、下单支付;买完之后还可以在订单里发测评、评分、传图传视频。这种“内容驱动交易”的模式在化妆品类目里特别吃香,因为化妆品决策高度依赖口碑和真实体验,干巴巴的商品列表很难让用户下单。
如果你正在做毕业设计、个人作品集,或者小团队想快速验证一个美妆电商的业务闭环,这套系统的架构思路和实现细节都很有参考价值。下面我把整个项目的关键设计、核心代码实现、常见问题排查思路完整拆一遍。
1. 先把项目想清楚:商城和测评分享到底怎么揉在一起
1.1 核心业务闭环设计
很多新手一上来就急着建表、写接口,结果做到一半发现业务逻辑混乱。我建议先把用户路径画出来。这套系统的核心路径是:浏览测评内容 → 被种草 → 查看商品详情 → 加入购物车 → 提交订单并支付 → 写测评/晒单 → 获得关注和点赞。
注意这里有一个关键点:测评不是和商城并列的两个模块,而是嵌入在用户购物动线里的。用户在测评详情页看到推荐商品,可以直接跳转到商品详情页,下单完成之后,用户的“待评价列表”里才会出现这条订单,这就保证了测评内容的真实性。我在设计的时候把“是否购买后测评”做成了一个字段purchased_flag,购买过的用户发布的测评会打上“已购”标签,没有购买的用户也能发测评,但权重排序会靠后,而且不能带“购买建议”类的高引导性内容。
1.2 功能模块划分
从功能上看,系统分成四个端:
- C端小程序/App(用户端):微信登录、首页信息流、测评详情、商品列表与详情、购物车、下单支付、订单管理、个人中心、发布测评、点赞收藏关注。
- 管理后台(Vue PC端):商品管理(SPU/SKU)、库存管理、测评审核、订单管理、用户管理、轮播图管理、分类管理。
- 后端服务(SpringBoot):提供 RESTful API,处理鉴权、业务逻辑、文件上传、订单状态流转、内容审核。
- 存储与中间件:MySQL 存业务数据,Redis 做缓存和库存扣减,MinIO 做图片和视频的对象存储。
1.3 这套方案适合谁
如果你只是做一个静态展示页面,那用不着这么重的架构。这套方案适合有真实交易逻辑的项目:用户要登录、要下单、要支付(至少走模拟支付或微信支付)、要有内容发布和审核。换句话说,它不是一个简单的“商城 Demo”,而是一个可运行的、能演示完整业务闭环的系统。也正因为如此,用 SpringBoot + Vue + 小程序这套技术栈是最稳的:个人能驾驭、社区资料多、部署成本低。
2. 技术选型:SpringBoot + Vue + 小程序,为什么这么搭
2.1 后端为什么选 SpringBoot
SpringBoot 在 Java 后端领域基本是事实标准了,尤其适合单体应用快速落地。我选的是SpringBoot 2.7.x 版本,为什么不直接上 3.x?因为很多毕业设计和公司内部项目还在用 2.x,而且 3.x 要求 JDK 17,对不熟悉的新手来说有点门槛,2.7.x 配合 JDK 8 就能跑得稳稳的。后续要升级也不是问题,核心业务代码改动量不大。
Maven 构建是这个项目的标配,pom.xml里核心依赖就那几样:spring-boot-starter-web、spring-boot-starter-data-jpa(或者 MyBatis-Plus)、spring-boot-starter-data-redis、mysql-connector-j、jjwt(JWT 鉴权)、minio(对象存储 SDK)。我在这套项目里用的是 MyBatis-Plus,它和 SpringBoot 配合起来写 CRUD 极其省事,特别是分页查询,自带Page对象,不用自己手写 PageHelper。
2.2 前端和管理后台:Vue 3 + Vite
管理后台我用的 Vue 3 + Vite + Element Plus。Vite 比 Webpack 快太多,开发时热更新几乎秒开,对于频繁调整页面的场景非常友好。Vue Router 用的history 模式,但这里埋了一个坑:打包之后部署到静态服务器或者 SpringBoot 的static目录时,刷新二级页面会 404。我后来直接把管理后台改成了hash 模式,省去一堆路由回退配置。如果你的项目要求必须用 history 模式,那记住在服务器或 SpringBoot 里加一个forward到index.html的兜底处理就行。
2.3 C端:为什么不写两套原生,而是用 uni-app
小程序和 App 如果各写一套原生,成本和维护量直接翻倍。我选择了uni-app,一套 Vue 语法代码同时编译到微信小程序和 App(离线打包或云打包)。它在 C 端的好处非常明显:页面结构用 Vue 单文件组件写,生命周期和微信小程序的生命周期做了映射,你可以在onLoad、onShow、onReachBottom这些钩子里写业务逻辑。这套代码后续如果要做抖音小程序、支付宝小程序,也能复用一大半。
2.4 存储方案:MySQL + Redis + MinIO
数据存储我分了三层:MySQL 存用户、订单、商品、测评的内容数据;Redis 存热点商品缓存、购物车临时数据、库存扣减的原子操作;MinIO 存测评图片、商品主图、测评视频。MinIO 是一个开源的对象存储服务,完全兼容亚马逊 S3 API,你可以部署在自己服务器上,几十 GB 的图片和视频完全没压力,而且不像云厂商的 OSS 会产生外网流量费用。把 MinIO 加入 SpringBoot 也是很常规的操作,加一个依赖、配置一个 client 就能用。
3. 核心表结构设计:这一步做稳,后面少走一半弯路
3.1 用户与登录相关表
用户表我设计了字段:id、openid、unionid、nickname、avatar、skin_type(肤质类型:干性、油性、混合、敏感)、phone、status、create_time。这里重点说一下openid和unionid:微信小程序登录拿到的code换回来后主要是openid,同一个用户在不同小程序下openid不同,只有绑定了开放平台账号才能拿到unionid。我设计了独立索引,并且把openid设为唯一键,避免同一个微信号反复插入多条用户记录。
3.2 商品表:SPU/SKU 分离
化妆品商品有规格属性,比如色号、容量,所以我拆了product(SPU)和product_sku(SKU)两张表。SPU 表存商品标题、品牌、主图、详情图、分类 ID、功效标签;SKU 表存具体的规格名、库存、价格、SKU 图片。查询商品列表时按 SPU 维度返回,进入详情页后通过product_id查出所有 SKU,前端做规格选择。库存我放在了 SKU 表上而不是 SPU 表,因为用户下单时锁定的是具体 SKU 的库存,比如一支口红可以有大牌色号和小样色号,库存消耗完全独立。
3.3 测评内容表
测评表是整个系统的灵魂,我设计了这些关键字段:id、user_id、product_id、order_id(关联订单,判断是否已购)、content、score(评分1-5)、skin_type(用户肤质)、effect_tags(功效标签,如保湿、控油、不致痘)、images(JSON 数组,最多9张图)、video_url、purchased_flag、status(待审核/通过/驳回)、like_count、view_count、create_time。
这里有两个细节建议特别注意:一是images用 JSON 数组存,在前端渲染时直接JSON.parse,比额外建一张图片子表简单得多,管理后台展示也方便;二是order_id字段要允许为空,因为非购买用户也能发测评,但通过purchased_flag区分展示层级。
3.4 订单与购物车链路
购物车表字段相对简单:id、user_id、sku_id、quantity、checked(是否选中)、create_time。订单我拆了主表和子表:order存订单号、用户 ID、总金额、实付金额、状态、收货地址快照;order_item存订单下的每个商品 SKU、单价、数量、商品快照。为什么要做快照?因为商品价格和标题随时可能调整,如果不快照,用户查看历史订单时看到的是当前价格,会造成纠纷。订单状态我用status字段管理:0待支付、1已支付/待发货、2已发货/待收货、3已完成、4已取消、5退款中/已退款。
关于“测评和订单”的关联,我特意用order_id把测评关联到订单项目上,防止用户买了一次口红然后发十篇测评。表结构上就是在测评表里加一条唯一约束uk_user_order_product,同一个用户同一个订单同一个商品只能发一条购买型测评。
4. 后端 SpringBoot 实现:登录、文件存储、库存防超卖这些硬骨头
4.1 项目初始化和配置
用 IDEA 新建 SpringBoot 项目的时候,可以直接选择 Spring Initializr,依赖选择 Web、MySQL Driver、Redis、Lombok、Validation。我习惯在pom.xml里再加MyBatis-Plus、jjwt、MinIO三个依赖,版本号用 Maven 仓库里稳定版即可。application.yml里核心配置如下:
server: port: 8080 spring: datasource: url: jdbc:mysql://localhost:3306/beauty_mall?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai username: root password: your_password driver-class-name: com.mysql.cj.jdbc.Driver redis: host: localhost port: 6379 servlet: multipart: max-file-size: 50MB max-request-size: 100MB minio: endpoint: http://localhost:9000 access-key: minioadmin secret-key: minioadmin bucket: beauty-mall注意 MySQL 连接串里一定要写serverTimezone=Asia/Shanghai,不写的话你会在时间字段上遇到 8 小时时差问题,尤其是订单创建时间对不上,排查起来很头疼。
4.2 微信登录与 JWT 鉴权
微信小程序登录的流程是:小程序端调用wx.login()拿到临时code,把这个code传到后端;后端拿着code+appid+secret请求微信的接口换openid;拿到openid后查用户表,不存在就自动注册;最后生成一个 JWT token 返回给前端。前端后续请求都把 token 放在Authorization请求头里,后端用一个拦截器解析 token 并往ThreadLocal里塞当前用户 ID。
核心接口逻辑大概是:
@PostMapping("/wx/login") public Result login(@RequestBody WxLoginDTO dto) { String url = "https://api.weixin.qq.com/sns/jscode2session?appid=" + appId + "&secret=" + appSecret + "&js_code=" + dto.getCode() + "&grant_type=authorization_code"; String response = restTemplate.getForObject(url, String.class); JSONObject json = JSON.parseObject(response); String openid = json.getString("openid"); User user = userMapper.selectOne(new LambdaQueryWrapper<User>() .eq(User::getOpenid, openid)); if (user == null) { user = new User(); user.setOpenid(openid); user.setNickname("微信用户" + openid.substring(openid.length() - 6)); userMapper.insert(user); } String token = JwtUtil.createToken(user.getId()); return Result.success(token); }JWT 我用的jjwt库,生成 token 时把用户 ID 写进subject,过期时间设为 7 天。注意不要把openid写进 token,因为 token 会暴露在客户端,虽然 JWT 签名不可伪造,但信息仍然可以解码,敏感数据一律不放。
4.3 MinIO 文件存储:图片和视频统一托管
化妆品测评里图片和视频是重头戏,我用 MinIO 做了统一存储。把 MinIO 集成到 SpringBoot 其实就是三步:写一个配置类读取application.yml里的参数并创建MinioClient;封装 upload、getUrl、delete 方法;写一个通用文件上传接口。
@Service public class MinioService { @Autowired private MinioClient minioClient; public String upload(MultipartFile file, String objectName) throws Exception { PutObjectArgs args = PutObjectArgs.builder() .bucket("beauty-mall") .object(objectName) .stream(file.getInputStream(), file.getSize(), -1) .contentType(file.getContentType()) .build(); minioClient.putObject(args); return minioClient.getPresignedObjectUrl( GetPresignedObjectUrlArgs.builder() .method(Method.GET) .bucket("beauty-mall") .object(objectName) .expiry(7 * 24 * 3600) .build()); } }这里有一个坑要重点提醒:getPresignedObjectUrl生成的链接是有过期时间的,默认 7 天,超过时间图片就访问不了了。如果是商品主图这种长期展示的图片,建议把存储桶权限设为公开读,用http://ip:9000/beauty-mall/objectName这种固定地址拼接返回给前端,否则用户过几天打开小程序会发现所有图片全部裂开。视频文件基本都是几百 MB 量级,上传时注意multipart大小配置,同时在前端做一个上传前压缩和进度条,体验会好很多。
4.4 商品库存的防超卖方案
商城系统最怕的就是超卖,也就是用户下单成功后库存变成负数。我用的方案是 Redis 预热 + 数据库扣减双重校验:商品上架时把 SKU 库存同步到 Redis;用户下单时先从 Redis 用decr原子操作扣减库存,返回结果如果小于 0 说明没货了,直接回滚;Redis 扣减成功后再往订单表插入数据。为什么不能只依赖数据库的update product_sku set stock = stock - 1 where id = ? and stock > 0?因为这个操作在高并发下会锁行,而且一旦数据库挂了就完全不可用。实际生产里电商系统通常两者结合,Redis 扛住峰值流量,数据库负责最终一致性对账。
不过如果你做的只是一个演示项目,单纯用数据库乐观锁实现完全可以:UPDATE product_sku SET stock = stock - #{count} WHERE id = #{skuId} AND stock >= #{count},受影响行数为 0 就提示用户库存不足,简单且不会超卖。
5. Vue 端与小程序端:管理后台和 C 端页面怎么落地
5.1 管理后台:Vue3 动态路由与权限控制
管理后台我用 Vue3 + Element Plus,路由分成了静态路由和动态路由两部分。静态路由只有登录页;登录成功后根据当前用户的角色(管理员、运营、普通用户)动态添加路由。项目里我用的是一个asyncRoutes数组,里面包含商品管理、订单管理、测评审核、用户管理等页面路由,每个路由对象都有meta.roles字段,登录成功后遍历过滤出有权限的路由,用router.addRoute()动态添加。
router.beforeEach((to, from, next) => { const token = localStorage.getItem('token') if (!token && to.path !== '/login') { next('/login') } else if (token) { const userInfo = JSON.parse(localStorage.getItem('userInfo')) if (userInfo && userInfo.role === 'ADMIN') { next() } else { next('/403') } } else { next() } })这里建议把动态路由做成一个小函数,部署的时候直接执行一遍看是否有报错,因为路由表一旦编译错误,管理后台整个白屏,而控制台里的报错往往要到跳转时才触发,排查周期很长。
5.2 小程序端:uniapp 打包微信小程序的适配细节
C 端我用的 uni-app,核心页面包括首页信息流、测评详情页、商品详情页、购物车、订单确认、支付、个人中心、发布测评页。这里有几个微信小程序特有的适配点,新手容易踩:
页面头部标题:小程序页面的标题栏要在pages.json里配置navigationBarTitleText,它是个静态配置,没法在页面 onLoad 里随时改,除非你用uni.setNavigationBarTitle()。
顶部导航栏高度:微信小程序的胶囊按钮高度在不同机型上不一样,iPhone 老机型和新机型的statusBarHeight差别很大。我在公共样式里用了 CSS 变量:
var(--status-bar-height)uni-app 自己封装了这个变量,适配刘海屏和挖孔屏时非常好用。
列表加载更多:首页信息流和测评列表是典型的瀑布流分页场景,用onReachBottom触底加载:
onReachBottom() { if (this.page * this.limit >= this.total) { uni.showToast({ title: '没有更多了', icon: 'none' }) return } this.page += 1 this.loadData() }注意onReachBottom触发很频繁,用户滚动到底部可能会连续触发两三次,建议加一个isLoading锁,请求没结束前丢弃后续触发。
微信登录:uni-app 里直接uni.login({ provider: 'weixin' })拿到 code,再调后端接口。这里有个细节,如果你用uni.getUserProfile()去拿用户头像昵称,微信官方已经限制了,2022 年后新注册的小程序不让通过这个接口直接弹出授权头像昵称了,项目里最好引导用户去个人中心手动上传头像、填写昵称。
5.3 测评视频播放:带 m3u8 的免安装方案
测评内容里除了图片还能传短视频,我当时顺便把视频播放也处理了。如果你的视频文件是 m3u8 分片格式,在微信小程序里可以直接用官方video组件播放,因为微信内置了解码器:
<video :src="videoUrl" object-fit="contain" autoplay playsinline controls ></video>但要注意 m3u8 的域名必须在小程序后台的“业务域名”白名单里,否则就是黑屏加报错。如果是 H5 端(你后续把同一套代码编译成浏览器版),浏览器不能原生播放 m3u8,需要用 hls.js 做封装,这个项目里我在 H5 端加了一个 video 播放器组件,内部集成 hls.js,加载时判断Hls.isSupported(),支持就用 Hls 对象挂载播放,不支持就走原生 video。整套下来大约是几十行代码,但解决了“免安装播放”的需求。
5.4 小程序调试与抓包:charles 和开发者工具的正确用法
小程序联调阶段最大的痛点是看不到后端返回的真实接口数据。我推荐两种方式:第一种最直接,微信开发者工具里勾选“不校验合法域名”,然后把本地后端接口地址填进去,直接跑通联调;第二种就是抓包,我用的 charles 抓包工具。把手机和电脑连同一个 WiFi,手机 HTTP 代理指向电脑 IP 的 8888 端口,再安装 charles 的 SSL 证书,就能看到小程序发出的所有 HTTPS 请求,包括微信登录的 code、后端返回的 token。抓包对排查“小程序端收到了什么、到底发了什么”非常有用,尤其是支付回调、token 过期这类前后端衔接问题,五分钟就能定位是前端传参的问题还是后端返回的问题。
6. 常见问题避坑速查表:这些坑我替你踩过了
6.1 后端编程中的高频问题
| 问题 | 原因 | 解决方案 |
|---|---|---|
| 时间字段差 8 小时 | 数据库连接串没有设置时区 | url 里加serverTimezone=Asia/Shanghai |
| 上传大视频报 413 | Nginx 默认请求体太小 | client_max_body_size 100m; |
| MinIO 图片地址过期 | 用了预签名 URL 且过期时间短 | 桶设置为公开读,直接拼字符串地址 |
| JWT 解析失败 401 | token 过期或签名不一致 | 登录时设置统一的密钥,filter 里校验 |
| SpringBoot 启动报循环依赖 | 不同 service 互相注入 | 把被依赖的方法抽到单独类,或加@Lazy |
6.2 SpringBoot 版本和 Maven 构建的坑
我遇到的一个典型问题是 Maven 构建时本地仓库缺少新依赖,导致报编译错误。解决思路是确认 JDK 版本和 SpringBoot 版本配套:2.7.x 对应 JDK 8,3.x 对应 JDK 17。如果你是 JDK 17 环境,硬跑 SpringBoot 2.3.x 的老项目会报错。还有一个常见的坑是 Maven 打包后 jar 文件非常大,里面包含了 SpringBoot 插件的 repackage 打包,如果你在构建部署时用的是mvn package默认配置,一定要注意spring-boot-maven-plugin的配置,否则打出来的包里缺主类,一启动就是no main manifest attribute。
6.3 前端打包放进 SpringBoot 的路径问题
这个项目最后要能一键部署,我选择把 Vue 管理后台打包后的dist目录直接复制到 SpringBoot 的src/main/resources/static下面。这里有两个注意点:一是 Vite 打包时要修改base: './',否则静态资源路径会在ip:8080这个根路径上带一层绝对路径,导致 JS 和 CSS 加载不到;二是前端路由要用 hash 模式,否则在二级页面手动刷新就会 404。如果你非要 history 模式,就在 SpringBoot 里写一个WebMvcConfigurer的addViewControllers,把所有非 API 的路径都 forward 到index.html。
6.4 小程序审核和上线准备
小程序端做好之后,提交微信审核前我提醒自己检查这几项:类目选择要和商城+测评匹配,如果是个人主体千万不能选错类目,个人主体小程序里“商城”类目基本不开放,这也是为什么很多同类项目会挂到公司主体下;页面必须完整,不能留空的 tabBar 页面;用户隐私协议、客服功能、用户反馈入口都要有。测评内容涉及化妆品功效类词汇,比如“美白”“祛斑”“治疗”这类词很容易触发审核规则,我做的处理是在发表测评接口里加入关键词过滤,交给运营在后端人工二次审核,避免内容直接上墙。
6.5 我的一点实战心得
最后说几条我个人觉得特别有用的经验。第一,做这种商城+测评项目,不要一上来就写代码,先把关键页面流程用小程序的页面草稿画一遍,页面之间的联系理清楚之后,表结构和接口都顺了。第二,尽量提前把数据权限和内容审核做进去,化妆品测评涉及功效宣称,后期平台合规要求只会越来越严,早做比晚做强。第三,如果时间紧张,优先跑通“微信登录-浏览测评-下单支付-发布测评”这条最小闭环,其他功能比如优惠券、积分、分享裂变都可以迭代补上。
这个项目的价值更多在于帮你完整掌握一套现代 Web 应用从零到一的构建流程:SpringBoot 后端接口设计、SQL 数据建模、Redis 缓存使用、对象存储接入、Vue3 管理后台、uni-app 小程序端打包上线。美妆加测评这个业务方向本身也很有延展性,后续完全可以在此基础上扩展社区问答、达人榜单、AI 肤质分析、商品对比等功能。希望这篇拆解能给你省下一些查资料和踩坑的时间。