Browse Source

redis迁移 实时更新动态的拉黑,关注,点赞已完成

lutong 4 ngày trước cách đây
mục cha
commit
fcc65e0335

+ 41 - 0
alien-entity/src/main/java/shop/alien/entity/store/dto/LifeClockInFeedApiResponse.java

@@ -0,0 +1,41 @@
+package shop.alien.entity.store.dto;
+
+import io.swagger.annotations.ApiModel;
+import io.swagger.annotations.ApiModelProperty;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+
+import java.util.List;
+
+/**
+ * 打卡推荐 feeds 分页响应(camelCase JSON;可与 Redis / HTTP 反序列化)。
+ * <p>
+ * {@code blockedId}:当前用户已拉黑商户的 {@code store_user.id}(与用户拉黑商户场景一致)。
+ * </p>
+ */
+@Data
+@NoArgsConstructor
+@ApiModel(description = "打卡推荐 feeds 分页响应")
+public class LifeClockInFeedApiResponse {
+
+    @ApiModelProperty("打卡条目列表")
+    private List<LifeClockInFeedItemDto> list;
+
+    @ApiModelProperty("过滤后总条数")
+    private Integer total;
+
+    @ApiModelProperty("当前页")
+    private Integer page;
+
+    @ApiModelProperty("每页条数")
+    private Integer size;
+
+    @ApiModelProperty("总页数")
+    private Integer totalPages;
+
+    @ApiModelProperty("接口耗时(秒)")
+    private Double responseTimeS;
+
+    @ApiModelProperty("当前用户已拉黑商户的 store_user.id")
+    private List<Integer> blockedId;
+}

+ 170 - 0
alien-entity/src/main/java/shop/alien/entity/store/dto/LifeClockInFeedItemDto.java

@@ -0,0 +1,170 @@
+package shop.alien.entity.store.dto;
+
+import io.swagger.annotations.ApiModel;
+import io.swagger.annotations.ApiModelProperty;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+
+import java.util.List;
+
+/**
+ * 打卡推荐 feeds 列表单项(与下游 camelCase JSON 对齐,{@code type}/{@code dynamicsType} 固定为 5)。
+ */
+@Data
+@NoArgsConstructor
+@ApiModel(description = "打卡推荐 feeds 列表项")
+public class LifeClockInFeedItemDto {
+
+    @ApiModelProperty("打卡 ID,store_clock_in.id")
+    private Integer id;
+
+    @ApiModelProperty("门店 phone_id(store_{手机号})")
+    private String phoneId;
+
+    @ApiModelProperty("标题,打卡场景一般为 null")
+    private String title;
+
+    @ApiModelProperty("打卡文案,store_clock_in.content")
+    private String context;
+
+    @ApiModelProperty("主图 URL,逗号分隔原图")
+    private String imagePath;
+
+    @ApiModelProperty("坐标")
+    private String address;
+
+    @ApiModelProperty("地址详情")
+    private String addressContext;
+
+    @ApiModelProperty("地点名")
+    private String addressName;
+
+    @ApiModelProperty("浏览数 store_clock_in.view_count")
+    private Integer liulanCount;
+
+    @ApiModelProperty("点赞总数 store_clock_in.like_count")
+    private Integer dianzanCount;
+
+    @ApiModelProperty("打卡类型固定 5")
+    private String type;
+
+    @ApiModelProperty("0 非草稿")
+    private Integer draft;
+
+    @ApiModelProperty("0 未删除")
+    private Integer deleteFlag;
+
+    @ApiModelProperty("创建时间 ISO8601")
+    private String createdTime;
+
+    @ApiModelProperty("打卡人 life_user.id,对应 store_clock_in.user_id")
+    private Integer createdUserId;
+
+    @ApiModelProperty("更新时间 ISO8601")
+    private String updatedTime;
+
+    @ApiModelProperty("更新人")
+    private Integer updatedUserId;
+
+    @ApiModelProperty("未实现时可 null")
+    private String isBlack;
+
+    @ApiModelProperty("门店省+市,用于 cityname 过滤")
+    private String addressProvince;
+
+    @ApiModelProperty("置顶状态")
+    private Integer topStatus;
+
+    @ApiModelProperty("置顶时间")
+    private String topTime;
+
+    @ApiModelProperty("启用状态")
+    private Integer enableStatus;
+
+    @ApiModelProperty("达人 ID")
+    private Integer expertId;
+
+    @ApiModelProperty("业务 ID")
+    private Integer businessId;
+
+    @ApiModelProperty("转发数")
+    private Integer transferCount;
+
+    @ApiModelProperty("实际浏览")
+    private Integer realityCount;
+
+    @ApiModelProperty("审核状态,2=审核完成")
+    private Integer checkFlag;
+
+    @ApiModelProperty("AI 审核任务 ID")
+    private String aiTaskId;
+
+    @ApiModelProperty("审核失败原因")
+    private String reason;
+
+    @ApiModelProperty("封面")
+    private String coverImage;
+
+    @ApiModelProperty("store_info.id")
+    private String storeId;
+
+    @ApiModelProperty("打卡人昵称 life_user.user_name")
+    private String userName;
+
+    @ApiModelProperty("打卡人头像 life_user.user_image")
+    private String userImage;
+
+    @ApiModelProperty(value = "门店商家是否关注我 0/1(life_fans,store_ phone_id)")
+    private String isFollowMe;
+
+    @ApiModelProperty(value = "我是否关注门店商家 0/1(life_fans,store_ phone_id)")
+    private String isFollowThis;
+
+    @ApiModelProperty(value = "打卡发布用户是否关注我 0/1(life_fans fans_type=1,user_ phone_id)")
+    private String isPublisherFollowMe;
+
+    @ApiModelProperty(value = "我是否关注打卡发布用户 0/1(life_fans fans_type=1,user_ phone_id)")
+    private String isFollowPublisher;
+
+    @ApiModelProperty(value = "我是否点赞该打卡 0/1(life_like_record type=5)")
+    private String isLike;
+
+    @ApiModelProperty("评论数")
+    private Integer commentCount;
+
+    @ApiModelProperty("商家 store_user.id(非打卡人 id)")
+    private String storeOrUserId;
+
+    @ApiModelProperty("0/1 是否达人")
+    private String isExpert;
+
+    @ApiModelProperty("门店粉丝数")
+    private Integer fansCount;
+
+    @ApiModelProperty("未使用")
+    private String userType;
+
+    @ApiModelProperty("与 type 一致,打卡固定 5")
+    private String dynamicsType;
+
+    @ApiModelProperty("由 imagePath 拆分")
+    private List<String> imagePathList;
+
+    @ApiModelProperty("门店名称")
+    private String storeName;
+
+    @ApiModelProperty("门店简介")
+    private String storeBlurb;
+
+    @ApiModelProperty("店铺平均分")
+    private Double scoreAvg;
+
+    @ApiModelProperty("经营板块")
+    private String businessSection;
+
+    @ApiModelProperty("评价数量(字符串)")
+    private String ratingCount;
+
+    @ApiModelProperty("经营种类")
+    private String businessTypeName;
+}

+ 46 - 0
alien-entity/src/main/java/shop/alien/entity/store/dto/LifeDynamicsFeedApiResponse.java

@@ -0,0 +1,46 @@
+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;
+import lombok.NoArgsConstructor;
+
+import java.util.List;
+
+/**
+ * 动态 feeds 分页接口响应体(与下游 snake_case JSON 对齐)。
+ * <p>
+ * 可作为 Redis 缓存载体:Key {@code dynamics:recommend:v15:{lifeUserId}},
+ * {@code lifeUserId} 为 {@code life_user.id};服务端增量补丁由 alien-store 模块
+ * {@code DynamicsRecommendCacheService} 维护。
+ * </p>
+ */
+@Data
+@NoArgsConstructor
+@JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class)
+@ApiModel(description = "动态 feeds 分页响应")
+public class LifeDynamicsFeedApiResponse {
+
+    @ApiModelProperty("动态列表")
+    private List<LifeDynamicsFeedItemDto> list;
+
+    @ApiModelProperty("候选总数")
+    private Integer total;
+
+    @ApiModelProperty("当前页")
+    private Integer page;
+
+    @ApiModelProperty("每页条数")
+    private Integer size;
+
+    @ApiModelProperty("总页数")
+    private Integer totalPages;
+
+    @ApiModelProperty("耗时(秒)")
+    private Double responseTimeS;
+
+    @ApiModelProperty("拉黑商户账号 ID(store_user.id)。仅用户拉黑商户时写入推荐缓存;拉黑用户不维护本字段")
+    private List<Integer> blockedId;
+}

+ 167 - 0
alien-entity/src/main/java/shop/alien/entity/store/dto/LifeDynamicsFeedItemDto.java

@@ -0,0 +1,167 @@
+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;
+import lombok.NoArgsConstructor;
+
+import java.util.List;
+
+/**
+ * 动态列表单项(与下游 snake_case JSON 字段对齐)。
+ */
+@Data
+@NoArgsConstructor
+@JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class)
+@ApiModel(description = "动态 feeds 列表项")
+public class LifeDynamicsFeedItemDto {
+
+    @ApiModelProperty("动态ID")
+    private Integer id;
+
+    @ApiModelProperty("商户 phone_id")
+    private String phoneId;
+
+    @ApiModelProperty("标题")
+    private String title;
+
+    @ApiModelProperty("正文")
+    private String context;
+
+    @ApiModelProperty("媒体原串(可逗号多资源)")
+    private String imagePath;
+
+    @ApiModelProperty("拆分后的 URL 数组")
+    private List<String> imagePathList;
+
+    @ApiModelProperty("封面(空串表示无)")
+    private String coverImage;
+
+    @ApiModelProperty("经度,纬度")
+    private String address;
+
+    @ApiModelProperty("地址详情")
+    private String addressContext;
+
+    @ApiModelProperty("地点名")
+    private String addressName;
+
+    @ApiModelProperty("省市区")
+    private String addressProvince;
+
+    @ApiModelProperty("浏览数")
+    private Integer liulanCount;
+
+    @ApiModelProperty("点赞总数(整数,表示该动态累计被点赞次数)")
+    private Integer dianzanCount;
+
+    @ApiModelProperty("评论数")
+    private Integer commentCount;
+
+    @ApiModelProperty("转发数")
+    private Integer transferCount;
+
+    @ApiModelProperty("实际浏览")
+    private Integer realityCount;
+
+    @ApiModelProperty("粉丝数")
+    private Integer fansCount;
+
+    @ApiModelProperty("1 动态 2 商家社区")
+    private String type;
+
+    @ApiModelProperty("动态推荐类型,接口固定可为 null")
+    private String dynamicsType;
+
+    @ApiModelProperty("0 非草稿")
+    private Integer draft;
+
+    @ApiModelProperty("0 未删")
+    private Integer deleteFlag;
+
+    @ApiModelProperty("启用状态")
+    private Integer enableStatus;
+
+    @ApiModelProperty("置顶状态")
+    private Integer topStatus;
+
+    @ApiModelProperty("置顶时间(原样字符串,精度不一)")
+    private String topTime;
+
+    @ApiModelProperty("0 未审 1 审中 2 完成")
+    private Integer checkFlag;
+
+    @ApiModelProperty("AI 审核任务 ID")
+    private String aiTaskId;
+
+    @ApiModelProperty("审核失败原因")
+    private String reason;
+
+    @ApiModelProperty("创建时间(原样字符串)")
+    private String createdTime;
+
+    @ApiModelProperty("更新时间(原样字符串)")
+    private String updatedTime;
+
+    @ApiModelProperty("创建人 ID")
+    private Integer createdUserId;
+
+    @ApiModelProperty("修改人 ID")
+    private Integer updatedUserId;
+
+    @ApiModelProperty("达人 ID")
+    private Integer expertId;
+
+    @ApiModelProperty("业务 ID")
+    private Integer businessId;
+
+    @ApiModelProperty("0/1 是否达人")
+    private String isExpert;
+
+    @ApiModelProperty("未实现时可恒为 null")
+    private String isBlack;
+
+    @ApiModelProperty(value = "商户是否关注当前用户:0-商户没有关注我;1-商户已经关注我")
+    private String isFollowMe;
+
+    @ApiModelProperty(value = "当前用户是否关注该商户:0-我没有关注该商户;1-我已经关注该商户")
+    private String isFollowThis;
+
+    @ApiModelProperty(value = "当前用户是否点赞该动态:0-我没有点赞该动态;1-我已经点赞该动态")
+    private String isLike;
+
+    @ApiModelProperty("store_info.id")
+    private String storeId;
+
+    @ApiModelProperty("store_user.id")
+    private String storeOrUserId;
+
+    @ApiModelProperty("门店账号名")
+    private String userName;
+
+    @ApiModelProperty("门店头像 URL")
+    private String userImage;
+
+    @ApiModelProperty("店名")
+    private String storeName;
+
+    @ApiModelProperty("简介")
+    private String storeBlurb;
+
+    @ApiModelProperty("店铺评分")
+    private Double scoreAvg;
+
+    @ApiModelProperty("经营板块")
+    private String businessSection;
+
+    @ApiModelProperty("评价数(字符串)")
+    private String ratingCount;
+
+    @ApiModelProperty("经营种类")
+    private String businessTypeName;
+
+    @ApiModelProperty("未使用")
+    private String userType;
+}

+ 25 - 0
alien-entity/src/main/resources/db/migration/life_discount_coupon_indexes.sql

