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:
hailin 2026-03-04 05:04:57 -08:00
parent 8fcfec9b65
commit ac3adfc90a
15 changed files with 521 additions and 4 deletions

View File

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

View File

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

View File

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

View File

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

View File

@ -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_markersynced_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_overridepre_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 },
});
}
}

View File

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

View File

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

View File

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

View File

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

View File

@ -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 + Redisfail-open
OrderService, OrderService,
TransferService, TransferService,
P2pTransferService, P2pTransferService,

View File

@ -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-opencontribution-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)) {

View File

@ -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-opencontribution-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秒相当于立即失效
}
}

View File

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

View File

@ -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 类型

View File

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