最近在做一个基于SpringBoot+Vue的美食推荐系统,这个项目前后折腾了差不多两个月,踩了不少坑也积累了一些经验。现在系统已经稳定运行了三个月,用户反馈还不错,所以想把整个开发过程做个总结分享给大家。
这个系统主要解决两个痛点:一是用户面对海量美食信息时的选择困难症,二是商家精准触达目标用户的营销需求。通过协同过滤算法分析用户行为数据,系统能够为不同口味的用户推荐合适的美食,同时为商家提供数据分析和用户画像支持。
技术选型上,后端用SpringBoot+MyBatis构建RESTful API,前端用Vue.js实现响应式界面,数据库采用MySQL 8.0。整个系统采用前后端分离架构,部署在阿里云ECS上。下面我会从系统设计、核心实现到部署运维,详细讲解每个环节的关键技术和注意事项。
选择SpringBoot作为后端框架主要基于以下几点考虑:
前端选择Vue.js而不是React/Angular的原因是:
系统核心的三张表设计值得重点说明:
用户表(user_info)
sql复制CREATE TABLE `user_info` (
`user_id` bigint NOT NULL AUTO_INCREMENT,
`username` varchar(50) NOT NULL,
`password_hash` varchar(255) NOT NULL,
`email` varchar(100) UNIQUE,
`preference_tag` varchar(255) COMMENT 'JSON格式存储用户偏好标签',
`create_time` timestamp DEFAULT CURRENT_TIMESTAMP,
`last_login` timestamp,
PRIMARY KEY (`user_id`),
UNIQUE KEY `idx_username` (`username`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
美食表(dish_info)
sql复制CREATE TABLE `dish_info` (
`dish_id` bigint NOT NULL AUTO_INCREMENT,
`dish_name` varchar(100) NOT NULL,
`cuisine_type` varchar(50) COMMENT '菜系分类',
`ingredients` text,
`calorie_info` int COMMENT '单位:千卡',
`price_range` varchar(20),
`popularity` int DEFAULT 0,
`update_time` timestamp DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`dish_id`),
FULLTEXT KEY `ft_idx` (`dish_name`,`ingredients`) COMMENT '全文检索索引'
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
推荐记录表(recommend_log)
sql复制CREATE TABLE `recommend_log` (
`recommend_id` bigint NOT NULL AUTO_INCREMENT,
`user_id` bigint NOT NULL,
`dish_id` bigint NOT NULL,
`recommend_score` float(4,2) COMMENT '推荐分数0-5',
`feedback_flag` tinyint(1) DEFAULT 0 COMMENT '0未反馈1喜欢2不喜欢',
`generate_time` timestamp DEFAULT CURRENT_TIMESTAMP,
`expire_time` timestamp COMMENT '推荐过期时间',
PRIMARY KEY (`recommend_id`),
KEY `idx_user` (`user_id`),
KEY `idx_dish` (`dish_id`),
CONSTRAINT `fk_user` FOREIGN KEY (`user_id`) REFERENCES `user_info` (`user_id`),
CONSTRAINT `fk_dish` FOREIGN KEY (`dish_id`) REFERENCES `dish_info` (`dish_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
设计时的几个关键决策:
采用JWT+Spring Security实现认证授权,关键配置如下:
java复制@Configuration
@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter {
@Autowired
private UserDetailsServiceImpl userDetailsService;
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
@Override
protected void configure(HttpSecurity http) throws Exception {
http.csrf().disable()
.authorizeRequests()
.antMatchers("/api/auth/**").permitAll()
.antMatchers("/api/admin/**").hasRole("ADMIN")
.anyRequest().authenticated()
.and()
.addFilter(new JwtAuthenticationFilter(authenticationManager()))
.addFilter(new JwtAuthorizationFilter(authenticationManager()))
.sessionManagement()
.sessionCreationPolicy(SessionCreationPolicy.STATELESS);
}
@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
auth.userDetailsService(userDetailsService).passwordEncoder(passwordEncoder());
}
}
安全相关的最佳实践:
采用基于用户的协同过滤算法,核心逻辑:
java复制public List<DishDTO> recommendDishes(Long userId, int size) {
// 1. 获取目标用户偏好
UserPreference userPref = getUserPreference(userId);
// 2. 查找相似用户
List<SimilarUser> similarUsers = findSimilarUsers(userPref, 5);
// 3. 计算推荐菜品得分
Map<Long, Double> dishScores = new HashMap<>();
for (SimilarUser simUser : similarUsers) {
List<UserDishRating> ratings = getRatingsByUser(simUser.getUserId());
for (UserDishRating rating : ratings) {
double weightedScore = rating.getScore() * simUser.getSimilarity();
dishScores.merge(rating.getDishId(), weightedScore, Double::sum);
}
}
// 4. 过滤已尝试过的菜品
Set<Long> triedDishes = getTriedDishes(userId);
dishScores.keySet().removeAll(triedDishes);
// 5. 返回TopN推荐
return dishScores.entrySet().stream()
.sorted(Map.Entry.<Long, Double>comparingByValue().reversed())
.limit(size)
.map(entry -> getDishInfo(entry.getKey()))
.collect(Collectors.toList());
}
算法优化点:
RESTful API设计规范:
java复制@RestController
@RequestMapping("/api/dishes")
public class DishController {
@GetMapping
public ResponseEntity<PageResult<DishVO>> listDishes(
@RequestParam(required = false) String keyword,
@RequestParam(defaultValue = "1") int page,
@RequestParam(defaultValue = "10") int size) {
// 分页查询逻辑
}
@GetMapping("/{id}")
public ResponseEntity<DishDetailVO> getDishDetail(@PathVariable Long id) {
// 详情查询逻辑
}
@PostMapping
@PreAuthorize("hasRole('ADMIN')")
public ResponseEntity<Void> addDish(@Valid @RequestBody DishCreateDTO dto) {
// 新增菜品逻辑
}
@PutMapping("/{id}")
@PreAuthorize("hasRole('ADMIN')")
public ResponseEntity<Void> updateDish(
@PathVariable Long id,
@Valid @RequestBody DishUpdateDTO dto) {
// 更新菜品逻辑
}
}
前端API调用示例(Vue + Axios):
javascript复制// api/dish.js
import request from '@/utils/request'
export function getDishList(params) {
return request({
url: '/api/dishes',
method: 'get',
params
})
}
export function recommendDishes(userId) {
return request({
url: `/api/recommend/${userId}`,
method: 'get'
})
}
// Vue组件中使用
export default {
data() {
return {
dishes: [],
loading: false
}
},
methods: {
async loadRecommendations() {
this.loading = true
try {
const { data } = await recommendDishes(this.userId)
this.dishes = data
} catch (error) {
this.$message.error('获取推荐失败')
} finally {
this.loading = false
}
}
}
}
sql复制-- 避免全表扫描
EXPLAIN SELECT * FROM dish_info WHERE cuisine_type = '川菜';
-- 使用覆盖索引
CREATE INDEX idx_cuisine ON dish_info(cuisine_type);
-- 分页查询优化
SELECT * FROM dish_info
WHERE cuisine_type = '川菜'
ORDER BY popularity DESC
LIMIT 20 OFFSET 0; -- 第一页
SELECT * FROM dish_info
WHERE cuisine_type = '川菜' AND popularity < ?
ORDER BY popularity DESC
LIMIT 20; -- 后续分页
yaml复制spring:
datasource:
hikari:
maximum-pool-size: 20
minimum-idle: 5
connection-timeout: 30000
idle-timeout: 600000
max-lifetime: 1800000
使用Redis缓存热门数据和推荐结果:
java复制@Configuration
@EnableCaching
public class RedisConfig {
@Bean
public RedisCacheManager cacheManager(RedisConnectionFactory factory) {
RedisCacheConfiguration config = RedisCacheConfiguration.defaultCacheConfig()
.entryTtl(Duration.ofHours(1))
.disableCachingNullValues()
.serializeValuesWith(RedisSerializationContext.SerializationPair
.fromSerializer(new GenericJackson2JsonRedisSerializer()));
return RedisCacheManager.builder(factory)
.cacheDefaults(config)
.transactionAware()
.build();
}
}
@Service
public class DishServiceImpl implements DishService {
@Cacheable(value = "dishes", key = "#dishId")
public DishDetailVO getDishDetail(Long dishId) {
// 数据库查询逻辑
}
@CacheEvict(value = "dishes", key = "#dishId")
public void updateDish(DishUpdateDTO dto) {
// 更新逻辑
}
}
javascript复制const DishDetail = () => import('@/views/dish/Detail')
html复制<img v-lazy="dish.imageUrl" alt="菜品图片">
javascript复制// 使用Promise.all并行请求
async loadAllData() {
const [dishes, recommends] = await Promise.all([
getDishList(),
recommendDishes(this.userId)
])
this.dishes = dishes.data
this.recommends = recommends.data
}
使用Docker Compose编排服务:
yaml复制version: '3'
services:
mysql:
image: mysql:8.0
environment:
MYSQL_ROOT_PASSWORD: ${DB_ROOT_PASS}
MYSQL_DATABASE: food_recommend
volumes:
- mysql_data:/var/lib/mysql
ports:
- "3306:3306"
restart: always
redis:
image: redis:6.2
ports:
- "6379:6379"
volumes:
- redis_data:/data
restart: always
backend:
build: ./backend
ports:
- "8080:8080"
environment:
SPRING_PROFILES_ACTIVE: prod
depends_on:
- mysql
- redis
restart: always
frontend:
build: ./frontend
ports:
- "80:80"
restart: always
volumes:
mysql_data:
redis_data:
Spring Boot Actuator配置:
yaml复制management:
endpoints:
web:
exposure:
include: health,info,metrics,prometheus
metrics:
export:
prometheus:
enabled: true
endpoint:
health:
show-details: always
Prometheus监控指标示例:
yaml复制scrape_configs:
- job_name: 'spring'
metrics_path: '/actuator/prometheus'
static_configs:
- targets: ['backend:8080']
JWT续期问题
最初设计时JWT过期后要求用户重新登录,体验很差。后来改为双Token方案:
MySQL全文检索的坑
发现中文搜索效果很差,解决方案:
ini复制[mysqld]
ngram_token_size=2
sql复制ALTER TABLE dish_info
ADD FULLTEXT INDEX ft_idx (dish_name, ingredients)
WITH PARSER ngram;
Vue响应式数据更新问题
当直接通过索引修改数组元素时,Vue无法检测到变化。正确做法:
javascript复制// 错误方式
this.dishes[index].popularity += 1
// 正确方式
this.$set(this.dishes, index, {
...this.dishes[index],
popularity: this.dishes[index].popularity + 1
})
MyBatis批量插入优化
最初单条插入性能极差,优化后采用批量插入:
java复制@Insert("<script>" +
"INSERT INTO dish_info (dish_name, cuisine_type) VALUES " +
"<foreach collection='list' item='item' separator=','>" +
"(#{item.dishName}, #{item.cuisineType})" +
"</foreach>" +
"</script>")
@Options(useGeneratedKeys = true, keyProperty = "dishId")
void batchInsert(List<Dish> dishes);
跨域问题的终极解决方案
开发时遇到各种CORS问题,最终方案:
java复制@Configuration
public class CorsConfig implements WebMvcConfigurer {
@Override
public void addCorsMappings(CorsRegistry registry) {
registry.addMapping("/**")
.allowedOrigins("http://localhost:8080", "https://yourdomain.com")
.allowedMethods("*")
.allowedHeaders("*")
.allowCredentials(true)
.maxAge(3600);
}
}
同时Nginx配置也需要添加CORS头:
nginx复制add_header 'Access-Control-Allow-Origin' 'https://yourdomain.com';
add_header 'Access-Control-Allow-Methods' 'GET, POST, OPTIONS';
add_header 'Access-Control-Allow-Headers' 'DNT,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Range,Authorization';
add_header 'Access-Control-Expose-Headers' 'Content-Length,Content-Range';
这个项目从技术选型到最终上线,整个过程让我对现代Web开发有了更深入的理解。最大的体会是:不要过度追求新技术,选择稳定、熟悉的工具栈,把精力放在业务逻辑和用户体验上才是正道。系统目前运行稳定,后续计划加入实时推荐和社交化功能,让推荐更加精准有趣。