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:
hailin 2025-12-18 00:31:08 -08:00
parent f65b0d14b7
commit 0e367d042c
21 changed files with 1110 additions and 10 deletions

View File

@ -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",

View File

@ -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"

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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,

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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