Sfoglia il codice sorgente

个人设置,调整通知发送代码

zhangchen 1 settimana fa
parent
commit
4bce71fb69

+ 60 - 0
alien-entity/src/main/java/shop/alien/entity/store/LifeUserPushDevice.java

@@ -0,0 +1,60 @@
+package shop.alien.entity.store;
+
+import com.baomidou.mybatisplus.annotation.IdType;
+import com.baomidou.mybatisplus.annotation.TableField;
+import com.baomidou.mybatisplus.annotation.TableId;
+import com.baomidou.mybatisplus.annotation.TableName;
+import com.fasterxml.jackson.annotation.JsonFormat;
+import com.fasterxml.jackson.annotation.JsonInclude;
+import io.swagger.annotations.ApiModel;
+import io.swagger.annotations.ApiModelProperty;
+import lombok.Data;
+
+import java.io.Serializable;
+import java.util.Date;
+
+/**
+ * 推送设备 cid 绑定;{@link #userId} 含义由 {@link #ownerType} 决定。
+ */
+@Data
+@JsonInclude
+@TableName("life_user_push_device")
+@ApiModel(value = "LifeUserPushDevice", description = "用户推送设备绑定")
+public class LifeUserPushDevice implements Serializable {
+
+    private static final long serialVersionUID = 1L;
+
+    @ApiModelProperty(value = "主键")
+    @TableId(value = "id", type = IdType.AUTO)
+    private Integer id;
+
+    @ApiModelProperty(value = "业务用户主键:owner_type=user 时为 life_user.id;store 为 store_user.id;lawyer 为 lawyer_user.id")
+    @TableField("user_id")
+    private Integer userId;
+
+    @ApiModelProperty(value = "归属类型:user / store / lawyer(与 JWT userType 一致)")
+    @TableField("owner_type")
+    private String ownerType;
+
+    @ApiModelProperty(value = "uni-push client id")
+    @TableField("push_client_id")
+    private String pushClientId;
+
+    @ApiModelProperty(value = "平台:ios、android 等")
+    @TableField("platform")
+    private String platform;
+
+    @ApiModelProperty(value = "DCloud appid,如 __UNI__xxxx")
+    @TableField("dcloud_app_id")
+    private String dcloudAppId;
+
+    @ApiModelProperty(value = "创建时间")
+    @TableField("created_time")
+    @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8")
+    private Date createdTime;
+
+    @ApiModelProperty(value = "更新时间")
+    @TableField("updated_time")
+    @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8")
+    private Date updatedTime;
+}

+ 60 - 0
alien-entity/src/main/java/shop/alien/entity/store/PushDeviceOwnerType.java

@@ -0,0 +1,60 @@
+package shop.alien.entity.store;
+
+/**
+ * 推送设备绑定归属类型,与 JWT 中 userType 及表 life_user_push_device.owner_type 一致。
+ * <ul>
+ *   <li>{@link #USER} — C 端 life_user.id</li>
+ *   <li>{@link #STORE} — 门店 store_user.id</li>
+ *   <li>{@link #LAWYER} — 律师 lawyer_user.id</li>
+ * </ul>
+ */
+public enum PushDeviceOwnerType {
+
+    USER("user"),
+    STORE("store"),
+    LAWYER("lawyer");
+
+    private final String code;
+
+    PushDeviceOwnerType(String code) {
+        this.code = code;
+    }
+
+    public String getCode() {
+        return code;
+    }
+
+    /**
+     * 根据登录 token 中的 userType 解析;不支持的类型返回 null。
+     */
+    public static PushDeviceOwnerType fromJwtUserType(String jwtUserType) {
+        if (jwtUserType == null || jwtUserType.trim().isEmpty()) {
+            return USER;
+        }
+        String t = jwtUserType.trim().toLowerCase();
+        switch (t) {
+            case "user":
+                return USER;
+            case "store":
+            case "merchant":
+                return STORE;
+            case "lawyer":
+                return LAWYER;
+            default:
+                return null;
+        }
+    }
+
+    public static PushDeviceOwnerType fromCode(String code) {
+        if (code == null || code.trim().isEmpty()) {
+            return null;
+        }
+        String c = code.trim().toLowerCase();
+        for (PushDeviceOwnerType v : values()) {
+            if (v.code.equals(c)) {
+                return v;
+            }
+        }
+        return null;
+    }
+}

+ 7 - 0
alien-entity/src/main/java/shop/alien/mapper/LifeUserPushDeviceMapper.java

@@ -0,0 +1,7 @@
+package shop.alien.mapper;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import shop.alien.entity.store.LifeUserPushDevice;
+
+public interface LifeUserPushDeviceMapper extends BaseMapper<LifeUserPushDevice> {
+}

+ 47 - 0
alien-store/src/main/java/shop/alien/store/config/UniPushProperties.java

@@ -0,0 +1,47 @@
+package shop.alien.store.config;
+
+import lombok.Data;
+import org.springframework.boot.context.properties.ConfigurationProperties;
+import org.springframework.cloud.context.config.annotation.RefreshScope;
+import org.springframework.stereotype.Component;
+
+/**
+ * uniCloud 云函数 URL 化后,由 Java 侧发起推送的 HTTP 配置。
+ * 云函数内需使用 uni-cloud-push 的 sendMessage;请求体字段需与云函数约定一致。
+ */
+@Data
+@Component
+@RefreshScope
+@ConfigurationProperties(prefix = "alien.unipush")
+public class UniPushProperties {
+
+    /**
+     * 是否启用 HTTP 调用云函数推送(未配置 url 时 {@link shop.alien.store.service.UniPushCloudInvokeService} 会直接跳过)
+     */
+    private boolean enabled = false;
+
+    /**
+     * 云函数 URL 化完整地址,例如 https://xxx.com/push/send
+     */
+    private String cloudFunctionUrl = "";
+
+    /**
+     * 可选:鉴权请求头名,如 X-Api-Secret
+     */
+    private String authHeaderName = "";
+
+    /**
+     * 可选:鉴权请求头值
+     */
+    private String authHeaderValue = "";
+
+    /**
+     * 连接超时毫秒
+     */
+    private int connectTimeoutMs = 5000;
+
+    /**
+     * 读超时毫秒
+     */
+    private int readTimeoutMs = 10000;
+}

+ 62 - 0
alien-store/src/main/java/shop/alien/store/controller/LifeUserPushDeviceController.java

@@ -0,0 +1,62 @@
+package shop.alien.store.controller;
+
+import io.swagger.annotations.Api;
+import io.swagger.annotations.ApiImplicitParam;
+import io.swagger.annotations.ApiImplicitParams;
+import io.swagger.annotations.ApiOperation;
+import io.swagger.annotations.ApiOperationSupport;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.web.bind.annotation.*;
+import shop.alien.entity.result.R;
+import shop.alien.entity.store.LifeUserPushDevice;
+import shop.alien.entity.store.UserLoginInfo;
+import shop.alien.store.dto.LifeUserPushBindDto;
+import shop.alien.store.service.LifeUserPushDeviceService;
+import shop.alien.util.common.TokenInfo;
+import springfox.documentation.annotations.ApiIgnore;
+
+import java.util.List;
+
+/**
+ * uni-push 等设备 cid 绑定;业务主键与类型取自登录态 JWT(userId + userType),不信任客户端传参区分身份。
+ * <p>userType:user → life_user.id;store/merchant → store_user.id;lawyer → lawyer_user.id</p>
+ */
+@Api(tags = {"用户推送设备"})
+@Slf4j
+@RestController
+@CrossOrigin
+@RequestMapping("/lifeUserPushDevice")
+@RequiredArgsConstructor
+public class LifeUserPushDeviceController {
+
+    private final LifeUserPushDeviceService lifeUserPushDeviceService;
+
+    @ApiOperation("绑定当前用户与 push cid(登录后调用)")
+    @ApiOperationSupport(order = 1)
+    @PostMapping("/bind")
+    public R<String> bind(@ApiIgnore @TokenInfo UserLoginInfo userLoginInfo,
+                          @RequestBody LifeUserPushBindDto dto) {
+        log.info("LifeUserPushDeviceController.bind, userId={}",
+                userLoginInfo == null ? null : userLoginInfo.getUserId());
+        return lifeUserPushDeviceService.bind(userLoginInfo, dto);
+    }
+
+    @ApiOperation("解绑指定 cid(仅删除当前用户名下记录)")
+    @ApiOperationSupport(order = 2)
+    @ApiImplicitParams({
+            @ApiImplicitParam(name = "pushClientId", value = "cid", required = true, paramType = "query", dataType = "String")
+    })
+    @PostMapping("/unbind")
+    public R<String> unbind(@ApiIgnore @TokenInfo UserLoginInfo userLoginInfo,
+                            @RequestParam String pushClientId) {
+        return lifeUserPushDeviceService.unbindByCid(userLoginInfo, pushClientId);
+    }
+
+    @ApiOperation("当前用户已绑定的推送设备列表")
+    @ApiOperationSupport(order = 3)
+    @GetMapping("/listMine")
+    public R<List<LifeUserPushDevice>> listMine(@ApiIgnore @TokenInfo UserLoginInfo userLoginInfo) {
+        return lifeUserPushDeviceService.listMine(userLoginInfo);
+    }
+}

+ 26 - 0
alien-store/src/main/java/shop/alien/store/dto/LifeUserPushBindDto.java

@@ -0,0 +1,26 @@
+package shop.alien.store.dto;
+
+import io.swagger.annotations.ApiModel;
+import io.swagger.annotations.ApiModelProperty;
+import lombok.Data;
+
+import java.io.Serializable;
+
+/**
+ * App 上报 uni-push cid 与当前登录用户绑定
+ */
+@Data
+@ApiModel(description = "绑定推送 cid 请求体")
+public class LifeUserPushBindDto implements Serializable {
+
+    private static final long serialVersionUID = 1L;
+
+    @ApiModelProperty(value = "uni.getPushClientId 返回的 cid", required = true)
+    private String pushClientId;
+
+    @ApiModelProperty(value = "平台:ios、android、harmony 等")
+    private String platform;
+
+    @ApiModelProperty(value = "DCloud 应用 appid,如 __UNI__xxxx")
+    private String dcloudAppId;
+}

+ 108 - 14
alien-store/src/main/java/shop/alien/store/service/LifeCommentService.java

@@ -8,6 +8,7 @@ import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
 import lombok.RequiredArgsConstructor;
 import lombok.extern.slf4j.Slf4j;
 import org.springframework.beans.BeanUtils;
+import org.springframework.beans.factory.annotation.Value;
 import org.springframework.stereotype.Service;
 import org.springframework.util.CollectionUtils;
 import org.springframework.util.ObjectUtils;
@@ -36,6 +37,10 @@ import java.util.stream.Collectors;
 @RequiredArgsConstructor
 public class LifeCommentService {
 
+    /** App 点击推送后跳转页面(uni-app 页面路径) */
+    private static final String DYNAMICS_LIKE_PUSH_OPEN_PATH =
+            "pages/secondHandTransactions/pages/message/noticesAndMessage";
+
     private final LifeCommentMapper lifeCommentMapper;
 
     private final StoreCommentMapper storeCommentMapper;
@@ -64,6 +69,18 @@ public class LifeCommentService {
 
     private final LifeUserPersonalizationSettingService lifeUserPersonalizationSettingService;
 
+    private final LifeUserPushDeviceService lifeUserPushDeviceService;
+
+    private final UniPushCloudInvokeService uniPushCloudInvokeService;
+
+    private final StoreUserMapper storeUserMapper;
+
+    /**
+     * 系统app通知开关
+     */
+    @Value("${alien.unipush.enabled:false}")
+    private boolean uniPushOn;
+
     /**
      * 点赞操作
      * <p>
@@ -123,13 +140,28 @@ public class LifeCommentService {
             // 根据类型更新对应表的点赞数
             int updateResult = updateLikeCountByType(huifuId, type);
             
-            // 如果是动态类型,按点赞用户个性化设置决定是否发送通知
+            // 动态点赞:按「被点赞动态的发布者」的个性化设置决定是否通知(接收方是否愿意收点赞类通知)
             if (updateResult > 0 && CommonConstant.LIKE_TYPE_DYNAMICS.equals(type)) {
-                Integer likerLifeUserId = resolveLifeUserIdFromLikeParam(userId);
-                boolean suppressNotice = likerLifeUserId != null
-                        && lifeUserPersonalizationSettingService.shouldSuppressLikeRelatedNotice(likerLifeUserId);
+                Integer receiverLifeUserId = resolveLifeUserIdFromDynamicsAuthorPhoneId(huifuId);
+                boolean suppressNotice = receiverLifeUserId != null
+                        && lifeUserPersonalizationSettingService.shouldSuppressLikeRelatedNotice(receiverLifeUserId);
                 if (!suppressNotice) {
-                    insertNotice(userId, huifuId, type);
+                    try {
+                        insertNotice(userId, huifuId, type);
+                    } catch (Exception e) {
+                        log.error("动态点赞站内信保存失败, huifuId={}", huifuId, e);
+                    }
+
+                    // 发送系统通知开关
+                    if(uniPushOn){
+                        try {
+                            sendDynamicsLikeAppPushByDynamicsId(Integer.parseInt(huifuId.trim()));
+                        } catch (NumberFormatException e) {
+                            log.warn("动态点赞 App 推送跳过:huifuId 非合法动态 id, huifuId={}", huifuId);
+                        } catch (Exception e) {
+                            log.warn("动态点赞 App 推送失败, huifuId={}, err={}", huifuId, e.getMessage());
+                        }
+                    }
                 }
             }
             
@@ -219,15 +251,23 @@ public class LifeCommentService {
     }
 
     /**
-     * 点赞接口传入的 userId(字符串)解析为 life_user.id:纯数字按主键;user_ 前缀按手机号查用户
+     * 根据动态主键查发布者 phoneId,仅当为 C 端 user_ 前缀时解析为 life_user.id;门店动态 store_ 无 C 端个性化表则返回 null(不拦截通知)。
      */
-    private Integer resolveLifeUserIdFromLikeParam(String likeUserId) {
-        if (!StringUtils.hasText(likeUserId)) {
+    private Integer resolveLifeUserIdFromDynamicsAuthorPhoneId(String dynamicsIdStr) {
+        if (!StringUtils.hasText(dynamicsIdStr)) {
             return null;
         }
-        String trimmed = likeUserId.trim();
-        if (trimmed.startsWith("user_")) {
-            String phone = trimmed.substring("user_".length());
+        try {
+            int dynamicsId = Integer.parseInt(dynamicsIdStr.trim());
+            LifeUserDynamics dynamics = lifeUserDynamicsMapper.selectById(dynamicsId);
+            if (dynamics == null || !StringUtils.hasText(dynamics.getPhoneId())) {
+                return null;
+            }
+            String phoneId = dynamics.getPhoneId().trim();
+            if (!phoneId.startsWith("user_")) {
+                return null;
+            }
+            String phone = phoneId.substring("user_".length());
             if (!StringUtils.hasText(phone)) {
                 return null;
             }
@@ -236,9 +276,6 @@ public class LifeCommentService {
                     .eq(LifeUser::getDeleteFlag, 0)
                     .last("LIMIT 1"));
             return u != null ? u.getId() : null;
-        }
-        try {
-            return Integer.parseInt(trimmed);
         } catch (NumberFormatException e) {
             return null;
         }
@@ -262,6 +299,63 @@ public class LifeCommentService {
     }
 
     /**
+     * 仅发起 App 推送(uniCloud),与 {@link #insertNotice} 独立;未配置、无 cid 时静默返回。
+     */
+    private void sendDynamicsLikeAppPushByDynamicsId(int dynamicsId) {
+        LifeUserDynamics dynamics = lifeUserDynamicsMapper.selectById(dynamicsId);
+        sendDynamicsLikeAppPushOnly(dynamics, "动态通知", "点赞了你的动态", dynamicsId);
+    }
+
+    private void sendDynamicsLikeAppPushOnly(LifeUserDynamics dynamics, String title, String content, int dynamicsId) {
+        if (dynamics == null || !StringUtils.hasText(dynamics.getPhoneId())) {
+            return;
+        }
+        String phoneId = dynamics.getPhoneId().trim();
+        List<String> cids = resolvePushClientIdsForDynamicsAuthor(phoneId);
+        if (CollectionUtils.isEmpty(cids)) {
+            return;
+        }
+        Map<String, Object> payload = new HashMap<>(12);
+        payload.put("scene", "dynamics_like");
+        payload.put("dynamicsId", dynamicsId);
+        payload.put("noticeType", 0);
+        payload.put("path", DYNAMICS_LIKE_PUSH_OPEN_PATH);
+        uniPushCloudInvokeService.sendToClientIds(cids, title, content, payload);
+    }
+
+    private List<String> resolvePushClientIdsForDynamicsAuthor(String phoneId) {
+        if (phoneId.startsWith("user_")) {
+            String phone = phoneId.substring("user_".length());
+            if (!StringUtils.hasText(phone)) {
+                return Collections.emptyList();
+            }
+            LifeUser u = lifeUserMapper.selectOne(new LambdaQueryWrapper<LifeUser>()
+                    .eq(LifeUser::getUserPhone, phone)
+                    .eq(LifeUser::getDeleteFlag, 0)
+                    .last("LIMIT 1"));
+            if (u == null) {
+                return Collections.emptyList();
+            }
+            return lifeUserPushDeviceService.listPushClientIdsByUserId(u.getId(), PushDeviceOwnerType.USER.getCode());
+        }
+        if (phoneId.startsWith("store_")) {
+            String phone = phoneId.substring("store_".length());
+            if (!StringUtils.hasText(phone)) {
+                return Collections.emptyList();
+            }
+            StoreUser su = storeUserMapper.selectOne(new LambdaQueryWrapper<StoreUser>()
+                    .eq(StoreUser::getPhone, phone)
+                    .eq(StoreUser::getDeleteFlag, 0)
+                    .last("LIMIT 1"));
+            if (su == null) {
+                return Collections.emptyList();
+            }
+            return lifeUserPushDeviceService.listPushClientIdsByUserId(su.getId(), PushDeviceOwnerType.STORE.getCode());
+        }
+        return Collections.emptyList();
+    }
+
+    /**
      * 取消点赞操作
      * <p>
      * 检查是否已点赞,如果已点赞则删除点赞记录并更新对应表的点赞数

+ 3 - 2
alien-store/src/main/java/shop/alien/store/service/LifeUserPersonalizationSettingService.java

@@ -26,9 +26,10 @@ public interface LifeUserPersonalizationSettingService extends IService<LifeUser
     LifeUserPersonalizationSetting getByUserIdCacheAside(Integer userId);
 
     /**
-     * 是否不应发送与点赞相关的通知:notifyReceiveMessage=0,或 notifyReceiveMessage=1 且 notifyLike=0。
+     * 是否不应向该用户发送与「收到点赞」相关的通知:notifyReceiveMessage=0,或 notifyReceiveMessage=1 且 notifyLike=0。
+     * 调用方应传入接收通知的一方对应的 life_user.id(例如动态点赞场景为动态发布者,而非点赞操作人)。
      *
-     * @param userId life_user.id
+     * @param userId life_user.id(接收方)
      * @return userId 为空时 false(不拦截);有设置且满足上述条件时 true
      */
     boolean shouldSuppressLikeRelatedNotice(Integer userId);

+ 34 - 0
alien-store/src/main/java/shop/alien/store/service/LifeUserPushDeviceService.java

@@ -0,0 +1,34 @@
+package shop.alien.store.service;
+
+import shop.alien.entity.result.R;
+import shop.alien.entity.store.LifeUserPushDevice;
+import shop.alien.entity.store.UserLoginInfo;
+import shop.alien.store.dto.LifeUserPushBindDto;
+
+import java.util.List;
+
+public interface LifeUserPushDeviceService {
+
+    /**
+     * 将 cid 绑定到当前登录用户;同一 cid 全局唯一,重复上报会更新为当前用户并刷新平台等信息。
+     */
+    R<String> bind(UserLoginInfo login, LifeUserPushBindDto dto);
+
+    /**
+     * 当前用户解绑指定 cid(仅可删除本人绑定记录)
+     */
+    R<String> unbindByCid(UserLoginInfo login, String pushClientId);
+
+    /**
+     * 查询当前用户已绑定的设备列表
+     */
+    R<List<LifeUserPushDevice>> listMine(UserLoginInfo login);
+
+    /**
+     * 供业务侧按归属类型 + 业务用户主键查询 cid 列表(去重),用于组装云函数推送目标。
+     *
+     * @param userId    ownerType=user 时为 life_user.id;store 为 store_user.id;lawyer 为 lawyer_user.id
+     * @param ownerType {@link shop.alien.entity.store.PushDeviceOwnerType#getCode()}:user / store / lawyer
+     */
+    List<String> listPushClientIdsByUserId(int userId, String ownerType);
+}

+ 80 - 0
alien-store/src/main/java/shop/alien/store/service/LifeUserService.java

@@ -9,6 +9,7 @@ import com.baomidou.mybatisplus.core.toolkit.support.SFunction;
 import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
 import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
 import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
 import org.apache.commons.lang3.StringUtils;
 import org.apache.commons.lang3.tuple.Triple;
 import org.springframework.beans.factory.annotation.Autowired;
@@ -24,6 +25,8 @@ import shop.alien.entity.second.SecondRiskControlRecord;
 import shop.alien.entity.store.LifeFans;
 import shop.alien.entity.store.LifeNotice;
 import shop.alien.entity.store.LifeUser;
+import shop.alien.entity.store.PushDeviceOwnerType;
+import shop.alien.entity.store.StoreUser;
 import shop.alien.entity.store.vo.LifeMessageVo;
 import shop.alien.entity.store.vo.LifeUserVo;
 import shop.alien.entity.store.vo.WebSocketVo;
@@ -31,6 +34,7 @@ import shop.alien.mapper.LifeFansMapper;
 import shop.alien.mapper.LifeMessageMapper;
 import shop.alien.mapper.LifeNoticeMapper;
 import shop.alien.mapper.LifeUserMapper;
+import shop.alien.mapper.StoreUserMapper;
 import shop.alien.mapper.second.LifeUserLogMapper;
 import shop.alien.mapper.second.SecondRiskControlRecordMapper;
 import shop.alien.mapper.second.SecondUserCreditMapper;
@@ -48,10 +52,15 @@ import java.util.stream.Collectors;
 /**
  * 用户
  */
+@Slf4j
 @Service
 @RequiredArgsConstructor
 public class LifeUserService extends ServiceImpl<LifeUserMapper, LifeUser> {
 
+    /** App 点击推送后跳转页面(与动态点赞通知一致) */
+    private static final String FOLLOW_APP_PUSH_OPEN_PATH =
+            "pages/secondHandTransactions/pages/message/noticesAndMessage";
+
     private final LifeUserMapper lifeUserMapper;
 
     private final LifeFansMapper lifeFansMapper;
@@ -76,12 +85,24 @@ public class LifeUserService extends ServiceImpl<LifeUserMapper, LifeUser> {
 
     private final LifeUserPersonalizationSettingService lifeUserPersonalizationSettingService;
 
+    private final LifeUserPushDeviceService lifeUserPushDeviceService;
+
+    private final UniPushCloudInvokeService uniPushCloudInvokeService;
+
+    private final StoreUserMapper storeUserMapper;
+
     @Autowired
     private RiskControlProperties riskControlProperties;
 
     @Value("${jwt.expiration-time}")
     private String effectiveTime;
 
+    /**
+     * 系统app通知开关
+     */
+    @Value("${alien.unipush.enabled:false}")
+    private boolean uniPushOn;
+
     public IPage<LifeUser> getStoresPage(int page, int size, String realName, String userPhone) {
         IPage<LifeUser> storePage = new Page<>(page, size);
         QueryWrapper<LifeUser> queryWrapper = new QueryWrapper<>();
@@ -133,6 +154,15 @@ public class LifeUserService extends ServiceImpl<LifeUserMapper, LifeUser> {
                     && lifeUserPersonalizationSettingService.shouldSuppressFollowRelatedNotice(followedLifeUserId);
             if (!suppressNotice) {
                 lifeNoticeMapper.insert(notice);
+
+                // 发送系统通知开关
+                if(uniPushOn) {
+                    try {
+                        sendFollowRelationAppPush(fans.getFollowedId());
+                    } catch (Exception e) {
+                        log.warn("关注 App 推送失败,followedId={},err={}", fans.getFollowedId(), e.getMessage());
+                    }
+                }
             }
         }
 
@@ -140,6 +170,56 @@ public class LifeUserService extends ServiceImpl<LifeUserMapper, LifeUser> {
     }
 
     /**
+     * 关注站内通知写入成功后,向被关注方已绑定 cid 的设备发 App 系统消息;与站内信条件一致,失败不影响关注与通知入库。
+     */
+    private void sendFollowRelationAppPush(String followedId) {
+        if (StringUtils.isBlank(followedId)) {
+            return;
+        }
+        List<String> cids = resolvePushClientIdsForFollowReceiver(followedId.trim());
+        if (CollectionUtils.isEmpty(cids)) {
+            return;
+        }
+        Map<String, Object> payload = new HashMap<>(8);
+        payload.put("scene", "follow");
+        payload.put("noticeType", 0);
+        payload.put("path", FOLLOW_APP_PUSH_OPEN_PATH);
+        uniPushCloudInvokeService.sendToClientIds(cids, "关注通知", "关注了你", payload);
+    }
+
+    private List<String> resolvePushClientIdsForFollowReceiver(String phoneId) {
+        if (phoneId.startsWith("user_")) {
+            String phone = phoneId.substring("user_".length());
+            if (StringUtils.isBlank(phone)) {
+                return Collections.emptyList();
+            }
+            LifeUser u = lifeUserMapper.selectOne(new LambdaQueryWrapper<LifeUser>()
+                    .eq(LifeUser::getUserPhone, phone)
+                    .eq(LifeUser::getDeleteFlag, 0)
+                    .last("LIMIT 1"));
+            if (u == null) {
+                return Collections.emptyList();
+            }
+            return lifeUserPushDeviceService.listPushClientIdsByUserId(u.getId(), PushDeviceOwnerType.USER.getCode());
+        }
+        if (phoneId.startsWith("store_")) {
+            String phone = phoneId.substring("store_".length());
+            if (StringUtils.isBlank(phone)) {
+                return Collections.emptyList();
+            }
+            StoreUser su = storeUserMapper.selectOne(new LambdaQueryWrapper<StoreUser>()
+                    .eq(StoreUser::getPhone, phone)
+                    .eq(StoreUser::getDeleteFlag, 0)
+                    .last("LIMIT 1"));
+            if (su == null) {
+                return Collections.emptyList();
+            }
+            return lifeUserPushDeviceService.listPushClientIdsByUserId(su.getId(), PushDeviceOwnerType.STORE.getCode());
+        }
+        return Collections.emptyList();
+    }
+
+    /**
      * followedId 为 user_手机号 时解析为 life_user.id;店铺等非用户被关注方返回 null(不读个性化表,照常发通知)
      */
     private Integer resolveLifeUserIdFromFollowedId(String followedId) {

+ 82 - 0
alien-store/src/main/java/shop/alien/store/service/UniPushCloudInvokeService.java

@@ -0,0 +1,82 @@
+package shop.alien.store.service;
+
+import com.alibaba.fastjson2.JSONObject;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.apache.commons.lang3.StringUtils;
+import org.springframework.http.HttpEntity;
+import org.springframework.http.HttpHeaders;
+import org.springframework.http.MediaType;
+import org.springframework.http.ResponseEntity;
+import org.springframework.http.client.SimpleClientHttpRequestFactory;
+import org.springframework.stereotype.Service;
+import org.springframework.web.client.RestTemplate;
+import shop.alien.store.config.UniPushProperties;
+
+import java.util.List;
+import java.util.Map;
+
+/**
+ * 调用 uniCloud URL 化云函数触发推送。请求体需与云函数内解析字段一致(示例见类注释)。
+ */
+@Slf4j
+@Service
+@RequiredArgsConstructor
+public class UniPushCloudInvokeService {
+
+    private final UniPushProperties properties;
+
+    /**
+     * 向云函数 POST JSON,默认结构(云函数内自行读取并调用 uniPush.sendMessage):
+     * <pre>
+     * {
+     *   "push_clientid": ["cid1","cid2"],
+     *   "title": "...",
+     *   "content": "...",
+     *   "payload": { }  // 可选,自定义透传
+     * }
+     * </pre>
+     */
+    public String sendToClientIds(List<String> pushClientIds, String title, String content, Map<String, Object> payload) {
+        if (!properties.isEnabled() || StringUtils.isBlank(properties.getCloudFunctionUrl())) {
+            log.debug("uni-push 云函数调用未启用或未配置 cloudFunctionUrl,跳过");
+            return null;
+        }
+        if (pushClientIds == null || pushClientIds.isEmpty()) {
+            log.warn("uni-push 推送跳过:pushClientIds 为空");
+            return null;
+        }
+
+        JSONObject body = new JSONObject();
+        body.put("push_clientid", pushClientIds);
+        body.put("title", title != null ? title : "");
+        body.put("content", content != null ? content : "");
+        if (payload != null && !payload.isEmpty()) {
+            body.put("payload", payload);
+        }
+
+        SimpleClientHttpRequestFactory factory = new SimpleClientHttpRequestFactory();
+        factory.setConnectTimeout(properties.getConnectTimeoutMs());
+        factory.setReadTimeout(properties.getReadTimeoutMs());
+        RestTemplate restTemplate = new RestTemplate(factory);
+
+        HttpHeaders headers = new HttpHeaders();
+        headers.setContentType(MediaType.APPLICATION_JSON);
+        if (StringUtils.isNotBlank(properties.getAuthHeaderName())
+                && StringUtils.isNotBlank(properties.getAuthHeaderValue())) {
+            headers.set(properties.getAuthHeaderName(), properties.getAuthHeaderValue());
+        }
+
+        HttpEntity<String> entity = new HttpEntity<>(body.toJSONString(), headers);
+        try {
+            ResponseEntity<String> resp = restTemplate.postForEntity(
+                    properties.getCloudFunctionUrl(), entity, String.class);
+            String respBody = resp.getBody();
+            log.info("uni-push 云函数调用完成, status={}, body={}", resp.getStatusCode(), respBody);
+            return respBody;
+        } catch (Exception e) {
+            log.error("uni-push 云函数调用失败: {}", e.getMessage(), e);
+            throw e;
+        }
+    }
+}

+ 194 - 0
alien-store/src/main/java/shop/alien/store/service/impl/LifeUserPushDeviceServiceImpl.java

@@ -0,0 +1,194 @@
+package shop.alien.store.service.impl;
+
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.apache.commons.lang3.StringUtils;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+import shop.alien.entity.result.R;
+import shop.alien.entity.store.LawyerUser;
+import shop.alien.entity.store.LifeUser;
+import shop.alien.entity.store.LifeUserPushDevice;
+import shop.alien.entity.store.PushDeviceOwnerType;
+import shop.alien.entity.store.StoreUser;
+import shop.alien.entity.store.UserLoginInfo;
+import shop.alien.mapper.LawyerUserMapper;
+import shop.alien.mapper.LifeUserMapper;
+import shop.alien.mapper.LifeUserPushDeviceMapper;
+import shop.alien.mapper.StoreUserMapper;
+import shop.alien.store.dto.LifeUserPushBindDto;
+import shop.alien.store.service.LifeUserPushDeviceService;
+
+import java.util.Collections;
+import java.util.Date;
+import java.util.List;
+import java.util.stream.Collectors;
+
+@Slf4j
+@Service
+@RequiredArgsConstructor
+public class LifeUserPushDeviceServiceImpl implements LifeUserPushDeviceService {
+
+    private final LifeUserPushDeviceMapper lifeUserPushDeviceMapper;
+    private final LifeUserMapper lifeUserMapper;
+    private final StoreUserMapper storeUserMapper;
+    private final LawyerUserMapper lawyerUserMapper;
+
+    @Override
+    @Transactional(rollbackFor = Exception.class)
+    public R<String> bind(UserLoginInfo login, LifeUserPushBindDto dto) {
+        if (login == null || login.getUserId() <= 0) {
+            return R.fail("请先登录");
+        }
+        PushDeviceOwnerType ownerType = PushDeviceOwnerType.fromJwtUserType(login.getType());
+        if (ownerType == null) {
+            return R.fail("当前登录类型不支持绑定推送设备");
+        }
+        if (dto == null || StringUtils.isBlank(dto.getPushClientId())) {
+            return R.fail("pushClientId 不能为空");
+        }
+        String cid = StringUtils.trim(dto.getPushClientId());
+        if (cid.length() > 128) {
+            return R.fail("pushClientId 过长");
+        }
+
+        R<String> valid = validateOwnerExists(ownerType, login.getUserId());
+        if (valid != null) {
+            return valid;
+        }
+
+        LifeUserPushDevice existing = lifeUserPushDeviceMapper.selectOne(
+                new LambdaQueryWrapper<LifeUserPushDevice>()
+                        .eq(LifeUserPushDevice::getPushClientId, cid)
+                        .last("LIMIT 1"));
+
+        Date now = new Date();
+        String platform = StringUtils.trimToNull(dto.getPlatform());
+        String appId = StringUtils.trimToNull(dto.getDcloudAppId());
+        if (platform != null && platform.length() > 32) {
+            platform = platform.substring(0, 32);
+        }
+        if (appId != null && appId.length() > 64) {
+            appId = appId.substring(0, 64);
+        }
+
+        String typeCode = ownerType.getCode();
+        if (existing != null) {
+            existing.setUserId(login.getUserId());
+            existing.setOwnerType(typeCode);
+            existing.setPlatform(platform);
+            existing.setDcloudAppId(appId);
+            existing.setUpdatedTime(now);
+            lifeUserPushDeviceMapper.updateById(existing);
+            log.info("更新推送设备绑定, ownerType={}, userId={}, cid={}", typeCode, login.getUserId(), cid);
+        } else {
+            LifeUserPushDevice row = new LifeUserPushDevice();
+            row.setUserId(login.getUserId());
+            row.setOwnerType(typeCode);
+            row.setPushClientId(cid);
+            row.setPlatform(platform);
+            row.setDcloudAppId(appId);
+            row.setCreatedTime(now);
+            row.setUpdatedTime(now);
+            lifeUserPushDeviceMapper.insert(row);
+            log.info("新增推送设备绑定, ownerType={}, userId={}, cid={}", typeCode, login.getUserId(), cid);
+        }
+        return R.success("绑定成功");
+    }
+
+    /**
+     * @return null 表示校验通过
+     */
+    private R<String> validateOwnerExists(PushDeviceOwnerType ownerType, int businessUserId) {
+        switch (ownerType) {
+            case USER:
+                LifeUser user = lifeUserMapper.selectById(businessUserId);
+                if (user == null) {
+                    return R.fail("用户不存在");
+                }
+                break;
+            case STORE:
+                StoreUser su = storeUserMapper.selectById(businessUserId);
+                if (su == null || (su.getDeleteFlag() != null && su.getDeleteFlag() != 0)) {
+                    return R.fail("门店用户不存在或已删除");
+                }
+                break;
+            case LAWYER:
+                LawyerUser lu = lawyerUserMapper.selectById(businessUserId);
+                if (lu == null || (lu.getDeleteFlag() != null && lu.getDeleteFlag() != 0)) {
+                    return R.fail("律师用户不存在或已删除");
+                }
+                break;
+            default:
+                return R.fail("不支持的归属类型");
+        }
+        return null;
+    }
+
+    @Override
+    @Transactional(rollbackFor = Exception.class)
+    public R<String> unbindByCid(UserLoginInfo login, String pushClientId) {
+        if (login == null || login.getUserId() <= 0) {
+            return R.fail("请先登录");
+        }
+        PushDeviceOwnerType ownerType = PushDeviceOwnerType.fromJwtUserType(login.getType());
+        if (ownerType == null) {
+            return R.fail("当前登录类型不支持解绑推送设备");
+        }
+        if (StringUtils.isBlank(pushClientId)) {
+            return R.fail("pushClientId 不能为空");
+        }
+        String cid = StringUtils.trim(pushClientId);
+        int n = lifeUserPushDeviceMapper.delete(
+                new LambdaQueryWrapper<LifeUserPushDevice>()
+                        .eq(LifeUserPushDevice::getOwnerType, ownerType.getCode())
+                        .eq(LifeUserPushDevice::getUserId, login.getUserId())
+                        .eq(LifeUserPushDevice::getPushClientId, cid));
+        if (n <= 0) {
+            return R.fail("未找到绑定记录");
+        }
+        return R.success("解绑成功");
+    }
+
+    @Override
+    public R<List<LifeUserPushDevice>> listMine(UserLoginInfo login) {
+        if (login == null || login.getUserId() <= 0) {
+            return R.fail("请先登录");
+        }
+        PushDeviceOwnerType ownerType = PushDeviceOwnerType.fromJwtUserType(login.getType());
+        if (ownerType == null) {
+            return R.fail("当前登录类型不支持查询推送设备");
+        }
+        List<LifeUserPushDevice> list = lifeUserPushDeviceMapper.selectList(
+                new LambdaQueryWrapper<LifeUserPushDevice>()
+                        .eq(LifeUserPushDevice::getOwnerType, ownerType.getCode())
+                        .eq(LifeUserPushDevice::getUserId, login.getUserId())
+                        .orderByDesc(LifeUserPushDevice::getUpdatedTime));
+        return R.data(list);
+    }
+
+    @Override
+    public List<String> listPushClientIdsByUserId(int userId, String ownerType) {
+        if (userId <= 0) {
+            return Collections.emptyList();
+        }
+        PushDeviceOwnerType ot = PushDeviceOwnerType.fromCode(ownerType);
+        if (ot == null) {
+            return Collections.emptyList();
+        }
+        List<LifeUserPushDevice> list = lifeUserPushDeviceMapper.selectList(
+                new LambdaQueryWrapper<LifeUserPushDevice>()
+                        .eq(LifeUserPushDevice::getOwnerType, ot.getCode())
+                        .eq(LifeUserPushDevice::getUserId, userId)
+                        .select(LifeUserPushDevice::getPushClientId));
+        if (list == null || list.isEmpty()) {
+            return Collections.emptyList();
+        }
+        return list.stream()
+                .map(LifeUserPushDevice::getPushClientId)
+                .filter(StringUtils::isNotBlank)
+                .distinct()
+                .collect(Collectors.toList());
+    }
+}