feat(notification): 添加通知中心功能

后端 (admin-service):
- 新增 Notification 和 NotificationRead 数据模型
- 支持通知类型: 系统/活动/收益/升级/公告
- 实现管理端 API: 创建/更新/删除/列表
- 实现移动端 API: 获取通知列表/未读数量/标记已读

前端 (mobile-app):
- 新增 NotificationService 和 Provider
- 新增通知中心页面 (NotificationInboxPage)
- 在"我的"页面右上角添加通知图标(带未读角标)
- 支持查看通知详情、标记已读、全部已读

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
hailin 2025-12-14 20:45:03 -08:00
parent 11d9b57bda
commit db37fbf860
17 changed files with 1907 additions and 1 deletions

View File

@ -0,0 +1,53 @@
-- CreateEnum
CREATE TYPE "NotificationType" AS ENUM ('SYSTEM', 'ACTIVITY', 'REWARD', 'UPGRADE', 'ANNOUNCEMENT');
-- CreateEnum
CREATE TYPE "NotificationPriority" AS ENUM ('LOW', 'NORMAL', 'HIGH', 'URGENT');
-- CreateEnum
CREATE TYPE "TargetType" AS ENUM ('ALL', 'NEW_USER', 'VIP');
-- CreateTable
CREATE TABLE "notifications" (
"id" TEXT NOT NULL,
"title" TEXT NOT NULL,
"content" TEXT NOT NULL,
"type" "NotificationType" NOT NULL,
"priority" "NotificationPriority" NOT NULL DEFAULT 'NORMAL',
"targetType" "TargetType" NOT NULL DEFAULT 'ALL',
"imageUrl" TEXT,
"linkUrl" TEXT,
"isEnabled" BOOLEAN NOT NULL DEFAULT true,
"publishedAt" TIMESTAMP(3),
"expiresAt" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
"createdBy" TEXT NOT NULL,
CONSTRAINT "notifications_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "notification_reads" (
"id" TEXT NOT NULL,
"notificationId" TEXT NOT NULL,
"userSerialNum" TEXT NOT NULL,
"readAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "notification_reads_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "notifications_isEnabled_publishedAt_idx" ON "notifications"("isEnabled", "publishedAt");
-- CreateIndex
CREATE INDEX "notifications_type_idx" ON "notifications"("type");
-- CreateIndex
CREATE INDEX "notification_reads_userSerialNum_idx" ON "notification_reads"("userSerialNum");
-- CreateIndex
CREATE UNIQUE INDEX "notification_reads_notificationId_userSerialNum_key" ON "notification_reads"("notificationId", "userSerialNum");
-- AddForeignKey
ALTER TABLE "notification_reads" ADD CONSTRAINT "notification_reads_notificationId_fkey" FOREIGN KEY ("notificationId") REFERENCES "notifications"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@ -43,3 +43,70 @@ enum Platform {
ANDROID
IOS
}
// =============================================================================
// Notification System (通知系统)
// =============================================================================
/// 系统通知 - 管理员发布的公告/通知
model Notification {
id String @id @default(uuid())
title String // 通知标题
content String // 通知内容
type NotificationType // 通知类型
priority NotificationPriority @default(NORMAL) // 优先级
targetType TargetType @default(ALL) // 目标用户类型
imageUrl String? // 可选的图片URL
linkUrl String? // 可选的跳转链接
isEnabled Boolean @default(true) // 是否启用
publishedAt DateTime? // 发布时间null表示草稿
expiresAt DateTime? // 过期时间null表示永不过期
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
createdBy String // 创建人ID
// 用户已读记录
readRecords NotificationRead[]
@@index([isEnabled, publishedAt])
@@index([type])
@@map("notifications")
}
/// 用户已读记录
model NotificationRead {
id String @id @default(uuid())
notificationId String
userSerialNum String // 用户序列号
readAt DateTime @default(now())
notification Notification @relation(fields: [notificationId], references: [id], onDelete: Cascade)
@@unique([notificationId, userSerialNum])
@@index([userSerialNum])
@@map("notification_reads")
}
/// 通知类型
enum NotificationType {
SYSTEM // 系统通知
ACTIVITY // 活动通知
REWARD // 收益通知
UPGRADE // 升级通知
ANNOUNCEMENT // 公告
}
/// 通知优先级
enum NotificationPriority {
LOW
NORMAL
HIGH
URGENT
}
/// 目标用户类型
enum TargetType {
ALL // 所有用户
NEW_USER // 新用户
VIP // VIP用户
}

View File

@ -0,0 +1,202 @@
import {
Controller,
Get,
Post,
Put,
Delete,
Body,
Param,
Query,
HttpCode,
HttpStatus,
Inject,
} from '@nestjs/common';
import { v4 as uuidv4 } from 'uuid';
import {
NOTIFICATION_REPOSITORY,
NotificationRepository,
} from '../../domain/repositories/notification.repository';
import { NotificationEntity } from '../../domain/entities/notification.entity';
import {
CreateNotificationDto,
UpdateNotificationDto,
ListNotificationsDto,
UserNotificationsDto,
MarkReadDto,
} from '../dto/request/notification.dto';
import {
NotificationResponseDto,
UserNotificationResponseDto,
UnreadCountResponseDto,
NotificationListResponseDto,
} from '../dto/response/notification.dto';
/**
*
*/
@Controller('admin/notifications')
export class AdminNotificationController {
constructor(
@Inject(NOTIFICATION_REPOSITORY)
private readonly notificationRepo: NotificationRepository,
) {}
/**
*
*/
@Post()
async create(@Body() dto: CreateNotificationDto): Promise<NotificationResponseDto> {
const notification = NotificationEntity.create({
id: uuidv4(),
title: dto.title,
content: dto.content,
type: dto.type,
priority: dto.priority,
targetType: dto.targetType,
imageUrl: dto.imageUrl,
linkUrl: dto.linkUrl,
publishedAt: dto.publishedAt ? new Date(dto.publishedAt) : null,
expiresAt: dto.expiresAt ? new Date(dto.expiresAt) : null,
createdBy: 'admin', // TODO: 从认证信息获取
});
const saved = await this.notificationRepo.save(notification);
return NotificationResponseDto.fromEntity(saved);
}
/**
*
*/
@Get(':id')
async findOne(@Param('id') id: string): Promise<NotificationResponseDto> {
const notification = await this.notificationRepo.findById(id);
if (!notification) {
throw new Error('Notification not found');
}
return NotificationResponseDto.fromEntity(notification);
}
/**
*
*/
@Get()
async findAll(@Query() dto: ListNotificationsDto): Promise<NotificationResponseDto[]> {
const notifications = await this.notificationRepo.findAll({
type: dto.type,
limit: dto.limit,
offset: dto.offset,
});
return notifications.map(NotificationResponseDto.fromEntity);
}
/**
*
*/
@Put(':id')
async update(
@Param('id') id: string,
@Body() dto: UpdateNotificationDto,
): Promise<NotificationResponseDto> {
const existing = await this.notificationRepo.findById(id);
if (!existing) {
throw new Error('Notification not found');
}
const updated = new NotificationEntity(
existing.id,
dto.title ?? existing.title,
dto.content ?? existing.content,
dto.type ?? existing.type,
dto.priority ?? existing.priority,
dto.targetType ?? existing.targetType,
dto.imageUrl !== undefined ? dto.imageUrl : existing.imageUrl,
dto.linkUrl !== undefined ? dto.linkUrl : existing.linkUrl,
dto.isEnabled ?? existing.isEnabled,
dto.publishedAt !== undefined
? dto.publishedAt
? new Date(dto.publishedAt)
: null
: existing.publishedAt,
dto.expiresAt !== undefined
? dto.expiresAt
? new Date(dto.expiresAt)
: null
: existing.expiresAt,
existing.createdAt,
new Date(),
existing.createdBy,
);
const saved = await this.notificationRepo.save(updated);
return NotificationResponseDto.fromEntity(saved);
}
/**
*
*/
@Delete(':id')
@HttpCode(HttpStatus.NO_CONTENT)
async delete(@Param('id') id: string): Promise<void> {
await this.notificationRepo.delete(id);
}
}
/**
*
*/
@Controller('mobile/notifications')
export class MobileNotificationController {
constructor(
@Inject(NOTIFICATION_REPOSITORY)
private readonly notificationRepo: NotificationRepository,
) {}
/**
*
*/
@Get()
async getNotifications(
@Query() dto: UserNotificationsDto,
): Promise<NotificationListResponseDto> {
const [notifications, unreadCount] = await Promise.all([
this.notificationRepo.findNotificationsForUser({
userSerialNum: dto.userSerialNum,
type: dto.type,
limit: dto.limit ?? 50,
offset: dto.offset ?? 0,
}),
this.notificationRepo.countUnreadForUser(dto.userSerialNum),
]);
return {
notifications: notifications.map(UserNotificationResponseDto.fromEntity),
total: notifications.length,
unreadCount,
};
}
/**
*
*/
@Get('unread-count')
async getUnreadCount(
@Query('userSerialNum') userSerialNum: string,
): Promise<UnreadCountResponseDto> {
const unreadCount = await this.notificationRepo.countUnreadForUser(userSerialNum);
return { unreadCount };
}
/**
*
*/
@Post('mark-read')
@HttpCode(HttpStatus.OK)
async markRead(@Body() dto: MarkReadDto): Promise<{ success: boolean }> {
if (dto.notificationId) {
await this.notificationRepo.markAsRead(dto.notificationId, dto.userSerialNum);
} else {
await this.notificationRepo.markAllAsRead(dto.userSerialNum);
}
return { success: true };
}
}

View File

@ -0,0 +1,140 @@
import { IsString, IsOptional, IsEnum, IsBoolean, IsDateString, IsInt, Min, Max } from 'class-validator';
import { NotificationType, NotificationPriority, TargetType } from '../../../domain/entities/notification.entity';
/**
*
*/
export class CreateNotificationDto {
@IsString()
title: string;
@IsString()
content: string;
@IsEnum(NotificationType)
type: NotificationType;
@IsOptional()
@IsEnum(NotificationPriority)
priority?: NotificationPriority;
@IsOptional()
@IsEnum(TargetType)
targetType?: TargetType;
@IsOptional()
@IsString()
imageUrl?: string;
@IsOptional()
@IsString()
linkUrl?: string;
@IsOptional()
@IsDateString()
publishedAt?: string;
@IsOptional()
@IsDateString()
expiresAt?: string;
}
/**
*
*/
export class UpdateNotificationDto {
@IsOptional()
@IsString()
title?: string;
@IsOptional()
@IsString()
content?: string;
@IsOptional()
@IsEnum(NotificationType)
type?: NotificationType;
@IsOptional()
@IsEnum(NotificationPriority)
priority?: NotificationPriority;
@IsOptional()
@IsEnum(TargetType)
targetType?: TargetType;
@IsOptional()
@IsString()
imageUrl?: string;
@IsOptional()
@IsString()
linkUrl?: string;
@IsOptional()
@IsBoolean()
isEnabled?: boolean;
@IsOptional()
@IsDateString()
publishedAt?: string;
@IsOptional()
@IsDateString()
expiresAt?: string;
}
/**
*
*/
export class ListNotificationsDto {
@IsOptional()
@IsEnum(NotificationType)
type?: NotificationType;
@IsOptional()
@IsInt()
@Min(1)
@Max(100)
limit?: number;
@IsOptional()
@IsInt()
@Min(0)
offset?: number;
}
/**
*
*/
export class UserNotificationsDto {
@IsString()
userSerialNum: string;
@IsOptional()
@IsEnum(NotificationType)
type?: NotificationType;
@IsOptional()
@IsInt()
@Min(1)
@Max(100)
limit?: number;
@IsOptional()
@IsInt()
@Min(0)
offset?: number;
}
/**
*
*/
export class MarkReadDto {
@IsString()
userSerialNum: string;
@IsOptional()
@IsString()
notificationId?: string; // 如果不传,则标记所有为已读
}

View File

@ -0,0 +1,84 @@
import { NotificationEntity, NotificationType, NotificationPriority, TargetType } from '../../../domain/entities/notification.entity';
import { NotificationWithReadStatus } from '../../../domain/repositories/notification.repository';
/**
* DTO
*/
export class NotificationResponseDto {
id: string;
title: string;
content: string;
type: NotificationType;
priority: NotificationPriority;
targetType: TargetType;
imageUrl: string | null;
linkUrl: string | null;
isEnabled: boolean;
publishedAt: string | null;
expiresAt: string | null;
createdAt: string;
static fromEntity(entity: NotificationEntity): NotificationResponseDto {
return {
id: entity.id,
title: entity.title,
content: entity.content,
type: entity.type,
priority: entity.priority,
targetType: entity.targetType,
imageUrl: entity.imageUrl,
linkUrl: entity.linkUrl,
isEnabled: entity.isEnabled,
publishedAt: entity.publishedAt?.toISOString() ?? null,
expiresAt: entity.expiresAt?.toISOString() ?? null,
createdAt: entity.createdAt.toISOString(),
};
}
}
/**
* DTO
*/
export class UserNotificationResponseDto {
id: string;
title: string;
content: string;
type: NotificationType;
priority: NotificationPriority;
imageUrl: string | null;
linkUrl: string | null;
publishedAt: string | null;
isRead: boolean;
readAt: string | null;
static fromEntity(item: NotificationWithReadStatus): UserNotificationResponseDto {
return {
id: item.notification.id,
title: item.notification.title,
content: item.notification.content,
type: item.notification.type,
priority: item.notification.priority,
imageUrl: item.notification.imageUrl,
linkUrl: item.notification.linkUrl,
publishedAt: item.notification.publishedAt?.toISOString() ?? null,
isRead: item.isRead,
readAt: item.readAt?.toISOString() ?? null,
};
}
}
/**
*
*/
export class UnreadCountResponseDto {
unreadCount: number;
}
/**
*
*/
export class NotificationListResponseDto {
notifications: UserNotificationResponseDto[];
total: number;
unreadCount: number;
}

View File

@ -21,6 +21,11 @@ import { VersionController } from './api/controllers/version.controller';
import { MobileVersionController } from './api/controllers/mobile-version.controller';
import { HealthController } from './api/controllers/health.controller';
import { DownloadController } from './api/controllers/download.controller';
// Notification imports
import { NotificationMapper } from './infrastructure/persistence/mappers/notification.mapper';
import { NotificationRepositoryImpl } from './infrastructure/persistence/repositories/notification.repository.impl';
import { NOTIFICATION_REPOSITORY } from './domain/repositories/notification.repository';
import { AdminNotificationController, MobileNotificationController } from './api/controllers/notification.controller';
@Module({
imports: [
@ -34,7 +39,14 @@ import { DownloadController } from './api/controllers/download.controller';
serveRoot: '/uploads',
}),
],
controllers: [VersionController, MobileVersionController, HealthController, DownloadController],
controllers: [
VersionController,
MobileVersionController,
HealthController,
DownloadController,
AdminNotificationController,
MobileNotificationController,
],
providers: [
PrismaService,
AppVersionMapper,
@ -54,6 +66,12 @@ import { DownloadController } from './api/controllers/download.controller';
DeleteVersionHandler,
ToggleVersionHandler,
UploadVersionHandler,
// Notification
NotificationMapper,
{
provide: NOTIFICATION_REPOSITORY,
useClass: NotificationRepositoryImpl,
},
],
})
export class AppModule {}

