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:
hailin 2026-01-04 06:55:08 -08:00
parent 8148f7a52a
commit 46b68e8652
7 changed files with 355 additions and 1 deletions

View File

@ -1,6 +1,7 @@
import { Module } from '@nestjs/common';
import { PlantingOrderController } from './controllers/planting-order.controller';
import { PlantingPositionController } from './controllers/planting-position.controller';
import { PlantingStatsController } from './controllers/planting-stats.controller';
import { HealthController } from './controllers/health.controller';
import {
ContractSigningController,
@ -14,6 +15,7 @@ import { JwtAuthGuard } from './guards/jwt-auth.guard';
controllers: [
PlantingOrderController,
PlantingPositionController,
PlantingStatsController,
HealthController,
ContractSigningController,
ContractSigningConfigController,

View File

@ -1,4 +1,5 @@
export * from './planting-order.controller';
export * from './planting-position.controller';
export * from './planting-stats.controller';
export * from './health.controller';
export * from './contract-signing.controller';

View File

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

View File

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

View File

@ -509,4 +509,53 @@ export class PlantingApplicationService {
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;
}

View File

@ -24,6 +24,44 @@ export interface IPlantingOrderRepository {
* KYC
*/
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');

View File

@ -1,7 +1,12 @@
import { Injectable } from '@nestjs/common';
import { Prisma } from '@prisma/client';
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 { PlantingOrderStatus } from '../../../domain/value-objects/planting-order-status.enum';
import { PlantingOrderMapper } from '../mappers/planting-order.mapper';
@ -224,4 +229,133 @@ export class PlantingOrderRepositoryImpl implements IPlantingOrderRepository {
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;
}
}