From 86461a052d5615e4baf1b671e345de1284bd7523 Mon Sep 17 00:00:00 2001 From: hailin Date: Thu, 5 Feb 2026 22:50:17 -0800 Subject: [PATCH] =?UTF-8?q?feat(contracts):=20=E5=90=88=E5=90=8C=E7=AE=A1?= =?UTF-8?q?=E7=90=86=E5=8A=9F=E8=83=BD=20-=20=E6=9F=A5=E8=AF=A2/=E4=B8=8B?= =?UTF-8?q?=E8=BD=BD/=E6=89=B9=E9=87=8F=E6=89=93=E5=8C=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增功能: - 合同列表查询(支持省市、状态、时间筛选) - 单个合同 PDF 下载(支持断点续传) - 批量下载 ZIP 打包(异步任务处理) - 增量下载(基于上次下载时间) - 用户详情页合同 Tab 后端: - planting-service: 内部合同查询 API - admin-service: 合同管理控制器、服务、批量下载 Job - 新增 contract_batch_download_tasks 表 前端: - 新增独立合同管理页面 /contracts - 用户详情页新增合同信息 Tab - 侧边栏新增合同管理入口 Co-Authored-By: Claude Opus 4.5 --- backend/services/admin-service/package.json | 3 + .../migration.sql | 40 ++ .../admin-service/prisma/schema.prisma | 55 +++ .../api/controllers/contract.controller.ts | 309 ++++++++++++++ .../services/admin-service/src/app.module.ts | 9 + .../application/services/contract.service.ts | 203 +++++++++ .../jobs/contract-batch-download.job.ts | 343 ++++++++++++++++ .../planting-service/src/api/api.module.ts | 4 + .../controllers/contract-admin.controller.ts | 369 +++++++++++++++++ .../contracts/contracts.module.scss | 175 ++++++++ .../src/app/(dashboard)/contracts/page.tsx | 388 ++++++++++++++++++ .../src/app/(dashboard)/users/[id]/page.tsx | 176 +++++++- .../src/components/layout/Sidebar/Sidebar.tsx | 2 + .../src/infrastructure/api/endpoints.ts | 19 + .../admin-web/src/services/contractService.ts | 201 +++++++++ 15 files changed, 2282 insertions(+), 14 deletions(-) create mode 100644 backend/services/admin-service/prisma/migrations/20260205120000_add_contract_batch_download/migration.sql create mode 100644 backend/services/admin-service/src/api/controllers/contract.controller.ts create mode 100644 backend/services/admin-service/src/application/services/contract.service.ts create mode 100644 backend/services/admin-service/src/infrastructure/jobs/contract-batch-download.job.ts create mode 100644 backend/services/planting-service/src/api/controllers/contract-admin.controller.ts create mode 100644 frontend/admin-web/src/app/(dashboard)/contracts/contracts.module.scss create mode 100644 frontend/admin-web/src/app/(dashboard)/contracts/page.tsx create mode 100644 frontend/admin-web/src/services/contractService.ts diff --git a/backend/services/admin-service/package.json b/backend/services/admin-service/package.json index 9f48d906..6571fb99 100644 --- a/backend/services/admin-service/package.json +++ b/backend/services/admin-service/package.json @@ -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", diff --git a/backend/services/admin-service/prisma/migrations/20260205120000_add_contract_batch_download/migration.sql b/backend/services/admin-service/prisma/migrations/20260205120000_add_contract_batch_download/migration.sql new file mode 100644 index 00000000..f9e226c7 --- /dev/null +++ b/backend/services/admin-service/prisma/migrations/20260205120000_add_contract_batch_download/migration.sql @@ -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"); diff --git a/backend/services/admin-service/prisma/schema.prisma b/backend/services/admin-service/prisma/schema.prisma index 1312be6a..4d345cdc 100644 --- a/backend/services/admin-service/prisma/schema.prisma +++ b/backend/services/admin-service/prisma/schema.prisma @@ -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 (客服联系方式) // ============================================================================= diff --git a/backend/services/admin-service/src/api/controllers/contract.controller.ts b/backend/services/admin-service/src/api/controllers/contract.controller.ts new file mode 100644 index 00000000..ee663c0b --- /dev/null +++ b/backend/services/admin-service/src/api/controllers/contract.controller.ts @@ -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 { + 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); + } + } +} diff --git a/backend/services/admin-service/src/app.module.ts b/backend/services/admin-service/src/app.module.ts index a9c78794..e1aff591 100644 --- a/backend/services/admin-service/src/app.module.ts +++ b/backend/services/admin-service/src/app.module.ts @@ -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 {} diff --git a/backend/services/admin-service/src/application/services/contract.service.ts b/backend/services/admin-service/src/application/services/contract.service.ts new file mode 100644 index 00000000..2197013f --- /dev/null +++ b/backend/services/admin-service/src/application/services/contract.service.ts @@ -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( + '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 { + 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(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 { + return this.getContracts({ + accountSequences: [accountSequence], + page: params?.page, + pageSize: params?.pageSize, + status: undefined, // 查询所有状态 + }); + } + + /** + * 获取单个合同详情 + */ + async getContract(orderNo: string): Promise { + try { + const url = `/planting/internal/contracts/${orderNo}`; + this.logger.debug(`[getContract] 请求: ${url}`); + + const response = await this.httpClient.get(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 { + 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 { + 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(url); + return response.data; + } catch (error) { + this.logger.error(`[getStatistics] 失败: ${error.message}`); + return { + totalContracts: 0, + signedContracts: 0, + pendingContracts: 0, + expiredContracts: 0, + }; + } + } +} diff --git a/backend/services/admin-service/src/infrastructure/jobs/contract-batch-download.job.ts b/backend/services/admin-service/src/infrastructure/jobs/contract-batch-download.job.ts new file mode 100644 index 00000000..5c61e9c0 --- /dev/null +++ b/backend/services/admin-service/src/infrastructure/jobs/contract-batch-download.job.ts @@ -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('UPLOAD_DIR') || './uploads'; + this.baseUrl = this.configService.get('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 { + 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 { + 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 { + 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 { + 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 }; + } +} diff --git a/backend/services/planting-service/src/api/api.module.ts b/backend/services/planting-service/src/api/api.module.ts index ea391d67..9a187ffb 100644 --- a/backend/services/planting-service/src/api/api.module.ts +++ b/backend/services/planting-service/src/api/api.module.ts @@ -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], }) diff --git a/backend/services/planting-service/src/api/controllers/contract-admin.controller.ts b/backend/services/planting-service/src/api/controllers/contract-admin.controller.ts new file mode 100644 index 00000000..ad0f1ae5 --- /dev/null +++ b/backend/services/planting-service/src/api/controllers/contract-admin.controller.ts @@ -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 { + 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 = {}; + + // 账户序列号过滤 + 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).gte = new Date(signedAfter); + } + if (signedBefore) { + (where.signedAt as Record).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 { + this.logger.log(`========== internal/contracts/statistics 请求 ==========`); + + const where: Record = {}; + 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 { + 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 { + 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); + } +} diff --git a/frontend/admin-web/src/app/(dashboard)/contracts/contracts.module.scss b/frontend/admin-web/src/app/(dashboard)/contracts/contracts.module.scss new file mode 100644 index 00000000..401ee846 --- /dev/null +++ b/frontend/admin-web/src/app/(dashboard)/contracts/contracts.module.scss @@ -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; + } +} diff --git a/frontend/admin-web/src/app/(dashboard)/contracts/page.tsx b/frontend/admin-web/src/app/(dashboard)/contracts/page.tsx new file mode 100644 index 00000000..d3096f78 --- /dev/null +++ b/frontend/admin-web/src/app/(dashboard)/contracts/page.tsx @@ -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({ + page: 1, + pageSize: 20, + provinceCode: '', + cityCode: '', + status: '', + signedAfter: '', + signedBefore: '', + orderBy: 'signedAt', + orderDir: 'desc', + }); + + // 数据状态 + const [data, setData] = useState(null); + const [statistics, setStatistics] = useState(null); + const [loading, setLoading] = useState(true); + const [statsLoading, setStatsLoading] = useState(true); + + // 增量下载状态 + const [lastDownloadTime, setLastDownloadTime] = useState(() => { + 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 ( + +
+ {/* 统计卡片 */} +
+
+ 合同总数 + + {statsLoading ? '...' : formatNumber(statistics?.totalContracts || 0)} + +
+
+ 已签署 + + {statsLoading ? '...' : formatNumber(statistics?.signedContracts || 0)} + +
+
+ 待签署 + + {statsLoading ? '...' : formatNumber(statistics?.pendingContracts || 0)} + +
+
+ 已超时 + + {statsLoading ? '...' : formatNumber(statistics?.expiredContracts || 0)} + +
+
+ + {/* 筛选栏 */} +
+
+
+ + +
+ +
+ + +
+ +
+ + setFilters(prev => ({ ...prev, signedAfter: e.target.value ? `${e.target.value}T00:00:00.000Z` : '', page: 1 }))} + /> +
+ +
+ + setFilters(prev => ({ ...prev, signedBefore: e.target.value ? `${e.target.value}T23:59:59.999Z` : '', page: 1 }))} + /> +
+ + +
+ +
+ + {lastDownloadTime && ( + + )} +
+
+ + {/* 合同列表 */} +
+
+
合同编号
+
订单号
+
用户
+
认种数量
+
金额
+
省市
+
状态
+
签署时间
+
操作
+
+ + {loading ? ( +
加载中...
+ ) : data && data.items.length > 0 ? ( + data.items.map((contract) => ( +
+
{contract.contractNo}
+
{contract.orderNo}
+
+
{contract.userRealName || '未实名'}
+
{contract.accountSequence}
+
+
{formatNumber(contract.treeCount)}
+
{formatAmount(contract.totalAmount)}
+
+ {contract.provinceName} / {contract.cityName} +
+
+ {CONTRACT_STATUS_LABELS[contract.status] || contract.status} +
+
+ {contract.signedAt ? formatDate(contract.signedAt) : '-'} +
+
+ {contract.status === 'SIGNED' && contract.signedPdfUrl && ( + + )} +
+
+ )) + ) : ( +
暂无合同数据
+ )} +
+ + {/* 分页 */} + {data && data.totalPages > 1 && ( +
+ + + 第 {filters.page} / {data.totalPages} 页,共 {data.total} 条 + + +
+ )} +
+
+ ); +} diff --git a/frontend/admin-web/src/app/(dashboard)/users/[id]/page.tsx b/frontend/admin-web/src/app/(dashboard)/users/[id]/page.tsx index 83e95844..5baf07a0 100644 --- a/frontend/admin-web/src/app/(dashboard)/users/[id]/page.tsx +++ b/frontend/admin-web/src/app/(dashboard)/users/[id]/page.tsx @@ -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 是节点 accountSequence,value 是其子节点数组(null 表示未加载) const [expandedNodes, setExpandedNodes] = useState>({}); + // [2026-02-05] 新增:合同信息状态 + const [contractsPage, setContractsPage] = useState(1); + const [contractsData, setContractsData] = useState(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() { )} )} + + {/* [2026-02-05] 新增:合同信息 Tab */} + {activeTab === 'contracts' && ( +
+ {contractsLoading ? ( +
加载中...
+ ) : contractsData ? ( + <> + {/* 合同汇总 */} +
+

合同汇总

+
+
+ 合同总数 + + {formatNumber(contractsData.total)} + +
+
+ 已签署 + + {formatNumber(contractsData.items.filter(c => c.status === 'SIGNED').length)} + +
+
+ 待签署 + + {formatNumber(contractsData.items.filter(c => ['PENDING', 'SCROLLED', 'ACKNOWLEDGED'].includes(c.status)).length)} + +
+
+
+ + {/* 合同列表 */} +
+

合同列表

+
+
+
合同编号
+
订单号
+
认种数量
+
金额
+
省市
+
状态
+
签署时间
+
操作
+
+ {contractsData.items.length === 0 ? ( +
暂无合同记录
+ ) : ( + contractsData.items.map((contract) => ( +
+
{contract.contractNo}
+
{contract.orderNo}
+
{formatNumber(contract.treeCount)}
+
{formatAmount(contract.totalAmount.toString())}
+
+ {contract.provinceName} / {contract.cityName} +
+
+ {CONTRACT_STATUS_LABELS[contract.status] || contract.status} +
+
+ {contract.signedAt ? formatDate(contract.signedAt) : '-'} +
+
+ {contract.status === 'SIGNED' && contract.signedPdfUrl && ( + + )} +
+
+ )) + )} +
+ + {/* 分页 */} + {contractsData.totalPages > 1 && ( +
+ + 第 {contractsPage} / {contractsData.totalPages} 页 + +
+ )} +
+ + ) : ( +
暂无合同数据
+ )} +
+ )} diff --git a/frontend/admin-web/src/components/layout/Sidebar/Sidebar.tsx b/frontend/admin-web/src/components/layout/Sidebar/Sidebar.tsx index 01181be9..e31b1fc0 100644 --- a/frontend/admin-web/src/components/layout/Sidebar/Sidebar.tsx +++ b/frontend/admin-web/src/components/layout/Sidebar/Sidebar.tsx @@ -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' }, diff --git a/frontend/admin-web/src/infrastructure/api/endpoints.ts b/frontend/admin-web/src/infrastructure/api/endpoints.ts index cf07e5e2..7f23381a 100644 --- a/frontend/admin-web/src/infrastructure/api/endpoints.ts +++ b/frontend/admin-web/src/infrastructure/api/endpoints.ts @@ -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; diff --git a/frontend/admin-web/src/services/contractService.ts b/frontend/admin-web/src/services/contractService.ts new file mode 100644 index 00000000..b5290630 --- /dev/null +++ b/frontend/admin-web/src/services/contractService.ts @@ -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 = { + 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 { + return apiClient.get(API_ENDPOINTS.CONTRACTS.LIST, { params }); + }, + + /** + * 获取合同统计信息 + */ + async getStatistics(params?: { + provinceCode?: string; + cityCode?: string; + }): Promise { + return apiClient.get(API_ENDPOINTS.CONTRACTS.STATISTICS, { params }); + }, + + /** + * 获取单个合同详情 + */ + async getContract(orderNo: string): Promise { + return apiClient.get(API_ENDPOINTS.CONTRACTS.DETAIL(orderNo)); + }, + + /** + * 获取用户的合同列表 + */ + async getUserContracts( + accountSequence: string, + params?: { page?: number; pageSize?: number }, + ): Promise { + 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 { + return apiClient.get(API_ENDPOINTS.CONTRACTS.BATCH_DOWNLOAD_STATUS(taskNo)); + }, +}; + +export default contractService;