feat(admin): 实现预种管理页面完整API端点

planting-service: InternalPrePlantingController 新增4个管理员查询端点
- GET /internal/pre-planting/admin/orders (分页订单列表)
- GET /internal/pre-planting/admin/positions (分页持仓列表)
- GET /internal/pre-planting/admin/merges (分页合并记录)
- GET /internal/pre-planting/admin/stats (统计汇总)

admin-service: HTTP代理层新增5个端点
- PUT config/toggle (开关切换)
- GET orders/positions/merges/stats (代理转发到planting-service)
- 新建 PrePlantingProxyService (复用ContractService的axios代理模式)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
hailin 2026-02-26 20:21:31 -08:00
parent 63a169abb0
commit 37a5610d74
4 changed files with 415 additions and 1 deletions

View File

@ -87,6 +87,8 @@ import { ContractService } from './application/services/contract.service';
// [2026-02-17] 新增:预种计划开关管理
import { PrePlantingConfigController, PublicPrePlantingConfigController } from './pre-planting/pre-planting-config.controller';
import { PrePlantingConfigService } from './pre-planting/pre-planting-config.service';
// [2026-02-27] 新增预种计划数据代理admin-service → planting-service 内部 HTTP
import { PrePlantingProxyService } from './pre-planting/pre-planting-proxy.service';
// [2026-02-26] 新增:认种树定价配置(总部运营成本压力涨价)
import { AdminTreePricingController, PublicTreePricingController } from './pricing/tree-pricing.controller';
import { TreePricingService } from './pricing/tree-pricing.service';
@ -231,6 +233,8 @@ import { AutoPriceIncreaseJob } from './infrastructure/jobs/auto-price-increase.
ContractService,
// [2026-02-17] 新增:预种计划开关管理
PrePlantingConfigService,
// [2026-02-27] 新增:预种计划数据代理
PrePlantingProxyService,
// [2026-02-26] 新增:认种树定价配置(总部运营成本压力涨价)
TreePricingService,
AutoPriceIncreaseJob,

View File

@ -2,23 +2,31 @@ import {
Controller,
Get,
Post,
Put,
Body,
Query,
HttpCode,
HttpStatus,
} from '@nestjs/common';
import { ApiTags, ApiOperation, ApiResponse } from '@nestjs/swagger';
import { ApiTags, ApiOperation, ApiResponse, ApiQuery } from '@nestjs/swagger';
import { PrePlantingConfigService } from './pre-planting-config.service';
import { PrePlantingProxyService } from './pre-planting-proxy.service';
class UpdatePrePlantingConfigDto {
isActive: boolean;
updatedBy?: string;
}
class TogglePrePlantingConfigDto {
isActive: boolean;
}
@ApiTags('预种计划配置')
@Controller('admin/pre-planting')
export class PrePlantingConfigController {
constructor(
private readonly configService: PrePlantingConfigService,
private readonly proxyService: PrePlantingProxyService,
) {}
@Get('config')
@ -35,6 +43,81 @@ export class PrePlantingConfigController {
async updateConfig(@Body() dto: UpdatePrePlantingConfigDto) {
return this.configService.updateConfig(dto.isActive, dto.updatedBy);
}
// ============================================
// [2026-02-27] 新增预种管理端点toggle + 数据查询代理)
// ============================================
@Put('config/toggle')
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: '切换预种计划开关' })
@ApiResponse({ status: HttpStatus.OK, description: '切换成功' })
async toggleConfig(@Body() dto: TogglePrePlantingConfigDto) {
return this.configService.updateConfig(dto.isActive);
}
@Get('orders')
@ApiOperation({ summary: '预种订单列表(管理员视角)' })
@ApiQuery({ name: 'page', required: false })
@ApiQuery({ name: 'pageSize', required: false })
@ApiQuery({ name: 'keyword', required: false })
@ApiQuery({ name: 'status', required: false })
async getOrders(
@Query('page') page?: string,
@Query('pageSize') pageSize?: string,
@Query('keyword') keyword?: string,
@Query('status') status?: string,
) {
return this.proxyService.getOrders({
page: page ? parseInt(page, 10) : undefined,
pageSize: pageSize ? parseInt(pageSize, 10) : undefined,
keyword: keyword || undefined,
status: status || undefined,
});
}
@Get('positions')
@ApiOperation({ summary: '预种持仓列表(管理员视角)' })
@ApiQuery({ name: 'page', required: false })
@ApiQuery({ name: 'pageSize', required: false })
@ApiQuery({ name: 'keyword', required: false })
async getPositions(
@Query('page') page?: string,
@Query('pageSize') pageSize?: string,
@Query('keyword') keyword?: string,
) {
return this.proxyService.getPositions({
page: page ? parseInt(page, 10) : undefined,
pageSize: pageSize ? parseInt(pageSize, 10) : undefined,
keyword: keyword || undefined,
});
}
@Get('merges')
@ApiOperation({ summary: '预种合并记录列表(管理员视角)' })
@ApiQuery({ name: 'page', required: false })
@ApiQuery({ name: 'pageSize', required: false })
@ApiQuery({ name: 'keyword', required: false })
@ApiQuery({ name: 'status', required: false })
async getMerges(
@Query('page') page?: string,
@Query('pageSize') pageSize?: string,
@Query('keyword') keyword?: string,
@Query('status') status?: string,
) {
return this.proxyService.getMerges({
page: page ? parseInt(page, 10) : undefined,
pageSize: pageSize ? parseInt(pageSize, 10) : undefined,
keyword: keyword || undefined,
status: status || undefined,
});
}
@Get('stats')
@ApiOperation({ summary: '预种统计汇总' })
async getStats() {
return this.proxyService.getStats();
}
}
/**

View File

@ -0,0 +1,121 @@
/**
*
* [2026-02-27] HTTP planting-service
*
* === ===
* admin-web admin-service () planting-service /internal/pre-planting/admin/*
* ContractService axios
*/
import { Injectable, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import axios, { AxiosInstance } from 'axios';
@Injectable()
export class PrePlantingProxyService {
private readonly logger = new Logger(PrePlantingProxyService.name);
private readonly httpClient: AxiosInstance;
constructor(private readonly configService: ConfigService) {
const plantingServiceUrl = this.configService.get<string>(
'PLANTING_SERVICE_URL',
'http://rwa-planting-service:3003',
);
this.httpClient = axios.create({
baseURL: plantingServiceUrl,
timeout: 30000,
});
this.logger.log(
`PrePlantingProxyService initialized, planting-service URL: ${plantingServiceUrl}`,
);
}
async getOrders(params: {
page?: number;
pageSize?: number;
keyword?: string;
status?: string;
}) {
try {
const qp = new URLSearchParams();
if (params.page) qp.append('page', params.page.toString());
if (params.pageSize) qp.append('pageSize', params.pageSize.toString());
if (params.keyword) qp.append('keyword', params.keyword);
if (params.status) qp.append('status', params.status);
const url = `/api/v1/internal/pre-planting/admin/orders?${qp.toString()}`;
this.logger.debug(`[getOrders] 请求: ${url}`);
const response = await this.httpClient.get(url);
return response.data;
} catch (error) {
this.logger.error(`[getOrders] 失败: ${error.message}`);
return { items: [], total: 0, page: params.page ?? 1, pageSize: params.pageSize ?? 20 };
}
}
async getPositions(params: {
page?: number;
pageSize?: number;
keyword?: string;
}) {
try {
const qp = new URLSearchParams();
if (params.page) qp.append('page', params.page.toString());
if (params.pageSize) qp.append('pageSize', params.pageSize.toString());
if (params.keyword) qp.append('keyword', params.keyword);
const url = `/api/v1/internal/pre-planting/admin/positions?${qp.toString()}`;
this.logger.debug(`[getPositions] 请求: ${url}`);
const response = await this.httpClient.get(url);
return response.data;
} catch (error) {
this.logger.error(`[getPositions] 失败: ${error.message}`);
return { items: [], total: 0, page: params.page ?? 1, pageSize: params.pageSize ?? 20 };
}
}
async getMerges(params: {
page?: number;
pageSize?: number;
keyword?: string;
status?: string;
}) {
try {
const qp = new URLSearchParams();
if (params.page) qp.append('page', params.page.toString());
if (params.pageSize) qp.append('pageSize', params.pageSize.toString());
if (params.keyword) qp.append('keyword', params.keyword);
if (params.status) qp.append('status', params.status);
const url = `/api/v1/internal/pre-planting/admin/merges?${qp.toString()}`;
this.logger.debug(`[getMerges] 请求: ${url}`);
const response = await this.httpClient.get(url);
return response.data;
} catch (error) {
this.logger.error(`[getMerges] 失败: ${error.message}`);
return { items: [], total: 0, page: params.page ?? 1, pageSize: params.pageSize ?? 20 };
}
}
async getStats() {
try {
const url = '/api/v1/internal/pre-planting/admin/stats';
this.logger.debug(`[getStats] 请求: ${url}`);
const response = await this.httpClient.get(url);
return response.data;
} catch (error) {
this.logger.error(`[getStats] 失败: ${error.message}`);
return {
totalOrders: 0,
totalPortions: 0,
totalAmount: 0,
totalMerges: 0,
totalTreesMerged: 0,
totalUsers: 0,
pendingContracts: 0,
};
}
}
}

View File

@ -2,21 +2,28 @@ import {
Controller,
Get,
Param,
Query,
HttpStatus,
Logger,
} from '@nestjs/common';
import {
ApiTags,
ApiOperation,
ApiResponse,
ApiParam,
ApiQuery,
} from '@nestjs/swagger';
import { PrePlantingApplicationService } from '../../application/services/pre-planting-application.service';
import { PrismaService } from '../../../infrastructure/persistence/prisma/prisma.service';
@ApiTags('预种计划-内部API')
@Controller('internal/pre-planting')
export class InternalPrePlantingController {
private readonly logger = new Logger(InternalPrePlantingController.name);
constructor(
private readonly prePlantingService: PrePlantingApplicationService,
private readonly prisma: PrismaService,
) {}
@Get('eligibility/:accountSequence')
@ -28,4 +35,203 @@ export class InternalPrePlantingController {
) {
return this.prePlantingService.getEligibility(accountSequence);
}
// ============================================
// [2026-02-27] 新增:管理员查询端点(供 admin-service 内部调用)
// ============================================
@Get('admin/orders')
@ApiOperation({ summary: '预种订单列表(管理员视角)' })
@ApiQuery({ name: 'page', required: false })
@ApiQuery({ name: 'pageSize', required: false })
@ApiQuery({ name: 'keyword', required: false })
@ApiQuery({ name: 'status', required: false })
async getAdminOrders(
@Query('page') page?: string,
@Query('pageSize') pageSize?: string,
@Query('keyword') keyword?: string,
@Query('status') status?: string,
) {
const p = Math.max(1, parseInt(page || '1', 10));
const ps = Math.min(100, Math.max(1, parseInt(pageSize || '20', 10)));
const where: any = {};
if (status) where.status = status;
if (keyword) {
where.OR = [
{ orderNo: { contains: keyword, mode: 'insensitive' } },
{ accountSequence: { contains: keyword, mode: 'insensitive' } },
];
}
const [items, total] = await Promise.all([
this.prisma.prePlantingOrder.findMany({
where,
orderBy: { createdAt: 'desc' },
skip: (p - 1) * ps,
take: ps,
}),
this.prisma.prePlantingOrder.count({ where }),
]);
return {
items: items.map((o) => ({
id: o.id.toString(),
orderNo: o.orderNo,
userId: o.userId.toString(),
accountSequence: o.accountSequence,
portionCount: o.portionCount,
pricePerPortion: Number(o.pricePerPortion),
totalAmount: Number(o.totalAmount),
provinceCode: o.provinceCode,
cityCode: o.cityCode,
status: o.status,
mergedToMergeId: o.mergedToMergeId?.toString() ?? null,
createdAt: o.createdAt.toISOString(),
paidAt: o.paidAt?.toISOString() ?? null,
mergedAt: o.mergedAt?.toISOString() ?? null,
})),
total,
page: p,
pageSize: ps,
};
}
@Get('admin/positions')
@ApiOperation({ summary: '预种持仓列表(管理员视角)' })
@ApiQuery({ name: 'page', required: false })
@ApiQuery({ name: 'pageSize', required: false })
@ApiQuery({ name: 'keyword', required: false })
async getAdminPositions(
@Query('page') page?: string,
@Query('pageSize') pageSize?: string,
@Query('keyword') keyword?: string,
) {
const p = Math.max(1, parseInt(page || '1', 10));
const ps = Math.min(100, Math.max(1, parseInt(pageSize || '20', 10)));
const where: any = {};
if (keyword) {
where.accountSequence = { contains: keyword, mode: 'insensitive' };
}
const [items, total] = await Promise.all([
this.prisma.prePlantingPosition.findMany({
where,
orderBy: { updatedAt: 'desc' },
skip: (p - 1) * ps,
take: ps,
}),
this.prisma.prePlantingPosition.count({ where }),
]);
return {
items: items.map((pos) => ({
id: pos.id.toString(),
userId: pos.userId.toString(),
accountSequence: pos.accountSequence,
totalPortions: pos.totalPortions,
availablePortions: pos.availablePortions,
mergedPortions: pos.mergedPortions,
totalTreesMerged: pos.totalTreesMerged,
provinceCode: pos.provinceCode ?? null,
cityCode: pos.cityCode ?? null,
firstPurchaseAt: pos.firstPurchaseAt?.toISOString() ?? null,
updatedAt: pos.updatedAt.toISOString(),
})),
total,
page: p,
pageSize: ps,
};
}
@Get('admin/merges')
@ApiOperation({ summary: '预种合并记录列表(管理员视角)' })
@ApiQuery({ name: 'page', required: false })
@ApiQuery({ name: 'pageSize', required: false })
@ApiQuery({ name: 'keyword', required: false })
@ApiQuery({ name: 'status', required: false })
async getAdminMerges(
@Query('page') page?: string,
@Query('pageSize') pageSize?: string,
@Query('keyword') keyword?: string,
@Query('status') status?: string,
) {
const p = Math.max(1, parseInt(page || '1', 10));
const ps = Math.min(100, Math.max(1, parseInt(pageSize || '20', 10)));
const where: any = {};
if (status) where.contractStatus = status;
if (keyword) {
where.OR = [
{ mergeNo: { contains: keyword, mode: 'insensitive' } },
{ accountSequence: { contains: keyword, mode: 'insensitive' } },
];
}
const [items, total] = await Promise.all([
this.prisma.prePlantingMerge.findMany({
where,
orderBy: { mergedAt: 'desc' },
skip: (p - 1) * ps,
take: ps,
}),
this.prisma.prePlantingMerge.count({ where }),
]);
return {
items: items.map((m) => ({
id: m.id.toString(),
mergeNo: m.mergeNo,
userId: m.userId.toString(),
accountSequence: m.accountSequence,
sourceOrderNos: m.sourceOrderNos as string[],
treeCount: m.treeCount,
contractStatus: m.contractStatus,
contractSignedAt: m.contractSignedAt?.toISOString() ?? null,
miningEnabledAt: m.miningEnabledAt?.toISOString() ?? null,
selectedProvince: m.provinceCode ?? null,
selectedCity: m.cityCode ?? null,
mergedAt: m.mergedAt.toISOString(),
})),
total,
page: p,
pageSize: ps,
};
}
@Get('admin/stats')
@ApiOperation({ summary: '预种统计汇总(管理员视角)' })
async getAdminStats() {
const [
totalOrders,
portionsAgg,
amountAgg,
totalMerges,
treesMergedAgg,
totalUsers,
pendingContracts,
] = await Promise.all([
this.prisma.prePlantingOrder.count(),
this.prisma.prePlantingOrder.aggregate({ _sum: { portionCount: true } }),
this.prisma.prePlantingOrder.aggregate({
_sum: { totalAmount: true },
where: { status: { in: ['PAID', 'MERGED'] } },
}),
this.prisma.prePlantingMerge.count(),
this.prisma.prePlantingMerge.aggregate({ _sum: { treeCount: true } }),
this.prisma.prePlantingPosition.count(),
this.prisma.prePlantingMerge.count({ where: { contractStatus: 'PENDING' } }),
]);
return {
totalOrders,
totalPortions: portionsAgg._sum.portionCount ?? 0,
totalAmount: Number(amountAgg._sum.totalAmount ?? 0),
totalMerges,
totalTreesMerged: treesMergedAgg._sum.treeCount ?? 0,
totalUsers,
pendingContracts,
};
}
}