资讯动态

SpringBoot+Vue双角色图书商城项目实战:权限、状态机与并发设计

发布时间:2026/10/25 2:04:28 来源:尧图企业网站定制
如果你正准备用 SpringBoot 和 Vue 做一套完整的前后端分离项目又不想烂大街地复刻一个后台管理系统那我强烈建议你试试「双角色图书商城」这个方向。所谓双角色就是商城同时面向普通用户和后台管理员两个人各有一套界面和操作权限从账号登录、页面展示到接口访问全程都要区分身份。这个项目最妙的地方在于领域模型简单到所有人都能理解——买书、卖书、管库存——但它又完整覆盖了权限设计、状态机流转、接口安全这几个真项目里最值钱的硬骨头。适合谁呢已经学完 SpringBoot 基础、Vue 基础但缺一个能写进简历的完整项目的同学也包括想从 CRUD 阶段往上走一层、想触碰真实业务规则的开发者。下文是我带着 A 同学完整做这套系统的记录从表结构设计讲到路由守卫从权限校验讲到部署上线其中不少坑是我自己在几个模拟项目里反复踩过之后才总结出来的。你照着做大概率能省掉两三个通宵。1. 双角色图书商城为什么它是最该做的全栈练手项目1.1 一个项目把全栈要点串起来了市面上能练手的项目很多但大多数要么偏前端要么偏后端。我自己带人做项目有一个判断标准这个项目能不能在一次开发里同时讲清楚前端如何控制显示、后端如何控制权限、数据如何流转。图书商城恰好每一样都沾而且每一样都不过度复杂。它的完整闭环是这样的用户注册登录后可以浏览图书、加购物车、下单、查看订单管理员登录后可以维护图书信息、管理库存、处理订单状态、查看用户列表。也就是说同一个系统前端要渲染两套页面后端要对同一批接口做两种授权策略。光这一条就能把 Vue Router、状态管理、axios 封装、Spring 拦截器、JWT 讲个遍。我个人觉得这类一体两角色的项目比单纯的博客系统、简单的管理后台都更有含金量。因为它逼着你认真思考一个问题同一份数据不同身份的人看到的世界应该不一样。想清楚了这个问题你对前后端分离的理解才算真正成型。1.2 双角色比单角色多出来的三个硬骨头单角色的后台管理系统最难的也就是 CRUD。但一旦加上用户端这个角色事情就不一样了我总结为三个难点第一路由级别的访问控制。用户和管理员登录后看到的菜单完全不同用户直接敲管理员页面的地址怎么办这不能光靠前端藏链接还得靠后端接口拦截。第二同一套数据模型下的视角切换。比如图书表用户在页面看到的是价格、封面、简介管理员看到的是库存、上下架状态、采购建议。你不可能给同一个查询接口做两种返回所以要么拆接口要么在 VO 层做角色化输出。很多新手在这里容易把接口写成一团乱麻。第三订单和库存的联动。用户下单要扣库存管理员可能同时在上架调整库存两条操作撞在一起就会超卖。这个问题不解决项目做完也只是看起来能跑。这三块任何一个单独拿出来都能写一篇踩坑记录而图书商城把它们全揉进了一个项目里。这也是为什么我推荐新手优先选它而不是选纯后台管理系统。1.3 为什么偏偏是图书不是别的品类我推荐图书不推荐服装、数码或者生鲜图的是领域简单。图书的属性非常稳定书名、作者、出版社、ISBN、定价、库存、封面。没有颜色尺码组合、没有重量运费计算、没有保质期做表结构的时候几乎不用纠结。稳定领域模型的好处是你可以把 80% 的脑力留给权限、状态、并发这些真正的难点而不是浪费在一个商品到底有几个 SKU上。当然图书行业也有它自己的小讲究。比如 ISBN 唯一性、上下架状态和库存量的区分、图书分类的多级关系。这几个点加进来刚刚好既能体现业务思考又不至于劝退新手。2. 开工前的架构设计角色模型、数据流向与目录约定2.1 用户-角色-权限的基础模型双角色听起来是两个角色但如果你真的在用户表里加一个 role 字段就完事后面一定会后悔。正确做法是参照 RBAC基于角色的访问控制的思路拆出用户、角色、权限三层哪怕现在角色只有两个。原因很简单以后想加一个运营角色或者想给某个接口单独开权限三层模型只需要加数据不需要改代码。我的模拟项目用了四张核心表表名作用关键字段user用户基本信息id, username, password, nickname, created_atrole角色定义id, role_code, role_nameuser_role用户与角色关联id, user_id, role_idpermission权限点定义可选id, perm_code, perm_name在只有两个角色的时候可以先不做 permission 表和菜单表直接在接口层用角色码判断。但 user_role 关联表一定要留这是为了避免用户表里写死角色这种后期没法改的设计。我见过有人图省事直接在 user 表加了 role 字段结果需求一变要支持一个用户同时拥有两个角色整个表结构都要推倒重来。2.2 一次完整登录请求的令牌流转前后端分离项目的登录流程核心是无状态。服务端不保存用户登录状态用户登录成功后服务端签发一个带签名和过期时间的 JWT前端存起来每次请求放进请求头服务端每次校验签名和过期时间。步骤拆开是前端把用户名密码发给 /api/auth/login。后端校验密码查用户角色生成 JWTpayload 里带上 userId 和 role。前端把 token 存到 localStorage同时把用户名、角色信息也存一份。之后所有需要登录的请求axios 拦截器自动在请求头加 Authorization: Bearer 。后端拦截器对每个请求校验 token解析出角色决定是否放行。这里我要强调一个点JWT 的 payload 是 base64 编码的不是加密的。谁都能解码看到里面的内容所以你千万不能把密码放进去也不能在里面放敏感信息。我见过有人把手机号直接放 payload然后被别人抓包解出来属于典型的安全意识缺失。正确的做法是 payload 只放 userId、role 这种非敏感标识其他信息需要时再查库。2.3 前后端目录结构怎么划双角色项目最怕的就是目录混乱写着写着用户端和管理员端的代码混在一起。我的习惯是后端按业务模块分包前端按角色视图分目录。后端建议结构com.example.bookmall ├── controller # 接口层 │ ├── user # 用户端接口 │ └── admin # 管理员接口 ├── service # 业务逻辑 ├── mapper # 数据访问 ├── entity # 实体 ├── vo # 视图对象如 BookVO、OrderVO ├── common # 统一返回、异常处理 └── config # 拦截器、跨域等配置前端建议结构src ├── views │ ├── user # 用户端页面图书列表、购物车、订单 │ └── admin # 管理端页面图书管理、订单管理、用户管理 ├── router # 路由含角色守卫 ├── api # 接口封装按模块拆文件 └── store # 登录态、角色信息这样划分的最大好处是拿到一个需求开发能立刻判断它该落在哪个目录、该动哪些文件不至于在几万行代码里迷路。我见过不少项目controller 里用户端和管理员端的接口全塞在同一个类里光看代码根本分不清哪个接口是给谁用的这种项目维护起来就是灾难。3. 后端落地的关键代码权限校验与订单状态机3.1 建表脚本直接可用的版本图书商城最核心的业务表四张就够图书表、购物车表、订单表、订单明细表。我贴一下我实际用的精简版建表脚本字段命名统一用下划线方便前端直接用驼峰映射。CREATE TABLE t_book ( id BIGINT AUTO_INCREMENT PRIMARY KEY, isbn VARCHAR(20) NOT NULL UNIQUE, title VARCHAR(100) NOT NULL, author VARCHAR(50), publisher VARCHAR(100), price DECIMAL(10,2) NOT NULL, stock INT NOT NULL DEFAULT 0, status TINYINT NOT NULL DEFAULT 1 COMMENT 1-上架 0-下架, created_at DATETIME DEFAULT CURRENT_TIMESTAMP ); CREATE TABLE t_cart ( id BIGINT AUTO_INCREMENT PRIMARY KEY, user_id BIGINT NOT NULL, book_id BIGINT NOT NULL, quantity INT NOT NULL DEFAULT 1, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uk_user_book (user_id, book_id) ); CREATE TABLE t_order ( id BIGINT AUTO_INCREMENT PRIMARY KEY, order_no VARCHAR(32) NOT NULL UNIQUE, user_id BIGINT NOT NULL, total_amount DECIMAL(10,2) NOT NULL, status TINYINT NOT NULL DEFAULT 0 COMMENT 0-待支付 1-已支付 2-已发货 3-已完成 4-已取消, address VARCHAR(200), version INT NOT NULL DEFAULT 0, created_at DATETIME DEFAULT CURRENT_TIMESTAMP ); CREATE TABLE t_order_item ( id BIGINT AUTO_INCREMENT PRIMARY KEY, order_id BIGINT NOT NULL, book_id BIGINT NOT NULL, book_title VARCHAR(100), price DECIMAL(10,2) NOT NULL, quantity INT NOT NULL );注意订单表里的 version 字段这是我故意留下的并发控制字段后面第五节专门讲为什么需要它。购物车表里的唯一键 uk_user_book 也值得留意它保证一个用户对同一本书只会有一条购物车记录重复加购只是更新数量不会插出两条脏数据。3.2 基于注解的角色校验实现双角色系统里最核心的是接口授权。我用的是 Spring 拦截器加自定义注解的方案不用引入全套 Spring Security先让大家把权限模型看懂后面再换框架也不亏。先定义一个注解Target(ElementType.METHOD) Retention(RetentionPolicy.RUNTIME) public interface RequireRole { String[] value(); }然后实现一个 HandlerInterceptorpublic class RoleInterceptor implements HandlerInterceptor { Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) { if (!(handler instanceof HandlerMethod)) return true; HandlerMethod handlerMethod (HandlerMethod) handler; RequireRole requireRole handlerMethod.getMethodAnnotation(RequireRole.class); if (requireRole null) return true; String token request.getHeader(Authorization); // 解析 token拿到角色码这里略去 JWT 解析细节 String userRole JwtUtil.getRoleFromToken(token); for (String role : requireRole.value()) { if (role.equals(userRole)) return true; } response.setStatus(403); return false; } }用的时候直接在接口方法上标注PostMapping(/admin/book) RequireRole(ADMIN) public Result addBook(RequestBody Book book) { bookService.addBook(book); return Result.success(); }这套方案十几行代码就把角色校验立起来了比引一整套安全框架再配一堆 XML 要清晰得多。当然如果项目要上生产建议还是换成 Spring Security 或 Sa-Token拦截器方案适合理解和教学。实际写的时候还要注意把拦截器注册到 WebMvcConfigurer并且要放行登录、注册、图书列表这些公开接口不然用户还没登录就被拦在门外了。3.3 订单创建中的状态机与库存扣减订单是图书商城业务复杂度最高的地方。我设计的订单状态是待支付 - 已支付 - 已发货 - 已完成中间任意状态都可以到已取消。为什么必须用状态机而不能随便改状态因为已支付这个状态牵动着库存、用户余额、优惠券等多个数据如果管理员能把用户订单直接改成已完成这个系统的账就对不上了。订单状态只能按照既定规则流转这是业务规则不是技术上的习惯问题。创建订单的核心代码如下Transactional public Order createOrder(Long userId, ListCartItem items, String address) { BigDecimal total BigDecimal.ZERO; Order order new Order(); order.setOrderNo(generateOrderNo()); order.setUserId(userId); order.setStatus(0); order.setAddress(address); order.setVersion(0); // 先创建订单壳拿到订单号 orderMapper.insert(order); for (CartItem item : items) { Book book bookMapper.selectById(item.getBookId()); if (book.getStock() item.getQuantity()) { throw new BizException(《 book.getTitle() 》库存不足); } // 扣库存 int rows bookMapper.decreaseStock(item.getBookId(), item.getQuantity()); if (rows 0) { throw new BizException(《 book.getTitle() 》库存不足); } total total.add(book.getPrice().multiply(BigDecimal.valueOf(item.getQuantity()))); orderItemMapper.insert(new OrderItem(order.getId(), book.getId(), book.getTitle(), book.getPrice(), item.getQuantity())); cartMapper.delete(item.getId()); } order.setTotalAmount(total); orderMapper.updateTotal(order); return order; }这里最关键的细节是decreaseStock不能用先查再改必须用一条带条件的 SQLUPDATE t_book SET stock stock - #{num} WHERE id #{id} AND stock #{num}。这样库存扣减就是原子操作两个用户同时下单也不会把库存扣成负数。这个点我在面试里见过很多人答不上来属于实战和纯理论的分水岭。另外 Transactional 必须加上订单明细、库存、购物车删除任何一步失败整单回滚否则会出现订单建了但明细没写的脏数据。3.4 统一响应体与全局异常前后端分离项目最忌讳每个接口返回格式都不一样。前端每次都要判断这次是直接 data 还是 {code, data} 还是 string写不了几天就会暴躁。我的统一响应类是public class Result { private int code; private String message; private Object data; public static Result success(Object data) { Result r new Result(); r.code 200; r.message ok; r.data data; return r; } public static Result error(int code, String message) { Result r new Result(); r.code code; r.message message; return r; } }配合全局异常处理用 RestControllerAdvice 捕获业务异常前端拿到的一切响应都是同一个结构。这样 axios 响应拦截器里可以统一处理业务错误不用每个页面写一遍 try-catch。实际开发我省掉的重复代码至少能按千行算。全局异常里我把参数校验异常、业务异常、系统异常分开处理日志也能打得更有针对性。4. 前端双角色的关键实现路由守卫与接口拦截4.1 登录态和角色信息的存放Vue 3 项目里我习惯用 Pinia 管理全局状态但登录态里有个很现实的问题一刷新页面Pinia 里的数据就没了。所以 token 和角色信息必须持久化。我的做法是token 存 localStorage这是必须的要带进请求头用户名、角色码同步存 localStorage路由守卫要用Pinia 里放一份内存副本组件里读起来方便刷新后做一个初始化动作从 localStorage 恢复状态。不要用 sessionStorage 存 token因为用户可能关掉标签页再打开如果 token 还在有效期内没必要让人重新登录。另外token 不要放进 localStorage 的 key 里带上业务含义就统一叫 token。4.2 路由守卫里的角色判断双角色前端最核心的一环是路由守卫。我的规则很简单每个路由的 meta 里声明需要哪些角色守卫里判断当前登录角色是否匹配。const routes [ { path: /admin, component: AdminLayout, meta: { roles: [ADMIN] }, children: [] }, { path: /shop, component: ShopLayout, meta: { roles: [USER] }, children: [] }, { path: /login, component: Login } ] router.beforeEach((to, from, next) { const token localStorage.getItem(token) if (to.meta.roles !token) { next(/login) return } if (to.meta.roles) { const role localStorage.getItem(role) if (!to.meta.roles.includes(role)) { next(/403) return } } next() })这套代码看起来不难但有两个容易忽略的细节。第一个是登录页本身必须放行否则人会卡在死循环里没 token 跳登录页又要登录页判断 token来回重定向。第二个是 /403 页面要独立成一个路由别把无权限用户直接弹回首页那样用户根本不知道发生了什么直接弹 403 页面至少能明确传达你没权限。另外菜单也应该根据角色动态渲染。我的做法是在布局组件里读取角色再通过 v-if 控制菜单项。前端隐藏菜单是体验优化不是安全措施真正的防线还是后端接口校验——这一点我反复跟人强调。4.3 axios 拦截器处理 401 和 403axios 拦截器是前端双角色系统的交通警察。我封装了一个统一的 request 实例const service axios.create({ baseURL: /api, timeout: 10000 }) service.interceptors.request.use(config { const token localStorage.getItem(token) if (token) { config.headers.Authorization Bearer token } return config }) service.interceptors.response.use( response { const res response.data if (res.code 200) return res.data if (res.code 401) { localStorage.clear() router.push(/login) } if (res.code 403) { router.push(/403) } return Promise.reject(new Error(res.message)) }, error { if (error.response error.response.status 401) { localStorage.clear() router.push(/login) } return Promise.reject(error) } )有一个新手很容易踩的坑后端返回的业务错误比如库存不足通常也是 HTTP 200只是 body 里的 code 不是 200。如果你在响应拦截器里只处理 HTTP 状态码业务错误就会一路抛到页面组件里弹窗、提示全都乱了。所以上面代码先判断res.code这才是前后端约定统一响应体的价值所在。5. 最容易踩坑的三个地方权限漏洞、令牌过期、并发下单5.1 前端隐藏按钮不等于后端安全实话说我在模拟项目里见过太多只做了前端权限的系统。菜单按角色隐藏了按钮按角色隐藏了感觉挺安全结果用 Postman 直接调接口照样能删别人的订单。原因特别简单前端的 v-if 只是不让你点按钮它决定不了服务器收到请求时认不认你。所以双角色系统里后端每个敏感接口都必须做角色校验。我在开发时给自己定了条规矩数据库的增删改接口默认全部加上 RequireRole只有明确允许的才放行。这里分享一个自查方法项目做完后把每个接口在 Swagger 或接口文档里过一遍凡是写了仅管理员的去代码里确认有没有对应的注解。这一步花不了半小时但能堵住绝大多数权限漏洞。5.2 多请求同时过期时别连环跳登录这是我的一个真实经历。用户在一个页面里同时发了三个请求token 刚好过期三个请求全部返回 401。前端拦截器每处理一个 401 就 clear 一次 localStorage、跳一次 /login。结果就是你看到用户被踢到登录页还没来得及输入密码又被莫名弹来弹去。解决思路是给重新登录加一个锁。我用了一个非常简单的标记变量let isRedirecting false function handleUnauthorized() { if (isRedirecting) return isRedirecting true localStorage.clear() router.push(/login) setTimeout(() { isRedirecting false }, 1000) }这样即使同一时间有十个请求报 401也只会触发一次跳转。如果你用的是 axios 的响应拦截器务必把所有 401 处理逻辑收敛到同一个函数里不要在多个页面里各写一份。更进一步的方案是维护一个等待队列token 过期时只刷新一次 token其余请求排队重放不过这个方案复杂度偏高新手阶段用锁标记就足够了。5.3 商品库存和订单状态的并发处理并发问题听起来高大上但在图书商城里确实是会真实发生的。两个用户同时买同一本书库存只剩一本如果代码是先查库存再减库存两次查询都查到 1然后都各自减 1结果库存变成 0卖了 2 本。这就是超卖。我前面在 3.3 节里已经写了减库存的 SQL用条件更新保证原子性。这里补一个订单状态更新的坑管理员在后台把订单从已支付改成已发货同一时刻用户可能点了取消订单两边都把订单改成了自己期望的状态最后结果取决于谁后执行业务逻辑就乱了。最简单的兜底方案是加乐观锁。在 t_order 表留了 version 字段更新订单状态时带上条件UPDATE t_order SET status #{newStatus}, version version 1 WHERE id #{id} AND version #{version}如果影响行数为 0说明订单状态已经被别人改过当前操作需要重新读取再决策。用乐观锁的代价很小但能把状态被悄悄覆盖这种问题挡在门外。这套项目的所有写操作我都建议过一遍凡是有读-改-写三步的都值得考虑乐观锁。6. 从本地联调到上线部署构建细节与后续扩展6.1 本地联调最容易出问题的是跨域前后端分离本地联调99% 的第一个报错是跨域。Vue 开发服务器跑在 5173SpringBoot 跑在 8080前端直接请求后端接口会被浏览器拦下来。我的处理方式分两层。开发环境用 Vite 代理在 vite.config.js 里配置server: { proxy: { /api: { target: http://localhost:8080, changeOrigin: true } } }生产环境用 Nginx 反代把 /api 转发到后端服务。这两种方式其实是一个思路让浏览器永远只请求同源的 /api 路径跨域由服务器到服务器转发浏览器层面根本不存在跨域。这里提醒一句别一上来就在后端开allowedOrigin(*)加 Allow-Credentials 混合用那样要么拿不到 Cookie要么被安全扫描报高风险。能用代理解决的问题就不要动跨域配置。6.2 Nginx 部署与动静分离项目上线我用的方案很常规前端构建出 dist 目录后端打成 jar 包Nginx 做入口。server { listen 80; server_name localhost; root /usr/share/nginx/html; index 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; } location / { try_files $uri $uri/ /index.html; } }注意try_files $uri $uri/ /index.html这一行。Vue 是单页应用用户直接访问 /admin 这种前端路由时Nginx 找不到对应的物理文件如果不回退到 index.html页面就 404 了。这个坑我见过太多次部署完点首页没问题一刷新子页面就白屏查半天发现是路由刷新 404。6.3 后续还能怎么扩展这套双角色图书商城做完它不是终点而是很好的扩展底座。我列几条性价比最高的扩展方向。第一把 JWT 换成 Spring Security 加认证授权让权限模型更完整顺便把密码加密方案统一成 BCrypt这个在简历上很加分。第二给图书搜索引入全文检索解决按书名模糊搜索效率低的问题同时练习搜索引擎和倒排索引的思路。第三给订单模块模拟对接支付回调用一张支付记录表记录回调状态理解幂等设计——同一个支付回调发两次系统不能重复加余额或重复发货。第四把部署改成 Docker Compose一次性拉起前后端和数据库对后续可能的多实例部署也更有感觉。我个人的体会是这套项目做完之后最大的收获不是代码量而是建立了一条完整的思考链条从一个需求出发先想数据怎么设计再想接口怎么分接着想前端怎么切视图最后想哪里会有并发和权限问题。带着这条链条再去写任何系统都不会只盯着一个类一个函数死磕了。最后再送一个小技巧开发时把每个接口的耗时打印出来给自己定一个超过 500 毫秒就要查原因的规矩。双角色系统里最容易出现慢查询的就是那些联表查询带上权限过滤的复杂 SQL。你把这个习惯保持住项目越做会越顺手。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