Java 项目实战: 交友项目 - 今日佳人:需求分析

概述

「今日佳人」是交友项目 App 首页的第一个推荐位,这里只解决两件事:推荐数据从哪来、服务端接口怎么设计。CRUD 语法已在前面几篇覆盖,这里不再重复,需要时回看SpringData-Mongo:CRUD操作即可。

纲要

  • 业务背景:首页推荐位的产品定位
  • 数据来源:推荐系统(Spark 算法服务)写、业务服务读的解耦架构
  • 场景选型:为什么这个业务落在 MongoDB
  • 数据模型:recommend_user 集合字段与 userId/toUserId 的方向语义
  • 接口设计:GET /mf/todayBest 的入参、返回与「今日」语义
  • 调用链路:App → 鉴权 → Dubbo → Mongo → 跨存储组装的时序
  • 原型还原:交友页「今日佳人」卡片线框图
  • 异常与边界:冷启动、当天无数据、同分排序
  • 验证路径:mongosh 造数 → 调接口核对

业务背景

用户登录成功后进入首页,首页从上到下大致是三块:今日佳人、推荐好友列表、项目/搜附近等功能入口。今日佳人位于页面最顶部,产品语义很直白:系统每天替当前用户挑选一位「缘分值」最高的异性,做成一张卡片推到首页。卡片上展示头像、昵称、年龄、标签和缘分值,用户可以点「喜欢」,也可以点进去查看详细资料。

缘分值在推荐领域叫「打分」或「评分」,只是面向用户时包装成了更容易理解的词。它的计算依据是双方的行为数据,你给别人点赞、评论、喜欢、查看资料,都会累计分数;你自己的资料(婚姻状态、学历等)也参与打分。这些维度汇总后,就得到一对用户之间的缘分值。

对这个功能来说,打分不是服务端的事 。项目的整体架构里除了 Java 服务端,还有一个独立的推荐系统(基于 Spark 的算法服务),它负责跑算法、算分,然后把结果写进 MongoDB。Java 服务端拿到需求后要做的只有一件事:从 MongoDB 里把推荐给当前用户的、缘分值最高的那条记录查出来,展示到手机端。

这是这里最重要的架构认知,值得单独画出来:
#mermaid-svg-PyUe80v3YjTezStM{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-PyUe80v3YjTezStM .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-PyUe80v3YjTezStM .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-PyUe80v3YjTezStM .error-icon{fill:#552222;}#mermaid-svg-PyUe80v3YjTezStM .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-PyUe80v3YjTezStM .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-PyUe80v3YjTezStM .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-PyUe80v3YjTezStM .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-PyUe80v3YjTezStM .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-PyUe80v3YjTezStM .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-PyUe80v3YjTezStM .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-PyUe80v3YjTezStM .marker{fill:#333333;stroke:#333333;}#mermaid-svg-PyUe80v3YjTezStM .marker.cross{stroke:#333333;}#mermaid-svg-PyUe80v3YjTezStM svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-PyUe80v3YjTezStM p{margin:0;}#mermaid-svg-PyUe80v3YjTezStM .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-PyUe80v3YjTezStM .cluster-label text{fill:#333;}#mermaid-svg-PyUe80v3YjTezStM .cluster-label span{color:#333;}#mermaid-svg-PyUe80v3YjTezStM .cluster-label span p{background-color:transparent;}#mermaid-svg-PyUe80v3YjTezStM .label text,#mermaid-svg-PyUe80v3YjTezStM span{fill:#333;color:#333;}#mermaid-svg-PyUe80v3YjTezStM .node rect,#mermaid-svg-PyUe80v3YjTezStM .node circle,#mermaid-svg-PyUe80v3YjTezStM .node ellipse,#mermaid-svg-PyUe80v3YjTezStM .node polygon,#mermaid-svg-PyUe80v3YjTezStM .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-PyUe80v3YjTezStM .rough-node .label text,#mermaid-svg-PyUe80v3YjTezStM .node .label text,#mermaid-svg-PyUe80v3YjTezStM .image-shape .label,#mermaid-svg-PyUe80v3YjTezStM .icon-shape .label{text-anchor:middle;}#mermaid-svg-PyUe80v3YjTezStM .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-PyUe80v3YjTezStM .rough-node .label,#mermaid-svg-PyUe80v3YjTezStM .node .label,#mermaid-svg-PyUe80v3YjTezStM .image-shape .label,#mermaid-svg-PyUe80v3YjTezStM .icon-shape .label{text-align:center;}#mermaid-svg-PyUe80v3YjTezStM .node.clickable{cursor:pointer;}#mermaid-svg-PyUe80v3YjTezStM .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-PyUe80v3YjTezStM .arrowheadPath{fill:#333333;}#mermaid-svg-PyUe80v3YjTezStM .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-PyUe80v3YjTezStM .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-PyUe80v3YjTezStM .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-PyUe80v3YjTezStM .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-PyUe80v3YjTezStM .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-PyUe80v3YjTezStM .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-PyUe80v3YjTezStM .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-PyUe80v3YjTezStM .cluster text{fill:#333;}#mermaid-svg-PyUe80v3YjTezStM .cluster span{color:#333;}#mermaid-svg-PyUe80v3YjTezStM div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-PyUe80v3YjTezStM .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-PyUe80v3YjTezStM rect.text{fill:none;stroke-width:0;}#mermaid-svg-PyUe80v3YjTezStM .icon-shape,#mermaid-svg-PyUe80v3YjTezStM .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-PyUe80v3YjTezStM .icon-shape p,#mermaid-svg-PyUe80v3YjTezStM .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-PyUe80v3YjTezStM .icon-shape .label rect,#mermaid-svg-PyUe80v3YjTezStM .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-PyUe80v3YjTezStM .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-PyUe80v3YjTezStM .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-PyUe80v3YjTezStM :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 高频批量写入推荐结果
按 toUserId 查询 top1
用户行为数据

