Java 项目实战: 交友项目 - 项目:需求分析与滑动数据设计

概述

交友项目里的「Tinder 式快配」功能:推荐用户以卡片形式随机展现,右滑喜欢、左滑不感兴趣,双向右滑自动结为好友。这里聚焦它的需求拆解与数据设计,滑到的人记在哪、推荐时怎么排除、配对怎么判定。

纲要

  • 业务场景:项目功能的产品价值
  • 卡片式浏览,比「进主页再操作」的转化路径更短
  • 右滑喜欢 / 左滑不感兴趣 / 双向喜欢即配对
  • 交互与数据设计
  • 两个 Redis Set:USER_LIKE_SET_{userId} 与 USER_NOT_LIKE_SET_{userId}
  • 为什么用 Set:高频写、天然去重、O(1) 判存
  • 持久化取舍:Redis + MongoDB 双写
  • 推荐列表与排除逻辑
  • recommend_user 集合:项目卡片、今日佳人、推荐好友列表共用一份数据源
  • 卡片列表排除已滑过的用户,随机取样
  • 配对机制:SISMEMBER 检查对方是否也喜欢过我
  • 接口设计:/mf/cards、/mf/{id}/love、/mf/{id}/unlove
  • 原型线框图、异常与边界、验证要点

业务场景:为什么要有项目

推荐体系(今日佳人、推荐好友列表)解决的是「把对的人摆到你面前」,但用户的下一步动作成本不低,要点进主页、看完资料、再点关注。项目把这一步压缩成一次滑动:卡片上已经把关键信息(头像、昵称、缘分值、单身/学历/年龄标签)铺好了,用户凭第一印象右滑或左滑,整个过程不到一秒。

这背后是交友类产品的经典设计:用最低成本表达意向。滑动是廉价的,用户不会心疼;而大量滑动产生的数据反过来又筛选出了高意向用户。双方都右滑时,「我喜欢你」和「你也喜欢我」同时成立,系统直接帮两人结为好友,不需要任何一方再发起关注请求。

讲义里的需求描述拆开是四条:

  • 推荐好友以卡片形式随机展现
  • 左滑 = 不喜欢,右滑 = 喜欢
  • 双方互相喜欢,自动成为好友(记录到环信 + 自己的数据库)
  • 已经喜欢或不喜欢的用户,之后不再出现在卡片列表里

前两条是交互,第三条是配对,第四条是排除,这里的重点在后三条的数据设计上。

交互与数据设计:滑动记录放在哪

为什么选 Redis Set

滑动的数据特征决定了存储选型:

特征 含义 对存储的要求
高频写 用户刷卡片时每次滑动都产生一次写入 写入要快、成本低
单用户重复 同一个人可能被滑到多次(默认兜底、卡片重现) 天然去重,重复滑动幂等
判存查询 推荐时要排除已滑过的、配对时要查对方是否喜欢我 O(1) 成员判断
个性化扩展 「共同喜欢的人」这类需求(你和张三都喜欢李四) 交集、并集运算

逐条对照下来,Redis Set 是最贴合的:

  • SADD 是 O(1) 写入,重复 SADD 同一成员不会产生冗余数据,重复滑动同一人天然幂等
  • SISMEMBER 是 O(1) 判存,推荐排除、配对判定都依赖它
  • SINTER / SUNION 直接支持交集并集,「共同喜欢」这种后续需求不用改表

工程里的 key 定义在 mf-commons 的 Constants.java(实读第 46、49 行):

java 复制代码
package com.mf.commons.utils;

//常量定义
public class Constants {

    //用户喜欢Redis的key
    public static final String USER_LIKE_KEY="USER_LIKE_SET_";

    //用户不喜欢Redis的key
    public static final String USER_NOT_LIKE_KEY="USER_NOT_LIKE_SET_";
}

key 的设计规则:

  • USER_LIKE_SET_{userId}:value 部分存「我喜欢的用户 id」,一个用户一个 Set
  • USER_NOT_LIKE_SET_{userId}:存「我不感兴趣的用户 id」
  • 成员是用户 id 的字符串形式(106),不是 Long,Redis 字符串值统一按 String 存,Java 侧读写都要 toString() / 转换,这是容易踩的点
  • 喜欢和不感兴趣分两个 Set 而不是用一个 Set 加标记位,因为两者的查询语义不同:配对只查 LIKE Set,排除要查两个 Set 的并集