View File

@ -0,0 +1,113 @@
/**
*
*/
export class NotificationEntity {
constructor(
public readonly id: string,
public readonly title: string,
public readonly content: string,
public readonly type: NotificationType,
public readonly priority: NotificationPriority,
public readonly targetType: TargetType,
public readonly imageUrl: string | null,
public readonly linkUrl: string | null,
public readonly isEnabled: boolean,
public readonly publishedAt: Date | null,
public readonly expiresAt: Date | null,
public readonly createdAt: Date,
public readonly updatedAt: Date,
public readonly createdBy: string,
) {}
/**
*
*/
isActive(): boolean {
if (!this.isEnabled || !this.publishedAt) {
return false;
}
const now = new Date();
if (this.publishedAt > now) {
return false;
}
if (this.expiresAt && this.expiresAt < now) {
return false;
}
return true;
}
/**
*
*/
isExpired(): boolean {
if (!this.expiresAt) {
return false;
}
return this.expiresAt < new Date();
}
/**
*
*/
static create(params: {
id: string;
title: string;
content: string;
type: NotificationType;
priority?: NotificationPriority;
targetType?: TargetType;
imageUrl?: string | null;
linkUrl?: string | null;
publishedAt?: Date | null;
expiresAt?: Date | null;
createdBy: string;
}): NotificationEntity {
const now = new Date();
return new NotificationEntity(
params.id,
params.title,
params.content,
params.type,
params.priority ?? NotificationPriority.NORMAL,
params.targetType ?? TargetType.ALL,
params.imageUrl ?? null,
params.linkUrl ?? null,
true,
params.publishedAt ?? null,
params.expiresAt ?? null,
now,
now,
params.createdBy,
);
}
}
/**
*
*/
export enum NotificationType {
SYSTEM = 'SYSTEM',
ACTIVITY = 'ACTIVITY',
REWARD = 'REWARD',
UPGRADE = 'UPGRADE',
ANNOUNCEMENT = 'ANNOUNCEMENT',
}
/**
*
*/
export enum NotificationPriority {
LOW = 'LOW',
NORMAL = 'NORMAL',
HIGH = 'HIGH',
URGENT = 'URGENT',
}
/**
*
*/
export enum TargetType {
ALL = 'ALL',
NEW_USER = 'NEW_USER',
VIP = 'VIP',
}

