Building Message Features and User Profile Pages

Overview

This tutorial covers implementing the following features:

  • Likes, loves, and comments notification lists
  • System announcements
  • User profile page
  • Chat with strangers functionality
  • Who viewed my profile feature
  1. Notification Lists (Likes, Loves, Comments)

The notification center displays interactions from other users on your content. Since these three types share identical logic, we implement them together.

API endpoints:

1.1 Dubbo Service Layer

1.1.1 Interface Definition

//com.socialapp.dubbo.server.api.SocialContentApi
    /**
     * Retrieve likes received on user's content
     */
    PageInfo<Interaction> fetchReceivedLikes(Long userId, Integer page, Integer pageSize);

    /**
     * Retrieve loves received on user's content
     */
    PageInfo<Interaction> fetchReceivedLoves(Long userId, Integer page, Integer pageSize);

    /**
     * Retrieve comments received on user's content
     */
    PageInfo<Interaction> fetchReceivedComments(Long userId, Integer page, Integer pageSize);

1.1.2 Implementation

//com.socialapp.dubbo.server.api.SocialContentApiImpl

    @Override
    public PageInfo<Interaction> fetchReceivedLikes(Long userId, Integer page, Integer pageSize) {
        return this.fetchInteractionsByType(userId, InteractionType.LIKE, page, pageSize);
    }

    @Override
    public PageInfo<Interaction> fetchReceivedLoves(Long userId, Integer page, Integer pageSize) {
        return this.fetchInteractionsByType(userId, InteractionType.LOVE, page, pageSize);
    }

    @Override
    public PageInfo<Interaction> fetchReceivedComments(Long userId, Integer page, Integer pageSize) {
        return this.fetchInteractionsByType(userId, InteractionType.COMMENT, page, pageSize);
    }

    private PageInfo<Interaction> fetchInteractionsByType(Long userId, InteractionType type, Integer page, Integer pageSize) {
        PageRequest request = PageRequest.of(page - 1, pageSize,
                Sort.by(Sort.Order.desc("created")));

        Query query = new Query(Criteria
                .where("contentOwnerId").is(userId)
                .and("interactionType").is(type.getCode())).with(request);

        List<Interaction> interactions = this.mongoTemplate.find(query, Interaction.class);

        PageInfo<Interaction> result = new PageInfo<>();
        result.setPageNum(page);
        result.setPageSize(pageSize);
        result.setRecords(interactions);
        return result;
    }

1.2 Application Service Layer

1.2.1 Notification Response Object

package com.socialapp.server.vo;

import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;

@Data
@NoArgsConstructor
@AllArgsConstructor
public class InteractionNotificationDto {

    private String id;
    private String avatar;
    private String nickname;
    private String timestamp; //format: "2019-09-08 10:07"
}

1.2.2 NotificationController

//com.socialapp.server.controller.NotificationController

    /**
     * Get likes notification list
     */
    @GetMapping("likes")
    public ResponseEntity<PaginatedResult> getLikesNotifications(
            @RequestParam(value = "page", defaultValue = "1") Integer page,
            @RequestParam(value = "pagesize", defaultValue = "10") Integer pageSize) {
        try {
            PaginatedResult result = this.notificationService.getLikesNotifications(page, pageSize);
            return ResponseEntity.ok(result);
        } catch (Exception e) {
            log.error("Failed to retrieve likes notifications", e);
        }
        return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build();
    }

    /**
     * Get comments notification list
     */
    @GetMapping("comments")
    public ResponseEntity<PaginatedResult> getCommentsNotifications(
            @RequestParam(value = "page", defaultValue = "1") Integer page,
            @RequestParam(value = "pagesize", defaultValue = "10") Integer pageSize) {
        try {
            PaginatedResult result = this.notificationService.getCommentsNotifications(page, pageSize);
            return ResponseEntity.ok(result);
        } catch (Exception e) {
            log.error("Failed to retrieve comments notifications", e);
        }
        return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build();
    }

    /**
     * Get loves notification list
     */
    @GetMapping("loves")
    public ResponseEntity<PaginatedResult> getLovesNotifications(
            @RequestParam(value = "page", defaultValue = "1") Integer page,
            @RequestParam(value = "pagesize", defaultValue = "10") Integer pageSize) {
        try {
            PaginatedResult result = this.notificationService.getLovesNotifications(page, pageSize);
            return ResponseEntity.ok(result);
        } catch (Exception e) {
            log.error("Failed to retrieve loves notifications", e);
        }
        return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build();
    }

1.2.3 NotificationService

//com.socialapp.server.service.NotificationService

    public PaginatedResult getLikesNotifications(Integer page, Integer pageSize) {
        AuthenticatedUser currentUser = AuthenticatedUserContext.get();
        PageInfo<Interaction> pageData = this.socialContentApi.fetchReceivedLikes(
                currentUser.getId(), page, pageSize);
        return this.assembleNotificationResponse(pageData);
    }

    public PaginatedResult getLovesNotifications(Integer page, Integer pageSize) {
        AuthenticatedUser currentUser = AuthenticatedUserContext.get();
        PageInfo<Interaction> pageData = this.socialContentApi.fetchReceivedLoves(
                currentUser.getId(), page, pageSize);
        return this.assembleNotificationResponse(pageData);
    }

    public PaginatedResult getCommentsNotifications(Integer page, Integer pageSize) {
        AuthenticatedUser currentUser = AuthenticatedUserContext.get();
        PageInfo<Interaction> pageData = this.socialContentApi.fetchReceivedComments(
                currentUser.getId(), page, pageSize);
        return this.assembleNotificationResponse(pageData);
    }

    private PaginatedResult assembleNotificationResponse(PageInfo<Interaction> pageData) {
        PaginatedResult result = new PaginatedResult();
        result.setPage(pageData.getPageNum());
        result.setPagesize(pageData.getPageSize());

        List<Interaction> items = pageData.getRecords();
        if (CollectionUtils.isEmpty(items)) {
            return result;
        }

        List<Object> userIdCollection = items.stream()
                .map(Interaction::getActorId)
                .collect(Collectors.toList());
        List<UserProfile> profiles = this.userProfileService.fetchProfilesByIds(userIdCollection);

        List<InteractionNotificationDto> notifications = new ArrayList<>();
        for (Interaction interaction : items) {
            UserProfile profile = profiles.stream()
                    .filter(p -> p.getUserId().equals(interaction.getActorId()))
                    .findFirst()
                    .orElse(null);

            if (profile != null) {
                InteractionNotificationDto dto = new InteractionNotificationDto();
                dto.setId(interaction.getId().toHexString());
                dto.setAvatar(profile.getAvatarUrl());
                dto.setNickname(profile.getDisplayName());
                dto.setTimestamp(DateTimeFormatter.format(
                        new Date(interaction.getCreated()), "yyyy-MM-dd HH:mm"));
                notifications.add(dto);
            }
        }

        result.setItems(notifications);
        return result;
    }

1.3 Testing2. System Announcements

Announcements are official notifications broadcast by administrators to all users.

API endpoint: https://mock-java.itheima.net/project/35/interface/api/797

2.1 Database Schema

CREATE TABLE `system_announcements` (
  `id` bigint(20) NOT NULL AUTO_INCREMENT,
  `title` varchar(200) DEFAULT NULL,
  `content` text,
  `created_at` datetime DEFAULT NULL,
  `updated_at` datetime DEFAULT NULL,
  PRIMARY KEY (`id`),
  KEY `created_at` (`created_at`)
) ENGINE=InnoDB AUTO_INCREMENT=4 DEFAULT CHARSET=utf8;

-- Sample data
INSERT INTO `system_announcements` (`id`, `title`, `content`, `created_at`, `updated_at`) 
VALUES ('1', 'New Version Released', 'Summer party event now available', '2019-10-14 11:06:34', '2019-10-14 11:06:37');
INSERT INTO `system_announcements` (`id`, `title`, `content`, `created_at`, `updated_at`) 
VALUES ('2', 'New Circles Feature', 'Groups feature is now live', '2019-10-14 11:09:31', '2019-10-14 11:09:33');
INSERT INTO `system_announcements` (`id`, `title`, `content`, `created_at`, `updated_at`) 
VALUES ('3', 'Holiday Notice', 'Service remains operational during holidays', '2019-10-14 11:10:01', '2019-10-14 11:10:04');

