From 7a588898fad7ae2e348612ff4dcad1c2ec9105f0 Mon Sep 17 00:00:00 2001 From: Zhou Weidong <502333560@qq.com> Date: Thu, 22 Oct 2026 12:15:51 +0800 Subject: [PATCH] =?UTF-8?q?feat(debug):=20=E5=A2=9E=E5=8A=A0=20HTTP=20?= =?UTF-8?q?=E8=AF=B7=E6=B1=82=E5=93=8D=E5=BA=94=E6=97=A5=E5=BF=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/plans/2026-10-22-debug-http-logging.md | 24 ++ docs/plans/PROGRESS.md | 79 +++++++ lib/core/data/remote/api_client.dart | 4 + lib/core/data/remote/api_interceptors.dart | 5 + .../data/remote/network_log_interceptor.dart | 219 ++++++++++++++++++ .../remote/network_log_interceptor_test.dart | 203 ++++++++++++++++ 6 files changed, 534 insertions(+) create mode 100644 docs/plans/2026-10-22-debug-http-logging.md create mode 100644 lib/core/data/remote/network_log_interceptor.dart create mode 100644 test/core/data/remote/network_log_interceptor_test.dart diff --git a/docs/plans/2026-10-22-debug-http-logging.md b/docs/plans/2026-10-22-debug-http-logging.md new file mode 100644 index 0000000..583255c --- /dev/null +++ b/docs/plans/2026-10-22-debug-http-logging.md @@ -0,0 +1,24 @@ +# Android Studio 调试 HTTP 日志 + +## 目标 + +- 仅在调试构建中输出 Dio 请求、响应与异常日志。 +- Android Studio Run 控制台通过 `debugPrint` 展示,logcat 标签保持 `flutter`。 +- Authorization 脱敏,X-Device-Id 保留,二进制响应只显示字节数。 +- 单条请求体或响应体最多 3000 字符。 + +## 实现步骤 + +1. 新增 `NetworkLogInterceptor`,支持注入日志回调并保留请求耗时。 +2. 在 Envelope 解包前暂存原始 bytes;日志拦截器追加到拦截链末尾后读取暂存值。 +3. 在 `ApiClient` 注册调试拦截器,`kDebugMode` 与常量开关同时满足时才输出。 +4. 以 fake adapter 补单测,覆盖行格式、脱敏、UTF-8、截断、非 JSON 与二进制。 +5. 运行 `dart analyze lib test` 和 `flutter test`,更新 PROGRESS 留痕。 + +## 风险与约束 + +- `ResponseType.bytes` 不能直接 `toString`,响应体统一 `utf8.decode`。 +- Dio 响应拦截器按 FIFO 执行;链尾日志通过 Envelope 暂存的原始 bytes 读取, + 避免拿到已解包后的 `data`。 +- release/profile 通过 `kDebugMode` 短路,不输出任何 HTTP 日志。 +- 不新增依赖,不提交现有 `pubspec.lock` 改动,不启停服务、不操作真机。 diff --git a/docs/plans/PROGRESS.md b/docs/plans/PROGRESS.md index 6fa020d..143fc27 100644 --- a/docs/plans/PROGRESS.md +++ b/docs/plans/PROGRESS.md @@ -531,3 +531,82 @@ $ flutter test --no-pub 解读:VTT 时间/xywh/多 sprite、越界最近 cue、LRU2、403 重试、10 分钟 TTL、 回退链、sprite 零本地取帧、无 URL 本地回退和最终封面兜底均通过;全量静态分析 零告警,617 项测试全过。`pubspec.lock` 已还原,未启停服务,未操作真机。 + +## 2026-10-22 Android Studio 调试 HTTP 日志 + +- 分支:`feature/debug-http-logging` +- 基线:`develop` / `origin/develop` = `dc4b602` +- 计划:`docs/plans/2026-10-22-debug-http-logging.md` + +```text +$ git status --short --branch +## develop...origin/develop + M pubspec.lock + +$ git rev-parse HEAD +dc4b60270dd38d594510b45bd7a3d50b7dcd2602 +``` + +解读:基于最新 develop 创建功能分支;开工时 `pubspec.lock` 已有未提交改动, +本次未修改、未暂存该文件。 + +### 实现结果 + +- 新增 `NetworkLogInterceptor`,默认 `debugPrint`,可注入 + `void Function(String)` 作为单测日志收集器。 +- `enabled` 常量与 `kDebugMode` 同时为真才输出,release/profile 零 HTTP 日志。 +- 请求行包含完整 URL 与 query,同时输出耗时起点、脱敏请求头和格式化请求体。 +- Authorization 仅保留前 8 字符后加 `...`;X-Device-Id 原样输出; + 其余请求头值统一 `***`。 +- ResponseType.bytes 响应统一 `utf8.decode(..., allowMalformed: true)`, + JSON 自动缩进;图片/音视频/字体/PDF/ZIP 等只输出 MIME 与字节数。 +- 请求体和响应体超过 3000 字符时截断并追加 `…(truncated)`。 +- 拦截链为 `AuthHeaderInterceptor → EnvelopeInterceptor → NetworkLogInterceptor`。 + Envelope 在解包前暂存原始 bytes,末尾日志读取暂存值,因此仍能打印 Envelope + 处理前的原始响应。 +- 异常路径输出 `[HTTP] ✗ DioException类型 耗时ms URL message`,并继续 + `handler.next(err)`,不吞异常。 + +代表性原始日志: + +```text +[HTTP] → POST http://logging.test/app/logging?keyword=%E4%B8%AD%E6%96%87 +[HTTP] → 耗时起点:2026-10-22T12:12:25.377357 +[HTTP] → headers:{"Accept":"***","Authorization":"Bearer e...","X-Device-Id":"device-raw-001"} +[HTTP] → body:{ + "filter": "中文" +} +[HTTP] ← 200 12ms POST http://logging.test/app/logging?keyword=%E4%B8%AD%E6%96%87 +[HTTP] ← body:{ + "code": 0, + "message": "ok", + "data": { + "title": "中文标题" + } +} +``` + +### 门禁 + +```text +$ flutter test --no-pub test/core/data/remote/network_log_interceptor_test.dart +00:00 +0: should_log_request_and_response_lines_with_elapsed_when_requested +00:00 +1: should_truncate_long_body_when_log_exceeds_limit +00:00 +2: should_log_plain_text_without_throwing_when_response_is_not_json +00:00 +3: should_log_byte_count_only_when_response_is_image +00:00 +4: should_log_dio_exception_type_and_message_when_request_fails +00:00 +5: All tests passed! + +$ dart analyze lib test +Analyzing lib, test... +No issues found! + +$ flutter test --no-pub +00:58 +622: All tests passed! +``` + +解读:请求/响应行与耗时、Authorization 脱敏、X-Device-Id 原样、bytes 中文 +UTF-8 解码、3000 字符截断、非 JSON、二进制字节数、DioException 类型与 message +均通过;全量静态分析零告警,622 项测试全过。 + +未新增依赖、未启停服务、未操作真机;`pubspec.lock` 既有改动未提交。 diff --git a/lib/core/data/remote/api_client.dart b/lib/core/data/remote/api_client.dart index 6f934f1..6568177 100644 --- a/lib/core/data/remote/api_client.dart +++ b/lib/core/data/remote/api_client.dart @@ -1,8 +1,11 @@ +// Author: fruit.com.cn + import 'package:dio/dio.dart'; import '../../config/app_config.dart'; import 'api_exception.dart'; import 'api_interceptors.dart'; +import 'network_log_interceptor.dart'; /// `ApiException` 从这里也能拿到:历史调用方一律 `import 'api_client.dart'`,不必改 import。 export 'api_exception.dart'; @@ -39,6 +42,7 @@ class ApiClient { deviceIdReader: deviceIdReader, ), const EnvelopeInterceptor(), + NetworkLogInterceptor(), ]); if (adapter != null) { _dio.httpClientAdapter = adapter; diff --git a/lib/core/data/remote/api_interceptors.dart b/lib/core/data/remote/api_interceptors.dart index a48087d..cee6819 100644 --- a/lib/core/data/remote/api_interceptors.dart +++ b/lib/core/data/remote/api_interceptors.dart @@ -5,6 +5,7 @@ import 'dart:convert'; import 'package:dio/dio.dart'; import 'api_exception.dart'; +import 'network_log_interceptor.dart'; /// 请求级开关:放在 `RequestOptions.extra` 里,由 `ApiClient` 写入、拦截器读取。 class ApiRequestFlags { @@ -72,6 +73,10 @@ class EnvelopeInterceptor extends Interceptor { ResponseInterceptorHandler handler, ) { final options = response.requestOptions; + if (NetworkLogInterceptor.isEnabled) { + options.extra[NetworkLogInterceptor.rawResponseDataExtraKey] = + response.data; + } final shell = _decodeShell(response.data); final code = shell['code'] as int? ?? -1; final message = shell['message'] as String?; diff --git a/lib/core/data/remote/network_log_interceptor.dart b/lib/core/data/remote/network_log_interceptor.dart new file mode 100644 index 0000000..babd542 --- /dev/null +++ b/lib/core/data/remote/network_log_interceptor.dart @@ -0,0 +1,219 @@ +// Author: fruit.com.cn + +import 'dart:convert'; +import 'dart:math' as math; + +import 'package:dio/dio.dart'; +import 'package:flutter/foundation.dart'; + +/// 调试构建下输出 Dio 请求、响应和异常详情。 +class NetworkLogInterceptor extends Interceptor { + /// 在调试时关闭日志的本地常量开关。 + static const bool enabled = true; + + /// Envelope 解包前暂存原始响应体,供链尾日志读取。 + static const String rawResponseDataExtraKey = 'app.http.rawResponseData'; + + /// 请求、响应体单条日志最大字符数。 + static const int maxBodyLength = 3000; + + static const String _truncatedSuffix = '…(truncated)'; + static const String _stopwatchKey = 'app.http.stopwatch'; + static const String _startedAtKey = 'app.http.startedAt'; + static const String _emptyBody = ''; + static const String _redacted = '***'; + + /// 允许单测注入日志收集器;默认走 Android Studio Run 控制台。 + NetworkLogInterceptor({void Function(String)? logger}) + : _logger = logger ?? debugPrint; + + final void Function(String) _logger; + + /// release/profile 下恒为 false,保证零 HTTP 日志输出。 + static bool get isEnabled => kDebugMode && enabled; + + @override + void onRequest(RequestOptions options, RequestInterceptorHandler handler) { + if (!isEnabled) { + return handler.next(options); + } + final stopwatch = Stopwatch()..start(); + options.extra[_stopwatchKey] = stopwatch; + options.extra[_startedAtKey] = DateTime.now().toIso8601String(); + final body = _formatRequestBody(options.data); + final message = StringBuffer() + ..writeln('[HTTP] → ${options.method} ${options.uri}') + ..writeln('[HTTP] → 耗时起点:${options.extra[_startedAtKey]}') + ..writeln('[HTTP] → headers:${_formatHeaders(options.headers)}') + ..writeln('[HTTP] → body:$body'); + _logger(message.toString().trimRight()); + handler.next(options); + } + + @override + void onResponse( + Response response, + ResponseInterceptorHandler handler, + ) { + if (!isEnabled) { + return handler.next(response); + } + final options = response.requestOptions; + final elapsed = _elapsedMilliseconds(options); + final rawBody = options.extra[rawResponseDataExtraKey] ?? response.data; + final body = _formatResponseBody( + rawBody, + response.headers.value(Headers.contentTypeHeader), + ); + final message = StringBuffer() + ..writeln( + '[HTTP] ← ${response.statusCode ?? 0} ${elapsed}ms ' + '${options.method} ${options.uri}', + ) + ..writeln('[HTTP] ← body:$body'); + _logger(message.toString().trimRight()); + handler.next(response); + } + + @override + void onError(DioException err, ErrorInterceptorHandler handler) { + if (!isEnabled) { + return handler.next(err); + } + final options = err.requestOptions; + final message = StringBuffer() + ..writeln( + '[HTTP] ✗ ${err.type.name} ${_elapsedMilliseconds(options)}ms ' + '${options.uri} ${err.message ?? ''}', + ) + ..write(_errorBodyLine(err)); + _logger(message.toString().trimRight()); + handler.next(err); + } + + int _elapsedMilliseconds(RequestOptions options) { + final stopwatch = options.extra[_stopwatchKey]; + if (stopwatch is Stopwatch) { + return stopwatch.elapsedMilliseconds; + } + return 0; + } + + String _errorBodyLine(DioException error) { + final rawBody = + error.requestOptions.extra[rawResponseDataExtraKey] ?? + error.response?.data; + if (rawBody == null) { + return ''; + } + final body = _formatResponseBody( + rawBody, + error.response?.headers.value(Headers.contentTypeHeader), + ); + return '\n[HTTP] ✗ body:$body'; + } + + String _formatHeaders(Map headers) { + if (headers.isEmpty) { + return '{}'; + } + final entries = headers.entries.toList() + ..sort((left, right) => left.key.compareTo(right.key)); + final safe = { + for (final entry in entries) + entry.key: _safeHeaderValue(entry.key, entry.value), + }; + return _truncate(jsonEncode(safe)); + } + + String _safeHeaderValue(String name, Object? value) { + final text = value?.toString() ?? ''; + if (name.toLowerCase() == 'authorization') { + return _maskAuthorization(text); + } + if (name.toLowerCase() == 'x-device-id') { + return text; + } + return _redacted; + } + + String _maskAuthorization(String value) { + if (value.length <= 8) { + return _redacted; + } + return '${value.substring(0, math.min(8, value.length))}...'; + } + + String _formatRequestBody(Object? body) { + if (body == null) { + return _emptyBody; + } + final json = _tryPrettyJson(body); + if (json != null) { + return _truncate(json); + } + final length = switch (body) { + FormData data => data.length, + List bytes => bytes.length, + _ => utf8.encode(body.toString()).length, + }; + return '${body.runtimeType} ($length bytes)'; + } + + String _formatResponseBody(Object? body, String? contentType) { + if (body == null) { + return _emptyBody; + } + if (body is List) { + final bytes = body is Uint8List ? body : Uint8List.fromList(body); + if (_isBinary(contentType)) { + return ''; + } + final text = utf8.decode(bytes, allowMalformed: true); + return _formatText(text); + } + return _formatText(body.toString()); + } + + String _formatText(String text) { + if (text.isEmpty) { + return _emptyBody; + } + return _truncate(_tryPrettyJson(text) ?? text); + } + + String? _tryPrettyJson(Object? body) { + try { + final value = body is String + ? jsonDecode(body) + : body is List + ? jsonDecode(utf8.decode(body, allowMalformed: true)) + : body; + if (value is! Map && value is! List) { + return null; + } + return const JsonEncoder.withIndent(' ').convert(value); + } catch (_) { + return null; + } + } + + bool _isBinary(String? contentType) { + final type = contentType?.toLowerCase() ?? ''; + return type.startsWith('image/') || + type.startsWith('audio/') || + type.startsWith('video/') || + type.startsWith('font/') || + type.contains('application/octet-stream') || + type.contains('application/pdf') || + type.contains('application/zip'); + } + + String _truncate(String value) { + if (value.length <= maxBodyLength) { + return value; + } + final end = maxBodyLength - _truncatedSuffix.length; + return '${value.substring(0, end)}$_truncatedSuffix'; + } +} diff --git a/test/core/data/remote/network_log_interceptor_test.dart b/test/core/data/remote/network_log_interceptor_test.dart new file mode 100644 index 0000000..ef80004 --- /dev/null +++ b/test/core/data/remote/network_log_interceptor_test.dart @@ -0,0 +1,203 @@ +// Author: fruit.com.cn + +import 'dart:convert'; +import 'dart:typed_data'; + +import 'package:dio/dio.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:fruitshorts/core/data/remote/api_interceptors.dart'; +import 'package:fruitshorts/core/data/remote/network_log_interceptor.dart'; + +void main() { + const baseUrl = 'http://logging.test'; + const token = 'eyJhbGciOiJIUzM4NCJ9.secret'; + + Dio buildDio( + List logs, + HttpClientAdapter adapter, { + bool useEnvelope = true, + }) { + final dio = Dio( + BaseOptions( + baseUrl: baseUrl, + responseType: ResponseType.bytes, + validateStatus: (_) => true, + ), + ); + dio.httpClientAdapter = adapter; + dio.interceptors.addAll([ + AuthHeaderInterceptor( + accessTokenReader: () => token, + deviceIdReader: () async => 'device-raw-001', + ), + if (useEnvelope) const EnvelopeInterceptor(), + NetworkLogInterceptor(logger: logs.add), + ]); + return dio; + } + + test( + 'should_log_request_and_response_lines_with_elapsed_when_requested', + () async { + final logs = []; + final dio = buildDio( + logs, + _RawHttpAdapter.fromText( + jsonEncode({ + 'code': 0, + 'message': 'ok', + 'data': {'title': '中文标题'}, + }), + ), + ); + + await dio.post( + '/app/logging', + queryParameters: {'keyword': '中文'}, + data: {'filter': '中文'}, + ); + + expect( + logs.first, + contains('[HTTP] → POST $baseUrl/app/logging?keyword='), + ); + expect(logs.join('\n'), contains('[HTTP] → 耗时起点:')); + expect(logs.join('\n'), matches(RegExp(r'\[HTTP\] ← 200 \d+ms POST '))); + expect(logs.join('\n'), contains('"filter": "中文"')); + expect(logs.join('\n'), contains('"中文标题"')); + expect(logs.join('\n'), contains('"Authorization":"Bearer e..."')); + expect(logs.join('\n'), contains('"X-Device-Id":"device-raw-001"')); + expect(logs.join('\n'), isNot(contains(token))); + }, + ); + + test('should_truncate_long_body_when_log_exceeds_limit', () async { + final logs = []; + final dio = buildDio( + logs, + _RawHttpAdapter.fromText( + jsonEncode({ + 'code': 0, + 'message': 'ok', + 'data': {'text': List.filled(3500, '长').join()}, + }), + ), + ); + + await dio.get('/app/long'); + + final responseLog = logs.singleWhere( + (line) => line.startsWith('[HTTP] ← 200'), + ); + const prefix = '[HTTP] ← body:'; + final body = responseLog.substring( + responseLog.indexOf(prefix) + prefix.length, + ); + expect(body.length, NetworkLogInterceptor.maxBodyLength); + expect(body, endsWith('…(truncated)')); + }); + + test( + 'should_log_plain_text_without_throwing_when_response_is_not_json', + () async { + final logs = []; + final dio = buildDio( + logs, + _RawHttpAdapter.fromText('plain text response'), + ); + + await expectLater( + dio.get('/app/plain'), + throwsA(isA()), + ); + + expect(logs.join('\n'), contains('[HTTP] ✗')); + expect(logs.join('\n'), contains('plain text response')); + }, + ); + + test('should_log_byte_count_only_when_response_is_image', () async { + final logs = []; + final dio = buildDio( + logs, + _RawHttpAdapter( + Uint8List.fromList([0xff, 0xd8, 0xff, 0x00]), + contentType: 'image/jpeg', + ), + useEnvelope: false, + ); + + await dio.get('/files/image.jpg'); + + expect(logs.join('\n'), contains('')); + expect(logs.join('\n'), isNot(contains('�'))); + }); + + test( + 'should_log_dio_exception_type_and_message_when_request_fails', + () async { + final logs = []; + final dio = buildDio(logs, _ThrowingHttpAdapter()); + + await expectLater( + dio.get('/app/offline'), + throwsA(isA()), + ); + + expect( + logs.join('\n'), + matches(RegExp(r'\[HTTP\] ✗ connectionError \d+ms .* offline')), + ); + }, + ); +} + +class _RawHttpAdapter implements HttpClientAdapter { + _RawHttpAdapter(this.bytes, {required this.contentType}); + + factory _RawHttpAdapter.fromText(String body) { + return _RawHttpAdapter( + Uint8List.fromList(utf8.encode(body)), + contentType: 'application/json; charset=utf-8', + ); + } + + final Uint8List bytes; + final String contentType; + + @override + Future fetch( + RequestOptions options, + Stream? requestStream, + Future? cancelFuture, + ) async { + return ResponseBody.fromBytes( + bytes, + 200, + headers: { + Headers.contentTypeHeader: [contentType], + }, + ); + } + + @override + void close({bool force = false}) {} +} + +class _ThrowingHttpAdapter implements HttpClientAdapter { + @override + Future fetch( + RequestOptions options, + Stream? requestStream, + Future? cancelFuture, + ) { + throw DioException( + requestOptions: options, + type: DioExceptionType.connectionError, + message: 'offline', + ); + } + + @override + void close({bool force = false}) {} +}