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'] }); + }, + }); +}