Przeglądaj źródła

打卡的点赞 也做好了 还有查询详细信息的点赞三件套反馈

lutong 4 dni temu
rodzic
commit
96469711fe

+ 9 - 2
alien-entity/src/main/java/shop/alien/entity/store/dto/LifeClockInFeedApiResponse.java

@@ -1,5 +1,7 @@
 package shop.alien.entity.store.dto;
 
+import com.fasterxml.jackson.databind.PropertyNamingStrategies;
+import com.fasterxml.jackson.databind.annotation.JsonNaming;
 import io.swagger.annotations.ApiModel;
 import io.swagger.annotations.ApiModelProperty;
 import lombok.Data;
@@ -8,13 +10,18 @@ import lombok.NoArgsConstructor;
 import java.util.List;
 
 /**
- * 打卡推荐 feeds 分页响应(camelCase JSON;可与 Redis / HTTP 反序列化)。
+ * 打卡推荐 feeds 分页响应(与 Redis / 动态推荐一致采用 snake_case JSON)。
  * <p>
- * {@code blockedId}:当前用户已拉黑商户的 {@code store_user.id}(与用户拉黑商户场景一致)。
+ * Redis Key:{@code clock_in:recommend:v15:{lifeUserId}},{@code lifeUserId} 为 {@code life_user.id};
+ * 拉黑 / 点赞 / 商户维度关注等增量补丁见 alien-store {@code ClockInRecommendCacheService}(与动态推荐缓存并行维护)。
+ * </p>
+ * <p>
+ * {@code blocked_id}:当前用户已拉黑商户的 {@code store_user.id}(仅用户拉黑商户场景写入缓存)。
  * </p>
  */
 @Data
 @NoArgsConstructor
+@JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class)
 @ApiModel(description = "打卡推荐 feeds 分页响应")
 public class LifeClockInFeedApiResponse {
 

+ 15 - 8
alien-entity/src/main/java/shop/alien/entity/store/dto/LifeClockInFeedItemDto.java

@@ -1,5 +1,7 @@
 package shop.alien.entity.store.dto;
 
+import com.fasterxml.jackson.databind.PropertyNamingStrategies;
+import com.fasterxml.jackson.databind.annotation.JsonNaming;
 import io.swagger.annotations.ApiModel;
 import io.swagger.annotations.ApiModelProperty;
 import lombok.Data;
@@ -8,10 +10,15 @@ import lombok.NoArgsConstructor;
 import java.util.List;
 
 /**
- * 打卡推荐 feeds 列表单项(与下游 camelCase JSON 对齐,{@code type}/{@code dynamicsType} 固定为 5)。
+ * 打卡推荐 feeds 列表单项(与 Redis snake_case JSON 对齐;{@code type}/{@code dynamics_type} 固定为 5)。
+ * <p>
+ * 商户维度:{@code is_follow_me}/{@code is_follow_this};打卡发布用户维度:{@code is_publisher_follow_me}/{@code is_follow_publisher};
+ * {@code fans_count} 与关联门店 {@code phone_id} 侧粉丝数对应;{@code is_like}/{@code dianzan_count} 为当前用户对「本条打卡」的点赞状态与总赞数。
+ * </p>
  */
 @Data
 @NoArgsConstructor
+@JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class)
 @ApiModel(description = "打卡推荐 feeds 列表项")
 public class LifeClockInFeedItemDto {
 
@@ -42,7 +49,7 @@ public class LifeClockInFeedItemDto {
     @ApiModelProperty("浏览数 store_clock_in.view_count")
     private Integer liulanCount;
 
-    @ApiModelProperty("点赞总数 store_clock_in.like_count")
+    @ApiModelProperty(value = "点赞总数(int)。本条打卡累计点赞数,对应 store_clock_in.like_count(JSON 亦可映射为 dianzan_count)", example = "0")
     private Integer dianzanCount;
 
     @ApiModelProperty("打卡类型固定 5")
@@ -114,19 +121,19 @@ public class LifeClockInFeedItemDto {
     @ApiModelProperty("打卡人头像 life_user.user_image")
     private String userImage;
 
-    @ApiModelProperty(value = "门店商家是否关注我 0/1(life_fans,store_ phone_id)")
+    @ApiModelProperty(value = "商户维度:门店商家是否关注我。0/1(life_fans,store_ phone_id;等价 snake_case: is_follow_me)")
     private String isFollowMe;
 
-    @ApiModelProperty(value = "我是否关注门店商家 0/1(life_fans,store_ phone_id)")
+    @ApiModelProperty(value = "商户维度:我是否关注门店商家。0/1(life_fans,store_ phone_id;等价 snake_case: is_follow_this)")
     private String isFollowThis;
 
-    @ApiModelProperty(value = "打卡发布用户是否关注我 0/1(life_fans fans_type=1,user_ phone_id)")
+    @ApiModelProperty(value = "打卡独有:打卡发布用户是否关注我。0/1(life_fans fans_type=1,user_ phone_id;snake_case: is_publisher_follow_me)")
     private String isPublisherFollowMe;
 
-    @ApiModelProperty(value = "我是否关注打卡发布用户 0/1(life_fans fans_type=1,user_ phone_id)")
+    @ApiModelProperty(value = "打卡独有:我是否关注打卡发布用户。0/1(life_fans fans_type=1,user_ phone_id;snake_case: is_follow_publisher)")
     private String isFollowPublisher;
 
-    @ApiModelProperty(value = "我是否点赞该打卡 0/1(life_like_record type=5)")
+    @ApiModelProperty(value = "当前用户是否点赞本条打卡。0/1(life_like_record type=5;snake_case: is_like)")
     private String isLike;
 
     @ApiModelProperty("评论数")
@@ -138,7 +145,7 @@ public class LifeClockInFeedItemDto {
     @ApiModelProperty("0/1 是否达人")
     private String isExpert;
 
-    @ApiModelProperty("门店粉丝数")
+    @ApiModelProperty(value = "对方粉丝数(int,可为 null)。动态+打卡通用语义:与关联门店 phone_id(商户维度)对应的粉丝量(snake_case: fans_count)")
     private Integer fansCount;
 
     @ApiModelProperty("未使用")

+ 9 - 5
alien-entity/src/main/java/shop/alien/entity/store/dto/LifeDynamicsFeedItemDto.java

@@ -11,6 +11,10 @@ import java.util.List;
 
 /**
  * 动态列表单项(与下游 snake_case JSON 字段对齐)。
+ * <p>
+ * 互动字段:{@code is_like}/{@code dianzan_count};关系字段(商户维度):{@code is_follow_me}/{@code is_follow_this};
+ * {@code fans_count} 为关联门店 {@code phone_id} 侧粉丝数。
+ * </p>
  */
 @Data
 @NoArgsConstructor
@@ -54,7 +58,7 @@ public class LifeDynamicsFeedItemDto {
     @ApiModelProperty("浏览数")
     private Integer liulanCount;
 
-    @ApiModelProperty("点赞总数(整数,表示该动态累计被点赞次数)")
+    @ApiModelProperty(value = "点赞总数(int)。累计点赞条数,来源于动态表 life_user_dynamics.dianzan_count", example = "0")
     private Integer dianzanCount;
 
     @ApiModelProperty("评论数")
@@ -66,7 +70,7 @@ public class LifeDynamicsFeedItemDto {
     @ApiModelProperty("实际浏览")
     private Integer realityCount;
 
-    @ApiModelProperty("粉丝数")
+    @ApiModelProperty(value = "对方粉丝数(int,可为 null)。与关联门店 phone_id(商户维度)对应的粉丝量")
     private Integer fansCount;
 
     @ApiModelProperty("1 动态 2 商家社区")
@@ -123,13 +127,13 @@ public class LifeDynamicsFeedItemDto {
     @ApiModelProperty("未实现时可恒为 null")
     private String isBlack;
 
-    @ApiModelProperty(value = "商户是否关注当前用户:0-商户没有关注我;1-商户已经关注我")
+    @ApiModelProperty(value = "商户维度:对方(门店侧)是否关注我。0-未关注;1-已关注(JSON: is_follow_me)")
     private String isFollowMe;
 
-    @ApiModelProperty(value = "当前用户是否关注该商户:0-我没有关注该商户;1-我已经关注该商户")
+    @ApiModelProperty(value = "商户维度:我是否关注对方(门店商家)。0-未关注;1-已关注(JSON: is_follow_this)")
     private String isFollowThis;
 
-    @ApiModelProperty(value = "当前用户是否点赞该动态:0-我没有点赞该动态;1-我已经点赞该动态")
+    @ApiModelProperty(value = "当前用户是否点赞本条动态。0-未点赞;1-已点赞(JSON: is_like)")
     private String isLike;
 
     @ApiModelProperty("store_info.id")

+ 15 - 0
alien-store/src/main/java/shop/alien/store/service/LifeCommentService.java

@@ -19,6 +19,7 @@ import shop.alien.entity.store.*;
 import shop.alien.entity.store.vo.LifePinglunVo;
 import shop.alien.mapper.*;
 import shop.alien.mapper.second.SecondGoodsMapper;
+import shop.alien.store.service.clockin.ClockInRecommendCacheService;
 import shop.alien.store.service.dynamics.DynamicsRecommendCacheService;
 
 import java.time.LocalDate;
@@ -78,6 +79,8 @@ public class LifeCommentService {
 
     private final DynamicsRecommendCacheService dynamicsRecommendCacheService;
 
+    private final ClockInRecommendCacheService clockInRecommendCacheService;
+
     /**
      * 系统app通知开关
      */
@@ -142,12 +145,18 @@ public class LifeCommentService {
             
             // 根据类型更新对应表的点赞数
             int updateResult = updateLikeCountByType(huifuId, type);
+            // DB 已成功更新点赞计数后,仅尽力同步推荐 Redis(动态/打卡);异常已捕获,不改变返回值及后续通知逻辑
             if (updateResult > 0) {
                 try {
                     dynamicsRecommendCacheService.trySyncLikeCache(userId, huifuId, type, true, 1);
                 } catch (Exception e) {
                     log.error("推荐动态缓存同步失败(点赞),不影响主流程 userId={} huifuId={} type={}", userId, huifuId, type, e);
                 }
+                try {
+                    clockInRecommendCacheService.trySyncLikeCache(userId, huifuId, type, true, 1);
+                } catch (Exception e) {
+                    log.error("推荐打卡缓存同步失败(点赞),不影响主流程 userId={} huifuId={} type={}", userId, huifuId, type, e);
+                }
             }
 
             // 动态点赞:按「被点赞动态的发布者」的个性化设置决定是否通知(接收方是否愿意收点赞类通知)
@@ -423,12 +432,18 @@ public class LifeCommentService {
             
             // 根据类型更新对应表的点赞数
             int updateResult = decreaseLikeCountByType(huifuId, type);
+            // DB 已成功扣减点赞计数后,仅尽力同步推荐 Redis;异常已捕获,不改变返回值
             if (updateResult > 0) {
                 try {
                     dynamicsRecommendCacheService.trySyncLikeCache(userId, huifuId, type, false, -1);
                 } catch (Exception e) {
                     log.error("推荐动态缓存同步失败(取消点赞),不影响主流程 userId={} huifuId={} type={}", userId, huifuId, type, e);
                 }
+                try {
+                    clockInRecommendCacheService.trySyncLikeCache(userId, huifuId, type, false, -1);
+                } catch (Exception e) {
+                    log.error("推荐打卡缓存同步失败(取消点赞),不影响主流程 userId={} huifuId={} type={}", userId, huifuId, type, e);
+                }
             }
 
             log.info("取消点赞操作完成,userId={},huifuId={},type={},更新结果={}", userId, huifuId, type, updateResult);

+ 19 - 5
alien-store/src/main/java/shop/alien/store/service/LifeUserService.java

@@ -35,6 +35,7 @@ import shop.alien.mapper.second.SecondRiskControlRecordMapper;
 import shop.alien.mapper.second.SecondUserCreditMapper;
 import shop.alien.store.config.BaseRedisService;
 import shop.alien.store.config.WebSocketProcess;
+import shop.alien.store.service.clockin.ClockInRecommendCacheService;
 import shop.alien.store.service.dynamics.DynamicsRecommendCacheService;
 import shop.alien.store.feign.SecondServiceFeign;
 import shop.alien.store.util.FunctionMagic;
@@ -98,6 +99,8 @@ public class LifeUserService extends ServiceImpl<LifeUserMapper, LifeUser> {
 
     private final DynamicsRecommendCacheService dynamicsRecommendCacheService;
 
+    private final ClockInRecommendCacheService clockInRecommendCacheService;
+
     private final JdbcTemplate jdbcTemplate;
 
     @Autowired
@@ -156,14 +159,14 @@ public class LifeUserService extends ServiceImpl<LifeUserMapper, LifeUser> {
             int revived = reviveDeletedFollowRow(fans.getFollowedId(), fans.getFansId());
             if (revived > 0) {
                 sendFollowNoticeIfAllowed(fans);
-                notifyDynamicsRecommendFeedFollow(fans, true);
+                notifyRecommendFeedCachesFollow(fans, true);
                 return LifeFansFollowOutcome.success("关注成功");
             }
 
             int num = lifeFansMapper.insert(fans);
             if (num == 1) {
                 sendFollowNoticeIfAllowed(fans);
-                notifyDynamicsRecommendFeedFollow(fans, true);
+                notifyRecommendFeedCachesFollow(fans, true);
                 return LifeFansFollowOutcome.success("关注成功");
             }
             return LifeFansFollowOutcome.failure("关注失败:数据未保存成功,请稍后重试");
@@ -255,8 +258,11 @@ public class LifeUserService extends ServiceImpl<LifeUserMapper, LifeUser> {
                 fansId);
     }
 
-    /** DB 已成功变更关注关系后,尽力同步推荐动态 Redis(失败不影响主流程)。 */
-    private void notifyDynamicsRecommendFeedFollow(LifeFans fans, boolean followed) {
+    /**
+     * 仅在数据库关注关系已成功写入/删除之后调用:增量同步推荐 Redis(动态 + 打卡)。
+     * 与业务返回值、事务无关;任一缓存同步失败仅打日志,不反向影响 DB。
+     */
+    private void notifyRecommendFeedCachesFollow(LifeFans fans, boolean followed) {
         try {
             dynamicsRecommendCacheService.onFollowRelationChanged(fans, followed);
         } catch (Exception e) {
@@ -265,6 +271,14 @@ public class LifeUserService extends ServiceImpl<LifeUserMapper, LifeUser> {
                     fans != null ? fans.getFollowedId() : null,
                     e);
         }
+        try {
+            clockInRecommendCacheService.onFollowRelationChanged(fans, followed);
+        } catch (Exception e) {
+            log.error("推荐打卡缓存同步失败(关注),不影响主流程 fansId={} followedId={}",
+                    fans != null ? fans.getFansId() : null,
+                    fans != null ? fans.getFollowedId() : null,
+                    e);
+        }
     }
 
     private void sendFollowNoticeIfAllowed(LifeFans fans) {
@@ -390,7 +404,7 @@ public class LifeUserService extends ServiceImpl<LifeUserMapper, LifeUser> {
             if (n <= 0) {
                 return LifeFansFollowOutcome.success("当前未关注或已取消,无需重复操作");
             }
-            notifyDynamicsRecommendFeedFollow(fans, false);
+            notifyRecommendFeedCachesFollow(fans, false);
             return LifeFansFollowOutcome.success("取消关注成功");
         } catch (Exception ex) {
             log.error("cancelFans 失败 followedId={} fansId={}", fans.getFollowedId(), fans.getFansId(), ex);

+ 21 - 0
alien-store/src/main/java/shop/alien/store/service/clockin/ClockInRecommendCacheConstants.java

@@ -0,0 +1,21 @@
+package shop.alien.store.service.clockin;
+
+import lombok.AccessLevel;
+import lombok.NoArgsConstructor;
+
+/**
+ * 打卡推荐 feeds 在 Redis 中的约定(与 {@link shop.alien.entity.store.dto.LifeClockInFeedApiResponse} 对应,snake_case JSON)。
+ * <p>
+ * Key:{@code clock_in:recommend:v15:{lifeUserId}},{@code lifeUserId} 为 C 端 {@code life_user.id},
+ * 语义对齐动态推荐的 {@code dynamics:recommend:v15:{lifeUserId}}。
+ * </p>
+ */
+@NoArgsConstructor(access = AccessLevel.PRIVATE)
+public final class ClockInRecommendCacheConstants {
+
+    public static final String REDIS_KEY_PREFIX = "clock_in:recommend:v15:";
+
+    public static String userFeedKey(int lifeUserId) {
+        return REDIS_KEY_PREFIX + lifeUserId;
+    }
+}

+ 246 - 0
alien-store/src/main/java/shop/alien/store/service/clockin/ClockInRecommendCacheService.java

@@ -0,0 +1,246 @@
+package shop.alien.store.service.clockin;
+
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.fasterxml.jackson.databind.DeserializationFeature;
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.data.redis.core.StringRedisTemplate;
+import org.springframework.stereotype.Service;
+import org.springframework.util.CollectionUtils;
+import org.springframework.util.StringUtils;
+import shop.alien.entity.store.LifeFans;
+import shop.alien.entity.store.LifeUser;
+import shop.alien.entity.store.dto.LifeClockInFeedApiResponse;
+import shop.alien.entity.store.dto.LifeClockInFeedItemDto;
+import shop.alien.mapper.LifeUserMapper;
+import shop.alien.store.config.BaseRedisService;
+import shop.alien.store.util.CommonConstant;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.concurrent.TimeUnit;
+import java.util.function.Consumer;
+
+/**
+ * 维护「推荐打卡」Redis 缓存与用户互动状态(拉黑、点赞、商户维度关注),行为对齐 {@link shop.alien.store.service.dynamics.DynamicsRecommendCacheService}。
+ * <p>
+ * 不替代数据库:须在 <strong>DB 已成功变更之后</strong> 由业务侧触发;缓存同步失败只记日志,不改变接口结果。
+ * </p>
+ */
+@Slf4j
+@Service
+public class ClockInRecommendCacheService {
+
+    private static final String LOCK_KEY_PREFIX = "clockInRecommendMutate:";
+
+    private final StringRedisTemplate stringRedisTemplate;
+    private final BaseRedisService baseRedisService;
+    private final LifeUserMapper lifeUserMapper;
+    private final ObjectMapper clockInFeedObjectMapper;
+
+    public ClockInRecommendCacheService(StringRedisTemplate stringRedisTemplate,
+                                        BaseRedisService baseRedisService,
+                                        LifeUserMapper lifeUserMapper,
+                                        ObjectMapper objectMapper) {
+        this.stringRedisTemplate = stringRedisTemplate;
+        this.baseRedisService = baseRedisService;
+        this.lifeUserMapper = lifeUserMapper;
+        this.clockInFeedObjectMapper = objectMapper.copy();
+        this.clockInFeedObjectMapper.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false);
+    }
+
+    public void onClockInLikeChanged(String likerUserIdParam, int clockInId, boolean nowLiked, int dianzanDelta) {
+        Integer viewerId = resolveLifeUserId(likerUserIdParam);
+        if (viewerId == null) {
+            return;
+        }
+        mutateFeed(viewerId, feed -> {
+            if (CollectionUtils.isEmpty(feed.getList())) {
+                return;
+            }
+            for (LifeClockInFeedItemDto item : feed.getList()) {
+                if (item.getId() != null && item.getId() == clockInId) {
+                    item.setIsLike(nowLiked ? "1" : "0");
+                    int base = item.getDianzanCount() == null ? 0 : item.getDianzanCount();
+                    item.setDianzanCount(Math.max(0, base + dianzanDelta));
+                    break;
+                }
+            }
+        });
+    }
+
+    /**
+     * 商户维度关注关系:刷新列表项中与门店 {@code phoneId} 匹配的 {@code isFollowThis}/{@code isFollowMe}。
+     * (发布用户维度 {@code isPublisherFollowMe}/{@code isFollowPublisher} 暂不据此增量维护,与动态缓存范围一致。)
+     */
+    public void onFollowRelationChanged(LifeFans fans, boolean followed) {
+        if (fans == null || !StringUtils.hasText(fans.getFansId()) || !StringUtils.hasText(fans.getFollowedId())) {
+            return;
+        }
+        String fansId = fans.getFansId().trim();
+        String followedId = fans.getFollowedId().trim();
+
+        if (fansId.startsWith("user_") && followedId.startsWith("store_")) {
+            Integer viewerId = resolveLifeUserIdFromFansPhoneId(fansId);
+            if (viewerId != null) {
+                patchIsFollowThisByIssuerPhoneId(viewerId, followedId, followed);
+            }
+            return;
+        }
+        if (fansId.startsWith("store_") && followedId.startsWith("user_")) {
+            Integer viewerId = resolveLifeUserIdFromFansPhoneId(followedId);
+            if (viewerId != null) {
+                patchIsFollowMeByIssuerPhoneId(viewerId, fansId, followed);
+            }
+            return;
+        }
+        if (fansId.startsWith("user_") && followedId.startsWith("user_")) {
+            Integer viewerId = resolveLifeUserIdFromFansPhoneId(fansId);
+            if (viewerId != null) {
+                patchIsFollowThisByIssuerPhoneId(viewerId, followedId, followed);
+            }
+        }
+    }
+
+    public void addBlockedStore(int viewerLifeUserId, int storeUserId) {
+        mutateFeed(viewerLifeUserId, feed -> {
+            List<Integer> blocked = feed.getBlockedId();
+            if (blocked == null) {
+                blocked = new ArrayList<>();
+            } else {
+                blocked = new ArrayList<>(blocked);
+            }
+            if (!blocked.contains(storeUserId)) {
+                blocked.add(storeUserId);
+            }
+            feed.setBlockedId(blocked);
+        });
+    }
+
+    public void removeBlockedStore(int viewerLifeUserId, int storeUserId) {
+        mutateFeed(viewerLifeUserId, feed -> {
+            List<Integer> blocked = feed.getBlockedId();
+            if (CollectionUtils.isEmpty(blocked)) {
+                return;
+            }
+            List<Integer> next = new ArrayList<>(blocked.size());
+            for (Integer id : blocked) {
+                if (id != null && id != storeUserId) {
+                    next.add(id);
+                }
+            }
+            feed.setBlockedId(next);
+        });
+    }
+
+    private void patchIsFollowThisByIssuerPhoneId(int viewerLifeUserId, String issuerPhoneId, boolean followed) {
+        mutateFeed(viewerLifeUserId, feed -> {
+            if (CollectionUtils.isEmpty(feed.getList())) {
+                return;
+            }
+            String flag = followed ? "1" : "0";
+            for (LifeClockInFeedItemDto item : feed.getList()) {
+                if (issuerPhoneId.equals(item.getPhoneId())) {
+                    item.setIsFollowThis(flag);
+                }
+            }
+        });
+    }
+
+    private void patchIsFollowMeByIssuerPhoneId(int viewerLifeUserId, String issuerPhoneId, boolean followed) {
+        mutateFeed(viewerLifeUserId, feed -> {
+            if (CollectionUtils.isEmpty(feed.getList())) {
+                return;
+            }
+            String flag = followed ? "1" : "0";
+            for (LifeClockInFeedItemDto item : feed.getList()) {
+                if (issuerPhoneId.equals(item.getPhoneId())) {
+                    item.setIsFollowMe(flag);
+                }
+            }
+        });
+    }
+
+    private void mutateFeed(int viewerLifeUserId, Consumer<LifeClockInFeedApiResponse> mutator) {
+        String key = ClockInRecommendCacheConstants.userFeedKey(viewerLifeUserId);
+        String lockToken = baseRedisService.lock(LOCK_KEY_PREFIX + viewerLifeUserId, 8_000L, 5_000L);
+        if (lockToken == null) {
+            log.warn("推荐打卡缓存未获取到锁,跳过更新 viewerLifeUserId={}", viewerLifeUserId);
+            return;
+        }
+        try {
+            String json = stringRedisTemplate.opsForValue().get(key);
+            if (!StringUtils.hasText(json)) {
+                return;
+            }
+            Long ttlSeconds = stringRedisTemplate.getExpire(key, TimeUnit.SECONDS);
+            JsonNode rootNode = clockInFeedObjectMapper.readTree(json);
+            JsonNode listNode = rootNode.get("list");
+            int rawListLen = listNode != null && listNode.isArray() ? listNode.size() : 0;
+
+            LifeClockInFeedApiResponse feed = clockInFeedObjectMapper.readValue(json, LifeClockInFeedApiResponse.class);
+            int pojoListLen = feed.getList() == null ? 0 : feed.getList().size();
+            if (rawListLen > 0 && pojoListLen == 0) {
+                log.warn("推荐打卡缓存:原始 JSON list 有 {} 条但反序列化为空,跳过写回以免清空 Redis viewerLifeUserId={}",
+                        rawListLen, viewerLifeUserId);
+                return;
+            }
+            if (rawListLen > pojoListLen) {
+                log.warn("推荐打卡缓存:list 反序列化不完整 raw={} pojo={},跳过写回 viewerLifeUserId={}",
+                        rawListLen, pojoListLen, viewerLifeUserId);
+                return;
+            }
+
+            mutator.accept(feed);
+            String out = clockInFeedObjectMapper.writeValueAsString(feed);
+            stringRedisTemplate.opsForValue().set(key, out);
+            if (ttlSeconds != null && ttlSeconds > 0) {
+                stringRedisTemplate.expire(key, ttlSeconds, TimeUnit.SECONDS);
+            }
+        } catch (Exception e) {
+            log.warn("推荐打卡缓存读写失败 viewerLifeUserId={}", viewerLifeUserId, e);
+        } finally {
+            baseRedisService.unlock(LOCK_KEY_PREFIX + viewerLifeUserId, lockToken);
+        }
+    }
+
+    private Integer resolveLifeUserId(String userIdParam) {
+        if (!StringUtils.hasText(userIdParam)) {
+            return null;
+        }
+        String t = userIdParam.trim();
+        if (t.matches("^\\d+$")) {
+            try {
+                return Integer.valueOf(t);
+            } catch (NumberFormatException ignored) {
+                return null;
+            }
+        }
+        return resolveLifeUserIdFromFansPhoneId(t.startsWith("user_") ? t : "user_" + t);
+    }
+
+    private Integer resolveLifeUserIdFromFansPhoneId(String fansPhoneId) {
+        if (!StringUtils.hasText(fansPhoneId) || !fansPhoneId.startsWith("user_")) {
+            return null;
+        }
+        String phone = fansPhoneId.substring("user_".length());
+        if (!StringUtils.hasText(phone)) {
+            return null;
+        }
+        LifeUser u = lifeUserMapper.selectOne(new LambdaQueryWrapper<LifeUser>()
+                .eq(LifeUser::getUserPhone, phone)
+                .eq(LifeUser::getDeleteFlag, 0)
+                .last("LIMIT 1"));
+        return u != null ? u.getId() : null;
+    }
+
+    /** 打卡点赞 type=5 时由 {@link shop.alien.store.service.LifeCommentService} 调用。 */
+    public void trySyncLikeCache(String userId, String huifuId, String type, boolean liked, int dianzanDelta) {
+        if (!CommonConstant.LIKE_TYPE_CLOCK_IN.equals(type)) {
+            return;
+        }
+        int clockInId = Integer.parseInt(huifuId.trim());
+        onClockInLikeChanged(userId, clockInId, liked, dianzanDelta);
+    }
+}

+ 21 - 0
alien-store/src/main/java/shop/alien/store/service/dynamics/DynamicsRecommendCacheService.java

@@ -2,6 +2,7 @@ package shop.alien.store.service.dynamics;
 
 import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
 import com.fasterxml.jackson.databind.DeserializationFeature;
+import com.fasterxml.jackson.databind.JsonNode;
 import com.fasterxml.jackson.databind.ObjectMapper;
 import lombok.extern.slf4j.Slf4j;
 import org.springframework.data.redis.core.StringRedisTemplate;
@@ -24,6 +25,10 @@ import java.util.function.Consumer;
 /**
  * 维护「推荐动态」Redis 缓存与用户互动状态的一致性。
  * <p>
+ * 不替代数据库:由业务在 <strong>DB 已成功提交相关变更之后</strong> 调用本服务做增量补丁;
+ * 缓存缺失或同步失败不影响主流程返回值。
+ * </p>
+ * <p>
  * 仅在对应用户存在缓存 Key 时改写;无缓存则跳过(用户尚未拉过推荐或已过期)。
  * 写操作使用分布式锁 + 保留 TTL,降低并发下覆盖丢失的概率。
  * </p>
@@ -185,7 +190,23 @@ public class DynamicsRecommendCacheService {
                 return;
             }
             Long ttlSeconds = stringRedisTemplate.getExpire(key, TimeUnit.SECONDS);
+            JsonNode rootNode = dynamicsFeedObjectMapper.readTree(json);
+            JsonNode listNode = rootNode.get("list");
+            int rawListLen = listNode != null && listNode.isArray() ? listNode.size() : 0;
+
             LifeDynamicsFeedApiResponse feed = dynamicsFeedObjectMapper.readValue(json, LifeDynamicsFeedApiResponse.class);
+            int pojoListLen = feed.getList() == null ? 0 : feed.getList().size();
+            if (rawListLen > 0 && pojoListLen == 0) {
+                log.warn("推荐动态缓存:原始 JSON list 有 {} 条但反序列化为空,跳过写回以免清空 Redis viewerLifeUserId={}",
+                        rawListLen, viewerLifeUserId);
+                return;
+            }
+            if (rawListLen > pojoListLen) {
+                log.warn("推荐动态缓存:list 反序列化不完整 raw={} pojo={},跳过写回 viewerLifeUserId={}",
+                        rawListLen, pojoListLen, viewerLifeUserId);
+                return;
+            }
+
             mutator.accept(feed);
             String out = dynamicsFeedObjectMapper.writeValueAsString(feed);
             stringRedisTemplate.opsForValue().set(key, out);

+ 13 - 6
alien-store/src/main/java/shop/alien/store/service/impl/LifeBlacklistServiceImpl.java

@@ -12,6 +12,7 @@ import shop.alien.entity.store.vo.LifeBlacklistVo;
 import shop.alien.mapper.*;
 import shop.alien.store.config.BaseRedisService;
 import shop.alien.store.service.LifeBlacklistService;
+import shop.alien.store.service.clockin.ClockInRecommendCacheService;
 import shop.alien.store.service.dynamics.DynamicsRecommendCacheService;
 import shop.alien.util.common.JwtUtil;
 
@@ -43,6 +44,8 @@ public class LifeBlacklistServiceImpl extends ServiceImpl<LifeBlacklistMapper, L
 
     private final DynamicsRecommendCacheService dynamicsRecommendCacheService;
 
+    private final ClockInRecommendCacheService clockInRecommendCacheService;
+
     @Override
     public int blackList(LifeBlacklist lifeBlacklist) {
         LambdaUpdateWrapper<LifeBlacklist> wrapper = new LambdaUpdateWrapper<>();
@@ -82,12 +85,13 @@ public class LifeBlacklistServiceImpl extends ServiceImpl<LifeBlacklistMapper, L
         } else {
             baseRedisService.setSaveOrOverwriteScriptList("blackList_" + blockedPhoneId, Arrays.asList(blockerPhoneId));
         }
+        // DB 入库与原有 blackList_* Redis 列表维护已完成;以下为推荐 feeds 增量同步(动态+打卡),失败不改变返回值
         if (num > 0) {
-            // 推荐 feeds(dynamics:recommend:v15:*):仅「用户拉黑商户」写 Redis;拉黑用户或商户发起拉黑均不写
+            // dynamics:recommend:v15:* 与 clock_in:recommend:v15:*:仅「用户拉黑商户」写推荐缓存;拉黑用户不写
             try {
                 tryPatchDynamicsRecommendBlockedStoresAfterBlack(lifeBlacklist);
             } catch (Exception e) {
-                log.error("推荐动态缓存同步失败(拉黑),不影响主流程 blockerId={} blockedId={}",
+                log.error("推荐 feeds 缓存同步失败(拉黑,动态+打卡),不影响主流程 blockerId={} blockedId={}",
                         lifeBlacklist.getBlockerId(), lifeBlacklist.getBlockedId(), e);
             }
         }
@@ -169,12 +173,13 @@ public class LifeBlacklistServiceImpl extends ServiceImpl<LifeBlacklistMapper, L
             baseRedisService.removeAllOccurrences("blackList_" + black.getBlockedPhoneId(), black.getBlockerPhoneId());
         }
         int deleted = lifeBlacklistMapper.deleteById(black.getId());
+        // DB 删除成功后,尽力同步推荐 feeds(动态+打卡);失败不改变返回值
         if (deleted > 0) {
-            // 与拉黑入库一致:仅用户拉黑商户时维护推荐 Redis;拉黑用户不产生该缓存字段
+            // 与拉黑入库一致:仅用户拉黑商户时维护两片推荐 Redis
             try {
                 tryPatchDynamicsRecommendBlockedStoresAfterUnblack(black);
             } catch (Exception e) {
-                log.error("推荐动态缓存同步失败(取消拉黑),不影响主流程 blockerId={} blockedId={}",
+                log.error("推荐 feeds 缓存同步失败(取消拉黑,动态+打卡),不影响主流程 blockerId={} blockedId={}",
                         black.getBlockerId(), black.getBlockedId(), e);
             }
         }
@@ -196,7 +201,7 @@ public class LifeBlacklistServiceImpl extends ServiceImpl<LifeBlacklistMapper, L
     }
 
     /**
-     * 是否要把拉黑关系同步到「推荐动态」Redis({@code dynamics:recommend:v15:{lifeUserId}})。
+     * 是否要把拉黑关系同步到推荐 Redis(动态 {@code dynamics:recommend:v15:*} + 打卡 {@code clock_in:recommend:v15:*})。
      * <p>
      * 仅当 <strong>C 端用户拉黑商户</strong> 时返回 true:{@code blocker_type=2} 且 {@code blocked_type=1}。
      * 拉黑用户、商户拉黑用户/商户等场景均返回 false,不写推荐缓存。
@@ -209,7 +214,7 @@ public class LifeBlacklistServiceImpl extends ServiceImpl<LifeBlacklistMapper, L
     }
 
     /**
-     * 用户拉黑商户:把被拉黑门店账号 {@code store_user.id} 写入推荐缓存 {@code blocked_id}
+     * 用户拉黑商户:同步动态与打卡推荐缓存中的 {@code blocked_id}(store_user.id)
      */
     private void tryPatchDynamicsRecommendBlockedStoresAfterBlack(LifeBlacklist row) {
         if (!shouldSyncDynamicsRecommendFeedBlacklist(row)) {
@@ -220,6 +225,7 @@ public class LifeBlacklistServiceImpl extends ServiceImpl<LifeBlacklistMapper, L
         StoreUser su = storeUserMapper.selectById(blockedStoreUserId);
         if (su != null) {
             dynamicsRecommendCacheService.addBlockedStore(viewerLifeUserId, blockedStoreUserId);
+            clockInRecommendCacheService.addBlockedStore(viewerLifeUserId, blockedStoreUserId);
         }
     }
 
@@ -232,6 +238,7 @@ public class LifeBlacklistServiceImpl extends ServiceImpl<LifeBlacklistMapper, L
         StoreUser su = storeUserMapper.selectById(blockedStoreUserId);
         if (su != null) {
             dynamicsRecommendCacheService.removeBlockedStore(viewerLifeUserId, blockedStoreUserId);
+            clockInRecommendCacheService.removeBlockedStore(viewerLifeUserId, blockedStoreUserId);
         }
     }
 

+ 24 - 0
alien-store/src/main/java/shop/alien/store/service/impl/StoreClockInServiceImpl.java

@@ -486,6 +486,9 @@ public class StoreClockInServiceImpl extends ServiceImpl<StoreClockInMapper, Sto
             return null;
         }
         StoreClockInVo storeClockInVo = BeanUtil.copyProperties(storeClockIn, StoreClockInVo.class);
+        storeClockInVo.setIsFollowMe("0");
+        storeClockInVo.setIsFollowThis("0");
+        storeClockInVo.setIsLike("0");
 
         StoreInfo storeInfo = storeInfoMapper.selectById(storeClockIn.getStoreId());
         if (storeInfo == null) {
@@ -523,6 +526,27 @@ public class StoreClockInServiceImpl extends ServiceImpl<StoreClockInMapper, Sto
             if (collectList.contains(storeClockInVo.getStoreId().toString())){
                 storeClockInVo.setIsCollect("1");
             }
+
+            LambdaQueryWrapper<LifeLikeRecord> likeQw = new LambdaQueryWrapper<>();
+            likeQw.eq(LifeLikeRecord::getDianzanId, userId);
+            likeQw.eq(LifeLikeRecord::getType, "5");
+            likeQw.eq(LifeLikeRecord::getHuifuId, String.valueOf(storeClockIn.getId()));
+            storeClockInVo.setIsLike(lifeLikeRecordMapper.selectCount(likeQw) > 0 ? "1" : "0");
+
+            LifeUser viewer = lifeUserMapper.selectById(userId);
+            String viewerPhoneId = viewer != null && StringUtils.isNotBlank(viewer.getUserPhone())
+                    ? "user_" + viewer.getUserPhone()
+                    : null;
+            String authorPhoneId = storeClockInVo.getPhoneId();
+            if (viewerPhoneId != null && StringUtils.isNotBlank(authorPhoneId)) {
+                LambdaQueryWrapper<LifeFans> iFollowAuthor = new LambdaQueryWrapper<>();
+                iFollowAuthor.eq(LifeFans::getFansId, viewerPhoneId).eq(LifeFans::getFollowedId, authorPhoneId);
+                storeClockInVo.setIsFollowThis(lifeFansMapper.selectCount(iFollowAuthor) > 0 ? "1" : "0");
+
+                LambdaQueryWrapper<LifeFans> authorFollowsMe = new LambdaQueryWrapper<>();
+                authorFollowsMe.eq(LifeFans::getFansId, authorPhoneId).eq(LifeFans::getFollowedId, viewerPhoneId);
+                storeClockInVo.setIsFollowMe(lifeFansMapper.selectCount(authorFollowsMe) > 0 ? "1" : "0");
+            }
         }
 
         if(storeInfo.getBusinessSection() != null){