OrderReminderTask.java 13.5 KB
package com.infoloop.tianting.logic.task;

import com.infoloop.tianting.mealorderservice.PendingOrderSubscriptionMessageRpcResponse;
import com.infoloop.tianting.mealorderservice.SingleMpAccountRpcResponse;
import com.infoloop.tianting.model.dto.WxUserDto.SendSubscriptionMessageResponse;
import com.infoloop.tianting.service.OrderReminderService;
import com.infoloop.tianting.service.client.DeliveryRuleServiceRpcClient;
import com.infoloop.tianting.service.client.MealOrderServiceRpcClient;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.collections4.CollectionUtils;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;

import java.time.DayOfWeek;
import java.time.LocalDate;
import java.time.ZoneId;
import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.Executor;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.stream.Collectors;

import static com.infoloop.tianting.constant.ConfigConstants.ASYNC_EXECUTOR;

/**
 * 订餐提醒定时任务
 * 在订餐周期开启前(周一 9:00)向已授权的家长发送微信服务通知
 * 
 * 推送逻辑:
 * 1. 查询所有待发送的订阅消息(未删除的记录)
 * 2. 按 openId 去重,每个用户只推送一次
 * 3. 通过 openId 查询活跃的小程序用户
 * 4. 根据配送规则配置生成订餐时间提示
 * 5. 发送消息,发送成功后标记为已删除
 */
@Slf4j
@Component
public class OrderReminderTask {

    private static final int ENTERPRISE_ID = 449;
    
    /**
     * 用餐类型
     */
    private static final String MEAL_TYPE = "下次订餐即将开启";
    
    /**
     * 温馨提醒内容
     */
    private static final String TIPS = "请点击卡片,准时进入小程序完成预订";

    /**
     * 订餐时间配置(格式:HH:mm~HH:mm)
     */
    private record OrderTimeConfig(String orderTime) {}

    private final MealOrderServiceRpcClient mealOrderServiceRpcClient;
    private final DeliveryRuleServiceRpcClient deliveryRuleServiceRpcClient;
    private final OrderReminderService orderReminderService;
    private final Executor asyncExecutor;

    public OrderReminderTask(
            MealOrderServiceRpcClient mealOrderServiceRpcClient,
            DeliveryRuleServiceRpcClient deliveryRuleServiceRpcClient,
            OrderReminderService orderReminderService,
            @Qualifier(ASYNC_EXECUTOR) Executor asyncExecutor) {
        this.mealOrderServiceRpcClient = mealOrderServiceRpcClient;
        this.deliveryRuleServiceRpcClient = deliveryRuleServiceRpcClient;
        this.orderReminderService = orderReminderService;
        this.asyncExecutor = asyncExecutor;
    }

    /**
     * 每周一 9:00 执行订餐提醒推送
     */
    @Scheduled(cron = "${task.orderReminder.cron:0 0 9 * * MON}", zone = "Asia/Shanghai")
    public void executeTask() {
        log.info("OrderReminderTask started");
        
        try {
            // 1. 获取配送规则配置,用于生成订餐时间提示
            OrderTimeConfig orderTimeConfig = getOrderTimeConfigFromRule();
            if (orderTimeConfig == null) {
                log.warn("Failed to get order time config from rule, using default");
                orderTimeConfig = new OrderTimeConfig("请查看小程序");
            }
            log.info("Order time config: orderTime={}", orderTimeConfig.orderTime());

            // 2. 查询所有待发送的订阅消息(未删除的记录)
            List<PendingOrderSubscriptionMessageRpcResponse> pendingMessages = 
                    mealOrderServiceRpcClient.queryPendingOrderSubscriptionMessages(ENTERPRISE_ID);
            
            if (pendingMessages.isEmpty()) {
                log.info("No pending order subscription messages to send");
                return;
            }
            log.info("Found {} pending order subscription messages", pendingMessages.size());

            // 3. 提取所有 openId,查询活跃的小程序用户
            Set<String> openIds = pendingMessages.stream()
                    .map(PendingOrderSubscriptionMessageRpcResponse::getOpenId)
                    .collect(Collectors.toSet());
            
            Map<String, SingleMpAccountRpcResponse> activeMpAccountMap = getActiveMpAccountMap(openIds);
            log.info("Found {} active mp accounts from {} openIds", activeMpAccountMap.size(), openIds.size());

            int totalMessages = pendingMessages.size();
            AtomicInteger skippedInactive = new AtomicInteger(0);
            AtomicInteger skippedDuplicate = new AtomicInteger(0);
            AtomicInteger sentCount = new AtomicInteger(0);
            List<Long> sentMessageIds = Collections.synchronizedList(new ArrayList<>());
            Set<String> sentOpenIds = ConcurrentHashMap.newKeySet(); // 线程安全的 Set,用于 openId 去重

            // 4. 按 openId 去重,筛选出需要发送的消息
            List<PendingOrderSubscriptionMessageRpcResponse> messagesToSend = new ArrayList<>();
            for (PendingOrderSubscriptionMessageRpcResponse message : pendingMessages) {
                String openId = message.getOpenId();

                // 检查用户是否活跃
                if (!activeMpAccountMap.containsKey(openId)) {
                    log.debug("Skipping message {} - user {} is not active", message.getId(), openId);
                    skippedInactive.incrementAndGet();
                    sentMessageIds.add(message.getId());
                    continue;
                }

                // openId 去重:每个用户只推送一次
                if (!sentOpenIds.add(openId)) {
                    log.debug("Skipping message {} - already queued for openId: {}", message.getId(), openId);
                    skippedDuplicate.incrementAndGet();
                    sentMessageIds.add(message.getId());
                    continue;
                }

                messagesToSend.add(message);
            }

            log.info("Messages to send: {}, Skipped inactive: {}, Skipped duplicate: {}",
                    messagesToSend.size(), skippedInactive.get(), skippedDuplicate.get());

            // 5. 多线程并发发送消息
            final OrderTimeConfig finalOrderTimeConfig = orderTimeConfig;
            List<CompletableFuture<Void>> futures = messagesToSend.stream()
                    .map(message -> CompletableFuture.runAsync(() -> {
                        try {
                            boolean success = sendReminderMessage(message.getOpenId(), message, finalOrderTimeConfig);
                            if (success) {
                                sentMessageIds.add(message.getId());
                                sentCount.incrementAndGet();
                            }
                        } catch (Exception e) {
                            log.error("Failed to send reminder to openId: {}, messageId: {}",
                                    message.getOpenId(), message.getId(), e);
                        }
                    }, asyncExecutor))
                    .toList();

            // 等待所有任务完成
            CompletableFuture.allOf(futures.toArray(new CompletableFuture[0])).join();

            // 6. 批量标记已发送的消息(逻辑删除)
            if (!sentMessageIds.isEmpty()) {
                int affectedRows = mealOrderServiceRpcClient.batchDeleteOrderSubscriptionMessagesByIds(
                        ENTERPRISE_ID, sentMessageIds);
                log.info("Marked {} messages as sent", affectedRows);
            }

            log.info("OrderReminderTask completed. Total: {}, Sent: {}, SkippedInactive: {}, SkippedDuplicate: {}",
                    totalMessages, sentCount.get(), skippedInactive.get(), skippedDuplicate.get());

        } catch (Exception e) {
            log.error("OrderReminderTask failed", e);
        }
    }

