最近在整理毕业设计选题时,发现"食物节约盲盒"这个概念特别有意思。作为一个常年和外卖浪费作斗争的吃货,我深刻体会到现代餐饮消费中存在的食物浪费问题。据统计,仅外卖行业每年产生的食物浪费就高达数百万吨,而与此同时全球仍有数亿人面临饥饿威胁。
这个系统本质上是通过技术手段连接餐饮商家和消费者,将临期食品、当日未售出但品质完好的餐食以盲盒形式低价出售。既帮助商家减少库存损耗,又让消费者以实惠价格获得美食,同时为环保事业做贡献——典型的三赢模式。
从技术角度看,这个选题巧妙结合了当下热门的SpringBoot框架和实际社会需求。不同于普通的电商系统,它需要处理特殊的商品属性(如保质期倒计时)、动态定价机制、地理位置敏感型配送等业务场景,对后端架构设计提出了有趣挑战。
选择SpringBoot作为基础框架主要基于以下几个考量:
整体采用分层架构:
code复制表现层:Thymeleaf + Bootstrap
业务层:Spring MVC + Spring Security
数据层:Spring Data JPA + MySQL
辅助组件:Redis(缓存)、RabbitMQ(异步消息)
系统有三大核心实体:
java复制@Entity
public class MysteryBox {
@Id @GeneratedValue
private Long id;
@Enumerated(EnumType.STRING)
private FoodType type; // 餐食类型
private Double originalPrice; // 原价
private Double discountPrice; // 盲盒价
private LocalDateTime expiryTime; // 保质截止时间
@ManyToOne
private Merchant merchant; // 关联商家
// 动态计算剩余时间百分比
public Double getTimeRatio() {
return Duration.between(LocalDateTime.now(), expiryTime)
.toMinutes() / 1440.0; // 24小时标准化
}
}
java复制public class Order {
// 常规字段...
private OrderStatus status;
@PreUpdate
public void checkExpiry() {
if (status == OrderStatus.PAID &&
LocalDateTime.now().isAfter(expiryTime)) {
status = OrderStatus.AUTO_REFUNDED;
// 触发退款流程
}
}
}
java复制@RestController
@RequestMapping("/merchant")
public class MerchantController {
@GetMapping("/dashboard")
public DashboardVO getRealTimeData(
@AuthenticationPrincipal User user) {
Merchant merchant = merchantRepo.findByUser(user);
return new DashboardVO(
mysteryBoxRepo.countByMerchantAndStatus(merchant, Status.ON),
orderRepo.sumTodaySales(merchant.getId()),
// 使用Redis缓存热门商品
redisTemplate.opsForZSet().range("hot:boxes:"+merchant.getId(), 0, 2)
);
}
}
盲盒价格随时间推移递减是本系统的核心特色。经过实测,采用分段指数衰减模型效果最佳:
java复制public class PricingService {
// 价格衰减曲线参数
private static final double BASE_FACTOR = 0.8;
private static final double[] TIME_SEGMENTS = {0.25, 0.5, 0.75, 1.0};
private static final double[] DISCOUNT_RATES = {0.1, 0.25, 0.4, 0.6};
public double calculateCurrentPrice(MysteryBox box) {
double timeLeftRatio = box.getTimeRatio();
for (int i = 0; i < TIME_SEGMENTS.length; i++) {
if (timeLeftRatio <= TIME_SEGMENTS[i]) {
return box.getOriginalPrice() *
(BASE_FACTOR - DISCOUNT_RATES[i]);
}
}
return box.getOriginalPrice() * 0.2; // 最低折扣
}
}
实际项目中需要根据商家反馈调整参数,建议做成可配置化
基于用户历史订单和实时位置的推荐逻辑:
java复制public List<MysteryBox> recommendBoxes(User user) {
// 1. 获取附近5km商家
Set<String> nearbyMerchants = redisTemplate.opsForGeo()
.radius("merchants:geo",
user.getLng(), user.getLat(),
new Distance(5, Metrics.KILOMETERS))
.getContent().stream()
.map(geoResult -> geoResult.getContent().getName())
.collect(Collectors.toSet());
// 2. 优先推荐同类食品
List<FoodType> preferredTypes = orderRepo
.findTop3FoodTypesByUser(user.getId());
// 3. 组合查询
return mysteryBoxRepo.findRecommended(
nearbyMerchants,
preferredTypes,
PageRequest.of(0, 10));
}
前端采用WebSocket实现实时刷新:
javascript复制// 前端代码
const socket = new SockJS('/ws-box-updates');
const stompClient = Stomp.over(socket);
stompClient.connect({}, () => {
stompClient.subscribe('/topic/boxes', (message) => {
const boxes = JSON.parse(message.body);
boxes.forEach(box => {
const element = document.getElementById(`box-${box.id}`);
if(element) {
element.querySelector('.discount-price').innerText = box.discountPrice;
element.querySelector('.time-left').innerText = formatTime(box.expiryTime);
}
});
});
});
// 后台广播服务
@EnableWebSocketMessageBroker
public class WebSocketConfig implements WebSocketMessageBrokerConfigurer {
@Override
public void configureMessageBroker(MessageBrokerRegistry config) {
config.enableSimpleBroker("/topic");
config.setApplicationDestinationPrefixes("/app");
}
@Scheduled(fixedRate = 60000) // 每分钟广播一次
public void sendBoxUpdates() {
List<MysteryBox> updatedBoxes = pricingService.updateAllPrices();
messagingTemplate.convertAndSend("/topic/boxes", updatedBoxes);
}
}
使用Redis实现分布式锁,防止超卖:
java复制public Order createOrder(Long boxId, User user) {
String lockKey = "lock:box:" + boxId;
String requestId = UUID.randomUUID().toString();
try {
// 获取锁(设置10秒过期)
Boolean locked = redisTemplate.opsForValue()
.setIfAbsent(lockKey, requestId, 10, TimeUnit.SECONDS);
if (!locked) {
throw new BusinessException("当前抢购人数过多,请稍后再试");
}
// 执行库存检查
MysteryBox box = boxRepo.findById(boxId)
.orElseThrow(() -> new NotFoundException("盲盒不存在"));
if (box.getStock() <= 0) {
throw new BusinessException("该盲盒已售罄");
}
// 扣减库存
box.setStock(box.getStock() - 1);
boxRepo.save(box);
// 创建订单
return orderRepo.save(new Order(user, box));
} finally {
// 释放锁(Lua脚本保证原子性)
String script = "if redis.call('get', KEYS[1]) == ARGV[1] then " +
"return redis.call('del', KEYS[1]) " +
"else return 0 end";
redisTemplate.execute(
new DefaultRedisScript<>(script, Long.class),
Collections.singletonList(lockKey),
requestId);
}
}
使用Spring Profiles管理不同环境配置:
yaml复制# application-dev.yml
spring:
datasource:
url: jdbc:mysql://localhost:3306/foodbox_dev
username: devuser
password: dev123
# application-prod.yml
spring:
datasource:
url: jdbc:mysql://prod-db:3306/foodbox_prod
username: ${DB_USER}
password: ${DB_PASSWORD}
redis:
host: redis-cluster
采用多级缓存提升性能:
java复制@Configuration
@EnableCaching
public class CacheConfig {
@Bean
public CacheManager cacheManager() {
CaffeineCacheManager manager = new CaffeineCacheManager();
manager.registerCustomCache("boxes",
Caffeine.newBuilder()
.maximumSize(1000)
.expireAfterWrite(5, TimeUnit.MINUTES)
.build());
return manager;
}
@Cacheable(value = "boxes", key = "#boxId")
public MysteryBox getBoxWithCache(Long boxId) {
return boxRepo.findById(boxId).orElse(null);
}
}
初期发现商家端设置的过期时间与用户端显示存在偏差,解决方案:
java复制@RestController
@RequestMapping("/api/time")
public class TimeController {
@GetMapping("/server")
public Map<String, String> getServerTime() {
return Map.of(
"timestamp", String.valueOf(System.currentTimeMillis()),
"timezone", "UTC+8"
);
}
}
在高并发测试时出现的典型问题及解决方案:
sql复制ALTER TABLE mystery_box ADD COLUMN version INT DEFAULT 0;
UPDATE mystery_box
SET stock = stock - 1, version = version + 1
WHERE id = ? AND version = ? AND stock > 0
这个项目最让我有成就感的是,技术方案与社会价值形成了完美闭环。在开发过程中有几个关键体会:
对于想尝试类似项目的同学,建议先从最小闭环做起:一个商家、一种食品类型、基础的下单流程。跑通后再逐步扩展复杂功能,这样的迭代方式更容易把控进度。