View File

@ -0,0 +1,84 @@
import { NotificationEntity, NotificationType } from '../entities/notification.entity';
export const NOTIFICATION_REPOSITORY = Symbol('NOTIFICATION_REPOSITORY');
/**
*
*/
export interface NotificationRepository {
/**
*
*/
save(notification: NotificationEntity): Promise<NotificationEntity>;
/**
* ID查找通知
*/
findById(id: string): Promise<NotificationEntity | null>;
/**
*
*/
findActiveNotifications(params?: {
type?: NotificationType;
limit?: number;
offset?: number;
}): Promise<NotificationEntity[]>;
/**
*
*/
findNotificationsForUser(params: {
userSerialNum: string;
type?: NotificationType;
limit?: number;
offset?: number;
}): Promise<NotificationWithReadStatus[]>;
/**
*
*/
countUnreadForUser(userSerialNum: string): Promise<number>;
/**
*
*/
markAsRead(notificationId: string, userSerialNum: string): Promise<void>;
/**
*
*/
markAllAsRead(userSerialNum: string): Promise<void>;
/**
*
*/
delete(id: string): Promise<void>;
/**
*
*/
findAll(params?: {
type?: NotificationType;
isEnabled?: boolean;
limit?: number;
offset?: number;
}): Promise<NotificationEntity[]>;
/**
*
*/
count(params?: {
type?: NotificationType;
isEnabled?: boolean;
}): Promise<number>;
}
/**
*
*/
export interface NotificationWithReadStatus {
notification: NotificationEntity;
isRead: boolean;
readAt: Date | null;
}

