概述
「谁看过我」是社交产品最经典的回访钩子:用户好奇谁浏览过自己,于是频繁打开首页。这里聚焦交友项目访客功能的需求拆解与数据设计,记录谁看了我、一天只记一次、缘分值随记录落库、首页列表怎么查。发布小视频的实现链路见后续文章《发布视频:代码实现》,此处不展开。
纲要
- 业务场景:谁看过我,为什么值得做
- 数据设计:
visitors集合(MongoDB) Visitors实体字段逐个拆解- 为什么有两个「时间」字段(
date与visitDate) score缘分值从哪来- 记录时机与去重设计:查看佳人详情接口内顺手记一条
- 接口设计:首页访客列表
GET /movements/visitors - 列表组装:
VisitorsVo跨存储批查 - 原型:最近来访页线框图
- 异常与边界、验证要点、常见误解
业务场景:谁看过我
打开项目 APP 首页,顶部有一行提示:「最近有 4 个人来访」。点进去能看到访客的头像、昵称、标签,还有一个「缘分值」分数。这个功能看起来简单,产品逻辑上却踩中了两个点:
- 好奇心驱动回访:用户想知道谁看了自己,尤其是看了自己却没打招呼的人。访客列表给了用户一个每天打开 APP 的理由。
- 与缘分值联动 :原型文案明确写着「最近 4 人来访,其中有 2 个缘分值很高」,缘分值高的访客会被重点提示。缘分值是推荐系统(今日佳人/推荐用户那套
RecommendUser体系)算出的匹配分数,访客功能直接复用它,把「谁看过我」升级成「谁看过我且和我很配」。
功能拆成两件事:
| 功能 | 说明 | 落点 |
|---|---|---|
| 保存访客记录 | 其他用户查看我的动态、视频、个人信息时记录一条 | 这里设计,实现见后续文章 |
| 查询访客列表 | 首页展示最新 5 条访客;「我的」卡片展示全部历史记录 | 这里设计接口 |
原始资料中的三条需求约束值得划重点:
- 其他用户访问我的动态、视频、个人信息时,需要记录访客数据到数据库
- 首页端展示最新的访客用户,只显示 5 条
我的功能中,展示全部历史访客记录
数据设计:visitors 集合
数据库沿用 MongoDB,集合名 visitors。一条文档长这样(示例):
json
{
"_id": ObjectId("60176f785098b26d1c3ff043"),
"date": NumberLong("1762075497000"),
"userId": NumberLong("99"),
"visitorUserId": NumberLong("2"),
"score": 72,
"from": "首页",
"visitDate": "20261102"
}
对应 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;
@Data
@NoArgsConstructor
@AllArgsConstructor
@Document(collection = "visitors")
public class Visitors implements java.io.Serializable {
private static final long serialVersionUID = 2811682148052386573L;
private ObjectId id;
private Long userId; // 我的id(被访者)
private Long visitorUserId; // 来访用户id(访客)
private String from; // 来源,如首页、圈子等
private Long date; // 来访时间(毫秒数)
private String visitDate; // 来访日期(yyyyMMdd 字符串)
private Double score; // 得分(缘分值)
}
字段逐个说明:
| 字段 | 类型 | 含义 | 设计意图 |
|---|---|---|---|
userId |
Long | 被访者 id(「我的」id) | 列表按它查询:查我的访客就是 userId = 我 |
visitorUserId |
Long | 访客 id | 组装访客头像昵称时靠它反查 UserInfo |
from |
String | 来源入口 | 区分从首页、圈子等哪里点进来的,方便后续做来源分析 |
date |
Long | 来访时间毫秒数 | 排序、增量查询(大于上次查看时间)都靠它 |
visitDate |
String | 来访日期 yyyyMMdd |
去重键:同一访客对同一被访者一天只记一条 |
score |
Double | 缘分值 | 落库时随记录带上,列表展示时不用现算 |
为什么要两个时间字段
这是这里最容易困惑的设计。原始资料里把 date 念成「data」、visitDate 念成「visitdata」,两个名字又长得像,极容易搞混。实际分工是:
date(Long):精确到毫秒的来访时间戳,用于排序 和增量查询visitDate(String):20261102这种日期字符串,用于当天去重判断 为什么去重不直接用date?因为一天内的时间戳千千万,没法用「是否等于今天」这种条件判断;而visitDate存的就是yyyyMMdd,判断「今天是否已来访」只需一个等值匹配。两个字段各司其职,去重查询就不用做任何日期换算。
为什么访客记录适合 MongoDB
访客数据是典型的写多读少、按用户查询、无事务需求 的场景:每个详情页浏览都产生一次写入,读取只发生在用户打开访客列表时;查询维度固定(按 userId 过滤、按 date 倒序);记录丢了坏了也不影响资金或关系链,不需要跨文档事务。文档结构里还有 from 这种扩展性字段,以后加新入口不用改表结构。这与前文「用户行为数据选 MongoDB」的结论是同一逻辑,RecommendUser、Movement 都是这么落的。
score 从哪来
缘分值不是访客模块自己算的。用户 A 查看 B 详情时,服务端本来就要调 recommendUserApi.queryByUserId(userId, visitorUserId) 查两者的推荐数据(推荐系统那套体系),返回的 RecommendUser 里带着推荐系统预先算好的 score。构造 Visitors 时把这个 score 直接塞进去落库。
这样设计的好处:查询访客列表时不需要再回推荐系统算分,列表接口只摸 MongoDB 和 UserInfo 两个存储。代价是缘分值被「快照」进了访客记录,之后推荐分变了,历史访客记录里的分数不变。对访客场景来说这个代价可以接受。
记录时机与去重设计
记录时机:查询用户详情接口里顺手记
原始资料中有一句关键提醒:
注意:记录访客数据,在查询用户详情的接口中记录即可,不需要额外的接口提供。
也就是说没有独立的「上报访客」接口 。APP 点击某个推荐用户进入详情页时,会调用查看佳人详情接口;服务端在返回详情的同时构造 Visitors 对象保存。工程实读确认落点在 mfService#personalInfo(mf-app-server),流程如下:
java
// 查看佳人详情(节选自 mfService,完整实现见后续文章保存访客记录)
public TodayBest personalInfo(Long userId) {
UserInfo userInfo = userInfoApi.findById(userId);
RecommendUser user = recommendUserApi.queryByUserId(userId, UserHolder.getUserId());
// 构造访客数据,调用 API 保存
Visitors visitors = new Visitors();
visitors.setUserId(userId); // 被访者
visitors.setVisitorUserId(UserHolder.getUserId()); // 访客 = 当前登录人
visitors.setFrom("首页");
visitors.setDate(System.currentTimeMillis());
visitors.setVisitDate(new SimpleDateFormat("yyyyMMdd").format(new Date()));
visitors.setScore(user.getScore()); // 推荐分快照
visitorsApi.save(visitors);
return TodayBest.init(userInfo, user);
}
这个设计的取舍:
- 不漏记:只要详情被打开,记录必然发生,不依赖客户端主动上报(客户端上报会被杀进程、断网吞掉)
- 无额外请求:APP 端零改动,服务端在一个已有接口里加一步写操作
- 代价:记录动作和查询动作耦合,详情接口多了一次 Mongo 写入;高频访问的详情页可以考虑后续异步化,当前示例保持同步写
跨服务调用走 Dubbo:mf-app-server 通过 VisitorsApi 调用 mf-dubbo-mongo 里的实现。接口定义(工程实读):
java
package com.mf.dubbo.api;
import com.mf.model.mongo.Visitors;
import java.util.List;
public interface VisitorsApi {
// 保存访客数据
void save(Visitors visitors);
// 查询首页访客列表
List<Visitors> queryMyVisitors(Long date, Long userId);
}
去重策略:一访客一被访者一天一条
保存逻辑的设计约束只有一句话:对于同一个用户,一天之内只能保存一次访客数据。判断条件是三字段等值匹配:
text
userId + visitorUserId + visitDate 已存在 → 跳过保存
不存在 → mongoTemplate.save()
也就是说上午访问过一次,下午再点进同一个主页,不会再产生新记录;但第二天再访问会新记一条(visitDate 变了)。具体实现代码见后续文章保存访客记录。
完整时序:
MongoDB mf-dubbo-mongo mf-app-server APP(访客) MongoDB mf-dubbo-mongo mf-app-server APP(访客) #mermaid-svg-SvAsGrPs0gwCUxUM{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-SvAsGrPs0gwCUxUM .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-SvAsGrPs0gwCUxUM .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-SvAsGrPs0gwCUxUM .error-icon{fill:#552222;}#mermaid-svg-SvAsGrPs0gwCUxUM .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-SvAsGrPs0gwCUxUM .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-SvAsGrPs0gwCUxUM .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-SvAsGrPs0gwCUxUM .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-SvAsGrPs0gwCUxUM .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-SvAsGrPs0gwCUxUM .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-SvAsGrPs0gwCUxUM .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-SvAsGrPs0gwCUxUM .marker{fill:#333333;stroke:#333333;}#mermaid-svg-SvAsGrPs0gwCUxUM .marker.cross{stroke:#333333;}#mermaid-svg-SvAsGrPs0gwCUxUM svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-SvAsGrPs0gwCUxUM p{margin:0;}#mermaid-svg-SvAsGrPs0gwCUxUM .actor{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-SvAsGrPs0gwCUxUM text.actor>tspan{fill:black;stroke:none;}#mermaid-svg-SvAsGrPs0gwCUxUM .actor-line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-SvAsGrPs0gwCUxUM .innerArc{stroke-width:1.5;stroke-dasharray:none;}#mermaid-svg-SvAsGrPs0gwCUxUM .messageLine0{stroke-width:1.5;stroke-dasharray:none;stroke:#333;}#mermaid-svg-SvAsGrPs0gwCUxUM .messageLine1{stroke-width:1.5;stroke-dasharray:2,2;stroke:#333;}#mermaid-svg-SvAsGrPs0gwCUxUM #arrowhead path{fill:#333;stroke:#333;}#mermaid-svg-SvAsGrPs0gwCUxUM .sequenceNumber{fill:white;}#mermaid-svg-SvAsGrPs0gwCUxUM #sequencenumber{fill:#333;}#mermaid-svg-SvAsGrPs0gwCUxUM #crosshead path{fill:#333;stroke:#333;}#mermaid-svg-SvAsGrPs0gwCUxUM .messageText{fill:#333;stroke:none;}#mermaid-svg-SvAsGrPs0gwCUxUM .labelBox{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-SvAsGrPs0gwCUxUM .labelText,#mermaid-svg-SvAsGrPs0gwCUxUM .labelText>tspan{fill:black;stroke:none;}#mermaid-svg-SvAsGrPs0gwCUxUM .loopText,#mermaid-svg-SvAsGrPs0gwCUxUM .loopText>tspan{fill:black;stroke:none;}#mermaid-svg-SvAsGrPs0gwCUxUM .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-SvAsGrPs0gwCUxUM .note{stroke:#aaaa33;fill:#fff5ad;}#mermaid-svg-SvAsGrPs0gwCUxUM .noteText,#mermaid-svg-SvAsGrPs0gwCUxUM .noteText>tspan{fill:black;stroke:none;}#mermaid-svg-SvAsGrPs0gwCUxUM .activation0{fill:#f4f4f4;stroke:#666;}#mermaid-svg-SvAsGrPs0gwCUxUM .activation1{fill:#f4f4f4;stroke:#666;}#mermaid-svg-SvAsGrPs0gwCUxUM .activation2{fill:#f4f4f4;stroke:#666;}#mermaid-svg-SvAsGrPs0gwCUxUM .actorPopupMenu{position:absolute;}#mermaid-svg-SvAsGrPs0gwCUxUM .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-SvAsGrPs0gwCUxUM .actor-man line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-SvAsGrPs0gwCUxUM .actor-man circle,#mermaid-svg-SvAsGrPs0gwCUxUM line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;stroke-width:2px;}#mermaid-svg-SvAsGrPs0gwCUxUM :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} alt 当天未记录 当天已记录 GET 佳人详情(userId) UserHolder 取当前登录人(访客) recommendUserApi.queryByUserId 查推荐数据 RecommendUser(含 score) 构造 Visitors(userId/visitorUserId/from/date/visitDate/score) visitorsApi.save(visitors) exists(userId + visitorUserId + visitDate)? 不存在 save(visitors) 已存在 跳过保存 返回佳人详情
接口设计
本模块对外只暴露一个查询接口(保存复用详情接口,无独立上报):
| 接口 | 方法 | 路径 | 说明 |
|---|---|---|---|
| 首页访客列表 | GET | /movements/visitors |
返回访客 VisitorsVo 列表,最多 5 条 |
响应示例(VisitorsVo 结构):
json
[
{
"id": 2,
"avatar": "https://mf-avatar.example.com/2.png",
"nickname": "小美",
"gender": "woman",
"age": 26,
"tags": ["单身", "本科", "年龄相仿"],
"fateValue": 72
}
]
查询语义上有两种情况(原始资料):
| 场景 | 传参 date |
查询行为 |
|---|---|---|
| 从没看过访客列表 | null | 返回最新 5 条访客 |
| 之前看过 | 上次查看的时间戳 | 只查 date > 上次时间 的 5 条,看过的不再重复出现 |
第二种情况就是「首页只推增量」的实现基础:中午看过 4 个人,下午这 4 条不再展示,只有新访客才出现。「上次查看时间」由 Redis Hash 记录(key 为常量 VISITOR_USER,hashKey 为用户 id),更新时机在用户查看访客列表之后,这套已读机制的具体代码同样留到后续文章。
Dubbo 侧查询方法签名即上面 VisitorsApi#queryMyVisitors(Long date, Long userId):date 为空查全量最新 5 条,不为空做增量;按 date 倒序,limit(5)。
列表组装:VisitorsVo
访客记录里只有 id 和缘分值,APP 要展示的是头像、昵称、性别、年龄、标签。Visitors 存 MongoDB,用户资料存 MySQL 的 UserInfo,跨存储组装的老套路:
- 拿到
List<Visitors>后提取visitorUserId列表 userInfoApi.findByIds(userIds, null)批量查 UserInfo(避免循环单查)VisitorsVo.init(userInfo, visitors)逐条转化,其中fateValue = visitors.getScore().longValue(),缘分值直接取落库快照
VisitorsVo 定义(工程实读):
java
package com.mf.model.vo;
import com.mf.model.domain.UserInfo;
import com.mf.model.mongo.Visitors;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import org.springframework.beans.BeanUtils;
@Data
@NoArgsConstructor
@AllArgsConstructor
public class VisitorsVo {
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 VisitorsVo init(UserInfo userInfo, Visitors visitors) {
VisitorsVo vo = new VisitorsVo();
BeanUtils.copyProperties(userInfo, vo);
if (userInfo.getTags() != null) {
vo.setTags(userInfo.getTags().split(","));
}
vo.setFateValue(visitors.getScore() == null ? 0L : visitors.getScore().longValue());
return vo;
}
}
这和"今日佳人"列表的组装方式(TodayBest.init)完全同构:Mongo 出行为数据、MySQL 出资料数据、Vo 静态工厂收口转化逻辑。批量查代替逐条查是这里的性能关键,5 条访客若循环单查就是 5 次 MySQL 往返。
原型:最近来访页
doc
+--------------------------------------+
| < 返回 谁看过我 |
+--------------------------------------+
| 最近4人来访,其中有2个缘分值很高 |
+--------------------------------------+
| [头像] 小美 |
| 单身 本科 年龄相仿 |
| 缘分值 92 |
+--------------------------------------+
| [头像] 小美 |
| 单身 本科 年龄相仿 |
| 缘分值 88 |
+--------------------------------------+
| [头像] 小美 |
| 单身 本科 年龄相仿 |
| 缘分值 72 |
+--------------------------------------+
| [头像] 小美 |
| 单身 本科 年龄相仿 |
| 缘分值 65 |
+--------------------------------------+
| 圈子 小视频 消息 我的 |
+--------------------------------------+
要点:
- 顶部文案「最近4人来访,其中有2个缘分值很高」为示例界面中的原始文案:人数是总量提示,「缘分值很高」是引导语,指向
fateValue高的访客 - 入口有两处:首页功能卡片(只展示最新增量,最多 5 条)和「我的」页面「谁看过我」卡片(全部历史记录)
- 每个访客卡片:头像 + 昵称 + 标签(
tags数组:单身/本科/年龄相仿)+ 缘分值标签 - 数据对应关系:卡片字段与
VisitorsVo一一对应,fateValue排序靠前即「缘分值很高」
异常与边界
| 边界场景 | 处理方式 | 说明 |
|---|---|---|
| 自己看自己 | 个人主页入口不构造访客记录 | 需求未强制,实现在详情接口侧可加 userId != 当前登录人 判断,否则自己刷自己详情页也会产生访客数据 |
| 同一访客当日重复访问 | userId + visitorUserId + visitDate 存在即跳过 |
每日一条,次日重新计算 |
| 已看过的访客 | 查询时带 date > 上次查看时间 |
首页只推增量,历史记录在「我的」里看全量 |
| 访客已注销/资料缺失 | 组装时 userInfo == null 直接跳过该条 |
findByIds 批查后逐条判空 |
| 空列表 | 返回空数组而非 null | 避免客户端空指针 |
| 隐身访问 | 不提供 | 产品取舍:访客可见性本身就是功能价值,做隐身会瓦解「谁看过我」的信任基础;真实社交产品多以会员权益形式提供 |
验证要点
模拟器操作 + mongosh 双向验证:
bash
# 1. 用账号 A 登录模拟器,在首页点击某个推荐用户进入详情页
# 2. 切换账号 B 重复上述操作(或直接观察 A 访问别人)
# mongosh 查看访客记录是否落库
mongosh
use mf
db.visitors.find({ userId: NumberLong("99") }).sort({ date: -1 })
# 预期:按时间倒序看到访客记录,visitDate 为当前日期(如 "20261102"),score 有值
# 3. 同一天内再次访问同一详情页,再查一次
db.visitors.find({ userId: NumberLong("99"), visitorUserId: NumberLong("2"), visitDate: "20261102" }).count()
# 预期:仍为 1,去重生效
# 4. 调用 GET /movements/visitors,预期返回含 nickname/avatar/fateValue 的列表,最多 5 条
# 5. 查看列表后新增一个访客,再调一次接口,预期只返回新增的那条(增量逻辑生效)
常见误解澄清
| 误解 | 事实 |
|---|---|
visitorUserId 是被访者,userId 是访客 |
反了。userId 是被访者 (「我的」id),visitorUserId 是来访用户 。李四看了我,记 userId=我,visitorUserId=李四 |
| 需要客户端上报访客 | 不需要。在查询用户详情接口里记录,无独立上报接口,APP 零改动 |
去重靠 date 时间戳判断 |
靠 visitDate(yyyyMMdd 字符串)。毫秒级 date 无法做「是否当天」的等值判断 |
date 和 visitDate 冗余,留一个就行 |
分工不同:date 管排序和增量查询,visitDate 管当天去重 |
| 每次打开列表都要回推荐系统算缘分值 | 不用。score 在记录时就落库快照,列表只查 MongoDB + UserInfo |
| 首页每次都展示最新 5 条 | 只展示增量:看过列表后,已展示过的访客不再重复出现,靠 Redis 里的上次查看时间实现 |
官方文档
总结
- 访客功能的本质是两个动作:详情被查看时记一条 、用户打开列表时查一批 ,全部围绕 MongoDB 的
visitors集合 - 数据设计有意识地做了拆分:
date管排序与增量,visitDate管当天去重,score落库快照避免查询期算分 - 记录复用查看佳人详情接口,不漏记、不增加客户端负担;去重用「存在即跳过」三字段判断,一天一条
- 列表查询按「上次查看时间」做增量,首页永远只推新访客,历史全量留给「我的」页面
- 组装沿用「Mongo 行为数据 + MySQL 资料数据 + Vo 静态工厂 + 批量查」的既有套路
后续文章将进入保存访客记录的代码实现:VisitorsApiImpl 的去重保存、首页访客列表接口的完整实现,以及 Redis 已读时间的写入。