diff --git a/backend/services/planting-service/prisma/migrations/20241225110000_add_account_sequence_to_order/migration.sql b/backend/services/planting-service/prisma/migrations/20241225110000_add_account_sequence_to_order/migration.sql new file mode 100644 index 00000000..bf2d6999 --- /dev/null +++ b/backend/services/planting-service/prisma/migrations/20241225110000_add_account_sequence_to_order/migration.sql @@ -0,0 +1,15 @@ +-- 在订单表添加 account_sequence 字段 +-- 用于替代 userId 进行用户标识,与 identity-service 保持一致 + +-- 1. 添加字段(允许 NULL 以便迁移现有数据) +ALTER TABLE "planting_orders" ADD COLUMN "account_sequence" VARCHAR(20); + +-- 2. 为现有订单设置默认值(临时使用 userId 的字符串形式) +-- 后续需要通过脚本从 identity-service 获取正确的 accountSequence +UPDATE "planting_orders" SET "account_sequence" = CAST("user_id" AS VARCHAR(20)) WHERE "account_sequence" IS NULL; + +-- 3. 设置为 NOT NULL +ALTER TABLE "planting_orders" ALTER COLUMN "account_sequence" SET NOT NULL; + +-- 4. 创建索引 +CREATE INDEX "planting_orders_account_sequence_idx" ON "planting_orders"("account_sequence"); diff --git a/backend/services/planting-service/prisma/schema.prisma b/backend/services/planting-service/prisma/schema.prisma index 23847f33..d31014bc 100644 --- a/backend/services/planting-service/prisma/schema.prisma +++ b/backend/services/planting-service/prisma/schema.prisma @@ -11,9 +11,10 @@ datasource db { // 认种订单表 (状态表) // ============================================ model PlantingOrder { - id BigInt @id @default(autoincrement()) @map("order_id") - orderNo String @unique @map("order_no") @db.VarChar(50) - userId BigInt @map("user_id") + id BigInt @id @default(autoincrement()) @map("order_id") + orderNo String @unique @map("order_no") @db.VarChar(50) + userId BigInt @map("user_id") + accountSequence String @map("account_sequence") @db.VarChar(20) // 认种信息 treeCount Int @map("tree_count") @@ -48,6 +49,7 @@ model PlantingOrder { batch PoolInjectionBatch? @relation(fields: [poolInjectionBatchId], references: [id]) @@index([userId]) + @@index([accountSequence]) @@index([orderNo]) @@index([status]) @@index([poolInjectionBatchId]) diff --git a/backend/services/planting-service/src/api/controllers/planting-order.controller.ts b/backend/services/planting-service/src/api/controllers/planting-order.controller.ts index 667ae8a1..a6dfb03e 100644 --- a/backend/services/planting-service/src/api/controllers/planting-order.controller.ts +++ b/backend/services/planting-service/src/api/controllers/planting-order.controller.ts @@ -59,7 +59,8 @@ export class PlantingOrderController { @Body() dto: CreatePlantingOrderDto, ): Promise { const userId = BigInt(req.user.id); - return this.plantingService.createOrder(userId, dto.treeCount); + const accountSequence = req.user.accountSequence; + return this.plantingService.createOrder(userId, accountSequence, dto.treeCount); } @Post('orders/:orderNo/select-province-city') diff --git a/backend/services/planting-service/src/application/application.module.ts b/backend/services/planting-service/src/application/application.module.ts index 72247c5d..1c67ae8b 100644 --- a/backend/services/planting-service/src/application/application.module.ts +++ b/backend/services/planting-service/src/application/application.module.ts @@ -4,6 +4,7 @@ import { PlantingApplicationService } from './services/planting-application.serv import { PoolInjectionService } from './services/pool-injection.service'; import { ContractSigningService } from './services/contract-signing.service'; import { ContractSigningTimeoutJob } from './jobs/contract-signing-timeout.job'; +import { ContractSigningRecoveryJob } from './jobs/contract-signing-recovery.job'; import { DomainModule } from '../domain/domain.module'; @Module({ @@ -13,6 +14,7 @@ import { DomainModule } from '../domain/domain.module'; PoolInjectionService, ContractSigningService, ContractSigningTimeoutJob, + ContractSigningRecoveryJob, ], exports: [PlantingApplicationService, PoolInjectionService, ContractSigningService], }) diff --git a/backend/services/planting-service/src/application/jobs/contract-signing-recovery.job.ts b/backend/services/planting-service/src/application/jobs/contract-signing-recovery.job.ts new file mode 100644 index 00000000..b2b9b394 --- /dev/null +++ b/backend/services/planting-service/src/application/jobs/contract-signing-recovery.job.ts @@ -0,0 +1,147 @@ +import { Injectable, Logger, Inject } from '@nestjs/common'; +import { Cron } from '@nestjs/schedule'; +import { ContractSigningService } from '../services/contract-signing.service'; +import { + IPlantingOrderRepository, + PLANTING_ORDER_REPOSITORY, +} from '../../domain/repositories'; +import { IdentityServiceClient } from '../../infrastructure/external/identity-service.client'; +import { PrismaService } from '../../infrastructure/persistence/prisma/prisma.service'; + +/** + * 合同签署恢复定时任务 + * + * 每3分钟扫描一次已支付但未创建合同的订单 + * 为已通过KYC的用户补创建合同签署任务 + * + * 场景: + * 1. KYCVerified 事件发布失败或消费失败 + * 2. identity-service 接口临时不可用 + * 3. 其他导致合同任务未创建的异常情况 + */ +@Injectable() +export class ContractSigningRecoveryJob { + private readonly logger = new Logger(ContractSigningRecoveryJob.name); + + constructor( + private readonly contractSigningService: ContractSigningService, + @Inject(PLANTING_ORDER_REPOSITORY) + private readonly orderRepo: IPlantingOrderRepository, + private readonly identityServiceClient: IdentityServiceClient, + private readonly prisma: PrismaService, + ) {} + + /** + * 每3分钟执行一次 + */ + @Cron('0 */3 * * * *') + async handleRecovery(): Promise { + this.logger.debug('[CONTRACT-RECOVERY] Starting recovery check...'); + + try { + // 1. 查找所有已支付但未创建合同的订单 + const ordersWithoutContract = await this.findAllOrdersWithoutContract(); + + if (ordersWithoutContract.length === 0) { + this.logger.debug('[CONTRACT-RECOVERY] No orders need recovery'); + return; + } + + this.logger.log( + `[CONTRACT-RECOVERY] Found ${ordersWithoutContract.length} orders without contract`, + ); + + let successCount = 0; + let skipCount = 0; + let errorCount = 0; + + // 2. 逐个处理订单 - 订单中已包含 accountSequence + for (const order of ordersWithoutContract) { + try { + // 检查省市信息 + if (!order.selectedProvince || !order.selectedCity) { + this.logger.warn( + `[CONTRACT-RECOVERY] Order ${order.orderNo} has no province/city, skipping`, + ); + skipCount++; + continue; + } + + // 订单中已包含 accountSequence + const accountSequence = order.accountSequence; + + // 使用 accountSequence 获取 KYC 信息 + const kycInfo = await this.identityServiceClient.getUserKycInfo(accountSequence); + + if (!kycInfo) { + this.logger.debug( + `[CONTRACT-RECOVERY] User ${accountSequence} KYC not verified, skipping order ${order.orderNo}`, + ); + skipCount++; + continue; + } + + // 创建合同签署任务 + await this.contractSigningService.createSigningTask({ + orderNo: order.orderNo, + userId: order.userId, + accountSequence, + treeCount: order.treeCount, + totalAmount: Number(order.totalAmount), + provinceCode: order.selectedProvince, + provinceName: order.selectedProvince, + cityCode: order.selectedCity, + cityName: order.selectedCity, + userPhoneNumber: kycInfo.phoneNumber, + userRealName: kycInfo.realName, + userIdCardNumber: kycInfo.idCardNumber, + }); + + successCount++; + this.logger.log(`[CONTRACT-RECOVERY] Created contract for order: ${order.orderNo}`); + } catch (orderError) { + // createSigningTask 有幂等检查,重复创建会直接返回已存在的任务 + // 如果是其他错误才记录 + const errorMessage = orderError instanceof Error ? orderError.message : String(orderError); + if (!errorMessage.includes('already exists')) { + errorCount++; + this.logger.error( + `[CONTRACT-RECOVERY] Failed to create contract for order ${order.orderNo}:`, + orderError, + ); + } + } + } + + this.logger.log( + `[CONTRACT-RECOVERY] Completed: success=${successCount}, skipped=${skipCount}, errors=${errorCount}`, + ); + } catch (error) { + this.logger.error('[CONTRACT-RECOVERY] Error in recovery job:', error); + } + } + + /** + * 查找所有已支付但未创建合同的订单 + */ + private async findAllOrdersWithoutContract() { + const paidStatuses = ['PAID', 'FUND_ALLOCATED', 'POOL_SCHEDULED', 'POOL_INJECTED', 'MINING_ENABLED']; + + // 获取已有合同的订单号 + const existingContracts = await this.prisma.contractSigningTask.findMany({ + select: { orderNo: true }, + }); + const existingOrderNos = new Set(existingContracts.map((c) => c.orderNo)); + + // 查找没有合同的订单 + const orders = await this.prisma.plantingOrder.findMany({ + where: { + status: { in: paidStatuses }, + }, + orderBy: { createdAt: 'asc' }, + }); + + // 过滤掉已有合同的订单 + return orders.filter((o) => !existingOrderNos.has(o.orderNo)); + } +} diff --git a/backend/services/planting-service/src/application/services/planting-application.service.ts b/backend/services/planting-service/src/application/services/planting-application.service.ts index 92744f1d..67318357 100644 --- a/backend/services/planting-service/src/application/services/planting-application.service.ts +++ b/backend/services/planting-service/src/application/services/planting-application.service.ts @@ -77,9 +77,10 @@ export class PlantingApplicationService { */ async createOrder( userId: bigint, + accountSequence: string, treeCount: number, ): Promise { - this.logger.log(`Creating order for user ${userId}, treeCount: ${treeCount}`); + this.logger.log(`Creating order for user ${accountSequence}, treeCount: ${treeCount}`); // 检查余额 const balance = await this.walletService.getBalance(userId.toString()); @@ -91,7 +92,7 @@ export class PlantingApplicationService { } // 创建订单 - const order = PlantingOrder.create(userId, treeCount); + const order = PlantingOrder.create(userId, accountSequence, treeCount); await this.orderRepository.save(order); this.logger.log(`Order created: ${order.orderNo}`); @@ -253,8 +254,8 @@ export class PlantingApplicationService { try { // 6. 标记已支付 (内存操作) // 注意:资金分配已移至 reward-service - // accountSequence 用于 wallet-service 结算用户的待领取奖励 - order.markAsPaid(accountSequence || ''); + // accountSequence 已存储在订单中,用于 wallet-service 结算用户的待领取奖励 + order.markAsPaid(); // 7. 使用事务保存本地数据库的所有变更 + Outbox事件 // 这确保了订单状态和事件发布的原子性 diff --git a/backend/services/planting-service/src/domain/aggregates/planting-order.aggregate.ts b/backend/services/planting-service/src/domain/aggregates/planting-order.aggregate.ts index 6e1a003a..1cb49a37 100644 --- a/backend/services/planting-service/src/domain/aggregates/planting-order.aggregate.ts +++ b/backend/services/planting-service/src/domain/aggregates/planting-order.aggregate.ts @@ -15,6 +15,7 @@ export interface PlantingOrderData { id?: bigint; orderNo: string; userId: bigint; + accountSequence: string; treeCount: number; totalAmount: number; status: PlantingOrderStatus; @@ -36,6 +37,7 @@ export class PlantingOrder { private _id: bigint | null; private readonly _orderNo: string; private readonly _userId: bigint; + private readonly _accountSequence: string; private readonly _treeCount: TreeCount; private readonly _totalAmount: number; private _provinceCitySelection: ProvinceCitySelection | null; @@ -56,6 +58,7 @@ export class PlantingOrder { private constructor( orderNo: string, userId: bigint, + accountSequence: string, treeCount: TreeCount, totalAmount: number, createdAt?: Date, @@ -63,6 +66,7 @@ export class PlantingOrder { this._id = null; this._orderNo = orderNo; this._userId = userId; + this._accountSequence = accountSequence; this._treeCount = treeCount; this._totalAmount = totalAmount; this._status = PlantingOrderStatus.CREATED; @@ -88,6 +92,9 @@ export class PlantingOrder { get userId(): bigint { return this._userId; } + get accountSequence(): string { + return this._accountSequence; + } get treeCount(): TreeCount { return this._treeCount; } @@ -137,7 +144,7 @@ export class PlantingOrder { /** * 工厂方法:创建认种订单 */ - static create(userId: bigint, treeCount: number): PlantingOrder { + static create(userId: bigint, accountSequence: string, treeCount: number): PlantingOrder { if (treeCount <= 0) { throw new Error('认种数量必须大于0'); } @@ -146,13 +153,14 @@ export class PlantingOrder { const tree = TreeCount.create(treeCount); const totalAmount = treeCount * PRICE_PER_TREE; - const order = new PlantingOrder(orderNo, userId, tree, totalAmount); + const order = new PlantingOrder(orderNo, userId, accountSequence, tree, totalAmount); // 发布领域事件 order._domainEvents.push( new PlantingOrderCreatedEvent(orderNo, { orderNo: order.orderNo, userId: order.userId.toString(), + accountSequence: order.accountSequence, treeCount: order.treeCount.value, totalAmount: order.totalAmount, }), @@ -215,9 +223,8 @@ export class PlantingOrder { /** * 标记为已支付 - * @param accountSequence 用户账户序列号,用于 wallet-service 结算待领取奖励 */ - markAsPaid(accountSequence: string): void { + markAsPaid(): void { this.ensureStatus(PlantingOrderStatus.PROVINCE_CITY_CONFIRMED); this._status = PlantingOrderStatus.PAID; @@ -227,7 +234,7 @@ export class PlantingOrder { new PlantingOrderPaidEvent(this.orderNo, { orderNo: this.orderNo, userId: this.userId.toString(), - accountSequence, + accountSequence: this._accountSequence, treeCount: this.treeCount.value, totalAmount: this.totalAmount, provinceCode: this._provinceCitySelection!.provinceCode, @@ -362,6 +369,7 @@ export class PlantingOrder { const order = new PlantingOrder( data.orderNo, data.userId, + data.accountSequence, TreeCount.create(data.treeCount), data.totalAmount, data.createdAt, @@ -401,6 +409,7 @@ export class PlantingOrder { id: this._id || undefined, orderNo: this._orderNo, userId: this._userId, + accountSequence: this._accountSequence, treeCount: this._treeCount.value, totalAmount: this._totalAmount, status: this._status, diff --git a/backend/services/planting-service/src/domain/events/planting-order-created.event.ts b/backend/services/planting-service/src/domain/events/planting-order-created.event.ts index 33f8f86f..b6ab2003 100644 --- a/backend/services/planting-service/src/domain/events/planting-order-created.event.ts +++ b/backend/services/planting-service/src/domain/events/planting-order-created.event.ts @@ -10,6 +10,7 @@ export class PlantingOrderCreatedEvent implements DomainEvent { public readonly data: { orderNo: string; userId: string; + accountSequence: string; treeCount: number; totalAmount: number; }, diff --git a/backend/services/planting-service/src/infrastructure/persistence/mappers/planting-order.mapper.ts b/backend/services/planting-service/src/infrastructure/persistence/mappers/planting-order.mapper.ts index e80c21f3..5639a519 100644 --- a/backend/services/planting-service/src/infrastructure/persistence/mappers/planting-order.mapper.ts +++ b/backend/services/planting-service/src/infrastructure/persistence/mappers/planting-order.mapper.ts @@ -21,6 +21,7 @@ export class PlantingOrderMapper { id: prismaOrder.id, orderNo: prismaOrder.orderNo, userId: prismaOrder.userId, + accountSequence: prismaOrder.accountSequence, treeCount: prismaOrder.treeCount, totalAmount: Number(prismaOrder.totalAmount), status: prismaOrder.status as PlantingOrderStatus, @@ -46,6 +47,7 @@ export class PlantingOrderMapper { id?: bigint; orderNo: string; userId: bigint; + accountSequence: string; treeCount: number; totalAmount: Prisma.Decimal; selectedProvince: string | null; @@ -76,6 +78,7 @@ export class PlantingOrderMapper { id: data.id, orderNo: data.orderNo, userId: data.userId, + accountSequence: data.accountSequence, treeCount: data.treeCount, totalAmount: new Prisma.Decimal(data.totalAmount), selectedProvince: data.selectedProvince || null, diff --git a/backend/services/planting-service/src/infrastructure/persistence/repositories/planting-order.repository.impl.ts b/backend/services/planting-service/src/infrastructure/persistence/repositories/planting-order.repository.impl.ts index 72ef057e..3bd7de19 100644 --- a/backend/services/planting-service/src/infrastructure/persistence/repositories/planting-order.repository.impl.ts +++ b/backend/services/planting-service/src/infrastructure/persistence/repositories/planting-order.repository.impl.ts @@ -58,6 +58,7 @@ export class PlantingOrderRepositoryImpl implements IPlantingOrderRepository { data: { orderNo: orderData.orderNo, userId: orderData.userId, + accountSequence: orderData.accountSequence, treeCount: orderData.treeCount, totalAmount: orderData.totalAmount, status: orderData.status, diff --git a/backend/services/planting-service/src/infrastructure/persistence/unit-of-work.ts b/backend/services/planting-service/src/infrastructure/persistence/unit-of-work.ts index 4566fbe7..1188d2df 100644 --- a/backend/services/planting-service/src/infrastructure/persistence/unit-of-work.ts +++ b/backend/services/planting-service/src/infrastructure/persistence/unit-of-work.ts @@ -160,6 +160,7 @@ export class TransactionalUnitOfWork { data: { orderNo: orderData.orderNo, userId: orderData.userId, + accountSequence: orderData.accountSequence, treeCount: orderData.treeCount, totalAmount: orderData.totalAmount, status: orderData.status,