View File

@ -0,0 +1,54 @@
import { Injectable } from '@nestjs/common';
import {
Notification as PrismaNotification,
NotificationType as PrismaNotificationType,
NotificationPriority as PrismaPriority,
TargetType as PrismaTargetType,
} from '@prisma/client';
import {
NotificationEntity,
NotificationType,
NotificationPriority,
TargetType,
} from '../../../domain/entities/notification.entity';
@Injectable()
export class NotificationMapper {
toDomain(prisma: PrismaNotification): NotificationEntity {
return new NotificationEntity(
prisma.id,
prisma.title,
prisma.content,
prisma.type as NotificationType,
prisma.priority as NotificationPriority,
prisma.targetType as TargetType,
prisma.imageUrl,
prisma.linkUrl,
prisma.isEnabled,
prisma.publishedAt,
prisma.expiresAt,
prisma.createdAt,
prisma.updatedAt,
prisma.createdBy,
);
}
toPersistence(entity: NotificationEntity): Omit<PrismaNotification, 'id'> & { id: string } {
return {
id: entity.id,
title: entity.title,
content: entity.content,
type: entity.type as PrismaNotificationType,
priority: entity.priority as PrismaPriority,
targetType: entity.targetType as PrismaTargetType,
imageUrl: entity.imageUrl,
linkUrl: entity.linkUrl,
isEnabled: entity.isEnabled,
publishedAt: entity.publishedAt,
expiresAt: entity.expiresAt,
createdAt: entity.createdAt,
updatedAt: entity.updatedAt,
createdBy: entity.createdBy,
};
}
}

View File

@ -0,0 +1,182 @@
import { Injectable } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
import {
NotificationRepository,
NotificationWithReadStatus,
} from '../../../domain/repositories/notification.repository';
import {
NotificationEntity,
NotificationType,
} from '../../../domain/entities/notification.entity';
import { NotificationMapper } from '../mappers/notification.mapper';
@Injectable()
export class NotificationRepositoryImpl implements NotificationRepository {
constructor(
private readonly prisma: PrismaService,
private readonly mapper: NotificationMapper,
) {}
async save(notification: NotificationEntity): Promise<NotificationEntity> {
const data = this.mapper.toPersistence(notification);
const saved = await this.prisma.notification.upsert({
where: { id: notification.id },
create: data,
update: data,
});
return this.mapper.toDomain(saved);
}
async findById(id: string): Promise<NotificationEntity | null> {
const notification = await this.prisma.notification.findUnique({
where: { id },
});
return notification ? this.mapper.toDomain(notification) : null;
}
async findActiveNotifications(params?: {
type?: NotificationType;
limit?: number;
offset?: number;
}): Promise<NotificationEntity[]> {
const now = new Date();
const notifications = await this.prisma.notification.findMany({
where: {
isEnabled: true,
publishedAt: { lte: now },
OR: [{ expiresAt: null }, { expiresAt: { gt: now } }],
...(params?.type && { type: params.type }),
},
orderBy: [{ priority: 'desc' }, { publishedAt: 'desc' }],
take: params?.limit ?? 50,
skip: params?.offset ?? 0,
});
return notifications.map((n) => this.mapper.toDomain(n));
}
async findNotificationsForUser(params: {
userSerialNum: string;
type?: NotificationType;
limit?: number;
offset?: number;
}): Promise<NotificationWithReadStatus[]> {
const now = new Date();
const notifications = await this.prisma.notification.findMany({
where: {
isEnabled: true,
publishedAt: { lte: now },
OR: [{ expiresAt: null }, { expiresAt: { gt: now } }],
...(params.type && { type: params.type }),
},
include: {
readRecords: {
where: { userSerialNum: params.userSerialNum },
take: 1,
},
},
orderBy: [{ priority: 'desc' }, { publishedAt: 'desc' }],
take: params.limit ?? 50,
skip: params.offset ?? 0,
});
return notifications.map((n) => ({
notification: this.mapper.toDomain(n),
isRead: n.readRecords.length > 0,
readAt: n.readRecords[0]?.readAt ?? null,
}));
}
async countUnreadForUser(userSerialNum: string): Promise<number> {
const now = new Date();
const count = await this.prisma.notification.count({
where: {
isEnabled: true,
publishedAt: { lte: now },
OR: [{ expiresAt: null }, { expiresAt: { gt: now } }],
readRecords: {
none: { userSerialNum },
},
},
});
return count;
}
async markAsRead(notificationId: string, userSerialNum: string): Promise<void> {
await this.prisma.notificationRead.upsert({
where: {
notificationId_userSerialNum: {
notificationId,
userSerialNum,
},
},
create: {
notificationId,
userSerialNum,
},
update: {},
});
}
async markAllAsRead(userSerialNum: string): Promise<void> {
const now = new Date();
// 获取所有未读的有效通知
const unreadNotifications = await this.prisma.notification.findMany({
where: {
isEnabled: true,
publishedAt: { lte: now },
OR: [{ expiresAt: null }, { expiresAt: { gt: now } }],
readRecords: {
none: { userSerialNum },
},
},
select: { id: true },
});
// 批量创建已读记录
if (unreadNotifications.length > 0) {
await this.prisma.notificationRead.createMany({
data: unreadNotifications.map((n) => ({
notificationId: n.id,
userSerialNum,
})),
skipDuplicates: true,
});
}
}
async delete(id: string): Promise<void> {
await this.prisma.notification.delete({
where: { id },
});
}
async findAll(params?: {
type?: NotificationType;
isEnabled?: boolean;
limit?: number;
offset?: number;
}): Promise<NotificationEntity[]> {
const notifications = await this.prisma.notification.findMany({
where: {
...(params?.type && { type: params.type }),
...(params?.isEnabled !== undefined && { isEnabled: params.isEnabled }),
},
orderBy: { createdAt: 'desc' },
take: params?.limit ?? 50,
skip: params?.offset ?? 0,
});
return notifications.map((n) => this.mapper.toDomain(n));
}
async count(params?: {
type?: NotificationType;
isEnabled?: boolean;
}): Promise<number> {
return this.prisma.notification.count({
where: {
...(params?.type && { type: params.type }),
...(params?.isEnabled !== undefined && { isEnabled: params.isEnabled }),
},
});
}
}

View File

