1. 项目背景与核心价值
家政服务行业近年来呈现爆发式增长,但传统电话预约模式存在服务不透明、评价体系缺失等痛点。这个基于SpringBoot的家政服务预约系统,正是为了解决以下行业核心问题:
- 服务标准化难题:通过线上展示服务项目、价格、服务者资质,消除信息不对称
- 评价体系缺失:建立双向评价机制,服务完成后用户可对服务质量打分,服务者也能反馈用户配合度
- 预约效率低下:可视化时间选择、在线支付、服务进度跟踪等功能大幅提升交易效率
我在开发过程中发现,真正的技术难点不在于基础CRUD实现,而在于如何设计合理的状态机来管理服务生命周期,以及如何构建防刷评的评价体系。下面分享具体实现方案。
2. 系统架构设计
2.1 技术栈选型
code复制前端:Vue.js + ElementUI
后端:SpringBoot 2.7 + MyBatis-Plus
数据库:MySQL 8.0
中间件:Redis(缓存+分布式锁)
消息队列:RabbitMQ(异步处理评价通知)
选择这套技术栈主要基于:
- SpringBoot的快速开发特性适合互联网项目迭代
- MyBatis-Plus的Lambda查询能减少30%的SQL编写量
- Redis分布式锁解决高并发预约时的超卖问题
- RabbitMQ异步处理评价通知避免主流程阻塞
2.2 核心业务流程图
mermaid复制graph TD
A[用户注册/登录] --> B[浏览服务项目]
B --> C[选择服务时间]
C --> D[在线支付]
D --> E[服务执行]
E --> F[双向评价]
F --> G[评价展示]
注意:实际开发时要为每个状态变更设计防重放机制,比如支付成功后修改订单状态时需要version乐观锁控制
3. 核心模块实现
3.1 预约状态机设计
定义6种核心状态和转换规则:
| 状态 | 允许操作 | 下一状态 |
|---|---|---|
| 待支付 | 取消/支付 | 已取消/待服务 |
| 待服务 | 开始服务 | 服务中 |
| 服务中 | 完成服务 | 待评价 |
| 待评价 | 提交评价 | 已完成 |
| 已取消 | - | - |
| 已完成 | - | - |
代码实现采用状态模式:
java复制public interface OrderState {
void cancel(Order order);
void pay(Order order);
// 其他操作方法...
}
@Service
@RequiredArgsConstructor
public class PendingPaymentState implements OrderState {
private final OrderRepository orderRepo;
@Override
@Transactional
public void pay(Order order) {
if (!order.getStatus().equals(OrderStatus.PENDING_PAYMENT)) {
throw new IllegalStateException("当前状态不可支付");
}
order.setStatus(OrderStatus.PENDING_SERVICE);
orderRepo.updateWithVersion(order);
}
}
3.2 防刷评体系设计
评价系统面临的主要风险:
- 商家刷好评
- 恶意差评
- 虚假评价
我们采用三级防御机制:
- 行为验证:评价前强制验证手机号
- 关联性校验:只有实际完成服务的用户可评价
- 语义分析:使用NLP检测评价内容相似度
核心校验逻辑:
java复制public class ReviewValidator {
public void validate(Review review) {
// 校验订单状态
Order order = orderService.getById(review.getOrderId());
if (!order.getStatus().equals(OrderStatus.COMPLETED)) {
throw new BusinessException("未完成服务不可评价");
}
// 校验评价频率
long reviewCount = reviewService.lambdaQuery()
.eq(Review::getUserId, review.getUserId())
.ge(Review::getCreateTime, LocalDateTime.now().minusDays(1))
.count();
if (reviewCount > 3) {
throw new BusinessException("评价过于频繁");
}
}
}
4. 典型问题解决方案
4.1 高并发预约冲突
当热门服务时段开放预约时,可能出现超卖问题。我们采用Redis分布式锁+数据库乐观锁方案:
java复制public boolean bookService(Long serviceId, LocalDateTime timeSlot, Long userId) {
String lockKey = "lock:service:" + serviceId + ":" + timeSlot;
// 获取分布式锁(设置10秒过期防止死锁)
boolean locked = redisTemplate.opsForValue()
.setIfAbsent(lockKey, "1", 10, TimeUnit.SECONDS);
if (!locked) {
throw new BusinessException("当前时段预约火爆,请稍后再试");
}
try {
// 查询剩余可预约量
ServiceTimeSlot slot = slotService.getByTime(serviceId, timeSlot);
if (slot.getAvailableCount() <= 0) {
return false;
}
// 扣减库存(带版本号校验)
int updated = slotService.lambdaUpdate()
.eq(ServiceTimeSlot::getId, slot.getId())
.eq(ServiceTimeSlot::getVersion, slot.getVersion())
.setSql("available_count = available_count - 1")
.update();
return updated > 0;
} finally {
// 释放锁
redisTemplate.delete(lockKey);
}
}
4.2 评价排序算法
评价展示不是简单按时间倒序,而是采用加权分数:
code复制综合分数 = 基础分(0.6) + 用户信用分(0.2) + 时效分(0.2)
其中:
- 基础分:评价星级(1-5分标准化到0-1)
- 用户信用分:根据该用户历史评价准确度计算
- 时效分:评价时间越近分数越高(30天衰减到0)
实现代码:
java复制public List<ReviewVO> getSortedReviews(Long serviceId) {
List<Review> reviews = reviewService.getByServiceId(serviceId);
return reviews.stream()
.map(this::calculateScore)
.sorted(Comparator.comparing(ReviewVO::getCompositeScore).reversed())
.collect(Collectors.toList());
}
private ReviewVO calculateScore(Review review) {
// 获取用户信用分(缓存优化)
UserCredit credit = creditService.getUserCredit(review.getUserId());
// 计算时效分(30天线性衰减)
long days = ChronoUnit.DAYS.between(review.getCreateTime(), LocalDateTime.now());
double timeScore = Math.max(0, 1 - days / 30.0);
// 综合计算
double compositeScore = review.getRating() * 0.6
+ credit.getScore() * 0.2
+ timeScore * 0.2;
return ReviewVO.builder()
.review(review)
.compositeScore(compositeScore)
.build();
}
5. 部署与性能优化
5.1 数据库分表策略
评价数据增长快速,我们按照服务ID哈希分表:
- 主表:review(存储基础信息)
- 分表:review_[0-15](按service_id%16分布)
配置MyBatis动态表名拦截器:
java复制public class ReviewTableInterceptor implements Interceptor {
@Override
public Object intercept(Invocation invocation) {
// 获取参数
Object parameter = invocation.getArgs()[1];
if (parameter instanceof Review) {
Review review = (Review) parameter;
// 设置动态表名
String tableSuffix = review.getServiceId() % 16;
TableNameHelper.set("review_" + tableSuffix);
}
return invocation.proceed();
}
}
5.2 缓存设计要点
采用多级缓存策略:
- 本地缓存(Caffeine):高频访问的服务基本信息,过期时间5分钟
- 分布式缓存(Redis):
- 服务详情:30分钟过期
- 评价列表:10分钟过期 + 分页缓存
- 缓存击穿防护:
java复制public ServiceDetail getServiceDetail(Long id) {
String cacheKey = "service:" + id;
// 1. 先查Redis
ServiceDetail detail = redisTemplate.opsForValue().get(cacheKey);
if (detail != null) {
return detail;
}
// 2. 获取分布式锁
String lockKey = "lock:" + cacheKey;
boolean locked = redisTemplate.opsForValue()
.setIfAbsent(lockKey, "1", 30, TimeUnit.SECONDS);
if (!locked) {
// 没拿到锁的请求短暂等待后重试
Thread.sleep(100);
return getServiceDetail(id);
}
try {
// 3. 二次检查缓存
detail = redisTemplate.opsForValue().get(cacheKey);
if (detail != null) {
return detail;
}
// 4. 查询数据库
detail = serviceRepository.getDetailById(id);
if (detail != null) {
redisTemplate.opsForValue().set(
cacheKey,
detail,
30,
TimeUnit.MINUTES);
}
return detail;
} finally {
redisTemplate.delete(lockKey);
}
}
6. 安全防护措施
6.1 敏感数据保护
- 支付信息加密:
java复制// 使用国密SM4加密银行卡号
public String encryptBankCard(String cardNo) {
SM4Engine engine = new SM4Engine();
engine.init(true, new KeyParameter(sm4Key.getBytes()));
byte[] encrypted = engine.processBlock(cardNo.getBytes(), 0, cardNo.length());
return Base64.encodeBase64String(encrypted);
}
- 日志脱敏处理:
java复制@Around("execution(* com..service.*.*(..))")
public Object around(ProceedingJoinPoint pjp) {
Object[] args = pjp.getArgs();
// 对手机号、身份证等敏感参数脱敏
for (int i = 0; i < args.length; i++) {
if (args[i] instanceof String) {
String arg = (String) args[i];
if (isSensitive(arg)) {
args[i] = maskSensitiveInfo(arg);
}
}
}
return pjp.proceed(args);
}
6.2 接口防刷策略
- 预约接口限流:
java复制@Slf4j
@RestController
@RequestMapping("/api/booking")
public class BookingController {
// 每用户每分钟最多5次预约请求
@RateLimiter(value = 5, key = "#userId")
@PostMapping
public Result book(@RequestParam Long userId,
@RequestParam Long serviceId) {
// 业务逻辑
}
}
- 评价接口人机验证:
java复制public void submitReview(ReviewDTO dto) {
// 验证滑块token
boolean valid = captchaService.verify(dto.getCaptchaToken());
if (!valid) {
throw new BusinessException("请完成人机验证");
}
// 继续处理评价逻辑
}
7. 监控与运维方案
7.1 业务指标监控
使用Prometheus采集关键指标:
yaml复制# application.yml
management:
endpoints:
web:
exposure:
include: prometheus,health,metrics
metrics:
export:
prometheus:
enabled: true
自定义指标采集:
java复制@RestController
public class BookingController {
private final Counter bookingCounter;
public BookingController(MeterRegistry registry) {
this.bookingCounter = Counter.builder("service.booking.count")
.tag("type", "total")
.register(registry);
}
@PostMapping
public Result book() {
bookingCounter.increment();
// 业务逻辑
}
}
7.2 日志排查技巧
- 关键日志标记:
java复制MDC.put("traceId", UUID.randomUUID().toString());
try {
log.info("开始处理预约请求");
// 业务逻辑
} finally {
MDC.clear();
}
- 慢查询监控:
sql复制-- MySQL开启慢查询日志
SET GLOBAL slow_query_log = 'ON';
SET GLOBAL long_query_time = 1;
SET GLOBAL slow_query_log_file = '/var/log/mysql/mysql-slow.log';
8. 扩展性设计
8.1 插件化支付对接
定义支付接口规范:
java复制public interface PaymentPlugin {
String getName();
PaymentResult pay(PaymentRequest request);
PaymentResult query(String orderNo);
}
// 支付宝实现
@Service
@RequiredArgsConstructor
public class AlipayPlugin implements PaymentPlugin {
private final AlipayClient alipayClient;
@Override
public PaymentResult pay(PaymentRequest request) {
// 调用支付宝SDK
}
}
支付路由策略:
java复制public PaymentPlugin getPlugin(String payType) {
switch (payType) {
case "alipay": return alipayPlugin;
case "wechat": return wechatPlugin;
default: throw new UnsupportedOperationException("不支持的支付方式");
}
}
8.2 多租户支持方案
通过ThreadLocal传递租户标识:
java复制public class TenantContext {
private static final ThreadLocal<String> currentTenant = new ThreadLocal<>();
public static void setTenant(String tenant) {
currentTenant.set(tenant);
}
public static String getTenant() {
return currentTenant.get();
}
}
// 在MyBatis拦截器中自动添加租户条件
public Object intercept(Invocation invocation) {
String tenant = TenantContext.getTenant();
if (tenant != null) {
BoundSql boundSql = ((MappedStatement)invocation.getArgs()[0])
.getBoundSql(invocation.getArgs()[1]);
String newSql = boundSql.getSql() + " AND tenant_id = '" + tenant + "'";
resetSql(invocation, newSql);
}
return invocation.proceed();
}