diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 740fe95f..54499695 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -664,7 +664,18 @@ "Bash(git -C \"c:/Users/dong/Desktop/rwadurian\" commit -m \"$\\(cat <<''EOF''\nfix\\(planting-service\\): 修复合同PDF签署日期显示为UTC时间的问题\n\n合同生成时使用 new Date\\(\\).toISOString\\(\\).split\\(''T''\\)[0] 获取日期,\n该方法返回UTC时间,导致北京时间凌晨签署的合同显示为前一天日期。\n\n修复方案:新增 getBeijingDateString\\(\\) 函数,将UTC时间转换为北京时间\\(UTC+8\\)\n\n影响范围:仅影响PDF合同上显示的签署日期,不影响数据库时间戳或业务逻辑\n\n🤖 Generated with [Claude Code]\\(https://claude.com/claude-code\\)\n\nCo-Authored-By: Claude Opus 4.5 \nEOF\n\\)\")", "Bash(git -C \"c:/Users/dong/Desktop/rwadurian\" push origin main)", "Bash(git -C \"c:/Users/dong/Desktop/rwadurian\" tag -a v1.0.0 -m \"$\\(cat <<''EOF''\nRelease v1.0.0 - 正式发布\n\n主要功能:\n- 用户身份认证与KYC实名认证\n- 榴莲树认种与合同签署系统\n- 钱包与资产管理(USDT/绿积分/算力)\n- 推荐关系与团队管理\n- 收益分配与奖励系统\n- 排行榜系统\n- 后台管理系统\n- MPC多方计算钱包\n- 区块链服务(KAVA链)\n\n🤖 Generated with [Claude Code]\\(https://claude.com/claude-code\\)\nEOF\n\\)\")", - "Bash(git -C \"c:/Users/dong/Desktop/rwadurian\" push origin v1.0.0)" + "Bash(git -C \"c:/Users/dong/Desktop/rwadurian\" push origin v1.0.0)", + "Bash(git -C \"c:/Users/dong/Desktop/rwadurian\" commit -m \"$\\(cat <<''EOF''\nfix\\(admin-service\\): 修复用户数据CDC同步使用userId导致的数据不一致问题\n\n问题原因:\n- 旧的Kafka事件消费者和CDC消费者同时运行\n- 旧消费者写入的数据userId可能为0\n- CDC消费者使用userId作为upsert条件,导致唯一键冲突失败\n- 用户的nickname和kycStatus等信息没有正确同步\n\n修复方案:\n- upsert方法改用accountSequence作为唯一键\n- CDC消费者的handleUpdate使用accountSequence检查和更新\n- 更新时同时修复可能错误的userId\n- 新增existsByAccountSequence和updateKycStatusByAccountSequence方法\n\n影响范围:\n- admin-web用户管理页面现在能正确显示用户昵称和KYC状态\n- 新用户注册后数据能正确同步\n\n🤖 Generated with [Claude Code]\\(https://claude.com/claude-code\\)\n\nCo-Authored-By: Claude Opus 4.5 \nEOF\n\\)\")", + "Bash(git -C \"c:/Users/dong/Desktop/rwadurian\" diff backend/services/docker-compose.yml)", + "Bash(git -C \"c:/Users/dong/Desktop/rwadurian\" add backend/services/docker-compose.yml)", + "Bash(git -C \"c:/Users/dong/Desktop/rwadurian\" commit -m \"$\\(cat <<''EOF''\nfix\\(admin-service\\): 添加uploads目录的volume持久化配置\n\n问题:admin-service重新部署后,上传的APK文件会丢失\n原因:主docker-compose.yml中admin-service未配置volume挂载,\n 导致容器重建时/app/uploads目录数据丢失\n\n修复:\n- 添加admin_uploads_data volume挂载到/app/uploads\n- 添加UPLOAD_DIR环境变量\n- 在volumes部分声明admin_uploads_data\n\n影响范围:仅影响admin-service的文件存储持久化\n\n🤖 Generated with [Claude Code]\\(https://claude.com/claude-code\\)\n\nCo-Authored-By: Claude Opus 4.5 \nEOF\n\\)\")", + "Bash(git -C \"c:/Users/dong/Desktop/rwadurian\" diff frontend/admin-web/src/app/\\\\\\(dashboard\\\\\\)/authorization/page.tsx)", + "Bash(git -C \"c:/Users/dong/Desktop/rwadurian\" add frontend/admin-web/src/app/\\\\\\(dashboard\\\\\\)/authorization/page.tsx)", + "Bash(git -C \"c:/Users/dong/Desktop/rwadurian\" commit -m \"$\\(cat <<''EOF''\nfix\\(admin-web\\): 优化授权页面错误提示,显示后端真实错误信息\n\n问题:创建授权失败时只显示\"Request failed with status code 400\"\n用户无法了解失败的真实原因(如用户未种树、授权冲突等)\n\n修复:\n- handleCreate和handleRevoke的catch块优先从err.response.data.message提取后端错误\n- 后端已有完善的错误提示如\"用户尚未认种任何树,无法授权\"\n- 前端现在能正确显示这些提示帮助管理员了解真实情况\n\n🤖 Generated with [Claude Code]\\(https://claude.com/claude-code\\)\n\nCo-Authored-By: Claude Opus 4.5 \nEOF\n\\)\")", + "Bash(git -C \"c:/Users/dong/Desktop/rwadurian\" diff frontend/mobile-app/lib/features/pending_actions/presentation/pages/pending_actions_page.dart)", + "Bash(git -C \"c:/Users/dong/Desktop/rwadurian\" checkout -- frontend/mobile-app/lib/features/pending_actions/presentation/pages/pending_actions_page.dart)", + "Bash(git -C \"c:/Users/dong/Desktop/rwadurian\" add frontend/mobile-app/lib/features/pending_actions/presentation/pages/pending_actions_page.dart)", + "Bash(git -C \"c:/Users/dong/Desktop/rwadurian\" commit -m \"$\\(cat <<''EOF''\nfix\\(mobile-app\\): 修复认种向导待办操作无法正确标记完成的问题\n\n问题:用户完成认种并签署合同后,ADOPTION_WIZARD待办操作没有被标记为完成,\n导致用户被卡在待办操作页面无法进入App。\n\n原因:原来的检查逻辑只检查是否有\"待签合同\",当用户已签署合同后,\npendingTasks为空,返回false,导致待办操作无法完成。\n\n修复方案:\n- 改为检查用户是否有已支付的认种订单(PAID/FUND_ALLOCATED状态)\n- 通过比较订单创建时间和待办操作创建时间来判断\n- 订单在待办操作之后创建 → 已完成\n- 订单在待办操作之前但相差不超过24小时 → 也认为已完成(兼容延迟)\n- 保留待签合同的备用检查逻辑\n\n影响范围:仅影响ADOPTION_WIZARD待办操作的完成检测\n\n🤖 Generated with [Claude Code]\\(https://claude.com/claude-code\\)\n\nCo-Authored-By: Claude Opus 4.5 \nEOF\n\\)\")" ], "deny": [], "ask": [] diff --git a/frontend/mobile-app/lib/core/network/api_client.dart b/frontend/mobile-app/lib/core/network/api_client.dart index 94c0ceb7..939dc3ed 100644 --- a/frontend/mobile-app/lib/core/network/api_client.dart +++ b/frontend/mobile-app/lib/core/network/api_client.dart @@ -264,16 +264,23 @@ class ApiClient { } /// GET 请求 + /// + /// [cancelToken] 用于取消请求 + /// [onReceiveProgress] 下载进度回调,参数为 (已接收字节数, 总字节数) Future> get( String path, { Map? queryParameters, Options? options, + CancelToken? cancelToken, + void Function(int, int)? onReceiveProgress, }) async { try { return await _dio.get( path, queryParameters: queryParameters, options: options, + cancelToken: cancelToken, + onReceiveProgress: onReceiveProgress, ); } on DioException catch (e) { throw _handleDioError(e); diff --git a/frontend/mobile-app/lib/core/services/contract_signing_service.dart b/frontend/mobile-app/lib/core/services/contract_signing_service.dart index c342dbaf..449d116f 100644 --- a/frontend/mobile-app/lib/core/services/contract_signing_service.dart +++ b/frontend/mobile-app/lib/core/services/contract_signing_service.dart @@ -545,25 +545,79 @@ class ContractSigningService { } /// 下载合同 PDF 文件 - Future downloadContractPdf(String orderNo) async { - try { - debugPrint('[ContractSigningService] 下载合同 PDF: $orderNo'); + /// + /// [orderNo] 订单号 + /// [onProgress] 下载进度回调,参数为 (已接收字节数, 总字节数) + /// [cancelToken] 用于取消下载的 token + /// + /// 特性: + /// - 10 次自动重试 + /// - 300 秒超时 + /// - 支持进度回调 + /// - 支持取消下载 + Future downloadContractPdf( + String orderNo, { + void Function(int received, int total)? onProgress, + CancelToken? cancelToken, + }) async { + const maxRetries = 10; + const timeout = Duration(seconds: 300); - final response = await _apiClient.get( - '/planting/contract-signing/tasks/$orderNo/pdf', - options: Options(responseType: ResponseType.bytes), - ); + for (int attempt = 1; attempt <= maxRetries; attempt++) { + try { + debugPrint('[ContractSigningService] 下载合同 PDF: $orderNo (尝试 $attempt/$maxRetries)'); - if (response.statusCode == 200) { - debugPrint('[ContractSigningService] PDF 下载成功'); - return Uint8List.fromList(response.data); + final response = await _apiClient.get( + '/planting/contract-signing/tasks/$orderNo/pdf', + options: Options( + responseType: ResponseType.bytes, + receiveTimeout: timeout, + ), + cancelToken: cancelToken, + onReceiveProgress: onProgress, + ); + + if (response.statusCode == 200) { + debugPrint('[ContractSigningService] PDF 下载成功,大小: ${response.data.length} bytes'); + return Uint8List.fromList(response.data); + } + + throw Exception('下载 PDF 失败: ${response.statusCode}'); + } on DioException catch (e) { + debugPrint('[ContractSigningService] 下载 PDF 失败 (尝试 $attempt/$maxRetries): ${e.type} - ${e.message}'); + + // 如果是用户取消,直接抛出不重试 + if (e.type == DioExceptionType.cancel) { + debugPrint('[ContractSigningService] 用户取消下载'); + rethrow; + } + + // 最后一次尝试失败,抛出异常 + if (attempt == maxRetries) { + debugPrint('[ContractSigningService] 已达到最大重试次数,放弃下载'); + throw Exception('下载 PDF 失败,已重试 $maxRetries 次: ${e.message}'); + } + + // 等待后重试,使用指数退避策略(2s, 4s, 6s...最大10s) + final waitSeconds = (attempt * 2).clamp(2, 10); + debugPrint('[ContractSigningService] 等待 ${waitSeconds}s 后重试...'); + await Future.delayed(Duration(seconds: waitSeconds)); + } catch (e) { + debugPrint('[ContractSigningService] 下载 PDF 异常 (尝试 $attempt/$maxRetries): $e'); + + if (attempt == maxRetries) { + debugPrint('[ContractSigningService] 已达到最大重试次数,放弃下载'); + rethrow; + } + + final waitSeconds = (attempt * 2).clamp(2, 10); + debugPrint('[ContractSigningService] 等待 ${waitSeconds}s 后重试...'); + await Future.delayed(Duration(seconds: waitSeconds)); } - - throw Exception('下载 PDF 失败: ${response.statusCode}'); - } catch (e) { - debugPrint('[ContractSigningService] 下载 PDF 失败: $e'); - rethrow; } + + // 理论上不会到这里,但为了类型安全 + throw Exception('下载 PDF 失败'); } } diff --git a/frontend/mobile-app/lib/features/contract_signing/presentation/pages/contract_signing_page.dart b/frontend/mobile-app/lib/features/contract_signing/presentation/pages/contract_signing_page.dart index fd7768e3..d21fd535 100644 --- a/frontend/mobile-app/lib/features/contract_signing/presentation/pages/contract_signing_page.dart +++ b/frontend/mobile-app/lib/features/contract_signing/presentation/pages/contract_signing_page.dart @@ -59,6 +59,12 @@ class _ContractSigningPageState extends ConsumerState { /// 错误信息 String? _errorMessage; + /// PDF 下载进度 (0-100) + int _downloadProgress = 0; + + /// PDF 下载重试次数 + int _downloadRetryCount = 0; + /// 倒计时定时器 Timer? _countdownTimer; @@ -138,26 +144,45 @@ class _ContractSigningPageState extends ConsumerState { Future _loadPdf() async { setState(() { _isPdfLoading = true; + _downloadProgress = 0; + _errorMessage = null; }); try { final service = ref.read(contractSigningServiceProvider); - final pdfBytes = await service.downloadContractPdf(widget.orderNo); + final pdfBytes = await service.downloadContractPdf( + widget.orderNo, + onProgress: (received, total) { + if (total > 0 && mounted) { + final progress = ((received / total) * 100).round(); + setState(() { + _downloadProgress = progress; + }); + } + }, + ); // 保存到临时文件 final tempDir = await getTemporaryDirectory(); final tempFile = File('${tempDir.path}/contract_${widget.orderNo}.pdf'); await tempFile.writeAsBytes(pdfBytes); - setState(() { - _pdfPath = tempFile.path; - _isPdfLoading = false; - }); + if (mounted) { + setState(() { + _pdfPath = tempFile.path; + _isPdfLoading = false; + _downloadProgress = 100; + }); + } } catch (e) { - setState(() { - _isPdfLoading = false; - _errorMessage = '加载合同 PDF 失败: $e'; - }); + debugPrint('[ContractSigningPage] PDF 加载失败: $e'); + if (mounted) { + setState(() { + _isPdfLoading = false; + _downloadRetryCount++; + _errorMessage = '加载合同失败,请点击重试'; + }); + } } } @@ -575,26 +600,54 @@ class _ContractSigningPageState extends ConsumerState { } if (_errorMessage != null) { + // 判断是任务加载错误还是 PDF 下载错误 + final isPdfError = _task != null && _pdfPath == null; return Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - const Icon(Icons.error_outline, color: Colors.red, size: 48), - const SizedBox(height: 16), - Text( - _errorMessage!, - style: const TextStyle(fontSize: 16, color: Colors.red), - textAlign: TextAlign.center, - ), - const SizedBox(height: 16), - ElevatedButton( - onPressed: _loadTask, - style: ElevatedButton.styleFrom( - backgroundColor: const Color(0xFFD4AF37), + child: Padding( + padding: const EdgeInsets.all(24), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + isPdfError ? Icons.cloud_download_outlined : Icons.error_outline, + color: isPdfError ? const Color(0xFFD4AF37) : Colors.red, + size: 56, ), - child: const Text('重试', style: TextStyle(color: Colors.white)), - ), - ], + const SizedBox(height: 16), + Text( + _errorMessage!, + style: TextStyle( + fontSize: 16, + color: isPdfError ? const Color(0xFF666666) : Colors.red, + ), + textAlign: TextAlign.center, + ), + const SizedBox(height: 24), + ElevatedButton.icon( + onPressed: isPdfError ? _loadPdf : _loadTask, + style: ElevatedButton.styleFrom( + backgroundColor: const Color(0xFFD4AF37), + padding: const EdgeInsets.symmetric(horizontal: 32, vertical: 12), + ), + icon: const Icon(Icons.refresh, color: Colors.white), + label: const Text( + '重试', + style: TextStyle(color: Colors.white, fontSize: 16), + ), + ), + if (isPdfError && _downloadRetryCount > 0) + Padding( + padding: const EdgeInsets.only(top: 12), + child: Text( + '已自动重试 $_downloadRetryCount 次', + style: const TextStyle( + fontSize: 12, + color: Color(0xFF999999), + ), + ), + ), + ], + ), ), ); } @@ -610,19 +663,54 @@ class _ContractSigningPageState extends ConsumerState { // PDF 加载中 if (_isPdfLoading || _pdfPath == null) { - return const Center( + return Center( child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ - CircularProgressIndicator(color: Color(0xFFD4AF37)), - SizedBox(height: 16), + // 进度圆环 + SizedBox( + width: 80, + height: 80, + child: Stack( + alignment: Alignment.center, + children: [ + CircularProgressIndicator( + value: _downloadProgress > 0 ? _downloadProgress / 100 : null, + color: const Color(0xFFD4AF37), + strokeWidth: 6, + backgroundColor: const Color(0xFFE0E0E0), + ), + if (_downloadProgress > 0) + Text( + '$_downloadProgress%', + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + color: Color(0xFFD4AF37), + ), + ), + ], + ), + ), + const SizedBox(height: 16), Text( - '正在加载合同...', - style: TextStyle( + _downloadProgress > 0 ? '正在下载合同 $_downloadProgress%' : '正在加载合同...', + style: const TextStyle( fontSize: 14, color: Color(0xFF666666), ), ), + if (_downloadRetryCount > 0) + Padding( + padding: const EdgeInsets.only(top: 8), + child: Text( + '正在重试 (第 $_downloadRetryCount 次)', + style: const TextStyle( + fontSize: 12, + color: Color(0xFF999999), + ), + ), + ), ], ), );