1. 项目背景与需求分析
在旅游业快速发展的今天,景区民宿作为传统酒店的重要补充,正面临着管理效率和服务质量的挑战。传统的人工预约方式不仅效率低下,还容易出现错订、漏订等问题。我们团队基于SpringBoot+Vue技术栈开发的景区民宿预约管理系统,正是为了解决这些痛点而生。
这个系统需要满足几个核心需求:
- 为游客提供24小时在线的民宿查询、预约服务
- 为民宿经营者提供房源管理、订单处理功能
- 为景区管理者提供数据统计和监管工具
从技术角度看,系统需要处理高并发的预约请求,保证数据一致性,同时提供良好的用户体验。这也是我们选择SpringBoot作为后端框架的原因——它的自动配置和快速开发特性非常适合此类业务系统的开发。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 技术选型与架构设计
2.1 后端技术栈
我们采用SpringBoot 2.7作为基础框架,主要基于以下考虑:
- 内嵌Tomcat服务器,简化部署
- 自动配置减少了大量样板代码
- 丰富的starter依赖可以快速集成常用组件
- 与MyBatis的整合非常成熟
数据库选用MySQL 8.0,主要看中其:
- 成熟的ACID事务支持
- 良好的性能表现
- 丰富的索引类型
- 完善的社区支持
MyBatis作为ORM框架,相比Hibernate提供了更灵活的SQL控制能力,这对需要复杂查询的业务系统尤为重要。
2.2 前端技术栈
前端采用Vue 3 + Element Plus的组合:
- Vue 3的Composition API使代码组织更清晰
- 响应式系统对数据展示类应用非常友好
- Element Plus提供了丰富的UI组件,加速开发
- 单页应用(SPA)模式提供更流畅的用户体验
2.3 系统架构
整体采用前后端分离架构:
code复制前端(Vue) <-- HTTP/HTTPS --> 后端(SpringBoot) <--> MySQL
这种架构的优势在于:
- 前后端可以并行开发
- 前端可以独立部署,减轻服务器压力
- 更易于实现微服务化扩展
3. 数据库设计与实现
3.1 核心表结构
系统主要包含以下几张核心表:
- 用户表(users):
sql复制CREATE TABLE `users` (
`id` bigint NOT NULL AUTO_INCREMENT,
`username` varchar(50) NOT NULL,
`password` varchar(100) NOT NULL,
`phone` varchar(20) NOT NULL,
`email` varchar(100) DEFAULT NULL,
`user_type` tinyint NOT NULL COMMENT '1-游客 2-民宿主 3-管理员',
`create_time` datetime NOT NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `idx_username` (`username`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
- 民宿表(homestays):
sql复制CREATE TABLE `homestays` (
`id` bigint NOT NULL AUTO_INCREMENT,
`name` varchar(100) NOT NULL,
`owner_id` bigint NOT NULL,
`location` varchar(255) NOT NULL,
`price` decimal(10,2) NOT NULL,
`description` text,
`status` tinyint NOT NULL DEFAULT '1' COMMENT '0-下架 1-可预订',
`create_time` datetime NOT NULL,
PRIMARY KEY (`id`),
KEY `idx_owner` (`owner_id`),
KEY `idx_location` (`location`(20))
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
- 订单表(orders):
sql复制CREATE TABLE `orders` (
`id` bigint NOT NULL AUTO_INCREMENT,
`order_no` varchar(32) NOT NULL,
`user_id` bigint NOT NULL,
`homestay_id` bigint NOT NULL,
`check_in_date` date NOT NULL,
`check_out_date` date NOT NULL,
`total_amount` decimal(10,2) NOT NULL,
`status` tinyint NOT NULL COMMENT '0-已取消 1-待支付 2-已支付 3-已完成',
`create_time` datetime NOT NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `idx_order_no` (`order_no`),
KEY `idx_user` (`user_id`),
KEY `idx_homestay` (`homestay_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
3.2 关键业务表关系
- 用户与民宿:一对多关系(一个用户可以拥有多个民宿)
- 民宿与订单:一对多关系(一个民宿可以有多个订单)
- 用户与订单:一对多关系(一个用户可以有多个订单)
这种关系设计保证了数据的完整性和查询效率。我们特别注意了索引的建立,确保高频查询字段都有合适的索引覆盖。
4. 后端核心功能实现
4.1 用户认证与授权
采用JWT(JSON Web Token)实现无状态认证:
java复制@Service
public class AuthService {
@Value("${jwt.secret}")
private String secret;
@Value("${jwt.expiration}")
private Long expiration;
public String generateToken(UserDetails userDetails) {
Map<String, Object> claims = new HashMap<>();
return Jwts.builder()
.setClaims(claims)
.setSubject(userDetails.getUsername())
.setIssuedAt(new Date())
.setExpiration(new Date(System.currentTimeMillis() + expiration * 1000))
.signWith(SignatureAlgorithm.HS512, secret)
.compact();
}
public Boolean validateToken(String token, UserDetails userDetails) {
final String username = extractUsername(token);
return (username.equals(userDetails.getUsername()) && !isTokenExpired(token));
}
// 其他辅助方法...
}
配合Spring Security配置:
java复制@Configuration
@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter {
@Override
protected void configure(HttpSecurity http) throws Exception {
http.csrf().disable()
.authorizeRequests()
.antMatchers("/api/auth/**").permitAll()
.antMatchers("/api/user/**").hasAnyRole("USER", "OWNER", "ADMIN")
.antMatchers("/api/owner/**").hasRole("OWNER")
.antMatchers("/api/admin/**").hasRole("ADMIN")
.anyRequest().authenticated()
.and()
.addFilter(new JwtAuthenticationFilter(authenticationManager()))
.addFilter(new JwtAuthorizationFilter(authenticationManager()))
.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS);
}
// 其他配置...
}
4.2 民宿预约业务逻辑
预约核心流程:
- 检查日期有效性
- 检查民宿是否可预订
- 检查日期冲突
- 创建订单
实现代码示例:
java复制@Service
@Transactional
public class BookingService {
@Autowired
private HomestayRepository homestayRepository;
@Autowired
private OrderRepository orderRepository;
public Order createBooking(BookingRequest request, Long userId) {
// 1. 验证民宿是否存在且可预订
Homestay homestay = homestayRepository.findById(request.getHomestayId())
.orElseThrow(() -> new BusinessException("民宿不存在"));
if (homestay.getStatus() != 1) {
throw new BusinessException("该民宿暂不可预订");
}
// 2. 验证日期有效性
if (request.getCheckInDate().isBefore(LocalDate.now())) {
throw new BusinessException("入住日期不能早于今天");
}
if (request.getCheckOutDate().isBefore(request.getCheckInDate())) {
throw new BusinessException("退房日期不能早于入住日期");
}
// 3. 检查日期冲突
boolean hasConflict = orderRepository.existsByHomestayAndDateRange(
homestay.getId(),
request.getCheckInDate(),
request.getCheckOutDate());
if (hasConflict) {
throw new BusinessException("该时间段已被预订");
}
// 4. 计算总价
long days = ChronoUnit.DAYS.between(request.getCheckInDate(), request.getCheckOutDate());
BigDecimal totalAmount = homestay.getPrice().multiply(new BigDecimal(days));
// 5. 创建订单
Order order = new Order();
order.setOrderNo(generateOrderNo());
order.setUserId(userId);
order.setHomestayId(homestay.getId());
order.setCheckInDate(request.getCheckInDate());
order.setCheckOutDate(request.getCheckOutDate());
order.setTotalAmount(totalAmount);
order.setStatus(1); // 待支付
order.setCreateTime(LocalDateTime.now());
return orderRepository.save(order);
}
private String generateOrderNo() {
// 生成唯一订单号逻辑...
}
}
4.3 支付处理
我们集成了支付宝沙箱环境进行支付处理:
java复制@Service
public class PaymentService {
@Value("${alipay.appId}")
private String appId;
@Value("${alipay.privateKey}")
private String privateKey;
@Value("${alipay.publicKey}")
private String publicKey;
@Value("${alipay.notifyUrl}")
private String notifyUrl;
@Value("${alipay.returnUrl}")
private String returnUrl;
public String createPayment(Order order) throws AlipayApiException {
AlipayClient alipayClient = new DefaultAlipayClient(
"https://openapi.alipaydev.com/gateway.do",
appId,
privateKey,
"json",
"UTF-8",
publicKey,
"RSA2");
AlipayTradePagePayRequest request = new AlipayTradePagePayRequest();
request.setReturnUrl(returnUrl);
request.setNotifyUrl(notifyUrl);
JSONObject bizContent = new JSONObject();
bizContent.put("out_trade_no", order.getOrderNo());
bizContent.put("total_amount", order.getTotalAmount());
bizContent.put("subject", "民宿预订:" + order.getHomestay().getName());
bizContent.put("product_code", "FAST_INSTANT_TRADE_PAY");
request.setBizContent(bizContent.toString());
return alipayClient.pageExecute(request).getBody();
}
public boolean verifyPayment(Map<String, String> params) throws AlipayApiException {
return AlipaySignature.rsaCheckV1(
params,
publicKey,
"UTF-8",
"RSA2");
}
}
5. 前端关键功能实现
5.1 民宿列表与搜索
使用Vue 3的Composition API实现:
vue复制<template>
<div class="homestay-list">
<el-row :gutter="20">
<el-col :span="6" v-for="homestay in filteredHomestays" :key="homestay.id">
<homestay-card :homestay="homestay" @click="viewDetail(homestay.id)"/>
</el-col>
</el-row>
<el-pagination
@current-change="handlePageChange"
:current-page="pagination.current"
:page-size="pagination.size"
:total="pagination.total"
layout="prev, pager, next"
/>
</div>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { searchHomestays } from '@/api/homestay'
import HomestayCard from '@/components/HomestayCard.vue'
const router = useRouter()
const homestays = ref([])
const searchParams = ref({
location: '',
checkInDate: '',
checkOutDate: '',
priceRange: [0, 2000]
})
const pagination = ref({
current: 1,
size: 12,
total: 0
})
const filteredHomestays = computed(() => {
return homestays.value.filter(homestay => {
const matchesLocation = !searchParams.value.location ||
homestay.location.includes(searchParams.value.location)
const matchesPrice = homestay.price >= searchParams.value.priceRange[0] &&
homestay.price <= searchParams.value.priceRange[1]
return matchesLocation && matchesPrice
})
})
const fetchHomestays = async () => {
try {
const res = await searchHomestays({
page: pagination.value.current,
size: pagination.value.size,
...searchParams.value
})
homestays.value = res.data.records
pagination.value.total = res.data.total
} catch (error) {
console.error('获取民宿列表失败:', error)
}
}
const handlePageChange = (page) => {
pagination.value.current = page
fetchHomestays()
}
const viewDetail = (id) => {
router.push(`/homestay/${id}`)
}
onMounted(() => {
fetchHomestays()
})
</script>
5.2 日期选择与冲突检测
使用Element Plus的DatePicker组件,并添加自定义验证:
vue复制<template>
<el-form :model="form" :rules="rules" ref="bookingForm">
<el-form-item label="入住日期" prop="checkInDate">
<el-date-picker
v-model="form.checkInDate"
type="date"
placeholder="选择日期"
:disabled-date="disabledCheckInDate"
@change="handleCheckInChange"
/>
</el-form-item>
<el-form-item label="退房日期" prop="checkOutDate">
<el-date-picker
v-model="form.checkOutDate"
type="date"
placeholder="选择日期"
:disabled-date="disabledCheckOutDate"
/>
</el-form-item>
</el-form>
</template>
<script setup>
import { ref, computed } from 'vue'
import { checkDateConflict } from '@/api/booking'
const props = defineProps({
homestayId: {
type: Number,
required: true
},
bookedDates: {
type: Array,
default: () => []
}
})
const form = ref({
checkInDate: '',
checkOutDate: ''
})
const disabledCheckInDate = (time) => {
return time.getTime() < Date.now() - 86400000
}
const disabledCheckOutDate = (time) => {
if (!form.value.checkInDate) return true
return time.getTime() <= new Date(form.value.checkInDate).getTime()
}
const handleCheckInChange = async () => {
if (form.value.checkInDate && form.value.checkOutDate) {
try {
const conflict = await checkDateConflict({
homestayId: props.homestayId,
checkInDate: form.value.checkInDate,
checkOutDate: form.value.checkOutDate
})
if (conflict.data) {
ElMessage.warning('您选择的日期区间存在冲突,请重新选择')
form.value.checkOutDate = ''
}
} catch (error) {
console.error('检查日期冲突失败:', error)
}
}
}
const rules = {
checkInDate: [
{ required: true, message: '请选择入住日期', trigger: 'blur' }
],
checkOutDate: [
{ required: true, message: '请选择退房日期', trigger: 'blur' },
{ validator: validateCheckOutDate, trigger: 'blur' }
]
}
const validateCheckOutDate = (rule, value, callback) => {
if (!value) {
callback(new Error('请选择退房日期'))
} else if (new Date(value) <= new Date(form.value.checkInDate)) {
callback(new Error('退房日期必须晚于入住日期'))
} else {
callback()
}
}
</script>
5.3 支付流程实现
集成支付宝支付页面:
vue复制<template>
<div class="payment-container">
<el-steps :active="activeStep" finish-status="success">
<el-step title="填写订单"></el-step>
<el-step title="支付"></el-step>
<el-step title="完成"></el-step>
</el-steps>
<div v-if="activeStep === 0">
<!-- 订单信息表单 -->
</div>
<div v-if="activeStep === 1" class="payment-content">
<div v-html="paymentForm" ref="paymentForm"></div>
</div>
<div v-if="activeStep === 2" class="result-content">
<el-result icon="success" title="支付成功">
<template #extra>
<el-button type="primary" @click="goToOrderDetail">查看订单</el-button>
</template>
</el-result>
</div>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { createPayment } from '@/api/payment'
const route = useRoute()
const router = useRouter()
const activeStep = ref(0)
const paymentForm = ref('')
const orderId = ref(route.params.id)
const submitOrder = async () => {
try {
const res = await createPayment(orderId.value)
paymentForm.value = res.data
activeStep.value = 1
// 自动提交支付表单
nextTick(() => {
document.forms[0].submit()
})
} catch (error) {
console.error('创建支付失败:', error)
}
}
const goToOrderDetail = () => {
router.push(`/order/${orderId.value}`)
}
// 支付完成后从支付宝跳转回来时的处理
onMounted(() => {
if (route.query.out_trade_no) {
activeStep.value = 2
}
})
</script>
6. 系统部署与优化
6.1 后端部署方案
我们采用Docker容器化部署SpringBoot应用:
dockerfile复制# Dockerfile
FROM openjdk:11-jre-slim
WORKDIR /app
COPY target/homestay-booking-0.0.1-SNAPSHOT.jar app.jar
EXPOSE 8080
ENTRYPOINT ["java", "-jar", "app.jar"]
构建并运行容器:
bash复制# 构建镜像
docker build -t homestay-booking .
# 运行容器
docker run -d -p 8080:8080 \
-e SPRING_DATASOURCE_URL=jdbc:mysql://mysql-server:3306/homestay \
-e SPRING_DATASOURCE_USERNAME=root \
-e SPRING_DATASOURCE_PASSWORD=yourpassword \
--name homestay-booking \
homestay-booking
6.2 前端部署方案
使用Nginx部署Vue应用:
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;
}
}
6.3 性能优化措施
- 数据库优化:
- 添加合适的索引
- 使用连接池(HikariCP)
- 对大表进行分表
- 缓存策略:
java复制@Configuration
@EnableCaching
public class CacheConfig {
@Bean
public RedisCacheManager cacheManager(RedisConnectionFactory factory) {
RedisCacheConfiguration config = RedisCacheConfiguration.defaultCacheConfig()
.entryTtl(Duration.ofMinutes(30))
.disableCachingNullValues()
.serializeValuesWith(SerializationPair.fromSerializer(new GenericJackson2JsonRedisSerializer()));
return RedisCacheManager.builder(factory)
.cacheDefaults(config)
.transactionAware()
.build();
}
}
@Service
public class HomestayService {
@Cacheable(value = "homestay", key = "#id")
public Homestay getById(Long id) {
return homestayRepository.findById(id).orElse(null);
}
@CacheEvict(value = "homestay", key = "#homestay.id")
public Homestay update(Homestay homestay) {
return homestayRepository.save(homestay);
}
}
- 前端性能优化:
- 路由懒加载
- 组件按需引入
- 图片懒加载
- API请求节流
7. 开发中的经验与教训
7.1 日期处理的坑
在初期开发中,我们遇到了时区问题。MySQL的日期时间与Java的LocalDateTime在时区处理上存在差异。解决方案是在JDBC连接字符串中明确指定时区:
code复制spring.datasource.url=jdbc:mysql://localhost:3306/homestay?useSSL=false&serverTimezone=Asia/Shanghai
7.2 并发预订问题
在高并发场景下,可能会出现超卖问题。我们通过以下方案解决:
- 数据库乐观锁:
java复制@Transactional
public boolean confirmBooking(Long orderId) {
Order order = orderRepository.findById(orderId).orElseThrow();
if (order.getStatus() != 1) {
return false;
}
int updated = orderRepository.updateStatus(orderId, 1, 2);
return updated > 0;
}
// Repository中的更新方法
@Modifying
@Query("UPDATE Order o SET o.status = :newStatus WHERE o.id = :id AND o.status = :oldStatus")
int updateStatus(@Param("id") Long id, @Param("oldStatus") int oldStatus, @Param("newStatus") int newStatus);
- 使用Redis分布式锁:
java复制public boolean tryLock(String key, long expireTime) {
return redisTemplate.opsForValue().setIfAbsent(key, "locked", expireTime, TimeUnit.SECONDS);
}
public void unlock(String key) {
redisTemplate.delete(key);
}
7.3 支付回调处理
支付宝回调需要特别注意:
- 验证签名
- 处理幂等性
- 记录完整的回调信息
实现代码:
java复制@RestController
@RequestMapping("/api/payment")
public class PaymentController {
@PostMapping("/alipay/notify")
public String alipayNotify(HttpServletRequest request) {
Map<String, String> params = convertRequestParams(request);
try {
// 1. 验证签名
boolean signVerified = paymentService.verifyPayment(params);
if (!signVerified) {
return "failure";
}
// 2. 验证订单状态
String orderNo = params.get("out_trade_no");
Order order = orderService.findByOrderNo(orderNo);
if (order == null) {
return "failure";
}
// 3. 处理业务逻辑
if ("TRADE_SUCCESS".equals(params.get("trade_status"))) {
orderService.confirmPayment(orderNo, params);
}
return "success";
} catch (Exception e) {
log.error("支付回调处理失败", e);
return "failure";
}
}
private Map<String, String> convertRequestParams(HttpServletRequest request) {
Map<String, String> params = new HashMap<>();
Map<String, String[]> requestParams = request.getParameterMap();
for (String name : requestParams.keySet()) {
String[] values = requestParams.get(name);
String valueStr = "";
for (int i = 0; i < values.length; i++) {
valueStr = (i == values.length - 1) ? valueStr + values[i] : valueStr + values[i] + ",";
}
params.put(name, valueStr);
}
return params;
}
}
8. 系统扩展与未来改进
虽然当前系统已经实现了核心功能,但还有几个方向可以进一步优化:
-
微服务化改造:将系统拆分为用户服务、民宿服务、订单服务、支付服务等独立微服务,提高系统可扩展性。
-
引入消息队列:使用RabbitMQ或Kafka处理异步任务,如发送预订确认邮件、生成账单等。
-
增加智能推荐:基于用户历史行为数据,实现个性化民宿推荐。
-
完善监控系统:集成Prometheus和Grafana,实时监控系统健康状态。
-
多租户支持:改造系统架构,支持多个景区同时使用同一套系统。
在开发过程中,我们深刻体会到良好的架构设计对后期扩展的重要性。特别是在数据库表设计阶段,多考虑一些未来可能的业务场景,可以避免很多后期的重构工作。