@ -9,6 +9,7 @@ import '../services/deposit_service.dart';
import '../services/wallet_service.dart';
import '../services/planting_service.dart';
import '../services/reward_service.dart';
import '../services/notification_service.dart';
// Storage Providers
final secureStorageProvider = Provider<SecureStorage>((ref) {
@ -72,6 +73,12 @@ final rewardServiceProvider = Provider<RewardService>((ref) {
return RewardService(apiClient: apiClient);
});
// Notification Service Provider ( admin-service)
final notificationServiceProvider = Provider<NotificationService>((ref) {
final apiClient = ref.watch(apiClientProvider);
return NotificationService(apiClient: apiClient);
});
// Override provider with initialized instance
ProviderContainer createProviderContainer(LocalStorage localStorage) {
return ProviderContainer(

View File

@ -0,0 +1,231 @@
import 'package:flutter/foundation.dart';
import '../network/api_client.dart';
///
enum NotificationType {
system,
activity,
reward,
upgrade,
announcement,
}
///
enum NotificationPriority {
low,
normal,
high,
urgent,
}
///
class NotificationItem {
final String id;
final String title;
final String content;
final NotificationType type;
final NotificationPriority priority;
final String? imageUrl;
final String? linkUrl;
final DateTime? publishedAt;
final bool isRead;
final DateTime? readAt;
NotificationItem({
required this.id,
required this.title,
required this.content,
required this.type,
required this.priority,
this.imageUrl,
this.linkUrl,
this.publishedAt,
required this.isRead,
this.readAt,
});
factory NotificationItem.fromJson(Map<String, dynamic> json) {
return NotificationItem(
id: json['id'] ?? '',
title: json['title'] ?? '',
content: json['content'] ?? '',
type: _parseNotificationType(json['type']),
priority: _parseNotificationPriority(json['priority']),
imageUrl: json['imageUrl'],
linkUrl: json['linkUrl'],
publishedAt: json['publishedAt'] != null
? DateTime.tryParse(json['publishedAt'])
: null,
isRead: json['isRead'] ?? false,
readAt: json['readAt'] != null ? DateTime.tryParse(json['readAt']) : null,
);
}
static NotificationType _parseNotificationType(String? type) {
switch (type) {
case 'SYSTEM':
return NotificationType.system;
case 'ACTIVITY':
return NotificationType.activity;
case 'REWARD':
return NotificationType.reward;
case 'UPGRADE':
return NotificationType.upgrade;
case 'ANNOUNCEMENT':
return NotificationType.announcement;
default:
return NotificationType.system;
}
}
static NotificationPriority _parseNotificationPriority(String? priority) {
switch (priority) {
case 'LOW':
return NotificationPriority.low;
case 'NORMAL':
return NotificationPriority.normal;
case 'HIGH':
return NotificationPriority.high;
case 'URGENT':
return NotificationPriority.urgent;
default:
return NotificationPriority.normal;
}
}
///
String get typeName {
switch (type) {
case NotificationType.system:
return '系统通知';
case NotificationType.activity:
return '活动通知';
case NotificationType.reward:
return '收益通知';
case NotificationType.upgrade:
return '升级通知';
case NotificationType.announcement:
return '公告';
}
}
///
String get typeIcon {
switch (type) {
case NotificationType.system:
return '🔔';
case NotificationType.activity:
return '🎉';
case NotificationType.reward:
return '💰';
case NotificationType.upgrade:
return '⬆️';
case NotificationType.announcement:
return '📢';
}
}
}
///
class NotificationListResponse {
final List<NotificationItem> notifications;
final int total;
final int unreadCount;
NotificationListResponse({
required this.notifications,
required this.total,
required this.unreadCount,
});
factory NotificationListResponse.fromJson(Map<String, dynamic> json) {
final list = (json['notifications'] as List?)
?.map((e) => NotificationItem.fromJson(e as Map<String, dynamic>))
.toList() ??
[];
return NotificationListResponse(
notifications: list,
total: json['total'] ?? list.length,
unreadCount: json['unreadCount'] ?? 0,
);
}
}
///
class NotificationService {
final ApiClient _apiClient;
NotificationService({required ApiClient apiClient}) : _apiClient = apiClient;
///
Future<NotificationListResponse> getNotifications({
required String userSerialNum,
NotificationType? type,
int limit = 50,
int offset = 0,
}) async {
try {
final queryParams = {
'userSerialNum': userSerialNum,
'limit': limit.toString(),
'offset': offset.toString(),
};
if (type != null) {
queryParams['type'] = type.name.toUpperCase();
}
final response = await _apiClient.get(
'/admin-service/mobile/notifications',
queryParameters: queryParams,
);
return NotificationListResponse.fromJson(response.data);
} catch (e) {
debugPrint('[NotificationService] 获取通知列表失败: $e');
rethrow;
}
}
///
Future<int> getUnreadCount({required String userSerialNum}) async {
try {
final response = await _apiClient.get(
'/admin-service/mobile/notifications/unread-count',
queryParameters: {'userSerialNum': userSerialNum},
);
return response.data['unreadCount'] ?? 0;
} catch (e) {
debugPrint('[NotificationService] 获取未读数量失败: $e');
return 0;
}
}
///
Future<bool> markAsRead({
required String userSerialNum,
String? notificationId,
}) async {
try {
final body = {
'userSerialNum': userSerialNum,
if (notificationId != null) 'notificationId': notificationId,
};
final response = await _apiClient.post(
'/admin-service/mobile/notifications/mark-read',
data: body,
);
return response.data['success'] ?? false;
} catch (e) {
debugPrint('[NotificationService] 标记已读失败: $e');
return false;
}
}
///
Future<bool> markAllAsRead({required String userSerialNum}) async {
return markAsRead(userSerialNum: userSerialNum);
}
}

View File

@ -0,0 +1,549 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../../../core/di/injection_container.dart';
import '../../../../core/services/notification_service.dart';
import '../../../../features/auth/presentation/providers/auth_provider.dart';
///
class NotificationInboxPage extends ConsumerStatefulWidget {
const NotificationInboxPage({super.key});
@override
ConsumerState<NotificationInboxPage> createState() =>
_NotificationInboxPageState();
}
class _NotificationInboxPageState extends ConsumerState<NotificationInboxPage> {
///
List<NotificationItem> _notifications = [];
///
int _unreadCount = 0;
///
bool _isLoading = true;
///
String? _error;
@override
void initState() {
super.initState();
_loadNotifications();
}
///
Future<void> _loadNotifications() async {
setState(() {
_isLoading = true;
_error = null;
});
try {
final authState = ref.read(authProvider);
final userSerialNum = authState.userSerialNum;
if (userSerialNum == null) {
setState(() {
_error = '用户未登录';
_isLoading = false;
});
return;
}
final notificationService = ref.read(notificationServiceProvider);
final response = await notificationService.getNotifications(
userSerialNum: userSerialNum,
);
if (mounted) {
setState(() {
_notifications = response.notifications;
_unreadCount = response.unreadCount;
_isLoading = false;
});
}
} catch (e) {
if (mounted) {
setState(() {
_error = '加载通知失败';
_isLoading = false;
});
}
}
}
///
Future<void> _markAsRead(NotificationItem notification) async {
if (notification.isRead) return;
final authState = ref.read(authProvider);
final userSerialNum = authState.userSerialNum;
if (userSerialNum == null) return;
final notificationService = ref.read(notificationServiceProvider);
final success = await notificationService.markAsRead(
userSerialNum: userSerialNum,
notificationId: notification.id,
);
if (success && mounted) {
setState(() {
final index = _notifications.indexWhere((n) => n.id == notification.id);
if (index != -1) {
_notifications[index] = NotificationItem(
id: notification.id,
title: notification.title,
content: notification.content,
type: notification.type,
priority: notification.priority,
imageUrl: notification.imageUrl,
linkUrl: notification.linkUrl,
publishedAt: notification.publishedAt,
isRead: true,
readAt: DateTime.now(),
);
_unreadCount = (_unreadCount - 1).clamp(0, _unreadCount);
}
});
}
}
///
Future<void> _markAllAsRead() async {
if (_unreadCount == 0) return;
final authState = ref.read(authProvider);
final userSerialNum = authState.userSerialNum;
if (userSerialNum == null) return;
final notificationService = ref.read(notificationServiceProvider);
final success = await notificationService.markAllAsRead(
userSerialNum: userSerialNum,
);
if (success && mounted) {
setState(() {
_notifications = _notifications.map((n) {
if (!n.isRead) {
return NotificationItem(
id: n.id,
title: n.title,
content: n.content,
type: n.type,
priority: n.priority,
imageUrl: n.imageUrl,
linkUrl: n.linkUrl,
publishedAt: n.publishedAt,
isRead: true,
readAt: DateTime.now(),
);
}
return n;
}).toList();
_unreadCount = 0;
});
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('已全部标记为已读'),
backgroundColor: Color(0xFFD4AF37),
),
);
}
}
}
///
void _showNotificationDetail(NotificationItem notification) {
//
_markAsRead(notification);
//
showDialog(
context: context,
builder: (context) => AlertDialog(
title: Row(
children: [
Text(
notification.typeIcon,
style: const TextStyle(fontSize: 20),
),
const SizedBox(width: 8),
Expanded(
child: Text(
notification.title,
style: const TextStyle(
fontSize: 18,
fontWeight: FontWeight.w600,
color: Color(0xFF5D4037),
),
),
),
],
),
content: SingleChildScrollView(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(
notification.content,
style: const TextStyle(
fontSize: 14,
color: Color(0xFF8B7355),
height: 1.5,
),
),
const SizedBox(height: 16),
Text(
_formatTime(notification.publishedAt),
style: const TextStyle(
fontSize: 12,
color: Color(0xFFBDBDBD),
),
),
],
),
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text(
'关闭',
style: TextStyle(color: Color(0xFFD4AF37)),
),
),
],
),
);
}
///
String _formatTime(DateTime? dateTime) {
if (dateTime == null) return '';
final now = DateTime.now();
final diff = now.difference(dateTime);
if (diff.inMinutes < 1) {
return '刚刚';
} else if (diff.inHours < 1) {
return '${diff.inMinutes}分钟前';
} else if (diff.inDays < 1) {
return '${diff.inHours}小时前';
} else if (diff.inDays < 7) {
return '${diff.inDays}天前';
} else {
return '${dateTime.month}${dateTime.day}';
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: const Color(0xFFFFF8E1),
body: SafeArea(
child: Column(
children: [
_buildAppBar(),
Expanded(
child: _isLoading
? const Center(
child: CircularProgressIndicator(
valueColor:
AlwaysStoppedAnimation<Color>(Color(0xFFD4AF37)),
),
)
: _error != null
? _buildErrorView()
: _notifications.isEmpty
? _buildEmptyView()
: _buildNotificationList(),
),
],
),
),
);
}
/// AppBar
Widget _buildAppBar() {
return Container(
height: 56,
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Row(
children: [
//
GestureDetector(
onTap: () => context.pop(),
child: Container(
width: 40,
height: 40,
alignment: Alignment.center,
child: const Icon(
Icons.arrow_back,
size: 24,
color: Color(0xFF5D4037),
),
),
),
const SizedBox(width: 8),
//
const Expanded(
child: Text(
'通知中心',
style: TextStyle(
fontSize: 18,
fontFamily: 'Inter',
fontWeight: FontWeight.w600,
color: Color(0xFF5D4037),
),
),
),
//
if (_unreadCount > 0)
GestureDetector(
onTap: _markAllAsRead,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
decoration: BoxDecoration(
color: const Color(0xFFD4AF37).withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(16),
),
child: const Text(
'全部已读',
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.w500,
color: Color(0xFFD4AF37),
),
),
),
),
],
),
);
}
///
Widget _buildEmptyView() {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.notifications_none,
size: 64,
color: Colors.grey.shade300,
),
const SizedBox(height: 16),
Text(
'暂无通知',
style: TextStyle(
fontSize: 16,
color: Colors.grey.shade500,
),
),
],
),
);
}
///
Widget _buildErrorView() {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.error_outline,
size: 64,
color: Colors.grey.shade300,
),
const SizedBox(height: 16),
Text(
_error ?? '加载失败',
style: TextStyle(
fontSize: 16,
color: Colors.grey.shade500,
),
),
const SizedBox(height: 16),
ElevatedButton(
onPressed: _loadNotifications,
style: ElevatedButton.styleFrom(
backgroundColor: const Color(0xFFD4AF37),
),
child: const Text('重试'),
),
],
),
);
}
///
Widget _buildNotificationList() {
return RefreshIndicator(
onRefresh: _loadNotifications,
color: const Color(0xFFD4AF37),
child: ListView.separated(
padding: const EdgeInsets.all(16),
itemCount: _notifications.length,
separatorBuilder: (context, index) => const SizedBox(height: 12),
itemBuilder: (context, index) {
final notification = _notifications[index];
return _buildNotificationCard(notification);
},
),
);
}
///
Widget _buildNotificationCard(NotificationItem notification) {
return GestureDetector(
onTap: () => _showNotificationDetail(notification),
child: Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: notification.isRead
? Colors.white
: const Color(0xFFFFF8E1),
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: notification.isRead
? const Color(0xFFE0E0E0)
: const Color(0xFFD4AF37).withValues(alpha: 0.3),
width: 1,
),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.05),
blurRadius: 4,
offset: const Offset(0, 2),
),
],
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
//
Container(
width: 40,
height: 40,
decoration: BoxDecoration(
color: _getTypeColor(notification.type).withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(20),
),
child: Center(
child: Text(
notification.typeIcon,
style: const TextStyle(fontSize: 20),
),
),
),
const SizedBox(width: 12),
//
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
//
if (!notification.isRead)
Container(
width: 8,
height: 8,
margin: const EdgeInsets.only(right: 8),
decoration: const BoxDecoration(
color: Color(0xFFD4AF37),
shape: BoxShape.circle,
),
),
//
Expanded(
child: Text(
notification.title,
style: TextStyle(
fontSize: 15,
fontWeight: notification.isRead
? FontWeight.w500
: FontWeight.w600,
color: const Color(0xFF5D4037),
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
],
),
const SizedBox(height: 4),
//
Text(
notification.content,
style: TextStyle(
fontSize: 13,
color: notification.isRead
? const Color(0xFFBDBDBD)
: const Color(0xFF8B7355),
height: 1.4,
),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 8),
//
Row(
children: [
Text(
notification.typeName,
style: TextStyle(
fontSize: 11,
color: _getTypeColor(notification.type),
fontWeight: FontWeight.w500,
),
),
const SizedBox(width: 8),
Text(
_formatTime(notification.publishedAt),
style: const TextStyle(
fontSize: 11,
color: Color(0xFFBDBDBD),
),
),
],
),
],
),
),
//
const Icon(
Icons.chevron_right,
size: 20,
color: Color(0xFFBDBDBD),
),
],
),
),
);
}
///
Color _getTypeColor(NotificationType type) {
switch (type) {
case NotificationType.system:
return const Color(0xFF5D4037);
case NotificationType.activity:
return const Color(0xFFFF9800);
case NotificationType.reward:
return const Color(0xFFD4AF37);
case NotificationType.upgrade:
return const Color(0xFF4CAF50);
case NotificationType.announcement:
return const Color(0xFF2196F3);
}
}
}

