feat: 设备推送系统 — FCM/APNs/HMS/小米/OPPO/vivo 多通道推送 + PROMOTION广告类型

新增设备推送通道架构,支持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 <noreply@anthropic.com>
This commit is contained in:
hailin 2026-02-12 23:48:52 -08:00
parent acaec56849
commit 8adead23b6
20 changed files with 917 additions and 12 deletions

View File

@ -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'));

View File

@ -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<Announcement> {
@ -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<void> {
const data: Record<string, string> = {
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,
);
}
}
}

View File

@ -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<DeviceToken> {
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<void> {
await this.deviceTokenRepo.deactivateToken(token);
this.logger.log(`Device token unregistered: ${token.slice(0, 12)}`);
}
async unregisterAllForUser(userId: string): Promise<void> {
await this.deviceTokenRepo.deactivateAllForUser(userId);
this.logger.log(`All device tokens deactivated for user ${userId}`);
}
async getActiveTokens(userId: string): Promise<DeviceToken[]> {
return this.deviceTokenRepo.findActiveByUserId(userId);
}
async getChannelStats(): Promise<Array<{ channel: PushChannel; count: number }>> {
return this.deviceTokenRepo.countByChannel();
}
}

View File

@ -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<PushChannel, IPushChannelProvider>;
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, IPushChannelProvider>([
[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<string, string>,
): Promise<void> {
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<PushChannel, PushPayload[]>();
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<string, string>,
): Promise<void> {
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<PushChannel, PushPayload[]>();
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<PushChannel, PushPayload[]>,
): Promise<void> {
const promises: Promise<void>[] = [];
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);
}
}

View File

@ -13,6 +13,7 @@ export enum AnnouncementType {
REWARD = 'REWARD',
UPGRADE = 'UPGRADE',
ANNOUNCEMENT = 'ANNOUNCEMENT',
PROMOTION = 'PROMOTION',
}
export enum AnnouncementPriority {

View File

@ -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;
}
}

View File

@ -0,0 +1,30 @@
import { PushChannel } from '../entities/device-token.entity';
export interface PushPayload {
token: string;
title: string;
body: string;
data?: Record<string, string>;
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<PushResult>;
sendBatch(payloads: PushPayload[]): Promise<PushResult[]>;
}
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');

View File

@ -0,0 +1,12 @@
import { DeviceToken, PushChannel } from '../entities/device-token.entity';
export interface IDeviceTokenRepository {
findActiveByUserId(userId: string): Promise<DeviceToken[]>;
findActiveByUserIds(userIds: string[]): Promise<DeviceToken[]>;
upsertToken(data: Partial<DeviceToken>): Promise<DeviceToken>;
deactivateToken(token: string): Promise<void>;
deactivateAllForUser(userId: string): Promise<void>;
countByChannel(): Promise<Array<{ channel: PushChannel; count: number }>>;
}
export const DEVICE_TOKEN_REPOSITORY = Symbol('IDeviceTokenRepository');

View File

@ -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<DeviceToken>,
) {}
async findActiveByUserId(userId: string): Promise<DeviceToken[]> {
return this.repo.find({
where: { userId, isActive: true },
order: { updatedAt: 'DESC' },
});
}
async findActiveByUserIds(userIds: string[]): Promise<DeviceToken[]> {
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<DeviceToken>): Promise<DeviceToken> {
// 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<void> {
await this.repo.update({ token }, { isActive: false });
}
async deactivateAllForUser(userId: string): Promise<void> {
await this.repo.update({ userId, isActive: true }, { isActive: false });
}
async countByChannel(): Promise<Array<{ channel: PushChannel; count: number }>> {
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),
}));
}
}

View File

@ -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<PushResult> {
// 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<PushResult[]> {
// 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)}`,
}));
}
}

View File

@ -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<PushResult> {
// 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<PushResult[]> {
// 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)}`,
}));
}
}

View File

@ -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<PushResult> {
// 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<PushResult[]> {
// 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)}`,
}));
}
}

View File

@ -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';

View File

@ -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<PushResult> {
// 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<PushResult[]> {
// 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)}`,
}));
}
}

View File

@ -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<PushResult> {
// 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<PushResult[]> {
// 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)}`,
}));
}
}

View File

@ -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<PushResult> {
// 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<PushResult[]> {
// 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)}`,
}));
}
}

View File

@ -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<boolean> {
// 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;
}
}
}

View File

@ -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 };
}
}

View File

@ -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;
}

View File

@ -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 {}