桂林作为世界知名的旅游目的地,每年吸引着大量游客前来观光。但在实际旅游过程中,游客常常面临信息获取不便、路线规划困难等问题。传统纸质地图和分散的在线资源难以满足现代游客对即时性、个性化服务的需求。
这个桂林旅游景点导游平台正是为了解决这些痛点而设计。它采用前后端分离架构,后端使用SpringBoot提供RESTful API服务,前端基于Vue.js构建响应式界面,为游客提供一站式的旅游信息服务。
提示:选择SpringBoot+Vue的技术栈,主要考虑到SpringBoot在Java生态中的成熟度,以及Vue.js在构建用户友好界面方面的优势。这种组合既能保证后端服务的稳定性,又能提供流畅的前端体验。
后端技术栈:
前端技术栈:
系统主要分为以下几个功能模块:
sql复制CREATE TABLE `attraction` (
`attraction_id` int NOT NULL AUTO_INCREMENT,
`attraction_name` varchar(100) NOT NULL,
`location` varchar(200) NOT NULL,
`open_time` varchar(50) DEFAULT NULL,
`ticket_price` decimal(10,2) DEFAULT NULL,
`description` text,
`cover_image` varchar(255) DEFAULT NULL,
`create_time` datetime DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`attraction_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
sql复制CREATE TABLE `user` (
`user_id` int NOT NULL AUTO_INCREMENT,
`username` varchar(50) NOT NULL,
`password_hash` varchar(255) NOT NULL,
`email` varchar(100) DEFAULT NULL,
`phone` varchar(20) DEFAULT NULL,
`role` varchar(20) DEFAULT 'user',
`register_time` datetime DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`user_id`),
UNIQUE KEY `username` (`username`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
sql复制CREATE TABLE `order` (
`order_id` int NOT NULL AUTO_INCREMENT,
`user_id` int NOT NULL,
`attraction_id` int NOT NULL,
`quantity` int NOT NULL,
`total_amount` decimal(10,2) NOT NULL,
`payment_status` varchar(20) DEFAULT 'unpaid',
`create_time` datetime DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`order_id`),
KEY `user_id` (`user_id`),
KEY `attraction_id` (`attraction_id`),
CONSTRAINT `order_ibfk_1` FOREIGN KEY (`user_id`) REFERENCES `user` (`user_id`),
CONSTRAINT `order_ibfk_2` FOREIGN KEY (`attraction_id`) REFERENCES `attraction` (`attraction_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
code复制src/main/java
├── com.guilin.tourism
│ ├── config # 配置类
│ ├── controller # 控制器
│ ├── service # 服务层
│ ├── dao # 数据访问层
│ ├── entity # 实体类
│ ├── dto # 数据传输对象
│ ├── vo # 视图对象
│ ├── util # 工具类
│ └── exception # 异常处理
java复制@Component
public class JwtTokenProvider {
@Value("${app.jwt.secret}")
private String jwtSecret;
@Value("${app.jwt.expiration-in-ms}")
private int jwtExpirationInMs;
public String generateToken(UserPrincipal userPrincipal) {
Date now = new Date();
Date expiryDate = new Date(now.getTime() + jwtExpirationInMs);
return Jwts.builder()
.setSubject(Long.toString(userPrincipal.getId()))
.setIssuedAt(new Date())
.setExpiration(expiryDate)
.signWith(SignatureAlgorithm.HS512, jwtSecret)
.compact();
}
public Long getUserIdFromJWT(String token) {
Claims claims = Jwts.parser()
.setSigningKey(jwtSecret)
.parseClaimsJws(token)
.getBody();
return Long.parseLong(claims.getSubject());
}
public boolean validateToken(String authToken) {
try {
Jwts.parser().setSigningKey(jwtSecret).parseClaimsJws(authToken);
return true;
} catch (SignatureException ex) {
logger.error("Invalid JWT signature");
} catch (MalformedJwtException ex) {
logger.error("Invalid JWT token");
} catch (ExpiredJwtException ex) {
logger.error("Expired JWT token");
} catch (UnsupportedJwtException ex) {
logger.error("Unsupported JWT token");
} catch (IllegalArgumentException ex) {
logger.error("JWT claims string is empty.");
}
return false;
}
}
java复制@Service
public class AttractionRecommendationService {
@Autowired
private AttractionRepository attractionRepository;
@Autowired
private UserBehaviorRepository userBehaviorRepository;
public List<Attraction> recommendAttractions(Long userId, int limit) {
// 1. 获取用户历史行为数据
List<UserBehavior> behaviors = userBehaviorRepository.findByUserId(userId);
// 2. 基于内容的推荐
List<Attraction> contentBased = contentBasedRecommendation(behaviors, limit/2);
// 3. 基于热门的推荐
List<Attraction> popular = popularRecommendation(limit/2);
// 4. 合并结果并去重
List<Attraction> result = new ArrayList<>();
result.addAll(contentBased);
result.addAll(popular);
return result.stream()
.distinct()
.limit(limit)
.collect(Collectors.toList());
}
private List<Attraction> contentBasedRecommendation(List<UserBehavior> behaviors, int limit) {
if (behaviors.isEmpty()) {
return Collections.emptyList();
}
// 获取用户浏览最多的景点类别
Map<String, Long> categoryCount = behaviors.stream()
.filter(b -> b.getBehaviorType().equals("VIEW"))
.collect(Collectors.groupingBy(
b -> b.getAttraction().getCategory(),
Collectors.counting()
));
String favoriteCategory = categoryCount.entrySet().stream()
.max(Map.Entry.comparingByValue())
.map(Map.Entry::getKey)
.orElse("");
// 推荐同类别景点
return attractionRepository.findByCategory(favoriteCategory, PageRequest.of(0, limit));
}
private List<Attraction> popularRecommendation(int limit) {
return attractionRepository.findPopularAttractions(PageRequest.of(0, limit));
}
}
code复制src/
├── api/ # API请求
├── assets/ # 静态资源
├── components/ # 公共组件
├── router/ # 路由配置
├── store/ # Vuex状态管理
├── utils/ # 工具函数
├── views/ # 页面组件
├── App.vue # 根组件
└── main.js # 入口文件
vue复制<template>
<div class="attraction-list">
<el-row :gutter="20">
<el-col :span="6" v-for="item in attractions" :key="item.attraction_id">
<el-card :body-style="{ padding: '0px' }" shadow="hover">
<img :src="item.cover_image" class="image" @click="goDetail(item.attraction_id)">
<div style="padding: 14px;">
<span>{{ item.attraction_name }}</span>
<div class="bottom">
<span class="price">¥{{ item.ticket_price }}</span>
<el-button type="text" class="button" @click="goDetail(item.attraction_id)">查看详情</el-button>
</div>
</div>
</el-card>
</el-col>
</el-row>
<el-pagination
@current-change="handlePageChange"
:current-page="pagination.current"
:page-size="pagination.size"
layout="total, prev, pager, next"
:total="pagination.total">
</el-pagination>
</div>
</template>
<script>
import { getAttractionList } from '@/api/attraction'
export default {
data() {
return {
attractions: [],
pagination: {
current: 1,
size: 12,
total: 0
}
}
},
created() {
this.fetchData()
},
methods: {
fetchData() {
getAttractionList({
page: this.pagination.current,
size: this.pagination.size
}).then(response => {
this.attractions = response.data.records
this.pagination.total = response.data.total
})
},
handlePageChange(current) {
this.pagination.current = current
this.fetchData()
},
goDetail(id) {
this.$router.push(`/attraction/detail/${id}`)
}
}
}
</script>
vue复制<template>
<div class="map-container">
<div id="map" style="width: 100%; height: 500px;"></div>
<el-card class="map-control">
<el-checkbox-group v-model="selectedCategories">
<el-checkbox v-for="category in categories"
:key="category"
:label="category">
{{ category }}
</el-checkbox>
</el-checkbox-group>
</el-card>
</div>
</template>
<script>
export default {
data() {
return {
map: null,
markers: [],
categories: ['自然风光', '历史文化', '休闲娱乐', '美食购物'],
selectedCategories: ['自然风光', '历史文化']
}
},
mounted() {
this.initMap()
this.loadAttractions()
},
methods: {
initMap() {
// 使用高德地图API初始化地图
this.map = new AMap.Map('map', {
zoom: 12,
center: [110.299, 25.274]
})
// 添加缩放控件
this.map.addControl(new AMap.ControlBar({
showZoomBar: true,
showControlButton: true
}))
},
loadAttractions() {
getAttractionList({
categories: this.selectedCategories
}).then(response => {
this.clearMarkers()
this.addMarkers(response.data)
})
},
addMarkers(attractions) {
attractions.forEach(attraction => {
const marker = new AMap.Marker({
position: new AMap.LngLat(attraction.longitude, attraction.latitude),
title: attraction.attraction_name,
content: `<div class="marker-content">${attraction.attraction_name}</div>`
})
marker.on('click', () => {
this.$router.push(`/attraction/detail/${attraction.attraction_id}`)
})
this.map.add(marker)
this.markers.push(marker)
})
},
clearMarkers() {
this.markers.forEach(marker => {
this.map.remove(marker)
})
this.markers = []
}
},
watch: {
selectedCategories() {
this.loadAttractions()
}
}
}
</script>
后端环境:
前端环境:
推荐使用Docker容器化部署:
dockerfile复制# 后端Dockerfile
FROM openjdk:8-jdk-alpine
VOLUME /tmp
ARG JAR_FILE=target/*.jar
COPY ${JAR_FILE} app.jar
ENTRYPOINT ["java","-jar","/app.jar"]
dockerfile复制# 前端Dockerfile
FROM nginx:alpine
COPY dist/ /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]
nginx复制server {
listen 80;
server_name yourdomain.com;
location / {
root /usr/share/nginx/html;
try_files $uri $uri/ /index.html;
}
location /api/ {
proxy_pass http://backend:8080/;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
location /static/ {
alias /usr/share/nginx/html/static/;
expires 30d;
}
}
跨域问题解决方案:
前端路由与后端路由冲突:
地图API加载性能优化:
数据库层面:
应用层面:
前端层面:
接口安全:
数据安全:
传输安全:
移动端适配:
智能推荐增强:
社交功能扩展:
商业化功能:
提示:在实际开发过程中,建议采用迭代式开发方法,先实现核心功能,再逐步扩展。同时要注意保持代码的可维护性和可扩展性,为后续功能开发预留接口。