Java 项目实战:交友项目 - 我的访客:需求说明

概述

「谁看过我」是社交产品最经典的回访钩子:用户好奇谁浏览过自己,于是频繁打开首页。这里聚焦交友项目访客功能的需求拆解与数据设计,记录谁看了我、一天只记一次、缘分值随记录落库、首页列表怎么查。发布小视频的实现链路见后续文章《发布视频:代码实现》,此处不展开。

纲要

  • 业务场景:谁看过我,为什么值得做
  • 数据设计: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,跨存储组装的老套路:

  1. 拿到 List<Visitors> 后提取 visitorUserId 列表
  2. userInfoApi.findByIds(userIds, null) 批量查 UserInfo(避免循环单查)
  3. 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 已读时间的写入。

相关推荐
布莱克6051 小时前
C++ 中堆和栈的区别详解
开发语言·c++·栈·堆
像风一样自由20201 小时前
3.Dockerfile如何构建一个Python算法镜像
开发语言·人工智能·python·大模型
云析赢指标公式网42 小时前
文华财经指标源码笔记:WINNER筹码函数、控盘变量与MACD变体的调试记录
java·笔记·算法
从琳开始9892 小时前
从Socket到TCP回显服务器:Java网络编程完整指南
java·服务器·网络·tcp/ip·java-ee
养军博客2 小时前
Spring Security 4.x / 5.x / 6.x 配置全对比:前后端不分离与前后端分离
java·后端·spring·springsecurity
wifi___2 小时前
SpringBoot自动装配原理
java·spring boot·后端
SL_staff3 小时前
制造订单交付闭环实践:用跨部门计划模板解决缺料-停机-返工三角死循环
java·开源·产品
device_Any3 小时前
接口文档写着三个字段,接口返回了五个
android·java·linux
Wang's Blog3 小时前
Java 项目实战: 交友项目 - 小视频模块实战:访客记录、FastDFS 存储与 SpringCache 缓存
java·缓存·交友