做智慧生活商城这套系统,我最初是给一个做社区生鲜配送的团队接的私活。当时对方提的需求很直白:能上下架商品、用户能下单付款、后台能看到订单、最好还能做点简单营销。我拿着这套需求折腾了三轮,最后沉淀出SpringBoot+Vue3+MyBatis+MySQL这个前后端分离的组合,代码结构整理干净后,我屡次推荐给身边正在做毕设或者想写进简历的Java新人作为完整项目参考——因为商城系统的业务闭环非常典型,从用户登录到商品浏览、加购、下单、支付回调,再到后台管理,几乎覆盖了企业级开发的主流程。这篇文章就是把我整理这套源码时的设计思路、落地方案和踩过的坑一次性讲清楚,不是零散地贴代码,而是告诉你每一步为什么这么做。
1. 商城系统的业务模型,决定了技术栈的每一处取舍
1.1 "智慧生活"归根结底是场景拼图
很多人看到"智慧生活商城"这个名字,第一反应是智能家居设备售卖平台,其实不完全对。在我做的这个系统里,"智慧生活"指向的是线上购买生活服务与生鲜日用品的闭环:小区门口的生鲜店把货架搬到微信端,用户下单后选择自提或者配送,后台自动汇总订单给拣货员。这个模型最核心的特点有两个:一是商品种类杂、图片多、价格变化频繁;二是订单量有潮汐效应,比如周末早上的峰值可以达到平日的五六倍。
这两点直接影响技术决策。商品杂意味着数据库表设计不能太死,商品和分类要支持多级、多维度筛选;图片多意味着接口返回的数据量要可控,需要分页和懒加载;价格频繁变化意味着缓存设计不能盲目,分类和商品详情可以缓存,但库存和价格必须有实时数据兜底;订单潮汐效应则提醒我在设计核心交易链路时优先考虑数据库事务的稳定性,而不是一开始就上分布式那一套。
坦白说,如果不先把业务模型想清楚,上来就照着网上的开源商城项目敲代码,很容易做出一个"看起来什么都有、用起来到处别扭"的东西。比如有人喜欢用复杂的权限框架,结果一个小商城光角色权限就建了六七张表,维护成本极高。我在这套系统里只保留了"用户端"和"管理端"两种角色,用最简单的角色字段区分,配合JWT登录,完全够用。
1.2 后端为什么落在SpringBoot+MyBatis,而不是JPA或MyBatis-Plus
这可能是后台开发者争论最多的地方。我的看法很朴素:如果你的项目需要手写复杂SQL并掌控每一条查询的性能,MyBatis是比JPA更可控的选择;如果你不想在XML里写一堆基础CRUD并用代码生成器弥补,MyBatis-Plus会更顺手。我在这套系统里选的是原生MyBatis,因为商城系统的查询条件组合多变且包含大量统计SQL,原生XML写法在应对"多表联查+动态条件"时非常清晰,SQL的直接可读性和DBA的排查体验都比半自动拼接好。
SpringBoot在这个组合里是毫无疑问的底座,它帮我把Tomcat容器、数据库连接池、事务管理、参数校验这些繁琐配置都默认掉了。你在pom.xml里引入spring-boot-starter-web和spring-boot-starter-jdbc之后,只要专注写@RestController和@Mapper即可。这里我要特别建议的是:SpringBoot版本建议选2.7.x分支,而不是一上来就追3.x。3.0之后底层从Java EE迁移到Jakarta EE规范,部分老插件和教程不兼容,新手排查起来非常难受。商城的核心在业务而不是追求最新版本,我的源码统一用的是2.7.14,稳定且资料齐全。
1.3 前端为什么是Vue3,以及Vue2用户怎么平滑迁移
我用Vue3开始重构这套系统的时候,身边的项目组还在Vue2的生态里。Vue3带来的Composition API是这次切换里最值得说的一点。Vue2时代我们写一个商品列表页面,需要的data、methods、computed分散在同一个对象的不同块里,几百行代码逻辑一多就开始乱。Vue3的setup语法配合ref、reactive、computed,可以把商品列表的数据请求、分页状态、筛选条件、加载状态放进同一个组合函数里,复用性直接上了一个台阶。
当然,如果之前完全没接触过Vue3,也不用太担心,核心的模板语法和组件通信方式基本延续了Vue2的风格,最大的变化在于:一、数据定义从data()变成了ref和reactive;二、生命周期钩子在setup里改成了onMounted、onBeforeUnmount这种函数式写法;三、全局事件总线和过滤器被官方移除。前半个月会有点别扭,一旦把setup的思维方式理顺,回头看你Vue2的代码就不太想继续维护了。在代码生成上,我建议直接用Vite脚手架,官方推荐且启动速度快,Vite4搭配Vue3.4的体验比Webpack时代快得不是一点半点。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 数据库设计与项目骨架:开工前最值得死磕的部分
2.1 核心表结构设计思路
商城系统的表我分成了三类:用户交易、商品管理、营销活动。用户交易相关表是交易一致性的核心,包括user、address、cart_item、orders、order_item;商品管理相关表包括category、product、product_sku、product_image;营销活动相关表主要是coupon、coupon_user,我这套还加了一张seckill_product用来做秒杀场景演示。
以订单表为例,我贴一下核心字段设计:
sql复制CREATE TABLE `orders` (
`id` BIGINT NOT NULL COMMENT '订单ID(雪花算法生成)',
`order_no` VARCHAR(32) 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 '实付金额',
`freight_amount` DECIMAL(10,2) DEFAULT 0.00 NULL,
`status` TINYINT NOT NULL DEFAULT 0 COMMENT '0待支付 1已支付 2已发货 3已完成 4已取消',
`address_snapshot` VARCHAR(500) NOT NULL COMMENT '收货地址快照',
`pay_time` DATETIME NULL,
`create_time` DATETIME DEFAULT CURRENT_TIMESTAMP,
`update_time` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
UNIQUE KEY `uk_order_no` (`order_no`),
KEY `idx_user_id` (`user_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;
里面有两点经验值得强调。第一个是订单号表里单独存order_no而不是直接用雪花ID做主键。雪花ID是BIGINT类型,能保证分布式场景下不冲突,但它是趋势递增而不相邻,如果直接用做业务单号,打印在单据上会比较长。我用雪花ID做主键保证索引性能,同时业务上生成一串可读性更好的订单号(比如时间戳+用户ID后四位+随机序号),方便客服沟通和日志排查。第二个是地址信息用快照而不是关联地址表,因为下单之后如果用户改了默认地址,订单不能跟着变,必须把下单那一刻的地址信息原样存下来,这是非常容易踩的业务坑。
2.2 MySQL单库设计与连接配置的实践经验
商城系统的体量在中小场景下完全没有必要一开始就分库分表,一台性能尚可的MySQL 8.0实例跑万级订单量毫无压力。我把全部表放在同一个库smart_life里,字符集统一使用utf8mb4,这个字符集能完整支持MySQL 8.0的emoji存储——生鲜商品名字里带个"🍎"这类符号再也不会报错。
连接配置上,我的application.yml里有几处是新手容易忽略的:
yaml复制spring:
datasource:
url: jdbc:mysql://localhost:3306/smart_life?useUnicode=true&characterEncoding=utf8&useSSL=false&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true
username: root
password: yourpassword
driver-class-name: com.mysql.cj.jdbc.Driver
serverTimezone=Asia/Shanghai这个参数必须显式声明,否则MySQL 8.0驱动默认读取服务器时区,如果你的服务器是UTC,数据库里存的时间和北京时间就差了8个小时,前端页面显示订单时间永远不对。allowPublicKeyRetrieval=true这个参数主要解决MySQL 8.0使用caching_sha2_password认证方式时首次连接报Public Key Retrieval is not allowed的问题。这两个配置都是实打实排出来的坑,你复制过去直接用能省半小时排查时间。
2.3 MyBatis映射文件的SQL组织方式
项目中我把SQL按照模块分文件组织,ProductMapper.xml只放商品模块的SQL,OrderMapper.xml只放订单模块的SQL,避免一个超大XML维护困难。这里有个细节值得特别说明:动态SQL里的<where>和<set>标签尽量让MyBatis帮你处理拼接的空条件和尾逗号,比如商品列表的多条件筛选:
xml复制<select id="searchProducts" resultType="com.smartlife.product.dto.ProductVO">
SELECT p.id, p.name, p.price, p.main_image,
c.name AS category_name
FROM product p
LEFT JOIN category c ON p.category_id = c.id
<where>
<if test="keyword != null and keyword != ''">
AND p.name LIKE CONCAT('%', #{keyword}, '%')
</if>
<if test="categoryId != null">
AND p.category_id = #{categoryId}
</if>
<if test="minPrice != null">
AND p.price >= #{minPrice}
</if>
<if test="maxPrice != null">
AND p.price <= #{maxPrice}
</if>
</where>
ORDER BY p.sort_order DESC, p.id DESC
LIMIT #{offset}, #{pageSize}
</select>
注意<if>条件里写>和<时要用XML转义字符>和<,否则XML解析直接报错。LIKE查询我用的CONCAT('%', #{keyword}, '%')而不是'%${keyword}%',这是防范SQL注入的第一步——#{}预编译,${}直接拼接字符串,绝不能让用户的搜索关键词以${}形式进入SQL。
3. 后端核心功能落地的关键设计
3.1 JWT登录鉴权与拦截器实现
商城系统的所有用户端接口,除了登录注册和商品浏览之外,都需要做登录校验。我用的是JWT,流程是:用户登录成功后服务端生成一个token返回给前端,前端每次请求在Authorization请求头里带上这个token,后端通过拦截器统一校验。这套机制的好处是服务端不需要存储会话信息,水平扩容的时候不需要考虑session同步。
JWT的生成代码我封装成了一个JwtUtil工具类,要点包括:
java复制public String generateToken(Long userId, String role) {
return Jwts.builder()
.setSubject(String.valueOf(userId))
.claim("role", role)
.setIssuedAt(new Date())
.setExpiration(new Date(System.currentTimeMillis() + EXPIRE_TIME))
.signWith(SignatureAlgorithm.HS256, SECRET_KEY)
.compact();
}
实际项目里我不建议拿user_id当subject就结束,最好再塞一个随机生成的会话标识进去,比如UUID,然后配合Redis做登录态管理,这样才能做到改密码后让旧token失效。不过作为入门级完整项目,单纯用JWT也是成立的,关键点在于拦截器排除路径要配置对。登录、注册、商品列表、商品详情这些接口要放行;购物车、订单、个人中心这些接口必须验证token。
实现上我写了一个AuthInterceptor,继承HandlerInterceptorAdapter(SpringBoot 2.x)或者实现HandlerInterceptor接口,重写preHandle方法。校验不通过时直接返回401状态码和一个统一的JSON响应体,而不是重定向到登录页——因为前后端分离模式下,重定向目标页面的逻辑应该由前端路由负责。拦截器注册时指定addPathPatterns("/api/**"),然后用excludePathPatterns排除掉登录注册和商品浏览的路径。
拦截器这块很多新手会犯的一个错误:只在某个Controller里手动校验token,这样做接口多了之后必然有遗漏。统一拦截器+注解(比如自定义@RequireLogin)双保险才是企业级项目的标准做法,我在代码里保留了完整的实现,你可以对照参考。
3.2 商品与购物车的并发控制细节
商品模块本身难度不高,但购物车加到订单有一个经典坑:购物车的价格必须以下单时刻的数据库价格为准,而不是直接用前端传过来的价格。很多新人图省事,前端把购物车里商品的小计传给后端,后端信任前端直接组装订单,结果用户改一下请求包就能把商品价格改成一分钱,这就是严重的逻辑漏洞。我的做法是后端接收到下单请求后,根据购物车条目ID重新从product表查询最新价格,逐项计算金额,前端传的单价一律不信任。
购物车功能我用的是传统的"未登录存localStorage,登录后同步到后端"方案,实际开发中也可以直接用Redis维护用户购物车。数据库方案的优点是订单链路一致性好,cart_item表里的记录直接标记为已结算就不会再被重复下单。加购和改数量这些接口要控制并发,我在cart_item表加了version字段做乐观锁:
sql复制UPDATE cart_item SET quantity = #{quantity}, version = version + 1
WHERE id = #{id} AND version = #{version}
如果更新影响行数为0,说明这个购物车条目在读取后被别人改过了,返回提示"请刷新购物车后再操作"。
3.3 订单事务与库存扣减的一致性方案
提交订单是商城系统里对事务要求最高的操作。一个完整的下单动作涉及:创建订单主记录、创建订单明细、扣减商品库存、清空购物车对应条目。我的做法是在Service层使用@Transactional注解,遇到任何异常自动回滚。
但事务能保证数据一致性,却解决不了高并发下的超卖问题。测试中最典型的表现是:库存只剩1件,两个用户同时下单,都读取到库存为1件,都执行扣减都成功了,最终卖出了两件。解决超卖有一个非常经典的方案:在扣减库存的SQL语句里加上库存大于0的条件。
sql复制UPDATE product SET stock = stock - #{quantity}
WHERE id = #{productId} AND stock >= #{quantity}
这条SQL利用数据库行锁保证了原子性,库存不够时影响行数为0,业务层判断影响行数如果为0就抛异常,让秒杀失败的请求走提示流程。对智能生活商城这种量级来说,这个方案已经足够了,不需要一开始就上Redis分布式锁(那个是另一套复杂度)。秒杀场景我还单独设计了一张seckill_product表,开始时间和结束时间都存精确到秒,接口在查询时就过滤掉未开始和已结束的活动,然后用上面的原子扣减SQL,效果稳定。
3.4 MyBatis缓存机制在查询优化里的实际作用
MyBatis缓存是面试里常被问到的点,实际项目中也要用对。MyBatis一级缓存默认开启,是SqlSession级别的,同一个SqlSession内执行两次完全相同的查询,第二次直接命中缓存不查数据库。但是Spring整合MyBatis后,每次Mapper方法调用都可能新建一个SqlSession,一级缓存基本不生效,我之前很多同事以为项目里已经用了缓存,实际上什么缓存都没生效。
真正对性能有帮助的是二级缓存,它是namespace级别的,需要在你的Mapper XML里显式开启:
xml复制<cache eviction="LRU" flushInterval="60000" size="512" readOnly="true"/>
在智慧生活商城里,我对category分类表和product商品表的查询开启了二级缓存,因为商品分类基本不变,商品信息的变化频率也远低于订单。但要注意,只要执行了该namespace下的任意INSERT/UPDATE/DELETE,MyBatis会默认把二级缓存清空,这也是为什么订单表、购物车表绝对不能开二级缓存——它们频繁写入,开了缓存不仅没用,还会因为频繁刷新缓存增加开销。
如果你想把缓存能力再向前推一步,可以引入Redis做业务层缓存。举个具体场景:首页的商品分类导航,每次用户刷新页面都要查一次category表,虽然MySQL性能不错,但顶不住高并发。我在CategoryService里加了一层Redis缓存:查询时先查Redis,没有则查数据库并写入Redis,设置过期时间30分钟。
java复制public List<CategoryVO> getCategoryList() {
String cacheKey = "smart:category:all";
String json = redisTemplate.opsForValue().get(cacheKey);
if (StringUtils.hasText(json)) {
return JSON.parseArray(json, CategoryVO.class);
}
List<CategoryVO> list = categoryMapper.selectAll();
redisTemplate.opsForValue().set(cacheKey, JSON.toJSONString(list), 30, TimeUnit.MINUTES);
return list;
}
这里还要注意缓存穿透。如果数据库里真的没有分类数据,Redis里也不会缓存,下一次请求又去打数据库。最简单的防线是:即使查询结果为空,也把一个空对象或者特殊标志缓存一小段时间,避免恶意请求用不存在的ID穷举打穿数据库。
4. Vue3前端页面的搭建逻辑与联调细节
4.1 Vite初始化与项目结构规划
Vue3端我用Vite搭建,初始化命令很简单:
bash复制npm create vite@latest smart-life-web -- --template vue
创建完成后安装核心依赖:
bash复制npm install vue-router@4 pinia axios element-plus
项目目录结构我习惯按模块拆:
code复制src/
api/ # 每个模块的接口请求定义
assets/ # 静态资源
components/ # 公共组件
router/ # 路由配置
stores/ # Pinia状态管理
views/
home/ # 首页
product/ # 商品列表与详情
cart/ # 购物车
order/ # 订单确认与支付
user/ # 个人中心
admin/ # 管理后台
这种按模块拆目录的方式,比按文件类型拆(比如所有的.vue文件放一个文件夹)要清晰得多,因为你在改订单功能时,只需要关注api/order.js、views/order/、stores/order.js三个位置,不用在多个目录里来回跳。
4.2 Pinia + Vue Router的配合要点
Vue3状态管理我用Pinia替代Vuex。Pinia的API更简洁,去掉了mutations,直接在actions里同步或异步修改state。在电商系统里,购物车和用户登录态是最适合放到全局状态管理的,因为它们在多个页面里共享。
以用户登录态为例,我在stores/user.js里维护token和userInfo两个字段:
javascript复制export const useUserStore = defineStore('user', () => {
const token = ref(localStorage.getItem('token') || '')
const userInfo = ref({})
function setLoginData(data) {
token.value = data.token
userInfo.value = data.userInfo
localStorage.setItem('token', data.token)
}
function logout() {
token.value = ''
userInfo.value = {}
localStorage.removeItem('token')
}
return { token, userInfo, setLoginData, logout }
})
注意这里我用localStorage持久化了token,用户刷新页面后依然保持登录状态。路由守卫在router/index.js里配合Pinia使用:需要登录的页面通过meta.requiresAuth标记,路由跳转时判断如果没有token就跳转到登录页。
商品列表页的筛选条件联动——分类、价格区间、排序方式三个状态变化时都请求接口,我把它封装成一个useProductFilter组合函数,内部用ref管理状态,通过watch监听变化后触发重新加载列表,页面组件里只需要一行调用就能拿到所有筛选状态和方法,这也是Composition API让复用性提升的最好例证。
4.3 Axios封装与请求拦截处理
整个项目的API请求我统一封装在utils/request.js中。封装的核心目的有两个:统一携带token和统一处理错误响应。在请求拦截器里:
javascript复制service.interceptors.request.use(config => {
const userStore = useUserStore()
if (userStore.token) {
config.headers.Authorization = `Bearer ${userStore.token}`
}
return config
})
在响应拦截器里,我做了几层处理:HTTP状态码200且业务码为200时直接返回response.data.data,让页面代码不用再拆一层;业务码为401时说明token失效,自动清除登录态并跳转到登录页;网络错误或者业务码非200时弹出统一错误提示。这样各个页面调用接口时只需关心成功的数据,错误处理全部收敛到拦截器里。
4.4 前后端联调时的跨域问题
本地前端地址是http://localhost:5173,后端接口是http://localhost:8080,端口不同就存在跨域。我的做法是在SpringBoot里加一个CORS配置类,允许本地开发域名访问。
java复制@Configuration
public class CorsConfig implements WebMvcConfigurer {
@Override
public void addCorsMappings(CorsRegistry registry) {
registry.addMapping("/api/**")
.allowedOriginPatterns("*")
.allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS")
.allowedHeaders("*")
.allowCredentials(true)
.maxAge(3600);
}
}
重点说一下maxAge(3600),这个参数请你务必配上。浏览器在发起非简单请求(比如带Authorization头的请求)前,会先发一个OPTIONS预检请求,服务端如果没有正确响应,真实请求就不会发出去。maxAge的作用是告诉浏览器预检结果可以缓存一小时,避免每次请求都先来一次OPTIONS,能明显减少联调阶段的"请求莫名其妙被拦截"问题。
生产环境我建议把CORS配置关掉,改用Nginx反向代理,让前端静态资源和后端接口在同一个域名下,从根上规避跨域。
5. 部署过程中最常见的坑与排查思路
5.1 后端Jar包与前端静态资源的部署方案
后端打部署包的步骤很简单:
bash复制mvn clean package -DskipTests
生成的smart-life-server.jar放到服务器上直接运行:
bash复制java -jar smart-life-server.jar --spring.profiles.active=prod
我的项目里有application-dev.yml和application-prod.yml两套配置,区别在于数据库连接地址、Redis地址和日志级别。生产环境一定要用独立的配置,不要带着本地开发数据库连接信息上生产,这不是给自己省事,是给公司的服务器安全埋雷。
前端打包:
bash复制npm run build
生成在dist/目录下面的文件,放到Nginx配置的root路径。Nginx里最核心的配置有两块:一是将/api/请求反向代理到后端服务:
nginx复制location /api/ {
proxy_pass http://127.0.0.1:8080;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}
二是解决Vue Router的history模式刷新404问题:
nginx复制location / {
root /opt/smart-life-web;
index index.html;
try_files $uri $uri/ /index.html;
}
try_files这一行非常关键。Vue Router的history模式,路由地址是真实路径,但刷新时如果Nginx只按文件路径查找,找不到/product/123这个文件就会返回404。加上try_files $uri $uri/ /index.html之后,所有匹配不到文件的地址都回退到根index.html,由前端路由接管展示。
5.2 后端服务起不来的几个经典原因
如果你执行java -jar之后日志刷了很多但马上退出,大概率逃不出下面三类问题。
第一是端口被占用。SpringBoot默认8080端口可能被其他服务占用了,报错关键词是Port 8080 was already in use。排查方法是netstat -tlnp | grep 8080,找到占用进程处理掉,或者在配置文件里换一个端口。
第二是MySQL连接失败。连接不上数据库时日志会报Communications link failure,这时优先检查三件事:数据库服务有没有启动、url里的IP和端口对不对、密码是否匹配。另外如果你的MySQL部署在云服务器或者Docker容器里,还要确认端口安全组有没有放行,我用telnet ip 3306这条命令能快速判断。
第三是Redis连接失败。如果开启了Redis缓存但服务没启动,SpringBoot默认健康检查会把应用标记为DOWN,接口会返回RedisConnectionFailureException。本地调试没用到Redis功能时可以直接把相关配置注释掉,不要让它卡住应用启动。
5.3 前端部署后页面白屏的排查链路
前端部署到Nginx后出现白屏,我的排查顺序是:先看浏览器控制台有没有报错,再看Network面板的静态资源请求状态码,最后确认assets目录路径。
Vite默认打包出来的静态资源路径是以/开头的绝对路径,比如/assets/index.css,如果你的前端部署在域名根路径下没问题,但如果部署在子路径(比如http://ip:8080/shop/),这些绝对路径就全部404了。解决方法是给Vite配置base: './':
javascript复制export default defineConfig({
base: './',
plugins: [vue()]
})
改成相对路径后,打包出来的资源引用都带着./前缀,放在任何子路径下都能正确加载。我用http://ip/admin/方式访问项目时,就靠这一行配置避开了白屏问题。
6. 进阶优化方向与血泪教训总结
6.1 Redis在这套系统里的更多玩法
除了商品缓存,Redis在商城里还有两个高频使用场景。第一个是验证码存储,登录注册时的短信验证码或图形验证码,我存Redis并设置5分钟过期,Redis自带的过期机制省去了手动清理脏数据的工作。第二个是接口防刷,最简单的是用INCR+EXPIRE做计数器:用户每次请求某个接口就INCR,第一次请求时设置过期时间,一分钟内超过限制次数就拒绝服务。这个机制在秒杀场景中尤其重要,能挡住大部分脚本刷单。
Redis分布式锁在高并发下单中也有用武之地,但对这个体量的商城项目,我前面提的数据库原子扣减方案已经满足需求。分布式锁引入后还要处理锁过期、误删锁、重入等一系列问题,复杂度远大于收益。理解"项目需要什么技术,而不是学了什么技术就往上塞"是我这几年带项目最深的感悟。
6.2 安全加固不能只停留在登录注册
商城系统涉及资金交易,安全是底线。我总结自己在这套系统里的防御措施,至少包括以下几层:
SQL注入层面,所有参数拼SQL的地方必须用#{}占位符,杜绝${}直接拼接。同时开启MyBatis的日志插件在测试环境打印SQL,方便人肉检查有没有异常拼接。
XSS攻击层面,用户在商品评价、收货人姓名这些可以提交文本的接口,我加了一层自定义的XssFilter,对请求参数做HTML特殊字符转义。<script>标签一旦被存到数据库,所有用户打开商品详情页都会执行恶意脚本,后果非常严重。
接口越权层面,这是很多新项目容易忽略的。用户A访问自己的订单列表没问题,但把URL里的订单ID改成用户B的订单ID,能不能查到B的订单?我在Service层查询订单时强制拼接user_id条件,确保只能查自己的订单。永远不要相信前端传来的任何ID,所有资源访问都要做归属校验。
6.3 时间与精力的最优投入方式
最后以自己的经验给正在学这套技术栈的朋友一个建议:不要一上来就沉迷于"完整跑通"的成就感。我第一次搭这类项目时,一天之内把前后端都跑通了,但过了一周遇到问题完全不知道从哪查起,因为我只是在运行,没有理解各层之间到底怎么协作。
我建议的投入方式是:先用两天把SpringBoot+MyBatis的CRUD吃透,再花两天把Vue3+Vite的前端基础写好,然后用一个周末做一个"商品列表+登录"的最小闭环,如果你能独立走通从数据库建表、后端写接口、前端发请求、页面渲染的全流程,接下来一次性补齐商城系统的其他模块就会顺畅很多。数据库表设计至少花半天认真思考字段之间的关系,缓存和并发控制每一块都动手写测试用例验证,遇到Bug先看日志,再断点排查,不要用System.out暴力打印。这套源码里完整的注释和SQL文件都是我实际调试通过的,你拿着一行行对照着看,会比自己从零摸索节省大量时间。
提示:如果你在按这套流程走的时候遇到依赖版本冲突或者接口返回不符合预期,多半是环境问题而不是代码逻辑问题,先对照
pom.xml里的SpringBoot 2.7.14版本、Vite 4.x版本和Node 18+的版本要求逐一检查。
我个人的体会是,商城系统既是面试中常被问到的经典项目类型,也是真正能体现一个开发者对数据一致性、缓存策略和安全防护理解深度的试金石。它不像一些管理系统那样只是增删改查的罗列,而是处处有业务规则、有时间线、有状态流转。把这一套从数据库到前端页面完整做完,你对"前后端分离项目"这几个字的理解,会从一个空泛的概念变成一条清晰的链路。
