feat(trading+app): 预种卖出限制 — 前端 UI 禁用 + 后端查询端点

trading-service:
- asset.controller.ts: 新增 GET /asset/sell-restriction,供 mobile app 查询当前用户限制状态
- application.module.ts: 导出 TradingSellRestrictionService

mining-app:
- api_endpoints.dart: 新增 sellRestriction 端点常量
- trading_remote_datasource.dart: 新增 getSellRestriction()(fail-open)
- trading_repository.dart/impl: 新增接口与实现
- trading_providers.dart: 新增 sellRestrictionProvider(2分钟缓存,fail-open)
- trading_page.dart: 卖出限制时显示红色提示文字并禁用"确认交易"按钮
- c2c_publish_page.dart: 发布卖出广告时显示红色提示文字并禁用发布按钮

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
hailin 2026-03-04 05:51:56 -08:00
parent ac3adfc90a
commit ee734fb7b9
9 changed files with 131 additions and 21 deletions

View File

@ -1,6 +1,7 @@
import { Controller, Get, Param, Query, Req } from '@nestjs/common'; import { Controller, Get, Param, Query, Req } from '@nestjs/common';
import { ApiTags, ApiOperation, ApiParam, ApiQuery, ApiBearerAuth } from '@nestjs/swagger'; import { ApiTags, ApiOperation, ApiParam, ApiQuery, ApiBearerAuth } from '@nestjs/swagger';
import { AssetService } from '../../application/services/asset.service'; import { AssetService } from '../../application/services/asset.service';
import { TradingSellRestrictionService } from '../../application/services/sell-restriction.service';
import { RequireCapability } from '../../shared/decorators/require-capability.decorator'; import { RequireCapability } from '../../shared/decorators/require-capability.decorator';
import { Public } from '../../shared/guards/jwt-auth.guard'; import { Public } from '../../shared/guards/jwt-auth.guard';
@ -8,7 +9,10 @@ import { Public } from '../../shared/guards/jwt-auth.guard';
@ApiBearerAuth() @ApiBearerAuth()
@Controller('asset') @Controller('asset')
export class AssetController { export class AssetController {
constructor(private readonly assetService: AssetService) {} constructor(
private readonly assetService: AssetService,
private readonly sellRestrictionService: TradingSellRestrictionService,
) {}
@Get('my') @Get('my')
@RequireCapability('VIEW_ASSET') @RequireCapability('VIEW_ASSET')
@ -67,4 +71,14 @@ export class AssetController {
const perSecond = this.assetService.calculateAssetGrowthPerSecond(dailyAllocation); const perSecond = this.assetService.calculateAssetGrowthPerSecond(dailyAllocation);
return { dailyAllocation, assetGrowthPerSecond: perSecond }; 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 };
}
} }

View File

@ -48,6 +48,6 @@ import { C2cBotScheduler } from './schedulers/c2c-bot.scheduler';
C2cExpiryScheduler, C2cExpiryScheduler,
C2cBotScheduler, 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 {} export class ApplicationModule {}

View File

@ -59,6 +59,7 @@ class ApiEndpoints {
'/api/v2/trading/asset/account/$accountSequence'; '/api/v2/trading/asset/account/$accountSequence';
static const String estimateSell = '/api/v2/trading/asset/estimate-sell'; static const String estimateSell = '/api/v2/trading/asset/estimate-sell';
static const String marketOverview = '/api/v2/trading/asset/market'; static const String marketOverview = '/api/v2/trading/asset/market';
static const String sellRestriction = '/api/v2/trading/asset/sell-restriction';
// Transfer endpoints () // Transfer endpoints ()
static const String transferIn = '/api/v2/trading/transfers/in'; static const String transferIn = '/api/v2/trading/transfers/in';

View File

@ -141,6 +141,9 @@ abstract class TradingRemoteDataSource {
/// ///
Future<C2cOrderModel> uploadC2cPaymentProof(String orderNo, String filePath); Future<C2cOrderModel> uploadC2cPaymentProof(String orderNo, String filePath);
/// fail-open false
Future<bool> getSellRestriction();
} }
class TradingRemoteDataSourceImpl implements TradingRemoteDataSource { class TradingRemoteDataSourceImpl implements TradingRemoteDataSource {
@ -601,4 +604,15 @@ class TradingRemoteDataSourceImpl implements TradingRemoteDataSource {
throw ServerException(e.toString()); throw ServerException(e.toString());
} }
} }
@override
Future<bool> getSellRestriction() async {
try {
final response = await client.get(ApiEndpoints.sellRestriction);
return response.data['isRestricted'] == true;
} catch (e) {
// fail-open
return false;
}
}
} }

View File

