diff --git a/backend/services/admin-service/prisma/migrations/20260226100000_add_tree_pricing_config/migration.sql b/backend/services/admin-service/prisma/migrations/20260226100000_add_tree_pricing_config/migration.sql new file mode 100644 index 00000000..72c87b10 --- /dev/null +++ b/backend/services/admin-service/prisma/migrations/20260226100000_add_tree_pricing_config/migration.sql @@ -0,0 +1,41 @@ +-- CreateTable: 认种树定价配置 +-- 基础价 15831 USDT 不变,supplement 作为加价全额归总部 (S0000000001) +-- 涨价原因:总部运营成本压力 +CREATE TABLE "tree_pricing_configs" ( + "id" TEXT NOT NULL, + "current_supplement" INTEGER NOT NULL DEFAULT 0, + "auto_increase_enabled" BOOLEAN NOT NULL DEFAULT false, + "auto_increase_amount" INTEGER NOT NULL DEFAULT 0, + "auto_increase_interval_days" INTEGER NOT NULL DEFAULT 0, + "last_auto_increase_at" TIMESTAMP(3), + "next_auto_increase_at" TIMESTAMP(3), + "updated_at" TIMESTAMP(3) NOT NULL, + "updated_by" VARCHAR(50), + + CONSTRAINT "tree_pricing_configs_pkey" PRIMARY KEY ("id") +); + +-- 插入默认配置(加价为0,自动涨价关闭) +INSERT INTO "tree_pricing_configs" ("id", "current_supplement", "auto_increase_enabled", "updated_at") +VALUES (gen_random_uuid(), 0, false, NOW()); + +-- CreateTable: 认种树价格变更审计日志 +-- 每次价格变更(手动或自动)都会记录一条不可修改的日志,用于审计追踪 +CREATE TYPE "PriceChangeType" AS ENUM ('MANUAL', 'AUTO'); + +CREATE TABLE "tree_price_change_logs" ( + "id" TEXT NOT NULL, + "change_type" "PriceChangeType" NOT NULL, + "previous_supplement" INTEGER NOT NULL, + "new_supplement" INTEGER NOT NULL, + "change_amount" INTEGER NOT NULL, + "reason" VARCHAR(500), + "operator_id" VARCHAR(50), + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "tree_price_change_logs_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE INDEX "tree_price_change_logs_created_at_idx" ON "tree_price_change_logs"("created_at"); +CREATE INDEX "tree_price_change_logs_change_type_idx" ON "tree_price_change_logs"("change_type"); diff --git a/backend/services/admin-service/prisma/schema.prisma b/backend/services/admin-service/prisma/schema.prisma index f094a0ee..98c333cf 100644 --- a/backend/services/admin-service/prisma/schema.prisma +++ b/backend/services/admin-service/prisma/schema.prisma @@ -1272,3 +1272,47 @@ model PrePlantingConfig { @@map("pre_planting_configs") } + +// ============================================================================= +// 认种树定价配置 (Tree Pricing Supplement) +// 基础价 15831 USDT 不变,supplement 作为加价全额归总部 (S0000000001) +// 正式认种总价 = 15831 + currentSupplement +// 预种总价 = (15831 + currentSupplement) / 5 +// ============================================================================= +model TreePricingConfig { + id String @id @default(uuid()) + currentSupplement Int @default(0) @map("current_supplement") + autoIncreaseEnabled Boolean @default(false) @map("auto_increase_enabled") + autoIncreaseAmount Int @default(0) @map("auto_increase_amount") + autoIncreaseIntervalDays Int @default(0) @map("auto_increase_interval_days") + lastAutoIncreaseAt DateTime? @map("last_auto_increase_at") + nextAutoIncreaseAt DateTime? @map("next_auto_increase_at") + updatedAt DateTime @updatedAt @map("updated_at") + updatedBy String? @map("updated_by") @db.VarChar(50) + + @@map("tree_pricing_configs") +} + +// ============================================================================= +// 认种树价格变更审计日志 (Tree Price Change Audit Log) +// 每次价格变更(手动或自动)都会记录一条不可修改的日志 +// ============================================================================= +model TreePriceChangeLog { + id String @id @default(uuid()) + changeType PriceChangeType @map("change_type") + previousSupplement Int @map("previous_supplement") + newSupplement Int @map("new_supplement") + changeAmount Int @map("change_amount") + reason String? @db.VarChar(500) + operatorId String? @map("operator_id") @db.VarChar(50) + createdAt DateTime @default(now()) @map("created_at") + + @@index([createdAt]) + @@index([changeType]) + @@map("tree_price_change_logs") +} + +enum PriceChangeType { + MANUAL // 管理员手动调整 + AUTO // 自动涨价任务 +} diff --git a/backend/services/admin-service/src/app.module.ts b/backend/services/admin-service/src/app.module.ts index e73b583d..d1704899 100644 --- a/backend/services/admin-service/src/app.module.ts +++ b/backend/services/admin-service/src/app.module.ts @@ -87,6 +87,10 @@ 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'; +// [2026-02-26] 新增:认种树定价配置(总部运营成本压力涨价) +import { AdminTreePricingController, PublicTreePricingController } from './pricing/tree-pricing.controller'; +import { TreePricingService } from './pricing/tree-pricing.service'; +import { AutoPriceIncreaseJob } from './infrastructure/jobs/auto-price-increase.job'; @Module({ imports: [ @@ -133,6 +137,9 @@ import { PrePlantingConfigService } from './pre-planting/pre-planting-config.ser // [2026-02-17] 新增:预种计划开关管理 PrePlantingConfigController, PublicPrePlantingConfigController, + // [2026-02-26] 新增:认种树定价配置(总部运营成本压力涨价) + AdminTreePricingController, + PublicTreePricingController, ], providers: [ PrismaService, @@ -224,6 +231,9 @@ import { PrePlantingConfigService } from './pre-planting/pre-planting-config.ser ContractService, // [2026-02-17] 新增:预种计划开关管理 PrePlantingConfigService, + // [2026-02-26] 新增:认种树定价配置(总部运营成本压力涨价) + TreePricingService, + AutoPriceIncreaseJob, ], }) export class AppModule {} diff --git a/backend/services/admin-service/src/infrastructure/jobs/auto-price-increase.job.ts b/backend/services/admin-service/src/infrastructure/jobs/auto-price-increase.job.ts new file mode 100644 index 00000000..0a0f53f4 --- /dev/null +++ b/backend/services/admin-service/src/infrastructure/jobs/auto-price-increase.job.ts @@ -0,0 +1,42 @@ +import { Injectable, Logger, OnModuleInit } from '@nestjs/common'; +import { Cron } from '@nestjs/schedule'; +import { TreePricingService } from '../../pricing/tree-pricing.service'; + +/** + * 自动涨价定时任务 + * + * 每小时检查一次是否到达自动涨价时间。 + * 涨价间隔以天为单位,小时级精度足够。 + * 涨价原因:总部运营成本压力。 + */ +@Injectable() +export class AutoPriceIncreaseJob implements OnModuleInit { + private readonly logger = new Logger(AutoPriceIncreaseJob.name); + private isRunning = false; + + constructor(private readonly pricingService: TreePricingService) {} + + onModuleInit() { + this.logger.log('AutoPriceIncreaseJob initialized'); + } + + @Cron('0 * * * *') // 每小时第0分钟执行 + async checkAndExecuteAutoIncrease(): Promise { + if (this.isRunning) { + this.logger.warn('Auto price increase check already running, skipping...'); + return; + } + + this.isRunning = true; + try { + const executed = await this.pricingService.executeAutoIncrease(); + if (executed) { + this.logger.log('Auto price increase executed successfully'); + } + } catch (error) { + this.logger.error(`Auto price increase failed: ${error}`); + } finally { + this.isRunning = false; + } + } +} diff --git a/backend/services/admin-service/src/pricing/tree-pricing.controller.ts b/backend/services/admin-service/src/pricing/tree-pricing.controller.ts new file mode 100644 index 00000000..425d00f1 --- /dev/null +++ b/backend/services/admin-service/src/pricing/tree-pricing.controller.ts @@ -0,0 +1,110 @@ +import { + Controller, + Get, + Post, + Put, + Body, + Query, + HttpCode, + HttpStatus, +} from '@nestjs/common'; +import { ApiTags, ApiOperation, ApiResponse } from '@nestjs/swagger'; +import { TreePricingService } from './tree-pricing.service'; + +// ======================== DTO ======================== + +class UpdateSupplementDto { + /** 新的加价金额(整数 USDT) */ + newSupplement: number; + /** 变更原因 */ + reason: string; + /** 操作人ID */ + operatorId?: string; +} + +class UpdateAutoIncreaseDto { + /** 是否启用自动涨价 */ + enabled: boolean; + /** 每次自动涨价金额(整数 USDT) */ + amount?: number; + /** 自动涨价间隔天数 */ + intervalDays?: number; + /** 操作人ID */ + operatorId?: string; +} + +class ChangeLogQueryDto { + page?: number; + pageSize?: number; +} + +// ======================== Admin Controller ======================== + +@ApiTags('认种定价配置') +@Controller('admin/tree-pricing') +export class AdminTreePricingController { + constructor( + private readonly pricingService: TreePricingService, + ) {} + + @Get('config') + @ApiOperation({ summary: '获取当前定价配置' }) + @ApiResponse({ status: HttpStatus.OK, description: '定价配置信息' }) + async getConfig() { + return this.pricingService.getConfig(); + } + + @Post('supplement') + @HttpCode(HttpStatus.OK) + @ApiOperation({ summary: '手动修改加价金额(总部运营成本压力涨价)' }) + @ApiResponse({ status: HttpStatus.OK, description: '更新成功,返回最新配置' }) + async updateSupplement(@Body() dto: UpdateSupplementDto) { + return this.pricingService.updateSupplement( + dto.newSupplement, + dto.reason, + dto.operatorId || 'admin', + ); + } + + @Put('auto-increase') + @ApiOperation({ summary: '设置自动涨价(总部运营成本压力自动涨价)' }) + @ApiResponse({ status: HttpStatus.OK, description: '设置成功,返回最新配置' }) + async updateAutoIncrease(@Body() dto: UpdateAutoIncreaseDto) { + return this.pricingService.updateAutoIncreaseSettings( + dto.enabled, + dto.amount, + dto.intervalDays, + dto.operatorId || 'admin', + ); + } + + @Get('change-log') + @ApiOperation({ summary: '获取价格变更审计日志' }) + @ApiResponse({ status: HttpStatus.OK, description: '分页审计日志' }) + async getChangeLog(@Query() query: ChangeLogQueryDto) { + return this.pricingService.getChangeLog( + Number(query.page) || 1, + Number(query.pageSize) || 20, + ); + } +} + +// ======================== Public Controller ======================== + +/** + * 公开 API(供 planting-service 和 mobile-app 调用) + * 不需要管理员认证 + */ +@ApiTags('认种定价配置-公开API') +@Controller('api/v1/tree-pricing') +export class PublicTreePricingController { + constructor( + private readonly pricingService: TreePricingService, + ) {} + + @Get('config') + @ApiOperation({ summary: '获取当前定价配置(公开接口)' }) + async getConfig() { + return this.pricingService.getConfig(); + } +} diff --git a/backend/services/admin-service/src/pricing/tree-pricing.service.ts b/backend/services/admin-service/src/pricing/tree-pricing.service.ts new file mode 100644 index 00000000..2dfb0f02 --- /dev/null +++ b/backend/services/admin-service/src/pricing/tree-pricing.service.ts @@ -0,0 +1,252 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { PrismaService } from '../infrastructure/persistence/prisma/prisma.service'; + +/** 基础价常量 */ +const BASE_PRICE = 15831; +const BASE_PORTION_PRICE = 3171; // 预种基础价 = BASE_PRICE 的 1/5 取整 + +export interface TreePricingConfigResponse { + basePrice: number; + basePortionPrice: number; + currentSupplement: number; + totalPrice: number; + totalPortionPrice: number; + autoIncreaseEnabled: boolean; + autoIncreaseAmount: number; + autoIncreaseIntervalDays: number; + lastAutoIncreaseAt: Date | null; + nextAutoIncreaseAt: Date | null; + updatedAt: Date; +} + +export interface TreePriceChangeLogItem { + id: string; + changeType: string; + previousSupplement: number; + newSupplement: number; + changeAmount: number; + reason: string | null; + operatorId: string | null; + createdAt: Date; +} + +@Injectable() +export class TreePricingService { + private readonly logger = new Logger(TreePricingService.name); + + constructor(private readonly prisma: PrismaService) {} + + /** + * 获取当前定价配置,不存在则创建默认配置 + */ + async getConfig(): Promise { + let config = await this.prisma.treePricingConfig.findFirst({ + orderBy: { updatedAt: 'desc' }, + }); + + if (!config) { + config = await this.prisma.treePricingConfig.create({ + data: { currentSupplement: 0, autoIncreaseEnabled: false }, + }); + this.logger.log('[TREE-PRICING] Default config created'); + } + + const totalPrice = BASE_PRICE + config.currentSupplement; + return { + basePrice: BASE_PRICE, + basePortionPrice: BASE_PORTION_PRICE, + currentSupplement: config.currentSupplement, + totalPrice, + totalPortionPrice: Math.floor(totalPrice / 5), + autoIncreaseEnabled: config.autoIncreaseEnabled, + autoIncreaseAmount: config.autoIncreaseAmount, + autoIncreaseIntervalDays: config.autoIncreaseIntervalDays, + lastAutoIncreaseAt: config.lastAutoIncreaseAt, + nextAutoIncreaseAt: config.nextAutoIncreaseAt, + updatedAt: config.updatedAt, + }; + } + + /** + * 手动修改加价金额(事务:同时更新配置 + 写入审计日志) + * 涨价原因:总部运营成本压力 + */ + async updateSupplement( + newSupplement: number, + reason: string, + operatorId: string, + ): Promise { + if (newSupplement < 0) { + throw new Error('加价金额不能为负数'); + } + + const config = await this.getOrCreateConfig(); + const previousSupplement = config.currentSupplement; + const changeAmount = newSupplement - previousSupplement; + + if (changeAmount === 0) { + return this.getConfig(); + } + + await this.prisma.$transaction([ + this.prisma.treePricingConfig.update({ + where: { id: config.id }, + data: { + currentSupplement: newSupplement, + updatedBy: operatorId, + }, + }), + this.prisma.treePriceChangeLog.create({ + data: { + changeType: 'MANUAL', + previousSupplement, + newSupplement, + changeAmount, + reason: reason || '总部运营成本压力调价', + operatorId, + }, + }), + ]); + + this.logger.log( + `[TREE-PRICING] Manual supplement update: ${previousSupplement} → ${newSupplement} (${changeAmount > 0 ? '+' : ''}${changeAmount}) by ${operatorId}, reason: ${reason}`, + ); + + return this.getConfig(); + } + + /** + * 更新自动涨价设置 + */ + async updateAutoIncreaseSettings( + enabled: boolean, + amount?: number, + intervalDays?: number, + operatorId?: string, + ): Promise { + const config = await this.getOrCreateConfig(); + + const data: Record = { + autoIncreaseEnabled: enabled, + updatedBy: operatorId || null, + }; + + if (amount !== undefined) { + if (amount < 0) throw new Error('自动涨价金额不能为负数'); + data.autoIncreaseAmount = amount; + } + + if (intervalDays !== undefined) { + if (intervalDays < 1) throw new Error('自动涨价间隔天数不能小于1'); + data.autoIncreaseIntervalDays = intervalDays; + } + + // 启用时计算下次涨价时间 + if (enabled) { + const interval = intervalDays ?? config.autoIncreaseIntervalDays; + if (interval > 0) { + const nextDate = new Date(); + nextDate.setDate(nextDate.getDate() + interval); + data.nextAutoIncreaseAt = nextDate; + } + } else { + data.nextAutoIncreaseAt = null; + } + + await this.prisma.treePricingConfig.update({ + where: { id: config.id }, + data, + }); + + this.logger.log( + `[TREE-PRICING] Auto-increase settings updated: enabled=${enabled}, amount=${amount ?? config.autoIncreaseAmount}, intervalDays=${intervalDays ?? config.autoIncreaseIntervalDays} by ${operatorId || 'unknown'}`, + ); + + return this.getConfig(); + } + + /** + * 获取变更审计日志(分页) + */ + async getChangeLog( + page: number = 1, + pageSize: number = 20, + ): Promise<{ items: TreePriceChangeLogItem[]; total: number }> { + const [items, total] = await this.prisma.$transaction([ + this.prisma.treePriceChangeLog.findMany({ + orderBy: { createdAt: 'desc' }, + skip: (page - 1) * pageSize, + take: pageSize, + }), + this.prisma.treePriceChangeLog.count(), + ]); + + return { items, total }; + } + + /** + * 执行自动涨价(由定时任务调用) + * 涨价原因:系统自动涨价(总部运营成本压力) + * @returns true 如果执行了涨价,false 如果未到时间或未启用 + */ + async executeAutoIncrease(): Promise { + const config = await this.prisma.treePricingConfig.findFirst({ + orderBy: { updatedAt: 'desc' }, + }); + + if (!config) return false; + if (!config.autoIncreaseEnabled) return false; + if (!config.nextAutoIncreaseAt) return false; + if (config.autoIncreaseAmount <= 0) return false; + + const now = new Date(); + if (now < config.nextAutoIncreaseAt) return false; + + const previousSupplement = config.currentSupplement; + const newSupplement = previousSupplement + config.autoIncreaseAmount; + const nextDate = new Date(now); + nextDate.setDate(nextDate.getDate() + config.autoIncreaseIntervalDays); + + await this.prisma.$transaction([ + this.prisma.treePricingConfig.update({ + where: { id: config.id }, + data: { + currentSupplement: newSupplement, + lastAutoIncreaseAt: now, + nextAutoIncreaseAt: nextDate, + }, + }), + this.prisma.treePriceChangeLog.create({ + data: { + changeType: 'AUTO', + previousSupplement, + newSupplement, + changeAmount: config.autoIncreaseAmount, + reason: '系统自动涨价(总部运营成本压力)', + operatorId: 'SYSTEM', + }, + }), + ]); + + this.logger.log( + `[TREE-PRICING] Auto-increase executed: ${previousSupplement} → ${newSupplement} (+${config.autoIncreaseAmount}), next: ${nextDate.toISOString()}`, + ); + + return true; + } + + /** 获取或创建配置(内部方法) */ + private async getOrCreateConfig() { + let config = await this.prisma.treePricingConfig.findFirst({ + orderBy: { updatedAt: 'desc' }, + }); + + if (!config) { + config = await this.prisma.treePricingConfig.create({ + data: { currentSupplement: 0, autoIncreaseEnabled: false }, + }); + } + + return config; + } +} diff --git a/backend/services/planting-service/prisma/migrations/20260226100000_add_price_supplement/migration.sql b/backend/services/planting-service/prisma/migrations/20260226100000_add_price_supplement/migration.sql new file mode 100644 index 00000000..7e99af55 --- /dev/null +++ b/backend/services/planting-service/prisma/migrations/20260226100000_add_price_supplement/migration.sql @@ -0,0 +1,9 @@ +-- AlterTable: 认种订单新增总部运营成本压力涨价字段 +-- 每棵树加价金额,涨价部分全额归总部 (S0000000001) +-- 默认值 0 表示不涨价,兼容所有历史订单 +ALTER TABLE "planting_orders" ADD COLUMN "price_supplement" INTEGER NOT NULL DEFAULT 0; + +-- AlterTable: 预种订单新增总部运营成本压力涨价字段 +-- 每份加价金额 = Math.floor(每棵树加价金额 / 5),涨价部分全额归总部 (S0000000001) +-- 默认值 0 表示不涨价,兼容所有历史订单 +ALTER TABLE "pre_planting_orders" ADD COLUMN "price_supplement" INTEGER NOT NULL DEFAULT 0; diff --git a/backend/services/planting-service/prisma/schema.prisma b/backend/services/planting-service/prisma/schema.prisma index d031dae4..64d5057c 100644 --- a/backend/services/planting-service/prisma/schema.prisma +++ b/backend/services/planting-service/prisma/schema.prisma @@ -17,8 +17,9 @@ model PlantingOrder { accountSequence String @map("account_sequence") @db.VarChar(20) // 认种信息 - treeCount Int @map("tree_count") - totalAmount Decimal @map("total_amount") @db.Decimal(20, 8) + treeCount Int @map("tree_count") + totalAmount Decimal @map("total_amount") @db.Decimal(20, 8) + priceSupplement Int @default(0) @map("price_supplement") // 总部运营成本压力涨价(每棵树加价金额),归总部 (S0000000001) // 转让锁定(纯新增,不影响现有逻辑) transferLockedCount Int @default(0) @map("transfer_locked_count") @@ -408,6 +409,7 @@ model PrePlantingOrder { 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) + priceSupplement Int @default(0) @map("price_supplement") // 总部运营成本压力涨价(每份加价金额),归总部 (S0000000001) // 省市选择 (购买时即选择,后续复用) provinceCode String @map("province_code") @db.VarChar(10) diff --git a/backend/services/planting-service/src/application/services/planting-application.service.ts b/backend/services/planting-service/src/application/services/planting-application.service.ts index 66863152..40566987 100644 --- a/backend/services/planting-service/src/application/services/planting-application.service.ts +++ b/backend/services/planting-service/src/application/services/planting-application.service.ts @@ -23,6 +23,7 @@ import { CompensationType, FailureStage, } from '../../infrastructure/persistence/repositories/payment-compensation.repository'; +import { TreePricingAdminClient } from '../../infrastructure/external/tree-pricing-admin.client'; export interface CreateOrderResult { orderNo: string; @@ -70,6 +71,7 @@ export class PlantingApplicationService { private readonly walletService: WalletServiceClient, private readonly referralService: ReferralServiceClient, private readonly compensationService: PaymentCompensationService, + private readonly treePricingClient: TreePricingAdminClient, ) {} /** @@ -82,17 +84,21 @@ export class PlantingApplicationService { ): Promise { this.logger.log(`Creating order for user ${accountSequence}, treeCount: ${treeCount}`); - // 检查余额 - 使用 accountSequence 进行跨服务调用 + // 获取当前定价配置(含总部运营成本压力涨价) + const pricingConfig = await this.treePricingClient.getTreePricingConfig(); + const priceSupplement = pricingConfig.currentSupplement; // 每棵树加价,归总部 (S0000000001) + + // 检查余额 - 使用动态价格(基础价 + 涨价补充) const balance = await this.walletService.getBalance(accountSequence); - const requiredAmount = treeCount * PRICE_PER_TREE; + const requiredAmount = treeCount * (PRICE_PER_TREE + priceSupplement); if (balance.available < requiredAmount) { throw new Error( `余额不足: 需要 ${requiredAmount} USDT, 当前可用 ${balance.available} USDT`, ); } - // 创建订单 - const order = PlantingOrder.create(userId, accountSequence, treeCount); + // 创建订单(priceSupplement 快照到订单,确保后续涨价不影响已创建订单) + const order = PlantingOrder.create(userId, accountSequence, treeCount, priceSupplement); await this.orderRepository.save(order); this.logger.log(`Order created: ${order.orderNo}`); @@ -481,6 +487,9 @@ export class PlantingApplicationService { // 新增:订单信息,供 referral-service 转发给 reward-service orderId: order.orderNo, paidAt: order.paidAt!.toISOString(), + // [2026-02-26] 总部运营成本压力涨价(每棵树加价金额),归总部 (S0000000001) + priceSupplement: order.priceSupplement, + totalAmount: order.totalAmount, }, }, aggregateId: order.orderNo, diff --git a/backend/services/planting-service/src/domain/aggregates/planting-order.aggregate.ts b/backend/services/planting-service/src/domain/aggregates/planting-order.aggregate.ts index 1cb49a37..f8d71db0 100644 --- a/backend/services/planting-service/src/domain/aggregates/planting-order.aggregate.ts +++ b/backend/services/planting-service/src/domain/aggregates/planting-order.aggregate.ts @@ -18,6 +18,7 @@ export interface PlantingOrderData { accountSequence: string; treeCount: number; totalAmount: number; + priceSupplement?: number; // 总部运营成本压力涨价,归总部 (S0000000001) status: PlantingOrderStatus; selectedProvince?: string | null; selectedCity?: string | null; @@ -40,6 +41,7 @@ export class PlantingOrder { private readonly _accountSequence: string; private readonly _treeCount: TreeCount; private readonly _totalAmount: number; + private readonly _priceSupplement: number; // 总部运营成本压力涨价 private _provinceCitySelection: ProvinceCitySelection | null; private _status: PlantingOrderStatus; private _fundAllocations: FundAllocation[]; @@ -61,6 +63,7 @@ export class PlantingOrder { accountSequence: string, treeCount: TreeCount, totalAmount: number, + priceSupplement: number = 0, createdAt?: Date, ) { this._id = null; @@ -69,6 +72,7 @@ export class PlantingOrder { this._accountSequence = accountSequence; this._treeCount = treeCount; this._totalAmount = totalAmount; + this._priceSupplement = priceSupplement; this._status = PlantingOrderStatus.CREATED; this._provinceCitySelection = null; this._fundAllocations = []; @@ -101,6 +105,10 @@ export class PlantingOrder { get totalAmount(): number { return this._totalAmount; } + /** 总部运营成本压力涨价(每棵树加价金额),归总部 (S0000000001) */ + get priceSupplement(): number { + return this._priceSupplement; + } get status(): PlantingOrderStatus { return this._status; } @@ -143,17 +151,23 @@ export class PlantingOrder { /** * 工厂方法:创建认种订单 + * @param priceSupplement 总部运营成本压力涨价(每棵树加价金额),默认 0 不影响现有逻辑 */ - static create(userId: bigint, accountSequence: string, treeCount: number): PlantingOrder { + static create( + userId: bigint, + accountSequence: string, + treeCount: number, + priceSupplement: number = 0, + ): PlantingOrder { if (treeCount <= 0) { throw new Error('认种数量必须大于0'); } const orderNo = `PLT${Date.now()}${Math.random().toString(36).substring(2, 8).toUpperCase()}`; const tree = TreeCount.create(treeCount); - const totalAmount = treeCount * PRICE_PER_TREE; + const totalAmount = treeCount * (PRICE_PER_TREE + priceSupplement); - const order = new PlantingOrder(orderNo, userId, accountSequence, tree, totalAmount); + const order = new PlantingOrder(orderNo, userId, accountSequence, tree, totalAmount, priceSupplement); // 发布领域事件 order._domainEvents.push( @@ -372,6 +386,7 @@ export class PlantingOrder { data.accountSequence, TreeCount.create(data.treeCount), data.totalAmount, + data.priceSupplement || 0, data.createdAt, ); @@ -412,6 +427,7 @@ export class PlantingOrder { accountSequence: this._accountSequence, treeCount: this._treeCount.value, totalAmount: this._totalAmount, + priceSupplement: this._priceSupplement, status: this._status, selectedProvince: this._provinceCitySelection?.provinceCode || null, selectedCity: this._provinceCitySelection?.cityCode || null, diff --git a/backend/services/planting-service/src/domain/services/fund-allocation.service.ts b/backend/services/planting-service/src/domain/services/fund-allocation.service.ts index 5e516b4c..6c990bfb 100644 --- a/backend/services/planting-service/src/domain/services/fund-allocation.service.ts +++ b/backend/services/planting-service/src/domain/services/fund-allocation.service.ts @@ -168,9 +168,21 @@ export class FundAllocationDomainService { ), ); - // 验证总额 + // [2026-02-26] 总部运营成本压力涨价:加价部分全额归总部 (S0000000001) + if (order.priceSupplement > 0) { + allocations.push( + new FundAllocation( + FundAllocationTargetType.HQ_PRICE_SUPPLEMENT, + order.priceSupplement * treeCount, + 'SYSTEM_HEADQUARTERS', // 总部 S0000000001 + { reason: '总部运营成本压力涨价' }, + ), + ); + } + + // 验证总额(基础 15831 + 涨价补充) const total = allocations.reduce((sum, a) => sum + a.amount, 0); - const expected = 15831 * treeCount; + const expected = order.totalAmount; if (Math.abs(total - expected) > 0.01) { throw new Error(`资金分配计算错误: 总额 ${total} != ${expected}`); } @@ -325,9 +337,21 @@ export class FundAllocationDomainService { ), ); - // 验证总额 + // [2026-02-26] 总部运营成本压力涨价:加价部分全额归总部 (S0000000001) + if (order.priceSupplement > 0) { + allocations.push( + new FundAllocation( + FundAllocationTargetType.HQ_PRICE_SUPPLEMENT, + order.priceSupplement * treeCount, + 'SYSTEM:HEADQUARTERS', // 总部 S0000000001 + { reason: '总部运营成本压力涨价' }, + ), + ); + } + + // 验证总额(基础 15831 + 涨价补充) const total = allocations.reduce((sum, a) => sum + a.amount, 0); - const expected = 15831 * treeCount; + const expected = order.totalAmount; if (Math.abs(total - expected) > 0.01) { throw new Error(`资金分配计算错误: 总额 ${total} != ${expected}`); } diff --git a/backend/services/planting-service/src/domain/value-objects/fund-allocation-target-type.enum.ts b/backend/services/planting-service/src/domain/value-objects/fund-allocation-target-type.enum.ts index 70272914..19fb6cff 100644 --- a/backend/services/planting-service/src/domain/value-objects/fund-allocation-target-type.enum.ts +++ b/backend/services/planting-service/src/domain/value-objects/fund-allocation-target-type.enum.ts @@ -9,10 +9,16 @@ export enum FundAllocationTargetType { CITY_TEAM_RIGHTS = 'CITY_TEAM_RIGHTS', // 288 USDT - 市团队权益 COMMUNITY_RIGHTS = 'COMMUNITY_RIGHTS', // 576 USDT - 社区权益 RWAD_POOL = 'RWAD_POOL', // 5760 USDT - RWAD底池 + // [2026-02-26] 新增:总部运营成本压力涨价,加价部分全额归总部 (S0000000001) + // 金额动态(由 admin-service TreePricingConfig 配置),不计入 FUND_ALLOCATION_AMOUNTS + HQ_PRICE_SUPPLEMENT = 'HQ_PRICE_SUPPLEMENT', } -// 每棵树的资金分配规则 (总计 15831 USDT) -export const FUND_ALLOCATION_AMOUNTS: Record = +// 基础 10 类分配类型(不含动态加价 HQ_PRICE_SUPPLEMENT) +type BaseAllocationTargetType = Exclude; + +// 每棵树的资金分配规则 (总计 15831 USDT,不含涨价补充) +export const FUND_ALLOCATION_AMOUNTS: Record = { [FundAllocationTargetType.COST_ACCOUNT]: 2800, [FundAllocationTargetType.OPERATION_ACCOUNT]: 2100, diff --git a/backend/services/planting-service/src/infrastructure/external/tree-pricing-admin.client.ts b/backend/services/planting-service/src/infrastructure/external/tree-pricing-admin.client.ts new file mode 100644 index 00000000..2a47d09b --- /dev/null +++ b/backend/services/planting-service/src/infrastructure/external/tree-pricing-admin.client.ts @@ -0,0 +1,62 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { HttpService } from '@nestjs/axios'; +import { firstValueFrom } from 'rxjs'; + +export interface TreePricingConfig { + basePrice: number; + basePortionPrice: number; + currentSupplement: number; + totalPrice: number; + totalPortionPrice: number; + autoIncreaseEnabled: boolean; + autoIncreaseAmount: number; + autoIncreaseIntervalDays: number; + nextAutoIncreaseAt: string | null; +} + +/** + * HTTP 客户端:从 admin-service 获取当前树定价配置 + * + * 涨价原因:总部运营成本压力,加价部分全额归总部 (S0000000001) + * 遵循 PrePlantingAdminClient 模式 + */ +@Injectable() +export class TreePricingAdminClient { + private readonly logger = new Logger(TreePricingAdminClient.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 getTreePricingConfig(): Promise { + try { + const response = await firstValueFrom( + this.httpService.get( + `${this.baseUrl}/api/v1/tree-pricing/config`, + ), + ); + return response.data; + } catch (error) { + this.logger.error('Failed to get tree pricing config, using default (supplement=0)', error); + // 安全降级:加价为 0,等同于原始价格 15831,不影响现有业务 + return { + basePrice: 15831, + basePortionPrice: 3171, + currentSupplement: 0, + totalPrice: 15831, + totalPortionPrice: 3171, + autoIncreaseEnabled: false, + autoIncreaseAmount: 0, + autoIncreaseIntervalDays: 0, + nextAutoIncreaseAt: null, + }; + } + } +} diff --git a/backend/services/planting-service/src/infrastructure/infrastructure.module.ts b/backend/services/planting-service/src/infrastructure/infrastructure.module.ts index c169c5bb..5da9267a 100644 --- a/backend/services/planting-service/src/infrastructure/infrastructure.module.ts +++ b/backend/services/planting-service/src/infrastructure/infrastructure.module.ts @@ -12,6 +12,7 @@ import { UnitOfWork, UNIT_OF_WORK } from './persistence/unit-of-work'; import { WalletServiceClient } from './external/wallet-service.client'; import { ReferralServiceClient } from './external/referral-service.client'; import { IdentityServiceClient } from './external/identity-service.client'; +import { TreePricingAdminClient } from './external/tree-pricing-admin.client'; import { KafkaModule } from './kafka/kafka.module'; import { OutboxPublisherService } from './kafka/outbox-publisher.service'; import { EventAckController } from './kafka/event-ack.controller'; @@ -85,6 +86,7 @@ import { ContractSigningService } from '../application/services/contract-signing WalletServiceClient, ReferralServiceClient, IdentityServiceClient, + TreePricingAdminClient, ], exports: [ PrismaService, @@ -104,6 +106,7 @@ import { ContractSigningService } from '../application/services/contract-signing WalletServiceClient, ReferralServiceClient, IdentityServiceClient, + TreePricingAdminClient, ], }) export class InfrastructureModule {} diff --git a/backend/services/planting-service/src/infrastructure/persistence/mappers/planting-order.mapper.ts b/backend/services/planting-service/src/infrastructure/persistence/mappers/planting-order.mapper.ts index 5639a519..06aac198 100644 --- a/backend/services/planting-service/src/infrastructure/persistence/mappers/planting-order.mapper.ts +++ b/backend/services/planting-service/src/infrastructure/persistence/mappers/planting-order.mapper.ts @@ -24,6 +24,7 @@ export class PlantingOrderMapper { accountSequence: prismaOrder.accountSequence, treeCount: prismaOrder.treeCount, totalAmount: Number(prismaOrder.totalAmount), + priceSupplement: prismaOrder.priceSupplement ?? 0, status: prismaOrder.status as PlantingOrderStatus, selectedProvince: prismaOrder.selectedProvince, selectedCity: prismaOrder.selectedCity, @@ -81,6 +82,7 @@ export class PlantingOrderMapper { accountSequence: data.accountSequence, treeCount: data.treeCount, totalAmount: new Prisma.Decimal(data.totalAmount), + priceSupplement: data.priceSupplement ?? 0, selectedProvince: data.selectedProvince || null, selectedCity: data.selectedCity || null, provinceCitySelectedAt: data.provinceCitySelectedAt || null, 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 index 9d319f8e..b635c5eb 100644 --- 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 @@ -14,6 +14,7 @@ import { PrePlantingPositionRepository } from '../../infrastructure/repositories 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'; +import { TreePricingAdminClient } from '../../../infrastructure/external/tree-pricing-admin.client'; import { EventPublisherService } from '../../../infrastructure/kafka/event-publisher.service'; @Injectable() @@ -29,6 +30,7 @@ export class PrePlantingApplicationService { private readonly mergeRepo: PrePlantingMergeRepository, private readonly rewardService: PrePlantingRewardService, private readonly adminClient: PrePlantingAdminClient, + private readonly treePricingClient: TreePricingAdminClient, private readonly eventPublisher: EventPublisherService, ) {} @@ -52,8 +54,13 @@ export class PrePlantingApplicationService { // Step 1: 前置校验 await this.validatePurchase(userId, portionCount); + // 获取当前定价配置(含总部运营成本压力涨价) + const pricingConfig = await this.treePricingClient.getTreePricingConfig(); + // 预种每份加价 = Math.floor(每棵树加价 / 5),涨价部分全额归总部 (S0000000001) + const portionSupplement = Math.floor(pricingConfig.currentSupplement / 5); + const orderNo = this.generateOrderNo(); - const totalAmount = portionCount * PRE_PLANTING_PRICE_PER_PORTION; + const totalAmount = portionCount * (PRE_PLANTING_PRICE_PER_PORTION + portionSupplement); // Step 2: 冻结余额 await this.walletClient.freezeForPlanting({ @@ -71,7 +78,7 @@ export class PrePlantingApplicationService { // Step 3-4: 事务内处理(创建订单 + 更新持仓 + 分配记录 + outbox) // 注意:事务内只做 DB 写入,不做 HTTP 调用 await this.prisma.$transaction(async (tx) => { - // 创建预种订单 + // 创建预种订单(portionSupplement 快照到订单,确保后续涨价不影响已创建订单) const order = PrePlantingOrder.create( orderNo, userId, @@ -79,6 +86,7 @@ export class PrePlantingApplicationService { portionCount, provinceCode, cityCode, + portionSupplement, ); // 获取或创建持仓 @@ -99,7 +107,7 @@ export class PrePlantingApplicationService { await this.orderRepo.save(tx, order); await this.positionRepo.save(tx, position); - // 分配 10 类权益 — 事务内只持久化记录,返回 allocations 供事务后转账 + // 分配 10 类权益 + 涨价补充 — 事务内只持久化记录,返回 allocations 供事务后转账 rewardAllocations = await this.rewardService.prepareAndPersistRewards( tx, orderNo, @@ -107,6 +115,7 @@ export class PrePlantingApplicationService { provinceCode, cityCode, portionCount, + portionSupplement, ); // Outbox: 购买事件(包装为 { eventName, data } 格式,与现有 planting 事件一致) 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 index 2b5a1d59..b071acac 100644 --- 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 @@ -38,6 +38,9 @@ export class PrePlantingRewardService { * * 返回 allocations 供事务提交后执行转账(不在事务内做 HTTP 调用) */ + /** + * @param portionSupplement 总部运营成本压力涨价(每份加价金额),默认 0 不影响现有逻辑 + */ async prepareAndPersistRewards( tx: Prisma.TransactionClient, orderNo: string, @@ -45,10 +48,11 @@ export class PrePlantingRewardService { provinceCode: string, cityCode: string, portionCount: number, + portionSupplement: number = 0, ): Promise { this.logger.log( `[PRE-PLANTING] Preparing rewards for order ${orderNo}, ` + - `${portionCount} portion(s), province=${provinceCode}, city=${cityCode}`, + `${portionCount} portion(s), province=${provinceCode}, city=${cityCode}, supplement=${portionSupplement}`, ); // Step 3: 确定所有分配对象 @@ -57,6 +61,7 @@ export class PrePlantingRewardService { provinceCode, cityCode, portionCount, + portionSupplement, ); // Step 4: 在事务内持久化分配记录 @@ -106,13 +111,15 @@ export class PrePlantingRewardService { } /** - * 确定 10 类权益分配对象 + * 确定 10 类权益分配对象 + 涨价补充(如有) + * @param portionSupplement 总部运营成本压力涨价(每份加价金额),默认 0 */ private async resolveAllocations( accountSequence: string, provinceCode: string, cityCode: string, portionCount: number, + portionSupplement: number = 0, ): Promise { const allocations: RewardAllocation[] = []; const multiplier = portionCount; @@ -234,6 +241,17 @@ export class PrePlantingRewardService { rewardStatus: PrePlantingRewardStatus.SETTLED, }); + // [2026-02-26] 总部运营成本压力涨价:加价部分全额归总部 (S0000000001) + if (portionSupplement > 0) { + allocations.push({ + recipientAccountSequence: SYSTEM_ACCOUNTS.HEADQUARTERS, + rightType: PrePlantingRightType.HQ_PRICE_SUPPLEMENT, + amount: portionSupplement * multiplier, + memo: '预种总部运营成本压力涨价', + rewardStatus: PrePlantingRewardStatus.SETTLED, + }); + } + return allocations; } 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 index dcbf391d..4888e1f0 100644 --- 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 @@ -11,6 +11,7 @@ export interface PrePlantingOrderData { portionCount: number; pricePerPortion: number; totalAmount: number; + priceSupplement?: number; // 总部运营成本压力涨价(每份加价金额),归总部 (S0000000001) provinceCode: string; cityCode: string; status: PrePlantingOrderStatus; @@ -28,6 +29,7 @@ export class PrePlantingOrder { private readonly _portionCount: number; private readonly _pricePerPortion: number; private readonly _totalAmount: number; + private readonly _priceSupplement: number; // 总部运营成本压力涨价(每份加价金额) private readonly _provinceCode: string; private readonly _cityCode: string; private _status: PrePlantingOrderStatus; @@ -45,6 +47,7 @@ export class PrePlantingOrder { portionCount: number, provinceCode: string, cityCode: string, + priceSupplement: number = 0, createdAt?: Date, ) { this._id = null; @@ -52,8 +55,9 @@ export class PrePlantingOrder { 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._pricePerPortion = PRE_PLANTING_PRICE_PER_PORTION + priceSupplement; + this._totalAmount = portionCount * (PRE_PLANTING_PRICE_PER_PORTION + priceSupplement); + this._priceSupplement = priceSupplement; this._provinceCode = provinceCode; this._cityCode = cityCode; this._status = PrePlantingOrderStatus.CREATED; @@ -66,6 +70,9 @@ export class PrePlantingOrder { /** * 创建新的预种订单 */ + /** + * @param priceSupplement 总部运营成本压力涨价(每份加价金额),默认 0 不影响现有逻辑 + */ static create( orderNo: string, userId: bigint, @@ -73,6 +80,7 @@ export class PrePlantingOrder { portionCount: number, provinceCode: string, cityCode: string, + priceSupplement: number = 0, ): PrePlantingOrder { if (portionCount < 1) { throw new Error('购买份数必须大于 0'); @@ -84,6 +92,7 @@ export class PrePlantingOrder { portionCount, provinceCode, cityCode, + priceSupplement, ); } @@ -98,6 +107,7 @@ export class PrePlantingOrder { data.portionCount, data.provinceCode, data.cityCode, + data.priceSupplement || 0, data.createdAt, ); order._id = data.id || null; @@ -159,6 +169,8 @@ export class PrePlantingOrder { get portionCount(): number { return this._portionCount; } get pricePerPortion(): number { return this._pricePerPortion; } get totalAmount(): number { return this._totalAmount; } + /** 总部运营成本压力涨价(每份加价金额),归总部 (S0000000001) */ + get priceSupplement(): number { return this._priceSupplement; } get provinceCode(): string { return this._provinceCode; } get cityCode(): string { return this._cityCode; } get status(): PrePlantingOrderStatus { return this._status; } @@ -179,6 +191,7 @@ export class PrePlantingOrder { portionCount: this._portionCount, pricePerPortion: this._pricePerPortion, totalAmount: this._totalAmount, + priceSupplement: this._priceSupplement, provinceCode: this._provinceCode, cityCode: this._cityCode, status: this._status, 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 index 2c025fa3..47aaf7ed 100644 --- 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 @@ -36,6 +36,9 @@ export enum PrePlantingRightType { CITY_AREA_RIGHT = 'CITY_AREA_RIGHT', CITY_TEAM_RIGHT = 'CITY_TEAM_RIGHT', COMMUNITY_RIGHT = 'COMMUNITY_RIGHT', + // [2026-02-26] 新增:总部运营成本压力涨价,加价部分全额归总部 (S0000000001) + // 金额动态(由 admin-service TreePricingConfig 配置),不计入 PRE_PLANTING_RIGHT_AMOUNTS + HQ_PRICE_SUPPLEMENT = 'HQ_PRICE_SUPPLEMENT', } /** 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 index 4a19feed..c8baf0dd 100644 --- 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 @@ -35,6 +35,7 @@ export class PrePlantingOrderRepository { portionCount: data.portionCount, pricePerPortion: new Prisma.Decimal(data.pricePerPortion), totalAmount: new Prisma.Decimal(data.totalAmount), + priceSupplement: data.priceSupplement ?? 0, provinceCode: data.provinceCode, cityCode: data.cityCode, status: data.status, @@ -91,6 +92,7 @@ export class PrePlantingOrderRepository { portionCount: record.portionCount, pricePerPortion: Number(record.pricePerPortion), totalAmount: Number(record.totalAmount), + priceSupplement: record.priceSupplement ?? 0, provinceCode: record.provinceCode, cityCode: record.cityCode, status: record.status as PrePlantingOrderStatus, diff --git a/backend/services/reward-service/src/application/services/reward-application.service.ts b/backend/services/reward-service/src/application/services/reward-application.service.ts index 2bb0a8db..94212252 100644 --- a/backend/services/reward-service/src/application/services/reward-application.service.ts +++ b/backend/services/reward-service/src/application/services/reward-application.service.ts @@ -59,6 +59,7 @@ export class RewardApplicationService { treeCount: number; provinceCode: string; cityCode: string; + priceSupplement?: number; // [2026-02-26] 总部运营成本压力涨价(每棵树加价金额),归总部 (S0000000001) }): Promise { this.logger.log(`Distributing rewards for order ${params.sourceOrderNo}, accountSequence=${params.sourceAccountSequence}`); diff --git a/backend/services/reward-service/src/domain/services/reward-calculation.service.ts b/backend/services/reward-service/src/domain/services/reward-calculation.service.ts index 9a743234..de1ff2d5 100644 --- a/backend/services/reward-service/src/domain/services/reward-calculation.service.ts +++ b/backend/services/reward-service/src/domain/services/reward-calculation.service.ts @@ -94,6 +94,7 @@ export class RewardCalculationService { treeCount: number; provinceCode: string; cityCode: string; + priceSupplement?: number; // [2026-02-26] 总部运营成本压力涨价(每棵树加价金额),归总部 (S0000000001) }): Promise { this.logger.log( `[calculateRewards] START orderNo=${params.sourceOrderNo}, userId=${params.sourceUserId}, ` + @@ -204,6 +205,18 @@ export class RewardCalculationService { ); rewards.push(...communityRewards); + // [2026-02-26] 11. 总部运营成本压力涨价(动态金额,归总部 S0000000001) + if (params.priceSupplement && params.priceSupplement > 0) { + const supplementReward = this.calculateHqPriceSupplement( + params.sourceOrderNo, + params.sourceUserId, + params.sourceAccountSequence, + params.treeCount, + params.priceSupplement, + ); + rewards.push(supplementReward); + } + this.logger.log( `[calculateRewards] DONE orderNo=${params.sourceOrderNo}, totalRewards=${rewards.length}`, ); @@ -339,6 +352,38 @@ export class RewardCalculationService { }); } + /** + * [2026-02-26] 计算总部运营成本压力涨价 + * 动态金额,全额归总部 (S0000000001) + * 不影响基础 10 类分配,纯增量 + */ + private calculateHqPriceSupplement( + sourceOrderNo: string, + sourceUserId: bigint, + sourceAccountSequence: string | undefined, + treeCount: number, + priceSupplement: number, + ): RewardLedgerEntry { + const usdtAmount = Money.USDT(priceSupplement * treeCount); + const hashpower = Hashpower.fromTreeCount(treeCount, 0); // 涨价部分无算力 + + const rewardSource = RewardSource.create( + RightType.HQ_PRICE_SUPPLEMENT, + sourceOrderNo, + sourceUserId, + sourceAccountSequence, + ); + + return RewardLedgerEntry.createSettleable({ + userId: HEADQUARTERS_COMMUNITY_USER_ID, + accountSequence: 'S0000000001', + rewardSource, + usdtAmount, + hashpowerAmount: hashpower, + memo: `总部运营成本压力涨价:来自用户${sourceAccountSequence || sourceUserId}的认种,${treeCount}棵树,每棵加价${priceSupplement}U`, + }); + } + // ============================================ // 用户权益计算方法 // ============================================ @@ -727,6 +772,7 @@ export class RewardCalculationService { treeCount: number; provinceCode: string; cityCode: string; + priceSupplement?: number; // [2026-02-26] 总部运营成本压力涨价 }): Promise { this.logger.log( `[calculateRewardsForExpiredContract] START orderNo=${params.sourceOrderNo}, userId=${params.sourceUserId}, ` + @@ -827,6 +873,17 @@ export class RewardCalculationService { params.treeCount, )); + // [2026-02-26] 11. 总部运营成本压力涨价(动态金额,归总部 S0000000001) + if (params.priceSupplement && params.priceSupplement > 0) { + rewards.push(this.calculateHqPriceSupplement( + params.sourceOrderNo, + params.sourceUserId, + params.sourceAccountSequence, + params.treeCount, + params.priceSupplement, + )); + } + this.logger.log( `[calculateRewardsForExpiredContract] DONE orderNo=${params.sourceOrderNo}, totalRewards=${rewards.length}`, ); diff --git a/backend/services/reward-service/src/domain/value-objects/right-type.enum.ts b/backend/services/reward-service/src/domain/value-objects/right-type.enum.ts index a5faf70b..540e2bc8 100644 --- a/backend/services/reward-service/src/domain/value-objects/right-type.enum.ts +++ b/backend/services/reward-service/src/domain/value-objects/right-type.enum.ts @@ -12,10 +12,17 @@ export enum RightType { CITY_AREA_RIGHT = 'CITY_AREA_RIGHT', // 市区域权益 252U + 2%算力 CITY_TEAM_RIGHT = 'CITY_TEAM_RIGHT', // 市团队权益 288U COMMUNITY_RIGHT = 'COMMUNITY_RIGHT', // 社区权益 576U + + // [2026-02-26] 新增:总部运营成本压力涨价,加价部分全额归总部 (S0000000001) + // 金额动态(由 admin-service TreePricingConfig 配置),不计入基础 15831 + HQ_PRICE_SUPPLEMENT = 'HQ_PRICE_SUPPLEMENT', } -// 权益金额配置 -export const RIGHT_AMOUNTS: Record = { +// 基础 10 类分配类型(不含动态加价 HQ_PRICE_SUPPLEMENT) +type BaseRightType = Exclude; + +// 权益金额配置(基础 10 类,不含动态加价) +export const RIGHT_AMOUNTS: Record = { // 系统费用类 [RightType.COST_FEE]: { usdt: 2880, hashpowerPercent: 0 }, [RightType.OPERATION_FEE]: { usdt: 2100, hashpowerPercent: 0 }, diff --git a/backend/services/reward-service/src/infrastructure/kafka/event-consumer.controller.ts b/backend/services/reward-service/src/infrastructure/kafka/event-consumer.controller.ts index 12aa1a14..785b67d6 100644 --- a/backend/services/reward-service/src/infrastructure/kafka/event-consumer.controller.ts +++ b/backend/services/reward-service/src/infrastructure/kafka/event-consumer.controller.ts @@ -17,6 +17,9 @@ interface PlantingOrderPaidEvent { provinceCode: string; cityCode: string; paidAt: string; + // [2026-02-26] 总部运营成本压力涨价(每棵树加价金额),归总部 (S0000000001) + priceSupplement?: number; + totalAmount?: number; }; // 兼容旧格式 orderId?: string; @@ -94,6 +97,7 @@ export class EventConsumerController { treeCount: eventData.treeCount, provinceCode: eventData.provinceCode, cityCode: eventData.cityCode, + priceSupplement: eventData.priceSupplement || 0, }); // 2. 检查该用户是否有待领取奖励需要转为可结算 diff --git a/frontend/admin-web/src/app/(dashboard)/settings/page.tsx b/frontend/admin-web/src/app/(dashboard)/settings/page.tsx index 1baee5a1..e2652fe2 100644 --- a/frontend/admin-web/src/app/(dashboard)/settings/page.tsx +++ b/frontend/admin-web/src/app/(dashboard)/settings/page.tsx @@ -7,6 +7,7 @@ import styles from './settings.module.scss'; import { systemConfigService, type DisplaySettings } from '@/services/systemConfigService'; import { appAssetService, type AppAsset, type AppAssetType } from '@/services/appAssetService'; import { customerServiceContactService, type CustomerServiceContact, type ContactType } from '@/services/customerServiceContactService'; +import { treePricingService, type TreePricingConfig, type TreePriceChangeLogItem } from '@/services/treePricingService'; /** * 后台账号数据接口 @@ -103,6 +104,19 @@ export default function SettingsPage() { const [editingContact, setEditingContact] = useState | null>(null); const [contactSaving, setContactSaving] = useState(false); + // 认种定价设置(总部运营成本压力涨价) + const [pricingConfig, setPricingConfig] = useState(null); + const [pricingLoading, setPricingLoading] = useState(false); + const [pricingSaving, setPricingSaving] = useState(false); + const [newSupplement, setNewSupplement] = useState(''); + const [supplementReason, setSupplementReason] = useState(''); + const [autoIncreaseEnabled, setAutoIncreaseEnabled] = useState(false); + const [autoIncreaseAmount, setAutoIncreaseAmount] = useState(''); + const [autoIncreaseIntervalDays, setAutoIncreaseIntervalDays] = useState(''); + const [changeLog, setChangeLog] = useState([]); + const [changeLogTotal, setChangeLogTotal] = useState(0); + const [changeLogPage, setChangeLogPage] = useState(1); + // 后台账号与安全 const [approvalCount, setApprovalCount] = useState('3'); const [sensitiveOperations, setSensitiveOperations] = useState(['修改结算参数', '删除用户']); @@ -282,12 +296,102 @@ export default function SettingsPage() { } }, [loadContacts]); + // 加载认种定价配置 + const loadPricingConfig = useCallback(async () => { + setPricingLoading(true); + try { + const config = await treePricingService.getConfig(); + setPricingConfig(config); + setNewSupplement(String(config.currentSupplement)); + setAutoIncreaseEnabled(config.autoIncreaseEnabled); + setAutoIncreaseAmount(String(config.autoIncreaseAmount)); + setAutoIncreaseIntervalDays(String(config.autoIncreaseIntervalDays)); + // 加载变更日志 + const log = await treePricingService.getChangeLog(1, 10); + setChangeLog(log.items); + setChangeLogTotal(log.total); + setChangeLogPage(1); + } catch (error) { + console.error('Failed to load pricing config:', error); + } finally { + setPricingLoading(false); + } + }, []); + + // 手动修改加价金额 + const handleSupplementUpdate = useCallback(async () => { + const amount = parseInt(newSupplement, 10); + if (isNaN(amount) || amount < 0) { + alert('请输入有效的加价金额(非负整数)'); + return; + } + if (!supplementReason.trim()) { + alert('请填写变更原因'); + return; + } + if (!confirm(`确认将加价金额修改为 ${amount} USDT?\n正式认种总价将变为 ${15831 + amount} USDT/棵\n预种总价将变为 ${Math.floor((15831 + amount) / 5)} USDT/份`)) return; + setPricingSaving(true); + try { + await treePricingService.updateSupplement(amount, supplementReason.trim()); + setSupplementReason(''); + await loadPricingConfig(); + alert('加价金额修改成功'); + } catch (error) { + console.error('Failed to update supplement:', error); + alert('修改失败,请重试'); + } finally { + setPricingSaving(false); + } + }, [newSupplement, supplementReason, loadPricingConfig]); + + // 更新自动涨价设置 + const handleAutoIncreaseUpdate = useCallback(async () => { + const amount = parseInt(autoIncreaseAmount, 10); + const days = parseInt(autoIncreaseIntervalDays, 10); + if (autoIncreaseEnabled && (isNaN(amount) || amount <= 0)) { + alert('请输入有效的自动涨价金额'); + return; + } + if (autoIncreaseEnabled && (isNaN(days) || days < 1)) { + alert('请输入有效的涨价间隔天数(至少1天)'); + return; + } + setPricingSaving(true); + try { + await treePricingService.updateAutoIncrease({ + enabled: autoIncreaseEnabled, + amount: autoIncreaseEnabled ? amount : undefined, + intervalDays: autoIncreaseEnabled ? days : undefined, + }); + await loadPricingConfig(); + alert('自动涨价设置保存成功'); + } catch (error) { + console.error('Failed to update auto-increase:', error); + alert('保存失败,请重试'); + } finally { + setPricingSaving(false); + } + }, [autoIncreaseEnabled, autoIncreaseAmount, autoIncreaseIntervalDays, loadPricingConfig]); + + // 变更日志翻页 + const handleChangeLogPageChange = useCallback(async (page: number) => { + try { + const log = await treePricingService.getChangeLog(page, 10); + setChangeLog(log.items); + setChangeLogTotal(log.total); + setChangeLogPage(page); + } catch (error) { + console.error('Failed to load change log:', error); + } + }, []); + // 组件挂载时加载展示设置 useEffect(() => { loadDisplaySettings(); loadAppAssets(); loadContacts(); - }, [loadDisplaySettings, loadAppAssets, loadContacts]); + loadPricingConfig(); + }, [loadDisplaySettings, loadAppAssets, loadContacts, loadPricingConfig]); // 切换货币选择 const toggleCurrency = (currency: string) => { @@ -1037,6 +1141,193 @@ export default function SettingsPage() { + {/* [2026-02-26] 认种定价设置(总部运营成本压力涨价) */} +
+
+

认种定价设置

+ {pricingLoading && 加载中...} +
+
+ {/* 当前价格展示 */} + {pricingConfig && ( +
+
+ 当前定价 +
+
+
+ 基础价: {pricingConfig.basePrice.toLocaleString()} USDT/棵 + 加价: {pricingConfig.currentSupplement.toLocaleString()} USDT + 正式认种总价: {pricingConfig.totalPrice.toLocaleString()} USDT/棵 +
+
+ 预种基础价: {pricingConfig.basePortionPrice.toLocaleString()} USDT/份 + 预种总价: {pricingConfig.totalPortionPrice.toLocaleString()} USDT/份 +
+ {pricingConfig.autoIncreaseEnabled && pricingConfig.nextAutoIncreaseAt && ( +
+ 下次自动涨价: {new Date(pricingConfig.nextAutoIncreaseAt).toLocaleString('zh-CN')} + (+{pricingConfig.autoIncreaseAmount} USDT → 届时总价 {(pricingConfig.totalPrice + pricingConfig.autoIncreaseAmount).toLocaleString()} USDT/棵) +
+ )} + + 最后更新: {new Date(pricingConfig.updatedAt).toLocaleString('zh-CN')} + +
+
+ )} + + {/* 手动调价 */} +
+
+ 手动调价(总部运营成本压力涨价) +
+
+ 加价金额 + setNewSupplement(e.target.value)} + /> + USDT +
+
+ 变更原因 + setSupplementReason(e.target.value)} + style={{ flex: 1 }} + /> + +
+ + 加价部分全额归总部 (S0000000001),不影响现有 10 类分配金额。 + {newSupplement && !isNaN(parseInt(newSupplement, 10)) && parseInt(newSupplement, 10) >= 0 && ( + <> 修改后: 正式认种 {(15831 + parseInt(newSupplement, 10)).toLocaleString()} USDT/棵,预种 {Math.floor((15831 + parseInt(newSupplement, 10)) / 5).toLocaleString()} USDT/份 + )} + +
+ + {/* 自动涨价设置 */} +
+
+ 自动涨价 + +
+ {autoIncreaseEnabled && ( +
+ + setAutoIncreaseIntervalDays(e.target.value)} + /> + 天,自动加价 + setAutoIncreaseAmount(e.target.value)} + /> + USDT +
+ )} +
+ +
+ + 启用后系统将每隔指定天数自动执行一次涨价,涨价部分全额归总部。 + +
+ + {/* 变更历史 */} + {changeLog.length > 0 && ( +
+

定价变更历史

+
+
+
+
时间
+
类型
+
变更前
+
变更后
+
变更量
+
原因
+
操作人
+
+
+ {changeLog.map((item) => ( +
+
{new Date(item.createdAt).toLocaleString('zh-CN')}
+
+ + {item.changeType === 'AUTO' ? '自动' : '手动'} + +
+
{item.previousSupplement}
+
{item.newSupplement}
+
{item.changeAmount > 0 ? '+' : ''}{item.changeAmount}
+
{item.reason || '-'}
+
{item.operatorId || 'SYSTEM'}
+
+ ))} +
+
+
+ {changeLogTotal > 10 && ( +
+ + + 第 {changeLogPage} 页 / 共 {Math.ceil(changeLogTotal / 10)} 页 + + +
+ )} +
+ )} +
+
+ {/* 页面底部操作按钮 */}