Explorar el Código

Merge remote-tracking branch 'origin/sit' into uat-20260202

dujian hace 4 días
padre
commit
afd60a988a

+ 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")

+ 26 - 0
alien-store/src/main/java/shop/alien/store/config/WxJsSdkProperties.java

@@ -0,0 +1,26 @@
+package shop.alien.store.config;
+
+import lombok.Data;
+import org.springframework.boot.context.properties.ConfigurationProperties;
+import org.springframework.cloud.context.config.annotation.RefreshScope;
+import org.springframework.stereotype.Component;
+
+/**
+ * 微信公众号 JS-SDK 配置(H5 分享、扫一扫等)
+ */
+@Data
+@Component
+@RefreshScope
+@ConfigurationProperties(prefix = "wx")
+public class WxJsSdkProperties {
+
+    /**
+     * 公众号 AppID
+     */
+    private String appId;
+
+    /**
+     * 公众号 AppSecret
+     */
+    private String appSecret;
+}

+ 37 - 0
alien-store/src/main/java/shop/alien/store/controller/WxJsSdkController.java

@@ -0,0 +1,37 @@
+package shop.alien.store.controller;
+
+import io.swagger.annotations.Api;
+import io.swagger.annotations.ApiImplicitParam;
+import io.swagger.annotations.ApiImplicitParams;
+import io.swagger.annotations.ApiOperation;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.web.bind.annotation.*;
+import shop.alien.entity.result.R;
+import shop.alien.store.dto.WxJsSdkConfigVo;
+import shop.alien.store.service.WxJsSdkService;
+
+/**
+ * 微信公众号 JS-SDK(H5 分享等)签名接口
+ */
+@Slf4j
+@Api(tags = {"微信公众号-JS-SDK"})
+@CrossOrigin
+@RestController
+@RequestMapping("/wx")
+@RequiredArgsConstructor
+public class WxJsSdkController {
+
+    private final WxJsSdkService wxJsSdkService;
+
+    @ApiOperation("获取微信 JS-SDK 签名参数(前端 wx.config 使用)")
+    @ApiImplicitParams({
+            @ApiImplicitParam(name = "url", value = "当前 H5 页面完整 URL(不含 # 及后面部分,需与前端 location.href 处理后一致)",
+                    dataType = "String", paramType = "query", required = true)
+    })
+    @GetMapping("/getWxConfig")
+    public R<WxJsSdkConfigVo> getWxConfig(@RequestParam String url) {
+        log.info("WxJsSdkController.getWxConfig url={}", url);
+        return R.data(wxJsSdkService.buildJsSdkConfig(url));
+    }
+}

+ 29 - 0
alien-store/src/main/java/shop/alien/store/dto/WxJsSdkConfigVo.java

@@ -0,0 +1,29 @@
+package shop.alien.store.dto;
+
+import io.swagger.annotations.ApiModel;
+import io.swagger.annotations.ApiModelProperty;
+import lombok.AllArgsConstructor;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+
+/**
+ * 微信 JS-SDK 前端初始化参数
+ */
+@Data
+@NoArgsConstructor
+@AllArgsConstructor
+@ApiModel(value = "微信JS-SDK签名参数")
+public class WxJsSdkConfigVo {
+
+    @ApiModelProperty(value = "公众号 AppID")
+    private String appId;
+
+    @ApiModelProperty(value = "时间戳(秒)")
+    private String timestamp;
+
+    @ApiModelProperty(value = "随机字符串")
+    private String nonceStr;
+
+    @ApiModelProperty(value = "签名")
+    private String signature;
+}

+ 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);

+ 16 - 0
alien-store/src/main/java/shop/alien/store/service/WxJsSdkService.java

@@ -0,0 +1,16 @@
+package shop.alien.store.service;
+
+import shop.alien.store.dto.WxJsSdkConfigVo;
+
+/**
+ * 微信公众号 JS-SDK 签名服务
+ */
+public interface WxJsSdkService {
+
+    /**
+     * 根据当前 H5 页面 URL 生成 JS-SDK 配置(不含 # 及其后片段)
+     *
+     * @param url 前端当前页完整 URL,需与 wx.config 所用 url 一致
+     */
+    WxJsSdkConfigVo buildJsSdkConfig(String url);
+}

+ 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){

+ 150 - 0
alien-store/src/main/java/shop/alien/store/service/impl/WxJsSdkServiceImpl.java

@@ -0,0 +1,150 @@
+package shop.alien.store.service.impl;
+
+import com.alibaba.fastjson2.JSON;
+import com.alibaba.fastjson2.JSONObject;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import okhttp3.OkHttpClient;
+import okhttp3.Request;
+import okhttp3.Response;
+import org.apache.commons.codec.digest.DigestUtils;
+import org.springframework.stereotype.Service;
+import org.springframework.util.StringUtils;
+import shop.alien.store.config.BaseRedisService;
+import shop.alien.store.config.WxJsSdkProperties;
+import shop.alien.store.dto.WxJsSdkConfigVo;
+import shop.alien.store.service.WxJsSdkService;
+
+import java.io.IOException;
+import java.security.SecureRandom;
+import java.util.concurrent.TimeUnit;
+
+/**
+ * 微信公众号 JS-SDK 签名实现
+ */
+@Slf4j
+@Service
+@RequiredArgsConstructor
+public class WxJsSdkServiceImpl implements WxJsSdkService {
+
+    private static final String ACCESS_TOKEN_CACHE_KEY = "wx:jsapi:access_token";
+    private static final String JSAPI_TICKET_CACHE_KEY = "wx:jsapi:jsapi_ticket";
+    private static final long CACHE_SECONDS = 7000L;
+
+    private static final String ACCESS_TOKEN_URL =
+            "https://api.weixin.qq.com/cgi-bin/token?grant_type=client_credential&appid=%s&secret=%s";
+    private static final String JSAPI_TICKET_URL =
+            "https://api.weixin.qq.com/cgi-bin/ticket/getticket?access_token=%s&type=jsapi";
+
+    private static final SecureRandom RANDOM = new SecureRandom();
+    private static final char[] NONCE_CHARS =
+            "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789".toCharArray();
+
+    private final WxJsSdkProperties wxJsSdkProperties;
+    private final BaseRedisService redisService;
+
+    private final OkHttpClient httpClient = new OkHttpClient.Builder()
+            .connectTimeout(30, TimeUnit.SECONDS)
+            .readTimeout(30, TimeUnit.SECONDS)
+            .writeTimeout(30, TimeUnit.SECONDS)
+            .build();
+
+    @Override
+    public WxJsSdkConfigVo buildJsSdkConfig(String url) {
+        if (!StringUtils.hasText(url)) {
+            throw new IllegalArgumentException("url 不能为空");
+        }
+        String pageUrl = stripHash(url.trim());
+
+        String ticket = getJsapiTicket();
+        String nonceStr = randomNonceStr(15);
+        String timestamp = String.valueOf(System.currentTimeMillis() / 1000);
+
+        String signStr = "jsapi_ticket=" + ticket
+                + "&noncestr=" + nonceStr
+                + "&timestamp=" + timestamp
+                + "&url=" + pageUrl;
+        String signature = DigestUtils.sha1Hex(signStr);
+
+        return new WxJsSdkConfigVo(
+                wxJsSdkProperties.getAppId(),
+                timestamp,
+                nonceStr,
+                signature
+        );
+    }
+
+    private String getAccessToken() {
+        String cached = redisService.getString(ACCESS_TOKEN_CACHE_KEY);
+        if (StringUtils.hasText(cached)) {
+            return cached;
+        }
+
+        String appId = wxJsSdkProperties.getAppId();
+        String appSecret = wxJsSdkProperties.getAppSecret();
+        if (!StringUtils.hasText(appId) || !StringUtils.hasText(appSecret)) {
+            throw new IllegalStateException("未配置 wx.appId / wx.appSecret");
+        }
+
+        String requestUrl = String.format(ACCESS_TOKEN_URL, appId, appSecret);
+        JSONObject body = getWechatJson(requestUrl, "获取 access_token");
+        String accessToken = body.getString("access_token");
+        if (!StringUtils.hasText(accessToken)) {
+            throw new RuntimeException("获取 access_token 失败,返回数据异常: " + body);
+        }
+
+        redisService.setString(ACCESS_TOKEN_CACHE_KEY, accessToken, CACHE_SECONDS);
+        return accessToken;
+    }
+
+    private String getJsapiTicket() {
+        String cached = redisService.getString(JSAPI_TICKET_CACHE_KEY);
+        if (StringUtils.hasText(cached)) {
+            return cached;
+        }
+
+        String accessToken = getAccessToken();
+        String requestUrl = String.format(JSAPI_TICKET_URL, accessToken);
+        JSONObject body = getWechatJson(requestUrl, "获取 jsapi_ticket");
+        String ticket = body.getString("ticket");
+        if (!StringUtils.hasText(ticket)) {
+            throw new RuntimeException("获取 jsapi_ticket 失败,返回数据异常: " + body);
+        }
+
+        redisService.setString(JSAPI_TICKET_CACHE_KEY, ticket, CACHE_SECONDS);
+        return ticket;
+    }
+
+    private JSONObject getWechatJson(String url, String action) {
+        Request request = new Request.Builder().url(url).get().build();
+        try (Response response = httpClient.newCall(request).execute()) {
+            if (!response.isSuccessful()) {
+                throw new RuntimeException(action + "失败,HTTP状态码: " + response.code());
+            }
+            String responseBody = response.body() != null ? response.body().string() : "";
+            JSONObject jsonObject = JSON.parseObject(responseBody);
+            if (jsonObject.containsKey("errcode") && jsonObject.getIntValue("errcode") != 0) {
+                log.error("{}失败,errcode={}, errmsg={}, body={}",
+                        action, jsonObject.getIntValue("errcode"), jsonObject.getString("errmsg"), responseBody);
+                throw new RuntimeException(action + "失败: " + jsonObject.getString("errmsg"));
+            }
+            return jsonObject;
+        } catch (IOException e) {
+            log.error("{}网络请求异常", action, e);
+            throw new RuntimeException(action + "网络请求失败: " + e.getMessage(), e);
+        }
+    }
+
+    private static String stripHash(String url) {
+        int hashIndex = url.indexOf('#');
+        return hashIndex >= 0 ? url.substring(0, hashIndex) : url;
+    }
+
+    private static String randomNonceStr(int length) {
+        StringBuilder sb = new StringBuilder(length);
+        for (int i = 0; i < length; i++) {
+            sb.append(NONCE_CHARS[RANDOM.nextInt(NONCE_CHARS.length)]);
+        }
+        return sb.toString();
+    }
+}