滑动的读写流程

右滑(喜欢)的写操作在 mfService.likeUser(实读原文):

java 复制代码
//项目喜欢 106 -  2
public void likeUser(Long likeUserId) {
    //1、调用API,保存喜欢数据(保存到MongoDB中)
    Boolean save = userLikeApi.saveOrUpdate(UserHolder.getUserId(),likeUserId,true);
    if(!save) {
        //失败
        throw new BusinessException(ErrorResult.error());
    }
    //2、操作redis,写入喜欢的数据,删除不喜欢的数据 (喜欢的集合,不喜欢的集合)
    redisTemplate.opsForSet().remove(Constants.USER_NOT_LIKE_KEY+UserHolder.getUserId(),likeUserId.toString());
    redisTemplate.opsForSet().add(Constants.USER_LIKE_KEY+UserHolder.getUserId(),likeUserId.toString());
    //3、判断是否双向喜欢
    if(isLike(likeUserId,UserHolder.getUserId())) {
        //4、添加好友
        messagesService.contacts(likeUserId);
    }
}

左滑(不感兴趣)是对称操作:

java 复制代码
//不喜欢
public void notLikeUser(Long likeUserId) {
    //1、调用API,保存喜欢数据(保存到MongoDB中)
    Boolean save = userLikeApi.saveOrUpdate(UserHolder.getUserId(),likeUserId,false);
    if(!save) {
        //失败
        throw new BusinessException(ErrorResult.error());
    }
    //2、操作redis,写入不喜欢的数据,删除喜欢的数据
    redisTemplate.opsForSet().add(Constants.USER_NOT_LIKE_KEY+UserHolder.getUserId(),likeUserId.toString());
    redisTemplate.opsForSet().remove(Constants.USER_LIKE_KEY+UserHolder.getUserId(),likeUserId.toString());
    //3、判断是否双向喜欢,删除好友(各位自行实现)
}

这里有个容易忽略的细节:右滑时先 SREM 不感兴趣 Set、再 SADD 喜欢 Set。因为态度可以反转,之前左滑过的人如果通过兜底推荐又刷到了,这次右滑必须把旧记录翻转过来。两个 Set 之间是互斥的,同一个人只会存在于其中一个里。

持久化取舍:Redis 之外还要不要落库

单看滑动数据,它是典型的「低价值高频写」:一次左滑本身没有太多业务价值,用户也不会回看「我上个月滑过谁」。按这个逻辑只存 Redis 似乎够了,但工程里实际做了双写,每次滑动先调 Dubbo 接口落 MongoDB,再操作 Redis:

MongoDB 侧的 UserLike 实体(mf-model 模块,com.mf.model.mongo.UserLike):

java 复制代码
package com.mf.model.mongo;

import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import org.bson.types.ObjectId;
import org.springframework.data.mongodb.core.index.Indexed;
import org.springframework.data.mongodb.core.mapping.Document;

@Data
@NoArgsConstructor
@AllArgsConstructor
@Document(collection = "user_like")
public class UserLike implements java.io.Serializable {

    private static final long serialVersionUID = 6739966698394686523L;

    private ObjectId id;
    @Indexed
    private Long userId; //用户id,自己
    @Indexed
    private Long likeUserId; //喜欢的用户id,对方
    private Boolean isLike; // 是否喜欢
    private Long created; //创建时间
    private Long updated; // 更新时间
}

对应的 UserLikeApiImpl.saveOrUpdate(实读):查 userId + likeUserId,不存在就插入,存在就只更新 isLike 和 updated,再次保证了重复滑动的幂等:

java 复制代码
@DubboService
public class UserLikeApiImpl implements UserLikeApi {

    @Autowired
    private MongoTemplate mongoTemplate;

    @Override
    public Boolean saveOrUpdate(Long userId, Long likeUserId, boolean isLike) {
        try {
            //1、查询数据
            Query query = Query.query(Criteria.where("userId").is(userId).and("likeUserId").is(likeUserId));
            UserLike userLike = mongoTemplate.findOne(query, UserLike.class);
            //2、如果不存在,保存
            if(userLike == null) {
                userLike = new UserLike();
                userLike.setUserId(userId);
                userLike.setLikeUserId(likeUserId);
                userLike.setCreated(System.currentTimeMillis());
                userLike.setUpdated(System.currentTimeMillis());
                userLike.setIsLike(isLike);
                mongoTemplate.save(userLike);
            }else {
                //3、更新
                Update update = Update.update("isLike", isLike)
                        .set("updated",System.currentTimeMillis());
                mongoTemplate.updateFirst(query,update,UserLike.class);
            }
            return true;
        } catch (Exception e) {
            e.printStackTrace();
            return false;
        }
    }
}

为什么数据价值低还要落库?两个原因:

  1. Redis 挂了滑动记录不能全丢。喜欢记录是配对判定的依据,A 右滑 B 这件事如果因为 Redis 重启消失,B 后来右滑 A 时配对就漏了。这类「错过」对交友产品是实打实的损失,所以 MongoDB 兜底是必要的。
  2. 复杂查询要靠 MongoDB。Redis Set 擅长判存和集合运算,但「统计某用户一段时间的喜欢行为」「分析右滑转化率」这类需求,还是得查持久化数据。

注意一个不对称:写是双写,读却各有分工。配对判定走 Redis(快),推荐排除走 MongoDB(见下一节)。Redis 里的数据可以视为 MongoDB 的读优化缓存。

推荐列表:数据源与排除逻辑

与今日佳人、推荐好友列表的关系

实读代码确认,项目卡片列表和今日佳人、推荐好友列表共用同一份数据源 ,MongoDB 的 recommend_user 集合(推荐系统算好的缘分值数据),只是查询方式不同:

入口 方法 查询方式
今日佳人 /mf/todayBest queryWithMaxScore 按 score 倒序取第一条
推荐好友列表 /mf/recommendation queryRecommendUserList 按 score 倒序分页
项目卡片 /mf/cards queryCardsList 排除已滑过 + 随机取样

三者返回的都是 TodayBest VO,前端卡片能直接复用缘分值字段。今日佳人是「缘分值最高的那一个」,项目卡片则是「从剩下的里面随机来一批」,产品语义上,最佳推荐单独放大展示,其余的进快配池。

排除已滑过 + 随机取样

RecommendUserApiImpl.queryCardsList 的实现(实读):

java 复制代码
/**
 * 1、排除喜欢,不喜欢的用户
 * 2、随机展示
 * 3、指定数量
 */
public List<RecommendUser> queryCardsList(Long userId, int counts) {
    //1、查询喜欢不喜欢的用户ID
    List<UserLike> likeList = mongoTemplate.find(Query.query(Criteria.where("userId").is(userId)), UserLike.class);
    List<Long> likeUserIdS = CollUtil.getFieldValues(likeList, "likeUserId", Long.class);
    //2、构造查询推荐用户的条件
    Criteria criteria = Criteria.where("toUserId").is(userId).and("userId").nin(likeUserIdS);
    //3、使用统计函数,随机获取推荐的用户列表
    TypedAggregation<RecommendUser> newAggregation = TypedAggregation.newAggregation(RecommendUser.class,
            Aggregation.match(criteria),//指定查询条件
            Aggregation.sample(counts)
    );
    AggregationResults<RecommendUser> results = mongoTemplate.aggregate(newAggregation, RecommendUser.class);
    //4、构造返回
    return results.getMappedResults();
}

三步:先从 MongoDB 的 user_like 集合把该用户所有滑过的人查出来,再用 $nin 从 recommend_user 里排除,最后用 $sample 随机取 counts 条。注意排除依据是 MongoDB 而不是 Redis,虽然 Redis Set 里同样有这份数据。

用 Redis 做排除在思路上也成立,可以作为优化方向:

  • 逐个判存 :推荐结果取回来后,对每个候选 id 执行 SISMEMBER USER_LIKE_SET_{userId} id 和 SISMEMBER USER_NOT_LIKE_SET_{userId} id,过滤掉命中的。候选只有 10 个左右时开销可以忽略。
  • SDIFF 一步排除 :如果推荐 id 全集能先取出来,SDIFF candidate_set USER_LIKE_SET_{userId} 再和 NOT_LIKE Set 差一次,两次集合运算直接得到排除后的结果。数据量大时比逐个判存更划算。