2.2 Entity Model

package com.socialapp.common.entity;

import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;

@Data
@NoArgsConstructor
@AllArgsConstructor
public class Announcement extends BaseEntity {

    private Long id;
    private String title;
    private String content;
}

2.3 AnnouncementMapper

package com.socialapp.common.repository;

import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.socialapp.common.entity.Announcement;

public interface AnnouncementMapper extends BaseMapper<Announcement> {
}

2.4 AnnouncementService

package com.socialapp.server.service;

import com.baomidou.mybatisplus.core.conditions.query.QueryCondition;
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.socialapp.common.repository.AnnouncementMapper;
import com.socialapp.common.entity.Announcement;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;

@Service
public class AnnouncementService {

    @Autowired
    private AnnouncementMapper announcementMapper;

    public IPage<Announcement> fetchAnnouncements(Integer page, Integer pageSize) {
        QueryCondition condition = new QueryCondition();
        condition.orderByDesc("createdAt");
        return this.announcementMapper.selectPage(
                new Page<Announcement>(page, pageSize), condition);
    }
}

2.5 Response DTO

package com.socialapp.server.vo;

import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;

@Data
@NoArgsConstructor
@AllArgsConstructor
public class AnnouncementDto {

    private String id;
    private String title;
    private String content;
    private String postedAt;
}

2.6 NotificationController

    /**
     * Get system announcements
     */
    @GetMapping("announcements")
    @PublicEndpoint  //No authentication required
    public ResponseEntity<PaginatedResult> getAnnouncements(
            @RequestParam(value = "page", defaultValue = "1") Integer page,
            @RequestParam(value = "pagesize", defaultValue = "10") Integer pageSize) {
        try {
            PaginatedResult result = this.notificationService.getAnnouncements(page, pageSize);
            return ResponseEntity.ok(result);
        } catch (Exception e) {
            log.error("Failed to fetch announcements", e);
        }
        return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build();
    }

2.7 NotificationService

    public PaginatedResult getAnnouncements(Integer page, Integer pageSize) {
        IPage<Announcement> announcementPage = 
                this.announcementService.fetchAnnouncements(page, pageSize);

        List<AnnouncementDto> announcementList = new ArrayList<>();

        for (Announcement record : announcementPage.getRecords()) {
            AnnouncementDto dto = new AnnouncementDto();
            dto.setId(record.getId().toString());
            dto.setTitle(record.getTitle());
            dto.setContent(record.getContent());
            dto.setPostedAt(DateTimeFormatter.format(
                    record.getCreatedAt(), "yyyy-MM-dd HH:mm"));
            announcementList.add(dto);
        }

        PaginatedResult result = new PaginatedResult();
        result.setPage(page);
        result.setPagesize(pageSize);
        result.setItems(announcementList);

        return result;
    }

2.8 Testing3. User Profile Page

Clicking on featured users or recommendations navigates to their profile page, displaying personal information, compatibility scores, and photo albums.

3.1 User Information

3.1.1 Dubbo Service Layer

The compatibility score is calculated based on recommendation algorithms.

3.1.1.1 Interface Definition
//com.socialapp.dubbo.server.api.RecommendationApi

    /**
     * Calculate compatibility score between two users
     */
    Double calculateCompatibility(Long userId, Long targetUserId);

3.1.1.2 Implementation
//com.socialapp.dubbo.server.api.RecommendationApiImpl

    @Override
    public Double calculateCompatibility(Long userId, Long targetUserId) {
        Query query = Query.query(Criteria.where("targetUserId").is(targetUserId)
                .and("userId").is(userId));
        CompatibilityRecord record = this.mongoTemplate.findOne(query, CompatibilityRecord.class);
        if (record != null) {
            return record.getScore();
        }
        return null;
    }

3.1.2 Application Service Layer

API endpoint: https://mock-java.itheima.net/project/35/interface/api/629

3.1.2.1 UserProfileController
package com.socialapp.server.controller;

import com.socialapp.server.service.UserProfileService;
import com.socialapp.server.vo.UserProfileSummary;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;

@RestController
@RequestMapping("userprofile")
public class UserProfileController {

    @Autowired
    private UserProfileService userProfileService;

    /**
     * Retrieve user profile information
     */
    @GetMapping("{userId}/details")
    public ResponseEntity<UserProfileSummary> getUserProfile(
            @PathVariable("userId") Long userId) {
        try {
            UserProfileSummary profile = this.userProfileService.getUserProfile(userId);
            return ResponseEntity.ok(profile);
        } catch (Exception e) {
            log.error("Error fetching user profile", e);
        }
        return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build();
    }
}

3.1.2.2 UserProfileService
package com.socialapp.server.service;

import cn.hutool.core.util.StrUtil;
import com.socialapp.common.entity.User;
import com.socialapp.common.entity.UserProfile;
import com.socialapp.common.utils.UserContext;
import com.socialapp.server.vo.UserProfileSummary;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;

@Service
public class UserProfileService {

    @Autowired
    private UserProfileService userProfileService;

    @Autowired
    private CompatibilityService compatibilityService;

    public UserProfileSummary getUserProfile(Long userId) {
        UserProfile profileData = this.userProfileService.fetchProfileByUserId(userId);
        if (profileData == null) {
            return null;
        }

        UserProfileSummary summary = new UserProfileSummary();
        summary.setUserId(userId);
        summary.setAge(profileData.getAge());
        summary.setGender(profileData.getGender().name().toLowerCase());
        summary.setNickname(profileData.getNickName());
        summary.setTags(StrUtil.split(profileData.getTags(), ','));
        summary.setAvatar(profileData.getAvatarUrl());

        //Compatibility score
        User currentUser = UserContext.get();
        summary.setCompatibilityScore(
                this.compatibilityService.getScore(userId, currentUser.getId()).longValue());

        return summary;
    }
}

3.1.2.3 CompatibilityService
//com.socialapp.server.service.CompatibilityService

    public Double getScore(Long userId, Long targetUserId) {
        Double score = this.recommendationApi.calculateCompatibility(userId, targetUserId);
        if (score != null) {
            return score;
        }
        return 98.0; //Default score
    }

3.1.3 Testing

3.2 Photo Albums

3.2.1 Dubbo Service Layer

3.2.1.1 Interface Definition
//com.socialapp.dubbo.server.api.SocialContentApi

    /**
     * Retrieve user's photo album
     */
    PageInfo<Post> fetchUserAlbum(Long userId, Integer page, Integer pageSize);

3.2.1.2 Implementation
//com.socialapp.dubbo.server.api.SocialContentApiImpl

    @Override
    public PageInfo<Post> fetchUserAlbum(Long userId, Integer page, Integer pageSize) {
        PageInfo<Post> result = new PageInfo<>();
        result.setPageNum(page);
        result.setPageSize(pageSize);

        PageRequest request = PageRequest.of(page - 1, pageSize,
                Sort.by(Sort.Order.desc("created")));
        Query query = new Query().with(request);

        List<AlbumEntry> albumEntries = 
                this.mongoTemplate.find(query, AlbumEntry.class, "album_" + userId);

        if (CollectionUtils.isEmpty(albumEntries)) {
            return result;
        }

        List<Object> postIds = albumEntries.stream()
                .map(AlbumEntry::getPostId)
                .collect(Collectors.toList());

        Query postQuery = Query.query(Criteria.where("id").in(postIds))
                .with(Sort.by(Sort.Order.desc("created")));

        List<Post> posts = this.mongoTemplate.find(postQuery, Post.class);
        result.setRecords(posts);

        return result;
    }

3.2.2 Applicaiton Service Layer

API endpoint: https://mock-java.itheima.net/project/35/interface/api/689

3.2.2.1 SocialFeedController
//com.socialapp.server.controller.SocialFeedController

    /**
     * Get user's complete activity feed
     */
    @GetMapping("all")
    public ResponseEntity<PaginatedResult> getUserAlbum(
            @RequestParam(value = "page", defaultValue = "1") Integer page,
            @RequestParam(value = "pagesize", defaultValue = "10") Integer pageSize,
            @RequestParam(value = "userId") Long userId) {
        try {
            PaginatedResult result = this.feedService.getUserAlbum(userId, page, pageSize);
            return ResponseEntity.ok(result);
        } catch (Exception e) {
            log.error("Error retrieving user album", e);
        }
        return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build();
    }

3.2.2.2 SocialFeedService
//com.socialapp.server.service.SocialFeedService

    public PaginatedResult getUserAlbum(Long userId, Integer page, Integer pageSize) {
        PaginatedResult result = new PaginatedResult();
        result.setPage(page);
        result.setPagesize(pageSize);

        PageInfo<Post> pageData = this.socialContentApi.fetchUserAlbum(userId, page, pageSize);
        if (CollectionUtils.isEmpty(pageData.getRecords())) {
            return result;
        }

        result.setItems(this.transformToFeedItems(pageData.getRecords()));
        return result;
    }

3.2.3 Testing

3.3 Integration Testing4. Chat with Strangers

Clicking "Chat" on a user profile opens a question prompt. After answering, the system sends a connection request. If the recipient accepts, both users become connections.

User A clicks "Chat" on User B's profile:

4.1 Stranger Questions

Questions must be stored in the database to display when initiating chat.

4.1.1 Database Schema

