From 8adead23b6c0bf04b8d856dfccb00a9d8cfc4222 Mon Sep 17 00:00:00 2001 From: hailin Date: Thu, 12 Feb 2026 23:48:52 -0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E8=AE=BE=E5=A4=87=E6=8E=A8=E9=80=81?= =?UTF-8?q?=E7=B3=BB=E7=BB=9F=20=E2=80=94=20FCM/APNs/HMS/=E5=B0=8F?= =?UTF-8?q?=E7=B1=B3/OPPO/vivo=20=E5=A4=9A=E9=80=9A=E9=81=93=E6=8E=A8?= =?UTF-8?q?=E9=80=81=20+=20PROMOTION=E5=B9=BF=E5=91=8A=E7=B1=BB=E5=9E=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增设备推送通道架构,支持6大推送平台的多通道路由: 【SQL迁移】 - 039_create_device_tokens.sql: device_tokens表 (userId, platform, channel, token, deviceId等) - announcements表新增 PROMOTION 类型约束 【Domain层 — 实体/接口/端口】 - DeviceToken实体: DevicePlatform枚举(ANDROID/IOS) + PushChannel枚举(FCM/APNS/HMS/XIAOMI/OPPO/VIVO) - IDeviceTokenRepository: 6个仓储方法 (findActive/upsert/deactivate/countByChannel) - IPushChannelProvider端口: PushPayload/PushResult类型 + 6个DI Symbol - AnnouncementType枚举: 新增 PROMOTION (广告/促销推送) 【Infrastructure层 — 持久化/推送通道】 - DeviceTokenRepositoryImpl: TypeORM实现,支持批量IN查询(每批500)、token upsert - 6个推送通道Provider (mock骨架,结构完整可直接替换为真实SDK): · FcmPushProvider — Google FCM (firebase-admin) · ApnsPushProvider — Apple APNs (HTTP/2直连,中国区必需) · HmsPushProvider — 华为HMS Push Kit · XiaomiPushProvider — 小米Mi Push · OppoPushProvider — OPPO Push · VivoPushProvider — vivo Push - PushNotificationProvider重构: 从mock改为委托PushDispatcherService 【Application层 — 服务】 - PushDispatcherService: 核心调度器 · sendToUser/sendToUsers → 查device tokens → 按channel分组 → 路由到对应provider · 自动清理无效token (shouldDeactivateToken) - DeviceTokenService: 设备token注册/注销/查询 - AnnouncementService: 公告创建后自动触发设备推送 · BY_TAG → 解析tag对应userIds → pushDispatcher.sendToUsers · SPECIFIC → 直接推送指定userIds 【Interface层 — DTO/Controller】 - RegisterDeviceTokenDto/UnregisterDeviceTokenDto: Swagger + class-validator - DeviceTokenController: POST/DELETE/GET /device-tokens (JWT认证) 【Module注册】 - notification.module.ts: 新增DeviceToken实体、DEVICE_TOKEN_REPOSITORY、 6个push channel provider DI绑定、PushDispatcherService、DeviceTokenService、 DeviceTokenController 推送链路: 公告创建 → triggerPush → PushDispatcher → 查token → 按channel分组 → FCM/APNs/HMS/小米/OPPO/vivo Co-Authored-By: Claude Opus 4.6 --- .../migrations/039_create_device_tokens.sql | 26 ++++ .../services/announcement.service.ts | 74 ++++++++++ .../services/device-token.service.ts | 60 ++++++++ .../services/push-dispatcher.service.ts | 138 ++++++++++++++++++ .../domain/entities/announcement.entity.ts | 1 + .../domain/entities/device-token.entity.ts | 71 +++++++++ .../ports/push-channel-provider.interface.ts | 30 ++++ .../device-token.repository.interface.ts | 12 ++ .../persistence/device-token.repository.ts | 84 +++++++++++ .../push-channels/apns-push.provider.ts | 42 ++++++ .../push-channels/fcm-push.provider.ts | 42 ++++++ .../push-channels/hms-push.provider.ts | 42 ++++++ .../providers/push-channels/index.ts | 6 + .../push-channels/oppo-push.provider.ts | 42 ++++++ .../push-channels/vivo-push.provider.ts | 42 ++++++ .../push-channels/xiaomi-push.provider.ts | 42 ++++++ .../providers/push-notification.provider.ts | 35 +++-- .../controllers/device-token.controller.ts | 56 +++++++ .../interface/http/dto/device-token.dto.ts | 41 ++++++ .../src/notification.module.ts | 43 +++++- 20 files changed, 917 insertions(+), 12 deletions(-) create mode 100644 backend/migrations/039_create_device_tokens.sql create mode 100644 backend/services/notification-service/src/application/services/device-token.service.ts create mode 100644 backend/services/notification-service/src/application/services/push-dispatcher.service.ts create mode 100644 backend/services/notification-service/src/domain/entities/device-token.entity.ts create mode 100644 backend/services/notification-service/src/domain/ports/push-channel-provider.interface.ts create mode 100644 backend/services/notification-service/src/domain/repositories/device-token.repository.interface.ts create mode 100644 backend/services/notification-service/src/infrastructure/persistence/device-token.repository.ts create mode 100644 backend/services/notification-service/src/infrastructure/providers/push-channels/apns-push.provider.ts create mode 100644 backend/services/notification-service/src/infrastructure/providers/push-channels/fcm-push.provider.ts create mode 100644 backend/services/notification-service/src/infrastructure/providers/push-channels/hms-push.provider.ts create mode 100644 backend/services/notification-service/src/infrastructure/providers/push-channels/index.ts create mode 100644 backend/services/notification-service/src/infrastructure/providers/push-channels/oppo-push.provider.ts create mode 100644 backend/services/notification-service/src/infrastructure/providers/push-channels/vivo-push.provider.ts create mode 100644 backend/services/notification-service/src/infrastructure/providers/push-channels/xiaomi-push.provider.ts create mode 100644 backend/services/notification-service/src/interface/http/controllers/device-token.controller.ts create mode 100644 backend/services/notification-service/src/interface/http/dto/device-token.dto.ts diff --git a/backend/migrations/039_create_device_tokens.sql b/backend/migrations/039_create_device_tokens.sql new file mode 100644 index 0000000..5a430ff --- /dev/null +++ b/backend/migrations/039_create_device_tokens.sql @@ -0,0 +1,26 @@ +-- 039: Device tokens for push notification channels +-- Stores device push tokens per user, supporting multiple channels: +-- FCM (Google), APNs (Apple), HMS (Huawei), XIAOMI, OPPO, VIVO + +CREATE TABLE device_tokens ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL, + platform VARCHAR(20) NOT NULL CHECK (platform IN ('ANDROID','IOS')), + channel VARCHAR(20) NOT NULL CHECK (channel IN ('FCM','APNS','HMS','XIAOMI','OPPO','VIVO')), + token TEXT NOT NULL, + device_id VARCHAR(200), + device_model VARCHAR(200), + app_version VARCHAR(50), + is_active BOOLEAN NOT NULL DEFAULT true, + last_used_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE UNIQUE INDEX idx_device_tokens_token ON device_tokens(token); +CREATE INDEX idx_device_tokens_user_active ON device_tokens(user_id, is_active); + +-- Add PROMOTION type to announcements for advertisement / promotional push +ALTER TABLE announcements DROP CONSTRAINT IF EXISTS announcements_type_check; +ALTER TABLE announcements ADD CONSTRAINT announcements_type_check + CHECK (type IN ('SYSTEM','ACTIVITY','REWARD','UPGRADE','ANNOUNCEMENT','PROMOTION')); diff --git a/backend/services/notification-service/src/application/services/announcement.service.ts b/backend/services/notification-service/src/application/services/announcement.service.ts index fa3d470..75d2244 100644 --- a/backend/services/notification-service/src/application/services/announcement.service.ts +++ b/backend/services/notification-service/src/application/services/announcement.service.ts @@ -14,6 +14,7 @@ import { USER_TAG_REPOSITORY, IUserTagRepository, } from '../../domain/repositories/user-tag.repository.interface'; +import { PushDispatcherService } from './push-dispatcher.service'; export interface CreateAnnouncementParams { title: string; @@ -52,6 +53,7 @@ export class AnnouncementService { private readonly announcementRepo: IAnnouncementRepository, @Inject(USER_TAG_REPOSITORY) private readonly userTagRepo: IUserTagRepository, + private readonly pushDispatcher: PushDispatcherService, ) {} async create(params: CreateAnnouncementParams): Promise { @@ -97,6 +99,15 @@ export class AnnouncementService { } this.logger.log(`Announcement created: ${saved.id} (targetType=${targetType})`); + + // If published immediately (publishedAt is null or ≤ now), trigger push + const shouldPush = !saved.publishedAt || saved.publishedAt <= new Date(); + if (shouldPush) { + this.triggerPushForAnnouncement(saved).catch((err) => + this.logger.error(`Push trigger failed for announcement ${saved.id}: ${err.message}`), + ); + } + return saved; } @@ -223,4 +234,67 @@ export class AnnouncementService { const userTags = await this.userTagRepo.findTagsByUserId(userId); await this.announcementRepo.markAllAsRead(userId, userTags); } + + // ── Push Integration ── + + /** + * Trigger device push for an announcement based on its targeting: + * - ALL → sendToUsers with empty filter (all registered tokens) + * - BY_TAG → resolve userIds from tags, then push + * - SPECIFIC → push to specific userIds + */ + private async triggerPushForAnnouncement(announcement: Announcement): Promise { + const data: Record = { + announcementId: announcement.id, + type: announcement.type, + }; + if (announcement.linkUrl) { + data.linkUrl = announcement.linkUrl; + } + + if (announcement.targetType === TargetType.ALL) { + // For ALL targeting, pass empty array — PushDispatcher handles gracefully + // In production, this would page through all users + this.logger.log(`Push for ALL-targeted announcement ${announcement.id} (broadcast)`); + // Broadcast push requires fetching all user IDs or using topic-based push + // For now we log; real implementation would use FCM topics or iterate users + return; + } + + if (announcement.targetType === TargetType.BY_TAG && announcement.targetConfig?.tags?.length) { + const tags = announcement.targetConfig.tags; + const userIds: string[] = []; + for (const tag of tags) { + const ids = await this.userTagRepo.findUserIdsByTag(tag); + userIds.push(...ids); + } + // Deduplicate + const uniqueUserIds = [...new Set(userIds)]; + if (uniqueUserIds.length > 0) { + this.logger.log( + `Push for BY_TAG announcement ${announcement.id}: ${uniqueUserIds.length} users`, + ); + await this.pushDispatcher.sendToUsers( + uniqueUserIds, + announcement.title, + announcement.content.slice(0, 200), + data, + ); + } + return; + } + + if (announcement.targetType === TargetType.SPECIFIC && announcement.targetConfig?.userIds?.length) { + const userIds = announcement.targetConfig.userIds; + this.logger.log( + `Push for SPECIFIC announcement ${announcement.id}: ${userIds.length} users`, + ); + await this.pushDispatcher.sendToUsers( + userIds, + announcement.title, + announcement.content.slice(0, 200), + data, + ); + } + } } diff --git a/backend/services/notification-service/src/application/services/device-token.service.ts b/backend/services/notification-service/src/application/services/device-token.service.ts new file mode 100644 index 0000000..c1954f3 --- /dev/null +++ b/backend/services/notification-service/src/application/services/device-token.service.ts @@ -0,0 +1,60 @@ +import { Injectable, Inject, Logger } from '@nestjs/common'; +import { DeviceToken, DevicePlatform, PushChannel } from '../../domain/entities/device-token.entity'; +import { + DEVICE_TOKEN_REPOSITORY, + IDeviceTokenRepository, +} from '../../domain/repositories/device-token.repository.interface'; + +export interface RegisterTokenParams { + userId: string; + platform: DevicePlatform; + channel: PushChannel; + token: string; + deviceId?: string; + deviceModel?: string; + appVersion?: string; +} + +@Injectable() +export class DeviceTokenService { + private readonly logger = new Logger(DeviceTokenService.name); + + constructor( + @Inject(DEVICE_TOKEN_REPOSITORY) + private readonly deviceTokenRepo: IDeviceTokenRepository, + ) {} + + async registerToken(params: RegisterTokenParams): Promise { + const saved = await this.deviceTokenRepo.upsertToken({ + userId: params.userId, + platform: params.platform, + channel: params.channel, + token: params.token, + deviceId: params.deviceId ?? null, + deviceModel: params.deviceModel ?? null, + appVersion: params.appVersion ?? null, + }); + this.logger.log( + `Device token registered: user=${params.userId} channel=${params.channel} platform=${params.platform}`, + ); + return saved; + } + + async unregisterToken(token: string): Promise { + await this.deviceTokenRepo.deactivateToken(token); + this.logger.log(`Device token unregistered: ${token.slice(0, 12)}…`); + } + + async unregisterAllForUser(userId: string): Promise { + await this.deviceTokenRepo.deactivateAllForUser(userId); + this.logger.log(`All device tokens deactivated for user ${userId}`); + } + + async getActiveTokens(userId: string): Promise { + return this.deviceTokenRepo.findActiveByUserId(userId); + } + + async getChannelStats(): Promise> { + return this.deviceTokenRepo.countByChannel(); + } +} diff --git a/backend/services/notification-service/src/application/services/push-dispatcher.service.ts b/backend/services/notification-service/src/application/services/push-dispatcher.service.ts new file mode 100644 index 0000000..5afde23 --- /dev/null +++ b/backend/services/notification-service/src/application/services/push-dispatcher.service.ts @@ -0,0 +1,138 @@ +import { Injectable, Inject, Logger } from '@nestjs/common'; +import { PushChannel } from '../../domain/entities/device-token.entity'; +import { + DEVICE_TOKEN_REPOSITORY, + IDeviceTokenRepository, +} from '../../domain/repositories/device-token.repository.interface'; +import { + IPushChannelProvider, + PushPayload, + FCM_PUSH_PROVIDER, + APNS_PUSH_PROVIDER, + HMS_PUSH_PROVIDER, + XIAOMI_PUSH_PROVIDER, + OPPO_PUSH_PROVIDER, + VIVO_PUSH_PROVIDER, +} from '../../domain/ports/push-channel-provider.interface'; + +@Injectable() +export class PushDispatcherService { + private readonly logger = new Logger(PushDispatcherService.name); + private readonly providerMap: Map; + + constructor( + @Inject(DEVICE_TOKEN_REPOSITORY) + private readonly deviceTokenRepo: IDeviceTokenRepository, + @Inject(FCM_PUSH_PROVIDER) fcm: IPushChannelProvider, + @Inject(APNS_PUSH_PROVIDER) apns: IPushChannelProvider, + @Inject(HMS_PUSH_PROVIDER) hms: IPushChannelProvider, + @Inject(XIAOMI_PUSH_PROVIDER) xiaomi: IPushChannelProvider, + @Inject(OPPO_PUSH_PROVIDER) oppo: IPushChannelProvider, + @Inject(VIVO_PUSH_PROVIDER) vivo: IPushChannelProvider, + ) { + this.providerMap = new Map([ + [PushChannel.FCM, fcm], + [PushChannel.APNS, apns], + [PushChannel.HMS, hms], + [PushChannel.XIAOMI, xiaomi], + [PushChannel.OPPO, oppo], + [PushChannel.VIVO, vivo], + ]); + } + + /** + * Send push to a single user across all their registered devices. + */ + async sendToUser( + userId: string, + title: string, + body: string, + data?: Record, + ): Promise { + const tokens = await this.deviceTokenRepo.findActiveByUserId(userId); + if (!tokens.length) { + this.logger.debug(`No active device tokens for user ${userId}`); + return; + } + + // Group tokens by channel + const grouped = new Map(); + for (const t of tokens) { + const payloads = grouped.get(t.channel) ?? []; + payloads.push({ token: t.token, title, body, data }); + grouped.set(t.channel, payloads); + } + + await this.dispatchByChannel(grouped); + } + + /** + * Send push to multiple users (batch). + * Fetches tokens in batches of 500, then dispatches by channel. + */ + async sendToUsers( + userIds: string[], + title: string, + body: string, + data?: Record, + ): Promise { + if (!userIds.length) return; + + const tokens = await this.deviceTokenRepo.findActiveByUserIds(userIds); + if (!tokens.length) { + this.logger.debug(`No active device tokens for ${userIds.length} users`); + return; + } + + // Group tokens by channel + const grouped = new Map(); + for (const t of tokens) { + const payloads = grouped.get(t.channel) ?? []; + payloads.push({ token: t.token, title, body, data }); + grouped.set(t.channel, payloads); + } + + await this.dispatchByChannel(grouped); + } + + private async dispatchByChannel( + grouped: Map, + ): Promise { + const promises: Promise[] = []; + + for (const [channel, payloads] of grouped.entries()) { + const provider = this.providerMap.get(channel); + if (!provider) { + this.logger.warn(`No provider registered for channel ${channel}`); + continue; + } + + promises.push( + provider + .sendBatch(payloads) + .then(async (results) => { + let successCount = 0; + for (let i = 0; i < results.length; i++) { + if (results[i].success) { + successCount++; + } else if (results[i].shouldDeactivateToken) { + // Token is invalid, deactivate it + await this.deviceTokenRepo.deactivateToken(payloads[i].token); + this.logger.warn( + `Deactivated invalid token: ${payloads[i].token.slice(0, 12)}… (${channel})`, + ); + } + } + this.logger.log( + `[${channel}] Sent ${successCount}/${payloads.length} push notifications`, + ); + }) + .catch((err) => { + this.logger.error(`[${channel}] Batch send failed: ${err.message}`); + }), + ); + } + + await Promise.allSettled(promises); + } +} diff --git a/backend/services/notification-service/src/domain/entities/announcement.entity.ts b/backend/services/notification-service/src/domain/entities/announcement.entity.ts index 0b3c040..bf1b805 100644 --- a/backend/services/notification-service/src/domain/entities/announcement.entity.ts +++ b/backend/services/notification-service/src/domain/entities/announcement.entity.ts @@ -13,6 +13,7 @@ export enum AnnouncementType { REWARD = 'REWARD', UPGRADE = 'UPGRADE', ANNOUNCEMENT = 'ANNOUNCEMENT', + PROMOTION = 'PROMOTION', } export enum AnnouncementPriority { diff --git a/backend/services/notification-service/src/domain/entities/device-token.entity.ts b/backend/services/notification-service/src/domain/entities/device-token.entity.ts new file mode 100644 index 0000000..06cd8f2 --- /dev/null +++ b/backend/services/notification-service/src/domain/entities/device-token.entity.ts @@ -0,0 +1,71 @@ +import { + Entity, + Column, + PrimaryGeneratedColumn, + CreateDateColumn, + UpdateDateColumn, + Index, +} from 'typeorm'; + +export enum DevicePlatform { + ANDROID = 'ANDROID', + IOS = 'IOS', +} + +export enum PushChannel { + FCM = 'FCM', + APNS = 'APNS', + HMS = 'HMS', + XIAOMI = 'XIAOMI', + OPPO = 'OPPO', + VIVO = 'VIVO', +} + +@Entity('device_tokens') +@Index('idx_device_tokens_token', ['token'], { unique: true }) +@Index('idx_device_tokens_user_active', ['userId', 'isActive']) +export class DeviceToken { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ name: 'user_id', type: 'uuid' }) + userId: string; + + @Column({ type: 'varchar', length: 20 }) + platform: DevicePlatform; + + @Column({ type: 'varchar', length: 20 }) + channel: PushChannel; + + @Column({ type: 'text' }) + token: string; + + @Column({ name: 'device_id', type: 'varchar', length: 200, nullable: true }) + deviceId: string | null; + + @Column({ name: 'device_model', type: 'varchar', length: 200, nullable: true }) + deviceModel: string | null; + + @Column({ name: 'app_version', type: 'varchar', length: 50, nullable: true }) + appVersion: string | null; + + @Column({ name: 'is_active', type: 'boolean', default: true }) + isActive: boolean; + + @Column({ name: 'last_used_at', type: 'timestamptz', nullable: true }) + lastUsedAt: Date | null; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' }) + updatedAt: Date; + + // ── Domain Methods ── + + isExpired(maxAgeDays = 90): boolean { + if (!this.lastUsedAt) return false; + const diffMs = Date.now() - this.lastUsedAt.getTime(); + return diffMs > maxAgeDays * 24 * 60 * 60 * 1000; + } +} diff --git a/backend/services/notification-service/src/domain/ports/push-channel-provider.interface.ts b/backend/services/notification-service/src/domain/ports/push-channel-provider.interface.ts new file mode 100644 index 0000000..ada7649 --- /dev/null +++ b/backend/services/notification-service/src/domain/ports/push-channel-provider.interface.ts @@ -0,0 +1,30 @@ +import { PushChannel } from '../entities/device-token.entity'; + +export interface PushPayload { + token: string; + title: string; + body: string; + data?: Record; + imageUrl?: string; +} + +export interface PushResult { + success: boolean; + messageId?: string; + error?: string; + /** If true, the token is invalid and should be deactivated */ + shouldDeactivateToken?: boolean; +} + +export interface IPushChannelProvider { + readonly channel: PushChannel; + send(payload: PushPayload): Promise; + sendBatch(payloads: PushPayload[]): Promise; +} + +export const FCM_PUSH_PROVIDER = Symbol('IFcmPushProvider'); +export const APNS_PUSH_PROVIDER = Symbol('IApnsPushProvider'); +export const HMS_PUSH_PROVIDER = Symbol('IHmsPushProvider'); +export const XIAOMI_PUSH_PROVIDER = Symbol('IXiaomiPushProvider'); +export const OPPO_PUSH_PROVIDER = Symbol('IOppoPushProvider'); +export const VIVO_PUSH_PROVIDER = Symbol('IVivoPushProvider'); diff --git a/backend/services/notification-service/src/domain/repositories/device-token.repository.interface.ts b/backend/services/notification-service/src/domain/repositories/device-token.repository.interface.ts new file mode 100644 index 0000000..a51119c --- /dev/null +++ b/backend/services/notification-service/src/domain/repositories/device-token.repository.interface.ts @@ -0,0 +1,12 @@ +import { DeviceToken, PushChannel } from '../entities/device-token.entity'; + +export interface IDeviceTokenRepository { + findActiveByUserId(userId: string): Promise; + findActiveByUserIds(userIds: string[]): Promise; + upsertToken(data: Partial): Promise; + deactivateToken(token: string): Promise; + deactivateAllForUser(userId: string): Promise; + countByChannel(): Promise>; +} + +export const DEVICE_TOKEN_REPOSITORY = Symbol('IDeviceTokenRepository'); diff --git a/backend/services/notification-service/src/infrastructure/persistence/device-token.repository.ts b/backend/services/notification-service/src/infrastructure/persistence/device-token.repository.ts new file mode 100644 index 0000000..5f604e3 --- /dev/null +++ b/backend/services/notification-service/src/infrastructure/persistence/device-token.repository.ts @@ -0,0 +1,84 @@ +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository, In } from 'typeorm'; +import { DeviceToken, PushChannel } from '../../domain/entities/device-token.entity'; +import { IDeviceTokenRepository } from '../../domain/repositories/device-token.repository.interface'; + +@Injectable() +export class DeviceTokenRepositoryImpl implements IDeviceTokenRepository { + constructor( + @InjectRepository(DeviceToken) + private readonly repo: Repository, + ) {} + + async findActiveByUserId(userId: string): Promise { + return this.repo.find({ + where: { userId, isActive: true }, + order: { updatedAt: 'DESC' }, + }); + } + + async findActiveByUserIds(userIds: string[]): Promise { + if (!userIds.length) return []; + + // Process in batches of 500 to avoid oversized IN clauses + const results: DeviceToken[] = []; + for (let i = 0; i < userIds.length; i += 500) { + const batch = userIds.slice(i, i + 500); + const tokens = await this.repo.find({ + where: { userId: In(batch), isActive: true }, + }); + results.push(...tokens); + } + return results; + } + + async upsertToken(data: Partial): Promise { + // Find by token value (unique) + const existing = await this.repo.findOne({ where: { token: data.token! } }); + + if (existing) { + // Update existing — may be re-registered by same or different user + existing.userId = data.userId ?? existing.userId; + existing.platform = data.platform ?? existing.platform; + existing.channel = data.channel ?? existing.channel; + existing.deviceId = data.deviceId ?? existing.deviceId; + existing.deviceModel = data.deviceModel ?? existing.deviceModel; + existing.appVersion = data.appVersion ?? existing.appVersion; + existing.isActive = true; + existing.lastUsedAt = new Date(); + return this.repo.save(existing); + } + + // Create new + const token = this.repo.create({ + ...data, + isActive: true, + lastUsedAt: new Date(), + }); + return this.repo.save(token); + } + + async deactivateToken(token: string): Promise { + await this.repo.update({ token }, { isActive: false }); + } + + async deactivateAllForUser(userId: string): Promise { + await this.repo.update({ userId, isActive: true }, { isActive: false }); + } + + async countByChannel(): Promise> { + const result = await this.repo + .createQueryBuilder('dt') + .select('dt.channel', 'channel') + .addSelect('COUNT(*)', 'count') + .where('dt.is_active = true') + .groupBy('dt.channel') + .getRawMany(); + + return result.map((r) => ({ + channel: r.channel as PushChannel, + count: parseInt(r.count, 10), + })); + } +} diff --git a/backend/services/notification-service/src/infrastructure/providers/push-channels/apns-push.provider.ts b/backend/services/notification-service/src/infrastructure/providers/push-channels/apns-push.provider.ts new file mode 100644 index 0000000..dd2f18d --- /dev/null +++ b/backend/services/notification-service/src/infrastructure/providers/push-channels/apns-push.provider.ts @@ -0,0 +1,42 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { PushChannel } from '../../../domain/entities/device-token.entity'; +import { + IPushChannelProvider, + PushPayload, + PushResult, +} from '../../../domain/ports/push-channel-provider.interface'; + +/** + * Apple Push Notification service (APNs) provider. + * + * Production integration: + * npm install @parse/node-apn (or use HTTP/2 client directly) + * Environment: APNS_KEY_ID, APNS_TEAM_ID, APNS_KEY_PATH, APNS_BUNDLE_ID, APNS_PRODUCTION (bool) + * + * Primary channel for iOS devices in China where FCM is unreliable. + */ +@Injectable() +export class ApnsPushProvider implements IPushChannelProvider { + private readonly logger = new Logger(ApnsPushProvider.name); + readonly channel = PushChannel.APNS; + + async send(payload: PushPayload): Promise { + // TODO: Replace with APNs HTTP/2 push + this.logger.log( + `[MOCK APNs] → token=${payload.token.slice(0, 12)}… title="${payload.title}"`, + ); + return { + success: true, + messageId: `apns-mock-${Date.now()}`, + }; + } + + async sendBatch(payloads: PushPayload[]): Promise { + // TODO: Replace with parallel APNs sends + this.logger.log(`[MOCK APNs] Batch send: ${payloads.length} messages`); + return payloads.map((p) => ({ + success: true, + messageId: `apns-mock-${Date.now()}-${p.token.slice(0, 8)}`, + })); + } +} diff --git a/backend/services/notification-service/src/infrastructure/providers/push-channels/fcm-push.provider.ts b/backend/services/notification-service/src/infrastructure/providers/push-channels/fcm-push.provider.ts new file mode 100644 index 0000000..8543e5f --- /dev/null +++ b/backend/services/notification-service/src/infrastructure/providers/push-channels/fcm-push.provider.ts @@ -0,0 +1,42 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { PushChannel } from '../../../domain/entities/device-token.entity'; +import { + IPushChannelProvider, + PushPayload, + PushResult, +} from '../../../domain/ports/push-channel-provider.interface'; + +/** + * Google Firebase Cloud Messaging (FCM) push provider. + * + * Production integration: + * npm install firebase-admin + * Environment: FIREBASE_SERVICE_ACCOUNT_JSON (path or JSON string) + * + * Supports both Android and iOS devices globally. + */ +@Injectable() +export class FcmPushProvider implements IPushChannelProvider { + private readonly logger = new Logger(FcmPushProvider.name); + readonly channel = PushChannel.FCM; + + async send(payload: PushPayload): Promise { + // TODO: Replace with firebase-admin messaging.send() + this.logger.log( + `[MOCK FCM] → token=${payload.token.slice(0, 12)}… title="${payload.title}"`, + ); + return { + success: true, + messageId: `fcm-mock-${Date.now()}`, + }; + } + + async sendBatch(payloads: PushPayload[]): Promise { + // TODO: Replace with firebase-admin messaging.sendEach() + this.logger.log(`[MOCK FCM] Batch send: ${payloads.length} messages`); + return payloads.map((p) => ({ + success: true, + messageId: `fcm-mock-${Date.now()}-${p.token.slice(0, 8)}`, + })); + } +} diff --git a/backend/services/notification-service/src/infrastructure/providers/push-channels/hms-push.provider.ts b/backend/services/notification-service/src/infrastructure/providers/push-channels/hms-push.provider.ts new file mode 100644 index 0000000..c2068eb --- /dev/null +++ b/backend/services/notification-service/src/infrastructure/providers/push-channels/hms-push.provider.ts @@ -0,0 +1,42 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { PushChannel } from '../../../domain/entities/device-token.entity'; +import { + IPushChannelProvider, + PushPayload, + PushResult, +} from '../../../domain/ports/push-channel-provider.interface'; + +/** + * Huawei Mobile Services (HMS) Push Kit provider. + * + * Production integration: + * HMS Push Kit REST API (https://developer.huawei.com/consumer/en/hms/huawei-pushkit) + * Environment: HMS_APP_ID, HMS_APP_SECRET, HMS_TOKEN_URL, HMS_PUSH_URL + * + * Required for Huawei devices in China (no Google Play Services). + */ +@Injectable() +export class HmsPushProvider implements IPushChannelProvider { + private readonly logger = new Logger(HmsPushProvider.name); + readonly channel = PushChannel.HMS; + + async send(payload: PushPayload): Promise { + // TODO: Replace with HMS Push Kit REST API call + this.logger.log( + `[MOCK HMS] → token=${payload.token.slice(0, 12)}… title="${payload.title}"`, + ); + return { + success: true, + messageId: `hms-mock-${Date.now()}`, + }; + } + + async sendBatch(payloads: PushPayload[]): Promise { + // TODO: Replace with HMS batch push + this.logger.log(`[MOCK HMS] Batch send: ${payloads.length} messages`); + return payloads.map((p) => ({ + success: true, + messageId: `hms-mock-${Date.now()}-${p.token.slice(0, 8)}`, + })); + } +} diff --git a/backend/services/notification-service/src/infrastructure/providers/push-channels/index.ts b/backend/services/notification-service/src/infrastructure/providers/push-channels/index.ts new file mode 100644 index 0000000..6af2186 --- /dev/null +++ b/backend/services/notification-service/src/infrastructure/providers/push-channels/index.ts @@ -0,0 +1,6 @@ +export { FcmPushProvider } from './fcm-push.provider'; +export { ApnsPushProvider } from './apns-push.provider'; +export { HmsPushProvider } from './hms-push.provider'; +export { XiaomiPushProvider } from './xiaomi-push.provider'; +export { OppoPushProvider } from './oppo-push.provider'; +export { VivoPushProvider } from './vivo-push.provider'; diff --git a/backend/services/notification-service/src/infrastructure/providers/push-channels/oppo-push.provider.ts b/backend/services/notification-service/src/infrastructure/providers/push-channels/oppo-push.provider.ts new file mode 100644 index 0000000..a2b4889 --- /dev/null +++ b/backend/services/notification-service/src/infrastructure/providers/push-channels/oppo-push.provider.ts @@ -0,0 +1,42 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { PushChannel } from '../../../domain/entities/device-token.entity'; +import { + IPushChannelProvider, + PushPayload, + PushResult, +} from '../../../domain/ports/push-channel-provider.interface'; + +/** + * OPPO Push provider. + * + * Production integration: + * OPPO Push HTTP API (https://open.oppomobile.com/new/developmentDoc/info?id=11224) + * Environment: OPPO_APP_KEY, OPPO_MASTER_SECRET + * + * Required for OPPO/OnePlus/Realme devices in China. + */ +@Injectable() +export class OppoPushProvider implements IPushChannelProvider { + private readonly logger = new Logger(OppoPushProvider.name); + readonly channel = PushChannel.OPPO; + + async send(payload: PushPayload): Promise { + // TODO: Replace with OPPO Push HTTP API call + this.logger.log( + `[MOCK OPPO] → token=${payload.token.slice(0, 12)}… title="${payload.title}"`, + ); + return { + success: true, + messageId: `oppo-mock-${Date.now()}`, + }; + } + + async sendBatch(payloads: PushPayload[]): Promise { + // TODO: Replace with OPPO Push batch API + this.logger.log(`[MOCK OPPO] Batch send: ${payloads.length} messages`); + return payloads.map((p) => ({ + success: true, + messageId: `oppo-mock-${Date.now()}-${p.token.slice(0, 8)}`, + })); + } +} diff --git a/backend/services/notification-service/src/infrastructure/providers/push-channels/vivo-push.provider.ts b/backend/services/notification-service/src/infrastructure/providers/push-channels/vivo-push.provider.ts new file mode 100644 index 0000000..287e63e --- /dev/null +++ b/backend/services/notification-service/src/infrastructure/providers/push-channels/vivo-push.provider.ts @@ -0,0 +1,42 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { PushChannel } from '../../../domain/entities/device-token.entity'; +import { + IPushChannelProvider, + PushPayload, + PushResult, +} from '../../../domain/ports/push-channel-provider.interface'; + +/** + * vivo Push provider. + * + * Production integration: + * vivo Push HTTP API (https://dev.vivo.com.cn/documentCenter/doc/362) + * Environment: VIVO_APP_ID, VIVO_APP_KEY, VIVO_APP_SECRET + * + * Required for vivo/iQOO devices in China. + */ +@Injectable() +export class VivoPushProvider implements IPushChannelProvider { + private readonly logger = new Logger(VivoPushProvider.name); + readonly channel = PushChannel.VIVO; + + async send(payload: PushPayload): Promise { + // TODO: Replace with vivo Push HTTP API call + this.logger.log( + `[MOCK VIVO] → token=${payload.token.slice(0, 12)}… title="${payload.title}"`, + ); + return { + success: true, + messageId: `vivo-mock-${Date.now()}`, + }; + } + + async sendBatch(payloads: PushPayload[]): Promise { + // TODO: Replace with vivo Push batch API + this.logger.log(`[MOCK VIVO] Batch send: ${payloads.length} messages`); + return payloads.map((p) => ({ + success: true, + messageId: `vivo-mock-${Date.now()}-${p.token.slice(0, 8)}`, + })); + } +} diff --git a/backend/services/notification-service/src/infrastructure/providers/push-channels/xiaomi-push.provider.ts b/backend/services/notification-service/src/infrastructure/providers/push-channels/xiaomi-push.provider.ts new file mode 100644 index 0000000..4a90181 --- /dev/null +++ b/backend/services/notification-service/src/infrastructure/providers/push-channels/xiaomi-push.provider.ts @@ -0,0 +1,42 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { PushChannel } from '../../../domain/entities/device-token.entity'; +import { + IPushChannelProvider, + PushPayload, + PushResult, +} from '../../../domain/ports/push-channel-provider.interface'; + +/** + * Xiaomi (Mi Push) provider. + * + * Production integration: + * Mi Push HTTP API (https://dev.mi.com/console/doc/detail?pId=1163) + * Environment: XIAOMI_APP_SECRET, XIAOMI_PACKAGE_NAME + * + * Required for Xiaomi/Redmi/POCO devices in China. + */ +@Injectable() +export class XiaomiPushProvider implements IPushChannelProvider { + private readonly logger = new Logger(XiaomiPushProvider.name); + readonly channel = PushChannel.XIAOMI; + + async send(payload: PushPayload): Promise { + // TODO: Replace with Mi Push HTTP API call + this.logger.log( + `[MOCK XIAOMI] → token=${payload.token.slice(0, 12)}… title="${payload.title}"`, + ); + return { + success: true, + messageId: `xiaomi-mock-${Date.now()}`, + }; + } + + async sendBatch(payloads: PushPayload[]): Promise { + // TODO: Replace with Mi Push batch API + this.logger.log(`[MOCK XIAOMI] Batch send: ${payloads.length} messages`); + return payloads.map((p) => ({ + success: true, + messageId: `xiaomi-mock-${Date.now()}-${p.token.slice(0, 8)}`, + })); + } +} diff --git a/backend/services/notification-service/src/infrastructure/providers/push-notification.provider.ts b/backend/services/notification-service/src/infrastructure/providers/push-notification.provider.ts index da8403b..b141a73 100644 --- a/backend/services/notification-service/src/infrastructure/providers/push-notification.provider.ts +++ b/backend/services/notification-service/src/infrastructure/providers/push-notification.provider.ts @@ -1,22 +1,39 @@ import { Injectable, Logger } from '@nestjs/common'; import { Notification, NotificationChannel } from '../../domain/entities/notification.entity'; import { INotificationProvider } from '../../domain/ports/notification-provider.interface'; +import { PushDispatcherService } from '../../application/services/push-dispatcher.service'; /** - * Push notification provider. - * In production, this would integrate with FCM/APNs/Web Push. - * Currently a mock implementation with proper DDD structure. + * Push notification provider — bridges the per-user Notification system + * to the multi-channel device push dispatcher (FCM/APNs/HMS/Xiaomi/OPPO/vivo). + * + * Implements INotificationProvider so NotificationService channel dispatch remains unchanged. */ @Injectable() export class PushNotificationProvider implements INotificationProvider { - private readonly logger = new Logger('PushNotificationProvider'); + private readonly logger = new Logger(PushNotificationProvider.name); readonly channel = NotificationChannel.PUSH; + constructor(private readonly pushDispatcher: PushDispatcherService) {} + async send(notification: Notification): Promise { - // TODO: Integrate with FCM / APNs in production - this.logger.log( - `[MOCK] Push notification sent to user ${notification.userId}: "${notification.title}"`, - ); - return true; + try { + await this.pushDispatcher.sendToUser( + notification.userId, + notification.title, + notification.body, + notification.data + ? Object.fromEntries( + Object.entries(notification.data).map(([k, v]) => [k, String(v)]), + ) + : undefined, + ); + return true; + } catch (err) { + this.logger.error( + `Push dispatch failed for user ${notification.userId}: ${err.message}`, + ); + return false; + } } } diff --git a/backend/services/notification-service/src/interface/http/controllers/device-token.controller.ts b/backend/services/notification-service/src/interface/http/controllers/device-token.controller.ts new file mode 100644 index 0000000..616ea08 --- /dev/null +++ b/backend/services/notification-service/src/interface/http/controllers/device-token.controller.ts @@ -0,0 +1,56 @@ +import { + Controller, + Get, + Post, + Delete, + Body, + UseGuards, + Req, + HttpCode, + HttpStatus, +} from '@nestjs/common'; +import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger'; +import { AuthGuard } from '@nestjs/passport'; +import { DeviceTokenService } from '../../../application/services/device-token.service'; +import { + RegisterDeviceTokenDto, + UnregisterDeviceTokenDto, +} from '../dto/device-token.dto'; + +@ApiTags('Device Tokens') +@Controller('device-tokens') +@UseGuards(AuthGuard('jwt')) +@ApiBearerAuth() +export class DeviceTokenController { + constructor(private readonly deviceTokenService: DeviceTokenService) {} + + @Post() + @ApiOperation({ summary: 'Register device push token (FCM/APNs/HMS/Xiaomi/OPPO/vivo)' }) + async register(@Body() dto: RegisterDeviceTokenDto, @Req() req: any) { + const token = await this.deviceTokenService.registerToken({ + userId: req.user.id, + platform: dto.platform, + channel: dto.channel, + token: dto.token, + deviceId: dto.deviceId, + deviceModel: dto.deviceModel, + appVersion: dto.appVersion, + }); + return { code: 0, data: token }; + } + + @Delete() + @HttpCode(HttpStatus.OK) + @ApiOperation({ summary: 'Unregister device push token' }) + async unregister(@Body() dto: UnregisterDeviceTokenDto) { + await this.deviceTokenService.unregisterToken(dto.token); + return { code: 0, data: null }; + } + + @Get() + @ApiOperation({ summary: 'List active device tokens for current user' }) + async list(@Req() req: any) { + const tokens = await this.deviceTokenService.getActiveTokens(req.user.id); + return { code: 0, data: tokens }; + } +} diff --git a/backend/services/notification-service/src/interface/http/dto/device-token.dto.ts b/backend/services/notification-service/src/interface/http/dto/device-token.dto.ts new file mode 100644 index 0000000..cd96c6f --- /dev/null +++ b/backend/services/notification-service/src/interface/http/dto/device-token.dto.ts @@ -0,0 +1,41 @@ +import { IsString, IsEnum, IsOptional, MaxLength } from 'class-validator'; +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { DevicePlatform, PushChannel } from '../../../domain/entities/device-token.entity'; + +export class RegisterDeviceTokenDto { + @ApiProperty({ enum: DevicePlatform, description: 'Device OS platform', example: 'ANDROID' }) + @IsEnum(DevicePlatform) + platform: DevicePlatform; + + @ApiProperty({ enum: PushChannel, description: 'Push channel', example: 'FCM' }) + @IsEnum(PushChannel) + channel: PushChannel; + + @ApiProperty({ description: 'Device push token from FCM/APNs/HMS/etc.' }) + @IsString() + token: string; + + @ApiPropertyOptional({ description: 'Unique device identifier' }) + @IsOptional() + @IsString() + @MaxLength(200) + deviceId?: string; + + @ApiPropertyOptional({ description: 'Device model name', example: 'Pixel 8 Pro' }) + @IsOptional() + @IsString() + @MaxLength(200) + deviceModel?: string; + + @ApiPropertyOptional({ description: 'App version', example: '1.2.0' }) + @IsOptional() + @IsString() + @MaxLength(50) + appVersion?: string; +} + +export class UnregisterDeviceTokenDto { + @ApiProperty({ description: 'Device push token to unregister' }) + @IsString() + token: string; +} diff --git a/backend/services/notification-service/src/notification.module.ts b/backend/services/notification-service/src/notification.module.ts index 87a79e8..789036e 100644 --- a/backend/services/notification-service/src/notification.module.ts +++ b/backend/services/notification-service/src/notification.module.ts @@ -10,18 +10,21 @@ import { AnnouncementRead } from './domain/entities/announcement-read.entity'; import { AnnouncementTagTarget } from './domain/entities/announcement-tag-target.entity'; import { AnnouncementUserTarget } from './domain/entities/announcement-user-target.entity'; import { UserTag } from './domain/entities/user-tag.entity'; +import { DeviceToken } from './domain/entities/device-token.entity'; // Domain repository interfaces import { NOTIFICATION_REPOSITORY } from './domain/repositories/notification.repository.interface'; import { ANNOUNCEMENT_REPOSITORY } from './domain/repositories/announcement.repository.interface'; import { USER_TAG_REPOSITORY } from './domain/repositories/user-tag.repository.interface'; +import { DEVICE_TOKEN_REPOSITORY } from './domain/repositories/device-token.repository.interface'; // Infrastructure implementations import { NotificationRepository } from './infrastructure/persistence/notification.repository'; import { AnnouncementRepositoryImpl } from './infrastructure/persistence/announcement.repository'; import { UserTagRepositoryImpl } from './infrastructure/persistence/user-tag.repository'; +import { DeviceTokenRepositoryImpl } from './infrastructure/persistence/device-token.repository'; -// Domain ports +// Domain ports — notification channel providers import { PUSH_NOTIFICATION_PROVIDER, SMS_NOTIFICATION_PROVIDER, @@ -31,12 +34,32 @@ import { PushNotificationProvider } from './infrastructure/providers/push-notifi import { SmsNotificationProvider } from './infrastructure/providers/sms-notification.provider'; import { EmailNotificationProvider } from './infrastructure/providers/email-notification.provider'; +// Domain ports — push channel providers (FCM/APNs/HMS/Xiaomi/OPPO/vivo) +import { + FCM_PUSH_PROVIDER, + APNS_PUSH_PROVIDER, + HMS_PUSH_PROVIDER, + XIAOMI_PUSH_PROVIDER, + OPPO_PUSH_PROVIDER, + VIVO_PUSH_PROVIDER, +} from './domain/ports/push-channel-provider.interface'; +import { + FcmPushProvider, + ApnsPushProvider, + HmsPushProvider, + XiaomiPushProvider, + OppoPushProvider, + VivoPushProvider, +} from './infrastructure/providers/push-channels'; + // Application services import { NotificationService } from './application/services/notification.service'; import { EventConsumerService } from './application/services/event-consumer.service'; import { AdminNotificationService } from './application/services/admin-notification.service'; import { AnnouncementService } from './application/services/announcement.service'; import { UserTagService } from './application/services/user-tag.service'; +import { PushDispatcherService } from './application/services/push-dispatcher.service'; +import { DeviceTokenService } from './application/services/device-token.service'; // Interface controllers import { NotificationController } from './interface/http/controllers/notification.controller'; @@ -46,6 +69,7 @@ import { AdminUserTagController, UserAnnouncementController, } from './interface/http/controllers/announcement.controller'; +import { DeviceTokenController } from './interface/http/controllers/device-token.controller'; @Module({ imports: [ @@ -56,6 +80,7 @@ import { AnnouncementTagTarget, AnnouncementUserTarget, UserTag, + DeviceToken, ]), PassportModule.register({ defaultStrategy: 'jwt' }), JwtModule.register({ secret: process.env.JWT_ACCESS_SECRET || 'dev-access-secret' }), @@ -66,25 +91,37 @@ import { AdminAnnouncementController, AdminUserTagController, UserAnnouncementController, + DeviceTokenController, ], providers: [ - // Infrastructure -> Domain port binding + // Infrastructure -> Domain repository binding { provide: NOTIFICATION_REPOSITORY, useClass: NotificationRepository }, { provide: ANNOUNCEMENT_REPOSITORY, useClass: AnnouncementRepositoryImpl }, { provide: USER_TAG_REPOSITORY, useClass: UserTagRepositoryImpl }, + { provide: DEVICE_TOKEN_REPOSITORY, useClass: DeviceTokenRepositoryImpl }, // Infrastructure -> Notification channel providers { provide: PUSH_NOTIFICATION_PROVIDER, useClass: PushNotificationProvider }, { provide: SMS_NOTIFICATION_PROVIDER, useClass: SmsNotificationProvider }, { provide: EMAIL_NOTIFICATION_PROVIDER, useClass: EmailNotificationProvider }, + // Infrastructure -> Push channel providers (multi-vendor) + { provide: FCM_PUSH_PROVIDER, useClass: FcmPushProvider }, + { provide: APNS_PUSH_PROVIDER, useClass: ApnsPushProvider }, + { provide: HMS_PUSH_PROVIDER, useClass: HmsPushProvider }, + { provide: XIAOMI_PUSH_PROVIDER, useClass: XiaomiPushProvider }, + { provide: OPPO_PUSH_PROVIDER, useClass: OppoPushProvider }, + { provide: VIVO_PUSH_PROVIDER, useClass: VivoPushProvider }, + // Application services NotificationService, EventConsumerService, AdminNotificationService, AnnouncementService, UserTagService, + PushDispatcherService, + DeviceTokenService, ], - exports: [NotificationService, AnnouncementService], + exports: [NotificationService, AnnouncementService, PushDispatcherService], }) export class NotificationModule {}