
简介面向中小型电脑装机店与硬件零售场景的一套 ASP 自助装机与报价系统源码可满足客户在线选配主机、实时查询配件价格、提交订购留言等需求适合具备一定 ASP 与 Access 基础的开发者二次开发也可用于课程设计参考。压缩包共 142 个文件以 51 个 asp 动态页面和 16 个 inc 公共包含文件为核心配合 47 个 gif、19 个 jpg 界面素材、css 样式表、1 个 mdb 数据库及少量 txt 说明文件前后台模块划分清晰。前台包含首页配置查询与新闻展示、按 CPU 或主板型号自动匹配主机并计算总价、生成机器 ID 以便回查的自选配置流程以及带价格波动提示的今日报价和订购留言后台提供分级管理员MD5 加密、数据库在线备份恢复与 SQL 批量处理、新闻与产品品牌管理等功能。已有 433 人学习下载可作为装机电商类小型系统的实现范例帮助理解配件关联配置、报价联动与权限分级的落地写法。1. 上个月帮人攒机我在三个电商页面之间来回切上个月帮朋友配一台八千块的主机CPU 是 LGA1700 还是 AM5、主板内存是 DDR4 还是 DDR5、显卡 336mm 塞不塞得进那个中塔机箱全靠人肉比对翻车一次就是几百块运费。这套自助装机与硬件报价系统干的就是把这件事搬到网页上用户按分类挑配件系统实时告诉你能不能装、总价多少、电源够不够最后生成一张可分享的配置单。它跟普通商品列表页是两回事真正值钱的是「兼容性校验」和「动态报价」这两块前端的筛选联动、后台的配件管理、数据库里的规格字段设计全都是为这两件事服务的。适合两类人想照着源码做一套装机商城或者报价工具的开发者以及手里有一堆硬件 SKU 却只能用 Excel 报价的商家技术负责人。2. 技术栈与目录结构一套前后端分离的装机骨架长什么样2.1 为什么是 Spring Boot Vue 3 这套组合硬件品类有个麻烦点不同配件的规格字段完全不一样。CPU 关心插槽、核心数、TDP显卡关心长度、厚度、供电接口、TDP内存关心代数、频率、单条容量机箱关心支持的板型和显卡限长。如果按传统做法给每个分类建一张表加一个品类就要改一次表结构和一堆 Mapper维护成本会失控。所以这套系统的做法是把「通用字段」和「规格字段」拆开通用部分品牌、型号、价格、库存、状态放在配件主表差异化规格统一塞进一个 JSON 字段里查询时用 MySQL 8 的 JSON 函数过。Spring Boot 配 MyBatis-Plus 写这种 CRUD 密集型的业务很快Redis 扛报价读多写少的场景前端 Vue 3 Element Plus 做级联筛选和表单联动最省事。下面是这套工程里实际用到的技术组件层次组件版本在这套系统里负责什么后端框架Spring Boot2.7.x提供 REST 接口、事务管理、参数校验持久层MyBatis-Plus3.5.x单表 CRUD、分页、JSON 字段的条件查询数据库MySQL8.0配件、配置单、兼容规则、区域价缓存Redis6.x配件报价缓存、热门配置单缓存前端Vue 3 Vite3.x / 4.x装机页、后台管理页UIElement Plus2.x表格、级联选择、抽屉弹窗鉴权Spring Security JWT—后台登录、用户配置单归属校验选 Spring Boot 还有一个现实原因装机系统往往要接商家的库存接口和 ERPJava 生态里现成的 HTTP 客户端、定时任务、消息队列方案最全后面要做价格同步不用推倒重来。前端选 Vite 而不是 Webpack主要是装机页的配件卡片多冷启动和热更新体验差太多改一行样式等十秒的滋味不好受。2.2 目录结构前后端两个工程边界划在哪工程是标准的前后端分离后端一个 Maven 模块前端一个 Vite 工程接口通过/api前缀统一转发。目录结构大致如下pc-builder/ ├── server/ # 后端工程 │ ├── src/main/java/com/example/pcb/ │ │ ├── controller/ # 对外接口层 │ │ │ ├── BuildController.java # 装机、配置单 │ │ │ ├── ProductController.java # 配件查询 │ │ │ └── AdminPriceController.java# 后台改价 │ │ ├── service/ │ │ │ ├── CompatCheckService.java # 兼容性校验核心 │ │ │ ├── PriceEngine.java # 报价计算 │ │ │ └── BuildService.java # 配置单落库 │ │ ├── mapper/ # MyBatis-Plus Mapper │ │ ├── entity/ # 数据库实体 │ │ ├── dto/ # 出入参对象 │ │ └── common/ # 统一返回、异常、工具 │ └── src/main/resources/ │ ├── application-dev.yml # 本地配置 │ ├── mapper/ProductMapper.xml # JSON 字段查询 SQL │ └── sql/ # 建表与种子数据 └── web/ # 前端工程 ├── src/ │ ├── views/Build.vue # 自助装机主页面 │ ├── views/admin/Product.vue # 配件管理 │ ├── api/build.js # 接口封装 │ └── store/build.js # Pinia已选配件状态 └── vite.config.js # 代理 /api 到 8080拿到源码后建议先盯住三个文件CompatCheckService.java里是所有装机经验的落点PriceEngine.java决定最终报价怎么算Build.vue是用户实际操作的界面配置单的增删改和实时算价全在这一个页面的状态里。其余 Controller 大多是薄薄一层转发改起来没什么难度。2.3 数据表设计配件、配置单、兼容规则三张核心表整套系统的数据模型围绕三张表展开另外两张是它们的从属。配件表用 JSON 存规格是前面说的那个取舍的直接体现-- 配件主表通用字段进列差异化规格进 spec CREATE TABLE hw_product ( id BIGINT PRIMARY KEY AUTO_INCREMENT, category_code VARCHAR(16) NOT NULL COMMENT CPU/MB/RAM/GPU/SSD/PSU/CASE/COOLER, brand VARCHAR(64) NOT NULL, model VARCHAR(128) NOT NULL, base_price DECIMAL(10,2) NOT NULL DEFAULT 0, stock INT NOT NULL DEFAULT 0, status TINYINT NOT NULL DEFAULT 1 COMMENT 1 上架 0 下架, spec JSON COMMENT {socket:LGA1700,tdp:125,mem_type:DDR5}, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, KEY idx_cat_status (category_code, status), KEY idx_model (model) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; -- 配置单主表share_code 用于对外分享 CREATE TABLE pc_build ( id BIGINT PRIMARY KEY AUTO_INCREMENT, user_id BIGINT NOT NULL, title VARCHAR(64) NOT NULL, total_price DECIMAL(10,2) NOT NULL DEFAULT 0, share_code CHAR(8) NOT NULL, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uk_share_code (share_code), KEY idx_user (user_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; -- 配置单明细价格和规格都做快照后面讲为什么必须这么做 CREATE TABLE pc_build_item ( id BIGINT PRIMARY KEY AUTO_INCREMENT, build_id BIGINT NOT NULL, category_code VARCHAR(16) NOT NULL, product_id BIGINT NOT NULL, snapshot_price DECIMAL(10,2) NOT NULL, snapshot_spec JSON, KEY idx_build (build_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;spec字段用 JSON 是有代价的不能像普通列那样建索引跨品类做范围查询比如「找出所有长度小于 320mm 的显卡」只能靠 MySQL 8 的函数索引或者生成列。常见的补救办法是给高频查询的规格建生成列例如ALTER TABLE hw_product ADD COLUMN gpu_len INT AS (spec-$.length) STORED, ADD KEY idx_len (gpu_len);这样长度过滤又能走索引了。装机系统的配件量通常在几千条量级全表扫描也不算致命但一旦要做比价或者爬虫同步这个索引迟早得补。snapshot_price和snapshot_spec这两列是血泪经验换来的第 4 章会详细说后台改价引发的用户投诉。这里先记住一个原则配置单是「历史凭证」不能因为商品后来调价就跟着变金额。3. 自助装机链路从选 CPU 到生成一张配置单3.1 配件筛选接口按已选配件反查候选列表装机页最核心的交互是联动用户选了 LGA1700 的 CPU主板那一栏就不该再出现 AM5 的板子。这个过滤必须在后端做前端过滤只是体验层接口能被直接调用绕过前端选了个不兼容的配件最后炸的是客服。接口设计上前端把当前已选的配件 id 列表传给后端后端反查这些配件推导出目标分类必须满足的条件再去查配件表RestController RequestMapping(/api/build) public class BuildController { private final BuildService buildService; public BuildController(BuildService buildService) { this.buildService buildService; } /** * 查询某分类下的候选配件 * param category 目标分类如 MB、RAM、CASE * param selected 已选配件 id 列表可为空 */ GetMapping(/candidates) public ResultListProductVO candidates(RequestParam String category, RequestParam(required false) ListLong selected) { return Result.ok(buildService.candidates(category, selected)); } }Service 里的推导逻辑是这段代码的重点它决定了联动的「智能」程度public ListProductVO candidates(String category, ListLong selectedIds) { MapString, Product picked loadPicked(selectedIds); // categoryCode - 已选配件 MapString, String filters new LinkedHashMap(); // 选 CPU 之后主板插槽必须跟 CPU 一致 if (MB.equals(category) picked.containsKey(CPU)) { filters.put(socket, picked.get(CPU).spec(socket)); } // 选主板之后内存代数必须跟主板一致 if (RAM.equals(category) picked.containsKey(MB)) { filters.put(mem_type, picked.get(MB).spec(mem_type)); } // 选显卡之后机箱的显卡限长不能小于显卡长度 if (CASE.equals(category) picked.containsKey(GPU)) { filters.put(gpu_max_len_gte, picked.get(GPU).spec(length)); } // 选主板之后机箱板型必须支持 if (CASE.equals(category) picked.containsKey(MB)) { filters.put(form_factor_in, picked.get(MB).spec(form_factor)); } return productMapper.selectBySpecFilters(category, filters); }filters这个 Map 的 key 是约定好的查询指令不是数据库字段名。socket、mem_type这类是等值匹配gpu_max_len_gte是大于等于form_factor_in是包含匹配Mapper 里根据 key 的前后缀决定拼spec-$.gpu_max_len ?还是JSON_CONTAINS。这样加一个新联动规则只需要在 Service 里多写四行不用改 SQL。有个细节值得注意selected参数允许只传部分配件。用户先挑显卡再挑机箱跟先挑机箱再挑显卡走的是同一个接口过滤方向自然相反。前端每次改动已选列表就重新拉一次当前分类的候选代价是请求次数变多缓解办法是给候选接口加一层短 TTL 的缓存key 用category 排序后的 selectedIds拼出来。3.2 兼容性校验把装机师傅的经验写成规则联动筛选解决的是「选不到不该选的」兼容性校验解决的是「已经选错了要拦住」。两件事不能互相替代用户可能一次性导入一张别人给的配置单或者从收藏夹恢复一份旧单子这时候没有逐个点选的过程必须有一次完整校验。校验服务的结构是把每个检查项拆成独立方法主流程顺序调用。这样加规则不动老代码写单元测试也能一条一条覆盖Service public class CompatCheckService { /** 电源余量按估算功耗的 130% 推荐再向上取整到 50W 档位 */ private static final int PSU_HEADROOM 130; public ListCompatIssue check(ListBuildItem items) { MapString, ListBuildItem byCat items.stream() .collect(Collectors.groupingBy(BuildItem::getCategoryCode)); ListCompatIssue issues new ArrayList(); checkSocket(byCat, issues); // CPU 插槽 vs 主板插槽 checkMemoryType(byCat, issues); // 内存代数 vs 主板 checkGpuLength(byCat, issues); // 显卡长度 vs 机箱限长 checkCoolerHeight(byCat, issues); // 散热器高度 vs 机箱限高 checkPower(byCat, issues); // 整机功耗 vs 电源额定 return issues; } private void checkSocket(MapString, ListBuildItem byCat, ListCompatIssue issues) { BuildItem cpu first(byCat, CPU); BuildItem mb first(byCat, MB); if (cpu null || mb null) return; String cpuSocket cpu.spec(socket); String mbSocket mb.spec(socket); if (cpuSocket ! null mbSocket ! null !cpuSocket.equals(mbSocket)) { issues.add(CompatIssue.error(CPU_SOCKET, String.format(CPU 插槽 %s 与主板插槽 %s 不匹配, cpuSocket, mbSocket))); } } private void checkPower(MapString, ListBuildItem byCat, ListCompatIssue issues) { int load 0; load intSpec(first(byCat, CPU), tdp, 0); load intSpec(first(byCat, GPU), tdp, 0); load 30; // 主板 load 6 * size(byCat, RAM); // 每条内存按 6W load 10 * size(byCat, SSD); // 每块固态按 10W load 15; // 风扇、灯效等杂项 int recommend (int) Math.ceil(load * PSU_HEADROOM / 100.0 / 50.0) * 50; BuildItem psu first(byCat, PSU); if (psu ! null intSpec(psu, rated_power, 0) recommend) { issues.add(CompatIssue.warn(PSU_POWER, String.format(整机估算功耗约 %dW建议电源不低于 %dW当前额定 %dW, load, recommend, intSpec(psu, rated_power, 0)))); } } }CompatIssue分error和warn两级这个分级很关键。插槽不匹配是硬错必须拦住电源功率偏低、内存频率超过主板支持上限这些属于「能用但体验打折」给个黄色提示就够了。全都当硬错拦下来的后果是老用户配个 600W 电源带中端卡直接被判死刑转化率直线下滑。功耗估算这段的系数是经验值不是标准。主板 30W、内存每条 6W、固态每块 10W 属于偏保守的估法真实待机远低于此但装机场景要考虑瞬时峰值宁可高估。电源推荐向上取整到 50W 一档是因为市面上的电源额定功率就是 450/500/550/650 这样跳的算出个 547W 没有意义。显卡如果带peak_power字段应该优先用它而不是tdp某些卡的瞬时功耗能冲到标称值的两倍只按 TDP 算电源玄学死机就会找上门。3.3 配置单落库与分享码别用自增 ID 当对外凭证配置单保存是整条链路的收口这里要做三件事再校验一次、算总价、生成不可猜测的分享码。Transactional(rollbackFor Exception.class) public String saveBuild(Long userId, BuildSaveDTO dto) { // 1. 前端校验不可信落库前再跑一遍有硬错直接拒绝 ListCompatIssue issues compatCheckService.check(dto.getItems()); if (issues.stream().anyMatch(i - ERROR.equals(i.getLevel()))) { throw new BizException(issues.get(0).getMessage()); } // 2. 主表 PcBuild build new PcBuild(); build.setUserId(userId); build.setTitle(dto.getTitle()); build.setShareCode(shareCodeGenerator.next()); build.setTotalPrice(priceEngine.sum(dto.getItems())); buildMapper.insert(build); // 3. 明细价格与规格全部快照不依赖商品表后续变更 for (BuildItem item : dto.getItems()) { PcBuildItem row new PcBuildItem(); row.setBuildId(build.getId()); row.setCategoryCode(item.getCategoryCode()); row.setProductId(item.getProductId()); row.setSnapshotPrice(item.getPrice()); row.setSnapshotSpec(JSON.toJSONString(item.getSpec())); buildItemMapper.insert(row); } return build.getShareCode(); }分享码生成器用SecureRandom从 62 个字符里取 8 位空间约 2.18×10^14配合唯一索引做兜底重试够用了Component public class ShareCodeGenerator { private static final String ALPHABET 0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ; private static final int LEN 8; private final SecureRandom random new SecureRandom(); public String next() { StringBuilder sb new StringBuilder(LEN); for (int i 0; i LEN; i) { sb.append(ALPHABET.charAt(random.nextInt(ALPHABET.length()))); } return sb.toString(); } }用Random而不是SecureRandom也能跑但分享码本质上是访问凭证可预测的随机序列意味着别人能顺着生成规律猜出别人的配置单。这个坑我在别的项目里踩过用自增 ID 当分享链接被人写了个循环把整站配置单一晚上爬了个遍连用户备注都没放过。从那以后凡是「对外可见的资源定位符」一律走随机码加唯一索引不再图省事。priceEngine.sum()用的是前端传来的单价还是后端实时查的价格这里有个取舍。前端传价格省一次查询但用户改个请求参数就能把 8000 的配置单存成 80后端重查最安全代价是保存时多几次数据库往返。这套系统的做法是后端重查前端传的价格只用于展示落库前全部覆盖。4. 报价引擎价格怎么算、缓存怎么放、后台改价怎么不打脸老配置单4.1 报价组装基础价、区域价、会员折扣的叠加顺序报价看起来简单实际上一旦掺进区域加价和会员等级顺序错了就会出现「先打折后加价」和「先加价后打折」差几十块的情况。这套系统的规则是基础价 → 加区域差价 → 应用会员折扣折扣只作用在商品金额上不作用于运费和延保这类附加项。public BigDecimal quote(Long productId, Long regionId, Integer memberLevel) { // 缓存只放与用户无关的部分会员折扣在内存里算 String key price: productId : regionId; String cached redis.opsForValue().get(key); if (cached ! null) { return applyMemberDiscount(new BigDecimal(cached), memberLevel); } Product p productMapper.selectById(productId); if (p null || p.getStatus() ! 1) { throw new BizException(配件已下架); } BigDecimal base p.getBasePrice(); BigDecimal regionAdd regionPriceMapper.selectAdd(productId, regionId); // 可能为 null BigDecimal finalPrice base.add(regionAdd null ? BigDecimal.ZERO : regionAdd); redis.opsForValue().set(key, finalPrice.toPlainString(), Duration.ofMinutes(5)); return applyMemberDiscount(finalPrice, memberLevel); } private BigDecimal applyMemberDiscount(BigDecimal price, Integer level) { // 0 普通 1 银卡 98 折 2 金卡 95 折配置化的话从字典表读 BigDecimal rate switch (level null ? 0 : level) { case 2 - new BigDecimal(0.95); case 1 - new BigDecimal(0.98); default - BigDecimal.ONE; }; return price.multiply(rate).setScale(2, RoundingMode.HALF_UP); }缓存 key 里带regionId不带memberLevel这个设计是有意为之。区域价对所有用户一样缓存命中率高会员折扣跟人走缓存进去等于给每个用户每个商品都存一份命中率暴跌不说用户升级会员后还可能读到旧折扣。金额一律用BigDecimalsetScale(2, HALF_UP)显式指定舍入方式别用默认的HALF_EVEN财务对账时那种差一分钱的单子最难查。4.2 缓存策略读多写少场景下的一致性底线装机页一屏能展示几十个配件每个都要显示价格不做缓存数据库会被打穿。5 分钟的 TTL 是个折中用户逛一圈的时间差不多就这个量级后台改价最坏情况下延迟 5 分钟生效对配件这种非秒杀品类完全可以接受。真正麻烦的是删缓存。后台改价事务提交后要清掉这个商品在所有区域的缓存但 Redis 的DEL不支持通配符写delete(price: productId :*)是不会生效的这是新手最容易翻车的地方。两种做法Transactional(rollbackFor Exception.class) public void updatePrice(Long productId, BigDecimal newPrice) { Product p new Product(); p.setId(productId); p.setBasePrice(newPrice); productMapper.updateById(p); // 事务提交后再清缓存避免事务回滚导致缓存被误删 TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() { Override public void afterCommit() { ListLong regionIds regionMapper.selectAllIds(); // 区域数量有限一般几十个 for (Long rid : regionIds) { redis.delete(price: productId : rid); } } }); }区域数量通常不超过三位数遍历删除完全够用比SCAN简单可靠。如果区域维度会膨胀到成千上万那就换思路给价格 key 加一个全局版本号price:ver读的时候拼进 key改价时INCR price:ver老 key 靠 TTL 自然淘汰。这个方案的好处是改价只写一次 Redis坏处是版本号一变全部缓存瞬间失效热点商品会有一波缓存击穿得配合互斥锁或者逻辑过期来兜。提示afterCommit这个时机很重要。如果在事务方法里直接删缓存事务后续回滚了数据库还是旧价、缓存却被删了下一次读会把旧价重新写回缓存看起来「没生效」排查起来极其费劲。4.3 后台改价与配置单快照老单子的金额不能跟着变回到第 2 章埋的那个伏笔。用户 A 三个月前配了一台机器分享给朋友看配置单显示 8299。这三个月里显卡涨了 800如果配置单明细只存product_id、展示时实时 join 商品表算钱那张分享出去的配置单会变成 9099。用户截图发给商家的报价单和网页上显示的对不上这种事解释起来非常消耗信任。snapshot_price和snapshot_spec就是解决这个问题的。明细表落库那一刻价格和规格一起冻结展示分享页时直接读快照不再碰商品表。代价是商品下架后老配置单仍然能正常展示这个「代价」其实正是想要的效果。需要一并冻结的还有规格尤其是影响理解的那些字段。比如内存频率后来从 6000 改成了 6400如果只冻结价格不冻结规格用户看到的配置单会显示一个他当初没选的参数。snapshot_spec存的是完整 JSON前端渲染时优先读快照快照缺失老数据才回落到商品表。这里还有个容易忽略的点后台改价要记录变更日志谁改的、什么时候改的、从多少改到多少。不是因为审计要求而是用户拿着三个月前的配置单来问「为什么当时是这个价」没有日志就只能靠猜。日志表加一张hw_price_log字段product_id / old_price / new_price / operator / created_at写起来十分钟省下来的扯皮时间不止十分钟。5. 部署与排查本地跑通到线上可用的踩坑记录5.1 从空库到能出配置单启动步骤拿到源码后按这个顺序走基本能在半小时内看到装机页。先建库导数据种子数据里包含配件分类和一批测试配件没有数据的话页面是空的容易误以为接口挂了# 1. 建库、导表结构、导种子数据 mysql -uroot -p -e CREATE DATABASE pc_builder DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci; mysql -uroot -p pc_builder server/src/main/resources/sql/schema.sql mysql -uroot -p pc_builder server/src/main/resources/sql/seed_category.sql mysql -uroot -p pc_builder server/src/main/resources/sql/seed_product.sql # 2. 后端改配置后打包启动 cd server mvn clean package -DskipTests java -jar target/pc-builder-server.jar --spring.profiles.activedev # 3. 前端vite 代理到 8080避免跨域 cd web npm install npm run devapplication-dev.yml里需要改的其实就三处数据源、Redis、文件上传路径server: port: 8080 spring: datasource: url: jdbc:mysql://127.0.0.1:3306/pc_builder?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/Shanghai username: root password: 你的密码 redis: host: 127.0.0.1 port: 6379 database: 0 pcb: upload-path: ./upload/ # 配件图片落盘目录线上要改成绝对路径 share-base-url: http://localhost:5173/build/ # 分享链接前缀share-base-url这个配置容易被忽略本地开发时它指向 5173上线要改成正式域名否则分享出去的链接全是 localhost用户点开一片空白。这类「本地能跑线上不行」的问题十有八九是配置没跟着环境走。前端vite.config.js里配了/api代理到http://127.0.0.1:8080打包部署到 Nginx 时要在 Nginx 里配一条等价的location /api/ { proxy_pass http://后端地址; }否则前端页面能打开但所有接口 404。这一条每年都要在群里回答几次。5.2 五个真实踩过的坑现象、原因、解决坑一选了 CPU 之后主板列表没过滤用户直接下了不兼容的单现象是装机页选完 CPU主板那一栏还是全部主板用户挑了个 AM5 的板子配 LGA1700 的 U保存时被后端拦住但他已经在页面上点了半天。原因是候选接口只按category_code查前端把selected参数拼上了但后端没读。修复是在 Controller 上加RequestParam(required false) ListLong selectedService 里按 3.1 节的逻辑推导过滤条件。改完之后还有个连带问题用户把 CPU 换成另一家的之前选的主板不会自动清空得在前端监听已选列表变化发现新选 CPU 的插槽跟已选主板不一致时弹窗提示并清空主板。坑二后台改了显卡价格用户三个月前的配置单金额跟着变了现象是用户投诉分享页金额和下单时不一样。原因是明细表只存了product_id展示时实时 join 商品表。解决方案是明细表加snapshot_price和snapshot_spec保存时冻结展示时优先读快照。历史数据用一条 SQL 回填UPDATE pc_build_item i JOIN hw_product p ON i.product_id p.id SET i.snapshot_price p.base_price WHERE i.snapshot_price IS NULL;回填的是当前价不完美但比空着强。坑三电源推荐功率算出来 547W用户一脸问号现象是提示文案写「建议电源不低于 547W」。原因是最初直接按load * 1.3算完就输出没做档位对齐。修复是把结果向上取整到 50W 的倍数Math.ceil(load * 1.3 / 50.0) * 50。顺带把「估算功耗」和「推荐电源」分开展示用户看到 420W 估算、550W 推荐能理解这是留了余量而不是觉得系统算错了。坑四Redis 里价格一直不更新等五分钟才恢复现象是后台改完价前台刷新还是旧价。原因是改价逻辑只更新了数据库没清缓存。第一版修复写成了redis.delete(price: productId :*)实际什么都没删。正确做法是按区域列表逐个删或者改用全局版本号方案。修的时候还发现另一个隐患清缓存写在了事务方法体内事务回滚时缓存已经被删了后来挪到afterCommit里才彻底干净。坑五配置单分享链接被批量爬取现象是上线两周后出现大量陌生 IP 访问分享页配置单 ID 明显是被顺着遍历的。原因是share_code早期版本直接用了自增 ID。修复是改成 8 位随机码UNIQUE KEY兜底对已有数据做一次批量生成。同时给分享页加了最简单的限流同一 IP 每分钟超过 60 次直接返回 429。这个改动本身不复杂复杂的是存量数据迁移得停机或者双写过渡所以一开始就把凭证设计对能省掉一次半夜加班。注意坑三和坑五看着是两类问题其实同源——都是「临时凑合的实现被当成了正式方案」。装机这种业务量不大但用户对准确性极敏感的系统宁可第一版多写二十行也不要留个 TODO 等线上出事。6. 二次开发从改一个分类到加一套比价逻辑想在这套系统上加东西先分清改的是「数据」还是「代码」。加一个新配件分类比如显示器、外设属于纯数据改造往hw_category插一行给这类配件定义好spec里的字段名后台管理页的规格表单是按分类配置渲染的加完就能录入。这种情况下唯一要动代码的地方是兼容性校验得决定显示器跟主机有没有约束关系——通常没有不写也行。真正需要动代码的是三类改造。第一类是加配件之间的新约束比如「水冷排尺寸与机箱顶部支持长度」做法是在CompatCheckService里加一个checkRadiator方法主流程里调用一行CompatIssue给 warn 级别别一上来就 error。第二类是接外部价格源常见做法是写个定时任务每天凌晨拉一次供应商报价落到hw_product.base_price之前先写hw_price_log同时触发 4.2 节的缓存清理。第三类是加比价功能在配件卡片上挂多个渠道的链接和价格这需要新增一张渠道价格表配置单总价仍然以系统价为准渠道价只做展示。验证改造有没有搞坏原有逻辑最省事的办法是建一组固定的测试用例覆盖典型的不兼容组合。这套工程里我加了一个CompatCheckServiceTest用 JSON 文件描述输入和期望的 issue 码跑起来一目了然ParameterizedTest MethodSource(cases) void check_should_return_expected_issue(CompatCase c) { ListBuildItem items c.getItems().stream().map(BuildItem::fromJson).toList(); ListString actual service.check(items).stream() .map(CompatIssue::getCode) .sorted() .toList(); assertEquals(c.getExpectCodes().stream().sorted().toList(), actual); }改完任何一条校验规则先跑这组用例再看有没有误伤。装机系统的校验规则是会长出来的今天加一条下个月商家又提一条没有回归用例三个月后谁也不敢动CompatCheckService。最后一个技巧也是我觉得这套工程里最值得抄的一点把兼容规则做成「数据驱动 代码兜底」的混合模式。简单的等值比较插槽、内存代数完全可以写进hw_compat_rule表字段是rule_type / left_cat / left_field / operator / right_cat / right_field / level / message运行时遍历规则表动态判断复杂的功耗估算、尺寸计算才留在代码里。这样商家自己就能在后台加「内存频率不得超过主板支持上限」这种规则不用每次都找你发版。这套东西我第一次写的时候图快把所有规则硬编码在 Service 里结果半年后规则膨胀到四十多条一个方法三百行改一条得通读一遍。从那以后我每次接手类似的校验模块都强制先把配置项和规则表设计出来再写逻辑哪怕当时只有三条规则。希望帮到你。本文还有配套的精品资源点击获取