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:
parent
ac3adfc90a
commit
ee734fb7b9
|
|
@ -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 };
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {}
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -141,6 +141,9 @@ abstract class TradingRemoteDataSource {
|
|||
|
||||
/// 上传付款水单(买方操作)
|
||||
Future<C2cOrderModel> uploadC2cPaymentProof(String orderNo, String filePath);
|
||||
|
||||
/// 查询当前用户预种卖出限制状态(fail-open:接口异常时返回 false 允许卖出)
|
||||
Future<bool> getSellRestriction();
|
||||
}
|
||||
|
||||
class TradingRemoteDataSourceImpl implements TradingRemoteDataSource {
|
||||
|
|
@ -601,4 +604,15 @@ class TradingRemoteDataSourceImpl implements TradingRemoteDataSource {
|
|||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
Future<Either<Failure, PriceInfo>> getCurrentPrice() async {
|
||||
try {
|
||||
|
|
|
|||
|
|
@ -11,6 +11,9 @@ abstract class TradingRepository {
|
|||
/// 获取买入功能开关状态
|
||||
Future<Either<Failure, bool>> getBuyEnabled();
|
||||
|
||||
/// 查询当前用户预种卖出限制状态(fail-open:异常时返回 false)
|
||||
Future<Either<Failure, bool>> getSellRestriction();
|
||||
|
||||
/// 获取当前价格信息
|
||||
Future<Either<Failure, PriceInfo>> getCurrentPrice();
|
||||
|
||||
|
|
|
|||
|
|
@ -711,9 +711,10 @@ class _C2cPublishPageState extends ConsumerState<C2cPublishPage> {
|
|||
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,12 +726,34 @@ class _C2cPublishPageState extends ConsumerState<C2cPublishPage> {
|
|||
|
||||
return Padding(
|
||||
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,
|
||||
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),
|
||||
|
|
@ -763,6 +786,8 @@ class _C2cPublishPageState extends ConsumerState<C2cPublishPage> {
|
|||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -813,20 +813,44 @@ class _TradingPageState extends ConsumerState<TradingPage> {
|
|||
),
|
||||
),
|
||||
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,6 +869,8 @@ class _TradingPageState extends ConsumerState<TradingPage> {
|
|||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}),
|
||||
],
|
||||
|
|
|
|||
|
|
@ -31,6 +31,23 @@ final buyEnabledProvider = FutureProvider<bool>((ref) async {
|
|||
);
|
||||
});
|
||||
|
||||
// 预种卖出限制状态 Provider (2分钟缓存,fail-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线周期选择
|
||||
final selectedKlinePeriodProvider = StateProvider<String>((ref) => '1h');
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue