1. 苍穹外卖菜品管理模块设计思路
作为外卖平台的核心功能模块,菜品管理系统直接关系到商户运营效率和用户体验。在苍穹外卖系统的第三天开发中,我们重点构建了完整的菜品生命周期管理体系。这个模块需要同时满足三个核心诉求:
- 商户侧:需要便捷的菜品上架/下架管理
- 运营侧:需要严格的菜品审核流程
- 用户侧:需要实时准确的菜品信息展示
在技术实现上,我们采用Spring Boot + MyBatis Plus作为基础框架,配合Redis缓存和MySQL主从分离的架构方案。这种组合既能满足高并发读取需求(如用户浏览菜单),又能保证数据强一致性(如库存扣减)。
提示:菜品管理模块要特别注意状态机的设计,一个菜品通常会经历"待审核-已上架-已下架-已删除"等状态变迁,需要设计合理的状态转换规则。
2. 菜品基础信息建模
2.1 数据库表设计
菜品主表(food)的核心字段包括:
sql复制CREATE TABLE `food` (
`id` bigint NOT NULL COMMENT '主键',
`shop_id` bigint NOT NULL COMMENT '所属店铺',
`category_id` bigint NOT NULL COMMENT '分类ID',
`name` varchar(64) NOT NULL COMMENT '菜品名称',
`price` decimal(10,2) NOT NULL COMMENT '价格',
`origin_price` decimal(10,2) DEFAULT NULL COMMENT '原价',
`description` varchar(255) DEFAULT NULL COMMENT '描述',
`cover_image` varchar(255) DEFAULT NULL COMMENT '封面图URL',
`status` tinyint NOT NULL DEFAULT '0' COMMENT '状态:0-待审核 1-已上架 2-已下架',
`sales` int DEFAULT '0' COMMENT '月销量',
`stock` int DEFAULT '-1' COMMENT '库存(-1表示无限)',
`create_time` datetime NOT NULL COMMENT '创建时间',
`update_time` datetime NOT NULL COMMENT '更新时间',
PRIMARY KEY (`id`),
KEY `idx_shop_category` (`shop_id`,`category_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
2.2 规格属性设计
为支持不同菜品的规格选项(如辣度、大小份),我们设计了单独的规格表:
java复制public class FoodSpec {
private Long id;
private Long foodId;
private String specName; // 如"辣度"
private String specValue; // 如"微辣"
private BigDecimal extraPrice; // 附加价格
}
在实际项目中,我们发现过早优化是常见误区。初期可以先用JSON字段存储简单规格,等业务复杂度上升后再拆分成独立表结构。
3. 菜品管理后台实现
3.1 管理员审核流程
采用状态模式设计审核流程:
mermaid复制stateDiagram
[*] --> PENDING
PENDING --> APPROVED: 审核通过
PENDING --> REJECTED: 审核驳回
APPROVED --> OFFLINE: 手动下架
APPROVED --> [*]: 删除
OFFLINE --> APPROVED: 重新上架
对应的Java状态机实现:
java复制public class FoodStatusMachine {
public static boolean canChangeStatus(Integer from, Integer to) {
if (from == null) return false;
switch (from) {
case 0: // 待审核
return to == 1 || to == 2;
case 1: // 已上架
return to == 2;
case 2: // 已下架
return to == 1;
default:
return false;
}
}
}
3.2 批量操作优化
商户经常需要批量上架/下架菜品,我们采用两种优化方案:
- 前端分页全选方案:
javascript复制// 使用虚拟滚动加载所有ID
async function getAllIds() {
let ids = [];
let page = 1;
while(true) {
const res = await api.getFoodList({page, pageSize: 100});
ids = [...ids, ...res.data.list.map(item => item.id)];
if(!res.data.hasNext) break;
page++;
}
return ids;
}
- 后端批量处理SQL:
sql复制UPDATE food SET status = #{status}
WHERE id IN
<foreach collection="ids" item="id" open="(" separator="," close=")">
#{id}
</foreach>
4. 前端展示关键实现
4.1 菜品分类联动
采用两级缓存策略:
- 本地内存缓存:存储分类树结构(有效期5分钟)
- Redis缓存:存储分类下的菜品列表(有效期1分钟)
java复制public List<FoodVO> getFoodsByCategory(Long categoryId) {
String cacheKey = "food:category:" + categoryId;
// 先查本地缓存
List<FoodVO> list = localCache.get(cacheKey);
if (list != null) {
return list;
}
// 再查Redis
String json = redisTemplate.opsForValue().get(cacheKey);
if (StringUtils.isNotBlank(json)) {
list = JSON.parseArray(json, FoodVO.class);
localCache.put(cacheKey, list);
return list;
}
// 最后查数据库
list = foodMapper.selectByCategory(categoryId);
redisTemplate.opsForValue().set(cacheKey, JSON.toJSONString(list), 1, TimeUnit.MINUTES);
return list;
}
4.2 图片加载优化
针对菜品封面图做了以下优化:
- 使用WebP格式(比JPEG小25-35%)
- 实现懒加载
- CDN加速
- 自适应分辨率(通过URL参数控制)
html复制<img
src="placeholder.jpg"
data-src="https://cdn.example.com/food/123.webp?w=300&q=80"
class="lazyload"
alt="宫保鸡丁"
>
5. 实战中的经验总结
- 库存扣减的并发控制:
java复制@Transactional
public boolean reduceStock(Long foodId, int quantity) {
// 使用乐观锁
int updated = foodMapper.reduceStockWithVersion(
foodId,
quantity,
foodMapper.selectVersion(foodId)
);
if (updated == 0) {
throw new ConcurrentUpdateException("库存变更冲突");
}
// 记录库存变更流水
stockFlowMapper.insert(buildStockFlow(foodId, quantity));
return true;
}
- 敏感词过滤的实践:
- 初始化时加载敏感词库到Trie树
- 使用异步线程池处理审核任务
- 对用户输入的描述字段进行全角/半角转换后再过滤
- 一个隐蔽的Bug:
在早期版本中,我们发现当菜品价格设置为0元时,前端展示异常。排查发现是价格格式化组件没有处理0值情况。修复方案:
javascript复制// 旧代码
const formatPrice = (price) => {
return price ? `¥${price.toFixed(2)}` : '';
};
// 新代码
const formatPrice = (price) => {
return typeof price === 'number' ? `¥${price.toFixed(2)}` : '';
};
这个案例告诉我们,边界条件测试非常重要,特别是对于金融相关字段。
6. 性能优化方案
通过Arthas工具分析发现,菜品列表查询的慢SQL主要出现在联表查询上。我们做了以下优化:
- 建立联合索引:
sql复制ALTER TABLE food ADD INDEX idx_shop_status (shop_id, status);
- 改用冗余字段:
sql复制-- 在food表增加category_name字段
UPDATE food f, category c
SET f.category_name = c.name
WHERE f.category_id = c.id;
- 引入Elasticsearch:
对于搜索场景,我们同步菜品数据到ES,使用以下mapping:
json复制{
"mappings": {
"properties": {
"food_id": {"type": "keyword"},
"shop_id": {"type": "keyword"},
"name": {
"type": "text",
"analyzer": "ik_max_word"
},
"description": {
"type": "text",
"analyzer": "ik_smart"
}
}
}
}
优化后,搜索性能提升8倍,P99延迟从320ms降到40ms。
7. 监控与报警配置
为确保菜品模块稳定运行,我们配置了以下监控项:
- Prometheus监控指标:
yaml复制- name: food_service
metrics:
- name: food_api_count
type: counter
help: 菜品接口调用次数
- name: food_status_change
type: gauge
help: 菜品状态变更统计
- name: food_stock_alarm
type: alert
expr: sum(food_stock{status="1"} <= 0) by (shop_id)
for: 5m
labels:
severity: warning
annotations:
summary: "{{$labels.shop_id}} 有菜品库存不足"
- 关键日志标记:
在审核流程中增加traceId:
java复制MDC.put("traceId", UUID.randomUUID().toString());
logger.info("菜品审核开始:{}", foodId);
try {
auditService.approve(foodId);
logger.info("菜品审核通过:{}", foodId);
} catch (Exception e) {
logger.error("菜品审核异常:{}", foodId, e);
} finally {
MDC.remove("traceId");
}
- 我们发现在高峰期,审核操作的平均响应时间会从平时的200ms上升到1.2s。通过线程dump分析发现是数据库连接池不够用。调整后配置:
properties复制# 原配置
spring.datasource.hikari.maximum-pool-size=20
# 新配置
spring.datasource.hikari.maximum-pool-size=50
spring.datasource.hikari.connection-timeout=3000
这个调整使高峰期性能回归正常水平,同时避免了连接泄露导致的池耗尽问题。
