农产品销售管理系统是连接农业生产者与消费者的重要数字化桥梁。在传统农产品流通环节中,普遍存在信息不对称、交易效率低下、质量追溯困难等问题。我们开发的这套系统采用SpringBoot+Vue技术栈,旨在构建一个高效、透明、易用的农产品在线交易平台。
从实际需求来看,系统需要解决三个核心痛点:
技术选型上,我们采用前后端分离架构,主要基于以下考虑:
系统采用经典的三层架构设计,各层职责明确:
code复制表示层(Vue.js)
│
├─ 用户界面组件
├─ 路由管理
└─ 状态管理(Vuex)
业务逻辑层(SpringBoot)
│
├─ 控制器(Controller)
├─ 服务(Service)
└─ 数据访问(DAO)
数据持久层(MySQL)
│
├─ 商品信息表
├─ 订单表
└─ 用户表
前后端通过RESTful API进行数据交互,接口设计遵循以下原则:
农产品销售场景对数据一致性要求较高,我们在数据库设计中特别注意:
sql复制CREATE TABLE `product` (
`id` bigint(20) NOT NULL AUTO_INCREMENT,
`name` varchar(100) NOT NULL COMMENT '商品名称',
`category_id` int(11) NOT NULL COMMENT '分类ID',
`farm_id` bigint(20) NOT NULL COMMENT '农户ID',
`price` decimal(10,2) NOT NULL COMMENT '单价',
`unit` varchar(10) NOT NULL COMMENT '计量单位',
`stock` decimal(10,3) NOT NULL COMMENT '库存数量',
`harvest_date` date DEFAULT NULL COMMENT '采收日期',
`shelf_life` int(11) DEFAULT NULL COMMENT '保质期(天)',
`origin_place` varchar(200) DEFAULT NULL COMMENT '产地',
`quality_cert` varchar(255) DEFAULT NULL COMMENT '质检证书',
PRIMARY KEY (`id`),
KEY `idx_category` (`category_id`),
KEY `idx_farm` (`farm_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
sql复制CREATE TABLE `order` (
`id` bigint(20) NOT NULL AUTO_INCREMENT,
`order_no` varchar(32) NOT NULL COMMENT '订单编号',
`buyer_id` bigint(20) NOT NULL COMMENT '买家ID',
`total_amount` decimal(12,2) NOT NULL COMMENT '订单总额',
`payment_status` tinyint(4) NOT NULL DEFAULT '0' COMMENT '支付状态',
`delivery_status` tinyint(4) NOT NULL DEFAULT '0' COMMENT '物流状态',
`create_time` datetime NOT NULL COMMENT '下单时间',
`update_time` datetime NOT NULL COMMENT '更新时间',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_order_no` (`order_no`),
KEY `idx_buyer` (`buyer_id`),
KEY `idx_create_time` (`create_time`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
提示:农产品订单需要特别注意时效性管理,我们在业务逻辑层实现了自动取消超时未支付订单的定时任务。
商品管理是系统的核心功能之一,我们实现了:
后端核心代码采用Spring Data JPA实现:
java复制@Service
@Transactional
public class ProductServiceImpl implements ProductService {
@Autowired
private ProductRepository productRepository;
@Override
public Page<Product> searchProducts(ProductQuery query, Pageable pageable) {
Specification<Product> spec = (root, query, cb) -> {
List<Predicate> predicates = new ArrayList<>();
if (StringUtils.isNotBlank(query.getKeyword())) {
predicates.add(cb.like(root.get("name"), "%" + query.getKeyword() + "%"));
}
if (query.getCategoryId() != null) {
predicates.add(cb.equal(root.get("category").get("id"), query.getCategoryId()));
}
if (query.getMinPrice() != null) {
predicates.add(cb.ge(root.get("price"), query.getMinPrice()));
}
if (query.getMaxPrice() != null) {
predicates.add(cb.le(root.get("price"), query.getMaxPrice()));
}
return cb.and(predicates.toArray(new Predicate[0]));
};
return productRepository.findAll(spec, pageable);
}
}
订单模块采用状态模式设计,主要状态包括:
状态转换通过Spring状态机实现:
java复制@Configuration
@EnableStateMachineFactory
public class OrderStateMachineConfig extends EnumStateMachineConfigurerAdapter<OrderState, OrderEvent> {
@Override
public void configure(StateMachineStateConfigurer<OrderState, OrderEvent> states) throws Exception {
states
.withStates()
.initial(OrderState.PENDING_PAYMENT)
.states(EnumSet.allOf(OrderState.class));
}
@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.CONFIRM);
}
}
针对农产品销售场景中常见的图片上传需求,我们做了以下优化:
javascript复制function compressImage(file, quality = 0.8) {
return new Promise((resolve) => {
const reader = new FileReader()
reader.onload = (event) => {
const img = new Image()
img.onload = () => {
const canvas = document.createElement('canvas')
const ctx = canvas.getContext('2d')
canvas.width = img.width
canvas.height = img.height
ctx.drawImage(img, 0, 0, canvas.width, canvas.height)
canvas.toBlob(
(blob) => resolve(new File([blob], file.name, { type: 'image/jpeg' })),
'image/jpeg',
quality
)
}
img.src = event.target.result
}
reader.readAsDataURL(file)
})
}
java复制@PostMapping("/upload/chunk")
public ResponseEntity<?> uploadChunk(
@RequestParam("file") MultipartFile file,
@RequestParam("chunkNumber") int chunkNumber,
@RequestParam("totalChunks") int totalChunks,
@RequestParam("identifier") String identifier) {
String tempDir = System.getProperty("java.io.tmpdir") + "/upload_tmp/" + identifier;
File chunkFile = new File(tempDir, chunkNumber + ".part");
try {
FileUtils.forceMkdirParent(chunkFile);
file.transferTo(chunkFile);
if (chunkNumber == totalChunks) {
// 所有分片上传完成,合并文件
File outputFile = mergeChunks(tempDir, totalChunks);
return ResponseEntity.ok().body(doFinalSave(outputFile));
}
return ResponseEntity.ok().build();
} catch (IOException e) {
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build();
}
}
针对农产品价格波动频繁的特点,我们设计了多级缓存:
java复制@Configuration
public class CacheConfig {
@Bean
public CacheManager cacheManager() {
CaffeineCacheManager cacheManager = new CaffeineCacheManager();
cacheManager.setCaffeine(Caffeine.newBuilder()
.initialCapacity(100)
.maximumSize(1000)
.expireAfterWrite(5, TimeUnit.MINUTES)
.recordStats());
return cacheManager;
}
}
java复制@Service
public class CategoryCacheServiceImpl implements CategoryCacheService {
@Autowired
private RedisTemplate<String, Object> redisTemplate;
private static final String CATEGORY_KEY = "product:categories";
private static final long CACHE_EXPIRE = 3600 * 24; // 24小时
@Override
public List<Category> getAllCategories() {
List<Object> cached = redisTemplate.opsForList().range(CATEGORY_KEY, 0, -1);
if (cached != null && !cached.isEmpty()) {
return cached.stream()
.map(obj -> (Category) obj)
.collect(Collectors.toList());
}
List<Category> categories = categoryRepository.findAll();
if (!categories.isEmpty()) {
redisTemplate.opsForList().rightPushAll(CATEGORY_KEY, categories.toArray());
redisTemplate.expire(CATEGORY_KEY, CACHE_EXPIRE, TimeUnit.SECONDS);
}
return categories;
}
}
农产品交易涉及资金流动,我们实施了多重安全防护:
java复制@Aspect
@Component
public class RateLimitAspect {
private final RateLimiter rateLimiter = RateLimiter.create(100); // 每秒100个请求
@Around("@annotation(rateLimit)")
public Object around(ProceedingJoinPoint joinPoint, RateLimit rateLimit) throws Throwable {
if (rateLimiter.tryAcquire()) {
return joinPoint.proceed();
}
throw new BusinessException("请求过于频繁,请稍后再试");
}
}
xml复制<select id="searchProducts" resultType="Product">
SELECT * FROM product
WHERE 1=1
<if test="name != null and name != ''">
AND name LIKE CONCAT('%', #{name}, '%')
</if>
<if test="minPrice != null">
AND price >= #{minPrice}
</if>
ORDER BY create_time DESC
</select>
sql复制SELECT * FROM product
INNER JOIN (
SELECT id FROM product
WHERE category_id = 5
ORDER BY create_time DESC
LIMIT 10000, 10
) AS tmp USING(id);
javascript复制// vue-virtual-scroller配置
<RecycleScroller
class="scroller"
:items="products"
:item-size="100"
key-field="id"
v-slot="{ item }"
>
<ProductCard :product="item" />
</RecycleScroller>
我们推荐使用Docker Compose进行容器化部署:
yaml复制version: '3'
services:
mysql:
image: mysql:5.7
environment:
MYSQL_ROOT_PASSWORD: ${DB_ROOT_PASSWORD}
MYSQL_DATABASE: agri_sales
volumes:
- mysql_data:/var/lib/mysql
ports:
- "3306:3306"
redis:
image: redis:alpine
ports:
- "6379:6379"
volumes:
- redis_data:/data
backend:
build: ./backend
ports:
- "8080:8080"
environment:
SPRING_DATASOURCE_URL: jdbc:mysql://mysql:3306/agri_sales
SPRING_REDIS_HOST: redis
depends_on:
- mysql
- redis
frontend:
build: ./frontend
ports:
- "80:80"
depends_on:
- backend
volumes:
mysql_data:
redis_data:
yaml复制management:
endpoints:
web:
exposure:
include: "*"
endpoint:
health:
show-details: always
metrics:
enabled: true
java复制@Configuration
public class PrometheusConfig {
@Bean
MeterRegistryCustomizer<MeterRegistry> metricsCommonTags() {
return registry -> registry.config().commonTags(
"application", "agricultural-sales"
);
}
}
在实际开发过程中,我们发现以下几个值得优化的方向:
python复制# 简化的协同过滤推荐示例
from surprise import Dataset, KNNBasic
def train_recommend_model():
data = Dataset.load_builtin('ml-100k')
trainset = data.build_full_trainset()
sim_options = {'name': 'cosine', 'user_based': False}
algo = KNNBasic(sim_options=sim_options)
algo.fit(trainset)
return algo
java复制// 简化的Flink作业示例
StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment();
DataStream<Order> orders = env
.addSource(new KafkaSource<>())
.keyBy(Order::getProductId);
orders
.window(TumblingEventTimeWindows.of(Time.days(1)))
.aggregate(new SalesAggregator())
.addSink(new JdbcSink());
在项目开发过程中,我们积累了一些宝贵经验:
java复制@ControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(BusinessException.class)
public ResponseEntity<ErrorResponse> handleBusinessException(BusinessException ex) {
ErrorResponse response = new ErrorResponse(
ex.getCode(),
ex.getMessage(),
System.currentTimeMillis()
);
return new ResponseEntity<>(response, HttpStatus.BAD_REQUEST);
}
@ExceptionHandler(Exception.class)
public ResponseEntity<ErrorResponse> handleException(Exception ex) {
ErrorResponse response = new ErrorResponse(
500,
"系统繁忙,请稍后再试",
System.currentTimeMillis()
);
return new ResponseEntity<>(response, HttpStatus.INTERNAL_SERVER_ERROR);
}
}
java复制@Testcontainers
public class ProductServiceIntegrationTest {
@Container
static MySQLContainer<?> mysql = new MySQLContainer<>("mysql:5.7");
@DynamicPropertySource
static void registerPgProperties(DynamicPropertyRegistry registry) {
registry.add("spring.datasource.url", mysql::getJdbcUrl);
registry.add("spring.datasource.username", mysql::getUsername);
registry.add("spring.datasource.password", mysql::getPassword);
}
@Test
void shouldSaveProduct() {
Product product = new Product("有机苹果", new BigDecimal("12.5"), "kg");
Product saved = productRepository.save(product);
assertNotNull(saved.getId());
}
}
这套农产品销售管理系统经过多次迭代,已经形成了相对稳定的架构。在开发过程中,我们特别注重代码的可维护性和系统的可扩展性,为后续功能升级打下了良好基础。对于计算机专业的学生来说,理解这种规模的项目架构,对提升工程能力有很大帮助。