diff --git a/backend/services/admin-service/prisma/schema.prisma b/backend/services/admin-service/prisma/schema.prisma index 4d345cdc..f094a0ee 100644 --- a/backend/services/admin-service/prisma/schema.prisma +++ b/backend/services/admin-service/prisma/schema.prisma @@ -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") +} diff --git a/backend/services/admin-service/src/app.module.ts b/backend/services/admin-service/src/app.module.ts index e1aff591..e73b583d 100644 --- a/backend/services/admin-service/src/app.module.ts +++ b/backend/services/admin-service/src/app.module.ts @@ -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 {} diff --git a/backend/services/admin-service/src/pre-planting/pre-planting-config.controller.ts b/backend/services/admin-service/src/pre-planting/pre-planting-config.controller.ts new file mode 100644 index 00000000..caf9da59 --- /dev/null +++ b/backend/services/admin-service/src/pre-planting/pre-planting-config.controller.ts @@ -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(); + } +} diff --git a/backend/services/admin-service/src/pre-planting/pre-planting-config.service.ts b/backend/services/admin-service/src/pre-planting/pre-planting-config.service.ts new file mode 100644 index 00000000..0228729a --- /dev/null +++ b/backend/services/admin-service/src/pre-planting/pre-planting-config.service.ts @@ -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, + }; + } +} diff --git a/backend/services/authorization-service/src/app.module.ts b/backend/services/authorization-service/src/app.module.ts index a348c712..e9463c6b 100644 --- a/backend/services/authorization-service/src/app.module.ts +++ b/backend/services/authorization-service/src/app.module.ts @@ -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, diff --git a/backend/services/authorization-service/src/pre-planting/pre-planting-guard.interceptor.ts b/backend/services/authorization-service/src/pre-planting/pre-planting-guard.interceptor.ts new file mode 100644 index 00000000..ac7cd5e8 --- /dev/null +++ b/backend/services/authorization-service/src/pre-planting/pre-planting-guard.interceptor.ts @@ -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> { + 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(); + } +} diff --git a/backend/services/authorization-service/src/pre-planting/pre-planting-guard.module.ts b/backend/services/authorization-service/src/pre-planting/pre-planting-guard.module.ts new file mode 100644 index 00000000..d1a2899a --- /dev/null +++ b/backend/services/authorization-service/src/pre-planting/pre-planting-guard.module.ts @@ -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 {} diff --git a/backend/services/authorization-service/src/pre-planting/pre-planting.client.ts b/backend/services/authorization-service/src/pre-planting/pre-planting.client.ts new file mode 100644 index 00000000..ce8709f7 --- /dev/null +++ b/backend/services/authorization-service/src/pre-planting/pre-planting.client.ts @@ -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( + 'PLANTING_SERVICE_URL', + 'http://localhost:3003', + ); + } + + async getEligibility(accountSequence: string): Promise { + const url = `${this.baseUrl}/internal/pre-planting/eligibility/${accountSequence}`; + const response = await axios.get(url, { + timeout: 3000, + }); + return response.data; + } +} diff --git a/backend/services/planting-service/prisma/schema.prisma b/backend/services/planting-service/prisma/schema.prisma index f69fb85b..f3205346 100644 --- a/backend/services/planting-service/prisma/schema.prisma +++ b/backend/services/planting-service/prisma/schema.prisma @@ -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") +} diff --git a/backend/services/planting-service/src/app.module.ts b/backend/services/planting-service/src/app.module.ts index 40c94eb9..c457639d 100644 --- a/backend/services/planting-service/src/app.module.ts +++ b/backend/services/planting-service/src/app.module.ts @@ -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: [ { diff --git a/backend/services/planting-service/src/pre-planting/api/controllers/internal-pre-planting.controller.ts b/backend/services/planting-service/src/pre-planting/api/controllers/internal-pre-planting.controller.ts new file mode 100644 index 00000000..36a0738f --- /dev/null +++ b/backend/services/planting-service/src/pre-planting/api/controllers/internal-pre-planting.controller.ts @@ -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); + } +} diff --git a/backend/services/planting-service/src/pre-planting/api/controllers/pre-planting.controller.ts b/backend/services/planting-service/src/pre-planting/api/controllers/pre-planting.controller.ts new file mode 100644 index 00000000..2399ead1 --- /dev/null +++ b/backend/services/planting-service/src/pre-planting/api/controllers/pre-planting.controller.ts @@ -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); + } +} diff --git a/backend/services/planting-service/src/pre-planting/api/dto/request/purchase-pre-planting.dto.ts b/backend/services/planting-service/src/pre-planting/api/dto/request/purchase-pre-planting.dto.ts new file mode 100644 index 00000000..4ac9fae2 --- /dev/null +++ b/backend/services/planting-service/src/pre-planting/api/dto/request/purchase-pre-planting.dto.ts @@ -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; +} diff --git a/backend/services/planting-service/src/pre-planting/api/dto/request/sign-pre-planting-contract.dto.ts b/backend/services/planting-service/src/pre-planting/api/dto/request/sign-pre-planting-contract.dto.ts new file mode 100644 index 00000000..c0ce59a4 --- /dev/null +++ b/backend/services/planting-service/src/pre-planting/api/dto/request/sign-pre-planting-contract.dto.ts @@ -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; +} diff --git a/backend/services/planting-service/src/pre-planting/application/services/pre-planting-application.service.ts b/backend/services/planting-service/src/pre-planting/application/services/pre-planting-application.service.ts new file mode 100644 index 00000000..ef7bdaf5 --- /dev/null +++ b/backend/services/planting-service/src/pre-planting/application/services/pre-planting-application.service.ts @@ -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, + 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, + 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 { + 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, + 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 { + 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(); + } +} diff --git a/backend/services/planting-service/src/pre-planting/application/services/pre-planting-reward.service.ts b/backend/services/planting-service/src/pre-planting/application/services/pre-planting-reward.service.ts new file mode 100644 index 00000000..a5e1c23c --- /dev/null +++ b/backend/services/planting-service/src/pre-planting/application/services/pre-planting-reward.service.ts @@ -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 { + 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 { + 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 { + // 只转 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, + })), + }); + } +} diff --git a/backend/services/planting-service/src/pre-planting/domain/aggregates/pre-planting-merge.aggregate.ts b/backend/services/planting-service/src/pre-planting/domain/aggregates/pre-planting-merge.aggregate.ts new file mode 100644 index 00000000..412534dd --- /dev/null +++ b/backend/services/planting-service/src/pre-planting/domain/aggregates/pre-planting-merge.aggregate.ts @@ -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, + }; + } +} diff --git a/backend/services/planting-service/src/pre-planting/domain/aggregates/pre-planting-order.aggregate.ts b/backend/services/planting-service/src/pre-planting/domain/aggregates/pre-planting-order.aggregate.ts new file mode 100644 index 00000000..dcbf391d --- /dev/null +++ b/backend/services/planting-service/src/pre-planting/domain/aggregates/pre-planting-order.aggregate.ts @@ -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, + }; + } +} diff --git a/backend/services/planting-service/src/pre-planting/domain/aggregates/pre-planting-position.aggregate.ts b/backend/services/planting-service/src/pre-planting/domain/aggregates/pre-planting-position.aggregate.ts new file mode 100644 index 00000000..e74c2e4a --- /dev/null +++ b/backend/services/planting-service/src/pre-planting/domain/aggregates/pre-planting-position.aggregate.ts @@ -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, + }; + } +} diff --git a/backend/services/planting-service/src/pre-planting/domain/events/index.ts b/backend/services/planting-service/src/pre-planting/domain/events/index.ts new file mode 100644 index 00000000..0d396630 --- /dev/null +++ b/backend/services/planting-service/src/pre-planting/domain/events/index.ts @@ -0,0 +1,3 @@ +export * from './pre-planting-portion-purchased.event'; +export * from './pre-planting-merged.event'; +export * from './pre-planting-contract-signed.event'; diff --git a/backend/services/planting-service/src/pre-planting/domain/events/pre-planting-contract-signed.event.ts b/backend/services/planting-service/src/pre-planting/domain/events/pre-planting-contract-signed.event.ts new file mode 100644 index 00000000..afeb818f --- /dev/null +++ b/backend/services/planting-service/src/pre-planting/domain/events/pre-planting-contract-signed.event.ts @@ -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(); + } +} diff --git a/backend/services/planting-service/src/pre-planting/domain/events/pre-planting-merged.event.ts b/backend/services/planting-service/src/pre-planting/domain/events/pre-planting-merged.event.ts new file mode 100644 index 00000000..2c8b8b11 --- /dev/null +++ b/backend/services/planting-service/src/pre-planting/domain/events/pre-planting-merged.event.ts @@ -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(); + } +} diff --git a/backend/services/planting-service/src/pre-planting/domain/events/pre-planting-portion-purchased.event.ts b/backend/services/planting-service/src/pre-planting/domain/events/pre-planting-portion-purchased.event.ts new file mode 100644 index 00000000..f8923a53 --- /dev/null +++ b/backend/services/planting-service/src/pre-planting/domain/events/pre-planting-portion-purchased.event.ts @@ -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(); + } +} diff --git a/backend/services/planting-service/src/pre-planting/domain/value-objects/index.ts b/backend/services/planting-service/src/pre-planting/domain/value-objects/index.ts new file mode 100644 index 00000000..23f366aa --- /dev/null +++ b/backend/services/planting-service/src/pre-planting/domain/value-objects/index.ts @@ -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'; diff --git a/backend/services/planting-service/src/pre-planting/domain/value-objects/pre-planting-contract-status.enum.ts b/backend/services/planting-service/src/pre-planting/domain/value-objects/pre-planting-contract-status.enum.ts new file mode 100644 index 00000000..d8bf20b8 --- /dev/null +++ b/backend/services/planting-service/src/pre-planting/domain/value-objects/pre-planting-contract-status.enum.ts @@ -0,0 +1,5 @@ +export enum PrePlantingContractStatus { + PENDING = 'PENDING', + SIGNED = 'SIGNED', + EXPIRED = 'EXPIRED', +} diff --git a/backend/services/planting-service/src/pre-planting/domain/value-objects/pre-planting-order-status.enum.ts b/backend/services/planting-service/src/pre-planting/domain/value-objects/pre-planting-order-status.enum.ts new file mode 100644 index 00000000..32006a76 --- /dev/null +++ b/backend/services/planting-service/src/pre-planting/domain/value-objects/pre-planting-order-status.enum.ts @@ -0,0 +1,5 @@ +export enum PrePlantingOrderStatus { + CREATED = 'CREATED', + PAID = 'PAID', + MERGED = 'MERGED', +} diff --git a/backend/services/planting-service/src/pre-planting/domain/value-objects/pre-planting-reward-status.enum.ts b/backend/services/planting-service/src/pre-planting/domain/value-objects/pre-planting-reward-status.enum.ts new file mode 100644 index 00000000..c4c23877 --- /dev/null +++ b/backend/services/planting-service/src/pre-planting/domain/value-objects/pre-planting-reward-status.enum.ts @@ -0,0 +1,5 @@ +export enum PrePlantingRewardStatus { + SETTLED = 'SETTLED', + PENDING = 'PENDING', + EXPIRED = 'EXPIRED', +} diff --git a/backend/services/planting-service/src/pre-planting/domain/value-objects/pre-planting-right-amounts.ts b/backend/services/planting-service/src/pre-planting/domain/value-objects/pre-planting-right-amounts.ts new file mode 100644 index 00000000..2c025fa3 --- /dev/null +++ b/backend/services/planting-service/src/pre-planting/domain/value-objects/pre-planting-right-amounts.ts @@ -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; diff --git a/backend/services/planting-service/src/pre-planting/infrastructure/external/pre-planting-admin.client.ts b/backend/services/planting-service/src/pre-planting/infrastructure/external/pre-planting-admin.client.ts new file mode 100644 index 00000000..75e26834 --- /dev/null +++ b/backend/services/planting-service/src/pre-planting/infrastructure/external/pre-planting-admin.client.ts @@ -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('ADMIN_SERVICE_URL') || + 'http://localhost:3010'; + } + + async getPrePlantingConfig(): Promise { + try { + const response = await firstValueFrom( + this.httpService.get( + `${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; + } + } +} diff --git a/backend/services/planting-service/src/pre-planting/infrastructure/external/pre-planting-authorization.client.ts b/backend/services/planting-service/src/pre-planting/infrastructure/external/pre-planting-authorization.client.ts new file mode 100644 index 00000000..cf072015 --- /dev/null +++ b/backend/services/planting-service/src/pre-planting/infrastructure/external/pre-planting-authorization.client.ts @@ -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('AUTHORIZATION_SERVICE_URL') || + 'http://localhost:3006'; + } + + /** + * 社区权益分配对象 + */ + async getCommunityDistribution( + accountSequence: string, + ): Promise { + 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 { + 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 { + 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 { + 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 { + 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, + }; + } + } +} diff --git a/backend/services/planting-service/src/pre-planting/infrastructure/external/pre-planting-referral.client.ts b/backend/services/planting-service/src/pre-planting/infrastructure/external/pre-planting-referral.client.ts new file mode 100644 index 00000000..fe728df1 --- /dev/null +++ b/backend/services/planting-service/src/pre-planting/infrastructure/external/pre-planting-referral.client.ts @@ -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('REFERRAL_SERVICE_URL') || + 'http://localhost:3004'; + } + + /** + * 获取直接推荐人信息 + */ + async getReferralChain(accountSequence: string): Promise { + try { + const response = await firstValueFrom( + this.httpService.get( + `${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; + } + } +} diff --git a/backend/services/planting-service/src/pre-planting/infrastructure/repositories/pre-planting-merge.repository.ts b/backend/services/planting-service/src/pre-planting/infrastructure/repositories/pre-planting-merge.repository.ts new file mode 100644 index 00000000..96881a32 --- /dev/null +++ b/backend/services/planting-service/src/pre-planting/infrastructure/repositories/pre-planting-merge.repository.ts @@ -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 { + 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 { + const record = await tx.prePlantingMerge.findUnique({ + where: { mergeNo }, + }); + if (!record) return null; + return this.toDomain(record); + } + + async findByUserId( + tx: Prisma.TransactionClient, + userId: bigint, + ): Promise { + 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 { + 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); + } +} diff --git a/backend/services/planting-service/src/pre-planting/infrastructure/repositories/pre-planting-order.repository.ts b/backend/services/planting-service/src/pre-planting/infrastructure/repositories/pre-planting-order.repository.ts new file mode 100644 index 00000000..4a19feed --- /dev/null +++ b/backend/services/planting-service/src/pre-planting/infrastructure/repositories/pre-planting-order.repository.ts @@ -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 { + 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 { + 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 { + 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 { + 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); + } +} diff --git a/backend/services/planting-service/src/pre-planting/infrastructure/repositories/pre-planting-position.repository.ts b/backend/services/planting-service/src/pre-planting/infrastructure/repositories/pre-planting-position.repository.ts new file mode 100644 index 00000000..dff29993 --- /dev/null +++ b/backend/services/planting-service/src/pre-planting/infrastructure/repositories/pre-planting-position.repository.ts @@ -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 { + 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 { + 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 { + const record = await tx.prePlantingPosition.findUnique({ + where: { userId }, + }); + if (!record) return null; + return this.toDomain(record); + } + + async findByAccountSequence( + tx: Prisma.TransactionClient, + accountSequence: string, + ): Promise { + 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); + } +} diff --git a/backend/services/planting-service/src/pre-planting/infrastructure/repositories/pre-planting-reward-entry.repository.ts b/backend/services/planting-service/src/pre-planting/infrastructure/repositories/pre-planting-reward-entry.repository.ts new file mode 100644 index 00000000..499722bd --- /dev/null +++ b/backend/services/planting-service/src/pre-planting/infrastructure/repositories/pre-planting-reward-entry.repository.ts @@ -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 { + 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 { + 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 { + 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, + })); + } +} diff --git a/backend/services/planting-service/src/pre-planting/pre-planting.module.ts b/backend/services/planting-service/src/pre-planting/pre-planting.module.ts new file mode 100644 index 00000000..586e14df --- /dev/null +++ b/backend/services/planting-service/src/pre-planting/pre-planting.module.ts @@ -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 {} diff --git a/backend/services/referral-service/prisma/schema.prisma b/backend/services/referral-service/prisma/schema.prisma index d06df42c..5a7ecca4 100644 --- a/backend/services/referral-service/prisma/schema.prisma +++ b/backend/services/referral-service/prisma/schema.prisma @@ -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") diff --git a/backend/services/referral-service/src/app.module.ts b/backend/services/referral-service/src/app.module.ts index 77c62208..ccfacf46 100644 --- a/backend/services/referral-service/src/app.module.ts +++ b/backend/services/referral-service/src/app.module.ts @@ -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 {} diff --git a/backend/services/referral-service/src/pre-planting/pre-planting-purchased.handler.ts b/backend/services/referral-service/src/pre-planting/pre-planting-purchased.handler.ts new file mode 100644 index 00000000..3b135c0b --- /dev/null +++ b/backend/services/referral-service/src/pre-planting/pre-planting-purchased.handler.ts @@ -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, + ): Promise { + 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, + ): Promise { + 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); + } + } + } +} diff --git a/backend/services/referral-service/src/pre-planting/pre-planting-stats.module.ts b/backend/services/referral-service/src/pre-planting/pre-planting-stats.module.ts new file mode 100644 index 00000000..8b61c992 --- /dev/null +++ b/backend/services/referral-service/src/pre-planting/pre-planting-stats.module.ts @@ -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 {} diff --git a/backend/services/wallet-service/src/app.module.ts b/backend/services/wallet-service/src/app.module.ts index 55c99171..3ffafff4 100644 --- a/backend/services/wallet-service/src/app.module.ts +++ b/backend/services/wallet-service/src/app.module.ts @@ -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: [ { diff --git a/backend/services/wallet-service/src/pre-planting/pre-planting-guard.interceptor.ts b/backend/services/wallet-service/src/pre-planting/pre-planting-guard.interceptor.ts new file mode 100644 index 00000000..54f04648 --- /dev/null +++ b/backend/services/wallet-service/src/pre-planting/pre-planting-guard.interceptor.ts @@ -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> { + 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(); + } +} diff --git a/backend/services/wallet-service/src/pre-planting/pre-planting-guard.module.ts b/backend/services/wallet-service/src/pre-planting/pre-planting-guard.module.ts new file mode 100644 index 00000000..6bd53254 --- /dev/null +++ b/backend/services/wallet-service/src/pre-planting/pre-planting-guard.module.ts @@ -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 {} diff --git a/backend/services/wallet-service/src/pre-planting/pre-planting.client.ts b/backend/services/wallet-service/src/pre-planting/pre-planting.client.ts new file mode 100644 index 00000000..ce8709f7 --- /dev/null +++ b/backend/services/wallet-service/src/pre-planting/pre-planting.client.ts @@ -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( + 'PLANTING_SERVICE_URL', + 'http://localhost:3003', + ); + } + + async getEligibility(accountSequence: string): Promise { + const url = `${this.baseUrl}/internal/pre-planting/eligibility/${accountSequence}`; + const response = await axios.get(url, { + timeout: 3000, + }); + return response.data; + } +}