View File

@ -10,8 +10,10 @@ import 'package:device_info_plus/device_info_plus.dart';
import '../../../../core/di/injection_container.dart';
import '../../../../core/services/referral_service.dart';
import '../../../../core/services/reward_service.dart';
import '../../../../core/services/notification_service.dart';
import '../../../../routes/route_paths.dart';
import '../../../../routes/app_router.dart';
import '../../../auth/presentation/providers/auth_provider.dart';
import '../widgets/team_tree_widget.dart';
/// -
@ -119,6 +121,9 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
String _osVersion = '--';
String _platform = '--';
//
int _unreadNotificationCount = 0;
@override
void initState() {
super.initState();
@ -131,6 +136,8 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
_loadAuthorizationData();
//
_loadWalletData();
//
_loadUnreadNotificationCount();
}
///
@ -435,6 +442,36 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
}
}
///
Future<void> _loadUnreadNotificationCount() async {
try {
final authState = ref.read(authProvider);
final userSerialNum = authState.userSerialNum;
if (userSerialNum == null) return;
final notificationService = ref.read(notificationServiceProvider);
final count = await notificationService.getUnreadCount(
userSerialNum: userSerialNum,
);
if (mounted) {
setState(() {
_unreadNotificationCount = count;
});
}
} catch (e) {
debugPrint('[ProfilePage] 加载通知未读数量失败: $e');
}
}
///
void _goToNotifications() {
context.push(RoutePaths.notifications).then((_) {
//
_loadUnreadNotificationCount();
});
}
/// ( reward-service )
Future<void> _loadWalletData() async {
try {
@ -733,6 +770,8 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
//
_buildPageHeader(),
const SizedBox(height: 16),
//
_buildUserHeader(),
@ -758,6 +797,79 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
);
}
///
Widget _buildPageHeader() {
return Container(
height: 48,
padding: const EdgeInsets.only(top: 8),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
//
const Text(
'我的',
style: TextStyle(
fontSize: 20,
fontFamily: 'Inter',
fontWeight: FontWeight.w700,
color: Color(0xFF5D4037),
),
),
//
GestureDetector(
onTap: _goToNotifications,
child: Container(
width: 40,
height: 40,
alignment: Alignment.center,
child: Stack(
clipBehavior: Clip.none,
children: [
const Icon(
Icons.notifications_outlined,
size: 26,
color: Color(0xFF5D4037),
),
//
if (_unreadNotificationCount > 0)
Positioned(
top: -4,
right: -4,
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: 5,
vertical: 2,
),
decoration: BoxDecoration(
color: const Color(0xFFD4AF37),
borderRadius: BorderRadius.circular(10),
),
constraints: const BoxConstraints(
minWidth: 18,
minHeight: 18,
),
child: Text(
_unreadNotificationCount > 99
? '99+'
: _unreadNotificationCount.toString(),
style: const TextStyle(
fontSize: 10,
fontWeight: FontWeight.w600,
color: Colors.white,
),
textAlign: TextAlign.center,
),
),
),
],
),
),
),
],
),
);
}
///
Widget _buildUserHeader() {
return Row(

View File

@ -24,6 +24,7 @@ import '../features/security/presentation/pages/change_password_page.dart';
import '../features/security/presentation/pages/bind_email_page.dart';
import '../features/withdraw/presentation/pages/withdraw_usdt_page.dart';
import '../features/withdraw/presentation/pages/withdraw_confirm_page.dart';
import '../features/notification/presentation/pages/notification_inbox_page.dart';
import 'route_paths.dart';
import 'route_names.dart';
@ -175,6 +176,13 @@ final appRouterProvider = Provider<GoRouter>((ref) {
builder: (context, state) => const EditProfilePage(),
),
// Notification Inbox ()
GoRoute(
path: RoutePaths.notifications,
name: RouteNames.notifications,
builder: (context, state) => const NotificationInboxPage(),
),
// Share Page ()
GoRoute(
path: RoutePaths.share,

View File

@ -22,6 +22,7 @@ class RouteNames {
static const editProfile = 'edit-profile';
static const referralList = 'referral-list';
static const earningsDetail = 'earnings-detail';
static const notifications = 'notifications';
static const deposit = 'deposit';
static const depositUsdt = 'deposit-usdt';
static const plantingQuantity = 'planting-quantity';

View File

@ -22,6 +22,7 @@ class RoutePaths {
static const editProfile = '/profile/edit';
static const referralList = '/profile/referrals';
static const earningsDetail = '/profile/earnings';
static const notifications = '/notifications';
static const deposit = '/deposit';
static const depositUsdt = '/deposit/usdt';
static const plantingQuantity = '/planting/quantity';