@@ -0,0 +1,25 @@
+-- life_discount_coupon 建议索引(依据代码里 LambdaQueryWrapper / Mapper 常见条件)
+--
+-- 执行说明:
+-- • 请先 SHOW INDEX FROM life_discount_coupon ,若已有覆盖左前缀相同的索引请勿重复 ADD。
+-- • MySQL 8.0 若希望与 ORDER BY created_time DESC 完全一致,可把最后一列写成 `created_time DESC`;
+--   为兼容更广版本,此处使用默认升序,优化器仍可走索引反向扫描。
+-- • (unlimited_qty OR single_qty) 类条件 btree 较难单独优化,主要靠 store_id/delete_flag/get_status 等缩小范围。
+--
+
+-- 1) 店铺维度列表 + `ORDER BY created_time DESC`
+ALTER TABLE `life_discount_coupon`
+    ADD INDEX `idx_ldc_store_del_created` (`store_id`, `delete_flag`, `created_time`);
+
+-- 2) 领取窗口:store_id + 开启领取 + begin_get_date / end_get_date 区间
+ALTER TABLE `life_discount_coupon`
+    ADD INDEX `idx_ldc_store_del_get_endget` (`store_id`, `delete_flag`, `get_status`, `end_get_date`, `begin_get_date`);
+
+-- 3) 使用有效期(点餐可领列表等):store_id + 开启领取 + end_date/start_date 区间
+ALTER TABLE `life_discount_coupon`
+    ADD INDEX `idx_ldc_store_del_get_usedates` (`store_id`, `delete_flag`, `get_status`, `end_date`, `start_date`);
+
+-- 4) 平台优惠券列表:按 type(如 type=3)+ 删除标记 + created_time(含按日 between)
+ALTER TABLE `life_discount_coupon`
+    ADD INDEX `idx_ldc_type_del_created` (`type`, `delete_flag`, `created_time`);
+

+ 6 - 0
alien-entity/src/main/resources/db/migration/life_discount_coupon_long_term_unlimited.sql

@@ -0,0 +1,6 @@
+-- life_discount_coupon:创建优惠券支持「领取后有效期-长期有效」「发行数量-不限」
+-- 请在业务库执行本脚本后再发布应用。
+
+ALTER TABLE `life_discount_coupon`
+    ADD COLUMN `long_term_valid` TINYINT(1) NOT NULL DEFAULT 0 COMMENT '领取后是否长期有效:0-否(按天数/截止日期),1-是' AFTER `expiration_date`,
+    ADD COLUMN `unlimited_qty` TINYINT(1) NOT NULL DEFAULT 0 COMMENT '发行数量是否不限:0-否(使用single_qty),1-是' AFTER `single_qty`;

+ 11 - 0
alien-entity/src/main/resources/db/migration/life_discount_coupon_store_friend_long_term_valid.sql

@@ -0,0 +1,11 @@
+-- life_discount_coupon_store_friend:快照「领取后长期有效」,模板逻辑删除后好友赠券列表仍可展示
+-- 请在业务库执行后再发布应用。
+
+ALTER TABLE `life_discount_coupon_store_friend`
+    ADD COLUMN `long_term_valid` TINYINT(1) NULL DEFAULT NULL COMMENT '领取后是否长期有效:0-否,1-是(赠券时从 life_discount_coupon.long_term_valid 快照)' AFTER `expiration_date`;
+
+-- 历史数据:按关联模板回填
+UPDATE `life_discount_coupon_store_friend` sf
+    INNER JOIN `life_discount_coupon` c ON c.id = sf.coupon_id
+SET sf.`long_term_valid` = c.`long_term_valid`
+WHERE sf.`coupon_id` IS NOT NULL;

+ 50 - 0
alien-entity/src/main/resources/db/migration/life_discount_coupon_store_friend_sender_receiver.sql

@@ -0,0 +1,50 @@
+-- 赠送方 / 接收方语义明确:与旧列保持同步回填,旧列仍可被历史脚本读取
+
+ALTER TABLE `life_discount_coupon_store_friend`
+
+  ADD COLUMN `receiver_store_id` int DEFAULT NULL COMMENT '接收方店铺(store_info.id)' AFTER `friend_store_user_id`,
+
+  ADD COLUMN `receiver_user_id` int DEFAULT NULL COMMENT '接收方商户用户(store_user.id),可选' AFTER `receiver_store_id`,
+
+  ADD COLUMN `sender_store_id` int DEFAULT NULL COMMENT '赠送方店铺(store_info.id)' AFTER `receiver_user_id`,
+
+  ADD COLUMN `sender_user_id` int DEFAULT NULL COMMENT '赠送方商户用户(store_user.id)' AFTER `sender_store_id`;
+
+
+
+-- 接收店铺:沿用原 store_user_id(实为收到券的门店 id)
+
+UPDATE `life_discount_coupon_store_friend`
+
+SET `receiver_store_id` = `store_user_id`
+
+WHERE `receiver_store_id` IS NULL AND `store_user_id` IS NOT NULL;
+
+
+
+-- 赠送人:沿用原 friend_store_user_id(历史数据可能含错误值,仅作尽力回填)
+
+UPDATE `life_discount_coupon_store_friend`
+
+SET `sender_user_id` = `friend_store_user_id`
+
+WHERE `sender_user_id` IS NULL AND `friend_store_user_id` IS NOT NULL;
+
+
+
+UPDATE `life_discount_coupon_store_friend` sf
+
+INNER JOIN `store_user` su ON su.id = sf.`sender_user_id` AND COALESCE(su.delete_flag, 0) = 0
+
+SET sf.`sender_store_id` = su.`store_id`
+
+WHERE sf.`sender_store_id` IS NULL AND sf.`sender_user_id` IS NOT NULL;
+
+
+
+-- 建议(首次上线后按需执行):为「我收到的/我送出的」列表增加索引可加速查询
+
+-- ALTER TABLE life_discount_coupon_store_friend ADD INDEX idx_ldcsf_receiver_del (receiver_store_id, delete_flag);
+
+-- ALTER TABLE life_discount_coupon_store_friend ADD INDEX idx_ldcsf_sender_del (sender_user_id, delete_flag);
+

+ 20 - 0
alien-entity/src/main/resources/db/migration/life_fans_follow_pair_unique.sql

@@ -0,0 +1,20 @@
+-- 同一对 (followed_id, fans_id) 只允许一行,避免并发/重复调用产生多条有效关注,
+-- 与应用层 revive + INSERT 兜底一致。
+-- 需要 MySQL 8+(窗口函数)。
+-- 若 UNIQUE KEY uk_life_fans_follow_pair 已存在请勿重复执行 ADD,必要时先 DROP。
+
+DELETE FROM life_fans
+WHERE id IN (
+    SELECT id FROM (
+                       SELECT lf.id AS id,
+                              ROW_NUMBER() OVER (
+                                      PARTITION BY lf.followed_id, lf.fans_id
+                                          ORDER BY CASE WHEN lf.delete_flag = 0 THEN 0 ELSE 1 END, lf.id ASC
+                                  )       AS rn
+                       FROM life_fans lf
+                   ) ranked
+    WHERE ranked.rn > 1
+);
+
+ALTER TABLE life_fans
+    ADD UNIQUE KEY uk_life_fans_follow_pair (followed_id, fans_id);

+ 19 - 2
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.dynamics.DynamicsRecommendCacheService;
 
 import java.time.LocalDate;
 import java.time.ZoneId;
@@ -75,6 +76,8 @@ public class LifeCommentService {
 
     private final StoreUserMapper storeUserMapper;
 
+    private final DynamicsRecommendCacheService dynamicsRecommendCacheService;
+
     /**
      * 系统app通知开关
      */
@@ -139,7 +142,14 @@ public class LifeCommentService {
             
             // 根据类型更新对应表的点赞数
             int updateResult = updateLikeCountByType(huifuId, type);
-            
+            if (updateResult > 0) {
+                try {
+                    dynamicsRecommendCacheService.trySyncLikeCache(userId, huifuId, type, true, 1);
+                } catch (Exception e) {
+                    log.error("推荐动态缓存同步失败(点赞),不影响主流程 userId={} huifuId={} type={}", userId, huifuId, type, e);
+                }
+            }
+
             // 动态点赞:按「被点赞动态的发布者」的个性化设置决定是否通知(接收方是否愿意收点赞类通知)
             if (updateResult > 0 && CommonConstant.LIKE_TYPE_DYNAMICS.equals(type)) {
                 Integer receiverLifeUserId = resolveLifeUserIdFromDynamicsAuthorPhoneId(huifuId);
@@ -413,7 +423,14 @@ public class LifeCommentService {
             
             // 根据类型更新对应表的点赞数
             int updateResult = decreaseLikeCountByType(huifuId, type);
-            
+            if (updateResult > 0) {
+                try {
+                    dynamicsRecommendCacheService.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);
             return updateResult;
         } catch (Exception e) {

+ 18 - 0
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.dynamics.DynamicsRecommendCacheService;
 import shop.alien.store.feign.SecondServiceFeign;
 import shop.alien.store.util.FunctionMagic;
 
@@ -95,6 +96,8 @@ public class LifeUserService extends ServiceImpl<LifeUserMapper, LifeUser> {
 
     private final StoreUserMapper storeUserMapper;
 
+    private final DynamicsRecommendCacheService dynamicsRecommendCacheService;
+
     private final JdbcTemplate jdbcTemplate;
 
     @Autowired
@@ -153,12 +156,14 @@ public class LifeUserService extends ServiceImpl<LifeUserMapper, LifeUser> {
             int revived = reviveDeletedFollowRow(fans.getFollowedId(), fans.getFansId());
             if (revived > 0) {
                 sendFollowNoticeIfAllowed(fans);
+                notifyDynamicsRecommendFeedFollow(fans, true);
                 return LifeFansFollowOutcome.success("关注成功");
             }
 
             int num = lifeFansMapper.insert(fans);
             if (num == 1) {
                 sendFollowNoticeIfAllowed(fans);
+                notifyDynamicsRecommendFeedFollow(fans, true);
                 return LifeFansFollowOutcome.success("关注成功");
             }
             return LifeFansFollowOutcome.failure("关注失败:数据未保存成功,请稍后重试");
@@ -250,6 +255,18 @@ public class LifeUserService extends ServiceImpl<LifeUserMapper, LifeUser> {
                 fansId);
     }
 
+    /** DB 已成功变更关注关系后,尽力同步推荐动态 Redis(失败不影响主流程)。 */
+    private void notifyDynamicsRecommendFeedFollow(LifeFans fans, boolean followed) {
+        try {
+            dynamicsRecommendCacheService.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) {
         LifeNotice notice = new LifeNotice();
         notice.setTitle("关注通知");
@@ -373,6 +390,7 @@ public class LifeUserService extends ServiceImpl<LifeUserMapper, LifeUser> {
             if (n <= 0) {
                 return LifeFansFollowOutcome.success("当前未关注或已取消,无需重复操作");
             }
+            notifyDynamicsRecommendFeedFollow(fans, false);
             return LifeFansFollowOutcome.success("取消关注成功");
         } catch (Exception ex) {
             log.error("cancelFans 失败 followedId={} fansId={}", fans.getFollowedId(), fans.getFansId(), ex);

+ 22 - 0
alien-store/src/main/java/shop/alien/store/service/dynamics/DynamicsRecommendCacheConstants.java

@@ -0,0 +1,22 @@
+package shop.alien.store.service.dynamics;
+
+import lombok.AccessLevel;
+import lombok.NoArgsConstructor;
+
+/**
+ * 动态推荐 feeds 在 Redis 中的约定。
+ * <p>
+ * Key:{@code dynamics:recommend:v15:{lifeUserId}},值为序列化后的 {@link shop.alien.entity.store.dto.LifeDynamicsFeedApiResponse} JSON。
+ * {@code lifeUserId} 为 C 端用户 {@code life_user.id}(与示例中的 682 一致)。
+ * </p>
+ */
+@NoArgsConstructor(access = AccessLevel.PRIVATE)
+public final class DynamicsRecommendCacheConstants {
+
+    /** 目录前缀(不含用户 ID) */
+    public static final String REDIS_KEY_PREFIX = "dynamics:recommend:v15:";
+
+    public static String userFeedKey(int lifeUserId) {
+        return REDIS_KEY_PREFIX + lifeUserId;
+    }
+}

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

@@ -0,0 +1,242 @@
+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.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.LifeDynamicsFeedApiResponse;
+import shop.alien.entity.store.dto.LifeDynamicsFeedItemDto;
+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 缓存与用户互动状态的一致性。
+ * <p>
+ * 仅在对应用户存在缓存 Key 时改写;无缓存则跳过(用户尚未拉过推荐或已过期)。
+ * 写操作使用分布式锁 + 保留 TTL,降低并发下覆盖丢失的概率。
+ * </p>
+ */
+@Slf4j
+@Service
+public class DynamicsRecommendCacheService {
+
+    private static final String LOCK_KEY_PREFIX = "dynamicsRecommendMutate:";
+
+    private final StringRedisTemplate stringRedisTemplate;
+    private final BaseRedisService baseRedisService;
+    private final LifeUserMapper lifeUserMapper;
+    private final ObjectMapper dynamicsFeedObjectMapper;
+
+    public DynamicsRecommendCacheService(StringRedisTemplate stringRedisTemplate,
+                                         BaseRedisService baseRedisService,
+                                         LifeUserMapper lifeUserMapper,
+                                         ObjectMapper objectMapper) {
+        this.stringRedisTemplate = stringRedisTemplate;
+        this.baseRedisService = baseRedisService;
+        this.lifeUserMapper = lifeUserMapper;
+        this.dynamicsFeedObjectMapper = objectMapper.copy();
+        this.dynamicsFeedObjectMapper.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false);
+    }
+
+    /**
+     * 动态点赞 / 取消点赞后:同步当前用户在缓存列表中的 {@code is_like} 与 {@code dianzan_count}。
+     */
+    public void onDynamicsLikeChanged(String likerUserIdParam, int dynamicsId, boolean nowLiked, int dianzanDelta) {
+        Integer viewerId = resolveLifeUserId(likerUserIdParam);
+        if (viewerId == null) {
+            return;
+        }
+        mutateFeed(viewerId, feed -> {
+            if (CollectionUtils.isEmpty(feed.getList())) {
+                return;
+            }
+            for (LifeDynamicsFeedItemDto item : feed.getList()) {
+                if (item.getId() != null && item.getId() == dynamicsId) {
+                    item.setIsLike(nowLiked ? "1" : "0");
+                    int base = item.getDianzanCount() == null ? 0 : item.getDianzanCount();
+                    item.setDianzanCount(Math.max(0, base + dianzanDelta));
+                    break;
+                }
+            }
+        });
+    }
+
+    /**
+     * 关注关系变更:按粉丝双方身份刷新缓存中与 {@link LifeDynamicsFeedItemDto#getPhoneId()} 匹配的 {@code is_follow_this} / {@code is_follow_me}。
+     *
+     * @param followed {@code true} 建立关注;{@code false} 取消关注
+     */
+    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);
+            }
+        }
+    }
+
+    /**
+     * 用户拉黑商户:把被拉黑方 {@code store_user.id} 写入 {@link LifeDynamicsFeedApiResponse#getBlockedId()}。
+     */
+    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);
+        });
+    }
+
+    /**
+     * 取消拉黑商户:从 {@code blocked_id} 中移除对应 {@code store_user.id}。
+     */
+    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 (LifeDynamicsFeedItemDto 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 (LifeDynamicsFeedItemDto item : feed.getList()) {
+                if (issuerPhoneId.equals(item.getPhoneId())) {
+                    item.setIsFollowMe(flag);
+                }
+            }
+        });
+    }
+
+    private void mutateFeed(int viewerLifeUserId, Consumer<LifeDynamicsFeedApiResponse> mutator) {
+        String key = DynamicsRecommendCacheConstants.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);
+            LifeDynamicsFeedApiResponse feed = dynamicsFeedObjectMapper.readValue(json, LifeDynamicsFeedApiResponse.class);
+            mutator.accept(feed);
+            String out = dynamicsFeedObjectMapper.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;
+    }
+
+    /**
+     * 供点赞入口调用:若为社区动态类型则刷新缓存。
+     */
+    public void trySyncLikeCache(String userId, String huifuId, String type, boolean liked, int dianzanDelta) {
+        if (!CommonConstant.LIKE_TYPE_DYNAMICS.equals(type)) {
+            return;
+        }
+        int dynamicsId = Integer.parseInt(huifuId.trim());
+        onDynamicsLikeChanged(userId, dynamicsId, liked, dianzanDelta);
+    }
+}

+ 67 - 7
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.dynamics.DynamicsRecommendCacheService;
 import shop.alien.util.common.JwtUtil;
 
 import java.util.ArrayList;
@@ -40,6 +41,8 @@ public class LifeBlacklistServiceImpl extends ServiceImpl<LifeBlacklistMapper, L
     private final BaseRedisService baseRedisService;
     private final StoreInfoMapper storeInfoMapper;
 
+    private final DynamicsRecommendCacheService dynamicsRecommendCacheService;
+
     @Override
     public int blackList(LifeBlacklist lifeBlacklist) {
         LambdaUpdateWrapper<LifeBlacklist> wrapper = new LambdaUpdateWrapper<>();
@@ -79,6 +82,15 @@ public class LifeBlacklistServiceImpl extends ServiceImpl<LifeBlacklistMapper, L
         } else {
             baseRedisService.setSaveOrOverwriteScriptList("blackList_" + blockedPhoneId, Arrays.asList(blockerPhoneId));
         }
+        if (num > 0) {
+            // 推荐 feeds(dynamics:recommend:v15:*):仅「用户拉黑商户」写 Redis;拉黑用户或商户发起拉黑均不写
+            try {
+                tryPatchDynamicsRecommendBlockedStoresAfterBlack(lifeBlacklist);
+            } catch (Exception e) {
+                log.error("推荐动态缓存同步失败(拉黑),不影响主流程 blockerId={} blockedId={}",
+                        lifeBlacklist.getBlockerId(), lifeBlacklist.getBlockedId(), e);
+            }
+        }
         return num;
     }
 
@@ -149,16 +161,24 @@ public class LifeBlacklistServiceImpl extends ServiceImpl<LifeBlacklistMapper, L
     @Override
     public int cancelBlacklist(LifeBlacklist lifeBlacklist) {
         LifeBlacklist black = lifeBlacklistMapper.selectById(lifeBlacklist.getId());
-        if (black == null) return 0;
-//        LambdaUpdateWrapper<LifeBlacklist> wrapper = new LambdaUpdateWrapper<>();
-//        wrapper.eq(LifeBlacklist::getBlockedId, lifeBlacklist.getBlockedId());
-//        wrapper.eq(LifeBlacklist::getBlockerId, lifeBlacklist.getBlockerId());
-//        wrapper.eq(LifeBlacklist::getBlockedType, lifeBlacklist.getBlockedType());
-//        wrapper.eq(LifeBlacklist::getBlockerType, lifeBlacklist.getBlockerType());
+        if (black == null) {
+            return 0;
+        }
+        // 保持与原逻辑一致:先维护 blackList_{blockedPhoneId},再删库
         if (baseRedisService.hasKey("blackList_" + black.getBlockedPhoneId())) {
             baseRedisService.removeAllOccurrences("blackList_" + black.getBlockedPhoneId(), black.getBlockerPhoneId());
         }
-        return lifeBlacklistMapper.deleteById(black.getId());
+        int deleted = lifeBlacklistMapper.deleteById(black.getId());
+        if (deleted > 0) {
+            // 与拉黑入库一致:仅用户拉黑商户时维护推荐 Redis;拉黑用户不产生该缓存字段
+            try {
+                tryPatchDynamicsRecommendBlockedStoresAfterUnblack(black);
+            } catch (Exception e) {
+                log.error("推荐动态缓存同步失败(取消拉黑),不影响主流程 blockerId={} blockedId={}",
+                        black.getBlockerId(), black.getBlockedId(), e);
+            }
+        }
+        return deleted;
     }
 
     @Override
@@ -175,5 +195,45 @@ public class LifeBlacklistServiceImpl extends ServiceImpl<LifeBlacklistMapper, L
         }
     }
 
+    /**
+     * 是否要把拉黑关系同步到「推荐动态」Redis({@code dynamics:recommend:v15:{lifeUserId}})。
+     * <p>
+     * 仅当 <strong>C 端用户拉黑商户</strong> 时返回 true:{@code blocker_type=2} 且 {@code blocked_type=1}。
+     * 拉黑用户、商户拉黑用户/商户等场景均返回 false,不写推荐缓存。
+     * </p>
+     */
+    private static boolean shouldSyncDynamicsRecommendFeedBlacklist(LifeBlacklist row) {
+        return row != null
+                && "2".equals(row.getBlockerType())
+                && "1".equals(row.getBlockedType());
+    }
+
+    /**
+     * 用户拉黑商户:把被拉黑门店账号 {@code store_user.id} 写入推荐缓存 {@code blocked_id}。
+     */
+    private void tryPatchDynamicsRecommendBlockedStoresAfterBlack(LifeBlacklist row) {
+        if (!shouldSyncDynamicsRecommendFeedBlacklist(row)) {
+            return;
+        }
+        int viewerLifeUserId = Integer.parseInt(row.getBlockerId().trim());
+        int blockedStoreUserId = Integer.parseInt(row.getBlockedId().trim());
+        StoreUser su = storeUserMapper.selectById(blockedStoreUserId);
+        if (su != null) {
+            dynamicsRecommendCacheService.addBlockedStore(viewerLifeUserId, blockedStoreUserId);
+        }
+    }
+
+    private void tryPatchDynamicsRecommendBlockedStoresAfterUnblack(LifeBlacklist row) {
+        if (!shouldSyncDynamicsRecommendFeedBlacklist(row)) {
+            return;
+        }
+        int viewerLifeUserId = Integer.parseInt(row.getBlockerId().trim());
+        int blockedStoreUserId = Integer.parseInt(row.getBlockedId().trim());
+        StoreUser su = storeUserMapper.selectById(blockedStoreUserId);
+        if (su != null) {
+            dynamicsRecommendCacheService.removeBlockedStore(viewerLifeUserId, blockedStoreUserId);
+        }
+    }
+
 
 }