戰(zhàn):SKU多規(guī)格、Redis購(gòu)物車與狀態(tài)機(jī)訂單)
簡(jiǎn)介這是一套面向Java初學(xué)者與Spring Boot進(jìn)階開發(fā)者的家具電商實(shí)戰(zhàn)項(xiàng)目源碼聚焦Web應(yīng)用開發(fā)全流程實(shí)踐適用于課程設(shè)計(jì)、畢業(yè)設(shè)計(jì)及中小型商城系統(tǒng)二次開發(fā)。資源包含798個(gè)文件主體為115個(gè)Java后端業(yè)務(wù)類、45個(gè)Vue前端組件、164個(gè)JS交互邏輯與162個(gè)SVG圖標(biāo)資源輔以CSS樣式、HTML模板、SQL建表腳本及yml配置文件完整覆蓋前后端分離架構(gòu)壓縮包僅13.86MB輕量易部署。已有458人學(xué)習(xí)下載項(xiàng)目結(jié)構(gòu)規(guī)范含install/run/build三階段bat腳本預(yù)覽可見IndexMain.vue.bak等模塊化視圖文件及index.html.bak等靜態(tài)入口體現(xiàn)清晰的MVC分層與組件化設(shè)計(jì)思路。讀者可直接運(yùn)行調(diào)試深入理解Spring Boot自動(dòng)配置、MyBatis數(shù)據(jù)訪問(wèn)、JWT鑒權(quán)、Thymeleaf模板渲染及Redis緩存集成等核心實(shí)踐快速掌握企業(yè)級(jí)電商系統(tǒng)開發(fā)范式。1. 這不是一個(gè)“能跑就行”的 Spring Boot 商城 demo而是一套可直接切入真實(shí)家具電商業(yè)務(wù)邏輯的 Java 工程骨架你打開1-install.bat執(zhí)行完看到BUILD SUCCESS再雙擊2-run.bat啟動(dòng)成功——這不叫掌握這個(gè)項(xiàng)目。真正有價(jià)值的是它用MyBatis-Plus封裝了商品 SKU 多規(guī)格組合如“橡木色1.8m帶抽屜”在ProductServiceImpl里通過(guò)LambdaQueryWrapper動(dòng)態(tài)拼接查詢條件規(guī)避了傳統(tǒng) XML 中if test...嵌套過(guò)深導(dǎo)致的 SQL 可讀性崩塌它的購(gòu)物車不是存 Session而是用RedisTemplateString, CartItemVO存儲(chǔ)key 設(shè)計(jì)為cart:${userId}value 序列化用GenericJackson2JsonRedisSerializer避免了 JDK 默認(rèn)序列化在升級(jí)后反序列化失敗的線上事故它的訂單狀態(tài)流轉(zhuǎn)不是靠 if-else 判斷而是用StateMachineBuilder配置了WAIT_PAY → PAID → SHIPPED → COMPLETED的有向狀態(tài)圖并在OrderStateListener中監(jiān)聽STATE_ENTERED事件觸發(fā)庫(kù)存扣減和物流單號(hào)生成。這套代碼沒(méi)寫一行注釋講“Spring Boot 是什么”但它每一處Transactional(rollbackFor Exception.class)、每一個(gè)Validated分組校驗(yàn)、每一條application-prod.yml里的spring.redis.jedis.pool.max-wait3000ms都在回答一個(gè)實(shí)際問(wèn)題當(dāng)家具類目日均 UV 5 萬(wàn)、SKU 超 2000、下單峰值達(dá) 300 TPS 時(shí)Java 層怎么扛住它適合兩類人剛寫完 CRUD 想看真實(shí)業(yè)務(wù)如何落地的 Java 初級(jí)開發(fā)者以及需要快速搭建家具垂類 MVP 并驗(yàn)證供應(yīng)鏈對(duì)接流程的技術(shù)負(fù)責(zé)人。2. 從1-install.bat到IndexMain.vue.bak解析三層構(gòu)建鏈路與前后端耦合點(diǎn)2.1 構(gòu)建腳本鏈.bat文件背后的真實(shí) Maven 生命周期控制項(xiàng)目根目錄下三個(gè)批處理文件不是簡(jiǎn)單封裝mvn clean package。1-install.bat的核心命令是mvn clean compile -Dmaven.test.skiptrue -Pdev其中-Pdev激活pom.xml中定義的devprofile該 profile 綁定了mybatis-plus-generator插件在compile階段自動(dòng)生成entity、mapper、service三層代碼——這意味著你修改數(shù)據(jù)庫(kù)表結(jié)構(gòu)后只需重跑1-install.batProduct.java和ProductMapper.java就會(huì)同步更新無(wú)需手動(dòng)維護(hù) POJO 字段。2-run.bat實(shí)際執(zhí)行java -jar target/furniture-shop-0.0.1-SNAPSHOT.jar --spring.profiles.activedev --server.port8081注意--server.port8081覆蓋了application-dev.yml中的默認(rèn)端口這是為避免與本地已啟動(dòng)的其他 Spring Boot 服務(wù)沖突。而3-build.bat則調(diào)用mvn clean package -Pprod -Dmaven.test.skiptrue其中prodprofile 關(guān)閉了 HikariCP 連接池的leakDetectionThreshold生產(chǎn)環(huán)境禁用內(nèi)存泄漏檢測(cè)并啟用spring-boot-maven-plugin的repackage目標(biāo)生成可執(zhí)行 jar。提示若執(zhí)行1-install.bat報(bào)錯(cuò)Could not resolve dependencies for project檢查pom.xml中repository是否指向了公司內(nèi)網(wǎng) Nexus 地址如http://nexus.internal:8081/repository/maven-public/而非默認(rèn)的 Maven Central。此時(shí)需將settings.xml中的mirror配置同步到本地 Maven 安裝目錄的conf/下。2.2 前端靜態(tài)資源.vue.bak文件揭示的漸進(jìn)式遷移路徑目錄中大量.vue.bak文件如IndexMain.vue.bak并非備份而是項(xiàng)目從傳統(tǒng) JSP/Thymeleaf 模板引擎向 Vue 單頁(yè)應(yīng)用遷移的中間態(tài)證據(jù)。以IndexMain.vue.bak為例其script區(qū)域包含export default { data() { return { productList: [], categoryList: [] } }, mounted() { this.loadProducts() this.loadCategories() }, methods: { loadProducts() { // 注意此處調(diào)用的是 /api/product/list 接口而非 Thymeleaf 的 {/product/list} axios.get(/api/product/list).then(res { this.productList res.data.data }) } } }這說(shuō)明后端已提供標(biāo)準(zhǔn) RESTful API路徑前綴/api/但前端尚未完成 Vue Router 路由配置故仍依賴index.html.bak中的div idapp/div作為掛載點(diǎn)。對(duì)比IndexAsideStatic.vue.bak中的硬編碼導(dǎo)航菜單lia href/admin/product商品管理/a/li lia href/admin/order訂單管理/a/li這些/admin/*路徑指向 Spring Security 配置的antMatchers(/admin/**).hasRole(ADMIN)證明權(quán)限控制已下沉到 Controller 層而非前端路由守衛(wèi)。這種混合模式允許團(tuán)隊(duì)分階段改造先確保后端 API 穩(wěn)定再逐步替換前端視圖層。2.3 后端核心模塊ProductController與ProductServiceImpl的事務(wù)邊界設(shè)計(jì)ProductController中的關(guān)鍵方法簽名如下PostMapping(/list) public ResultListProductVO list(RequestBody Validated ProductQuery query) { return Result.success(productService.listByQuery(query)); }ProductQuery類使用Min(value 1, message 頁(yè)碼必須大于0)等注解進(jìn)行參數(shù)校驗(yàn)校驗(yàn)失敗時(shí)由GlobalExceptionHandler攔截并返回Result.fail(頁(yè)碼必須大于0)。而ProductServiceImpl.listByQuery()方法內(nèi)部實(shí)現(xiàn)為Override Transactional(readOnly true) public ListProductVO listByQuery(ProductQuery query) { LambdaQueryWrapperProduct wrapper new LambdaQueryWrapper(); wrapper.eq(query.getCategoryId() ! null, Product::getCategoryId, query.getCategoryId()) .like(query.getKeyword() ! null, Product::getName, query.getKeyword()) .orderByDesc(Product::getSalesCount); return productMapper.selectList(wrapper).stream() .map(this::convertToVO).collect(Collectors.toList()); }這里Transactional(readOnly true)顯式聲明只讀事務(wù)HikariCP 會(huì)自動(dòng)設(shè)置Connection.setReadOnly(true)數(shù)據(jù)庫(kù)可據(jù)此優(yōu)化執(zhí)行計(jì)劃如 MySQL 5.7 對(duì)只讀事務(wù)禁用 binlog 寫入。更關(guān)鍵的是convertToVO()方法中對(duì)關(guān)聯(lián)數(shù)據(jù)的處理private ProductVO convertToVO(Product product) { ProductVO vo new ProductVO(); BeanUtils.copyProperties(product, vo); // 避免 N1 查詢此處應(yīng)通過(guò) MyBatis-Plus 的 SelectJoinTable 注解或手寫 SQL JOIN 獲取品牌名 vo.setBrandName(brandService.getById(product.getBrandId()).getName()); return vo; }這段代碼存在性能隱患——若一次查 20 個(gè)商品就會(huì)觸發(fā) 20 次brandService.getById()查詢。正確做法是在ProductMapper.xml中編寫 JOIN SQLselect idselectProductWithBrand resultTypecom.example.furniture.vo.ProductVO SELECT p.*, b.name as brandName FROM product p LEFT JOIN brand b ON p.brand_id b.id WHERE p.category_id #{categoryId} /select然后在ProductMapper接口中定義ListProductVO selectProductWithBrand(Param(categoryId) Long categoryId);。這正是源碼中未完全優(yōu)化但留出明確改進(jìn)路徑的設(shè)計(jì)。3. 數(shù)據(jù)庫(kù)與安全MySQL 表結(jié)構(gòu)設(shè)計(jì)與 Spring Security 權(quán)限模型落地3.1 核心表關(guān)系product、product_sku、order的范式化實(shí)踐項(xiàng)目使用 MySQL 8.0product表存儲(chǔ)家具基礎(chǔ)信息名稱、描述、主圖 URL而具體銷售單元如“北歐風(fēng)布藝沙發(fā)-米白色-2.2m”存于product_sku表其關(guān)鍵字段包括字段名類型說(shuō)明idBIGINT PKSKU 主鍵product_idBIGINT FK關(guān)聯(lián)product.idspecificationJSON存儲(chǔ)規(guī)格組合如{color:米白色,length:2.2m}stockINT實(shí)時(shí)庫(kù)存下單時(shí)扣減priceDECIMAL(10,2)銷售價(jià)格這種設(shè)計(jì)避免了為每個(gè)規(guī)格單獨(dú)建表如sofa_white_220同時(shí)利用 MySQL 5.7 的 JSON 函數(shù)支持動(dòng)態(tài)查詢-- 查找所有“米白色”且長(zhǎng)度≥2m的沙發(fā) SKU SELECT * FROM product_sku WHERE product_id IN (SELECT id FROM product WHERE category_id 101) AND JSON_CONTAINS(specification, 米白色, $.color) AND CAST(JSON_EXTRACT(specification, $.length) AS DECIMAL) 2.0;order表則采用樂(lè)觀鎖控制并發(fā)超賣version字段初始為 0每次更新庫(kù)存時(shí)UPDATE product_sku SET stock stock - 1, version version 1 WHERE id ? AND version ?若ROW_COUNT() 0則拋出OptimisticLockException觸發(fā)重試邏輯。3.2 Spring Security 配置基于角色的 URL 訪問(wèn)控制與密碼加密策略SecurityConfig.java中定義了細(xì)粒度權(quán)限Override protected void configure(HttpSecurity http) throws Exception { http.authorizeRequests() .antMatchers(/api/user/login, /api/user/register).permitAll() .antMatchers(/api/product/**, /api/category/**).permitAll() // 商品瀏覽無(wú)需登錄 .antMatchers(/api/cart/**, /api/order/**).authenticated() // 購(gòu)物車和訂單需登錄 .antMatchers(/api/admin/**).hasRole(ADMIN) // 后臺(tái)管理僅管理員 .anyRequest().authenticated(); }用戶密碼加密使用BCryptPasswordEncoder強(qiáng)度 factor12其encode()方法生成的密文形如$2a$12$ZQVJzX9YvKqLmNpOcRtS.uEwFgHiJkLmNoPqRsTuVwXyZaBcDeFgH前綴$2a$表示 BCrypt 算法12為迭代輪數(shù)。在UserDetailsServiceImpl.loadUserByUsername()中框架自動(dòng)比對(duì)明文密碼與密文哈希值無(wú)需手動(dòng)調(diào)用matches()。注意application-dev.yml中spring.security.user.name和password僅用于開發(fā)環(huán)境快速登錄生產(chǎn)環(huán)境必須禁用此配置改用數(shù)據(jù)庫(kù)用戶表認(rèn)證。3.3 敏感操作審計(jì)PreAuthorize注解與操作日志切面對(duì)高危操作如刪除商品、修改訂單狀態(tài)使用 SpEL 表達(dá)式校驗(yàn)PreAuthorize(securityService.canDeleteProduct(#productId, principal.username)) DeleteMapping(/{id}) public ResultVoid delete(PathVariable Long id) { productService.removeById(id); return Result.success(); }securityService.canDeleteProduct()方法檢查當(dāng)前用戶是否為商品創(chuàng)建者或 ADMIN 角色。同時(shí)OperationLogAspect切面捕獲所有PostMapping方法的執(zhí)行Around(annotation(org.springframework.web.bind.annotation.PostMapping)) public Object logOperation(ProceedingJoinPoint joinPoint) throws Throwable { long start System.currentTimeMillis(); Object result joinPoint.proceed(); long cost System.currentTimeMillis() - start; // 記錄到 operation_log 表操作人、URL、參數(shù)、耗時(shí)、返回狀態(tài) operationLogService.save(new OperationLog( SecurityContextHolder.getContext().getAuthentication().getName(), ((ServletRequestAttributes) RequestContextHolder.getRequestAttributes()).getRequest().getRequestURL().toString(), JSON.toJSONString(joinPoint.getArgs()), cost, result instanceof Result ? ((Result?) result).getCode() : 500 )); return result; }該日志表結(jié)構(gòu)包含operator操作人、url、paramsJSON 字符串、cost_time毫秒、status_codeHTTP 狀態(tài)碼為后續(xù)排查“誰(shuí)在什么時(shí)間刪了哪個(gè)商品”提供直接證據(jù)。4. 生產(chǎn)就緒配置Redis 緩存穿透防護(hù)與日志分級(jí)策略4.1 緩存設(shè)計(jì)CacheManager配置與空值緩存防穿透RedisConfig.java中定義了兩級(jí)緩存策略Bean public CacheManager cacheManager(RedisConnectionFactory factory) { RedisCacheConfiguration config RedisCacheConfiguration.defaultCacheConfig() .entryTtl(Duration.ofHours(2)) // 默認(rèn) 2 小時(shí)過(guò)期 .serializeKeysWith(RedisSerializationContext.SerializationPair.fromSerializer(new StringRedisSerializer())) .serializeValuesWith(RedisSerializationContext.SerializationPair.fromSerializer(new GenericJackson2JsonRedisSerializer())); // 為商品詳情單獨(dú)配置10 分鐘過(guò)期且啟用空值緩存 MapString, RedisCacheConfiguration cacheConfigurations new HashMap(); cacheConfigurations.put(productDetail, config.entryTtl(Duration.ofMinutes(10))); cacheConfigurations.put(categoryList, config.entryTtl(Duration.ofHours(1))); return new RedisCacheManager.RedisCacheManagerBuilder(factory) .cacheDefaults(config) .withInitialCacheConfigurations(cacheConfigurations) .build(); }在ProductServiceImpl.getProductDetail()方法上添加CacheableCacheable(value productDetail, key #id, unless #result null) public ProductVO getProductDetail(Long id) { Product product productMapper.selectById(id); if (product null) { // 防穿透緩存空對(duì)象過(guò)期時(shí)間設(shè)為 2 分鐘短于正常緩存 redisTemplate.opsForValue().set(product:detail: id, null, Duration.ofMinutes(2)); return null; } return convertToVO(product); }當(dāng)查詢不存在的商品 ID如id999999時(shí)redisTemplate手動(dòng)寫入null字符串后續(xù)請(qǐng)求直接命中 Redis 返回空避免穿透到數(shù)據(jù)庫(kù)。unless #result null確保只有非空結(jié)果才進(jìn)入CacheManager的自動(dòng)緩存流程。4.2 日志體系Logback 分環(huán)境輸出與 ERROR 級(jí)別告警logback-spring.xml中定義了三套輸出策略!-- 開發(fā)環(huán)境控制臺(tái)輸出 DEBUG 級(jí)別 -- springProfile namedev root levelDEBUG appender-ref refCONSOLE/ /root /springProfile !-- 測(cè)試環(huán)境文件輸出 INFO 級(jí)別按天滾動(dòng) -- springProfile nametest appender nameFILE classch.qos.logback.core.rolling.RollingFileAppender filelogs/app-test.log/file rollingPolicy classch.qos.logback.core.rolling.TimeBasedRollingPolicy fileNamePatternlogs/app-test.%d{yyyy-MM-dd}.%i.log/fileNamePattern /rollingPolicy encoder pattern%d{HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n/pattern /encoder /appender root levelINFO appender-ref refFILE/ /root /springProfile !-- 生產(chǎn)環(huán)境ERROR 級(jí)別獨(dú)立文件 控制臺(tái) -- springProfile nameprod appender nameERROR_FILE classch.qos.logback.core.rolling.RollingFileAppender filelogs/error-prod.log/file filter classch.qos.logback.core.filter.LevelFilter levelERROR/level onMatchACCEPT/onMatch onMismatchDENY/onMismatch /filter !-- ... 滾動(dòng)策略 -- /appender root levelWARN appender-ref refERROR_FILE/ appender-ref refCONSOLE/ /root /springProfile關(guān)鍵點(diǎn)在于prod環(huán)境下ERROR_FILE使用LevelFilter嚴(yán)格隔離 ERROR 日志確保運(yùn)維人員收到告警郵件時(shí)日志內(nèi)容不含 INFO/WARN 噪聲。同時(shí)root級(jí)別設(shè)為WARN保證System.out.println()等調(diào)試語(yǔ)句不會(huì)污染生產(chǎn)日志。5. 快速驗(yàn)證與壓測(cè)技巧用 curl 模擬高并發(fā)下單與 Redis 監(jiān)控命令5.1 接口連通性驗(yàn)證三步確認(rèn)核心鏈路健康在項(xiàng)目啟動(dòng)后2-run.bat成功執(zhí)行以下命令驗(yàn)證# 1. 檢查服務(wù)存活返回 HTTP 200 curl -I http://localhost:8081/actuator/health # 2. 查詢商品列表驗(yàn)證 MyBatis-Plus MySQL 連通 curl http://localhost:8081/api/product/list?pageNum1pageSize10 | jq .code # 3. 模擬用戶登錄獲取 token驗(yàn)證 Spring Security TOKEN$(curl -s -X POST http://localhost:8081/api/user/login \ -H Content-Type: application/json \ -d {username:admin,password:123456} | jq -r .data.token) # 4. 用 token 添加商品到購(gòu)物車驗(yàn)證 Redis 認(rèn)證 curl -X POST http://localhost:8081/api/cart/add \ -H Authorization: Bearer $TOKEN \ -H Content-Type: application/json \ -d {productId:1,skuId:1,count:2}若第 4 步返回{code:200,msg:success}說(shuō)明從認(rèn)證、緩存、數(shù)據(jù)庫(kù)寫入的全鏈路已打通。jq命令需提前安裝macOSbrew install jqWindowschoco install jq。5.2 Redis 實(shí)時(shí)監(jiān)控識(shí)別緩存雪崩與熱點(diǎn) Key連接 Redis 服務(wù)器后執(zhí)行以下命令定位問(wèn)題# 查看當(dāng)前所有 key 的內(nèi)存占用按大小降序 redis-cli --bigkeys # 監(jiān)控最近 1 秒內(nèi)被訪問(wèn)最多的 10 個(gè) key識(shí)別熱點(diǎn) redis-cli --hotkeys # 檢查是否存在大量過(guò)期 key 導(dǎo)致的 CPU 占用飆升 redis-cli info | grep expired_keys # 手動(dòng)觸發(fā) key 清理生產(chǎn)環(huán)境慎用 redis-cli config set activedefrag yes針對(duì)家具商城場(chǎng)景重點(diǎn)監(jiān)控cart:*和productDetail:*前綴的 key。若cart:*key 數(shù)量隨用戶數(shù)線性增長(zhǎng)如 10 萬(wàn)用戶產(chǎn)生 10 萬(wàn)個(gè) key需評(píng)估是否啟用 Redis Cluster 分片若productDetail:123的 TTL 頻繁重置為 10 分鐘說(shuō)明該商品被高頻訪問(wèn)應(yīng)考慮將其提升至本地 Caffeine 緩存。5.3 JMeter 壓測(cè)下單接口驗(yàn)證庫(kù)存扣減的線程安全性創(chuàng)建 JMeter 測(cè)試計(jì)劃配置線程組100 線程循環(huán) 10 次HTTP 請(qǐng)求目標(biāo)為POST http://localhost:8081/api/order/createBody Data 為{ cartItems: [ {skuId: 1, count: 1}, {skuId: 2, count: 1} ], addressId: 1001 }添加“響應(yīng)斷言”檢查返回$.code 200添加“聚合報(bào)告”觀察錯(cuò)誤率。若錯(cuò)誤率 0%檢查OrderServiceImpl.createOrder()中的庫(kù)存扣減邏輯// 正確使用 Redis Lua 腳本保證原子性 String script if redis.call(exists, KEYS[1]) 1 then local stock tonumber(redis.call(hget, KEYS[1], stock)) if stock tonumber(ARGV[1]) then redis.call(hincrby, KEYS[1], stock, 0-ARGV[1]) return 1 end end return 0; Long result redisTemplate.execute(new DefaultRedisScript(script, Long.class), Collections.singletonList(sku:1), 1); if (result 0) throw new BusinessException(庫(kù)存不足);此腳本在 Redis 服務(wù)端原子執(zhí)行徹底規(guī)避 JVM 層多線程競(jìng)爭(zhēng)。若壓測(cè)中出現(xiàn)超賣則說(shuō)明代碼中仍存在getStock()updateStock()的非原子操作需立即替換為 Lua 方案。本文還有配套的精品資源點(diǎn)擊獲取