交友盲盒源码全解析:基于 Spring Boot+Vue 的前后端分离架构设计与实现

来源:这里教程网 时间:2026-03-01 18:34:41 作者:

  在数字化社交蓬勃发展的今天,传统社交软件的确定性匹配机制逐渐让用户感到审美疲劳。基于"未知惊喜"和"社交破冰"理念的交友盲盒系统应运而生,它通过随机匹配、匿名互动等创新玩法,为用户带来全新的社交体验。这种模式不仅降低了社交门槛,更创造了独特的互动趣味性,正在成为Z世代社交的新宠。我们将从最基础的数据库设计开始,逐步深入到Spring Boot后端业务逻辑、Vue.js前端交互、WebSocket实时通信等关键技术,最终实现一个功能完整、性能可靠、易于扩展的交友盲盒系统。每个模块都配有详细的代码示例和实现思路,确保你可以真正理解并应用这些技术。   系统架构设计  1. 技术栈选择   •源码及演示:m.ymzan.top  •前端:Vue 3+Element Plus+WebSocket  •后端:Spring Boot 2.7+MyBatis Plus  •数据库:MySQL 8.0+Redis 7.0  •实时通信:Netty+WebSocket  •部署:Docker+Nginx  2. 系统架构图

用户层(Web/小程序) → 网关层(Nginx) → 应用层(Spring Boot) → 服务层(业务逻辑) → 数据层(MySQL+Redis)
                               ↓
                         实时通信层(Netty)

   数据库设计  1. 核心表结构设计

