☰
购车报价与文件管理系统:Spring Boot + Vue 实战
2026/10/22 4:05:51 网站建设 项目流程

在汽车销售和交付环节里,购车文件、配置、价格、折扣这四类数据很少是孤立存在的。客户选车时先看基础款价格,再决定要不要加装科技包、冬季轮胎或充电桩;销售给出一版报价后,还得把优惠、置换补贴和分期权益叠进去;到了交付阶段,合同、车辆合格证、保险单又要归档到同一个订单下。这个场景看起来简单,但实际做系统时,要比写一段 CRUD 复杂得多。

本文以示例车型“海獭 Racco”为业务对象,设计并实现一个购车报价与文件管理系统。需要提前说明的是,这里的车型名称仅用于演示业务逻辑,不指向任何真实品牌或产品。文中会重点讲清楚:配置项如何建模、价格如何计算、折扣如何叠加、购车文件如何归档,以及整套流程在数据库和后端接口中如何落地。阅读本文需要先熟悉 Spring Boot、MySQL 和 Vue 基础概念,如果只看后端,也完全可以跳过前端部分。

1. 先理解购车文件、配置、价格、折扣为什么需要系统化管理

1.1 业务场景:一份标准交付订单包含哪些数据

以一台电动车为例,客户从看车到提车,至少会经历选配、询价、下单、签约、交付五个阶段。每个阶段都会产生一批数据:

  • 车型基础信息:车型编码、车型名称、基础售价、保修政策。
  • 配置项信息:车身颜色、轮毂、电池容量、辅助驾驶包、内饰包、充电桩套装。
  • 价格信息:基础价、配置加装价格、优惠金额、税费、最终成交价。
  • 折扣信息:厂家促销、区域补贴、老客户优惠、限时权益。
  • 购车文件:订单合同、购车发票、车辆合格证、保险单、交付确认单。

在门店经营的初期,这些数据通常散落在 Excel 表格、企业微信群和纸质合同里。销售报出的价格可能和财务算出来的不一致,客户补交的证件照片也容易在聊天记录里被淹没。

系统的价值不在于把表格搬到网页上,而在于把“配置选择—价格计算—折扣生效—文件归档”这条链路串起来,让每一版报价都有依据,让每一份文件都挂在对应订单下。

1.2 系统要解决的四个核心问题

核心问题典型现状系统化后的目标
配置管理混乱不同销售对同一车型的选装包叫法不一致统一维护车型与配置项,允许前端按车型拉取可选配置
报价口径不统一有人算价格时加入赠品,有人不算由后端统一计算基础价、配置加价、折扣和最终价
折扣规则不可控优惠活动靠口头通知,容易过期或叠加错误使用折扣规则表管理有效期、优先级和叠加方式
购车文件分散合同、发票、证件照片存在不同设备里上传后按订单号归档,保留文件名、类型、上传时间

这四个问题对应四个核心数据域:车型配置、价格、折扣、订单文件。后续的数据库设计和接口设计都围绕它们展开。

2. 业务建模与数据库设计

2.1 车型与配置项建模

车型与配置项之间是多对多关系。一个车型可以有多个可选配置,一个配置项也可能适用于多个车型。为了避免每加一个车型就重复维护配置项,需要拆成三张表:

  • vehicle_model:车型表,保存基础信息。
  • vehicle_option:配置项表,保存配置名称和加装价格。
  • vehicle_model_option:车型与配置项关联表,保存某个车型支持哪些配置,以及该配置是否必选。

这里的关键是“加装价格”放在配置项表还是关联表。如果同一配置在不同车型上的价格不同,应该放在关联表;如果价格全球统一,放在配置项表更简单。实际项目中建议放在关联表,因为配置定价经常随车型调整,拆开后不会污染配置项主数据。

2.2 价格与折扣规则的建模

价格字段统一使用DECIMAL(10,2),不要用浮点数。金额计算必须精确,浮点数在减法、乘法之后会出现微小误差,虽然单个订单差距不大,但累计到月报、对账阶段会非常麻烦。

折扣规则需要支持多种形式,否则系统上线后每来一个活动就要改代码。最低限度要支持三种:

  • 满减:订单金额满 200000 减 5000。
  • 折扣率:订单总价打 9.5 折。
  • 直降:固定减 3000。

设计时增加rule_type、rule_value、priority、start_time、end_time、stackable字段。stackable表示是否允许与其他规则叠加,这是很多系统最容易忽略的点。如果两个活动都是大额优惠,又允许叠加,最终成交价可能变成负数。