CREATE TABLE `user_questions` (
  `id` bigint(20) NOT NULL AUTO_INCREMENT,
  `user_id` bigint(20) DEFAULT NULL,
  `question_text` varchar(200) DEFAULT NULL,
  `created_at` datetime DEFAULT NULL,
  `updated_at` datetime DEFAULT NULL,
  PRIMARY KEY (`id`),
  KEY `user_id` (`user_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8;

4.1.2 Question Entity

package com.socialapp.common.entity;

import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;

@Data
@NoArgsConstructor
@AllArgsConstructor
public class UserQuestion extends BaseEntity {

    private Long id;
    private Long userId;
    private String questionText;
}

4.1.3 QuestionRepository

package com.socialapp.common.repository;

import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.socialapp.common.entity.UserQuestion;

public interface QuestionRepository extends BaseMapper<UserQuestion> {
}

4.1.4 QuestionService

package com.socialapp.server.service;

import com.baomidou.mybatisplus.core.conditions.query.QueryCondition;
import com.socialapp.common.repository.QuestionRepository;
import com.socialapp.common.entity.UserQuestion;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;

@Service
public class QuestionService {

    @Autowired
    private QuestionRepository questionRepository;

    public UserQuestion getUserQuestion(Long userId) {
        QueryCondition condition = new QueryCondition();
        condition.eq("user_id", userId);
        return this.questionRepository.selectOne(condition);
    }
}

4.2 Application Service Layer

API endpoint: https://mock-java.itheima.net/project/35/interface/api/635

4.2.1 UserProfileController

//com.socialapp.server.controller.UserProfileController

    @GetMapping("strangerQuestions")
    public ResponseEntity<String> fetchQuestion(@RequestParam("userId") Long userId) {
        try {
            String question = this.userProfileService.fetchQuestion(userId);
            return ResponseEntity.ok(question);
        } catch (Exception e) {
            log.error("Error fetching question", e);
        }
        return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build();
    }

4.2.2 UserProfileService

//com.socialapp.server.service.UserProfileService

    public String fetchQuestion(Long userId) {
        UserQuestion question = this.questionService.getUserQuestion(userId);
        if (question != null) {
            return question.getQuestionText();
        }
        return "What are your hobbies?";
    }

4.2.3 Testing

4.3 Submitting Question Answers

Clicking "Send" on the question dialog sends a system message with admin privileges.

4.3.1 Dubbo Service Layer

4.3.1.1 Interface Definition
//com.socialapp.dubbo.server.api.ChatApi

    /**
     * Send message as system administrator
     */
    Boolean dispatchAdminMessage(String recipientUsername, MessageType type, String content);

package com.socialapp.dubbo.server.enums;

public enum MessageType {
    TEXT("txt"), IMAGE("img"), LOCATION("loc"), 
    AUDIO("audio"), VIDEO("video"), FILE("file");

    private final String code;

    MessageType(String code) {
        this.code = code;
    }

    public String getCode() {
        return code;
    }
}

4.3.1.2 Implementation
//com.socialapp.dubbo.server.api.ChatApiImpl

    @Override
    public Boolean dispatchAdminMessage(String recipient, MessageType type, String content) {
        String endpoint = this.chatConfig.getBaseUrl()
                + this.chatConfig.getOrgName() + "/"
                + this.chatConfig.getAppName() + "/messages";

        try {
            String payload = JSONUtil.createObj()
                    .set("target_type", "users")
                    .set("target", JSONUtil.createArray().set(recipient))
                    .set("msg", JSONUtil.createObj()
                            .set("type", type.getCode())
                            .set("msg", content)).toString();

            return this.httpClient.post(endpoint, payload).isSuccessful();
        } catch (Exception e) {
            log.error("Failed to send message to {}", recipient, e);
        }
        return false;
    }

4.3.2 Application Service Layer

API endpoint: https://mock-java.itheima.net/project/35/interface/api/641

4.3.2.1 UserProfileController
//com.socialapp.server.controller.UserProfileController

    /**
     * Submit answer to stranger question
     */
    @PostMapping("strangerQuestions")
    public ResponseEntity<Void> submitAnswer(@RequestBody Map<String, Object> payload) {
        try {
            Long targetUserId = Long.valueOf(payload.get("userId").toString());
            String answer = payload.get("reply").toString();
            Boolean success = this.userProfileService.submitAnswer(targetUserId, answer);
            if (success) {
                return ResponseEntity.ok(null);
            }
        } catch (Exception e) {
            log.error("Error submitting answer", e);
        }
        return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build();
    }

4.3.2.2 UserProfileService
//com.socialapp.server.service.UserProfileService

    @DubboReference(version = "1.0.0")
    private ChatApi chatApi;

    public Boolean submitAnswer(Long targetUserId, String answer) {
        User currentUser = UserContext.get();
        UserProfile profile = this.userProfileService.fetchProfileByUserId(currentUser.getId());

        Map<String, Object> message = new HashMap<>();
        message.put("userId", currentUser.getId());
        message.put("chatUsername", "CHAT_" + currentUser.getId());
        message.put("displayName", profile.getNickName());
        message.put("question", this.fetchQuestion(targetUserId));
        message.put("answer", answer);

        return this.chatApi.dispatchAdminMessage(
                "CHAT_" + targetUserId, MessageType.TEXT, JSONUtil.toJsonStr(message));
    }

4.3.3 Testing5. Who Viewed My Profile

Track visitors to your profile. Each visitor is recorded once per day. When fetching the list, return the last 5 visitors since the previous query time, or the 5 most recent visitors if no previous query exists.

5.1 Dubbo Service Layer

5.1.1 Entity Model

package com.socialapp.dubbo.server.model;

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 = "profile_visitors")
public class ProfileVisitor implements Serializable {

    private static final long serialVersionUID = 2811682148052386573L;

    private ObjectId id;
    private Long profileOwnerId;
    private Long visitorId;
    private String sourceChannel;
    private Long visitTimestamp;
    private Double compatibilityScore;
}

5.1.2 Interface Definition

package com.socialapp.dubbo.server.api;

import com.socialapp.dubbo.server.model.ProfileVisitor;
import java.util.List;

public interface VisitorTrackingApi {

    /**
     * Record a profile visit
     */
    String recordVisit(Long profileOwnerId, Long visitorId, String source);

    /**
     * Retrieve recent visitors. Returns last 5 visitors since last query,
     * or 5 most recent if first query.
     */
    List<ProfileVisitor> fetchRecentVisitors(Long profileOwnerId);
}

5.1.3 Implementation

package com.socialapp.dubbo.server.api;

import cn.hutool.core.date.DateUtil;
import cn.hutool.core.util.ObjectUtil;
import com.alibaba.dubbo.config.annotation.Service;
import com.socialapp.dubbo.server.model.CompatibilityRecord;
import com.socialapp.dubbo.server.model.ProfileVisitor;
import org.bson.types.ObjectId;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Sort;
import org.springframework.data.mongodb.core.MongoTemplate;
import org.springframework.data.mongodb.core.query.Criteria;
import org.springframework.data.mongodb.core.query.Query;
import org.springframework.data.redis.core.RedisTemplate;

import java.util.List;

@Service(version = "1.0.0")
public class VisitorTrackingApiImpl implements VisitorTrackingApi {

    @Autowired
    private MongoTemplate mongoTemplate;

    private static final String VISITOR_INDEX_KEY = "PROFILE_VISITOR";

    @Autowired
    private RedisTemplate<String, String> redisTemplate;

    @Override
    public String recordVisit(Long profileOwnerId, Long visitorId, String source) {
        if (ObjectUtil.hasEmpty(profileOwnerId, visitorId, source)) {
            return null;
        }

        String today = DateUtil.today();
        Long dayStart = DateUtil.parseDateTime(today + " 00:00:00").getTime();
        Long dayEnd = DateUtil.parseDateTime(today + " 23:59:59").getTime();

        Query checkQuery = Query.query(Criteria.where("profileOwnerId").is(profileOwnerId)
                .and("visitorId").is(visitorId)
                .andOperator(Criteria.where("visitTimestamp").gte(dayStart),
                        Criteria.where("visitTimestamp").lte(dayEnd)
                )
        );
        long existingCount = this.mongoTemplate.count(checkQuery, ProfileVisitor.class);
        if (existingCount > 0) {
            return null;
        }

        ProfileVisitor visitor = new ProfileVisitor();
        visitor.setSourceChannel(source);
        visitor.setVisitorId(visitorId);
        visitor.setProfileOwnerId(profileOwnerId);
        visitor.setVisitTimestamp(System.currentTimeMillis());
        visitor.setId(ObjectId.get());

        this.mongoTemplate.save(visitor);

        return visitor.getId().toHexString();
    }


    @Override
    public List<ProfileVisitor> fetchRecentVisitors(Long profileOwnerId) {
        Long lastQueryTime = this.redisTemplate.opsForHash()
                .get(VISITOR_INDEX_KEY, String.valueOf(profileOwnerId));

        PageRequest pageRequest = PageRequest.of(0, 5, Sort.by(Sort.Order.desc("visitTimestamp")));
        Query query = Query.query(Criteria.where("profileOwnerId").is(profileOwnerId))
                .with(pageRequest);
        if (lastQueryTime != null) {
            query.addCriteria(Criteria.where("visitTimestamp").gte(lastQueryTime));
        }

        List<ProfileVisitor> visitors = this.mongoTemplate.find(query, ProfileVisitor.class);
        
        for (ProfileVisitor visitor : visitors) {
            Query scoreQuery = Query.query(Criteria.where("targetUserId")
                    .is(profileOwnerId).and("userId").is(visitor.getVisitorId())
            );
            CompatibilityRecord record = this.mongoTemplate.findOne(scoreQuery, CompatibilityRecord.class);
            if (record != null) {
                visitor.setCompatibilityScore(record.getScore());
            } else {
                visitor.setCompatibilityScore(90.0);
            }
        }

        return visitors;
    }
}

5.1.4 Unit Tests

package com.socialapp.dubbo.server.api;

import org.junit.Test;
import org.junit.runner.RunWith;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.context.junit4.SpringRunner;

@RunWith(SpringRunner.class)
@SpringBootTest
public class VisitorTrackingApiTests {

    @Autowired
    private VisitorTrackingApi visitorTrackingApi;

    @Test
    public void testRecordVisit() {
        this.visitorTrackingApi.recordVisit(1L, 2L, "Profile Page");
        this.visitorTrackingApi.recordVisit(1L, 3L, "Profile Page");
        this.visitorTrackingApi.recordVisit(1L, 2L, "Profile Page");
    }

    @Test
    public void testFetchRecentVisitors() {
        this.visitorTrackingApi.fetchRecentVisitors(1L)
                .forEach(System.out::println);
    }
}

5.2 Application Service Layer

API ednpoint: https://mock-java.itheima.net/project/35/interface/api/743

package com.socialapp.server.vo;

import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;

@Data
@NoArgsConstructor
@AllArgsConstructor
public class VisitorSummaryDto {

    private Long userId;
    private String avatar;
    private String nickname;
    private String gender;
    private Integer age;
    private String[] tags;
    private Integer compatibilityScore;
}

5.2.2 SocialFeedController

//com.socialapp.server.controller.SocialFeedController

    /**
     * Get profile visitors
     */
    @GetMapping("visitors")
    public ResponseEntity<List<VisitorSummaryDto>> getProfileVisitors() {
        try {
            List<VisitorSummaryDto> visitors = this.feedService.getProfileVisitors();
            return ResponseEntity.ok(visitors);
        } catch (Exception e) {
            log.error("Error fetching visitors", e);
        }
        return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build();
    }

5.2.3 SocialFeedService

//com.socialapp.server.service.SocialFeedService

    public List<VisitorSummaryDto> getProfileVisitors() {
        User currentUser = UserContext.get();
        List<ProfileVisitor> visitors = 
                this.visitorTrackingApi.fetchRecentVisitors(currentUser.getId());
        
        if (CollectionUtils.isEmpty(visitors)) {
            return Collections.emptyList();
        }

        List<Object> visitorIds = visitors.stream()
                .map(ProfileVisitor::getVisitorId)
                .collect(Collectors.toList());
        List<UserProfile> profiles = 
                this.userProfileService.fetchProfilesByIds(visitorIds);

        List<VisitorSummaryDto> visitorDtos = new ArrayList<>();

        for (ProfileVisitor visitor : visitors) {
            UserProfile profile = profiles.stream()
                    .filter(p -> p.getUserId().equals(visitor.getVisitorId()))
                    .findFirst()
                    .orElse(null);

            if (profile != null) {
                VisitorSummaryDto dto = new VisitorSummaryDto();
                dto.setAge(profile.getAge());
                dto.setAvatar(profile.getAvatarUrl());
                dto.setGender(profile.getGender().name().toLowerCase());
                dto.setUserId(profile.getUserId());
                dto.setNickname(profile.getNickName());
                dto.setTags(profile.getTags().split(","));
                dto.setCompatibilityScore(visitor.getCompatibilityScore().intValue());
                visitorDtos.add(dto);
            }
        }

        return visitorDtos;
    }

5.3 Recording Visitor Data

//com.socialapp.server.service.UserProfileService

    public UserProfileSummary getUserProfile(Long userId) {
        UserProfile profileData = this.userProfileService.fetchProfileByUserId(userId);
        if (profileData == null) {
            return null;
        }

        UserProfileSummary summary = new UserProfileSummary();
        summary.setUserId(userId);
        summary.setAge(profileData.getAge());
        summary.setGender(profileData.getGender().name().toLowerCase());
        summary.setNickname(profileData.getNickName());
        summary.setTags(StrUtil.split(profileData.getTags(), ','));
        summary.setAvatar(profileData.getAvatarUrl());

        User currentUser = UserContext.get();
        summary.setCompatibilityScore(
                this.compatibilityService.getScore(userId, currentUser.getId()).longValue());

        //Record this visit
        this.visitorTrackingApi.recordVisit(userId, currentUser.getId(), "Profile Page");

        return summary;
    }

5.4 Testing

Tags: java spring-boot Dubbo mongodb Redis

Posted on Sun, 10 May 2026 23:19:01 +0000 by usefulphp