概述
交友项目的「我的访客」功能要落地,第一步就是把访客数据真正存进 MongoDB。这里只讲一件事:在查看用户详情的接口里,顺手把访客记录写下来,触发点在哪、Visitors 对象怎么构建、缘分值 score 从哪来、一天只记一次的去重怎么实现。
纲要
- 需求回顾与设计特点:为什么访客记录挂在「读接口」上
- 文件清单:涉及的 5 个文件(表格 + 目录树)
- 完整代码链路:
- 触发点:
mfController.personalInfo→mfService.personalInfo - 实体:
mf-model的Visitors - Dubbo 接口与实现:
VisitorsApi/VisitorsApiImpl.save - score 缘分值的传递:冗余换查询效率
- 去重策略:同一用户一天只记一次的三种工程选择
- 时序图:查看主页 → 记录访客 → 被访者列表查询
- 踩坑记录与验证步骤
需求回顾与设计特点「我的访客」的需求是:其他用户访问我的动态、视频、个人信息时,记录访客数据;首页展示最新 5 条访客,「我的」页面展示全部历史记录。
需求描述里有一句容易被忽略的关键约束:
注意:记录访客数据,在查询用户详情的接口中记录即可,不需要额外的接口提供。
也就是说,记录访客这个「写」动作,寄生在「查看用户详情」这个「读」接口里 。用户在前端点开一个推荐用户的详情页,后端返回详情的同时,悄悄写一条访客记录。没有独立的「上报访客」接口。
这个设计有两个直接后果:
- 前端零改造:APP 不需要新增埋点或上报逻辑,访客统计天然覆盖所有「点开详情」的行为。
- 读路径上多了一次同步写:详情接口的响应时间里混入了 Dubbo 调用 + MongoDB 写入的耗时。生产环境上应该把这段保存逻辑异步化(丢线程池或发 MQ),主流程只管查详情返回。教学项目里为了链路清晰是同步写的,这个性能影响先点到为止,后面的坑表里还会提。
文件清单
| 模块 | 文件 | 职责 |
|---|---|---|
| mf-model | model/mongo/Visitors.java |
访客记录 Mongo 实体,映射 visitors 集合 |
| mf-app-server | server/controller/mfController.java |
查看用户详情的 HTTP 入口 |
| mf-app-server | server/service/mfService.java |
构建 Visitors 对象,调用 Dubbo API 保存 |
| mf-dubbo-interface | dubbo/api/VisitorsApi.java |
定义保存访客数据的 Dubbo 接口 |
| mf-dubbo-mongo | dubbo/api/VisitorsApiImpl.java |
保存实现:查重 + 落库 |
| 目录树: |
tree
mf
├── mf-model
│ └── src/main/java/com/mf/model/mongo
│ └── Visitors.java # 访客记录实体
├── mf-app-server
│ └── src/main/java/com/mf/server
│ ├── controller
│ │ └── mfController.java # GET /{id}/personalInfo 触发点
│ └── service
│ └── mfService.java # 构建 Visitors 并调用保存
└── mf-dubbo
├── mf-dubbo-interface
│ └── src/main/java/com/mf/dubbo/api
│ └── VisitorsApi.java # Dubbo 接口定义
└── mf-dubbo-mongo
└── src/main/java/com/mf/dubbo/api
└── VisitorsApiImpl.java # 去重 + 落库实现
触发点:查看用户详情接口
先看 HTTP 入口。访问他人主页走的是这个接口:
java
package com.mf.server.controller;
import com.mf.model.vo.TodayBest;
import com.mf.server.service.mfService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequestMapping("/mf")
public class mfController {
@Autowired
private mfService mfService;
/**
* 查看用户详情
*/
@GetMapping("/{id}/personalInfo")
public ResponseEntity personalInfo(@PathVariable("id") Long userId) {
TodayBest best = mfService.personalInfo(userId);
return ResponseEntity.ok(best);
}
}
Controller 本身不含任何访客逻辑,一行改动都没有,所有工作都在 mfService.personalInfo 里。这正是「不需要额外接口」的落点:改造发生在已有方法的内部。
实体:Visitors
visitors 集合对应的实体放在 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 |
被访者(主页的主人) | 路径参数 userId |
visitorUserId |
访客(谁来看的) | UserHolder.getUserId(),当前登录人 |
date |
精确时间戳,用于列表按时间倒序 | System.currentTimeMillis() |
visitDate |
日期字符串 yyyyMMdd,用于按天去重 |
new SimpleDateFormat("yyyyMMdd").format(new Date()) |
score |
两人之间的缘分值快照 | 从 RecommendUser 查来 |
date 和 visitDate 看似重复,其实各司其职:前者服务「展示最新访客」的排序,后者服务「一天只记一次」的去重判断。用一个毫秒数去判断「是否同一天」很别扭,冗余一列日期字符串是最省事的做法。
核心:mfService.personalInfo 的保存逻辑
mfService 里注入了 VisitorsApi(Dubbo 引用),personalInfo 方法在查完详情后构建访客数据并保存。以下是与工程实读一致的完整代码:
java
package com.mf.server.service;
import com.mf.dubbo.api.UserInfoApi;
import com.mf.dubbo.api.VisitorsApi;
import com.mf.model.domain.UserInfo;
import com.mf.model.mongo.RecommendUser;
import com.mf.model.mongo.Visitors;
import com.mf.model.vo.TodayBest;
import com.mf.server.interceptor.UserHolder;
import org.apache.dubbo.config.annotation.DubboReference;
import org.springframework.stereotype.Service;
import java.text.SimpleDateFormat;
import java.util.Date;
@Service
public class mfService {
@DubboReference
private UserInfoApi userInfoApi;
@DubboReference
private RecommendUserApi recommendUserApi;
@DubboReference
private VisitorsApi visitorsApi;
//查看佳人详情
public TodayBest personalInfo(Long userId) {
//1、根据用户id查询用户详情
UserInfo userInfo = userInfoApi.findById(userId);
//2、根据操作人id和查看的用户id,查询两者的推荐数据
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);
//3、构造返回值
return TodayBest.init(userInfo, user);
}
}
逐行拆开看三个关键点:
① 两个 id 的方向 。setUserId(userId) 存的是路径参数,即被看的人;setVisitorUserId(UserHolder.getUserId()) 存的是当前登录人。逻辑很直白:「谁来看了谁」,看的人在 visitorUserId,被看的人在 userId。后面「首页谁看过我」的查询就是拿 userId = 我 去捞谁来看过我,方向反了整个功能就废了。
② from 来源 。当前只有从首页访问这一条路径,所以写死 "首页"。字段预留了扩展空间,后续从圈子、动态页进来的访问可以带上不同来源。
③ score 的来源 。user.getScore() 里的 user 是第 2 步查出来的 RecommendUser,也就是说缘分值不是实时计算,而是直接复用推荐系统已经算好的结果。这一步是本节设计的精髓,单独展开。
Dubbo 接口与实现:VisitorsApi / VisitorsApiImpl
跨模块保存走 Dubbo。先在 mf-dubbo-interface 定义接口:
java
package com.mf.dubbo.api;
import com.mf.model.mongo.Visitors;
public interface VisitorsApi {
//保存访客数据
void save(Visitors visitors);
}
实现放在 mf-dubbo-mongo,操作 MongoDB:
java
package com.mf.dubbo.api;
import com.mf.model.mongo.Visitors;
import org.apache.dubbo.config.annotation.DubboService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.mongodb.core.MongoTemplate;
import org.springframework.data.mongodb.core.query.Criteria;
import org.springframework.data.mongodb.core.query.Query;
@DubboService
public class VisitorsApiImpl implements VisitorsApi {
@Autowired
private MongoTemplate mongoTemplate;
/**
* 保存访客数据
* 对于同一个用户,一天之内只能保存一次访客数据
*/
public void save(Visitors visitors) {
//1、查询访客数据
Query query = Query.query(Criteria.where("userId").is(visitors.getUserId())
.and("visitorUserId").is(visitors.getVisitorUserId())
.and("visitDate").is(visitors.getVisitDate()));
//2、不存在,保存
if (!mongoTemplate.exists(query, Visitors.class)) {
mongoTemplate.save(visitors);
}
}
}
实现很短,但三个条件一个都不能少:
userId:被访者visitorUserId:访客visitDate:访问日期(yyyyMMdd字符串)
「同一天同一人对同一人只记一次」这个语义,就是靠 userId + visitorUserId + visitDate 三元组唯一来保证的。mongoTemplate.exists() 只判断存在性,不拉数据,比 findOne 省一次反序列化。
score 缘分值的传递:冗余换查询效率
为什么访客记录里要存 score?查询时再去 recommend_user 集合现算不行吗?
不行,也不划算。看这两个集合的数据面:
| 对比项 | recommend_user | visitors |
|---|---|---|
| 数据规模 | 每个用户对每个候选人都有一条,量大 | 只有点开详情才产生,量小 |
| score 含义 | 实时推荐计算的缘分值 | 访问发生那一刻的缘分值快照 |
| 查询场景 | 推荐列表(按 toUserId 查) | 访客列表(按 userId 查) |
「首页谁看过我」的列表页要给每个访客展示缘分值,如果查询时再拿 userId + visitorUserId 回 recommend_user 联查,列表 5 条就是 5 次额外查询,而且两次查询分属两个集合,联查在 MongoDB 里意味着应用层手动组装(这就是前面章节说的跨存储组装问题)。
把 score 随访客记录一起存下来,是典型的冗余换查询效率:写入时多存一个字段,读取时免掉一整轮跨集合查询。访客记录本身是快照性质的数据(记录的是「访问那一刻」的缘分值),这个冗余在语义上也站得住。
这里顺带回答一个实读中确认的细节,recommendUserApi.queryByUserId 查不到推荐数据时怎么办:
java
@Override
public RecommendUser queryByUserId(Long userId, Long toUserId) {
Criteria criteria = Criteria.where("toUserId").is(toUserId).and("userId").is(userId);
Query query = Query.query(criteria);
RecommendUser user = mongoTemplate.findOne(query, RecommendUser.class);
if (user == null) {
user = new RecommendUser();
user.setUserId(userId);
user.setToUserId(toUserId);
//构建缘分值
user.setScore(95d);
}
return user;
}
推荐数据缺失时兜底给 95d。这样保证了 personalInfo 里的 user.getScore() 永远不会拿到 null,下游的访客记录也永远有 score 值。代价是数据库里会出现一批 score=95 的「假缘分值」,教学项目可接受,生产环境更稳的做法是 personalInfo 里对 user 判空后走默认值,而不是在查询方法里悄悄造数据。
去重策略:三种工程选择
VisitorsApiImpl.save 的策略是当天去重:同一天内同一访客对同一被访者只记一条,第二天再访问则新增一条。这是课程给定的业务要求。实际工程里还有另外两种常见选择:
| 策略 | 行为 | 实现 | 适用场景 |
|---|---|---|---|
| 每次都记 | 每次访问新增一条,数据量随访问次数线性增长 | 直接 save,无查重 |
需要精确统计访问次数/PV |
| 当天去重(本项目) | 一天一条,多条只留首条 | exists(userId + visitorUserId + visitDate) 后再 save |
「谁看过我」列表,只关心谁哪天来过 |
| 覆盖更新 | 一人一条,重复访问只刷新时间 | 先 findOne,存在则 update date,否则 insert |
关心「最近一次访问时间」,控数据量 |
三者的取舍点在数据量 和信息量之间:
- 每次都记信息最全,但一个热门用户一天被刷几百次详情页,
visitors集合会被灌爆,列表查询还得再去重。 - 覆盖更新数据量最省(每人一条),但丢失了「每天都来过」的历史信息,且存在先查后写的并发窗口,高并发下可能插入两条。
- 当天去重是折中:既保留了「每天来过」的维度,又把单日重复访问压成一条。
本项目的实现还有个小坑:exists + save 是两步操作,不是原子的。同一用户极短时间内连点两次详情,理论上可能两个请求都通过了 exists 检查、各写一条。教学场景无所谓,生产上可以给三元组建唯一索引兜底(db.visitors.createIndex({userId:1, visitorUserId:1, visitDate:1}, {unique:true})),重复写入直接被索引拦下。
时序图
把整条链路串起来:
MongoDB VisitorsApiImpl RecommendUserApi mfService mfController APP(访客) MongoDB VisitorsApiImpl RecommendUserApi mfService mfController APP(访客) #mermaid-svg-NE3NemUdGZdBZMin{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-NE3NemUdGZdBZMin .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-NE3NemUdGZdBZMin .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-NE3NemUdGZdBZMin .error-icon{fill:#552222;}#mermaid-svg-NE3NemUdGZdBZMin .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-NE3NemUdGZdBZMin .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-NE3NemUdGZdBZMin .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-NE3NemUdGZdBZMin .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-NE3NemUdGZdBZMin .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-NE3NemUdGZdBZMin .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-NE3NemUdGZdBZMin .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-NE3NemUdGZdBZMin .marker{fill:#333333;stroke:#333333;}#mermaid-svg-NE3NemUdGZdBZMin .marker.cross{stroke:#333333;}#mermaid-svg-NE3NemUdGZdBZMin svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-NE3NemUdGZdBZMin p{margin:0;}#mermaid-svg-NE3NemUdGZdBZMin .actor{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-NE3NemUdGZdBZMin text.actor>tspan{fill:black;stroke:none;}#mermaid-svg-NE3NemUdGZdBZMin .actor-line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-NE3NemUdGZdBZMin .innerArc{stroke-width:1.5;stroke-dasharray:none;}#mermaid-svg-NE3NemUdGZdBZMin .messageLine0{stroke-width:1.5;stroke-dasharray:none;stroke:#333;}#mermaid-svg-NE3NemUdGZdBZMin .messageLine1{stroke-width:1.5;stroke-dasharray:2,2;stroke:#333;}#mermaid-svg-NE3NemUdGZdBZMin #arrowhead path{fill:#333;stroke:#333;}#mermaid-svg-NE3NemUdGZdBZMin .sequenceNumber{fill:white;}#mermaid-svg-NE3NemUdGZdBZMin #sequencenumber{fill:#333;}#mermaid-svg-NE3NemUdGZdBZMin #crosshead path{fill:#333;stroke:#333;}#mermaid-svg-NE3NemUdGZdBZMin .messageText{fill:#333;stroke:none;}#mermaid-svg-NE3NemUdGZdBZMin .labelBox{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-NE3NemUdGZdBZMin .labelText,#mermaid-svg-NE3NemUdGZdBZMin .labelText>tspan{fill:black;stroke:none;}#mermaid-svg-NE3NemUdGZdBZMin .loopText,#mermaid-svg-NE3NemUdGZdBZMin .loopText>tspan{fill:black;stroke:none;}#mermaid-svg-NE3NemUdGZdBZMin .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-NE3NemUdGZdBZMin .note{stroke:#aaaa33;fill:#fff5ad;}#mermaid-svg-NE3NemUdGZdBZMin .noteText,#mermaid-svg-NE3NemUdGZdBZMin .noteText>tspan{fill:black;stroke:none;}#mermaid-svg-NE3NemUdGZdBZMin .activation0{fill:#f4f4f4;stroke:#666;}#mermaid-svg-NE3NemUdGZdBZMin .activation1{fill:#f4f4f4;stroke:#666;}#mermaid-svg-NE3NemUdGZdBZMin .activation2{fill:#f4f4f4;stroke:#666;}#mermaid-svg-NE3NemUdGZdBZMin .actorPopupMenu{position:absolute;}#mermaid-svg-NE3NemUdGZdBZMin .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-NE3NemUdGZdBZMin .actor-man line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-NE3NemUdGZdBZMin .actor-man circle,#mermaid-svg-NE3NemUdGZdBZMin line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;stroke-width:2px;}#mermaid-svg-NE3NemUdGZdBZMin :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} alt当日记录不存在当日记录已存在 之后:被访者打开"谁看过我"按 userId 查 visitors,score 直接展示无需联查 GET /mf/{id}/personalInfopersonalInfo(userId)queryByUserId(userId, 当前用户id)查 recommend_user 集合RecommendUser(含 score,无则兜底 95d)RecommendUser构建 Visitors(userId/visitorUserId/from/date/visitDate/score)save(visitors)exists(userId + visitorUserId + visitDate)?save(visitors) 写入 visitors 集合跳过保存TodayBest佳人详情 JSON
踩坑记录
| 坑 | 现象 | 处理 |
|---|---|---|
| 读路径上的同步写 | 详情接口响应时间混入 Dubbo + Mongo 写入耗时,热点用户主页被频繁刷新时放大 | 生产上把 visitorsApi.save 异步化(线程池 / MQ),这里教学代码保持同步便于调试 |
| 两个 id 写反 | userId 存成了访客自己,「谁看过我」查出来说「我看过谁」 |
记牢:userId=被访者(主页主人),visitorUserId=当前操作人 |
| score 取不到 null | 新注册用户互无推荐数据时,queryByUserId 返回 null,getScore() NPE |
课程方案:queryByUserId 内部兜底 score=95d;更稳妥做法是调用方判空 |
| 自己看自己 | 用户点开自己主页也记一条访客,「谁看过我」里出现自己 | 可在 personalInfo 开头判断 userId.equals(UserHolder.getUserId()) 则跳过保存,课程代码未做此处理 |
| exists + save 非原子 | 极端并发下同一天可能写入两条重复记录 | 给 userId + visitorUserId + visitDate 建唯一索引兜底 |
| 日期格式不统一 | visitDate 一处用 yyyyMMdd 一处用 yyyy-MM-dd,去重永远失效 |
格式收口到常量或工具类,全项目统一 yyyyMMdd |
验证
复现课程测试步骤:
- 启动 MongoDB、Dubbo(dubbo-mongo 工程)、mf-app-server。
- 用 106 号用户登录 APP,从首页点开 2 号用户的详情页。
- mongosh 查看访客记录:
javascript
// 切换到 mf 库
use mf
// 查看刚写入的访客记录,确认 userId=2、visitorUserId=106、score 有值
db.visitors.find().pretty()
// 预期输出(节选):
// {
// "_id": ObjectId("..."),
// "userId": NumberLong("2"),
// "visitorUserId": NumberLong("106"),
// "from": "首页",
// "date": NumberLong("1635..."),
// "visitDate": "20260930",
// "score": 86.0
// }
- 验证去重 :同一账号当天内再次访问同一详情页,重复第 3 步,记录条数不变;把其中一条的
visitDate手动改成昨天再访问,会新增一条:
javascript
// 构造"昨天"条件,确认第二天可以再次记录
db.visitors.countDocuments({ userId: NumberLong("2"), visitorUserId: NumberLong("106"), visitDate: "20260930" })
// 连续访问多次后仍应为 1
- 被访者(2 号)登录,调「谁看过我」相关接口,确认访客列表里出现 106,且缘分值直接来自
visitors.score,没有回查recommend_user。
常见误解澄清
| 误解 | 事实 |
|---|---|
| 记录访客需要前端单独上报 | 不需要,写在查看详情接口内部,前端零改动 |
| score 是保存访客时实时算出来的 | 不是,直接取推荐系统已算好的 recommend_user.score,缺失时兜底 95 |
userId 是「我」(当前登录人) |
反了。userId 是被看的人,visitorUserId 才是当前操作人 |
| 同一访客的记录全局只有一条 | 只有「同一天」内唯一,跨天会新增记录(visitDate 变了) |
去重判断用了 date 时间戳 |
用的是 visitDate 日期字符串,date 只负责列表排序 |
总结
- 访客记录挂在
GET /mf/{id}/personalInfo读接口内部完成,无需独立接口,代价是读路径上多一次同步写,生产环境应异步化。 - 保存链路:
mfService.personalInfo构建Visitors→ Dubbo 调用VisitorsApi.save→VisitorsApiImpl按userId + visitorUserId + visitDate查重后落 MongoDB。 - score 随记录冗余存储,让「谁看过我」列表免于跨集合联查
recommend_user,是冗余换查询效率的典型应用。 - 去重采用「当天去重」策略,在数据量与信息量之间取平衡;并发严格场景用唯一索引兜底。
下一篇同章内容是首页谁看过我:思路分析,基于这里写入的 visitors 数据实现访客列表查询(含「上次查看之后的新访客」的增量逻辑)。