feat(planting-service): 订单表添加 accountSequence,实现合同恢复任务
变更内容: 1. 订单表添加 account_sequence 字段 - 创建订单时保存用户的 accountSequence - 避免跨服务调用 identity-service 获取用户信息 2. 新增 ContractSigningRecoveryJob 定时任务 - 每 3 分钟扫描已支付但未创建合同的订单 - 使用订单中的 accountSequence 获取 KYC 信息 - 为已通过 KYC 的用户补创建合同签署任务 3. 修改 PlantingOrder 聚合根 - create() 方法增加 accountSequence 参数 - markAsPaid() 不再需要 accountSequence 参数 - 事件中携带 accountSequence 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
1c3ddb0f9b
commit
c907f44851
|
|
@ -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");
|
||||
|
|
@ -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])
|
||||
|
|
|
|||
|
|
@ -59,7 +59,8 @@ export class PlantingOrderController {
|
|||
@Body() dto: CreatePlantingOrderDto,
|
||||
): Promise<CreateOrderResponse> {
|
||||
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')
|
||||
|
|
|
|||
|
|
@ -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],
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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<void> {
|
||||
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));
|
||||
}
|
||||
}
|
||||
|
|
@ -77,9 +77,10 @@ export class PlantingApplicationService {
|
|||
*/
|
||||
async createOrder(
|
||||
userId: bigint,
|
||||
accountSequence: string,
|
||||
treeCount: number,
|
||||
): Promise<CreateOrderResult> {
|
||||
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事件
|
||||
// 这确保了订单状态和事件发布的原子性
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ export class PlantingOrderCreatedEvent implements DomainEvent {
|
|||
public readonly data: {
|
||||
orderNo: string;
|
||||
userId: string;
|
||||
accountSequence: string;
|
||||
treeCount: number;
|
||||
totalAmount: number;
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Reference in New Issue