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:
hailin 2026-02-18 05:06:03 -08:00
parent 875f86c263
commit 010b0392fd
44 changed files with 3089 additions and 0 deletions

View File

@ -1258,3 +1258,17 @@ model CustomerServiceContact {
@@index([sortOrder])
@@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")
}

View File

@ -84,6 +84,9 @@ import { AdminCustomerServiceContactController, PublicCustomerServiceContactCont
// [2026-02-05] 新增:合同管理模块
import { ContractController } from './api/controllers/contract.controller';
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({
imports: [
@ -127,6 +130,9 @@ import { ContractService } from './application/services/contract.service';
PublicCustomerServiceContactController,
// [2026-02-05] 新增:合同管理控制器
ContractController,
// [2026-02-17] 新增:预种计划开关管理
PrePlantingConfigController,
PublicPrePlantingConfigController,
],
providers: [
PrismaService,
@ -216,6 +222,8 @@ import { ContractService } from './application/services/contract.service';
},
// [2026-02-05] 新增:合同管理服务
ContractService,
// [2026-02-17] 新增:预种计划开关管理
PrePlantingConfigService,
],
})
export class AppModule {}

View File

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

View File

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

View File

@ -52,6 +52,8 @@ import {
// Shared
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)
const MockReferralRepository = {
@ -79,6 +81,8 @@ const MockReferralRepository = {
}),
RedisModule,
KafkaModule,
// [2026-02-17] 新增:预种计划授权限制
PrePlantingGuardModule,
],
controllers: [
AuthorizationController,

View File

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

View File

@ -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 {}

View File

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

View File

@ -390,3 +390,148 @@ model DebeziumHeartbeat {
@@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")
}

View File

@ -5,6 +5,8 @@ import { InfrastructureModule } from './infrastructure/infrastructure.module';
import { DomainModule } from './domain/domain.module';
import { ApplicationModule } from './application/application.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 configs from './config';
@ -19,6 +21,7 @@ import configs from './config';
DomainModule,
ApplicationModule,
ApiModule,
PrePlantingModule, // 预种计划:独立聚合根、独立 Kafka Topic、独立数据表
],
providers: [
{

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,3 @@
export * from './pre-planting-portion-purchased.event';
export * from './pre-planting-merged.event';
export * from './pre-planting-contract-signed.event';

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,5 @@
export enum PrePlantingContractStatus {
PENDING = 'PENDING',
SIGNED = 'SIGNED',
EXPIRED = 'EXPIRED',
}

View File

@ -0,0 +1,5 @@
export enum PrePlantingOrderStatus {
CREATED = 'CREATED',
PAID = 'PAID',
MERGED = 'MERGED',
}

View File

@ -0,0 +1,5 @@
export enum PrePlantingRewardStatus {
SETTLED = 'SETTLED',
PENDING = 'PENDING',
EXPIRED = 'EXPIRED',
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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/ 11/551
*
*
* === ===
* - NestJS Module PlantingOrder
* - PrePlantingOrderPrePlantingPositionPrePlantingMerge
* - Prisma pre_planting_orderspre_planting_positions
* pre_planting_mergespre_planting_reward_entries
* - Kafka Topicpre-planting.portion.purchasedpre-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 {}

View File

@ -88,6 +88,10 @@ model TeamStatistics {
provinceTeamPercentage Decimal @default(0) @map("province_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存储详细分布) ===
// 格式: { "provinceCode": { "cityCode": count, ... }, ... }
provinceCityDistribution Json @default("{}") @map("province_city_distribution")

View File

@ -1,6 +1,8 @@
import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { ApiModule } from './modules';
// [2026-02-17] 新增:预种计划团队统计
import { PrePlantingStatsModule } from './pre-planting/pre-planting-stats.module';
@Module({
imports: [
@ -9,6 +11,8 @@ import { ApiModule } from './modules';
envFilePath: ['.env.development', '.env'],
}),
ApiModule,
// [2026-02-17] 新增:预种计划团队统计(消费 pre-planting.portion.purchased 事件)
PrePlantingStatsModule,
],
})
export class AppModule {}

View File

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

View File

@ -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
* - ApplicationModuleTeamStatisticsService
*/
@Module({
imports: [ApplicationModule],
providers: [PrePlantingPurchasedHandler],
})
export class PrePlantingStatsModule {}

View File

@ -7,6 +7,8 @@ import { InfrastructureModule } from '@/infrastructure/infrastructure.module';
import { DomainExceptionFilter } from '@/shared/filters/domain-exception.filter';
import { TransformInterceptor } from '@/shared/interceptors/transform.interceptor';
import { JwtAuthGuard } from '@/shared/guards/jwt-auth.guard';
// [2026-02-17] 新增:预种计划提现限制
import { PrePlantingGuardModule } from './pre-planting/pre-planting-guard.module';
@Module({
imports: [
@ -22,6 +24,8 @@ import { JwtAuthGuard } from '@/shared/guards/jwt-auth.guard';
ScheduleModule.forRoot(),
InfrastructureModule,
ApiModule,
// [2026-02-17] 新增:预种计划提现限制
PrePlantingGuardModule,
],
providers: [
{

View File

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

View File

@ -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 {}

View File

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