2.3 订单与购车文件建模

订单表不需要把配置项以 JSON 字符串直接塞进去,但实际项目中常见做法是两种并存:

  • 订单关联vehicle_model_option的明细表,适合做库存统计。
  • 订单中保存一份配置快照 JSON,适合展示历史数据。

价格和配置在交付后可能调整,所以订单必须保存快照。否则三个月后客户投诉当时确认过某项配置,你翻数据库时发现配置表里的名称已经换了,很难说清责任。

购车文件表需要记录文件原始文件名、存储路径、文件类型和上传时间。文件本体建议不直接存在数据库,而是存到本地磁盘目录、NFS 或对象存储,数据库只保存路径和元数据。

2.4 建表 SQL 示例

CREATE TABLE vehicle_model ( model_code VARCHAR(32) PRIMARY KEY, model_name VARCHAR(64) NOT NULL, base_price DECIMAL(10,2) NOT NULL, status TINYINT NOT NULL DEFAULT 1, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ); CREATE TABLE vehicle_option ( option_code VARCHAR(32) PRIMARY KEY, option_name VARCHAR(64) NOT NULL, category VARCHAR(32) NOT NULL, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ); CREATE TABLE vehicle_model_option ( model_code VARCHAR(32) NOT NULL, option_code VARCHAR(32) NOT NULL, price DECIMAL(10,2) NOT NULL, required TINYINT NOT NULL DEFAULT 0, PRIMARY KEY (model_code, option_code) ); CREATE TABLE discount_rule ( rule_id BIGINT AUTO_INCREMENT PRIMARY KEY, rule_name VARCHAR(64) NOT NULL, rule_type VARCHAR(16) NOT NULL COMMENT 'FULL_REDUCE / RATE / DIRECT', rule_value DECIMAL(10,2) NOT NULL, priority INT NOT NULL DEFAULT 0, stackable TINYINT NOT NULL DEFAULT 1, start_time DATETIME NOT NULL, end_time DATETIME NOT NULL, status TINYINT NOT NULL DEFAULT 1 ); CREATE TABLE purchase_order ( order_id BIGINT AUTO_INCREMENT PRIMARY KEY, order_no VARCHAR(64) NOT NULL UNIQUE, model_code VARCHAR(32) NOT NULL, config_snapshot JSON NULL, base_price DECIMAL(10,2) NOT NULL, option_amount DECIMAL(10,2) NOT NULL DEFAULT 0, discount_amount DECIMAL(10,2) NOT NULL DEFAULT 0, final_price DECIMAL(10,2) NOT NULL, order_status VARCHAR(16) NOT NULL DEFAULT 'CREATED', created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ); CREATE TABLE order_file ( file_id BIGINT AUTO_INCREMENT PRIMARY KEY, order_id BIGINT NOT NULL, file_name VARCHAR(128) NOT NULL, file_path VARCHAR(256) NOT NULL, file_type VARCHAR(32) NOT NULL, upload_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP );

这里有一个学习环境可以直接用的简化方式:purchase_order.config_snapshot存 JSON,省去额外建订单配置明细表的步骤。生产环境如果要做库存、选装率分析,建议再建一张order_option_detail明细表。

3. 后端接口实现:配置查询、报价计算、文件归档

3.1 项目依赖与环境

后端使用 Spring Boot 3 + MyBatis-Plus,数据库使用 MySQL 8。以下版本组合用于说明思路,实际落地前要确认当前稳定版。

<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>com.baomidou</groupId> <artifactId>mybatis-plus-spring-boot3-starter</artifactId> <version>3.5.5</version> </dependency> <dependency> <groupId>com.mysql</groupId> <artifactId>mysql-connector-j</artifactId> <scope>runtime</scope> </dependency> <dependency> <groupId>org.projectlombok</groupId> <artifactId>lombok</artifactId> <optional>true</optional> </dependency>

配置文件application.yml:

spring: datasource: url: jdbc:mysql://localhost:3306/seaotter_car?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai username: root password: root driver-class-name: com.mysql.cj.jdbc.Driver servlet: multipart: max-file-size: 10MB max-request-size: 30MB mybatis-plus: configuration: map-underscore-to-camel-case: true global-config: db-config: id-type: auto

这里要特别注意serverTimezone。如果数据库连接串不指定时区,而部署机器的系统时区与数据库不同,start_time、end_time这类字段在查询时会与本地时间产生偏差,导致折扣提前或延后生效。建议统一使用Asia/Shanghai,并在应用层也设置相同时区。

3.2 查询车型配置接口

