feat(contracts): 合同管理功能 - 查询/下载/批量打包

新增功能:
- 合同列表查询(支持省市、状态、时间筛选)
- 单个合同 PDF 下载(支持断点续传)
- 批量下载 ZIP 打包(异步任务处理)
- 增量下载(基于上次下载时间)
- 用户详情页合同 Tab

后端:
- planting-service: 内部合同查询 API
- admin-service: 合同管理控制器、服务、批量下载 Job
- 新增 contract_batch_download_tasks 表

前端:
- 新增独立合同管理页面 /contracts
- 用户详情页新增合同信息 Tab
- 侧边栏新增合同管理入口

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
hailin 2026-02-05 22:50:17 -08:00
parent 5bacd21840
commit 86461a052d
15 changed files with 2282 additions and 14 deletions

View File

@ -41,6 +41,8 @@
"@prisma/client": "^5.7.0",
"@types/multer": "^2.0.0",
"adbkit-apkreader": "^3.2.0",
"archiver": "^6.0.1",
"axios": "^1.6.2",
"bplist-parser": "^0.3.2",
"class-transformer": "^0.5.1",
"class-validator": "^0.14.0",
@ -56,6 +58,7 @@
"@nestjs/cli": "^10.0.0",
"@nestjs/schematics": "^10.0.0",
"@nestjs/testing": "^10.0.0",
"@types/archiver": "^6.0.2",
"@types/express": "^4.17.17",
"@types/jest": "^29.5.2",
"@types/node": "^20.3.1",

View File

@ -0,0 +1,40 @@
-- 合同批量下载任务表
-- [2026-02-05] 新增:用于记录和追踪合同批量下载任务
-- CreateEnum
CREATE TYPE "BatchDownloadStatus" AS ENUM ('PENDING', 'PROCESSING', 'COMPLETED', 'FAILED', 'CANCELLED');
-- CreateTable
CREATE TABLE "contract_batch_download_tasks" (
"id" BIGSERIAL NOT NULL,
"task_no" VARCHAR(50) NOT NULL,
"status" "BatchDownloadStatus" NOT NULL DEFAULT 'PENDING',
"total_contracts" INTEGER NOT NULL DEFAULT 0,
"downloaded_count" INTEGER NOT NULL DEFAULT 0,
"failed_count" INTEGER NOT NULL DEFAULT 0,
"progress" INTEGER NOT NULL DEFAULT 0,
"last_processed_order_no" VARCHAR(50),
"result_file_url" VARCHAR(500),
"result_file_size" BIGINT,
"errors" JSONB,
"operator_id" VARCHAR(50) NOT NULL,
"filters" JSONB,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"started_at" TIMESTAMP(3),
"completed_at" TIMESTAMP(3),
"expires_at" TIMESTAMP(3),
CONSTRAINT "contract_batch_download_tasks_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "contract_batch_download_tasks_task_no_key" ON "contract_batch_download_tasks"("task_no");
-- CreateIndex
CREATE INDEX "contract_batch_download_tasks_status_idx" ON "contract_batch_download_tasks"("status");
-- CreateIndex
CREATE INDEX "contract_batch_download_tasks_operator_id_idx" ON "contract_batch_download_tasks"("operator_id");
-- CreateIndex
CREATE INDEX "contract_batch_download_tasks_created_at_idx" ON "contract_batch_download_tasks"("created_at");

View File

@ -1178,6 +1178,61 @@ model AppAsset {
@@map("app_assets")
}
// =============================================================================
// Contract Batch Download Tasks (合同批量下载任务)
// [2026-02-05] 新增:支持管理后台合同批量下载
// 回滚方式:删除此 model 并运行 prisma migrate
// =============================================================================
/// 批量下载任务状态
enum BatchDownloadStatus {
PENDING // 待处理
PROCESSING // 处理中
COMPLETED // 已完成
FAILED // 失败
CANCELLED // 已取消
}
/// 合同批量下载任务 - 记录批量下载请求的执行状态
model ContractBatchDownloadTask {
id BigInt @id @default(autoincrement())
taskNo String @unique @map("task_no") @db.VarChar(50)
status BatchDownloadStatus @default(PENDING)
// 下载统计
totalContracts Int @default(0) @map("total_contracts")
downloadedCount Int @default(0) @map("downloaded_count")
failedCount Int @default(0) @map("failed_count")
progress Int @default(0) // 0-100
// 断点续传支持
lastProcessedOrderNo String? @map("last_processed_order_no") @db.VarChar(50)
// 结果文件
resultFileUrl String? @map("result_file_url") @db.VarChar(500)
resultFileSize BigInt? @map("result_file_size")
// 错误信息
errors Json? // 失败的合同列表
// 操作者
operatorId String @map("operator_id") @db.VarChar(50)
// 筛选条件
filters Json? // { signedAfter, signedBefore, provinceCode, cityCode }
// 时间戳
createdAt DateTime @default(now()) @map("created_at")
startedAt DateTime? @map("started_at")
completedAt DateTime? @map("completed_at")
expiresAt DateTime? @map("expires_at") // 结果文件过期时间
@@index([status])
@@index([operatorId])
@@index([createdAt])
@@map("contract_batch_download_tasks")
}
// =============================================================================
// Customer Service Contacts (客服联系方式)
// =============================================================================

View File

@ -0,0 +1,309 @@
/**
*
* [2026-02-05]
* app.module.ts
*/
import {
Controller,
Get,
Post,
Param,
Query,
Body,
Res,
Req,
NotFoundException,
BadRequestException,
Logger,
StreamableFile,
} from '@nestjs/common';
import { ApiTags, ApiOperation, ApiQuery, ApiResponse, ApiParam, ApiBody } from '@nestjs/swagger';
import { Request, Response } from 'express';
import { PrismaService } from '../../infrastructure/persistence/prisma/prisma.service';
import { ContractService, ContractQueryParams } from '../../application/services/contract.service';
/**
* DTO
*/
interface BatchDownloadRequestDto {
filters?: {
signedAfter?: string;
signedBefore?: string;
provinceCode?: string;
cityCode?: string;
};
orderNos?: string[];
}
/**
*
*
*/
@ApiTags('Admin - Contracts')
@Controller('v1/admin/contracts')
export class ContractController {
private readonly logger = new Logger(ContractController.name);
constructor(
private readonly prisma: PrismaService,
private readonly contractService: ContractService,
) {}
/**
*
*/
@Get()
@ApiOperation({ summary: '获取合同列表' })
@ApiQuery({ name: 'signedAfter', required: false, description: '签署时间起始ISO格式' })
@ApiQuery({ name: 'signedBefore', required: false, description: '签署时间结束ISO格式' })
@ApiQuery({ name: 'provinceCode', required: false, description: '省份代码' })
@ApiQuery({ name: 'cityCode', required: false, description: '城市代码' })
@ApiQuery({ name: 'status', required: false, description: '合同状态' })
@ApiQuery({ name: 'page', required: false, description: '页码默认1' })
@ApiQuery({ name: 'pageSize', required: false, description: '每页条数默认50' })
@ApiQuery({ name: 'orderBy', required: false, description: '排序字段signedAt/createdAt' })
@ApiQuery({ name: 'orderDir', required: false, description: '排序方向asc/desc' })
@ApiResponse({ status: 200, description: '合同列表' })
async getContracts(
@Query('signedAfter') signedAfter?: string,
@Query('signedBefore') signedBefore?: string,
@Query('provinceCode') provinceCode?: string,
@Query('cityCode') cityCode?: string,
@Query('status') status?: string,
@Query('page') page?: string,
@Query('pageSize') pageSize?: string,
@Query('orderBy') orderBy?: string,
@Query('orderDir') orderDir?: string,
) {
this.logger.log(`========== GET /v1/admin/contracts 请求 ==========`);
const params: ContractQueryParams = {
signedAfter,
signedBefore,
provinceCode,
cityCode,
status,
page: page ? parseInt(page, 10) : undefined,
pageSize: pageSize ? parseInt(pageSize, 10) : undefined,
orderBy: orderBy as 'signedAt' | 'createdAt',
orderDir: orderDir as 'asc' | 'desc',
};
return this.contractService.getContracts(params);
}
/**
*
*/
@Get('statistics')
@ApiOperation({ summary: '获取合同统计信息' })
@ApiQuery({ name: 'provinceCode', required: false, description: '省份代码' })
@ApiQuery({ name: 'cityCode', required: false, description: '城市代码' })
@ApiResponse({ status: 200, description: '合同统计' })
async getStatistics(
@Query('provinceCode') provinceCode?: string,
@Query('cityCode') cityCode?: string,
) {
this.logger.log(`========== GET /v1/admin/contracts/statistics 请求 ==========`);
return this.contractService.getStatistics({ provinceCode, cityCode });
}
/**
*
*/
@Post('batch-download')
@ApiOperation({ summary: '创建合同批量下载任务' })
@ApiBody({ description: '筛选条件' })
@ApiResponse({ status: 201, description: '任务创建成功' })
async createBatchDownload(
@Body() body: BatchDownloadRequestDto,
@Req() req: Request,
) {
this.logger.log(`========== POST /v1/admin/contracts/batch-download 请求 ==========`);
this.logger.log(`筛选条件: ${JSON.stringify(body.filters)}`);
// 生成任务号
const taskNo = `BD${Date.now()}`;
// 获取操作者 ID从请求头或默认值
const operatorId = (req.headers['x-operator-id'] as string) || 'system';
// 创建任务记录
const task = await this.prisma.contractBatchDownloadTask.create({
data: {
taskNo,
operatorId,
filters: body.filters ? JSON.parse(JSON.stringify(body.filters)) : null,
status: 'PENDING',
expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000), // 7天后过期
},
});
// TODO: 触发异步任务处理(后续实现)
// 可以使用 Bull Queue 或 Kafka 消息
this.logger.log(`批量下载任务创建成功: ${taskNo}`);
return {
success: true,
taskId: task.id.toString(),
taskNo: task.taskNo,
status: task.status,
createdAt: task.createdAt.toISOString(),
};
}
/**
*
*/
@Get('batch-download/:taskNo')
@ApiOperation({ summary: '查询批量下载任务状态' })
@ApiParam({ name: 'taskNo', description: '任务号' })
@ApiResponse({ status: 200, description: '任务状态' })
@ApiResponse({ status: 404, description: '任务不存在' })
async getBatchDownloadStatus(@Param('taskNo') taskNo: string) {
this.logger.log(`========== GET /v1/admin/contracts/batch-download/${taskNo} 请求 ==========`);
const task = await this.prisma.contractBatchDownloadTask.findUnique({
where: { taskNo },
});
if (!task) {
throw new NotFoundException(`任务不存在: ${taskNo}`);
}
return {
taskId: task.id.toString(),
taskNo: task.taskNo,
status: task.status,
totalContracts: task.totalContracts,
downloadedCount: task.downloadedCount,
failedCount: task.failedCount,
progress: task.progress,
resultFileUrl: task.resultFileUrl,
resultFileSize: task.resultFileSize?.toString(),
errors: task.errors,
createdAt: task.createdAt.toISOString(),
startedAt: task.startedAt?.toISOString(),
completedAt: task.completedAt?.toISOString(),
expiresAt: task.expiresAt?.toISOString(),
};
}
/**
*
*/
@Get('users/:accountSequence')
@ApiOperation({ summary: '获取用户的合同列表' })
@ApiParam({ name: 'accountSequence', description: '用户账户序列号' })
@ApiQuery({ name: 'page', required: false, description: '页码' })
@ApiQuery({ name: 'pageSize', required: false, description: '每页条数' })
@ApiResponse({ status: 200, description: '合同列表' })
async getUserContracts(
@Param('accountSequence') accountSequence: string,
@Query('page') page?: string,
@Query('pageSize') pageSize?: string,
) {
this.logger.log(`========== GET /v1/admin/contracts/users/${accountSequence} 请求 ==========`);
return this.contractService.getUserContracts(accountSequence, {
page: page ? parseInt(page, 10) : undefined,
pageSize: pageSize ? parseInt(pageSize, 10) : undefined,
});
}
/**
*
*/
@Get(':orderNo')
@ApiOperation({ summary: '获取合同详情' })
@ApiParam({ name: 'orderNo', description: '订单号' })
@ApiResponse({ status: 200, description: '合同详情' })
@ApiResponse({ status: 404, description: '合同不存在' })
async getContract(@Param('orderNo') orderNo: string) {
this.logger.log(`========== GET /v1/admin/contracts/${orderNo} 请求 ==========`);
const contract = await this.contractService.getContract(orderNo);
if (!contract) {
throw new NotFoundException(`合同不存在: ${orderNo}`);
}
return contract;
}
/**
* PDF
*/
@Get(':orderNo/download')
@ApiOperation({ summary: '下载合同 PDF支持断点续传' })
@ApiParam({ name: 'orderNo', description: '订单号' })
@ApiResponse({ status: 200, description: 'PDF 文件' })
@ApiResponse({ status: 206, description: '部分内容(断点续传)' })
@ApiResponse({ status: 404, description: '合同不存在' })
async downloadContract(
@Param('orderNo') orderNo: string,
@Req() req: Request,
@Res() res: Response,
): Promise<void> {
this.logger.log(`========== GET /v1/admin/contracts/${orderNo}/download 请求 ==========`);
// 获取合同详情
const contract = await this.contractService.getContract(orderNo);
if (!contract) {
throw new NotFoundException(`合同不存在: ${orderNo}`);
}
if (!contract.signedPdfUrl) {
throw new NotFoundException(`合同PDF不存在: ${orderNo},状态: ${contract.status}`);
}
// 下载 PDF
const pdfBuffer = await this.contractService.downloadContractPdf(orderNo);
const fileSize = pdfBuffer.length;
// 生成文件名
const safeRealName = contract.userRealName?.replace(/[\/\\:*?"<>|]/g, '_') || '未知';
const fileName = `${contract.contractNo}_${safeRealName}_${contract.treeCount}棵_${contract.provinceName}${contract.cityName}.pdf`;
const encodedFileName = encodeURIComponent(fileName);
// 检查 Range 请求头
const range = req.headers.range;
// 设置通用响应头
res.setHeader('Accept-Ranges', 'bytes');
res.setHeader('Content-Type', 'application/pdf');
res.setHeader('Content-Disposition', `attachment; filename*=UTF-8''${encodedFileName}`);
res.setHeader('Cache-Control', 'public, max-age=86400');
if (range) {
// 断点续传
const parts = range.replace(/bytes=/, '').split('-');
const start = parseInt(parts[0], 10);
const end = parts[1] ? parseInt(parts[1], 10) : fileSize - 1;
if (start >= fileSize || end >= fileSize || start > end) {
res.status(416);
res.setHeader('Content-Range', `bytes */${fileSize}`);
res.end();
return;
}
const chunkSize = end - start + 1;
const chunk = pdfBuffer.slice(start, end + 1);
this.logger.log(`Range 请求: ${fileName}, bytes ${start}-${end}/${fileSize}`);
res.status(206);
res.setHeader('Content-Range', `bytes ${start}-${end}/${fileSize}`);
res.setHeader('Content-Length', chunkSize);
res.end(chunk);
} else {
// 完整文件
this.logger.log(`完整下载: ${fileName}, size=${fileSize}`);
res.setHeader('Content-Length', fileSize);
res.end(pdfBuffer);
}
}
}

View File

@ -61,6 +61,7 @@ import { UserTagController } from './api/controllers/user-tag.controller';
import { ClassificationRuleController } from './api/controllers/classification-rule.controller';
import { AudienceSegmentController } from './api/controllers/audience-segment.controller';
import { AutoTagSyncJob } from './infrastructure/jobs/auto-tag-sync.job';
import { ContractBatchDownloadJob } from './infrastructure/jobs/contract-batch-download.job';
// Co-Managed Wallet imports
import { CoManagedWalletController } from './api/controllers/co-managed-wallet.controller';
import { CoManagedWalletService } from './application/services/co-managed-wallet.service';
@ -80,6 +81,9 @@ import { MaintenanceInterceptor } from './api/interceptors/maintenance.intercept
import { AdminAppAssetController, PublicAppAssetController } from './api/controllers/app-asset.controller'
// Customer Service Contact imports
import { AdminCustomerServiceContactController, PublicCustomerServiceContactController } from './api/controllers/customer-service-contact.controller';
// [2026-02-05] 新增:合同管理模块
import { ContractController } from './api/controllers/contract.controller';
import { ContractService } from './application/services/contract.service';
@Module({
imports: [
@ -121,6 +125,8 @@ import { AdminCustomerServiceContactController, PublicCustomerServiceContactCont
// Customer Service Contact Controllers
AdminCustomerServiceContactController,
PublicCustomerServiceContactController,
// [2026-02-05] 新增:合同管理控制器
ContractController,
],
providers: [
PrismaService,
@ -186,6 +192,7 @@ import { AdminCustomerServiceContactController, PublicCustomerServiceContactCont
AudienceSegmentService,
// Scheduled Jobs
AutoTagSyncJob,
ContractBatchDownloadJob,
// Co-Managed Wallet
CoManagedWalletMapper,
CoManagedWalletService,
@ -207,6 +214,8 @@ import { AdminCustomerServiceContactController, PublicCustomerServiceContactCont
provide: APP_INTERCEPTOR,
useClass: MaintenanceInterceptor,
},
// [2026-02-05] 新增:合同管理服务
ContractService,
],
})
export class AppModule {}

View File

@ -0,0 +1,203 @@
/**
*
* [2026-02-05] planting-service API
* app.module.ts
*/
import { Injectable, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import axios, { AxiosInstance } from 'axios';
/**
* DTO
*/
export interface ContractDto {
orderNo: string;
contractNo: string;
userId: string;
accountSequence: string;
userRealName: string | null;
userPhoneNumber: string | null;
treeCount: number;
totalAmount: number;
provinceCode: string;
provinceName: string;
cityCode: string;
cityName: string;
status: string;
signedAt: string | null;
signedPdfUrl: string | null;
createdAt: string;
}
/**
*
*/
export interface ContractsListResponse {
items: ContractDto[];
total: number;
page: number;
pageSize: number;
totalPages: number;
}
/**
*
*/
export interface ContractStatisticsResponse {
totalContracts: number;
signedContracts: number;
pendingContracts: number;
expiredContracts: number;
}
/**
*
*/
export interface ContractQueryParams {
accountSequences?: string[];
signedAfter?: string;
signedBefore?: string;
provinceCode?: string;
cityCode?: string;
status?: string;
page?: number;
pageSize?: number;
orderBy?: 'signedAt' | 'createdAt';
orderDir?: 'asc' | 'desc';
}
@Injectable()
export class ContractService {
private readonly logger = new Logger(ContractService.name);
private readonly httpClient: AxiosInstance;
private readonly plantingServiceUrl: string;
constructor(private readonly configService: ConfigService) {
this.plantingServiceUrl = this.configService.get<string>(
'PLANTING_SERVICE_URL',
'http://rwa-planting-service:3002',
);
this.httpClient = axios.create({
baseURL: this.plantingServiceUrl,
timeout: 30000,
});
this.logger.log(`ContractService initialized, planting-service URL: ${this.plantingServiceUrl}`);
}
/**
*
*/
async getContracts(params: ContractQueryParams): Promise<ContractsListResponse> {
try {
const queryParams = new URLSearchParams();
if (params.accountSequences?.length) {
queryParams.append('accountSequences', params.accountSequences.join(','));
}
if (params.signedAfter) queryParams.append('signedAfter', params.signedAfter);
if (params.signedBefore) queryParams.append('signedBefore', params.signedBefore);
if (params.provinceCode) queryParams.append('provinceCode', params.provinceCode);
if (params.cityCode) queryParams.append('cityCode', params.cityCode);
if (params.status) queryParams.append('status', params.status);
if (params.page) queryParams.append('page', params.page.toString());
if (params.pageSize) queryParams.append('pageSize', params.pageSize.toString());
if (params.orderBy) queryParams.append('orderBy', params.orderBy);
if (params.orderDir) queryParams.append('orderDir', params.orderDir);
const url = `/planting/internal/contracts?${queryParams.toString()}`;
this.logger.debug(`[getContracts] 请求: ${url}`);
const response = await this.httpClient.get<ContractsListResponse>(url);
return response.data;
} catch (error) {
this.logger.error(`[getContracts] 失败: ${error.message}`);
return {
items: [],
total: 0,
page: params.page ?? 1,
pageSize: params.pageSize ?? 50,
totalPages: 0,
};
}
}
/**
*
*/
async getUserContracts(accountSequence: string, params?: {
page?: number;
pageSize?: number;
}): Promise<ContractsListResponse> {
return this.getContracts({
accountSequences: [accountSequence],
page: params?.page,
pageSize: params?.pageSize,
status: undefined, // 查询所有状态
});
}
/**
*
*/
async getContract(orderNo: string): Promise<ContractDto | null> {
try {
const url = `/planting/internal/contracts/${orderNo}`;
this.logger.debug(`[getContract] 请求: ${url}`);
const response = await this.httpClient.get<ContractDto>(url);
return response.data;
} catch (error) {
if (error.response?.status === 404) {
return null;
}
this.logger.error(`[getContract] 失败: ${error.message}`);
throw error;
}
}
/**
* PDF
* @returns PDF Buffer
*/
async downloadContractPdf(orderNo: string): Promise<Buffer> {
const url = `/planting/internal/contracts/${orderNo}/pdf`;
this.logger.debug(`[downloadContractPdf] 请求: ${url}`);
const response = await this.httpClient.get(url, {
responseType: 'arraybuffer',
});
return Buffer.from(response.data);
}
/**
*
*/
async getStatistics(params?: {
provinceCode?: string;
cityCode?: string;
}): Promise<ContractStatisticsResponse> {
try {
const queryParams = new URLSearchParams();
if (params?.provinceCode) queryParams.append('provinceCode', params.provinceCode);
if (params?.cityCode) queryParams.append('cityCode', params.cityCode);
const url = `/planting/internal/contracts/statistics?${queryParams.toString()}`;
this.logger.debug(`[getStatistics] 请求: ${url}`);
const response = await this.httpClient.get<ContractStatisticsResponse>(url);
return response.data;
} catch (error) {
this.logger.error(`[getStatistics] 失败: ${error.message}`);
return {
totalContracts: 0,
signedContracts: 0,
pendingContracts: 0,
expiredContracts: 0,
};
}
}
}

View File

@ -0,0 +1,343 @@
/**
*
* [2026-02-05]
* app.module.ts
*/
import { Injectable, Logger, OnModuleInit } from '@nestjs/common';
import { Cron } from '@nestjs/schedule';
import { ConfigService } from '@nestjs/config';
import * as fs from 'fs/promises';
import * as path from 'path';
import * as archiver from 'archiver';
import { createWriteStream, existsSync, mkdirSync } from 'fs';
import { PrismaService } from '../persistence/prisma/prisma.service';
import { ContractService, ContractDto } from '../../application/services/contract.service';
/**
*
*/
interface BatchDownloadFilters {
signedAfter?: string;
signedBefore?: string;
provinceCode?: string;
cityCode?: string;
}
/**
* Job
*
*/
@Injectable()
export class ContractBatchDownloadJob implements OnModuleInit {
private readonly logger = new Logger(ContractBatchDownloadJob.name);
private isRunning = false;
private readonly downloadDir: string;
private readonly baseUrl: string;
constructor(
private readonly prisma: PrismaService,
private readonly contractService: ContractService,
private readonly configService: ConfigService,
) {
this.downloadDir = this.configService.get<string>('UPLOAD_DIR') || './uploads';
this.baseUrl = this.configService.get<string>('BASE_URL') || 'http://localhost:3005';
}
onModuleInit() {
this.logger.log('ContractBatchDownloadJob initialized');
// 确保下载目录存在
const contractsDir = path.join(this.downloadDir, 'contracts');
if (!existsSync(contractsDir)) {
mkdirSync(contractsDir, { recursive: true });
this.logger.log(`Created contracts download directory: ${contractsDir}`);
}
}
/**
*
*/
@Cron('0 * * * * *') // 每分钟的第0秒
async processPendingTasks(): Promise<void> {
if (this.isRunning) {
this.logger.debug('Batch download job is already running, skipping...');
return;
}
this.isRunning = true;
try {
// 查找待处理的任务
const pendingTask = await this.prisma.contractBatchDownloadTask.findFirst({
where: { status: 'PENDING' },
orderBy: { createdAt: 'asc' },
});
if (!pendingTask) {
return;
}
this.logger.log(`开始处理批量下载任务: ${pendingTask.taskNo}`);
// 更新状态为处理中
await this.prisma.contractBatchDownloadTask.update({
where: { id: pendingTask.id },
data: {
status: 'PROCESSING',
startedAt: new Date(),
},
});
try {
await this.processTask(pendingTask.id, pendingTask.taskNo, pendingTask.filters as BatchDownloadFilters);
} catch (error) {
this.logger.error(`任务处理失败: ${pendingTask.taskNo}`, error);
await this.prisma.contractBatchDownloadTask.update({
where: { id: pendingTask.id },
data: {
status: 'FAILED',
errors: { message: error.message, stack: error.stack },
completedAt: new Date(),
},
});
}
} catch (error) {
this.logger.error('批量下载任务检查失败', error);
} finally {
this.isRunning = false;
}
}
/**
*
*/
private async processTask(
taskId: bigint,
taskNo: string,
filters: BatchDownloadFilters | null,
): Promise<void> {
const errors: Array<{ orderNo: string; error: string }> = [];
let downloadedCount = 0;
let failedCount = 0;
// 1. 获取符合条件的合同列表(只获取已签署的)
this.logger.log(`获取合同列表, 筛选条件: ${JSON.stringify(filters)}`);
const contractsResult = await this.contractService.getContracts({
signedAfter: filters?.signedAfter,
signedBefore: filters?.signedBefore,
provinceCode: filters?.provinceCode,
cityCode: filters?.cityCode,
status: 'SIGNED',
pageSize: 10000, // 最大获取1万份
orderBy: 'signedAt',
orderDir: 'asc',
});
const contracts = contractsResult.items;
const totalContracts = contracts.length;
this.logger.log(`共找到 ${totalContracts} 份已签署合同`);
if (totalContracts === 0) {
// 没有合同需要下载
await this.prisma.contractBatchDownloadTask.update({
where: { id: taskId },
data: {
status: 'COMPLETED',
totalContracts: 0,
downloadedCount: 0,
failedCount: 0,
progress: 100,
completedAt: new Date(),
},
});
return;
}
// 更新总数
await this.prisma.contractBatchDownloadTask.update({
where: { id: taskId },
data: { totalContracts },
});
// 2. 创建临时目录
const tempDir = path.join(this.downloadDir, 'temp', taskNo);
if (!existsSync(tempDir)) {
mkdirSync(tempDir, { recursive: true });
}
// 3. 逐个下载合同 PDF
for (let i = 0; i < contracts.length; i++) {
const contract = contracts[i];
try {
// 下载 PDF
const pdfBuffer = await this.contractService.downloadContractPdf(contract.orderNo);
// 生成文件路径(按省市分组)
const safeProvince = this.sanitizeFileName(contract.provinceName || '未知省份');
const safeCity = this.sanitizeFileName(contract.cityName || '未知城市');
const subDir = path.join(tempDir, safeProvince, safeCity);
if (!existsSync(subDir)) {
mkdirSync(subDir, { recursive: true });
}
// 生成文件名
const safeRealName = this.sanitizeFileName(contract.userRealName || '未知');
const fileName = `${contract.contractNo}_${safeRealName}_${contract.treeCount}棵.pdf`;
const filePath = path.join(subDir, fileName);
// 保存文件
await fs.writeFile(filePath, pdfBuffer);
downloadedCount++;
this.logger.debug(`下载成功: ${contract.orderNo} -> ${fileName}`);
} catch (error) {
failedCount++;
errors.push({ orderNo: contract.orderNo, error: error.message });
this.logger.warn(`下载失败: ${contract.orderNo} - ${error.message}`);
}
// 更新进度
const progress = Math.floor(((i + 1) / totalContracts) * 100);
if (progress % 10 === 0 || i === totalContracts - 1) {
await this.prisma.contractBatchDownloadTask.update({
where: { id: taskId },
data: {
downloadedCount,
failedCount,
progress,
lastProcessedOrderNo: contract.orderNo,
errors: errors.length > 0 ? errors : null,
},
});
this.logger.log(`进度: ${progress}% (${downloadedCount}/${totalContracts})`);
}
}
// 4. 打包成 ZIP
this.logger.log('开始打包 ZIP...');
const zipFileName = this.generateZipFileName(filters, downloadedCount);
const zipDir = path.join(this.downloadDir, 'contracts');
const zipPath = path.join(zipDir, zipFileName);
await this.createZipArchive(tempDir, zipPath);
// 获取 ZIP 文件大小
const zipStats = await fs.stat(zipPath);
const resultFileUrl = `${this.baseUrl}/uploads/contracts/${zipFileName}`;
this.logger.log(`ZIP 打包完成: ${zipFileName}, 大小: ${zipStats.size} bytes`);
// 5. 清理临时文件
await this.cleanupTempDir(tempDir);
// 6. 更新任务状态为完成
await this.prisma.contractBatchDownloadTask.update({
where: { id: taskId },
data: {
status: 'COMPLETED',
downloadedCount,
failedCount,
progress: 100,
resultFileUrl,
resultFileSize: BigInt(zipStats.size),
errors: errors.length > 0 ? errors : null,
completedAt: new Date(),
expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000), // 7天后过期
},
});
this.logger.log(`任务完成: ${taskNo}, 成功: ${downloadedCount}, 失败: ${failedCount}`);
}
/**
* ZIP
*/
private generateZipFileName(filters: BatchDownloadFilters | null, count: number): string {
const dateStr = new Date().toISOString().slice(0, 10).replace(/-/g, '');
let rangeStr = '';
if (filters?.signedAfter || filters?.signedBefore) {
const start = filters.signedAfter
? new Date(filters.signedAfter).toISOString().slice(0, 10).replace(/-/g, '')
: 'all';
const end = filters.signedBefore
? new Date(filters.signedBefore).toISOString().slice(0, 10).replace(/-/g, '')
: 'now';
rangeStr = `_${start}-${end}`;
}
return `contracts_${dateStr}${rangeStr}_${count}份.zip`;
}
/**
* ZIP
*/
private async createZipArchive(sourceDir: string, zipPath: string): Promise<void> {
return new Promise((resolve, reject) => {
const output = createWriteStream(zipPath);
const archive = archiver('zip', {
zlib: { level: 6 }, // 压缩级别
});
output.on('close', () => {
this.logger.log(`ZIP 文件大小: ${archive.pointer()} bytes`);
resolve();
});
archive.on('error', (err: Error) => {
reject(err);
});
archive.pipe(output);
// 添加目录下所有文件
archive.directory(sourceDir, false);
archive.finalize();
});
}
/**
*
*/
private async cleanupTempDir(tempDir: string): Promise<void> {
try {
await fs.rm(tempDir, { recursive: true, force: true });
this.logger.debug(`清理临时目录: ${tempDir}`);
} catch (error) {
this.logger.warn(`清理临时目录失败: ${tempDir}`, error);
}
}
/**
*
*/
private sanitizeFileName(name: string): string {
return name.replace(/[\/\\:*?"<>|]/g, '_').trim() || '未知';
}
/**
* API
*/
async triggerProcessing(): Promise<{ processed: boolean; taskNo?: string }> {
if (this.isRunning) {
return { processed: false };
}
await this.processPendingTasks();
return { processed: true };
}
/**
*
*/
getProcessingStatus(): { isRunning: boolean } {
return { isRunning: this.isRunning };
}
}

View File

@ -7,6 +7,8 @@ import {
ContractSigningController,
ContractSigningConfigController,
} from './controllers/contract-signing.controller';
// [2026-02-05] 新增:合同管理内部 API
import { ContractAdminController } from './controllers/contract-admin.controller';
import { ApplicationModule } from '../application/application.module';
import { JwtAuthGuard } from './guards/jwt-auth.guard';
@ -19,6 +21,8 @@ import { JwtAuthGuard } from './guards/jwt-auth.guard';
HealthController,
ContractSigningController,
ContractSigningConfigController,
// [2026-02-05] 新增:合同管理内部 API
ContractAdminController,
],
providers: [JwtAuthGuard],
})

View File

@ -0,0 +1,369 @@
/**
* API
* [2026-02-05]
* api.module.ts
*/
import {
Controller,
Get,
Param,
Query,
Logger,
Res,
StreamableFile,
NotFoundException,
} from '@nestjs/common';
import { Response } from 'express';
import { ApiTags, ApiOperation, ApiQuery, ApiResponse, ApiParam } from '@nestjs/swagger';
import { PrismaService } from '../../infrastructure/persistence/prisma/prisma.service';
import { MinioStorageService } from '../../infrastructure/storage/minio-storage.service';
import { ContractSigningStatus } from '../../domain/value-objects/contract-signing-status.enum';
/**
* DTO
*/
interface ContractDto {
orderNo: string;
contractNo: string;
userId: string;
accountSequence: string;
userRealName: string | null;
userPhoneNumber: string | null;
treeCount: number;
totalAmount: number;
provinceCode: string;
provinceName: string;
cityCode: string;
cityName: string;
status: string;
signedAt: string | null;
signedPdfUrl: string | null;
createdAt: string;
}
/**
*
*/
interface ContractsListResponse {
items: ContractDto[];
total: number;
page: number;
pageSize: number;
totalPages: number;
}
/**
*
*/
interface ContractStatisticsResponse {
totalContracts: number;
signedContracts: number;
pendingContracts: number;
expiredContracts: number;
}
/**
* API
* JWT
*/
@ApiTags('Internal - Contract Admin')
@Controller('planting/internal/contracts')
export class ContractAdminController {
private readonly logger = new Logger(ContractAdminController.name);
constructor(
private readonly prisma: PrismaService,
private readonly minioStorage: MinioStorageService,
) {}
/**
*
*/
@Get()
@ApiOperation({ summary: '查询合同列表(内部接口)' })
@ApiQuery({ name: 'accountSequences', required: false, description: '账户序列号列表(逗号分隔)' })
@ApiQuery({ name: 'signedAfter', required: false, description: '签署时间起始ISO格式' })
@ApiQuery({ name: 'signedBefore', required: false, description: '签署时间结束ISO格式' })
@ApiQuery({ name: 'provinceCode', required: false, description: '省份代码' })
@ApiQuery({ name: 'cityCode', required: false, description: '城市代码' })
@ApiQuery({ name: 'status', required: false, description: '合同状态' })
@ApiQuery({ name: 'page', required: false, description: '页码默认1' })
@ApiQuery({ name: 'pageSize', required: false, description: '每页条数默认50' })
@ApiQuery({ name: 'orderBy', required: false, description: '排序字段signedAt/createdAt默认signedAt' })
@ApiQuery({ name: 'orderDir', required: false, description: '排序方向asc/desc默认desc' })
@ApiResponse({ status: 200, description: '合同列表' })
async getContracts(
@Query('accountSequences') accountSequences?: string,
@Query('signedAfter') signedAfter?: string,
@Query('signedBefore') signedBefore?: string,
@Query('provinceCode') provinceCode?: string,
@Query('cityCode') cityCode?: string,
@Query('status') status?: string,
@Query('page') page?: string,
@Query('pageSize') pageSize?: string,
@Query('orderBy') orderBy?: string,
@Query('orderDir') orderDir?: string,
): Promise<ContractsListResponse> {
this.logger.log(`========== internal/contracts 查询请求 ==========`);
const pageNum = page ? parseInt(page, 10) : 1;
const pageSizeNum = pageSize ? parseInt(pageSize, 10) : 50;
const skip = (pageNum - 1) * pageSizeNum;
// 构建查询条件
const where: Record<string, unknown> = {};
// 账户序列号过滤
if (accountSequences) {
const sequences = accountSequences.split(',').map(s => s.trim());
where.accountSequence = { in: sequences };
}
// 签署时间过滤
if (signedAfter || signedBefore) {
where.signedAt = {};
if (signedAfter) {
(where.signedAt as Record<string, Date>).gte = new Date(signedAfter);
}
if (signedBefore) {
(where.signedAt as Record<string, Date>).lte = new Date(signedBefore);
}
}
// 省份过滤
if (provinceCode) {
where.provinceCode = provinceCode;
}
// 城市过滤
if (cityCode) {
where.cityCode = cityCode;
}
// 状态过滤
if (status) {
where.status = status;
} else {
// 默认只查询已签署的合同
where.status = ContractSigningStatus.SIGNED;
}
// 排序
const sortField = orderBy === 'createdAt' ? 'createdAt' : 'signedAt';
const sortDir = orderDir === 'asc' ? 'asc' : 'desc';
this.logger.log(`查询条件: ${JSON.stringify(where)}`);
// 查询总数
const total = await this.prisma.contractSigningTask.count({ where });
// 查询列表
const tasks = await this.prisma.contractSigningTask.findMany({
where,
skip,
take: pageSizeNum,
orderBy: { [sortField]: sortDir },
select: {
orderNo: true,
contractNo: true,
userId: true,
accountSequence: true,
userRealName: true,
userPhoneNumber: true,
treeCount: true,
totalAmount: true,
provinceCode: true,
provinceName: true,
cityCode: true,
cityName: true,
status: true,
signedAt: true,
signedPdfUrl: true,
createdAt: true,
},
});
const items: ContractDto[] = tasks.map(task => ({
orderNo: task.orderNo,
contractNo: task.contractNo,
userId: task.userId.toString(),
accountSequence: task.accountSequence,
userRealName: task.userRealName,
userPhoneNumber: task.userPhoneNumber,
treeCount: task.treeCount,
totalAmount: Number(task.totalAmount),
provinceCode: task.provinceCode,
provinceName: task.provinceName,
cityCode: task.cityCode,
cityName: task.cityName,
status: task.status,
signedAt: task.signedAt?.toISOString() ?? null,
signedPdfUrl: task.signedPdfUrl,
createdAt: task.createdAt.toISOString(),
}));
this.logger.log(`查询结果: total=${total}, page=${pageNum}, pageSize=${pageSizeNum}`);
return {
items,
total,
page: pageNum,
pageSize: pageSizeNum,
totalPages: Math.ceil(total / pageSizeNum),
};
}
/**
*
*/
@Get('statistics')
@ApiOperation({ summary: '获取合同统计信息(内部接口)' })
@ApiQuery({ name: 'provinceCode', required: false, description: '省份代码' })
@ApiQuery({ name: 'cityCode', required: false, description: '城市代码' })
@ApiResponse({ status: 200, description: '合同统计' })
async getStatistics(
@Query('provinceCode') provinceCode?: string,
@Query('cityCode') cityCode?: string,
): Promise<ContractStatisticsResponse> {
this.logger.log(`========== internal/contracts/statistics 请求 ==========`);
const where: Record<string, unknown> = {};
if (provinceCode) where.provinceCode = provinceCode;
if (cityCode) where.cityCode = cityCode;
// 并行查询各状态数量
const [totalContracts, signedContracts, pendingContracts, expiredContracts] = await Promise.all([
this.prisma.contractSigningTask.count({ where }),
this.prisma.contractSigningTask.count({ where: { ...where, status: ContractSigningStatus.SIGNED } }),
this.prisma.contractSigningTask.count({
where: {
...where,
status: { in: [ContractSigningStatus.PENDING, ContractSigningStatus.SCROLLED, ContractSigningStatus.ACKNOWLEDGED] },
},
}),
this.prisma.contractSigningTask.count({ where: { ...where, status: ContractSigningStatus.EXPIRED } }),
]);
return {
totalContracts,
signedContracts,
pendingContracts,
expiredContracts,
};
}
/**
*
*/
@Get(':orderNo')
@ApiOperation({ summary: '获取合同详情(内部接口)' })
@ApiParam({ name: 'orderNo', description: '订单号' })
@ApiResponse({ status: 200, description: '合同详情' })
@ApiResponse({ status: 404, description: '合同不存在' })
async getContract(@Param('orderNo') orderNo: string): Promise<ContractDto> {
this.logger.log(`========== internal/contracts/${orderNo} 请求 ==========`);
const task = await this.prisma.contractSigningTask.findUnique({
where: { orderNo },
select: {
orderNo: true,
contractNo: true,
userId: true,
accountSequence: true,
userRealName: true,
userPhoneNumber: true,
treeCount: true,
totalAmount: true,
provinceCode: true,
provinceName: true,
cityCode: true,
cityName: true,
status: true,
signedAt: true,
signedPdfUrl: true,
createdAt: true,
},
});
if (!task) {
throw new NotFoundException(`合同不存在: ${orderNo}`);
}
return {
orderNo: task.orderNo,
contractNo: task.contractNo,
userId: task.userId.toString(),
accountSequence: task.accountSequence,
userRealName: task.userRealName,
userPhoneNumber: task.userPhoneNumber,
treeCount: task.treeCount,
totalAmount: Number(task.totalAmount),
provinceCode: task.provinceCode,
provinceName: task.provinceName,
cityCode: task.cityCode,
cityName: task.cityName,
status: task.status,
signedAt: task.signedAt?.toISOString() ?? null,
signedPdfUrl: task.signedPdfUrl,
createdAt: task.createdAt.toISOString(),
};
}
/**
* PDF
*/
@Get(':orderNo/pdf')
@ApiOperation({ summary: '下载合同 PDF内部接口' })
@ApiParam({ name: 'orderNo', description: '订单号' })
@ApiResponse({ status: 200, description: 'PDF 文件流' })
@ApiResponse({ status: 404, description: '合同或PDF不存在' })
async downloadPdf(
@Param('orderNo') orderNo: string,
@Res({ passthrough: true }) res: Response,
): Promise<StreamableFile> {
this.logger.log(`========== internal/contracts/${orderNo}/pdf 下载请求 ==========`);
// 查询合同任务
const task = await this.prisma.contractSigningTask.findUnique({
where: { orderNo },
select: {
contractNo: true,
userRealName: true,
treeCount: true,
provinceName: true,
cityName: true,
signedPdfUrl: true,
status: true,
},
});
if (!task) {
throw new NotFoundException(`合同不存在: ${orderNo}`);
}
if (!task.signedPdfUrl) {
throw new NotFoundException(`合同PDF不存在: ${orderNo},状态: ${task.status}`);
}
// 从 MinIO 下载 PDF
const pdfBuffer = await this.minioStorage.downloadSignedPdf(task.signedPdfUrl);
// 生成文件名
const safeRealName = task.userRealName?.replace(/[\/\\:*?"<>|]/g, '_') || '未知';
const fileName = `${task.contractNo}_${safeRealName}_${task.treeCount}棵_${task.provinceName}${task.cityName}.pdf`;
const encodedFileName = encodeURIComponent(fileName);
// 设置响应头
res.set({
'Content-Type': 'application/pdf',
'Content-Disposition': `attachment; filename*=UTF-8''${encodedFileName}`,
'Content-Length': pdfBuffer.length,
'Accept-Ranges': 'bytes',
});
this.logger.log(`下载合同 PDF: ${fileName}, size=${pdfBuffer.length}`);
return new StreamableFile(pdfBuffer);
}
}

View File

@ -0,0 +1,175 @@
/**
* 合同管理页面样式
* [2026-02-05] 新增
* 回滚方式删除此文件
*/
.contracts {
display: flex;
flex-direction: column;
gap: 1.5rem;
&__statsGrid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 1rem;
@media (max-width: 768px) {
grid-template-columns: repeat(2, 1fr);
}
}
&__statCard {
background: var(--bg-secondary, #f8f9fa);
border-radius: 8px;
padding: 1.25rem;
display: flex;
flex-direction: column;
gap: 0.5rem;
}
&__statLabel {
font-size: 0.875rem;
color: var(--text-secondary, #6c757d);
}
&__statValue {
font-size: 1.5rem;
font-weight: 600;
color: var(--text-primary, #212529);
}
&__filters {
background: var(--bg-secondary, #f8f9fa);
border-radius: 8px;
padding: 1rem;
display: flex;
flex-direction: column;
gap: 1rem;
}
&__filterRow {
display: flex;
flex-wrap: wrap;
gap: 1rem;
align-items: flex-end;
}
&__filterItem {
display: flex;
flex-direction: column;
gap: 0.25rem;
label {
font-size: 0.75rem;
color: var(--text-secondary, #6c757d);
}
select,
input {
padding: 0.5rem 0.75rem;
border: 1px solid var(--border-color, #dee2e6);
border-radius: 4px;
font-size: 0.875rem;
min-width: 150px;
&:focus {
outline: none;
border-color: var(--primary-color, #0d6efd);
}
}
}
&__actions {
display: flex;
gap: 0.75rem;
flex-wrap: wrap;
}
&__table {
background: var(--bg-primary, #fff);
border: 1px solid var(--border-color, #dee2e6);
border-radius: 8px;
overflow: hidden;
}
&__tableHeader {
display: grid;
grid-template-columns: 1fr 1fr 1.2fr 0.8fr 0.8fr 1fr 0.8fr 1fr 0.8fr;
gap: 0.5rem;
padding: 0.75rem 1rem;
background: var(--bg-secondary, #f8f9fa);
font-weight: 600;
font-size: 0.875rem;
color: var(--text-secondary, #6c757d);
border-bottom: 1px solid var(--border-color, #dee2e6);
}
&__tableRow {
display: grid;
grid-template-columns: 1fr 1fr 1.2fr 0.8fr 0.8fr 1fr 0.8fr 1fr 0.8fr;
gap: 0.5rem;
padding: 0.75rem 1rem;
font-size: 0.875rem;
border-bottom: 1px solid var(--border-color, #dee2e6);
transition: background 0.2s;
&:hover {
background: var(--bg-hover, #f8f9fa);
}
&:last-child {
border-bottom: none;
}
}
&__tableCell {
display: flex;
align-items: center;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
&__tableSubtext {
font-size: 0.75rem;
color: var(--text-secondary, #6c757d);
}
&__loading,
&__empty {
padding: 3rem;
text-align: center;
color: var(--text-secondary, #6c757d);
}
&__pagination {
display: flex;
justify-content: center;
align-items: center;
gap: 1rem;
padding: 1rem 0;
}
&__pageInfo {
font-size: 0.875rem;
color: var(--text-secondary, #6c757d);
}
// 状态颜色
&__statusGreen {
color: #16a34a !important;
}
&__statusYellow {
color: #ca8a04 !important;
}
&__statusRed {
color: #dc2626 !important;
}
&__statusGray {
color: #4b5563 !important;
}
}

View File

@ -0,0 +1,388 @@
'use client';
/**
*
* [2026-02-05]
*
*/
import { useState, useEffect, useCallback } from 'react';
import { Button, toast } from '@/components/common';
import { PageContainer } from '@/components/layout';
import { cn } from '@/utils/helpers';
import { formatNumber } from '@/utils/formatters';
import {
contractService,
CONTRACT_STATUS_LABELS,
type ContractsListResponse,
type ContractStatisticsResponse,
type ContractQueryParams,
} from '@/services/contractService';
import { PROVINCE_CODE_NAMES } from '@/types';
import styles from './contracts.module.scss';
// 获取合同状态对应的样式类
const getStatusStyleClass = (status: string): string => {
switch (status) {
case 'SIGNED':
return styles.contracts__statusGreen;
case 'PENDING':
case 'SCROLLED':
case 'ACKNOWLEDGED':
return styles.contracts__statusYellow;
case 'EXPIRED':
case 'TIMEOUT':
return styles.contracts__statusRed;
default:
return styles.contracts__statusGray;
}
};
// 省份列表(用于筛选)
const PROVINCE_OPTIONS = Object.entries(PROVINCE_CODE_NAMES).map(([code, name]) => ({
value: code,
label: name,
}));
/**
*
*/
export default function ContractsPage() {
// 筛选状态
const [filters, setFilters] = useState<ContractQueryParams>({
page: 1,
pageSize: 20,
provinceCode: '',
cityCode: '',
status: '',
signedAfter: '',
signedBefore: '',
orderBy: 'signedAt',
orderDir: 'desc',
});
// 数据状态
const [data, setData] = useState<ContractsListResponse | null>(null);
const [statistics, setStatistics] = useState<ContractStatisticsResponse | null>(null);
const [loading, setLoading] = useState(true);
const [statsLoading, setStatsLoading] = useState(true);
// 增量下载状态
const [lastDownloadTime, setLastDownloadTime] = useState<string | null>(() => {
if (typeof window !== 'undefined') {
return localStorage.getItem('contract_last_download_time');
}
return null;
});
// 加载合同列表
const loadContracts = useCallback(async () => {
setLoading(true);
try {
const result = await contractService.getContracts({
...filters,
provinceCode: filters.provinceCode || undefined,
cityCode: filters.cityCode || undefined,
status: filters.status || undefined,
signedAfter: filters.signedAfter || undefined,
signedBefore: filters.signedBefore || undefined,
});
setData(result);
} catch (error) {
console.error('获取合同列表失败:', error);
toast.error('获取合同列表失败');
} finally {
setLoading(false);
}
}, [filters]);
// 加载统计信息
const loadStatistics = useCallback(async () => {
setStatsLoading(true);
try {
const result = await contractService.getStatistics({
provinceCode: filters.provinceCode || undefined,
cityCode: filters.cityCode || undefined,
});
setStatistics(result);
} catch (error) {
console.error('获取统计信息失败:', error);
} finally {
setStatsLoading(false);
}
}, [filters.provinceCode, filters.cityCode]);
useEffect(() => {
loadContracts();
}, [loadContracts]);
useEffect(() => {
loadStatistics();
}, [loadStatistics]);
// 格式化日期
const formatDate = (dateStr: string | null) => {
if (!dateStr) return '-';
return new Date(dateStr).toLocaleString('zh-CN');
};
// 格式化金额
const formatAmount = (amount: number) => {
return amount.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
};
// 下载单个合同
const handleDownloadContract = (orderNo: string) => {
const downloadUrl = contractService.getDownloadUrl(orderNo);
window.open(downloadUrl, '_blank');
};
// 批量下载(创建下载任务)
const handleBatchDownload = async () => {
try {
const result = await contractService.createBatchDownload({
filters: {
signedAfter: filters.signedAfter || undefined,
signedBefore: filters.signedBefore || undefined,
provinceCode: filters.provinceCode || undefined,
cityCode: filters.cityCode || undefined,
},
});
toast.success(`批量下载任务已创建,任务号: ${result.taskNo}`);
// 保存当前时间作为最后下载时间
const now = new Date().toISOString();
localStorage.setItem('contract_last_download_time', now);
setLastDownloadTime(now);
} catch (error) {
console.error('创建批量下载任务失败:', error);
toast.error('创建批量下载任务失败');
}
};
// 增量下载(只下载上次之后签署的合同)
const handleIncrementalDownload = async () => {
if (!lastDownloadTime) {
toast.error('未找到上次下载记录,请先执行全量下载');
return;
}
try {
const result = await contractService.createBatchDownload({
filters: {
signedAfter: lastDownloadTime,
provinceCode: filters.provinceCode || undefined,
cityCode: filters.cityCode || undefined,
},
});
toast.success(`增量下载任务已创建,任务号: ${result.taskNo}`);
// 更新最后下载时间
const now = new Date().toISOString();
localStorage.setItem('contract_last_download_time', now);
setLastDownloadTime(now);
} catch (error) {
console.error('创建增量下载任务失败:', error);
toast.error('创建增量下载任务失败');
}
};
// 重置筛选
const handleResetFilters = () => {
setFilters({
page: 1,
pageSize: 20,
provinceCode: '',
cityCode: '',
status: '',
signedAfter: '',
signedBefore: '',
orderBy: 'signedAt',
orderDir: 'desc',
});
};
// 翻页
const handlePageChange = (newPage: number) => {
setFilters(prev => ({ ...prev, page: newPage }));
};
return (
<PageContainer title="合同管理">
<div className={styles.contracts}>
{/* 统计卡片 */}
<div className={styles.contracts__statsGrid}>
<div className={styles.contracts__statCard}>
<span className={styles.contracts__statLabel}></span>
<span className={styles.contracts__statValue}>
{statsLoading ? '...' : formatNumber(statistics?.totalContracts || 0)}
</span>
</div>
<div className={styles.contracts__statCard}>
<span className={styles.contracts__statLabel}></span>
<span className={cn(styles.contracts__statValue, styles.contracts__statusGreen)}>
{statsLoading ? '...' : formatNumber(statistics?.signedContracts || 0)}
</span>
</div>
<div className={styles.contracts__statCard}>
<span className={styles.contracts__statLabel}></span>
<span className={cn(styles.contracts__statValue, styles.contracts__statusYellow)}>
{statsLoading ? '...' : formatNumber(statistics?.pendingContracts || 0)}
</span>
</div>
<div className={styles.contracts__statCard}>
<span className={styles.contracts__statLabel}></span>
<span className={cn(styles.contracts__statValue, styles.contracts__statusRed)}>
{statsLoading ? '...' : formatNumber(statistics?.expiredContracts || 0)}
</span>
</div>
</div>
{/* 筛选栏 */}
<div className={styles.contracts__filters}>
<div className={styles.contracts__filterRow}>
<div className={styles.contracts__filterItem}>
<label>:</label>
<select
value={filters.provinceCode}
onChange={(e) => setFilters(prev => ({ ...prev, provinceCode: e.target.value, cityCode: '', page: 1 }))}
>
<option value=""></option>
{PROVINCE_OPTIONS.map(opt => (
<option key={opt.value} value={opt.value}>{opt.label}</option>
))}
</select>
</div>
<div className={styles.contracts__filterItem}>
<label>:</label>
<select
value={filters.status}
onChange={(e) => setFilters(prev => ({ ...prev, status: e.target.value, page: 1 }))}
>
<option value=""></option>
<option value="SIGNED"></option>
<option value="PENDING"></option>
<option value="EXPIRED"></option>
</select>
</div>
<div className={styles.contracts__filterItem}>
<label>:</label>
<input
type="date"
value={filters.signedAfter?.split('T')[0] || ''}
onChange={(e) => setFilters(prev => ({ ...prev, signedAfter: e.target.value ? `${e.target.value}T00:00:00.000Z` : '', page: 1 }))}
/>
</div>
<div className={styles.contracts__filterItem}>
<label>:</label>
<input
type="date"
value={filters.signedBefore?.split('T')[0] || ''}
onChange={(e) => setFilters(prev => ({ ...prev, signedBefore: e.target.value ? `${e.target.value}T23:59:59.999Z` : '', page: 1 }))}
/>
</div>
<Button variant="outline" size="sm" onClick={handleResetFilters}>
</Button>
</div>
<div className={styles.contracts__actions}>
<Button variant="primary" onClick={handleBatchDownload}>
ZIP
</Button>
{lastDownloadTime && (
<Button variant="outline" onClick={handleIncrementalDownload}>
: {new Date(lastDownloadTime).toLocaleString('zh-CN')}
</Button>
)}
</div>
</div>
{/* 合同列表 */}
<div className={styles.contracts__table}>
<div className={styles.contracts__tableHeader}>
<div className={styles.contracts__tableCell}></div>
<div className={styles.contracts__tableCell}></div>
<div className={styles.contracts__tableCell}></div>
<div className={styles.contracts__tableCell}></div>
<div className={styles.contracts__tableCell}></div>
<div className={styles.contracts__tableCell}></div>
<div className={styles.contracts__tableCell}></div>
<div className={styles.contracts__tableCell}></div>
<div className={styles.contracts__tableCell}></div>
</div>
{loading ? (
<div className={styles.contracts__loading}>...</div>
) : data && data.items.length > 0 ? (
data.items.map((contract) => (
<div key={contract.orderNo} className={styles.contracts__tableRow}>
<div className={styles.contracts__tableCell}>{contract.contractNo}</div>
<div className={styles.contracts__tableCell}>{contract.orderNo}</div>
<div className={styles.contracts__tableCell}>
<div>{contract.userRealName || '未实名'}</div>
<div className={styles.contracts__tableSubtext}>{contract.accountSequence}</div>
</div>
<div className={styles.contracts__tableCell}>{formatNumber(contract.treeCount)}</div>
<div className={styles.contracts__tableCell}>{formatAmount(contract.totalAmount)}</div>
<div className={styles.contracts__tableCell}>
{contract.provinceName} / {contract.cityName}
</div>
<div className={cn(styles.contracts__tableCell, getStatusStyleClass(contract.status))}>
{CONTRACT_STATUS_LABELS[contract.status] || contract.status}
</div>
<div className={styles.contracts__tableCell}>
{contract.signedAt ? formatDate(contract.signedAt) : '-'}
</div>
<div className={styles.contracts__tableCell}>
{contract.status === 'SIGNED' && contract.signedPdfUrl && (
<Button
variant="outline"
size="sm"
onClick={() => handleDownloadContract(contract.orderNo)}
>
</Button>
)}
</div>
</div>
))
) : (
<div className={styles.contracts__empty}></div>
)}
</div>
{/* 分页 */}
{data && data.totalPages > 1 && (
<div className={styles.contracts__pagination}>
<Button
variant="outline"
size="sm"
disabled={filters.page === 1}
onClick={() => handlePageChange(filters.page! - 1)}
>
</Button>
<span className={styles.contracts__pageInfo}>
{filters.page} / {data.totalPages} {data.total}
</span>
<Button
variant="outline"
size="sm"
disabled={filters.page === data.totalPages}
onClick={() => handlePageChange(filters.page! + 1)}
>
</Button>
</div>
)}
</div>
</PageContainer>
);
}

View File

@ -2,9 +2,8 @@
import { useState, useCallback, useEffect } from 'react';
import { useParams, useRouter } from 'next/navigation';
import Image from 'next/image';
import Link from 'next/link';
import { Button, toast } from '@/components/common';
import { Button } from '@/components/common';
import { PageContainer } from '@/components/layout';
import { cn } from '@/utils/helpers';
import { formatNumber, formatRanking } from '@/utils/formatters';
@ -16,28 +15,40 @@ import {
useAuthorizationDetail,
} from '@/hooks/useUserDetailPage';
import { userDetailService } from '@/services/userDetailService';
import type {
ReferralNode,
PlantingLedgerItem,
WalletLedgerItem,
WALLET_ENTRY_TYPE_LABELS,
ASSET_TYPE_LABELS,
PLANTING_STATUS_LABELS,
AUTHORIZATION_ROLE_LABELS,
AUTHORIZATION_STATUS_LABELS,
ASSESSMENT_RESULT_LABELS,
} from '@/types/userDetail.types';
// [2026-02-05] 新增:合同服务
import { contractService, CONTRACT_STATUS_LABELS, type ContractsListResponse } from '@/services/contractService';
// 获取合同状态颜色(内联样式)
const getContractStatusStyle = (status: string): React.CSSProperties => {
switch (status) {
case 'SIGNED':
return { color: '#16a34a' };
case 'PENDING':
case 'SCROLLED':
case 'ACKNOWLEDGED':
return { color: '#ca8a04' };
case 'EXPIRED':
case 'TIMEOUT':
return { color: '#dc2626' };
default:
return { color: '#4b5563' };
}
};
import type { ReferralNode } from '@/types/userDetail.types';
import { PROVINCE_CODE_NAMES, CITY_CODE_NAMES } from '@/types';
import styles from './user-detail.module.scss';
// Tab 类型
type TabType = 'referral' | 'planting' | 'wallet' | 'authorization';
// [2026-02-05] 更新:新增 contracts Tab
type TabType = 'referral' | 'planting' | 'wallet' | 'authorization' | 'contracts';
const tabs: { key: TabType; label: string }[] = [
{ key: 'referral', label: '引荐关系' },
{ key: 'planting', label: '认种信息' },
{ key: 'wallet', label: '钱包信息' },
{ key: 'authorization', label: '授权信息' },
// [2026-02-05] 新增:合同信息 Tab
{ key: 'contracts', label: '合同信息' },
];
// 流水类型标签
@ -168,6 +179,10 @@ export default function UserDetailPage() {
const [walletPage, setWalletPage] = useState(1);
// 存储已展开节点的状态key 是节点 accountSequencevalue 是其子节点数组null 表示未加载)
const [expandedNodes, setExpandedNodes] = useState<Record<string, ReferralNode[] | null>>({});
// [2026-02-05] 新增:合同信息状态
const [contractsPage, setContractsPage] = useState(1);
const [contractsData, setContractsData] = useState<ContractsListResponse | null>(null);
const [contractsLoading, setContractsLoading] = useState(false);
// 获取用户完整信息
const { data: userDetail, isLoading: detailLoading, error: detailError } = useUserFullDetail(accountSequence);
@ -200,6 +215,27 @@ export default function UserDetailPage() {
}
}, [referralTree]);
// [2026-02-05] 新增:加载合同数据
useEffect(() => {
if (activeTab === 'contracts') {
setContractsLoading(true);
contractService.getUserContracts(accountSequence, {
page: contractsPage,
pageSize: 10,
})
.then((data) => {
setContractsData(data);
})
.catch((error) => {
console.error('获取合同列表失败:', error);
setContractsData(null);
})
.finally(() => {
setContractsLoading(false);
});
}
}, [activeTab, accountSequence, contractsPage]);
// 切换推荐关系树的根节点
const handleTreeNodeClick = useCallback((node: ReferralNode) => {
setTreeRootUser(node.accountSequence);
@ -241,6 +277,12 @@ export default function UserDetailPage() {
router.push('/users');
}, [router]);
// [2026-02-05] 新增:下载合同 PDF
const handleDownloadContract = useCallback((orderNo: string) => {
const downloadUrl = contractService.getDownloadUrl(orderNo);
window.open(downloadUrl, '_blank');
}, []);
// 格式化日期
const formatDate = (dateStr: string | null) => {
if (!dateStr) return '-';
@ -927,6 +969,112 @@ export default function UserDetailPage() {
)}
</div>
)}
{/* [2026-02-05] 新增:合同信息 Tab */}
{activeTab === 'contracts' && (
<div className={styles.plantingTab}>
{contractsLoading ? (
<div className={styles.plantingTab__loading}>...</div>
) : contractsData ? (
<>
{/* 合同汇总 */}
<div className={styles.plantingTab__summary}>
<h3></h3>
<div className={styles.plantingTab__summaryGrid}>
<div className={styles.plantingTab__summaryItem}>
<span className={styles.plantingTab__summaryLabel}></span>
<span className={styles.plantingTab__summaryValue}>
{formatNumber(contractsData.total)}
</span>
</div>
<div className={styles.plantingTab__summaryItem}>
<span className={styles.plantingTab__summaryLabel}></span>
<span className={styles.plantingTab__summaryValue}>
{formatNumber(contractsData.items.filter(c => c.status === 'SIGNED').length)}
</span>
</div>
<div className={styles.plantingTab__summaryItem}>
<span className={styles.plantingTab__summaryLabel}></span>
<span className={styles.plantingTab__summaryValue}>
{formatNumber(contractsData.items.filter(c => ['PENDING', 'SCROLLED', 'ACKNOWLEDGED'].includes(c.status)).length)}
</span>
</div>
</div>
</div>
{/* 合同列表 */}
<div className={styles.plantingTab__ledger}>
<h3></h3>
<div className={styles.ledgerTable}>
<div className={styles.ledgerTable__header}>
<div className={styles.ledgerTable__cell}></div>
<div className={styles.ledgerTable__cell}></div>
<div className={styles.ledgerTable__cell}></div>
<div className={styles.ledgerTable__cell}></div>
<div className={styles.ledgerTable__cell}></div>
<div className={styles.ledgerTable__cell}></div>
<div className={styles.ledgerTable__cell}></div>
<div className={styles.ledgerTable__cell}></div>
</div>
{contractsData.items.length === 0 ? (
<div className={styles.ledgerTable__empty}></div>
) : (
contractsData.items.map((contract) => (
<div key={contract.orderNo} className={styles.ledgerTable__row}>
<div className={styles.ledgerTable__cell}>{contract.contractNo}</div>
<div className={styles.ledgerTable__cell}>{contract.orderNo}</div>
<div className={styles.ledgerTable__cell}>{formatNumber(contract.treeCount)}</div>
<div className={styles.ledgerTable__cell}>{formatAmount(contract.totalAmount.toString())}</div>
<div className={styles.ledgerTable__cell}>
{contract.provinceName} / {contract.cityName}
</div>
<div className={styles.ledgerTable__cell} style={getContractStatusStyle(contract.status)}>
{CONTRACT_STATUS_LABELS[contract.status] || contract.status}
</div>
<div className={styles.ledgerTable__cell}>
{contract.signedAt ? formatDate(contract.signedAt) : '-'}
</div>
<div className={styles.ledgerTable__cell}>
{contract.status === 'SIGNED' && contract.signedPdfUrl && (
<Button
variant="outline"
size="sm"
onClick={() => handleDownloadContract(contract.orderNo)}
>
PDF
</Button>
)}
</div>
</div>
))
)}
</div>
{/* 分页 */}
{contractsData.totalPages > 1 && (
<div className={styles.pagination}>
<button
disabled={contractsPage === 1}
onClick={() => setContractsPage((p) => p - 1)}
>
</button>
<span> {contractsPage} / {contractsData.totalPages} </span>
<button
disabled={contractsPage === contractsData.totalPages}
onClick={() => setContractsPage((p) => p + 1)}
>
</button>
</div>
)}
</div>
</>
) : (
<div className={styles.plantingTab__empty}></div>
)}
</div>
)}
</div>
</div>
</PageContainer>

View File

@ -25,6 +25,8 @@ interface MenuItem {
const topMenuItems: MenuItem[] = [
{ key: 'dashboard', icon: '/images/Container1.svg', label: '仪表板', path: '/dashboard' },
{ key: 'users', icon: '/images/Container2.svg', label: '用户管理', path: '/users' },
// [2026-02-05] 新增:合同管理
{ key: 'contracts', icon: '/images/Container2.svg', label: '合同管理', path: '/contracts' },
{ key: 'leaderboard', icon: '/images/Container3.svg', label: '龙虎榜', path: '/leaderboard' },
{ key: 'authorization', icon: '/images/Container4.svg', label: '授权管理', path: '/authorization' },
{ key: 'co-managed-wallet', icon: '/images/Container4.svg', label: '共管钱包', path: '/co-managed-wallet' },

View File

@ -269,4 +269,23 @@ export const API_ENDPOINTS = {
UPDATE: (id: string) => `/v1/admin/customer-service-contacts/${id}`,
DELETE: (id: string) => `/v1/admin/customer-service-contacts/${id}`,
},
// [2026-02-05] 新增:合同管理 (admin-service)
// 回滚方式:删除此部分即可
CONTRACTS: {
// 合同列表查询
LIST: '/v1/admin/contracts',
// 合同统计信息
STATISTICS: '/v1/admin/contracts/statistics',
// 合同详情
DETAIL: (orderNo: string) => `/v1/admin/contracts/${orderNo}`,
// 下载合同 PDF支持断点续传
DOWNLOAD: (orderNo: string) => `/v1/admin/contracts/${orderNo}/download`,
// 用户合同列表
USER_CONTRACTS: (accountSequence: string) => `/v1/admin/contracts/users/${accountSequence}`,
// 批量下载任务
BATCH_DOWNLOAD: '/v1/admin/contracts/batch-download',
// 批量下载任务状态
BATCH_DOWNLOAD_STATUS: (taskNo: string) => `/v1/admin/contracts/batch-download/${taskNo}`,
},
} as const;

View File

@ -0,0 +1,201 @@
/**
*
* [2026-02-05] API
*
*/
import apiClient from '@/infrastructure/api/client';
import { API_ENDPOINTS } from '@/infrastructure/api/endpoints';
/**
* DTO
*/
export interface ContractDto {
orderNo: string;
contractNo: string;
userId: string;
accountSequence: string;
userRealName: string | null;
userPhoneNumber: string | null;
treeCount: number;
totalAmount: number;
provinceCode: string;
provinceName: string;
cityCode: string;
cityName: string;
status: string;
signedAt: string | null;
signedPdfUrl: string | null;
createdAt: string;
}
/**
*
*/
export interface ContractsListResponse {
items: ContractDto[];
total: number;
page: number;
pageSize: number;
totalPages: number;
}
/**
*
*/
export interface ContractStatisticsResponse {
totalContracts: number;
signedContracts: number;
pendingContracts: number;
expiredContracts: number;
}
/**
*
*/
export interface ContractQueryParams {
signedAfter?: string;
signedBefore?: string;
provinceCode?: string;
cityCode?: string;
status?: string;
page?: number;
pageSize?: number;
orderBy?: 'signedAt' | 'createdAt';
orderDir?: 'asc' | 'desc';
}
/**
*
*/
export interface BatchDownloadRequest {
filters?: {
signedAfter?: string;
signedBefore?: string;
provinceCode?: string;
cityCode?: string;
};
orderNos?: string[];
}
/**
*
*/
export interface BatchDownloadTaskResponse {
taskId: string;
taskNo: string;
status: 'PENDING' | 'PROCESSING' | 'COMPLETED' | 'FAILED' | 'CANCELLED';
totalContracts: number;
downloadedCount: number;
failedCount: number;
progress: number;
resultFileUrl: string | null;
resultFileSize: string | null;
errors: unknown | null;
createdAt: string;
startedAt: string | null;
completedAt: string | null;
expiresAt: string | null;
}
/**
*
*/
export const CONTRACT_STATUS_LABELS: Record<string, string> = {
PENDING: '待签署',
SCROLLED: '已阅读',
ACKNOWLEDGED: '已确认',
SIGNED: '已签署',
EXPIRED: '已超时',
TIMEOUT: '已超时',
};
/**
*
*/
export const getContractStatusColor = (status: string): string => {
switch (status) {
case 'SIGNED':
return 'text-green-600';
case 'PENDING':
case 'SCROLLED':
case 'ACKNOWLEDGED':
return 'text-yellow-600';
case 'EXPIRED':
case 'TIMEOUT':
return 'text-red-600';
default:
return 'text-gray-600';
}
};
/**
*
*/
export const contractService = {
/**
*
*/
async getContracts(params?: ContractQueryParams): Promise<ContractsListResponse> {
return apiClient.get(API_ENDPOINTS.CONTRACTS.LIST, { params });
},
/**
*
*/
async getStatistics(params?: {
provinceCode?: string;
cityCode?: string;
}): Promise<ContractStatisticsResponse> {
return apiClient.get(API_ENDPOINTS.CONTRACTS.STATISTICS, { params });
},
/**
*
*/
async getContract(orderNo: string): Promise<ContractDto> {
return apiClient.get(API_ENDPOINTS.CONTRACTS.DETAIL(orderNo));
},
/**
*
*/
async getUserContracts(
accountSequence: string,
params?: { page?: number; pageSize?: number },
): Promise<ContractsListResponse> {
return apiClient.get(API_ENDPOINTS.CONTRACTS.USER_CONTRACTS(accountSequence), { params });
},
/**
* PDF
* URL
*/
getDownloadUrl(orderNo: string): string {
// 获取 API 基础 URL
const baseUrl = process.env.NEXT_PUBLIC_API_URL || '';
return `${baseUrl}${API_ENDPOINTS.CONTRACTS.DOWNLOAD(orderNo)}`;
},
/**
*
*/
async createBatchDownload(request: BatchDownloadRequest): Promise<{
success: boolean;
taskId: string;
taskNo: string;
status: string;
createdAt: string;
}> {
return apiClient.post(API_ENDPOINTS.CONTRACTS.BATCH_DOWNLOAD, request);
},
/**
*
*/
async getBatchDownloadStatus(taskNo: string): Promise<BatchDownloadTaskResponse> {
return apiClient.get(API_ENDPOINTS.CONTRACTS.BATCH_DOWNLOAD_STATUS(taskNo));
},
};
export default contractService;