From 37a5610d7451a7ed1d5efd154e673d01386bd70d Mon Sep 17 00:00:00 2001 From: hailin Date: Thu, 26 Feb 2026 20:21:31 -0800 Subject: [PATCH] =?UTF-8?q?feat(admin):=20=E5=AE=9E=E7=8E=B0=E9=A2=84?= =?UTF-8?q?=E7=A7=8D=E7=AE=A1=E7=90=86=E9=A1=B5=E9=9D=A2=E5=AE=8C=E6=95=B4?= =?UTF-8?q?API=E7=AB=AF=E7=82=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../services/admin-service/src/app.module.ts | 4 + .../pre-planting-config.controller.ts | 85 +++++++- .../pre-planting-proxy.service.ts | 121 ++++++++++ .../internal-pre-planting.controller.ts | 206 ++++++++++++++++++ 4 files changed, 415 insertions(+), 1 deletion(-) create mode 100644 backend/services/admin-service/src/pre-planting/pre-planting-proxy.service.ts diff --git a/backend/services/admin-service/src/app.module.ts b/backend/services/admin-service/src/app.module.ts index d1704899..9b2cd443 100644 --- a/backend/services/admin-service/src/app.module.ts +++ b/backend/services/admin-service/src/app.module.ts @@ -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, diff --git a/backend/services/admin-service/src/pre-planting/pre-planting-config.controller.ts b/backend/services/admin-service/src/pre-planting/pre-planting-config.controller.ts index caf9da59..93fde51e 100644 --- a/backend/services/admin-service/src/pre-planting/pre-planting-config.controller.ts +++ b/backend/services/admin-service/src/pre-planting/pre-planting-config.controller.ts @@ -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(); + } } /** diff --git a/backend/services/admin-service/src/pre-planting/pre-planting-proxy.service.ts b/backend/services/admin-service/src/pre-planting/pre-planting-proxy.service.ts new file mode 100644 index 00000000..8e7105b6 --- /dev/null +++ b/backend/services/admin-service/src/pre-planting/pre-planting-proxy.service.ts @@ -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( + '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, + }; + } + } +} diff --git a/backend/services/planting-service/src/pre-planting/api/controllers/internal-pre-planting.controller.ts b/backend/services/planting-service/src/pre-planting/api/controllers/internal-pre-planting.controller.ts index 36a0738f..588398d5 100644 --- a/backend/services/planting-service/src/pre-planting/api/controllers/internal-pre-planting.controller.ts +++ b/backend/services/planting-service/src/pre-planting/api/controllers/internal-pre-planting.controller.ts @@ -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, + }; + } }