feat(pre-planting): 3171 预种计划 1.0 全量实现(纯新增,零侵入)
预种计划(拼种团购):用户以 3171 USDT/份参与认种(1棵树的1/5价格), 累计5份自动合成1棵树,触发合同签署并解除交易/提现限制。 涉及服务(现有代码仅 app.module.ts 加 1 行 import,其余全部为新增文件): - planting-service: PrePlantingModule(独立聚合根、购买/合并/签约/分配) - admin-service: 预种开关管理(PrePlantingConfig 表 + API) - referral-service: PrePlantingStatsModule(消费预种事件更新团队统计) - authorization-service: PrePlantingGuardModule(未合并不可申请授权) - wallet-service: PrePlantingGuardModule(未合并不可提现) 新增数据表:pre_planting_orders, pre_planting_positions, pre_planting_merges, pre_planting_reward_entries, pre_planting_configs 新增 Kafka Topics:pre-planting.portion.purchased, pre-planting.merged, pre-planting.contract.signed Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
875f86c263
commit
010b0392fd
|
|
@ -1258,3 +1258,17 @@ model CustomerServiceContact {
|
||||||
@@index([sortOrder])
|
@@index([sortOrder])
|
||||||
@@map("customer_service_contacts")
|
@@map("customer_service_contacts")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// 预种计划开关配置
|
||||||
|
// 控制预种功能的开启/关闭,不影响已完成的业务流程
|
||||||
|
// =============================================================================
|
||||||
|
model PrePlantingConfig {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
isActive Boolean @default(false) @map("is_active")
|
||||||
|
activatedAt DateTime? @map("activated_at")
|
||||||
|
updatedAt DateTime @updatedAt @map("updated_at")
|
||||||
|
updatedBy String? @map("updated_by") @db.VarChar(50)
|
||||||
|
|
||||||
|
@@map("pre_planting_configs")
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -84,6 +84,9 @@ import { AdminCustomerServiceContactController, PublicCustomerServiceContactCont
|
||||||
// [2026-02-05] 新增:合同管理模块
|
// [2026-02-05] 新增:合同管理模块
|
||||||
import { ContractController } from './api/controllers/contract.controller';
|
import { ContractController } from './api/controllers/contract.controller';
|
||||||
import { ContractService } from './application/services/contract.service';
|
import { ContractService } from './application/services/contract.service';
|
||||||
|
// [2026-02-17] 新增:预种计划开关管理
|
||||||
|
import { PrePlantingConfigController, PublicPrePlantingConfigController } from './pre-planting/pre-planting-config.controller';
|
||||||
|
import { PrePlantingConfigService } from './pre-planting/pre-planting-config.service';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
|
|
@ -127,6 +130,9 @@ import { ContractService } from './application/services/contract.service';
|
||||||
PublicCustomerServiceContactController,
|
PublicCustomerServiceContactController,
|
||||||
// [2026-02-05] 新增:合同管理控制器
|
// [2026-02-05] 新增:合同管理控制器
|
||||||
ContractController,
|
ContractController,
|
||||||
|
// [2026-02-17] 新增:预种计划开关管理
|
||||||
|
PrePlantingConfigController,
|
||||||
|
PublicPrePlantingConfigController,
|
||||||
],
|
],
|
||||||
providers: [
|
providers: [
|
||||||
PrismaService,
|
PrismaService,
|
||||||
|
|
@ -216,6 +222,8 @@ import { ContractService } from './application/services/contract.service';
|
||||||
},
|
},
|
||||||
// [2026-02-05] 新增:合同管理服务
|
// [2026-02-05] 新增:合同管理服务
|
||||||
ContractService,
|
ContractService,
|
||||||
|
// [2026-02-17] 新增:预种计划开关管理
|
||||||
|
PrePlantingConfigService,
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
export class AppModule {}
|
export class AppModule {}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,55 @@
|
||||||
|
import {
|
||||||
|
Controller,
|
||||||
|
Get,
|
||||||
|
Post,
|
||||||
|
Body,
|
||||||
|
HttpCode,
|
||||||
|
HttpStatus,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { ApiTags, ApiOperation, ApiResponse } from '@nestjs/swagger';
|
||||||
|
import { PrePlantingConfigService } from './pre-planting-config.service';
|
||||||
|
|
||||||
|
class UpdatePrePlantingConfigDto {
|
||||||
|
isActive: boolean;
|
||||||
|
updatedBy?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
@ApiTags('预种计划配置')
|
||||||
|
@Controller('admin/pre-planting')
|
||||||
|
export class PrePlantingConfigController {
|
||||||
|
constructor(
|
||||||
|
private readonly configService: PrePlantingConfigService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
@Get('config')
|
||||||
|
@ApiOperation({ summary: '获取预种计划开关状态' })
|
||||||
|
@ApiResponse({ status: HttpStatus.OK, description: '开关状态' })
|
||||||
|
async getConfig() {
|
||||||
|
return this.configService.getConfig();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('config')
|
||||||
|
@HttpCode(HttpStatus.OK)
|
||||||
|
@ApiOperation({ summary: '更新预种计划开关状态' })
|
||||||
|
@ApiResponse({ status: HttpStatus.OK, description: '更新成功' })
|
||||||
|
async updateConfig(@Body() dto: UpdatePrePlantingConfigDto) {
|
||||||
|
return this.configService.updateConfig(dto.isActive, dto.updatedBy);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 公开 API(供 planting-service 调用)
|
||||||
|
*/
|
||||||
|
@ApiTags('预种计划配置-内部API')
|
||||||
|
@Controller('api/v1/admin/pre-planting')
|
||||||
|
export class PublicPrePlantingConfigController {
|
||||||
|
constructor(
|
||||||
|
private readonly configService: PrePlantingConfigService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
@Get('config')
|
||||||
|
@ApiOperation({ summary: '获取预种计划开关状态(内部API)' })
|
||||||
|
async getConfig() {
|
||||||
|
return this.configService.getConfig();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,78 @@
|
||||||
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
|
import { PrismaService } from '../infrastructure/persistence/prisma/prisma.service';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class PrePlantingConfigService {
|
||||||
|
private readonly logger = new Logger(PrePlantingConfigService.name);
|
||||||
|
|
||||||
|
constructor(private readonly prisma: PrismaService) {}
|
||||||
|
|
||||||
|
async getConfig(): Promise<{
|
||||||
|
isActive: boolean;
|
||||||
|
activatedAt: Date | null;
|
||||||
|
}> {
|
||||||
|
const config = await this.prisma.prePlantingConfig.findFirst({
|
||||||
|
orderBy: { updatedAt: 'desc' },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!config) {
|
||||||
|
return { isActive: false, activatedAt: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
isActive: config.isActive,
|
||||||
|
activatedAt: config.activatedAt,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateConfig(
|
||||||
|
isActive: boolean,
|
||||||
|
updatedBy?: string,
|
||||||
|
): Promise<{
|
||||||
|
isActive: boolean;
|
||||||
|
activatedAt: Date | null;
|
||||||
|
}> {
|
||||||
|
const existing = await this.prisma.prePlantingConfig.findFirst({
|
||||||
|
orderBy: { updatedAt: 'desc' },
|
||||||
|
});
|
||||||
|
|
||||||
|
const activatedAt = isActive ? new Date() : null;
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
const updated = await this.prisma.prePlantingConfig.update({
|
||||||
|
where: { id: existing.id },
|
||||||
|
data: {
|
||||||
|
isActive,
|
||||||
|
activatedAt: isActive ? (existing.activatedAt || activatedAt) : existing.activatedAt,
|
||||||
|
updatedBy: updatedBy || null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
this.logger.log(
|
||||||
|
`[PRE-PLANTING] Config updated: isActive=${updated.isActive} by ${updatedBy || 'unknown'}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
isActive: updated.isActive,
|
||||||
|
activatedAt: updated.activatedAt,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const created = await this.prisma.prePlantingConfig.create({
|
||||||
|
data: {
|
||||||
|
isActive,
|
||||||
|
activatedAt,
|
||||||
|
updatedBy: updatedBy || null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
this.logger.log(
|
||||||
|
`[PRE-PLANTING] Config created: isActive=${created.isActive} by ${updatedBy || 'unknown'}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
isActive: created.isActive,
|
||||||
|
activatedAt: created.activatedAt,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -52,6 +52,8 @@ import {
|
||||||
|
|
||||||
// Shared
|
// Shared
|
||||||
import { JwtStrategy } from '@/shared/strategies'
|
import { JwtStrategy } from '@/shared/strategies'
|
||||||
|
// [2026-02-17] 新增:预种计划授权限制
|
||||||
|
import { PrePlantingGuardModule } from './pre-planting/pre-planting-guard.module'
|
||||||
|
|
||||||
// Mock repositories for external services (should be replaced with actual implementations)
|
// Mock repositories for external services (should be replaced with actual implementations)
|
||||||
const MockReferralRepository = {
|
const MockReferralRepository = {
|
||||||
|
|
@ -79,6 +81,8 @@ const MockReferralRepository = {
|
||||||
}),
|
}),
|
||||||
RedisModule,
|
RedisModule,
|
||||||
KafkaModule,
|
KafkaModule,
|
||||||
|
// [2026-02-17] 新增:预种计划授权限制
|
||||||
|
PrePlantingGuardModule,
|
||||||
],
|
],
|
||||||
controllers: [
|
controllers: [
|
||||||
AuthorizationController,
|
AuthorizationController,
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,81 @@
|
||||||
|
import {
|
||||||
|
Injectable,
|
||||||
|
NestInterceptor,
|
||||||
|
ExecutionContext,
|
||||||
|
CallHandler,
|
||||||
|
ForbiddenException,
|
||||||
|
Logger,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { Observable } from 'rxjs';
|
||||||
|
import { PrePlantingClient } from './pre-planting.client';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 预种授权申请拦截器
|
||||||
|
*
|
||||||
|
* 仅拦截用户端授权申请路由(POST /authorizations/...)
|
||||||
|
* 规则:
|
||||||
|
* - 无预种记录(纯认种用户)→ 直接放行
|
||||||
|
* - 有预种记录且已合并成树 → 放行
|
||||||
|
* - 有预种记录但未合并 → 拦截
|
||||||
|
* - planting-service 不可达 → 放行(fail-open,不影响现有功能)
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class PrePlantingAuthorizationInterceptor implements NestInterceptor {
|
||||||
|
private readonly logger = new Logger(PrePlantingAuthorizationInterceptor.name);
|
||||||
|
|
||||||
|
private readonly protectedPaths = [
|
||||||
|
'/authorizations/community',
|
||||||
|
'/authorizations/province',
|
||||||
|
'/authorizations/city',
|
||||||
|
'/authorizations/self-apply',
|
||||||
|
];
|
||||||
|
|
||||||
|
constructor(private readonly client: PrePlantingClient) {}
|
||||||
|
|
||||||
|
async intercept(
|
||||||
|
context: ExecutionContext,
|
||||||
|
next: CallHandler,
|
||||||
|
): Promise<Observable<unknown>> {
|
||||||
|
const req = context.switchToHttp().getRequest();
|
||||||
|
|
||||||
|
// 仅拦截 POST 请求
|
||||||
|
if (req.method !== 'POST') {
|
||||||
|
return next.handle();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 仅拦截特定路由
|
||||||
|
const reqPath: string = req.path || req.url || '';
|
||||||
|
if (!this.protectedPaths.some((p) => reqPath.endsWith(p))) {
|
||||||
|
return next.handle();
|
||||||
|
}
|
||||||
|
|
||||||
|
const accountSequence = req.user?.accountSequence;
|
||||||
|
if (!accountSequence) {
|
||||||
|
return next.handle();
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const eligibility = await this.client.getEligibility(accountSequence);
|
||||||
|
|
||||||
|
// 无预种记录 → 纯认种用户,直接放行
|
||||||
|
if (!eligibility.hasPrePlanting) {
|
||||||
|
return next.handle();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 有预种但未满足条件 → 拦截
|
||||||
|
if (!eligibility.canApplyAuthorization) {
|
||||||
|
throw new ForbiddenException(
|
||||||
|
'须累积购买5份预种计划合并成树后方可申请授权',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof ForbiddenException) throw error;
|
||||||
|
// planting-service 不可达,默认放行
|
||||||
|
this.logger.warn(
|
||||||
|
`[PRE-PLANTING] Failed to check eligibility for ${accountSequence}, allowing through`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return next.handle();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,22 @@
|
||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { APP_INTERCEPTOR } from '@nestjs/core';
|
||||||
|
import { PrePlantingClient } from './pre-planting.client';
|
||||||
|
import { PrePlantingAuthorizationInterceptor } from './pre-planting-guard.interceptor';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 预种授权限制模块
|
||||||
|
*
|
||||||
|
* 注册路由级 Interceptor,仅对授权申请路由生效
|
||||||
|
* 未合并成树的预种用户不可申请社区/省/市授权
|
||||||
|
*/
|
||||||
|
@Module({
|
||||||
|
providers: [
|
||||||
|
PrePlantingClient,
|
||||||
|
PrePlantingAuthorizationInterceptor,
|
||||||
|
{
|
||||||
|
provide: APP_INTERCEPTOR,
|
||||||
|
useClass: PrePlantingAuthorizationInterceptor,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})
|
||||||
|
export class PrePlantingGuardModule {}
|
||||||
|
|
@ -0,0 +1,35 @@
|
||||||
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
import axios from 'axios';
|
||||||
|
|
||||||
|
export interface PrePlantingEligibility {
|
||||||
|
hasPrePlanting: boolean;
|
||||||
|
totalPortions: number;
|
||||||
|
totalTreesMerged: number;
|
||||||
|
canApplyAuthorization: boolean;
|
||||||
|
canTrade: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 调用 planting-service 内部 API 查询预种资格
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class PrePlantingClient {
|
||||||
|
private readonly logger = new Logger(PrePlantingClient.name);
|
||||||
|
private readonly baseUrl: string;
|
||||||
|
|
||||||
|
constructor(private readonly configService: ConfigService) {
|
||||||
|
this.baseUrl = this.configService.get<string>(
|
||||||
|
'PLANTING_SERVICE_URL',
|
||||||
|
'http://localhost:3003',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async getEligibility(accountSequence: string): Promise<PrePlantingEligibility> {
|
||||||
|
const url = `${this.baseUrl}/internal/pre-planting/eligibility/${accountSequence}`;
|
||||||
|
const response = await axios.get<PrePlantingEligibility>(url, {
|
||||||
|
timeout: 3000,
|
||||||
|
});
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -390,3 +390,148 @@ model DebeziumHeartbeat {
|
||||||
|
|
||||||
@@map("debezium_heartbeat")
|
@@map("debezium_heartbeat")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// 预种订单表 (3171 预种计划)
|
||||||
|
// 每次购买一份预种创建一条记录
|
||||||
|
// ============================================
|
||||||
|
model PrePlantingOrder {
|
||||||
|
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)
|
||||||
|
|
||||||
|
// 购买信息
|
||||||
|
portionCount Int @default(1) @map("portion_count")
|
||||||
|
pricePerPortion Decimal @default(3171) @map("price_per_portion") @db.Decimal(20, 8)
|
||||||
|
totalAmount Decimal @map("total_amount") @db.Decimal(20, 8)
|
||||||
|
|
||||||
|
// 省市选择 (购买时即选择,后续复用)
|
||||||
|
provinceCode String @map("province_code") @db.VarChar(10)
|
||||||
|
cityCode String @map("city_code") @db.VarChar(10)
|
||||||
|
|
||||||
|
// 订单状态: CREATED → PAID → MERGED
|
||||||
|
status String @default("CREATED") @map("status") @db.VarChar(20)
|
||||||
|
|
||||||
|
// 合并关联
|
||||||
|
mergedToMergeId BigInt? @map("merged_to_merge_id")
|
||||||
|
mergedAt DateTime? @map("merged_at")
|
||||||
|
|
||||||
|
// 时间戳
|
||||||
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
|
paidAt DateTime? @map("paid_at")
|
||||||
|
updatedAt DateTime @updatedAt @map("updated_at")
|
||||||
|
|
||||||
|
@@index([userId])
|
||||||
|
@@index([accountSequence])
|
||||||
|
@@index([orderNo])
|
||||||
|
@@index([status])
|
||||||
|
@@index([mergedToMergeId])
|
||||||
|
@@index([createdAt])
|
||||||
|
@@map("pre_planting_orders")
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// 预种持仓表 (每用户一条)
|
||||||
|
// 跟踪用户累计购买份数和合并状态
|
||||||
|
// ============================================
|
||||||
|
model PrePlantingPosition {
|
||||||
|
id BigInt @id @default(autoincrement()) @map("position_id")
|
||||||
|
userId BigInt @unique @map("user_id")
|
||||||
|
accountSequence String @unique @map("account_sequence") @db.VarChar(20)
|
||||||
|
|
||||||
|
// 持仓统计
|
||||||
|
totalPortions Int @default(0) @map("total_portions")
|
||||||
|
availablePortions Int @default(0) @map("available_portions")
|
||||||
|
mergedPortions Int @default(0) @map("merged_portions")
|
||||||
|
totalTreesMerged Int @default(0) @map("total_trees_merged")
|
||||||
|
|
||||||
|
// 省市 (首次购买时选择,后续复用)
|
||||||
|
provinceCode String? @map("province_code") @db.VarChar(10)
|
||||||
|
cityCode String? @map("city_code") @db.VarChar(10)
|
||||||
|
|
||||||
|
// 首次购买时间 (1年冻结起点)
|
||||||
|
firstPurchaseAt DateTime? @map("first_purchase_at")
|
||||||
|
|
||||||
|
// 时间戳
|
||||||
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
|
updatedAt DateTime @updatedAt @map("updated_at")
|
||||||
|
|
||||||
|
@@index([userId])
|
||||||
|
@@index([accountSequence])
|
||||||
|
@@index([totalTreesMerged])
|
||||||
|
@@map("pre_planting_positions")
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// 预种合并记录表
|
||||||
|
// 5 份预种合并为 1 棵树,不进入现有 planting_orders 表
|
||||||
|
// ============================================
|
||||||
|
model PrePlantingMerge {
|
||||||
|
id BigInt @id @default(autoincrement()) @map("merge_id")
|
||||||
|
mergeNo String @unique @map("merge_no") @db.VarChar(50)
|
||||||
|
userId BigInt @map("user_id")
|
||||||
|
accountSequence String @map("account_sequence") @db.VarChar(20)
|
||||||
|
|
||||||
|
// 合并来源
|
||||||
|
sourceOrderNos Json @map("source_order_nos")
|
||||||
|
treeCount Int @default(1) @map("tree_count")
|
||||||
|
|
||||||
|
// 省市 (从 PrePlantingPosition 带入)
|
||||||
|
provinceCode String? @map("province_code") @db.VarChar(10)
|
||||||
|
cityCode String? @map("city_code") @db.VarChar(10)
|
||||||
|
|
||||||
|
// 合同签署: PENDING → SIGNED → EXPIRED
|
||||||
|
contractStatus String @default("PENDING") @map("contract_status") @db.VarChar(20)
|
||||||
|
contractSignedAt DateTime? @map("contract_signed_at")
|
||||||
|
|
||||||
|
// 挖矿
|
||||||
|
miningEnabledAt DateTime? @map("mining_enabled_at")
|
||||||
|
|
||||||
|
// 时间戳
|
||||||
|
mergedAt DateTime @default(now()) @map("merged_at")
|
||||||
|
updatedAt DateTime @updatedAt @map("updated_at")
|
||||||
|
|
||||||
|
@@index([userId])
|
||||||
|
@@index([accountSequence])
|
||||||
|
@@index([mergeNo])
|
||||||
|
@@index([contractStatus])
|
||||||
|
@@map("pre_planting_merges")
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// 预种分配记录表
|
||||||
|
// 记录每笔预种订单的 10 类权益分配明细
|
||||||
|
// 独立于 reward-service,不经过现有分配流程
|
||||||
|
// ============================================
|
||||||
|
model PrePlantingRewardEntry {
|
||||||
|
id BigInt @id @default(autoincrement()) @map("entry_id")
|
||||||
|
|
||||||
|
// 来源
|
||||||
|
sourceOrderNo String @map("source_order_no") @db.VarChar(50)
|
||||||
|
sourceAccountSequence String @map("source_account_sequence") @db.VarChar(20)
|
||||||
|
|
||||||
|
// 接收者
|
||||||
|
recipientAccountSequence String @map("recipient_account_sequence") @db.VarChar(20)
|
||||||
|
|
||||||
|
// 权益信息
|
||||||
|
rightType String @map("right_type") @db.VarChar(50)
|
||||||
|
usdtAmount Decimal @map("usdt_amount") @db.Decimal(20, 8)
|
||||||
|
|
||||||
|
// 状态: SETTLED / PENDING / EXPIRED
|
||||||
|
rewardStatus String @default("SETTLED") @map("reward_status") @db.VarChar(20)
|
||||||
|
|
||||||
|
// 备注
|
||||||
|
memo String? @map("memo") @db.Text
|
||||||
|
|
||||||
|
// 时间戳
|
||||||
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
|
|
||||||
|
@@index([sourceOrderNo])
|
||||||
|
@@index([sourceAccountSequence])
|
||||||
|
@@index([recipientAccountSequence])
|
||||||
|
@@index([rightType])
|
||||||
|
@@index([rewardStatus])
|
||||||
|
@@index([createdAt])
|
||||||
|
@@map("pre_planting_reward_entries")
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,8 @@ import { InfrastructureModule } from './infrastructure/infrastructure.module';
|
||||||
import { DomainModule } from './domain/domain.module';
|
import { DomainModule } from './domain/domain.module';
|
||||||
import { ApplicationModule } from './application/application.module';
|
import { ApplicationModule } from './application/application.module';
|
||||||
import { ApiModule } from './api/api.module';
|
import { ApiModule } from './api/api.module';
|
||||||
|
// [2026-02-17] 新增:3171 预种计划模块(纯新增,与现有 PlantingOrder 零耦合)
|
||||||
|
import { PrePlantingModule } from './pre-planting/pre-planting.module';
|
||||||
import { GlobalExceptionFilter } from './shared/filters/global-exception.filter';
|
import { GlobalExceptionFilter } from './shared/filters/global-exception.filter';
|
||||||
import configs from './config';
|
import configs from './config';
|
||||||
|
|
||||||
|
|
@ -19,6 +21,7 @@ import configs from './config';
|
||||||
DomainModule,
|
DomainModule,
|
||||||
ApplicationModule,
|
ApplicationModule,
|
||||||
ApiModule,
|
ApiModule,
|
||||||
|
PrePlantingModule, // 预种计划:独立聚合根、独立 Kafka Topic、独立数据表
|
||||||
],
|
],
|
||||||
providers: [
|
providers: [
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,31 @@
|
||||||
|
import {
|
||||||
|
Controller,
|
||||||
|
Get,
|
||||||
|
Param,
|
||||||
|
HttpStatus,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import {
|
||||||
|
ApiTags,
|
||||||
|
ApiOperation,
|
||||||
|
ApiResponse,
|
||||||
|
ApiParam,
|
||||||
|
} from '@nestjs/swagger';
|
||||||
|
import { PrePlantingApplicationService } from '../../application/services/pre-planting-application.service';
|
||||||
|
|
||||||
|
@ApiTags('预种计划-内部API')
|
||||||
|
@Controller('internal/pre-planting')
|
||||||
|
export class InternalPrePlantingController {
|
||||||
|
constructor(
|
||||||
|
private readonly prePlantingService: PrePlantingApplicationService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
@Get('eligibility/:accountSequence')
|
||||||
|
@ApiOperation({ summary: '查询预种资格信息(内部API)' })
|
||||||
|
@ApiParam({ name: 'accountSequence', description: '用户账户序列号' })
|
||||||
|
@ApiResponse({ status: HttpStatus.OK, description: '资格信息' })
|
||||||
|
async getEligibility(
|
||||||
|
@Param('accountSequence') accountSequence: string,
|
||||||
|
) {
|
||||||
|
return this.prePlantingService.getEligibility(accountSequence);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,92 @@
|
||||||
|
import {
|
||||||
|
Controller,
|
||||||
|
Post,
|
||||||
|
Get,
|
||||||
|
Body,
|
||||||
|
UseGuards,
|
||||||
|
Req,
|
||||||
|
HttpCode,
|
||||||
|
HttpStatus,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import {
|
||||||
|
ApiTags,
|
||||||
|
ApiOperation,
|
||||||
|
ApiResponse,
|
||||||
|
ApiBearerAuth,
|
||||||
|
} from '@nestjs/swagger';
|
||||||
|
import { PrePlantingApplicationService } from '../../application/services/pre-planting-application.service';
|
||||||
|
import { PurchasePrePlantingDto } from '../dto/request/purchase-pre-planting.dto';
|
||||||
|
import { SignPrePlantingContractDto } from '../dto/request/sign-pre-planting-contract.dto';
|
||||||
|
import { JwtAuthGuard } from '../../../api/guards/jwt-auth.guard';
|
||||||
|
|
||||||
|
interface AuthenticatedRequest {
|
||||||
|
user: { id: string; accountSequence: string };
|
||||||
|
}
|
||||||
|
|
||||||
|
@ApiTags('预种计划')
|
||||||
|
@ApiBearerAuth()
|
||||||
|
@Controller('pre-planting')
|
||||||
|
@UseGuards(JwtAuthGuard)
|
||||||
|
export class PrePlantingController {
|
||||||
|
constructor(
|
||||||
|
private readonly prePlantingService: PrePlantingApplicationService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
@Post('purchase')
|
||||||
|
@HttpCode(HttpStatus.CREATED)
|
||||||
|
@ApiOperation({ summary: '购买预种份额' })
|
||||||
|
@ApiResponse({ status: HttpStatus.CREATED, description: '购买成功' })
|
||||||
|
@ApiResponse({ status: HttpStatus.BAD_REQUEST, description: '参数错误或校验失败' })
|
||||||
|
async purchase(
|
||||||
|
@Req() req: AuthenticatedRequest,
|
||||||
|
@Body() dto: PurchasePrePlantingDto,
|
||||||
|
) {
|
||||||
|
const userId = BigInt(req.user.id);
|
||||||
|
const accountSequence = req.user.accountSequence;
|
||||||
|
return this.prePlantingService.purchasePortion(
|
||||||
|
userId,
|
||||||
|
accountSequence,
|
||||||
|
dto.portionCount,
|
||||||
|
dto.provinceCode,
|
||||||
|
dto.cityCode,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('sign-contract')
|
||||||
|
@HttpCode(HttpStatus.OK)
|
||||||
|
@ApiOperation({ summary: '签署合并后的合同' })
|
||||||
|
@ApiResponse({ status: HttpStatus.OK, description: '签署成功' })
|
||||||
|
@ApiResponse({ status: HttpStatus.NOT_FOUND, description: '合并记录不存在' })
|
||||||
|
async signContract(
|
||||||
|
@Req() req: AuthenticatedRequest,
|
||||||
|
@Body() dto: SignPrePlantingContractDto,
|
||||||
|
) {
|
||||||
|
const userId = BigInt(req.user.id);
|
||||||
|
await this.prePlantingService.signContract(userId, dto.mergeNo);
|
||||||
|
return { success: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('position')
|
||||||
|
@ApiOperation({ summary: '获取预种持仓信息' })
|
||||||
|
@ApiResponse({ status: HttpStatus.OK, description: '持仓信息' })
|
||||||
|
async getPosition(@Req() req: AuthenticatedRequest) {
|
||||||
|
const userId = BigInt(req.user.id);
|
||||||
|
return this.prePlantingService.getPosition(userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('orders')
|
||||||
|
@ApiOperation({ summary: '获取预种订单列表' })
|
||||||
|
@ApiResponse({ status: HttpStatus.OK, description: '订单列表' })
|
||||||
|
async getOrders(@Req() req: AuthenticatedRequest) {
|
||||||
|
const userId = BigInt(req.user.id);
|
||||||
|
return this.prePlantingService.getOrders(userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('merges')
|
||||||
|
@ApiOperation({ summary: '获取合并记录列表' })
|
||||||
|
@ApiResponse({ status: HttpStatus.OK, description: '合并记录列表' })
|
||||||
|
async getMerges(@Req() req: AuthenticatedRequest) {
|
||||||
|
const userId = BigInt(req.user.id);
|
||||||
|
return this.prePlantingService.getMerges(userId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,20 @@
|
||||||
|
import { IsInt, IsString, Min, Max, IsNotEmpty } from 'class-validator';
|
||||||
|
import { ApiProperty } from '@nestjs/swagger';
|
||||||
|
|
||||||
|
export class PurchasePrePlantingDto {
|
||||||
|
@ApiProperty({ description: '购买份数', example: 1, minimum: 1, maximum: 5 })
|
||||||
|
@IsInt()
|
||||||
|
@Min(1)
|
||||||
|
@Max(5)
|
||||||
|
portionCount: number;
|
||||||
|
|
||||||
|
@ApiProperty({ description: '省代码', example: '44' })
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty()
|
||||||
|
provinceCode: string;
|
||||||
|
|
||||||
|
@ApiProperty({ description: '市代码', example: '4401' })
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty()
|
||||||
|
cityCode: string;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,9 @@
|
||||||
|
import { IsString, IsNotEmpty } from 'class-validator';
|
||||||
|
import { ApiProperty } from '@nestjs/swagger';
|
||||||
|
|
||||||
|
export class SignPrePlantingContractDto {
|
||||||
|
@ApiProperty({ description: '合并记录编号', example: 'PMG...' })
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty()
|
||||||
|
mergeNo: string;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,427 @@
|
||||||
|
import { Injectable, Logger, BadRequestException, NotFoundException } from '@nestjs/common';
|
||||||
|
import { PrismaService } from '../../../infrastructure/persistence/prisma/prisma.service';
|
||||||
|
import { OutboxRepository, OutboxEventData } from '../../../infrastructure/persistence/repositories/outbox.repository';
|
||||||
|
import { WalletServiceClient } from '../../../infrastructure/external/wallet-service.client';
|
||||||
|
import { PrePlantingOrder } from '../../domain/aggregates/pre-planting-order.aggregate';
|
||||||
|
import { PrePlantingMerge } from '../../domain/aggregates/pre-planting-merge.aggregate';
|
||||||
|
import { PrePlantingOrderStatus } from '../../domain/value-objects/pre-planting-order-status.enum';
|
||||||
|
import {
|
||||||
|
PRE_PLANTING_PRICE_PER_PORTION,
|
||||||
|
PRE_PLANTING_PORTIONS_PER_TREE,
|
||||||
|
} from '../../domain/value-objects/pre-planting-right-amounts';
|
||||||
|
import { PrePlantingOrderRepository } from '../../infrastructure/repositories/pre-planting-order.repository';
|
||||||
|
import { PrePlantingPositionRepository } from '../../infrastructure/repositories/pre-planting-position.repository';
|
||||||
|
import { PrePlantingMergeRepository } from '../../infrastructure/repositories/pre-planting-merge.repository';
|
||||||
|
import { PrePlantingRewardService } from './pre-planting-reward.service';
|
||||||
|
import { PrePlantingAdminClient } from '../../infrastructure/external/pre-planting-admin.client';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class PrePlantingApplicationService {
|
||||||
|
private readonly logger = new Logger(PrePlantingApplicationService.name);
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly prisma: PrismaService,
|
||||||
|
private readonly outboxRepo: OutboxRepository,
|
||||||
|
private readonly walletClient: WalletServiceClient,
|
||||||
|
private readonly orderRepo: PrePlantingOrderRepository,
|
||||||
|
private readonly positionRepo: PrePlantingPositionRepository,
|
||||||
|
private readonly mergeRepo: PrePlantingMergeRepository,
|
||||||
|
private readonly rewardService: PrePlantingRewardService,
|
||||||
|
private readonly adminClient: PrePlantingAdminClient,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 购买预种份额
|
||||||
|
*
|
||||||
|
* Flow: 校验 → 冻结余额 → 事务(创建订单+更新持仓+分配记录+outbox) → 确认扣款+分配 → 检查合并
|
||||||
|
*/
|
||||||
|
async purchasePortion(
|
||||||
|
userId: bigint,
|
||||||
|
accountSequence: string,
|
||||||
|
portionCount: number,
|
||||||
|
provinceCode: string,
|
||||||
|
cityCode: string,
|
||||||
|
): Promise<{ orderNo: string; merged: boolean; mergeNo?: string }> {
|
||||||
|
this.logger.log(
|
||||||
|
`[PRE-PLANTING] Purchase request: userId=${userId}, portions=${portionCount}, ` +
|
||||||
|
`province=${provinceCode}, city=${cityCode}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Step 1: 前置校验
|
||||||
|
await this.validatePurchase(userId, portionCount);
|
||||||
|
|
||||||
|
const orderNo = this.generateOrderNo();
|
||||||
|
const totalAmount = portionCount * PRE_PLANTING_PRICE_PER_PORTION;
|
||||||
|
|
||||||
|
// Step 2: 冻结余额
|
||||||
|
await this.walletClient.freezeForPlanting({
|
||||||
|
userId: userId.toString(),
|
||||||
|
accountSequence,
|
||||||
|
amount: totalAmount,
|
||||||
|
orderId: orderNo,
|
||||||
|
});
|
||||||
|
|
||||||
|
let merged = false;
|
||||||
|
let mergeNo: string | undefined;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Step 3-4: 事务内处理(创建订单 + 更新持仓 + 分配记录 + outbox)
|
||||||
|
await this.prisma.$transaction(async (tx) => {
|
||||||
|
// 创建预种订单
|
||||||
|
const order = PrePlantingOrder.create(
|
||||||
|
orderNo,
|
||||||
|
userId,
|
||||||
|
accountSequence,
|
||||||
|
portionCount,
|
||||||
|
provinceCode,
|
||||||
|
cityCode,
|
||||||
|
);
|
||||||
|
|
||||||
|
// 获取或创建持仓
|
||||||
|
const position = await this.positionRepo.getOrCreate(tx, userId, accountSequence);
|
||||||
|
|
||||||
|
// 续购时验证省市一致性
|
||||||
|
if (position.provinceCode && position.provinceCode !== provinceCode) {
|
||||||
|
throw new BadRequestException('续购必须与首次购买选择相同省份');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 增加份数
|
||||||
|
position.addPortions(portionCount, provinceCode, cityCode);
|
||||||
|
|
||||||
|
// 标记订单为已支付
|
||||||
|
order.markAsPaid(position.totalPortions, position.availablePortions);
|
||||||
|
|
||||||
|
// 持久化
|
||||||
|
await this.orderRepo.save(tx, order);
|
||||||
|
await this.positionRepo.save(tx, position);
|
||||||
|
|
||||||
|
// 分配 10 类权益(在事务内记录,事务外执行转账)
|
||||||
|
await this.rewardService.distributeRewards(
|
||||||
|
tx,
|
||||||
|
orderNo,
|
||||||
|
accountSequence,
|
||||||
|
provinceCode,
|
||||||
|
cityCode,
|
||||||
|
portionCount,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Outbox: 购买事件(包装为 { eventName, data } 格式,与现有 planting 事件一致)
|
||||||
|
const outboxEvents: OutboxEventData[] = order.domainEvents.map((event) => ({
|
||||||
|
eventType: event.type,
|
||||||
|
topic: 'pre-planting.portion.purchased',
|
||||||
|
key: accountSequence,
|
||||||
|
payload: {
|
||||||
|
eventName: 'pre-planting.portion.purchased',
|
||||||
|
data: event.data,
|
||||||
|
} as Record<string, unknown>,
|
||||||
|
aggregateId: event.aggregateId,
|
||||||
|
aggregateType: event.aggregateType,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Step 6: 检查是否触发合并
|
||||||
|
if (position.canMerge()) {
|
||||||
|
const mergeResult = await this.performMerge(tx, userId, accountSequence, position);
|
||||||
|
merged = true;
|
||||||
|
mergeNo = mergeResult.mergeNo;
|
||||||
|
|
||||||
|
// 合并事件也写入 Outbox
|
||||||
|
for (const event of mergeResult.domainEvents) {
|
||||||
|
outboxEvents.push({
|
||||||
|
eventType: event.type,
|
||||||
|
topic: 'pre-planting.merged',
|
||||||
|
key: accountSequence,
|
||||||
|
payload: {
|
||||||
|
eventName: 'pre-planting.merged',
|
||||||
|
data: event.data,
|
||||||
|
} as Record<string, unknown>,
|
||||||
|
aggregateId: event.aggregateId,
|
||||||
|
aggregateType: event.aggregateType,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.outboxRepo.saveInTransaction(tx, outboxEvents);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Step 5: 确认扣款(事务成功后)
|
||||||
|
await this.walletClient.confirmPlantingDeduction({
|
||||||
|
userId: userId.toString(),
|
||||||
|
accountSequence,
|
||||||
|
orderId: orderNo,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
// 事务失败,解冻余额
|
||||||
|
this.logger.error(
|
||||||
|
`[PRE-PLANTING] Purchase failed for order ${orderNo}, unfreezing`,
|
||||||
|
error,
|
||||||
|
);
|
||||||
|
await this.walletClient.unfreezeForPlanting({
|
||||||
|
userId: userId.toString(),
|
||||||
|
accountSequence,
|
||||||
|
orderId: orderNo,
|
||||||
|
}).catch((unfreezeErr) => {
|
||||||
|
this.logger.error(
|
||||||
|
`[PRE-PLANTING] Failed to unfreeze for order ${orderNo}`,
|
||||||
|
unfreezeErr,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.logger.log(
|
||||||
|
`[PRE-PLANTING] Purchase completed: order=${orderNo}, merged=${merged}${mergeNo ? `, mergeNo=${mergeNo}` : ''}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
return { orderNo, merged, mergeNo };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 签署合并后的合同
|
||||||
|
*/
|
||||||
|
async signContract(
|
||||||
|
userId: bigint,
|
||||||
|
mergeNo: string,
|
||||||
|
): Promise<void> {
|
||||||
|
this.logger.log(`[PRE-PLANTING] Sign contract: userId=${userId}, mergeNo=${mergeNo}`);
|
||||||
|
|
||||||
|
await this.prisma.$transaction(async (tx) => {
|
||||||
|
const merge = await this.mergeRepo.findByMergeNo(tx, mergeNo);
|
||||||
|
if (!merge) {
|
||||||
|
throw new NotFoundException(`合并记录 ${mergeNo} 不存在`);
|
||||||
|
}
|
||||||
|
if (merge.userId !== userId) {
|
||||||
|
throw new BadRequestException('无权操作此合并记录');
|
||||||
|
}
|
||||||
|
|
||||||
|
merge.signContract();
|
||||||
|
await this.mergeRepo.save(tx, merge);
|
||||||
|
|
||||||
|
// Outbox: 合同签署事件
|
||||||
|
const outboxEvents: OutboxEventData[] = merge.domainEvents.map((event) => ({
|
||||||
|
eventType: event.type,
|
||||||
|
topic: 'pre-planting.contract.signed',
|
||||||
|
key: merge.accountSequence,
|
||||||
|
payload: {
|
||||||
|
eventName: 'pre-planting.contract.signed',
|
||||||
|
data: event.data,
|
||||||
|
} as Record<string, unknown>,
|
||||||
|
aggregateId: event.aggregateId,
|
||||||
|
aggregateType: event.aggregateType,
|
||||||
|
}));
|
||||||
|
await this.outboxRepo.saveInTransaction(tx, outboxEvents);
|
||||||
|
});
|
||||||
|
|
||||||
|
// 事务成功后,设置 hasPlanted=true(调用 wallet-service)
|
||||||
|
// TODO: 调用 wallet-service 设置 hasPlanted
|
||||||
|
this.logger.log(`[PRE-PLANTING] Contract signed: mergeNo=${mergeNo}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取用户预种持仓信息
|
||||||
|
*/
|
||||||
|
async getPosition(userId: bigint): Promise<{
|
||||||
|
totalPortions: number;
|
||||||
|
availablePortions: number;
|
||||||
|
mergedPortions: number;
|
||||||
|
totalTreesMerged: number;
|
||||||
|
provinceCode: string | null;
|
||||||
|
cityCode: string | null;
|
||||||
|
firstPurchaseAt: Date | null;
|
||||||
|
} | null> {
|
||||||
|
return this.prisma.$transaction(async (tx) => {
|
||||||
|
const position = await this.positionRepo.findByUserId(tx, userId);
|
||||||
|
if (!position) return null;
|
||||||
|
return {
|
||||||
|
totalPortions: position.totalPortions,
|
||||||
|
availablePortions: position.availablePortions,
|
||||||
|
mergedPortions: position.mergedPortions,
|
||||||
|
totalTreesMerged: position.totalTreesMerged,
|
||||||
|
provinceCode: position.provinceCode,
|
||||||
|
cityCode: position.cityCode,
|
||||||
|
firstPurchaseAt: position.firstPurchaseAt,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取用户预种订单列表
|
||||||
|
*/
|
||||||
|
async getOrders(userId: bigint): Promise<{
|
||||||
|
orderNo: string;
|
||||||
|
portionCount: number;
|
||||||
|
totalAmount: number;
|
||||||
|
provinceCode: string;
|
||||||
|
cityCode: string;
|
||||||
|
status: string;
|
||||||
|
createdAt: Date;
|
||||||
|
paidAt: Date | null;
|
||||||
|
mergedAt: Date | null;
|
||||||
|
}[]> {
|
||||||
|
return this.prisma.$transaction(async (tx) => {
|
||||||
|
const orders = await this.orderRepo.findByUserId(tx, userId);
|
||||||
|
return orders.map((o) => ({
|
||||||
|
orderNo: o.orderNo,
|
||||||
|
portionCount: o.portionCount,
|
||||||
|
totalAmount: o.totalAmount,
|
||||||
|
provinceCode: o.provinceCode,
|
||||||
|
cityCode: o.cityCode,
|
||||||
|
status: o.status,
|
||||||
|
createdAt: o.createdAt,
|
||||||
|
paidAt: o.paidAt,
|
||||||
|
mergedAt: o.mergedAt,
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取用户合并记录列表
|
||||||
|
*/
|
||||||
|
async getMerges(userId: bigint): Promise<{
|
||||||
|
mergeNo: string;
|
||||||
|
sourceOrderNos: string[];
|
||||||
|
treeCount: number;
|
||||||
|
contractStatus: string;
|
||||||
|
contractSignedAt: Date | null;
|
||||||
|
miningEnabledAt: Date | null;
|
||||||
|
mergedAt: Date;
|
||||||
|
}[]> {
|
||||||
|
return this.prisma.$transaction(async (tx) => {
|
||||||
|
const merges = await this.mergeRepo.findByUserId(tx, userId);
|
||||||
|
return merges.map((m) => ({
|
||||||
|
mergeNo: m.mergeNo,
|
||||||
|
sourceOrderNos: m.sourceOrderNos,
|
||||||
|
treeCount: m.treeCount,
|
||||||
|
contractStatus: m.contractStatus,
|
||||||
|
contractSignedAt: m.contractSignedAt,
|
||||||
|
miningEnabledAt: m.miningEnabledAt,
|
||||||
|
mergedAt: m.mergedAt,
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取预种资格信息(供内部 API 使用)
|
||||||
|
*/
|
||||||
|
async getEligibility(accountSequence: string): Promise<{
|
||||||
|
hasPrePlanting: boolean;
|
||||||
|
totalPortions: number;
|
||||||
|
totalTreesMerged: number;
|
||||||
|
canApplyAuthorization: boolean;
|
||||||
|
canTrade: boolean;
|
||||||
|
}> {
|
||||||
|
return this.prisma.$transaction(async (tx) => {
|
||||||
|
const position = await this.positionRepo.findByAccountSequence(tx, accountSequence);
|
||||||
|
if (!position) {
|
||||||
|
return {
|
||||||
|
hasPrePlanting: false,
|
||||||
|
totalPortions: 0,
|
||||||
|
totalTreesMerged: 0,
|
||||||
|
canApplyAuthorization: true,
|
||||||
|
canTrade: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
hasPrePlanting: true,
|
||||||
|
totalPortions: position.totalPortions,
|
||||||
|
totalTreesMerged: position.totalTreesMerged,
|
||||||
|
canApplyAuthorization: position.totalTreesMerged >= 1,
|
||||||
|
canTrade: position.totalTreesMerged >= 1,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== Private Methods =====
|
||||||
|
|
||||||
|
private async validatePurchase(
|
||||||
|
userId: bigint,
|
||||||
|
portionCount: number,
|
||||||
|
): Promise<void> {
|
||||||
|
if (portionCount < 1) {
|
||||||
|
throw new BadRequestException('购买份数必须大于 0');
|
||||||
|
}
|
||||||
|
|
||||||
|
const config = await this.adminClient.getPrePlantingConfig();
|
||||||
|
|
||||||
|
if (config.isActive) {
|
||||||
|
return; // 开关打开,任何人都可以购买
|
||||||
|
}
|
||||||
|
|
||||||
|
// 开关关闭:检查续购规则
|
||||||
|
const position = await this.prisma.$transaction(async (tx) => {
|
||||||
|
return this.positionRepo.findByUserId(tx, userId);
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!position || position.totalPortions === 0) {
|
||||||
|
throw new BadRequestException('预种功能待开启');
|
||||||
|
}
|
||||||
|
|
||||||
|
const maxAdditional = position.maxAdditionalPortionsToMerge();
|
||||||
|
if (maxAdditional === 0) {
|
||||||
|
throw new BadRequestException('预种功能已关闭,您当前份额已满,无法继续购买');
|
||||||
|
}
|
||||||
|
if (portionCount > maxAdditional) {
|
||||||
|
throw new BadRequestException(`当前只可再购买 ${maxAdditional} 份以凑满5份`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async performMerge(
|
||||||
|
tx: import('@prisma/client').Prisma.TransactionClient,
|
||||||
|
userId: bigint,
|
||||||
|
accountSequence: string,
|
||||||
|
position: import('../../domain/aggregates/pre-planting-position.aggregate').PrePlantingPosition,
|
||||||
|
) {
|
||||||
|
// 获取 5 笔待合并的已支付订单
|
||||||
|
const paidOrders = await this.orderRepo.findPaidOrdersByUserId(
|
||||||
|
tx,
|
||||||
|
userId,
|
||||||
|
PRE_PLANTING_PORTIONS_PER_TREE,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (paidOrders.length < PRE_PLANTING_PORTIONS_PER_TREE) {
|
||||||
|
throw new Error('不足 5 笔已支付订单进行合并');
|
||||||
|
}
|
||||||
|
|
||||||
|
const sourceOrders = paidOrders.slice(0, PRE_PLANTING_PORTIONS_PER_TREE);
|
||||||
|
const sourceOrderNos = sourceOrders.map((o) => o.orderNo);
|
||||||
|
const mergeNo = this.generateMergeNo();
|
||||||
|
|
||||||
|
// 执行持仓合并
|
||||||
|
position.performMerge();
|
||||||
|
await this.positionRepo.save(tx, position);
|
||||||
|
|
||||||
|
// 创建合并记录
|
||||||
|
const merge = PrePlantingMerge.create(
|
||||||
|
mergeNo,
|
||||||
|
userId,
|
||||||
|
accountSequence,
|
||||||
|
sourceOrderNos,
|
||||||
|
position.provinceCode || '',
|
||||||
|
position.cityCode || '',
|
||||||
|
position.totalTreesMerged,
|
||||||
|
);
|
||||||
|
await this.mergeRepo.save(tx, merge);
|
||||||
|
|
||||||
|
// 标记 5 笔订单为已合并
|
||||||
|
for (const order of sourceOrders) {
|
||||||
|
order.markAsMerged(merge.id!);
|
||||||
|
await this.orderRepo.save(tx, order);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
mergeNo,
|
||||||
|
domainEvents: merge.domainEvents,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private generateOrderNo(): string {
|
||||||
|
const timestamp = Date.now().toString(36);
|
||||||
|
const random = Math.random().toString(36).substring(2, 8);
|
||||||
|
return `PPL${timestamp}${random}`.toUpperCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
private generateMergeNo(): string {
|
||||||
|
const timestamp = Date.now().toString(36);
|
||||||
|
const random = Math.random().toString(36).substring(2, 8);
|
||||||
|
return `PMG${timestamp}${random}`.toUpperCase();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,245 @@
|
||||||
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
|
import { Prisma } from '@prisma/client';
|
||||||
|
import {
|
||||||
|
PRE_PLANTING_RIGHT_AMOUNTS,
|
||||||
|
PrePlantingRightType,
|
||||||
|
SYSTEM_ACCOUNTS,
|
||||||
|
} from '../../domain/value-objects/pre-planting-right-amounts';
|
||||||
|
import { PrePlantingRewardStatus } from '../../domain/value-objects/pre-planting-reward-status.enum';
|
||||||
|
import {
|
||||||
|
PrePlantingRewardEntryRepository,
|
||||||
|
PrePlantingRewardEntryData,
|
||||||
|
} from '../../infrastructure/repositories/pre-planting-reward-entry.repository';
|
||||||
|
import { PrePlantingReferralClient } from '../../infrastructure/external/pre-planting-referral.client';
|
||||||
|
import { PrePlantingAuthorizationClient } from '../../infrastructure/external/pre-planting-authorization.client';
|
||||||
|
import { WalletServiceClient } from '../../../infrastructure/external/wallet-service.client';
|
||||||
|
|
||||||
|
export interface RewardAllocation {
|
||||||
|
recipientAccountSequence: string;
|
||||||
|
rightType: PrePlantingRightType;
|
||||||
|
amount: number;
|
||||||
|
memo: string;
|
||||||
|
rewardStatus: PrePlantingRewardStatus;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class PrePlantingRewardService {
|
||||||
|
private readonly logger = new Logger(PrePlantingRewardService.name);
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly rewardEntryRepo: PrePlantingRewardEntryRepository,
|
||||||
|
private readonly referralClient: PrePlantingReferralClient,
|
||||||
|
private readonly authorizationClient: PrePlantingAuthorizationClient,
|
||||||
|
private readonly walletClient: WalletServiceClient,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 计算并执行预种的 10 类权益分配
|
||||||
|
*
|
||||||
|
* Step 3-5 in the purchase flow:
|
||||||
|
* 3. 确定 10 类权益的分配对象
|
||||||
|
* 4. 持久化分配记录(在事务内)
|
||||||
|
* 5. 执行资金转账
|
||||||
|
*/
|
||||||
|
async distributeRewards(
|
||||||
|
tx: Prisma.TransactionClient,
|
||||||
|
orderNo: string,
|
||||||
|
accountSequence: string,
|
||||||
|
provinceCode: string,
|
||||||
|
cityCode: string,
|
||||||
|
portionCount: number,
|
||||||
|
): Promise<void> {
|
||||||
|
this.logger.log(
|
||||||
|
`[PRE-PLANTING] Distributing rewards for order ${orderNo}, ` +
|
||||||
|
`${portionCount} portion(s), province=${provinceCode}, city=${cityCode}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Step 3: 确定所有分配对象
|
||||||
|
const allocations = await this.resolveAllocations(
|
||||||
|
accountSequence,
|
||||||
|
provinceCode,
|
||||||
|
cityCode,
|
||||||
|
portionCount,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Step 4: 在事务内持久化分配记录
|
||||||
|
const entries: PrePlantingRewardEntryData[] = allocations.map((a) => ({
|
||||||
|
sourceOrderNo: orderNo,
|
||||||
|
sourceAccountSequence: accountSequence,
|
||||||
|
recipientAccountSequence: a.recipientAccountSequence,
|
||||||
|
rightType: a.rightType,
|
||||||
|
usdtAmount: a.amount,
|
||||||
|
rewardStatus: a.rewardStatus,
|
||||||
|
memo: a.memo,
|
||||||
|
}));
|
||||||
|
|
||||||
|
await this.rewardEntryRepo.saveMany(tx, entries);
|
||||||
|
|
||||||
|
// Step 5: 执行资金转账(调用 wallet-service 已有 API)
|
||||||
|
await this.executeAllocations(orderNo, allocations);
|
||||||
|
|
||||||
|
this.logger.log(
|
||||||
|
`[PRE-PLANTING] Rewards distributed: ${allocations.length} allocations for order ${orderNo}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 确定 10 类权益分配对象
|
||||||
|
*/
|
||||||
|
private async resolveAllocations(
|
||||||
|
accountSequence: string,
|
||||||
|
provinceCode: string,
|
||||||
|
cityCode: string,
|
||||||
|
portionCount: number,
|
||||||
|
): Promise<RewardAllocation[]> {
|
||||||
|
const allocations: RewardAllocation[] = [];
|
||||||
|
const multiplier = portionCount;
|
||||||
|
|
||||||
|
// ===== 4 类系统费用(硬编码,无需查询) =====
|
||||||
|
allocations.push({
|
||||||
|
recipientAccountSequence: SYSTEM_ACCOUNTS.COST,
|
||||||
|
rightType: PrePlantingRightType.COST_FEE,
|
||||||
|
amount: PRE_PLANTING_RIGHT_AMOUNTS.COST_FEE * multiplier,
|
||||||
|
memo: '预种成本费',
|
||||||
|
rewardStatus: PrePlantingRewardStatus.SETTLED,
|
||||||
|
});
|
||||||
|
|
||||||
|
allocations.push({
|
||||||
|
recipientAccountSequence: SYSTEM_ACCOUNTS.OPERATION,
|
||||||
|
rightType: PrePlantingRightType.OPERATION_FEE,
|
||||||
|
amount: PRE_PLANTING_RIGHT_AMOUNTS.OPERATION_FEE * multiplier,
|
||||||
|
memo: '预种运营费',
|
||||||
|
rewardStatus: PrePlantingRewardStatus.SETTLED,
|
||||||
|
});
|
||||||
|
|
||||||
|
allocations.push({
|
||||||
|
recipientAccountSequence: SYSTEM_ACCOUNTS.HEADQUARTERS,
|
||||||
|
rightType: PrePlantingRightType.HEADQUARTERS_BASE_FEE,
|
||||||
|
amount: PRE_PLANTING_RIGHT_AMOUNTS.HEADQUARTERS_BASE_FEE * multiplier,
|
||||||
|
memo: '预种总部社区费',
|
||||||
|
rewardStatus: PrePlantingRewardStatus.SETTLED,
|
||||||
|
});
|
||||||
|
|
||||||
|
allocations.push({
|
||||||
|
recipientAccountSequence: SYSTEM_ACCOUNTS.RWAD_POOL,
|
||||||
|
rightType: PrePlantingRightType.RWAD_POOL_INJECTION,
|
||||||
|
amount: PRE_PLANTING_RIGHT_AMOUNTS.RWAD_POOL_INJECTION * multiplier,
|
||||||
|
memo: '预种RWAD底池注入',
|
||||||
|
rewardStatus: PrePlantingRewardStatus.SETTLED,
|
||||||
|
});
|
||||||
|
|
||||||
|
// ===== 6 类用户权益(需要查询各服务 API) =====
|
||||||
|
// 并行查询推荐人和授权信息
|
||||||
|
const [
|
||||||
|
referralInfo,
|
||||||
|
communityResult,
|
||||||
|
provinceAreaResult,
|
||||||
|
provinceTeamResult,
|
||||||
|
cityAreaResult,
|
||||||
|
cityTeamResult,
|
||||||
|
] = await Promise.all([
|
||||||
|
this.referralClient.getReferralChain(accountSequence),
|
||||||
|
this.authorizationClient.getCommunityDistribution(accountSequence),
|
||||||
|
this.authorizationClient.getProvinceAreaDistribution(provinceCode),
|
||||||
|
this.authorizationClient.getProvinceTeamDistribution(accountSequence),
|
||||||
|
this.authorizationClient.getCityAreaDistribution(cityCode),
|
||||||
|
this.authorizationClient.getCityTeamDistribution(accountSequence),
|
||||||
|
]);
|
||||||
|
|
||||||
|
// 推荐奖励 (SHARE_RIGHT)
|
||||||
|
const referrer = referralInfo.directReferrer;
|
||||||
|
if (referrer) {
|
||||||
|
allocations.push({
|
||||||
|
recipientAccountSequence: referrer.accountSequence,
|
||||||
|
rightType: PrePlantingRightType.SHARE_RIGHT,
|
||||||
|
amount: PRE_PLANTING_RIGHT_AMOUNTS.SHARE_RIGHT * multiplier,
|
||||||
|
memo: referrer.hasPlanted
|
||||||
|
? '预种推荐奖励(立即到账)'
|
||||||
|
: '预种推荐奖励(待推荐人认种后生效)',
|
||||||
|
rewardStatus: referrer.hasPlanted
|
||||||
|
? PrePlantingRewardStatus.SETTLED
|
||||||
|
: PrePlantingRewardStatus.PENDING,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
allocations.push({
|
||||||
|
recipientAccountSequence: SYSTEM_ACCOUNTS.SHARE_RIGHT_POOL,
|
||||||
|
rightType: PrePlantingRightType.SHARE_RIGHT,
|
||||||
|
amount: PRE_PLANTING_RIGHT_AMOUNTS.SHARE_RIGHT * multiplier,
|
||||||
|
memo: '预种推荐奖励(无推荐人,归入分享权益池)',
|
||||||
|
rewardStatus: PrePlantingRewardStatus.SETTLED,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 社区权益 (COMMUNITY_RIGHT)
|
||||||
|
allocations.push({
|
||||||
|
recipientAccountSequence: communityResult.recipientAccountSequence,
|
||||||
|
rightType: PrePlantingRightType.COMMUNITY_RIGHT,
|
||||||
|
amount: PRE_PLANTING_RIGHT_AMOUNTS.COMMUNITY_RIGHT * multiplier,
|
||||||
|
memo: communityResult.isFallback ? '预种社区权益(无社区,归总部)' : '预种社区权益',
|
||||||
|
rewardStatus: PrePlantingRewardStatus.SETTLED,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 省区域权益 (PROVINCE_AREA_RIGHT)
|
||||||
|
allocations.push({
|
||||||
|
recipientAccountSequence: provinceAreaResult.recipientAccountSequence,
|
||||||
|
rightType: PrePlantingRightType.PROVINCE_AREA_RIGHT,
|
||||||
|
amount: PRE_PLANTING_RIGHT_AMOUNTS.PROVINCE_AREA_RIGHT * multiplier,
|
||||||
|
memo: provinceAreaResult.isFallback ? '预种省区域权益(系统省账户)' : '预种省区域权益',
|
||||||
|
rewardStatus: PrePlantingRewardStatus.SETTLED,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 省团队权益 (PROVINCE_TEAM_RIGHT)
|
||||||
|
allocations.push({
|
||||||
|
recipientAccountSequence: provinceTeamResult.recipientAccountSequence,
|
||||||
|
rightType: PrePlantingRightType.PROVINCE_TEAM_RIGHT,
|
||||||
|
amount: PRE_PLANTING_RIGHT_AMOUNTS.PROVINCE_TEAM_RIGHT * multiplier,
|
||||||
|
memo: provinceTeamResult.isFallback ? '预种省团队权益(归总部)' : '预种省团队权益',
|
||||||
|
rewardStatus: PrePlantingRewardStatus.SETTLED,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 市区域权益 (CITY_AREA_RIGHT)
|
||||||
|
allocations.push({
|
||||||
|
recipientAccountSequence: cityAreaResult.recipientAccountSequence,
|
||||||
|
rightType: PrePlantingRightType.CITY_AREA_RIGHT,
|
||||||
|
amount: PRE_PLANTING_RIGHT_AMOUNTS.CITY_AREA_RIGHT * multiplier,
|
||||||
|
memo: cityAreaResult.isFallback ? '预种市区域权益(系统市账户)' : '预种市区域权益',
|
||||||
|
rewardStatus: PrePlantingRewardStatus.SETTLED,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 市团队权益 (CITY_TEAM_RIGHT)
|
||||||
|
allocations.push({
|
||||||
|
recipientAccountSequence: cityTeamResult.recipientAccountSequence,
|
||||||
|
rightType: PrePlantingRightType.CITY_TEAM_RIGHT,
|
||||||
|
amount: PRE_PLANTING_RIGHT_AMOUNTS.CITY_TEAM_RIGHT * multiplier,
|
||||||
|
memo: cityTeamResult.isFallback ? '预种市团队权益(归总部)' : '预种市团队权益',
|
||||||
|
rewardStatus: PrePlantingRewardStatus.SETTLED,
|
||||||
|
});
|
||||||
|
|
||||||
|
return allocations;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 执行资金转账(调用 wallet-service 已有 API)
|
||||||
|
*/
|
||||||
|
private async executeAllocations(
|
||||||
|
orderNo: string,
|
||||||
|
allocations: RewardAllocation[],
|
||||||
|
): Promise<void> {
|
||||||
|
// 只转 SETTLED 状态的分配
|
||||||
|
const settledAllocations = allocations.filter(
|
||||||
|
(a) => a.rewardStatus === PrePlantingRewardStatus.SETTLED,
|
||||||
|
);
|
||||||
|
|
||||||
|
// wallet-service 的 allocateFunds API 接受通用分配数据
|
||||||
|
// 预种的 rightType 不属于现有 FundAllocationTargetType 枚举,
|
||||||
|
// 但 wallet-service 内部实际只使用 targetAccountId 做转账
|
||||||
|
await this.walletClient.allocateFunds({
|
||||||
|
orderId: orderNo,
|
||||||
|
allocations: settledAllocations.map((a) => ({
|
||||||
|
targetType: a.rightType as unknown as import('../../../domain/value-objects/fund-allocation-target-type.enum').FundAllocationTargetType,
|
||||||
|
amount: a.amount,
|
||||||
|
targetAccountId: a.recipientAccountSequence,
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,180 @@
|
||||||
|
import { PrePlantingContractStatus } from '../value-objects/pre-planting-contract-status.enum';
|
||||||
|
import { DomainEvent } from '../../../domain/events/domain-event.interface';
|
||||||
|
import { PrePlantingMergedEvent } from '../events/pre-planting-merged.event';
|
||||||
|
import { PrePlantingContractSignedEvent } from '../events/pre-planting-contract-signed.event';
|
||||||
|
|
||||||
|
export interface PrePlantingMergeData {
|
||||||
|
id?: bigint;
|
||||||
|
mergeNo: string;
|
||||||
|
userId: bigint;
|
||||||
|
accountSequence: string;
|
||||||
|
sourceOrderNos: string[];
|
||||||
|
treeCount: number;
|
||||||
|
provinceCode?: string | null;
|
||||||
|
cityCode?: string | null;
|
||||||
|
contractStatus: PrePlantingContractStatus;
|
||||||
|
contractSignedAt?: Date | null;
|
||||||
|
miningEnabledAt?: Date | null;
|
||||||
|
mergedAt?: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class PrePlantingMerge {
|
||||||
|
private _id: bigint | null;
|
||||||
|
private readonly _mergeNo: string;
|
||||||
|
private readonly _userId: bigint;
|
||||||
|
private readonly _accountSequence: string;
|
||||||
|
private readonly _sourceOrderNos: string[];
|
||||||
|
private readonly _treeCount: number;
|
||||||
|
private _provinceCode: string | null;
|
||||||
|
private _cityCode: string | null;
|
||||||
|
private _contractStatus: PrePlantingContractStatus;
|
||||||
|
private _contractSignedAt: Date | null;
|
||||||
|
private _miningEnabledAt: Date | null;
|
||||||
|
private readonly _mergedAt: Date;
|
||||||
|
|
||||||
|
private _domainEvents: DomainEvent[] = [];
|
||||||
|
|
||||||
|
private constructor(
|
||||||
|
mergeNo: string,
|
||||||
|
userId: bigint,
|
||||||
|
accountSequence: string,
|
||||||
|
sourceOrderNos: string[],
|
||||||
|
provinceCode: string | null,
|
||||||
|
cityCode: string | null,
|
||||||
|
mergedAt?: Date,
|
||||||
|
) {
|
||||||
|
this._id = null;
|
||||||
|
this._mergeNo = mergeNo;
|
||||||
|
this._userId = userId;
|
||||||
|
this._accountSequence = accountSequence;
|
||||||
|
this._sourceOrderNos = sourceOrderNos;
|
||||||
|
this._treeCount = 1;
|
||||||
|
this._provinceCode = provinceCode;
|
||||||
|
this._cityCode = cityCode;
|
||||||
|
this._contractStatus = PrePlantingContractStatus.PENDING;
|
||||||
|
this._contractSignedAt = null;
|
||||||
|
this._miningEnabledAt = null;
|
||||||
|
this._mergedAt = mergedAt || new Date();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建合并记录
|
||||||
|
*/
|
||||||
|
static create(
|
||||||
|
mergeNo: string,
|
||||||
|
userId: bigint,
|
||||||
|
accountSequence: string,
|
||||||
|
sourceOrderNos: string[],
|
||||||
|
provinceCode: string,
|
||||||
|
cityCode: string,
|
||||||
|
totalTreesMergedAfter: number,
|
||||||
|
): PrePlantingMerge {
|
||||||
|
if (sourceOrderNos.length !== 5) {
|
||||||
|
throw new Error(`合并需要 5 个订单,收到 ${sourceOrderNos.length} 个`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const merge = new PrePlantingMerge(
|
||||||
|
mergeNo,
|
||||||
|
userId,
|
||||||
|
accountSequence,
|
||||||
|
sourceOrderNos,
|
||||||
|
provinceCode,
|
||||||
|
cityCode,
|
||||||
|
);
|
||||||
|
|
||||||
|
merge._domainEvents.push(
|
||||||
|
new PrePlantingMergedEvent(mergeNo, {
|
||||||
|
mergeNo,
|
||||||
|
userId: userId.toString(),
|
||||||
|
accountSequence,
|
||||||
|
sourceOrderNos,
|
||||||
|
treeCount: 1,
|
||||||
|
provinceCode,
|
||||||
|
cityCode,
|
||||||
|
totalTreesMergedAfter,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
return merge;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 从持久化数据重建
|
||||||
|
*/
|
||||||
|
static reconstitute(data: PrePlantingMergeData): PrePlantingMerge {
|
||||||
|
const merge = new PrePlantingMerge(
|
||||||
|
data.mergeNo,
|
||||||
|
data.userId,
|
||||||
|
data.accountSequence,
|
||||||
|
data.sourceOrderNos,
|
||||||
|
data.provinceCode || null,
|
||||||
|
data.cityCode || null,
|
||||||
|
data.mergedAt,
|
||||||
|
);
|
||||||
|
merge._id = data.id || null;
|
||||||
|
merge._contractStatus = data.contractStatus;
|
||||||
|
merge._contractSignedAt = data.contractSignedAt || null;
|
||||||
|
merge._miningEnabledAt = data.miningEnabledAt || null;
|
||||||
|
return merge;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 签署合同
|
||||||
|
*/
|
||||||
|
signContract(): void {
|
||||||
|
if (this._contractStatus !== PrePlantingContractStatus.PENDING) {
|
||||||
|
throw new Error(`合并 ${this._mergeNo} 合同状态不允许签署: ${this._contractStatus}`);
|
||||||
|
}
|
||||||
|
this._contractStatus = PrePlantingContractStatus.SIGNED;
|
||||||
|
this._contractSignedAt = new Date();
|
||||||
|
this._miningEnabledAt = new Date();
|
||||||
|
|
||||||
|
this._domainEvents.push(
|
||||||
|
new PrePlantingContractSignedEvent(this._mergeNo, {
|
||||||
|
mergeNo: this._mergeNo,
|
||||||
|
userId: this._userId.toString(),
|
||||||
|
accountSequence: this._accountSequence,
|
||||||
|
provinceCode: this._provinceCode || '',
|
||||||
|
cityCode: this._cityCode || '',
|
||||||
|
treeCount: this._treeCount,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
setId(id: bigint): void {
|
||||||
|
this._id = id;
|
||||||
|
}
|
||||||
|
|
||||||
|
get id(): bigint | null { return this._id; }
|
||||||
|
get mergeNo(): string { return this._mergeNo; }
|
||||||
|
get userId(): bigint { return this._userId; }
|
||||||
|
get accountSequence(): string { return this._accountSequence; }
|
||||||
|
get sourceOrderNos(): string[] { return [...this._sourceOrderNos]; }
|
||||||
|
get treeCount(): number { return this._treeCount; }
|
||||||
|
get provinceCode(): string | null { return this._provinceCode; }
|
||||||
|
get cityCode(): string | null { return this._cityCode; }
|
||||||
|
get contractStatus(): PrePlantingContractStatus { return this._contractStatus; }
|
||||||
|
get contractSignedAt(): Date | null { return this._contractSignedAt; }
|
||||||
|
get miningEnabledAt(): Date | null { return this._miningEnabledAt; }
|
||||||
|
get mergedAt(): Date { return this._mergedAt; }
|
||||||
|
|
||||||
|
get domainEvents(): DomainEvent[] { return [...this._domainEvents]; }
|
||||||
|
clearDomainEvents(): void { this._domainEvents = []; }
|
||||||
|
|
||||||
|
toPersistence(): PrePlantingMergeData {
|
||||||
|
return {
|
||||||
|
id: this._id || undefined,
|
||||||
|
mergeNo: this._mergeNo,
|
||||||
|
userId: this._userId,
|
||||||
|
accountSequence: this._accountSequence,
|
||||||
|
sourceOrderNos: this._sourceOrderNos,
|
||||||
|
treeCount: this._treeCount,
|
||||||
|
provinceCode: this._provinceCode,
|
||||||
|
cityCode: this._cityCode,
|
||||||
|
contractStatus: this._contractStatus,
|
||||||
|
contractSignedAt: this._contractSignedAt,
|
||||||
|
miningEnabledAt: this._miningEnabledAt,
|
||||||
|
mergedAt: this._mergedAt,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,191 @@
|
||||||
|
import { PrePlantingOrderStatus } from '../value-objects/pre-planting-order-status.enum';
|
||||||
|
import { PRE_PLANTING_PRICE_PER_PORTION } from '../value-objects/pre-planting-right-amounts';
|
||||||
|
import { DomainEvent } from '../../../domain/events/domain-event.interface';
|
||||||
|
import { PrePlantingPortionPurchasedEvent } from '../events/pre-planting-portion-purchased.event';
|
||||||
|
|
||||||
|
export interface PrePlantingOrderData {
|
||||||
|
id?: bigint;
|
||||||
|
orderNo: string;
|
||||||
|
userId: bigint;
|
||||||
|
accountSequence: string;
|
||||||
|
portionCount: number;
|
||||||
|
pricePerPortion: number;
|
||||||
|
totalAmount: number;
|
||||||
|
provinceCode: string;
|
||||||
|
cityCode: string;
|
||||||
|
status: PrePlantingOrderStatus;
|
||||||
|
mergedToMergeId?: bigint | null;
|
||||||
|
mergedAt?: Date | null;
|
||||||
|
createdAt?: Date;
|
||||||
|
paidAt?: Date | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class PrePlantingOrder {
|
||||||
|
private _id: bigint | null;
|
||||||
|
private readonly _orderNo: string;
|
||||||
|
private readonly _userId: bigint;
|
||||||
|
private readonly _accountSequence: string;
|
||||||
|
private readonly _portionCount: number;
|
||||||
|
private readonly _pricePerPortion: number;
|
||||||
|
private readonly _totalAmount: number;
|
||||||
|
private readonly _provinceCode: string;
|
||||||
|
private readonly _cityCode: string;
|
||||||
|
private _status: PrePlantingOrderStatus;
|
||||||
|
private _mergedToMergeId: bigint | null;
|
||||||
|
private _mergedAt: Date | null;
|
||||||
|
private readonly _createdAt: Date;
|
||||||
|
private _paidAt: Date | null;
|
||||||
|
|
||||||
|
private _domainEvents: DomainEvent[] = [];
|
||||||
|
|
||||||
|
private constructor(
|
||||||
|
orderNo: string,
|
||||||
|
userId: bigint,
|
||||||
|
accountSequence: string,
|
||||||
|
portionCount: number,
|
||||||
|
provinceCode: string,
|
||||||
|
cityCode: string,
|
||||||
|
createdAt?: Date,
|
||||||
|
) {
|
||||||
|
this._id = null;
|
||||||
|
this._orderNo = orderNo;
|
||||||
|
this._userId = userId;
|
||||||
|
this._accountSequence = accountSequence;
|
||||||
|
this._portionCount = portionCount;
|
||||||
|
this._pricePerPortion = PRE_PLANTING_PRICE_PER_PORTION;
|
||||||
|
this._totalAmount = portionCount * PRE_PLANTING_PRICE_PER_PORTION;
|
||||||
|
this._provinceCode = provinceCode;
|
||||||
|
this._cityCode = cityCode;
|
||||||
|
this._status = PrePlantingOrderStatus.CREATED;
|
||||||
|
this._mergedToMergeId = null;
|
||||||
|
this._mergedAt = null;
|
||||||
|
this._createdAt = createdAt || new Date();
|
||||||
|
this._paidAt = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建新的预种订单
|
||||||
|
*/
|
||||||
|
static create(
|
||||||
|
orderNo: string,
|
||||||
|
userId: bigint,
|
||||||
|
accountSequence: string,
|
||||||
|
portionCount: number,
|
||||||
|
provinceCode: string,
|
||||||
|
cityCode: string,
|
||||||
|
): PrePlantingOrder {
|
||||||
|
if (portionCount < 1) {
|
||||||
|
throw new Error('购买份数必须大于 0');
|
||||||
|
}
|
||||||
|
return new PrePlantingOrder(
|
||||||
|
orderNo,
|
||||||
|
userId,
|
||||||
|
accountSequence,
|
||||||
|
portionCount,
|
||||||
|
provinceCode,
|
||||||
|
cityCode,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 从持久化数据重建
|
||||||
|
*/
|
||||||
|
static reconstitute(data: PrePlantingOrderData): PrePlantingOrder {
|
||||||
|
const order = new PrePlantingOrder(
|
||||||
|
data.orderNo,
|
||||||
|
data.userId,
|
||||||
|
data.accountSequence,
|
||||||
|
data.portionCount,
|
||||||
|
data.provinceCode,
|
||||||
|
data.cityCode,
|
||||||
|
data.createdAt,
|
||||||
|
);
|
||||||
|
order._id = data.id || null;
|
||||||
|
order._status = data.status;
|
||||||
|
order._mergedToMergeId = data.mergedToMergeId || null;
|
||||||
|
order._mergedAt = data.mergedAt || null;
|
||||||
|
order._paidAt = data.paidAt || null;
|
||||||
|
return order;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 标记为已支付
|
||||||
|
*/
|
||||||
|
markAsPaid(
|
||||||
|
totalPortionsAfter: number,
|
||||||
|
availablePortionsAfter: number,
|
||||||
|
): void {
|
||||||
|
if (this._status !== PrePlantingOrderStatus.CREATED) {
|
||||||
|
throw new Error(`订单 ${this._orderNo} 状态不允许支付: ${this._status}`);
|
||||||
|
}
|
||||||
|
this._status = PrePlantingOrderStatus.PAID;
|
||||||
|
this._paidAt = new Date();
|
||||||
|
|
||||||
|
this._domainEvents.push(
|
||||||
|
new PrePlantingPortionPurchasedEvent(this._orderNo, {
|
||||||
|
orderNo: this._orderNo,
|
||||||
|
userId: this._userId.toString(),
|
||||||
|
accountSequence: this._accountSequence,
|
||||||
|
portionCount: this._portionCount,
|
||||||
|
totalAmount: this._totalAmount,
|
||||||
|
provinceCode: this._provinceCode,
|
||||||
|
cityCode: this._cityCode,
|
||||||
|
totalPortionsAfter,
|
||||||
|
availablePortionsAfter,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 标记为已合并
|
||||||
|
*/
|
||||||
|
markAsMerged(mergeId: bigint): void {
|
||||||
|
if (this._status !== PrePlantingOrderStatus.PAID) {
|
||||||
|
throw new Error(`订单 ${this._orderNo} 状态不允许合并: ${this._status}`);
|
||||||
|
}
|
||||||
|
this._status = PrePlantingOrderStatus.MERGED;
|
||||||
|
this._mergedToMergeId = mergeId;
|
||||||
|
this._mergedAt = new Date();
|
||||||
|
}
|
||||||
|
|
||||||
|
setId(id: bigint): void {
|
||||||
|
this._id = id;
|
||||||
|
}
|
||||||
|
|
||||||
|
get id(): bigint | null { return this._id; }
|
||||||
|
get orderNo(): string { return this._orderNo; }
|
||||||
|
get userId(): bigint { return this._userId; }
|
||||||
|
get accountSequence(): string { return this._accountSequence; }
|
||||||
|
get portionCount(): number { return this._portionCount; }
|
||||||
|
get pricePerPortion(): number { return this._pricePerPortion; }
|
||||||
|
get totalAmount(): number { return this._totalAmount; }
|
||||||
|
get provinceCode(): string { return this._provinceCode; }
|
||||||
|
get cityCode(): string { return this._cityCode; }
|
||||||
|
get status(): PrePlantingOrderStatus { return this._status; }
|
||||||
|
get mergedToMergeId(): bigint | null { return this._mergedToMergeId; }
|
||||||
|
get mergedAt(): Date | null { return this._mergedAt; }
|
||||||
|
get createdAt(): Date { return this._createdAt; }
|
||||||
|
get paidAt(): Date | null { return this._paidAt; }
|
||||||
|
|
||||||
|
get domainEvents(): DomainEvent[] { return [...this._domainEvents]; }
|
||||||
|
clearDomainEvents(): void { this._domainEvents = []; }
|
||||||
|
|
||||||
|
toPersistence(): PrePlantingOrderData {
|
||||||
|
return {
|
||||||
|
id: this._id || undefined,
|
||||||
|
orderNo: this._orderNo,
|
||||||
|
userId: this._userId,
|
||||||
|
accountSequence: this._accountSequence,
|
||||||
|
portionCount: this._portionCount,
|
||||||
|
pricePerPortion: this._pricePerPortion,
|
||||||
|
totalAmount: this._totalAmount,
|
||||||
|
provinceCode: this._provinceCode,
|
||||||
|
cityCode: this._cityCode,
|
||||||
|
status: this._status,
|
||||||
|
mergedToMergeId: this._mergedToMergeId,
|
||||||
|
mergedAt: this._mergedAt,
|
||||||
|
createdAt: this._createdAt,
|
||||||
|
paidAt: this._paidAt,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,135 @@
|
||||||
|
import { PRE_PLANTING_PORTIONS_PER_TREE } from '../value-objects/pre-planting-right-amounts';
|
||||||
|
|
||||||
|
export interface PrePlantingPositionData {
|
||||||
|
id?: bigint;
|
||||||
|
userId: bigint;
|
||||||
|
accountSequence: string;
|
||||||
|
totalPortions: number;
|
||||||
|
availablePortions: number;
|
||||||
|
mergedPortions: number;
|
||||||
|
totalTreesMerged: number;
|
||||||
|
provinceCode?: string | null;
|
||||||
|
cityCode?: string | null;
|
||||||
|
firstPurchaseAt?: Date | null;
|
||||||
|
createdAt?: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class PrePlantingPosition {
|
||||||
|
private _id: bigint | null;
|
||||||
|
private readonly _userId: bigint;
|
||||||
|
private readonly _accountSequence: string;
|
||||||
|
private _totalPortions: number;
|
||||||
|
private _availablePortions: number;
|
||||||
|
private _mergedPortions: number;
|
||||||
|
private _totalTreesMerged: number;
|
||||||
|
private _provinceCode: string | null;
|
||||||
|
private _cityCode: string | null;
|
||||||
|
private _firstPurchaseAt: Date | null;
|
||||||
|
private readonly _createdAt: Date;
|
||||||
|
|
||||||
|
private constructor(userId: bigint, accountSequence: string, createdAt?: Date) {
|
||||||
|
this._id = null;
|
||||||
|
this._userId = userId;
|
||||||
|
this._accountSequence = accountSequence;
|
||||||
|
this._totalPortions = 0;
|
||||||
|
this._availablePortions = 0;
|
||||||
|
this._mergedPortions = 0;
|
||||||
|
this._totalTreesMerged = 0;
|
||||||
|
this._provinceCode = null;
|
||||||
|
this._cityCode = null;
|
||||||
|
this._firstPurchaseAt = null;
|
||||||
|
this._createdAt = createdAt || new Date();
|
||||||
|
}
|
||||||
|
|
||||||
|
static create(userId: bigint, accountSequence: string): PrePlantingPosition {
|
||||||
|
return new PrePlantingPosition(userId, accountSequence);
|
||||||
|
}
|
||||||
|
|
||||||
|
static reconstitute(data: PrePlantingPositionData): PrePlantingPosition {
|
||||||
|
const position = new PrePlantingPosition(data.userId, data.accountSequence, data.createdAt);
|
||||||
|
position._id = data.id || null;
|
||||||
|
position._totalPortions = data.totalPortions;
|
||||||
|
position._availablePortions = data.availablePortions;
|
||||||
|
position._mergedPortions = data.mergedPortions;
|
||||||
|
position._totalTreesMerged = data.totalTreesMerged;
|
||||||
|
position._provinceCode = data.provinceCode || null;
|
||||||
|
position._cityCode = data.cityCode || null;
|
||||||
|
position._firstPurchaseAt = data.firstPurchaseAt || null;
|
||||||
|
return position;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 增加购买份数
|
||||||
|
*/
|
||||||
|
addPortions(count: number, provinceCode: string, cityCode: string): void {
|
||||||
|
this._totalPortions += count;
|
||||||
|
this._availablePortions += count;
|
||||||
|
|
||||||
|
// 首次购买设置省市和时间
|
||||||
|
if (!this._firstPurchaseAt) {
|
||||||
|
this._firstPurchaseAt = new Date();
|
||||||
|
this._provinceCode = provinceCode;
|
||||||
|
this._cityCode = cityCode;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 是否可以触发合并
|
||||||
|
*/
|
||||||
|
canMerge(): boolean {
|
||||||
|
return this._availablePortions >= PRE_PLANTING_PORTIONS_PER_TREE;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 执行合并(消耗 5 份)
|
||||||
|
*/
|
||||||
|
performMerge(): void {
|
||||||
|
if (!this.canMerge()) {
|
||||||
|
throw new Error(`可用份数不足 ${PRE_PLANTING_PORTIONS_PER_TREE},当前: ${this._availablePortions}`);
|
||||||
|
}
|
||||||
|
this._availablePortions -= PRE_PLANTING_PORTIONS_PER_TREE;
|
||||||
|
this._mergedPortions += PRE_PLANTING_PORTIONS_PER_TREE;
|
||||||
|
this._totalTreesMerged += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 计算续购时还可以购买的最大份数(关闭开关时使用)
|
||||||
|
*/
|
||||||
|
maxAdditionalPortionsToMerge(): number {
|
||||||
|
const remainder = this._availablePortions % PRE_PLANTING_PORTIONS_PER_TREE;
|
||||||
|
if (remainder === 0) return 0;
|
||||||
|
return PRE_PLANTING_PORTIONS_PER_TREE - remainder;
|
||||||
|
}
|
||||||
|
|
||||||
|
setId(id: bigint): void {
|
||||||
|
this._id = id;
|
||||||
|
}
|
||||||
|
|
||||||
|
get id(): bigint | null { return this._id; }
|
||||||
|
get userId(): bigint { return this._userId; }
|
||||||
|
get accountSequence(): string { return this._accountSequence; }
|
||||||
|
get totalPortions(): number { return this._totalPortions; }
|
||||||
|
get availablePortions(): number { return this._availablePortions; }
|
||||||
|
get mergedPortions(): number { return this._mergedPortions; }
|
||||||
|
get totalTreesMerged(): number { return this._totalTreesMerged; }
|
||||||
|
get provinceCode(): string | null { return this._provinceCode; }
|
||||||
|
get cityCode(): string | null { return this._cityCode; }
|
||||||
|
get firstPurchaseAt(): Date | null { return this._firstPurchaseAt; }
|
||||||
|
get createdAt(): Date { return this._createdAt; }
|
||||||
|
|
||||||
|
toPersistence(): PrePlantingPositionData {
|
||||||
|
return {
|
||||||
|
id: this._id || undefined,
|
||||||
|
userId: this._userId,
|
||||||
|
accountSequence: this._accountSequence,
|
||||||
|
totalPortions: this._totalPortions,
|
||||||
|
availablePortions: this._availablePortions,
|
||||||
|
mergedPortions: this._mergedPortions,
|
||||||
|
totalTreesMerged: this._totalTreesMerged,
|
||||||
|
provinceCode: this._provinceCode,
|
||||||
|
cityCode: this._cityCode,
|
||||||
|
firstPurchaseAt: this._firstPurchaseAt,
|
||||||
|
createdAt: this._createdAt,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
export * from './pre-planting-portion-purchased.event';
|
||||||
|
export * from './pre-planting-merged.event';
|
||||||
|
export * from './pre-planting-contract-signed.event';
|
||||||
|
|
@ -0,0 +1,21 @@
|
||||||
|
import { DomainEvent } from '../../../domain/events/domain-event.interface';
|
||||||
|
|
||||||
|
export class PrePlantingContractSignedEvent implements DomainEvent {
|
||||||
|
readonly type = 'PrePlantingContractSigned';
|
||||||
|
readonly aggregateType = 'PrePlantingMerge';
|
||||||
|
readonly occurredAt: Date;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
public readonly aggregateId: string,
|
||||||
|
public readonly data: {
|
||||||
|
mergeNo: string;
|
||||||
|
userId: string;
|
||||||
|
accountSequence: string;
|
||||||
|
provinceCode: string;
|
||||||
|
cityCode: string;
|
||||||
|
treeCount: number;
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
this.occurredAt = new Date();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,23 @@
|
||||||
|
import { DomainEvent } from '../../../domain/events/domain-event.interface';
|
||||||
|
|
||||||
|
export class PrePlantingMergedEvent implements DomainEvent {
|
||||||
|
readonly type = 'PrePlantingMerged';
|
||||||
|
readonly aggregateType = 'PrePlantingMerge';
|
||||||
|
readonly occurredAt: Date;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
public readonly aggregateId: string,
|
||||||
|
public readonly data: {
|
||||||
|
mergeNo: string;
|
||||||
|
userId: string;
|
||||||
|
accountSequence: string;
|
||||||
|
sourceOrderNos: string[];
|
||||||
|
treeCount: number;
|
||||||
|
provinceCode: string;
|
||||||
|
cityCode: string;
|
||||||
|
totalTreesMergedAfter: number;
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
this.occurredAt = new Date();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,24 @@
|
||||||
|
import { DomainEvent } from '../../../domain/events/domain-event.interface';
|
||||||
|
|
||||||
|
export class PrePlantingPortionPurchasedEvent implements DomainEvent {
|
||||||
|
readonly type = 'PrePlantingPortionPurchased';
|
||||||
|
readonly aggregateType = 'PrePlantingOrder';
|
||||||
|
readonly occurredAt: Date;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
public readonly aggregateId: string,
|
||||||
|
public readonly data: {
|
||||||
|
orderNo: string;
|
||||||
|
userId: string;
|
||||||
|
accountSequence: string;
|
||||||
|
portionCount: number;
|
||||||
|
totalAmount: number;
|
||||||
|
provinceCode: string;
|
||||||
|
cityCode: string;
|
||||||
|
totalPortionsAfter: number;
|
||||||
|
availablePortionsAfter: number;
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
this.occurredAt = new Date();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,4 @@
|
||||||
|
export * from './pre-planting-order-status.enum';
|
||||||
|
export * from './pre-planting-contract-status.enum';
|
||||||
|
export * from './pre-planting-right-amounts';
|
||||||
|
export * from './pre-planting-reward-status.enum';
|
||||||
|
|
@ -0,0 +1,5 @@
|
||||||
|
export enum PrePlantingContractStatus {
|
||||||
|
PENDING = 'PENDING',
|
||||||
|
SIGNED = 'SIGNED',
|
||||||
|
EXPIRED = 'EXPIRED',
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,5 @@
|
||||||
|
export enum PrePlantingOrderStatus {
|
||||||
|
CREATED = 'CREATED',
|
||||||
|
PAID = 'PAID',
|
||||||
|
MERGED = 'MERGED',
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,5 @@
|
||||||
|
export enum PrePlantingRewardStatus {
|
||||||
|
SETTLED = 'SETTLED',
|
||||||
|
PENDING = 'PENDING',
|
||||||
|
EXPIRED = 'EXPIRED',
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,50 @@
|
||||||
|
/**
|
||||||
|
* 预种 1/5 分配金额常量
|
||||||
|
*
|
||||||
|
* 基准:reward-service 的 RIGHT_AMOUNTS(实际生效的分配金额)
|
||||||
|
* 预种每份 = 整棵树金额 / 5,差额 4.8 归入总部社区(HQ_BASE_FEE)
|
||||||
|
*/
|
||||||
|
export const PRE_PLANTING_RIGHT_AMOUNTS = {
|
||||||
|
COST_FEE: 576, // 2880/5
|
||||||
|
OPERATION_FEE: 420, // 2100/5
|
||||||
|
HEADQUARTERS_BASE_FEE: 29.4, // 123/5 + 4.8 差额(3171 - 3166.2 = 4.8)
|
||||||
|
RWAD_POOL_INJECTION: 1152, // 5760/5
|
||||||
|
SHARE_RIGHT: 720, // 3600/5 = 720(推荐奖励金额)
|
||||||
|
PROVINCE_AREA_RIGHT: 21.6, // 108/5
|
||||||
|
PROVINCE_TEAM_RIGHT: 28.8, // 144/5
|
||||||
|
CITY_AREA_RIGHT: 50.4, // 252/5
|
||||||
|
CITY_TEAM_RIGHT: 57.6, // 288/5
|
||||||
|
COMMUNITY_RIGHT: 115.2, // 576/5
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
// 合计 = 576 + 420 + 29.4 + 1152 + 720 + 21.6 + 28.8 + 50.4 + 57.6 + 115.2 = 3171.0
|
||||||
|
|
||||||
|
export const PRE_PLANTING_PRICE_PER_PORTION = 3171;
|
||||||
|
export const PRE_PLANTING_PORTIONS_PER_TREE = 5;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 预种分配权益类型
|
||||||
|
*/
|
||||||
|
export enum PrePlantingRightType {
|
||||||
|
COST_FEE = 'COST_FEE',
|
||||||
|
OPERATION_FEE = 'OPERATION_FEE',
|
||||||
|
HEADQUARTERS_BASE_FEE = 'HEADQUARTERS_BASE_FEE',
|
||||||
|
RWAD_POOL_INJECTION = 'RWAD_POOL_INJECTION',
|
||||||
|
SHARE_RIGHT = 'SHARE_RIGHT',
|
||||||
|
PROVINCE_AREA_RIGHT = 'PROVINCE_AREA_RIGHT',
|
||||||
|
PROVINCE_TEAM_RIGHT = 'PROVINCE_TEAM_RIGHT',
|
||||||
|
CITY_AREA_RIGHT = 'CITY_AREA_RIGHT',
|
||||||
|
CITY_TEAM_RIGHT = 'CITY_TEAM_RIGHT',
|
||||||
|
COMMUNITY_RIGHT = 'COMMUNITY_RIGHT',
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 系统账户常量
|
||||||
|
*/
|
||||||
|
export const SYSTEM_ACCOUNTS = {
|
||||||
|
HEADQUARTERS: 'S0000000001', // 总部社区
|
||||||
|
COST: 'S0000000002', // 成本账户
|
||||||
|
OPERATION: 'S0000000003', // 运营账户
|
||||||
|
RWAD_POOL: 'S0000000004', // RWAD底池
|
||||||
|
SHARE_RIGHT_POOL: 'S0000000005', // 分享权益池
|
||||||
|
} as const;
|
||||||
|
|
@ -0,0 +1,42 @@
|
||||||
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
import { HttpService } from '@nestjs/axios';
|
||||||
|
import { firstValueFrom } from 'rxjs';
|
||||||
|
|
||||||
|
export interface PrePlantingConfig {
|
||||||
|
isActive: boolean;
|
||||||
|
activatedAt: Date | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class PrePlantingAdminClient {
|
||||||
|
private readonly logger = new Logger(PrePlantingAdminClient.name);
|
||||||
|
private readonly baseUrl: string;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly configService: ConfigService,
|
||||||
|
private readonly httpService: HttpService,
|
||||||
|
) {
|
||||||
|
this.baseUrl =
|
||||||
|
this.configService.get<string>('ADMIN_SERVICE_URL') ||
|
||||||
|
'http://localhost:3010';
|
||||||
|
}
|
||||||
|
|
||||||
|
async getPrePlantingConfig(): Promise<PrePlantingConfig> {
|
||||||
|
try {
|
||||||
|
const response = await firstValueFrom(
|
||||||
|
this.httpService.get<PrePlantingConfig>(
|
||||||
|
`${this.baseUrl}/api/v1/admin/pre-planting/config`,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return response.data;
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error('Failed to get pre-planting config', error);
|
||||||
|
if (this.configService.get('NODE_ENV') === 'development') {
|
||||||
|
this.logger.warn('Development mode: returning default config (active)');
|
||||||
|
return { isActive: true, activatedAt: null };
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,165 @@
|
||||||
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
import { HttpService } from '@nestjs/axios';
|
||||||
|
import { firstValueFrom } from 'rxjs';
|
||||||
|
import { SYSTEM_ACCOUNTS } from '../../domain/value-objects/pre-planting-right-amounts';
|
||||||
|
|
||||||
|
export interface RewardDistributionResult {
|
||||||
|
recipientAccountSequence: string;
|
||||||
|
isFallback: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class PrePlantingAuthorizationClient {
|
||||||
|
private readonly logger = new Logger(PrePlantingAuthorizationClient.name);
|
||||||
|
private readonly baseUrl: string;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly configService: ConfigService,
|
||||||
|
private readonly httpService: HttpService,
|
||||||
|
) {
|
||||||
|
this.baseUrl =
|
||||||
|
this.configService.get<string>('AUTHORIZATION_SERVICE_URL') ||
|
||||||
|
'http://localhost:3006';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 社区权益分配对象
|
||||||
|
*/
|
||||||
|
async getCommunityDistribution(
|
||||||
|
accountSequence: string,
|
||||||
|
): Promise<RewardDistributionResult> {
|
||||||
|
try {
|
||||||
|
const response = await firstValueFrom(
|
||||||
|
this.httpService.get<{ accountSequence: string }>(
|
||||||
|
`${this.baseUrl}/internal/authorization/community-reward-distribution`,
|
||||||
|
{ params: { accountSequence } },
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
recipientAccountSequence: response.data.accountSequence,
|
||||||
|
isFallback: false,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.warn(
|
||||||
|
`Failed to get community distribution for ${accountSequence}, fallback to HQ`,
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
recipientAccountSequence: SYSTEM_ACCOUNTS.HEADQUARTERS,
|
||||||
|
isFallback: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 省区域权益分配对象
|
||||||
|
*/
|
||||||
|
async getProvinceAreaDistribution(
|
||||||
|
provinceCode: string,
|
||||||
|
): Promise<RewardDistributionResult> {
|
||||||
|
try {
|
||||||
|
const response = await firstValueFrom(
|
||||||
|
this.httpService.get<{ accountSequence: string }>(
|
||||||
|
`${this.baseUrl}/internal/authorization/province-area-reward-distribution`,
|
||||||
|
{ params: { provinceCode } },
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
recipientAccountSequence: response.data.accountSequence,
|
||||||
|
isFallback: false,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.warn(
|
||||||
|
`Failed to get province area distribution for ${provinceCode}, fallback to system province account`,
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
recipientAccountSequence: `9${provinceCode}`,
|
||||||
|
isFallback: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 省团队权益分配对象
|
||||||
|
*/
|
||||||
|
async getProvinceTeamDistribution(
|
||||||
|
accountSequence: string,
|
||||||
|
): Promise<RewardDistributionResult> {
|
||||||
|
try {
|
||||||
|
const response = await firstValueFrom(
|
||||||
|
this.httpService.get<{ accountSequence: string }>(
|
||||||
|
`${this.baseUrl}/internal/authorization/province-team-reward-distribution`,
|
||||||
|
{ params: { accountSequence } },
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
recipientAccountSequence: response.data.accountSequence,
|
||||||
|
isFallback: false,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.warn(
|
||||||
|
`Failed to get province team distribution, fallback to HQ`,
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
recipientAccountSequence: SYSTEM_ACCOUNTS.HEADQUARTERS,
|
||||||
|
isFallback: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 市区域权益分配对象
|
||||||
|
*/
|
||||||
|
async getCityAreaDistribution(
|
||||||
|
cityCode: string,
|
||||||
|
): Promise<RewardDistributionResult> {
|
||||||
|
try {
|
||||||
|
const response = await firstValueFrom(
|
||||||
|
this.httpService.get<{ accountSequence: string }>(
|
||||||
|
`${this.baseUrl}/internal/authorization/city-area-reward-distribution`,
|
||||||
|
{ params: { cityCode } },
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
recipientAccountSequence: response.data.accountSequence,
|
||||||
|
isFallback: false,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.warn(
|
||||||
|
`Failed to get city area distribution for ${cityCode}, fallback to system city account`,
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
recipientAccountSequence: `8${cityCode}`,
|
||||||
|
isFallback: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 市团队权益分配对象
|
||||||
|
*/
|
||||||
|
async getCityTeamDistribution(
|
||||||
|
accountSequence: string,
|
||||||
|
): Promise<RewardDistributionResult> {
|
||||||
|
try {
|
||||||
|
const response = await firstValueFrom(
|
||||||
|
this.httpService.get<{ accountSequence: string }>(
|
||||||
|
`${this.baseUrl}/internal/authorization/city-team-reward-distribution`,
|
||||||
|
{ params: { accountSequence } },
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
recipientAccountSequence: response.data.accountSequence,
|
||||||
|
isFallback: false,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.warn(
|
||||||
|
`Failed to get city team distribution, fallback to HQ`,
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
recipientAccountSequence: SYSTEM_ACCOUNTS.HEADQUARTERS,
|
||||||
|
isFallback: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,51 @@
|
||||||
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
import { HttpService } from '@nestjs/axios';
|
||||||
|
import { firstValueFrom } from 'rxjs';
|
||||||
|
|
||||||
|
export interface ReferralChainInfo {
|
||||||
|
accountSequence: string;
|
||||||
|
directReferrer: {
|
||||||
|
accountSequence: string;
|
||||||
|
hasPlanted: boolean;
|
||||||
|
} | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class PrePlantingReferralClient {
|
||||||
|
private readonly logger = new Logger(PrePlantingReferralClient.name);
|
||||||
|
private readonly baseUrl: string;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly configService: ConfigService,
|
||||||
|
private readonly httpService: HttpService,
|
||||||
|
) {
|
||||||
|
this.baseUrl =
|
||||||
|
this.configService.get<string>('REFERRAL_SERVICE_URL') ||
|
||||||
|
'http://localhost:3004';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取直接推荐人信息
|
||||||
|
*/
|
||||||
|
async getReferralChain(accountSequence: string): Promise<ReferralChainInfo> {
|
||||||
|
try {
|
||||||
|
const response = await firstValueFrom(
|
||||||
|
this.httpService.get<ReferralChainInfo>(
|
||||||
|
`${this.baseUrl}/api/v1/referrals/${accountSequence}/chain`,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return response.data;
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error(
|
||||||
|
`Failed to get referral chain for ${accountSequence}`,
|
||||||
|
error,
|
||||||
|
);
|
||||||
|
if (this.configService.get('NODE_ENV') === 'development') {
|
||||||
|
this.logger.warn('Development mode: returning empty referral chain');
|
||||||
|
return { accountSequence, directReferrer: null };
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,104 @@
|
||||||
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
|
import { Prisma } from '@prisma/client';
|
||||||
|
import {
|
||||||
|
PrePlantingMerge,
|
||||||
|
PrePlantingMergeData,
|
||||||
|
} from '../../domain/aggregates/pre-planting-merge.aggregate';
|
||||||
|
import { PrePlantingContractStatus } from '../../domain/value-objects/pre-planting-contract-status.enum';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class PrePlantingMergeRepository {
|
||||||
|
private readonly logger = new Logger(PrePlantingMergeRepository.name);
|
||||||
|
|
||||||
|
async save(
|
||||||
|
tx: Prisma.TransactionClient,
|
||||||
|
merge: PrePlantingMerge,
|
||||||
|
): Promise<void> {
|
||||||
|
const data = merge.toPersistence();
|
||||||
|
|
||||||
|
if (merge.id) {
|
||||||
|
await tx.prePlantingMerge.update({
|
||||||
|
where: { id: merge.id },
|
||||||
|
data: {
|
||||||
|
provinceCode: data.provinceCode || null,
|
||||||
|
cityCode: data.cityCode || null,
|
||||||
|
contractStatus: data.contractStatus,
|
||||||
|
contractSignedAt: data.contractSignedAt || null,
|
||||||
|
miningEnabledAt: data.miningEnabledAt || null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
const created = await tx.prePlantingMerge.create({
|
||||||
|
data: {
|
||||||
|
mergeNo: data.mergeNo,
|
||||||
|
userId: data.userId,
|
||||||
|
accountSequence: data.accountSequence,
|
||||||
|
sourceOrderNos: data.sourceOrderNos,
|
||||||
|
treeCount: data.treeCount,
|
||||||
|
provinceCode: data.provinceCode || null,
|
||||||
|
cityCode: data.cityCode || null,
|
||||||
|
contractStatus: data.contractStatus,
|
||||||
|
contractSignedAt: data.contractSignedAt || null,
|
||||||
|
miningEnabledAt: data.miningEnabledAt || null,
|
||||||
|
mergedAt: data.mergedAt || new Date(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
merge.setId(created.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async findByMergeNo(
|
||||||
|
tx: Prisma.TransactionClient,
|
||||||
|
mergeNo: string,
|
||||||
|
): Promise<PrePlantingMerge | null> {
|
||||||
|
const record = await tx.prePlantingMerge.findUnique({
|
||||||
|
where: { mergeNo },
|
||||||
|
});
|
||||||
|
if (!record) return null;
|
||||||
|
return this.toDomain(record);
|
||||||
|
}
|
||||||
|
|
||||||
|
async findByUserId(
|
||||||
|
tx: Prisma.TransactionClient,
|
||||||
|
userId: bigint,
|
||||||
|
): Promise<PrePlantingMerge[]> {
|
||||||
|
const records = await tx.prePlantingMerge.findMany({
|
||||||
|
where: { userId },
|
||||||
|
orderBy: { mergedAt: 'desc' },
|
||||||
|
});
|
||||||
|
return records.map((r) => this.toDomain(r));
|
||||||
|
}
|
||||||
|
|
||||||
|
async findPendingByUserId(
|
||||||
|
tx: Prisma.TransactionClient,
|
||||||
|
userId: bigint,
|
||||||
|
): Promise<PrePlantingMerge[]> {
|
||||||
|
const records = await tx.prePlantingMerge.findMany({
|
||||||
|
where: {
|
||||||
|
userId,
|
||||||
|
contractStatus: PrePlantingContractStatus.PENDING,
|
||||||
|
},
|
||||||
|
orderBy: { mergedAt: 'desc' },
|
||||||
|
});
|
||||||
|
return records.map((r) => this.toDomain(r));
|
||||||
|
}
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
private toDomain(record: any): PrePlantingMerge {
|
||||||
|
const data: PrePlantingMergeData = {
|
||||||
|
id: record.id,
|
||||||
|
mergeNo: record.mergeNo,
|
||||||
|
userId: record.userId,
|
||||||
|
accountSequence: record.accountSequence,
|
||||||
|
sourceOrderNos: record.sourceOrderNos as string[],
|
||||||
|
treeCount: record.treeCount,
|
||||||
|
provinceCode: record.provinceCode,
|
||||||
|
cityCode: record.cityCode,
|
||||||
|
contractStatus: record.contractStatus as PrePlantingContractStatus,
|
||||||
|
contractSignedAt: record.contractSignedAt,
|
||||||
|
miningEnabledAt: record.miningEnabledAt,
|
||||||
|
mergedAt: record.mergedAt,
|
||||||
|
};
|
||||||
|
return PrePlantingMerge.reconstitute(data);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,104 @@
|
||||||
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
|
import { Prisma } from '@prisma/client';
|
||||||
|
import {
|
||||||
|
PrePlantingOrder,
|
||||||
|
PrePlantingOrderData,
|
||||||
|
} from '../../domain/aggregates/pre-planting-order.aggregate';
|
||||||
|
import { PrePlantingOrderStatus } from '../../domain/value-objects/pre-planting-order-status.enum';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class PrePlantingOrderRepository {
|
||||||
|
private readonly logger = new Logger(PrePlantingOrderRepository.name);
|
||||||
|
|
||||||
|
async save(
|
||||||
|
tx: Prisma.TransactionClient,
|
||||||
|
order: PrePlantingOrder,
|
||||||
|
): Promise<void> {
|
||||||
|
const data = order.toPersistence();
|
||||||
|
|
||||||
|
if (order.id) {
|
||||||
|
await tx.prePlantingOrder.update({
|
||||||
|
where: { id: order.id },
|
||||||
|
data: {
|
||||||
|
status: data.status,
|
||||||
|
mergedToMergeId: data.mergedToMergeId || null,
|
||||||
|
mergedAt: data.mergedAt || null,
|
||||||
|
paidAt: data.paidAt || null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
const created = await tx.prePlantingOrder.create({
|
||||||
|
data: {
|
||||||
|
orderNo: data.orderNo,
|
||||||
|
userId: data.userId,
|
||||||
|
accountSequence: data.accountSequence,
|
||||||
|
portionCount: data.portionCount,
|
||||||
|
pricePerPortion: new Prisma.Decimal(data.pricePerPortion),
|
||||||
|
totalAmount: new Prisma.Decimal(data.totalAmount),
|
||||||
|
provinceCode: data.provinceCode,
|
||||||
|
cityCode: data.cityCode,
|
||||||
|
status: data.status,
|
||||||
|
createdAt: data.createdAt || new Date(),
|
||||||
|
paidAt: data.paidAt || null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
order.setId(created.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async findByOrderNo(
|
||||||
|
tx: Prisma.TransactionClient,
|
||||||
|
orderNo: string,
|
||||||
|
): Promise<PrePlantingOrder | null> {
|
||||||
|
const record = await tx.prePlantingOrder.findUnique({
|
||||||
|
where: { orderNo },
|
||||||
|
});
|
||||||
|
if (!record) return null;
|
||||||
|
return this.toDomain(record);
|
||||||
|
}
|
||||||
|
|
||||||
|
async findPaidOrdersByUserId(
|
||||||
|
tx: Prisma.TransactionClient,
|
||||||
|
userId: bigint,
|
||||||
|
limit: number,
|
||||||
|
): Promise<PrePlantingOrder[]> {
|
||||||
|
const records = await tx.prePlantingOrder.findMany({
|
||||||
|
where: { userId, status: PrePlantingOrderStatus.PAID },
|
||||||
|
orderBy: { createdAt: 'asc' },
|
||||||
|
take: limit,
|
||||||
|
});
|
||||||
|
return records.map((r) => this.toDomain(r));
|
||||||
|
}
|
||||||
|
|
||||||
|
async findByUserId(
|
||||||
|
tx: Prisma.TransactionClient,
|
||||||
|
userId: bigint,
|
||||||
|
): Promise<PrePlantingOrder[]> {
|
||||||
|
const records = await tx.prePlantingOrder.findMany({
|
||||||
|
where: { userId },
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
});
|
||||||
|
return records.map((r) => this.toDomain(r));
|
||||||
|
}
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
private toDomain(record: any): PrePlantingOrder {
|
||||||
|
const data: PrePlantingOrderData = {
|
||||||
|
id: record.id,
|
||||||
|
orderNo: record.orderNo,
|
||||||
|
userId: record.userId,
|
||||||
|
accountSequence: record.accountSequence,
|
||||||
|
portionCount: record.portionCount,
|
||||||
|
pricePerPortion: Number(record.pricePerPortion),
|
||||||
|
totalAmount: Number(record.totalAmount),
|
||||||
|
provinceCode: record.provinceCode,
|
||||||
|
cityCode: record.cityCode,
|
||||||
|
status: record.status as PrePlantingOrderStatus,
|
||||||
|
mergedToMergeId: record.mergedToMergeId,
|
||||||
|
mergedAt: record.mergedAt,
|
||||||
|
createdAt: record.createdAt,
|
||||||
|
paidAt: record.paidAt,
|
||||||
|
};
|
||||||
|
return PrePlantingOrder.reconstitute(data);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,106 @@
|
||||||
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
|
import { Prisma } from '@prisma/client';
|
||||||
|
import {
|
||||||
|
PrePlantingPosition,
|
||||||
|
PrePlantingPositionData,
|
||||||
|
} from '../../domain/aggregates/pre-planting-position.aggregate';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class PrePlantingPositionRepository {
|
||||||
|
private readonly logger = new Logger(PrePlantingPositionRepository.name);
|
||||||
|
|
||||||
|
async save(
|
||||||
|
tx: Prisma.TransactionClient,
|
||||||
|
position: PrePlantingPosition,
|
||||||
|
): Promise<void> {
|
||||||
|
const data = position.toPersistence();
|
||||||
|
|
||||||
|
if (position.id) {
|
||||||
|
await tx.prePlantingPosition.update({
|
||||||
|
where: { id: position.id },
|
||||||
|
data: {
|
||||||
|
totalPortions: data.totalPortions,
|
||||||
|
availablePortions: data.availablePortions,
|
||||||
|
mergedPortions: data.mergedPortions,
|
||||||
|
totalTreesMerged: data.totalTreesMerged,
|
||||||
|
provinceCode: data.provinceCode || null,
|
||||||
|
cityCode: data.cityCode || null,
|
||||||
|
firstPurchaseAt: data.firstPurchaseAt || null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
const created = await tx.prePlantingPosition.create({
|
||||||
|
data: {
|
||||||
|
userId: data.userId,
|
||||||
|
accountSequence: data.accountSequence,
|
||||||
|
totalPortions: data.totalPortions,
|
||||||
|
availablePortions: data.availablePortions,
|
||||||
|
mergedPortions: data.mergedPortions,
|
||||||
|
totalTreesMerged: data.totalTreesMerged,
|
||||||
|
provinceCode: data.provinceCode || null,
|
||||||
|
cityCode: data.cityCode || null,
|
||||||
|
firstPurchaseAt: data.firstPurchaseAt || null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
position.setId(created.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async getOrCreate(
|
||||||
|
tx: Prisma.TransactionClient,
|
||||||
|
userId: bigint,
|
||||||
|
accountSequence: string,
|
||||||
|
): Promise<PrePlantingPosition> {
|
||||||
|
const existing = await tx.prePlantingPosition.findUnique({
|
||||||
|
where: { userId },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
return this.toDomain(existing);
|
||||||
|
}
|
||||||
|
|
||||||
|
const position = PrePlantingPosition.create(userId, accountSequence);
|
||||||
|
await this.save(tx, position);
|
||||||
|
return position;
|
||||||
|
}
|
||||||
|
|
||||||
|
async findByUserId(
|
||||||
|
tx: Prisma.TransactionClient,
|
||||||
|
userId: bigint,
|
||||||
|
): Promise<PrePlantingPosition | null> {
|
||||||
|
const record = await tx.prePlantingPosition.findUnique({
|
||||||
|
where: { userId },
|
||||||
|
});
|
||||||
|
if (!record) return null;
|
||||||
|
return this.toDomain(record);
|
||||||
|
}
|
||||||
|
|
||||||
|
async findByAccountSequence(
|
||||||
|
tx: Prisma.TransactionClient,
|
||||||
|
accountSequence: string,
|
||||||
|
): Promise<PrePlantingPosition | null> {
|
||||||
|
const record = await tx.prePlantingPosition.findUnique({
|
||||||
|
where: { accountSequence },
|
||||||
|
});
|
||||||
|
if (!record) return null;
|
||||||
|
return this.toDomain(record);
|
||||||
|
}
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
private toDomain(record: any): PrePlantingPosition {
|
||||||
|
const data: PrePlantingPositionData = {
|
||||||
|
id: record.id,
|
||||||
|
userId: record.userId,
|
||||||
|
accountSequence: record.accountSequence,
|
||||||
|
totalPortions: record.totalPortions,
|
||||||
|
availablePortions: record.availablePortions,
|
||||||
|
mergedPortions: record.mergedPortions,
|
||||||
|
totalTreesMerged: record.totalTreesMerged,
|
||||||
|
provinceCode: record.provinceCode,
|
||||||
|
cityCode: record.cityCode,
|
||||||
|
firstPurchaseAt: record.firstPurchaseAt,
|
||||||
|
createdAt: record.createdAt,
|
||||||
|
};
|
||||||
|
return PrePlantingPosition.reconstitute(data);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,78 @@
|
||||||
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
|
import { Prisma } from '@prisma/client';
|
||||||
|
import { PrePlantingRewardStatus } from '../../domain/value-objects/pre-planting-reward-status.enum';
|
||||||
|
|
||||||
|
export interface PrePlantingRewardEntryData {
|
||||||
|
sourceOrderNo: string;
|
||||||
|
sourceAccountSequence: string;
|
||||||
|
recipientAccountSequence: string;
|
||||||
|
rightType: string;
|
||||||
|
usdtAmount: number;
|
||||||
|
rewardStatus: PrePlantingRewardStatus;
|
||||||
|
memo?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class PrePlantingRewardEntryRepository {
|
||||||
|
private readonly logger = new Logger(PrePlantingRewardEntryRepository.name);
|
||||||
|
|
||||||
|
async saveMany(
|
||||||
|
tx: Prisma.TransactionClient,
|
||||||
|
entries: PrePlantingRewardEntryData[],
|
||||||
|
): Promise<void> {
|
||||||
|
if (entries.length === 0) return;
|
||||||
|
|
||||||
|
await tx.prePlantingRewardEntry.createMany({
|
||||||
|
data: entries.map((entry) => ({
|
||||||
|
sourceOrderNo: entry.sourceOrderNo,
|
||||||
|
sourceAccountSequence: entry.sourceAccountSequence,
|
||||||
|
recipientAccountSequence: entry.recipientAccountSequence,
|
||||||
|
rightType: entry.rightType,
|
||||||
|
usdtAmount: new Prisma.Decimal(entry.usdtAmount),
|
||||||
|
rewardStatus: entry.rewardStatus,
|
||||||
|
memo: entry.memo || null,
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
|
||||||
|
this.logger.debug(
|
||||||
|
`[PRE-PLANTING] Saved ${entries.length} reward entries for order ${entries[0].sourceOrderNo}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async findByOrderNo(
|
||||||
|
tx: Prisma.TransactionClient,
|
||||||
|
orderNo: string,
|
||||||
|
): Promise<PrePlantingRewardEntryData[]> {
|
||||||
|
const records = await tx.prePlantingRewardEntry.findMany({
|
||||||
|
where: { sourceOrderNo: orderNo },
|
||||||
|
});
|
||||||
|
return records.map((r) => ({
|
||||||
|
sourceOrderNo: r.sourceOrderNo,
|
||||||
|
sourceAccountSequence: r.sourceAccountSequence,
|
||||||
|
recipientAccountSequence: r.recipientAccountSequence,
|
||||||
|
rightType: r.rightType,
|
||||||
|
usdtAmount: Number(r.usdtAmount),
|
||||||
|
rewardStatus: r.rewardStatus as PrePlantingRewardStatus,
|
||||||
|
memo: r.memo || undefined,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
async findByAccountSequence(
|
||||||
|
tx: Prisma.TransactionClient,
|
||||||
|
accountSequence: string,
|
||||||
|
): Promise<PrePlantingRewardEntryData[]> {
|
||||||
|
const records = await tx.prePlantingRewardEntry.findMany({
|
||||||
|
where: { sourceAccountSequence: accountSequence },
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
});
|
||||||
|
return records.map((r) => ({
|
||||||
|
sourceOrderNo: r.sourceOrderNo,
|
||||||
|
sourceAccountSequence: r.sourceAccountSequence,
|
||||||
|
recipientAccountSequence: r.recipientAccountSequence,
|
||||||
|
rightType: r.rightType,
|
||||||
|
usdtAmount: Number(r.usdtAmount),
|
||||||
|
rewardStatus: r.rewardStatus as PrePlantingRewardStatus,
|
||||||
|
memo: r.memo || undefined,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,78 @@
|
||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { HttpModule } from '@nestjs/axios';
|
||||||
|
|
||||||
|
// Controllers
|
||||||
|
import { PrePlantingController } from './api/controllers/pre-planting.controller';
|
||||||
|
import { InternalPrePlantingController } from './api/controllers/internal-pre-planting.controller';
|
||||||
|
|
||||||
|
// Application Services
|
||||||
|
import { PrePlantingApplicationService } from './application/services/pre-planting-application.service';
|
||||||
|
import { PrePlantingRewardService } from './application/services/pre-planting-reward.service';
|
||||||
|
|
||||||
|
// Repositories
|
||||||
|
import { PrePlantingOrderRepository } from './infrastructure/repositories/pre-planting-order.repository';
|
||||||
|
import { PrePlantingPositionRepository } from './infrastructure/repositories/pre-planting-position.repository';
|
||||||
|
import { PrePlantingMergeRepository } from './infrastructure/repositories/pre-planting-merge.repository';
|
||||||
|
import { PrePlantingRewardEntryRepository } from './infrastructure/repositories/pre-planting-reward-entry.repository';
|
||||||
|
|
||||||
|
// External Clients
|
||||||
|
import { PrePlantingAdminClient } from './infrastructure/external/pre-planting-admin.client';
|
||||||
|
import { PrePlantingReferralClient } from './infrastructure/external/pre-planting-referral.client';
|
||||||
|
import { PrePlantingAuthorizationClient } from './infrastructure/external/pre-planting-authorization.client';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 预种计划模块 (3171 预种计划 / 拼种团购计划)
|
||||||
|
*
|
||||||
|
* === 功能概述 ===
|
||||||
|
* 用户以 3171 USDT/份 参与认种(1棵树的1/5价格),累计5份自动合成1棵树,
|
||||||
|
* 触发合同签署和挖矿开启,同时解除交易和提现限制。
|
||||||
|
*
|
||||||
|
* === 架构设计 ===
|
||||||
|
* - 完全独立的 NestJS Module,与现有 PlantingOrder 聚合根零耦合
|
||||||
|
* - 拥有自己的聚合根:PrePlantingOrder、PrePlantingPosition、PrePlantingMerge
|
||||||
|
* - 拥有自己的 Prisma 表:pre_planting_orders、pre_planting_positions、
|
||||||
|
* pre_planting_merges、pre_planting_reward_entries
|
||||||
|
* - 拥有自己的 Kafka Topic:pre-planting.portion.purchased、pre-planting.merged、
|
||||||
|
* pre-planting.contract.signed(不复用任何现有 Topic)
|
||||||
|
*
|
||||||
|
* === 跨服务调用 ===
|
||||||
|
* - wallet-service: 冻结/确认扣款/分配资金(复用已有内部 API)
|
||||||
|
* - referral-service: 获取推荐人信息(GET /api/v1/referrals/:seq/chain)
|
||||||
|
* - authorization-service: 获取社区/省/市权益分配对象
|
||||||
|
* - admin-service: 获取预种开关状态
|
||||||
|
*
|
||||||
|
* === 对现有系统的影响 ===
|
||||||
|
* - 零侵入:不创建 PlantingOrder → 现有 referral/reward/contract 流程完全不受触发
|
||||||
|
* - 不发布 planting.planting.created → 现有消费者无感知
|
||||||
|
* - 通过 InfrastructureModule (@Global) 注入共享依赖
|
||||||
|
*/
|
||||||
|
@Module({
|
||||||
|
imports: [
|
||||||
|
HttpModule.register({
|
||||||
|
timeout: 5000,
|
||||||
|
maxRedirects: 5,
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
controllers: [
|
||||||
|
PrePlantingController,
|
||||||
|
InternalPrePlantingController,
|
||||||
|
],
|
||||||
|
providers: [
|
||||||
|
// Application Services
|
||||||
|
PrePlantingApplicationService,
|
||||||
|
PrePlantingRewardService,
|
||||||
|
|
||||||
|
// Repositories
|
||||||
|
PrePlantingOrderRepository,
|
||||||
|
PrePlantingPositionRepository,
|
||||||
|
PrePlantingMergeRepository,
|
||||||
|
PrePlantingRewardEntryRepository,
|
||||||
|
|
||||||
|
// External Clients
|
||||||
|
PrePlantingAdminClient,
|
||||||
|
PrePlantingReferralClient,
|
||||||
|
PrePlantingAuthorizationClient,
|
||||||
|
],
|
||||||
|
exports: [PrePlantingApplicationService],
|
||||||
|
})
|
||||||
|
export class PrePlantingModule {}
|
||||||
|
|
@ -88,6 +88,10 @@ model TeamStatistics {
|
||||||
provinceTeamPercentage Decimal @default(0) @map("province_team_percentage") @db.Decimal(5, 2) // 本省占比
|
provinceTeamPercentage Decimal @default(0) @map("province_team_percentage") @db.Decimal(5, 2) // 本省占比
|
||||||
cityTeamPercentage Decimal @default(0) @map("city_team_percentage") @db.Decimal(5, 2) // 本市占比
|
cityTeamPercentage Decimal @default(0) @map("city_team_percentage") @db.Decimal(5, 2) // 本市占比
|
||||||
|
|
||||||
|
// === 预种计划统计 ===
|
||||||
|
selfPrePlantingPortions Int @default(0) @map("self_pre_planting_portions") // 个人预种份数
|
||||||
|
teamPrePlantingPortions Int @default(0) @map("team_pre_planting_portions") // 团队预种份数(含自己和所有下级)
|
||||||
|
|
||||||
// === 省市分布 (JSON存储详细分布) ===
|
// === 省市分布 (JSON存储详细分布) ===
|
||||||
// 格式: { "provinceCode": { "cityCode": count, ... }, ... }
|
// 格式: { "provinceCode": { "cityCode": count, ... }, ... }
|
||||||
provinceCityDistribution Json @default("{}") @map("province_city_distribution")
|
provinceCityDistribution Json @default("{}") @map("province_city_distribution")
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,8 @@
|
||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
import { ConfigModule } from '@nestjs/config';
|
import { ConfigModule } from '@nestjs/config';
|
||||||
import { ApiModule } from './modules';
|
import { ApiModule } from './modules';
|
||||||
|
// [2026-02-17] 新增:预种计划团队统计
|
||||||
|
import { PrePlantingStatsModule } from './pre-planting/pre-planting-stats.module';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
|
|
@ -9,6 +11,8 @@ import { ApiModule } from './modules';
|
||||||
envFilePath: ['.env.development', '.env'],
|
envFilePath: ['.env.development', '.env'],
|
||||||
}),
|
}),
|
||||||
ApiModule,
|
ApiModule,
|
||||||
|
// [2026-02-17] 新增:预种计划团队统计(消费 pre-planting.portion.purchased 事件)
|
||||||
|
PrePlantingStatsModule,
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
export class AppModule {}
|
export class AppModule {}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,275 @@
|
||||||
|
import { Injectable, Inject, Logger, OnModuleInit } from '@nestjs/common';
|
||||||
|
import { KafkaService, PrismaService } from '../infrastructure';
|
||||||
|
import { EventAckPublisher } from '../infrastructure/kafka/event-ack.publisher';
|
||||||
|
import {
|
||||||
|
REFERRAL_RELATIONSHIP_REPOSITORY,
|
||||||
|
IReferralRelationshipRepository,
|
||||||
|
TEAM_STATISTICS_REPOSITORY,
|
||||||
|
ITeamStatisticsRepository,
|
||||||
|
} from '../domain';
|
||||||
|
import { TeamStatisticsService } from '../application/services';
|
||||||
|
import { UpdateTeamStatisticsCommand } from '../application/commands';
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// 预种购买事件结构
|
||||||
|
// ============================================
|
||||||
|
interface PrePlantingPurchasedEvent {
|
||||||
|
eventName: string;
|
||||||
|
data: {
|
||||||
|
orderNo: string;
|
||||||
|
userId: string;
|
||||||
|
accountSequence: string;
|
||||||
|
portionCount: number;
|
||||||
|
totalAmount: number;
|
||||||
|
provinceCode: string;
|
||||||
|
cityCode: string;
|
||||||
|
totalPortionsAfter: number;
|
||||||
|
availablePortionsAfter: number;
|
||||||
|
};
|
||||||
|
_outbox?: {
|
||||||
|
id: string;
|
||||||
|
aggregateId: string;
|
||||||
|
eventType: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// 预种合并事件结构
|
||||||
|
// ============================================
|
||||||
|
interface PrePlantingMergedEvent {
|
||||||
|
eventName: string;
|
||||||
|
data: {
|
||||||
|
mergeNo: string;
|
||||||
|
userId: string;
|
||||||
|
accountSequence: string;
|
||||||
|
sourceOrderNos: string[];
|
||||||
|
treeCount: number;
|
||||||
|
provinceCode: string;
|
||||||
|
cityCode: string;
|
||||||
|
totalTreesMergedAfter: number;
|
||||||
|
};
|
||||||
|
_outbox?: {
|
||||||
|
id: string;
|
||||||
|
aggregateId: string;
|
||||||
|
eventType: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 预种事件处理器
|
||||||
|
*
|
||||||
|
* 消费 planting-service 发出的预种事件,更新团队统计
|
||||||
|
*
|
||||||
|
* 处理两类事件:
|
||||||
|
* 1. pre-planting.portion.purchased — 更新预种份数统计(新增字段)
|
||||||
|
* 2. pre-planting.merged — 5 份合并成树后,更新树级统计(复用现有 handlePlantingEvent)
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class PrePlantingPurchasedHandler implements OnModuleInit {
|
||||||
|
private readonly logger = new Logger(PrePlantingPurchasedHandler.name);
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly kafkaService: KafkaService,
|
||||||
|
private readonly prisma: PrismaService,
|
||||||
|
private readonly eventAckPublisher: EventAckPublisher,
|
||||||
|
private readonly teamStatisticsService: TeamStatisticsService,
|
||||||
|
@Inject(REFERRAL_RELATIONSHIP_REPOSITORY)
|
||||||
|
private readonly referralRepo: IReferralRelationshipRepository,
|
||||||
|
@Inject(TEAM_STATISTICS_REPOSITORY)
|
||||||
|
private readonly teamStatsRepo: ITeamStatisticsRepository,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async onModuleInit() {
|
||||||
|
// 订阅预种购买事件
|
||||||
|
await this.kafkaService.subscribe(
|
||||||
|
'referral-service-pre-planting-purchased',
|
||||||
|
['pre-planting.portion.purchased'],
|
||||||
|
this.handlePurchased.bind(this),
|
||||||
|
);
|
||||||
|
|
||||||
|
// 订阅预种合并事件
|
||||||
|
await this.kafkaService.subscribe(
|
||||||
|
'referral-service-pre-planting-merged',
|
||||||
|
['pre-planting.merged'],
|
||||||
|
this.handleMerged.bind(this),
|
||||||
|
);
|
||||||
|
|
||||||
|
this.logger.log('Subscribed to pre-planting events (purchased + merged)');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// 处理预种购买事件
|
||||||
|
// 更新用户和所有上级的预种份数统计
|
||||||
|
// ============================================
|
||||||
|
private async handlePurchased(
|
||||||
|
topic: string,
|
||||||
|
message: Record<string, unknown>,
|
||||||
|
): Promise<void> {
|
||||||
|
const event = message as unknown as PrePlantingPurchasedEvent;
|
||||||
|
|
||||||
|
if (event.eventName !== 'pre-planting.portion.purchased') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const outboxInfo = event._outbox;
|
||||||
|
const eventId = outboxInfo?.aggregateId || 'unknown';
|
||||||
|
|
||||||
|
// 幂等性检查
|
||||||
|
if (eventId !== 'unknown') {
|
||||||
|
const existing = await this.prisma.processedEvent.findUnique({
|
||||||
|
where: { eventId },
|
||||||
|
});
|
||||||
|
if (existing) {
|
||||||
|
this.logger.log(`[PRE-PLANTING] Event ${eventId} already processed, skipping`);
|
||||||
|
if (outboxInfo) {
|
||||||
|
await this.eventAckPublisher.sendSuccess(eventId, outboxInfo.eventType);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { accountSequence, portionCount } = event.data;
|
||||||
|
|
||||||
|
// 1. 查找用户推荐关系
|
||||||
|
const relationship = await this.referralRepo.findByAccountSequence(accountSequence);
|
||||||
|
if (!relationship) {
|
||||||
|
this.logger.warn(
|
||||||
|
`[PRE-PLANTING] User ${accountSequence} has no referral relationship`,
|
||||||
|
);
|
||||||
|
// 仍然发送确认,避免无限重试
|
||||||
|
if (outboxInfo) {
|
||||||
|
await this.eventAckPublisher.sendSuccess(eventId, outboxInfo.eventType);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const userId = relationship.userId;
|
||||||
|
|
||||||
|
// 2. 更新用户自己的预种份数
|
||||||
|
await this.prisma.teamStatistics.updateMany({
|
||||||
|
where: { userId },
|
||||||
|
data: {
|
||||||
|
selfPrePlantingPortions: { increment: portionCount },
|
||||||
|
teamPrePlantingPortions: { increment: portionCount },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// 3. 获取所有上级并更新团队预种份数
|
||||||
|
const ancestors = relationship.getAllAncestorIds();
|
||||||
|
if (ancestors.length > 0) {
|
||||||
|
await this.prisma.teamStatistics.updateMany({
|
||||||
|
where: { userId: { in: ancestors } },
|
||||||
|
data: {
|
||||||
|
teamPrePlantingPortions: { increment: portionCount },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
this.logger.log(
|
||||||
|
`[PRE-PLANTING] Updated pre-planting portions: user=${accountSequence}, ` +
|
||||||
|
`portions=${portionCount}, ancestors=${ancestors.length}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
// 4. 记录已处理
|
||||||
|
if (eventId !== 'unknown') {
|
||||||
|
await this.prisma.processedEvent.create({
|
||||||
|
data: {
|
||||||
|
eventId,
|
||||||
|
eventType: outboxInfo?.eventType || 'pre-planting.portion.purchased',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. 发送确认
|
||||||
|
if (outboxInfo) {
|
||||||
|
await this.eventAckPublisher.sendSuccess(eventId, outboxInfo.eventType);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error(
|
||||||
|
`[PRE-PLANTING] Failed to process purchase event for ${event.data.accountSequence}:`,
|
||||||
|
error,
|
||||||
|
);
|
||||||
|
if (outboxInfo) {
|
||||||
|
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||||
|
await this.eventAckPublisher.sendFailure(eventId, outboxInfo.eventType, errorMessage);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// 处理预种合并事件
|
||||||
|
// 5 份合并成 1 棵树 → 更新树级团队统计(复用现有流程)
|
||||||
|
// ============================================
|
||||||
|
private async handleMerged(
|
||||||
|
topic: string,
|
||||||
|
message: Record<string, unknown>,
|
||||||
|
): Promise<void> {
|
||||||
|
const event = message as unknown as PrePlantingMergedEvent;
|
||||||
|
|
||||||
|
if (event.eventName !== 'pre-planting.merged') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const outboxInfo = event._outbox;
|
||||||
|
const eventId = outboxInfo?.aggregateId || 'unknown';
|
||||||
|
|
||||||
|
// 幂等性检查
|
||||||
|
if (eventId !== 'unknown') {
|
||||||
|
const existing = await this.prisma.processedEvent.findUnique({
|
||||||
|
where: { eventId },
|
||||||
|
});
|
||||||
|
if (existing) {
|
||||||
|
this.logger.log(`[PRE-PLANTING] Merge event ${eventId} already processed, skipping`);
|
||||||
|
if (outboxInfo) {
|
||||||
|
await this.eventAckPublisher.sendSuccess(eventId, outboxInfo.eventType);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { accountSequence, treeCount, provinceCode, cityCode } = event.data;
|
||||||
|
|
||||||
|
// 复用现有 TeamStatisticsService 更新树级统计
|
||||||
|
// 这会更新 selfPlantingCount, totalTeamPlantingCount, leaderboard, 省市分布等
|
||||||
|
const command = new UpdateTeamStatisticsCommand(
|
||||||
|
accountSequence,
|
||||||
|
treeCount,
|
||||||
|
provinceCode,
|
||||||
|
cityCode,
|
||||||
|
);
|
||||||
|
|
||||||
|
await this.teamStatisticsService.handlePlantingEvent(command);
|
||||||
|
|
||||||
|
this.logger.log(
|
||||||
|
`[PRE-PLANTING] Merge event processed: mergeNo=${event.data.mergeNo}, ` +
|
||||||
|
`trees=${treeCount}, accountSequence=${accountSequence}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
// 记录已处理
|
||||||
|
if (eventId !== 'unknown') {
|
||||||
|
await this.prisma.processedEvent.create({
|
||||||
|
data: {
|
||||||
|
eventId,
|
||||||
|
eventType: outboxInfo?.eventType || 'pre-planting.merged',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 发送确认
|
||||||
|
if (outboxInfo) {
|
||||||
|
await this.eventAckPublisher.sendSuccess(eventId, outboxInfo.eventType);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error(
|
||||||
|
`[PRE-PLANTING] Failed to process merge event for ${event.data.accountSequence}:`,
|
||||||
|
error,
|
||||||
|
);
|
||||||
|
if (outboxInfo) {
|
||||||
|
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||||
|
await this.eventAckPublisher.sendFailure(eventId, outboxInfo.eventType, errorMessage);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,18 @@
|
||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { ApplicationModule } from '../modules/application.module';
|
||||||
|
import { PrePlantingPurchasedHandler } from './pre-planting-purchased.handler';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 预种团队统计模块
|
||||||
|
*
|
||||||
|
* 消费 planting-service 发出的预种事件,更新团队统计
|
||||||
|
*
|
||||||
|
* 依赖:
|
||||||
|
* - InfrastructureModule (@Global):KafkaService, PrismaService, EventAckPublisher, Repos
|
||||||
|
* - ApplicationModule:TeamStatisticsService(合并事件复用树级统计更新)
|
||||||
|
*/
|
||||||
|
@Module({
|
||||||
|
imports: [ApplicationModule],
|
||||||
|
providers: [PrePlantingPurchasedHandler],
|
||||||
|
})
|
||||||
|
export class PrePlantingStatsModule {}
|
||||||
|
|
@ -7,6 +7,8 @@ import { InfrastructureModule } from '@/infrastructure/infrastructure.module';
|
||||||
import { DomainExceptionFilter } from '@/shared/filters/domain-exception.filter';
|
import { DomainExceptionFilter } from '@/shared/filters/domain-exception.filter';
|
||||||
import { TransformInterceptor } from '@/shared/interceptors/transform.interceptor';
|
import { TransformInterceptor } from '@/shared/interceptors/transform.interceptor';
|
||||||
import { JwtAuthGuard } from '@/shared/guards/jwt-auth.guard';
|
import { JwtAuthGuard } from '@/shared/guards/jwt-auth.guard';
|
||||||
|
// [2026-02-17] 新增:预种计划提现限制
|
||||||
|
import { PrePlantingGuardModule } from './pre-planting/pre-planting-guard.module';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
|
|
@ -22,6 +24,8 @@ import { JwtAuthGuard } from '@/shared/guards/jwt-auth.guard';
|
||||||
ScheduleModule.forRoot(),
|
ScheduleModule.forRoot(),
|
||||||
InfrastructureModule,
|
InfrastructureModule,
|
||||||
ApiModule,
|
ApiModule,
|
||||||
|
// [2026-02-17] 新增:预种计划提现限制
|
||||||
|
PrePlantingGuardModule,
|
||||||
],
|
],
|
||||||
providers: [
|
providers: [
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,84 @@
|
||||||
|
import {
|
||||||
|
Injectable,
|
||||||
|
NestInterceptor,
|
||||||
|
ExecutionContext,
|
||||||
|
CallHandler,
|
||||||
|
ForbiddenException,
|
||||||
|
Logger,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { Observable } from 'rxjs';
|
||||||
|
import { PrePlantingClient } from './pre-planting.client';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 预种提现限制拦截器
|
||||||
|
*
|
||||||
|
* 仅拦截提现路由(POST /wallet/withdraw, /wallet/fiat-withdrawal)
|
||||||
|
* 规则:
|
||||||
|
* - 无预种记录(纯认种用户)→ 直接放行
|
||||||
|
* - 有预种记录且已合并成树 → 放行
|
||||||
|
* - 有预种记录但未合并 → 拦截提现
|
||||||
|
* - SMS 验证码发送和取消操作不拦截
|
||||||
|
* - planting-service 不可达 → 放行(fail-open)
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class PrePlantingWithdrawalInterceptor implements NestInterceptor {
|
||||||
|
private readonly logger = new Logger(PrePlantingWithdrawalInterceptor.name);
|
||||||
|
|
||||||
|
constructor(private readonly client: PrePlantingClient) {}
|
||||||
|
|
||||||
|
async intercept(
|
||||||
|
context: ExecutionContext,
|
||||||
|
next: CallHandler,
|
||||||
|
): Promise<Observable<unknown>> {
|
||||||
|
const req = context.switchToHttp().getRequest();
|
||||||
|
|
||||||
|
// 仅拦截 POST 请求
|
||||||
|
if (req.method !== 'POST') {
|
||||||
|
return next.handle();
|
||||||
|
}
|
||||||
|
|
||||||
|
const reqPath: string = req.path || req.url || '';
|
||||||
|
|
||||||
|
// 排除 SMS 和取消操作
|
||||||
|
if (reqPath.includes('/send-sms') || reqPath.includes('/cancel')) {
|
||||||
|
return next.handle();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 仅拦截提现路由
|
||||||
|
const isWithdrawal =
|
||||||
|
reqPath.endsWith('/wallet/withdraw') ||
|
||||||
|
reqPath.endsWith('/wallet/fiat-withdrawal');
|
||||||
|
if (!isWithdrawal) {
|
||||||
|
return next.handle();
|
||||||
|
}
|
||||||
|
|
||||||
|
const accountSequence = req.user?.accountSequence;
|
||||||
|
if (!accountSequence) {
|
||||||
|
return next.handle();
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const eligibility = await this.client.getEligibility(accountSequence);
|
||||||
|
|
||||||
|
// 无预种记录 → 纯认种用户,直接放行
|
||||||
|
if (!eligibility.hasPrePlanting) {
|
||||||
|
return next.handle();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 有预种但未满足条件 → 拦截提现
|
||||||
|
if (!eligibility.canTrade) {
|
||||||
|
throw new ForbiddenException(
|
||||||
|
'须累积购买5份预种计划合并成树后方可提现',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof ForbiddenException) throw error;
|
||||||
|
// planting-service 不可达,默认放行
|
||||||
|
this.logger.warn(
|
||||||
|
`[PRE-PLANTING] Failed to check eligibility for ${accountSequence}, allowing through`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return next.handle();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,22 @@
|
||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { APP_INTERCEPTOR } from '@nestjs/core';
|
||||||
|
import { PrePlantingClient } from './pre-planting.client';
|
||||||
|
import { PrePlantingWithdrawalInterceptor } from './pre-planting-guard.interceptor';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 预种提现限制模块
|
||||||
|
*
|
||||||
|
* 注册路由级 Interceptor,仅对提现路由生效
|
||||||
|
* 未合并成树的预种用户不可提现
|
||||||
|
*/
|
||||||
|
@Module({
|
||||||
|
providers: [
|
||||||
|
PrePlantingClient,
|
||||||
|
PrePlantingWithdrawalInterceptor,
|
||||||
|
{
|
||||||
|
provide: APP_INTERCEPTOR,
|
||||||
|
useClass: PrePlantingWithdrawalInterceptor,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})
|
||||||
|
export class PrePlantingGuardModule {}
|
||||||
|
|
@ -0,0 +1,35 @@
|
||||||
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
import axios from 'axios';
|
||||||
|
|
||||||
|
export interface PrePlantingEligibility {
|
||||||
|
hasPrePlanting: boolean;
|
||||||
|
totalPortions: number;
|
||||||
|
totalTreesMerged: number;
|
||||||
|
canApplyAuthorization: boolean;
|
||||||
|
canTrade: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 调用 planting-service 内部 API 查询预种资格
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class PrePlantingClient {
|
||||||
|
private readonly logger = new Logger(PrePlantingClient.name);
|
||||||
|
private readonly baseUrl: string;
|
||||||
|
|
||||||
|
constructor(private readonly configService: ConfigService) {
|
||||||
|
this.baseUrl = this.configService.get<string>(
|
||||||
|
'PLANTING_SERVICE_URL',
|
||||||
|
'http://localhost:3003',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async getEligibility(accountSequence: string): Promise<PrePlantingEligibility> {
|
||||||
|
const url = `${this.baseUrl}/internal/pre-planting/eligibility/${accountSequence}`;
|
||||||
|
const response = await axios.get<PrePlantingEligibility>(url, {
|
||||||
|
timeout: 3000,
|
||||||
|
});
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue