博客 / 詳情

返回

基於 Spring Boot 的交友盲盒源碼開發:核心功能實現與數據庫設計方案

在數字化社交時代,傳統社交平台的確定性匹配模式逐漸引發用户審美疲勞,交友盲盒模式應運而生。這種結合隨機性與趣味性的社交新形態,不僅滿足了年輕人對未知社交體驗的好奇,也創造了更具吸引力的社交裂變場景。本文詳細介紹了基於 Spring Boot 框架的交友盲盒系統的設計與實現,包括系統架構、核心功能模塊、數據庫設計方案以及關鍵代碼實現。

技術棧選擇

  • 源碼及演示:m.ymzan.top
  • 後端框架:Spring Boot 2.7.x
  • 數據庫:MySQL 8.0 + Redis
  • 消息中間件:WebSocket + RabbitMQ
  • 安全框架:Spring Security + JWT
  • 部署:Docker + Nginx

數據庫設計方案

1. 數據庫總體設計

系統採用MySQL作為主數據庫,Redis作為緩存和會話存儲。數據庫設計遵循第三範式,確保數據一致性。

2. 核心表結構設計

-- 用户表
CREATE TABLE `user` (
  `id` bigint NOT NULL AUTO_INCREMENT COMMENT '用户ID',
  `username` varchar(50) NOT NULL COMMENT '用户名',
  `password` varchar(100) NOT NULL COMMENT '加密密碼',
  `nickname` varchar(50) DEFAULT NULL COMMENT '暱稱',
  `avatar` varchar(255) DEFAULT NULL COMMENT '頭像URL',
  `gender` tinyint DEFAULT '0' COMMENT '性別 0:未知 1:男 2:女',
  `age` int DEFAULT NULL COMMENT '年齡',
  `interests` json DEFAULT NULL COMMENT '興趣標籤',
  `matching_count` int DEFAULT '0' COMMENT '匹配次數',
  `status` tinyint DEFAULT '1' COMMENT '狀態 0:禁用 1:正常',
  `created_at` datetime DEFAULT CURRENT_TIMESTAMP,
  `updated_at` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
  PRIMARY KEY (`id`),
  UNIQUE KEY `uk_username` (`username`),
  KEY `idx_status` (`status`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户表';

-- 盲盒匹配記錄表
CREATE TABLE `blind_box_match` (
  `id` bigint NOT NULL AUTO_INCREMENT,
  `match_code` varchar(32) NOT NULL COMMENT '匹配會話碼',
  `user_id1` bigint NOT NULL COMMENT '用户1ID',
  `user_id2` bigint NOT NULL COMMENT '用户2ID',
  `match_time` datetime DEFAULT CURRENT_TIMESTAMP COMMENT '匹配時間',
  `expire_time` datetime DEFAULT NULL COMMENT '匹配過期時間',
  `status` tinyint DEFAULT '1' COMMENT '1:匹配中 2:已完成 3:已過期',
  PRIMARY KEY (`id`),
  UNIQUE KEY `uk_match_code` (`match_code`),
  KEY `idx_user_id1` (`user_id1`),
  KEY `idx_user_id2` (`user_id2`),
  KEY `idx_status` (`status`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='盲盒匹配記錄';

-- 聊天消息表
CREATE TABLE `chat_message` (
  `id` bigint NOT NULL AUTO_INCREMENT,
  `match_id` bigint NOT NULL COMMENT '匹配記錄ID',
  `sender_id` bigint NOT NULL COMMENT '發送者ID',
  `content` text COMMENT '消息內容',
  `message_type` tinyint DEFAULT '1' COMMENT '1:文本 2:圖片 3:語音',
  `is_read` tinyint DEFAULT '0' COMMENT '是否已讀',
  `created_at` datetime DEFAULT CURRENT_TIMESTAMP,
  PRIMARY KEY (`id`),
  KEY `idx_match_id` (`match_id`),
  KEY `idx_sender_id` (`sender_id`),
  KEY `idx_created` (`created_at`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='聊天消息表';

-- 用户反饋表
CREATE TABLE `user_feedback` (
  `id` bigint NOT NULL AUTO_INCREMENT,
  `match_id` bigint NOT NULL,
  `user_id` bigint NOT NULL,
  `target_user_id` bigint NOT NULL,
  `rating` tinyint DEFAULT NULL COMMENT '評分1-5',
  `comment` varchar(500) DEFAULT NULL COMMENT '評價內容',
  `tags` json DEFAULT NULL COMMENT '標籤',
  `created_at` datetime DEFAULT CURRENT_TIMESTAMP,
  PRIMARY KEY (`id`),
  UNIQUE KEY `uk_match_user` (`match_id`,`user_id`),
  KEY `idx_target_user` (`target_user_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户評價表';

3. Redis緩存設計

  • 用户會話:session:{userId}
  • 匹配隊列:matching:queue
  • 在線狀態:online:{userId}
  • 消息緩存:chat:{matchId}:last10

核心功能實現

1. 用户認證模塊

1.1 JWT工具類實現

@Component
public class JwtTokenProvider {
    
    @Value("${jwt.secret}")
    private String jwtSecret;
    
    @Value("${jwt.expiration}")
    private int jwtExpiration;
    
    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() + jwtExpiration))
                .signWith(SignatureAlgorithm.HS512, jwtSecret)
                .compact();
    }
    
    public String getUsernameFromToken(String token) {
        return Jwts.parser()
                .setSigningKey(jwtSecret)
                .parseClaimsJws(token)
                .getBody()
                .getSubject();
    }
    
    public boolean validateToken(String token) {
        try {
            Jwts.parser().setSigningKey(jwtSecret).parseClaimsJws(token);
            return true;
        } catch (Exception e) {
            return false;
        }
    }
}

1.2 Spring Security配置

@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfig extends WebSecurityConfigurerAdapter {
    
    private final JwtAuthenticationFilter jwtAuthenticationFilter;
    private final UserDetailsServiceImpl userDetailsService;
    
    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http.csrf().disable()
            .sessionManagement()
            .sessionCreationPolicy(SessionCreationPolicy.STATELESS)
            .and()
            .authorizeRequests()
            .antMatchers("/api/auth/**", "/websocket/**").permitAll()
            .antMatchers("/api/user/register", "/api/user/login").permitAll()
            .anyRequest().authenticated();
        
        http.addFilterBefore(jwtAuthenticationFilter, 
                           UsernamePasswordAuthenticationFilter.class);
    }
    
    @Bean
    public PasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }
    
    @Override
    protected void configure(AuthenticationManagerBuilder auth) throws Exception {
        auth.userDetailsService(userDetailsService)
            .passwordEncoder(passwordEncoder());
    }
}

2. 盲盒匹配算法

2.1 匹配服務實現

@Service
@Slf4j
public class MatchService {
    
    @Autowired
    private RedisTemplate<String, String> redisTemplate;
    
    @Autowired
    private UserService userService;
    
    @Autowired
    private BlindBoxMatchRepository matchRepository;
    
    private static final String MATCHING_QUEUE = "matching:queue";
    private static final long MATCH_TIMEOUT = 30 * 60 * 1000; // 30分鐘
    
    /**
     * 加入匹配隊列
     */
    public MatchResult joinMatchingQueue(Long userId) {
        // 檢查用户是否已在隊列中
        Boolean isInQueue = redisTemplate.opsForSet().isMember(MATCHING_QUEUE, userId.toString());
        if (Boolean.TRUE.equals(isInQueue)) {
            return MatchResult.alreadyInQueue();
        }
        
        // 加入隊列
        redisTemplate.opsForSet().add(MATCHING_QUEUE, userId.toString());
        
        // 嘗試匹配
        return tryMatchUser(userId);
    }
    
    /**
     * 嘗試匹配用户
     */
    private MatchResult tryMatchUser(Long userId) {
        // 獲取隊列中所有用户
        Set<String> queueUsers = redisTemplate.opsForSet().members(MATCHING_QUEUE);
        
        if (queueUsers == null || queueUsers.size() < 2) {
            return MatchResult.waiting();
        }
        
        // 排除自己
        List<Long> otherUsers = queueUsers.stream()
            .map(Long::valueOf)
            .filter(id -> !id.equals(userId))
            .collect(Collectors.toList());
        
        if (otherUsers.isEmpty()) {
            return MatchResult.waiting();
        }
        
        // 隨機選擇匹配用户
        Long matchedUserId = otherUsers.get(new Random().nextInt(otherUsers.size()));
        
        // 創建匹配記錄
        BlindBoxMatch match = createMatchRecord(userId, matchedUserId);
        
        // 從隊列移除
        redisTemplate.opsForSet().remove(MATCHING_QUEUE, 
            userId.toString(), matchedUserId.toString());
        
        return MatchResult.success(match, matchedUserId);
    }
    
    /**
     * 創建匹配記錄
     */
    private BlindBoxMatch createMatchRecord(Long userId1, Long userId2) {
        BlindBoxMatch match = new BlindBoxMatch();
        match.setMatchCode(generateMatchCode());
        match.setUserId1(userId1);
        match.setUserId2(userId2);
        match.setMatchTime(new Date());
        match.setExpireTime(new Date(System.currentTimeMillis() + MATCH_TIMEOUT));
        match.setStatus(MatchStatus.MATCHING.getCode());
        
        return matchRepository.save(match);
    }
    
    private String generateMatchCode() {
        return UUID.randomUUID().toString().replace("-", "");
    }
}

2.2 匹配結果DTO

@Data
@AllArgsConstructor
@NoArgsConstructor
public class MatchResult {
    private boolean success;
    private String message;
    private String matchCode;
    private Long matchedUserId;
    private Date matchTime;
    
    public static MatchResult success(BlindBoxMatch match, Long matchedUserId) {
        return new MatchResult(true, "匹配成功", 
            match.getMatchCode(), matchedUserId, match.getMatchTime());
    }
    
    public static MatchResult waiting() {
        return new MatchResult(false, "等待匹配中...", 
            null, null, null);
    }
    
    public static MatchResult alreadyInQueue() {
        return new MatchResult(false, "您已在匹配隊列中", 
            null, null, null);
    }
}

3. 即時通訊模塊

3.1 WebSocket配置

@Configuration
@EnableWebSocket
public class WebSocketConfig implements WebSocketConfigurer {
    
    @Autowired
    private ChatWebSocketHandler chatWebSocketHandler;
    
    @Override
    public void registerWebSocketHandlers(WebSocketHandlerRegistry registry) {
        registry.addHandler(chatWebSocketHandler, "/ws/chat")
                .setAllowedOrigins("*")
                .addInterceptors(new HttpSessionHandshakeInterceptor());
    }
    
    @Bean
    public ServerEndpointExporter serverEndpointExporter() {
        return new ServerEndpointExporter();
    }
}

3.2 WebSocket處理器

@Component
@Slf4j
public class ChatWebSocketHandler extends TextWebSocketHandler {
    
    @Autowired
    private ChatService chatService;
    
    @Autowired
    private JwtTokenProvider jwtTokenProvider;
    
    private static final ConcurrentHashMap<String, WebSocketSession> sessions = 
        new ConcurrentHashMap<>();
    
    @Override
    public void afterConnectionEstablished(WebSocketSession session) {
        String token = extractToken(session);
        if (token != null && jwtTokenProvider.validateToken(token)) {
            String username = jwtTokenProvider.getUsernameFromToken(token);
            sessions.put(username, session);
            log.info("用户 {} WebSocket連接建立", username);
        } else {
            try {
                session.close(CloseStatus.NOT_ACCEPTABLE);
            } catch (IOException e) {
                log.error("關閉連接失敗", e);
            }
        }
    }
    
    @Override
    protected void handleTextMessage(WebSocketSession session, 
                                   TextMessage message) throws Exception {
        String payload = message.getPayload();
        ChatMessageDTO chatMessage = JSON.parseObject(payload, ChatMessageDTO.class);
        
        // 保存消息到數據庫
        chatService.saveMessage(chatMessage);
        
        // 轉發給接收方
        WebSocketSession targetSession = sessions.get(chatMessage.getReceiverId());
        if (targetSession != null && targetSession.isOpen()) {
            targetSession.sendMessage(message);
        }
    }
    
    private String extractToken(WebSocketSession session) {
        List<String> tokenHeaders = session.getHandshakeHeaders().get("Authorization");
        if (tokenHeaders != null && !tokenHeaders.isEmpty()) {
            String header = tokenHeaders.get(0);
            if (header.startsWith("Bearer ")) {
                return header.substring(7);
            }
        }
        return null;
    }
}

3.3 消息傳輸對象

@Data
@AllArgsConstructor
@NoArgsConstructor
public class ChatMessageDTO {
    private Long matchId;
    private Long senderId;
    private Long receiverId;
    private String content;
    private Integer messageType;
    private Date sendTime;
}

@Entity
@Table(name = "chat_message")
@Data
public class ChatMessage {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    
    @Column(name = "match_id")
    private Long matchId;
    
    @Column(name = "sender_id")
    private Long senderId;
    
    @Column(name = "content")
    private String content;
    
    @Column(name = "message_type")
    private Integer messageType;
    
    @Column(name = "is_read")
    private Boolean isRead = false;
    
    @Column(name = "created_at")
    private Date createdAt = new Date();
}

4. 用户反饋系統

@Service
public class FeedbackService {
    
    @Autowired
    private FeedbackRepository feedbackRepository;
    
    @Autowired
    private MatchRepository matchRepository;
    
    /**
     * 提交用户反饋
     */
    public FeedbackResult submitFeedback(FeedbackRequest request) {
        // 驗證匹配記錄
        Optional<BlindBoxMatch> matchOpt = matchRepository.findById(request.getMatchId());
        if (!matchOpt.isPresent()) {
            return FeedbackResult.error("匹配記錄不存在");
        }
        
        BlindBoxMatch match = matchOpt.get();
        if (!match.getUserId1().equals(request.getUserId()) && 
            !match.getUserId2().equals(request.getUserId())) {
            return FeedbackResult.error("無權評價此匹配");
        }
        
        // 檢查是否已評價
        Optional<UserFeedback> existingFeedback = feedbackRepository
            .findByMatchIdAndUserId(request.getMatchId(), request.getUserId());
        if (existingFeedback.isPresent()) {
            return FeedbackResult.error("您已評價過此次匹配");
        }
        
        // 保存評價
        UserFeedback feedback = new UserFeedback();
        feedback.setMatchId(request.getMatchId());
        feedback.setUserId(request.getUserId());
        feedback.setTargetUserId(getTargetUserId(match, request.getUserId()));
        feedback.setRating(request.getRating());
        feedback.setComment(request.getComment());
        feedback.setTags(request.getTags());
        
        feedbackRepository.save(feedback);
        
        return FeedbackResult.success("評價提交成功");
    }
    
    private Long getTargetUserId(BlindBoxMatch match, Long userId) {
        return match.getUserId1().equals(userId) ? 
               match.getUserId2() : match.getUserId1();
    }
}

部署與監控

1. Docker部署配置

# Dockerfile
FROM openjdk:11-jre-slim
VOLUME /tmp
COPY target/blind-box-0.0.1-SNAPSHOT.jar app.jar
ENTRYPOINT ["java", "-jar", "/app.jar"]

2. 應用監控

  1. Spring Boot Actuator健康檢查
  2. Prometheus + Grafana監控
  3. ELK日誌收集

結論

基於Spring Boot的交友盲盒系統開發,不僅是一次技術創新實踐,更是對現代社交需求的深度迴應。通過本文闡述的分層架構設計、智能匹配算法和實時交互方案,大家可以構建了一個既保障系統性能又注重用户體驗的社交平台,通過合理的數據庫設計和緩存策略,能夠支撐較高的併發訪問。

user avatar
0 位用戶收藏了這個故事!

發佈 評論

Some HTML is okay.