点赞/评论/查看资料
推荐系统

Spark 算法服务
MongoDB

recommend_user
Java 服务端

mf-dubbo-mongo
App 首页

今日佳人卡片

算法与业务通过 MongoDB 这张「结果表」解耦:算法侧不用关心业务接口长什么样,业务侧也不用懂推荐算法。哪天算法换模型、换打分公式,服务端一行代码不用改,只要写入的集合结构不变。后面的推荐系统章节会展开算法侧的实现,这里始终站在「读结果」这一侧。

为什么这个场景用 MongoDB

选型论证在引入篇已经完整讲过,这里只对号入座,看这个场景的三个特征正好落在 MongoDB 的强项上:

场景特征 MongoDB 的对应能力
推荐结果由算法服务高频、批量写入(每天为每个用户生成一批推荐记录) 文档模型写入成本低,批量 insert 无需维护严格的表结构约束,天然适合「算完就写」的结果落库
查询模式固定:按 toUserId 点查,按 score 排序取 topN 单集合点查 + 排序是 MongoDB 最基础的查询路径,配合索引性能足够
数据有时效性:每天的推荐结果只对当天有意义,旧数据可整体清理 无 Schema 约束、按日期字段批量删除成本低,历史数据不拖累在线查询

一句话:这是典型的「结果存储」场景,数据是别的系统算出来的产物,业务只做读写,MongoDB 的文档模型和写入吞吐正好匹配。

数据模型

recommend_user 集合一共五个字段,字段不多,但方向语义是全篇最容易写错的地方。

字段 类型 说明
_id ObjectId 主键,MongoDB 自动生成
userId Long 被推荐人的用户 id(佳人/帅哥是谁)
toUserId Long 目标用户的用户 id(推荐给谁,即当前登录用户)
score Double 推荐得分,页面上展示为「缘分值」
date String 推荐日期,如 2026/9/30,用于「今日」过滤与旧数据清理

对应实体类(实读自 mf-model 模块):

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.mapping.Document;

@AllArgsConstructor
@NoArgsConstructor
@Data
@Document(collection = "recommend_user")
public class RecommendUser implements java.io.Serializable {
    private ObjectId id; //主键id
    private Long userId; //推荐的用户id
    private Long toUserId; //用户id
    private Double score = 0d; //推荐得分
    private String date; //日期
}

注意集合里只存用户 id ,昵称、头像这些资料一概不存,它们在 MySQL 的 user_info 表里,展示时通过 id 关联查询。推荐结果这种高频重写的数据,没必要在 Mongo 里冗余一份用户资料。

userId 与 toUserId 的方向

两个字段长得很像,方向搞反查询结果就全错了。用一句话锚定:

userId 是「推荐出来的那个人」,toUserId 是「看卡片的那个人」。

两个方向的查询语义完全不同:

查询目标 条件 业务含义
给 userId 推荐了谁 where("toUserId").is(userId) 当前用户首页要展示的佳人列表(今日佳人走这个方向)
userId 被谁推荐 where("userId").is(userId) 「我出现在多少人的推荐页上」,反向运营/排查时用

假设当前登录用户是 106 号,推荐系统给他算了一批结果,落库大概是这样:

javascript 复制代码
use mf
// 106 是目标用户(toUserId),每次推荐的佳人是 userId
db.recommend_user.insertMany([
    { "userId": NumberLong(1),    "toUserId": NumberLong(106), "score": 89, "date": "2026/9/30" },
    { "userId": NumberLong(1024), "toUserId": NumberLong(106), "score": 95, "date": "2026/9/30" },
    { "userId": NumberLong(2048), "toUserId": NumberLong(106), "score": 72, "date": "2026/9/30" }
])
// 验证:按 toUserId 查 106 的推荐,score 降序取第一条,期望拿到 userId=1024、score=95
db.recommend_user.find({ "toUserId": NumberLong(106) }).sort({ "score": -1 }).limit(1)

写代码前先在 mongosh 里把这条查询跑通,后面 Java 侧的 Criteria.where("toUserId") 不过是把同样的条件翻译过来。

接口设计

实读工程代码,本节只交付一个接口,首页卡片的 top1 查询。推荐好友列表是后面的章节,这里不涉及。

项目 内容
路径 GET /mf/todayBest
请求参数 无 URL 参数;用户身份从请求头 token 解析(拦截器写入 ThreadLocal)
返回 ResponseEntity 包装的 TodayBest VO,今日缘分值最高的一个用户
所属模块 mf-app-server(消费者)

TodayBest VO 的字段(实读自 mf-model):

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

import com.mf.model.domain.UserInfo;
import com.mf.model.mongo.RecommendUser;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import org.springframework.beans.BeanUtils;

/**
 * 今日佳人
 */
@Data
@NoArgsConstructor
@AllArgsConstructor
public class TodayBest {

    private Long id; //用户id
    private String avatar;
    private String nickname;
    private String gender; //性别 man woman
    private Integer age;
    private String[] tags;
    private Long fateValue; //缘分值

    /**
     * 在vo对象中,补充一个工具方法,封装转化过程
     */
    public static TodayBest init(UserInfo userInfo, RecommendUser recommendUser) {
        TodayBest vo = new TodayBest();
        BeanUtils.copyProperties(userInfo, vo);
        if (userInfo.getTags() != null) {
            vo.setTags(userInfo.getTags().split(","));
        }
        vo.setFateValue(recommendUser.getScore() == null ? 0L : recommendUser.getScore().longValue());
        return vo;
    }
}

接口返回的 JSON 大致长这样,fateValue 就是卡片上的「缘分值89」:

json 复制代码
{
  "id": 1024,
  "avatar": "https://mf.example.com/avatar/1024.png",
  "nickname": "小美",
  "gender": "woman",
  "age": 22,
  "tags": ["单身", "本科", "年龄相仿"],
  "fateValue": 95
}

有两个设计点值得展开。

跨存储组装 。recommend_user 里只有 id 和 score,接口返回的昵称、头像、年龄来自 MySQL 的 UserInfo。所以 Service 层查到 RecommendUser 后,要拿 recommendUser.getUserId() 再调 userInfoApi.findById() 去查用户资料,最后在 TodayBest.init() 里用 BeanUtils.copyProperties 拼成一个 VO。这个「Mongo 查关系、MySQL 查资料、内存拼 VO」的模式,和黑名单分页里先批查 Mongo 再补用户信息的做法是同一个思路,后面凡是有推荐性质的功能都会反复出现。

「今日」的语义怎么实现 。讲义的表结构里设计了 date 字段,但实读 RecommendUserApiImpl 的查询条件,只有 toUserId 等值匹配 + score 降序 + limit(1),并没有按 date 过滤当天:

java 复制代码
@DubboService
public class RecommendUserApiImpl implements RecommendUserApi {

    @Autowired
    private MongoTemplate mongoTemplate;

    //查询今日佳人
    public RecommendUser queryWithMaxScore(Long toUserId) {
        //根据toUserId查询,根据评分score排序,获取第一条
        //构建Criteria
        Criteria criteria = Criteria.where("toUserId").is(toUserId);
        //构建Query对象
        Query query = Query.query(criteria).with(Sort.by(Sort.Order.desc("score")))
                .limit(1);
        //调用mongoTemplate查询
        return mongoTemplate.findOne(query, RecommendUser.class);
    }
}

这是一个教学工程的简化:date 字段保留在模型里,「当天」由数据侧保证 ,推荐系统每天重写数据、旧数据定期清理,集合里始终只有最新一天的结果,查询自然就是「今日」的。生产上更稳妥的做法是查询条件里加 Criteria.where("date").is(今天),或者按 date 降序先锁定最新一天再取 top1,两种方案的选择依据在「异常与边界」一节讨论。

调用链路

把整条链路串起来,注意分层的归属:controller、service 在消费者 mf-app-server;服务提供者是 mf-dubbo-mongo 模块,它没有 DAO/DO 层,直接是 API 层,通过 MongoTemplate 操作 Mongo。
MySQL user_info UserInfoApi(Dubbo) MongoDB MongoTemplate RecommendUserApi(Dubbo) mfService mfController App 首页 MySQL user_info UserInfoApi(Dubbo) MongoDB MongoTemplate RecommendUserApi(Dubbo) mfService mfController App 首页 #mermaid-svg-eHw1zjbICrapR7Zg{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-eHw1zjbICrapR7Zg .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-eHw1zjbICrapR7Zg .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-eHw1zjbICrapR7Zg .error-icon{fill:#552222;}#mermaid-svg-eHw1zjbICrapR7Zg .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-eHw1zjbICrapR7Zg .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-eHw1zjbICrapR7Zg .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-eHw1zjbICrapR7Zg .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-eHw1zjbICrapR7Zg .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-eHw1zjbICrapR7Zg .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-eHw1zjbICrapR7Zg .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-eHw1zjbICrapR7Zg .marker{fill:#333333;stroke:#333333;}#mermaid-svg-eHw1zjbICrapR7Zg .marker.cross{stroke:#333333;}#mermaid-svg-eHw1zjbICrapR7Zg svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-eHw1zjbICrapR7Zg p{margin:0;}#mermaid-svg-eHw1zjbICrapR7Zg .actor{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-eHw1zjbICrapR7Zg text.actor>tspan{fill:black;stroke:none;}#mermaid-svg-eHw1zjbICrapR7Zg .actor-line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-eHw1zjbICrapR7Zg .innerArc{stroke-width:1.5;stroke-dasharray:none;}#mermaid-svg-eHw1zjbICrapR7Zg .messageLine0{stroke-width:1.5;stroke-dasharray:none;stroke:#333;}#mermaid-svg-eHw1zjbICrapR7Zg .messageLine1{stroke-width:1.5;stroke-dasharray:2,2;stroke:#333;}#mermaid-svg-eHw1zjbICrapR7Zg #arrowhead path{fill:#333;stroke:#333;}#mermaid-svg-eHw1zjbICrapR7Zg .sequenceNumber{fill:white;}#mermaid-svg-eHw1zjbICrapR7Zg #sequencenumber{fill:#333;}#mermaid-svg-eHw1zjbICrapR7Zg #crosshead path{fill:#333;stroke:#333;}#mermaid-svg-eHw1zjbICrapR7Zg .messageText{fill:#333;stroke:none;}#mermaid-svg-eHw1zjbICrapR7Zg .labelBox{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-eHw1zjbICrapR7Zg .labelText,#mermaid-svg-eHw1zjbICrapR7Zg .labelText>tspan{fill:black;stroke:none;}#mermaid-svg-eHw1zjbICrapR7Zg .loopText,#mermaid-svg-eHw1zjbICrapR7Zg .loopText>tspan{fill:black;stroke:none;}#mermaid-svg-eHw1zjbICrapR7Zg .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-eHw1zjbICrapR7Zg .note{stroke:#aaaa33;fill:#fff5ad;}#mermaid-svg-eHw1zjbICrapR7Zg .noteText,#mermaid-svg-eHw1zjbICrapR7Zg .noteText>tspan{fill:black;stroke:none;}#mermaid-svg-eHw1zjbICrapR7Zg .activation0{fill:#f4f4f4;stroke:#666;}#mermaid-svg-eHw1zjbICrapR7Zg .activation1{fill:#f4f4f4;stroke:#666;}#mermaid-svg-eHw1zjbICrapR7Zg .activation2{fill:#f4f4f4;stroke:#666;}#mermaid-svg-eHw1zjbICrapR7Zg .actorPopupMenu{position:absolute;}#mermaid-svg-eHw1zjbICrapR7Zg .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-eHw1zjbICrapR7Zg .actor-man line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-eHw1zjbICrapR7Zg .actor-man circle,#mermaid-svg-eHw1zjbICrapR7Zg line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;stroke-width:2px;}#mermaid-svg-eHw1zjbICrapR7Zg :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 拦截器校验 tokenUserHolder.getUserId() GET /mf/todayBest (携带 token)todayBest()queryWithMaxScore(userId)Query(toUserId=userId).sort(score desc).limit(1)findOneRecommendUser(userId, score)RecommendUserRecommendUserfindById(recommendUser.getUserId())查询用户资料UserInfo(昵称/头像/年龄/标签)UserInfoTodayBest.init(userInfo, recommendUser)TodayBest200 OK + TodayBest JSON