-- 用户表
CREATE TABLE `user` (
  `id` bigint NOT NULL AUTO_INCREMENT COMMENT '用户ID',
  `nickname` varchar(50) NOT NULL COMMENT '昵称',
  `avatar` varchar(255) DEFAULT NULL COMMENT '头像',
  `gender` tinyint DEFAULT '0' COMMENT '性别 0:未知 1:男 2:女',
  `age` tinyint DEFAULT NULL COMMENT '年龄',
  `hobbies` json DEFAULT NULL COMMENT '兴趣爱好JSON数组',
  `introduction` text COMMENT '个人介绍',
  `status` tinyint DEFAULT '1' COMMENT '状态 0:禁用 1:正常 2:冻结',
  `last_login_time` datetime DEFAULT NULL COMMENT '最后登录时间',
  `create_time` datetime DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
  `update_time` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
  PRIMARY KEY (`id`),
  KEY `idx_gender_age` (`gender`,`age`),
  KEY `idx_status` (`status`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户表';
-- 盲盒表
CREATE TABLE `blind_box` (
  `id` bigint NOT NULL AUTO_INCREMENT COMMENT '盲盒ID',
  `user_id` bigint NOT NULL COMMENT '用户ID',
  `title` varchar(100) NOT NULL COMMENT '盲盒标题',
  `content_type` tinyint NOT NULL COMMENT '内容类型 1:文字 2:图片 3:语音',
  `content` text NOT NULL COMMENT '盲盒内容',
  `like_count` int DEFAULT '0' COMMENT '喜欢数',
  `match_count` int DEFAULT '0' COMMENT '匹配成功数',
  `visibility` tinyint DEFAULT '1' COMMENT '可见性 0:私密 1:公开',
  `status` tinyint DEFAULT '1' COMMENT '状态 0:关闭 1:开启',
  `expire_time` datetime DEFAULT NULL COMMENT '过期时间',
  `create_time` datetime DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
  PRIMARY KEY (`id`),
  KEY `idx_user_id` (`user_id`),
  KEY `idx_status_expire` (`status`,`expire_time`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='盲盒表';
-- 匹配记录表
CREATE TABLE `match_record` (
  `id` bigint NOT NULL AUTO_INCREMENT COMMENT '匹配ID',
  `blind_box_id` bigint NOT NULL COMMENT '盲盒ID',
  `sender_id` bigint NOT NULL COMMENT '发送方用户ID',
  `receiver_id` bigint NOT NULL COMMENT '接收方用户ID',
  `match_type` tinyint NOT NULL COMMENT '匹配类型 1:喜欢 2:超级喜欢',
  `match_status` tinyint NOT NULL COMMENT '匹配状态 0:待回应 1:匹配成功 2:已拒绝 3:已过期',
  `chat_room_id` varchar(64) DEFAULT NULL COMMENT '聊天室ID',
  `expire_time` datetime DEFAULT NULL COMMENT '匹配过期时间',
  `create_time` datetime DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
  `update_time` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
  PRIMARY KEY (`id`),
  UNIQUE KEY `uk_box_sender` (`blind_box_id`,`sender_id`),
  KEY `idx_sender_status` (`sender_id`,`match_status`),
  KEY `idx_receiver_status` (`receiver_id`,`match_status`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='匹配记录表';
-- 聊天记录表
CREATE TABLE `chat_message` (
  `id` bigint NOT NULL AUTO_INCREMENT COMMENT '消息ID',
  `chat_room_id` varchar(64) NOT NULL COMMENT '聊天室ID',
  `sender_id` bigint NOT NULL COMMENT '发送者ID',
  `receiver_id` bigint NOT NULL COMMENT '接收者ID',
  `message_type` tinyint NOT NULL COMMENT '消息类型 1:文本 2:图片 3:语音 4:系统',
  `content` text NOT NULL COMMENT '消息内容',
  `is_read` tinyint DEFAULT '0' COMMENT '是否已读',
  `create_time` datetime DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
  PRIMARY KEY (`id`),
  KEY `idx_chat_room` (`chat_room_id`,`create_time`),
  KEY `idx_sender_receiver` (`sender_id`,`receiver_id`,`is_read`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='聊天记录表';

  2. Redis数据结构设计

// Redis配置类
@Configuration
public class RedisConfig {
    
    @Bean
    public RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory factory) {
        RedisTemplate<String, Object> template = new RedisTemplate<>();
        template.setConnectionFactory(factory);
        
        // 使用Jackson2JsonRedisSerializer来序列化和反序列化value
        Jackson2JsonRedisSerializer<Object> serializer = new Jackson2JsonRedisSerializer<>(Object.class);
        ObjectMapper mapper = new ObjectMapper();
        mapper.setVisibility(PropertyAccessor.ALL, JsonAutoDetect.Visibility.ANY);
        mapper.activateDefaultTyping(mapper.getPolymorphicTypeValidator(), 
            ObjectMapper.DefaultTyping.NON_FINAL);
        serializer.setObjectMapper(mapper);
        
        // 使用StringRedisSerializer来序列化和反序列化key
        template.setKeySerializer(new StringRedisSerializer());
        template.setValueSerializer(serializer);
        template.setHasKeySerializer(new StringRedisSerializer());
        template.setHashValueSerializer(serializer);
        
        template.afterPropertiesSet();
        return template;
    }
}
// Redis Key管理
@Component
public class RedisKeyManager {
    
    // 用户在线状态
    public static String getUserOnlineKey(Long userId) {
        return String.format("user:online:%d", userId);
    }
    
    // 盲盒推荐缓存
    public static String getBlindBoxRecommendKey(Long userId) {
        return String.format("box:recommend:%d", userId);
    }
    
    // 匹配队列
    public static String getMatchQueueKey(Long boxId) {
        return String.format("match:queue:%d", boxId);
    }
    
    // 未读消息数
    public static String getUnreadCountKey(Long userId) {
        return String.format("chat:unread:%d", userId);
    }
    
    // 限流
    public static String getRateLimitKey(String action, Long userId) {
        return String.format("rate:limit:%s:%d", action, userId);
    }
}

   核心功能源码实现  1. 用户服务

// 用户服务实现
@Service
@Slf4j
public class UserServiceImpl implements UserService {
    
    @Autowired
    private UserMapper userMapper;
    
    @Autowired
    private RedisTemplate<String, Object> redisTemplate;
    
    // 用户注册
    @Override
    @Transactional(rollbackFor = Exception.class)
    public UserDTO register(UserRegisterDTO registerDTO) {
        // 验证手机号唯一性
        if (userMapper.existsByPhone(registerDTO.getPhone())) {
            throw new BusinessException("手机号已注册");
        }
        
        // 创建用户
        User user = new User();
        BeanUtils.copyProperties(registerDTO, user);
        
        // 密码加密
        user.setPassword(PasswordUtil.encrypt(registerDTO.getPassword()));
        user.setStatus(UserStatus.NORMAL.getCode());
        
        userMapper.insert(user);
        
        // 生成token
        String token = JwtUtil.generateToken(user.getId(), user.getPhone());
        
        // 缓存用户信息
        cacheUserInfo(user);
        
        UserDTO userDTO = new UserDTO();
        BeanUtils.copyProperties(user, userDTO);
        userDTO.setToken(token);
        
        return userDTO;
    }
    
    // 用户登录
    @Override
    public UserDTO login(LoginDTO loginDTO) {
        User user = userMapper.selectByPhone(loginDTO.getPhone());
        if (user == null) {
            throw new BusinessException("用户不存在");
        }
        
        if (!PasswordUtil.verify(loginDTO.getPassword(), user.getPassword())) {
            throw new BusinessException("密码错误");
        }
        
        if (user.getStatus() != UserStatus.NORMAL.getCode()) {
            throw new BusinessException("账号状态异常");
        }
        
        // 更新登录时间
        user.setLastLoginTime(new Date());
        userMapper.updateById(user);
        
        // 生成token
        String token = JwtUtil.generateToken(user.getId(), user.getPhone());
        
        // 设置在线状态
        setUserOnline(user.getId());
        
        UserDTO userDTO = new UserDTO();
        BeanUtils.copyProperties(user, userDTO);
        userDTO.setToken(token);
        
        return userDTO;
    }
    
    // 设置用户在线状态
    private void setUserOnline(Long userId) {
        String key = RedisKeyManager.getUserOnlineKey(userId);
        redisTemplate.opsForValue().set(key, "online", 5, TimeUnit.MINUTES);
    }
    
    // 缓存用户信息
    private void cacheUserInfo(User user) {
        String key = "user:info:" + user.getId();
        redisTemplate.opsForValue().set(key, user, 30, TimeUnit.MINUTES);
    }
}
// 用户控制器
@RestController
@RequestMapping("/api/user")
@Api(tags = "用户管理")
public class UserController {
    
    @Autowired
    private UserService userService;
    
    @PostMapping("/register")
    @ApiOperation("用户注册")
    public Result<UserDTO> register(@Valid @RequestBody UserRegisterDTO registerDTO) {
        UserDTO userDTO = userService.register(registerDTO);
        return Result.success(userDTO);
    }
    
    @PostMapping("/login")
    @ApiOperation("用户登录")
    public Result<UserDTO> login(@Valid @RequestBody LoginDTO loginDTO) {
        UserDTO userDTO = userService.login(loginDTO);
        return Result.success(userDTO);
    }
    
    @GetMapping("/profile")
    @ApiOperation("获取用户资料")
    public Result<UserProfileDTO> getProfile(@RequestParam Long userId) {
        UserProfileDTO profile = userService.getUserProfile(userId);
        return Result.success(profile);
    }
}

  2. 盲盒服务

// 盲盒服务实现
@Service
@Slf4j
public class BlindBoxServiceImpl implements BlindBoxService {
    
    @Autowired
    private BlindBoxMapper blindBoxMapper;
    
    @Autowired
    private UserMapper userMapper;
    
    @Autowired
    private RedisTemplate<String, Object> redisTemplate;
    
    @Autowired
    private MatchRecordMapper matchRecordMapper;
    
    // 创建盲盒
    @Override
    @Transactional(rollbackFor = Exception.class)
    public BlindBoxDTO createBlindBox(CreateBoxDTO boxDTO, Long userId) {
        // 限制用户每日创建盲盒数量
        String limitKey = RedisKeyManager.getRateLimitKey("create_box", userId);
        Long count = redisTemplate.opsForValue().increment(limitKey, 1);
        if (count != null && count == 1) {
            redisTemplate.expire(limitKey, 1, TimeUnit.DAYS);
        }
        if (count != null && count > 10) {
            throw new BusinessException("每日最多创建10个盲盒");
        }
        
        // 创建盲盒
        BlindBox blindBox = new BlindBox();
        BeanUtils.copyProperties(boxDTO, blindBox);
        blindBox.setUserId(userId);
        blindBox.setStatus(BoxStatus.OPEN.getCode());
        
        // 设置过期时间(24小时)
        Calendar calendar = Calendar.getInstance();
        calendar.add(Calendar.HOUR, 24);
        blindBox.setExpireTime(calendar.getTime());
        
        blindBoxMapper.insert(blindBox);
        
        // 缓存到推荐系统
        cacheToRecommendSystem(blindBox);
        
        BlindBoxDTO result = new BlindBoxDTO();
        BeanUtils.copyProperties(blindBox, result);
        
        return result;
    }
    
    // 获取推荐盲盒
    @Override
    public List<BlindBoxVO> getRecommendBoxes(Long userId, int page, int size) {
        // 尝试从缓存获取推荐
        String cacheKey = RedisKeyManager.getBlindBoxRecommendKey(userId);
        List<BlindBoxVO> cached = (List<BlindBoxVO>) redisTemplate.opsForValue().get(cacheKey);
        
        if (cached != null && !cached.isEmpty()) {
            return cached;
        }
        
        // 缓存不存在,从数据库获取
        User user = userMapper.selectById(userId);
        
        // 构建推荐策略
        Page<BlindBox> pageInfo = new Page<>(page, size);
        LambdaQueryWrapper<BlindBox> queryWrapper = new LambdaQueryWrapper<>();
        
        // 排除自己创建的盲盒
        queryWrapper.ne(BlindBox::getUserId, userId);
        
        // 排除已经匹配过的盲盒
        List<Long> matchedBoxIds = matchRecordMapper.selectMatchedBoxIds(userId);
        if (!matchedBoxIds.isEmpty()) {
            queryWrapper.notIn(BlindBox::getId, matchedBoxIds);
        }
        
        // 性别偏好匹配
        queryWrapper.eq(BlindBox::getGender, getOppositeGender(user.getGender()));
        
        // 年龄范围筛选
        int minAge = user.getAge() - 5;
        int maxAge = user.getAge() + 5;
        queryWrapper.between(BlindBox::getAge, minAge, maxAge);
        
        // 只显示开启状态且未过期的盲盒
        queryWrapper.eq(BlindBox::getStatus, BoxStatus.OPEN.getCode())
                   .gt(BlindBox::getExpireTime, new Date());
        
        // 按创建时间倒序
        queryWrapper.orderByDesc(BlindBox::getCreateTime);
        
        Page<BlindBox> boxPage = blindBoxMapper.selectPage(pageInfo, queryWrapper);
        
        // 转换为VO
        List<BlindBoxVO> result = boxPage.getRecords().stream()
            .map(this::convertToVO)
            .collect(Collectors.toList());
        
        // 缓存推荐结果(5分钟)
        redisTemplate.opsForValue().set(cacheKey, result, 5, TimeUnit.MINUTES);
        
        return result;
    }
    
    // 缓存到推荐系统
    private void cacheToRecommendSystem(BlindBox blindBox) {
        // 根据盲盒特征进行聚类
        String features = generateBoxFeatures(blindBox);
        
        // 将盲盒ID加入到相关推荐池
        String poolKey = "recommend:pool:" + features;
        redisTemplate.opsForZSet().add(poolKey, blindBox.getId().toString(), 
            System.currentTimeMillis());
        
        // 设置过期时间
        redisTemplate.expire(poolKey, 24, TimeUnit.HOURS);
    }
    
    // 转换盲盒为VO
    private BlindBoxVO convertToVO(BlindBox blindBox) {
        BlindBoxVO vo = new BlindBoxVO();
        BeanUtils.copyProperties(blindedBox, vo);
        
        // 隐藏敏感信息
        vo.setUserId(null);
        vo.setPhone(null);
        
        return vo;
    }
}

  3. 匹配服务

// 匹配服务实现
@Service
@Slf4j
public class MatchServiceImpl implements MatchService {
    
    @Autowired
    private MatchRecordMapper matchRecordMapper;
    
    @Autowired
    private BlindBoxMapper blindBoxMapper;
    
    @Autowired
    private UserMapper userMapper;
    
    @Autowired
    private ChatRoomService chatRoomService;
    
    @Autowired
    private WebSocketHandler webSocketHandler;
    
    // 喜欢盲盒
    @Override
    @Transactional(rollbackFor = Exception.class)
    public MatchResultDTO likeBlindBox(Long boxId, Long userId, int matchType) {
        // 检查盲盒状态
        BlindBox blindBox = blindBoxMapper.selectById(boxId);
        if (blindBox == null || blindBox.getStatus() != BoxStatus.OPEN.getCode()) {
            throw new BusinessException("盲盒不存在或已关闭");
        }
        
        if (blindBox.getUserId().equals(userId)) {
            throw new BusinessException("不能喜欢自己的盲盒");
        }
        
        // 检查是否已经喜欢过
        MatchRecord existRecord = matchRecordMapper.selectByBoxAndSender(boxId, userId);
        if (existRecord != null) {
            throw new BusinessException("已经喜欢过这个盲盒");
        }
        
        // 创建匹配记录
        MatchRecord matchRecord = new MatchRecord();
        matchRecord.setBlindBoxId(boxId);
        matchRecord.setSenderId(userId);
        matchRecord.setReceiverId(blindBox.getUserId());
        matchRecord.setMatchType(matchType);
        matchRecord.setMatchStatus(MatchStatus.PENDING.getCode());
        
        // 设置匹配过期时间(24小时)
        Calendar calendar = Calendar.getInstance();
        calendar.add(Calendar.HOUR, 24);
        matchRecord.setExpireTime(calendar.getTime());
        
        matchRecordMapper.insert(matchRecord);
        
        // 更新盲盒喜欢数
        blindBox.setLikeCount(blindBox.getLikeCount() + 1);
        blindBoxMapper.updateById(blindBox);
        
        // 如果是超级喜欢,立即通知对方
        if (matchType == MatchType.SUPER_LIKE.getCode()) {
            notifySuperLike(blindBox.getUserId(), userId);
        }
        
        // 检查是否互相喜欢
        checkMutualLike(boxId, userId, blindBox.getUserId());
        
        return MatchResultDTO.builder()
            .matchId(matchRecord.getId())
            .status(matchRecord.getMatchStatus())
            .message("喜欢成功,等待对方回应")
            .build();
    }
    
    // 处理匹配回应
    @Override
    @Transactional(rollbackFor = Exception.class)
    public MatchResultDTO handleMatchResponse(Long matchId, Long userId, boolean accept) {
        MatchRecord matchRecord = matchRecordMapper.selectById(matchId);
        if (matchRecord == null || !matchRecord.getReceiverId().equals(userId)) {
            throw new BusinessException("匹配记录不存在或无权限操作");
        }
        
        if (matchRecord.getMatchStatus() != MatchStatus.PENDING.getCode()) {
            throw new BusinessException("匹配已处理");
        }
        
        if (matchRecord.getExpireTime() != null && 
            matchRecord.getExpireTime().before(new Date())) {
            matchRecord.setMatchStatus(MatchStatus.EXPIRED.getCode());
            matchRecordMapper.updateById(matchRecord);
            throw new BusinessException("匹配已过期");
        }
        
        if (accept) {
            // 匹配成功
            matchRecord.setMatchStatus(MatchStatus.SUCCESS.getCode());
            
            // 创建聊天室
            String chatRoomId = chatRoomService.createChatRoom(
                matchRecord.getSenderId(), 
                matchRecord.getReceiverId()
            );
            matchRecord.setChatRoomId(chatRoomId);
            
            // 更新盲盒匹配数
            BlindBox blindBox = blindBoxMapper.selectById(matchRecord.getBlindBoxId());
            if (blindBox != null) {
                blindBox.setMatchCount(blindBox.getMatchCount() + 1);
                blindBoxMapper.updateById(blindBox);
            }
            
            // 发送匹配成功通知
            sendMatchSuccessNotification(matchRecord);
            
        } else {
            // 拒绝匹配
            matchRecord.setMatchStatus(MatchStatus.REJECTED.getCode());
        }
        
        matchRecordMapper.updateById(matchRecord);
        
        return MatchResultDTO.builder()
            .matchId(matchRecord.getId())
            .status(matchRecord.getMatchStatus())
            .message(accept ? "匹配成功,开始聊天吧" : "已拒绝匹配")
            .chatRoomId(matchRecord.getChatRoomId())
            .build();
    }
    
    // 检查是否互相喜欢
    private void checkMutualLike(Long boxId, Long senderId, Long receiverId) {
        // 查询对方是否也喜欢了我的盲盒
        List<BlindBox> myBoxes = blindBoxMapper.selectByUserId(senderId);
        
        for (BlindBox myBox : myBoxes) {
            MatchRecord mutualRecord = matchRecordMapper.selectByBoxAndSender(
                myBox.getId(), receiverId);
            
            if (mutualRecord != null && mutualRecord.getMatchStatus() == MatchStatus.PENDING.getCode()) {
                // 发现互相喜欢,自动创建聊天室
                String chatRoomId = chatRoomService.createChatRoom(senderId, receiverId);
                
                // 更新两条匹配记录
                matchRecordMapper.updateMatchSuccess(mutualRecord.getId(), chatRoomId);
                matchRecordMapper.updateMatchSuccessByBoxAndSender(boxId, senderId, chatRoomId);
                
                // 发送匹配成功通知
                notifyMutualMatch(senderId, receiverId, chatRoomId);
                break;
            }
        }
    }
}

  4. WebSocket实时通信

// WebSocket处理器
@Component
@Slf4j
public class WebSocketHandler extends TextWebSocketHandler {
    
    @Autowired
    private ChatMessageService chatMessageService;
    
    @Autowired
    private RedisTemplate<String, Object> redisTemplate;
    
    // 用户会话映射
    private static final ConcurrentHashMap<Long, WebSocketSession> userSessions = 
        new ConcurrentHashMap<>();
    
    @Override
    public void afterConnectionEstablished(WebSocketSession session) {
        Long userId = getUserIdFromSession(session);
        if (userId != null) {
            userSessions.put(userId, session);
            log.info("用户 {} WebSocket连接建立", userId);
            
            // 更新在线状态
            updateUserOnlineStatus(userId, true);
            
            // 推送未读消息
            pushUnreadMessages(userId);
        }
    }
    
    @Override
    protected void handleTextMessage(WebSocketSession session, TextMessage message) {
        try {
            JSONObject jsonMessage = JSON.parseObject(message.getPayload());
            String type = jsonMessage.getString("type");
            Long userId = getUserIdFromSession(session);
            
            if (userId == null) {
                session.close();
                return;
            }
            
            switch (type) {
                case "chat":
                    handleChatMessage(userId, jsonMessage);
                    break;
                case "heartbeat":
                    handleHeartbeat(userId, session);
                    break;
                case "typing":
                    handleTypingStatus(userId, jsonMessage);
                    break;
            }
            
        } catch (Exception e) {
            log.error("处理WebSocket消息异常", e);
        }
    }
    
    @Override
    public void afterConnectionClosed(WebSocketSession session, CloseStatus status) {
        Long userId = getUserIdFromSession(session);
        if (userId != null) {
            userSessions.remove(userId);
            log.info("用户 {} WebSocket连接关闭", userId);
            
            // 更新在线状态
            updateUserOnlineStatus(userId, false);
        }
    }
    
    // 处理聊天消息
    private void handleChatMessage(Long senderId, JSONObject message) {
        Long receiverId = message.getLong("receiverId");
        String content = message.getString("content");
        String chatRoomId = message.getString("chatRoomId");
        
        // 保存消息到数据库
        ChatMessage chatMessage = new ChatMessage();
        chatMessage.setChatRoomId(chatRoomId);
        chatMessage.setSenderId(senderId);
        chatMessage.setReceiverId(receiverId);
        chatMessage.setMessageType(MessageType.TEXT.getCode());
        chatMessage.setContent(content);
        
        chatMessageService.saveMessage(chatMessage);
        
        // 构建推送消息
        JSONObject pushMessage = new JSONObject();
        pushMessage.put("type", "message");
        pushMessage.put("messageId", chatMessage.getId());
        pushMessage.put("senderId", senderId);
        pushMessage.put("content", content);
        pushMessage.put("timestamp", chatMessage.getCreateTime().getTime());
        pushMessage.put("chatRoomId", chatRoomId);
        
        // 发送给接收者
        sendToUser(receiverId, pushMessage.toJSONString());
        
        // 发送回执给发送者
        JSONObject receipt = new JSONObject();
        receipt.put("type", "message_receipt");
        receipt.put("messageId", chatMessage.getId());
        receipt.put("status", "sent");
        sendToUser(senderId, receipt.toJSONString());
        
        // 更新未读消息计数
        updateUnreadCount(receiverId, chatRoomId);
    }
    
    // 发送消息给指定用户
    public void sendToUser(Long userId, String message) {
        WebSocketSession session = userSessions.get(userId);
        if (session != null && session.isOpen()) {
            try {
                session.sendMessage(new TextMessage(message));
            } catch (IOException e) {
                log.error("发送WebSocket消息失败", e);
                userSessions.remove(userId);
            }
        } else {
            // 用户不在线,可以存储为离线消息
            storeOfflineMessage(userId, message);
        }
    }
    
    // 更新未读消息计数
    private void updateUnreadCount(Long userId, String chatRoomId) {
        String key = RedisKeyManager.getUnreadCountKey(userId);
        redisTemplate.opsForHash().increment(key, chatRoomId, 1);
        redisTemplate.expire(key, 7, TimeUnit.DAYS);
    }
    
    // 获取用户ID
    private Long getUserIdFromSession(WebSocketSession session) {
        Map<String, Object> attributes = session.getAttributes();
        return (Long) attributes.get("userId");
    }
}

   前端核心组件实现

<!-- 盲盒卡片组件 -->
<template>
  <div class="blind-box-card" @click="handleClick">
    <div class="box-header">
      <el-avatar :size="40" :src="box.avatar" />
      <div class="box-info">
        <h3>{{ box.nickname }}</h3>
        <p class="meta">
          <span>{{ box.age }}岁</span>
          <span>·</span>
          <span>{{ getGenderText(box.gender) }}</span>
        </p>
      </div>
    </div>
    
    <div class="box-content">
      <p class="title">{{ box.title }}</p>
      <p class="content">{{ box.content }}</p>
      
      <div v-if="box.contentType === 2" class="image-container">
        <el-image 
          :src="box.content" 
          fit="cover" 
          :preview-src-list="[box.content]"
        />
      </div>
      
      <div v-if="box.contentType === 3" class="audio-container">
        <audio-player :src="box.content" />
      </div>
    </div>
    
    <div class="box-footer">
      <div class="stats">
        <span>❤️ {{ box.likeCount }}</span>
        <span>???? {{ box.matchCount }}</span>
      </div>
      
      <div class="actions">
        <el-button 
          type="danger" 
          icon="el-icon-close" 
          circle 
          @click.stop="handleDislike"
        />
        <el-button 
          type="primary" 
          icon="el-icon-star-off" 
          circle 
          @click.stop="handleLike"
        />
        <el-button 
          type="success" 
          icon="el-icon-star-on" 
          circle 
          @click.stop="handleSuperLike"
        />
      </div>
    </div>
  </div>
</template>
<script>
import { reactive, toRefs } from 'vue'
import { ElMessage } from 'element-plus'
import { likeBlindBox, superLikeBlindBox } from '@/api/match'
export default {
  name: 'BlindBoxCard',
  props: {
    box: {
      type: Object,
      required: true
    }
  },
  emits: ['matched'],
  setup(props, { emit }) {
    const state = reactive({
      loading: false
    })
    
    const handleLike = async () => {
      if (state.loading) return
      
      state.loading = true
      try {
        const res = await likeBlindBox(props.box.id, 1)
        if (res.data.status === 2) { // 匹配成功
          emit('matched', res.data)
        } else {
          ElMessage.success('喜欢成功,等待对方回应')
        }
      } catch (error) {
        ElMessage.error(error.message)
      } finally {
        state.loading = false
      }
    }
    
    const handleSuperLike = async () => {
      if (state.loading) return
      
      state.loading = true
      try {
        const res = await superLikeBlindBox(props.box.id, 2)
        if (res.data.status === 2) { // 匹配成功
          emit('matched', res.data)
        } else {
          ElMessage.success('超级喜欢已发送')
        }
      } catch (error) {
        ElMessage.error(error.message)
      } finally {
        state.loading = false
      }
    }
    
    const handleDislike = () => {
      // 实现不喜欢逻辑
    }
    
    const getGenderText = (gender) => {
      return gender === 1 ? '男生' : gender === 2 ? '女生' : '未知'
    }
    
    return {
      ...toRefs(state),
      handleLike,
      handleSuperLike,
      handleDislike,
      getGenderText
    }
  }
}
</script>
<style scoped>
.blind-box-card {
  background: white;
  border-radius: 12px;
  padding: 20px;
  margin-bottom: 16px;
  box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.1);
  cursor: pointer;
  transition: transform 0.3s;
}
.blind-box-card:hover {
  transform: translateY(-4px);
  box-shadow: 0 4px 20px 0 rgba(0, 0, 0, 0.15);
}
.box-header {
  display: flex;
  align-items: center;
  margin-bottom: 16px;
}
.box-info {
  margin-left: 12px;
}
.box-info h3 {
  margin: 0;
  font-size: 16px;
  color: #333;
}
.box-info .meta {
  margin: 4px 0 0;
  font-size: 14px;
  color: #999;
}
.box-content .title {
  font-size: 18px;
  font-weight: bold;
  color: #333;
  margin-bottom: 8px;
}
.box-content .content {
  font-size: 14px;
  color: #666;
  line-height: 1.5;
  margin-bottom: 16px;
}
.image-container {
  margin: 12px 0;
}
.audio-container {
  margin: 12px 0;
}
.box-footer {
  display: flex;
  justify-content: space-between;
  align-items: center;
  padding-top: 16px;
  border-top: 1px solid #f0f0f0;
}
.stats span {
  margin-right: 12px;
  font-size: 14px;
  color: #999;
}
.actions button {
  margin-left: 8px;
}
</style>

   系统部署  1. Docker Compose部署

version: '3.8'
services:
  # MySQL数据库
  mysql:
    image: mysql:8.0
    container_name: blindbox-mysql
    environment:
      MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD}
      MYSQL_DATABASE: blindbox
      MYSQL_USER: ${MYSQL_USER}
      MYSQL_PASSWORD: ${MYSQL_PASSWORD}
    volumes:
      - mysql_data:/var/lib/mysql
      - ./init.sql:/docker-entrypoint-initdb.d/init.sql
    ports:
      - "3306:3306"
    networks:
      - blindbox-network
    healthcheck:
      test: ["CMD", "mysqladmin", "ping", "-h", "localhost"]
      timeout: 20s
      retries: 10
  # Redis缓存
  redis:
    image: redis:7.0-alpine
    container_name: blindbox-redis
    command: redis-server --requirepass ${REDIS_PASSWORD}
    volumes:
      - redis_data:/data
    ports:
      - "6379:6379"
    networks:
      - blindbox-network
    healthcheck:
      test: ["CMD", "redis-cli", "ping"]
      timeout: 5s
      retries: 5
  # 后端应用
  backend:
    build: ./backend
    container_name: blindbox-backend
    depends_on:
      mysql:
        condition: service_healthy
      redis:
        condition: service_healthy
    environment:
      SPRING_PROFILES_ACTIVE: prod
      DB_HOST: mysql
      DB_PORT: 3306
      DB_NAME: blindbox
      DB_USER: ${MYSQL_USER}
      DB_PASSWORD: ${MYSQL_PASSWORD}
      REDIS_HOST: redis
      REDIS_PORT: 6379
      REDIS_PASSWORD: ${REDIS_PASSWORD}
    volumes:
      - ./backend/logs:/app/logs
      - ./backend/uploads:/app/uploads
    ports:
      - "8080:8080"
    networks:
      - blindbox-network
    restart: unless-stopped
  # 前端应用
  frontend:
    build: ./frontend
    container_name: blindbox-frontend
    volumes:
      - ./frontend/nginx.conf:/etc/nginx/nginx.conf
      - ./frontend/dist:/usr/share/nginx/html
    ports:
      - "80:80"
      - "443:443"
    depends_on:
      - backend
    networks:
      - blindbox-network
    restart: unless-stopped
  # Nginx反向代理
  nginx:
    image: nginx:alpine
    container_name: blindbox-nginx
    volumes:
      - ./nginx/nginx.conf:/etc/nginx/nginx.conf
      - ./nginx/ssl:/etc/nginx/ssl
      - ./logs/nginx:/var/log/nginx
    ports:
      - "80:80"
      - "443:443"
    depends_on:
      - frontend
      - backend
    networks:
      - blindbox-network
    restart: unless-stopped
  # 监控服务
  prometheus:
    image: prom/prometheus
    container_name: blindbox-prometheus
    volumes:
      - ./monitoring/prometheus.yml:/etc/prometheus/prometheus.yml
    ports:
      - "9090:9090"
    networks:
      - blindbox-network
    restart: unless-stopped
  # 日志收集
  filebeat:
    image: elastic/filebeat:7.15.0
    container_name: blindbox-filebeat
    volumes:
      - ./logs:/var/log/blindbox
      - ./monitoring/filebeat.yml:/usr/share/filebeat/filebeat.yml
    networks:
      - blindbox-network
    restart: unless-stopped
networks:
  blindbox-network:
    driver: bridge
volumes:
  mysql_data:
  redis_data:

  2. Nginx配置

# nginx.conf
user nginx;
worker_processes auto;
error_log /var/log/nginx/error.log warn;
pid /var/run/nginx.pid;
events {
    worker_connections 1024;
    use epoll;
    multi_accept on;
}
http {
    include /etc/nginx/mime.types;
    default_type application/octet-stream;
    
    # 日志格式
    log_format main '$remote_addr - $remote_user [$time_local] "$request" '
                    '$status $body_bytes_sent "$http_referer" '
                    '"$http_user_agent" "$http_x_forwarded_for"';
    
    access_log /var/log/nginx/access.log main;
    
    # 基础配置
    sendfile on;
    tcp_nopush on;
    tcp_nodelay on;
    keepalive_timeout 65;
    types_hash_max_size 2048;
    client_max_body_size 20M;
    
    # Gzip压缩
    gzip on;
    gzip_vary on;
    gzip_min_length 1024;
    gzip_types text/plain text/css text/xml text/javascript 
               application/json application/javascript application/xml+rss application/atom+xml 
               image/svg+xml;
    
    # WebSocket支持
    map $http_upgrade $connection_upgrade {
        default upgrade;
        '' close;
    }
    
    # 上游服务器
    upstream backend_servers {
        least_conn;
        server backend:8080 max_fails=3 fail_timeout=30s;
        keepalive 32;
    }
    
    upstream frontend_servers {
        server frontend:80;
    }
    
    # HTTP服务器
    server {
        listen 80;
        server_name your-domain.com;
        return 301 https://$server_name$request_uri;
    }
    
    # HTTPS服务器
    server {
        listen 443 ssl http2;
        server_name your-domain.com;
        
        # SSL证书
        ssl_certificate /etc/nginx/ssl/your-domain.com.crt;
        ssl_certificate_key /etc/nginx/ssl/your-domain.com.key;
        
        # SSL配置
        ssl_protocols TLSv1.2 TLSv1.3;
        ssl_ciphers ECDHE-RSA-AES256-GCM-SHA512:DHE-RSA-AES256-GCM-SHA512;
        ssl_prefer_server_ciphers off;
        ssl_session_cache shared:SSL:10m;
        ssl_session_timeout 10m;
        
        # 安全头部
        add_header X-Frame-Options DENY;
        add_header X-Content-Type-Options nosniff;
        add_header X-XSS-Protection "1; mode=block";
        add_header Strict-Transport-Security "max-age=63072000; includeSubDomains; preload";
        
        # 前端应用
        location / {
            proxy_pass http://frontend_servers;
            proxy_set_header Host $host;
            proxy_set_header X-Real-IP $remote_addr;
            proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
            proxy_set_header X-Forwarded-Proto $scheme;
            
            # 代理超时设置
            proxy_connect_timeout 60s;
            proxy_send_timeout 60s;
            proxy_read_timeout 60s;
        }
        
        # 后端API
        location /api/ {
            proxy_pass http://backend_servers;
            proxy_set_header Host $host;
            proxy_set_header X-Real-IP $remote_addr;
            proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
            proxy_set_header X-Forwarded-Proto $scheme;
            
            # 增加缓冲区大小处理大请求
            proxy_buffer_size 128k;
            proxy_buffers 4 256k;
            proxy_busy_buffers_size 256k;
            
            # 超时设置
            proxy_connect_timeout 30s;
            proxy_send_timeout 30s;
            proxy_read_timeout 30s;
        }
        
        # WebSocket支持
        location /ws/ {
            proxy_pass http://backend_servers;
            proxy_http_version 1.1;
            proxy_set_header Upgrade $http_upgrade;
            proxy_set_header Connection $connection_upgrade;
            proxy_set_header Host $host;
            proxy_set_header X-Real-IP $remote_addr;
            proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
            proxy_set_header X-Forwarded-Proto $scheme;
            
            # WebSocket超时设置
            proxy_read_timeout 3600s;
            proxy_send_timeout 3600s;
        }
        
        # 静态资源
        location /static/ {
            alias /usr/share/nginx/html/static/;
            expires 1y;
            add_header Cache-Control "public, immutable";
            
            # 启用gzip压缩
            gzip_static on;
        }
        
        # 上传文件
        location /uploads/ {
            alias /app/uploads/;
            expires 30d;
            add_header Cache-Control "public";
            
            # 文件上传限制
            client_max_body_size 20M;
        }
        
        # 健康检查
        location /health {
            access_log off;
            return 200 "healthy\n";
        }
    }
}

   系统监控与优化  1. 应用监控配置

# application-prod.yml
spring:
  application:
    name: blindbox-service
  
  # 数据库配置
  datasource:
    driver-class-name: com.mysql.cj.jdbc.Driver
    url: jdbc:mysql://${DB_HOST:localhost}:${DB_PORT:3306}/${DB_NAME}?useUnicode=true&characterEncoding=utf8&useSSL=false&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true
    username: ${DB_USER}
    password: ${DB_PASSWORD}
    hikari:
      maximum-pool-size: 20
      minimum-idle: 10
      connection-timeout: 30000
      idle-timeout: 600000
      max-lifetime: 1800000
      connection-test-query: SELECT 1
  
  # Redis配置
  redis:
    host: ${REDIS_HOST:localhost}
    port: ${REDIS_PORT:6379}
    password: ${REDIS_PASSWORD:}
    database: 0
    timeout: 10000ms
    lettuce:
      pool:
        max-active: 20
        max-idle: 10
        min-idle: 5
        max-wait: 5000ms
  
  # 监控端点
  boot:
    admin:
      client:
        url: http://localhost:9091
        instance:
          prefer-ip: true
  
  # 应用监控
  application:
    name: blindbox-service
management:
  endpoints:
    web:
      exposure:
        include: health,info,metrics,prometheus
      base-path: /actuator
  endpoint:
    health:
      show-details: always
  metrics:
    export:
      prometheus:
        enabled: true
    tags:
      application: ${spring.application.name}
  trace:
    sampling:
      probability: 1.0
# 日志配置
logging:
  level:
    com.blindbox: INFO
    org.springframework.web: WARN
  file:
    name: logs/blindbox.log
  logback:
    rollingpolicy:
      max-file-size: 10MB
      max-history: 30
  pattern:
    console: "%d{yyyy-MM-dd HH:mm:ss} - %msg%n"
    file: "%d{yyyy-MM-dd HH:mm:ss} [%thread] %-5level %logger{36} - %msg%n"
# 应用配置
app:
  # 文件上传配置
  upload:
    max-file-size: 10MB
    allowed-extensions: jpg,jpeg,png,gif,mp3,wav
    
  # 盲盒配置
  blindbox:
    max-per-day: 10
    expire-hours: 24
    recommend-cache-time: 300
    
  # 匹配配置
  match:
    expire-hours: 24
    super-like-limit: 3
    
  # 聊天配置
  chat:
    max-message-length: 1000
    message-retention-days: 30
    
  # 安全配置
  security:
    jwt:
      secret: ${JWT_SECRET:your-jwt-secret-key-here}
      expiration: 86400000
    cors:
      allowed-origins: ${ALLOWED_ORIGINS:http://localhost:8080,https://your-domain.com}
      allowed-methods: GET,POST,PUT,DELETE,OPTIONS
      allowed-headers: "*"
      allow-credentials: true
      
# MyBatis配置
mybatis-plus:
  mapper-locations: classpath:/mapper/**/*.xml
  type-aliases-package: com.blindbox.entity
  configuration:
    map-underscore-to-camel-case: true
    log-impl: org.apache.ibatis.logging.stdout.StdOutImpl
  global-config:
    db-config:
      logic-delete-field: is_deleted
      logic-delete-value: 1
      logic-not-delete-value: 0

  2. 启动脚本

#!/bin/bash
# startup.sh
# 设置环境变量
export JAVA_OPTS="-server -Xmx2g -Xms1g -XX:+UseG1GC -XX:MaxGCPauseMillis=200 -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/app/logs/heapdump.hprof"
export SPRING_PROFILES_ACTIVE=prod
# 创建日志目录
mkdir -p /app/logs
# 启动应用
nohup java $JAVA_OPTS -jar /app/blindbox.jar > /app/logs/console.log 2>&1 &
# 记录PID
echo $! > /app/pid.file
echo "应用启动成功,PID: $!"

   结语  通过本文的系统性讲解,我们不仅完成了一个功能完整的交友盲盒系统,更重要的是掌握了社交类应用开发的核心方法论。从数据库的范式设计到缓存策略的优化,从实时通信的实现到安全机制的构建,每个技术选择都体现了实际项目开发中的权衡思考。得强调的是,一个成功的社交产品不仅需要技术实现,更需要关注用户体验和运营策略。本文实现的系统提供了良好的技术基础,但真正的产品价值还需要在以下几个方面持续打磨:匹配算法的智能化优化、内容安全的风控机制、用户增长的运营策略以及数据驱动的产品迭代。在实际部署前,建议根据预估用户规模对系统进行压力测试,特别是在匹配算法性能和WebSocket连接数等关键指标上进行针对性优化。同时,用户隐私保护和数据安全应始终放在首位,合规运营是产品长期发展的基石。

相关推荐