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;
|
package com.danyang.match.platform.notification.controller;
|
||||||
|
|
||||||
import com.danyang.match.platform.shared.security.principal.UserPrincipal;
|
import com.danyang.match.platform.notification.entity.Notification;
|
||||||
import com.danyang.match.platform.notification.dto.NotificationDTO;
|
|
||||||
import com.danyang.match.platform.notification.service.NotificationService;
|
import com.danyang.match.platform.notification.service.NotificationService;
|
||||||
import com.danyang.match.platform.shared.dto.ApiResponse;
|
import com.danyang.match.platform.shared.dto.ApiResponse;
|
||||||
|
import com.danyang.match.platform.shared.security.principal.UserPrincipal;
|
||||||
|
import lombok.Data;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
||||||
import org.springframework.web.bind.annotation.*;
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
|
||||||
import java.util.List;
|
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
|
@RestController
|
||||||
@RequestMapping("/notifications")
|
@RequestMapping("/notifications")
|
||||||
@@ -21,61 +28,68 @@ public class NotificationController {
|
|||||||
|
|
||||||
private final NotificationService notificationService;
|
private final NotificationService notificationService;
|
||||||
|
|
||||||
/**
|
|
||||||
* 获取通知列表
|
|
||||||
* GET /api/v1/notifications?page=1&pageSize=20
|
|
||||||
*/
|
|
||||||
@GetMapping
|
@GetMapping
|
||||||
public ApiResponse<Map<String, Object>> getNotifications(
|
public ApiResponse<List<NotificationResponse>> list(@RequestParam(defaultValue = "20") int limit,
|
||||||
@AuthenticationPrincipal UserPrincipal principal,
|
@RequestParam(defaultValue = "0") int offset,
|
||||||
@RequestParam(defaultValue = "1") int page,
|
@AuthenticationPrincipal UserPrincipal principal) {
|
||||||
@RequestParam(defaultValue = "20") int pageSize) {
|
if (principal == null) return ApiResponse.unauthorized();
|
||||||
String userId = principal.id();
|
List<Notification> list = notificationService.list(principal.id(), limit, offset);
|
||||||
List<NotificationDTO> list = notificationService.getNotifications(userId, page, pageSize);
|
return ApiResponse.ok(list.stream().map(NotificationResponse::from).toList());
|
||||||
int unreadCount = notificationService.getUnreadCount(userId);
|
}
|
||||||
Map<String, Object> result = new java.util.HashMap<>();
|
|
||||||
result.put("notifications", list);
|
@GetMapping("/unread")
|
||||||
result.put("unreadCount", unreadCount);
|
public ApiResponse<UnreadCountResponse> unread(@AuthenticationPrincipal UserPrincipal principal) {
|
||||||
result.put("page", page);
|
if (principal == null) return ApiResponse.unauthorized();
|
||||||
result.put("pageSize", pageSize);
|
return ApiResponse.ok(new UnreadCountResponse(notificationService.countUnread(principal.id())));
|
||||||
return ApiResponse.ok(result);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 标记已读
|
|
||||||
* PUT /api/v1/notifications/{id}/read
|
|
||||||
*/
|
|
||||||
@PutMapping("/{id}/read")
|
@PutMapping("/{id}/read")
|
||||||
public ApiResponse<Void> markAsRead(
|
public ApiResponse<Void> markRead(@PathVariable String id,
|
||||||
@AuthenticationPrincipal UserPrincipal principal,
|
@AuthenticationPrincipal UserPrincipal principal) {
|
||||||
@PathVariable String id) {
|
if (principal == null) return ApiResponse.unauthorized();
|
||||||
String userId = principal.id();
|
notificationService.markRead(principal.id(), id);
|
||||||
notificationService.markAsRead(userId, id);
|
|
||||||
return ApiResponse.ok();
|
return ApiResponse.ok();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 全部已读
|
|
||||||
* PUT /api/v1/notifications/read-all
|
|
||||||
*/
|
|
||||||
@PutMapping("/read-all")
|
@PutMapping("/read-all")
|
||||||
public ApiResponse<Map<String, Object>> markAllRead(
|
public ApiResponse<Void> markAllRead(@AuthenticationPrincipal UserPrincipal principal) {
|
||||||
@AuthenticationPrincipal UserPrincipal principal) {
|
if (principal == null) return ApiResponse.unauthorized();
|
||||||
String userId = principal.id();
|
notificationService.markAllRead(principal.id());
|
||||||
int count = notificationService.markAllRead(userId);
|
return ApiResponse.ok();
|
||||||
return ApiResponse.ok(Map.of("markedCount", count));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
// ==================== DTO ====================
|
||||||
* 未读数
|
|
||||||
* GET /api/v1/notifications/unread-count
|
@Data
|
||||||
*/
|
public static class NotificationResponse {
|
||||||
@GetMapping("/unread-count")
|
private String id;
|
||||||
public ApiResponse<Map<String, Object>> getUnreadCount(
|
private String type;
|
||||||
@AuthenticationPrincipal UserPrincipal principal) {
|
private String title;
|
||||||
String userId = principal.id();
|
private String content;
|
||||||
return ApiResponse.ok(Map.of("unreadCount", notificationService.getUnreadCount(userId)));
|
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.Data;
|
||||||
import lombok.EqualsAndHashCode;
|
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
|
@Data
|
||||||
@EqualsAndHashCode(callSuper = true)
|
@EqualsAndHashCode(callSuper = true)
|
||||||
@TableName("notifications")
|
@TableName("notifications")
|
||||||
public class Notification extends BaseEntity {
|
public class Notification extends BaseEntity {
|
||||||
|
|
||||||
private String userId;
|
private String userId;
|
||||||
private String type; // review, match, matchmaker, activity, risk, account, system
|
private String type;
|
||||||
private String title;
|
private String title;
|
||||||
private String content;
|
private String content;
|
||||||
private String businessType;
|
private String businessType;
|
||||||
private String businessId;
|
private String businessId;
|
||||||
private String readStatus; // unread, read
|
private String readStatus; // unread / read
|
||||||
private java.time.Instant readAt;
|
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.Mapper;
|
||||||
import org.apache.ibatis.annotations.Param;
|
import org.apache.ibatis.annotations.Param;
|
||||||
import org.apache.ibatis.annotations.Select;
|
import org.apache.ibatis.annotations.Select;
|
||||||
|
import org.apache.ibatis.annotations.Update;
|
||||||
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
|
||||||
@Mapper
|
@Mapper
|
||||||
public interface NotificationMapper extends BaseMapper<Notification> {
|
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")
|
@Select("SELECT * FROM notifications WHERE user_id = #{userId} ORDER BY created_at DESC LIMIT #{limit} OFFSET #{offset}")
|
||||||
List<Notification> findByUserId(String userId);
|
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")
|
@Select("SELECT COUNT(*) FROM notifications WHERE user_id = #{userId} AND read_status = 'unread'")
|
||||||
int countUnreadByUserId(@Param("userId") String userId);
|
int countUnread(@Param("userId") String userId);
|
||||||
|
|
||||||
@Select("UPDATE notifications SET read_status = 'read', read_at = NOW() " +
|
@Update("UPDATE notifications SET read_status = 'read', read_at = NOW() WHERE user_id = #{userId} AND read_status = 'unread'")
|
||||||
"WHERE user_id = #{userId} AND read_status = 'unread' AND deleted_at IS NULL")
|
|
||||||
int markAllRead(@Param("userId") String userId);
|
int markAllRead(@Param("userId") String userId);
|
||||||
|
|
||||||
@Select("UPDATE notifications SET read_status = 'read', read_at = NOW() " +
|
@Update("UPDATE notifications SET read_status = 'read', read_at = NOW() WHERE id = #{id} AND user_id = #{userId}")
|
||||||
"WHERE id = #{notificationId} AND user_id = #{userId} AND deleted_at IS NULL")
|
int markRead(@Param("id") String id, @Param("userId") String userId);
|
||||||
int markAsRead(@Param("notificationId") String notificationId, @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;
|
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.entity.Notification;
|
||||||
import com.danyang.match.platform.notification.mapper.NotificationMapper;
|
import com.danyang.match.platform.notification.mapper.NotificationMapper;
|
||||||
import com.danyang.match.platform.shared.constant.Constants;
|
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import org.slf4j.Logger;
|
import org.slf4j.Logger;
|
||||||
import org.slf4j.LoggerFactory;
|
import org.slf4j.LoggerFactory;
|
||||||
import org.springframework.scheduling.annotation.Async;
|
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
import org.springframework.transaction.annotation.Transactional;
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
import java.time.Instant;
|
import java.time.Instant;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import java.util.Set;
|
import java.util.UUID;
|
||||||
import java.util.concurrent.ConcurrentHashMap;
|
|
||||||
import java.util.stream.Collectors;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 统一通知服务
|
* 通知服务 — 站内 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
|
@Service
|
||||||
@RequiredArgsConstructor
|
@RequiredArgsConstructor
|
||||||
@@ -28,313 +40,209 @@ public class NotificationService {
|
|||||||
|
|
||||||
private static final Logger log = LoggerFactory.getLogger(NotificationService.class);
|
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 userId 接收者
|
||||||
* @param type 通知类型
|
* @param type 通知类型(NOTIF_TYPE_*)
|
||||||
* @param title 标题
|
* @param title 标题
|
||||||
* @param content 内容
|
* @param content 正文
|
||||||
* @param businessType 业务类型
|
* @param businessType 业务关联类型(appointment / counseling / local_eligibility 等)
|
||||||
* @param businessId 业务ID
|
* @param businessId 业务关联 ID
|
||||||
* @param idempotentKey 幂等Key(可选)
|
|
||||||
*/
|
*/
|
||||||
@Transactional
|
@Transactional
|
||||||
public void send(String userId, String type, String title, String content,
|
public Notification send(String userId, String type, String title, String content,
|
||||||
String businessType, String businessId, String... idempotentKey) {
|
String businessType, String businessId) {
|
||||||
// 幂等检查
|
// 幂等:同一 user + type + business_type + business_id + content 已存在则直接返回
|
||||||
if (idempotentKey.length > 0 && idempotentKey[0] != null && !idempotentKey[0].isBlank()) {
|
if (businessId != null) {
|
||||||
String key = idempotentKey[0];
|
Notification existing = mapper.findExisting(userId, type, businessType, businessId, content);
|
||||||
Instant now = Instant.now();
|
if (existing != null) return existing;
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Notification notification = new Notification();
|
Notification n = new Notification();
|
||||||
notification.setUserId(userId);
|
n.setId(UUID.randomUUID().toString());
|
||||||
notification.setType(type);
|
n.setUserId(userId);
|
||||||
notification.setTitle(title);
|
n.setType(type);
|
||||||
notification.setContent(content);
|
n.setTitle(title);
|
||||||
notification.setBusinessType(businessType);
|
n.setContent(content);
|
||||||
// businessId must be a valid UUID or null
|
n.setBusinessType(businessType);
|
||||||
try { java.util.UUID.fromString(businessId); notification.setBusinessId(businessId); }
|
n.setBusinessId(businessId);
|
||||||
catch (IllegalArgumentException e) { notification.setBusinessId(null); }
|
n.setReadStatus("unread");
|
||||||
notification.setReadStatus(Constants.NOTIFICATION_READ_STATUS_UNREAD);
|
mapper.insert(n);
|
||||||
notificationMapper.insert(notification);
|
|
||||||
|
|
||||||
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 后续接入微信订阅消息推送
|
return n;
|
||||||
// wechatSubscribeMessageAdapter.send(userId, type, title, content);
|
}
|
||||||
|
|
||||||
|
// ==================== 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
|
private Map<String, String> buildPrivacySafeCopy(String type, String title, String content) {
|
||||||
public void sendAsync(String userId, String type, String title, String content,
|
// 映射表:通知 type → 外部安全标题
|
||||||
String businessType, String businessId) {
|
Map<String, String> safeTitle = Map.ofEntries(
|
||||||
send(userId, type, title, content, businessType, businessId);
|
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", "资格未通过")
|
||||||
|
);
|
||||||
|
|
||||||
/**
|
String externalTitle = safeTitle.getOrDefault(type, "善缘丹阳通知");
|
||||||
* 获取通知列表
|
// 外部消息正文永远不带敏感详情
|
||||||
*/
|
String externalContent = switch (type) {
|
||||||
public List<NotificationDTO> getNotifications(String userId, int page, int pageSize) {
|
case "counseling_assigned", "counseling_scheduled", "counseling_completed" ->
|
||||||
List<Notification> notifications = notificationMapper.findByUserId(userId);
|
"您有一条新的服务通知,请打开小程序查看。";
|
||||||
int from = Math.min((page - 1) * pageSize, notifications.size());
|
case "premarital_scheduled" ->
|
||||||
int to = Math.min(from + pageSize, notifications.size());
|
"请按时参加,祝您幸福。";
|
||||||
|
default -> "请打开小程序查看详情。";
|
||||||
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 -> "公益服务";
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
return Map.of("title", externalTitle, "content", externalContent);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
// ==================== 查询 / 标记 ====================
|
||||||
* V1.1: 咨询已分配通知(管理员分配咨询师后通知用户)
|
|
||||||
*/
|
public List<Notification> list(String userId, int limit, int offset) {
|
||||||
public void sendCounselingAssigned(String userId, String appointmentId) {
|
return mapper.findByUser(userId, Math.min(limit, 50), offset);
|
||||||
String title = "您的公益婚恋咨询已安排";
|
|
||||||
String content = "您的咨询申请已被受理,工作人员将尽快与您联系安排服务时间。";
|
|
||||||
send(userId, Constants.NOTIFICATION_TYPE_COUNSELING, title, content,
|
|
||||||
"counseling", appointmentId, "counseling:assigned:" + userId + ":" + appointmentId);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
public int countUnread(String userId) {
|
||||||
* V1.1: 咨询已排期通知
|
return mapper.countUnread(userId);
|
||||||
*/
|
|
||||||
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 void markRead(String userId, String id) {
|
||||||
* V1.1: 咨询已完成通知
|
mapper.markRead(id, userId);
|
||||||
*/
|
|
||||||
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 markAllRead(String userId) {
|
||||||
* V1.1: 本地资格需补充佐证通知
|
mapper.markAllRead(userId);
|
||||||
*/
|
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user