教学工程选择 Mongo 排除,好处是实现简单、排除逻辑与持久化数据强一致;代价是每次请求都要把该用户的全部滑动记录捞一遍,滑动次数多了以后这个 find 会变重。生产实现更可能把排除条件下推到 Redis,或者把 nin 换成预先维护的排除集合。

兜底数据

推荐数据不存在时(新用户或推荐系统没跑),返回配置文件里的默认推荐用户:

yaml 复制代码
#默认推荐列表
mf:
  default:
    recommend:
      users: 2,3,8,10,18,20,24,29,27,32,36,37,56,64,75,88

mfService.queryCardsList 里把这份配置拆成 id 列表,随机给 60~90 之间的缘分值,保证页面永远有卡片可滑:

java 复制代码
//项目-推荐用户列表
public List<TodayBest> queryCardsList() {
    //1、调用推荐API查询数据列表(排除喜欢/不喜欢的用户,数量限制)
    List<RecommendUser> users = recommendUserApi.queryCardsList(UserHolder.getUserId(),10);
    //2、判断数据是否存在,如果不存在,构造默认数据 1,2,3
    if(CollUtil.isEmpty(users)) {
        users = new ArrayList<>();
        String[] userIdS = recommendUser.split(",");
        for (String userId : userIdS) {
            RecommendUser recommendUser = new RecommendUser();
            recommendUser.setUserId(Convert.toLong(userId));
            recommendUser.setToUserId(UserHolder.getUserId());
            recommendUser.setScore(RandomUtil.randomDouble(60, 90));
            users.add(recommendUser);
        }
    }
    //3、构造VO
    List<Long> ids = CollUtil.getFieldValues(users, "userId", Long.class);
    Map<Long, UserInfo> infoMap = userInfoApi.findByIds(ids, null);

    List<TodayBest> vos = new ArrayList<>();
    for (RecommendUser user : users) {
        UserInfo userInfo = infoMap.get(user.getUserId());
        if(userInfo != null) {
            TodayBest vo = TodayBest.init(userInfo, user);
            vos.add(vo);
        }
    }
    return vos;
}

配对机制:双向喜欢自动成为好友

配对判定的核心一行在 mfService.isLike,检查对方的 LIKE Set 里有没有我:

java 复制代码
//判断是否双向喜欢
public Boolean isLike(Long userId, Long likeUserId) {
    String key = Constants.USER_LIKE_KEY + userId;
    return redisTemplate.opsForSet().isMember(key, likeUserId.toString());
}

调用时参数是翻转的:isLike(likeUserId, UserHolder.getUserId()),key 用的是对方 的 USER_LIKE_SET_,成员查的是我的 id。语义就是「A 右滑 B 时,看 B 是不是早就右滑过 A」。

命中后走 messagesService.contacts(实读),做两件事:先把好友关系注册到环信(IM 通道),再把好友关系落到 MongoDB 的 Friend 集合:

java 复制代码
public void contacts(Long friendId) {
    //1、将好友关系注册到环信
    Boolean aBoolean = huanXinTemplate.addContact(Constants.HX_USER_PREFIX + UserHolder.getUserId(),
            Constants.HX_USER_PREFIX + friendId);
    if(!aBoolean) {
        throw new BusinessException(ErrorResult.error());
    }
    //2、如果注册成功,记录好友关系到mongodb
    friendApi.save(UserHolder.getUserId(),friendId);
}

完整时序:
用户B(早已右滑过A) MongoDB(dubbo) Redis mf-app-server 用户A(右滑) 用户B(早已右滑过A) MongoDB(dubbo) Redis mf-app-server 用户A(右滑) #mermaid-svg-1bxaUgAsl97FVE4u{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-1bxaUgAsl97FVE4u .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-1bxaUgAsl97FVE4u .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-1bxaUgAsl97FVE4u .error-icon{fill:#552222;}#mermaid-svg-1bxaUgAsl97FVE4u .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-1bxaUgAsl97FVE4u .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-1bxaUgAsl97FVE4u .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-1bxaUgAsl97FVE4u .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-1bxaUgAsl97FVE4u .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-1bxaUgAsl97FVE4u .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-1bxaUgAsl97FVE4u .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-1bxaUgAsl97FVE4u .marker{fill:#333333;stroke:#333333;}#mermaid-svg-1bxaUgAsl97FVE4u .marker.cross{stroke:#333333;}#mermaid-svg-1bxaUgAsl97FVE4u svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-1bxaUgAsl97FVE4u p{margin:0;}#mermaid-svg-1bxaUgAsl97FVE4u .actor{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-1bxaUgAsl97FVE4u text.actor>tspan{fill:black;stroke:none;}#mermaid-svg-1bxaUgAsl97FVE4u .actor-line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-1bxaUgAsl97FVE4u .innerArc{stroke-width:1.5;stroke-dasharray:none;}#mermaid-svg-1bxaUgAsl97FVE4u .messageLine0{stroke-width:1.5;stroke-dasharray:none;stroke:#333;}#mermaid-svg-1bxaUgAsl97FVE4u .messageLine1{stroke-width:1.5;stroke-dasharray:2,2;stroke:#333;}#mermaid-svg-1bxaUgAsl97FVE4u #arrowhead path{fill:#333;stroke:#333;}#mermaid-svg-1bxaUgAsl97FVE4u .sequenceNumber{fill:white;}#mermaid-svg-1bxaUgAsl97FVE4u #sequencenumber{fill:#333;}#mermaid-svg-1bxaUgAsl97FVE4u #crosshead path{fill:#333;stroke:#333;}#mermaid-svg-1bxaUgAsl97FVE4u .messageText{fill:#333;stroke:none;}#mermaid-svg-1bxaUgAsl97FVE4u .labelBox{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-1bxaUgAsl97FVE4u .labelText,#mermaid-svg-1bxaUgAsl97FVE4u .labelText>tspan{fill:black;stroke:none;}#mermaid-svg-1bxaUgAsl97FVE4u .loopText,#mermaid-svg-1bxaUgAsl97FVE4u .loopText>tspan{fill:black;stroke:none;}#mermaid-svg-1bxaUgAsl97FVE4u .loopLine{stroke-width:2px;stroke-dasharray:2,2;stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-1bxaUgAsl97FVE4u .note{stroke:#aaaa33;fill:#fff5ad;}#mermaid-svg-1bxaUgAsl97FVE4u .noteText,#mermaid-svg-1bxaUgAsl97FVE4u .noteText>tspan{fill:black;stroke:none;}#mermaid-svg-1bxaUgAsl97FVE4u .activation0{fill:#f4f4f4;stroke:#666;}#mermaid-svg-1bxaUgAsl97FVE4u .activation1{fill:#f4f4f4;stroke:#666;}#mermaid-svg-1bxaUgAsl97FVE4u .activation2{fill:#f4f4f4;stroke:#666;}#mermaid-svg-1bxaUgAsl97FVE4u .actorPopupMenu{position:absolute;}#mermaid-svg-1bxaUgAsl97FVE4u .actorPopupMenuPanel{position:absolute;fill:#ECECFF;box-shadow:0px 8px 16px 0px rgba(0,0,0,0.2);filter:drop-shadow(3px 5px 2px rgb(0 0 0 / 0.4));}#mermaid-svg-1bxaUgAsl97FVE4u .actor-man line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-1bxaUgAsl97FVE4u .actor-man circle,#mermaid-svg-1bxaUgAsl97FVE4u line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;stroke-width:2px;}#mermaid-svg-1bxaUgAsl97FVE4u :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 环信注册好友 + Friend 落库 alt B 也喜欢 A B 未滑过 A GET /mf/{B}/love saveOrUpdate(A, B, isLike=true) true SREM USER_NOT_LIKE_SET_A B SADD USER_LIKE_SET_A B SISMEMBER USER_LIKE_SET_B A true contacts(B) 配对成功, 双方收到通知 false 右滑成功, 静默等待

配对的时机在「后滑的一方」这里:A 晚于 B 右滑,配对由 A 这次请求触发,B 是被动收到好友关系的一方。这意味着 B 的请求早已结束,通知 B 只能靠 IM 通道或推送,不能靠 B 的 HTTP 响应。

