“智能健康饮食系统”这个题目,我在不少地方都见过——课程设计、毕业设计、个人练手项目里它都是常客。乍一看似乎就是个“记录一日三餐”的CRUD,但真正动手做起来才会发现,营养分析、食材数据、推荐策略、消息提醒、文件存储……每一块展开都是坑。我基于Spring Boot完整实现了一套,源码也整理好了(编号05961),这篇文章把整个设计思路、核心实现、踩坑记录和上线部署都摊开来讲,希望能给你省下几周自己摸索的时间。
1. 这个系统到底解决什么问题:从题目到真实需求
先别急着写代码。拿到“智能健康饮食系统”这个题目,第一步是搞清楚它到底要做到什么程度。“智能”这两个字是核心——如果只是做一个餐饮记录表,那跟普通的记账本没区别。真正要解决的是:用户不知道自己每天吃了多少热量、营养是否均衡、怎么调整饮食结构更合理。整套系统的价值就落在“记录→计算→分析→推荐”这条链路上。
1.1 从题目里拆出的核心需求
我按自己的理解把需求拆成六个功能域,每个功能域对应一组具体能力:
- 用户管理:注册、登录、个人信息维护,以及健康档案(身高、体重、年龄、活动水平等)。
- 饮食记录:按早餐/午餐/晚餐/加餐记录食物,支持选择食材、填写克数,也可以直接拍照上传餐品图片。
- 营养分析:根据饮食记录自动汇总热量、蛋白质、脂肪、碳水化合物等核心营养素,并与个人推荐摄入量做对比。
- 健康评估:根据健康档案计算BMI、基础代谢率BMR、每日推荐摄入量TDEE,形成基础的健康画像。
- 智能推荐:结合健康档案和近N天的饮食记录,推荐合适的食材与菜品,兼顾热量缺口和营养均衡。
- 提醒服务:定时推送反馈,比如“该记录早餐了”“今日饮水目标未完成”,帮助用户坚持记录。
这是我自己做的需求拆解。如果你拿到的任务书里有额外要求,比如管理员端、食谱管理、评论收藏之类的,就按任务书往这个骨架上挂就行。核心链路仍然是“档案→记录→分析→推荐”。
1.2 系统整体功能一览
为了让读者更直观地感受,我把最终实现的功能模块整理成一张表:
| 模块 | 面向用户 | 核心功能点 |
|---|---|---|
| 用户与档案 | C端 | 注册登录、JWT鉴权、健康档案维护 |
| 饮食记录 | C端 | 三餐/加餐记录、食材选择、克数填写、图片上传 |
| 营养分析 | C端 | 日/周维度营养素汇总、摄入量对比图表 |
| 健康评估 | C端 | BMI计算、BMR/TDEE估算、健康建议生成 |
| 智能推荐 | C端 | 基于热量缺口与营养打分的食材推荐 |
| 食材库管理 | B端(可选) | 食材CRUD、营养数据维护、分类管理 |
| 提醒服务 | C端 | 基于ActiveMQ的异步提醒消费、定时任务生产 |
| 数据看板 | C端 | 近7日/30日热量摄入趋势、营养素占比 |
这套模块划分有一个好处:B端食材库可以单独维护,数据不跟用户记录耦合,后续想接入外部食材API也方便扩展。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. Spring Boot是骨架,但骨架的选型都藏在这些细节里
既然题目指定Spring Boot,那技术主框架基本没有悬念。但“用Spring Boot”和“把Spring Boot用明白”是两回事。这个项目里我踩过最痛的坑基本都集中在版本选择、自动装配机制和ORM选型上。
2.1 版本选择与自动装配的一点理解
我选的是Spring Boot 2.7.x,而不是最新的3.x。原因很实际:2.7.x生态最稳,网上资料多,很多中间件(比如我后面要用的ActiveMQ、MinIO SDK)的兼容性验证也比较充分。3.x虽然新,但javax到jakarta的迁移、Spring Security 6的API变化,对一个以业务功能为主的项目来说,没必要给自己加戏。
理解Spring Boot自动装配,对这个项目帮助很大。比如你在application.yml里配置了spring.datasource.url,为什么DataSource就自动可用了?因为spring-boot-autoconfigure里的DataSourceAutoConfiguration会按条件注解@ConditionalOnClass和@ConditionalOnMissingBean来决定是否创建一个默认的DataSource。我排查过一个问题:改了数据库密码重启之后一直报连接失败,结果发现是HikariCP的连接池缓存了旧连接,跟自动装配本身无关。这个我后面在部署排错章节再细讲。
所以我的建议是:第一次用Spring Boot做项目,花两小时翻一下自动配置的源码逻辑,比背十个面试题都有用。 面试题问“自动装配原理”,你至少能说清楚@EnableAutoConfiguration通过AutoConfigurationImportSelector加载META-INF/spring.factories中的配置类,再按条件注解生效。
2.2 ORM选择:MyBatis-Plus让单表操作省一半时间
ORM我选了MyBatis-Plus,理由很直白:它对单表CRUD的支持几乎是零成本,BaseMapper自带insert/update/selectById/wrapper查询,省掉一大片重复的XML。这个项目里大量的操作都是针对用户表、食材表、记录表的单表读写,MyBatis-Plus几乎是为这种场景量身定做的。
核心配置就三件事:
yaml复制mybatis-plus:
mapper-locations: classpath:/mapper/**/*.xml
configuration:
map-underscore-to-camel-case: true
global-config:
db-config:
id-type: auto
map-underscore-to-camel-case必须开,不然数据库的diet_record映射不到实体的dietRecord。还有分页插件,注册一个MybatisPlusInterceptor:
java复制@Configuration
public class MybatisPlusConfig {
@Bean
public MybatisPlusInterceptor mybatisPlusInterceptor() {
MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();
interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL));
return interceptor;
}
}
营养分析里查询某用户的近30条饮食记录,直接Page对象分页就搞定,不用手写limit。
2.3 认证方案与接口安全
认证这块,标准做法是Spring Security + JWT,但如果你觉得Spring Security门槛太高,还有个务实的替代方案是Sa-Token,API设计更贴合中国人习惯,文档也全。我用的是Spring Security + JWT的组合,因为它在面试里更有说头,而且@PreAuthorize注解做接口级权限控制非常顺手。
java复制@PreAuthorize("hasRole('USER')")
@GetMapping("/record/list")
public Result<List<DietRecordVO>> list(@RequestParam Long userId,
@RequestParam String date) {
return Result.ok(dietRecordService.listByUserAndDate(userId, date));
}
JWT的过滤器链要放在UsernamePasswordAuthenticationFilter之前,一旦放错位置,token校验就会失效,请求直接打到controller里,然后因为拿不到Authentication而报403。这是我真实踩过的坑,排查了半天,最后发现是SecurityFilterChain里addFilterBefore的过滤器顺序问题。
3. 数据库设计:一张食材表决定了营养计算的成败
这个项目的数据模型,我会说核心不在用户表也不在记录表,而在食材表(food_item)。因为后面所有营养计算、智能推荐,都是建立在“每100克食材含有多少热量、蛋白质、脂肪、碳水”这个数据基础上的。这张表设计得不好,后面每一步都会别扭。
3.1 核心表结构与字段说明
我的库里一共有7张核心表,其中最重要的字段设计如下。
用户表(sys_user):主键id、用户名、密码(BCrypt加密)、角色、创建时间。健康档案字段我建议单独建表,因为档案字段比较多,而且未来可能扩展。
健康档案表(health_profile):
| 字段 | 类型 | 说明 |
|---|---|---|
| id | bigint | 主键 |
| user_id | bigint | 关联用户 |
| height | decimal(5,2) | 身高cm |
| weight | decimal(5,2) | 体重kg |
| age | int | 年龄 |
| gender | tinyint | 1男 2女 |
| activity_level | varchar | LOW/MEDIUM/HIGH |
| target_weight | decimal(5,2) | 目标体重 |
| allergies | varchar | 过敏或禁忌食材,逗号分隔 |
食材表(food_item):
| 字段 | 类型 | 说明 |
|---|---|---|
| id | bigint | 主键 |
| name | varchar | 食材名称 |
| category | varchar | 分类:主食/肉类/蔬菜/水果/蛋奶/坚果 |
| calories | decimal(7,2) | 每100克热量kCal |
| protein | decimal(7,2) | 每100克蛋白质g |
| fat | decimal(7,2) | 每100克脂肪g |
| carbs | decimal(7,2) | 每100克碳水化合物g |
| unit | varchar | 建议计量单位,如g/个/份 |
饮食记录表(diet_record):主键、用户id、餐次(breakfast/lunch/dinner/snack)、记录日期、备注、图片URL。明细单独拆一张表(diet_record_detail),用来存“这顿饭里选了哪几种食材、各多少克”。为什么拆?因为一顿饭可能包含3种以上食材,如果塞在record表里变成JSON数组,后面统计营养素时根本无法高效聚合,查询和索引都吃亏。
sql复制CREATE TABLE diet_record_detail (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
record_id BIGINT NOT NULL,
food_item_id BIGINT NOT NULL,
grams DECIMAL(7,2) NOT NULL,
INDEX idx_record (record_id)
);
提醒配置表(remind_config):用户id、提醒类型(meal/water)、提醒时间、开关状态。
3.2 营养数据从哪来:食材库的数据初始化思路
食材数据是这类项目公认的痛点。中国食物成分表是权威来源,但数据量大。我的建议是两条腿走路:先自己整理50~80条日常生活常见食材,覆盖主食、肉蛋奶、蔬菜、水果这几个大类,保证Demo跑起来数据不寒酸;同时预留批量导入接口,后面有精力了可以用Excel往库里灌更完整的数据。
把每条食材都统一落到“每100克”的营养量,这一点要守住,不能出现有的按100克、有的按每份的情况。因为计算引擎里所有聚合运算都默认基于100克标准,一旦数据口径混乱,算出来的结果就全错了。这块我吃过亏,初始化时手抖把“鸡蛋按1个”录了进去,结果用户记录了一个鸡蛋,系统算出200多克蛋白质,数据离谱得一眼就能看出来。
4. 营养计算引擎:热量和营养素到底是怎么算出来的
这是整个系统“智能”二字的底气所在。把饮食记录里的食材克数累加乘以营养系数,这只是第一步——真正让用户觉得专业的,是你还能算出他“一天该吃多少”,并跟实际摄入做对比。这两件事我都会展开说说。
4.1 BMR与TDEE:推荐摄入量的计算逻辑
推荐摄入量的起点是基础代谢率BMR,我用的是Mifflin-St Jeor公式,目前营养学上认可度较高:
- 男性:BMR = 10 × 体重(kg) + 6.25 × 身高(cm) - 5 × 年龄(岁) + 5
- 女性:BMR = 10 × 体重(kg) + 6.25 × 身高(cm) - 5 × 年龄(岁) - 161
然后根据活动水平乘系数得到每日总消耗TDEE。我定义了三个等级:
java复制public enum ActivityLevel {
LOW(1.2), // 久坐为主
MEDIUM(1.55), // 每周运动1-3次
HIGH(1.725); // 每周高强度运动4次以上
}
目标热量 = TDEE ± 500,取决于用户是想减重还是增重。注意这里的减重建议要设置安全下限,比如女性不低于1200、男性不低于1500,低于这个值系统会给出警示。这个细节很重要——作为开发人员,我们做的是“参考建议”,必须明确标注不要低于安全值,也是内容合规的底线。
4.2 记录明细的聚合计算:实现代码一步步说
拿到当天所有记录明细之后,营养聚合就是一个标准的group by操作。我先按recordId查出所有明细,再join食材表取得营养数据,然后逐条累加:
java复制public DailyNutritionVO calculateDailyNutrition(Long userId, String date) {
List<DietRecord> records = dietRecordMapper.selectList(
new LambdaQueryWrapper<DietRecord>()
.eq(DietRecord::getUserId, userId)
.eq(DietRecord::getRecordDate, date));
List<Long> recordIds = records.stream().map(DietRecord::getId).toList();
if (recordIds.isEmpty()) {
return DailyNutritionVO.zero();
}
List<DietRecordDetail> details = detailMapper.selectList(
new LambdaQueryWrapper<DietRecordDetail>()
.in(DietRecordDetail::getRecordId, recordIds));
DailyNutritionVO vo = new DailyNutritionVO();
for (DietRecordDetail detail : details) {
FoodItem food = foodItemMapper.selectById(detail.getFoodItemId());
double factor = detail.getGrams() / 100.0;
vo.addCalories(food.getCalories() * factor);
vo.addProtein(food.getProtein() * factor);
vo.addFat(food.getFat() * factor);
vo.addCarbs(food.getCarbs() * factor);
}
return vo;
}
这段代码在数据量小时没有问题,但有个性能隐患:N条明细会执行N次selectById。虽然是本项目不至于压垮数据库,但写出来不太好看。我后来优化成先一次性查出所有食材id,用selectBatchIds批量拿回来,再组装成一个Map内存里匹配。这个优化思路在技术分享里值得提一句,能体现你对性能的敏感度。
4.3 计算结果与“智能建议”的衔接
算出当天四类核心营养值之后,还要跟推荐摄入量做对比,生成可读的反馈。比如“今日摄入热量1630大卡,低于推荐摄入1800大卡,建议增加一份主食或优质蛋白”。实现上很简单——写一套规则判断:
- 实际热量 < 目标热量 - 200 → 建议增加摄入
- 实际热量 > 目标热量 + 200 → 建议控制摄入
- 蛋白质占比 < 15% → 建议补充瘦肉/蛋奶/豆制品
- 脂肪占比 > 35% → 建议减少油炸食品
这些建议全部是参考性的,措辞上我会加“建议”“参考”这类词,绝不出现“治疗”“必须”等带有医疗指导意味的表述,这是做健康类产品必须守住的边界。
5. 智能推荐不只是“查个热量缺口”
如果推荐逻辑只是“找出热量小于剩余缺口的食材列表”,那用户三分钟就会觉得这个系统很傻。真正像样的推荐,至少要经过“过滤→打分→排序”三个步骤,我分别说一下各自的实现思路。
5.1 推荐策略:过滤、打分、排序三步走
第一步是硬性过滤,把不能吃的食材先排除掉:
- 排除用户健康档案里填写的过敏/禁忌食材;
- 排除热量超过目标摄入量一半的食材(一顿饭不该被一种食材撑爆);
- 排除当前时段不适合的分类(早餐推荐重口味的红烧肉确实不合适)。
第二步是打分,我给的权重是这样拆的:
- 营养均衡度(占40%):跟当前营养素缺口匹配的加分。举个例子,今天蛋白质摄入偏低,那蛋白质密度高的食材得分就高。
- 热量适配度(占30%):优先推荐能让用户靠近目标热量的食材。
- 分类多样性(占20%):最近三天同类食材出现次数越少,得分越高。
- 用户历史偏好(占10%):用户过去主动选用频率高的食材,给一个小幅度的加分。
打分函数大致长这样:
java复制public double score(FoodItem food, DailyNutritionVO current, NutritionTargetVO target,
Map<String, Integer> recentCategoryCount, List<String> preference) {
double score = 0;
// 营养素缺口匹配
double proteinGap = target.getProtein() - current.getProtein();
if (proteinGap > 0 && food.getProtein() > 5) {
score += 40 * Math.min(1, food.getProtein() / 20.0);
}
// 热量适配
double remainCalories = target.getCalories() - current.getCalories();
if (remainCalories > 0 && food.getCalories() < remainCalories) {
score += 30;
}
// 多样性
int count = recentCategoryCount.getOrDefault(food.getCategory(), 0);
if (count == 0) score += 20;
else if (count == 1) score += 10;
// 历史偏好
if (preference.contains(food.getName())) score += 10;
return score;
}
第三部按分数降序取Top10,返回时每一条都带上推荐理由字符串,比如“蛋白质密度高,有助于补充今日蛋白质缺口”,用户能看懂为什么被推荐,信任感完全不一样。
5.2 让推荐看起来不傻:历史偏好与重复抑制
只靠上面的规则,推荐结果仍然可能“今天推荐鸡蛋,明天还推荐鸡蛋”。我加了一个去重层:查询近7天用户饮食记录里出现过的食材,做一个“疲劳度系数”,最近一次使用距今越近,推荐排序越靠后。
还有一个细节是“同分类互斥”:推荐列表里同类食材最多出现2种。不然会看到早餐推荐出来5种主食,这体验显然不对。最后把推荐结果缓存到Redis,key做成“userId:recommend:yyyy-MM-dd”,当天内多次请求直接命中缓存,响应速度提升非常明显。
5.3 推荐结果的合理边界
这里必须提醒一句:系统推荐的所有内容,本质上都是基于公开营养数据的参考建议,不能替代专业医生或注册营养师的个性化指导。我在前端页面的推荐模块底部固定展示一条免责提示:“推荐结果基于通用营养数据计算,仅供饮食管理参考。”这个不是走形式,做健康类项目还是要把边界划清楚。
6. 消息提醒:用ActiveMQ把“饮食打卡”变成习惯
提醒服务是这个系统里容易被忽略、但实际很有价值的一块。它要做的事是:到点给用户发提醒,引导用户记录饮食或喝水。实现方案很多,我选的是ActiveMQ——既能练到实实在在的消息中间件整合能力,又能把“生产-消费”这套架构跑通。
6.1 为什么引入ActiveMQ而不是直接Scheduled调用
其实这个场景直接写个@Scheduled定时任务也是能跑的,但有两个问题:一是定时任务在单体进程里如果执行时间过长,会拖累后续任务;二是如果以后想把提醒方式从站内信扩展到邮件、短信、App推送,生产者不需要改动,只要增加消费者即可。这就是解耦的价值。
另外,如果我直接在定时任务里挨个查用户、拼消息、发通知,万一中间某一步报错,整个批量任务就中断了。引入MQ之后,生产者只负责把“userId + remindType + time”的消息发进队列,真正的提醒逻辑由消费者处理,失败可以重试,两边的压力都小很多。
6.2 生产者与消费者的落地代码
Spring Boot整合ActiveMQ比较简单,加spring-boot-starter-activemq,然后在application.yml里配置连接信息:
yaml复制spring:
activemq:
broker-url: tcp://localhost:61616
user: admin
password: admin
jms:
template:
default-destination: diet.remind.queue
生产者就是定时任务,每天按用户配置的提醒时间,生成消息发送:
java复制@Component
public class RemindProducer {
@Autowired
private JmsMessagingTemplate messagingTemplate;
@Scheduled(cron = "0 0 8 * * ?") // 每天早上8点触发新一轮提醒生成
public void produceRemind() {
List<RemindConfig> configs = remindConfigMapper.selectList(
new LambdaQueryWrapper<RemindConfig>().eq(RemindConfig::getEnabled, 1));
for (RemindConfig cfg : configs) {
messagingTemplate.convertAndSend("diet.remind.queue",
new RemindMessage(cfg.getUserId(), cfg.getRemindType()));
}
}
}
消费者负责真正发送提醒,并把发送记录落库。这里我建议落一张remind_log表,方便后续排查“为什么用户没收到提醒”。
java复制@Component
public class RemindConsumer {
@JmsListener(destination = "diet.remind.queue")
public void consume(RemindMessage message) {
// 构建提醒内容,发送站内信/邮件
remindLogService.log(message);
}
}
6.3 踩过的坑:消息重发与消费幂等
我用ActiveMQ过程中踩过的坑有好几个,最典型的一个是:消费者处理消息抛异常后,消息会被重投,如果消费者没有做幂等,同一个提醒就会重复发送。 解决办法有两个层面:
- 在
RemindLog表对user_id + remind_type + remind_date建唯一索引,消费者入库前先查重,存在就跳过; - 失败的消费者逻辑里做好重试次数上限,多次失败后记录并告警,而不是死循环重发。
选这个方案还有一个现实考虑:ActiveMQ跟很多公司现有的Spring Boot技术栈契合度很高,不需要额外引入重型的MQ(比如Kafka)。虽然Kafka吞吐量高,但在这个提醒场景里,ActiveMQ的能力完全够用,运维成本也低。
7. MinIO文件存储:把餐品图片管起来
饮食记录里有个高频需求是拍照上传——用户吃完饭拍一张照片,系统把图片存起来,既能提升记录的真实性,也为以后做图像识别预留扩展空间。文件这块我用的是MinIO,理由也很实在:部署简单、兼容S3协议、社区活跃。把MinIO加到Spring Boot项目里,核心就三步。
7.1 整合MinIO的配置步骤
先在服务器上用Docker起一个MinIO实例:
bash复制docker run -d \
--name minio \
-p 9000:9000 -p 9001:9001 \
-e MINIO_ROOT_USER=minioadmin \
-e MINIO_ROOT_PASSWORD=minioadmin123 \
-v /data/minio:/data \
minio/minio server /data --console-address ":9001"
然后在Spring Boot里加minio依赖,写一个配置类:
yaml复制minio:
endpoint: http://localhost:9000
access-key: minioadmin
secret-key: minioadmin123
bucket: diet-images
java复制@Configuration
public class MinioConfig {
@Value("${minio.endpoint}")
private String endpoint;
@Value("${minio.access-key}")
private String accessKey;
@Value("${minio.secret-key}")
private String secretKey;
@Bean
public MinioClient minioClient() {
return MinioClient.builder()
.endpoint(endpoint)
.credentials(accessKey, secretKey)
.build();
}
}
启动时自动建桶并设置策略的代码我就不贴了,核心思路是bucketExists判断后makeBucket。有一点容易踩坑:Spring Boot 2.7引入MinIO SDK会有依赖冲突的可能,建议引入时用exclusion把冲突的依赖排除掉。
7.2 上传与访问控制:不是所有图片都要公开
上传接口的核心逻辑就是:前端拿MultipartFile → 后端起一个带UUID的objectName → putObject → 返回objectName入库。
java复制public String upload(MultipartFile file) throws Exception {
String objectName = UUID.randomUUID() + "_" + file.getOriginalFilename();
minioClient.putObject(PutObjectArgs.builder()
.bucket(bucket)
.object(objectName)
.stream(file.getInputStream(), file.getSize(), -1)
.contentType(file.getContentType())
.build());
return objectName;
}
访问控制上有个决策要提前做:桶策略是公开读还是签名URL读? 我最初图省事把桶设成公开读,结果发现任何人都能通过拼接URL看到用户传的餐品图片,隐私上不体面。后来改成私有桶,上传时返回对象名,前端展示时后端临时生成一个有效期15分钟的下载链接:
java复制public String getPresignedObjectUrl(String objectName) {
return minioClient.getPresignedObjectUrl(GetPresignedObjectUrlArgs.builder()
.method(Method.GET)
.bucket(bucket)
.object(objectName)
.expiry(15, TimeUnit.MINUTES)
.build());
}
这个改动不大,但对“健康饮食”这类跟个人数据强相关的项目,隐私意识是必须有的。
7.3 图片存储的空间清理与小技巧
图片会越积越多,如果不处理,一两年后磁盘就满了。我加了一个定时清理任务:每天凌晨扫描上传超过30天、且没有关联到任何饮食记录的孤立图片,直接删除object。实现方式是维护一张file_record表,上传落库、删除时同步移记录,清理任务按记录时间过滤即可。
还有一个比较实际的建议:前端上传图片时,先做前端压缩,1MB以上的图片压到200KB以内再传到后端,这样既省MinIO空间,也显著加快上传速度,用户在弱网环境下体验友好得多。
8. 打包上线的最后一公里:部署经验与常见故障排查
项目开发完了,最终还是要跑在服务器上给人用。部署这一步看似简单,实际上问题最多。我把自己部署过程中遇到的高频问题和解决过程完整梳理一遍。
8.1 Docker部署的完整流程
我用Docker部署,Dockerfile采用多阶段构建,先编译后打包,保证镜像干净:
dockerfile复制FROM maven:3.8.7-openjdk-8 AS build
WORKDIR /app
COPY pom.xml .
RUN mvn dependency:go-offline -B
COPY src ./src
RUN mvn clean package -DskipTests
FROM openjdk:8-jre-alpine
WORKDIR /app
COPY --from=build /app/target/health-diet-system.jar app.jar
EXPOSE 8080
ENTRYPOINT ["java", "-jar", "app.jar", "--spring.profiles.active=prod"]
然后写一个docker-compose.yml,把MySQL、Redis、ActiveMQ、MinIO、应用服务一键编排:
yaml复制services:
app:
build: .
ports:
- "8080:8080"
depends_on:
- mysql
- redis
environment:
- SPRING_DATASOURCE_URL=jdbc:mysql://mysql:3306/health_diet?useUnicode=true&characterEncoding=utf8
- SPRING_DATA_REDIS_HOST=redis
这里有个很容易掉进去的坑:容器内服务互相访问要用服务名,不能用localhost。我第一次部署时应用日志一直报连不上数据库,就是这个原因。
为了部署灵活,生产环境的配置我建议不要打进镜像里,而是用--spring.config.location=file:/config/application-prod.yml挂载外部文件。数据库密码、MinIO密钥这些敏感信息更推荐用环境变量注入,别写死在配置文件提交到代码仓库。
8.2 上线后最容易遇到的三个问题
第一个问题是时区不一致。MySQL默认时区跟应用服务器不同的话,饮食记录的“今天”会莫名其妙变成“昨天”,推荐和统计全乱。解决方式是在JDBC URL上强制URL时区参数:
text复制serverTimezone=Asia/Shanghai&useLegacyDatetimeCode=false
第二个问题是数据库连接池耗尽。用户量增长后,每个请求都占一个连接,并发一上来就报Connection is not available, request timed out。我当时的处理是调整HikariCP参数:
yaml复制spring:
datasource:
hikari:
maximum-pool-size: 20
minimum-idle: 5
connection-timeout: 30000
同时在代码层给频繁查询加了Redis缓存,比如食材列表、营养目标值这些不常变化的数据,就不再每次查库了。
第三个问题是静态资源或图片404。Docker容器里的MinIO数据卷如果没有挂载到宿主机,容器一重启图片全丢。务必要在docker-compose.yml里把MinIO的/data目录映射到宿主机持久化路径。
还有一个我印象深刻的故障:更新了数据库密码后应用重启依旧用旧密码连接成功,排查后定位是HikariCP的某些版本会缓存旧连接。处理很简单——不单是改配置,还要kill掉旧进程再重启容器,让连接池重新初始化。这个问题虽然不大,但当时排查了很久,分享出来希望能帮你跳过。
就像我最开始说的,这套系统的价值不在于功能表多长,而在于“记录→计算→分析→推荐”这条链路是否真正顺滑。我在这个项目上反复调整最多的,反而是那些看起来不显眼的地方:食材数据口径的统一、推荐结果的解释性、提醒消息的幂等消费、图片URL的过期策略。这些细节单个拿出来都不算技术难点,但组合在一起,才是“智能”两个字能立住的基础。源码编号05961放出来了,跑起来之后再对照这篇文章看,你对自己的项目会有更完整的把握。
