From ac3adfc90a3531cbc0032aa7ab2cfe6cac780369 Mon Sep 17 00:00:00 2001
From: hailin
Date: Wed, 4 Mar 2026 05:04:57 -0800
Subject: [PATCH] =?UTF-8?q?feat(pre-planting):=20=E6=96=B0=E5=A2=9E?=
=?UTF-8?q?=E9=A2=84=E7=A7=8D=E7=A7=AF=E5=88=86=E8=82=A1=E5=8D=96=E5=87=BA?=
=?UTF-8?q?=E9=99=90=E5=88=B6=EF=BC=88=E6=96=B9=E6=A1=88B=E7=BA=AF?=
=?UTF-8?q?=E6=96=B0=E5=A2=9E=EF=BC=89?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
限制仅有预种份数(未合并成棵)的用户卖出积分股,
直到用户完成首次预种合并后方可卖出。
=== 改动范围(全部 2.0 系统,纯新增)===
contribution-service:
- prisma/pre-planting/schema.prisma: 新增 PrePlantingSellRestrictionOverride 模型
- migrations/20260304000000: 对应建表 SQL
- src/pre-planting/application/services/sell-restriction.service.ts: 核心判断逻辑
isRestricted = has_pre_planting_marker AND !has_real_tree AND !admin_override
- src/api/controllers/pre-planting-restriction.controller.ts: 暴露内部接口
GET /api/v2/pre-planting/sell-restriction/:accountSequence (@Public)
POST /api/v2/pre-planting/sell-restriction/:accountSequence/unlock (@Public)
- src/api/api.module.ts: 注册新 controller 和 SellRestrictionService
trading-service:
- src/application/services/sell-restriction.service.ts: HTTP + Redis 缓存(TTL 60s)
fail-open:contribution-service 不可用时允许卖出,保障业务连续性
- src/application/services/order.service.ts: 卖单前增加限制检查(4行)
- src/application/application.module.ts: 注册 TradingSellRestrictionService
mining-admin-service:
- src/application/services/pre-planting-restriction.service.ts: 代理接口 + 审计日志
每次管理员解除操作均写入 AuditLog,保证严格可追溯性
- src/api/controllers/pre-planting-restriction.controller.ts:
GET /pre-planting-restriction/:accountSequence
POST /pre-planting-restriction/:accountSequence/unlock
- api.module.ts / application.module.ts: 注册新服务和接口
mining-admin-web:
- users.api.ts: 新增 getPrePlantingRestriction / unlockPrePlantingRestriction
- use-users.ts: 新增 usePrePlantingRestriction / useUnlockPrePlantingRestriction hooks
- users/[accountSequence]/page.tsx: 受限时在基本信息卡显示红色警告 + 解除按钮
Co-Authored-By: Claude Sonnet 4.6
---
.../migration.sql | 12 +++
.../prisma/pre-planting/schema.prisma | 17 ++++
.../src/api/api.module.ts | 21 +++-
.../pre-planting-restriction.controller.ts | 65 +++++++++++++
.../services/sell-restriction.service.ts | 96 +++++++++++++++++++
.../src/api/api.module.ts | 3 +
.../pre-planting-restriction.controller.ts | 46 +++++++++
.../src/application/application.module.ts | 4 +
.../pre-planting-restriction.service.ts | 88 +++++++++++++++++
.../src/application/application.module.ts | 3 +
.../src/application/services/order.service.ts | 11 +++
.../services/sell-restriction.service.ts | 86 +++++++++++++++++
.../users/[accountSequence]/page.tsx | 41 +++++++-
.../src/features/users/api/users.api.ts | 12 +++
.../src/features/users/hooks/use-users.ts | 20 ++++
15 files changed, 521 insertions(+), 4 deletions(-)
create mode 100644 backend/services/contribution-service/prisma/pre-planting/migrations/20260304000000_add_sell_restriction_override/migration.sql
create mode 100644 backend/services/contribution-service/src/api/controllers/pre-planting-restriction.controller.ts
create mode 100644 backend/services/contribution-service/src/pre-planting/application/services/sell-restriction.service.ts
create mode 100644 backend/services/mining-admin-service/src/api/controllers/pre-planting-restriction.controller.ts
create mode 100644 backend/services/mining-admin-service/src/application/services/pre-planting-restriction.service.ts
create mode 100644 backend/services/trading-service/src/application/services/sell-restriction.service.ts
diff --git a/backend/services/contribution-service/prisma/pre-planting/migrations/20260304000000_add_sell_restriction_override/migration.sql b/backend/services/contribution-service/prisma/pre-planting/migrations/20260304000000_add_sell_restriction_override/migration.sql
new file mode 100644
index 00000000..d293fd11
--- /dev/null
+++ b/backend/services/contribution-service/prisma/pre-planting/migrations/20260304000000_add_sell_restriction_override/migration.sql
@@ -0,0 +1,12 @@
+-- Migration: add pre_planting_sell_restriction_overrides table
+-- [2026-03-04] 新增:预种卖出限制管理员手动解除记录表
+--
+-- 用途:管理员手动解除某用户的预种卖出限制时,写入此表。
+-- 判断逻辑:isRestricted = has_pre_planting_marker AND !has_real_tree AND !admin_override
+CREATE TABLE "pre_planting_sell_restriction_overrides" (
+ "account_sequence" VARCHAR(20) NOT NULL,
+ "unlocked_by" VARCHAR(50) NOT NULL,
+ "reason" VARCHAR(200),
+ "created_at" TIMESTAMPTZ NOT NULL DEFAULT now(),
+ CONSTRAINT "pre_planting_sell_restriction_overrides_pkey" PRIMARY KEY ("account_sequence")
+);
diff --git a/backend/services/contribution-service/prisma/pre-planting/schema.prisma b/backend/services/contribution-service/prisma/pre-planting/schema.prisma
index 18f508db..622536ba 100644
--- a/backend/services/contribution-service/prisma/pre-planting/schema.prisma
+++ b/backend/services/contribution-service/prisma/pre-planting/schema.prisma
@@ -160,3 +160,20 @@ model PrePlantingProcessedCdcEvent {
@@index([processedAt])
@@map("pre_planting_processed_cdc_events")
}
+
+// ============================================
+// 预种卖出限制表
+// ============================================
+
+/// 预种卖出限制手动解除记录
+///
+/// 判断逻辑:isRestricted = has_pre_planting_marker AND !has_real_tree AND !admin_override
+/// 本表记录管理员手动解除的账户,写入后不会自动恢复。
+model PrePlantingSellRestrictionOverride {
+ accountSequence String @id @map("account_sequence") @db.VarChar(20)
+ unlockedBy String @map("unlocked_by") @db.VarChar(50) // 管理员 accountSequence 或 'ADMIN_MANUAL'
+ reason String? @db.VarChar(200)
+ createdAt DateTime @default(now()) @map("created_at")
+
+ @@map("pre_planting_sell_restriction_overrides")
+}
diff --git a/backend/services/contribution-service/src/api/api.module.ts b/backend/services/contribution-service/src/api/api.module.ts
index 269b972e..8b9559b4 100644
--- a/backend/services/contribution-service/src/api/api.module.ts
+++ b/backend/services/contribution-service/src/api/api.module.ts
@@ -5,9 +5,26 @@ import { ContributionController } from './controllers/contribution.controller';
import { SnapshotController } from './controllers/snapshot.controller';
import { HealthController } from './controllers/health.controller';
import { AdminController } from './controllers/admin.controller';
+// [2026-03-04] 新增:预种卖出限制接口
+import { PrePlantingRestrictionController } from './controllers/pre-planting-restriction.controller';
+import { PrePlantingPrismaModule } from '../pre-planting/infrastructure/prisma/pre-planting-prisma.module';
+import { SellRestrictionService } from '../pre-planting/application/services/sell-restriction.service';
@Module({
- imports: [ApplicationModule, InfrastructureModule],
- controllers: [ContributionController, SnapshotController, HealthController, AdminController],
+ imports: [
+ ApplicationModule,
+ InfrastructureModule,
+ PrePlantingPrismaModule, // 提供 PrePlantingPrismaService(用于 override 表)
+ ],
+ controllers: [
+ ContributionController,
+ SnapshotController,
+ HealthController,
+ AdminController,
+ PrePlantingRestrictionController, // 预种卖出限制接口
+ ],
+ providers: [
+ SellRestrictionService, // 预种卖出限制判断逻辑
+ ],
})
export class ApiModule {}
diff --git a/backend/services/contribution-service/src/api/controllers/pre-planting-restriction.controller.ts b/backend/services/contribution-service/src/api/controllers/pre-planting-restriction.controller.ts
new file mode 100644
index 00000000..8e1daba7
--- /dev/null
+++ b/backend/services/contribution-service/src/api/controllers/pre-planting-restriction.controller.ts
@@ -0,0 +1,65 @@
+import { Controller, Get, Post, Param, Body } from '@nestjs/common';
+import { ApiTags, ApiOperation, ApiParam, ApiResponse, ApiBody } from '@nestjs/swagger';
+import { IsString, IsOptional } from 'class-validator';
+import { SellRestrictionService } from '../../pre-planting/application/services/sell-restriction.service';
+import { Public } from '../../shared/guards/jwt-auth.guard';
+
+class UnlockRestrictionDto {
+ @IsString()
+ unlockedBy: string;
+
+ @IsString()
+ @IsOptional()
+ reason?: string;
+}
+
+/**
+ * 预种卖出限制接口
+ *
+ * [2026-03-04] 新增:内部接口,供 trading-service 和 mining-admin-service 调用。
+ *
+ * GET /api/v2/pre-planting/sell-restriction/:accountSequence
+ * → { isRestricted: boolean }(Public,供 trading-service 调用)
+ *
+ * POST /api/v2/pre-planting/sell-restriction/:accountSequence/unlock
+ * → { success: true }(Public,内部服务间调用,依赖网络隔离保护)
+ */
+@ApiTags('Pre-planting Sell Restriction')
+@Controller('pre-planting/sell-restriction')
+export class PrePlantingRestrictionController {
+ constructor(private readonly sellRestrictionService: SellRestrictionService) {}
+
+ /**
+ * 查询预种卖出限制状态
+ * 标记为 @Public 以便 trading-service 无需 JWT 即可调用
+ */
+ @Get(':accountSequence')
+ @Public()
+ @ApiOperation({ summary: '查询账户预种卖出限制状态' })
+ @ApiParam({ name: 'accountSequence', description: '账户序列' })
+ @ApiResponse({ status: 200, description: '{ isRestricted: boolean }' })
+ async getRestriction(
+ @Param('accountSequence') accountSequence: string,
+ ): Promise<{ isRestricted: boolean }> {
+ const isRestricted = await this.sellRestrictionService.isRestricted(accountSequence);
+ return { isRestricted };
+ }
+
+ /**
+ * 管理员手动解除卖出限制
+ * 标记为 @Public,依赖 Docker 内网隔离(仅 mining-admin-service 可达)
+ */
+ @Post(':accountSequence/unlock')
+ @Public()
+ @ApiOperation({ summary: '管理员手动解除预种卖出限制' })
+ @ApiParam({ name: 'accountSequence', description: '账户序列' })
+ @ApiBody({ type: UnlockRestrictionDto })
+ @ApiResponse({ status: 201, description: '{ success: true }' })
+ async unlock(
+ @Param('accountSequence') accountSequence: string,
+ @Body() body: UnlockRestrictionDto,
+ ): Promise<{ success: boolean }> {
+ await this.sellRestrictionService.createOverride(accountSequence, body.unlockedBy, body.reason);
+ return { success: true };
+ }
+}
diff --git a/backend/services/contribution-service/src/pre-planting/application/services/sell-restriction.service.ts b/backend/services/contribution-service/src/pre-planting/application/services/sell-restriction.service.ts
new file mode 100644
index 00000000..ae41e1a2
--- /dev/null
+++ b/backend/services/contribution-service/src/pre-planting/application/services/sell-restriction.service.ts
@@ -0,0 +1,96 @@
+import { Injectable } from '@nestjs/common';
+import { PrismaService } from '../../../infrastructure/persistence/prisma/prisma.service';
+import { PrePlantingPrismaService } from '../../infrastructure/prisma/pre-planting-prisma.service';
+
+/**
+ * 预种卖出限制服务
+ *
+ * [2026-03-04] 新增:限制仅有预种份数(未合并成棵)的用户卖出积分股,
+ * 直到用户完成"首次预种合并"(将份数合并成整棵树)为止。
+ *
+ * === 判断逻辑 ===
+ * isRestricted = has_pre_planting_marker AND !has_real_tree AND !admin_override
+ *
+ * - has_pre_planting_marker:synced_adoptions.distribution_summary = 'PRE_PLANTING_MARKER'
+ * - has_real_tree:
+ * 正常认种:original_adoption_id < 10_000_000_000 AND status = 'MINING_ENABLED' AND tree_count > 0
+ * 合并树: original_adoption_id >= 20_000_000_000 AND tree_count > 0
+ * - admin_override:pre_planting_sell_restriction_overrides 表中存在该账户记录
+ *
+ * === 注意 ===
+ * - 本服务使用两个 Prisma 实例:
+ * PrismaService(主 schema):查询 synced_adoptions 判断预种和真实树状态
+ * PrePlantingPrismaService(预种 schema):查询/写入 override 表
+ */
+@Injectable()
+export class SellRestrictionService {
+ // 10B 偏移:pre-planting 份额的 originalAdoptionId
+ private static readonly PRE_PLANTING_OFFSET = 10_000_000_000n;
+ // 20B 偏移:合并树的 originalAdoptionId
+ private static readonly MERGE_OFFSET = 20_000_000_000n;
+ // 预种标记字符串
+ private static readonly PRE_PLANTING_MARKER = 'PRE_PLANTING_MARKER';
+
+ constructor(
+ private readonly prisma: PrismaService,
+ private readonly prePlantingPrisma: PrePlantingPrismaService,
+ ) {}
+
+ /**
+ * 判断账户是否受预种卖出限制
+ *
+ * @returns true = 限制卖出;false = 允许卖出
+ */
+ async isRestricted(accountSequence: string): Promise {
+ // 1. 管理员手动解除?
+ const override = await this.prePlantingPrisma.prePlantingSellRestrictionOverride.findUnique({
+ where: { accountSequence },
+ });
+ if (override) return false;
+
+ // 2. 有预种标记?(预种份额写入 synced_adoptions 时设置此标记)
+ const marker = await this.prisma.syncedAdoption.findFirst({
+ where: {
+ accountSequence,
+ distributionSummary: SellRestrictionService.PRE_PLANTING_MARKER,
+ },
+ select: { id: true },
+ });
+ if (!marker) return false; // 没有预种,不限制
+
+ // 3. 有真实树?(正常认种 或 合并树)
+ const realTree = await this.prisma.syncedAdoption.findFirst({
+ where: {
+ accountSequence,
+ treeCount: { gt: 0 },
+ OR: [
+ // 正常认种(originalAdoptionId < 10B),状态为挖矿启用
+ {
+ originalAdoptionId: { lt: SellRestrictionService.PRE_PLANTING_OFFSET },
+ status: 'MINING_ENABLED',
+ },
+ // 合并树(originalAdoptionId >= 20B,来自 pre-planting-merge-synced.handler)
+ {
+ originalAdoptionId: { gte: SellRestrictionService.MERGE_OFFSET },
+ },
+ ],
+ },
+ select: { id: true },
+ });
+
+ return !realTree; // 无真实树 = 受限
+ }
+
+ /**
+ * 管理员手动解除卖出限制
+ *
+ * 使用 upsert 保证幂等性:重复解除只更新记录,不报错。
+ */
+ async createOverride(accountSequence: string, unlockedBy: string, reason?: string): Promise {
+ await this.prePlantingPrisma.prePlantingSellRestrictionOverride.upsert({
+ where: { accountSequence },
+ create: { accountSequence, unlockedBy, reason },
+ update: { unlockedBy, reason },
+ });
+ }
+}
diff --git a/backend/services/mining-admin-service/src/api/api.module.ts b/backend/services/mining-admin-service/src/api/api.module.ts
index bbe7caa2..3e8137b2 100644
--- a/backend/services/mining-admin-service/src/api/api.module.ts
+++ b/backend/services/mining-admin-service/src/api/api.module.ts
@@ -18,6 +18,8 @@ import { MobileVersionController } from './controllers/mobile-version.controller
import { PoolAccountController } from './controllers/pool-account.controller';
import { CapabilityController } from './controllers/capability.controller';
import { AdminNotificationController, MobileNotificationController } from './controllers/notification.controller';
+// [2026-03-04] 新增:预种卖出限制管理接口
+import { PrePlantingRestrictionController } from './controllers/pre-planting-restriction.controller';
@Module({
imports: [
@@ -47,6 +49,7 @@ import { AdminNotificationController, MobileNotificationController } from './con
CapabilityController,
AdminNotificationController,
MobileNotificationController,
+ PrePlantingRestrictionController, // 预种卖出限制管理
],
})
export class ApiModule {}
diff --git a/backend/services/mining-admin-service/src/api/controllers/pre-planting-restriction.controller.ts b/backend/services/mining-admin-service/src/api/controllers/pre-planting-restriction.controller.ts
new file mode 100644
index 00000000..6aab835e
--- /dev/null
+++ b/backend/services/mining-admin-service/src/api/controllers/pre-planting-restriction.controller.ts
@@ -0,0 +1,46 @@
+import { Controller, Get, Post, Param, Body, Req } from '@nestjs/common';
+import { ApiTags, ApiOperation, ApiBearerAuth, ApiParam, ApiBody } from '@nestjs/swagger';
+import { IsString, IsOptional } from 'class-validator';
+import { PrePlantingRestrictionService } from '../../application/services/pre-planting-restriction.service';
+
+class UnlockRestrictionDto {
+ @IsString()
+ @IsOptional()
+ reason?: string;
+}
+
+/**
+ * 预种卖出限制管理接口(管理员端)
+ *
+ * [2026-03-04] 新增:管理员查询/解除用户的预种卖出限制。
+ *
+ * GET /pre-planting-restriction/:accountSequence → 查询限制状态
+ * POST /pre-planting-restriction/:accountSequence/unlock → 解除限制(严格审计)
+ */
+@ApiTags('Pre-planting Restriction')
+@ApiBearerAuth()
+@Controller('pre-planting-restriction')
+export class PrePlantingRestrictionController {
+ constructor(private readonly restrictionService: PrePlantingRestrictionService) {}
+
+ @Get(':accountSequence')
+ @ApiOperation({ summary: '查询用户预种卖出限制状态' })
+ @ApiParam({ name: 'accountSequence', type: String, description: '账户序列' })
+ async getRestrictionStatus(@Param('accountSequence') accountSequence: string) {
+ return this.restrictionService.getRestrictionStatus(accountSequence);
+ }
+
+ @Post(':accountSequence/unlock')
+ @ApiOperation({ summary: '管理员手动解除预种卖出限制(操作记录审计日志)' })
+ @ApiParam({ name: 'accountSequence', type: String, description: '账户序列' })
+ @ApiBody({ type: UnlockRestrictionDto, required: false })
+ async unlockRestriction(
+ @Param('accountSequence') accountSequence: string,
+ @Body() body: UnlockRestrictionDto,
+ @Req() req: any,
+ ) {
+ const adminId = req.admin?.id || req.admin?.username || 'ADMIN_MANUAL';
+ await this.restrictionService.unlockRestriction(adminId, accountSequence, body?.reason);
+ return { success: true };
+ }
+}
diff --git a/backend/services/mining-admin-service/src/application/application.module.ts b/backend/services/mining-admin-service/src/application/application.module.ts
index c92d3e14..81a45eaf 100644
--- a/backend/services/mining-admin-service/src/application/application.module.ts
+++ b/backend/services/mining-admin-service/src/application/application.module.ts
@@ -12,6 +12,8 @@ import { BatchMiningService } from './services/batch-mining.service';
import { VersionService } from './services/version.service';
import { CapabilityAdminService } from './services/capability-admin.service';
import { NotificationService } from './services/notification.service';
+// [2026-03-04] 新增:预种卖出限制管理(代理接口 + 审计日志)
+import { PrePlantingRestrictionService } from './services/pre-planting-restriction.service';
@Module({
imports: [InfrastructureModule],
@@ -28,6 +30,7 @@ import { NotificationService } from './services/notification.service';
VersionService,
CapabilityAdminService,
NotificationService,
+ PrePlantingRestrictionService, // 预种卖出限制管理
],
exports: [
AuthService,
@@ -42,6 +45,7 @@ import { NotificationService } from './services/notification.service';
VersionService,
CapabilityAdminService,
NotificationService,
+ PrePlantingRestrictionService,
],
})
export class ApplicationModule implements OnModuleInit {
diff --git a/backend/services/mining-admin-service/src/application/services/pre-planting-restriction.service.ts b/backend/services/mining-admin-service/src/application/services/pre-planting-restriction.service.ts
new file mode 100644
index 00000000..d9f55e66
--- /dev/null
+++ b/backend/services/mining-admin-service/src/application/services/pre-planting-restriction.service.ts
@@ -0,0 +1,88 @@
+import { Injectable, Logger } from '@nestjs/common';
+import { ConfigService } from '@nestjs/config';
+import { PrismaService } from '../../infrastructure/persistence/prisma/prisma.service';
+
+/**
+ * 预种卖出限制管理服务(mining-admin-service 侧)
+ *
+ * [2026-03-04] 新增:管理员手动解除用户的预种卖出限制,并记录审计日志。
+ *
+ * === 调用链 ===
+ * mining-admin-web → mining-admin-service (本服务) → contribution-service
+ *
+ * === 审计记录 ===
+ * 每次解除操作均写入 AuditLog,保证可追溯性。
+ */
+@Injectable()
+export class PrePlantingRestrictionService {
+ private readonly logger = new Logger(PrePlantingRestrictionService.name);
+ private readonly contributionServiceUrl: string;
+
+ constructor(
+ private readonly prisma: PrismaService,
+ private readonly configService: ConfigService,
+ ) {
+ this.contributionServiceUrl = this.configService.get(
+ 'CONTRIBUTION_SERVICE_URL',
+ 'http://localhost:3020',
+ );
+ }
+
+ /**
+ * 查询账户的预种卖出限制状态
+ */
+ async getRestrictionStatus(accountSequence: string): Promise<{ isRestricted: boolean }> {
+ const url = `${this.contributionServiceUrl}/api/v2/pre-planting/sell-restriction/${accountSequence}`;
+ const response = await fetch(url, {
+ method: 'GET',
+ headers: { 'Content-Type': 'application/json' },
+ signal: AbortSignal.timeout(5000),
+ });
+
+ if (!response.ok) {
+ throw new Error(`contribution-service returned ${response.status}`);
+ }
+
+ const data = (await response.json()) as { data?: { isRestricted: boolean }; isRestricted?: boolean };
+ const isRestricted = data?.data?.isRestricted ?? data?.isRestricted ?? false;
+ return { isRestricted };
+ }
+
+ /**
+ * 管理员手动解除预种卖出限制
+ *
+ * 调用 contribution-service 写入 override 记录,并在本地写入审计日志。
+ *
+ * @param adminId 执行操作的管理员 ID(来自 JWT)
+ * @param accountSequence 被解除限制的用户账户序列
+ * @param reason 解除原因(可选)
+ */
+ async unlockRestriction(adminId: string, accountSequence: string, reason?: string): Promise {
+ // 1. 调用 contribution-service 写入 override
+ const url = `${this.contributionServiceUrl}/api/v2/pre-planting/sell-restriction/${accountSequence}/unlock`;
+ const response = await fetch(url, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ unlockedBy: adminId, reason }),
+ signal: AbortSignal.timeout(5000),
+ });
+
+ if (!response.ok) {
+ const text = await response.text().catch(() => '');
+ throw new Error(`contribution-service unlock failed (${response.status}): ${text}`);
+ }
+
+ this.logger.log(`Admin ${adminId} unlocked pre-planting sell restriction for ${accountSequence}`);
+
+ // 2. 写入审计日志(严格审计:每次操作必须记录)
+ await this.prisma.auditLog.create({
+ data: {
+ adminId,
+ action: 'UNLOCK',
+ resource: 'PRE_PLANTING_SELL_RESTRICTION',
+ resourceId: accountSequence,
+ newValue: { reason: reason ?? null, unlockedBy: adminId },
+ },
+ });
+ }
+}
diff --git a/backend/services/trading-service/src/application/application.module.ts b/backend/services/trading-service/src/application/application.module.ts
index e9254f73..f6054030 100644
--- a/backend/services/trading-service/src/application/application.module.ts
+++ b/backend/services/trading-service/src/application/application.module.ts
@@ -3,6 +3,8 @@ import { ScheduleModule } from '@nestjs/schedule';
import { InfrastructureModule } from '../infrastructure/infrastructure.module';
import { ApiModule } from '../api/api.module';
import { OrderService } from './services/order.service';
+// [2026-03-04] 新增:预种卖出限制检查(HTTP + Redis 缓存,fail-open)
+import { TradingSellRestrictionService } from './services/sell-restriction.service';
import { TransferService } from './services/transfer.service';
import { P2pTransferService } from './services/p2p-transfer.service';
import { PriceService } from './services/price.service';
@@ -30,6 +32,7 @@ import { C2cBotScheduler } from './schedulers/c2c-bot.scheduler';
PriceService,
BurnService,
AssetService,
+ TradingSellRestrictionService, // 预种卖出限制(HTTP + Redis,fail-open)
OrderService,
TransferService,
P2pTransferService,
diff --git a/backend/services/trading-service/src/application/services/order.service.ts b/backend/services/trading-service/src/application/services/order.service.ts
index 4fac6d9b..1451ec0c 100644
--- a/backend/services/trading-service/src/application/services/order.service.ts
+++ b/backend/services/trading-service/src/application/services/order.service.ts
@@ -11,6 +11,8 @@ import { MatchingEngineService } from '../../domain/services/matching-engine.ser
import { Money } from '../../domain/value-objects/money.vo';
import { BurnService } from './burn.service';
import { PriceService } from './price.service';
+// [2026-03-04] 新增:预种卖出限制检查
+import { TradingSellRestrictionService } from './sell-restriction.service';
import {
TradingEventTypes,
TradingTopics,
@@ -36,6 +38,7 @@ export class OrderService {
private readonly redis: RedisService,
private readonly burnService: BurnService,
private readonly priceService: PriceService,
+ private readonly sellRestrictionService: TradingSellRestrictionService,
) {}
async createOrder(
@@ -62,6 +65,14 @@ export class OrderService {
const quantityAmount = new Money(quantity);
const totalCost = quantityAmount.multiply(priceAmount.value);
+ // [2026-03-04] 预种卖出限制检查(fail-open:contribution-service 不可用时允许卖出)
+ if (type === OrderType.SELL) {
+ const restricted = await this.sellRestrictionService.isRestricted(accountSequence);
+ if (restricted) {
+ throw new Error('预种积分股暂时不可卖出,请先完成预种合并(满5份合成1棵树后即可卖出)');
+ }
+ }
+
// 检查余额并冻结
if (type === OrderType.BUY) {
if (account.availableCash.isLessThan(totalCost)) {
diff --git a/backend/services/trading-service/src/application/services/sell-restriction.service.ts b/backend/services/trading-service/src/application/services/sell-restriction.service.ts
new file mode 100644
index 00000000..3bf4f3da
--- /dev/null
+++ b/backend/services/trading-service/src/application/services/sell-restriction.service.ts
@@ -0,0 +1,86 @@
+import { Injectable, Logger } from '@nestjs/common';
+import { ConfigService } from '@nestjs/config';
+import { RedisService } from '../../infrastructure/redis/redis.service';
+
+/**
+ * 预种卖出限制检查服务(trading-service 侧)
+ *
+ * [2026-03-04] 新增:在卖单创建前调用 contribution-service 检查该账户是否受预种卖出限制。
+ *
+ * === 设计原则 ===
+ * - Fail-open:contribution-service 不可用时允许卖出,保障业务连续性
+ * - Redis 缓存:TTL 60 秒,减少对 contribution-service 的 HTTP 调用频率
+ * - 无状态:不修改任何本地表,只做只读检查
+ *
+ * === 缓存 key 格式 ===
+ * `sell_restriction:{accountSequence}` → '1'(受限)或 '0'(不受限)
+ */
+@Injectable()
+export class TradingSellRestrictionService {
+ private readonly logger = new Logger(TradingSellRestrictionService.name);
+ private readonly contributionServiceUrl: string;
+ private static readonly CACHE_TTL = 60; // 秒
+ private static readonly CACHE_KEY_PREFIX = 'sell_restriction:';
+
+ constructor(
+ private readonly configService: ConfigService,
+ private readonly redis: RedisService,
+ ) {
+ this.contributionServiceUrl = this.configService.get(
+ 'CONTRIBUTION_SERVICE_URL',
+ 'http://localhost:3020',
+ );
+ }
+
+ /**
+ * 检查账户是否受预种卖出限制
+ *
+ * @returns true = 受限(禁止卖出);false = 不受限(允许卖出)
+ */
+ async isRestricted(accountSequence: string): Promise {
+ const cacheKey = `${TradingSellRestrictionService.CACHE_KEY_PREFIX}${accountSequence}`;
+
+ try {
+ // 1. 先查 Redis 缓存
+ const cached = await this.redis.get(cacheKey);
+ if (cached !== null) {
+ return cached === '1';
+ }
+
+ // 2. 调用 contribution-service
+ const url = `${this.contributionServiceUrl}/api/v2/pre-planting/sell-restriction/${accountSequence}`;
+ const response = await fetch(url, {
+ method: 'GET',
+ headers: { 'Content-Type': 'application/json' },
+ signal: AbortSignal.timeout(3000), // 3 秒超时
+ });
+
+ if (!response.ok) {
+ this.logger.warn(`contribution-service returned ${response.status} for ${accountSequence}, fail-open`);
+ return false; // fail-open
+ }
+
+ const data = (await response.json()) as { data?: { isRestricted: boolean }; isRestricted?: boolean };
+ // 兼容 TransformInterceptor 包装格式 { data: { isRestricted } } 和直接格式 { isRestricted }
+ const isRestricted = data?.data?.isRestricted ?? data?.isRestricted ?? false;
+
+ // 3. 写入 Redis 缓存
+ await this.redis.set(cacheKey, isRestricted ? '1' : '0', TradingSellRestrictionService.CACHE_TTL);
+
+ return isRestricted;
+ } catch (error) {
+ // Fail-open:任何异常(网络、超时等)都允许卖出
+ this.logger.warn(`sell-restriction check failed for ${accountSequence}, fail-open: ${error}`);
+ return false;
+ }
+ }
+
+ /**
+ * 主动清除某账户的卖出限制缓存
+ * (合并完成后可调用此方法,使限制状态立即失效)
+ */
+ async invalidateCache(accountSequence: string): Promise {
+ const cacheKey = `${TradingSellRestrictionService.CACHE_KEY_PREFIX}${accountSequence}`;
+ await this.redis.set(cacheKey, '0', 1); // TTL=1秒,相当于立即失效
+ }
+}
diff --git a/frontend/mining-admin-web/src/app/(dashboard)/users/[accountSequence]/page.tsx b/frontend/mining-admin-web/src/app/(dashboard)/users/[accountSequence]/page.tsx
index e6d9538e..71822714 100644
--- a/frontend/mining-admin-web/src/app/(dashboard)/users/[accountSequence]/page.tsx
+++ b/frontend/mining-admin-web/src/app/(dashboard)/users/[accountSequence]/page.tsx
@@ -3,7 +3,7 @@
import { useParams } from 'next/navigation';
import Link from 'next/link';
import { PageHeader } from '@/components/layout/page-header';
-import { useUserDetail } from '@/features/users/hooks/use-users';
+import { useUserDetail, usePrePlantingRestriction, useUnlockPrePlantingRestriction } from '@/features/users/hooks/use-users';
import { formatDecimal, formatNumber } from '@/lib/utils/format';
import { formatDateTime } from '@/lib/utils/date';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
@@ -11,6 +11,7 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { Skeleton } from '@/components/ui/skeleton';
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
import { Badge } from '@/components/ui/badge';
+import { Button } from '@/components/ui/button';
import { ContributionRecordsList } from '@/features/users/components/contribution-records-list';
import { MiningRecordsList } from '@/features/users/components/mining-records-list';
import { TradeOrdersList } from '@/features/users/components/trade-orders-list';
@@ -19,7 +20,7 @@ import { PlantingLedger } from '@/features/users/components/planting-ledger';
import { WalletLedger } from '@/features/users/components/wallet-ledger';
import { BatchMiningRecordsList } from '@/features/users/components/batch-mining-records-list';
import { CapabilityManagement } from '@/features/users/components/capability-management';
-import { Users, TreePine, Wallet, Zap, ShoppingCart, Network, Coins, Gift, Shield } from 'lucide-react';
+import { Users, TreePine, Wallet, Zap, ShoppingCart, Network, Coins, Gift, Shield, Lock, Unlock } from 'lucide-react';
function UserDetailSkeleton() {
return (
@@ -44,6 +45,18 @@ export default function UserDetailPage() {
const params = useParams();
const accountSequence = params.accountSequence as string;
const { data: user, isLoading } = useUserDetail(accountSequence);
+ const { data: restrictionData } = usePrePlantingRestriction(accountSequence);
+ const unlockMutation = useUnlockPrePlantingRestriction(accountSequence);
+
+ const handleUnlockRestriction = async () => {
+ if (!confirm(`确认解除用户 ${accountSequence} 的预种卖出限制?\n解除后该用户可立即卖出积分股,此操作将记录审计日志。`)) return;
+ try {
+ await unlockMutation.mutateAsync('管理员手动解除');
+ alert('已成功解除预种卖出限制');
+ } catch {
+ alert('解除失败,请重试');
+ }
+ };
if (isLoading) {
return (
@@ -180,6 +193,30 @@ export default function UserDetailPage() {
+ {/* 预种卖出限制(仅受限时显示) */}
+ {restrictionData?.isRestricted && (
+
+
+
+
+
+
预种卖出限制中
+
该用户仅有预种份数,尚未合并成棵,无法卖出积分股
+
+
+
+
+
+ )}
diff --git a/frontend/mining-admin-web/src/features/users/api/users.api.ts b/frontend/mining-admin-web/src/features/users/api/users.api.ts
index fdc8fc24..3e4e603b 100644
--- a/frontend/mining-admin-web/src/features/users/api/users.api.ts
+++ b/frontend/mining-admin-web/src/features/users/api/users.api.ts
@@ -249,6 +249,18 @@ export const usersApi = {
pageSize: result.pageSize || 20,
};
},
+
+ // ========== 预种卖出限制 API ==========
+
+ getPrePlantingRestriction: async (accountSequence: string): Promise<{ isRestricted: boolean }> => {
+ const response = await apiClient.get(`/pre-planting-restriction/${accountSequence}`);
+ const data = response.data.data;
+ return { isRestricted: data?.isRestricted ?? false };
+ },
+
+ unlockPrePlantingRestriction: async (accountSequence: string, reason?: string): Promise => {
+ await apiClient.post(`/pre-planting-restriction/${accountSequence}/unlock`, { reason });
+ },
};
// Capability 类型
diff --git a/frontend/mining-admin-web/src/features/users/hooks/use-users.ts b/frontend/mining-admin-web/src/features/users/hooks/use-users.ts
index d0a09ef7..ad3a5518 100644
--- a/frontend/mining-admin-web/src/features/users/hooks/use-users.ts
+++ b/frontend/mining-admin-web/src/features/users/hooks/use-users.ts
@@ -114,3 +114,23 @@ export function useCapabilityLogs(accountSequence: string, params: PaginationPar
enabled: !!accountSequence,
});
}
+
+// ========== 预种卖出限制 Hooks ==========
+
+export function usePrePlantingRestriction(accountSequence: string) {
+ return useQuery({
+ queryKey: ['users', accountSequence, 'pre-planting-restriction'],
+ queryFn: () => usersApi.getPrePlantingRestriction(accountSequence),
+ enabled: !!accountSequence,
+ });
+}
+
+export function useUnlockPrePlantingRestriction(accountSequence: string) {
+ const queryClient = useQueryClient();
+ return useMutation({
+ mutationFn: (reason?: string) => usersApi.unlockPrePlantingRestriction(accountSequence, reason),
+ onSuccess: () => {
+ queryClient.invalidateQueries({ queryKey: ['users', accountSequence, 'pre-planting-restriction'] });
+ },
+ });
+}