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/schedule": "^4.0.0",
|
||||||
"@nestjs/swagger": "^7.1.17",
|
"@nestjs/swagger": "^7.1.17",
|
||||||
"@prisma/client": "^5.7.0",
|
"@prisma/client": "^5.7.0",
|
||||||
|
"axios": "^1.13.2",
|
||||||
"class-transformer": "^0.5.1",
|
"class-transformer": "^0.5.1",
|
||||||
"class-validator": "^0.14.0",
|
"class-validator": "^0.14.0",
|
||||||
"csv-stringify": "^6.4.4",
|
"csv-stringify": "^6.4.4",
|
||||||
|
|
@ -45,6 +46,7 @@
|
||||||
"@types/uuid": "^9.0.7",
|
"@types/uuid": "^9.0.7",
|
||||||
"@typescript-eslint/eslint-plugin": "^6.0.0",
|
"@typescript-eslint/eslint-plugin": "^6.0.0",
|
||||||
"@typescript-eslint/parser": "^6.0.0",
|
"@typescript-eslint/parser": "^6.0.0",
|
||||||
|
"dotenv": "^17.2.3",
|
||||||
"eslint": "^8.42.0",
|
"eslint": "^8.42.0",
|
||||||
"eslint-config-prettier": "^9.0.0",
|
"eslint-config-prettier": "^9.0.0",
|
||||||
"eslint-plugin-prettier": "^5.0.0",
|
"eslint-plugin-prettier": "^5.0.0",
|
||||||
|
|
@ -251,6 +253,7 @@
|
||||||
"integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==",
|
"integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/code-frame": "^7.27.1",
|
"@babel/code-frame": "^7.27.1",
|
||||||
"@babel/generator": "^7.28.5",
|
"@babel/generator": "^7.28.5",
|
||||||
|
|
@ -1825,6 +1828,18 @@
|
||||||
"rxjs": "^7.1.0"
|
"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": {
|
"node_modules/@nestjs/core": {
|
||||||
"version": "10.4.20",
|
"version": "10.4.20",
|
||||||
"resolved": "https://registry.npmjs.org/@nestjs/core/-/core-10.4.20.tgz",
|
"resolved": "https://registry.npmjs.org/@nestjs/core/-/core-10.4.20.tgz",
|
||||||
|
|
@ -3522,7 +3537,6 @@
|
||||||
"version": "0.4.0",
|
"version": "0.4.0",
|
||||||
"resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
|
"resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
|
||||||
"integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==",
|
"integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/available-typed-arrays": {
|
"node_modules/available-typed-arrays": {
|
||||||
|
|
@ -3540,6 +3554,17 @@
|
||||||
"url": "https://github.com/sponsors/ljharb"
|
"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": {
|
"node_modules/babel-jest": {
|
||||||
"version": "29.7.0",
|
"version": "29.7.0",
|
||||||
"resolved": "https://registry.npmjs.org/babel-jest/-/babel-jest-29.7.0.tgz",
|
"resolved": "https://registry.npmjs.org/babel-jest/-/babel-jest-29.7.0.tgz",
|
||||||
|
|
@ -4317,7 +4342,6 @@
|
||||||
"version": "1.0.8",
|
"version": "1.0.8",
|
||||||
"resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
|
"resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
|
||||||
"integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==",
|
"integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"delayed-stream": "~1.0.0"
|
"delayed-stream": "~1.0.0"
|
||||||
|
|
@ -4740,7 +4764,6 @@
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
|
||||||
"integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==",
|
"integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=0.4.0"
|
"node": ">=0.4.0"
|
||||||
|
|
@ -4848,9 +4871,10 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/dotenv": {
|
"node_modules/dotenv": {
|
||||||
"version": "16.4.5",
|
"version": "17.2.3",
|
||||||
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.4.5.tgz",
|
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.2.3.tgz",
|
||||||
"integrity": "sha512-ZmdL2rui+eB2YwhsWzjInR8LldtZHGDoQ1ugH85ppHKwpUHL7j7rN0Ti9NCnGiQbhaZ11FpR+7ao1dNsmduNUg==",
|
"integrity": "sha512-JVUnt+DUIzu87TABbhPmNfVdBDt18BLOWjMUFJMSi/Qqg7NTYtabbvSNJGOJ7afbRuv9D/lngizHtP7QyLQ+9w==",
|
||||||
|
"dev": true,
|
||||||
"license": "BSD-2-Clause",
|
"license": "BSD-2-Clause",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=12"
|
"node": ">=12"
|
||||||
|
|
@ -5079,7 +5103,6 @@
|
||||||
"version": "2.1.0",
|
"version": "2.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz",
|
||||||
"integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==",
|
"integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"es-errors": "^1.3.0",
|
"es-errors": "^1.3.0",
|
||||||
|
|
@ -5824,6 +5847,26 @@
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "ISC"
|
"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": {
|
"node_modules/fontkit": {
|
||||||
"version": "1.9.0",
|
"version": "1.9.0",
|
||||||
"resolved": "https://registry.npmjs.org/fontkit/-/fontkit-1.9.0.tgz",
|
"resolved": "https://registry.npmjs.org/fontkit/-/fontkit-1.9.0.tgz",
|
||||||
|
|
@ -5930,7 +5973,6 @@
|
||||||
"version": "4.0.5",
|
"version": "4.0.5",
|
||||||
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz",
|
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz",
|
||||||
"integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==",
|
"integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"asynckit": "^0.4.0",
|
"asynckit": "^0.4.0",
|
||||||
|
|
@ -9355,6 +9397,12 @@
|
||||||
"node": ">= 0.10"
|
"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": {
|
"node_modules/punycode": {
|
||||||
"version": "2.3.1",
|
"version": "2.3.1",
|
||||||
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
|
||||||
|
|
|
||||||
|
|
@ -35,6 +35,7 @@
|
||||||
"@nestjs/schedule": "^4.0.0",
|
"@nestjs/schedule": "^4.0.0",
|
||||||
"@nestjs/swagger": "^7.1.17",
|
"@nestjs/swagger": "^7.1.17",
|
||||||
"@prisma/client": "^5.7.0",
|
"@prisma/client": "^5.7.0",
|
||||||
|
"axios": "^1.13.2",
|
||||||
"class-transformer": "^0.5.1",
|
"class-transformer": "^0.5.1",
|
||||||
"class-validator": "^0.14.0",
|
"class-validator": "^0.14.0",
|
||||||
"csv-stringify": "^6.4.4",
|
"csv-stringify": "^6.4.4",
|
||||||
|
|
@ -61,6 +62,7 @@
|
||||||
"@types/uuid": "^9.0.7",
|
"@types/uuid": "^9.0.7",
|
||||||
"@typescript-eslint/eslint-plugin": "^6.0.0",
|
"@typescript-eslint/eslint-plugin": "^6.0.0",
|
||||||
"@typescript-eslint/parser": "^6.0.0",
|
"@typescript-eslint/parser": "^6.0.0",
|
||||||
|
"dotenv": "^17.2.3",
|
||||||
"eslint": "^8.42.0",
|
"eslint": "^8.42.0",
|
||||||
"eslint-config-prettier": "^9.0.0",
|
"eslint-config-prettier": "^9.0.0",
|
||||||
"eslint-plugin-prettier": "^5.0.0",
|
"eslint-plugin-prettier": "^5.0.0",
|
||||||
|
|
@ -82,7 +84,10 @@
|
||||||
"ts"
|
"ts"
|
||||||
],
|
],
|
||||||
"rootDir": ".",
|
"rootDir": ".",
|
||||||
"roots": ["<rootDir>/src", "<rootDir>/test"],
|
"roots": [
|
||||||
|
"<rootDir>/src",
|
||||||
|
"<rootDir>/test"
|
||||||
|
],
|
||||||
"testRegex": ".*\\.(spec|integration\\.spec)\\.ts$",
|
"testRegex": ".*\\.(spec|integration\\.spec)\\.ts$",
|
||||||
"transform": {
|
"transform": {
|
||||||
"^.+\\.(t|j)s$": "ts-jest"
|
"^.+\\.(t|j)s$": "ts-jest"
|
||||||
|
|
|
||||||
|
|
@ -3,9 +3,15 @@ import { ApplicationModule } from '../application/application.module';
|
||||||
import { HealthController } from './controllers/health.controller';
|
import { HealthController } from './controllers/health.controller';
|
||||||
import { ReportController } from './controllers/report.controller';
|
import { ReportController } from './controllers/report.controller';
|
||||||
import { ExportController } from './controllers/export.controller';
|
import { ExportController } from './controllers/export.controller';
|
||||||
|
import { DashboardController } from './controllers/dashboard.controller';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [ApplicationModule],
|
imports: [ApplicationModule],
|
||||||
controllers: [HealthController, ReportController, ExportController],
|
controllers: [
|
||||||
|
HealthController,
|
||||||
|
ReportController,
|
||||||
|
ExportController,
|
||||||
|
DashboardController,
|
||||||
|
],
|
||||||
})
|
})
|
||||||
export class ApiModule {}
|
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 { ConfigModule } from '@nestjs/config';
|
||||||
import { APP_FILTER, APP_INTERCEPTOR, APP_GUARD } from '@nestjs/core';
|
import { APP_FILTER, APP_INTERCEPTOR, APP_GUARD } from '@nestjs/core';
|
||||||
import { ApiModule } from './api/api.module';
|
import { ApiModule } from './api/api.module';
|
||||||
|
import { KafkaModule } from './infrastructure/kafka';
|
||||||
import { GlobalExceptionFilter } from './shared/filters/global-exception.filter';
|
import { GlobalExceptionFilter } from './shared/filters/global-exception.filter';
|
||||||
import { TransformInterceptor } from './shared/interceptors/transform.interceptor';
|
import { TransformInterceptor } from './shared/interceptors/transform.interceptor';
|
||||||
import { JwtAuthGuard } from './shared/guards/jwt-auth.guard';
|
import { JwtAuthGuard } from './shared/guards/jwt-auth.guard';
|
||||||
|
|
@ -21,6 +22,7 @@ import {
|
||||||
load: [appConfig, databaseConfig, jwtConfig, redisConfig],
|
load: [appConfig, databaseConfig, jwtConfig, redisConfig],
|
||||||
}),
|
}),
|
||||||
ApiModule,
|
ApiModule,
|
||||||
|
KafkaModule,
|
||||||
],
|
],
|
||||||
providers: [
|
providers: [
|
||||||
JwtStrategy,
|
JwtStrategy,
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ import { GenerateReportHandler } from './commands/generate-report/generate-repor
|
||||||
import { ExportReportHandler } from './commands/export-report/export-report.handler';
|
import { ExportReportHandler } from './commands/export-report/export-report.handler';
|
||||||
import { GetReportSnapshotHandler } from './queries/get-report-snapshot/get-report-snapshot.handler';
|
import { GetReportSnapshotHandler } from './queries/get-report-snapshot/get-report-snapshot.handler';
|
||||||
import { ReportingApplicationService } from './services/reporting-application.service';
|
import { ReportingApplicationService } from './services/reporting-application.service';
|
||||||
|
import { DashboardApplicationService } from './services/dashboard-application.service';
|
||||||
import { ReportGenerationScheduler } from './schedulers/report-generation.scheduler';
|
import { ReportGenerationScheduler } from './schedulers/report-generation.scheduler';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
|
|
@ -15,6 +16,7 @@ import { ReportGenerationScheduler } from './schedulers/report-generation.schedu
|
||||||
ExportReportHandler,
|
ExportReportHandler,
|
||||||
GetReportSnapshotHandler,
|
GetReportSnapshotHandler,
|
||||||
ReportingApplicationService,
|
ReportingApplicationService,
|
||||||
|
DashboardApplicationService,
|
||||||
ReportGenerationScheduler,
|
ReportGenerationScheduler,
|
||||||
],
|
],
|
||||||
exports: [
|
exports: [
|
||||||
|
|
@ -22,6 +24,7 @@ import { ReportGenerationScheduler } from './schedulers/report-generation.schedu
|
||||||
ExportReportHandler,
|
ExportReportHandler,
|
||||||
GetReportSnapshotHandler,
|
GetReportSnapshotHandler,
|
||||||
ReportingApplicationService,
|
ReportingApplicationService,
|
||||||
|
DashboardApplicationService,
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
export class ApplicationModule {}
|
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 './report-schedule.vo';
|
||||||
export * from './snapshot-data.vo';
|
export * from './snapshot-data.vo';
|
||||||
export * from './data-source.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