feat(pre-planting): 新增预种积分股卖出限制(方案B纯新增)
限制仅有预种份数(未合并成棵)的用户卖出积分股, 直到用户完成首次预种合并后方可卖出。 === 改动范围(全部 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 <noreply@anthropic.com>
This commit is contained in:
parent
8fcfec9b65
commit
ac3adfc90a
|
|
@ -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")
|
||||||
|
);
|
||||||
|
|
@ -160,3 +160,20 @@ model PrePlantingProcessedCdcEvent {
|
||||||
@@index([processedAt])
|
@@index([processedAt])
|
||||||
@@map("pre_planting_processed_cdc_events")
|
@@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")
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -5,9 +5,26 @@ import { ContributionController } from './controllers/contribution.controller';
|
||||||
import { SnapshotController } from './controllers/snapshot.controller';
|
import { SnapshotController } from './controllers/snapshot.controller';
|
||||||
import { HealthController } from './controllers/health.controller';
|
import { HealthController } from './controllers/health.controller';
|
||||||
import { AdminController } from './controllers/admin.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({
|
@Module({
|
||||||
imports: [ApplicationModule, InfrastructureModule],
|
imports: [
|
||||||
controllers: [ContributionController, SnapshotController, HealthController, AdminController],
|
ApplicationModule,
|
||||||
|
InfrastructureModule,
|
||||||
|
PrePlantingPrismaModule, // 提供 PrePlantingPrismaService(用于 override 表)
|
||||||
|
],
|
||||||
|
controllers: [
|
||||||
|
ContributionController,
|
||||||
|
SnapshotController,
|
||||||
|
HealthController,
|
||||||
|
AdminController,
|
||||||
|
PrePlantingRestrictionController, // 预种卖出限制接口
|
||||||
|
],
|
||||||
|
providers: [
|
||||||
|
SellRestrictionService, // 预种卖出限制判断逻辑
|
||||||
|
],
|
||||||
})
|
})
|
||||||
export class ApiModule {}
|
export class ApiModule {}
|
||||||
|
|
|
||||||
|
|
@ -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 };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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<boolean> {
|
||||||
|
// 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<void> {
|
||||||
|
await this.prePlantingPrisma.prePlantingSellRestrictionOverride.upsert({
|
||||||
|
where: { accountSequence },
|
||||||
|
create: { accountSequence, unlockedBy, reason },
|
||||||
|
update: { unlockedBy, reason },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -18,6 +18,8 @@ import { MobileVersionController } from './controllers/mobile-version.controller
|
||||||
import { PoolAccountController } from './controllers/pool-account.controller';
|
import { PoolAccountController } from './controllers/pool-account.controller';
|
||||||
import { CapabilityController } from './controllers/capability.controller';
|
import { CapabilityController } from './controllers/capability.controller';
|
||||||
import { AdminNotificationController, MobileNotificationController } from './controllers/notification.controller';
|
import { AdminNotificationController, MobileNotificationController } from './controllers/notification.controller';
|
||||||
|
// [2026-03-04] 新增:预种卖出限制管理接口
|
||||||
|
import { PrePlantingRestrictionController } from './controllers/pre-planting-restriction.controller';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
|
|
@ -47,6 +49,7 @@ import { AdminNotificationController, MobileNotificationController } from './con
|
||||||
CapabilityController,
|
CapabilityController,
|
||||||
AdminNotificationController,
|
AdminNotificationController,
|
||||||
MobileNotificationController,
|
MobileNotificationController,
|
||||||
|
PrePlantingRestrictionController, // 预种卖出限制管理
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
export class ApiModule {}
|
export class ApiModule {}
|
||||||
|
|
|
||||||
|
|
@ -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 };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -12,6 +12,8 @@ import { BatchMiningService } from './services/batch-mining.service';
|
||||||
import { VersionService } from './services/version.service';
|
import { VersionService } from './services/version.service';
|
||||||
import { CapabilityAdminService } from './services/capability-admin.service';
|
import { CapabilityAdminService } from './services/capability-admin.service';
|
||||||
import { NotificationService } from './services/notification.service';
|
import { NotificationService } from './services/notification.service';
|
||||||
|
// [2026-03-04] 新增:预种卖出限制管理(代理接口 + 审计日志)
|
||||||
|
import { PrePlantingRestrictionService } from './services/pre-planting-restriction.service';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [InfrastructureModule],
|
imports: [InfrastructureModule],
|
||||||
|
|
@ -28,6 +30,7 @@ import { NotificationService } from './services/notification.service';
|
||||||
VersionService,
|
VersionService,
|
||||||
CapabilityAdminService,
|
CapabilityAdminService,
|
||||||
NotificationService,
|
NotificationService,
|
||||||
|
PrePlantingRestrictionService, // 预种卖出限制管理
|
||||||
],
|
],
|
||||||
exports: [
|
exports: [
|
||||||
AuthService,
|
AuthService,
|
||||||
|
|
@ -42,6 +45,7 @@ import { NotificationService } from './services/notification.service';
|
||||||
VersionService,
|
VersionService,
|
||||||
CapabilityAdminService,
|
CapabilityAdminService,
|
||||||
NotificationService,
|
NotificationService,
|
||||||
|
PrePlantingRestrictionService,
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
export class ApplicationModule implements OnModuleInit {
|
export class ApplicationModule implements OnModuleInit {
|
||||||
|
|
|
||||||
|
|
@ -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<string>(
|
||||||
|
'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<void> {
|
||||||
|
// 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 },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -3,6 +3,8 @@ import { ScheduleModule } from '@nestjs/schedule';
|
||||||
import { InfrastructureModule } from '../infrastructure/infrastructure.module';
|
import { InfrastructureModule } from '../infrastructure/infrastructure.module';
|
||||||
import { ApiModule } from '../api/api.module';
|
import { ApiModule } from '../api/api.module';
|
||||||
import { OrderService } from './services/order.service';
|
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 { TransferService } from './services/transfer.service';
|
||||||
import { P2pTransferService } from './services/p2p-transfer.service';
|
import { P2pTransferService } from './services/p2p-transfer.service';
|
||||||
import { PriceService } from './services/price.service';
|
import { PriceService } from './services/price.service';
|
||||||
|
|
@ -30,6 +32,7 @@ import { C2cBotScheduler } from './schedulers/c2c-bot.scheduler';
|
||||||
PriceService,
|
PriceService,
|
||||||
BurnService,
|
BurnService,
|
||||||
AssetService,
|
AssetService,
|
||||||
|
TradingSellRestrictionService, // 预种卖出限制(HTTP + Redis,fail-open)
|
||||||
OrderService,
|
OrderService,
|
||||||
TransferService,
|
TransferService,
|
||||||
P2pTransferService,
|
P2pTransferService,
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,8 @@ import { MatchingEngineService } from '../../domain/services/matching-engine.ser
|
||||||
import { Money } from '../../domain/value-objects/money.vo';
|
import { Money } from '../../domain/value-objects/money.vo';
|
||||||
import { BurnService } from './burn.service';
|
import { BurnService } from './burn.service';
|
||||||
import { PriceService } from './price.service';
|
import { PriceService } from './price.service';
|
||||||
|
// [2026-03-04] 新增:预种卖出限制检查
|
||||||
|
import { TradingSellRestrictionService } from './sell-restriction.service';
|
||||||
import {
|
import {
|
||||||
TradingEventTypes,
|
TradingEventTypes,
|
||||||
TradingTopics,
|
TradingTopics,
|
||||||
|
|
@ -36,6 +38,7 @@ export class OrderService {
|
||||||
private readonly redis: RedisService,
|
private readonly redis: RedisService,
|
||||||
private readonly burnService: BurnService,
|
private readonly burnService: BurnService,
|
||||||
private readonly priceService: PriceService,
|
private readonly priceService: PriceService,
|
||||||
|
private readonly sellRestrictionService: TradingSellRestrictionService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async createOrder(
|
async createOrder(
|
||||||
|
|
@ -62,6 +65,14 @@ export class OrderService {
|
||||||
const quantityAmount = new Money(quantity);
|
const quantityAmount = new Money(quantity);
|
||||||
const totalCost = quantityAmount.multiply(priceAmount.value);
|
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 (type === OrderType.BUY) {
|
||||||
if (account.availableCash.isLessThan(totalCost)) {
|
if (account.availableCash.isLessThan(totalCost)) {
|
||||||
|
|
|
||||||
|
|
@ -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<string>(
|
||||||
|
'CONTRIBUTION_SERVICE_URL',
|
||||||
|
'http://localhost:3020',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检查账户是否受预种卖出限制
|
||||||
|
*
|
||||||
|
* @returns true = 受限(禁止卖出);false = 不受限(允许卖出)
|
||||||
|
*/
|
||||||
|
async isRestricted(accountSequence: string): Promise<boolean> {
|
||||||
|
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<void> {
|
||||||
|
const cacheKey = `${TradingSellRestrictionService.CACHE_KEY_PREFIX}${accountSequence}`;
|
||||||
|
await this.redis.set(cacheKey, '0', 1); // TTL=1秒,相当于立即失效
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -3,7 +3,7 @@
|
||||||
import { useParams } from 'next/navigation';
|
import { useParams } from 'next/navigation';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { PageHeader } from '@/components/layout/page-header';
|
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 { formatDecimal, formatNumber } from '@/lib/utils/format';
|
||||||
import { formatDateTime } from '@/lib/utils/date';
|
import { formatDateTime } from '@/lib/utils/date';
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
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 { Skeleton } from '@/components/ui/skeleton';
|
||||||
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
|
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
import { ContributionRecordsList } from '@/features/users/components/contribution-records-list';
|
import { ContributionRecordsList } from '@/features/users/components/contribution-records-list';
|
||||||
import { MiningRecordsList } from '@/features/users/components/mining-records-list';
|
import { MiningRecordsList } from '@/features/users/components/mining-records-list';
|
||||||
import { TradeOrdersList } from '@/features/users/components/trade-orders-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 { WalletLedger } from '@/features/users/components/wallet-ledger';
|
||||||
import { BatchMiningRecordsList } from '@/features/users/components/batch-mining-records-list';
|
import { BatchMiningRecordsList } from '@/features/users/components/batch-mining-records-list';
|
||||||
import { CapabilityManagement } from '@/features/users/components/capability-management';
|
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() {
|
function UserDetailSkeleton() {
|
||||||
return (
|
return (
|
||||||
|
|
@ -44,6 +45,18 @@ export default function UserDetailPage() {
|
||||||
const params = useParams();
|
const params = useParams();
|
||||||
const accountSequence = params.accountSequence as string;
|
const accountSequence = params.accountSequence as string;
|
||||||
const { data: user, isLoading } = useUserDetail(accountSequence);
|
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) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
|
|
@ -180,6 +193,30 @@ export default function UserDetailPage() {
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{/* 预种卖出限制(仅受限时显示) */}
|
||||||
|
{restrictionData?.isRestricted && (
|
||||||
|
<div className="mt-4 p-3 bg-red-50 dark:bg-red-950 rounded-lg border border-red-200 dark:border-red-800">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Lock className="h-4 w-4 text-red-600" />
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium text-red-700 dark:text-red-300">预种卖出限制中</p>
|
||||||
|
<p className="text-xs text-red-600 dark:text-red-400">该用户仅有预种份数,尚未合并成棵,无法卖出积分股</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={handleUnlockRestriction}
|
||||||
|
disabled={unlockMutation.isPending}
|
||||||
|
className="border-red-300 text-red-700 hover:bg-red-100 dark:border-red-700 dark:text-red-300"
|
||||||
|
>
|
||||||
|
<Unlock className="h-3 w-3 mr-1" />
|
||||||
|
{unlockMutation.isPending ? '解除中...' : '手动解除限制'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -249,6 +249,18 @@ export const usersApi = {
|
||||||
pageSize: result.pageSize || 20,
|
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<void> => {
|
||||||
|
await apiClient.post(`/pre-planting-restriction/${accountSequence}/unlock`, { reason });
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
// Capability 类型
|
// Capability 类型
|
||||||
|
|
|
||||||
|
|
@ -114,3 +114,23 @@ export function useCapabilityLogs(accountSequence: string, params: PaginationPar
|
||||||
enabled: !!accountSequence,
|
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'] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue