feat(planting-service): add global stats API for data verification
Add new endpoint GET /api/v1/planting/stats/global to query planting statistics directly from the database, providing reliable data source for verifying reporting-service statistics. New features: - GlobalPlantingStats: total tree count, order count, amount - StatusDistribution: breakdown by order status (PAID to MINING_ENABLED) - TodayStats: daily statistics with tree count, order count, amount Implementation: - Pure additive changes, no modifications to existing code - Read-only aggregate queries using Prisma aggregate/groupBy - No database schema changes required 🤖 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
8148f7a52a
commit
46b68e8652
|
|
@ -1,6 +1,7 @@
|
||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
import { PlantingOrderController } from './controllers/planting-order.controller';
|
import { PlantingOrderController } from './controllers/planting-order.controller';
|
||||||
import { PlantingPositionController } from './controllers/planting-position.controller';
|
import { PlantingPositionController } from './controllers/planting-position.controller';
|
||||||
|
import { PlantingStatsController } from './controllers/planting-stats.controller';
|
||||||
import { HealthController } from './controllers/health.controller';
|
import { HealthController } from './controllers/health.controller';
|
||||||
import {
|
import {
|
||||||
ContractSigningController,
|
ContractSigningController,
|
||||||
|
|
@ -14,6 +15,7 @@ import { JwtAuthGuard } from './guards/jwt-auth.guard';
|
||||||
controllers: [
|
controllers: [
|
||||||
PlantingOrderController,
|
PlantingOrderController,
|
||||||
PlantingPositionController,
|
PlantingPositionController,
|
||||||
|
PlantingStatsController,
|
||||||
HealthController,
|
HealthController,
|
||||||
ContractSigningController,
|
ContractSigningController,
|
||||||
ContractSigningConfigController,
|
ContractSigningConfigController,
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
export * from './planting-order.controller';
|
export * from './planting-order.controller';
|
||||||
export * from './planting-position.controller';
|
export * from './planting-position.controller';
|
||||||
|
export * from './planting-stats.controller';
|
||||||
export * from './health.controller';
|
export * from './health.controller';
|
||||||
export * from './contract-signing.controller';
|
export * from './contract-signing.controller';
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,72 @@
|
||||||
|
import { Controller, Get, UseGuards } from '@nestjs/common';
|
||||||
|
import {
|
||||||
|
ApiTags,
|
||||||
|
ApiOperation,
|
||||||
|
ApiResponse,
|
||||||
|
ApiBearerAuth,
|
||||||
|
} from '@nestjs/swagger';
|
||||||
|
import { PlantingApplicationService } from '../../application/services/planting-application.service';
|
||||||
|
import { JwtAuthGuard } from '../guards/jwt-auth.guard';
|
||||||
|
import { GlobalStatsResponseDto } from '../dto/response/planting-stats.response';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 认种统计控制器
|
||||||
|
* 提供全局统计数据 API,数据直接从订单表聚合查询
|
||||||
|
*/
|
||||||
|
@ApiTags('认种统计')
|
||||||
|
@Controller('planting/stats')
|
||||||
|
export class PlantingStatsController {
|
||||||
|
constructor(
|
||||||
|
private readonly plantingService: PlantingApplicationService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取全局认种统计(公开接口,无需认证)
|
||||||
|
* 此接口可用于验证 reporting-service 的统计数据准确性
|
||||||
|
*/
|
||||||
|
@Get('global')
|
||||||
|
@ApiOperation({
|
||||||
|
summary: '获取全局认种统计',
|
||||||
|
description: '从订单表实时聚合统计数据,可用于验证其他服务的统计准确性',
|
||||||
|
})
|
||||||
|
@ApiResponse({
|
||||||
|
status: 200,
|
||||||
|
description: '全局统计数据',
|
||||||
|
type: GlobalStatsResponseDto,
|
||||||
|
})
|
||||||
|
async getGlobalStats(): Promise<{ code: number; message: string; data: GlobalStatsResponseDto }> {
|
||||||
|
const stats = await this.plantingService.getGlobalStats();
|
||||||
|
|
||||||
|
return {
|
||||||
|
code: 0,
|
||||||
|
message: 'success',
|
||||||
|
data: stats,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取全局认种统计(需要认证)
|
||||||
|
* 管理后台调用此接口
|
||||||
|
*/
|
||||||
|
@Get('admin/global')
|
||||||
|
@UseGuards(JwtAuthGuard)
|
||||||
|
@ApiBearerAuth()
|
||||||
|
@ApiOperation({
|
||||||
|
summary: '获取全局认种统计(管理员)',
|
||||||
|
description: '从订单表实时聚合统计数据,需要管理员权限',
|
||||||
|
})
|
||||||
|
@ApiResponse({
|
||||||
|
status: 200,
|
||||||
|
description: '全局统计数据',
|
||||||
|
type: GlobalStatsResponseDto,
|
||||||
|
})
|
||||||
|
async getAdminGlobalStats(): Promise<{ code: number; message: string; data: GlobalStatsResponseDto }> {
|
||||||
|
const stats = await this.plantingService.getGlobalStats();
|
||||||
|
|
||||||
|
return {
|
||||||
|
code: 0,
|
||||||
|
message: 'success',
|
||||||
|
data: stats,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,58 @@
|
||||||
|
import { ApiProperty } from '@nestjs/swagger';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 状态分布 DTO
|
||||||
|
*/
|
||||||
|
export class StatusDistributionDto {
|
||||||
|
@ApiProperty({ description: '已支付状态的认种数', example: 100 })
|
||||||
|
paid: number;
|
||||||
|
|
||||||
|
@ApiProperty({ description: '资金已分配状态的认种数', example: 80 })
|
||||||
|
fundAllocated: number;
|
||||||
|
|
||||||
|
@ApiProperty({ description: '底池已排期状态的认种数', example: 50 })
|
||||||
|
poolScheduled: number;
|
||||||
|
|
||||||
|
@ApiProperty({ description: '底池已注入状态的认种数', example: 30 })
|
||||||
|
poolInjected: number;
|
||||||
|
|
||||||
|
@ApiProperty({ description: '挖矿已开启状态的认种数(有效认种)', example: 500 })
|
||||||
|
miningEnabled: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 今日统计 DTO
|
||||||
|
*/
|
||||||
|
export class TodayStatsDto {
|
||||||
|
@ApiProperty({ description: '今日认种棵数', example: 10 })
|
||||||
|
treeCount: number;
|
||||||
|
|
||||||
|
@ApiProperty({ description: '今日订单数', example: 5 })
|
||||||
|
orderCount: number;
|
||||||
|
|
||||||
|
@ApiProperty({ description: '今日认种金额', example: '1000.00' })
|
||||||
|
amount: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 全局统计响应 DTO
|
||||||
|
*/
|
||||||
|
export class GlobalStatsResponseDto {
|
||||||
|
@ApiProperty({ description: '总认种棵数(PAID及之后状态)', example: 760 })
|
||||||
|
totalTreeCount: number;
|
||||||
|
|
||||||
|
@ApiProperty({ description: '总订单数', example: 150 })
|
||||||
|
totalOrderCount: number;
|
||||||
|
|
||||||
|
@ApiProperty({ description: '总认种金额', example: '76000.00' })
|
||||||
|
totalAmount: string;
|
||||||
|
|
||||||
|
@ApiProperty({ description: '按状态分布的认种数', type: StatusDistributionDto })
|
||||||
|
statusDistribution: StatusDistributionDto;
|
||||||
|
|
||||||
|
@ApiProperty({ description: '今日统计', type: TodayStatsDto })
|
||||||
|
todayStats: TodayStatsDto;
|
||||||
|
|
||||||
|
@ApiProperty({ description: '统计计算时间', example: '2026-01-04T12:00:00.000Z' })
|
||||||
|
calculatedAt: string;
|
||||||
|
}
|
||||||
|
|
@ -509,4 +509,53 @@ export class PlantingApplicationService {
|
||||||
|
|
||||||
return events;
|
return events;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取全局认种统计数据
|
||||||
|
* 直接从订单表聚合查询,确保数据可靠性
|
||||||
|
*/
|
||||||
|
async getGlobalStats(): Promise<GlobalStatsResult> {
|
||||||
|
this.logger.log('Getting global planting stats from database');
|
||||||
|
|
||||||
|
const [globalStats, todayStats, statusDistribution] = await Promise.all([
|
||||||
|
this.orderRepository.getGlobalStats(),
|
||||||
|
this.orderRepository.getTodayStats(),
|
||||||
|
this.orderRepository.getStatusDistribution(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
totalTreeCount: globalStats.totalTreeCount,
|
||||||
|
totalOrderCount: globalStats.totalOrderCount,
|
||||||
|
totalAmount: globalStats.totalAmount,
|
||||||
|
statusDistribution,
|
||||||
|
todayStats,
|
||||||
|
calculatedAt: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 全局统计结果 */
|
||||||
|
export interface GlobalStatsResult {
|
||||||
|
/** 总认种棵数(PAID及之后状态) */
|
||||||
|
totalTreeCount: number;
|
||||||
|
/** 总订单数 */
|
||||||
|
totalOrderCount: number;
|
||||||
|
/** 总金额 */
|
||||||
|
totalAmount: string;
|
||||||
|
/** 按状态分布的认种数 */
|
||||||
|
statusDistribution: {
|
||||||
|
paid: number;
|
||||||
|
fundAllocated: number;
|
||||||
|
poolScheduled: number;
|
||||||
|
poolInjected: number;
|
||||||
|
miningEnabled: number;
|
||||||
|
};
|
||||||
|
/** 今日统计 */
|
||||||
|
todayStats: {
|
||||||
|
treeCount: number;
|
||||||
|
orderCount: number;
|
||||||
|
amount: string;
|
||||||
|
};
|
||||||
|
/** 统计时间 */
|
||||||
|
calculatedAt: string;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,44 @@ export interface IPlantingOrderRepository {
|
||||||
* 用于用户完成 KYC 后补创建合同
|
* 用于用户完成 KYC 后补创建合同
|
||||||
*/
|
*/
|
||||||
findPaidOrdersWithoutContract(userId: bigint): Promise<PlantingOrder[]>;
|
findPaidOrdersWithoutContract(userId: bigint): Promise<PlantingOrder[]>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取全局统计数据(从订单表实时聚合)
|
||||||
|
*/
|
||||||
|
getGlobalStats(): Promise<GlobalPlantingStats>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取今日统计数据
|
||||||
|
*/
|
||||||
|
getTodayStats(): Promise<DailyPlantingStats>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 按状态获取订单分布统计
|
||||||
|
*/
|
||||||
|
getStatusDistribution(): Promise<StatusDistribution>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 全局认种统计 */
|
||||||
|
export interface GlobalPlantingStats {
|
||||||
|
totalTreeCount: number;
|
||||||
|
totalOrderCount: number;
|
||||||
|
totalAmount: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 每日认种统计 */
|
||||||
|
export interface DailyPlantingStats {
|
||||||
|
treeCount: number;
|
||||||
|
orderCount: number;
|
||||||
|
amount: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 订单状态分布 */
|
||||||
|
export interface StatusDistribution {
|
||||||
|
paid: number;
|
||||||
|
fundAllocated: number;
|
||||||
|
poolScheduled: number;
|
||||||
|
poolInjected: number;
|
||||||
|
miningEnabled: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const PLANTING_ORDER_REPOSITORY = Symbol('IPlantingOrderRepository');
|
export const PLANTING_ORDER_REPOSITORY = Symbol('IPlantingOrderRepository');
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,12 @@
|
||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import { Prisma } from '@prisma/client';
|
import { Prisma } from '@prisma/client';
|
||||||
import { PrismaService } from '../prisma/prisma.service';
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
import { IPlantingOrderRepository } from '../../../domain/repositories/planting-order.repository.interface';
|
import {
|
||||||
|
IPlantingOrderRepository,
|
||||||
|
GlobalPlantingStats,
|
||||||
|
DailyPlantingStats,
|
||||||
|
StatusDistribution,
|
||||||
|
} from '../../../domain/repositories/planting-order.repository.interface';
|
||||||
import { PlantingOrder } from '../../../domain/aggregates/planting-order.aggregate';
|
import { PlantingOrder } from '../../../domain/aggregates/planting-order.aggregate';
|
||||||
import { PlantingOrderStatus } from '../../../domain/value-objects/planting-order-status.enum';
|
import { PlantingOrderStatus } from '../../../domain/value-objects/planting-order-status.enum';
|
||||||
import { PlantingOrderMapper } from '../mappers/planting-order.mapper';
|
import { PlantingOrderMapper } from '../mappers/planting-order.mapper';
|
||||||
|
|
@ -224,4 +229,133 @@ export class PlantingOrderRepositoryImpl implements IPlantingOrderRepository {
|
||||||
|
|
||||||
return orders.map(PlantingOrderMapper.toDomain);
|
return orders.map(PlantingOrderMapper.toDomain);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取全局统计数据(从订单表实时聚合)
|
||||||
|
* 只统计已支付(PAID)及之后状态的订单
|
||||||
|
*/
|
||||||
|
async getGlobalStats(): Promise<GlobalPlantingStats> {
|
||||||
|
const paidStatuses = [
|
||||||
|
PlantingOrderStatus.PAID,
|
||||||
|
PlantingOrderStatus.FUND_ALLOCATED,
|
||||||
|
PlantingOrderStatus.POOL_SCHEDULED,
|
||||||
|
PlantingOrderStatus.POOL_INJECTED,
|
||||||
|
PlantingOrderStatus.MINING_ENABLED,
|
||||||
|
];
|
||||||
|
|
||||||
|
const result = await this.prisma.plantingOrder.aggregate({
|
||||||
|
where: {
|
||||||
|
status: { in: paidStatuses },
|
||||||
|
},
|
||||||
|
_sum: {
|
||||||
|
treeCount: true,
|
||||||
|
totalAmount: true,
|
||||||
|
},
|
||||||
|
_count: {
|
||||||
|
id: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
totalTreeCount: result._sum.treeCount || 0,
|
||||||
|
totalOrderCount: result._count.id || 0,
|
||||||
|
totalAmount: result._sum.totalAmount?.toString() || '0',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取今日统计数据
|
||||||
|
*/
|
||||||
|
async getTodayStats(): Promise<DailyPlantingStats> {
|
||||||
|
const today = new Date();
|
||||||
|
today.setHours(0, 0, 0, 0);
|
||||||
|
|
||||||
|
const tomorrow = new Date(today);
|
||||||
|
tomorrow.setDate(tomorrow.getDate() + 1);
|
||||||
|
|
||||||
|
const paidStatuses = [
|
||||||
|
PlantingOrderStatus.PAID,
|
||||||
|
PlantingOrderStatus.FUND_ALLOCATED,
|
||||||
|
PlantingOrderStatus.POOL_SCHEDULED,
|
||||||
|
PlantingOrderStatus.POOL_INJECTED,
|
||||||
|
PlantingOrderStatus.MINING_ENABLED,
|
||||||
|
];
|
||||||
|
|
||||||
|
const result = await this.prisma.plantingOrder.aggregate({
|
||||||
|
where: {
|
||||||
|
status: { in: paidStatuses },
|
||||||
|
paidAt: {
|
||||||
|
gte: today,
|
||||||
|
lt: tomorrow,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
_sum: {
|
||||||
|
treeCount: true,
|
||||||
|
totalAmount: true,
|
||||||
|
},
|
||||||
|
_count: {
|
||||||
|
id: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
treeCount: result._sum.treeCount || 0,
|
||||||
|
orderCount: result._count.id || 0,
|
||||||
|
amount: result._sum.totalAmount?.toString() || '0',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 按状态获取订单分布统计
|
||||||
|
*/
|
||||||
|
async getStatusDistribution(): Promise<StatusDistribution> {
|
||||||
|
const statusCounts = await this.prisma.plantingOrder.groupBy({
|
||||||
|
by: ['status'],
|
||||||
|
_sum: {
|
||||||
|
treeCount: true,
|
||||||
|
},
|
||||||
|
where: {
|
||||||
|
status: {
|
||||||
|
in: [
|
||||||
|
PlantingOrderStatus.PAID,
|
||||||
|
PlantingOrderStatus.FUND_ALLOCATED,
|
||||||
|
PlantingOrderStatus.POOL_SCHEDULED,
|
||||||
|
PlantingOrderStatus.POOL_INJECTED,
|
||||||
|
PlantingOrderStatus.MINING_ENABLED,
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const distribution: StatusDistribution = {
|
||||||
|
paid: 0,
|
||||||
|
fundAllocated: 0,
|
||||||
|
poolScheduled: 0,
|
||||||
|
poolInjected: 0,
|
||||||
|
miningEnabled: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const item of statusCounts) {
|
||||||
|
const count = item._sum.treeCount || 0;
|
||||||
|
switch (item.status) {
|
||||||
|
case PlantingOrderStatus.PAID:
|
||||||
|
distribution.paid = count;
|
||||||
|
break;
|
||||||
|
case PlantingOrderStatus.FUND_ALLOCATED:
|
||||||
|
distribution.fundAllocated = count;
|
||||||
|
break;
|
||||||
|
case PlantingOrderStatus.POOL_SCHEDULED:
|
||||||
|
distribution.poolScheduled = count;
|
||||||
|
break;
|
||||||
|
case PlantingOrderStatus.POOL_INJECTED:
|
||||||
|
distribution.poolInjected = count;
|
||||||
|
break;
|
||||||
|
case PlantingOrderStatus.MINING_ENABLED:
|
||||||
|
distribution.miningEnabled = count;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return distribution;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue