概述
「今日佳人」是交友项目 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 与实现类),把这里定义的查询真正跑起来。