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:
parent
acaec56849
commit
8adead23b6
|
|
@ -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'));
|
||||
|
|
@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -13,6 +13,7 @@ export enum AnnouncementType {
|
|||
REWARD = 'REWARD',
|
||||
UPGRADE = 'UPGRADE',
|
||||
ANNOUNCEMENT = 'ANNOUNCEMENT',
|
||||
PROMOTION = 'PROMOTION',
|
||||
}
|
||||
|
||||
export enum AnnouncementPriority {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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');
|
||||
|
|
@ -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');
|
||||
|
|
@ -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),
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
|
@ -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)}`,
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
|
@ -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)}`,
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
|
@ -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)}`,
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
|
@ -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';
|
||||
|
|
@ -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)}`,
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
|
@ -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)}`,
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
|
@ -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)}`,
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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 {}
|
||||
|
|
|
|||
Loading…
Reference in New Issue