前端进入选配页时,需要一次性拿到车型基础价、可选配置列表和每个配置的加价。接口可以这样设计:

@RestController @RequestMapping("/api/model") public class ModelController { @Resource private ModelService modelService; @GetMapping("/config") public Result<ModelConfigVO> getModelConfig(@RequestParam String modelCode) { return Result.ok(modelService.getModelConfig(modelCode)); } }

ModelConfigVO包含三个部分:车型基础信息、必选配置、可选配置。查询时需要先查车型表,再查关联表,最后关联配置项表,避免前端反复请求。

这里推荐一个容易踩坑的点:不要直接使用vehicle_option表里的加装价,而要从vehicle_model_option表取价格,因为同一配置在不同车型上的价格可能不同。

3.3 报价计算接口

报价计算是整篇文章最核心的接口。它接收车型编码、用户选择的配置编码列表、适用的折扣规则编码列表,返回基础价、配置加价、优惠金额、最终价。

@Service public class QuoteService { public QuoteResult calculate(QuoteRequest request) { BigDecimal basePrice = modelMapper.selectById(request.getModelCode()).getBasePrice(); BigDecimal optionAmount = calculateOptionAmount(request.getModelCode(), request.getOptionCodes()); BigDecimal originalAmount = basePrice.add(optionAmount); BigDecimal discountAmount = calculateDiscount(originalAmount, request.getDiscountRuleIds()); BigDecimal finalPrice = originalAmount.subtract(discountAmount); if (finalPrice.compareTo(BigDecimal.ZERO) < 0) { throw new BizException("最终成交价不能小于0,请检查折扣规则是否叠加异常"); } return new QuoteResult(basePrice, optionAmount, discountAmount, finalPrice); } }

配置加价计算时,要校验每个配置编码是否真的适用于当前车型,避免前端传入了其他车型的配置编码。校验不通过的情况不能静默跳过,否则最终价格会比客户预期低,订单成交后才发现问题。

折扣计算按优先级排序,先执行高优先级规则,再执行低优先级规则。同一优先级下,如果规则不允许叠加,只取金额最大的一条。

实际项目中建议把折扣计算设计成策略模式,而不是一堆if...else。否则每增加一种活动类型,报价服务就要重新发布。

3.4 创建订单与上传购车文件

创建订单接口要做三件事:

  1. 根据订单中的配置快照重新计算一遍价格,不信任前端传过来的最终价。
  2. 生成唯一订单号。
  3. 保存订单数据。

这里有一个非常重要的原则:前端传入的价格只能用于展示,不能作为落库依据。必须由后端按数据库中的配置价格和折扣规则重新计算,否则销售在浏览器里改一下参数,就能改成交价。

上传文件接口:

@PostMapping("/api/order/file/upload") public Result<String> uploadFile( @RequestParam Long orderId, @RequestParam String fileType, @RequestParam("file") MultipartFile file) { String originalFilename = Objects.requireNonNull(file.getOriginalFilename()); String ext = StringUtils.getFilenameExtension(originalFilename); String datePath = LocalDate.now().toString().replace("-", ""); String fileName = orderId + "_" + System.currentTimeMillis() + "." + ext; String relativePath = "/uploads/" + datePath + "/" + fileName; File dir = new File(uploadRootPath, datePath); if (!dir.exists()) { dir.mkdirs(); } file.transferTo(new File(uploadRootPath + relativePath)); return Result.ok(relativePath); }

文件名要重命名,不要直接使用用户上传的原始文件名,避免路径穿越和重名覆盖。存储路径建议按日期分目录,既方便归档,也避免单个目录文件过多。

4. 前端页面:选配、报价试算与文件上传

4.1 页面结构与交互

前端使用 Vue 3 + Element Plus,核心页面只有一个,但包含三个区域:车型配置区、价格试算区、购车文件上传区。

用户交互流程:

  1. 输入或选择车型编码,页面请求/api/model/config。
  2. 渲染基础价、必选配置、可选配置。
  3. 用户勾选可选配置后,前端调用/api/quote/calculate获取最新报价。
  4. 用户确认订单,调用/api/order/create生成订单。
  5. 在订单详情页上传合同、发票、合格证等文件。

前端不直接计算价格,而是把配置变更后的结果交给后端计算。这样可以保证页面展示的价格和最终落库的价格一致。

4.2 配置选择与价格试算

页面中展示车型配置项的部分:

