feat(pricing): 认种树动态定价涨价系统(总部运营成本压力涨价)

基础价 15831 USDT/棵不变,新增 HQ_PRICE_SUPPLEMENT 加价项全额归总部(S0000000001)。
支持手动调价+自动周期涨价,所有变更可审计,移动端动态展示价格及涨价预告。

- admin-service: TreePricingConfig/ChangeLog 表 + Service + Controller + 定时任务
- planting-service: 正式认种和预种订单快照 priceSupplement,动态价格校验
- reward-service: HQ_PRICE_SUPPLEMENT 分配类型,涨价金额直接入总部账户
- admin-web: Settings 页面新增定价配置区间(手动调价/自动涨价/变更历史)
- mobile-app: TreePricingService + 动态价格加载 + 涨价预告展示

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
hailin 2026-02-26 03:02:56 -08:00
parent 023d71ac33
commit ed6b48562a
31 changed files with 1393 additions and 29 deletions

View File

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

View File

@ -1272,3 +1272,47 @@ model PrePlantingConfig {
@@map("pre_planting_configs") @@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 // 自动涨价任务
}

View File

@ -87,6 +87,10 @@ import { ContractService } from './application/services/contract.service';
// [2026-02-17] 新增:预种计划开关管理 // [2026-02-17] 新增:预种计划开关管理
import { PrePlantingConfigController, PublicPrePlantingConfigController } from './pre-planting/pre-planting-config.controller'; import { PrePlantingConfigController, PublicPrePlantingConfigController } from './pre-planting/pre-planting-config.controller';
import { PrePlantingConfigService } from './pre-planting/pre-planting-config.service'; 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({ @Module({
imports: [ imports: [
@ -133,6 +137,9 @@ import { PrePlantingConfigService } from './pre-planting/pre-planting-config.ser
// [2026-02-17] 新增:预种计划开关管理 // [2026-02-17] 新增:预种计划开关管理
PrePlantingConfigController, PrePlantingConfigController,
PublicPrePlantingConfigController, PublicPrePlantingConfigController,
// [2026-02-26] 新增:认种树定价配置(总部运营成本压力涨价)
AdminTreePricingController,
PublicTreePricingController,
], ],
providers: [ providers: [
PrismaService, PrismaService,
@ -224,6 +231,9 @@ import { PrePlantingConfigService } from './pre-planting/pre-planting-config.ser
ContractService, ContractService,
// [2026-02-17] 新增:预种计划开关管理 // [2026-02-17] 新增:预种计划开关管理
PrePlantingConfigService, PrePlantingConfigService,
// [2026-02-26] 新增:认种树定价配置(总部运营成本压力涨价)
TreePricingService,
AutoPriceIncreaseJob,
], ],
}) })
export class AppModule {} export class AppModule {}

View File

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

View File

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

View File

@ -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<TreePricingConfigResponse> {
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<TreePricingConfigResponse> {
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<TreePricingConfigResponse> {
const config = await this.getOrCreateConfig();
const data: Record<string, unknown> = {
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<boolean> {
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;
}
}

View File

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

View File

@ -19,6 +19,7 @@ model PlantingOrder {
// 认种信息 // 认种信息
treeCount Int @map("tree_count") treeCount Int @map("tree_count")
totalAmount Decimal @map("total_amount") @db.Decimal(20, 8) 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") transferLockedCount Int @default(0) @map("transfer_locked_count")
@ -408,6 +409,7 @@ model PrePlantingOrder {
portionCount Int @default(1) @map("portion_count") portionCount Int @default(1) @map("portion_count")
pricePerPortion Decimal @default(3171) @map("price_per_portion") @db.Decimal(20, 8) pricePerPortion Decimal @default(3171) @map("price_per_portion") @db.Decimal(20, 8)
totalAmount Decimal @map("total_amount") @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) provinceCode String @map("province_code") @db.VarChar(10)

View File

@ -23,6 +23,7 @@ import {
CompensationType, CompensationType,
FailureStage, FailureStage,
} from '../../infrastructure/persistence/repositories/payment-compensation.repository'; } from '../../infrastructure/persistence/repositories/payment-compensation.repository';
import { TreePricingAdminClient } from '../../infrastructure/external/tree-pricing-admin.client';
export interface CreateOrderResult { export interface CreateOrderResult {
orderNo: string; orderNo: string;
@ -70,6 +71,7 @@ export class PlantingApplicationService {
private readonly walletService: WalletServiceClient, private readonly walletService: WalletServiceClient,
private readonly referralService: ReferralServiceClient, private readonly referralService: ReferralServiceClient,
private readonly compensationService: PaymentCompensationService, private readonly compensationService: PaymentCompensationService,
private readonly treePricingClient: TreePricingAdminClient,
) {} ) {}
/** /**
@ -82,17 +84,21 @@ export class PlantingApplicationService {
): Promise<CreateOrderResult> { ): Promise<CreateOrderResult> {
this.logger.log(`Creating order for user ${accountSequence}, treeCount: ${treeCount}`); 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 balance = await this.walletService.getBalance(accountSequence);
const requiredAmount = treeCount * PRICE_PER_TREE; const requiredAmount = treeCount * (PRICE_PER_TREE + priceSupplement);
if (balance.available < requiredAmount) { if (balance.available < requiredAmount) {
throw new Error( throw new Error(
`余额不足: 需要 ${requiredAmount} USDT, 当前可用 ${balance.available} USDT`, `余额不足: 需要 ${requiredAmount} USDT, 当前可用 ${balance.available} USDT`,
); );
} }
// 创建订单 // 创建订单priceSupplement 快照到订单,确保后续涨价不影响已创建订单)
const order = PlantingOrder.create(userId, accountSequence, treeCount); const order = PlantingOrder.create(userId, accountSequence, treeCount, priceSupplement);
await this.orderRepository.save(order); await this.orderRepository.save(order);
this.logger.log(`Order created: ${order.orderNo}`); this.logger.log(`Order created: ${order.orderNo}`);
@ -481,6 +487,9 @@ export class PlantingApplicationService {
// 新增:订单信息,供 referral-service 转发给 reward-service // 新增:订单信息,供 referral-service 转发给 reward-service
orderId: order.orderNo, orderId: order.orderNo,
paidAt: order.paidAt!.toISOString(), paidAt: order.paidAt!.toISOString(),
// [2026-02-26] 总部运营成本压力涨价(每棵树加价金额),归总部 (S0000000001)
priceSupplement: order.priceSupplement,
totalAmount: order.totalAmount,
}, },
}, },
aggregateId: order.orderNo, aggregateId: order.orderNo,

View File

@ -18,6 +18,7 @@ export interface PlantingOrderData {
accountSequence: string; accountSequence: string;
treeCount: number; treeCount: number;
totalAmount: number; totalAmount: number;
priceSupplement?: number; // 总部运营成本压力涨价,归总部 (S0000000001)
status: PlantingOrderStatus; status: PlantingOrderStatus;
selectedProvince?: string | null; selectedProvince?: string | null;
selectedCity?: string | null; selectedCity?: string | null;
@ -40,6 +41,7 @@ export class PlantingOrder {
private readonly _accountSequence: string; private readonly _accountSequence: string;
private readonly _treeCount: TreeCount; private readonly _treeCount: TreeCount;
private readonly _totalAmount: number; private readonly _totalAmount: number;
private readonly _priceSupplement: number; // 总部运营成本压力涨价
private _provinceCitySelection: ProvinceCitySelection | null; private _provinceCitySelection: ProvinceCitySelection | null;
private _status: PlantingOrderStatus; private _status: PlantingOrderStatus;
private _fundAllocations: FundAllocation[]; private _fundAllocations: FundAllocation[];
@ -61,6 +63,7 @@ export class PlantingOrder {
accountSequence: string, accountSequence: string,
treeCount: TreeCount, treeCount: TreeCount,
totalAmount: number, totalAmount: number,
priceSupplement: number = 0,
createdAt?: Date, createdAt?: Date,
) { ) {
this._id = null; this._id = null;
@ -69,6 +72,7 @@ export class PlantingOrder {
this._accountSequence = accountSequence; this._accountSequence = accountSequence;
this._treeCount = treeCount; this._treeCount = treeCount;
this._totalAmount = totalAmount; this._totalAmount = totalAmount;
this._priceSupplement = priceSupplement;
this._status = PlantingOrderStatus.CREATED; this._status = PlantingOrderStatus.CREATED;
this._provinceCitySelection = null; this._provinceCitySelection = null;
this._fundAllocations = []; this._fundAllocations = [];
@ -101,6 +105,10 @@ export class PlantingOrder {
get totalAmount(): number { get totalAmount(): number {
return this._totalAmount; return this._totalAmount;
} }
/** 总部运营成本压力涨价(每棵树加价金额),归总部 (S0000000001) */
get priceSupplement(): number {
return this._priceSupplement;
}
get status(): PlantingOrderStatus { get status(): PlantingOrderStatus {
return this._status; 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) { if (treeCount <= 0) {
throw new Error('认种数量必须大于0'); throw new Error('认种数量必须大于0');
} }
const orderNo = `PLT${Date.now()}${Math.random().toString(36).substring(2, 8).toUpperCase()}`; const orderNo = `PLT${Date.now()}${Math.random().toString(36).substring(2, 8).toUpperCase()}`;
const tree = TreeCount.create(treeCount); 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( order._domainEvents.push(
@ -372,6 +386,7 @@ export class PlantingOrder {
data.accountSequence, data.accountSequence,
TreeCount.create(data.treeCount), TreeCount.create(data.treeCount),
data.totalAmount, data.totalAmount,
data.priceSupplement || 0,
data.createdAt, data.createdAt,
); );
@ -412,6 +427,7 @@ export class PlantingOrder {
accountSequence: this._accountSequence, accountSequence: this._accountSequence,
treeCount: this._treeCount.value, treeCount: this._treeCount.value,
totalAmount: this._totalAmount, totalAmount: this._totalAmount,
priceSupplement: this._priceSupplement,
status: this._status, status: this._status,
selectedProvince: this._provinceCitySelection?.provinceCode || null, selectedProvince: this._provinceCitySelection?.provinceCode || null,
selectedCity: this._provinceCitySelection?.cityCode || null, selectedCity: this._provinceCitySelection?.cityCode || null,

View File

@ -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 total = allocations.reduce((sum, a) => sum + a.amount, 0);
const expected = 15831 * treeCount; const expected = order.totalAmount;
if (Math.abs(total - expected) > 0.01) { if (Math.abs(total - expected) > 0.01) {
throw new Error(`资金分配计算错误: 总额 ${total} != ${expected}`); 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 total = allocations.reduce((sum, a) => sum + a.amount, 0);
const expected = 15831 * treeCount; const expected = order.totalAmount;
if (Math.abs(total - expected) > 0.01) { if (Math.abs(total - expected) > 0.01) {
throw new Error(`资金分配计算错误: 总额 ${total} != ${expected}`); throw new Error(`资金分配计算错误: 总额 ${total} != ${expected}`);
} }

View File

@ -9,10 +9,16 @@ export enum FundAllocationTargetType {
CITY_TEAM_RIGHTS = 'CITY_TEAM_RIGHTS', // 288 USDT - 市团队权益 CITY_TEAM_RIGHTS = 'CITY_TEAM_RIGHTS', // 288 USDT - 市团队权益
COMMUNITY_RIGHTS = 'COMMUNITY_RIGHTS', // 576 USDT - 社区权益 COMMUNITY_RIGHTS = 'COMMUNITY_RIGHTS', // 576 USDT - 社区权益
RWAD_POOL = 'RWAD_POOL', // 5760 USDT - RWAD底池 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) // 基础 10 类分配类型(不含动态加价 HQ_PRICE_SUPPLEMENT
export const FUND_ALLOCATION_AMOUNTS: Record<FundAllocationTargetType, number> = type BaseAllocationTargetType = Exclude<FundAllocationTargetType, FundAllocationTargetType.HQ_PRICE_SUPPLEMENT>;
// 每棵树的资金分配规则 (总计 15831 USDT不含涨价补充)
export const FUND_ALLOCATION_AMOUNTS: Record<BaseAllocationTargetType, number> =
{ {
[FundAllocationTargetType.COST_ACCOUNT]: 2800, [FundAllocationTargetType.COST_ACCOUNT]: 2800,
[FundAllocationTargetType.OPERATION_ACCOUNT]: 2100, [FundAllocationTargetType.OPERATION_ACCOUNT]: 2100,

View File

@ -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<string>('ADMIN_SERVICE_URL') ||
'http://localhost:3010';
}
async getTreePricingConfig(): Promise<TreePricingConfig> {
try {
const response = await firstValueFrom(
this.httpService.get<TreePricingConfig>(
`${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,
};
}
}
}

View File

@ -12,6 +12,7 @@ import { UnitOfWork, UNIT_OF_WORK } from './persistence/unit-of-work';
import { WalletServiceClient } from './external/wallet-service.client'; import { WalletServiceClient } from './external/wallet-service.client';
import { ReferralServiceClient } from './external/referral-service.client'; import { ReferralServiceClient } from './external/referral-service.client';
import { IdentityServiceClient } from './external/identity-service.client'; import { IdentityServiceClient } from './external/identity-service.client';
import { TreePricingAdminClient } from './external/tree-pricing-admin.client';
import { KafkaModule } from './kafka/kafka.module'; import { KafkaModule } from './kafka/kafka.module';
import { OutboxPublisherService } from './kafka/outbox-publisher.service'; import { OutboxPublisherService } from './kafka/outbox-publisher.service';
import { EventAckController } from './kafka/event-ack.controller'; import { EventAckController } from './kafka/event-ack.controller';
@ -85,6 +86,7 @@ import { ContractSigningService } from '../application/services/contract-signing
WalletServiceClient, WalletServiceClient,
ReferralServiceClient, ReferralServiceClient,
IdentityServiceClient, IdentityServiceClient,
TreePricingAdminClient,
], ],
exports: [ exports: [
PrismaService, PrismaService,
@ -104,6 +106,7 @@ import { ContractSigningService } from '../application/services/contract-signing
WalletServiceClient, WalletServiceClient,
ReferralServiceClient, ReferralServiceClient,
IdentityServiceClient, IdentityServiceClient,
TreePricingAdminClient,
], ],
}) })
export class InfrastructureModule {} export class InfrastructureModule {}

View File

@ -24,6 +24,7 @@ export class PlantingOrderMapper {
accountSequence: prismaOrder.accountSequence, accountSequence: prismaOrder.accountSequence,
treeCount: prismaOrder.treeCount, treeCount: prismaOrder.treeCount,
totalAmount: Number(prismaOrder.totalAmount), totalAmount: Number(prismaOrder.totalAmount),
priceSupplement: prismaOrder.priceSupplement ?? 0,
status: prismaOrder.status as PlantingOrderStatus, status: prismaOrder.status as PlantingOrderStatus,
selectedProvince: prismaOrder.selectedProvince, selectedProvince: prismaOrder.selectedProvince,
selectedCity: prismaOrder.selectedCity, selectedCity: prismaOrder.selectedCity,
@ -81,6 +82,7 @@ export class PlantingOrderMapper {
accountSequence: data.accountSequence, accountSequence: data.accountSequence,
treeCount: data.treeCount, treeCount: data.treeCount,
totalAmount: new Prisma.Decimal(data.totalAmount), totalAmount: new Prisma.Decimal(data.totalAmount),
priceSupplement: data.priceSupplement ?? 0,
selectedProvince: data.selectedProvince || null, selectedProvince: data.selectedProvince || null,
selectedCity: data.selectedCity || null, selectedCity: data.selectedCity || null,
provinceCitySelectedAt: data.provinceCitySelectedAt || null, provinceCitySelectedAt: data.provinceCitySelectedAt || null,

View File

@ -14,6 +14,7 @@ import { PrePlantingPositionRepository } from '../../infrastructure/repositories
import { PrePlantingMergeRepository } from '../../infrastructure/repositories/pre-planting-merge.repository'; import { PrePlantingMergeRepository } from '../../infrastructure/repositories/pre-planting-merge.repository';
import { PrePlantingRewardService } from './pre-planting-reward.service'; import { PrePlantingRewardService } from './pre-planting-reward.service';
import { PrePlantingAdminClient } from '../../infrastructure/external/pre-planting-admin.client'; 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'; import { EventPublisherService } from '../../../infrastructure/kafka/event-publisher.service';
@Injectable() @Injectable()
@ -29,6 +30,7 @@ export class PrePlantingApplicationService {
private readonly mergeRepo: PrePlantingMergeRepository, private readonly mergeRepo: PrePlantingMergeRepository,
private readonly rewardService: PrePlantingRewardService, private readonly rewardService: PrePlantingRewardService,
private readonly adminClient: PrePlantingAdminClient, private readonly adminClient: PrePlantingAdminClient,
private readonly treePricingClient: TreePricingAdminClient,
private readonly eventPublisher: EventPublisherService, private readonly eventPublisher: EventPublisherService,
) {} ) {}
@ -52,8 +54,13 @@ export class PrePlantingApplicationService {
// Step 1: 前置校验 // Step 1: 前置校验
await this.validatePurchase(userId, portionCount); 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 orderNo = this.generateOrderNo();
const totalAmount = portionCount * PRE_PLANTING_PRICE_PER_PORTION; const totalAmount = portionCount * (PRE_PLANTING_PRICE_PER_PORTION + portionSupplement);
// Step 2: 冻结余额 // Step 2: 冻结余额
await this.walletClient.freezeForPlanting({ await this.walletClient.freezeForPlanting({
@ -71,7 +78,7 @@ export class PrePlantingApplicationService {
// Step 3-4: 事务内处理(创建订单 + 更新持仓 + 分配记录 + outbox // Step 3-4: 事务内处理(创建订单 + 更新持仓 + 分配记录 + outbox
// 注意:事务内只做 DB 写入,不做 HTTP 调用 // 注意:事务内只做 DB 写入,不做 HTTP 调用
await this.prisma.$transaction(async (tx) => { await this.prisma.$transaction(async (tx) => {
// 创建预种订单 // 创建预种订单portionSupplement 快照到订单,确保后续涨价不影响已创建订单)
const order = PrePlantingOrder.create( const order = PrePlantingOrder.create(
orderNo, orderNo,
userId, userId,
@ -79,6 +86,7 @@ export class PrePlantingApplicationService {
portionCount, portionCount,
provinceCode, provinceCode,
cityCode, cityCode,
portionSupplement,
); );
// 获取或创建持仓 // 获取或创建持仓
@ -99,7 +107,7 @@ export class PrePlantingApplicationService {
await this.orderRepo.save(tx, order); await this.orderRepo.save(tx, order);
await this.positionRepo.save(tx, position); await this.positionRepo.save(tx, position);
// 分配 10 类权益 — 事务内只持久化记录,返回 allocations 供事务后转账 // 分配 10 类权益 + 涨价补充 — 事务内只持久化记录,返回 allocations 供事务后转账
rewardAllocations = await this.rewardService.prepareAndPersistRewards( rewardAllocations = await this.rewardService.prepareAndPersistRewards(
tx, tx,
orderNo, orderNo,
@ -107,6 +115,7 @@ export class PrePlantingApplicationService {
provinceCode, provinceCode,
cityCode, cityCode,
portionCount, portionCount,
portionSupplement,
); );
// Outbox: 购买事件(包装为 { eventName, data } 格式,与现有 planting 事件一致) // Outbox: 购买事件(包装为 { eventName, data } 格式,与现有 planting 事件一致)

View File

@ -38,6 +38,9 @@ export class PrePlantingRewardService {
* *
* allocations HTTP * allocations HTTP
*/ */
/**
* @param portionSupplement 0
*/
async prepareAndPersistRewards( async prepareAndPersistRewards(
tx: Prisma.TransactionClient, tx: Prisma.TransactionClient,
orderNo: string, orderNo: string,
@ -45,10 +48,11 @@ export class PrePlantingRewardService {
provinceCode: string, provinceCode: string,
cityCode: string, cityCode: string,
portionCount: number, portionCount: number,
portionSupplement: number = 0,
): Promise<RewardAllocation[]> { ): Promise<RewardAllocation[]> {
this.logger.log( this.logger.log(
`[PRE-PLANTING] Preparing rewards for order ${orderNo}, ` + `[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: 确定所有分配对象 // Step 3: 确定所有分配对象
@ -57,6 +61,7 @@ export class PrePlantingRewardService {
provinceCode, provinceCode,
cityCode, cityCode,
portionCount, portionCount,
portionSupplement,
); );
// Step 4: 在事务内持久化分配记录 // Step 4: 在事务内持久化分配记录
@ -106,13 +111,15 @@ export class PrePlantingRewardService {
} }
/** /**
* 10 * 10 +
* @param portionSupplement 0
*/ */
private async resolveAllocations( private async resolveAllocations(
accountSequence: string, accountSequence: string,
provinceCode: string, provinceCode: string,
cityCode: string, cityCode: string,
portionCount: number, portionCount: number,
portionSupplement: number = 0,
): Promise<RewardAllocation[]> { ): Promise<RewardAllocation[]> {
const allocations: RewardAllocation[] = []; const allocations: RewardAllocation[] = [];
const multiplier = portionCount; const multiplier = portionCount;
@ -234,6 +241,17 @@ export class PrePlantingRewardService {
rewardStatus: PrePlantingRewardStatus.SETTLED, 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; return allocations;
} }

View File

@ -11,6 +11,7 @@ export interface PrePlantingOrderData {
portionCount: number; portionCount: number;
pricePerPortion: number; pricePerPortion: number;
totalAmount: number; totalAmount: number;
priceSupplement?: number; // 总部运营成本压力涨价(每份加价金额),归总部 (S0000000001)
provinceCode: string; provinceCode: string;
cityCode: string; cityCode: string;
status: PrePlantingOrderStatus; status: PrePlantingOrderStatus;
@ -28,6 +29,7 @@ export class PrePlantingOrder {
private readonly _portionCount: number; private readonly _portionCount: number;
private readonly _pricePerPortion: number; private readonly _pricePerPortion: number;
private readonly _totalAmount: number; private readonly _totalAmount: number;
private readonly _priceSupplement: number; // 总部运营成本压力涨价(每份加价金额)
private readonly _provinceCode: string; private readonly _provinceCode: string;
private readonly _cityCode: string; private readonly _cityCode: string;
private _status: PrePlantingOrderStatus; private _status: PrePlantingOrderStatus;
@ -45,6 +47,7 @@ export class PrePlantingOrder {
portionCount: number, portionCount: number,
provinceCode: string, provinceCode: string,
cityCode: string, cityCode: string,
priceSupplement: number = 0,
createdAt?: Date, createdAt?: Date,
) { ) {
this._id = null; this._id = null;
@ -52,8 +55,9 @@ export class PrePlantingOrder {
this._userId = userId; this._userId = userId;
this._accountSequence = accountSequence; this._accountSequence = accountSequence;
this._portionCount = portionCount; this._portionCount = portionCount;
this._pricePerPortion = PRE_PLANTING_PRICE_PER_PORTION; this._pricePerPortion = PRE_PLANTING_PRICE_PER_PORTION + priceSupplement;
this._totalAmount = portionCount * PRE_PLANTING_PRICE_PER_PORTION; this._totalAmount = portionCount * (PRE_PLANTING_PRICE_PER_PORTION + priceSupplement);
this._priceSupplement = priceSupplement;
this._provinceCode = provinceCode; this._provinceCode = provinceCode;
this._cityCode = cityCode; this._cityCode = cityCode;
this._status = PrePlantingOrderStatus.CREATED; this._status = PrePlantingOrderStatus.CREATED;
@ -66,6 +70,9 @@ export class PrePlantingOrder {
/** /**
* *
*/ */
/**
* @param priceSupplement 0
*/
static create( static create(
orderNo: string, orderNo: string,
userId: bigint, userId: bigint,
@ -73,6 +80,7 @@ export class PrePlantingOrder {
portionCount: number, portionCount: number,
provinceCode: string, provinceCode: string,
cityCode: string, cityCode: string,
priceSupplement: number = 0,
): PrePlantingOrder { ): PrePlantingOrder {
if (portionCount < 1) { if (portionCount < 1) {
throw new Error('购买份数必须大于 0'); throw new Error('购买份数必须大于 0');
@ -84,6 +92,7 @@ export class PrePlantingOrder {
portionCount, portionCount,
provinceCode, provinceCode,
cityCode, cityCode,
priceSupplement,
); );
} }
@ -98,6 +107,7 @@ export class PrePlantingOrder {
data.portionCount, data.portionCount,
data.provinceCode, data.provinceCode,
data.cityCode, data.cityCode,
data.priceSupplement || 0,
data.createdAt, data.createdAt,
); );
order._id = data.id || null; order._id = data.id || null;
@ -159,6 +169,8 @@ export class PrePlantingOrder {
get portionCount(): number { return this._portionCount; } get portionCount(): number { return this._portionCount; }
get pricePerPortion(): number { return this._pricePerPortion; } get pricePerPortion(): number { return this._pricePerPortion; }
get totalAmount(): number { return this._totalAmount; } get totalAmount(): number { return this._totalAmount; }
/** 总部运营成本压力涨价(每份加价金额),归总部 (S0000000001) */
get priceSupplement(): number { return this._priceSupplement; }
get provinceCode(): string { return this._provinceCode; } get provinceCode(): string { return this._provinceCode; }
get cityCode(): string { return this._cityCode; } get cityCode(): string { return this._cityCode; }
get status(): PrePlantingOrderStatus { return this._status; } get status(): PrePlantingOrderStatus { return this._status; }
@ -179,6 +191,7 @@ export class PrePlantingOrder {
portionCount: this._portionCount, portionCount: this._portionCount,
pricePerPortion: this._pricePerPortion, pricePerPortion: this._pricePerPortion,
totalAmount: this._totalAmount, totalAmount: this._totalAmount,
priceSupplement: this._priceSupplement,
provinceCode: this._provinceCode, provinceCode: this._provinceCode,
cityCode: this._cityCode, cityCode: this._cityCode,
status: this._status, status: this._status,

View File

@ -36,6 +36,9 @@ export enum PrePlantingRightType {
CITY_AREA_RIGHT = 'CITY_AREA_RIGHT', CITY_AREA_RIGHT = 'CITY_AREA_RIGHT',
CITY_TEAM_RIGHT = 'CITY_TEAM_RIGHT', CITY_TEAM_RIGHT = 'CITY_TEAM_RIGHT',
COMMUNITY_RIGHT = 'COMMUNITY_RIGHT', COMMUNITY_RIGHT = 'COMMUNITY_RIGHT',
// [2026-02-26] 新增:总部运营成本压力涨价,加价部分全额归总部 (S0000000001)
// 金额动态(由 admin-service TreePricingConfig 配置),不计入 PRE_PLANTING_RIGHT_AMOUNTS
HQ_PRICE_SUPPLEMENT = 'HQ_PRICE_SUPPLEMENT',
} }
/** /**

View File

@ -35,6 +35,7 @@ export class PrePlantingOrderRepository {
portionCount: data.portionCount, portionCount: data.portionCount,
pricePerPortion: new Prisma.Decimal(data.pricePerPortion), pricePerPortion: new Prisma.Decimal(data.pricePerPortion),
totalAmount: new Prisma.Decimal(data.totalAmount), totalAmount: new Prisma.Decimal(data.totalAmount),
priceSupplement: data.priceSupplement ?? 0,
provinceCode: data.provinceCode, provinceCode: data.provinceCode,
cityCode: data.cityCode, cityCode: data.cityCode,
status: data.status, status: data.status,
@ -91,6 +92,7 @@ export class PrePlantingOrderRepository {
portionCount: record.portionCount, portionCount: record.portionCount,
pricePerPortion: Number(record.pricePerPortion), pricePerPortion: Number(record.pricePerPortion),
totalAmount: Number(record.totalAmount), totalAmount: Number(record.totalAmount),
priceSupplement: record.priceSupplement ?? 0,
provinceCode: record.provinceCode, provinceCode: record.provinceCode,
cityCode: record.cityCode, cityCode: record.cityCode,
status: record.status as PrePlantingOrderStatus, status: record.status as PrePlantingOrderStatus,

View File

@ -59,6 +59,7 @@ export class RewardApplicationService {
treeCount: number; treeCount: number;
provinceCode: string; provinceCode: string;
cityCode: string; cityCode: string;
priceSupplement?: number; // [2026-02-26] 总部运营成本压力涨价(每棵树加价金额),归总部 (S0000000001)
}): Promise<void> { }): Promise<void> {
this.logger.log(`Distributing rewards for order ${params.sourceOrderNo}, accountSequence=${params.sourceAccountSequence}`); this.logger.log(`Distributing rewards for order ${params.sourceOrderNo}, accountSequence=${params.sourceAccountSequence}`);

View File

@ -94,6 +94,7 @@ export class RewardCalculationService {
treeCount: number; treeCount: number;
provinceCode: string; provinceCode: string;
cityCode: string; cityCode: string;
priceSupplement?: number; // [2026-02-26] 总部运营成本压力涨价(每棵树加价金额),归总部 (S0000000001)
}): Promise<RewardLedgerEntry[]> { }): Promise<RewardLedgerEntry[]> {
this.logger.log( this.logger.log(
`[calculateRewards] START orderNo=${params.sourceOrderNo}, userId=${params.sourceUserId}, ` + `[calculateRewards] START orderNo=${params.sourceOrderNo}, userId=${params.sourceUserId}, ` +
@ -204,6 +205,18 @@ export class RewardCalculationService {
); );
rewards.push(...communityRewards); 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( this.logger.log(
`[calculateRewards] DONE orderNo=${params.sourceOrderNo}, totalRewards=${rewards.length}`, `[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; treeCount: number;
provinceCode: string; provinceCode: string;
cityCode: string; cityCode: string;
priceSupplement?: number; // [2026-02-26] 总部运营成本压力涨价
}): Promise<RewardLedgerEntry[]> { }): Promise<RewardLedgerEntry[]> {
this.logger.log( this.logger.log(
`[calculateRewardsForExpiredContract] START orderNo=${params.sourceOrderNo}, userId=${params.sourceUserId}, ` + `[calculateRewardsForExpiredContract] START orderNo=${params.sourceOrderNo}, userId=${params.sourceUserId}, ` +
@ -827,6 +873,17 @@ export class RewardCalculationService {
params.treeCount, 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( this.logger.log(
`[calculateRewardsForExpiredContract] DONE orderNo=${params.sourceOrderNo}, totalRewards=${rewards.length}`, `[calculateRewardsForExpiredContract] DONE orderNo=${params.sourceOrderNo}, totalRewards=${rewards.length}`,
); );

View File

@ -12,10 +12,17 @@ export enum RightType {
CITY_AREA_RIGHT = 'CITY_AREA_RIGHT', // 市区域权益 252U + 2%算力 CITY_AREA_RIGHT = 'CITY_AREA_RIGHT', // 市区域权益 252U + 2%算力
CITY_TEAM_RIGHT = 'CITY_TEAM_RIGHT', // 市团队权益 288U CITY_TEAM_RIGHT = 'CITY_TEAM_RIGHT', // 市团队权益 288U
COMMUNITY_RIGHT = 'COMMUNITY_RIGHT', // 社区权益 576U COMMUNITY_RIGHT = 'COMMUNITY_RIGHT', // 社区权益 576U
// [2026-02-26] 新增:总部运营成本压力涨价,加价部分全额归总部 (S0000000001)
// 金额动态(由 admin-service TreePricingConfig 配置),不计入基础 15831
HQ_PRICE_SUPPLEMENT = 'HQ_PRICE_SUPPLEMENT',
} }
// 权益金额配置 // 基础 10 类分配类型(不含动态加价 HQ_PRICE_SUPPLEMENT
export const RIGHT_AMOUNTS: Record<RightType, { usdt: number; hashpowerPercent: number }> = { type BaseRightType = Exclude<RightType, RightType.HQ_PRICE_SUPPLEMENT>;
// 权益金额配置(基础 10 类,不含动态加价)
export const RIGHT_AMOUNTS: Record<BaseRightType, { usdt: number; hashpowerPercent: number }> = {
// 系统费用类 // 系统费用类
[RightType.COST_FEE]: { usdt: 2880, hashpowerPercent: 0 }, [RightType.COST_FEE]: { usdt: 2880, hashpowerPercent: 0 },
[RightType.OPERATION_FEE]: { usdt: 2100, hashpowerPercent: 0 }, [RightType.OPERATION_FEE]: { usdt: 2100, hashpowerPercent: 0 },

View File

@ -17,6 +17,9 @@ interface PlantingOrderPaidEvent {
provinceCode: string; provinceCode: string;
cityCode: string; cityCode: string;
paidAt: string; paidAt: string;
// [2026-02-26] 总部运营成本压力涨价(每棵树加价金额),归总部 (S0000000001)
priceSupplement?: number;
totalAmount?: number;
}; };
// 兼容旧格式 // 兼容旧格式
orderId?: string; orderId?: string;
@ -94,6 +97,7 @@ export class EventConsumerController {
treeCount: eventData.treeCount, treeCount: eventData.treeCount,
provinceCode: eventData.provinceCode, provinceCode: eventData.provinceCode,
cityCode: eventData.cityCode, cityCode: eventData.cityCode,
priceSupplement: eventData.priceSupplement || 0,
}); });
// 2. 检查该用户是否有待领取奖励需要转为可结算 // 2. 检查该用户是否有待领取奖励需要转为可结算

View File

@ -7,6 +7,7 @@ import styles from './settings.module.scss';
import { systemConfigService, type DisplaySettings } from '@/services/systemConfigService'; import { systemConfigService, type DisplaySettings } from '@/services/systemConfigService';
import { appAssetService, type AppAsset, type AppAssetType } from '@/services/appAssetService'; import { appAssetService, type AppAsset, type AppAssetType } from '@/services/appAssetService';
import { customerServiceContactService, type CustomerServiceContact, type ContactType } from '@/services/customerServiceContactService'; 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<Partial<CustomerServiceContact> | null>(null); const [editingContact, setEditingContact] = useState<Partial<CustomerServiceContact> | null>(null);
const [contactSaving, setContactSaving] = useState(false); const [contactSaving, setContactSaving] = useState(false);
// 认种定价设置(总部运营成本压力涨价)
const [pricingConfig, setPricingConfig] = useState<TreePricingConfig | null>(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<TreePriceChangeLogItem[]>([]);
const [changeLogTotal, setChangeLogTotal] = useState(0);
const [changeLogPage, setChangeLogPage] = useState(1);
// 后台账号与安全 // 后台账号与安全
const [approvalCount, setApprovalCount] = useState('3'); const [approvalCount, setApprovalCount] = useState('3');
const [sensitiveOperations, setSensitiveOperations] = useState(['修改结算参数', '删除用户']); const [sensitiveOperations, setSensitiveOperations] = useState(['修改结算参数', '删除用户']);
@ -282,12 +296,102 @@ export default function SettingsPage() {
} }
}, [loadContacts]); }, [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(() => { useEffect(() => {
loadDisplaySettings(); loadDisplaySettings();
loadAppAssets(); loadAppAssets();
loadContacts(); loadContacts();
}, [loadDisplaySettings, loadAppAssets, loadContacts]); loadPricingConfig();
}, [loadDisplaySettings, loadAppAssets, loadContacts, loadPricingConfig]);
// 切换货币选择 // 切换货币选择
const toggleCurrency = (currency: string) => { const toggleCurrency = (currency: string) => {
@ -1037,6 +1141,193 @@ export default function SettingsPage() {
</div> </div>
</section> </section>
{/* [2026-02-26] 认种定价设置(总部运营成本压力涨价) */}
<section className={styles.settings__section}>
<div className={styles.settings__sectionHeader}>
<h2 className={styles.settings__sectionTitle}></h2>
{pricingLoading && <span className={styles.settings__hint}>...</span>}
</div>
<div className={styles.settings__content}>
{/* 当前价格展示 */}
{pricingConfig && (
<div className={styles.settings__quotaCard}>
<div className={styles.settings__quotaHeader}>
<span className={styles.settings__quotaTitle}></span>
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: '8px', padding: '8px 0' }}>
<div style={{ display: 'flex', gap: '24px', flexWrap: 'wrap' }}>
<span>: <strong>{pricingConfig.basePrice.toLocaleString()}</strong> USDT/</span>
<span>: <strong>{pricingConfig.currentSupplement.toLocaleString()}</strong> USDT</span>
<span>: <strong style={{ color: '#e74c3c' }}>{pricingConfig.totalPrice.toLocaleString()}</strong> USDT/</span>
</div>
<div style={{ display: 'flex', gap: '24px', flexWrap: 'wrap' }}>
<span>: <strong>{pricingConfig.basePortionPrice.toLocaleString()}</strong> USDT/</span>
<span>: <strong style={{ color: '#e74c3c' }}>{pricingConfig.totalPortionPrice.toLocaleString()}</strong> USDT/</span>
</div>
{pricingConfig.autoIncreaseEnabled && pricingConfig.nextAutoIncreaseAt && (
<div style={{ marginTop: '4px', color: '#8e44ad' }}>
: {new Date(pricingConfig.nextAutoIncreaseAt).toLocaleString('zh-CN')}
+{pricingConfig.autoIncreaseAmount} USDT {(pricingConfig.totalPrice + pricingConfig.autoIncreaseAmount).toLocaleString()} USDT/
</div>
)}
<span className={styles.settings__hint}>
: {new Date(pricingConfig.updatedAt).toLocaleString('zh-CN')}
</span>
</div>
</div>
)}
{/* 手动调价 */}
<div className={styles.settings__quotaCard}>
<div className={styles.settings__quotaHeader}>
<span className={styles.settings__quotaTitle}></span>
</div>
<div className={styles.settings__quotaInputRow}>
<span className={styles.settings__quotaText}></span>
<input
type="number"
min="0"
className={cn(styles.settings__input, styles['settings__input--medium'])}
placeholder="加价金额"
value={newSupplement}
onChange={(e) => setNewSupplement(e.target.value)}
/>
<span className={styles.settings__inputUnit}>USDT</span>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', marginTop: '8px' }}>
<span className={styles.settings__quotaText}></span>
<input
type="text"
className={styles.settings__input}
placeholder="请输入变更原因(必填)"
value={supplementReason}
onChange={(e) => setSupplementReason(e.target.value)}
style={{ flex: 1 }}
/>
<button
className={styles.settings__saveBtn}
onClick={handleSupplementUpdate}
disabled={pricingSaving}
style={{ minWidth: '100px' }}
>
{pricingSaving ? '保存中...' : '确认修改'}
</button>
</div>
<span className={styles.settings__hint}>
(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/</>
)}
</span>
</div>
{/* 自动涨价设置 */}
<div className={styles.settings__quotaCard}>
<div className={styles.settings__quotaHeader}>
<span className={styles.settings__quotaTitle}></span>
<Toggle checked={autoIncreaseEnabled} onChange={setAutoIncreaseEnabled} />
</div>
{autoIncreaseEnabled && (
<div className={styles.settings__quotaInputRow}>
<span className={styles.settings__quotaText}></span>
<input
type="number"
min="1"
className={cn(styles.settings__input, styles['settings__input--small'])}
placeholder="天数"
value={autoIncreaseIntervalDays}
onChange={(e) => setAutoIncreaseIntervalDays(e.target.value)}
/>
<span className={styles.settings__quotaText}></span>
<input
type="number"
min="1"
className={cn(styles.settings__input, styles['settings__input--small'])}
placeholder="金额"
value={autoIncreaseAmount}
onChange={(e) => setAutoIncreaseAmount(e.target.value)}
/>
<span className={styles.settings__quotaText}>USDT</span>
</div>
)}
<div style={{ marginTop: '8px' }}>
<button
className={styles.settings__saveBtn}
onClick={handleAutoIncreaseUpdate}
disabled={pricingSaving}
style={{ minWidth: '140px' }}
>
{pricingSaving ? '保存中...' : '保存自动涨价设置'}
</button>
</div>
<span className={styles.settings__hint}>
</span>
</div>
{/* 变更历史 */}
{changeLog.length > 0 && (
<div style={{ marginTop: '16px' }}>
<h3 className={styles.settings__logTitle}></h3>
<div className={styles.settings__logTable}>
<div className={styles.settings__logTableInner}>
<div className={styles.settings__logHead}>
<div className={cn(styles.settings__logHeadCell, styles['settings__logHeadCell--time'])}></div>
<div className={cn(styles.settings__logHeadCell, styles['settings__logHeadCell--type'])}></div>
<div className={cn(styles.settings__logHeadCell, styles['settings__logHeadCell--desc'])}></div>
<div className={cn(styles.settings__logHeadCell, styles['settings__logHeadCell--desc'])}></div>
<div className={cn(styles.settings__logHeadCell, styles['settings__logHeadCell--type'])}></div>
<div className={cn(styles.settings__logHeadCell, styles['settings__logHeadCell--desc'])}></div>
<div className={cn(styles.settings__logHeadCell, styles['settings__logHeadCell--account'])}></div>
</div>
<div className={styles.settings__logBody}>
{changeLog.map((item) => (
<div key={item.id} className={styles.settings__logRow}>
<div className={cn(styles.settings__logCell, styles['settings__logCell--time'])}>{new Date(item.createdAt).toLocaleString('zh-CN')}</div>
<div className={cn(styles.settings__logCell, styles['settings__logCell--type'])}>
<span className={cn(
styles.settings__logResult,
item.changeType === 'AUTO' ? styles['settings__logResult--pending'] : styles['settings__logResult--pass']
)}>
{item.changeType === 'AUTO' ? '自动' : '手动'}
</span>
</div>
<div className={cn(styles.settings__logCell, styles['settings__logCell--desc'])}>{item.previousSupplement}</div>
<div className={cn(styles.settings__logCell, styles['settings__logCell--desc'])}>{item.newSupplement}</div>
<div className={cn(styles.settings__logCell, styles['settings__logCell--type'])}>{item.changeAmount > 0 ? '+' : ''}{item.changeAmount}</div>
<div className={cn(styles.settings__logCell, styles['settings__logCell--desc'])}>{item.reason || '-'}</div>
<div className={cn(styles.settings__logCell, styles['settings__logCell--account'])}>{item.operatorId || 'SYSTEM'}</div>
</div>
))}
</div>
</div>
</div>
{changeLogTotal > 10 && (
<div style={{ display: 'flex', justifyContent: 'center', gap: '8px', marginTop: '8px' }}>
<button
className={styles.settings__resetBtn}
disabled={changeLogPage <= 1}
onClick={() => handleChangeLogPageChange(changeLogPage - 1)}
>
</button>
<span className={styles.settings__hint}>
{changeLogPage} / {Math.ceil(changeLogTotal / 10)}
</span>
<button
className={styles.settings__resetBtn}
disabled={changeLogPage >= Math.ceil(changeLogTotal / 10)}
onClick={() => handleChangeLogPageChange(changeLogPage + 1)}
>
</button>
</div>
)}
</div>
)}
</div>
</section>
{/* 页面底部操作按钮 */} {/* 页面底部操作按钮 */}
<footer className={styles.settings__footer}> <footer className={styles.settings__footer}>
<button className={styles.settings__cancelBtn}></button> <button className={styles.settings__cancelBtn}></button>

View File

@ -312,4 +312,12 @@ export const API_ENDPOINTS = {
STATS: '/v1/admin/transfers/stats', STATS: '/v1/admin/transfers/stats',
FORCE_CANCEL: (no: string) => `/v1/admin/transfers/${no}/force-cancel`, FORCE_CANCEL: (no: string) => `/v1/admin/transfers/${no}/force-cancel`,
}, },
// [2026-02-26] 新增:认种树定价配置 (admin-service / TreePricingModule)
// 总部运营成本压力涨价管理:手动调价 + 自动涨价 + 审计日志
TREE_PRICING: {
CONFIG: '/v1/admin/tree-pricing/config',
SUPPLEMENT: '/v1/admin/tree-pricing/supplement',
AUTO_INCREASE: '/v1/admin/tree-pricing/auto-increase',
CHANGE_LOG: '/v1/admin/tree-pricing/change-log',
},
} as const; } as const;

View File

@ -0,0 +1,72 @@
/**
*
* + +
* [2026-02-26]
*/
import apiClient from '@/infrastructure/api/client';
import { API_ENDPOINTS } from '@/infrastructure/api/endpoints';
/** 定价配置响应 */
export interface TreePricingConfig {
basePrice: number;
basePortionPrice: number;
currentSupplement: number;
totalPrice: number;
totalPortionPrice: number;
autoIncreaseEnabled: boolean;
autoIncreaseAmount: number;
autoIncreaseIntervalDays: number;
lastAutoIncreaseAt: string | null;
nextAutoIncreaseAt: string | null;
updatedAt: string;
}
/** 变更日志项 */
export interface TreePriceChangeLogItem {
id: string;
changeType: 'MANUAL' | 'AUTO';
previousSupplement: number;
newSupplement: number;
changeAmount: number;
reason: string | null;
operatorId: string | null;
createdAt: string;
}
/** 变更日志分页响应 */
export interface TreePriceChangeLogResponse {
items: TreePriceChangeLogItem[];
total: number;
}
/**
*
*/
export const treePricingService = {
/** 获取当前定价配置 */
async getConfig(): Promise<TreePricingConfig> {
return apiClient.get(API_ENDPOINTS.TREE_PRICING.CONFIG);
},
/** 手动修改加价金额(总部运营成本压力涨价) */
async updateSupplement(newSupplement: number, reason: string): Promise<TreePricingConfig> {
return apiClient.post(API_ENDPOINTS.TREE_PRICING.SUPPLEMENT, { newSupplement, reason });
},
/** 更新自动涨价设置 */
async updateAutoIncrease(params: {
enabled: boolean;
amount?: number;
intervalDays?: number;
}): Promise<TreePricingConfig> {
return apiClient.put(API_ENDPOINTS.TREE_PRICING.AUTO_INCREASE, params);
},
/** 获取变更审计日志 */
async getChangeLog(page: number = 1, pageSize: number = 20): Promise<TreePriceChangeLogResponse> {
return apiClient.get(API_ENDPOINTS.TREE_PRICING.CHANGE_LOG, {
params: { page, pageSize },
});
},
};

View File

@ -16,6 +16,8 @@ import '../services/transfer_service.dart';
import '../services/reward_service.dart'; import '../services/reward_service.dart';
import '../services/notification_service.dart'; import '../services/notification_service.dart';
import '../services/system_config_service.dart'; import '../services/system_config_service.dart';
// [2026-02-26]
import '../services/tree_pricing_service.dart';
import '../services/app_asset_service.dart'; import '../services/app_asset_service.dart';
import '../services/customer_service_contact_service.dart'; import '../services/customer_service_contact_service.dart';
import '../services/leaderboard_service.dart'; import '../services/leaderboard_service.dart';
@ -131,6 +133,13 @@ final systemConfigServiceProvider = Provider<SystemConfigService>((ref) {
return SystemConfigService(apiClient: apiClient); return SystemConfigService(apiClient: apiClient);
}); });
// [2026-02-26] Tree Pricing Service Provider ( admin-service )
// basePrice + supplement5
final treePricingServiceProvider = Provider<TreePricingService>((ref) {
final apiClient = ref.watch(apiClientProvider);
return TreePricingService(apiClient: apiClient);
});
// App Asset Service Provider ( admin-service) // App Asset Service Provider ( admin-service)
final appAssetServiceProvider = Provider<AppAssetService>((ref) { final appAssetServiceProvider = Provider<AppAssetService>((ref) {
final apiClient = ref.watch(apiClientProvider); final apiClient = ref.watch(apiClientProvider);

View File

@ -0,0 +1,138 @@
import 'package:flutter/foundation.dart';
import '../network/api_client.dart';
/// [2026-02-26]
///
///
/// - = basePrice + currentSupplement
/// - = totalPrice / 5
class TreePricingConfig {
/// 15831
final int basePrice;
/// 3171
final int basePortionPrice;
///
final int currentSupplement;
/// = basePrice + currentSupplement
final int totalPrice;
/// = totalPrice / 5
final int totalPortionPrice;
///
final bool autoIncreaseEnabled;
///
final int autoIncreaseAmount;
///
final int autoIncreaseIntervalDays;
///
final DateTime? nextAutoIncreaseAt;
TreePricingConfig({
required this.basePrice,
required this.basePortionPrice,
required this.currentSupplement,
required this.totalPrice,
required this.totalPortionPrice,
required this.autoIncreaseEnabled,
required this.autoIncreaseAmount,
required this.autoIncreaseIntervalDays,
this.nextAutoIncreaseAt,
});
factory TreePricingConfig.fromJson(Map<String, dynamic> json) {
return TreePricingConfig(
basePrice: json['basePrice'] ?? 15831,
basePortionPrice: json['basePortionPrice'] ?? 3171,
currentSupplement: json['currentSupplement'] ?? 0,
totalPrice: json['totalPrice'] ?? 15831,
totalPortionPrice: json['totalPortionPrice'] ?? 3171,
autoIncreaseEnabled: json['autoIncreaseEnabled'] ?? false,
autoIncreaseAmount: json['autoIncreaseAmount'] ?? 0,
autoIncreaseIntervalDays: json['autoIncreaseIntervalDays'] ?? 0,
nextAutoIncreaseAt: json['nextAutoIncreaseAt'] != null
? DateTime.tryParse(json['nextAutoIncreaseAt'])
: null,
);
}
/// supplement=0
factory TreePricingConfig.defaultConfig() {
return TreePricingConfig(
basePrice: 15831,
basePortionPrice: 3171,
currentSupplement: 0,
totalPrice: 15831,
totalPortionPrice: 3171,
autoIncreaseEnabled: false,
autoIncreaseAmount: 0,
autoIncreaseIntervalDays: 0,
);
}
}
///
///
/// admin-service 5
/// supplement=0
class TreePricingService {
final ApiClient _apiClient;
///
TreePricingConfig? _cachedConfig;
///
DateTime? _lastFetchTime;
/// 5
static const Duration _cacheExpiration = Duration(minutes: 5);
TreePricingService({required ApiClient apiClient}) : _apiClient = apiClient;
///
///
/// [forceRefresh] 使
Future<TreePricingConfig> getConfig({bool forceRefresh = false}) async {
//
if (!forceRefresh && _cachedConfig != null && _lastFetchTime != null) {
final cacheAge = DateTime.now().difference(_lastFetchTime!);
if (cacheAge < _cacheExpiration) {
return _cachedConfig!;
}
}
try {
final response = await _apiClient.get(
'/tree-pricing/config',
);
_cachedConfig = TreePricingConfig.fromJson(response.data);
_lastFetchTime = DateTime.now();
debugPrint(
'[TreePricingService] 获取定价成功: totalPrice=${_cachedConfig!.totalPrice}, '
'totalPortionPrice=${_cachedConfig!.totalPortionPrice}, '
'supplement=${_cachedConfig!.currentSupplement}',
);
return _cachedConfig!;
} catch (e) {
debugPrint('[TreePricingService] 获取定价失败: $e');
//
return _cachedConfig ?? TreePricingConfig.defaultConfig();
}
}
///
void clearCache() {
_cachedConfig = null;
_lastFetchTime = null;
}
}

View File

@ -3,6 +3,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
import '../../../../routes/route_paths.dart'; import '../../../../routes/route_paths.dart';
import '../../../../core/di/injection_container.dart'; import '../../../../core/di/injection_container.dart';
import '../../../../core/services/tree_pricing_service.dart';
import 'planting_location_page.dart'; import 'planting_location_page.dart';
/// ///
@ -16,8 +17,11 @@ class PlantingQuantityPage extends ConsumerStatefulWidget {
} }
class _PlantingQuantityPageState extends ConsumerState<PlantingQuantityPage> { class _PlantingQuantityPageState extends ConsumerState<PlantingQuantityPage> {
/// (USDT) /// (USDT) - admin-service
static const double _pricePerTree = 15831.0; double _pricePerTree = 15831.0;
///
TreePricingConfig? _pricingConfig;
/// (USDT) - API /// (USDT) - API
double _availableBalance = 0.0; double _availableBalance = 0.0;
@ -46,10 +50,29 @@ class _PlantingQuantityPageState extends ConsumerState<PlantingQuantityPage> {
@override @override
void initState() { void initState() {
super.initState(); super.initState();
_loadPricingConfig();
_loadBalance(); _loadBalance();
_quantityController.addListener(_onQuantityChanged); _quantityController.addListener(_onQuantityChanged);
} }
/// admin-service
Future<void> _loadPricingConfig() async {
try {
final treePricingService = ref.read(treePricingServiceProvider);
final config = await treePricingService.getConfig();
if (mounted) {
setState(() {
_pricingConfig = config;
_pricePerTree = config.totalPrice.toDouble();
});
debugPrint('[PlantingQuantity] 动态定价: ${config.totalPrice} USDT/棵 (supplement=${config.currentSupplement})');
}
} catch (e) {
debugPrint('[PlantingQuantity] 获取定价失败,使用默认 15831: $e');
// 15831
}
}
@override @override
void dispose() { void dispose() {
_quantityController.removeListener(_onQuantityChanged); _quantityController.removeListener(_onQuantityChanged);
@ -684,6 +707,40 @@ class _PlantingQuantityPageState extends ConsumerState<PlantingQuantityPage> {
color: Color(0xFF000000), color: Color(0xFF000000),
), ),
), ),
//
if (_pricingConfig != null &&
_pricingConfig!.autoIncreaseEnabled &&
_pricingConfig!.nextAutoIncreaseAt != null) ...[
const SizedBox(height: 12),
Container(
padding: const EdgeInsets.all(10),
decoration: BoxDecoration(
color: const Color(0xFFFFF3E0),
borderRadius: BorderRadius.circular(8),
),
child: Row(
children: [
const Icon(
Icons.trending_up,
color: Color(0xFFE65100),
size: 18,
),
const SizedBox(width: 8),
Expanded(
child: Text(
'预计涨价: ${_pricingConfig!.nextAutoIncreaseAt!.month}${_pricingConfig!.nextAutoIncreaseAt!.day}日后每棵 +${_pricingConfig!.autoIncreaseAmount} 绿积分',
style: const TextStyle(
fontSize: 13,
fontFamily: 'Inter',
height: 1.4,
color: Color(0xFFE65100),
),
),
),
],
),
),
],
], ],
), ),
); );

View File

@ -4,6 +4,7 @@ import 'package:go_router/go_router.dart';
import 'package:city_pickers/city_pickers.dart'; import 'package:city_pickers/city_pickers.dart';
import '../../../../core/di/injection_container.dart'; import '../../../../core/di/injection_container.dart';
import '../../../../core/services/pre_planting_service.dart'; import '../../../../core/services/pre_planting_service.dart';
import '../../../../core/services/tree_pricing_service.dart';
// ============================================ // ============================================
// [2026-02-17] // [2026-02-17]
@ -41,12 +42,15 @@ class _PrePlantingPurchasePageState
extends ConsumerState<PrePlantingPurchasePage> { extends ConsumerState<PrePlantingPurchasePage> {
// === === // === ===
/// USDT /// USDT- admin-service
static const double _pricePerPortion = 3171.0; double _pricePerPortion = 3171.0;
/// ///
static const int _portionsPerTree = 5; static const int _portionsPerTree = 5;
///
TreePricingConfig? _pricingConfig;
// === === // === ===
/// 绿 / USDT /// 绿 / USDT
@ -126,6 +130,7 @@ class _PrePlantingPurchasePageState
try { try {
final walletService = ref.read(walletServiceProvider); final walletService = ref.read(walletServiceProvider);
final prePlantingService = ref.read(prePlantingServiceProvider); final prePlantingService = ref.read(prePlantingServiceProvider);
final treePricingService = ref.read(treePricingServiceProvider);
// //
final results = await Future.wait([ final results = await Future.wait([
@ -140,12 +145,14 @@ class _PrePlantingPurchasePageState
totalTreesMerged: 0, totalTreesMerged: 0,
), ),
), ),
treePricingService.getConfig(), // [4]
]); ]);
final walletResponse = results[0]; final walletResponse = results[0];
final config = results[1] as PrePlantingConfig; final config = results[1] as PrePlantingConfig;
final eligibility = results[2] as PrePlantingEligibility; final eligibility = results[2] as PrePlantingEligibility;
final position = results[3] as PrePlantingPosition; final position = results[3] as PrePlantingPosition;
final pricingConfig = results[4] as TreePricingConfig;
// 使 USDT // 使 USDT
final balance = (walletResponse as dynamic).balances.usdt.available as double; final balance = (walletResponse as dynamic).balances.usdt.available as double;
@ -155,6 +162,8 @@ class _PrePlantingPurchasePageState
_config = config; _config = config;
_eligibility = eligibility; _eligibility = eligibility;
_position = position; _position = position;
_pricingConfig = pricingConfig;
_pricePerPortion = pricingConfig.totalPortionPrice.toDouble();
_isLoading = false; _isLoading = false;
// //
@ -1310,6 +1319,40 @@ class _PrePlantingPurchasePageState
color: Color(0xFF745D43), color: Color(0xFF745D43),
), ),
), ),
//
if (_pricingConfig != null &&
_pricingConfig!.autoIncreaseEnabled &&
_pricingConfig!.nextAutoIncreaseAt != null) ...[
const SizedBox(height: 12),
Container(
padding: const EdgeInsets.all(10),
decoration: BoxDecoration(
color: const Color(0xFFFFF3E0),
borderRadius: BorderRadius.circular(8),
),
child: Row(
children: [
const Icon(
Icons.trending_up,
color: Color(0xFFE65100),
size: 18,
),
const SizedBox(width: 8),
Expanded(
child: Text(
'预计涨价: ${_pricingConfig!.nextAutoIncreaseAt!.month}${_pricingConfig!.nextAutoIncreaseAt!.day}日后每份 +${(_pricingConfig!.autoIncreaseAmount / 5).floor()} 绿积分',
style: const TextStyle(
fontSize: 13,
fontFamily: 'Inter',
height: 1.4,
color: Color(0xFFE65100),
),
),
),
],
),
),
],
], ],
), ),
); );