数据从两套存储各取一半,在 service 层合成一个 VO 返回,这就是前面说的跨存储组装落在链路上的位置。

原型还原

打开原型文件(交友.html 对应的页面文本),首页「今日佳人」卡片区域的可见文案有:今日佳人、缘分值、缘分值89、小美、单身 / 本科 / 年龄相仿、喜欢、聊一下。还原成线框图:

doc 复制代码
+--------------------------------------+
|  项目                        [推荐]   |
|                                      |
|  +--------------------------------+  |
|  |          今日佳人               |  |
|  |  ------------------------------ |  |
|  |                                |  |
|  |         (用户头像)              |  |
|  |                                |  |
|  |         小美                |  |
|  |   单身   本科   年龄相仿        |  |
|  |                                |  |
|  |          缘分值89               |  |
|  |                                |  |
|  |   [   喜欢   ]  [  聊一下  ]    |  |
|  +--------------------------------+  |
|                                      |
|  (向下滑动:推荐好友列表...)          |
|                                      |
+--------------------------------------+
|   交友   |   消息   |   我的   |      |
+--------------------------------------+

要点:

  • 「今日佳人」是首页顶部的独立卡片区域,一屏一位,数据来自 GET /mf/todayBest。
  • 卡片信息自上而下:头像、昵称、标签组(单身/本科/年龄相仿,来自 UserInfo.tags 拆分)、缘分值(来自 fateValue,原型示例为 89)。
  • 操作按钮「喜欢/聊一下」对应后续的喜欢与 IM 功能,这里只负责把卡片数据展示出来。
  • 底部 Tab 为交友/消息/我的三个主入口,卡片属于「交友」页。
  • 向下滑动后今日佳人卡片缩小消失,让位给推荐好友列表(原型注释原文)。

异常与边界

