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:
hailin 2025-12-25 08:19:36 -08:00
parent 1c3ddb0f9b
commit c907f44851
11 changed files with 196 additions and 13 deletions

View File

@ -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");

View File

@ -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])

View File

@ -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')

View File

@ -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],
})

View File

@ -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));
}
}

View File

@ -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事件
// 这确保了订单状态和事件发布的原子性

View File

@ -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,

View File

@ -10,6 +10,7 @@ export class PlantingOrderCreatedEvent implements DomainEvent {
public readonly data: {
orderNo: string;
userId: string;
accountSequence: string;
treeCount: number;
totalAmount: number;
},

View File

@ -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,

View File

@ -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,

View File

@ -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,