Selaa lähdekoodia

feat(notification): 添加Firebase推送通知功能和订单退款处理

- 新增FirebaseUtil工具类,支持用户管理、消息推送、主题订阅等功能
- 集成Firebase Admin SDK依赖,支持跨平台消息推送
- 实现订单退款功能,包括状态更新和积分回退处理
- 添加消息开关查询和未读消息统计功能
- 实现异步任务监听器,支持订单完成通知和相册更新
- 添加消息去重机制和iOS/Android差异化推送支持
ChenYu 7 kuukautta sitten
vanhempi
sitoutus
2b045de645

+ 12 - 0
pom.xml

@@ -185,6 +185,18 @@
             <artifactId>google-cloud-storage</artifactId>
             <version>${google.cloud.storage.version}</version>
         </dependency>
+
+        <dependency>
+            <groupId>com.google.firebase</groupId>
+            <artifactId>firebase-admin</artifactId>
+            <version>9.2.0</version>
+            <exclusions>
+                <exclusion>
+                    <groupId>com.google.auth</groupId>
+                    <artifactId>google-auth-library-oauth2-http</artifactId>
+                </exclusion>
+            </exclusions>
+        </dependency>
     </dependencies>
 
     <build>

+ 107 - 37
src/main/java/com/aitori/listener/WorkflowTaskMessageListener.java

@@ -2,9 +2,12 @@ package com.aitori.listener;
 
 import com.aitori.entity.TAiAlbum;
 import com.aitori.entity.TAiOrderV2;
+import com.aitori.entity.TAiStyle;
 import com.aitori.entity.message.WorkflowTaskMessage;
 import com.aitori.service.TAiAlbumService;
 import com.aitori.service.TAiOrderV2Service;
+import com.aitori.service.TAiStyleService;
+import com.aitori.service.TUserMessageService;
 import com.aitori.utils.VideoThumbnailUtil;
 import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
 import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
@@ -12,6 +15,7 @@ import com.fasterxml.jackson.core.JsonProcessingException;
 import com.fasterxml.jackson.databind.ObjectMapper;
 import lombok.RequiredArgsConstructor;
 import lombok.extern.slf4j.Slf4j;
+import org.springframework.core.task.AsyncTaskExecutor;
 import org.springframework.data.redis.connection.Message;
 import org.springframework.data.redis.connection.MessageListener;
 import org.springframework.stereotype.Component;
@@ -28,10 +32,14 @@ import java.util.Map;
 public class WorkflowTaskMessageListener implements MessageListener {
 
     private static final String STATUS_SUCCESS = "successes";
+    private static final int MSG_TYPE_ORDER_V2_FINISH = 11;
 
     private final TAiOrderV2Service aiOrderV2Service;
     private final TAiAlbumService aiAlbumService;
+    private final TAiStyleService aiStyleService;
+    private final TUserMessageService userMessageService;
     private final ObjectMapper objectMapper;
+    private final AsyncTaskExecutor asyncTaskExecutor;
 
     @Override
     public void onMessage(Message message, byte[] pattern) {
@@ -39,14 +47,21 @@ public class WorkflowTaskMessageListener implements MessageListener {
         String body = new String(message.getBody());
         log.info("收到 Redis 订阅消息, channel: {}, body: {}", channel, body);
 
+        WorkflowTaskMessage taskMessage;
         try {
-            WorkflowTaskMessage taskMessage = objectMapper.readValue(body, WorkflowTaskMessage.class);
-            handleTaskMessage(taskMessage);
+            taskMessage = objectMapper.readValue(body, WorkflowTaskMessage.class);
         } catch (JsonProcessingException e) {
             log.error("解析 WorkflowTask 消息失败, body: {}", body, e);
-        } catch (Exception e) {
-            log.error("处理 WorkflowTask 消息异常, body: {}", body, e);
+            return;
         }
+
+        asyncTaskExecutor.execute(() -> {
+            try {
+                handleTaskMessage(taskMessage);
+            } catch (Exception e) {
+                log.error("处理 WorkflowTask 消息异常, taskId: {}", taskMessage.getTaskId(), e);
+            }
+        });
     }
 
     private void handleTaskMessage(WorkflowTaskMessage taskMessage) {
@@ -64,52 +79,55 @@ public class WorkflowTaskMessageListener implements MessageListener {
             return;
         }
 
-        LambdaUpdateWrapper<TAiOrderV2> updateWrapper = new LambdaUpdateWrapper<>();
-        updateWrapper.eq(TAiOrderV2::getTaskId, taskId);
+        if (order.getTaskStatus() != null && (order.getTaskStatus() == 2 || order.getTaskStatus() == 3)) {
+            log.info("订单已处理, taskId={}, orderId={}, taskStatus={}, 跳过重复处理",
+                    taskId, order.getId(), order.getTaskStatus());
+            return;
+        }
 
         if (STATUS_SUCCESS.equals(taskMessage.getStatus())) {
-            String resultJson = buildResultJson(taskMessage.getData());
-            updateWrapper.set(TAiOrderV2::getTaskStatus, 2)
-                    .set(TAiOrderV2::getResult, resultJson)
-                    .set(TAiOrderV2::getErrorMessage, null);
-            aiOrderV2Service.update(updateWrapper);
-            log.info("任务完成, taskId={}, orderId={}", taskId, order.getId());
-
-            updateAlbumIfExists(order.getId(), taskMessage.getData());
+            handleSuccess(taskId, order, taskMessage.getData());
         } else {
-            updateWrapper.set(TAiOrderV2::getTaskStatus, 3)
-                    .set(TAiOrderV2::getErrorMessage, taskMessage.getMessage());
-            aiOrderV2Service.update(updateWrapper);
-            log.info("任务失败, taskId={}, orderId={}, status={}, message={}",
+            aiOrderV2Service.refundOrder(order, taskMessage.getMessage());
+            failAlbumIfExists(order.getId());
+            log.info("任务失败已退款, taskId={}, orderId={}, status={}, message={}",
                     taskId, order.getId(), taskMessage.getStatus(), taskMessage.getMessage());
         }
     }
 
+    private void handleSuccess(String taskId, TAiOrderV2 order, WorkflowTaskMessage.TaskData data) {
+        String imagesArrayJson = buildImagesArrayJson(data != null ? data.getImages() : null);
+        String videosArrayJson = buildVideosArrayJson(data != null ? data.getVideos() : null);
+
+        String resultJson = assembleResultJson(imagesArrayJson, videosArrayJson);
+        LambdaUpdateWrapper<TAiOrderV2> updateWrapper = new LambdaUpdateWrapper<>();
+        updateWrapper.eq(TAiOrderV2::getTaskId, taskId)
+                .set(TAiOrderV2::getTaskStatus, 2)
+                .set(TAiOrderV2::getResult, resultJson)
+                .set(TAiOrderV2::getErrorMessage, null);
+        aiOrderV2Service.update(updateWrapper);
+        log.info("任务完成, taskId={}, orderId={}", taskId, order.getId());
+
+        TAiAlbum album = updateAlbumIfExists(order.getId(), imagesArrayJson, videosArrayJson);
+        notifyUser(order, album);
+    }
+
     /**
-     * 构建 result JSON,格式:
+     * 组装 result JSON,格式:
      * images → {"images":"[\"url1\",\"url2\"]"}
      * video  → {"videos":"[{\"url\":\"...\",\"thumbnailUrl\":\"...\"}]"}
      */
-    private String buildResultJson(WorkflowTaskMessage.TaskData data) {
-        if (data == null) {
-            return null;
-        }
-
+    private String assembleResultJson(String imagesArrayJson, String videosArrayJson) {
         Map<String, String> result = new LinkedHashMap<>();
-        String imagesArrayJson = buildImagesArrayJson(data.getImages());
-        String videosArrayJson = buildVideosArrayJson(data.getVideos());
-
         if (imagesArrayJson != null) {
             result.put("images", imagesArrayJson);
         }
         if (videosArrayJson != null) {
             result.put("videos", videosArrayJson);
         }
-
         if (result.isEmpty()) {
             return null;
         }
-
         try {
             return objectMapper.writeValueAsString(result);
         } catch (JsonProcessingException e) {
@@ -159,11 +177,11 @@ public class WorkflowTaskMessageListener implements MessageListener {
     }
 
     /**
-     * 根据 orderId 查找相册,若存在则将 images/video 数据更新到相册的 pictures/videos 字段
+     * 根据 orderId 查找相册,若存在则将预计算好的 images/videos JSON 更新到相册的 pictures/videos 字段
      */
-    private void updateAlbumIfExists(String orderId, WorkflowTaskMessage.TaskData data) {
-        if (orderId == null || data == null) {
-            return;
+    private TAiAlbum updateAlbumIfExists(String orderId, String imagesArrayJson, String videosArrayJson) {
+        if (orderId == null) {
+            return null;
         }
 
         TAiAlbum album = aiAlbumService.getOne(
@@ -171,7 +189,7 @@ public class WorkflowTaskMessageListener implements MessageListener {
         );
         if (album == null) {
             log.info("orderId={} 无关联相册, 跳过相册更新", orderId);
-            return;
+            return null;
         }
 
         LambdaUpdateWrapper<TAiAlbum> albumUpdate = new LambdaUpdateWrapper<>();
@@ -179,13 +197,10 @@ public class WorkflowTaskMessageListener implements MessageListener {
 
         boolean needUpdate = false;
 
-        String imagesArrayJson = buildImagesArrayJson(data.getImages());
         if (imagesArrayJson != null) {
             albumUpdate.set(TAiAlbum::getPictures, imagesArrayJson);
             needUpdate = true;
         }
-
-        String videosArrayJson = buildVideosArrayJson(data.getVideos());
         if (videosArrayJson != null) {
             albumUpdate.set(TAiAlbum::getVideos, videosArrayJson);
             needUpdate = true;
@@ -196,5 +211,60 @@ public class WorkflowTaskMessageListener implements MessageListener {
             aiAlbumService.update(albumUpdate);
             log.info("相册更新成功, albumId={}, orderId={}", album.getId(), orderId);
         }
+        return album;
+    }
+
+    private void failAlbumIfExists(String orderId) {
+        if (orderId == null) {
+            return;
+        }
+        TAiAlbum album = aiAlbumService.getOne(
+                new LambdaQueryWrapper<TAiAlbum>().eq(TAiAlbum::getOrderId, orderId)
+        );
+        if (album == null) {
+            return;
+        }
+        LambdaUpdateWrapper<TAiAlbum> albumUpdate = new LambdaUpdateWrapper<>();
+        albumUpdate.eq(TAiAlbum::getId, album.getId())
+                .set(TAiAlbum::getStatus, 0);
+        aiAlbumService.update(albumUpdate);
+        log.info("相册标记失败, albumId={}, orderId={}", album.getId(), orderId);
+    }
+
+    private void notifyUser(TAiOrderV2 order, TAiAlbum album) {
+        if (order.getUserId() == null || order.getStyleId() == null) {
+            log.warn("订单缺少 userId 或 styleId, 跳过消息通知, orderId={}", order.getId());
+            return;
+        }
+
+        TAiStyle style = aiStyleService.getById(order.getStyleId());
+        if (style == null) {
+            log.warn("未找到 styleId={} 对应的风格, 跳过消息通知", order.getStyleId());
+            return;
+        }
+
+        String styleName = style.getName();
+        log.info("styleName:{}", styleName);
+
+        String title;
+        String content = switch (order.getOrderLang() != null ? order.getOrderLang() : "") {
+            case "zh_CN" -> {
+                title = "图像生成已完成";
+                yield String.format("「%s」中所述修改相应参数的值。现在可以从生成的相册中查看和下载图像。", styleName);
+            }
+            case "en_US" -> {
+                title = "Image creation completed";
+                yield String.format("「%s」 An image was generated. You can view and download images from generated albums.", styleName);
+            }
+            default -> {
+                title = "画像生成は完了しました";
+                yield String.format("「%s」の画像が生成されました。生成されたアルバムから画像を表示およびダウンロードできるようになりました。", styleName);
+            }
+        };
+
+        Integer albumId = album != null ? album.getId().intValue() : null;
+        log.info("发送消息给用户:{}, orderLang:{}", order.getUserId(), order.getOrderLang());
+        userMessageService.sendMessage(order.getUserId(), title, content, order.getId(),
+                MSG_TYPE_ORDER_V2_FINISH, albumId, style.getCoverImg());
     }
 }

+ 5 - 0
src/main/java/com/aitori/mapper/TUserMapper.java

@@ -2,6 +2,11 @@ package com.aitori.mapper;
 
 import com.aitori.entity.TUser;
 import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import org.apache.ibatis.annotations.Param;
+import org.apache.ibatis.annotations.Select;
 
 public interface TUserMapper extends BaseMapper<TUser> {
+
+    @Select("SELECT msg_switch FROM t_user WHERE id = #{userId}")
+    int selectMsgSwitch(@Param("userId") long userId);
 }

+ 6 - 0
src/main/java/com/aitori/mapper/TUserMessageMapper.java

@@ -7,7 +7,13 @@ import com.baomidou.mybatisplus.core.mapper.BaseMapper;
 import com.baomidou.mybatisplus.core.metadata.IPage;
 import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
 import org.apache.ibatis.annotations.Param;
+import org.apache.ibatis.annotations.Select;
 
 public interface TUserMessageMapper extends BaseMapper<TUserMessage> {
     IPage<UserMessageVO> selectMessageVO(Page<UserMessageVO> page, @Param("req") UserMessageQueryRequest req);
+
+    int selectMsgCount(@Param("userId") long userId, @Param("type") Integer type, @Param("orderId") String orderId);
+
+    @Select("SELECT count(1) FROM t_user_message WHERE user_id = #{userId} AND `status` = 1 AND read_status = 0")
+    int selectUnread(@Param("userId") long userId);
 }

+ 2 - 0
src/main/java/com/aitori/service/TAiOrderV2Service.java

@@ -9,4 +9,6 @@ import com.baomidou.mybatisplus.extension.service.IService;
 
 public interface TAiOrderV2Service extends IService<TAiOrderV2> {
     IPage<AiOrderV2VO> getOrderVOPage(Page<AiOrderV2VO> page, AiOrderV2QueryRequest request);
+
+    void refundOrder(TAiOrderV2 order, String errorMessage);
 }

+ 19 - 0
src/main/java/com/aitori/service/TUserMessageService.java

@@ -8,5 +8,24 @@ import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
 import com.baomidou.mybatisplus.extension.service.IService;
 
 public interface TUserMessageService extends IService<TUserMessage> {
+
+    /**
+     * 获取消息列表
+     * @param page 分页对象
+     * @param request 查询条件
+     * @return 消息列表
+     */
     IPage<UserMessageVO> getMessageVOPage(Page<UserMessageVO> page, UserMessageQueryRequest request);
+
+    /**
+     * 发送消息
+     * @param userId 用户id
+     * @param title 标题
+     * @param content 内容
+     * @param orderId 订单id
+     * @param messageType 消息类型
+     * @param albumId 相册id
+     * @param thumbUrl 缩略图
+     */
+    void sendMessage(Integer userId, String title, String content, String orderId, int messageType, Integer albumId, String thumbUrl);
 }

+ 46 - 0
src/main/java/com/aitori/service/impl/TAiOrderV2ServiceImpl.java

@@ -1,19 +1,65 @@
 package com.aitori.service.impl;
 
 import com.aitori.entity.TAiOrderV2;
+import com.aitori.entity.TUserPointsRecord;
 import com.aitori.entity.request.AiOrderV2QueryRequest;
 import com.aitori.entity.vo.AiOrderV2VO;
 import com.aitori.mapper.TAiOrderV2Mapper;
 import com.aitori.service.TAiOrderV2Service;
+import com.aitori.service.TUserPointsRecordService;
+import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
 import com.baomidou.mybatisplus.core.metadata.IPage;
 import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
 import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
+
+import cn.hutool.core.date.DateField;
+import cn.hutool.core.date.DateUtil;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
 import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import java.util.Date;
 
+@Slf4j
 @Service
+@RequiredArgsConstructor
 public class TAiOrderV2ServiceImpl extends ServiceImpl<TAiOrderV2Mapper, TAiOrderV2> implements TAiOrderV2Service {
+
+    private static final int POINTS_SOURCE_REFUND = 12;
+
+    private final TUserPointsRecordService userPointsRecordService;
+
     @Override
     public IPage<AiOrderV2VO> getOrderVOPage(Page<AiOrderV2VO> page, AiOrderV2QueryRequest request) {
         return baseMapper.selectOrderVO(page, request);
     }
+
+    @Override
+    @Transactional(rollbackFor = Exception.class)
+    public void refundOrder(TAiOrderV2 order, String errorMessage) {
+        LambdaUpdateWrapper<TAiOrderV2> updateWrapper = new LambdaUpdateWrapper<>();
+        updateWrapper.eq(TAiOrderV2::getId, order.getId())
+                .set(TAiOrderV2::getTaskStatus, 3)
+                .set(TAiOrderV2::getStatus, 0)
+                .set(TAiOrderV2::getErrorMessage, errorMessage);
+        update(updateWrapper);
+
+        if (order.getUserId() == null) {
+            log.warn("订单缺少 userId, 跳过积分退款, orderId={}", order.getId());
+            return;
+        }
+
+        if (order.getPayPoints() != null && order.getPayPoints() > 0) {
+            TUserPointsRecord refundRecord = new TUserPointsRecord();
+            refundRecord.setUserId(Long.valueOf(order.getUserId()));
+            refundRecord.setPoints(order.getPayPoints());
+            refundRecord.setSource(POINTS_SOURCE_REFUND);
+            refundRecord.setExtParam(order.getId());
+            refundRecord.setExpireTime(DateUtil.offset(new Date(), DateField.MONTH, 6).toLocalDateTime());
+            userPointsRecordService.save(refundRecord);
+            log.info("积分退款成功, orderId={}, userId={}, points={}",
+                    order.getId(), order.getUserId(), order.getPayPoints());
+        }
+    }
 }

+ 42 - 0
src/main/java/com/aitori/service/impl/TUserMessageServiceImpl.java

@@ -3,18 +3,60 @@ package com.aitori.service.impl;
 import com.aitori.entity.TUserMessage;
 import com.aitori.entity.request.UserMessageQueryRequest;
 import com.aitori.entity.vo.UserMessageVO;
+import com.aitori.mapper.TUserMapper;
 import com.aitori.mapper.TUserMessageMapper;
 import com.aitori.service.TUserMessageService;
+import com.aitori.utils.FirebaseUtil;
 import com.baomidou.mybatisplus.core.metadata.IPage;
 import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
 import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
+import lombok.RequiredArgsConstructor;
 import org.springframework.stereotype.Service;
 
+import java.time.LocalDateTime;
+import java.util.Date;
+
+import static com.aitori.utils.FirebaseUtil.COLLAPSEKEY_SYSTEM;
+
 @Service
+@RequiredArgsConstructor
 public class TUserMessageServiceImpl extends ServiceImpl<TUserMessageMapper, TUserMessage> implements TUserMessageService {
 
+
+    private final TUserMapper tUserMapper;
+
     @Override
     public IPage<UserMessageVO> getMessageVOPage(Page<UserMessageVO> page, UserMessageQueryRequest request) {
         return baseMapper.selectMessageVO(page, request);
     }
+
+    @Override
+    public void sendMessage(Integer userId, String title, String content, String orderId, int messageType, Integer albumId, String thumbUrl) {
+        int i = baseMapper.selectMsgCount(userId, messageType, orderId);
+        if (i > 0) {
+            return;
+        }
+        TUserMessage message = generateMessage(userId, messageType, title, content, orderId, albumId, thumbUrl);
+        save(message);
+        int msgSwitch = tUserMapper.selectMsgSwitch(userId);
+        int unread = baseMapper.selectUnread(userId);
+        if (msgSwitch == 1) {
+            FirebaseUtil.sendMsgByIOS(COLLAPSEKEY_SYSTEM, String.valueOf(userId), title, content, unread);
+        }
+    }
+
+    private TUserMessage generateMessage(Integer userId, int messageType, String title, String content, String orderId, Integer albumId, String thumbUrl) {
+        TUserMessage message = new TUserMessage();
+        message.setUserId(userId);
+        message.setTitle(title);
+        message.setContent(content);
+        message.setStatus(1);
+        message.setReadStatus(0);
+        message.setType(messageType);
+        message.setOrderId(orderId);
+        message.setCreateTime(LocalDateTime.now());
+        message.setThumbUrl(thumbUrl);
+        message.setAlbumId(albumId);
+        return message;
+    }
 }

+ 350 - 0
src/main/java/com/aitori/utils/FirebaseUtil.java

@@ -0,0 +1,350 @@
+package com.aitori.utils;
+
+
+import com.google.firebase.auth.*;
+import com.google.firebase.messaging.*;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.util.StringUtils;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.ExecutionException;
+
+@Slf4j
+public class FirebaseUtil {
+
+    private static final FirebaseAuth firebaseAuth = FirebaseAuth.getInstance();
+
+    /**
+     * 系统标识符
+     */
+    public static final String COLLAPSEKEY_SYSTEM = "collApseKey_system";
+
+    /**
+     * 系统主题
+     */
+    public static final String TOPIC_SYSTEM = "topic_system";
+
+    /**
+     * 功能描述:
+     * <根据uid查找firebase用户>
+     *
+     */
+    public static UserRecord getUserByUid(Long uid) throws FirebaseAuthException {
+        return firebaseAuth.getUser(uid.toString());
+    }
+
+    /**
+     * 功能描述:
+     * <根据email查找firebase用户>
+     *
+     */
+    public static UserRecord getUserByEmail(String email) throws FirebaseAuthException {
+        return firebaseAuth.getUserByEmail(email);
+    }
+
+    /**
+     * 功能描述:
+     * <查询firebase全部用户>
+     *
+     */
+    public static List<UserRecord> findAllUser() throws FirebaseAuthException {
+        List<UserRecord> userRecords = new ArrayList<>();
+        ListUsersPage page = firebaseAuth.listUsers(null);
+        while (page != null) {
+            for (ExportedUserRecord user : page.getValues()) {
+                userRecords.add(user);
+            }
+            page = page.getNextPage();
+        }
+        return userRecords;
+    }
+
+
+    /**
+     * 功能描述:
+     * <创建firebase用户>
+     *
+     */
+    public static UserRecord createUser(Long uid) throws FirebaseAuthException {
+        UserRecord.CreateRequest createRequest = new UserRecord.CreateRequest()
+                .setUid(uid.toString());
+        return firebaseAuth.createUser(createRequest);
+    }
+
+    public static UserRecord updateUser(Long uid, String userName, String email, String avatar) throws FirebaseAuthException {
+
+        UserRecord.UpdateRequest request = new UserRecord.UpdateRequest(uid.toString());
+        if (!StringUtils.isEmpty(userName)) {
+            request.setDisplayName(userName);
+        }
+        if (!StringUtils.isEmpty(avatar)) {
+            request.setPhotoUrl(avatar);
+        }
+        if (!StringUtils.isEmpty(email)) {
+            request.setEmail(email);
+        }
+        request.setDisabled(false)
+                .setEmailVerified(false);
+
+        return firebaseAuth.updateUser(request);
+    }
+
+    /**
+     * 功能描述:
+     * <删除firebase用户>
+     *
+     */
+    public static void deleteUser(Long uid) throws FirebaseAuthException {
+        firebaseAuth.deleteUser(uid.toString());
+    }
+
+
+    /**
+     * 通用消息推送  单条消息  向单个用户推送
+     *
+     * @param registrationToken 用户设备令牌
+     * @param collapseKey       消息标识符
+     * @param datas             额外参数 k-v
+     */
+    public static String sendMsgToToken(String title, String body, String registrationToken, String collapseKey, Map<String, String> datas) {
+        Message message = getBuilder(title, body, collapseKey, datas)
+                .setToken(registrationToken)
+                .build();
+        return send(message);
+    }
+
+    /**
+     * 通用消息推送  向多个用户推送
+     *
+     * @param registrationTokens 用户设备令牌
+     * @param collapseKey        消息标识符
+     * @param datas              额外参数 k-v
+     */
+    public static void sendMsgToTokens(String title, String body, List<String> registrationTokens, String collapseKey, Map<String, String> datas) throws FirebaseMessagingException {
+        MulticastMessage message = getMulticastBuilder(title, body, collapseKey, datas)
+                .addAllTokens(registrationTokens)
+                .build();
+        BatchResponse response = FirebaseMessaging.getInstance().sendMulticast(message);
+        if (response.getFailureCount() > 0) {
+            List<SendResponse> responses = response.getResponses();
+            List<String> failedTokens = new ArrayList<>();
+            for (int i = 0; i < responses.size(); i++) {
+                if (!responses.get(i).isSuccessful()) {
+                    // The order of responses corresponds to the order of the registration tokens.
+                    failedTokens.add(registrationTokens.get(i));
+                }
+            }
+
+            log.info("List of tokens that caused failures: " + failedTokens);
+        }
+    }
+
+    /**
+     * 通用消息推送  主题消息  订阅该主题的用户全部可以接收到
+     *
+     * @param collapseKey 一组消息的标识符,这组消息可以折叠,以便当恢复传送时只发送最后一条消息。在任意给定时间最多允许 4 个不同的折叠键。
+     * @param topic       要将消息发送至的主题名称。该主题名称不能包含 /topics/ 前缀。
+     * @param datas       额外参数 k-v
+     */
+    /*----------------------------------关于anps的headers配置说明---------------------------------------*/
+/*        authorization
+
+       授权APN发送指定主题的推送通知的提供者令牌。令牌采用Base64URL编码的JWT格式,指定为bearer <provider token>。
+
+        使用提供程序证书建立连接时,将忽略此请求标头。
+
+        apns-id
+
+        标识通知的规范UUID。如果发送通知时出错,则APN使用此值来标识通知您的服务器。
+
+        规范形式是32个小写十六进制数字,显示在由8-4-4-4-12形式的连字符分隔的五个组中。示例UUID如下:
+
+        123e4567-e89b-12d3-a456-42665544000
+
+        如果省略此标头,则APN会创建一个新的UUID并在响应中返回。
+
+        apns-expiration
+
+        以星期(UTC)表示的UNIX纪元日期。此标头标识通知不再有效的日期,可以将其丢弃。
+
+        如果此值非零,则APN会存储通知并尝试至少传递一次,如果第一次无法传递通知,则会根据需要重复尝试。如果值为0,则APN将通知视为立即过期,并且不存储通知或尝试重新发送通知。
+
+        apns-priority
+
+        通知的优先级。指定以下值之一:
+
+        10 - 立即发送推送消息。具有此优先级的通知必须在目标设备上触发警报,声音或徽章。将此优先级用于仅包含content-available密钥的推送通知是错误的。
+
+        5 - 在考虑设备功率考虑因素的时间发送推送消息。具有此优先级的通知可能会分组并以突发方式传递。它们受到限制,在某些情况下不会被交付。
+
+        如果省略此标头,则APNs服务器将优先级设置为10。
+
+        apns-topic
+
+        远程通知的主题,通常是应用程序的软件包ID。您在开发人员帐户中创建的证书必须包含此主题的功能。
+
+        如果您的证书包含多个主题,则必须为此标题指定值。
+
+        如果省略此请求标头,并且您的APNs证书未指定多个主题,则APNs服务器将证书的主题用作默认主题。
+
+        如果使用提供者令牌而不是证书,则必须为此请求标头指定值。您应该为开发人员帐户中指定的团队配置您提供的主题。
+
+        apns-collapse-id
+
+        具有相同折叠标识符的多个通知作为单个通知显示给用户。该密钥的值不得超过64个字节。有关更多信息,请参阅服务质量,存储转发和合并通知。*/
+    public static void sendMsg(String collapseKey, String topic, String title, String body, Map<String, String> datas) {
+        Message message = getBuilder(title, body, collapseKey, datas)
+                .setTopic(topic)
+                .build();
+        send(message);
+    }
+
+    /**
+     * 安卓消息推送  主题订阅模式
+     *
+     * @param collapseKey 一组消息的标识符,这组消息可以折叠,以便当恢复传送时只发送最后一条消息。在任意给定时间最多允许 4 个不同的折叠键。
+     * @param topic       要将消息发送至的主题名称。该主题名称不能包含 /topics/ 前缀。
+     */
+    public static void sendMsgByAndroid(String collapseKey, String topic, String title, String body) {
+        Message message = Message.builder()
+                .setAndroidConfig(AndroidConfig.builder()
+                        .setTtl(3600 * 1000)//消息的生存时间
+                        .setCollapseKey(collapseKey)
+                        .setPriority(AndroidConfig.Priority.HIGH)//消息优先级。必须是 normal 和 high 中的一个。
+                        .setNotification(AndroidNotification.builder()
+                                .setTitle(title)
+                                .setBody(body)
+                                .build())
+                        .build())
+                .setTopic(topic)
+                .build();
+        send(message);
+    }
+
+    private static String send(Message message) {
+        String response = null;
+        try {
+            response = FirebaseMessaging.getInstance().sendAsync(message).get();
+            System.out.println("run successful: " + response);
+        } catch (InterruptedException e) {
+            e.printStackTrace();
+        } catch (ExecutionException e) {
+            e.printStackTrace();
+        }
+        return response;
+    }
+
+    /**
+     * IOS推送  主题订阅模式
+     *
+     * @param collapseKey 一组消息的标识符,这组消息可以折叠,以便当恢复传送时只发送最后一条消息。在任意给定时间最多允许 4 个不同的折叠键。
+     * @param topic       要将消息发送至的主题名称。该主题名称不能包含 /topics/ 前缀。
+     */
+    public static void sendMsgByIOS(String collapseKey, String topic, String title, String body,int qty) {
+        Message message = Message.builder()
+                .setApnsConfig(ApnsConfig.builder()
+                        .putHeader("apns-priority", "10")
+                        .putHeader("apns-collapse-id", collapseKey)
+                        .setAps(Aps.builder()
+                                .setAlert(ApsAlert.builder()
+                                        .setTitle(title)
+                                        .setBody(body)
+                                        .build())
+                                .setBadge(qty)
+                                .build())
+                        .build())
+                .setTopic(topic)
+                .build();
+        send(message);
+    }
+
+    /**
+     * 功能描述:
+     * <创建主题订阅>
+     *
+     */
+    public static void subscribeToTopic(List<String> registrationTokens, String topic) throws
+            FirebaseMessagingException {
+        List<List<String>> lists = splitList(registrationTokens, 1000);
+        for (List<String> tokens : lists) {
+            TopicManagementResponse response = FirebaseMessaging.getInstance().subscribeToTopic(tokens, topic);
+            log.info(response.getSuccessCount() + " tokens were subscribed successfully");
+        }
+    }
+
+    /**
+     * 功能描述:
+     * <退订主题订阅>
+     *
+     */
+    public static void unsubscribeFromTopic(List<String> registrationTokens, String topic) throws
+            FirebaseMessagingException {
+        List<List<String>> lists = splitList(registrationTokens, 1000);
+        for (List<String> tokens : lists) {
+            TopicManagementResponse response = FirebaseMessaging.getInstance().unsubscribeFromTopic(
+                    tokens, topic);
+            // See the TopicManagementResponse reference documentation
+            // for the contents of response.
+            log.info(response.getSuccessCount() + " tokens were unsubscribed successfully");
+        }
+    }
+
+    private static List<List<String>> splitList(List<String> list, int groupSize) {
+        int length = list.size();
+        // 计算可以分成多少组
+        int num = (length + groupSize - 1) / groupSize; // TODO
+        List<List<String>> newList = new ArrayList<>(num);
+        for (int i = 0; i < num; i++) {
+            // 开始位置
+            int fromIndex = i * groupSize;
+            // 结束位置
+            int toIndex = Math.min((i + 1) * groupSize, length);
+            newList.add(list.subList(fromIndex, toIndex));
+        }
+        return newList;
+    }
+
+    private static Message.Builder getBuilder(String title, String body, String collapseKey, Map<String, String> map) {
+        return Message.builder()
+                .setNotification(Notification.builder().setTitle(title).setBody(body).build())
+                .setAndroidConfig(AndroidConfig.builder()
+                        .setTtl(3600 * 1000)
+                        .setCollapseKey(collapseKey)
+                        .setPriority(AndroidConfig.Priority.HIGH)
+                        .setNotification(AndroidNotification.builder()
+                                .setTitle(title)
+                                .setBody(body)
+                                .build())
+                        .build())
+                .setApnsConfig(ApnsConfig.builder()
+                        .putHeader("apns-priority", "10")
+                        .putHeader("apns-collapse-id", collapseKey)
+                        .setAps(Aps.builder()
+                                .setBadge(42)
+                                .build())
+                        .build()).putAllData(map);
+    }
+
+    private static MulticastMessage.Builder getMulticastBuilder(String title, String body, String collapseKey, Map<String, String> map) {
+        return MulticastMessage.builder()
+                .setNotification(Notification.builder().setTitle(title).setBody(body).build())
+                .setAndroidConfig(AndroidConfig.builder()
+                        .setTtl(3600 * 1000)
+                        .setCollapseKey(collapseKey)
+                        .setPriority(AndroidConfig.Priority.HIGH)
+                        .setNotification(AndroidNotification.builder()
+                                .setTitle(title)
+                                .setBody(body)
+                                .build())
+                        .build())
+                .setApnsConfig(ApnsConfig.builder()
+                        .putHeader("apns-priority", "10")
+                        .putHeader("apns-collapse-id", collapseKey)
+                        .setAps(Aps.builder()
+                                .setBadge(42)
+                                .build())
+                        .build()).putAllData(map);
+    }
+}

+ 18 - 0
src/main/resources/mapper/TUserMessageMapper.xml

@@ -32,5 +32,23 @@
         </where>
         ORDER BY m.create_time DESC
     </select>
+    <select id="selectMsgCount" resultType="java.lang.Integer">
+        select count(1)
+        from t_user_message
+        where user_id = #{userId}
+        and order_id = #{orderId}
+        <if test="type != null">
+            <choose>
+                <!-- type=1/3 → 同时匹配 1 或 3 -->
+                <when test="type in (1, 3)">
+                    and type in (1, 3)
+                </when>
+                <!-- type=2/4/10/11 → 只匹配自身 -->
+                <when test="type in (2, 4, 10, 11)">
+                    and type = #{type}
+                </when>
+            </choose>
+        </if>
+    </select>
 
 </mapper>