边界情况 现象 实读代码的取舍 生产取舍建议
新用户无推荐数据(冷启动) queryWithMaxScore 返回 null 兜底:new RecommendUser() 塞入 userId=1, score=99,展示系统默认用户,接口不报错、页面不空白 可接受;更精细的做法是返回兜底推荐位或引导用户完善资料,但首版「给个默认人」成本最低
当天无数据(推荐系统故障) 集合里是昨天的数据 实读代码不过滤 date,昨天的 top1 会被返回,等于天然降级为「查最近一天」 若改为按 date 过滤,就要配套「查不到时回退最近一天」的降级查询,否则当天页面直接开天窗;两种策略二选一,别混用
score 相同的多条记录 排序不确定 Mongo 的 sort 对同分文档不保证稳定顺序,limit(1) 每次可能返回不同人 补第二排序键 date 或 _id(Sort.by(desc("score"), desc("_id")))让结果确定;或由算法侧保证同分不打

顺带说明:recommendUser == null 时兜底造的假数据 userId=1 要求 user_info 表里真的存在 id=1 的用户,否则下一步 userInfoApi.findById(1) 查出 null,TodayBest.init 里的 copyProperties 会抛 NPE。造验证数据时记得连这个兜底用户一起造。

验证要点

需求分析阶段能落地的验证路径是「先造数、再对答案」:

bash 复制代码
# 1. mongosh 造数(用上文"数据模型"一节的 insertMany,注意 toUserId 用当前登录用户 id)
# 2. 起 mf-dubbo-mongo(端口 18082)与 mf-app-server
# 3. 带登录 token 调接口
curl -H "Authorization: <token>" http://localhost:8080/mf/todayBest

核对三点:

  • 返回的 id 是否为该 toUserId 下 score 最大的那条记录对应的 userId;
  • fateValue 是否等于该条记录的 score(Double 转 Long,小数部分被截断);
  • 把同 toUserId 的记录调换 score 再查一次,返回应跟着变化,确认查询方向没写反。

常见误解澄清

误解 事实
缘分值是 Java 服务端算的 不是。打分由独立的 Spark 推荐系统完成并写入 Mongo,服务端只读结果
userId 是当前登录用户 反了。toUserId 才是当前登录用户,userId 是被推荐的佳人
接口需要传日期参数来限定「今日」 不需要。接口无 URL 参数;「今日」由数据侧每天重写/清理保证,实读代码未按 date 过滤
recommend_user 里存了佳人的昵称头像 只存 id 和 score,资料在 MySQL,展示时跨存储组装
查到 null 应该直接返回空对象给前端 实读代码造了一条 userId=1, score=99 的兜底数据,页面永远有卡片

总结

  • 今日佳人 = 首页推荐位 = 读 推荐结果里缘分值最高的一个人;算分是推荐系统(Spark)的事,写入 recommend_user 集合,Java 服务端只消费,算法与业务靠一张 Mongo 集合解耦。
  • 方向语义记住一句话:toUserId 是看卡片的人,userId 是卡片上的人;今日佳人按 toUserId 查、按 score 降序取第一条。
  • 接口 GET /mf/todayBest 无参数、返回单个 TodayBest;昵称头像要从 MySQL 补齐,TodayBest.init() 负责拼装。
  • 边界上抓住三件事:冷启动兜底、当天无数据的降级策略二选一、同分排序补第二排序键。

下一篇是同章的今日佳人:编写服务提供者:搭 mf-dubbo-mongo 模块(启动类、application.yml、RecommendUserApi 与实现类),把这里定义的查询真正跑起来。

相关推荐
老孟说禅1 小时前
自动化测试入门(一)
开发语言·python
余额瞒着我当琳1 小时前
C++11,发展历史浅谈,初始化列表和对象初始化,右值引用和移动语义
java·开发语言·c++·算法
学逆向的1 小时前
C 宏函数
c语言·开发语言·网络安全
Code_Solitude1 小时前
C语言:指针题型总结
c语言·开发语言
曹牧1 小时前
Java:`tools.jar`
java·开发语言·jar
IT枫斗者枫哥1 小时前
Spring AI文档更新后仍命中旧内容:核对分片ID、来源和版本
java·人工智能
aibo8603081 小时前
基于 Spring Boot + Vue3 的爱心众筹管理系统设计与实现
java·spring boot·后端
SimonKing1 小时前
OpenCode免费模型直接给`Workbuddy`用,一键导入
java·后端·程序员