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])
|
||||
@@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] 新增:合同管理模块
|
||||
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 {}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
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,
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// 预种订单表 (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 { 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: [
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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) // 本省占比
|
||||
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")
|
||||
|
|
|
|||
|
|
@ -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 {}
|
||||
|
|
|
|||
|
|
@ -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 { 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: [
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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