    /**
     * 从配送规则配置中获取订餐时间配置
     * 根据配送规则中的星期开关(monday~sunday)计算本周对应的具体日期,
     * 第一个启用的星期作为开始日期,最后一个启用的星期作为结束日期。
     * 返回订餐时间,格式:yyyy年MM月dd日 HH:mm~yyyy年MM月dd日 HH:mm
     */
    private OrderTimeConfig getOrderTimeConfigFromRule() {
        try {
            final var response = deliveryRuleServiceRpcClient.getDefaultDeliveryTemplateAndRule(ENTERPRISE_ID);
            final var rule = response.getRule();
            if (rule == null || rule.getConfigJson().getRulesList().isEmpty()) {
                log.warn("Delivery rule not configured");
                return null;
            }

            final var cfg = rule.getConfigJson().getRules(0);
            final String startTimeStr = cfg.getStartTime();
            final String endTimeStr = cfg.getEndTime();
            if (startTimeStr == null || endTimeStr == null) {
                log.warn("Delivery rule time config incomplete");
                return null;
            }

            // 根据配置中的星期开关,收集所有启用的星期
            final List<DayOfWeek> enabledDays = new ArrayList<>();
            if (cfg.getMonday()) enabledDays.add(DayOfWeek.MONDAY);
            if (cfg.getTuesday()) enabledDays.add(DayOfWeek.TUESDAY);
            if (cfg.getWednesday()) enabledDays.add(DayOfWeek.WEDNESDAY);
            if (cfg.getThursday()) enabledDays.add(DayOfWeek.THURSDAY);
            if (cfg.getFriday()) enabledDays.add(DayOfWeek.FRIDAY);
            if (cfg.getSaturday()) enabledDays.add(DayOfWeek.SATURDAY);
            if (cfg.getSunday()) enabledDays.add(DayOfWeek.SUNDAY);

            if (enabledDays.isEmpty()) {
                log.warn("Delivery rule has no enabled days");
                return null;
            }

            // 格式化时间(去掉秒)
            String startTime = formatTime(startTimeStr);
            String endTime = formatTime(endTimeStr);

            // 根据脚本执行日期计算本周对应星期的具体日期
            final ZoneId zone = ZoneId.of("Asia/Shanghai");
            final LocalDate today = LocalDate.now(zone);
            final DayOfWeek firstEnabledDay = Collections.min(enabledDays);
            final DayOfWeek lastEnabledDay = Collections.max(enabledDays);

            // 计算本周对应星期的日期(以本周一为基准)
            final LocalDate mondayOfThisWeek = today.with(DayOfWeek.MONDAY);
            final LocalDate startDate = mondayOfThisWeek.plusDays(firstEnabledDay.getValue() - 1L);
            final LocalDate endDate = mondayOfThisWeek.plusDays(lastEnabledDay.getValue() - 1L);

            final DateTimeFormatter dateFormatter = DateTimeFormatter.ofPattern("yyyy年MM月dd日");
            String startDateStr = startDate.format(dateFormatter);
            String endDateStr = endDate.format(dateFormatter);

            // 返回订餐时间,格式:yyyy年MM月dd日 HH:mm~yyyy年MM月dd日 HH:mm
            String orderTime = startDateStr + " " + startTime + "~" + endDateStr + " " + endTime;
            return new OrderTimeConfig(orderTime);

        } catch (Exception e) {
            log.error("Failed to get order time config from rule", e);
            return null;
        }
    }

    /**
     * 格式化时间字符串(HH:mm:ss -> HH:mm)
     */
    private String formatTime(String timeStr) {
        if (timeStr == null || timeStr.length() < 5) {
            return timeStr;
        }
        // 取前5位 HH:mm
        return timeStr.substring(0, 5);
    }

    /**
     * 获取活跃的小程序用户 Map(openId -> MpAccount)
     */
    private Map<String, SingleMpAccountRpcResponse> getActiveMpAccountMap(Set<String> openIds) {
        if (CollectionUtils.isEmpty(openIds)) {
            return Collections.emptyMap();
        }
        List<SingleMpAccountRpcResponse> allAccounts = mealOrderServiceRpcClient.queryAllActiveMpAccounts(ENTERPRISE_ID);
        return allAccounts.stream()
                .filter(account -> openIds.contains(account.getOpenId()))
                .collect(Collectors.toMap(SingleMpAccountRpcResponse::getOpenId, account -> account, (a, b) -> a));
    }

    /**
     * 发送单条提醒消息
     * 
     * 小程序模板字段(模板ID: BCROwaS_oj1_b0fzc_qrgvtSeCtfxctghcLMu-Obkfw):
     * - thing8: 用餐类型(如:下次订单即将开始)
     * - time1: 订餐时间(如:13:00~21:00)
     * - thing2: 温馨提示
     */
    private boolean sendReminderMessage(String openId, PendingOrderSubscriptionMessageRpcResponse message, OrderTimeConfig orderTimeConfig) {
        String jumpPath = message.getJumpPath();
        
        // 发送消息
        SendSubscriptionMessageResponse response = orderReminderService.sendOrderReminder(
                openId,
                jumpPath,
                MEAL_TYPE,                       // thing8: 用餐类型
                orderTimeConfig.orderTime(),    // time1: 订餐时间
                TIPS                             // thing2: 温馨提示
        );
        
        // 判断发送结果
        if (response.getErrcode() != null && response.getErrcode() == 0) {
            return true;
        }
        
        // 记录失败原因
        log.warn("Failed to send reminder. openId: {}, errcode: {}, errmsg: {}", 
                openId, response.getErrcode(), response.getErrmsg());
        return false;
    }

}