@ -29,6 +29,16 @@ class TradingRepositoryImpl implements TradingRepository {
} }
} }
@override
Future<Either<Failure, bool>> getSellRestriction() async {
try {
final result = await remoteDataSource.getSellRestriction();
return Right(result);
} catch (e) {
return const Right(false); // fail-open
}
}
@override @override
Future<Either<Failure, PriceInfo>> getCurrentPrice() async { Future<Either<Failure, PriceInfo>> getCurrentPrice() async {
try { try {

View File

@ -11,6 +11,9 @@ abstract class TradingRepository {
/// ///
Future<Either<Failure, bool>> getBuyEnabled(); Future<Either<Failure, bool>> getBuyEnabled();
/// fail-open false
Future<Either<Failure, bool>> getSellRestriction();
/// ///
Future<Either<Failure, PriceInfo>> getCurrentPrice(); Future<Either<Failure, PriceInfo>> getCurrentPrice();

View File

@ -711,9 +711,10 @@ class _C2cPublishPageState extends ConsumerState<C2cPublishPage> {
final isSell = _selectedType == 1; final isSell = _selectedType == 1;
final capabilities = ref.watch(capabilitiesProvider).valueOrNull ?? CapabilityMap.defaultAll(); final capabilities = ref.watch(capabilitiesProvider).valueOrNull ?? CapabilityMap.defaultAll();
final c2cEnabled = capabilities.c2cEnabled; final c2cEnabled = capabilities.c2cEnabled;
final isSellRestricted = isSell && (ref.watch(sellRestrictionProvider).valueOrNull ?? false);
// #11: 11 // #11: 11
bool isValid = c2cEnabled && quantity >= 1; bool isValid = c2cEnabled && !isSellRestricted && quantity >= 1;
if (isSell) { if (isSell) {
// //
if (_selectedPaymentMethods.any((m) => m != 'GREEN_POINTS')) { if (_selectedPaymentMethods.any((m) => m != 'GREEN_POINTS')) {
@ -725,12 +726,34 @@ class _C2cPublishPageState extends ConsumerState<C2cPublishPage> {
return Padding( return Padding(
padding: const EdgeInsets.symmetric(horizontal: 16), padding: const EdgeInsets.symmetric(horizontal: 16),
child: SizedBox( 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, width: double.infinity,
height: 50, height: 50,
child: ElevatedButton( child: ElevatedButton(
onPressed: isValid && !c2cState.isLoading onPressed: isValid && !c2cState.isLoading
? _handlePublish ? _handlePublish
: isSellRestricted
? () => ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('预种积分股暂时不可卖出,请先完成预种合并'), backgroundColor: _red),
)
: !c2cEnabled : !c2cEnabled
? () => ScaffoldMessenger.of(context).showSnackBar( ? () => ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('您的C2C交易功能已被限制'), backgroundColor: _red), const SnackBar(content: Text('您的C2C交易功能已被限制'), backgroundColor: _red),
@ -763,6 +786,8 @@ class _C2cPublishPageState extends ConsumerState<C2cPublishPage> {
), ),
), ),
), ),
],
),
); );
} }

View File

@ -813,20 +813,44 @@ class _TradingPageState extends ConsumerState<TradingPage> {
), ),
), ),
const SizedBox(height: 24), const SizedBox(height: 24),
// + // + +
Builder(builder: (context) { Builder(builder: (context) {
final sellError = _selectedTab == 1 ? _getSellValidationError(tradingShareBalance) : null; final sellError = _selectedTab == 1 ? _getSellValidationError(tradingShareBalance) : null;
final capabilities = ref.watch(capabilitiesProvider).valueOrNull ?? CapabilityMap.defaultAll(); final capabilities = ref.watch(capabilitiesProvider).valueOrNull ?? CapabilityMap.defaultAll();
final tradingEnabled = capabilities.tradingEnabled; final tradingEnabled = capabilities.tradingEnabled;
final isDisabled = sellError != null || !tradingEnabled; final isSellRestricted = _selectedTab == 1 && (ref.watch(sellRestrictionProvider).valueOrNull ?? false);
return SizedBox( 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, width: double.infinity,
height: 48, height: 48,
child: ElevatedButton( child: ElevatedButton(
onPressed: isDisabled onPressed: isDisabled
? !tradingEnabled ? isSellRestricted || !tradingEnabled
? () => ScaffoldMessenger.of(context).showSnackBar( ? () => ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('您的交易功能已被限制'), backgroundColor: AppColors.down), SnackBar(
content: Text(isSellRestricted
? '预种积分股暂时不可卖出,请先完成预种合并'
: '您的交易功能已被限制'),
backgroundColor: AppColors.down,
),
) )
: null : null
: _handleTrade, : _handleTrade,
@ -845,6 +869,8 @@ class _TradingPageState extends ConsumerState<TradingPage> {
), ),
), ),
), ),
),
],
); );
}), }),
], ],

View File

@ -31,6 +31,23 @@ final buyEnabledProvider = FutureProvider<bool>((ref) async {
); );
}); });
// Provider (2fail-open)
final sellRestrictionProvider = FutureProvider<bool>((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线周期选择 // K线周期选择
final selectedKlinePeriodProvider = StateProvider<String>((ref) => '1h'); final selectedKlinePeriodProvider = StateProvider<String>((ref) => '1h');