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