feat(redpacket): 红包与游戏横幅全量实现 (#19~#24)

- #19 fix: SendRedEnvelopeUseCase 动态取 currencyType(workspaceId>0 取
  workspace.currency,修复 iOS 硬编码 PEA → 150001 错误)
- #20: RedEnvelopeBubble typ=8,四态(橙色领取/已领/过期/抢完)+ 领取按钮
- #21: ReceiveRedEnvelopeUseCase POST /app/api/wallet/rp/receive,
  typed JSON body(避免 code=30007),SnackBar 反馈
- #22: SendRedEnvelopeSheet BottomSheet,STANDARD_RP + LUCKY_RP,
  发送成功后构建 typ=8 content JSON 回调给 ChatPage
- #23: BannerViewModel Notifier,Group.topic 双格式解析(JSON object/string),
  FetchBannerUseCase + Timer 倒计时 + applyNewRound WS 接口
- #24: BannerView 游戏横幅条(状态/倒计时/上期结果),
  MiniAppFloatButton 悬浮按钮(hasGame 显示/隐藏,onTap TODO #25)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
pp-bot
2026-03-23 23:11:29 +09:00
parent 83774f5f61
commit d9539d391c
12 changed files with 1616 additions and 0 deletions

View File

@@ -0,0 +1,16 @@
import 'package:networks_sdk/networks_sdk.dart';
import 'package:im_app/data/remote/red_envelope_request.dart';
/// 获取游戏横幅 UseCase
///
/// 对应 Gitea issue #23 / iOS BannerViewModel.loadBanner()
class FetchBannerUseCase {
final NetworksSdkApi _api;
const FetchBannerUseCase({required NetworksSdkApi api}) : _api = api;
Future<GameBannerData?> execute(String gameId) async {
return _api.executeRequest(FetchBannerRequest(gameId: gameId));
}
}

View File

@@ -0,0 +1,74 @@
import 'package:networks_sdk/networks_sdk.dart';
import 'package:im_app/data/remote/red_envelope_request.dart';
/// 领取红包结果
class ReceiveRpResult {
final bool success;
final String amount;
final int rpStatus;
final String displayMessage;
const ReceiveRpResult({
required this.success,
required this.amount,
required this.rpStatus,
required this.displayMessage,
});
}
/// 领取红包 UseCase
///
/// 对应 Gitea issue #21 / iOS RedEnvelopeDetailSheet.claim()
///
/// ⚠️ `ReceiveRpRequest` 使用 JSON typed body非 form string
/// 服务端要求 bool/int 为原生类型,否则返回 code=30007。
class ReceiveRedEnvelopeUseCase {
final NetworksSdkApi _api;
const ReceiveRedEnvelopeUseCase({required NetworksSdkApi api}) : _api = api;
Future<ReceiveRpResult> execute({
required String rpId,
required int chatId,
required String rpType,
required int messageId,
}) async {
final result = await _api.executeRequest(
ReceiveRpRequest(
rpId: rpId,
chatId: chatId,
rpType: rpType,
sendRpMsgId: messageId,
),
);
if (result == null) {
return const ReceiveRpResult(
success: false,
amount: '0',
rpStatus: 0,
displayMessage: '领取失败',
);
}
final msg = _statusMessage(result.grabFlag, result.rpStatus, result.amount);
return ReceiveRpResult(
success: result.grabFlag,
amount: result.amount,
rpStatus: result.rpStatus,
displayMessage: msg,
);
}
String _statusMessage(bool grabbed, int status, String amount) {
if (grabbed) return '领取成功!获得 $amount';
return switch (status) {
2 => '您已领取过该红包',
3 => '红包已过期',
4 => '手慢了,红包已抢完',
5 => '不在领取范围内',
_ => '领取失败',
};
}
}

View File

@@ -0,0 +1,83 @@
import 'package:networks_sdk/networks_sdk.dart';
import 'package:im_app/data/remote/red_envelope_request.dart';
/// 发送红包 UseCase
///
/// 对应 Gitea issue #19 + #22
///
/// ## currencyType 动态化(#19 bug fix
///
/// - `workspaceId > 0`:先请求 `/workspace/workspace/get` 取 workspace.currency如 USDT
/// - `workspaceId == 0`:默认 `"PEA"`
///
/// iOS 老项目硬编码 `currencyType=PEA`,导致 workspace 群报错 150001。
///
/// ## 成功后调用方需要
///
/// 使用返回的 `rpId` 构建 typ=8 消息 content通过 ChatViewModel.sendMessage() 发送。
class SendRedEnvelopeUseCase {
final NetworksSdkApi _api;
const SendRedEnvelopeUseCase({required NetworksSdkApi api}) : _api = api;
/// 发送红包,返回 rpId
///
/// [chatId] 会话 ID
/// [chatType] 1=私聊 / 2=群聊
/// [workspaceId] 群 workspaceId0 表示非工作台群)
/// [rpType] "STANDARD_RP" | "LUCKY_RP"(初期支持,后续扩展 MINE_RP / NN_RP
/// [amount] 总金额字符串,如 "10.00"
/// [rpNum] 红包数量
/// [remark] 红包祝福语
Future<String> execute({
required int chatId,
required int chatType,
required int workspaceId,
required String rpType,
required String amount,
required int rpNum,
required String remark,
}) async {
// #19 fix: 根据 workspaceId 动态决定 currencyType
final currencyType = await _resolveCurrencyType(workspaceId);
final result = await _api.executeRequest(
SendRpRequest(
amount: amount,
currencyType: currencyType,
chatId: chatId,
chatType: chatType,
rpType: rpType,
recipientIds: const [],
rpNum: rpNum,
remark: remark,
msgSendTime: DateTime.now().microsecondsSinceEpoch,
),
);
if (result == null || result.rpId.isEmpty) {
throw Exception('发送红包失败:未返回 rpId');
}
return result.rpId;
}
/// 根据 workspaceId 解析 currencyType
///
/// - `workspaceId > 0` → 请求 workspace 取 currency 字段
/// - `workspaceId == 0` → 直接返回 "PEA"
Future<String> _resolveCurrencyType(int workspaceId) async {
if (workspaceId <= 0) return 'PEA';
try {
final ws = await _api.executeRequest(
GetWorkspaceRequest(workspaceId: workspaceId),
);
final currency = ws?.currency ?? '';
return currency.isNotEmpty ? currency : 'PEA';
} catch (_) {
// 降级:取不到 workspace 信息时使用默认值
return 'PEA';
}
}
}