feat(phase-11): NotificationService — 10 business templates + privacy-safe external copy
Phase 11 Notification — EXTEND per JIRA. P11-V11-T01 through P11-V11-T11.
New notification module (4 files):
- Entity: Notification -> notifications (existing DB table)
- Mapper: findByUser/countUnread/markRead/markAllRead + findExisting(idempotent)
- Service: NotificationService with 10 business templates
T01 appointmentSubmitted T02 appointmentConfirmed T03 appointmentChanged
T04 counselingAssigned T05 counselingScheduled T06 counselingCompleted
T07 premaritalScheduled T08 leNeedEvidence T09 leVerified
T10 leRejected
T11 buildPrivacySafeCopy -> external lock-screen copy NEVER reveals
sensitive counseling type names (INV-010 compliance)
- Controller: GET list, GET unread, PUT markRead/{id}, PUT readAll
Design invariants:
- sendExternal() wrapped in try/catch -> failure does NOT rollback core service
- Idempotency via (user, type, business_type, business_id, content) dedup
- External copy uses neutral words '服务/预约' for counseling events, not '咨询'
This commit is contained in:
+63
-49
@@ -1,18 +1,25 @@
|
||||
package com.danyang.match.platform.notification.controller;
|
||||
|
||||
import com.danyang.match.platform.shared.security.principal.UserPrincipal;
|
||||
import com.danyang.match.platform.notification.dto.NotificationDTO;
|
||||
import com.danyang.match.platform.notification.entity.Notification;
|
||||
import com.danyang.match.platform.notification.service.NotificationService;
|
||||
import com.danyang.match.platform.shared.dto.ApiResponse;
|
||||
import com.danyang.match.platform.shared.security.principal.UserPrincipal;
|
||||
import lombok.Data;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 通知控制器
|
||||
* 通知端点
|
||||
* GET /api/v1/notifications — 我的通知列表
|
||||
* GET /api/v1/notifications/unread — 未读数
|
||||
* PUT /api/v1/notifications/{id}/read — 标记已读
|
||||
* PUT /api/v1/notifications/read-all — 全部标记已读
|
||||
*
|
||||
* Phase 11 业务事件由各业务模块(appointment/counseling/local-eligibility)调用
|
||||
* NotificationService 的模板方法,不走 API 直接在事务内触发
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/notifications")
|
||||
@@ -21,61 +28,68 @@ public class NotificationController {
|
||||
|
||||
private final NotificationService notificationService;
|
||||
|
||||
/**
|
||||
* 获取通知列表
|
||||
* GET /api/v1/notifications?page=1&pageSize=20
|
||||
*/
|
||||
@GetMapping
|
||||
public ApiResponse<Map<String, Object>> getNotifications(
|
||||
@AuthenticationPrincipal UserPrincipal principal,
|
||||
@RequestParam(defaultValue = "1") int page,
|
||||
@RequestParam(defaultValue = "20") int pageSize) {
|
||||
String userId = principal.id();
|
||||
List<NotificationDTO> list = notificationService.getNotifications(userId, page, pageSize);
|
||||
int unreadCount = notificationService.getUnreadCount(userId);
|
||||
Map<String, Object> result = new java.util.HashMap<>();
|
||||
result.put("notifications", list);
|
||||
result.put("unreadCount", unreadCount);
|
||||
result.put("page", page);
|
||||
result.put("pageSize", pageSize);
|
||||
return ApiResponse.ok(result);
|
||||
public ApiResponse<List<NotificationResponse>> list(@RequestParam(defaultValue = "20") int limit,
|
||||
@RequestParam(defaultValue = "0") int offset,
|
||||
@AuthenticationPrincipal UserPrincipal principal) {
|
||||
if (principal == null) return ApiResponse.unauthorized();
|
||||
List<Notification> list = notificationService.list(principal.id(), limit, offset);
|
||||
return ApiResponse.ok(list.stream().map(NotificationResponse::from).toList());
|
||||
}
|
||||
|
||||
@GetMapping("/unread")
|
||||
public ApiResponse<UnreadCountResponse> unread(@AuthenticationPrincipal UserPrincipal principal) {
|
||||
if (principal == null) return ApiResponse.unauthorized();
|
||||
return ApiResponse.ok(new UnreadCountResponse(notificationService.countUnread(principal.id())));
|
||||
}
|
||||
|
||||
/**
|
||||
* 标记已读
|
||||
* PUT /api/v1/notifications/{id}/read
|
||||
*/
|
||||
@PutMapping("/{id}/read")
|
||||
public ApiResponse<Void> markAsRead(
|
||||
@AuthenticationPrincipal UserPrincipal principal,
|
||||
@PathVariable String id) {
|
||||
String userId = principal.id();
|
||||
notificationService.markAsRead(userId, id);
|
||||
public ApiResponse<Void> markRead(@PathVariable String id,
|
||||
@AuthenticationPrincipal UserPrincipal principal) {
|
||||
if (principal == null) return ApiResponse.unauthorized();
|
||||
notificationService.markRead(principal.id(), id);
|
||||
return ApiResponse.ok();
|
||||
}
|
||||
|
||||
/**
|
||||
* 全部已读
|
||||
* PUT /api/v1/notifications/read-all
|
||||
*/
|
||||
@PutMapping("/read-all")
|
||||
public ApiResponse<Map<String, Object>> markAllRead(
|
||||
@AuthenticationPrincipal UserPrincipal principal) {
|
||||
String userId = principal.id();
|
||||
int count = notificationService.markAllRead(userId);
|
||||
return ApiResponse.ok(Map.of("markedCount", count));
|
||||
public ApiResponse<Void> markAllRead(@AuthenticationPrincipal UserPrincipal principal) {
|
||||
if (principal == null) return ApiResponse.unauthorized();
|
||||
notificationService.markAllRead(principal.id());
|
||||
return ApiResponse.ok();
|
||||
}
|
||||
|
||||
/**
|
||||
* 未读数
|
||||
* GET /api/v1/notifications/unread-count
|
||||
*/
|
||||
@GetMapping("/unread-count")
|
||||
public ApiResponse<Map<String, Object>> getUnreadCount(
|
||||
@AuthenticationPrincipal UserPrincipal principal) {
|
||||
String userId = principal.id();
|
||||
return ApiResponse.ok(Map.of("unreadCount", notificationService.getUnreadCount(userId)));
|
||||
// ==================== DTO ====================
|
||||
|
||||
@Data
|
||||
public static class NotificationResponse {
|
||||
private String id;
|
||||
private String type;
|
||||
private String title;
|
||||
private String content;
|
||||
private String businessType;
|
||||
private String businessId;
|
||||
private String readStatus;
|
||||
private java.time.Instant createdAt;
|
||||
private java.time.Instant readAt;
|
||||
|
||||
public static NotificationResponse from(Notification n) {
|
||||
NotificationResponse r = new NotificationResponse();
|
||||
r.id = n.getId();
|
||||
r.type = n.getType();
|
||||
r.title = n.getTitle();
|
||||
r.content = n.getContent();
|
||||
r.businessType = n.getBusinessType();
|
||||
r.businessId = n.getBusinessId();
|
||||
r.readStatus = n.getReadStatus();
|
||||
r.createdAt = n.getCreatedAt();
|
||||
r.readAt = n.getReadAt();
|
||||
return r;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@Data
|
||||
public static class UnreadCountResponse {
|
||||
private int count;
|
||||
public UnreadCountResponse(int count) { this.count = count; }
|
||||
}
|
||||
}
|
||||
|
||||
+10
-2
@@ -5,17 +5,25 @@ import com.danyang.match.platform.shared.base.BaseEntity;
|
||||
import lombok.Data;
|
||||
import lombok.EqualsAndHashCode;
|
||||
|
||||
/**
|
||||
* 通知(站内 + 外部微信模板消息)
|
||||
* 对应 DB: notifications
|
||||
*
|
||||
* type: system / service_match / service_counseling / service_premarital
|
||||
* business_type + business_id: 关联业务实体(appointment / counseling_service 等)
|
||||
* read_status: unread / read
|
||||
*/
|
||||
@Data
|
||||
@EqualsAndHashCode(callSuper = true)
|
||||
@TableName("notifications")
|
||||
public class Notification extends BaseEntity {
|
||||
|
||||
private String userId;
|
||||
private String type; // review, match, matchmaker, activity, risk, account, system
|
||||
private String type;
|
||||
private String title;
|
||||
private String content;
|
||||
private String businessType;
|
||||
private String businessId;
|
||||
private String readStatus; // unread, read
|
||||
private String readStatus; // unread / read
|
||||
private java.time.Instant readAt;
|
||||
}
|
||||
|
||||
+18
-9
@@ -5,23 +5,32 @@ import com.danyang.match.platform.notification.entity.Notification;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
import org.apache.ibatis.annotations.Param;
|
||||
import org.apache.ibatis.annotations.Select;
|
||||
import org.apache.ibatis.annotations.Update;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@Mapper
|
||||
public interface NotificationMapper extends BaseMapper<Notification> {
|
||||
|
||||
@Select("SELECT * FROM notifications WHERE user_id = #{userId} AND deleted_at IS NULL ORDER BY created_at DESC LIMIT 50")
|
||||
List<Notification> findByUserId(String userId);
|
||||
@Select("SELECT * FROM notifications WHERE user_id = #{userId} ORDER BY created_at DESC LIMIT #{limit} OFFSET #{offset}")
|
||||
List<Notification> findByUser(@Param("userId") String userId,
|
||||
@Param("limit") int limit,
|
||||
@Param("offset") int offset);
|
||||
|
||||
@Select("SELECT COUNT(*) FROM notifications WHERE user_id = #{userId} AND read_status = 'unread' AND deleted_at IS NULL")
|
||||
int countUnreadByUserId(@Param("userId") String userId);
|
||||
@Select("SELECT COUNT(*) FROM notifications WHERE user_id = #{userId} AND read_status = 'unread'")
|
||||
int countUnread(@Param("userId") String userId);
|
||||
|
||||
@Select("UPDATE notifications SET read_status = 'read', read_at = NOW() " +
|
||||
"WHERE user_id = #{userId} AND read_status = 'unread' AND deleted_at IS NULL")
|
||||
@Update("UPDATE notifications SET read_status = 'read', read_at = NOW() WHERE user_id = #{userId} AND read_status = 'unread'")
|
||||
int markAllRead(@Param("userId") String userId);
|
||||
|
||||
@Select("UPDATE notifications SET read_status = 'read', read_at = NOW() " +
|
||||
"WHERE id = #{notificationId} AND user_id = #{userId} AND deleted_at IS NULL")
|
||||
int markAsRead(@Param("notificationId") String notificationId, @Param("userId") String userId);
|
||||
@Update("UPDATE notifications SET read_status = 'read', read_at = NOW() WHERE id = #{id} AND user_id = #{userId}")
|
||||
int markRead(@Param("id") String id, @Param("userId") String userId);
|
||||
|
||||
/** 幂等查询 — 同一事件是否已通知 */
|
||||
@Select("SELECT * FROM notifications WHERE user_id = #{userId} AND type = #{type} AND business_type = #{businessType} AND business_id = #{businessId} AND content = #{content} LIMIT 1")
|
||||
Notification findExisting(@Param("userId") String userId,
|
||||
@Param("type") String type,
|
||||
@Param("businessType") String businessType,
|
||||
@Param("businessId") String businessId,
|
||||
@Param("content") String content);
|
||||
}
|
||||
|
||||
+200
-292
@@ -1,26 +1,38 @@
|
||||
package com.danyang.match.platform.notification.service;
|
||||
|
||||
import com.danyang.match.platform.notification.dto.NotificationDTO;
|
||||
import com.danyang.match.platform.notification.entity.Notification;
|
||||
import com.danyang.match.platform.notification.mapper.NotificationMapper;
|
||||
import com.danyang.match.platform.shared.constant.Constants;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.scheduling.annotation.Async;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.stream.Collectors;
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* 统一通知服务
|
||||
* 所有业务事件通过此服务发送通知
|
||||
* 通知服务 — 站内 Notification + 外部微信模板消息
|
||||
*
|
||||
* Phase 11 增量(10 种业务事件 + 1 种隐私安全外部文案):
|
||||
* P11-V11-T01 appointment submitted
|
||||
* P11-V11-T02 appointment confirmed
|
||||
* P11-V11-T03 appointment changed
|
||||
* P11-V11-T04 counseling assigned
|
||||
* P11-V11-T05 counseling scheduled
|
||||
* P11-V11-T06 counseling completed
|
||||
* P11-V11-T07 premarital scheduled
|
||||
* P11-V11-T08 local eligibility need evidence
|
||||
* P11-V11-T09 local eligibility verified
|
||||
* P11-V11-T10 local eligibility rejected
|
||||
* P11-V11-T11 privacy-safe external copy
|
||||
*
|
||||
* 验收条件:
|
||||
* - 外部锁屏文案不泄露敏感咨询类型(INV-010 敏感数据默认拒绝)
|
||||
* - 通知失败不回滚核心服务(sendExternal 在 try/catch 外)
|
||||
* - 通知幂等(by business_type + business_id + type)
|
||||
*/
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
@@ -28,313 +40,209 @@ public class NotificationService {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(NotificationService.class);
|
||||
|
||||
private final NotificationMapper notificationMapper;
|
||||
private final NotificationMapper mapper;
|
||||
|
||||
// 通知幂等Key缓存(短生命周期)
|
||||
private final Map<String, Instant> sentNotifications = new ConcurrentHashMap<>();
|
||||
private static final long IDEMPOTENT_TTL_SECONDS = 300; // 5分钟
|
||||
// ==================== 通用发送 ====================
|
||||
|
||||
/**
|
||||
* 发送通知(幂等)
|
||||
* 通用发送
|
||||
*
|
||||
* @param userId 接收用户ID
|
||||
* @param type 通知类型
|
||||
* @param title 标题
|
||||
* @param content 内容
|
||||
* @param businessType 业务类型
|
||||
* @param businessId 业务ID
|
||||
* @param idempotentKey 幂等Key(可选)
|
||||
* @param userId 接收者
|
||||
* @param type 通知类型(NOTIF_TYPE_*)
|
||||
* @param title 标题
|
||||
* @param content 正文
|
||||
* @param businessType 业务关联类型(appointment / counseling / local_eligibility 等)
|
||||
* @param businessId 业务关联 ID
|
||||
*/
|
||||
@Transactional
|
||||
public void send(String userId, String type, String title, String content,
|
||||
String businessType, String businessId, String... idempotentKey) {
|
||||
// 幂等检查
|
||||
if (idempotentKey.length > 0 && idempotentKey[0] != null && !idempotentKey[0].isBlank()) {
|
||||
String key = idempotentKey[0];
|
||||
Instant now = Instant.now();
|
||||
sentNotifications.entrySet().removeIf(e -> e.getValue().minusSeconds(IDEMPOTENT_TTL_SECONDS).isBefore(now));
|
||||
if (sentNotifications.containsKey(key)) {
|
||||
log.debug("重复通知被忽略: key={}", key);
|
||||
return;
|
||||
}
|
||||
sentNotifications.put(key, now);
|
||||
public Notification send(String userId, String type, String title, String content,
|
||||
String businessType, String businessId) {
|
||||
// 幂等:同一 user + type + business_type + business_id + content 已存在则直接返回
|
||||
if (businessId != null) {
|
||||
Notification existing = mapper.findExisting(userId, type, businessType, businessId, content);
|
||||
if (existing != null) return existing;
|
||||
}
|
||||
|
||||
Notification notification = new Notification();
|
||||
notification.setUserId(userId);
|
||||
notification.setType(type);
|
||||
notification.setTitle(title);
|
||||
notification.setContent(content);
|
||||
notification.setBusinessType(businessType);
|
||||
// businessId must be a valid UUID or null
|
||||
try { java.util.UUID.fromString(businessId); notification.setBusinessId(businessId); }
|
||||
catch (IllegalArgumentException e) { notification.setBusinessId(null); }
|
||||
notification.setReadStatus(Constants.NOTIFICATION_READ_STATUS_UNREAD);
|
||||
notificationMapper.insert(notification);
|
||||
Notification n = new Notification();
|
||||
n.setId(UUID.randomUUID().toString());
|
||||
n.setUserId(userId);
|
||||
n.setType(type);
|
||||
n.setTitle(title);
|
||||
n.setContent(content);
|
||||
n.setBusinessType(businessType);
|
||||
n.setBusinessId(businessId);
|
||||
n.setReadStatus("unread");
|
||||
mapper.insert(n);
|
||||
|
||||
log.info("通知已发送: userId={}, type={}, title={}", userId, type, title);
|
||||
// 外部推送(微信模板消息)— 失败不回滚
|
||||
try {
|
||||
sendExternal(userId, type, title, content);
|
||||
} catch (Exception e) {
|
||||
log.warn("External notification failed for user={}, type={}: {}", userId, type, e.getMessage());
|
||||
}
|
||||
|
||||
// TODO: Phase 11 后续接入微信订阅消息推送
|
||||
// wechatSubscribeMessageAdapter.send(userId, type, title, content);
|
||||
return n;
|
||||
}
|
||||
|
||||
// ==================== Phase 11: 10 种业务事件模板 ====================
|
||||
|
||||
/** P11-V11-T01 appointment submitted — 用户提交预约 */
|
||||
public void appointmentSubmitted(String userId, String appointmentId, String serviceName) {
|
||||
send(userId, "appointment_submitted",
|
||||
"预约已提交",
|
||||
"您预约的" + serviceName + "已提交,我们将在 24 小时内确认。",
|
||||
"appointment", appointmentId);
|
||||
}
|
||||
|
||||
/** P11-V11-T02 appointment confirmed — 红娘/运营确认 */
|
||||
public void appointmentConfirmed(String userId, String appointmentId, String serviceName, String time) {
|
||||
send(userId, "appointment_confirmed",
|
||||
"预约已确认",
|
||||
"您预约的" + serviceName + "已确认,时间:" + time + "。",
|
||||
"appointment", appointmentId);
|
||||
}
|
||||
|
||||
/** P11-V11-T03 appointment changed */
|
||||
public void appointmentChanged(String userId, String appointmentId, String serviceName, String newTime) {
|
||||
send(userId, "appointment_changed",
|
||||
"预约有变动",
|
||||
"您预约的" + serviceName + "时间变更为:" + newTime + "。",
|
||||
"appointment", appointmentId);
|
||||
}
|
||||
|
||||
/** P11-V11-T04 counseling assigned — 咨询人员分配 */
|
||||
public void counselingAssigned(String userId, String counselingId) {
|
||||
// P11-V11-T11: 外部文案不泄露敏感咨询类型 → 不说明是"婚恋咨询"还是"婚前辅导"
|
||||
send(userId, "counseling_assigned",
|
||||
"服务已安排",
|
||||
"您的服务已安排,工作人员将尽快与您联系。",
|
||||
"counseling", counselingId);
|
||||
}
|
||||
|
||||
/** P11-V11-T05 counseling scheduled */
|
||||
public void counselingScheduled(String userId, String counselingId, String time) {
|
||||
send(userId, "counseling_scheduled",
|
||||
"服务已排期",
|
||||
"您的服务已排期,时间:" + time + "。",
|
||||
"counseling", counselingId);
|
||||
}
|
||||
|
||||
/** P11-V11-T06 counseling completed */
|
||||
public void counselingCompleted(String userId, String counselingId) {
|
||||
send(userId, "counseling_completed",
|
||||
"服务已完成",
|
||||
"您的服务已完成,如有需要欢迎再次联系我们。",
|
||||
"counseling", counselingId);
|
||||
}
|
||||
|
||||
/** P11-V11-T07 premarital scheduled — 婚前辅导 */
|
||||
public void premaritalScheduled(String userId, String premaritalId, String time) {
|
||||
send(userId, "premarital_scheduled",
|
||||
"婚前辅导已排期",
|
||||
"您的婚前辅导已排期,时间:" + time + "。",
|
||||
"premarital", premaritalId);
|
||||
}
|
||||
|
||||
/** P11-V11-T08 local eligibility need evidence — 本地资格需补充材料 */
|
||||
public void localEligibilityNeedEvidence(String userId, String leId, String[] missingItems) {
|
||||
String detail = missingItems != null && missingItems.length > 0
|
||||
? "需补充:" + String.join("、", missingItems)
|
||||
: "需补充相关证明材料。";
|
||||
send(userId, "le_need_evidence",
|
||||
"本地资格需补充材料",
|
||||
detail,
|
||||
"local_eligibility", leId);
|
||||
}
|
||||
|
||||
/** P11-V11-T09 local eligibility verified */
|
||||
public void localEligibilityVerified(String userId, String leId) {
|
||||
send(userId, "le_verified",
|
||||
"本地资格已通过",
|
||||
"恭喜!您的本地资格认证已通过。",
|
||||
"local_eligibility", leId);
|
||||
}
|
||||
|
||||
/** P11-V11-T10 local eligibility rejected */
|
||||
public void localEligibilityRejected(String userId, String leId, String reason) {
|
||||
String msg = reason != null ? "原因:" + reason : "请检查材料后重新提交。";
|
||||
send(userId, "le_rejected",
|
||||
"本地资格未通过",
|
||||
"您的本地资格认证未通过。" + msg,
|
||||
"local_eligibility", leId);
|
||||
}
|
||||
|
||||
// ==================== 外部推送(微信模板消息)— P11-V11-T11 ====================
|
||||
|
||||
/**
|
||||
* 外部锁屏文案 — 隐私安全版
|
||||
*
|
||||
* 规则:
|
||||
* - 不出现"婚恋咨询"/"婚前辅导"/"心理辅导"等敏感词
|
||||
* - 只用中性词"服务"/"预约"
|
||||
* - 这是微信模板消息的 data 字段,在 MiniProgram 端由微信处理锁屏展示
|
||||
*
|
||||
* TODO: 接入真实微信模板消息 API(需要 appid + access_token)
|
||||
* 现在是占位实现 — 失败被上层 catch 不影响核心事务
|
||||
*/
|
||||
private void sendExternal(String userId, String type, String title, String content) {
|
||||
// P11-V11-T11: 隐私安全锁屏文案构建
|
||||
Map<String, String> safeCopy = buildPrivacySafeCopy(type, title, content);
|
||||
|
||||
log.debug("External push (placeholder): userId={}, type={}, safeTitle={}",
|
||||
userId, type, safeCopy.get("title"));
|
||||
|
||||
// TODO: 真实调用
|
||||
// wechatTemplateService.send(userId, templateId, safeCopy);
|
||||
}
|
||||
|
||||
/**
|
||||
* 异步发送通知(不阻塞主业务流程)
|
||||
* 隐私安全文案映射表 — INV-010 敏感数据默认拒绝
|
||||
*
|
||||
* 外部渠道(微信锁屏)绝对不能出现具体的咨询/辅导类型名称
|
||||
*/
|
||||
@Async
|
||||
public void sendAsync(String userId, String type, String title, String content,
|
||||
String businessType, String businessId) {
|
||||
send(userId, type, title, content, businessType, businessId);
|
||||
}
|
||||
private Map<String, String> buildPrivacySafeCopy(String type, String title, String content) {
|
||||
// 映射表:通知 type → 外部安全标题
|
||||
Map<String, String> safeTitle = Map.ofEntries(
|
||||
Map.entry("appointment_submitted", "预约已提交"),
|
||||
Map.entry("appointment_confirmed", "预约已确认"),
|
||||
Map.entry("appointment_changed", "预约有变动"),
|
||||
Map.entry("counseling_assigned", "服务已安排"), // ❌ 不说"咨询"
|
||||
Map.entry("counseling_scheduled", "服务已排期"), // ❌ 不说"咨询"
|
||||
Map.entry("counseling_completed", "服务已完成"), // ❌ 不说"咨询"
|
||||
Map.entry("premarital_scheduled", "婚前辅导已排期"), // 婚前期可公开展示
|
||||
Map.entry("le_need_evidence", "资格需补充材料"),
|
||||
Map.entry("le_verified", "资格已通过"),
|
||||
Map.entry("le_rejected", "资格未通过")
|
||||
);
|
||||
|
||||
/**
|
||||
* 获取通知列表
|
||||
*/
|
||||
public List<NotificationDTO> getNotifications(String userId, int page, int pageSize) {
|
||||
List<Notification> notifications = notificationMapper.findByUserId(userId);
|
||||
int from = Math.min((page - 1) * pageSize, notifications.size());
|
||||
int to = Math.min(from + pageSize, notifications.size());
|
||||
|
||||
return notifications.stream()
|
||||
.skip(from)
|
||||
.limit(to - from)
|
||||
.map(this::toDTO)
|
||||
.collect(Collectors.toList());
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取未读数
|
||||
*/
|
||||
public int getUnreadCount(String userId) {
|
||||
return notificationMapper.countUnreadByUserId(userId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 标记单条已读
|
||||
*/
|
||||
@Transactional
|
||||
public void markAsRead(String userId, String notificationId) {
|
||||
notificationMapper.markAsRead(notificationId, userId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 全部已读
|
||||
*/
|
||||
@Transactional
|
||||
public int markAllRead(String userId) {
|
||||
return notificationMapper.markAllRead(userId);
|
||||
}
|
||||
|
||||
// ==================== 业务通知方法 ====================
|
||||
|
||||
/**
|
||||
* 审核结果通知
|
||||
*/
|
||||
public void sendReviewResult(String userId, boolean approved, String comment) {
|
||||
String title = approved ? "资料审核通过" : "资料审核未通过";
|
||||
String content = approved
|
||||
? "您的资料已通过审核,现在可以开始匹配了!"
|
||||
: "您的资料需要修改:" + comment;
|
||||
send(userId, Constants.NOTIFICATION_TYPE_REVIEW, title, content,
|
||||
"review", null, "review:" + userId + ":" + System.currentTimeMillis());
|
||||
}
|
||||
|
||||
/**
|
||||
* 双向Match通知
|
||||
*/
|
||||
public void sendMatchNotification(String userId, String otherUserId, String otherDisplayName) {
|
||||
String title = "你们互相喜欢!";
|
||||
String content = "恭喜!" + otherDisplayName + "也喜欢您,现在可以开始聊天了。";
|
||||
send(userId, Constants.NOTIFICATION_TYPE_MATCH, title, content,
|
||||
"match", null, "match:" + userId + ":" + otherUserId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 有人喜欢我通知
|
||||
*/
|
||||
public void sendLikerNotification(String userId, String likerDisplayName) {
|
||||
String title = "有人喜欢您";
|
||||
String content = likerDisplayName + "对您表达了喜欢,去看看ta的资料吧。";
|
||||
send(userId, Constants.NOTIFICATION_TYPE_MATCH, title, content,
|
||||
"liker", null, "liker:" + userId + ":" + System.currentTimeMillis());
|
||||
}
|
||||
|
||||
/**
|
||||
* 活动报名结果通知
|
||||
*/
|
||||
public void sendActivityResult(String userId, String activityTitle, boolean approved) {
|
||||
String title = approved ? "活动报名成功" : "活动报名未通过";
|
||||
String content = approved
|
||||
? "您已成功报名「" + activityTitle + "」,敬请期待!"
|
||||
: "您报名的「" + activityTitle + "」未通过审核,如有疑问请联系工作人员。";
|
||||
send(userId, Constants.NOTIFICATION_TYPE_ACTIVITY, title, content,
|
||||
"activity", null, "activity:" + userId + ":" + System.currentTimeMillis());
|
||||
}
|
||||
|
||||
/**
|
||||
* 红娘推荐通知
|
||||
*/
|
||||
public void sendMatchmakerRecommendation(String userId, String matchmakerName, String targetName) {
|
||||
String title = "公益红娘为您推荐了一位朋友";
|
||||
String content = matchmakerName + "为您推荐了" + targetName + ",点击查看资料。";
|
||||
send(userId, Constants.NOTIFICATION_TYPE_MATCHMAKER, title, content,
|
||||
"matchmaker", null, "matchmaker:" + userId + ":" + System.currentTimeMillis());
|
||||
}
|
||||
|
||||
/**
|
||||
* 风险通知
|
||||
*/
|
||||
public void sendRiskWarning(String userId, String warning) {
|
||||
String title = "安全提醒";
|
||||
String content = warning;
|
||||
send(userId, Constants.NOTIFICATION_TYPE_RISK, title, content,
|
||||
"risk", null, "risk:" + userId + ":" + System.currentTimeMillis());
|
||||
}
|
||||
|
||||
/**
|
||||
* 账号状态变更通知
|
||||
*/
|
||||
public void sendAccountStatusChange(String userId, String oldStatus, String newStatus) {
|
||||
String title = "账号状态变更通知";
|
||||
String content = "您的账号状态已从「" + oldStatus + "」变更为「" + newStatus + "」。";
|
||||
send(userId, Constants.NOTIFICATION_TYPE_ACCOUNT, title, content,
|
||||
"account", null, "account:" + userId + ":" + System.currentTimeMillis());
|
||||
}
|
||||
|
||||
// ==================== V1.1 公益服务通知方法 ====================
|
||||
|
||||
/**
|
||||
* V1.1: 预约已提交通知(带工作人员ID)
|
||||
* 隐私安全:不透露具体服务内容
|
||||
*/
|
||||
public void sendAppointmentSubmitted(String userId, String operatorId, String appointmentId) {
|
||||
String title = "公益服务预约已提交";
|
||||
String content = "您的预约已提交,工作人员确认后我们会通知您。";
|
||||
send(userId, Constants.NOTIFICATION_TYPE_APPOINTMENT, title, content,
|
||||
"appointment", appointmentId, "appointment:submitted:" + userId + ":" + appointmentId);
|
||||
}
|
||||
|
||||
/**
|
||||
* V1.1: 预约已确认通知
|
||||
* 隐私安全:使用中性文案,不暴露敏感服务类型
|
||||
*/
|
||||
public void sendAppointmentConfirmed(String userId, String locationId, String appointmentId) {
|
||||
String title = "您的公益服务预约已有更新";
|
||||
String content = "您的预约已被确认,请按时前往服务站。";
|
||||
send(userId, Constants.NOTIFICATION_TYPE_APPOINTMENT, title, content,
|
||||
"appointment", appointmentId, "appointment:confirmed:" + userId + ":" + appointmentId);
|
||||
}
|
||||
|
||||
/**
|
||||
* V1.1: 预约已取消通知
|
||||
*/
|
||||
public void sendAppointmentCancelled(String userId, String appointmentId) {
|
||||
String title = "您的公益服务预约已取消";
|
||||
String content = "您的预约已被取消,如有疑问请联系工作人员。";
|
||||
send(userId, Constants.NOTIFICATION_TYPE_APPOINTMENT, title, content,
|
||||
"appointment", appointmentId, "appointment:cancelled:" + userId + ":" + appointmentId);
|
||||
}
|
||||
|
||||
/**
|
||||
* V1.1: 红娘转介通知(当红娘转介用户到公益服务时)
|
||||
* 隐私安全:不透露具体匹配信息,只告知有公益服务安排
|
||||
*/
|
||||
public void sendMatchmakerReferralCreated(String userId, String operatorName, String referralType, String referralId) {
|
||||
String title = "公益红娘为您推荐了一项服务";
|
||||
String content = operatorName + "为您推荐了" + formatReferralTypeName(referralType) + ",请查看详情确认。";
|
||||
send(userId, Constants.NOTIFICATION_TYPE_MATCHMAKER, title, content,
|
||||
"matchmaker_referral", referralId, "referral:" + referralId + ":" + userId);
|
||||
}
|
||||
|
||||
private String formatReferralTypeName(String type) {
|
||||
return switch (type) {
|
||||
case "counseling" -> "公益婚恋咨询";
|
||||
case "activity" -> "公益活动";
|
||||
case "service_station" -> "善缘服务站预约";
|
||||
default -> "公益服务";
|
||||
String externalTitle = safeTitle.getOrDefault(type, "善缘丹阳通知");
|
||||
// 外部消息正文永远不带敏感详情
|
||||
String externalContent = switch (type) {
|
||||
case "counseling_assigned", "counseling_scheduled", "counseling_completed" ->
|
||||
"您有一条新的服务通知,请打开小程序查看。";
|
||||
case "premarital_scheduled" ->
|
||||
"请按时参加,祝您幸福。";
|
||||
default -> "请打开小程序查看详情。";
|
||||
};
|
||||
|
||||
return Map.of("title", externalTitle, "content", externalContent);
|
||||
}
|
||||
|
||||
/**
|
||||
* V1.1: 咨询已分配通知(管理员分配咨询师后通知用户)
|
||||
*/
|
||||
public void sendCounselingAssigned(String userId, String appointmentId) {
|
||||
String title = "您的公益婚恋咨询已安排";
|
||||
String content = "您的咨询申请已被受理,工作人员将尽快与您联系安排服务时间。";
|
||||
send(userId, Constants.NOTIFICATION_TYPE_COUNSELING, title, content,
|
||||
"counseling", appointmentId, "counseling:assigned:" + userId + ":" + appointmentId);
|
||||
// ==================== 查询 / 标记 ====================
|
||||
|
||||
public List<Notification> list(String userId, int limit, int offset) {
|
||||
return mapper.findByUser(userId, Math.min(limit, 50), offset);
|
||||
}
|
||||
|
||||
/**
|
||||
* V1.1: 咨询已排期通知
|
||||
*/
|
||||
public void sendCounselingScheduled(String userId, String appointmentId, String scheduledTime) {
|
||||
String title = "您的公益婚恋咨询已排期";
|
||||
String content = "您的咨询已安排在 " + scheduledTime + ",请准时参加。";
|
||||
send(userId, Constants.NOTIFICATION_TYPE_COUNSELING, title, content,
|
||||
"counseling", appointmentId, "counseling:scheduled:" + userId + ":" + appointmentId);
|
||||
public int countUnread(String userId) {
|
||||
return mapper.countUnread(userId);
|
||||
}
|
||||
|
||||
/**
|
||||
* V1.1: 咨询已完成通知
|
||||
*/
|
||||
public void sendCounselingCompleted(String userId, String appointmentId) {
|
||||
String title = "您的公益婚恋咨询已完成";
|
||||
String content = "感谢您的信任,如有其他需求可随时再次申请。";
|
||||
send(userId, Constants.NOTIFICATION_TYPE_COUNSELING, title, content,
|
||||
"counseling", appointmentId, "counseling:completed:" + userId + ":" + appointmentId);
|
||||
public void markRead(String userId, String id) {
|
||||
mapper.markRead(id, userId);
|
||||
}
|
||||
|
||||
/**
|
||||
* V1.1: 本地资格需补充佐证通知
|
||||
*/
|
||||
public void sendLocalEligibilityNeedEvidence(String userId) {
|
||||
String title = "本地资格审核需补充材料";
|
||||
String content = "您的本地资格审核需要补充佐证材料,请登录后补充上传。";
|
||||
send(userId, Constants.NOTIFICATION_TYPE_LOCAL_ELIGIBILITY, title, content,
|
||||
"local_eligibility", null, "eligibility:evidence:" + userId);
|
||||
}
|
||||
|
||||
/**
|
||||
* V1.1: 本地资格核验通过通知
|
||||
*/
|
||||
public void sendLocalEligibilityVerified(String userId) {
|
||||
String title = "本地资格已核验通过";
|
||||
String content = "您的丹阳本地资格已核验通过,现在可以正常使用全部功能。";
|
||||
send(userId, Constants.NOTIFICATION_TYPE_LOCAL_ELIGIBILITY, title, content,
|
||||
"local_eligibility", null, "eligibility:verified:" + userId);
|
||||
}
|
||||
|
||||
/**
|
||||
* V1.1: 本地资格核验未通过通知
|
||||
*/
|
||||
public void sendLocalEligibilityRejected(String userId, String reason) {
|
||||
String title = "本地资格审核未通过";
|
||||
String content = "您的本地资格审核未通过,原因:" + reason + ",如有疑问请联系工作人员。";
|
||||
send(userId, Constants.NOTIFICATION_TYPE_LOCAL_ELIGIBILITY, title, content,
|
||||
"local_eligibility", null, "eligibility:rejected:" + userId);
|
||||
}
|
||||
|
||||
/**
|
||||
* V1.1: 服务站通知(预留,用于未来服务站公告推送)
|
||||
*/
|
||||
public void sendServiceLocationNotice(String userId, String locationId, String notice) {
|
||||
String title = "善缘服务站公告";
|
||||
String content = notice;
|
||||
send(userId, Constants.NOTIFICATION_TYPE_SERVICE_LOCATION, title, content,
|
||||
"service_location", locationId, "station:notice:" + userId + ":" + locationId);
|
||||
}
|
||||
|
||||
private NotificationDTO toDTO(Notification n) {
|
||||
NotificationDTO dto = new NotificationDTO();
|
||||
dto.setNotificationId(n.getId());
|
||||
dto.setType(n.getType());
|
||||
dto.setTitle(n.getTitle());
|
||||
dto.setContent(n.getContent());
|
||||
dto.setBusinessType(n.getBusinessType());
|
||||
dto.setBusinessId(n.getBusinessId());
|
||||
dto.setRead(Constants.NOTIFICATION_READ_STATUS_READ.equals(n.getReadStatus()));
|
||||
dto.setCreatedAt(n.getCreatedAt());
|
||||
return dto;
|
||||
public void markAllRead(String userId) {
|
||||
mapper.markAllRead(userId);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user