<template> <el-card> <template #header> <span>车型配置</span> </template> <div v-for="item in configList" :key="item.optionCode"> <el-checkbox :model-value="selectedOptions.includes(item.optionCode)" :disabled="item.required" @change="handleOptionChange(item, $event)" > {{ item.optionName }} <span v-if="item.required">(必选)</span> <span>+¥{{ item.price }}</span> </el-checkbox> </div> <el-divider /> <p>基础价:¥{{ basePrice }}</p> <p>配置加价:¥{{ optionAmount }}</p> <p>优惠金额:¥{{ discountAmount }}</p> <p>最终价:¥{{ finalPrice }}</p> </el-card> </template>

handleOptionChange里不能只改勾选状态,还要重新请求报价接口。如果用户快速连续勾选多个配置,会产生并发请求。比较稳妥的处理是使用防抖,或者用前端状态记录最后一次请求序号,响应返回时只更新最新一次结果。

async function handleOptionChange(option, checked) { if (checked) { selectedOptions.push(option.optionCode) } else { const index = selectedOptions.indexOf(option.optionCode) if (index > -1) selectedOptions.splice(index, 1) } const res = await getQuote(modelCode, selectedOptions, selectedDiscounts) quoteResult.value = res.data }

4.3 文件上传组件

Element Plus 的el-upload组件在设置action时,需要把后端地址和请求头中的 Token 一起配置好。实际项目里文件上传一般不走网关或者走独立网关,前端只需要关心上传成功后的返回值。

<el-upload :action="uploadUrl" :headers="uploadHeaders" :data="{ orderId: currentOrderId, fileType: 'contract' }" name="file" :limit="5" :on-success="handleUploadSuccess" :on-error="handleUploadError" > <el-button type="primary">上传合同文件</el-button> </el-upload>

文件上传成功后,后端返回相对路径。前端要把这个路径保存到订单详情展示区域,同时在下一次页面刷新时,仍然能从订单接口中读出文件列表。

5. 启动验证与预期结果

5.1 准备数据库

执行第 2 节的建表 SQL 后,插入一批演示数据。例如基础车型“海獭 Racco 标准版”基础价 198000 元,可选配置包括“冬季轮胎包”8000 元、“辅助驾驶包”15000 元、“充电桩套装”5000 元。

INSERT INTO vehicle_model (model_code, model_name, base_price) VALUES ('RACCO-STD', '海獭 Racco 标准版', 198000); INSERT INTO vehicle_option (option_code, option_name, category) VALUES ('OPT-SNOW', '冬季轮胎包', '性能'), ('OPT-ADAS', '辅助驾驶包', '智能'), ('OPT-CHARGER', '充电桩套装', '能源'); INSERT INTO vehicle_model_option (model_code, option_code, price) VALUES ('RACCO-STD', 'OPT-SNOW', 8000), ('RACCO-STD', 'OPT-ADAS', 15000), ('RACCO-STD', 'OPT-CHARGER', 5000);

5.2 启动后端

在项目根目录执行:

mvn spring-boot:run

启动成功后,控制台会出现 Spring Boot 的启动日志,表示端口 8080 已经在监听。

5.3 调用接口验证

查询车型配置:

curl "http://localhost:8080/api/model/config?modelCode=RACCO-STD"

预期返回中包含基础价 198000,可选配置列表 3 条。

调用报价计算:

curl -X POST "http://localhost:8080/api/quote/calculate" \ -H "Content-Type: application/json" \ -d '{ "modelCode": "RACCO-STD", "optionCodes": ["OPT-SNOW", "OPT-ADAS"], "discountRuleIds": [1] }'

如果没有配置任何折扣规则,预期结果是:

{ "basePrice": 198000.00, "optionAmount": 23000.00, "discountAmount": 0.00, "finalPrice": 221000.00 }

5.4 预期结果

检查项预期结果
查询车型配置返回车型基础价与可选配置
勾选冬季轮胎包和辅助驾驶包配置加价变为 23000,最终价变为 221000
上传合同文件返回文件相对路径,数据库 order_file 新增一行
订单详情能读出配置快照、最终价、文件列表

这里要提醒一点:不要只验证“接口返回 200”,还要验证金额数字、文件路径、数据库记录是否都正确。很多上线事故就是接口状态码正常、但业务字段错误。

6. 常见问题与排查路径

6.1 报价计算结果与 Excel 对不上

现象:同一台车,系统算出的最终价和销售原有 Excel 表差了几千元。

排查链路:

  1. 先核对基础价是否一致,重点看车型编码对应的是不是同一款车。
  2. 再核对配置加价,前端选中的配置是否在后端按vehicle_model_option表的单价计算。
  3. 最后核对折扣规则,Excel 里可能存在手工优惠,而系统里没有录入对应规则。

多数情况下,问题出在“Excel 里有人工折扣”或“配置价格不同步”。解决方式是规定所有折扣必须通过规则表配置,不允许后台手工改价。如果业务上确实需要特批价格,也要在建单时留下审批记录,而不是直接覆盖最终价。

6.2 折扣不生效

现象:用户下单时选择了折扣,但最终价没有变化。

检查方式:

SELECT rule_id, rule_name, rule_type, rule_value, priority, stackable, start_time, end_time, status FROM discount_rule WHERE rule_id = 1;

重点看三个字段:status是否为 1,当前时间是否在start_time和end_time之间,stackable是否允许叠加。时间判断要非常小心,如果数据库存的是DATETIME,应用连接字符串没有指定时区,可能出现偏差。

6.3 上传文件后无法访问

现象:文件上传接口返回成功,但是浏览器访问http://localhost:8080/uploads/...返回 404。

原因通常是 Spring Boot 没有配置静态资源映射。解决方式是在配置类中增加:

@Configuration public class WebConfig implements WebMvcConfigurer { @Value("${file.upload-root-path}") private String uploadRootPath; @Override public void addResourceHandlers(ResourceHandlerRegistry registry) { String location = "file:" + uploadRootPath + "/"; registry.addResourceHandler("/uploads/**").addResourceLocations(location); } }

生产环境一般不让应用进程直接服务文件,而是上传到对象存储,再用 CDN 或对象存储自带域名访问。本地映射只适合学习和联调。

6.4 折扣叠加后最终价出现负数

现象:同时选用满减、打折、直降三种规则后,最终价变成负数。

处理原则:

  • 在discount_rule表增加stackable字段,默认只允许同优先级且同类型的规则叠加。
  • 在报价计算服务的末尾增加金额下限校验,最终价不能小于 0,也不能小于一个合理值。
  • 对异常情况记录日志,包含订单号、规则编码、规则值、最终价,方便复盘。

7. 生产环境最佳实践与扩展方向

7.1 上线前检查清单

检查项建议
数据库连接时区统一使用Asia/Shanghai,避免折扣时间判断错误
金额字段类型数据库用DECIMAL,Java 用BigDecimal,前端金额展示用分转元或字符串
订单价格计算后端必须重算,不信任前端传入的最终价
文件上传限制限制文件大小、类型,文件名重命名,存储路径分日期
折扣规则叠加增加stackable字段和最终价下限校验
日志记录报价计算过程的输入、输出和所使用规则,方便对账
权限配置维护、折扣维护、文件下载需要独立权限
回滚配置和折扣表变更前做备份,订单表不物理删除,只做状态流转

7.2 价格与折扣的版本化

车型价格、配置价格、折扣规则都会随时间调整。如果一张表直接更新价格,历史订单的报价就失去依据。生产环境建议:

  • 价格调整时给vehicle_model、vehicle_model_option增加版本号。
  • 订单保存价格快照和配置快照,并记录当时使用的版本号。
  • 折扣规则不要修改原有记录的有效期,活动结束后直接更新end_time,不要删除。

这样既能保证历史可追溯,也能让财务报表按版本号核对订单价格。

7.3 文件存储与审计

学习环境可以把文件存在本地,生产环境建议使用对象存储。购车文件属于交易凭证,需要额外的审计要求:

  • 上传完成后记录操作人、时间、文件哈希。
  • 下载操作要记录日志,避免客户证件照片被无关人员查看。
  • 合同、合格证、发票等文件要设置访问权限,不能允许匿名访问。

7.4 扩展方向

当前系统已经覆盖了文件、配置、价格、折扣四个核心域,下一步可以从三个方向扩展:

扩展方向要解决的问题
审批流特权折扣、异常文件上传、订单改价需要多级审批
规则引擎当优惠活动类型增多时,用独立规则引擎替代硬编码计算
消息通知订单创建后通知客户签署电子合同,文件上传后通知财务复审

如果只是做一个门店内部报价工具,当前设计已经足够。如果要开放给客户自助下单,还需要增加用户体系、电子签章、支付流水和库存占用,复杂度会明显上升。建议先跑通内部流程,再逐步开放。

整个系统的核心并不复杂,真正影响稳定性的地方在于:价格必须由后端重算、折扣必须有优先级和叠加限制、文件必须归档到订单下。把这三点做好,购车文件、配置、价格、折扣的管理就不会再依赖 Excel 和人工记忆。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询