diff --git a/backend/services/trading-service/src/api/controllers/asset.controller.ts b/backend/services/trading-service/src/api/controllers/asset.controller.ts index 596c5e5d..13e59d74 100644 --- a/backend/services/trading-service/src/api/controllers/asset.controller.ts +++ b/backend/services/trading-service/src/api/controllers/asset.controller.ts @@ -1,6 +1,7 @@ import { Controller, Get, Param, Query, Req } from '@nestjs/common'; import { ApiTags, ApiOperation, ApiParam, ApiQuery, ApiBearerAuth } from '@nestjs/swagger'; import { AssetService } from '../../application/services/asset.service'; +import { TradingSellRestrictionService } from '../../application/services/sell-restriction.service'; import { RequireCapability } from '../../shared/decorators/require-capability.decorator'; import { Public } from '../../shared/guards/jwt-auth.guard'; @@ -8,7 +9,10 @@ import { Public } from '../../shared/guards/jwt-auth.guard'; @ApiBearerAuth() @Controller('asset') export class AssetController { - constructor(private readonly assetService: AssetService) {} + constructor( + private readonly assetService: AssetService, + private readonly sellRestrictionService: TradingSellRestrictionService, + ) {} @Get('my') @RequireCapability('VIEW_ASSET') @@ -67,4 +71,14 @@ export class AssetController { const perSecond = this.assetService.calculateAssetGrowthPerSecond(dailyAllocation); return { dailyAllocation, assetGrowthPerSecond: perSecond }; } + + // [2026-03-04] 预种卖出限制状态查询(供 mobile app 前端判断是否禁用卖出按钮) + @Get('sell-restriction') + @ApiOperation({ summary: '查询当前用户预种卖出限制状态' }) + async getSellRestriction(@Req() req: any) { + const accountSequence = req.user?.accountSequence; + if (!accountSequence) throw new Error('Unauthorized'); + const isRestricted = await this.sellRestrictionService.isRestricted(accountSequence); + return { isRestricted }; + } } diff --git a/backend/services/trading-service/src/application/application.module.ts b/backend/services/trading-service/src/application/application.module.ts index f6054030..0c0f9649 100644 --- a/backend/services/trading-service/src/application/application.module.ts +++ b/backend/services/trading-service/src/application/application.module.ts @@ -48,6 +48,6 @@ import { C2cBotScheduler } from './schedulers/c2c-bot.scheduler'; C2cExpiryScheduler, C2cBotScheduler, ], - exports: [OrderService, TransferService, P2pTransferService, PriceService, BurnService, AssetService, MarketMakerService, C2cService, C2cBotService, PaymentProofService, AuditLedgerService], + exports: [OrderService, TransferService, P2pTransferService, PriceService, BurnService, AssetService, TradingSellRestrictionService, MarketMakerService, C2cService, C2cBotService, PaymentProofService, AuditLedgerService], }) export class ApplicationModule {} diff --git a/frontend/mining-app/lib/core/network/api_endpoints.dart b/frontend/mining-app/lib/core/network/api_endpoints.dart index 42cb6119..9ddc252d 100644 --- a/frontend/mining-app/lib/core/network/api_endpoints.dart +++ b/frontend/mining-app/lib/core/network/api_endpoints.dart @@ -59,6 +59,7 @@ class ApiEndpoints { '/api/v2/trading/asset/account/$accountSequence'; static const String estimateSell = '/api/v2/trading/asset/estimate-sell'; static const String marketOverview = '/api/v2/trading/asset/market'; + static const String sellRestriction = '/api/v2/trading/asset/sell-restriction'; // Transfer endpoints (内部划转) static const String transferIn = '/api/v2/trading/transfers/in'; diff --git a/frontend/mining-app/lib/data/datasources/remote/trading_remote_datasource.dart b/frontend/mining-app/lib/data/datasources/remote/trading_remote_datasource.dart index 2f6d07f3..f55036e6 100644 --- a/frontend/mining-app/lib/data/datasources/remote/trading_remote_datasource.dart +++ b/frontend/mining-app/lib/data/datasources/remote/trading_remote_datasource.dart @@ -141,6 +141,9 @@ abstract class TradingRemoteDataSource { /// 上传付款水单(买方操作) Future uploadC2cPaymentProof(String orderNo, String filePath); + + /// 查询当前用户预种卖出限制状态(fail-open:接口异常时返回 false 允许卖出) + Future getSellRestriction(); } class TradingRemoteDataSourceImpl implements TradingRemoteDataSource { @@ -601,4 +604,15 @@ class TradingRemoteDataSourceImpl implements TradingRemoteDataSource { throw ServerException(e.toString()); } } + + @override + Future getSellRestriction() async { + try { + final response = await client.get(ApiEndpoints.sellRestriction); + return response.data['isRestricted'] == true; + } catch (e) { + // fail-open:接口异常时允许卖出,避免误封正常用户 + return false; + } + } } diff --git a/frontend/mining-app/lib/data/repositories/trading_repository_impl.dart b/frontend/mining-app/lib/data/repositories/trading_repository_impl.dart index af8a06f2..f5d746a2 100644 --- a/frontend/mining-app/lib/data/repositories/trading_repository_impl.dart +++ b/frontend/mining-app/lib/data/repositories/trading_repository_impl.dart @@ -29,6 +29,16 @@ class TradingRepositoryImpl implements TradingRepository { } } + @override + Future> getSellRestriction() async { + try { + final result = await remoteDataSource.getSellRestriction(); + return Right(result); + } catch (e) { + return const Right(false); // fail-open + } + } + @override Future> getCurrentPrice() async { try { diff --git a/frontend/mining-app/lib/domain/repositories/trading_repository.dart b/frontend/mining-app/lib/domain/repositories/trading_repository.dart index 649d3a2d..0dfaa738 100644 --- a/frontend/mining-app/lib/domain/repositories/trading_repository.dart +++ b/frontend/mining-app/lib/domain/repositories/trading_repository.dart @@ -11,6 +11,9 @@ abstract class TradingRepository { /// 获取买入功能开关状态 Future> getBuyEnabled(); + /// 查询当前用户预种卖出限制状态(fail-open:异常时返回 false) + Future> getSellRestriction(); + /// 获取当前价格信息 Future> getCurrentPrice(); diff --git a/frontend/mining-app/lib/presentation/pages/c2c/c2c_publish_page.dart b/frontend/mining-app/lib/presentation/pages/c2c/c2c_publish_page.dart index deeff72d..2a64afce 100644 --- a/frontend/mining-app/lib/presentation/pages/c2c/c2c_publish_page.dart +++ b/frontend/mining-app/lib/presentation/pages/c2c/c2c_publish_page.dart @@ -711,9 +711,10 @@ class _C2cPublishPageState extends ConsumerState { final isSell = _selectedType == 1; final capabilities = ref.watch(capabilitiesProvider).valueOrNull ?? CapabilityMap.defaultAll(); final c2cEnabled = capabilities.c2cEnabled; + final isSellRestricted = isSell && (ref.watch(sellRestrictionProvider).valueOrNull ?? false); // #11: 价格固定为1,数量最小为1 - bool isValid = c2cEnabled && quantity >= 1; + bool isValid = c2cEnabled && !isSellRestricted && quantity >= 1; if (isSell) { // 如果选择了其他支付方式,还需要填写收款账号和姓名 if (_selectedPaymentMethods.any((m) => m != 'GREEN_POINTS')) { @@ -725,17 +726,39 @@ class _C2cPublishPageState extends ConsumerState { return Padding( padding: const EdgeInsets.symmetric(horizontal: 16), - child: SizedBox( - width: double.infinity, - height: 50, - child: ElevatedButton( - onPressed: isValid && !c2cState.isLoading - ? _handlePublish - : !c2cEnabled - ? () => ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('您的C2C交易功能已被限制'), backgroundColor: _red), - ) - : null, + child: Column( + children: [ + if (isSellRestricted) + Container( + width: double.infinity, + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + margin: const EdgeInsets.only(bottom: 8), + decoration: BoxDecoration( + color: _red.withOpacity(0.08), + borderRadius: BorderRadius.circular(8), + border: Border.all(color: _red.withOpacity(0.3)), + ), + child: const Text( + '预种积分股暂时不可卖出,请先完成预种合并(满5份合成1棵树后即可卖出)', + style: TextStyle(fontSize: 12, color: _red), + textAlign: TextAlign.center, + ), + ), + SizedBox( + width: double.infinity, + height: 50, + child: ElevatedButton( + onPressed: isValid && !c2cState.isLoading + ? _handlePublish + : isSellRestricted + ? () => ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('预种积分股暂时不可卖出,请先完成预种合并'), backgroundColor: _red), + ) + : !c2cEnabled + ? () => ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('您的C2C交易功能已被限制'), backgroundColor: _red), + ) + : null, style: ElevatedButton.styleFrom( backgroundColor: _selectedType == 0 ? _green : _red, foregroundColor: Colors.white, @@ -761,7 +784,9 @@ class _C2cPublishPageState extends ConsumerState { fontWeight: FontWeight.bold, ), ), - ), + ), + ), + ], ), ); } diff --git a/frontend/mining-app/lib/presentation/pages/trading/trading_page.dart b/frontend/mining-app/lib/presentation/pages/trading/trading_page.dart index 00f1cd44..968c421c 100644 --- a/frontend/mining-app/lib/presentation/pages/trading/trading_page.dart +++ b/frontend/mining-app/lib/presentation/pages/trading/trading_page.dart @@ -813,20 +813,44 @@ class _TradingPageState extends ConsumerState { ), ), const SizedBox(height: 24), - // 提交按钮(卖出时校验积分股余额 + 能力检查) + // 提交按钮(卖出时校验积分股余额 + 能力检查 + 预种限制检查) Builder(builder: (context) { final sellError = _selectedTab == 1 ? _getSellValidationError(tradingShareBalance) : null; final capabilities = ref.watch(capabilitiesProvider).valueOrNull ?? CapabilityMap.defaultAll(); final tradingEnabled = capabilities.tradingEnabled; - final isDisabled = sellError != null || !tradingEnabled; - return SizedBox( + final isSellRestricted = _selectedTab == 1 && (ref.watch(sellRestrictionProvider).valueOrNull ?? false); + final isDisabled = sellError != null || !tradingEnabled || isSellRestricted; + return Column( + children: [ + if (isSellRestricted) + Container( + width: double.infinity, + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + margin: const EdgeInsets.only(bottom: 8), + decoration: BoxDecoration( + color: AppColors.down.withOpacity(0.08), + borderRadius: BorderRadius.circular(8), + border: Border.all(color: AppColors.down.withOpacity(0.3)), + ), + child: const Text( + '预种积分股暂时不可卖出,请先完成预种合并(满5份合成1棵树后即可卖出)', + style: TextStyle(fontSize: 12, color: AppColors.down), + textAlign: TextAlign.center, + ), + ), + SizedBox( width: double.infinity, height: 48, child: ElevatedButton( onPressed: isDisabled - ? !tradingEnabled + ? isSellRestricted || !tradingEnabled ? () => ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('您的交易功能已被限制'), backgroundColor: AppColors.down), + SnackBar( + content: Text(isSellRestricted + ? '预种积分股暂时不可卖出,请先完成预种合并' + : '您的交易功能已被限制'), + backgroundColor: AppColors.down, + ), ) : null : _handleTrade, @@ -845,7 +869,9 @@ class _TradingPageState extends ConsumerState { ), ), ), - ); + ), + ], + ); }), ], ], diff --git a/frontend/mining-app/lib/presentation/providers/trading_providers.dart b/frontend/mining-app/lib/presentation/providers/trading_providers.dart index a76c75d2..ea6991e7 100644 --- a/frontend/mining-app/lib/presentation/providers/trading_providers.dart +++ b/frontend/mining-app/lib/presentation/providers/trading_providers.dart @@ -31,6 +31,23 @@ final buyEnabledProvider = FutureProvider((ref) async { ); }); +// 预种卖出限制状态 Provider (2分钟缓存,fail-open) +final sellRestrictionProvider = FutureProvider((ref) async { + final repository = ref.watch(tradingRepositoryProvider); + final result = await repository.getSellRestriction(); + + ref.keepAlive(); + final timer = Timer(const Duration(minutes: 2), () { + ref.invalidateSelf(); + }); + ref.onDispose(() => timer.cancel()); + + return result.fold( + (failure) => false, // 接口异常时 fail-open,允许卖出 + (restricted) => restricted, + ); +}); + // K线周期选择 final selectedKlinePeriodProvider = StateProvider((ref) => '1h');