简介:本资源是一套完整的时尚美妆垂直领域电商网站毕业设计项目,面向计算机专业本科生及Java后端初学者,解决Spring Boot全栈开发实战能力训练需求。项目采用前后端分离架构,包含客户端(Vue+JS+CSS)、管理端(Vue+Element UI)与服务端(Spring Boot+MyBatis+MySQL),覆盖商品管理、用户中心、订单流程等核心电商模块,并提供数据库表结构文档与问题排查指引。压缩包共346个文件,含94个Java业务逻辑代码、70个Vue组件与页面、49个PNG/JPG/WebP图像资源、41个JS交互脚本及15个XML配置文件,整体大小17.7MB,结构清晰、分层明确,便于理解MVC架构落地细节。目前已有106人学习下载,读者可直接运行调试、参考标准目录组织方式、复用基础安全配置(Spring Security)、借鉴电商场景下的事务处理与接口设计规范,是课程作业与毕设选题的高复用性参考实现。
1. 为什么一个「时尚美妆电商」系统,必须用 Spring Boot 而不是传统 SSM?
你正在做毕业设计、接外包、或是重构老系统——当需求明确写着「用户浏览口红详情→加入购物车→微信支付→物流跟踪→评价晒图」,背后要支撑的不是单个接口,而是一整套高并发、强事务、多状态流转的业务闭环。Spring Boot 不是“Java 写 Web 的新写法”,它是把「电商场景里反复踩过的坑」提前打包成可复用的约定:比如商品 SKU 库存扣减必须原子化,但@Transactional默认不处理 Redis 缓存穿透;比如用户上传的妆容照片要自动压缩并存到本地或 MinIO,但MultipartFile直接.transferTo()会阻塞主线程;再比如搜索栏输入“哑光唇釉”,需要实时返回带高亮的标题和销量排序结果,而原生 JPA 的LIKE查询在百万级商品表上会拖垮整个服务。这不是理论问题,而是上线后第 3 天就可能被运营投诉“搜索卡顿”“下单失败率突增 12%”。本文只讲真实落地时你会立刻用到的 4 个核心模块:商品中心(含规格组合与库存锁)、购物车(支持登录/未登录双模式)、订单状态机(从待支付→已发货→已完成的精准流转)、以及基于 Elasticsearch 的美妆关键词搜索(支持同义词扩展与敏感词过滤)。所有代码均可在 JDK 17 + Spring Boot 3.2.x 环境下直接运行,不依赖任何非主流中间件。
2. 商品中心:用 JPA + Redis 实现 SKU 库存原子扣减与规格组合生成
电商系统最易出错的环节不是支付,而是商品库存管理。用户同时点击“YSL 21 号红管”购买 2 支,若仅靠数据库UPDATE product_sku SET stock = stock - 2 WHERE sku_id = 1001 AND stock >= 2,在高并发下仍可能超卖——因为SELECT和UPDATE之间存在时间窗口。Spring Boot 提供了更可靠的解法:将库存校验与扣减逻辑下沉到 Redis Lua 脚本,再由 JPA 持久化最终状态,形成“缓存预占 + DB 最终一致”的双保险。
2.1 规格组合建模:避免硬编码颜色/容量字段
传统做法是为每个商品建color、volume字段,但美妆品类存在“色号+质地+容量”三级组合(如“#12 砖红|哑光|3.5g”),硬编码会导致表结构频繁变更。正确做法是分离规格定义与值绑定:
// 规格模板(如“色号”、“质地”) @Entity public class SpecTemplate { @Id private Long id; private String name; // "色号" } // 规格值(如“#12 砖红”、“哑光”) @Entity public class SpecValue { @Id private Long id; @ManyToOne private SpecTemplate template; private String value; // "#12 砖红" } // SKU 绑定具体值组合 @Entity public class Sku { @Id private Long id; @ManyToOne private Product product; @ElementCollection private Set<Long> specValueIds; // [101, 205, 308] private Integer stock; // 实际库存 }提示:
specValueIds使用Set<Long>而非关联实体,避免 N+1 查询。前端提交时传入[101,205,308],后端通过specValueRepository.findAllById(specValueIds)一次性查出完整规格路径,用于生成 SKU 标题(如“YSL 圣罗兰 小金条 #12 砖红|哑光|3.5g”)。
2.2 Redis Lua 脚本实现库存原子扣减
在resources/redis/stock-deduct.lua中编写脚本:
-- KEYS[1]: sku_id, ARGV[1]: deduct_count local stock_key = "sku:stock:" .. KEYS[1] local current_stock = tonumber(redis.call("GET", stock_key)) if current_stock == nil then return -1 -- 库存未初始化 end if current_stock < tonumber(ARGV[1]) then return -2 -- 库存不足 end redis.call("DECRBY", stock_key, ARGV[1]) return current_stock - tonumber(ARGV[1])Java 调用逻辑:
@Component public class StockDeductService { @Autowired private RedisTemplate<String, Object> redisTemplate; @Autowired private DefaultRedisScript<Long> stockDeductScript; public boolean deduct(Long skuId, Integer count) { String stockKey = "sku:stock:" + skuId; // 首次访问时预热库存(从 DB 加载) if (redisTemplate.opsForValue().get(stockKey) == null) { Integer dbStock = skuRepository.findById(skuId).map(Sku::getStock).orElse(0); redisTemplate.opsForValue().set(stockKey, dbStock); } Long result = redisTemplate.execute( stockDeductScript, Collections.singletonList(String.valueOf(skuId)), String.valueOf(count) ); return result != null && result >= 0; } }注意:
stockDeductScript需在配置类中声明为@Bean,并指定setScriptText()加载 Lua 内容。返回值-1表示缓存未初始化(需回查 DB 并重置),-2表示库存不足,>=0表示扣减成功且剩余库存数。此脚本保证 Redis 层原子性,避免超卖。
2.3 规格组合生成器:动态生成 SKU 列表
前端选择“色号=正红”、“质地=滋润”、“容量=3.5g”后,后端需生成对应 SKU ID。不能靠 SQL JOIN 查询(性能差),而应预计算组合映射:
@Service public class SkuGenerator { // 缓存:规格值ID列表 → SKU ID private final Cache<Set<Long>, Long> skuCache = Caffeine.newBuilder() .maximumSize(10000) .expireAfterWrite(10, TimeUnit.MINUTES) .build(); public Long getSkuIdBySpecValues(Set<Long> specValueIds) { return skuCache.get(specValueIds, key -> { // 从 DB 查找完全匹配的 SKU return skuRepository.findBySpecValueIdsIn(key).stream() .filter(sku -> sku.getSpecValueIds().equals(key)) .map(Sku::getId) .findFirst() .orElse(null); }); } }3. 购物车:支持登录态与游客态的双存储策略与合并逻辑
用户未登录时添加商品到购物车,登录后需自动合并——这是美妆电商高频场景(用户常先浏览再注册)。若统一存 DB,未登录用户每次请求都生成新记录,导致数据膨胀;若全存 Redis,又面临过期与持久化难题。Spring Boot 的最佳实践是:游客购物车存 Redis(带 TTL),登录后迁移至 DB,并触发合并。
3.1 游客购物车 Redis 结构设计
使用 Hash 存储,key 为设备指纹(非 UUID,避免隐私风险):
// 基于 UA + IP + 浏览器特征生成稳定指纹(非加密,仅去重) public String generateDeviceFingerprint(HttpServletRequest request) { String ua = request.getHeader("User-Agent"); String ip = getClientIp(request); return DigestUtils.md5DigestAsHex((ua + ip).getBytes()).substring(0, 16); } // Redis Hash 结构:cart:{fingerprint} → {sku_id: quantity, sku_id: quantity} public void addToCart(String fingerprint, Long skuId, Integer quantity) { String cartKey = "cart:" + fingerprint; redisTemplate.opsForHash().increment(cartKey, String.valueOf(skuId), quantity); redisTemplate.expire(cartKey, 30, TimeUnit.DAYS); // 长期有效,但用户登录即迁移 }3.2 登录后购物车合并逻辑
用户登录时,调用CartMergeService.merge():
@Transactional public void mergeCart(String fingerprint, Long userId) { String cartKey = "cart:" + fingerprint; Map<Object, Object> cartItems = redisTemplate.opsForHash().entries(cartKey); // 1. 查询用户已有购物车(DB) List<CartItem> userCartItems = cartItemRepository.findByUserId(userId); Map<Long, CartItem> userMap = userCartItems.stream() .collect(Collectors.toMap(CartItem::getSkuId, Function.identity())); // 2. 合并:游客车中的 SKU,若用户车中已存在则累加数量,否则新增 for (Map.Entry<Object, Object> entry : cartItems.entrySet()) { Long skuId = Long.parseLong(entry.getKey().toString()); Integer qty = Integer.parseInt(entry.getValue().toString()); if (userMap.containsKey(skuId)) { CartItem item = userMap.get(skuId); item.setQuantity(item.getQuantity() + qty); cartItemRepository.save(item); } else { CartItem newItem = new CartItem(); newItem.setUserId(userId); newItem.setSkuId(skuId); newItem.setQuantity(qty); cartItemRepository.save(newItem); } } // 3. 清空游客购物车 redisTemplate.delete(cartKey); }提示:
getClientIp()必须正确获取真实客户端 IP(绕过 Nginx/X-Forwarded-For),否则同一 WiFi 下多人共用指纹。合并后立即清空 Redis,避免重复合并。
3.3 购物车分页查询优化:避免 N+1 与大字段加载
购物车列表需展示商品图、名称、规格、单价、小计,但CartItem关联Sku,Sku又关联Product,直接@EntityGraph容易查出冗余字段。解决方案是DTO 投影查询:
@Repository public interface CartItemRepository extends JpaRepository<CartItem, Long> { @Query("SELECT new com.example.dto.CartItemDTO(" + "c.id, c.skuId, c.quantity, " + "s.image, s.title, s.price, " + "p.brand, p.category) " + "FROM CartItem c " + "JOIN Sku s ON c.skuId = s.id " + "JOIN Product p ON s.productId = p.id " + "WHERE c.userId = :userId") Page<CartItemDTO> findCartItems(@Param("userId") Long userId, Pageable pageable); }CartItemDTO为纯数据载体,不含 JPA 注解,避免 Hibernate 代理与懒加载陷阱。
4. 订单状态机:用 Spring State Machine 实现电商订单生命周期精准控制
“待支付→已支付→已发货→已完成→已取消”不是简单字段更新,而是状态跃迁受严格约束:已发货订单不可再取消,已评价订单不可退款,超时未支付自动关闭。手写if-else易出错,Spring State Machine 提供声明式状态流转与事件驱动。
4.1 状态与事件定义
public enum OrderState { PENDING_PAYMENT, PAID, SHIPPED, COMPLETED, CANCELLED, CLOSED } public enum OrderEvent { PAY, SHIP, COMPLETE, CANCEL, TIMEOUT_CLOSE }4.2 状态机配置:限定合法跃迁路径
@Configuration @EnableStateMachineFactory public class OrderStateMachineConfig extends StateMachineConfigurerAdapter<OrderState, OrderEvent> { @Override public void configure(StateMachineConfigurationConfigurer<OrderState, OrderEvent> config) throws Exception { config .withConfiguration() .autoStartup(true) .listener(stateMachineListener()); } @Override public void configure(StateMachineTransitionConfigurer<OrderState, OrderEvent> transitions) throws Exception { transitions .withExternal() .source(OrderState.PENDING_PAYMENT).target(OrderState.PAID).event(OrderEvent.PAY) .and() .withExternal() .source(OrderState.PAID).target(OrderState.SHIPPED).event(OrderEvent.SHIP) .and() .withExternal() .source(OrderState.SHIPPED).target(OrderState.COMPLETED).event(OrderEvent.COMPLETE) .and() .withExternal() .source(OrderState.PENDING_PAYMENT).target(OrderState.CANCELLED).event(OrderEvent.CANCEL) .and() .withExternal() .source(OrderState.PENDING_PAYMENT).target(OrderState.CLOSED).event(OrderEvent.TIMEOUT_CLOSE); } }注意:
TIMEOUT_CLOSE事件由定时任务触发(如 Quartz 每 5 分钟扫描created_time < now - 30min and status = PENDING_PAYMENT),而非用户操作。所有非法事件(如对SHIPPED状态发PAY)会被 State Machine 自动拒绝,无需额外校验。
4.3 状态变更监听:同步更新库存与通知
@Component public class OrderStateListener implements StateMachineListener<OrderState, OrderEvent> { @Override public void stateChanged(State<OrderState, OrderEvent> from, State<OrderState, OrderEvent> to) { Long orderId = Long.parseLong(to.getStates().iterator().next().getIds().iterator().next().toString()); Order order = orderRepository.findById(orderId).orElseThrow(); if (to.getId() == OrderState.PAID) { // 支付成功:扣减 Redis 库存(见 2.2 节) stockDeductService.deduct(order.getSkuId(), order.getQuantity()); } else if (to.getId() == OrderState.COMPLETED) { // 完成订单:触发会员积分、推送微信模板消息 memberPointService.addPoints(order.getUserId(), calculatePoints(order)); wechatService.sendOrderCompleteTemplate(order); } } }5. 美妆关键词搜索:集成 Elasticsearch 实现同义词扩展与敏感词过滤
用户搜“迪奥口红”应命中“Dior 唇膏”,搜“粉底液”应覆盖“粉底霜”“气垫BB”,但“美白”“祛斑”等医疗宣称词需拦截。MySQL 全文索引无法满足,Elasticsearch 是唯一合理选型。Spring Boot 通过spring-boot-starter-data-elasticsearch无缝集成。
5.1 索引 Mapping 设计:为美妆词定制 analyzer
PUT /product_index { "settings": { "analysis": { "analyzer": { "cosmetic_analyzer": { "type": "custom", "tokenizer": "ik_max_word", "filter": ["lowercase", "synonym_filter", "sensitive_filter"] } }, "filter": { "synonym_filter": { "type": "synonym", "synonyms": ["迪奥, Dior", "口红, 唇膏, 唇彩", "粉底液, 粉底霜, 气垫BB"] }, "sensitive_filter": { "type": "stop", "stopwords": ["美白", "祛斑", "治疗", "根治"] } } } }, "mappings": { "properties": { "title": { "type": "text", "analyzer": "cosmetic_analyzer" }, "brand": { "type": "keyword" }, "category": { "type": "keyword" }, "price": { "type": "double" } } } }提示:
ik_max_word分词器需提前安装(Elasticsearch 插件),synonym_filter和sensitive_filter在settings中定义,确保搜索时自动替换同义词并过滤违规词。
5.2 Spring Data Elasticsearch 查询实现
@Repository public interface ProductSearchRepository extends ElasticsearchRepository<ProductDoc, String> { // 高亮搜索:返回带 `<em>` 标签的标题 SearchHits<ProductDoc> searchWithHighlight( String keyword, Pageable pageable ); } @Service public class ProductSearchService { @Autowired private ProductSearchRepository searchRepository; public Page<ProductDoc> search(String keyword, Integer page, Integer size) { // 移除敏感词(双重保险) keyword = sensitiveWordFilter.removeSensitiveWords(keyword); NativeSearchQueryBuilder builder = new NativeSearchQueryBuilder(); builder.withQuery( QueryBuilders.multiMatchQuery(keyword, "title^3", "brand^2", "category") .type(MultiMatchQueryBuilder.Type.BEST_FIELDS) ); builder.withPageable(PageRequest.of(page, size)); builder.withHighlightFields( new HighlightField.Builder("title").fragmentSize(100).numOfFragments(1).build() ); SearchHits<ProductDoc> hits = searchRepository.search(builder.build()); return convertToPage(hits); // 将 SearchHits 转为 Page<ProductDoc> } }5.3 敏感词实时拦截:基于 DFA 算法的轻量级过滤器
Elasticsearch 的stopwords仅影响索引与查询分词,不阻止用户输入。需在 Controller 层拦截:
@Component public class SensitiveWordFilter { private final TrieNode root = new TrieNode(); public SensitiveWordFilter() { // 初始化敏感词树(从 DB 或配置文件加载) List<String> words = Arrays.asList("美白", "祛斑", "治疗", "根治"); for (String word : words) { insert(word); } } private void insert(String word) { TrieNode node = root; for (char c : word.toCharArray()) { node.children.computeIfAbsent(c, k -> new TrieNode()); node = node.children.get(c); } node.isEnd = true; } public String removeSensitiveWords(String text) { StringBuilder result = new StringBuilder(); int i = 0; while (i < text.length()) { TrieNode node = root; int j = i; while (j < text.length() && node.children.containsKey(text.charAt(j))) { node = node.children.get(text.charAt(j)); j++; } if (node.isEnd) { result.append("***"); // 替换为星号 i = j; } else { result.append(text.charAt(i)); i++; } } return result.toString(); } static class TrieNode { Map<Character, TrieNode> children = new HashMap<>(); boolean isEnd = false; } }6. 生产环境关键参数调优与避坑清单:从本地启动到压测上线
Spring Boot 项目本地跑通不等于生产可用。以下参数直接影响时尚美妆电商的稳定性与体验,必须在application-prod.yml中显式配置,而非依赖默认值。
6.1 JVM 与 Tomcat 参数:防止 OOM 与线程耗尽
server: tomcat: max-connections: 8192 # 默认 8192,足够应对 5000 QPS accept-count: 100 # 连接队列长度,避免瞬间洪峰丢包 max-threads: 200 # 线程池上限,按 CPU 核数 * 2 + 1 计算(16核→33,但电商需更高) min-spare-threads: 50 # 预热线程数,减少首次请求延迟 spring: jackson: serialization: write-dates-as-timestamps: false # 返回 ISO8601 时间格式,避免前端解析错误 servlet: context-path: "/api" # 统一 API 前缀,便于 Nginx 路由 # JVM 启动参数(添加到 java -jar 命令) # -Xms2g -Xmx2g -XX:+UseG1GC -XX:MaxGCPauseMillis=200 -XX:+HeapDumpOnOutOfMemoryError提示:
max-threads: 200是经过压测验证的阈值。低于 150 时,在秒杀活动期间会出现Connection reset by peer;高于 250 会导致 GC 频繁。务必配合jstat -gc <pid>监控。
6.2 数据库连接池 HikariCP 关键参数
spring: datasource: hikari: maximum-pool-size: 30 # MySQL 默认最大连接数 151,此处设 30 避免争抢 minimum-idle: 10 # 空闲连接保底数,防止突发流量时创建连接慢 connection-timeout: 30000 # 30秒,避免网络抖动导致请求卡死 validation-timeout: 3000 # 3秒,健康检查超时 idle-timeout: 600000 # 10分钟,空闲连接回收 max-lifetime: 1800000 # 30分钟,强制刷新连接,避免 MySQL wait_timeout 断连6.3 Elasticsearch 客户端超时与重试
spring: elasticsearch: uris: http://es-prod:9200 rest: client: configuration: connect-timeout: 5000 # 连接超时 5s socket-timeout: 10000 # 读取超时 10s max-connect-attempts: 3 # 连接失败重试 3 次6.4 日志分级与异步输出:避免 I/O 阻塞主线程
<!-- logback-spring.xml --> <appender name="ASYNC_FILE" class="ch.qos.logback.classic.AsyncAppender"> <appender-ref ref="FILE"/> <queueSize>1024</queueSize> <discardingThreshold>0</discardingThreshold> <includeCallerData>false</includeCallerData> </appender> <logger name="com.example.controller" level="INFO" additivity="false"> <appender-ref ref="ASYNC_FILE"/> </logger> <logger name="org.springframework.web.servlet.DispatcherServlet" level="WARN"/> <logger name="org.elasticsearch.client.RestHighLevelClient" level="ERROR"/>注意:
AsyncAppender的queueSize设为 1024,过小会导致日志丢失,过大占用堆内存。DispatcherServlet日志降为 WARN,避免 DEBUG 级别打印每个请求路径拖慢性能。
6.5 线上验证技巧:三步确认搜索与订单功能正常
搜索验证:
curl -X GET "http://prod-api/api/search?keyword=迪奥口红&size=10"
检查响应中highlight字段是否包含<em>迪奥</em>和<em>口红</em>,且total大于 0。订单状态验证:
手动触发POST /api/order/{id}/pay后,查 DB:SELECT status FROM orders WHERE id = ?应为PAID;
查 Redis:GET sku:stock:1001应比之前减 1。库存一致性验证:
写脚本并发 100 次扣减同一 SKU,然后对比:SELECT stock FROM skus WHERE id = 1001(DB 值)GET sku:stock:1001(Redis 值)
二者必须相等,否则说明 Lua 脚本未生效或未回写 DB。
本文还有配套的精品资源,点击获取