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 { 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 };
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 {}
|
||||||
|
|
|
||||||
|
|
@ -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';
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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: 价格固定为1,数量最小为1
|
// #11: 价格固定为1,数量最小为1
|
||||||
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> {
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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> {
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
);
|
);
|
||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
|
|
|
||||||
|
|
@ -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线周期选择
|
// K线周期选择
|
||||||
final selectedKlinePeriodProvider = StateProvider<String>((ref) => '1h');
|
final selectedKlinePeriodProvider = StateProvider<String>((ref) => '1h');
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue