|
|
@@ -0,0 +1,546 @@
|
|
|
+package com.etotem.cfc.service;
|
|
|
+
|
|
|
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
|
|
+import com.etotem.cfc.dto.RecommendItemVO;
|
|
|
+import com.etotem.cfc.dto.RecommendRequestDTO;
|
|
|
+import com.etotem.cfc.entity.*;
|
|
|
+import com.etotem.cfc.mapper.*;
|
|
|
+import lombok.extern.slf4j.Slf4j;
|
|
|
+import org.springframework.stereotype.Service;
|
|
|
+
|
|
|
+import javax.annotation.Resource;
|
|
|
+import java.util.*;
|
|
|
+import java.util.stream.Collectors;
|
|
|
+
|
|
|
+@Slf4j
|
|
|
+@Service
|
|
|
+public class ContentRecommendService {
|
|
|
+
|
|
|
+ @Resource
|
|
|
+ private ArticleMapper articleMapper;
|
|
|
+
|
|
|
+ @Resource
|
|
|
+ private ProductMapper productMapper;
|
|
|
+
|
|
|
+ @Resource
|
|
|
+ private ActivityMapper activityMapper;
|
|
|
+
|
|
|
+ @Resource
|
|
|
+ private TaskMapper taskMapper;
|
|
|
+
|
|
|
+ @Resource
|
|
|
+ private ArticleReadingRecordMapper articleReadingRecordMapper;
|
|
|
+
|
|
|
+ @Resource
|
|
|
+ private ProductOrderMapper productOrderMapper;
|
|
|
+
|
|
|
+ @Resource
|
|
|
+ private ActivityRegistrationMapper activityRegistrationMapper;
|
|
|
+
|
|
|
+ @Resource
|
|
|
+ private PeriodicServiceMapper periodicServiceMapper;
|
|
|
+
|
|
|
+ @Resource
|
|
|
+ private RecommendationLogMapper recommendationLogMapper;
|
|
|
+
|
|
|
+ @Resource
|
|
|
+ private FamilyMemberMapper familyMemberMapper;
|
|
|
+
|
|
|
+ @Resource
|
|
|
+ private UserMapper userMapper;
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 通用推荐入口
|
|
|
+ */
|
|
|
+ public Map<String, Object> recommend(RecommendRequestDTO request, Long userId, Long familyId) {
|
|
|
+ String type = request.getType();
|
|
|
+ String dimensionCode = request.getDimensionCode();
|
|
|
+ Long childId = request.getChildId();
|
|
|
+ int size = request.getSize() != null ? request.getSize() : 10;
|
|
|
+
|
|
|
+ // 解析排除ID列表
|
|
|
+ Set<Long> excludeIds = new HashSet<>();
|
|
|
+ if (request.getExcludeIds() != null && !request.getExcludeIds().isEmpty()) {
|
|
|
+ for (String id : request.getExcludeIds().split(",")) {
|
|
|
+ try { excludeIds.add(Long.parseLong(id.trim())); } catch (NumberFormatException ignored) {}
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ List<RecommendItemVO> items;
|
|
|
+
|
|
|
+ switch (type) {
|
|
|
+ case "article":
|
|
|
+ items = recommendArticles(dimensionCode, userId, familyId, childId, excludeIds);
|
|
|
+ break;
|
|
|
+ case "task":
|
|
|
+ items = recommendTasks(dimensionCode, childId, excludeIds);
|
|
|
+ break;
|
|
|
+ case "product":
|
|
|
+ items = recommendProducts(dimensionCode, userId, familyId, excludeIds);
|
|
|
+ break;
|
|
|
+ case "activity":
|
|
|
+ items = recommendActivities(dimensionCode, userId, familyId, excludeIds);
|
|
|
+ break;
|
|
|
+ default:
|
|
|
+ items = new ArrayList<>();
|
|
|
+ }
|
|
|
+
|
|
|
+ // 按推荐分降序排列
|
|
|
+ items.sort((a, b) -> Integer.compare(
|
|
|
+ b.getRecommendScore() != null ? b.getRecommendScore() : 0,
|
|
|
+ a.getRecommendScore() != null ? a.getRecommendScore() : 0));
|
|
|
+
|
|
|
+ // 取前size条
|
|
|
+ List<RecommendItemVO> result = items.size() > size ? items.subList(0, size) : items;
|
|
|
+
|
|
|
+ // 记录推荐展现日志(异步风格,不阻塞)
|
|
|
+ try {
|
|
|
+ logRecommendations(userId, familyId, childId, type, result);
|
|
|
+ } catch (Exception e) {
|
|
|
+ log.warn("记录推荐日志失败: {}", e.getMessage());
|
|
|
+ }
|
|
|
+
|
|
|
+ Map<String, Object> data = new HashMap<>();
|
|
|
+ data.put("recommendations", result);
|
|
|
+ data.put("hasMore", items.size() > size);
|
|
|
+ return data;
|
|
|
+ }
|
|
|
+
|
|
|
+ // ==================== 文章推荐 ====================
|
|
|
+
|
|
|
+ private List<RecommendItemVO> recommendArticles(String dimensionCode, Long userId,
|
|
|
+ Long familyId, Long childId, Set<Long> excludeIds) {
|
|
|
+ LambdaQueryWrapper<Article> wrapper = new LambdaQueryWrapper<Article>()
|
|
|
+ .eq(Article::getStatus, "published")
|
|
|
+ .orderByDesc(Article::getIsFeatured)
|
|
|
+ .orderByDesc(Article::getPublishedAt)
|
|
|
+ .last("LIMIT 50");
|
|
|
+
|
|
|
+ if (dimensionCode != null && !dimensionCode.trim().isEmpty()) {
|
|
|
+ wrapper.and(w -> w
|
|
|
+ .apply("COALESCE(JSON_EXTRACT(dimension_weights, '$." + dimensionCode.trim() + "'), 0) > 0")
|
|
|
+ .or().like(Article::getRelatedDimensions, dimensionCode.trim()));
|
|
|
+ }
|
|
|
+
|
|
|
+ List<Article> allArticles = articleMapper.selectList(wrapper);
|
|
|
+ if (allArticles.isEmpty()) return new ArrayList<>();
|
|
|
+
|
|
|
+ // 获取孩子已读的文章ID
|
|
|
+ Set<Long> readArticleIds = new HashSet<>();
|
|
|
+ if (childId != null) {
|
|
|
+ List<ArticleReadingRecord> records = articleReadingRecordMapper.selectList(
|
|
|
+ new LambdaQueryWrapper<ArticleReadingRecord>()
|
|
|
+ .eq(ArticleReadingRecord::getChildId, childId)
|
|
|
+ .select("DISTINCT article_id"));
|
|
|
+ for (ArticleReadingRecord r : records) {
|
|
|
+ if (r.getArticleId() != null) readArticleIds.add(r.getArticleId());
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ List<RecommendItemVO> items = new ArrayList<>();
|
|
|
+ for (Article a : allArticles) {
|
|
|
+ if (excludeIds.contains(a.getId())) continue;
|
|
|
+ if (readArticleIds.contains(a.getId())) continue;
|
|
|
+
|
|
|
+ int score = 0;
|
|
|
+ List<String> reasons = new ArrayList<>();
|
|
|
+
|
|
|
+ // 维度匹配 (0-25)
|
|
|
+ if (dimensionCode != null && hasDimensionMatch(a.getDimensionWeights(), dimensionCode)) {
|
|
|
+ score += 20;
|
|
|
+ reasons.add(dimensionLabel(dimensionCode) + "相关");
|
|
|
+ } else {
|
|
|
+ score += 5;
|
|
|
+ }
|
|
|
+
|
|
|
+ // 新鲜度 (0-15)
|
|
|
+ score += calcFreshnessScore(a.getPublishedAt(), 15);
|
|
|
+
|
|
|
+ // 热度 (0-10)
|
|
|
+ int viewCount = a.getViewCount() != null ? a.getViewCount() : 0;
|
|
|
+ score += Math.min(10, viewCount / 100);
|
|
|
+ if (viewCount >= 500) reasons.add("热门");
|
|
|
+
|
|
|
+ // 内容类型加权
|
|
|
+ String contentType = a.getContentType();
|
|
|
+ if ("course".equals(contentType)) score += 8;
|
|
|
+ else if ("knowledge".equals(contentType)) score += 6;
|
|
|
+ else if ("article".equals(contentType)) score += 4;
|
|
|
+
|
|
|
+ RecommendItemVO vo = new RecommendItemVO();
|
|
|
+ vo.setId(a.getId());
|
|
|
+ vo.setTitle(a.getTitle());
|
|
|
+ vo.setSummary(a.getSummary());
|
|
|
+ vo.setCoverImage(a.getCoverImage());
|
|
|
+ vo.setDimensionCode(dimensionCode);
|
|
|
+ vo.setRecommendScore(score);
|
|
|
+ vo.setRecommendReason(reasons.isEmpty() ? "为你推荐" : String.join("·", reasons));
|
|
|
+ vo.setBadge(score >= 30 ? "hot" : "");
|
|
|
+ items.add(vo);
|
|
|
+ }
|
|
|
+ return items;
|
|
|
+ }
|
|
|
+
|
|
|
+ // ==================== 任务推荐 ====================
|
|
|
+
|
|
|
+ private List<RecommendItemVO> recommendTasks(String dimensionCode, Long childId, Set<Long> excludeIds) {
|
|
|
+ if (childId == null) return new ArrayList<>();
|
|
|
+
|
|
|
+ // 获取已存在的任务(用于去重)
|
|
|
+ Set<Long> articleSourceIds = new HashSet<>();
|
|
|
+ List<Task> existingTasks = taskMapper.selectList(
|
|
|
+ new LambdaQueryWrapper<Task>()
|
|
|
+ .eq(Task::getChildId, childId)
|
|
|
+ .in(Task::getStatus, "pending", "completed")
|
|
|
+ .select("id", "source_type", "source_id"));
|
|
|
+ for (Task t : existingTasks) {
|
|
|
+ if ("article".equals(t.getSourceType()) && t.getSourceId() != null) {
|
|
|
+ articleSourceIds.add(t.getSourceId());
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ List<RecommendItemVO> items = new ArrayList<>();
|
|
|
+
|
|
|
+ // 从已发布文章中推荐可转化为任务的
|
|
|
+ LambdaQueryWrapper<Article> articleWrapper = new LambdaQueryWrapper<Article>()
|
|
|
+ .eq(Article::getStatus, "published")
|
|
|
+ .orderByDesc(Article::getIsFeatured)
|
|
|
+ .orderByDesc(Article::getPublishedAt)
|
|
|
+ .last("LIMIT 20");
|
|
|
+
|
|
|
+ if (dimensionCode != null && !dimensionCode.trim().isEmpty()) {
|
|
|
+ articleWrapper.and(w -> w
|
|
|
+ .apply("COALESCE(JSON_EXTRACT(dimension_weights, '$." + dimensionCode.trim() + "'), 0) > 0")
|
|
|
+ .or().like(Article::getRelatedDimensions, dimensionCode.trim()));
|
|
|
+ }
|
|
|
+
|
|
|
+ List<Article> articles = articleMapper.selectList(articleWrapper);
|
|
|
+ for (Article a : articles) {
|
|
|
+ if (excludeIds.contains(a.getId())) continue;
|
|
|
+ if (articleSourceIds.contains(a.getId())) continue;
|
|
|
+
|
|
|
+ int score = 20;
|
|
|
+ List<String> reasons = new ArrayList<>();
|
|
|
+ reasons.add("阅读任务");
|
|
|
+
|
|
|
+ if (dimensionCode != null && hasDimensionMatch(a.getDimensionWeights(), dimensionCode)) {
|
|
|
+ score += 15;
|
|
|
+ }
|
|
|
+
|
|
|
+ score += calcFreshnessScore(a.getPublishedAt(), 10);
|
|
|
+
|
|
|
+ RecommendItemVO vo = new RecommendItemVO();
|
|
|
+ vo.setId(a.getId());
|
|
|
+ vo.setTitle("阅读: " + a.getTitle());
|
|
|
+ vo.setSummary(a.getSummary());
|
|
|
+ vo.setCoverImage(a.getCoverImage());
|
|
|
+ vo.setDimensionCode(dimensionCode);
|
|
|
+ vo.setRecommendScore(score);
|
|
|
+ vo.setRecommendReason(String.join("·", reasons));
|
|
|
+ vo.setBadge("task");
|
|
|
+ items.add(vo);
|
|
|
+ }
|
|
|
+
|
|
|
+ return items;
|
|
|
+ }
|
|
|
+
|
|
|
+ // ==================== 商品推荐 ====================
|
|
|
+
|
|
|
+ private List<RecommendItemVO> recommendProducts(String dimensionCode, Long userId,
|
|
|
+ Long familyId, Set<Long> excludeIds) {
|
|
|
+ LambdaQueryWrapper<Product> wrapper = new LambdaQueryWrapper<Product>()
|
|
|
+ .eq(Product::getStatus, "on_shelf")
|
|
|
+ .orderByDesc(Product::getSalesCount)
|
|
|
+ .last("LIMIT 50");
|
|
|
+
|
|
|
+ if (dimensionCode != null && !dimensionCode.trim().isEmpty()) {
|
|
|
+ wrapper.eq(Product::getDomain, dimensionCode);
|
|
|
+ }
|
|
|
+
|
|
|
+ List<Product> allProducts = productMapper.selectList(wrapper);
|
|
|
+ if (allProducts.isEmpty()) return new ArrayList<>();
|
|
|
+
|
|
|
+ // 获取家庭已购商品ID
|
|
|
+ Set<Long> purchasedProductIds = getPurchasedProductIds(familyId, userId);
|
|
|
+
|
|
|
+ // 获取周期性服务配置
|
|
|
+ List<PeriodicService> periodicServices = periodicServiceMapper.selectList(
|
|
|
+ new LambdaQueryWrapper<PeriodicService>().eq(PeriodicService::getEnabled, 1));
|
|
|
+ Map<Long, PeriodicService> psMap = new HashMap<>();
|
|
|
+ for (PeriodicService ps : periodicServices) {
|
|
|
+ psMap.put(ps.getProductId(), ps);
|
|
|
+ }
|
|
|
+
|
|
|
+ // 获取用户的订单历史
|
|
|
+ List<ProductOrder> userOrders = new ArrayList<>();
|
|
|
+ if (userId != null) {
|
|
|
+ LambdaQueryWrapper<ProductOrder> orderWrapper = new LambdaQueryWrapper<ProductOrder>()
|
|
|
+ .eq(ProductOrder::getStatus, "paid")
|
|
|
+ .orderByDesc(ProductOrder::getCreatedAt);
|
|
|
+ if (familyId != null) {
|
|
|
+ orderWrapper.eq(ProductOrder::getFamilyId, familyId);
|
|
|
+ } else {
|
|
|
+ orderWrapper.eq(ProductOrder::getBuyerId, userId);
|
|
|
+ }
|
|
|
+ userOrders = productOrderMapper.selectList(orderWrapper);
|
|
|
+ }
|
|
|
+
|
|
|
+ List<RecommendItemVO> items = new ArrayList<>();
|
|
|
+ for (Product p : allProducts) {
|
|
|
+ if (excludeIds.contains(p.getId())) continue;
|
|
|
+
|
|
|
+ // 消费去重
|
|
|
+ boolean isFamilyScope = p.getFamilyScope() == null || p.getFamilyScope() == 1;
|
|
|
+ if (isFamilyScope && purchasedProductIds.contains(p.getId())) continue;
|
|
|
+ if (!isFamilyScope && userId != null) {
|
|
|
+ boolean personalPurchased = productOrderMapper.selectCount(
|
|
|
+ new LambdaQueryWrapper<ProductOrder>()
|
|
|
+ .eq(ProductOrder::getProductId, p.getId())
|
|
|
+ .eq(ProductOrder::getBuyerId, userId)
|
|
|
+ .eq(ProductOrder::getStatus, "paid")) > 0;
|
|
|
+ if (personalPurchased) continue;
|
|
|
+ }
|
|
|
+
|
|
|
+ int score = 0;
|
|
|
+ List<String> reasons = new ArrayList<>();
|
|
|
+
|
|
|
+ // 维度匹配 (0-25)
|
|
|
+ if (dimensionCode != null && dimensionCode.equals(p.getDomain())) {
|
|
|
+ score += 20;
|
|
|
+ reasons.add(dimensionLabel(dimensionCode) + "好物");
|
|
|
+ } else {
|
|
|
+ score += 5;
|
|
|
+ }
|
|
|
+
|
|
|
+ // 销量热度 (0-10)
|
|
|
+ int sales = p.getSalesCount() != null ? p.getSalesCount() : 0;
|
|
|
+ score += Math.min(10, sales / 10);
|
|
|
+ if (sales >= 50) reasons.add("热销");
|
|
|
+
|
|
|
+ // 周期性服务续费提示 (0-20)
|
|
|
+ PeriodicService ps = psMap.get(p.getId());
|
|
|
+ if (ps != null) {
|
|
|
+ int renewScore = calcRenewalScore(p.getId(), ps, userOrders);
|
|
|
+ if (renewScore > 0) {
|
|
|
+ score += renewScore;
|
|
|
+ reasons.add("续费提醒");
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ RecommendItemVO vo = new RecommendItemVO();
|
|
|
+ vo.setId(p.getId());
|
|
|
+ vo.setTitle(p.getName());
|
|
|
+ vo.setSummary(p.getIntro());
|
|
|
+ vo.setCoverImage(p.getCoverImage());
|
|
|
+ vo.setDimensionCode(p.getDomain());
|
|
|
+ vo.setRecommendScore(score);
|
|
|
+ vo.setRecommendReason(reasons.isEmpty() ? "为你推荐" : String.join("·", reasons));
|
|
|
+ vo.setBadge(ps != null && calcRenewalScore(p.getId(), ps, userOrders) > 0 ? "renew" :
|
|
|
+ score >= 25 ? "hot" : "");
|
|
|
+ items.add(vo);
|
|
|
+ }
|
|
|
+ return items;
|
|
|
+ }
|
|
|
+
|
|
|
+ // ==================== 活动推荐 ====================
|
|
|
+
|
|
|
+ private List<RecommendItemVO> recommendActivities(String dimensionCode, Long userId,
|
|
|
+ Long familyId, Set<Long> excludeIds) {
|
|
|
+ LambdaQueryWrapper<Activity> wrapper = new LambdaQueryWrapper<Activity>()
|
|
|
+ .eq(Activity::getStatus, "published")
|
|
|
+ .ge(Activity::getEndTime, new Date())
|
|
|
+ .orderByDesc(Activity::getStartTime)
|
|
|
+ .last("LIMIT 50");
|
|
|
+
|
|
|
+ if (dimensionCode != null && !dimensionCode.trim().isEmpty()) {
|
|
|
+ wrapper.eq(Activity::getDimensionCode, dimensionCode);
|
|
|
+ }
|
|
|
+
|
|
|
+ List<Activity> allActivities = activityMapper.selectList(wrapper);
|
|
|
+ if (allActivities.isEmpty()) return new ArrayList<>();
|
|
|
+
|
|
|
+ // 获取家庭已参与活动ID
|
|
|
+ Set<Long> registeredActivityIds = getRegisteredActivityIds(familyId, userId);
|
|
|
+
|
|
|
+ List<RecommendItemVO> items = new ArrayList<>();
|
|
|
+ for (Activity a : allActivities) {
|
|
|
+ if (excludeIds.contains(a.getId())) continue;
|
|
|
+
|
|
|
+ // 参与去重
|
|
|
+ boolean isFamilyScope = a.getFamilyScope() == null || a.getFamilyScope() == 1;
|
|
|
+ if (isFamilyScope && registeredActivityIds.contains(a.getId())) continue;
|
|
|
+ if (!isFamilyScope && userId != null) {
|
|
|
+ boolean personalRegistered = activityRegistrationMapper.selectCount(
|
|
|
+ new LambdaQueryWrapper<ActivityRegistration>()
|
|
|
+ .eq(ActivityRegistration::getActivityId, a.getId())
|
|
|
+ .eq(ActivityRegistration::getUserId, userId)) > 0;
|
|
|
+ if (personalRegistered) continue;
|
|
|
+ }
|
|
|
+
|
|
|
+ int score = 0;
|
|
|
+ List<String> reasons = new ArrayList<>();
|
|
|
+
|
|
|
+ // 维度匹配 (0-25)
|
|
|
+ if (dimensionCode != null && dimensionCode.equals(a.getDimensionCode())) {
|
|
|
+ score += 20;
|
|
|
+ reasons.add(dimensionLabel(dimensionCode) + "活动");
|
|
|
+ } else {
|
|
|
+ score += 5;
|
|
|
+ }
|
|
|
+
|
|
|
+ // 新鲜度 (0-15)
|
|
|
+ score += calcFreshnessScore(a.getCreatedAt(), 15);
|
|
|
+
|
|
|
+ // 参与热度 (0-10)
|
|
|
+ int participants = a.getCurrentParticipants() != null ? a.getCurrentParticipants() : 0;
|
|
|
+ score += Math.min(10, participants);
|
|
|
+ if (participants >= 20) reasons.add("热门");
|
|
|
+
|
|
|
+ RecommendItemVO vo = new RecommendItemVO();
|
|
|
+ vo.setId(a.getId());
|
|
|
+ vo.setTitle(a.getTitle());
|
|
|
+ vo.setSummary(a.getDescription());
|
|
|
+ vo.setCoverImage(a.getCoverImage());
|
|
|
+ vo.setDimensionCode(a.getDimensionCode());
|
|
|
+ vo.setRecommendScore(score);
|
|
|
+ vo.setRecommendReason(reasons.isEmpty() ? "为你推荐" : String.join("·", reasons));
|
|
|
+ vo.setBadge(score >= 30 ? "hot" : "");
|
|
|
+ items.add(vo);
|
|
|
+ }
|
|
|
+ return items;
|
|
|
+ }
|
|
|
+
|
|
|
+ // ==================== 辅助方法 ====================
|
|
|
+
|
|
|
+ /** 检查dimension_weights JSON中是否包含指定维度的正权重 */
|
|
|
+ private boolean hasDimensionMatch(String dimensionWeightsJson, String dimCode) {
|
|
|
+ if (dimensionWeightsJson == null || dimensionWeightsJson.isEmpty()) return false;
|
|
|
+ try {
|
|
|
+ String key = "\"" + dimCode + "\"";
|
|
|
+ int idx = dimensionWeightsJson.indexOf(key);
|
|
|
+ if (idx < 0) return false;
|
|
|
+ int colonIdx = dimensionWeightsJson.indexOf(':', idx);
|
|
|
+ int commaIdx = dimensionWeightsJson.indexOf(',', colonIdx);
|
|
|
+ int braceIdx = dimensionWeightsJson.indexOf('}', colonIdx);
|
|
|
+ int endIdx = commaIdx > 0 && commaIdx < braceIdx ? commaIdx : braceIdx;
|
|
|
+ if (endIdx < 0) endIdx = dimensionWeightsJson.length();
|
|
|
+ String valStr = dimensionWeightsJson.substring(colonIdx + 1, endIdx).trim();
|
|
|
+ return Integer.parseInt(valStr) > 0;
|
|
|
+ } catch (Exception e) {
|
|
|
+ return false;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ /** 计算新鲜度分 */
|
|
|
+ private int calcFreshnessScore(Date date, int maxScore) {
|
|
|
+ if (date == null) return 0;
|
|
|
+ long diff = System.currentTimeMillis() - date.getTime();
|
|
|
+ long days = diff / (24 * 60 * 60 * 1000);
|
|
|
+ if (days <= 7) return maxScore;
|
|
|
+ if (days <= 30) return (int) (maxScore * 0.7);
|
|
|
+ if (days <= 90) return (int) (maxScore * 0.4);
|
|
|
+ if (days <= 180) return (int) (maxScore * 0.2);
|
|
|
+ return 0;
|
|
|
+ }
|
|
|
+
|
|
|
+ /** 计算续费提醒分 */
|
|
|
+ private int calcRenewalScore(Long productId, PeriodicService ps, List<ProductOrder> orders) {
|
|
|
+ for (ProductOrder order : orders) {
|
|
|
+ if (!productId.equals(order.getProductId())) continue;
|
|
|
+ Date paidAt = order.getPaidAt() != null ? order.getPaidAt() : order.getCreatedAt();
|
|
|
+ if (paidAt == null) continue;
|
|
|
+ long serviceEnd = paidAt.getTime() + ps.getCycleDays() * 24L * 60 * 60 * 1000;
|
|
|
+ long now = System.currentTimeMillis();
|
|
|
+ long daysLeft = (serviceEnd - now) / (24 * 60 * 60 * 1000);
|
|
|
+ int remindDays = ps.getRemindBeforeDays() != null ? ps.getRemindBeforeDays() : 30;
|
|
|
+ if (daysLeft <= remindDays && daysLeft >= 0) {
|
|
|
+ return 20;
|
|
|
+ }
|
|
|
+ if (daysLeft < 0 && daysLeft > -30) {
|
|
|
+ return 15;
|
|
|
+ }
|
|
|
+ return 0;
|
|
|
+ }
|
|
|
+ return 5;
|
|
|
+ }
|
|
|
+
|
|
|
+ /** 获取家庭已购商品ID */
|
|
|
+ private Set<Long> getPurchasedProductIds(Long familyId, Long userId) {
|
|
|
+ Set<Long> ids = new HashSet<>();
|
|
|
+ LambdaQueryWrapper<ProductOrder> wrapper = new LambdaQueryWrapper<ProductOrder>()
|
|
|
+ .eq(ProductOrder::getStatus, "paid");
|
|
|
+ if (familyId != null) {
|
|
|
+ wrapper.eq(ProductOrder::getFamilyId, familyId);
|
|
|
+ } else if (userId != null) {
|
|
|
+ wrapper.eq(ProductOrder::getBuyerId, userId);
|
|
|
+ } else {
|
|
|
+ return ids;
|
|
|
+ }
|
|
|
+ List<ProductOrder> orders = productOrderMapper.selectList(wrapper.select("distinct product_id"));
|
|
|
+ for (ProductOrder o : orders) {
|
|
|
+ ids.add(o.getProductId());
|
|
|
+ }
|
|
|
+ return ids;
|
|
|
+ }
|
|
|
+
|
|
|
+ /** 获取家庭已参与活动ID */
|
|
|
+ private Set<Long> getRegisteredActivityIds(Long familyId, Long userId) {
|
|
|
+ Set<Long> ids = new HashSet<>();
|
|
|
+ if (familyId != null) {
|
|
|
+ List<FamilyMember> members = familyMemberMapper.selectList(
|
|
|
+ new LambdaQueryWrapper<FamilyMember>().eq(FamilyMember::getFamilyId, familyId));
|
|
|
+ Set<Long> memberUserIds = new HashSet<>();
|
|
|
+ for (FamilyMember m : members) {
|
|
|
+ if (m.getUserId() != null) memberUserIds.add(m.getUserId());
|
|
|
+ }
|
|
|
+ if (!memberUserIds.isEmpty()) {
|
|
|
+ List<ActivityRegistration> regs = activityRegistrationMapper.selectList(
|
|
|
+ new LambdaQueryWrapper<ActivityRegistration>()
|
|
|
+ .in(ActivityRegistration::getUserId, memberUserIds)
|
|
|
+ .select("distinct activity_id"));
|
|
|
+ for (ActivityRegistration r : regs) {
|
|
|
+ ids.add(r.getActivityId());
|
|
|
+ }
|
|
|
+ }
|
|
|
+ } else if (userId != null) {
|
|
|
+ List<ActivityRegistration> regs = activityRegistrationMapper.selectList(
|
|
|
+ new LambdaQueryWrapper<ActivityRegistration>()
|
|
|
+ .eq(ActivityRegistration::getUserId, userId)
|
|
|
+ .select("distinct activity_id"));
|
|
|
+ for (ActivityRegistration r : regs) {
|
|
|
+ ids.add(r.getActivityId());
|
|
|
+ }
|
|
|
+ }
|
|
|
+ return ids;
|
|
|
+ }
|
|
|
+
|
|
|
+ /** 记录推荐日志 */
|
|
|
+ private void logRecommendations(Long userId, Long familyId, Long childId,
|
|
|
+ String type, List<RecommendItemVO> items) {
|
|
|
+ for (RecommendItemVO item : items) {
|
|
|
+ RecommendationLog log = new RecommendationLog();
|
|
|
+ log.setUserId(userId);
|
|
|
+ log.setFamilyId(familyId);
|
|
|
+ log.setChildId(childId);
|
|
|
+ log.setContentType(type);
|
|
|
+ log.setContentId(item.getId());
|
|
|
+ log.setRecommendScore(item.getRecommendScore());
|
|
|
+ log.setAction("show");
|
|
|
+ log.setCreatedAt(new Date());
|
|
|
+ recommendationLogMapper.insert(log);
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ /** 维度代码 → 中文标签 */
|
|
|
+ private String dimensionLabel(String code) {
|
|
|
+ if (code == null) return "";
|
|
|
+ switch (code) {
|
|
|
+ case "body": return "身体";
|
|
|
+ case "mind": return "心理";
|
|
|
+ case "wisdom": return "智慧";
|
|
|
+ case "action": return "行动";
|
|
|
+ case "wealth": return "财富";
|
|
|
+ default: return code;
|
|
|
+ }
|
|
|
+ }
|
|
|
+}
|