feat(reporting): 实现 Dashboard API 完整功能
## 概述 为 reporting-service 实现完整的 Dashboard API 端点,支持统计卡片、趋势图表、 区域分布和最近活动等功能。 ## API 端点 - GET /dashboard/stats: 获取统计卡片数据 - GET /dashboard/charts: 获取趋势图表数据 (支持 7d/30d/90d 周期) - GET /dashboard/region: 获取区域分布数据 - GET /dashboard/activities: 获取最近活动列表 ## 新增 DTO - DashboardStatsResponseDto: 统计卡片响应 - DashboardTrendResponseDto: 趋势数据响应 - DashboardRegionResponseDto: 区域分布响应 - DashboardActivitiesResponseDto: 活动列表响应 ## Repository 层 - IDashboardStatsSnapshotRepository: 统计快照接口 - IDashboardTrendDataRepository: 趋势数据接口 - ISystemActivityRepository: 系统活动接口 ## External Clients (已弃用) - AuthorizationServiceClient: 授权服务客户端 - IdentityServiceClient: 身份服务客户端 注:已改为事件驱动架构,这些客户端仅作为备用 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
f65b0d14b7
commit
0e367d042c
|
|
@ -19,6 +19,7 @@
|
|||
"@nestjs/schedule": "^4.0.0",
|
||||
"@nestjs/swagger": "^7.1.17",
|
||||
"@prisma/client": "^5.7.0",
|
||||
"axios": "^1.13.2",
|
||||
"class-transformer": "^0.5.1",
|
||||
"class-validator": "^0.14.0",
|
||||
"csv-stringify": "^6.4.4",
|
||||
|
|
@ -45,6 +46,7 @@
|
|||
"@types/uuid": "^9.0.7",
|
||||
"@typescript-eslint/eslint-plugin": "^6.0.0",
|
||||
"@typescript-eslint/parser": "^6.0.0",
|
||||
"dotenv": "^17.2.3",
|
||||
"eslint": "^8.42.0",
|
||||
"eslint-config-prettier": "^9.0.0",
|
||||
"eslint-plugin-prettier": "^5.0.0",
|
||||
|
|
@ -251,6 +253,7 @@
|
|||
"integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@babel/code-frame": "^7.27.1",
|
||||
"@babel/generator": "^7.28.5",
|
||||
|
|
@ -1825,6 +1828,18 @@
|
|||
"rxjs": "^7.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@nestjs/config/node_modules/dotenv": {
|
||||
"version": "16.4.5",
|
||||
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.4.5.tgz",
|
||||
"integrity": "sha512-ZmdL2rui+eB2YwhsWzjInR8LldtZHGDoQ1ugH85ppHKwpUHL7j7rN0Ti9NCnGiQbhaZ11FpR+7ao1dNsmduNUg==",
|
||||
"license": "BSD-2-Clause",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://dotenvx.com"
|
||||
}
|
||||
},
|
||||
"node_modules/@nestjs/core": {
|
||||
"version": "10.4.20",
|
||||
"resolved": "https://registry.npmjs.org/@nestjs/core/-/core-10.4.20.tgz",
|
||||
|
|
@ -3522,7 +3537,6 @@
|
|||
"version": "0.4.0",
|
||||
"resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
|
||||
"integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/available-typed-arrays": {
|
||||
|
|
@ -3540,6 +3554,17 @@
|
|||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/axios": {
|
||||
"version": "1.13.2",
|
||||
"resolved": "https://registry.npmjs.org/axios/-/axios-1.13.2.tgz",
|
||||
"integrity": "sha512-VPk9ebNqPcy5lRGuSlKx752IlDatOjT9paPlm8A7yOuW2Fbvp4X3JznJtT4f0GzGLLiWE9W8onz51SqLYwzGaA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"follow-redirects": "^1.15.6",
|
||||
"form-data": "^4.0.4",
|
||||
"proxy-from-env": "^1.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/babel-jest": {
|
||||
"version": "29.7.0",
|
||||
"resolved": "https://registry.npmjs.org/babel-jest/-/babel-jest-29.7.0.tgz",
|
||||
|
|
@ -4317,7 +4342,6 @@
|
|||
"version": "1.0.8",
|
||||
"resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
|
||||
"integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"delayed-stream": "~1.0.0"
|
||||
|
|
@ -4740,7 +4764,6 @@
|
|||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
|
||||
"integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.4.0"
|
||||
|
|
@ -4848,9 +4871,10 @@
|
|||
}
|
||||
},
|
||||
"node_modules/dotenv": {
|
||||
"version": "16.4.5",
|
||||
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.4.5.tgz",
|
||||
"integrity": "sha512-ZmdL2rui+eB2YwhsWzjInR8LldtZHGDoQ1ugH85ppHKwpUHL7j7rN0Ti9NCnGiQbhaZ11FpR+7ao1dNsmduNUg==",
|
||||
"version": "17.2.3",
|
||||
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.2.3.tgz",
|
||||
"integrity": "sha512-JVUnt+DUIzu87TABbhPmNfVdBDt18BLOWjMUFJMSi/Qqg7NTYtabbvSNJGOJ7afbRuv9D/lngizHtP7QyLQ+9w==",
|
||||
"dev": true,
|
||||
"license": "BSD-2-Clause",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
|
|
@ -5079,7 +5103,6 @@
|
|||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz",
|
||||
"integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"es-errors": "^1.3.0",
|
||||
|
|
@ -5824,6 +5847,26 @@
|
|||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/follow-redirects": {
|
||||
"version": "1.15.11",
|
||||
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz",
|
||||
"integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "individual",
|
||||
"url": "https://github.com/sponsors/RubenVerborgh"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=4.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"debug": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/fontkit": {
|
||||
"version": "1.9.0",
|
||||
"resolved": "https://registry.npmjs.org/fontkit/-/fontkit-1.9.0.tgz",
|
||||
|
|
@ -5930,7 +5973,6 @@
|
|||
"version": "4.0.5",
|
||||
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz",
|
||||
"integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"asynckit": "^0.4.0",
|
||||
|
|
@ -9355,6 +9397,12 @@
|
|||
"node": ">= 0.10"
|
||||
}
|
||||
},
|
||||
"node_modules/proxy-from-env": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz",
|
||||
"integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/punycode": {
|
||||
"version": "2.3.1",
|
||||
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
|
||||
|
|
|
|||
|
|
@ -35,6 +35,7 @@
|
|||
"@nestjs/schedule": "^4.0.0",
|
||||
"@nestjs/swagger": "^7.1.17",
|
||||
"@prisma/client": "^5.7.0",
|
||||
"axios": "^1.13.2",
|
||||
"class-transformer": "^0.5.1",
|
||||
"class-validator": "^0.14.0",
|
||||
"csv-stringify": "^6.4.4",
|
||||
|
|
@ -61,6 +62,7 @@
|
|||
"@types/uuid": "^9.0.7",
|
||||
"@typescript-eslint/eslint-plugin": "^6.0.0",
|
||||
"@typescript-eslint/parser": "^6.0.0",
|
||||
"dotenv": "^17.2.3",
|
||||
"eslint": "^8.42.0",
|
||||
"eslint-config-prettier": "^9.0.0",
|
||||
"eslint-plugin-prettier": "^5.0.0",
|
||||
|
|
@ -82,7 +84,10 @@
|
|||
"ts"
|
||||
],
|
||||
"rootDir": ".",
|
||||
"roots": ["<rootDir>/src", "<rootDir>/test"],
|
||||
"roots": [
|
||||
"<rootDir>/src",
|
||||
"<rootDir>/test"
|
||||
],
|
||||
"testRegex": ".*\\.(spec|integration\\.spec)\\.ts$",
|
||||
"transform": {
|
||||
"^.+\\.(t|j)s$": "ts-jest"
|
||||
|
|
|
|||
|
|
@ -3,9 +3,15 @@ import { ApplicationModule } from '../application/application.module';
|
|||
import { HealthController } from './controllers/health.controller';
|
||||
import { ReportController } from './controllers/report.controller';
|
||||
import { ExportController } from './controllers/export.controller';
|
||||
import { DashboardController } from './controllers/dashboard.controller';
|
||||
|
||||
@Module({
|
||||
imports: [ApplicationModule],
|
||||
controllers: [HealthController, ReportController, ExportController],
|
||||
controllers: [
|
||||
HealthController,
|
||||
ReportController,
|
||||
ExportController,
|
||||
DashboardController,
|
||||
],
|
||||
})
|
||||
export class ApiModule {}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,78 @@
|
|||
import { Controller, Get, Query, UseGuards } from '@nestjs/common';
|
||||
import {
|
||||
ApiTags,
|
||||
ApiOperation,
|
||||
ApiResponse,
|
||||
ApiBearerAuth,
|
||||
} from '@nestjs/swagger';
|
||||
import { DashboardApplicationService } from '../../application/services/dashboard-application.service';
|
||||
import {
|
||||
DashboardTrendQueryDto,
|
||||
DashboardActivitiesQueryDto,
|
||||
} from '../dto/request/dashboard-query.dto';
|
||||
import { DashboardStatsResponseDto } from '../dto/response/dashboard-stats.dto';
|
||||
import { DashboardTrendResponseDto } from '../dto/response/dashboard-trend.dto';
|
||||
import { DashboardActivitiesResponseDto } from '../dto/response/dashboard-activity.dto';
|
||||
import { DashboardRegionResponseDto } from '../dto/response/dashboard-region.dto';
|
||||
import { JwtAuthGuard } from '../../shared/guards/jwt-auth.guard';
|
||||
import { DashboardPeriod } from '../../domain/value-objects';
|
||||
|
||||
@ApiTags('Dashboard')
|
||||
@Controller('dashboard')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@ApiBearerAuth()
|
||||
export class DashboardController {
|
||||
constructor(
|
||||
private readonly dashboardService: DashboardApplicationService,
|
||||
) {}
|
||||
|
||||
@Get('stats')
|
||||
@ApiOperation({ summary: '获取仪表板统计卡片数据' })
|
||||
@ApiResponse({
|
||||
status: 200,
|
||||
description: '统计数据',
|
||||
type: DashboardStatsResponseDto,
|
||||
})
|
||||
async getStats(): Promise<DashboardStatsResponseDto> {
|
||||
return this.dashboardService.getStats();
|
||||
}
|
||||
|
||||
@Get('charts')
|
||||
@ApiOperation({ summary: '获取趋势图表数据' })
|
||||
@ApiResponse({
|
||||
status: 200,
|
||||
description: '趋势数据',
|
||||
type: DashboardTrendResponseDto,
|
||||
})
|
||||
async getTrendData(
|
||||
@Query() query: DashboardTrendQueryDto,
|
||||
): Promise<DashboardTrendResponseDto> {
|
||||
return this.dashboardService.getTrendData(
|
||||
query.period ?? DashboardPeriod.SEVEN_DAYS,
|
||||
);
|
||||
}
|
||||
|
||||
@Get('activities')
|
||||
@ApiOperation({ summary: '获取最近活动列表' })
|
||||
@ApiResponse({
|
||||
status: 200,
|
||||
description: '活动列表',
|
||||
type: DashboardActivitiesResponseDto,
|
||||
})
|
||||
async getActivities(
|
||||
@Query() query: DashboardActivitiesQueryDto,
|
||||
): Promise<DashboardActivitiesResponseDto> {
|
||||
return this.dashboardService.getActivities(query.limit ?? 5);
|
||||
}
|
||||
|
||||
@Get('region')
|
||||
@ApiOperation({ summary: '获取区域分布数据' })
|
||||
@ApiResponse({
|
||||
status: 200,
|
||||
description: '区域分布数据',
|
||||
type: DashboardRegionResponseDto,
|
||||
})
|
||||
async getRegionDistribution(): Promise<DashboardRegionResponseDto> {
|
||||
return this.dashboardService.getRegionDistribution();
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,36 @@
|
|||
import { IsEnum, IsOptional, IsInt, Min, Max } from 'class-validator';
|
||||
import { ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { Type } from 'class-transformer';
|
||||
import { DashboardPeriod } from '../../../domain/value-objects';
|
||||
|
||||
/**
|
||||
* 仪表板趋势查询参数
|
||||
*/
|
||||
export class DashboardTrendQueryDto {
|
||||
@ApiPropertyOptional({
|
||||
enum: DashboardPeriod,
|
||||
default: DashboardPeriod.SEVEN_DAYS,
|
||||
description: '时间周期 (7d, 30d, 90d)',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsEnum(DashboardPeriod)
|
||||
period?: DashboardPeriod = DashboardPeriod.SEVEN_DAYS;
|
||||
}
|
||||
|
||||
/**
|
||||
* 仪表板活动查询参数
|
||||
*/
|
||||
export class DashboardActivitiesQueryDto {
|
||||
@ApiPropertyOptional({
|
||||
default: 5,
|
||||
minimum: 1,
|
||||
maximum: 20,
|
||||
description: '返回数量限制',
|
||||
})
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
@Max(20)
|
||||
limit?: number = 5;
|
||||
}
|
||||
|
|
@ -0,0 +1,48 @@
|
|||
import { ApiProperty } from '@nestjs/swagger';
|
||||
|
||||
/**
|
||||
* 活动类型
|
||||
*/
|
||||
export type ActivityType =
|
||||
| 'user_register'
|
||||
| 'company_authorization'
|
||||
| 'planting_order'
|
||||
| 'system_update'
|
||||
| 'report_generated';
|
||||
|
||||
/**
|
||||
* 活动记录项
|
||||
*/
|
||||
export class DashboardActivityItemDto {
|
||||
@ApiProperty({ description: '活动ID', example: '1' })
|
||||
id: string;
|
||||
|
||||
@ApiProperty({
|
||||
enum: ['user_register', 'company_authorization', 'planting_order', 'system_update', 'report_generated'],
|
||||
description: '活动类型',
|
||||
})
|
||||
type: ActivityType;
|
||||
|
||||
@ApiProperty({ description: '图标', example: '👤' })
|
||||
icon: string;
|
||||
|
||||
@ApiProperty({ description: '活动标题', example: '新用户注册' })
|
||||
title: string;
|
||||
|
||||
@ApiProperty({ description: '活动描述', example: '用户 张三 完成注册' })
|
||||
description: string;
|
||||
|
||||
@ApiProperty({ description: '相对时间', example: '5分钟前' })
|
||||
timestamp: string;
|
||||
|
||||
@ApiProperty({ description: '创建时间 ISO 格式', example: '2024-03-25T10:30:00Z' })
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 仪表板活动响应
|
||||
*/
|
||||
export class DashboardActivitiesResponseDto {
|
||||
@ApiProperty({ type: [DashboardActivityItemDto], description: '活动列表' })
|
||||
activities: DashboardActivityItemDto[];
|
||||
}
|
||||
|
|
@ -0,0 +1,23 @@
|
|||
import { ApiProperty } from '@nestjs/swagger';
|
||||
|
||||
/**
|
||||
* 区域分布项
|
||||
*/
|
||||
export class RegionDistributionItemDto {
|
||||
@ApiProperty({ description: '区域名称', example: '华东地区' })
|
||||
region: string;
|
||||
|
||||
@ApiProperty({ description: '占比百分比', example: 35 })
|
||||
percentage: number;
|
||||
|
||||
@ApiProperty({ description: '颜色', example: '#1565C0' })
|
||||
color: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 区域分布响应
|
||||
*/
|
||||
export class DashboardRegionResponseDto {
|
||||
@ApiProperty({ type: [RegionDistributionItemDto], description: '区域分布列表' })
|
||||
regions: RegionDistributionItemDto[];
|
||||
}
|
||||
|
|
@ -0,0 +1,40 @@
|
|||
import { ApiProperty } from '@nestjs/swagger';
|
||||
|
||||
/**
|
||||
* 趋势变化
|
||||
*/
|
||||
export class StatChangeDto {
|
||||
@ApiProperty({ description: '变化百分比', example: 5.6 })
|
||||
value: number;
|
||||
|
||||
@ApiProperty({ enum: ['up', 'down'], description: '趋势方向' })
|
||||
trend: 'up' | 'down';
|
||||
}
|
||||
|
||||
/**
|
||||
* 统计卡片项
|
||||
*/
|
||||
export class DashboardStatItemDto {
|
||||
@ApiProperty({ description: '标题', example: '总认种量' })
|
||||
title: string;
|
||||
|
||||
@ApiProperty({ description: '数值', example: 12580 })
|
||||
value: number;
|
||||
|
||||
@ApiProperty({ description: '单位后缀', example: '棵' })
|
||||
suffix: string;
|
||||
|
||||
@ApiProperty({ type: StatChangeDto, description: '变化趋势' })
|
||||
change: StatChangeDto;
|
||||
|
||||
@ApiProperty({ description: '颜色标识', example: '#1565C0' })
|
||||
color: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 仪表板统计响应
|
||||
*/
|
||||
export class DashboardStatsResponseDto {
|
||||
@ApiProperty({ type: [DashboardStatItemDto], description: '统计卡片列表' })
|
||||
stats: DashboardStatItemDto[];
|
||||
}
|
||||
|
|
@ -0,0 +1,32 @@
|
|||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { DashboardPeriod } from '../../../domain/value-objects';
|
||||
|
||||
/**
|
||||
* 趋势数据点
|
||||
*/
|
||||
export class TrendDataPointDto {
|
||||
@ApiProperty({ description: '日期', example: '03-19' })
|
||||
date: string;
|
||||
|
||||
@ApiProperty({ description: '数值', example: 150 })
|
||||
value: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 趋势数据
|
||||
*/
|
||||
export class DashboardTrendDataDto {
|
||||
@ApiProperty({ enum: DashboardPeriod, description: '时间周期' })
|
||||
period: DashboardPeriod;
|
||||
|
||||
@ApiProperty({ type: [TrendDataPointDto], description: '趋势数据点' })
|
||||
data: TrendDataPointDto[];
|
||||
}
|
||||
|
||||
/**
|
||||
* 仪表板趋势响应
|
||||
*/
|
||||
export class DashboardTrendResponseDto {
|
||||
@ApiProperty({ type: DashboardTrendDataDto, description: '趋势数据' })
|
||||
trend: DashboardTrendDataDto;
|
||||
}
|
||||
|
|
@ -2,6 +2,7 @@ import { Module } from '@nestjs/common';
|
|||
import { ConfigModule } from '@nestjs/config';
|
||||
import { APP_FILTER, APP_INTERCEPTOR, APP_GUARD } from '@nestjs/core';
|
||||
import { ApiModule } from './api/api.module';
|
||||
import { KafkaModule } from './infrastructure/kafka';
|
||||
import { GlobalExceptionFilter } from './shared/filters/global-exception.filter';
|
||||
import { TransformInterceptor } from './shared/interceptors/transform.interceptor';
|
||||
import { JwtAuthGuard } from './shared/guards/jwt-auth.guard';
|
||||
|
|
@ -21,6 +22,7 @@ import {
|
|||
load: [appConfig, databaseConfig, jwtConfig, redisConfig],
|
||||
}),
|
||||
ApiModule,
|
||||
KafkaModule,
|
||||
],
|
||||
providers: [
|
||||
JwtStrategy,
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import { GenerateReportHandler } from './commands/generate-report/generate-repor
|
|||
import { ExportReportHandler } from './commands/export-report/export-report.handler';
|
||||
import { GetReportSnapshotHandler } from './queries/get-report-snapshot/get-report-snapshot.handler';
|
||||
import { ReportingApplicationService } from './services/reporting-application.service';
|
||||
import { DashboardApplicationService } from './services/dashboard-application.service';
|
||||
import { ReportGenerationScheduler } from './schedulers/report-generation.scheduler';
|
||||
|
||||
@Module({
|
||||
|
|
@ -15,6 +16,7 @@ import { ReportGenerationScheduler } from './schedulers/report-generation.schedu
|
|||
ExportReportHandler,
|
||||
GetReportSnapshotHandler,
|
||||
ReportingApplicationService,
|
||||
DashboardApplicationService,
|
||||
ReportGenerationScheduler,
|
||||
],
|
||||
exports: [
|
||||
|
|
@ -22,6 +24,7 @@ import { ReportGenerationScheduler } from './schedulers/report-generation.schedu
|
|||
ExportReportHandler,
|
||||
GetReportSnapshotHandler,
|
||||
ReportingApplicationService,
|
||||
DashboardApplicationService,
|
||||
],
|
||||
})
|
||||
export class ApplicationModule {}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,57 @@
|
|||
/**
|
||||
* 仪表板统计快照仓储接口
|
||||
*/
|
||||
export const DASHBOARD_STATS_SNAPSHOT_REPOSITORY = Symbol(
|
||||
'DASHBOARD_STATS_SNAPSHOT_REPOSITORY',
|
||||
);
|
||||
|
||||
export interface DashboardStatsSnapshotData {
|
||||
id?: bigint;
|
||||
snapshotDate: Date;
|
||||
totalPlantingCount: number;
|
||||
totalPlantingChange: number;
|
||||
activeUserCount: number;
|
||||
activeUserChange: number;
|
||||
provinceCompanyCount: number;
|
||||
provinceCompanyChange: number;
|
||||
cityCompanyCount: number;
|
||||
cityCompanyChange: number;
|
||||
regionDistribution?: Record<string, unknown>;
|
||||
createdAt?: Date;
|
||||
updatedAt?: Date;
|
||||
}
|
||||
|
||||
export interface IDashboardStatsSnapshotRepository {
|
||||
/**
|
||||
* 保存或更新快照
|
||||
*/
|
||||
save(snapshot: DashboardStatsSnapshotData): Promise<DashboardStatsSnapshotData>;
|
||||
|
||||
/**
|
||||
* 根据ID查找
|
||||
*/
|
||||
findById(id: bigint): Promise<DashboardStatsSnapshotData | null>;
|
||||
|
||||
/**
|
||||
* 根据日期查找
|
||||
*/
|
||||
findByDate(date: Date): Promise<DashboardStatsSnapshotData | null>;
|
||||
|
||||
/**
|
||||
* 获取最新的快照
|
||||
*/
|
||||
findLatest(): Promise<DashboardStatsSnapshotData | null>;
|
||||
|
||||
/**
|
||||
* 获取日期范围内的快照
|
||||
*/
|
||||
findByDateRange(
|
||||
startDate: Date,
|
||||
endDate: Date,
|
||||
): Promise<DashboardStatsSnapshotData[]>;
|
||||
|
||||
/**
|
||||
* 删除指定日期之前的快照
|
||||
*/
|
||||
deleteBeforeDate(date: Date): Promise<number>;
|
||||
}
|
||||
|
|
@ -0,0 +1,57 @@
|
|||
/**
|
||||
* 仪表板趋势数据仓储接口
|
||||
*/
|
||||
export const DASHBOARD_TREND_DATA_REPOSITORY = Symbol(
|
||||
'DASHBOARD_TREND_DATA_REPOSITORY',
|
||||
);
|
||||
|
||||
export interface DashboardTrendDataItem {
|
||||
id?: bigint;
|
||||
trendDate: Date;
|
||||
plantingCount: number;
|
||||
orderCount: number;
|
||||
newUserCount: number;
|
||||
activeUserCount: number;
|
||||
createdAt?: Date;
|
||||
updatedAt?: Date;
|
||||
}
|
||||
|
||||
export interface IDashboardTrendDataRepository {
|
||||
/**
|
||||
* 保存或更新趋势数据
|
||||
*/
|
||||
save(data: DashboardTrendDataItem): Promise<DashboardTrendDataItem>;
|
||||
|
||||
/**
|
||||
* 批量保存趋势数据
|
||||
*/
|
||||
saveMany(dataList: DashboardTrendDataItem[]): Promise<number>;
|
||||
|
||||
/**
|
||||
* 根据ID查找
|
||||
*/
|
||||
findById(id: bigint): Promise<DashboardTrendDataItem | null>;
|
||||
|
||||
/**
|
||||
* 根据日期查找
|
||||
*/
|
||||
findByDate(date: Date): Promise<DashboardTrendDataItem | null>;
|
||||
|
||||
/**
|
||||
* 获取最近N天的趋势数据
|
||||
*/
|
||||
findRecentDays(days: number): Promise<DashboardTrendDataItem[]>;
|
||||
|
||||
/**
|
||||
* 获取日期范围内的趋势数据
|
||||
*/
|
||||
findByDateRange(
|
||||
startDate: Date,
|
||||
endDate: Date,
|
||||
): Promise<DashboardTrendDataItem[]>;
|
||||
|
||||
/**
|
||||
* 删除指定日期之前的数据
|
||||
*/
|
||||
deleteBeforeDate(date: Date): Promise<number>;
|
||||
}
|
||||
|
|
@ -0,0 +1,87 @@
|
|||
/**
|
||||
* 系统活动日志仓储接口
|
||||
*/
|
||||
export const SYSTEM_ACTIVITY_REPOSITORY = Symbol('SYSTEM_ACTIVITY_REPOSITORY');
|
||||
|
||||
export type ActivityType =
|
||||
| 'user_register'
|
||||
| 'company_authorization'
|
||||
| 'planting_order'
|
||||
| 'system_update'
|
||||
| 'report_generated';
|
||||
|
||||
export interface SystemActivityData {
|
||||
id?: bigint;
|
||||
activityType: ActivityType | string;
|
||||
title: string;
|
||||
description: string;
|
||||
icon?: string;
|
||||
relatedUserId?: bigint;
|
||||
relatedEntityId?: string;
|
||||
relatedEntityType?: string;
|
||||
metadata?: Record<string, unknown>;
|
||||
createdAt?: Date;
|
||||
}
|
||||
|
||||
export interface ISystemActivityRepository {
|
||||
/**
|
||||
* 创建活动记录
|
||||
*/
|
||||
create(activity: SystemActivityData): Promise<SystemActivityData>;
|
||||
|
||||
/**
|
||||
* 批量创建活动记录
|
||||
*/
|
||||
createMany(activities: SystemActivityData[]): Promise<number>;
|
||||
|
||||
/**
|
||||
* 根据ID查找
|
||||
*/
|
||||
findById(id: bigint): Promise<SystemActivityData | null>;
|
||||
|
||||
/**
|
||||
* 获取最近的活动记录
|
||||
*/
|
||||
findRecent(limit: number): Promise<SystemActivityData[]>;
|
||||
|
||||
/**
|
||||
* 根据活动类型查找
|
||||
*/
|
||||
findByType(
|
||||
type: ActivityType | string,
|
||||
limit?: number,
|
||||
): Promise<SystemActivityData[]>;
|
||||
|
||||
/**
|
||||
* 根据关联用户ID查找
|
||||
*/
|
||||
findByUserId(userId: bigint, limit?: number): Promise<SystemActivityData[]>;
|
||||
|
||||
/**
|
||||
* 根据关联实体查找
|
||||
*/
|
||||
findByEntity(
|
||||
entityType: string,
|
||||
entityId: string,
|
||||
limit?: number,
|
||||
): Promise<SystemActivityData[]>;
|
||||
|
||||
/**
|
||||
* 获取日期范围内的活动
|
||||
*/
|
||||
findByDateRange(
|
||||
startDate: Date,
|
||||
endDate: Date,
|
||||
limit?: number,
|
||||
): Promise<SystemActivityData[]>;
|
||||
|
||||
/**
|
||||
* 删除指定日期之前的活动
|
||||
*/
|
||||
deleteBeforeDate(date: Date): Promise<number>;
|
||||
|
||||
/**
|
||||
* 统计活动数量
|
||||
*/
|
||||
countByType(type: ActivityType | string): Promise<number>;
|
||||
}
|
||||
|
|
@ -0,0 +1,14 @@
|
|||
/**
|
||||
* 仪表板时间周期枚举
|
||||
*/
|
||||
export enum DashboardPeriod {
|
||||
SEVEN_DAYS = '7d',
|
||||
THIRTY_DAYS = '30d',
|
||||
NINETY_DAYS = '90d',
|
||||
}
|
||||
|
||||
export const DashboardPeriodDays: Record<DashboardPeriod, number> = {
|
||||
[DashboardPeriod.SEVEN_DAYS]: 7,
|
||||
[DashboardPeriod.THIRTY_DAYS]: 30,
|
||||
[DashboardPeriod.NINETY_DAYS]: 90,
|
||||
};
|
||||
|
|
@ -7,3 +7,4 @@ export * from './report-parameters.vo';
|
|||
export * from './report-schedule.vo';
|
||||
export * from './snapshot-data.vo';
|
||||
export * from './data-source.vo';
|
||||
export * from './dashboard-period.enum';
|
||||
|
|
|
|||
|
|
@ -0,0 +1,82 @@
|
|||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import axios from 'axios';
|
||||
|
||||
export interface CompanyStats {
|
||||
provinceCompanyCount: number;
|
||||
cityCompanyCount: number;
|
||||
}
|
||||
|
||||
export interface CompanyStatsChange {
|
||||
provinceCompanyCount: number;
|
||||
provinceCompanyChange: number;
|
||||
cityCompanyCount: number;
|
||||
cityCompanyChange: number;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class AuthorizationServiceClient {
|
||||
private readonly logger = new Logger(AuthorizationServiceClient.name);
|
||||
private readonly baseUrl: string;
|
||||
|
||||
constructor(private readonly configService: ConfigService) {
|
||||
this.baseUrl = this.configService.get<string>(
|
||||
'AUTHORIZATION_SERVICE_URL',
|
||||
'http://localhost:3004',
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取公司统计数据
|
||||
*/
|
||||
async getCompanyStats(): Promise<CompanyStats> {
|
||||
this.logger.debug(`Fetching company stats from ${this.baseUrl}`);
|
||||
|
||||
try {
|
||||
// TODO: 实现真实API调用
|
||||
// const response = await axios.get(`${this.baseUrl}/internal/stats/companies`);
|
||||
// return response.data;
|
||||
|
||||
// Mock data for development
|
||||
return {
|
||||
provinceCompanyCount: 28,
|
||||
cityCompanyCount: 156,
|
||||
};
|
||||
} catch (error) {
|
||||
this.logger.error('Failed to fetch company stats', error);
|
||||
return {
|
||||
provinceCompanyCount: 0,
|
||||
cityCompanyCount: 0,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取公司统计数据(含环比变化)
|
||||
*/
|
||||
async getCompanyStatsWithChange(): Promise<CompanyStatsChange> {
|
||||
this.logger.debug(`Fetching company stats with change from ${this.baseUrl}`);
|
||||
|
||||
try {
|
||||
// TODO: 实现真实API调用
|
||||
// const response = await axios.get(`${this.baseUrl}/internal/stats/companies/change`);
|
||||
// return response.data;
|
||||
|
||||
// Mock data for development
|
||||
return {
|
||||
provinceCompanyCount: 28,
|
||||
provinceCompanyChange: 2.1,
|
||||
cityCompanyCount: 156,
|
||||
cityCompanyChange: 4.8,
|
||||
};
|
||||
} catch (error) {
|
||||
this.logger.error('Failed to fetch company stats with change', error);
|
||||
return {
|
||||
provinceCompanyCount: 0,
|
||||
provinceCompanyChange: 0,
|
||||
cityCompanyCount: 0,
|
||||
cityCompanyChange: 0,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,85 @@
|
|||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import axios from 'axios';
|
||||
|
||||
export interface UserStats {
|
||||
totalUsers: number;
|
||||
activeUsers: number;
|
||||
newUsersToday: number;
|
||||
}
|
||||
|
||||
export interface UserStatsWithChange {
|
||||
totalUsers: number;
|
||||
totalUsersChange: number;
|
||||
activeUsers: number;
|
||||
activeUsersChange: number;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class IdentityServiceClient {
|
||||
private readonly logger = new Logger(IdentityServiceClient.name);
|
||||
private readonly baseUrl: string;
|
||||
|
||||
constructor(private readonly configService: ConfigService) {
|
||||
this.baseUrl = this.configService.get<string>(
|
||||
'IDENTITY_SERVICE_URL',
|
||||
'http://localhost:3001',
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取用户统计数据
|
||||
*/
|
||||
async getUserStats(): Promise<UserStats> {
|
||||
this.logger.debug(`Fetching user stats from ${this.baseUrl}`);
|
||||
|
||||
try {
|
||||
// TODO: 实现真实API调用
|
||||
// const response = await axios.get(`${this.baseUrl}/internal/stats/users`);
|
||||
// return response.data;
|
||||
|
||||
// Mock data for development
|
||||
return {
|
||||
totalUsers: 15680,
|
||||
activeUsers: 3240,
|
||||
newUsersToday: 45,
|
||||
};
|
||||
} catch (error) {
|
||||
this.logger.error('Failed to fetch user stats', error);
|
||||
return {
|
||||
totalUsers: 0,
|
||||
activeUsers: 0,
|
||||
newUsersToday: 0,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取用户统计数据(含环比变化)
|
||||
*/
|
||||
async getUserStatsWithChange(): Promise<UserStatsWithChange> {
|
||||
this.logger.debug(`Fetching user stats with change from ${this.baseUrl}`);
|
||||
|
||||
try {
|
||||
// TODO: 实现真实API调用
|
||||
// const response = await axios.get(`${this.baseUrl}/internal/stats/users/change`);
|
||||
// return response.data;
|
||||
|
||||
// Mock data for development
|
||||
return {
|
||||
totalUsers: 15680,
|
||||
totalUsersChange: 8.5,
|
||||
activeUsers: 3240,
|
||||
activeUsersChange: 3.2,
|
||||
};
|
||||
} catch (error) {
|
||||
this.logger.error('Failed to fetch user stats with change', error);
|
||||
return {
|
||||
totalUsers: 0,
|
||||
totalUsersChange: 0,
|
||||
activeUsers: 0,
|
||||
activeUsersChange: 0,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,116 @@
|
|||
import { Injectable } from '@nestjs/common';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import {
|
||||
IDashboardStatsSnapshotRepository,
|
||||
DashboardStatsSnapshotData,
|
||||
} from '../../../domain/repositories';
|
||||
|
||||
@Injectable()
|
||||
export class DashboardStatsSnapshotRepository
|
||||
implements IDashboardStatsSnapshotRepository
|
||||
{
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
async save(
|
||||
snapshot: DashboardStatsSnapshotData,
|
||||
): Promise<DashboardStatsSnapshotData> {
|
||||
const data = {
|
||||
snapshotDate: snapshot.snapshotDate,
|
||||
totalPlantingCount: snapshot.totalPlantingCount,
|
||||
totalPlantingChange: new Prisma.Decimal(snapshot.totalPlantingChange),
|
||||
activeUserCount: snapshot.activeUserCount,
|
||||
activeUserChange: new Prisma.Decimal(snapshot.activeUserChange),
|
||||
provinceCompanyCount: snapshot.provinceCompanyCount,
|
||||
provinceCompanyChange: new Prisma.Decimal(snapshot.provinceCompanyChange),
|
||||
cityCompanyCount: snapshot.cityCompanyCount,
|
||||
cityCompanyChange: new Prisma.Decimal(snapshot.cityCompanyChange),
|
||||
regionDistribution: snapshot.regionDistribution as Prisma.InputJsonValue,
|
||||
};
|
||||
|
||||
const result = await this.prisma.dashboardStatsSnapshot.upsert({
|
||||
where: {
|
||||
snapshotDate: snapshot.snapshotDate,
|
||||
},
|
||||
update: data,
|
||||
create: data,
|
||||
});
|
||||
|
||||
return this.toDomain(result);
|
||||
}
|
||||
|
||||
async findById(id: bigint): Promise<DashboardStatsSnapshotData | null> {
|
||||
const found = await this.prisma.dashboardStatsSnapshot.findUnique({
|
||||
where: { id },
|
||||
});
|
||||
return found ? this.toDomain(found) : null;
|
||||
}
|
||||
|
||||
async findByDate(date: Date): Promise<DashboardStatsSnapshotData | null> {
|
||||
const found = await this.prisma.dashboardStatsSnapshot.findUnique({
|
||||
where: {
|
||||
snapshotDate: date,
|
||||
},
|
||||
});
|
||||
return found ? this.toDomain(found) : null;
|
||||
}
|
||||
|
||||
async findLatest(): Promise<DashboardStatsSnapshotData | null> {
|
||||
const found = await this.prisma.dashboardStatsSnapshot.findFirst({
|
||||
orderBy: { snapshotDate: 'desc' },
|
||||
});
|
||||
return found ? this.toDomain(found) : null;
|
||||
}
|
||||
|
||||
async findByDateRange(
|
||||
startDate: Date,
|
||||
endDate: Date,
|
||||
): Promise<DashboardStatsSnapshotData[]> {
|
||||
const found = await this.prisma.dashboardStatsSnapshot.findMany({
|
||||
where: {
|
||||
snapshotDate: {
|
||||
gte: startDate,
|
||||
lte: endDate,
|
||||
},
|
||||
},
|
||||
orderBy: { snapshotDate: 'desc' },
|
||||
});
|
||||
return found.map((item) => this.toDomain(item));
|
||||
}
|
||||
|
||||
async deleteBeforeDate(date: Date): Promise<number> {
|
||||
const result = await this.prisma.dashboardStatsSnapshot.deleteMany({
|
||||
where: {
|
||||
snapshotDate: {
|
||||
lt: date,
|
||||
},
|
||||
},
|
||||
});
|
||||
return result.count;
|
||||
}
|
||||
|
||||
private toDomain(
|
||||
record: Awaited<
|
||||
ReturnType<typeof this.prisma.dashboardStatsSnapshot.findFirst>
|
||||
>,
|
||||
): DashboardStatsSnapshotData {
|
||||
if (!record) {
|
||||
throw new Error('Record is null');
|
||||
}
|
||||
return {
|
||||
id: record.id,
|
||||
snapshotDate: record.snapshotDate,
|
||||
totalPlantingCount: record.totalPlantingCount,
|
||||
totalPlantingChange: record.totalPlantingChange.toNumber(),
|
||||
activeUserCount: record.activeUserCount,
|
||||
activeUserChange: record.activeUserChange.toNumber(),
|
||||
provinceCompanyCount: record.provinceCompanyCount,
|
||||
provinceCompanyChange: record.provinceCompanyChange.toNumber(),
|
||||
cityCompanyCount: record.cityCompanyCount,
|
||||
cityCompanyChange: record.cityCompanyChange.toNumber(),
|
||||
regionDistribution: record.regionDistribution as Record<string, unknown>,
|
||||
createdAt: record.createdAt,
|
||||
updatedAt: record.updatedAt,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,124 @@
|
|||
import { Injectable } from '@nestjs/common';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import {
|
||||
IDashboardTrendDataRepository,
|
||||
DashboardTrendDataItem,
|
||||
} from '../../../domain/repositories';
|
||||
|
||||
@Injectable()
|
||||
export class DashboardTrendDataRepository
|
||||
implements IDashboardTrendDataRepository
|
||||
{
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
async save(data: DashboardTrendDataItem): Promise<DashboardTrendDataItem> {
|
||||
const result = await this.prisma.dashboardTrendData.upsert({
|
||||
where: {
|
||||
trendDate: data.trendDate,
|
||||
},
|
||||
update: {
|
||||
plantingCount: data.plantingCount,
|
||||
orderCount: data.orderCount,
|
||||
newUserCount: data.newUserCount,
|
||||
activeUserCount: data.activeUserCount,
|
||||
},
|
||||
create: {
|
||||
trendDate: data.trendDate,
|
||||
plantingCount: data.plantingCount,
|
||||
orderCount: data.orderCount,
|
||||
newUserCount: data.newUserCount,
|
||||
activeUserCount: data.activeUserCount,
|
||||
},
|
||||
});
|
||||
|
||||
return this.toDomain(result);
|
||||
}
|
||||
|
||||
async saveMany(dataList: DashboardTrendDataItem[]): Promise<number> {
|
||||
let count = 0;
|
||||
for (const data of dataList) {
|
||||
await this.save(data);
|
||||
count++;
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
async findById(id: bigint): Promise<DashboardTrendDataItem | null> {
|
||||
const found = await this.prisma.dashboardTrendData.findUnique({
|
||||
where: { id },
|
||||
});
|
||||
return found ? this.toDomain(found) : null;
|
||||
}
|
||||
|
||||
async findByDate(date: Date): Promise<DashboardTrendDataItem | null> {
|
||||
const found = await this.prisma.dashboardTrendData.findUnique({
|
||||
where: {
|
||||
trendDate: date,
|
||||
},
|
||||
});
|
||||
return found ? this.toDomain(found) : null;
|
||||
}
|
||||
|
||||
async findRecentDays(days: number): Promise<DashboardTrendDataItem[]> {
|
||||
const startDate = new Date();
|
||||
startDate.setDate(startDate.getDate() - days);
|
||||
startDate.setHours(0, 0, 0, 0);
|
||||
|
||||
const found = await this.prisma.dashboardTrendData.findMany({
|
||||
where: {
|
||||
trendDate: {
|
||||
gte: startDate,
|
||||
},
|
||||
},
|
||||
orderBy: { trendDate: 'asc' },
|
||||
});
|
||||
return found.map((item) => this.toDomain(item));
|
||||
}
|
||||
|
||||
async findByDateRange(
|
||||
startDate: Date,
|
||||
endDate: Date,
|
||||
): Promise<DashboardTrendDataItem[]> {
|
||||
const found = await this.prisma.dashboardTrendData.findMany({
|
||||
where: {
|
||||
trendDate: {
|
||||
gte: startDate,
|
||||
lte: endDate,
|
||||
},
|
||||
},
|
||||
orderBy: { trendDate: 'asc' },
|
||||
});
|
||||
return found.map((item) => this.toDomain(item));
|
||||
}
|
||||
|
||||
async deleteBeforeDate(date: Date): Promise<number> {
|
||||
const result = await this.prisma.dashboardTrendData.deleteMany({
|
||||
where: {
|
||||
trendDate: {
|
||||
lt: date,
|
||||
},
|
||||
},
|
||||
});
|
||||
return result.count;
|
||||
}
|
||||
|
||||
private toDomain(
|
||||
record: Awaited<
|
||||
ReturnType<typeof this.prisma.dashboardTrendData.findFirst>
|
||||
>,
|
||||
): DashboardTrendDataItem {
|
||||
if (!record) {
|
||||
throw new Error('Record is null');
|
||||
}
|
||||
return {
|
||||
id: record.id,
|
||||
trendDate: record.trendDate,
|
||||
plantingCount: record.plantingCount,
|
||||
orderCount: record.orderCount,
|
||||
newUserCount: record.newUserCount,
|
||||
activeUserCount: record.activeUserCount,
|
||||
createdAt: record.createdAt,
|
||||
updatedAt: record.updatedAt,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,156 @@
|
|||
import { Injectable } from '@nestjs/common';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import {
|
||||
ISystemActivityRepository,
|
||||
SystemActivityData,
|
||||
ActivityType,
|
||||
} from '../../../domain/repositories';
|
||||
|
||||
@Injectable()
|
||||
export class SystemActivityRepository implements ISystemActivityRepository {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
async create(activity: SystemActivityData): Promise<SystemActivityData> {
|
||||
const result = await this.prisma.systemActivity.create({
|
||||
data: {
|
||||
activityType: activity.activityType,
|
||||
title: activity.title,
|
||||
description: activity.description,
|
||||
icon: activity.icon || '📌',
|
||||
relatedUserId: activity.relatedUserId,
|
||||
relatedEntityId: activity.relatedEntityId,
|
||||
relatedEntityType: activity.relatedEntityType,
|
||||
metadata: activity.metadata as Prisma.InputJsonValue,
|
||||
},
|
||||
});
|
||||
|
||||
return this.toDomain(result);
|
||||
}
|
||||
|
||||
async createMany(activities: SystemActivityData[]): Promise<number> {
|
||||
const result = await this.prisma.systemActivity.createMany({
|
||||
data: activities.map((activity) => ({
|
||||
activityType: activity.activityType,
|
||||
title: activity.title,
|
||||
description: activity.description,
|
||||
icon: activity.icon || '📌',
|
||||
relatedUserId: activity.relatedUserId,
|
||||
relatedEntityId: activity.relatedEntityId,
|
||||
relatedEntityType: activity.relatedEntityType,
|
||||
metadata: activity.metadata as Prisma.InputJsonValue,
|
||||
})),
|
||||
});
|
||||
return result.count;
|
||||
}
|
||||
|
||||
async findById(id: bigint): Promise<SystemActivityData | null> {
|
||||
const found = await this.prisma.systemActivity.findUnique({
|
||||
where: { id },
|
||||
});
|
||||
return found ? this.toDomain(found) : null;
|
||||
}
|
||||
|
||||
async findRecent(limit: number): Promise<SystemActivityData[]> {
|
||||
const found = await this.prisma.systemActivity.findMany({
|
||||
take: limit,
|
||||
orderBy: { createdAt: 'desc' },
|
||||
});
|
||||
return found.map((item) => this.toDomain(item));
|
||||
}
|
||||
|
||||
async findByType(
|
||||
type: ActivityType | string,
|
||||
limit = 20,
|
||||
): Promise<SystemActivityData[]> {
|
||||
const found = await this.prisma.systemActivity.findMany({
|
||||
where: { activityType: type },
|
||||
take: limit,
|
||||
orderBy: { createdAt: 'desc' },
|
||||
});
|
||||
return found.map((item) => this.toDomain(item));
|
||||
}
|
||||
|
||||
async findByUserId(
|
||||
userId: bigint,
|
||||
limit = 20,
|
||||
): Promise<SystemActivityData[]> {
|
||||
const found = await this.prisma.systemActivity.findMany({
|
||||
where: { relatedUserId: userId },
|
||||
take: limit,
|
||||
orderBy: { createdAt: 'desc' },
|
||||
});
|
||||
return found.map((item) => this.toDomain(item));
|
||||
}
|
||||
|
||||
async findByEntity(
|
||||
entityType: string,
|
||||
entityId: string,
|
||||
limit = 20,
|
||||
): Promise<SystemActivityData[]> {
|
||||
const found = await this.prisma.systemActivity.findMany({
|
||||
where: {
|
||||
relatedEntityType: entityType,
|
||||
relatedEntityId: entityId,
|
||||
},
|
||||
take: limit,
|
||||
orderBy: { createdAt: 'desc' },
|
||||
});
|
||||
return found.map((item) => this.toDomain(item));
|
||||
}
|
||||
|
||||
async findByDateRange(
|
||||
startDate: Date,
|
||||
endDate: Date,
|
||||
limit = 100,
|
||||
): Promise<SystemActivityData[]> {
|
||||
const found = await this.prisma.systemActivity.findMany({
|
||||
where: {
|
||||
createdAt: {
|
||||
gte: startDate,
|
||||
lte: endDate,
|
||||
},
|
||||
},
|
||||
take: limit,
|
||||
orderBy: { createdAt: 'desc' },
|
||||
});
|
||||
return found.map((item) => this.toDomain(item));
|
||||
}
|
||||
|
||||
async deleteBeforeDate(date: Date): Promise<number> {
|
||||
const result = await this.prisma.systemActivity.deleteMany({
|
||||
where: {
|
||||
createdAt: {
|
||||
lt: date,
|
||||
},
|
||||
},
|
||||
});
|
||||
return result.count;
|
||||
}
|
||||
|
||||
async countByType(type: ActivityType | string): Promise<number> {
|
||||
return this.prisma.systemActivity.count({
|
||||
where: { activityType: type },
|
||||
});
|
||||
}
|
||||
|
||||
private toDomain(
|
||||
record: Awaited<ReturnType<typeof this.prisma.systemActivity.findFirst>>,
|
||||
): SystemActivityData {
|
||||
if (!record) {
|
||||
throw new Error('Record is null');
|
||||
}
|
||||
return {
|
||||
id: record.id,
|
||||
activityType: record.activityType as ActivityType,
|
||||
title: record.title,
|
||||
description: record.description,
|
||||
icon: record.icon,
|
||||
relatedUserId: record.relatedUserId ?? undefined,
|
||||
relatedEntityId: record.relatedEntityId ?? undefined,
|
||||
relatedEntityType: record.relatedEntityType ?? undefined,
|
||||
metadata: record.metadata as Record<string, unknown>,
|
||||
createdAt: record.createdAt,
|
||||
};
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue