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