1. 先把这个行业痛点说透:三七的“产地”为什么值钱
三七这味药材,懂行的人都知道一句话:产地基本决定品质。它的生长周期最少三年起步,对海拔、土壤成分、积温、湿度都极度敏感,同一种苗换个地方栽,有效皂苷成分的含量能拉开很大差距。也正因为如此,“是不是原产地的货”就成了买卖双方最关心的核心问题,也是整个交易链条里最容易埋雷的地方。
我最早接触这个领域是被一个做中药材电商的朋友拉去调研的。当时在线下批发市场转了几天,发现一个很普遍的现象:不少贴着“产地直供”标签的货,实际已经转了三四手,中间商根本拿不出任何可信凭证来证明货源到底来自哪里。买家只能看外观、闻气味、凭经验判断,这里面能操作的空间实在太大了。后来我们自己采购也踩过类似的坑,花了一级品的价钱,收到的货明显是普通产区冒充的。从那一刻起我就意识到,三七交易缺的不是另一个“卖货网站”,而是一个能把产地信息数据化、可验证、可追溯的销售平台。
这个基于 Spring Boot 的三七原产地销售平台,核心目标就两件事:第一,把“产地”从一句广告语变成一串可查询、可校验的数据记录;第二,缩短交易链条,让种植端能够直接面向终端消费者。平台跑通后的业务闭环大概是这样的——产地端按批次录入种植、采收、加工信息,系统为每个批次生成唯一的溯源标识,商品上架后全部来源信息全程可见,买家下单支付后,每个关键节点都留痕。
这篇文章适合两类人看:一类是想找个完整电商业务来练手的 Java 后端开发者,另一类是打算给特色农产品搭建线上销售渠道的技术团队。前者我会重点讲代码思路和实现细节,后者可以重点关注业务建模和流程设计。两条线我都会覆盖到。
2. 技术选型复盘:Spring Boot 在这个场景里为什么是合适的选择
我先直接给出最终用到的技术栈清单,再逐条解释选型理由,这样大家在考虑自己项目时可以对照参考。
| 层面 | 选型 | 说明 |
|---|---|---|
| 后端框架 | Spring Boot 2.x | 快速搭建工程,生态成熟,社区资料丰富 |
| ORM | MyBatis-Plus | 单表 CRUD 免写 SQL,复杂查询用 XML 兜底 |
| 数据库 | MySQL 8.x | 业务数据主存储,InnoDB 引擎 |
| 缓存 | Redis | 热点商品缓存、登录 Token、库存预扣 |
| 前端 | Vue 2 + Element UI | 后台管理端为主,移动端 H5 做溯源展示页 |
| 鉴权 | JWT | 前后端分离接口的无状态鉴权 |
| 支付 | 第三方支付网关 SDK | 按常规流程接入,回调做幂等处理 |
| 部署 | Docker + Nginx | 环境一致性 + 静态资源分离和反向代理 |
为什么没有一上来就上微服务?这是很多同行看到项目规模后问的第一个问题。我的判断很简单:团队规模小、业务链路清晰、单体应用完全撑得住。微服务带来的服务拆分、分布式事务、链路追踪、多环境部署成本,对于这个阶段的项目来说纯粹是负担。后来平台进入增长期,也没有遇到必须靠拆分才能解决的瓶颈,说明这个决策是对的。
选 Spring Boot 还有一个现实原因:排错成本低。Spring Boot 的默认配置已经覆盖了大部分常见场景,启动快、错误提示明确、和 Redis、MyBatis-Plus、Docker 的整合方案都极其成熟。出了问题网上基本能找到对应解法,对团队新人也友好。相比之下,一些轻量级框架虽然启动更快、内存占用更低,但遇到需要整合定时任务、分布式锁、消息队列的场景时,就得自己造轮子,维护成本反而更高。
架构上我分了三层:
- 接入层:Nginx 处理静态资源、Gzip 压缩、反向代理到后端;
- 应用层:Spring Boot 单体应用,按业务模块分包,标准 Controller / Service / Mapper 结构;
- 数据层:MySQL 作为业务主库,Redis 负责缓存和热数据操作。
考虑到后台管理端和 C 端商城共用一套后端,我把接口做了路径区分:/admin/**走管理鉴权,/api/**走用户鉴权,这样两套权限模型互不干扰,又复用同一套业务 Service 逻辑。
3. 数据库建模:让“产地”成为贯穿业务的主线
做农产品电商,最忌讳的就是把商品表设计成一个大宽表,把所有信息不分主次地堆在一起。尤其是三七这种强产地属性的商品,产地不应该只是一个字符串字段,而应该是一个能追溯、能反查、能独立管理的主体。
最终核心表拆成了六张:产地表、批次表、商品表、溯源码表、订单表、订单明细表。下面逐个讲设计思路。
3.1 产地、批次与溯源码表的设计
产地表存的是基础档案:产区编码、产区名称、海拔区间、土壤类型、负责人。批次表则对应一次采收或加工的单位,和产地表是多对一关系,记录采收年份、加工方式、检测报告编号。这里把产区和批次拆开,是为了让同一个产地的不同批次可以独立管理。如果某一个批次出了问题,可以把它单独下架,完全不影响产地里其他商品的正常销售。
溯源码表是整个平台信任体系的底座。每个批次录入信息后,系统就生成一条溯源主记录,包含溯源编号、批次 ID、产地 ID、商品 ID、生成时间和扫码次数。这里有个细节值得注意:我刻意把溯源编号设计成组合规则——产地编码(4 位)+ 批次序号(6 位)+ 采收年份(4 位)+ 校验位(2 位)。这样做的最大好处是,运营人员拿到编号不用查库,光看编码结构就能大致判断来源;排查问题、处理客诉时效率会高很多。
3.2 商品表与订单表的设计取舍
商品表本质上是 SKU。核心字段包括商品名称、规格(三七头数)、加工方式、单价、库存、产地 ID、批次 ID、上下架状态。这里要重点解释“头数”的概念:三七行业用“500 克有多少个”来定等级,20 头就是 500 克里有 20 个左右的三七,数字越小代表单体越大、等级越高。这个字段在商品表里必须是独立列,不能塞在描述文本里,因为后面列表页要按头数做筛选和排序,如果设计成文本字段,SQL 会写得很痛苦。
订单表和订单明细表是一对多关系。订单明细里我冗余了商品名称、单价、商品 ID、溯源码编号。很多人会问:为什么订单明细里不直接关联查询商品表和溯源码表?原因是订单生效之后商品有可能下架,但买家依然需要凭订单详情里的溯源信息去验证真伪,如果依赖关联查询,一旦商品被删,订单记录就变成“死数据”了。做电商业务,订单快照意识一定要有。
3.3 核心建表语句参考
直接贴一段简化后的 DDL,实际业务中还会有用户表、收货地址表、支付回调记录表、操作日志表等,这里只展示主体部分:
CREATE TABLE tb_origin ( id BIGINT PRIMARY KEY AUTO_INCREMENT, origin_code VARCHAR(10) NOT NULL UNIQUE COMMENT '产地编码', origin_name VARCHAR(64) NOT NULL COMMENT '产区名称', altitude VARCHAR(32) COMMENT '海拔区间', soil_type VARCHAR(32) COMMENT '土壤类型', manager VARCHAR(32) COMMENT '负责人', create_time DATETIME DEFAULT CURRENT_TIMESTAMP ); CREATE TABLE tb_batch ( id BIGINT PRIMARY KEY AUTO_INCREMENT, origin_id BIGINT NOT NULL, batch_no VARCHAR(32) NOT NULL COMMENT '批次号', harvest_year VARCHAR(4) COMMENT '采收年份', process_method VARCHAR(16) COMMENT '加工方式', report_no VARCHAR(64) COMMENT '检测报告编号', UNIQUE KEY uk_batch_no (batch_no) ); CREATE TABLE tb_product ( id BIGINT PRIMARY KEY AUTO_INCREMENT, product_name VARCHAR(128) NOT NULL, head_count INT COMMENT '三七头数', price DECIMAL(10,2) NOT NULL, stock INT NOT NULL DEFAULT 0, origin_id BIGINT NOT NULL, batch_id BIGINT NOT NULL, status TINYINT DEFAULT 1 COMMENT '1上架 0下架', KEY idx_origin_batch (origin_id, batch_id) ); CREATE TABLE tb_traceability ( id BIGINT PRIMARY KEY AUTO_INCREMENT, trace_code VARCHAR(32) NOT NULL UNIQUE COMMENT '溯源码', batch_id BIGINT NOT NULL, origin_id BIGINT NOT NULL, product_id BIGINT NOT NULL, scan_count INT DEFAULT 0, create_time DATETIME DEFAULT CURRENT_TIMESTAMP ); CREATE TABLE tb_order ( id BIGINT PRIMARY KEY AUTO_INCREMENT, order_no VARCHAR(32) NOT NULL UNIQUE, user_id BIGINT NOT NULL, total_amount DECIMAL(10,2) NOT NULL, status TINYINT NOT NULL COMMENT '0待支付 1已支付 2已发货 3已完成 4已取消', pay_time DATETIME, create_time DATETIME DEFAULT CURRENT_TIMESTAMP, KEY idx_user (user_id) ); CREATE TABLE tb_order_item ( id BIGINT PRIMARY KEY AUTO_INCREMENT, order_id BIGINT NOT NULL, product_id BIGINT NOT NULL, product_name VARCHAR(128) NOT NULL, price DECIMAL(10,2) NOT NULL, quantity INT NOT NULL, trace_code VARCHAR(32) COMMENT '冗余溯源码' );索引方面有三个容易忽略的点:一是商品表的(origin_id, batch_id)联合索引,因为后台运营最常按产地过滤商品,这个索引命中率极高;二是order_no的唯一索引,这是后面支付幂等设计的基础;三是trace_code的唯一索引,防止并发场景下生成重复溯源编号。
4. 核心链路实现:溯源、检索、下单、支付回调
4.1 溯源编号的生成与二维码展示
溯源编号的生成逻辑本身不复杂,难在批量生成时保证不重复、不碰撞。我用 Redis 的INCR命令维护一个按批次自增的序列,再拼上产地编码和年份,最后加一个校验位。校验位的算法是把前面所有数字累加后对 10 取模,这样扫码端可以快速判断编号是否合法,也算是一道非常轻量的防伪逻辑。
public String generateTraceCode(String originCode, Long batchId) { Long seq = redisTemplate.opsForValue().increment("trace:seq:" + batchId); String raw = originCode + String.format("%06d", seq) + String.valueOf(LocalDate.now().getYear()); int check = 0; for (char c : raw.toCharArray()) { if (Character.isDigit(c)) { check = (check + (c - '0')) % 10; } } return raw + check; }生成后在后台管理端用二维码库把溯源编号输出为二维码图片,印刷到包装上。消费者扫码进入一个 H5 展示页,可以看到该批次对应的种植产区、采收年份、加工方式、检测报告截图。这个 H5 页面不需要强制登录,因为溯源信息是给最终消费者看的,用户的每一次扫码都会被记录到scan_count字段里,采购方可以通过扫码次数判断商品流通情况,也算是附加的价值。
4.2 商品多条件检索的动态 SQL 思路
商品列表页常见的筛选条件有这些:产地、头数、加工方式、价格区间、采收年份。如果全部靠拼接 SQL 或者堆一层又一层的OR条件,代码会越来越难维护,而且一旦数据量上来,查询性能会先出问题。
我的做法是定义一个查询 DTO,用 MyBatis-Plus 的LambdaQueryWrapper动态构建条件。这里最关键的原则是:条件字段必须走索引,不要在 SQL 里对索引列做函数运算,比如YEAR(create_time) = ?这种写法尽量避开,否则索引就失效了。
LambdaQueryWrapper<TbProduct> wrapper = Wrappers.lambdaQuery(); wrapper.eq(dto.getOriginId() != null, TbProduct::getOriginId, dto.getOriginId()) .eq(dto.getHeadCount() != null, TbProduct::getHeadCount, dto.getHeadCount()) .between(dto.getMinPrice() != null, TbProduct::getPrice, dto.getMinPrice(), dto.getMaxPrice()) .eq(TbProduct::getStatus, 1) .orderByDesc(TbProduct::getCreateTime); List<TbProduct> products = productMapper.selectList(wrapper);查询结果返回后再批量去 Redis 里取每个商品的热度值,把热销商品在排序上往上提几位。这种“MySQL 查主体数据 + Redis 补充热度排序”的组合在初期非常实用,不用引入搜索中间件就能满足需求。
4.3 下单与库存扣减的原子性
订单链路里最容易踩的坑是“先扣库存还是先生成订单”。我的方案是:先预扣库存,再创建订单,下单失败就把库存回补。核心扣减动作用条件更新实现,把“检查库存”和“扣减库存”合并成一条 SQL:
int updated = productMapper.deductStockWithCondition(productId, quantity); // 对应 SQL: // UPDATE tb_product SET stock = stock - #{quantity} // WHERE id = #{productId} AND stock >= #{quantity} if (updated == 0) { throw new BusinessException("库存不足或商品已下架"); }为什么不先SELECT stock判断再UPDATE?因为查和扣之间有时间差,并发场景下两个请求都读到库存充足,就会同时执行扣减,超卖就这么发生了。条件更新把这两个步骤合并成一个原子操作,问题从根源上被堵住。
注意一个前提:库存扣减和订单创建必须在同一个事务里。我是把deductStockWithCondition和insertOrder放在同一个 Service 方法内,用@Transactional保证要么都成功,要么都回滚,否则会出现库存扣了但订单没建成的数据错乱。
4.4 支付回调的幂等处理
支付回调是电商系统里“看起来简单、实际坑最多”的地方。第三方支付网关为了保证通知送达,会有超时重试机制,同一个支付结果可能被推送多次;如果处理逻辑不做幂等,订单状态会被反复更新,严重时甚至出现重复发货。
我的处理方式分为两步。第一步,维护一张支付回调记录表,唯一键是“订单号 + 支付流水号”。回调进来先尝试插入记录,如果插入冲突说明这条回调已经处理过,直接返回成功,不再往下执行:
public void handlePayCallback(PayNotify notify) { try { payCallbackMapper.insert(buildCallbackRecord(notify)); } catch (DuplicateKeyException e) { log.info("重复支付回调, 订单号: {}", notify.getOrderNo()); return; } orderService.markPaid(notify.getOrderNo()); }第二步,订单状态的更新必须走状态机校验。我定义了一张合法的状态流转表,只允许“待支付”迁移到“已支付”,“已支付”迁移到“已发货”,以此类推。任何不在路径内的状态变更直接拒绝并告警。这一步能防住另一个典型问题——回调乱序,比如先收到“支付成功”再收到“订单关闭”,如果都无脑执行,订单状态就会被改坏。
5. 实战踩坑记录:三个让我熬夜修到凌晨的问题
5.1 限量活动超卖二十多单的排查过程
第一次压测就暴露了问题。当时为了拉新用户,做了一个前一百名下单立减的活动。压测脚本一跑,后台订单数据直接多出了二十多单超过活动名额的订单。
排查链路是这样的:先看日志,发现大量请求同时通过了库存预扣;然后去看 SQL,发现问题出在最初的扣减语句上没有加stock >= quantity条件,相当于无条件扣减,库存直接扣成负数,超卖顺理成章地发生了。
修复方式就是前面讲的条件更新。另外,考虑到活动期间接口压力大,我在 Redis 里维护了一个活动库存的预扣数字,用 Lua 脚本保证扣减的原子性,MySQL 里的库存作为最终一致的数据源。活动期间的扣减全部走 Redis,异步把结果同步回 MySQL。这个方案在后续几次活动中都很稳定。
提示:凡是涉及数字增减的更新操作,都尽量写成条件更新。别再用“先查出来、在代码里改、再写回去”的老思路,并发场景下那三条语句之间的空隙,就是超卖和脏数据的温床。
5.2 支付回调乱序把订单状态改坏
上线第二天就接到运营反馈:有一个用户支付成功,但订单一直显示“待支付”。查了半天订单和支付记录,最后发现是回调乱序导致的。
整个过程是这样的:用户支付成功,支付网关先推送了“支付成功”的通知,状态机把订单从“待支付”改成了“已支付”;紧接着又来了一条“订单超时未支付关闭”的通知,而超时关单逻辑里没有判断当前订单状态,直接把已支付订单改成了“已取消”。
修复方案就是上一节说的状态机硬校验。我定义了一个Map存放合法的状态流转路径,不在路径内的变更直接抛出异常并触发告警。从那以后,这类乱序通知再也没有造成过订单状态错乱。
5.3 图片上传把后台接口拖到十几秒
溯源信息里需要上传种植基地的实拍照片,产地那边网络环境普遍一般,经常是十几 MB 的原始图片直接传到服务器,一次传四五张,后台接口响应时间直接飙到十几秒,运营根本没法正常工作。
处理方案分三层。第一层,前端在上传前先做图片压缩,长边超过 2000 像素就等比缩小;第二层,后端限制文件大小,超过 5M 的请求直接返回提示,拒绝接收;第三层,把上传接口改成异步处理,图片先存到临时目录,立即返回一个任务 ID,处理完成后通过 WebSocket 通知前台刷新状态。这样一改,运营反馈后台操作流畅多了,数据库压力也跟着降了下来。
6. 运行了几个月后的复盘与扩展打算
平台上线跑了几个月,有几点体会比较深,写下来给后来人参考。
第一,溯源数据本身就是信任资产。刚开始运营团队觉得每次上架都要传产地照片、检测报告,流程太繁琐,没少抱怨。后来数据出来了:带完整溯源信息的商品,转化率明显高于普通商品,复购率更是高出一截。消费者的心理其实很简单——他愿意为“看得见的来源”多花钱,这就是产地对三七的溢价逻辑。
第二,数据比想象中有用。通过订单数据,我们能看到哪个头数规格最受欢迎、哪个产区的货在哪些区域卖得更好、什么样的加工方式接受度更高。这些反馈给合作农户之后,对方调整种植和加工结构就有了具体依据,不再靠猜。
第三,技术选型不需要追新。这套 Spring Boot 单体的技术栈放到现在不算新鲜,但它足够稳定,团队上手快,业务迭代一直没有被技术拖后腿。相比那些一上来就把架构搞得特别复杂的项目,老老实实把单体做好、把数据模型理清,反而走得快。
6.1 如果重新做一遍,我会改什么
一个是库存模块。当时把库存逻辑散落在商品和订单模块里,后期做活动和限量抢购时,发现代码有些绕。如果重新设计,我会从第一天就把库存做成独立的能力模块,统一暴露预扣、释放、确认三个接口,这样后续扩展会轻松很多。
另一个是对象存储。前期图省事把图片存在本地磁盘,后面数据迁移和备份花了不少精力。如果需求允许,应该尽早接入对象存储服务,把图片、附件这类非结构化数据和业务数据彻底分离。
6.2 后续还想做的两个方向
平台后续可以做的事情还有不少。一个是针对大宗采购客户的产地直供拍卖模块,让采购方直接对某个批次的货进行竞价,把碎片化的交易变成更高效的撮合;另一个是把溯源链路延伸到物流环节,签收时再次扫码确认,形成种植、加工、流通、消费的完整闭环。
这些方向能不能落地,取决于业务侧的需求优先级,但至少在技术层面,当前这套基于 Spring Boot 的架构已经为它们留好了扩展空间。做这类平台,最怕的就是一开始把系统框死,等业务想清楚了,技术却接不住了。