在数字化社交蓬勃发展的今天,传统社交软件的确定性匹配机制逐渐让用户感到审美疲劳。基于"未知惊喜"和"社交破冰"理念的交友盲盒系统应运而生,它通过随机匹配、匿名互动等创新玩法,为用户带来全新的社交体验。这种模式不仅降低了社交门槛,更创造了独特的互动趣味性,正在成为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连接数等关键指标上进行针对性优化。同时,用户隐私保护和数据安全应始终放在首位,合规运营是产品长期发展的基石。