接口设计

接口 方法 路径 参数 说明
项目推荐列表 GET /mf/cards 无 随机取 10 张卡片,排除已滑过
喜欢(右滑) GET /mf/{id}/love 路径参数 likeUserId 双向喜欢时自动结为好友
不喜欢(左滑) GET /mf/{id}/unlove 路径参数 likeUserId 记录不感兴趣,排除出列表

Controller 层(mfController 实读):

java 复制代码
/**
 * 项目-推荐用户列表
 */
@GetMapping("/cards")
public ResponseEntity queryCardsList() {
    List<TodayBest> list = this.mfService.queryCardsList();
    return ResponseEntity.ok(list);
}

/**
 * 喜欢
 */
@GetMapping("{id}/love")
public ResponseEntity<Void> likeUser(@PathVariable("id") Long likeUserId) {
    this.mfService.likeUser(likeUserId);
    return ResponseEntity.ok(null);
}

/**
 * 不喜欢
 */
@GetMapping("{id}/unlove")
public ResponseEntity<Void> notLikeUser(@PathVariable("id") Long likeUserId) {
    this.mfService.notLikeUser(likeUserId);
    return ResponseEntity.ok(null);
}

卡片列表返回的 JSON 结构(TodayBest 序列化后):

json 复制代码
[
  {
    "id": "2",
    "nickname": "小美",
    "avatar": "https://mf.cn/avatar/2.png",
    "age": "25",
    "gender": "woman",
    "tags": "单身,本科,年龄相仿",
    "fateValue": 89
  }
]

滑动接口无响应体,成败由 HTTP 状态码表达;配对成功与否前端不在这次响应里拿,而是通过消息通道获得。

原型:项目卡片页

交友首页入口与项目卡片页(文案取自原型原文):

doc 复制代码
+--------------------------------------+
| 交友                                 |
|                                      |
|  +--------------------------------+  |
|  |  [头像]      缘分值89           |  |
|  |                                |  |
|  |  小美                       |  |
|  |  单身  本科  年龄相仿            |  |
|  |                                |  |
|  |          << 左滑: 不喜欢        |  |
|  |          右滑: 喜欢 >>          |  |
|  |                                |  |
|  |  [聊一下]        [喜欢]         |  |
|  +--------------------------------+  |
|                                      |
|  [今日佳人] [推荐]                    |
|                                      |
+--------------------------------------+
|  交友   |   消息    |   我的          |
+--------------------------------------+

要点:

  • 卡片是核心控件,一张一屏,展示头像、昵称(小美)、缘分值 89、标签组合「单身 本科 年龄相仿」
  • 左滑/右滑是手势交互,右滑 = 喜欢,左滑 = 不喜欢;卡片下方保留「聊一下」「喜欢」两个按钮,等价于手势的显式操作,降低交互门槛
  • 缘分值来自 recommend_user.score,即今日佳人同款字段
  • 底部 Tab(交友/消息/我的)表明项目是交友首页的一个入口,与今日佳人、推荐同层

异常与边界

场景 处理方式 说明
滑动自己 不允许 卡片数据源是推荐系统给「别人」,正常流程滑不到自己;接口层可加 likeUserId == userId 校验兜底
重复滑动同一人 幂等 Redis SADD 重复成员无副作用;MongoDB 侧 saveOrUpdate 走更新分支,不会产生重复文档
态度翻转 先删后加 右滑先 SREM NOT_LIKE 再 SADD LIKE,两个 Set 互斥,保证一人只在一个 Set
已配对后再次滑动 重复触发 contacts 现实现会重复调环信加好友,生产上应先查 Friend 关系存在则跳过
已配对后又左滑 讲义要求解除好友 工程注释「各位自行实现」,需同时删环信好友和 MongoDB Friend 记录
MongoDB 写入失败 抛 BusinessException saveOrUpdate 返回 false 直接中断,Redis 不写,保证双写不至于半截
Redis 不可用 滑动失败 MongoDB 已写成功但 Redis 操作抛异常,出现「落库成功、缓存缺失」,需要补偿任务对账
默认推荐重复出现 靠 Set 幂等 兜底用户是固定几个,反复滑到不产生脏数据,但已滑过的人仍可能经兜底通道重现

验证要点

功能验证按数据流走一遍:

  1. 用户 106 调 GET /mf/2/love,用 redis-cli 查 SMEMBERS USER_LIKE_SET_106,应包含 2;USER_NOT_LIKE_SET_106 不含 2
  2. MongoDB user_like 集合应出现 {userId: 106, likeUserId: 2, isLike: true} 的文档
  3. 模拟用户 2 先前已右滑 106(USER_LIKE_SET_2 含 106),第 1 步执行后应触发 contacts,环信后台出现好友关系,MongoDB friend 集合新增文档
  4. 反向场景:USER_LIKE_SET_2 不含 106 时右滑,不产生好友关系
  5. 滑动后调 GET /mf/cards,连续请求多次,返回的卡片列表中不再出现已滑过的用户 id
  6. 态度翻转:先 unlove 再 love 同一人,确认两个 Set 状态正确翻转,MongoDB 中同一条文档的 isLike 变为 true 而不是新增一条

官方文档

常见误解澄清

误解 实际情况
推荐排除是查 Redis Set 过滤的 实读代码:排除依据是 MongoDB user_like 集合 + $nin,Redis 只服务配对判定
滑动记录只存 Redis,重启就丢 工程是 Redis + MongoDB 双写,MongoDB 持久化兜底,Redis 可视为读缓存
配对判定要查 MongoDB 不用,SISMEMBER USER_LIKE_SET_{对方} 我 一次 Redis 判存完成,O(1)
左滑右滑应该用 POST 工程用的 GET({id}/love),语义上不标准(有副作用用 GET),但读接口文档时别看错
两个 Set 可以合并成一个加标记 合并后配对判定、「共同喜欢」交集运算都要逐成员解析标记,Set 的集合运算优势全丢了
默认推荐用户的缘分值是真实分 是 RandomUtil.randomDouble(60, 90) 随机造的,只为保证页面有数据

总结

  • 项目用一次滑动替代「进主页再关注」,右滑喜欢、左滑不感兴趣,双向右滑自动配对,产品价值在于把表达意向的成本压到最低
  • 滑动记录用两个 Redis Set(USER_LIKE_SET_ / USER_NOT_LIKE_SET_):Set 的去重让重复滑动幂等,SISMEMBER 的 O(1) 判存支撑配对判定,SINTER 留住了「共同喜欢」这类扩展空间
  • 持久化是双写:MongoDB user_like 兜底保数据不丢,Redis 承担高速判存;但推荐排除走的是 MongoDB $nin + $sample,两边的读分工不同
  • 配对由「后滑的一方」触发,好友关系要同时写环信和 MongoDB,通知被动一方只能走 IM/推送
  • 遗留点:左滑解除好友、配对后重复滑动的防护,工程里留作练习,生产实现需要补齐

下一篇项目左滑右滑:思路分析会展开滑动接口的实现思路与代码步骤。

相关推荐
前端 贾公子1 小时前
第 5 章:Codex 从可运行程序到可交付工具
java·前端·数据库
gz927cool1 小时前
Python 全栈应用最佳实践完整指南
开发语言·python·ai
CoderYanger1 小时前
Java EE 进阶:5. Spring IoC&DI(第三弹):Spring DI
java·开发语言·spring boot·spring·面试·java-ee·mvc
AIFQuant1 小时前
Python期货行情API接入:股指、黄金、原油多品种数据管道实战
开发语言·python·金融·区块链·量化开发
echome8881 小时前
JavaScript async/await 错误处理:别让 Promise 静默失败
开发语言·前端·javascript·promise
运维行者_1 小时前
供应链安全成最大风险源,企业如何构建可控的运维体系?
运维·开发语言·网络·数据库·安全·云计算·流量监控
一条泥憨鱼2 小时前
【Kafka】零基础学习教程
java·分布式·学习·kafka
.YM.Z2 小时前
优选算法——【双指针算法2】C++实现
开发语言·c++·算法
逆向编程2 小时前
MATLAB高级数据结构:cell、struct与table深度应用
开发语言·数据结构·matlab