ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

Spring Boot二次元商城实战:从数据库还原到安全上线全流程

Spring Boot二次元商城实战:从数据库还原到安全上线全流程 简介这是一套基于Spring Boot的二次元商品购物商城系统源码适合Java学习者、毕业设计开发者以及想了解电商系统整体架构的初级工程师。系统实现商品分类浏览、关键词搜索、购物车与订单管理、在线支付对接、用户注册登录、收货地址维护、商品评价及后台运营管理等功能覆盖商城业务的主要链路。资源共717个文件压缩包约45.88MB其中java代码约63个用于后端接口与业务逻辑前端包含374个jpg、153个png图片素材以及js、css等样式交互文件另有sql脚本和mwb数据库模型设计可快速导入运行并理解数据表关系同时附带说明文档、配置文件和前端打包资源便于直接部署调试。目前已有35647人学习下载整体结构层次清晰前端采用Vue打包后的静态资源后端为Spring Boot工程配套说明文档与配置示例便于对照源码进行二次开发或论文撰写。1. 解析二次元商品商城数据库备份和一堆 CSS 背后是 Spring Boot 的主场做 Java 的人拿到一个新项目第一件事不是看代码而是看“它留下了什么痕迹”。如果手头只有sb_shop2021.mwb.bak、style.css、chunk-vendors.3c269362.css这类文件基本能猜到背后是一套 MySQL 建模 Spring Boot 后端 打包好的前端资源。很多刚毕业的同学容易被这些文件吓住以为是完整的源码包结果发现连 pom.xml 都没有反过来有经验的开发会马上想到数据库备份可以还原前端产物可以重新对接核心业务逻辑其实就在接口链路里。这一篇我把这个二次元商品购物商城的数据库还原、后台接口、前端集成、下单链路和上线安全完整走一遍你拿去能直接跑通。2. 数据库骨架sb_shop2021.mwb.bak 恢复与 Spring Boot 自动建表2.1 用 MySQL Workbench 从 .mwb.bak 导出建表 SQLsb_shop2021.mwb.bak并不是一个 SQL 备份而是 MySQL Workbench 的模型备份文件。直接改后缀名再用 Workbench 打开是最稳妥的恢复方式打开后能看到一套 EER 图。要注意这个文件可能是在早期 MySQL Workbench 版本里保存的新版打开时如果提示修复模型选“Create a copy”避免破坏原文件。# 复制备份为可识别格式注意保留原文件 cp sb_shop2021.mwb.bak sb_shop2021.mwb然后用 Workbench 打开sb_shop2021.mwb按File - Export - Forward Engineer SQL CREATE Script导出sb_shop.sql。之所以推荐 Forward Engineer 而不是直接读.bak里的文本是因为模型文件里的外键、索引、枚举会被正确转换成 DDL手工写容易漏。拿到 DDL 后首先创建数据库。注意字符集选utf8mb4二次元商品标题经常出现日文、表情符号和特殊符号utf8只支持基本多语言平面遇到\u1F600这类字符会直接报错或变成乱码。# 创建 utf8mb4 数据库避免商品标题乱码 mysql -uroot -p -e CREATE DATABASE sb_shop DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci; # 导入表结构 mysql -uroot -p sb_shop sb_shop.sql之前的-e部分创建库第二个命令导入表结构。执行完以后用SHOW TABLES;验证表是否完整。我这个项目里核心表如下表名说明关键字段t_user用户表id, username, password_hash, phone, pointst_category商品分类id, parent_id, name, sort, icont_product商品主表id, category_id, title, subtitle, cover_url, price, stock, statust_product_sku规格库存表id, product_id, sku_name, price, stockt_cart购物车表id, user_id, sku_id, quantityt_order订单主表id, order_no, user_id, total_amount, status, pay_time, logistics_not_order_item订单明细表id, order_id, product_id, sku_id, quantity, pricet_address收货地址表id, user_id, receiver, phone, province, city, district, detail, is_defaultt_payment支付记录表id, order_no, channel, transaction_id, amount, status这个表结构最大的特点是“商品主表 SKU 表”分离。手办、景品、扭蛋如果只是商品维度去库存完全够用但盲盒类商品经常有不同规格比如“A款/B款/C款”库存一定要落在 SKU 表而不是商品主表否则一个连接误改会把整批库存打乱。2.2 新建 Spring Boot 工程并配置数据源表准备好以后用 Spring Boot 初始化一个标准工程。spring-boot-starter-web、mybatis-plus-boot-starter、mysql-connector-j、lombok是我在这个项目里的固定组合。application.yml 中数据源配置建议单独拆到环境配置文件里# application-dev.yml server: port: 8080 spring: datasource: driver-class-name: com.mysql.cj.jdbc.Driver url: jdbc:mysql://localhost:3306/sb_shop?useUnicodetruecharacterEncodingutf8useSSLfalseserverTimezoneAsia/ShanghaiallowPublicKeyRetrievaltrue username: root password: 你的密码 redis: host: localhost port: 6379 timeout: 3000ms mybatis-plus: mapper-locations: classpath*:mapper/**/*.xml type-aliases-package: com.example.sbshop.entity configuration: map-underscore-to-camel-case: trueURL 里的serverTimezoneAsia/Shanghai是 MySQL 8 必须处理的时区参数不加会在建立连接时出现 8 小时误差。allowPublicKeyRetrievaltrue解决 MySQL 8 在 SSL 关闭时 RSA 公钥获取失败的问题本地开发比较常用生产环境建议用 SSL 或去掉。2.3 表不存在时自动建表而不是依赖 Workbench很多从sb_shop2021.mwb.bak恢复出来的项目会带着大量种子数据。真正放到别人机器上跑时最好是让 Spring Boot 在启动阶段自动把表建出来避免每次交付都要手动导 SQL。常见做法是把 Workbench 导出的建表语句精简成schema.sql再开启 Spring SQL Initspring: sql: init: mode: always schema-locations: classpath:schema.sqlschema.sql里统一使用CREATE TABLE IF NOT EXISTS。这句看起来多余实际很有用项目升级时如果表已经存在不会因为重复建表导致启动失败。另外Spring Boot 2.5 以后不再用spring.datasource.initialization-mode而是spring.sql.init.mode网上很多旧博客写的配置在 3.x 里已经失效注意版本。CREATE TABLE IF NOT EXISTS t_product ( id BIGINT PRIMARY KEY AUTO_INCREMENT, category_id BIGINT NOT NULL, title VARCHAR(200) NOT NULL, subtitle VARCHAR(500), cover_url VARCHAR(500), price DECIMAL(10,2) NOT NULL, stock INT NOT NULL DEFAULT 0, status VARCHAR(10) NOT NULL DEFAULT OFF, create_time DATETIME DEFAULT CURRENT_TIMESTAMP, update_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;这里用DECIMAL(10,2)而不是DOUBLE存金额是为了避免浮点误差status用ON/OFF字符串表示上架下架比0/1在接口层更可读。建表逻辑做完后MyBatis-Plus 的实体类再通过TableName和它对应起来后续 CRUD 就不需要再写原始的 JDBC 代码。3. 商品模块Controller、动态搜索和 Redis 缓存三层拆解3.1 实体类TableName 与 MyBatis-Plus 约定数据库表属于t_前缀实体类直接用Product两者通过TableName(t_product)建立映射。如果不加这个注解MyBatis-Plus 默认会去找product表字段映射也会变成create_time-createTime的驼峰规则这个开关其实已经在上一章的map-underscore-to-camel-case: true里打开了Data TableName(t_product) public class Product { TableId(type IdType.AUTO) private Long id; private Long categoryId; private String title; private BigDecimal price; private Integer stock; private String status; TableField(fill FieldFill.INSERT) private LocalDateTime createTime; }TableId(type IdType.AUTO)对应数据库自增主键。如果数据库主键是分布式 ID这里要换成ASSIGN_ID。TableField(fill FieldFill.INSERT)配合MetaObjectHandler可以在 insert 时自动写入创建时间我习惯在项目里自己实现一个 Handler避免每个实体 set 一遍时间。3.2 商品列表接口与动态条件查询商城首页需要同时支持关键词、分类、价格区间和分页接口参数要尽量宽松。Controller 层不要放业务代码只做参数接收和结果封装RestController RequestMapping(/api/product) public class ProductController { Autowired private ProductService productService; GetMapping(/list) public ResultPageResultProductVO list( RequestParam(defaultValue 1) Integer page, RequestParam(defaultValue 20) Integer size, RequestParam(required false) String keyword, RequestParam(required false) Long categoryId, RequestParam(required false) BigDecimal minPrice, RequestParam(required false) BigDecimal maxPrice) { return Result.ok(productService.search(page, size, keyword, categoryId, minPrice, maxPrice)); } }RequestParam(required false)表示这些筛选条件可以有也可以没有。前端传来的参数名就是keyword、categoryId这种小驼峰URL 结构自然是GET /api/product/list?keyword手办categoryId12minPrice100maxPrice500。参数类型必填说明pageInteger否页码默认 1sizeInteger否每页条数默认 20keywordString否商品标题关键字categoryIdLong否分类 IDminPriceBigDecimal否最低价格maxPriceBigDecimal否最高价格Service 层的实现核心是LambdaQueryWrapper它比写 XML 的if判断更简洁也能避免字符串拼接 SQL 带来的注入风险public PageResultProductVO search(Integer page, Integer size, String keyword, Long categoryId, BigDecimal minPrice, BigDecimal maxPrice) { PageProduct pageParam new Page(page, size); LambdaQueryWrapperProduct wrapper new LambdaQueryWrapper(); wrapper.eq(Product::getStatus, ON) .like(StringUtils.hasText(keyword), Product::getTitle, keyword) .eq(categoryId ! null, Product::getCategoryId, categoryId) .ge(minPrice ! null, Product::getPrice, minPrice) .le(maxPrice ! null, Product::getPrice, maxPrice) .orderByDesc(Product::getCreateTime); IPageProduct result productMapper.selectPage(pageParam, wrapper); return PageResult.from(result); }like(StringUtils.hasText(keyword), ...)第一个参数是布尔条件条件为 true 时才拼接该查询条件。ge和le分别是大于等于、小于等于。这里需要注意like会默认在关键字前后加%所以用户在页面上输入的手办、盲盒、景品都能命中商品标题但如果商品标题很短且需要精确匹配不要用 like改成eq走索引。3.3 把热点商品缓存到 Redis而不是把所有商品都塞进去商品列表接口通常会加 Redis 缓存。常见的误用是直接把整个 PageResult 缓存key 复杂、更新困难还会出现“下架了还能看到”的脏数据。我一般只对两种数据做缓存商品详情和热搜分类下的商品 ID 列表。Spring Cache 使用 Redis 时的配置spring: cache: type: redis redis: time-to-live: 30m key-prefix: shop:product: cache-null-values: falsetime-to-live统一设成 30 分钟避免缓存无限制增长cache-null-values: false表示空值不缓存防止高频空查询穿透到数据库。然后在商品详情方法上直接加注解Cacheable(cacheNames product.detail, key #id) public ProductVO getProductDetail(Long id) { Product product productMapper.selectById(id); return ProductVO.from(product); }加了Cacheable以后第二次请求同一个 id 时方法体不会执行直接返回 Redis 里的值。修改商品时要记得CacheEvict(cacheNames product.detail, key #product.id)保证数据一致。对于商城这种读多写少的场景这个方案比手动 set/get 代码少而且缓存抽象不绑定具体中间件。如果以后把 Redis 换成 Caffeine只需要改依赖和配置业务代码不用动。4. 前端静态资源与 Spring Boot 联调部署4.1 先判断手头这堆 CSS 是什么拿到app.013f2588.css、chunk-vendors.3c269362.css、bootstrap.css、responsive.css这些文件说明项目已经构建过。chunk-vendors是 Webpack 打包第三方库后的产物app.*.js/css是 Vue 单页应用的主文件这类文件名通常带 hash方便浏览器清缓存。style.css和responsive.css是从模板里带进来的业务样式。换句话说前端源码不在这套资源里但构建产物可以直接用。你不需要花时间反编译混淆后的 JS只需要把静态资源放到能被后端服务的目录里。4.2 方式一把 dist 放进 Spring Boot 的 static 目录如果前后端最终要部署在同一台服务器最简单粗暴的方式是把构建产物复制到src/main/resources/static下。Spring Boot 会把这个目录作为默认静态资源路径。复制完以后http://localhost:8080/index.html默认会展示index.html。单页应用还需要解决路由刷新 404 的问题。Vue 默认使用 history 模式时访问/item/1001后端没有item这个 Spring MVC 映射静态资源处理器也找不到就会返回 404。解决方法是加一个转发 Controller把非/api路径全部转发到index.htmlController public class SpaForwardController { // 把前端 history 路由转发到入口文件避免刷新 404 GetMapping(value {/item/**, /cart/**, /user/**}) public String forward() { return forward:/index.html; } }这里只列了商城页面里会涉及的几个前端路由实际项目里可以把路径收敛成只要是text/html请求、且不是/api和静态文件就转发。注意这个 Controller 不管/api/**因为这些请求必须交给业务接口否则刷新页面时会把购物车请求也转发到 index.html接口全部返回 200 HTML前端解析 JSON 直接报错。4.3 方式二Nginx 托管静态资源并代理 /api生产环境更推荐用 Nginx 托管前端文件Spring Boot 专心做 API。把app.013f2588.css这些文件放到/opt/sb_shop/frontend/dist配置如下server { listen 80; server_name shop.example.com; root /opt/sb_shop/frontend/dist; 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; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; } location / { try_files $uri $uri/ /index.html; } }location /api/里的proxy_pass不带末尾斜杠会保留原始 URI 传给后端所以后端接口路径必须也是/api/product/list。try_files $uri $uri/ /index.html是 history 路由的关键它先找真实文件找不到再交给 index.html前端 Vue Router 正好接管。如果这时接口返回 404先检查是不是location /api/的 proxy_pass 写成了http://127.0.0.1:8080/;多一个斜杠会把/api/product/list变成/product/list这个细节很容易被忽略。4.4 本地联调Vue devServer 代理与跨域本地拿到这套 Spring Boot 项目前端也想在 8080 端口跑热更新就需要把前端的 API 请求转发到后端 8080。Vue CLI 项目在vue.config.js中配置代理即可// vue.config.js module.exports { devServer: { proxy: { /api: { target: http://localhost:8080, changeOrigin: true } } } };changeOrigin: true会修改请求头里的 Host 为 target 地址后端如果在日志里按照 Host 做多域名判断必须开启。这里没处理 WebSocket商城一般也用不到。如果你在前端代码里看到axios.defaults.baseURL process.env.VUE_APP_API_BASE说明构建时可以通过环境变量切换 API 地址生产环境就不用在代码里写死 IP。部署方式适用场景关键注意点Spring Boot static毕设/小项目交付单包可运行但更新前端要重新打包Nginx /api生产环境前后端分离proxy_pass 的斜杠和 try_filesdevServer 代理本地联调changeOrigin 必须开启5. 下单到支付库存扣减、幂等回调与定时关单5.1 库存扣减用一条 UPDATE而不是先查再减商城最怕超卖。一张订单里如果同时买两个手办库存判断逻辑写成“先 select stock再 if stock 0 再 update stock-1”两个并发请求可能同时读到库存为 1最后都执行 update库存变成 -1。正确做法是把“判断库存足够”合并进 update 语句Mapper public interface SkuMapper { // 只有库存足够时才会扣减返回影响行数 Update(UPDATE t_product_sku SET stock stock - #{count} WHERE id #{skuId} AND stock #{count}) int reduceStock(Param(skuId) Long skuId, Param(quantity) Integer quantity); }rows 0说明要么 SKU 不存在要么库存不足。这条 SQL 利用行锁和条件stock #{count}保证同一时刻只有一个事务能成功扣减。注意这里别用乐观锁版本号一个version字段也能防超卖但用户频繁刷新下单会让大量请求失败体验很差库存扣减只需要保证原子性不必要求 CAS 失败重试。5.2 订单创建Redis 锁 事务扣减库存后要插入订单主表和明细表。整个过程需要事务保护否则扣了库存但订单没生成库存就凭空消失。我在创建订单时还会用 Redis SETNX 做一层并发控制防止同一用户对同一 SKU 瞬间点两次按钮Transactional(rollbackFor Exception.class) public String createOrder(OrderCreateDTO dto, Long userId) { // 同一个 SKU 只允许一个下单请求进入5 秒后自动过期 String lockKey stock:lock: dto.getSkuId(); Boolean locked stringRedisTemplate.opsForValue() .setIfAbsent(lockKey, 1, 5, TimeUnit.SECONDS); if (Boolean.FALSE.equals(locked)) { throw new BizException(当前下单人数过多请稍后再试); } try { int rows skuMapper.reduceStock(dto.getSkuId(), dto.getQuantity()); if (rows 0) { throw new BizException(库存不足); } String orderNo SO System.currentTimeMillis() RandomUtil.randomNumbers(4); Order order new Order(); order.setOrderNo(orderNo); order.setUserId(userId); order.setSkuId(dto.getSkuId()); order.setQuantity(dto.getQuantity()); order.setStatus(CREATED); orderMapper.insert(order); return orderNo; } finally { stringRedisTemplate.delete(lockKey); } }setIfAbsent如果 key 不存在才设置等价于加锁。给锁设置 5 秒过期是防止进程在 delete 前崩溃导致死锁。但这套手写锁有个大坑如果业务执行超过 5 秒锁自动释放后另一个线程拿到锁而第一个线程的 finally 又把锁删掉了。生产环境我建议换成 Redisson 的RLock它会在解锁时校验持有者避免误删别人锁的问题。这里保留手写版本是为了方便你理解分布式锁最基本的语义。状态含义流转方向CREATED已创建未支付CREATED - PAID / CLOSEDPAID已支付PAID - SHIPPED - FINISHEDCLOSED超时未支付关闭终态REFUNDING退款中REFUNDING - REFUNDED5.3 支付回调和幂等处理支付渠道回调接口必须做幂等。同一个支付成功通知渠道可能重发多次如果不做幂等订单状态会被重复更新库存可能被重复识别。最简单的做法是处理前用 Redis 记录一个已处理标识PostMapping(/api/pay/notify) public String payNotify(RequestBody NotifyBody body) { boolean verify payService.verifySign(body); if (!verify) { return fail; } String orderNo body.getOrderNo(); // 同一个订单只处理一次回调24 小时内有效 Boolean first stringRedisTemplate.opsForValue() .setIfAbsent(PAY: orderNo, 1, 1, TimeUnit.DAYS); if (!Boolean.TRUE.equals(first)) { return success; } orderService.markPaid(orderNo); return success; }setIfAbsent第一次返回true后续重复回调返回false直接返回成功不再处理业务。这里需要注意markPaid里的 SQL 要写成UPDATE t_order SET statusPAID, pay_timeNOW() WHERE order_no? AND statusCREATED把“只有未支付订单能改成已支付”也约束在数据库层防止两个请求同时把已退款订单重新标记为已支付。5.4 Scheduled 定时关单先把订单扫出来再改状态用户生成订单后 30 分钟没支付需要自动关闭并恢复库存。Spring Boot 里的Scheduled适合单实例下的简单定时任务。Component public class OrderCloseTask { // 每分钟执行一次优先扫描未支付订单 Scheduled(cron 0 */1 * * * ?) Transactional(rollbackFor Exception.class) public void closeTimeoutOrders() { ListOrder orders orderMapper.selectList( new LambdaQueryWrapperOrder() .eq(Order::getStatus, CREATED) .lt(Order::getCreateTime, LocalDateTime.now().minusMinutes(30)) .last(LIMIT 100)); for (Order order : orders) { int updated orderMapper.closeOrder(order.getId()); if (updated 0) { skuMapper.restoreStock(order.getSkuId(), order.getQuantity()); } } } }cron 0 */1 * * * ?表示每分钟执行一次秒位必须是 0避免同一分钟重复扫出同一批订单。closeOrder的 update 条件里带上status CREATED返回 0 说明订单已经支付或已被别的定时任务关闭这时不能恢复库存。最后LIMIT 100是给大数据量订单留缓冲每轮只处理 100 单下一分钟继续。如果以后要部署多个后端实例Scheduled会在每台机器同时触发必须换成 xxl-job 或者把任务节点用 Redis 锁只有一台执行。6. 上线前安全排查HeapDump 泄露和 YML 密文处理6.1 Actuator 触发敏感信息泄露商城项目为了监控通常会引入spring-boot-starter-actuator。默认在 Spring Boot 2.x/3.x 里只暴露health可一旦有人把management.endpoints.web.exposure.include*配了通配攻击者访问/actuator/heapdump就能直接下载 JVM 堆转储文件。这个文件里包含 Redis 密码、数据库账号、用户 token、支付私钥字符串等所有运行时内存数据。这也是 SRC 漏洞报告里高频出现的 Spring Boot 漏洞点。下面的配置只保留健康检查并在生产环境关闭详细健康信息management: endpoints: web: exposure: include: health,info endpoint: health: show-details: nevershow-details: never让/actuator/health只返回{status:UP}不会把数据库连接状态、Redis 状态细化给外部。如果你一定要用heapdump排查内存泄漏不要在公网暴露/actuator最稳妥的办法是只允许内网 IP 访问management: server: address: 127.0.0.1这样/actuator只能本机 curl 访问再配合 SSH 隧道在本地抓取。6.2 Spring Boot 数据源密码用 Jasypt 加密明文密码写在 application.yml 里是很多毕设项目的通病。如果你把项目发布到 GitHub 或者交付给客户数据库账号、Redis 密码、支付商户号全部裸奔。Jasypt 是解决这个问题的常用工具。先在 pom.xml 加入jasypt-spring-boot-starter然后在配置里指定算法jasypt: encryptor: algorithm: PBEWithHMACSHA512AndAES_128 iv-generator-classname: org.jasypt.iv.RandomIvGenerator password: ${JASYPT_MASTER_PASSWORD}${JASYPT_MASTER_PASSWORD}从环境变量读取不要把主密码写进 yml。加密后的配置会变成spring: datasource: username: ENC(M9kqW...) password: ENC(q7VzE...)初始化项目时可以通过命令行生成密文mvn jasypt:encrypt-value \ -Djasypt.encryptor.password你的主密码 \ -Djasypt.encryptor.algorithmPBEWithHMACSHA512AndAES_128 \ -Djasypt.encryptor.iv-generator-classnameorg.jasypt.iv.RandomIvGenerator \ -Djasypt.encryptor.valueroot使用ENC()前缀后Spring 启动时 Jasypt 会自动解密。这里要特别注意旧算法PBEWithMD5AndDES在 JDK 17 上会被默认安全策略拒绝所以PBEWithHMACSHA512AndAES_128是 JDK 17 Spring Boot 3 的常见搭配。6.3 给商城接口补上三个最便宜的安全措施二次元商城的用户评论、收货地址和支付回调是三个容易被忽略的入口。下面这份检查表可以直接对照检查项问题最小修复评论接口XSS 脚本注入渲染前对script转义地址接口IDOR 水平越权查询时强制带上user_id条件支付回调签名未校验回调必须验签并校验金额管理后台未授权访问Spring Security 或拦截器控制/admin/**其中 IDOR 是最阴的问题用户修改自己地址时传addressId1001如果把接口实现成selectById(addressId)而不带 userId任何人都能读别人的收货地址。正确做法是写where id #{addressId} and user_id #{userId}让数据库层做拦截。如果这篇项目你打算跑在公网别忘了给 admin 模块加权限校验。可以先用 Spring MVC 拦截器管理/admin/**等业务逻辑稳定后再上 Spring Security JWT。先把heapdump和明文密码处理掉比任何业务优化优先级都高。本文还有配套的精品资源点击获取
返回列表