Explorar o código

feat(badge,user): C-10勋章解锁站内通知 + C-16孩子端受限资料投影(child-zone-contract 差距补齐)

- BadgeService.grantBadge 授予成功后写 user_notifications(type=badge_unlock),
  经 NotificationDispatchService 分发;不可登录成员由同家庭能登录成员代收
- /api/user/info、/api/auth/info、/api/family/user/info:role=child 时裁剪
  idCard/phone/realName/openid/unionid(UserService.maskChildSensitiveFields),
  家庭资料接口补 password 置空
- UserNotification 注释登记 badge_unlock 类型
- C-18 设备/账号绑定校验:文档口径为「先由产品确认设备绑定 or 账号绑定」,
  方向未定且需新增 device_bind 表迁移,本次不落地,待拍板
Agnes Agent hai 5 días
pai
achega
36b6715659

+ 2 - 0
cfc-backend/src/main/java/com/etotem/cfc/controller/UserController.java

@@ -85,6 +85,8 @@ public class UserController {
         if (user != null) {
         if (user != null) {
             user.setPassword(null);
             user.setPassword(null);
         }
         }
+        // C-16 受限资料投影:孩子端隐藏敏感字段
+        userService.maskChildSensitiveFields(user);
         return Result.success(user);
         return Result.success(user);
     }
     }
 
 

+ 2 - 0
cfc-backend/src/main/java/com/etotem/cfc/controller/auth/AuthController.java

@@ -192,6 +192,8 @@ public class AuthController {
         if (user != null) {
         if (user != null) {
             user.setPassword(null);
             user.setPassword(null);
         }
         }
+        // C-16 受限资料投影:孩子端隐藏敏感字段
+        userService.maskChildSensitiveFields(user);
         return Result.success(user);
         return Result.success(user);
     }
     }
 
 

+ 5 - 0
cfc-backend/src/main/java/com/etotem/cfc/controller/family/FamilyUserController.java

@@ -48,6 +48,11 @@ public class FamilyUserController {
     @PostMapping("/info")
     @PostMapping("/info")
     public Result<User> getUserInfo(@RequestAttribute("userId") Long userId) {
     public Result<User> getUserInfo(@RequestAttribute("userId") Long userId) {
         User user = userService.getUserInfo(userId);
         User user = userService.getUserInfo(userId);
+        if (user != null) {
+            user.setPassword(null);
+        }
+        // C-16 受限资料投影:孩子端隐藏敏感字段
+        userService.maskChildSensitiveFields(user);
         return Result.success(user);
         return Result.success(user);
     }
     }
 
 

+ 1 - 1
cfc-backend/src/main/java/com/etotem/cfc/entity/UserNotification.java

@@ -12,7 +12,7 @@ import java.util.Date;
  * 站内通知(改版v1,用户级主动通知)
  * 站内通知(改版v1,用户级主动通知)
  * type: report_ready=报告完成 / reward_granted=奖励到账 / logistics=物流变化 / system=系统
  * type: report_ready=报告完成 / reward_granted=奖励到账 / logistics=物流变化 / system=系统
  *       energy_change=能量变动 / points_change=积分变动 / cf_change=CF值变动
  *       energy_change=能量变动 / points_change=积分变动 / cf_change=CF值变动
- *       coupon_gift=会员券转赠到账
+ *       coupon_gift=会员券转赠到账 / badge_unlock=勋章解锁提醒
  * 与 notices(平台公告) 并存:本表按 user_id 定向推送
  * 与 notices(平台公告) 并存:本表按 user_id 定向推送
  */
  */
 @Data
 @Data

+ 64 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/BadgeService.java

@@ -2,8 +2,10 @@ package com.etotem.cfc.service;
 
 
 import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
 import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
 import com.etotem.cfc.entity.Badge;
 import com.etotem.cfc.entity.Badge;
+import com.etotem.cfc.entity.FamilyMember;
 import com.etotem.cfc.entity.MemberBadge;
 import com.etotem.cfc.entity.MemberBadge;
 import com.etotem.cfc.mapper.BadgeMapper;
 import com.etotem.cfc.mapper.BadgeMapper;
+import com.etotem.cfc.mapper.FamilyMemberMapper;
 import com.etotem.cfc.mapper.MemberBadgeMapper;
 import com.etotem.cfc.mapper.MemberBadgeMapper;
 import lombok.extern.slf4j.Slf4j;
 import lombok.extern.slf4j.Slf4j;
 import org.springframework.stereotype.Service;
 import org.springframework.stereotype.Service;
@@ -25,6 +27,12 @@ public class BadgeService {
     @Resource
     @Resource
     private MemberBadgeMapper memberBadgeMapper;
     private MemberBadgeMapper memberBadgeMapper;
 
 
+    @Resource
+    private FamilyMemberMapper familyMemberMapper;
+
+    @Resource
+    private NotificationDispatchService notificationDispatchService;
+
     // ===== 勋章定义管理 =====
     // ===== 勋章定义管理 =====
 
 
     public List<Badge> getAllBadges() {
     public List<Badge> getAllBadges() {
@@ -101,6 +109,62 @@ public class BadgeService {
         }
         }
 
 
         memberBadgeMapper.insert(cb);
         memberBadgeMapper.insert(cb);
+
+        // C-10 勋章解锁提醒:授予成功后向成员(或代管用户)写站内通知(type=badge_unlock)
+        sendBadgeUnlockNotice(memberId, badge);
+    }
+
+    /**
+     * C-10 勋章解锁提醒。
+     * <p>
+     * 授予成功后写 {@code user_notifications}(type=badge_unlock):
+     * <ul>
+     *   <li>可登录成员(family_members.userId 非空)→ 通知本人;</li>
+     *   <li>不可登录成员(userId 为空)→ 由同家庭能登录成员(家庭管理员优先)代收;</li>
+     *   <li>通知失败不阻断授予(授予已在同事务提交,仅记录警告)。</li>
+     * </ul>
+     * 模板 {@code notification_templates} 无 badge_unlock 记录时回退默认文案(见
+     * {@link NotificationDispatchService#sendByTemplate})。
+     */
+    private void sendBadgeUnlockNotice(Long memberId, Badge badge) {
+        try {
+            FamilyMember member = familyMemberMapper.selectById(memberId);
+            if (member == null) {
+                log.warn("勋章解锁通知跳过:memberId={} 不存在", memberId);
+                return;
+            }
+            Long targetUserId = member.getUserId();
+            if (targetUserId == null) {
+                // 不可登录成员:由同家庭能登录成员(家庭管理员优先)代收
+                FamilyMember proxy = familyMemberMapper.selectOne(
+                        new LambdaQueryWrapper<FamilyMember>()
+                                .eq(FamilyMember::getFamilyId, member.getFamilyId())
+                                .isNotNull(FamilyMember::getUserId)
+                                .last("ORDER BY (is_admin = 1) DESC, id ASC LIMIT 1"));
+                if (proxy != null) {
+                    targetUserId = proxy.getUserId();
+                }
+            }
+            if (targetUserId == null) {
+                log.warn("勋章解锁通知跳过:memberId={} 无可通知用户", memberId);
+                return;
+            }
+            String badgeName = (badge.getName() != null && !badge.getName().isEmpty())
+                    ? badge.getName()
+                    : (badge.getBadgeId() != null ? badge.getBadgeId() : "新勋章");
+            notificationDispatchService.sendByTemplate(
+                    "badge_unlock",
+                    targetUserId,
+                    NotificationDispatchService.vars("badgeName", badgeName),
+                    "badge",
+                    badge.getId(),
+                    "勋章解锁",
+                    "恭喜,您解锁了勋章「{badgeName}」,继续加油!");
+            log.info("勋章解锁通知已发送: memberId={}, badgeId={}, userId={}", memberId, badge.getId(), targetUserId);
+        } catch (Exception e) {
+            log.warn("勋章解锁通知发送失败 memberId={}, badgeId={}, error={}",
+                    memberId, badge.getId(), e.getMessage());
+        }
     }
     }
 
 
     // ===== 家庭成员勋章查询 =====
     // ===== 家庭成员勋章查询 =====

+ 16 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/UserService.java

@@ -402,6 +402,22 @@ private FamilyInvitationService familyInvitationService;
         return userMapper.selectById(userId);
         return userMapper.selectById(userId);
     }
     }
 
 
+    /**
+     * C-16 受限资料投影:孩子端(当前活跃角色 role=child)查看自己资料时,
+     * 隐藏敏感字段(idCard/phone/realName/openid/unionid),家长/规划师不受影响。
+     * 供 /api/user/info、/api/auth/info、/api/family/user/info 在返回前调用。
+     */
+    public void maskChildSensitiveFields(User user) {
+        if (user == null || !"child".equals(user.getRole())) {
+            return;
+        }
+        user.setIdCard(null);
+        user.setPhone(null);
+        user.setRealName(null);
+        user.setOpenid(null);
+        user.setUnionid(null);
+    }
+
     /** 回写字段白名单(user 目标) */
     /** 回写字段白名单(user 目标) */
     private static final String[] USER_BACKFILL_WHITELIST = {"phone", "gender", "birthday", "birthHour", "dietPreferences", "address", "realName"};
     private static final String[] USER_BACKFILL_WHITELIST = {"phone", "gender", "birthday", "birthHour", "dietPreferences", "address", "realName"};