Mobile Application User Behavior Tracking and Interaction State Management
Event Tracking and Behavior Data Collection
User interaction data encompasses actions such as following authors, liking articles, marking as disliked, bookmarking content, and recording reading sessions. While these operations do not directly block core feature execution, capturing them is essential for downstream analytics and recommendation engines. The practice of embedding data collection hooks into user interactions is known as event tracking (or instrumentation), which captures specific user events—like icon taps, reading duration, or video watch time—and transmits them for processing.
This section details the implementation of follow, like, and read behavior persistence. Dislike and bookmark operations follow analogous patterns and can be derived from the provided examples.
Behavior Microservice Setup
Module Initialization
Given the high volume of interaction writes, a dedicated microservice handles all behavior-related persistence. Create a new module techpress-event-tracker with Maven dependencies mirroring the article service module, along with a corresponding Spring Boot application entry point.
Configuration
server:
port: 9005
spring:
application:
name: event-tracker-service
cloud:
nacos:
discovery:
server-addr: 192.168.200.130:8848
datasource:
driver-class-name: com.mysql.jdbc.Driver
url: jdbc:mysql://localhost:3306/techpress_behavior?useUnicode=true&characterEncoding=UTF-8&serverTimezone=UTC
username: root
password: root
mybatis-plus:
mapper-locations: classpath*:mapper/*.xml
type-aliases-package: com.techpress.model.event.pojosIncorporate shared configurations from other services: global exception handling and Jackson serialization settings.
Follow Behavior Implementation
Requirements
When a user taps the follow button on an article detail page, the interaction must be persisted. The stored data will later feed into real-time stream processing for trending content computation. Unfollowing does not generate a behavior record—only the initial follow action is captured.
Data Model
The user_follow_action table stores follow events:
@Data
@TableName("user_follow_action")
public class UserFollowAction implements Serializable {
private static final long serialVersionUID = 1L;
@TableId(value = "id", type = IdType.ID_WORKER)
private Long id;
@TableField("actor_id")
private Integer actorId;
@TableField("content_id")
private Long contentId;
@TableField("target_user_id")
private Integer targetUserId;
@TableField("action_timestamp")
private Date actionTimestamp;
}The interaction_actor table represents the behavior entity—either a registered user or an anonymous device. It carries a category field: 0 for device, 1 for user. Each follow record references an actor, establishing a one-to-many relationship.
@Data
@TableName("interaction_actor")
public class InteractionActor implements Serializable {
private static final long serialVersionUID = 1L;
@TableId(value = "id", type = IdType.AUTO)
private Integer id;
@TableField("category")
private Short category;
@TableField("reference_id")
private Integer referenceId;
@TableField("registered_at")
private Date registeredAt;
public enum Category {
USER((short)1), DEVICE((short)0);
private final short code;
Category(short code) { this.code = code; }
public short getCode() { return code; }
}
public boolean isUserType() {
return category != null && category == Category.USER.getCode();
}
}Implementation Flow
The user service publishes a Kafka message upon follow. The behavior service consumes it, resolves the interaction actor, and persists the follow record.
Step 1: Publish from User Service
Add Kafka producer configuration to the user service:
spring:
kafka:
bootstrap-servers: 192.168.200.130:9092
producer:
key-serializer: org.apache.kafka.common.serialization.StringSerializer
value-serializer: org.apache.kafka.common.serialization.StringSerializerDefine the transfer object:
@Data
public class FollowActionPayload {
private Long contentId;
private Integer targetUserId;
private Integer userId;
}Define the topic constant:
public class EventTopics {
public static final String FOLLOW_ACTION = "user.follow.action";
}Inject KafkaTemplate into the user relation service and dispatch the message:
FollowActionPayload payload = new FollowActionPayload();
payload.setTargetUserId(followId);
payload.setContentId(contentId);
payload.setUserId(currentUser.getId());
kafkaTemplate.send(EventTopics.FOLLOW_ACTION, JSON.toJSONString(payload));Step 2: Resolve Interaction Actor in Behavior Service
@Mapper
public interface InteractionActorMapper extends BaseMapper<InteractionActor> {}
public interface InteractionActorService extends IService<InteractionActor> {
InteractionActor resolveByUserOrDevice(Integer userId, Integer deviceId);
}
@Service
public class InteractionActorServiceImpl
extends ServiceImpl<InteractionActorMapper, InteractionActor>
implements InteractionActorService {
@Override
public InteractionActor resolveByUserOrDevice(Integer userId, Integer deviceId) {
if (userId != null) {
return getOne(Wrappers.<InteractionActor>lambdaQuery()
.eq(InteractionActor::getReferenceId, userId)
.eq(InteractionActor::getCategory, 1));
}
if (deviceId != null && deviceId != 0) {
return getOne(Wrappers.<InteractionActor>lambdaQuery()
.eq(InteractionActor::getReferenceId, deviceId)
.eq(InteractionActor::getCategory, 0));
}
return null;
}
}Step 3: Persist Follow Record
@Mapper
public interface UserFollowActionMapper extends BaseMapper<UserFollowAction> {}
public interface FollowActionService extends IService<UserFollowAction> {
ResponseResult recordFollow(FollowActionPayload payload);
}
@Slf4j
@Service
public class FollowActionServiceImpl
extends ServiceImpl<UserFollowActionMapper, UserFollowAction>
implements FollowActionService {
@Autowired
private InteractionActorService actorService;
@Override
public ResponseResult recordFollow(FollowActionPayload payload) {
InteractionActor actor = actorService.resolveByUserOrDevice(payload.getUserId(), null);
if (actor == null) {
return ResponseResult.errorResult(AppHttpCodeEnum.PARAM_INVALID);
}
UserFollowAction record = new UserFollowAction();
record.setActorId(actor.getId());
record.setActionTimestamp(new Date());
record.setContentId(payload.getContentId());
record.setTargetUserId(payload.getTargetUserId());
save(record);
return ResponseResult.okResult(record);
}
}Step 4: Kafka Consumer in Behavior Service
spring:
kafka:
bootstrap-servers: 192.168.200.130:9092
consumer:
group-id: ${spring.application.name}-kafka-group
key-deserializer: org.apache.kafka.common.serialization.StringDeserializer
value-deserializer: org.apache.kafka.common.serialization.StringDeserializer@Component
public class FollowActionConsumer {
@Autowired
private FollowActionService followActionService;
@KafkaListener(topics = EventTopics.FOLLOW_ACTION)
public void onFollowEvent(ConsumerRecord<?, ?> record) {
Optional<?> optional = Optional.ofNullable(record);
if (optional.isPresent()) {
FollowActionPayload payload = JSON.parseObject(
record.value().toString(), FollowActionPayload.class);
followActionService.recordFollow(payload);
}
}
}Like Behavior Implementation
Requirements
When a logged-in user taps the like button, the action is persisted. Canceling a like does not delete the row—it flips the action_state from 0 (liked) to 1 (revoked).
Data Model
@Data
@TableName("user_like_action")
public class UserLikeAction implements Serializable {
private static final long serialVersionUID = 1L;
@TableId(value = "id", type = IdType.ID_WORKER)
private Long id;
@TableField("actor_id")
private Integer actorId;
@TableField("content_id")
private Long contentId;
@TableField("content_category")
private Short contentCategory;
@TableField("action_state")
private Short actionState;
@TableField("action_timestamp")
private Date actionTimestamp;
public enum ContentCategory {
POST((short)0), FEED((short)1), REPLY((short)2);
private final short code;
ContentCategory(short code) { this.code = code; }
public short getCode() { return code; }
}
public enum ActionState {
ACTIVE((short)0), REVOKED((short)1);
private final short code;
ActionState(short code) { this.code = code; }
public short getCode() { return code; }
}
}API Definition
public interface LikeActionApi {
ResponseResult processLike(LikeActionPayload payload);
}
@Data
public class LikeActionPayload {
@IdEncrypt
private Integer deviceId;
@IdEncrypt
private Long contentId;
private Short contentCategory;
private Short actionState;
}Authentication Filter
A servlet filter extracts the userId header and stores it in a thread-local utility, enabling downstream logic to access the authenticated user:
@Order(1)
@WebFilter(filterName = "authTokenFilter", urlPatterns = "/*")
public class AuthTokenFilter extends GenericFilterBean {
@Override
public void doFilter(ServletRequest req, ServletResponse res, FilterChain chain)
throws IOException, ServletException {
HttpServletRequest request = (HttpServletRequest) req;
String userIdHeader = request.getHeader("userId");
if (userIdHeader != null && Integer.parseInt(userIdHeader) != 0) {
AppUser user = new AppUser();
user.setId(Integer.valueOf(userIdHeader));
ThreadLocalContext.setCurrentUser(user);
}
chain.doFilter(req, res);
}
}Annotate the main application class with @ServletComponentScan to activate the filter.
Service Layer
public interface LikeActionService extends IService<UserLikeAction> {
ResponseResult processLike(LikeActionPayload payload);
}
@Service
public class LikeActionServiceImpl
extends ServiceImpl<UserLikeActionMapper, UserLikeAction>
implements LikeActionService {
@Autowired
private InteractionActorService actorService;
@Override
public ResponseResult processLike(LikeActionPayload payload) {
if (payload == null || payload.getContentId() == null
|| payload.getContentCategory() < 0 || payload.getContentCategory() > 2
|| payload.getActionState() < 0 || payload.getActionState() > 1) {
return ResponseResult.errorResult(AppHttpCodeEnum.PARAM_INVALID);
}
AppUser currentUser = ThreadLocalContext.getCurrentUser();
InteractionActor actor = actorService.resolveByUserOrDevice(
currentUser.getId(), payload.getDeviceId());
if (actor == null) {
return ResponseResult.errorResult(AppHttpCodeEnum.PARAM_INVALID);
}
UserLikeAction existing = getOne(Wrappers.<UserLikeAction>lambdaQuery()
.eq(UserLikeAction::getContentId, payload.getContentId())
.eq(UserLikeAction::getActorId, actor.getId()));
if (existing == null && payload.getActionState() == 0) {
UserLikeAction newAction = new UserLikeAction();
newAction.setActionState(payload.getActionState());
newAction.setContentId(payload.getContentId());
newAction.setActorId(actor.getId());
newAction.setContentCategory(payload.getContentCategory());
newAction.setActionTimestamp(new Date());
save(newAction);
} else if (existing != null) {
existing.setActionState(payload.getActionState());
updateById(existing);
}
return ResponseResult.okResult(AppHttpCodeEnum.SUCCESS);
}
}Controller
@RestController
@RequestMapping("/api/v1/like_action")
public class LikeActionController implements LikeActionApi {
@Autowired
private LikeActionService likeActionService;
@PostMapping
@Override
public ResponseResult processLike(@RequestBody LikeActionPayload payload) {
return likeActionService.processLike(payload);
}
}Add the behavior service route in the gateway configuration:
- id: event-tracker-service
uri: lb://event-tracker-service
predicates:
- Path=/behavior/**
filters:
- StripPrefix= 1Read Behavior Implementation
Requirements
Record how many times a user reads an article, the reading duration in seconds, the scroll percentage, and optionally the page load duration. Each subsequent read increments the count field on the existing record.
Data Model
@Data
@TableName("user_read_action")
public class UserReadAction implements Serializable {
private static final long serialVersionUID = 1L;
@TableId(value = "id", type = IdType.ID_WORKER)
private Long id;
@TableField("actor_id")
private Integer actorId;
@TableField("content_id")
private Long contentId;
@TableField("read_count")
private Short readCount;
@TableField("duration_seconds")
private Integer durationSeconds;
@TableField("scroll_percentage")
private Short scrollPercentage;
@TableField("load_time")
private Short loadTime;
@TableField("created_at")
private Date createdAt;
@TableField("updated_at")
private Date updatedAt;
}API and Payload
public interface ReadActionApi {
ResponseResult recordRead(ReadActionPayload payload);
}
@Data
public class ReadActionPayload {
@IdEncrypt
private Integer deviceId;
@IdEncrypt
private Long contentId;
private Short readCount;
private Integer durationSeconds;
private Short scrollPercentage;
private Short loadTime;
}Service Layer
public interface ReadActionService extends IService<UserReadAction> {
ResponseResult recordRead(ReadActionPayload payload);
}
@Service
public class ReadActionServiceImpl
extends ServiceImpl<UserReadActionMapper, UserReadAction>
implements ReadActionService {
@Autowired
private InteractionActorService actorService;
@Override
public ResponseResult recordRead(ReadActionPayload payload) {
if (payload == null || payload.getContentId() == null) {
return ResponseResult.errorResult(AppHttpCodeEnum.PARAM_INVALID);
}
AppUser currentUser = ThreadLocalContext.getCurrentUser();
InteractionActor actor = actorService.resolveByUserOrDevice(
currentUser.getId(), payload.getDeviceId());
if (actor == null) {
return ResponseResult.errorResult(AppHttpCodeEnum.PARAM_INVALID);
}
UserReadAction existing = getOne(Wrappers.<UserReadAction>lambdaQuery()
.eq(UserReadAction::getActorId, actor.getId())
.eq(UserReadAction::getContentId, payload.getContentId()));
if (existing == null) {
UserReadAction newRecord = new UserReadAction();
newRecord.setReadCount(payload.getReadCount());
newRecord.setContentId(payload.getContentId());
newRecord.setScrollPercentage(payload.getScrollPercentage());
newRecord.setActorId(actor.getId());
newRecord.setLoadTime(payload.getLoadTime());
newRecord.setDurationSeconds(payload.getDurationSeconds());
newRecord.setCreatedAt(new Date());
save(newRecord);
} else {
existing.setUpdatedAt(new Date());
existing.setReadCount((short)(existing.getReadCount() + 1));
updateById(existing);
}
return ResponseResult.okResult(AppHttpCodeEnum.SUCCESS);
}
}Controller
@RestController
@RequestMapping("/api/v1/read_action")
public class ReadActionController implements ReadActionApi {
@Autowired
private ReadActionService readActionService;
@PostMapping
@Override
public ResponseResult recordRead(@RequestBody ReadActionPayload payload) {
return readActionService.recordRead(payload);
}
}Dislike and Bookmark Behavior Patterns
Dislike Behavior
The dislike mechanism prevents recommendation of certain content categories to the user. The user_dislike_action table tracks this with an action_state field: 0 for active dislike, 1 for canceled dislike.
@Data
@TableName("user_dislike_action")
public class UserDislikeAction implements Serializable {
private static final long serialVersionUID = 1L;
@TableId(value = "id", type = IdType.ID_WORKER)
private Long id;
@TableField("actor_id")
private Integer actorId;
@TableField("content_id")
private Long contentId;
@TableField("action_state")
private Integer actionState;
@TableField("action_timestamp")
private Date actionTimestamp;
public enum ActionState {
ACTIVE((short)0), REVOKED((short)1);
private final short code;
ActionState(short code) { this.code = code; }
public short getCode() { return code; }
}
}Implementation pattern: resolve the interaction actor, then either insert a new row or toggle action_state on the existing record. The endpoint is /api/v1/dislike_action (POST).
@Data
public class DislikeActionPayload {
@IdEncrypt
private Integer deviceId;
@IdEncrypt
private Long contentId;
private Short actionState;
}Bookmark Behavior
Bookmarks reside in the article database rather than the behavior database because users need to retrieve their saved article lists from the profile section. The content_bookmark table records entries with a content_category (post vs. feed) and a bookmark_timestamp.
@Data
@TableName("content_bookmark")
public class ContentBookmark implements Serializable {
private static final long serialVersionUID = 1L;
@TableId(value = "id", type = IdType.ID_WORKER)
private Long id;
@TableField("actor_id")
private Integer actorId;
@TableField("content_id")
private Long contentId;
@TableField("content_category")
private Short contentCategory;
@TableField("bookmark_timestamp")
private Date bookmarkTimestamp;
@TableField("publish_timestamp")
private Date publishTimestamp;
public enum ContentCategory {
POST((short)0), FEED((short)1);
private final short code;
ContentCategory(short code) { this.code = code; }
public short getCode() { return code; }
}
public boolean isPostBookmark() {
return contentCategory != null && contentCategory.equals(ContentCategory.POST.getCode());
}
}Implementation pattern: within the article microservice, fetch the interaction actor via a remote call, then persist or update the bookmark. Avoid duplicate bookmarks by checking for existing records before insertion.
@Data
public class BookmarkPayload {
@IdEncrypt
private Integer deviceId;
@IdEncrypt
private Long contentId;
private Short contentCategory;
private Short operation;
private Date publishTimestamp;
}Article Interaction State Display
Requirements
When a logged-in user opens an article detail page, the UI must reflect whether the user has followed the author, liked the article, disliked it, or bookmarked it. Each affirmative state highlights its corresponding button.
Implementation Strategy
- Resolve the interaction actor from the user ID or device ID.
- Query the like, dislike, and bookmark tables using the actor ID and article ID. Like and dislike data reside in the behavior service and require Feign calls; bookmark data is local to the article service.
- Query the author table to obtain the author's user ID, then call the user service to check for a follow relationship.
- Return a JSON map:
{"isfollow": true, "islike": true, "isunlike": false, "iscollection": true}
Remote Interface Preparation
Behavior Service Endpoints
Expose internal query endpoints in the behavior service for Feign consumption. These return domain objects directly rather than ResponseResult wrappers.
@RestController
@RequestMapping("/api/v1/actor")
public class InteractionActorController implements InteractionActorApi {
@Autowired
private InteractionActorService actorService;
@GetMapping("/resolve")
@Override
public InteractionActor resolveByUserOrDevice(
@RequestParam("userId") Integer userId,
@RequestParam("deviceId") Integer deviceId) {
return actorService.resolveByUserOrDevice(userId, deviceId);
}
}@RestController
@RequestMapping("/api/v1/like_action")
public class LikeActionController implements LikeActionApi {
@Autowired
private LikeActionService likeActionService;
@GetMapping("/query")
public UserLikeAction queryByArticleAndActor(
@RequestParam("contentId") Long contentId,
@RequestParam("actorId") Integer actorId,
@RequestParam("category") Short category) {
return likeActionService.getOne(Wrappers.<UserLikeAction>lambdaQuery()
.eq(UserLikeAction::getContentId, contentId)
.eq(UserLikeAction::getActorId, actorId)
.eq(UserLikeAction::getContentCategory, category));
}
}Dislike Endpoint
@Mapper
public interface UserDislikeActionMapper extends BaseMapper<UserDislikeAction> {}
public interface DislikeActionService extends IService<UserDislikeAction> {}
@Service
public class DislikeActionServiceImpl
extends ServiceImpl<UserDislikeActionMapper, UserDislikeAction>
implements DislikeActionService {}
@RestController
@RequestMapping("/api/v1/dislike_action")
public class DislikeActionController implements DislikeActionApi {
@Autowired
private DislikeActionService dislikeActionService;
@GetMapping("/query")
@Override
public UserDislikeAction queryByArticleAndActor(
@RequestParam("actorId") Integer actorId,
@RequestParam("contentId") Long contentId) {
return dislikeActionService.getOne(Wrappers.<UserDislikeAction>lambdaQuery()
.eq(UserDislikeAction::getContentId, contentId)
.eq(UserDislikeAction::getActorId, actorId));
}
}User Service: Follow Check
@Mapper
public interface UserFollowMapper extends BaseMapper<UserFollowRecord> {}
public interface UserFollowService extends IService<UserFollowRecord> {}
@Service
public class UserFollowServiceImpl
extends ServiceImpl<UserFollowMapper, UserFollowRecord>
implements UserFollowService {}
@RestController
@RequestMapping("/api/v1/user_follow")
public class UserFollowController implements UserFollowApi {
@Autowired
private UserFollowService followService;
@GetMapping("/check")
@Override
public UserFollowRecord checkFollowRelation(
@RequestParam("userId") Integer userId,
@RequestParam("authorUserId") Integer authorUserId) {
return followService.getOne(Wrappers.<UserFollowRecord>lambdaQuery()
.eq(UserFollowRecord::getUserId, userId)
.eq(UserFollowRecord::getFollowId, authorUserId));
}
}Article Service: Feign Clients
Add the OpenFeign dependency to the article service:
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-openfeign</artifactId>
</dependency>@FeignClient("event-tracker-service")
public interface BehaviorClient {
@GetMapping("/api/v1/actor/resolve")
InteractionActor resolveActor(@RequestParam("userId") Integer userId,
@RequestParam("deviceId") Integer deviceId);
@GetMapping("/api/v1/dislike_action/query")
UserDislikeAction queryDislike(@RequestParam("actorId") Integer actorId,
@RequestParam("contentId") Long contentId);
@GetMapping("/api/v1/like_action/query")
UserLikeAction queryLike(@RequestParam("actorId") Integer actorId,
@RequestParam("contentId") Long contentId,
@RequestParam("category") short category);
}
@FeignClient("user-service")
public interface UserClient {
@GetMapping("/api/v1/user_follow/check")
UserFollowRecord checkFollow(@RequestParam("userId") Integer userId,
@RequestParam("authorUserId") Integer authorUserId);
}Annotate the article application class with @EnableFeignClients.
Article Interaction State Endpoint
API Definition
public interface ArticleDetailApi {
ResponseResult loadInteractionState(ArticleDetailPayload payload);
}
@Data
public class ArticleDetailPayload {
@IdEncrypt
private Integer deviceId;
@IdEncrypt
private Long contentId;
@IdEncrypt
private Integer authorId;
}Bookmark Mapper (local to article service)
@Mapper
public interface ContentBookmarkMapper extends BaseMapper<ContentBookmark> {}
Auth Filter in Article Service
@Order(1)
@WebFilter(filterName = "authTokenFilter", urlPatterns = "/*")
public class AuthTokenFilter extends GenericFilterBean {
Logger logger = LoggerFactory.getLogger(AuthTokenFilter.class);
@Override
public void doFilter(ServletRequest req, ServletResponse res, FilterChain chain)
throws IOException, ServletException {
HttpServletRequest request = (HttpServletRequest) req;
String userIdHeader = request.getHeader("userId");
if (userIdHeader != null && Integer.parseInt(userIdHeader) != 0) {
AppUser user = new AppUser();
user.setId(Long.valueOf(userIdHeader));
ThreadLocalContext.setCurrentUser(user);
}
chain.doFilter(req, res);
}
}Enable the filter by adding @ServletComponentScan to the application class.
Service Implementation
@Autowired
private BehaviorClient behaviorClient;
@Autowired
private ContentBookmarkMapper bookmarkMapper;
@Autowired
private UserClient userClient;
@Autowired
private AuthorMapper authorMapper;
@Override
public ResponseResult loadInteractionState(ArticleDetailPayload payload) {
if (payload == null || payload.getContentId() == null) {
return ResponseResult.errorResult(AppHttpCodeEnum.PARAM_INVALID);
}
AppUser currentUser = ThreadLocalContext.getCurrentUser();
InteractionActor actor = behaviorClient.resolveActor(currentUser.getId(), payload.getDeviceId());
if (actor == null) {
return ResponseResult.errorResult(AppHttpCodeEnum.PARAM_INVALID);
}
boolean isFollow = false, isLike = false, isDislike = false, isBookmark = false;
UserDislikeAction dislike = behaviorClient.queryDislike(actor.getId(), payload.getContentId());
if (dislike != null && dislike.getActionState() == UserDislikeAction.ActionState.ACTIVE.getCode()) {
isDislike = true;
}
UserLikeAction like = behaviorClient.queryLike(actor.getId(), payload.getContentId(), UserLikeAction.ContentCategory.POST.getCode());
if (like != null && like.getActionState() == UserLikeAction.ActionState.ACTIVE.getCode()) {
isLike = true;
}
ContentBookmark bookmark = bookmarkMapper.selectOne(Wrappers.<ContentBookmark>lambdaQuery()
.eq(ContentBookmark::getActorId, actor.getId())
.eq(ContentBookmark::getContentId, payload.getContentId())
.eq(ContentBookmark::getContentCategory, ContentBookmark.ContentCategory.POST.getCode()));
if (bookmark != null) {
isBookmark = true;
}
Author author = authorMapper.selectById(payload.getAuthorId());
if (author != null) {
UserFollowRecord followRecord = userClient.checkFollow(currentUser.getId(), author.getUserId());
if (followRecord != null) {
isFollow = true;
}
}
Map<String, Object> stateMap = new HashMap<>();
stateMap.put("isfollow", isFollow);
stateMap.put("islike", isLike);
stateMap.put("isunlike", isDislike);
stateMap.put("iscollection", isBookmark);
return ResponseResult.okResult(stateMap);
}Controller
@PostMapping("/load_article_behavior")
@Override
public ResponseResult loadInteractionState(@RequestBody ArticleDetailPayload payload) {
return articleDetailService.loadInteractionState(payload);
}